pnpm是如何解决幻影依赖的?

我们来举个例子:

我们这里有一个工程,工程里边有一些依赖,我们使用 npm 安装后,看到 node_modules 目录下出现了很多在依赖里没有声明的东西,那么这些没有声明的东西统称为幻影依赖。 由于这里安装了一大堆我们并没有声明的东西,就意味着我们可以在代码里边去导入一个我们根本就没有声明过的库,比如这个 loadsh:

我们并没有声明 loadsh 这个库的依赖,但是我们任然可以导入这个库,并且可以正常的使用它里边的一些函数,控制台也正常输出了。

也就是说在这个例子里 loadsh 这个库就好像是一个幽灵一样,明明没有手动的去安装,但是莫名其妙的就可以使用了。

为什么不是Yarn?

yarn的扁平化包管理存在什么问题?

After a few days, I realized that Yarn is just a small improvement over npm. Although it makes installations faster and it has some nice new features, it uses the same flat node_modules structure that npm does (since version 3).几天后,我意识到纱线对NPM只是一个小改进。尽管它使安装更快,并且具有一些不错的新功能,但它使用了NPM所做的相同的Flat node_modules结构(由于版本3)。

And flattened dependency trees come with a bunch of issues:扁平的依赖树带有许多问题:

  1. modules can access packages they don't depend on模块可以访问他们不依赖的软件包
  2. the algorithm of flattening a dependency tree is pretty complex依赖树的变平算法非常复杂
  3. some of the packages have to be copied inside one project's node_modules folder其中一些软件包必须在一个项目的node_modules文件夹中复制

关于第三点我们解释一下: 比如你的项目直接依赖了A@2 你的项目也直接依赖了B@1, C@1. B@1和C@1 将A@2作为子依赖. 那么按照扁平化的规则. A@2 和B@1 和C@1位于同一个层级. B@1和C@1各自有自己的node_modules,并在各自的node_modules中存放一份A@1, 避免和项目的A@2冲突. 这显然造成了 A@1的复制.

幽灵依赖造成的问题

问题一:版本

比如说我们这里有一个项目,安装了 A 这个库,版本是 v1,但是 A 库又依赖一个 B 库,版本也是 v1。

我们项目里明明没有手动安装这个 B 库,但是在项目里边仍然可以去导入它并且使用,这就产生了幽灵依赖。

一旦有一天因为某种原因,我们要把 A 库进行升级,升级的 v2 的版本,v2 这个版本有可能要使用 B 库的 v2 版本,于是 B 库也会跟着升级,而 B 库升级之后,它里边有些 API 可能有变动,那么就会导致我们之前用 B 库的代码出问题了。

这个时候开发者就会很疑惑,我升级个 A 库,另一个 B 库咋出问题了呢?这就是个版本问题,当然版本问题还有很多很多复杂的情况,每一种情况都很难排查和处理。

而且除了版本问题之外,还有一个依赖丢失的问题。

问题二:依赖丢失

现在的问题和版本无关了,项目使用开发依赖安装了一个 A 库,A 库又依赖 B 库,然后项目里导入了 B 库来使用。

因为我们 A 库使用的是开发依赖,而到了生产环境我们就不会安装这个 A 库了,那么 A 依赖的 B 也不会被安装,但是我们在开发的时候又去使用了这个 B,到了生产环境 B 库也没了,这就导致了依赖丢失。

这个问题同样很难排查,在本地好好的到了生产环境就出问题了。

那么同学说我以后写代码的时候小心一点,要用哪一个库的时候先去安装它,如果这样做的话肯定没问题,关键是真的能做到吗?

一些项目上了规模之后依赖了几十个第三方库,而且还是在一个团队协作的环境里,你不可能在导入每一个包的时候,都去手动的看一下依赖里有没有声明,这样太麻烦了,所有很多时候我们在导包的时候看一下有没有智能提示,有只能提示就认为是安装的。

所以我们需要一个强有力的东西来进行约束。

问题的产生

我们说回问题的根本,为什么会产生这样的问题,npm 是傻子吗?干嘛要允许这样的问题产生呢?画个图看看:

我们要用很多的包,包与包之间会形成依赖关系,我们经常说什么依赖树,其实准确来说应该叫做依赖图,那么图结构就图结构呗,但是问题就发生在 npm 的这个包管理器使用的是文件结构,而文件结构是一个树结构。

早些的时候 npm 的处理方式就是文件树的方式,不同的包有相同的依赖就会在文件里嵌套相同的依赖包,这样一来的话就导致了有重复的包,项目如果上了规模,重复的包就多了,这些包就会大量占用磁盘空间。

所以后来出现了一个包管理器叫 yarn,它把这个问题解决了,yarn 将依赖拍扁了,不管什么依赖关系,通通作为 node_modules 的子目录。

那么拍扁了之后如何来表示依赖关系呢?咱们 nodeJs 不是有一个查询包的流程吗?如果说 A 依赖了 B 和 C,那么 A 里边的代码在 require 或者是 import 的时候,在本身的目录里找不到这两个依赖,就会向上查找,向上找就找到了。

这样子做是不会嵌套了,但是会产生幽灵依赖,本来我们手动安装了 A,但是依赖 B C 依旧会安装进来。

那么我们了解了问题产生的原因,现在我们就陷入了一个两难的情况,要么就接收重复文件,这样子就没有幽灵依赖了,要么就得接受幽灵依赖,无法两者兼得。

pnpm

用一个比较好记的词描述 pnpm 的优势那就是 "快、准、狠":

  • 快:安装速度快。
  • 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间,逻辑上也严丝合缝。
  • 狠:直接废掉了幻影依赖,在逻辑合理性与含糊的便捷性上,毫不留情的选择了逻辑合理性。

而带来这些优势的点子,全在官网上的这张图上:

  • 所有 npm 包都安装在全局目录 ~/.pnpm-store/v3/files 下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。
  • 每个项目的 node_modules 下有 .pnpm 目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。
  • 每个项目 node_modules 下安装的包结构为树状,符合 node 就近查找规则,以软链接方式将内容指向 node_modules/.pnpm 中的包

所以每个包的寻找都要经过三层结构:node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx

经过这三层寻址带来了什么好处呢?为什么是三层,而不是两层或者四层呢?

依赖文件三层寻址的目的

第一层

接着上面的例子思考,*第一层寻找依赖是 nodejswebpack 等运行环境/打包工具进行的,他们的在 node_modules 文件夹寻找依赖,并遵循就近原则,所以第一层依赖文件势必要写在 node_modules/package-a 下,一方面遵循依赖寻找路径,一方面没有将依赖都拎到上级目录,也没有将依赖打平,目的就是还原最语义化的 package.json 定义:即定义了什么包就能依赖什么包,反之则不行,同时每个包的子依赖也从该包内寻找,解决了多版本管理的问题,*同时也使 node_modules 拥有一个稳定的结构,即该目录组织算法仅与 package.json 定义有关,而与包安装顺序无关。

如果止步于此,这就是 npm@2.x 的包管理方案,但正因为 npm@2.x 包管理方案最没有歧义,所以第一层沿用了该方案的设计。

第二层

从第二层开始,就要解决 npm@2.x 设计带来的问题了,主要是包复用的问题。所以第二层的 node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a 寻址利用软链接解决了代码重复引用的问题。相比 npm@3 将包打平的设计,软链接可以保持包结构的稳定,同时用文件指针解决重复占用硬盘空间的问题。

若止步于此,也已经解决了一个项目内的包管理问题,但项目不止一个,多个项目对于同一个包的多份拷贝还是太浪费,因此要进行第三步映射。

第三层

第三层映射 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a > 硬链接 ~/.pnpm-store/v3/files/00/xxxxxx 已经脱离当前项目路径,指向一个全局统一管理路径了,这正是跨项目复用的必然选择,然而 pnpm 更进一步,没有将包的源码直接存储在 pnpm-store,而是将其拆分为一个个文件块,这在后面详细讲解。

