案例 Node 上 Intl 输出不一致:CLDR 48 的 unit ID 改名、周编号与 de-CH 分隔符

2026-10-20 00:04:18

Node 上 Intl 输出不一致:CLDR 48 的 unit ID 改名、周编号与 de-CH 分隔符

现象

同一个 Node 服务,本地和线上跑同一段 Intl 代码,输出对不上:

  • de-CH 的千分位,本地是 ’,线上是 ,。
  • 某语言的周编号跨年时差一周。
  • 时区短名从 EST 变成 GMT-5。

这类问题集中在升级 Node 大版本、或更换基础镜像之后出现。代码没动,行为变了。

根因:运行时打包了不同版本的 CLDR

CLDR 是数据仓库(UTS #35 / LDML),ICU 是读取它的实现。两边版本对不上,同一个 API 就会给出不同结果。

版本矩阵:

版本时间说明
CLDR 482025-10-29随 ICU 78 发布
CLDR 48.12026-01-08dot release
CLDR 48.22026-03-17dot release
CLDR 48.2.12026-07-08只做 TZDB 2026c 兼容
CLDR 48.2.22026-09-21只做 TZDB 2026d 兼容
CLDR 48.2.32026-09-30只做 TZDB 2026e 兼容
CLDR 492026-10 正式版2026-09 进入 beta

ICU4X 2.3(2026-08-14,Rust)同样使用 CLDR 数据。

所以,同一个 Intl 调用在不同环境输出不同,原因并不在于代码里存在 bug;问题在于各个运行时打包了不同版本的 CLDR。Node 使用 full-icu 还是 small-icu,决定你有没有全量 locale;浏览器自带 ICU;操作系统 glibc 的 locale 与 CLDR 是两套东西(locale 环境变量那条链路另算)。

排查第一步不是读代码,是查版本:

node -p "process.versions.cldr + ' / ' + process.versions.icu + ' / ' + process.versions.unicode"

把这个值连同 Node 版本一起记下来,后面所有对比都以它为前提。

CLDR 48 迁移清单(按命中概率排序)

  1. unit ID 改名:permillion → part-per-1e6,portion-per-1e9 → part-per-1e9;portion 弃用;复合分量 100-kilometer 弃用(因为分母允许整数)。别名保留,解析/格式化跨版本仍可用,但硬编码字符串的地方会拿到 deprecated 行为。英文显示值不变(parts per million)。
  2. 不带 numberSystem 限定的数字 pattern(如 latn/arab)最终移除。自己解析 numbers.json 的代码会拿到空值或直接报错。
  3. era 的 type 属性不再必须从 0 开始;新增 Before Hijri(bh,所有伊斯兰历)、aa/shaka/broc 等 code,删除带历法名的 code(roc 除外)。用数组下标映射 era 的实现必须改。
  4. 周编号机制改为与 ISO 一致(不再基于日历周)。跨年周的归属会变,这就是「差一周」的来源。
  5. supplementalData 里 languageData 的 territories 属性被删除(CLDR-5708),改用 territoryInfo。读过这个字段的统计/语言选择逻辑会静默拿到 undefined。
  6. 时区格式:移除 specific location format,z 的 fallback 行为变了;Etc/Unknown 的 exemplarCity 从 Unknown City 改为 Unknown Location;部分 locale 的短时区缩写(EST)被删或下沉到子 locale。
  7. de-CH 分组符改为 apostrophe(CLDR-13542),fr-CH 在 48.2 跟进;48.2 里 Hv 的 availableFormat 回滚到 CLDR 47 行为,理由是「当前 JS 能力下的 web 兼容问题」。这条很典型:规范想改,浏览器厂商按住不动。
  8. dot release 的修正范围:48.1 只修了 RSD 现金/非现金小数位、旁遮普 exemplar、zh_Hant/yue 数字日期解析、小时周期显示名;48.2 修 emoji 短名冲突、ko/ps 的 AM-PM。

CLDR 49 beta 里提前处理的三件事

  • H24 弃用,遇到时按 H23 处理。
  • core.zip / cldr-common-49.zip 不再包含未达 Basic 覆盖的 94 个 locale(若 50 还没进展就删)。需要这些数据要额外解 cldr-exemplars-delta-49.zip,解到同一目录。
  • 明治以前的日本年号数据删除,理由是取值不确定性大,官方建议直接用公历。

不装 ICU,直接读 cldr-json

适用场景:CI 里跑快照断言、服务端只做少数 locale 的决策、Rust/Go 侧不想背 ICU 依赖。

包版本跟 CLDR 版本一一对应,要锁死:

npm i cldr-core@48.2.0 cldr-numbers-full@48.2.0

数据在 cldr-core/supplemental/:likelySubtags.json、plurals.json、ordinals.json、weekData.json、numberingSystems.json、territoryInfo.json。

下面用 likelySubtags 做 locale maximize(zh → zh-Hans-CN),再取复数类别,全程不碰 Intl:

const { supplemental: { likelySubtags } } = require('cldr-core/supplemental/likelySubtags.json');
const { supplemental: { 'plurals-type-cardinal': cardinal } } = require('cldr-core/supplemental/plurals.json');

const trie = Object.entries(likelySubtags).sort((a, b) => b[0].length - a[0].length);
function maximize(tag) {
  const [lang, script, region] = tag.split('-');
  const key = [lang, script, region].filter(Boolean).join('-');
  for (const [k, v] of trie) if (key === k || key.startsWith(k + '-')) return v;
  return 'en-Latn-US';
}
console.log(maximize('zh-TW')); // zh-Hant-TW
console.log(Object.keys(cardinal.ru)); // pluralRule-count-one/few/many/other

plurals.json 的规则串是 ICU 的复数语法(i = 1 and v = 0 @integer 1)。要自己写解析器,就会落到「解析规则表达式」的坑里;只用类别不求值的话,读 @integer/@decimal 示例列表就够。真要求值,还是交给 Intl.PluralRules 或 ICU。

代价要说清楚:

  • 数据进仓库,仓库变大。cldr-numbers-full 单包是几十 MB 级,按 locale 拆包会好些。
  • fallback 链、继承、别名解析要自己实现。一旦自己实现,就不是「用 CLDR」,而是「重写 ICU 的一部分」。

多数团队的正确解仍然是:用 Intl,但把运行时版本钉住并做断言。

ICU data 不要手改

ICU 里的绝大多数数据由 CLDR 自动生成,手改 .dat 会被下次构建覆盖。要改数据去 CLDR 提 ticket,不要给 ICU 报数据 bug。

ICU 64 起标准 data library 超过 20MB,体积敏感的场景用 ICU Data Build Tool 只打需要的 locale。

CI 里该有的两条断言

  1. 版本快照:把 process.versions.cldr / icu / unicode 打进构建产物或日志,出问题时先看这个。
  2. 行为快照:固定一组 locale 的输出做 golden file,diff 出来就是 CLDR 数据变更,而不是「玄学」。
const snapshot = {
  deCH: new Intl.NumberFormat('de-CH').format(1234567.89),
  week: new Intl.Locale('en-US').weekInfo,           // weekInfo 的 Node 支持版本需自查
  est: new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', timeZoneName: 'short' })
        .formatToParts(new Date('2026-01-15T12:00:00Z'))
        .find(p => p.type === 'timeZoneName').value,
};

deCH 这一项会在 ’ 和 , 之间翻;week 对应第 4 条周编号改动;est 对应第 6 条短时区缩写被删。三个字段足够覆盖这次踩到的大部分差异。

浏览器端控制不了

Chrome / Firefox / Safari 各自打包 ICU 与数据,升级节奏不同,你没法钉住浏览器端的 CLDR 版本,只能做 feature detection,或把关键格式在服务端算好下发。

对跨端一致要求高的金额/日期展示,服务端格式化更省事,代价是失去用户本地时区与偏好。

项目与资料

  • CLDR 主页:https://cldr.unicode.org/
  • 下载与版本表:https://cldr.unicode.org/index/downloads
  • CLDR 48 release note(含 Migration 段):https://cldr.unicode.org/downloads/cldr-48
  • 规范 UTS #35:https://www.unicode.org/reports/tr35/
  • 数据仓库:https://github.com/unicode-org/cldr
  • JSON 数据:https://github.com/unicode-cldr/cldr-json
  • 最新发布总表:https://unicode.org/releases/index.html
复制全文 生成海报 CLDR ICU i18n 国际化 locale Node.js Intl

推荐文章

程序员茄子在线接单