前言
这里说明一下打包工具的概念,打包工具是用来将多个 js 文件和他们的依赖组合成一个或者多个 bundle 文件的工具。前端知名的打包工具有 webpack,rollup,gulp 等。
我们的前端项目一般都会使用 webpack 进行构建。随着项目规模不断增加,引入的依赖逐渐增多,我们开启开发服务器和进行生产构建的时间会不断增加。目前我的公司的项目开启开发服务器需要 1 分钟左右,进行生产构建需要 5 分钟以上,而这已经是进行微前端拆分后的结果,如果还是单体巨石应用的结构,那画面太美不敢想象。webpack 的速度实在是太慢,esbuild 就是为了解决这一痛点而被创造出来的。
esbuild 是使用 go 语言编写的一个新一代的 JavaScript 打包工具,它的作者是 figma 的 CTO Evan Wallace。得益于 go 语言编译型语言的优点,打包速度可以达到 webpack 的几十倍。一些知名的热门项目已经内置了 esbuild,比如 vite,snowpack。今天我们就跟随 esbuild 的官方文档,学习一下 esbuild 的相关知识。
下文的主要内容包括
- 为什么 esbuild 这么快
- 上手使用 esbuild
- esbuild plugin api 简介
- esbuild 目前的局限
esbuild 为什么那么快
在文档中作者给出了 esbuild 与其他打包工具对 three.js 进行的打包基准测试结果,我们可以看到 esbuild 的速度惊人的快,最高是 rollup 和 webpack 的百倍。
| Bundler | Time | Relative slowdown |Absolute speed| Output size|
|—-|—-|—-|—-|—-|
|esbuild |0.37s |1x |1479.6 kloc/s |5.81mb|
|esbuild (1 thread) |1.61s |4x |340.0 kloc/s |5.81mb|
|rollup + terser |37.79s |102x |14.5 kloc/s |5.81mb|
|parcel 2 |39.28s |106x |13.9 kloc/s |5.87mb|
|webpack 4 |43.07s |116x |12.7 kloc/s |5.97mb|
|webpack 5 |55.25s |149x |9.9 kloc/s |5.84mb|
这里大家一定非常好奇 esbuild 是如何做到如此之快的,在官方文档的 FAQ 中给出了以下解释:
go 语言编写
go 是为了并行而被设计出来的编译型语言,速度相比使用脚本语言 JavaScript 编写的同类工具有很大的优势。
尽管 v8 引擎提供了 JIT 的特性,很大程度上提升了 Javascript 的性能,但是对于命令行程序,它依然太慢了。 每次运行打包器时,JavaScript VM 都会在没有任何优化的情况下运行打包程序的代码。在 esbuild 忙于解析 JavaScript 时,node 还在忙于解析打包程序的 JavaScript。
在线程间通信方面, Go 在线程之间可以共享内存,而 JavaScript 必须在线程之间传递序列化数据,这一点显然共享内存的效率更高。
Go 和 JavaScript 都有并行的垃圾收集器,但是 Go 的堆在所有线程之间共享,而对于 JavaScript, 每个 JavaScript 线程中都有一个单独的堆。
充分使用并行
esbuild 内部的算法专门被设计为可以最大程度利用 cpu 资源的形式。esbuild 的工作流程可大致分为三个阶段,解析,链接和代码生成。其中作为主要工作的解析代码和代码生成全部并行方式处理。因为所有的线程共享了内存,不同入口文件的打包工作可以使用相同的导入的 Javascript 模块数据。
这里需要额外说明一下,Go 语言是一门为并发编程设计的语言,它原生支持协程。
代码完全自己编写
esbuild 不使用第三方库,自己来编写实现 js/ts 的编译相关模块,带来了很多细节上的性能优化。
高效使用内存
理想情况下,编译器以近乎 O(n) 的复杂度处理输入,如果需要处理大量数据,内存访问速度可能会严重影响性能。如果你能更少地遍历代码,编译器的运行速度就会更快。
esbuild 只会访问 js/ts 的 AST 三次。
第一次是对于词法解析,作用域设置,以及声明符号
第二次是绑定符号,压缩语法, JSX/TS 转 JS,ESNext 转 ES2015
第三次是压缩标识符,压缩空白字符,生成代码与生成 sourcemap
esbuild 最大程度重用了 AST 数据,当它们还在 cpu 的缓存中时。
综合以上的几个方面,使得 esbuild 的速度比起其他打包工具高出一个数量级。
开始使用
esbuild 提供了三种方式来使用 esbuild,分别是 命令行,npm 包 和 go 包
esbuild 提供了两种调用类型主要 API 类型,Transform API 和 Build API
下面分别简单介绍一下它们的使用
npm 包方式
首先安装 esbuild
1 | npm install esbuild |
可以看到 esbuild 的 npm 包结构如下:
│ esbuild.exe
│ install.js
│ package.json
│ README.md
│
├─bin
│ esbuild
│
├─lib
│ main.d.ts
│ main.js
esbuild 是一个 go 语言编译的可执行程序, 包在安装时会根据所在平台安装对应的可执行程序,windows 系统下就是 esbuild.exe 。lib 下 main.js 提供了对于 esbuild 可执行程序调用的封装。
以下是官网的示例
1 | require("fs").writeFileSync("in.ts", "let x: number = 1"); |
命令行方式
使用 npm 安装 esbuild 后,我们也可以通过命令行的方式调用 esbuild。esbuild 在 package.json 中定义了 bin 的配置,该文件是一个 node 脚本,用以子进程方式调用 esbuild 可执行文件。
根据 npm 的约定,当 package.json 中定义了 bin 选项时,包安装时会自动将脚本软链接到 node_modules/.bin 目录下。当使用 npm 执行定义在 scripts 中的命令时,会自动将 node_modules/.bin 目录下添加到 PATH 中。通过这种方式就可以以命令行方式调用 esbuild。
Transform API
Transform API 对单个字符串进行操作,它不访问文件系统。这使得它非常适合在没有文件系统(例如浏览器)的环境中使用,或者作为另一个工具链的一部分。
1 | require('esbuild').transformSync('let x: number = 1', { |
社区中有很多基于 esbuild Transform API 打造的其他打包工具的插件,比如
Build API
esbuild 的 Build API 做的事情和 webpack,rollup 等一样,需要入口文件,用于将一系列文件及其以来打包输出到一个或多个文件中。
1 | require("fs").writeFileSync("in.ts", "let x: number = 1"); |
设置 watch 选项为 true 既可以开启开发服务器模式,esbuild 会监听文件系统的变化并自动重新 build。
内置 loaders
esbuild 内置的 loaders 可以处理以下文件类型:JavaScript,typescript,jsx,json,css,text,binary,base64,dataurl,external file
除了 external,其他类型的 loader 会在处理相关扩展名的文件时自动调用,也可以通过 loader 手动设置调用规则。
需要注意的是 esbuild 对于 js 的处理。如果你想要用 esbuild 转译 es5 的代码,需要将target 设置为 es5,这可以避免 esbuild 进行错误的转译,比如将{x:x}
转译为 {x}
。
esbuild 目前对将es6+ 的代码转译为 es5 的代码的支持不完善,如 let
,const
等语法不会被转译。
插件机制
同 rollup 和 和 webpack 一样,esbuild 提供了插件机制,目前 esbuild 的插件 api 还在实验阶段,在未来可能会有很大的变动。
esbuild 目前只支持在 build API 中使用插件,不支持 transform API。
编写 esbuild 插件
一个 esbuild 的插件是一个对象,有一个 name
属性和一个 setup
函数。在 build API 中,我们可以设置 plugins 数组。插件对象中的 setup
函数会在 build 每次运行时调用一次。
下面是文档中的例子:
1 | let envPlugin = { |
这个插件的作用是让 esbuild 将以 env 结尾的文件作为 json 文件解析。可以看到 esbuild 的插件编写非常简单,只有少量的关键概念和关键 API,下面我们来简单了解一下。
两个关键观念
Namespaces 命名空间
每个模块有一个关联的命名空间。默认情况下,esbuild 在 file
命名空间下工作,它对应于真实文件系统上的文件。
esbuild 也可以处理虚拟的模块,即并不对应于文件系统上的文件。举个例子,比如从 stdin
中提供的模块。
可以用插件去创建虚拟模块,标注模块在一个特定的命名空间,以使用其他的插件进行特殊处理。
Filters 过滤器
每一个回调函数必须提供一个正则表达式作为过滤器,那些不匹配的模块将被跳过处理。
你应该尽可能使用过滤正则表达式而非使用 js 代码来处理,因为 Filters 是在 esbuild 内部处理的,效率更高。
两个主要回调
onResolve 回调
使用 onResolve 添加的回调会在每次 esbuild build 过程中的路径解析阶段被调用。这个回调用来自定义 esbuild 解析路径的行为。
下面这个例子中,插件将以 images 开头的导入路径重定向到 /public/images 目录,将 http 开头的路径标记为 external。
1 | let exampleOnResolvePlugin = { |
onLoad 回调
使用 onLoad 添加的回调会在所有没有被标记为 external 的模块中运行,模块由路径和命名空间共同标识。它用来自定义模块返回的内容,告诉 esbuild 如何去转译它。
下面这个例子中,插件将以 txt 文件以 utf8 方式获取内容后,以 json 数据形式读取。
1 | let exampleOnLoadPlugin = { |
esbuild 插件的局限
esbuild 的插件无法做到覆盖全部的用户场景。它只提供了自定义模块家在方式和自定义模块返回内容的能力,没有提供给我们直接访问 js ast 的能力,这是出于为了保持 esbuild 卓越性能的考虑,但这也导致很多 babel 插件提供的基于 js ast 的转译功能都无法使用 esbuild 的插件机制来实现。
结论
esbuild 作为打包工具的性能表现是极为出色的,但是现阶段并不能代替 webpack。一方面,webpack 经过多年的发展,生态非常成熟,可以覆盖到几乎所有的使用场景,另一方面,esbuild 本身受限的插件机制使得它注定无法完成一些事情。
比如说我们使用 webpack 中使用的 基于 ast 的 babel 插件,是没办法迁移到 esbuild 中去的,因为 esbuild 根本不提供访问 ast 的能力。
对于基于 webpack 的老项目,通常包含了很多基于 webpack 生态的 loader,插件等,将它们迁移到 esbuild 是一项艰巨且风险巨大的工作。新的项目没有技术债务,可以使用 esbuild 作为构建工具,可以极大提升开发体验。
但是,把 esbuild 作为纯的 js/ts 转译工具,或者作为代码压缩工具,作为其他构建工具工具链的一部分,是可以的融入到老项目中,提升构建速度的。