Esbuild作为Vite双引擎之一,在很多关键的构建阶段(如:依赖预构建、TS语法转译、代码压缩),让Vite获得了相当优异的性能。
为什么Esbuild性能极高
Esbuild
是基于Glolang
开发的一款打包工具,相比传统的打包工具,主打性能优势,在构建速度上可以比传统工具快 10~100 倍。那么,它是如何达到这样超高的构建性能的呢?主要原因可以概括为 4 点。
- 使用Golang开发,构建逻辑代码直接被编译为原生机器码,而不用像JS一样先解析为字节码,然后转换为机器码,大大节省了程序运行时间。
- 多核并行。内部打包算法充分利用多核 CPU 优势,所有的步骤尽可能并行,这也是得益于 Go 当中多线程共享内存的优势。
- 从零造轮子。 几乎没有使用任何第三方库,所有逻辑自己编写,大到 AST 解析,小到字符串的操作,保证极致的代码性能。
- 高效的内存利用。Esbuild 中从头到尾尽可能地复用一份 AST 节点数据,而不用像 JS 打包工具中频繁地解析和传递 AST 数据(如
string -> TS -> JS -> string
),造成内存的大量浪费。
Esbuild功能使用
一、项目打包
build API
Build API主要用来进行项目打包,包括
build、buildSync和serve
三个方法。
首先我们来试着在 Node.js 中使用build 方法。你可以在项目根目录新建build.js文件,内容如下:
1 | const { build, buildSync, serve } = require("esbuild"); |
随后,你在命令行执行node build.js
,就能在控制台发现信息,就是 Esbuild 打包的元信息,这对我们编写插件扩展 Esbuild 能力非常有用。
不推荐大家使用 buildSync 这种同步的 API,它们会导致两方面不良后果。一方面容易使 Esbuild 在当前线程阻塞,丧失并发任务处理的优势。另一方面,Esbuild 所有插件中都不能使用任何异步操作,这给插件开发增加了限制。
serve API
开启 serve 模式后,将在指定的端口和目录上搭建一个静态文件服务,这个服务器用原生 Go 语言实现,性能比 Nodejs 更高。
类似 webpack-dev-server
,所有的产物文件都默认不会写到磁盘,而是放在内存中,通过请求服务来访问。
每次请求到来时,都会进行重新构建(rebuild),永远返回新的产物。
值得注意的是,触发 rebuild 的条件并不是代码改动,而是新的请求到来。
后续每次在浏览器请求都会触发 Esbuild 重新构建,而每次重新构建都是一个增量构建的过程,耗时也会比首次构建少很多(一般能减少 70% 左右)。
Serve API 只适合在开发阶段使用,不适用于生产环境。
二、单文件转译
除了项目的打包功能之后,Esbuild 还专门提供了单文件编译的能力,即Transform API
,与 Build API
类似,它也包含了同步和异步的两个方法,分别是transformSync和transform。下面,我们具体使用下这些方法。
首先,在项目根目录新建transform.js,内容如下:
1 | // transform.js |
同步的 API 会使 Esbuild 丧失并发任务处理的优势(Build API的部分已经分析过)
出于性能考虑,Vite 的底层实现也是采用 transform这个异步的 API 进行 TS 及 JSX 的单文件转译的。
三、Esbuild插件开发
基本概念
插件开发其实就是基于原有的体系结构中进行扩展和自定义。 Esbuild 插件也不例外,通过 Esbuild 插件我们可以扩展 Esbuild 原有的路径解析、模块加载等方面的能力,并在 Esbuild 的构建过程中执行一系列自定义的逻辑。
Esbuild 插件结构被设计为一个对象,里面有name
和setup
两个属性,name是插件的名称,setup是一个函数,其中入参是一个 build 对象,这个对象上挂载了一些钩子可供我们自定义一些钩子函数逻辑。
以下是一个简单的Esbuild插件示例:
1 | let envPlugin = { |
使用插件后效果如下:
// 应用了 env 插件后,构建时将会被替换成 process.env 对象
1 | import { PATH } from 'env' |
那么,build对象上的各种钩子函数是如何使用的呢?
onResolve和onLoad钩子
在 Esbuild 插件中,onResolve 和 onload是两个非常重要的钩子,分别控制路径解析和模块内容加载的过程。
首先,我们来说说上面插件示例中的两个钩子该如何使用。
1 | build.onResolve({ filter: /^env$/ }, args => ({ |
可以发现这两个钩子函数中都需要传入两个参数: Options 和 Callback。
先说说Options。它是一个对象,对于onResolve 和 onload 都一样,包含filter和namespace两个属性,类型定义如下:
1 | interface Options { |
filter 为必传参数,是一个正则表达式,它决定了要过滤出的特征文件。
📢 注意: 插件中的 filter 正则是使用 Go 原生正则实现的,为了不使性能过于劣化,规则应该尽可能严格。同时它本身和 JS 的正则也有所区别,不支持前瞻(?<=)、后顾(?=)和反向引用(\1)这三种规则。
namespace 为选填参数,一般在 onResolve 钩子中的回调参数返回namespace属性作为标识,我们可以在onLoad钩子中通过 namespace 将模块过滤出来。如上述插件示例就在onLoad钩子通过env-ns这个 namespace 标识过滤出了要处理的env模块。
除了 Options 参数,还有一个回调参数 Callback,它的类型根据不同的钩子会有所不同。相比于 Options,Callback 函数入参和返回值的结构复杂得多,涉及很多属性。不过,我们也不需要看懂每个属性的细节,先了解一遍即可,常用的一些属性会在插件实战部分讲解来讲。
在 onResolve 钩子中函数参数和返回值梳理如下:
1 | build.onResolve({ filter: /^env$/ }, (args: onResolveArgs): onResolveResult => { |
1 | build.onLoad({ filter: /.*/, namespace: 'env-ns' }, (args: OnLoadArgs): OnLoadResult => { |
其他钩子
在 build 对象中,除了onResolve和onLoad,还有onStart和onEnd两个钩子用来在构建开启和结束时执行一些自定义的逻辑,使用上比较简单,如下面的例子所示:
1 | let examplePlugin = { |
在使用这些钩子的时候,有 2 点需要注意。
onStart 的执行时机是在每次 build 的时候,包括触发 watch 或者 serve模式下的重新构建。
onEnd 钩子中如果要拿到 metafile,必须将 Esbuild 的构建配置中metafile属性设为 true。