NodeJs

NPM语义版本号、package.json 和 package-lock.json

老规矩,看文章前先答个题:

我刚刚使用 create-react-app 新建了一个 React 项目,其中package.json如下

{
  "name": "playground",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.9",
    "@testing-library/react": "^11.2.3",
    "@testing-library/user-event": "^12.6.0",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-scripts": "4.0.1",
    "web-vitals": "^0.2.4"
  },
  // ... 
}

以上的

"react-dom": "^17.0.1"

意味着安装的 react-dom 版本是什么呢?

前言


提起 npm,大家第一个想到的应该就是 npm install 了,但是 npm install 之后生成的 node_modules 大家有观察过吗?package-lock.json 文件的作用大家知道吗?除了 dependencies 和 devDependencies,其他的依赖有什么作用呢?接下来,本文将针对 npm 中的你可能忽略的细节和大家分享一些经验。

npm 安装机制


A 和 B 同时依赖 C,C 这个包会被安装在哪里呢?C 的版本相同和版本不同时安装会有什么差异呢?package.json 中包的前后顺序对于安装时有什么影响吗?这些问题平时大家可能没有注意过,今天我们就来一起研究一下吧。
A 和 B 同时依赖 C,这个包会被安装在哪里呢?

假如有 A 和 B 两个包,两个包都依赖 C 这个包,npm 2 会依次递归安装 A 和 B 两个包及其子依赖包到 node_modules 中。执行完毕后,我们会看到 ./node_modules 这层目录只含有这两个子目录:

node_modules/
├─┬ A
│ ├── C
├─┬ B
│ └── C

如果使用 npm 3 来进行安装的话,./node_modules 下的目录将会包含三个子目录:

node_modules/
├─┬ A
├─┬ B
├─┬ C

为什么会出现这样的区别呢?这就要从 npm 的工作方式说起了:

npm 2 和 npm 3 模块安装机制的差异


虽然目前最新的 npm 版本是 npm 6,但 npm 2 到 npm 3 的版本变更中实现了目录打平,与其他版本相比差别较大。因此,让我们具体看下这两个版本的差异。

npm 2 在安装依赖包时,采用简单的递归安装方法。执行 npm install 后,npm 根据 dependencies 和 devDependencies 属性中指定的包来确定第一层依赖,npm 2 会根据第一层依赖的子依赖,递归安装各个包到子依赖的 node_modules 中,直到子依赖不再依赖其他模块。执行完毕后,我们会看到 ./node_modules 这层目录中包含有我们 package.json 文件中所有的依赖包,而这些依赖包的子依赖包都安装在了自己的 node_modules 中 ,形成类似于下面的依赖树:

这样的目录有较为明显的好处:

1)层级结构非常明显,可以清楚的在第一层的 node_modules 中看到我们安装的所有包的子目录;

2)在已知自己所需包的名字以及版本号时,可以复制粘贴相应的文件到 node_modules 中,然后手动更改 package.json 中的配置;

3)如果想要删除某个包,只需要简单的删除 package.json 文件中相应的某一行,然后删除 node_modules 中该包的目录;

但是这样的层级结构也有较为明显的缺陷,当我的 A,B,C 三个包中有相同的依赖 D 时,执行 npm install 后,D 会被重复下载三次,而随着我们的项目越来越复杂,node_modules 中的依赖树也会越来越复杂,像 D 这样的包也会越来越多,造成了大量的冗余;在 windows 系统中,甚至会因为目录的层级太深导致文件的路径过长,触发文件路径不能超过 280 个字符的错误;

为了解决以上问题,npm 3 的 node_modules 目录改成了更为扁平状的层级结构,尽量把依赖以及依赖的依赖平铺在 node_modules 文件夹下共享使用。

npm 3 对于同一依赖的不同版本会怎么处理呢?


npm 3 会遍历所有的节点,逐个将模块放在 node_modules 的第一层,当发现有重复模块时,则丢弃, 如果遇到某些依赖版本不兼容的问题,则继续采用 npm 2 的处理方式,前面的放在 node_modules 目录中,后面的放在依赖树中。举个????:A,B,依赖 D(v 0.0.1),C 依赖 D(v 0.0.2):

但是 npm 3 会带来一个新的问题:由于在执行 npm install 的时候,按照 package.json 里依赖的顺序依次解析,上图如果 C 的顺序在 A,B 的前边,node_modules 树则会改变,会出现下边的情况:

由此可见,npm 3 并未完全解决冗余的问题,甚至还会带来新的问题。

NPM语义版本号


npm 的官方文档中建议库的版本号的发布规则如下 :

场景阶段规则例子
首次发布新产品从 1.0.0 开始1.0.0
向后兼容的bug修复发布补丁第三位数字值递增1.0.1
向后兼容的新特性小版本更新第二位数字递增且将第三位数字重置为01.1.0
破坏向后兼容的修改大版本更新第一位数字递增且将第二、三为数字重置为02.0.0

以上的规则可以简单总结为

