Monorepo 架构
全链路深度解析
想象一个大型购物中心 —— 每家店铺(包)独立经营,但共享同一栋建筑(仓库)、同一套消防系统(CI/CD) 和同一个导航地图(依赖图)。这,就是 Monorepo。
三大支柱:隔离、编排、缓存
1依赖隔离 (Dependency Isolation)
npm/yarn classic 采用扁平化提升 (hoisting)策略 —— 将所有依赖提升到根 node_modules。 这导致了两个严重问题:幽灵依赖 (Phantom Dependencies) —— 你可以 import 未声明的包;依赖地狱 (Dependency Hell) —— 同一个包只能存在一个版本。
// ❌ npm / yarn classic 的扁平化 node_modules
//
// node_modules/
// ├── react@17.0.2 ← 顶层只有 1 个版本
// ├── lodash@4.17.21
// └── some-lib/
// └── node_modules/
// └── lodash@3.10.1 ← 不同版本可能被提升到顶层
//
// 问题:幽灵依赖 (Phantom Dependencies)
// 你的代码可以直接 import "lodash",即使你没有声明依赖它!// ✅ pnpm 的严格 node_modules 结构
//
// node_modules/
// ├── .pnpm/ ← 全局内容寻址存储
// │ ├── react@18.2.0/
// │ │ └── node_modules/
// │ │ └── react/ ← 硬链接到 store
// │ └── lodash@4.17.21/
// ├── .modules.yaml
// └── react → .pnpm/react@18.2.0/node_modules/react ← 符号链接
//
// 你的代码只能 import 你在 package.json 中声明的依赖
// 未声明的 import 会直接报错 —— 彻底消灭幽灵依赖workspace: 协议 —— 本地与发布的无缝切换
pnpm 提供 workspace:* 协议, 开发时符号链接到本地包,发布时自动替换为实际版本号。这意味着你永远不需要手动管理内部依赖版本。
{
"name": "@myorg/web",
"dependencies": {
"@myorg/shared-ui": "workspace:*", // ← 始终链接本地最新
"@myorg/utils": "workspace:^1.0.0", // ← 发布时转为 ^1.0.0
"react": "^18.2.0"
}
}
// pnpm 自动处理:
// 开发时 → 符号链接到 packages/shared-ui
// 发布时 → "workspace:*" 被替换为实际版本号2任务编排 (Task Orchestration)
依赖图 (DAG) —— 任务调度的基石
Turborepo 和 Nx 都基于有向无环图 (DAG) 进行任务调度。 当你写 "dependsOn": ["^build"] 时,^ 前缀表示 "先完成所有上游依赖包的 build,再执行当前包的 build"。
┌─────────┐ ┌─────────┐
│ utils │ │ config │ ← 第一层:基础包(并行构建)
└────┬─────┘ └────┬─────┘
│ │
▼ ▼
┌────────────────────────┐
│ shared-ui │ ← 第二层:依赖 utils + config
└───────────┬────────────┘
│
┌───────┴───────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ web │ │ admin │ ← 第三层:应用层(并行构建)
└─────────┘ └──────────┘
无缓存串行: ~180s 有缓存并行: ~45s 全命中缓存: ~2s
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // ← 先构建所有依赖包
"outputs": ["dist/**"] // ← 缓存这些产物
},
"test": {
"dependsOn": ["build"], // ← 本包构建完才能测
"outputs": []
},
"lint": {
"outputs": [] // ← lint 无产物,纯副作用
},
"dev": {
"cache": false, // ← 开发服务不缓存
"persistent": true
},
"deploy": {
"dependsOn": ["build", "test"],
"outputs": [],
"cache": false // ← 部署不缓存!
}
}
}// apps/web/project.json
{
"name": "web",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"outputPath": "dist/apps/web",
"main": "apps/web/src/main.ts",
"tsConfig": "apps/web/tsconfig.app.json"
},
"dependsOn": ["^build"] // ← 依赖图拓扑排序
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/web/jest.config.ts",
"passWithNoTests": true
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["apps/web/**/*.{ts,tsx}"]
}
}
}
}任务编排对比
| 特性 | pnpm (纯脚本) | Turborepo | Nx |
|---|---|---|---|
| 拓扑排序 | ❌ 需手动 --sort | ✅ 自动 (DAG) | ✅ 自动 (DAG) |
| 并行执行 | ⚠️ --parallel (无依赖感知) | ✅ 自动按依赖图并行 | ✅ 自动 + 可配 concurrency |
| 本地缓存 | ❌ 无 | ✅ 文件指纹 → 本地缓存 | ✅ 文件指纹 → 本地缓存 |
| 远程缓存 | ❌ 无 | ✅ Vercel / 自建 | ✅ Nx Cloud (免费 300 CI 小时/月) |
| 任务依赖 | ❌ 手动排序 | ✅ dependsOn 语法 | ✅ dependsOn + inputs 配置 |
| 增量执行 | ❌ 全量 | ✅ 缓存命中跳过 | ✅ affected 命令 (Git diff) |
| 配置复杂度 | 低 (package.json) | 低 (turbo.json) | 中-高 (project.json + nx.json) |
| 学习成本 | ~30 分钟 | ~2 小时 | ~1-2 天 |
3构建缓存 (Build Cache) —— 从 120 秒到 2 秒
Turborepo 的缓存机制基于内容哈希 (Content Hash)。 它对以下内容计算 SHA-256 哈希:包内源码文件 + 依赖锁定文件 + 任务配置 + 环境变量。 只要哈希不变,直接恢复上次构建的产物 —— 连代码都不用执行。
# Turborepo 缓存实战
# 第一次构建:全部执行,~120 秒
$ pnpm turbo run build
┌──── web#build → dist/**
│ cache miss, executing... 48.2s
├──── admin#build → dist/**
│ cache miss, executing... 35.1s
├──── shared-ui#build → dist/**
│ cache miss, executing... 12.4s
├──── utils#build → dist/**
│ cache miss, executing... 3.8s
│
│ Tasks: 4 successful, 4 total
│ Cached: 0/4
│ Duration: 99.5s (↑ 并行执行,串行需 ~120s)
# 修改 utils 后第二次构建:只重建受影响的包!
$ pnpm turbo run build
┌──── shared-ui#build → dist/**
│ cache miss (dependency changed), 12.4s ← 依赖了 utils
├──── web#build → dist/**
│ cache miss (dependency changed), 48.2s ← 间接依赖
├──── admin#build → dist/**
│ cache miss (dependency changed), 35.1s
├──── utils#build → dist/**
│ cache miss (source changed), 3.8s ← 源码变了
│
│ Tasks: 4 successful, 4 total
│ Cached: 0/4 ← 全部缓存失效
│ Duration: 42.3s ← 并行后显著缩短
# 未修改任何文件的第三次构建:
$ pnpm turbo run build
│ Tasks: 4 successful, 4 total
│ Cached: 4/4 ← 全部命中缓存!
│ Duration: 1.2s ← 🚀 从 120s 到 1.2s远程缓存 —— 团队级别的构建加速
本地缓存只能加速你自己的机器。远程缓存 让整个团队共享构建产物 —— 当同事 A 在 CI 上构建了 web 包,同事 B 本地构建时直接下载缓存结果,耗时从 48 秒降至 0.3 秒。 对于 20 人团队,远程缓存平均可以节省 60-80% 的 CI 计算时间。
// turbo.json 远程缓存配置
{
"$schema": "https://turbo.build/schema.json",
"remoteCache": {
"signature": true // ← 开启签名验证,防止缓存投毒
}
}
// Vercel 零配置远程缓存
$ npx turbo login
$ npx turbo link
# 现在所有 CI 机器共享同一个缓存 —— 团队 20 人不再重复构建三工具选型对比实验室
pnpm workspace
底层基础设施,零开销依赖管理
✅ 优势
- 原生 workspace 支持
- 严格依赖隔离
- 磁盘占用降低 50-70%
- 零学习曲线
⚠️ 局限
- 无任务编排
- 无缓存机制
- 需要自建脚本
# 1. 安装 pnpm
$ npm install -g pnpm@9
# 2. 根目录创建 workspace 配置
$ cat > pnpm-workspace.yaml << EOF
packages:
- "packages/*"
- "apps/*"
EOF
# 3. 安装依赖
$ pnpm install # 自动链接所有 workspace 包
# 4. 跨包命令
$ pnpm --filter @myorg/web add @myorg/utils # 安装本地依赖
$ pnpm --filter @myorg/shared-ui run build # 只在指定包执行
$ pnpm -r run build # 递归执行所有包从零搭建 Monorepo
1pnpm workspace 初始化
# pnpm-workspace.yaml —— Monorepo 的地基文件
packages:
- "packages/*" # 共享库:ui / utils / config
- "apps/*" # 应用:web / admin / api
- "plugins/*" # 插件(可选){
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel run dev", // ← -r 递归所有包
"build": "pnpm -r run build",
"lint": "pnpm -r run lint",
"test": "pnpm -r run test",
"clean": "pnpm -r run clean"
}
}2Turborepo 任务配置详解
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // ← 先构建所有依赖包
"outputs": ["dist/**"] // ← 缓存这些产物
},
"test": {
"dependsOn": ["build"], // ← 本包构建完才能测
"outputs": []
},
"lint": {
"outputs": [] // ← lint 无产物,纯副作用
},
"dev": {
"cache": false, // ← 开发服务不缓存
"persistent": true
},
"deploy": {
"dependsOn": ["build", "test"],
"outputs": [],
"cache": false // ← 部署不缓存!
}
}
}🔑 关键概念
^build— 先构建所有上游依赖outputs— 声明哪些产物需要缓存cache: false— 禁用缓存(dev / deploy)persistent: true— 长驻进程标记
⚠️ 常见陷阱
- ❌ 忘记声明 outputs → 缓存永远 miss
- ❌ 在 dev 任务上开缓存 → 内存泄漏
- ❌ deploy 不设 cache: false → 跳过部署
- ❌ 环境变量未在 turbo.json 声明 → 缓存投毒
3Nx 的杀手锏:affected 命令
Turborepo 的缓存跳过的是"没变的包",但仍然会检查所有包。 Nx 的 affected 命令更进一步 —— 它通过 Git diff 分析哪些包被影响了,然后只执行这些包及其下游依赖。 在 50+ 包的仓库中,这可以将 CI 测试范围从 "全部" 缩小到 "3 个"。
# Nx 的杀手锏:affected 命令
# 只测试受当前分支改动影响的包
$ nx affected --target=test --base=main --head=HEAD
# Nx 自动分析 git diff,构建依赖图:
# packages/utils/src/math.ts 改了
# → packages/utils (direct)
# → packages/shared-ui (depends on utils)
# → apps/web (depends on shared-ui)
# → apps/admin (depends on shared-ui)
#
# 未受影响的包完全跳过 —— 在 50+ 包的仓库中节省 70%+ CI 时间
# 分析依赖图(生成可视化 HTML)
$ nx graph
# 浏览器打开交互式依赖关系图,支持按路径过滤4版本管理与发布 (Changesets)
Monorepo 中最大的挑战之一是版本管理:当 shared-ui 发布 minor 版本时, 依赖它的 web 包是否也需要 bump?Changesets 是目前最成熟的解决方案 —— 它让每个 PR 附带一个人类可读的变更记录,release 时自动计算版本、生成 changelog、发布 npm。
# 使用 changesets 管理发布流程
# 1. 添加变更记录
$ pnpm changeset
? Which packages would you like to include?
◉ @myorg/shared-ui (minor)
◯ @myorg/utils (patch)
◉ @myorg/web (major)
? Provide a summary: 添加 Button 组件的 size 属性
# 生成 .changeset/silly-cats-dance.md
# ---
# '@myorg/shared-ui': minor
# '@myorg/web': major
# ---
# 添加 Button 组件的 size 属性
# 2. 版本升级(自动更新依赖关系)
$ pnpm changeset version
# shared-ui: 1.2.0 → 1.3.0
# web: 2.0.0 → 3.0.0 ← 因为 major
# utils: 不变 ← 未选中
# 3. 发布
$ pnpm changeset publish
# npm notice: @myorg/shared-ui@1.3.0
# npm notice: @myorg/web@3.0.0完整发布流程图
┌──────────┐ ┌───────────────┐ ┌──────────────┐ ┌────────────────┐
│ PR #42 │──▶│ changeset add │──▶│ review + CI │──▶│ merge to main │
│ 修改代码 │ │ 记录变更意图 │ │ turbo build │ │ │
└──────────┘ └───────────────┘ │ turbo test │ └───────┬────────┘
└──────────────┘ │
▼
┌────────────────────────────────────────────────┐
│ GitHub Action: "Release" Workflow │
│ │
│ 1. pnpm changeset version ← 自动 bump 版本 │
│ 2. git commit -am "Version Packages" │
│ 3. pnpm turbo run build ← 用缓存极速构建 │
│ 4. pnpm changeset publish ← 发布到 npm │
│ 5. 创建 GitHub Release + Tag │
└────────────────────────────────────────────────┘
选型决策、陷阱与最佳实践
选型决策矩阵
📦 pnpm workspace 单独使用
适用场景:2-5 个包的小型 monorepo
团队规模:1-3 人
包数量:≤ 10
✅ 零额外依赖
⚠️ 需要自己写构建脚本
⚡ pnpm + Turborepo
适用场景:中小型全栈 monorepo
团队规模:3-30 人
包数量:5-100
✅ 社区最主流
✅ 学习成本极低
🔷 Nx (全套)
适用场景:企业级大型平台
团队规模:20-500+ 人
包数量:50-1000+
✅ affected 精准 CI
✅ 插件生态丰富
# 🏗️ 推荐组合:pnpm + Turborepo(覆盖率 ~85% 场景)
# 这是目前社区最主流的搭配:
# • pnpm 负责依赖管理 + workspace 协议
# • Turborepo 负责任务编排 + 远程缓存
# • changesets 负责版本管理 + 发布
#
# 学习成本:~2 小时 | 维护成本:极低
# 适用团队:3-50 人 | 包数量:5-100 个
$ pnpm create turbo@latest # 一行命令搞定脚手架常见陷阱与反模式
🚫 陷阱 #1:随意 hoisting 依赖
// 根目录 package.json
{
"dependencies": {
"react": "^18.2.0" // ← 把所有子包的依赖提到根目录
}
}// 每个包声明自己的依赖
// apps/web/package.json
{
"dependencies": {
"react": "^18.2.0" // ← 显式声明在使用它的包中
}
}
// pnpm 自动去重,不需要手动 hoisting把依赖提升到根目录会导致幽灵依赖,且破坏 pnpm 的严格隔离机制。让每个包声明自己需要的依赖,pnpm 会通过内容寻址自动去重存储。
🚫 陷阱 #2:忘记配置 envVar
// turbo.json — 环境变量未声明
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
// ← 缺少 env 声明!
}
}
}// turbo.json — 正确声明环境变量
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
// ← 这些变量变化也会使缓存失效
}
}
}Turborepo 默认只监控文件内容。如果 NODE_ENV 从 'development' 变为 'production',但源码没变,你会得到一个错误的缓存结果 —— 这叫缓存投毒 (Cache Poisoning)。
🚫 陷阱 #3:循环依赖
// ❌ packages/shared-ui/package.json
{ "dependencies": { "@myorg/utils": "workspace:*" } }
// ❌ packages/utils/package.json
{ "dependencies": { "@myorg/shared-ui": "workspace:*" } }
// 💥 DAG 不允许环!构建会失败或死循环// ✅ 提取公共模块打破循环
// packages/core/types.ts ← 共享类型
// packages/utils ← 依赖 core/types
// packages/shared-ui ← 依赖 utils + core/types
// 层级: core → utils → shared-ui → apps循环依赖会导致 DAG 算法失败。解决方法:提取共享代码到更底层的包中,保持单向依赖链。使用 nx graph 或 turbo run build --graph 可视化检查。
🚫 陷阱 #4:忽略 outputs 配置
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"]
// ← 没有 outputs!
// Turborepo 不知道缓存什么
// 每次都会重新构建
}
}
}// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [
"dist/**", // ← Next.js / Vite 输出
".next/**", // ← Next.js 缓存
"build/**" // ← 自定义输出
]
}
}
}outputs 告诉 Turborepo 哪些文件是构建产物。如果没声明,Turborepo 不会保存任何缓存 —— 你获得了并行但失去了缓存,白白浪费 80% 的加速潜力。
性能基准 (实测数据参考)
* 数据基于 20 个包 (5 apps + 15 libs),Next.js + TypeScript 项目,M2 MacBook Pro 实测。 实际性能取决于包数量、依赖深度、磁盘 I/O 和 CPU 核心数。
Monorepo 命令速查表
$ pnpm add -w -D turbo安装到根 workspace$ pnpm --filter web add lodash给指定包安装依赖$ pnpm -r run build递归执行所有包$ pnpm --filter ./packages/* update批量更新匹配包$ turbo run build并行 + 缓存构建$ turbo run build --filter=web只构建 web 及依赖$ turbo run build --dry-run预览执行计划$ turbo run build --force强制跳过缓存$ turbo prune --scope=web裁剪子集 Docker 构建$ turbo run build --graph=graph.html导出依赖图$ nx affected -t test只测试受影响的包$ nx run-many -t build构建全部$ nx graph打开交互式依赖图$ nx show projects --affected列出受影响的项目$ nx daemon启动后台分析进程$ pnpm changeset创建变更记录$ pnpm changeset version自动 bump 版本$ pnpm changeset publish发布到 npm$ pnpm changeset status查看待发布状态$ pnpm why lodash查看依赖链路$ pnpm ls -r --depth=0列出所有包依赖$ turbo run build --graph可视化任务图$ pnpm store status检查 store 一致性$ turbo prune web --docker生成 Docker 子集$ pnpm install --frozen-lockfileCI 安装 (不更新锁文件)$ pnpm fetch仅下载不解压 (Docker 层缓存)$ pnpm install --offline离线模式 (从 store 安装)TL;DR — 最终推荐
对于 85% 的团队,直接上 pnpm + Turborepo + changesets。 这个组合覆盖了依赖管理、任务编排、构建缓存和版本发布四大核心需求,总学习成本约 2 小时。 只有当你的仓库超过 50 个包、CI 时间超过 15 分钟、且需要精细的增量测试时,才考虑迁移到 Nx。