SamHwang1990/blog

浅谈npm 的依赖与版本

SamHwang1990 opened this issue · 13 comments

在日常依赖npmyarn 的开发中,我们需要使用package.json文件来描述程序所依赖的库及其版本。

平时在使用或看一些package.json时,总会对里面出现的依赖描述部分有些不理解的地方,包括但不限于:

  • peerDependencies对应是什么场景下的依赖;

  • 然后peerDependencies为什么在npm@v3 被取消了呢?而最近比较热的yarn又继续使用它呢?

  • 为什么依赖库的版本号有那么多写法呢?0.10.1~1.1.1^1.14.1

带着种种小疑问去搜索了相关文档,然后把笔记摘抄如下。

依赖类型

package.json中可能会用到以下5 种类型的依赖声明。

dependencies

该类型依赖一般属于运行项目业务逻辑需要依赖的第三方库。

当运行npm install 命令时,默认package.json中该节点下声明的依赖库都会被解析、下载到node_modules 中。

当我们运行npm install $packagenpm install $package --save命令时声明的$package都会被当做该类型依赖处理,这两条命令的差别在于选项--save,该选项能将$package注册到package.json中的dependencies节点中。

devDependencies

开发模式工作流下需要依赖的第三方库都可以声明到该类型下。

开发模式工作流,我大致的理解是,与核心业务逻辑开发无关的任务,而这些任务又支撑着核心业务的开发过程以及程序从开发环境向生产环境的支撑,举些例子:

  • 单元测试支撑(mocha、chai);
  • 语法兼容(babel);
  • 语法转换(jsx to js、coffeescript to js、typescript to js)
  • 程序构建与优化(webpack、gulp、grunt、uglifyJS);
  • css 处理器(postCSS、SCSS、Stylus);
  • 代码规范(eslint);

当运行npm install 命令时,默认package.json中该节点下声明的依赖库都会被解析、下载到node_modules 中,除非你显式使用--production选项来声明处于生产环境。即,当运行命令npm install --production时,package.json节点devDependencies下声明的依赖库都不会被安装。

当我们运行npm install $package --devnpm install $package --save-dev命令时声明的$package都会被当做该类型依赖处理,这两条命令的差别在于选项--save,该选项能将$package注册到package.json中的devDependencies节点中。

peerDependencies

(看到这个名字,我真的根本不知道用来干什么的~~~)

设计peerDependencies类型到底是为了解决什么情境下的问题呢?

最常见的情境是插件(Plugins),比如以jQueryWebpackGruntGulp为核心开发的插件体系,各举一个插件例子:jquery-uihtml-loadergrunt-contrib-uglifygulp-uglify

上面的插件有一些相同的特点:

  • 插件正确运行的前提是,核心依赖库必须先下载安装,不能脱离核心依赖库而被单独依赖并引用;
  • 插件入口api 的设计必须要符合核心依赖库的规范;
  • 插件的核心逻辑运行在依赖库的调用中;
  • 在项目实践中,同一插件体系下,核心依赖库版本最好是相同的;

好了,继续探讨peerDependencies是如何改变插件体系下的依赖树生成。以webpack体系为例。假设我们的项目构建中使用到了webpack@1.14.1html-loader@0.4.3html-webpack-plugin@2.24.0

如果插件都没有使用peerDependencies的情况下,依赖树可能会呈现为以下结构:

helloworld/node_modules/
                       |
                       +- webpack@1.14.1/
                       |
                       +- html-loader@0.4.3/node_modules/
                                          |
                                          +- webpack@1.14.1/
                       |
                       +- html-webpack-plugin@2.24.0/node_modules/
                                          |
                                          +- webpack@1.14.1/

合并下package.json,大概长这样:

// helloworld/package.json
{
  "devDependencies": {
    "webpack": "1.14.1",
    "html-loader": "0.4.3",
    "html-webpack-plugin": "2.24.0"
  }
}

// html-loader/package.json
// html-webpack-plugin/package.json
{
  "dependencies": {
    "webpack": "1.14.1"
  }
}

从上面的依赖树看到,项目helloworld本身因为使用webpack做构建,所以一定会显式声明webpack为依赖的,然后,各插件也显式声明依赖webpack。于是,如果在helloworld下运行npm install,则会生成类似上面的依赖树,其中,webpack@1.14.1有两次多余的下载和安装。

而且,站在插件自身来说,它的逻辑也应该是不需要引用核心依赖的,因为调用链从来只能是核心依赖=>插件api。所以,如果出现上面的依赖树,在发布的插件下居然还要安装核心依赖库,基本上就是有问题的。

