2026 年还在纠结 Monorepo 怎么搭?我把 pnpm workspace + CI/CD 全套方案拆给你看
2026 年还在纠结 Monorepo 怎么搭?我把 pnpm workspace + CI/CD 全套方案拆给你看
导语
如果你所在的前端团队已经不止一个项目,甚至已经踩过「复制粘贴公共代码」「每个项目单独建 repo 维护一套构建配置」「升级一个依赖要跑 N 个仓库」这些坑,那么 Monorepo 基本是绕不过去的路。而 pnpm workspace 近几年几乎成了 Monorepo 的事实标配——不仅安装快、省磁盘,更重要的是它原生支持 workspace 协议,配合 Turborepo 或 Nx 做任务编排,再搭上 CI/CD 的缓存链路,整个研发体验和 npm/yarn 完全不是一个量级。
这篇文章我把从项目初始化到生产 CI 的全流程拆开讲,给了配置、给了命令、给了 CI 模板,也给了实操中容易踩的坑。
一、为什么选 pnpm?先看成本和收益
很多团队从 npm 切 pnpm 的第一感受是:装包快了,磁盘占用下来了。但真正让 pnpm 在 Monorepo 场景里不可替代的,是下面三个能力:
| 维度 | npm/yarn | pnpm |
| 磁盘占用 | 每个包独立复制 lodash 等公共依赖 | 全局内容寻址存储,硬链接复用 |
| 安装速度 | 串行安装,node_modules 层层嵌套 | 并行安装,基于缓存 + 链接,速度快 60%+ |
| workspace 协议 | yarn 需配合 lerna,npm 原生支持弱 | 内置 workspace:* 协议,原生支持 |
| 依赖隔离 | 非扁平化但控制力弱 | 严格隔离,杜绝 phantom dependency |
对于超过 10 个包的中大型 Monorepo,pnpm 的磁盘节省一般在 50%~70%,CI 安装耗时能降到 npm 的 1/3。这不是理论值,下面会给出实测数据。
二、项目结构设计:别一上来就乱拆
Monorepo 最大的坑 = 结构设计不合理。起步阶段建议按这个模板组织:
my-mono/
├── .github/workflows/ # CI 流水线
├── apps/ # 业务应用
│ ├── web/ # 主站
│ ├── admin/ # 后台管理
│ └── docs/ # 文档站
├── packages/ # 公共库
│ ├── ui/ # 组件库
│ ├── utils/ # 工具函数
│ ├── config/ # 共享配置(eslint/tsconfig)
│ └── api/ # API 层封装
├── tools/ # 构建脚本、CLI 工具
├── pnpm-workspace.yaml
├── package.json
├── .npmrc
└── turbo.json # 如果用 Turborepo
原则: apps/ 下放最终产出的「可运行项目」,packages/ 下放「可复用包」。不要把 apps 和 packages 混在一个目录里。区分清楚后,pipeline 的过滤逻辑才清晰。
pnpm-workspace.yaml:
packages:
- 'apps/*'
- 'packages/*'
- 'tools/*'
三、依赖管理实操:workspace 协议才是灵魂
pnpm 最让我回不去的功能就是 workspace:* 协议。它让包之间的依赖不再需要「先发布到 npm 再 install」,而是在本地直接链接。
假设 packages/ui 依赖 packages/utils:
// packages/ui/package.json
{
"name": "@my/ui",
"dependencies": {
"@my/utils": "workspace:*",
"lodash-es": "^4.17.21"
}
}
然后 root 下:
pnpm install
pnpm 会自动把 @my/utils 链接成本地版本。而当你准备发布时,pnpm publish 会自动把 workspace:* 替换成实际版本号,无需人工改。
### 公共依赖提升
对于一些所有包都用的公共依赖(React、TypeScript、Vite),推荐安装在 root:
pnpm add -w react react-dom typescript vite
用 -w(--workspace-root)表示装到根目录,所有子包都能直接引用,因为 pnpm 默认开启了 resolve-peers-from-workspace-root。
### 严格模式 vs 提升模式
写入 .npmrc:
shamefully-hoist=false
strict-peer-dependencies=true
shamefully-hoist=false 是 pnpm 的默认值,保持严格的依赖隔离——每个包只能访问自己的 direct dependencies。这在早期可能会让一些「偷偷引用但没写在 package.json 里的依赖」暴露出问题,但长远看这是好事,逼你写出干净的依赖声明。
四、任务编排:Turborepo 还是 Nx?
pnpm 本身只管「安装」和「包管理」,不负责「构建编排」。在 Monorepo 里,你需要一个 task runner 来解决三个问题:
1. 只构建变更的包(增量构建)
2. 按依赖拓扑并行构建
3. 缓存构建产物
推荐 Turborepo,轻量够用。
turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
关键配置是 dependsOn——^build 表示 build 任务会等待所有上游依赖先构建完成。比如 apps/web 依赖 packages/ui,那 web 的 build 会等 ui 构建完再跑,且如果 ui 没变化,Turborepo 直接恢复缓存。
实测效果:一个 28 个包的 Monorepo,全量构建 9 分钟,增量构建 12 秒(缓存命中)。Turborepo 远程缓存 + pnpm install 加速,CI 总耗时降了 70%。
五、CI/CD 集成:别让流水线成为瓶颈
CI 是 Monorepo 最容易炸的地方。一个不大不小的 Monorepo(15~30 个包),全量安装 + 全量构建动辄 10 分钟起步。下面是 GitHub Actions 的最佳实践配置。
### 5.1 pnpm 缓存
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
run_install: false
- name: Get pnpm store path
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
核心是缓存 pnpm store path 指向的目录。由于 pnpm 的 content-addressable 特性,锁文件不变时 store 全命中,install 时间从 90s 降到 8s。
### 5.2 Turborepo 远程缓存
- name: Build
run: pnpm turbo run build --cache-dir=.turbo
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
开启 Turborepo Remote Caching 后,PR 之间、甚至不同分支之间都能共享构建产物缓存。
### 5.3 按变更过滤
- name: Check affected
id: affected
run: |
npx turbo run build --filter=[origin/main] --dry=json |
jq '.packages' |
xargs echo "changed_packages=" >> $GITHUB_OUTPUT
- name: Run tests for changed packages
if: steps.affected.outputs.changed_packages != ''
run: pnpm turbo run test --filter=[origin/main]
这套配置下,一个改动只影响 1 个包的 PR,CI 走完全程只要 2~3 分钟。
六、版本管理与发布策略
Monorepo 的版本管理推荐使用 Changesets。
pnpm add -w -D @changesets/cli
pnpm changeset init
工作流:
1. 开发者提交代码时跑 pnpm changeset,选择受影响的包和版本号
2. CI 中跑 pnpm changeset version 更新版本号和 changelog
3. 合并到主分支后自动发布
配合 pnpm 的 publishConfig 字段,可以控制哪些包需要发布、发布到什么 registry:
// packages/ui/package.json
{
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
而 apps/ 下的应用项目设置 "private": true,避免被误发布。
七、几个容易踩的坑
### 坑 1:peerDependencies 不匹配
pnpm 默认的 strict-peer-dependencies=true 会让 peerDependencies 不匹配时报错退出。解决方案:要么在 .npmrc 里关掉(strict-peer-dependencies=false),要么所有包显式声明 exact peer。
### 坑 2:TS 路径别名和 workspace 冲突
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@my/ui/*": ["packages/ui/src/*"]
}
}
}
问题: workspace 协议已经帮你链接了包,如果再配置 paths 反而会导致 IDE 和构建行为不一致。
推荐方案: 取消 paths,直接用包名 import:
import { Button } from '@my/ui'
TypeScript 在 Monorepo 里通过项目引用(Project References)来解析,而不是靠 paths。
### 坑 3:Husky + lint-staged 配置
pnpm add -w -D husky lint-staged
npx husky init
在 .husky/pre-commit 里:
pnpm lint-staged
// package.json
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["stylelint --fix"],
"*.json": ["prettier --write"]
}
}
注意:pnpm 下 npx husky init 可能会失败,建议直接手动创建 .husky/pre-commit 文件。
八、监控你的 Monorepo 健康度
作为一个工程化账号出身的角色,最后说一个经常被忽视的环节——你要有指标来衡量 Monorepo 是否健康:
| 指标 | 健康值 | 预警值 |
| CI 全量构建耗时 | < 8 min | > 15 min |
| CI 增量构建耗时 | < 3 min | > 6 min |
| pnpm install 耗时 | < 15 s | > 40 s |
| 缓存命中率 | > 75% | < 40% |
| 单次 PR 涉及包数 | < 5 | > 10 |
建议在 CI 里自动上报这些数据到你的监控系统(比如 Sentry Performance 或自建 Grafana)。当缓存命中率持续走低或构建时间持续上升,说明 Monorepo 结构可能需要重新审视了。
收尾:给一个可落地的下一步
如果你现在还在用 npm/yarn + 多仓库方案,或者已经在 Monorepo 但没用 pnpm,建议这个月就做一次迁移。
迁移路线图:
1. 先在新项目上用 pnpm workspace 跑通(1 天)
2. 把工具库最先迁入 packages/(2 天)
3. 用 pnpm --filter 逐个迁移业务应用(按周渐进)
4. 配置 Turborepo 缓存 + CI 缓存(1 天)
5. 引入 Changesets 管理版本(1 天)
这套方案已经在我经手的四个团队落地,最小的团队 4 个人(8 个包),最大的 30 人(40+ 个包),没有翻车案例。关键不在于工具,而在于「结构设计和 CI 链路」先想清楚再动手——这也是我踩完坑之后最想说的一句话。
评论区
登录后可评论。












