前言
这是一个关于 reduce-enum-webpack-plugin 由来的故事,我已经把它放到 github 和发到 npm 了,有需要的可以看下,觉得能帮到你的话也请给个 star~
我们的接口是通过 proto 转为 ts (不知道 proto 是什么的可以看下这篇文章),目的是为了更好的类型提示,比如字段类型、后端字段注释,enum 等等,而不是后端定义一套数据结构,前端又得重复定义。
生成 api 后,我们就可以直接 import 对应的接口名,接口有对应的 RequestType 和 ResponseType,大大提升了开发体验。
例子如下:
但直到在小程序中使用后,发现打包出来的主包实在太大了,所以用webpack-bundle-analyzer
分析下问题究竟出在哪里,想必大家都知道,问题肯定是出在这个 api 的包了。是的,没错,深入定位后发现具体问题是出现在生成的 enum
。
比如有些模块目录下,一个 enum Status_code 就大概 140 行,而更恐怖的是一个 enum Permission 就达到 1600 行!!
但 api 项目中可不只一个 enum 呀,每个模块目录下都有大大小小的 num。
这对于 web 项目来说可能影响不是特别的大,但对于体积限制寸土寸金的小程序来说,就成为了限制项目上线的阻碍了。(毕竟超过 2m 就没法上传了。。。)
而大家都知道,ts 转 js 的过程中,enum 的转化过程如下:
点击链接可看到
也就是说,enum 最终是转化为一个对象,对象结构类似如下:
var Status = {
PAID: 0,
UN_PAID: 1,
0: 'PAID',
1: 'UN_PAID',
}
先别急
疑点一:webpack 不是能 tree-shaking 吗?
是的,我一开始也是这么想,难道 shaking 失败了?
大家上面也看到 ts enum 转 js 是一个 IIFE 的过程,是有副作用的,这种情况下 webpack 是没法对其 tree-shaking 的,用大白话就是,这些枯枝烂叶看起来摇摇欲坠,但是无论你怎么摇晃,它们就是死活不掉。
疑点二:你难道不会用 const enum 吗?
好,那我就用用呗,大家能看到,用了 const enum 后,转 js 的过程突然变得如此的简洁!!
哇,看起来很有希望,赶紧全部都声明为 const enum!!
然后,我开始打包,结果发现:
const enum
不支持
因为上面的 const enum 只限于当前文件使用,也就是说,你在单文件里使用,是有如下效果的
if (code === Status.PAID) { /*...*/ }
if (code === 0 /* Status.PAID */) { /*...*/ }
但如果你是 export 给其他地方使用,babel 就没法处理了哇
疑点三:@babel/preset-typescript v7.15.0 不是有个 optimizeConstEnums 吗
大致作用就是将 babel 编译为一个对象,比如:
enum Status {
PAID
}
// =>
var Status = {
PAID: 0
}
// 而不是
var Status = {
PAID: 0,
0: 'PAID'
}
但我配置后,发现还是不起作用(可能我哪里弄错了?)
// babel.config.js
module.exports = () => {
return {
presets: [
'xxx',
[
'@babel/preset-typescript',
{
optimizeConstEnums: true,
},
],
],
}
}
ps,注意 preset 的顺序是从后到前
既然不起作用,那我们自己实现就好吧~(麻烦知道问题原因的大佬点拨下,万分感谢~)
分析下打包后的产物
发现结果都是类似 e[e["xxx"]=0]="xxx"
,那看起来不难,我们让其最终只生成e["xxx"]=0
不就好?
动手,就是现在,就在这里!!
看到上面的产物,那我们可以写个 webpack plugin,在 asset 输出之前,对每个 js asset 做下处理。
简单了解下 plugin
首先我们要知道,plugin 可以监听 webpack 打包过程中的各个节点,从而做出相应的逻辑,优化并拆分 bundle。
而 webpack 的生命周期钩子有这么多
一个最简单的 plugin 例子如下:
class MyPlugin {
// 每个plugin上面必须有apply方法,接收一个compiler实例
apply(compiler) {
// 通过compiler上的不同hook的生命周期节点,监听对应的逻辑
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
})
}
}
在 emit 阶段处理
根据官网可知,emit 阶段是输出 asset 到 output 目录之前执行,那我们在输出之前,对 enum 产物做下提前处理。
自然而然想到判断产物中如果有对应的e[e["xxx"]=0]="xxx"
格式的话,用正则提取中间的e["xxx"]=0
。
那如何写这个正则?
我们观察下,上面结构有两个重复的,即'e'、'xxx',那看起来我们可以用正则的反向引用来实现,这里先跟大家介绍下什么是反向引用。
分支任务,什么是反向引用
举个 🌰
比如我要匹配到上面的 e,而 e 是属于 \w
,那我可以这么写:
/(\w)\[\1/
上面的\1
就表示引用匹配的()
中的第一个分组
而其中的 xxx 也是同样道理,我们照猫画虎,得到的正则长这样:
/(\w)\[(\1\["(\w*?)"\]=\d+)\]="\3"/g
- 其中(\w)表示第一个字母,如 e
- \1 表示引用(\w)匹配到的字母,也就是必须严格如
e[e
,而不能e[w
- (\1["(\w*?)"]=\d+)是第二个分组,也就是第二个括号,也正是我们要提取的,所以结果会返回
$2
,然后其中的(\w*?)
是第三个括号 - 所以后面的
\3
就代表引用第三个括号匹配的内容 - 而
\d+
表示多个数字,因为可能是 10,300,而不都只是 0、1、2... - 结尾 g 表示匹配全部,而不是只替换一个,因为有很多个
这里大家可以先用回调函数的写法,看看回调函数长啥样,上面的正则不是一蹴而就写成了,是经过很多次调试才得到的结果(本来想问 chatgpt的,但回答结果总是错误。。,所以只能自己动手)
那最终得到的结果是:
newContent = content.replace(/(\w)\[(\1\["(\w*?)"\]=\d+)\]="\3"/g, '$2')
那我们写下 plugin:
class RuduceEnumWebpackPlugin {
// 每个plugin实例必须有apply方法,接收compiler实例
apply(compiler) {
// compilation => 此次打包的上下文
compiler.hooks.emit.tap(RuduceEnumWebpackPlugin.name, (compilation) => {
// 遍历资源文息,其中assetName为每个资源的名称
for (const assetName in compilation.assets) {
if (assetName.endsWith('.js')) {
// 每个资源的值
const content = compilation.assets[assetName].source()
const newContent = content.replace(/(\w)\[(\1\["(\w*?)"\]=\d+)\]="\3"/g, '$2')
// 覆盖原始内容
compilation.assets[assetName] = {
source: () => newContent,
size: () => newContent.length,
}
}
}
})
}
}
在项目中使用
小试牛刀
我们在 webpack 配置中:
plugins: [
...,
new RuduceEnumWebpackPlugin()
]
看看打包后的产物长啥样:
看起来很符合预期
问题往往没那么简单
1.e[e"xxx"=1e3]="xxx"
如果 enum 是:
enum Status {
xxx = 1000,
}
那它转换后的结果不是e[e"xxx"=1000]="xxx"
,而是e[e"xxx"=1e3]="xxx"
,且 e 前面不只有一个数字,有可能是 1024e3
我们可看到 998,999 是没问题的,但到了 1000 就变成 1e3 了
还有个问题,数字可能是负数,所以要加上-?
。
那这个容易,修改下正则就好了
replace(/(\w)\[(\1\[("\w*")?\]=(-?\d+|\d+e\d))\]=\3/g, '$2')
- -?\d+:是匹配 1、10、-1、-10
- \d+e\d:是匹配 1e3、 123e3 测试下:
如果是 e[e.xxx=0]="xxx"呢?
你还别说,真有这种可能,我用了uglifyjs-webpack-plugin
后,产物长这样了:
区别是通过(\w).xxx=123,那我们继续修正:
// 之前最新得到的正则:
/(\w)\[(\1\[("\w*")?\]=(-?\d+|\d+e\d))\]=\3/g
// 修改e['xxx'] => e.xxx,得到
/(\w)\[(\1\.(\w+)=(-?\d+|\d+e\d))\]=\3/g
// 后面的\3要加上"",也就是"\3"
/(\w)\[(\1\.(\w+)=(-?\d+|\d+e\d))\]="\3"/g
测试下结果:
成功将e[e.W=10]="W"
变为e.W=10
那么也就是:
const newContent = contents
.replace(/(\w)\[(\1\.(\w+)=(-?\d+|\d+e\d))\]="\3"/g, '$2')
.replace(/(\w)\[(\1\[("\w*")?\]=(-?\d+|\d+e\d))\]=\3/g, '$2')
这里可以把两个正则合成一个,但为了看起来清晰一点,就把它拆开了,大家可以尝试下怎么合起来
看下效果:
如果是 e[e.xxx]="xxx"呢?
可能有些人会问,如果是上面这种情况呢?我们看下
对应链接
这种情况实际上不用处理
整理封装下 plugin
经过上面的一步步试错和推导,我们得到了最终的 plugin,如下:
class RuduceEnumWebpackPlugin {
// 每个plugin实例必须有apply方法,接收compiler实例
apply(compiler) {
// compilation => 此次打包的上下文
compiler.hooks.emit.tap('MyPlugin', (compilation) => {
// 遍历资源文息,其中assetName为每个资源的名称,value为文件内容
for (const assetName in compilation.assets) {
if (assetName.endsWith('.js')) {
// 每个资源的值
const content = compilation.assets[assetName].source()
const newContent = content
.replace(/(\w)\[(\1\.(\w+)=(-?\d+|\d+e\d))\]="\3"/g, '$2')
.replace(/(\w)\[(\1\[("\w*")?\]=(-?\d+|\d+e\d))\]=\3/g, '$2')
// 覆盖原始对象
compilation.assets[assetName] = {
ource: () => newContent,
size: () => newContent.length,
}
}
}
})
}
}
特别注意!! 使用该plugin的前提是只通过enum.key 访问,而不要使用enum.value获取key值,也就是说,这种是不允许的:
enum Status {
PAID
}
// ❌、
Status[0]
请遵循这种用法:
// ✅
Status.PAID
看看成果吧
先看下打包前的:
在对比下打包后的:
足足减少了 120k!!
总结
以上就是优化 ts enum 产物带来的问题,我们来总结一下:
enum 由于转 js 是个 IIFE 的过程,是有副作用的,这种情况下 webpack 是没法对其 tree-shaking 的。
所以我们转而尝试 const enum,但 const enum 只限于当前文件使用,babel 没法跨文件处理。
然后又尝试了@babel/preset-typescript v7.15.0 的 optimizeConstEnums,但是发现不起作用,还是报'const' enums are not supported.(可能我哪里没配好,或者其他地方影响到)
然后我们看了下 enum 的产物,大概有两种样子:
- e[e["xxx"]=0]="xxx"
- e[e.xxx=0]="xxx"
所以我们打算写个 plugin 来处理产物,过程是在输出 asset 到 output 目录之前执行,然后我们介绍了 webpack 的生命周期,怎么去写一个 plugin,并通过正则匹配处理了产物内容,使之最终得到类似 e["xxx"]=0 或 e.xxx=0。在这过程中大家也学到了什么是反向引用。
多提一嘴,我们是不是还可以将 e["xxx"]=0 统一处理成 e.xxx=0 ?毕竟前者比后者多了三个字符呢!,如果数量级达到 10w 以上,那就是 30w 字符的区别了
你看看,我都统一转e.xxx后,有从227.53 -> 216.29,又减少了 11.24k 呢。
只需要将第一个正则回调改为这样即可
const newContent = content
.replace(/(\w)\[(\1\.(\w+)=(-?\d+|\d+e\d))\]="\3"/g, '$2')
.replace(/(\w)\[(\1\[("(\w*)")?\]=(-?\d+|\d+e\d))\]=\3/g, (_, __, $2) => {
return $2.replace(/\["(.*?)"\]/, (_, $1) => `.${$1}`)
})
也就是将得到的e["xxx"]=0'
中的["
替换为.
,再去掉 "]
遗留问题
以上问题是解决了一半,因为没使用到的enum产物还是打包进去了,之后再想想其他办法,将没用到的enum全部干掉。
当然,归根结底还是在于小程序体积的限制,你说是不是?哈哈,不限制就没有这些七七八八的问题了~~
下次再见
好了,以上就是分享如何通过骚操作将小程序主包降低 120k 的故事,折腾了我一晚上,主要在于正则不太熟,一直在试错,最后搞完差不多晚上 10 点了,饿得要死,赶紧去吃个螺蛳粉回回血~
就是为了这炸蛋,我才写的这文章