包管理器系列之——你不知道的npm

1,501 阅读14分钟

一、导读

npm 发展到现在仍然是世界上最大的包管理系统,这主要得益于活跃的 nodejs 社区,作为前端开发的同学当然不能仅仅只停留在基本的使用阶段,我们还需要去深入学习与之相关的配置、原理以及新特性,这篇文章将会从package.json、依赖安装原理、版本管理、现存的问题、最新的特性、未来展望这几个方面去介绍 npm。

二、package.json

我们知道对于每一个 nodejs 项目,都有它对应的描述文件、即 package.json,里面包含了项目的名称、版本、依赖等基本信息,比如当我们运行 npm install 时就会根据描述文件中的 dependenciesdevDependencies 配置去下载对应的包。

1、name

name 是每个 package 必需的字段,他和 version 一起组成了一个 package 的唯一标识,我们可以通过 npm view packageName 去查看包名是否被占用,比如查看下 react:

image.png

2、version

package 每发布一次就需要一个新的 version,version 一般的格式是 主版本号.次版本号.修订号,通常情况下当我们做了比较大的功能性改动时就需要更新主版本号,当新增了一些功能时就修改次版本号,当只是修复了一些问题时就修改修订号

如果某次改动比较大而且在不是很稳定的情况下,就需要先发布先行版,比如 react17 -> react18,通常会在版本后通过 - 这个连接符去设置版本信息,alpha 表示内测版、beta 表示公测版、rc 表示候选版。

3、 地址

  "homepage": "https://reactjs.org/",
  "bugs": "https://github.com/facebook/react/issues",
  "repository": {
    "type": "git",
    "url": "https://github.com/facebook/react.git",
    "directory": "packages/react"
  },

homepage 表示项目的主页面地址,上面表示 react 的官方网址。

repository 表示项目的仓库地址。

4、dependencies

dependencies 字段中申明的包是项目生产环境中需要的,这里最需要注重的是依赖包的版本控制,一般我们可以通过三种方式去控制依赖的版本号,下面看一个例子:

"dependencies": { 
    "react": "16.0.0", // 16.0.0
    
    "react": "^17.0.2", // >=17.0.2 < 18.0.0
    "react": "^0.0.1", // >=0.0.1 <0.0.2
    "react": "^0.2.0", // >=0.2.0 <0.3.0
    "react": "^16.0.x", // >=16.0.0 <17.0.0
    "react": "^0.0.x", // >=0.0.0 <0.1.0
    "react": "^0.x", // >=0.0.0 <0.1.0
    "react": "^1.x" // >=1.0.0 <2.0.0
    
    "react": "~15.5.0", // >=15.5.0 < 15.6.0
    "react": "~15.5", // >=15.5.0 < 15.6.0
    "react": "~15", // >=15.0.0 < 16.0.0
    "react": "~0.2.0", // >=0.2.0 <0.3.0
    "react": "~0.2", // >=0.2.0 <0.3.0
    "react": "~0" // >=0.0.0 <1.0.0-0
},

大家先不要看答案自己想想其对应的版本是什么,然后可以在这个网站进行验证。

  • 固定版本号

    "react": "16.0.0" 就是固定版本,安装时就会安装这个指定的版本。

  • 波浪号 (~)

    一般情况下(存在主版本号和次版本号)只会改变修订号,主版本号和次版本号是不会发生变化的,比如 ~15.5.0,但是当缺失次版本号时就会改变次版本号,比如 ~0、~15

  • 插入号 (^)

    插入号一般情况下(主版本号大于1)只会改变次版本号和修订号,比如^17.0.2,但是一旦主版本号等于0时,事情就不是我们想的那样了,下面分情况讨论:

    • 主版本号和次版本号都为0,修订号不缺失

      比如 ^0.0.1,他就只会在 0.0.10.0.2 之间寻找合适的版本。

    • 主版本号为0,次版本号不为0,且次版本号和修订号都不缺失

      比如 ^0.2.0,他就会改变修订号,即在 0.2.00.3.0 之间寻找合适的版本。

    • 主版本号和次版本号为0,修订号缺失,

      比如 ^0.0.x,此时修订号会降为0,同时会在 0.0.00.1.0 之间寻找合适的包。

    • 主版本号为0,次版本号和修订号缺失

      比如 ^0.x,此时修订号和次版本号都会被降为0,同时会在 0.0.00.1.0 之间寻找包

    • 主版本号不为0,次版本号或者修订号缺失

      比如 ^16.0.x、^1.x,此时的效果和一般情况就没区别。

注意点:

1.0.0 的版本号用于界定公共 API。当你的软件发布到了正式环境,或者有稳定的API时,就可以发布1.0.0版本了。所以,当你决定对外部发布一个正式版本的npm包时,把它的版本标为1.0.0。

