JavaScript數字精度前端BigInt浮點數金額計算

前端數字地雷:JavaScript 浮點數精度與大數處理的正確解法

整理前端開發中的數字精度問題:金額計算的整數分策略、BigInt 用法、decimal.js 比較、Intl.NumberFormat 格式化,以及後端 API 傳大數的最佳實踐。

· 4 分鐘閱讀

0.1 + 0.2 !== 0.3、Number.MAX_SAFE_INTEGER 以上的數字不再精確、JSON 解析大 ID 被截斷——這三個問題是前端開發中最常見的數字地雷。這篇用實際踩坑經歷,整理所有你需要知道的預防措施和解決方案。


問題一:0.1 + 0.2 !== 0.3

這不是 bug——這是 IEEE 754 雙精度浮點數的設計特性。

console.log(0.1 + 0.2);  // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);  // false

// 解釋:0.1 和 0.2 在二進位中是無窮循環小數
// 儲存時被截斷,所以相加後出現了誤差

這個誤差的量級通常很小(~2.22e-16),但在某些場景下足以造成問題。

比較兩個浮點數是否「足夠接近」:

function roughlyEqual(a, b, epsilon = 1e-10) {
  return Math.abs(a - b) < epsilon;
}

console.log(roughlyEqual(0.1 + 0.2, 0.3));  // true
console.log(Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON);  // 這個不夠好
console.log(Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON * Math.max(Math.abs(0.1 + 0.2), Math.abs(0.3)));  // 相對誤差

問題二:MAX_SAFE_INTEGER 以上的數字不再精確

JavaScript 的 Number 類型是 53 位精度的浮點數,超過 Number.MAX_SAFE_INTEGER(9007199254740991)的數字就會出現「相鄰整數無法區分」的問題:

console.log(Number.MAX_SAFE_INTEGER);  // 9007199254740991
console.log(Number.MAX_SAFE_INTEGER + 1);  // 9007199254740992(精確)
console.log(Number.MAX_SAFE_INTEGER + 2);  // 9007199254740992(錯誤!等於 +1)
console.log(Number.MAX_SAFE_INTEGER + 3);  // 9007199254740994(錯誤!)

實務上踩坑的地方:

  • Twitter Snowflake ID(範圍可能超過 MAX_SAFE_INTEGER)
  • Blockchain 交易 hash(有時需要大數)
  • 某些資料庫的 BIGINT 欄位(超過 JS Number 精度的 ID)

問題三:JSON 解析大數字被截斷

這是最容易被忽視的一個問題。當後端返回的 JSON 包含大數字時,JS 解析時就已經被截斷了:

// 後端返回
{ "orderId": 9007199254740993, "transactionId": 90071992547409923 }

// 在 JavaScript 解析時(JSON.parse):
const data = JSON.parse('{"orderId": 9007199254740993}');
console.log(data.orderId);  // 9007199254740992(已經截斷!)

// 解決方案:後端應該用字串傳遞大數
// { "orderId": "9007199254740993" }  // 字串
// { "orderId": { "$numberLong": "9007199254740993" } }  // MongoDB 格式

正確解法:不同場景的實際方案

1. 金錢計算:整數分(cents)策略

最簡單、最安全的方案:永遠以「分」儲存,以「元」顯示。

// 錯誤
const prices = [0.1, 0.2, 0.3];
const total = prices.reduce((a, b) => a + b, 0);
console.log(total === 0.6);  // false

// 正確:全部乘以 100,變成整數
const priceCents = [10, 20, 30];  // 分
const totalCents = priceCents.reduce((a, b) => a + b, 0);
// 顯示時除回去
const display = (totalCents / 100).toFixed(2);  // "0.60"

2. 需要小數的金融計算:decimal.js

import Decimal from 'decimal.js';

// 過去:浮點數精度問題
console.log(new Decimal(0.1).plus(0.2).toString());  // "0.3"

// 金融計算
const amount = new Decimal('0.1')
  .plus('0.2')
  .times('3')
  .toFixed(2);  // "0.90"

// 處理大數
const bigNum = new Decimal('9007199254740993');
console.log(bigNum.plus(2).toString());  // "9007199254740995"(精確)

3. 純大整數:BigInt

// BigInt 可以處理任意大小的精確整數
const big = BigInt('9007199254740993000');
console.log(big + BigInt(2));  // 9007199254740993002n

// BigInt 的限制:不支援小數
const bad = BigInt(0.1);  // TypeError

4. 顯示格式化:Intl.NumberFormat(不需要 library)

// 格式化幣種
const formatter = new Intl.NumberFormat('zh-TW', {
  style: 'currency',
  currency: 'TWD',
});
console.log(formatter.format(1234567.89));  // "NT$1,234,567.89"

// 格式化大數(帶分隔符)
console.log(new Intl.NumberFormat('zh-TW').format(9007199254740993));
// "9,007,199,254,740,993"

Library 選擇指南

Library大小特點適合場景
不用 library0 KB整數分策略 + Intl.NumberFormat大多數場景
decimal.js~25 KB完整小數支援、高精度配置金融計算
bignumber.js~25 KB更多配置選項需要不同精度配置
currency.js~2 KB專門為幣種設計簡單的幣種計算

後端 API 傳大數的最佳實踐

// 後端:永遠用字串傳大數
// Express
app.get('/orders/:id', (req, res) => {
  res.json({
    id: String(order.id),  // 用字串,不用數字
    amount: order.amount_cents  // 用整數分,不用 float
  });
});

// 前端
const data = await fetch('/orders/123').then(r => r.json());
console.log(data.id);  // "9007199254740993"(字串,精確)

實戰 Checklist

## 數字處理安全清單

### 金額相關
- [ ] 後端所有金額相關欄位用整數分(cents)儲存
- [ ] 前端顯示前用 Intl.NumberFormat 格式化
- [ ] API 請求中的金額也用整數分傳遞
- [ ] 禁止 float 用於任何金額儲存或計算

### 大數 ID 相關
- [ ] 後端返回大數 ID 時使用字串
- [ ] 前端收到字串後不轉 Number(除非必要)
- [ ] JSON.parse 時檢查是否有大數(> MAX_SAFE_INTEGER)

### 計算精度
- [ ] 需要精確小數的場景使用 decimal.js
- [ ] 需要比較浮點數時使用相對誤差比較
- [ ] 避免使用 toFixed(它本身也有精度問題)
  - ❌ (0.1 + 0.2).toFixed(2) === '0.30'
  - ✅ 用 Math.round(0.3 * 100) / 100

總結:記住這三件事

  1. 永遠不要用 float 存金額——用整數分(cents)是最簡單最安全的做法
  2. 後端返回大數 ID 永遠用字串——JSON.parse 的那一刻就截斷了,來不及救
  3. 需要精確小數計算時用 decimal.js——Number 類型不擅長這個
// 記住這個模式
const totalCents = items.reduce((sum, item) => {
  const cents = Math.round(parseFloat(item.price) * 100);
  return sum + cents;
}, 0);

// 顯示
new Intl.NumberFormat('zh-TW', { style: 'currency', currency: 'TWD' })
  .format(totalCents / 100);

延伸閱讀


本文基於 FRO-84 研究資料整理,涵蓋 IEEE 754 浮點數限制、金額計算最佳實踐、BigInt 使用場景、以及 API 設計建議。