研究一下「pnpm」这个神奇的包管理工具

最近搬砖 🧱 在搞前端项目部署优化 🎡,大部分项目的包管理工具都已经从 npm/yarn 替换成了 pnpm,整体来看无论是在 install 或是 build 阶段都提速了不少 🚀,借此时机,做个总结!🤔 

pnpm 简介

从 pnpm 官网 的定义来 👀「pnpm 是一个快速的,节省磁盘空间的包管理工具」。它用于管理 JavaScript 依赖包,类似于 npm 或 yarn,旨在解决传统包管理工具在安装和升级依赖时的一些常见问题,例如:占用大量磁盘空间、重复下载依赖项等...  🤕

「pnpm」的主要特点之一是它使用一种称为「虚拟化节点模块」的技术来管理依赖项。它通过在单个磁盘位置存储依赖项的多个版本来减少磁盘占用空间,并通过「软连接 — 符号链接」将它们正确引用到项目中,这种方法还可以加快安装和更新依赖项的速度!🐂🍺

下图是目前 pnpm 官网 benchmarks 将 npm、pnpm、Yarn Classic 和 Yarn PnP 的性能做了对比 🚴‍♂️,从图中可以看出在一些综合场景下 pnpm 比  npm/yarn 快了大概 2-3 倍!🎉

依赖包管理工具的历史

以史为鉴,可以知兴替。在学习 pnpm 之前,我们先了解一下依赖管理工具的历史!🤔️

npm

npm(Node Package Manager, 2010年)是用于 Node.js 的默认包管理器。它可以使开发人员轻松地共享和管理 JavaScript 包。npm 使用「package.json」文件来定义项目的依赖关系,并提供命令行工具来安装、更新和删除依赖项。

通过 npm 生成的 node_modules 结构通常是一个「扁平化的目录结构」,其中包含项目的所有依赖项。当您使用 npm 安装项目的依赖项时,相关的包将直接安装到项目的 node_modules 目录中。例如,假设你的项目依赖于包 A 和包 B,它们的安装目录结构可能如下所示:

Project
└── node_modules
    ├── package-A
    └── package-B

// node_modules 结构是扁平化的,但每个依赖项的包内部结构可能是嵌套的

但这种「扁平化的目录结构」存在一些「缺点」🤕

  1. 冗余的依赖项:由于每个依赖项都被直接放置在 node_modules 目录下,如果多个依赖项依赖于同一个包的不同版本,那么这个包将会在 node_modules 目录下重复出现多次,造成了冗余的存储 🙁
    Project
    └── node_modules
        ├── package-A
        │   └── node_modules
        │       └── shared-package@1.0.0
        └── package-B
            └── node_modules
                └── shared-package@2.0.0
  2. 磁盘空间占用:由于每个依赖项都被直接放置在 node_modules 目录下,即使同一个依赖项在不同的包中重复出现,也会占用额外的磁盘空间 🙁
    Project
    └── node_modules
        ├── package-A
        │   └── node_modules
        │       └── shared-package@1.0.0
        └── package-B
            └── node_modules
                └── shared-package@1.0.0
  3. 安装时间延长:由于扁平化结构中的所有依赖项都位于同一层级,安装时需要递归解析和安装所有依赖项。当项目具有大量依赖项或存在大量依赖项冲突时,按包顺序逐个执行的「串行安装」,会导致安装时间较长 🙁

npm v3

npm v3(2015年)是 npm 包管理器的一个重要里程碑,引入了一些重大的变化和改进!🎉

  1. 扁平化依赖:npm v3 引入了「扁平化依赖」的概念(npm v5 做了完善和优化),即将依赖项的多个版本共享在一个统一的层次结构中。这样可以避免传统的嵌套依赖结构,减少了冗余和冲突,从而降低了项目的磁盘空间占用和安装时间。
    Project
    └── node_modules
        ├── package-A
        ├── package-B
        ├── shared-package@1.0.0
        └── shared-package@2.0.0 // npm v5 才能做到
    
  2. 并行安装:npm v3 引入了「并行安装」的能力,可以同时下载和安装多个依赖项,从而加快整体的安装速度,这对于大型项目或拥有大量依赖项的项目特别有益。

当然,npm v3 也存在一定的「缺点」🤕

  1. npm v3 这种「扁平化依赖」的思想虽然有效避免了冗余的依赖项,但也使得项目/包可以访问到并不依赖的包,可能产生安全问题 ➡️「幽灵依赖」。
  2. 不同包依赖同一个包的版本不同时,多个版本的包只能有一个被提升上来,其余版本的包会嵌套安装到各自的依赖当中,哪个版本的包被提升,依赖于包的安装顺序。

第二条缺点可能不是很好理解,我们来举个 🌰,假设有以下依赖关系:

  • 包 A 依赖于 shared-package@1.0.0
  • 包 B 依赖于 shared-package@2.0.0
  • 包 C 依赖于 包 A 和 包 B

在安装过程中,如果先安装了包 A,那么 shared-package@1.0.0 将被提升到顶层的 node_modules 目录。然后,在安装包 C 的过程中,会发现它依赖于包 A 和包 B,但由于 shared-package@1.0.0 已经被提升,所以 shared-package@2.0.0 会被嵌套安装在包 B 的依赖项中,而不是被提升到顶层。

相反,如果先安装了包 B,那么 shared-package@2.0.0 将被提升到顶层的 node_modules 目录。然后,在安装包 C 的过程中,发现它依赖于包 A 和包 B,但由于 shared-package@2.0.0 已经被提升,所以 shared-package@1.0.0 会被嵌套安装在包 A 的依赖项中。

注:npm v3 作为一个过渡版本,在后续的 npm 版本中进行了进一步的改进和优化。npm v5 引入了更高级的依赖项解析和管理策略,更好地处理了依赖项冗余和安装效率的问题。

yarn

Yarn(2016年)是由 Facebook、Google、Expo 和 Tilde 等公司合作开发的 JavaScript 包管理器。它旨在改进 npm 的性能和可靠性,并引入了一些新功能,如并行安装、版本锁定和离线模式!🎉

下面是 Yarn 的一些优点和解决的问题:

  1. 离线模式:Yarn 可以在离线环境下工作,它会缓存已经下载的依赖项,以便在没有网络连接时重新使用。这对于团队合作、CI/CD 构建和在低速或不稳定的网络环境中工作的开发人员来说特别有用。
  2. 并行安装:Yarn 使用并行安装的方式,可以同时下载多个依赖项,从而提高安装速度。这对于具有大量依赖项或需要频繁重复安装的项目来说,可以显著减少等待时间。
  3. 确定性安装:Yarn 确保在不同的环境中安装相同的依赖项版本,从而避免了由于依赖项版本的差异导致的构建或运行时错误。Yarn 使用 「yarn.lock」 文件来记录依赖项的确切版本,以确保跨团队和环境的一致性。
  4. 优化的网络请求:Yarn 使用了更高效的网络请求算法,可以减少对网络的负载和响应时间。它能够智能地利用缓存和并行下载来最大限度地提高依赖项的获取速度。

这也是为什么当时 yarn 在安装速度上比 npm 有显著提高。需要注意的是,尽管 Yarn 带来了这些优点和改进,npm 也在后续的版本中逐渐改进自身,引入了类似的功能和优化。比如确定性安装这一点,npm 的 package-lock.json 文件就是在 npm v5.0.0 版本中引入的!🎉

Why pnpm?

了解完包管理工具的历史和原理,我们再来看看 pnpm 到底有什么特别之处,为什么 pnpm 是所谓的「快速的,节省磁盘空间的包管理工具」,它是怎么做到的?🤔

store

pnpm 有一个 store 的概念(为了方便记忆,可以联想成 Vuex 的 store,全局状态管理),store 目录内部使用「基于内容寻址」的文件系统来存储磁盘上所有的文件。

基于内容寻址是一种文件系统的设计原则,以文件内容的哈希值作为文件的唯一标识符,并将文件存储在以哈希值命名的目录中。这样的设计使得相同内容的文件只会存储一次,避免了重复存储相同文件的问题。

在 pnpm 的 store 目录中,每个文件的名称都是其内容的哈希值,因此同一个文件无论在多个项目中使用,都只会被存储一次。当安装或更新依赖项时,pnpm 会检查 store 目录中是否已经存在所需的文件,如果存在则直接复用,避免了重复的下载和存储。

这种基于内容寻址的文件系统设计可以带来一些好处:

  1. 磁盘空间的节省:相同的文件只会存储一次,避免了冗余的存储,尤其是在多个项目共享依赖项时。
  2. 快速的依赖项安装:由于文件已经存在于 store 中,所以安装依赖项时可以直接使用已有的文件,而无需下载和解压,从而提高了安装速度。
  3. 安全性:基于内容寻址的文件系统使用哈希值来标识文件,这意味着文件内容的完整性可以通过哈希校验来验证,提供了一定的数据完整性保护。

通过 store 目录和基于内容寻址的文件系统,pnpm 能够实现依赖项的共享和重复使用,减少磁盘空间占用,并提高依赖项的安装和管理效率。

pnpm install 过程中,我们会看到如下的信息:其中的 content-addressable store 就是以上提到的 store。Mac 中默认会将 store 设置到 {home dir}>/.pnpm-store/v3

link

了解 pnpm 原理之前,我们还需要了解一下「硬连接」和「软连接」🤔

硬链接 hard link

硬链接就是同一个文件的一个或多个文件名,它所引用的是文件的物理数据而不是文件在文件结构中的位置,使得用户可以通过不同的路径引用方式去找到某个文件。pnpm 会在全局的 store 目录里存储项目 node_modules 文件的硬连接。但是,硬链接只能用于文件不能用于目录(可能导致目录循环)。

是不是不太好理解?举个 🌰:

假设有一个名为 "file.txt" 的文件,它有一些数据块 A、B 和 C。当你创建一个硬链接 "link.txt" 指向 "file.txt" 时,实际上只是创建了一个新的文件条目 "link.txt",它指向相同的数据块 A、B 和 C。这样,"file.txt" 和 "link.txt" 都指向相同的数据,它们是相互独立但内容相同的文件。

如果你修改了 "file.txt" 中的内容,那么 "link.txt" 中的内容也会发生相应的改变,因为它们实际上是指向同一组数据块。同样地,如果你删除 "file.txt","link.txt" 仍然可以访问相同的数据块,因为它们共享相同的内容。

硬链接的特点是,无论有多少个硬链接指向相同的数据块,它们都被视为相互独立的完整文件副本。因此,硬链接不会占用额外的磁盘空间,因为它们共享相同的数据块。

软链接 symbolic link

软链接,又称为符号链接,是一个指向另一个文件或目录的引用,类似于创建一个快捷方式。与硬链接不同,软链接创建的链接文件保存的是被链接文件的「路径信息」,而不是实际的文件内容或索引节点。当访问软链接时,操作系统会跟随链接并转到被链接的文件或目录。

这个也不太好理解?再举个 🌰:

假设你有两个文件夹:FolderA 和 FolderB

FolderA 中有一个名为 "Documents" 的文件夹,里面存放着你的文档文件。现在,你希望在 FolderB 中也能访问这些文档文件,但不想复制它们。

你可以创建一个软链接(符号链接)来实现这个目的。在 FolderB 中创建一个名为 "Documents" 的软链接,它指向 FolderA 中的 "Documents" 文件夹。这个软链接就像一个指向实际文件夹的快捷方式。

现在,当你在 FolderB 中访问 "Documents" 文件夹时,实际上是通过软链接跳转到 FolderA 中的 "Documents" 文件夹。你可以查看、编辑或操作那些文档文件,就像它们实际存在于 FolderB 中一样。

