☰
彻底解决JavaScript浮点数精度问题:从原理到decimal.js实战
2026/10/18 21:54:56 网站建设 项目流程

前阵子在排查一笔线上订单金额差异时,发现前端计算总价用的还是老一套19.9 + 6.5,控制台输出了25.400000000000002,入库前再经过toFixed(2)四舍五入,和后端按"分"算出来的结果就对不上了。这种问题在 JavaScript 里几乎天天发生,凡是碰过金额、统计报表、图表的前端,多少都跟它打过照面。要真正解决它,不能靠零散的补丁,而是要从原理上理解浮点数为什么丢精度,再选一个靠谱的高精度计算插件来兜底——比如标题里这个decimal.js。这篇文章我会从二进制浮点数的根源讲起,再把 decimal.js 的用法、选型、踩坑点完整过一遍,尽量让不同基础的读者都能直接落地到业务里。

1. 0.1 + 0.2 !== 0.3:先弄清楚 JS 的精度到底丢在哪一环

1.1 二进制浮点数天生表达不了 0.1

很多新人第一次看到0.1 + 0.2 === 0.30000000000000004时,第一反应是"JS 是不是有 Bug"。其实这件事跟 JavaScript 本身没什么关系,根子在 IEEE 754 双精度浮点数标准上。

JS 里的 Number 类型统一用 64 位双精度浮点存储,其中 1 位符号位、11 位指数位、52 位尾数位。这个结构决定了它只能表示有限个二进制小数。十进制里的 0.1 转成二进制会变成一个无限循环小数:

0.1 = 0.0001100110011001100110011001100110011001100110011001101...

52 位尾数装不下无限循环,计算机只能截断,于是内存里的 0.1 并不是真正的 0.1,而是一个非常接近 0.1 的近似值。0.2、0.3 同样都是近似值,两个近似值相加,误差就会暴露出来。

可以拿十进制做个类比:我们平时写1 / 3 = 0.333...,如果你只保留 10 位小数,那0.3333333333 * 3 = 0.9999999999,永远不等于 1。二进制浮点数对 0.1 的处境一模一样,只不过它是在二进制里无限循环。

理解了这层原理,你就会明白:这不是某个浏览器或某个引擎的问题,而是所有遵循 IEEE 754 的语言都可能出现的现象。Python 里的 float、Java 里的 double、C 语言里的 double,底层都逃不掉。区别在于很多语言提供了内置的Decimal类型来绕开它,而 JS 原生没有,只能引入第三方库。

还要注意,不是所有小数都有这种问题。0.5、0.25、0.125 这类分母是 2 的幂的小数,二进制可以有限表示,并不会丢精度。真正容易踩雷的,是 0.1、0.2、0.7 这类十进制分母里含有 5 的数。

1.2 崩溃现场:不只是 0.1 + 0.2

我把几个典型例子整理成了表格,方便你直接拿去给同事当"反例"看:

表达式实际结果说明
0.1 + 0.20.30000000000000004最著名的反面教材
0.3 - 0.10.19999999999999998减法同样有问题
0.7 * 0.60.41999999999999993乘法不是"精确翻倍"
19.9 * 1001989.9999999999998金额换算到"分"时翻车
(10 次 0.1 累加)0.9999999999999999循环累加的误差会被放大
2 ** 53 + 19007199254740992整数超出安全范围也会丢

最后一行值得多说一句:JS 的安全整数上限是Number.MAX_SAFE_INTEGER,也就是2 ** 53 - 1。超过这个值以后,连续整数之间就不再是一一对应的了。所以后端返回非常大的 ID 时,官方始终建议用字符串传输,这跟本文的金额精度问题同根同源。

还有个很容易误导人的细节:有时候你在控制台输入0.1 + 0.3,得到的是0.4,看起来一切正常。这不是 JS 变聪明了,而是浏览器打印数字时会自动选择"能唯一标识这个浮点数的最短十进制字符串",比较巧的误差就被隐藏了。隐藏不代表不存在,一旦进入后续运算,误差仍然会累积,只是早晚暴露的问题。

1.3 toFixed、乘 100、EPSILON:这些"应急方案"为什么治标不治本

很多项目遇到精度问题时,第一反应是给结果加一个toFixed(2)。我能理解这个操作,因为展示层确实需要保留两位小数,但它本质上只是把误差"遮住"了,并没有消除中间过程的偏差。比如0.615.toFixed(2)在某些浏览器里会得到"0.61",因为内存里存的其实是0.6149999999999999...;这种出乎意料的结果,恰恰说明toFixed本身也是建立在浮点数误差之上的。

