

1/1/1970
我如何在 3 天内把 i18n 做到支持 20 种语言
嗨!我刚干完一件超级大的活,把 Foony 翻译成了 20 种不同的语言。这次改动几乎动到了代码库里的每一个文件,不过最后我还是在短短 3 天内把它搞定了。
下面我会拆开说说我是怎么做的,这次改动背后具体有哪些数字,还有为什么我又一次选择自己写一个翻译库,而不是用业界标准。
为什么不用 i18next?
一开始考虑加多语言的时候,我当然也先看了业界标准:i18next 和 react-i18next。
但最后我决定优先考虑的是让 AI 更好维护。i18next 很强大,不过它的 API 形态太多,LLM 很容易幻觉、写出风格不一致的代码。把这个库收紧到只暴露简单的 t() 和 interpolate(),就可以保证十几个并行的 Agent 幾乎不用人工干预,就能写出 100% 类型安全的代码。
我也有点怕被一个庞大的生态套住,哪天来一波重大变更就得跟着一起受罪。之前被 React Router v5 和 MUI 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 种语言的包。
- 按“命名空间”拆分翻译文件(比如
common、misc、games/{gameId})。 - 提供一个“debug”语言环境,直接显示原始 key,方便我确认东西有没有接好。
为了让这个系统以后也好维护,我还在 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 负责一个区块,比如“把 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),把这一整套流程自动化。
它的工作流程是这样的:
- 扫描
client/src/posts/en目录里的英文 MDX 文件。 - 检查其它语言目录里有哪些还缺翻译的文件(比如
posts/ja、posts/es)。 - 如果缺少某个翻译,就读取英文内容,带上特定提示词丢给 Gemini 3 Pro Preview,让它在保留 Markdown 格式的前提下做翻译。
- 把生成好的文件存到正确的位置。
在前端这边,我用 import.meta.glob 动态加载所有 MDX 文件。PostPage 组件只需要根据当前用户的语言,按需加载对应的 MDX 文件就行。如果某个翻译还没生成(比如我还没跑脚本),页面就会优雅地回退到英文版本。
收个尾
做到这里,一个完整可用、支持 20 种语言的站点就已经跑起来了!
这 3 天确实有点疯狂,不过最后的成果是一个对全世界用户来说都(大部分)像本地站一样自然的完整多语言网站。靠着自研的轻量级库,再加上让 AI Agent 去干那些枯燥的重构活,我完成了一年前几乎不可能做到的事:一个人用 3 天时间,把一个复杂网站的全站 i18n 搞定。未来的编程重点,已经不再是“谁写代码更快”,而是“谁能更好地指挥 AI Agent,并且有足够深的领域知识来验证它们的产出”。