最近搬砖 在搞前端项目部署优化 ,大部分项目的包管理工具都已经从 npm/yarn 替换成了 pnpm,整体来看无论是在 install 或是 build 阶段都提速了不少 ,借此时机,做个总结!
从 pnpm 官网 的定义来 「pnpm 是一个快速的,节省磁盘空间的包管理工具」。它用于管理 JavaScript 依赖包,类似于 npm 或 yarn,旨在解决传统包管理工具在安装和升级依赖时的一些常见问题,例如:占用大量磁盘空间、重复下载依赖项等...
「pnpm」的主要特点之一是它使用一种称为「虚拟化节点模块」的技术来管理依赖项。它通过在单个磁盘位置存储依赖项的多个版本来减少磁盘占用空间,并通过「软连接 — 符号链接」将它们正确引用到项目中,这种方法还可以加快安装和更新依赖项的速度!
下图是目前 pnpm 官网 benchmarks 将 npm、pnpm、Yarn Classic 和 Yarn PnP 的性能做了对比 ♂️,从图中可以看出在一些综合场景下 pnpm 比 npm/yarn 快了大概 2-3 倍!
以史为鉴,可以知兴替。在学习 pnpm 之前,我们先了解一下依赖管理工具的历史!️
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 结构是扁平化的,但每个依赖项的包内部结构可能是嵌套的
但这种「扁平化的目录结构」存在一些「缺点」
node_modules
目录下,如果多个依赖项依赖于同一个包的不同版本,那么这个包将会在 node_modules
目录下重复出现多次,造成了冗余的存储 Project
└── node_modules
├── package-A
│ └── node_modules
│ └── [email protected]
└── package-B
└── node_modules
└── [email protected]
node_modules
目录下,即使同一个依赖项在不同的包中重复出现,也会占用额外的磁盘空间 Project
└── node_modules
├── package-A
│ └── node_modules
│ └── [email protected]
└── package-B
└── node_modules
└── [email protected]
npm v3(2015年)是 npm 包管理器的一个重要里程碑,引入了一些重大的变化和改进!
Project
└── node_modules
├── package-A
├── package-B
├── [email protected]
└── [email protected] // npm v5 才能做到
当然,npm v3 也存在一定的「缺点」
第二条缺点可能不是很好理解,我们来举个 ,假设有以下依赖关系:
[email protected]
[email protected]
在安装过程中,如果先安装了包 A,那么 [email protected]
将被提升到顶层的 node_modules
目录。然后,在安装包 C 的过程中,会发现它依赖于包 A 和包 B,但由于 [email protected]
已经被提升,所以 [email protected]
会被嵌套安装在包 B 的依赖项中,而不是被提升到顶层。
相反,如果先安装了包 B,那么 [email protected]
将被提升到顶层的 node_modules
目录。然后,在安装包 C 的过程中,发现它依赖于包 A 和包 B,但由于 [email protected]
已经被提升,所以 [email protected]
会被嵌套安装在包 A 的依赖项中。
注:npm v3 作为一个过渡版本,在后续的 npm 版本中进行了进一步的改进和优化。npm v5 引入了更高级的依赖项解析和管理策略,更好地处理了依赖项冗余和安装效率的问题。
Yarn(2016年)是由 Facebook、Google、Expo 和 Tilde 等公司合作开发的 JavaScript 包管理器。它旨在改进 npm 的性能和可靠性,并引入了一些新功能,如并行安装、版本锁定和离线模式!
下面是 Yarn 的一些优点和解决的问题:
这也是为什么当时 yarn 在安装速度上比 npm 有显著提高。需要注意的是,尽管 Yarn 带来了这些优点和改进,npm 也在后续的版本中逐渐改进自身,引入了类似的功能和优化。比如确定性安装这一点,npm 的 package-lock.json
文件就是在 npm v5.0.0 版本中引入的!
了解完包管理工具的历史和原理,我们再来看看 pnpm 到底有什么特别之处,为什么 pnpm 是所谓的「快速的,节省磁盘空间的包管理工具」,它是怎么做到的?
pnpm 有一个 store 的概念(为了方便记忆,可以联想成 Vuex 的 store,全局状态管理),store 目录内部使用「基于内容寻址」的文件系统来存储磁盘上所有的文件。
基于内容寻址是一种文件系统的设计原则,以文件内容的哈希值作为文件的唯一标识符,并将文件存储在以哈希值命名的目录中。这样的设计使得相同内容的文件只会存储一次,避免了重复存储相同文件的问题。
在 pnpm 的 store 目录中,每个文件的名称都是其内容的哈希值,因此同一个文件无论在多个项目中使用,都只会被存储一次。当安装或更新依赖项时,pnpm 会检查 store 目录中是否已经存在所需的文件,如果存在则直接复用,避免了重复的下载和存储。
这种基于内容寻址的文件系统设计可以带来一些好处:
通过 store 目录和基于内容寻址的文件系统,pnpm 能够实现依赖项的共享和重复使用,减少磁盘空间占用,并提高依赖项的安装和管理效率。
pnpm install 过程中,我们会看到如下的信息:其中的 content-addressable store 就是以上提到的 store。Mac 中默认会将 store 设置到 {home dir}>/.pnpm-store/v3
了解 pnpm 原理之前,我们还需要了解一下「硬连接」和「软连接」
硬链接就是同一个文件的一个或多个文件名,它所引用的是文件的物理数据而不是文件在文件结构中的位置,使得用户可以通过不同的路径引用方式去找到某个文件。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" 仍然可以访问相同的数据块,因为它们共享相同的内容。
硬链接的特点是,无论有多少个硬链接指向相同的数据块,它们都被视为相互独立的完整文件副本。因此,硬链接不会占用额外的磁盘空间,因为它们共享相同的数据块。
软链接,又称为符号链接,是一个指向另一个文件或目录的引用,类似于创建一个快捷方式。与硬链接不同,软链接创建的链接文件保存的是被链接文件的「路径信息」,而不是实际的文件内容或索引节点。当访问软链接时,操作系统会跟随链接并转到被链接的文件或目录。
这个也不太好理解?再举个 :
假设你有两个文件夹:FolderA 和 FolderB
FolderA 中有一个名为 "Documents" 的文件夹,里面存放着你的文档文件。现在,你希望在 FolderB 中也能访问这些文档文件,但不想复制它们。
你可以创建一个软链接(符号链接)来实现这个目的。在 FolderB 中创建一个名为 "Documents" 的软链接,它指向 FolderA 中的 "Documents" 文件夹。这个软链接就像一个指向实际文件夹的快捷方式。
现在,当你在 FolderB 中访问 "Documents" 文件夹时,实际上是通过软链接跳转到 FolderA 中的 "Documents" 文件夹。你可以查看、编辑或操作那些文档文件,就像它们实际存在于 FolderB 中一样。
如果你在 FolderA 的 "Documents" 文件夹中添加、删除或修改文件,这些变化也会反映在 FolderB 的 "Documents" 软链接中,因为它们指向同一个文件夹。
如果你还是分不清的话,你就记:硬连接就是那个 "link.txt";软连接就是路径(快捷方式) ♂️
.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
└── [email protected]
└── node_modules
└── f -> /f
由于硬链接机制,每次安装依赖的时候,如果有项目/包都用到相同的依赖,那么这个依赖实际上最优情况(即版本相同)只需要安装一次,避免了二次安装带来的消耗。因此每个依赖包寻址都要经过这三层结构:
node_modules/f // 在 node_modules 文件夹寻找依赖,并遵循就近原则
—> 软链接 node_modules/.pnpm/[email protected]/node_modules/f // 解决一个项目内的代码重复引用问题
—> 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx // 解决项目间的包重复拷贝问题
pnpm 包的依赖项与依赖包的位置位于同一目录级别:f 这个依赖的内部相关依赖会被平铺到 .pnpm/[email protected]/node_modules/ 这个目录下面,而不是 .pnpm/[email protected]/node_modules/f/node_modules/
node_modules
├── f -> ./.pnpm/[email protected]/node_modules/f
└── .pnpm
├── [email protected]
│ └── node_modules
│ └── b -> /b
└── [email protected]
└── node_modules
├── f -> /f
└── b -> ../../[email protected]/node_modules/b
这个平铺的结构使得所有被提升的包都可以访问。不但保留了包之间的相互隔离,而且避免了创建的嵌套 node_modules 引起的长路径问题。
比如,添加 [email protected] 作为 b 和 f 的依赖项,即使层级现在更深(f > b > q),但目录深度没有发生变化:
node_modules
├── f -> ./.pnpm/[email protected]/node_modules/f
└── .pnpm
├── [email protected]
│ └── node_modules
│ ├── b -> /b
│ └── q -> ../../[email protected]/node_modules/q
├── [email protected]
│ └── node_modules
│ ├── f -> /f
│ ├── b -> ../../[email protected]/node_modules/b
│ └── q -> ../../[email protected]/node_modules/q
└── [email protected]
└── node_modules
└── q -> /q
解析模块时,当 [email protected] 需要 b 时,Node 不会使用在 [email protected]/node_modules/b 的 b,而是其实际位置 [email protected]/node_modules/b。 因此,b 也可以解析其在 [email protected]/node_modules 中的依赖项。
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 安装的是 [email protected][email protected],Package B 安装的是 [email protected][email protected],则其在 .pnpm 中的结构为:
node_modules
└── .pnpm
├── [email protected][email protected][email protected]
│ └── node_modules
│ ├── f
│ ├── b -> ../../[email protected]/node_modules/b
│ ├── c -> ../../[email protected]/node_modules/c
│ ├── u -> ../../[email protected]/node_modules/u
│ └── p -> ../../[email protected]/node_modules/p
├── [email protected][email protected][email protected]
│ └── node_modules
│ ├── f
│ ├── b -> ../../[email protected]/node_modules/b
│ ├── c -> ../../[email protected]/node_modules/c
│ ├── u -> ../../[email protected]/node_modules/u
│ └── p -> ../../[email protected]/node_modules/p
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
可以看到,安装了两个相同版本的 f ,对应相同的软链接,但却分别拥有不同的名称: [email protected][email protected][email protected] 、 [email protected][email protected][email protected] 。这也是 pnpm 规则严格的体现:任何包都不应该有全局副作用。
pnpm 使用软链接和硬链接的组合,同时采用了平铺的目录结构。这意味着所有的包都被存储在一级子目录下,并且依赖关系使用软链接来连接。这样的设计有助于节省磁盘空间和提高安装速度。
pnpm 的平铺结构确保所有的包都在一级子目录下,这与传统的 npm 文件结构相同。每个包只能访问自身的依赖项,而不会直接访问其他包的依赖项。这种结构的好处是每个包都有自己的上下文环境,避免了潜在的冲突和版本问题。
在 pnpm 中,同一个包的不同版本会分别存储,并且不会互相影响。每个版本的包被拉平后都会保留在存储中,而不会被替换或删除。这样做是为了确保不同项目对于特定版本的依赖项的一致性,即使其他项目可能依赖于不同的版本。
综合来说,pnpm 使用软链接和硬链接的组合来优化依赖项的共享和存储,采用平铺的目录结构来确保每个包的上下文隔离,同时保留不同版本的包以维持依赖项的一致性。这些设计选择有助于减少磁盘空间占用,提高安装速度,并提供更可靠的依赖项管理。
End