自定义webpack loader和babel plugin,替换国际化Key,减小打包体积

244 阅读5分钟

我正在参加「掘金·启航计划」

前言

上篇文章 使用Top-level await新特性,按需加载国际化资源文件,提高首屏效率 介绍了如何通过Top-level await特性,拆分国际化资源文件,达到按需加载,减小了浏览器请求js size体积。

在实际项目中,还会存在另一种情况,也会导致打包后的js sizet体积过大。

比如国际化资源文件是以json格式储存,如果项目比较大,词条量就会很大,然后业务开发时,定义的I18N Key也不规范,会定义的很长很长,这些Key在打包后,都会以字符串格式打包到js里,就会导致打完包的js里有大量国际化Key格式的字符串,导致js很大,从而影响首屏效率。

// i18n/en/a.en.json
{
    "I18!N_A_Key1": "value1",
    "I18N_A_Key2": "value2",
    "I18N_A_Key3": "value3"
}
// i18n/en/b.en.json
{
    "I18N_B_Key1": "en b value1",
    "I18N_B_Key2": "en b value2",
    "I18N_B_Key3": "en b value3",
    // 很多Key会定义的很长
    "I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key0": "i18n en value 0",
    "I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key_I18N_B_Key1": "i18n en value 1",
    ",
    ...数量也会很大
}

打包后会以json字符串形式编译在js里。

image.png

思路

想法是,在打包时通过一些命令,在编译阶段,把I18N key值转换成较短的字符串,比如a.en.json里替换为a{自增序号int}这种,保证了唯一性,字符串长度也会很短。

解决方案

编译js\jsx

打包用的是Webpack,先研究下Webpack是怎么编译文件的,用到了各种loader以及babel,编译js和jsx用的是babel-loader,这里就可以考虑写一个自定义的Babel plugin插件,通过解析AST,来转义词条Key字符串。

Babel 插件手册:github.com/jamiebuilds…

{
    test: /\.(jsx|js)$/,
    exclude: /(node_modules|bower_components)/,
    use: 'babel-loader',
}

下面是一个简单plugin例子,把所有js\jsx文件中的所有字符串,都替换为new key,所以这里需要加些判断,判断是I18N Key才需要转换。

// babel.config.js
module.exports = {
    "presets": [
        "@babel/preset-env",
        [
            "@babel/preset-react", { "runtime": "automatic" }
        ]
    ],
    "plugins": [
        ["./my-babel-plugin.js"]
    ],
};
// my-babel-plugin.js
module.exports = function () {
    return {
        visitor: {
            StringLiteral(path) {
                // 把所有js\jsx文件中的所有字符串,都替换为'new key'
                path.node.value = 'new key';
            }
        },
    };
}

编译json

尝试把json也用上babel-loader,但是不支持,babel只支持js系列文件的转换,webpack内部已经内置了json-loader,不需要配置,所以得自己写一个loader来转换json格式文件。

Webpack官方自定义loader教程:webpack.docschina.org/contribute/…

尝试写了个例子测试了下,可以达到转换效果:

