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,其中包含当前的 format、splitting 标志、完整的 options 以及 logger。PluginContainer 会在任何钩子执行前完成这一上下文绑定。
renderChunk 是其中最强大的钩子。它接收 chunk 的代码字符串以及一个 ChunkInfo 对象,后者包含文件路径、source map、入口点信息、导出列表和导入元数据。它可以返回 { code, map } 来转换 chunk,也可以返回 undefined 或 null 表示跳过处理。
buildAll() 中的插件组装与顺序
插件链在 src/index.ts 的 buildAll() 中完成组装:
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!)"]
这个顺序是经过深思熟虑的:
- shebang 最先执行:在任何转换可能修改首行之前,先检测
#! - 用户插件紧随其后:让用户代码获得最大控制权
- tree-shaking 在 CJS 转换之前:Rollup 处理 ESM,而 esbuild 在启用 CJS 代码分割时正是输出 ESM
- CJS 代码分割在 tree-shaking 之后:等死代码移除完毕,再将 ESM 转换为 CJS
- CJS interop 在代码分割之后:在 CJS 输出上修补
module.exports - SWC target 在格式转换之后:对最终代码形态进行降级处理
- size reporter 倒数第二:在所有转换完成后、压缩之前统计体积
- 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 钩子中,插件检测到 es5 或 es3 目标后,会将其替换为 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 路径。