如果你在 FolderA 的 "Documents" 文件夹中添加、删除或修改文件,这些变化也会反映在 FolderB 的 "Documents" 软链接中,因为它们指向同一个文件夹。

硬链接 vs 软连接

如果你还是分不清的话,你就记:硬连接就是那个 "link.txt";软连接就是路径(快捷方式) 🤷‍♂️

.pnpm

.pnpm/ 为虚拟磁盘目录,它以平铺的形式储存着所有的包。pnpm 使用「软链接 + 平铺」目录结合的方式来构建一个嵌套结构:

软连接

当我们在项目中安装依赖,执行 pnpm add f 后,项目中的 node_modules 目录为:

node_modules
├── .pnpm
└── f // 这是一个软连接

node_modules 中只有一个叫 .pnpm 的文件夹以及一个叫做 f 的软链接。 f 是此项目/应用必须拥有访问权限的包,在访问 node_modules 中的 f 时,会形成一个到 .pnpm 中的软链接,链接到的文件是从全局  store 硬链接过来的。

node_modules
├── f
└── .pnpm
       └── f@1.0.0
             └── node_modules
                   └── f -> <store>/f

由于硬链接机制,每次安装依赖的时候,如果有项目/包都用到相同的依赖,那么这个依赖实际上最优情况(即版本相同)只需要安装一次,避免了二次安装带来的消耗。因此每个依赖包寻址都要经过这三层结构:

node_modules/f // 在 node_modules 文件夹寻找依赖,并遵循就近原则

—> 软链接 node_modules/.pnpm/f@1.0.0/node_modules/f // 解决一个项目内的代码重复引用问题

—> 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx // 解决项目间的包重复拷贝问题

平铺

pnpm 包的依赖项与依赖包的位置位于同一目录级别:f 这个依赖的内部相关依赖会被平铺到 .pnpm/f@1.0.0/node_modules/ 这个目录下面,而不是 .pnpm/f@1.0.0/node_modules/f/node_modules/

node_modules
├── f -> ./.pnpm/f@1.0.0/node_modules/f
└── .pnpm
       ├── b@1.0.0
       │    └── node_modules
       │          └── b -> <store>/b
       └── f@1.0.0
             └── node_modules
                   ├── f -> <store>/f
                   └── b -> ../../b@1.0.0/node_modules/b

这个平铺的结构使得所有被提升的包都可以访问。不但保留了包之间的相互隔离,而且避免了创建的嵌套 node_modules 引起的长路径问题。

比如,添加 q@2.0.0 作为  b 和  f 的依赖项,即使层级现在更深(f > b > q),但目录深度没有发生变化:

node_modules
├── f -> ./.pnpm/f@1.0.0/node_modules/f
└── .pnpm
      ├── b@1.0.0
      │    └── node_modules
      │          ├── b -> <store>/b
      │          └── q -> ../../q@2.0.0/node_modules/q
      ├── f@1.0.0
      │    └── node_modules
      │          ├── f -> <store>/f
      │          ├── b -> ../../b@1.0.0/node_modules/b
      │          └── q -> ../../q@2.0.0/node_modules/q
      └── q@2.0.0
            └── node_modules
                  └── q -> <store>/q

解析模块时,当 f@1.0.0 需要  b 时,Node 不会使用在  f@1.0.0/node_modules/b 的  b,而是其实际位置 b@1.0.0/node_modules/b。 因此,b 也可以解析其在  b@1.0.0/node_modules 中的依赖项。

同级依赖 peer dependencies

pnpm 的一个重要特性是确保一个 package 的特定版本只有一组依赖项,但对于具有同级依赖(peer dependencies)的 package,它允许安装多个不同版本的 peer dependencies,以满足不同版本要求的兼容性需求。这样既能节省磁盘空间,又能满足依赖关系的正确性。

同级依赖 peer dependencies?了解,举个 🌰:

