乐趣区

关于前端:这次我写了个babel-plugin将小程序体积降低了286k

前言

之前看过我文章顺手写了个 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 来优化下代码,使之看起来更清晰

好啦,文章到这里也就完结了,感激各位看官的浏览,感觉不错的话还请点个赞再走,谢谢啦~

退出移动版