为什么 0.1 + 0.2 !== 0.3?怎么解决?

基础高频约 7 分钟读完

一句话回答

JS 的 number 采用 IEEE 754 双精度浮点数,0.1 和 0.2 转成二进制都是无限循环小数,只能舍入后存储,本身就有误差,相加后得到 0.30000000000000004。比较时要允许一个误差范围;涉及金额时转成整数(以分为单位)计算,或者使用 decimal.js 这类十进制计算库。

详细解析

双精度浮点数

number 用 64 位存储一个数:

部分 位数 作用
符号位 1 表示正负
指数 11 表示数的量级(乘以 2 的多少次方)
尾数 52 表示有效数字;加上隐含的最高位 1,一共 53 位精度

误差从哪里来

十进制小数转二进制的方法是不断乘 2 取整数部分。0.1 转换的结果是 0.0001100110011…,其中 0011 无限循环,0.2 也是如此。尾数只有 52 位,只能舍入成最接近的值:

JavaScript
console.log((0.1).toFixed(20)) // 0.10000000000000000555
console.log((0.2).toFixed(20)) // 0.20000000000000001110
console.log((0.3).toFixed(20)) // 0.29999999999999998890
console.log(0.1 + 0.2) // 0.30000000000000004

0.1 和 0.2 存储的值都比真实值略大,相加后还要再舍入一次,结果和 0.3 存储的值(略小于 0.3)不是同一个数,所以不相等。平时打印 0.1 显示的是 0.1,是因为数字转字符串时,引擎会输出能唯一还原这个值的最短十进制形式。

能用二进制精确表示的小数没有这个问题,比如 0.5 + 0.25 === 0.75 的结果是 true。

比较浮点数

JavaScript
// 绝对误差:Number.EPSILON 只适合量级在 1 附近的数
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON) // true
console.log(Math.abs(1.1 + 2.2 - 3.3) < Number.EPSILON) // false

// 相对误差:允许的误差随数值大小缩放,更通用
function nearlyEqual(a, b, tolerance = 1e-9) {
  return Math.abs(a - b) <= tolerance * Math.max(Math.abs(a), Math.abs(b))
}
console.log(nearlyEqual(1.1 + 2.2, 3.3)) // true
console.log(nearlyEqual(1000.1 + 1000.2, 2000.3)) // true

1.1 + 2.2 得到 3.3000000000000003,和 3.3 的差已经是 Number.EPSILON 的 2 倍:数越大,相邻两个浮点数的间距越大,固定的 Number.EPSILON 就不够用了。反过来,数值非常接近 0 时,相对误差又过于严格,通常再配合一个很小的绝对误差。

金额计算

  • 转成整数计算:以分为单位,元转分用 Math.round(yuan * 100),计算完再除以 100 用于展示
  • 使用十进制计算库:decimal.js、big.js 等按十进制规则运算,最好用字符串构造,避免传入的数字本身已经不精确
  • BigInt 只适合整数:不能表示小数,除法会舍去小数部分,也不能和 number 直接混合运算
JavaScript
import Decimal from 'decimal.js'

new Decimal('0.1').plus('0.2').toString() // '0.3'
new Decimal('1.005').toFixed(2) // '1.01'

toFixed 的坑

  • 返回的是字符串:(1.2).toFixed(2) + 1 的结果是 '1.201'
  • 按存储的实际值舍入:1.005 实际存的是 1.00499999…,所以 (1.005).toFixed(2) 是 '1.00'

安全整数与长整型 ID

有效数字一共 53 位(52 位尾数加隐含的 1 位),所以 Number.MAX_SAFE_INTEGER 是 2^53 - 1(9007199254740991),超过它的整数就不一定能精确表示了:

JavaScript
console.log(2 ** 53 === 2 ** 53 + 1) // true
console.log(JSON.parse('{"id": 1234567890123456789}').id) // 1234567890123456800

后端用 64 位长整型做 ID(如雪花算法生成的 ID)时,数值往往超过这个范围,前端 JSON.parse 之后就丢失了精度,应该让后端以字符串形式返回。

面试官可能追问

Number.EPSILON 是什么?

它是 1 与大于 1 的最小浮点数之差,等于 2^-52,约为 2.22e-16。也就是说,在 1 和 2 之间,相邻两个浮点数的间距就是 Number.EPSILON;数越大间距越大,所以它只适合作为量级在 1 附近的数的误差范围。

为什么 (1.005).toFixed(2) 是 "1.00"?

1.005 无法用二进制精确表示,实际存储的值略小于 1.005:

JavaScript
console.log((1.005).toFixed(20)) // 1.00499999999999989342

toFixed 按这个实际值保留两位小数,第三位是 4,舍去后得到 '1.00'。所以不能把 toFixed 当作可靠的四舍五入;需要按十进制规则舍入时,用 decimal.js 这类库处理。金额如果一开始就用整数(分)表示,也就不会遇到这个问题。

如何安全地处理大整数 ID?
  • 后端序列化时把长整型转成字符串,比如 Java 中用 Jackson 把 Long 序列化为字符串
  • 前端始终把 ID 当作字符串,只用于展示和传回,不参与计算
  • 确实需要运算时用 BigInt('1234567890123456789');如果改不了接口,可以用支持大整数的 JSON 解析库(如 json-bigint)

易错点

  • toFixed 返回的是字符串,再参与加法会变成字符串拼接
  • 元转分要用 Math.round(yuan * 100),直接乘 100 可能出错,比如 19.99 * 100 的结果是 1998.9999999999998
  • 不是所有小数都有误差,0.5、0.25 这类能用二进制精确表示的小数就没有

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。