工程化篇 | Monorepo
前言
随着前端项目越来越复杂,一个团队维护十几个甚至几十个包已经是常态。组件库、工具函数、CLI 工具、文档站……每个包各自一个仓库?还是全部放在一个仓库里?
这就是 Monorepo(单一仓库)和 Polyrepo(多仓库)之争。
很多人听说过 Monorepo,也知道 Google、Meta、微软等大厂在用它,但真到面试被问到:
- Monorepo 和 Polyrepo 各自的优缺点是什么?
- pnpm workspace、Turborepo、Nx 这些工具分别解决什么问题?
- 依赖提升和隔离是怎么回事?
- 任务编排和缓存是怎么加速构建的?
很多人就只能泛泛而谈了。
本章,我们从 Monorepo 的核心理念出发,拆解主流工具的设计思路,再聊聊大型项目的 Monorepo 实践。
诊断自测
Q1:Monorepo 和把所有代码放在一个仓库里有什么区别?它和”巨型单体应用”是一回事吗?
点击查看答案
Monorepo 不是”巨型单体应用”。Monorepo 是一个仓库里包含多个独立的包/项目,每个包有自己的 package.json、独立的构建和发布流程。它只是共享同一个 Git 仓库和一套基础设施(CI/CD、代码规范等)。
而”巨型单体应用”是所有代码耦合在一起,没有清晰的边界。Monorepo 强调的是代码共享 + 独立管理的平衡。
Q2:Turborepo 和 pnpm workspace 的关系是什么?它们解决的是同一个问题吗?
点击查看答案
不是同一个问题。
- pnpm workspace 解决的是依赖管理问题:多个包之间如何共享依赖、如何互相引用、如何统一安装
- Turborepo 解决的是任务编排问题:多个包的构建、测试、lint 任务之间的依赖关系如何、如何并行执行、如何利用缓存避免重复构建
它们是互补的关系。一个典型的 Monorepo 项目会同时使用 pnpm workspace(管理依赖)和 Turborepo(编排任务)。
一、Monorepo vs Polyrepo
1.1 什么是 Polyrepo?
Polyrepo 就是”一个包一个仓库”的传统模式:
github.com/team/
├── component-library/ ← 仓库 1
├── utils/ ← 仓库 2
├── cli-tool/ ← 仓库 3
└── web-app/ ← 仓库 4
每个仓库有独立的 Git 历史、独立的 CI/CD、独立的发布流程。
1.2 什么是 Monorepo?
Monorepo 是”多个包共存于同一个仓库”:
github.com/team/monorepo/
├── packages/
│ ├── component-library/ ← 包 1
│ ├── utils/ ← 包 2
│ ├── cli-tool/ ← 包 3
│ └── web-app/ ← 包 4
├── package.json ← 根 package.json
├── pnpm-workspace.yaml ← workspace 配置
└── turbo.json ← Turborepo 配置
1.3 对比
| 维度 | Polyrepo | Monorepo |
|---|---|---|
| 代码共享 | 通过 npm 发包互相引用,有版本延迟 | 直接引用源码,改了就生效 |
| 跨包重构 | 痛苦——改一个接口要改 N 个仓库 | 一个 PR 搞定所有包的修改 |
| CI/CD | 每个仓库独立配置 | 统一配置,但需要增量构建能力 |
| 代码规范 | 每个仓库各自维护 | 统一 ESLint、Prettier、TS 配置 |
| 权限控制 | 天然隔离 | 需要额外的工具支持(如 CODEOWNERS) |
| Git 历史 | 干净(只有自己的提交) | 混合(所有包的提交混在一起) |
| 上手门槛 | 低 | 较高(需要学习 workspace 和任务编排工具) |
1.4 什么时候选 Monorepo?
- 多个包之间有频繁的互相引用
- 需要经常做跨包重构
- 希望统一代码规范、CI/CD、工具链
- 团队规模中等,沟通成本可控
1.5 什么时候不适合 Monorepo?
- 各个项目完全独立,几乎没有代码共享
- 团队之间权限需要严格隔离
- 仓库规模极大(数百 GB 的 Git 历史),需要专门的工具支持
二、依赖管理:pnpm workspace
2.1 基本配置
pnpm workspace 是目前最流行的 Monorepo 依赖管理方案。
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
这告诉 pnpm:“packages/ 和 apps/ 下的每个目录都是一个独立的包。“
2.2 包之间的互相引用
假设 packages/utils 要被 apps/web 引用:
// packages/utils/package.json
{
"name": "@myorg/utils",
"version": "1.0.0"
}
// apps/web/package.json
{
"dependencies": {
"@myorg/utils": "workspace:*"
}
}
workspace:* 告诉 pnpm:“这个包就在本地 workspace 里,直接链接过去,不要去 npm registry 下载。”
运行 pnpm install 后,pnpm 会自动创建一个符号链接,让 apps/web 的 node_modules/@myorg/utils 指向 packages/utils。改了 utils 的代码,web 里立刻生效。
2.3 依赖提升与隔离
在 Monorepo 中,依赖提升是一个需要仔细权衡的问题:
提升(Hoisting): 把多个包共同的依赖安装到根目录的 node_modules,减少重复安装。
monorepo/
├── node_modules/
│ └── react/ ← 被提升到根目录,所有包共享
├── packages/
│ ├── app-a/
│ └── app-b/
隔离(Isolation): 每个包只能访问自己声明的依赖,防止幽灵依赖。
PNPM 默认就是隔离模式——每个包只能访问自己在 package.json 中声明的依赖。这在 Monorepo 中尤其重要,因为包之间的依赖关系必须是显式声明的,否则发布后在用户端会出问题。
2.4 常用命令
# 在所有包中执行命令
pnpm -r run build # -r 表示递归(recursive)
# 只在某个包中执行
pnpm --filter @myorg/utils run test
# 给某个包添加依赖
pnpm --filter @myorg/web add lodash
# 给根目录添加开发依赖(通常是共享的工具)
pnpm add -Dw eslint prettier
三、任务编排:Turborepo
依赖管理解决了”包怎么组织”的问题,但还有一个关键问题:构建怎么编排?
一个有 20 个包的 Monorepo,每个包都要 build、test、lint。如果串行执行,要跑 60 个任务;如果不考虑依赖关系胡乱并行,又可能因为包 A 的构建产物还没生成,依赖 A 的包 B 就先构建了。
这就是 Turborepo 要解决的问题。
3.1 任务拓扑(Task Graph)
Turborepo 会分析所有包之间的依赖关系,构建一个任务拓扑图(Task Graph)。
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // 先构建上游依赖
"outputs": ["dist/**"] // 构建产物目录
},
"test": {
"dependsOn": ["build"] // 先构建,再测试
},
"lint": {} // 无依赖,可以并行
}
}
"dependsOn": ["^build"] 中的 ^ 表示拓扑依赖——如果包 B 依赖了包 A,那么 B 的 build 任务要等 A 的 build 任务完成。
Turborepo 会根据这个拓扑图,自动确定哪些任务可以并行、哪些必须串行,并以最大并行度执行。
3.2 缓存
Turborepo 最强大的特性之一是任务缓存。
原理:每个任务的”输入”(源代码、依赖版本、环境变量等)会被哈希成一个唯一的 key。如果某次构建的输入没有变化,直接从缓存中恢复输出,跳过实际的构建过程。
$ turbo build
Tasks: 10 successful, 10 total
Cached: 8 cached, 10 total ← 8 个任务命中缓存,直接跳过
Time: 2.3s ← 本来要 60s 的构建变成 2.3s
缓存类型:
- 本地缓存:默认存储在
node_modules/.cache/turbo - 远程缓存:可以配置 Vercel Remote Cache 或自建缓存服务器,让 CI 和团队成员共享缓存
远程缓存意味着:你的同事刚跑过一次 build,你 pull 代码后再跑 build,直接命中远程缓存——几秒钟完成。
3.3 增量构建
有了缓存和拓扑分析,Turborepo 实现了真正的增量构建:
- 你改了
packages/utils的一行代码 - Turborepo 分析出只有
utils和依赖它的web-app需要重新构建 - 其他 18 个包的构建结果直接从缓存恢复
- 总构建时间从 60s 降到 5s
四、Nx:企业级 Monorepo 方案
4.1 Nx 是什么?
Nx 是另一个流行的 Monorepo 工具,由 Nrwl 团队开发。和 Turborepo 相比,Nx 更”重”——它不仅做任务编排,还提供了项目脚手架、代码生成器、依赖可视化等一整套工具。
4.2 Nx vs Turborepo
| 维度 | Turborepo | Nx |
|---|---|---|
| 定位 | 轻量级任务编排器 | 全功能 Monorepo 开发平台 |
| 上手难度 | 低(一个 turbo.json) | 较高(有学习曲线) |
| 任务缓存 | 支持 | 支持 |
| 远程缓存 | 支持(Vercel) | 支持(Nx Cloud) |
| 代码生成器 | 无 | 有(nx generate) |
| 依赖图可视化 | 有(turbo graph) | 有(nx graph,更强大) |
| 插件生态 | 轻量 | 丰富(React、Angular、Node 等插件) |
| 适合场景 | 轻量 Monorepo | 大型企业级 Monorepo |
4.3 选型建议
- 小中型项目(10 个包以内):pnpm workspace + Turborepo 足够
- 大型项目(几十个包、多团队协作):考虑 Nx 的全套方案
- 已有 Angular 项目:Nx 原生支持 Angular,迁移成本低
五、大型项目的 Monorepo 实践
5.1 目录结构规范
一个成熟的 Monorepo 通常是这样组织的:
monorepo/
├── apps/ ← 应用(可部署的最终产物)
│ ├── web/
│ ├── admin/
│ └── mobile/
├── packages/ ← 共享包(被 apps 引用)
│ ├── ui/ ← 组件库
│ ├── utils/ ← 工具函数
│ ├── config/ ← 共享配置(ESLint、TS 等)
│ └── types/ ← 共享类型定义
├── tools/ ← 内部工具
│ └── scripts/
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json ← 共享 TS 基础配置
5.2 共享配置
ESLint、TypeScript、Prettier 等工具的配置可以抽成一个共享包:
// packages/config/eslint-config/index.js
module.exports = {
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
// 团队统一规则
}
};
// apps/web/.eslintrc.js
module.exports = {
extends: ['@myorg/eslint-config']
};
5.3 版本管理与发布
Monorepo 中包的版本管理有两种策略:
统一版本(Fixed): 所有包使用相同的版本号。改了任何一个包,所有包一起升版。适合紧密耦合的包集(如 Babel 的各个子包)。
独立版本(Independent): 每个包独立管理版本号,只有实际修改的包才升版。适合松散耦合的包集。
常用的版本管理工具:
- Changesets:社区最流行的方案,和 pnpm workspace 配合良好
- Lerna:老牌工具,现已被 Nx 收购和维护
# Changesets 工作流
pnpm changeset # 1. 记录变更
pnpm changeset version # 2. 根据变更生成版本号
pnpm changeset publish # 3. 发布到 npm
5.4 CI/CD 优化
大型 Monorepo 的 CI/CD 必须做到增量,否则每次 PR 都要全量构建/测试,时间不可接受。
# GitHub Actions 示例
- name: Build affected packages
run: turbo build --filter=...[HEAD~1]
# 只构建受本次提交影响的包
关键策略:
- 只构建受影响的包:Turborepo/Nx 都支持分析哪些包受当前变更影响
- 利用远程缓存:CI 上的构建结果缓存到远程,下次 CI 直接复用
- 并行化:充分利用 CI 的多核能力
常见误区
误区一:“Monorepo 就是把所有代码放一起”
Monorepo 的核心不是”放一起”,而是有组织地放一起。每个包应该有清晰的边界(独立的 package.json、独立的构建配置),包之间的依赖关系应该是显式声明的。如果所有代码都耦合在一起没有明确边界,那不叫 Monorepo,叫”大泥球”。
误区二:“Monorepo 一定比 Polyrepo 好”
不是。Monorepo 的优势在于代码共享和统一管理,但它也带来了复杂性:需要额外的工具(workspace、任务编排)、CI/CD 需要增量能力、Git 仓库可能变得很大。如果你的几个项目之间几乎没有代码共享,Polyrepo 反而更简单直接。
误区三:“有了 pnpm workspace 就够了,不需要 Turborepo”
pnpm workspace 只解决依赖管理问题,不解决任务编排问题。没有 Turborepo 或 Nx,你的 pnpm -r run build 会串行执行所有包的 build 任务,没有并行、没有缓存、没有增量构建。项目小的时候没感觉,包一多就会成为瓶颈。
误区四:“Turborepo 和 Nx 只能二选一”
虽然它们有功能重叠,但确实是二选一的——在同一个项目中使用两者没有意义。选择时考虑项目规模和团队偏好:Turborepo 轻量、上手快;Nx 功能全、适合大型项目和 Angular 生态。
小结
本章我们从 Monorepo 的核心理念出发,对比了 Monorepo 和 Polyrepo 的适用场景,深入拆解了 pnpm workspace(依赖管理)和 Turborepo(任务编排)的工作原理,并分享了大型项目的实践经验。
核心要点
- Monorepo ≠ 巨型单体:Monorepo 是多个独立包共存于一个仓库,有清晰边界
- pnpm workspace 做依赖管理:
workspace:*本地链接、严格依赖隔离 - Turborepo 做任务编排:拓扑分析 → 最大并行 → 缓存加速
- 缓存是关键:本地缓存 + 远程缓存,让增量构建成为可能
- 版本管理用 Changesets:记录变更 → 生成版本号 → 发布
- CI/CD 必须增量化:只构建/测试受影响的包
本章思维导图
- 概念
- Monorepo vs Polyrepo
- 不是"大泥球",是有组织的多包共存
- 依赖管理:pnpm workspace
- pnpm-workspace.yaml 定义包范围
- workspace:* 本地链接
- 依赖隔离(防止幽灵依赖)
- pnpm --filter 精确操作
- 任务编排:Turborepo
- 任务拓扑图(Task Graph)
- ^build:拓扑依赖
- 缓存(本地 + 远程)
- 增量构建:只处理受影响的包
- Nx
- 全功能 Monorepo 平台
- vs Turborepo:重 vs 轻
- 适合大型企业级项目
- 实践
- 目录结构:apps / packages / tools
- 共享配置:ESLint / TS / Prettier
- 版本管理:Changesets / 统一版本 vs 独立版本
- CI/CD:增量构建 + 远程缓存
练习挑战
第一题 ⭐(基础):概念辨析
请判断以下说法是否正确,并说明原因:
- “Monorepo 意味着所有代码必须用同一种语言”
- “pnpm workspace 可以让包 A 直接 import 包 B 的源码”
- “Turborepo 的缓存只能在本地使用”
点击查看答案与解析
-
错误。 Monorepo 是仓库组织方式,和编程语言无关。一个 Monorepo 里可以同时有 TypeScript 前端项目、Go 后端服务和 Python 脚本。Google 的 Monorepo 就包含了几乎所有语言的代码。
-
正确。 使用
workspace:*声明依赖后,pnpm 会创建符号链接,让包 A 的node_modules中的包 B 直接指向包 B 的源码目录。修改包 B 的代码后,包 A 中立即生效,不需要重新发布。 -
错误。 Turborepo 支持远程缓存(Remote Cache)。可以通过 Vercel Remote Cache 或自建缓存服务器,让团队成员和 CI 共享构建缓存。
第二题 ⭐⭐(进阶):配置 turbo.json
一个 Monorepo 有以下包依赖关系:web 依赖 ui 和 utils,ui 依赖 utils。每个包都有 build、test、lint 三个任务。请配置 turbo.json,要求:
build必须先构建上游依赖test必须在本包的build完成后才能执行lint无依赖,可以和任何任务并行
点击查看答案与解析
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}
执行 turbo build test lint 时,Turborepo 会:
- 先构建
utils(没有上游依赖) utils构建完成后,并行构建ui和web(取决于web是否等ui——因为web依赖ui,所以web的 build 要等ui的 build)- 实际顺序:
utils.build→ui.build→web.build - 每个包的
test在其build完成后执行 - 所有包的
lint可以随时并行执行
^build 中的 ^ 是关键——它表示”我的上游依赖的 build 任务”,而不是”当前包自己的某个任务”。
第三题 ⭐⭐⭐(综合):Monorepo 选型方案
你们团队有以下项目:一个 React Web 应用、一个 React Native 移动端应用、一个共享组件库、一个共享工具函数库、一个 Node.js BFF 服务。目前是 5 个独立仓库,频繁出现”改了组件库要等发布、其他项目才能用”的问题。
请设计一个 Monorepo 迁移方案,包括:目录结构、工具选型、依赖管理策略、CI/CD 方案。
点击查看答案与解析
一、目录结构
monorepo/
├── apps/
│ ├── web/ ← React Web 应用
│ ├── mobile/ ← React Native 应用
│ └── bff/ ← Node.js BFF
├── packages/
│ ├── ui/ ← 共享组件库
│ ├── utils/ ← 共享工具函数
│ ├── config/ ← 共享配置
│ │ ├── eslint-config/
│ │ └── tsconfig/
│ └── types/ ← 共享类型定义
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── .changeset/ ← Changesets 配置
二、工具选型
| 用途 | 工具 | 原因 |
|---|---|---|
| 包管理器 | PNPM | 严格依赖、省磁盘、内置 workspace |
| 任务编排 | Turborepo | 轻量、上手快、缓存能力强 |
| 版本管理 | Changesets | 社区主流、和 PNPM 配合好 |
| CI | GitHub Actions | 配合 Turborepo 远程缓存 |
三、依赖管理策略
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
- 包之间用
workspace:*互相引用 - 共享的开发工具(ESLint、Prettier、TypeScript)安装在根目录
- 每个包声明自己的运行时依赖
四、CI/CD 方案
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo build test lint --cache-dir=.turbo
关键点:
- 使用
--frozen-lockfile确保 CI 和本地安装一致 - Turborepo 自动只构建受变更影响的包
- 配置远程缓存进一步加速 CI
自我检测
- 能说清楚 Monorepo 和 Polyrepo 的各自优缺点和适用场景
- 能解释 pnpm workspace 中
workspace:*的作用和工作原理 - 能描述 Turborepo 的任务拓扑图和缓存机制
- 能区分 pnpm workspace 和 Turborepo 各自解决的问题
- 能说出 Turborepo 和 Nx 的核心差异和各自适用场景
- 能设计一个合理的 Monorepo 目录结构
- 能解释版本管理的两种策略(统一版本 vs 独立版本)
- 能说出大型 Monorepo CI/CD 的增量构建策略
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90