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
这三个问题的表现各不相同,但根源都在同一组数字格式选项上。
默认值表
| style | minimumFractionDigits | maximumFractionDigits |
|---|---|---|
decimal | 0 | 3 |
currency | ISO 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):
| roundingMode | 2.23 | 2.25 | 2.28 | -2.23 | -2.25 | -2.28 |
|---|---|---|---|---|---|---|
ceil | 2.3 | 2.3 | 2.3 | -2.2 | -2.2 | -2.2 |
floor | 2.2 | 2.2 | 2.2 | -2.3 | -2.3 | -2.3 |
expand | 2.3 | 2.3 | 2.3 | -2.3 | -2.3 | -2.3 |
trunc | 2.2 | 2.2 | 2.2 | -2.2 | -2.2 | -2.2 |
halfExpand | 2.2 | 2.3 | 2.3 | -2.2 | -2.3 | -2.3 |
halfEven | 2.2 | 2.2 | 2.3 | -2.2 | -2.2 | -2.3 |
ceil 对负数朝 0 走,floor 对正数朝 0 走,这是最容易被「向上取整」「向下取整」这种说法带偏的地方。halfCeil、halfFloor、halfTrunc 的取舍方向分别与 ceil、floor、trunc 一致,区别只在恰好落在中点时。
halfEven 还要看末位奇偶:数值落在 2.22.3 之间时表现同 2.4 之间时表现同 halfTrunc,落在 2.3halfExpand。
这些选项对应 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"
排查顺序
- 先跑
resolvedOptions(),确认roundingMode/roundingIncrement/trailingZeroDisplay是期望值而不是默认值——旧引擎会静默忽略未知选项。 - 检查是否只设了
minimumFractionDigits而漏了maximumFractionDigits,decimal 下后者默认 3。 - 检查
roundingIncrement是否与 significant digits 或非"auto"的roundingPriority混用。 - 检查
roundingIncrement !== 1且未写maximumFractionDigits的情况,mxfdDefault会被mnfdDefault覆盖。 - 检查
style: "currency"下币种的 minor units 是否符合预期(JPY 0 位、KWD 3 位)。 - 分组符和小数点不要做字符串硬比较,改用
formatToParts()或normalize()。 - 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() 就是干这个用的。