Skip to main content

ESbuild

ESbuild

ESbuild 是一款基于 Go 语言开发的 JavaScript 构建打包工具,相比传统的构建工具,主打性能优势。同样规模的项目,使用 ESbuild 可以将打包速度提升 10 - 100 倍。

esbuild

为什么快

  1. Golang 开发。采用 Go 语言开发,传统的 JavaScript 开发的构建工具并不适合资源打包这种 CPU 密集场景,而Go更具性能优势。
  2. 多核并行。Go 具有多线程运行能力,而 JavaScript 本质上是一门单线程语言。由于 Go 的多个线程是可以共享内存的,所以可以将解析、编译和生成的工作并行化。
  3. 从零开始。从一开始就考虑性能,不使用第三方依赖,从始至终是使用的是一致的数据结构,避免数据转换无意义的消耗。
  4. 有效利用内存。在 JavaScript 开发的传统打包工具当中一般会频繁地解析和传递 AST 数据,这会涉及复杂的编译工具链,而每次接触到新的工具链,都得重新解析 AST,导致大量的内存占用。
有效利用内存

传统的打包工具会频繁地使用 AST,比如:

  • 字符串 -> TypeScript -> JavaScript -> 字符串
  • 字符串 -> JavaScript -> 旧的JavaScript -> 字符串
  • 字符串 -> JavaScript -> minified JavaScript -> 字符串

除此之外,还会涉及复杂的编译工具链,比如:

  • webpack
  • babel
  • terser
  • loader
  • plugin

每次使用一个工具链,都得重新解析 AST,导致大量的内存占用。

而 ESbuild 整个过程仅触及 AST 3次:

  • 进行词法分析,解析,作用域设置和声明符号的过程。
  • 绑定符号,最小化语法。比如:将 JSX / TS转换为 JS。
  • AST生成 JavaScript,source map生成。

当 AST 数据在CPU缓存中仍然处于活跃状态时,会最大化AST数据的重用。

esbuild mermory

缺点

ESbuild虽然有很多优点,但缺点也非常明显。从官方文档upcoming roadmap中,可以看出如下的问题:

  • ESbuild 没有提供 AST 的操作能力,无法将打包产物降级到 ES5 及以下,不能兼容一些低版本浏览器。
  • Code Splitting 目前还不支持,还在计划中。
  • 没有 TypeScript 的类型检测。
  • 默认不支持 Vue、Angular 等代码文件格式。

如果希望直接将 ESbuild 放入到生产环境中,还需要一些路走,其并不足以支撑一个大型项目的开发需求。虽然有些功能可以通过插件实现,但并不是开箱即用的功能,对于开发者来说,变相增加了开发成本。

Vite

ESbuild 是 Vite 的两架马车之一。Vite 使用 ESbuild 的主要原因就是因为快。

Vite

Vite 在几个方面都依托于 ESbuild,而随着 ESbuild 的完善,可能会做进一步处理:

  • 依赖预构建:作为 Bundle 工具。
  • 单文件编译:作为 TypeScript 和 JSX 的编译工具。
  • 代码压缩:作为压缩工具。
no-bundle

ESM 是 ECMA 官方提出的标准化模块系统,不同于之前的CommonJS、AMD、CMD、UMD等,ESM 提供了更原生以及更动态的模块加载方案,最重要的是它是浏览器原生支持的,也就是说可以直接在浏览器中 import,动态引入需要的模块,而不是把所有模块打包在一起。

Vite 是一个提倡 no-bundle 的构建工具,相比于传统的打包工具,能做到开发时的模块按需编译,而不用先打包完再加载。

  • Bundle Server
Bundle Server
  • Native ESM Server
Native ESM Server
依赖预构建

模块代码其实分为两部分,一部分是源代码,也就是业务代码,另一部分是第三方依赖的代码,即 node_modules 中的代码。所谓的 no-bundle 只是对于源代码而言,对于第三方依赖而言,因为基本上不会去改变它,Vite 还是选择将其 bundle,而这个过程依赖于 ESbuild。

