前端不一样的国际化方案

2,265 阅读9分钟

前文

国际化 前端开发er绕不开的话题

关于怎么实现国际化,现在主流的方案有很多,比如基于三大框架的: Vue - Vue-i18n,React - react-intlreact-i18next,Angular - angular-translate。还有像阿里开源的 kiwi 国际化全流程解决方案等......

而这些国际化的实现,都是以修改源码的方式。如果项目从初期就要求支持国际化,那还好。若一个开发了许久、并长期处于迭代中的项目,在短期内要求支持国际化,想想就很头疼。

本文将介绍一种不一样的国家化解决方案。

背景

不久前接到了平台国际化的需求,目标平台是一个典型的 Vue-SPA 的 SAAS 平台,通过 Webpack 打包,CDN 部署静态资源。项目总体不算复杂,但一直处于快速的迭代开发中,如果采用传统的替换源文件中字符的方式,那开发成本还有解决冲突的难度就会很大。

结合平台实际状况,萌生了一个想法,有没有办法能不替换源文件,而直接在打包过程中对页面静态资源中的中文进行某种特殊规则的替换,以达到对国际化的支持。 其实这也很好理解,传统主流的方式都是从输入端或者说是源头上进行国际化支持改造。那自然而然的就会想到,有没有办法可以从输出端,也就是从看到的页面进行改造支持?

方案

有了方向后,经过一番调研,基本罗列了以下三个方向:

  • 通过项目中配置的 babel

    babel 只能支持js代码的操作,对于像 .vue 或者 .tsx 等类型的文件支持就比较繁琐

    很多 ts 项目会使用 ts-loader 进行代码解析

    基于以上两点考虑,该方案跳过

  • Webpack 的 loader

    为此大致浏览了一遍 vue-loader-plugin,对扩展编辑没有提供简单的支持

    loader 的实现也是比较具有针对性,需要对不同类型的文件都予以支持,过于繁琐

    该方案也跳过

  • Webpack 的 plugin

    Webpack 基于 tabable 构建的打包流程,在打包的各个阶段暴露了对应的 Hooks 方法,对扩展提供了强大的支持,获取各阶段的资源也会很轻松。而本功能的实现最初也是相对打包后的资源进行替换,因此选择了此方案。

实现

插件的实现主要分为以下两步:

  • 差量提取中文,生成包含中文的 Excel 文件

  • 打包时自动以占位符替换中文字符,并自动挂载语言包文件

提取中文

得益于 Webpack 强大的 Hooks 体系,提取就显得很容易。为了能将最终呈现在页面上的中文字符都准确无误的提取出来,该功能需要加入打包构建过程中,而且得位于所有其他变异转换之后,最终输出资源前。鉴于很多项目无法轻松地获取 Webpack 配置,例如:使用 Vue-cli 脚手架构建的项目,因此,可以简单的将这一步放入 build 流程,并会在提取完毕后,自动退出 build 。可以通过插件的 action: 'collect' 参数,设置此次 build 是否为提取中文操作。

大致的步骤如下:

  1. emit 中获取所有资源的 Chunk,默认过滤掉 Node_modules 中打包出来的 Chunk
  2. 将这些字符串通过 babel 转成 AST,找出其中类型为 stringLiteral 的节点,通过正则判断是否为中文字符
  3. 将提取的中文字符,写入 Excel 文件

核心代码如下:

