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/yarn 迁过来
按顺序做,跳步会把自己搞乱:
- 导入 lockfile ——
pnpm import会读package-lock.json生成pnpm-lock.yaml,保持已解析的版本不变,避免迁移顺带升级一堆依赖; - 清装一遍 —— 删掉
node_modules与旧 lockfile,重新pnpm install; - 修幻影依赖 —— 这时候报错会集中出现,逐个
pnpm add补声明,或者改掉用法。这一步的工作量要提前预留,它暴露的都是真实存在的技术债; - 改 CI —— 把
npm ci换成pnpm install --frozen-lockfile,缓存指向 store 而不是node_modules; - 检查特殊包 —— 有原生编译、补丁依赖、或依赖 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,治理幻影依赖时通常就在这几个开关之间取舍。