Node 上 Intl 输出不一致:CLDR 48 的 unit ID 改名、周编号与 de-CH 分隔符
现象
同一个 Node 服务,本地和线上跑同一段 Intl 代码,输出对不上:
de-CH的千分位,本地是’,线上是,。- 某语言的周编号跨年时差一周。
- 时区短名从
EST变成GMT-5。
这类问题集中在升级 Node 大版本、或更换基础镜像之后出现。代码没动,行为变了。
根因:运行时打包了不同版本的 CLDR
CLDR 是数据仓库(UTS #35 / LDML),ICU 是读取它的实现。两边版本对不上,同一个 API 就会给出不同结果。
版本矩阵:
| 版本 | 时间 | 说明 |
|---|---|---|
| CLDR 48 | 2025-10-29 | 随 ICU 78 发布 |
| CLDR 48.1 | 2026-01-08 | dot release |
| CLDR 48.2 | 2026-03-17 | dot release |
| CLDR 48.2.1 | 2026-07-08 | 只做 TZDB 2026c 兼容 |
| CLDR 48.2.2 | 2026-09-21 | 只做 TZDB 2026d 兼容 |
| CLDR 48.2.3 | 2026-09-30 | 只做 TZDB 2026e 兼容 |
| CLDR 49 | 2026-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 迁移清单(按命中概率排序)
- unit ID 改名:
permillion→part-per-1e6,portion-per-1e9→part-per-1e9;portion弃用;复合分量100-kilometer弃用(因为分母允许整数)。别名保留,解析/格式化跨版本仍可用,但硬编码字符串的地方会拿到 deprecated 行为。英文显示值不变(parts per million)。 - 不带
numberSystem限定的数字 pattern(如latn/arab)最终移除。自己解析numbers.json的代码会拿到空值或直接报错。 - era 的
type属性不再必须从 0 开始;新增Before Hijri(bh,所有伊斯兰历)、aa/shaka/broc等 code,删除带历法名的 code(roc除外)。用数组下标映射 era 的实现必须改。 - 周编号机制改为与 ISO 一致(不再基于日历周)。跨年周的归属会变,这就是「差一周」的来源。
supplementalData里languageData的territories属性被删除(CLDR-5708),改用territoryInfo。读过这个字段的统计/语言选择逻辑会静默拿到undefined。- 时区格式:移除 specific location format,
z的 fallback 行为变了;Etc/Unknown的exemplarCity从Unknown City改为Unknown Location;部分 locale 的短时区缩写(EST)被删或下沉到子 locale。 de-CH分组符改为 apostrophe(CLDR-13542),fr-CH在 48.2 跟进;48.2 里Hv的availableFormat回滚到 CLDR 47 行为,理由是「当前 JS 能力下的 web 兼容问题」。这条很典型:规范想改,浏览器厂商按住不动。- 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 里该有的两条断言
- 版本快照:把
process.versions.cldr/icu/unicode打进构建产物或日志,出问题时先看这个。 - 行为快照:固定一组 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