Read OSS

Watch 模式、onSuccess 钩子与开发反馈循环

中级

前置知识

  • 已完成第 1–3 篇文章
  • 对 chokidar 和 Node.js 文件系统事件有基本了解
  • 理解子进程管理(spawn、kill 信号)

Watch 模式、onSuccess 钩子与开发反馈循环

只能运行一次的打包工具在开发阶段几乎没什么用。tsup 的 watch 模式将构建变成了一个持续的反馈循环——文件变更,bundle 重新构建,开发服务器随之重启。但要做好这件事并不简单:你需要只在相关文件变更时触发重构建,避免在快速保存时产生构建叠加,还要在各平台上干净地管理子进程。本文将逐一拆解其中的每个环节。

进入 Watch 模式:chokidar 初始化与首次构建

Watch 模式在 mainTasks() 内部被激活。首次 buildAll() 完成后,第 451 行会调用 startWatcher()。该函数仅在 watch 模式启用时才懒加载 chokidar:

const startWatcher = async () => {
  if (!options.watch) return
  const { watch } = await import('chokidar')
  // ...
}

watcher 在第 378–382 行配置了合理的忽略规则:

const ignored = [
  '**/{.git,node_modules}/**',
  options.outDir,
  ...customIgnores,
]

输出目录始终被忽略——否则每次重构建都会触发新的文件变更事件,形成无限循环。

flowchart TD
    A["mainTasks()"] --> B["buildAll() — initial build"]
    B --> C["startWatcher()"]
    C --> D["import chokidar"]
    D --> E["chokidar.watch(paths, { ignored })"]
    E --> F["watcher.on('all', handler)"]
    F --> G{"File changed"}
    G -->|relevant file| H["debouncedBuildAll()"]
    G -->|irrelevant file| I["Skip"]
    H --> B

--watch 以布尔值传入时,监听路径默认为 '.'(当前目录)。也可以传入字符串或数组:

tsup --watch src --watch lib    # Watch specific directories
tsup --watch                     # Watch everything in "."

基于 buildDependencies 与 esbuild Metafile 的智能重构建

并非所有文件变更都需要触发重构建。如果你在 tsup 监听期间编辑了 README,完全没有理由重新构建。tsup 通过 esbuild 的 metafile 精确追踪构建过程中实际用到的文件。

runEsbuild() 末尾,每个输入文件都会被加入共享的 buildDependencies 集合:

if (result.metafile) {
  for (const file of Object.keys(result.metafile.inputs)) {
    buildDependencies.add(file)
  }
}

metafile 的 inputs 对象包含 esbuild 在构建过程中读取的所有文件——源文件、导入的模块、解析后的依赖项。watcher 的变更处理器会在第 428–435 行查询这个集合:

if (options.watch === true) {
  if (file === 'package.json' && !buildDependencies.has(file)) {
    const currentHash = await getAllDepsHash(process.cwd())
    shouldSkipChange = currentHash === depsHash
    depsHash = currentHash
  } else if (!buildDependencies.has(file)) {
    shouldSkipChange = true
  }
}
sequenceDiagram
    participant FS as File System
    participant W as Watcher
    participant BD as buildDependencies Set
    participant BUILD as buildAll()

    Note over BD: After initial build:<br/>{src/index.ts, src/utils.ts, ...}
    FS->>W: Change: README.md
    W->>BD: Has "README.md"?
    BD-->>W: No
    W->>W: shouldSkipChange = true
    Note over W: Skip rebuild

    FS->>W: Change: src/utils.ts
    W->>BD: Has "src/utils.ts"?
    BD-->>W: Yes
    W->>BUILD: debouncedBuildAll()

当指定了自定义监听路径(例如 --watch src)时,这些路径下的所有变更都会触发重构建——buildDependencies 检查仅在 options.watch === true(默认布尔模式)时生效。

注意第 293–296 行的容错处理:如果构建失败,之前的 buildDependencies 会被恢复,确保 watcher 在下次尝试时仍能知道要监听哪些文件。

package.json 依赖哈希:按需触发依赖变更重构建

package.json 享有特殊待遇。修改 descriptionversion 字段不应触发重构建,但新增依赖项应该触发(因为 externals 列表可能随之改变)。

getAllDepsHash() 会对所有依赖项部分计算哈希值:

export async function getAllDepsHash(cwd: string) {
  const data = await loadPkg(cwd, true)  // clearCache: true
  return JSON.stringify({
    ...data.dependencies,
    ...data.peerDependencies,
    ...data.devDependencies,
  })
}

哈希值在首次构建前计算并存储。当 package.json 变更时,新哈希值会与存储值比较,只有发生变化时才会触发重构建。clearCache: true 标志确保 joycon 重新读取文件,而不是使用过期的缓存版本。

flowchart TD
    A["package.json changed"] --> B["Compute new depsHash"]
    B --> C{"depsHash changed?"}
    C -->|yes| D["Rebuild — externals may have changed"]
    C -->|no| E["Skip — metadata-only change"]

debouncePromise:合并快速连续的文件变更

在编辑器中保存文件时,文件系统可能会连续触发多个事件(write、rename、change)。如果不加防抖处理,每个事件都会启动一次新构建,造成构建堆积。

src/utils.ts 中的 debouncePromise 工具函数并非标准的防抖实现——它是异步感知的:

export function debouncePromise<T extends unknown[]>(
  fn: (...args: T) => Promise<void>,
  delay: number,
  onError: (err: unknown) => void,
) {
  let timeout: ReturnType<typeof setTimeout> | undefined
  let promiseInFly: Promise<void> | undefined
  let callbackPending: (() => void) | undefined

  return function debounced(...args: Parameters<typeof fn>) {
    if (promiseInFly) {
      callbackPending = () => {
        debounced(...args)
        callbackPending = undefined
      }
    } else {
      if (timeout != null) clearTimeout(timeout)
      timeout = setTimeout(() => {
        timeout = undefined
        promiseInFly = fn(...args)
          .catch(onError)
          .finally(() => {
            promiseInFly = undefined
            if (callbackPending) callbackPending()
          })
      }, delay)
    }
  }
}
sequenceDiagram
    participant E as File Events
    participant D as debounced()
    participant B as buildAll()

    E->>D: Change event 1
    Note over D: Start 100ms timer
    E->>D: Change event 2 (50ms later)
    Note over D: Reset timer to 100ms
    E->>D: Change event 3 (80ms later)
    Note over D: Reset timer to 100ms
    Note over D: 100ms elapsed...
    D->>B: Start build
    E->>D: Change event 4 (during build)
    Note over D: Build in flight —<br/>store as pending callback
    B-->>D: Build complete
    D->>D: Execute pending callback
    Note over D: Start new 100ms timer

该函数有三种状态:

  1. 空闲:新事件重置延迟计时器(标准防抖行为)
  2. 构建中:新事件被存储为待执行的回调——当前构建完成前不会启动新的构建
  3. 构建完成且有待执行回调:待执行回调触发,开启新一轮防抖周期

这样就避免了构建叠加的问题。第 287 行的默认 100ms 延迟能很好地应对典型的快速保存场景。

提示: 如果发现 watch 模式重构建过于频繁,可以使用 --ignore-watch 排除特定目录。例如,--ignore-watch test 可以防止测试文件变更触发重构建。ignored 模式会直接传递给 chokidar。

onSuccess 钩子:命令与回调

watch 模式下构建成功后,你通常需要执行一些后续操作——重启开发服务器、运行测试,或复制文件。onSuccess 选项支持两种形式。

字符串命令通过 tinyexec 以 shell 进程方式执行:

if (typeof options.onSuccess === 'function') {
  onSuccessCleanup = await options.onSuccess()
} else {
  onSuccessProcess = exec(options.onSuccess, [], {
    nodeOptions: { shell: true, stdio: 'inherit' },
  })
}

函数回调可以返回一个可选的清理函数,该函数会在下次重构建前被调用。

生命周期管理由第 269–281 行doOnSuccessCleanup() 负责。每次重构建前,上一次的 onSuccess 进程或清理函数必须先完成:

stateDiagram-v2
    [*] --> Idle
    Idle --> Building: File change
    Building --> RunningOnSuccess: Build succeeds
    Building --> Idle: Build fails
    RunningOnSuccess --> KillingPrevious: File change
    KillingPrevious --> Building: Previous process killed
    RunningOnSuccess --> Idle: Process exits

对于 shell 命令,终止上一个进程使用的是 killProcess(),它封装了 tree-kill 包:

