TypeScript Monorepo 中源码引用与构建引用

说明 monorepo 中开发期源码引用、构建期产物引用的分层思路,以及几种更优雅的实现路径。

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

[!info] related notes

TypeScript Monorepo 中源码引用与构建引用

范围

这篇笔记讨论的不是“代码里写两套 import”,而是 monorepo 中更稳妥的一种分层:

  • 业务代码始终只写稳定包名
  • 开发态把包名解析到源码
  • 构建态把包名解析到构建产物或真实 exports

换句话说,变化的应该是解析策略,而不是业务代码的写法。

为什么要放在一起理解

源码引用和构建引用经常被拿来对立讨论,但它们解决的是两组不同目标:

  • 开发态更关心联调速度、IDE 跳转、热更新和改动即时可见
  • 构建态更关心包边界、声明产物、可缓存性和发布一致性

如果强迫同一套解析策略同时满足这两组目标,常见结果就是:

  • 开发顺手,但构建边界模糊
  • 构建很严,但日常联调成本过高

因此更优雅的做法通常不是二选一,而是分层处理。

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

不优雅的起点:代码层出现两套 import 风格

最差的形态通常是:

  • 一部分地方写 @dailyuse/foo
  • 另一部分地方直接写 ../foo/src/index.ts

这会让“包边界”直接泄漏到业务代码层,后续迁移、发布和验证都会更混乱。

更优雅的起点:代码层只写稳定包名

更稳的基线是:

  • 业务代码永远只 import @scope/pkg
  • 开发工具决定它落到 src
  • 构建工具决定它落到 dist / exports

这样做的价值是:

  • import 语义稳定
  • 重构 alias 和构建链路时,不需要改业务代码
  • 可以分别优化开发体验和发布边界

开发态为什么偏向源码引用

开发期把包名解析到源码,常见收益是:

  • IDE 跳转和类型联调更自然
  • 改上游包后,下游不用先手动 build
  • 前端 dev server、测试和 watch 体验通常更顺

但代价也很明确:

  • 当前 TypeScript program 更容易把上游源码一起吃进来
  • rootDir、边界泄漏和 IDE 压力更常见
  • 如果配置不严,CI 容易和本地看到不同世界

构建态为什么偏向构建引用

构建期回到产物或真实导出,常见收益是:

  • 包边界更接近真实消费者环境
  • 更早暴露 exports、类型入口和运行时入口问题
  • 声明生成不必无限追整个 monorepo 的源码图
  • 构建结果更稳定,也更容易缓存

这就是为什么很多仓库会接受“开发看源码、构建看产物”的分层。

对比与易混淆点

不是“源码引用先进,构建引用落后”

这两个词更像在回答不同问题:

  • 源码引用回答“开发时怎样更顺手”
  • 构建引用回答“发布边界怎样更真实”

真正优雅的工程化,通常是同时承认这两件事都重要。

pathsexports 和 project references 不在同一层

  • paths 更像开发期解析映射
  • exports 更像包对外暴露的真实运行时边界
  • project references 更像 TypeScript 项目级依赖图和增量构建机制

把它们混成“一种东西”来理解,配置会很容易失控。

成熟实现通常分成四类

1. 双 tsconfig

开发态 tsconfig.json 指向 workspace 源码,构建态 tsconfig.build.json 指向 dist / .d.ts,或者至少切断源码级 paths

这是最通用、最稳的实现。

2. JS 和 DTS 双轨

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

这是复杂业务库里很常见的标准答案。

3. 轻量包继续用 bundler 自带 DTS

对依赖图很浅的包,继续用 tsup dts: true 也可以,但最好显式切断构建态的源码级 paths

4. 完整 project references / solution-style build

这是 TypeScript 官方味最重的路线,适合 tsc 主导的大型仓库,但在 bundler-heavy 的前端仓库里不一定最省心。

真正优雅的默认原则

可以把更稳的基线总结成五条:

  1. 业务代码只导入稳定包名,不导入别的包的 src
  2. 开发态允许受控的源码白名单
  3. 构建态必须回到真实包边界
  4. 复杂库和轻量库使用不同实现手段
  5. DTS 生成策略必须显式,不能靠继承根 paths 碰运气

常见反模式

下面这些通常不够优雅:

  • tsconfig.base.json 把所有 workspace 包全局映射到 src,库构建也直接继承它
  • 库源码直接写 ../other-package/src/...
  • 同一个库一边声称模拟发布边界,一边偷偷依赖别的 workspace 私有源码结构
  • 所有包都默认 tsup dts: true,但没人控制 DTS 的解析边界
  • 为了掩盖 OOM 或类型图爆炸,只加内存,不处理依赖边界

默认推荐

对大多数 TypeScript monorepo,可以把默认答案压缩成一句话:

开发态尽量消费源码,构建态尽量消费产物,但业务代码永远只写稳定包名。

如果再展开一层,通常会是:

  • 代码层:统一写包名
  • 开发层:tsconfig / dev server alias 指向源码
  • 构建层:复杂库用 bundler(JS) + tsc(DTS),简单库再考虑 bundler 一把梭
  • 验证层:至少有一条 CI 链路按 dist / exports 跑,防止本地源码别名把问题藏起来

问题(思考)

Q1: tsconfig 配置的规则

要求应该是: 开发期用 tsconfig.dev.json 把 workspace 包映射到源码;构建期用 tsconfig.build.json 移除源码 paths,并要求依赖包先 build,通过 package.json exports 读取 dist。 问题是 在正在页面时的静态分析中,它会使用什么配置文件

创建于 2026/2/10 更新于 2026/5/27