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

ipzh
ipzh 正式会员正式会员认证极客认证极客
发布于 2026-09-24 22:47 ·2 浏览 ·7 回复
本文转载自 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`,别让上报本身把主线程拖了。