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 個套件同步。消除版本漂移。
.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 執行和團隊成員之間共享建置結果。第二個建置同一分支的開發者秒得快取命中。
依賴管理進階
防止循環依賴
// ❌ 循環依賴!
// 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
共享配置:一次定義,全局使用
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 建立一個僅包含目標應用及其傳遞依賴的最小子集。
遷移路徑:15 個倉庫 → 1 個 Monorepo
階段一:審計依賴(第 1 週)
發現 4 個不同版本的 date-fns、3 個 lodash、2 個 zod。全部統一到 catalog。
階段二:建立骨架(第 2 週)
建立 apps/、packages/、tooling/ 目錄結構。
階段三:提取共享套件(第 3-4 週)
自底向上提取:types → utils → ui → 設定套件。
階段四:遷移應用(第 5 週)
逐應用遷移,每次遷移後驗證建置和部署。
階段五:最佳化收尾(第 6 週)
啟用快取、最佳化 CI、更新 Docker 建置。
真實效能數據
| 指標 | 遷移前(15 個倉庫) | 遷移後(Monorepo) | 變化 |
|---|---|---|---|
pnpm install 時間(冷) |
11分15秒 | 28秒 | -95.8% |
| 全量 CI 建置 | 48分 | 3.8分(快取命中:12秒) | -92.1% |
| 跨套件改動 PR 數 | 6-8 個 | 1 個 | -85% |
| 磁碟佔用(node_modules) | 12.75GB | 2.1GB | -83.5% |
| 新開發者上手時間 | ~2 小時 | 8 分鐘 | -93.3% |
| 依賴版本衝突 | 每迭代 4-6 次 | 0 | -100% |
常見踩坑與解決方案
坑 1:workspace:* 在發布時沒有替換
現象:發布到 npm 的套件中 workspace:* 協定沒有被替換為實際版本號。
原因:未使用 pnpm publish 而是用了 npm publish。
解決:統一使用 pnpm publish 或設定 publishConfig。
坑 2:Turborepo 快取命中但程式碼未生效
現象:修改了原始碼但建置結果不變。
原因:inputs 設定不全,修改的檔案未被納入快取鍵。
解決:檢查 turbo.json 中 inputs 是否覆蓋了所有相關檔案。
坑 3:Cannot find module 但依賴已安裝
現象:PNPM 嚴格模式下找不到依賴模組。
原因:程式碼中使用了未在 package.json 中宣告的依賴(幽靈依賴)。
解決:pnpm --filter <package> add <pkg> 顯式添加依賴。
總結:Monorepo 不是把所有東西塞進一個資料夾——而是讓跨套件改動變得便宜且安全。三個支柱:PNPM workspaces 做嚴格依賴隔離,Turborepo 做建置編排和快取,共享配置 做跨套件一致性。從提取共享類型和工具函數開始。在加人之前加快取。度量一切。數據不撒謊:遷移後團隊跨套件改動效率提升了 5 倍。
線上工具推薦
本站提供瀏覽器本地工具,免註冊即可試用 →