前端国际化 i18n:Intl API 与消息格式规范

ipzh
ipzh 正式会员正式会员认证极客认证极客
发布于 2026-09-24 22:47 ·3 浏览 ·7 回复

学完这篇,你能把前端多语言从「字符串拼接 + if 判断」升级成一套可维护的规范:数值、日期、货币交给 Intl API 格式化,句子的形态(单复数、性别、语序)交给 ICU 消息格式,改文案不用改代码。

第一步:先把「值格式化」和「句子形态」拆开

很多人 i18n 写崩,是因为把两件事混在模板字符串里。记住分工:

  • Intl API 管值:数字、货币、日期、相对时间、列表连接词。
  • ICU 消息格式管句法:一个句子在不同语言下哪里放变量、变量有几个形态。
const nf = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' });
nf.format(1234.5); // "¥1,234.50"

const dt = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long' });
dt.format(new Date()); // "2025年3月8日"

别自己写 `'¥' + n.toFixed(2)`——千分位、阿拉伯语数字、货币符号位置全由 locale 决定。

第二步:确定消息 key 与文件结构

npm i @formatjs/intl intl-messageformat
npx formatjs extract "src/**/*.{ts,tsx}" --out-file lang/zh-CN.json

用点分层级的英文 key,不要拿中文原文当 key,否则一个错别字就得全库替换。

{
  "cart.items": "{count, plural, =0 {购物车是空的} other {购物车里有 # 件商品}}",
  "user.greeting": "{gender, select, male {先生} female {女士} other {朋友}},欢迎回来"
}

第三步:用 ICU 语法写复数与选择

三种最常用的:

  • `{count, plural, ...}` —— 按数量形态选分支,`#` 代表当前数值。
  • `{n, selectordinal, ...}` —— 序数(第 1、第 2、第 3)。
  • `{gender, select, ...}` —— 按枚举值选分支。

`=0`、`=1` 是精确匹配,`other` 分支必须写,否则运行时报错。

注意:永远不要手写 `n > 1 ? '条' : '条'` 这种判断。英语只有 one/other,俄语有 one/few/many/other,阿拉伯语有 zero/one/two/few/many/other,硬编码必然翻车。

第四步:运行时格式化,并缓存 Intl 实例

import { createIntl, createIntlCache } from '@formatjs/intl';

const cache = createIntlCache(); // 关键:缓存 Intl 实例
const intl = createIntl(
  {
    locale: 'zh-CN',
    defaultLocale: 'zh-CN',
    timeZone: 'Asia/Shanghai',
    messages,
    onError: (e) => console.warn(e.code, e.id), // 缺翻译时别静默吞掉
  },
  cache
);

intl.formatMessage({ id: 'cart.items' }, { count: 3 });

注意:`new Intl.NumberFormat()` 创建开销很大,在列表渲染里每次 new 会明显掉帧,一定要用 `createIntlCache` 或自己维护 Map 缓存。

第五步:做语言协商与兜底链

用户浏览器语言不一定在支持列表里,用 `supportedLocalesOf` 做 best-fit 匹配,再逐级回落:

function pickLocale(supported, requested) {
  const hit = Intl.NumberFormat.supportedLocalesOf(requested, { localeMatcher: 'best fit' })[0];
  return hit || supported[0];
}
const locale = pickLocale(['zh-CN', 'en-US', 'ja-JP'], navigator.languages);

顺序建议:URL 参数 → 用户设置 → `navigator.languages` → 站点默认。URL 参数必须优先,否则分享出去的链接别人看到的语言和自己不一样。

第六步:补 polyfill,并在构建期体检

npm i @formatjs/intl-pluralrules @formatjs/intl-numberformat @formatjs/intl-datetimeformat

入口文件按需引入对应 locale 数据:

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/zh';

同时把编译放进 CI:

npx formatjs compile lang/zh-CN.json --out-file lang/compiled/zh-CN.json
npx formatjs extract "src/**/*.tsx" --out-file /tmp/check.json --throws

`--throws` 能让「代码里用了某个 id 但语言包里没有」直接让流水线失败,比上线后看到 `cart.items` 裸奔强得多。

注意:老版本 Safari 和部分 Node 环境缺少 locale 数据,`Intl.NumberFormat` 会静默退回 en-US,不报错但输出全错,这类问题只能靠 polyfill 加真机测试发现。

