image.png

https://pnpm.io

1. What

快速的,节省磁盘空间的包管理工具

  • 快速:pnpm 比替代方案快 2 倍
  • 高效:Node_modules 中的文件是从一个单一的可内容寻址的存储中链接过来的
  • 支持 monorepos:pnpm 内置支持了单仓多包
  • 严格:pnpm 创建了一个非平铺的 node_modules,因此代码无法访问任意包

1.1 节约磁盘空间并提升安装速度

当使用 npm 或 Yarn 时,如果你有100个项目使用了某个依赖,就会有100份该依赖的副本保存在硬盘上。 对于 pnpm ,依赖项将存储在一个内容可寻址的仓库中,因此:

如果你用到了某依赖项的不同版本,那么只会将有差异的文件添加到仓库。 例如,如果它有100个文件,而新版本只改变了其中1个文件。那么 pnpm update 只会向存储中心添加1个新文件,不会仅仅因为单一的改变而克隆整个依赖。
所有文件都会存储在硬盘上的同一位置。 当多个包(package)被安装时,所有文件都会从同一位置创建硬链接,不会占用额外的磁盘空间。 这允许你跨项目地共享同一版本的依赖。
最终你节省了大量与项目和依赖成比例的硬盘空间,并且拥有更快的安装速度!

1.2 创建非扁平化的 node_modules 文件夹

当使用 npm 安装依赖时,所有的依赖都会被提升到模块的根目录。 因此,项目可以访问到未被添加进 当前 项目的依赖。
pnpm 使用软链的方式将项目的直接依赖添加进模块文件夹的根目录。

1.3 Benchmarks

作为黄色部分的 pnpm,在绝多大数场景下,包安装的速度都明显优于 npm/yarn,速度会比 npm/yarn 快 2-3 倍。
image.png

2. Why

2.1 为什么快?

2.1.1 软链&硬链

image.png
inode
inode是指在许多“类Unix文件系统”中的一种数据结构。每个inode保存了文件系统中的一个文件系统对象(包括文件、目录、设备文件、socket、管道, 等等)的元信息数据,但不包括数据内容或者文件名。

文件系统中每个“文件系统对象”对应一个“inode”数据,并用一个整数值来辨识。这个整数常被称为inode号码(“i-number”或“inode number”)。由于文件系统的inode表的存储位置、总条目数量都是固定的,因此可以用inode号码去索引查找inode表。

简而言之:

  • inode存储的是文件的元数据
  • inode是文件在磁盘上的索引编号
  • inode是文件的唯一标示符(主键), 而非文件名
  • Linux系统中,显示文件的inode使用ls -i,使用df -i可以显示当前挂载列表中inode使用情况

硬链接
和源文件的inode节点号相同,两者互为硬链接。

软链接
软链接也叫符号链接,就是一个普通文件,只是数据块内容有点特殊,类似于快捷方式,存储着源文件的位置信息便于指向。文件数据块中存放的内容是源文件的位置信息便于指向。

2.1.2 结合pnpm

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

  • 直接依赖硬连接到store目录
  • 非直接依赖软链接到虚拟目录(.pnpm)

示例
假如有一个项目依赖了 bar@1.0.0 和 foo@1.0.0 ,那么最后的 node_modules 结构呈现出来的依赖结构可能会是这样的:

  1. node_modules
  2. └── bar // symlink to .pnpm/bar@1.0.0/node_modules/bar
  3. └── foo // symlink to .pnpm/foo@1.0.0/node_modules/foo
  4. └── .pnpm
  5. ├── bar@1.0.0
  6. └── node_modules
  7. └── bar // hardlink to <store>/bar
  8. ├── index.js
  9. └── package.json
  10. └── foo@1.0.0
  11. └── node_modules
  12. └── foo // hardlink to <store>/foo
  13. ├── index.js
  14. └── package.json

兼容性问题
实际上 hard link 在主流系统上(Unix/Win)使用都是没有问题的,但是 symlink 即软连接的方式可能会在windows 存在一些兼容的问题,但是针对这个问题,pnpm 也提供了对应的解决方案:在 win 系统上使用一个叫做junctions 的特性来替代软连接,这个方案在 win 上的兼容性要好于 symlink。

