我们项目需要适配多国语言,如果靠人工维护多语言包很容易漏,每次新增一个按钮、一个表单校验、一条错误提示,都要记得同步到好几个语言文件里。时间久了,语言包就会变成一件又碎又烦的事。
后来我们想了个办法,在业务代码里只统一写 t('中文'),然后利用自研工具接入阿里云自动翻译自动扫描并生成多国语言包。
使用方式如下:
t("中文");
t("中文{count}条", { count });
然后只需要在发布的时候,执行一条命令,脚本就会扫描源码、调用翻译 API,并把结果写进多语言语言包。
下面是具体用法。
一、项目目录结构
目录上先把“机器生成”和“人工修正”分开:
project/
├── src/
│ ├── pages/
│ │ └── User.tsx
│ ├── locales/
│ │ ├── auto-translate/
│ │ │ ├── zh.ts
│ │ │ ├── en.ts
│ │ │ └── id.ts
│ │ ├── manual-translate/
│ │ │ ├── en.ts
│ │ │ └── id.ts
│ │ ├── zh-CN.ts
│ │ ├── en-US.ts
│ │ ├── id-ID.ts
│ │ └── config.ts
│ └── utils/
│ └── intl.ts
├── scripts/
│ └── i18n/
│ ├── cli.ts
│ ├── scanner.ts
│ ├── placeholder.ts
│ ├── locale-file.ts
│ └── translators/
│ └── aliyun.ts
├── i18n.config.js
└── package.json
这里最重要的是两个目录的边界:
src/locales/auto-translate:命令自动生成,永远不手动改,减少误操作。src/locales/manual-translate:人工修正永远放这里,同名 key 会覆盖机翻结果。
例如自动翻译生成:
// src/locales/auto-translate/en.ts
export default {
暂无数据: "No data",
"共有{count}条记录": "{count} records in total",
};
如果某条翻译不满意,不改 auto-translate/en.ts,而是在 manual-translate/en.ts 里覆盖:
// src/locales/manual-translate/en.ts
export default {
"共有{count}条记录": "Total {count} records",
};
运行时先在具体语言入口文件里合并,manual-translate 放在后面:
// src/locales/en-US.ts
import autoEn from "./auto-translate/en";
import manualEn from "./manual-translate/en";
export default {
...autoEn,
...manualEn,
};
再由 config.ts 按运行时 locale 注册:
// src/locales/config.ts
import enMap from "./en-US";
import idMap from "./id-ID";
import zhMap from "./zh-CN";
export const LanguageMap: Record<string, Record<string, string>> = {
"zh-CN": zhMap,
"en-US": enMap,
"id-ID": idMap,
};
后面无论自动翻译跑多少次,人工修正都不会被覆盖。
二、配置文件
项目根目录放一个 i18n.config.js。配置不用复杂,能描述清楚“扫哪里、输出到哪里、翻译哪些语言、用哪个翻译服务”就够了:
// i18n.config.js
module.exports = {
entry: "./src",
output: {
dir: "./src/locales/auto-translate",
ext: "ts",
},
languages: ["zh", "en", "id", "th", "vi"],
test: /\.(jsx|ts|tsx)$/,
exclude: [".umi", ".umi-production", "locales", "types"],
enabledParsers: ["CallExpression"],
enabledParsersCallExpression: ["t"],
translateServer: "ali",
translateServerConfig: {
qps: 10,
accessKeyId: process.env.ALIYUN_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIYUN_ACCESS_KEY_SECRET,
},
};
几个字段的含义如下:
| 字段 | 作用 |
|---|---|
entry | 从哪个目录开始扫描源码 |
output.dir | 自动语言包输出目录 |
output.ext | 生成语言包的文件后缀 |
languages | 需要生成哪些语言,包含源语言 zh |
test | 哪些源码文件参与扫描 |
exclude | 哪些目录或文件跳过 |
enabledParsers | 启用哪些 AST 扫描器,这里只启用函数调用扫描 |
enabledParsersCallExpression | 扫描哪些函数调用,这里只扫 t() |
translateServer | 使用哪个翻译服务,ali 表示阿里云机器翻译 |
translateServerConfig.qps | 每秒最多请求次数 |
translateServerConfig.accessKeyId | 翻译服务访问密钥 |
translateServerConfig.accessKeySecret | 翻译服务访问密钥 |
这里特意把扫描范围收窄到 t('中文'):
enabledParsers: ["CallExpression"]表示只扫描函数调用,不扫描裸字符串和 JSX 文本。enabledParsersCallExpression: ["t"]表示在函数调用里只处理t()。
密钥可以写在配置里,但不要提交真实值。更推荐通过环境变量传入:
ALIYUN_ACCESS_KEY_ID=xxx ALIYUN_ACCESS_KEY_SECRET=xxx npm run translate
也可以写一个被 .gitignore 忽略的本地密钥文件,再在 i18n.config.js 里读取。
三、package.json 和执行方式
安装依赖:
npm i -D tsx prettier @babel/parser @babel/traverse ignore
npm i @alicloud/alimt20181012 @alicloud/openapi-client @alicloud/tea-util
配置命令:
{
"scripts": {
"translate": "tsx scripts/i18n/cli.ts"
}
}
这里先按项目内脚本开发,等多个项目都要复用时,再把 scripts/i18n 封装成通用 npm 包即可。
业务代码里直接写中文:
import { t } from "@/utils/intl";
export function UserList({ count }: { count: number }) {
return (
<>
<div>{t("暂无数据")}</div>
<div>{t("共有{count}条记录", { count })}</div>
</>
);
}
执行:
npm run translate
执行后会生成或更新源语言和所有目标语言:
src/locales/auto-translate/
├── zh.ts
├── en.ts
├── id.ts
├── th.ts
└── vi.ts
四、运行时 t() 函数
最小实现如下:
// src/utils/intl.ts
import { LanguageMap } from "@/locales/config";
const PLACEHOLDER_RE = /\{([a-zA-Z_$][\w$]*)\}/g;
function getCurrentLang() {
return localStorage.getItem("umi_locale") || "zh-CN";
}
export function t(key: string, params?: Record<string, unknown>) {
const lang = getCurrentLang();
const langMap = LanguageMap[lang] ?? LanguageMap["zh-CN"];
let text = langMap?.[key] ?? key;
if (!params) return text;
return text.replace(PLACEHOLDER_RE, (_, name: string) => {
const value = params[name];
return value === undefined || value === null ? `{${name}}` : String(value);
});
}
因为中文原文就是 key,所以就算某个语言包里暂时没有翻译,页面也至少能回退显示中文:
t("保存成功"); // 找不到翻译时返回“保存成功”
五、核心实现思路
真正容易出问题的地方主要有五个:扫什么、key 怎么设计、参数怎么保护、怎么避免重复翻译、人工修正放哪里。
1. 只扫描 t(),不扫所有中文
不能用正则直接搜 t('...')。正则很容易误伤注释、普通字符串,也处理不好 TSX、嵌套调用和对象参数。
用 Babel AST 后,只处理 CallExpression,也就是函数调用,并且只认函数名为 t 的调用:
t("暂无数据");
t("共有{count}条记录", { count });
为了减少应用的复杂度,本方案只支持静态字符串。下面这些动态写法不自动翻译:
t(statusText);
t(`你好${name}`);
t("状态:" + status);
动态文案应该改成静态模板加参数:
t("你好{name}", { name });
这条约定很简单,用户能看到的中文,就写进 t();没写进 t() 的,脚本不管。
2. 中文原文就是 key
传统国际化常见写法是:
t("user.list.empty");
语言包里再维护:
export default {
"user.list.empty": "暂无数据",
};
这个方式稳定,但开发时要先命名 key。本文方案把中文直接作为 key:
t("暂无数据");
好处是开发时不用想 key 名,缺失翻译时也能自然回退到中文。代价也很直接:中文一改,key 就变了,旧翻译不会自动继承。对中后台项目来说,这个取舍通常可以接受。
3. 翻译前先保护占位符
这句文案:
t("共有{count}条记录", { count });
如果直接发给翻译 API,{count} 可能被翻译服务改坏。正确做法是翻译前替换成安全 token:
共有{count}条记录
共有__I18N_PH_0__条记录
翻译后再校验 token 是否还在:
__I18N_PH_0__ records in total
校验通过后还原:
{count} records in total
如果 token 丢失或多出来,说明这条翻译不可靠,不写入语言包,直接打印到控制台报告。
4. 只翻译缺失词条
每次执行命令时,工具会先读取已有语言包:
src/locales/auto-translate/en.ts
如果某个 key 已经存在,就跳过;如果不存在,才加入本次待翻译列表。
扫描到:["暂无数据", "保存成功", "共有{count}条记录"]
已有 en:["暂无数据"]
待翻译:["保存成功", "共有{count}条记录"]
这样每次只处理新增文案,速度和成本都可控;已经生成过的机翻结果也不会因为重复执行命令而来回变化。
批量翻译时,阿里云会通过返回结果里的 index 对应请求 JSON 里的 key。最直接的做法是用翻译前文本作为 key:
{
"暂无数据": "暂无数据",
"共有__I18N_PH_0__条记录": "共有__I18N_PH_0__条记录"
}
这样返回后用 index 找回原词条,不依赖数组顺序。
如果希望日志更短、映射更稳定,也可以进一步用递增 id 做 key:
{
"0": "暂无数据",
"1": "共有__I18N_PH_0__条记录"
}
递增 id 不是必须。只是自己维护脚本时,用 id 会更方便排查,也能避免重复文本在对象里被合并。
5. 机器翻译和人工修正分层
这里有个小经验:不要把“机翻结果”和“人工修正”混在同一个文件里。更清晰的做法是分成两层:
auto-translate 机器生成层
manual-translate 人工覆盖层
auto-translate 只由命令生成,不手动修改。manual-translate 只放人工修正过的少量 key。
合并顺序固定为:
{
...autoTranslate,
...manualTranslate,
}
所以最终展示时:
先查 auto-translate
再用 manual-translate 覆盖同名 key
这样自动翻译可以放心重跑,人工修正始终优先,也不会被下一次翻译命令覆盖。
六、整体执行流程
把上面的设计串起来,npm run translate 大致会按这个顺序跑:
1. 读取 i18n.config.js
2. 根据 entry / test / exclude 找到需要扫描的源码文件
3. 用 AST 找出 t('中文') 调用
4. 提取第一个静态中文字符串,生成词条列表
5. 读取 src/locales/auto-translate 下已有语言包
6. zh.ts 直接写中文原文
7. 其他语言只挑出缺失词条
8. 翻译前保护 {name} 占位符
9. 调用翻译 API,每 50 条一批,并按 qps 限流
10. 翻译后还原占位符
11. 写入 auto-translate/*.ts
12. 控制台打印本次翻译报告
输出语言包格式保持简单:
export default {
暂无数据: "No data",
"共有{count}条记录": "{count} records in total",
};
自动文件最好保持这种简单对象格式,不写变量、函数、spread 和复杂逻辑。越简单,读写越稳定。
七、常见问题
| 问题 | 原因 | 建议 |
|---|---|---|
| 修改中文后旧翻译丢失 | 中文就是 key,改文案等于换 key | 重新执行翻译;重要文案可把旧翻译复制到 manual-translate |
| 同一句中文翻译不准确 | 缺少上下文,机器翻译无法判断语义 | 改写中文,让上下文更明确 |
| 扫描不到模板字符串 | 动态文案无法静态提取 | 改成 t('你好{name}', { name }) |
| 语言包冲突很多 | 人工编辑了自动生成文件 | 自动文件不手动改,修正写到 manual-translate |
| 密钥泄漏 | 写进配置文件并提交 | 使用环境变量或密钥管理服务 |
八、总结
最后总结一下,这个方案其实不复杂:
开发时直接写
t('中文'),构建辅助命令负责扫描中文、自动翻译、生成语言包;机器翻译结果放auto-translate,人工修正放manual-translate,运行时 manual 优先。
它适合中文源语言的中后台、内部系统和文案维护成本较高的项目。重点不是“调用一次翻译接口”,而是把扫描、占位符保护、批量翻译、增量写入、人工覆盖这几件小事串成一个稳定流程。
下面只放少量关键伪代码,帮助理解 npm run translate 背后大概在做什么。真实项目里还可以继续补菜单翻译、密钥解密、清理未使用 key 等能力。
附录:核心伪代码
核心伪代码仅供参考,重点是理解几个关键动作,而不是照抄完整实现。
1. AST 扫描 t()
扫描器的作用:找到函数调用、确认函数名是 t、提取第一个静态字符串参数。
const CJK_RE = /[\u3400-\u9fff]/;
traverse(ast, {
CallExpression(path) {
const node = path.node;
if (node.callee.type !== "Identifier" || node.callee.name !== "t") return;
const firstArg = node.arguments[0];
if (firstArg?.type !== "StringLiteral") return;
const text = firstArg.value;
if (!CJK_RE.test(text)) return;
entries.add({
key: text,
source: text,
file,
line: node.loc?.start.line,
});
},
});
2. 占位符保护
翻译前把 {count} 替换成不容易被翻译的 token;翻译后再还原。
function protect(text) {
let i = 0;
const map = {};
const safeText = text.replace(/\{([a-zA-Z_$][\w$]*)\}/g, (_, name) => {
const token = `__I18N_PH_${i++}__`;
map[token] = name;
return token;
});
return { safeText, map };
}
function restore(text, map) {
return text.replace(/__I18N_PH_\d+__/g, token => {
const name = map[token];
return name ? `{${name}}` : token;
});
}
3. 翻译和写入
翻译只处理目标语言中缺失的 key;写文件时先保留旧数据,再补充新增翻译。
for (const lang of languages) {
const oldData = readLocale(lang);
if (lang === "zh") {
writeLocale("zh", {
...oldData,
...zhEntries,
});
continue;
}
const missing = entries.filter(entry => !oldData[entry.key]);
const translated = await batchTranslate(missing, lang);
writeLocale(lang, {
...oldData,
...translated,
});
}
参考:阿里云机器翻译 GetBatchTranslate 接口文档说明了批量 JSON 入参、返回 index 映射以及批量数量限制,实际开发时应以当前官方文档为准。