随着 pnpm 的逐渐崭露头角,越来越多的开发者和项目开始倾向于采用 pnpm 作为其包管理工具,以期利用 pnpm 的独特优势来解决 npm 在处理大型、复杂项目时可能遇到的一些问题。特别是在 Vue 3 及其生态中的许多包迁移使用pnpm做完包管理器后,这个趋势变得更为明显。pnpm 通过其独特的包管理策略和硬链接、软链接技术,以及三层寻址策略,显著减少了冗余数据,并提升了依赖解析的效率。同时,在 Node.js 环境中,理解如何处理依赖引用的逻辑是至关重要的。随着时间的推移,npm 也从简单的依赖解析演变为现今更为成熟和复杂的依赖管理系统。下面,我们将探讨 pnpm 的包管理策略、Node.js 在处理依赖引用时的逻辑、npm 的依赖历史、硬链接与软链接的原理,以及全局 pnpm-store 的组织方式。
一、pnpm的包管理策略带来的优点
- 磁盘空间效率:
pnpm
使用一个公共内容地址able的存储区域来存放所有的包。每个版本的每个包在磁盘上只存储一次。如果多个项目使用相同的包和版本,pnpm
只需使用符号链接(或硬链接,取决于系统和配置)来链接到公共存储位置。这种方法节省了大量磁盘空间,而npm 7
会在每个项目的node_modules
文件夹中独立存储每个包的副本。 - 安装速度: 由于
pnpm
使用符号链接,所以它的安装速度通常更快,尤其是在多个项目使用相同的依赖时。 - 更强的隔离性:
pnpm
的node_modules
结构确保了包只能访问其声明的依赖,这提供了更强的隔离性,能更早地捕获到未声明的依赖问题,而npm
的扁平结构可能会隐藏这类问题,造成幻影依赖。
二、Node.js 在处理依赖引用时的逻辑
这里先简要描述下Node.js 在处理依赖引用时的逻辑,当使用 require()
函数导入模块时,Node.js 的解析方式如下:
-
如果参数指向的是核心模块(如"fs"或"path")或是一个绝对或相对的文件路径(例如
./packageA.js
或/myLib/packageB.js
),Node.js 将直接加载相应的文件或模块。 -
对于其他情况,Node.js 会启动对
node_modules
目录的搜索流程:- 首先,Node.js 会在当前的工作目录下查找
node_modules
。 - 如果在当前目录未找到,它会逐级向上移动到父目录进行查找。
- 这一过程会持续进行,直到达到系统的根目录。
- 首先,Node.js 会在当前的工作目录下查找
-
一旦找到
node_modules
目录,Node.js 会在目录中查找具有指定模块名的.js
文件,或者一个与模块名同名的子目录。
三、 npm的依赖历史
从npm的诞生开始,其嵌套的node_modules
目录结构旨在解决版本冲突,确保每个项目都有特定版本的依赖。但这也导致了大量的磁盘空间浪费。在 npm1、npm2 中呈现出的是嵌套结构,类似下面这样: (packageA 依赖 packageB 依赖 packageN...)
go
```
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里却多出了很多其他的包。这就是扁平化依赖管理的结果。相比之前的嵌套结构,现在的目录结构类似下面这样:
js
node_modules
├─ packageA
| ├─ index.js
| └─ package .json (packageA 依赖 packageB)
└─ packageB
├─ index.js
└─ package .json
└─ package ...
扁平化依赖管理带来的优点
- 减少了相同依赖包的重复安装
- 因为扁平化的依赖管理,解决的最大路径长度问题
- 相对于嵌套模式更简洁的依赖树
处理版本冲突
尽管尽量采用扁平化的结构,但有时会发生版本冲突,即两个包依赖于 同一个包 的不同版本。 (如packageA 依赖了 lodash@1.0.0, packageB 依赖了 lodash@1.0.1) 在这种情况下,一个版本会放置在项目的根node_modules目录中,而另一个版本会被嵌套在依赖的node_modules目录中。
目录结构类似下面这样:
js
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中。
-
计算如何扁平化依赖并解决版本冲突的算法较为复杂,因此可能导致更长的处理时间。
- 多版本依赖:在许多项目中,不同的包可能依赖于同一个包的不同版本。扁平化的目标是尽量减少重复,但当面对版本冲突时,算法必须决定哪个版本将被放置在顶层,以及如何处理其他版本。
- 递归依赖:包A可能依赖于包B,而包B又依赖于包C。每个这样的链可能都有其自己的版本要求和冲突,算法需要递归地处理这些情况。
- 循环依赖:某些复杂的依赖关系可能导致循环,例如包A依赖包B,而包B又反过来依赖包A。处理这种情况需要算法进行特殊处理。
- 优化磁盘空间:算法的目的之一是尽量减少磁盘空间的使用。这意味着在考虑版本冲突的同时,还要考虑如何最小化存储的依赖项。
- 保持更新:依赖管理工具还需要考虑如何在保持扁平结构的同时,能够轻松地更新和添加新的依赖。
- 向后兼容性:为了不破坏现有的项目和工作流程,算法需要在新版本的依赖管理工具中考虑向后兼容性,这进一步增加了复杂性。
-
因为依赖已经扁平化,项目中的代码可以访问那些并未明确声明的依赖包(幻影依赖),造成潜在的问题。
为解决依赖版本和依赖树的确定性和安装的效率,npm5
推出了package-lock.json
,以下是 package-lock.json 的详细作用:
- 确保一致性
- 由于 package.json 中的依赖可以使用诸如 ^ 或 ~ 的语义版本控制,这意味着依赖可以在某个范围内自动更新。这可能导致不同的开发者或在不同的时间点获取到不同版本的依赖。
- package-lock.json 锁定了每个依赖的确切版本,从而消除了上述的不确定性,确保每次安装时都会获得相同的依赖版本。
- 加速安装过程
- package-lock.json 文件为npm提供了一个完整的依赖树,这可以加速node_modules目录的生成过程,因为npm不再需要解析package.json来构建这个依赖树。
- 详细的依赖信息
- 除了主要的依赖版本,package-lock.json 还包含了其他的元数据,例如每个包的完整URL、预期的内容哈希等。这有助于确保每次安装的包不仅版本相同,而且其内容也确切地匹配原始安装时的内容。
- 增强安全性
- package-lock.json 中的内容哈希(通常是SHA-512)确保了依赖的完整性和身份验证。这可以确保每次安装的依赖都是原始的、未被篡改的。
- 便于团队协作
- 当多个开发者在团队中共同开发项目时,package-lock.json 确保每个开发者使用的都是相同版本的依赖,从而减少了"在我机器上可行"类型的问题。
- 文档化间接依赖
- 除了你直接在 package.json 中指定的依赖外,这些依赖自己可能还有其他依赖。package-lock.json 还为这些间接或"传递性"依赖提供了完整记录。
四. 硬连接与软链接的原理
上文提到pnpm
通过硬链接、软链接技术
来实现依赖包的管理,这里简要了解下软硬链接。
软链接(符号链接):
- 软链接是什么:软链接是一个指向另一个文件或目录的路径的特殊文件。你可以把它看作是一个快捷方式。
- 占用的空间:由于软链接只是一个包含目标路径的文件,因此它本身只占用存储该路径所需的少量空间。
- 效果:如果目标文件被删除,软链接将变得"失效"或"破损",因为它指向的路径不再存在。
硬链接:
- 硬链接是什么:硬链接是文件系统中的一个条目,它与原始文件共享相同的数据块。这意味着原始文件和硬链接实际上是指向相同的内容。
- 占用的空间:硬链接不会复制原始文件的内容,所以它不会占用额外的存储空间。
- 效果:即使原始文件被删除,硬链接仍然可以访问内容,因为它们共享相同的数据块。 结合上述描述,我们可以得出以下结论:
- pnpm使用软链接和硬链接来实现代码复用,意味着它不需要为同一个版本的包多次存储相同的内容。
- 软链接几乎不占用空间,因为它们只是路径引用。
- 硬链接真的不占用额外的空间,因为它们指向的是已存在的数据块。 它们与存储空间的关系类似下面这样:
js
源文件 (inode 1) --> 磁盘块1
硬链接1 (inode 2) --> 磁盘块1
硬链接2 (inode 3) --> 磁盘块1
软连接 (inode 4) ---> 源文件(inode 1)
五. pnpm的包管理策略
终于到了正题pnpm
了,pnpm
采用了一种特殊的依赖结构,我们来看个实际的例子,以下是示例项目的依赖关系:
js
// 项目依赖关系
testProject --> wadejs-package-a --> wadejs-package-b --> wadejs-package-c
使用npm安装目录结构如下
同一个项目如是通过pnpm install 后得到的 node_modules如下所示
kotlin
├─. 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/<name>@<version>/node_modules/
每个包的寻找都要经过三层结构:
node_modules/wadejs-package-a
> 软链接
node_modules/.pnpm/wadejs-package-a@1.0.1/node_modules/wadejs-package-a
> 硬链接
~/.pnpm-store/v3/files/xx/xxxxxx
最后通过硬链接到磁盘根目录的 .pnpm-store 文件夹下的对应文件。这么做的原因在下一章概述。
六. pnpm的三层寻址策略
- 第一层:本地 node_modules
- 目的:维持项目结构的语义性,并且提供一个确定性的依赖解析方式。
- 原理:每个项目的 node_modules 目录中的直接依赖都被组织成与 package.json 中声明的结构相匹配的方式。这遵循了 Node.js 的模块解析逻辑,确保每个依赖都能被正确地找到。到这里其实与npm2的依赖结构是类似的,只是这些依赖下没有再嵌套依赖。
- 第二层:项目级的 .pnpm 目录
- 目的:为单个项目提供一个集中的地方来存储其所有依赖的软链接(或符号链接),以减少重复并确保稳定的包结构。
- 原理:每个项目中的 .pnpm 目录链接到全局 pnpm 存储中的依赖版本。项目的 node_modules 目录中的每个依赖实际上都是指向这个 .pnpm 目录中的相应版本的软链接(或在 Windows 上的符号链接)。这确保了项目内的 node_modules 可以维持一个干净和结构化的布局,而真正的包文件都存储在全局存储中,并通过项目级的 .pnpm 目录链接。
- 第三层:全局的 pnpm 存储
- 目的:允许跨项目共享依赖,进一步减少存储和下载的冗余。
- 原理:pnpm 维护了一个全局的存储(通常位于用户的家目录下),在其中保存了所有下载的包的版本。这些版本被硬链接到项目的 .pnpm 目录。这意味着,不同的项目可以共享全局存储中的同一个物理文件,从而不需要重复下载或存储相同的文件。
这里第三层pnpm使用了一种叫内容寻址(content-addressable)的文件组织方式,基于内容的寻址相较于基于文件名的寻址有一个特别的又是,即当包版本更新时,只需保存变更的差异(Diff),而无需保存新版本的全部文件内容。这种方式在版本管理上极大地节省了存储空间。
七. 全局 pnpm-store 的组织方式
以下是pnpm的全局store目录 .pnpm-store
.pnpm-store\v3\files
js
├─ 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中的。
总结
- pnpm 通过利用硬链接和软链接的技术,巧妙地实现了依赖包的高效管理和存储空间的优化,避免了重复存储相同内容并实现了代码的复用。
- pnpm 创新地实施了一个三层寻址策略,包括本地 node_modules、项目级的 .pnpm 目录,以及全局的 pnpm 存储,确保了在项目的各个层级中实现高效的依赖引用和存储。
- 旨在维持项目结构的语义性,以及提供一个确定性的依赖解析方式,保持与 package.json 结构的匹配并且遵循 Node.js 的模块解析逻辑。
- 创新地允许跨项目共享依赖,通过硬链接技术大大减少存储和下载的冗余,并且使用内容寻址的文件组织方式,优化了版本管理和存储空间的利用。
- pnpm 通过内容寻址的文件组织方式,即基于文件内容生成的哈希文件,仅存储包版本之间的差异而非全部内容,这样在版本更新时只需要保存内容变更的差异部分,从而在存储和版本管理方面实现了极大的优化。