npm 依赖版本冲突处理机制

前言

npm(全称 Node Package Manager,即“node包管理器”)是Node.js预设的、用JavaScript编写的包管理工具。虽然是Node.js中的工具,但现在更多的被用来配合前端构建工具给前端进行包管理。

作为一个包管理器,最重要的就是管理依赖了。对于复杂的依赖树,npm 的处理机制和其他的包管理器会有所不同,本文将会详细介绍这些细节

npm2和npm3+版本对依赖的处理有所不同,但现在很少有使用npm3以下版本的项目了,本文中所有的介绍都是基于npm3+以上版本

npm 依赖管理机制

npm 大体上来看,和其他的包管理器差不多,都是包依赖包,并且用版本号来声明这些依赖的包。

语义化版本号

npm 中使用语义化版本来控制版本依赖包的版本,比如^~>=<之类的范围符号,不过本文中版本号的解析方式不是重点,只需要知道如果使用范围版本号,npm会安装范围内可用的最新版本
**
这里要吐槽一下npm的文档,光是找这个范围版本号具体使用的版本策略,就找了很久,文档中并没有清晰的说明……最后在npm update页面中找到了一丝介绍

If app’s package.json contains:

"dependencies": {"dep1": "^1.1.1"}

Then npm update will install dep1@1.2.2, because 1.2.2 is latest and 1.2.2 satisfies ^1.1.1.

npm的这个范围版本设计的理念还是挺先进的,通过范围版本号让使用方可以及时的自动更新小版本,升级后可能修复一些bug,但是随之而来的也会有很多因更新导致的风险。毕竟版本号是人类控制的,人类控制就有可能出现失误,比如一个修订版本号的更新中删除了某些api,导致无法兼容

个人看来,这种范围版本号的包管理机制,是弊大于利的,风险过高。如果在服务端场景下,什么都没改的情况下就偷摸换了个(小)版本,很可能会出现一些严重的事故。一般来说,任何改动都需要经过测试,尤其是这种依赖包升级,是个挺有风险的事情。如果是那种通用基础包的风险就更大了,引用的地方过多,很可能出现一些不兼容的情况。

依赖树和传递依赖

npm 会默认会将传递依赖的包用flat的形式,也安装至node_modules的根目录,比如有一个模块A,他依赖了模块B:
**

版本冲突

现在增加一个模块C,C也依赖B,但是C依赖了B的高版本V2.0,此时npm的处理就有点不一样了;由于C依赖的B模块版本和A依赖的B版本不兼容,npm 会先将A模块依赖的B1.0安装至根目录,然后将C依赖的B2.0安装至C自己的node_modules中,如下图所示

目录结构

|————mod-A@1.0
|————mod-B@1.0
|————mod-C@1.0
    |————mod-B@2.0

对于版本不兼容的依赖树,npm的处理是先检查是否版本兼容,如果版本兼容就不重复安装,如果和之前的的传递依赖包版本不兼容,那么就将该依赖包安装至当前引用的包的node_modules下
**
npm 的包版本冲突解决方案虽然带来了包文件的 冗余,但可以很好的解决冲突问题

这种版本冲突解决机制真的很完美吗?

从来面的介绍可以看出,当出现版本不兼容时,npm会将依赖的包安装至当前包的node_modules下,有点submodule的意思,但也不是真的万无一失,还是有可能出现由于多版本共存导致的冲突。

还是拿上面的A/B/C三个依赖模块来举例,比如B v1.0中向window对象注册了一个属性,B v2.0也向window中注册了一个属性,由于B v1.0和v2.0差距很大,虽然注册的是同一个对象,但属性和其函数差距很大,当一个页面同时引入A和C模块时,B v1.0和B v2.0都会加载,可能会出现一些意外的错误。对于使用者来说是不能接受的

上面这个例子可能还不是很恰当,因为注册window这件事本来就有一定风险。现在设想另一种常见的场景,比如有在Angular(2)中,两个基于Angular的组件依赖了不同的Angular(Core)大版本,那么当一个页面同时使用两个组件,并且两个组件需要在当前页面进行交互时,比如赋值或者函数调用之类,就很容易出现上图中的问题。

这种问题在Java生态中的包管理虽然也有,但形式会有所不同:

在Maven中(Java生态的包管理工具),虽然依赖是树状结构的,但构建后的结果其实是平面(flat)的的。如果出现多个版本的jar包,运行时一般会将所有jar包都加载;不过由于JAVA中ClassLoader的parent delegate机制,同样的Class只会被加载一次,下N个Jar包内的的同名类(包名+类名)会被忽略,这样的好处是简单,如果出现版本冲突也清晰可见,冲突问题需要使用者自行处理。

Maven Build对包(传递)依赖多版本的处理,如下图所示:

npm 对于这种可能出现的版本冲突问题,也提供了一个解决办法:peerDependencies

peerDependencies

peerDependencies和maven中的provide scope很像,当一个依赖模块X定义在peerDependencies中而不是devDependencies或dependencies中时,依赖该模块的项目就不会自动下载该依赖。

项目中需要直接或间接的声明符合该版本的依赖,直接依赖是指直接在devDependencies或dependencies中声明,间接依赖是指当前项目依赖的其他模块依赖了X符合版本范围的模块,如果二者都不满足,在npm install时会出现一个告警,比如:

npm WARN hidash@0.2.0 requires a peer of lodash@~1.3.1 but none is installed. You must install peer dependencies yourself.

npm & webpack

现在很多项目都会使用webpack来作为项目的构建工具,但是和java中的maven 不同,webpack和npm是两套独立的工具,构建和包管理是分开的

也就是说,哪怕npm将冲突包作为“submodule”的形式安装在当前包内,但是webpack可不一定认

比如上面ABC三个模块的例子,如果A模块的代码中import BObj from B mod,那么webpack构建之后,会让A引用哪一个B版本呢?v1.0 还是 v2.0?

这个场景相当复杂,本文就不介绍了,有一篇文章详细介绍了webpack下的处理方式和测试场景:《Finding and fixing duplicates in webpack with Inspectpack》

总结

npm 包管理的设计理念虽然很好,但不适合所有的场景,比如这种submodule的模式拿到java里就不可行,而且submodule的模式还是有一定的风险,只是风险降低了。一旦有多个依赖的代码在一个页面同时工作或交互,就很容易出问题。

无论是什么包管理工具,最安全的做法还是避免重复。在增加新依赖或是新建项目后,使用一些依赖分析检查工具检测一遍,修复重复/冲突的依赖。

参考

你可能感兴趣的