看看实际长什么样
pnpm add react react-dom express ls -la node_modules/
node_modules/ ├── .pnpm/ ← 所有包的真实家 │ ├── accepts@1.3.8/ │ ├── body-parser@1.20.3/ │ ├── debug@4.3.7/ │ ├── express@4.21.1/ │ ├── react@18.3.1/ │ ├── react-dom@18.3.1_react@18.3.1/ │ ├── scheduler@0.23.2/ │ └── ...(子依赖全都在这) ├── .modules.yaml ← pnpm 元数据 ├── express -> .pnpm/express@4.21.1/node_modules/express ├── react -> .pnpm/react@18.3.1/node_modules/react └── react-dom -> .pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom
顶层三个 symlink(指向 .pnpm 里的真实目录)——跟 package.json 的 dependencies 严格一一对应。
.pnpm 目录展开
.pnpm/react@18.3.1/
└── node_modules/
├── react/ ← 真实文件(硬链自 store)
│ ├── package.json
│ ├── index.js
│ └── ...
└── (空)
.pnpm/react-dom@18.3.1_react@18.3.1/
└── node_modules/
├── react-dom/ ← 真实文件
│ └── ...
└── react -> ../../react@18.3.1/node_modules/react ← symlink
每个包的 .pnpm/pkg@ver/node_modules/ 里放它自己 + 它的 direct 依赖(全是 symlink)。Node 的模块解析顺着 symlink 走,完全能找到。
为什么包 ID 有个 _
react-dom@18.3.1_react@18.3.1
^^^^^^
peer 依赖的具体版本
这是 pnpm 对 peer dependencies 的处理:同一个 react-dom,在不同 react 版本下视作不同「身份」——需要时并存,互不干扰。
.pnpm/
├── react-dom@18.3.1_react@18.3.1/ ← 配 react 18.3.1 时的副本
└── react-dom@18.3.1_react@18.2.0/ ← 配 react 18.2.0 时的副本
↑
内容相同,但 peer 树里的 react 版本不同
幽灵依赖:为什么被堵
// package.json 只声明了 react { "dependencies": { "react": "^18.0.0" } }
import _ from 'lodash'; // ❌ Cannot find module 'lodash' // 因为 node_modules 顶层就没 lodash 这个 symlink
lodash 即便是 React 的孙子依赖,也藏在 .pnpm/lodash@4.17.21/node_modules/lodash——Node 解析 'lodash' 会从 node_modules/lodash 找起,找不到就报错。显式声明是硬要求。
node-linker:三种布局模式
| 模式 | node_modules 形态 | 适用 |
|---|---|---|
isolated(默认) | .pnpm + symlink(严格) | 99% 项目 |
hoisted | 像 npm 一样扁平 | 部分老工具不兼容时 |
pnp | 无 node_modules,走 PnP | 激进方案,Yarn Berry 同款 |
// .npmrc node-linker=hoisted public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*prettier*
中和方案:保留 isolated,但对 ESLint/Prettier 这些「需要被顶层找到」的包做 public hoist——大多数情况不需要动。
shamefully-hoist
shamefully-hoist=true
全量扁平化,等同 npm/Yarn。名字里的 "shamefully"(羞耻地)体现了 pnpm 作者的态度:这是让步,不是推荐——只在旧项目/旧工具卡住时用。
.modules.yaml
hoistPattern: ['*'] hoistedDependencies: '@types/node@22.7.5': '@types/node': public included: { dependencies: true, devDependencies: true, optionalDependencies: true } injectedDeps: {} layoutVersion: 5 nodeLinker: isolated packageManager: pnpm@9.12.0 pendingBuilds: [] publicHoistPattern: [] registries: { default: 'https://registry.npmjs.org/' } skipped: [] storeDir: /Users/mi/.local/share/pnpm/store/v3 virtualStoreDir: node_modules/.pnpm
pnpm 的「状态文件」,记录当前 node_modules 是怎么装的。改了配置再 install 它会根据这里的差异决定要不要重建。
strict-peer-dependencies
strict-peer-dependencies=true
开了之后,peer 没满足直接报错(而不是警告)。推荐新项目开,旧项目关着免得装不上。
auto-install-peers
v8 起默认 true:遇到没装的 peer,自动补装(不写 package.json)。解决「react 要手动装」那种古老痛点。
dedupe-peer-dependents
v9 开关,默认 true。避免同一个 peer 被多次实例化——跟 React 要求「单例」匹配。关掉会生成更多重复节点。
看依赖链
pnpm why react # react 18.3.1 # ├── direct dependency # └─┬ next 15.0.0 # └── peer dependency pnpm ls --depth=-1 lodash # lodash 4.17.21 (from express → body-parser → ...)
injected(注入)依赖
Monorepo 场景:workspace 包之间用 symlink 引用。但有时你想把一个包真实复制,不共享 symlink——比如要独立 tsc 编译:
// pnpm-workspace.yaml packages: - 'packages/*' injectWorkspacePackages: true
或者在 package.json 用 injected:
{
"dependencies": {
"ui": "workspace:*"
},
"dependenciesMeta": {
"ui": { "injected": true }
}
}
模拟 Node 解析
// 某个业务代码 import 'react' // 1. 查 node_modules/react → symlink 到 .pnpm/react@18.3.1/node_modules/react // 2. 从那个目录 require('scheduler') → 查 .pnpm/react@18.3.1/node_modules/scheduler // → symlink 到 .pnpm/scheduler@0.23.2/node_modules/scheduler → OK
关键:Node 的模块解析沿着 symlink 走,pnpm 把「每个包能看到的依赖」通过 .pnpm 内部 symlink 精确控制——每个包只能 require 它自己声明的包,这就是"严格"的含义。
兼容性问题的几个经典案例
public-hoist-pattern 兜底。eslint-plugin-* 需要 hoist。.npmrc 里配 public-hoist-pattern[]=*eslint*。.next/standalone,symlink 要 outputFileTracingRoot 配好。Next 14+ 对 pnpm 友好。本章小结
- 顶层 node_modules 只有 package.json 显式声明的包,其余藏
.pnpm/ - 每个包只看到自己的 direct deps,peer 版本不同 = 独立副本(名字带
_) - Node 模块解析沿 symlink 走,幽灵依赖在文件系统层被堵死
node-linker:isolated(默认)、hoisted、pnp,按需选择- 兼容性问题用
public-hoist-pattern局部开洞,不要整包 shamefully-hoist