WSの小屋

前端工程化:Monorepo 实践与 Turborepo 入门

随着项目规模增长,多项目共享代码的需求越来越强。Monorepo(单仓库多包)配合 Turborepo 的增量构建,是当前前端工程化的主流方案之一。本文从零搭建一个完整的 Monorepo 项目,覆盖实战中的关键决策点。

为什么选择 Monorepo

Multirepo 的痛点

在 Multirepo 模式下,每个项目一个仓库。当多个项目需要共享工具函数、UI 组件、类型定义时,你会遇到:

  • 依赖版本不一致:A 项目用 Vue 3.3,B 项目用 Vue 3.4,共享组件难以兼容
  • 跨仓库修改成本高:改一个共享工具函数,需要:提交到共享仓库 → 发版 → 在各项目仓库升级 → 测试
  • 原子提交不可能:一个需求涉及共享包和业务项目,无法在一个 PR 中完整 review
  • CI 配置重复:每个仓库都维护一套构建、部署、代码规范的配置

Monorepo 的优势

  • 代码共享:多个项目直接引用同仓库的包,无需发包
  • 统一版本:所有项目使用同一版本的 Vue、TypeScript 等
  • 原子提交:跨项目的修改在一个 PR 中完成
  • 统一工具链:ESLint、Prettier、tsconfig、CI 配置集中管理

什么时候不适合 Monorepo

  • 团队只有 1-2 个项目,且不打算共享代码
  • 项目技术栈差异巨大(如一个 React、一个 Vue)
  • 没有合适的工具链(Turborepo / Nx / pnpm workspace)

项目结构设计

my-monorepo/
├── apps/                    # 应用
│   ├── web/                 # 官网(Nuxt)
│   │   ├── package.json
│   │   └── nuxt.config.ts
│   └── admin/               # 管理后台(Vue + Vite)
│       ├── package.json
│       └── vite.config.ts
├── packages/                # 共享包
│   ├── ui/                  # 共享 UI 组件库
│   ├── utils/               # 工具函数
│   ├── types/               # 共享类型定义
│   └── config/              # 共享配置(eslint, tsconfig)
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json

从零搭建

1. 初始化 pnpm workspace

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// package.json (根目录)
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "clean": "turbo clean && rm -rf node_modules"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.4.0"
  },
  "packageManager": "pnpm@9.0.0"
}

2. Turborepo 配置

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".nuxt/**", ".output/**"],
      "env": ["NODE_ENV", "DATABASE_URL"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "clean": {
      "cache": false
    }
  }
}

关键配置说明:

  • dependsOn: ["^build"]^ 表示先构建依赖的包,再构建自己
  • outputs:声明构建产物路径,用于缓存命中判断
  • env:声明影响构建的环境变量,变量变化时缓存自动失效
  • persistent: true:dev 任务是长运行进程,不参与缓存

3. 共享包配置

// packages/utils/package.json
{
  "name": "@my/utils",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./date": {
      "types": "./dist/date.d.ts",
      "import": "./dist/date.js"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts src/date.ts --format esm --dts",
    "dev": "tsup src/index.ts src/date.ts --format esm --dts --watch"
  },
  "devDependencies": {
    "tsup": "^8.0.0"
  }
}
// packages/utils/src/index.ts
export * from './date'
export * from './format'

// packages/utils/src/date.ts
export function formatDate(date: Date | string, format = 'YYYY-MM-DD'): string {
  const d = typeof date === 'string' ? new Date(date) : date
  // ...实现
  return format
    .replace('YYYY', String(d.getFullYear()))
    .replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
    .replace('DD', String(d.getDate()).padStart(2, '0'))
}

4. 应用引用共享包

// apps/web/package.json
{
  "name": "@my/web",
  "dependencies": {
    "@my/utils": "workspace:*",
    "@my/ui": "workspace:*"
  }
}

workspace:* 是 pnpm 的语法,表示从当前 workspace 引用,不走 npm registry。

