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

在以前作一些前端國際化的項目的時候,由於業務不是很複雜,相關的需求通常都停留在文案的翻譯上,即國際化多語言,基本上使用相關的 I18n 插件便可知足開發的需求。可是隨着業務的迭代和需求複雜度的增長,這些 I18n 插件不必定能知足相關的需求開發,接下來就和你們具體聊下在作國際化項目的過程當中所遇到的問題以及所作的思考。javascript

由於團隊的技術棧主要是基於 Vue,所以相關的解決方案也是基於 Vue 以及相關的國際化插件(vue-i18n)進行展開。html

一期

背景

咱們藉助 vue-i18n 來完成相關國際化的工做。當項目比較簡單,沒有大量語言包文件的時候,將語言包直接打包進業務代碼中是沒有太大問題的。不過一旦語言包文件多起來,這個時候是能夠考慮將語言包單獨打包,減小業務代碼體積,經過異步加載的方式去使用。此外,考慮到國際化語言包相對來講是非高頻修改的內容,所以能夠考慮將語言包進行緩存,每次頁面渲染時優先從緩存中獲取語言包來加快頁面打開速度。前端

解決方案

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

示例代碼:java

文件目錄結構:node

src
|--components
|--pages
|--di18n-locales  // 項目應用語言包
|   |--zh-CN.js
|   |--en-US.js
|   |--pt-US.js
|--App.vue
|--main.js
複製代碼

main.js:webpack

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)
  })
})
複製代碼

以上首先解決了語言包的分包和異步加載的問題。git

接下來聊下關於若是給語言包作緩存,以及相關的緩存機制,大體的思路是:github

打開頁面後,優先判斷 localStorage 是否存在對應語言包文件,若是有的話,那麼直接從 localStorage 中同步的獲取語言包,而後完成頁面的渲染,若是沒有的話,那麼須要異步從 CDN 獲取語言包,並將語言包緩存到 localStorage 當中,而後完成頁面的渲染.web

固然在實現的過程當中還須要考慮到如下的問題:

  • 若是語言包發生了更新,那麼如何更新 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 插件完成組件語言包和項目應用的語言包的組織和管理

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

相關文章
相關標籤/搜索