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 享有特殊待遇。修改 description 或 version 字段不应触发重构建,但新增依赖项应该触发(因为 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
该函数有三种状态:
- 空闲:新事件重置延迟计时器(标准防抖行为)
- 构建中:新事件被存储为待执行的回调——当前构建完成前不会启动新的构建
- 构建完成且有待执行回调:待执行回调触发,开启新一轮防抖周期
这样就避免了构建叠加的问题。第 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 构建的全生命周期:
- 架构:三个入口点汇聚到
build(),由它并行分发 JS 和 DTS 任务 - 配置:joycon 负责发现配置文件,
bundle-require执行 TypeScript 配置,normalizeOptions()将一切解析为严格类型 - esbuild Pipeline:自动 externalize、六个用于文件级转换的 esbuild plugin、
write: false实现内存输出 - tsup Plugin:七个内置的构建后 plugin,顺序严格——shebang → tree-shaking → CJS splitting → CJS interop → SWC target → size reporter → terser
- DTS 生成:两种策略——在 Worker 线程中基于 Rollup 的
--dts,以及使用 tsc + API Extractor 的--experimental-dts - Watch 模式:通过 metafile 追踪实现智能重构建、防抖构建、跨平台进程管理
tsup 的设计哲学是务实的组合:为每项任务选用最合适的工具(esbuild 追求速度,Rollup 负责 tree-shaking,SWC 处理 decorator metadata,TypeScript 提供类型支持),再通过干净的内存 pipeline 将它们串联起来。最终得到的是一个默认即快速、需要时可扩展的打包工具。