幻影依赖

幻影依赖是指,项目代码引用的某个包没有直接定义在 package.json 中,而是作为子依赖被某个包顺带安装了。代码里依赖幻影依赖的最大隐患是,对包的语义化控制不能穿透到其子包,也就是包 a@patch 的改动可能意味着其子依赖包 b@major 级别的 Break Change。

正因为这三层寻址的设计,使得第一层可以仅包含 package.json 定义的包,使 node_modules 不可能寻址到未定义在 package.json 中的包,自然就解决了幻影依赖的问题。

pnpm工作原理

pnpm does not flatten the dependency tree.PNPM不会使依赖树弄平

So how does pnpm structure the node_modules directory, if not by flattening? 那么PNPM如何构建node_modules目录,即使不是通过扁平化?

与NPM@3不同,PNPM试图解决NPM@2遇到的问题,而不会使依赖关系树扁平化。在由PNPM创建的Node_modules文件夹中,所有软件包都将自己的依赖项分组在一起,但是目录树从未像NPM@2那样深。 PNPM保持所有依赖关系平整,但使用符号链接将它们分组在一起。

npm@2 : 使用嵌套的依赖结构,每个包的依赖都安装在自己的 node_modules 文件夹中

npm@3: 通过扁平化减少重复安装,提高安装速度和减少磁盘使用

bash 复制代码
-> - a symlink (or junction on Windows)

node_modules
├─ foo -> .registry.npmjs.org/foo/1.0.0/node_modules/foo
└─ .registry.npmjs.org
   ├─ foo/1.0.0/node_modules
   |  ├─ bar -> ../../bar/2.0.0/node_modules/bar
   |  └─ foo
   |     ├─ index.js
   |     └─ package.json
   └─ bar/2.0.0/node_modules
      └─ bar
         ├─ index.js
         └─ package.json

第一点

您可能已经注意到,node_modules根中的软件包只是一个符号链接。这很好,因为node.js忽略了符号链接并执行真实路径。因此,require('foo')将在node_modules/.registry.npmjs.org/foo/1.0.0/node_modules/foo/index.js中执行文件。

第二点

所有已安装的包在它们的目录中都没有自己的 node_modules 文件夹. 比如上面的foo 本身依赖了bar,但是 foo没有node_modules文件夹来存储bar。那么 foo 是如何引用 bar 的呢?

要想解释清楚这个问题我们先来说一个简单的例子 假设依赖A依赖了依赖B,则正常清空下依赖B位于依赖A的node_modules中. A的index.js会从当前文件夹下的node_modules中寻找依赖B. 根据依赖的查找规则 如果没有找到,则到父目录下的node_moduls中查找依赖. 所以为了能够让 A的index.js找到B.我们可以 首先创建一个node_modules目录,然后在这个目录下平级存放A和B两个项目

lua 复制代码
`-- node_modules
    |-- A
    |   |-- index.js
    |   `-- package.json
    `-- B
        |-- index.js
        `-- package.json

3 directories, 4 files

我们可以将上面的整个作为一个整体, 这个整体作为依赖A

