前端国际化解决方案

1,030 阅读7分钟

关于国际化的工作流程及痛点可以参考项目国际化的难点痛点是什么这篇文章,感觉写的很详细,文中所列的痛点我们几乎都遇到了,本文旨在分享我们在实践中的一些解决方案。

国际化框架选择

想做国际化,首先要选择一个适合你当前技术架构的国际化框架,我们的技术栈是react,所以选择了React-i18next作为国际化的框架。

它的优点:

与react无缝衔接

  • Hooks 支持:提供 useTranslation 钩子,方便在函数组件中使用国际化功能。
  • Higher Order Component (HOC) :提供 withTranslation 高阶组件,方便在类组件中使用国际化功能。
  • Context API:基于 React 的 Context API 实现,与 React 的状态管理和生命周期无缝结合。
  • Trans: Trans组件可自定义语义顺序及dom渲染

动态加载和性能优化

  • 按需加载:支持动态加载翻译文件,减少初始加载时间,提高性能。
  • 缓存机制:内置缓存机制,减少重复加载,提高响应速度。
  • 服务器端渲染 (SSR) :支持 SSR,使国际化内容在服务端预渲染,提高 SEO 和首屏加载速度。

丰富的插件和中间件支持

  • 语言检测:内置语言检测插件,可以自动检测用户语言偏好(如浏览器设置、URL 参数、Cookie 等)。
  • 多语言切换:提供简单的 API,方便实现多语言切换功能。

强大的功能和灵活性

  • 命名空间:支持命名空间管理翻译文件,结构清晰,易于维护。
  • 嵌套翻译:支持嵌套翻译和占位符替换,处理复杂的翻译需求。

为了配合后续的自动化提取工具,其实没有选择太多的API,主要使用了翻译实例$t(自动化提取)和Trans组件(复杂dom的语序处理)

import i18n from 'i18next';
import {initReactI18next, Trans} from 'react-i18next';

import zh from '../../../locales/zh.json';
import en from '../../../locales/en.json';
i18n.use(initReactI18next).init({
  resources: {
    en: {
      translations: en
    },
    zh: {
      translations: zh
    }
  },
  lng: global.localStorage ? global.localStorage.getItem('lang') || 'zh' : 'zh',
  debug: true,
  ns: ['translations'],
  defaultNS: 'translations',
  interpolation: {
    escapeValue: false
  }
});
const $t = i18n.getFixedT();
export {Trans, $t};
export default i18n;

配合webpack的auto-import插件,在自动化提取的时候就无需考虑在不同的场景和文件中引入所需的$t和Trans组件

 plugins: [
            [
              'auto-import',
              {
                declarations: [
                  {
                    members: ['$t', 'Trans'],
                    path: path.join(__dirname, '../../../config/i18n/index')
                  }
                ]
              }
            ]
          ]

切换语言使用i18n.changeLanguage('en');

词条提取

在选择完适合自己技术栈的国际化框架后,就是将项目的中文用国际化框架包裹来做语言的翻译切换,其实现在市面上有很多词条提取的库,但是最终还是选择自己造轮子,有几点原因:

  1. 现有看到的工具,匹配不精细,对模板字符串/DOM元素支持不友好,只是将模版字符串两边的中文分别提取,有的模版字符串的替换可能需要执行好几遍。例如存在${a}种情况这种模板字符串,你分别提取两边的中文,google翻译对应的就是exist${a}situation,这种翻译其实不符合语义,而且英文你需要对两边的翻译做空格处理,而你拿整个语句去翻译的时候就是There are ${a} situations,这种其实更符合语义规范
  2. 希望尽可能的细分中文的类型,减少手动的语义更改(例如上述的模板字符串,当然还有一些普通dom层,json层等等)
  3. 需要添加一些辅助的工具融入我们的开发流程,比如产品希望区分短语名词和句子,现有的规则就是小于几个单词的算作短语,需要将翻译首字母大写,看起来更正式。也考虑过可以配合AI识别语义做一些翻译的处理,将翻译里的规范性词语直接替换,提高翻译的准确性。

提取工具

做提取工具的时候也有两种方案选择:

  • webpack插件编译AST,不做业务代码的入侵。
  • 提取代码中的中文,重写替换成$t('key')的形式

这两种方案各有优缺点吧,我们选择了第二种方案。

使用webpack插件自动化编译AST,它的编译翻译是在后台运行,前端无感的。而我们的业务场景比较的复杂,有很多定制化的需求。

  • 针对同一个中文,它在不同的页面场景下,对应的翻译是不同的(坑点很多。。)
  • 一些复杂的dom,很难做到完全的自动化编译,需要去做自定义的语序翻译
  • B端产品翻译替换后样式乱掉的几率很大,需要手动更改
  • 针对国际化版需要做一些功能的阉割或者替换

所以还是代码入侵的方案更加的自由和方便扩展。当然你不得不做取舍接受这种方案带来的一些问题。例如业务入侵在提取替换的时候可能会影响你的功能,提取的时候你的key值规则可能会带来你打包体积的增大等等。

具体的代码实现,可以选择ts或者babel的AST解析编译来做替换

image.png

区分了几种AST的类型:

  • 普通的字符串
  • 模板字符串
  • jsx里的普通文本,组件的参数
  • dom层面
  • json里的key值
  • 换行的模板字符串(保留模板字符串回车带来的格式)

