哈喽大家好,我是Jiaynn~
今天想要分享的是包管理器,在编写本文的时候参考了很多资料,包含了很多我借鉴的文章,都给出了相应的链接。
包管理器历史
这里我主要把他分成了下图的几个阶段,本次分享也主要围绕这张图展开
精简概括:
-
npm、yarn:扁平化node_modules
-
pnpm:软硬链接
-
yarn2:PnP即插即用
-
npm7:支持了workspaces
npm3
npm版本比较大的更新发生在npm3,这个版本基本上是对之前版本的重写
这里,我主要想讲一下他的这两个比较大的改动
首先是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
的工作流程是这样的:
- 安装
my-ui
时,npm会检查你的项目是否已经安装了正确的React和React DOM版本。 - 如果没有,或者版本不匹配,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还会存在一些新的问题,比如说这个博客提到的:
yarn
除了刚才提到的问题,npm当时还存在更严重的问题,比如说他没有锁文件、安装速度慢、安全性方面都存在一定问题。所以2016年,yarn就出现了。
他主要解决了哪些问题:
- 会自动生成yarn.lock文件——解决版本不一致问题(npm3都需要手动生成一个shrinkwrap文件)
- 采用了并行安装依赖
- 缓存下载过的包
- 安全性,检查安装的每个包的许可证
但他其实和npm3一样,仍然存在一些问题(这个我们在讲pnpm的时候说)
npm5
npm5他其实就是把yarn解决了的问题给解决了一遍,所以这个时候,选择包管理器的话,npm和yarn都差不多,只是选择yarn的安装速度更快
那么他又做了哪些更新嘞?
1. 新增package-lock.json
2. 安全性
- 每次发布新的包时,都会在包的元数据中同时包含
sha512
和sha1
校验和 - 这样我们以后在下载包的时候,npm 会检查这个包的校验和,确保它与发布时的校验和一致,从而保证包的完整性和安全性。
- 在npm5之前只有sha1校验和,但是sha1较旧且较弱的哈希算法。SHA-1 算法已经被证明在安全性上存在缺陷,容易受到攻击,在安全性方面存在缺陷。
- 所以在npm5后,采用了
sha512
和sha1
校验和,在保证兼容的情况下也提高了安全性
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结构
这种巧妙的结构解决了很多问题:
- 节省磁盘空间:由于使用硬链接,相同的包不需要被重复存储,大大减少了磁盘空间的需求。
- 提高安装速度:安装包时,pnpm 通过创建链接而非复制文件,这使得安装过程非常快速。
- 确保依赖隔离:通过软链接有效减少了幽灵依赖产生的可能,同时保证了依赖的隔离。
一些证明项目依赖包的源文件就是 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都有什么新的特性
新特性比较多,这里主要挑几个来解释下他是什么意思
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)
- 开发者克隆项目
- 检查
.pnp.cjs
文件:当开发者尝试运行项目时,Node.js会使用项目中的.pnp.cjs
文件来解析和加载依赖项。 - 依赖项不在本地缓存:如果开发者是第一次在这个机器上使用这些依赖项,Yarn会发现全局缓存中没有所需的依赖项。
- 自动下载依赖项:Yarn会自动从远程注册表中下载所需的依赖项版本,并将其存储在全局缓存中。这是一个透明的过程,开发者不需要手动干预。
- 更新
.pnp.cjs
文件:一旦依赖项被下载并存储在全局缓存中,Yarn会更新.pnp.cjs
文件,以确保它包含了新的依赖项信息。 - 执行项目:现在,所有必要的依赖项都已经在全局缓存中,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
peerDependencies:
npm install && npx:
yarn
yarn2:dev.to/arcanis/int…
新特性解读:juejin.cn/post/684490…
pnpm
面试官:说说包管理工具的发展以及 pnpm 依赖治理的最佳实践
包管理器对比
特别好,很详细:JavaScript package managers compared