配置加载:从 CLI 参数到 NormalizedOptions
前置知识
- ›已阅读第 1 篇文章(架构概览)
- ›熟悉 TypeScript tsconfig.json 的各项配置
- ›了解 glob 模式的基本用法
配置加载:从 CLI 参数到 NormalizedOptions
每个打包工具都需要一套配置处理流程,tsup 的设计理念是:零配置要真正做到开箱即用,同时也要让进阶用户能够掌控一切细节。本文将完整梳理 tsup 从开始查找配置文件,到生成一个完整的 NormalizedOptions 对象并交付给 esbuild 管道的全过程。
使用 joycon 发现配置文件
tsup 支持七种配置文件格式,以及内嵌在 package.json 中的配置键。发现逻辑位于 src/load.ts,通过 joycon 库按优先级依次搜索:
const configPath = await configJoycon.resolve({
files: configFile
? [configFile]
: [
'tsup.config.ts',
'tsup.config.cts',
'tsup.config.mts',
'tsup.config.js',
'tsup.config.cjs',
'tsup.config.mjs',
'tsup.config.json',
'package.json',
],
cwd,
stopDir: path.parse(cwd).root,
packageKey: 'tsup',
})
flowchart TD
A["Start: loadTsupConfig(cwd)"] --> B{"Custom config<br/>file specified?"}
B -->|yes| C["Search for that file only"]
B -->|no| D["Search priority list:<br/>1. tsup.config.ts<br/>2. tsup.config.cts<br/>3. tsup.config.mts<br/>4. tsup.config.js<br/>5. tsup.config.cjs<br/>6. tsup.config.mjs<br/>7. tsup.config.json<br/>8. package.json#tsup"]
C --> E{"Found?"}
D --> E
E -->|no| F["Return {} — no config"]
E -->|yes, .json| G["Parse JSON,<br/>extract .tsup key if package.json"]
E -->|yes, .ts/.js/.mts/.cjs/...| H["bundleRequire()"]
H --> I["config.mod.tsup || config.mod.default || config.mod"]
G --> J["Return { path, data }"]
I --> J
搜索从当前目录开始,逐级向上直到文件系统根目录(stopDir),匹配到第一个文件即停止。TypeScript 变体(.ts、.cts、.mts)的优先级最高——这是合理的默认行为,因为 tsup 的主要用户群都在使用 TypeScript。
如果没有找到任何配置文件,loadTsupConfig 会返回一个空对象,tsup 则仅凭 CLI 参数继续运行。这正是"零配置"体验的实现方式——tsup src/index.ts 不需要任何额外配置即可运行。
packageKey: 'tsup' 参数告诉 joycon:如果搜索到了 package.json,应当查找其中的 tsup 字段。代码在第 61 行显式处理了这一情况:
if (configPath.endsWith('package.json')) {
data = data.tsup
}
使用 bundle-require 执行 TypeScript 配置文件
这里有一个核心难题:Node.js 无法原生 require() 一个 .ts 文件。tsup 通过 bundle-require 解决了这个问题——它在内部使用 esbuild 即时编译配置文件,将结果写入临时文件,再 require() 该临时文件。
关键调用位于第 70–76 行:
const config = await bundleRequire({
filepath: configPath,
})
return {
path: configPath,
data: config.mod.tsup || config.mod.default || config.mod,
}
sequenceDiagram
participant tsup as tsup (load.ts)
participant br as bundle-require
participant esbuild as esbuild
participant node as Node.js
tsup->>br: bundleRequire({ filepath: "tsup.config.ts" })
br->>esbuild: Build tsup.config.ts → temp .js
esbuild-->>br: Compiled JavaScript
br->>node: require(tempFile)
node-->>br: module.exports
br-->>tsup: { mod: { default: ... } }
tsup->>tsup: Extract mod.tsup || mod.default || mod
config.mod.tsup || config.mod.default || config.mod 这条解析链兼容三种导出风格:
export const tsup = { ... }— 具名导出tsupexport default { ... }— 默认导出(最常见的写法)module.exports = { ... }— CJS 风格(整个模块即配置)
提示: Vite 处理
vite.config.ts时也采用了同样的bundle-require方案。esbuild 编译配置文件只需几毫秒,速度很快,但需要注意:编译后的代码会被实际执行——你的配置文件在构建时运行,动态配置的能力正是由此而来。
动态配置:defineConfig 的函数形式
defineConfig 是一个恒等函数,它原样返回传入的参数,唯一的作用是提供 TypeScript IntelliSense:
export const defineConfig = (
options:
| Options
| Options[]
| ((overrideOptions: Options) => MaybePromise<Options | Options[]>),
) => options
注意它的函数签名接受三种形式:单个配置对象、配置对象数组,或一个接收 CLI 选项并返回上述两者之一的函数。函数形式非常强大,可以让你根据 tsup 的调用方式动态调整配置:
// tsup.config.ts
export default defineConfig((overrideOptions) => ({
entry: ['src/index.ts'],
format: overrideOptions.watch ? ['esm'] : ['cjs', 'esm'],
}))
函数形式在 build() 的第 176–179 行被解析:
const configData =
typeof config.data === 'function'
? await config.data(_options)
: config.data
当配置返回数组时,每个元素会通过第 181 行的 Promise.all 并行独立处理。这就是在单个配置文件中定义多个构建目标的方式——例如同时构建一个库和一个 CLI 工具,各自使用不同的入口点和输出格式。
flowchart TD
A["config.data"] --> B{"typeof === 'function'?"}
B -->|yes| C["config.data(_options)<br/>Pass CLI flags to function"]
B -->|no| D["Use as-is"]
C --> E{"Returns array?"}
D --> E
E -->|yes| F["Promise.all — process each config in parallel"]
E -->|no| G["Process single config"]
F --> H["normalizeOptions() × N"]
G --> H
选项优先级与 normalizeOptions()
normalizeOptions() 将宽松的 Options 类型转换为严格的 NormalizedOptions 类型。优先级规则很直观——CLI 参数覆盖配置文件中的值:
const _options = {
...optionsFromConfigFile,
...optionsOverride, // CLI flags win
}
随后应用默认值:
| 选项 | 默认值 | 说明 |
|---|---|---|
outDir |
'dist' |
标准输出目录 |
format |
['cjs'] |
单个字符串会被包装为数组 |
target |
'node16' |
读取 tsconfig 后的兜底值 |
removeNodeProtocol |
true |
移除 import 中的 node: 前缀 |
dts 选项在第 88–95 行经过了较为复杂的规范化处理,从 boolean | string | DtsConfig 转换为 DtsConfig | undefined:
dts:
typeof _options.dts === 'boolean'
? _options.dts
? {} // true → empty DtsConfig (use defaults)
: undefined // false → disabled
: typeof _options.dts === 'string'
? { entry: _options.dts } // string → entry shorthand
: _options.dts, // already DtsConfig
入口文件同样在此处完成规范化。当 entry 是字符串数组(如 ['src/**/*.ts'])时,会通过第 111 行的 tinyglobby glob() 展开。当它是 Record<string, string> 时,每个文件都会被验证是否实际存在。
tsconfig 集成:路径别名、装饰器与编译目标
完成基本默认值设置后,normalizeOptions() 会通过 bundle-require 的 loadTsConfig 工具函数加载项目的 tsconfig.json,并提取三项关键数据,对应第 129–155 行:
const tsconfig = loadTsConfig(process.cwd(), options.tsconfig)
if (tsconfig) {
options.tsconfigResolvePaths = tsconfig.data?.compilerOptions?.paths || {}
options.tsconfigDecoratorMetadata =
tsconfig.data?.compilerOptions?.emitDecoratorMetadata
// ...
if (!options.target) {
options.target = tsconfig.data?.compilerOptions?.target?.toLowerCase()
}
}
flowchart TD
TC["tsconfig.json"] --> PATHS["compilerOptions.paths<br/>→ tsconfigResolvePaths"]
TC --> DEC["compilerOptions.emitDecoratorMetadata<br/>→ tsconfigDecoratorMetadata"]
TC --> TGT["compilerOptions.target<br/>→ target (fallback)"]
PATHS --> EXT["esbuild external plugin<br/>(resolve aliased paths)"]
DEC --> SWC["SWC esbuild plugin<br/>(emit decorator metadata)"]
TGT --> ESB["esbuild target option"]
tsconfigResolvePaths 字段会传给 external plugin(第 3 篇文章),确保 @/utils 这类路径别名能被正确解析,而不是被标记为外部依赖。tsconfigDecoratorMetadata 标志决定是否激活 SWC esbuild plugin——由于 esbuild 不支持 emitDecoratorMetadata,这部分转换会由 SWC 作为预处理步骤来完成。
编译目标的兜底链为:--target CLI 参数 → 配置文件中的 target → tsconfig 的 compilerOptions.target → 'node16'。也就是说,如果你的 tsconfig.json 已经指定了 es2020,tsup 会直接使用它,无需任何额外配置。
提示: 如果你在使用 tsup 时遇到路径别名行为异常,请检查
tsconfig.json中是否正确配置了paths。tsup 会直接读取这些路径,并用它们来防止别名导入被当作外部依赖处理。
使用 defaultOutExtension 智能处理输出扩展名
package.json 中的 type 字段、输出格式与文件扩展名之间的关系,是一个常见的混淆点。tsup 在 defaultOutExtension 中自动处理了这一逻辑:
export function defaultOutExtension({
format,
pkgType,
}: {
format: Format
pkgType?: string
}): { js: string; dts: string } {
let jsExtension = '.js'
let dtsExtension = '.d.ts'
const isModule = pkgType === 'module'
if (isModule && format === 'cjs') {
jsExtension = '.cjs'
dtsExtension = '.d.cts'
}
if (!isModule && format === 'esm') {
jsExtension = '.mjs'
dtsExtension = '.d.mts'
}
if (format === 'iife') {
jsExtension = '.global.js'
}
return { js: jsExtension, dts: dtsExtension }
}
对应的逻辑矩阵如下:
package.json type |
格式 | JS 扩展名 | DTS 扩展名 |
|---|---|---|---|
(无/"commonjs") |
cjs |
.js |
.d.ts |
(无/"commonjs") |
esm |
.mjs |
.d.mts |
"module" |
cjs |
.cjs |
.d.cts |
"module" |
esm |
.js |
.d.ts |
| 任意 | iife |
.global.js |
.d.ts |
规则很简单:.js 用于包的"原生"格式(非 module 包用 CJS,module 包用 ESM)。当需要输出另一种格式时,显式的扩展名(.mjs 或 .cjs)会向 Node.js 的模块系统明确表示模块类型。IIFE 始终使用 .global.js,因为它不参与 Node.js 的模块解析。
用户可以通过 outExtension 选项覆盖默认行为,该选项可以获取包含格式、选项和包类型在内的完整上下文——不过默认值已经能正确处理绝大多数场景。
下一步
选项规范化完成后,构建流程便可正式启动。第 3 篇文章将深入 runEsbuild()——探讨 tsup 如何将 NormalizedOptions 转换为完整的 esbuild 配置、六个内置 esbuild plugin 的工作机制、关键的 write: false 模式,以及 PluginContainer 如何协调构建后的转换流程与 source map 合并。