(在npm v3 中移除了peerDependency的支持,貌似是内部做了优化,生成的依赖树已经不是这样了)

peerDependency就是来避免类似的核心依赖库被重复下载的问题。npm 或yarn 在处理该类型的依赖时逻辑大致如下:

  • 如果用户显式依赖了核心库,则可以忽略各插件的peerDependency声明;
  • 如果用户没有显式依赖核心库,则按照插件peerDependencies中声明的版本将库安装到项目根目录中;
  • 当用户依赖的版本、各插件依赖的版本之间不相互兼容,貌似会报错让用户自行修复;(这里待确认)

而当各插件使用peerDependencies声明核心库的依赖时,package.json结构大致如下:

// helloworld/package.json
{
  "devDependencies": {
    "webpack": "1.14.1",
    "html-loader": "0.4.3",
    "html-webpack-plugin": "2.24.0"
  }
}

// html-loader/package.json
// html-webpack-plugin/package.json
{
  "peerDependencies": {
    "webpack": "1.14.1"
  }
}

上面依赖声明生成的依赖树如下:

helloworld/node_modules/
                       |
                       +- webpack@1.14.1/
                       +- html-loader@0.4.3/
                       +- html-webpack-plugin@2.24.0/

此时,依赖树的结构就很扁平了,不需要安装多余的核心依赖。

总结一下,peerDependencies比较适合插件库来声明所依赖的核心库。好处时,避免同一插件体系下重复下载核心库。

peerDependencies in npm3

参考:https://docs.npmjs.com/files/package.json#peerdependencies

在npm3 中,依赖树的生成会尽量的扁平,相应peerDependency的行为有所变化。peerDependencies中声明的依赖,如果项目没有显式依赖并安装,则不会被npm 自动安装,转而输出warning日志,告诉项目开发者,你需要显式依赖了,不要再依靠我了。

optionalDependencies

顾名思义,可选的依赖,指的是,即使在npm install时,该依赖安装失败,install命令依然可以继续,不需要抛错误终端。

相关文档中,该类型针对的场景是,对于针对特定平台才能安装成功的库,或者即使这些库安装失败,你也已经有备用的库来替代(这里考虑的可以是不同库的兼容性程度或性能优劣),可以声明依赖到optionalDependencies中。

bundleDependencies

当处于开发模式时,bundleDependencies节点的功能跟dependencies节点是一样的,区别在于,当需要构建项目并发布版本时,bundleDependencies节点下的依赖会被包含在构建结果中,不需要另外npm install来安装了。该类型适用于以下场景(这里是纯翻译过来的):

  • when you want to re-use a third party library that doesn't come from npm or that you modified
  • when you want to re-use your own projects as modules
  • you want to distribute some files with your module
  • 你对第三方库做了定制或该库不是来自于npm;
  • 想将项目中的一些模块当作依赖;

依赖版本

package.json的使用中,除了有多种类型的依赖外,每个依赖的版本描述也是多种多样的。在详细了解各种版本描述前,先熟悉一个实践规范:语义化程序版本(Semantic Version),简称semver

语义化程序版本

PS:以下节点的内容基本上时从规范文档中摘抄过来的。

该规范的设计目的是:

用一组简单的规则及条件来约束版本号的配置和增长。

规范下的程序版本号使用的描述格式是:

主版本号.次版本号.修订号,版本号递增规则如下:

  1. 主版本号:当你做了不兼容的 API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。

版本号的规范摘抄如下:

  • 标准的版本号“必须 MUST ”采用 X.Y.Z 的格式,其中 X、Y 和 Z 为非负的整数,版本变更时只允许以数值类型递增;
  • 主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变;
  • 1.0.0 的版本号用于界定公共 API 的形成;
  • 修订号 Z(x.y.Z | x > 0)“必须 MUST ”在只做了向下兼容的修正时才递增;
  • 次版本号 Y(x.Y.z | x > 0)“必须 MUST ”在有向下兼容的新功能出现时递增,在任何公共 API 的功能被标记为弃用时也“必须 MUST ”递增。每当次版本号递增时,修订号“必须 MUST ”归零;
  • 主版本号 X(X.y.z | X > 0)“必须 MUST ”在有任何不兼容的修改被加入公共 API 时递增。每当主版本号递增时,次版本号和修订号“必须 MUST ”归零;
  • 先行版本号“可以 MAY ”被标注在修订版之后,先加上一个连接号再加上一连串以句点分隔的标识符号来修饰。标识符号“必须 MUST ”由 ASCII 码的英数字和连接号 [0-9A-Za-z-] 组成,且“禁止 MUST NOT ”留白。数字型的标识符号“禁止 MUST NOT ”在前方补零;
  • 版本编译信息“可以 MAY ”被标注在修订版或先行版本号之后,先加上一个加号再加上一连串以句点分隔的标识符号来修饰。标识符号“必须 MUST ”由 ASCII 的英数字和连接号 [0-9A-Za-z-] 组成,且“禁止 MUST NOT ”留白。当判断版本的优先层级时,版本编译信息“可 SHOULD ”被忽略