或许你也会好奇为啥 pnpm 要使用 hard links 而不是全都用 symlink 来去实现。
实际上存在 store 目录里面的依赖也是可以通过软连接去找到的,nodejs 本身有提供一个叫做 —preserve-symlinks 的参数来支持 symlink,但实际上这个参数实际上对于 symlink 的支持并不好导致作者放弃了该方案从而采用 hard links 的方式:
image.png
具体可以参考 https://github.com/nodejs/node-eps/issues/46 该issue 讨论。

2.2 为什么安全?

image.png

2.2.1 Phatom

“幽灵依赖”:某个包没有被安装(package.json 中并没有)但是用户却能够引用到这个包。

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

  1. package.json -> foo(bar foo 依赖)
  2. node_modules
  3. /foo
  4. /bar -> 幽灵依赖

那么这里这个 bar 就成了一个幽灵依赖,如果某天某个版本的 foo 依赖不再依赖 bar 或者 foo 依赖的 bar 的版本发生了变化,那么 require bar 的模块部分就会抛错。

以上其实只是一个简单的例子,但是在很多 monorepo (主要为 lerna + yarn )项目中,这其实是个比较常见的现象,甚至有些包会直接去利用这种残缺的引入方式去减轻包体积。

而根据前面提到的 pnpm 的 node_modules 依赖结构,这种现象是显然不会发生的,因为被打平的依赖会被放到 .pnpm 这个虚拟磁盘目录下面去,用户通过 require 是根本找不到的。

值得一提的是,pnpm 本身其实也提供了将依赖提升并且按照 yarn 那种形式组织的 node_modules 结构的 Option,作者将其命名为 —shamefully-hoist ,即 “羞耻的 hoist”

2.2.2 Doppelgangers

npm1, npm2中的嵌套结构:

  1. node_modules
  2. └─ foo
  3. ├─ index.js
  4. ├─ package.json
  5. └─ node_modules
  6. └─ bar
  7. ├─ index.js
  8. └─ package.json
  • 依赖层级太深,会导致文件路径过长的问题,尤其在 window 系统下。
  • 重复安装,文件体积超级大。比如跟 foo 同级目录下有一个baz,两者都依赖于同一个版本的lodash,那么 lodash 会分别在两者的 node_modules 中被安装,也就是重复安装。
  • 模块实例不能共享。比如 React 有一些内部变量,在两个不同包引入的 React 不是同一个模块实例,因此无法共享内部变量,导致一些不可预知的 bug。

npm3及以上,包括yarn的扁平结构:

  1. node_modules
  2. ├─ foo
  3. | ├─ index.js
  4. | └─ package.json
  5. └─ bar
  6. ├─ index.js
  7. └─ package.json
  • 依赖结构的不确定性
  • 扁平化算法本身的复杂性很高,耗时较长。
  • 项目中可以非法访问没有声明过依赖的包

不确定性
假如现在项目依赖两个包 foo 和 bar,这两个包的依赖又是这样的:
image.png
那么 npm/yarn install 的时候,通过扁平化处理之后,究竟是这样:
image.png
还是这样?
image.png
答案是: 都有可能。取决于 foo 和 bar 在 package.json中的位置,如果 foo 声明在前面,那么就是前面的结构,否则是后面的结构。

这就是为什么会产生依赖结构的不确定问题,也是 lock 文件诞生的原因,无论是package-lock.json(npm 5.x才出现)还是yarn.lock,都是为了保证 install 之后都产生确定的node_modules结构。

尽管如此,npm/yarn 本身还是存在扁平化算法复杂和package 非法访问的问题,影响性能和安全。

3. How

3.1 pnpm install

别名: i

pnpm install 用于安装项目所有依赖.
在CI环境中, 如果存在需要更新的 lockfile 会安装失败.
在 workspace内, pnpm install 下载项目所有依赖. 如果想禁用这个行为, 将 recursive-install 设置为 false.

—shamefully-hoist
默认值: false
类型:Boolean
创建一个扁平node_modules 目录结构, 类似于npm 或 yarn. 非常不推荐但却不得不设置,为了现有项目的兼容性。

3.2 pnpm add

安装软件包及其依赖的任何软件包。 默认情况下,任何新软件包都安装为生产依赖项。