compiler.hooks.emit.tap(name, (compilation: compilation.Compilation) => {
	const visitor = {
      enter(path: NodePath) {
        if (isStringLiteral(path.node)) {	// 判断节点类型
          const value = path.node.value.trim()
          if (onlyChReg.test(value) && !cache.has(value)) {
            const match = escapeSymbolReg && value.match(escapeSymbolReg)

            if (match) !cache.has(match[1]) && set.add(match[1])
            else set.add(value)
          }
        }
      },
    }
    
	for (const chunk of compilation.chunks.values()) {
      if (ignoreChunk(chunk)) continue   // 跳过仅包含第三方模块的chunk

      const { files } = chunk

      if (!Array.isArray(files) || files.length === 0) continue

      for (const filename of files.values()) {
        if (jsFileReg.test(filename)) {   // 匹配js文件
          const source = compilation.assets[filename].source()
          const ast = parse(source, { sourceType: 'script' })

          traverse(ast, visitor)  // 自定义访问者
        }
      }
    }
})

优化

1. 对部分内容不需要翻译的支持
  1. 只能支持到单文件级别的内容
  2. 该文件需要 import 的方式动态导入,引用格式如下:
// a.tsx 中的内容不需要国际化支持
// b.tsx 文件中引用了 a.tsx
import('./a.tsx?withouti18n=true')
// 此时 a.tsx 中的所有内容,包括导入的其他文件都不会被本插件所翻译
2. 对字符后缀的智能优化

项目中有很多类似于:成功? || 成功! || 成功。 || 成功:,只有末尾标点不一致字符,插件支持只匹配出成功,并且在替换时,自动加上末尾的标点,具体需要忽略的符号可以由参数 ignoreEndSymbol 配置。

兼容

经过测试,以上提取的中文能满足绝大部分情况,但很多复杂的场景并不能很好的支持,如下:

  • 场景 一
handleExceed (files, fileList) {
	const message = `当前限制选择 ${this.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.length} 个文件 `
	...
}

经过上述方式提取后,将会是以下几段中文字符:'当前限制选择'、'个文件'、'本次选择了'、'共选择了'。由于缺少了上下文的限制,翻译后将不能是连贯的语句。于是提供了自定义复杂场景的函数,具体实现将会在替换中文那一节介绍。

  • 场景 二
let html = `
    <el-row><el-col align="center"><h2>是否确认依据以下信息回收资源</h2></el-col></el-row>
    <el-row><el-col align="center"><h3>工单号: ${orderCode}</h3></el-col></el-row>
`

通过 ES6 反引号定义的 html 模板,直接用于 innerHTML 或者 v-html 渲染。对于此类场景,直接简单地替换模板中的中文字符无法正确的加载语言包,建议改造如下:

const warn = '是否确认依据以下信息回收资源'
const order = '工单号'
let html = `
    <el-row><el-col align="center"><h2>${warn}</h2></el-col></el-row>
    <el-row><el-col align="center"><h3>${order}: ${orderCode}</h3></el-col></el-row>
`

替换中文字符

这一步是插件开发中最为复杂,也是花的时间最多的一步。关于如何挂载语言包,挂载在什么位置,如何兼容开发模式等都是经过很多次的尝试,最终得出的我认为的最佳方式,下面将一一介绍:

项目中的语言包文件格式

项目中语言包文件包含两部分:

  1. 位于 Excel 文件中,已翻译好的中文及对应的多语言

  2. 为了满足复杂场景,用户自定义的语言包文件

以本人之前项目中的实践为例,设置的语言包根目录为 process.cwd()/src/i18n 。自定义的替换规则,需要在该目录中新建名为 customize 的目录,并在 customize 目录中新建以语言类型命名的 .js 文件,以英文文件内容为例:

// en.js
// 支持 方法、箭头函数、普通函数 定义
export default {
	uploadExceed (limit, length, total) {
      return (
        'The current limit is' + limit +
        ' files,and ' +
        length +
        ' files are selected this time,' +
        total +
        ' files are selected in total'
      )
  	},
    get: () => {},
    done: function () {},
    name: 'i18n'
}

项目中对应位置需要手动执行替换,还是以上面复杂场景为例,手动替换后如下:

handleExceed (files, fileList) {
    const message = window.i18n.c.uploadExceed(this.limit, files.length, files.length + fileList.length)
	...
}

语言包挂载

