创建日期:2026-09-23 | 最近更新:2026-09-23 实测环境:pnpm 11.26.0 + node v24.19.0(脚本
source/pnpm-monorepo-lab/run.sh,输出run.log)。 规范条款为 pnpm 官方文档逐字原文(抓取日期 2026-09-23,留档official-sources.md)。 ⚠️ 版本落差:pnpm.io 文档站标的是 Version: 12.x,本机是 11.26.0(pnpm install自己提示可升到 12.5.1); 文档里有版本标注的特性正文会注明"未实测"。
pnpm 管理 monorepo 入门
一句话:pnpm 的 monorepo 支持就两样东西——根目录一个
pnpm-workspace.yaml声明"哪些目录是包", 然后所有命令都能用--filter指向其中一部分。 剩下的(严格node_modules、全局 store、workspace:协议) 都是为了让"多包一块儿开发"这件事不出岔子。
先说清它解决的是什么问题。你有一个仓库、里面好几个包(app 依赖 utils、utils 依赖 core):
- 用 npm/yarn 的传统做法,要么每个包各自
npm install(同一份依赖装 N 遍、还容易版本漂移), 要么用 npm 的 workspaces(扁平化node_modules——好处是兼容旧代码,代价是下面说的"幽灵依赖"); - 而且你没有一条命令能"只给改动过的包跑测试"。monorepo 真正的日常痛点其实是这个。
pnpm 的路子是:一份全局内容寻址 store + 每个包只软链自己声明过的依赖。下面全部用实测说话。
1. 最小工作区:一个 yaml 就够
demo-monorepo/
├── pnpm-workspace.yaml ← 必需:声明"哪些目录是包"
├── package.json ← 根包(private: true)
└── packages/
├── core/ (@demo/core 依赖 ms)
├── utils/ (@demo/utils 依赖 @demo/core)
└── app/ (@demo/app 依赖 @demo/utils)
# pnpm-workspace.yaml
packages:
- 'packages/*'
官方原文:
【官方原文】"A workspace must have a
pnpm-workspace.yamlfile in its root." —— pnpm.io/workspaces
包与包之间怎么引用?写 workspace:*:
// packages/utils/package.json
{ "name": "@demo/utils", "version": "1.0.0",
"dependencies": { "@demo/core": "workspace:*" } }
就这样。pnpm install 之后 utils 里的 @demo/core 就是指向 packages/core 目录的软链接。
2. pnpm install 之后,node_modules 到底长什么样
这是 pnpm 最"反常识"的地方,也是最该亲眼看一次的地方。三层结构:
① 全局 store(在用户目录,全机器共用一份)
/home/sumshare/.local/share/pnpm/store/v11
② 虚拟 store(在工作区里,每个包的真实落点)
node_modules/.pnpm/ms@2.1.3/node_modules/ms/
③ 包自己的 node_modules(只有它【声明过】的依赖,全是软链)
packages/utils/node_modules/@demo/core -> ../../../core
【本人实测】
pnpm install自己的输出就是这么说的: "Packages are hard linked from the content-addressable store to the virtual store. Content-addressable store is at:…/store/v11Virtual store is at:node_modules/.pnpm"
「省磁盘」不是口号,是可验证的。 我 stat 了一下落进 store 的那个文件:
链接数=4 node_modules/.pnpm/ms@2.1.3/node_modules/ms/package.json
链接数 4 —— 大于 1 就说明它是指向 store 的硬链接(同一份磁盘数据被多个位置共用), 而不是被复制了四份。
同一时刻,根目录的 node_modules 里没有任何第三方包的平铺:
node_modules/
├── .modules.yaml
├── .package-map.json
├── .pnpm/ ← 真正的东西都在这里
└── .pnpm-workspace-state-v1.json
这一条是 pnpm 与 npm/yarn-classic 最大的观感差别:你只能
require到自己package.json里写过的东西。
3. 幽灵依赖:没声明过,就是用不了
"幽灵依赖"(phantom dependency)指的是:你的代码 require 了一个你没写在 package.json 里的包,
而这个包恰好因为别人的依赖被"平铺"到了上层 node_modules 里,于是它碰巧能跑——
直到某天那个间接依赖升级、不再带上它,你的代码就炸了,而且你根本不知道为什么。
pnpm 用严格布局把这个隐患变成立刻可见的错误。实测:app 只声明了 @demo/utils,偏偏去 require @demo/core:
require 失败 → MODULE_NOT_FOUND | Cannot find module '@demo/core'
换成声明过的依赖就正常:
build 结果: 约 2 minutes
【本人实测】npm 的扁平布局下,这种代码很可能一直能跑——所以它是"未来某天的故障",不是"今天的报错"。 pnpm 让它当场失败,等于把问题前移到开发期。这是我认为选用 pnpm 最实质的理由,比"省磁盘"重要得多。
4. workspace: 协议:唯一的正确写法
pnpm 也支持"用普通版本范围引本地包",但它自己说这有不确定性:
【官方原文】"If
linkWorkspacePackagesis set totrue, pnpm will link packages from the workspace if the available packages match the declared ranges. … This behavior introduces some uncertainty."
而 workspace: 协议是硬的:
【官方原文】"When this protocol is used, pnpm will refuse to resolve to anything other than a local workspace package. So, if you set
"foo": "workspace:2.0.0", this time installation will fail becausefoo@2.0.0isn't present in the workspace."
实测一下"写普通范围会怎样"(@demo/core 从未发布到 registry):
$ pnpm install
[ERR_PNPM_FETCH_404] GET https://registry.npmmirror.com/@demo%2Fcore: Not Found - 404
【本人实测】pnpm 没有贴心地回退到工作区里的
@demo/core——它老老实实去 registry 找,找不到就整仓装不上。 结论很直接:工作区内部依赖一律写workspace:*,别用普通版本范围。 (顺带印证了文档里linkWorkspacePackages默认行为——在 11.26.0 上,普通范围不会自动链接本地包。)
workspace: 还有两个实用变体:
| 写法 | 含义 |
|---|---|
"foo": "workspace:*" | 常用写法,本地链接 |
"bar": "workspace:foo@*" | 别名:把 foo 引进来但叫 bar |
"foo": "workspace:../foo" | 用相对路径引用 |
5. --filter:monorepo 的日常主力
这是你会天天用的东西。核心是四种方向(官方原文 + 实测结果):
| 选择器 | 选中的是 | 实测输出 |
|---|---|---|
pnpm -r run whoami | 全部包 | core → utils → app(按依赖顺序) |
--filter "@demo/app..." | 自己 + 全部依赖 | core → utils → app |
--filter "@demo/app^..." | 只要依赖,不含自己 | core → utils |
--filter "...@demo/core" | 自己 + 依赖它的(反向依赖) | core → utils → app |
--filter "!@demo/core" | 排除 core | utils → app |
记住那个记号很好记:省略号 ... 表示"顺着依赖走",尖角 ^ 表示"不含自己";写在前面就是反向走。
【本人实测】
-r(全量)那一次的执行顺序是core → utils → app——依赖在前、依赖者在后, 这正是"先构建被依赖的包"需要的顺序(文档也提醒:如果有循环依赖,拓扑顺序就不保证了, pnpm 会在安装时告警)。
其它几个高频开关:
pnpm --filter "@demo/*" run build # 按名字 glob(含 scope)
pnpm --filter "./packages/**" ... # 按目录 glob
pnpm --filter "{packages/**}" ... # {glob} 形式
pnpm --filter "...[origin/master]" test # ★ 自 master 起【改动过】的包 + 它们的反向依赖
pnpm --filter "!./lib" ... # 排除某目录
pnpm --filter "@demo/app" add left-pad # 只给 app 装依赖
pnpm --filter "@demo/app" exec -- node -v # 在包目录里执行命令
其中 "[<since>]" 是 CI 里最值钱的一条:只对改动过的包(及其依赖者)跑测试/构建。
配套还有 --test-pattern <glob>(改动的是测试文件时,不牵连反向依赖)和
--changed-files-ignore-pattern(忽略 **/README.md 这类改动)。想让它"没匹配到就报错",加 --fail-if-no-match。
⚠️ 一个容易踩的细节(官方原文):
{<dir>}现在按 glob 匹配而不是"整个子树", 而且 pnpm 自用的"workspace 根选择器"since v11.24.0 起总是按 glob 处理。老脚本迁移时留意。
6. catalog:一处定义版本,全仓引用
monorepo 的老问题:react 在 8 个 package.json 里各写一遍版本,升级时要改 8 处,还容易改漏。
【官方原文】"'Catalogs' are a workspace feature for defining dependency version ranges as reusable constants."
# pnpm-workspace.yaml
packages:
- 'packages/*'
catalog:
ms: ^2.1.3 # 定义一次
// packages/core/package.json
{ "dependencies": { "ms": "catalog:" } }
【本人实测】
catalog:正常解析:pnpm why ms输出ms@2.1.3 └── @demo/core@1.0.0 (dependencies)。
官方给的三条好处(逐字):保持版本唯一(重复依赖会在运行期冲突、打包后体积变大)、
升级只改一处、减少合并冲突(升级不再动 package.json)。
还可以用复数字段 catalogs: 定义多套具名 catalog(比如迁移期 catalog:react17 与 catalog:react18 并存)。
⚠️ 未实测:文档标注 "Workspace dependencies in a catalog — Added in: v12.2.0"(即 catalog 里放
workspace:^这种引用)。本机 11.26.0 早于该版本,正文不把它当既有能力。
7. 发布时 workspace:* 会被换成真实版本
本地是软链,那发布出去别人怎么装?pnpm 在打包/发布时动态替换:
【官方原文】"When a workspace package is packed into an archive (whether it's through
pnpm packor one of the publish commands likepnpm publish), we dynamically replace anyworkspace:dependency by: the corresponding version in the target workspace (if you useworkspace:,workspace:*,workspace:~, orworkspace:^)…"
实测验证:把 utils 打成包,看包内 package.json:
$ pnpm --filter "@demo/utils" pack
包:demo-utils-1.0.0.tgz
包内 package.json 的 dependencies:
"dependencies": {
"@demo/core": "1.0.0" ← 已经变成真实版本号
},
【本人实测】
workspace:*→1.0.0。所以本地用软链开发、发出去是普通 semver 依赖,两边都成立。
8. 版本管理:pnpm 不管,交给 Changesets
这是最容易误解的一点——pnpm 不提供"给包升版本、生成 CHANGELOG、发多个包"的方案:
【官方原文】"Versioning packages inside a workspace is a complex task and pnpm currently does not provide a built-in solution for it. However, there are 2 well tested tools that handle versioning and support pnpm: changesets, Rush."
所以标准的 monorepo 技术栈大致是:
pnpm(装依赖 + 跑脚本 + --filter)
├─ 版本与发布 → Changesets(或 Rush)
└─ 任务编排/缓存 → Turborepo / Nx(可选;--filter 已经能做"改动的包")
顺带一个佐证:官方列出的迁移到 pnpm workspace 的知名项目里,Next.js、Vite、Vue、Nuxt、Astro、 Prisma、Turborepo、Element Plus、Vitest、SvelteKit、Qwik 都在名单上——这不是小众玩法。
9. 常用命令速查
# 安装(CI 里锁死 lockfile)
pnpm install
pnpm install --frozen-lockfile # CI 必加:lockfile 与 package.json 不一致就失败
# 装依赖
pnpm add -w react # 装到【根】(-w = workspace root)
pnpm --filter "@demo/app" add lodash # 装到指定包
pnpm --filter "@demo/app" add -D vitest
# 跑脚本
pnpm -r run build # 所有包,按依赖顺序
pnpm --filter "...[origin/master]" test # 只跑改动过的包及其依赖者
pnpm -r --parallel run dev # 并行(注意:并行不等于按依赖顺序)
# 维护
pnpm why ms # 这个依赖是谁引进来的
pnpm outdated -r # 全仓看可升级项
pnpm dedupe # 合并可合并的版本
pnpm store prune # 清理全局 store 里没人用的包
pnpm exec / pnpm dlx # 跑本地 / 临时下载的 CLI
两个"第一次用会疑惑"的点:
-w是往根装。不加-w又在根目录执行pnpm add,pnpm 会提示你别往根装(根通常是 private 的)。-r是递归全量;--filter本身就隐含了"在这些包里执行",所以pnpm --filter X run build不必再写-r。
10. 什么时候不值得上 monorepo
客观讲一句:monorepo 不是默认选项。
| 情况 | 更简单的做法 |
|---|---|
| 就一个包 | 单仓库单包,别为了 workspace 而 workspace |
| 两个包、几乎不互相依赖、各自独立发布 | 分开两个仓库更省心 |
| 团队没有 CI 习惯、也不打算搞统一发布 | 上 monorepo 会先把"流程"的成本放大 |
| 包很多、构建很重 | 这才需要 Turborepo / Nx 这类 缓存 + 任务图 编排(pnpm 本身不做远程缓存) |
反过来,只要出现"多个包要一起改、一起测",pnpm workspace 的收益就立刻显现——
尤其是 --filter "...[origin/master]" 这条:它把"改动影响面"变成了可执行的命令。
11. 本文的局限
- 全部在本机、单进程实测:pnpm 11.26.0 + node 24.19.0,registry 走本机配置的镜像
(输出里可见
registry.npmmirror.com)——换版本/换环境数字会变; - 工作区只有 4 个包、1 个真实第三方依赖,安装耗时(1s)没有代表性,只说明机制;
- 实验 3 的"按依赖顺序"只观察了打印顺序,未测并发度与耗时(
--workspace-concurrency未涉及); - 未实测:
linkWorkspacePackages: deep、patchedDependencies、pnpm deploy、 具名/继承型 catalogs、v12.2.0 的 catalog 内workspace:引用、Changesets 与 Turbo/Nx 的集成; - 官方文档站为 12.x,本机为 11.26.0,涉及版本差异的地方已逐处标注。
关联
- 另一套生态里的同类工具(依赖解析 + 构建生命周期):Java 侧的 Maven、Gradle
- 另一个"多包共用一份东西"的问题域:Agent Skills 入门(渐进式披露与
--filter是同一类"按需用资源"的问题) - 数据文件格式那一侧:JSONL 是什么
- 实测脚本与逐字来源:仓库内
source/pnpm-monorepo-lab/(run.sh/run.log/README.md/official-sources.md)
自测
- 一个工作区至少要有什么文件?包之间互相引用应该写什么?
- pnpm 的三个
node_modules层次分别是什么?"省磁盘"怎么用一条命令验证? - 什么叫幽灵依赖?为什么说 pnpm 让它"提前暴露"而不是"变得不可能"?
workspace:*和普通版本范围(如^1.0.0)在 pnpm 下的实际差别是什么?实测里后者导致了什么?--filter "a..."、--filter "a^..."、--filter "...a"、--filter "...^a"各选中哪些包?- CI 里最该用的
--filter写法是什么?为什么? catalog解决什么问题?它和changesets是同一件事吗?workspace:*在pnpm publish之后会变成什么?为什么要这么设计?
参考
- pnpm · Workspace(
workspace:协议、发布替换规则、版本管理工具):pnpm.io/workspaces - pnpm · Filtering(
--filter全部选择器语法):pnpm.io/filtering - pnpm · Catalogs(
catalog:/catalogs:协议):pnpm.io/catalogs - 逐字引文留档与实测输出:
source/pnpm-monorepo-lab/official-sources.md、source/pnpm-monorepo-lab/run.log