前言
之前看过我文章随手写了个 plugin,就将小程序体积减少了 120k的小伙伴(没看过的也可以了解下前因后果,说不定以后碰到相同的问题,就能按这种方式解决),肯定知道我们公司的 api 项目因为里面有大量 enum,导致小程序打包体积接近最大限制 2M,大部分原因就是因为 enum 转 js 是个 IIFE 的过程,是有副作用的,这种情况下 webpack 无法对其 tree-shaking。
为了降低 enum 带来的体积增大的影响,我就写了个 webpack plugin reduce-enum-webpack-plugin,将 enum 的体积降低了一半,然而实际上还是有遗留问题,也就是另一半没用到的体积也打包进去了,这个我在上篇文章结尾也有提到:
然后在写完这个 plugin 之后的下一周,实际上我就想到用另一种方法来尝试解决,即通过一个 babel plugin 来实现,具体理由如下:
- 比起用正则匹配产物,babel plugin 可以遍历 AST,能准确地识别到 enum,具体是
TSEnumDeclaration
- 既然上面我们说到,enum 转 js 是个 IIFE 的过程,那我不让其变成 IIFE,而是转换为一个常量对象不就好?
这样的话,就将 enum 的产物从有副作用变成无副作用了,也就能满足 tree shaking 了!!
所以,基于上面的想法,我就开始着手实现这个 plugin 了,而且既然目的是将 enum 转 object,所以我将其命名为babel-plugin-enum-to-object,插件已经开源在 github 了,如果各位大佬觉得还不错的话,还请给个 star✨ 支持下~
好了,废话不多说,接下来会从这个插件的用法、效果以及如何实现来展开。
如何使用
首先我们先安装下:
pnpm add babel-plugin-enum-to-object -D
# or
yarn add babel-plugin-enum-to-object -D
# or
npm i babel-plugin-enum-to-object -D
然后在 babel.config.js 或者 .babelrc 里面添加:
// babel.config.js
module.exports = (api) => {
return {
plugins: [
['enum-to-object', {
reflect: true // 默认值 代表需要反射值
// reflect: false // 代表不需要反射值
}]
]
}
}
⚠️ 注意:
- babel 插件的执行顺序是从左往右,或者说从上到下,所以请务必在 ts 插件处理之前使用该插件,否则 ts 都已经被转译了,就再也没法遍历到 TSEnumDeclaration 了
- 该插件只有一个参数
reflect
,默认值为 true,目的是为了保持跟原本 enum 产物一致,如果你不需要反射值(个人觉得绝大部分人都不需要,所以建议关闭该功能),请设置 reflect 为 false
使用效果
说完了配置,我们赶紧来看下前后对比的效果:
添加插件之前,总包是 3.07M,主包是 1.96M,vendors 是 689K
添加插件后,总包是 2.79M,主包是 1.72M,vendors 是 442K
整整降低了 286K,其中主包降低了 240K!!
然后做下对比,写个测试 enum,同文件里面还 export 了一个常量 a:
在入口 app.tsx 里面引入,但只使用到 a:
我们对比下使用插件前后的效果:
1.使用前,由于上面所说的 enum 转 js 是有副作用的,所以虽然没使用到,但还是会打包到产物里:
2.使用后,由于没有副作用,所以能 shaking 掉,我们看下,确实搜不到对应的值了:
接着,再做下测试,即该 enum 被使用到了,如打印其任意值
看下效果:
看看,即使你用到了 enum,现在打包也只会将用到的值转换为常量,而不是把所有产物打包进去!!这样就很舒服了哇~
讲下如何实现吧
介绍完用法和效果,接下来我们讲下如何实现该插件。
实际上这个插件很简单,上面已经说过了,我们把 enum 转为 object,那就是判断到是 TSEnumDeclaration 类型的,对其进行一些处理即可。
一个 babel 插件怎么写
但为照顾一些小伙伴,我们还是先讲下 babel plugin 的大致框架。
module.exports = (api: BabelAPI, options: O, dirname: string) => {
return {
name: '你的插件名', // 如上面是babel-plugin-enum-to-object, 那么这里的name就可以写成babel-plugin-enum-to-object,不用写签名的babel-plugin
visitor: {
// 这里根据你要处理的各种结构进行处理
}
}
}
可以看到,其返回一个函数,接收了三个参数,一般我们会用到 api 和 options。
第一个参数 api
其中 api 的类型是:
也就是说,@babel/core
上的所有方法你都可以使用,免去了自己手动 import 的过程,当然还有个babel.ConfigAPI
,我们一般比较常用的就是assertVersion
,用来声明需要使用的 babel 版本,这里我们用的是 babel7,所以在入口处会通过以下代码声明:
module.exports = (api) => {
api.assertVersion(7)
...
}
第二个参数 options
options 也就是你传给插件的配置了,比如babel-plugin-enum-to-object
支持传参reflect
,那么你在babel.config.js
里面给插件传参的时候,通过 options 就能拿到了
// babel-plugin-enum-to-object.js
module.exports = (api, options) => {
const { reflect = true } = options
}
// babel.config.js
module.exports = {
plugins: [
['enum-to-object', { reflect: true }]
]
}
@babel/helper-plugin-utils declare 提升开发体验
同时,为了更好的开发体验,我们可以借助@babel/helper-plugin-utils
的declare
给插件提供类型支持,这样开发就方便许多了
import { declare } from '@babel/helper-plugin-utils'
module.exports = declare((api, options) => {
...
})
visitor 才是重点
visitor 实际上是访问者模式,我们对 ast 进行增删改,实际上就是在 visitor 上面对各种类型结构进行操作,具体有什么类型可以看下babel 手册。(反正我是只有在使用到才去查)
astexplorer 神器
当然,为了可视化 ast,这里非常有必要给大家介绍两个网站,第一个是astexplorer,可以一边查看代码的 ast 结构,一边写 plugin,同时能输出转换后的结果:
也可以通过配置你需要添加的额外插件
分析 enum 的结构
然后我们通过 astexplorer 来分析 enum 的 ast 究竟长啥样,可以看到,enum 名是 Identifier 上的 name,成员在 members 上,每个 member 的类型是TSEnumMember
,其中 id 为 key,initializer 为 value,且不一定有 initializer(也就是说没有默认值)
所以,我们实际上需要重点处理的也就是每一个 TSEnumMember 的 id 和 initializer,而 id 的情况比较简单,就只有StringLiteral
和 Identifier
两种
而 enum 值的情况比较多,所以下面我们重点讲下
enum value 的情况
- 第一种,都没初始值,那 enum 会自动从 0 累加
- 第二种,值都为字符串
- 第三种情况也是最麻烦的,就是 initializer 可能是空的,也就是没默认值,也可能是 NumericLiteral,也可能是 StringLiteral
如上图,A 没有初始值,所以默认为 0,而之后是 B,有初始值'B',之后是 C,初始值是 4,number 类型,最后是 D,其没有初始值,但由于前一个是 C,所以自动推导出 D 的值是 5。
那有小伙伴可能要问,如果前一项值为字符串,那下一项的initializer能为空吗?
答案是不行的,因为 initializer 为空肯定就要自动推导,而自动推导只发生在第一项或上一个值是 number 类型,所以我们可以用个变量 preVal,初始值为-1,然后遍历每一个 member:
- 如果 initializer 为空,就优先将 preVal 加一(所以上面初始值才为-1),然后生成一个 initializer
- 如果 initializer 为 NumericLiteral,则将值赋值给 preVal,以便遍历到下一个为空时则借助上次值计算本次的值
实现 babel-plugin-enum-to-object
上面我们说过,enum 的类型是 TSEnumDeclaration,所以 visitor 里面我们就判断到是 TSEnumDeclaration,则进入对应的 ast,然后要替换成对象,而对象的结构是 VariableDeclarator
然后我们可以通过 path.node.id 拿到该 enum 的名字:
也就是说生成对象的名字要跟 enum 名字相同,之后的工作就是遍历 members 并处理其 key 和 value
这里给出整体的代码,可以先快速过一遍,后面我们再一一分析:
import { declare } from '@babel/helper-plugin-utils'
// @ts-ignore
import syntaxTypeScript from '@babel/plugin-syntax-typescript'
import type { NumericLiteral, ObjectProperty, StringLiteral } from '@babel/types'
export default declare((api, options) => {
api.assertVersion(7)
const { types: t } = api
const { reflect = true } = options
return {
name: 'enum-to-object',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
inherits: syntaxTypeScript,
visitor: {
TSEnumDeclaration(path) {
const { node } = path
const { id, members } = node
const objProperties: ObjectProperty[] = []
let preNum = -1
members.forEach((member) => {
let { initializer, id: memberId } = member
if (!initializer) {
preNum++
initializer = t.numericLiteral(preNum)
}
else if (t.isNumericLiteral(initializer)) {
preNum = initializer.value
}
const objProerty = t.objectProperty(memberId, initializer)
objProperties.push(objProerty)
if (reflect) {
// add reflect
const key = t.identifier(String((objProerty.value as StringLiteral | NumericLiteral).value))
const value = t.stringLiteral(t.isIdentifier(memberId) ? memberId.name : memberId.value)
if (key.name === value.value)
return
objProperties.push(
t.objectProperty(
key,
value,
),
)
}
})
const obj = t.variableDeclarator(
id,
t.objectExpression(objProperties),
)
const constObjVariable = t.variableDeclaration('const', [obj])
path.replaceWith(constObjVariable)
},
},
}
})
借助 preVal 自动推导值
上面说过,preVal 记录的是上个 initializer 为 NumericLiteral 时的值,以便当 initializer 为空时,可以借助 preVal 来得到当前值:
if (!initializer) {
preNum++
// 为空的话就初始化一个initializer
initializer = t.numericLiteral(preNum)
}
else if (t.isNumericLiteral(initializer)) {
preNum = initializer.value
}
创建对象的 objectProperty
我们通过 member 的 id 拿到对象的 key,initializer 就作为对象的 value,然后通过 t.objectProperty 创建对象的每一项,之后塞入对象属性数组中:
const objProerty = t.objectProperty(memberId, initializer)
objProperties.push(objProerty)
添加反射值
由于我们上面已经得到了 objProerty,且 enum 的值类型只有 number 和 string,所以这里我们可以很轻松地拿到值来作为反射的 key,且 key 应该为 Identifier 类型:
const key = t.identifier(String((objProerty.value as StringLiteral | NumericLiteral).value))
这里 identifier 要求传入 string 参数,所以要用 String 转一下,因为 value 有可能是 number。
然后就是获取反射的值了,也就是 member 的 id,其可能是 identifier,也有可能是 StringLiteral,具体存放值放在不同的结构,所以这里我们要判断下
const value = t.stringLiteral(t.isIdentifier(memberId) ? memberId.name : memberId.value)
当然,key 与 value 相同,则没必要生成反射值了
所以我们拦截一下:
if (key.name === value.value) return
否则 push 到 objProperties 里即可
生成对象 ast 并替换
上面已经得到了成员属性和值了,那就可以通过 t.variableDeclarator 生成对象,然后通过 t.variableDeclaration 生成一个 const obj = {...}
,之后替换原节点则达到目的了~
const obj = t.variableDeclarator(
id,
t.objectExpression(objProperties),
)
const constObjVariable = t.variableDeclaration('const', [obj])
path.replaceWith(constObjVariable)
用 template 实现更简单
上面的实现实际上看起来有点啰嗦,但具体思路就是我们拿到 members 的 key 和 value,然后塞到对象里面,所以我们可以声明一个空对象来存对应 key 和 value,
之后通过 template.ast 将拿到的 obj 转换为新的变量即可,省去了上面一堆类型判断
用 template 实现的代码如下:
const { types: t, template } = api
...
TSEnumDeclaration(path) {
...
const targetOb: Record = {}
members.forEach((member) => {
...
let key = ''
if (t.isIdentifier(memberId))
key = memberId.name
else
key = memberId.value
let value: number | string = preNum
if (t.isStringLiteral(initializer))
value = initializer.value
targetOb[key] = value
if (reflect)
targetOb[value] = key
})
const constObjVariable = template.ast(`const ${id.name} = ${JSON.stringify(targetOb)}`) as Statement
path.replaceWith(constObjVariable)
}
可以看到,代码清晰了很多
吐槽下
实际上这个插件在上个月就做好了,但是当时在本地测试的时候,小程序总是报[MobX] MobX requires global 'Symbol' to be available or polyfilled
,然后期间一直跟大佬讨论,也没找到问题
但是诡异的是在其他小程序测试又不会,最后就让我大佬同事帮忙看看,大佬很快就找到了问题:
然后我看了开发者工具确实这几项开启了,当然 taro 文档上也有说过,只是我没留意到:
所以把以上几项关闭后,打包就不会再报错了~,至于其他小程序为何不会报错,那肯定就是这几项没开启呀,哈哈哈
感慨一下
实际上从去年我负责这项目开始,就有组里小伙伴跟我反馈 api 体积太大的问题
且当我跟组员说有些代码需要优化时,给出的原因是怕公共代码被多次引用的话,taro 会将其打包到主包的 common
原因如下:
我也尝试配置过 minChunks,主包确实变小了,但页面也报错了。。。所以后面就没再继续尝试。
但期间时不时就有组员说打包超过 2m,没法发布,所以我也断断续续地为小程序打包体积优化而努力,比如将 moment 替换为 dayjs
然后又通过写了个 loader 来处理,效果如下:
再然后又通过上篇文章所说的写了个 webpack plugin 将 enum 带来的体积影响降低了一半,但终究还是没能完全解决问题。
最后,终于通过 babel plugin 成功将没用到的 enum 全部 shaking 掉,完美解决了 api 项目太大的问题。
说实话,还是相当的开心,毕竟历经七八个月,期间探索了很多方案,也走了很多弯路,但总算是解决了这问题,而不是做无用功。
当然也特别感谢我同事指出是开启了那几个选项的问题,否则还得折腾很久,虽然插件本身没啥问题,但编译这种东西有时候就是奇奇怪怪,如果知道具体原因的小伙伴还请评论区指出一下,万分感谢。
总结
以上就是分享如何通过写一个 babel plugin,来完美解决 enum 产物无法 shaking 的问题。
我们做下总结:
- 我们首先快速讲了一个 babel plugin 的结构,如入参、出参,其通过访问者模式来对 ast 进行增删改
- 之后分析 enum 的类型和结构,其有可能都没初始值,也有可能值都为字符串,也有可能同时存在一些有初始值,一些没有,一些值是 number、一些值为 string,所以我们通过一个 preVal 来实现:当 initializer 为空时自动推导值
- 接着,我们开始着手实现插件功能,主要就是遍历 members 的每一项,其为 TSEnumMember 类型,来拿到对应的 key 和 value
- 然后根据配置参数
reflect
来决定是否需要反射值 - 最后替换原先 enum 为 object,使之能完全支持 tree shaking
- 后面,我们又讲了可以用 babel 提供的 template 来优化下代码,使之看起来更清晰
好啦,文章到这里也就结束了,感谢各位看官的阅读,觉得不错的话还请点个赞再走,谢谢啦~