TypeScript Monorepo 中源码引用与构建引用
说明 monorepo 中开发期源码引用、构建期产物引用的分层思路,以及几种更优雅的实现路径。
[!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 的源码图
- 构建结果更稳定,也更容易缓存
这就是为什么很多仓库会接受“开发看源码、构建看产物”的分层。
对比与易混淆点
不是“源码引用先进,构建引用落后”
这两个词更像在回答不同问题:
- 源码引用回答“开发时怎样更顺手”
- 构建引用回答“发布边界怎样更真实”
真正优雅的工程化,通常是同时承认这两件事都重要。
paths、exports 和 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只负责 JStsc -p tsconfig.build.json --emitDeclarationOnly只负责声明
这是复杂业务库里很常见的标准答案。
3. 轻量包继续用 bundler 自带 DTS
对依赖图很浅的包,继续用 tsup dts: true 也可以,但最好显式切断构建态的源码级 paths。
4. 完整 project references / solution-style build
这是 TypeScript 官方味最重的路线,适合 tsc 主导的大型仓库,但在 bundler-heavy 的前端仓库里不一定最省心。
真正优雅的默认原则
可以把更稳的基线总结成五条:
- 业务代码只导入稳定包名,不导入别的包的
src - 开发态允许受控的源码白名单
- 构建态必须回到真实包边界
- 复杂库和轻量库使用不同实现手段
- 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。
问题是 在正在页面时的静态分析中,它会使用什么配置文件