本节目标:理解 Monorepo 的收益与成本,能判断自己的项目该不该用;掌握 Project References 的配置方式与
tsc -b的增量构建原理;知道composite、paths、references三者为什么会互相打架,以及怎么绕开。
16.3 Monorepo 与 Project References
前两节我们都在处理「一个包」的问题。但真实的工程往往不是一个包:服务端、Web 前端、共享类型库、CLI 工具,它们互相依赖,又需要一起演进。
把这些包放进一个仓库,叫 Monorepo。TypeScript 为此提供了一个原生机制,叫 Project References(项目引用)。这一节把这两件事讲清楚。
16.3.1 什么时候真的需要 Monorepo
Monorepo 不是「更高级的仓库形态」,它有明确的成本:
| 维度 | 收益 | 成本 |
|---|---|---|
| 代码复用 | 共享包改动立刻全仓可见 | 容易产生隐性耦合 |
| 依赖管理 | 版本对齐简单,一份 lockfile | 单一版本策略可能冲突 |
| 重构 | 跨包重命名一次改完 | 影响面大,审查压力高 |
| CI | 可做增量构建 | 需要额外工具支持,否则全量跑 |
| 权限 | 无 | 无法按包做仓库级权限隔离 |
判断标准可以简化成两条:
- 包之间有真实的类型或接口契约。 比如前后端共享一套 API 类型定义——这是最强的信号。
- 它们必须一起发布或一起演进。 如果两个包半年才互相看一眼,拆成独立仓库更省事。反过来,如果只是「想在一个窗口里看所有代码」,那不需要 Monorepo。
16.3.2 一个最小 Monorepo 的目录布局
我们用 pnpm workspace 搭一个三包结构:
my-monorepo/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── packages/
│ ├── shared/ # 共享类型与工具
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/index.ts
│ ├── server/ # 依赖 shared
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/index.ts
│ └── web/ # 依赖 shared
│ ├── package.json
│ ├── tsconfig.json
│ └── src/index.ts
└── tsconfig.json # 根配置,只做「聚合」
pnpm-workspace.yaml 声明哪些目录是工作区:
packages:
- "packages/*"
包之间的依赖用 workspace: 协议声明:
{
"name": "@my/server",
"dependencies": {
"@my/shared": "workspace:*"
}
}
workspace:* 的意思是「永远用仓库里这一份」,发布时 pnpm 会自动替换成真实版本号。这样本地开发不需要先 npm link,改 shared 立刻对 server 生效。
16.3.3 不用 Project References 会怎样
先看「土办法」:在每个包的 tsconfig 里配路径别名,直接指到源码。
// packages/server/tsconfig.json(不推荐)
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@my/shared": ["../shared/src/index.ts"]
}
}
}
这能让编辑器跳转正常,但埋了三个雷:
- 编译产物里会留下别名。
tsc不认识打包器,产出的.js里还是@my/shared,Node 运行时直接ERR_MODULE_NOT_FOUND。 - 类型是「看源码」得来的,不是「看声明」得来的。 这掩盖了
shared是否真的正确导出了类型,容易在发布后暴露。 - 无法增量。 每次编译
server都要把shared的源码重新分析一遍,包一多,时间线性上涨。
Project References 就是为这三件事设计的:它要求每个被引用的包先构建出声明文件,引用方消费的是 .d.ts 而不是源码。这样既保证了运行时正确(产物是真实文件),又让增量构建成为可能。
16.3.4 composite:让包「可被引用」
要成为被引用的项目,tsconfig 里必须打开 composite:
// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}
composite: true 会隐式打开几个开关,其中有两条是硬性的:
- 必须能生成声明文件。 否则引用方拿不到类型。
include必须显式写出。 引用项目需要知道这个包的确切文件集合,靠默认扫描不行。
如果漏了 include,会看到:
error TS6307: File 'src/index.ts' is not listed within the file list of project
'packages/shared/tsconfig.json'. Projects must list all files or use an
'include' pattern.
composite 还会生成 .tsbuildinfo 文件——这是增量构建的「记忆」,记录了上次编译时每个文件的哈希。它应该进 .gitignore,但 CI 里建议缓存它,否则每次都是全量编译。
16.3.5 references:声明依赖关系
引用方在 tsconfig 里列出它依赖的项目:
// packages/server/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
注意 references 里的 path 指向的是目录(含 tsconfig.json 的目录),不是文件。
配好之后,构建方式要换成 -b(build 模式)。可以只构建某个包,也可以从根上构建全部:
npx tsc -b packages/server
npx tsc -b
tsc -b 和普通 tsc 有三个本质区别:
- 按依赖顺序构建。 它会先看
references图,从叶子节点开始,自动构建shared再构建server。 - 增量。 只重建变化的项目,靠
.tsbuildinfo判断。 - 读
.d.ts而非源码。 所以速度稳定,不随依赖深度爆炸。
一个必须记住的差异:tsc -b 不会因为一个项目出错就全盘停止,它会继续构建其他不受影响的项目,最后汇总报错。这在 CI 里很有用,但也会让人误以为「没报错」。
16.3.6 tsconfig 分层:base / 各包 / 根
Monorepo 里通常有三层配置,职责不同:
// tsconfig.base.json —— 所有包共享的编译选项,不含 include
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
}
}
// packages/web/tsconfig.json —— 包自己的差异
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["es2022", "dom", "dom.iterable"],
"outDir": "dist",
"rootDir": "src"
},
"references": [{ "path": "../shared" }],
"include": ["src"]
}
// tsconfig.json(根)—— 只做聚合,不编译任何文件
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/server" },
{ "path": "./packages/web" }
]
}
根配置里的 "files": [] 是个小技巧:它让根项目本身不包含任何文件,纯粹作为「构建入口」。编辑器打开仓库根目录时,会顺着 references 找到所有子项目,从而获得完整的跨包跳转与类型提示。
注意 extends 与 references 是两套独立机制:extends 是配置继承(共享选项),references 是项目依赖(共享产物)。很多人第一次配时把它们搞混,结果要么类型跳不过去,要么构建顺序错乱。
第 16.1 节讲的严格模式与 target 选项,就集中放在 tsconfig.base.json 里——这正是分层最大的价值:严格度只在一处定义,全仓统一。相关细节见 16.1 编译目标与严格模式配置
。
16.3.7 增量构建:tsc -b --watch 与 CI 缓存
开发时用 build 模式的 watch:
npx tsc -b --watch
它会监听所有被引用项目的文件,任何一处改动都会重建受影响的项目。相比在每个包里各跑一个 tsc --watch,这种方式**不会出现「改了 shared 但 server 不知道」**的问题。
CI 里的关键是把 .tsbuildinfo 缓存起来:
- uses: actions/cache@v4
with:
path: "**/*.tsbuildinfo"
key: tsbuild-${{ hashFiles('**/tsconfig*.json', 'pnpm-lock.yaml') }}
缓存命中时,tsc -b 几乎瞬间完成。如果缓存 key 里不含 tsconfig,改了配置却复用旧缓存,会得到「改了配置不生效」的诡异现象——这类问题排查起来很耗时。
如果你还想进一步缩短流水线,可以看 TypeScript 构建性能优化 。
16.3.8 paths 别名与项目引用的冲突
这是 Monorepo 里最容易出问题的地方。为了让 import 短一点,很多人配 paths:
{
"compilerOptions": {
"paths": {
"@my/shared": ["../shared/src/index.ts"]
}
}
}
一旦配了指向源码的 paths,references 就形同虚设——因为编译器直接读源码,跳过了声明文件与增量机制。
正确做法二选一:
方案一:靠 workspace 依赖,不配 paths。 让 @my/shared 通过 node_modules 解析到 shared 包的 package.json,再用 exports 指向 dist:
{
"name": "@my/shared",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
这样 import { User } from "@my/shared" 走的是真实包解析,产物与声明都对得上。
方案二:paths 指向 dist 的声明文件(而不是源码):
{
"compilerOptions": {
"paths": {
"@my/shared": ["../shared/dist/index.d.ts"]
}
}
}
这样保留了短别名,同时消费的是声明文件。代价是必须先构建 shared 才有类型,冷启动会报「找不到模块」,需要先跑一次 tsc -b。
两种方案都可行,但不要混用:一部分包指向源码、一部分指向 dist,会导致类型不一致甚至重复定义。关于包入口字段的完整语义,第 11 章 11.3 npm 包、类型声明与 exports 有系统讲解。
16.3.9 与 pnpm、Turborepo 的配合
tsc -b 解决的是「TypeScript 项目之间」的依赖。但一个仓库里还有 lint、test、build 脚本需要编排,这就轮到任务编排工具出场:
| 工具 | 职责 | 与 tsc -b 的关系 |
|---|---|---|
| pnpm workspace | 依赖安装、workspace: 协议 | 提供包解析基础 |
| tsc -b | 类型检查与声明文件构建 | 编排器里的一条任务 |
| Turborepo | 任务编排、远程缓存 | 调用 tsc -b 并缓存结果 |
| Nx | 任务编排、依赖图可视化 | 同上,功能更重 |
典型 turbo.json:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "*.tsbuildinfo"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
"dependsOn": ["^build"] 里的 ^ 表示「先构建所有被依赖的包」,语义与 references 一致。注意这里存在功能重叠:Turborepo 自己也会按依赖图排序,如果你同时用 tsc -b,两层编排可能重复工作。常见做法是二选一——要么让 Turborepo 调用每个包的 tsc --noEmit(纯检查),要么让每个包的 build 脚本内部用 tsc -b(自己管依赖)。
延伸阅读:TypeScript Monorepo 与 Turborepo 、Monorepo 工程对比 、Monorepo CI 策略 。
16.3.10 常见报错速查
| 报错 | 原因 | 处理 |
|---|---|---|
TS6307: File is not listed within the file list of project | 被引用项目缺 include | 显式写出 include |
TS6306: Referenced project must have setting "composite": true | 被引用项目没开 composite | 加上 "composite": true |
TS6059: File is not under 'rootDir' | rootDir 配小了 | 调整 rootDir 或文件位置 |
TS6305: Output file has not been built from source file | 引用方在消费源码而非产物 | 先跑 tsc -b,并检查 paths |
| 改了 shared,server 类型没更新 | 没走 tsc -b,或缓存过期 | 用 tsc -b;清 .tsbuildinfo |
Cannot find module '@my/shared'(冷启动) | paths 指向 dist 但尚未构建 | 先执行一次 tsc -b |
| CI 里构建时间没变短 | .tsbuildinfo 未被缓存 | 在 CI 中缓存该文件 |
排查这类问题的通用思路是:先跑 npx tsc -b --verbose,它会打印每个项目的构建决策(是跳过、是复用缓存、还是重建),一眼就能看出编排是否符合预期。
npx tsc -b --verbose 2>&1 | head -30
小结
本节把「多个包在一个仓库里协同」这件事拆成了三层:目录与依赖层用 pnpm workspace 与 workspace: 协议把包连起来;类型层用 composite + references 让包与包之间消费声明文件而非源码,从而获得正确的运行时行为与增量构建能力;编排层用 Turborepo 之类的工具把 lint、test、build 串成流水线。我们还特别强调了 paths 与 references 的冲突——指向源码的别名会让项目引用失效,这是 Monorepo 里最高频的坑。
到此为止,你已经掌握了从单包配置、构建打包到多包协同的完整工程配置能力。接下来该把视角拉高一层:一个真实项目的目录该怎么分层、类型该放在哪、前后端的契约怎么共享。下一节进入第 17 章,从 17.1 项目结构与分层设计 开始。
阅读导航:上一节:16.2 esbuild/swc/tsup 与打包产物 · 下一节:17.1 项目结构与分层设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。