Monorepo 概述与方案对比
Monorepo 的本质是用仓库结构换取协作效率:跨包改动能一个 PR 提交、公共代码零成本复用。 代价是工具链复杂度和仓库膨胀 —— 所以要先想清楚「你的痛点是不是多仓带来的」。
一、该不该用
| 你的情况 | 选 |
|---|---|
| 一个前端应用 + 一个后端 + 一个共享类型包 | ✅ 小型 Monorepo |
| 多个业务应用共享组件库、工具库 | ✅ 典型场景 |
| 每个包有独立团队、独立发布节奏、权限要求严格 | ⚠️ 多仓更合适 |
| 只有一个应用 | ❌ 单仓即可,别为拆而拆 |
优势 vs 劣势
| Monorepo | MultiRepo | |
|---|---|---|
| 代码复用 | ✅ 跨项目共享零成本 | ❌ 要发 npm 包、走版本 |
| 原子提交 | ✅ 跨包修改一个 PR | ❌ 多仓协同、顺序发布 |
| 依赖管理 | ✅ 公共依赖提升、省磁盘 | ❌ 重复安装 |
| 构建速度 | ❌ 全量构建慢(靠缓存解决) | ✅ 各自独立构建最快 |
| 权限控制 | ❌ 代码全可见 | ✅ 可精确到仓库 |
| 工具链成本 | ❌ 要配 workspace + 缓存 + 发布 | ✅ 简单直接 |
二、方案对比
| 方案 | 包管理 | 构建缓存 | 任务编排 | 适用 |
|---|---|---|---|---|
| pnpm workspace | pnpm | 无 | 无(只有 -r 批量) | 小型 Monorepo |
| Turborepo | 任意 | ✅ 本地 + 远程 | ✅ 依赖图 + 并行 | 中大型 |
| Nx | 任意 | ✅ 本地 + 远程 | ✅ 依赖图 + 插件生态 | 大型、多技术栈 |
| Rush | 内置 pnpm | ✅ | ✅ | 微软生态 |
| Lerna | 任意 | ❌(已不维护构建) | ✅ 批量发布 | 存量项目 |
建议:pnpm workspace 打底,需要缓存和任务编排就加 Turborepo。Nx 功能强但绑定深,小团队容易背不动。
三、pnpm workspace 最小配置
yaml
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'json
// apps/web/package.json —— 用 workspace: 协议引用内部包
{
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:^"
}
}// apps/web/package.json —— 用 workspace: 协议引用内部包
{
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:^"
}
}bash
pnpm -r build # 所有包依次构建
pnpm --filter @my/web dev # 只跑某个包
pnpm --filter @my/web... build # 该包及其依赖
pnpm --filter ...@my/ui build # 依赖该包的所有包pnpm -r build # 所有包依次构建
pnpm --filter @my/web dev # 只跑某个包
pnpm --filter @my/web... build # 该包及其依赖
pnpm --filter ...@my/ui build # 依赖该包的所有包workspace: 协议的价值:不经过 npm registry,直接软链到本地源码,改完立即生效。
四、Turborepo:任务管道与缓存
json
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // 先构建依赖包
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // 先构建依赖包
"outputs": ["dist/**"]
},
"test": { "dependsOn": ["build"] },
"lint": {},
"dev": { "cache": false, "persistent": true }
}
}bash
turbo run build --filter=web...turbo run build --filter=web...缓存的关键:Turbo 用「输入文件 + 环境变量 + 依赖图」的哈希做 key。同样的输入直接复用上次产物(命中会显示 FULL TURBO)。
| 缓存类型 | 说明 |
|---|---|
| 本地缓存 | 默认,存在 .turbo/cache |
| 远程缓存 | 团队共享(Vercel / 自建),CI 直接命中 |
要注意:环境变量变了必须体现在哈希里(env / globalEnv 配置),否则会拿到「用旧变量构建的产物」—— 这是最隐蔽的缓存坑。
五、版本与发布:changesets
bash
pnpm add -Dw @changesets/cli
pnpm changeset # 交互式选择改动的包与版本类型
pnpm changeset version # 按 changeset 升版本、写 CHANGELOG
pnpm changeset publish # 发布到 npmpnpm add -Dw @changesets/cli
pnpm changeset # 交互式选择改动的包与版本类型
pnpm changeset version # 按 changeset 升版本、写 CHANGELOG
pnpm changeset publish # 发布到 npm| 版本策略 | 说明 | 适用 |
|---|---|---|
| fixed(统一版本) | 所有包同版本号,一起发 | 强关联的包(如 React 生态) |
| independent(独立版本) | 各包按需升版 | 松耦合的工具库 |
六、四个常见坑
1. 幽灵依赖(Phantom Dependency)
# ❌ npm/yarn 的提升(hoist)让没声明的包也能 require
import lodash from 'lodash' // package.json 里根本没有它,但能跑
# ✅ pnpm 的严格 node_modules:没声明就解析不到# ❌ npm/yarn 的提升(hoist)让没声明的包也能 require
import lodash from 'lodash' // package.json 里根本没有它,但能跑
# ✅ pnpm 的严格 node_modules:没声明就解析不到解法:用 pnpm 的严格模式;真的需要提升时显式写进 public-hoist-pattern。
2. 循环依赖
packages/a 依赖 packages/b,b 又依赖 a —— 构建会死循环或产物不完整。
解法:把公共部分抽到第三个包(分层:utils ← ui ← apps)。
3. CI 全量构建
每次 PR 都构建所有包,CI 越来越慢。
解法:Turborepo 远程缓存 + --filter=...[origin/main] 只构建受影响的包。
4. 发布到 npm 时带上了 workspace 协议
"@my/ui": "workspace:*" 直接发布,外部用户装不上。
解法:用 pnpm publish(会自动替换 workspace 协议),或用 changesets 的 publish 流程。
七、选型决策树
包之间有共享代码吗?
├─ 没有 → 多仓,别硬凑
└─ 有
├─ 包数量 < 5,只在本地开发 → pnpm workspace 足够
├─ 需要「只构建受影响的包」+ 团队缓存 → 加 Turborepo
├─ 多技术栈(前端 + Node 服务 + 移动端)+ 强约束 → Nx
└─ 存量 Lerna 项目 → Lerna + pnpm,逐步迁 changesets包之间有共享代码吗?
├─ 没有 → 多仓,别硬凑
└─ 有
├─ 包数量 < 5,只在本地开发 → pnpm workspace 足够
├─ 需要「只构建受影响的包」+ 团队缓存 → 加 Turborepo
├─ 多技术栈(前端 + Node 服务 + 移动端)+ 强约束 → Nx
└─ 存量 Lerna 项目 → Lerna + pnpm,逐步迁 changesets八、速查
| 问题 | 答案 |
|---|---|
| 什么时候该用 Monorepo | 包之间有共享代码、需要原子提交 |
| 什么时候别用 | 单应用、团队间要强权限隔离 |
| 内部包怎么引用 | workspace:* 协议 |
| 只构建受影响的包 | Turbo 的 --filter=...[origin/main] |
| 幽灵依赖怎么来的 | npm/yarn 的 hoist;pnpm 严格模式可避免 |
| 版本统一还是独立 | 强关联用 fixed,松耦合用 independent |
| 最容易踩的缓存坑 | 环境变量没进哈希,拿到旧产物 |
Cao Note