编程 Intl.NumberFormat 位数与舍入:只设 minFrac 会把 maxFrac 定成 3,分组符也不一定是空格

2026-10-25 00:04:53

Intl.NumberFormat 位数与舍入:只设 minFrac 会把 maxFrac 定成 3,分组符也不一定是空格

现象与可复现代码

只设 minimumFractionDigits,小数位会被截到 3 位而不是 2 位:

new Intl.NumberFormat("en", { minimumFractionDigits: 2 }).format(4.33145)
// "4.331",不是 "4.33"

原因是 maximumFractionDigits 被补成了默认值 3,问题不在舍入。

用字符串比较法分组符,在 fr-FR 下必然失败:

new Intl.NumberFormat("fr-FR").format(1234.56) === "1 234,56"
// false:实际分隔符是 U+202F NARROW NO-BREAK SPACE,不是普通空格

roundingIncrement 和有效数字一起用会直接抛错:

new Intl.NumberFormat("en", {
  roundingIncrement: 5,
  maximumSignificantDigits: 3,
});
// RangeError

这三个问题的表现各不相同,但根源都在同一组数字格式选项上。

默认值表

styleminimumFractionDigitsmaximumFractionDigits
decimal03
currencyISO 4217 minor units(USD 2、EUR 2、JPY 0、KWD 3)同左
percent—0

有效数字是另一组默认值:minimumSignificantDigits 为 1,maximumSignificantDigits 为 21;只设其中一个时,另一个取默认。

关键联动:只写 minimumFractionDigits 时,maximumFractionDigits 取默认值 3。所以上例的 4.33145 变成 "4.331"。反过来只写 maximumFractionDigits 时,minimumFractionDigits 取 0,不会出现补零。

roundingMode 默认 "halfExpand",即半值远离 0。

roundingMode 九种与默认 halfExpand

可选值:ceil(向 +∞)、floor(向 -∞)、expand(远离 0)、trunc(向 0)、halfCeil、halfFloor、halfExpand、halfTrunc、halfEven。

正负数的方向差异落在 ceil/floor/expand/trunc 上,MDN 对照表(2.23 / 2.25 / 2.28 与 -2.23 / -2.25 / -2.28):

roundingMode2.232.252.28-2.23-2.25-2.28
ceil2.32.32.3-2.2-2.2-2.2
floor2.22.22.2-2.3-2.3-2.3
expand2.32.32.3-2.3-2.3-2.3
trunc2.22.22.2-2.2-2.2-2.2
halfExpand2.22.32.3-2.2-2.3-2.3
halfEven2.22.22.3-2.2-2.2-2.3

ceil 对负数朝 0 走,floor 对正数朝 0 走,这是最容易被「向上取整」「向下取整」这种说法带偏的地方。halfCeil、halfFloor、halfTrunc 的取舍方向分别与 ceil、floor、trunc 一致,区别只在恰好落在中点时。

halfEven 还要看末位奇偶:数值落在 2.22.3 之间时表现同 halfTrunc,落在 2.32.4 之间时表现同 halfExpand。

这些选项对应 ICU 的 UP/DOWN。

roundingIncrement 的枚举限制与 0.05 / 0.1 舍入

允许值只有 1、2、5、10、20、25、50、100、200、250、500、1000、2000、2500、5000,默认 1。不在这个集合里的值会被拒。

镍币舍入(nickel rounding):style: "currency" + maximumFractionDigits: 2 + roundingIncrement: 5,增量是 0.05:

const nickel = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 2,
  roundingIncrement: 5,
});

nickel.format(11.29); // "$11.30"
nickel.format(11.22); // "$11.20"

十美分舍入把 roundingIncrement 换成 10,增量变成 0.10;同理 0.25 用 25。

限制有两条:不能与有效数字舍入混用,也不能在 roundingPriority !== "auto" 时使用,否则抛 RangeError。

规范 SetNumberFormatDigitOptions 第 13 步还有一条:roundingIncrement !== 1 时,mxfdDefault 被设为 mnfdDefault。也就是没显式给 maximumFractionDigits 时,用的是由 minimumFractionDigits 推出来的默认值:

new Intl.NumberFormat("en", { roundingIncrement: 5 })
  .resolvedOptions().maximumFractionDigits;
// decimal 下 mnfdDefault 为 0,mxfd 也被设为 0

所以上面 nickel 的例子必须显式写 maximumFractionDigits: 2,否则小数位会被压没。

trailingZeroDisplay="stripIfInteger"

取值 "auto"(默认,按 minFrac/minSig 保留尾零)和 "stripIfInteger"(小数全为 0 时去掉小数位;有非零小数位时与 auto 相同)。

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  trailingZeroDisplay: "stripIfInteger",
});

usd.format(5);    // "$5"
usd.format(5.01); // "$5.01"

proposal-intl-keep-trailing-zeros 里还提议过 "stripToMinimum"。

roundingPriority 与 compact notation 的隐式归一