另一种常见做法是把金额乘以 100,转成"分"再用整数计算。在金额不超过 2^53 且固定两位小数的范围内,这招确实有效,我以前也这么干过。但它有天然的局限性:一旦遇到利率、折扣、百分比这些多位小数的场景,乘法单位就不成立了;遇到19.9 * 100这种反直觉结果时,方案直接崩掉。而且价税分离、分摊、复利一出现,"转整数"的思路会越来越绕。

还有人会用Number.EPSILON判断近似相等:

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true

Number.EPSILON是2 ** -52,表示的是"相对 1 的精度"。当数字很小或很大时,这个阈值并不合理;而且它只解决了"判断相等"的问题,对于求和误差累积、逐项分摊尾差完全无能为力。判断通过不等于结果正确。

所以结论很清晰:业务上需要"精确十进制"时,与其反复修补浮点数,不如直接换一套十进制计算体系。这也是我下面要展开的核心思路。

2. decimal.js 的解题思路:把二进制浮点换成十进制小数记账

2.1 底层是怎么"精准记账"的

decimal.js 没有去改进二进制浮点的算法,而是换了一个存储模型:把数字拆成"系数 + 指数"的形式,用十进制整数序列来记账。举例来说,0.1会被保存成类似1 × 10^-1的结构,0.2保存成2 × 10^-1,它们的加减法会像小学竖式一样先对齐小数点,再逐位相加;乘法则是系数相乘、指数相加。整个过程都发生在十进制整数域里,不存在二进制近似值,所以0.1 + 0.2可以干净地得到0.3。

如果你用过早期的编程课里"字符串实现大数加法",就能理解这种思路:数字的每一位都被拆开管理,而不是塞进一个固定宽度的小数槽里。decimal.js 只是把这个思路商业化、工程化,做成了稳定可靠的库。

当然,"精确"不是无限的。decimal.js 默认保留 20 位有效数字,超过部分按舍入规则处理。20 位对绝大多数金额、比例、统计业务已经绰绰有余,如果还不够,可以通过配置提升精度,但要注意计算性能会随精度上升而下降。

我这里给一个最简单的类比:原生 Number 像一台只能算近似值的计算器,decimal.js 更像一本账本,每一位都按十进制规则记账,最后对账时干干净净。

2.2 三分钟上手:安装、构造、四则运算

引入方式很简单,从包管理器安装之后,直接导入即可:

npm install decimal.js
import Decimal from 'decimal.js'; 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' a.comparedTo(b); // -1 a.equals(b); // false

需要注意几个地方:

  • Decimal实例不是一个原始数字,它的运算结果也全是新的Decimal实例,原对象不会被修改。
  • 要拿回普通字符串或数字,分别用toString()和toNumber()。
  • 加减乘除的方法名是plus、minus、times、div,不是+、-、*、/,写代码时别顺手用符号运算符。

关于构造函数传数字还是传字符串,这里先给结论:业务里的金额、比例字段,尽量以字符串进入Decimal。具体原因后面踩坑篇会详细讲。

2.3 精度和舍入规则:默认值不等于永恒值

decimal.js 有两个全局配置很关键:precision和rounding。

Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP });

precision表示有效数字位数,不是固定的小数位数,默认是 20。rounding决定当结果超过精度时怎么舍入。它提供了 7 种舍入模式,我把常用场景列在下面:

常量含义典型场景
ROUND_UP远离零取整分页、额度预留
ROUND_DOWN趋向零截断直接丢弃小数部分
ROUND_CEIL向正无穷取整向上计费
ROUND_FLOOR向负无穷取整折扣下限控制
ROUND_HALF_UP四舍五入通用金额
ROUND_HALF_DOWN半数趋零特定统计口径
ROUND_HALF_EVEN银行家舍入会计、金融统计

银行家舍入是"四舍六入五成双",规则是:5 前面的数字是奇数就进位,是偶数就舍去。这能减小批量统计时的系统性偏差,财务系统里经常见到。

实际使用中,toFixed也可以单独传入舍入模式:

new Decimal('1.005').toFixed(2, Decimal.ROUND_HALF_UP); // '1.01' new Decimal('1.004').toFixed(2, Decimal.ROUND_HALF_UP); // '1.00'

注意1.005这种数字,原生Number处理时经常四舍五入成1.00,但 decimal.js 可以得到正确的1.01。这就是为什么涉及价格校验、退款金额的地方,不能只靠原生toFixed。

3. 落到实际业务:金额、分摊、合计中 decimal.js 的标准用法

3.1 购物车结算:每一步都别出 Decimal 体系

