pnpm: Exploring Core Linking and Dependency Management
Prerequisite Knowledge
本文是对 how pnpm links 文章的补充与扩展,原作者为 杨健。原文发表于 知乎 ([2023-02-25])。
本补充内容旨在基于原作者的工作提供额外视角、最新信息或实践经验,不代表对原文的批评或否定。所有原创观点已在原文中完整表达,本文仅作为其扩展阅读。
若原文作者认为本补充内容不当,请联系 SenaoXi 进行沟通或删除。
The specifications followed by project Vrite
vrite
项目始终保持 pnpm
的最严格状态,保持依赖间强约束力、保持依赖包的合规性、保持可预测的依赖管理行为。
背景
日常我们会经常碰到关于 pnpm
以及幻影依赖相关的问题,有些问题比较复杂,涉及到了 pnpm
背后的实现原理,因此本文展开讲一讲 pnpm
的 link
机制。
我们通常说 pnpm
的一大优点就是避免了幻影依赖,默认禁止了依赖库的 hoist
行为,但其实 pnpm
的 hoist
分为多种情况,同时 pnpm
禁止不同 hoist
行为采取的策略也有所不同。
接下来就结合 pnpm
的 link
策略来具体分析不同 hoist
的表现行为。
在讨论具体的 hoist
行为前,我们需要先区分两种代码:
- 一种为
application code
,即我们日常开发的业务代码。 - 一种为
vendor code
,即三方库的代码(包括直接和间接依赖的第三方库),通常经打包后发布。
这时候 hoist
的不同表现就体现在 vendor
和 application
的各种交互上。
原章节是建立在
pnpm@7
基础上(pnpm@6
和pnpm@7
存在一些差异),当前补充这篇文章时(2025-05-08
),pnpm
已经更新到了pnpm@10.10.0
,因此本章节内容建立在pnpm@10
基础上。
应用与依赖库之间的 hoist
行为 - public-hoist
public-hoist
就是我们日常提到的最常见的 hoist
行为,即应用代码能够访问未声明在应用的 package.json
模块中的 dependencies
/ devDependencies
/ optionalDependencies
字段(直接依赖)。
当我们配置 pnpm
的 node-linker
为 hoisted
时,那么所有依赖库都会被 hoist
到根目录的 node_modules
里,此时的 node_modules
结构与 npm
或 yarn classic
创建的 node_modules
结构一致。
这里虽然应用只依赖了 express
依赖库,但应用代码(src/index.js
)是可以自由的访问 debug
依赖库,这个正是因为间接依赖库 hoist
到根目录的 node_modules
里。
node_modules/
express
debug
cookie
... [63 packages]
src
index.js
package.json
{
"dependencies": {
"express": "4.18.1"
}
}
这看上去带来了一定的便捷性,但危害是巨大的,具体详见 phantom-deps 一文。
对于这类幻影依赖,pnpm
默认是 严格禁止 的,那么 pnpm
是如何做到禁止的呢?方法很简单,只要不将间接依赖库直接放置到项目 根目录 下的 node_modules
里即可。
观察 pnpm
的 node_modules
结构,我们看到 node_modules
里已经没有间接依赖库(例如 debug
、cookie
等),因此应用代码(src/index.js
) 自然无法直接访问到这些库。
node_modules/
express
.pnpm
src/index.js
但是因为 prettier
和 eslint
的相关设计缺陷,需要将其强依赖其相关的 plugin
存放在项目根目录的 node_modules
里,因此 pnpm
在 v10
版本之前默认并没有禁止所有的库的 hoist
行为,而是给 eslint
和 prettier
开了后门。
默认提升行为在 v10
版本中有所改变
pnpm
的 v10
版本默认情况下不会提升任何内容包含 eslint
或 prettier
以及相关命名的依赖包到 node_modules
的根目录,详情可见 Remove the default option eslint and prettier from public-hoist-pattern option in next major version,其中提议了从 public-hoist-pattern
配置项的默认值中移除 *eslint*
和 *prettier*
。
提议的核心问题:
pnpm
在先前版本中(pnpm@<10
)默认将 eslint
和 prettier
相关的包提升(hoist
)到 node_modules
的顶层。然而,随着 eslint 9
支持了 flat config
以及 prettier
插件可以通过 import()
表达式自行解析路径,这种默认提升行为不再是必需的,反而可能导致 node_modules
文件夹结构不一致和一些预料之外的问题。
ESLint 的演进 - Flat Config
ESLint
在其v9
版本中引入了新的配置文件系统,称为 flat config (通常是eslint.config.js
文件)。flat config
改变了ESLint
查找和加载插件及配置的方式。ESLint
本身能够更好地处理依赖解析,不再强烈依赖于包必须被提升到node_modules
顶层的特定结构。他倾向于让用户或插件自身明确指定依赖路径,或者通过标准的node.js
的require.resolve
或import()
机制来查找。Prettier 的演进 - Import
Prettier
的文档指出,其插件可以通过import()
表达式来加载,这意味着Prettier
可以动态地导入插件,无论这些插件位于node_modules
的哪个位置(只要它们可以被node.js
的模块解析算法找到)。因此,Prettier
也不再严格需要其插件被提升到node_modules
的顶层。
public-hoist
可以通过 publicHoistPattern
配置项来进行控制,现阶段默认值为 []
,意味着 pnpm
不会提升任何间接依赖包(包含 eslint
或 prettier
以及相关命名的依赖包)到应用的 node_modules
目录中,避免了应用中出现 意外的幻影依赖问题。用户可通过该配置项来有意控制哪些依赖包需要提升到应用的 node_modules
目录中,让 pnpm
的 public-hoist
行为更加可控、可预测。
public-hoist
问题似乎就这样迎刃而解了。但若应用直接依赖了 A
包和 B
包,但是 A
包和 B
包又同时依赖了同一个版本的 C
包,那么我们的 C
包该怎么处理呢?
最简单粗暴的处理方式是将 C
包放在 A
包和 B
包各自的 node_modules
里:
node_modules
A
node_modules
C
B
node_modules
C
若通过频繁复制 C
包的方式来解决,那么会占据大量的磁盘空间。同时由于 C
包的路径不一致,捆绑器无法复用 C
包,导致包体积变大,这也就是 npm@{1,2}
的默认行为。
pnpm
的解决方案是采用链接的方式来解决这个问题,通用操作系统支持软链接 (symlink
) 和硬链接 (hardlink
) 两种链接方式。
那么先来讲解一下 软链接 和 硬链接 的区别,举以下例子说明:
echo "111" > a
ln a b
ln -s a c
此时 a
、b
、c
的结果为
cat a --> 111
cat b --> 111
cat c --> 111
可知 a
、b
、c
的结果保持同步,若尝试删除 a
文件,此时可以观察到:
rm a
cat a --> No such file or directory
cat b --> 111
cat c --> No such file or directory
c
的内容一并被删除,但 b
的内容并不受到影响。有意思的是,若将 a
文件进行复原:
echo "222" > a
cat a --> 222
cat b --> 111
cat c --> 222
此时可以观察到 a
和 b
的内容不一致,但是 a
和 c
的内容一致,这反映了 hardlink
和 symlink
的一个重要区别:
- 删除文件并不会影响
hardlink
的内容,但是会影响symlink
的内容。 - 文件删除后再恢复内容,那么
hardlink
文件与原文件之间的关系将不再维持,后续原文件发生的所有变更不会同步到hardlink
文件里。而symlink
文件与原文件之间的关系仍然维持,后续原文件发生的所有变更会同步到symlink
文件里。
有上可知 hardlink
难以保证和原文件的一致性,会受到原文件删除的影响而导致原文件与 hardlink
文件之间的关系不再维持,后续原文件发生的所有变更不会同步到 hardlink
文件里,导致 watch
实效,也就意味着开发阶段 hmr
无法正常工作,后续内容还会对此进行额外补充说明。
hardlink
相比 symlink
还有一个限制就是 hardlink
指令并不能针对目录进行 hardlink
,而 symlink
是可以的,同时 hardlink
并不支持跨文件系统进行 hardlink
,而 symlink
是支持的。
inode 编号在单一文件系统内的唯一性,各个文件系统本质上是独立的命名空间和存储池。如果允许跨文件系统创建硬链接,不同文件系统上相同的 inode 号码会产生歧义,可能导致数据混淆。
另一个区别就是两者在 node
对于 hardlink
和 symlink
的 resolve
路径算法上存在巨大差异:
echo "console.log('resolve:', module.paths[0]);" >> a/index.js
ln a/index.js b/index.js
ln -s a/index.js c/index.js
三个目录的寻路算法:
node a/index.js --> a/node_modules
node b/index.js --> b/node_modules
node c/index.js --> a/node_modules
观察发现对于 hardlink
的文件,node
的 resolve
算法和原文件位置无关;而对于 symlink
文件,其 resolve
算法与源文件位置相关。捆绑器也遵循 node
的 resolve
算法,因此对于 hardlink
和 symlink
的文件寻路行为在 node
运行时和 bundler
阶段(resolveId
)均存在差异。
当然上述所提到的是工具的默认行为,是可以通过配置参数来改变的,例如 node
提供了 preserveSymlink
参数 (node-preserveSymlink
)、捆绑器也提供了类似 preserveSymlinks
参数 (rollup-preserveSymlink
、vite-preserveSymlinks
、typescript-preserveSymlinks
、webpack-symlinks
、node-preserveSymlinks
) 来改变 symlink
的路径解析行为。
当 preserve-symlinks = true
的时候,symlink
的计算路径就是基于当前 symlink
的路径而非原文件路径进行计算。
node --preserve-symlinks-main --preserve-symlinks c/index.js --> c/node_modules
但这个配置项需要谨慎使用,因为 preserve-symlinks
可能会导致目标库无法检索到,或者同一个库 resolve
到了不同的结果,从而破坏了 单例模式 和导致 bundle
了多份产物,导致包大小问题。
依赖库与依赖库之间的 hoist
行为 - hoist
依赖库和依赖库之间的 hoist
是指,一个三方库可以访问到不在其依赖里(dependencies
、optionalDependencies
、peerDependencies
)声明的其他三方库代码。这听起来有点不可思议,既然一个库依赖了某个库,那么理所当然应当将这个库放置在其依赖声明中,否则这个库肯定跑不起来。然而由于历史原因(例如 npm
的默认行为),仍然有大量的三方库,没遵守这个约定。
以 langium@3.3.1
为例,在其产物中直接依赖了 vscode-languageserver-types
、vscode-jsonrpc
、@chevrotain/regexp-to-ast
,但是这些依赖库却没有包含在 langium
的 dependencies
、optionalDependencies
、peerDependencies
(默认情况下 pnpm
会自动安装) 中。那么当项目中刚好也依赖了上述依赖库或项目中已安装的包中也依赖了上述依赖库,那么 langium
库将正常可以执行,但如果没有包含上述依赖库或者某一天整个依赖树中的库没有包含上述依赖库,那么 langium
将无法正常执行。
考虑到历史遗留问题(依赖库之间共享依赖),npm
包中还存在大量的这种库,pnpm
采取默认启用 hoist
模式做向后兼容 npm
的默认行为(依赖提升),意味着 pnpm
会将整个应用的依赖树中的所有依赖库额外软链到 node_modules/.pnpm/node_modules
,保证依赖库之间具有共享依赖的能力。
node_modules/
.pnpm/
node_modules/
a
b
a@1.0.0/
node_modules/
a [softlink -> ../../node_modules/a]
b@1.0.0/
node_modules/
b [softlink -> ../../node_modules/b]
index.js [require('a')]
你如果比较有追求,可以通过设置 hoist
为 false
,关闭三方库的 vendor
的 hoist
行为,此时 pnpm
就不会创建 node_modules/.pnpm/node_modules
目录来共享三方库的依赖。
pnpm 的不同级别拓扑结构
事实上 pnpm
支持四种级别的 node_modules
结构,从松到严依次为
hoisted
模式所有的依赖库都平铺在根目录的
node_modules
,这意味着应用能直接访问所有的间接依赖库,所有依赖库互相共享依赖库,这也是npm
的默认行为。bashshamefully-hoist=true
semi strict
模式这是
pnpm
的默认模式,应用仅能访问其依赖声明里(dependencies
、devDependencies
、optionalDependencies
)的依赖库,但是所有的依赖库之间依旧是共享依赖库的,可以通过hoist-pattern
配置项来控制共享的依赖库。bash; All packages are hoisted to node_modules/.pnpm/node_modules hoist-pattern[]=* ; All types are hoisted to the root in order to make TypeScript happy public-hoist-pattern[]=*types* ; All ESLint-related packages are hoisted to the root as well public-hoist-pattern[]=*eslint*
strict
模式这种情况下,
pnpm
既禁止应用访问依赖声明外的库,也禁止依赖库之间的共享访问。这个模式也是最推荐业务使用的模式。但是不幸的是,pnpm
出于对npm
包生态的兼容性考虑,默认情况下并非严格模式。但这是最佳实践,
vrite
项目现阶段也采取了严格模式,这可以保证你的业务不会突然有一天因为依赖的不可预测性而出现异常问题。bashhoist=false
pnp 模式
即使
pnpm
开了最严格的strict
模式,但不幸的是只能控制当前项目内的node_modules
的拓扑结构,项目外层的node_modules
并不受影响,因此仍然存在幻影依赖的风险(应用访问外层node_modules
的依赖库)。这个根因在于
node
的resolve
算法是递归向上检索的,因此若不修改node
默认resolve
算法是无法根除幻影依赖的。因此yarn
提出了具有pnp
特性的yarn pnp
模式,采取了修改node resolve
的默认行为来根除幻影依赖问题,但是其也会带来新的问题,此处就不再多赘述。
那么问题来了,由于 npm
包生态的复杂性,早期的 npm
包存在大量不遵循 semver
规范的包,这些包在 pnpm strict
以及 yarn pnp
模式下是无法正常运作的,那么需要采取措施来治理不规范包的依赖问题。
依赖修复方案
如果你的三方库的依赖存在问题,pnpm
提供了多种方式来对依赖进行修复,你可以根据自己的需求选择合适的依赖修复方案。
overrides
考虑一个场景,若应用依赖 A
包,A
包又依赖 B
包,但 B
包存在问题,需要通过升级版本来修复这个问题,而当前评估风险时不想升级依赖 A
来解决这个问题。那么通过 overrides
可以强行指定 B
的版本。
{
"pnpm": {
"overrides": {
"B": "15.0.0"
}
}
}
但是这带来了一个问题就是 pnpm
会将 workspace
子项目的 B
依赖包版本都统一成了 15.0.0
, 这有时候并不符合预期。因此可以采用下述方式来更精确控制依赖版本:
{
"pnpm": {
"overrides": {
"A@1>B": "15.0.0"
}
}
}
packageExtensions
另一个常见的场景是依赖包访问了未声明的依赖包,在严格模式下,pnpm
并不会提升依赖包到 node_module/.pnpm/node_modules
中作为共享依赖包,就会导致依赖包在运行时和构建阶段执行失败。
例如上述提到 langium
包缺失对于 vscode-languageserver-types
、vscode-jsonrpc
、@chevrotain/regexp-to-ast
的依赖包声明,是不合规的行为。那么此时可以通过 pnpm
的 packageExtensions
字段来额外追加缺失的依赖包。
{
"pnpm": {
"packageExtensions": {
"langium": {
"dependencies": {
"vscode-languageserver-types": "*",
"vscode-jsonrpc": "*",
"@chevrotain/regexp-to-ast": "*"
}
}
}
}
}
pnpm
并不会对 langium
包的 package.json
中 dependencies
字段进行修改,而仅是额外告知 pnpm
需要在分析 langium
包时,还存在 vscode-languageserver-types
、vscode-jsonrpc
、@chevrotain/regexp-to-ast
依赖包,需要额外下载。
.pnpmfile.cjs
上述提到的两个方案(overrides
和 packageExtensions
)对于处理简单的依赖修复场景是足够的,但若碰到比较复杂的依赖修复,例如需要借助复杂的逻辑来进行修复,那么通过 pnpm
的 hook 可以做到灵活控制。
如上面两种修复都可以基于 hook
来进行实现。
function readPackage(pkg) {
/**
* langium contains the following ghost dependencies,
* which attempt to access indirect dependencies (not declared in the dependencies of langium),
* this is not a compliant behavior, under strict pnpm specifications, this will result in errors.
*
* therefore, the following declarations for indirect dependencies are provided,
* and pnpm will independently download the required dependency packages when parsing langium.
*/
if (pkg.name && pkg.name.startsWith('langium')) {
pkg.dependencies = pkg.dependencies || {};
pkg.dependencies['vscode-languageserver-types'] = '*';
pkg.dependencies['vscode-jsonrpc'] = '*';
pkg.dependencies['@chevrotain/regexp-to-ast'] = '*';
}
if (pkg.name && pkg.name.startsWith('A') && pkg.version.startsWith('1')) {
pkg.dependencies = pkg.dependencies || {};
pkg.dependencies['B'] = '15.0.0';
}
return pkg;
}
module.exports = {
hooks: {
readPackage
}
};
如果遇到
readPackage
钩子没有全量执行的问题,请尝试运行emo i --fix-lockfile
。
npm alias
上述的修复是具有明确性的,在其他依赖包版本上已经被修复(升级包版本)或者依赖包缺失依赖声明(packageExtensions
、.pnpmfile.cjs
追加依赖)。
但还有一种场景是所有的依赖包版本都存在问题,单纯依赖 pnpm patch
来打补丁难以进行维护(大量项目需要同步 patch
、patch
中需要大量修改)。此时可能需要自行 fork
对应库的版本并进行修复,然后再进行发布。但由于没有依赖库的发版权限,因此通常需要换个库名发布,通过 npm alias
可以无感知的将异常依赖库的版本进行替换,从而解决依赖库的问题。
例如,下述 react-virtualized
依赖包存在问题,@byted-cg/react-virtualized-fixed-import@9.22.3
是修复了问题的版本,我们通过 npm alias
将 react-virtualized
替换为修复后的 react-virtualized-fixed-import
版本。无需对项目的其他部分做出改动,即可解决依赖库的问题。
{
"dependencies": {
"react-virtualized": "npm:@byted-cg/react-virtualized-fixed-import@9.22.3"
}
}
pnpm 的链接方式
传统 npm
的 node_modules
拓扑结构是难以精确控制 hoist
和 public-hoist
行为的。那么来看一下 pnpm
是如何实现精确的控制 public-hoist
和 hoist
行为。
{
"dependencies": {
"express": "4.18.1",
"koa": "2.13.4"
}
}
隐藏根目录的间接依赖库
首先为了解决 public-hoist
,pnpm
默认不会提升间接依赖库到根目录的 node_modules
里,而是将间接依赖库隐藏在根目录的 .pnpm
目录下,从而避免应用直接访问幽灵依赖库,即
node_modules
.pnpm
express@4.18.1
koa@2.13.4
accepts@1.3.8
array-flatten@1.1.1
express -> .pnpm/express@4.18.1/node_modules/express
koa -> .pnpm/koa@2.13.4/node_modules/koa
index.js
链接依赖库解决同一版本依赖重复问题
为了避免两个 vendor
里的同一版本依赖出现多次,我们需要将其进行链接到同一地方。如这里的 koa
和 express
使用了同一个版本的 accepts
,node_modules/koa
和 node_modules/express
分别是通过 node_modules/.pnpm/express@4.18.1/node_modules/express
和 .pnpm/koa@2.13.4/node_modules/koa
进行软链的。同时 express
包和 koa
包中对于 accepts
的依赖均是通过 node_modules/.pnpm/accepts@1.3.8/node_modules/accepts
进行软链。
node_modules
.pnpm
accepts@1.3.8
node_modules
accepts
array-flatten@1.1.1
node_modules
array-flatten
express@4.18.1
node_modules
accepts -> ../../accepts@1.3.8/node_modules/accepts
koa@2.13.4
node_modules
accepts -> ../../accepts@1.3.8/node_modules/accepts
koa
express -> .pnpm/express@4.18.1/node_modules/express
koa -> .pnpm/koa@2.13.4/node_modules/koa
这里有个很特别的设计,我们并不是将 koa
直接软链到 .pnpm/koa@2.13.4
,而是将其软链到 .pnpm/koa@2.13.4/node_modules/koa
里,为什么要这么设计呢?
出于如下几个原因:
保证
koa
可以将自身当作包依赖(self package
)。jsconst koa = require('koa');
避免循环
symlink
: 如a
依赖了b
,b
依赖了a
,那么如果都使用symlink
,那么将很容易出现循环symlink
。处理多
peerDependencies
问题:当存在多种版本的peerDependencies
的时候,我们必须保证我们能同时resolve
到不同的peerDependencies
的版本。
这里有个需要注意的地方就是 pnpm
对于库的存储是采取内容寻址存储(Content-Addressable Storage
),详情可参考 pnpm 的内容可寻址存储 一篇,里面详细介绍了 pnpm
的内容寻址存储策略。上述的 koa@2.13.4/node_modules/koa
是 pnpm
通过存储策略,从全局存储库中 reflink/hardlink
到项目中的 koa@2.13.4/node_modules/koa
目录的。正因有 reflink/hardlink
的存在,避免了出现 circular symlink
的情况。
实现跨项目的资源共享方式
pnpm
的另一大优势在于可以实现跨项目的内容共享,对于大部分人可能会熟知 pnpm
会采用 hardlink
来实现跨项目的资源共享,但是 pnpm
其实支持多种共享方式,可以通过 packageImportMethod
来配置。
auto
(默认值): 这是pnpm
的首选策略。它会优先尝试使用clone
(即 引用链接 / 写时复制)。如果文件系统不支持clone
,则尝试使用hardlink
(硬链接)。如果硬链接也失败(例如,尝试跨文件系统链接),最后会回退到copy
(普通文件复制)。默认设置旨在智能地选择当前环境下最优的可行选项。clone
(引用链接/写时复制): 这是 最快 且 最安全 的方法。他创建一个指向原始文件数据的引用。如果之后修改了项目node_modules
中的这个文件,文件系统会自动创建一个新的副本,而不会影响CAS
中的原始文件。这种方式既节省空间(初始时不复制数据),又保证了 隔离性。但它需要底层文件系统的支持(例如Btrfs
,APFS
, 以及支持reflink
的XFS
)。在速度、空间和安全性(隔离性)之间提供了最佳平衡,但依赖于现代文件系统。hardlink
(硬链接): 创建一个硬链接。这意味着项目node_modules
中的文件条目和CAS
中的文件条目指向磁盘上完全相同的物理数据块。这种方式在 空间效率上 非常高,因为不占用太多的额外空间(记录inode
信息)。但是,它的一个重要后果是,如果在项目node_modules
中直接修改了这个硬链接文件,也会同时修改CAS
中的原始文件,会无意中破坏CAS
,从而影响其他项目。硬链接要求源文件和链接目标必须在同一文件系统上。极度节省空间,但将项目与存储库紧密耦合,存在意外修改存储库文件的风险。copy
(复制): 执行标准的文件复制操作。这是磁盘空间和安装速度效率最低的方式,但它具有普遍适用性,即使跨文件系统也能工作。是万能的后备方案,但牺牲了pnpm
的主要优势(节省磁盘空间 和 安装速度)。clone-or-copy
: 优先尝试clone
,如果不支持则回退到copy
。
控制依赖包之间的 hoist
行为
前面讲了通过 pnpm
的 publicHoistPattern
配置项提升间接依赖包到根目录的 node_modules
中,使应用可以访问到间接依赖包。那么对于依赖包之间的 hoist
行为,如何控制呢?
答案很简单,将共享依赖包提升置 .pnpm/node_modules
目录下即可。
因为 .pnpm/node_modules
目录是所有 .pnpm/[[package@version]]/node_modules/[[package]]
的公共 node_modules
依赖链目录,所有的依赖包都能访问 .pnpm/node_modules/[[package]]
的共享依赖包,与此同时应用是无法访问到 .pnpm/node_modules
,因此若想让依赖库共享给其他依赖库使用,可以将共享依赖库链接到 .pnpm/node_modules
里即可。
如下面的 node_modules/.pnpm/node_modules/accepts
就可以被所有 node_modules/.pnpm/[[package@version]]/node_modules/[[package]]
包进行访问。
当然若不想作为共享依赖库,通过 hoistPattern
将共享依赖库从 node_modules/.pnpm/node_modules
中移除即可。
node_modules
.modules.yaml
.pnpm
accepts@1.3.8
node_modules
array-flatten@1.1.1
node_modules
node_modules
.bin
accepts -> ../accepts@1.3.8/node_modules/accepts
koa-compose -> ../koa-compose@4.1.0/node_modules/koa-compose
express -> .pnpm/express@4.18.1/node_modules/express
koa -> .pnpm/koa@2.13.4/node_modules/koa
Supplement of hoistPattern
feature
pnpm
的默认行为是 hoistPattern: ['*']
,即所有依赖库都会被提升到 node_modules/.pnpm/node_modules
目录下。需要注意的是 .pnpm/node_modules
目录中的包是具体版本的包,同一个包的多个版本只会选择一个版本(通常为最高的版本)提升到 .pnpm/node_modules
目录下。
这个特性主要是为了兼容早期 npm
生态包利用 npm
弱约束力(幽灵依赖),在构建产物中直接访问间接依赖包的问题。现代应用最佳实践配置 hoist
为 false
,不再需要 node_modules/.pnpm/node_modules
目录,避免构建产物中直接访问间接依赖包的问题。
处理 peerDependencies
前面我们已经很好的解决了 public-hoist
(针对应用和依赖库之间) 和 hoist
(针对依赖库与依赖库之间) 问题,但是还有一类更复杂的问题,就是 peerDependencies
的处理,peerDependencies
把本就很复杂的 resolve
逻辑无疑又提高到了一个新的高度。
peerDependencies
有两个鲜明的特征,严重影响了 resolve
的流程
若
foo
包使用peerDependencies
声明对等依赖foo-peer
包,也就是说foo-peer
包是由宿主依赖方来进行消费的。bashapp dependencies bar --> install foo-peer@1.0.0 devDependencies foo@1.0.0 peerDependencies foo-peer@1.0.0
bashapp --> install foo-peer@1.0.0 dependencies bar dependencies foo@1.0.0 peerDependencies foo-peer@1.0.0
如果
app1
依赖了foo@1.0.0
且app2
也依赖了foo@1.0.0
,那么即使两个app
都依赖了同一个foo
的版本,但由于foo@1.0.0
中存在peerDependencies
声明foo-peer
包,这个包是由宿主依赖方(app1
和app2
)来进行消费的。但由于app1
中依赖的foo-peer
版本与app2
中依赖的foo-peer
版本不同,导致resolve
到的foo@1.0.0
是两个不同的文件。json{ "dependencies": { "foo": "1.0.0", "foo-peer": "1.0.0" } }
json{ "dependencies": { "foo": "1.0.0", "foo-peer": "2.0.0" } }
bashnode_modules .pnpm foo@1.0.0_foo-peer@1.0.0 node_modules foo foo-peer -> ../../foo-peer@1.0.0/node_modules/foo-peer foo@1.0.0_foo-peer@2.0.0 node_modules foo foo-peer -> ../../foo-peer@2.0.0/node_modules/foo-peer foo-peer@1.0.0 node_modules foo-peer foo-peer@2.0.0 node_modules foo-peer packages app1 node_modules foo -> ../../../node_modules/.pnpm/foo@1.0.0_foo-peer@1.0.0/node_modules/foo foo-peer -> ../../../node_modules/.pnpm/foo@1.0.0_foo-peer@1.0.0/node_modules/foo-peer app2 node_modules foo -> ../../../node_modules/.pnpm/foo@1.0.0_foo-peer@2.0.0/node_modules/foo foo-peer -> ../../../node_modules/.pnpm/foo@1.0.0_foo-peer@2.0.0/node_modules/foo-peer
我们看到,
app1
和app2
都加载了同一foo
版本,但是其foo-peer
版本不同,此时在node_modules
中,app1
链接到了foo@1.0.0_foo-peer@1.0.0/node_modules/foo-peer
而app2
链接到了foo@1.0.0_foo-peer@2.0.0/node_modules/foo-peer
。若
pnpm
采用硬链接的策略,那么就会发现foo@1.0.0_foo-peer@1.0.0
和foo@1.0.0_foo-peer@2.0.0
的foo-peer
的inode
相同,也就意味着两个不同的硬链接指向同一个包,这显然是不符合预期的。pnpm
巧妙的通过hardlink
解决了peerDependencies
的多版本问题,但是带来了另一个问题,即peerDependencies
碎片化问题。
peerDependencies
的碎片化
我们看到因为 peerDependencies
的存在,导致了即使我们在项目中使用了同一个版本 foo
包,但 pnpm
为了确保 foo
能 resolve
到不同的 peerDependencies
版本,从而导致存在了多个 foo
分身,这是典型的 npm
分身问题(npm
分身),分身问题有哪些危害就不再赘述,最常见的就是会导致 重复打包 和 单例模式破坏。
如我们的 app
依赖了 app1
和 app2
,当我们对 app
进行打包,最终会发现同一版本的 foo
被打包多次。
情况更糟糕的是,peerDependencies
导致的分身问题具有传染性,不仅仅是 foo
会导致多重分身,foo
的所有父依赖都需要进行分身来进行兼容。
即使 pnpm
的处理策略,满足 peerDependencies
的语义,但是可能并不符合用户的实际语义,用户大多数情况下并不想要打包多份 foo
包,同时用户通常也能接受 peerDependencies
使用同一版本。因此对于这种场景下,可以通过维护 peerDependencies
保持同一版本即可将 foo
的包进行统一。
通过上述提到的 pnpm
的 hook
就可以容易的实现这个需求,当然更好的办法是手动修改 package.json
,保持所有的 peerDependencies
的版本拥有共用的最低版本,pnpm
会采取智能的方式选取满足条件的版本来减少重复包的使用。
function readPackage(pkg, context) {
if (pkg.dependencies && pkg.peerDependencies) {
if (pkg.dependencies['foo'] && pkg.dependencies['foo-peer']) {
pkg.dependencies['foo-peer'] = '1.0.0';
}
}
return pkg;
}
module.exports = {
hooks: {
readPackage
}
};
inject workspace
peerDependencies
的问题还不仅如此(在 pnpm
中),pnpm
有个独特的性质,即 workspace
和依赖库的 link
方式不同,通常情况下每个依赖库都有一个指向 pnpm
全局存储库的 hardlink/reflink
。
当存在多重 peerDependencies
的版本的情况下,会存在多个 hardlink/reflink
分身。但对于 workspace
来说,如果 app1
依赖了某个 workspace sdk
,那么这个 sdk
并不会创建 hardlink/reflink
,而是直接将 app1
的 node_modules
的 sdk
软链接到 sdk
。区别如下图所示
因为 workspace
没有使用 hardlink/reflink
,这进一步导致难以为 workspace
创建多重 hardlink/reflink
分身,因此 workspace
和依赖库处理 peerDependencies
的方式也略有不同。
使用软链给 workspace
带来的一个问题就是 peerDependencies
的查找问题,我们以一个常见的 react
组件库为例。
假设我们有三个 workspace packages
,分别是 form
包、card
包和 button
包:
form
包和card
包依赖button
包。form
包需要运行在react@17
版本下。card
包需要运行在react@^16
版本下。button
包同时支持react@16
包和react@17
包,并将react
作为peerDependencies
声明。
packages
form
node_modules
react [react@17.0.0]
button -> link ../../button/node_modules/button
index.jsx -> [workspace:button]
package.json
card
node_modules
react [react@^16.0.0]
button -> link ../../button/node_modules/button
index.jsx -> [workspace:button]
package.json
button
node_modules
index.jsx -> peer dependencies [react@*]
package.json
此时若打包 form
包,就会发现一个错误:
[ERROR] Could not resolve "react"
这是因为捆绑器的 默认解析路径 为软链接原始文件路径(preserveSymlink = false || symlink = true
),而 button
的 peerDependencies
是依赖宿主依赖方(form
和 card
)。换句话说 react
作为 button
的 peerDependencies
,是安装在 form
和 card
的 node_modules
里,而不是 button
或 [root]
的 node_modules
里,导致 button
无法找到 react
包。
PS: 若已经发布了
form
和card
包,那么就不会出现这个问题,因为通过pnpm
下载后是采用hardlink
链接的。
有以下两种方式来解决这个问题:
使用
preserveSymlink = true
来保证解析button
路径是在form
的node_modules
中,而非button
的node_modules
,这样可以保证引入form
里的react
,这种做法通常来说并不可靠,很容易导致依赖包之间关系不明确。bashpackages form node_modules react [react@17.0.0] button -> link ../../button/node_modules/button index.jsx -> workspace [button] package.json button node_modules index.jsx -> peer dependencies [react@*] package.json
基于
inject
实现,card
包和form
包是通过hardlink
的方式链接button
包,这样使得button
解析react
包的时候是处于宿主依赖方(card
和form
)的node_modules
中,就和发布出去的npm
包一样,这样button
就能正常打包。pnpm
支持通过dependencies
的injected
来调整其symlink
为hardlink
。
{
"dependenciesMeta": {
"button": {
"injected": true
}
}
}
但正如前面提到过,一旦对 hardlink
做了删除等操作,就会导致后续的 watch
失效。上述的 button
组件是通过 hardlink
的方式链接的,若 button
原文件发生删除操作,会导致 form
和 button
的 hardlink
断掉,当恢复原文件后对恢复文件的内容进行修改,这对于硬链接的文件来说是不可感知的,watch
无法检测到,导致 HMR
触发失效。这是十分常见的一个场景,特别是开发者在开发庞大的 monorepo
项目时,出于性能考虑,通常会直接通过链接及 watch
非活跃子项目 的 dist
目录(已打包),使得无需在开发阶段编译非活跃子项目源码,这大大提高开发效率。对于默认情况下的软链处理影响不大,但如果通过 hardlink
的方式链接的,那么就会出现上述问题。
这就要求在使用 hardlink
的时候,不要对被 link
的文件内容进行删除操作。另一方面 watcher
(如 chokidar
)可能对 hardlink
支持不太友好,也可能导致即使没删除文件的情况下,一些 watch events
信息丢失,详情见 watch-event-missing