roundingPriority 三个值:"auto"(默认,若设了任一 SignificantDigits 则忽略 FractionDigits)、"morePrecision"、"lessPrecision"。

notation: "compact" 且四项都没设置时,auto 会归一为 morePrecision,四项被设为:

{
  minimumFractionDigits: 0,
  maximumFractionDigits: 0,
  minimumSignificantDigits: 1,
  maximumSignificantDigits: 2,
}

所以 new Intl.NumberFormat("en", { notation: "compact" }).format(1234) 得到 "1.2K"——这是 maximumSignificantDigits: 2 生效的结果,并非 fraction digits 在起作用。在 compact 场景下想控制精度,要显式覆盖这些值。

分组符不是空格

fr-FR 的千位分隔是 U+202F NARROW NO-BREAK SPACE,不是 ASCII 空格;de-DE 是 "1.234,56",点作分组符、逗号作小数点。

断言里不要写死空格,用 formatToParts() 按 part.type 判断:

const fr = new Intl.NumberFormat("fr-FR");
const parts = fr.formatToParts(1234.56);

parts.find((p) => p.type === "group").value === "\u202F";   // true
parts.find((p) => p.type === "decimal").value === ",";      // true

或者两边 normalize() 后再比较。resolvedOptions() 可以读出实际生效的 roundingMode / roundingIncrement / trailingZeroDisplay,用来探测引擎是否支持:

const o = new Intl.NumberFormat("en", {
  roundingMode: "halfEven",
  roundingIncrement: 5,
  trailingZeroDisplay: "stripIfInteger",
}).resolvedOptions();

o.roundingMode;         // "halfEven";旧引擎忽略选项,返回 "halfExpand"
o.roundingIncrement;    // 5;旧引擎返回 1
o.trailingZeroDisplay;  // "stripIfInteger";旧引擎无此键

金额用最小单位整数传,格式化只负责显示

format 接受 Number 与 BigInt,BigInt 走精确路径:

new Intl.NumberFormat("en-US").format(12345678901234567890n);

金额本身建议后端以最小单位整数(分)传输,前端只把 Intl.NumberFormat 当显示层用——roundingIncrement、trailingZeroDisplay 这些选项影响的是呈现,不该参与账目计算:

// 后端返回 { amount: 1129, currency: "USD" }
const nf = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
nf.format(1129 / 100); // "$11.29"

排查顺序

  1. 先跑 resolvedOptions(),确认 roundingMode / roundingIncrement / trailingZeroDisplay 是期望值而不是默认值——旧引擎会静默忽略未知选项。
  2. 检查是否只设了 minimumFractionDigits 而漏了 maximumFractionDigits,decimal 下后者默认 3。
  3. 检查 roundingIncrement 是否与 significant digits 或非 "auto" 的 roundingPriority 混用。
  4. 检查 roundingIncrement !== 1 且未写 maximumFractionDigits 的情况,mxfdDefault 会被 mnfdDefault 覆盖。
  5. 检查 style: "currency" 下币种的 minor units 是否符合预期(JPY 0 位、KWD 3 位)。
  6. 分组符和小数点不要做字符串硬比较,改用 formatToParts() 或 normalize()。
  7. compact notation 下确认 digit options 是否被隐式归一,需要精度就显式写。

自检代码:

function supportsNumberFormatV3() {
  const o = new Intl.NumberFormat("en", {
    roundingMode: "halfEven",
    roundingIncrement: 5,
    trailingZeroDisplay: "stripIfInteger",
  }).resolvedOptions();

  return o.roundingMode === "halfEven"
    && o.roundingIncrement === 5
    && o.trailingZeroDisplay === "stripIfInteger";
}

function assertFormat(nf, value, expected, label) {
  const actual = nf.format(value);
  if (actual !== expected) {
    throw new Error(`${label}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`);
  }
}

assertFormat(
  new Intl.NumberFormat("en", { minimumFractionDigits: 2 }),
  4.33145,
  "4.331",
  "minFrac 单独设置"
);

assertFormat(
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    maximumFractionDigits: 2,
    roundingIncrement: 5,
  }),
  11.22,
  "$11.20",
  "nickel rounding"
);

const group = new Intl.NumberFormat("fr-FR")
  .formatToParts(1234.56)
  .find((p) => p.type === "group").value;
if (group !== "\u202F") {
  throw new Error(`fr-FR 分组符不是 U+202F:${group.codePointAt(0).toString(16)}`);
}

版本边界

roundingMode / roundingIncrement / roundingPriority / trailingZeroDisplay 属于 Intl.NumberFormat v3(ES2023 一带)。旧引擎会忽略未知选项,静默按默认 halfExpand 跑,不报错,所以问题往往到线上才暴露。据公开信息,V8 106+(Chrome 106 / Node 19)支持;WebKit 在 2023-08 的 PR #16902 里对齐了 roundingIncrement / roundingMode / roundingPriority 的处理。具体到自己的运行时和 polyfill 组合,需要自行验证——上面的 supportsNumberFormatV3() 就是干这个用的。

参考

推荐文章

程序员茄子在线接单