在以前作一些前端国际化的项目的时候,由于业务不是很复杂,相关的需求通常都停留在文案的翻译上,即国际化多语言,基本上使用相关的 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 是异步组件的话是没问题的)。
为了在初期目标的基础上继续完善组件的国际化方案,这里咱们试图将组件的语言包和组件进行解耦,即组件不须要单独引入多语言包,同时组件的语言包也能够经过异步的方式去加载。
这样在咱们的预期范围内,可能会遇到以下几个问题:
首先在咱们小组内部,后编译(关于后编译能够戳我)应该是咱们技术栈的标配,所以咱们的组件库最终也是经过源码的形式直接发布,项目应用当中经过按需引入+后编译的方式进行使用。
项目应用的多语言包组织应该问题不大,通常放置于一个独立的目录(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>
复制代码
那么在不须要你手动引入语言包的状况下:
coupon-list
这个组件下的语言包?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 的流程是:
咱们看到在 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 字段。)
上述过程即解决了以前提出来的几个问题:
如今咱们经过 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,而后完成翻译的功能,若是没有找到,那么兜底使用项目应用对应字段的语言文案。
以上就是咱们对于近期所作的一些国际化项目的思考,总结一下就是:
事实上上面所作的工做都是为了更多的减小相关功能对于官方提供的插件的依赖,提供一种较为抹平技术栈的通用解决方案。