具体对比npm、yarn、pnpm —— pnpm确实很香

5,505 阅读10分钟

在我们的前端开发过程中,往往需要引用各种不同库,安装和管理这些库就需要用到包管理工具

主流的包管理工具

本文主要讲目前前端开发中使用的三个主流的包管理工具,分别为 npm、yarn和pnpm。

npm

2010 年发布,是nodeJs内置的包管理工具。

yarn

Facebook 2016年发布的包管理工具, 为了解决当时npm存在的一些缺陷

  • 使用yarn.lock文件解决那时候npm安装的不确定性(那时候npm还没有lock文件,npm5才有)
  • yarn并行安装的机制比npm的顺序安装速度更快,
  • 带来了可以从缓存中获取的离线模式。
  • 更简洁的命令行输出
  • 更好的语义化命令,比如 yarn add/remove等

安装yarn

npm i -g yarn

pnpm

pnpm,全称为performant npm,意为高性能的Node.js包管理器,由Zoltan Kochan 于2017 年发布,具有速度快、节省磁盘空间的特点。

安装pnpm

npm i -g pnpm

对比npm、yarn、pnpm

在对比这三个管理工具之前,为了更加直观地体现npm、yarn、pnpm的区别,笔者准备三个测试用的npm包,分别是

  • kai_npm_test_a(下面简称A包)
  • kai_npm_test_b(下面简称B包)
  • kai_npm_test_c(下面简称C包)

这三个包的依赖关系为 C 包中添加了B包,B包添加了A包

三个包的代码如下

// A包
export const AName = "kai_npm_test_a";
// B包
import { AName } from "kai_npm_test_a";

export const BName = "kai_npm_test_b";

export function getImportPackageName() {
  return AName;
}
// C包
import { BName } from "kai_npm_test_b";

export const CName = "kai_npm_test_c";

export function getImportPackageName() {
  return BName;
}

依赖包的占用空间

不管是使用npm或者是yarn,安装依赖时一般是下载该依赖的tar包到本地离线镜像,然后解压到本地缓存,最后再将其拷贝到项目的node_modules中。

如果有100个项目,并且这100个项目都有一个相同的依赖包,那么这100个项目的node_modules中都保存有这个相同依赖的副本,也就是说硬盘上需要保存100份相同依赖包的副本。

那我们肯定希望的是如果是一样的依赖,那应该只保存一份,而这100个项目都一起使用这同一份依赖。而pnpm通过 hard link (硬链接)的方式来实现。

pnpm安装依赖时,依赖包会被存放在统一的位置(称为store,可以通过命令pnpm store path获取store的位置),然后使用该依赖的项目会硬链接对应的依赖位置,也就是说所有用这个的依赖的项目都是通过硬链接的方式共享了同一份store上的依赖。

关于什么是 hard link(硬链接),这里简单说下,在Linux文件系统中,磁盘中的文件都有一个索引编号(Inode Index),在Linux中,可以多个文件名指向同一索引节点,这种就是硬链接。硬链接的机制可以让多个不同的位置寻址到相同的空间,也就说硬链接文件和原始文件其实是同一份文件,所以在pnpm管理的项目,100个项目的相同依赖只需占用一份依赖的空间。

而且后续安装依赖时,如果该依赖之前已经安装过了,在store中已经有了该依赖,这时候就会直接使用hard link,大大减少安装时间。

所以,总的来说,pnpm比起npm和yarn不仅节省了磁盘空间,并且安装速度更快。

下图是pnpm官网的各种安装方式下的性能对比图,可以发现综合来看,pnpm的速度是最快的。

alotta-files.svg

node_modules结构

npm或yarn执行npm install/yarn add添加依赖后,会把对应的包添加到 node_modules 中。

在早期的npm1和npm2中 node_modules 的目录结构是嵌套的结构,如果安装了我们的测试C包,则node_modules 的目录结构会如下

node_modules
└─ kai_npm_test_c
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ kai_npm_test_b
         ├─ index.js
         ├─ package.json
         └─ node_modules
      		└─ kai_npm_test_a
         	├─ index.js
         	└─ package.json

这种嵌套的结构比较直观的体现各个依赖之间的关系,但是也导致了以下几个问题,特别在项目依赖比较多,依赖嵌套比较深的情况下

  • 嵌套结构非常深的情况下,越深层的依赖的文件路径就越长,这会带来很多麻烦,特别在window系统下,很多程序无法处理超过260个字符的文件路径名。

  • 相同的依赖会重复安装造成浪费,比如上面的例子在安装了C包后又再安装了其他依赖包(例如叫它D包),假如D包也引用了测试B包,则B包会重复安装,并出现在C包和D包下的node_modules中。

