esbuild:Go 原生转换器与打包器的能力边界
核验日期:2026-07-09。示例版本来自
examples/build-tools/package.json:esbuild0.28.1。esbuild 仍是 0.x 主线,本文按官方 API 和 FAQ 说明稳定行为,不把 0.x 简化为“不能生产使用”。
需要掌握的问题
| 主题 | 读者必须回答的问题 | 原理与边界 | 高频错误 | 代码或观察证据 | 来源 |
|---|---|---|---|---|---|
| 定位与固定成本 | esbuild 在构建链路里负责哪几段? | Go 原生实现把 parse、link、code generation、minify 和部分 bundling 集成在一个工具里;快来自并行和少做不必要工作,不等于功能覆盖所有上层工具 | 把 esbuild 当 Vite/webpack 的完整替代,忽略 HTML、HMR、框架插件和类型检查 | 共享示例的 Build API 配置和 benchmark 分组 | esbuild API |
Transform API | 什么时候只需要把一段字符串转换成 JS? | Transform API 接收内存字符串,隔离于文件系统,不解析依赖,不能 bundle,也不能用 plugins | 用 Transform API 期待解析 import、处理 CSS 或调用 onResolve | benchmark.mjs 中 esbuild.transform(src/*.ts) | Transform API |
Build API | 什么时候需要 entry、依赖图、bundle 和产物文件? | Build API 读取 entry points,按 loader/platform/format/target 解析、链接和输出,支持 bundle、splitting、metafile、plugins 与 context | 忘记 bundle: true 却期待依赖被内联;platform 写错导致条件导出错 | examples/build-tools/configs/esbuild.config.mjs | Build API |
| loader、platform、format、target | 这些选项分别改变什么? | loader 决定输入类型,platform 决定解析条件和内置变量,format 决定输出模块形式,target 决定语法降级边界 | 以为 target 自动补 polyfill;把 browser 平台构建成 node 条件 | .css loader、platform: 'browser'、format: 'esm'、target 示例 | Content Types |
| syntax transform 与类型检查 | TypeScript/JSX 处理到什么程度? | esbuild 能删除 TS 类型并转换受支持语法;不会执行 TypeScript 类型检查,也不会按 tsc 完整语义报类型错误 | 构建成功后上线运行时错误;把 tsconfig 当类型检查入口 | catalog.ts 类型被删除,artifact test 只验证输出形态 | TypeScript content type |
| Tree Shaking 与 code splitting | 未使用导出和动态导入如何进入产物? | esbuild 在 bundling 模式下基于 ESM 做 Tree Shaking;splitting: true 目前要求 format: 'esm' 并输出到 outdir | CommonJS 依赖无法理想 tree shake;HTML 直接加载 async chunk | metafile.json 证明 detail.ts 是 dynamic-import | Tree shaking |
metafile 与分析 | 怎样证明产物结构,而不是只看退出码? | metafile: true 输出 inputs/outputs/imports/bytes,适合做产物断言和大小分析 | 手写 hash 文件名、只看 dist 文件列表、忽略 dynamic import 边 | artifact test 读取 dist/esbuild/metafile.json | Metafile |
plugins:onResolve/onLoad | esbuild 插件能改哪里,不能改哪里? | 插件通过 setup 注册 onResolve、onLoad、onStart、onEnd 等回调;filter/namespace 决定命中范围 | filter 写太宽拖慢构建;插件里做类型检查或复杂全局优化 | 最小虚拟模块插件和边界表 | Plugins |
context/rebuild/watch/serve | 增量、监听和本地服务有什么区别? | context() 保存构建选项和内部状态;rebuild 手动重建,watch 自动重建,serve 提供最近构建结果 | 把 live reload 当 HMR;忘记 dispose();serve 当生产服务器 | context 示例和故障表 | Rebuild, watch, serve |
| 性能与重大变化 | 官方性能数字怎样引用才不误导? | 官方 benchmark 有固定输入、机器和版本;本仓库 benchmark 只说明测量方法,不排名所有项目 | 用一次 wall time 宣称“最快”;混测 cold/hot/cache | benchmark.mjs 的 JSON 输出和官方 FAQ 条件 | esbuild FAQ |
内容边界说明:
| 分类 | 本文落点 |
|---|---|
| 编程语言与类型系统 | 解释 JS/TS/JSX 语法解析、类型删除、target 降级和类型检查边界;不教学 TypeScript 类型系统本身 |
| Browser APIs 与持久化能力 | 覆盖浏览器 ESM、dynamic import、CSS、Source Map、live reload 与静态资源路径;不讨论 IndexedDB、Storage 或权限 API |
| UI 框架与元框架 | 只说明 JSX/loader/插件入口和上层框架工具链边界;组件渲染、水合、状态管理不在本文重复 |
| 构建工具与运行时 | 覆盖输入、parse、link、code generation、bundle、plugins、context、metafile、产物和故障定位 |
| 后端、数据与 Agent 技术 | 只覆盖插件和构建脚本以开发者权限执行、define/env 泄露和 CI 资源边界;请求生命周期、数据库一致性和 Agent 评测不适用 |
esbuild 的主线:少层封装,直接完成常见转换和打包
esbuild 的懒办法是:如果你只需要“把 TS/JSX 转成 JS、快速打一个浏览器 bundle、拿到机器可读产物元数据”,直接用它;如果你需要复杂 HTML 模板、框架 HMR、Module Federation 或上百个生态插件,别把这些能力硬塞给 esbuild。
官方 FAQ 对“为什么快”的解释有两个关键点:esbuild 用 Go 写成并编译为原生代码,命令行启动时不需要像 JS bundler 一样先让 Node 解析 bundler 自己的大量 JavaScript;同时 Go 的共享内存和 goroutine 让它更容易把编译工作铺满 CPU 核心。esbuild 的内部流程可以粗略拆成 parse、link、code generation 三段:parse 和 code generation 占大头且高度可并行,link 主要负责符号绑定和图连接,天然更偏串行。JS API/CLI 调用时,Node 侧只是把 build/transform 请求送进 esbuild 的原生服务进程,真正的解析、链接和代码生成在 Go service 内完成。
和相邻工具的边界:
| 工具 | 更适合 | 不适合 |
|---|---|---|
| esbuild Transform API | 单文件 TS/JSX 转换、快速 minify、内存字符串处理 | 依赖解析、CSS 图、插件、bundle |
| esbuild Build API | 小到中型浏览器 bundle、CLI/脚本化构建、metafile 分析 | 框架开发服务器、复杂生态插件兼容 |
| SWC | 编译转换、宿主工具内核、Rust/Wasm 插件 | 应用级 bundling、HTML/CSS 产物 |
| Vite/Rspack/webpack | 开发服务器、HMR、框架插件、复杂应用构建 | 单纯 transform 场景 |
并行模型的边界:
| 阶段 | 并行性 | 说明 |
|---|---|---|
| parse | 高 | 多个输入文件可并行词法分析、解析、建立局部符号 |
| link | 低到中 | 需要跨模块绑定符号、连接 dependency graph,很多决策依赖全局图 |
| code generation | 高 | 多个 chunk/文件可并行打印代码和 Source Map |
| JS API 调用层 | 低 | Node 只发请求和接收结果,不应把重工作放在插件回调里 |
严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
import 仍留在 Transform 输出里 | Transform API 不 bundle,也不读文件图 | 用最小 transform("import x from './x'") 复现 | 需要依赖图时改 Build API |
老浏览器缺 Promise、Map 或 DOM API | target 只处理语法,不自动补运行时 API | 搜索产物是否有 polyfill 模块 | 应用入口或上层工具显式管理 polyfill |
| 页面点击后 async chunk 404 | HTML 或部署路径没跟 esbuild 输出结构对齐 | 构建后静态预览并点击按钮 | 用 metafile 生成准确 HTML,验证 dynamic import |
Transform API:隔离字符串转换,不看文件系统
官方把 Transform API 定位为 Build API 的受限特例。它接收一段代码字符串,返回转换后的代码和 map。优点是简单、快、可在浏览器 Wasm 版本中使用;代价是它和文件系统断开,不会读取附近 package.json、不会解析 import、不能 bundle、不能用 plugins。
import { transform } from 'esbuild';
const result = await transform('let x: number = 1', {
loader: 'ts',
format: 'esm',
sourcemap: true,
target: 'es2022',
});
console.log(result.code); // let x = 1;本仓库 benchmark 用 Transform API 处理所有 .ts 文件:
const result = await transform(source, {
loader: 'ts',
format: 'esm',
sourcemap: true,
target: 'es2022',
});边界表:
| 能力 | Transform API | Build API |
|---|---|---|
| 输入 | 内存字符串 | entry points、stdin、文件系统 |
| 依赖解析 | 不解析 | 解析并可 bundle |
| plugins | 不支持 | 支持 async API plugins |
| CSS bundle | 不支持 | 支持 loader 与 CSS 输出 |
metafile | 不支持 | 支持 |
| 典型用途 | 编译片段、在线编辑器、minify 单文件 | 应用/库构建、产物分析 |
错误完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
onResolve 从不触发 | Transform API 不能用 plugins | 检查调用的是 transform() 还是 build() | 切换到 Build API |
输出仍有 import './style.css' | Transform 不知道 CSS 文件 | 打印 result.code | 用 Build API 的 loader: { ".css": "css" } |
tsconfig paths 未按预期解析 | Transform 与文件系统隔离 | 最小 transform fixture | 用 Build API 或上层工具解析路径 |
Build API:从 entry 建图、链接、输出
共享示例使用 Build API,因为它需要浏览器 bundle、CSS、Source Map、metafile 和动态导入。
import { build } from 'esbuild';
const result = await build({
entryPoints: ['src/main.ts'],
outdir: 'dist/esbuild/assets',
bundle: true,
splitting: true,
format: 'esm',
platform: 'browser',
sourcemap: true,
minify: true,
metafile: true,
loader: { '.css': 'css' },
});这些选项分别解决不同问题:
| 选项 | 作用 | 错配后表现 |
|---|---|---|
entryPoints | 图入口 | 没有主 JS 或入口不含业务代码 |
bundle: true | 递归内联/链接依赖 | import 留给浏览器,CSS/dynamic 边界不完整 |
splitting: true | 允许 dynamic import 输出 async chunk | detail.ts 被合并或无法形成异步边界 |
format: 'esm' | 输出 ESM,支持 splitting | 非 ESM format 下 code splitting 不成立 |
platform: 'browser' | 使用浏览器条件和内置行为 | 打进 Node-only 入口或 process 泄露 |
loader: { '.css': 'css' } | 把 CSS 作为构建输入 | CSS 没生成 asset |
metafile: true | 产出机器可读图 | 无法自动验证 dynamic import 和大小 |
示例脚本用最少代码生成 HTML,不引入 HTML 插件:
const main = Object.entries(result.metafile.outputs)
.find(([, output]) => output.entryPoint === 'src/main.ts')?.[0];
const css = result.metafile.outputs[main].cssBundle;
await writeFile('dist/esbuild/index.html', `<!doctype html>
<script type="module" src="./${toOutputPath(main)}"></script>
<link rel="stylesheet" href="./${toOutputPath(css)}">
`);这里故意不写通用 HTML 生成器。示例只需要证明 esbuild 产物能被浏览器加载,metafile 已经给了主 JS 和 CSS 路径。
loader、platform、format、target:四个选项别互相替代
这四个选项最容易被混成“兼容配置”:
| 选项 | 输入/输出层 | 解决的问题 | 不解决 |
|---|---|---|---|
loader | 输入解析 | .ts、.tsx、.css、.json 等如何被读取 | 目标浏览器支持范围 |
platform | 模块解析和内置语义 | browser/node/neutral 条件、默认 format、主字段 | polyfill 注入 |
format | 输出模块形式 | esm、cjs、iife | 是否解析依赖图 |
target | 语法转换 | 降级部分 JS/CSS 语法 | 运行时 API、类型检查 |
严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
浏览器报 process is not defined | platform 或条件导出选到 Node 入口 | 搜索产物 process.、检查 package exports | 设置 platform: 'browser',必要时 alias 到 browser 入口 |
| 低版本浏览器仍报缺 API | target 不注入 polyfill | 搜索产物是否有 core-js 或 polyfill | 上层应用统一引入 polyfill |
| 动态分块未出现 | splitting、format、outdir 条件不满足 | 检查 metafile.outputs | 使用 format: 'esm'、splitting: true、outdir |
syntax transform、polyfill 和类型检查是三条线
esbuild 可以把 TypeScript 语法擦除成 JavaScript:
type Tool = { name: string };
export function label(tool: Tool): string {
return tool.name.toUpperCase();
}预期输出要点:
export function label(tool) {
return tool.name.toUpperCase();
}它不会证明调用方传入的对象有 name,也不会给 Promise、Map、Array.prototype.at 注入 polyfill。
| 需求 | esbuild 能做 | 仍需要 |
|---|---|---|
| 删除 TS 类型 | 是,loader 为 ts/tsx | tsc --noEmit 做类型检查 |
| JSX transform | 是,配置 JSX 相关选项 | 框架运行时和插件语义 |
| 语法降级 | 是,按 target | 运行时 API polyfill |
| 类型错误阻塞 CI | 否 | TypeScript checker |
| decorator/emit metadata 等 TS 特性 | 有边界,不等价完整 tsc | 按项目需求保留 tsc 或 Babel/SWC 插件 |
严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
const n: number = "x" 构建通过 | esbuild 转译 TS,不做类型检查 | 单独运行 tsc --noEmit | CI 保留 TypeScript 类型检查 |
老浏览器缺 Array.prototype.at | target 只改语法,不补 API | 浏览器报错或搜索 polyfill | 入口引入 polyfill 或调整兼容策略 |
decorator 行为和 tsc 不同 | 依赖 TS 编译器专有语义 | 最小 decorator fixture 对比 | 保留 tsc/Babel/SWC 对应链路 |
Tree Shaking、code splitting 和 metafile
共享入口里有一个未使用导出和一个必须保留的副作用:
export const removableMarker = 'SHOULD_NOT_REACH_BUNDLE';
globalThis.__BUILD_TOOLS_SIDE_EFFECT__ = 'preserved';Build API 的生产 bundle 应删除前者、保留后者。artifact test 读取所有 JS 验证:
| 观察点 | 预期结果 |
|---|---|
SHOULD_NOT_REACH_BUNDLE | 不出现在 JS 中 |
preserved | 出现在 JS 中 |
detail.ts | 作为 dynamic-import 对应的 async output |
| HTML | 只加载 initial JS/CSS,不直接加载 async chunk |
metafile 是最省事的机器证据。它记录 inputs、outputs、imports、entryPoint、bytes 等信息。共享测试用它判断主产物是否以 dynamic-import 指向 detail.ts 的输出:
const outputs = result.metafile.outputs;
for (const [owner, output] of Object.entries(outputs)) {
if (output.entryPoint !== 'src/main.ts') continue;
console.log(output.imports.filter((item) => item.kind === 'dynamic-import'));
}严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| 未用导出仍在 bundle | 输入不是 ESM、动态访问导出、禁用 minify/tree shaking | rg "SHOULD_NOT_REACH_BUNDLE" dist/esbuild | 保留 ESM,减少动态导出访问 |
| 副作用被删 | 错误标注 sideEffects: false 或 pure 注释过激 | 搜索 preserved | 精确声明副作用文件 |
| async chunk 被 HTML 直接加载 | HTML 生成逻辑误读 metafile outputs | rg "detail" dist/esbuild/index.html | HTML 只引用主 JS 和 CSS,dynamic 由运行时加载 |
plugins:onResolve/onLoad 是边界,不是万能中间层
esbuild 插件通过 setup(build) 注册回调。最常用的是 onResolve 和 onLoad。
const virtualPlugin = {
name: 'virtual-demo',
setup(build) {
build.onResolve({ filter: /^virtual:info$/ }, (args) => ({
path: args.path,
namespace: 'virtual',
}));
build.onLoad({ filter: /.*/, namespace: 'virtual' }, () => ({
contents: 'export const name = "esbuild";',
loader: 'js',
}));
},
};插件设计要点:
| 机制 | 作用 | 常见错误 |
|---|---|---|
filter | 用正则快速筛选请求 | 写成 /.*/ 导致每个路径都进 JS 回调 |
namespace | 把虚拟模块和真实文件隔离 | 所有模块混在 file namespace |
onResolve | 决定路径、external、namespace | 在这里读完整源码 |
onLoad | 返回 contents/loader/watchFiles | 忘记登记外部依赖导致 watch 不更新 |
onEnd | 查看 errors/warnings/metafile | 修改已写产物而不更新 map/hash |
插件边界:
| 能力 | 支持情况 |
|---|---|
| Build API async plugins | 支持 |
| Transform API plugins | 不支持 |
| Build sync API plugins | 不支持 |
| 跨模块高级优化 | 能做一部分,但不如 Rollup/webpack 插件生命周期完整 |
| 安全隔离 | 不提供;插件是构建时代码,能读文件和环境变量 |
严重问题完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| 构建突然很慢 | 插件 filter 过宽,每个路径都进 JS 回调 | 打印命中次数 | 收窄 filter/namespace |
| watch 不响应虚拟模块源文件 | onLoad 读取外部文件但没返回 watchFiles | 修改外部文件看是否 rebuild | 返回 watchFiles/watchDirs |
| CI secret 出现在 bundle | 插件读取 process.env 写入 contents | `rg "sk_ | password |
context/rebuild/watch/serve:live reload 不是 HMR
Build API 适合一次性构建。开发集成通常用 context():
const ctx = await esbuild.context({
entryPoints: ['src/main.ts'],
outdir: 'dist/esbuild/assets',
bundle: true,
});
await ctx.watch();
const result = await ctx.rebuild();
const server = await ctx.serve({ servedir: 'dist/esbuild' });
await ctx.dispose();官方文档把增量相关能力分成三类:watch 自动重建、serve 提供最近构建结果、rebuild 手动重建。watch + serve 可以实现 live reload,但这不是 Vite/webpack 意义上的 HMR。
| 能力 | esbuild | Vite/webpack HMR |
|---|---|---|
| 文件变化后重建 | 支持 watch/rebuild | 支持 |
| 浏览器自动刷新 | 需要 live reload 集成 | 内置或插件支持 |
| 模块级状态保留 | 不提供完整 HMR 协议 | 通过 import.meta.hot 或 runtime |
| 框架边界更新 | 不负责 | 框架插件负责 |
错误完整流程:
| 症状 | 根因 | 观察命令 | 修正 |
|---|---|---|---|
| 修改代码后页面不更新 | 只调用 build,没有 context/watch | 看脚本是否使用 context() | 使用 watch 或上层 dev server |
| 进程退出后端口还占用 | 忘记 ctx.dispose() | lsof -i :<port> | 在 shutdown 钩子里 dispose |
| 团队以为 live reload 会保留状态 | live reload 是整页刷新 | 浏览器状态丢失 | 需要状态保留就用 Vite/webpack HMR |
共享示例:esbuild Build API 构建浏览器入口
运行命令:
pnpm --filter @roadmap/build-tools-slice build:esbuild输入:
examples/build-tools/src/main.ts
examples/build-tools/src/catalog.ts
examples/build-tools/src/detail.ts
examples/build-tools/src/side-effect.ts
examples/build-tools/src/styles.css预期结果:
examples/build-tools/dist/esbuild/index.html
examples/build-tools/dist/esbuild/metafile.json
examples/build-tools/dist/esbuild/assets/*.js
examples/build-tools/dist/esbuild/assets/*.css
examples/build-tools/dist/esbuild/assets/*.js.map
examples/build-tools/dist/esbuild/assets/*.css.map验证观察:
find examples/build-tools/dist/esbuild -type f | sort
node --test --test-name-pattern esbuild examples/build-tools/test/artifacts.test.mjs预期输出要点:
| 观察点 | 预期结果 |
|---|---|
index.html | 通过相对 script type="module" 加载主 JS,通过 link rel="stylesheet" 加载 CSS |
metafile.json | 主 JS 的 imports 以 dynamic-import 指向 detail.ts 产物 |
| JS source map | 主 chunk 指回 main.ts,async chunk 指回 detail.ts |
| CSS source map | 指回 styles.css |
| bundled JS | 包含 Babel 和 preserved,不包含 SHOULD_NOT_REACH_BUNDLE |
性能、迁移和重大变化:只引用有条件的数字
esbuild 的官方 FAQ 解释了它为什么快,也给了 benchmark details。引用这类数字时必须保留条件:输入规模、机器、版本、是否 minify、是否 bundle、是否 warm cache。本文不从本仓库小样例推出普遍排名。
官方 benchmark 条件要一起引用,否则数字没有意义:
| 官方 benchmark | 输入 | esbuild 选项 | 对照工具条件 | 机器与统计方式 |
|---|---|---|---|---|
| JavaScript benchmark | 把 three.js 复制 10 次,约 547,441 行,冷构建单 bundle | --bundle --minify --sourcemap | Rollup 4 加 terser;webpack 5 用 production + sourcemap;Parcel 2 默认 | 6-core 2019 MacBook Pro、16GB RAM、关闭 Spotlight;取 3 次中的最短耗时 |
| TypeScript benchmark | 旧 Rome TypeScript 代码库,约 131,836 行,输出单个可运行 minified bundle + Source Map | --bundle --minify --sourcemap --platform=node | webpack 5 用 ts-loader 的 transpileOnly: true、production + sourcemap;Parcel 2 用 package engines.node | 同一台 6-core 2019 MacBook Pro、16GB RAM、关闭 Spotlight;取 3 次中的最短耗时 |
这些条件说明两件事:第一,官方数字测的是冷构建大输入,不是小项目热更新;第二,TypeScript 对照明确关闭类型检查,所以不能把结果解释成“esbuild 比带类型检查的完整 TypeScript 构建快多少”。
本仓库的测量命令:
pnpm --silent --filter @roadmap/build-tools-slice benchmark -- --iterations=1 --warmup=0它把 esbuild 分成两项:
| 分组 | 命令 | 解释 |
|---|---|---|
| 转换器 | esbuild.transform(src/*.ts) | 只测 Transform API 对 TS 文件的内存转换 |
| 打包器 | pnpm run build:esbuild | 测 Build API 从入口到 browser bundle |
0.x 迁移的实际策略:
| 变化类型 | 风险 | 最小迁移动作 |
|---|---|---|
| 默认 target 或语法支持调整 | 产物语法变化 | 显式写 target,跑旧浏览器 fixture |
| 插件 API 行为变化 | 自研插件输出变化 | 给插件保留最小 fixture,检查 onResolve/onLoad |
| tree shaking/minify 变化 | 副作用被误删或体积变化 | 搜索可观察副作用和 dead marker |
| metafile schema 变化 | 自动分析脚本误判 | 对 metafile.outputs 加结构断言 |
严重问题总表:
| 严重问题 | 表现 | 根因 | 最小观察 | 修正 |
|---|---|---|---|---|
| Transform 当 Build 用 | 依赖、CSS、插件都不生效 | API 选错 | 看调用是 transform 还是 build | 改 Build API |
| target 当 polyfill | 老环境缺 API | 语法转换和运行时 API 混淆 | 浏览器报错或搜索 polyfill | 单独管理 polyfill |
| live reload 当 HMR | 页面状态丢失 | esbuild 没提供框架 HMR 边界 | 修改模块看状态是否保留 | 用 Vite/webpack HMR |
| metafile 不验收 | async chunk 或 CSS 路径错 | 只看命令退出 0 | artifact test 读 metafile.json | 用 metadata 驱动 HTML/测试 |
| 插件泄露 secret | 插件读取 env 写入 bundle | 构建代码信任边界失控 | `rg "secret | password |