基於後編譯的國際化解決方案

滴滴WebApp架構組發表於2018-07-12

在之前做一些前端國際化的專案的時候,因為業務不是很複雜,相關的需求一般都停留在文案的翻譯上,即國際化多語言,基本上使用相關的 I18n 外掛即可滿足開發的需求。但是隨著業務的迭代和需求複雜度的增加,這些 I18n 外掛不一定能滿足相關的需求開發,接下來就和大傢俱體聊下在做國際化專案的過程中所遇到的問題以及所做的思考。

因為團隊的技術棧主要是基於 Vue,因此相關的解決方案也是基於 Vue 以及相關的國際化外掛(vue-i18n)進行展開。

一期

背景

我們藉助 vue-i18n 來完成相關國際化的工作。當專案比較簡單,沒有大量語言包檔案的時候,將語言包直接打包進業務程式碼中是沒有太大問題的。不過一旦語言包檔案多起來,這個時候是可以考慮將語言包單獨打包,減少業務程式碼體積,通過非同步載入的方式去使用。此外,考慮到國際化語言包相對來說是非高頻修改的內容,因此可以考慮將語言包進行快取,每次頁面渲染時優先從快取中獲取語言包來加快頁面開啟速度。

解決方案

關於分包相關的工作可以藉助 webpack 來自動完成分包及非同步載入的工作。從 1.x 的版本開始,webpack 便提供了 require.ensure() 等相關 API 去完成語言包的分包的工作,不過那個時候 require.ensure() 必須要接受一個指定的路徑,從 2.6.0 版本開始,webpack的 import 語法可以指定不同的模式解析動態匯入,具體可以參見文件。因此結合 webpack 及 vue-i18n 提供的相關的 API 即可完成語言包的分包及非同步載入語言包,同時在執行時完成語言的切換的工作。

示例程式碼:

檔案目錄結構:

src
|--components
|--pages
|--di18n-locales  // 專案應用語言包
|   |--zh-CN.js
|   |--en-US.js
|   |--pt-US.js
|--App.vue
|--main.js
複製程式碼

main.js:

import Vue from 'vue'
import VueI18n from 'vue-i18n'
import App from './App.vue'

Vue.use(VueI18n)

const i18n = new VueI18n({
    locale: 'en',
    messages: {}
})

function loadI18nMessages(lang) {
    return import(`./di18n-locales/${lang}`).then(msg => {
        i18n.setLocaleMessage(lang, msg.default)
        i18n.locale = lang
        return Promise.resolve()
    })
}

loadI18nMessages('zh').then(() => {
  new Vue({
    el: '#app',
    i18n,
    render: h => h(App)
  })
})
複製程式碼

以上首先解決了語言包的分包和非同步載入的問題。

接下來聊下關於如果給語言包做快取,以及相關的快取機制,大致的思路是:

開啟頁面後,優先判斷 localStorage 是否存在對應語言包檔案,如果有的話,那麼直接從 localStorage 中同步的獲取語言包,然後完成頁面的渲染,如果沒有的話,那麼需要非同步從 CDN 獲取語言包,並將語言包快取到 localStorage 當中,然後完成頁面的渲染.

當然在實現的過程中還需要考慮到以下的問題:

  • 如果語言包發生了更新,那麼如何更新 localStorage 中快取的語言包?

    首先在程式碼編譯的環節,通過 webpack 外掛去完成每次編譯後,語言包的版本 hash 值的收集工作,同時注入到業務程式碼當中。當頁面開啟,業務程式碼開始執行後,首先會判斷業務程式碼中語言包的版本和 localStorage 中快取的版本是否一致,如果一致則同步獲取對應語言包檔案,若不一致,則非同步獲取語言包

  • 在 localStorage 中版本號及語言包的儲存方式?

    資料都是儲存到 localStorage 當中的, localStorage 因為是按域名進行劃分的,所以如果多個國際化專案部署在同一域名下,那麼可按專案名進行 namespace 的劃分,避免語言包/版本hash被覆蓋

以上是初期對於國際化專案做的一些簡單的優化。總結一下就是:語言包單獨打包成 chunk,並提供非同步載入及 localStorage 儲存的功能,加快下次頁面開啟速度。

二期

背景

隨著專案的迭代和國際化專案的增多,越來越多的元件被單獨抽離成元件庫以供複用,其中部分元件也是需要支援國際化多語言。

已有方案

其中關於這部分的內容,vue-i18n 現階段也是支援元件國際化的,具體的用法請參加文件,大致的思路就是提供區域性註冊 vue-i18n 例項物件的能力,每當在子元件內部呼叫翻譯函式$t$tc等時,首先會獲取子元件上例項化的 vue-i18n 物件,然後去做區域性的語言 map 對映。

它所提供的方式僅僅限於語言包的區域性 component 註冊,在最終程式碼編譯打包環節語言包最終也會被打包進業務程式碼當中,這也與我們初期對於國際化專案所做的優化目標不太相容(當然如果你的 component 是非同步元件的話是沒問題的)。

優化方案

為了在初期目標的基礎上繼續完善元件的國際化方案,這裡我們試圖將元件的語言包和元件進行解耦,即元件不需要單獨引入多語言包,同時元件的語言包也可以通過非同步的方式去載入

這樣在我們的預期範圍內,可能會遇到如下幾個問題:

  • 專案應用當中也會有自己的多語言,那麼如何管理專案應用的多語言和元件之間的多語言?
  • vue-i18n 外掛提供了元件多語言的區域性序號產生器制,那麼如果將多語言包和元件進行解耦,最終元件進行渲染時,多語言的文案如何翻譯?
  • 元件庫內部也會存在父子/巢狀元件,那麼元件庫內部的多語言包應該如何去管理和組織?
  • ...

首先在我們小組內部,後編譯(關於後編譯可以戳我)應該是我們技術棧的標配,因此我們的元件庫最終也是通過原始碼的形式直接釋出,專案應用當中通過按需引入+後編譯的方式進行使用。

專案應用的多語言包組織應該問題不大,一般放置於一個獨立的目錄(di18n-locales)當中:

// 目錄結構:
src
├── App.vue
├── di18n-locales
│   ├── en-US.js
│   └── zh-CN.js
└── main.js


// en-US.js
export default {
    messages: {
        'en-US': {
            viper: 'viper',
            sk: 'sk'
        }
    }
}

// zh-CN.js
export default {
    messages: {
        'zh-CN': {
            viper: '冥界亞龍',
            sk: '沙王'
        }
    }
}
複製程式碼

di18n-locales 目錄下的每個語言包最終會單獨打包成一個 chunk,所以這裡我們考慮是否可以將元件庫當中每個元件自己的語言包最終也和專案應用下的語言包打包在一起為一個 chunk:即專案應用的 en-US.js 和元件庫當中所有被專案引用的元件對應的 en-US.js 打包在一起,其他語言包與此相同。這樣做的目的是為了將元件庫的語言包和元件進行解耦(與 vue-i18n 的方案正好相反),同時和專案應用的語言包進行統一的打包,以供非同步載入。向著這樣一個目的,我們在規劃元件庫的目錄時,做了如下的約定:與每個元件同級也會有一個 di18n-locales(與專案應用的語言包目錄保持一致,當然也支援可配)目錄,這個目錄下存放了每個元件對應的多語言包:

├── node_modules
|   ├── @didi
|       ├── common-biz-ui
|           └── src
|               └── components
|                   ├── coupon-list
|                   │   ├── coupon-list.vue
|                   │   └── di18n-locales
|                   │       ├── en.js // 當前元件對應的en語言包
|                   │       └── zh.js // 當前元件對應的zh語言包
|                   └── withdraw
|                       ├── withdraw.vue
|                       └── di18n-locales
|                           ├── en.js  // 當前元件對應的en語言包
|                           └── zh.js  // 當前元件對應的zh語言包 
├── src
│   ├── App.vue
│   ├── di18n-locales
│   │   ├── en.js   // 專案應用 en 語言包
│   │   └── zh.js   // 專案應用 zh 語言包
│   └── main.js
複製程式碼

當你的專案應用當中使用了元件庫當中的某個元件時:

// App.vue
<template>
    ...
</template>

<script>
import couponList from 'common-biz-ui/coupon-list'
export default {
    components: {
        couponList
    }
}
</script>
複製程式碼

那麼在不需要你手動引入語言包的情況下:

  1. 如何才能拿到 coupon-list 這個元件下的語言包?
  2. coupon-list 元件所使用的語言包打包進專案應用對應的語言包當中並輸出一個 chunk?

為此我們開發了一個 webpack 外掛:di18n-webpack-plugin。用以解決以上2個問題,我們來看下這個外掛的核心程式碼:

