Monorepo 管理策略
Monorepo 管理策略
Monorepo(单一仓库)是一种代码管理策略:把多个项目或包放进同一个版本控制仓库里统一管理。它不是一个具体的工具,而是一种组织方式;配合 pnpm workspace、Turborepo、Nx、changesets 等工具链,才能在大规模下真正发挥价值。本篇从概念出发,逐层展开到任务编排、依赖图、远程缓存、增量构建、版本发布、TypeScript 引用、循环依赖治理、权限与 CI 优化,并给出大量可直接落地的配置示例与真实案例数据。
如果用一个类比来理解:Multirepo(多仓库)像一栋栋独立的别墅,每栋有自己的水电、门禁、装修队;Monorepo 则像一座大型公寓楼,共享水电管网、统一物业和电梯,住户之间搬东西(复用代码)只需走个楼道,但楼越高(仓库越大),电梯(构建/CI)的调度就越考验工程能力。
---
一、Monorepo 概念
概念定义
Monorepo 的核心特征只有一句话:一个仓库,多个可独立发布/部署的项目。它通常包含:
需要澄清两个常见误解:
为什么重要
前端与 Node 生态在过去十年经历了「组件化 → 微前端 → 设计系统 → BFF/全栈」的演进,一个中大型团队往往同时维护:主站、后台、移动端 H5、组件库、工具库、脚手架、Node 服务。如果每个都是独立仓库,会出现:
Monorepo 把这些痛点收敛到一个仓库里,一次原子提交就能同时改动组件库和所有使用方,天然保证「改了就一起改」。
原理
Monorepo 的可行性建立在三块基础设施之上:
优势与挑战对比
| 维度 | Monorepo 优势 | Monorepo 挑战 |
| --- | --- | --- |
| 代码复用 | 内部包直接引用,改动即时可见 | 边界容易被打破,耦合失控 |
| 版本管理 | 统一视图,原子提交 | 大量包时版本策略复杂 |
| 依赖管理 | 单一 lockfile,依赖收敛 | 幽灵依赖、版本提升冲突 |
| 协作 | 跨团队变更一次搞定 | 权限与 review 边界需额外治理 |
| 构建/CI | 可做增量、缓存、affected | 全量构建慢,需工具优化 |
| 仓库规模 | 单一克隆入口 | 体积膨胀,Git 操作变慢 |
常见坑
最佳实践
小结
Monorepo 的本质是「用工程能力换协作效率」。它把跨仓库协作的隐性成本,转化为可以用工具(缓存、增量、依赖图)系统性解决的显性问题。
---
二、Monorepo vs Multirepo
概念定义
为什么重要
选错策略的代价很高:Multirepo 团队后期常被「跨仓库联动发布」拖垮,Monorepo 团队则常被「构建/CI 性能」和「权限混乱」反噬。这个决策通常在项目早期做出,后期迁移成本巨大,所以值得认真权衡。
原理:差异根源
两者的根本差异在于「变更的作用域」。
代码示例:同一个需求的两种落地
场景:组件库给 `Button` 新增一个 `loading` 属性,主站要用上。
Multirepo 流程(伪脚本):
# 仓库 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 等 CIMonorepo 流程:
# 单一仓库,一个分支
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真实案例
数据/对比表格
| 维度 | Monorepo | Multirepo |
| --- | --- | --- |
| 版本管理 | 统一,一处可见全貌 | 各自独立 |
| 原子提交 | 支持,跨包一次提交 | 不支持,需多次发版联动 |
| 代码共享 | 直接引用,即改即用 | 需发布 npm 包 |
| 依赖管理 | 单一 lockfile,易收敛 | 分散,易版本漂移 |
| 权限隔离 | 需 CODEOWNERS 等细粒度治理 | 天然按仓库隔离 |
| 仓库体积 | 大,克隆慢 | 小,克隆快 |
| CI/CD | 需增量/缓存优化 | 天然按仓库触发 |
| 上手成本 | 高(需工具链) | 低 |
| 适合场景 | 紧密相关、共享多、协作频繁 | 独立、权限严、发布节奏不同 |
常见坑
最佳实践
小结
没有银弹。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)+ 硬链接/符号链接」实现依赖复用:
npm/yarn(classic)默认「扁平化 hoist」,所有依赖提升到根 `node_modules`,导致你可能 import 到没在 `package.json` 里声明的包——这就是幽灵依赖,换个环境就崩。
代码示例
pnpm workspace 声明文件:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 'configs/*'
# 排除某目录
- '!**/__tests__/**'npm workspace(npm 7+)在根 `package.json` 声明:
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}yarn workspace(yarn 1/berry)同样在根 `package.json`:
{
"name": "my-monorepo",
"private": true,
"workspaces": {
"packages": ["apps/*", "packages/*"],
"nohoist": ["**/react-native", "**/react-native/**"]
}
}常用命令对比:
# 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 有兼容坑) | 低 | 低 |
| 推荐度 | 首选 | 可选 | 不推荐新项目 | 够用 |
常见坑
最佳实践
小结
workspace 是 Monorepo 的地基,pnpm 因其磁盘效率与严格的依赖隔离成为当下首选。选对包管理器,后面的构建编排才有意义。
---
四、构建编排工具全景对比
概念定义
包管理器解决「包怎么装、怎么互相引用」,但「build/test/lint 这些任务按什么顺序、能不能并行、结果能不能缓存」需要专门的任务编排器。主流选手:Turborepo、Nx、Lerna、Rush、Bazel。
为什么重要
当仓库里有 30 个包时,`pnpm -r run build` 会一个个串行构建,可能要 15 分钟。任务编排器能:理解依赖图 → 拓扑排序 → 最大化并行 → 缓存未变化包的结果 → 只构建受影响的包。这直接决定本地开发和 CI 的体感速度。
原理
编排器的核心是三件事:
代码示例:各工具最小配置
Turborepo(`turbo.json`,2.x 使用 `tasks` 字段):
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}Nx(`nx.json` 片段):
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
}
}
}Lerna(`lerna.json`,现由 Nx 团队维护,底层可接 Nx 缓存):
{
"version": "independent",
"npmClient": "pnpm",
"command": {
"publish": {
"conventionalCommits": true
}
}
}Rush(`rush.json` 片段):
{
"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` 片段,声明式、语言无关):
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,
)真实案例
数据/对比表格
| 工具 | 出品方 | 定位 | 缓存 | 远程缓存 | 代码生成 | 依赖图约束 | 学习曲线 | 适合规模 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Turborepo | Vercel | 轻量任务编排 | 本地+远程 | 内置 | 弱 | 弱 | 平缓 | 中小到中大 |
| Nx | Nrwl | 全家桶平台 | 本地+远程 | Nx Cloud | 强 | 强 | 较陡 | 中大到大 |
| Lerna | 社区/Nx | 版本发布为主 | 可接 Nx | 依赖 Nx | 无 | 无 | 平缓 | 中小 |
| Rush | 微软 | 企业级严格治理 | 本地+远程 | 支持 | 一般 | 强 | 陡 | 大 |
| Bazel | Google | 语言无关超大规模 | 本地+远程 | 强 | 一般 | 强 | 很陡 | 超大 |
常见坑
最佳实践
小结
工具没有优劣,只有匹配度。绝大多数前端团队用 pnpm + Turborepo 就能覆盖 90% 需求;只有当边界治理、代码生成、跨语言构建成为刚需时,再向 Nx/Bazel 升级。
---
五、任务编排与依赖图
概念定义
任务编排(task orchestration)是指编排器根据包依赖关系和任务间依赖关系,自动决定「先跑谁、后跑谁、谁能并行」。依赖图(task graph)是这一切的基础数据结构:一个有向无环图,节点是「某个包的某个任务」,边是「依赖关系」。
为什么重要
手动写脚本控制构建顺序既易错又难维护。假设 web 依赖 ui,ui 依赖 utils,正确顺序必须是 utils → ui → web。包一多,人工维护顺序不现实,且无法自动并行。依赖图让编排器自动算出最优执行计划。
原理
关键语法是 `dependsOn` 里的两种前缀:
编排器读取依赖图后,做拓扑排序 + 并行调度:同一层级、无依赖关系的任务并行执行,充分利用多核。
代码示例:完整 turbo.json 任务图
{
"$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"]
}
}
}查看依赖图(可视化排查顺序问题):
# 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 秒 | 直接复用产物 |
常见坑
最佳实践
小结
依赖图是 Monorepo 编排的大脑。正确声明任务依赖,编排器才能既保证顺序正确,又榨干多核并行的性能。
---
六、远程缓存与增量构建
概念定义
为什么重要
远程缓存是 Monorepo CI 提速的杀手锏。典型场景:PR 的 CI 里,大部分包和 main 分支一模一样,如果 CI 能直接下载 main 已构建好的缓存,就只需构建你真正改动的几个包。这能把 CI 时间从几十分钟压到几分钟。
原理
缓存命中的判定基于「输入哈希」。编排器把一个任务的所有输入(源文件内容、依赖包版本、任务配置、相关环境变量、工具版本)序列化后算出一个哈希值。如果这个哈希在缓存里存在,就命中,直接取出对应的输出产物(`dist`、日志、退出码)。任何输入变化都会导致哈希变化,从而 miss、重新执行并写入新缓存。
远程缓存只是把「缓存的存储层」从本地磁盘换成了云端对象存储(S3、Vercel、Nx Cloud 等),命中逻辑完全一致。
代码示例:turbo.json 开启远程缓存
{
"$schema": "https://turbo.build/schema.json",
"remoteCache": {
"enabled": true,
"signature": true
},
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["API_URL", "PUBLIC_KEY"]
}
}
}登录并链接远程缓存(Vercel 托管):
# 使用 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):
# 指向自建缓存服务
export TURBO_API=https://cache.mycompany.com
export TURBO_TOKEN=internal-token
export TURBO_TEAM=frontend
turbo run build --remote-onlyNx 远程缓存(Nx Cloud):
npx nx connect
# nx.json 中会写入 accessToken,CI 自动共享缓存真实案例
数据/对比表格
| 场景 | 无缓存 | 本地缓存命中 | 远程缓存命中 |
| --- | --- | --- | --- |
| 本地二次构建 | 约 4 分钟 | 约 15 秒 | 约 15 秒 |
| 新克隆/新机器 | 约 4 分钟 | 未命中,全量 | 约 40 秒(下载产物) |
| CI(改 2 个包) | 约 22 分钟 | 不适用 | 约 4 分钟 |
| 缓存命中率(成熟团队) | - | 约 60%-80% | 约 80%-95% |
常见坑
最佳实践
小结
远程缓存把「团队和 CI 的重复劳动」变成「一次构建、处处复用」,是 Monorepo 规模化后 ROI 最高的一项投入。前提是把输入哈希(inputs/outputs/env)配置精确。
---
七、affected:只构建变更影响的包
概念定义
affected(受影响范围)是指:给定一次代码变更(相对某个 base,通常是 main),计算出「哪些包的代码直接改了,以及哪些包因为依赖了这些改动而间接受影响」,然后只对这批包执行任务。
为什么重要
即使有缓存,全量遍历所有包判断缓存也有开销;更重要的是,affected 让 CI 的语义变成「只验证这次改动可能破坏的东西」。改一个只被后台用的包,就不必跑主站的 e2e。这在几十上百个包的仓库里能节省大量时间。
原理
affected 的计算分两步:
注意方向:affected 是沿「被依赖 → 依赖方」向下游扩散,因为下游会消费上游的产物。
代码示例
Nx affected(一等公民):
# 只对受影响的包跑 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/mainTurborepo 用 `--filter` 配合 git 范围实现类似效果:
# 只对相对 main 有改动的包及其下游跑 build
turbo run build --filter="...[origin/main]"
# 只跑改动包本身(不含下游)
turbo run test --filter="[origin/main]"
# 组合:改动包 + 其下游依赖方
turbo run lint --filter="...[origin/main]...":CI 中的典型用法(先取到 base 引用):
# 确保有 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 秒 | 最优组合 |
常见坑
最佳实践
小结
affected 让 CI 从「验证一切」变成「只验证这次可能弄坏的东西」,是 Monorepo 增量 CI 的核心思想。它依赖精确的依赖图和可靠的 git base。
---
八、版本管理与发布:changesets 与语义化发布
概念定义
为什么重要
Monorepo 里几十个包,手动改版本号、写 changelog、按依赖顺序发布,既繁琐又易错(漏发一个上游包,下游装不上)。changesets 把「版本决策」左移到 PR 阶段,由作者声明意图,自动化后续所有环节。
原理
changesets 工作流:
代码示例
配置文件:
{
"$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`):
---
"@my-org/ui": minor
"@my-org/utils": patch
---
Button 组件新增 loading 属性;修复 formatDate 时区问题根 package.json 的发布脚本:
{
"scripts": {
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo run build --filter=./packages/* && changeset publish"
}
}GitHub Actions 自动发布:
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) | 自动 | 单包或简单多包 |
常见坑
最佳实践
小结
changesets 把版本决策变成 PR 里的一份声明,把发布变成自动化流水线。它是当下 Monorepo(尤其是要对外发 npm 包的)事实标准。
---
九、内部包引用:workspace 协议
概念定义
`workspace:` 协议是包管理器提供的一种依赖版本写法,表示「这个依赖来自本仓库的 workspace,用本地版本,别去 registry 找」。常见写法:`workspace:*`、`workspace:^`、`workspace:~`。
为什么重要
它是「本地开发用软链接、发布时自动替换成真实版本号」的关键。开发时你改 ui 的源码,web 立刻能感知;发布时 `workspace:*` 会被自动替换成 ui 当时的真实版本(如 `^1.3.0`),保证发到 npm 的包依赖是正确的语义化版本,外部用户装得上。
原理
- `workspace:*` → 当前确切版本(如 `1.3.0`)
- `workspace:^` → `^1.3.0`
- `workspace:~` → `~1.3.0`
代码示例
内部包互相引用:
{
"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"
}
}应用引用组件库:
{
"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:
// 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:` 协议是 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.base.json`(共享编译选项):
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"incremental": true
}
}包级 `packages/ui/tsconfig.json`:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}根 `tsconfig.json`(编排所有引用):
{
"files": [],
"references": [
{ "path": "./packages/types" },
{ "path": "./packages/utils" },
{ "path": "./packages/ui" },
{ "path": "./apps/web" }
]
}构建命令:
# 增量构建整个引用图
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 |
常见坑
最佳实践
小结
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 的)绑定。
代码示例:发现与拆解
用工具检测循环:
# 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-cycleeslint 配置阻断循环:
{
"rules": {
"import/no-cycle": ["error", { "maxDepth": 10 }]
}
}拆解手法——提取共享层。改造前(循环):
// 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` 打破环):
// 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 分离 |
常见坑
最佳实践
小结
循环依赖是 Monorepo 架构腐化的信号。用工具尽早检测、用分层与共享包主动拆解、用 lint 规则持续守护,才能保持依赖图的健康。
---
十二、CODEOWNERS 与权限治理
概念定义
CODEOWNERS 是 GitHub/GitLab 支持的一个文件,用「路径 → 负责人/团队」的映射,声明每块代码由谁负责。当 PR 改动某路径时,对应 owner 会被自动请求 review,并可强制要求其批准才能合并。
为什么重要
Monorepo 打破了「一个仓库一个团队」的天然边界,所有人都能改所有代码。没有权限治理,组件库可能被业务同学随手改坏、核心基础设施无人把关。CODEOWNERS 在「共享仓库」里重建了「模块所有权」,兼顾开放协作与质量把控。
原理
代码示例
`.github/CODEOWNERS`:
# 默认所有文件由平台团队兜底
* @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/设置,示意):
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 是 Monorepo 权限治理的第一道防线,它在「谁都能改」的开放仓库里重建了「谁负责」的秩序,是大规模协作不可或缺的一环。
---
十三、增量 CI 与流水线优化
概念定义
增量 CI 指 CI 流水线不再「每次全量构建测试所有包」,而是结合 affected、缓存、并行分片(sharding),只做必要的工作。它是前面几节(依赖图、远程缓存、affected)在 CI 中的综合落地。
为什么重要
CI 时间直接决定「合并一个 PR 要等多久」,进而影响整个团队的迭代速度。Monorepo 若不做增量 CI,随着包增长,CI 时间线性膨胀,最终成为团队最大的效率瓶颈之一。
原理
增量 CI 的三板斧叠加:
代码示例:Turborepo 增量 CI
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=.turboNx 增量 + 分布式 CI:
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)示例:
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- run: pnpm test -- --shard=${{ matrix.shard }}/4真实案例
某仓库 CI 优化历程:
整体 CI 时间下降约 92%。
数据/对比表格
| 优化阶段 | CI 耗时 | 相对起点 |
| --- | --- | --- |
| 全量串行 | 约 34 分钟 | 基线 |
| 缓存 + 并行 | 约 18 分钟 | -47% |
| + 远程缓存 | 约 9 分钟 | -74% |
| + affected | 约 4 分钟 | -88% |
| + 测试分片 | 约 2.5 分钟 | -92% |
常见坑
最佳实践
小结
增量 CI 是 Monorepo 前面所有优化手段在流水线中的集大成。做到位,几十个包的仓库也能保持分钟级 CI,团队迭代不被拖慢。
---
十四、大仓性能问题与治理
概念定义
当 Monorepo 增长到超大规模(数千包、数百万文件、数百 GB 历史),会遇到「工具本身撑不住」的问题:`git status` 慢、克隆慢、IDE 卡、node_modules 巨大、构建图爆炸。这类问题超出常规工具范畴,需要专门的大仓(large-scale monorepo)技术。
为什么重要
多数团队不会到这个规模,但了解上限有助于判断「我的方案能撑多大」和「Google/Meta 是怎么做的」。理解这些技术也能反哺中等规模仓库的优化思路。
原理与技术手段
代码示例
# 部分克隆 + 稀疏检出,只拉取需要的部分
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 truepnpm 针对大仓的配置(`.npmrc`):
# 只安装被过滤到的包的依赖,加速局部开发
prefer-workspace-packages=true
# 提升安装并发
network-concurrency=32
# 用 hard link 复用 store
package-import-method=hardlink真实案例
数据/对比表格
| 规模层级 | 包/文件量级 | 典型工具栈 | 关键瓶颈 |
| --- | --- | --- | --- |
| 小型 | < 10 包 | pnpm workspace | 几乎无 |
| 中型 | 10-100 包 | pnpm + Turborepo | 构建/CI 时间 |
| 大型 | 100-1000 包 | pnpm + Nx + 远程缓存 | 依赖图、缓存命中率 |
| 超大型 | 1000+ 包 / 百万文件 | Bazel + 虚拟文件系统 + 远程执行 | Git 性能、文件系统 |
常见坑
最佳实践
小结
大仓性能是「规模到了才需要面对」的问题。Google/Meta 的方案代表工程上限,但对 99% 的团队,pnpm + Turborepo/Nx + 远程缓存 + affected 已经足够,把这几样用好比盲目追求大厂方案更重要。
---
十五、完整落地:一个中型 Monorepo 的全套配置
目录结构
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
{
"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)
{
"name": "@my-org/config",
"version": "0.0.0",
"private": true,
"exports": {
"./eslint": "./eslint.js",
"./tsconfig": "./tsconfig.json",
"./prettier": "./prettier.js"
}
}其他包复用共享 eslint:
// packages/ui/.eslintrc.js
module.exports = {
extends: [require.resolve('@my-org/config/eslint')],
rules: {
'import/no-cycle': 'error'
}
};一次典型开发流程(端到端)
# 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)」,再声明标签之间允许的依赖方向:
lint 在解析每条 import 时,查出源包与目标包的标签,比对规则表,违规即报错。
代码示例
Nx 里给项目打标签(`project.json`):
{
"name": "feature-order",
"tags": ["type:feature", "scope:order"]
}在根 eslint 配置声明允许的依赖关系:
{
"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`):
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 里执行校验:
# 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 | 无 | 靠人 | 靠人 | 不可规模化 |
常见坑
最佳实践
小结
边界约束把「分层架构」从文档里的图变成 CI 里的红线。它是循环依赖治理的上位手段:与其事后拆环,不如事前禁止不该有的依赖形成。
---
十八、多语言 Monorepo
概念定义
多语言 Monorepo(polyglot monorepo)指一个仓库里同时容纳多种技术栈:前端 TS、后端 Go/Java/Python、移动端、甚至 protobuf 契约。它超出了 pnpm/Turborepo 这类「JS 专用」工具的能力边界,通常需要语言无关的构建系统。
为什么重要
全栈团队常希望「前端和它调用的后端接口放一个仓库,改接口和改调用一次提交搞定」。这时纯 JS 工具链无法编排 Go 的编译、Python 的测试。要么用语言无关的 Bazel/Buck2,要么用 Nx 的多语言插件把外部命令包进任务图。
原理
代码示例
用 protobuf 作为跨语言契约:
proto/order/v1/order.proto -> 生成 TS 客户端 + Go 服务端Nx 把 Go 服务包成一个 target(`apps/order-service/project.json`):
{
"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:
# 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"],
)一次跨语言的契约变更 + 双端更新:
# 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 | 中 |
| 各语言原生工具 + 脚本 | 全语言 | 无统一缓存 | 无 | 低但难维护 |
常见坑
最佳实践
小结
多语言 Monorepo 的关键不是「塞进一个仓库」,而是「用统一契约保证跨语言一致 + 用语言无关的编排共享缓存」。工具选型上,Bazel 是上限、Nx run-commands 是性价比之选。
---
十九、Monorepo 迁移实战与踩坑
概念定义
Monorepo 迁移指把原本分散的多个 Git 仓库合并进一个 Monorepo,或反过来从「大泥球单仓」重构出清晰的包边界。难点在于既要保留 Git 历史,又要在迁移过程中不阻塞正常业务开发。
为什么重要
迁移是一次性的高风险操作,做不好会丢失历史、打乱 CI、破坏发布流程,甚至让团队对 Monorepo 失去信心。一套稳妥的迁移剧本能把风险降到最低。
原理与步骤
代码示例
用 git-filter-repo 保留历史迁移(推荐):
# 把旧仓 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 的等价做法:
git subtree add --prefix=packages/ui \
https://github.com/my-org/ui-lib main迁移后校验没有幽灵依赖与循环:
# 全量重装,暴露隐式依赖
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。过程中踩到三个典型坑:
迁移完成后,跨仓库联动发布的场景(改组件库同时更新 4 个应用)从原来平均 2 天缩短到 1 个 PR、约 2 小时。
数据/对比表格
| 迁移方式 | 是否保留历史 | 复杂度 | 适用场景 |
| --- | --- | --- | --- |
| 直接复制文件 | 否 | 低 | 不在乎历史的小仓 |
| git subtree | 是 | 中 | 少量仓库合并 |
| git-filter-repo | 是(可重写路径) | 中高 | 多仓、需规整目录 |
| 保留旧仓只读归档 | 历史留在旧仓 | 低 | 折中,新代码进 mono |
常见坑
最佳实践
小结
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 + 分支保护 |
工具会被更好的工具取代,但「清晰依赖图、彻底增量、自动发布」这三条原理会一直成立。把原理吃透,换任何工具都只是配置问题。