npm/yarn/pnpm 如何进行依赖关系管理
本文介绍了业界最主流的三个包管理器(npm/yarn/pnpm)的依赖管理策略,并对各种策略的优缺点进行了对比分析
1. npm(v1和v2)
npm(v1和v2)采用嵌套结构的node_modules,间接依赖直接嵌套在直接依赖的node_modules中
若项目包含A和C两个依赖,A依赖B、C也依赖B,那么node_modules的结构如下:
这种嵌套结构会导致node_modules文件极大地占用了磁盘空间,降低依赖安装的速度,原因如下:
1.1 嵌套层次深
在直接依赖越来越多的情况下,间接依赖也会越来越多,node_modules目录的嵌套层次就会越来越深
1.2 依赖冗余
如上图所示,如果A和C都依赖了B,那么B就会被重复安装2次,即使B有可能是同一版本的
2. npm(v3+)
从v3开始,npm采用了扁平化结构的node_modules,间接依赖会尽量平铺在node_modules的一级目录中
若项目包含A和C两个依赖,A依赖B、C也依赖B
安装依赖时就会对B进行提升,node_modules的结构如下:
之所以能够进行「依赖提升」,是因为:当我们使用require
引入模块时,NodeJs会按照如下优先级去寻找对应的模块:
- 核心模块:NodeJs提供的系统核心模块,例如fs、querystring等
- 文件模块:以./或../开头的参数,会被当做文件模块进行处理
- 第三方模块:在当前目录下的node_modules目录下查找,如果找不到就一直向上(父目录)递归
详见面试官:你真的了解CommonJs和EsModule吗?- 掘金
所以当依赖A中的代码使用require('B')时,会往上层父目录的node_modules中继续查找
「依赖提升」虽然在一定程度上减少了node_modules文件对磁盘空间的占用,但是也带了新的问题
2.1 幽灵依赖
幽灵依赖指的是在package.json中未声明的依赖,但项目中依然可以正确地引用
如上图所示,项目的直接依赖只包含A和C两个,但由于依赖提升,在项目的代码中引入B还是能够正常编译构建的
如果在项目后续的迭代中,B不再是间接依赖,那么项目中引入B的代码就会报错
2.2 不确定性
不确定性指的是:同样的项目,在不同开发者的本地安装依赖后可能会得到不同的node_modules目录
若A依赖B@1.0.0,C依赖B@2.0.0,那么提升哪个版本的B,可能取决于依赖的安装顺序
-
若执行
npm install
,则基于package.json中、依赖名称的字母顺序进行安装,过程如下:- 安装A
- A依赖了B,因此需要提升B
- 安装C
- C依赖了B,但B已经被提升
- 最终被提升的就是1.0.0版本的B
-
若先后执行
npm install C
和npm install A
,则安装过程如下:- 安装C
- C依赖了B,因此需要提升B
- 安装A
- A依赖了B,但B已经被提升
- 最终被提升的就是2.0.0版本的B
2.3 依赖冗余
若在后续的迭代中,项目又安装了依赖D和E
- D依赖B@1.0.0
- E依赖B@2.0.0
那么无论提升哪个版本的B,都会存在重复版本的B被安装
3. yarn
yarn同样采用「依赖提升」的方式形成扁平化结构的node_modules,同时也带来了一些新的变化
3.1 提升安装速度
- 并行安装
使用npm安装依赖时,安装任务是串行执行的,必须等到一个包安装完成、再安装下一个
而yarn采用了并行的方式来安装依赖,提升了安装速度
- 本地缓存
最早被yarn提出,npm也在后续的版本中支持了这个特性
在安装依赖时,yarn/npm会在本地磁盘中进行缓存
npm还提供了几个命令行参数来控制依赖的安装策略
-
--prefer-offline
:本地找不到缓存才会进行网络请求 -
--prefer-online
:网络请求失败才会去本地缓存取 -
--offline
:强制使用本地缓存
3.2 解决不确定性
yarn会在项目第一次安装依赖时生成yarn.lock文件,用于确定依赖结构
它记录了所有依赖的版本(包括直接依赖和间接依赖),以及每个依赖的下载源地址
npm从v5版本开始,也会在第一次安装时生成package-lock.json文件
// yarn.lock
"lodash@^4.17.0", "lodash@^4.17.15", "lodash@^4.17.21":
version: "4.17.21"
resolved: "xxx"
integrity: "xxx"
- version:实际安装的版本,通常是满足版本区间里的一个版本
- resolved:该依赖的下载源地址
- integrity:hash值,用于对下载的文件进行完整性校验
基于lockfile,yarn会遍历所有直接依赖、并递归遍历它们的依赖,计算出各个依赖的各个版本被引用的次数。一般来说,会对 被引用次数最多的版本 进行提升,从而生成一份确定的node_modules结构
详见yarn/src/package-hoister.js at master · yarnpkg/yarn
但依赖提升也受其他因素的影响,例如:
- 不同版本间是否兼容
- 是否声明了resolution
- 是否声明了peerDependency
4. yarn - PnP
PnP(Plug an Play)是通过重写依赖解析机制来实现的
- 它在项目中维护了一份静态映射(.pnp.cjs),记录依赖在缓存中的具体位置
- 不再生成node_modules目录,而是自建解析器、通过上述的静态映射去找到项目的依赖文件
PnP解决了依赖冗余的问题,也提升了依赖安装的速度,但同时它也脱离了NodeJs的生态、兼容性不够好
详见Plug'n'Play | Yarn
5. pnpm
先介绍几个相关的符号/术语:
- store:表示全局store,pnpm会将依赖下载到系统的全局store中
- Symbolic link:表示软链接,它指明了另一个文件的路径名,pnpm通过它找到另一个文件
- Hard link:表示硬链接,它指向文件实际存储的磁盘地址,但它本身并不占用实际的存储空间
详见Linux 硬链接与软链接
接下来用同样的例子进行说明:
若项目包含A@1.0.0和C@1.0.0两个依赖,A依赖B@1.0.0、C依赖B@2.0.0,那么pnpm生成的node_modules结构如下:
首先,node_modules下的一级目录非常简洁,仅包含项目的直接依赖和一个.pnpm目录
其次,一级目录下的直接依赖目录只是一个Symbolic link(软链接),它指向.pnpm目录中对应依赖的目录
最后,我们再来看.pnpm目录:
-
它平铺了项目的所有直接依赖和间接依赖(A、B、C)
-
在每个依赖的node_modules目录中
-
自身:使用Hard link指向全局store中对应的地址
-
其他依赖:使用Symbolic link指向.pnpm目录中对应的目录
-
5.1 解决幽灵依赖
在pnpm生成的node_modules目录中,仅包含项目的直接依赖和一个.pnpm目录
因此,在项目的代码中引入间接依赖是不可行的,会导致编译报错,幽灵依赖的问题得以解决
5.2 解决依赖冗余&提升安装速度
pnpm会将依赖下载到全局的store中,确保每个版本的依赖只会被下载一次
这个特性使得不同的项目可以从全局store中寻找到同一个依赖,极大程度节省了磁盘空间,同时也提升了依赖安装的速度
6. 总结
- npm(v1和v2)
采用嵌套结构的node_modules,占用较大磁盘空间,依赖安装速度慢,原因在于:
- 嵌套层次深
- 依赖冗余
- npm(v3+)
通过「依赖提升」形成扁平化结构的node_modules
- 一定程度上减少了node_modules文件对磁盘空间的占用
- 依然没有解决依赖冗余的问题
- 带来了新的问题(幽灵依赖 & 不确定性)
- yarn
通过「依赖提升」形成扁平化结构的node_modules
- 提出并行安装和本地缓存的方案以提升依赖安装速度
- 提出lockfile以解决node_modules结构的不确定性
- 依然没有解决幽灵依赖和依赖冗余的问题
- yarn-PnP
废弃node_modules,通过自建依赖解析器、根据静态映射文件在全局缓存中找到依赖
- 解决了依赖冗余的问题
- 提升了依赖安装的速度
- 但脱离了NodeJs的生态,导致兼容性不够好
- pnpm 采用了一套全新的依赖管理策略:内容寻址存储
- 通过非扁平的node_modules目录结构解决了幽灵依赖的问题
- 通过全局store和硬链接解决了依赖冗余的问题,同时也提升了依赖安装的速度
参考资料
- 深入浅出 npm & yarn & pnpm 包管理机制 - 掘金
- 关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn? - 掘金
- 从npm 到 yarn 再到 pnpm —— 为什么要使用pnpm? - 掘金
- 面试官:你真的了解CommonJs和EsModule吗?- 掘金
- Flat node_modules is not the only way | pnpm中文文档 | pnpm中文网
- Yarn Plug'n'Play可否助你脱离node_modules苦海?