Chapter 04

严格的 node_modules

pnpm 的 node_modules 和 npm 看起来都差不多,真相完全不同:顶层只有 package.json 显式声明的包,其它所有依赖藏在 .pnpm/ 虚拟仓库——这是幽灵依赖被彻底堵死的关键。

看看实际长什么样

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 它自己声明的包,这就是"严格"的含义。

兼容性问题的几个经典案例

webpack 老版本找不到 loader
某些 loader 期望出现在顶层 node_modules。v5+ 支持 symlink,但遇到老项目可能要 public-hoist-pattern 兜底。
ESLint 插件
ESLint 沿着 cwd 找插件。对 eslint-plugin-* 需要 hoist。.npmrc 里配 public-hoist-pattern[]=*eslint*
Next.js 的 standalone 输出
Next 把运行时依赖复制到 .next/standalone,symlink 要 outputFileTracingRoot 配好。Next 14+ 对 pnpm 友好。

本章小结