在 esbuild 中实现 babel 超人气爆款插件

4,540 阅读6分钟

前言

esbuild 是近些天来非常火的一个全新的工具,年前霸屏掘金前端板块,被称作第三代构建方案,它最与众不同之处就是它独特的使用 go 语言构建。得益于此,它的打包速度非常的惊人。

image.png

构建十次 threejs 副本竟然只需要 0.37s,它被作为 vite 的打包工具的构建底层方案从而一战成名。

令人遗憾的是 esbuild 本身是不支持 babel 插件,所以本次文章,将带大家编写一个插件,该插件对标 babel-plugin-import,将赋予 esbuild 一个 babel 中非常通用的插件功能——动态引入。

why

动态 import 的想法其实非常简单,即通过改变 ast 的方式将代码强行改为仅引入单文件,即:

import { Button, Select } from 'antd'
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
import Button from 'antd/lib/button'
import Select from 'antd/lib/select'

为什么要这样做?

我们知道,引入模块时,如果引入形如 import { Button } from 'antd' 是会将包全部模块都引进来的。

我们在看一个库时,首先要读这个库的 pacakge.json 文件:

image.png

其中的 mainmodule 字段标记着用户在使用 import 语句是具体引入的文件是哪个,main 字段代表 commonjs 引入形式默认查找的文件,module 字段为 es 形式默认查找的文件。这是 commonjs 时期遗留下的规定,因此你在使用不带 module 字段的库时,形如 import { Button } from 'antd' 总是会引入 main 所标记的文件,丢失了 esm 的 tree-shaking 特性。

在本例中我们需要查看 main 中指向的文件,即 lib/index.js

这是一个 commonjs 形式的文件,你会发现,所有的组件都通过 require 的形式引入,我们知道 commonjs 的 require 是动态依赖的,这意味着只要引入了 import { Button } from 'antd'(即便你只使用 Button 组件),就会将全量组件引入,导致打包结果增大。

image.png

尝试仅引入 antd 的 Button 组件,将 antd 的 package.json 中的 module 字段删除后打包:

image.png

然后再用动态引入的方式:

image.png

OMG!竟足足少了 80%!

我们通过 import 指定路径的文件,绕过了 lib/index.js 文件,自然也就不会将所有组件打包。社区中一般将此行为称作「动态引入」(dynamic import) 或者「按需引入」(import on demand)。

how

在最流行的 webpack 中我们可以利用 babel-plugin-import 插件来解决这一问题。该插件由 antd 团队开发,周均下载超 30 万次,可谓是前端界的超级网红,为的正是解决这一情形:

// .babelrc
"plugins": [
  ["import", { "libraryName": "antd" }]
]

如此设置,babel 仅针对 antd 库做按需引入,且引入形式默认是 <libraryName>/lib/<componentName>。但其他的类库路径不一定如此,比如 lodash,lodash 的函数路径就是 lodash/<functionName> 的,因此它允许你配置 libraryDirectory 更改引入包路径:

// .babelrc
"plugins": [
  ["import", { "libraryName": "lodash", libraryDirectory: '' }]
]

看来 babel-plugin-import 早有准备。继续翻看文档我们会看到如下配置项,能够覆盖所有的引入情形:

配置项意义
libraryName包名
libraryDirectory模块引入路径
styleLibraryDirectory样式引入路径
camel2DashComponentName是否将 camelCase 转为 kebabCase,默认为 true
style是否默认引入样式,支持函数形式
customName改变模块引用路径的函数
customStyleName改变样式引入路径的函数

那么,让我们开始 esbuild 插件的开发,首先我们要了解一下 esbuild 的插件机制。

esbuild 插件机制

esbuild 插件的机制相比 webpack 可谓是极简,对程序仅有 onStart、onEnd 两个环节,对文件仅影响 onResolve、onLoad 两个环节。

image.png

esbuild 的规范要求插件必须是有 name、setup 的对象,其中 setup 是一个接收 build 流程的函数,你可以在 build 上注册周期钩子,在 build 流程开始时会统一挂载,并在相应环节触发。

const plugin = {
  name: "myPlugin",
  setup: (build) => {
    build.onLoad({}, (args) => {
      const contents = fs.readFileSync(args.path, "utf-8");
      console.log("content: ", contents);
      return {
        contents: contents + "\n",
      };
    });
  },
};

