npm使用总结(1)探究依赖树

在项目开发中,通常会使用npm来管理项目中的依赖,下面我们就来看看它是如何帮助我们管理这些依赖的。

npm

从最初npm v1版本到npm v3,再到最新的 npm v5,npm 对于依赖的管理模式经历过三个主版本的重大变化。这次我们先讨论一下依赖树的变化,依赖树变化主要在于 v1(v2沿用v1,纯嵌套模式)和 v3(v4、v5沿用v3,扁平+嵌套模式)版本依赖方式的不同。

npm v1

最初的npm版本进行依赖管理时采用的是简单的嵌套模式。设想这里有三个模块:A,B,C。A 载入了 1.0 版本的 B 模块,但是 C 载入的是 2.0 版本的 B 模块:

1
2
3
4
"dependencies": {
A: "1.0.0",
C: "1.0.0"
}
1
2
A@1.0.0 -> B@1.0.0
C@1.0.1 -> B@2.0.0

通过执行 npm install 命令,生成的 node_modules目录如下:

1
2
3
4
5
6
7
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
└── C@1.0.0
└── node_modules
└── B@2.0.0

可以看到在每个依赖下都还有一个 node_modules 目录来存放依赖的依赖。这种依赖管理模式简单明了,但是会有很多问题,除了 node_modules 目录长度的嵌套过深之外,还会造成相同的依赖存储多份的问题,造成存储空间的浪费。

npm v3

npm2 和npm1 一样使用嵌套的方式来安装所有的依赖,npm3 则试图减少结构树的深度和这种嵌套方式带来的冗余。 npm3 的依赖管理做出了重大的改变,尝试将二级依赖抹平(同时也会对二级依赖的依赖尽可能的展开),就是在同样的目录中像一级依赖那样去载入它。

npm3 关键的不同之处在于:

  • 目录结构中的位置不再能够显示包之间的依赖关系(一级, 二级等)

  • 依赖的解决取决于安装的顺序,或者说安装的顺序会改变 node_modules 的目录结构

对于上述情况,使用 npm 3 执行 npm install 命令后生成的 node_modules 目录如下:

1
2
3
4
5
6
7
node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0

可以看到,在 npm 安装的过程中,npm3 会同时在 /node_modules 目录下扁平化地安装模块 A 和它的依赖,B 模块。而在 npm2 版本中则是以一种嵌套的方式完成安装的。

当我们需要再引入的另一个模块C,C 依赖模块 B, 但是与 A 依赖的不是同一个版本时。由于 v1.0 版本的 B 模块已经存在于目录的最顶层,我们不可能再将 v2.0 也安装到目录的顶级。npm3 此时的处理方式与 npm2 的行为一致,将新的,不同版本的模块 B 嵌套安装在依赖它的模块中 – 也就是此例中的 C 模块中。依赖树与npm2 对比如下图:

B5E7A9B3-48AB-4D02-95CB-422702FF90DE

更复杂的情况

这部分npm官方文档有讲过,可以参考,这里还有中文版demo。npm v3 及 v5 版本依赖树处理方式相同,我这里总结了几点经验(说的有点晕,不明白的可以找文档查看练习题,不过感觉文档说的也有些坑):

  • 先安装的模块会尽可能的展开并占据顶层位置,扁平化地安装模块,所以目录结构中的位置不再能够显示包之间的依赖关系
  • 如果 parent path 上有模块安装过了,那么 child 则不会继续安装,而如果发现顶级位置被同一个包的不同版本占用,并且占用无法删除(是一个顶级包,或者又被其他包依赖),则又回到了嵌套模式,将不同版本安装在自己目录下(即如果一个二级依赖被载入了两次,但是在目录层次中它并不处于第一层,那么它就会被复制并嵌套在主依赖下)
  • 如果使用交互界面的 npm install 命令安装就意味着该模块是最后一个安装的,如果顶级依赖已经存在则删除之,再进行安装顶级依赖,展开二级,查看位置是否被占用,占用并且无法删除则嵌套安装的逻辑(就在这时安装信息可能就跟全部安装时遵循字母顺序的情况有出入,产生不确定性)
  • 不确定性导致了两个人的 node_nodules 目录结构可能不同,但是不影响使用
    • 如果实在想使目录结构保持一致,可以删除 node_nodules 统一的不带参数的使用npm i,因为这时根据 package.json 安装,顺序始终遵循着字母表,相同的安装顺序便意味着会得到相同的结构树
    • 可以用 npm dedupe 重新计算依赖关系,和删了 node_nodules 重新安装的效果一样

