Skip to content

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 背后的实现原理,因此本文展开讲一讲 pnpmlink 机制。

我们通常说 pnpm 的一大优点就是避免了幻影依赖,默认禁止了依赖库的 hoist 行为,但其实 pnpmhoist 分为多种情况,同时 pnpm 禁止不同 hoist 行为采取的策略也有所不同。

接下来就结合 pnpmlink 策略来具体分析不同 hoist 的表现行为。

在讨论具体的 hoist 行为前,我们需要先区分两种代码:

  • 一种为 application code,即我们日常开发的业务代码。
  • 一种为 vendor code,即三方库的代码(包括直接和间接依赖的第三方库),通常经打包后发布。

这时候 hoist 的不同表现就体现在 vendorapplication 的各种交互上。

原章节是建立在 pnpm@7 基础上(pnpm@6pnpm@7 存在一些差异),当前补充这篇文章时(2025-05-08),pnpm 已经更新到了 pnpm@10.10.0,因此本章节内容建立在 pnpm@10 基础上。

应用与依赖库之间的 hoist 行为 - public-hoist

public-hoist 就是我们日常提到的最常见的 hoist 行为,即应用代码能够访问未声明在应用的 package.json 模块中的 dependencies / devDependencies / optionalDependencies 字段(直接依赖)。

当我们配置 pnpmnode-linkerhoisted 时,那么所有依赖库都会被 hoist 到根目录的 node_modules 里,此时的 node_modules 结构与 npmyarn classic 创建的 node_modules 结构一致。

这里虽然应用只依赖了 express 依赖库,但应用代码(src/index.js)是可以自由的访问 debug 依赖库,这个正是因为间接依赖库 hoist 到根目录的 node_modules 里。

npm/yarn classic 的 node_modules 结构
bash
node_modules/
  express
  debug
  cookie
  ... [63 packages]
src
  index.js
package.json
package.json
json
{
  "dependencies": {
    "express": "4.18.1"
  }
}

这看上去带来了一定的便捷性,但危害是巨大的,具体详见 phantom-deps 一文。

对于这类幻影依赖,pnpm 默认是 严格禁止 的,那么 pnpm 是如何做到禁止的呢?方法很简单,只要不将间接依赖库直接放置到项目 根目录 下的 node_modules 里即可。

观察 pnpmnode_modules 结构,我们看到 node_modules 里已经没有间接依赖库(例如 debugcookie 等),因此应用代码(src/index.js) 自然无法直接访问到这些库。

pnpm 的 node_modules 结构
bash
node_modules/
  express
  .pnpm
src/index.js

但是因为 prettiereslint 的相关设计缺陷,需要将其强依赖其相关的 plugin 存放在项目根目录的 node_modules 里,因此 pnpmv10 版本之前默认并没有禁止所有的库的 hoist 行为,而是给 eslintprettier 开了后门。

默认提升行为在 v10 版本中有所改变

