npm、yarn 和 pnpm 有什么区别?幽灵依赖是什么?
npm 和 Yarn Classic 把依赖提升到 node_modules 根目录,形成扁平结构,代码能用到没声明的包,这就是幽灵依赖。pnpm 把文件存在内容寻址的全局仓库,用硬链接装进项目,再用符号链接只把直接依赖放到根目录,所以省磁盘、依赖也更严格。Yarn Berry 默认用 PnP,不生成 node_modules。
简答
项目只装了
express,代码里却能直接require('debug'),而package.json里根本没写debug。这是因为debug是express的依赖,npm 把它提升到了node_modules根目录,Node.js 查找时顺着找到了它。这种没声明却能用的依赖就叫幽灵依赖,哪天express不再依赖debug,代码就跑不起来了。npm 从 v3 起默认把依赖尽量提升到顶层,Yarn Classic 也是这样,好处是减少重复安装,代价就是幽灵依赖。pnpm 换了一种结构:所有包的文件存在一个按内容寻址的全局仓库里,装进项目时用硬链接,不额外占磁盘;
node_modules根目录只放直接依赖的符号链接,真实文件在node_modules/.pnpm里,每个包只能找到自己声明过的依赖。Yarn 2 以后的版本,也就是 Yarn Berry,默认用 Plug'n'Play,不生成
node_modules,而是生成一个.pnp.cjs文件,告诉 Node.js 每个包在磁盘上的位置,同样能拦住幽灵依赖。要注意,pnpm 的符号链接结构偶尔会和不支持符号链接的工具冲突,这时可以把
nodeLinker设成hoisted,生成和 npm 类似的扁平结构,但也就失去了严格性。
扁平的 node_modules 是怎么来的
Node.js 解析 require('debug') 时,会从当前文件所在目录开始,逐级往上找 node_modules/debug。所以只要某个包被放在项目根目录的 node_modules 里,项目里任何文件都能找到它。
npm 文档描述了它的做法:为了减少重复,npm 从 v3 起默认把依赖安装到尽可能高的层级,这叫提升(hoisting)。假设项目依赖 A,A 依赖 B,提升以后目录是这样的:
node_modules
├── A
└── B ← 被提升到根目录
项目自己没声明 B,可是 B 就在根目录下,require('B') 能成功。pnpm 文档也指出,npm 和 Yarn Classic 把包提升到根目录,结果是源码能访问到没有声明为依赖的包。
问题出在这种“能用”不受控制。B 的版本由 A 决定,A 升级后换了 B 的版本或者不再依赖 B,项目代码就会在没改任何东西的情况下出错。另一种情况是,同一个包的两个版本只能有一个被提升,其他版本嵌套在各自依赖方的目录里,于是安装顺序不同,最终谁在根目录也可能不同。
pnpm 的目录长什么样
pnpm 文档用 foo@1.0.0 依赖 bar@1.0.0 作例子。装完后的结构大致是:
node_modules
├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar (文件硬链接到全局仓库)
└── foo@1.0.0
└── node_modules
├── foo (文件硬链接到全局仓库)
└── bar -> ../../bar@1.0.0/node_modules/bar
先看根目录:只有项目直接依赖的 foo,而且它只是一个指向 .pnpm 里真实位置的符号链接。项目代码 require('bar') 会失败,因为根目录下没有 bar,幽灵依赖在这里就暴露出来了。
那 foo 自己怎么找到 bar?Node.js 解析模块时会把符号链接换成真实路径,所以 foo 的代码实际位于 .pnpm/foo@1.0.0/node_modules/foo。从这里往上一级,就是 .pnpm/foo@1.0.0/node_modules,里面恰好放着 bar 的符号链接。pnpm 文档称这种结构和 Node.js 的模块解析算法完全兼容,而且依赖图再深,目录深度也不变。
文件本身从哪来?pnpm 维护一个内容寻址的全局仓库,也就是按文件内容来存储,同样内容的文件只存一份。.pnpm 里每个包的文件都是指向仓库的硬链接,文件系统不支持时会改用写时复制,两者都不支持才真正复制。所以 100 个项目用同一版本的依赖,磁盘上也只有一份;新版本只改了一个文件,仓库里也只新增这一个文件。
不过 pnpm 并不是一个包都不提升。生态里有不少包没在 package.json 里写全自己的依赖,为了减少报错,pnpm 默认把所有依赖提升到 node_modules/.pnpm/node_modules。这个位置只有 .pnpm 里的包往上查找时能找到,项目自己的代码找不到,可以用 hoist: false 关掉。
Yarn Plug'n'Play 是另一种思路
Yarn Berry 的 nodeLinker 设置有三个值:默认的 pnp 只生成一个 Node.js 加载文件;pnpm 用符号链接和硬链接到全局内容寻址仓库,和 pnpm 类似;node-modules 生成和 Yarn Classic、npm 一样的常规目录。
PnP 模式下项目里没有 node_modules,.pnp.cjs 记录了整棵依赖树和每个包在磁盘上的位置,由它来处理 require 和 import 的解析。Yarn 文档把幽灵依赖保护列为 PnP 的特点之一:包没声明的依赖,解析时就会报错。代价是有些工具默认直接读 node_modules,需要额外适配。
常见追问
怎样修复项目里的幽灵依赖?
把代码里直接用到的包都写进自己的
package.json。切换到 pnpm 往往是发现它们最快的办法,因为没声明的包会直接报找不到模块。如果是第三方包漏写了依赖,pnpm 可以用packageExtensions给它补上,Yarn 也有同名的设置。
pnpm 用硬链接,改了 node_modules 里的文件会怎样?
硬链接指向的是同一份文件内容,所以直接改
node_modules里依赖的文件,会影响全局仓库里的那份,进而影响其他用到它的项目。需要改依赖源码时应该用pnpm patch这类打补丁的方式。
lockfile 各家通用吗?
不通用。npm 用
package-lock.json,Yarn 用yarn.lock,pnpm 用pnpm-lock.yaml,一个项目应该只用一种包管理器并提交对应的锁文件。锁文件的作用见《为什么需要提交 lockfile?npm ci 和 npm install 有何区别?》,多包仓库的管理见《pnpm workspace 怎样管理多包前端仓库?》。