ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Rocket.Chat 国际化(i18n)工程实践指南:翻译键的存储、命名、插值与自动校验

2026/9/9 13:44:36 拓冰建站 浏览量
Rocket.Chat 国际化(i18n)工程实践指南:翻译键的存储、命名、插值与自动校验 Rocket.Chat 国际化i18n工程实践指南翻译键的存储、命名、插值与自动校验【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.ChatRocket.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.tsnamespacesMap记录了这五个命名空间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);上面是错误示范。正确写法是把决策交给 i18nextt(message_counter, { count });特殊形态zeroi18next 还支持一个特殊的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-keysen的键按字母序排序大小写不敏感sort-keys每个 locale 遵循en的键顺序wipe-extra-keys语言文件不得包含en中没有的键wipe-invalid-plurals复数形态对该语言必须合法外加zerofind-missing-plurals语言必须定义en定义的全部复数形态replace-2-underscores__name__→{{name}}missing-placeholders/extra-placeholders占位符必须与en完全一致find-duplicate-keysJSON 中不得出现重复键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),仅供参考