随手写了个plugin,就将小程序体积减少了120k
前言
这是一个关于 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 {
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 = 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[ http:// 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 {
// ❌、
Status[0]
请遵循这种用法:
// ✅
Status.PAID