5、devDependencies

devDependencies 中保存的是一些只有在开发环境下才会用到的依赖,比如 eslint、babel-loader、webpack等,我们生产环境的代码是已经经过编译压缩后的代码,不需要这些依赖也能正常运行,如果装上这些依赖反而会增加包的体积。

6、optionalDependencies

有些场景下,对于一些依赖包不是强依赖的,当这个包无法被获取到时我希望 npm install 能够继续运行而不被阻断,此时我们就可以将依赖放到这里,需要注意的是 optionalDependencies 的配置会覆盖 dependencies 中的配置,所以我们只需在这里配置就行。

7、main

main 字段表示包的入口,比如 antd 的入口为 lib/index.js,我们通过 import 引入的时候其实就是引入这个入口文件中暴露出来的组件。

8、bin

当我们开发了一个命令行工具时,我们需要为命令行工具指定一个入口,即指定我这个命令对应的本地文件,如果我们是全局安装,npm 会使用符号连接把可执行文件链接到 /user/local/bin 下面,如果是本地安装,就会链接到 。/node_modules/.bin下,下面看下 babel/cli 这个命令行工具包。

  "bin": {
    "babel": "./bin/babel.js",
    "babel-external-helpers": "./bin/babel-external-helpers.js"
  },

我们执行 babel 其实就是运行 ./bin/babel.js 这个文件。

9、files

files 属性用于描述你 npm publish 后推送到 npm 服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来。下面看下 antd 是怎么做的:

image.png

我们看到我们 npm install antd 后下下来的包里面的文件就是 files 中定义的文件。

10、private

private 属性决定是否将该项目发布到 npm 上,用来放置将私有的包发布出去。

11、publishConfig

publishConfig 包含我们发布模块时的一些配置,比如设置发布的目的 npm 源,更详细的配置看参考 npm-config

12、os

os 可以指定我们的包只能被安装在特定的环境下,比如说我只想让linux、window用户安装,我可以这样配置:

"os" : [ "win32", "linux" ]

三、npm 是怎么管理包的?

npm 的包管理主要分为两个阶段,一个是 npm2 及之前的管理方式,由于当时这种管理方式存在很多不足,所以在在 npm3 就对管理方式进行了优化。

1、 npm2的依赖管理

npm2 安装以来的方式比较简单粗暴,他会按照依赖的树形结构依次安装对应的依赖,比如我现在项目中依赖了 A@3.0.0,B@2.0.0,C@2.0.0,而B又依赖了 A@3.0.0、D@2.0.1,C 依赖了 A@3.0.0,D@2.0.0,那么对应的树形结构图如下:

image.png

那么 npm 就会更具这个树形结构图一次在对应包的 node_modules 中安装依赖,即使 D@2.0.0 这个包具有相同的版本。

这种包管理方式会带来什么问题呢?

  • 依赖的层级太深,这就会导致文件的路径过长,在 window 环境下就会出现问题。

  • 大量包被重复的安装,这不仅会导致整个项目的体积会变大,同时安装依赖的时间也会变得非常的长。

2、npm3的依赖管理

从 npm3 开始就采用了扁平化管理的方式管理依赖,比如说我们去安装一个 express 这个包,按 npm2 的管理方式全局 node_mosules 中只会出现 express 这个包,而 express 本身的依赖会安装到他自己的 node_mosules 中,但是在 npm3 中采用了扁平化的方式,他会把所有的依赖全部打平放在顶层的 node_modules 中,结果如下:

image.png

所有的依赖都被拍平到node_modules目录下,不再有很深层次的嵌套关系。这样在安装新的包时,根据 node module resolution algorithm 机制,会不停往上级的node_modules当中去找,如果找到相同版本的包就不会重新安装,解决了大量包重复安装的问题,而且依赖层级也不会太深。

但是我们仔细想想大量重复安装包的问题真的被解决了吗?扁平化操作就没有其他的问题吗?我们一个一个来介绍。

1、重复安装包的问题真的被解决了吗?

答案是没有,我们来看一个例子:

image.png

项目对应的 package.json 如下:

  "dependencies": {
    "xl-package2": "1.0.0",
    "xl-package4": "1.0.0",
    "xl-package5": "1.0.0",
    "xl-package1": "1.1.0"
  }

执行 npm install 后我们来看下 node_modules 的结构,

image.png

也就是说 npm 只把 xl-package3@1.0.0 提升到了全局,当我去安装 xl-package3@1.1.0 时会去向上找这个版本有没有被安装,显然是没有被安装的,所以 xl-package3@1.1.0 会被重复安装三次,因为 npm 最终会通过内部的一个 compare 方法对依赖进行一次排序,所以字典序在前面的包会被优先提出来。

2、Phantom dependencies

一个库使用了不属于其 dependencies 里的 Package 称之为 Phantom dependencies,这主要是由于包的查找规则,这种现象在 monorepo 中会被放大,比如我们所熟悉的 yarn workspace + lerna,它会将所有package 的依赖提升到顶层的 node_modules,这样就会导致 package 之间相互使用自身没有申明的依赖。

比如上面的例子,我们可以在项目中直接 import / require xl-package3 这个包,但是由于无法保证幻影依赖的版本正确性,给程序运行带来了不可控的风险,我们能否引用到 xl-package3,以及引用到什么版本的 xl-package3 完全取决于 xl-package1xl-package2 等包的开发者。

3、npm5的改进点

npm5 在包管理上其实和 npm3 没啥差别,npm5 主要是在一个功能上做了一些改进,毕竟作为官方的包管理工具不能被 yarn 给取代,下面列举下 npm5 的主要改进点:

1、新增 package-lock.json 来记录依赖树信息,进行依赖锁定

在我们首次去安装依赖时,npm 会为我们生成一个 package-lock.json 文件,这个文件记录了我们本次安装的依赖的一些信息,当下次再去执行 install 时最终的依赖版本和树形结构回合第一次一样,这样就保证了多人开发的环境一致性。

其实锁定依赖信息的功能其实之前就有-npm-shrinkwrap.json,他的文件格式和 package-lock.json 是一致的,当我们安装完依赖之后再运行 npm shrinkwrap 命令,会发现就是把 package-lock.json 重命名为 npm-shrinkwrap.json 而已。

那这两种文件既然都一样,那为什么还要多增加一个文件干啥?其实这两个文件在使用场景上还是有区别的:

  • package-lock.json 用于开发时锁定版本使用,应该提交到 git 仓库,但是不应该跟随发布。

  • npm-shrinkwrap.json 可以作为库的依赖锁进行发布,当依赖包有此文件时,将按照此文件安装对应的依赖。

  • 当两个文件同时存在时,npm-shrinkwrap.json 有高优先级,package-lock.json 文件将被忽略。

2、缓存优化

新版本重写了整个缓存系统,缓存将由 npm 来全局维护不用用户操心,--cache-min and --cache-max也已经被弃用,同时在离线情况下, npm 不会持续的发送请求,而是会寻找包的缓存或者结束安装。

3、文件依赖的优化

npm5 中新增了 npm install file: ./xxx/file 这种方式去引用本地文件,在之前的版本中我们想要实现这种效果就得 copy 一份代码到 node_mosules 中,这种方式也是费力不讨好做不到事实时同步更新,使用 npm install file: xxx 会直接通过软链接链接到本地的文件夹,同时能实现实时的同步更新。那这种方式和 npm link 有什么区别呢?

image.png 详细解释可以查看这里,其实概括下来就是这两点:

  • npm link 后的包在 package.json 中是没有记录的,而使用 npm install file: xxx 将会在我们项目的 package.json 中留下记录方便查找:

image.png

  • npm link 需要先全局建立一个软连接文件,然后本地如果需要引用就直接指向这个全局的文件,而 npm install file: xxx 是直接软链接到对应的文件,如下图所示: image.png

4、npm6的改进点

npm6在功能上倒是没太多改动,它将侧重于性能、稳定性和安全性,与先前版本的 npm 相比,性能提高 1700%,详细信息可查看这里

5、npm7令人兴奋的新功能

  • 支持自动安装 peerDependencies

  • 支持workspaces

    npm 为了跟随 monorepo 的步伐,在版本7中推出了 workspaces 功能,这个功能可以让我们在一个目录下维护多个项目,项目之间支持相互引用而不用我们手动去通过 npm link 去进行链接。同时提高了依赖的安装效率,如果多个项目依赖了同一个包,那这个包也只会在全局安装一次,详细介绍可查看官网

四、npm install 做了啥?

image.png

1、package.lock.json

在介绍 npm5 特性的时候介绍了 package.lock.json 这个文件的作用,他就是用来锁定依赖的信息,同时它的整体结构是和 node_modules 是一样的,这样就能保证每次安装依赖时都能得到结构和版本信息都相同的 node_mosules 树,我们可以先看一下这个文件的结构,我们以 express 为例:

