TypeScript 项目引用与 tsconfig 分层:大型仓库的增量编译与边界治理

HTMLPAGE 团队
18 分钟阅读

项目一变大,TypeScript 的问题就不再是语法,而是编译速度、依赖边界和 tsconfig 漂移。本文从 project references、tsconfig 分层和包边界设计出发,讲清大型仓库里的 TypeScript 工程治理方法。

#TypeScript #Project References #tsconfig #Monorepo #Incremental Build

很多团队第一次认真讨论 TypeScript 工程治理,不是因为突然爱上了类型系统,而是因为仓库开始变慢:保存一个文件,编辑器转半天;跑一次 tsc --noEmit,CI 里要排长队;一个包里改了路径别名,另一个包突然开始报类型错误。这个阶段的问题已经不再是“泛型会不会写”,而是“类型系统有没有被组织起来”。

大型仓库里的 TypeScript,最怕的不是约束变严,而是所有东西都挤在同一个 tsconfig.json 里假装自己还很简单。项目引用、增量编译和 tsconfig 分层的价值,本质上是在把仓库切回多个清晰边界:谁先编译、谁依赖谁、谁只暴露声明、谁不能跨边界直连内部实现,都要明确定义。

为什么单一 tsconfig 在仓库变大后会开始失灵

单一 tsconfig 早期确实省事。所有源码都被一个配置覆盖,路径别名统一写一遍,编辑器和构建工具也容易对齐。但当仓库扩张到多个包、多个运行环境或多个交付目标时,单一配置会把三类问题叠在一起:

  • 编译目标不同,例如浏览器包、Node 包、脚本工具包的 libmodule 需求完全不一样。
  • 依赖边界不清,任何目录都能随手相对路径穿透到别的包内部。
  • 缓存粒度太粗,只要一个角落变化,整个仓库的类型检查都得重算。

到这一步,你看到的“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.jsontsconfig.node.jsontsconfig.build.jsontsconfig.test.jsontsconfig.editor.json,最后谁也说不清哪个配置才是真相。分层不是为了文件数量好看,而是为了分清两类责任:

  • 通用约束:严格模式、路径映射约定、基础语言选项。
  • 环境差异:DOM、Node、测试、构建脚本各自的 lib 和 module 需求。

一个比较稳的做法通常是三层:

  1. tsconfig.base.json 只放全仓共享规则。
  2. 环境配置如 tsconfig.node.jsontsconfig.dom.json 只处理运行环境差异。
  3. 具体包的 tsconfig 只声明本包输入输出和 references。

这样做的好处是,团队知道什么地方可以调,什么地方不能随便改。否则每次遇到报错都往最近的 tsconfig 里塞配置,最后整个仓库会变成“能过就行”的配置堆。

边界治理的关键不是 alias,而是禁止绕过公共入口

路径别名能提升可读性,但它本身不等于边界治理。很多仓库用了 @shared/* 之后,实际上只是把深层相对路径换成了更好看的深层穿透:

// 看起来整洁,实际上仍在依赖内部实现
import { internalNormalize } from '@shared/src/internal/normalize'

这类导入最大的问题不是样式,而是调用方直接绑定了内部细节。一旦共享包重构目录、拆文件或替换实现,所有下游都会跟着抖动。

更稳的原则是:

  • 允许依赖包入口导出的类型和函数。
  • 禁止导入另一个包的 src/internalutils/private 等内部层。
  • exports、lint 规则或构建约定把边界做成机制,而不是写进团队口头规则。

项目引用负责描述“谁依赖谁”,公共入口负责约束“依赖到哪一层为止”。

一个常见失败案例:仓库拆包了,但还是像大泥球一样互相穿透

某个中型前端仓库做了 monorepo 改造,也开启了 project references,但几个月后编译依旧慢、改动依旧脆。复盘时发现,他们虽然把代码移到了 packages/ 下,却没有真正建立边界:

  • 包之间继续互相导入 src 内部文件。
  • 每个子项目都私自覆盖严格模式和路径别名。
  • CI 仍然按“全仓单次 typecheck”运行,没有利用项目粒度缓存。

最后问题不是工具没开,而是仓库结构和治理方式没有跟上。TypeScript 只能复用已经被组织好的边界,不能替团队自动发明边界。

落地顺序建议:先拆公共基础包,再建立根引用,再收紧边界

如果你准备从单仓单配置迁移,不要一开始就试图把所有包都改成标准形态。更稳的做法是分三步:

  1. 先识别真正的共享基础包,例如 schema、工具函数、类型定义。
  2. 给这些基础包补 composite、声明输出和清晰入口。
  3. 再逐步让上层应用通过 references 依赖它们,并把深层穿透关掉。

这个顺序的好处是,你先把仓库里最容易被反复依赖的部分稳定下来,再处理更复杂的应用层。否则一上来全盘迁移,团队很容易在配置和路径问题里失去耐心。

决策清单:什么时候说明你该上项目引用和 tsconfig 分层了

  • 一个仓库里已经存在多个可独立发布或独立部署的包。
  • 类型检查时间开始显著影响本地反馈或 CI 时长。
  • 包之间出现大量深层相对路径或路径别名穿透。
  • 不同运行环境需要反复互相覆盖 libmoduletypes 配置。
  • 新成员已经无法判断“改一个 tsconfig 会影响哪些项目”。

如果这些信号已经同时出现,继续维持单一配置只会让问题在更大的规模上重复发生。

总结

大型仓库里的 TypeScript 治理,重点不是把配置拆得多漂亮,而是让编译、依赖和边界重新对应起来。项目引用解决编译顺序和缓存粒度,tsconfig 分层解决通用规则与环境差异,公共入口和导出约束解决依赖穿透。三件事一起做,仓库才会从“能跑”走向“可持续扩张”。

本批次专题导航:

本系列导航:

延伸阅读: