如何在 JavaScript 中将数字四舍五入到最接近的 0.05 或其他增量

了解如何将货币和数字四舍五入到 0.05、0.10 等特定增量,适用于没有小额硬币的国家

简介

加拿大的一位客户将商品加入购物车,总价为 $11.23,但最终结算金额显示为 $11.25。这是因为加拿大在 2013 年取消了 1 分硬币,因此现金交易会四舍五入到最接近的 0.05 元。您的应用需要显示与客户实际支付金额一致的价格。

将数字四舍五入到特定增量可以解决这个问题。您可以将数字四舍五入到最接近的 0.05(5 分)、0.10(1 角)或其他任意增量。这适用于货币格式化、计量系统以及任何需要与特定增量对齐的场景。

本指南将演示如何使用 JavaScript 将数字四舍五入到自定义增量,包括手动实现和现代 Intl.NumberFormat API。

为什么要四舍五入到特定增量

许多国家出于实际原因取消了小面额硬币。生产和流通这些硬币的成本高于其面值。在这些国家,现金交易会四舍五入到最接近的可用硬币面额。

采用 0.05 四舍五入进行现金支付的国家:

  • 加拿大(2013 年取消 1 分硬币)
  • 澳大利亚(取消了 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 保持不变。

手动实现增量舍入

创建一个函数,将任意数字按指定增量舍入:

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) {
  // Find the number of decimal places in the increment
  const decimals = (increment.toString().split('.')[1] || '').length;
  const multiplier = Math.pow(10, decimals);

  // Convert to integers
  const valueInt = Math.round(value * multiplier);
  const incrementInt = Math.round(increment * multiplier);

  // Round and convert back
  return Math.round(valueInt / incrementInt) * incrementInt / multiplier;
}

console.log(roundToIncrementPrecise(11.23, 0.05)); // 11.25

Math.round() 使用四舍五入(half-up rounding)方式,即当数值正好处于两个增量的中间时,向上取整。一些应用需要不同的舍入模式:

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

现代浏览器支持 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: 2roundingIncrement: 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 (rounded up)

可用的舍入模式:

  • 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()) {
  // Use Intl.NumberFormat with roundingIncrement
} else {
  // Fall back to manual rounding
}

实用实现示例

创建一个根据 locale 处理现金舍入的货币格式化器:

function createCashFormatter(locale, currency) {
  // Define cash rounding rules by currency
  const cashRoundingRules = {
    'CAD': { increment: 5, digits: 2 },
    'AUD': { increment: 5, digits: 2 },
    'EUR': { increment: 5, digits: 2 }, // Some eurozone countries
    'CHF': { increment: 5, digits: 2 },
    'NZD': { increment: 10, digits: 2 }
  };

  const rule = cashRoundingRules[currency];

  if (!rule) {
    // No special rounding needed
    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 (no rounding)

处理现金和刷卡支付:

function formatPaymentAmount(amount, currency, locale, paymentMethod) {
  if (paymentMethod === 'cash') {
    const formatter = createCashFormatter(locale, currency);
    return formatter.format(amount);
  } else {
    // Electronic payments use exact amounts
    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);

  // Apply cash rounding only to the final total
  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

对非货币数值使用增量取整:

// Temperature display rounded to nearest 0.5 degrees
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

// Slider values snapped to increments
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];

// Wrong: rounding each item
const wrongTotal = items
  .map(price => roundToIncrement(price, 0.05))
  .reduce((sum, price) => sum + price, 0);
console.log(wrongTotal); // 3.75

// Correct: rounding the total
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, // Store exact value
  currency: 'CAD',
  paymentMethod: 'cash'
};

// Calculate display amount
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;
}

// Work in cents
const amountCents = 1123; // $11.23
const roundedCents = roundToIncrementCents(amountCents, 5); // 1125
const dollars = roundedCents / 100; // 11.25

用边界情况测试你的取整实现:

// Test cases for 0.05 rounding
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})`);
});

roundingIncrement 选项在 Intl.NumberFormat 中已被现代浏览器支持。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
    });

    // Check if roundingIncrement was actually applied
    if (formatter.resolvedOptions().roundingIncrement === increment) {
      return formatter.format(amount);
    }
  } catch (e) {
    // roundingIncrement not supported
  }

  // Fallback: manual rounding
  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">Subtotal: $11.23</div>
  <div class="line-item total">Total (cash): $11.25</div>
  <div class="note">Cash payments rounded to nearest $0.05</div>
</div>

不同司法辖区对取整收益归属(顾客或商家)及边界情况处理有不同规定。请根据你的具体场景查阅当地法规。