库构建中 JS 打包与 DTS 生成分层

说明为什么复杂库常把 JavaScript 打包和 TypeScript 声明生成拆成两条链路,以及这种分层何时比 bundler 一把梭更稳。

#tech / dev #resource / typescript #type / synthesis #status / growing

[!info] related notes

库构建中 JS 打包与 DTS 生成分层

范围

这篇笔记讨论的是库构建中的一个具体分层:

  • JavaScript 产物由 bundler 负责
  • 类型声明产物由 tsc 单独负责

它关心的不是“该用哪个打包工具”,而是:

什么时候应该把 JS build 和 DTS generation 拆成两条链路?

为什么要放在一起理解

很多仓库一开始会觉得:

  • tsup --dts
  • rollup + dts 插件
  • vite build 顺手带类型

看起来最省事。

但当库开始变复杂以后,JS 打包和 DTS 生成其实会暴露出不同约束:

  • JS 打包更关心运行时代码、格式、external、tree-shaking
  • DTS 生成更关心类型图、跨包边界、声明入口、paths 解析

把两者强绑在一条隐式链路上,问题通常更难定位。

依赖路径 / 调用链 / 演进链

简单阶段:bundler 一把梭

在叶子包或轻量工具包里,直接:

  • tsup dts: true

往往足够。

因为这时:

  • 入口少
  • 类型图浅
  • 跨包边界简单

复杂阶段:开始出现职责分裂

当包变成下面这种形态时,分层价值会迅速变大:

  • 多入口
  • 子路径导出
  • 复杂类型导出
  • 深层跨包依赖
  • monorepo 内需要区分开发态和构建态解析

这时更稳的结构通常是:

  • bundler 只读源码,产出 .js
  • tsc --emitDeclarationOnly 单独产出 .d.ts

典型落地

一个常见组合是:

  • tsup / vite build / rollup 负责 JS
  • tsc -p tsconfig.build.json --emitDeclarationOnly 负责 DTS

构建态如果还需要更严格的边界,可以再配合:

  • 构建专用 tsconfig.build.json
  • 切断或收窄源码级 paths
  • 必要时改为消费上游 dist/*.d.ts

对比与易混淆点

这不是“tsc 替代 bundler”

这里的分层不是让 tsc 接管整个 build,而是:

  • bundler 继续解决运行时代码打包问题
  • tsc 只负责类型产物

emitDeclarationOnly 的价值不只是“少生成 JS”

更重要的是:

  • 让声明生成拥有自己独立的解析边界
  • 让构建问题能分清是 JS 侧还是类型侧

不是所有包都值得上这套

如果包很轻,强行拆出第二条声明链路,收益可能不够。

更合理的经验法则通常是:

  • 轻量包先保持简单
  • 复杂库再显式分层

这套做法为什么常被认为更工程化

因为它把两个本来就不同的问题拆开了:

  • JS 怎么产出
  • 类型怎么产出

一旦出了问题,你更容易知道该看:

  • bundler 配置
  • tsconfig.build.json
  • 声明入口与 package.json types
  • project references / 上游 dist

而不是面对一个“大概都在 build 里”的黑箱。

创建于 2026/5/16 更新于 2026/5/27