pnpm add sax 保存到 dependencies
pnpm add -D sax 保存到 devDependencies
pnpm add -O sax 保存到 optionalDependencies
pnpm add sax@next 安装 next tag
pnpm add sax@3.0.0 安装指定版本 3.0.0

3.3 pnpm remove

别名: rm, uninstall, un

从项目的 package.json 文件 及 node_modules 目录删除指定模块。

pnpm remove sax -D 仅删除 devDependencies 中的依赖项
pnpm remove sax -P 仅删除 dependencies 中的依赖项
pnpm remove —global sax 从全局删除一个依赖包

3.4 pnpm update

别名: up

pnpm update 根据指定的范围更新软件包的最新版本。
在不带参数的情况下使用时,将更新所有依赖关系。 您可以使用一些模式来更新特定的依赖项。

pnpm up 遵循 package.json 指定的范围更新所有的依赖项
pnpm up —latest 更新所有依赖项,此操作会忽略 package.json 指定的范围
pnpm up foo@2 将 foo 更新到 v2 上的最新版本
pnpm up “@babel/*” 更新 @babel 范围内的所有依赖项

3.5 pnpm store prune

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

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

3.6 workspace

对于 monorepo 类型的项目,pnpm 提供了 workspace 来支持,具体可以参考官网文档: https://pnpm.io/workspaces/

4. Who

Who’s Using This?

image.png

5. Diff

5.1 npm

nodejs 自带的包管理工具。但node与npm属于两个完全独立的组织。npm面临巨大的商业化压力。2020年3月中旬被Github收购,2018年6月Github被微软收购。
nodejs:
npm:
微软系:

  • 编程语言:typescript
  • IDE:vs code
  • 包管理:npm
  • 代码托管:github

image.png

5.2 cnpm

默认使用淘宝镜像,不支持package-lock.json

5.3 yarn

  • 通过并行下载提高了包的下载速度
  • 本地缓存:本地Copy
  • yarn.lock:版本+内容级别的lock
  • 下载界面简洁

5.4 pnpm

6. 管理包管理工具的工具

6.1 ni

https://github.com/lxchuan12/ni-analysis
ni 假设您使用锁文件(并且您应该),在它运行之前,它会检测你的 yarn.lock / pnpm-lock.yaml / package-lock.json 以了解当前的包管理器,并运行相应的命令。
假设项目目录下没有锁文件,默认就会让用户从npm、yarn、pnpm选择,然后执行相应的命令。但如果在~/.nirc文件中,设置了全局默认的配置,则使用默认配置执行对应命令。

单从这句话中可能有些不好理解,还是不知道它是个什么。我解释一下。

  1. 使用 `ni` 在项目中安装依赖时:
  2. 假设你的项目中有锁文件 `yarn.lock`,那么它最终会执行 `yarn install` 命令。
  3. 假设你的项目中有锁文件 `pnpm-lock.yaml`,那么它最终会执行 `pnpm i` 命令。
  4. 假设你的项目中有锁文件 `package-lock.json`,那么它最终会执行 `npm i` 命令。
  5. 使用 `ni -g vue-cli` 安装全局依赖时
  6. 默认使用 `npm i -g vue-cli`
  7. 当然不只有 `ni` 安装依赖。
  8. 还有 `nr` - run
  9. `nx` - execute
  10. `nu` - upgrade
  11. `nci` - clean install
  12. `nrm` - remove

总结:

  1. 根据锁文件猜测用哪个包管理器 npm/yarn/pnpm - detect 函数
  2. 抹平不同的包管理器的命令差异 - parseNi 函数
  3. 最终运行相应的脚本 - execa 工具

6.2 Corepack

https://github.com/nodejs/corepack

Corepack is an experimental tool to help with managing versions of your package managers. It exposes binary proxies for each supported package manager that, when called, will identify whatever package manager is configured for the current project, transparently install it if needed, and finally run it without requiring explicit user interactions.

简单来说,Corepack 会成为 Node.js 官方的内置 CLI,用来管理『包管理工具(npm、yarn、pnpm、cnpm)』,用户无需手动安装,即『包管理器的管理器』。

7. 参考

Node.js 简史
废宅阿斗 NPM 即将被 Node.js 官方抛弃 → Corepack
pnpm: 最先进的包管理工具
为什么现在我更推荐 pnpm 而不是 npm/yarn?