普通的字符串就是简单的提取替换,json的key值需要做[]包裹处理,jsx参数需要将字符串替换成{$t('')}方法调用的形式,复杂的在于模板字符串及dom的替换。做了几种规则的处理

  • 简单的模板字符串和普通的dom层,例如存在${list.length}种情况<span>存在{list.length}种情况</span>这种简单的文本,直接把里面的变量替换成$t('xx', {data0: list.length})这种形式,对应的中文翻译是存在{{data0}}种情况(代码中大部分都是这种场景)。由于{}里的逻辑统一替换成{{data0}}的形式,所以翻译无须关心逻辑。

  • 如果遇到变量里还有普通的字符串,也直接替换成方法包裹 ,例如存在${list.length ? '中文1': '中文2'}种情况替换成 $t('xx', {data0: list.length ? $t('aaa') : $t('bbb')})

  • 特别处理拼接dom元素和多层嵌套的模板字符串,采用传统的提取方式,分别提取两边的中文去做翻译

  • 换行格式的模板字符串做分行的中文句子提取翻译,保持换行的格式

写法特别复杂的场景其实不多,因为你平时代码嵌套太多,可读性也比较差。

针对复杂的DOM元素,可以使用react-i18next自带的Trans做处理,它支持原始的dom元素样式,比如说你有一个简单的高亮样式这是<span style={{color: 'red'}}>一个</span>高亮内容,使用Trans组件包裹,在翻译中使用这是<1>一个<1>高亮内容即可继承dom元素的样式(只支持简单的dom元素,自定义react组件不支持)

key值

关于key值的映射,由于自动化提取的文本是包含变量的,所以使用nanoid生成的随机字符串作为key值。

使用中文做key值,开发的过程中可以直观的看到原本的文案,但是如果句子过长,key值就会很长,且如果中文文案做了一点修改,需要同步修改json语言包里的key值映射和其他地方的引用,如果不修改key值只修改语言包的话,其实看到的key值与翻译是有误差的。

但使用随机字符串做为key值,在开发过程中是看不到具体的中文的,降低了开发的效率,所以配合 vscode 插件 i18n Ally 使用,可以查看对应 hash 的中英文,效果如下

image.png

如果需要修改文案,直接点击编辑即可更新

image.png

插件配置项

"i18n-ally.localesPaths": "./locales", // 加载的语言包的路径
"i18n-ally.sourceLanguage": "zh",
"i18n-ally.displayLanguage": "zh", // 插件展示的语言
"i18n-ally.keystyle": "flat",
"i18n-ally.preferredDelimiter": "_",
"i18n-ally.sortKeys": false,
"i18n-ally.keepFulfilled": true,
"i18n-ally.translate.promptSource": true,
"i18n-ally.pathMatcher": "{locale}.json", //  匹配翻译的文件
"i18n-ally.translate.saveAsCandidates": true,
"i18n-ally.keysInUse": [
  "view.progress_submenu.translated_keys",
  "view.progress_submenu.missing_keys",
  "view.progress_submenu.empty_keys"
],
"i18n-ally.usage.scanningIgnore": [
  "examples",
],
"i18n-ally.regex.usageMatchAppend": [
  "/\\*\\s*i18n\\s*\\*/\\s*['\"`]({key})['\"`]"
],
"i18n-ally.enabledFrameworks": [
  "vscode",
  "vue",
  "general"
],
"i18n-ally.theme.annotationMissingBorder": "#d37070",
"i18n-ally.theme.annotationMissing": "#d37070",

因为自动化提取的时候采用的是随机字符串,所以对于一些规范性的词语,会采用语义化的方式定义,这样在开发维护时就知道这是个规范性的词语,不能随意更改。针对作为 key === '中文'这种参数对比或者前后端传参的一些中文字段,就需要使用语义化方式维护,保持逻辑的一致性

image.png

(但是这种方案最近发现了一个坑点就是,开发是很方便,但是如果你的代码需要做很久之前的版本维护,你在git的提交上其实是无法看到具体的文案的。 - .-)

翻译准备

翻译准备看公司的投入成本吧,我们之前打算找专业的团队来做,但是成本很高,所以还是使用自动化翻译。市面上有很多自动化翻译的三方库,但是里面的服务经常中断。。。而且不易融于我们的开发流程,所以找了两个服务来做。

  • google翻译(一直在用,还挺稳定),遍历你的文案,去发请求即可(如果翻译较多英文,google的服务器可能会中途中断,注意定时器频率,不要DDOS攻击,很容易被封),但是需要自己搭🪜
const sourceLanguage = 'zh';
const targetLanguage = 'en';

const url =
  'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' +
  sourceLanguage +
  '&tl=' +
  targetLanguage +
  '&dt=t&q=' +
  encodeURI(source);

const result = await fetch(url);
const json = await result.json();
  • 百度的服务,每个月有免费的额度,申请APPID和KEY就可以用

开发流程

将上述功能集成了一套开发工具,使用工具初始化生成i18next的配置文件。然后执行i18n --all命令一键提取翻译即可(每个服务也可通过传参单独调用)。

增量开发:直接拿中文开发,开发完成后执行命令提取替换翻译,会做增量文案的提取,然后将增量文案进行google翻译,并将得到的翻译做首字母大小写的处理后添加进en.json中(其实就是简单的一些node脚本,可以做很多工具)

  • 输出没有翻译的文案
  • 删除废弃的文案key值等

然后是替换中文图片和文件,走查产品的翻译及样式,这种还是需要人工去做。需要注意的点是:

  • 中英文的分词换行样式,需要将break-all换成break-word,不然换行分割单词太low了
  • 针对表单校验,需要考虑到英文填写需要加空格,所以一些特殊字符的校验需要做区别处理。且由于单词的长度,对于长度的存储需要放宽
  • 长度方面,对于后端数据已经做了长度的兼容,加上flex布局,只有一些静态布局需要修改。对于一些特殊定制的样式,可以在最外层做en className的绑定,在样式文件中做定制化处理。