// webpack.config.js
module: {
    rules: [
        ...
        {
            test: /\.(json)$/,
            exclude: /(node_modules|bower_components)/,
            use: {
                loader: path.resolve(__dirname, "./my-loader.js"),
            }
        },
// my-loader.js
module.exports = function (source) {
    const json = typeof source === "string" ? JSON.parse(source) : source;
    const result = {};
    Object.entries(json).forEach(([k, v], i) => {
        result['newkey' + i] = v;
    });
    return JSON.stringify(result);
};

上面例子就可以把json文件的对象属性名,都改成了newkey+{index}格式了,从打包后的js里可以看出来。

image.png

实践

解决方案和技术方向已经研究好了,就可以开始实践了。

1. 加node脚本,把新旧Key的mapping关系确定好了

写一个node程序 scan-i18n-map.js,用 fast-glob 读取i18n json文件,然后用文件名+序号作为新key,最后把新旧Key mapping输出到i18n.map.json文件里。

// scan-i18n-map.js
const fg = require('fast-glob');
const fs = require('fs');

const map = {};
// 读取所有en词条文件
const files = fg.sync('./src/i18n/en/*.en.json');
for (const file of files) {
    const path = file; // 文件路径
    const name = path.split('/').at(-1).split('.').at(0); // 取文件前缀,比如a.en.json取到的是a
    const content = fs.readFileSync(file, 'utf-8'); // 读取内容
    const json = JSON.parse(content); // 转成json对象
    Object.entries(json).forEach(([k, v], i) => {
        let key = name + i;
        map[k] = key; // 把新旧key关系存到map上
    });
};
// 最后写到json文件里
fs.writeFileSync('./i18n.map.json', JSON.stringify(map, null, 2), { encoding: 'utf8' }, (err) => {
    if (err) throw err;
});

执行node scan-i18n-map.js,生成i18n.map.json文件:

image.png

2. 自定义webpack loader,编译i18n json文件,替换Key

重新自定义一个webpack loader,文件名叫i18n-loader.js,然后里面读取上一步生成的i18n.map.json,然后进行替换。

// i18n-loader.js
let map = null
// 只在prod build生效
if (process.env.NODE_ENV === "production") {
    map = require('./i18n.map.json'); // 读取新旧key mapping关系
}

module.exports = function (source) {
    if (!map) return source;
    const json = typeof source === "string" ? JSON.parse(source) : source;
    const result = {}; // 替换后的json
    Object.entries(json).forEach(([k, v]) => {
        let key = map[k]; // 新key
        if (key) {
            // 取到对应的新key,替换
            result[key] = v;
        }
    });
    return JSON.stringify(result); // 最后输出新json string
};

3. 自定义babel plugin,编译js\jsx文件,替换Key string

重新自定义一个babel plugin,文件名叫i18n-babel-plugin.js,然后依然是读取mapping json,然后判断js\jsx里所有字符串是否完全匹配旧key,再替换成新key。

// i18n-babel-plugin.js
let map = null
// 只在prod build生效
if (process.env.NODE_ENV === "production") {
    map = require('./i18n.map.json'); // 读取新旧key mapping关系
}

module.exports = function () {
    if (!map) return {};
    return {
        visitor: {
            // 解析字符串语法
            StringLiteral(path) {
                // 取到所有字符串
                const value = path.node.value;
                // 判断字符串是否是mapping里的旧key
                const key = map[value];
                if (key) {
                    // 取到对应的新key,替换
                    path.node.value = key;
                }
            }
        },
    };
}

4. 最后整合,执行

  1. 把生成mapping关系的node脚本scan-i18n-map.js添加到package.json scripts pre 指令里,这样每次执行npm run prod时会自动先执行脚本,生成mapping json文件。
// package.json
"scripts": {
    "preprod": "node scan-i18n-map.js",
    "prod": "cross-env NODE_ENV=production webpack --config webpack.config.js"
  },
  1. 把webpack loader i18n-loader.js,加到webpack.config.js里。
// webpack.config.js
    test: /\.(json)$/,
    exclude: /(node_modules|bower_components)/,
    use: {
        loader: path.resolve(__dirname, "./i18n-loader.js"),
    }
  1. 把babel plugin i18n-babel-plugin.js加到babel.config.js里。
// babel.config.js
"plugins": [
    ["./i18n-babel-plugin.js"]
],

执行npm run prod后查看打包文件:

image.png

image.png

可以看到国际化json、业务jsx编译后的文件里,i18n key都被替换了,

这里用到了webpack拆包,所以是两个js,感兴趣可以参考之前的文章:使用Top-level await新特性,按需加载国际化资源文件,提高首屏效率

对比了下前后文件size,肉眼可见提升。测试例子放了100个左右比较长的Key,如果真实项目,词条量多了就更可观了。

image.png

缺点及优化

也存在一些缺点,比如要求业务使用get词条时,词条key必须已完整字符串定义,不能有字符拼接。

也可以看看市面上比较成熟的国际化解决方案,比如 i18next formatjs,找找有没有类似需求的功能或扩展。

总结

本文介绍了如何通过自定义webpack loader以及babel plugin,实现自定义文件打包编译逻辑,用来替换国际化Key字符串,最终达到减小打包文件体积,提高首屏加载效率。

以后如果遇到类似的webpack打包编译需求,也可以参考这个路子来,做一些简单的替换字符串逻辑,还是很简单的。