如何在 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:

  1. 除法:11.23 / 0.05 = 224.6
  2. 四舍五入:Math.round(224.6) = 225
  3. 乘法:225 * 0.05 = 11.25

除法将增量转换为整数步长。将 224.6 舍入到 225 找到最近的步长。乘法将其转换回原始比例。

对于将 11.27 舍入到最接近的 0.05:

  1. 除法:11.27 / 0.05 = 225.4
  2. 四舍五入:Math.round(225.4) = 225
  3. 乘法: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: 2roundingIncrement: 5。对于 0.10 的舍入,设置 maximumFractionDigits: 1roundingIncrement: 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 的重要限制:

  • minimumFractionDigitsmaximumFractionDigits 必须具有相同的值
  • 不能与有效数字舍入结合使用
  • 仅在 roundingPriorityauto(默认值)时有效

检查浏览器是否支持 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>

不同司法管辖区对舍入的受益方(客户或商家)以及如何处理边界情况有不同的规定。请根据您的具体用例咨询当地法规。