上週在同一天,兩個不同的專案分別回報了同一個問題:「報表底部的加總數字怎麼怪怪的?」
一個是出庫建議表,另一個是工單報工畫面。症狀一模一樣——數字加總後尾巴多了一串莫名其妙的小數。
出事現場
先說第一個案例。出庫建議表有好幾種報表類型,每種報表底部都有 footer 做欄位加總。程式碼大概長這樣:
1 | const total = data.reduce((sum, row) => sum + Number(row.weight), 0); |
看起來很正常對吧?Number() 轉數字,reduce 加總,教科書等級的寫法。
但使用者跑出來的結果是這樣的:
1 | 預期:1234.56 |
然後第二個專案,工單報工畫面也是差不多的情境。顯示的加工數量莫名其妙多了一堆小數位。
0.1 + 0.2 !== 0.3
如果你寫 JavaScript 超過一年,應該聽過這個經典問題:
1 | console.log(0.1 + 0.2); |
這不是 JavaScript 的 bug,這是 IEEE 754 雙精度浮點數的標準行為。電腦用二進位存十進位小數,有些數字(像 0.1)在二進位下是無限循環,就像 1/3 在十進位下是 0.333… 一樣。
單筆資料的時候你可能感覺不到,因為誤差在小數點後十幾位。但問題是——加總會累積誤差。
一筆偏差 0.0000000000001,一千筆就可能偏差到小數點後第二位。報表數字就炸了。
「那用 toFixed 不就好了?」
這是很多人的第一反應。加個 .toFixed(2) 收工。
1 | const total = data.reduce((sum, row) => sum + Number(row.weight), 0); |
先不說 toFixed 回傳的是 string 不是 number(後續拿去比較或運算會出事),更根本的問題是:你只是把誤差藏起來了,不是消除它。
假設中間某一步的加總已經累積到 1234.5649999999998,toFixed(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 | import Decimal from 'decimal.js'; |
就這樣。邏輯幾乎一樣,只是把原生的 + 換成 Decimal 的 .plus()。
實際改了什麼
第一個專案(出庫建議表),原本有 4 個不同的加總函式,涵蓋 5 種報表的 footer。每個都是原生 Number() + reduce 的寫法。全部改成 Decimal.js 版本:
1 | // Before |
第二個專案(報工畫面),加工數量的顯示計算也改了。而且這邊多了一個小細節——near-zero clamping:
1 | function clampNearZero(value: number, threshold = 1e-10): number { |
為什麼?因為有時候 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 都會感謝你的 😂