Monorepo 工程化实战:PNPM Workspaces、Turborepo 流水线与 200 个包的规模化实践

前端工程

我们把 15 个仓库合并成一个的那一周

周一:15 个独立仓库,各有各的 CI 流水线、依赖版本、lint 规则、发布流程。一个跨切面的共享类型更新意味着 15 个 PR、15 次 CI、3 天的协调。周五:一个 Monorepo。一次 pnpm install。一次 turbo run build。跨越 8 个包的改动一起提交、一起测试、一起发布。

这个迁移花了 6 周,是我们那年最好的基础设施投资。但我们也犯了错——CI 跑了 45 分钟才加上缓存,node_modules 吃掉 12GB 磁盘才正确配置 PNPM,包间的循环依赖花了一周才解开。以下是我们的全部经验。


Monorepo vs Multi-Repo:决策框架

因素 Multi-Repo Monorepo
跨包改动 多个 PR,协调合并 单一 PR,原子改动
依赖版本管理 仓库间版本漂移 单一真相来源(catalog)
CI 速度 单仓库快,跨仓库慢 无缓存时慢,有缓存时快
本地开发搭建 克隆一个仓库,npm install 克隆一个仓库,pnpm install
代码共享 npm 包,版本不匹配风险 直接导入,始终同步
权限控制 逐仓库权限(简单) CODEOWNERS 方式(更复杂)
学习曲线 无 大团队非平凡

什么时候不要用 Monorepo

  1. 团队代码不互交:Go 后端团队和 React 前端团队从不共享类型或逻辑,不值得。
  2. 严格的权限控制需求:某些包必须对特定团队不可见,Multi-Repo 更简单。
  3. 超大仓库(>10GB):Git 操作变慢。用 partial clone 或必要的拆分。

PNPM Workspace 深度解析

PNPM 是 2026 年 Monorepo 的事实标准。其严格的依赖解析防止了最常见的 Monorepo 错误:意外导入了未声明为依赖的包。