// apps/web/app.vue
import { formatDate } from '@my/utils'

const today = formatDate(new Date())

5. 共享 TypeScript 配置

// tsconfig.base.json (根目录)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
// packages/utils/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Turborepo 缓存机制深入

本地缓存

Turborepo 会根据以下因素计算缓存 key:

  • 源文件内容(hash)
  • 依赖包的构建输出
  • 环境变量
  • 构建命令

缓存命中时,直接恢复 outputs 中声明的文件,跳过实际构建:

$ turbo build
• packages/utils:build: cache hit, outputs dist/**
• packages/ui:build: cache miss, building...
• apps/web:build: cache miss, building...

  Tasks:    3 successful, 3 total
Cached:    1 cached, 3 total
  Time:    2.3s → Full run would take 12s

远程缓存

在团队协作中,CI 上的构建结果可以缓存到远程,其他开发者(或 CI)直接复用:

# 使用 Vercel 远程缓存(免费)
turbo login
turbo link

# 之后 build 会自动同步远程缓存

也可以自建远程缓存服务器(兼容 Turborepo 缓存协议):

# .env
TURBO_API=https://my-cache-server.com
TURBO_TOKEN=xxx
TURBO_TEAM=my-team

增量构建实战

只构建变更的包

# 只构建相比 main 分支有变化的包及其依赖链
turbo build --filter=...[origin/main]

# 只构建某个包
turbo build --filter=@my/web

# 构建某个包及其依赖
turbo build --filter=@my/web...

# 排除某些包
turbo build --filter="!@my/admin"

CI 中的增量构建

# .github/workflows/ci.yml
name: CI
on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # turbo 需要完整 git 历史来计算 diff

      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      # PR 中只构建变更的包
      - run: pnpm build --filter=...[origin/main]
      - run: pnpm test --filter=...[origin/main]
      - run: pnpm lint --filter=...[origin/main]

常见问题与最佳实践

1. 循环依赖检测

$ turbo build
⚠ packages/ui imports @my/utils
⚠ packages/utils imports @my/ui
ERROR: circular dependency detected

解法:将循环依赖的公共部分提取到第三个包。

2. 版本管理

使用 Changesets 管理包版本和发布:

pnpm add -D @changesets/cli @changesets/changelog-github
npx changeset init

# 添加变更记录
npx changeset

# 发版(自动 bump 版本 + 生成 changelog + 发布)
npx changeset publish

3. Workspace 协议 vs 链接

workspace:* 在 pnpm 中会创建硬链接,不是 symlink。如果共享包需要热更新,确保 dev 脚本使用了 watch 模式,且应用配置了 transpileDependencies(Nuxt)或类似选项。

4. 跨包类型推导

确保每个包都生成了 .d.ts 文件(declaration: true),否则消费方无法获得类型提示。使用 tsuptsc 生成类型声明文件。

与 Nx 的对比

特性 Turborepo Nx
学习曲线
配置复杂度 简单(一个 turbo.json) 丰富(project.json + nx.json)
缓存 ✅ 本地 + 远程 ✅ 本地 + 远程
代码生成器 ✅ 丰富的 plugin 生态
依赖图可视化 nx graph
适合场景 中小型 Monorepo 大型企业级 Monorepo

如果团队不超过 20 人,包数量不超过 30 个,Turborepo 完全够用。超过这个规模,Nx 的代码生成和依赖分析会更有价值。

总结

Monorepo + Turborepo 的组合带来了三个核心价值:

  1. 代码共享无摩擦workspace:* 直接引用,无需发包
  2. 构建速度:增量构建 + 缓存命中,大型项目构建从分钟级降到秒级
  3. 统一工程规范:ESLint、tsconfig、CI 集中管理,一致性有保障

建议从一个小项目开始:先建立目录结构和 turbo.json,创建 1-2 个共享包(如 @my/utils),然后逐步迁移现有项目进来。渐进式迁移比一次到位更务实。

Comments | 0条评论