pnpmv10 版本默认情况下不会提升任何内容包含 eslintprettier 以及相关命名的依赖包到 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)默认将 eslintprettier 相关的包提升(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.jsrequire.resolveimport() 机制来查找。

  • Prettier 的演进 - Import

    Prettier 的文档指出,其插件可以通过 import() 表达式来加载,这意味着 Prettier 可以动态地导入插件,无论这些插件位于 node_modules 的哪个位置(只要它们可以被 node.js 的模块解析算法找到)。因此,Prettier 也不再严格需要其插件被提升到 node_modules 的顶层。

public-hoist 可以通过 publicHoistPattern 配置项来进行控制,现阶段默认值为 [],意味着 pnpm 不会提升任何间接依赖包(包含 eslintprettier 以及相关命名的依赖包)到应用的 node_modules 目录中,避免了应用中出现 意外的幻影依赖问题。用户可通过该配置项来有意控制哪些依赖包需要提升到应用的 node_modules 目录中,让 pnpmpublic-hoist 行为更加可控、可预测。

public-hoist 问题似乎就这样迎刃而解了。但若应用直接依赖了 A 包和 B 包,但是 A 包和 B 包又同时依赖了同一个版本的 C 包,那么我们的 C 包该怎么处理呢?

最简单粗暴的处理方式是将 C 包放在 A 包和 B 包各自的 node_modules 里:

目录结构
bash
node_modules
  A
    node_modules
      C
  B
    node_modules
      C

若通过频繁复制 C 包的方式来解决,那么会占据大量的磁盘空间。同时由于 C 包的路径不一致,捆绑器无法复用 C 包,导致包体积变大,这也就是 npm@{1,2} 的默认行为。

pnpm 的解决方案是采用链接的方式来解决这个问题,通用操作系统支持软链接 (symlink) 和硬链接 (hardlink) 两种链接方式。

那么先来讲解一下 软链接硬链接 的区别,举以下例子说明:

bash
echo "111" > a
ln a b
ln -s a c

此时 abc 的结果为

bash
cat a --> 111
cat b --> 111
cat c --> 111

可知 abc 的结果保持同步,若尝试删除 a 文件,此时可以观察到:

bash
rm a
cat a --> No such file or directory
cat b --> 111
cat c --> No such file or directory

c 的内容一并被删除,但 b 的内容并不受到影响。有意思的是,若将 a 文件进行复原:

bash
echo "222" > a
cat a --> 222
cat b --> 111
cat c --> 222

此时可以观察到 ab 的内容不一致,但是 ac 的内容一致,这反映了 hardlinksymlink 的一个重要区别:

  • 删除文件并不会影响 hardlink 的内容,但是会影响 symlink 的内容。
  • 文件删除后再恢复内容,那么 hardlink 文件与原文件之间的关系将不再维持,后续原文件发生的所有变更不会同步到 hardlink 文件里。而 symlink 文件与原文件之间的关系仍然维持,后续原文件发生的所有变更会同步到 symlink 文件里。

有上可知 hardlink 难以保证和原文件的一致性,会受到原文件删除的影响而导致原文件与 hardlink 文件之间的关系不再维持,后续原文件发生的所有变更不会同步到 hardlink 文件里,导致 watch 实效,也就意味着开发阶段 hmr 无法正常工作,后续内容还会对此进行额外补充说明。

hardlink 相比 symlink 还有一个限制就是 hardlink 指令并不能针对目录进行 hardlink,而 symlink 是可以的,同时 hardlink 并不支持跨文件系统进行 hardlink,而 symlink 是支持的。

inode 编号在单一文件系统内的唯一性,各个文件系统本质上是独立的命名空间和存储池。如果允许跨文件系统创建硬链接,不同文件系统上相同的 inode 号码会产生歧义,可能导致数据混淆。

另一个区别就是两者在 node 对于 hardlinksymlinkresolve 路径算法上存在巨大差异:

bash
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

三个目录的寻路算法:

bash
node a/index.js --> a/node_modules
node b/index.js --> b/node_modules
node c/index.js --> a/node_modules

观察发现对于 hardlink 的文件,noderesolve 算法和原文件位置无关;而对于 symlink 文件,其 resolve 算法与源文件位置相关。捆绑器也遵循 noderesolve 算法,因此对于 hardlinksymlink 的文件寻路行为在 node 运行时和 bundler 阶段(resolveId)均存在差异。

当然上述所提到的是工具的默认行为,是可以通过配置参数来改变的,例如 node 提供了 preserveSymlink 参数 (node-preserveSymlink)、捆绑器也提供了类似 preserveSymlinks 参数 (rollup-preserveSymlinkvite-preserveSymlinkstypescript-preserveSymlinkswebpack-symlinksnode-preserveSymlinks) 来改变 symlink 的路径解析行为。

preserve-symlinks = true 的时候,symlink 的计算路径就是基于当前 symlink 的路径而非原文件路径进行计算。

bash
node --preserve-symlinks-main --preserve-symlinks c/index.js --> c/node_modules

但这个配置项需要谨慎使用,因为 preserve-symlinks 可能会导致目标库无法检索到,或者同一个库 resolve 到了不同的结果,从而破坏了 单例模式 和导致 bundle 了多份产物,导致包大小问题。

依赖库与依赖库之间的 hoist 行为 - hoist

依赖库和依赖库之间的 hoist 是指,一个三方库可以访问到不在其依赖里(dependenciesoptionalDependenciespeerDependencies)声明的其他三方库代码。这听起来有点不可思议,既然一个库依赖了某个库,那么理所当然应当将这个库放置在其依赖声明中,否则这个库肯定跑不起来。然而由于历史原因(例如 npm 的默认行为),仍然有大量的三方库,没遵守这个约定。

langium@3.3.1 为例,在其产物中直接依赖了 vscode-languageserver-typesvscode-jsonrpc@chevrotain/regexp-to-ast,但是这些依赖库却没有包含在 langiumdependenciesoptionalDependenciespeerDependencies(默认情况下 pnpm 会自动安装) 中。那么当项目中刚好也依赖了上述依赖库或项目中已安装的包中也依赖了上述依赖库,那么 langium 库将正常可以执行,但如果没有包含上述依赖库或者某一天整个依赖树中的库没有包含上述依赖库,那么 langium 将无法正常执行。

考虑到历史遗留问题(依赖库之间共享依赖),npm 包中还存在大量的这种库,pnpm 采取默认启用 hoist 模式做向后兼容 npm 的默认行为(依赖提升),意味着 pnpm 会将整个应用的依赖树中的所有依赖库额外软链到 node_modules/.pnpm/node_modules,保证依赖库之间具有共享依赖的能力。

bash
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')]

你如果比较有追求,可以通过设置 hoistfalse,关闭三方库的 vendorhoist 行为,此时 pnpm 就不会创建 node_modules/.pnpm/node_modules 目录来共享三方库的依赖。

pnpm 的不同级别拓扑结构

事实上 pnpm 支持四种级别的 node_modules 结构,从松到严依次为

  • hoisted 模式

    所有的依赖库都平铺在根目录的 node_modules,这意味着应用能直接访问所有的间接依赖库,所有依赖库互相共享依赖库,这也是 npm 的默认行为。

    bash
    shamefully-hoist=true
  • semi strict 模式

    这是 pnpm 的默认模式,应用仅能访问其依赖声明里(dependenciesdevDependenciesoptionalDependencies)的依赖库,但是所有的依赖库之间依旧是共享依赖库的,可以通过 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 项目现阶段也采取了严格模式,这可以保证你的业务不会突然有一天因为依赖的不可预测性而出现异常问题。

    bash
    hoist=false
  • pnp 模式

    即使 pnpm 开了最严格的 strict 模式,但不幸的是只能控制当前项目内的 node_modules 的拓扑结构,项目外层的 node_modules 并不受影响,因此仍然存在幻影依赖的风险(应用访问外层 node_modules 的依赖库)。

    这个根因在于 noderesolve 算法是递归向上检索的,因此若不修改 node 默认 resolve 算法是无法根除幻影依赖的。因此 yarn 提出了具有 pnp 特性的 yarn pnp 模式,采取了修改 node resolve 的默认行为来根除幻影依赖问题,但是其也会带来新的问题,此处就不再多赘述。

那么问题来了,由于 npm 包生态的复杂性,早期的 npm 包存在大量不遵循 semver 规范的包,这些包在 pnpm strict 以及 yarn pnp 模式下是无法正常运作的,那么需要采取措施来治理不规范包的依赖问题。

依赖修复方案

如果你的三方库的依赖存在问题,pnpm 提供了多种方式来对依赖进行修复,你可以根据自己的需求选择合适的依赖修复方案。

overrides

考虑一个场景,若应用依赖 A 包,A 包又依赖 B 包,但 B 包存在问题,需要通过升级版本来修复这个问题,而当前评估风险时不想升级依赖 A 来解决这个问题。那么通过 overrides 可以强行指定 B 的版本。

package.json
json
{
  "pnpm": {
    "overrides": {
      "B": "15.0.0"
    }
  }
}

但是这带来了一个问题就是 pnpm 会将 workspace 子项目的 B 依赖包版本都统一成了 15.0.0, 这有时候并不符合预期。因此可以采用下述方式来更精确控制依赖版本:

package.json
json
{
  "pnpm": {
    "overrides": {
      "A@1>B": "15.0.0"
    }
  }
}

packageExtensions

另一个常见的场景是依赖包访问了未声明的依赖包,在严格模式下,pnpm 并不会提升依赖包到 node_module/.pnpm/node_modules 中作为共享依赖包,就会导致依赖包在运行时和构建阶段执行失败。

例如上述提到 langium 包缺失对于 vscode-languageserver-typesvscode-jsonrpc@chevrotain/regexp-to-ast 的依赖包声明,是不合规的行为。那么此时可以通过 pnpmpackageExtensions 字段来额外追加缺失的依赖包。

package.json
json
{
  "pnpm": {
    "packageExtensions": {
      "langium": {
        "dependencies": {
          "vscode-languageserver-types": "*",
          "vscode-jsonrpc": "*",
          "@chevrotain/regexp-to-ast": "*"
        }
      }
    }
  }
}

pnpm 并不会对 langium 包的 package.jsondependencies 字段进行修改,而仅是额外告知 pnpm 需要在分析 langium 包时,还存在 vscode-languageserver-typesvscode-jsonrpc@chevrotain/regexp-to-ast 依赖包,需要额外下载。

.pnpmfile.cjs

上述提到的两个方案(overridespackageExtensions)对于处理简单的依赖修复场景是足够的,但若碰到比较复杂的依赖修复,例如需要借助复杂的逻辑来进行修复,那么通过 pnpmhook 可以做到灵活控制。

如上面两种修复都可以基于 hook 来进行实现。

.pnpmfile.cjs
js
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 来打补丁难以进行维护(大量项目需要同步 patchpatch 中需要大量修改)。此时可能需要自行 fork 对应库的版本并进行修复,然后再进行发布。但由于没有依赖库的发版权限,因此通常需要换个库名发布,通过 npm alias 可以无感知的将异常依赖库的版本进行替换,从而解决依赖库的问题。

例如,下述 react-virtualized 依赖包存在问题,@byted-cg/react-virtualized-fixed-import@9.22.3 是修复了问题的版本,我们通过 npm aliasreact-virtualized 替换为修复后的 react-virtualized-fixed-import 版本。无需对项目的其他部分做出改动,即可解决依赖库的问题。

package.json
json
{
  "dependencies": {
    "react-virtualized": "npm:@byted-cg/react-virtualized-fixed-import@9.22.3"
  }
}

pnpm 的链接方式

传统 npmnode_modules 拓扑结构是难以精确控制 hoistpublic-hoist 行为的。那么来看一下 pnpm 是如何实现精确的控制 public-hoisthoist 行为。

package.json
json
{
  "dependencies": {
    "express": "4.18.1",
    "koa": "2.13.4"
  }
}

隐藏根目录的间接依赖库

首先为了解决 public-hoistpnpm 默认不会提升间接依赖库到根目录的 node_modules 里,而是将间接依赖库隐藏在根目录的 .pnpm 目录下,从而避免应用直接访问幽灵依赖库,即

bash
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 里的同一版本依赖出现多次,我们需要将其进行链接到同一地方。如这里的 koaexpress 使用了同一个版本的 acceptsnode_modules/koanode_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 进行软链。

bash
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)。

    js
    const koa = require('koa');
  • 避免循环 symlink: 如 a 依赖了 bb 依赖了 a,那么如果都使用 symlink,那么将很容易出现循环 symlink

  • 处理多 peerDependencies 问题:当存在多种版本的 peerDependencies 的时候,我们必须保证我们能同时 resolve 到不同的 peerDependencies 的版本。

