React填坑記(三):國際化方案

Topbuzz 作為今日頭條海外版,需要支持四十多種語言地區的文案,所有文案加起來大小大約是1M,同時Topbuzz也是一個多頁應用,除了作者的後台管理頁面是一個較複雜的單頁應用使用了較多的文案,其餘頁面使用的文案都較少,如果需要將所有語言和所有頁面的文案一次性載入,那麼勢必對網站的首屏載入速度造成很大的影響,因此需要支持一種按語言和頁面進行按需載入的方案。

現在主流的國際化分為兩種,編譯時按需載入和運行時按需載入。

編譯時按需載入

編譯時按需載入的思路是在對代碼進行打包編譯時就將文案佔位符替換為實際的文案,使用i18n-webpack-plugin 插件可以很方便的實現編譯時按需載入。使用方法如下:

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

webpack配置如下:

// webpack.config.jsvar 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.jsonde.jsonindex.js// index.jsimport * 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.jsvar 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.jsconsole.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導入,則仍然為stringsconst 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插件,實現了文案的按頁面和按需載入了。

事實上這樣的方案碰到服務端渲染時,仍然會帶來種種的問題。

下一篇將會繼續討論服務端渲染帶來的種種問題和解決方式。


推薦閱讀:

React 實踐與性能(技術周刊 2018-02-02)
React 16 發布,Facebook 如約解除了專利條款
解鎖 React 組件化新姿勢 react-call-return
從年會看聲明式編程(Declarative Programming)

TAG:前端開發 | 國際化與本地化 | React |