在你的项目中,你安装了 React 和 React-DOM 作为主要的依赖项。然后,你决定使用一个名为 "react-router" 的路由库来实现应用程序的导航功能。你使用 pnpm 安装了 "react-router",虽然 "react-router" 它依赖于 React 和 React-DOM,但 "react-router" 并不直接将 React 和 React-DOM 作为自己的依赖项进行安装,而是将它们标记为同级依赖(peer dependencies)。这意味着 "react-router" 期望项目的根目录中已经安装了与其所需的 React 和 React-DOM 版本匹配的依赖项。它不会在自己的依赖树中直接安装 React 和 React-DOM,而是依赖于项目根目录中已经安装的版本。通过这种方式,"react-router" 和主项目共享同一个 React 和 React-DOM 的实例,确保它们使用的是相同版本的库。这有助于避免不同版本之间的冲突和兼容性问题。

对于定义了 peer dependencies 的包来说,意味着为 peer dependencies 内容是敏感的,也就是说对于不同的 peer dependencies ,这个包可能拥有不同的表现,因此  pnpm 针对不同的 peer dependencies 环境,可能对同一个包创建多份拷贝。

明白,举个 🌰:

比如包 f 同级依赖了 b 与 c ,我们在  Monorepo 环境两个 Package A 和  Package B 下分别安装不同版本的 b 和 c,Package A 安装的是 b@1.0.0+c@1.0.0,Package B 安装的是 b@1.0.0+c@1.1.0,则其在 .pnpm 中的结构为:

node_modules
└── .pnpm
     ├── f@1.0.0_b@1.0.0+c@1.0.0
     │     └── node_modules
     │           ├── f
     │           ├── b -> ../../b@1.0.0/node_modules/b
     │           ├── c -> ../../c@1.0.0/node_modules/c
     │           ├── u -> ../../u@1.0.0/node_modules/u
     │           └── p -> ../../p@1.0.0/node_modules/p
     ├── f@1.0.0_b@1.0.0+c@1.1.0
     │     └── node_modules
     │           ├── f
     │           ├── b -> ../../b@1.0.0/node_modules/b
     │           ├── c -> ../../c@1.1.0/node_modules/c
     │           ├── u -> ../../u@1.0.0/node_modules/u
     │           └── p -> ../../p@1.0.0/node_modules/p
     ├── b@1.0.0
     ├── c@1.0.0
     ├── c@1.1.0
     ├── u@1.0.0
     ├── p@1.0.0

可以看到,安装了两个相同版本的 f ,对应相同的软链接,但却分别拥有不同的名称: f@1.0.0_b@1.0.0+c@1.0.0 、 f@1.0.0_b@1.0.0+c@1.1.0 。这也是  pnpm 规则严格的体现:任何包都不应该有全局副作用。

总结

  1. pnpm 使用软链接和硬链接的组合,同时采用了平铺的目录结构。这意味着所有的包都被存储在一级子目录下,并且依赖关系使用软链接来连接。这样的设计有助于节省磁盘空间和提高安装速度。

  2. pnpm 的平铺结构确保所有的包都在一级子目录下,这与传统的 npm 文件结构相同。每个包只能访问自身的依赖项,而不会直接访问其他包的依赖项。这种结构的好处是每个包都有自己的上下文环境,避免了潜在的冲突和版本问题。

  3. 在 pnpm 中,同一个包的不同版本会分别存储,并且不会互相影响。每个版本的包被拉平后都会保留在存储中,而不会被替换或删除。这样做是为了确保不同项目对于特定版本的依赖项的一致性,即使其他项目可能依赖于不同的版本。

综合来说,pnpm 使用软链接和硬链接的组合来优化依赖项的共享和存储,采用平铺的目录结构来确保每个包的上下文隔离,同时保留不同版本的包以维持依赖项的一致性。这些设计选择有助于减少磁盘空间占用,提高安装速度,并提供更可靠的依赖项管理。

End 🎉

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值