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 二进制
关键认知:精度问题不是发生在"运算时",而是发生在**"存储时"**。
就像十进制无法精确表示 ,二进制也无法精确表示某些十进制小数:
// 0.1 的二进制是无限循环小数
0.1 → 0.0001100110011001100110011... (循环)
// 5.9 同样无法精确存储
5.9 → 存储的其实是 5.9000000000000003552713...
// 0.059 也无法精确存储
0.059 → 存储的其实是 0.05899999999999999444888...
所以 5.9 / 100 和 0.059 * 100 的精度问题,在 5.9/0.059 被存入内存的那一刻就已经发生了,运算只是把底层的存储误差放大了。
3. 打破认知误区
| 误区 ❌ | 真相 ✅ |
|---|---|
| 除不尽才会有精度问题 | 存不下就会有精度问题 |
| 5.9 / 100、0.059 * 100 能整除 | 5.9/0.059 本身就不精确,运算只是放大了误差 |
| 整数运算没问题 | 仅在安全整数范围内()成立 |
三、业务场景中的隐患
场景 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 * 100、0.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 🤓
希望这篇分享能帮大家少踩一个坑!欢迎评论区交流补充 🙌