这里有个需要注意的地方就是 pnpm 对于库的存储是采取内容寻址存储(Content-Addressable Storage),详情可参考 pnpm 的内容可寻址存储 一篇,里面详细介绍了 pnpm 的内容寻址存储策略。上述的 koa@2.13.4/node_modules/koapnpm 通过存储策略,从全局存储库中 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, 以及支持 reflinkXFS)。在速度、空间和安全性(隔离性)之间提供了最佳平衡,但依赖于现代文件系统。
  • hardlink (硬链接): 创建一个硬链接。这意味着项目 node_modules 中的文件条目和 CAS 中的文件条目指向磁盘上完全相同的物理数据块。这种方式在 空间效率上 非常高,因为不占用太多的额外空间(记录 inode 信息)。但是,它的一个重要后果是,如果在项目 node_modules 中直接修改了这个硬链接文件,也会同时修改 CAS 中的原始文件,会无意中破坏 CAS,从而影响其他项目。硬链接要求源文件和链接目标必须在同一文件系统上。极度节省空间,但将项目与存储库紧密耦合,存在意外修改存储库文件的风险。
  • copy (复制): 执行标准的文件复制操作。这是磁盘空间和安装速度效率最低的方式,但它具有普遍适用性,即使跨文件系统也能工作。是万能的后备方案,但牺牲了 pnpm 的主要优势(节省磁盘空间安装速度)。
  • clone-or-copy: 优先尝试 clone,如果不支持则回退到 copy

