根据官方 NPM 统计数据,到2023年底,来自将近90万维护者的 NPM 注册表已经导入了近400万个包,使其成为地球上最大的单一语言代码存储库,极大地丰富了 Node.js 生态。
背靠 Node.js 庞大的生态,我们不必从底层开始编写所有代码,大部分问题都能在社区中找到解决方案,而且它们往往经历了大量的实践并拥有更高的性能和稳定性,我们所需的仅仅是在项目中依赖它们使用即可。
我们每天都在接触 xxx install
来安装依赖,其中包管理器是必不可少的工具,它从最初的 NPM 发展到 Yarn、Pnpm、Bun 百花齐放,这篇文章将带大家探索包管理器的发展历程和原理。
1.0 -- NPM
NPM 是 Node.js 的标准包管理器,使用 npm install
命令安装依赖。
npm2 - 嵌套地狱
当我们使用 npm1、npm2 安装 express 依赖 npm install express
,观察 node_modules 结构,可以发现 node_modules 的结构是嵌套的:
随着依赖层级的增多,会形成嵌套地狱:
这样做的问题是无法复用重复依赖,会增加磁盘占用,而且会导致安装速度变慢。
比如项目依赖了 A 和 C,而 A 和 C 又依赖了相同版本的 B,那么 A 和 C 都会安装 B,但实际上 B 只需要安装一次:
kotlin
▾ node_modules
▾ A@1.0.0
▾ node_modules
▸ B@1.0.0
▾ C@1.0.0
▾ node_modules
▸ B@1.0.0
而且在 Windows 中文件路径最大字符限制为 260 个字符,嵌套地狱会导致文件路径过长,无法安装。
npm3
针对嵌套地狱的问题,npm3 将依赖提升(hoist),采用扁平的 node_modules 结构,子依赖会尽量平铺安装在主依赖项目所在的目录中。
同样 npm install express
,可以发现依赖都被安装在了第一层,大部分第二层是没有 node_modules 的:
不确定性
如果我引入了一个包的不同版本,那 NPM 会将哪个版本的包安装到 node_modules 中呢?
举个例子
- A@1.0.0 依赖 B@1.0.0
- C@1.0.0 依赖 B@2.0.0
存在两种情况:
- 提升 B@1.0.0:
kotlin
▾ node_modules
▸ A@1.0.0
▸ B@1.0.0
▾ C@1.0.0
▾ node_modules
▸ B@2.0.0
- 提升 B@2.0.0:
kotlin
▾ node_modules
▾ A@1.0.0
▾ node_modules
▸ B@1.0.0
▸ B@2.0.0
▸ C@1.0.0
具体提升哪个版本的 B 取决于 A、C 在 package.json 中声明的顺序,如果 A 声明在前就是前面的结果,反之就是后面的结果。
所以后续推出了 lock 文件来保证 install 过程中依赖结构的稳定性。
幽影依赖(Phantom dependencies)
幽影依赖是指我在 package.json 中没有引入这个包但却可以使用这个包,因为扁平化结构可能将依赖的子依赖提升到了主依赖项目所在的目录中,这也就是非法访问。
因为没有显式声明,万一哪天这个包被删除了,那它的所有子依赖也不会再被安装,就会导致项目无法正常运行。
依赖分身(NPM doppelgangers)
这个也是依赖提升所引起的,会导致项目中大量的依赖被重复安装。
举个例子:
- A@1.0.0、C@1.0.0 依赖 B@1.0.0
- D@1.0.0、E@1.0.0 依赖 B@2.0.0
如果是提升 B@1.0.0,整体的结构如下:
kotlin
▾ node_modules
▸ A@2.0.0
▸ B@2.0.0
▸ C@2.0.0
▾ D@1.0.0
▾ node_modules
▸ B@2.0.0
▾ E@1.0.0
▾ node_modules
▸ B@2.0.0
可以看到 B@2.0.0 会被安装两次。反之如果提升的是 B@2.0.0 则 B@1.0.0 会被安装两次。
而且虽然 D 和 E 都依赖 B@2.0,但其实引用的不是同一个 B,假设 B 在导出之前做了一些缓存或者副作用,那么使用者的项目就会因此而出错。
package-lock.json
package.json 中依赖的版本遵循 semver。
对于相同的版本号 ^1.0.0,不同的设备可能安装不同的版本(1.0.0、1.1.0),从而可能导致开发和线上或多个开发者之间效果不一致的情况。
所以 NPM 推出了 package-lock.json 解决上述问题。
package-lock.json 包含了版本的锁定,但存在该 lock 文件时,NPM 会根据 lock 中锁定的版本安装依赖,如此就保证了不同设备下同一项目的依赖版本一致:
npm install
-
解析 package.json、package-lock.json 文件:NPM 会首先读取当前项目目录下的 package.json、package-lock.json 文件,确定项目的依赖包和版本信息。
-
构建依赖树:根据依赖信息,NPM 会递归解析所有的依赖关系,构建一个依赖树。这个依赖树包括了项目所需的所有依赖包及其版本。
-
检查缓存或下载依赖包:
- 存在缓存:解压到 node_modules 下。
- 不存在缓存:从 NPM 源下载对应的包,验证其完整性并添加到缓存中,之后解压到 node_modules 下。
- 生成、更新 package-lock.json。
不足
- 安装速度慢
- 没有解决扁平化带来的算法复杂度、幽影依赖等问题
2.0 -- Yarn
Yarn 早期先于 NPM 解决了嵌套地狱等问题,但随着 Npm 的迭代这些问题也被解决。
目前这两者存在很多共同点:
- Yarn 和 NPM 都提供了将依赖项保存在离线缓存中的选项,即使处于离线状态也可以安装依赖项。
- Yarn 和 NPM 都支持 workspace,允许从单个存储库管理多个项目的依赖项。
- Yarn 和 NPM 都会自动生成一个 lock 文件,用于跟踪项目所使用的依赖项的确切列表。
当然也存在一些不同:
- Yarn 使用并行安装依赖,Npm 是串行,Yarn 的安装速度更快。
- Yarn 的 lock 文件为 yarn.lock,NPM 的是 package-lock.json。
3.0 -- PNPM
PNPM 是一个现代的包管理器,看官网的介绍:
Fast, disk space efficient package manager
相比 NPM、Yarn 主要有两个明显优势:
- 速度快
- 节省磁盘空间
下面我们就从这两个优势出发,看看 PNPM 是如何解决这些问题的。
速度快
根据 PNPM 给的 Benchmarks 可以看到,在绝大数场景下,PNPM 包安装速度会比 NPM/Yarn 快两到三倍:
具体原因下面会具体分析。
节省磁盘空间
当你有 100 个项目使用同一个包时,如果使用 NPM/Yarn 你需要安装 100 次,那么就可能在磁盘中重复写入了 100 次该代码。
而 Pnpm 通过内容寻址存储(content-addressable store)来管理包:
这样做的好处是:
- 不会重复安装包:对于一个包的某个版本,Pnpm 仅会向磁盘中写入一次,后续安装仅会从该位置创建一个Hark Link(下面会详细说明),不会占用额外的磁盘空间。
- 尽可能地复用文件:即使一个包的不同版本,Pnpm 也会尽可能地复用相同文件,而不是完全重新写入全部的文件。
Link
文件系统的两种链接方式:Hark Link(硬链接)、Symbolic Link(软链接/符号链接)。
PNPM 中的一些技术实现是基于 Link 实现的,所以先来介绍下这两种连接方式。
Hark Link
硬链接是通过索引节点来进行连接的。
在Linux文件系统中,每个文件都有一个与之关联的索引节点(inode),而硬链接就是多个目录项中的索引节点指向同一个文件。
硬链接的作用是允许一个文件拥有多个有效路径名,这样用户可以建立硬链接到重要文件,以防止误删。
当删除原始文件时,硬链接仍然存在。只有当最后一个链接被删除后,文件的数据块及目录的连接才会被释放。
简而言之,硬链接就是源文件的副本,通过硬链接就可以创建多个文件名,用户可以通过这些文件路径访问到同一个源文件。
PNPM 会在全局的 store 中维护所有的依赖文件的硬链接,不同的项目可以从全局 store 中通过硬链接寻找到同一份源文件。
所以使用 NPM/Yarn,如果有 100 个项目安装了同一依赖的同一版本,那么在磁盘中会存储 100 份相同的依赖文件。而使用 PNPM 仅会在全局中维护一份依赖文件,至于硬链接所占用的磁盘空间几乎可以忽略不计。
Symbolic Link
软链接(也称为符号链接)是创建一个指向目标文件或目录的特殊文件。
软链接类似于快捷方式,它包含了目标文件或目录的路径信息。
当删除了源文件后,链接文件不能单独存在。
与硬链接不同,软链接可以跨越不同的文件系统,并且可以链接到目录。
总而言之:
- 硬链接是多个文件名指向同一份文件内容,删除原始文件后硬链接仍然存在。
- 软链接是创建一个指向目标文件或目录的特殊文件,可以跨越不同的文件系统。
创建非扁平化的 node_modules 结构
上述 NPM/Yarn 所采用的 node_modules 扁平化结构,所有依赖被提升到主依赖项目的目录,带来了不确定性、幽影依赖、依赖分身等问题。
而 PNPM 采用的是非扁平化的 node_modules 结构来解决这个问题。
我们可以先来看一下 pnpm 下 node_modules 的结构,同样的例子 pnpm install express
,node_modules 结构如下:
▾ node_modules
▸ .pnpm
▸ express
▸ .modules.yaml
展开 express 目录,可以发现没有 node_modules:
那么 express 的依赖项存在哪里?打开 .pnpm 目录,可以看到所有被扁平化的依赖,且文件名格式为 .pnpm/<name>@<version>/node_modules/<name>
:
所以上述的 express 其实是一个软链接 ,NodeJS 解析依赖项时会使用它的真实路径,即 .pnpm/express@4.19.2/node_modules/express
:
但你可以发现它依旧没有 node_modules,其实 pnpm 是将子依赖项都提到了跟 express 上一层 .pnpm/express@4.19.2/node_modules
目录下,根据 Node.js 逐级向上 require 的特性就可以找到依赖项:
并且这些依赖项也都是指向 node_modules/.pnpm/
的软链接。
将依赖项放置在 express
上一层可以避免循环软链接。
sql
▾ node_modules
▾ .pnpm
▸ accepts@1.3.8
▸ array-flatten@1.1.1
...
▾ express@4.19.2
▾ node_modules
▸ accepts -> ../accepts@1.3.8/node_modules/accepts
▸ array-flatten -> ../array-flatten@1.1.1/node_modules/array-flatten
...
▾ express
▸ lib
History.md
index.js
LICENSE
package.json
Readme.md
看到这里再看一下 NPM/Yarn 扁平化带来的三个问题:
- 不确定性:每个依赖在 .pnpm 下的目录名称都包含版本
.pnpm/<name>@<version>
,所以天然就允许多个版本的存在并且进行隔离,通过软链接指向真实的版本。 - 幽影依赖:只有直接依赖会平铺到 node_modules 下,子依赖不会被提升,不会产生幽影依赖。比如上述例子 node_modules 下只有 express,即主项目只能引用 express 而无法访问到其它依赖。
- 依赖分身:同理,同一个依赖的不同版本都被扁平化到了 .pnpm 下,该问题自然被解决。
综上,PNPM 将所有依赖都扁平在了 .pnpm 下。
node_modules 下仅保存了直接依赖 并软链接到 node_modules/.pnpm/
对应的依赖下,每一个依赖又是通过硬链接到 store,其子依赖依旧是软链接。
如此,巧妙地通过扁平化结构实现了依赖的嵌套,同时解决了不确定性、幽影依赖、依赖分身等问题。
workspace
workspace 是 PNPM 的一个特性,允许从单个存储库管理多个项目的依赖项,广泛应用于 Monorepo 项目。
相比于 NPM、Yarn,Pnpm 的 workspace 更加灵活,并且性能、节省磁盘空间等优势会进一步放大。
pnpm install
- 依赖解析:识别所有必需的依赖项并提取到全局 store 中。
- 结构树构建:根据依赖关系计算出 node_modules 目录结构。
- 链接依赖:所有剩余的依赖都会从 store 获取并硬链接到 node_modules。
这种方法比解析、获取所有依赖项并将所有依赖项写入 node_modules 的传统三阶段安装过程要快得多:
4.0 -- Bun?
Bun 是一个 JavaScript 运行时,同时也是一个包管理器。
安装依赖使用 bun install
,官方列出的数据是 NPM 的25倍,实测下来确实非常快,速度甚至也远在 PNPM 之上。
那为什么 Bun 有这么快的安装速度呢,主要有以下原因:
- 是用 Zig 编程语言编写的,它提供了对内存的低级控制和优化,与 Node.js 中使用的 JavaScript 等高级语言相比,运行速度更快。
- 针对底层做了大量的架构优化,比如 Lock 使用的是二进制文件。
但 node_modules 结构依旧是 NPM/Yarn 的扁平化结构,依旧存在因依赖提升造成的问题。
总结
NPM 作为 Node.js 标准包管理器,NPM3 的迭代解决了嵌套地狱等问题,但是扁平化结构同样带来了依赖提升问题,会造成不确定性、幽影依赖、依赖分身等问题。
Yarn 作为 NPM 的替代品,拥有更快的安装速度,但没有解决扁平化带来的问题。
PNPM 采用硬链接 + 软链接的方式,节省了大量安装时间和磁盘空间,同时也解决了扁平化带来的问题,尤其对于 workspace 的支持使得其广泛应用于 Monorepo 项目。也是目前最推荐的包管理器方案。
Bun 拥有非常快的安装速度,但没有解决扁平化带来的问题,同时作为新兴方案稳定性有待观察。