Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

浅析依赖管理

npm#

npm 是最早的依赖安装命令行工具,以下是 npm 如何安装依赖的步骤:

  • 发出 npm install 命令
  • npm 会向 registry 查询模块压缩包的网址
  • 下载压缩包,并将其存放在~/.npm 目录下
  • 将压缩包解压到当前项目的 node_modules 目录中。

需要注意的是,npm2 和 npm3 之间有一些区别。

npm2 嵌套地狱#

npm2 安装依赖比较简单直接,按包的依赖树形结构下载并填充本地目录结构,即嵌套的 node_modules 结构。直接的依赖项放在 node_modules 下,而子依赖项则嵌套在其直接依赖项的 node_modules 中。

例如,如果项目依赖 A 和 C,而 A 和 C 都依赖相同版本的 B@1.0,同时 C 还依赖 D@1.0.0,则 node_modules 的结构如下:

node_modules
├── A@1.0.0
   └── node_modules
       └── B@1.0.0
└── C@1.0.0
    └── node_modules
        └── B@1.0.0
        └── D@1.0.0

可以看到同版本的 B 分别被 A 和 C 安装了两次。

如果依赖的层级越多,且依赖包数量越多,久而久之,就会形成嵌套地狱:

image

npm3#

扁平化嵌套#

为了解决 npm2 存在的问题,npm3 提出了新的解决方案,即将依赖项进行展平,也就是扁平化。

npm v3 通过子依赖 "提升"(hoist)采用扁平化的 node_modules 结构,在主依赖项所在的目录中尽可能地安装子依赖项。

例如,如果项目依赖 A 和 C,而 A 依赖 B@1.0.0,同时 C 依赖 B@2.0.0,则:

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
     └── node_modules
          └── B@2.0.0

可以看到,A 的子依赖项 B@1.0 不再位于 A 的 node_modules 下,而是与 A 位于同一级别。由于版本号的原因,C 依赖的 B@2.0 仍然位于 C 的 node_modules 中。

这样可以避免大量的包重复安装,同时也不会形成太深的依赖层次,从而解决了依赖地狱问题。

那为什么不把 B@2.0 提升到 node_modules 而是 B@1.0 呢?如果将 B 直接提取到我们的 node_modules,是否意味着我们可以在代码中直接引用 B 包?这就引出了我们下面的问题:

不确定性#

对于这种处理方式,我们很容易有疑问:如果我同时引用了同一个包的不同版本,会帮我提取哪个包?每次运行 npm i 之后提取出的包的版本是否都是相同的?这意味着即使使用相同的 package.json 文件,安装依赖项后也可能得到不同的 node_modules 目录结构。

例如:

  • A@1.0.0: B@1.0.0
  • C@1.0.0: B@2.0.0

在 install 后,到底是应该提升 B 的 1.0 还是 2.0 呢?

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
     └── node_modules
         └── B@2.0.0
node_modules
├── A@1.0.0
   └── node_modules
       └── B@1.0.0
├── B@2.0.0
└── C@1.0.0

很多人认为会根据 package.json 中的顺序来决定提取哪个包,将出现在更前面的包先提取。但实际上,查看源代码后,npm 会使用一个名为 localeCompare 的方法对依赖项进行排序。实际上,字典序在前面的 npm 包的底层依赖项会被优先提取。

幽灵依赖#

所谓幽灵依赖,是指在 package.json 文件中没有列出该依赖项,但实际上项目中使用了该依赖项,并且由于扁平化嵌套,该依赖项可以直接访问,这是一种非法的访问方式。其中,dayjs 包是最常见的案例。

例如,我的项目使用 arco,但 arco 的子依赖项包括 dayjs。根据扁平化规则,dayjs 会放在 node_modules 的顶层。然而,这产生了一个很大的问题:一旦 arco 删除了 dayjs 的子依赖项,我们的代码将直接报错。

依赖分身#