版本优先级比较:

  1. “必须 MUST ”把版本依序拆分为主版本号、次版本号、修订号及先行版本号后进行比较;
  2. 由左到右依序比较每个标识符号,第一个差异值用来决定优先层级:主版本号、次版本号及修订号以数值比较;
  3. 当主版本号、次版本号及修订号都相同时,改以优先层级比较低的先行版本号决定;
  4. 有相同主版本号、次版本号及修订号的两个先行版本号,其优先层级“必须 MUST ”透过由左到右的每个被句点分隔的标识符号来比较,直到找到一个差异值后决定:
    1. 只有数字的标识符号以数值高低比较,有字母或连接号时则逐字以 ASCII 的排序来比较;
    2. 数字的标识符号比非数字的标识符号优先层级低;
    3. 若开头的标识符号都相同时,栏位比较多的先行版本号优先层级比较高;

node-semver

npm 和yarn 中对依赖库版本的解析也是遵从语义化程序版本的规范的,同时为了增加版本解析的灵活度,基于node-semver 引入了一些operator。可以这么说,这些operator允许用户指定一定范围(Range)的依赖库版本。npm 和yarn 在安装依赖库时都尝试会从服务器上拉取符合范围的最新版本的依赖库。

下面参考文档,摘抄部分常见的,可以用于定义范围的operator:

Comparators(比较符)
Comparator Description
< 例如<2.0.0,指向小于2.0.0的版本
<= 例如<=2.0.0,指向小于等于2.0.0的版本
> 例如>2.0.0,指向大于2.0.0的版本
>= 例如>=2.0.0,指向大于等于2.0.0的版本
= 例如=2.0.0,指向等于2.0.0的版本

当没有使用Comparator时,默认为=

Intersections(交集符)

使用空格来连接两个比较符,从而匹配在交集内的版本号。比如有以下依赖声明:webpack:>1.0.0 <= 1.14.1。该声明匹配的webpack 版本处于区间:(v1.0.0, v1.14.1]。

Unions(并集符)

使用||来连接两个比较符,从而匹配在交集内的版本号。比如有以下依赖声明:vue:<1.0.0 >= 2.0.0。该声明匹配的webpack 版本处于区间:[v0.0.0, v1.0.0)和[v2.0.0, 正无穷]。

Pre-release tags(先行版本号)

comparator中的版本号包涵先行版本号时,无论comparator的类型时什么,最终只有同主版本号.次版本号.修订号的版本才会匹配到。比如>=3.1.4-beta.2则只能匹配到的版本区间是:[3.1.4-beta.2, 3.1.5],经 @guokangf 指正,应该是:[3.1.4-beta.2, 3.1.5)

Hyphen Ranges(连字符范围)

用以声明一个闭区间的版本范围,比如:

Version range Expanded version range
2.0.0 - 3.1.4 >=2.0.0 <=3.1.4
0.4 - 2 >=0.4.0 <3.0.0

在区间中,下界中的空白版本段会用0 来填充,比如上例中0.4=>0.4.0,上界中的空白版本段则会用x来填充,比如上面例子中2=>2.x.x=><3.0.0

X-Ranges(通配符)

使用字符Xx*来取代版本段中的数字来表示,该版本段所有可能性均可以匹配。比如:

Version range Expanded version range
* >=0.0.0 (any version)
2.x >=2.0.0 <3.0.0 (match major version)
3.1.x >=3.1.0 <3.2.0 (match major and minor version)

当单独声明版本号时,空白的版本段会用X来填充。

Version range Expanded version range
``(empty string) * or >=0.0.0
2 2.x.x or >=2.0.0 <3.0.0
3.1 3.1.x or >=3.1.0 <3.2.0

Hyphen Ranges中的空白版本段填充行为参见上一小节。

Tilde Ranges(~)

若用在次版本号不为空的版本依赖时,则只允许匹配范围只包涵修订号变化。

若用在主版本号不为空,次版本号为空的版本依赖时,则只允许匹配范围只包涵次版本号变化。

