Read OSS

配置加载:从 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 这条解析链兼容三种导出风格:

  1. export const tsup = { ... } — 具名导出 tsup
  2. export default { ... } — 默认导出(最常见的写法)
  3. 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-requireloadTsConfig 工具函数加载项目的 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 合并。