
商品页要显示「1.005 元」这类价格,保留两位小数,接口原样给了这个数,页面上却出现 1.00。单价栏和总价栏用的还是不同写法,一栏用 toFixed(2),另一栏用 Intl.NumberFormat,两个数字对不上,客诉就这么来的。
七个数,三种写法,落在三个地方
脚本 tools/verify-money-rounding.mjs,Node v26.8.1,Intl.NumberFormat('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2})。
| 输入 | toFixed(2) | Math.round(x*100)/100 | Intl 两位 |
|---|---|---|---|
| 1.005 | 1.00 | 1 | 1.01 |
| 2.675 | 2.67 | 2.68 | 2.68 |
| 8.575 | 8.57 | 8.57 | 8.58 |
| 1.45 | 1.45 | 1.45 | 1.45 |
| 1.015 | 1.01 | 1.01 | 1.02 |
| 1234.565 | 1234.57 | 1234.57 | 1,234.57 |
| 0.1 + 0.2 | 0.30 | 0.3 | 0.30 |
四行里 toFixed 与 Intl 给出不同答案。Math.round(x*100)/100 的落点又是第三种:1.005 给 1,因为中间那次乘法把 100.49999999999999 交给了 Math.round。最后一行还多一个差别,Intl 默认带千分位,toFixed 不带,拼字符串时得留意这一层。
差在舍入之前取了哪个值
把这三个数用二十位精度打出来,答案就摆在眼前:1.005 在 double 里的精确值是 1.0049999999999998934,2.675 是 2.6749999999999998224。
toFixed 的输入是二进制精确值,这个值比 1.005 小,往两位上舍,落到 1.00。Intl 先把它转成最短的十进制写法,也就是「1.005」,再按十进制规则处理平局,于是给 1.01。2.675 的精确值比它短写更小,Intl 仍然按十进制平局往上走,给 2.68。
Math.round(x*100)/100 则是另一条路:乘法本身发生在二进制里,1.005 乘 100 得到 100.49999999999999,取整自然偏低。三种写法没有一种是「错的」,它们只是各自忠实于不同的那一层。
roundingMode 能选,但选不了「按十进制」
Node 的 Intl 支持 roundingMode 参数。同一批数字,六档规则给出的结果如下。
| roundingMode | 2.5 | 3.5 | -2.5 | 1.005 |
|---|---|---|---|---|
| halfExpand | 3 | 4 | -3 | 1.01 |
| halfEven | 2 | 4 | -2 | 1 |
| halfTrunc | 2 | 3 | -2 | 1 |
| ceil | 3 | 4 | -2 | 1.01 |
| floor | 2 | 3 | -3 | 1 |
| trunc | 2 | 3 | -2 | 1 |
halfEven 是银行家舍入,2.5 落到 2、3.5 落到 4。要注意的是这里的平局判断也发生在二进制值上,所以换规则能改行为,换不出「照十进制字面来舍」这件事。1.005 在 halfEven 下给 1,在 halfExpand 下给 1.01,两种都站得住,取决于业务想要哪一种。
二十万个随机数里只有一个不一致
有人会说,换 Intl 总比 toFixed 稳。把两个 API 放在同一批数据上比一比。
=== 6. 同样的输入,Intl 与 toFixed 完全一致(说明换 API 不解决问题)===
20 万个随机数:一致 199999 个,不一致 1 个
二十万个随机浮点数里只有一个不一致,看不出来是因为随机数撞上平局的概率低。价格数据不一样,1.005、2.675、8.575 这些都是人工定的零售价,恰好是平局高发区,越靠近定价规则越容易踩到。前端这一层调 API、加参数,都改不掉根因:钱不该用 double 存。
整数分是唯一稳的那条路
先转成分再做运算,最后自己拼字符串。
=== 5. 整数分方案:先转成分,再自己拼字符串 ===
10.05 → 1005 分 → 10.05
1.005 → 100 分 → 1.00
2.675 → 267 分 → 2.67
0.30000000000000004 → 30 分 → 0.30
99999999999.99 → 9999999999999 分 → 99999999999.99
注意第一栏:1.005 走 toFixed 就已经变成 100 分,错误在进入 BigInt 之前就发生了
这段输出里最该看的是第一栏。整数分方案本身没问题,但只要入口那一步是拿 toFixed(2) 把浮点转成分,错误在转换时就发生了,后面用 BigInt 再稳也白搭。入口如果是接口给的字符串或者分单位的整数,直接 BigInt 接住,这条路才成立;浮点只在最后展示时才碰。我在本机试过的顺序就是这样,先定单位,再谈 API。
至于展示层的取舍:如果价格只用于显示、参与计算的值来自整数分,那 toFixed(2) 和 Intl 用哪个都行,Intl 多了千分位和本地化,代价是要记得它会加逗号。反过来,只要页面上算的是钱本身,这两个都别当依据。