对等依赖 (peers) 是如何被处理的

pnpm 的最佳特性之一是,在一个项目中,软件包的特定版本将始终具有同一组依赖项。不过,这个规则 有一个例外 - 具有对等依赖 (peer dependencies)的软件包。

对等依赖会从依赖图中更高的已安装的依赖项中解析,因为它们与父级共享相同的版本。这意味着,如果 foo@1.0.0 有两个peers依赖(bar@^1 和 baz@^1),那么它可能在一个项目中有多个不同的依赖项集合。

- foo-parent-1
  - bar@1.0.0
  - baz@1.0.0
  - foo@1.0.0
- foo-parent-2
  - bar@1.0.0
  - baz@1.1.0
  - foo@1.0.0

在上面的示例中, foo@1.0.0 已安装在 foo-parent-1 和 foo-parent-2 中。这两个包都有依赖包 baz 和 bar, 但是它们却依赖着不同版本的 baz。因此, foo@1.0.0 有两组不同的依赖项:一组具有 baz@1.0.0 ,另一组具有 baz@1.1.0。若要支持这些用例,pnpm 必须有几组不同的依赖项,就去硬链接几次 foo@1.0.0。

通常,如果一个包没有对等依赖项,它会被硬链接到其依赖项的符号链接旁边的 node_modules 文件夹,如下所示:

node_modules
└── .pnpm
    ├── foo@1.0.0
    │   └── node_modules
    │       ├── foo
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── qux@1.0.0
    ├── plugh@1.0.0

但是,如果 foo 有对等依赖,那么它可能就会有多组依赖项,所以我们为不同的对等依赖项创建不同的解析:

node_modules
└── .pnpm
    ├── foo@1.0.0_bar@1.0.0+baz@1.0.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.0.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── foo@1.0.0_bar@1.0.0+baz@1.1.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.1.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── bar@1.0.0
    ├── baz@1.0.0
    ├── baz@1.1.0
    ├── qux@1.0.0
    ├── plugh@1.0.0

我们创建 foo@1.0.0_bar@1.0.0+baz@1.0.0 或foo@1.0.0_bar@1.0.0+baz@1.1.0 内到 foo 的软链接。因此,Node.js 模块解析器将找到正确的对等依赖。

如果某个包没有对等依赖,但存在依赖项,其含有在途中被更高解析的对等依赖,那么该传递包可以出现在具有不同依赖关系集的项目中。例如,a@1.0.0 具有单个依赖项 b@1.0.0。 b@1.0.0 有一个对等依赖为 c@^1。 a@1.0.0 永远不会解析b@1.0.0的 peer, 所以它也会依赖于 b@1.0.0 的对等。

该结构在 node_modules 中的样子如下。在这个例子中,a@1.0.0 需要在项目的node_modules 中出现两次 - 其中一次是被 c@1.0.0 解析,另一次被 c@1.1.0 再次解析。

node_modules
└── .pnpm
    ├── a@1.0.0_c@1.0.0
    │   └── node_modules
    │       ├── a
    │       └── b -> ../../b@1.0.0_c@1.0.0/node_modules/b
    ├── a@1.0.0_c@1.1.0
    │   └── node_modules
    │       ├── a
    │       └── b -> ../../b@1.0.0_c@1.1.0/node_modules/b
    ├── b@1.0.0_c@1.0.0
    │   └── node_modules
    │       ├── b
    │       └── c -> ../../c@1.0.0/node_modules/c
    ├── b@1.0.0_c@1.1.0
    │   └── node_modules
    │       ├── b
    │       └── c -> ../../c@1.1.0/node_modules/c
    ├── c@1.0.0
    ├── c@1.1.0

循环依赖#

添加于:v12.0.0-rc.5(仅 pnpm v12)

包之间可能以循环的方式相互依赖:a 依赖 b,而 b ——直接地,或通过更多的包——反过来又依赖 a。当这样一个循环中的某个包具有对等依赖时,并不存在一个「图中更高处」的单一位置来解析它们,因为在 a 处进入循环与在 b 处进入循环会得到不同的父级。

pnpm 会在固定的位置切断每个循环。构成循环的包会按其包 ID 排序,而闭合该循环的边总会在同一点被切断,无论安装过程从何处进入该循环。被切断的边仍然是一条真实的依赖 —— 它会按照其目标在项目层面的一次出现来解析 —— 但遍历永远不会绕着循环行进,因此循环内的一个包不再为每条到达它的路径各解析一次。循环内包的对等依赖仍会沿该顺序解析到最近的匹配项。

由于切断不再取决于安装所走的路径,锁文件仅由依赖图决定。重复执行安装、调换 pnpm-workspace.yaml 中 packages 通配符的顺序,以及调换 package.json 中条目的顺序,都会产生逐字节相同的锁文件。依赖图中含大量循环的项目最终得到的锁文件也更小,因为循环内的包不再为每条到达它的路径生成单独的对等变体。

已有的锁文件仍可继续使用:--frozen-lockfile 安装会原样使用它们,而跳过解析的安装则不会改动它们。首次重新解析的安装 —— 例如在依赖变更之后 —— 会一次性重新生成循环包的对等变体键值,这会表现为一次性的锁文件差异。

在 pnpm v11 中,切断取决于遍历依赖图的顺序,同样的依赖可能因项目与依赖的列出顺序不同而产生不同的锁文件。