带你走进包管理器的世界

223 阅读15分钟

哈喽大家好,我是Jiaynn~

今天想要分享的是包管理器,在编写本文的时候参考了很多资料,包含了很多我借鉴的文章,都给出了相应的链接。

包管理器历史

这里我主要把他分成了下图的几个阶段,本次分享也主要围绕这张图展开

精简概括:

  • npm、yarn:扁平化node_modules

  • pnpm:软硬链接

  • yarn2:PnP即插即用

  • npm7:支持了workspaces

npm3

npm版本比较大的更新发生在npm3,这个版本基本上是对之前版本的重写

主要参考:github.com/npm/npm/rel…

这里,我主要想讲一下他的这两个比较大的改动

首先是peerDependencies,这个字段我之前不太了解,因为平时也没有经常去开发包,所以这次顺带着分享就了解了一下。

1. peerDependencies

参考:www.solutelabs.com/blog/peer-d…

是什么:在npm包中用来声明与宿主环境兼容的依赖版本,确保插件或库能在特定的库版本上正常运行。

先来看一下npm3更新后,这个字段的使用。

假设我们现在有一个为 my-ui 的一个 UI 库,它依赖于 React 来构建组件。

package.json
{
    "name": "my-ui",
    "version": "1.0.0",
    "peerDependencies": 
        {
            "react": "^18.0.0",
            "react-dom": "^18.0.0"
        }
}

在这个例子中,peerDependencies 指定了我们的ui库希望宿主项目(使用这个库的应用程序)已经安装了特定版本的 React 和 React DOM。

现在,假设我们有一个使用 这个ui库 的 React 应用程序 my-app