所以到了npm3以后,就和yarn一样,采用扁平依赖树来管理依赖包,解决了依赖嵌套层级过深的问题,也避免了相同版本的包重复安装的问题。

npm3和yarn安装测试C包后的node_modules 的目录结构如下

node_modules
├─ kai_npm_test_a
│  ├─ index.js
│  └─ package.json
│
├─ kai_npm_test_b
│  ├─ index.js
│  └─ package.json
│
└─ kai_npm_test_c
   ├─ index.js
   └─ package.json

扁平依赖树解决了上诉的嵌套结构的问题,但是要生成扁平依赖的结构,就需要先遍历所有项目依赖关系来构建完整的依赖树,以此来决定生成的目录结构,这也造成了安装慢的问题。

同时,扁平依赖还带来了其他的问题,例如,上面提供了A,B,C三个测试包,假如我们再加一个D包,D包和C包一样都依赖于B包,不过D包依赖的B包版本是1.0.0,而C包依赖的B包版本则是1.0.1。

假如我们在npm或者yarn的项目中同时安装C包和D包,那么安装后的项目node_modules结构会是下面的哪一种呢

// 第一种
node_modules
├─ kai_npm_test_a
│  ├─ index.js
│  └─ package.json
│
├─ kai_npm_test_b  // @1.0.1
│  ├─ index.js
│  └─ package.json
│
├─ kai_npm_test_c
|  ├─ index.js
|  └─ package.json
|
└─ kai_npm_test_d
   ├─ node_modules
   |  └─ kai_npm_test_b // @1.0.0
   |     ├─ index.js
   |     └─ package.json
   ├─ index.js
   └─ package.json
// 第二种
node_modules
├─ kai_npm_test_a
│  ├─ index.js
│  └─ package.json
│
├─ kai_npm_test_b  // @1.0.0
│  ├─ index.js
│  └─ package.json
│
├─ kai_npm_test_c
|  ├─ node_modules
|  |  └─ kai_npm_test_b // @1.0.1
|  |     ├─ index.js
|  |     └─ package.json
|  ├─ index.js
|  └─ package.json
|
└─ kai_npm_test_d
   ├─ index.js
   └─ package.json

分析安装后的项目node_modules结构是上面的哪一种,其实就是判断哪个版本的包会被提到“扁平结构”中,网上不少资料说是取决于package.json中依赖的位置,声明在前面的会被提出来,但是笔者试验了几次发现其实不是,上面的例子不管我把C包写在D包前面还是把D包写在C包前面,安装后的node_modules结构都是上面的第一种,也就是说都是C包依赖的B包@1.0.1版本会被提出。

其实在安装依赖包时,npm/yarn会对所有依赖先进行一次排序,按照字典序kai_npm_test_c在kai_npm_test_d的前面,所以不管package.json中的顺序怎么写,被提取出来的都会是C包的依赖B包。

pnpm的依赖管理则用了另外的一种方式,我们同样用pnpm安装测试C包后,node_modules的结构如下

image-20220516103718304.png

可以看出node_modules下的目录非常简洁,我们这里只安装了C包,所以node_modules下就有了kai_npm_test_c文件夹,但是注意,这里其实是一个软链接(图中可以看到C包的右边有个弯曲的箭头),关于什么是软链接,简单说下,软链接(soft link),也叫符号链接(symbolic link),是指向另一个文件的特殊文件,可以简单理解为一个快捷方式。

关于 Node 的模块解析算法,Node在解析模块时,会忽略符号链接,直接解析其真正位置。

通过在node_modules下执行ls -l命令,我们可以看到其真正指向的位置是/.pnpm/kai_npm_test_c@1.0.1/node_modules/kai_npm_test_c',在node_modules下的.pnpm文件夹中

image-20220515192128531.png

如下图,.pnpm下的目录结构,可以发现其目录结构是扁平的结构,并且其目录的名称都是包名加上@符号和版本号。

image-20220515192849561.png

点开kai_npm_test_c@1.0.1,如下图,可以看到上面说到的C包软链接的真正位置是在kai_npm_test_c@1.0.1下的node_modules文件夹下的kai_npm_test_c文件夹。这里的kai_npm_test_c文件夹就是全局对应依赖的硬链接,而由于C包依赖B包,所以该node_modules文件夹下还有个kai_npm_test_b文件夹,这里是个软链接,真正位置在.pnpm下的kai_npm_test_b@1.0.1/node_modules/kai_npm_test_b。

同理,由于B包依赖A包,所以目录kai_npm_test_b@1.0.1/node_modules下也会有个软链接的kai_npm_test_a,其真正位置就是.pnpm下的kai_npm_test_a@1.0.1/node_modules/kai_npm_test_a

image-20220516002303015.png

