编程 Intl.NumberFormat 不解析:19 位金额串、min2 分组与 -0 的三种显示

2026-11-02 00:04:30

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 当合法结果传下去。

推荐文章

程序员茄子在线接单