前端实战|踩坑合集 |
0.1 + 0.2 !== 0.3只是冰山一角。在生产环境中,浮点数精度问题可能导致金额计算错误、报表对不上、甚至用户可以直接「刷钱」------这不是危言耸听。
问题场景
场景一:电商优惠计算的「一两分钱」
某电商平台上线后,财务发现每天的对账都差几毛钱:
javascript
// 商品:19.99 元,满 20 减 5,差 1 分钱不满足条件
const price = 9.99;
const shipping = 5.00;
const coupon = 10.00;
// 用户实际支付
const total = price + shipping - coupon;
console.log(total);
// 预期:4.99
// 实际:4.989999999999998 ❌
// 直接展示给用户
document.querySelector('.total').textContent = `¥${total}`;
// 用户看到:¥4.989999999999998 ❌
场景二:批量加购的「累计偏差」
javascript
// 采购系统:一次采购 1000 个单价 0.1 元的商品
let total = 0;
for (let i = 0; i < 1000; i++) {
total += 0.1;
}
console.log(total);
// 预期:100
// 实际:99.9999999999986 ❌
// 财务对不上账,每一笔都差一丁点,累积起来就很明显
场景三:百分比分摊的「金额丢失」
javascript
// 三个用户分摊 10 元,比例 1/3 各一份
const amounts = [10 / 3, 10 / 3, 10 / 3];
const sum = amounts.reduce((a, b) => a + b, 0);
console.log(sum);
// 预期:10
// 实际:9.999999999999998 ❌
// 最离谱的是最后一个人可能看到 -0.00 元的退款
原因分析:为什么计算机算不对简单的数学?
根本原因:二进制无法精确表示十进制小数
这不是 JavaScript 的 Bug,而是 IEEE 754 双精度浮点数 的固有限制。
javascript
// 为什么 0.1 + 0.2 !== 0.3?
// 因为 0.1 和 0.2 在二进制中是无限循环小数
// 0.1 的二进制:0.0001100110011001100110011001100110011001100110011001101...
// 0.2 的二进制:0.0011001100110011001100110011001100110011001100110011010...
//
// 64 位浮点数的精度有限,只能近似表示,求和时误差累积
// 看看到底差多少
console.log(0.1 + 0.2 - 0.3);
// 输出:5.551115123125783e-17
不是所有小数都有问题
javascript
// 这些是"安全"的------因为分母是 2 的幂
0.5 // 2⁻¹ → 精确
0.25 // 2⁻² → 精确
0.125 // 2⁻³ → 精确
// 这些是不安全的------分母含有 3, 5, 7 等因子
0.1 // 1/10 → 不精确
0.2 // 1/5 → 不精确
0.3 // 3/10 → 不精确
1/3 // 不精确
但 JS 的 toFixed 也靠不住
javascript
(1.005).toFixed(2);
// 预期:"1.01"
// 实际:"1.00" ❌
// 因为 1.005 在内存中实际上是 1.0049999999999999...
(2.55).toFixed(1);
// 预期:"2.6"
// 实际:"2.5" ❌
解决方案:前端金额计算的五种方案
方案 1:乘以 100,用整数计算(最推荐)
把元转为分,所有计算都用整数:
javascript
// 工具函数:元 ↔ 分 转换
function yuanToFen(price) {
// 关键:用 toFixed 避免浮点误差后再转整数
return Math.round(parseFloat(price) * 100);
}
function fenToYuan(fen) {
return (fen / 100).toFixed(2);
}
// 计算
const a = yuanToFen(19.99); // 1999
const b = yuanToFen(5.00); // 500
const total = a + b; // 2499(分)
console.log(fenToYuan(total)); // "24.99" ✅
// 满减计算
const subtotal = yuanToFen(19.99) + yuanToFen(5.00); // 2499
const coupon = yuanToFen(10.00); // 1000
const pay = subtotal - coupon; // 1499
console.log(fenToYuan(pay)); // "14.99" ✅
注意 Math.round 的必要性:
javascript
// 如果不加 Math.round,可能有隐藏坑
function badYuanToFen(price) {
return price * 100; // 浮点数乘法依然有精度问题
}
console.log(badYuanToFen(1.005));
// 输出:100.49999999999999 ❌ 后续 parseInt 会变成 100
方案 2:使用 JavaScript 的内置 BigInt(ES2020+)
对于超大金额或超高精度场景:
javascript
// 使用 BigInt 做精确整数运算
function toCents(price) {
const [int, dec = '00'] = String(price).split('.');
return BigInt(int) * 100n + BigInt(dec.padEnd(2, '0').slice(0, 2));
}
function fromCents(cents) {
const yuanStr = String(cents);
const len = yuanStr.length;
if (len <= 2) {
return `0.${yuanStr.padStart(2, '0')}`;
}
return `${yuanStr.slice(0, -2)}.${yuanStr.slice(-2)}`;
}
// 使用
const a = toCents('19.99'); // 1999n
const b = toCents('5.00'); // 500n
const total = a + b; // 2499n
console.log(fromCents(total)); // "24.99"
方案 3:使用 decimal.js / bignumber.js 库
生产环境最省心的方案:
javascript
import Decimal from 'decimal.js';
// 所有计算都精确
const price = new Decimal('19.99');
const quantity = new Decimal('3');
const tax = new Decimal('0.06');
const subtotal = price.times(quantity); // 59.97
const taxAmount = subtotal.times(tax).toFixed(2); // 3.60
const total = subtotal.plus(taxAmount).toFixed(2); // 63.57
console.log(total); // "63.57"
// 比较也 OK
console.log(new Decimal('0.1').plus('0.2').equals('0.3'));
// true ✅
为什么不用 eval('0.1 + 0.2')? 因为 eval 还是让 JS 引擎计算,精度问题还在。
方案 4:处理 toFixed 的四舍五入 Bug
如果你必须用原生 toFixed,需要自己实现正确的四舍五入:
javascript
// 安全的四舍五入
function safeRound(value, decimals = 2) {
const factor = Math.pow(10, decimals);
// 加一个极小的偏移量,抵消浮点误差
return Math.round((value + Number.EPSILON) * factor) / factor;
}
console.log(safeRound(1.005, 2)); // 1.01 ✅
console.log(safeRound(2.55, 1)); // 2.6 ✅
console.log(safeRound(0.1 + 0.2, 2)); // 0.3 ✅
方案 5:显示层的格式处理
无论内部怎么算,展示给用户必须格式化:
javascript
// Intl.NumberFormat 是浏览器内置的格式化工具
const formatter = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
console.log(formatter.format(19.99)); // "¥19.99"
console.log(formatter.format(10000)); // "¥10,000.00"
console.log(formatter.format(0.1 + 0.2)); // "¥0.30"(会被舍入)
// 严格模式:先修复精度再格式化
function formatMoney(value) {
const fixed = Math.round((value + Number.EPSILON) * 100) / 100;
return formatter.format(fixed);
}
真实项目中的「大坑」案例
案例 1:支付宝账单对不上
某 SaaS 系统发现每到月底对账就差 0.01 元,不是同一笔------是很多笔优惠计算的微小误差累积。排查发现是在计算多个优惠叠加时用了链式浮点运算。
教训 :金额计算必须在每个操作节点都做舍入,而不是只在最后一步。
javascript
// ❌ 错误:链式运算只舍入最后结果
function calcPrice(items) {
return items
.map(i => i.price * i.qty) // 先乘
.reduce((a, b) => a + b, 0) // 再累加
.toFixed(2); // 最后舍入
}
// ✅ 正确:每一步都舍入
function calcPrice(items) {
return items
.map(i => Math.round(i.price * i.qty * 100) / 100) // 每项先舍入
.reduce((a, b) => Math.round((a + b) * 100) / 100, 0) // 每步舍入
.toFixed(2);
}
案例 2:「一分钱刷单」漏洞
某平台积分兑换系统,用户可以用 0.01 元购买 10 积分。后端用浮点数计算购买次数:
javascript
// 后端伪代码
const balance = 10.00; // 余额 10 元
const unitPrice = 0.01; // 单价
const maxBuy = Math.floor(balance / unitPrice);
// 10.00 / 0.01 = 999.9999999999999
// Math.floor → 999 ❌ 用户本应能买 1000 个
// 实际被刷了 1000 次,每次买 999 个,多出来的自动退款?
// 退款又是浮点数计算 → 继续积累误差 → 黑客发现了这个 offset
教训 :涉及金额的除法一定要先转为整数再算。
javascript
function canBuy(balanceYuan, priceYuan) {
const balanceFen = Math.round(balanceYuan * 100);
const priceFen = Math.round(priceYuan * 100);
return Math.floor(balanceFen / priceFen);
}
// 1000 / 1 = 1000 ✅
要点总结
- 根本原因:IEEE 754 双精度浮点数的二进制表示无法精确表达大多数十进制小数(分母不是 2 的幂)
- 核心策略:金额统一用「分」做单位,全程整数运算,只在显示时转回「元」
- Safe pattern :
- 乘法用
Math.round(x * 100)转分 - 每一步运算后都舍入,不要只舍入最终结果
- 比较用
Math.abs(a - b) < Number.EPSILON而非===
- 乘法用
- toFixed 有 Bug :
1.005.toFixed(2)是"1.00",要用Math.round((value + Number.EPSILON) * 100) / 100 - 生产环境 :推荐
decimal.js库或BigInt+ 分单位方案 - 显示层 :用
Intl.NumberFormat做格式化输出
一句话总结 :前端金额计算的门规------小数的世界里没有精确,但整数的世界里有。把所有金额×100 存成「分」,才是真正的安全感。