npm 依赖管理中一些重要的细节

前言

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

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

npm2和npm3+版本对依赖的处理有所不一样,但如今不多有使用npm3如下版本的项目了,本文中全部的介绍都是基于npm3+以上版本java

npm 依赖管理机制

npm 大致上来看,和其余的包管理器差很少,都是包依赖包,而且用版本号来声明这些依赖的包。node

语义化版本号

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

If app’s package.json contains:

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

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

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

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

依赖树和传递依赖

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

版本冲突

如今增长一个模块C,C也依赖B,可是C依赖了B的高版本V2.0,此时npm的处理就有点不同了;因为C依赖的B模块版本和A依赖的B版本不兼容,npm 会先将A模块依赖的B1.0安装至根目录,而后将C依赖的B2.0安装至C本身的node_modules中,以下图所示
npm_dependency_managenent_2.svg
目录结构

|————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都会加载,可能会出现一些意外的错误。对于使用者来讲是不能接受的
npm_dependency_conflict.svg

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

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

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

Maven Build对包(传递)依赖多版本的处理,以下图所示:
npm&maven_dependency_management.svg
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的模式仍是有必定的风险,只是风险下降了。一旦有多个依赖的代码在一个页面同时工做或交互,就很容易出问题。

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

参考