主题
Monorepo
Monorepo(Monolithic Repository)是一种将多个项目、多个包放在同一个代码仓库中进行统一管理的代码组织策略。它不是简单地把代码塞到一个仓库里,而是通过工具链支撑 + 工程化约束,实现多包之间的高效协作。Google、Meta、Microsoft、Vercel 等公司都在大规模使用 Monorepo。
本文将从核心概念 → 工具链深度解析(pnpm workspace / Turborepo / Nx / Changesets)→ 工具横向对比 → 最佳实践 → 面试高频题全面拆解 Monorepo 工程化体系。
一、Monorepo 概念
1.1 三种代码组织方式
在软件工程实践中,代码的组织方式主要有三种:
1. Monolith(单体应用)
┌─────────────────────────────────────┐
│ 一个仓库 │
│ ┌───────────────────────────────┐ │
│ │ 一个巨大的应用 │ │
│ │ │ │
│ │ 前端 + 后端 + 数据库 + 配置 │ │
│ │ 所有代码耦合在一起 │ │
│ │ 共用一个 package.json │ │
│ │ 共用一套构建流程 │ │
│ │ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
2. Multirepo(多仓库)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ repo-A │ │ repo-B │ │ repo-C │
│ │ │ │ │ │
│ 独立仓库 │ │ 独立仓库 │ │ 独立仓库 │
│ 独立发布 │ │ 独立发布 │ │ 独立发布 │
│ 独立 CI │ │ 独立 CI │ │ 独立 CI │
└──────────┘ └──────────┘ └──────────┘
↕ ↕ ↕
通过 npm 发布后互相引用,版本协调成本高
3. Monorepo(单仓多包)
┌─────────────────────────────────────────┐
│ 一个仓库 │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ app-web │ │ app-api │ │app-admin│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────┬─────┘───────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ packages/shared-utils │ │
│ │ packages/ui-components │ │
│ │ packages/config │ │
│ └──────────────────────────────────┘ │
│ │
│ 统一依赖管理 · 原子提交 · 代码复用 │
└─────────────────────────────────────────┘1.2 三种方式对比
| 维度 | Monolith | Multirepo | Monorepo |
|---|---|---|---|
| 代码组织 | 一个项目一份代码 | 多个仓库各自独立 | 一个仓库多个包 |
| 代码共享 | 天然共享(但易耦合) | 通过 npm 包共享 | 通过 workspace 直接引用 |
| 依赖管理 | 单一 package.json | 各仓库独立管理 | 统一管理 + 自动提升 |
| 版本协调 | 无需协调 | 手动协调版本 | 统一版本或独立版本 |
| 原子提交 | ✅ 天然支持 | ❌ 跨仓库无法原子提交 | ✅ 支持 |
| CI/CD | 简单 | 各仓库独立配置 | 统一配置 + 增量构建 |
| 构建速度 | 全量构建 | 各仓库独立构建 | 增量构建 + 缓存 |
| 权限管理 | 简单 | ✅ 仓库级别隔离 | 需要额外工具(CODEOWNERS) |
| 仓库体积 | 中等 | 小 | 可能很大 |
| 适用场景 | 小型项目 | 独立性强的项目 | 关联性强的多项目 |
1.3 Monorepo 的优势
代码共享与复用:不同应用可以直接引用内部包,无需发布到 npm,修改即时生效。
修改 packages/ui-components 中的 Button 组件
→ app-web 立即可见最新代码
→ app-admin 立即可见最新代码
→ 无需 npm publish → npm install 的繁琐流程统一依赖管理:所有项目共享同一份 node_modules(经过优化),避免依赖版本不一致导致的 "在我这能跑" 问题。
原子提交:一次 commit 可以同时修改多个包,保证跨包变更的一致性。比如修改一个共享 API 的接口签名,可以在同一个 PR 中同步修改所有调用方。
统一 CI/CD:一套 CI 配置覆盖所有项目,结合增量构建只测试受影响的包。
统一代码规范:所有项目共享同一份 ESLint、Prettier、TypeScript 配置,保证代码风格一致。
1.4 Monorepo 的劣势
仓库体积膨胀:随着项目增多,仓库体积会越来越大,git clone 和 git status 等操作变慢。需要 Git 的 sparse checkout、shallow clone 等策略缓解。
构建速度:如果没有增量构建工具的支持,每次 CI 都需要全量构建所有包,耗时巨大。
权限管理复杂:所有代码在同一仓库,无法通过仓库级别的权限来隔离。需要依赖 CODEOWNERS 文件和 CI 检查来实现细粒度权限控制。
工具链门槛:需要掌握 pnpm workspace、Turborepo/Nx、Changesets 等一整套工具链。
1.5 Monorepo 典型项目结构
my-monorepo/
├── apps/ # 应用层
│ ├── web/ # Web 前端应用
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ ├── admin/ # 管理后台
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ └── api/ # Node.js 后端
│ ├── src/
│ └── package.json
│
├── packages/ # 共享包层
│ ├── ui/ # 共享 UI 组件库
│ │ ├── src/
│ │ │ ├── Button/
│ │ │ ├── Modal/
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── utils/ # 工具函数库
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── types/ # 共享类型定义
│ │ ├── src/
│ │ └── package.json
│ └── config/ # 共享配置
│ ├── eslint/
│ ├── tsconfig/
│ └── package.json
│
├── tools/ # 工具脚本层
│ ├── scripts/
│ └── generators/
│
├── package.json # 根 package.json
├── pnpm-workspace.yaml # pnpm workspace 配置
├── turbo.json # Turborepo 配置
├── .changeset/ # Changesets 配置
│ └── config.json
├── .github/
│ └── workflows/
│ └── ci.yml
└── pnpm-lock.yaml二、pnpm workspace
2.1 为什么选择 pnpm
pnpm(performant npm)是目前 Monorepo 场景下最主流的包管理器。相比 npm 和 yarn,pnpm 有两个核心优势:
优势一:内容寻址存储(Content-Addressable Storage)
npm 和 yarn 会在每个项目的 node_modules 中保存依赖的完整副本。如果你有 10 个项目都依赖 lodash@4.17.21,磁盘上会存在 10 份完全相同的 lodash 代码。
pnpm 使用全局的内容寻址存储(默认在 ~/.pnpm-store/),所有版本的所有包只存储一份。项目中的 node_modules 通过硬链接指向全局存储:
pnpm 的存储机制:
~/.pnpm-store/v3/ # 全局存储(内容寻址)
├── files/
│ ├── 00/
│ │ ├── 5a8...hash # lodash 的某个文件
│ │ └── 3b2...hash # react 的某个文件
│ ├── 01/
│ └── ...
项目 node_modules(非扁平结构):
my-monorepo/
├── node_modules/
│ ├── .pnpm/ # 虚拟存储目录
│ │ ├── lodash@4.17.21/
│ │ │ └── node_modules/
│ │ │ └── lodash/ # 硬链接 → ~/.pnpm-store
│ │ ├── react@18.2.0/
│ │ │ └── node_modules/
│ │ │ ├── react/ # 硬链接 → ~/.pnpm-store
│ │ │ └── loose-envify/ # 符号链接 → .pnpm/loose-envify@1.4.0
│ │ └── ...
│ ├── lodash → .pnpm/lodash@4.17.21/node_modules/lodash # 符号链接
│ └── react → .pnpm/react@18.2.0/node_modules/react # 符号链接优势二:严格的依赖隔离
npm 和 yarn 采用扁平化(flat)的 node_modules 结构,这导致了**幽灵依赖(Phantom Dependencies)**问题——你可以引用没有在 package.json 中声明的包。
幽灵依赖问题:
package.json 只声明了 express
→ npm install 后 node_modules 被扁平化
→ body-parser(express 的依赖)也出现在 node_modules 顶层
→ 你的代码 import bodyParser from 'body-parser' 可以运行
→ 但某天 express 不再依赖 body-parser,你的代码突然就挂了
pnpm 的非扁平结构天然杜绝了这个问题:
→ node_modules 顶层只有你声明的直接依赖的符号链接
→ 未声明的包无法被访问2.2 pnpm-workspace.yaml 配置
在项目根目录创建 pnpm-workspace.yaml,声明哪些目录属于 workspace:
yaml
packages:
- 'apps/*'
- 'packages/*'
- 'tools/*'这告诉 pnpm:apps/、packages/、tools/ 下的每个子目录都是一个独立的 workspace 包。
更灵活的配置:
yaml
packages:
- 'apps/*'
- 'packages/*'
- 'packages/config/*'
- '!packages/deprecated-*'使用 ! 前缀可以排除特定的包。
2.3 workspace 协议
在 Monorepo 中,包之间的依赖引用使用 workspace: 协议:
json
{
"name": "@myorg/web",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0",
"@myorg/types": "workspace:~1.0.0"
}
}workspace: 协议的几种写法:
| 写法 | 含义 | 发布时转换为 |
|---|---|---|
workspace:* | 任意版本,始终引用本地 | 1.2.3(当前本地版本) |
workspace:^ | 兼容版本 | ^1.2.3 |
workspace:~ | 近似版本 | ~1.2.3 |
workspace:^1.0.0 | 指定兼容范围 | ^1.0.0 |
关键点:workspace: 协议在本地开发时始终链接到本地包,发布时 pnpm 会自动将其转换为实际版本号。
2.4 包之间的依赖引用
假设我们有以下包结构:
packages/
├── ui/
│ └── package.json # name: @myorg/ui
├── utils/
│ └── package.json # name: @myorg/utils
└── types/
└── package.json # name: @myorg/types在 @myorg/ui 中引用 @myorg/utils:
json
{
"name": "@myorg/ui",
"version": "1.0.0",
"dependencies": {
"@myorg/utils": "workspace:*",
"@myorg/types": "workspace:*"
}
}安装后,pnpm 会创建符号链接:
node_modules/
├── @myorg/
│ ├── ui → ../../packages/ui # 符号链接到本地包
│ ├── utils → ../../packages/utils # 符号链接到本地包
│ └── types → ../../packages/types # 符号链接到本地包在代码中就可以直接 import:
typescript
import { formatDate } from '@myorg/utils'
import type { User } from '@myorg/types'2.5 常用命令
根目录执行命令:
bash
pnpm install
pnpm run build
pnpm add typescript -D -w-w 表示在根 workspace 安装依赖。
过滤执行(--filter):
bash
pnpm --filter @myorg/web dev
pnpm --filter @myorg/ui build
pnpm --filter @myorg/web add axios
pnpm --filter "@myorg/*" build
pnpm --filter @myorg/web... build--filter 的高级用法:
| 命令 | 含义 |
|---|---|
--filter @myorg/web | 只在 @myorg/web 包中执行 |
--filter "@myorg/*" | 匹配所有 @myorg 作用域的包 |
--filter @myorg/web... | @myorg/web 及其所有依赖 |
--filter ...@myorg/ui | @myorg/ui 及所有依赖它的包 |
--filter @myorg/web...[origin/main] | @myorg/web 及其自 main 分支以来有变更的依赖 |
递归执行(-r):
bash
pnpm -r build
pnpm -r --parallel build
pnpm -r exec -- rm -rf dist
pnpm -r publish --access public-r 在所有包中递归执行,--parallel 表示并行执行。
三、Turborepo
Turborepo 是 Vercel 开源的高性能 Monorepo 构建系统。它的核心价值在于:智能任务编排 + 增量构建 + 远程缓存,让 Monorepo 的构建速度从线性增长变为近乎恒定。
3.1 核心架构
Turborepo 核心架构:
┌────────────────────────────────────────────────────┐
│ turbo run build │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 1. 构建任务依赖图 │ │
│ │ │ │
│ │ 读取所有 package.json + turbo.json │ │
│ │ 解析 dependsOn 关系 │ │
│ │ 生成有向无环图(DAG) │ │
│ └──────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 2. 计算内容哈希 │ │
│ │ │ │
│ │ 对每个任务计算输入文件的哈希值 │ │
│ │ 包含:源代码 + 依赖版本 + 环境变量 + 配置 │ │
│ └──────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 3. 查找缓存 │ │
│ │ │ │
│ │ 本地缓存 → 远程缓存 → 未命中则执行任务 │ │
│ │ 命中则直接复制缓存产物 │ │
│ └──────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 4. 并行执行 │ │
│ │ │ │
│ │ 基于 DAG 拓扑排序 │ │
│ │ 无依赖关系的任务自动并行 │ │
│ │ 最大化 CPU 利用率 │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘3.2 turbo.json 配置
json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local",
".env"
],
"globalEnv": ["NODE_ENV", "CI"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "vite.config.*"],
"outputs": ["dist/**", ".next/**"],
"env": ["VITE_API_URL"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "tests/**", "vitest.config.*"],
"outputs": ["coverage/**"]
},
"lint": {
"dependsOn": [],
"inputs": ["src/**", ".eslintrc.*", "eslint.config.*"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"],
"outputs": []
}
}
}配置详解:
dependsOn: ["^build"]:^前缀表示先执行所有上游依赖包的build任务。比如@myorg/web依赖@myorg/ui,那么@myorg/ui的build会先执行。dependsOn: ["build"]:没有^前缀表示先执行当前包自身的build任务。dependsOn: []:没有依赖,可以和其他任务并行执行。inputs:声明任务的输入文件,只有这些文件变化时才会重新执行。outputs:声明任务的输出目录,用于缓存。cache: false:禁用缓存,适用于dev这类长期运行的任务。persistent: true:标记为持久运行的任务(如 dev server),Turborepo 不会等待它结束。
3.3 dependsOn 与任务依赖图
dependsOn 是 Turborepo 最核心的概念。它定义了任务之间的依赖关系:
项目依赖关系:
@myorg/web → 依赖 → @myorg/ui → 依赖 → @myorg/utils
运行 turbo run build 时的任务依赖图(DAG):
┌─────────────────┐
│ @myorg/utils │
│ build │
└────────┬────────┘
│
▼
┌─────────────────┐
│ @myorg/ui │
│ build │
└────────┬────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ @myorg/web │ │ @myorg/admin │
│ build │ │ build │
└─────────────────┘ └─────────────────┘
执行顺序:
第 1 步:@myorg/utils build (无依赖,最先执行)
第 2 步:@myorg/ui build (依赖 utils,utils 完成后执行)
第 3 步:@myorg/web build ┐
@myorg/admin build┘ (依赖 ui,ui 完成后并行执行)更复杂的场景——同一个包的多个任务依赖:
turbo run test 的完整任务图:
@myorg/utils @myorg/ui @myorg/web
┌──────────┐ ┌──────────┐ ┌──────────┐
│ build │ │ build │ │ build │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ ╲ │ ╲ │
│ ╲ │ ╲ │
▼ ╲ ▼ ╲ ▼
┌──────────┐ ╲ ┌──────────┐ ╲ ┌──────────┐
│ test │ ╲→│ test │ ╲→│ test │
└──────────┘ └──────────┘ └──────────┘
test 依赖自身 build(dependsOn: ["build"])
build 依赖上游 build(dependsOn: ["^build"])3.4 增量构建:基于内容哈希的缓存
Turborepo 的缓存机制是其性能的关键。对于每个任务,Turborepo 会计算一个内容哈希:
哈希计算的输入:
┌─────────────────────────────────────────┐
│ inputs 声明的所有文件内容 │
│ + 所有依赖包的哈希值 │
│ + 锁文件(pnpm-lock.yaml)的相关部分 │
│ + turbo.json 中该任务的配置 │
│ + env 声明的环境变量值 │
│ + globalDependencies 文件内容 │
│ + globalEnv 环境变量值 │
│ │
│ → SHA-256 → 得到唯一哈希值 │
│ → 例如:3a1b9c2d... │
└─────────────────────────────────────────┘
缓存命中流程:
turbo run build
│
▼
计算 @myorg/ui#build 的哈希 → 3a1b9c2d
│
▼
查找缓存:node_modules/.cache/turbo/3a1b9c2d.tar.zst
│
├── 命中 → 解压缓存产物到 dist/ → 跳过实际构建
│ 终端显示 "cache hit, replaying output"
│ 耗时:< 100ms
│
└── 未命中 → 执行实际构建
构建完成后将 outputs 打包存入缓存
耗时:实际构建时间3.5 远程缓存
本地缓存只能一个开发者自己享用。Turborepo 支持远程缓存(Remote Caching),让团队中所有成员和 CI 共享构建缓存:
远程缓存协作流程:
开发者 A Remote Cache 开发者 B
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ 修改代码 │ │ │ │ │
│ 运行 build│───上传缓存────→│ Vercel / 自 │ │ │
│ 耗时 30s │ │ 部署 Server │ │ │
└──────────┘ │ │ │ │
│ 存储: │ │ git pull │
│ hash→产物 │───下载缓存────→│ 运行 build│
│ │ │ 耗时 <1s │
└──────────────┘ └──────────┘
CI Pipeline
┌──────────┐
│ 运行 build│───查询远程缓存───→ 命中!→ 跳过构建
│ 耗时 <1s │
└──────────┘启用远程缓存:
bash
npx turbo login
npx turbo link自托管远程缓存(使用开源实现):
json
{
"remoteCache": {
"enabled": true,
"signature": true
}
}bash
TURBO_API=https://cache.mycompany.com TURBO_TOKEN=xxx TURBO_TEAM=myteam turbo run build3.6 并行执行
Turborepo 基于任务依赖图(DAG)自动最大化并行度。通过 --concurrency 控制并行数:
bash
turbo run lint --concurrency=10
turbo run lint --concurrency=100%并行执行的可视化:
时间线 ──────────────────────────────────────────────→
┌───────────────────┐
CPU 核 1: │ @myorg/utils build│
└───────────────────┘
┌───────────────────┐
CPU 核 1: │ @myorg/ui build │
└───────────────────┘
┌──────────────────────────────┐
CPU 核 2: │ @myorg/utils lint │
└──────────────────────────────┘
┌──────────────────────────────┐
CPU 核 3: │ @myorg/ui lint │
└──────────────────────────────┘
┌──────────────────────────────┐
CPU 核 4: │ @myorg/web lint │
└──────────────────────────────┘
┌───────────────────┐
CPU 核 2: │ @myorg/web build │
└───────────────────┘
┌───────────────────┐
CPU 核 3: │@myorg/admin build │
└───────────────────┘
lint 任务无依赖(dependsOn: []),所以全部并行
build 任务有依赖关系,按拓扑顺序执行
两者之间也可以并行(不同任务类型之间无冲突)3.7 常用命令
bash
turbo run build
turbo run build --filter=@myorg/web...
turbo run build --dry
turbo run build --graph
turbo run build --summarize
turbo run build --force
turbo prune --scope=@myorg/web --docker| 命令 | 用途 |
|---|---|
--dry | 干运行,只展示将要执行的任务 |
--graph | 生成任务依赖图的 SVG/DOT |
--summarize | 输出构建摘要(命中率、耗时等) |
--force | 忽略缓存,强制执行所有任务 |
turbo prune | 提取指定包及其依赖,生成精简的子集仓库(适用于 Docker 构建) |
四、Nx
4.1 Nx 核心特性
Nx 是由 Nrwl 公司开发的 Monorepo 工具,功能比 Turborepo 更丰富,但学习曲线也更陡峭。
项目图(Project Graph)
Nx 在内存中维护一个完整的项目依赖图,通过分析源代码中的 import 语句自动推导项目之间的依赖关系:
Nx 项目图:
┌──────────────────────────────────────────────┐
│ Nx Project Graph │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ web │────────→│ ui │ │
│ └────┬───┘ └───┬────┘ │
│ │ │ │
│ │ ┌────────┐ │ │
│ └───→│ utils │←──┘ │
│ └────────┘ │
│ ↑ │
│ ┌────────┐ │
│ │ admin │ │
│ └────────┘ │
│ │
│ 自动从源代码 import 推导依赖关系 │
│ 无需手动维护配置 │
└──────────────────────────────────────────────┘受影响命令(Affected)
Nx 的 affected 命令只运行受变更影响的项目的任务:
bash
nx affected -t build
nx affected -t test --base=main --head=HEAD
nx affected:graphaffected 工作原理:
1. 比较 HEAD 与 base(默认 main)的文件差异
2. 在项目图中标记变更的项目
3. 递归标记所有下游依赖项目
4. 只对标记的项目执行任务
示例:只修改了 @myorg/utils
@myorg/utils ← 直接修改 ✅ 受影响
↑
@myorg/ui ← 依赖 utils ✅ 受影响
↑
@myorg/web ← 依赖 ui ✅ 受影响
@myorg/docs ← 无关 ❌ 不受影响(跳过)计算缓存
与 Turborepo 类似,Nx 也基于输入哈希进行任务缓存,避免重复计算。
4.2 nx.json 配置
json
{
"$schema": "https://nx.dev/reference/nx-json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/tsconfig.spec.json"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"inputs": ["default", "{workspaceRoot}/.eslintrc.json"],
"cache": true
}
},
"defaultBase": "main"
}配置详解:
namedInputs:定义可复用的输入集合。production排除了测试文件,用于 build 任务。targetDefaults:为所有项目的目标(target)设置默认配置。{projectRoot}:当前项目的根目录。{workspaceRoot}:整个 workspace 的根目录。^production:表示所有上游依赖的production输入。
4.3 Nx 特有功能
代码生成器(Generators):
bash
nx generate @nx/react:component Button --project=ui
nx generate @nx/js:library shared-types模块边界规则(Module Boundary Rules):
通过标签和 ESLint 规则强制模块间的依赖约束:
json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:app",
"onlyDependOnLibsWithTags": ["scope:lib", "scope:shared"]
},
{
"sourceTag": "scope:lib",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
}
}这保证了架构分层不被破坏:app 层可以依赖 lib 层和 shared 层,但 lib 层不能反向依赖 app 层。
4.4 Turborepo vs Nx 对比
| 维度 | Turborepo | Nx |
|---|---|---|
| 定位 | 轻量级构建编排工具 | 全功能 Monorepo 框架 |
| 学习曲线 | 低,配置简单 | 较高,概念多 |
| 配置文件 | turbo.json(简洁) | nx.json + project.json(丰富) |
| 任务编排 | ✅ 基于 turbo.json | ✅ 基于 nx.json |
| 本地缓存 | ✅ | ✅ |
| 远程缓存 | ✅ Vercel 原生支持 | ✅ Nx Cloud |
| 项目图 | 基于 package.json | 基于源代码分析(更精确) |
| 受影响分析 | --filter 手动过滤 | nx affected 自动推导 |
| 代码生成器 | ❌ 不内置 | ✅ 丰富的生成器 |
| 模块边界 | ❌ 不内置 | ✅ 强制架构约束 |
| 插件生态 | 少(专注构建) | 丰富(React、Angular、Node 等) |
| 增量采用 | ✅ 非常容易 | ✅ 支持(但完整功能需更多配置) |
| 适用场景 | 中小型 Monorepo | 大型企业级 Monorepo |
选型建议:
- 如果你追求简单易用、快速上手,选 Turborepo
- 如果你需要强大的架构约束、代码生成、精确的 affected 分析,选 Nx
- 两者都可以和 pnpm workspace 配合使用
五、Changesets
Changesets 是专为 Monorepo 设计的版本管理与发布工具。它解决了 Monorepo 中最头疼的问题:多个包如何独立版本管理、如何自动生成 CHANGELOG、如何协调关联包的版本更新。
5.1 核心概念
一个 changeset 是一个描述变更的 Markdown 文件,记录了哪些包被修改以及变更的类型(major/minor/patch):
.changeset/
├── config.json
├── brave-dogs-dance.md # 自动生成的随机文件名
└── happy-cats-fly.md一个 changeset 文件的内容:
markdown
---
"@myorg/ui": minor
"@myorg/utils": patch
---
新增 DatePicker 组件,修复 formatDate 函数在 Safari 下的兼容问题。5.2 工作流:add → version → publish
Changesets 完整工作流:
开发阶段:
┌──────────────────────────────────────────────┐
│ 1. 开发者修改代码 │
│ 2. 运行 changeset add │
│ → 交互式选择受影响的包 │
│ → 选择版本类型(major/minor/patch) │
│ → 填写变更描述 │
│ → 生成 .changeset/xxx.md 文件 │
│ 3. 将 changeset 文件随代码一起提交 │
└──────────────────────────────────────────────┘
│
▼
发布阶段:
┌──────────────────────────────────────────────┐
│ 4. 运行 changeset version │
│ → 消费所有 .changeset/*.md 文件 │
│ → 计算每个包的新版本号 │
│ → 更新 package.json 中的 version 字段 │
│ → 更新关联包的依赖版本 │
│ → 生成/更新 CHANGELOG.md │
│ → 删除已消费的 changeset 文件 │
│ │
│ 5. 审查并提交版本变更 │
│ │
│ 6. 运行 changeset publish │
│ → 检测哪些包版本号有变化 │
│ → 执行 npm publish │
│ → 创建 git tag │
└──────────────────────────────────────────────┘具体命令:
bash
npx changeset add交互式流程:
🦋 Which packages would you like to include?
◯ @myorg/web
◉ @myorg/ui
◉ @myorg/utils
◯ @myorg/admin
🦋 Which packages should have a major bump?
◯ @myorg/ui
◯ @myorg/utils
🦋 Which packages should have a minor bump?
◉ @myorg/ui
◯ @myorg/utils
🦋 The following packages will be patch bumped:
@myorg/utils
🦋 Please enter a summary for this change:
新增 DatePicker 组件,修复 formatDate 时区问题
🦋 Summary: 新增 DatePicker 组件,修复 formatDate 时区问题
🦋 === Changeset added! ===bash
npx changeset version执行后的变化:
Before:
@myorg/ui: 1.2.0
@myorg/utils: 2.0.5
@myorg/web: 依赖 @myorg/ui: "workspace:^1.2.0"
After:
@myorg/ui: 1.3.0 (minor bump)
@myorg/utils: 2.0.6 (patch bump)
@myorg/web: 依赖 @myorg/ui: "workspace:^1.3.0" (自动更新)
同时生成:
packages/ui/CHANGELOG.md ← 追加变更记录
packages/utils/CHANGELOG.md ← 追加变更记录bash
npx changeset publish5.3 语义化版本(SemVer)
Changesets 严格遵循语义化版本规范:
版本号格式:MAJOR.MINOR.PATCH
MAJOR(主版本号):不兼容的 API 变更
例:删除一个公开函数、修改函数签名
1.2.3 → 2.0.0
MINOR(次版本号):向后兼容的功能新增
例:新增一个组件、新增一个工具函数
1.2.3 → 1.3.0
PATCH(补丁版本号):向后兼容的问题修复
例:修复一个 bug、优化性能
1.2.3 → 1.2.4
预发布版本:
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.15.4 自动生成 CHANGELOG
changeset version 执行后,会自动在每个受影响的包目录下生成或更新 CHANGELOG.md:
markdown
# @myorg/ui
## 1.3.0
### Minor Changes
- 新增 DatePicker 组件
### Patch Changes
- Updated dependencies
- @myorg/utils@2.0.6
## 1.2.0
### Minor Changes
- 新增 Select 组件5.5 Changesets 配置
.changeset/config.json:
json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [["@myorg/ui", "@myorg/utils"]],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@myorg/web", "@myorg/admin"]
}| 字段 | 含义 |
|---|---|
fixed | 绑定版本的包组,组内所有包始终保持同一版本号 |
linked | 关联版本的包组,组内包的版本号会协同升级 |
access | npm 发布的访问级别(public/restricted) |
updateInternalDependencies | 当依赖的内部包有版本变化时,如何更新本包版本 |
ignore | 忽略不需要发布的包(如应用层的包) |
5.6 CI 中的自动化发布
结合 GitHub Actions 实现自动化版本发布:
yaml
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
commit: 'chore: release packages'
title: 'chore: release packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}这个 Action 会自动创建一个 "Version Packages" PR,合并后自动发布到 npm。
六、Monorepo 工具对比
6.1 包管理器对比:pnpm vs Yarn vs npm
| 维度 | pnpm workspace | Yarn workspace | npm workspace |
|---|---|---|---|
| 出现时间 | 2017 | 2017(v1) | 2020(v7) |
| node_modules 结构 | 非扁平(符号链接) | 扁平 | 扁平 |
| 幽灵依赖 | ✅ 杜绝 | ❌ 存在 | ❌ 存在 |
| 磁盘占用 | 极低(硬链接 + CAS) | 高 | 高 |
| 安装速度 | 最快 | 中等 | 较慢 |
| workspace 协议 | workspace:* | workspace:* | *(隐式) |
| --filter 过滤 | ✅ 功能丰富 | yarn workspace <name> | --workspace |
| 严格模式 | 默认严格 | 需手动配置 | 无 |
| 锁文件格式 | pnpm-lock.yaml | yarn.lock | package-lock.json |
| 社区活跃度 | 🔥 高速增长 | 稳定 | 稳定 |
6.2 构建工具对比:Turborepo vs Nx vs Lerna
| 维度 | Turborepo | Nx | Lerna(v6+) |
|---|---|---|---|
| 维护方 | Vercel | Nrwl | Nx(收购后) |
| 定位 | 构建编排工具 | 全功能 Monorepo 框架 | 版本管理 + 构建 |
| 配置复杂度 | 低 | 高 | 中 |
| 任务编排 | ✅ | ✅ | ✅(使用 Nx 内核) |
| 本地缓存 | ✅ | ✅ | ✅(通过 Nx) |
| 远程缓存 | ✅ Vercel | ✅ Nx Cloud | ✅(通过 Nx) |
| 增量构建 | ✅ 内容哈希 | ✅ 内容哈希 | ✅(通过 Nx) |
| 受影响分析 | 手动 filter | ✅ 自动 affected | ✅(通过 Nx) |
| 项目图可视化 | --graph 导出 | ✅ 交互式 UI | ❌ |
| 代码生成 | ❌ | ✅ 丰富 | ❌ |
| 模块边界 | ❌ | ✅ | ❌ |
| 版本管理 | ❌(配合 Changesets) | ✅ 内置 | ✅ 内置 |
| 安装体积 | ~10MB | ~40MB+ | ~10MB |
| 上手难度 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 社区生态 | 快速增长 | 成熟丰富 | 经典但逐渐被替代 |
| 适用项目 | 中小型 Monorepo | 大型企业级项目 | 已有 Lerna 项目迁移 |
6.3 选型决策树
开始选型
│
├── Q: 你的项目有多少个包?
│ ├── < 10 个包
│ │ └── pnpm workspace + Turborepo + Changesets
│ │ 简单高效,5 分钟上手
│ │
│ ├── 10-50 个包
│ │ ├── 需要架构约束?
│ │ │ ├── 是 → Nx
│ │ │ └── 否 → Turborepo
│ │ │
│ │ └── 都搭配 pnpm workspace + Changesets
│ │
│ └── > 50 个包
│ └── Nx(项目图 + affected 分析 + 模块边界)
│ + pnpm workspace + Changesets
│
└── Q: 你的团队对工具链的接受度?
├── 希望尽量简单 → Turborepo
└── 愿意投入学习 → Nx七、最佳实践
7.1 项目结构组织
推荐的三层目录结构:
monorepo/
├── apps/ # 应用层:可独立部署的应用
│ ├── web/ # 面向用户的 Web 应用
│ ├── admin/ # 管理后台
│ ├── api/ # 后端服务
│ └── docs/ # 文档站点
│
├── packages/ # 共享包层:被应用层引用的共享库
│ ├── ui/ # 共享 UI 组件
│ ├── utils/ # 工具函数
│ ├── types/ # 共享 TypeScript 类型
│ ├── hooks/ # 共享 React Hooks
│ └── config/ # 共享配置(见 7.2)
│
└── tools/ # 工具层:内部开发工具
├── scripts/ # 自动化脚本
├── generators/ # 代码生成器
└── cli/ # 内部 CLI 工具命名规范:
统一使用 npm scope(如 @myorg/),所有包以 @myorg/ 开头:
json
{
"name": "@myorg/ui",
"name": "@myorg/utils",
"name": "@myorg/web"
}7.2 共享配置
共享 TypeScript 配置:
packages/config/
└── tsconfig/
├── package.json
├── base.json
├── react-library.json
└── node.jsonbase.json:
json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}react-library.json:
json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"target": "ES2022",
"outDir": "./dist"
}
}node.json:
json
{
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2022"],
"module": "CommonJS",
"target": "ES2022",
"outDir": "./dist"
}
}在子包中引用:
json
{
"extends": "@myorg/tsconfig/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}共享 ESLint 配置:
packages/config/
└── eslint/
├── package.json
├── base.js
├── react.js
└── node.jsbase.js:
js
module.exports = {
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
'no-console': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
}react.js:
js
module.exports = {
extends: [
'./base.js',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
settings: {
react: { version: 'detect' },
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
},
}在子包中引用:
js
module.exports = {
extends: [require.resolve('@myorg/eslint-config/react')],
}共享 Prettier 配置:
根目录 prettier.config.js:
js
module.exports = {
semi: false,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
}7.3 内部包开发策略
内部包有两种主要开发模式:
模式一:仅源码引用(推荐用于内部不发布的包)
不构建内部包,直接通过 TypeScript 引用源码:
json
{
"name": "@myorg/utils",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}优势:修改后即时生效,无需构建步骤。应用层的构建工具(Vite、Next.js)会直接编译这些源码。
模式二:预构建(推荐用于需要发布到 npm 的包)
使用 tsup、unbuild 等工具将包预构建为 ESM/CJS:
json
{
"name": "@myorg/ui",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts"
}
}7.4 Docker 构建优化
Monorepo 中的 Docker 构建需要特殊处理。Turborepo 提供了 turbo prune 命令来提取精简子集:
dockerfile
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY . .
RUN pnpm turbo prune --scope=@myorg/api --docker
FROM node:20-alpine AS installer
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN pnpm turbo run build --filter=@myorg/api
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=installer /app/apps/api/dist ./dist
COPY --from=installer /app/apps/api/package.json .
CMD ["node", "dist/index.js"]turbo prune 生成的 out/ 目录结构:
out/
├── json/ # 只包含 package.json 文件(用于 pnpm install 缓存层)
│ ├── package.json
│ ├── apps/api/package.json
│ └── packages/utils/package.json
├── full/ # 完整的源代码(只包含相关的包)
│ ├── apps/api/
│ └── packages/utils/
└── pnpm-lock.yaml # 精简后的锁文件7.5 CI/CD 优化
yaml
name: CI
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo run build lint test typecheck
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}关键优化点:
fetch-depth: 2:只拉取最近 2 次提交,减少 clone 时间cache: 'pnpm':缓存 pnpm storeTURBO_TOKEN+TURBO_TEAM:启用远程缓存- 一条
turbo run命令执行多个任务,Turborepo 自动处理依赖和并行
八、面试高频问题
Q1:Monorepo 和 Multirepo 各适用于什么场景?如何选型?
回答思路:
Multirepo 适合独立性强、团队分散、技术栈差异大的项目。每个仓库独立开发、独立发布、独立部署。典型场景:开源社区中的独立项目。
Monorepo 适合关联性强的多个项目:共享大量代码(组件库、工具函数、类型定义)、需要频繁跨包变更、需要统一代码规范和 CI/CD。典型场景:一个产品的前端应用 + 管理后台 + 组件库 + 工具包。
关键决策因素:代码复用程度、跨包变更频率、团队组织结构。如果每周都要跨多个包做变更,Monorepo 的原子提交和即时引用就是巨大优势。
Q2:pnpm 的 node_modules 结构和 npm/yarn 有什么区别?为什么能解决幽灵依赖?
回答思路:
npm/yarn 使用扁平化(hoisting)的 node_modules,把所有依赖都提升到顶层。这导致你可以 import 没有在 package.json 中声明的包(幽灵依赖),一旦这个包不再是某个直接依赖的子依赖,代码就会突然报错。
pnpm 使用嵌套 + 符号链接的结构:node_modules 顶层只有直接依赖的符号链接,指向 .pnpm/ 目录下的实际位置。.pnpm/ 中每个包通过硬链接指向全局 content-addressable store,实现去重。
核心是三层结构:node_modules/<pkg> → 符号链接 → .pnpm/<pkg>@version/node_modules/<pkg> → 硬链接 → ~/.pnpm-store。
Q3:Turborepo 的缓存机制是怎么工作的?
回答思路:
Turborepo 对每个任务计算一个基于内容的哈希值(content hash),输入包括:声明的 inputs 文件内容、依赖包的哈希值、环境变量、turbo.json 配置、lockfile 相关部分。
如果哈希值与上次执行时相同,说明输入没变,直接从缓存(node_modules/.cache/turbo/)中恢复之前的 outputs 目录和终端输出,耗时从几十秒降到毫秒级。
支持远程缓存:将缓存推送到远端(Vercel 或自部署),团队成员和 CI 可以共享。开发者 A 构建过的产物,开发者 B 直接复用,无需重新构建。
Q4:什么是 dependsOn 中的 ^ 前缀?
回答思路:
^ 表示拓扑依赖——先执行上游依赖包的同名任务。
dependsOn: ["^build"] 意味着:在执行当前包的 build 之前,先执行它在 package.json 中声明的所有 workspace 依赖包的 build 任务。
没有 ^ 的 dependsOn: ["build"] 意味着:先执行当前包自身的 build 任务(同包内的任务间依赖)。
dependsOn: [] 意味着无依赖,可以立即执行,与其他任务并行。
这套机制让 Turborepo 构建出一个有向无环图(DAG),据此进行拓扑排序和最大化并行执行。
Q5:Changesets 的 fixed 和 linked 有什么区别?
回答思路:
fixed:绑定版本。组内所有包始终保持完全相同的版本号。只要组内任一包有变更,所有包都一起升版到同一版本。适用于紧密耦合的包组。
linked:关联版本。组内包可以有不同的版本号,但当组内某个包进行了 major/minor 升版时,组内其他有变更的包也会进行至少同级别的升版。适用于希望版本号有关联但不完全一致的包组。
举例:@myorg/react-core 和 @myorg/react-dom 使用 fixed,保证版本号一致(类似 React 自身)。@myorg/plugin-a 和 @myorg/plugin-b 使用 linked,版本可以不同但升版协调。
Q6:Monorepo 中如何处理不同包需要不同版本的同一个依赖?
回答思路:
pnpm 天然支持同一个依赖的多个版本共存。不同于 npm 的扁平化会把某一版本提升到顶层,pnpm 的 .pnpm/ 目录下可以同时存在 react@17.0.2 和 react@18.2.0,各个包的符号链接指向各自需要的版本。
但在实践中,应尽量统一版本。可以利用 pnpm 的 pnpm.overrides 强制统一:
json
{
"pnpm": {
"overrides": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
}或者使用 syncpack 等工具检查跨包的版本一致性。
Q7:大型 Monorepo 中 Git 操作变慢怎么办?
回答思路:
几个常用的 Git 优化策略:
- Shallow Clone:
git clone --depth=1,只拉取最近一次提交 - Sparse Checkout:只检出需要的目录,而非整个仓库
- Git LFS:大型二进制文件使用 Git LFS 存储
git maintenance:定期运行 Git 维护命令优化仓库性能- Partial Clone:
git clone --filter=blob:none,按需下载文件内容
在 CI 中尤其重要——结合 fetch-depth: 2 和远程缓存,可以显著减少 CI 时间。
Q8:如何从 Multirepo 迁移到 Monorepo?
回答思路:
渐进式迁移策略:
- 先搭建 Monorepo 骨架:pnpm workspace + Turborepo + 共享配置
- 从最核心的共享包开始迁入(如组件库、工具函数)
- 使用
git subtree或工具保留 git 历史 - 逐步迁入应用层项目
- 统一 CI/CD 配置
- 建立 Changesets 发布流程
关键原则:每一步都保证可回滚,不要一次性迁移所有项目。优先迁移依赖关系紧密、跨仓库变更频繁的项目。
九、延伸阅读
- pnpm 官方文档
- Turborepo 官方文档
- Nx 官方文档
- Changesets 官方文档
- Monorepo Handbook - Turborepo
- Why TurboPack? — Vercel 对 Monorepo 构建的思考
- Google 的 Monorepo 论文 — Why Google Stores Billions of Lines of Code in a Single Repository
- Nx 与 Turborepo 的官方对比
- Changesets 最佳实践