
在后台系统里做金额展示的前端同学大概率都经历过这样一幕接口返回 1.005代码里规规矩矩写了个toFixed(2)页面渲染出来却是 1.00运营截个图丢到群里问少的那一分钱去哪了。你去控制台敲一遍发现还真是 1.00再敲Math.round(1.005 * 100) / 100得到的是 1一样不对。于是开始怀疑人生JS 的四舍五入到底怎么回事toFixed是不是有 bug这篇就把 JS 里跟四舍五入、保留小数相关的所有东西一次讲透Math.round家族的边界行为、toFixed在规范层面究竟做了什么、为什么它会在某些数字上失灵、js里五种保留小数方案的横向对比以及一个可以直接抄走的生产级精确舍入函数。不管你是刚接触前端的新手还是写了几年业务代码、被金额精度折磨过的老手看完都能对JS 里怎么正确地四舍五入有一套稳定的判断标准而不是出了问题再去搜零散的帖子。1. 为什么 JS 的四舍五入总在关键时刻翻车1.1 先把浮点数这笔账算清楚要理解四舍五入为什么不听话得先接受一个反直觉的事实JS 里所有的 Number 都是 64 位双精度浮点数没有整数类型也没有定点小数类型。它的存储结构是 1 位符号位、11 位指数位、52 位尾数位加起来 64 位。52 位尾数加上隐含的最高位 1实际能表达 53 位二进制有效数字换算成十进制大约是 15 到 17 位有效数字。问题就出在这里十进制里看着很干净的小数比如 0.1、0.2、1.005在二进制下大多数是无限循环小数。就像十进制写不出 1/3 的精确值一样二进制也写不出 0.1 的精确值。0.1 在 double 里真正存的值是0.1000000000000000055511151231257827021181583404541015625它比 0.1 大了一点点。而 0.2 存的值比 0.2 也大了一点点。两个都偏大的数相加误差累积就得到了那个著名的结果0.1 0.2 // 0.30000000000000004 0.1 0.2 0.3 // false反过来1.005 在 double 里存的值比 1.005小1.00499999999999989341858963598497211933135986328125这个细节非常重要后面讲toFixed的坑全靠它解释。你可以用一行代码自己验证任意数字的真实存储值function exact(n) { // 用足够多的小数位把 double 的真实值打出来 return n.toFixed(20); } exact(1.005); // 1.00499999999999989342 exact(2.55); // 2.54999999999999982236 exact(8.575); // 8.57499999999999928946toFixed(20)在规范允许的范围内0 到 100会把 double 的真实十进制展开输出虽然末尾会有二次舍入但前 17 位基本可信足够看清真值到底偏大还是偏小。这个技巧我在排查精度问题时用得最多比凭感觉猜靠谱得多。1.2 三个真实业务里的翻车现场场景一金额计算。电商订单里商品单价 19.9 元买 3 件19.9 * 3得到 59.699999999999996。如果你直接toFixed(2)展示结果倒是能看59.70但如果中间参与的是满减判断这类逻辑比如判断是否大于 59.7就会得到false优惠券不发用户投诉。这类问题最麻烦的地方在于它偶发——大部分数字碰巧是对的只有特定组合才炸测试环境不一定复现得出来。场景二成绩与统计。学生总分 84.5 分按四舍五入取整应该是 85但如果总分是0.1 0.2 84.2累加出来的真实值可能是 84.49999999999999取整变成 84。学生来找你你打开控制台一看明明是 84.5 啊。这时候你得知道你看到的 84.5 只是toString做了最短往返表示后的结果真实值不是它。场景三百分比与比例分摊。100 元按 3 个人平均分每人 33.33 元加起来 99.99少了一分钱。这个问题本质上跟浮点数无关是分摊后余数归属的算法问题但只要涉及保留两位小数就一定绕不开舍入函数的选型。谁该承担那一分钱取决于业务规则代码里必须显式处理。这三个场景有个共同点出错的从来不是四舍五入这个动作本身而是参与四舍五入的那个数到底是多少。想清楚这一点后面所有的坑都能自己推出来。2. toFixed() 的完整行为拆解2.1 规范层面 toFixed 究竟做了什么很多人把toFixed当成四舍五入工具其实它的规范定义比这精细得多。ECMAScript 规范里Number.prototype.toFixed(fractionDigits)的执行步骤大致是这样对fractionDigits做ToIntegerOrInfinity如果结果不在 0 到 100 之间含抛RangeError。令x为这个 Number 的数值。如果x不是有限数NaN 或 Infinity直接返回ToString(x)。如果x的绝对值大于等于 10^21直接返回ToString(x)。否则令n为一个整数使得n / 10^f与x在数值上最接近如果存在两个这样的n与x距离相等取较大的那个 n。按十进制格式输出小数位不足f位时补零。关键在于第 4 步它比较的是n / 10^f与x的真实数值而x是那个有二进制误差的 double。所以toFixed的舍入依据不是人类看到的十进制字符串而是内存里那个真实的近似值。拿 1.005 举例。x的真实值是 1.00499999999999989...保留两位时候选的n是 100 和 101候选 nn / 100与 x 的距离1001.000.004999999999999891011.010.00500000000000010100 更近所以输出1.00。从内存里的数值这个角度看toFixed完全正确一点没算错。只不过它不是你要的那个四舍五入——你要的是对十进制字面量 1.005 做舍入它给的是对 1.00499999999999989 做舍入。这是两种完全不同的需求toFixed只满足后一种。而 1.035 恰好相反它在 double 里存的值是 1.03500000000000003...比 1.035 大所以(1.035).toFixed(2)会给出1.04。同一个写法在不同的数字上一个偏小一个偏大表现出来就像是随机失灵这是所有人第一次踩坑时最迷惑的地方。2.2 破除一个流传很广的误解toFixed 不是银行家舍入网上有大量文章说toFixed用的是银行家舍入round half to even所以 1.005 变成 1.00、2.5 变成 2。这个说法是错的而且错得挺离谱照着这个理解去写代码会踩更大的坑。银行家舍入的规则是恰好在中点时取最近的偶数。而toFixed的规范明确写着距离相等时取较大的 n也就是标准的 round half up向正无穷方向的半值进位。用一组数字就能验证(0.5).toFixed(0); // 1 如果是 half-even0.5 应该进位到 0 (1.5).toFixed(0); // 2 如果是 half-even1.5 应该变成 2碰巧一致 (2.5).toFixed(0); // 3 如果是 half-even2.5 应该变成 2 (3.5).toFixed(0); // 4 如果是 half-even3.5 应该变成 4碰巧一致如果真是银行家舍入(0.5).toFixed(0)必须是0、(2.5).toFixed(0)必须是2。实测都不是。这些数字有个共同特点它们在二进制里都能精确表示没有误差所以能干净地反映规范里的规则。真正会翻车的 1.005、2.55 之所以看起来像银行家舍入纯粹是因为它们的 double 真值恰好落在中点的下方跟舍入模式毫无关系。理解了这个区别你排查问题时的思路就完全不一样了看到toFixed结果不对第一反应不该是它用了什么奇怪模式而应该是这个数字在内存里到底是几然后toFixed(20)打出来看看。2.3 参数边界、返回类型与几个必须知道的使用限制toFixed还有一堆容易忽略的细节我整理成一张表情况行为示例参数小于 0 或大于 100抛RangeError(1).toFixed(101)报错参数是小数先截断成整数(1.23).toFixed(1.9)等价于toFixed(1)得1.2参数是undefined按 0 处理(1.6).toFixed()得2数值绝对值 ≥ 1e21退化成toString(1e21).toFixed(2)得1e21是 NaN / Infinity原样输出字符串(NaN).toFixed(2)得NaN小数位不足补零到指定位数(1).toFixed(3)得1.000返回类型始终是字符串(1.005).toFixed(2) 1得1.001最后一条是新手最常犯的错误。toFixed返回的是 String如果直接参与算术运算会触发隐式类型转换如果参与运算会变成字符串拼接(0.1).toFixed(2) (0.2).toFixed(2); // 0.100.20不是 0.3 Number((0.1).toFixed(2)) Number((0.2).toFixed(2)); // 0.30000000000000004还有个小语法坑数字字面量后面直接跟点号会被解析成小数点1.toFixed(2)会报语法错误。正确写法是(1).toFixed(2)或者1..toFixed(2)。我在代码评审里见过不止一次这个失误特别是写测试用例的时候。一般建议全程用括号包裹可读性也更好。3. Math 家族整数舍入的四种姿势与半值陷阱3.1 Math.round 对负数的诡异行为Math.round的规范是返回最接近的整数如果两个整数距离相等取较大的那个即向 ∞ 方向。这条规则对正数符合直觉对负数就会产生一个让所有人第一次见都愣住的后果Math.round(0.5); // 1 Math.round(1.5); // 2 Math.round(-0.5); // -0 注意是负零 Math.round(-1.5); // -1 不是 -2 Math.round(-2.5); // -2 Math.round(-0.4); // -0 Math.round(-0.6); // -1Math.round(-0.5)返回的是-0一个符号位为负的零。Object.is(-0, 0)为 false-0 0为 trueJSON.stringify(-0)输出0但它出现在除法里会得到-Infinity。这些特性凑在一起在取整后做分母的场景里能整出很隐蔽的 bug建议拿到结果后统一过一遍x 0或者Object.is判断把负零规范化掉。Math.round(-1.5)返回 -1 这件事在业务上经常是错的。用户直觉是负一点五四舍五入到负二代码给的却是 -1。如果你的业务涉及负数金额退款、负数库存并且要求对称的远离零舍入Math.round不能直接用必须先取绝对值处理再补回符号。3.2 floor / ceil / trunc 的选择逻辑四个取整函数的分工用一张表最清楚。取x分别为 1.7、-1.7函数含义f(1.7)f(-1.7)典型用途Math.round就近取整半值向 ∞2-2通用取整正数场景Math.floor向下向 -∞取整1-2分页、桶排序、按天归档Math.ceil向上向 ∞取整2-1分页总页数、分片数量Math.trunc截断小数部分1-1去掉小数、不支持时的兜底最容易混的是floor和trunc正数时两者一样负数时floor(-1.7) -2而trunc(-1.7) -1。Math.trunc是 ES6 加入的老环境没有可以用x 0 ? Math.ceil(x) : Math.floor(x)兼容。分页是ceil最经典的用场。总条数 101、每页 20 条Math.ceil(101 / 20)得 6 页。但这里有个隐藏问题如果总条数是浮点计算出来的比如按比例估算得到100.00000000000001ceil会给你 6 页而实际只需要 5 页页面上就多出一个空白页。所以凡是ceil之前的除法结果最好先做一次精度收敛比如Math.ceil((total / size).toFixed(10))。3.3 用 Math.round 保留小数为什么还是不准最广为人知的保留两位小数写法是这样的const round2 (n) Math.round(n * 100) / 100; round2(1.005); // 1期望 1.01 round2(2.675); // 2.67期望 2.68为什么还是错因为n * 100这一步引入了新的浮点误差。1.005 在内存里是 1.00499999999999989乘以 100 之后二进制乘法的结果舍入到最近的 double得到 100.49999999999999 而不是 100.5。于是Math.round只能进位到 100除以 100 又是 100 / 100而 100 / 100 的二进制除法结果正好是 1得到 1。换句话说这条链路上有两个独立的误差源原始数字的表示误差以及中间乘除法的舍入误差。你想控制舍入行为却让两次不可控的浮点运算夹在中间结果自然不稳定。要精确舍入核心思路是减少参与二进制运算的环节后面几节的所有方案都是围绕这个原则设计的。4. 五种保留小数的实现方案横向对比4.1 指数位移法改动最小、收益最大的写法先说结论日常业务里指数位移法也有人叫e计数法是改动量最小、稳定性提升最大的方案。原理很朴素——把乘以 10 的 n 次方从浮点运算换成字符串解析function roundByExponent(value, digits 2) { const shifted Number(${value}e${digits}); const rounded Math.round(shifted); return Number(${rounded}e-${digits}); } roundByExponent(1.005, 2); // 1.01 roundByExponent(2.55, 1); // 2.6 roundByExponent(1.005 * 100 / 100, 2); // 1第三行说明了一个重要的边界这个方法修的是位移引入的误差修不了value 本身已经是脏的。1.005 * 100 / 100的结果虽然打印出来是 1.005但它的 double 真值已经不是 1.005 了乘除过程中又丢了一次精度再位移也没用。所以这个方案的适用前提是你要舍入的那个数字来自用户输入或接口的十进制字符串还没被算过。它为什么有效因为${value}会调用Number.prototype.toString输出最短往返表示就是那个能唯一还原 double 的、位数最少的十进制字符串。Number(1.005e2)解析时按十进制字符串 100.5 精确计算而 100.5 恰好是二进制可精确表示的100.5 100 0.5 1100100.1₂于是拿到干净的 100.5。相比1.005 * 100走二进制乘法路径字符串解析这条路上的十进制语义被保留了。这个方法也不是万能的。它有四个明确的限制超过Number.MAX_SAFE_INTEGER9007199254740991的大数位移后同样丢精度。digits超过 15 左右基本没意义double 的有效位就那么些。value本身如果已经是被算脏的浮点数救不回来。负数要单独处理因为Math.round的半值方向不对称。4.2 字符串法彻底摆脱二进制误差如果你要的是跟用户看到的一模一样的舍入结果最彻底的办法是全程不碰浮点数把数字当字符串操作。思路是拆出整数部分和小数部分看第digits 1位是不是大于等于 5是就用字符串加法进位。整条链路只有字符处理和整数加法不存在任何精度损失。// 字符串加法只用于纯数字串 function incString(s) { const arr s.split(); let i arr.length - 1; while (i 0) { if (arr[i] 9) { arr[i] 0; i--; } else { arr[i] String(Number(arr[i]) 1); break; } } if (i 0) arr.unshift(1); return arr.join(); } // 把各种输入归一化成 { sign, intPart, fracPart } function normalize(input) { const s String(input).trim(); const m /^([-]?)(\d)(?:\.(\d))?(?:[eE]([-]?\d))?$/.exec(s); if (!m) throw new TypeError(不是合法的十进制数字: ${input}); const sign m[1] - ? - : ; const intPartRaw m[2]; const fracPartRaw m[3] || ; const exp m[4] ? Number(m[4]) : 0; const all intPartRaw fracPartRaw; const pointIdx intPartRaw.length exp; let intPart, fracPart; if (pointIdx 0) { intPart 0; fracPart 0.repeat(-pointIdx) all; } else if (pointIdx all.length) { intPart all 0.repeat(pointIdx - all.length); fracPart ; } else { intPart all.slice(0, pointIdx); fracPart all.slice(pointIdx); } intPart intPart.replace(/^0(?\d)/, ) || 0; return { sign, intPart, fracPart }; }normalize处理了科学计数法输入、前导零、纯小数.5这种等情况输出三段结构符号、整数部分、小数部分。有了它舍入就是纯粹的位操作。4.3 五种方案横向对比在给出完整的舍入函数之前先把市面上的方案摆在一起看。测试输入统一为1.005保留两位方案代码结果是否达标适用场景直接 toFixed(1.005).toFixed(2)1.00否仅用于纯展示、数据来源可信乘除 Math.roundMath.round(1.005*100)/1001否不推荐误差源最多指数位移法Number(${1.005}e2)后回退1.01是日常业务输入未被污染字符串法逐位判断进位1.01是金额、精度敏感场景Intl 格式化Intl.NumberFormat1.00否纯展示仍受 double 真值影响关于最后一行需要补充说明Intl.NumberFormat的默认舍入模式roundingMode是halfExpand半值远离零规则本身比toFixed更符合业务直觉但它依然是在 double 真值上做舍入。1.005在内存里小于 1.005格式化出来照样是1.00。所以它适合做最后一公里的展示不适合做参与后续计算的值。选型建议很直接展示且数据可信用Intl.NumberFormat需要参与计算或对精度有要求用字符串法介于两者之间、想低成本改造用指数位移法。5. 手写一个生产可用的精确舍入函数5.1 设计取舍为什么采用远离零而不是向正无穷前面提到Math.round(-1.5)返回 -1而业务上大多数人对负数的直觉是 -2。我在写通用舍入函数时选择了round half away from zero半值远离零理由是对称性round(1.5) 2且round(-1.5) -2正负对称财务对账时不会出现借方贷方规则不一致的问题。符合直觉非技术同事看到 -0.5 变成 -1 不会有疑问看到 0 反而会来问。和主流语言一致很多语言和数据库的默认舍入都是 half away from zero前后端联调时省事。但要注意它不是所有场景的最优解。如果是分摊类业务100 元分 3 份理论最优是让每个人承担的平均误差最小通常的做法是先算基础值余数按规则补给特定人跟舍入模式关系不大。如果是统计报表、科学计算可能更希望用银行家舍入half to even来减少长序列求和的累积偏差。选哪种取决于业务但必须显式选定并写进代码注释最怕的就是我以为是四舍五入你以为是截断。5.2 完整代码实现基于前面的normalize和incString补上舍入主体/** * 精确舍入半值远离零 * param {string|number} input - 待舍入的值推荐传字符串 * param {number} digits - 保留的小数位数0 ~ 100 * returns {string} 舍入后的十进制字符串 */ function roundExact(input, digits 0) { if (!Number.isInteger(digits) || digits 0 || digits 100) { throw new RangeError(digits 必须是 0 到 100 之间的整数); } const { sign, intPart, fracPart } normalize(input); const full intPart fracPart; // 去掉小数点后的全部数字 const pointIdx intPart.length; // 小数点在 full 中的位置 const keepLen pointIdx digits; // 需要保留的数字个数 let kept; if (keepLen full.length) { kept full 0.repeat(keepLen - full.length); } else { kept full.slice(0, keepLen); if (Number(full[keepLen]) 5) kept incString(kept); } let out; if (digits 0) { out kept; } else { const cut kept.length - digits; out kept.slice(0, cut) . kept.slice(cut); } out out.replace(/^0(?\d)/, ); const isZero /^0(\.0*)?$/.test(out); return (sign - !isZero ? - : ) out; }再包一层返回 Number 的版本方便需要参与计算的场合function roundExactToNumber(input, digits 0) { return Number(roundExact(input, digits)); }5.3 测试用例与边界验证写精度相关的工具函数我最看重的不是实现有多巧妙而是测试用例覆盖得够不够狠。下面这组是我实际项目里在用的直接抄进单测就能跑const cases [ [1.005, 2, 1.01], [2.55, 1, 2.6], [8.575, 2, 8.58], [0.145, 2, 0.15], [1.004999, 2, 1.00], [9.995, 2, 10.00], // 进位后整数位增加 [99.999, 2, 100.00], [-1.5, 0, -2], // 半值远离零 [-0.5, 0, -1], [-0.4, 0, 0], // 结果为零时不带负号 [0, 2, 0.00], [1, 3, 1.000], // 位数不足补零 [0.5, 0, 1], [.5, 1, 0.5], [1.5e2, 1, 150.0], // 科学计数法输入 [1.23456789e-3, 5, 0.00123], [000123.456, 1, 123.5], // 前导零 ];有几个用例值得单独说9.995保留两位得到10.00这是进位后位数进位的经典场景。很多简化实现只处理了末位加一遇到9不会往前进位结果输出9.10这种离谱的东西。incString里的while循环就是为这个写的。-0.4保留零位应该输出0不能输出-0。字符串法天然不产生负零因为是字符串拼接但如果你把这个结果Number()一下Number(0)是正零没问题而如果实现里用-0去算就可能带出负零。这个isZero判断就是专门防这个的。1.23456789e-3保留五位得到0.00123验证科学计数法展开逻辑。normalize里pointIdx intPartRaw.length exp这一行是关键1.23456789e-3中intPartRaw 1all 123456789exp -3pointIdx 1 - 3 -2落到pointIdx 0分支前面补两个零得到0.00123456789。逻辑是对的。6. 金额与业务场景的落地建议6.1 存储层用整数分是最省心的架构选择前面所有的讨论都建立在一个前提上你得在某个时刻把一个十进制小数交给 JS 的 Number。而最彻底的解决方案其实是根本不这么做。金额场景下我的建议是数据库用DECIMAL(18,2)或者BIGINT存分绝对不用FLOAT/DOUBLE。接口传字符串19.90或者传整数分1990。传 Number 是给未来埋雷。前端状态如果传的是分全程用整数运算只在渲染的最后一步除以 100。渲染roundExact(cents / 100, 2)或者用Intl.NumberFormat对cents / 100格式化。为什么整分存储这么有效因为 double 能精确表示的整数范围到了 2^53也就是 9007199254740991换算成分是 90 万亿元任何正常业务都用不完。在这个范围内加减乘都是精确的不存在任何舍入问题。把小数问题消灭在数据入口比在数据出口做补救要可靠得多。如果接口实在只能给小数也要在解析的第一时间转成分function yuanToCents(input) { // 先按字符串处理再转整数避免 19.9 * 100 1989.9999999999998 const s String(input).trim(); const str roundExact(s, 2); // 先对齐到两位 const [i, f ] str.split(.); const sign i.startsWith(-) ? -1 : 1; return sign * (Math.abs(Number(i)) * 100 Number(f.padEnd(2, 0))); } yuanToCents(19.9); // 1990 yuanToCents(-0.015); // -2半值远离零 yuanToCents(19.9 * 3); // 5970最后一行值得注意19.9 * 3是59.699999999999996roundExact对齐两位得到59.70再转分是 5970。如果你直接Math.round(19.9 * 3 * 100)得到的是Math.round(5969.999999999999) 5970碰巧也对但换个数字就不一定了。用字符串法走一遍逻辑上是确定的。6.2 展示层的格式化选型展示层的选择取决于三件事要不要千分位、要不要货币符号、要不要处理多语言。只做基础保留两位roundExact输出字符串就够了。需要加千分位最省事的是Intl.NumberFormatconst fmt new Intl.NumberFormat(zh-CN, { minimumFractionDigits: 2, maximumFractionDigits: 2, }); fmt.format(1234567.891); // 1,234,567.89Intl.NumberFormat的舍入模式默认是halfExpand规则本身是合理的但依然基于 double 真值。所以如果输入是算出来的数先roundExact一遍再交给它格式化两条防线叠加最稳。如果输入本来就是整分可以自己拼function formatCents(cents) { const sign cents 0 ? - : ; const abs Math.abs(cents); const yuan Math.floor(abs / 100); const fen String(abs % 100).padStart(2, 0); const withSep String(yuan).replace(/\B(?(\d{3})(?!\d))/g, ,); return ${sign}${withSep}.${fen}; } formatCents(123456789); // 1,234,567.89 formatCents(-1990); // -19.90这个函数全程整数运算没有任何浮点参与\B(?(\d{3})(?!\d))是给整数部分加千分位的经典正则。它还有个额外好处不依赖Intl在任何 JS 环境里行为完全一致包括各种小程序和嵌入式 JS 引擎比如js宏这类自动化脚本环境里Intl的支持情况常常不完整。6.3 什么时候该引入专业库自己写一套精度工具好处是体积小、可控、没有依赖代价是要自己维护测试用例而且只覆盖了舍入这一个环节。什么情况下该上专业库判断标准是如果业务涉及多步运算加减乘除混合、需要可配置的舍入模式和精度、或者要处理非常大的数就应该上库。原因很简单舍入只是浮点问题的冰山一角真正难的是(a b) * c / d这种表达式链上的误差传播自己手写会没完没了地打补丁。常见的几类方案方案类型思路适合场景任意精度十进制库用字符串或数组模拟十进制运算金融、计费、报表大整数方案全程整数运算手动管理小数点位置已有整分存储的架构内置国际化格式化只负责展示层纯前端展示数据可信服务端计算精度敏感逻辑放服务端前后端分离的成熟系统最后一行其实是最容易被忽略、但工程上最靠谱的方案。凡是涉及钱的计算如果服务端能算就别在前端算。前端拿到的应该是已经算好的、可以直接展示的结果前端只负责格式化和交互。这条原则能消灭掉 90% 的精度事故。7. 常见问题速查与排查技巧7.1 十二个高频问题速查表这张表是我这几年被问得最多的问题汇总遇到问题先查表现象根本原因处理办法1.005.toFixed(2)得1.00double 真值略小于 1.005用字符串法或先转分做整数运算Math.round(1.005*100)/100得1乘法引入新误差换指数位移法Number(1.005e2)0.1 0.2 ! 0.3二进制无法精确表示用Math.abs(a-b) 1e-10比较0.3 - 0.1得0.19999999999999998同上转成分整数再运算toFixed返回值参与加法变成拼接返回 String 类型用Number()或parseFloat()包一层(1e21).toFixed(2)得1e21超过 1e21 退化成toString大数场景用字符串法Math.round(-0.5)得-0规范定义半值向 ∞结果加 0 或用Object.is判断Math.round(-1.5)得-1同上需要对称舍入时先取绝对值大整数相加结果末位变了超过Number.MAX_SAFE_INTEGER用BigInt处理1.toFixed(2)报语法错误点号被解析成小数点写(1).toFixed(2)累加 1000 笔金额后总额差几分每次舍入都有偏差误差累积全程整分累加最后一次性格式化Vue / React 里 computed 每次结果不同中间某处产生了负零或不同精度在数据源统一转分禁止小数进状态7.2 我实际踩过的几个坑第一个坑把toFixed当成了四舍五入。这是我最早做电商项目时犯的错。当时线上跑得好好的因为大部分价格乘数量之后的小数位恰好不落在中点上。上线一个促销活动某款商品单价 8.575 元、买 3 件按规则该打 9 折前后端算出来的应付金额差了一分钱对账对不上。后来把整条链路改成接口传分、前端整数运算问题就再没出现过。教训是toFixed不是舍入函数是格式化函数这两个角色的职责要分清楚。第二个坑用toFixed做相等判断。曾经写过这样的代码判断两个价格是否相等if (a.toFixed(2) b.toFixed(2)) { /* 认为相等 */ }但字符串比较会踩字母顺序的坑而且精度上也不对比如1.005和1.004保留两位都得到1.00或1.00与1.00看起来相等但其实差了 0.001。这个判断后来被替换成了整数分比较逻辑清爽了很多。第三个坑在 Vue 的 computed 里做浮点累加。购物车总价用reduce累加每个商品的小计每个小计都是小数。由于计算属性会在依赖变化时重新求值实时输出上看着是 199.99 还是 200取决于数据更新的顺序和缓存命中的情况。用户刷新页面看到的总价跟下单页面的总价不一致。最后改成每个小计存分总价用整数累加只在一处格式化彻底消掉了这类随机性。第四个坑忘了负零。退款场景里Math.round(-0.4)得到-0直接用来做JSON.stringify时输出0但用它做除数会得到-Infinity在分页计算里把 pageSize 算成负数后端接口直接报错。后来我在所有取整函数的出口统一加了一句x 0把负零规范化成正零简单粗暴但有效。7.3 排查精度问题的三步法最后分享一套我现在排查精度问题的固定流程比漫无目的地翻代码快很多第一步先看数据源。打开 Network 面板找到出问题的那个字段看接口返回的原始类型。如果返回的是 Number 且小数位数超过 2 位问题大概率在传输层。如果返回的是字符串看看有没有被前端某处Number()或parseFloat()转掉。第二步把中间的每一步都打印出来。用toFixed(20)看每一步的真实值别用console.log直接看它会走最短往返表示把小的误差藏起来const step (label, v) console.log(label, typeof v, v, 真值:, Number(v).toFixed(20)); step(接口原始, raw); step(转数字后, Number(raw)); step(乘以数量, Number(raw) * qty); step(舍入后, roundExactToNumber(raw, 2) * qty);我给这个step函数写过好几次后来干脆抽成了项目里的调试工具。它能一眼看出是哪一步开始偏的比打断点看变量快得多。第三步定位到具体环节后再决定改法。如果是数据源问题推后端改类型如果是中间计算问题把这段换成整数分运算如果只是最后一公里展示问题加一个roundExact就够了。不要一上来就把全链路都换成十进制库那样改动面太大、风险太高而且往往解决不了真正的问题。我见过有团队为了一个展示层的舍入问题引入了完整的精度库包体积涨了几十 KB结果因为用法不对问题还在。顺带说一句ES2020 起全面支持的BigInt在金额整数运算里非常好用尤其是需要处理超过 2^53 的场景。BigInt不支持小数但支持任意大的整数所以配合整分存储的思路正好互补。唯一要注意的是它不能和 Number 混用1n 1会直接抛TypeError必须写成1n 1n在类型转换那一层要格外小心。