

1/1/1970
我如何在 3 天内将 i18n 实现到 20 种语言
大家好!我刚刚完成了一项艰巨的任务,将 Foony 翻译成了 20 种不同的语言。这是一项浩大的工程,几乎涉及代码库中的每一个文件,但我成功地在短短 3 天内完成了所有工作。
下面我会详细讲讲我是怎么做的、这次改动背后的具体数字,以及为什么我决定再次自己写一个翻译库,而不是使用业界标准。
为什么不用 i18next?
最初考虑添加翻译功能时,我也考虑过业界标准:i18next 和 react-i18next。
但最终,我决定优先优化对 AI 友好的可维护性。i18next 功能强大,但它丰富多样的 API 可能会让 LLM 出现幻觉或写出不一致的代码。通过将库限制为简单的 t() 和 interpolate(),我确保 10 多个并行 agent 能够编写 100% 类型安全的代码,几乎无需人工干预。
我也对绑定到一个庞大的生态系统感到警惕,因为它以后可能会引入破坏性变更。曾经被 React Router v5 和 MUI v4 → v5 的痛苦迁移坑过,我深知向后兼容性被频繁打破在 JavaScript 圈子里太常见了。以后再添加复数化功能的成本,远低于现在手动迁移 13.9 万行代码的成本。
我想要一个简单到极致、超轻量、并且完全契合我团队需求的东西。
所以我自己写了一个。
我构建了一个 3 KB 的受限子集,专门设计用来支持高准确率、自主化的 AI 重构。这让我作为单枪匹马的一名工程师,在仅仅 3 天内完成了一个 5 人团队 3 周的工作量。
自定义实现
我设计了一个极简的 i18n 库,gzip 后大约 3 KB。它暴露了两个主要函数:用于非 React 上下文的 getTranslation(),以及用于组件的 useTranslation() hook。
它们返回 t() 用于简单的字符串替换,以及 interpolate() 用于需要在翻译字符串中注入 React 组件(比如链接或图标)时使用。两个函数都支持变量替换,例如 "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();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
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 种语言都打包给每个用户。
- 按 "namespace" 进行翻译代码分割(例如
common、misc、games/{gameId})。 - 一个 "debug" 区域设置,显示原始的键名,方便我验证一切是否正确连接。
为了确保系统易于维护,我还在 shared/src/i18n/README.md 中添加了详尽的文档,涵盖了从文件结构到客户端和服务端的使用示例。由于我没有使用标准库,这份参考资料对于新团队成员的入职(或者只是提醒未来的我或 LLM 它是怎么工作的)至关重要。
数据说话
为了让大家感受这次更新的规模,以下是代码库中的变动:
- 支持 20 种语言(外加一个开发用的 debug 区域设置)。
- 创建了 360 个区域设置文件。
- 139,031 行翻译代码。
- 在客户端添加了 3,938 次
t()调用。 - 修改了 728 个源文件。
- 18 个英文源文件作为真实来源(16 款游戏 + common + misc)。
用 Agent 进行编排
手工完成这些会耗费几个月枯燥乏味的机械劳动。所以,我同时编排了十几个 Cursor agent 来分担重活。
我先按文件夹将代码库划分成 "区块"。Foony 上的每款游戏都有自己的文件夹和自己的翻译命名空间。这样保持了初始加载体积小,因为你只加载正在玩的游戏的翻译内容。
我同时运行了多个 Cursor agent。我给每个 agent 分配一个具体的区块,比如 "把象棋游戏改造成使用翻译",它就会逐个文件地查找面向用户的字符串,并替换为 t('games/chess/some.key')。
然后 agent 会把这个键添加到对应的英文区域设置文件中,并附带一段 JSDoc 注释,说明这个字符串的 "是什么" 和 "在哪里"。这种上下文在为其他语言生成翻译时非常重要,它能帮助 LLM 理解 "Save" 是 "保存游戏配置" 还是 "保存你的 Draw & Guess 画作"。
质量把控
我快速审阅了所有生成的代码。这些 agent 表现出乎意料地好,但它们偶尔也会出错,比如把 useTranslation hook 放在某个提前 return 语句之后。
强类型翻译给了我极大的帮助。它确保每个区域设置的所有翻译都拥有正确的键(并且没有错误的键)。它还确保对 t() 和 interpolate() 的调用使用的都是真实存在的翻译字符串。
类型系统会从英文源文件中提取出所有可能的翻译键:
/**
* 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 自动补全,任何翻译键的拼写错误都会在编译时被发现。Agent 不可能写出 t('games/ches/name') 这种错误,因为 TypeScript 会立刻标记出来。
本地化
英文版改造完成后,我把剩下的区域设置任务也拆分了。我让每个 agent 负责把单个英文区域设置文件转换为指定的语言。
例如,我给 agent 的提示词大致是这样的:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
我也考虑过让 Cursor 写一个脚本,把这些文件喂给 LLM 来生成,但我想稍微节省一点 LLM 成本。用脚本只更新缺失的翻译是更好的方法,我以后可能也会用类似的方案。我希望能追踪哪些字符串需要更新或翻译,但又想保持简洁。我可能会把翻译工作迁移到数据库或类似的地方。
我还添加了一个仅在开发环境可用的 "debug" 区域设置。它让我可以查看所有被替换的字符串,确认一切正常运行(顺便说一句,我觉得这个挺酷的)。当你使用 debug 区域设置时,t() 会返回用括号包裹的键名:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
所以你看到的不是 "Welcome to Foony!",而是 ⟦welcome⟧,这样就能轻松发现遗漏的翻译。
最后,另一个 agent 实现了 /{locale}/** 路由,这样像 /ja/games/chess 之类的路径就能路由到正确的语言(本例中是日语)。
翻译博客
翻译 UI 字符串是一回事,但博客文章怎么办呢?我可不想再启动和管理更多的 agent 来翻译我所有的博客文章。
我让一个 agent 创建了一个脚本(scripts/src/generateBlogTranslations.ts),它能自动化整个流程。
它的工作方式是这样的:
- 扫描
client/src/posts/en目录下的英文 MDX 文件。 - 检查其他区域设置文件夹(例如
posts/ja、posts/es)中缺失的翻译。 - 如果缺少翻译,它会读取英文内容,并将其连同特定的提示词一起喂给 Gemini 3 Pro Preview,在保留 Markdown 格式的同时翻译内容。
- 将新文件保存到正确的位置。
在前端,我使用 import.meta.glob 动态导入所有这些 MDX 文件。我的 PostPage 组件只需检查用户当前的区域设置,然后懒加载对应的 MDX 文件。如果缺少翻译(因为我还没运行脚本),它会优雅地回退到英文版本。
第 4 天:自动化翻译生成
我知道最初的方案没法扩展。所以,既然 i18n 已经搞定,是时候用基于数据库的方法把它再加固一下了。
简而言之:当英文文本或 JSDoc 注释发生变化时,翻译需要重新生成。手动追踪哪些内容需要更新既容易出错,又浪费开发者的时间。
于是我搭建了最初计划好的方案:一个由 PostgreSQL 支持的翻译生成系统。
数据库结构
我在我们的 PostgreSQL 数据库中添加了一个 translations 表,结构如下:
key:采用 "斜杠加点号" 写法的翻译键(例如"games/yacht/nested.name"、"config.timeLimit.label")。en_value:英文源值target_locale:目标区域设置代码(例如"es"、"fr"、"zh")target_value:翻译后的值context:一个 JSONB 字段,包含该键及其所有祖先键的 JSDoccreated_at和updated_at:用于追踪的时间戳
唯一索引建立在 (key, target_locale, en_value, context) 上。这点至关重要:通过将 context 包含在唯一约束中,我们可以自动检测 JSDoc 注释何时发生变化,并重新生成翻译。旧翻译会被保留作为历史参考。
生成脚本
我创建了 scripts/src/generateLocalizations.ts 来自动化整个翻译流程:
- 提取英文键:使用 AST 解析(ts-morph)从
shared/src/i18n/locales/en/**文件中提取所有翻译键,只处理默认导出 - 提取 JSDoc 上下文:解析每个键及其所有祖先键(父对象)的 JSDoc 注释,以提供丰富的上下文
- 查询数据库:检查 PostgreSQL 中已有的翻译,在
key、target_locale、en_value以及context上做匹配。如果其中任何一项发生变化,翻译就会被重新生成。 - 识别缺失/变更的键:找出需要翻译或英文值/注释已变更的键
- 批量翻译:按区域设置和命名空间前缀分组,以便更高效地调用 LLM(同时也让翻译更快)。但如果批次太大,翻译质量会变差。
- 生成翻译:使用 GPT 5.1,并配合全面的上下文(JSDoc、语言+地区、语气、术语表、示例)。我看过资料说 5.1 在写作上比 5.2 更好(不那么平淡),但还没亲自验证过。
- QA 检查:验证占位符是否保留,例如
{{name}}、键的完整性、JSON 格式 - 存入数据库:将翻译连同完整上下文(JSDoc + 祖先 JSDoc)一起保存
- 生成区域设置文件:从数据库读取并写入格式正确的、带
RecursivePartial类型的 TypeScript 区域设置文件
主要好处
这种方法给我们带来了多项 DevEx 改进:
- 自动重新生成:当英文文本或 JSDoc 注释发生变化时,翻译会自动重新生成。所以如果有人说某条翻译不好,只需通过注释提供更多上下文,就能很容易地重新生成翻译。
- 丰富的上下文:JSDoc 注释提供了翻译上下文(例如,"显示给玩家的错误信息,最多 15 个字符"),帮助 LLM 产出更准确的翻译
- 祖先上下文:父对象的 JSDoc 提供了命名空间上下文(例如,"在一场所有蛋都被摧毁的游戏中获得的成就"),提供更多清晰度
- 历史追踪:旧翻译被保存在数据库中。它们占用空间不大,所以暂时我没看到删除它们的理由,而且看历史记录也挺酷的。
技术细节
实现中使用了几种技术来确保可靠性和效率:
- 基于 AST 的提取,确保我能拿到正确的注释
- 使用信号量(Semaphore)进行并行处理,实现并发批量翻译
- 针对 API 故障的指数退避重试逻辑。LLM 调用以不稳定著称。
可以从 scripts 目录运行 npm run generate-localizations 来执行该脚本。运行时它会连接 PostgreSQL,处理所有支持的区域设置中所有缺失或变更的翻译。
结语
到这里,我已经拥有了一个翻译成全部 20 个区域设置的、功能完备的网站!
这是疯狂的 3 天,但成果是一个对世界各地的用户来说(几乎)感觉本地化的完整网站。通过构建一个定制的、轻量级的库,并利用 AI agent 完成繁琐的重构工作,我做成了仅仅一年前还不可能办到的事:由 1 名工程师在 3 天内为一个复杂网站完成完整的 i18n。编程的未来不在于写代码有多快,而在于编排 AI agent 的能力,以及具备深厚的领域专业知识来验证它们的产出。