Read OSS

tsup 的插件系统:构建后转换与内置插件

高级

前置知识

  • 已完成第 3 篇(esbuild 流水线)
  • 理解 CJS/ESM 互操作模式
  • 熟悉 Rollup bundle API(tree-shaking 章节需要)

tsup 的插件系统:构建后转换与内置插件

在第 3 篇中,我们看到 esbuild 通过 write: false 模式将输出文件保存在内存中。tsup 的插件系统正是作用于这些内存中的代码块,执行 esbuild 原生无法处理的各类转换——CJS 代码分割、二次 tree-shaking、降级到 ES5、Terser 代码压缩等。本文将逐一剖析插件 API 及每个内置插件,并解释它们各自解决的具体问题。

Plugin 类型与生命周期钩子

Plugin 类型定义了四个生命周期钩子:

export type Plugin = {
  name: string
  esbuildOptions?: ModifyEsbuildOptions
  buildStart?: BuildStart
  renderChunk?: RenderChunk
  buildEnd?: BuildEnd
}
钩子 触发时机 同步/异步 用途
esbuildOptions esbuild 运行前 同步 修改 esbuild 配置(例如覆盖 target)
buildStart esbuild 配置完成后、构建开始前 异步 初始化工作、清理目录
renderChunk esbuild 完成后,对每个输出 chunk 触发 异步 转换代码、调整 source map
buildEnd 所有文件写入磁盘后 异步 上报体积、设置文件权限

每个钩子的 this 都绑定到一个 PluginContext,其中包含当前的 formatsplitting 标志、完整的 options 以及 loggerPluginContainer 会在任何钩子执行前完成这一上下文绑定。

renderChunk 是其中最强大的钩子。它接收 chunk 的代码字符串以及一个 ChunkInfo 对象,后者包含文件路径、source map、入口点信息、导出列表和导入元数据。它可以返回 { code, map } 来转换 chunk,也可以返回 undefinednull 表示跳过处理。

buildAll() 中的插件组装与顺序

插件链在 src/index.tsbuildAll() 中完成组装:

const pluginContainer = new PluginContainer([
  shebang(),
  ...(options.plugins || []),
  treeShakingPlugin({ treeshake: options.treeshake, ... }),
  cjsSplitting(),
  cjsInterop(),
  swcTarget(),
  sizeReporter(),
  terserPlugin({ minifyOptions: options.minify, ... }),
])
flowchart TD
    A["1. shebang<br/>Detect #! lines, set mode 0o755"] --> B["2. User plugins<br/>Custom transforms"]
    B --> C["3. treeShaking<br/>Rollup secondary pass"]
    C --> D["4. cjsSplitting<br/>Sucrase ESM→CJS conversion"]
    D --> E["5. cjsInterop<br/>module.exports = exports.default"]
    E --> F["6. swcTarget<br/>ES5/ES3 downleveling"]
    F --> G["7. sizeReporter<br/>Log output sizes"]
    G --> H["8. terser<br/>Minification (last!)"]

这个顺序是经过深思熟虑的:

  1. shebang 最先执行:在任何转换可能修改首行之前,先检测 #!
  2. 用户插件紧随其后:让用户代码获得最大控制权
  3. tree-shaking 在 CJS 转换之前:Rollup 处理 ESM,而 esbuild 在启用 CJS 代码分割时正是输出 ESM
  4. CJS 代码分割在 tree-shaking 之后:等死代码移除完毕,再将 ESM 转换为 CJS
  5. CJS interop 在代码分割之后:在 CJS 输出上修补 module.exports
  6. SWC target 在格式转换之后:对最终代码形态进行降级处理
  7. size reporter 倒数第二:在所有转换完成后、压缩之前统计体积
  8. Terser 绝对最后:代码压缩必须作用于最终代码

shebang 插件:保留 CLI 文件的可执行权限

shebang 插件是整个插件链中最简单的,但它解决了一个实际问题:

export const shebang = (): Plugin => {
  return {
    name: 'shebang',
    renderChunk(_, info) {
      if (
        info.type === 'chunk' &&
        /\.(cjs|js|mjs)$/.test(info.path) &&
        info.code.startsWith('#!')
      ) {
        info.mode = 0o755
      }
    },
  }
}

