esbuild 深入:为什么它比 Webpack 快 100 倍

3185 字
16 分钟
esbuild 深入:为什么它比 Webpack 快 100 倍

esbuild 深入:为什么它比 Webpack 快 100 倍#

esbuild 在 2020 年横空出世,以惊人的构建速度震撼了前端社区。它比 Webpack 快 10-100 倍,比 Rollup 快 10-50 倍。这不是增量优化,而是数量级的碾压。本文将深入分析 esbuild 为何如此之快,从架构设计到实现细节,理解其背后的工程哲学。

一、性能数据说话#

以一个包含 1000+ 模块的典型项目为例(three.js 完整构建):

工具构建时间倍数
esbuild0.33s1x
esbuild (1 thread)1.02s3x
Rollup + terser32.07s97x
Parcel 233.28s101x
Webpack 541.53s126x

这不是 benchmark 作弊——esbuild 在真实项目中的表现同样惊人。Vite 之所以能提供闪电般的开发体验,其依赖预构建(pre-bundling)正是依靠 esbuild。

二、核心原因分析#

2.1 用 Go 而不是 JavaScript#

这是最根本的原因。JavaScript 打包工具用 JavaScript 写(Webpack、Rollup),而 esbuild 用 Go 写。

为什么 Go 比 JS 快?

JavaScript (V8):
源码 → 解析 → 字节码 → JIT 编译 → 机器码
- 动态类型:运行时类型检查
- GC 压力:频繁创建/销毁对象
- 单线程:受限于事件循环
Go:
源码 → 编译 → 机器码
- 静态类型:编译时确定
- 值类型:减少堆分配
- 原生多线程:goroutine 并发

关键差距:

  1. 无 JIT 预热:Go 编译为原生机器码,没有 V8 的预热开销
  2. 内存布局:Go 的 struct 是值类型,内存连续分配,CPU 缓存友好
  3. GC 压力:Go 的 GC 比 V8 轻量,且可以利用值类型减少堆分配
  4. 并发:Go 的 goroutine 比 JS 的 Worker 轻量 1000 倍

2.2 极致的并行化#

esbuild 的解析、链接、代码生成全部是并行的:

传统打包器(串行):
文件A解析 → 文件B解析 → 文件C解析 → 链接 → 代码生成A → 代码生成B → 代码生成C
esbuild(并行):
┌─ 文件A解析 ─┐
├─ 文件B解析 ─┤──→ 并行链接 ──→ ┌─ 代码生成A ─┐
└─ 文件C解析 ─┘ ├─ 代码生成B ─┤──→ 合并输出
└─ 代码生成C ─┘

Go 的 goroutine 让这种并行几乎零成本:

// esbuild 内部的并行解析(简化)
func (b *Bundle) parseFiles(entryPoints []string) {
var wg sync.WaitGroup
results := make(chan parseResult, len(entryPoints))
for _, entry := range entryPoints {
wg.Add(1)
go func(path string) {
defer wg.Done()
// 每个文件在独立 goroutine 中解析
ast := b.parseFile(path)
results <- parseResult{path: path, ast: ast}
}(entry)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
b.files[result.path] = result.ast
}
}

esbuild 甚至可以在解析过程中发现新的 import,立即启动新的 goroutine 去解析被引用的文件,形成一个动态的并行解析图。

2.3 从零手写一切#

Webpack 和 Rollup 依赖大量第三方库:

Webpack 依赖链:
webpack → acorn (解析器) → terser (压缩) → source-map (映射)
每个库都有自己的 AST 格式,需要反复转换
esbuild:
所有功能都是从零实现的:
- 自己的 JS/TS/CSS 解析器
- 自己的 AST
- 自己的代码生成器
- 自己的 source map 实现
- 自己的压缩器

好处是:

  1. 零 AST 转换开销:解析器直接生成 esbuild 的内部 AST,代码生成器直接消费它,中间不需要任何转换
  2. 内存局部性:所有数据结构都是为 esbuild 的工作流定制的
  3. 零序列化开销:不需要在不同库之间传递数据

2.4 AST 直出(Streaming)#

传统打包器的流程:

源码 → 字符串 → Token 流 → AST → 遍历 → 修改 → 序列化 → 字符串 → Token 流 → AST → ... → 最终字符串

每一步都涉及内存分配和数据转换。esbuild 则是:

源码 → Token 流 → AST → 一次遍历完成所有转换 → 直接生成输出字符串

esbuild 在一次 AST 遍历中同时完成:

  • TypeScript 类型擦除
  • JSX 转换
  • 目标语法降级
  • Tree shaking 标记
  • 代码压缩
// 简化的一次遍历示例
func (p *printer) printExpr(expr Expr) {
switch e := expr.Data.(type) {
case *EBinary:
// 常量折叠(压缩)
if result, ok := foldBinary(e); ok {
p.printExpr(result)
return
}
p.printExpr(e.Left)
p.print(e.Op.String())
p.printExpr(e.Right)
case *ECall:
// 同时检查是否可以 tree shake
if p.isUnused(expr) && p.canTreeShake(e) {
return // 跳过死代码
}
p.printExpr(e.Target)
p.print("(")
for i, arg := range e.Args {
if i > 0 { p.print(",") }
p.printExpr(arg)
}
p.print(")")
}
}

2.5 高效的内存管理#

esbuild 对内存使用做了极致优化:

字符串驻留(String Interning)

// 所有相同的标识符只存储一份
type Ref struct {
SourceIndex uint32
InnerIndex uint32
}
// 而不是存储字符串本身
// 这极大减少了内存使用和比较开销

紧凑的 AST 表示

// esbuild 的 AST 节点使用紧凑的 tagged union
type Expr struct {
Loc Loc
Data E // 接口类型,但实际类型在编译时确定
}
// 而不是像 Babel 那样每个节点都是一个大对象
// {
// type: "BinaryExpression",
// start: 0,
// end: 10,
// left: { ... },
// right: { ... },
// operator: "+",
// // ... 更多元数据
// }

避免不必要的分配

// 使用 arena 分配器,批量分配/释放
type Arena struct {
chunks [][]byte
offset int
}
func (a *Arena) Alloc(size int) []byte {
// 从预分配的大块内存中切片
// 而不是每次都调用 malloc
}

三、esbuild 的使用#

3.1 基础构建#

build.mjs
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
platform: 'browser',
target: ['es2020'],
format: 'esm',
minify: true,
sourcemap: true,
treeShaking: true,
});

3.2 多入口与代码分割#

await esbuild.build({
entryPoints: ['src/app.ts', 'src/worker.ts'],
bundle: true,
outdir: 'dist',
splitting: true, // 开启代码分割
format: 'esm', // 代码分割需要 ESM 格式
chunkNames: 'chunks/[name]-[hash]',
});

3.3 插件系统#

esbuild 的插件 API 简洁但强大:

svg-plugin.mjs
import { readFile } from 'fs/promises';
const svgPlugin = {
name: 'svg',
setup(build) {
// 拦截 .svg 导入的解析
build.onResolve({ filter: /\.svg$/ }, (args) => ({
path: require.resolve(args.path, { paths: [args.resolveDir] }),
namespace: 'svg',
}));
// 加载 SVG 文件为 data URL
build.onLoad({ filter: /.*/, namespace: 'svg' }, async (args) => {
const svg = await readFile(args.path, 'utf8');
const base64 = Buffer.from(svg).toString('base64');
return {
contents: `export default "data:image/svg+xml;base64,${base64}"`,
loader: 'js',
};
});
},
};
await esbuild.build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [svgPlugin],
});

3.4 Watch 模式与开发服务器#

// 带有 live reload 的开发服务器
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outdir: 'dist',
sourcemap: true,
});
// 启动开发服务器
const { host, port } = await ctx.serve({
servedir: 'dist',
port: 3000,
});
console.log(`Server running at http://${host}:${port}`);
// 启动 watch 模式
await ctx.watch();
console.log('Watching for changes...');

3.5 Transform API#

不需要文件系统,直接转换代码字符串:

import * as esbuild from 'esbuild';
// TypeScript → JavaScript
const result = await esbuild.transform(`
const greet = (name: string): string => {
return \`Hello, \${name}!\`;
};
export default greet;
`, {
loader: 'ts',
target: 'es2015',
minify: true,
});
console.log(result.code);
// var e=e=>\"Hello, \"+e+\"!\";export{e as default};
// JSX → JavaScript
const jsxResult = await esbuild.transform(`
const App = () => <div class="app">Hello</div>;
`, {
loader: 'jsx',
jsxFactory: 'h',
jsxFragment: 'Fragment',
});
console.log(jsxResult.code);
// const App = () => h("div", { class: "app" }, "Hello");

