JavaScript为什么会存在数字精度丢失的问题,以及如何进行解决

5 阅读5分钟

JavaScript 中数字精度丢失是语言底层设计导致的必然现象,核心源于 JS 采用 IEEE 754 双精度浮点数 标准存储数字,该标准无法精确表示所有十进制小数(如 0.1、0.2),进而引发计算误差。以下从「根源解析」「常见场景」「解决方案」三方面彻底讲清。

一、核心原因:IEEE 754 双精度浮点数的局限性

1. 浮点数的存储规则

JS 中所有数字(整数、小数)都以 64 位双精度浮点数 存储,结构如下:

位段符号位(S)指数位(E)尾数位(M)
位数1 位11 位52 位
作用正负(0 = 正,1 = 负)表示指数(偏移值 1023)表示有效数字(隐含首位 1)

核心限制:

  • 尾数位(52 位)决定了 有效数字最多只能精确到 53 位二进制(约 16 位十进制);
  • 十进制小数(如 0.1)转换为二进制时,大概率是「无限循环小数」,但尾数位只能存储 52 位,必然截断,导致存储值与原值存在微小偏差。

2. 直观示例:0.1 + 0.2 ≠ 0.3 的本质

console.log(0.1 + 0.2); // 输出 0.30000000000000004

拆解过程:

  • 0.1 的十进制转二进制:0.0001100110011...(无限循环);
  • 0.2 的十进制转二进制:0.001100110011...(无限循环);
  • 二者存储时被截断为 52 位尾数位,相加后再转回十进制,就出现了精度偏差。

3. 整数的精度丢失场景

很多人误以为只有小数会丢失精度,但 JS 整数超过 2^53(9007199254740992)  后,也会无法精确表示:

console.log(9007199254740992 + 1); // 9007199254740992(错误,应为 9007199254740993)
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991(最大安全整数)

原因:53 位二进制有效数字只能精确表示到 2^53,超过后无法区分相邻整数(如 2^53 和 2^53+1 存储为同一个值)。

二、常见精度丢失场景

  1. 小数运算:0.1+0.2、0.7*10、1.0-0.9 等;
  2. 大整数处理:后端返回的雪花 ID(18 位以上)、订单号等;
  3. 货币计算:金额(如 0.01 元)累加 / 乘法;
  4. 数据序列化:JSON 传输大整数时被截断(如 JSON.parse('9007199254740993') 结果为 9007199254740992)。

三、解决方案(按场景选型)

方案 1:放大法(适合简单小数运算,如货币)

核心思路:将小数转换为整数运算,结果再缩小对应倍数,避免浮点数直接计算。

// 加法:0.1 + 0.2
function add(a, b) {
  const decimalA = (a.toString().split('.')[1] || '').length; // 小数位数
  const decimalB = (b.toString().split('.')[1] || '').length;
  const maxDecimal = Math.max(decimalA, decimalB);
  const multiple = Math.pow(10, maxDecimal); // 放大倍数
  // 转为整数运算,避免精度丢失
  return (a * multiple + b * multiple) / multiple;
}
console.log(add(0.1, 0.2)); // 0.3

// 货币计算(如 0.01 + 0.02)
console.log((0.01 * 100 + 0.02 * 100) / 100); // 0.03

⚠️ 注意:放大倍数不能超过 2^53(如 10^16 以内),否则整数也会丢失精度。

方案 2:使用 Number.EPSILON 做误差判断(适合比较场景)

Number.EPSILON 是 JS 表示的最小精度(≈ 2.22e-16),用于判断两个数的差值是否在误差范围内:

// 判断两个数是否“实际相等”
function isEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON;
}
console.log(isEqual(0.1 + 0.2, 0.3)); // true
console.log(0.1 + 0.2 === 0.3); // false

方案 3:原生 BigInt(适合大整数处理,ES6+)

BigInt 是 ES6 新增的原生类型,可精确表示任意大整数,解决 2^53 以上整数的精度问题:

// 声明 BigInt:数字后加 n 或用 BigInt() 转换
const bigNum1 = 9007199254740993n;
const bigNum2 = BigInt('9007199254740993');
console.log(bigNum1 === bigNum2); // true

// 大整数运算
console.log(9007199254740992n + 1n); // 9007199254740993n(正确)

// 注意:BigInt 不能与 Number 混合运算
console.log(1n + 1); // 报错,需统一类型:1n + BigInt(1)

✅ 适用场景:雪花 ID、订单号、大整数传输 / 存储。

方案 4:第三方库(适合复杂运算,推荐生产环境)

对于复杂的小数 / 大整数运算(如金融系统),推荐使用成熟库,避免手写逻辑出错:

(1)decimal.js(功能最全)

npm install decimal.js
import Decimal from 'decimal.js';

// 0.1 + 0.2
const a = new Decimal(0.1);
const b = new Decimal(0.2);
console.log(a.plus(b).toNumber()); // 0.3

// 大整数运算
const bigNum = new Decimal('9007199254740993');
console.log(bigNum.plus(1).toString()); // 9007199254740994

(2)math.js(轻量,适合通用计算)

npm install mathjs
import { add, multiply } from 'mathjs';

console.log(add(0.1, 0.2)); // 0.3
console.log(multiply(0.7, 10)); // 7(而非 6.999999999999999)

(3)big.js(极简,仅处理小数)

专注小数精度,体积最小(≈ 3KB):

npm install big.js
import Big from 'big.js';

console.log(Big(0.1).plus(0.2).toNumber()); // 0.3

方案 5:序列化大整数(JSON 传输场景)

后端返回的大整数(如 18 位 ID)通过 JSON 传输时,会被 JS 解析为不精确的浮点数,解决方案:

  1. 后端将大整数转为字符串返回;

  2. 前端用 BigInt() 转换后处理:

    // 后端返回:{ "id": "9007199254740993" }
    const res = JSON.parse('{ "id": "9007199254740993" }');
    const id = BigInt(res.id); // 9007199254740993n(精确)
    

四、避坑指南

  1. 避免直接比较浮点数:永远不要用 === 比较 0.1+0.2 和 0.3,改用 Number.EPSILON 或库;

  2. 货币计算优先放大法 / 库:金额运算必须保证精确,禁止直接浮点数相加;

  3. 大整数优先用 BigInt:超过 2^53 的整数,一律用 BigInt 处理;

  4. 选择合适的库

    • 金融场景:decimal.js;
    • 通用场景:math.js;
    • 极简需求:big.js。

总结

JS 数字精度丢失的本质是「IEEE 754 双精度浮点数无法精确表示所有十进制数」,解决思路分三类:

  1. 简单小数运算:放大法 + Number.EPSILON;
  2. 大整数处理:BigInt;
  3. 复杂 / 金融运算:decimal.js/math.js 等专业库。

生产环境中,优先使用成熟库而非手写逻辑,可最大程度避免精度问题。