如果在开发阶段不进行预构建会怎么样?
  • Vite 是基于浏览器原生 ESM 模块规范实现的,不论是应用代码,还是第三方依赖的代码,理应符合 ESM 规范才能够正常运行。但是,作为一个包的使用者,没有办法控制第三方包的打包规范,况且目前还有相当多的第三方库仍然没有 ESM 版本的产物。
  • ESM 还有一个比较重要的问题——请求瀑布流问题。ESM 的每个 import 都会触发一次新的文件请求,因此在依赖层级深、涉及模块数量多的情况下,会触发很多个网络请求,巨大的请求量加上Chrome 对同一个域名下只能同时支持 6个 HTTP 并发请求的限制,导致页面加载十分缓慢,与 Vite 主导性能优势的初衷背道而驰。

在进行依赖的预构建之后,第三方库的代码被打包成了一个文件,这样请求的数量会骤然减少,页面加载也快了许多。

Command

  • --version:查看 ESbuild 版本。
  • --outfile:指定输出的文件。
  • --outdir:指定输出的文件目录。
  • --target:指定输出的 ECMA 版本,但只支持 ES6 及以上版本,否则会报错。
  • --bundle:表示是否进行打包,默认输出格式为 IIFE
  • --minify:表示对输出的代码进行压缩。
  • --platform:指定输出的平台。
  • --sourcemap:表示是否输出源码映射文件。
  • --loader:指定特定资源处理的 loader,用于代码转换。
npx esbuild --verison
npx esbuild src/index.ts --outfile=dist/index.js
npx esbuild src/index.ts --outdir=dist --bundle --minify --target=esnext
npx esbuild src/index.ts --outdir=dist --loader:.png=dataurl

API

ESbuild 对外暴露了一系列的 API,主要包括两类: Build API 和 Transform API。

Build API

Build API主要用来进行项目打包,主要包括 buildbuildSync 方法。

build(options?: Config): Promise<Result>
buildSync(options?: Config): Result

buildSyncbuild 唯一区别就是这个方法是同步的。使用 buildSync 会导致如下问题:

  • 容易使 ESbuild 在当前线程阻塞,丧失并发任务处理的优势。
  • ESbuild 所有插件中都不能使用任何异步操作,这给插件开发和使用增加了限制。
import esbuild from 'esbuild'

;(async () => {
const result = await esbuild.build({
// 入口文件列表,为一个数组
entryPoints: ['./src/index.js', './index.html'],
// 是否需要打包,一般设为 true
bundle: true,
// 是否进行代码压缩
minify: false,
// 是否生成 SourceMap 文件
sourcemap: false,
// 是否生成打包的元信息文件
metafile: true,
// 指定语言版本和目标环境
target: [
'es2020',
'chrome58',
'firefox57',
'safari11'
],
// 指定输出文件
// outfile: './dist/index.js',
// 多入口需要指定输出目录
outdir: './dist',
// 指定loader
loader: {
'.html': 'copy',
'.svg': 'dataurl',
'.js': 'jsx',
'.module.css': 'local-css'
}
})
})()

esbuild.build 是一个异步函数,可以根据这个函数的返回值进行元数据的分析:

const text = await esbuild.analyzeMetafile(result.metafile, {
verbose: true,
});

console.log(text);
Context API

除了 buildbuildSync,ESbuild 还提供了另外一个强大的 API context

context 提供了三种可以增量构建的 API,context及这三种 API 都是异步的:

  • Watch Mode:简单来说就是监听模式,当修改源文件后,会自动重新构建。
  • Serve Mode:启动本地开发服务器,提供最新的构建结果。Serve Mode会自动构建打包源文件,但并不支持热重载。
  • Rebuild Mode:允许手动调用构建。当将 ESbuild 与其他工具集成时非常有用。
esbuild.config.js
import esbuild from 'esbuild'

;(async () => {
const ctx = await esbuild.context(options)

await ctx.watch()

const server = await ctx.serve({
servedir: './dist',
port: 8000,
host: 'localhost'
})

console.log(`server is running at ${server.host}:${server.port}`)
})()
Live Reload

