esbuild 原理与速度革命

esbuild 原理与速度革命

写在前面

上一篇讲了 webpack 的辉煌与瓶颈。这一篇讲 esbuild——它重新定义了"快"的含义,从 webpack 的 60 秒压到 0.5 秒,让前端工程界第一次认真思考:构建工具的天花板可以重写。

本篇会深入 esbuild 的核心设计:为什么 Go 写的编译器能比 webpack 快 100 倍,以及 esbuild 的局限决定了它不会完全替代 webpack。

一、esbuild 是什么

1.1 一句话定义

esbuild 是一个用 Go 语言写的 JavaScript/TypeScript 打包器,主打极致的构建速度。

由 Figma 的 CTO Evan Wallace 一个人业余时间写的(2020 年开源),完全颠覆了构建工具的认知。

1.2 性能对比

同样的项目,各工具的构建时间(越小越好):

esbuild:   ████ 0.5s
swc:       ████████ 1.5s
parcel:    ███████████████ 5s
rollup:    ████████████████████████ 10s
webpack:   ████████████████████████████████████████████████████████ 60s

100 倍速度差。

二、为什么这么快

esbuild 的快来自多个层面的优化叠加。

2.1 编程语言:Go

JS 是解释型语言(虽然有 V8 JIT),Go 是编译型 + 静态类型 + 垃圾回收:

维度 Node.js (JS) Go
启动时间 100ms 5ms
内存效率 一般(对象头 16-24 字节) 高(对象头 8-16 字节)
并发 单线程 + 事件循环 多线程 + goroutine
类型 动态 静态
性能(计算密集) 慢 快 10-100x

同样的代码逻辑,Go 比 Node.js 快 10-100 倍(计算密集场景)。

2.2 并行处理

esbuild 把能并行的任务都并行:

// 简化的伪代码
func build() {
    var wg sync.WaitGroup
    jobs := make(chan string)

    // 启动多个 worker
    for i := 0; i < runtime.NumCPU(); i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for file := range jobs {
                parse(file)  // 每个 worker 处理一个文件
            }
        }()
    }

    // 派发所有 JS 文件
    for _, file := range allJSFiles {
        jobs <- file
    }
    close(jobs)
    wg.Wait()
}

8 核 CPU,理论 8 倍速度。

webpack 是单线程,Node.js 的 worker_threads 用得很少。

2.3 内存中表示所有内容

webpack 在编译过程中,把 AST 转成字符串再解析,反复 parse/unparse。

esbuild 全程在内存中表示 AST:

JS 源码 → Lexer → Token → Parser → AST(内存)
                                          ↓
                                       Linker(内存)
                                          ↓
                                       Printer → 目标代码

整个过程只 parse 一次,只 generate 一次,中间没有任何字符串转换。

2.4 精心设计的数据结构

// esbuild 的 AST 节点(简化)
type ASTNode struct {
    Kind        uint8   // 节点类型(用 uint8 而不是 string)
    Flags       uint16
    NumRef      uint32
    Ref         Ref
    Data        interface{}
}

紧凑的字段布局,缓存友好。

而 webpack 用的 JS 对象:

{
    type: 'CallExpression',
    callee: { type: 'Identifier', name: 'foo' },
    arguments: [...],
}

每个对象 100+ 字节,GC 压力大。

2.5 不做的事(同样重要)

esbuild 故意不做这些事情,换速度:

不做 TS 类型检查

// esbuild 不报这个错
function add(a: number, b: string): number {
    return a + b;  // TS:类型错误,esbuild 静默通过
}

类型检查交给 IDE(tsc –noEmit)。

不打 bundle(默认)

esbuild app.js
# 输出:app.js(单文件,不打包)

要做 bundle 必须显式 --bundle。

不生成 source map(默认)

esbuild app.js --sourcemap

默认关闭,需要时开启。

不做复杂的 chunk 分割

简单的代码分割,不做复杂的 optimization。

不做 CSS Modules / PostCSS 复杂处理

简单 CSS 支持,PostCSS 插件需要 fork。

2.6 性能数据

来看 Evan Wallace 的官方 benchmark:

场景 esbuild webpack 5 swc
Three.js 构建 0.12s 22.3s 1.49s
Vue 3 构建 0.04s 6.27s 0.51s
整个 lodash 构建 0.04s 1.31s 0.21s

10-100 倍速度差。

三、esbuild 的 API

3.1 CLI

# 单文件转换
esbuild src/index.ts --outfile=dist/bundle.js

# 打包
esbuild src/index.ts --bundle --outfile=dist/bundle.js

# 监听
esbuild src/index.ts --bundle --watch

# 启用 source map
esbuild src/index.ts --bundle --sourcemap

# 压缩
esbuild src/index.ts --bundle --minify

# 多入口
esbuild src/entry1.ts src/entry2.ts --bundle --outdir=dist

# 平台
esbuild src/index.ts --bundle --platform=browser --format=esm

3.2 JS API

import * as esbuild from 'esbuild';

const result = await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    outfile: 'dist/bundle.js',
    minify: true,
    sourcemap: true,
    target: ['es2020'],
    platform: 'browser',
    format: 'esm',
    loader: {
        '.png': 'file',
        '.svg': 'text',
        '.txt': 'text',
    },
    define: {
        'process.env.NODE_ENV': '"production"',
    },
});

console.log(result.errors);
console.log(result.warnings);

3.3 监听模式

const ctx = await esbuild.context({
    entryPoints: ['src/index.ts'],
    bundle: true,
    outfile: 'dist/bundle.js',
});

await ctx.watch();