my-app pacakge.json
{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "my-ui": "^1.0.0",
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

这个字段的作用是告诉使用 my-ui 的项目(比如你的 my-app 应用),它们需要已经安装了特定版本的React和React DOM。

这里的关键点是,当你在 my-app 中安装 my-ui 时,npm不会自动安装React和React DOM。这是因为 peerDependencies 是用来确保你的项目和你使用的库可以和平共处,不会因为依赖版本不同而产生冲突。

简单来说,peerDependencies 的工作流程是这样的:

  1. 安装 my-ui 时,npm会检查你的项目是否已经安装了正确的React和React DOM版本。
  2. 如果没有,或者版本不匹配,npm会给你一个警告,告诉你需要手动安装兼容的版本。

所以,当你看到这样的警告:

npm WARN my-ui@1.0.0 requires a peer of react@^18.0.0 but none is installed. You must install peer dependencies yourself.
npm WARN my-ui@1.0.0 requires a peer of react-dom@^18.0.0 but none is installed. You must install peer dependencies yourself.

在npm2之前,npm会帮我们自动安装peerDependencies 字段中的内容,相当于他会存在重复安装。

2. 扁平化

在 npm2 及以前,每个包会将其依赖安装在自己的 node_modules 目录下,这意味着每个依赖也会带上自己的依赖,形成一个嵌套的结构。

我们可以很明显的看到他存在一些问题:

  • 磁盘空间占用:每个依赖都会安装自己的依赖,导致了大量的重复,特别是在多个包共享同一依赖的场景下。
  • 深层嵌套问题:目录层级过深,导致文件路径过长,会在windows系统下删除node_modules文件,出现删除不掉的情况。相关 issue:github.com/nodejs/node…
  • 安装和更新缓慢:每次安装或更新依赖时,npm 需要处理和解析整个依赖树,过程非常缓慢。

为解决这些问题,npm 在第三个版本进行了重构:

通过将依赖扁平化,尽可能地减少了重复的包版本,有效减少了项目的总体积,同时也避免了 npm 早期的深层嵌套问题。扁平化结构如下:

可以看到还是会有一定可能产生嵌套问题,因为根目录只能存放某个包的一个版本。

其实npm3还会存在一些新的问题,比如说这个博客提到的:

int64ago.org/2016/10/15/…

yarn

除了刚才提到的问题,npm当时还存在更严重的问题,比如说他没有锁文件、安装速度慢、安全性方面都存在一定问题。所以2016年,yarn就出现了。

他主要解决了哪些问题:

  1. 会自动生成yarn.lock文件——解决版本不一致问题(npm3都需要手动生成一个shrinkwrap文件)
  2. 采用了并行安装依赖
  3. 缓存下载过的包
  4. 安全性,检查安装的每个包的许可证

但他其实和npm3一样,仍然存在一些问题(这个我们在讲pnpm的时候说)

npm5

参考文档:github.com/npm/npm/rel…

npm5他其实就是把yarn解决了的问题给解决了一遍,所以这个时候,选择包管理器的话,npm和yarn都差不多,只是选择yarn的安装速度更快

那么他又做了哪些更新嘞?

1. 新增package-lock.json

2. 安全性

  • 每次发布新的包时,都会在包的元数据中同时包含 sha512sha1 校验和
  • 这样我们以后在下载包的时候,npm 会检查这个包的校验和,确保它与发布时的校验和一致,从而保证包的完整性和安全性。
  • 在npm5之前只有sha1校验和,但是sha1较旧且较弱的哈希算法。SHA-1 算法已经被证明在安全性上存在缺陷,容易受到攻击,在安全性方面存在缺陷。
  • 所以在npm5后,采用了 sha512sha1 校验和,在保证兼容的情况下也提高了安全性

3. 重构缓存

在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是:{cache}/{name}/{version}。

npmv5之后的缓存机制:

接下来打开_cacache文件,看看 npm 缓存了哪些东西,一共有 3 个目录:

  • content-v2
  • index-v5
  • tmp

其中 content-v2 里面基本都是一些二进制文件。为了使这些二进制文件可读,我们把二进制文件的扩展名改为 .tgz,然后进行解压,得到的结果其实就是我们的 npm 包资源。

而 index-v5 文件中,我们采用跟刚刚一样的操作就可以获得一些描述性的文件,事实上这些内容就是 content-v2 里文件的索引。

这些缓存如何被储存并被利用的呢?

  • 依赖下载到缓存:当执行 npm install 时,npm 会先通过 npm-registry-fetch 下载所需的包,并将其缓存到本地缓存目录中,而不是直接下载到 node_modules 目录。

pacote 依赖npm-registry-fetch来下载包,npm-registry-fetch 可以通过设置 cache 属性,在给定的路径下根据IETF RFC 7234生成缓存数据。

  • 生成缓存唯一标识:接着,在每次安装资源时,根据 package-lock.json 中存储的 integrity、version、name 信息生成一个唯一的 key,这个 key 能够对应到 index-v5 目录下的缓存记录。
  • 缓存查找和匹配:如果发现有缓存资源,就会找到 tar 包的 hash,根据 hash 再去找缓存的 tar 包。
  • 解压到 node_modules:找到对应的缓存文件后,npm 通过pacote把对应的二进制文件解压到相应的项目 node_modules 下面,完成安装。

npm 的安装机制

npm install 执行之后,首先,检查并获取 npm 配置,这里的优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件。

pnpm

相对于npm、yarn扁平化node_modules,pnpm采用了一种新的方式去处理

主要是为了解决幽灵依赖、减少磁盘空间、提升速度

软硬链接

参考视频:www.bilibili.com/video/BV1bh…

特性软链接(Symbolic Link)硬链接(Hard Link)
存储内容存储目标路径(路径名)存储目标文件的数据块引用(同一个 inode)
文件系统限制可以跨文件系统创建只能在同一个文件系统中创建
删除目标文件后的行为软链接失效,变为悬空链接目标文件被删除后,硬链接依然有效,文件内容不受影响
独立的 inode有独立的 inode 和文件类型无独立 inode,硬链接与原文件共享同一个 inode 和数据块
用途创建快捷方式、跨文件系统链接创建文件的多个引用、数据共享、备份
符号链接是否可识别可以通过文件类型(符号链接)识别无法识别硬链接和原文件的区别,文件无区别

硬链接在pnpm中的使用

pnpm 通过使用全局的 .pnpm-store 来存储下载的包,使用硬链接来重用存储在全局存储中的包文件,这样不同项目中相同的包无需重复下载,节约磁盘空间。

软链接在pnpm中的使用

pnpm 将各类包的不同版本平铺在 node_modules/.pnpm 下,对于那些需要构建的包,它使用符号链接连接到存储在项目中的实际位置。这种方式使得包的安装非常快速,并且节约磁盘空间。

举个例子,项目中依赖了 A,这时候可以通过创建软链接,在 node_modules 根目录下创建 A 软链指向了 node_modules/.pnpm/A/node_modules/A。此时如果 A 依赖 B,pnpm 同样会把 B 放置在 .pnpm 中,A 同样可以通过 软链接依赖到 B,避免了嵌套过深的情况。

node_modules结构

这种巧妙的结构解决了很多问题:

  1. 节省磁盘空间:由于使用硬链接,相同的包不需要被重复存储,大大减少了磁盘空间的需求。
  2. 提高安装速度:安装包时,pnpm 通过创建链接而非复制文件,这使得安装过程非常快速。
  3. 确保依赖隔离:通过软链接有效减少了幽灵依赖产生的可能,同时保证了依赖的隔离。

一些证明项目依赖包的源文件就是 store 目录下的 hard link :juejin.cn/post/716608…

幽灵依赖产生的根本原因

然而就算使用 pnpm,幽灵依赖还是难以根除,我们不妨分析一下幽灵依赖产生的根本原因。

包管理工具的依赖解析机制

这就是前面介绍的平铺式带来的问题,这边就不重复讲述了。

第三方库历史问题

由于历史原因或开发者的疏忽,有些项目可能没有正确地声明所有直接使用的依赖。对于三方依赖,幽灵依赖已经被当做了默认的一种功能来使用,提 issue 修复的话,周期很长,对此 pnpm 也没有任何办法,只能做出妥协。

下面是 pnpm 的处理方式:

  • 对直接依赖严格管理:对于项目的直接依赖,pnpm 保持严格的依赖隔离,确保项目只能访问到它在package.json 中声明的依赖。
  • 对间接依赖妥协处理:考虑到一些第三方库可能依赖于未直接声明的包(幽灵依赖),pnpm 默认启用了 hoist 配置。这个配置会将一些间接依赖提升(hoist)到一个特殊的目录 node_modules/.pnpm/node_modules中。这样做的目的是在保持依赖隔离的同时,允许某些特殊情况下的间接依赖被访问。

JavaScript 模块解析策略

Node.js 的模块解析策略允许从当前文件夹的 node_modules 开始,向上遍历文件系统,直到找到所需模块。

这种解析策略,虽然提供了灵活性,也使得幽灵依赖更容易产生,因为它允许模块加载那些未直接声明在项目package.json 中的依赖。

综合来看,幽灵依赖在目前是无法根除的,只能通过一些额外的处理进行管控,比如 eslint 对幽灵依赖的检查规则、pnpm 的 hoist 配置等。

Yarn Berry

为什么要开发v2版本

原有代码架构满足不了新的需求

Yarn创建于2016年初,它在刚开始的时候借鉴了很多npm的东西,其中的架构设计本身就不是很符合Yarn开发者的愿景。在那之后,由于不断有新的需求产生,Yarn在接下来的几年中还添加了很多新的功能,其中包括Workspaces(2017), Plug'n'Play(2018)和Zip loading(2019)。这些新的概念在Yarn刚刚被创建的时候压根就不存在,所以Yarn的架构设计也就没有考虑到日后这些新功能的添加,因此随着时间的推移,Yarn的代码变得越来越难维护和扩展。由于这个技术原因,Yarn需要一个更加现代化的代码架构来满足新需求的开发。

yarn 放弃了 yarn v1 版本的迭代,将 yarn v1 定性为 yarn classic,从而 yarn berry 诞生。

v2都有什么新的特性

参考:dev.to/arcanis/int…

新特性比较多,这里主要挑几个来解释下他是什么意思

pnp 模式的依赖管理策略

yarn 认为目前包管理工具出现的各种问题很大程度上来自于 node_modules 本身:无论怎么样利用缓存,或者使用什么样的思路以及目录结构来设计 node_modules,只要你生成它,那么就需要知道 node_modules 要包含的内容并且执行繁重的 I/O 操作。

而为什么之前包管理工具一定要生成 node_modules 的依赖包呢,原因在于 node module resolution 的机制就是如此,即 node 会一层一层的依照目录层级顺序去 node_modules 中去寻找相应的依赖。

但是在安装依赖的过程中,包管理工具将会去获取并梳理项目依赖树的所有信息,那么在已知了项目依赖信息之后,为什么还要依靠 node 再去寻找一次依赖包呢,这个就是 pnp 特性要解决的问题。

在 pnp 模式下,安装项目依赖后根目录下将不会出现 node_modules 文件夹了,相应代替的则是 .pnp.cjs 文件。

  • 依赖映射:PnP创建了一个依赖映射文件,该文件记录了项目中每个包的版本和位置。这个映射文件替代了node_modules目录,成为Yarn解析依赖关系的方式。
  • 缓存机制:Yarn将下载的依赖项存储在一个全局缓存中,而不是项目的本地node_modules目录。这意味着依赖项只需下载一次,并且可以在不同的项目中重用。

零安装

Yarn 2的零安装(Zero-Install)特性是围绕其新的依赖管理策略——Plug’n’Play(PnP)构建的

简单来说就是不需要yarn install就可以运行项目。

项目开发者使用yarn2 初始化项目并提交到github

  • yarn init -2
  • yarn add express——Yarn会将express及其所有依赖项下载到全局缓存中,并在项目目录中创建一个.pnp.cjs文件,该文件包含了依赖项的映射信息。
  • 项目已经使用Yarn 2初始化,并且express作为依赖项被添加到package.json中。项目结构如下:
project/
├── .pnp.cjs
├── package.json
├── start.js
└── ...
  • 提交代码到git仓库

开发者使用yarn2项目(克隆后可以直接运行yarn start)

  1. 开发者克隆项目
  2. 检查.pnp.cjs文件:当开发者尝试运行项目时,Node.js会使用项目中的.pnp.cjs文件来解析和加载依赖项。
  3. 依赖项不在本地缓存:如果开发者是第一次在这个机器上使用这些依赖项,Yarn会发现全局缓存中没有所需的依赖项。
  4. 自动下载依赖项:Yarn会自动从远程注册表中下载所需的依赖项版本,并将其存储在全局缓存中。这是一个透明的过程,开发者不需要手动干预。
  5. 更新.pnp.cjs文件:一旦依赖项被下载并存储在全局缓存中,Yarn会更新.pnp.cjs文件,以确保它包含了新的依赖项信息。
  6. 执行项目:现在,所有必要的依赖项都已经在全局缓存中,Node.js可以使用.pnp.cjs文件来加载它们,并且项目可以正常运行。

New Command: yarn dlx

全称“download and execute”,和npx类似,但比npx更安全。

npx 执行模块时会优先安装依赖,但是在安装执行后便删除此依赖,这就避免了全局安装模块带来的问题。

why? 一个例子:

假设我们使用create-my-app的远程脚本来创建一个新的项目。

使用npx的命令可能是:

npx create-my-app

如果你不小心打成了:

npx create-my-ppp

假设本地有一个名为create-my-ppp的恶意脚本,npx将会执行这个本地脚本,这可能导致安全风险。

使用yarn dlx的命令是:

yarn dlx create-my-app

即便你打错了命令,如:

yarn dlx create-my-ppp

yarn dlx也会尝试从远程下载create-my-ppp脚本,而不会在本地查找并执行名为create-my-ppp的任何文件,从而避免了执行潜在恶意本地脚本的风险。

更好的workspaces支持

参考文档汇总:

npm

npm3:github.com/npm/npm/rel…

npm5:github.com/npm/npm/rel…

peerDependencies:

npm install && npx:

juejin.cn/post/706084…

yarn

yarn2:dev.to/arcanis/int…

pnp:yarnpkg.com/features/pn…

新特性解读:juejin.cn/post/684490…

pnpm

linux软链接vs硬链接

面试官:说说包管理工具的发展以及 pnpm 依赖治理的最佳实践

www.youtube.com/watch?v=d1E…

pnpm.io/motivation

github.com/lvqq/blog/i…

包管理器对比

特别好,很详细:JavaScript package managers compared

An abbreviated history of JavaScript package managers

都2022年了,pnpm快到碗里来!

Why should we use pnpm?

Node.js 包管理器发展史

包管理工具的演进