为什么执行npm install会更新package-lock.json?

你是否也有疑惑,为什么介入新项目初始化的时候,执行npm install安装依赖,项目的package-lock.json有时候会更新?package-lock.json就是用来固定依赖版本的,按理说依赖的版本都固定了,又没有安装新的依赖就不应该改变的,这是为什么?如果你也有这样的疑问,那么接下来可以进一步跟随我去了解npm install的模块安装机制。当然需要明确一下,这里提到的npm版本version 5及其之后的版本,因为在此之前,还未有package-lock.json之类的配置文件。

npm install(不带参数)安装机制

确定首层依赖

先检查package.json,通过dependencies和devDependencies属性中直接指定的模块。

构建依赖树

确定首层依赖后,会去构建依赖树。这是一个递归的过程,工程本身是整棵依赖树的根节点,每个首层依赖模块都是根节点下面的一棵子树,npm 会开启多进程从每个首层依赖模块开始逐步寻找更深层级的节点,形成依赖树。每个节点在递归的过程中会去确定节点模块的信息,比如版本、下载地址、压缩包地址等。

版本的确定机制:

package.json 中往往是 semantic version(semver,语义化版本)。此时如果版本描述文件(npm-shrinkwrap.json 或 package-lock.json或yarn.lock)中有该模块信息直接拿即可,如果没有则从远程仓库获取。如 packaeg.json 中某个包的版本是 ^1.0.0,npm 就会去仓库中获取符合 1.x.x 形式的最新版本。

依赖树扁平化

上一步得到的依赖树,一般是包含很多重复模块的,比如A模块依赖了moment,B模块也依赖了moment,npm在npm3之后进行了优化,不在严格按照依赖树来进行安装,因为这个过程会浪费大量资源和时间。做法就是对这颗树进行扁平化处理。即简单说来,它会遍历所有节点,逐个将模块放在根节点下面,也就是 node_modules 的第一层。当发现有重复模块时,则将其丢弃。

比如 node_modules 下 A 模块依赖 moment@^1.0.0,B 模块依赖 moment@^1.1.0,则 ^1.1.0 为兼容版本。

而当 A 模块依赖 moment@^2.0.0,B 模块依赖 moment@^1.1.0,则依据 semver 的规则,二者不存在兼容版本。会将一个版本放在 node_modules 中,另一个仍保留在依赖树里。

举个例子,假设一个依赖树原本是这样:

node_modules
-- moduleA
---- moment@version1
-- moduleB
---- moment@version2

假设 version1 和 version2 是兼容版本,则经过 扁平化 会成为下面的形式:

node_modules
-- moduleA
-- moduleB
-- moment(保留的版本为兼容版本)

假设 version1 和 version2 为非兼容版本,则后面的版本保留在依赖树中:

node_modules
-- moduleA
-- moment@version1
-- moduleB
---- moment@version2

获取依赖树确定的模块

先会通过压缩包地址去判断是否在缓存中存在该版本模块,如果有,就直接拿过来,如果没有,就回去远程仓库下载,下载完后放入缓存,并解压放到node_modules目录中,最后会去新增或者更新lock文件。

回答文章刚开始提出的问题

根据上面构建依赖树的过程,即npm install并不是完全按照lock文件去安装依赖的,是先通过package.json递归创建依赖,然后再看lock中对应的模块固定的版本是否符合semver规则,符合就直接确认版本安装,不符合就会按照package.json的指定的semver规则去仓库拉最新的版本信息并更新lock文件,外部条件不变的情况下(npm版本、上次构建代码等),理论上依赖版本不会发生变化,也就是lock不应该出现更新才对。那么究竟是什么导致了日常中我们感知到的lock变化了呢?

先明确一个机制:

## package.json
"dependencies": {
"core-js": "~3.4.5"
}
## package-lock.json
"dependencies": {
"core-js": {
"version": "3.4.7",
"resolved": "https://registry.npm.taobao.org/core-js/download/core-js-3.4.7.tgz",
"integrity": "sha1-PdplYR2VaZtet3QupFHqBS03qmU="
}
}

如上示例代码,依赖的是core-js ~3.4.5, 锁定的是3.4.7。 你把package.json里 core-js 的依赖改成 ~3.4.6 , ~3.4.7,重新安装都不会使 package-lock.json变化, 因为lock文件里面保存的版本比package文件里面的大。但是如果你把package.json文件里面的版本直接改成 core-js: ~3.4.8, 这就比lock文件里面的版本要高了,需要重新下载最新的,会下载符合3.5.x 的最新版。同时更新lock文件。

上面这种情况属于小概率事件,但也不是不会发生的。即手动改了package.json的某个依赖的版本,但是没有去安装和自动更新lock文件,就提交上去了,那么你接手初始化项目,就可能出现lock文件更新的情况。

另外根据实践,还有一种可能,就是有些公司内部有自己的npm镜像,类似cnpm,使用这种类似cnpm install来安装依赖,一般不会更新lock文件,如果团队没统一安装依赖命令,有的用npm install 有的用类似cnpm install,package.json和lock的差异性就会增加,当你使用npm install时,就有可能会更新lock文件。

另外,最大可能的一种情况是大家使用的npm版本不一致,版本不同,那么组织生成lock文件的逻辑可能不同,就可能导致lock文件更新。

npm ci

之所以提这个命令,是因为开篇的那个问题,其实有一层含义就是提问者想要通过lock固定下来的版本安装工程,不想有任何版本变化,保持绝对一致。那么npm ci就是为此而生的。这个命令会完全按照工程的lock描述文件去安装依赖。要注意的是,当lock文件的描述版本不满足package.json依赖指定到semver规则时,会报错退出,并不会往下执行或者去更新lock文件。

参考链接

npm install模块安装机制