同一棵依赖树,为什么三个包管理器装出三种 node_modules

npm、pnpm、yarn 的分歧不在下载速度,而在把解析好的依赖树以什么形式落到磁盘:npm 默认把不冲突的依赖提升到顶层 node_modules,pnpm 用全局内容寻址仓库加硬链接与符号链接做严格隔离,Yarn Berry 干脆不生成 node_modules,改用一个 .pnp.cjs 加载器接管解析。布局不同,直接决定了幽灵依赖会不会被放过、同一版本会不会重复占盘、CI 能不能复现,以及安装脚本带来的供应链风险有多大。最常见的误区是把 pnpm 的快归功于算法,或者以为 lockfile 只锁版本号。读完你能按项目类型选对工具,也能在迁移炸掉时知道该调哪个开关。

是什么

包管理器其实只做三件事:把 package.json 里的 semver 范围解析成一棵确定的依赖树,把对应的压缩包取到本地,再把这棵树以某种磁盘布局摆好,让 Node 的 require 与 import 能找到它。前两步三家大同小异,真正的分歧在第三步——它们对"磁盘上的真相"做了不同选择。

一句话直觉:npm 相信"扁平化的目录本身就是解析结果",pnpm 相信"一份内容只存一次,目录只是索引",Yarn Berry 相信"解析结果应该写进一个加载器,而不是铺在磁盘上"。

典型场景:新项目选型、monorepo 里多个包共享依赖、CI 缓存怎么配,以及从 npm 迁到 pnpm 之后某些包突然报 MODULE_NOT_FOUND。

核心原理

第一步其实一样:lockfile 才是坐标

package.json 里写的是 ^4.17.0 这样的范围,它不构成可复现的构建。lockfile 才记录这次到底装了 4.17.21、从哪个地址下载、内容的 integrity 哈希是多少。所以"换包管理器"从来不是换一条命令,而是换一份 lockfile 格式:package-lock.json、pnpm-lock.yaml、yarn.lock 三者互不通用,pnpm 提供了 import 命令做一次性转换。没有 lockfile 的安装,等于每次都在重新抽签。

npm:用提升换路径长度

npm 3 起默认把不冲突的传递依赖提升到顶层 node_modules,只有版本冲突时才在依赖自己的目录下嵌套一份。好处很直接:路径短、重复少,Node 逐级向上的查找能命中大多数包。

代价是幽灵依赖(phantom dependency)。你的代码 require('ms'),但 package.json 里从没声明过它,只因为某个依赖带上了 ms 并被提升到顶层,它就能被解析到。在本机跑得好好的,换一个不提升的包管理器,或者上游把依赖换掉,立刻 MODULE_NOT_FOUND。npm 自己也知道这点,文档里给了 --install-strategy=linked 之类的实验选项,但默认仍是 hoisted。

pnpm:把"一份内容"和"一棵树"拆开

pnpm 先维护一个全局的、内容寻址的 store:文件按内容哈希存放,同一个包的同一个文件在整个磁盘上只留一份。安装时它做三件事(官方文档把这种布局称为符号链接结构):

  • 在 node_modules/.pnpm/<包名>@<版本>/node_modules/<包名>/ 里,放指向 store 的硬链接;
  • 根 node_modules/<包名> 只是指向上述目录的符号链接,而且只为 package.json 里直接声明的依赖创建;
  • 包与包之间的依赖同样用符号链接按真实依赖图连接。

于是项目根能看到的只有自己声明过的东西,幽灵依赖在根这一层直接失败。这里有个常被忽略的细节:pnpm 默认仍会把所有依赖 hoist 到 node_modules/.pnpm/node_modules,目的是兼容那些偷用未声明依赖的"坏包"。但项目根并不在那条查找路径上,所以它救的是依赖内部,不是你的代码。真要彻底关掉,把 hoist 设为 false。

硬链接不复制文件,所以第二次安装、或同一台机器上第二个项目装同一批依赖时,几乎不产生新的磁盘写入。跨文件系统(比如 store 放在另一块盘)或某些 Windows 场景下硬链接不可用,pnpm 会退回复制,这时"快"的优势就明显缩水——这也是"pnpm 有时并不快"的真实原因。

Yarn:Classic 与 Berry 是两种东西

Yarn 1(Classic)基本沿袭 npm 的扁平 node_modules 思路,差异主要在 lockfile 与命令细节。Yarn 2 之后的 Berry 默认走 PnP(Plug'n'Play):不再生成 node_modules,而是生成一个 .pnp.cjs 加载器(ESM 另配 .pnp.loader.mjs),里面写着哪个包名、哪个版本、对应压缩包里的哪个路径。运行时由 Yarn 注入这个加载器(直接跑 node 时需要自己加 --require ./.pnp.cjs),require 与 import 的解析被接管,包内容直接从 .yarn/cache 的 zip 里读出。

