- Published on
Why pnpm
- Authors
- Name
- Pursue
目录
1 开篇
上文笔者详细回顾了 npm
的演进之路以及在依赖树解析上的算法迭代,同时也简单地和 yarn
进行了对比。不难看出,它们尤其在依赖树的解析上煞费苦心,从嵌套算法到扁平算法,从发现重复依赖到 hoist 依赖优化等等, 这一系列的良苦用心都是围绕着以下两个话题:
- 如何构造一颗“完美”的依赖树
- 如何最大化的节省依赖储存开销
2 pnpm 的大道至简
回顾 npm
和 yarn
的依赖树结构,它们都在扁平化和嵌套化之间不断的“妥协”,根据它们现在的算法,无论优化到什么地步,都还是无法解决以下两个问题:
- 磁盘空间
以 npm
为例子,虽然 npm
在安装依赖时也有缓存机制,但缓存只能解决下载速度的问题,并不能优化存储空间。假设你本地有 10 个 Node.js
项目,他们的技术栈都类似,即有相当一部分的依赖都是重复的。 那么即使有缓存,这些依赖也得重复的安装到每个项目的node_modules
文件夹下。10 个项目是这样,10000 个亦是如此。
- 杂乱的 node_modules
由于 npm
和 yarn
会将间接依赖(子依赖)扁平化到 node_modules
下,如此一来,区分直接依赖(写在 package.json 里的)就会变得困难。更加具有隐患的是,由于间接依赖 直接暴露在了 node_modules
里,那么就导致间接依赖是可以直接在项目里被引用的。
以上,扁平化的树结构优化起来如此的复杂(参考上篇的解析逻辑),但最终还有这么多的问题。
pnpm
在面对同样的问题时另辟蹊径,它没有费尽心思去想出一个更好的扁平化思路,而是选择在逻辑上保留原有树结构的同时,不但能让 node_modules
直观明了,而且还节省了空间。
如此神器,我们来一探究竟。
3 pnpm 一探究竟
3.1 巧妙的 node_modules 结构
我们以axios
为例,分别用npm
和pnpm
来安装,node_modules
文件结构如下:
{
"dependencies": {
"axios": "latest"
}
}
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
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
├── .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.2
和form-data@4.0.0
所指向的源文件并不在同一个node_modules
文件里,当axios
是如何找到自己的依赖form-data
的呢?
我们把它们分别展开一下:
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
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.2
在 node_modules
下不但列出了自己,还列出了子依赖form-data
,如此就构造了一个“逻辑”级别的 node_modules
来保证能被正确的引用。
一图胜千言,它们的引用关系如下:
3.2 独特的文件共享思路
到此,pnpm
的优化之路还是没有停止。
回到最开始提到的那个场景,假设有 100 个项目,同时都依赖了同一版本的axios
,缓存只能解决快速下载的问题,但是并不能减少存储空间,还是得重复安装 100 个axios
。但是在 pnpm
的项目里,全局范围内,无论有多少个项目,有且仅有一个axios
文件。
还是以axios
为例,来看下pnpm
是如何做到的。
假设我们只查看axios
这个依赖的文件存储(忽略它的依赖),先查看这个模块的哈希:
pnpm info axios
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 进制:
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
结尾的文件,表明它是模块的描述文件。 由于内容过长,截取片段如下:
{
"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
安装以下依赖:
{
"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
为例:
{
"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
提供了类似yarn
的 resolution
(npm 8.3 以上才有)机制,但不同的是,开发者不需要自己去维护这个列表,执行以下命令:
pnpm audit --fix
即可自动覆盖不可修复的依赖:
{
"dependencies": {
"webpack": "^4.0.0"
},
"pnpm": {
"overrides": {
"glob-parent@<5.1.2": ">=5.1.2"
}
}
}
这一点可比yarn
方便多了。
5 总结
本文通过对比npm
和yarn
依赖树和依赖漏洞修复策略,详细描述了pnpm
的优势。对pnpm
的缺点还在探索中,仅就目前而言,笔者认为pnpm
是一个非常值得推荐的 Node.js
包管理器,的确是名副其实Permanent npm
。