lua 复制代码
`-- A@2.2
    `-- node_modules
        |-- A
        |   |-- index.js
        |   `-- package.json
        `-- B
            |-- index.js
            `-- package.json

这种结构也能保证A/index.js 能够找到依赖B. 此时我们将依赖B 替换到一个符号链接指向 .pnpm/B 首先上面的这个结构的特点是 A@2.2作为依赖名, 这个依赖下存在node_modules目录. 但是这里的node_modules/A内部没有node_modules存储依赖B.

上面这个结构就解释了 foo 如何引用bar.

我们以 依赖@vue/compiler-core@3.5.16 为例. 首先pnpm会将@vue/compiler-core@3.5.16 平铺到 .pnpm目录下.所以得到的目录如下: E:\临时文件\Examplefolder\node_modules.pnpm@vue+compiler-core@3.5.16 在这个文件夹内,需要自包含 @vue/compiler-core@3.5.16 本身的依赖项.

perl 复制代码
  "dependencies": {
    "@babel/parser": "^7.27.2",
    "entities": "^4.5.0",
    "estree-walker": "^2.0.2",
    "source-map-js": "^1.2.1",
    "@vue/shared": "3.5.16"
  },

所以首先创建 E:\临时文件\Examplefolder\node_modules.pnpm@vue+compiler-core@3.5.16\node_modules 目录来存放 依赖本身和这个依赖所依赖的项目.

sql 复制代码
40294@antlrv E:\临时文件\Examplefolder\node_modules\.pnpm\@vue+compiler-core@3.5.16\node_modules
> tree -d -F -L 2
.
|-- @babel
|   `-- parser ---> 当前依赖的依赖
|-- @vue
|   |-- compiler-core --->当前依赖本身
|   `-- shared  --->当前依赖的依赖
|-- entities --->当前依赖的依赖
|   `-- lib
|-- estree-walker --> 当前依赖的依赖
|   |-- dist
|   |-- src
|   `-- types
`-- source-map-js
    `-- lib

其中 @vue/compile-core 文件夹存放当前依赖项本身.

使用 eza命令查看的效果更好

perl 复制代码
40294@antlrv E:\临时文件\Examplefolder\node_modules\.pnpm\@vue+compiler-core@3.5.16\node_modules
> eza -T
.
├── @babel
│   └── parser -> E:\临时文件\Examplefolder\node_modules\.pnpm\@babel+parser@7.27.5\node_modules\@babel\parser
├── @vue
│   ├── compiler-core
│   │   ├── dist
│   │   │   ├── compiler-core.cjs.js
│   │   │   ├── compiler-core.cjs.prod.js
│   │   │   ├── compiler-core.d.ts
│   │   │   └── compiler-core.esm-bundler.js
│   │   ├── index.js
│   │   ├── LICENSE
│   │   ├── node_modules
│   │   ├── package.json
│   │   └── README.md
│   └── shared -> E:\临时文件\Examplefolder\node_modules\.pnpm\@vue+shared@3.5.16\node_modules\@vue\shared
├── entities -> E:\临时文件\Examplefolder\node_modules\.pnpm\entities@4.5.0\node_modules\entities
├── estree-walker -> E:\临时文件\Examplefolder\node_modules\.pnpm\estree-walker@2.0.2\node_modules\estree-walker
└── source-map-js -> E:\临时文件\Examplefolder\node_modules\.pnpm\source-map-js@1.2.1\node_modules\source-map-js
40294@antlrv E:\临时文件\Examplefolder\node_modules\.pnpm\@vue+compiler-core@3.5.16\node_modules

注意:

  1. 所有已安装的包在它们的目录中都没有自己的 node_modules 文件夹. 我们说 @vue/compiler-core 这个依赖 不会通过node_modules来管理他的子依赖. 从上面的图片中我们看到了@vue/compiler-core 存在子目录node_modules. 也就是 E:\临时文件\Examplefolder\node_modules.pnpm@vue+compiler-core@3.5.16\node_modules@vue\compiler-core\node_modules 实际上这个目录下没有子依赖
  1. 我们看到 @vue/compiler-core的子依赖在 E:\临时文件\Examplefolder\node_modules.pnpm@vue+compiler-core@3.5.16\node_modules 目录下都是以符号链接的形式存在.

所有软件包都将自己的依赖项分组在一起, 比如在下图中 @uni-helper/uni-types这个依赖被平铺 放置到了.pnpm/ ,但是 这个依赖的子依赖,位于他的node_modules目录下, 整个依赖项目分组在一起. 只不过子依赖是符号链接到其他位置.

相关推荐
@大迁世界1 天前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 天前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 天前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 天前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 天前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 天前
GDAL 实现矢量合并
前端
hxjhnct1 天前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 天前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 天前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 天前
我学习到的AG-UI的概念
前端