Gatsby Starter Blog

深入React: 国际化方案

January 22, 2020

头条海外产品需要支持多个语言地区的文案,所有文案加起来大小大约是 1M,同时也是一个多页应用,除了作者的后台管理页面是一个较复杂的单页应用使用了较多的文案,其余页面使用的文案都较少,如果需要将所有语言和所有页面的文案一次性加载,那么势必对网站的首屏加载速度造成很大的影响,因此需要支持一种按语言和页面进行按需加载的方案。

现在主流的国际化分为两种,编译时按需加载和运行时按需加载。

编译时按需加载

编译时按需加载的思路是在对代码进行打包编译时就将文案占位符替换为实际的文案,使用 i18n-webpack-plugin 插件可以很方便的实现编译时按需加载。使用方法如下:

// input.js
console.log(__("Hello World"));
console.log(__("Missing Text"));

webpack 配置如下:

// webpack.config.js
var path = require("path");
var I18nPlugin = require("i18n-webpack-plugin");
var languages = {
	"en": null,
	"de": require("./de.json")
};
module.exports = Object.keys(languages).map(function(language) {
	return {
		name: language,
		// mode: "development || "production",
		entry: "./example",
		output: {
			path: path.join(__dirname, "dist"),
			filename: language + ".output.js"
		},
		plugins: [
			new I18nPlugin(
				languages[language]
			)
		]
	};
});

文案格式入下:

// de.json
{
	"Hello World": "Hallo Welt"
}
// en.json
{
       "Hello World": "Hello World"
}

编译时按需加载的好处有使用配置比较方便,运行时不需要任何依赖,自动的实现了文案的按页面的按需加载。缺点是使用文案的地方必须要使用函数调用的方式,没有对象属性的方式自然。最严重的问题是,编译时按需加载需要为每种语言都生成一份 js 文件,因此 n 种语言,我们需要生成 n 个 js 文件(这也导致了我们需要维护 n 个版本的 html 文件以解决 js 的更新),这样较为浪费 cdn 资源,而且也难以管理,难以进行热更新操作。

运行时按需加载

运行时按需加载与编译时按需加载相反,服务端根据客户端请求,判断识别出客户端语言地区信息,服务端下发客户端所需语言地区的文案,服务端运行时替换文案。使用方法如下:

服务端代码:

const Koa = require('koa');
const i18n = require('./i18n');
const app = new Koa();
...
app.use(async (ctx,next) => {
  const { lang, region} = ctx.userInfo;
  const strings = i18n[`${lang}-${region}`];
  ctx.body = `
  <html>
  window.strings = ${strings}
  ...
  </html>
  `
});

客户端代码:

console.log(strings['Hello World'])

文案格式如下:

i18n/
en.json
de.json
index.js

// index.js
import * as dict from './*.json';

export default dict;

//en.json
{
	"Hello World": "Hallo World"
}
//de.json
{
	"Hello World": "Hallo Welt"
}

服务端下发文案成功的解决了需要维护 n 个版本 js 代码的问题,我们只需要替换 window.strings 为对应语言文案,就可以替换代码中对应文案了。然而其带来了另外一个问题,服务端下发文案会下发所有页面的文案,这样每个页面都需要加载所有页面的文案,这对于单页应用没有太大问题,但对于多页应用,仍然造成了不小的资源浪费。

编译时按需加载难以实现按语言记载,运行时按需加载又难以实现按页面记载,有没有办法把两者的好处结合起来呢?

答案就是同时使用编译时按需加载和运行时按需加载。编译时收集每个页面所用的文案 key 值,运行下发用户对应语言和访问页面 key 的文案即可。使用方法如下:

const Koa = require('koa');
const i18n = require('./i18n');
const keyMap = require('./keyMap');
const app = new Koa();

app.use(async (ctx,next) => {
  const { lang, region} = ctx.userInfo;
  const page = ctx.request.url;
  const keys = keyMap[page];
  const strings = i18n[`${lang}-${region}`];
  const obj = {};
  for(const key of keys){
   obj[key] = strings[key];
  }
  ctx.strings = obj;
});

keyMap 如下:

{
'feed': ['Hello World', 'Nice Try'],
'story': ['Hello Story']
}

文案格式如下:

//en.json
{
 "Hello World": "Hello World",
 "Nice Try" : "Nice Try",
 "Hello Story": "This is a good story"
}

此时问题的关键在于如何获取 keyMap 的值,即如何获取页面中使用了哪些文案。

获取页面文案 key 列表

webpack 中对资源的处理主要有两种方式,loader 和 plugin。我们如果需要获取代码中使用哪些文案的信息,那么可以通过编写 loader 或者 plugin 来解决。

我们首先尝试了 loader,loader 虽然可以轻易的通过遍历 ast,来获取文案的相关信息,但是在 loader 中却无法获取到 entry 的信息,我们只能通过 loader 获取到每个模块使用了哪些模块,但是没法将模块与 entry 相关联,这也就导致我们无法通过 loader 获取页面所有模块的文案信息了。