具体的周期钩子的接受值和返回值可以参阅 esbuild 官方 using plugins 一栏

开始开发

我们可以借助最近非常火的一个 AST 分析框架 gogocode 的能力,快速将我们的想法变现。

我们要干的事情事实上就两种情况:

情况一:ImportDefaultSpecifier 类型

import Antd from "antd";

const Button = Antd.Button;
const { Select } = Antd;
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
import Button from "antd/lib/button";
import Select from "antd/lib/select";

ReactDOM.render(
  <>
    <Button />
    <Select />
    <Antd.Table />
  </>,
  document.querySelector("#root")
);

情况二:ImportSepecifier 类型

import { Button, Select } from 'antd'
// ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
import Button from 'antd/lib/button'
import Select from 'antd/lib/select'

ReactDOM.render(
  <>
    <Button />
    <Select />
  </>,
  document.querySelector('#root')
)

情况二处理起来相对比较简单,我们分析它所有的 ImportDeclarationImportSepecifier,只要分析到了,就向原 ast 下方 push 几个 DefaultImportSepecifier ,并将原 ImportDeclaration 删除。

image.png

……:省略号用以省略与本需求无关的 ast 声明

需要注意的是这个声明有一特殊情况:import { Button as MyButton } from 'antd',我们需要记录它的「引入名称」(importSepecifier.imported)和「开发者想要使用的新名称」(importSepecifier.local),在替换时将其赋值即可。

对于情况一,我们又可以分成三种情况:

  • 直接使用:<Antd.Select />
  • 间接引用:const Button = Antd.Button
  • 间接的列表引用:const { Button: MyButton, Select } = Antd

对于后两者,我们得在脑内将 VariableDeclaration 转变为 ImportDeclaration

由于 gogocode 使用的是直接匹配的模式,所以可以选择性地跳过无关环节,非常方便。

image.png

上代码!

image.png

回过头来看看 getUsedComponents 干了什么:

image.png

就是这样喵!

测试用例

对于测试用例,babel-plugin-import 已经帮我们写好了,我们直接使用即可。

esbuild 处理文件使用何种 loader 与 webpack 不同,它是直接根据后缀名来判断的,所以我们要将所有用到 js 的文件后缀名改成 jsx。例如:

image.png

然后,让我们写好测试逻辑:

image.png

跑下试试:

image.png

芜湖!比预计的还要顺利,还就那个全部通过!

image.png

这个库已经发布到了 npm,名字叫 esbuild-dynamic-import-plugin,并且已经加入到 awesome esbuild 全家桶。在 awesome esbuild 中还有大量插件没有被覆盖,欢迎大家使用 gogocode 进行开发!

写在后面

我们在《webpack、babel、vite、rollup 中完美融入 gogocode》的那次文章中,对 webpack 的插件系统做了一个小剖析,它的确更可控,但是实在太很重了:webpack 竟然有 28 个生命周期钩子,而 esbuild 只有 4 个;webpack 的 tapable 的规范容易让开发者陷入无所适从的境地,esbuild 就相对来说清爽得很。

其次 gogocode 匹配和替换 ast 的方案非常的高效,能够节省大量的代码,比如刚刚我们要匹配 ImportDeclaration 如果使用 babel 的 visitor 模式,那将至少编写十倍不止的代码,而 gogocode 只有一行简单的字符串就解决了。让开发着把注意力放在对代码转换的思路上,不仅节省时间,还节省了程序猿的掉的头发。真正做到了以「猿」(¿) 为本,赞一个!

为什么会变成这样呢~!第一次写 esbuild 构建框架的插件;第一次用 gogocode 搞出如此复杂的插件。这两件愉快的事情交织在了一起。本应该是双倍的快乐,但为什么会这样呢~!(指变成了四倍快乐) image.png

GoGoCode 相关链接

GoGoCode的Github仓库(新项目求star ^_^) github.com/thx/gogocod…

GoGoCode的官网 gogocode.io/

可以来 playground 快速体验一下 play.gogocode.io/

阿里妈妈出的新工具,给批量修改项目代码减轻了痛苦
「GoGoCode 实战」一口气学会 30 个 AST 代码替换小诀窍
0成本上手AST,用GoGoCode解决Vue2迁移Vue3难题
GoGoCode协助清理代码中的「垃圾」

本文作者:冰块