共计 8159 个字符,预计需要花费 21 分钟才能阅读完成。
前言
之前看过我文章顺手写了个 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,同时能输入转换后的后果:
第二个是 babel-ast-explorer
也能够通过配置你须要增加的额定插件
剖析 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<BabelPluginEnumToObjectOptions>((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<string, number | string> = {}
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 来优化下代码,使之看起来更清晰
好啦,文章到这里也就完结了,感激各位看官的浏览,感觉不错的话还请点个赞再走,谢谢啦~