background blurbackground mobile blur

1/1/1970

我如何在 3 天内把 i18n 做到支持 20 种语言

嗨!我刚干完一件超级大的活,把 Foony 翻译成了 20 种不同的语言。这次改动几乎动到了代码库里的每一个文件,不过最后我还是在短短 3 天内把它搞定了。

下面我会拆开说说我是怎么做的,这次改动背后具体有哪些数字,还有为什么我又一次选择自己写一个翻译库,而不是用业界标准。

为什么不用 i18next?

一开始考虑加多语言的时候,我当然也先看了业界标准:i18nextreact-i18next

但最后我决定优先考虑的是让 AI 更好维护i18next 很强大,不过它的 API 形态太多,LLM 很容易幻觉、写出风格不一致的代码。把这个库收紧到只暴露简单的 t()interpolate(),就可以保证十几个并行的 Agent 幾乎不用人工干预,就能写出 100% 类型安全的代码。

我也有点怕被一个庞大的生态套住,哪天来一波重大变更就得跟着一起受罪。之前被 React Router v5MUI v4 → v5 这种迁移折磨过之后,我很清楚,在 JavaScript 的世界里,快速打破向后兼容是家常便饭。以后慢慢补上复数这种特性,成本肯定比现在手动迁移 13.9 万行代码要低得多。

我想要的是一个极度简单、非常轻量,而且刚好贴合我们团队需求的东西。

所以我就自己写了一个。

我做了一个只有 3 KB 的精简版库,专门为了让 AI 能高精度、自动地重构代码。有了它,我一个人就顶了一个 5 人团队 3 周的工作量,而且只花了 3 天。

自己写的这套实现

我设计了一个极简的 i18n 库,gzip 后大概 3 KB。它只暴露两个主要函数:非 React 场景用 getTranslation(),组件里用 useTranslation() 这个 hook。

这两个东西会返回一个用来做简单字符串替换的 t(),还有一个在翻译字符串里插 React 组件时用的 interpolate()(比如链接或图标)。两个都支持变量替换,比如 "Hello {{thing}}", {thing: 'World'}

下面是核心的 t() 函数:

export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
  let namespace: string = '';
  let translationKey: string = key;
  
  // Check if key contains '/' - this indicates a namespace
  const slashIndex = key.indexOf('/');
  if (slashIndex !== -1) {
    const parts = key.split('/');
    namespace = parts.slice(0, -1).join('/');
    translationKey = parts[parts.length - 1];
  }
  
  const targetLocale = locale ?? currentLocale;
  const text = getTranslationValue(targetLocale, namespace, translationKey);
  
  if (values) {
    return interpolateString(text, values);
  }
  
  return text;
}

还有 React 里的 hook:

export function useTranslation() {
  const [language] = useLanguage();
  
  return useMemo(() => ({
    t: (key: TranslationKeys, values?: Record<string, string | number>) => 
      t(key, values, language),
    interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) => 
      interpolate(key, components, language),
  }), [language, version]);
}

整个库的核心代码只有大概 580 行,但已经能做这些事:

  • 按需加载翻译文件,这样不会给每个用户一次性塞 20 种语言的包。
  • 按“命名空间”拆分翻译文件(比如 commonmiscgames/{gameId})。
  • 提供一个“debug”语言环境,直接显示原始 key,方便我确认东西有没有接好。

为了让这个系统以后也好维护,我还在 shared/src/i18n/README.md 里写了一份比较完整的文档,从文件结构到前后端的使用示例都讲了。因为没用标准库,这份文档对新同事上手非常关键,也能帮未来的我(或者 LLM)回忆起这套东西是怎么运转的。

用数字看这次改动

为了让你感受一下这次更新有多大,代码库里一共改了这些东西:

  • 支持 20 种语言(外加一个开发用的 debug 语言)。
  • 新增 360 个语言文件。
  • 一共 139,031 行翻译相关代码。
  • 在前端里新增了 3,938t() 调用。
  • 一共改动了 728 个源码文件。
  • 18 个英文源文件作为“权威来源”(16 个游戏 + common + misc)。

用 Agent 来指挥协作

要是全靠手动做,这种又多又机械的工作估计得干上好几个月。于是我换了个思路,指挥十来个 Cursor Agent 一起上,把重体力活都交给它们。

我先按文件夹把整个代码库拆成一个个“区块”。Foony 里的每个游戏都有自己的文件夹,也有自己的翻译命名空间。这样首屏加载就很轻,只需要加载当前这个游戏的翻译。

我同时跑了好几个 Cursor Agent,每个 Agent 负责一个区块,比如“把 Chess 这个游戏全部改成用翻译”。它会一文件一文件地扫,把所有用户能看到的字符串找出来,替换成像 t('games/chess/some.key') 这样的调用。