总的来说,项目根目录下的node_modules文件夹下的各个依赖文件夹都是软链接,而 .pnpm 文件夹下有所有依赖的扁平化结构,以依赖名加版本号命名目录名,其目录下的node_modules下有个相同依赖名的目录,是硬链接,除了相同依赖名的目录,如果该依赖还有其他的依赖,也会展示在同级下,是软链接,它们的真正位置也是扁平在.pnpm项目下的对应位置的硬链接。

├─.pnpm
│  ├─kai_npm_test_a@1.0.1
│  │  └─node_modules
│  │      └─ kai_npm_test_a //硬链接 -> <store>/kai_npm_test_a
│  │         ├─ index.js
│  │		 └─ package.json
│  ├─kai_npm_test_b@1.0.1
│  │  └─node_modules
│  │      ├─ kai_npm_test_a //软链接 -> .pnpm/kai_npm_test_a@1.0.1/node_modules/kai_npm_test_a
│  │      │  ├─ index.js
│  │	  │  └─ package.json
│  │      └─ kai_npm_test_b //硬链接 -> <store>/kai_npm_test_b
│  │         ├─ index.js
│  │		 └─ package.json
│  ├─kai_npm_test_c@1.0.1
│  │  └─node_modules
│  │      ├─ kai_npm_test_b //软链接 -> .pnpm/kai_npm_test_b@1.0.1/node_modules/kai_npm_test_b
│  │      │  ├─ index.js
│  │	  │	 └─ package.json
│  │      └─ kai_npm_test_c //硬链接 -> <store>/kai_npm_test_c
│  │         ├─ index.js
│  │		 └─ package.json
│  └─node_modules
│      ├─ kai_npm_test_a //软链接 -> .pnpm/kai_npm_test_a@1.0.1/node_modules/kai_npm_test_a
│	   │  ├─ index.js
│  	   │  └─ package.json
│      └─ kai_npm_test_b //软链接 -> .pnpm/kai_npm_test_b@1.0.1/node_modules/kai_npm_test_b
│  		  ├─ index.js
│  		  └─ package.json
└─ kai_npm_test_c   // 软链接 -> .pnpm/kai_npm_test_c@1.0.1/node_modules/kai_npm_test_c
   ├─ index.js
   └─ package.json

所以,pnpm通过这种设计让根目录下的 node_modules 下面不再是乱糟糟,而是和你 package.json 中的依赖保持一致,而在.pnpm文件夹中,通过软链接创建的嵌套结构可以清晰的看出各个依赖间的依赖关系。

用pnpm同时安装C包和D包的结果如下

image-20220516151344128.png

可以发现.pnpm中有两个不同版本的B包,可以说pnpm的这种结构看上去更加的直观清晰。

安全性

这里谈论安全性,其实就是讲依赖的非法访问问题,什么是非法访问依赖,就是说如果某个包没有在项目的package.json中声明,讲道理,它是不能在项目中使用的。

还是以上面的测试包为例,C包依赖B包,B包依赖A包,假如我们在npm和yarn的项目中只安装C包,由于node_modules下是扁平化结构,所以其实我们在项目中可以正常引用A包。类似A包这种依赖也被称为幽灵依赖(Phantom dependencies)。

import { AName } from "kai_npm_test_a";
console.log(AName);

只装C包的情况下上面的代码可以正常执行。即使上线也可以正常运行。

但是这里面其实有潜在的风险,由于我们没有在package.json中声明直接使用的A包,所以我们并不知道B包中的A包依赖的版本控制是怎么样的,万一有天B包更新了,换了一个版本的A包或者干脆不用A包了,我们的代码可能会直接报错。

而在pnpm的项目中,由于其node_modules结构(如上面所说),在只装C包的情况下无法直接引用A包,直接引用会报错,只有真正在依赖项中的包才能访问。一定程度上保障了安全性。

pnpm命令

pnpm的使用和npm/yarn都是大同小异,下面列举下常用的几个命令

npmyarnpnpm
安装所有依赖npm installyarnpnpm install
安装依赖npm install <pkg>yarn add <pkg>pnpm install <pkg> / pnpm add <pkg>
移除依赖npm uninstall <pkg>yarn remove <pkg>pnpm remove <pkg> / pnpm uninstall <pkg>
更新依赖npm update <pkg>yarn update <pkg>pnpm update <pkg> / pnpm update <pkg>

总结

通过上面的分析,pnpm通过巧妙的设计解决了npm和yarn的问题。pnpm已经在开源社区得到许多认可,包括Vue3以及Vue的生态项目已经切换使用pnpm。同时,pnpm的作者也很积极地在完善和规划pnpm的未来。所以,准备好拥抱pnpm吧。