配置迁移本身很小。真正有意思的是如何保持 package contract 不变。
Hoi hoi! 👋
我是 @nyaomaru,一名前端工程师,这个季节还挺喜欢到处找蘑菇。😸🍄
最近,我把 is-kit 的构建配置从 tsup 迁移到了 tsdown。
一开始,我觉得这应该只是一个很小的改动。
删除 tsup。
安装 tsdown。
改一下配置。
跑一次 build。
完成!🎉
而且说实话......
配置迁移确实很小。
但过程中出现了一个很有意思的问题。
第一次使用
tsdown构建时,build 成功了,但它悄悄改掉了一些已经属于 package public contract 的文件。
所以,这篇文章并不是一篇 tsdown 入门介绍。
我更想分享的是:当我把一个已经真实发布的 TypeScript 库从 tsup 迁移到 tsdown 时,实际发生了什么、哪些地方发生了变化,以及我是如何验证现有用户最终安装到的 package 仍然保持兼容的。
一起来看看吧!👀

🤔 为什么要迁移一个本来就能正常构建的 package?
is-kit 是一个零依赖的 TypeScript runtime type guard 库。
它原本的构建配置其实相当无聊。
而无聊的构建系统通常是好事。😸
这个 package 有:
- 一个 entry point
- ESM 和 CJS 两种输出
- 打包后的 declaration files
- 显式配置的
package.jsonexports - declaration banner
- 针对 packed package 的 smoke test
迁移之前的配置大致是:
| 环境 | 值 |
|---|---|
| Package | is-kit@1.14.2 |
| Bundler | tsup@8.5.1 |
| Entry | src/index.ts |
| Output | ESM + CJS + bundled declarations |
| Target | esnext |
我并不是在修一个坏掉的 build。
这次迁移主要是出于维护方面的考虑。
tsdown 基于 Rolldown 构建,生态很活跃,而且它本身就明确提供了从 tsup 迁移的路径。
所以真正的问题不是:
tsdown能不能把这个库构建出来?
而是:
我能不能在不改变现有用户实际拿到的内容的前提下,把构建配置迁移到
tsdown?
对于一个已经发布出去的 package 来说,这个问题更有价值。
📦 我必须保持不变的 package contract
对于应用来说,修改一个输出文件名可能没什么大不了。
但对于 library 来说,它完全可能成为 breaking change。
is-kit 已经通过显式的 package exports 暴露文件。
关键路径大致是:
json
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
所以我希望迁移之后依然保留:
text
dist/index.mjs
dist/index.js
dist/index.d.ts
同时还要保持现有的 ESM/CJS runtime 行为、declaration compatibility、export 集合,以及 declaration banner。
换句话说:
这里真正的 contract 不是 source code,而是最终发布出去的 npm package。
这个区别很快就变得非常重要。
🛠️ 迁移看起来几乎简单得不能再简单
我把 tsup 替换成 tsdown,然后把:
text
tsup.config.ts
改成:
text
tsdown.config.mts
这里我特意使用了 .mts。
package 本身并没有设置 "type": "module",使用 ESM 专用的配置文件扩展名,可以避免 Node 重新解释配置文件并产生相关 warning。
大部分重要配置几乎都可以一一对应。
ts
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
outDir: "dist",
target: "esnext",
banner: {
dts: dtsBanner,
},
});
declaration banner 也继续只应用在 dts 上:
ts
banner: { dts: dtsBanner }
到这里,一切都很顺利。
然后我跑了一次 build。
成功了。
但 package contract 已经不对了。🙀
💥 第一次 build 成功了,但文件名变了
这就是整个迁移里最有意思的部分。
迁移之前,关键输出文件是:
| Build | 主要生成文件 |
|---|---|
tsup |
index.mjs, index.js, index.d.ts |
默认 tsdown 迁移 |
index.mjs, index.cjs, index.d.mts, index.d.cts |
tsdown 的 build 正常完成,exit code 是 0。
但我现有的 package.json 仍然期待:
text
require → ./dist/index.js
types → ./dist/index.d.ts
这些路径已经和实际生成的文件对不上了。
如果我当时只看到:
bash
pnpm build
成功,然后直接发布 package,
那么 CJS 用户以及 TypeScript resolution 都有可能被破坏。
这是这次迁移最重要的经验:
Build 成功 ≠ package contract 被保留。
Bundler 只知道自己是否成功生成了输出。
它并不会自动保证这些输出仍然满足一个已经发布过的 package 之前对用户做出的全部承诺。
🔧 使用 outExtensions 修复 public contract
tsdown 的迁移指南 明确提到了 outExtension 改名为 outExtensions。
在 is-kit 里,我用它来保留原本的文件名:
ts
outExtensions: ({ format }) => ({
dts: format === 'cjs' ? '.d.ts' : '.d.mts',
js: format === 'cjs' ? '.js' : '.mjs',
}),
这样之后,相关输出就变成了:
text
dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts
现有的 exports 也继续可以正确解析。
我并不认为 tsdown 原本的默认输出行为是 bug。
tsdown 会根据 package type 和 output format 选择扩展名,以避免 module 解释产生歧义。
这是合理的。
但对于一个已经存在的 package 来说,合理的新默认值依然是一种变化。
如果用户已经依赖你的文件名,那么这些文件名本身就是 compatibility surface 的一部分。
🧪 用 smoke test 验证真正发布出去的 package
这时候,is-kit 里本来就有的 packed-package smoke test 帮了大忙。
我原本就有一个:
text
pnpm test:package
它会 build package、执行 npm pack,把生成的 tarball 安装到临时项目里,然后从真实 consumer 的角度进行验证。
它不是测试:
text
src/index.ts
而是测试:
text
is-kit-1.14.2.tgz
↓
临时 consumer
↓
npm install
一个 library 完全可能在自己的 repository 里运行得很好,但最后发布出去的 package 却是坏的。
所以这个 smoke test 验证的是用户真正会安装的 artifact。
迁移之后,我扩展了测试范围,用它检查更多 package contract 👇
| Contract | 结果 |
|---|---|
exports["."].import → ./dist/index.mjs |
✅ pass |
exports["."].require → ./dist/index.js |
✅ pass |
exports["."].types → ./dist/index.d.ts |
✅ pass |
| Runtime dependencies | ✅ 0 |
| ESM exports | ✅ 同样是 83 个 exports |
| CJS exports | ✅ 同样是 83 个 exports |
| Declaration banner | ✅ 保留 |
| ESM runtime import | ✅ pass |
| CJS runtime require | ✅ pass |
| Packed TypeScript consumer | ✅ pass |
我还把打包后的 package 安装到了使用 TypeScript v5.7 到 v7.0 的临时 consumer 项目里。
它们都能正常解析和使用生成的 declaration files。
这里测试的并不是"每个 TypeScript 版本能不能通过 tsdown 生成 declaration"。
测试的是用户真正安装到本地的 artifact。
这样一来,如果未来某次构建改动意外改变了 extension、export path、declaration file 或 runtime behavior,那么 smoke test 理论上应该能在发布之前就把它抓出来。
对于这种 library migration 来说,这比单纯断言"build 成功退出"有意义得多。
📏 输出体积反而变大了
我也顺手对比了一下 artifact size。
结果有点出乎我的意料。
| 指标 | tsup |
tsdown |
差异 |
|---|---|---|---|
| JS + dts 总计 | 116,503 B | 144,007 B | +23.6% |
| ESM JavaScript | 15,787 B | 29,410 B | +86.3% |
| CJS JavaScript | 19,318 B | 31,155 B | +61.3% |
| dts,单一格式 | 40,699 B | 41,721 B | +2.5% |
npm pack tarball |
37,226 B | 42,366 B | +13.8% |
在这套配置下,tsdown 的输出保留了比之前 tsup 更多的 comments 和 region markers。
所以 raw JavaScript 的体积明显变大了。
压缩之后,最终 npm tarball 的差距有所缩小,但并没有完全消失。
对于 is-kit 来说,这并不是什么大问题。
它本身就是一个很小的零依赖 utility library,最终 tarball 只多了几 KB。
但这仍然提醒了我一件事:
更新、更快的 bundler,并不自动等于更小的 artifact。
如果 package size 对你的 library 是一个严格约束,那么迁移之前最好先比较实际生成文件,并决定是否需要调整 minification strategy。
⏱️ 构建性能呢?
我也测了一下 wall-clock build time。
旧的 tsup build:
text
1.50 s
新的 tsdown build,连续三次:
text
1.22 s
1.17 s
1.18 s
如果只看这些数字,很容易得出这样的结论:
tsdown让 build 更快了!🚀
但我并不认为这个 benchmark 足以支持这个结论。
这次对比里,我只有一次 tsup 的测量结果。
而且这是一个极小、单 entry 的 library,declaration generation 本身就占据了很大一部分构建时间。
两个 bundler 自己报告的时间大致是:
text
tsup
JavaScript: ~22 ms
dts: ~707 ms
tsdown
complete: ~717--755 ms
wall-clock 的结果确实更好一些,但因为只有一个 tsup 样本,而且 declaration generation 主导了这个小型 library 的 build time,所以我觉得证据还不足以证明存在有意义的性能提升。
而且不,我并不是为了省下大约 300 ms 才做这个迁移。
🐱 这次迁移真的很小吗?
对 is-kit 来说,是的。
没有任何 source code 变化。
整个迁移基本只涉及:
text
dependency
config
task documentation
package smoke assertions
关键选项几乎都是一一对应的。
但我觉得有必要说明一下:为什么它能保持这么小。
is-kit 有:
- 一个 entry
- 零 runtime dependencies
- 没有 bundler plugins
- 没有 CSS pipeline
- 没有复杂的 code splitting
而最重要的是,它原本就已经有办法从 consumer 的角度测试 packed package。
如果你的 package 依赖自定义 plugins、特殊 entry、CSS 处理、精确 source maps、自动生成 exports、严格 byte-size 限制,或者更旧的 Node build environment,那迁移就会变得更有挑战。
另外,还有一个 build environment requirement 值得提前确认。
这次我测试的版本是:
text
tsdown 0.23.0
Rolldown 1.2.8
对应的 Node engine requirement 是:
text
^22.18.0 || ^24.11.0 || >=26.0.0
我的 CI 使用:
text
Node 22.22.0
所以没有问题。
但如果你的 contributor 仍然使用 Node v20,或者较旧的 Node v22 来构建 library,那么这就是迁移前必须解决的问题。
这并不一定意味着你的 library consumers 也必须使用 Node v22。
这是 build tool 的要求。
不过,contributor 和 CI environment 本身也属于 migration cost 的一部分。😿
🎯 那么,从 tsup 迁移到 tsdown 值得吗?
对于 is-kit 来说,我觉得值得。
但不是因为性能。
这次迁移成功保留了 ESM、CJS、declarations、exports,以及 packed-package consumer tests 对应的 package contract。
配置修改很小,也很容易 review。
我现有的 Node environment 已经满足新的 build requirement。
而且现在这套构建配置也更贴近持续活跃发展的 Rolldown ecosystem。
当然,迁移成本也是真实存在的:
- artifact size 增加了
- Node build requirement 提高了
- output extensions 需要显式配置
所以我不会把这次迁移总结成:
所有使用 tsup 的人都应该立刻迁移到 tsdown,因为它更快!
这不是这次实验得出的结论。
对我来说,更准确的结论是:
如果你的项目已经满足 Node 要求,并且希望把构建体系逐渐迁向 Rolldown ecosystem,那么把一个小型 library 从 tsup 迁移到 tsdown,完全可以是一个合理的选择。
但一定要验证你最终发布出去的 package。
不要只看 source code。
不要只看 config。
更不要只看那个绿色的 build success。😸
因为这次迁移里最有意思的问题,恰恰发生在 build 告诉我"一切正常"的时候。
如果你想看看本文里的 package 在真实项目中是什么样子,is-kit 是开源的。
它是一个轻量、零依赖的 TypeScript type guard library,主要用于 runtime validation 和 safe narrowing。
如果你觉得它对你的项目有用,欢迎试试看。也欢迎在 GitHub 上给个 ⭐。
Issues 和 PR 也都非常欢迎。😸