
0.1 + 0.2 !== 0.3,这个梗大家都会背。但说句实话,它是 JS 数字陷阱里最无害的一个------至少你知道它存在。
真正吓人的是那些不报错、不抛异常、控制台没有任何红色提示,却悄悄把你算出来的钱、查出来的数据、跳过去的页码全改掉的坑。我实际踩过、并且付出过代价的是这 5 个,本文所有代码复制到 Chrome 控制台就能复现。
📌 toFixed 不是四舍五入,是看运气
第一次踩是在价格展示上。商品含税后单价 1.005 元,展示保留两位小数:
javascript
(1.005).toFixed(2); // "1.00" ------ 不是 1.01?
(0.615).toFixed(2); // "0.61" ------ 又向下?
(1.35).toFixed(1); // "1.4" ------ 这个怎么又向上了
同一个方法,一会儿向下舍一会儿向上入,看起来像随机 bug。其实不是。
原因:toFixed 舍入的对象不是"你以为的十进制数",而是这个数字在内存里真实存储的二进制浮点值。1.005 存进去其实是 1.00499999...,当然舍成 1.00;1.35 的真实存储值比 1.35 大一点点,所以入成 1.4。toFixed 的结果取决于二进制表示的尾巴落在中点哪边,跟你写的十进制字面量无关。
网上常见的"修复"是先乘 100 再 Math.round,一样翻车:
javascript
Math.round(1.005 * 100) / 100; // 1,不是 1.01
// 因为 1.005 * 100 = 100.49999999999999
另外 toFixed 返回的是字符串,直接参与运算还会再来一次隐式转换。
展示层的正确做法是 Intl.NumberFormat,它处理的是展示语义,结果稳定:
javascript
const fmt = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
});
fmt.format(1.005); // "¥1.01"
fmt.format(0.615); // "¥0.62"
但记住:格式化只解决"展示"。只要涉及钱的计算,源头就不能是浮点数,见陷阱五。
🛠️ 19 位 ID 在 JSON.parse 里静默丢尾
后端返回雪花算法生成的订单 ID,19 位:
javascript
Number.MAX_SAFE_INTEGER; // 9007199254740991,16 位
JSON.parse('{"id": 9007199254740993}').id;
// 9007199254740992 ------ 最后一位被改写了,没有任何警告
JS 的 number 是 IEEE754 双精度浮点,安全整数上限是 2^53 - 1。超过这个范围的整数,相邻两个可表示的数之间差 2 甚至更多,解析的瞬间尾数就被吞掉。
线上症状非常迷惑:详情页拿 ID 去查接口返回"订单不存在";把 ID 复制出来是对的,程序里跑的就是错的。
修复只有三条路:
- 后端把 ID 字段序列化成字符串------最干净,前端全程 string,这也是绝大多数大厂接口现在的默认做法;
- 前端用 BigInt 接收,但注意 BigInt 不能直接 JSON.stringify(会抛 TypeError),序列化时要转回字符串;
- 改不了后端,就在请求层用支持大数的 JSON 解析器(如 json-bigint),把超范围数字读成字符串。
🔍 NaN 的 type 是 number,而且它不等于自己
javascript
typeof NaN; // "number"
NaN === NaN; // false
Number(''); // 0
Number(undefined); // NaN
这四个事实凑在一起,就是一套完整的线上事故链。我踩过的真实场景:表单里某个数量输入框用户没填,Number('') 返回 0------"没填"被静默翻译成"数量是 0",然后一路参与乘法,整个金额算出来是 0,页面上显示 ¥0.00,没有任何报错。
更麻烦的是找 NaN:indexOf 找不到它(因为 NaN !== NaN),只有 includes 能找到。NaN 一旦混进数组或累加结果,会像病毒一样污染后续所有计算。
统一用一个校验函数兜底,注意空字符串必须单独拦------Number.isFinite(0) 是 true,光靠它拦不住:
javascript
function toSafeNumber(v, fallback = 0) {
if (typeof v === 'string' && v.trim() === '') return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
toSafeNumber(''); // 0(fallback)
toSafeNumber(undefined); // 0
toSafeNumber('12px'); // 0,而不是 12
toSafeNumber('3.5'); // 3.5
✅ parseInt(0.0000005) === 5
这是我认为最反直觉的一个:
javascript
parseInt(0.0000005); // 5
Math.round(-0.5); // -0
-7 % 3; // -1
三个看似无关的现象,背后都是"JS 的数字规则跟直觉不一致":
parseInt 的参数会先被转成字符串 。0.0000005 转字符串是 "5e-7",parseInt 从头解析到 e 停下,于是得到 5。所以"用 parseInt 取整"本身就是错的,取整请用 Math.trunc 或 Math.floor。
Math.round 的舍入方向朝 +∞ ,所以 -0.5 舍入到 -0 而不是 -1。-0 在绝大多数场景表现正常,但 Object.is(x, -0) 能区分它,极端情况(比如用它做除数)会炸出 -Infinity。
取模保留被除数的符号 ,-7 % 3 = -1。这个在写轮播图、分页、循环列表索引时最容易中招:索引往回越界一步就变成负数,arr[-1] 拿到 undefined。循环索引的标准写法是:
javascript
const idx = ((i % n) + n) % n;
📊 误差会在循环里累积,分账永远差一分
单个 0.1 + 0.2 不准大家都知道,但更隐蔽的是累积效应:
javascript
[0.1, 0.2, 0.3].reduce((a, b) => a + b) === 0.6; // false
(100 / 3).toFixed(2) * 3; // 99.99
每笔浮点运算都带一点误差,循环累加时误差跟着累加。账单对不上、分账差一分、进度条永远到不了 100%,很多都是这个根源。100 元拆三份,33.33 × 3 = 99.99,剩下那一分钱在数学上无解------必须有一个人多拿。
涉及钱的计算,整条链路都别碰浮点:
javascript
// 金额一律用整数「分」存储和计算
function splitCents(cents, n) {
const base = Math.floor(cents / n);
const rest = cents - base * n;
// 余数一分一分分给前几个人,总额永远不变
return Array.from({ length: n }, (_, i) =>
base + (i < rest ? 1 : 0)
);
}
splitCents(10000, 3); // [3334, 3333, 3333],加起来正好 10000
源头用整数分,展示层再用 Intl.NumberFormat 格式化,这是电商和金融前端的通用做法。
速查表:5 个陷阱一眼过(建议收藏)
| 陷阱 | 现象 | 根因 | 修复 |
|---|---|---|---|
| toFixed 舍入不稳 | (1.005).toFixed(2) = "1.00" | 舍入的是二进制存储值,不是字面量 | 展示用 Intl.NumberFormat,计算不用 toFixed |
| 长 ID 精度丢失 | JSON.parse 后 19 位 ID 尾数变了 | 超过 2^53-1 安全整数上限 | 后端返回字符串 / BigInt / json-bigint |
| NaN 静默传播 | 空输入变成 0 参与计算 | Number('') = 0,NaN 不等于自身 | 统一 toSafeNumber 校验,空串单独拦 |
| parseInt 隐式转换 | parseInt(0.0000005) = 5 | 参数先转字符串 "5e-7" | 取整用 Math.trunc / Math.floor |
| 浮点累积误差 | 分账差一分、合计对不上 | 每笔运算误差累积 | 金额全程整数分,余数法分摊 |
⚙️ 涉及钱要选库的话
我拉了下几个主流数字库的最新状态(截至本文写作时):
| 库 | Star | 最近维护 | 定位 |
|---|---|---|---|
| decimal.js | 7.2K | 今年 7 月 | 任意精度十进制运算,功能最全 |
| dinero.js | 6.8K | 上周 | 专为货币设计,支持汇率/分摊/格式化 |
| big.js | 5.2K | 去年 | 轻量四则运算,够用且小 |
| currency.js | 3.4K | 上周 | 轻量货币计算,API 直观 |
选择建议:只是展示格式化,Intl.NumberFormat 就够了;金额计算不复杂,整数分方案零依赖最稳;要处理汇率、多币种、复杂分摊,直接上 dinero.js 或 decimal.js,别自己造轮子。
🎯 写在最后
这 5 个坑有个共同点:全都不报错。JS 不会告诉你 ID 被截断了、钱少了一分、数量变成了 0,它只会安静地把错的结果交给你,然后等用户在生产环境替你发现。
防御方式也不复杂:凡是 ID,当字符串处理;凡是钱,用整数分;凡是用户输入,先过校验再参与计算。这三条养成肌肉记忆,能挡掉绝大多数数字事故。
你踩过这 5 个里的哪个?或者我漏掉了哪个更隐蔽的?评论区聊聊------我先说,parseInt(0.0000005) === 5 是我看到输出愣了足足十秒的那个。