Agent 接着会把这个 key 写进对应的英文语言文件里,还会顺手加一段 JSDoc 注释,说明这个字符串是干嘛用的、出现在什么地方。这些上下文在之后生成其它语言翻译时特别重要,可以帮 LLM 分清楚“Save”到底是“保存游戏配置”,还是“保存你在 Draw & Guess 里的画”。

质量把关

我飞快地把生成出来的代码都过了一遍。Agent 的表现其实比我想象得好,不过偶尔还是会犯些错,比如把 useTranslation hook 写在一个提前 return 的语句后面。

强类型的翻译系统帮了大忙。它能保证每个语言环境都有完整而正确的 key(也不会多出莫名其妙的 key)。而且还能确保所有对 t()interpolate() 的调用,引用的都是实际存在的翻译字符串。

类型系统会从英文源文件里把所有可能的翻译 key 全都提取出来:

/**
 * Extracts all possible paths from a nested object type, creating dot-notation keys.
 * Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
 */
type ExtractPaths<T, Prefix extends string = ''> = T extends string
  ? Prefix extends '' ? never : Prefix
  : T extends object
  ? {
      [K in keyof T]: K extends string | number
        ? T[K] extends string
          ? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
          : ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
        : never
    }[keyof T]
  : never;

export type TranslationKeys = 
  | ExtractPaths<typeof import('./locales/en/index').default>
  | `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
  | `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
  | `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
  // ... and so on for all games

这样一来,TypeScript 的自动补全非常给力,翻译 key 打错一个字母都会在编译时直接报错。Agent 也就没法写出 t('games/ches/name') 这种笔误,因为 TypeScript 会立刻红线提醒。

真正的本地化

英文这边全部转换完之后,我再把剩下的本地化任务拆开。每个 Agent 负责把一个英文语言文件翻译成指定的某一种语言。

比如,我会给 Agent 这样的提示词:

请确保 ar/games/dinomight.ts 拥有 en/games/dinomight.ts 里的全部翻译。
使用 `export const account: DinomightTranslations = {`。
不断修改直到这份翻译文件不再有类型错误(如果你看到的是别的文件报错,忽略就好--你是和其它负责那些文件的 Agent 并行运行的)。
你的翻译必须质量很高,而且要严格符合英文文件里 jsdoc 提供的上下文。
你必须手动完成这一切,不要写任何 "helper" 脚本,也不要走捷径。

我本来也是想过让 Cursor 写个脚本,把这些文件一个个丢给 LLM 让它自己生成翻译,不过后来还是想省一点 LLM 成本。只用脚本去更新缺失的翻译,事实证明更划算,以后我大概也会用类似的方案。我挺想追踪哪些字符串需要更新或翻译,不过又想保持简单,也许以后会把翻译工作搬到数据库之类的地方去做。

我还加了一个只在开发环境里可用的“debug”语言。这样我就能直接看到所有被替换掉的字符串,确认一切是否正常(而且看起来也挺酷)。在这个语言环境下,t() 会返回用括号包住的 key:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

所以你不会再看到 “Welcome to Foony!”,而是会看到 ⟦welcome⟧,这样一眼就能看出哪里还没翻译。

最后,另一个 Agent 实现了 /{locale}/** 这种路由规则,这样像 /ja/games/chess 这样的路径就会自动走到对应语言的页面(这里就是日语)。

那博客怎么翻译?

把 UI 里的字符串翻译好是一回事,那博客文章怎么办?我并不想再拉起一堆 Agent 专门来翻译我所有的博文。

我的做法是,让一个 Agent 帮我写了个脚本(scripts/src/generateBlogTranslations.ts),把这一整套流程自动化。

它的工作流程是这样的:

  1. 扫描 client/src/posts/en 目录里的英文 MDX 文件。
  2. 检查其它语言目录里有哪些还缺翻译的文件(比如 posts/japosts/es)。
  3. 如果缺少某个翻译,就读取英文内容,带上特定提示词丢给 Gemini 3 Pro Preview,让它在保留 Markdown 格式的前提下做翻译。
  4. 把生成好的文件存到正确的位置。

在前端这边,我用 import.meta.glob 动态加载所有 MDX 文件。PostPage 组件只需要根据当前用户的语言,按需加载对应的 MDX 文件就行。如果某个翻译还没生成(比如我还没跑脚本),页面就会优雅地回退到英文版本。

收个尾

做到这里,一个完整可用、支持 20 种语言的站点就已经跑起来了!

这 3 天确实有点疯狂,不过最后的成果是一个对全世界用户来说都(大部分)像本地站一样自然的完整多语言网站。靠着自研的轻量级库,再加上让 AI Agent 去干那些枯燥的重构活,我完成了一年前几乎不可能做到的事:一个人用 3 天时间,把一个复杂网站的全站 i18n 搞定。未来的编程重点,已经不再是“谁写代码更快”,而是“谁能更好地指挥 AI Agent,并且有足够深的领域知识来验证它们的产出”。

8 Ball Pool online multiplayer billiards icon