Monorepo 管理策略

中等 🟡工程化
9 个标签
预计阅读时间:91 分钟
工程化Monorepo代码管理工具链TurborepopnpmNxchangesetsCI/CD

Monorepo 管理策略

Monorepo(单一仓库)是一种代码管理策略:把多个项目或包放进同一个版本控制仓库里统一管理。它不是一个具体的工具,而是一种组织方式;配合 pnpm workspace、Turborepo、Nx、changesets 等工具链,才能在大规模下真正发挥价值。本篇从概念出发,逐层展开到任务编排、依赖图、远程缓存、增量构建、版本发布、TypeScript 引用、循环依赖治理、权限与 CI 优化,并给出大量可直接落地的配置示例与真实案例数据。

如果用一个类比来理解:Multirepo(多仓库)像一栋栋独立的别墅,每栋有自己的水电、门禁、装修队;Monorepo 则像一座大型公寓楼,共享水电管网、统一物业和电梯,住户之间搬东西(复用代码)只需走个楼道,但楼越高(仓库越大),电梯(构建/CI)的调度就越考验工程能力。

---

一、Monorepo 概念

概念定义

Monorepo 的核心特征只有一句话:一个仓库,多个可独立发布/部署的项目。它通常包含:

单一代码仓库(single repository)
多个 app(应用)与 package(库)
共享的依赖、构建配置、lint/格式化规则
统一的开发、测试、发布流程

需要澄清两个常见误解:

1.Monorepo 不等于「把所有代码塞进一个巨型工程」。它内部仍然是清晰拆分的多个包,每个包有自己的 `package.json`、边界与职责,只是共处一个 Git 仓库。
2.Monorepo 不等于 monolith(单体应用)。单体是「一个部署单元」,Monorepo 是「一个代码仓库」,二者正交:你完全可以在一个 Monorepo 里放 20 个可独立部署的微服务。

为什么重要

前端与 Node 生态在过去十年经历了「组件化 → 微前端 → 设计系统 → BFF/全栈」的演进,一个中大型团队往往同时维护:主站、后台、移动端 H5、组件库、工具库、脚手架、Node 服务。如果每个都是独立仓库,会出现:

组件库改一个 `Button`,要在 6 个仓库里逐个升级版本、发 PR、等 CI、合并、发布,链路长达数小时甚至数天。
版本地狱:A 仓库用组件库 1.2,B 仓库还停在 1.0,线上样式不一致,排查困难。
重复轮子:每个仓库各写一套日期格式化、请求封装、eslint 配置。

Monorepo 把这些痛点收敛到一个仓库里,一次原子提交就能同时改动组件库和所有使用方,天然保证「改了就一起改」。

原理

Monorepo 的可行性建立在三块基础设施之上:

1.包管理器的 workspace 机制:让仓库内的包互相以「本地软链接」方式引用,而不是从 npm registry 下载。
2.任务编排器(task runner):理解包之间的依赖图,按拓扑顺序、并行地执行 build/test/lint 等任务,并对结果做缓存。
3.版本与发布工具:管理哪些包变更了、如何升版本、生成 changelog、按顺序发布。

优势与挑战对比

| 维度 | Monorepo 优势 | Monorepo 挑战 |

| --- | --- | --- |

| 代码复用 | 内部包直接引用,改动即时可见 | 边界容易被打破,耦合失控 |

| 版本管理 | 统一视图,原子提交 | 大量包时版本策略复杂 |

| 依赖管理 | 单一 lockfile,依赖收敛 | 幽灵依赖、版本提升冲突 |

| 协作 | 跨团队变更一次搞定 | 权限与 review 边界需额外治理 |

| 构建/CI | 可做增量、缓存、affected | 全量构建慢,需工具优化 |

| 仓库规模 | 单一克隆入口 | 体积膨胀,Git 操作变慢 |

常见坑

一上来就 Monorepo:3 个人 2 个项目就搭 Nx + 远程缓存,收益远小于维护成本。
把 Monorepo 当垃圾桶:什么都往里塞,缺乏包边界规范,最后变成「大泥球」。
忽视构建性能:包数量到 50+ 后不做缓存与 affected,CI 从 5 分钟涨到 40 分钟。

最佳实践

有明确的「共享代码 + 多消费方」诉求时才上 Monorepo。
从轻量方案起步(pnpm workspace + Turborepo),规模上来再考虑 Nx/Rush/Bazel。
一开始就定好目录约定、包命名规范、依赖方向规则。

小结

Monorepo 的本质是「用工程能力换协作效率」。它把跨仓库协作的隐性成本,转化为可以用工具(缓存、增量、依赖图)系统性解决的显性问题。

---

二、Monorepo vs Multirepo

概念定义

Multirepo(多仓库 / polyrepo):每个项目/包一个独立 Git 仓库,独立版本、独立 CI、独立权限。
Monorepo(单仓库):多个项目/包共处一个 Git 仓库,统一管理。

为什么重要

选错策略的代价很高:Multirepo 团队后期常被「跨仓库联动发布」拖垮,Monorepo 团队则常被「构建/CI 性能」和「权限混乱」反噬。这个决策通常在项目早期做出,后期迁移成本巨大,所以值得认真权衡。

原理:差异根源

两者的根本差异在于「变更的作用域」。

Multirepo 中,一次变更天然被限制在单个仓库内,跨仓库联动靠「发版 + 升级依赖」间接完成,异步、松耦合。
Monorepo 中,一次 commit 可以横跨多个包,同步、强一致,但也要求工具能理解「这次改动影响了哪些包」。

代码示例:同一个需求的两种落地

场景:组件库给 `Button` 新增一个 `loading` 属性,主站要用上。

Multirepo 流程(伪脚本):

bashCode
# 仓库 A:组件库
cd ui-lib
git checkout -b feat/button-loading
# 改代码...
npm version minor              # 1.2.0 -> 1.3.0
npm publish
git push && 开 PR 等合并

# 仓库 B:主站(等 A 发布后)
cd web-app
npm install @my-org/ui@1.3.0   # 手动升级依赖
# 改代码用上 loading...
git push && 开 PR 等 CI

Monorepo 流程:

bashCode
# 单一仓库,一个分支
git checkout -b feat/button-loading
# 同时改 packages/ui/src/Button.tsx 和 apps/web/src/Page.tsx
pnpm build   # 自动按依赖图先构建 ui 再构建 web
git commit -m "feat(ui): Button 支持 loading,主站接入"
# 一个 PR,一次 review,一次 CI

真实案例

Google:整个公司几乎所有代码放在一个巨型 Monorepo,约 20 亿行代码、超过 8000 万个文件、每天数万次提交,用自研的 Piper 版本控制 + Blaze/Bazel 构建系统支撑。
Meta(Facebook):同样是单一巨型仓库,因 Git 无法承载其规模,自研了 Mercurial 扩展(EdenFS/Sapling)来支持稀疏检出与虚拟文件系统。
反例:很多创业团队早期用 Multirepo,随着共享组件增多,「联动发布」成为瓶颈,最终迁移到 Monorepo。也有大厂反向操作:把耦合过紧的 Monorepo 按业务域拆成若干中等仓库。

数据/对比表格

| 维度 | Monorepo | Multirepo |

| --- | --- | --- |

| 版本管理 | 统一,一处可见全貌 | 各自独立 |

| 原子提交 | 支持,跨包一次提交 | 不支持,需多次发版联动 |

| 代码共享 | 直接引用,即改即用 | 需发布 npm 包 |

| 依赖管理 | 单一 lockfile,易收敛 | 分散,易版本漂移 |

| 权限隔离 | 需 CODEOWNERS 等细粒度治理 | 天然按仓库隔离 |

| 仓库体积 | 大,克隆慢 | 小,克隆快 |

| CI/CD | 需增量/缓存优化 | 天然按仓库触发 |

| 上手成本 | 高(需工具链) | 低 |

| 适合场景 | 紧密相关、共享多、协作频繁 | 独立、权限严、发布节奏不同 |

常见坑

用 Monorepo 但不做 affected:每次 CI 全量跑,规模上来后极慢。
用 Multirepo 但共享代码多:陷入版本联动地狱。
混合方案(部分 Mono、部分 Multi)缺乏统一约定,认知负担翻倍。

最佳实践

判断标准:如果两个项目「经常需要一起改」,倾向 Monorepo;如果「几乎从不一起改」,倾向 Multirepo。
Monorepo 内部仍要保持清晰边界,避免退化成「假 Monorepo、真单体」。

小结

没有银弹。Monorepo 用工具成本换协作效率,Multirepo 用协作成本换隔离简单。选型看团队规模、代码耦合度与发布节奏。

---

三、包管理器 workspace:pnpm / yarn / npm

概念定义

workspace 是包管理器提供的能力:在一个仓库内声明多个子包,让它们之间的相互依赖以「本地符号链接(symlink)」的方式解析,而不是从远程 registry 下载。这是所有 Monorepo 的地基。

为什么重要

没有 workspace,Monorepo 里 `apps/web` 依赖 `packages/ui` 就只能靠「发布到 npm 再安装」或者手动 `npm link`,前者慢、后者脆弱。workspace 让「本地包 = 已安装的依赖」,这是 Monorepo 开发体验的核心。

