前言
现在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