Workspace 配置

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tooling/*"
// package.json(根目录)
{
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.5.0"
  },
  "packageManager": "pnpm@9.1.0",
  "engines": { "node": ">=20", "pnpm": ">=9" }
}

Workspace 协议(workspace:*)

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "dependencies": {
    "@myorg/utils": "workspace:*",    // 始终使用本地最新版
    "@myorg/types": "workspace:^",    // 兼容 semver 范围
    "react": "catalog:"              // 从 catalog 获取共享版本
  }
}

workspace:* 协议确保开发期间包始终引用本地版本。发布时 PNPM 替换为实际版本。workspace:^ 允许语义化版本兼容范围,适合更宽松的依赖声明。

PNPM Catalog 统一版本

# pnpm-workspace.yaml
catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  next: ^15.0.0
  typescript: ^5.5.0
  zod: ^3.23.0

一个地方更新 React,200 个包同步。消除版本漂移。catalog: 引用方式比 workspace:* 更适合管理第三方依赖的统一版本。

.npmrc 关键配置

# .npmrc
shamefully-hoist=false           # 严格模式,禁止意外访问未声明的依赖
strict-peer-dependencies=true    # 缺失 peer dependency 时报错,而非静默忽略
node-linker=isolated             # PNPM 9+ 默认:逐包符号链接,最严格的隔离模式

包结构:组织 200 个包的最佳实践

monorepo/
├── apps/
│   ├── web/              # Next.js 前端
│   ├── docs/             # 文档站点
│   └── admin/            # 管理后台
├── packages/
│   ├── ui/               # 共享 React 组件
│   ├── utils/            # 纯工具函数
│   ├── types/            # 共享 TypeScript 类型
│   ├── config-eslint/    # 共享 ESLint 配置
│   ├── config-tailwind/  # 共享 Tailwind 预设
│   └── config-ts/        # 共享 tsconfig 基类
├── tooling/
│   ├── scripts/          # 构建/部署脚本
│   └── generators/       # 代码生成器(plop/hygen)
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

包边界规则

// ✅ 正确:apps/web 引用 packages/ui
import { Button } from "@myorg/ui";

// ❌ 错误:apps/web 引用 apps/admin
import { AdminPanel } from "@myorg/admin"; // 应用不应依赖其他应用

// ❌ 错误:packages/ui 引用 apps/web
import { router } from "@myorg/web"; // 包不应依赖应用

// ✅ 正确:packages/ui 引用 packages/utils
import { cn } from "@myorg/utils"; // 包之间可以互相依赖(无循环前提)

这条依赖方向规则保证了健康的依赖图:应用 → 包,包 → 包,应用 ↛ 应用,包 ↛ 应用。


Turborepo 流水线配置

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**"],
      "inputs": ["src/**", "tsconfig.json", "package.json"]
    },
    "dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["^build"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "eslint.config.mjs"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**", "test/**", "vitest.config.ts"]
    },
    "check-types": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"]
    }
  }
}

Turborepo 关键概念

配置项 作用 为什么重要
dependsOn: ["^build"] 先构建依赖(^ = 上游) 确保消费者构建前依赖已构建完成
dependsOn: ["build"] 先构建自身 当前包自身的 build 先完成再执行此任务
outputs 可缓存的输出目录 Turbo 检查输出是否存在,存在则跳过重新执行
inputs 影响缓存有效性的文件 只改变非输入文件不会使缓存失效
cache: false 永远不缓存此任务 dev server 等长期运行任务不应被缓存
persistent: true 长期运行进程不退出 turbo dev 不会因为一个 app 退出而停止所有

远程缓存

# 登录 Vercel(也可自建缓存服务器)
npx turbo login
npx turbo link

远程缓存在 CI 运行和团队成员之间共享构建结果。第二个构建同一分支的开发者秒得缓存命中。自建可以用 turbo cache 配合 S3 或 GCS。


依赖管理进阶

防止循环依赖

// ❌ 循环依赖!
// packages/utils/src/format.ts
import { Button } from "@myorg/ui";

// packages/ui/src/Button.tsx
import { formatDate } from "@myorg/utils"; // 这形成了循环

修复:提取共享关注点到第三个包:

// packages/dates/src/format.ts —— 新包,不依赖 ui 或 utils
export function formatDate(date: Date): string { ... }

// packages/utils/src/format.ts —— 从 dates 导入
import { formatDate } from "@myorg/dates";

// packages/ui/src/Button.tsx —— 从 dates 导入
import { formatDate } from "@myorg/dates";

// 依赖图:dates ← utils, dates ← ui(无循环!)

检测循环依赖:

npx madge --circular --extensions ts,tsx packages/

依赖升级策略

# 检查所有包中可升级的依赖
pnpm update -r --latest

# 交互式升级(推荐)
pnpm up -r -i --latest

# 只升级特定包的依赖
pnpm --filter @myorg/ui update react react-dom

幽灵依赖检测

// packages/ui 的 package.json 中没有声明 lodash
// 但在代码中使用了它 —— "幽灵依赖"
import { debounce } from "lodash"; // ❌ PNPM 严格模式下会报错

// 正确做法:显式声明依赖
// pnpm --filter @myorg/ui add lodash

共享配置:一次定义,全局使用

TypeScript Project References

// tsconfig.json(根目录)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "declarationMap": true
  },
  "references": [
    { "path": "packages/types" },
    { "path": "packages/utils" },
    { "path": "packages/ui" },
    { "path": "apps/web" }
  ]
}

共享 ESLint 配置

// tooling/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  prettier,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "@typescript-eslint/consistent-type-imports": "error",
      "import/no-cycle": "error",
    },
  }
);
// apps/web/eslint.config.mjs —— 继承基础配置
import baseConfig from "@myorg/config-eslint/base.js";
export default [
  ...baseConfig,
  { rules: { "no-console": "warn" } },
];

Monorepo CI/CD 实战

GitHub Actions + Turbo 完整配置

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Turbo 变更检测需要完整 git 历史
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo lint check-types test build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

基于变更的增量执行

# 只对受变更影响的包运行任务(PR 场景)
pnpm turbo run build test lint --filter=[origin/main...HEAD]

# 只构建 web 应用及其所有依赖
pnpm turbo run build --filter=@myorg/web...

# 只构建 utils 包及其所有消费者
pnpm turbo run build --filter=...@myorg/utils

Docker 多阶段构建 + Turbo Prune

# Stage 1: Prune(提取最小依赖子集)
FROM node:20-alpine AS pruner
WORKDIR /app
RUN npm install -g turbo@latest
COPY . .
RUN turbo prune --scope=@myorg/web --docker

# Stage 2: 安装依赖并构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN corepack enable && pnpm install --frozen-lockfile --prod=false
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@myorg/web

# Stage 3: 运行时最小镜像
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

turbo prune 创建一个仅包含目标应用及其传递依赖的最小子集。不需要把 200 个包全部拷贝进 Docker 镜像。


迁移路径:15 个仓库 → 1 个 Monorepo

阶段一:审计依赖(第 1 周)

发现 4 个不同版本的 date-fns、3 个 lodash、2 个 zod。全部统一到 catalog。同时梳理出包间真实的依赖关系图。

阶段二:创建骨架(第 2 周)

建立 apps/、packages/、tooling/ 目录结构,初始化 pnpm-workspace.yaml 和 turbo.json。

阶段三:提取共享包(第 3-4 周)

自底向上提取:types → utils → ui → 配置包。每个提取的包保持独立构建和测试。

阶段四:迁移应用(第 5 周)

逐应用迁移,每次迁移后验证构建和部署。使用 Turborepo 的 --filter 确保只构建变更的包。

阶段五:优化收尾(第 6 周)

  • 启用 Turborepo 本地和远程缓存
  • 配置 Monorepo 专用 CI 流水线
  • 更新 Docker 构建为 turbo prune 模式
  • 统一 ESLint、TypeScript、Prettier 配置

真实性能数据

指标 迁移前(15 个仓库) 迁移后(Monorepo) 变化
pnpm install 时间(冷) 15 × 45s = 11分15秒 28秒 -95.8%
全量 CI 构建 15 × 3.2分 = 48分 3.8分(缓存命中:12秒) -92.1%
跨包改动 PR 数 6-8 个 1 个 -85%
磁盘占用(node_modules) 15 × 850MB = 12.75GB 2.1GB -83.5%
新开发者上手时间 ~2 小时 8 分钟 -93.3%
依赖版本冲突 每迭代 4-6 次 0 -100%

PNPM 的内容寻址存储和硬链接到单一全局存储消除了重复的 node_modules。Turborepo 缓存让 CI 增量构建几乎即时。


常见踩坑与解决方案

坑 1:workspace:* 在发布时没有替换

现象:发布到 npm 的包中 workspace:* 协议没有被替换为实际版本号。

原因:未使用 pnpm publish 而是用了 npm publish。

解决:统一使用 pnpm publish 或配置 publishConfig:

{
  "publishConfig": {
    "registry": "https://registry.npmjs.org"
  }
}

坑 2:Turborepo 缓存命中但代码未生效

现象:修改了源代码但构建结果不变。

原因:inputs 配置不全,修改的文件未被纳入缓存键。

解决:检查 turbo.json 中 inputs 是否覆盖了所有相关文件:

{
  "build": {
    "inputs": ["src/**", "tsconfig.json", "package.json", "tailwind.config.ts"]
  }
}

坑 3:Cannot find module 但依赖已安装

现象:PNPM 严格模式下找不到依赖模块。

原因:代码中使用了未在 package.json 中声明的依赖(幽灵依赖)。

解决:pnpm --filter <package> add <pkg> 显式添加依赖。


总结:Monorepo 不是把所有东西塞进一个文件夹——而是让跨包改动变得便宜且安全。三个支柱:PNPM workspaces 做严格依赖隔离,Turborepo 做构建编排和缓存,共享配置 做跨包一致性。从提取共享类型和工具函数开始。在加人之前加缓存。度量一切。数据不撒谎:迁移后团队跨包改动效率提升了 5 倍,基础设施成本增加在第一月内就被消除的 CI 重复抵消了。


在线工具推荐

  • JSON 格式化 — Monorepo 搭建时检查 package.json 和 turbo.json 配置
  • 哈希计算器 — 验证跨包文件完整性,用于构建缓存一致性校验
#Monorepo#PNPM#Turborepo#工程化#前端工程#依赖管理#CI/CD#2026