前端多语言翻译 AI 自动化实现思路

技术札记2026-05-217 分钟i18n前端工程化自动化AST

我们项目需要适配多国语言,如果靠人工维护多语言包很容易漏,每次新增一个按钮、一个表单校验、一条错误提示,都要记得同步到好几个语言文件里。时间久了,语言包就会变成一件又碎又烦的事。

后来我们想了个办法,在业务代码里只统一写 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

这里最重要的是两个目录的边界:

  1. src/locales/auto-translate:命令自动生成,永远不手动改,减少误操作。
  2. 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('中文')

  1. enabledParsers: ["CallExpression"] 表示只扫描函数调用,不扫描裸字符串和 JSX 文本。
  2. 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 映射以及批量数量限制,实际开发时应以当前官方文档为准。