当然,理想很丰满,现实很骨感~ 很多第三方库的版本号并没有严格按照以上的规则发布,因此也造成了一些“车祸”。(第三部分会分享我的案例)

你想使用什么版本?


回看文章开头我使用 create-react-app 新建的项目里 package.json 里的

"react-scripts": "4.0.1"

这边非常明确地指定了使用 react-scripts 的 4.0.1 版本。

所有依赖库都使用这样明确的版本声明不好吗?—— 是的,这可能不是最佳的做法。

设想,你的项目里依赖的某个库有个性能问题,作者最近发布了修复补丁,如果你不是经常关注该库的 changelog,你可能根本不知道,你的项目也享受不到这个性能提升了。

再设想,你的项目里依赖的某个库最近被作者更新了,提供了几个十分有用的小功能,同样的,使用精确的版本号不能让你及时体验到这些新特性。

那么有办法设置一个模糊的版本号范围吗? —— 有的!下面使用例子来更好地说明

  • 1) 及时帮我安装最新补丁

例子1:

"some-lib": "1.0"   
或   
"some-lib": "1.0.x"

说明1: npm install 的时候,请帮我安装 1.0 的小版本以及最新的补丁版本,如 1.0.2 , 1.0.6 等

例子2:

"some-lib": ”~1.0.4“ 

说明2: npm install 的时候,请帮我安装 1.0 的小版本 以及 1.0.4 以上的最新补丁版本, 如 1.0.6 等(但不会安装 1.0.2 因为它低于 1.0.4 )

  • 2)及时帮我安装最新小版本和最新补丁

例子3:

"some-lib": "1"   
或   
"some-lib": "1.x"

说明3: npm install 的时候,请帮我安装 1 的大版本以及最新的小版本以及补丁版本,如 1.0.6 , 1.1.6 , 1.2.3 , 1.3.0 等

例子4:

"some-lib": "^1.1.4" 

说明4: npm install 的时候,请帮我安装 1 的大版本以及 1.1.4 以上 最新的小版本以及补丁版本,如 1.1.6 , 1.2.3 , 1.3.0 等 (但不会安装 1.0.6 因为它低于 1.1.4 )

  • 3) 及时帮我安装最新的版本

例子5:

"some-lib": "*"   
或   
"some-lib": "x"

说明5: npm install 的时候, 都给我安装最最最新的版本!我不在乎兼容性!项目上线崩盘了我也无所谓! —— 这是最浮夸的一种写法,当然也是基本不可能出现在实际生产环境代码中的写法。

(以上都是以项目中没有 package-lock.json 为前提假设,后面的章节会谈到 package-lock.json)

官方提供了一个实验平台网站可以尝试以上这些写法 npm semantic version calculator

看到这里,你应该就能回答出文章开头的那个问题了吧

    "react-dom": "^17.0.1" 安装的是什么版本?

为什么会出现 package-lock.json 呢?


为什么会有 package-lock.json 文件呢?这个我们就要先从 package.json 文件说起了。

  • package.json 的不足之处

npm install 执行后,会生成一个 node_modules 树,在理想情况下, 希望对于同一个 package.json 总是生成完全相同 node_modules 树。在某些情况下,确实如此。但在多数情况下,npm 无法做到这一点。有以下两个原因:

1)某些依赖项自上次安装以来,可能已发布了新版本 。比如:A 包在团队中第一个人安装的时候是 1.0.5 版本,package.json 中的配置项为 A: ‘^1.0.5’;团队中第二个人把代码拉下来的时候,A 包的版本已经升级成了 1.0.8,根据 package.json 中的 semver-range version 规范,此时第二个人 npm install 后 A 的版本为 1.0.8;可能会造成因为依赖版本不同而导致的 bug;

2)针对 1)中的问题,可能有的小伙伴会是把 A 的版本号固定为 A: ‘1.0.5’ 不就可以了吗?但是这样的做法其实并没有解决问题, 比如 A 的某个依赖在第一个人下载的时候是 2.1.3 版本,但是第二个人下载的时候已经升级到了 2.2.5 版本,此时生成的 node_modules 树依旧不完全相同 ,固定版本只是固定来自身的版本,依赖的版本无法固定。

  • 针对 package.json 不足的解决方法

为了解决上述问题以及 npm 3 的问题,在 npm 5.0 版本后,npm install 后都会自动生成一个 package-lock.json 文件 ,当包中有 package-lock.json 文件时,npm install 执行时,如果 package.json 和 package-lock.json 中的版本兼容,会根据 package-lock.json 中的版本下载;如果不兼容,将会根据 package.json 的版本,更新 package-lock.json 中的版本,已保证 package-lock.json 中的版本兼容 package.json。

  • package-lock.json 文件的结构

