先来看最后的效果
问题
-
用过国际化
i18n
的朋友都知道,天下苦国际化久矣,尤其是中文为母语的开发者,在面对代码中一堆的$t('abc.def')
这种一点也不直观毫无可读性的代码,根本不知道自己写了啥
(如上图,你看得出来这是些啥吗) -
第二个问题就是i18n各种语言版本的语言包难以维护,随着项目变大这个语言包会越来越难以维护,能不能自动去维护呢
解决思路
所以我们前端组的小伙伴就想了个办法,能不能直接$t('中文')
呢,就像下图:
这样是不是就方便看了,但是问题依然有,使用中文做key可能会在打包时乱码或者在浏览器查看下乱码,总归就是直接使用中文不安全。
因此我们想出了一个万全之策:
- 针对以前做了国际化的项目,写node扫描一遍src目录,找出所有
$t('xxxx')
替换成$t('对应中文')
- 由于需要改的项目是vue2编写,所以写webpack插件做以下事:
- 在打包开始前扫描src目录,找到
$t('对应中文')
- 使用crc32将中文转为加密后的key,然后将
'key': '对应中文'
自动追加到语言包文件中,对应的语言包会长这样:
- 在打包结束后,扫描打包后的文件,将
$t('对应中文')
修改为$t(key)
,打包后的$t会长这样:
- 在打包开始前扫描src目录,找到
这样就不担心乱码问题了,而且可以自动维护语言包
将源码中的英文键替换成中文键
这一步之前没有写国际化的项目不用执行。
这一步只需要执行一次即可,因此不写进webpack插件中去,直接写nodejs脚本,具体步骤如下:
- 扫描src下所有文件夹,这个步骤需要用到递归,如果是文件夹就继续往下扫描,用正则表达式找出
$t('xxxx')
和i18n.t('xxxx')
这样的字符串 - 从之前的中文语言包中找出对应的中文并替换进源码
// replaceLang.js
const path = require('path')
const fs = require("fs");
let zhLang = require("./src/utils/languages/zh.js");
// 扫描文件的根路径
let gFilePath = resolve('/src')
// 需要扫描的文件
let gExtension = ['.js','.vue','.ts','.tsx','.jsx']
function resolve(dir){
return path.join(__dirname,dir)//path.join(__dirname)设置绝对路径
}
// 提取多级嵌套结构中的中文
function getValueByAttrs(attrs){
let str = '',langObj = zhLang;
attrs.forEach(item=>{
str = langObj[item]
if(typeof str == 'object'){
langObj = langObj[item]
}
})
return str
}
/**
* 替换文件夹下的英文键
*
* folderPath: 需要扫描替换的文件夹
* extension: 需要替换的文件后缀集合
*/
function replaceLangs(folderPath, extension){
// 读取文件夹下文件
const files = fs.readdirSync(folderPath,'utf8')
files.forEach((fileName) => {
const filePath = path.join(folderPath, fileName)
const stats = fs.statSync(filePath)
if(stats.isDirectory()) {
// 如果该目录是文件夹,继续往下扫描
this.replaceLangs(filePath,extension)
}else if(stats.isFile()) {
// 如果该目录是文件,进一步判断文件类型
if(extension.includes(path.extname(fileName).toLowerCase())) {
//读取文件内容
const fileContent = fs.readFileSync(filePath, 'utf8')
// 用正则表达式找出 `$t('xxxx')` 和 `i18n.t('xxxx')`这样的字符串
let results = fileContent.match(/\$t\((.+?)\)/g)||[]
let results2 = fileContent.match(/i18n.t\((.+?)\)/g)||[]
results.concat(results2).forEach(info=>{
let regex = /(?<=\()(.+?)(?=\))/g;
let attr = info.match(regex)[0]
try{
let attrs = eval(attr).split('.')||[];
// 从之前的语言包中获取对应的中文
let str = getValueByAttrs(attrs)
if(str){
if(info.includes('i18n.t')){
fileContent = fileContent.replace(info,"i18n.t('"+str+"')")
}else{
fileContent = fileContent.replace(info,"$t('"+str+"')")
}
}
}catch(e){
console.log(e)
}
})
// 更新文件
fs.writeFileSync(filePath, fileContent)
}
}
})
}
replaceLangs(gFilePath, gExtension)
webpack插件开发基础知识
可以参考插件开发文档
插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以在 webpack 构建流程中引入自定义的行为。
插件可以做些什么
可以在关键时间点执行一些逻辑,要改变输出,取决于我们可以获取到什么,以及对它做些什么修改操作,比如我们可以去除注释,去除空格,合并代码,压缩文件,提取公共代码,改变配置,修改,改变输出等。
webpack插件组成
webpack插件由一下组成:
- 一个
JavaScript命名函数
或JavaScript类
。 - 在插件函数的prototype上定义一个
apply
方法。 - 指定一个绑定到webpack自身的事件钩子。
- 处理webpack内部实例的特定数据。
- 功能完成后调用webpack提供的回调。
插件基本架构
插件是由「具有 apply 方法的 prototype 对象」所实例化出来的。这个 apply 方法在安装插件时,会被 webpack compiler 调用一次。apply 方法可以接收一个 webpack compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象。一个插件结构如下:
class HelloWorldPlugin {
apply(compiler) {
compiler.hooks.done.tap(
'Hello World Plugin',
(
stats /* 绑定 done 钩子后,stats 会作为参数传入。 */
) => {
console.log('Hello World!');
}
);
}
}
module.exports = HelloWorldPlugin;
然后,要安装这个插件,只需要在你的 webpack 配置的 plugin 数组中添加一个实例:
// webpack.config.js
var HelloWorldPlugin = require('hello-world');
module.exports = {
// ... 这里是其他配置 ...
plugins: [new HelloWorldPlugin({ options: true })],
};
Compiler
Compiler 负责编译,贯穿webpack的整个生命周期,Compiler 对象包含了当前运行Webpack的配置,包括entry、output、loaders等配置,这个对象在启动Webpack时被实例化,而且是全局唯一的。Plugin可以通过该对象获取到Webpack的配置信息进行处理。
常用钩子:
- beforeRun:
在开始执行一次构建之前调用,compiler.run 方法开始执行后立刻进行调用。 - watchRun:
在监听模式下,一个新的 compilation 触发之后,但在 compilation 实际开始之前执行。 - compilation:
compilation 创建之后执行。 - emit:
输出 asset 到 output 目录之前执行。 - done:
在 compilation 完成时执行。这个钩子 不会 被复制到子编译器。
Compilation
Compilation对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 Compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息,简单来讲就是把本次打包编译的内容存到内存里。Compilation 对象也提供了插件需要自定义功能的回调,以供插件做自定义处理时选择使用拓展。
简单来说,Compilation的职责就是构建模块和Chunk,并利用插件优化构建过程。
Compiler 和 Compilation 的区别
Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译,只要文件有改动,compilation就会被重新创建。
注意
有些插件钩子是异步的。我们可以像同步方式一样用 tap 方法来绑定,也可以用 tapAsync
或 tapPromise
这两个异步方法来绑定。
- 当我们用
tapAsync
方法来绑定插件时,必须
调用函数的最后一个参数callback
指定的回调函数。 - 当我们用
tapPromise
方法来绑定插件时,必须
返回一个pormise
,异步任务完成后resolve
。
Language插件开发
流程
- 在编译开始前扫描src目录下的所有文件,将
$t('中文')
字符串找到,将其通过crc32加密得到key
,并追加到语言包中 - 检测到文件变化时,重新执行步骤1,更新语言包
- 编译完成后,输出到dist目录前,将打包好的文件中的
$t('中文')
换成$t('key')
,再输出到目标目录
源码
//languagePlugin.js
const path = require('path')
const { crc32 } = require('crc')
const fs = require("fs");
// 扫描文件的根路径
let gFilePath = resolve('/src')
// 需要扫描的文件
let gExtension = ['.js','.vue','.ts','.tsx','.jsx']
function resolve(dir){
return path.join(__dirname,dir)//path.join(__dirname)设置绝对路径
}
class LanguagePlugin {
constructor(config) {
this.config = {
// 指定中文语言包
zh: resolve(config.zh),
// 需要生成的语言包,注意需要包含中文语言包
langs: config.langs.map(path => resolve(path))
}
// 中文语言包内容
this.zh = {}
// 所有语言包内容
this.keyFileList = []
// key引用计数,引用为0的key会被删除
this.keyUseNumber = {}
}
apply(compiler) {
// 编译开始前执行的钩子
compiler.hooks.run.tap('LanguagePluginRun',() => {
this.saveZhToCrc32JSON()
})
// 文件发生改变时执行的钩子
compiler.hooks.watchRun.tap('LanguagePluginWatch',() => {
this.saveZhToCrc32JSON()
})
compiler.hooks.emit.tapAsync('LanguagePlugin', (compilation, callback) => {
const now = Date.now()
const zh = this.zh
// 检索每个(构建输出的)chunk:
compilation.chunks.forEach(chunk => {
// 检索由 chunk 生成的每个资源(asset)文件名:
chunk.files.forEach(filename => {
// 文件类型是js才做检测和替换
var fileType = filename.split('.').pop()
if(fileType==='js' && compilation.assets[filename] && compilation.assets[filename].source) {
// 获取到源码
var source = compilation.assets[filename].source();
var newVal = source
var reg = /((i18n\.t)|(\$t))\((\\)*(\'|\")(.+?)(\\)*(\'|\")\)/g
// 替换源码
newVal = newVal.replace(reg, function(val) {
let str = val.replace(/((i18n\.t)|(\$t))\((\\)*(\'|\")/g,'').replace(/(\\)*(\'|\")\)/g,'').replace(/\"/g,'\"').replace(/\'/g,'\'')
let hashKey = crc32(str).toString(16)
if(zh[hashKey]) {
let ret = val.replace(str, hashKey)
return ret
}else{
return val
}
})
// 覆盖文件
compilation.assets[filename] = {
source: function () {
return newVal
},
size: function () {
return newVal.length
}
}
}
});
});
// 计时
console.log((Date.now() - now) / 1000)
callback();
});
}
saveZhToCrc32JSON() {
this.keyFileList = []
this.keyUseNumber = {}
// 判断几个XXkey.js文件存不存在,如果不存在就创建一个
this.config.langs.forEach(filePath => {
const { dir, base } = path.parse(filePath);
try {
fs.accessSync(dir, fs.constants.F_OK)
try {
fs.accessSync(filePath, fs.constants.F_OK)
} catch(err) {
console.log(filePath + '不存在,将为您自动创建')
fs.writeFileSync(filePath,"const lang = {\n}\nexport default lang")
}
} catch (err) {
console.log(dir + '不存在,将为您自动创建')
fs.mkdirSync(dir)
fs.writeFileSync(filePath,"const lang = {\n}\nexport default lang")
}
})
// 提取出langs文件夹下文件的内容
this.config.langs.forEach(filePath => {
let langFileContent = fs.readFileSync(filePath,'utf8')
// 提取{}之间的有效内容
langFileContent = langFileContent.match(/\{[\S\s]+\}/g)[0]
const obj = JSON.parse(langFileContent)
const origin = JSON.parse(langFileContent)
for(let key in obj) {
// 去掉首尾空格
obj[key] = obj[key].replace(/^\s*/g,'').replace(/\s*$/g,'')
}
this.keyFileList.push({
path: filePath,
val: obj,
origin: JSON.stringify(origin)
})
for(let key in obj) {
this.keyUseNumber[key] = 0
}
})
// 更新langs文件
this.updateLangsByFiles(gFilePath, gExtension)
this.keyFileList.forEach((keyFileItem) => {
// 删除没有使用到的key
for(let key in this.keyUseNumber) {
if(this.keyUseNumber[key] === 0) {
console.log("["+keyFileItem.val[key]+"]没有被使用,将为您自动删除")
delete keyFileItem.val[key]
}
}
// 语言包有改动才更新
if(JSON.stringify(keyFileItem.val) !== keyFileItem.origin) {
console.log("更改了文件"+keyFileItem.path)
let content = 'const lang = {'
for(let key in keyFileItem.val) {
content += '\n"' + key + '":"' + keyFileItem.val[key] + '",'
}
// 去掉最后一个逗号
content = content.substring(0, content.length - 1)
content += '\n}\nexport default lang'
fs.writeFileSync(keyFileItem.path, content)
}
if(this.config.zh === keyFileItem.path) {
this.zh = keyFileItem.val
}
})
}
/*
扫描文件更新语言包
*/
updateLangsByFiles(folderPath, extension) {
// 读取文件夹下的内容
const files = fs.readdirSync(folderPath,'utf8')
files.forEach((fileName) => {
const filePath = path.join(folderPath, fileName)
const stats = fs.statSync(filePath)
// 判断是文件夹还是文件
if(stats.isDirectory()) {
this.updateLangsByFiles(filePath,extension)
}else if(stats.isFile()) {
if(extension.includes(path.extname(fileName).toLowerCase())) {
const fileContent = fs.readFileSync(filePath, 'utf8')
let results = fileContent.match(/((i18n\.t)|(\$t))\((\'|\")(.+?)(\'|\")\)/g)
if(results) {
results.forEach(result => {
// 获取中文并且获取crc
let str = result.replace(/((i18n\.t)|(\$t))\((\'|\")/g,'').replace(/(\'|\")\)/g,'').replace(/\"/g,'\\"').replace(/\'/g,"\\'")
let hashKey = crc32(str).toString(16)
// 更新语言包数据以及计数数据
this.keyFileList.forEach((keyFileItem) => {
const obj = keyFileItem.val
if(!obj[hashKey]) {
obj[hashKey] = str
this.keyUseNumber[hashKey] = 0
}
this.keyUseNumber[hashKey]++
})
})
}
}
}
})
}
}
module.exports = LanguagePlugin;
插件使用
- 项目的根目录下添加
languagePlugin.js
- package.json 的 devDependencies 加上 “crc”:“^4.3.2”,运行npm install安装crc依赖
- vue.config.js中使用该插件
configureWebpack(config) {
return {
plugins: [
new LanguagePlugin({
zh: '/src/langs/zhKey.js',
langs: [
// 这里有中文版,中文繁体版,英文版语音包
'/src/langs/zhKey.js',
'/src/langs/zhTWKey.js',
'/src/langs/enKey.js'
]
})
]
}
}
- 修改国际化i18n插件引入语言包的路径
- 运行npm run dev,能看到语言包自动更新,页面效果正常。
- 运行npm run build正常打包。
后记
这个插件其实不难,就是我的正则表达式水平不太行,后面我可能会专门花时间去学习正则表达式。
webpack的各种钩子我理解的不是很深刻,目前这个代码里的钩子都是我一个一个试出来的。
关于这个插件我其实还有一些想法想实现,比如可以调用AI翻译的API自动翻译出来对应的语言包,国际化从此以后完全傻瓜式啦。
还可以写个vite版本的插件,不过这是以后的事情啦。。。公司的国际化改造告一段落,撒花~