跳到主要内容

认识

2023年05月26日
柏拉文
越努力,越幸运

一、认识


PNPM 是一个非常符合现代前端需求的工具。PNPM(Performant Node Package Manager) 是一个用于管理 Node.js 项目依赖的包管理工具。它被设计用于替代传统的 NPM(Node Package Manager),以提供更高效的依赖管理和更好的性能。PNPM 的核心理念是通过 符号链接全局存储 来优化 依赖管理,解决 NPM 在大型项目中常见的性能和管理问题。

PNPM Monorepo pnpm install 之前: 如所示, 根目录下的 node_modules 目录中只有直接依赖项,使用符号链接来创建依赖项的嵌套结构。所以 PNPMnode_modules 结构为非扁平结构。

pnpm-monorepo/
├── apps/
│ └── appA/
│ └── package.json // lodash@4.17.21 axios@1.7.8 react
│ └── appB/
│ └── package.json // lodash@4.17.15 axios@1.7.0 react
├── pnpm-workspace.yaml // 将 appA 和 appB 加入工作空间
└── package.json // lodash@4.17.9 axios@1.6.5 react

PNPM Monorepo pnpm install 完成:

pnpm-monorepo/
├── node_modules/
│ └── .pnpm/ // node_modules/.pnpm 包含实际内容的物理文件
│ └── asynckit@0.4.0
│ └── node_modules
│ └── asynckit
│ └── axios@1.6.5
│ └── node_modules
│ └── axios
│ └── follow-redirects
│ └── follow-redirects
│ └── proxy-from-env
│ └── axios@1.7.0
│ └── node_modules
│ └── axios
│ └── follow-redirects
│ └── follow-redirects
│ └── proxy-from-env
│ └── axios@1.7.8
│ └── node_modules
│ └── axios
│ └── follow-redirects
│ └── follow-redirects
│ └── proxy-from-env
│ └── combined-stream@1.0.8
│ └── node_modules
│ └── combined-stream
│ └── delayed-stream@1.0.0
│ └── node_modules
│ └── delayed-stream
│ └── follow-redirects@1.15.9
│ └── node_modules
│ └── follow-redirects
│ └── form-data@4.0.1
│ └── node_modules
│ └── form-data
│ └── lodash@4.17.9
│ └── node_modules
│ └── lodash
│ └── lodash@4.17.15
│ └── node_modules
│ └── lodash
│ └── lodash@4.17.21
│ └── node_modules
│ └── lodash
│ └── mime-db@1.52.0
│ └── node_modules
│ └── mime-db
│ └── mime-types@2.1.35
│ └── node_modules
│ └── mime-types
│ └── proxy-from-env@1.1.0
│ └── node_modules
│ └── proxy-from-env
│ └── react@19.0.0
│ └── node_modules
│ └── react
│ └── delayed-stream@1.0.0
│ └── node_modules
│ └── delayed-stream
│ └── axios // 符号链接 指向 .pnpm axios@1.6.5
│ └── lodash // 符号链接 指向 .pnpm lodash@4.17.9
│ └── react // 符号链接 指向 .pnpm react@19.0.0
├── apps/
│ └── appA/
│ └── node_modules/
│ └── axios // 符号链接 指向 .pnpm axios@1.7.8
│ └── lodash // 符号链接 指向 .pnpm lodash@4.17.21
│ └── react // 符号链接 指向 .pnpm react@19.0.0
│ └── package.json // lodash@4.17.21 axios@1.7.8 react
│ └── appB/
│ └── node_modules/
│ └── axios // 符号链接 指向 .pnpm axios@1.7.0
│ └── lodash // 符号链接 指向 .pnpm lodash@4.17.15
│ └── react // 符号链接 指向 .pnpm react@19.0.0
│ └── package.json // lodash@4.17.15 axios@1.7.0 react
├── pnpm-workspace.yaml // 将 appA 和 appB 加入工作空间
└── package.json // lodash@4.17.9 axios@1.6.5 react