控制依赖包之间的 hoist 行为

前面讲了通过 pnpmpublicHoistPattern 配置项提升间接依赖包到根目录的 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 中移除即可。

bash
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 弱约束力(幽灵依赖),在构建产物中直接访问间接依赖包的问题。现代应用最佳实践配置 hoistfalse,不再需要 node_modules/.pnpm/node_modules 目录,避免构建产物中直接访问间接依赖包的问题。

处理 peerDependencies

前面我们已经很好的解决了 public-hoist(针对应用和依赖库之间) 和 hoist(针对依赖库与依赖库之间) 问题,但是还有一类更复杂的问题,就是 peerDependencies 的处理,peerDependencies 把本就很复杂的 resolve 逻辑无疑又提高到了一个新的高度。

peerDependencies 有两个鲜明的特征,严重影响了 resolve 的流程

  • foo 包使用 peerDependencies 声明对等依赖 foo-peer 包,也就是说 foo-peer 包是由宿主依赖方来进行消费的。

    bash
    app
      dependencies
        bar --> install foo-peer@1.0.0
          devDependencies
            foo@1.0.0
              peerDependencies
                foo-peer@1.0.0
    bash
    app --> install foo-peer@1.0.0
      dependencies
        bar
          dependencies
            foo@1.0.0
              peerDependencies
                foo-peer@1.0.0
  • 如果 app1 依赖了 foo@1.0.0app2 也依赖了 foo@1.0.0,那么即使两个 app 都依赖了同一个 foo 的版本,但由于 foo@1.0.0 中存在 peerDependencies 声明 foo-peer 包,这个包是由宿主依赖方(app1app2)来进行消费的。但由于 app1 中依赖的 foo-peer 版本与 app2 中依赖的 foo-peer 版本不同,导致 resolve 到的 foo@1.0.0 是两个不同的文件。

    packages/app1/package.json
    json
    {
      "dependencies": {
        "foo": "1.0.0",
        "foo-peer": "1.0.0"
      }
    }
    packages/app2/package.json
    json
    {
      "dependencies": {
        "foo": "1.0.0",
        "foo-peer": "2.0.0"
      }
    }
    bash
    node_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

    我们看到,app1app2 都加载了同一 foo 版本,但是其 foo-peer 版本不同,此时在 node_modules 中,app1 链接到了 foo@1.0.0_foo-peer@1.0.0/node_modules/foo-peerapp2 链接到了 foo@1.0.0_foo-peer@2.0.0/node_modules/foo-peer

    pnpm 采用硬链接的策略,那么就会发现 foo@1.0.0_foo-peer@1.0.0foo@1.0.0_foo-peer@2.0.0foo-peerinode 相同,也就意味着两个不同的硬链接指向同一个包,这显然是不符合预期的。pnpm 巧妙的通过 hardlink 解决了 peerDependencies 的多版本问题,但是带来了另一个问题,即 peerDependencies 碎片化问题。