第七步:日期一律显式指定时区

new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeZone: 'Asia/Shanghai' }).format(ts);

服务器时间戳是对的,但用户在日本打开就变成另一天。要么统一按站点时区展示,要么明确标注「本地时间」。

小结

  1. 值格式化交给 `Intl.NumberFormat / DateTimeFormat / RelativeTimeFormat / ListFormat`,别手拼符号。
  2. 句法交给 ICU:`plural` 处理数量,`select` 处理枚举,`other` 分支不能少。
  3. Intl 实例必须缓存,否则列表渲染性能会塌。
  4. 语言协商顺序:URL → 用户设置 → 浏览器 → 默认。
  5. 旧环境加 polyfill,构建期用 `--throws` 卡住缺失翻译。
  6. 日期显式指定 `timeZone`,不依赖用户机器。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-585.html
转载请注明出处,版权归原作者所有。

全部回复 7

wbcm
wbcm 见习用户见习用户 1楼 2026-09-24 22:52

这套「Intl 管值、ICU 管句法」的拆法是对的,补几个坑就能直接进生产了。

先提醒一句:你正文在 `onErro` 那里断了,估计 `onError` 那段没贴完——但恰好这是最容易出事的地方。ICU 消息解析失败默认会直接抛异常,一条文案打错就整页白屏。生产环境务必配 `onError`:把 err 上报到监控,同时返回 fallback 消息,别让 i18n 把页面搞挂。

`createIntlCache` 这里补两点:一是 cache 要跟 locale 绑定,切语言时别复用同一个 intl 实例,否则格式化的月份/货币符号会串;二是 `timeZone` 一定显式写死(你写了 `Asia/Shanghai`,对的)。如果 SSR 首屏在服务端算日期、客户端再 hydration,两边时区不一致会出现 hydration mismatch,这个比复数错分支还难查。

复数部分想再加一条:别裸信浏览器自带的 `Intl.PluralRules`。老 Safari 和旧 Android WebView 的 CLDR 数据是旧的,俄语 few/many、阿拉伯语 zero/two 这些分支可能给错结果。要覆盖这些机型就上 `@formatjs` 的 polyfill,或者至少把目标机型的复数规则跑一遍。

延伸建议:把 `formatjs extract` + 编译放进 CI,比对实际用到的 key 和 `zh-CN.json`,缺 key 直接 fail;顺手写个脚本强制校验每条 plural 都有 `other` 分支。这两步能挡掉 90% 的线上文案事故。

pantao
pantao 正式会员正式会员认证极客认证极客 #132 2楼 2026-09-24 22:58
wbcm:这套「Intl 管值、ICU 管句法」的拆法是对的,补几个坑就能直接进生产了。 先提醒一句:你正文在 `onErro` 那里断了,估计 `onError` 那…

这几点补得比正文还值钱,尤其 `onError` 那段——我原文确实断在那儿了,正好借你的楼把它补全:生产环境 `onError` 里两个动作必须都做,上报监控 + 返回 fallback,但注意 fallback 别直接返回空串,那会让用户看到一片空白,返回 key 本身或默认语言文案都行。另外建议 dev 环境直接让它抛,把错误在开发期暴露出来,只有 production 走静默降级,否则本地写了错的 ICU 你还蒙在鼓里。

关于 cache 我再抠一下措辞:`createIntlCache` 这个实例本身是可复用的,真正的坑是别把绑定 locale 的 `intl` 实例跨语言复用。所以要么切语言时重建 `intl` 但 cache 留着(还能吃到 Intl 构造函数缓存,性能反而更好),要么干脆一个 locale 一个 `intl` 上下文。你提的「cache 跟 locale 绑定」如果是指这个意思,那我们的做法一致。

时区那条是真疼。补一个更土但更稳的招:SSR 首屏凡是涉及「今天/昨天/相对时间」的文案,干脆在服务端把已经格式化好的字符串塞进首屏数据,客户端 hydration 直接渲染,不给两边时区差留机会。相对时间尤其危险,`Intl.RelativeTimeFormat` 在跨时区边界会算成 N-1 天。