接下来我们尝试了使用 plugin,webpack 关于插件的文档,只能说是一言难尽/(ㄒ o ㄒ)/~~,我们首先的思路还是想通过 AST 来获取文案的信息。查阅了 webpack 相关文档,发现可以通过 Parser 来获取被解析模块的 AST 结构。然而坑爹的是 Parser 提供的 API 接口相当有限,其并不能提供属性访问的 hook,让我们做依赖收集,其只提供了方法调用的 hook。我们已有的代码里文案调用方式 console.log(strings.helloworld)这种属性访问的方式,所以要么我们需要将所有的调用方式都改写成\strings(‘hello_world’) 这种方法调用的方式,要么通过正则方式抽取出所有文案信息。对于方法调用方式的依赖收集,实际上已经有现成的库可以使用如 grassator/webpack- extract-translation-keys ,其实现方式正是通过 webpack 的 Parser 的方法调用 hook 实现的,主要实现如下:

  compiler.plugin('compilation', function(compilation, params) {
        var keys = this.keys;
        params.normalModuleFactory.plugin('parser', function(parser) {
            // parser的方法调用hook,其编译functionName(key)时触发。
            parser.plugin('call ' + functionName, function(expr) {
                var key;
                key = this.evaluateExpression(expr.arguments[0]);
                key = key.string;
                var value = expr.arguments[0].value;
                if (!(key in keys)) {
                    keys[key] = value;
                }
            });
        });
compiler.plugin('done', function() {
        this.done(this.keys);
        if (this.output) {
            require('fs').writeFileSync(this.output, JSON.stringify(this.keys));
        }
 }.bind(this));

使用方式如下

// webpack.config.js

var ExtractTranslationKeysPlugin = require('webpack-extract-translation-keys-plugin');
module.exports = {
    plugins: [
        new ExtractTranslationKeysPlugin({
            functionName: '_TR_',
            output: path.join(__dirname, 'dist', 'translation-keys.json')
        })
    ]

    // rest of your configuration...
}
// input.js
console.log(_TR_('translation-key-1'));
console.log(_TR_('translation-key-2'));
生成文件如下
// traslation-keys.json
{
    "translation-key-1": "translation-key-1",
    "translation-key-2": "translation-key-2"
}

对于新项目,当然可以使用函数调用这种方式,但对于我们的老项目,为了避免对已有代码做过多改造,需要兼容对象属性的使用方式,因此我们考虑使用正则对每个页面的所有代码的文案做依赖收集。

首先我们需要获得每个页面的所有引用的代码文本,然后在进行正则处理。

查阅文档可知,webpack 可以通过‘optimize-chunk- assets’获取所有的 chunk 的源码信息,我们只要收集所有 chunk 里的文案 key 值,然后与对应 entry 关联,即可得到每个 entry 使用的所有文案信息了。

实现如下:

const ModuleFilenameHelpers = require('webpack/lib/ModuleFilenameHelpers');
const fs = require('fs');
const defaultConfig = {
  test: /\.jsx?/,
  exclude: [/common\.bundle\.js/, /localize\.js/],
  output: 'config/trans-key.json'
}
// 由于适用babel-loader处理了js,且strings是使用import导入,导致strings被修改为_localization2.default,另有一些页面没有使用import strings导入,则仍然为strings
const babelMangleRegex = /(?:strings|_localization2\.default)\.(\w+)/g;
function isInitialOrHasNoParents(chunk) {
  const ret =  chunk.entrypoints.length > 0 || chunk.parents.length === 0;
  return ret;
}
class ExtractKeysPlugin {
  constructor(config){
    this.keyMap = {};
    this.config = Object.assign({}, defaultConfig, config);
  }
  apply(compiler){
    compiler.plugin('compilation', (compilation) => {
      compilation.plugin('optimize-chunk-assets', (chunks, callback) => {
        this.keyMap = {};
        for(const chunk of chunks){
          for(const file of chunk.files){
            if(!ModuleFilenameHelpers.matchObject(defaultConfig, file)){
              continue;
            }
            const asset = compilation.assets[file];
            const code = asset.source();
            let match = null;
            let entries = [];
            // 子路由的文案挂载到根路由上
            if(isInitialOrHasNoParents(chunk)){
              entries = [chunk.name] // 根路由
            }else{
              entries = chunk.parents.map(x => x.name); //子路由
            }
            entries.forEach(entry => {
              if(!this.keyMap[entry]){
                this.keyMap[entry] = {}
              }
            })

            while( match = babelMangleRegex.exec(code)){
              for(const entry of entries ){
                this.keyMap[entry][match[1]] = true; // 收集页面引用的key,并去重
              }
            }
            // 重置正则索引位置
            babelMangleRegex.lastIndex = 0;
          }
        }
        callback();
      })
    })
    compiler.plugin('done', () => {
      const output = {}
      for(const entry of Object.keys(this.keyMap)){
        const dict = this.keyMap[entry];
        output[entry] = Object.keys(dict).sort()
      }
      fs.writeFileSync(this.config.output, JSON.stringify(output,null, ' '));
    })
  }
}
module.exports = ExtractKeysPlugin;

有几点值得注意的地方:

  1. webpack optimize-chunk-assets 执行的时机是在 babel-loader 处理完 js 之后,这使得我们获取的代码是经过 babel-loader 处理了,babel-loader 主要是处理一些语法的转换处理,并不会对变量命名造成影响,但由于 node 并不支持 import 语法,出于 SSR 需求,我们需要将 import 转换为 commonjs,这就导致 import strings from ’…’这种方式引入的变量,会被 babel-loader 转换为 commonjs 的语法。由于历史问题,我们代码中部分 strings 变量的导入是通过 import,部分 strings 导入是通过全局变量。为了兼顾 strings 的 import 导入,正则的书写需要采用 babel-loader 编译过的命名。
  2. 每个 js 文件内文案引用的变动,都可能会影响所有 entry 的文案 key 列表,因此需要在 optimize-chunk-assets 里重置 keyMap
  3. 对于使用 dynamic import 加载的子页面,其 entry 入口判断要复杂一些,难以通过 entrypoints 和 parents 单独判断 entry 的方法(有没有更简单判断 entry 方法的方法???)。

这样我们就通过一个简单的 webpack 插件,实现了文案的按页面和按需加载了。

事实上这样的方案碰到服务端渲染时,仍然会带来种种的问题。

下一篇将会继续讨论服务端渲染带来的种种问题和解决方式。