我踩了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 是我看到输出愣了足足十秒的那个。


相关推荐
Setsuna_F_Seiei6 小时前
前端转型 Agent 开发 04 之 MCP 与 Skill(赋予 Agent 更广工作能力)
前端·agent
刘发财8 小时前
前端2秒生成500页矢量PDF,rust真的强到没朋友
前端·javascript·rust
找方案8 小时前
AI+人力资源:AI招聘面试的兴起与争议
人工智能·面试·职场和发展
郑州光合科技余经理8 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
梦想平凡9 小时前
百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收
java·前端·javascript·数据库·源代码管理
yume_sibai10 小时前
06-Rust Web 开发实战(Axum 框架 + 数据库 + JWT 认证 + 中间件 + 部署)
前端·数据库·rust
计算机魔术师11 小时前
特朗普上台打给黄仁勋:AI末日论是骗局,我们绝不让它发生
前端
troy12811 小时前
Python 基础语法(八):Web 后端开发、数据分析与可视化、网络爬虫、人工智能 / 大模型应用
前端·python·数据分析
计算机魔术师12 小时前
CEO说要慢下来,黑客说别做梦了——同一篇论文,两种命运
前端
kyriewen12 小时前
我花3天抓了一个幽灵bug,凶手藏在第4层
前端·javascript·程序员