关于语言包的挂载,最初尝试通过 Webpack 的 ProvidePluginexpose-loader 进行全局注入,然而测试下来,这两个功能只是面向的开发,并不支持依赖自动注入。后来尝试将语言包挂载在 Vue 的原型上,测试的结果是:Vue 组件中的都没问题,但是位于其他文件如 .js 文件的中文无法支持......最后根据项目的实际情况,决定将语言包文件挂载在全局对象 window 上,页面中所有中文字符在打包时都替换成 window.i18n.xxxx 加载语言包中对应的内容。

接下来面临的问题就是:

  • 如何整合语言包文件,进行挂载

  • 如何挂载

其中第一个问题主要解决了 import 导入的包含函数的对象如何序列化,并写入文件。

第二个面临的问题是:开发环境和生产环境如何挂载语言包。借助 html-webpack-plugin 抛出的 alterAssetTags Hooks。在开发环境中,将挂载函数以如下格式 <script>function setCurrentI18nList () {} setCurrentI18nList()</script> 直接写入 index.html 中。而生产环境是生成单独的文件,使用 terser 压缩代码,通过 script 标签加载,并且通过 spark-md5 以文件内容的格式生成 Hash 命名语言包文件,支持静态资源缓存。 这两部分实现代码比较多,就不贴了,有兴趣的可以在最下方的 git 仓库中查看源码。

占位符替换

该步骤也是通过 Webpack 的 Hooks 实现,不同于提取中文,这是在 optimizeChunkAssets Hooks中实现对输出资源的拦截、修改、替换。并通过 @bable/template 进行修正:

template.expression(`
	window.i18n[%%key%%]
`)

最后经过 webpack-sources 将最终的资源替换,后续 emit 后生成的 chunk 即是替换了占位符的代码。

命令行工具

插件目前集成了两个简单的命令行工具

  1. 查找中文字符位于哪个 Excel 文件中
  2. 合并现有的多个 Excel 文件

命令行配置

"script": {
  ...
  "i18n": "i18n"
}
// 用于查询中文字符位于哪些文件中
yarn/npm i18n --find/-f xxxx dddd eee

// 用于合并多个excel文件
// 不传参数默认合并所有的 excel 文件
// 或者指定要和的excel文件(注:默认都是与i18n.xlsx合并)
yarn/npm i18n --merge/-m xxx.xlsx

总结

插件采用 ts 开发,目前已经应用于项目中。虽然与最初设想的 完全非侵入式 有点出入,不过对旧项目的改造或者对一些简单项目的国际化支持,还是提供了较大的便利。关于该方式的国际化,做了如下总结:

优势:

  • 较小的倾入性
    • 保持项目中文开发
    • 使老项目支持国际化改造更加友好
  • 较好的操作支持,一键提取差量中文字符,打包时自动替换
  • 可以在不修改第三方包的前提下,使第三方包支持国际化

劣势:

  • 正如标题一样,基于 Webpack 的插件,非 Webpack 打包的项目无法支持
  • 每次打包发布都需要全量替换一次,对于特别大的项目每次会造成一定的损耗
  • 语言包是挂载在 window 对象上,无法支持服务端渲染的应用(关于这一问题,是否对服务端支持,欢迎留言区讨论)

待完善:

  • 低版本的 Webpack 支持,目前的开发和测试的环境为:
    • Webpack4+
    • babel7+
  • 对 node_modules 下的模块打包出来的 chunk 的判断不足
  • 部分内容要求支持国际化的项目,暂未提供支持。以模块化方式开发的项目,是否能得到较好的支持,还有待探究。

githubi18n-webpack-plugin

写完后才发现貌似早就有了同方案的实现,webpack-contrib / i18n-webpack-plugin ,但已经不再维护。看了源码,实现的具体方式也不相同。。。。。。

感谢您的阅读,对于插件有任何改善建议的,欢迎留言区讨论。