实时重新加载是一种开发方法,可以在浏览器与代码编辑器同时打开并可见。当编辑并保存源代码时,浏览器会自动重新加载,并且重新加载的应用程序版本包含新的更改。这意味着可以更快地迭代,而不必在每次更改后手动切换到浏览器、重新加载,然后切换回代码编辑器。

ESbuild 并没有提供 Live Reload 的 API,而是通过服务器发送事件来实现。ESbuild 在服务模式下提供一个 /esbuild 带有 change 事件的端点,每次 ESbuild 的输出更改时都会触发该事件。

note

服务器发送事件是一种将单向消息从服务器异步传递到客户端的简单方法。

通过 Watch Mode 结合 Serve Mode ,并加上少量客户端 JavaScript 来实现实时 Live Reload。

<script>
const es = new EventSource('/esbuild')
es.addEventListener('change', () => location.reload(), false)
</script>
Transform API

transformtransformSync 对单个字符串进行操作,不需要访问文件系统,非常适合在没有文件系统的环境中使用或作为另一个工具链的一部分。

transform(str: string, options?: Config): Promise<Result>
transformSync(str: string, options?: Config): Result
  • str:字符串(必填),指需要转化的代码。
  • options:配置项(可选),指转化需要的选项。
interface Config {
// 关键词替换
define: object

// 输出模块格式(iife/cjs/esm)
format: string

// transform API 只能使用 string
loader: string | object

// 压缩代码,包含删除空格、重命名变量、修改语法使语法更简练
minify: boolean
// minifyWhitespace: boolean // 删除空格
// minifyIdentifiers: boolean // 重命名变量
// minifySyntax: boolean // 修改语法使语法更简练

// 源码映射
sourcemap: boolean | string

// 设置目标环境,默认是 esnext(使用最新 es 特性)
target: string[]
}

Loader

ESbuild 加载器的作用与 Webpack 中 Loader 作用类似,是对某种类型的文件进行编译。

js-loader

默认用于 .js.cjs.mjs 文件,ESbuild 并没有对这模块类型进行区分。

ESbuild 支持所有现代 JavaScript 语法。然而,较新的语法可能不被旧的浏览器所支持,但 ESbuild 目前还不支持将 ES6+ 语法转换为 ES5

ts-loader

默认用于 .ts.tsx.mts.cts 文件。ESbuild 内置支持解析 TypeScript 语法并擦除类型注释,但不执行任何类型检查。

jsx-loader

将 JSX 转换为普通的 JavaScript。.jsx.tsx 会默认开启此 Loader,如果文件是以 .js 结尾的,那么必须手动声明:

{
loader: {
'.js': 'jsx'
}
}
json-loader

对于 .json 文件,这个加载器默认启用。它在构建时将 JSON 文件解析成一个 JavaScript 对象,并将该对象作为默认导出。

css-loader

对 CSS 文件进行处理,默认用于 .css 文件,local-css 默认用于 .module.css(CSS Moudules) 文件。

text-loader

默认用于 .txt 文件,会将文件内容作为字符串并默认导出。

import string from './example.txt'
console.log(string)
binary-loader

该加载器会在构建时将文件作为二进制缓冲区加载,并使用 Base64 编码将其嵌入到构建产物中。文件的原始字节在运行时从 Base64 解码,并通过默认导出以 Uint8Array 的形式导出。

import uint8array from './example.data'
console.log(uint8array)
base64-loader

该加载器会在构建时将文件作为二进制缓冲区加载,并使用 Base64 编码将其作为字符串嵌入到构建产物中,此字符串通过默认导出导出。

import base64string from './example.data'
console.log(base64string)

该 Loader 需要明确指定,默认不会被激活:

{
loader: {
'.data': 'base64'
}
}
dataurl-loader

该加载器会在构建时将文件作为二进制缓冲区加载,并将其作为 Base64 编码的数据 URL 嵌入到构建产物中,字符串通过默认导出导出。