我们看一个真实的购物车结算场景:商品单价 19.9 元,购买 3 件,促销打 9 折,运费 6.5 元。原生写法大概是19.9 * 3 * 0.9 + 6.5,每一步都有误差隐患。用 decimal.js 写是这样:

const price = new Decimal('19.9'); const count = new Decimal('3'); const discount = new Decimal('0.9'); const shipping = new Decimal('6.5'); const total = price .times(count) // 59.7 .times(discount) // 53.73 .plus(shipping); // 60.23 total.toFixed(2); // '60.23'

这里的关键不是最后一位数字恰好正确,而是每一步都在十进制体系内完成,没有中间误差。你可能觉得"反正最后toFixed(2)会修正",但在误差累积到一定程度后,toFixed(2)本身也会被污染。更麻烦的情况是金额相等判断、退款校验、批量优惠计算,这些场景不允许有"碰巧修正"。

我自己在项目里的体会是:一旦涉及"钱",整个计算链路都不能出现普通 Number。入口从字符串进来,中间用 Decimal,出口用字符串,全程不跨界。只要中间漏掉一个环节用原生数字做了运算,后续再精确的库也救不回来。

3.2 百分比分摊:用精确取整解决尾差

分摊是财务场景里最考验精度的问题之一。比如总金额 1000 元,A、B、C 三个部门分别占 33.3%、33.3%、33.4%,最后要精确到分,而且分配结果加起来必须等于 1000 元。

直接1000 * 0.333 = 333,1000 * 0.334 = 334,加起来是 1000,看起来很顺利;可一旦百分比是33.333%、33.333%、33.334%,每位取整后总和就可能不等。此时可以用"逐项向下取整 + 最后一项兜底"的套路:

const total = new Decimal('1000'); const ratios = ['0.33333', '0.33333', '0.33334']; const allocated = []; let used = new Decimal(0); for (let i = 0; i < ratios.length - 1; i++) { const part = total .times(ratios[i]) .toDecimalPlaces(2, Decimal.ROUND_DOWN); allocated.push(part); used = used.plus(part); } // 最后一项兜底,保证总和严格等于 total allocated.push(total.minus(used));

为什么要用ROUND_DOWN而不是四舍五入?分摊场景里,我们希望把精度误差集中到最后一笔,而不是平均撒在每一笔上。四舍五入会让每笔都可能有微小偏差,最后一笔兜底时反而难以对账。这个思路跟会计里的"尾差处理"是一致的:前几笔先按确定规则取整,最后一笔用减法找平。

3.3 前端表格合计与展示:toString 才是关键

前端表格里常见的合计逻辑,很多人会写成:

rows.reduce((acc, row) => acc + row.price * row.qty, 0);

一旦价格带小数,这个 reduce 的结果很难保证精确。正确的打开方式是所有中间量都用 Decimal:

const total = rows.reduce( (acc, row) => acc.plus(new Decimal(row.price).times(row.qty)), new Decimal(0) ).toFixed(2);

注意初始值必须是new Decimal(0),而不是0,否则第一次累加时 acc 还是普通 number,整个体系就破了。

展示和序列化也有细节。Decimal 实例调用JSON.stringify时,会自动使用实例上的toJSON()方法,输出的是字符串而不是对象。如果你想提交给后端的金额是一个 JSON number,需要显式toNumber();但更推荐直接提交字符串,因为转成 number 的一瞬间,精度就到头了。

4. 选型纠结:decimal.js、bignumber.js、big.js 到底怎么选

4.1 一张表看清三者的边界

很多人在项目里看到这三个库会懵,它们的名字太像了。我整理了一张对比表:

特性big.jsbignumber.jsdecimal.js
包体积最小(几 KB 级别)中等较大(功能最全)
四则运算与比较有有有
三角函数、幂、对数无部分有
舍入模式枚举数字多种模式7 种ROUND_*常量
API 风格方法式方法式链式和方法式
典型定位轻量金额处理中大型项目均衡选择金融、科学计算全覆盖

说句实话,如果只是为了避免0.1 + 0.2 !== 0.3这种基础问题,big.js就够了,体积小、逻辑简单,包体敏感时优势明显。如果你的项目里需要算百分比、复利、开根号、对数,decimal.js是最省心的,它把数学函数都做齐了。bignumber.js则是中间档,和decimal.jsAPI 风格很像,但功能稍少。

我个人的选型标准很简单:项目里有明确的金融语义,业务方会反复核对金额,选 decimal.js;个人小工具、时间紧、包体积敏感,选 big.js;团队内部对 API 风格有历史依赖,选 bignumber.js。本文所有示例都以 decimal.js 展开,但思路是通用的。

