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 --workspace3. 内部包的正确写法
内部包(如 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 --graph5. 共享配置统一管理
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 设为 bundler 或 nodenext。
相关阅读
如果你准备把内部包发布到 npm,推荐阅读 发布你的第一个 npm 包:从零开始的完整指南,涵盖 package.json 配置、构建打包和 CI 自动发布流程。