{
loader: {
'.png': 'dataurl'
}
}
file-loader

该加载器会将文件复制到输出目录中,并将文件名作为字符串嵌入到构建产物中,字符串通过默认导出导出。

{
loader: {
'.jpg': 'file'
}
}
copy-loader

该加载器会将文件复制到输出目录,并重写导入路径以指向复制的文件。这意味着在最终构建产物中导入仍然存在,且最终构建产物将引用该文件,而不是将文件直接嵌入到构建产物中。

{
loader: {
'.jpg': 'copy'
}
}
empty-loader

该加载器告诉 ESbuild 将文件视为空文件。这在某些情况下可以作为一种有效的方式,从捆绑包中移除内容。

Plugin

ESbuild 支持插件,开发者可根据需要自定义插件。例如Vite 依赖预编译的实现中大量应用了 ESbuild 插件的逻辑。目前已有一些ESbuild 社区插件可安装使用。

通过 ESbuild 插件可以扩展 ESbuild 原有的路径解析、模块加载等方面的能力,并在 ESbuild 的构建过程中执行一系列自定义的逻辑。

基本使用
import inlineImage from "esbuild-plugin-inline-image";
import classModules from "esbuild-plugin-class-modules";

esbuild.build({
plugins: [
// ...
inlineImage(),
classModules()
]
});
自定义插件

ESbuild 的插件是一个带有 namesetup 函数的对象。name 是一个字符串,表示插件名称,setup 是一个函数,其参数是一个 build 对象。

export interface Plugin {
name: string
setup: (build: PluginBuild) => (void | Promise<void>)
}

build 对象上暴露了 5 个钩子函数:onStartonResolveonLoadonEndonDispose

基本钩子

onStartonEnd 两个钩子在构建开始和结束时执行一些自定义的逻辑。onDispose 在构建完全结束后执行一些清理任务。这三个钩子函数都有一个回调函数作为参数,其中 onEnd 的回调函数可以获取 ESbuild 执行之后的返回值。

const customPlugin = () => ({
name: "custom-plugin",
setup(build) {
// build options
console.log(build.initialOptions)

build.onStart(() => {
// start code
});

build.onEnd((result) => {
// end code
});

build.onDispose(() => {
// clear code
});
}
});

export default customPlugin;
build.initialOptions (构建配置)
{
absWorkingDir: '/Users/jason/code/esbuild-demo',
platform: 'browser',
format: 'iife',
assetNames: 'assets/[name]-[hash]',
treeShaking: true,
tsconfig: './tsconfig.json',
logLevel: 'info',
publicPath: '/',
entryPoints: [ 'src/app.tsx', 'src/index.html' ],
bundle: true,
minify: false,
sourcemap: true,
target: [ 'es2020', 'chrome58', 'firefox57', 'safari11' ],
metafile: true,
outdir: './dist/',
loader: { '.html': 'copy', '.svg': 'dataurl', '.png': 'file' },
plugins: [ { name: 'custom-plugin', setup: [Function: setup] } ]
}
onEnd 回调函数参数
{
errors: [],
warnings: [],
outputFiles: undefined,
metafile: {
// 输入资源
inputs: {
'node_modules/react/cjs/react.development.js': [Object],
'node_modules/react/index.js': [Object],
'node_modules/scheduler/cjs/scheduler.development.js': [Object],
'node_modules/scheduler/index.js': [Object],
'node_modules/react-dom/cjs/react-dom.development.js': [Object],
'node_modules/react-dom/index.js': [Object],
'node_modules/react-dom/client.js': [Object],
'src/components/comp.css': [Object],
'node_modules/react/cjs/react-jsx-runtime.development.js': [Object],
'node_modules/react/jsx-runtime.js': [Object],
'src/components/Comp1.tsx': [Object],
'src/assets/react.svg': [Object],
'src/components/comps.module.css': [Object],
'src/components/Comp2.tsx': [Object],
'src/style.css': [Object],
'src/assets/logo192.png': [Object],
'src/app.tsx': [Object],
'src/index.html': [Object]
},
// 输出资源
outputs: {
'dist/assets/logo192-3BFQN3OB.png': [Object],
'dist/app.js.map': [Object],
'dist/app.js': [Object],
'dist/app.css.map': [Object],
'dist/app.css': [Object],
'dist/index.html': [Object]
}
},
mangleCache: undefined
}
onResolve