假设继续再安装依赖 B@1.0 的 D 模块和依赖 @B2.0 的 E 模块,此时:

  • A 和 D 依赖 B@1.0
  • C 和 E 依赖 B@2.0
    以下是提升 B@1.0 的 node_modules 结构:
node_modules
├── A@1.0.0
├── B@1.0.0
├── D@1.0.0
├── C@1.0.0
    └── node_modules
         └── B@2.0.0
└── E@1.0.0
      └── node_modules
           └── B@2.0.0

可以看到,B@2.0 被安装了两次。实际上,无论是提升 B@1.0 还是 B@2.0,都会导致重复安装的 B 版本存在。这些重复安装的 B 被称为 "doppelgangers"。

此外,尽管模块 C 和 E 似乎都依赖于 B@2.0,但它们实际上引用的并不是同一个 B。如果 B 在导出之前进行了某些缓存或副作用的处理,那么使用该项目的用户就可能出错。

npm install#

npm3 以上的版本安装依赖的步骤:

  1. 检查配置:读取 npm config 和 .npmrc 配置,比如配置镜像源。
  2. 确定依赖版本,构建依赖树:检查是否存在 package-lock.json。若存在,进行版本比对,处理方式和 npm 版本有关,根据最新 npm 版本处理规则,版本能兼容按照 package-lock 版本安装,反之按照 package.json 版本安装;若不存在,根据 package.json 确定依赖包信息。
  3. 检查缓存或下载:判断是否存在缓存。若存在,将对应缓存解压到 node_modules 下,生成 package-lock.json;若不存在,则下载资源包,验证包完整性并添加至缓存,之后解压到 node_modules 下,生成 package-lock.json

image

yarn#

并行安装#

当需要使用 npm 或 yarn 安装包时,会产生一系列任务。在使用 npm 时,这些任务按照包的顺序依次执行,只有当一个包完全安装完成后,才会安装下一个包。

Yarn 通过并行操作最大程度地提高资源利用率,因此再次下载时安装时间比之前更快。而在 npm5 之前,则采用串行下载方式,等待一个包安装完成后再安装下一个包。

yarn.lock#

我们知道,npm 中的 package.json 文件安装包的结构或版本并不总是一致的,因为 package.json 文件的写法是基于语义化版本控制(semantic versioning)的:发布的补丁应该只包含未实质修改的内容。但不幸的是,这并不总是符合事实的。npm 的策略可能会导致两个设备使用相同的 package.json 文件,但安装了不同版本的包,这可能会导致故障。

为了防止拉取到不同版本的包,yarn 使用一个锁定文件 (lock file) 来记录确切安装的模块的版本号。每次添加一个模块,yarn 都会创建(或更新)一个名为 yarn.lock 的文件。这样,每次拉取同一个项目的依赖时,就能确保使用相同的模块版本。

yarn.lock 文件只包含版本锁定,不确定依赖关系的结构,需要与 package.json 文件结合使用来确定依赖结构。在 install 过程中,会进行详细的解释。

yarn.lock 锁文件将所有依赖包扁平化展示,并将同名包但不兼容的 semver 作为不同字段放在 yarn.lock 的同一级结构中。

yarn install#

执行 yarn install 后会经过五个阶段:

  1. Validating package.json(检查 package.json):检查系统运行环境,包括 OS、CPU、engines 等信息。
  2. Resolving packages(解析包):整合依赖信息。
  3. Fetching packages(获取包):首先判断缓存目录中有没有缓存资源,其次读取文件系统,都不存在则从 Registry 进行下载。
  4. Linking dependencies(连接依赖):复制依赖到 node_modules。首先解析 peerDependencies 信息,之后基于扁平化原则(yarn 扁平化规则不同于 npm,使用频率较大的版本会安装到顶层目录,这个过程称为 dedupe),从缓存复制依赖至当前项目 node_modules 目录
  5. Building fresh packages(构建安装):这个过程会执行 install 相关钩子,包括 preinstall、install、postinstall。

