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.yamlturbo.json

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

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

阶段四:迁移应用(第 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.jsoninputs 是否覆盖了所有相关文件:

{
  "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.jsonturbo.json 配置
  • 哈希计算器 — 验证跨包文件完整性,用于构建缓存一致性校验

本站提供浏览器本地工具,免注册即可试用 →

#Monorepo#PNPM#Turborepo#工程化#前端工程#依赖管理#CI/CD#2026