A7540637-7C7C-43F2-9858-7E33E2E2BFED

​ (译:你的 node_modules 的目录结构和你的依赖树形式取决于安装顺序

关于循环依赖

循环依赖不会导致特别长的路径。因为从npm v1开始,npm在install的时候,同版本模块,parent path上有模块安装过了,child不会继续安装。可以npm install npm-cycle-a试下。

所以循环依赖在npm安装过程不是问题,真正的问题是执行时的依赖管理,尽管实际情况允许同一模块的两个不同版本可以在嵌套的位置共存,但是大多数的模块加载器并不能将它们同时载入到内存中。Node.js 的模块加载器在设计时已经考虑到了这种情况,它可以以一种很轻松地方式同时载入两个版本,而且不使它们相互冲突。

目前,通行的 JavaScript 模块化规范可以分为三种,CommonJSAMDES6。而关于运行时模块加载器如何处理循环依赖,实际上不同的规范在处理循环依赖时的做法也是不同的,Node.js一直采用的是CommonJS规范,至于各个模块规范具体如何处理,这里不展开讨论,有兴趣的可以看这篇文章),里面介绍的比较详细。

关于依赖地狱

关于Dependency Hell,官方解释:

A package manager would need to provide a version of module B. In all other runtimes prior to Node.js, this is what a package manager would try to do. This is dependency hell:

64B9E6C8-F837-4567-A46C-105A60086BE8

我的理解就是,B模块被A模块和C模块依赖并且依赖版本不同,包管理器需要提供某个版本的 B 模块,这在其他语言中是需要包管理器处理的。而npm选择了使用嵌套依赖的方式解决,这必然会带来一些路径过长、文件过多的问题,但是因为这是一个disk cheap的年代,为了快速开发的能力(最大程度的复用现有代码),这些缺点目前看来可以接受。关于依赖地狱更广义解释可以参考这篇wiki

对比yarn和cnpm

yarn

yarn 生成的 node_modules 目录结构和 npm v3、npm v5 是相同的。

cnpm

cnpm是淘宝的仓库,除了代表一个同步镜像,还代表了一个命令行工具,我们现在说的是这个命令。

cnpm在安装依赖时使用的是 npminstall,简单来说,cnpm 使用链接 link 的安装方式,最大限度地提高了安装速度,生成的 node_modules 目录采用的是和 npm 不一样的布局,直观的看是完全扁平的。用 cnpm 装的包都是在 node_modules 文件夹下以 版本号 @包名 命名,然后再做软链接到只以包名命名的文件夹上。如下图:

2123A94C-9A16-4D86-B013-ACAC1B35C3F4

cnpm 和 npm 以及 yarn 之间最大的区别就在于生成的 node_modules 目录结构不同,这在某些场景下可能会引发一些问题。此外也不会生成 lock 文件,这就导致在安装确定性方面会比 npm 和 yarn 稍逊一筹。但是 cnpm 使用的 link 安装方式还是很好的,既节省了磁盘空间,也保持了 node_modules 的目录结构清晰,可以说是在npm v1嵌套模式和npm v3扁平+嵌套模式之间找到了一个平衡。

参考链接

如何评价node_modules的设计?

[译]JavaScript 包管理器工作原理简介

探索 JavaScript 中的依赖管理及循环依赖问题