这套做法的收益很纯粹:缓存已热时,安装几乎只剩写一个 loader 文件,没有成千上万次复制或建链;依赖解析完全由 lockfile 决定,幽灵依赖在原理上不存在;把 .yarn/cache 提交进仓库,就是所谓的 zero-installs,克隆完即可运行。代价同样明确:任何绕过 Node resolver、直接扫 node_modules 的工具都会失灵——编辑器插件、部分 native 构建、React Native 这类工具链都要额外适配,Yarn 为此提供了 nodeLinker: node-modules 作为退路。

一段代码看清两种答案

// probe.js —— 在只声明了 debug 的项目里运行
const path = require('node:path');

for (const name of ['debug', 'ms']) {
  try {
    const file = require.resolve(name);
    console.log(name.padEnd(6), '->', path.relative(process.cwd(), file));
  } catch {
    console.log(name.padEnd(6), '-> MODULE_NOT_FOUND');
  }
}

debug 依赖 ms,但 ms 没有写进你的 package.json。npm 布局下 ms 被提升到顶层,第二行能打印出路径;pnpm 布局下根目录没有 ms 的链接,直接 MODULE_NOT_FOUND。这不是 bug,而是两种布局对"什么算合法依赖"给出的不同回答。

还有一层:peer 与安装脚本

peerDependencies 表达"我用你,但你得由宿主提供",典型是 React 插件。npm 7 起会自动安装 peer,解不出唯一解就抛 ERESOLVE;--legacy-peer-deps 是退回不自动安装的老行为,属于绕过而不是修复。pnpm 会按不同的 peer 组合生成多个实例,这也是 .pnpm 里同一版本可能出现多份的原因。

安装脚本是另一条暗线:依赖的 preinstall 与 postinstall 在安装时以你的权限执行,是供应链攻击的常用入口。npm 提供 --ignore-scripts;pnpm 10 起默认不执行依赖的构建脚本,必须在 onlyBuiltDependencies(新版为 allowBuilds)里显式放行。这会让需要编译的包直接失效,但把"默认信任"改成了"默认不信任"。

常见考点与误区

  • "pnpm 快是因为算法更聪明":错。解析算法三家都是图遍历,差别在落盘时复制多少文件。pnpm 靠硬链接复用 store,冷缓存首次安装仍要下载解压,并不神奇;跨盘退化成复制后,差距会大幅收窄。
  • "lockfile 只是锁版本号":不完整。它还锁定下载地址与 integrity 哈希,是校验"拿到的包是不是当初那个包"的依据。只锁版本不校验来源,镜像或上游被替换时照样中招。
  • "pnpm 的 node_modules 也是扁平的":错。根目录只有直接依赖的符号链接,真正的实体在 .pnpm 里;默认 hoist 到 .pnpm/node_modules 只对依赖内部的解析生效,项目代码并不能借此拿到未声明的包。
  • "yarn 就是 yarn 1":错得离谱。Classic 与 Berry 在布局、lockfile 格式、命令上都不兼容;--frozen-lockfile 是 Classic 的写法,Berry 用 --immutable,且 CI 上默认按不可变处理。
  • "npm ci 只是更干净的 npm install":它先删掉 node_modules,再严格按 lockfile 安装;package.json 与 lockfile 不一致时直接失败而不是顺手更新 lockfile,所以它可复现,也永远不会替你改锁文件。
  • "peer 冲突加上 --legacy-peer-deps 就修好了":那只是让 npm 别再自动装 peer,冲突本身还在。正确做法是升级插件、用 overrides 固定版本,或确认宿主版本确实兼容。

实践建议

  • 新项目优先 pnpm,但把 lockfile 一起提交。它省盘、隔离干净,monorepo 里收益最大;团队用 packageManager 字段配合 Corepack 锁死版本,避免三个人装出三种布局。
  • 写库要按最宽松的布局自测。库作者应该在 npm 的扁平 node_modules 下跑一遍测试,确认没有偷用传递依赖,再发布;只在自己偏好的包管理器里跑,等于把问题留给用户。
  • CI 缓存 store 而不是 node_modules。npm 缓存 ~/.npm/_cacache,pnpm 缓存全局 store,Yarn 缓存 .yarn/cache;node_modules 跨 Node 版本、跨平台极易失效,缓存命中率反而更低。安装命令用 npm ci、pnpm install --frozen-lockfile、yarn install --immutable。
  • 迁移时一次只改一个变量。先删 node_modules 但保留 lockfile,用 pnpm import 生成新锁文件,跑完整测试后再提交;直接删光重装,会把"锁文件里本来就没有的版本"重新解析一遍。
  • 兼容性开关是止痛药,不是默认配置。node-linker=hoisted、shamefully-hoist、nodeLinker: node-modules 都能让坏包跑起来,但也会把幽灵依赖放回来;用完记一笔技术债,等上游修好就撤掉。
  • 把安装脚本当成权限来管。CI 里加 --ignore-scripts,或只对确需编译的包开白名单;lockfile 的每次变动都值得看一眼,它比任何安全扫描都更接近真相。

一句话总结:三种包管理器的差别不在装得多快,而在它们让谁有权看见哪个依赖。

相关阅读

← 返回文章志