4.2 性能实测与优化策略

decimal.js 确实比原生 Number 慢,这是绕不开的事实。误差换来了精度,性能自然有代价。但在常规业务场景里,一次性计算几十、几百次,完全无感;只有在大数据统计、图表滚动重算、循环十万次的地方,性能才会凸显。

我自己做过一个粗糙对比:

console.time('number'); let n = 0; for (let i = 0; i < 100000; i++) n += 0.1; console.timeEnd('number'); console.time('decimal'); let d = new Decimal(0); const tenth = new Decimal('0.1'); for (let i = 0; i < 100000; i++) d = d.plus(tenth); console.timeEnd('decimal');

在我的电脑上,原生 Number 十万次加法几乎一瞬间完成,Decimal 大概要几十毫秒。看起来差距很大,但换算到用户操作层面,几百次计算根本感知不到。真正要注意的是不要在大循环里反复new Decimal(string),每次构造都有解析成本。把常量提出来复用,是性价比最高的优化:

const TEN_CENT = new Decimal('0.1'); for (let i = 0; i < 100000; i++) { d = d.plus(TEN_CENT); }

另一个实用优化是:固定两位小数的账务,可以全部换算成"分"整数计算,最后再用 Decimal 除以 100。整数在 2^53 范围内完全精确,运算速度接近原生。不过要保证过程中没有超出安全整数范围,否则还得回 Decimal。

4.3 场景边界:什么情况下不该上 decimal.js

decimal.js 不是万能钥匙。温度、GPS 坐标、物理模拟这类数据,本身就有测量误差和容差,原生 Number 处理完全合理,硬上 Decimal 只会降低性能和可读性。数据可视化里动辄十万个数据点要做归一化,逐点构造 Decimal 也不划算,生命周期和内存压力都扛不住。

还有一类场景是只想要千分位格式化,不需要计算精度。这种直接用Intl.NumberFormat或手写格式化函数即可,没必要引入整个库。

工具要用在刀刃上。精度问题集中在"钱、比例、统计尾差"这一类强语义场景,把它们交给 decimal.js,其他的让 Number 继续用,项目才能保持轻快。

5. 实际踩坑清单:decimal.js 用不对,照样翻车

5.1 构造函数传数字 vs 传字符串

这是我在代码审查里见过最多的问题。

new Decimal(0.1)和new Decimal('0.1')看起来差不多,结果却可能完全不同。传数字时,这个 0.1 已经在内存里是二进制近似值了,decimal.js 拿到的是"一个非常接近 0.1 的数字",再怎么精确也无法还原成真正的 0.1。传字符串时,它从第一个字符开始按十进制解析,得到的是严格意义上的 0.1。

这也是为什么接口返回的 JSON 里如果金额是 number 类型,前端要格外小心:数字在 JSON 序列化时可能已经失真。正确姿势是后端把金额、比例字段作为字符串返回,前端统一用字符串构造 Decimal,最后输出也是字符串。

看一个反面教材:

new Decimal(parseFloat('0.1').toFixed(2)); // 先转数字,再格式化成字符串,入口已经污染

这种写法每经过一次 Number 操作,精度就折损一次。正确方式很简单:如果字段本身是字符串,就直接new Decimal('0.1'),不要先parseFloat再传回去。

通用约定:金额字段在接口里用字符串传输,前端用 Decimal 处理,展示用toString()或toFixed(2)。后端大整数 ID 也一样。

5.2 中途溜出去一个 Number,全盘皆输

Decimal 实例和普通 number 做运算时,decimal.js 会自动把 number 转换进去。这个"智能"有时候会掩盖问题:你传入 number 时,它可能已经不是精确值了。比如:

new Decimal('0.1').plus(0.2);

这个写法里,0.2首先是一个普通 number,它在内存里已经是近似值。Decimal 虽然努力去转换它,但转出来的结果和精确的'0.2'之间可能已经有偏差。最稳妥的写法是两边都给字符串或 Decimal 实例:

new Decimal('0.1').plus('0.2');

在团队协作里,我强烈建议代码规范里明确:金额计算链路上不允许出现parseFloat、Number()、一元加号等方式。只要出现一个,整条链路的精度保证就要打个问号。

5.3 toFixed 的返回值是字符串,不是数字

new Decimal('1.005').toFixed(2)返回的是字符串'1.01',不是数字。有人拿到这个结果后继续做算术:

const total = new Decimal('1.005').toFixed(2) + 1; // '1.011'