// 重新构建
const result = await ctx.rebuild();

// 释放资源
await ctx.dispose();

3.4 Server 模式

const ctx = await esbuild.context({
    entryPoints: ['src/index.ts'],
    bundle: true,
    outfile: 'dist/bundle.js',
});

const { host, port } = await ctx.serve({
    servedir: 'dist',
    port: 3000,
});

console.log(`Server running at http://${host}:${port}`);

四、esbuild 的核心 API 设计

4.1 内置 Loader

Loader 用途
js JavaScript
jsx React JSX
ts TypeScript
tsx TypeScript + JSX
css CSS
json JSON
text 转字符串
base64 转 base64
file 输出文件,返回 URL
binary 转 Uint8Array
dataurl 转 data URL
copy 拷贝到 outdir
empty 输出空文件
{
    loader: {
        '.png': 'file',
        '.svg': 'dataurl',      // SVG 转 base64
        '.txt': 'text',
        '.woff2': 'file',
    },
}

4.2 内置 Plugin 接口

esbuild 的插件 API 比 webpack 简单得多:

const myPlugin = {
    name: 'my-plugin',
    setup(build) {
        // 解析阶段
        build.onResolve({ filter: /^virtual:/ }, args => ({
            path: args.path,
            namespace: 'virtual',
        }));

        // 加载阶段
        build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => ({
            contents: `export default ${JSON.stringify(args.path)};`,
        }));
    },
};

await esbuild.build({
    plugins: [myPlugin],
});

4.3 实战插件:解析 .env 文件

const dotenvPlugin = {
    name: 'dotenv',
    setup(build) {
        build.onResolve({ filter: /^\.env$/ }, args => ({
            path: args.path,
            namespace: 'env',
        }));

        build.onLoad({ filter: /.*/, namespace: 'env' }, () => {
            const env = {};
            const fs = require('fs');
            const content = fs.readFileSync('.env', 'utf8');
            content.split('\n').forEach(line => {
                const [k, ...v] = line.split('=');
                if (k && v.length) {
                    env[k.trim()] = v.join('=').trim();
                }
            });
            return {
                contents: `export default ${JSON.stringify(env)};`,
            };
        });
    },
};

五、esbuild 的局限

esbuild 不是银弹,有明确的边界。

5.1 不做 TS 类型检查

类型错误不报错,只删类型注解。

// tsc 报错,esbuild 通过
function add(a: number, b: string) {
    return a + b;
}

生产项目必须搭配 tsc:tsc --noEmit && esbuild。

5.2 不做复杂的代码分割

简单的 splitChunks,复杂的分包逻辑没有。

5.3 CSS 处理有限

5.4 不支持 HMR 完整的开发体验

esbuild 提供 serve 模式,但 HMR 支持简单——没有 Vite 那么丝滑。

5.5 插件生态不成熟

相比 webpack 几千个插件,esbuild 的插件生态还在早期。

六、esbuild 在工程中的角色

6.1 作为底层编译器

Vite 用 esbuild 做依赖预打包:

// Vite 用 esbuild 把 CommonJS 转成 ESM
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    optimizeDeps: {
        esbuildOptions: {
            target: 'es2020',
        },
    },
});

Vite 启动时用 esbuild 把 node_modules 里的 CommonJS 依赖转成 ESM,加速 dev 启动。

6.2 作为 JS/TS 转译器

很多项目的 CI 流程:

- name: Build
  run: |
    npx tsc --noEmit          # 类型检查
    npx esbuild src/index.ts \
      --bundle \
      --minify \
      --sourcemap \
      --outfile=dist/bundle.js    

6.3 作为小型工具

写个 CLI 工具:

// build.js
import * as esbuild from 'esbuild';
import { readFileSync } from 'fs';

await esbuild.build({
    stdin: {
        contents: readFileSync('src/index.ts', 'utf8'),
        resolveDir: 'src',
        sourcefile: 'index.ts',
    },
    bundle: true,
    outfile: 'dist/bundle.js',
});

七、esbuild 之外的 Go 系工具

7.1 swc

swc 是另一款 Rust 写的 JS/TS 转译器(注意,Rust 不是 Go)。

// .swcrc
{
    "jsc": {
        "parser": {
            "syntax": "typescript",
            "tsx": true
        },
        "target": "es2020"
    }
}

7.2 Bun

Bun 用 Zig 写,全栈运行时 + 打包器:

八、esbuild 在我们项目的应用

公司项目用 esbuild 做两件事:

8.1 工具链:CLI 打包

// scripts/build.js
import * as esbuild from 'esbuild';

await esbuild.build({
    entryPoints: ['src/cli.ts'],
    bundle: true,
    platform: 'node',
    target: 'node18',
    outfile: 'dist/cli.js',
    external: ['fsevents', 'aws-sdk'],
});

8.2 库代码转译

// scripts/build-lib.js
await esbuild.build({
    entryPoints: ['src/index.ts'],
    bundle: true,
    format: 'esm',
    target: 'es2020',
    outfile: 'dist/index.mjs',
    sourcemap: true,
});

九、本篇小结

优化点 效果
Go 语言 10-100x 计算速度
并行处理 多核 CPU 充分利用
内存 AST 不反复 parse
故意不做的事 类型检查、复杂分割、PostCSS
紧凑数据结构 缓存友好,GC 压力小
局限 应对
不做类型检查 配合 tsc --noEmit
不做复杂分包 用 Vite / Rsbuild
CSS 支持有限 外部预处理器

下一篇讲 Vite 双引擎与 ESM 魔法——开发体验的天花板是怎么做到的。

参考资料