工程架构Monorepo 选型与实战

Monorepo 架构
全链路深度解析

想象一个大型购物中心 —— 每家店铺(包)独立经营,但共享同一栋建筑(仓库)、同一套消防系统(CI/CD) 和同一个导航地图(依赖图)。这,就是 Monorepo。

磁盘节省 50-70%CI 时间减半原子提交依赖零漂移
L2 · 痛点与动机

为什么你需要 Monorepo?

Multi-Repo 之痛
  • 跨仓库改一个类型定义,需要 4 个 PR + 4 次 npm publish
  • react 版本不一致导致运行时幽灵 bug,排查耗时 2-4 小时
  • 每个仓库独立 CI 配置,维护 15+ 个 .github/workflows
  • 共享代码要么 copy-paste,要么发布 200 行的 npm 包
  • 新人 onboarding 需要 clone 8 个仓库,配好全部 link
Monorepo 之利
  • 改类型定义?一次提交,所有包立即同步,CI 自动回归
  • 依赖版本统一管理,pnpm 严格隔离消除幽灵依赖
  • 一套 CI 配置 + Turborepo 智能缓存,只跑受影响的包
  • 共享代码直接 import,修改即生效,零发布开销
  • git clone 一次,pnpm install 全部搞定,10 分钟 onboarding

Monorepo 工具演化史

2014

Bolt (早期尝试)

提出 workspace 概念,但性能差,已停止维护

2017

Yarn Workspaces

首个主流方案,但扁平化 node_modules 导致幽灵依赖

2020

pnpm v5

内容寻址存储 + 严格隔离,磁盘占用降低 50-70%,成为新标准

2021

Turborepo 开源

Vercel 收购后开源,引入增量构建 + 远程缓存,配置极简

2022

Nx 15+

添加 Crystal 自动推断、Module Federation 支持,向全平台演进

2024

当前格局

pnpm + Turborepo 成为主流搭配,Nx 占据企业级市场

L3 · 核心原理

三大支柱:隔离、编排、缓存

1
依赖隔离 (Dependency Isolation)

npm/yarn classic 采用扁平化提升 (hoisting)策略 —— 将所有依赖提升到根 node_modules。 这导致了两个严重问题:幽灵依赖 (Phantom Dependencies) —— 你可以 import 未声明的包;依赖地狱 (Dependency Hell) —— 同一个包只能存在一个版本。

❌ 传统扁平化结构bash
// ❌ 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 严格隔离结构bash
// ✅ 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:* 协议, 开发时符号链接到本地包,发布时自动替换为实际版本号。这意味着你永远不需要手动管理内部依赖版本。

packages/web/package.jsonjson
{
  "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
              
turbo.json — Turborepo 任务定义json
{
  "$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              // ← 部署不缓存!
    }
  }
}
project.json — Nx 任务定义json
// 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 (纯脚本)TurborepoNx
拓扑排序❌ 需手动 --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 哈希:包内源码文件 + 依赖锁定文件 + 任务配置 + 环境变量。 只要哈希不变,直接恢复上次构建的产物 —— 连代码都不用执行。

turbo run build — 缓存行为演示bash
# 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 计算时间

远程缓存配置bash
// turbo.json 远程缓存配置
{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "signature": true        // ← 开启签名验证,防止缓存投毒
  }
}

// Vercel 零配置远程缓存
$ npx turbo login
$ npx turbo link
# 现在所有 CI 机器共享同一个缓存 —— 团队 20 人不再重复构建
L3.5 · 交互实验场

三工具选型对比实验室

pnpm workspace

底层基础设施,零开销依赖管理

✅ 优势
  • 原生 workspace 支持
  • 严格依赖隔离
  • 磁盘占用降低 50-70%
  • 零学习曲线
⚠️ 局限
  • 无任务编排
  • 无缓存机制
  • 需要自建脚本
pnpm workspace 快速上手bash
# 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                            # 递归执行所有包
L4 · 代码实战

从零搭建 Monorepo

1
pnpm workspace 初始化

pnpm-workspace.yamlyaml
# pnpm-workspace.yaml  —— Monorepo 的地基文件
packages:
  - "packages/*"     # 共享库:ui / utils / config
  - "apps/*"         # 应用:web / admin / api
  - "plugins/*"      # 插件(可选)
package.json (root)json
{
  "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"
  }
}

2
Turborepo 任务配置详解

