React填坑记(三):国际化方案

头条海外产品需要支持多个语言地区的文案,所有文案加起来大小大约是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.hello_world)这种属性访问的方式,所以要么我们需要将所有的调用方式都改写成_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插件,实现了文案的按页面和按需加载了。


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

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

编辑于 2018-09-21 11:08