《TypeScript编程入门》16.3 Monorepo 与 Project References

本节解决「多个包如何在一个仓库里协同」这件事。先说清什么时候真的需要 Monorepo,再用一个 pnpm workspace 示例搭出目录结构;接着解释不用 Project References 会退化成什么样子,配好 composite 与 references 跑通 tsc -b 增量构建;最后处理 paths 别名与项目引用的冲突,并给出高频报错清单。

本节目标:理解 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可做增量构建需要额外工具支持,否则全量跑
权限无无法按包做仓库级权限隔离

判断标准可以简化成两条:

  1. 包之间有真实的类型或接口契约。 比如前后端共享一套 API 类型定义——这是最强的信号。
  2. 它们必须一起发布或一起演进。 如果两个包半年才互相看一眼,拆成独立仓库更省事。反过来,如果只是「想在一个窗口里看所有代码」,那不需要 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"]
    }
  }
}

这能让编辑器跳转正常,但埋了三个雷:

  1. 编译产物里会留下别名。 tsc 不认识打包器,产出的 .js 里还是 @my/shared,Node 运行时直接 ERR_MODULE_NOT_FOUND。
  2. 类型是「看源码」得来的,不是「看声明」得来的。 这掩盖了 shared 是否真的正确导出了类型,容易在发布后暴露。
  3. 无法增量。 每次编译 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 有三个本质区别:

  1. 按依赖顺序构建。 它会先看 references 图,从叶子节点开始,自动构建 shared 再构建 server。
  2. 增量。 只重建变化的项目,靠 .tsbuildinfo 判断。
  3. 读 .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 项目结构与分层设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes