JavaScript 浮点数精度踩坑:为什么 5.9 / 100 = 0.059000000000000004?

7 阅读5分钟

JavaScript 浮点数精度踩坑:为什么 5.9 / 100 ≠ 0.059?

今天业务开发时踩了个坑:5.9 / 100 结果居然是 0.059000000000000004。一直以为只有"除不尽"才会有精度问题,没想到看起来能整除的运算也会翻车。特此总结分享。


一、问题复现

5.9 / 100    // 0.059000000000000004 ❌
0.1 + 0.2    // 0.30000000000000004 ❌
0.1 * 0.1    // 0.010000000000000002 ❌
1.005 * 100  // 100.49999999999999 ❌
0.059 * 100  // 5.8999999999999995 ❌ 新增案例

直觉:5.9 / 100 = 0.059、0.059 * 100 = 5.9,这不是能整除吗?

现实:JavaScript 表示不服 🙃


二、根本原因:IEEE 754 的锅

1. 底层存储机制

JavaScript 所有数字都采用 IEEE 754 双精度浮点数(64 位)存储:

组成部分位数作用
符号位1 bit正/负
指数位11 bits数量级
尾数位52 bits精度(约 15-17 位有效数字)

2. 核心矛盾:十进制 vs 二进制

关键认知:精度问题不是发生在"运算时",而是发生在**"存储时"**。

就像十进制无法精确表示 13=0.333...\frac{1}{3} = 0.333...,二进制也无法精确表示某些十进制小数:

// 0.1 的二进制是无限循环小数
0.10.0001100110011001100110011... (循环)

// 5.9 同样无法精确存储
5.9 → 存储的其实是 5.9000000000000003552713...

// 0.059 也无法精确存储
0.059 → 存储的其实是 0.05899999999999999444888...

所以 5.9 / 1000.059 * 100 的精度问题,在 5.9/0.059 被存入内存的那一刻就已经发生了,运算只是把底层的存储误差放大了。

3. 打破认知误区

误区 ❌真相 ✅
除不尽才会有精度问题存不下就会有精度问题
5.9 / 100、0.059 * 100 能整除5.9/0.059 本身就不精确,运算只是放大了误差
整数运算没问题仅在安全整数范围内(±2531\pm 2^{53} - 1)成立

三、业务场景中的隐患

场景 1:百分比转换(我踩的坑)

// 后端返回比率 0.059,前端需要展示为 5.9%
const displayRatio = ratio * 100;  // 5.8999999999999995 ❌ 新增案例

// 用户输入 5.9%,需要存储为 0.059
const storeRatio = input / 100;    // 0.059000000000000004 ❌

场景 2:金额计算

// 商品价格 19.9,打 8.5 折
19.9 * 0.85  // 16.914999999999999 ❌

// 累加订单金额
let total = 0;
[0.1, 0.2, 0.3].forEach(n => total += n);
total  // 0.6000000000000001 ❌

// 折扣率 0.059,计算 100 元商品的优惠金额
100 * 0.059 // 5.8999999999999995 ❌ 新增案例

场景 3:条件判断失效

0.1 + 0.2 === 0.3       // false ❌
5.9 / 100 === 0.059     // false ❌
0.059 * 100 === 5.9    // false ❌ 新增案例

// 更隐蔽的 bug
if (amount === 0.059) {  // 永远不会命中!
  // ...
}
if (discountAmount === 5.9) { // 优惠金额判断,永远不命中 ❌ 新增案例
  // ...
}

四、解决方案

方案 1:字符串操作(适合纯展示/截断)

原理:全程不进行浮点运算,直接操作字符串。

// 截断小数位,不做四舍五入
const truncateToDecimalPlaces = (value, decimalPlaces) => {
  const str = String(value);
  const dotIndex = str.indexOf('.');
  if (dotIndex === -1) return str;
  return str.slice(0, dotIndex + decimalPlaces + 1);
};

truncateToDecimalPlaces(0.059000000000000004, 4);  // "0.059"
truncateToDecimalPlaces(5.8999999999999995, 1);    // "5.9" 新增案例

适用场景:用户输入校验、纯展示格式化

方案 2:toFixed(简单取精度)

(5.9 / 100).toFixed(4)           // "0.0590" (字符串)
Number((5.9 / 100).toFixed(10))  // 0.059 (数字)
(0.059 * 100).toFixed(1)         // "5.9" (字符串) 新增案例
Number((0.059 * 100).toFixed(1)) // 5.9 (数字) 新增案例

// 封装工具函数
const safeDiv100 = (num) => Number((num / 100).toFixed(10));
const safeMul100 = (num) => Number((num * 100).toFixed(10)); // 新增函数
safeDiv100(5.9);  // 0.059 ✅
safeMul100(0.059); // 5.9 ✅ 新增案例

⚠️ 注意toFixed 会四舍五入,且返回字符串。

方案 3:使用 Decimal.js / Big.js(推荐)

原理:用字符串模拟十进制运算,彻底绕过二进制浮点。

import Big from 'big.js';

// 乘法
new Big(0.1).times(0.1).toNumber()    // 0.01 ✅

// 除法
new Big(5.9).div(100).toNumber()       // 0.059 ✅

// 加法
new Big(0.1).plus(0.2).toNumber()      // 0.3 ✅

// 新增:0.059 * 100 精确计算
new Big(0.059).times(100).toNumber()   // 5.9 ✅

// 业务实战:百分比转换
const ratio = tool.ratio != null 
  ? Number(new Big(tool.ratio).times(100))  // 0.059 → 5.9 ✅
  : tool.ratio;

// 业务实战:优惠金额计算 新增案例
const discountAmount = Number(new Big(100).times(0.059)); // 5.9 ✅

适用场景:金融计算、奖金/薪酬系统、任何对精度有要求的业务

方案 4:整数运算(特定场景)

原理:先放大为整数运算,再缩小。

// 金额用"分"存储,展示时再转"元"
const priceInCents = 1990;  // 19.90 元
const discount = 85;        // 8.5 折 = 85%
const finalPrice = priceInCents * discount / 100;  // 1691.5 分 = 16.915 元

// 新增:折扣率 0.059 转换为整数运算(59/1000)
const baseAmount = 10000; // 100 元(以分为单位)
const discountRate = 59;  // 0.059(放大 1000 倍)
const discountAmount = baseAmount * discountRate / 1000; // 590 分 = 5.9 元 ✅

⚠️ 注意:对于 5.9 * 1000.059 * 100 这种,输入本身已经不精确,直接整数运算仍有风险,需先通过字符串/库处理后再转换。


五、方案选择指南

场景推荐方案理由
用户输入校验/格式化字符串操作无浮点运算,零风险
简单展示toFixed简单快捷
金额/奖金/薪酬计算Decimal.js / Big.js精度可靠,业务安全
后端存储整数(分)避免前后端精度不一致
精确比较差值比较或用库避免 === 直接比较浮点数
百分比正反转换(如 0.059 ↔ 5.9%)Decimal.js / Big.js彻底解决乘除 100 后的精度偏差

六、最佳实践总结

✅ Do

// 1. 涉及金额,用 Big.js
new Big(price).times(discount).toNumber();

// 2. 浮点比较,用差值
Math.abs(a - b) < Number.EPSILON;

// 3. 后端存储用整数
{ priceInCents: 1990 }  // 而不是 { price: 19.9 }

// 4. 百分比转换用库(新增)
new Big(ratio).times(100).toNumber(); // 0.059 → 5.9
new Big(percentage).div(100).toNumber(); // 5.9 → 0.059

// 5. 封装通用的乘除 100 安全函数(新增)
const safeMul100 = (num) => Number(new Big(num).times(100).toFixed(10));
const safeDiv100 = (num) => Number(new Big(num).div(100).toFixed(10));
safeMul100(0.059); // 5.9 ✅
safeDiv100(5.9);   // 0.059 ✅

❌ Don't

// 1. 直接比较浮点数
if (result === 0.059) { ... }
if (discountAmount === 5.9) { ... } // 新增 ❌

// 2. 金额直接运算
totalPrice = price1 + price2 + price3;

// 3. 信任"能整除"的直觉
5.9 / 100  // 你觉得是 0.059,实际不是
0.059 * 100 // 你觉得是 5.9,实际不是 新增 ❌

七、总结

要点说明
根因IEEE 754 无法精确存储某些十进制小数(如 5.9、0.059)
误区不是"除不尽"才有问题,是"存不下"就有问题;乘 100/除 100 这类看似简单的运算也会翻车
核心方案字符串操作 / toFixed / Big.js(金融场景首选)
经验法则涉及钱的计算,永远用专用库 💰;百分比转换优先用库处理乘除 100

一句话总结:JavaScript 的数字就像一个有点近视的人,它看 5.9 的时候看到的是 5.900000000000000355...,看 0.059 的时候看到的是 0.058999999999999994...,所以别指望它能算清楚简单的乘除 100 🤓

希望这篇分享能帮大家少踩一个坑!欢迎评论区交流补充 🙌