
遇到0.1 0.2 ! 0.3这种事十有八九不是 bug而是 JS 的浮点数精度机制在作怪。我最早是在一个优惠券分摊金额的项目里被它坑了一次一笔订单拆成三份优惠加回来差了 0.01 元财务对账怎么都对不上。后来老老实实用decimal.js把所有金额计算都收了才彻底消掉这类问题。今天就把这套处理精度问题的完整思路、实操写法、还有我在项目里踩过的坑一次讲清楚看完可以直接抄作业。先说这东西适合谁只要你写前端做过购物车、订单金额、预算报表、图表刻度、甚至地图坐标运算大概率都会撞上浮点精度问题。初级开发可以先从这里理解根源有经验的可以直接跳到decimal.js的封装实践和排查清单。1. 先从根上理解为什么 0.1 0.2 不等于 0.3很多人第一次看到console.log(0.1 0.2)输出0.30000000000000004时第一反应是“JS 是不是算错了”。其实不是 JS 在偷懒而是它底层使用的 IEEE 754 双精度浮点格式天生没法精确表示某些十进制小数。1.1 二进制浮点数的存储逻辑计算机内部只有 0 和 1所有数字最终都要转成二进制。整数转二进制很简单但小数转二进制有一个特点只有能拆成2 的负次幂之和的小数才能被精确表示。比如0.5就是2^-1所以计算机能精确存下来0.25是2^-2也能精确存。但0.1呢转成二进制会得到一个无限循环小数0.1 ≈ 0.0001100110011001100110011001100110011001100110011001101...这个循环小数根本写不完。而 JS 的 Number 类型只给浮点数分配了 64 位其中有效数字部分只有 52 位多出来的位只能截断。就像你在一张只能写 7 位小数的纸上写1/3 0.3333333...必然会出现舍入误差。0.1和0.2在存储时就已经各自带了一点误差加在一起误差就被放大了于是表现出0.30000000000000004。提示不仅是 JS任何采用 IEEE 754 的语言如 Java、Python、C#都会遇到同样的精度问题。这不是某个语言的缺陷而是浮点数格式的物理限制。1.2 误差怎么在连续运算中被放大单看0.1 0.2误差只出现在小数点后第 17 位左右很多场景下用toFixed(2)就“掩盖”过去了。但麻烦的是连续运算let price 0.1; for (let i 0; i 10; i) { price 0.2; } console.log(price); // 2.9999999999999996如果你在循环里累加金额、分期、利息误差会一层层叠加最后可能差出整分钱。更隐蔽的是比较场景console.log(0.1 0.2 0.3); // false console.log(0.1 0.2 0.3); // true看起来0.30000000000000004 0.3是 true但如果你在代码里判断“用户支付金额是否达到满减门槛”这类边界比较就可能出现不可预期的分支。1.3 受影响的典型业务场景我把这几年实际遇到过的精度事故场景列一下你可以对照检查自己项目里有没有这些代码场景示例后果订单金额计算商品单价 x 数量 运费 - 优惠对账不平优惠券分摊一张券按比例分到多个商品分摊金额加回不等于原券百分比/折扣原价 * 0.88计算折后价存在一两位误差图表坐标饼图百分比累加图例比例加起来不等于 100%表单校验判断输入是否大于某个阈值边界判断失误报表统计多行明细累加总计总计和小计不一致这些场景共同特点是对精确度有硬性要求尤其是金额。所以我现在的习惯是只要出现了“钱”、“百分比”、“剩余额度”这类词一律不做原生浮点运算。2. 选型思考为什么选 decimal.js 而不是别的方案遇到精度问题很多人第一反应是“那我用toFixed处理不就行了”或者“把小数转成整数再算”。这些思路确实能应付一部分简单场景但实际落地时都有坑。2.1 常见方案的局限性先说toFixed。它的问题是返回值是字符串而且舍入规则在不同场景下不一定符合业务预期。比如(0.1 0.2).toFixed(2)会返回0.30继续参与计算还得转回 Number转回来又可能产生新误差。更致命的是它基于的是已经含误差的结果console.log((0.1 0.2).toFixed(2)); // 0.30 好像没问题 console.log((1.005).toFixed(2)); // 1.00 让人猝不及防1.005用toFixed(2)得到1.00原因是1.005存储的实际值可能比1.005小一点点舍入时就被向下处理了。这在售价计算里就是直接少一分钱。再说“乘以 100 转整数”。整数运算确实避免了小数误差但有两个硬伤。第一有除法回来的时候比如Math.round((0.1 0.2) * 100) / 100中间过程仍然依赖浮点乘法0.1 * 100虽然在常见场景下看着是10但换更大的数或更多次除法时仍可能翻车。第二整数放大只是把误差挪走不是消除一旦遇到超大金额或超高精度要求整数范围也会不够用。最后一种思路是直接判断误差范围function nearlyEqual(a, b) { return Math.abs(a - b) Number.EPSILON; }Number.EPSILON约等于2.220446049250313e-16用它判断“两个数是否几乎相等”在数学上很漂亮但对业务场景来说它只解决“相等比较”问题解决不了“计算结果精确为某个小数”的问题。你总不能让订单金额显示39.599999999999994吧。2.2 decimal.js 的核心优势与适用边界我在对比过big.js、bignumber.js和decimal.js之后选了decimal.js。这几个库都是同一个作者体系的优秀作品风格也接近特性big.jsbignumber.jsdecimal.js体积最小中等略大但功能更全三角函数/对数/开方不支持有限支持支持科学计数法控制基础较好灵活可设阈值精度位数控制有有有且基于十进制文档完善度尚可好非常好它的核心机制是内部用字符串和十进制实数算法来执行计算而不是把数字转成二进制浮点再算。也就是说在 Decimal 的世界里0.1 0.2就是按人类习惯的十进制进位加法算出来的结果精确为0.3。不过我要说清楚decimal.js 不是万能药。它算的仍是“十进制精确”如果你把Math.PI这种本身无法被十进制精确表达的值传进去它也只能按入参的精度去近似。另外它处理超大科学计数法、三角函数时也有自己的舍入策略。所以我的结论是业务计算用它尤其是金额、比例、累加这类场景计算几何、物理模拟这类本身靠浮点近似也无所谓的场景没必要硬上。3. decimal.js 上手实操安装、引入与基础 API这节直接进入能跑的代码。安装和引入只用一分钟关键是后面的 API 习惯要建立起来。3.1 安装与引入Node 或打包器项目npm install decimal.js页面 CDN 直接引入script srchttps://cdn.example.com/decimal.js/script模块化的引入方式// ES Module import Decimal from decimal.js; // CommonJS const Decimal require(decimal.js); // CDN 引入后 // 直接使用全局变量 Decimal注意这里的 cdn 域名我只是举了个示意实际使用时替换成你项目的可用静态资源地址即可。另外有些版本树摇优化对 CommonJS 支持一般建议按包文档推荐的方式引入。3.2 加减乘除与四舍五入配置先看最基础的运算const a new Decimal(0.1); const b new Decimal(0.2); a.plus(b).toString(); // 0.3 a.minus(b).toString(); // -0.1 a.times(b).toString(); // 0.02 a.div(b).toString(); // 0.5注意我故意用了toString()而不是toNumber()。因为Decimal对象内部就是精确的十进制表示转成字符串能看到完整值如果转回 Number精度又会回到浮点那就白算了。常用链式写法new Decimal(10) .plus(0.5) .times(3) .div(7) .toDecimalPlaces(2, Decimal.ROUND_HALF_UP) .toString();toDecimalPlaces(位数, 舍入模式)是业务里最高频的方法。Decimal.ROUND_HALF_UP就是“四舍五入”对应日常生活中金额结算的习惯。除此之外还有ROUND_DOWN截断、ROUND_UP向上等。四舍五入还有一个快捷方式toFixed()new Decimal(1.005).toFixed(2).toString(); // 1.01 而不是 1.00这就是我说它比原生Number.prototype.toFixed可靠的原因。3.3 保留精度、格式化输出的常见写法项目中我经常把这几个方法混用const amount new Decimal(1999.90); // 保留两位小数四舍五入 amount.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toString(); // 1999.90 // 保留两位小数直接截断 amount.toDecimalPlaces(2, Decimal.ROUND_DOWN).toString(); // 1999.90 // 转成 Number仅用于和第三方库的接口对接 amount.toNumber(); // 1999.9还有一个容易被忽略的 String 入参问题new Decimal(0.1).plus(0.2); // 可以得到 0.3 new Decimal(0.1).plus(0.2); // 同样得到 0.3 new Decimal(19.90).toString(); // 19.9从字符串构造时19.90会被规范化成19.9这是合理的显示层再补零即可。但如果你从 Number 构造比如new Decimal(19.9), 它其实是从一个已经含误差的二进制浮点去转换可能不是最理想的入参方式。所以我在封装工具函数时通常要求外部传入字符串或整数字符串至少是“看起来安全”的小数整体效果会稳很多。4. 项目落地中的关键配置与封装实践理论都通了但真正上项目还是要有一套统一封装否则每个人各自new Decimal()风格混乱精度配置也不一致后面维护会很痛苦。4.1 全局精度、舍入模式与常用选项设置Decimal提供了一个全局配置对象Decimal.set()通常放在项目入口初始化一次import Decimal from decimal.js; Decimal.set({ precision: 20, // 有效数字位数默认 20金额场景 20 一般够用 rounding: Decimal.ROUND_HALF_UP, // 全局默认舍入模式四舍五入 toExpNeg: -9, // 小到多少位开始显示科学计数法-9 意思是小于 1e-9 才显示 toExpPos: 20 // 大到多少位开始显示科学计数法 });这里的precision是参与运算时保留的有效数字位数。它不限制你最终要显示的小数位数而是控制计算过程不丢失精度。我默认设 20绝大多数金额、百分比运算都够用如果你做的金融级系统要保留更多小数或更大数位可以改成 40 甚至 100只是性能和内存会略有上升。toExpNeg和toExpPos是控制“什么时候把数字显示成科学计数法”的阈值。默认值在某些场景下会出现1e-7这类中间结果对人不友好所以我把负阈值调低一点正阈值调高一点。4.2 封装一套业务可用的计算工具函数我给的这套封装核心就一句话所有进入业务层的计算必须走统一函数不允许裸写、-、*、/。可以参考下面这个版本import Decimal from decimal.js; // 初始化 Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP, toExpNeg: -9, toExpPos: 20 }); // 核心把入参转成 Decimal兼容字符串、数字、Decimal 实例 function toDecimal(value) { if (value instanceof Decimal) return value; if (typeof value number !Number.isFinite(value)) { return new Decimal(0); } if (value null || value undefined || value ) { return new Decimal(0); } return new Decimal(value); } export function add(a, b) { return toDecimal(a).plus(toDecimal(b)).toString(); } export function subtract(a, b) { return toDecimal(a).minus(toDecimal(b)).toString(); } export function multiply(a, b) { return toDecimal(a).times(toDecimal(b)).toString(); } export function divide(a, b) { const divisor toDecimal(b); if (divisor.isZero()) { return 0; // 或者主动抛错看业务要求 } return toDecimal(a).div(divisor).toString(); } // 四舍五入到指定小数位默认 2 位 export function roundTo(value, decimalPlaces 2) { return toDecimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toString(); } // 比较 export function equals(a, b) { return toDecimal(a).eq(toDecimal(b)); } export function greaterThan(a, b) { return toDecimal(a).gt(toDecimal(b)); } export function lessThan(a, b) { return toDecimal(a).lt(toDecimal(b)); } // 金额汇总 export function sum(values) { return values.reduce((acc, cur) acc.plus(toDecimal(cur)), new Decimal(0)).toString(); }这套封装有几个好处所有函数都返回字符串避免中途转回 Number 带来二次精度损失。入参容错处理统一传null、undefined不会直接炸。比较逻辑统一不会出现0.1 0.2 0.3判断失败。在 Vue 或 React 里可以直接引入这些函数模板中算出结果再展示import { add, roundTo } from /utils/decimal; const total add(19.90, 8.80); // 28.70 const display roundTo(total, 2); // 28.704.3 在框架中使用时的注意事项如果项目里大量使用响应式状态需要注意一个点别把 Decimal 实例直接塞进 Vue 的 reactive 或 React 的 state然后在模板里直接渲染。我在一个 Vue 项目里这么干过ref(new Decimal(0))模板里显示的是整个对象结构没法正常展示。必须靠toString()、toFixed()或toNumber()转成可渲染值。于是我更建议的状态管理方式是全局状态里存字符串展示层计算后再格式化。比如购物车清单每项金额用字符串19.90存计算合计时走封装函数合计结果也是字符串。这样既精确又方便序列化。另一个和框架相关的坑是表单输入。用户输入的价格是字符串但如果你在 watch 里做了Number(inputValue)再计算字符串转数字又埋了一次精度隐患。所以表单的原始值尽量保留字符串计算时才转 Decimal。5. 常见问题与排查技巧实录这里把我实际开发里遇到的高频问题和排查思路整理成一份速查表遇到类似问题可以直接对照。5.1 结果还是不对先把问题定位到“哪个环节引入误差”如果用了 decimal.js 结果还不精确我第一反应不是怀疑库而是怀疑数字在哪个环节先从 Decimal 变成了 Number。典型排查路径检查所有运算中间量是否有人把 Decimal 转成了 Number 又参与后续计算。检查入参来源是不是从第三方 SDK、后端接口直接拿到了 Number 类型的大数或带精度小数。检查代码里有没有混用原生toFixed在 Decimal 计算后补了个Number(result.toFixed(2))。检查有没有靠JSON.stringify序列化 Decimal 实例然后反序列化回来变了样。我遇到过最典型案例后端返回金额单位是“分”我转成“元”后用Number((cents / 100).toFixed(2))处理了一下再传给封装函数。人家本来精确让我一转换又带上了浮点尾巴。经验只要在“数值输入”和“数值输出”之间穿过一次 Number精度问题就可能重新出现。规则是想办法让 String 贯穿整条计算链。5.2 与 Number 和 String 交互时的隐藏坑首先new Decimal(true)不会报错true会被当成 1这类弱类型输入建议在封装层过滤避免业务代码意外传进布尔值。其次超大数字如果直接用 Number 传入比如new Decimal(123456789012345678901234567890)因为 Number 本身已经丢精度了Decimal 能做的就是“基于已经丢失的数再精确”这个坑比较隐性new Decimal(123456789012345678901234567890) // 实际可能变成 123456789012345680000000000000 之类的值所以金额计算时我从不让用户直接吃 Number而是建议接口层传字符串。我常在接口返回结构里就把金额统一成1999.90这种格式。再来一个和toNumber()有关的坑。Decimal是精确的但toNumber()会把它转换回 IEEE 754 浮点一旦你用它去和原生数字比较或进一步计算原本精确的结论就没了new Decimal(0.3).toNumber(); // 0.3 这个看起来没问题 // 但 console.log(0.1 0.2 0.3) 依然是 false所以我在封装函数里默认都返回字符串除非对接第三方图表库不得不传 Number才会在最后一步转。5.3 性能与请求频次控制有人担心 Decimal 运算会不会比原生慢很多。慢是肯定的因为它是字符串模拟十进制运算不是 CPU 的浮点指令级计算。但绝大多数业务场景单次计算几微秒到几十微秒的差别用户完全无感。我实际压过一万次Decimal.plus大约在几十毫秒量级完全能撑住页面交互。真正的性能瓶颈场景是大数据量表格里每行十几列都调 Decimal 做复杂计算。实时行情、高频轮询接口每个 tick 都做大量高精度计算。这种时候优化方向有两个一个是能合并计算的尽量合并比如汇总用sum([...])而不是循环内多次new Decimal()另一个是不要频繁创建 Decimal 实例复用一个已存在的实例调用方法或至少不要在一个循环里反复new Decimal。在 React Native 或小程序环境里还要注意安装体积。decimal.js大小在几十 KB 级别gzip 后更小比很多 UI 库轻得多一般不会成为体积瓶颈。5.4 我这些年攒下的几条实操心得这几条不算什么高深理论但确实帮我少写了不少 bug。第一金额一律以字符串为唯一可信输入。不管后端给的是数字还是字符串在前端进入业务逻辑之前统一转成字符串存到状态里。这样做最大好处是排查方便出了问题直接看值就知道对不对。第二比较大小也用封装函数。尤其满减、门槛判断这类逻辑不要写if (a b)哪怕你觉得a和b都是普通数字。因为一旦某天a变成了 Decimal 实例原生比较会直接变成字符串比较或地址比较结果非常迷惑。第三别把 Decimal 当普通数字传给后端。有人说“后端反正能处理”但很多后端脚本语言对超大数值也有精度限制。稳妥做法是转成字符串或整数分传给后端再由后端自己处理。第四测试用例里固定放一批“精度钉子”。我维护的工具函数测试里永远包含这样一组用例expect(add(0.1, 0.2)).toBe(0.3); expect(multiply(0.14, 100)).toBe(14); expect(subtract(1, 0.99)).toBe(0.01); expect(roundTo(1.005, 2)).toBe(1.01); expect(equals(0.1, 0.1)).toBe(true); expect(greaterThan(0.30000000000000004, 0.3)).toBe(true);每次重构或升级工具函数先跑这些用例基本上就放心了。第五团队的代码规范里要写清楚“什么时候不能用 Decimal”。不是所有数字都要高精度。比如动画帧率、性能统计、随机数这类本身基于近似计算的场景强行用 Decimal 只会拖慢速度毫无收益。边界越清晰团队执行越容易。写在最后的经验拆解如果你习惯用“整数分 四则运算”的方式处理金额在绝大多数场景下确实可行可一旦出现“按比例分摊”、“汇率换算”、“优惠均摊”这类运算整数方案很难优雅因为你总是绕不开除法和循环取整。我现在的习惯是“金额统一走 Decimal 字符串 封装工具函数”这条路虽然前期要把所有计算点都改一遍有点麻烦但改完之后几乎再没遇到过金额对不上的头疼事。另外再给一个小技巧如果项目里已经大量使用了原生运算没法一次性全改可以先挑“风险最高”的模块改比如订单结算、余额充值、优惠计算。改的时候把封装函数暴露到全局或统一模块后续新代码一律走封装老代码逐步替换这样不会在重构期延误业务发版。精度问题不会因为你无视它就消失但确实可以通过一层清晰的封装把它彻底挡在业务逻辑之外。