因此 pnpm install 过程如下:

  1. 解析工作空间: PNPM 读取 pnpm-workspace.yaml 文件,将 apps/appAapps/appB 加入工作空间(Workspace),确保依赖共享。

  2. 解析依赖树: 分别解析根 package.json 和每个子项目的 package.json,接着,它会解析出这些依赖项的依赖树,构建整个项目的全局依赖树, 确保所有的依赖项及其子依赖项都被正确解析和记录。

  3. 检查和下载包: PNPM 会检查全局存储中(通常在 ~/.pnpm-store 目录下)是否已经存在所需版本的包。如果所需包已经存在,它将跳过下载步骤。如果包不存在,PNPM 会从注册表(通常是 npm registry)中下载包并存储到全局存储中。PNPM 的依赖存储利用了 内容寻址存储(Content Addressable Storage, 每个包会基于其内容的哈希生成唯一的标识符,用于存储在全局缓存中(默认位于 ~/.pnpm-store)。如果某个版本的依赖已经存在于全局缓存,PNPM 会直接复用,而不重新下载。下载或缓存的依赖会解压到 node_modules/.pnpm 中。

  4. 创建 node_modules/.pnpm: 在项目的 node_modules 目录下,PNPM 会创建一个 .pnpm 子目录。这个目录将包含所有依赖项的实际包内容,按照包名和版本号进行组织。每个包的目录中还会包含其自身的 node_modules 目录,其中存储着该包的依赖项。

  5. 创建符号链接: PNPM 会在项目的 node_modules 目录中创建符号链接,这些链接指向 .pnpm 目录中的实际包内容。这样,当你在项目中导入一个依赖项时,Node.js 会通过符号链接找到实际的包内容。PNPM 的依赖存储利用了 内容寻址存储(Content Addressable Storage, 每个包会基于其内容的哈希生成唯一的标识符,用于存储在全局缓存中(默认位于 ~/.pnpm-store)。PNPM 随后通过 分离式依赖树 (Flat Dependency Tree) 通过符号链接实现不同版本的依赖共存, 实现了 依赖隔离, 比如 lodash@4.17.9lodash@4.17.15lodash@4.17.21 都被存储,因为它们的版本不同。相同的依赖内容只存储一次,实现了 依赖共享, 例如,follow-redirectsaxios 的依赖,它虽然出现在多个 axios 版本中,但在 .pnpm 里只存储一次(如 follow-redirects@1.15.9)。

  6. 生成 pnpm-lock.yaml 文件: PNPM 会生成或更新项目的 pnpm-lock.yaml 文件(类似于 NPMpackage-lock.json 文件)。这个文件记录了所有安装的依赖项及其版本,以及它们的依赖关系。这有助于确保在未来安装或更新依赖项时能够重现相同的依赖树。

  7. 最终的非扁平 node_modules 目录结构: PNPM 创建 符号链接 来构建嵌套的依赖关系图过程: 1. 直接依赖, 依赖首先 硬链接node_modules/.pnpm, 随后 符号链接node_modules 下, node_modules/.pnpm 是依赖的实际存放位置, 所以, node_modules 下只会存在 直接依赖 (package.json 中指定的依赖), 所以, 项目中不能直接访问 非直接依赖, 有效的解决了 幽灵依赖问题。2. 子级依赖(次级依赖): 将 直接依赖 依赖的 子级依赖(次级依赖) 硬链接node_modules/.pnpm, 然后 软链接 到与 直接依赖 同一级别 node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖……。所以, PNPM 使用软链与平铺目录来构建一个嵌套结构。而且, 直接依赖直接依赖的次级依赖(子依赖) 一起放到一个 ``node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖…… node_modules` 文件夹, 它们处于同级目录, 可以避免循环符号链接。

PNPM 的优势:

1. 节省磁盘空间: 使用 NPM 或者 Yarn 时,依赖每次被不同的项目使用,都会重复安装一次。 而 PNPM 的依赖存储利用了 内容寻址存储(Content Addressable Storage, 每个包会基于其内容的哈希生成唯一的标识符,用于存储在全局缓存中(默认位于 ~/.pnpm-store)。如果某个版本的依赖已经存在于全局缓存,PNPM 会直接复用,而不重新下载。这个依赖会 硬链接node_modules/.pnpm, 这些是唯一真实的文件, 就会创建 符号链接 来构建嵌套的依赖关系图结构。因此,你在磁盘上节省了大量空间,这与项目和依赖项的数量成正比,并且安装速度要快得多!

2. 提高安装速度: pnpm install 在解析、构建完依赖树 - 目录结构计算之后, 链接依赖项, 这时候, 所有以前安装过的依赖项都会直接从存储区中获取并链接到 node_modules。这种方法比传统的三阶段安装过程(解析、获取和写入所有依赖项到 node_modules)要快得多。

3. 非扁平的 node_modules 目录: 使用 npmYarn Classic 安装依赖项时,所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。默认情况下, PNPM node_modules 布局使用 符号链接 来创建依赖项的 嵌套结构(非扁平结构), 一旦依赖 硬链接node_modules/.pnpm,就会创建 符号链接 来构建嵌套的依赖关系图结构。创建 符号链接 来构建嵌套的依赖关系图过程: 1. 直接依赖, 依赖首先 硬链接node_modules/.pnpm, 随后 符号链接node_modules 下, node_modules/.pnpm 是依赖的实际存放位置, 所以, node_modules 下只会存在 直接依赖 (package.json 中指定的依赖), 所以, 项目中不能直接访问 非直接依赖, 有效的解决了 幽灵依赖问题。2. 子级依赖(次级依赖): 将 直接依赖 依赖的 子级依赖(次级依赖) 硬链接node_modules/.pnpm, 然后 软链接 到与 直接依赖 同一级别 node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖……。所以, PNPM 使用软链与平铺目录来构建一个嵌套结构。而且, 直接依赖直接依赖的次级依赖(子依赖) 一起放到一个 ``node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖…… node_modules` 文件夹, 它们处于同级目录, 可以避免循环符号链接。

4. 严格的版本控制和依赖隔离: PNPM 的依赖存储利用了 内容寻址存储(Content Addressable Storage, 每个包会基于其内容的哈希生成唯一的标识符,用于存储在全局缓存中(默认位于 ~/.pnpm-store)。PNPM 随后通过 分离式依赖树 (Flat Dependency Tree) 通过符号链接实现不同版本的依赖共存, 实现了 依赖隔离, 比如 lodash@4.17.9lodash@4.17.15lodash@4.17.21 都被存储,因为它们的版本不同。相同的依赖内容只存储一次,实现了 依赖共享, 例如,follow-redirectsaxios 的依赖,它虽然出现在多个 axios 版本中,但在 .pnpm 里只存储一次(如 follow-redirects@1.15.9)。

二、核心


2.1 硬链接

硬链接(Hard Link 是在文件系统中创建的指向某个文件数据块的引用。与符号链接不同,硬链接是文件系统级别的链接,它们指向相同的物理数据块,而不是文件的路径。因此,硬链接是对文件内容的直接引用,而符号链接是对文件路径的引用。

PNPM 使用硬链接来提高性能和节省磁盘空间。具体来说,PNPM 在安装依赖时,会在项目的 node_modules 目录中创建指向全局存储中实际包文件的硬链接。这样做的好处是避免了重复下载和存储相同的包内容,同时提高了文件访问的效率。

PNPM 硬链接的工作原理

  1. 全局存储: 当第一次下载某个依赖包时,PNPM 会将其存储在全局存储目录中(通常在 ~/.pnpm-store)。

  2. 创建硬链接: 在项目的 node_modules 目录中,PNPM 会创建指向全局存储中相应包文件的硬链接。这些硬链接指向相同的数据块,因此不会占用额外的磁盘空间。

  3. 依赖结构: 使用硬链接后,项目的 node_modules 目录中依赖项看起来像是独立文件,但实际上它们共享相同的数据块。

PNPM 硬链接优点

  1. 节省磁盘空间: 相同版本的包只会在全局存储中保存一次,通过硬链接共享这些包,避免重复存储,节省了磁盘空间。

  2. 提高文件访问速度: 由于硬链接是文件系统级别的引用,文件访问速度比符号链接更快。

  3. 一致的数据管理: 修改硬链接或原文件,数据同步更新,确保了一致性。

2.2 符号链接

PNPM 中,符号链接(Symbolic Links, symlinks 是用于在文件系统中创建指向另一个文件或目录的引用。这种机制允许在一个位置创建一个链接,指向存储在另一个位置的实际内容。在 PNPM 的上下文中,符号链接用于将 node_modules 目录中的依赖项链接到存储在 .pnpm 子目录中的实际包内容。这种方法有助于高效地管理依赖项,节省磁盘空间,并避免版本冲突。

符号链接: 每个项目的 node_modules 目录中的依赖项不是实际的包副本,而是指向全局存储中相应包的符号链接。PNPM 使用符号链接将项目的 node_modules 目录中的包链接到 .pnpm 目录中存储的实际包内容。这意味着在项目的 node_modules 目录中看到的包实际上是指向 .pnpm 目录中存储的内容的快捷方式。

PNPM 符号链接的工作原理

假设我们有一个项目,其 package.json 文件如下:

{
"dependencies": {
"express": "^4.17.1",
"lodash": "^4.17.21"
}
}

当运行 pnpm install 时,PNPM 的工作流程如下:

  1. 解析依赖树: 读取 package.json 文件并解析所有的依赖项及其版本要求。

  2. 检查和下载包: 检查全局存储中是否已经存在所需版本的包,如果不存在则下载这些包并存储到全局存储中。

  3. 创建 .pnpm 目录: 在项目的 node_modules 目录下创建 .pnpm 子目录,并将所有包存储在这个子目录中,按照包名和版本号进行组织。

  4. 创建符号链接: 在 node_modules 目录中创建指向 .pnpm 目录中相应包的符号链接。

生成的目录结构:

project
└── node_modules
├── .pnpm
│ ├── express@4.17.1
│ │ ├── node_modules
│ │ │ └── ...
│ ├── lodash@4.17.21
│ └── ...(其他依赖)
├── express -> .pnpm/express@4.17.1/node_modules/express
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
  • .pnpm 目录:实际的包内容存储在 node_modules/.pnpm 子目录中,按照包名和版本号进行组织。

  • 符号链接: 在 node_modules 目录中,expresslodash 是符号链接,指向 .pnpm 目录中的实际包内容。

PNPM 符号链接优点

  1. 节省磁盘空间: 相同版本的包只会在 .pnpm 目录中存储一次,通过符号链接共享这些包,避免重复安装,节省了磁盘空间。

  2. 依赖隔离: 符号链接确保了每个包使用其特定版本的依赖项,避免了版本冲突和相互干扰

  3. 提高安装速度: 全局存储和符号链接机制避免了重复下载和解压包,提高了安装速度

  4. 一致的依赖管理: 符号链接和 .pnpm 目录结构使得依赖关系更加清晰和可控,便于管理和调试。

2.3 隔离依赖

隔离依赖: 每个包的依赖项存储在 .pnpm 子目录中,并且包之间的依赖关系通过符号链接来管理。这种方式确保了依赖项之间的严格隔离,避免了不同包之间的版本冲突。

2.4 全局存储

全局存储: PNPM 使用一个全局存储(例如 ~/.pnpm-store)来存放所有安装过的包。

三、问题


3.1 PNPM 的弊端?

  • 调试问题: 所有项目引用的包都在全局一个地方,如果想对某个包进行调试,其他项目正好引用了,本地运行也会收到影响。

  • 兼容问题: 即软连接的方式可能会在 windows 存在一些兼容的问题,但是针对这个问题,pnpm 也提供了对应的解决方案:在 win 系统上使用一个叫做 junctions 的特性来替代软连接,这个方案在 window 上的兼容性要好于 symlink

3.2 npm vs cnpm vs yarn vs pnpm

npm: NPM 默认将所有依赖提升到根级 node_modules, 如果子项目的依赖版本与根项目的依赖冲突, NPM 会在子项目的 node_modules 中直接安装该版本,保证依赖的隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。根级的 node_modules 是全局共享的, 相同版本的依赖在整个 Monorepo 中只安装一次,极大地减少了磁盘空间浪费。子项目的 node_modules 文件夹只包含对其独有依赖的软链接(与根项目的依赖冲突的依赖)。扁平化的结构避免了传统 NPM 中依赖深层嵌套的问题,依赖查找更加高效,避免了传统 NPM 中长路径导致的问题。所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。

cnpm: cnpm 不生成 lock 文件,也不会识别项目中的lock文件

yarn: Yarn 默认将所有依赖提升到根级 node_modules, 如果子项目的依赖版本与根项目的依赖冲突, Yarn 会在子项目的 node_modules 中直接安装该版本,保证依赖的隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。根级的 node_modules 是全局共享的, 相同版本的依赖在整个 Monorepo 中只安装一次,极大地减少了磁盘空间浪费。子项目的 node_modules 文件夹只包含对其独有依赖的软链接(与根项目的依赖冲突的依赖)。扁平化的结构避免了传统 NPM 中依赖深层嵌套的问题,依赖查找更加高效,避免了传统 NPM 中长路径导致的问题。所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。

pnpm: PNPM 使用了一种非扁平结构,PNPM 创建 符号链接 来构建嵌套的依赖关系图过程: 1. 直接依赖, 依赖首先 硬链接node_modules/.pnpm, 随后 符号链接node_modules 下, node_modules/.pnpm 是依赖的实际存放位置, 所以, node_modules 下只会存在 直接依赖 (package.json 中指定的依赖), 所以, 项目中不能直接访问 非直接依赖, 有效的解决了 幽灵依赖问题。2. 子级依赖(次级依赖): 将 直接依赖 依赖的 子级依赖(次级依赖) 硬链接node_modules/.pnpm, 然后 软链接 到与 直接依赖 同一级别 node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖……。所以, PNPM 使用软链与平铺目录来构建一个嵌套结构。而且, 直接依赖直接依赖的次级依赖(子依赖) 一起放到一个 ``node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖…… node_modules 文件夹, 它们处于同级目录, 可以避免循环符号链接。**PNPM** 通过 **分离式依赖树 (Flat Dependency Tree)** 通过符号链接实现不同版本的依赖共存, 实现了 **依赖隔离**, 比如 lodash@4.17.9lodash@4.17.15lodash@4.17.21 都被存储,因为它们的版本不同。相同的依赖内容只存储一次,实现了 **依赖共享**, 例如,follow-redirectsaxios的依赖,它虽然出现在多个axios版本中,但在.pnpm里只存储一次(如follow-redirects@1.15.9)。 **PNPM** 会生成 pnpm-lock.json` 文件。

3.3 NPM node_modules 扁平结构和 PNPM node_modules 的非扁平结构式如何理解的?

NPM 扁平结构: 在 NPM 的扁平结构中,node_modules 目录尽可能地将依赖项安装在根目录中,以减少嵌套层级。这种扁平化的安装方式旨在避免过深的目录结构和长路径问题。NPM 扁平结构 可以避免过深的目录嵌套,减少了路径长度的问题。安装方式也很简单。但是,可能导致版本冲突。多个包依赖不同版本的同一依赖项时,NPM 可能会将它们扁平化到同一目录下,造成版本冲突。而且重复安装依赖项。相同的依赖项可能在多个项目中重复下载和安装,浪费磁盘空间。

PNPM 非扁平结构: PNPM 使用了一种非扁平结构,它在 node_modules 目录下创建一个 .pnpm 子目录,实际的包内容存储在这个子目录中,然后通过 符号链接(symlink 将依赖项链接到项目的 node_modules 目录中。这种结构确保了依赖项的隔离,避免了版本冲突。PNPM 非扁平结构 通过 符号链接.pnpm 目录隔离依赖项,确保不同项目之间的依赖项互不干扰,避免版本冲突, 全局存储和符号链接机制避免了重复安装相同版本的包, 节省了磁盘空间。全局存储和并行安装提高了安装速度。但是,PNPM 需要处理符号链接,可能会对一些工具和脚本造成兼容性问题。

参考资料


从npm发展历程看pnpm的高效