金额求和之后做保留两位小数,写的是 金额.toFixed(2)。打印结果里 1.005 元显示成 1.00 元,1.015 元显示成 1.01 元。差异都是 0.005,恰好是需要进位的那一位。
多数人第一反应是四舍五入失效了。把样本摊开跑一遍,看这批数字到底落在哪一侧。
// 验证脚本:Number.prototype.toFixed 对十进制小数的进位结果
const 样本 = [
{ 原始值: 1.005, 位数: 2 },
{ 原始值: 1.015, 位数: 2 },
{ 原始值: 1.025, 位数: 2 },
{ 原始值: 2.675, 位数: 2 },
{ 原始值: 0.615, 位数: 2 },
{ 原始值: 8.575, 位数: 2 },
];
console.log('原始值'.padEnd(10), '位数'.padEnd(6), 'toFixed 结果'.padEnd(14), '真实二进制值');
样本.forEach((条目) => {
console.log(
String(条目.原始值).padEnd(12),
String(条目.位数).padEnd(8),
String(条目.原始值.toFixed(条目.位数)).padEnd(16),
String(条目.原始值)
);
});
实际输出:
原始值 位数 toFixed 结果 真实二进制值
1.005 2 1.00 1.005
1.015 2 1.01 1.015
1.025 2 1.02 1.025
2.675 2 2.67 2.675
0.615 2 0.61 0.615
8.575 2 8.57 8.575
六个样本全部向下走,没有一个进位。这种整齐的偏向说明问题不在舍入规则本身,而在输入的表示上。
toFixed 收到的不是字面量 1.005,而是一个和它最接近的双精度值。把双精度能表达的全部有效位摊开看:
// 验证脚本:确认 toFixed 不进位的根因是二进制表示本身就小于字面量
const 样本 = [1.005, 2.675, 0.615, 8.575];
console.log('字面量'.padEnd(10), '双精度实际值'.padEnd(26), '比字面量小');
样本.forEach((值) => {
// toPrecision(20) 把双精度能表达的全部有效位摊开,避免默认 toString 的四舍五入掩盖真相
const 实际 = 值.toPrecision(20);
console.log(String(值).padEnd(12), 实际.padEnd(28), 实际 < String(值) ? '是' : '否');
});
实际输出:
字面量 双精度实际值 比字面量小
1.005 1.0049999999999998934 是
2.675 2.6749999999999998224 是
0.615 0.61499999999999999112 是
8.575 8.5749999999999992895 是
1.005 存进去的实际是 1.0049999999999998934,比字面量小。保留两位时这个值距离 1.00 比距离 1.01 更近,按最近值舍入自然落到 1.00。
所以 toFixed 的行为是自洽的,它只是忠实处理了拿到的那个数。真正的问题在于期待一个二进制小数系统按十进制字面量的规则走。
常见的修法有两种,把三种路径放在同一批样本上对比。样本里特意加入了能精确表示的 1.5、2.5、3.5,用来观察各方案在无误差输入上是否也出现偏差。
// 验证脚本:三种定点舍入路径在同一批样本上的表现差异
const 样本 = [1.005, 1.015, 1.025, 2.675, 0.615, 8.575, 1.5, 2.5, 3.5];
// 路径一:直接用 toFixed
const 甲 = (值) => 值.toFixed(2);
// 路径二:放大成整数后 Math.round,再缩小
const 乙 = (值) => (Math.round(值 * 100) / 100).toFixed(2);
// 路径三:先按字符串读入,用整数字符串做进位,全程不碰浮点
const 丙 = (值) => {
const 文本 = String(值);
const 小数点 = 文本.indexOf('.');
// 没有小数部分时直接补零
if (小数点 === -1) return `${文本}.00`;
const 整数部分 = 文本.slice(0, 小数点);
// 小数部分先补足两位,保证拼接出来的整数位权正确:1.5 要补成 50 而不是 5
const 小数部分 = 文本.slice(小数点 + 1).padEnd(2, '0');
const 保留 = 小数部分.slice(0, 2);
const 下一位 = Number(小数部分[2] || '0');
const 合并 = Number(整数部分 + 保留) + (下一位 >= 5 ? 1 : 0);
// 补零宽度按整数部分原始长度算,进位导致位数增加时不截断
const 结果文本 = String(合并).padStart(整数部分.length + 2, '0');
return `${结果文本.slice(0, -2)}.${结果文本.slice(-2)}`;
};
console.log('字面量'.padEnd(10), 'toFixed'.padEnd(11), '整数舍入'.padEnd(11), '字符串进位'.padEnd(13), '说明');
样本.forEach((值) => {
const 结果甲 = 甲(值);
const 结果乙 = 乙(值);
const 结果丙 = 丙(值);
const 众数 = [结果甲, 结果乙, 结果丙];
const 说明 = new Set(众数).size === 1 ? '三者一致' : '存在分歧';
console.log(String(值).padEnd(12), 结果甲.padEnd(13), 结果乙.padEnd(13), 结果丙.padEnd(15), 说明);
});
实际输出:
字面量 toFixed 整数舍入 字符串进位 说明
1.005 1.00 1.00 1.01 存在分歧
1.015 1.01 1.01 1.02 存在分歧
1.025 1.02 1.02 1.03 存在分歧
2.675 2.67 2.68 2.68 存在分歧
0.615 0.61 0.62 0.62 存在分歧
8.575 8.57 8.57 8.58 存在分歧
1.5 1.50 1.50 1.50 三者一致
2.5 2.50 2.50 2.50 三者一致
3.5 3.50 3.50 3.50 三者一致
三条信息。
放大取整的路径二并不比 toFixed 更接近十进制直觉。1.005 和 1.015 上它和路径一一样停在下方,因为 1.005 * 100 得到的仍是 100.49999999999999 这个量级的值,Math.round 拿到的还没到临界点。它能救回 2.675 和 0.615,救不回另外几个。
路径三全部按十进制字面量走出了进位结果,包括 2.675 和 0.615。它在 1.5、2.5、3.5 上与前两者一致,说明补足小数位的处理没有把无误差输入带偏。
路径三的代价是它工作在字符串上。输入得先转成十进制文本,且只按 String(值) 的形态解析,科学计数法表示的数值(比如 1e-7)进入这条路径时拿不到正确结果,因为文本里没有小数点会被当成整数处理。金额这类定长小数的场景输入形态可控,这个限制不构成障碍;输入来自任意数值计算的场景,路径三需要先补一层科学计数法的识别。
如果链路允许,把金额以最小货币单位(分)的整数形式在前后端之间传递,可以整体绕开这一层。代价是所有展示位置都要在最后一步做一次除法,且除法之后仍然不要再用 toFixed 做二次收尾,否则误差会以另一种形式回来。
另一种情形是数字本身来自字符串。如果后端返回的就是 "1.005" 这样的文本,前后端约定以字符串传递金额,那么解析这一步根本不需要发生,路径三可以直接作用于原始文本,比先转成 Number 再想办法救回来要干净。
至于该选哪条路径,取决于输入的来源是否可控。可控就用字符串进位,不可控就先把金额转成整数再传,两条路都比在小数上反复调舍入参数更省事。