先记住这个答案
JavaScript 的 Number 统一用 IEEE 754 双精度浮点数存储,只有 53 位有效二进制位。0.1 和 0.2 换成二进制都是无限循环小数,存储时只能截断成最接近的近似值。0.1 实际存的比 0.1 大一点,0.2 也大一点,两个误差加起来,结果比 0.3 的近似值还要大一档,于是得到 0.30000000000000004。比较浮点数时要判断两者之差是否足够小,金额这类必须精确的场景干脆转成整数分再算。
- 0.1 在二进制里是无限循环小数,只能近似存储
- 双精度浮点都可能出现相同误差
- 比较用容差,容差要随数值大小缩放
- 金额优先用整数最小单位计算
误差到底从哪来
十进制的 1/3 写不尽,只能写成 0.3333…。二进制也一样,凡是分母不是 2 的幂的小数都写不尽。0.1 就是 1/10,分母里有 5,所以在二进制里是 0.000110011001100… 无限循环。
console.log(0.1 + 0.2)
console.log(0.1 + 0.2 === 0.3)
// toPrecision(20) 显示内存里真实存的值
console.log((0.1).toPrecision(20))
console.log((0.2).toPrecision(20))
console.log((0.3).toPrecision(20))
console.log(0.5 + 0.25 === 0.75) // 能用 2 的幂精确表示的小数没有误差查看输出与解释
0.30000000000000004
false
0.10000000000000000555
0.20000000000000001110
0.29999999999999998890
true平时打印 0.1 显示的是 0.1,是因为引擎输出时会找「能唯一还原这个浮点数的最短十进制」,不代表存的就是精确的 0.1。用 toPrecision(20) 多看几位,真相就出来了。
0.1 和 0.2 都偏大,0.3 的近似值却偏小,所以 0.1 + 0.2 的结果和 0.3 落在了两个相邻但不同的浮点数上,=== 自然是 false。反过来,0.5 和 0.25 分别是 1/2 和 1/4,二进制能精确表示,相加没有任何误差。
- 这不是 JavaScript 的 bug
- Java 的
double、Python 的float、C 的double算0.1 + 0.2结果都一样,因为它们用的是同一个标准。JavaScript 的特殊之处只在于它没有单独的整数类型,所有数字默认都是浮点数,所以这个问题更容易被碰到。
比较和计算的正确姿势
常见的答案是「差值小于 Number.EPSILON 就算相等」。这个说法只在 1 附近成立。浮点数越大,相邻两个值之间的间距越大,1000 附近的间距已经是 Number.EPSILON 的好几百倍,固定阈值就不够用了。
function nearlyEqual(a, b) {
// 相对误差:数值越大,允许的误差跟着变大
const scale = Math.max(1, Math.abs(a), Math.abs(b))
return Math.abs(a - b) <= Number.EPSILON * scale
}
console.log(nearlyEqual(0.1 + 0.2, 0.3))
// 固定用 EPSILON 当阈值,数一大就失效
console.log(Math.abs(1000.1 + 0.2 - 1000.3) < Number.EPSILON)
console.log(nearlyEqual(1000.1 + 0.2, 1000.3))
// 金额转成「分」时,乘 100 本身也会带误差
console.log(1.1 * 100)
console.log(Math.round(1.1 * 100) + Math.round(2.2 * 100)) // 单位:分查看输出与解释
true
false
true
110.00000000000001
330nearlyEqual 把容差乘上两数中较大的绝对值,这样不论数字大小,允许的误差都在同一个相对量级。业务里如果精度要求没那么高,比如坐标比较,直接用一个业务容差(如 1e-6)也完全可以,关键是别拿 === 比。
金额的做法是后端直接下发整数分,前端全程用整数算,只在展示时除以 100。如果拿到的是元,转换时一定要 Math.round,因为 1.1 * 100 本身就是 110.00000000000001。更复杂的场景,比如多次乘除、汇率换算,用 decimal.js 这类十进制库,或者整数部分超出安全范围时改用 BigInt。
- 展示时用
toFixed要小心 (0.1 + 0.2).toFixed(2)能得到'0.30',用来展示没问题。但toFixed返回的是字符串,而且它按真实的二进制值舍入,(1.005).toFixed(2)是'1.00'。所以它只适合做最后一步的显示格式化,不适合拿来「修正」计算结果。
容易答错的地方
- 以为所有小数都有误差
- 只有分母不是 2 的幂的小数才存不准。0.5、0.25、0.125 都能精确表示,整数在
Number.MAX_SAFE_INTEGER以内也全是精确的。说「浮点数都不精确」不对,面试官会追问哪些是精确的。 - 用
Number.EPSILON当万能阈值 Number.EPSILON是 1 和下一个可表示浮点数之间的差,只描述 1 附近的精度。拿它去比较 1000 左右的数,两个其实「相等」的结果也会被判成不等,上面的代码已经演示了。- 用
toFixed或parseFloat修正精度 parseFloat((0.1 + 0.2).toFixed(10))看起来能得到 0.3,但它只是把显示结果截短了,中间每一步计算的误差还在,而且toFixed自己也有舍入问题。计算过程中要么用整数,要么用十进制库。
面试官还会怎么问?
Number.MAX_SAFE_INTEGER 为什么是 2 的 53 次方减 1?
双精度浮点数有 52 位尾数,加上隐含的一位 1,一共 53 位有效二进制位。在 2 的 53 次方以内,每个整数都能精确表示;超过之后,相邻浮点数的间距变成 2,2 ** 53 + 1 就存不下了,会被舍成 2 ** 53。
后端返回的 ID 超过 16 位,前端拿到后变了怎么办?
这是因为 JSON.parse 把它解析成了 Number,超出安全整数范围后被舍入。最稳妥的是让后端把 ID 作为字符串返回。前端没法改接口时,可以在解析前用正则把大数字段包成字符串,或者用支持大数的 JSON 解析库。
0.1 + 0.2 和 0.3 相差多少?
相差约 5.55e-17,正好是 0.3 附近两个相邻浮点数之间的一个间距。也就是说 0.1 + 0.2 的结果是 0.3 的近似值「下一个」浮点数,只差一格。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。