const killProcess = ({ pid, signal }: { pid: number; signal: KILL_SIGNAL }) =>
  new Promise<void>((resolve, reject) => {
    kill(pid, signal, (err) => {
      if (err && !isTaskkillCmdProcessNotFoundError(err)) return reject(err)
      resolve()
    })
  })

isTaskkillCmdProcessNotFoundError 处理了一个 Windows 特有的边界情况:tree-kill 在 Windows 上使用 taskkill 命令,当目标进程已经退出时,该命令会返回退出码 128。若不处理这种情况,每当上一个进程自然退出后,tsup 在每次重构建时都会抛出错误。

KILL_SIGNAL 选项(可通过 --killSignal 配置)默认为 SIGTERM,允许进程优雅关闭。如果你的 onSuccess 进程不响应 SIGTERM,可以将其设置为 SIGKILL 以立即强制终止。

测试基础设施:端到端验证

tsup 的集成测试清晰地展示了上述各个环节是如何协作的。test/utils.ts 中的测试工具会创建临时的 fixture 项目,并对其运行预构建好的 tsup 二进制文件:

const bin = path.resolve(__dirname, '../dist/cli-default.js')

export async function run(
  title: string,
  files: { [name: string]: string },
  options: { entry?: string[]; flags?: string[]; env?: Record<string, string> } = {},
) {
  const testDir = path.resolve(cacheDir, filenamify(title))
  // Write fixture files
  await Promise.all(
    Object.keys(files).map(async (name) => {
      const filePath = path.resolve(testDir, name)
      await fsp.mkdir(path.dirname(filePath), { recursive: true })
      return fsp.writeFile(filePath, files[name], 'utf8')
    }),
  )
  // Run tsup
  const processPromise = exec(bin, [...entry, ...(options.flags || [])], {
    nodeOptions: { cwd: testDir, env: { ...process.env, ...options.env } },
  })
  // ...
}

每个测试将文件写入 .cache/<test-name>/,以子进程方式执行 dist/cli-default.js,并对输出结果进行断言。这是真正意义上的端到端测试——它覆盖了 CLI 参数解析、配置加载、esbuild pipeline、plugin 转换以及文件输出的完整流程。

flowchart LR
    A["Test: write fixture files<br/>to .cache/test-name/"] --> B["exec(dist/cli-default.js,<br/>flags, { cwd: testDir })"]
    B --> C["tsup runs in subprocess"]
    C --> D["Read dist/ output files"]
    D --> E["Assert on file contents,<br/>output file list, logs"]

这种设计要求在运行测试前必须先构建 tsup——package.json 中的脚本对此做了强制要求:"test": "pnpm run build && pnpm run test-only"。这是有意为之的:测试验证的是发布产物,而非原始源码。如果构建过程本身存在 bug,测试同样能将其捕获。

提示: 为 tsup 贡献代码时,请在执行 pnpm test-only 前先运行 pnpm build,确保测试基于你的最新改动运行。在项目重新构建之前,测试套件不会感知到源码的修改。

系列回顾

在这六篇文章中,我们完整梳理了 tsup 构建的全生命周期:

  1. 架构:三个入口点汇聚到 build(),由它并行分发 JS 和 DTS 任务
  2. 配置:joycon 负责发现配置文件,bundle-require 执行 TypeScript 配置,normalizeOptions() 将一切解析为严格类型
  3. esbuild Pipeline:自动 externalize、六个用于文件级转换的 esbuild plugin、write: false 实现内存输出
  4. tsup Plugin:七个内置的构建后 plugin,顺序严格——shebang → tree-shaking → CJS splitting → CJS interop → SWC target → size reporter → terser
  5. DTS 生成:两种策略——在 Worker 线程中基于 Rollup 的 --dts,以及使用 tsc + API Extractor 的 --experimental-dts
  6. Watch 模式:通过 metafile 追踪实现智能重构建、防抖构建、跨平台进程管理

tsup 的设计哲学是务实的组合:为每项任务选用最合适的工具(esbuild 追求速度,Rollup 负责 tree-shaking,SWC 处理 decorator metadata,TypeScript 提供类型支持),再通过干净的内存 pipeline 将它们串联起来。最终得到的是一个默认即快速、需要时可扩展的打包工具。