Keep calm & thinking

pnpm 原理入门

基于结论分析

pnpm 快!为什么?基于文件系统的 hard-link 和 symbolic-link 使得同一个包的相同文件在计算机同一个磁盘内只会安装一次。

了解它们需要补充认识下基本其区别以及涉及的其它知识点:

   https://juejin.cn/post/7056581097429139463

https://juejin.cn/post/7056581097429139463

该知识点涉及 linux 文件系统的设计原理,其中提到的关键点为 inode 和 block

   https://cloud.tencent.com/developer/article/1409862

https://cloud.tencent.com/developer/article/1409862

基于以上认知,应该能够大概了解了其区别,也能够想到在一台计算机上操作文件,优化安装等必然离不开对它们更深入的理解。

基于现象分析

基于对 pnpm 网络上普遍知识的了解,它使用到了 2 个术语关键词:

  • Content-addressable store
  • Virtual store

前者是计算机知识中比较广泛的一种查找文件的方式,基于内容查找,还有一种是地址寻址,譬如根据 ip 地址找到一个资源文件。

后者我觉得是 pnpm 自己创造的,为了避免“幽灵依赖”。

在讲到 pnpm 与 npm 的区别时还会提到一个术语:hoist,还能看到最多的一个 .npmrc 的配置项:shamefully-hoist,它们的意思已经被讲烂了。

这里想要表达的是,因为 npm 为了避免在同一个项目中包的重复安装,所以将其做了“提升”,以保证其它包在安装时该依赖已存在。“提升”操作导致了 2 个最明显的问题:

  1. 将项目 package.json 中依赖的包以外的也提升到了 node_modules,开发者有时会直接引入,如果这个包在下个版本不依赖了,开发者的代码就会报错。
  2. 提升操作只能提升一个版本,如果依赖的包中同时依赖了另一个包的不同版本。

所以,使用单独的 .pnpm 目录用来存放所有被提升的包就可以解决上述的问题。

因为单独存放导致 node_modules 的向上查找机制失效,所以便采用了“文件链接”的方案来实现。

示例项目结构,为 pnpm workspace 支持下的 monorepo 项目结构,其中 packages/xmsdk 的依赖为 @babel 三件套。

Untitled

分析:

  1. xmsdk 中的 node_modules 很干净,有且仅有 @babel
  2. 根目录下也有个 @babel 和 .pnpm,已知 .pnpm 为 virtual store
  3. 为什么会有 2 个 @babel?

验证软链

带着基本的认识,我们从项目中 import * as babel from '@babel/core' 引入开始分析,首先会去 xmsdk/node_mobules 中去寻找,确实存在

Untitled

但其实 xmsdk/node_mobules/@babel/core 目录是个软链接,真实文件在右侧圈出来的位置,也就是 virtual store 目录中

Untitled

在真实的 @babel/core 目录的同级,还存在其他的 babel 库,这是因为 @babel/core 自身的依赖,但是我们知道,所有包都被安装在 virtual store 目录下的,所以这些同级的依赖包也是软链接

Untitled

这样的机制就能保证在该项目中,所有的依赖包有且仅有一份存在于 .pnpm 目录中。

我们还能从 .pnpm 下的目录名知晓一些规则:每个包会校验安装来源和版本

关于 2 个 @babel 目录,展开便得知,根目录下存放的是 types 类型文件,也就是说在 monorepo 的结构中,类型文件会被统一提升到全局。

另外,关于 node_modules/.bin 可执行文件,pnpm 也做了重写

Untitled

ll 查看文件是真实文件,文件内容为 node path/to/file.js 执行真实的文件,与 yarn 下面的可执行文件对比

Untitled

本质是一样的,只不过机制不同导致内容不一样。

验证硬连接

验证方式:在 virtual store 目录中,找到每个文件的 inode,然后根据此去 content-addressable store 目录中查找该文件,编辑该文件,virtual store 目录中的文件也被修改。

原理:根据硬链接的特点,目录和文件都会被以相同的 inode 和 block 创建一份,软链接则是新建的 inode 和 block,那么以真实的 @babel/core 举例。

core 目录的 inode 为 60549959

Untitled

core/package.json 文件的 inode 为 60121118

Untitled

进入全局 store 查找

Untitled

发现目录并不存在,但是文件存在,查看该文件

Untitled

inode 确实一致,且全局被链接了 2 次(第二处圈起来的数字),文件大小 2.4k 也对得上。从这里的链接次数可以知道,当次数越多时,说明 pnpm 发挥的特性越好。

直接 vi 修改全局下的文件

Untitled

Untitled

真实目录中查看

Untitled

验证同一个包被多次安装

示例:新建多个项目,使用 pnpm 分别安装 @babel/core@7.19.9@babel/core@7.17.8,由于之前修改过 src/package.json ,还能验证下当文件有修改时,同一个版本包中的文件是增量更新的。也就是如下 3 个场景:

  1. 多个项目安装同一个包
  2. 同一个包的不同版本,硬链接文件如何处理
  3. 同一个包中文件修改后,再次安装该包

结论:

  1. 多个项目同时安装 @babel/core@7.19.9 后,文件的 inode 依旧是全局的那个没变化,文件目录是新建的。

  2. 同时安装不同版本,在全局的 store 中,是硬链接的文件,不区分包的版本,也就是说,不同版本的包中,相同的文件是会被链接到统一文件的。且硬链接的次数会增加。

    06d7bb8519531b7d2259c1b82accd81847054d4b

    e1f99662811889a11e9b074f9e5b7551d618637f

  3. 第三个问题自然就能理解了,文件内容发生变化,文件必然是会重新创建的,因为硬链接的是文件,文件名称是通过内容生成的 hash。但是,需要思考的问题是,当安装一个包后修改其文件,再重新安装时,仍然下载了该包吗?

关于 content-addressable store

该目录下的文件夹是以 2 位 16进制即 16*16=256 个目录,至于为何是如此,目前还不清楚。

Untitled

另外,每个目录下的文件存放规则,也不清楚,就拿 core/package.json 文件所在的 2b 目录举例,文件名称应该是该文件内容的按照一定的算法生成的 hash。存在 3 类文件

  • xxx-exec 可执行文件

  • xxx-index.json 用来描述该包下的所有文件的硬链接创建时间,这里也能看到使用的 sha512 算法来生成文件名 hash

    Untitled

  • xxxx 对真实文件的硬链接文件

入门文章

上次更新: