每次克隆下别人的代码后,执行的第一步就是 npm install 安装依赖包,安装成功后所有的包都会放在项目的 node_modules 文件夹下,也会自动生成 package-lock.json文件。有没有好奇过 node_modules 下的文件都是啥?package-lock.json 文件的作用是啥?

本文主要解决以下几个问题:

  1. package.json中的 dependenciesdevDependencies 的区别是啥,peerDependenciesbundledDependenciesoptionalDependencies又是啥?
  2. 为什么有的命令写在 package.json 中的 script 中就可以执行,但是通过命令行直接执行就不行?
  3. 为什么需要 package-lock.json 文件?
  4. 一个包在项目中有可能需要不同的版本,最后安装到根目录 node_modules 中的具体是哪个版本?

带着这几个问题,我们先从 package.json 文件说起。

package.json

最靠谱的官方文档请点这里

官方文档中列出了好多属性,感兴趣的可以一个个看一遍。下面只列出其中几个比较常用且重要的属性。

name & version

如果想要发布一个 npm 包,nameversion 属性是必须的。他们两个组合会形成一个唯一的标识来表名当前包。以后每更新一次包,version 就需要进行相应的更改。如果你不打算发布包,只想在本地使用,这两个字段不是必须的。

name 字段命名的规则如下:

  • 长度不能超过214个字符(对于有scoped的包,该限制包括scoped字段)(什么是Scoped packages?
  • 有作用域的包名字可以以.或者_开头,没有作用域限制的不可
  • 不能含有大写字母
  • 不能含有非URL安全的字符

version字段

版本号需要符合 semver(语义化版本号)规则,具体版本格式为:主版本号.次版本号.修订号, 如1.1.0。

  • 主版本号(major):做了不兼容的 API 修改
  • 次版本号(minor):做了向下兼容的功能性新增
  • 修订号(patch):做了向下兼容的问题修正

当有一些先行版本需要发布时,可以在 主版本号.次版本号.修订号 之后加上一个中划线和标识符如alpha(内部版本)、beta(公测版本)、rc(候选版本)等来表明。

以vue的版本为例:

  • 最新的稳定版本:3.0.5
  • 最新的rc版本:3.0.0-rc.13
  • 最新的beta版本:3.0.0-beta.24
  • 最新的alpha版本:3.0.0-alpha.13

可以通过 npm install semver 来检查一个包的命名是否符合 semver 规则。有关 semver 具体的说明可以看这里

dependencies & devDependencies

dependenciesdevDependencies大家应该都不陌生,通过 npm install xx --save 安装的包会写入 dependencies 中,通过 npm install xx --save-dev 安装的包会写入 devDependencies

dependencies 中的包是生产环境的依赖,属于线上代码的一部分,比如 vueaxiosveui 等。devDependencies 中的包是开发环境的依赖,只是在本地开发的时候需要依赖这里的包,比如 vue-loadereslint等。

我们平时用的 npm install 命令既会安装 dependencies 中的包,也会安装 devDependencies 中的包。如果只想安装 dependencies 中包,可以使用 npm install --production 或者将 NODE_ENV 环境变量设置为 production,通常在生成环境我们会这么用。

需要注意的是,一个模块会不会被打包取决于我们在项目中是否引入了该模块,跟该模块放在 dependencies 中还是 devDependencies 并没有关系。

对于我们的项目来说,把用到的包写在 dependencies 或者 devDependencies 并没有什么区别。但要是做为一个包发到 npm 上时,写在 devDependencies 中的依赖不会被下载。

peerDependencies & bundledDependencies & optionalDependencies

这三个属性在平时我们的项目开发中都用不到。不同于 dependencies & devDependencies面向的是包的使用者,peerDependencies & optionalDependencies & bundledDependencies这三个属性是面向包的发布者。

peerDependencies

我们在一些 node_modules 包的 package.json 中可以看到 peerDependencies,它用来表明如果你想要使用此插件,此插件要求宿主环境所安装的包。比如项目中用到的 veui1.0.0-alpha.24 版本中:

"peerDependencies": {
    "vue": "^2.5.16"
 }

这表明如果你想要使用 veui1.0.0-alpha.24 版本,所要求的 vue 版本需要满足 >=2.5.16<3.0.0

npm3.x 以上版本中,如果安装结束后宿主环境没有满足 peerDependencies 中的要求,会在控制台打印出警告信息。

bundledDependencies

当我们想在本地保留一个 npm 完整的包或者想生成一个压缩文件来获取 npm 包的时候,会用到 bundledDependencies。本地使用 npm pack 打包时会将 bundledDependencies 中依赖的包一同打包,当 npm install 时相应的包会同时被安装。需要注意的是,bundledDependencies 中的包不应该包含具体的版本信息,具体的版本信息需要在 dependencies 中指定。

例如一个 package.json 文件如下:

{
  "name": "awesome-web-framework",
  "version": "1.0.0",
  "bundledDependencies": [
    "renderized",
    "super-streams"
  ]
}

当我们执行 npm pack 后会生成 awesome-web-framework-1.0.0.tgz 文件。该文件中包含 renderizedsuper-streams 这两个依赖,当执行 npm install awesome-web-framework-1.0.0.tgz 下载包时,这两个依赖会被安装。

当我们使用 npm publish 来发布包的话,这个属性不会起作用。

optionalDependencies

从名字上就可以看出,这是可选依赖。如果有包写在 optionalDependencies 中,即使 npm 找不到或者安装失败了也不会影响安装过程。需要注意的是, optionalDependencies 中的配置会覆盖 dependencies 中的配置,所以不要将同一个包同时放在这两个里面。

如果使用了 optionalDependencies,一定记得要在项目中做好异常处理,获取不到的情况下应该怎么办。

scripts

定义在 scripts 中的命令,我们通过 npm run <command> 就可以执行。npm run <command>npm run-script <command> 的简写。如果不加 command,则会列出当前目录下可执行的所有脚本。

teststartrestartstop 这几个命令执行时可以不加 run,直接 npm testnpm startnpm restartnpm stop 调用即可。

env 是一个内置的命令,可以通过 npm run env 可以获取到脚本运行时的所有环境变量。自定义的 env 命令会覆盖内置的 env 命令。

之前开发中遇到一种情况,比如我们想本地通过 http-server 启动一个服务器,如果事先没有全局安装过 http-server 包,只是安装在对应项目的 node_modules 中。在命令行中输入 http-server 会报 command not found,但是如果我们在 scripts 中增加如下一条命令就可以执行成功。

scripts: {
  "server": "http-server",
  "eslint": "eslint --ext .js"
}

为什么同样的命令写在 scripts 中就可以成功,但是在命令行中执行就不行呢?这是因为 npm run 命令会将 node_modules/.bin/ 加入到 shell 的环境变量 PATH 中,这样即使局部安装的包也可以直接执行而不用加 node_modules/.bin/ 前缀。当执行结束后,再将其删除。

是不是还是没明白,下面我们来具体分析一下。

首先要明确什么是环境变量。环境变量就是系统在执行一个程序,但是没有明确表明该程序所在的完整路径时,需要去哪里寻找该程序。

对于局部安装的包,拿 eslint 来说,npm 会在本地项目 ./node_modules/.bin 目录下创建一个指向 ./node_moudles/eslint/bin/eslint.js 名为 eslint 的软链接,即执行 ./node_modules/.bin/eslint 实际上是执行 ./node_moudles/eslint/bin/eslint.js。而当我们执行 npm run eslint 的时候,node_modules/.bin/ 会被加入到环境变量 PATH 中,实际上执行的是 ./node_modules/.bin/eslint,这样就串起来了。

理论说完之后,我们来实际验证一下。

首先看一下系统的环境变量。直接执行 env 即可。

然后在当前项目目录下通过npm run env查看脚本运行时的环境变量。

通过对比可以发现,运行时的 PATH 多了两个环境变量。即 npm 指令的路径和项目 /node_modules/.bin 的路径。

以上就是 package.json 中常用 & 重要的几个属性,接下来我们来看一看 package-lock.json

package-lock.json

对于 npmpackage.json 文件可以看成它的输入,node_modules 可以做为它的输出。在理想情况下,npm 应该是一个纯函数,无论何时执行相同的 package.json 文件都应该产生完全相同的 node_modules 树。在一些情况下,这确实可以做到。但是在大多情况下,都实现不了。主要有以下几个原因:

  • 使用者的 npm 版本有可能不同,不同的 npm 版本有着不同的安装算法
  • 自上次安装之后,有些符合 semver-range 的包已经有新的版本发布。这样再有别人安装的时候,会安装符合要求的最新版本。比如引入 vue 包:vue:^2.6.1。A小伙伴下载的时候是 2.6.1,过一阵有另一个小伙伴B入职在安装包的时候,vue 已经升级到 2.6.2,这样 npm 就会下载 2.6.2 的包安装在他的本地
  • 针对第二点,一个解决办法是固定自己引入的包的版本,但是通常我们不会这么做。即使这样做了,也只能保证自己引入的包版本固定,也无法保证包的依赖的升级。比如 vue 其中的一个依赖 lodashlodash:^4.17.4,A下载的是 4.17.4, B下载的时候有可能已经升级到了 4.17.21

为了解决上述问题,npm5.x 开始增加了 package-lock.json 文件。每当 npm install 执行的时候,npm 都会产生或者更新 package-lock.json 文件。package-lock.json 文件的作用就是锁定当前的依赖安装结构,与 node_modules 中下所有包的树状结构一一对应。

有了这个 package-lock.json 文件,就能保证团队每个人安装的包版本都是相同的,不会出现有些包升级造成我这好使别人那不好使的兼容性问题。

下面是 lesspackage-lock.json 文件结构:

"less": {
    "version": "3.13.1",
    "resolved": "https://registry.npmjs.org/less/-/less-3.13.1.tgz",
    "integrity": "sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==",
    "dev": true,
    "requires": {
      "copy-anything": "^2.0.1",
      "errno": "^0.1.1",
      "graceful-fs": "^4.1.2",
      "image-size": "~0.5.0",
      "make-dir": "^2.1.0",
      "mime": "^1.4.1",
      "native-request": "^1.0.5",
      "source-map": "~0.6.0",
      "tslib": "^1.10.0"
    },
    dependencies: {
        "copy-anything": {
          "version": "2.0.3",
          "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.3.tgz",
          "integrity": "sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ==",
          "dev": true,
          "requires": {
            "is-what": "^3.12.0"
          }
          }
    }
 }
  • version: 包的版本信息
  • resoloved: 包的安装源
  • integrity:一个 hash 值,用来校验包的完整性
  • dev:布尔值,如果为 true,表明此包如果不是顶层模块的一个开发依赖(写在 devDependencies 中),就是一个传递依赖(如上面 less 中的 copy-anything)。
  • requires: 对应子依赖的依赖,与依赖包的 package.jsondependencies 的依赖项相同
  • dependencies:结构与外层结构相同,存在于包自己的 node_modules 中的依赖(不是所有的包都有,当子依赖的依赖版本与根目录的 node_modules 中的依赖冲突时,才会有)

通过分析上面的 package-lock.json 文件,也许会有一个问题。为什么有的包可以被安装在根目录的 node_modules 中,有的包却只能安装在自己包下面的 node_modules 中?这就涉及到 npm 的安装机制。

npm从 3.x 开始,采用了扁平化的方式来安装 node_modules。在安装时,npm 会遍历整个依赖树,不管是项目的直接依赖还是子依赖的依赖,都会优先安装在根目录的 node_modules 中。遇到相同名称的包,如果发现根目录的 node_modules 中存在但是不符合 semver-range,会在子依赖的 node_modules 中安装符合条件的包。

具体的安装算法如下:

  • 从磁盘加载 node_modules
  • 克隆 node_modules
  • 获取 package.json 文件和分类完毕的元数据信息并把元数据信息插入到克隆树中
  • 遍历克隆树,检测是否有丢失的依赖。如果有,把他们添加到克隆树中,依赖会尽可能的添加到最高层
  • 比较原始树和克隆树,列出将原始树转换为克隆树所要采取的具体步骤
  • 执行,包括 install, update, remove and move

npm 官网的例子举例,假设 package{dep} 结构代表包和包的依赖,现有如下结构:A{B,C}, B{C}, C{D},按照上述算法执行完毕后,生成的 node_modules 结构如下:

A
+-- B
+-- C
+-- D

对于 B,C 被安装在顶层很好理解,因为是 A 的直接依赖。但是 B 又依赖 C,安装 C 的时候发现顶层已经有 C 了,所以不会在 B 自己的 node_modules 中再次安装。C 又依赖 D,安装 D 的时候发现根目录并没有 D,所以会把 D 提升到顶层。

换成 A{B,C}, B{C,D@1}, C{D@2} 这样的依赖关系后,产生的结构如下:

A
+-- B
+-- C
   +-- D@2
+-- D@1

B 又依赖了 D@1,安装时发现根目录的 node_modules 没有,所以会把 D@1 安装在顶层。C 依赖了 D@2,安装 D@2 时,因为 npm 不允许同层存在两个名字相同的包,这样就与跟目录 node_modulesD@1 冲突,所以会把 D@2 安装在 C 自己的 node_modules 中。

模块的安装顺序决定了当有相同的依赖时,哪个版本的包会被安装在顶层。首先项目中主动引入的包肯定会被安装在顶层,然后会按照包名称排序(a-z)进行依次安装,跟包在 package.json 中写入的顺序无关。因此,如果上述将 B{C,D@1} 换成 E{C,D@1},那么 D@2 将会被安装在顶层。

有一种情况,当我们项目中所引用的包版本较低,比如 A{B@1,C},而 C 所需要的是 C{B@2} 版本,现在的结构应该如下:

A
+-- B@1
+-- C
   +-- B@2

有一天我们将项目中的 B 升级到 B@2,理想情况下的结构应该如下:

A
+-- B@2
+-- C

但是现在 package-lock.json 文件的结构却是这样的:

A
+-- B@2
+-- C
   +-- B@2

B@2 不仅存在于根目录的 node_modules 下,C 下也同样存在。这时需要我们手动执行 npm dedupe 进行去重操作,执行完成后会发现 C 下面的 B@2 会消失。大家可以在自己的项目中试一试,优化一下 package-lock.json 文件的结构。

以下是在我的项目中执行 npm dedupe 的结果:

removed 41 packages, moved 15 packages and audited 1994 packages in 18.538s

npm5.x 之前,可以手动通过 npm shrinkwrap 生成 npm-shrinkwrap.json 文件,与 package-lock.json 文件的作用相同。当项目中同时存在 npm-shrinkwrap.jsonpackage-lock.json,将以 npm-shrinkwrap.json 为主。

执行 npm dedupe 去重之后的 node_modules 会瘦身一些,但做为一个有追求的程序员怎么能局限于仅仅瘦身呢,我们要紧跟时代的潮流,对一些过时的东西say no。这时, npm-outdated 命令就派上用场了。

npm-outdated 命令是用来检查项目中用到的包版本在当前是否已经过时。如果有过时的包,会在控制台打印出信息。默认情况下,只会列出项目中顶层依赖的过时信息。如果想要更深层的查看,可以加上 depth 参数,如 npm-outdated --depth=1

以下是在我的项目中执行 npm-outdated 的部分结果。从结果中可以看到包的当前版本,符合 semver-range 的最高版本以及当前的最新版本等信息。

Package          Current          Wanted          Latest         Location
animate.css      3.7.0            3.7.2           4.1.1          xxx
autoprefixer     9.7.6            9.8.6           10.2.5         xxx
axios            0.19.2           0.19.2          0.21.1         xxx
babel-eslint     7.2.3            7.2.3           10.1.0         xxx
babel-loader     7.1.5            7.1.5           8.2.2          xxx

有需求的小伙伴可以尝试把自己项目中用到的已经过时的包升级一下。

本文只是一些理论基础,之后会介绍一些 npm 源码相关的知识。

参考文章

  1. npm官网
  2. 前端工程化 - 剖析npm的包管理机制
  3. 前端工程化(5):你所需要的npm知识储备都在这了
  4. semver
03-05 18:18