"express": {
      "version": "4.18.0",
      "resolved": "https://registry.npmjs.org/express/-/express-4.18.0.tgz",
      "integrity": "sha512-EJEXxiTQJS3lIPrU1AE2vRuT7X7E+0KBbpm5GSoK524yl0K8X+er8zS2P14E64eqsVNoWbMCT7MpmQ+ErAhgRg==",
      "requires": {
        "accepts": "~1.3.8",
        "array-flatten": "1.1.1",
        "body-parser": "1.20.0",
        "content-disposition": "0.5.4",
        "content-type": "~1.0.4",
        // ...
      }
 },
 "send": {
      "version": "0.18.0",
      "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
      "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
      "requires": {
        // ...
      },
      "dependencies": {
        "ms": {
          "version": "2.1.3",
          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
        }
      }
 },

每个包中都有存在一些字段用来描述这个包的信息:

  • version

    当前已安装的包的版本,这个包会被放在顶层的 node_mosules 中。

  • resolved

    安装包的来源

  • integrity

    包对应的 hash 值,用来验证包的完整性

  • requires

    当前包的依赖,和这个包的 package.json 中dependencies的依赖项相同。

  • dependencies

    表示安装在当前包的 node_mosules 中的依赖,当然这个属性不是所有的包都有的,只有当版本存在冲突时才会在子包的 node_mosules 中进行安装。

为了更好的了解 package.lock.json 的作用,我们还是拿 express 来说:

我们先在命令行执行以下命令:

npm cache clean --force

这条命令就是清空本地缓存,再执行 npm i --timing=true --loglevel=verbose 去安装 express,我们会在控制台看到这些信息:

我们看到 npm 会去 npm 源la qu dui ying de bao

image.png

拉去对应的包,好,现在我们删除 node_mosules 文件夹,然后再执行一遍安装命令,就会出现下面这种情况:

image.png

我们看到 npm 并没有去远程拉去资源,因为package-lock.json 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。

2、缓存

首先我们先查看下缓存的位置,我们使用 npm config get cache 就行, mac 上一般在我们的 /User/username/.npm下,我们进入到这个文件夹下:

image.png 这个文件夹下有一个 _cacache 文件夹,里面就保存了我们的缓存信息,进入发现会有三个文件夹:

  • content-v2 content-v2 中存放的是压缩后的包文件,我们进入到其中一个包的文件夹下:

image.png 发现最后有一串字符,其实这个就是这个包的压缩包,我们解压一下:

image.png

先是是不是很熟悉了,解压后果然是这个包对应的文件,我们可以使用 vim 来查看 index.js 文件。

  • index-v5

index-v5 中存放的其实是 content-v2 文件的索引,当我们通过 npm install 去安装依赖时,如果有 lock 文件,就会将这个依赖对应的 integrityversionname 取出来生成一个唯一的 key, 这个 key 就能对应上 index-v5下的缓存记录,如果 index-v5 种存在这个 key,就会取到它对应的 value,即包的 hash 值,然后根据这个 hash 值去 context-v2 中寻找对应的压缩包,然后就可以通过 pacote 将这个压缩包解压到项目的 node_mosules 中,这样就可以省去网络请求和包校验的时间。

我们可以用 vim 查看下 index-v5 下文件中的内容:

image.png

五、为什么我们能直接运行 npm?

npm 其实就是一个命令行工具,我们在安装 node 环境的的时候会自动将这个工具下下来并安装到全局,我们在终端执行 npm 的时候就会找到 npm 对应的 bin 文件并执行里面的代码。

一般我们通过 npm install -g xxx 安装的包都会保存在全局的 node_modules 中,那这个 node_mosules 在哪呢?我们可以执行下面这个命令进行查看:

npm config get prefix

// 地址
/Users/username/.nvm/versions/node/v16.14.2

因为我使用了 nvm 来管理 node 的版本,所以每个版本的 node 都有对应的 node_mosules,我们进入对应版本的 lib 文件夹下我们就能看到了。

image.png

我们进入 npm 对应的文件夹,使用 vim 打开他的 package.json 就能看到 npm 对 bin 属性的配置:

image.png

我们执行 npm/npx 都是在执行对应文件的代码。

看到这里有同学就要问了,全局安装我知道了,但是有的时候我们也可以本地安装对应的命令行工具,比如 @babel/cli,我们也可以通过在 package.json 中的 script 配置运行命令:

{
    scripts: {
        babel: babel ./xxx
    }
}

// npm run babel 运行

那此时为什么能运行呢?它和直接在命令行运行 babel 有什么区别呢?

当我们执行 npm run babel 时 npm 首先会去项目的 node_mosules 文件中寻找 .bin 文件,然后在 .bin 文件中寻找 babel 这个命令对应的文件,如下所示:

image.png

我们看到这个文件其实是个软链接文件,他链接的是 @babel/cli 这个包的 bin 文件中的 babel.js 文件,找到这个文件之后就会执行这个文件中的代码,这就完成了一次命令的执行。

往期好文

深入理解React-router6实现原理