复数规则的 polyfill 我同意,但实话说多数国内项目机型分布还好,成本收益要算——如果 App 内嵌 WebView 是老安卓那批,别犹豫,直接上 `@formatjs/intl-pluralrules`,几十 KB 换线上不炸不亏。

CI 那条我照抄进公司规范了。再补一句:`formatjs extract` 只能查出代码里用了但 json 里没有的 key,json 里剩的僵尸 key 它不管,建议顺手加个反查脚本,长期能清掉一堆没人用的文案。

wbcm
wbcm 见习用户见习用户 #133 3楼 2026-09-24 23:07
pantao:这几点补得比正文还值钱,尤其 `onError` 那段——我原文确实断在那儿了,正好借你的楼把它补全:生产环境 `onError` 里两个动作必须都做,**上报…

dev 抛、prod 降级这个分工是对的,我再加一道闸:把 ICU 的静态校验放进 CI,把「dev 才发现」的错前移到合并前——反正 `intl-messageformat` 的 parse 是纯函数,跑一遍所有消息就行。

你提的反查脚本我必须泼一盆冷水:`formatjs extract` 对动态 key 是盲的,`t(\`cart.${type}\`)` 这种它要么漏掉、要么只抓到一个前缀,反查脚本会把它们全判成僵尸 key 清掉,然后线上直接炸。所以反查只能当候选清单,别当删除依据;要么统一走一个 key registry 白名单,要么给动态 key 加注释标记,正则先过滤掉再比对。另外 ICU 的 `other` 分支校验也别用正则——直接 parse 每条消息看 AST 里 plural/selectordinal 有没有 other,比正则靠谱一个量级。

SSR 预格式化那条同意,但缓存维度得想清楚:首屏数据一旦进 CDN/HTTP 缓存,key 必须带上 locale + timeZone + 文案版本号,否则用户切了语言、或者你改了文案,拿到的还是旧字符串。还有体积,长列表把每条相对时间都预格式化成字符串,比传时间戳大一截,粗粒度("3 天前")或者只对首屏几条做,后面的客户端算。

`@formatjs/intl-pluralrules` 记得按需只 import 目标 locale 的 data,全量打进去不是几十 KB 的事了。

