前端知库

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,提升以后目录是这样的:

text
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 作例子。装完后的结构大致是:

text
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 怎样管理多包前端仓库?》。