Steven
Steven2 分钟阅读

没有安全网就发布三十种语言

GeekBye v1.8.3 接入了 react-i18next,把整个应用翻译成了 30 种语言。用库的那部分是常规操作。有意思的是缺了什么:没有 key 一致性检查,也没有字符串提取器——于是写死的英文漏了出去,一个丹麦语拼写错误随版本发了出去,changelog 自己都说不清应用到底会说 28、29 还是 30 种语言。

工程
i18n
桌面端
GeekBye 发布
没有安全网就发布三十种语言

有些版本是加一个功能。GeekBye v1.8.3 看上去就是——"应用现在会说 30 种语言了"——但这个功能是轻松的那部分,也不是这篇文章要谈的。在 2026 年 2 月 17 日到 19 日之间,两个 pull request 把 react-i18next 接进了应用,把每一个写死的英文字符串重构成了一次翻译调用,并打包了三十个语言文件。那是实打实的活,但那正是一个库被设计出来就是为了让它变成常规操作的那类活。有意思的工程,是这个版本_没_去建的一切——那张本该接住它带着 bug 发出去的东西的安全网。

接线是轻松的那 80%

那个基础设施 commit 在 i18next 之上加了 react-i18next,做的是常规的事:一个 i18n/index.ts 调用 initReactI18next,静态导入全部三十个语言 JSON,并把它们注册到一个 resources 对象下。没有懒加载——每种语言都打进了包里。翻译字符串放在 src/i18n/locales/*.json 里,是扁平的、以点号分命名空间的映射:sidebar.meetingsstartMeeting.start,一个 key 对一个字符串。

从写死英文到 t('key') 的迁移是老老实实做的,一组组件接一组组件地做,你能从 commit log 里读出这一遍扫过:先是侧边栏、导航和聊天;然后是设置、登录和 onboarding;再然后是会议、仪表盘、toast 和导航栏的 tooltip。每个 commit 都把一批字面字符串换成翻译调用。二十九个非英语文件在一个 commit 里落地——一次九千行的投放,把英文源翻译成了从保加利亚语到越南语的一切。

这些没有哪一样是难做对的,因为做_错_是很响的:缺了 provider 会崩,格式坏掉的 JSON 解析不了,一个错的 import 会把构建搞垮。库为那些机械性的错误给了你护栏。它没给你的,是一道能接住那个真正发生了的错误的护栏。

三个语言设置,以及为什么这是对的

在讲哪里坏掉之前,先说一件 v1.8.3 做得完全正确的事,因为这是一个很多应用把它压扁了、而不该压扁的区分。这个版本之后,GeekBye 有了_三_个各自独立的语言设置:

  • AI 回答语言output_language)——助手用什么语言回答。三者里最老的,来自 2025 年 11 月。
  • 转录语言transcriptLanguage)——语音引擎把你的麦克风转录成什么语言。早几个版本就铺好了,在 v1.7.6。
  • 应用语言app_language)——按钮、菜单和 onboarding 的语言。v1.8.3 里新加的。

这些是真正不同的维度。柏林的一个开发者可能想要一个德语界面、英语的 AI 回答(因为那是他正在粘贴的文档所用的语言)、还有英语转录(因为会议就是用英语开的)。把这些压进一个"语言"设置里,对这个人来说会是错的,而这个版本里加的 onboarding 步骤正是为了这个原因把三者都摆成了各自独立的下拉框。

那个贴心之处在于升级路径。一次性的迁移在更新后首次启动时,把每个现有用户的 output_language 拷贝进新的 app_language key,而 provider 通过一条回退链来解析语言——先 app_language,然后 output_language,最后 'en'。于是那些早已告诉应用他们想要西班牙语 AI 回答的用户,醒来看到的是一个西班牙语界面,而不是被重置成英语、再被逼着去到处找那个设置。用一个相关的旧偏好去播种一个新偏好,是一份小小的善意,只花一次迁移,就替每个升级的用户省下了一次恼人的小刺。

那个不肯重绘的 onboarding

有一个从 i18n 工作里值得单拎出来讲的真 bug,因为它是任何做运行时语言切换的人都会踩到的一个 React 陷阱。onboarding 向导在一个 useMemo 里构建它的一些选项列表。当你_就在语言这一步上_切换语言时,UI 本该立刻用新语言重绘——但它没有,因为 t 不在这个 memo 的依赖数组里。翻译函数在语言变化时会换掉自己的身份;如果你 memo 出来的值是用 t 构建的、却不依赖它,React 就会开开心心地把那个过期的、上一个语言的版本递给你。这个修复在 log 里出现了两次——一次是为向导,一次是为设置里的下拉列表——这告诉你,在你学会永远把 t 列进去之前,这是那种你在每一个 memo 化翻译内容的地方都会犯的错。

onboarding 还带着一个 changelog 没提的褶皱:因为这是一个 white-label 的代码库,有_两_棵 onboarding 树要翻译——一个 GeekBye 向导和一个 Pavleur 向导——分别活在并行的 onboarding.*onboarding.pavleur.* key 命名空间下。每一个 onboarding 字符串都被翻译了两遍,翻成两种品牌的口吻,横跨全部二十九种语言。

构建自己都不知道它缺了什么

这里是让这件事成为一个教训、而不是一份清单的那部分。v1.8.3 没有任何自动化的办法去提取写死的字符串,也没有一个跨语言检查 key 一致性的 CI 检查。于是"做完"的定义就是"某个人访问了他记得的那些组件,把他看见的那些字符串包了起来"。这个定义会漏。

发布两周后,一个清理 commit 落了地,消息是_"把 10 个组件里所有写死的英文字符串翻译进 30 种语言"_。十个组件——问题反馈表单、摘要和指标标签页、报告标签页、转录和聊天内容、用量上限提示、聊天消息、复制代码按钮、悬浮操作按钮——在 v1.8.3 里发出去时,对每一个人、在每一种语言里,都还在说英语。没有谁的构建失败了。没有测试变红。一个德国用户只是在报告标签页里看到了英文,而唯一的探测器,是一个人最终看到了它。

同样的缺席还制造了那些更小的尴尬。一个丹麦语翻译带着一个错的单词发了出去——Luk application helt,而不是 Luk applikationen helt——并在后来的一个版本里被补上,因为没有母语者的审核关卡,也没有办法让流水线知道。"退出应用"的 key 在最初那批里就缺了,几天后才回填。而之后发的每一个功能——一个修饰键预设、麦克风静音、回顾 chips、悬浮窗透明度——都永远地在身后拖着一个属于自己的"把新 key 翻译进 29 种语言"的 commit,因为没有任何东西会自动去维持这条不变量。

你甚至能在 commit 消息里量出这份失序。同一套三十个语言文件,一个版本接一个版本地,被描述成"30 种语言"、"29 种语言"、还有——在一个 commit 里——"28 个 locale"。什么都没加、什么都没删;从 v1.8.3 到 v2 的每一个 tag 上都是三十个文件。这个数字在漂移,是因为"我们发多少种语言"没有一个唯一的事实来源,于是每个 commit 作者都靠手数,有时把英语算作一种语言,有时把它算作源。一个连自己的计数都稳不住的 changelog,是一个小小的、诚实的信号:它所描述的那件事,并没有被一台机器强制维持着。

复数这颗隐雷

还有一件事值得标出来,不是因为它响亮地坏了,而是因为它正是这套配置招来、又不会警告你的那类 bug。这些语言文件里的复数是手动做的,用成对的 key:meetings.meetingCountmeetings.meetingCountPluralprofiles.filesAttachedfilesAttachedPlural。那是一个干净的英语单/复数拆分。它对应用如今声称支持的一大块语言来说,也是错的。俄语、波兰语、捷克语根据数字有三种或四种复数形式——"1 个文件"、"2 个文件"、"5 个文件"和"21 个文件"可以取不同的词尾。一个两 key 的单/复数方案表达不了这个,于是那些计数渲染出来在语法上是走样的。i18next 恰好有为此而生的真正的复数机制;这些手动配对绕过了它,而没有一个 commit 承认这个缺口。这是写死字符串那种漏的复数版:对构建不可见,只对母语者可见。

这个版本教给我们的三件事

  1. 库是轻松的 80%;工具是难啃的 20%。 接上 react-i18next、批量翻译字符串,是一个已解决的问题。让三十个文件永远保持一致,不是——它需要一个提取写死字符串的提取器,和一个检查缺失 key 的 CI 检查,这两样库都不会替你交付。在你声称"完整 i18n"_之前_把它们建好,否则你声称的就是一条你根本没办法守住的不变量。
  2. 把那些真正不同的语言设置拆开。 界面语言、转录语言、AI 回答语言是三个维度,v1.8.3 给每一个都配了自己的 key,是对的——而通过迁移从一个相关的旧设置里播种那个新的,是更对的,于是升级对用户来说什么代价都没有。
  3. 你 changelog 里的计数是一个纪律指标。 当同一套文件在三个 commit 里是 28、29、30 种语言时,问题不在那个数字——问题在那个缺失的事实来源。如果一台机器没在数你的语言,那就没人会两次数出同一个数。

v1 故事的上一章,见围绕一张波浪图重新设计仪表盘(v1.8.2);整段弧线则见把软件打磨到完美地发布出去的解剖学