Published on

Why pnpm

Authors
  • avatar
    Name
    Pursue
    Twitter

目录

1 开篇

上文笔者详细回顾了 npm 的演进之路以及在依赖树解析上的算法迭代,同时也简单地和 yarn 进行了对比。不难看出,它们尤其在依赖树的解析上煞费苦心,从嵌套算法到扁平算法,从发现重复依赖到 hoist 依赖优化等等, 这一系列的良苦用心都是围绕着以下两个话题:

  • 如何构造一颗“完美”的依赖树
  • 如何最大化的节省依赖储存开销

2 pnpm 的大道至简

回顾 npmyarn 的依赖树结构,它们都在扁平化和嵌套化之间不断的“妥协”,根据它们现在的算法,无论优化到什么地步,都还是无法解决以下两个问题:

    1. 磁盘空间

npm 为例子,虽然 npm 在安装依赖时也有缓存机制,但缓存只能解决下载速度的问题,并不能优化存储空间。假设你本地有 10 个 Node.js 项目,他们的技术栈都类似,即有相当一部分的依赖都是重复的。 那么即使有缓存,这些依赖也得重复的安装到每个项目的node_modules文件夹下。10 个项目是这样,10000 个亦是如此。

    1. 杂乱的 node_modules

由于 npmyarn 会将间接依赖(子依赖)扁平化到 node_modules 下,如此一来,区分直接依赖(写在 package.json 里的)就会变得困难。更加具有隐患的是,由于间接依赖 直接暴露在了 node_modules 里,那么就导致间接依赖是可以直接在项目里被引用的。

以上,扁平化的树结构优化起来如此的复杂(参考上篇的解析逻辑),但最终还有这么多的问题。

pnpm在面对同样的问题时另辟蹊径,它没有费尽心思去想出一个更好的扁平化思路,而是选择在逻辑上保留原有树结构的同时,不但能让 node_modules 直观明了,而且还节省了空间。

如此神器,我们来一探究竟。

3 pnpm 一探究竟

3.1 巧妙的 node_modules 结构

我们以axios为例,分别用npmpnpm来安装,node_modules文件结构如下:

package.json
{
  "dependencies": {
    "axios": "latest"
  }
}
npm
node_modules
├── asynckit
│   └── lib
├── axios
│   ├── dist
│   │   ├── browser
│   │   ├── esm
│   │   └── node
│   └── lib
│       ├── adapters
│       ├── cancel
│       ├── core
│       ├── defaults
│       ├── env
│       │   └── classes
│       ├── helpers
│       └── platform
│           ├── browser
│           │   └── classes
│           ├── common
│           └── node
│               └── classes
├── combined-stream
│   └── lib
├── delayed-stream
│   └── lib
├── follow-redirects
├── form-data
│   └── lib
├── mime-db
├── mime-types
└── proxy-from-env
pnpm
node_modules
└── axios -> .pnpm/axios@1.6.2/node_modules/axios

对比很清晰:npm由于扁平化了所有的子依赖,不得不把它们全部都暴露在第一层,导致无法分辨直接依赖是什么;反观pnpm,你所见的就是直接依赖,完全和package.json里的定义一一映射,十分可读。

pnpm是如何做到的呢?axios -> .pnpm/axios@1.6.2/node_modules/axios是一个软连接,这个指向似乎透露了一些信息。

我们深入查看下pnpm下的node_modules路径(将.pnpm 也列出来):

node_modules
node_modules
├── .pnpm
│   ├── asynckit@0.4.0
│   │   └── node_modules
│   │       └── asynckit
│   │           └── lib
│   ├── axios@1.6.2
│   │   └── node_modules
│   │       ├── axios
│   │       │   ├── dist
│   │       │   │   ├── browser
│   │       │   │   ├── esm
│   │       │   │   └── node
│   │       │   └── lib
│   │       │       ├── adapters
│   │       │       ├── cancel
│   │       │       ├── core
│   │       │       ├── defaults
│   │       │       ├── env
│   │       │       │   └── classes
│   │       │       ├── helpers
│   │       │       └── platform
│   │       │           ├── browser
│   │       │           │   └── classes
│   │       │           ├── common
│   │       │           └── node
│   │       │               └── classes
│   │       ├── follow-redirects -> ../../follow-redirects@1.15.3/node_modules/follow-redirects
│   │       ├── form-data -> ../../form-data@4.0.0/node_modules/form-data
│   │       └── proxy-from-env -> ../../proxy-from-env@1.1.0/node_modules/proxy-from-env
│   ├── combined-stream@1.0.8
│   │   └── node_modules
│   │       ├── combined-stream
│   │       │   └── lib
│   │       └── delayed-stream -> ../../delayed-stream@1.0.0/node_modules/delayed-stream
│   ├── delayed-stream@1.0.0
│   │   └── node_modules
│   │       └── delayed-stream
│   │           └── lib
│   ├── follow-redirects@1.15.3
│   │   └── node_modules
│   │       └── follow-redirects
│   ├── form-data@4.0.0
│   │   └── node_modules
│   │       ├── asynckit -> ../../asynckit@0.4.0/node_modules/asynckit
│   │       ├── combined-stream -> ../../combined-stream@1.0.8/node_modules/combined-stream
│   │       ├── form-data
│   │       │   └── lib
│   │       └── mime-types -> ../../mime-types@2.1.35/node_modules/mime-types
│   ├── mime-db@1.52.0
│   │   └── node_modules
│   │       └── mime-db
│   ├── mime-types@2.1.35
│   │   └── node_modules
│   │       ├── mime-db -> ../../mime-db@1.52.0/node_modules/mime-db
│   │       └── mime-types
│   ├── node_modules
│   │   ├── asynckit -> ../asynckit@0.4.0/node_modules/asynckit
│   │   ├── combined-stream -> ../combined-stream@1.0.8/node_modules/combined-stream
│   │   ├── delayed-stream -> ../delayed-stream@1.0.0/node_modules/delayed-stream
│   │   ├── follow-redirects -> ../follow-redirects@1.15.3/node_modules/follow-redirects
│   │   ├── form-data -> ../form-data@4.0.0/node_modules/form-data
│   │   ├── mime-db -> ../mime-db@1.52.0/node_modules/mime-db
│   │   ├── mime-types -> ../mime-types@2.1.35/node_modules/mime-types
│   │   └── proxy-from-env -> ../proxy-from-env@1.1.0/node_modules/proxy-from-env
│   └── proxy-from-env@1.1.0
│       └── node_modules
│           └── proxy-from-env
└── axios -> .pnpm/axios@1.6.2/node_modules/axios

根据上图我们可以尝试总结下 pnpm 的文件规则如下:

  • 根目录下只暴露了直接依赖,子依赖都被藏在了.pnpm 下
  • .pnpm 里列出了项目里的所有依赖(含子依赖),有点类似 npm 或者yarn 的扁平化思路
  • 所有在.pnpm 下的依赖都符合依赖名@版本号的命名规则,而每个依赖下的 node_modules 里又继续列出了该依赖下的直接依赖(且包含自身)

这似乎有点乱,它能正常被 Node.js 的模块解析吗?

我们尝试拿其中一个子依赖delayed-stream作为例子,看看它是如何被 Node.js 解析的。

首先查看delayed-stream的依赖路径:

pnpm why delayed-stream
project
└─┬ axios@1.6.2 -> ./node_modules/.pnpm/axios@1.6.2/node_modules/axios
  └─┬ form-data@4.0.0 -> ./node_modules/.pnpm/form-data@4.0.0/node_modules/form-data
    └─┬ combined-stream@1.0.8 -> ./node_modules/.pnpm/combined-stream@1.0.8/node_modules/combined-stream
      └── delayed-stream@1.0.0 -> ./node_modules/.pnpm/delayed-stream@1.0.0/node_modules/delayed-stream

层级看起来很清晰,但这里有个问题,axios@1.6.2form-data@4.0.0所指向的源文件并不在同一个node_modules文件里,当axios是如何找到自己的依赖form-data的呢?

我们把它们分别展开一下:

axios
node_modules/.pnpm/axios@1.6.2
└── node_modules
    ├── axios
    │   ├── dist
    │   │   ├── browser
    │   │   ├── esm
    │   │   └── node
    │   └── lib
    │       ├── adapters
    │       ├── cancel
    │       ├── core
    │       ├── defaults
    │       ├── env
    │       │   └── classes
    │       ├── helpers
    │       └── platform
    │           ├── browser
    │           │   └── classes
    │           ├── common
    │           └── node
    │               └── classes
    ├── follow-redirects -> ../../follow-redirects@1.15.3/node_modules/follow-redirects
    ├── form-data -> ../../form-data@4.0.0/node_modules/form-data
    └── proxy-from-env -> ../../proxy-from-env@1.1.0/node_modules/proxy-from-env
form-data
node_modules/.pnpm/form-data@4.0.0
└── node_modules
    ├── asynckit -> ../../asynckit@0.4.0/node_modules/asynckit
    ├── combined-stream -> ../../combined-stream@1.0.8/node_modules/combined-stream
    ├── form-data
    │   └── lib
    └── mime-types -> ../../mime-types@2.1.35/node_modules/mime-types

原来如此,node_modules/.pnpm/axios@1.6.2node_modules 下不但列出了自己,还列出了子依赖form-data,如此就构造了一个“逻辑”级别的 node_modules 来保证能被正确的引用。

一图胜千言,它们的引用关系如下:

3.2 独特的文件共享思路

到此,pnpm的优化之路还是没有停止。

回到最开始提到的那个场景,假设有 100 个项目,同时都依赖了同一版本的axios,缓存只能解决快速下载的问题,但是并不能减少存储空间,还是得重复安装 100 个axios。但是在 pnpm 的项目里,全局范围内,无论有多少个项目,有且仅有一个axios文件。

还是以axios为例,来看下pnpm是如何做到的。

假设我们只查看axios这个依赖的文件存储(忽略它的依赖),先查看这个模块的哈希:

pnpm info axios
output
axios@1.6.2 | MIT | deps: 3 | versions: 85
Promise based HTTP client for the browser and node.js
https://axios-http.com

keywords: xhr, http, ajax, promise, node

dist
.tarball: https://registry.npmjs.org/axios/-/axios-1.6.2.tgz
.shasum: de67d42c755b571d3e698df1b6504cde9b0ee9f2
.integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==
.unpackedSize: 1.8 MB

dependencies:
follow-redirects: ^1.15.0 form-data: ^4.0.0         proxy-from-env: ^1.1.0

maintainers:
- mzabriskie <mzabriskie@gmail.com>
- nickuraltsev <nick.uraltsev@gmail.com>
- emilyemorehouse <emilyemorehouse@gmail.com>
- jasonsaayman <jasonsaayman@gmail.com>

dist-tags:
latest: 1.6.2        next: 1.2.0-alpha.1

published a month ago by jasonsaayman <jasonsaayman@gmail.com>

根据sha512可以判断出哈希值就是,

7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==

将这个 base64 编码后的哈希转化为 16 进制:

