MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

JavaScript 浮點數加總的陷阱:為什麼你的報表數字對不上

上週在同一天,兩個不同的專案分別回報了同一個問題:「報表底部的加總數字怎麼怪怪的?」

一個是出庫建議表,另一個是工單報工畫面。症狀一模一樣——數字加總後尾巴多了一串莫名其妙的小數。

出事現場

先說第一個案例。出庫建議表有好幾種報表類型,每種報表底部都有 footer 做欄位加總。程式碼大概長這樣:

1
const total = data.reduce((sum, row) => sum + Number(row.weight), 0);

看起來很正常對吧?Number() 轉數字,reduce 加總,教科書等級的寫法。

但使用者跑出來的結果是這樣的:

1
2
預期:1234.56
實際:1234.5600000000002

然後第二個專案,工單報工畫面也是差不多的情境。顯示的加工數量莫名其妙多了一堆小數位。

0.1 + 0.2 !== 0.3

如果你寫 JavaScript 超過一年,應該聽過這個經典問題:

1
2
console.log(0.1 + 0.2);
// 0.30000000000000004

這不是 JavaScript 的 bug,這是 IEEE 754 雙精度浮點數的標準行為。電腦用二進位存十進位小數,有些數字(像 0.1)在二進位下是無限循環,就像 1/3 在十進位下是 0.333… 一樣。

單筆資料的時候你可能感覺不到,因為誤差在小數點後十幾位。但問題是——加總會累積誤差

一筆偏差 0.0000000000001,一千筆就可能偏差到小數點後第二位。報表數字就炸了。

「那用 toFixed 不就好了?」

這是很多人的第一反應。加個 .toFixed(2) 收工。

1
2
const total = data.reduce((sum, row) => sum + Number(row.weight), 0);
return total.toFixed(2);

先不說 toFixed 回傳的是 string 不是 number(後續拿去比較或運算會出事),更根本的問題是:你只是把誤差藏起來了,不是消除它。

假設中間某一步的加總已經累積到 1234.5649999999998toFixed(2) 會給你 1234.56——剛好對。但如果累積到 1234.5650000000001,它會給你 1234.57——差了一分錢。

在財務報表裡,差一分錢就是差一分錢。你沒辦法跟客戶說「這是浮點數精度問題,數學上來說其實是對的」。他們只看到數字對不上。

Decimal.js 才是正解

好,正確的做法是什麼?用專門處理十進位運算的函式庫。我們選的是 Decimal.js

Decimal.js 的核心原理很簡單:它不用 IEEE 754 浮點數來存數字,而是用字串模擬十進位運算。所以 0.1 + 0.2 就是 0.3,沒有任何精度損失。

安裝:

1
npm install decimal.js

改寫加總邏輯:

1
2
3
4
5
6
7
8
import Decimal from 'decimal.js';

const total = data.reduce(
(sum, row) => sum.plus(new Decimal(row.weight || 0)),
new Decimal(0)
);

return total.toNumber();

就這樣。邏輯幾乎一樣,只是把原生的 + 換成 Decimal 的 .plus()

實際改了什麼

第一個專案(出庫建議表),原本有 4 個不同的加總函式,涵蓋 5 種報表的 footer。每個都是原生 Number() + reduce 的寫法。全部改成 Decimal.js 版本:

1
2
3
4
5
6
7
8
9
10
11
// Before
const sumWeight = items.reduce((acc, item) => acc + Number(item.weight), 0);
const sumAmount = items.reduce((acc, item) => acc + Number(item.amount), 0);

// After
const sumWeight = items
.reduce((acc, item) => acc.plus(new Decimal(item.weight || 0)), new Decimal(0))
.toNumber();
const sumAmount = items
.reduce((acc, item) => acc.plus(new Decimal(item.amount || 0)), new Decimal(0))
.toNumber();

第二個專案(報工畫面),加工數量的顯示計算也改了。而且這邊多了一個小細節——near-zero clamping:

1
2
3
function clampNearZero(value: number, threshold = 1e-10): number {
return Math.abs(value) < threshold ? 0 : value;
}

為什麼?因為有時候 Decimal 運算完轉回 number 時,理論上應該是 0 的值會變成 1e-15 之類的極小數。加個 clamp 確保 0 就是 0。

有趣的是:專案裡早就裝了 Decimal.js

最讓我哭笑不得的是,兩個專案的 package.json 裡都早就有 decimal.js 了。其他模組也有在用。就這幾個報表頁面沒有。

這就是典型的「框架裡有工具但開發者不知道」的問題。每個人寫自己那塊的時候,不見得會去翻其他模組怎麼做。Number() + reduce 看起來也沒什麼問題,直到上了生產環境跑真實資料才爆。

什麼時候需要 Decimal.js?

不是所有數字運算都需要上 Decimal.js。一般的 UI 邏輯、像素計算、動畫之類的,原生 number 夠用。但以下場景,我強烈建議用:

  • 金額計算:只要跟錢有關,沒有例外
  • 數量加總:報表 footer、小計、總計
  • 百分比計算:折扣率、稅率、佔比
  • 需要跟後端數字精確比對:後端通常用 decimal 型別,前端用 number 一定會有落差

一個簡單的判斷標準:如果這個數字最後會印出來給使用者看,而且使用者會在意小數點後的精度,就用 Decimal.js。

效能考量

有人會擔心:「用 Decimal.js 會不會很慢?」

會比原生 number 慢,這是事實。但慢多少?在一般的報表場景(幾百到幾千筆資料加總),你完全感覺不到差異。我們跑過 benchmark,1 萬筆 reduce 加總大約多 2-3 毫秒。

真正會有感的是在迴圈裡做大量矩陣運算之類的場景,但那本來就不是 Decimal.js 的使用情境。

其他替代方案

除了 Decimal.js,還有幾個常見的選擇:

  • big.js:同一個作者的輕量版,API 更少但檔案更小。如果你只需要基本四則運算,用這個就夠
  • bignumber.js:也是同作者,支援任意精度。比 Decimal.js 輕但功能少一點
  • 整數運算法:把所有金額乘以 100 變成整數(分),運算完再除回來。簡單粗暴但有效,不需要額外套件

我個人偏好 Decimal.js,因為 API 直覺、文件清楚、社群活躍。而且如果專案裡已經裝了,那就統一用同一個,不要東一個 big.js 西一個 bignumber.js。

結語

IEEE 754 浮點數精度問題不是新鮮事,但它就是那種「知道歸知道,實際寫 code 的時候還是會忘記」的坑。特別是在前端,大家太習慣用原生 number 了。

我的建議:如果你的專案有任何跟金額、數量相關的計算,從第一天就引入 Decimal.js,在 coding guideline 裡明確規定「數值加總一律用 Decimal」。不要等到使用者回報「數字怪怪的」才來改——那時候你要翻遍所有報表頁面,一個一個找,一個一個改。

就像我們這次一樣。兩個專案,9 個加總函式,全部翻出來改。如果一開始就用 Decimal.js,這些工時根本不需要花。

所以,下次寫 reduce 加總之前,先問自己一句:「這個數字,使用者會在意小數點嗎?」

如果答案是 yes,放下你的 Number(),拿起 new Decimal()。你的使用者和你的 QA 都會感謝你的 😂