稀土掘金技术社区

三行代码完成国际化适配,别等错过了才后悔没收藏!

前言

国际化适配一直以来都是一个棘手的问题,尤其是在项目一开始没有考虑的情况下,我们需要修改大量源码,使用类似于 ${t.xxx} 的占位符去一一修改我们已经写好的文字(如最耳熟能详的 vue-i18n)。这个工程量在项目后期是巨大的,令人无法接受的。

目前,网上有五花八门的国际化方案,但是大部分都只解决了基础问题——能用,但是都存在这个痛点——太麻烦了。

好,那么有没有一款插件,让我们不用自己动手做这件事呢?

有的兄弟有的

auto-i18n-translation-plugins 简介

wenps[1]/auto-i18n-translation-plugins[2] 正是这样一款通用插件

它最少只需要三行参数,像这样:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineconst i18nPlugin = vitePluginsAutoI18n({       targetLangList: ['en', 'ko', 'ja'],    translator: new YoudaoTranslator({             appId: '4xxxx9xxxx66fef',        appKey: 'ONIxxxxGRxxxxw7UM730xxxxmB3j'    })})

然后,在 vite 的 plugins 中填入 i18nPlugin 即可。

像这样:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport { defineConfig } from 'vite'
export default defineConfig({    resolve: {},    plugins: [i18nPlugin] //上面的对象})

当插件运行成功后,会生成最终的语言包,在根目录下的 lang 文件夹,然后我们需要在入口处引入,以 vue 为例,在 main.ts 中引入

ounter(lineounter(lineounter(lineounter(line// main.tsimport '../lang/index'

即可。

插件将在 localStorage 中获取到当前语言,所以切换语言时你只需:

ounter(lineounter(lineounter(lineounter(linewindow.localStorage.setItem('lang', value) // 你在 targetLangList 参数中传入的字符串,如 'en'window.location.reload()

当然,此插件同样支持 webpack、rollup

安装

ounter(lineounter(lineounter(linepnpm i vite-auto-i18n-plugin -D

ounter(lineounter(lineounter(linepnpm i webpack-auto-i18n-plugin -D

上面提到 YoudaoTranslator ,你需要申请自己的有道翻译 api key,或者使用代理使用免费的谷歌翻译(详见插件 readme.md[3])。

有道翻译 api 申请地址:ai.youdao.com/product-fan…[4]

优点

此插件的和目前市面上的插件的根本区别在于,将翻译、文本替换这两步都自动化了,翻译是前置执行的,而替换过程是在构建过程中发生的,对于使用者来说是不可见且无需关心的,使用之后,项目中的任何文本都无需改动,且插件也不会去修改我们的代码,看起来「一切如旧!妙哉」。

对于传统方式来说,使用此插件之后,工作量将降低 90% 以上。

由于机器翻译可能对于特定语境存在偏差,所以翻译可能不是 100% 准确,这时候我们可以手动去修改少量的翻译文本产物。

插件成功运行后,将在根目录下生成一个 lang 文件夹,lang/index.json 就是生成后的翻译。

❝

tip: 由于 vite 的运行机制,使用 vite 时,需要先执行 npm run build,这样可以节省 api 用量。

❞

它大概长这样:

ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line{    "qylb2": {        "zh-cn": "首页",        "en": "Home page",        "ko": "첫 페이지",        "ja": "トップページです"    },    "dud62": {        "zh-cn": "产品",        "en": "product",        "ko": "제품",        "ja": "製品です"    },    "ea9n2": {        "zh-cn": "关于",        "en": "With regard to",        "ko": "관",        "ja": "についてです"    }}

qylb2 等 key 值是源文本的一个 hash,只要源文本不变,就不会重新翻译,所以我们可以自由修改语言的翻译结果,而不会使插件自动重新翻译。

当此文件内容不全,或源文本发生改变(hash 发生改变),插件会在构建阶段重新补全(增量)。

Image

auto-i18n-translation-plugins 已加入 auto-plugin 开源联盟[5] ,我们致力于打造足够 auto 的 JS 插件。

作者是 @wenps[6] , Github 主页:github.com/wenps[7]

项目 Github 链接:github.com/wenps/auto-…[8]

为了让兄弟们用插件时足够放心,接下来,将讲解 auto-i18n-translation-plugins 的具体原理,你也可以点击上方链接亲自阅读源码。

auto-i18n-translation-plugins 原理解析

❝

本章由作者 @wenps[9] 亲自编写,内容十分硬核,不想看的同学可以直接跳到文末查看示例。

❞

它是如何找到要翻译的文本的?

在开始讨论如何定位需要翻译的文本前,我们首先需要理解 Babel 的核心机制。Babel 是一款 JavaScript 编译工具,它能够通过以下流程将代码转化为可操作的中间表示:

「文案标记」

  1. 「解析(Parse)」 Babel 将输入的 JavaScript 代码解析为「抽象语法树(AST)」 ,将代码结构分解为层级清晰的节点(Node)。例如,字符串字面量、模板字符串、JSX 元素等会转换为对应的 AST 节点类型(如 StringLiteral, TemplateLiteral, JSXText)以中文为例,一般中文就会出现在这些StringLiteral, TemplateLiteral, JSXTextast 节点中,因此处理这些节点即可。

  2. 「转换(Transform)」 通过 Babel.transform 方法对来源语言可能出现的StringLiteral, TemplateLiteral, JSXText等 AST 节点进行深度遍历,通过这种手段去扫描目标文案:

  • 「定位目标文本」:遍历 AST,筛选出StringLiteral, TemplateLiteral, JSXText等 AST 节点,如果来源语言是中文,那就匹配当前节点的值当前是否符合中文的正则,符合就往下走;
  • 「过滤无需翻译的内容」:排除路径引用(import '/path')、对象键名({ key: 'value' })、注释等非内容文本;
  • 「标记文本」:如果存在符合来源语言的正则,又不属于无需翻译的内容,就会对需要翻译的文本生成唯一哈希(为了保证不会出现重复翻译),并将其替换为翻译函数调用(如 $t('哈希值', '原始文本'))。
  • 全部文件遍历完之后,文案的标记就完成了
  • 「代码生成(Generate)」 将修改后的 AST 转换回 JavaScript 代码,最终输出包含翻译标记的源文件。

  • 「文案收集」

    标记完之后还需要去将标记的文案和 hash 收集起来,因此我们会先生成一个全局变量,这里有两个方案:

    1. 「遍历时同步收集」

    • 「流程」:在遍历 AST 标记文本时,将哈希值和原始文本实时「存储到全局对象」。
    • 「优点」:仅需遍历一次,节省时间;
  • 「分步处理」

    • 「流程」:

        1. 「首次遍历」:将文本替换为 $t 调用,不存储数据;
        2. 「二次遍历」:专门收集所有 $t 调用中的参数(哈希和文本)「存储到全局对象」。
    • 「优点」:逻辑分离清晰,避免副作用;

    目前我们这里使用的就是方案二,「完成这一步之后所有的待翻译文字就已经被存储到了全局对象中,接下来我们需要将这些文案进行翻译即可。」

    「文案收集举例」

    原始代码:

    ounter(lineounter(lineounter(lineounter(lineounter(line<Div>按钮文字</Div>const message = '系统提示';import 'styles.css'; // 路径无需翻译

    经 Babel 插件处理后输出:

    ounter(lineounter(lineounter(lineounter(lineounter(line_c('div', [$t('f8b7a1d', '按钮文字')]);const message = $t('2e3c9a7', '系统提示');import 'styles.css'; // 路径未被修改

    最终遍历函数,读取 hash 和文字,并收集的全局对象中,就会得到一个映射表:

    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(line{  f8b7a1d: '按钮文字',  2e3c9a7: '系统提示'}

    到这一步就完成了目标文案的函数转换和收集。

    通过这一机制,开发者无需手动标记文本,Babel 能够自动化识别和准备需要翻译的内容,同时确保结构化代码的准确性。

    它是如何进行翻译的?

    在上面的描述中我们已经通过 babel 完成文案的收集了,那我们怎么完成翻译呢?主要分成两步。

    这里我做了两个翻译器(class),它们负责接收用户参数,以及进行接下来的操作。

    第一步:实例化翻译器

    插件默认暴露了两个可用的翻译器类和一个翻译器基类,可用的翻译器类分别包括有道翻译器类和谷歌翻译器类,这两个类实例化即可使用,实例化代码如下:

    有道翻译:(强烈推荐有道翻译)

    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(linenew YoudaoTranslator({     	appId: '4xxxx9xxxx66fef',	appKey: 'ONIxxxxGRxxxxw7UM730xxxxmB3j'})

    谷歌翻译:

    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(linenew GoogleTranslator({  proxyOption: { // 国内使用需要配置代理    host: '127.0.0.1',    port: 8899,    headers: {      'User-Agent': 'Node'    }  }})

    通过内置的translate函数进行翻译。

    谷歌翻译和有道翻译都是继承于翻译器基类:

    Translator 是一个封装翻译功能的核心类,用于通过配置好的翻译 API(如机器翻译服务)将文本从源语言转换为目标语言。其设计目标是「标准化翻译调用流程」、「管理 API 请求频率」并提供「错误处理机制」。(更详细内容可以去看 github 源码,有相关的类型标识)

    「Translator」 源码

    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineexport class Translator {    protected option: TranslatorOption
        constructor(option: TranslatorOption) {        this.option = option        if (this.option.interval) {            this.option.fetchMethod = interval(this.option.fetchMethod, this.option.interval)        }    }
        protected getErrorMessage(error: unknown) {        if (error instanceof Error) {            return error.message        } else {            return String(error)        }    }
        async translate(text: string, fromKey: string, toKey: string) {        let result = ''        try {            result = await this.option.fetchMethod(text, fromKey, toKey)        } catch (error) {            const name = this.option.name            console.error(                `翻译api${name ? `【${name}】` : ''}请求异常:${this.getErrorMessage(error)}`            )        }        return result    }}

    ❝

    所以,好像你也可以做一个自定义的 CustomTranslator

    ❞

    第二步:翻译目标语言

    实例化翻译器之后就需要对我们扫描出来的文案进行翻译了,下面是具体的步骤

    「1. 找出需要翻译的新文案」
    • 「第一步」:读取两份数据:

      • 「全局对象」:代码中所有文案生成的全局对象(比如 "hash1" →"确定", "hash2" → "你好")。
      • 「已翻译的旧文件(运行插件的时候会生成一个 index.json, 里面存放的就是旧的翻译内容)」 :之前翻译好的结果(比如 hash1 的中英文都有了,但 hash2 还没翻译)。
    • 「筛选规则」:找出旧文件中「没有翻译过」的文案(如 hash2 的 “你好” 需要翻译成英文等)并将这个没翻译的重新存储在一个「临时对象」中。

    ounter(lineounter(lineounter(line{	hash2: 你好}
    「2. 合并文案方便一次性翻译」

    「操作」:通过 key 去读取临时对象,把需要翻译的原文用符号 \n┋┋┋\n 连起来,变成一个长文本。

    「例子」:

    • 原始文案列表: ["你好", "欢迎来到系统"]

    • 合并后的长文本:

    ounter(line   你好\n┋┋┋\n欢迎来到系统  

    「为什么这么做?」 :把多个短文案合并成一个长文本,可以一次批量翻译,减少多次调用翻译接口的时间,而且通过 key 去读取,可以保证顺序的一致性,因为 key 不会变。

    「3. 分语言翻译并拆分结果」

    「步骤」:

    1. 「选择目标语言」:比如要翻译成英文、韩语。

    2. 「逐个翻译」:

    对合并后的长文本调用翻译器实例的翻译函数,设置语言参数(如英文、韩语)。

    「结果示例」:

    英文翻译 → Hello\n┋┋┋\nWelcome to the system韩语翻译 → 안녕하세요\n┋┋┋\n시스템에 오신 것을 환영합니다

    3.  「拆分结果」:根据 \n┋┋┋\n 符号,把翻译后的文本切回一个个单独文案。例如:

    英文结果 → [ "Hello", "Welcome..." ]韩语结果 → [ "안녕하세요", ... ]「值得注意的时候此时的数组顺序,和我们生成的临时对象变量 key 的顺序是一致的」

    「4. 匹配原文顺序,更新翻译映射表」

    「关键点」: 因为合并时保持了原文档的顺序(如按哈希值 hash2排列),拆分后的翻译结果也能按顺序对应原文。

    「操作」:

    1.新建临时存储对象,把翻译结果按哈希值归类。例如:
    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line{  "hash2": {  "zh": "你好",    // 原文(不翻译)  "en": "Hello",   // 新翻译的英文  "ko": "안녕하세요", // 新翻译的韩语  },  ...  }  
    2.「合并到旧文件」:把临时对象中的新翻译内容,追加到已有的映射表中。

    「最终效果」:

    ounter(lineounter(lineounter(lineounter(line{  "hash1": { "zh": "确定", "en": "Confirm" },  // 已有的旧数据  "hash2": { "zh": "你好", "en": "Hello", "ko": "안녕하세요" } // 新增翻译  }  
    3.「合并完之后重新写入」
    • 将合并完之后的对象重新写入到配置文件中即可,完成文案的翻译

    通过这种流程,新增文案会自动被翻译并整合到文件里,同时保证已翻译的内容不受影响,整个过程就像 “把碎片拼成整张画 → 一起翻译 → 再分开展示” 一样简单。

    它是如何处理新增的语言和增量文案?

    新加语言

    当需要新增目标语言(如从「中→英」扩展为「中→英→韩」),「无需手动编辑映射表」:

    插件启动时会自动检查「当前配置语言列表」(如 zh, en, ko)与「映射表内已有语言」(如存在 zh 和 en)。若发现「新增语言未初始化」(如 ko),则触发自动补全流程——

    「具体步骤」:

    1. 「提取源语言文案」:直接从映射表中拉取「原始语言」(如中文)的所有纯文本内容(如 "确定","韩文")。

    2. 「批量翻译新增语言」:通过合并符 \n┇┇┇\n 拼接原始语言,(如确定\n┇┇┇\n韩文),将文案统一翻译为目标语言(如韩语 확인\n┇┇┇\n한글),重新按照合并符 \n┇┇┇\n进行切割,就可以得到新增语言的翻译如:확인, 한글。

    3. 「追加语言数据」:将新翻译结果「直接写入映射表」对应位置,例如:

      "确认按钮": {    "zh": "确定",    "en": "Confirm",    "ko": "확인"  }  

    此过程完全自动化,开发者只需在配置中添加目标语言代码,插件即可无缝扩展语言包,无需手动维护映射表或担忧变量错位问题。

    新加文案

    • 每次代码编译时,插件会扫描所有文本(如 "新按钮"),自动生成翻译函数调用(如 $t('哈希', '新按钮'))。

    • 这一过程「完全无感」,开发者无需手动标注新文案。

    • 插件将新文本的「哈希值」与「原始内容」写入全局映射表时,会先判断该哈希是否已存在:

      • 「若不存在」:追加新条目(如 "哈希": "新按钮")。
      • 「若已存在」:跳过写入,避免覆盖已有翻译记录。
    • 在翻译阶段,插件会比对:

      • 当前代码中的「全局映射表」(存储文本和 hash 的全局变量)
      • 已有翻译配置文件( index.json)。
    • 「自动筛选出未翻译的新增文案」,仅对它们触发翻译流程。

    • 然后对翻译结果进行切割,并重新写入到翻译配置文件中。

    「示例场景」

    「原始映射表」

    ounter(lineounter(lineounter(lineounter(lineounter(line{	"确定按钮hash": { "zh": "确定", "en": "Confirm" }  }

    「新增代码文案」:<button>重置</button>「插件动作」:

    1. 自动生成 $t('新哈希', '重置')。
    2. 更新映射表:
    ounter(lineounter(lineounter(lineounter(lineounter(line  {	"新哈希": { "zh": "重置" }, // 中文自动填充,其他语言待翻译    }

    1. 「翻译提示」:只需补充英文 "Reset"、日文 "リセット" 等,无需处理已存在的 “确定” 按钮。

    通过以上分步设计,开发者可专注于代码开发,翻译工作仅聚焦于真正新增的内容。

    它如何使结果回显到页面上的?

    通过上面的内容我们已经成功的将待翻译的文本转换成了翻译函数调用, 并且通过翻译实例将待翻译的文本进行了翻译,那么接下来我们需要将翻译的结果回显到页面上。

    不妨看看编译后的内容长什么样:

    ounter(lineounter(lineounter(lineounter(lineounter(line_c('div', [$t('f8b7a1d', '按钮文字')]);const message = $t('2e3c9a7', '系统提示');import 'styles.css'; // 路径未被修改

    因此为了使其回显到页面上,我们需要做的就是将「全局的 $t 实现即可」。

    下面通过源码来介绍:(这个文件要在项目首行引入)

    ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line	// 导入插件生成的国际化JSON文件    import langJSON from './index.json'    (function () {    // 定义翻译函数    let $t = function (key, val, nameSpace) {      // 获取指定命名空间下的语言包      const langPackage = $t[nameSpace];      // 返回翻译结果,如果不存在则返回默认值      return (langPackage || {})[key] || val;    };    // 定义设置语言包的方法    $t.locale = function (locale, nameSpace) {      // 将指定命名空间下的语言包设置为传入的locale      $t[nameSpace] = locale || {};    };    // 将翻译函数挂载到window对象上,如果已经存在则使用已有的    window.$t = window.$t || $t;    // 将简单翻译函数挂载到window对象上    window.$$t = $$t;    // 定义从JSON文件中获取指定键的语言对象的方法    window._getJSONKey = function (key, insertJSONObj = undefined) {        // 获取JSON对象        const JSONObj = insertJSONObj;        // 初始化语言对象        const langObj = {};        // 遍历JSON对象的所有键        Object.keys(JSONObj).forEach((value) => {            // 将每个语言的对应键值添加到语言对象中            langObj[value] = JSONObj[value][key];        });        // 返回语言对象        return langObj;    };    })();    // 定义语言映射对象    const langMap = {		// 根据插件的配置来生成语言map        'en': window?.lang?.en || window._getJSONKey('en', langJSON),		'ko': window?.lang?.ko || window._getJSONKey('ko', langJSON),		'zhcn': window?.lang?.zhcn || window._getJSONKey('zhcn', langJSON)    };    // 从本地存储中获取当前语言,如果不存在则使用源语言    const lang = window.localStorage.getItem('lang') || 'zhcn';    // 根据当前语言设置翻译函数的语言包    window.$t.locale(langMap[lang], 'lang');

    通过阅读上面的代码可以看到,为了回显到页面上,我们会导入生成的翻译 json,通过window.$t.locale(langMap[lang], 'lang')将对应的语言包设置到翻译函数上,这样就可以在页面上使用 $t 函数进行翻译了。

    它为什么不用影响现有代码?

    插件基于编译时 「AST 语法树分析」实现无感化改造:在代码构建阶段,通过解析源代码的抽象语法树(AST),精准定位需翻译的文本内容,智能替换为指定翻译函数(如 $t('哈希','原始文本'))。 同时,该过程会「动态归集」所有需翻译的文案数据,生成映射表(index.json)。这一处理完全运行于构建流程之中,既「不修改源代码文件的原始结构」,也不会对运行期 JavaScript 逻辑产生任何干扰,确保开发与生产环境的稳定性。

    ounter(lineounter(lineounter(line(例如对 `<div>文本</div>` 自动转译为 `$t('hash','文本')`,但原始源代码文件保持不变,开发调试时仍可直接查看原生字符串内容)

    它为什么可以兼容全部前端框架?

    auto-i18n-translation-plugins 的设计建立在「框架无关的后期处理」原则之上。由于所有前端框架(如 Vue、React、Svelte 等)「最终都会将其自定义语法(模板、组件、JSX 等)编译为标准 JavaScript 代码」,而该插件的文本提取与翻译函数替换逻辑「被明确置于构建流程的最后阶段执行」。这一策略的核心在于:

    • 开发者框架的各类「解析和编译操作」(如 Vue 的模板编译、React 的 JSX 转义)均在插件运行前完成;
    • 插件直接处理「最终的纯 JavaScript 代码」,无需理解具体框架的内部语法或结构;
    • 只要确保插件在构建管线的「最后阶段生效」(如 Webpack 的 loader 排序、Vite 的 plugin 配置顺序),即可兼容所有符合标准编译流程的前端框架。

    「效果」: 开发者只需将插件配置为构建流程的收尾环节,即可「无感支持 Vue + TS、React + SWC、纯 JS 项目等任意技术栈」,无需为不同框架单独配置适配层。

    仓库地址和案例

    Github:wenps[10]/auto-i18n-translation-plugins[11]

    NPM vite 版: www.npmjs.com/package/vit…[12]

    NPM webpack 版:www.npmjs.com/package/web…[13]

    案例:github.com/wenps/auto-…[14]

    TODO

    • ssr 全自动支持,目前需要手动适配
    • 自动引入 lang/index.js