四、esbuild vs 其他工具深度对比#

4.1 esbuild vs Webpack#

esbuild Webpack
语言 Go JavaScript
解析器 自研 acorn
压缩器 自研 terser (JS 实现)
AST 转换次数 1 次 多次(loader 链)
并行度 全流程并行 有限并行
插件生态 较少 极其丰富
HMR 基础 成熟
代码分割 基础 高级
配置复杂度 简单 复杂
适用场景 库打包、预构建 复杂应用

Webpack 的 loader 链机制是其灵活性的来源,但也是性能瓶颈:

// Webpack: 每个 loader 都要解析和序列化 AST
module: {
rules: [
{
test: /\.ts$/,
use: [
'babel-loader', // 解析 → AST → 转换 → 序列化
'ts-loader', // 解析 → AST → 转换 → 序列化
]
}
]
}
// 同一个文件被解析了多次!
// esbuild: 一次解析,一次输出
// TypeScript 解析、转换、压缩在一次遍历中完成

4.2 esbuild vs Rollup#

esbuild Rollup
Tree shaking 基础 高级(更精确)
代码分割 基础 灵活
输出格式 ESM/CJS/IIFE ESM/CJS/IIFE/UMD/AMD
插件 API 简洁 丰富
构建速度 极快 中等
Bundle 体积 稍大 更小
适用场景 速度优先 体积优先

Rollup 的 tree shaking 更精确,因为它在 AST 层面做了更深入的分析:

// Rollup 能检测到这个副作用并保留
export const result = sideEffect();
// esbuild 在某些边界情况下可能不够精确
// 但对于绝大多数代码来说差异不大

4.3 esbuild vs SWC#

SWC 是用 Rust 写的 JS/TS 编译器,它和 esbuild 的定位不完全相同:

esbuild SWC
语言 Go Rust
定位 打包器 + 编译器 编译器 + 压缩器
打包能力 ✅ 完整 ⚠️ 实验性 (swcpack)
编译速度 极快 极快(略快于 esbuild)
插件 (Rust) ❌ ✅ (Wasm 插件)
插件 (JS) ✅ ✅
Babel 兼容 ❌ 部分兼容

SWC 在纯编译(transpile)场景下可能比 esbuild 略快,因为 Rust 的零成本抽象和更精细的内存控制。但 esbuild 作为完整的打包器,综合能力更强。

五、esbuild 的局限性#

5.1 不支持的特性#

// ❌ 不支持 TypeScript 类型检查
// esbuild 只做类型擦除,不做类型检查
// 需要单独运行 tsc --noEmit
// ❌ 不支持 decorator metadata(旧版装饰器)
// 新版 TC39 装饰器已支持
// ❌ 有限的 CSS Modules 支持
// 基础的 CSS 打包没问题,但高级 CSS 特性支持有限
// ❌ 没有 HMR 开箱即用
// 需要自己实现或使用 Vite

5.2 Tree Shaking 的局限#

// esbuild 可能无法 shake 掉的情况
// 1. 带副作用的模块
import './polyfill'; // esbuild 不会移除,即使没使用任何导出
// 2. IIFE 中的副作用
const result = (() => {
// esbuild 可能认为这有副作用而保留
return { value: 42 };
})();
// 3. 类的静态属性
class MyClass {
static instance = new MyClass(); // 有副作用,不会被移除
}
// 可以用注释标记为纯函数
const result = /* @__PURE__ */ createSomething();

5.3 插件生态较少#

相比 Webpack 和 Rollup 丰富的插件生态,esbuild 的插件数量有限。这也是为什么 Vite 选择 Rollup 作为生产构建工具,而仅在开发时用 esbuild 做预构建。

六、esbuild 在 Vite 中的角色#

Vite 巧妙地结合了 esbuild 和 Rollup:

开发模式:
依赖预构建:esbuild(快速把 CJS 转 ESM,合并细碎模块)
源码编译:esbuild(TS/JSX → JS)
模块服务:Vite Dev Server(按需编译,不打包)
生产构建:
代码打包:Rollup(更精确的 tree shaking,更灵活的代码分割)
代码压缩:esbuild(默认)或 terser(可选,更小但更慢)
vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// 控制 esbuild 行为
esbuild: {
target: 'es2020',
// JSX 配置
jsxFactory: 'h',
jsxFragment: 'Fragment',
// 生产环境移除 console
drop: ['console', 'debugger'],
},
// 依赖预构建配置
optimizeDeps: {
include: ['lodash-es', 'axios'],
exclude: ['@my/local-package'],
esbuildOptions: {
target: 'es2020',
plugins: [/* esbuild 插件 */],
},
},
build: {
// 压缩器选择
minify: 'esbuild', // 默认,快
// minify: 'terser', // 更小,慢
target: 'es2020',
},
});