turbo.json — 完整任务配置json
{
  "$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 声明 → 缓存投毒

3
Nx 的杀手锏:affected 命令

Turborepo 的缓存跳过的是"没变的包",但仍然会检查所有包。 Nx 的 affected 命令更进一步 —— 它通过 Git diff 分析哪些包被影响了,然后只执行这些包及其下游依赖。 在 50+ 包的仓库中,这可以将 CI 测试范围从 "全部" 缩小到 "3 个"。

Nx affected — 精准 CI 实战bash
# 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 发布流程bash
# 使用 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                   │
                       └────────────────────────────────────────────────┘
              
L5 · 工程全景

选型决策、陷阱与最佳实践

选型决策矩阵

📦 pnpm workspace 单独使用

适用场景:2-5 个包的小型 monorepo

团队规模:1-3 人

包数量:≤ 10

✅ 零额外依赖
⚠️ 需要自己写构建脚本

推荐

⚡ pnpm + Turborepo

适用场景:中小型全栈 monorepo

团队规模:3-30 人

包数量:5-100

✅ 社区最主流
✅ 学习成本极低

🔷 Nx (全套)

适用场景:企业级大型平台

团队规模:20-500+ 人

包数量:50-1000+

✅ affected 精准 CI
✅ 插件生态丰富

推荐组合快速开始bash
# 🏗️ 推荐组合:pnpm + Turborepo(覆盖率 ~85% 场景)

# 这是目前社区最主流的搭配:
# • pnpm 负责依赖管理 + workspace 协议
# • Turborepo 负责任务编排 + 远程缓存
# • changesets 负责版本管理 + 发布
#
# 学习成本:~2 小时 | 维护成本:极低
# 适用团队:3-50 人 | 包数量:5-100 个

$ pnpm create turbo@latest   # 一行命令搞定脚手架

常见陷阱与反模式

🚫 陷阱 #1:随意 hoisting 依赖

❌ 错误做法json
// 根目录 package.json
{
  "dependencies": {
    "react": "^18.2.0"    // ← 把所有子包的依赖提到根目录
  }
}
✅ 正确做法json
// 每个包声明自己的依赖
// apps/web/package.json
{
  "dependencies": {
    "react": "^18.2.0"    // ← 显式声明在使用它的包中
  }
}

// pnpm 自动去重,不需要手动 hoisting

把依赖提升到根目录会导致幽灵依赖,且破坏 pnpm 的严格隔离机制。让每个包声明自己需要的依赖,pnpm 会通过内容寻址自动去重存储。

🚫 陷阱 #2:忘记配置 envVar

❌ 错误做法json
// turbo.json — 环境变量未声明
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
      // ← 缺少 env 声明!
    }
  }
}
✅ 正确做法json
// turbo.json — 正确声明环境变量
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV", "NEXT_PUBLIC_API_URL"]
      // ← 这些变量变化也会使缓存失效
    }
  }
}

Turborepo 默认只监控文件内容。如果 NODE_ENV 从 'development' 变为 'production',但源码没变,你会得到一个错误的缓存结果 —— 这叫缓存投毒 (Cache Poisoning)。

🚫 陷阱 #3:循环依赖

❌ 错误做法json
// ❌ packages/shared-ui/package.json
{ "dependencies": { "@myorg/utils": "workspace:*" } }

// ❌ packages/utils/package.json
{ "dependencies": { "@myorg/shared-ui": "workspace:*" } }
// 💥 DAG 不允许环!构建会失败或死循环
✅ 正确做法json
// ✅ 提取公共模块打破循环
// 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 配置

❌ 错误做法json
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
      // ← 没有 outputs!
      // Turborepo 不知道缓存什么
      // 每次都会重新构建
    }
  }
}
✅ 正确做法json
// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [
        "dist/**",           // ← Next.js / Vite 输出
        ".next/**",          // ← Next.js 缓存
        "build/**"           // ← 自定义输出
      ]
    }
  }
}

outputs 告诉 Turborepo 哪些文件是构建产物。如果没声明,Turborepo 不会保存任何缓存 —— 你获得了并行但失去了缓存,白白浪费 80% 的加速潜力。

性能基准 (实测数据参考)

~120s
首次构建 (无缓存)
20 个包, 8 核 CPU
~2s
Turborepo 本地缓存
节省 98.3%
~1.5s
Turborepo 远程缓存
CI 同享, 跨机器
~15s
Nx affected (小改动)
只构建 3 个包

* 数据基于 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批量更新匹配包
⚡ Turborepo
$ 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
$ 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 一致性
🐳 Docker 优化
$ 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。

pnpm create turbo@latest2h 学习零运行时开销社区主流