Как происходит разрешение peer depencies
Одной из лучших особенностей pnpm является то, что в одном проекте конкретная версия пакета всегда будет иметь один набор зависимостей. There is one exception from this rule, though - packages with peer dependencies.
Peer-зависимости разрешаются из зависимостей, установленных выше в графе зависимостей, пока их версии совпадают с версиями родительских пакетов. Это означает, что если у foo@1.0.0 есть две peer-зависимости (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. Both packages have bar and baz as well, but they depend on different versions of baz. В результате foo@1.0.0 имеет два разных набора из зависимостей: один с baz@1.0.0, а другой с baz@1.1.0. Чтобы это продолжало нормально работать, pnpm создаёт жёсткие ссылки на пакет foo@1.0.0 столько раз, сколько он попадается в разных наборах зависимостей.
Обычно, если пакет не имеет peer dependencies, он связан жёсткой ссылкой с папкой 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 содержит peer dependencies, для него может быть несколько наборов зависимостей, поэтому мы создаем разные наборы зависимостей для отличающихся peer dependencies:
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 внутри foo@1.0.0_bar@1.0.0+baz@1.0.0, либо на foo@1.0.0_bar@1.0.0+baz@1.1.0. Как следствие, загрузчик модулей в Node.js найдет правильные peer dependencies.
If a package has no peer dependencies but has dependencies with peers that are resolved higher in the graph, then that transitive package can appear in the project with different sets of dependencies. For instance, there's package a@1.0.0 with a single dependency b@1.0.0. b@1.0.0 has a peer dependency c@^1. a@1.0.0 will never resolve the peers of b@1.0.0, so it becomes dependent from the peers of b@1.0.0 as well.
Here's how that structure will look in node_modules. In this example, a@1.0.0 will need to appear twice in the project's node_modules - resolved once with c@1.0.0 and again with 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
Cyclic dependencies#
Added in: v12.0.0-rc.5 (pnpm v12 only)
Packages may depend on each other in a cycle: a depends on b, and b — directly, or through further packages — depends back on a. When a package inside such a cycle has peer dependencies, there is no single point "higher in the graph" to resolve them from, because entering the cycle at a and entering it at b lead to different parents.
pnpm cuts every cycle at a fixed place. The packages that form a cycle are ordered by their package IDs, and the edges that close it are cut at the same point no matter where the installation walks into it. The cut edge is still a real dependency — it is resolved against an occurrence of its target taken at the project level — but the walk never travels around the cycle, so a package inside a cycle is no longer resolved once per path that reaches it. Peer dependencies of packages inside a cycle still resolve to the nearest match along that order.
Because the cut no longer depends on the path the installation takes, the lockfile is a function of the dependency graph alone. Repeated installs, reordered packages globs in pnpm-workspace.yaml, and reordered entries in package.json all produce a byte-identical lockfile. Projects with cycle-heavy dependency graphs also end up with a smaller lockfile, since packages inside a cycle no longer get a separate peer variant per path that reaches them.
Existing lockfiles keep working: --frozen-lockfile installs consume them unchanged, and installs that skip resolution leave them untouched. The first install that re-resolves — after a dependency change, for instance — re-keys the peer variants of cyclic packages once, which shows up as a one-time lockfile diff.
In pnpm v11, where the cut depends on the order the graph is walked, the same dependencies can produce different lockfiles depending on the order projects and dependencies are listed in.