解决i18n国际化可读性问题,傻瓜式webpack中文支持国际化插件开发

先来看最后的效果

问题

  1. 用过国际化i18n的朋友都知道,天下苦国际化久矣,尤其是中文为母语的开发者,在面对代码中一堆的$t('abc.def')这种一点也不直观毫无可读性的代码,根本不知道自己写了啥

    (如上图,你看得出来这是些啥吗)

  2. 第二个问题就是i18n各种语言版本的语言包难以维护,随着项目变大这个语言包会越来越难以维护,能不能自动去维护呢

解决思路

所以我们前端组的小伙伴就想了个办法,能不能直接$t('中文')呢,就像下图:

这样是不是就方便看了,但是问题依然有,使用中文做key可能会在打包时乱码或者在浏览器查看下乱码,总归就是直接使用中文不安全。

因此我们想出了一个万全之策

  1. 针对以前做了国际化的项目,写node扫描一遍src目录,找出所有$t('xxxx')替换成$t('对应中文')
  2. 由于需要改的项目是vue2编写,所以写webpack插件做以下事:
    • 在打包开始前扫描src目录,找到 $t('对应中文')
    • 使用crc32将中文转为加密后的key,然后将'key': '对应中文'自动追加到语言包文件中,对应的语言包会长这样:
    • 在打包结束后,扫描打包后的文件,将$t('对应中文')修改为$t(key),打包后的$t会长这样:

这样就不担心乱码问题了,而且可以自动维护语言包

将源码中的英文键替换成中文键

这一步之前没有写国际化的项目不用执行。

这一步只需要执行一次即可,因此不写进webpack插件中去,直接写nodejs脚本,具体步骤如下:

  1. 扫描src下所有文件夹,这个步骤需要用到递归,如果是文件夹就继续往下扫描,用正则表达式找出 $t('xxxx')i18n.t('xxxx')这样的字符串
  2. 从之前的中文语言包中找出对应的中文并替换进源码
js 复制代码
// 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 对象。一个插件结构如下:

js 复制代码
class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap(
      'Hello World Plugin',
      (
        stats /* 绑定 done 钩子后,stats 会作为参数传入。 */
      ) => {
        console.log('Hello World!');
      }
    );
  }
}

module.exports = HelloWorldPlugin;

然后,要安装这个插件,只需要在你的 webpack 配置的 plugin 数组中添加一个实例:

js 复制代码
// 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 方法来绑定,也可以用 tapAsynctapPromise 这两个异步方法来绑定。

  • 当我们用 tapAsync 方法来绑定插件时,必须 调用函数的最后一个参数 callback 指定的回调函数。
  • 当我们用 tapPromise 方法来绑定插件时,必须 返回一个 pormise ,异步任务完成后 resolve

Language插件开发

流程

  1. 在编译开始前扫描src目录下的所有文件,将 $t('中文') 字符串找到,将其通过crc32加密得到key,并追加到语言包中
  2. 检测到文件变化时,重新执行步骤1,更新语言包
  3. 编译完成后,输出到dist目录前,将打包好的文件中的 $t('中文') 换成 $t('key'),再输出到目标目录

源码

js 复制代码
//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;

插件使用

  1. 项目的根目录下添加languagePlugin.js
  2. package.json 的 devDependencies 加上 "crc":"^4.3.2",运行npm install安装crc依赖
  3. vue.config.js中使用该插件
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'
            ]
          })
        ]
    }
}
  1. 修改国际化i18n插件引入语言包的路径
  2. 运行npm run dev,能看到语言包自动更新,页面效果正常。
  3. 运行npm run build正常打包。

后记

这个插件其实不难,就是我的正则表达式水平不太行,后面我可能会专门花时间去学习正则表达式。

webpack的各种钩子我理解的不是很深刻,目前这个代码里的钩子都是我一个一个试出来的。

关于这个插件我其实还有一些想法想实现,比如可以调用AI翻译的API自动翻译出来对应的语言包,国际化从此以后完全傻瓜式啦。

还可以写个vite版本的插件,不过这是以后的事情啦。。。公司的国际化改造告一段落,撒花~

相关推荐
崔庆才丨静觅35 分钟前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60611 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了1 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅2 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅2 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax