likes
comments
collection
share

幽灵依赖

作者站长头像
站长
· 阅读数 20

前言

现在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,所以当我们运行项目的时候,NodeJSrequire()函数能够在依赖目录找到classnames。这就导致了明明有一个库压根没被作为依赖定义在package.json文件中,但我们却引用了它。这也就是幽灵依赖的定义了
“幽灵依赖”指的是:
   那些在项目中被使用,但却没有被定义在项目 package.json 文件中的包
  • 幽灵依赖

npm2

在前面提到的场景中,为什么目录结构不是像下面这样嵌套下去呢?

  • node_modules (npm v2)
    └─ @shein-components/Icon
       └─ classnames
    
  • 其实我也标注了的,只有使用npm1、npm2安装依赖包的时候,生成的node_modules才会是嵌套结构的,这种嵌套结构有什么问题呢?
    • 层级太深。试想套娃的情况下,不停地依赖,一层又一层,导致层级过深,文件路径过长
    • 重复安装,占用内存,增加耗时。假设A包依赖版本1C包,然后B包也依赖版本1C包,那么最终生成的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包依赖版本1C包,然后B包也依赖版本1C包,那么最终生成的node_modules目录结构将会是如下这样的
    • node_modules (npm v3)
      ├─ A
      └─ C_v1
      └─ B
      
    • 执行顺序即:先安装A包,然后发现A还依赖版本1C包,就安装C包,又因为扁平化依赖提升到最上层,然后开始安装B包,B包依赖版本1C包,但是已经存在了,所以就不需要安装了
  • 这是理想的情况下,但实际上我们引用的C包版本是有可能不一样的,那么就会造成新的问题如下

扁平化依赖引发新问题

1.幽灵依赖

扁平化依赖造成的其中一个问题就是文章最开始提到的幽灵依赖,会导致我们能在项目中使用到未定义在package.json文件中的包,这也就增加了项目的不确定

2.依赖包目录结构的不确定

再看一个新的场景,如果一个项目里面,所需安装的包,包A依赖版本1C包,包B依赖版本2C包,最终生成的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.幽灵依赖丢失

我们在一个项目中使用了版本2A包,又因为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包升级,使用全新版本3A包,而版本3A包,不再使用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

再来看另一种情况,我们还是在一个项目中使用了版本2A包,又因为A包引用了版本1B包,然后在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包升级,使用全新版本3A包,而版本3A包,使用升级了版本2B包了,那么全新的文件目录结构如下
  • // 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,有可能会有本地能跑线上报错的问题,这更加说明幽灵依赖带来的更多不确定性

后语

  • 全文看完之后,你会发现幽灵依赖的各种问题,以及各种层出不同的场景问题。这些问题都有可能给我们开发甚至线上造成问题,而为了解决幽灵依赖和重复安装、安装速度慢等包管理问题,npmyarn都曾出过一些方案来解决,例如lock文件、PnP静态映射表等等,但还是没有彻底解决,直到pnpm的出现,pnpm用了内容寻址存储的方式去解决了以上的问题,那么下一期会再讲pnpm
转载自:https://juejin.cn/post/7237352232014266429
评论
请登录