logo

为什么更推荐使用 pnpm 代替 npm/yarn?

Mon Feb 06 2023 Posted 2 years ago

现在越来越多的公司或开源项目开始使用 pnpm 作为包管理工具,这篇文章主要想分享一下这个优秀的包管理器的用法和原理,以及为什么我们应该使用它来替代 npmyarn

pnpm 是什么? #

根据官方文档的描述,我们可以知道 pnpm 是一个快速的节省磁盘空间的包管理工具,同时它还对 monorepos 有良好的支持。

它的用处与 npmyarn 并没有什么本质区别,甚至连用法都十分相似。

并且它的安装也非常简单:npm i pnpm -g

pnpm的优势是什么? #

1. 速度快 #

这里直接放上官方文档中的 benchmark 对比:

Graph of the alotta-files results

我们可以清晰地看到在大多数的情况下,pnpm 的安装速度都要优于 npm/yarn

2. 节省磁盘空间 #

pnpm 内部使用**基于内容寻址存储(CAS - Content-addressable store)**的方式来存储依赖,它是一种存储信息的方式,根据内容而不是位置进行检索信息的存储方式,被用于高速存储和检索的固定内容.。它的优点在于:

  • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次。但使用 pnpm 则只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink(硬链接)。
  • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink仅仅写入那一个新增的文件

3. 支持 monorepo #

关于 monorepo 可以看这篇文章的介绍。

pnpmmonorepo 的支持体现在各个子命令的功能上,比如在根目录下 pnpm add A -r, 那么所有的 package 中都会被添加 A 这个依赖。

4. 安全性高 #

如果使用 npm/yarn 进行包管理,由于 node_module 的扁平结构,如果 A 依赖 B, B 依赖 C,那么 A 当中是可以直接使用 C 的,但问题是 A 当中并没有声明 C 这个依赖(幽灵依赖)。因此会出现这种非法访问的情况。但 pnpm 自创了一套依赖管理方式,很好地解决了这个问题,保证了安全性。

依赖管理方式对比 #

npm/yarn 的原理 #

当执行 npm/yarn install 命令之后,首先会构建依赖树,然后针对每个节点下的包会经历以下四个步骤:

  1. 将依赖包的版本区间解析为某个具体的版本号
  2. 下载对应版本依赖的 tar 包到本地离线镜像
  3. 将依赖从离线镜像解压到本地缓存
  4. 将依赖从缓存拷贝到当前目录的 node_modules 目录

之后对应包就会到达项目中的 node_modules 文件夹下。

npm 3.0 版本之前,项目的 node_modules 会呈现出嵌套结构:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json
└─ zoo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json

这种嵌套依赖树设计存在几个严重的问题:

  1. 依赖层级太深,这会导致文件的路径过长的问题,毕竟 windows 系统的文件路径默认最多支持 256 个字符。
  2. 会出现很多包被重复安装的情况,导致项目体积暴涨。比如上面 foobar 都依赖于 bar,那么 bar 就会在两者的 node_modules 中被安装两次。
  3. 模块实例不能共享。比如 React 有一些内部变量,在两个不同包引入的 React 不是同一个模块实例,因此无法共享内部变量,导致一些不可预知的 bug。

后来 yarn 横空出世,解决了上面的几个问题,并且 npm 也在 3.0 版本中沿用了 yarn 的解决方案,这个解决方案就是扁平化依赖

所谓的扁平化依赖就是将所有依赖铺平,放到同一级目录下。这时的 node_modules 结构类似这样:

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
|  ├─ index.js
|  └─ package.json
└─ zoo
   ├─ index.js
   └─ package.json

在这种扁平化管理下,node_modules 目录下不会再有很深层次的嵌套关系,这样在安装新的包时,根据 node require 机制,会不停往上级的 node_modules 当中去找,如果找到相同版本的包就不会重新安装,解决了包重复安装的问题。

虽然之前的问题得到了解决,但同时这种扁平化的处理方式自身也存在许多问题,其中最为明显的问题就是”幽灵依赖“。

所谓的幽灵依赖是指我们明明没有在 package.json 的 dependencies 里声明某个依赖,但在代码里却可以 import 进来。原因很简单,因为项目依赖被铺平了,那么依赖的依赖自然也是可以被引入到项目中。

幽灵依赖带来的弊端很明显:我们显式依赖了A,A又依赖了B,这时候我们在项目中直接使用B是可以的,但如果某一天A不再依赖于B,那么我们项目中使用B的地方就会报错。

那么如何解决幽灵依赖问题呢?这就要提到 pnpm 了。

硬链接和软链接 #

在探索 pnpm 的原理之前,我们必须要知道两个重要概念:硬链接软链接

首先说一下两种概念的定义,这里我直接贴出 wiki 上的定义。

  • 硬链接(hard link)是计算机文件系统中的多个文件平等地共享同一个文件存储单元
  • 符号链接软链接、Symbolic link)是一类特殊的文件,其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用

image-20230206155912289

软链接其实很好理解,它就相当于 windows 系统中的快捷方式。一个符号链接文件仅包含有一个文本字符串,其被操作系统解释为一条指向另一个文件或者目录的路径。它是一个独立文件,其存在并不依赖于目标文件。如果删除一个符号链接,它指向的目标文件不受影响。如果目标文件被移动、重命名或者删除,任何指向它的符号链接仍然存在,但是它们将会指向一个不复存在的文件

image-20230206153319348

那么什么是硬链接呢?我简单画了一幅图来解释它的概念:

image-20230206155004217

举一个不那么恰当的例子,我们可以把硬链接想象成 JavaScript 中的对象引用:

const foo = {
  age: 23
}
const bar = foo
bar.age = 24
console.log(foo.age) // 24

理解了硬链接和软链接后,我们就可以开始了解 pnpm 的原理了。

pnpm的原理 #

我们可以在两个项目中分别用 npmpnpm 来安装 express ,看一下两者的 node_modules 有什么不同。

首先是 npm :

image-20230206151328605

然后是 pnpm:

image-20230206151347503

通过简单的两个图的对比,我们可以明显感觉到 pnpm 的目录结构更加合理,因为我们的项目只依赖了 express,那么 node_modules 中就应该只存在 express 的文件。但问题在于 express 的依赖被放到哪里了呢?

其实这里的 express 仅仅只是一个软链接(注意截图中 express 文件夹右侧的小箭头),里面并不存在 node_modules。它的真正位置其实位于同级目录下的 .pnpm 文件夹中,展开 .pnpm 文件夹,我们可以找到其真正的位置,也就是 .pnpm/express@4.17.1/node_modules/express

image-20230206152045398

这也代表了 pnpm 中的依赖规律,也是 <package-name>@version/node_modules/<package-name> 这种目录结构。并且 express 的依赖都在 .pnpm/express@4.17.1/node_modules 下面,并且这些依赖也全都是软链接

也就是说,所有的依赖都是从全局 store 硬链接到了 node_modules/.pnpm 下,然后之间通过软链接来相互依赖。

官方文档中给出了这么一张图,可以很清晰地明白其中的原理。

img

包本身依赖放在同一个node_module下面,与原生 Node 完全兼容,又能将 package 与相关的依赖很好地组织到一起,不得不说 pnpm 的设计十分精妙。

总结:pnpm 使用软链接来创建依赖项的嵌套结构,将项目的直接依赖符号链接到node_modules的根目录,直接依赖的实际位置在.pnpm/<name>@<version>/node_modules/<name>,依赖包中的每个文件再硬链接到.pnpm store

pnpm的基本使用 #

pnpm 的使用成本非常低,因为它的基础命令和 npm/yarn 基本相似。

pnpm install #

npm install 类似,安装项目下所有的依赖。但对于 monorepo 项目,会安装 workspace 下面所有 packages 的所有依赖。不过可以通过 --filter 参数来指定 package,只对满足条件的 package 进行依赖安装。

// 安装 lodash
pnpm install lodash
// 添加至 devDependencies
pnpm install lodash -D
// 添加至 dependencies
pnpm install lodash -S

pnpm update #

根据指定的范围将包更新到最新版本,monorepo 项目中可以通过 --filter 来指定 package。

pnpm uninstall #

node_modulespackage.json 中移除指定的依赖。monorepo 项目同上。

pnpm run #

运行一个在 package.json 中定义的脚本

"scripts": {
    "watch": "webpack --watch"
}

pnpm run watch
// 或者简写为 pnpm watch (仅适用于那些不与已有的pnpm 命令相同名字的脚本)

结论 #

综合来看,pnpm 是一个相比 npm/yarn 更为优秀的包管理方案,推荐在新项目中使用。

参考资料:

  1. https://juejin.cn/post/7121386382936768542
  2. https://juejin.cn/post/6932046455733485575
  3. https://juejin.cn/post/7127295203177676837
  4. https://pnpm.io/zh/motivation
  5. https://www.bilibili.com/video/BV1hR4y1X73Q