头图

前言

之前看过我文章随手写了个 plugin,就将小程序体积减少了 120k的小伙伴(没看过的也可以了解下前因后果,说不定以后碰到相同的问题,就能按这种方式解决),肯定知道我们公司的 api 项目因为里面有大量 enum,导致小程序打包体积接近最大限制 2M,大部分原因就是因为 enum 转 js 是个 IIFE 的过程,是有副作用的,这种情况下 webpack 无法对其 tree-shaking

为了降低 enum 带来的体积增大的影响,我就写了个 webpack plugin reduce-enum-webpack-plugin,将 enum 的体积降低了一半,然而实际上还是有遗留问题,也就是另一半没用到的体积也打包进去了,这个我在上篇文章结尾也有提到:

然后在写完这个 plugin 之后的下一周,实际上我就想到用另一种方法来尝试解决,即通过一个 babel plugin 来实现,具体理由如下:

  1. 比起用正则匹配产物,babel plugin 可以遍历 AST,能准确地识别到 enum,具体是 TSEnumDeclaration
  2. 既然上面我们说到,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 // 代表不需要反射值
    }]
    ]
  }
}

⚠️ 注意:

  1. babel 插件的执行顺序是从左往右,或者说从上到下,所以请务必在 ts 插件处理之前使用该插件,否则 ts 都已经被转译了,就再也没法遍历到 TSEnumDeclaration 了
  2. 该插件只有一个参数 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-utilsdeclare给插件提供类型支持,这样开发就方便许多了

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 的情况比较简单,就只有StringLiteralIdentifier两种

而 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 来优化下代码,使之看起来更清晰

好啦,文章到这里也就结束了,感谢各位看官的阅读,觉得不错的话还请点个赞再走,谢谢啦~


暴走
10 声望0 粉丝