npm
npm 的安装机制
npm 的安装机制非常值得探究。Ruby 的 Gem、Python 的 pip 都是全局安装,但是 npm 的安装机制秉承了不同的设计哲学。它会优先安装依赖包到当前项目目录,使得不同应用项目的依赖各成体系,但这样做的缺陷也很明显:如果我们的项目 A 和项目 B,都依赖了相同的公共库 C,那么公共库 C 一般都会在项目 A 和项目 B 中,各被安装一次。也就是说,同一个依赖包可能在我们的电脑上进行多次安装。
npm install 的安装机制如下:
npm install
执行之后,首先检查并获取 npm 配置,这里的优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件
。
.npmrc
是一个配置文件,用于存储与npm
相关的配置信息。在.npmrc
文件中,你可以设置各种配置选项,例如:
- 设置代理服务器
- 设置镜像源
- 登录到私有仓库
- 设置缓存位置
-
如果项目中有
package-lock.json
文件。-
版本号和
package.json
中声明版本一致,根据package-lock.json
中的依赖从本地缓存或网络下载安装依赖。 -
版本号和
package.json
中声明版本不一致,则根据npm版本的不同进行处理。npm v5.0.x
:根据package-lock.json
下载。npm v5.1.0-v5.4.2
:当package.json
声明的依赖版本规范有符合的更新版本是,忽略package-lock.json
,按照package.json
安装,并更新package-lock.json
。npm v5.4.2
以上:当package.json
声明的依赖版本规范与package-lock.json
安装版本兼容,则根据package-lock.json
安装;否则按照package.json
安装,并更新package-lock.json
。
-
-
如果项目中没有
package-lock.json
文件,则根据package.json
递归构建依赖树。然后按照构建好的依赖树下载完整的依赖资源,在下载时就会检查是否存在相关资源缓存:- 存在,则将缓存内容解压到 node_modules 中。
- 否则就先从 npm 远程仓库下载包,校验包的完整性,并添加到缓存,同时解压到 node_modules。
构建依赖树时,当前依赖项目不管其是直接依赖还是子依赖的依赖,都应该按照扁平化原则,优先将其放置在 node_modules 根目录(最新版本 npm 规范)。在这个过程中,遇到相同模块就判断已放置在依赖树中的模块版本是否符合新模块的版本范围,如果符合则跳过;不符合则在当前模块的 node_modules 下放置该模块(最新版本 npm 规范)。
- 最后生成
package-lock.json
。
npm 缓存机制
前端工程中,依赖嵌套依赖,一个中型项目中 node_moduels
安装包可能就已经是海量的了。如果安装包每次都通过网络下载获取,无疑会增加安装时间成本。对于这个问题,缓存始终是一个好的解决思路。
可以通过命令 npm config get cache
得到配置缓存的根目录。cd
进入缓存目录中可以发现_cacache
文件夹。事实上,在 npm v5
版本之后,缓存数据均放在根目录中的_cacache
文件夹中。
可以通过命令 npm cache clean --force
清除缓存。
进入 _cacache
文件,一共有 3 个目录:
content-v2
:里面基本都是一些二进制文件。为了使这些二进制文件可读,我们把二进制文件的扩展名改为.tgz
,然后进行解压,得到的结果其实就是我们的 npm 包资源。
index-v5
:里面同样也是一些二进制文件。同样也可以将二进制文件的扩展名改为.tgz
,然后进行解压,就可以获得一些描述性的文件,这些内容就是 content-v2 里文件的索引。
tmp
这些缓存如何被储存并被利用的呢?
当 npm install
执行时,通过pacote
把相应的包解压在对应的 node_modules
下面。npm 在下载依赖时,先下载到缓存当中,再解压到项目 node_modules
下。pacote
依赖npm-registry-fetch
来下载包,npm-registry-fetch 可以通过设置 cache 属性,在给定的路径下生成缓存数据。
接着,在每次安装资源时,根据 package-lock.json
中存储的 integrity、version、name
信息生成一个唯一的 key,这个 key 能够对应到 index-v5
目录下的缓存记录。如果发现有缓存资源,就会找到 tar 包的 hash,根据 hash 再去找缓存的 tar 包,并再次通过pacote
把对应的二进制文件解压到相应的项目 node_modules
下面,省去了网络下载资源的开销。
这里提到的缓存机制是从 npm v5 版本开始的。在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是:{cache}/{name}/{version}。
yarn
yarn
是一个由 Facebook、Google、Exponent 和 Tilde 构建的新的 JavaScript 包管理器。出现时间为 2016 年,此时 npm 处于 v3 时期,其实当时 yarn 解决的问题基本就是 npm v5 解决的问题,包括使用 yarn.lock 等机制,锁定版本依赖,实现并发网络请求,最大化网络资源利用率,其次还有利用缓存机制,实现了离线模式。
其实后面很多 npm 都是在学习 yarn 的机制,上面的机制目前 npm 基本也都实现了,就目前而言 npm 和 yarn 其实并没有差异很大,具体使用 npm 还是 yarn 可以看个人需求
当 npm 还处在 v3 时期时,npm 还没有 package-lock.json
文件,安装速度很慢,稳定性也较差,而 Yarn 的理念很好地解决了以下问题:
- 确定性:通过
yarn.lock
等机制,保证了确定性。即不管安装顺序如何,相同的依赖关系在任何机器和环境下,都可以以相同的方式被安装。
在 npm v5 之前,没有 package-lock.json 机制,只有默认并不会使用的npm-shrinkwrap.json。
- 采用模块扁平安装模式:将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余。
npm 目前也有相同的优化
- 网络性能更好:Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。
- 采用缓存机制,实现了离线模式。
npm 目前也有类似实现
yarn 的安装机制
yarn 的安装过程主要有以下 5 大步骤:
-
检测包(checking Packages):这一步主要是检测项目中是否存在一些
npm
相关文件,比如package-lock.json
等。如果有,会提示用户,注意这些文件的存在可能会导致冲突。在这一步骤中,也会检查系统 OS、CPU 等信息。 -
解析包(Resolving Packages) :这一步会解析依赖树中每一个包的版本信息。首先获取当前项目中
package.json
定义的dependencies、devDependencies、optionalDependencies
的内容,这属于首层依赖。接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析。- 对于没有解析过的包 A,首次尝试从
yarn.lock
中获取到版本信息,并标记为已解析; - 如果在
yarn.lock
中没有找到包 A,则向Registry
发起请求获取满足版本范围的已知最高版本的包信息,获取后将当前包标记为已解析。
总之,在经过解析包这一步之后,我们就确定了所有依赖的具体版本信息以及下载地址。
- 对于没有解析过的包 A,首次尝试从
-
获取包(Fetching Packages):这一步我们首先需要检查缓存中是否存在当前的依赖包,同时将缓存中不存在的依赖包下载到缓存目录。
说起来简单,但是还是有些问题值得思考。比如:
如何判断缓存中是否存在当前的依赖包?
其实 Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载。这个 path 也就是依赖包缓存的具体路径。对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。如果下载包地址是一个 file 协议,或者是相对路径,就说明其指向一个本地目录,此时调用 Fetch From Local 从离线缓存中获取包;否则调用 Fetch From External 获取包。最终获取结果使用 fs.createWriteStream 写入到缓存目录下。
-
链接包(Linking Packages):上一步是将依赖下载到缓存目录,这一步是将项目中的依赖复制到项目
node_modules
下,同时遵循扁平化原则。在复制依赖前,Yarn
会先解析peerDependencies
,如果找不到符合peerDependencies
的包,则进行warning
提示,并最终拷贝依赖到项目中。这里提到的扁平化原则是核心原则,下面内容会详细说明
-
构建包(Building Packages):如果依赖包中存在二进制包需要进行编译,会在这一步进行。
破解依赖管理困境
早期 npm(npm v2)
的设计非常简单,在安装依赖时将依赖放到项目的 node_modules
文件中;同时如果某个直接依赖 A 还依赖其他模块 B,作为间接依赖,模块 B 将会被下载到 A 的 node_modules
文件夹中,依此递归执行,最终形成了一颗巨大的依赖模块树。 这样的 node_modules
结构,的确简单明了、符合预期,但对大型项目在某些方面却不友好,比如可能有很多重复的依赖包,而且会形成"嵌套地狱"。那么如何理解"嵌套地狱"呢?
- 项目依赖树的层级非常深,不利于调试和排查问题;
- 依赖树的不同分支里,可能存在同样版本的相同依赖。比如直接依赖 A 和 B,但 A 和 B 都依赖相同版本的模块 C,那么 C 会重复出现在 A 和 B 依赖的
node_modules
中。
这种重复问题使得安装结果浪费了较大的空间资源,也使得安装过程过慢,甚至会因为目录层级太深导致文件路径太长,最终在Windows 系统下删除 node_modules
文件夹出现失败情况。
因此 npm v3
之后,node_modules
的结构改成了扁平结构,按照上面的例子(项目直接依赖模块 A,A 还依赖其他模块 B),我们得到下面的图示:
当项目新添加了 C 依赖,而它依赖另一个版本的 B v2.0。这时候版本要求不一致导致冲突,B v2.0 没办法放在项目平铺目录下的 node_moduls
文件当中,npm v3
会把 C 依赖的 B v2.0 安装在 C 的 node_modules
下:
接下来,在 npm v3
中,假如我们的 App 现在还需要依赖一个 D,而 D 也依赖 B v2.0 ,我们会得到如下结构:
思考一个问题:为什么 B v1.0 出现在项目顶层 node_modules,而不是 B v2.0 出现在 node_modules 顶层呢?
其实这取决于模块 A 和 C 的安装顺序。因为 A 先安装,所以 A 的依赖 B v1.0 率先被安装在顶层 node_modules 中,接着 C 和 D 依次被安装,C 和 D 的依赖 B v2.0 就不得不安装在 C 和 D 的 node_modules 当中了。因此,模块的安装顺序可能影响node_modules 内的文件结构。
假设这时候项目又添加了一个依赖 E ,E 依赖了 B v1.0 ,安装 E 之后,我们会得到这样一个结构:
如果我们想更新模块 A 为 v2.0,而模块 A v2.0 依赖了 B v2.0,npm v3
会怎么处理呢?整个过程是这样的:
-
删除 A v1.0;
-
安装 A v2.0;
-
留下 B v1.0 ,因为 E v1.0 还在依赖;
-
把 B v2.0 安装在 A v2.0 下,因为顶层已经有了一个 B v1.0。
它的结构如下:
这时模块 B v2.0 分别出现在了 A、C、D 模块下,重复存在了。
通过这一系列操作我们可以看到:npm
包的安装顺序对于依赖树的影响很大。模块安装顺序可能影响 node_modules
内的文件数量。 这里一个更理想的依赖结构理应是:
过了一段时间,模块 E v2.0 发布了,并且 E v2.0 也依赖了模块 B v2.0 ,npm v3 更新 E 时会怎么做呢?
- 删除 E v1.0;
- 安装 E v2.0;
- 删除 B v1.0;
- 安装 B v2.0 在顶层 node_modules 中,因为现在顶层没有任何版本的 B 了。
此时得到图:
这时候,可以明显看到出现了较多重复的依赖模块 B v2.0。我们可以删除 node_modules
,重新安装,利用 npm 的依赖分析能力,得到一个更清爽的结构。 实际上,更优雅的方式是使用 npm dedupe
命令,得到:
实际上,Yarn
在安装依赖时会自动执行 dedupe
命令。
pnpm
pnpm 的全称为 "Performant npm",它旨在提供更快速、更高效的依赖管理体验。相较于传统的 npm 和 Yarn,pnpm 采用了一种全新的依赖管理方式,通过共享依赖来减少磁盘占用,同时提供了快速的安装和更新速度。
pnpm 主要有几个优势:
- 节省磁盘空间:pnpm 会在本地磁盘上维护一个共享的依赖包存储库,当你在不同项目中安装相同版本的依赖包时,pnpm 会将其存储在共享存储库中,避免了重复下载和存储依赖包。
- 提高安装速度:pnpm 使用符号链接技术和并行安装,因此能够显著提高依赖包的安装速度,尤其对于大型项目而言,这意味着能够大幅缩短依赖安装的时间成本。
-
高效更新:pnpm 的软链接方式可以让更新依赖包的操作更为高效,因为它能够复用已有的依赖,而无需重复下载和存储。
硬链接:我们的文件数据,都是存在磁盘上的,我们创建一个文件,就给它分配一段磁盘空间,文件是一个指针,指针指向这个磁盘空间,可以通过文件A创建一个硬链接文件B,如果是通过硬链接来创建的话,那么B的指针和A是一样的,他也是同样指向这个磁盘空间,这样就是两个文件,共用一块磁盘空间,这样在硬链接的前提下,把文件A干掉了,不会影响文件B。
软链接(符号链接):类似于快捷方式,它和硬链接的区别是,此处的文件A通过软链接创建文件B,文件B指向的是文件A,而不是磁盘空间,相当于B是A的一个快捷方式。
pnpm中软链接和硬链接都用到了,使用软链接来创建依赖项的嵌套结构,将项目的直接依赖软链接到
node_modules
的根目录,直接依赖的实际位置在.pnpm/<name>@<version>/node_modules/<name>
,依赖包中的每个文件再硬链接到.pnpm store
。pnpm这样的安装机制,很好的解决了幽灵依赖的问题。
-
pnpm 默认支持
monorepo
多项目管理的,在日渐复杂的前端多项目开发中尤其适用。 -
pnpm 还能管理 nodejs 版本,可以直接替代 nvm。
# 安装 LTS 版本
pnpm env use --global lts
# 安装指定版本
pnpm env use --global 20
总结
总的来说,pnpm 提供了一种令人激动的新方式来管理 JavaScript 包,它在磁盘空间占用和安装速度方面具有明显的优势。然而,在选择使用 pnpm 还是其他包管理工具时,我们应该基于项目的需求和团队的偏好做出明智的决策。