七、自己动手:用 esbuild 搭建构建流程#

7.1 完整的库构建脚本#

scripts/build.mjs
import * as esbuild from 'esbuild';
import { execSync } from 'child_process';
import { rmSync } from 'fs';
// 清理
rmSync('dist', { recursive: true, force: true });
// 共享配置
const shared = {
entryPoints: ['src/index.ts'],
bundle: true,
sourcemap: true,
target: ['es2020', 'node18'],
external: [
// 不打包 peer dependencies
'vue',
'vue-router',
],
};
// ESM 构建
await esbuild.build({
...shared,
format: 'esm',
outfile: 'dist/index.mjs',
// ESM 特定优化
splitting: false,
});
// CJS 构建
await esbuild.build({
...shared,
format: 'cjs',
outfile: 'dist/index.cjs',
});
// 压缩版本(用于 CDN)
await esbuild.build({
...shared,
format: 'iife',
globalName: 'MyLib',
outfile: 'dist/index.global.js',
minify: true,
define: {
'process.env.NODE_ENV': '"production"',
},
});
// 生成类型声明(esbuild 不处理 .d.ts)
execSync('tsc --emitDeclarationOnly --declaration --outDir dist/types', {
stdio: 'inherit',
});
console.log('Build complete!');

7.2 开发脚本#

scripts/dev.mjs
import * as esbuild from 'esbuild';
const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
format: 'esm',
outfile: 'dist/index.mjs',
sourcemap: true,
plugins: [{
name: 'rebuild-notify',
setup(build) {
let count = 0;
build.onEnd(result => {
if (result.errors.length > 0) {
console.error(`Build failed with ${result.errors.length} errors`);
} else {
console.log(`Build #${++count} succeeded [${new Date().toLocaleTimeString()}]`);
}
});
},
}],
});
await ctx.watch();
console.log('Watching for changes...');
// 优雅退出
process.on('SIGINT', async () => {
await ctx.dispose();
process.exit(0);
});

八、性能优化技巧#

8.1 减少解析范围#

await esbuild.build({
// 明确标记外部依赖
external: ['*.node', 'fsevents'],
// 使用 packages 选项自动外部化所有 node_modules
packages: 'external', // Node.js 库推荐
// 精确的入口点
entryPoints: {
'index': 'src/index.ts',
'utils': 'src/utils/index.ts',
},
});

8.2 利用增量构建#

const ctx = await esbuild.context({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
// 增量构建会缓存 AST 和中间结果
// 后续 rebuild 只处理变更的文件
});
// 首次构建
await ctx.rebuild(); // ~500ms
// 修改一个文件后
await ctx.rebuild(); // ~50ms(增量)
await ctx.dispose();

8.3 合理使用 define#

await esbuild.build({
define: {
'process.env.NODE_ENV': '"production"',
'__DEV__': 'false',
'import.meta.env.MODE': '"production"',
},
// define 在解析阶段就替换,让 tree shaking 更有效
// if (__DEV__) { ... } → if (false) { ... } → 被移除
});

九、总结#

esbuild 快的核心原因:

  1. Go 语言:编译型语言,原生机器码,无 JIT 预热
  2. 极致并行:goroutine 实现全流程并行
  3. 从零实现:自研解析器、压缩器、sourcemap,零转换开销
  4. 一次遍历:AST 直出,在一次遍历中完成所有转换
  5. 内存优化:字符串驻留、紧凑 AST、arena 分配

esbuild 改变了前端工具链的性能预期。即使你不直接使用 esbuild,它的设计理念也影响了整个生态:Vite 用它做预构建,Bun 用类似的策略重写了 bundler,Rspack 用 Rust 重写 Webpack。

速度不是功能,速度就是体验。 当构建从 30 秒变成 0.3 秒,你的开发方式会发生根本性的改变。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

esbuild 深入:为什么它比 Webpack 快 100 倍
https://boke.hackerdream.xyz/posts/esbuild-deep-dive/
作者
晴天
发布于
2026-03-12
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录