将一个真实的 TypeScript OSS 库从 tsup 迁移到 tsdown

配置迁移本身很小。真正有意思的是如何保持 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.json exports
  • 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 上给个 ⭐。

github.com/nyaomaru/is...

Issues 和 PR 也都非常欢迎。😸

相关推荐
胡写代码1 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
沐言人生1 小时前
82.4k 星!把十几万行代码变成知识图谱,新人终于不用硬啃了
前端·后端·github
溪语流沙1 小时前
【Web全栈进阶】JWT无状态认证:签发、校验、刷新
前端·git·python·github
在繁华处1 小时前
2.1 上下文:决定 Agent 能力上限的关键
前端·人工智能·microsoft
ndsc_d1 小时前
2026年有哪些好用的AI UI设计工具?主流工具功能和适用场景对比
前端·人工智能·ui·ai·设计师·ai ui·ai ui工具
溪语流沙2 小时前
【每天一个CSS | Day18】会摆动的拟物怀表:黄铜、皮革与催眠摆
前端·css3·html5
Lvan的前端笔记2 小时前
docker:基于docker的前端部署方案
前端·docker·状态模式
程序人生8882 小时前
从“把隐私文件上传到别人云端“到本地可控:DocConverter Web 立项初心与架构选型
前端·图像处理·人工智能·opencv·架构·ocr·文心一言
开开心心就好2 小时前
视频里的图片怎么提取?双击一下就导出
java·前端·人工智能·spring·智能手机·intellij-idea·excel