核心答案:0.1 在二进制里是无限循环小数(0.0001100110011…),IEEE754 双精度只有 52 位尾数必须截断,产生约 5.5×10⁻¹⁷ 的表示误差——所以 0.1+0.2=0.30000000000000004。对策:比较用容差 ε,金额用整数「分」,显示时再格式化。

为什么 0.1+0.2 不等于 0.3

十进制的 0.1 在二进制里是 0.00011001100110011...(0011 无限循环)。双精度浮点只有 52 位尾数,必须舍入:

  • 0.1 实际存储 ≈ 0.1000000000000000055511151231257827
  • 0.2 实际存储 ≈ 0.2000000000000000111022302462515654
  • 相加后舍入 → 0.3000000000000000444089209850062616

打印出来就是 0.30000000000000004这不是 JS 的 bug——所有用 IEEE754 的语言(Python/Java/C++)都一样。

IEEE754 双精度结构

64 位分三段:

部分位数作用
符号位 S10 正 1 负
指数位 E11实际指数+1023 偏移
尾数位 M52隐含前导 1,精度约 15-17 位十进制

值 = (−1)^S × 1.M × 2^(E−1023)

特殊值:指数全 0 + 尾数非 0 = 次正规数(渐进下溢);指数全 1 + 尾数 0 = ±∞;指数全 1 + 尾数非 0 = NaN。用 [IEEE754 解析器](/c/dev/ieee754) 输入任意小数可看 64 位逐段拆解。

安全比较与金额计算

浮点比较(禁止 ===):

```

Math.abs(a - b) < 1e-10

```

金额计算(禁止直接浮点):

  • 存储:以「分」为单位的整数(¥19.99 → 1999)
  • 运算:全部整数运算
  • 显示:(分 / 100).toFixed(2)

安全整数边界:2^53 = 9007199254740992。超过就用 BigInt——订单号、雪花 ID 超出此界会被静默改值。

位运算符速查表

运算符名称规则典型用途
&按位与同为 1 得 1掩码提取、判奇偶
\按位或有 1 得 1置位开关
^按位异或不同得 1翻转、交换变量
~按位非0↔1配合补码
<<左移低位补 0×2 的幂
>>有符号右移高位补符号位÷2 向下取整
>>>无符号右移高位补 0得到无符号值

注意 JS 位运算先把操作数截成 32 位有符号整数——2^31 以上的数会先溢出。

实例:用掩码做权限位

场景:读/写/执行三种权限用一个整数存。

  1. 定义:读=4(100)、写=2(010)、执行=1(001)
  2. 授权:读+写 = 4 | 2 = 6
  3. 校验:6 & 4 = 4 ≠ 0 → 有读权限;6 & 1 = 0 → 无执行权限
  4. 追加执行:6 | 1 = 7;撤销写:7 & ~2 = 5

这就是 chmod 755 的底层逻辑(rwxr-xr-x = 111101101)。用[位运算计算器](/c/dev/bitwise)可实时查看每一步的二进制竖式。

实例:徒手拆 float64

把 0.1 的 64 位 0x3FB999999999999A 拆开:

  • 符号 S=0(正数)
  • 指数段 01111111011 = 1019,实际指数 = 1019−1023 = −4
  • 尾数段 1001100110011001100110011001100110011001100110011010,加隐含前导 1 → 1.6000000000000000888…×2⁻⁴ ≈ 0.10000000000000000555

亲眼看到尾数段的 0011 循环被最后一位截断,就理解误差从何而来。

常见误区

  • 用 toFixed 做精确计算:toFixed 只做显示格式化,返回字符串——拿它继续做加减会先转回近似浮点。计算链路全程用整数分。
  • parseInt("08") 的旧坑:老式引擎把前导 0 当八进制,ES5 起已修复为十进制,但显式写 parseInt(s, 10) 更稳。
  • >> 当无符号用:负数 >> 补符号位(-4>>1=-2),想要逻辑右移用 >>>(-4>>>1=2147483646)。
  • NaN 与自身比较NaN === NaN 是 false——IEEE754 规定 NaN 不等于任何值包括自己,判断用 Number.isNaN()。