Rocket.Chat 国际化(i18n)工程实践指南:翻译键的存储、命名、插值与自动校验
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
Rocket.Chat 的翻译体系以@rocket.chat/i18n包为核心,为 Web 客户端、Meteor 服务器以及omnichannel-transcript等微服务提供共享的语言资源。本文是仓库内 docs/i18n.md 的深度展开:先梳理 68 个语言文件如何组织、为何en是唯一事实来源;再逐一讲解翻译键的命名规范、五大命名空间、i18next 插值与复数规则;随后结合源码剖析三个运行时如何初始化、服务端为何必须显式传lng,最后介绍内置 i18n 代码检查器所强制的全部规则。读者读完既能写出一条符合规范的翻译键,也能解释构建时类型生成、lint:fix自动排序等底层机制。客户端特有的Trans组件与转义约定不在本文范围,详见 docs/frontend/i18n.md。
翻译资源在哪里、如何组织
所有翻译都是扁平 JSON 文件,放在packages/i18n/src/locales/目录下,一个语言一个文件,按<language>.i18n.json命名。当前仓库内共有 68 个语言文件,从af.i18n.json(南非荷兰语)到zh.i18n.json(简体中文),其间覆盖ar、de、fr、ja、ko、pt-BR、ru、tr等主流语言以及zh-HK、zh-TW等地区变体。
这些文件描述的是键(key)与字符串(value)的映射,而非文档中的占位符介绍,因此任何消费@rocket.chat/i18n的运行时——无论是浏览器还是 Node 服务——看到的都是同一份资源集合。
en.i18n.json是唯一的基准语言
en.i18n.json是base language,也是新增功能时唯一允许手工编辑的文件。围绕它形成三条硬性约定:
- 缺键回退:任何 locale 缺失的键都会回退到
en,每个运行时的初始化参数都带fallbackLng: 'en'。 - 多余键即陈旧:某个 locale 中存在、但
en中不存在的键会被判定为过时残留,由检查器的wipe-extra-keys任务删除。 - 顺序继承:其他 67 个语言文件的键顺序完全由
en推导而来,所以重排en里的键就等于重排所有语言文件。
这也意味着:为新功能添加翻译时,只需要在en.i18n.json里追加键。其他语言的翻译由外部流程单独提供,不要为自己的功能手写其他语言的翻译。
键的联合类型是从en构建时生成的
仓库中packages/i18n/src/resources.ts是一个刻意保留的"假文件"(dummy),内容只是core.key1、onboarding.key1之类的占位联合类型。真正的键联合类型RocketchatI18nKeys是在构建阶段由脚本根据en.i18n.json实时生成到dist/resources.d.ts中的。在src/scripts/build.mts中可以看到这段逻辑:遍历 base language 的每个键,输出一个RocketchatI18n接口,再取keyof得到RocketchatI18nKeys。
需要特别注意的是:拼错的键不是编译错误。虽然packages/i18n/src/index.ts通过模块增强给 i18next 的TFunction追加了以RocketchatI18nKeys为参数类型的重载,但这是"添加过载"而非"收窄签名"——文档明确指出这是为类型检查性能而刻意避免的收窄。因此验证键名要靠 grep 基准语言文件,而不是编译器。刚添加的键在重新构建包之前也不会进入生成的类型:
yarn workspace @rocket.chat/i18n build该构建还会把每个 locale 逐一写入dist/resources/,并生成dist/languages.js语言清单文件。
翻译键的命名规范
代码库主导的命名约定是大写下划线式(Capitalized snake case),即Sentence_case_with_underscores,这也是Sentence_case_with_underscores被i18next识别为普通字符串的示例形式:
{ "Cam_on": "Camera on", "Delete_room": "Delete room", "You_are_offline_please_reconnect": "You are offline, please reconnect" }用"含义"命名,而不是用"字面值"或"渲染位置"命名
命名的第一原则是:让键描述语义,而不是描述当前文案,更不是描述它渲染在哪个按钮上。Delete_room在文案从 "Delete room" 改成 "Remove channel" 时依然成立;而Delete_room_red_button这样的键会随着 UI 细节变化立刻失效。这类键的前三个示例在en.i18n.json中都能直接检索到(如"Cam_on": "Camera on")。
第二原则是:含义完全相同时复用已有键。但仅仅因为英文恰好相同就复用是危险的——按上下文变形的语言(如需要性、数、格配合的语种)会需要分开的键。更关键的是,事后拆开一个被共享的键,对所有语言文件都是一次破坏性变更,代价极高。
命名空间:恰好五个
键可以被最多五种命名空间之一作为前缀,以点号分隔(i18next 的nsSeparator: '.'):
core(默认) ·onboarding·registration·cloud·subscription
{ "onboarding.component.form.action.next": "Next", "subscription.callout.title.limitsReached": "Limits reached" }无前缀的键自动落在core命名空间。这套集合定义在packages/i18n/src/index.ts:namespacesMap记录了这五个命名空间,defaultTranslationNamespace为core。命名空间的目的是让客户端按需加载资源子集(例如只用core和onboarding),而不是充当一般性的分组工具——从源码extractTranslationNamespaces的实现看,它只是按前缀把扁平键拆回五个对象。
还要注意命名空间内部键的风格差异:core里用大写下划线,而命名空间内部(如onboarding、subscription)的键沿用现有条目,使用小写点号路径(onboarding.component.form.action.next)。
插值(Interpolation)
运行时文案需要动态值时,使用 i18next 的命名占位符{{likeThis}},占位符名称采用 camelCase:
{ "Room_removed": "Room {{roomName}} removed from ABAC management" }三种占位符形态与三种废弃形态
基础语言里至今还残存两类废弃写法,新增键时严禁模仿:
| 形态 | 状态 |
|---|---|
{{name}} | ✅ 正确,应使用 |
__name__ | ❌ 已废弃,由检查器自动改写(replace-2-underscores) |
%s | ❌ 传统 sprintf,基于位置传参,属历史遗留 |
sprintf形式目前在运行时仍然有效:无论是客户端还是 Meteor 服务器,都安装并启用了i18next-sprintf-postprocessor,通过packages/i18n/src/index.ts导出的addSprinfToI18n把t包裹起来——当参数是一个数组时,它会把t(key, replaces)转成t(key, { postProcess: 'sprintf', sprintf: replaces })。但它是位置式的:翻译者一旦调整句子语序,参数就会悄悄错位。因此不要新增任何%s键。当前 base locale 中仍可直接 grep 到 6 处%s,由find-sprintf-params任务持续标记为 backlog。
另外,部分键名也内嵌了旧标记,例如Added__username__to_team、__count__result_found(两者在en.i18n.json中都能检索到)。这仅是命名上的历史遗留,其值使用的是{{...}}占位符,语义正确。新键不要模仿这种命名。
严禁用碎片拼接句子
词序并不是普适的,而翻译者只能看到你拼出来的碎片。下面这种写法是错误的:
`${t('Deleted')} ${count} ${t('messages')}`;正确做法是让一个键承载整个句子:
t('Messages_deleted', { count });携带计数的键还需要配套复数形式,因此Messages_deleted在语言文件里应定义为一个复数对象(见下文"复数化")。
需要区分的是:用「标签键 + 运行时值」组合出Label: value这样的键值对是允许的;把一段散文拆到多个键里才是不允许的。
格式化器(Formatters)
占位符后加逗号即可挂载 i18next 格式化器。所有运行时都内置基于Intl的内建格式化器:
{ "Exceeded_limits": "Your workspace exceeded the {{val, list}} license limits.", "Seats_used": "{{count, number}} seats used" }项目里还有一个自定义格式化器capitalize,但只在客户端注册(见apps/meteor/client/providers/TranslationProvider.tsx)。它存在的意义是:某些语言需要不同的词序,翻译者可以在翻译文件内部把"落在句首的那个词"首字母大写,而无需改代码。当前en中没有键使用它。注意:不要在一个服务器也会渲染的键里用它——服务器没有注册该格式化器,值会原样透传、不生效。
复数化(Pluralization)
需要随数量变化文案时,把键定义成一个复数形式对象,并在调用时传入count,由 i18next 依据该语言在 CLDR 中的复数规则挑选形态:
{ "message_counter": { "one": "{{count}} message", "other": "{{count}} messages" } }对英语而言只有one和other两种;其他语言则不同——例如阿拉伯语有六种复数形态。这正是不能手写判断的原因:
count === 1 ? t('message_counter_one') : t('message_counter_other');上面是错误示范。正确写法是把决策交给 i18next:
t('message_counter', { count });特殊形态zero
i18next 还支持一个特殊的zero形态,用于"空状态文案读起来比 '0 items' 更自然"的场景:
{ "Calls_in_queue": { "zero": "Queue is empty", "one": "{{count}} call in queue", "other": "{{count}} calls in queue" } }但只有当措辞确实不同时才加zero——对英语而言 0 已经能被other覆盖,没必要重复定义。
复数形态是按语言逐一校验的:不属于该语言 CLDR 形态集的形态会被wipe-invalid-plurals剥离(合法集合是zero、one、two、few、many、other,其中zero为 i18next 特例);而某个 locale 缺少en已定义的形态,则会被find-missing-plurals报告。相关实现可以分别在src/scripts/check.mts与src/scripts/common.mts(后者通过 i18next 的pluralResolver取各语言复数后缀)中看到。
服务端使用:三个运行时与"必须传 lng"
客户端、Meteor 服务器与独立服务共享同一份资源,但初始化方式不同:
| 运行时 | 初始化 |
|---|---|
| 客户端 | apps/meteor/client/providers/TranslationProvider.tsx——en随包静态内置,非英语活动语言通过 HTTP 按需加载 |
| Meteor 服务器 | apps/meteor/server/lib/i18n.ts—— 启动即加载,全部 68 个语言常驻内存 |
omnichannel-transcript服务 | ee/apps/omnichannel-transcript/src/i18n.ts—— 与服务器相同的全量预载形态 |
在服务器代码里应当导入共享实例而不是自己 new 一个:
import { i18n } from '../../app/utils/lib/i18n';服务端每次调用都要显式传lng
文档直言:这其实暴露了服务端 i18n 设计上的一个缺口。
服务端实例以lng: 'en'初始化,且没有任何"按请求取语言"的上下文。漏传lng不会报错——它只是静默地返回英语。在约 200 个服务端调用点中,只有大约三分之一传了lng,所以周边代码不能作为可靠参照。
错误示范——无论接收者是谁都返回英语:
i18n.t('Username_and_message_must_not_be_empty');正确示范:
i18n.t('Username_and_message_must_not_be_empty', { lng: user.language || settings.get('Language') || 'en' });这条回退链——接收者的语言 → 工作区Language设置 →en——是既定的通行写法;目前还没有共享的辅助函数,所以每个调用点都是这么显式写出来的。
选语言时遵循一条准则:取阅读这段字符串的人的语言,而不总是当前操作用户的语言。通知、邮件、导出文件都是渲染给接收者看的。
不要在 API 边界翻译
更优的做法是:接口只返回键,由客户端负责翻译——这也是绝大多数接口已经在做的。原因是客户端天然知道读者的语言,而服务端必须被"告知"。因此,新接口应优先返回翻译键而不是翻译后的字符串。
独立的子系统:packages/livechat
要注意,packages/livechat拥有自己的一套翻译,在src/i18n/下,与@rocket.chat/i18n完全无关。这套体系有自己的特点:语言文件是普通的<language>.json,统一嵌套在单个translation根键下,键采用lower_snake_case,复数用_one/_other键后缀而非嵌套对象表达。
本文描述的所有规则——包括代码检查器——对 livechat 都不适用。反过来也一样:不要在这两套体系之间互相照搬约定。
代码检查器(linter)强制了什么
在packages/i18n目录下执行:
yarn workspace @rocket.chat/i18n lint会运行 ESLint 加上src/scripts/check.mts中实现的自定义检查任务。绝大多数问题都可以用lint:fix自动修复:
yarn workspace @rocket.chat/i18n lint:fix检查任务一览:
| 任务 | 规则 |
|---|---|
sort-base-keys | en的键按字母序排序(大小写不敏感) |
sort-keys | 每个 locale 遵循en的键顺序 |
wipe-extra-keys | 语言文件不得包含en中没有的键 |
wipe-invalid-plurals | 复数形态对该语言必须合法(外加zero) |
find-missing-plurals | 语言必须定义en定义的全部复数形态 |
replace-2-underscores | __name__→{{name}} |
missing-placeholders/extra-placeholders | 占位符必须与en完全一致 |
find-duplicate-keys | JSON 中不得出现重复键 |
trim-eof | 文件末尾不得有尾随空白 |
排序的两处细节与执行顺序
sort-base-keys必须先于sort-keys运行,因为其他所有语言文件的顺序都由en推导而来。新增的键放在en的任何位置都可以——lint:fix会自动把它挪到正确位置,并同步重排其他 67 个文件。
但有两处排序细节不是字母序,而是 JavaScript 本身强制的(对应实现见src/scripts/check.mts的isIntegerLikeKey与compareBaseKeys):
- 整数样式的键排最前(如
"500"):因为JSON.parse无论文件里怎么写,都会把这类键提升到对象最前面,排序必须与实际 parse 结果一致才能让 lint 通过; - 仅大小写不同的键(如
Private/private,当前有 69 对):在大小写不敏感比较下会"打平",需要再用纯码点比较打破平局,保证顺序唯一且规范。
find-sprintf-params:定义了但不进默认运行
有一个任务已定义却被排除在默认运行之外,因此不会让构建失败——find-sprintf-params,它负责标记en中残留的%s(当前可实测为 6 处)。它被排除是因为存在历史 backlog,不应借功能 PR 顺手"顺手清理"它。想在不改动任何文件的前提下检查,可以单跑:
cd packages/i18n && node --experimental-transform-types ./src/scripts/check.mts -t find-sprintf-params-t参数支持传递任务名,会清空默认任务集合、只执行指定的检查。
提交规范
仅含翻译改动的提交(translation-only changes)使用i18n:作为 commit 类型前缀,遵循仓库 pull request 模板的约定。把 key 改动与功能逻辑改动分开提交,能让翻译相关的审阅与后续语言同步都更清晰。
小结:一份可直接照做的检查清单
最后把整篇指南浓缩成写新翻译键时的自检清单:
- 只编辑
packages/i18n/src/locales/en.i18n.json,追加的键用Sentence_case_with_underscores或对应命名空间内既有的小写点号路径风格; - 语义相同就复用旧键,语义不同绝不共用;不要用渲染位置、颜色等 UI 特征命名;
- 动态值一律用
{{camelCase}},绝不用%s、__name__或碎片拼接句子; - 带计数的键定义成复数对象并传
count,把复数决策交给 i18next 的 CLDR 规则; - 服务端渲染的文案务必按"接收者语言 →
Language设置 →en"的链条显式传lng;新接口优先返回键、在客户端翻译; - 最后跑一次
yarn workspace @rocket.chat/i18n lint:fix,让排序、占位符一致性、陈旧键清理等规则自动落地。
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考