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 個套件同步。消除版本漂移。

.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 倍。


線上工具推薦

  • JSON 格式化 — Monorepo 搭建時檢查 package.json 和 turbo.json 設定
  • 雜湊計算器 — 驗證跨套件檔案完整性,用於建置快取一致性校驗
#Monorepo#PNPM#Turborepo#工程化#前端工程#依赖管理#CI/CD#2026