开发工具2026-03-25 13 分钟

Monorepo 实战:pnpm Workspace + Turborepo 构建高效多包项目

从项目结构设计到 Turborepo 缓存加速,手把手教你用 pnpm Workspace 搭建 Monorepo,告别多仓库依赖地狱。

之前我负责维护 5 个前端项目,它们共享同一套 UI 组件和工具函数。每次改个 Button 组件的样式,要去 5 个仓库分别更新、提 PR、发版本。有一次改了个 props 类型,其中两个项目忘了更新,直接线上报错。那之后我就下定决心搞 Monorepo,把所有项目放到一个仓库里统一管理。

说到 Monorepo 工具,Lerna 当年号称 Monorepo 标配,结果维护者跑了,社区一度陷入混乱。Yarn Workspaces 看起来不错,但 Yarn 自己从 v1 到 v2 再到 v3 就像换了三个工具。最终 pnpm + Turborepo 这套组合彻底征服了我——pnpm 的严格依赖解析天然适合 Monorepo,Turborepo 的增量构建又快到离谱。

1. 为什么需要 Monorepo?

当你的团队维护多个相关项目时,Multi-repo(每个项目一个仓库)的痛点会越来越明显:

Multi-repo 的痛点

  • • 共享代码靠 npm 包,改一行代码要走「修改 → 发版 → 更新依赖」的完整流程
  • • 跨项目的 breaking change 很难同步,总有项目忘了更新
  • • ESLint、TypeScript、Prettier 等配置在每个项目都要维护一份
  • • CI/CD 流水线重复配置,每个项目都要单独设置

Monorepo 的优势

  • • 共享代码直接引用,改了立刻生效,不用发版
  • • 原子化提交:一个 PR 同时改组件库和使用方,保证一致性
  • • 统一配置:ESLint/TypeScript/Prettier 配置只维护一份
  • • 依赖去重:相同的包只安装一次,node_modules 体积更小

2. pnpm Workspace 基础搭建

pnpm 天生就对 Monorepo 友好。它的严格依赖解析(不会像 npm/yarn 一样把依赖提升到根目录让你「意外」能用到未声明的包)是 Monorepo 场景下最安全的选择。

初始化项目

# 创建项目目录
mkdir my-monorepo && cd my-monorepo

# 初始化
pnpm init

# 创建 workspace 配置
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'apps/*'
  - 'packages/*'
EOF

目录结构

my-monorepo/
├── apps/
│   ├── web/              # 主站应用
│   │   └── package.json
│   └── admin/            # 管理后台
│       └── package.json
├── packages/
│   ├── ui/               # 共享 UI 组件
│   │   └── package.json
│   ├── utils/            # 共享工具函数
│   │   └── package.json
│   └── tsconfig/         # 共享 TypeScript 配置
│       └── package.json
├── pnpm-workspace.yaml
├── package.json
└── turbo.json

安装依赖的常用命令

# 安装所有 workspace 的依赖
pnpm install

# 给根目录安装开发依赖
pnpm add -Dw typescript eslint

# 给指定包安装依赖
pnpm add vue --filter @my/web

# 给所有包安装同一个依赖
pnpm add -r lodash-es

# 在 workspace 内引用另一个包
pnpm add @my/ui --filter @my/web --workspace

3. 内部包的正确写法

内部包(如 packages/ui)的 package.json 需要正确配置 exports 字段,这是让其他包能正确引用它的关键。

packages/ui/package.json

{
  "name": "@my/ui",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts"
    },
    "./*": {
      "types": "./src/*.ts",
      "import": "./src/*.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
    "lint": "eslint src/"
  }
}

开发阶段的小技巧:注意 exports 直接指向 src/index.ts 而不是 dist/。在开发阶段,消费方(如 apps/web)通过 Vite/Next.js 的 transpile 能力直接引用源码,不需要先构建。只有发布到 npm 时才需要指向构建产物。

4. Turborepo 加速构建

Monorepo 最大的挑战是构建速度——随着包数量增长,全量构建会越来越慢。Turborepo 通过任务编排、增量构建和缓存来解决这个问题。

turbo.json 配置

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

关键概念解读:

  • dependsOn: ["^build"]—— ^ 表示先构建依赖的上游包。比如 web 依赖 ui,那 web 的 build 会等 ui 的 build 完成后再开始。
  • outputs—— 声明构建产物的路径,Turborepo 会缓存这些文件。下次如果源码没变,直接从缓存恢复,秒级完成。
  • cache: false—— dev 任务不需要缓存,因为它是长时间运行的开发服务器。

常用命令

# 构建所有包
turbo build

# 只构建指定包及其依赖
turbo build --filter=@my/web

# 只构建有改动的包(基于 git diff)
turbo build --affected

# 并行启动所有 dev 服务器
turbo dev

# 查看任务执行图(调试依赖关系)
turbo build --graph

5. 共享配置统一管理

Monorepo 的一大优势就是配置复用。把 ESLint、TypeScript 等配置抽成独立包,所有项目引用同一份配置。

packages/tsconfig/base.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

apps/web/tsconfig.json(引用共享配置)

{
  "extends": "@my/tsconfig/base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

6. CI/CD 集成

Turborepo 和 CI 配合使用时,Remote Cache 是杀手锏——不同开发者和 CI 共享构建缓存,大幅减少重复构建时间。

.github/workflows/ci.yml

name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - 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 build lint test --affected

注意:CI 中一定要用 pnpm install --frozen-lockfile,确保安装的依赖和 lockfile 完全一致。如果 lockfile 和 package.json 不匹配会直接报错,避免「在我电脑上能跑」的问题。

7. 常见踩坑与解决方案

幽灵依赖(Phantom Dependencies)

npm/yarn 的 hoisting 会把子包的依赖提升到根目录,导致你能引用到没有在 package.json 中声明的包。pnpm 默认严格模式不会有这个问题,但如果你从 yarn 迁移过来,可能会发现一堆「找不到模块」的错误——这恰恰说明之前用了幽灵依赖。

解决:把缺失的依赖显式加到对应包的 package.json 里。

版本不一致

不同包依赖了同一个库的不同版本(比如 apps/web 用 vue@3.4 而 packages/ui 用 vue@3.5),可能导致运行时出现诡异的 bug。

解决:在根目录的 package.json 中使用 pnpm 的 pnpm.overrides 强制统一版本,或者使用 catalog: 协议集中管理版本。

TypeScript 找不到内部包的类型

消费方 import 内部包时 TypeScript 报「找不到类型声明」。通常是 package.json 的 exports 或 types 字段配置不正确。

解决:确保内部包的 exports 字段包含 types 条件,并且 TypeScript 的 moduleResolution 设为 bundlernodenext

相关阅读

如果你准备把内部包发布到 npm,推荐阅读 发布你的第一个 npm 包:从零开始的完整指南,涵盖 package.json 配置、构建打包和 CI 自动发布流程。