跳到主要内容

创建日期: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.yaml file 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/v11 Virtual 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 linkWorkspacePackages is set to true, 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 because foo@2.0.0 isn'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"排除 coreutils → 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 pack or one of the publish commands like pnpm publish), we dynamically replace any workspace: dependency by: the corresponding version in the target workspace (if you use workspace:, workspace:*, workspace:~, or workspace:^)…"

实测验证:把 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)

自测​

  1. 一个工作区至少要有什么文件?包之间互相引用应该写什么?
  2. pnpm 的三个 node_modules 层次分别是什么?"省磁盘"怎么用一条命令验证?
  3. 什么叫幽灵依赖?为什么说 pnpm 让它"提前暴露"而不是"变得不可能"?
  4. workspace:* 和普通版本范围(如 ^1.0.0)在 pnpm 下的实际差别是什么?实测里后者导致了什么?
  5. --filter "a..."、--filter "a^..."、--filter "...a"、--filter "...^a" 各选中哪些包?
  6. CI 里最该用的 --filter 写法是什么?为什么?
  7. catalog 解决什么问题?它和 changesets 是同一件事吗?
  8. 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