很多团队第一次认真讨论 TypeScript 工程治理,不是因为突然爱上了类型系统,而是因为仓库开始变慢:保存一个文件,编辑器转半天;跑一次 tsc --noEmit,CI 里要排长队;一个包里改了路径别名,另一个包突然开始报类型错误。这个阶段的问题已经不再是“泛型会不会写”,而是“类型系统有没有被组织起来”。
大型仓库里的 TypeScript,最怕的不是约束变严,而是所有东西都挤在同一个 tsconfig.json 里假装自己还很简单。项目引用、增量编译和 tsconfig 分层的价值,本质上是在把仓库切回多个清晰边界:谁先编译、谁依赖谁、谁只暴露声明、谁不能跨边界直连内部实现,都要明确定义。
为什么单一 tsconfig 在仓库变大后会开始失灵
单一 tsconfig 早期确实省事。所有源码都被一个配置覆盖,路径别名统一写一遍,编辑器和构建工具也容易对齐。但当仓库扩张到多个包、多个运行环境或多个交付目标时,单一配置会把三类问题叠在一起:
- 编译目标不同,例如浏览器包、Node 包、脚本工具包的
lib和module需求完全不一样。 - 依赖边界不清,任何目录都能随手相对路径穿透到别的包内部。
- 缓存粒度太粗,只要一个角落变化,整个仓库的类型检查都得重算。
到这一步,你看到的“TypeScript 变慢”,通常不是 TypeScript 自身的问题,而是仓库结构还停留在小项目时代。
项目引用解决的不是性能魔法,而是编译顺序和边界可见性
project references 最常被误解成一个“让 tsc 更快”的开关。它确实有性能收益,但更关键的是把仓库拆成多个可独立编译的项目节点。只要节点关系清楚,增量编译和声明复用才有意义。
一个典型的根配置会长这样:
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/web" },
{ "path": "./packages/admin" }
]
}
被引用的子项目需要开启 composite,让 TypeScript 知道它是一个能生成声明、能被其他项目依赖的稳定单元:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types"
},
"include": ["src/**/*"]
}
这时仓库的核心变化不是“更快”三个字,而是每个包都开始拥有明确身份:它是一个可被消费的项目,不再只是一个目录。
tsconfig 分层要先回答“谁负责通用约束,谁负责环境差异”
很多团队做分层时犯的第一个错,是一口气拆出 tsconfig.base.json、tsconfig.node.json、tsconfig.build.json、tsconfig.test.json、tsconfig.editor.json,最后谁也说不清哪个配置才是真相。分层不是为了文件数量好看,而是为了分清两类责任:
- 通用约束:严格模式、路径映射约定、基础语言选项。
- 环境差异:DOM、Node、测试、构建脚本各自的 lib 和 module 需求。
一个比较稳的做法通常是三层:
tsconfig.base.json只放全仓共享规则。- 环境配置如
tsconfig.node.json、tsconfig.dom.json只处理运行环境差异。 - 具体包的 tsconfig 只声明本包输入输出和 references。
这样做的好处是,团队知道什么地方可以调,什么地方不能随便改。否则每次遇到报错都往最近的 tsconfig 里塞配置,最后整个仓库会变成“能过就行”的配置堆。
边界治理的关键不是 alias,而是禁止绕过公共入口
路径别名能提升可读性,但它本身不等于边界治理。很多仓库用了 @shared/* 之后,实际上只是把深层相对路径换成了更好看的深层穿透:
// 看起来整洁,实际上仍在依赖内部实现
import { internalNormalize } from '@shared/src/internal/normalize'
这类导入最大的问题不是样式,而是调用方直接绑定了内部细节。一旦共享包重构目录、拆文件或替换实现,所有下游都会跟着抖动。
更稳的原则是:
- 允许依赖包入口导出的类型和函数。
- 禁止导入另一个包的
src/internal、utils/private等内部层。 - 用
exports、lint 规则或构建约定把边界做成机制,而不是写进团队口头规则。
项目引用负责描述“谁依赖谁”,公共入口负责约束“依赖到哪一层为止”。
一个常见失败案例:仓库拆包了,但还是像大泥球一样互相穿透
某个中型前端仓库做了 monorepo 改造,也开启了 project references,但几个月后编译依旧慢、改动依旧脆。复盘时发现,他们虽然把代码移到了 packages/ 下,却没有真正建立边界:
- 包之间继续互相导入
src内部文件。 - 每个子项目都私自覆盖严格模式和路径别名。
- CI 仍然按“全仓单次 typecheck”运行,没有利用项目粒度缓存。
最后问题不是工具没开,而是仓库结构和治理方式没有跟上。TypeScript 只能复用已经被组织好的边界,不能替团队自动发明边界。
落地顺序建议:先拆公共基础包,再建立根引用,再收紧边界
如果你准备从单仓单配置迁移,不要一开始就试图把所有包都改成标准形态。更稳的做法是分三步:
- 先识别真正的共享基础包,例如 schema、工具函数、类型定义。
- 给这些基础包补
composite、声明输出和清晰入口。 - 再逐步让上层应用通过 references 依赖它们,并把深层穿透关掉。
这个顺序的好处是,你先把仓库里最容易被反复依赖的部分稳定下来,再处理更复杂的应用层。否则一上来全盘迁移,团队很容易在配置和路径问题里失去耐心。
决策清单:什么时候说明你该上项目引用和 tsconfig 分层了
- 一个仓库里已经存在多个可独立发布或独立部署的包。
- 类型检查时间开始显著影响本地反馈或 CI 时长。
- 包之间出现大量深层相对路径或路径别名穿透。
- 不同运行环境需要反复互相覆盖
lib、module、types配置。 - 新成员已经无法判断“改一个 tsconfig 会影响哪些项目”。
如果这些信号已经同时出现,继续维持单一配置只会让问题在更大的规模上重复发生。
总结
大型仓库里的 TypeScript 治理,重点不是把配置拆得多漂亮,而是让编译、依赖和边界重新对应起来。项目引用解决编译顺序和缓存粒度,tsconfig 分层解决通用规则与环境差异,公共入口和导出约束解决依赖穿透。三件事一起做,仓库才会从“能跑”走向“可持续扩张”。
本批次专题导航:
- 工程边界:TypeScript 项目引用与 tsconfig 分层、TypeScript Monorepo 依赖边界治理、TypeScript 类型检查性能优化
- 协议协作:TypeScript 公共 API 设计、TypeScript 运行时校验与静态类型协作、TypeScript 与 OpenAPI 契约协同
- 落地复用:TypeScript 设计模式实战、TypeScript 测试数据构建
- 状态建模:TypeScript 事件系统建模、TypeScript 表单与错误状态建模
本系列导航:
- 继续收紧仓库边界,先读 TypeScript Monorepo 依赖边界治理
- 如果你已经在拆包和做缓存,再接着看 TypeScript 类型检查性能优化
- 若你接下来要治理对外导出边界,可读 TypeScript 公共 API 设计
延伸阅读:


