Monorepo 与 Turborepo

当项目增长到一定规模——多个 Next.js 应用、共享 UI 组件库、公共工具函数、统一的配置——把它们分散在多个 Git 仓库中会带来版本同步噩梦。Monorepo 把所有代码放在一个仓库,用工作空间(workspace)管理依赖,用 Turborepo 加速构建。本章从零搭建一个生产级 Monorepo。

1. 为什么用 Monorepo

1.1 Monorepo vs Polyrepo

维度MonorepoPolyrepo(多仓库)
代码共享直接 import,零发布成本发 npm 包,版本管理
原子提交一个 PR 改多个包多仓库分别 PR
依赖一致性全局统一版本各仓库版本可能不同
CI/CD统一 pipeline每个仓库独立配置
构建速度需要增量构建工具天然独立
仓库体积较大较小
权限控制需要 CODEOWNERS仓库级隔离

适合 Monorepo 的场景

  • 多个 Next.js 应用共享组件和逻辑
  • 前端应用 + 共享 UI 库 + 工具包
  • 全栈项目(Web + API + Worker)
  • 团队 3-30 人

1.2 技术选型

包管理器:pnpm(workspace 原生支持 + 磁盘效率高)
构建编排:Turborepo(增量构建 + 远程缓存)
代码共享:Internal Packages(不发布到 npm)

2. pnpm Workspace 基础

2.1 初始化项目

mkdir my-saas && cd my-saas
pnpm init
 
# 创建 workspace 配置
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - "apps/*"
  - "packages/*"
EOF

2.2 目录结构

my-saas/
├── apps/
│   ├── web/                    # 主 Web 应用(Next.js)
│   │   ├── app/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── admin/                  # 管理后台(Next.js)
│   │   ├── app/
│   │   ├── next.config.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── docs/                   # 文档站(Next.js / VitePress)
│       ├── package.json
│       └── ...
├── packages/
│   ├── ui/                     # 共享 UI 组件库
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── db/                     # 数据库 Schema + 连接
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── auth/                   # 认证逻辑
│   │   ├── src/
│   │   └── package.json
│   ├── email/                  # 邮件模板
│   │   ├── src/
│   │   └── package.json
│   ├── config-eslint/          # 共享 ESLint 配置
│   │   └── package.json
│   ├── config-typescript/      # 共享 TS 配置
│   │   └── package.json
│   └── utils/                  # 通用工具函数
│       ├── src/
│       └── package.json
├── turbo.json
├── pnpm-workspace.yaml
├── package.json
└── .gitignore

2.3 根 package.json

{
  "name": "my-saas",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "type-check": "turbo type-check",
    "db:push": "pnpm --filter @my-saas/db db:push",
    "db:studio": "pnpm --filter @my-saas/db studio",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "prettier": "^3.2.0",
    "turbo": "^2.3.0"
  },
  "packageManager": "[email protected]"
}

2.4 包之间的依赖

// apps/web/package.json
{
  "name": "@my-saas/web",
  "dependencies": {
    "@my-saas/ui": "workspace:*",
    "@my-saas/db": "workspace:*",
    "@my-saas/auth": "workspace:*",
    "@my-saas/utils": "workspace:*",
    "next": "^15.1.0",
    "react": "^19.0.0"
  }
}

workspace:* 表示从本地 workspace 解析,pnpm 自动建立符号链接。

3. Turborepo 配置

3.1 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}

关键概念

  • dependsOn: ["^build"]:先构建依赖的包,再构建自己。^ 表示上游依赖。
  • outputs:声明构建产物,用于缓存。
  • inputs:声明影响构建的文件,变化时才重新构建。
  • cache: false:dev 命令不缓存。
  • persistent: true:dev 是长运行命令。

3.2 增量构建原理

第一次 build:
  packages/ui     → build → ✅ 缓存
  packages/db     → build → ✅ 缓存
  apps/web        → build → ✅ 缓存(依赖 ui + db)
  
修改了 packages/ui/src/button.tsx:
  packages/ui     → build → 🔄 重新构建(文件变了)
  packages/db     → build → ⚡ 命中缓存(没变)
  apps/web        → build → 🔄 重新构建(依赖的 ui 变了)
  apps/admin      → build → 🔄 重新构建(也依赖 ui)
  apps/docs       → build → ⚡ 命中缓存(不依赖 ui)

3.3 远程缓存

# 登录 Vercel(Turborepo 远程缓存托管在 Vercel)
npx turbo login
 
# 链接项目
npx turbo link
 
# 现在 CI 和团队成员可以共享构建缓存
# 团队成员 A 构建过的结果,成员 B 直接复用

效果:CI 构建从 10 分钟降到 2 分钟(大部分包命中缓存)。

3.4 过滤执行

# 只构建 web 应用(及其依赖)
turbo build --filter=@my-saas/web
 
# 只构建变化的包(相对于 main 分支)
turbo build --filter=...[main]
 
# 只运行 web 的 dev
turbo dev --filter=@my-saas/web
 
# 运行 web 和它依赖的所有包的 dev
turbo dev --filter=@my-saas/web...

4. 共享包实践

4.1 UI 组件库

// packages/ui/package.json
{
  "name": "@my-saas/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    "./button": "./src/button.tsx",
    "./card": "./src/card.tsx",
    "./dialog": "./src/dialog.tsx",
    "./form": "./src/form.tsx",
    "./globals.css": "./src/globals.css"
  },
  "dependencies": {
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.2.0"
  },
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "@my-saas/config-typescript": "workspace:*",
    "typescript": "^5.5.0"
  }
}
// packages/ui/src/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from './utils'
 
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
)
 
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}
 
export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button className={cn(buttonVariants({ variant, size, className }))} {...props} />
  )
}

在应用中使用:

// apps/web/app/page.tsx
import { Button } from '@my-saas/ui/button'
import { Card } from '@my-saas/ui/card'
 
export default function Page() {
  return (
    <Card>
      <Button variant="default">Click me</Button>
    </Card>
  )
}

4.2 数据库包

// packages/db/package.json
{
  "name": "@my-saas/db",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts",
    "./schema": "./src/schema.ts"
  },
  "scripts": {
    "db:push": "drizzle-kit push",
    "db:generate": "drizzle-kit generate",
    "studio": "drizzle-kit studio"
  },
  "dependencies": {
    "drizzle-orm": "^0.38.0",
    "@neondatabase/serverless": "^0.10.0"
  },
  "devDependencies": {
    "drizzle-kit": "^0.30.0"
  }
}
// packages/db/src/index.ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from './schema'
 
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
export type Database = typeof db
// packages/db/src/schema.ts
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core'
 
export const tenants = pgTable('tenants', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').unique().notNull(),
  plan: text('plan').default('free'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
  email: text('email').unique().notNull(),
  name: text('name'),
  role: text('role').default('member'),
  metadata: jsonb('metadata'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
export const projects = pgTable('projects', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
  name: text('name').notNull(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
})

多个应用共享同一份 Schema:

// apps/web/lib/actions/project.ts
import { db } from '@my-saas/db'
import { projects } from '@my-saas/db/schema'
 
export async function getProjects(tenantId: string) {
  return db.select().from(projects).where(eq(projects.tenantId, tenantId))
}

4.3 共享 TypeScript 配置

// packages/config-typescript/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "moduleDetection": "force"
  }
}
 
// packages/config-typescript/nextjs.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  }
}

应用中引用:

// apps/web/tsconfig.json
{
  "extends": "@my-saas/config-typescript/nextjs.json",
  "compilerOptions": {
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

4.4 共享 ESLint 配置

// packages/config-eslint/next.js
module.exports = {
  extends: [
    'next/core-web-vitals',
    'next/typescript',
    'prettier',
  ],
  rules: {
    'no-console': 'warn',
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    'import/no-duplicates': 'error',
  },
}
// apps/web/.eslintrc.json
{ "extends": ["@my-saas/config-eslint/next"] }

5. Next.js Monorepo 专项

5.1 Tailwind CSS 跨包配置

// apps/web/tailwind.config.ts
import type { Config } from 'tailwindcss'
 
const config: Config = {
  content: [
    './app/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    // 关键:扫描 UI 包中的类名
    '../../packages/ui/src/**/*.{ts,tsx}',
  ],
  theme: { extend: {} },
  plugins: [],
}
export default config

5.2 next.config.ts 的 transpilePackages

// apps/web/next.config.ts
import type { NextConfig } from 'next'
 
const config: NextConfig = {
  // 告诉 Next.js 编译这些 workspace 包
  transpilePackages: ['@my-saas/ui', '@my-saas/utils'],
}
 
export default config

5.3 环境变量管理

# 根目录 .env(共享变量)
DATABASE_URL=postgresql://...

# apps/web/.env.local(应用特定变量)
NEXT_PUBLIC_APP_URL=http://localhost:3000
CLERK_SECRET_KEY=sk_test_xxx

# apps/admin/.env.local(另一个应用)
NEXT_PUBLIC_APP_URL=http://localhost:3001
ADMIN_SECRET=xxx

turbo.json 中声明环境变量依赖:

{
  "globalEnv": ["DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_*", "CLERK_*", "STRIPE_*"]
    }
  }
}

6. CI/CD

6.1 GitHub Actions

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: pnpm/action-setup@v2
        with: { version: 9 }
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
 
      - run: pnpm install --frozen-lockfile
 
      - run: pnpm lint
      - run: pnpm type-check
      - run: pnpm build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}

6.2 选择性部署

# 只有 apps/web 变化时才部署 web
- name: Check changes
  id: changes
  run: |
    CHANGED=$(npx turbo build --filter=@my-saas/web --dry-run=json | jq '.packages | length')
    echo "web_changed=$([[ $CHANGED -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
 
- name: Deploy Web
  if: steps.changes.outputs.web_changed == 'true'
  run: vercel deploy --prod

7. 常见问题

7.1 版本管理

# 使用 changesets 管理版本(如果需要发布包到 npm)
pnpm add -D @changesets/cli -w
npx changeset init
 
# 日常:记录变更
npx changeset
# → 选择变更的包
# → 选择版本类型(patch/minor/major)
# → 写变更说明
 
# 发布时
npx changeset version  # 更新版本号
npx changeset publish  # 发布到 npm

对于纯内部包(private: true),不需要 changesets——直接用 workspace:* 引用即可。

7.2 开发体验

# 同时启动所有应用的 dev
pnpm dev
 
# 只启动 web(推荐,节省资源)
pnpm dev --filter=@my-saas/web
 
# 在指定包中安装依赖
pnpm add zod --filter=@my-saas/web
 
# 在根目录安装开发依赖
pnpm add -D prettier -w  # -w = workspace root
 
# 在所有包中运行命令
pnpm -r exec -- rm -rf node_modules

7.3 从单仓库迁移

步骤:
1. 创建 Monorepo 骨架(pnpm-workspace.yaml + turbo.json)
2. 把现有 Next.js 应用移到 apps/web/
3. 识别可复用的代码,提取到 packages/
4. 更新 import 路径
5. 配置 transpilePackages 和 Tailwind content
6. 测试构建

本章小结

  • Monorepo 优势:代码共享零成本、依赖统一、原子提交、统一 CI/CD
  • pnpm Workspaceworkspace:* 本地链接、高效磁盘管理、原生 workspace 支持
  • Turborepo:增量构建(只构建变化的包)、远程缓存(团队共享)、依赖图自动推导
  • 共享包模式:UI 组件库、数据库 Schema、TS/ESLint 配置——Internal Packages 不发 npm
  • Next.js 专项transpilePackages 编译 workspace 包、Tailwind content 跨包扫描
  • CI/CD:远程缓存加速 + 选择性部署 = 高效流水线