在我们的代码里面,经常会用到各种第三方软件包,当引用的包越来越多时,管理好代码中的依赖是很有必要的。主流的包管理工具有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安装机制
- 执行npm install,获取npm配置(下文)。
- 如果项目没有package-lock.json,则获取package.json中的根依赖信息,并递归查询子依赖,构建完整的依赖树信息,保存在package-lock.json。
- 如果项目有package-lock.json,则检查package-lock.json和package.json声明的依赖是否一致且版本是否兼容:如果依赖一致且版本兼容,则按package-lock.json加载依赖;否则,按package.json加载依赖并更新package-lock.json。
- 加载依赖时,会先检查缓存,有需要再从网络加载。
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文件记录安装的依赖树信息和明确的版本信息,通过锁版本来保证一致性。