Version range Expanded version range
~3.1.4 >=3.1.4 <3.2.0
~3.1 3.1.x or >=3.1.0 <3.2.0
~3 3.x or >=3.0.0 <4.0.0
~1.2.3-beta.2 >=1.2.3-beta.2 <1.3.0
~0 >=0.0.0 <1.0.0 or 0.x

当涉及到先行版本号的匹配时,则一律只匹配同主版本号.次版本号.修订号的版本。

Caret Ranges(^)

匹配与声明中第一个非0 版本段数字相同的版本,比如:

Version range Expanded version range
^3.1.4 >=3.1.4 <4.0.0
^0.4.2 >=0.4.2 <0.5.0
^0.0.2 >=0.0.2 <0.0.3

当声明中有效的版本段都是0 时,则以优先级最低的版本段为匹配依据。同时,在Caret Ranges 中,空白的版本段都会用0来填充。例子如下:

Version range Expanded version range
^0.0.x >=0.0.0 <0.1.0
^0.0 >=0.0.0 <0.1.0
^0.x >=0.0.0 <1.0.0
^0 >=0.0.0 <1.0.0

其他的依赖版本类型

除了上面符合semver 的版本号声明外,npm、yarn 还支持扩展的版本号声明来支持git、github 等:

  • http://... :指定目标依赖的一个可下载的url;
  • git url 将依赖指向一个git 项目路径;
  • user/repo :指向Github 上某个用户的某个项目;
  • tag:指向一个tag commit,建议tag 名字不要以单词v开头,避免与版本号混淆;
  • file:path/to/local/file:将依赖指向本地环境的文件;

上面的git urluser/repo均支持使用commit-ish 作后缀来更精确的指向项目的某次提交、某个tag 或某个分支。

依赖树

todo

参考资料

ql434 commented

mark

怎么没有打赏的入口?本来想打赏来着。

这个文章不错,但是并不能star,或者引用。

mark

当用户依赖的版本、各插件依赖的版本之间不相互兼容,貌似会报错让用户自行修复;

这个确认了么,由于不好复现,我想坐等答案

image
安装的某些依赖包,依赖了一些旧的依赖包,github检测到lock中有这些,就会给一些警告,怎么去指定依赖包中依赖包的版本呢?

image
安装的某些依赖包,依赖了一些旧的依赖包,github检测到lock中有这些,就会给一些警告,怎么去指定依赖包中依赖包的版本呢?

可以参考下 stackoverflow 上的一个问答,我没试过,搜出来的哈:

How do I override nested dependencies with yarn?

另外,好像 github autofix 也可以试用下。

当用户依赖的版本、各插件依赖的版本之间不相互兼容,貌似会报错让用户自行修复;

这个确认了么,由于不好复现,我想坐等答案

这个很好复现啊,本地建3 个 package(A, B, C),A 和 B 对同一个第三方库依赖不同的版本,C 的 dependencies 指向本地的 A 和 B 就好了吧。

pyuyu commented

你好,想请教一个npm问题。项目不是直接依赖的test-01,项目的依赖里只有test:^x.x.x,test里才依赖的test-01:^x.x.x
现在test-01发布了一个新版本。 项目package.json和test的package.json都没改,但是执行npm i一直下不到最新版本。正确的姿势应该是怎样呢

image
安装的某些依赖包,依赖了一些旧的依赖包,github检测到lock中有这些,就会给一些警告,怎么去指定依赖包中依赖包的版本呢?

可以参考下 stackoverflow 上的一个问答,我没试过,搜出来的哈:

How do I override nested dependencies with yarn?

另外,好像 github autofix 也可以试用下。

github的autofix试过了,能修好一部分,好像是会生成一个pullrequest,但是有时候又不生成,不知道是什么情况。
也用yarn resolutions试过,部分也不能修改好,修完之后还得手动改yarn.lock文件。
最后关于npm找到了一个npm-force-resolutions包,强制修改的,测试好使,使用命令:

rm -r node_modules
npx npm-force-resolutions
npm install

你好,想问下比如>=3.1.4-beta.2则只能匹配到的版本区间是:[3.1.4-beta.2, 3.1.5]。

这里是包含3.1.5版本的么,看描述应该是属于不包含呀

你好,想问下比如>=3.1.4-beta.2则只能匹配到的版本区间是:[3.1.4-beta.2, 3.1.5]。

这里是包含3.1.5版本的么,看描述应该是属于不包含呀

应该是开括号了

大哥可以转载吗?标明出处