补个容易忽略的:切语言时如果只重建 `intl` 不重建 cache,按你的说法没问题;反过来(cache 重建、intl 复用)才是真坑,月份和货币符号会串——所以干脆写成 `useMemo([locale])` 一个 locale 一个上下文,最不容易写错。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #134 4楼 2026-09-24 23:14
wbcm:dev 抛、prod 降级这个分工是对的,我再加一道闸:把 ICU 的静态校验放进 CI,把「dev 才发现」的错前移到合并前——反正 `intl-messag…

动态 key 那条我服,反查脚本确实只能当候选清单——但与其给它打注释标记,不如干脆消灭动态 key:`t(\`cart.${type}\`)` 收敛成 `t('cart.item', { type, count })`,用 ICU 的 select/plural 把类型收进参数。这样 key 全静态,extract、反查、IDE 跳转三条路同时通,注释标记那套维护成本反而更高。key registry 白名单适合历史包袱重的项目,新起项目直接参数化更划算。

AST 校验同意,而且 parse 一次能出两个产物:既收集用到的 key,又校验每条 plural/selectordinal 有没有 `other`,一趟遍历干完。正则碰到嵌套结构(plural 里再套 select)就废了,这个不用争。

SSR 缓存维度补一句:版本号别用 build hash,用 messages 内容的 hash。不然改个无关代码触发 rebuild,缓存全失效,等于白做。timeZone 也要进维度——用户在设置里能切时区的话,它跟 locale 是平级的。

`intl-pluralrules` 按需 import 有个前提:locale 运行时才知道,得配合动态 import + code splitting,别在入口静态 import 一串。

最后 `useMemo` 依赖建议写 `[locale, timeZone]` 而不是只写 `[locale]`——时区可切的情况下,只依赖 locale 就会撞上你前面说的月份串味那个坑,等于换个地方复现同一个 bug。

wbcm
wbcm 见习用户见习用户 #135 5楼 2026-09-24 23:23
一个达不溜:动态 key 那条我服,反查脚本确实只能当候选清单——但与其给它打注释标记,不如干脆消灭动态 key:`t(\`cart.${type}\`)` 收敛成 `t(…

动态 key 那条我认一半:参数化能吃掉的是「枚举收敛型」,另一类吃不掉。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 #136 6楼 2026-09-24 23:31
wbcm:动态 key 那条我认一半:参数化能吃掉的是「枚举收敛型」,另一类吃不掉。

同意一半——剩下那一半我说两类,都不是参数化能治的。

第一类是开放集合:key 的全集在编译期根本不存在。插件系统的 `t(\`plugin.${name}.title\`)`、后端下发的错误码 `err.${code}`、配置驱动的文案——key 空间由运行时数据甚至第三方仓库决定,extract 看不见,registry 也白不了,因为随时新增。第二类是组合型:`t(\`btn.${action}.${state}\`)` 不是枚举是笛卡尔积,参数化等于把 N 个 key 换成 N×M 个 ICU 分支,key 是静态了,可读性先崩。

对开放集合,能做的不是消灭动态 key,而是收口 + 兜底 + 可观测:所有动态拼 key 强制走一个 `tDynamic(prefix, ...)` 封装,内部校验白名单前缀,未命中就返回默认语言文案并上报监控。跑一段时间,生产上报的未命中 key 就是最真实的 registry——数据驱动白名单比注释标记自动化一个量级,注释标记本身就是你要维护的第二份真相。插件这类可以再进一步:要求插件 manifest 声明自己占用的 key 前缀,安装时校验,装完就能知道它认领了哪块命名空间。

组合型则建议降层级:`btn.action.submit` 这种拆成两级,或者让后端只回枚举 code、前端维护一张静态 code→key 映射表,extract 立刻又能看见。翻译侧也顺带受益——笛卡尔积拆成两句独立消息,译者不用在 20 个分支里找哪个跟他有关。

坑:未命中上报一定要带 locale + key + 触发页面,只报 key 你只知道漏了、不知道漏在哪个语言哪条路径上;另外别忘了兜底分支本身要参与体积核算,长文案全量兜底进主包,首屏会哭。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #137 7楼 2026-09-24 23:41
不能说的秘密:同意一半——剩下那一半我说两类,都不是参数化能治的。 第一类是**开放集合**:key 的全集在编译期根本不存在。插件系统的 `t(\`plugin.${na…

开放集合/组合型的二分我认同,但"前缀白名单"这个抓手我认为是假的——`plugin.${name}.title` 的前缀永远是 `plugin.`,白名单校验对它形同虚设,真正能机器校验的边界不是前缀存不存在,而是命名空间归属

具体做法:插件 manifest 声明的是具体命名空间(`plugin.weather.`),不是 `plugin.`;`tDynamic` 第一段必须落在调用方自己的命名空间内,跨命名空间直接拒绝并上报。这样能挡住"手滑拼到别人 key 上",也能挡住卸载插件时误删共享前缀文案——`common.*` 这种通用前缀我建议直接禁止插件使用,全部强制 `plugin.<id>.` 下,冲突检测才有意义。

生产上报驱动 registry 这个思路好,但不能做成自动合入白名单——那是拿运行时数据把白名单自我免责,等于没校验。做成构建期快照 + CI delta 报告,"新增 key"非零就提示,人确认后合入。这跟反查脚本是同一定位:只生成候选,不当删除/放行依据。

兜底策略要按场景分,别一刀切。面向用户的错误码(`err.${code}`)走主语言短文案,体验优先;插件缺失文案显示兜底占位并上报,可见性优先;dev 环境直接抛。体积上插件类文案本来就不该进主包,走插件自己的 chunk,主包只留一句极短 fallback 就够,你说的"长文案全量兜底进主包"用这条规则能直接规避。

组合型我加一句:`btn.${action}.${state}` 里 state 如果只是 loading/disabled 这类组件状态,它压根不该进 key——那是设计问题不是 i18n 问题;真正的文案组合(action × 性别/称谓)确实拆成后端 code + 前端静态 map 最实在,翻译侧也不用在 N×M 分支里找人。

监控维度再补一个:构建版本号。不带版本,你修完看到未命中还在涨,分不清是没修好还是旧包缓存;顺带按 key 去重 + 每 key 每版本只报一次,走 `sendBeacon`,别让上报本身把主线程拖了。