package-lock.json 文件中的 name、version 与 package.json 中的 name、version 一样,描述了当前包的名字和版本,dependencies 是一个对象,该对象和 node_modules 中的包结构一一对应,对象的 key 为包的名称,值为包的一些描述信息, 根据 package-lock-json官方文档 (https://docs.npmjs.com/configuring-npm/package-lock-json.html#requires),主要的结构如下:

version :包版本,即这个包当前安装在 node_modules 中的版本
resolved :包具体的安装来源
integrity :包 hash 值,验证已安装的软件包是否被改动过、是否已失效
requires :对应子依赖的依赖,与子依赖的 package.json 中 dependencies 的依赖项相同
dependencies :结构和外层的 dependencies 结构相同,存储安装在子依赖 node_modules 中的依赖包
  • package-lock.json 文件的作用
在团队开发中,确保每个团队成员安装的依赖版本是一致的,确定一棵唯一的 node_modules 树;
node_modules 目录本身是不会被提交到代码库的,但是 package-lock.json 可以提交到代码库,如果开发人员想要回溯到某一天的目录状态,只需要把 package.json 和 package-lock.json 这两个文件回退到那一天即可。
由于 package-lock.json 和 node_modules 中的依赖嵌套完全一致,可以更加清楚的了解树的结构及其变化。
在安装时,npm 会比较 node_modules 已有的包,和 package-lock.json 进行比较,如果重复的话,就跳过安装 ,从而优化了安装的过程。

依赖的区别与使用场景


npm 目前支持以下几类依赖包管理包括

dependencies
devDependencies
optionalDependencies 可选择的依赖包
peerDependencies 同等依赖
bundledDependencies 捆绑依赖包

dependencies

dependencies 是无论在开发环境还是在生产环境都必须使用的依赖,是我们最常用的依赖包管理对象,例如 React,Loadsh,Axios 等,通过 npm install XXX 下载的包都会默认安装在 dependencies 对象中,也可以使用 npm install XXX –save 下载 dependencies 中的包;

devDependencies

devDependencies 是指可以在开发环境使用的依赖,例如 eslint,debug 等,通过 npm install packageName –save-dev 下载的包都会在 devDependencies 对象中;

dependencies 和 devDependencies 最大的区别是在打包运行时,执行 npm install 时默认会把所有依赖全部安装,但是如果使用 npm install –production 时就只会安装 dependencies 中的依赖,如果是 node 服务项目,就可以采用这样的方式用于服务运行时安装和打包,减少包大小。

我到底安装了哪个版本?—— package-lock.json


package.json 里声明的版本支持以上的模糊写法,那么在 npm install 的时候,我怎么知道到底安装了具体哪个版本呢?

答案就在 package-lock.json 里,在我的测试项目的 package-lock.json 有以下内容

...
    "react-dom": {
      "version": "17.0.1",
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz",
      "integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==",
      "requires": {
        "loose-envify": "^1.1.0",
        "object-assign": "^4.1.1",
        "scheduler": "^0.20.1"
      }
    },
...

由此可以知道 当前安装的react-dom 的精确版本是 17.0.1

package-lock.json 还有一个很重要的作用 —— 当 npm install 时发现本地有 package-lock.json,就会严格按照 package-lock.json 的版本来安装。

  • package-lock.json 完整描述了当前项目的依赖树,保证了其他地方运行npm install 时都能安装一模一样的依赖库版本
  • 无需把 node_modules 上传到Git就能达到上面第一点提到的效果
  • 如果package-lock.json更新并上传新版本到 Git,利用 Git 的 Diff 功能可以清楚地了解发生了哪些变化可以让 Npm install 时跳过对一些依赖库的元数据的请求,优化了性能
  • 从 npm v7 版本开始,package-lock.json 的信息已经包含了 package.json 关于依赖树的的所有内容,因此无需再解析 package.json,大大提高了性能

实践遇到的坑


项目里很早之前就引用了某个库 lib-a,在dependency里使用的版本号是 ”^1.0.4″ 。

某次上线后,特定页面出现 bug,但是在我电脑本地无法复现同样的问题。于是我清除 node_modules 文件夹和 package-lock.json ,重新执行 npm install 并运行项目,这次复现了问题。

根据错误日志,追溯到了问题在于 lib-a 里。

对比了前后的 package-lock.json 的内容,发现新安装的 lib-a 的版本是 1.0.5 而之前一直是 1.0.4 ,看来 1.0.5 是有 bug 的。

但是为什么线上会安装 1.0.5 呢?原来 package-lock.json 因为某些历史原因被加入到了 .gitignore 里,导致线上部署的 npm install 时没有 package-lock.json限制 就安装了lib-a的最新的版本。

查明原因后,我在dependency里直接声明了准确的版本号 1.0.4 解决了问题。

吐槽:

理论上 1.0.5 只是 1.0.4 的补丁版本,为什么反而导致了 bug !

这就是骨感的现实 —— 不是所有库的补丁/小版本都不会破坏向后兼容,都可能出问题。

因此,这次得到的教训是 尽量将 package-lock.json 上传到 git 上;如果追求稳定,甚至可以使用精准版本号;尽量多使用单元测试和集成测试及时发现问题。