阅读 4182

基于后编译的国际化解决方案

在之前做一些前端国际化的项目的时候,因为业务不是很复杂,相关的需求一般都停留在文案的翻译上,即国际化多语言,基本上使用相关的 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 插件完成组件语言包和项目应用的语言包的组织和管理

事实上上面所做的工作都是为了更多的减少相关功能对于官方提供的插件的依赖,提供一种较为抹平技术栈的通用解决方案。

关注下面的标签,发现更多相似文章
评论

查看更多 >