compilation.plugin('finish-modules', function(modules) {
    ...
    for(const module of modules) {
        const resource = module.resource || ''

        if (that.context.test(resource)) {
          const dirName = path.dirname(resource)
          const localePath = path.join(dirName, 'di18n-locales')
          if (fs.existsSync(localePath) && !di18nComponents[dirName]) {
            di18nComponents[dirName] = {
              cNameArr: [],
              path: localePath
            }
            const files = fs.readdirSync(dirName)
            files.forEach(file => {
              if (path.extname(file) === '.vue') {
                const baseName = path.basename(file, '.vue')
                const componentPath = path.join(dirName, file)
                const prefix = getComponentPrefix(componentPrefixMap, componentPath)
                let componentName = ''
                if (prefix) {
                  // transform to camelize style
                  componentName = `${camelize(prefix)}${baseName.charAt(0).toUpperCase()}${camelize(baseName.slice(1))}`
                } else {
                  componentName = camelize(baseName)
                }
                // component name
                di18nComponents[dirName].cNameArr.push(componentName)
              }
            })
            ...
        }
    }
})
複製程式碼

原理就是在 finish-modules 這個編譯的階段,所有的 module 都完成了編譯,那麼這個階段便可以找到在專案應用當中到底使用了元件庫當中的哪些元件,即元件對應的絕對路徑,因為我們之前已經約定好了和元件同級的會有一個 di18n-locales 目錄專門存放元件的多語言檔案,所以對應的我們也能找到這個元件使用的語言包。最終通過這樣一個鉤子函式,以元件路徑作為 key,完成相關的收集工作。這樣上面的第一個問題便解決了。

接下來看下第二個問題。當我們通過 finish-modules 這個鉤子拿到都有哪些元件被按需引入後,但是我們會遇到一個非常尷尬的問題,就是 finish-modules 這個階段是在所有的 module 完成編譯後觸發的,這個階段之後便進入了 seal 階段,但是在 seal 階段裡面不會再去做有關模組編譯的工作。

但是通過閱讀 webpack 的原始碼,我們發現了在 compilation 上定義了一個 rebuildModule 的方法,從方法名上看應該是對一個 module 的進行重新編譯,具體到方法的內部實現確實是呼叫了 compliation 物件上的 buildModule 方法去對一個 module 進行編譯:

class Compilation extends Tapable {
    constructor() {
        ...
    }
    
    ...
    rebuildModule() {
        ...
        
        this.buildModule(module, false, module, null, err => {
            ...
        })
    }
    ...
}
複製程式碼

因為從一開始我們的目標就是元件庫當中的多語言包和元件之間是相互解耦的,同時對於專案應用來說是無感知的,因此是需要 webpack 外掛在編譯的階段去完成打包的工作的,所以針對上面第二個問題,我們嘗試在 finish-modules 階段完成後,拿到所有的被專案使用的元件的多語言包路徑,然後自動完成將元件多語言包作為依賴新增至專案應用的語言包的原始碼當中,並通過 rebuildModule 方法重新對專案應用的語言包進行編譯,這樣便完成了將無感知的語言包作為依賴注入到專案應用的語言包當中。

webpack 的 buildModule 的流程是:

webpack buildModule 流程

我們看到在 rebuild 的過程當中, webpack 會再次使用對應檔案型別的 loader 去載入相關檔案的原始碼到記憶體當中,因此我們可以在這個階段完成依賴語言包的新增。我們來看下 di18n-webpack-plugin 外掛的關於這塊內容的核心程式碼:

compilation.plugin('build-module', function (module) {
      if (!module.resource) {
        return
      }
      // di18n rules
      if (/src\/di18n-locales\//.test(module.resource) && module.createSource.name !== 'di18nCreateSource') {
        ...

          if (!componentMsgs.length) {
            return createSource.call(this, source, resourceBuffer, sourceMap)
          }
          let vars = []
          const varReg = /export\s+default\s+([^{;]+)/
          const exportDefaultVar = source.match(varReg)

          source = `
          ${componentMsgs.map((item, index) => {
            const varname = `di18n${index + 1}`
            const { path, cNameStr } = item
            vars.push({
              varname,
              cNameStr
            })
            return `import ${varname} from "${path}";`
          }).join('')}
          ${
            exportDefaultVar
              ? source.replace(varReg, function (_, m) {
                  return `
                    ${m}.components = {
                      ${getComponentMsgMap(vars)}
                    };
                    export default ${m}
                  `
                })
              : source.replace(/export\s+default\s*\{([^]+)\}/i, function (_, m) {
                  return `export default {${m},
                    components: {
                      ${getComponentMsgMap(vars)}
                    }
                  }
                  `
                })
          }
          `
          resourceBuffer = new Buffer(source)
          return createSource.call(this, source, resourceBuffer, sourceMap)
        }
      }
    })
複製程式碼

原理就是利用 webpack 對 module 開始進行編譯時暴露出來的 build-module 鉤子,它的 callback 傳參為當前正在編譯的 module ,這個時候我們對 createSource 方法進行了一層代理,即在 createSource 方法呼叫前,我們通過改寫專案應用語言包的原始碼來完成元件的語言包的引入。之後的流程還是交由 webpack 來進行處理,最終專案應用的每個語言包會單獨打包成一個 chunk,且這個語言包中還將按需引入的元件的語言包一併打包進去了。

最終達到的效果就是:

// 原始的專案應用中文(zh.js)語言包

export default {
    messages: {
        zh: {
            hello: '你好',
            goodbye: '再見'
        }
    }
}
複製程式碼

通過 di18n-webpack-plugin 外掛處理後的專案應用中文語言包:

// 將專案依賴的元件對應的語言包自動引入專案應用當中的語言包當中並完成編譯輸出為一個chunk

import bizCouponList from 'xxxx/xxxx/node_modules/xxx/src/components/coupon-list/di18n-locales/zh.js' // 元件語言包的路徑為絕對路徑

export default {
    messages: {
        zh: {
            hello: '你好',
            goodbye: '再見'
        }
    },
    components: {
        bizCouponList
    }
}
複製程式碼

(在這裡我們引入元件的語言包後,我們專案語言包中新增一個 components 欄位,並將子元件的名字作為 key ,子元件的語言包作為 value ,掛載至 components 欄位。)

上述過程即解決了之前提出來的幾個問題:

  1. 如何獲取元件使用的語言包
  2. 如何將元件使用的語言包打包進專案應用的語言包並單獨輸出一個 chunk
  3. 如何管理專案應用及元件之間的語言包的組織

現在我們通過 webpack 外掛在編譯環節已經幫我解決了專案語言包和元件語言包的組織,構建打包等問題。但是還有一個問題暫時還沒解決,就是我們將元件語言包和元件進行解耦後,即不再按 vue-i18n 提供的多語言區域性註冊的方式,而是將元件的語言包收斂至專案應用下的語言包,那麼如何才能完成元件的文案翻譯工作呢?

我們都清楚 Vue 在建立子 component 的 VNode 過程當中,會給每個 VNode 建立一個唯一的 component name:

// src/core/vdom/create-component.js

export function createComponent() {

    ...
    
    const vnode = new VNode(
        `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
        data, undefined, undefined, undefined, context,
        { Ctor, propsData, listeners, tag, children },
        asyncFactory
    )

    ...

}
複製程式碼

在實際的使用過程當中,我們要求元件必須要有自己唯一命名。

vue-i18n 提供的策略是區域性註冊 vue-i18n 例項物件,每當在子元件內部呼叫翻譯函式$t$tc等時,首先會獲取子元件上例項化的 vue-i18n 物件,然後去做區域性的語言 map 對映。這個時候我們可以換一種思路,我們將子元件的語言包做了統一管理,不在子元件上註冊 vue-i18n 例項,但是每次子元件呼叫$t$tc等翻譯函式的時候,這個時候我們從統一的語言包當中根據這個子元件的 component-name 來取得對應的語言包的內容,並完成翻譯的工作。

在上面我們也提到了我們是如何管理專案應用及元件之間的語言包的組織的:我們引入元件的語言包後,我們專案語言包中新增一個 components 欄位,並將子元件的名字作為 key,子元件的語言包作為 value,掛載至 components 欄位。這樣當子元件呼叫翻譯函式的方法時,始終首先去專案應用的語言包當中的 components 欄位中找到對應的元件名的 key,然後完成翻譯的功能,如果沒有找到,那麼兜底使用專案應用對應欄位的語言文案。

總結

以上就是我們對於近期所做的一些國際化專案的思考,總結一下就是:

  • 語言包單獨打包成 chunk,並非同步載入
  • 提供 localStorage 本地快取的功能,下次再開啟頁面不需要單獨載入語言包
  • 元件語言包和元件解耦,元件對元件的語言包是無感知的,不需要單獨單獨在元件上進行註冊
  • 通過 webpack 外掛完成元件語言包和專案應用的語言包的組織和管理

事實上上面所做的工作都是為了更多的減少相關功能對於官方提供的外掛的依賴,提供一種較為抹平技術棧的通用解決方案。

相關文章