Intl.NumberFormat 不解析:19 位金额串、min2 分组与 -0 的三种显示
信源与规范
- ECMA-402 第 16 章 NumberFormat Objects:https://402.ecma-international.org/ ,规范正文 https://tc39.es/ecma402/#numberformat-objects
- Intl.NumberFormat v3 提案(Interpret Strings as Decimals,ECMA-402 #334):https://tc39.es/proposal-intl-numberformat-v3/#interpret-strings-as-decimals-ecma-402-334
- ToIntlMathematicalValue:https://tc39.es/ecma402/#sec-tointlmathematicalvalue
- MDN 构造器:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
- MDN formatToParts:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/formatToParts
- TypeScript PR #57595(es2023.intl 增加字符串输入声明):https://github.com/microsoft/TypeScript/pull/57595
- 保留尾零提案(尚未落地):https://github.com/tc39/proposal-intl-keep-trailing-zeros
- caniuse:mdn-javascript_builtins_intl_numberformat_format_number_parameter-string_decimal
现象一:JSON 里金额是字符串,parseFloat 之后末位变 0
后端把金额以十进制字符串返回(避免 JSON number 精度丢失),前端照抄老代码:
const nf = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });
const raw = '1000000000000000110000'; // 分(minor unit)
nf.format(parseFloat(raw) * 1e-6); // 错
实际 format 拿到的是 double,1e15 量级只保留 15–16 位有效数字,末位被抹成 0,输出 €1,000,000,000,000,000.10。
正确做法:v3 起字符串输入不再强转 Number,而是走 ToIntlMathematicalValue,按 ECMA-262 StringNumericLiteral 文法解析成「数学值」,全程精确:
nf.format(String(1000000000000000110000n) + 'E-6'); // €1,000,000,000,000,000.11
另一个示例 nf.format('987654321987654321'):
- 旧实现(字符串按 Number 处理):
987,654,321,987,654,300 - v3:
987,654,321,987,654,321
要点:
- 这是显式设计,不是 coercion。TypeScript 5.5 起在
es2023.intl里声明了 StringNumericLiteral(${number}|'Infinity'|'-Infinity'|'+Infinity'),旧版 TS 传 string 会报类型错,很多人用parseFloat消掉多余的类型错误,正好把精度丢了。 - 文法沿用 StringNumericLiteral,所以
"0x10"之类非十进制整数字面量也能被接受,实现通常按 BigInt 精确解析。 - 仍受精度上限约束:整数部分约 300 位、小数 100 位(2023 年 ECMA-402 PR #786 把小数上限从 20 提到 100),超出的部分被舍入。
- 精确的是「输入」,不是「输出」:
maximumFractionDigits/ currency 的 minor unit 仍然生效,currency EUR 默认 2 位。
现象二:1000 不加分隔符,10000 加
useGrouping 在 v3 里从 boolean 扩成枚举:
false:不加分隔符'min2':某组至少有 2 位时才加,所以"1000"不变、"10000"→"10,000"'auto'(默认):按 locale 偏好,可能还跟货币相关'always':即使 locale 不偏好也加true:等价'always'- 字符串
'true'/'false':历史遗留,会被替换回默认值(规范第 24 步的 NOTE 写得很明确)
细节:
notation: 'compact'时默认useGrouping变成'min2',不是'auto'。规范原文:defaultUseGrouping = "auto"; if notation is "compact", set defaultUseGrouping to "min2"。- 分组位置本身是 implementation-defined。formatjs 的实现在 compact 且数值 ≥ 10000 时才分组,理由是对齐 Node 与 Chrome;
de没有千位 compact 形式,所以new Intl.NumberFormat('de', {notation:'compact'}).format(1234) → "1234",而普通 decimal 是"1.234"。 - 特性探测:v3 之前
resolvedOptions().useGrouping只返回 true/false。现在返回三字符串之一,所以new Intl.NumberFormat('und').resolvedOptions().useGrouping === 'auto'可作为探测。
现象三:-0 的三种显示
signDisplay 的五个取值对 IEEE-754 负零的处理不同(负零在格式化走 FormatNumericToString 第 1 步就被识别为 isNegative=true):
'auto'(默认):负数显示符号,含 -0 →"-0"'negative':负数显示符号,但排除 -0 →"0"'exceptZero':正负都显示,零(含 -0)不显示 →"0"、"+1"'always':正零也带号 →"+0"'never':一律不带
accounting 货币写法会改用括号:
new Intl.NumberFormat('bn', {style:'currency', currency:'USD', currencySign:'accounting', signDisplay:'always'}).format(-3500)
// '($3,500.00)'
收尾的几条判据
- 只要输入来自 JSON 字符串、BigInt 或超过 15 位有效数字的金额,就不要经过
Number/parseFloat,直接把字符串交给format()。 - 需要「某组至少 2 位才分组」用
useGrouping:'min2';不要写useGrouping:'true',它会被静默替换成默认值,不报错也不生效。 - 需要兼容旧引擎时,先用
resolvedOptions().useGrouping === 'auto'判断是否支持枚举,再决定传字符串还是 boolean。 - 负零是真实的显示分歧点,报表里
"-0"与"0"会被当成两个值;先确定signDisplay再定数据库口径。 - 反解没有规范兜底,任何实现都要留 round-trip 校验和最大长度限制,否则会把
"1.2.3"之类的脏输入解析成 1.23。 - trailing zero 保留(
nf.format('1.0') === '1.0')目前还在 tc39/proposal-intl-keep-trailing-zeros,没落地;要显示固定小数位只能靠minimumFractionDigits。
规范依据:没有 parse 是故意的
ECMA-402 开篇就写明:
While the API includes a variety of formatters, it does not provide any parsing facilities. This is intentional.
所以「把用户输入的 1.234,56 解析回数字」没有官方 API,可行路径只有一条:拿同一 locale 的 formatToParts 得到分隔符与符号,再剥离。
formatToParts 的 type 全集:integer / group / decimal / fraction / minusSign / plusSign / percentSign / currency / unit / literal / nan / infinity / compact / exponentSeparator / exponentMinusSign / exponentInteger / approximatelySign。
formatRange 的两个边界:
- 少传一个参数抛
TypeError(不是RangeError);start > end抛RangeError。 - 两端舍入后显示一致时,规范走
FormatApproximately,parts 里出现type: 'approximatelySign',值通常是"~",比如maxFrac:0时formatRange(3.1, 3.4)→"~3"。 formatRangeToParts的每个 part 带source: 'startRange' | 'endRange' | 'shared'。
可跑断言
// node intl-numberformat-assertions.js
function check(name, fn) {
try { fn(); console.log('ok ', name); } catch (e) { console.log('FAIL ', name, '->', e.message); }
}
// 1. 字符串按十进制精确解析
check('19 位数字串不被 double 截断', () => {
const s = new Intl.NumberFormat('en-US').format('987654321987654321');
if (s !== '987,654,321,987,654,321') throw new Error(s);
});
check('BigInt 分单位转字符串带 E-6 精确到分', () => {
const nf = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });
const s = nf.format(String(1000000000000000110000n) + 'E-6');
if (!s.endsWith('000.11')) throw new Error(s);
});
check('parseFloat 路径确实丢精度(对照)', () => {
const bad = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' })
.format(parseFloat('1000000000000000110000') * 1e-6);
if (bad.endsWith('.11')) throw new Error('本例未复现,检查引擎版本');
});
// 2. useGrouping
check('min2: 1000 不分组', () => {
const s = new Intl.NumberFormat('en-US', { useGrouping: 'min2' }).format(1000);
if (s !== '1000') throw new Error(s);
});
check('min2: 10000 分组', () => {
const s = new Intl.NumberFormat('en-US', { useGrouping: 'min2' }).format(10000);
if (s !== '10,000') throw new Error(s);
});
check("字符串 'false' 被替换为默认值", () => {
const o = new Intl.NumberFormat('en-US', { useGrouping: 'false' }).resolvedOptions();
if (o.useGrouping === 'false') throw new Error('未被替换');
});
check('compact 默认 useGrouping 是 min2', () => {
const o = new Intl.NumberFormat('en-US', { notation: 'compact' }).resolvedOptions();
if (o.useGrouping !== 'min2') throw new Error(String(o.useGrouping));
});
check('特性探测:resolvedOptions 返回字符串', () => {
const v = new Intl.NumberFormat('und').resolvedOptions().useGrouping;
console.log(' useGrouping =', v);
});
// 3. -0 与 signDisplay
const show = (signDisplay, v) => new Intl.NumberFormat('en-US', { signDisplay }).format(v);
check('auto 对 -0 输出 -0', () => {
if (show('auto', -0) !== '-0') throw new Error(show('auto', -0));
});
check('negative 对 -0 输出 0', () => {
if (show('negative', -0) !== '0') throw new Error(show('negative', -0));
});
check('negative 对 1 不带号', () => {
if (show('negative', 1) !== '1') throw new Error(show('negative', 1));
});
check('exceptZero 对 0 不带号', () => {
if (show('exceptZero', 0) !== '0') throw new Error(show('exceptZero', 0));
});
check('always 对 0 输出 +0', () => {
if (show('always', 0) !== '+0') throw new Error(show('always', 0));
});
// 4. formatRange
check('formatRange 少参数抛 TypeError', () => {
let err;
try { new Intl.NumberFormat('en-US').formatRange(1); } catch (e) { err = e; }
if (!(err instanceof TypeError)) throw new Error('没有抛 TypeError');
});
check('formatRange start > end 抛 RangeError', () => {
let err;
try { new Intl.NumberFormat('en-US').formatRange(2, 1); } catch (e) { err = e; }
if (!(err instanceof RangeError)) throw new Error('没有抛 RangeError');
});
check('舍入后相同输出 approximatelySign', () => {
const parts = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).formatRangeToParts(3.1, 3.4);
if (!parts.some((p) => p.type === 'approximatelySign')) throw new Error(JSON.stringify(parts));
});
用 formatToParts 反解数字
反解实现(要点):
- 先取 symbols:
new Intl.NumberFormat(locale).formatToParts(-1234.5)里读group/decimal/minusSign的实际字符,不要硬编码','和'.'。 - 千位分隔符未必是空格,也未必是 NBSP:
fr-FR是 U+202F(narrow no-break space),de是'.',en-IN存在次级分组(2 位)。 - 负数符号不一定是
'-':某些 locale 用 U+2212,阿拉伯语系会在数字前插 U+061C(ALM)。 - 剥完之后务必做 round-trip 校验:把解析结果再
format一次,与输入字符串比较;不一致就判为无效输入。这比正则更可靠,因为分组规则由实现决定。 - percent 样式会把数值乘 100,解析时要反向除;货币符号要按
currencyDisplay判断('code'时是'EUR ','narrow'时是'€')。 - 精度:
Number()只保证 double。金额场景解析回 BigInt/字符串十进制(如末尾不动),别经过 Number。
// 接上面的 check
function symbolsOf(locale) {
const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: 2 }).formatToParts(-1234.5);
const pick = (t) => (parts.find((p) => p.type === t) || {}).value;
return { group: pick('group'), decimal: pick('decimal'), minus: pick('minusSign') };
}
function parseLocalized(str, locale) {
const { group, decimal, minus } = symbolsOf(locale);
let negative = false;
let s = str.trim(); // 注意:某些 locale 前后是 NBSP / U+202F,不要只 trim 空格
if (minus && s.includes(minus)) { negative = true; s = s.split(minus).join(''); }
if (group) s = s.split(group).join('');
if (decimal) s = s.replace(decimal, '.');
// percent 样式要反向除 100;货币符号、unit 文本需先剥离
const digits = s.replace(/[^0-9.+-]/g, '');
if (!/^-?\d+(\.\d+)?$/.test(digits)) return NaN;
const n = Number(digits);
// round-trip 校验
const back = new Intl.NumberFormat(locale, { minimumFractionDigits: 2 }).format(negative ? -n : n);
if (back !== str) console.warn('round-trip 不一致:', JSON.stringify(back), '!=', JSON.stringify(str));
return negative ? -n : n;
}
check('fr-FR 反解(分组符是 U+202F)', () => {
const nf = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2 });
const text = nf.format(-1234.5);
const n = parseLocalized(text, 'fr-FR');
if (n !== -1234.5) throw new Error(text + ' -> ' + n);
});
check('fr-FR 分组符是 U+202F', () => {
const { group } = symbolsOf('fr-FR');
console.log(' group = U+' + group.codePointAt(0).toString(16).toUpperCase());
});
边界:反解只适用于「已知 locale、已知样式」的受控输入。用户自由输入的文本要先定最大长度和字符集,round-trip 不一致直接拒绝;不要把 NaN 当合法结果传下去。