pnpm Monorepo 工程化实践:从多仓库泥潭到一条命令发布
pnpm Monorepo 工程化实践:从多仓库泥潭到一条命令发布
前言
当项目从 1 个仓库膨胀到 6 个(web、admin、mobile、ui、utils、sdk),跨仓库改动就成了灾难:改一个共享组件要发 3 次包、装 3 次依赖、走 3 次 CI。Monorepo 解决的正是这个问题,但配错了比多仓库更痛苦。本文给出一套能直接抄的 pnpm workspace + Turborepo + Changesets 组合拳。
一、为什么是 pnpm
三点硬差异:
- 省磁盘、省安装时间:包内容存在全局 store,项目里的
node_modules用硬链接指过去。十个项目共用一份 React,磁盘占用是 npm 的零头; - 依赖结构严格:npm/yarn 会把没声明的依赖提升到顶层,你
import lodash即使 package.json 里没写也能跑——这叫"幽灵依赖",一旦某个中间包不再依赖它,构建立刻炸。pnpm 的node_modules只暴露你声明过的包; - 天生为 workspace 设计:
workspace:协议直接软链本地包,改源码即时生效,不需要npm link那套玄学。
corepack enable && corepack prepare pnpm@9 --activate
根 package.json 里写 "packageManager": "pnpm@9.15.0",团队和 CI 才不会各装各的版本。
二、目录结构与 workspace 声明
repo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/
│ └── admin/
└── packages/
├── ui/
├── utils/
└── tsconfig/ # 共享配置包
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
catalog:
react: ^19.0.0
typescript: ^5.6.0
catalog(pnpm 9.5+)统一管理跨包版本,子包写 catalog: 即可,升级 React 只改一行:
// apps/web/package.json
{
"name": "@repo/web",
"dependencies": {
"@repo/ui": "workspace:*",
"react": "catalog:"
}
}
workspace:* 在发布时会被替换成真实版本号,本地开发则是符号链接——这是 Monorepo 的核心机制。
三、依赖管理的三个坑
- 根目录只放工程工具:eslint、prettier、turbo 放根
package.json;业务依赖放各子包。根目录什么都装,等于把所有包强行耦合; shamefully-hoist=true是止痛药不是解药:它能救活不兼容 pnpm 的老包,但会把幽灵依赖重新引回来,只在迁移期临时开;- peerDependencies 必须显式声明:
packages/ui依赖 React 时写进peerDependencies,否则最终产物可能打进两份 React,报出 “Invalid hook call”。
排查依赖去向:
pnpm why react --filter @repo/web
pnpm list --depth 0 -r
四、任务编排:别再手动 cd
pnpm -r build # 所有包按拓扑序构建
pnpm --filter @repo/web... build # web 及其全部上游依赖
pnpm --filter @repo/web dev # 只跑一个
... 后缀表示"包含依赖",是 Monorepo 里最常用的过滤器。
规模再大就该上 Turborepo,核心价值是缓存——同样的输入不重复执行:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"], "outputs": [] },
"lint": { "outputs": [] },
"dev": { "cache": false, "persistent": true }
}
}
^build:先构建依赖包再构建自己,拓扑序自动推导,不用手写顺序;outputs声明产物路径,命中缓存直接还原文件:冷启动 3 分钟,改一行样式后 4 秒;- CI 上配
TURBO_TOKEN走远程缓存,团队共用一份产物。
五、发布:用 Changesets 而不是手改版本号
pnpm add -Dw @changesets/cli
pnpm changeset # 交互式选受影响包 + 版本级别 + 变更说明
pnpm changeset version # 批量升版本、改 CHANGELOG、更新内部依赖范围
pnpm changeset publish # 发 npm 并打 tag
.changeset/config.json 里把 "updateInternalDependencies": "patch" 配上,内部的 workspace:* 才能被正确改写,避免发布出去的包还引用本地路径。
六、CI 上的三个提速点
| 手段 | 做法 | 典型效果 |
|---|---|---|
| 依赖缓存 | 缓存 pnpm store,而不是 node_modules |
安装 90s → 10s |
| 增量构建 | --filter=...[origin/main] 只跑受影响包 |
耗时随改动范围下降 |
| 并发任务 | Turborepo 默认并行跑无依赖关系的任务 | 吃满 CI 机器核数 |
pnpm --filter=...[origin/main] run test
注意缓存 key 要包含 lockfile 哈希,否则改了依赖还在用旧缓存,会排查到怀疑人生。
总结
Monorepo 的收益不在"少开几个窗口",而在原子化变更:一次 PR 同时改组件库和两个应用,CI 全量验证后一起上线。
落地顺序建议:pnpm workspace 跑通 → catalog 统一版本 → Turborepo 加缓存 → Changesets 管发布。每步单独验证,别一次性上全家桶——出问题时你分不清是谁的锅。
- 点赞
- 收藏
- 关注作者
评论(0)