掘金
随着 pnpm 的逐渐崭露头角,越来越多的开发者和项目开始倾向于采用 pnpm 作为其包管理工具,以期利用 pnpm 的独特优势来解决 npm 在处理大型、复杂项目时可能遇到的一些问题。特别是在 Vue 3 及其生态中的许多包迁移使用pnpm做完包管理器后,这个趋势变得更为明显。pnpm 通过其独特的包管理策略和硬链接、软链接技术,以及三层寻址策略,显著减少了冗余数据,并提升了依赖解析的效率。同时,在 Node.js 环境中,理解如何处理依赖引用的逻辑是至关重要的。随着时间的推移,npm 也从简单的依赖解析演变为现今更为成熟和复杂的依赖管理系统。下面,我们将探讨 pnpm 的包管理策略、Node.js 在处理依赖引用时的逻辑、npm 的依赖历史、硬链接与软链接的原理,以及全局 pnpm-store 的组织方式。
pnpm
使用一个公共内容地址able的存储区域来存放所有的包。每个版本的每个包在磁盘上只存储一次。如果多个项目使用相同的包和版本,pnpm
只需使用符号链接(或硬链接,取决于系统和配置)来链接到公共存储位置。这种方法节省了大量磁盘空间,而 npm 7
会在每个项目的 node_modules
文件夹中独立存储每个包的副本。pnpm
使用符号链接,所以它的安装速度通常更快,尤其是在多个项目使用相同的依赖时。pnpm
的 node_modules
结构确保了包只能访问其声明的依赖,这提供了更强的隔离性,能更早地捕获到未声明的依赖问题,而 npm
的扁平结构可能会隐藏这类问题,造成幻影依赖。这里先简要描述下Node.js 在处理依赖引用时的逻辑,当使用 require()
函数导入模块时,Node.js 的解析方式如下:
如果参数指向的是核心模块(如“fs”或“path”)或是一个绝对或相对的文件路径(例如./packageA.js
或/myLib/packageB.js
),Node.js 将直接加载相应的文件或模块。
对于其他情况,Node.js 会启动对 node_modules
目录的搜索流程:
node_modules
。一旦找到node_modules
目录,Node.js 会在目录中查找具有指定模块名的.js
文件,或者一个与模块名同名的子目录。
从npm的诞生开始,其嵌套的node_modules
目录结构旨在解决版本冲突,确保每个项目都有特定版本的依赖。但这也导致了大量的磁盘空间浪费。在 npm1、npm2 中呈现出的是嵌套结构,类似下面这样:
(packageA 依赖 packageB 依赖 packageN…)
```
node_modules
└─ packageA
├─ index.js
├─ package.json
└─ node_modules
└─ packageB
├─ index.js
└─ package.json
└─ node_modules
└─ ...
```
是以依赖包之间互相嵌套的方式存储的,这样最明显的一个问题就是依赖的层级有可能很深,包A
依赖 包B
依赖 包C
…,这会造成文件路径长度过长的问题,另外还会磁盘空间的浪费:相同的包可能被多次安装在不同的嵌套级别上,导致了不必要的重复和空间的浪费。结合上面说的node.js的解析依赖的方式在层级过深的时候要进行大量文件的 I/O 操作,效率不高。
当npm进入其第三个主要版本(npm@3)时,它引入了一个显著的变化,即扁平化的依赖管理。包括 yarn,都着手来通过扁平化依赖的方式来避免前两个版本中常见的嵌套node_modules结构。
上面图片里只安装了一个 axios 作为项目的依赖,但是node_modules里却多出了很多其他的包。这就是扁平化依赖管理的结果。相比之前的嵌套结构,现在的目录结构类似下面这样:
node_modules
├─ packageA
| ├─ index.js
| └─ package .json (packageA 依赖 packageB)
└─ packageB
├─ index.js
└─ package .json
└─ package ...
尽管尽量采用扁平化的结构,但有时会发生版本冲突,即两个包依赖于 同一个包 的不同版本。
(如packageA 依赖了 [email protected], packageB 依赖了 [email protected])
在这种情况下,一个版本会放置在项目的根node_modules目录中,而另一个版本会被嵌套在依赖的node_modules目录中。
目录结构类似下面这样:
node_modules
├─ packageA
| ├─ index.js
| └─ package .json (packageA 依赖 packageB 和 lodash@ 1.0.0 )
└─ packageB
├─ index.js
└─ package .json
└─ node_modules
└─ lodash (@ 1.0.1 )
└─ lodash (@ 1.0.0 )
由于各种因素,如依赖版本的更新,导致的依赖关系的不稳定性。当依赖包版本冲突时不能保证哪一个包被提升至node_modules根目录哪一个被嵌套在对应依赖的node_modules中。
计算如何扁平化依赖并解决版本冲突的算法较为复杂,因此可能导致更长的处理时间。
因为依赖已经扁平化,项目中的代码可以访问那些并未明确声明的依赖包(幻影依赖),造成潜在的问题。
为解决依赖版本和依赖树的确定性和安装的效率,npm5
推出了package-lock.json
,以下是 package-lock.json 的详细作用:
上文提到pnpm
通过硬链接、软链接技术
来实现依赖包的管理,这里简要了解下软硬链接。
源文件 (inode 1) --> 磁盘块1
硬链接1 (inode 2) --> 磁盘块1
硬链接2 (inode 3) --> 磁盘块1
软连接 (inode 4) ---> 源文件(inode 1)
终于到了正题pnpm
了,pnpm
采用了一种特殊的依赖结构,我们来看个实际的例子,以下是示例项目的依赖关系:
// 项目依赖关系
testProject --> wadejs-package-a --> wadejs-package-b --> wadejs-package-c
使用npm安装目录结构如下
同一个项目如是通过pnpm install 后得到的 node_modules如下所示
├─. pnpm
│ ├─node_modules
│ │ ├─wadejs-package-a
│ │ ├─wadejs-package-b
│ │ └─wadejs-package-c
│ ├─wadejs-package-a@ 1.0.1
│ │ └─node_modules
│ │ ├─wadejs-package-a
│ │ └─wadejs-package-b
│ ├─wadejs-package-b@ 1.0.0
│ │ └─node_modules
│ │ ├─wadejs-package-b
│ │ └─wadejs-package-c
│ └─wadejs-package-c@ 1.0.0
│ └─node_modules
│ └─wadejs-package-c
└─wadejs-package-a
└─modules. yaml
可以看到通过pnpm 安装的node_modules根目录下除了.pnpm目录及modules.yaml(pnpm依赖的元数据文件)就只剩下一个当前项目的依赖wadejs-package-a,它是唯一一个当前项目必须拥有访问权限的包。 因此代码无法访问任意包,这种结构就避免了幻影依赖。
剩下的wadejs-package-b和wadejs-package-c都在node_modules.pnpm目录下,下面是pnpm官网的描述
.pnpm/ 以平铺的形式储存着所有的包,所以每个包都可以在这种命名模式的文件夹中被找到:.pnpm/
@ /node_modules/
每个包的寻找都要经过三层结构:
node_modules/wadejs-package-a
> 软链接
node_modules/.pnpm/[email protected]/node_modules/wadejs-package-a
> 硬链接
~/.pnpm-store/v3/files/xx/xxxxxx
最后通过硬链接到磁盘根目录的 .pnpm-store 文件夹下的对应文件。这么做的原因在下一章概述。
这里第三层pnpm使用了一种叫内容寻址(content-addressable)的文件组织方式,基于内容的寻址相较于基于文件名的寻址有一个特别的又是,即当包版本更新时,只需保存变更的差异(Diff),而无需保存新版本的全部文件内容。这种方式在版本管理上极大地节省了存储空间。
以下是pnpm的全局store目录 .pnpm-store
.pnpm-store\v3\files
├─ 02
│ 9615cb5b7be85ddc718096d6b864625472cf0020278067efc5071e1a87ebd4c4e7abf8db4b2a18ee5d8a86bdd9507107b17fee8b7df9cfbb0cb26b4692c974
│
├─ 09
│ b170f48de0d3f5570f7e6bcaeb25d126ab44d871b51719ea0cb269e47334451f694e064288fe836d9245aec56709759fc4e938c0ed5f8d6a71bd14eece3d77
│
├─0a
│ 6a0db1dedc4e14cfa484f0799e872f2a4e3ec3b39ef26e222bbb4f9062894f8c4f098cfcc64eda51d2465cf810c76c0af8926718f29831c6ccaff438a4e08b
│
pnpm 在其第三层的依赖寻址策略中使用了硬链接,但它引入了一个有趣的概念:目标文件并不是常规的NPM包源码,而是一个根据内容生成的哈希命名的文件。这种特殊的文件组织策略称为基于内容的寻址(content-addressable)。
基于内容的寻址的核心思想是依据文件内容而不是文件名或位置来组织文件。这种方法的优势在于,即使包的版本发生了变化,也只需要存储与前一版本的差异,而不是新版本的全部内容。这大大减少了存储需求。内容寻址完全不关心包的结构关系。当新的包被下载和解压时,系统只需检查文件的哈希值。如果该哈希值已存在,该文件就可以被忽略,从而只存储之前未出现的文件内容。这种机制使 pnpm 能够只存储包版本之间的差异,从而极大地节省了存储空间。
举个例子:假设有一个包版本为@1。0.0它有10个文件,pnpm会把这10个文件根据内容生成哈希名的文件存在全局的store中
.pnpm-store
,当这个包升级为1.0.1只改动了其中一个文件,pnpm只会新增一个文件存入store中,剩下的九个则会复用之前存在store中的。