还在npm或者yarn?试试pnpm

科技   2024-10-31 12:13   北京  

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群


还在npm或者yarn?试试pnpm

pnpm是一款当代受欢迎 新兴(问题较多) 的包管理工具。

为什么叫pnpm是因为pnpm作者对现有的包管理工具,尤其是npm和yarn的性能比较特别失望,所以起名叫做perfomance npm,即 pnpm(高性能npm)。

如何突显 pnpm 的性能优势?在 pnpm 官网上,提供了一个benchmarks图表,它比对了项目在npm、pnpm、yarn(正常版本和PnP版)中,install、update场景下的耗时。

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

node_modules 结构

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

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

  • 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

可以看到,hoist机制下,bar被提升到了顶层。如果同一个包的多个版本在项目中被依赖时,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被提升上来(但只能有一个版本提升)

细心的小伙伴可能发现,这其实并没有解决之前的问题,反而又引入了新的问题

npm3+和yarn存在的问题

Phantom dependencies 幽灵依赖

Phantom dependencies 被称之为幽灵依赖或幻影依赖,解释起来很简单,即某个包没有在package.json 被依赖,但是用户却能够引用到这个包。

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

nodejs的寻址方式:

  1. 对于核心模块(core module) => 绝对路径 寻址
  2. node标准库 => 相对路径寻址
  3. 第三方库(通过npm安装)到node_modules下的库:3.1 先在当前路径下,寻找 node_modules/xxx。    3.2 递归从下往上到上级路径,寻找 ../node_modules/xxx。    3.3 循环第二步。    3.4  在全局环境路径下寻找 .node_modules/xxx

PM 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

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

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

这种场景在monorepo 多包场景下尤其明显,这也是yarn workspace经常被吐槽的点,另外扁平化的算法实现也相当复杂,改动成本很高。

那么pnpm是如何解决这种问题的呢?

pnpm的破解之道:网状 + 平铺的node_modules结构

pnpm的用户可能会发现它node_modules并不是扁平化结构,而是目录树的结构,类似npm version 2.x版本中的结构,如下图所示

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

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

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

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

我们称.pnmp为虚拟存储目录,该目录通过@来实现相同模块不同版本之间隔离和复用,由于它只会根据项目中的依赖生成,并不存在提升,所以它不会存在之前提到的Phantom dependencies问题!

那么它如何跟文件资源进行关联的呢?又如何被项目中使用呢?

答案是Store + Links

Store

pnpm资源在磁盘上的存储位置。

pnpm 使用名为 .pnpm-store的 store dir,Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘的根目录下,比如C(C/.pnpm-store/v3)、D盘(D/.pnpm-store/v3)。

具体可以参考 @pnpm/store-path 这个 pnpm 子包中的代码:

const homedir = os.homedir()
if (await canLinkToSubdir(tempFile, homedir)) {
  await fs.unlink(tempFile)
  // If the project is on the drive on which the OS home directory
  // then the store is placed in the home directory
  return path.join(homedir, relStore, STORE_VERSION)
}

由于每个磁盘有自己的存储方式,所以Store会根据磁盘来划分。如果磁盘上存在主目录,存储则会被创建在 /.pnpm-store;如果磁盘上没有主目录,那么将在文件系统的根目录中创建该存储。例如,如果安装发生在挂载在 /mnt 的文件系统上,那么存储将在 /mnt/.pnpm-store 处创建。Windows系统上也是如此。

可以在不同的磁盘上设置同一个存储,但在这种情况下,pnpm 将复制包而不是硬链接它们,因为硬链接只能发生在同一文件系统同一分区上。

windows store如下图所示

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

CAS 内容寻址存储,是一种存储信息的方式,根据内容而不是位置进行检索信息的存储方式。

Virtual store 虚拟存储,指向存储的链接的目录,所有直接和间接依赖项都链接到此目录中,项目当中的.pnpm目录

如果是 npmyarn,那么这个依赖在多个项目中使用,在每次安装的时候都会被重新下载一次.

如图可以看到在使用 pnpm 对项目安装依赖的时候,如果某个依赖在 store 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。

当然这里你可能也会有问题:如果安装了很多很多不同的依赖,那么 store 目录会不会越来越大?

答案是当然会存在,针对这个问题,pnpm 提供了一个命令来解决这个问题: pnpm store | pnpm

同时该命令提供了一个选项,使用方法为 pnpm store prune ,它提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 axios@1.0.0 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0 的 axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。该命令推荐偶尔进行使用,但不要频繁使用,因为可能某天这个不被引用的包又突然被哪个项目引用了,这样就可以不用再去重新下载这个包了。

看到这里,你应该对Store有了一些简单的了解,接着我们来看下项目中的文件如何跟Store关联。

Links(hard link & symbolic link)

hard link 机制

通过hard link, 用户可以通过不同的路径引用方式去找到某个文件,需要注意的是一般用户权限下只能硬链接到文件,不能用于目录。

pnpm 会在Store(上面的Store) 目录里存储项目 node_modules 文件的 hard links ,通过访问这些link直接访问文件资源。

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

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

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

如果是 npmyarn,那么这个依赖在多个项目中使用,在每次安装的时候都会被重新下载一次。

如图可以看到在使用 pnpm 对项目安装依赖的时候,如果某个依赖在 store 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。

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

如果随着项目越来越大,版本变更变多,历史版本的资源会堆积,导致Store目录越来越大,那如何解决这个问题呢?针对这个现象,pnpm 提供了一个命令来解决这个问题: pnpm store | pnpm

同时该命令提供了一个选项,使用方法为 pnpm store prune ,它提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 axios@1.0.0 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0 的 axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。

该命令推荐偶尔进行使用,但不要频繁使用,因为可能某天这个不被引用的包又突然被哪个项目引用了,这样就可以不用再去重新下载这个包了。

symbolic link

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

通过symbolic link(也可称之为软链或者符号链接)来实现!

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

pnpm node_modules 结构一开始看起来很奇怪:

  • 它完全适配了 Node.js
  • 包与其依赖被完美地组织在一起。

有 peer 依赖的包的结构更加复杂一些,但思路是一样的:使用软链与平铺目录来构建一个嵌套结构。

假设我们有个mono repo,它有repo A、repo B、repo C和repo D4个repo。每个repo有各自的一些依赖项(包括dependenciespeerDependencies),假定结构如下图所示:(需要注意有个peer dep)

下面是pnpm workspace中,比较清晰(不清晰的话留言,我可以改改!)说明了StoreLinks间的相互关系:

官网也更新了类似的调用关 图,大家也可以看看!

PeerDependencies

pnpm 的最佳特征之一是,在一个项目中,package的一个特定版本将始终只有一组依赖项。这个规则有一个例外 -那就是具有 peer dependencies 的package。通常,如果一个package没有 peer 依赖项(peer dependencies),它会被硬链接到其依赖项的软连接(symlinks)旁的 node_modules,就像这样:

如果 foo 有 peer 依赖(peer dependencies),那么它可能就会有多组依赖项,所以我们为不同的 peer 依赖项创建不同的解析:

pnpm创建 foo@1.0.0_bar@1.0.0+baz@1.0.0 或foo@1.0.0_bar@1.0.0+baz@1.1.0内到foo的软链接。因此,Node.js 模块解析器将找到正确的 peers。

peerDep的包命名规则如下(看起来就很麻烦)

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

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

如果一个package没有 peer 依赖(peer dependencies),不过它的依赖项有 peer 依赖,这些依赖会在更高的依赖图中解析, 则这个传递package便可在项目中有几组不同的依赖项。例如,a@1.0.0 具有单个依赖项 b@1.0.0。b@1.0.0 有一个 peer 依赖为 c@^1。a@1.0.0 永远不会解析b@1.0.0的 peer, 所以它也会依赖于 b@1.0.0 的 peer 。

如果需要解决peerDep引入的多实例问题,可以通过 .pnpmfile.cjs文件更改依赖项的依赖关系。

最后

本文从 npm yarn 面临的一些问题出发,通过实际例子来讲解 pnpm 是怎么解决类似问题的。怎么样,看过之后,有没有想要切换到 pnpm 的冲动呢?觉得不错的话,就大胆尝试吧。毕竟“纸上得来终觉浅,绝知此事要躬行” 嘛。

Node 社群


我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

   “分享、点赞在看” 支持一波👍

程序员成长指北
专注 Node.js 技术栈分享,从 前端 到 Node.js 再到 后端数据库,祝您成为优秀的高级 Node.js 全栈工程师。一个有趣的且乐于分享的人。座右铭:今天未完成的,明天更不会完成。
 最新文章