解析包(resolving packages):首先根据项目 package.json 中 dependencies、devDependencies、optionalDependencies 字段形成首层依赖集合,之后对嵌套依赖逐级进行递归解析(将解析过和正在解析的包用一个 Set 数据结构来存储,保证同一个版本范围内的包不会被重复解析),结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash 值、子依赖等信息(过程中遵循依照 yarn.lock 优先原则)最终确定依赖版本信息、下载地址。

过程总结为两部分:

  • 收集首层依赖,将 package.json 中的 dependencies_、_devDependencies__optionalDependencies 依赖列表和 workspaces 中的顶级 packages 列表以 「包名 @版本范围」 的格式整合为首层依赖集合,可以具象为一个字符串数组;
  • 遍历所有依赖,收集依赖具体信息,从首层依赖集合出发,结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash 值、子依赖等信息。

image

pnpm#

pnpm 代表 performant(高性能的)npm,如 pnpm 官方介绍,它是:速度快、节省磁盘空间的软件包管理器,pnpm 本质上就是一个包管理器,它的两个优势在于:

  • 包安装速度极快
  • 磁盘空间利用非常高效

那么,pnpm 如何实现如此大的性能提升呢?这是因为在计算机中有一种叫做 "硬链接" (hard link) 的机制。硬链接允许用户通过不同的路径引用方式找到某个文件。pnpm 会将项目 node_modules 目录下的硬链接文件存储在全局 store 目录中。

硬链接可以理解为源文件的副本,而实际上,在项目中安装的就是这些副本。它使得用户可以通过路径引用查找到源文件。同时,pnpm 会在全局 store 中存储硬链接,不同的项目可以从全局 store 中寻找到相同的依赖项,从而大大节省了磁盘空间。

硬链接是通过索引节点来连接的。在 Linux 文件系统中,保存在磁盘分区中的每个文件都被分配一个编号,称为索引节点号 (inode index)。在 Linux 中,多个文件名可以指向同一个索引节点。例如:如果 A 是 B 的硬链接 (A 和 B 都是文件名),则 A 的目录项中的 inode 节点号与 B 的目录项中的 inode 节点号相同,即一个 inode 节点对应两个不同的文件名,两个文件名指向同一个文件。对于文件系统来说,A 和 B 是完全平等的。删除其中任何一个都不会影响另一个的访问。

Symbolic link 也称为软链接,它可以理解为快捷方式。pnpm 可以通过软链接找到对应磁盘目录下的依赖项地址。软链接文件只是其源文件的标记。当删除源文件后,链接文件将无法独立存在。虽然保留了文件名,但不能查看软链接文件的内容。

删除文件会影响 symlink 的内容,文件删除后再恢复内容,但是仍然会和 symlink 保持同步,链接文件甚至可以链接不存在的文件,这就产生一般称之为” 断链” 的现象。

image

这个全新的机制设计非常巧妙,不仅兼容了 node 的依赖解析,同时还解决了以下问题:

  • 幽灵依赖问题:只有直接依赖项会展开在 node_modules 目录下,子依赖项不会被提升,因此不会产生幽灵依赖。

  • 依赖分身问题:相同的依赖项只会在全局存储中安装一次。项目中只包含源文件的副本,几乎不会占用任何空间,因此没有依赖分身问题。

  • 最大的优点是节省磁盘空间,每个包在全局 store 中只保存一份,其余都是软链接或硬链接。

不足之处#
  • 全局硬链接也会导致一些问题。例如,修改了链接的代码,所有项目都会受到影响;也不友好于 postinstall(自动安装后)操作;在 postinstall 中修改代码可能会导致其他项目出现问题。pnpm 默认采用 cow(Copy on Write,写时复制)策略,但该配置在 Mac 上不生效。这实际上是由于 node 不支持所导致的,可以参考相应的 issue。

  • 由于 pnpm 创建的 node_modules 依赖项是软链接,因此在不支持软链接的环境中无法使用 pnpm,例如 Electron 应用程序。


References:
https://mp.weixin.qq.com/s/9JCs3rCmVuGT3FvKxXMJwg

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.