我踩了3次同一个坑才明白:JavaScript里比0.1+0.2更隐蔽的5个数字陷阱

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 复制出来是对的,程序里跑的就是错的。

修复只有三条路:

  1. 后端把 ID 字段序列化成字符串------最干净,前端全程 string,这也是绝大多数大厂接口现在的默认做法;
  2. 前端用 BigInt 接收,但注意 BigInt 不能直接 JSON.stringify(会抛 TypeError),序列化时要转回字符串;
  3. 改不了后端,就在请求层用支持大数的 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.truncMath.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 是我看到输出愣了足足十秒的那个。


相关推荐
徐小夕1 小时前
表格、文档、甘特、大屏、表单一站打通:pxcharts超级表格4.0正式上线!
前端·算法·github
用户594404103561 小时前
Vue3 + TypeScript + Leaflet.js 实战:构建企业级地理信息应用
前端
kyson_1 小时前
一次搭好 ESLint + Prettier + Husky + lint-staged 前端代码工作流
前端
zhifou1234562 小时前
Vue基础(二)
前端·javascript·vue.js
两只羊ovo2 小时前
手写一个最小版 Claude Code:从任务拆解到 Agent Loop 转起来
前端
给个offer养家糊口2 小时前
抽离 elpis npm 包
前端
默_笙2 小时前
🙃 我让爬虫终于看到了我的网站,后端同事说"这也行?"(下):App Router 全栈实战
前端·javascript
九九落2 小时前
JavaScript 实现北京时间精确到毫秒显示:UTC+8、Asia/Shanghai 与在线时间校准详解
开发语言·javascript·ecmascript
葡萄城技术团队2 小时前
一个单元格放置两个日期选择器:用 SpreadJS CellButtons 录入日期范围
前端