依赖管理结构

依赖管理结构

npm/yarn的依赖管理结构

主要分为两个部分,首先,执行npm/yarn install之后,包如何到达项目node_modules当中。其次,node_modules内部如何管理依赖。执行命令后,首先会构建依赖树,然后针对每个节点下的包,会经历下面四个步骤:

    1. 将依赖包的版本区间解析为某个具体的版本号
    1. 下载对应版本依赖的tar包到本地离线镜像
    1. 将依赖从离线镜像解压到本地缓存
    1. 将依赖从缓存拷贝到当前目录的node_modules目录

然后,对应的包就会到达项目的node_modules当中。那么,这些依赖在node_modules内部是什么样的目录结构呢,换句话说,项目的依赖树是什么样的呢?在npm1npm2中呈现出的是嵌套结构,比如下面这样:

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json

如果bar当中又有依赖,那么又会继续嵌套下去。试想一下这样的设计存在什么问题:

  • 依赖层级太深,会导致文件路径过长的问题,尤其在window系统下。
  • 大量重复的包被安装,文件体积超级大。比如跟foo同级目录下有一个baz,两者都依赖于同一个版本的lodash,那么lodash分别在两者的node_modules中被安装,也就是重复安装。
  • 模块实例不能共享。比如React有一些内部变量,在两个不同包引入的React不是同一个模块实例,因此无法共享内部变量,导致一些不可预知的bug

接着,从npm3开始,包括yarn,都着手来通过扁平化依赖的方式来解决这个问题。相比之前的嵌套结构,现在的目录结构类似下面这样:

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json

所有的依赖都被拍平到node_modules目录下,不再有很深层次的嵌套关系。这样在安装新的包时,根据node require机制,会不停往上级的node_modules当中去找,如果找到相同版本的包就不会重新安装,解决了大量包重复安装的问题,而且依赖层级也不会太深。之前的问题是解决了,但仔细想想这种扁平化的处理方式,它真的就是无懈可击吗?并不是。它照样存在诸多问题,梳理一下:

  • 依赖结构的不确定性。
  • 扁平化算法本身的复杂性很高,耗时较长。
  • 项目中仍然可以非法访问没有声明过依赖的包

后面两个都好理解,那第一点中的不确定性是什么意思?这里来详细解释一下。假如现在项目依赖两个包foobar,这两个包的依赖又是这样的:

混合依赖

那么npm/yarn install的时候,通过扁平化处理之后,可能是以下任一方式:

扁平化方式

取决于foobarpackage.json中的位置,如果foo声明在前面,那么就是前面的结构,否则是后面的结构。这就是为什么会产生依赖结构的不确定问题,也是lock文件诞生的原因,无论是package-lock.json(npm 5.x才出现)还是yarn.lock,都是为了保证install之后都产生确定的node_modules结构。

尽管如此,npm/yarn本身还是存在扁平化算法复杂和package非法访问的问题,影响性能和安全。

pnpm依赖管理

pnpm的作者Zoltan Kochan发现yarn并没有打算去解决上述的这些问题,于是另起炉灶,写了全新的包管理器,开创了一套新的依赖管理机制,现在就让我们去一探究竟。

node_modules
.pnpm
accepts@1.3.7
array-flatten@1.1.1
    ...
express@4.17.1
node_modules
accepts  -> ../accepts@1.3.7/node_modules/accepts
array-flatten -> ../array-flatten@1.1.1/node_modules/array-flatten
        ...
express
lib
            History.md
            index.js
            LICENSE
            package.json
            Readme.md

将包本身和依赖放在同一个node_module下面,与原生Node完全兼容,又能将package与相关的依赖很好地组织到一起,设计十分精妙。现在我们回过头来看,根目录下的node_modules下面不再是眼花缭乱的依赖,而是跟package.json声明的依赖基本保持一致。即使pnpm内部会有一些包会设置依赖提升,会被提升到根目录node_modules当中,但整体上,根目录的node_modules比以前还是清晰和规范了许多。

pnpm这种依赖管理的方式也很巧妙地规避了非法访问依赖的问题,也就是只要一个包未在package.json中声明依赖,那么在项目中是无法访问的。但在npm/yarn当中是做不到的,那你可能会问了,如果A依赖BB依赖C,那么A就算没有声明C的依赖,由于有依赖提升的存在,C被装到了Anode_modules里面,那我在A里面用C,跑起来没有问题呀,我上线了之后,也能正常运行啊。不是挺安全的吗?

  • 第一,你要知道B的版本是可能随时变化的,假如之前依赖的是C@1.0.1,现在发了新版,新版本的B依赖 C@2.0.1,那么在项目A当中npm/yarn install之后,装上的是2.0.1版本的C,而A当中用的还是C当中旧版的API,可能就直接报错了。
  • 第二,如果B更新之后,可能不需要C了,那么安装依赖的时候,C都不会装到node_modules里面,A当中引用C的代码直接报错。

还有一种情况,在monorepo项目中,如果A依赖XB依赖X,还有一个C,它不依赖X,但它代码里面用到了X。由于依赖提升的存在,npm/yarn会把X放到根目录的node_modules中,这样C在本地是能够跑起来的,因为根据node的包加载机制,它能够加载到monorepo项目根目录下的node_modules中的X。但试想一下,一旦C单独发包出去,用户单独安装C,那么就找不到X了,执行到引用X的代码时就直接报错了。

上一页