随手写了个plugin,就将小程序体积减少了120k

4,501 阅读8分钟

前言

这是一个关于 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不就好?

2023-03-05 14.02.36.gif

动手,就是现在,就在这里!!

看到上面的产物,那我们可以写个 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 呢。

image.png

只需要将第一个正则回调改为这样即可

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'中的["替换为.,再去掉 "]

image.png

遗留问题

以上问题是解决了一半,因为没使用到的enum产物还是打包进去了,之后再想想其他办法,将没用到的enum全部干掉。

当然,归根结底还是在于小程序体积的限制,你说是不是?哈哈,不限制就没有这些七七八八的问题了~~

下次再见

好了,以上就是分享如何通过骚操作将小程序主包降低 120k 的故事,折腾了我一晚上,主要在于正则不太熟,一直在试错,最后搞完差不多晚上 10 点了,饿得要死,赶紧去吃个螺蛳粉回回血~

就是为了这炸蛋,我才写的这文章