base64ToHex(from
function base64ToHex(base64String) {
    // Decode the Base64 string to a binary string
    const binaryString = atob(base64String);

    // Convert each byte of the binary string to a two-digit hex representation
    const hexArray = [];
    for (let i = 0; i < binaryString.length; i++) {
        const hex = binaryString.charCodeAt(i).toString(16).padStart(2, '0');
        hexArray.push(hex);
    }

    // Join the hex values into a single string
    const hexString = hexArray.join('');

    return hexString;
}

得到如下数字:

ee2db8462e2998345f25347b2c3061b0e4ed726fbd9235f95a26355f7c088acc7a1bd4a8de97cc904894ede99405ee3aa1c79522671c4c433793a0b975b556f0

其中前两位ee是文件名,后面的为文件名。

该文件存储在pnpm的全局store上,默认路径可以根据以下方式查到:

pnpm store path

我们以这个路径作为入口去寻找带有上面哈希的文件:

find $(pnpm store path) -name "2db8462e2998345f25347b2c3061b0e4ed726fbd9235f95a26355f7c088acc7a1bd4a8de97cc904894ede99405ee3aa1c79522671c4c433793a0b975b556f0*"

结果为:

/Users/***/.local/share/pnpm/store/v3/files/ee/2db8462e2998345f25347b2c3061b0e4ed726fbd9235f95a26355f7c088acc7a1bd4a8de97cc904894ede99405ee3aa1c79522671c4c433793a0b975b556f0-index.json

我们是以 axios 的包的哈希值作为线索,找到了这个以-index.json结尾的文件,表明它是模块的描述文件。 由于内容过长,截取片段如下:

2db8****56f0-index.json
{
  "name": "axios",
  "version": "1.6.2",
  "files": {
    "LICENSE": {
      "checkedAt": 1702694399469,
      "integrity": "sha512-gKYq8S+HaWnucGTbJ0iTjHMTHwUw+icYQYZGSmteLQmos2zRA4UP+nSiWgNmn5jOigc2yDNiqYfDJJAtihV4HA==",
      "mode": 420,
      "size": 1084
    },
    "dist/browser/axios.cjs": {
      "checkedAt": 1702694399476,
      "integrity": "sha512-OeebaYZXyf/t63SnPJSzfg1ov2H3qPJbopw0tVMlEYM9tp6tETlB54S9ilVE4SanskjPaWdSmTRWJxLykSo8bw==",
      "mode": 420,
      "size": 84442
    },
    "dist/node/axios.cjs": {
      "checkedAt": 1702694399484,
      "integrity": "sha512-4D/ArllvxeiAT1rzz/OKb1ET3VMGI1JK4pwhnVNlqrloK1E0nKK+SqDC2YfAuwEYdWtCwMm6slRpyRB7SoLghQ==",
      "mode": 420,
      "size": 117689
    },
    "index.d.cts": {
      "checkedAt": 1702694399484,
      "integrity": "sha512-16LoayhCNgsj7ac7o4RRXjXCCscys0bnLuLOePvTGJspbCDYwRWDqIvtkK5yugEKgDOMqjVU3W4/Cu9X9E7RRQ==",
      "mode": 420,
      "size": 17941
    }
  }
}

仔细对比就会发现,它描述的正是node_modules/.pnpm/axios@1.6.2里面所有的文件,即axios@1.6.2这个模块里的所有文件其实都存储在ee这个文件夹下。

如果这个文件是全局的文件,被所有的项目都引用,那如果删除了它,其他项目里的引用会如何呢?

答案是没有影响。

如果多个项目使用了同样的依赖,它们都会被 hard link 到全局的 store。hard link 的好处在于,文件无论做了多少次链接,它的inode都不会变,删除彼此就相当于删除副本。更大的好处是,hard link 不会占用更多的空间,即 n 个 hard link 依然占据 1 个单位的文件存储。

如下,先创建一个文件,然后做一个 hard link,由于它们的 inode 一样,所以只占据了一个文件的空间,且彼此的删除不影响对方,而更改却可以同步:

mkdir hard-link-test
echo first row > hard-link-test/a
du -sh hard-link-test
# 4.0K	hard-link-test

ln hard-link-test/a hard-link-test/b
Downloads du -sh hard-link-test
# 4.0K	hard-link-test

cat hard-link-test/a
# first row

cat hard-link-test/b
# first row

echo second row >> hard-link-test/a
cat hard-link-test/a
# first row
# second row

cat hard-link-test/b
# first row
# second row

以上,磁盘空间的问题就得到了解决。

4 对已有包管理器

4.1 对等依赖

npm默认是会安装对等依赖的,如果对等依赖不满足则会报错;而yarn则选择默认不安装需要自己安装,这种方式也导致很容易忘记。

尝试用pnpm安装以下依赖:

package.json
{
  "dependencies": {
    "react-dom": "18.0.0"
  }
}

结果如下:

ERR_PNPM_PEER_DEP_ISSUES  Unmet peer dependencies

.
└─┬ react-dom 18.0.0
  └── ✕ missing peer react@^18.0.0
Peer dependencies that should be installed:
  react@^18.0.0

hint: If you want peer dependencies to be automatically installed, add "auto-install-peers=true" to an .npmrc file at the root of your project.
hint: If you don't want pnpm to fail on peer dependency issues, add "strict-peer-dependencies=false" to an .npmrc file at the root of your project.

看起来”对等依赖缺失“这种情况,在pnpm这里依然认为是Unmet,但react-dom也确实被安装了,这似乎即不是npm也不是yarn的方式,而是一种折中方式。

4.2 漏洞检测与修复

以安装旧版本的webpack为例:

package.json
{
  "dependencies": {
    "webpack": "^4.0.0"
  }
}

检测漏洞:

pnpm auit
┌─────────────────────┬───────────────────────────────────────────────────────────────────────────────────┐
│ high                │ glob-parent vulnerable to Regular Expression Denial of Service in enclosure regex │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────┤
│ Package             │ glob-parent                                                                       │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────┤
│ Vulnerable versions │ <5.1.2                                                                            │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────┤
│ Patched versions    │ >=5.1.2                                                                           │
├─────────────────────┼───────────────────────────────────────────────────────────────────────────────────┤
│ More info           │ https://github.com/advisories/GHSA-ww39-953v-wcq6                                 │
└─────────────────────┴───────────────────────────────────────────────────────────────────────────────────┘

npm audit fix默认是npm的漏洞修复手段,但是如果执行了这个命令还没能修复依赖,那作为开发者,可能就得花精力去研究依赖链了,并不是很方便。

pnpm换了个思路,有任何的漏洞,首先要求开发者自己去利用pnpm update来更新依赖,这是一个看似很笨但的确很合理的思路:webpack4 依赖了 glob-parent,那能把 webpack 升级到最新当然是最佳的解决办法了。

如果升级了webpack还解决不了问题,那就说明我们作为这个模块的使用者是没办法用正常的方式来修复它的漏斗的,只有等包的作者来更新依赖。

这种情况下,pnpm提供了类似yarnresolution(npm 8.3 以上才有)机制,但不同的是,开发者不需要自己去维护这个列表,执行以下命令:

pnpm audit --fix

即可自动覆盖不可修复的依赖:

package.json
{
  "dependencies": {
    "webpack": "^4.0.0"
  },
  "pnpm": {
    "overrides": {
      "glob-parent@<5.1.2": ">=5.1.2"
    }
  }
}

这一点可比yarn方便多了。

5 总结

本文通过对比npmyarn依赖树和依赖漏洞修复策略,详细描述了pnpm的优势。对pnpm的缺点还在探索中,仅就目前而言,笔者认为pnpm是一个非常值得推荐的 Node.js 包管理器,的确是名副其实Permanent npm

6 参考

npm 是如何工作的

npm 如何处理对等依赖