幽灵依赖
前言
现在pnpm发展的很快,大家都因为pnpm的出现,逐步替换掉项目中的npm,但大家知道pnpm解决了什么关键问题吗?其中之一就是幽灵依赖的问题,那么什么是幽灵依赖呢?我们接着往下看
正文
什么是幽灵依赖?
先来看一个问题
- 在
npm3的环境当中,假设我安装了一个包,@shein-components/Icon,此时我当然可以import Icon from "@shein-components/Icon"进行使用 - 但我是否还能通过
import classnames from "classnames"来使用classnames这个包呢? -
import Icon from "@shein-components/Icon" // ✅ import classnames from "classnames" // ??? - 答案是可以的,为什么呢?我们可以看下此时
node_modules的目录结构 -
node_modules (npm v3) ├─ @shein-components/Icon └─ classnames
看上面的目录结构,大家应该也能够猜到具体原因了
- 就是因为
Icon这个包里面又依赖使用了classnames,所以当我们运行项目的时候,NodeJS的require()函数能够在依赖目录找到classnames。这就导致了明明有一个库压根没被作为依赖定义在package.json文件中,但我们却引用了它。这也就是幽灵依赖的定义了
“幽灵依赖”指的是:
那些在项目中被使用,但却没有被定义在项目 package.json 文件中的包
npm2
在前面提到的场景中,为什么目录结构不是像下面这样嵌套下去呢?
-
node_modules (npm v2) └─ @shein-components/Icon └─ classnames - 其实我也标注了的,只有使用
npm1、npm2安装依赖包的时候,生成的node_modules才会是嵌套结构的,这种嵌套结构有什么问题呢?- 层级太深。试想套娃的情况下,不停地依赖,一层又一层,导致层级过深,文件路径过长
- 重复安装,占用内存,增加耗时。假设
A包依赖版本1的C包,然后B包也依赖版本1的C包,那么最终生成的node_modules目录结构将会是如下这样的。那C包岂不是多安装了一遍,浪费内存,并且增加安装耗时 -
node_modules (npm v2) ├─ A | └─ C_v1 └─ B └─ C_v1
- (注意一个问题,
幽灵依赖是因为npm3开始使用的扁平化依赖方案导致出现的问题,继续看后文)
npm3
于是从npm3,包括yarn都开始采用了扁平化依赖的方案,于是乎才有了文章开头最开始提到的node_modules目录结构如下
-
node_modules (npm v3) ├─ @shein-components/Icon └─ classnames
扁平化依赖的优点
扁平化依赖的方案解决了层级过深的问题,使得node_modules目录结构一目了然- 其次,当安装依赖的时候,不再会重复安装
相同版本的依赖(注意这里是指的相同版本的情况下,不会重复安装)。假设A包依赖版本1的C包,然后B包也依赖版本1的C包,那么最终生成的node_modules目录结构将会是如下这样的-
node_modules (npm v3) ├─ A └─ C_v1 └─ B - 执行顺序即:先安装
A包,然后发现A还依赖版本1的C包,就安装C包,又因为扁平化依赖提升到最上层,然后开始安装B包,B包依赖版本1的C包,但是已经存在了,所以就不需要安装了
-
- 这是理想的情况下,但实际上我们引用的
C包版本是有可能不一样的,那么就会造成新的问题如下
扁平化依赖引发新问题
1.幽灵依赖
扁平化依赖造成的其中一个问题就是文章最开始提到的幽灵依赖,会导致我们能在项目中使用到未定义在package.json文件中的包,这也就增加了项目的不确定性
2.依赖包目录结构的不确定
再看一个新的场景,如果一个项目里面,所需安装的包,包A依赖版本1的C包,包B依赖版本2的C包,最终生成的node_modules的目录结构是怎样的?
- 答案是不确定的,这取决于
A包跟B包在package.json中的顺序,如果A包在前,则为下面代码片段1。反过来则为下面代码片段2-
// 代码片段1 node_modules (npm v3) ├─ A ├─ C_v1 └─ B └─ C_v2 // 代码片段2 node_modules (npm v3) ├─ B ├─ C_v2 └─ A └─ C_v1
-
- 从上面这个简单的场景来看,在扁平化依赖的情况下,我们对于生成的
node_modules目录结构是不确定的。这三个字对于一个项目来说是很危险的,因为这一点不确定,可能会引发严重的Bug,之后我们在后文的场景题中再举具体的例子
幽灵依赖的危害
看到这里,可能有的小伙伴会说,即使我使用了幽灵依赖又怎么样呢?我的依赖包只要安装的时候,并把相应被使用的幽灵依赖带出来不就好了吗?这是不对的,我们看下面的场景
1.幽灵依赖丢失
我们在一个项目中使用了版本2的A包,又因为A包引用了B包,然后在npm3的环境下,文件目录结构如下
-
// package.json { "name": "my-project", "version": "1.0.0", "main": "lib/index.js", "dependencies": { "A": "^2.0.0" }, } A_v2 -> B_v1 node_modules (npm v3) ├─ A_v2 └─ B_v1 import B from 'B' // 正常使用 ✅ - 然后我们引用了
B包,但是如果这个时候我们需要对A包升级,使用全新版本3的A包,而版本3的A包,不再使用B包了,而改用了C包,那么全新的文件目录结构如下 -
// package.json { "name": "my-project", "version": "2.0.0", "main": "lib/index.js", "dependencies": { "A": "^3.0.0" // 升级为版本3 }, } A_v3 -> C_v1 node_modules (npm v3) ├─ A_v3 └─ C_v1 import B from 'B' // 引用错误 ❌ - 此时你项目中只要引用
B包使用的地方都会报错
2.不兼容的幽灵依赖API
再来看另一种情况,我们还是在一个项目中使用了版本2的A包,又因为A包引用了版本1的B包,然后在npm3的环境下,文件目录结构如下
-
// package.json { "name": "my-project", "version": "1.0.0", "main": "lib/index.js", "dependencies": { "A": "^2.0.0" }, } A_v2 -> B_v1 node_modules (npm v3) ├─ A_v2 └─ B_v1 import B from 'B' B.x() // 正常使用 ✅ B.y(1,2) // 正常使用 ✅ - 然后我们引用了
B包,并且使用了B包中的x方法还有y方法。还是一样如果这个时候我们需要对A包升级,使用全新版本3的A包,而版本3的A包,使用升级了版本2的B包了,那么全新的文件目录结构如下 -
// package.json { "name": "my-project", "version": "2.0.0", "main": "lib/index.js", "dependencies": { "A": "^3.0.0" }, } A_v3 -> B_v2 node_modules (npm v3) ├─ A_v3 └─ B_v2 // 版本升级,废弃了部分的Api import B from 'B' B.x() // 找不到该方法 ❌ B.y(1,2) // 入参错误 ❌ - 而这个版本的
B包的x方法已经被去除,并且y方法的入参变成了数组,那么原项目中的使用都会直接报错,这是不在预期之内的代码变化,将会带来对包A版本的升级负担
3.重复安装的相同依赖包
又一种情况,假设现在包A依赖版本1的包B,包C依赖版本2的包B,而包D也依赖版本2的包B,则整个的依赖关系如下
-
// package.json { "name": "my-project", "version": "1.0.0", "main": "lib/index.js", "dependencies": { "A": "1.0.0", "C": "1.0.0", "D": "1.0.0", }, } A_v1 -> B_v1 C_v1 -> B_v2 D_v1 -> B_v2 - 那么最终生成的目录结构将会如下
-
node_modules (npm v3) ├─ A ├─ B_v1 ├─ C | └─ B_v2 └─ D └─ B_v2 - 大家发现了什么问题吗?
版本2的包B,被安装了两次,浪费了内存。这个有个比较好的词语概括,叫做双胞胎陌生人
4.本地与服务端不一致
4.1 包A 升级前
接着上一种情况,假设我们情况再复杂一点,加多一个E包依赖版本1的包B,则整个的依赖关系如下
-
// package.json { "name": "my-project", "version": "1.0.0", "main": "lib/index.js", "dependencies": { "A": "1.0.0", "C": "1.0.0", "D": "1.0.0", "E": "1.0.0", }, } A_v1 -> B_v1 C_v1 -> B_v2 D_v1 -> B_v2 E_v1 -> B_v1 - 那么最终生成的目录结构将会如下
-
node_modules (npm v3) ├─ A_v1 ├─ B_v1 ├─ C_v1 | └─ E_v2 ├─ D_v1 | └─ B_v2 └─ E_v1
4.2 包A 升级后
- 整个安装顺序就不再多说了,我们主要看在这个情况下进行升级,那么当此时
版本1的包A手动升级成了版本2的包A,并且其依赖项变成了版本2的包,即整个依赖关系如下 -
// package.json { "name": "my-project", "version": "1.0.0", "main": "lib/index.js", "dependencies": { "A": "2.0.0", // 升级了 "C": "1.0.0", "D": "1.0.0", "E": "1.0.0", }, } A_v2 -> B_v2 // 升级后依赖变更 C_v1 -> B_v2 D_v1 -> B_v2 E_v1 -> B_v1 - 那么此时本地的依赖树是怎样的呢?服务端生成的目录结构又是怎样的呢?如下
-
// 本地的 node_modules (npm v3) ├─ A_v1 | └─ B_v2 ├─ B_v1 ├─ C_v1 | └─ E_v2 ├─ D_v1 | └─ B_v2 └─ E_v1 // 服务端的 node_modules (npm v3) ├─ A_v2 ├─ B_v2 ├─ C_v1 ├─ D_v1 └─ E_v1 └─ B_v1 - 可以看到本地安装的依赖目录,与服务器端的依赖目录并不一致,这是为什么呢?可以分析下过程
- 本地升级,并没有删掉
node_modules,是在包A升级前的基础上进行升级安装的,由于顶层本身就有E依赖的版本1的包B,所以版本2的包B得不到提升,就会放在包A之下 - 而服务端安装,是重新安装依赖的,那么安装包
A先安装,版本2的包B直接就安装在了最顶层,而包E由于外层已经有版本2的包B,所以就将版本1的包B安装在其下
- 本地升级,并没有删掉
- 那么,如果项目中引用了包
B,有可能会有本地能跑线上报错的问题,这更加说明幽灵依赖带来的更多不确定性
后语
- 全文看完之后,你会发现幽灵依赖的各种问题,以及各种层出不同的场景问题。这些问题都有可能给我们开发甚至线上造成问题,而为了解决幽灵依赖和重复安装、安装速度慢等包管理问题,
npm和yarn都曾出过一些方案来解决,例如lock文件、PnP静态映射表等等,但还是没有彻底解决,直到pnpm的出现,pnpm用了内容寻址存储的方式去解决了以上的问题,那么下一期会再讲pnpm
转载自:https://juejin.cn/post/7237352232014266429
