npm是如何管理代码依赖的

834 阅读6分钟

在我们的代码里面,经常会用到各种第三方软件包,当引用的包越来越多时,管理好代码中的依赖是很有必要的。主流的包管理工具有npm和yarn,它们又是如何实现依赖管理的呢?

package.json

首先,我们需要把项目的依赖项描述出来。

// package.json文件
{
  "name": "npm-demo",
  "version": "1.0.0",
  "dependencies": {
    "axios": "^0.27.0"
  }
  "devDependencies": {
    "eslint": "^8.0.0"
  }
}

常见的依赖有以下三种:

  • dependencies是项目依赖,作为项目软件包的一部分,例如网络请求工具axios。
  • devDependencies是开发依赖,不会被打包到项目软件包,例如代码规范检查工具eslint。
  • peerDependencies是对等依赖,一般用于插件包要求宿主环境也安装指定版本的依赖。例如,react-ui组件库需要宿主环境提供指定的React版本来搭配使用,则react-ui的package.json文件会有以下配置:
"peerDependencies":"^17.0.0"

构建依赖树

根据package.json中的根依赖,递归查询每个包的具体版本和子依赖,获取到完整的依赖树信息。

嵌套结构

早期的npm使用嵌套结构来构建依赖树:即每个模块的子依赖都被安装到模块下的node_modules目录。

这个方案符合依赖之间的嵌套关系,但会导致重复下载依赖树层级过深

// 嵌套结构
node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0

例如上图,A、D模块都依赖了B@1.0.0,重复下载了两次。这不仅会导致安装速度慢,也会占用内存

扁平化结构

// 扁平化结构
node_modules
├── A@1.0.0
│── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0

优化方案就是扁平化处理,如上图。构建依赖树时,优先将依赖放在项目根目录的node_modules下。在这个过程中,会递归查询上级node_modules,如果依赖树已有兼容版本的模块就不再重复下载,如果版本冲突了就在当前模块的node_modules下安装依赖。提高了安装速度和解决了依赖树层级过深的问题。

引入的新问题是: 可以非法访问未在package.json中声明的包。

npm dedupe

因为模块的安装顺序会影响依赖树的结构,所以扁平化结构还是会存在重复下载的情况。

node_modules
├── A@1.0.0
│── B@1.0.0
├── C@1.0.0
    └── node_modules
        └── B@2.0.0

如上图,A@1.0.0依赖了B@1.0.0,C@1.0.0依赖了B@2.0.0。此时如果再安装D@1.0.0依赖了B@2.0.0,那么依赖树如下:

node_modules
├── A@1.0.0
│── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
│   └── node_modules
│       └── B@2.0.0

结果是C@1.0.0和D@1.0.0有重复的B@2.0.0。但如果C@1.0.0比A@1.0.0先安装,那么B@2.0.0就能被提到上级node_modules被复用。

对此,提供了npm dedupe指令,进行全局去重: 遍历依赖树,把重复的兼容版本移动到项目根目录的node_modules下,利用set数据结构进行去重。

package-lock.json

锁版本

使用范围版本可以在更新依赖时自动升级版本,让软件包支持最新的功能和修复已知的bug。但这会引入问题,即同一份package.json配置安装的依赖版本可能不一致。 所以,需要把明确的版本信息保存在package-lock.json,下次安装时读取package-lock.json配置进行安装,保证版本一致性

依赖树

package.json保存了项目的根依赖,由于依赖也会有子依赖,所以需要递归查询子依赖,获取到完整的依赖树信息,这些信息会被保存在package-lock.json。下次安装时读取package-lock.json,不需要去远程仓库查询信息了,提高安装速度

// package-lock.json文件
 {   
    "name": "npm-demo",
    "version": "1.0.0",
    "lockfileVersion": 1,
    "requires": true,
    "dependencies": {
        "axios": {
            // 根据package.json指定的动态版本"axios": "^0.27.0",找到最新的具体版本"0.27.2"
            "version": "0.27.2",
            "resolved": "https://registry.npmmirror.com/axios/-/axios-0.27.2.tgz",
            "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
            // 递归查询获取子依赖,构建依赖树
            "requires": {
              "follow-redirects": "^1.14.9",
              "form-data": "^4.0.0"
            }
        },
       "eslint": {
            "version": "8.23.1",
            "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.23.1.tgz",
            "integrity": "sha512-w7C1IXCc6fNqjpuYd0yPlcTKKmHlHHktRkzmBPZ+7cvNBQuiNjx0xaMTjAJGCafJhQkrFJooREv0CtrVzmHwqg==",
            "dev": true,
            "requires": {
              "@eslint/eslintrc": "^1.3.2"
            }
      },
      "form-data": {
        "version": "4.0.0",
        "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.0.tgz",
        "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
        "requires": {
          "asynckit": "^0.4.0",
          "combined-stream": "^1.0.8",
          "mime-types": "^2.1.12"
        }
      }
    }
}

npm安装机制

  1. 执行npm install,获取npm配置(下文)。
  2. 如果项目没有package-lock.json,则获取package.json中的根依赖信息,并递归查询子依赖,构建完整的依赖树信息,保存在package-lock.json。
  3. 如果项目有package-lock.json,则检查package-lock.json和package.json声明的依赖是否一致且版本是否兼容:如果依赖一致且版本兼容,则按package-lock.json加载依赖;否则,按package.json加载依赖并更新package-lock.json。
  4. 加载依赖时,会先检查缓存,有需要再从网络加载。

npm缓存机制

在前端项目中,每次安装依赖时都从网络加载资源,将会增加安装时间成本。对于这类问题,通过缓存,可以减少一些不必要的重复下载。

npm加载依赖时,根据package-lock.json中的name,version,integrity生成一个唯一的key。这个key就是对应本地缓存数据的索引。如果本地有缓存,则直接返回缓存数据;否则,再去请求网络资源,并把下载回来的数据进行缓存。

npm实战

提交lock文件到代码仓库

  • 如果开发一个应用,建议将package-lock.json提交到代码仓库。这样可以保证项目组成员开发或项目部署时,执行npm install或npm ci安装的依赖保持一致。
  • 如果开发一个给外部使用的库,发布package-lock.json会导致引用方执行扁平化处理时,无法复用已安装的兼容包,增加了包体积。建议同时提交package.json和package-lock.json,只发布package.json。

npx

在传统npm模式下,如果需要使用代码检测工具eslint,就要先进行安装:

npm install eslint --save-dev

然后在项目根目录下执行以下命令,或者通过package.json的npm scripts调用:

.\node_modules\.bin\eslint --init
.\node_modules\.bin\eslint yourfile.js

而使用npx时只需要以下两个步骤:

npx eslint --init
npx eslint yourfile.js

npx会自动去.\node_modules.bin路径和环境变量$PATH里检查命令是否存在。 npx在执行模块时会自动安装依赖,执行结束后会自动删除依赖。避免了全局安装带来的问题。

总结

在前端开发过程中,管理好项目的代码依赖是很有必要。依赖管理的核心是构建依赖树:即根据package.json中的根依赖,递归查询每个包的具体版本和子依赖,获取完整的依赖树信息。

早期npm用嵌套结构来构建依赖树,存在重复下载和依赖树层级深的问题。后起之秀yarn使用扁平化结构进行优化,提高了安装速度和解决了依赖树层级过深的问题。以上方案还是会存在重复下载的问题,所以有了npm dedupe指令:对整个依赖树进行全局去重

版本一致性是依赖管理的另一个要点。主要通过package-lock.json文件记录安装的依赖树信息和明确的版本信息,通过锁版本来保证一致性。