peerDependencies 的碎片化

我们看到因为 peerDependencies 的存在,导致了即使我们在项目中使用了同一个版本 foo 包,但 pnpm 为了确保 fooresolve 到不同的 peerDependencies 版本,从而导致存在了多个 foo 分身,这是典型的 npm 分身问题(npm 分身),分身问题有哪些危害就不再赘述,最常见的就是会导致 重复打包单例模式破坏

如我们的 app 依赖了 app1app2,当我们对 app 进行打包,最终会发现同一版本的 foo 被打包多次。

情况更糟糕的是,peerDependencies 导致的分身问题具有传染性,不仅仅是 foo 会导致多重分身,foo 的所有父依赖都需要进行分身来进行兼容。

即使 pnpm 的处理策略,满足 peerDependencies 的语义,但是可能并不符合用户的实际语义,用户大多数情况下并不想要打包多份 foo 包,同时用户通常也能接受 peerDependencies 使用同一版本。因此对于这种场景下,可以通过维护 peerDependencies 保持同一版本即可将 foo 的包进行统一。

通过上述提到的 pnpmhook 就可以容易的实现这个需求,当然更好的办法是手动修改 package.json,保持所有的 peerDependencies 的版本拥有共用的最低版本,pnpm 会采取智能的方式选取满足条件的版本来减少重复包的使用。

