# performance npm (高性能 npm)

pnpm 是一款当代备受关注的 新兴包管理工具
特点:快 | 省。
Q:为什么会出现 pnpm?
A:作者一开始对 yarn 的发布有很高的期待,但是发布后并没有满足作者的一些期待,反而让作者有些失望。

Q:如何突显 pnpm 的性能优势?
下图 pnpm 官网提供的 benchmarks 图表,它比对了项目在 npm、pnpm、yarn(正常版本和 PnP 版)中,install、update 场景下的耗时:
可以看到 pnpm(橘色)有很明显性能提升

在讨论性能提升原因之前,我们需要先了解下现有包管理工具中 node_modules 存在的问题

# node_modules 安装方式

目前有两种安装方式:Nested installation(嵌套安装)、Flat installation(扁平安装)

# Nested installation 嵌套安装

在 npm@3 之前,node_modules 结构是干净、可预测的,因为 node_modules 中的每个依赖项都有自己的 node_modules 文件夹,在 package.json 中指定了所有依赖项。例如下面所示,项目依赖了 foo,foo 又依赖了 bar,依赖关系如下所示:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json
1
2
3
4
5
6
7
8

上面结构有两个严重的问题:

  • package 中经常创建太深的依赖树,这会导致 Windows 上的目录路径过长问题
  • 当一个 package 在不同的依赖项中需要时,它会被多次复制粘贴并生成多份文件

# Flat installation 扁平安装

为了解决上述问题,npm 重新考虑了 node_modules 结构并提出了扁平化结构。在 npm@3+ 和 yarn 中,node_modules 结构变成如下所示:

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json
1
2
3
4
5
6
7

可以看到,hoist 机制下,bar 被提升到了顶层。
Q:如果同一个包的多个版本在项目中被依赖时,node_modules 结构又是怎么样的?
例如:一个项目 App 直接依赖了 A(version: 1.0)和 C(version: 1.0),A 和 C 都依赖了不同版本的 B,其中 A 依赖 B 1.0,C 依赖 B 2.0,可以通过下图清晰的看到 npm2 和 npm3+结构差异:
包 B 1.0 被提升到了顶层,这里需要注意的是,多个版本的包只能有一个被提升上来,其余版本的包会嵌套安装到各自的依赖当中(类似 npm2 的结构)
依赖变更会影响提升的版本号,比如变更后,有可能是 B 1.0 ,也有可能是 B 2.0 被提升上来(但只能有一个版本提升)。
这其实并没有解决之前的问题,反而又引入了新的问题。

Q:引入新的问题是什么?

# Phantom dependencies 幽灵依赖

即某个包没有在 package.json 被依赖,但是用户却能够引用到这个包。
引发这个现象的原因一般是因为 node_modules 结构所导致的。例如使用 npm 或 yarn 对项目安装依赖,依赖里面有个依赖叫做 foo,foo 这个依赖同时依赖了 bar,yarn 会对安装的 node_modules 做一个扁平化结构的处理,会把依赖在 node_modules 下打平,这样相当于 foo 和 bar 出现在同一层级下面。那么根据 nodejs 的寻径原理,用户能 require 到 foo,同样也能 require 到 bar。

# NPM doppelgangers NPM 分身

这个问题其实也可以说是 hoist 导致的,这个问题可能会导致有大量的依赖的被重复安装.

举个例子:项目中有 packageA、packageB、packageC、packageD。packageA 依赖 packageX 1.0 和 packageY 1.0,packageB 依赖 packageX 2.0 和 packageY 2.0,packageC 依赖 packageX 1.0 和 packageY 2.0,packageD 依赖 packageX 2.0 和 packageY 1.0。

在 npm2 时,结构如下:

- package A
    - packageX 1.0
    - packageY 1.0
- package B
    - packageX 2.0
    - packageY 2.0
- package C
    - packageX 1.0
    - packageY 2.0
- package D
    - packageX 2.0
    - packageY 1.0
1
2
3
4
5
6
7
8
9
10
11
12

在 npm3+和 yarn 中,由于存在 hoist 机制,所以 X 和 Y 各有一个版本被提升了上来,目录结构如下

 package X => 1.0版本
 package Y => 1.0版本

 package A
 package B
    - packageX 2.0
    - packageY 2.0
 package C
    - packageY 2.0
 package D
     packageX 2.0
1
2
3
4
5
6
7
8
9
10
11

如上所示的 packageX 2.0 和 packageY 2.0 被重复安装多次,从而造成 npm 和 yarn 的性能一些性能损失。

# pnpm 的破解之道

pnpm 的 node_modules 并不是扁平化结构,而是目录树的结构,类似 npm 2.x 版本中的结构,如下图所示

同时还有个.pnpm 目录,如下图所示

.pnpm 以平铺的形式储存着所有的包,正常的包都可以在这种命名模式的文件夹中被找到:

.pnpm/<organization-name>+<package-name>@<version>/node_modules/<name>

// 组织名(若无会省略)+包名@版本号/node_modules/名称(项目名称)

1
2
3
4

我们称.pnmp 为虚拟存储目录,该目录通过<package-name>@<version>来实现相同模块不同版本之间隔离和复用,由于它只会根据项目中的依赖生成,并不存在提升,所以它不会存在之前提到的 Phantom dependencies 问题! 那么它如何跟文件资源进行关联的呢?又如何被项目中使用呢? 答案是 Store + Links!

# Linux 硬链接与软链接

硬连接指通过索引节点来进行连接。在 Linux 的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在 Linux 中,多个文件名指向同一索引节点是存在的。比如:A 是 B 的硬链接(A 和 B 都是文件名),则 A 的目录项中的 inode 节点号与 B 的目录项中的 inode 节点号相同,即一个 inode 节点对应两个不同的文件名,两个文件名指向同一个文件,A 和 B 对文件系统来说是完全平等的。删除其中任何一个都不会影响另外一个的访问。 硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。

另外一种连接称之为符号连接(Symbolic Link),也叫软连接。软链接文件有类似于 Windows 的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。比如:A 是 B 的软链接(A 和 B 都是文件名),A 的目录项中的 inode 节点号与 B 的目录项中的 inode 节点号不相同,A 和 B 指向的是两个不同的 inode,继而指向两块不同的数据块。但是 A 的数据块中存放的只是 B 的路径名(可以根据这个找到 B 的目录项)。A 和 B 之间是“主从”关系,如果 B 被删除了,A 仍然存在(因为两个是不同的文件),但指向的是一个无效的链接。

# Store

pnpm 资源在磁盘上的存储位置。
pnpm 使用名为 .pnpm-store 的 store dir,Mac/linux 中默认会设置到{home dir}>/.pnpm-store/v3;windows 下会设置到当前盘的根目录下。

pnpm install 的安装过程中,我们会看到如下的信息,这个里面的 Content-addressable store 就是我们目前说的 Store

如果是 npm 或 yarn,那么这个依赖在多个项目中使用,在每次安装的时候都会被重新下载一次
如图可以看到在使用 pnpm 对项目安装依赖的时候,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。
Q:依赖安装的很多,Store 目录会不会越来越大?
A: 会,所以需要定期 pnpm store prune (它提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 axios@1.0.0 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0 的 axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。

Q: .pnpm 的文件如何跟 Store 关联?
A: Links(hard link & symbolic link)

通过 hard link, 用户可以通过不同的路径引用方式去找到某个文件,需要注意的是一般用户权限下只能硬链接到文件,不能用于目录。
pnpm 会在 Store(上面的 Store) 目录里存储项目 node_modules 文件的 hard links ,通过访问这些 link 直接访问文件资源。

举个例子,例如项目里面有个 2MB 的依赖 react,在 pnpm 中,看上去这个 react 依赖同时占用了 2MB 的 node_modules 目录以及全局 store 目录 2MB 的空间(加起来是 4MB),但因为 hard link 的机制使得两个目录下相同的 2MB 空间能从两个不同位置直接引用到文件,因此实际上这个 react 依赖只用占用 2MB 的空间,而不是 4MB。

因为这样一个机制,导致每次安装依赖的时候,如果是个相同的依赖,有好多项目都用到这个依赖,那么这个依赖实际上最优情况(即版本相同)只用安装一次。

而在 npm 和 yarn 中,如何一个依赖被多个项目使用,会发生多次下载和安装!

通过 Store + hard link 的方式,不仅解决了项目中的 NPM doppelgangers 问题,从而完美解决了 npm3+和 yarn 中的包重复问题!

Q:由于 hark link 只能用于文件不能用于目录,但是 pnpm 的 node_modules 是树形目录结构,那么如何链接到文件?

A:通过 symbolic link 来实现!

pnpm 在全局通过 Store 来存储所有的 node_modules 依赖,并且在.pnpm/node_modules 中存储项目的 hard links,通过 hard link 来链接真实的文件资源,项目中则通过 symbolic link 链接到.pnpm/node_modules 目录中,依赖放置在同一级别避免了循环的软链。