Skip to content

Monorepo 工程化:pnpm workspace + Turborepo ​

当前端团队同时维护 Web 应用、管理后台、组件库和工具包时,多仓库模式会带来几个固定成本:组件库改一个类型,要在多个仓库里升级、发版、联调;公共配置重复维护;跨仓库重构几乎不可能。

Monorepo 把相关项目放进同一个仓库,用工具链保证依赖、构建和发布的一致性。它的收益很大,但如果没有约束,也很容易变成一个巨大而混乱的仓库。

Monorepo 适合什么 ​

适合:

  • 多个应用共享组件库、工具函数、类型定义。
  • 应用之间有明确的协作关系,经常需要一起修改。
  • 团队有能力维护统一的工程配置和 CI。
  • 组件库需要独立发布,但与应用联调频繁。

不适合:

  • 项目之间毫无关系,只是"放在一起方便管理"。
  • 团队规模很小,一个应用都还没有稳定的测试和发布流程。
  • 没有专人维护构建工具链。

Monorepo 是一种组织方式,不是技术目标。它解决的是协作成本,代价是工具链复杂度。

目录结构 ​

一个典型的 pnpm workspace:

text
acme/
├── apps/
│   ├── web/                 # 面向用户的 Web 应用
│   └── admin/               # 管理后台
├── packages/
│   ├── ui/                  # 组件库
│   ├── utils/               # 工具函数
│   ├── api-client/          # 接口客户端与类型
│   ├── eslint-config/       # 共享 ESLint 配置
│   └── tsconfig/            # 共享 TypeScript 配置
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml

apps 放可部署的应用,packages 放被复用的库。依赖方向必须单向:应用依赖包,包之间尽量不互相依赖,禁止包反向依赖应用。

pnpm workspace 基础配置 ​

yaml
# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

catalog:
  vue: ^3.5.0
  typescript: ^5.6.0
  vitest: ^2.1.0

catalog 是 pnpm 的版本目录,统一声明依赖版本,子包通过 catalog: 引用:

json
{
  "name": "@acme/ui",
  "version": "1.0.0",
  "private": false,
  "dependencies": {
    "vue": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:"
  }
}

应用引用内部包时使用 workspace 协议:

json
{
  "name": "@acme/web",
  "private": true,
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:*"
  }
}

workspace:* 在发布时会被替换成真实版本号。内部包不要手动写版本号再靠本地 link,容易出现版本错配。

pnpm 默认使用严格依赖结构,子包只能访问自己声明的依赖。这能有效避免 phantom dependencies(未声明却因为提升而可用),迁移时可能会暴露一批隐藏问题,属于好事。

Turborepo:任务编排与缓存 ​

Monorepo 中运行 build 时,必须按依赖关系排序:先构建 ui,再构建依赖它的 web。Turborepo 通过任务图自动处理:

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

几个关键字段:

  • dependsOn: ["^build"]:先执行所有依赖包的 build。
  • outputs:声明产物路径,缓存命中时直接恢复这些文件。
  • env:影响构建结果的环境变量。漏声明会导致缓存命中错误产物。
  • cache: false:开发服务器不能缓存。
  • persistent: true:长期运行的任务,Turborepo 不会等待它结束。

根 package.json:

json
{
  "name": "acme",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "^2.3.0"
  }
}

常用命令:

bash
# 只构建 web 及其依赖
pnpm turbo run build --filter=@acme/web

# 构建受本次改动影响的包
pnpm turbo run build --filter='...[origin/main]'

# 只运行某个包的脚本
pnpm --filter @acme/ui test

远程缓存:CI 提速的关键 ​

本地缓存只能在同一个开发者机器上复用。开启远程缓存后,CI 和团队成员可以共享构建产物:

bash
turbo login
turbo link

在 CI 中配置:

yaml
# .github/workflows/ci.yml
name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  verify:
    runs-on: ubuntu-latest
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run lint test build

远程缓存生效的前提是输入稳定:lockfile、源码、环境变量、依赖版本,任何一项变化都会让缓存失效。如果发现 CI 频繁不命中,先检查 env 和 globalEnv 是否遗漏。

版本管理与发布 ​

内部包如果只供本仓库使用,设置 private: true,不需要发布。需要对外发布时,使用 Changesets 管理版本和 changelog:

bash
pnpm add -Dw @changesets/cli
pnpm changeset
pnpm changeset version
pnpm changeset publish

每次改动由开发者描述变更类型(patch/minor/major),发布时自动计算版本并生成更新日志。比手动改版本号可靠得多。

发布前确保包已经构建。可以在 package.json 中使用 publishConfig 和 files 控制产物:

json
{
  "files": ["dist"],
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

常见坑 ​

  1. 没有依赖边界。utils 反向依赖某个应用,或包之间循环依赖,任务图会直接失效。
  2. 缓存输出声明不全。只写 dist/** 却漏掉 types/ 或静态资源,命中缓存后产物缺失。
  3. 环境变量未纳入缓存键。不同环境的构建结果互相污染,排查成本极高。
  4. 所有包都发版。内部包频繁发布没有意义,使用 private 和 Changesets 的 ignore 配置控制。
  5. CI 安装依赖不锁版本。使用 --frozen-lockfile,否则 CI 拉到的依赖可能与本地不一致。
  6. 忽略仓库体积。大文件、构建产物、缓存目录要加入 .gitignore,否则 clone 和 CI 会越来越慢。

小结 ​

Monorepo 的核心是统一依赖、统一任务、统一发布。pnpm 解决依赖结构和版本一致性,Turborepo 解决任务编排和缓存,Changesets 解决版本发布。工具之外,更重要的是明确的包边界和依赖规则,否则再好的工具也只能加速一个混乱的仓库。

基于 VitePress 构建 · 持续更新中