Skip to main content

pnpm 与幻影依赖治理

幻影依赖 = 代码里 import 了一个没写在 package.json 里的包。它之所以能跑,是因为 npm 的扁平化 hoist 把它提到了顶层。

一、幻影依赖为什么危险​

npm 会把依赖树「拍平」放进 node_modules 顶层,于是你 import 一个从未声明过的包也能解析成功——只要某个间接依赖恰好带上了它。

危害在于能不能跑纯属运气:

  • 换台机器、换个 Node 版本、安装顺序变一下,解析就可能失败;
  • 依赖升个小版本,那个间接依赖不再带它了,构建直接挂;
  • 它不在你的依赖树里,安全审计扫不到它。供应链问题恰恰最容易藏在这种地方。

还有一条更隐蔽的:你以为项目只依赖 A,实际上还依赖了 A 带来的 B。当哪天换掉 A,B 就跟着消失,而代码里到处在 import B。

// package.json 里没有 dayjs,但某个库间接带了它
import dayjs from 'dayjs' // 本地能跑,CI 上偶发失败

排查命令很直接:pnpm why dayjs 会告诉你到底是谁把它带进来的。

二、符号链接与严格 node_modules​

pnpm 用内容寻址存储 + 硬链接 + 符号链接组织 node_modules:

  • 包实体只存一份在全局 store,项目里用硬链接指向它(所以省磁盘,但省磁盘只是副产品);
  • 项目的 node_modules 里只有直接依赖的符号链接,间接依赖藏在 .pnpm 目录里,不再是扁平的一层。

结果就是:你没声明的包,代码里根本解析不到。 幻影依赖从「本地能跑、线上偶发」变成「装包时就报错」——问题在最早、最便宜的环节暴露。结构上,直接依赖是根 node_modules 下的符号链接,间接依赖收在 .pnpm/ 子目录里(形如 react -> .pnpm/react@19.0.0/node_modules/react),未声明的 dayjs 之类对外不可见。

npm:扁平铺平node_modules/ 里全铺开没声明的包也能 require 到哪天传递依赖断了,代码才报错pnpm:严格隔离顶层只有你声明的依赖间接依赖藏在 .pnpm/ 里幻影依赖当场暴露,而不是上线才炸
图:扁平与严格——pnpm 的价值是把隐式依赖变成显式报错

三、从 npm/yarn 迁过来​

按顺序做,跳步会把自己搞乱:

  1. 导入 lockfile —— pnpm import 会读 package-lock.json 生成 pnpm-lock.yaml,保持已解析的版本不变,避免迁移顺带升级一堆依赖;
  2. 清装一遍 —— 删掉 node_modules 与旧 lockfile,重新 pnpm install;
  3. 修幻影依赖 —— 这时候报错会集中出现,逐个 pnpm add 补声明,或者改掉用法。这一步的工作量要提前预留,它暴露的都是真实存在的技术债;
  4. 改 CI —— 把 npm ci 换成 pnpm install --frozen-lockfile,缓存指向 store 而不是 node_modules;
  5. 检查特殊包 —— 有原生编译、补丁依赖、或依赖 npm 内部结构的包需要单独验证。
# GitHub Actions 示例
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile

四、提升与构建脚本的开关​

固定包管理器版本。 在 package.json 里写 packageManager 字段(例如 pnpm@11.0.0),让 Corepack 或 CI 能校验——不要省略版本号或用 latest,大版本之间的行为差异足以让团队各自装出不同的结果。

生命周期脚本默认被拦截。 这是新版本最需要注意的行为变化:依赖的 postinstall 之类的脚本默认不执行,需要显式批准。sharp、esbuild、canvas 这类要编译原生二进制的包会受影响——安装能成功,但脚本没跑,用的时候才发现不对。

pnpm approve-builds   # 交互式选择允许的构建脚本
# pnpm-workspace.yaml:pnpm 11 起改用 allowBuilds,键是包名、值是是否放行
allowBuilds:
sharp: true
esbuild: true

这是刻意的安全取舍:把供应链风险从「默认执行」改成「默认不执行」。代价是上手时多一步操作。

hoist 要克制。 有些工具假设依赖被提升到根目录(部分 ESLint 插件、@types/*),可以用 public-hoist-pattern 定向放开:

# pnpm-workspace.yaml
# pnpm 11 起 .npmrc 只保留 registry 与鉴权,pnpm 专有配置要写在这里
publicHoistPattern:
- '*eslint*'
- '@types/*'

shamefully-hoist=true 应当作为最后手段——它把整个树按 npm 的方式铺平,等于放弃了 pnpm 的核心价值,幻影依赖问题会原样回来。

peer 依赖与 catalogs。 依赖声明 peer 不完整时,可以用 packageExtensions 在配置里补,不改上游也不用 fork:

# pnpm-workspace.yaml
packageExtensions:
some-broken-pkg@*:
peerDependencies:
react: '*'

大仓库想统一版本,可以用 catalogs——在 pnpm-workspace.yaml 里集中声明版本,各包用 catalog: 引用,避免同一个包装出五个版本。

五、什么时候该开,什么时候别折腾​

判断其实只有两条,跟项目大小关系不大:

  • 多人协作、或多个包共享依赖 —— 值得开。这种规模下幻影依赖几乎必然存在,早暴露早修,比上线后偶发崩溃便宜得多。
  • 单人维护的单包项目 —— 收益有限,严格结构带来的额外配置(提升规则、补 peer 声明)可能比省下的那点时间还多。

另外三类情况建议先别切:依赖里有原生编译包且维护者不活跃、项目重度依赖 patch、构建链里有假设 npm 扁平结构的工具。这三类的迁移成本会明显高于预期,等有了明确替代方案再动。

hoist 相关配置的作用见 pnpm:npmrc,治理幻影依赖时通常就在这几个开关之间取舍。