原理

以 pnpm 为例,它用「内容寻址存储(content-addressable store)+ 硬链接/符号链接」实现依赖复用:

全局 store(`~/.pnpm-store`)保存每个包每个版本的唯一副本。
项目 `node_modules` 里通过硬链接指向 store,不重复占用磁盘。
workspace 内部包则通过符号链接指向源码目录。
pnpm 采用「非扁平化」的 `node_modules` 结构,只有显式声明的依赖才能被 import,从而杜绝「幽灵依赖(phantom dependency)」。

npm/yarn(classic)默认「扁平化 hoist」,所有依赖提升到根 `node_modules`,导致你可能 import 到没在 `package.json` 里声明的包——这就是幽灵依赖,换个环境就崩。

代码示例

pnpm workspace 声明文件:

yamlCode
# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'configs/*'
  # 排除某目录
  - '!**/__tests__/**'

npm workspace(npm 7+)在根 `package.json` 声明:

jsonCode
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

yarn workspace(yarn 1/berry)同样在根 `package.json`:

jsonCode
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": {
    "packages": ["apps/*", "packages/*"],
    "nohoist": ["**/react-native", "**/react-native/**"]
  }
}

常用命令对比:

bashCode
# pnpm:给指定包安装依赖
pnpm --filter @my-org/web add lodash
# pnpm:在所有包运行脚本
pnpm -r run build
# pnpm:只在依赖图上游/下游运行
pnpm --filter "@my-org/web..." run build   # web 及其依赖
pnpm --filter "...@my-org/ui" run test      # ui 及其被依赖方

# yarn berry
yarn workspace @my-org/web add lodash
yarn workspaces foreach run build

# npm
npm install lodash -w @my-org/web
npm run build --workspaces

真实案例

某中型团队从 yarn classic 迁移到 pnpm 后:`node_modules` 磁盘占用从约 3.2 GB 降到约 900 MB(多个应用共享 store),CI 的 install 阶段从约 90 秒降到约 35 秒(store 缓存命中时更快),同时消灭了长期困扰的幽灵依赖导致的偶发构建失败。

数据/对比表格

| 特性 | pnpm | yarn berry (v3/v4) | yarn classic (v1) | npm (v7+) |

| --- | --- | --- | --- | --- |

| workspace 支持 | 强 | 强 | 支持 | 支持 |

| 磁盘占用 | 最低(硬链接+store) | 低(PnP 可零 node_modules) | 高 | 高 |

| 安装速度 | 快 | 快 | 中 | 中 |

| 幽灵依赖防护 | 默认严格 | PnP 严格 | 无 | 无 |

| 依赖过滤 filter | 强大 | 强大 | 弱 | 一般 |

| 上手成本 | 低 | 中(PnP 有兼容坑) | 低 | 低 |

| 推荐度 | 首选 | 可选 | 不推荐新项目 | 够用 |

常见坑

peerDependencies 未声明:内部组件库依赖 react,却写进 dependencies,导致应用里出现两份 react,hooks 报错。应写进 peerDependencies。
hoist 相关的幽灵依赖:从 npm/yarn 迁到 pnpm 后突然报「找不到模块」,其实是之前一直在吃幽灵依赖。修复方式是显式声明,或临时配置 `.npmrc` 的 `public-hoist-pattern`。
lockfile 冲突:多人同时改依赖,`pnpm-lock.yaml` 冲突。约定「依赖变更单独提交」可缓解。

最佳实践

新项目直接用 pnpm workspace。
组件库的 react/vue 等宿主依赖放 peerDependencies。
用 `.npmrc` 固定 registry、开启 `shamefully-hoist` 仅作为兼容兜底而非常态。

小结

workspace 是 Monorepo 的地基,pnpm 因其磁盘效率与严格的依赖隔离成为当下首选。选对包管理器,后面的构建编排才有意义。

---

四、构建编排工具全景对比

概念定义

包管理器解决「包怎么装、怎么互相引用」,但「build/test/lint 这些任务按什么顺序、能不能并行、结果能不能缓存」需要专门的任务编排器。主流选手:Turborepo、Nx、Lerna、Rush、Bazel。

为什么重要

当仓库里有 30 个包时,`pnpm -r run build` 会一个个串行构建,可能要 15 分钟。任务编排器能:理解依赖图 → 拓扑排序 → 最大化并行 → 缓存未变化包的结果 → 只构建受影响的包。这直接决定本地开发和 CI 的体感速度。

原理

编排器的核心是三件事:

1.构建任务图:解析每个包的 `package.json` 依赖 + 任务配置里的 `dependsOn`,形成有向无环图(DAG)。
2.哈希与缓存:对每个任务的输入(源码、依赖、配置、环境变量)算哈希,若命中缓存则直接复用产物(本地或远程)。
3.调度执行:按拓扑顺序调度,无依赖关系的任务并行跑满 CPU。

代码示例:各工具最小配置

Turborepo(`turbo.json`,2.x 使用 `tasks` 字段):

jsonCode
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

Nx(`nx.json` 片段):

jsonCode
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

Lerna(`lerna.json`,现由 Nx 团队维护,底层可接 Nx 缓存):

jsonCode
{
  "version": "independent",
  "npmClient": "pnpm",
  "command": {
    "publish": {
      "conventionalCommits": true
    }
  }
}

Rush(`rush.json` 片段):

jsonCode
{
  "rushVersion": "5.100.0",
  "pnpmVersion": "8.6.0",
  "projects": [
    { "packageName": "@my-org/ui", "projectFolder": "packages/ui" },
    { "packageName": "@my-org/web", "projectFolder": "apps/web" }
  ]
}

Bazel(`BUILD.bazel` 片段,声明式、语言无关):

pythonCode
load("@npm//:defs.bzl", "npm_link_all_packages")
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")

ts_project(
    name = "ui",
    srcs = glob(["src/**/*.ts", "src/**/*.tsx"]),
    deps = ["//packages/utils"],
    declaration = True,
)

真实案例

Vercel:Turborepo 就是 Vercel 为解决自身 Next.js 生态的 Monorepo 构建慢而开发,后开源。其官网案例宣称远程缓存可让重复 CI 构建时间下降 40%-85%。
Nrwl/Nx:被大量企业级 Angular/React 团队采用,提供代码生成器(generators)、依赖图可视化、模块边界约束等超出「构建加速」的能力。
微软:Rush 用于内部大型仓库,强调严格的版本一致性策略(phantom dependency 防护、变更文件强制要求)。

数据/对比表格

| 工具 | 出品方 | 定位 | 缓存 | 远程缓存 | 代码生成 | 依赖图约束 | 学习曲线 | 适合规模 |

| --- | --- | --- | --- | --- | --- | --- | --- | --- |

| Turborepo | Vercel | 轻量任务编排 | 本地+远程 | 内置 | 弱 | 弱 | 平缓 | 中小到中大 |

| Nx | Nrwl | 全家桶平台 | 本地+远程 | Nx Cloud | 强 | 强 | 较陡 | 中大到大 |

| Lerna | 社区/Nx | 版本发布为主 | 可接 Nx | 依赖 Nx | 无 | 无 | 平缓 | 中小 |

| Rush | 微软 | 企业级严格治理 | 本地+远程 | 支持 | 一般 | 强 | 陡 | 大 |

| Bazel | Google | 语言无关超大规模 | 本地+远程 | 强 | 一般 | 强 | 很陡 | 超大 |

常见坑

过度选型:小团队直接上 Bazel/Nx 全家桶,配置和心智负担压垮生产力。
缓存配置错误:`outputs` 没配全,导致缓存命中但产物不完整,构建出错却难排查。
环境变量未纳入哈希:不同 env 复用了同一份缓存,产出错误的构建。

最佳实践

决策树:轻量需求(加速构建 + 简单发布)选 Turborepo;需要代码生成、强边界约束、企业级治理选 Nx;跨语言、万级模块选 Bazel。
Lerna 现在主要价值在「发布」,构建加速交给底层 Nx 或搭配 Turborepo。

小结

工具没有优劣,只有匹配度。绝大多数前端团队用 pnpm + Turborepo 就能覆盖 90% 需求;只有当边界治理、代码生成、跨语言构建成为刚需时,再向 Nx/Bazel 升级。

---

五、任务编排与依赖图

概念定义

任务编排(task orchestration)是指编排器根据包依赖关系和任务间依赖关系,自动决定「先跑谁、后跑谁、谁能并行」。依赖图(task graph)是这一切的基础数据结构:一个有向无环图,节点是「某个包的某个任务」,边是「依赖关系」。

为什么重要

手动写脚本控制构建顺序既易错又难维护。假设 web 依赖 ui,ui 依赖 utils,正确顺序必须是 utils → ui → web。包一多,人工维护顺序不现实,且无法自动并行。依赖图让编排器自动算出最优执行计划。

原理

关键语法是 `dependsOn` 里的两种前缀:

`^build`:脱字符 `^` 表示「先执行本包所有依赖(上游包)的 build」。这是拓扑依赖。
`build`(不带脱字符):表示「先执行本包自己的 build 任务」,用于同包内任务的先后(如 test 依赖本包 build)。

编排器读取依赖图后,做拓扑排序 + 并行调度:同一层级、无依赖关系的任务并行执行,充分利用多核。

代码示例:完整 turbo.json 任务图

jsonCode
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json", ".env"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "test/**"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "persistent": true
    },
    "deploy": {
      "dependsOn": ["build", "test", "lint"]
    }
  }
}

查看依赖图(可视化排查顺序问题):

bashCode
# Turborepo:生成任务图
turbo run build --graph=graph.html
# 或输出 dot 格式
turbo run build --dry-run=json

# Nx:可视化项目依赖图(打开交互式网页)
nx graph
# 只看受影响部分
nx affected:graph

真实案例

某电商前端仓库有 42 个包。改造前用 shell 脚本串行 build,全量约 12 分钟。接入 Turborepo 并配置正确的 `dependsOn` 后,依赖图允许 8 路并行,全量降到约 3 分半(未命中缓存),命中缓存时降到约 20 秒。

数据/对比表格

| 执行方式 | 全量构建耗时 | 说明 |

| --- | --- | --- |

| 串行脚本 | 约 12 分钟 | 一个个包顺序跑 |

| 并行无依赖感知 | 易出错 | 顺序错乱导致构建失败 |

| 依赖图 + 并行 | 约 3.5 分钟 | 拓扑排序 + 多核并行 |

| 依赖图 + 全缓存命中 | 约 20 秒 | 直接复用产物 |

常见坑

忘记 `^` 前缀:写成 `dependsOn: ["build"]` 会导致上游包没先构建,编译报错。
persistent 任务放进构建链:`dev` 是长驻任务(`persistent: true`),若被别的任务 `dependsOn`,会永远卡住。
inputs 配置过宽:把 `node_modules` 或产物目录也算进输入哈希,导致缓存永不命中。

最佳实践

明确区分「拓扑依赖 `^task`」与「同包任务依赖 `task`」。
用 `--dry-run` / `nx graph` 定期审查依赖图,发现异常依赖。
精确配置 `inputs` 和 `outputs`,是缓存有效的前提。

小结

依赖图是 Monorepo 编排的大脑。正确声明任务依赖,编排器才能既保证顺序正确,又榨干多核并行的性能。

---

六、远程缓存与增量构建

概念定义

本地缓存:任务产物按输入哈希存到本机磁盘,同一台机器重复构建直接复用。
远程缓存(remote cache):把缓存产物上传到共享存储(云端),团队成员和 CI 之间共享。A 同事构建过的产物,B 同事和 CI 可以直接下载复用,无需重新构建。
增量构建(incremental build):只重新构建输入发生变化的部分。

为什么重要

远程缓存是 Monorepo CI 提速的杀手锏。典型场景:PR 的 CI 里,大部分包和 main 分支一模一样,如果 CI 能直接下载 main 已构建好的缓存,就只需构建你真正改动的几个包。这能把 CI 时间从几十分钟压到几分钟。

原理

缓存命中的判定基于「输入哈希」。编排器把一个任务的所有输入(源文件内容、依赖包版本、任务配置、相关环境变量、工具版本)序列化后算出一个哈希值。如果这个哈希在缓存里存在,就命中,直接取出对应的输出产物(`dist`、日志、退出码)。任何输入变化都会导致哈希变化,从而 miss、重新执行并写入新缓存。

远程缓存只是把「缓存的存储层」从本地磁盘换成了云端对象存储(S3、Vercel、Nx Cloud 等),命中逻辑完全一致。

代码示例:turbo.json 开启远程缓存

jsonCode
{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "enabled": true,
    "signature": true
  },
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["API_URL", "PUBLIC_KEY"]
    }
  }
}

登录并链接远程缓存(Vercel 托管):

bashCode
# 使用 Vercel 官方远程缓存
npx turbo login
npx turbo link

# CI 环境用 token 方式
export TURBO_TOKEN=your-token
export TURBO_TEAM=your-team
turbo run build

自建远程缓存(例如用开源的 turbo-cache-server 或 S3):

bashCode
# 指向自建缓存服务
export TURBO_API=https://cache.mycompany.com
export TURBO_TOKEN=internal-token
export TURBO_TEAM=frontend
turbo run build --remote-only

Nx 远程缓存(Nx Cloud):

bashCode
npx nx connect
# nx.json 中会写入 accessToken,CI 自动共享缓存

真实案例

一个使用 Turborepo 远程缓存的团队,把 CI 从平均 22 分钟降到约 4 分钟,因为大多数 PR 只改动少数包,其余包全部缓存命中。官方与社区多个案例给出 40%-85% 的 CI 时间下降区间。
某团队接入 Nx Cloud 后,分布式任务执行(DTE)+ 远程缓存把一个大型 Angular 仓库的 CI 从约 45 分钟降到约 8 分钟。

数据/对比表格

| 场景 | 无缓存 | 本地缓存命中 | 远程缓存命中 |

| --- | --- | --- | --- |

| 本地二次构建 | 约 4 分钟 | 约 15 秒 | 约 15 秒 |

| 新克隆/新机器 | 约 4 分钟 | 未命中,全量 | 约 40 秒(下载产物) |

| CI(改 2 个包) | 约 22 分钟 | 不适用 | 约 4 分钟 |

| 缓存命中率(成熟团队) | - | 约 60%-80% | 约 80%-95% |

常见坑

环境变量污染缓存:任务实际依赖某个 env,但没写进 `env`/`globalEnv`,导致不同环境错误复用缓存,产出带错配置的产物。反过来,把频繁变化的 env(如 `GITHUB_SHA`)纳入哈希,会让缓存永不命中。
outputs 不完整:只声明了 `dist/`,漏了 `.next/`,命中缓存后 `.next` 是空的,运行报错。
缓存投毒(cache poisoning):开启 `signature` 校验,防止被篡改的缓存产物被信任。
非确定性构建:构建产物里带时间戳、随机 hash,导致相同输入产出不同,缓存看似命中实则不可靠。

最佳实践

精确声明每个任务的 `inputs`、`outputs`、`env`。
CI 里用只读 token 给 PR、读写 token 给 main,防止不受信 PR 污染缓存。
开启 `signature` 做完整性校验。
追求构建的确定性(deterministic build)。

小结

远程缓存把「团队和 CI 的重复劳动」变成「一次构建、处处复用」,是 Monorepo 规模化后 ROI 最高的一项投入。前提是把输入哈希(inputs/outputs/env)配置精确。

---

七、affected:只构建变更影响的包

概念定义

affected(受影响范围)是指:给定一次代码变更(相对某个 base,通常是 main),计算出「哪些包的代码直接改了,以及哪些包因为依赖了这些改动而间接受影响」,然后只对这批包执行任务。

为什么重要

即使有缓存,全量遍历所有包判断缓存也有开销;更重要的是,affected 让 CI 的语义变成「只验证这次改动可能破坏的东西」。改一个只被后台用的包,就不必跑主站的 e2e。这在几十上百个包的仓库里能节省大量时间。

原理

affected 的计算分两步:

1.找出直接变更的包:用 `git diff base...HEAD` 得到改动的文件列表,映射到所属的包。
2.沿依赖图向下游扩散:如果 utils 变了,所有(直接或间接)依赖 utils 的包都受影响。这一步是「反向依赖闭包」计算。

注意方向:affected 是沿「被依赖 → 依赖方」向下游扩散,因为下游会消费上游的产物。

代码示例

Nx affected(一等公民):

bashCode
# 只对受影响的包跑 test/build/lint
nx affected --target=test --base=origin/main --head=HEAD
nx affected --target=build --base=origin/main
# 查看受影响项目列表
nx show projects --affected --base=origin/main
# 可视化受影响依赖图
nx affected:graph --base=origin/main

Turborepo 用 `--filter` 配合 git 范围实现类似效果:

bashCode
# 只对相对 main 有改动的包及其下游跑 build
turbo run build --filter="...[origin/main]"
# 只跑改动包本身(不含下游)
turbo run test --filter="[origin/main]"
# 组合:改动包 + 其下游依赖方
turbo run lint --filter="...[origin/main]...":

CI 中的典型用法(先取到 base 引用):

bashCode
# 确保有 main 的历史用于 diff
git fetch origin main:main --depth=50
turbo run build test lint --filter="...[main]"

真实案例

某仓库 60 个包,一次典型 PR 平均只改动 2-3 个包,影响下游 4-5 个包。开启 affected 后,CI 平均只需处理约 8 个包而非 60 个,测试阶段从约 18 分钟降到约 3 分钟;结合远程缓存,进一步降到约 90 秒。

数据/对比表格

| 策略 | 处理包数量 | test 阶段耗时 | 适用场景 |

| --- | --- | --- | --- |

| 全量 | 60 | 约 18 分钟 | 每次都跑全部 |

| 仅缓存 | 60(多数命中) | 约 6 分钟 | 遍历判断有开销 |

| affected | 约 8 | 约 3 分钟 | 只验证受影响 |

| affected + 远程缓存 | 约 8(部分命中) | 约 90 秒 | 最优组合 |

常见坑

CI 拿不到 base:浅克隆(shallow clone)没有 main 历史,`git diff` 失败,affected 退化成全量或报错。需 `git fetch` 足够深度或用 `fetch-depth: 0`。
非代码变更漏判:改了根 `tsconfig.base.json` 或全局配置,理论上影响所有包,需通过 `globalDependencies` 声明,否则 affected 会漏掉。
合并策略影响 base:squash merge 后 main 的历史与分支 diff 关系变化,要选对 base 引用。

最佳实践

CI 中显式 `fetch` main,并用 `--base=origin/main`。
把影响全局的文件(根 tsconfig、lockfile、CI 配置)纳入 `globalDependencies`。
affected 与远程缓存叠加使用,效果最佳。

小结

affected 让 CI 从「验证一切」变成「只验证这次可能弄坏的东西」,是 Monorepo 增量 CI 的核心思想。它依赖精确的依赖图和可靠的 git base。

---

八、版本管理与发布:changesets 与语义化发布

概念定义

语义化版本(SemVer):`MAJOR.MINOR.PATCH`,分别对应破坏性变更、新功能、bug 修复。
changesets:一个专为 Monorepo 设计的版本与发布工具。开发者在 PR 里写一个「changeset」文件,声明本次改动影响哪些包、升什么级别;合并后由工具汇总、升版本、生成 changelog、发布。
固定版本 vs 独立版本:固定(fixed/locked)指所有包共用一个版本号一起升;独立(independent)指每个包各自独立升版本。

为什么重要

Monorepo 里几十个包,手动改版本号、写 changelog、按依赖顺序发布,既繁琐又易错(漏发一个上游包,下游装不上)。changesets 把「版本决策」左移到 PR 阶段,由作者声明意图,自动化后续所有环节。

原理

changesets 工作流:

1.开发者运行 `pnpm changeset`,交互式选择改动的包和 bump 级别,生成一个 markdown 描述文件放到 `.changeset/` 目录,随 PR 一起提交。
2.PR 合并到 main 后,CI 运行 `changeset version`:消费所有 changeset 文件,按 SemVer 规则升级各包版本号、更新相互依赖的版本范围、生成/追加 CHANGELOG.md,并删除已消费的 changeset 文件(这一步通常由机器人开一个「Version Packages」PR)。
3.该 Version PR 合并后,CI 运行 `changeset publish`,按依赖拓扑顺序把变更的包发布到 registry。

代码示例

配置文件:

jsonCode
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@my-org/web", "@my-org/admin"]
}

一个 changeset 文件(`.changeset/tidy-lions-smile.md`):

markdownCode
---
"@my-org/ui": minor
"@my-org/utils": patch
---

Button 组件新增 loading 属性;修复 formatDate 时区问题

根 package.json 的发布脚本:

jsonCode
{
  "scripts": {
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build --filter=./packages/* && changeset publish"
  }
}

GitHub Actions 自动发布:

yamlCode
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          version: pnpm run version-packages
          publish: pnpm run release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

真实案例

changesets 由 Atlassian 团队开源,被大量知名开源 Monorepo 采用,例如 pnpm 自身、Emotion、Chakra UI、Astro 等。它们的发布流程基本都是「PR 带 changeset → 机器人开 Version PR → 合并即发布」,实现了社区贡献者也能安全参与发版。

数据/对比表格

| 方案 | 版本策略 | changelog | 发布顺序 | 适合场景 |

| --- | --- | --- | --- | --- |

| 手动改版本 | 任意 | 手写 | 手动 | 极小仓库 |

| Lerna(conventional commits) | 固定/独立 | 自动(依 commit) | 自动 | 中小 |

| changesets | 固定/独立/linked | 自动(依 changeset) | 自动拓扑 | 中大,开源友好 |

| semantic-release | 独立 | 自动(依 commit) | 自动 | 单包或简单多包 |

常见坑

忘记写 changeset:PR 改了包却没带 changeset,导致发布时该包版本没升,下游装到旧版本。可用 CI 检查(`changeset status`)强制要求。
应用型包误发布:`apps/web` 这类不该发 npm 的包忘了加进 `ignore` 或设 `private: true`,导致误发。
内部依赖版本联动:ui 升了 minor,依赖它的包是否要跟着升?由 `updateInternalDependencies` 控制,配置不当会漏升。

最佳实践

应用包一律 `"private": true`,或加入 changesets `ignore`。
CI 加 `changeset status --since=origin/main` 检查,PR 无 changeset 时提醒。
用「Version PR」模式,让发版本身也经过 review。

小结

changesets 把版本决策变成 PR 里的一份声明,把发布变成自动化流水线。它是当下 Monorepo(尤其是要对外发 npm 包的)事实标准。

---

九、内部包引用:workspace 协议

概念定义

`workspace:` 协议是包管理器提供的一种依赖版本写法,表示「这个依赖来自本仓库的 workspace,用本地版本,别去 registry 找」。常见写法:`workspace:*`、`workspace:^`、`workspace:~`。

为什么重要

它是「本地开发用软链接、发布时自动替换成真实版本号」的关键。开发时你改 ui 的源码,web 立刻能感知;发布时 `workspace:*` 会被自动替换成 ui 当时的真实版本(如 `^1.3.0`),保证发到 npm 的包依赖是正确的语义化版本,外部用户装得上。

原理

开发/安装阶段:包管理器看到 `"@my-org/ui": "workspace:*"`,直接把 `node_modules/@my-org/ui` 符号链接到 `packages/ui`。
发布阶段:`pnpm publish` / `changeset publish` 会把 `workspace:*` 改写为实际版本。规则:

- `workspace:*` → 当前确切版本(如 `1.3.0`)

- `workspace:^` → `^1.3.0`

- `workspace:~` → `~1.3.0`

代码示例

内部包互相引用:

jsonCode
{
  "name": "@my-org/ui",
  "version": "1.3.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "dependencies": {
    "@my-org/utils": "workspace:^",
    "@my-org/types": "workspace:*"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

应用引用组件库:

jsonCode
{
  "name": "@my-org/web",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "@my-org/ui": "workspace:*",
    "@my-org/utils": "workspace:*",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

代码里直接 import:

tsCode
// apps/web/src/Page.tsx
import { Button } from '@my-org/ui';
import { formatDate } from '@my-org/utils';

export function Page() {
  return <Button loading>{formatDate(Date.now())}</Button>;
}

真实案例

几乎所有用 pnpm 的开源 Monorepo(如 Vite、Vitest、Vue 生态)都用 `workspace:` 管理内部依赖。以 Vue 3 仓库为例,`@vue/runtime-dom` 依赖 `@vue/runtime-core` 就是 `workspace:`,发布时自动替换成对应版本,保证外部安装的一致性。

数据/对比表格

| 写法 | 开发时 | 发布后被替换为 | 语义 |

| --- | --- | --- | --- |

| workspace:* | 本地链接 | 1.3.0(确切) | 锁死同版本 |

| workspace:^ | 本地链接 | ^1.3.0 | 允许兼容的次要升级 |

| workspace:~ | 本地链接 | ~1.3.0 | 只允许补丁升级 |

| 直接写 ^1.3.0 | 可能去 registry 下载 | ^1.3.0 | 不保证用本地版本 |

常见坑

写死版本号而非 workspace 协议:直接写 `"@my-org/ui": "^1.3.0"`,包管理器可能从 registry 下载旧版本而非用本地代码,导致本地改动不生效。
应用包用 workspace:^ 发布:应用不发布,用什么都行,但为清晰起见内部约定统一。
循环 workspace 依赖:A 依赖 B、B 又依赖 A,构建时死锁(见下一节)。

最佳实践

内部依赖一律用 `workspace:` 协议。
需要严格锁版本用 `workspace:*`;希望发布后仍允许小升级用 `workspace:^`。
配合 `exports` 字段规范包的入口,避免深层路径引用破坏封装。

小结

`workspace:` 协议是 Monorepo「本地开发即时联动 + 发布版本正确」的黏合剂。用它,你既得到 Monorepo 的开发体验,又不牺牲对外发布的正确性。

---

十、TypeScript project references

概念定义

project references(项目引用)是 TypeScript 提供的机制,允许把一个大 TS 工程拆成多个子项目,声明它们之间的依赖关系,从而支持增量编译(incremental)、构建缓存和更快的类型检查。在 Monorepo 里,通常一个包对应一个 TS 子项目。

为什么重要

不用 project references 时,TS 会把整个 Monorepo 当一坨代码全量 type check,包一多就极慢,且 IDE 跳转常常跳到 `.d.ts` 而非源码。用了之后,TS 能只重新检查变化的项目,产出 `.d.ts` 供下游复用,编辑器体验和 CI 类型检查都显著加速。

原理

每个子项目 `tsconfig.json` 设 `composite: true`,表示它是一个可被引用的独立编译单元,会产出 `.d.ts` 和 `.tsbuildinfo`(增量信息)。
下游项目用 `references` 声明依赖上游项目。
用 `tsc --build`(简称 `tsc -b`)时,TS 按引用关系拓扑排序、增量编译,只重编变化的部分。
`.tsbuildinfo` 记录上次编译状态,实现跨次运行的增量。

代码示例

根 `tsconfig.base.json`(共享编译选项):

jsonCode
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "incremental": true
  }
}

包级 `packages/ui/tsconfig.json`:

jsonCode
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../utils" },
    { "path": "../types" }
  ]
}

根 `tsconfig.json`(编排所有引用):

jsonCode
{
  "files": [],
  "references": [
    { "path": "./packages/types" },
    { "path": "./packages/utils" },
    { "path": "./packages/ui" },
    { "path": "./apps/web" }
  ]
}

构建命令:

bashCode
# 增量构建整个引用图
tsc -b

# 强制全量重建
tsc -b --force

# 监听模式
tsc -b --watch

# 清理构建产物与 tsbuildinfo
tsc -b --clean

真实案例

某后台管理系统的 Monorepo 有 25 个 TS 包。启用 project references 前,一次全量 `tsc --noEmit` 类型检查约 55 秒;启用后,全量首次约 40 秒,但改动单个包后的增量检查降到约 4 秒,VS Code 的「跳转到定义」也能正确跳到源码(配合 `declarationMap`)。

数据/对比表格

| 场景 | 无 project references | 有 project references |

| --- | --- | --- |

| 全量 type check | 约 55 秒 | 约 40 秒 |

| 改一个包后增量 | 约 55 秒(全量) | 约 4 秒 |

| 跳转到定义 | 跳到 .d.ts | 跳到源码(declarationMap) |

| 缓存文件 | 无 | .tsbuildinfo |

常见坑

忘了 `composite: true`:被引用的项目没开 composite,`tsc -b` 报错。
references 与 workspace 依赖不一致:`package.json` 里依赖了某包,`tsconfig` 的 references 却没加,导致增量构建顺序错误。二者需保持同步(可写脚本自动生成或校验)。
paths 与 references 混用混乱:既配 `paths` 别名又配 references,解析路径打架。推荐 references 为主,bundler 场景再辅以 paths。
产物目录被算进输入:`.tsbuildinfo` 或 `dist` 被 `include` 覆盖,导致循环或缓存失效。

最佳实践

每个包 `composite: true` + `declaration: true` + `declarationMap: true`。
保持 `package.json` 依赖与 `tsconfig` references 一致,可用工具(如 Nx 的自动同步)保证。
CI 用 `tsc -b` 做增量类型检查,配合 Turborepo/Nx 缓存 `.tsbuildinfo`。

小结

project references 是 TypeScript 在 Monorepo 下的性能与体验方案。它让类型检查增量化、让 IDE 跳转回到源码,代价是要维护一套与包依赖同步的引用关系。

---

十一、循环依赖治理

概念定义

循环依赖(circular dependency)指包(或模块)之间形成了环:A 依赖 B,B 依赖 A;或更隐蔽的 A → B → C → A。在 Monorepo 里既有「包级循环」也有「模块级循环」,前者更致命,会直接破坏构建拓扑排序。

为什么重要

包级循环让「先构建谁」无解,编排器无法拓扑排序,构建/发布直接失败或死锁。模块级循环则导致运行时某个 import 拿到 `undefined`(因为对方还没初始化完),引发难以复现的 bug,还会拖慢打包、破坏 tree-shaking。

原理

依赖图必须是 DAG(有向无环图)才能拓扑排序。一旦出现环,拓扑排序算法无法给出线性顺序。模块级循环则和 ESM/CJS 的加载语义有关:模块 A 在执行到一半去 import B,B 又 import 回 A,此时 A 尚未导出完成,B 拿到的是不完整的(甚至 undefined 的)绑定。

代码示例:发现与拆解

用工具检测循环:

bashCode
# madge:检测模块级循环
npx madge --circular --extensions ts,tsx packages/ui/src
# 生成依赖图图片
npx madge --image graph.svg packages/ui/src

# Nx:包级循环会在 lint 时报 dependency cycle
nx lint

# eslint 规则:import/no-cycle

eslint 配置阻断循环:

jsonCode
{
  "rules": {
    "import/no-cycle": ["error", { "maxDepth": 10 }]
  }
}

拆解手法——提取共享层。改造前(循环):

tsCode
// packages/user/index.ts
import { fetchOrders } from '@my-org/order';   // user 依赖 order

// packages/order/index.ts
import { getUser } from '@my-org/user';        // order 又依赖 user  -> 循环

改造后(引入 `core` 打破环):

tsCode
// packages/core/types.ts —— 双方都依赖 core,core 不依赖任何人
export interface User { id: string; name: string; }
export interface Order { id: string; userId: string; }

// packages/user/index.ts
import type { Order } from '@my-org/core';     // user -> core

// packages/order/index.ts
import type { User } from '@my-org/core';      // order -> core,环消失

真实案例

某团队的 `@app/auth` 与 `@app/api` 互相依赖,导致 Turborepo 报「cyclic dependency detected」构建中断。排查发现是 auth 里放了一个 api 的类型、api 里又用了 auth 的常量。解决方案:抽出 `@app/shared-contracts` 包放共享类型与常量,两者都单向依赖它,环被打破,构建恢复正常。

数据/对比表格

| 循环层级 | 症状 | 检测工具 | 常见修复 |

| --- | --- | --- | --- |

| 包级循环 | 构建拓扑失败/死锁 | Nx、Turborepo 报错 | 抽公共包、反转依赖 |

| 模块级循环 | 运行时 undefined、打包变慢 | madge、eslint import/no-cycle | 提取共享模块、依赖倒置 |

| 类型级循环 | 编译慢、d.ts 膨胀 | tsc、madge | 用 import type 分离 |

常见坑

用 `import type` 就以为没事:类型循环对运行时无害,但仍可能拖慢编译;而值级循环即使小也可能致命。
循环藏在 barrel 文件里:`index.ts` 到处 re-export,很容易无意中形成环。
只在生产构建才暴露:dev 模式下模块热更新掩盖了循环,上线才崩。

最佳实践

CI 里加 `madge --circular` 和 `import/no-cycle`,把循环挡在合并前。
依赖方向单向化:定义清晰的分层(如 core → domain → feature → app),只允许上层依赖下层。
用 Nx 的 module boundaries(`@nx/enforce-module-boundaries`)从架构层面约束谁能依赖谁。

小结

循环依赖是 Monorepo 架构腐化的信号。用工具尽早检测、用分层与共享包主动拆解、用 lint 规则持续守护,才能保持依赖图的健康。

---

十二、CODEOWNERS 与权限治理

概念定义

CODEOWNERS 是 GitHub/GitLab 支持的一个文件,用「路径 → 负责人/团队」的映射,声明每块代码由谁负责。当 PR 改动某路径时,对应 owner 会被自动请求 review,并可强制要求其批准才能合并。

为什么重要

Monorepo 打破了「一个仓库一个团队」的天然边界,所有人都能改所有代码。没有权限治理,组件库可能被业务同学随手改坏、核心基础设施无人把关。CODEOWNERS 在「共享仓库」里重建了「模块所有权」,兼顾开放协作与质量把控。

原理

平台在 PR 创建时,读取 CODEOWNERS,匹配本次 diff 涉及的路径。
匹配到的 owner 被自动加为 reviewer。
配合分支保护规则(branch protection)的「Require review from Code Owners」,可强制 owner 批准。
匹配采用「最后匹配优先」(后面的规则覆盖前面的)。

代码示例

`.github/CODEOWNERS`:

textCode
# 默认所有文件由平台团队兜底
*                             @my-org/platform-team

# 组件库归 UI 团队
/packages/ui/                 @my-org/design-system @alice

# 工具库归基础架构团队
/packages/utils/              @my-org/infra-team

# 主站归 web 团队
/apps/web/                    @my-org/web-team

# 构建与 CI 配置需要架构组把关(最后匹配优先,覆盖上面)
/turbo.json                   @my-org/architects
/.github/                     @my-org/architects
/pnpm-workspace.yaml          @my-org/architects

# 某个敏感文件指定具体负责人
/packages/utils/src/crypto.ts @bob @carol

配合分支保护(通过 GitHub API/设置,示意):

textCode
Branch protection for main:
- Require a pull request before merging
- Require approvals: 1
- Require review from Code Owners: enabled
- Require status checks to pass: CI / build, CI / test

真实案例

某公司把内部平台前端全部收进一个 Monorepo,30 多个团队协作。他们用 CODEOWNERS 精确到目录划分所有权,核心的设计系统包设为「必须设计系统团队 + 一名架构师双批准」。上线一年,跨团队误改核心包导致的线上事故从改造前的每季度数起降到接近零。

数据/对比表格

| 治理手段 | 作用 | Monorepo 中的必要性 |

| --- | --- | --- |

| CODEOWNERS | 路径级 review 归属 | 高,重建模块边界 |

| 分支保护规则 | 强制 review/CI 通过 | 高 |

| 模块边界 lint | 约束依赖方向 | 中高 |

| 目录级 CI 触发 | 减少无关流水线 | 中 |

常见坑

规则顺序搞反:CODEOWNERS 是「后匹配覆盖前匹配」,把宽泛的 `*` 放最后会覆盖掉前面的精细规则。应把通配放最前、精细规则放后。
owner 团队不存在或无仓库权限:写了个不存在的 team,规则静默失效。
过度细化:每个文件都指定 owner,导致 review 请求爆炸、合并卡顿。

最佳实践

从粗到细:先按顶层目录(apps/packages)划分,再对少数关键文件精细化。
关键基础设施(turbo.json、CI、lockfile)交给架构/平台组把关。
定期审查 CODEOWNERS 与团队实际归属是否同步。

小结

CODEOWNERS 是 Monorepo 权限治理的第一道防线,它在「谁都能改」的开放仓库里重建了「谁负责」的秩序,是大规模协作不可或缺的一环。

---

十三、增量 CI 与流水线优化

概念定义

增量 CI 指 CI 流水线不再「每次全量构建测试所有包」,而是结合 affected、缓存、并行分片(sharding),只做必要的工作。它是前面几节(依赖图、远程缓存、affected)在 CI 中的综合落地。

为什么重要

CI 时间直接决定「合并一个 PR 要等多久」,进而影响整个团队的迭代速度。Monorepo 若不做增量 CI,随着包增长,CI 时间线性膨胀,最终成为团队最大的效率瓶颈之一。

原理

增量 CI 的三板斧叠加:

1.affected:只处理受影响的包。
2.缓存:受影响包中,未真正改变输入的复用远程缓存。
3.并行/分片:把剩余任务分散到多个并行 job(矩阵)上跑,或用 Nx 的分布式任务执行(DTE)。

代码示例:Turborepo 增量 CI

yamlCode
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  build-test-lint:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 需要完整历史做 affected diff

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Lint / Typecheck / Test / Build (affected + cached)
        run: |
          turbo run lint typecheck test build \
            --filter="...[origin/main]" \
            --cache-dir=.turbo

Nx 增量 + 分布式 CI:

yamlCode
name: CI
on: [pull_request]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: nrwl/nx-set-shas@v4   # 自动计算 base/head SHA

      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile

      # 分布式执行 + 远程缓存(Nx Cloud)
      - run: npx nx affected -t lint test build --parallel=3

矩阵分片(sharding)示例:

yamlCode
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - run: pnpm test -- --shard=${{ matrix.shard }}/4

真实案例

某仓库 CI 优化历程:

起点:全量 build+test+lint,约 34 分钟。
第一步,接入 Turborepo 本地缓存 + 并行:约 18 分钟。
第二步,接入远程缓存:约 9 分钟(多数包命中 main 缓存)。
第三步,加 affected 过滤:约 4 分钟(只处理受影响包)。
第四步,测试分 4 片并行:约 2 分半。

整体 CI 时间下降约 92%。

数据/对比表格

| 优化阶段 | CI 耗时 | 相对起点 |

| --- | --- | --- |

| 全量串行 | 约 34 分钟 | 基线 |

| 缓存 + 并行 | 约 18 分钟 | -47% |

| + 远程缓存 | 约 9 分钟 | -74% |

| + affected | 约 4 分钟 | -88% |

| + 测试分片 | 约 2.5 分钟 | -92% |

常见坑

fetch-depth 不够:affected 需要 base 历史,浅克隆导致 diff 失败。用 `fetch-depth: 0` 或 `nrwl/nx-set-shas`。
PR 缓存写权限:不受信的 fork PR 若能写远程缓存,存在投毒风险。给 PR 只读 token。
分片不均:简单按包数分片,某片全是重测试,拖慢整体。应按历史耗时智能分片。
install 阶段没缓存:忘了 `cache: 'pnpm'`,每次重装依赖,白白浪费一两分钟。

最佳实践

affected + 远程缓存 + 分片,三者叠加。
main 分支跑全量并「预热」缓存,PR 分支享用缓存。
监控 CI 各阶段耗时,持续找瓶颈。

小结

增量 CI 是 Monorepo 前面所有优化手段在流水线中的集大成。做到位,几十个包的仓库也能保持分钟级 CI,团队迭代不被拖慢。

---

十四、大仓性能问题与治理

概念定义

当 Monorepo 增长到超大规模(数千包、数百万文件、数百 GB 历史),会遇到「工具本身撑不住」的问题:`git status` 慢、克隆慢、IDE 卡、node_modules 巨大、构建图爆炸。这类问题超出常规工具范畴,需要专门的大仓(large-scale monorepo)技术。

为什么重要

多数团队不会到这个规模,但了解上限有助于判断「我的方案能撑多大」和「Google/Meta 是怎么做的」。理解这些技术也能反哺中等规模仓库的优化思路。

原理与技术手段

稀疏检出(sparse checkout):只检出你需要的目录,而非整个仓库。`git sparse-checkout set apps/web packages/ui`。
部分克隆(partial clone):`git clone --filter=blob:none`,按需拉取文件内容,克隆瞬间完成。
虚拟文件系统:Meta 的 EdenFS、微软的 VFS for Git(Scalar),让文件按需从服务器拉取,本地看起来是完整仓库但实际懒加载。
Git 优化:`git maintenance`、commit-graph、multi-pack-index、fsmonitor(文件系统监控加速 status)。
构建系统:Bazel/Buck2 这类支持远程执行(remote execution)+ 远程缓存 + 精确依赖分析的系统,把构建分散到构建集群。

代码示例

bashCode
# 部分克隆 + 稀疏检出,只拉取需要的部分
git clone --filter=blob:none --sparse https://github.com/my-org/mega-repo
cd mega-repo
git sparse-checkout set apps/web packages/ui packages/utils

# 开启 Git 内建维护(后台优化)
git maintenance start

# 用微软 Scalar 管理超大仓库
scalar clone https://github.com/my-org/mega-repo

# 开启文件系统监控加速 status(Git 2.37+)
git config core.fsmonitor true
git config core.untrackedcache true

pnpm 针对大仓的配置(`.npmrc`):

textCode
# 只安装被过滤到的包的依赖,加速局部开发
prefer-workspace-packages=true
# 提升安装并发
network-concurrency=32
# 用 hard link 复用 store
package-import-method=hardlink

真实案例

Google:约 20 亿行代码、8000 万+ 文件、单仓每天数万提交,靠自研 Piper(版本控制)、CitC(文件系统)、Blaze/Bazel(构建,支持大规模远程执行与缓存)支撑。任何一次构建都能复用全公司的缓存。
Meta:仓库规模超出 Git 承载能力,先基于 Mercurial 深度改造,后开源 Sapling(源控)+ EdenFS(虚拟文件系统),实现「本地看似完整、实则按需拉取」。
微软 Windows:把 Windows 源码(约 300 GB、350 万文件)放进 Git,为此开发了 GVFS(后演进为 Scalar / partial clone),让 `git status` 从几分钟降到几秒。

数据/对比表格

| 规模层级 | 包/文件量级 | 典型工具栈 | 关键瓶颈 |

| --- | --- | --- | --- |

| 小型 | < 10 包 | pnpm workspace | 几乎无 |

| 中型 | 10-100 包 | pnpm + Turborepo | 构建/CI 时间 |

| 大型 | 100-1000 包 | pnpm + Nx + 远程缓存 | 依赖图、缓存命中率 |

| 超大型 | 1000+ 包 / 百万文件 | Bazel + 虚拟文件系统 + 远程执行 | Git 性能、文件系统 |

常见坑

过早引入大仓技术:中型仓库上 Bazel + 虚拟文件系统,复杂度爆炸。
Git 历史失控:把大二进制文件(图片、字体、构建产物)直接提交,历史膨胀到几十 GB。应用 Git LFS 或干脆不提交产物。
node_modules 失控:不用 pnpm 的 hard link,几十个应用各自一份依赖,磁盘几十 GB。

最佳实践

按需渐进:性能瓶颈出现在哪就优化哪,别一次性上全套。
二进制资源用 LFS 或外部存储,keep Git 历史纯净。
超大规模才考虑 Bazel + 远程执行 + 虚拟文件系统。

小结

大仓性能是「规模到了才需要面对」的问题。Google/Meta 的方案代表工程上限,但对 99% 的团队,pnpm + Turborepo/Nx + 远程缓存 + affected 已经足够,把这几样用好比盲目追求大厂方案更重要。

---

十五、完整落地:一个中型 Monorepo 的全套配置

目录结构

textCode
my-monorepo/
├── apps/
│   ├── web/                    # 主站(Next.js/Vite)
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   └── admin/                  # 后台
│       ├── package.json
│       ├── tsconfig.json
│       └── src/
├── packages/
│   ├── ui/                     # 组件库
│   ├── utils/                  # 工具函数
│   ├── types/                  # 类型定义
│   └── config/                 # 共享 eslint/tsconfig/prettier
├── .changeset/
│   └── config.json
├── .github/
│   ├── CODEOWNERS
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── package.json                # 根:scripts + devDependencies
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
├── tsconfig.json               # 根:project references 编排
└── .npmrc

根 package.json

jsonCode
{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@9.7.0",
  "engines": { "node": ">=20" },
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "typecheck": "turbo run typecheck",
    "clean": "turbo run clean && rm -rf node_modules",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build --filter=./packages/* && changeset publish",
    "ci": "turbo run lint typecheck test build --filter=...[origin/main]"
  },
  "devDependencies": {
    "@changesets/cli": "^2.27.0",
    "turbo": "^2.0.0",
    "typescript": "^5.5.0"
  }
}

共享配置包(packages/config)

jsonCode
{
  "name": "@my-org/config",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./eslint": "./eslint.js",
    "./tsconfig": "./tsconfig.json",
    "./prettier": "./prettier.js"
  }
}

其他包复用共享 eslint:

jsCode
// packages/ui/.eslintrc.js
module.exports = {
  extends: [require.resolve('@my-org/config/eslint')],
  rules: {
    'import/no-cycle': 'error'
  }
};

一次典型开发流程(端到端)

bashCode
# 1. 起分支
git checkout -b feat/button-loading

# 2. 同时改组件库和主站
#   packages/ui/src/Button.tsx  -> 新增 loading 属性
#   apps/web/src/Page.tsx        -> 用上 loading

# 3. 本地增量校验(只处理受影响包)
pnpm turbo run typecheck test build --filter=...[main]

# 4. 声明 changeset
pnpm changeset          # 选 @my-org/ui minor

# 5. 提交,一个 PR 搞定跨包变更
git add -A
git commit -m "feat(ui): Button 支持 loading 并在主站接入"
git push -u origin feat/button-loading

# 6. CI 自动跑 affected + 缓存;合并后 changesets 机器人开 Version PR;
#    Version PR 合并后自动 publish @my-org/ui@x.y.z

---

十六、总结

Monorepo 不是「把代码堆在一起」,而是一整套「用工程能力换协作效率」的方法论。它的价值链条是:workspace 提供地基(本地引用)→ 任务编排理解依赖图(正确顺序 + 并行)→ 缓存与 affected 提供性能(增量、复用)→ changesets 管理发布(版本与 changelog)→ TS project references 保障类型体验 → CODEOWNERS 与 lint 守护边界与权限 → 增量 CI 把这一切落到流水线。

核心判断:先问要不要用 Monorepo(代码是否经常一起改),再问用什么工具(规模匹配),最后持续做增量优化(缓存、affected、CI)。绝大多数团队的最优解是「pnpm workspace + Turborepo + changesets + project references」这套组合,规模再上去才向 Nx/Rush/Bazel 演进。

| 维度 | 推荐方案 | 何时升级 |

| --- | --- | --- |

| 包管理 | pnpm workspace | 几乎不需要换 |

| 任务编排 | Turborepo | 需强边界/代码生成 → Nx;跨语言超大规模 → Bazel |

| 缓存 | 本地 + 远程缓存 | 团队/CI 多机时必开远程 |

| 增量 | affected + 缓存 | 包数 > 20 时收益明显 |

| 版本发布 | changesets | 对外发 npm 包必备 |

| 类型 | TS project references | 包数多、类型检查慢时 |

| 依赖健康 | madge + import/no-cycle + 模块边界 | 一开始就该加 |

| 权限 | CODEOWNERS + 分支保护 | 多团队协作时必备 |

| 大仓性能 | 稀疏检出 / 虚拟文件系统 / Bazel 远程执行 | 千级包、百万文件时 |

一句话收尾:Monorepo 的成败不在于「用了什么工具」,而在于「有没有把依赖图、缓存、边界这三件事做扎实」。工具会更迭,但这三条原理长期有效。

---

十七、边界约束与模块依赖规则

概念定义

边界约束(module boundaries / dependency constraints)是指用工具在架构层面强制约定「谁可以依赖谁」,把口头约定变成 CI 里可以自动阻断的硬规则。常见做法有两类:Nx 内建的 `@nx/enforce-module-boundaries` lint 规则,以及框架无关的 `dependency-cruiser`。

为什么重要

前面讲循环依赖是「事后发现环」,而边界约束是「事前禁止形成不该有的依赖」。Monorepo 里所有代码都能互相 import,如果不设约束,业务 feature 包会横向乱引用、底层 util 包反向依赖上层应用,最终依赖图退化成一张蜘蛛网。一旦形成,重构成本极高。边界约束把「分层架构」变成机器可校验的契约。

原理

核心是给每个包打「标签(tag)」,再声明标签之间允许的依赖方向:

按层打标签:`type:app`、`type:feature`、`type:ui`、`type:util`。
按业务域打标签:`scope:order`、`scope:user`、`scope:shared`。
规则示例:`type:feature` 只能依赖 `type:ui` 和 `type:util`,不能依赖别的 `type:feature`;`scope:order` 不能依赖 `scope:user`,只能依赖 `scope:shared`。

lint 在解析每条 import 时,查出源包与目标包的标签,比对规则表,违规即报错。

代码示例

Nx 里给项目打标签(`project.json`):

jsonCode
{
  "name": "feature-order",
  "tags": ["type:feature", "scope:order"]
}

在根 eslint 配置声明允许的依赖关系:

jsonCode
{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "type:feature",
            "onlyDependOnLibsWithTags": ["type:ui", "type:util"]
          },
          {
            "sourceTag": "type:ui",
            "onlyDependOnLibsWithTags": ["type:util"]
          },
          {
            "sourceTag": "scope:order",
            "onlyDependOnLibsWithTags": ["scope:order", "scope:shared"]
          }
        ]
      }
    ]
  }
}

框架无关方案 `dependency-cruiser`(`.dependency-cruiser.js`):

jsCode
module.exports = {
  forbidden: [
    {
      name: 'no-circular',
      severity: 'error',
      from: {},
      to: { circular: true }
    },
    {
      name: 'util-not-depend-on-app',
      comment: '底层 util 不得反向依赖应用层',
      severity: 'error',
      from: { path: '^packages/utils' },
      to: { path: '^apps' }
    },
    {
      name: 'feature-no-cross-import',
      comment: 'feature 之间禁止横向依赖',
      severity: 'error',
      from: { path: '^packages/feature-([^/]+)' },
      to: { path: '^packages/feature-(?!$1)' }
    }
  ],
  options: {
    doNotFollow: { path: 'node_modules' },
    tsConfig: { fileName: 'tsconfig.base.json' }
  }
};

在 CI 里执行校验:

bashCode
# dependency-cruiser:校验并在违规时非零退出
npx depcruise packages apps --config .dependency-cruiser.js

# 生成架构依赖图(需要 graphviz)
npx depcruise packages --include-only "^packages" \
  --output-type dot | dot -T svg > architecture.svg

真实案例

某中台团队有 8 个业务域、约 45 个包。上线边界约束前,依赖图里存在 30 多条「跨业务域横向依赖」,任何一个域改动都可能波及其他域。他们分阶段给包打标签、把违规规则先设为 `warn` 收敛存量、再逐步升级为 `error`,三个月内把横向依赖清理到 0,随后 affected 计算的平均受影响包数量从 19 个降到 6 个,CI 也顺带提速。

数据/对比表格

| 方案 | 依赖形式 | 规则粒度 | 循环检测 | 适用范围 |

| --- | --- | --- | --- | --- |

| Nx enforce-module-boundaries | 绑定 Nx | 标签(type/scope) | 内建 | Nx 仓库 |

| dependency-cruiser | 框架无关 | 路径正则 | 内建 | 任意 JS/TS 仓库 |

| eslint import/no-restricted-paths | 框架无关 | 路径 | 否 | 轻量约束 |

| 人工 code review | 无 | 靠人 | 靠人 | 不可规模化 |

常见坑

一上来全设 error:存量违规太多,CI 全红,团队直接把规则关掉。应先 `warn` 收敛存量再升 `error`。
标签体系混乱:type 与 scope 维度混用、命名随意,规则越写越绕。应先定义清晰的两维标签规范。
只约束包级、放任模块级:包内部目录之间也可能烂,需配合 `import/no-restricted-paths`。

最佳实践

用「两维标签」:type(分层)+ scope(业务域),规则分别约束纵向与横向。
新规则先 `warn`、锁定存量基线,再逐步升 `error` 阻断新增违规。
把依赖图导出成 SVG 定期评审,让架构可见化。

小结

边界约束把「分层架构」从文档里的图变成 CI 里的红线。它是循环依赖治理的上位手段:与其事后拆环,不如事前禁止不该有的依赖形成。

---

十八、多语言 Monorepo

概念定义

多语言 Monorepo(polyglot monorepo)指一个仓库里同时容纳多种技术栈:前端 TS、后端 Go/Java/Python、移动端、甚至 protobuf 契约。它超出了 pnpm/Turborepo 这类「JS 专用」工具的能力边界,通常需要语言无关的构建系统。

为什么重要

全栈团队常希望「前端和它调用的后端接口放一个仓库,改接口和改调用一次提交搞定」。这时纯 JS 工具链无法编排 Go 的编译、Python 的测试。要么用语言无关的 Bazel/Buck2,要么用 Nx 的多语言插件把外部命令包进任务图。

原理

契约先行:用 protobuf/OpenAPI 定义接口,各语言从同一份契约生成代码,保证前后端类型一致。
语言无关编排:Bazel 用 `rules_go`、`rules_python`、`rules_js` 等规则集,把不同语言的编译/测试都表达为统一的 target,共享同一套缓存与远程执行。
Nx 折中方案:Nx 通过 `nx:run-commands` 把任意 shell 命令(如 `go build`)包装成 target,纳入依赖图与缓存,但缓存粒度不如 Bazel 精细。

代码示例

用 protobuf 作为跨语言契约:

textCode
proto/order/v1/order.proto  ->  生成 TS 客户端 + Go 服务端

Nx 把 Go 服务包成一个 target(`apps/order-service/project.json`):

jsonCode
{
  "name": "order-service",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "options": { "command": "go build -o bin/order ./cmd" },
      "inputs": ["{projectRoot}/**/*.go", "{workspaceRoot}/proto/**"],
      "outputs": ["{projectRoot}/bin"],
      "cache": true
    },
    "test": {
      "executor": "nx:run-commands",
      "options": { "command": "go test ./..." }
    }
  }
}

Bazel 里同一仓库编排多语言 target:

pythonCode
# apps/order-service/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary")

go_binary(
    name = "order",
    srcs = ["main.go"],
    deps = ["//proto/order/v1:order_go_proto"],
)

一次跨语言的契约变更 + 双端更新:

bashCode
# 1. 改 proto/order/v1/order.proto 新增字段
# 2. 重新生成两端代码
buf generate            # 或 bazel build //proto/...
# 3. 一次提交里同时改 Go 服务端与 TS 前端
nx affected -t build test   # 前端 + 后端受影响 target 一起校验

真实案例

某团队把「TS 前端 + Go BFF + protobuf 契约」放进一个 Nx 仓库。改造前接口变更要跨两个仓库、两个 PR、手动同步字段,平均耗时半天且经常前后端字段对不上;改造后一次 PR 同时改契约、后端、前端,`buf` 生成保证类型一致,联调时间从半天降到约 1 小时,接口不一致导致的联调 bug 基本消失。

数据/对比表格

| 方案 | 语言覆盖 | 缓存粒度 | 远程执行 | 上手成本 |

| --- | --- | --- | --- | --- |

| Bazel / Buck2 | 全语言 | 极细(文件级) | 支持 | 很高 |

| Nx + run-commands | 以 JS 为主,可包外部命令 | 中(target 级) | Nx Cloud | 中 |

| 各语言原生工具 + 脚本 | 全语言 | 无统一缓存 | 无 | 低但难维护 |

常见坑

契约不统一:前后端各写一份接口定义,很快漂移。必须单一契约源(proto/OpenAPI)生成。
用 JS 工具硬扛多语言:Turborepo 对 Go/Python 只能当黑盒 shell 调用,缓存粒度粗,收益有限。
生成代码提不提交:生成物提交会污染 diff,不提交则要求人人本地能跑生成器。需团队统一约定。

最佳实践

契约先行,单一来源生成多语言代码。
多语言且追求极致缓存/远程执行,才上 Bazel;否则 Nx run-commands 折中即可。
生成代码要么全提交并加 CI 校验「生成物是最新」,要么全不提交并在 CI 生成。

小结

多语言 Monorepo 的关键不是「塞进一个仓库」,而是「用统一契约保证跨语言一致 + 用语言无关的编排共享缓存」。工具选型上,Bazel 是上限、Nx run-commands 是性价比之选。

---

十九、Monorepo 迁移实战与踩坑

概念定义

Monorepo 迁移指把原本分散的多个 Git 仓库合并进一个 Monorepo,或反过来从「大泥球单仓」重构出清晰的包边界。难点在于既要保留 Git 历史,又要在迁移过程中不阻塞正常业务开发。

为什么重要

迁移是一次性的高风险操作,做不好会丢失历史、打乱 CI、破坏发布流程,甚至让团队对 Monorepo 失去信心。一套稳妥的迁移剧本能把风险降到最低。

原理与步骤

1.保留历史合并:用 `git subtree` 或 `git-filter-repo` 把旧仓库连同历史迁到新仓子目录,而非简单复制文件(那样会丢失 blame 与提交记录)。
2.统一工具链:合并后统一包管理器(转 pnpm)、lockfile、tsconfig、eslint。
3.建立边界与依赖图:把复制来的代码按包拆分,声明 workspace 依赖与 project references。
4.搭增量 CI:接入 Turborepo/Nx,配 affected + 缓存。
5.灰度切换发布:先并行运行新旧发布流程,验证无误再切换。

代码示例

用 git-filter-repo 保留历史迁移(推荐):

bashCode
# 把旧仓 ui-lib 的历史整体搬到 packages/ui 子目录
git clone https://github.com/my-org/ui-lib ui-lib-tmp
cd ui-lib-tmp
git filter-repo --to-subdirectory-filter packages/ui

# 回到 monorepo,把改造过的历史合并进来
cd ../my-monorepo
git remote add ui-lib ../ui-lib-tmp
git fetch ui-lib
git merge ui-lib/main --allow-unrelated-histories

用 git subtree 的等价做法:

bashCode
git subtree add --prefix=packages/ui \
  https://github.com/my-org/ui-lib main

迁移后校验没有幽灵依赖与循环:

bashCode
# 全量重装,暴露隐式依赖
rm -rf node_modules && pnpm install
# 检测循环依赖
npx madge --circular --extensions ts,tsx packages apps
# 全量构建确认拓扑正确
pnpm turbo run build

真实案例

某团队把 6 个前端仓库(主站、后台、H5、组件库、工具库、脚手架)合并成一个 pnpm + Turborepo Monorepo。迁移用 `git-filter-repo` 保留了全部历史与 blame。过程中踩到三个典型坑:

组件库把 react 写在 dependencies,合并后应用里出现两份 react,hooks 报错,改成 peerDependencies 才修复。
各仓 tsconfig 配置不一,合并后类型检查冲突,统一 extends 一份 `tsconfig.base.json` 解决。
首次 CI 忘了 `fetch-depth: 0`,affected 算不出 base 退化成全量,加上后恢复。

迁移完成后,跨仓库联动发布的场景(改组件库同时更新 4 个应用)从原来平均 2 天缩短到 1 个 PR、约 2 小时。

数据/对比表格

| 迁移方式 | 是否保留历史 | 复杂度 | 适用场景 |

| --- | --- | --- | --- |

| 直接复制文件 | 否 | 低 | 不在乎历史的小仓 |

| git subtree | 是 | 中 | 少量仓库合并 |

| git-filter-repo | 是(可重写路径) | 中高 | 多仓、需规整目录 |

| 保留旧仓只读归档 | 历史留在旧仓 | 低 | 折中,新代码进 mono |

常见坑

直接复制丢历史:blame 全指向「迁移那一次提交」,排查问题失去线索。务必用 subtree/filter-repo。
一次性大爆炸迁移:所有仓库同一天合并、同时切 CI,出问题难回滚。应分批、可灰度。
发布流程没并行验证:直接切到 changesets 发布,第一次就发错版本。应先并行跑新旧流程对比。
忽略幽灵依赖:旧仓靠 hoist 吃隐式依赖,迁到 pnpm 后集中爆雷。迁移后立刻全量重装暴露问题。

最佳实践

用 git-filter-repo/subtree 保留历史,分批迁移、每批独立验证。
迁移即统一工具链(pnpm + 一份 base 配置),别把各仓的历史差异带进新仓。
发布流程灰度切换,新旧并行验证一个周期再退旧。

小结

Monorepo 迁移的成败在于「保历史、分批次、统工具、灰度切」。把它当成一次有回滚预案的工程项目来做,而非一次性的文件搬运。

---

二十、结语:原理长青,工具流转

回顾全篇,Monorepo 的知识可以压缩成三条不变的原理:依赖图要清晰(拓扑正确、无环、边界受约束)、增量要彻底(缓存 + affected,只做必要的事)、发布要自动(版本声明左移、拓扑发布)。围绕这三条,workspace、Turborepo、Nx、changesets、project references、CODEOWNERS、dependency-cruiser 只是不同阶段的载具。

| 关注点 | 不变原理 | 当前主力工具 |

| --- | --- | --- |

| 本地引用 | 包间软链接、发布时替换版本 | pnpm workspace + workspace 协议 |

| 执行顺序 | 依赖图拓扑排序 + 并行 | Turborepo / Nx |

| 构建性能 | 输入哈希缓存 + 只做受影响 | 远程缓存 + affected |

| 类型体验 | 增量编译、产物复用 | TS project references |

| 架构健康 | 单向依赖、无环、分层 | dependency-cruiser / Nx boundaries |

| 版本发布 | 意图左移、拓扑发布 | changesets |

| 协作治理 | 路径级所有权 | CODEOWNERS + 分支保护 |

工具会被更好的工具取代,但「清晰依赖图、彻底增量、自动发布」这三条原理会一直成立。把原理吃透,换任何工具都只是配置问题。