JavaScript 課程 – 數學與數字處理(Math & Numbers)
主題:浮點數誤差
簡介
在日常的前端開發或 Node.js 後端程式中,數字運算是不可避免的。看似簡單的加、減、乘、除,卻常常因為電腦使用 IEEE‑754 雙精度浮點數 來表示小數,而產生意料之外的誤差。這些誤差在金額計算、統計分析、圖形繪製等實務情境裡,若未加以處理,可能會導致 錯誤的結果、使用者信任下降,甚至法律風險。
本篇文章將從 概念、常見陷阱、實用技巧 三個層面說明 JavaScript 的浮點數誤差,提供 可直接套用的程式碼範例,幫助讀者在開發時預防與修正這類問題,提升程式的可靠性與可維護性。
核心概念
1. 為什麼會有浮點數誤差?
JavaScript 只提供一種數值類型:Number,其底層遵循 IEEE‑754 雙精度(64 位元)規格。這個規格把實數映射成二進位的有限位元,不是所有十進位小數都能精確表示,例如 0.1 在二進位中是無限循環的:
0.1 → 0.0001100110011… (無限循環)
當 JavaScript 把這個無限循環截斷成 64 位元後,就會產生近似值,進一步的算術運算會把誤差累積,導致最終結果偏離我們預期的值。
2. 瀏覽器與 Node.js 的實作差異
大多數瀏覽器與 Node.js 都使用相同的 V8 引擎,浮點運算的行為相當一致。但在跨平台或跨語言(例如與後端 Java、Python)交換數據時,若不注意誤差的範圍,可能會出現「前端算 0.30000000000000004,後端卻是 0.3」的情況。
3. 如何觀察誤差?
最直接的方式是使用 === 或 == 比較有問題的結果:
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.1 + 0.2); // 0.30000000000000004
Tip:若要檢查「兩個浮點數是否足夠接近」,應使用 容差 (epsilon) 檢查,而非直接相等。
程式碼範例
以下範例展示常見的浮點數問題以及對策,請自行在瀏覽器或 Node.js 環境中執行觀察。
範例 1 – 基本加法誤差
// 0.1 + 0.2 之所以不等於 0.3,是因為二進位表示的誤差
const a = 0.1;
const b = 0.2;
const sum = a + b;
console.log('sum =', sum); // 0.30000000000000004
console.log('sum === 0.3 ?', sum === 0.3); // false
說明:sum 其實是 0.30000000000000004,與 0.3 差了 4e-16。
範例 2 – 使用容差比較
/**
* 判斷兩個浮點數是否相等(允許誤差範圍)
* @param {number} x
* @param {number} y
* @param {number} [epsilon=Number.EPSILON] // 預設為機器最小可辨識差距
* @returns {boolean}
*/
function floatEquals(x, y, epsilon = Number.EPSILON) {
return Math.abs(x - y) < epsilon;
}
console.log(floatEquals(0.1 + 0.2, 0.3)); // true
說明:Number.EPSILON 約為 2.220446049250313e-16,足以容納大多數常見誤差。
範例 3 – 乘法與除法的誤差
const result = 0.3 * 3; // 0.8999999999999999
const divided = 0.3 / 0.1; // 2.9999999999999996
console.log('0.3 * 3 =', result);
console.log('0.3 / 0.1 =', divided);
解法:可以先把小數轉成整數(乘以 10 的次方),再做運算,最後再除回去。
function multiplyPrecise(x, y, factor = 1e12) {
return Math.round(x * factor) * Math.round(y * factor) / (factor * factor);
}
function dividePrecise(x, y, factor = 1e12) {
return Math.round(x * factor) / Math.round(y * factor);
}
console.log(multiplyPrecise(0.3, 3)); // 0.9
console.log(dividePrecise(0.3, 0.1)); // 3
範例 4 – 金額計算的安全做法(使用 BigInt 或 Decimal.js)
// 方法一:以「分」為單位,使用整數計算
const price = 1999; // 19.99 元 → 1999 分
const quantity = 3;
const totalCents = price * quantity; // 5997 分
console.log('總金額 (元) =', (totalCents / 100).toFixed(2)); // 59.97
// 方法二:使用第三方 Decimal 套件(npm i decimal.js)
const Decimal = require('decimal.js');
const dPrice = new Decimal('19.99');
const dQty = new Decimal(3);
const dTotal = dPrice.mul(dQty);
console.log('Decimal 總金額 =', dTotal.toFixed(2)); // 59.97
說明:把金額轉成最小貨幣單位(分)或使用高精度十進位類庫,可徹底避免浮點誤差。
範例 5 – 使用 toFixed 與 Number.EPSILON 進行四捨五入
function roundTo(num, digits) {
const factor = 10 ** digits;
// 先加上一個微小的 epsilon,避免 1.005 變成 1.004999…
return Math.round((num + Number.EPSILON) * factor) / factor;
}
console.log(roundTo(1.005, 2)); // 1.01
console.log(roundTo(0.1 + 0.2, 2)); // 0.3
說明:Number.EPSILON 在四捨五入時扮演「微調」的角色,讓結果更符合直覺。
常見陷阱與最佳實踐
| 陷阱 | 可能後果 | 建議的解決方式 |
|---|---|---|
直接使用 === 比較浮點數 |
判斷失敗、邏輯錯誤 | 使用 容差比較(floatEquals) |
金額或計數以 Number 直接相加 |
產生 0.30000000000000004 等錯誤 | 轉為整數(分、毫秒)或使用 Decimal.js |
toFixed 前未加 Number.EPSILON |
四捨五入結果不正確 | 加上 epsilon 再四捨五入 |
大數相乘或除後直接使用 Math.round |
失去精度 | 先放大為整數,運算後再縮小 |
依賴 parseFloat 直接解析字串 |
解析過程中可能產生誤差 | 若需要精確計算,使用 big.js / decimal.js 之類的類庫 |
其他實用技巧
- 封裝常用函式:將容差比較、精確四捨五入等功能抽成工具函式,統一管理。
- 單元測試:針對金額、時間長度等關鍵計算寫測試,確保在不同環境、不同輸入下不會出錯。
- 避免不必要的浮點運算:例如在迴圈中累加
0.1,可以改為累加1再除以10。
實際應用場景
電商結帳
- 需精確計算每筆商品價格、稅金、折扣。若直接使用
Number,可能出現99.99999999999999,導致結帳金額與後端不符。解法:以「分」為單位或使用 Decimal。
- 需精確計算每筆商品價格、稅金、折扣。若直接使用
圖形與動畫
- Canvas、WebGL 位置或縮放值常以小數表示。累積偏差會造成畫面抖動或物件漂移。常見做法是每幀重新計算基準值,或使用 矩陣運算庫 內建的高精度處理。
統計與機器學習前處理
- 大量資料的均值、標準差計算若直接使用
Number,誤差會在迭代過程中放大。可採用 Kahan 求和演算法 來降低累積誤差。
- 大量資料的均值、標準差計算若直接使用
時間戳與計時器
Date.now()回傳毫秒整數,若再做除以1000產生秒的浮點數,建議保留毫秒或使用 BigInt 處理。
金融模型
- 利率、複利計算需要高精度。使用 big.js、decimal.js 或 BigInt(配合自訂小數位)是業界慣例。
總結
- JavaScript 的
Number受限於 IEEE‑754 雙精度規格,無法精確表示所有十進位小數,因此在加、減、乘、除等運算時會產生微小誤差。 - 容差比較、放大為整數、使用高精度類庫(Decimal.js、big.js) 等技巧能有效避免或修正這些問題。
- 在 金額、時間、統計、圖形 等對精度敏感的領域,務必採取上述最佳實踐,並加入單元測試以防止回歸。
掌握了浮點數誤差的本質與解決方案後,你的 JavaScript 程式碼將變得更加 可靠、可預測,也能在實務專案中降低因數值錯誤帶來的風險。祝你開發愉快!