pnpm 原理入门
基于结论分析
pnpm 快!为什么?基于文件系统的 hard-link 和 symbolic-link 使得同一个包的相同文件在计算机同一个磁盘内只会安装一次。
hard-link 和 symbolic-link
了解它们需要补充认识下基本其区别以及涉及的其它知识点:
https://juejin.cn/post/7056581097429139463
该知识点涉及 linux 文件系统的设计原理,其中提到的关键点为 inode 和 block
https://cloud.tencent.com/developer/article/1409862
基于以上认知,应该能够大概了解了其区别,也能够想到在一台计算机上操作文件,优化安装等必然离不开对它们更深入的理解。
基于现象分析
基于对 pnpm 网络上普遍知识的了解,它使用到了 2 个术语关键词:
- Content-addressable store
- Virtual store
前者是计算机知识中比较广泛的一种查找文件的方式,基于内容查找,还有一种是地址寻址,譬如根据 ip 地址找到一个资源文件。
后者我觉得是 pnpm 自己创造的,为了避免“幽灵依赖”。
在讲到 pnpm 与 npm 的区别时还会提到一个术语:hoist,还能看到最多的一个 .npmrc 的配置项:shamefully-hoist,它们的意思已经被讲烂了。
这里想要表达的是,因为 npm 为了避免在同一个项目中包的重复安装,所以将其做了“提升”,以保证其它包在安装时该依赖已存在。“提升”操作导致了 2 个最明显的问题:
- 将项目 package.json 中依赖的包以外的也提升到了 node_modules,开发者有时会直接引入,如果这个包在下个版本不依赖了,开发者的代码就会报错。
- 提升操作只能提升一个版本,如果依赖的包中同时依赖了另一个包的不同版本。
所以,使用单独的 .pnpm 目录用来存放所有被提升的包就可以解决上述的问题。
因为单独存放导致 node_modules 的向上查找机制失效,所以便采用了“文件链接”的方案来实现。
在系统文件中验证 hard-link 和 symbolic-link
示例项目结构,为 pnpm workspace 支持下的 monorepo 项目结构,其中 packages/xmsdk 的依赖为 @babel
三件套。
分析:
- xmsdk 中的 node_modules 很干净,有且仅有 @babel
- 根目录下也有个 @babel 和 .pnpm,已知 .pnpm 为
virtual store
- 为什么会有 2 个 @babel?
验证软链
带着基本的认识,我们从项目中 import * as babel from '@babel/core'
引入开始分析,首先会去 xmsdk/node_mobules 中去寻找,确实存在
但其实 xmsdk/node_mobules/@babel/core 目录是个软链接,真实文件在右侧圈出来的位置,也就是 virtual store
目录中
在真实的 @babel/core 目录的同级,还存在其他的 babel 库,这是因为 @babel/core 自身的依赖,但是我们知道,所有包都被安装在 virtual store 目录下的,所以这些同级的依赖包也是软链接
这样的机制就能保证在该项目中,所有的依赖包有且仅有一份存在于 .pnpm
目录中。
我们还能从 .pnpm 下的目录名知晓一些规则:每个包会校验安装来源和版本
关于 2 个 @babel 目录,展开便得知,根目录下存放的是 types 类型文件,也就是说在 monorepo 的结构中,类型文件会被统一提升到全局。
另外,关于 node_modules/.bin 可执行文件,pnpm 也做了重写
ll
查看文件是真实文件,文件内容为 node path/to/file.js
执行真实的文件,与 yarn 下面的可执行文件对比
本质是一样的,只不过机制不同导致内容不一样。
验证硬连接
验证方式:在 virtual store 目录中,找到每个文件的 inode,然后根据此去 content-addressable store 目录中查找该文件,编辑该文件,virtual store 目录中的文件也被修改。
原理:根据硬链接的特点,目录和文件都会被以相同的 inode 和 block 创建一份,软链接则是新建的 inode 和 block,那么以真实的 @babel/core 举例。
core 目录的 inode 为 60549959
core/package.json 文件的 inode 为 60121118
进入全局 store 查找
发现目录并不存在,但是文件存在,查看该文件
inode 确实一致,且全局被链接了 2 次(第二处圈起来的数字),文件大小 2.4k 也对得上。从这里的链接次数可以知道,当次数越多时,说明 pnpm 发挥的特性越好。
直接 vi 修改全局下的文件
真实目录中查看
验证同一个包被多次安装
示例:新建多个项目,使用 pnpm 分别安装 @babel/core@7.19.9
和 @babel/core@7.17.8
,由于之前修改过 src/package.json
,还能验证下当文件有修改时,同一个版本包中的文件是增量更新的。也就是如下 3 个场景:
- 多个项目安装同一个包
- 同一个包的不同版本,硬链接文件如何处理
- 同一个包中文件修改后,再次安装该包
结论:
多个项目同时安装
@babel/core@7.19.9
后,文件的 inode 依旧是全局的那个没变化,文件目录是新建的。同时安装不同版本,在全局的 store 中,是硬链接的文件,不区分包的版本,也就是说,不同版本的包中,相同的文件是会被链接到统一文件的。且硬链接的次数会增加。
第三个问题自然就能理解了,文件内容发生变化,文件必然是会重新创建的,因为硬链接的是文件,文件名称是通过内容生成的 hash。但是,需要思考的问题是,当安装一个包后修改其文件,再重新安装时,仍然下载了该包吗?
关于 content-addressable store
该目录下的文件夹是以 2 位 16进制即 16*16=256 个目录,至于为何是如此,目前还不清楚。
另外,每个目录下的文件存放规则,也不清楚,就拿 core/package.json 文件所在的 2b
目录举例,文件名称应该是该文件内容的按照一定的算法生成的 hash。存在 3 类文件
xxx-exec 可执行文件
xxx-index.json 用来描述该包下的所有文件的硬链接创建时间,这里也能看到使用的 sha512 算法来生成文件名 hash
xxxx 对真实文件的硬链接文件
入门文章
快 2 倍的原因?
- 使用了全局硬链接来避免安装重复版本的包;
- 拍平了依赖,因为有了缓存,安装也更快;
- 本地 node_modules 使用了 软链接 到 .pnpm 目录避免了幽灵依赖的问题。
为什么 pnpm@6 要使用 2 者,而不是只使用单一的其中一种?因为依赖 node 的支持度,目前只能这样设计。后续会脱离 node 的运行时依赖。