这是一个经典的字符串拼接陷阱。正确写法是,需要继续计算就用 Decimal 的plus,只有到展示或提交前最后一刻才转字符串。

toNumber()也有类似问题。它把 Decimal 转回原生 number,精度会立刻回到浮点数世界。所以它只适合用来对接第三方组件,比如图表库的数值轴、某些只认 number 的组件;不要拿它做计算桥梁,更不要转完再转回来。

5.4 全局 set 是双刃剑

Decimal.set({ precision: 30, rounding: Decimal.ROUND_HALF_UP })是全局配置,会影响项目里所有 Decimal 实例。看起来方便,实际很危险:如果某个第三方库内部也用了 decimal.js,你的配置会改变它的运行行为;反过来,第三方库改配置也会波及你的计算。

更可控的做法是只在独立模块里设置一次,并且明确注释用途;或者在每次计算时显式传入舍入模式,比如toDecimalPlaces(2, Decimal.ROUND_HALF_UP)。不要在每个业务文件里随手Decimal.set,那会让全局状态变成一团迷雾。

5.5 比较、排序、序列化的隐蔽细节

判断两个 Decimal 是否相等,用equals方法,不要用toString()之后===字符串比较。虽然多数情况下等价,但new Decimal('0.10').toString()返回'0.1',字符串格式与数值相等是两回事,容易引入格式歧义。

排序时用comparedTo:

items.sort((a, b) => new Decimal(a).comparedTo(b));

直接写成a - b会把 Decimal 强制转成 number,等于把精度又丢回浮点数。

序列化方面,JSON.stringify会自动调用 Decimal 实例的toJSON()方法,输出字符串。如果接口契约要求 number,需要显式toNumber()或自定义toJSON。但大多数场景我会建议提交字符串,因为后端拿到字符串再精确解析,比前端转 number 后再传更可靠。

还要提一句脏数据防御。后端可能返回'0.00'、''、null、甚至'abc'。统一在进入 Decimal 前做一次清洗,比如空字符串当作 0,非法值直接提示或替换成 0。decimal.js 提供了isNaN()、isFinite()、isPositive()等判断方法,可以配合使用,避免在一次计算链里炸掉。

6. 团队落地建议:把它封装进工具层,而不是散落各处

6.1 统一 money 模块

如果项目里每个人都各自new Decimal(...),很快就会失控:有人传字符串,有人传 number,有人中途toNumber(),精度纪律形同虚设。我习惯在utils/money.js里封装一层薄薄的工具:

import Decimal from 'decimal.js'; export const add = (a, b) => new Decimal(a).plus(b); export const subtract = (a, b) => new Decimal(a).minus(b); export const multiply = (a, b) => new Decimal(a).times(b); export const divide = (a, b) => new Decimal(a).div(b); export const round = (value, dp = 2, rm = Decimal.ROUND_HALF_UP) => new Decimal(value).toDecimalPlaces(dp, rm); export const equals = (a, b) => new Decimal(a).equals(b);

这样业务代码里调用的是语义明确的方法,没有人需要知道 Decimal 内部细节。同时所有函数都接收字符串或 Decimal,尽量不接收普通 number,从入口处就卡死精度风险。

6.2 给测试留一条精度断言后门

写单元测试时,很多人会写成:

expect(add('0.1', '0.2')).toBe(0.3);

这个断言大概率失败,因为add返回的是 Decimal 实例,而不是 number。也有人为了通过测试改成:

expect(add('0.1', '0.2').toNumber()).toBe(0.3);

这更糟糕,因为toNumber()把精度又丢回浮点数世界,等于你测的是"近似值是否约等于 0.3",完全没验证 decimal.js 的精确能力。正确做法是用字符串断言:

expect(add('0.1', '0.2').toString()).toBe('0.3'); expect(round('1.005', 2).toString()).toBe('1.01');

字符串断言才能真实反映业务期望的十进制结果。我在多个项目里反复踩过这个坑,现在写测试都会专门留一条"精度断言"用例,保证回归测试时能第一时间发现有人把金额链路改成原生 Number。

最后再说一点实际体会:把 decimal.js 引入项目不难,难的是让所有人都遵守同一套"字符串进、字符串出、中间不碰 Number"的纪律。我在代码审查时,会重点盯金额计算链路里有没有.toNumber()、parseFloat、Number()这类操作;只要发现一个,基本就可以让写这段代码的人重构一遍。因为精度问题从来不是靠某个库单方面解决的,而是靠一套统一的工程约束,让每一个步骤都在十进制体系内完成。这样才能让那些"0.1 + 0.2"的诡异数字彻底远离线上账单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询