
1. 先从 0.1 0.2 说起toFixed 的坑和四舍五入方法的坑本质上是同一个坑只要你用 JS 处理过金额、比例、统计报表这类带小数的数据大概率踩过这样的场景页面上算出来的价格是 1.005 元你顺手写了price.toFixed(2)心里想着四舍五入保留两位结果它吐出来一个1.00。你去查文档文档说 toFixed 就是按四舍五入来的你去 Excel 里算Excel 也告诉你应该是 1.01。于是你开始怀疑人生怀疑浏览器怀疑这门语言。我在第一次遇到这个问题的时候也是这个反应后来把 IEEE 754 的存储方式、ECMA-262 里 toFixed 的规范定义、以及 Math.round 家族的行为逐条对了一遍才意识到一件事toFixed 从来没有背叛过你是你给它的那个数字本来就不是 1.005。而这个问题和经典的0.1 0.2 ! 0.3是同一件事只是换了张脸出现在你面前。这篇内容我打算把 JS 里所有跟四舍五入沾边的东西捋一遍toFixed 到底怎么干活的、它的坑具体坑在哪里、Math.round 家族的边界在哪、有哪几种可以真正落地的精确舍入实现、以及在金额和统计这两个最容易翻车的场景里该怎么选。如果你写过 JS 但没仔细研究过浮点精度看完之后至少能少踩一半的坑如果你已经有经验里面关于指数记法和字符串解析法的对比应该也能给你一点新东西。1.1 双精度浮点里根本不存在精确的 1.005先补一个基础认知因为这个认知决定了你后面所有判断的方向。JS 里所有number类型都是 IEEE 754 双精度 64 位浮点数1 位符号位、11 位阶码、52 位尾数加上隐含的最高位有效精度是 53 位二进制。这套格式能精确表示的十进制小数只有那些分母是 2 的幂的数比如 0.5、0.25、0.125、0.0625 这类。而 0.1 呢它的二进制展开是0.000110011001100110011...无限循环。用一个有限长度的尾数去存它必然要截断截断就会产生误差。这个误差小到平时看不出来但一旦你要拿它做比较、做舍入它就会跳出来捣乱。0.1 0.2 // 0.30000000000000004 0.1 0.2 0.3 // false1.005 也是一样的道理。它在内存里实际存的值是(1.005).toPrecision(20) // 1.0049999999999998934眼熟吗它比 1.005 小了一丁点。这一丁点小到1.005 1.005判定为 true因为字面量解析出来是同一个值但它确确实实小于数学意义上的 1.005。这就是后面所有意外的根源。1.2 从 1.005 到 1.00 的完整推演现在我们有了真实值x 1.00499999999999989341858963598497211933135986328125。toFixed(2) 要做的事情是在所有两位小数的数里找一个离 x 最近的。候选只有两个1.00 和 1.01。x 与 1.00 的距离1.00499999999999989... - 1.00 0.00499999999999989...x 与 1.01 的距离1.01 - 1.00499999999999989... 0.00500000000000010...后者比前者大。所以最接近的是 1.00toFixed 返回1.00完全正确一点毛病没有。你可以自己验证这个推理链1.005 - 1.00 // 0.004999999999999893 1.01 - 1.005 // 0.0050000000000001155也就是说问题出在你以为你传进去的是 1.005实际上传进去的是 1.004999...。任何基于这个真实值做舍入的算法都会给出 1.00包括你自己手写的那一套。理解这一层之后你会发现网上那些toFixed 是银行家舍入toFixed 在不同浏览器里不一样的说法大部分是误解。真正的原因只有一个输入值本身在二进制里就已经不是你想的那个数了。1.3 toFixed 遵循的舍入规则并不是你以为的银行家舍入既然聊到了规则就顺手把 ECMA-262 里Number.prototype.toFixed的定义说清楚。规范里的算法大意是这样的令 f 为 ToIntegerOrInfinity(fractionDigits)如果 f 不在 0 到 100 之间含抛 RangeError如果 this 不是有限数返回 Number::toString(this)令 n 为一个整数使得n / 10^f - x的绝对值尽可能接近 0如果有两个这样的 n取较大的那个 n返回 n 的十进制字符串表示第 4 步是关键。如果有两个 n取较大的那个意思是当恰好落在正中平局时向正无穷方向取。这就是常说的 half-up远离零方向的四舍五入在正数区间等价于向正无穷不是 half-even银行家舍入。验证一下(2.5).toFixed(0) // 3 (3.5).toFixed(0) // 4如果是银行家舍入2.5 应该变 2、3.5 应该变 4结果是2和4。实际输出3和4说明是 half-up。那为什么平局这么难碰到因为平局要求 x 正好等于(2k1) / 10^(f1)而分母含因子 5 的十进制数在二进制里几乎都表示不精确除非分母能约成 2 的幂比如 0.5、0.25、0.125、0.625。所以实践中平局只在少数二进制可精确表示的数上出现比如(0.625).toFixed(2)会得到0.63因为 0.625 5/8是精确值真的落在正中然后按规则向上取。这就是完整的规则图景规范是 half-up但因为输入值普遍不能被精确表示实际观测结果经常会跟人类的四舍五入对不上。理解这一点比背下十几个坑案例有用得多。2. 把 toFixed 的边界条件一条条摸清搞懂了原理接下来把 toFixed 的行为边界系统性地过一遍。这些细节单独拎出来都不复杂但组合起来就很容易在代码 review 的时候漏过去然后在某个周五下午给你来一发线上问题。2.1 参数范围与返回值类型带来的两个隐式坑第一个坑是参数范围。规范限定 f 必须在 0 到 100 之间(1.234).toFixed(101) // RangeError: toFixed() digits argument must be between 0 and 100 (1.234).toFixed(-1) // RangeError负数和超过 100 都会直接抛错。这个在正常业务里不太会遇到但如果你写的是一个保留位数由配置下发的通用格式化函数参数校验就省不掉——配置写错了不能变成运行时崩溃。参数如果不是整数会被 ToIntegerOrInfinity 截断(1.23456).toFixed(2.9) // 1.23等价于 toFixed(2)第二个坑更隐蔽toFixed 返回的是字符串不是数字。typeof (1.5).toFixed(2) // string (1.5).toFixed(2) 1 // 1.501 (1.5).toFixed(2) * 1 // 1.5字符串1.50和数字 1 相加JS 会走字符串拼接得到1.501。这个错误在数学运算里几乎是灾难性的而且因为打印出来形似数字肉眼扫代码很难发现。我的建议是toFixed 只在最终要输出给界面或接口的最后一刻用中间的任何计算环节都不要出现它。如果你确实需要一个数字永远补一个Number()或parseFloat()。顺带说一个常用的反例技巧parseFloat((0.1 0.2).toFixed(10))得到 0.3。这个写法在消除累加误差的场景里很常见思路是先用高位精度把尾巴削掉再解析回数字。它在数值不大、累加项不多的时候能用但它同样没有解决根本问题只是把误差藏到了第 10 位之后。2.2 大数区间toFixed 会直接放弃小数位还有一个很少被提到但确实存在的边界当数字的绝对值大于等于 1e21 时Number::toString会改用指数形式输出toFixed 也跟着用指数形式(1e21).toFixed(2) // 1e21 (1.23e21).toFixed(2) // 1.23e21注意看它压根没有保留两位小数。因为 1e21 已经超过了双精度能表示连续整数的范围2^53 ≈ 9.007e15再谈小数位没有任何意义。这个坑在真实项目里出现的位置往往出人意料比如用 number 存订单号、雪花 ID、或者是某个统计口径下的总访问量然后再拿去格式化。这类数字本身就不该用 number 存从源头改成字符串才是正解。2.3 哪些数字正常、哪些必然翻车凭经验总结了一张对照表方便你快速判断某个数会不会出问题。判断标准很简单看这个数在二进制里是偏大还是偏小。表达式实际结果原因说明(0.5).toFixed(0)10.5 精确表示平局取较大值(2.5).toFixed(0)3同上half-up 而非银行家舍入(0.625).toFixed(2)0.630.625 5/8精确表示后平局向上(1.005).toFixed(2)1.00实际值略小于 1.005(2.55).toFixed(1)2.5实际值略小于 2.55(1.45).toFixed(1)1.4实际值略小于 1.45(1.55).toFixed(1)1.6实际值略大于 1.55(0.615).toFixed(2)0.61实际值略小于 0.615(1234.5678).toFixed(2)1234.57远离平局点方向不受影响规律一目了然只有当你要丢弃的那一位恰好是 5且它后面没有有效数字时才需要担心。其他情况误差的偏移量不足以翻转舍入方向toFixed 的结果跟人类直觉一致。所以有人提出过一个看起来能用的判断只有当被舍入位是 5 时才特殊处理。但这个判断本身也有问题——你凭什么知道那一位看起来是 5(1.005).toString()给你的就是1.005这个字符串是最短往返表示它不等于真实值。所以这个思路最终还是会绕回同一个问题你要么接受二进制真实值要么干脆放弃二进制用十进制字符串或整数来算。3. Math 家族做四舍五入时的三个陷阱很多人发现 toFixed 不可靠之后第一反应是转投Math.round写一个先放大再取整再缩小的通用函数。这个方向是对的但 Math.round 自己也有三个坑不注意的话你会从一个小坑跳进另一个更大的坑。3.1 Math.round 的负数方向和 -0Math.round的规则是返回最接近的整数如果正好在中间向正无穷方向舍入。注意是向正无穷不是向远离零。Math.round(2.5) // 3 Math.round(-2.5) // -2 注意不是 -3 Math.round(-0.5) // -0 Object.is(Math.round(-0.5), -0) // true-0是一个容易被忽略的坑。它在打印和比较时表现得跟 0 一样(-0).toString() // 0 -0 0 // true JSON.stringify(-0) // 0但在某些场景下它会露馅1 / -0 // -Infinity Object.is(-0, 0) // false如果你在做最低价格这类比较或者把结果写进某个用Object.is做比较的框架React 的 state 比较在某些路径上就干过这事-0就可能引发意料之外的渲染或逻辑分支。稳妥的做法是在返回值上加一层归一化比如result 0 ? 0 : result或者干脆用result 0因为-0 0 0。另外如果你的业务里负数四舍五入指的是按绝对值四舍五入-2.5 变 -3那 Math.round 直接就不符合预期必须自己拆符号处理。3.2 先乘后除的经典写法为什么同样不可靠网上流传最广的写法大概是这个function round1(v, d 2) { const f Math.pow(10, d); return Math.round(v * f) / f; }思路很直观把要保留的位数移到整数位取整再移回去。问题是v * f这一步本身就是在做浮点乘法会引入新的误差。我们前面遇到的那个 1.005在这里会变成1.005 * 100 // 100.49999999999999 Math.round(100.49999999999999) // 100 100 / 100 // 1结果还是 1一样错。再换个例子0.615保留两位0.615 * 100 // 61.49999999999999 Math.round(61.49999999999999) // 61 61 / 100 // 0.61期望是 0.62错有人会说2.55保留一位这个写法是对的确实Math.round(2.55 * 100) / 100 // 2.55但注意这是保留两位 Math.round(2.55 * 10) / 10 // 2.62.55 * 10 25.499999999999996Math.round 得到 2525/10 2.5实际上这里是错的让我重新验证一下2.55 * 10 // 25.499999999999996 Math.round(2.55 * 10) // 25 25 / 10 // 2.5所以先乘后除在 2.55 上也是错的。我记错了这更说明问题——这套写法的失败是随机的取决于具体数字在二进制里的偏移方向你没法靠肉眼判断哪个会错。这是它最危险的地方测试用例挑得好全绿真实数据一上随机崩。所以任何依赖乘 10 的幂的实现都必须配合更底层的机制来消除乘法误差。3.3 Number.EPSILON 补偿法的边界在哪既然问题出在浮点误差上那就加一个补偿量。Number.EPSILON是 2 的 -52 次方约等于 2.220446049250313e-16也就是 1 和比 1 大的最小双精度数之间的差。一个常见的修正写法是function roundEps(v, d 0) { const f 10 ** d; return Math.round(v * f * (1 Number.EPSILON)) / f; }原理是让放大后的值乘上一个略大于 1 的系数把差一点点的数字顶上去。对我们那个 1.0051.005 * 100 // 100.49999999999999 100.49999999999999 * (1 Number.EPSILON) // 100.50000000000001 Math.round(100.50000000000001) // 101 101 / 100 // 1.01这次对了。但这个方法有三个明显的局限必须说清楚。第一个局限补偿量是相对的不是绝对的。(1 EPSILON)相当于在数值上加了一个跟量级成正比的偏移。当数值很大时这个偏移可能超过你想要修正的误差把本该不进位的结果顶成进位。反过来当数值很小比如 1e-8 量级时偏移量又会小到不起作用。第二个局限它只修正了浮点乘法的误差没有处理输入值本身的偏移方向不确定这件事。如果某个数的二进制表示是偏大的加了补偿反而更容易翻车。第三个局限超过 2^53 之后double 已经无法表示连续整数了。比如1e15 0.5 // 1000000000000000.5还能表示 2 ** 53 1 // 9007199254740992加 1 被吃掉到了这个量级讨论小数位已经没有意义任何舍入算法都救不回来。所以用 EPSILON 前先确认你的数值规模在安全区间内。我的实际经验是EPSILON 补偿法可以当最后一层保险但不能当主力。真正稳妥的实现应该从避免浮点乘法这个角度入手也就是下面要讲的三种方法。4. 三种可落地的精确舍入实现与选型对比前面铺垫了这么多原理这一节给可直接抄走的实现。三种方法的思路完全不同适合的场景也不同我会把适用边界一起说清楚。4.1 指数记法最省事的十进制四舍五入这是我最推荐日常使用的一种代码短原理优雅function roundExp(value, digits 0) { if (typeof value ! number || !Number.isFinite(value)) return value; if (!Number.isInteger(digits) || digits 0 || digits 100) { throw new RangeError(digits 需为 0 到 100 之间的整数); } const shifted Number(value e digits); if (!Number.isFinite(shifted)) return value; return Number(Math.round(shifted) e- digits); }核心技巧是把1.005和字符串e2拼成1.005e2然后交给Number()解析。关键在于字符串到数字的解析走的是十进制语义引擎会去读1.005 乘以 10 的 2 次方而这个读法得到的值是 100.5——一个在二进制里可以精确表示的数。然后 Math.round 拿到的是干干净净的 100.5取整得到 101最后再拼成101e-2解析回 1.01。这一步的本质是把浮点乘法换成了十进制字符串解析绕开了 1.005 * 100 那个误差。验证一下roundExp(1.005, 2) // 1.01 roundExp(0.615, 2) // 0.62 roundExp(2.55, 1) // 2.6 roundExp(1.45, 1) // 1.5四个以前全错的用例现在都对了。使用的时候有几个注意点。第一输入不能是已经带指数记法的数字。比如 1e211e21 e2会得到1e21e2Number 解析出来是 NaN。所以我在函数里加了Number.isFinite(shifted)的守卫。第二Math.round 对负数是向正无穷舍入的。如果你希望负数按绝对值四舍五入-1.005 变 -1.01需要拆符号function roundHalfUp(value, digits 0) { if (!Number.isFinite(value)) return value; const negative value 0; const abs Math.abs(value); const shifted Number(abs e digits); const rounded Number(Math.round(shifted) e- digits); return negative ? -rounded : rounded; }第三digit 为 0 的时候e0和e-0都是合法的Number 能正确解析不用担心。4.2 字符串解析法完全掌控每一位的进位逻辑如果你需要的是按人类写的那个十进制数四舍五入而不是按二进制真实值舍入字符串法更直接。它的判断标准是看到被丢弃部分的第一位 ≥ 5 就进位不管它后面还有没有数字。function roundByString(value, digits 0) { if (typeof value ! number || !Number.isFinite(value)) return value; if (!Number.isInteger(digits) || digits 0) { throw new RangeError(digits 需为非负整数); } const negative value 0; const absStr Math.abs(value).toString(); // 科学计数法形态交给指数法处理 if (absStr.includes(e) || absStr.includes(E)) { return roundHalfUp(value, digits); } const [intPart, fracPart ] absStr.split(.); if (fracPart.length digits) return value; const kept fracPart.slice(0, digits); const flag fracPart.charCodeAt(digits) - 48; // 被丢弃部分的第一位 let digitsStr intPart kept; if (flag 5) { digitsStr (BigInt(digitsStr) 1n).toString(); } const needLen intPart.length digits; if (digitsStr.length needLen) { digitsStr digitsStr.padStart(needLen 1, 0); } const cut digitsStr.length - digits; const intOut digitsStr.slice(0, cut); const fracOut digitsStr.slice(cut); const result Number(digits 0 ? intOut : ${intOut}.${fracOut}); return negative ? -result : result; }这里有几个实现细节值得展开说。Math.abs(value).toString()给的是最短往返表示也就是能被解析回原值的最短十进制字符串。对 1.005它给的就是1.005对 0.1 0.2 的结果它给的是0.30000000000000004。这个特性在业务语义上正好是我们要的用户看到的数字就是它。进位用 BigInt 来做是因为字符串拼接出来的整数可能很长比如保留 15 位小数用 Number 转换会有精度风险。BigInt 加 1 不会有任何丢失。padStart那一段是为了处理进位后位数变化导致小数点位置算错的情况。比如9.995保留两位intPart 是9kept 是99拼出999进位后变成1000。此时 needLen 1 2 3digitsStr 长度是 4从右边切 2 位作为小数剩下10作为整数部分得到 10.00。正确。验证一轮roundByString(1.005, 2) // 1.01 roundByString(2.55, 1) // 2.6 roundByString(1.45, 1) // 1.5 roundByString(0.615, 2) // 0.62 roundByString(9.995, 2) // 10 roundByString(0.004, 2) // 0注意最后两个roundByString(9.995, 2)返回的是数字 10不是 10.00。因为函数返回的是 number 类型小数位只在需要的时候保留。如果你需要展示成10.00那是格式化层的事用toFixed(2)补零就行——此时数值已经精确toFixed 只是补字符串不会再有舍入问题。这个方法的代价是它假设你输入的十进制字符串就是真相。对于0.1 0.2这种计算结果输入它会老老实实按0.30000000000000004处理。这在业务上通常是合理的因为用户看到的就是这个值。4.3 整数放大法金额场景的首选方案前面两种方法解决的是如何正确舍入而整数放大法解决的是更根本的问题根本不要让小数参与运算。金额场景的标准做法是内部统一用分作为单位用整数存储和计算只在最后展示的时候转回元。// 字符串金额 分BigInt function toCents(input) { const s String(input).trim(); const m /^(-?)(\d)(?:\.(\d{0,2}))?$/.exec(s); if (!m) throw new Error(金额格式不合法: input); const [, sign, intPart, frac ] m; const cents BigInt(intPart) * 100n BigInt((frac 00).slice(0, 2)); return sign ? -cents : cents; } // 分BigInt 元字符串自动补两位小数 function formatCents(cents) { const negative cents 0n; const abs negative ? -cents : cents; const yuan abs / 100n; const rest (abs % 100n).toString().padStart(2, 0); return ${negative ? - : }${yuan}.${rest}; }用法const a toCents(1.005); // 会报错因为超过两位小数 const b toCents(12.34); // 1234n const c toCents(0.1); // 10n const sum b c; // 1244n formatCents(sum); // 12.44这套方案的好处是加减乘全是精确的永远不会出现0.1 0.2那种问题。除法需要额外定义舍入规则比如用 BigInt 的手写除法来指定保留位数。它对输入格式的要求比较严格只接受最多两位小数的字符串。这其实是一件好事——金额本身就只应该有两位小数如果上游给你传了个12.345那说明数据源有问题早报错早修。如果业务允许三位或四位小数比如某些计价场景把 100n 改成 1000n、10000n 就行改动很小。4.4 三种方法的横向对比与选型建议维度指数记法字符串解析法整数放大法代码量最少5 行左右中等30 行左右中等需配套转换函数依据的真相二进制真实值做十进制放大十进制字面值整数值1.005 保留两位1.011.01取决于输入格式校验负数按绝对值舍入需拆符号天然支持天然支持大数支持受 1e21 限制受 toString 格式限制无上限BigInt适合场景通用格式化、展示层需要严格对齐人类直觉的地方金额、账务、累计统计主要风险输入已是科学计数法形态会失效依赖 toString 的最短表示需改造数据模型选型上的我的建议是展示层用指数记法业务账务层用整数放大法需要跟用户手算结果完全一致的地方用字符串解析法。这三者不是互斥的一个项目里同时存在很正常关键是别在同一个数据流里混用两套舍入标准否则对账的时候你会很痛苦。5. 金额、统计与展示场景的实战取舍原理和实现都讲完了这一节聊几个真实场景下的决策。这些决策没有绝对的对错只有跟你的业务口径匹不匹配。5.1 电商价格展示toFixed 到底能不能用先说结论如果只是纯展示toFixed 可以用但你要能接受它不完全是人类意义上的四舍五入。理由是这样的用户看到价格的时候心理预期是这个数字看起来合理。1.005 显示成 1.00 还是 1.01用户不会去验证也不会投诉。真正会出问题的是展示值和实际扣款值不一致。所以关键不是能不能用 toFixed而是展示和结算必须用同一套口径。如果结算走的是整数分那展示也应该从整数分反向格式化而不是拿浮点金额直接 toFixed。我见过不少项目的做法是数据库存元decimal接口返回浮点元前端展示 toFixed后端结算又用另一套。三套口径早晚出事。一个更稳的做法是接口层直接返回字符串形式的金额比如12.34前端只做展示不做计算。前端真要算先转成整数分算完再转回来。这样从数据库到用户屏幕全链路只有一个真值。顺便说一下Intl.NumberFormat它是展示层非常好用的工具const fmt new Intl.NumberFormat(zh-CN, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); fmt.format(1234.5); // 1,234.50它自带千分位、货币符号、本地化格式是纯展示场景的省心选择。至于它的舍入行为较新的运行环境已经支持roundingMode选项halfExpand、halfEven、halfTrunc等可以显式指定。但因为你没法控制用户的浏览器版本涉及金额准确性的时候我还是建议先用前面讲的精确方法把值算好再交给Intl做纯格式化不要指望格式化工具帮你完成舍入这一步。5.2 财务口径下的银行家舍入银行家舍入half-even的规则是平局时向最近的偶数舍入。2.5 变 23.5 变 40.5 变 0。它的价值在于大量数据求和时不会有系统性偏差——普通的 half-up 每次都往上偏一点点一万条数据累加下来就是肉眼可见的差额。什么场景该用如果你们公司的财务口径明确要求四舍六入五成双或者你在做需要跟别人对账的统计报表那就用银行家舍入。实现的时候有个绕不开的麻烦怎么判断平局。因为在二进制里真正精确落在 0.5 上的情况很少你看到的 0.5 往往是 0.49999999999999994 或者 0.50000000000000011。所以必须引入一个误差容限function roundHalfEven(value, digits 0) { if (!Number.isFinite(value)) return value; const factor 10 ** digits; const scaled value * factor; const floor Math.floor(scaled); const diff scaled - floor; const EPS 1e-9; // 判定平局的容限 let result; if (Math.abs(diff - 0.5) EPS) { result floor % 2 0 ? floor : floor 1; } else { result Math.round(scaled); } return result / factor; }EPS的取值是个权衡。取太小浮点误差让你永远碰不到平局取太大会把本该正常进位的数误判成平局。1e-9 在常规量级放大后不超过 1e9下是个经验值如果你的数值量级比较大这个数要跟着调整。更严谨的做法是像字符串解析法那样先拿到十进制字面值再判断末位就不需要这个容限了。注意这个方法对负数的处理Math.floor是向负无穷取整的所以 -2.5 会得到 floor -3diff 0.5然后走偶数判断。如果你希望负数按绝对值处理前面加一层符号拆分。5.3 高精度需求该不该上 BigInt 或者 decimal 库遇到精度问题很多人的第一反应是上个库吧。市面上的选择主要有三类big.js体积最小约 3KB、bignumber.js功能全支持各种进制和格式、decimal.js精度和功能最全体积也最大。选哪个取决于你要什么。我的判断标准是三条第一条看业务对精度的要求是不是必须精确。如果是金额、库存、积分这类那确实值得上一个专门的十进制库因为这是业务正确性问题不是优化问题。如果只是图表展示、进度条比例这种用指数记法就够了没必要为此引入一个依赖。第二条看数据在链路里流动的范围。如果只有前端一小段逻辑需要高精度而上下游都是字符串传递那简单的字符串处理函数就够了。如果需要频繁做四则运算、比较、格式化那用库更省心。第三条看团队能不能接受这个依赖。一个 30KB 的库对现代前端不算什么但如果你的项目对包体积特别敏感比如嵌入到别的页面里那就要掂量一下。至于原生的 BigInt它的能力边界要说清楚它只能表示整数没有小数。所以它不能直接替代 decimal 库但它是实现整数分方案的最佳工具因为加减乘都不会溢出、不会丢精度。需要小数的场景只能自己用整数 固定小数位来模拟也就是前面说的放大法。最后提一句不要用 Number 存需要精确比较的 ID 或编码。订单号、身份证号、批次号这些超过 2^53 就会开始丢位。这类字段从接口定义开始就该是字符串。6. 一次线上对账差异的完整排查链路前面都是讲道理这一节讲一个我在实际项目里遇到的排查过程。之所以把它完整写出来是因为排查方法比结论更值钱——你下次遇到类似问题可以照着这个路子走。6.1 现象对账报表每天差几分钱场景是这样一个系统用户下单时按比例计算返利每天生成一张返利汇总表跟财务系统对账。上线跑了一周之后财务反馈每天的汇总金额都差几分到几毛不等而且是我们这边多他们那边少。第一反应是查数据源是不是订单漏了、状态过滤不一致。查了两天订单条数完全一致逐条金额也一致问题就出在汇总求和这一步。6.2 定位从求和结果往回缩小范围排查的第一步是把可疑范围切小。我把当天的数据按订单拆开分别用两种方式求和一种是后台的 JS 汇总逻辑一种是数据库的SUM()。两边结果不一样说明确实是 JS 侧的问题。第二步是二分查找。把订单列表分成两半分别求和对比找出差异出现在哪一半然后继续往下切。切到第 4 层的时候定位到一条订单它的单条金额在两边算出来差了一分。第三步是复现。把这条订单的原始参数单价、数量、折扣率、返利比例拿出来在控制台一步步打印中间值很快看到了熟悉的一幕0.1 * 3 * 0.85 // 0.25500000000000006 (0.25500000000000006).toFixed(2) // 0.26再换一个参数组合1.005 * 100 // 100.49999999999999问题定位完成计算链路上用了 toFixed 取值而 toFixed 在特定数字上会给出偏离人类预期的结果。同一批数据里有的订单偏上、有的偏下累加起来就成了对账差异。6.3 修复方案与回归验证定位到问题之后修复有三个层次我们最后是三层一起做的。第一层是改口径。把返利金额的计算改成全程用分作为单位用 BigInt 或者普通整数做运算只在最终写库和展示的时候转成元。这一步从根上消灭了小数运算。第二层是替换舍入实现。项目里散落着十几处toFixed调用逐一评估之后展示层的保留原样改成模板里统一走格式化函数计算层的全部替换成前面讲的roundHalfUp。第三层是加回归用例。把所有踩过的数字整理成一份测试用例清单写成单元测试每次改格式化相关代码都跑一遍。这份清单我列在下面你可以直接拿去用。关于验证方法有个小技巧别只看结果对不对要看结果和期望的差值分布。修复之后我把当天的数据重新算了一遍把所有订单的系统值 - 手工计算值列出来如果全是 0 或者全是同一个方向的极小偏差说明口径统一了。当时看到的是全 0才敢上线。7. 沉淀一份工具函数与回归用例清单排查完之后我做的一件事是把这套东西沉淀成一个独立模块不依赖任何第三方库控制在 100 行以内。这一节把最终版本和测试清单一起放出来。7.1 一份可直接抄走的舍入工具模块/** * JS 精确舍入工具集 * 不依赖任何第三方库 */ // 1. 通用十进制四舍五入指数记法推荐日常使用 function roundHalfUp(value, digits 0) { if (typeof value ! number || !Number.isFinite(value)) return value; if (!Number.isInteger(digits) || digits 0 || digits 100) { throw new RangeError(digits 需为 0 到 100 之间的整数); } const negative value 0; const abs Math.abs(value); const shifted Number(abs e digits); if (!Number.isFinite(shifted)) return value; // 超大数直接返回原值 const rounded Number(Math.round(shifted) e- digits); return negative ? -rounded : rounded; } // 2. 按十进制字面值四舍五入字符串解析法 function roundByLiteral(value, digits 0) { if (typeof value ! number || !Number.isFinite(value)) return value; if (!Number.isInteger(digits) || digits 0) { throw new RangeError(digits 需为非负整数); } const negative value 0; const absStr Math.abs(value).toString(); if (absStr.includes(e) || absStr.includes(E)) { return roundHalfUp(value, digits); } const [intPart, fracPart ] absStr.split(.); if (fracPart.length digits) return value; const kept fracPart.slice(0, digits); const flag fracPart.charCodeAt(digits) - 48; let digitsStr intPart kept; if (flag 5) { digitsStr (BigInt(digitsStr) 1n).toString(); } const needLen intPart.length digits; if (digitsStr.length needLen) { digitsStr digitsStr.padStart(needLen 1, 0); } const cut digitsStr.length - digits; const intOut digitsStr.slice(0, cut); const fracOut digitsStr.slice(cut); const result Number(digits 0 ? intOut : ${intOut}.${fracOut}); return negative ? -result : result; } // 3. 补齐小数位用于展示不做舍入只补零 function padDecimals(value, digits 2) { const fixed roundHalfUp(value, digits); let s String(fixed); if (digits 0) return s; if (!s.includes(.)) s .; const [, frac ] s.split(.); return s 0.repeat(Math.max(0, digits - frac.length)); } // 4. 金额转换整数分 function toCents(input) { const s String(input).trim(); const m /^(-?)(\d)(?:\.(\d{0,2}))?$/.exec(s); if (!m) throw new Error(金额格式不合法: input); const [, sign, intPart, frac ] m; const cents BigInt(intPart) * 100n BigInt((frac 00).slice(0, 2)); return sign ? -cents : cents; } function formatCents(cents) { const negative cents 0n; const abs negative ? -cents : cents; const yuan abs / 100n; const rest (abs % 100n).toString().padStart(2, 0); return ${negative ? - : }${yuan}.${rest}; } // 5. 归一化 -0 function normalizeZero(n) { return n 0 ? 0 : n; }几个使用上的建议roundHalfUp用在绝大多数需要数值舍入的地方roundByLiteral用在必须和用户手算结果一致的地方比如给用户的账单明细padDecimals专门负责把 1 变成1.00这种展示形态金额链路一律走toCents/formatCents。7.2 回归测试用例清单下面这些是我踩过坑之后整理出来的覆盖了 5 种典型失败模式。每次改格式化相关的代码我都会跑一遍。分类输入位数期望结果说明平局向上0.501二进制精确值平局向上2.503验证不是银行家舍入偏小翻转1.00521.01最经典的坑偏小翻转2.5512.6先乘后除会得到 2.5偏小翻转0.61520.62先乘后除会得到 0.61偏大翻转1.5511.6实际值略大于字面值负数-1.0052-1.01按绝对值四舍五入负数-0.50-1注意不是 -0进位跨位9.995210整数部分要进位小整数进位0.99521同上整数部分从 0 变 1不进位边界0.00420别误进位位数不足1.231.2原样返回补零展示121.00走 padDecimals大数1e212原值无法表示小数位特殊值NaN2NaN直接透传金额12.34-1234n转分金额1234n-12.34转元其中偏小翻转和偏大翻转这两类要特别注意它们的期望值取决于你是按二进制真值算还是按十进制字面值算。上表给的是按十进制字面值的期望也就是符合人类直觉的那种。如果你用的是roundHalfUp指数记法在前四个用例上都能通过但像1.55这种指数记法给出的也是 1.6因为它的实际值确实偏大两种口径在这里恰好一致。真正会分叉的是那种实际值偏小、但字面值看起来该进位的情况比如 1.005。指数记法通过十进制放大恰好把这种分叉修掉了这也是我推荐它作为默认实现的原因——它在绝大多数场景下既符合直觉又不需要复杂的字符串处理。如果你想做更彻底的验证可以写一个简单的对照脚本用 Python 的 decimal 模块或者 Java 的 BigDecimal 算一份基准结果导出成 JSON再用 JS 跑同样的用例做 diff。这种跨语言的对照比在 JS 内部自己跟自己对要有说服力得多。最后分享一个我在实际排查中形成的小习惯任何涉及金额或比例的字段在数据模型设计阶段就问一句它的最小单位是什么。如果能答上来比如分那就用整数存如果答不上来那说明这个字段的口径还没定清楚后面大概率会出问题。这个习惯帮我挡掉了不少后来会变成对账差异的隐患比事后写多少排查脚本都管用。