.pnpmfile.cjs
js
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,而是直接将 app1node_modulessdk 软链接到 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 声明。
bash
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 包,就会发现一个错误:

bash
[ERROR] Could not resolve "react"

这是因为捆绑器的 默认解析路径 为软链接原始文件路径(preserveSymlink = false || symlink = true),而 buttonpeerDependencies 是依赖宿主依赖方(formcard)。换句话说 react 作为 buttonpeerDependencies,是安装在 formcardnode_modules 里,而不是 button[root]node_modules 里,导致 button 无法找到 react 包。

PS: 若已经发布了 formcard 包,那么就不会出现这个问题,因为通过 pnpm 下载后是采用 hardlink 链接的。

有以下两种方式来解决这个问题:

  1. 使用 preserveSymlink = true 来保证解析 button 路径是在 formnode_modules 中,而非 buttonnode_modules,这样可以保证引入 form 里的 react,这种做法通常来说并不可靠,很容易导致依赖包之间关系不明确。

    bash
    packages
     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
  2. 基于 inject 实现,card 包和 form 包是通过 hardlink 的方式链接 button 包,这样使得 button 解析 react 包的时候是处于宿主依赖方(cardform)的 node_modules 中,就和发布出去的 npm 包一样,这样 button 就能正常打包。pnpm 支持通过 dependenciesinjected 来调整其 symlinkhardlink

form/package.json
json
{
  "dependenciesMeta": {
    "button": {
      "injected": true
    }
  }
}

但正如前面提到过,一旦对 hardlink 做了删除等操作,就会导致后续的 watch 失效。上述的 button 组件是通过 hardlink 的方式链接的,若 button 原文件发生删除操作,会导致 formbuttonhardlink 断掉,当恢复原文件后对恢复文件的内容进行修改,这对于硬链接的文件来说是不可感知的,watch 无法检测到,导致 HMR 触发失效。这是十分常见的一个场景,特别是开发者在开发庞大的 monorepo 项目时,出于性能考虑,通常会直接通过链接及 watch 非活跃子项目dist 目录(已打包),使得无需在开发阶段编译非活跃子项目源码,这大大提高开发效率。对于默认情况下的软链处理影响不大,但如果通过 hardlink 的方式链接的,那么就会出现上述问题。

这就要求在使用 hardlink 的时候,不要对被 link 的文件内容进行删除操作。另一方面 watcher(如 chokidar)可能对 hardlink 支持不太友好,也可能导致即使没删除文件的情况下,一些 watch events 信息丢失,详情见 watch-event-missing

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (9037680)