esbuild 构建流水线:编排、插件与输出处理
前置知识
- ›已完成第 1–2 篇文章
- ›具备 esbuild Plugin API 的实际使用经验(onResolve、onLoad、onEnd 钩子)
- ›理解 CJS 与 ESM 的模块语义差异
- ›熟悉 source map 的基本概念
esbuild 构建流水线:编排、插件与输出处理
tsup 本质上是连接 TypeScript 源码与 esbuild 的桥梁,但这座桥远比想象中复杂。runEsbuild() 函数负责从 NormalizedOptions 组装完整的 esbuild 配置,将构建输出保留在内存中运行,再将结果送入插件管道,最终才写入磁盘。本文将逐一拆解这一过程的每个环节。
配置 esbuild:自动外部化与格式选择
runEsbuild() 首先要确定哪些依赖应该被外部化。在第 77–83 行,生产依赖从 package.json 中读取并转换为正则表达式模式:
const deps = await getProductionDeps(process.cwd())
const external = [
...deps.map((dep) => new RegExp(`^${dep}($|\/|\\)`)),
...(await generateExternal(options.external || [])),
]
正则 ^lodash($|\/|\\) 可以同时匹配 lodash 本身以及 lodash/get 这样的深层导入。getProductionDeps() 会合并 dependencies 和 peerDependencies——这个设计理所当然:库不应该将自身的生产依赖打包进去。
另一个容易忽略却至关重要的细节是第 164–165 行的格式覆盖逻辑:
format:
(format === 'cjs' && splitting) || options.treeshake ? 'esm' : format,
当目标格式为 CJS 且同时开启了代码分割,或者启用了 tree-shaking 时,esbuild 实际上会以 ESM 格式构建。原因在于 esbuild 原生不支持 CJS 的代码分割。ESM 输出随后由 cjsSplitting 插件(详见第 4 篇)转换为 CJS。同样,tree-shaking 插件会使用 Rollup 处理 esbuild 输出的 ESM,再由 Rollup 生成最终的 CJS 产物。
| 请求格式 | splitting | treeshake | 实际 esbuild 格式 |
|---|---|---|---|
cjs |
false |
false |
cjs |
cjs |
true |
false |
esm(由 cjsSplitting 插件转换) |
cjs |
任意 | true |
esm(由 tree-shaking 插件转换) |
esm |
任意 | 任意 | esm |
iife |
N/A | 任意 | iife |
External 插件:skipNodeModulesBundle 与 noExternal
从 package.json 生成的自动外部化正则模式需要借助自定义 esbuild 插件实现,因为 esbuild 内置的 external 选项不支持正则表达式。externalPlugin 有两种不同的工作模式。
flowchart TD
A["Import resolved"] --> B{"skipNodeModulesBundle?"}
B -->|yes| C{"Matches tsconfig paths?"}
C -->|yes| D["Bundle it — let esbuild resolve"]
C -->|no| E{"Matches noExternal?"}
E -->|yes| D
E -->|no| F{"Matches explicit external?"}
F -->|yes| G["Mark external"]
F -->|no| H{"Is non-relative import?<br/>(looks like node_module)"}
H -->|yes| G
H -->|no| D
B -->|no| I{"Matches noExternal?"}
I -->|yes| D
I -->|no| J{"Matches external?"}
J -->|yes| G
J -->|no| D
在普通模式(tsup 使用)下,插件只检查显式的 external 和 noExternal 配置。在 skipNodeModulesBundle 模式(tsup-node 使用)下,所有非相对路径或绝对路径的导入都会被外部化——除非它匹配 noExternal 或 tsconfig 的路径别名。
第 5 行的正则是判断模块导入的启发式规则:
const NON_NODE_MODULE_RE = /^[A-Z]:[/\\]|^\.{0,2}\/|^\.{1,2}$/
如果导入路径不匹配这个正则(即既不是相对路径、绝对路径,也不是盘符路径),则将其视为 node_module 并外部化。
内置 esbuild 插件全览
tsup 注册了六个 esbuild 插件,在第 121–150 行统一组装。每个插件都针对 esbuild 能力上的某个具体缺口。
| 插件 | 文件 | 功能说明 |
|---|---|---|
nodeProtocolPlugin |
node-protocol.ts |
去除导入中的 node: 前缀(如 node:path → path),兼容旧版 Node.js 运行时 |
externalPlugin |
external.ts |
基于正则的外部依赖解析(详见上文) |
swcPlugin |
swc.ts |
对每个 .ts/.js 文件运行 SWC,当 tsconfig 中启用 emitDecoratorMetadata 时输出装饰器元数据 |
nativeNodeModulesPlugin |
native-node-module.ts |
处理 .node 二进制插件,解析并生成 require() 包装代码 |
postcssPlugin |
postcss.ts |
加载 CSS 文件,按需运行 PostCSS 转换,可选将 CSS 以 JS 形式注入 |
sveltePlugin |
svelte.ts |
使用 Svelte 编译器编译 .svelte 文件,支持 TypeScript 预处理 |
SWC 插件尤为值得关注。esbuild 不支持 TypeScript 的 emitDecoratorMetadata 特性,而 NestJS、TypeORM 等框架恰恰依赖这一能力。当 tsconfigDecoratorMetadata 为 true 时,swcPlugin 会通过 onLoad 拦截每个 TypeScript 文件,以 decoratorMetadata: true 调用 SWC 的 transformFile,再将结果回传给 esbuild:
const jsc: JscConfig = {
parser: {
syntax: isTs ? 'typescript' : 'ecmascript',
decorators: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
keepClassNames: true,
target: 'es2022',
}
注意这里强制设置了 keepClassNames: true 和 target: 'es2022'——前者确保类名在反射元数据中得以保留,后者确保输出的现代语法可以被 esbuild 正常处理。
提示: SWC、PostCSS 和 Svelte 插件都使用了
utils.ts中的localRequire(),它会相对于用户项目目录解析依赖包。这意味着用户只需在实际用到对应功能时才安装这些可选依赖——tsup 在导入时不会因为缺少这些包而崩溃。
write:false 模式与内存输出
tsup 配置中最关键的 esbuild 选项出现在第 234 行:
write: false,
设置 write: false 后,esbuild 会将所有输出文件以 OutputFile[] 对象的形式保留在内存中——每个对象包含 path、text 和 contents(Uint8Array)。此时不会有任何内容写入磁盘。
sequenceDiagram
participant RE as runEsbuild()
participant EB as esbuild
participant PC as PluginContainer
participant FS as File System
RE->>EB: esbuild({ write: false, ... })
EB-->>RE: { outputFiles: OutputFile[], metafile }
RE->>PC: buildFinished({ outputFiles, metafile })
PC->>PC: Filter out .map files
PC->>PC: Classify as ChunkInfo or AssetInfo
loop For each chunk
PC->>PC: Run renderChunk() through all plugins
PC->>PC: Merge source maps if plugin returned map
end
PC->>FS: outputFile() — write to disk
PC->>PC: Call buildEnd() on all plugins
这一模式是 tsup 插件层的先决条件。没有它,就无从对 CJS 输出进行转换、执行二次 tree-shaking、应用 Terser 压缩,或修复 shebang 行。代价是内存占用——所有输出同时驻留在内存中——但对于库构建场景而言,这通常不是问题。
PluginContainer.buildFinished():输出处理管道
esbuild 完成构建后,pluginContainer.buildFinished() 接管整个后处理流程。
首先,在第 131–149 行对输出文件进行分类。source map 文件被过滤出来,关联到各自的父文件。JS 和 CSS 文件以字符串形式转化为 ChunkInfo 对象;其余文件则以原始字节转化为 AssetInfo。
接着,针对每个 chunk,所有插件的 renderChunk 钩子按顺序依次调用:
for (const plugin of this.plugins) {
if (info.type === 'chunk' && plugin.renderChunk) {
const result = await plugin.renderChunk.call(
this.getContext(),
info.code,
info,
)
if (result) {
info.code = result.code
// source map merging...
}
}
}
flowchart LR
IN["esbuild output chunk"] --> S["shebang"]
S --> U["user plugins"]
U --> TS["tree-shaking"]
TS --> CS["cjs-splitting"]
CS --> CI["cjs-interop"]
CI --> SW["swc-target"]
SW --> SR["size-reporter"]
SR --> TR["terser"]
TR --> OUT["Final code + map"]
执行顺序至关重要。shebang 必须最先运行以检测 #! 行;tree-shaking 和 CJS splitting 必须在 Terser 压缩之前完成;size reporter 靠后执行,以便获取接近最终状态的产物体积。
多次转换后的 Source Map 合并
当插件同时返回 code 和 map 时,PluginContainer 必须将新的 source map 与已有的合并。这一逻辑在第 164–176 行实现:
if (result.map) {
const originalConsumer = await new SourceMapConsumer(
parseSourceMap(info.map),
)
const newConsumer = await new SourceMapConsumer(
parseSourceMap(result.map),
)
const generator = SourceMapGenerator.fromSourceMap(newConsumer)
generator.applySourceMap(originalConsumer, info.path)
info.map = generator.toJSON()
originalConsumer.destroy()
newConsumer.destroy()
}
sequenceDiagram
participant O as Original Map<br/>(esbuild → source)
participant N as New Map<br/>(plugin → esbuild output)
participant G as SourceMapGenerator
Note over G: Start from new map
G->>G: fromSourceMap(newConsumer)
G->>G: applySourceMap(originalConsumer)
Note over G: Composed map now traces<br/>plugin output → original source
source-map 库的 applySourceMap 方法承担了核心工作——它将插件输出位置的映射,通过 esbuild 生成的 map 追溯回原始源码位置。如果不做这一合并,调试时看到的将是转换后的代码位置,而非你原始的 TypeScript 代码。
所有插件执行完毕后,最终的代码和 source map 通过 outputFile() 写入磁盘。随后,buildEnd 钩子会携带已写入文件的元数据依次触发各插件——size reporter 正是在这里完成统计工作的。
CJS 与 ESM Shim 注入
开启 --shims 后,tsup 通过 esbuild 的 inject 选项注入 polyfill 文件。CJS shim assets/cjs_shims.js 在 CJS 环境中提供 import.meta.url:
const getImportMetaUrl = () =>
typeof document === "undefined"
? new URL(`file:${__filename}`).href
: (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT')
? document.currentScript.src
: new URL("main.js", document.baseURI).href;
export const importMetaUrl = /* @__PURE__ */ getImportMetaUrl()
注意这里的函数包装模式。第 1–4 行的注释解释了原因:esbuild 存在一个 bug,直接将 import.meta.url 以 const 形式导出,会导致 esbuild 在不需要的格式中也强制注入它。将其包裹在函数内可以规避这个问题。
ESM shim assets/esm_shims.js 则反过来,在 ESM 环境中通过 import.meta.url 提供 __dirname 和 __filename:
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const getFilename = () => fileURLToPath(import.meta.url)
const getDirname = () => path.dirname(getFilename())
export const __dirname = /* @__PURE__ */ getDirname()
export const __filename = /* @__PURE__ */ getFilename()
两者都使用了 /* @__PURE__ */ 注解,以便 tree-shaker 在未使用时将其移除。注入逻辑在 runEsbuild() 的第 220–228 行,根据格式和 shims 选项条件性启用。
提示: 如果在 ESM 输出中遇到
ReferenceError: __dirname is not defined,或在 CJS 输出中遇到import.meta.url is not available,开启--shims即可解决。tsup 会自动注入对应的 polyfill。
下一步
esbuild 的输出现已以 ChunkInfo 对象的形式驻留在内存中。第 4 篇文章将详细介绍每一个 tsup 内置插件——从设置文件权限的 shebang 处理器,到巧妙的 CJS splitting 解决方案,再到处理链末端的 Terser 压缩。