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 週)

自底向上提取:typesutilsui → 設定套件。

階段四:遷移應用(第 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.jsoninputs 是否覆蓋了所有相關檔案。

坑 3:Cannot find module 但依賴已安裝

現象:PNPM 嚴格模式下找不到依賴模組。

原因:程式碼中使用了未在 package.json 中宣告的依賴(幽靈依賴)。

解決pnpm --filter <package> add <pkg> 顯式添加依賴。


總結:Monorepo 不是把所有東西塞進一個資料夾——而是讓跨套件改動變得便宜且安全。三個支柱:PNPM workspaces 做嚴格依賴隔離,Turborepo 做建置編排和快取,共享配置 做跨套件一致性。從提取共享類型和工具函數開始。在加人之前加快取。度量一切。數據不撒謊:遷移後團隊跨套件改動效率提升了 5 倍。


線上工具推薦

  • JSON 格式化 — Monorepo 搭建時檢查 package.jsonturbo.json 設定
  • 雜湊計算器 — 驗證跨套件檔案完整性,用於建置快取一致性校驗

本站提供瀏覽器本地工具,免註冊即可試用 →

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