如何在 JavaScript 中将数字四舍五入到最接近的 0.05 或其他增量
了解如何将货币和数字四舍五入到特定的增量,例如 0.05 或 0.10,以适应没有小额硬币的国家
简介
一位加拿大的顾客将商品加入购物车,总金额为 $11.23,但最终结算金额显示为 $11.25。这是因为加拿大在 2013 年取消了分币,因此现金交易会四舍五入到最接近的 0.05。您的应用程序需要显示与顾客实际支付金额一致的价格。
通过四舍五入到特定的增量可以解决这个问题。您可以将数字四舍五入到最接近的 0.05(镍币)、0.10(角币)或其他增量。这适用于货币格式化、测量系统以及任何需要与特定增量对齐的场景。
本指南将向您展示如何使用 JavaScript 将数字四舍五入到自定义增量,包括手动实现和现代的 Intl.NumberFormat API。
为什么要四舍五入到特定增量
许多国家出于实际原因取消了小面额硬币。生产和处理这些硬币的成本高于其面值。这些国家的现金交易会四舍五入到最接近的可用硬币。
使用 0.05 四舍五入进行现金支付的国家:
- 加拿大(2013 年取消分币)
- 澳大利亚(取消了 1 分和 2 分硬币)
- 荷兰(四舍五入到最接近的 0.05 欧元)
- 比利时(四舍五入到最接近的 0.05 欧元)
- 爱尔兰(四舍五入到最接近的 0.05 欧元)
- 意大利(四舍五入到最接近的 0.05 欧元)
- 芬兰(四舍五入到最接近的 0.05 欧元)
- 瑞士(最小硬币为 0.05 瑞士法郎)
使用 0.10 四舍五入的国家:
- 新西兰(2006 年取消了 5 分硬币)
美国计划在 2026 年初停止生产分币,这将需要现金交易进行 0.05 的四舍五入。
现金四舍五入仅适用于最终交易总额,而不适用于单个商品。电子支付仍然是精确的,因为没有实际的硬币交易。
除了货币,您可能还需要在以下场景中使用自定义增量:
- 具有特定精度要求的测量系统
- 按仪器精度四舍五入的科学计算
- 对齐到特定值的用户界面控件
- 使用心理定价策略的定价方案
增量舍入背后的数学概念
增量舍入是指找到最接近该增量的倍数。公式是将数字除以增量,四舍五入到最接近的整数,然后再乘以增量。
roundedValue = Math.round(value / increment) * increment
例如,将 11.23 舍入到最接近的 0.05:
- 除法:11.23 / 0.05 = 224.6
- 四舍五入:Math.round(224.6) = 225
- 乘法:225 * 0.05 = 11.25
除法将增量转换为整数步长。将 224.6 舍入到 225 找到最近的步长。乘法将其转换回原始比例。
对于将 11.27 舍入到最接近的 0.05:
- 除法:11.27 / 0.05 = 225.4
- 四舍五入:Math.round(225.4) = 225
- 乘法:225 * 0.05 = 11.25
11.23 和 11.27 都舍入到 11.25,因为它们都在该值的 0.025 范围内。中点 11.25 保持为 11.25。
手动实现增量舍入
创建一个函数,将任意数字舍入到指定的增量:
function roundToIncrement(value, increment) {
return Math.round(value / increment) * increment;
}
console.log(roundToIncrement(11.23, 0.05)); // 11.25
console.log(roundToIncrement(11.27, 0.05)); // 11.25
console.log(roundToIncrement(11.28, 0.05)); // 11.30
console.log(roundToIncrement(4.94, 0.10)); // 4.90
console.log(roundToIncrement(4.96, 0.10)); // 5.00
这适用于任何增量,不仅限于货币:
console.log(roundToIncrement(17, 5)); // 15
console.log(roundToIncrement(23, 5)); // 25
console.log(roundToIncrement(2.7, 0.5)); // 2.5
JavaScript 的浮点运算可能会引入小的精度误差。对于需要高精度的货币计算,可以将值乘以 10 的幂以使用整数:
function roundToIncrementPrecise(value, increment) {
// 找到增量的小数位数
const decimals = (increment.toString().split('.')[1] || '').length;
const multiplier = Math.pow(10, decimals);
// 转换为整数
const valueInt = Math.round(value * multiplier);
const incrementInt = Math.round(increment * multiplier);
// 舍入并转换回原始值
return Math.round(valueInt / incrementInt) * incrementInt / multiplier;
}
console.log(roundToIncrementPrecise(11.23, 0.05)); // 11.25
Math.round() 使用的是四舍五入模式,其中正好位于两个增量中间的值会向上舍入。一些应用需要不同的舍入模式:
function roundToIncrementWithMode(value, increment, mode = 'halfUp') {
const divided = value / increment;
let rounded;
switch (mode) {
case 'up':
rounded = Math.ceil(divided);
break;
case 'down':
rounded = Math.floor(divided);
break;
case 'halfDown':
rounded = divided % 1 === 0.5 ? Math.floor(divided) : Math.round(divided);
break;
case 'halfUp':
default:
rounded = Math.round(divided);
break;
}
return rounded * increment;
}
console.log(roundToIncrementWithMode(11.225, 0.05, 'halfUp')); // 11.25
console.log(roundToIncrementWithMode(11.225, 0.05, 'halfDown')); // 11.20
console.log(roundToIncrementWithMode(11.23, 0.05, 'up')); // 11.25
console.log(roundToIncrementWithMode(11.23, 0.05, 'down')); // 11.20
使用 Intl.NumberFormat 和 roundingIncrement
现代浏览器支持 Intl.NumberFormat 中的 roundingIncrement 选项,该选项在格式化数字时可以自动处理增量舍入。
加拿大元五分舍入的基本用法:
const formatter = new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingIncrement: 5
});
console.log(formatter.format(11.23)); // CA$11.25
console.log(formatter.format(11.27)); // CA$11.25
console.log(formatter.format(11.28)); // CA$11.30
roundingIncrement 选项仅接受以下特定值:
- 1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000
对于 0.05 的舍入,设置 maximumFractionDigits: 2 和 roundingIncrement: 5。对于 0.10 的舍入,设置 maximumFractionDigits: 1 和 roundingIncrement: 1,或者保持 maximumFractionDigits: 2 并设置 roundingIncrement: 10。
瑞士法郎舍入到 0.05:
const chfFormatter = new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingIncrement: 5
});
console.log(chfFormatter.format(8.47)); // CHF 8.45
console.log(chfFormatter.format(8.48)); // CHF 8.50
新西兰元舍入到 0.10:
const nzdFormatter = new Intl.NumberFormat('en-NZ', {
style: 'currency',
currency: 'NZD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingIncrement: 10
});
console.log(nzdFormatter.format(15.94)); // $15.90
console.log(nzdFormatter.format(15.96)); // $16.00
您可以将 roundingIncrement 与不同的舍入模式结合使用:
const formatterUp = new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingIncrement: 5,
roundingMode: 'ceil'
});
console.log(formatterUp.format(11.21)); // CA$11.25 (向上舍入)
可用的舍入模式:
ceil:向正无穷大方向舍入floor:向负无穷大方向舍入expand:远离零点舍入trunc:向零点舍入halfCeil:半向正无穷大舍入halfFloor:半向负无穷大舍入halfExpand:半远离零点舍入(默认)halfTrunc:半向零点舍入halfEven:半向偶数舍入(银行家舍入法)
roundingIncrement 的重要限制:
minimumFractionDigits和maximumFractionDigits必须具有相同的值- 不能与有效数字舍入结合使用
- 仅在
roundingPriority为auto(默认值)时有效
检查浏览器是否支持 roundingIncrement:
function supportsRoundingIncrement() {
try {
const formatter = new Intl.NumberFormat('en-US', {
roundingIncrement: 5,
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const options = formatter.resolvedOptions();
return options.roundingIncrement === 5;
} catch (e) {
return false;
}
}
if (supportsRoundingIncrement()) {
// 使用 Intl.NumberFormat 和 roundingIncrement
} else {
// 回退到手动舍入
}
实际实现示例
创建一个根据区域设置处理现金舍入的货币格式化器:
function createCashFormatter(locale, currency) {
// 根据货币定义现金舍入规则
const cashRoundingRules = {
'CAD': { increment: 5, digits: 2 },
'AUD': { increment: 5, digits: 2 },
'EUR': { increment: 5, digits: 2 }, // 一些欧元区国家
'CHF': { increment: 5, digits: 2 },
'NZD': { increment: 10, digits: 2 }
};
const rule = cashRoundingRules[currency];
if (!rule) {
// 不需要特殊舍入
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
});
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: rule.digits,
maximumFractionDigits: rule.digits,
roundingIncrement: rule.increment
});
}
const cadFormatter = createCashFormatter('en-CA', 'CAD');
console.log(cadFormatter.format(47.83)); // CA$47.85
const usdFormatter = createCashFormatter('en-US', 'USD');
console.log(usdFormatter.format(47.83)); // $47.83(无舍入)
处理现金和卡片支付:
function formatPaymentAmount(amount, currency, locale, paymentMethod) {
if (paymentMethod === 'cash') {
const formatter = createCashFormatter(locale, currency);
return formatter.format(amount);
} else {
// 电子支付使用精确金额
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(amount);
}
}
console.log(formatPaymentAmount(11.23, 'CAD', 'en-CA', 'cash')); // CA$11.25
console.log(formatPaymentAmount(11.23, 'CAD', 'en-CA', 'card')); // CA$11.23
计算实际收费金额:
function calculateCashTotal(items, currency) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
// 仅对最终总额应用现金舍入
const cashRoundingRules = {
'CAD': 0.05,
'AUD': 0.05,
'CHF': 0.05,
'NZD': 0.10
};
const increment = cashRoundingRules[currency];
if (!increment) {
return subtotal;
}
return roundToIncrement(subtotal, increment);
}
function roundToIncrement(value, increment) {
return Math.round(value / increment) * increment;
}
const items = [
{ price: 5.99 },
{ price: 3.49 },
{ price: 1.75 }
];
console.log(calculateCashTotal(items, 'CAD')); // 11.25
console.log(calculateCashTotal(items, 'USD')); // 11.23
对非货币值使用增量舍入:
// 温度显示舍入到最接近的 0.5 度
function formatTemperature(celsius) {
const rounded = roundToIncrement(celsius, 0.5);
return new Intl.NumberFormat('en-US', {
style: 'unit',
unit: 'celsius',
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format(rounded);
}
console.log(formatTemperature(22.3)); // 22.5°C
console.log(formatTemperature(22.6)); // 22.5°C
// 滑块值对齐到增量
function snapToGrid(value, gridSize) {
return roundToIncrement(value, gridSize);
}
console.log(snapToGrid(47, 5)); // 45
console.log(snapToGrid(53, 5)); // 55
重要注意事项和边界情况
仅对最终交易总额应用现金舍入规则,而不是对每个单独的项目进行舍入。分别舍入每个项目会产生与对总和舍入不同的结果:
const items = [1.23, 1.23, 1.23];
// 错误:对每个项目进行舍入
const wrongTotal = items
.map(price => roundToIncrement(price, 0.05))
.reduce((sum, price) => sum + price, 0);
console.log(wrongTotal); // 3.75
// 正确:对总额进行舍入
const correctTotal = roundToIncrement(
items.reduce((sum, price) => sum + price, 0),
0.05
);
console.log(correctTotal); // 3.70
向用户显示舍入后的金额,但在数据库中存储精确金额。这可以保留会计的精确性,并允许在不丢失信息的情况下更改舍入规则:
const transaction = {
items: [
{ id: 1, price: 5.99 },
{ id: 2, price: 3.49 },
{ id: 3, price: 1.75 }
],
subtotal: 11.23, // 存储精确值
currency: 'CAD',
paymentMethod: 'cash'
};
// 计算显示金额
const displayAmount = roundToIncrement(transaction.subtotal, 0.05); // 11.25
使用相同货币的不同国家可能有不同的现金舍入规则。欧元在有现金舍入和无现金舍入的国家中都被使用。请检查特定国家的法规,而不仅仅是货币。
负数金额先对绝对值向零舍入,然后再应用负号:
console.log(roundToIncrement(-11.23, 0.05)); // -11.25
console.log(roundToIncrement(-11.22, 0.05)); // -11.20
非常大的数字可能会因浮点运算而丢失精度。对于处理大金额的金融应用程序,建议使用最小货币单位的整数运算:
function roundToIncrementCents(cents, incrementCents) {
return Math.round(cents / incrementCents) * incrementCents;
}
// 使用分为单位
const amountCents = 1123; // $11.23
const roundedCents = roundToIncrementCents(amountCents, 5); // 1125
const dollars = roundedCents / 100; // 11.25
使用边界情况测试您的舍入实现:
// 0.05 舍入的测试用例
const testCases = [
{ input: 0.00, expected: 0.00 },
{ input: 0.01, expected: 0.00 },
{ input: 0.02, expected: 0.00 },
{ input: 0.025, expected: 0.05 },
{ input: 0.03, expected: 0.05 },
{ input: 0.99, expected: 1.00 },
{ input: -0.03, expected: -0.05 },
{ input: 11.225, expected: 11.25 }
];
testCases.forEach(({ input, expected }) => {
const result = roundToIncrement(input, 0.05);
console.log(`${input} -> ${result} (expected ${expected})`);
});
Intl.NumberFormat 中的 roundingIncrement 选项在现代浏览器中受支持。Firefox 在版本 93 中添加了支持。对于支持旧版浏览器的应用程序,请实现回退方案:
function formatCurrency(amount, locale, currency, increment) {
try {
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
roundingIncrement: increment
});
// 检查是否实际应用了 roundingIncrement
if (formatter.resolvedOptions().roundingIncrement === increment) {
return formatter.format(amount);
}
} catch (e) {
// roundingIncrement 不受支持
}
// 回退:手动舍入
const rounded = roundToIncrement(amount, increment / 100);
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(rounded);
}
在面向用户的界面中清楚地记录您的舍入行为。用户需要了解为什么显示的金额与项目总额不同:
<div class="cart-summary">
<div class="line-item">小计:$11.23</div>
<div class="line-item total">总计(现金):$11.25</div>
<div class="note">现金支付舍入到最接近的 $0.05</div>
</div>
不同司法管辖区对舍入的受益方(客户或商家)以及如何处理边界情况有不同的规定。请根据您的具体用例咨询当地法规。