Read OSS

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() 会合并 dependenciespeerDependencies——这个设计理所当然:库不应该将自身的生产依赖打包进去。

另一个容易忽略却至关重要的细节是第 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 使用)下,插件只检查显式的 externalnoExternal 配置。在 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:pathpath),兼容旧版 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 等框架恰恰依赖这一能力。当 tsconfigDecoratorMetadatatrue 时,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: truetarget: 'es2022'——前者确保类名在反射元数据中得以保留,后者确保输出的现代语法可以被 esbuild 正常处理。

提示: SWC、PostCSS 和 Svelte 插件都使用了 utils.ts 中的 localRequire(),它会相对于用户项目目录解析依赖包。这意味着用户只需在实际用到对应功能时才安装这些可选依赖——tsup 在导入时不会因为缺少这些包而崩溃。

write:false 模式与内存输出

tsup 配置中最关键的 esbuild 选项出现在第 234 行

write: false,

设置 write: false 后,esbuild 会将所有输出文件以 OutputFile[] 对象的形式保留在内存中——每个对象包含 pathtextcontents(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 合并

当插件同时返回 codemap 时,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.urlconst 形式导出,会导致 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 压缩。