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
- 团队代码不互交:Go 后端团队和 React 前端团队从不共享类型或逻辑,不值得。
- 严格的权限控制需求:某些包必须对特定团队不可见,Multi-Repo 更简单。
- 超大仓库(>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 重复抵消了。
在线工具推荐
本站提供浏览器本地工具,免注册即可试用 →