当 esbuild 处理以 #!/usr/bin/env node 开头的文件时,会在输出中保留这行 shebang。该插件检测到这一情况后,会将 info.mode 设置为 0o755——即 Unix 的可执行权限位。写入磁盘时,outputFile() 会读取这个 mode 值并应用,确保构建后的 CLI 脚本无需手动执行 chmod 即可直接运行。

值得注意的是,该插件直接修改 info 对象,而不是返回新的 code/map。ChunkInfo 上的 mode 属性正是为此设计的——它会在 buildFinished() 的文件写入步骤中被读取。

cjsSplitting 插件:绕过 esbuild 的 CJS 代码分割限制

这是 tsup 最巧妙的变通方案之一。esbuild 不支持 CommonJS 输出的代码分割。解决思路是:先以 ESM 格式构建(并启用代码分割),再将输出转换为 CJS。

正如第 3 篇所述,当请求 CJS 代码分割时,runEsbuild() 会将格式覆盖为 'esm'。随后,cjsSplitting 插件使用 Sucrase 将每个 ESM chunk 转换为 CJS:

async renderChunk(code, info) {
  if (
    !this.splitting ||
    this.options.treeshake ||  // handled by rollup instead
    this.format !== 'cjs' ||
    info.type !== 'chunk' ||
    !/\.(js|cjs)$/.test(info.path)
  ) {
    return
  }

  const { transform } = await import('sucrase')
  const result = transform(code, {
    filePath: info.path,
    transforms: ['imports'],
    sourceMapOptions: this.options.sourcemap
      ? { compiledFilename: info.path }
      : undefined,
  })

  return { code: result.code, map: result.sourceMap }
}
flowchart LR
    A["Source files"] -->|"format: 'cjs', splitting: true"| B["esbuild builds as ESM<br/>(with splitting)"]
    B --> C["ESM chunks with<br/>import/export syntax"]
    C -->|"cjsSplitting plugin"| D["CJS chunks with<br/>require()/exports"]
    D --> E["disk"]

选择 Sucrase 而非完整的解析器,是因为它速度极快——它只将 import/export 语句转换为 require()/module.exports,不做任何其他处理。transforms: ['imports'] 选项明确指定 Sucrase 只做这一件事。

this.options.treeshake 这个守卫条件至关重要:当启用 tree-shaking 时,Rollup 会在 tree-shaking 插件中负责 ESM→CJS 的转换,因此 cjsSplitting 会跳过处理,避免重复转换。

cjsInterop 插件:默认导出兼容性

当 CJS 的使用方执行 const lib = require('my-lib') 时,通常期望直接获取默认导出。但 esbuild 的 CJS 输出将默认导出放在 exports.default 上。cjsInterop 插件弥合了这一差异:

renderChunk(code, info) {
  if (
    !this.options.cjsInterop ||
    this.format !== 'cjs' ||
    info.type !== 'chunk' ||
    !/\.(js|cjs)$/.test(info.path) ||
    !info.entryPoint ||
    info.exports?.length !== 1 ||
    info.exports[0] !== 'default'
  ) {
    return
  }

  return {
    code: `${code}\nmodule.exports = exports.default;\n`,
    map: info.map,
  }
}

该插件会在代码末尾追加 module.exports = exports.default;——但仅限于只有默认导出的入口 chunk。info.exports?.length !== 1 || info.exports[0] !== 'default' 这个守卫条件确保含有具名导出的模块不会因覆写 module.exports 而遭到破坏。

提示: 当你构建的库只有单一默认导出,且需要支持 CJS 的 require() 调用时,可以启用 --cjsInterop。但要注意——如果你的模块同时包含具名导出,则不应使用此标志,因为 module.exports = exports.default 会覆盖它们。

treeShaking 插件:将 Rollup 作为二次处理步骤

esbuild 的 tree-shaking 能处理大多数情况,但面对复杂的重导出模式时,有时会遗留死代码。treeShakingPlugin 将 Rollup 更强大的 tree-shaking 能力作为后处理步骤加以利用:

sequenceDiagram
    participant ESB as esbuild output
    participant RP as Rollup (virtual)
    participant OUT as Final output

    ESB->>RP: Feed chunk code as virtual module
    Note over RP: resolveId: only resolve self<br/>load: return esbuild's code
    RP->>RP: Tree-shake with<br/>preserveEntrySignatures: 'exports-only'
    RP->>RP: Generate output in target format
    RP-->>OUT: Smaller code + map

核心在于第 26-37 行的虚拟模块设置:

plugins: [
  {
    name: 'tsup',
    resolveId(source) {
      if (source === info.path) return source
      return false  // externalize everything else
    },
    load(id) {
      if (id === info.path) return { code, map: info.map }
    },
  },
],

该虚拟插件将 chunk 自身的路径作为唯一已知模块——其他所有模块都通过 return false 标记为外部依赖。这样一来,Rollup 只对当前 chunk 内的代码进行 tree-shaking,而不会尝试解析任何导入。传给 Rollup 的 treeshake 选项可以是布尔值、预设字符串('recommended''smallest''safest'),或完整的 Rollup TreeshakingOptions 对象。

swcTarget 插件:降级到 ES5/ES3

esbuild 对 ES5 的支持并不完整——某些与 class 相关的语法无法处理。swcTarget 插件介入这一环节:

esbuildOptions(options) {
  if (
    typeof options.target === 'string' &&
    TARGETS.includes(options.target as any)  // ['es5', 'es3']
  ) {
    target = options.target as any
    options.target = 'es2020'  // Override esbuild to target modern JS
    enabled = true
  }
},

esbuildOptions 钩子中,插件检测到 es5es3 目标后,会将其替换es2020。esbuild 先生成现代 JavaScript,然后在 renderChunk 阶段,由 SWC 的 transform 完成完整的降级处理:

const result = await swc.transform(code, {
  filename: info.path,
  sourceMaps: this.options.sourcemap,
  jsc: {
    target,  // 'es5' or 'es3'
    parser: { syntax: 'ecmascript' },
  },
  module: {
    type: this.format === 'cjs' ? 'commonjs' : 'es6',
  },
})

这是一种务实的设计:esbuild 速度快,承担了 95% 的工作;SWC 负责处理 esbuild 在旧版 target 上无法应对的边缘情况。

terser 插件:作为后处理步骤的代码压缩

terserPlugin 在插件链的最末尾运行,这一点至关重要——如果在其他转换之前就进行压缩,不仅会让后续处理难度倍增,还会导致最终结果更差。

该插件仅在 minify 被设置为字符串 'terser' 时才会激活(区别于布尔值 true,后者使用 esbuild 的内置压缩器):

if (minifyOptions !== 'terser' || !/\.(cjs|js|mjs)$/.test(info.path))
  return

插件会根据输出格式自动应用对应的 Terser 选项:

if (format === 'esm') {
  defaultOptions.module = true
} else if (!(format === 'iife' && globalName !== undefined)) {
  defaultOptions.toplevel = true
}

ESM 输出启用 module: true(开启 ESM 专属优化);CJS 和不带 globalName 的 IIFE 启用 toplevel: true(允许混淆顶层声明);带 globalName 的 IIFE 则跳过 toplevel,以保留全局变量名。

与 SWC 插件一样,Terser 通过 localRequire() 加载——它是一个可选依赖,需要显式安装。缺失时的错误提示会引导用户完成安装:

if (!terser) {
  throw new PrettyError(
    'terser is required for terser minification. Please install it with `npm install terser -D`',
  )
}

sizeReporter 插件:输出体积展示

sizeReporter 是一个 buildEnd 插件,在所有文件写入磁盘后触发:

buildEnd({ writtenFiles }) {
  reportSize(
    this.logger,
    this.format,
    writtenFiles.reduce((res, file) => {
      return { ...res, [file.name]: file.size }
    }, {}),
  )
}

它从 writtenFiles 数组中收集文件名和体积,并将格式化后的控制台输出委托给 reportSize。输出使用 prettyBytes 生成可读的体积格式,并对齐文件名显示。虽然简单,但它让你无需借助外部工具,就能在构建完成后立刻获得 bundle 体积的直观反馈。

提示: 插件链是构建自定义插件的绝佳参考模型。如果你需要自定义后处理——例如注入许可证声明头、修补导入路径或运行自定义校验——只需实现带有 renderChunk 钩子的 Plugin 类型,并将其添加到 tsup 配置的 plugins 数组中即可。你的插件会在 shebang 之后、内置转换插件之前运行。

下一步

至此,我们已经完整介绍了 tsup 插件架构的两个层面。第 5 篇将转向 Promise.all([dtsTask(), mainTasks()]) 的另一半——类型声明文件的生成流水线。我们将对比两种策略:基于 Rollup 的 --dts 路径(使用 Worker 线程和 rollup-plugin-dts),以及使用 TypeScript compiler API 配合 API Extractor 的 --experimental-dts 路径。