onResolve 在 ESbuild 构建的每个模块中的每个导入路径上运行,该回调可以自定义 ESbuild 如何进行路径解析。

export interface OnResolveOptions {
filter: RegExp
namespace?: string
}

onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) =>
(OnResolveResult | null | undefined | Promise<OnResolveResult | null | undefined>)): void
  • filter 为必传参数,是一个正则表达式,决定了要过滤出的特征文件。
  • namespace 为选填参数,默认为 file,一般在 onResolve 钩子中的回调参数返回 namespace 属性作为标识,可以在 onLoad 钩子中通过 namespace 将模块过滤出来。
const myPlugin = () => {
return {
name: 'my-plugin',
setup(build) {
build.onResolve({ filter: /.*/ }, args => {
console.log(args);
})
},
}
}

onResolve 的第二参数是一个回调函数,接收 args 作为参数,一般如果要操作 onResolve 钩子函数,一般都是基于这个参数。

{
// 模块路径
path: 'react-dom/client',
// 父模块路径
importer: '/xxx/work/demo/esbuild-demo/src/app.tsx',
// namespace 标识,默认为file
namespace: 'file',
// 基准路径
resolveDir: '/xxx/work/demo/esbuild-demo/src',
// 导入方式,如 import-statement、require-call、entry-point
kind: 'import-statement',
// 额外绑定的插件数据
pluginData: undefined
}

回调函数也可以有返回值:

{
// 错误信息
errors: [],
// 将其设置为true将模块标记为external,这意味着它将不会包含在捆绑包中,而是会在运行时导入
external: false;
// namespace 标识
namespace: 'xxx';
// 模块路径,如果要设置path,如果要和onLoad结合使用,要处理为绝对路径
path: args.path,
// 额外绑定的插件数据
pluginData: null,
// 插件名称
pluginName: 'xxx',
// 设置为 false,如果模块没有被用到,模块代码将会在产物中会删除。否则不会这么做
sideEffects: false,
// 添加一些路径后缀,如`?xxx`
suffix: '?xxx',
// 警告信息
warnings: [],
// 仅仅在 Esbuild 开启 watch 模式下生效
// 告诉 Esbuild 需要额外监听哪些文件/目录的变化
watchDirs: [],
watchFiles: []
}
onLoad

onLoad 钩子函数的返回模块的内容并告诉 ESbuild 如何解释它。

onLoad(options: OnLoadOptions, callback: (args: OnLoadArgs) =>
(OnLoadResult | null | undefined | Promise<OnLoadResult | null | undefined>)): void

export interface OnLoadOptions {
filter: RegExp
namespace?: string
}

export interface OnLoadArgs {
// 正则之后获取的路径,一般是绝对路径
path: string
// 命名空间
namespace: string
// 后缀信息
suffix: string
// 额外的插件数据
pluginData: any
}

export interface OnLoadResult {
// 插件名称
pluginName?: string

// 错误信息
errors?: PartialMessage[]
// 警告信息
warnings?: PartialMessage[]

// 模块具体内容
contents?: string | Uint8Array
// 指定 loader,如`js`、`ts`、`jsx`、`tsx`、`json`等等
loader?: Loader

// 基准路径
resolveDir?: string
// 额外的插件数据
pluginData?: any

// 仅仅在 Esbuild 开启 watch 模式下生效
// 告诉 Esbuild 需要额外监听哪些文件/目录的变化
watchFiles?: string[]
watchDirs?: string[]
}
运行机制

build 对象上的钩子函数,其实就是 ESbuild 构建过程中的几个阶段需要去扩展执行的内容。

ESbuild运行机制