背景描述
很多人第一次开发 NPM 包时因为对 dependencies、devDependencies 和 peerDependencies 三者了解不是很清楚,导致自己的 NPM 包出现了很多依赖的问题。
本文意在从根本上让大家了解 dependencies、devDependencies 和 peerDependencies 的来龙去脉和区别,让各位 NPM 包开发者明白其原理。
从结构上说会先介绍 NPM 包查找规则,然后深入各个包管理工具在安装包后的表现,然后引出影子依赖和多版本的问题,最后再介绍 dependencies、devDependencies 和 peerDependencies 三者区别以及如何正确使用。
NPM 包查找规则
dependencies、devDependencies、peerDependencies 和 NPM 包的查找规则密切相关,这是区分三者的基础知识,如果不搞懂 NPM 包的查找规则,在排查问题的时候也是会一头雾水。
按照 官方文档 描述:如果传递给 require() 的模块标识符不是 core 模块,并且不是以 '/'、'../' 或 './' 开头,则 Node.js 从当前模块的目录开始,并添加 /node_modules,并尝试从中加载模块。如果在那里找不到它,则它移动到父目录,依此类推,直到到达文件系统的根目录。
也就是 Node.js 首先判断它是不是 Node.js 自带的 core 包,如果不是,则继续判断是否为相对路径或者绝对路径,如果都不是那就只能是第三方的 NPM 包。则它就会按照如下规则查找:
- 先去项目目录下的 node_modules 找,如果能找到就返回
- 找不到的话,向上级目录找,直到找到
/node_modules
例如我在 /home/jack/demo/index.js 里面有下面一段代码:
import { useHistory } from 'react-router-dom'
它的查找规则就是:
- /home/jack/
demo/node_modules/react-router-dom: 先看看项目下的 node_modules 是否存在 - /home/
jack/node_modules/react-router-dom:再看父级目录的 node_modules 是否存在 - /
home/node_modules/react-router-dom:同上 /node_modules/react-router-dom:同上
你可以使用 require.resolve.paths API 查看解析的过程,例如: console.log(require.resolve.paths('react-router-dom'))。
包管理工具的区别和影响
为什么我们并没有直接安装 react-router 却在安装完 react-router-dom 后能直接从 react-router 中引用内容呢?核心就是因为包管理工具导致的。
我们更加简化一下,以 react-router 为例,它的 package.json 如下:
{
"dependencies": {
"@remix-run/router": "1.6.2"
},
"devDependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"react": ">=16.8"
},
}
我们从上图可以看到它三个类型都有,有 1 个 dependencies、1 个 devDependencies 和 1 个 peerDependencies。
而 @remix-run/router 自己是 0 依赖,react 则有依赖了 loose-envify,而 loose-envify 依赖 js-tokens,也就是 react -> loose-envify -> js-tokens。
包管理工具的表现
我们分别用三个包管理工具执行安装命令:
npm(9.5.1)
- npm 包管理器项目下有 5 个 npm 包,对应我们上面的分析。
- 把所有的依赖都打平到 node_modules 下面
- 安装了
peerDependencies
Yarn(1.22.19)
- Yarn 包管理器项目下有 2 个 npm 包。
- 把所有的依赖都打平到 node_modules 下面
- 没有安装
peerDependencies
pnpm(8.5.0)
- pnpm 项目下有 1 个 npm 包和一个
.pnpm的文件夹 - 自身的依赖并没有打平到 node_modules 下
需要注意的是:
.pnpm并不在 Node.JS 模块查找规则上,所以在项目下的文件require时仅能用到react-router这一个包react-router是一个软链接,它链接到了.pnpm/react-router@6.11.2_react@18.2.0/node_modules/react-router
它的真身如果需要查找自身依赖 @remix-run/router,按照 Node.JS 的模块查找规则,就会向父级目录查找,父级目录下的 node_modules 是有这个包的,也就能够查找到自己的依赖。
回答最开始的问题,为什么我们没有安装某个包,但却可以直接引用,就是因为使用 yarn 和 npm 做包管理工具。
影子依赖问题
但是没有安装却能够引用,这样真的好吗?
按到是否定的,它会产生所谓的影子依赖问题。
影子依赖的危害
编辑器无引入提示
我们再安装一个 antd,可以看到 VSCode 仅会提示 package.json 中有的 2 个包,其他的依赖都不会出现在提示列表里,这也是很多使用 @ant-design/icons 从中引入 icon 的时候总是没有类型提示的原因了。
不稳定问题
例如 webpack 的dependencies 依赖了 lodash,所以你就直接使用了 lodash 而没有安装, 有可能出现以下问题:
webpack升级大版本,直接把lodash换成了lodash-es这个包,也就是lodash这个包直接删掉了,就会导致你的项目突然跑不起来。webpack在自己升级小版本的时候,却把lodash升级了大版本,它自己内部的代码做了对应的改造不会有问题,但是你外面项目的使用却因为 break change 大概率会导致报错。
多版本问题
多版本问题是指,同一个包,一个项目里安装了不同版本,例如我们在上面的例子,react-router 依赖的 @remix-run/router 是 1.x 版本,我们在项目下再安装一个 0.x 的版本。
npm 应对多版本
npm 包管理工具将 react-router 里面依赖的 @remix-run/router 放到了自己的目录下的 node_modules,按照 Node.JS 模块查找规则会优先查找自己目录下的 node_modules,从而确保了 react-router 的 @remix-run/router 引入的是 1.x 的版本。
而项目下的 @remix-run/router 按照查找规则,会先到项目的 node_modules 里查找,找到的就是正确的 0.x 的版本。
Yarn 应对多版本
Yarn 的解决方案同上。
pnpm 应对多版本
pnpm 对于项目下的,按照查找规则,会找到 node_modules 下 0.x 的版本,0.x 的版本再 link 到 .pnpm 对应正确的目录。
对于 react-router 里面依赖的 @remix-run/router,按照查找规则,自己目录下没有,向父级目录查找的过程中就找到了对应正确版本的 @remix-run/router。
总体而言,包管理器默认情况下都能正确解决多版本问题。
dependencies、devDependencies 和 peerDependencies 的特点
以下对于 npm 开发者简称开发者,对于 npm 包的使用的项目简称使用者。例如尤雨溪是 VueJS 的作者(开发者),公司某个项目 npm install vue 了 Vue,那么这个项目就是使用者。
注意对于我们的业务项目而言,是不需要区分 dependencies、devDependencies,实际情况是安装到哪里都可以的,也不需要设置 peerDependencies,三者只针对于 npm 包的开发者 而言才需要明白。
dependencies
如果一个依赖是 src 源码中会使用到的,那么它有可能放到 2 个地方,devDependencies + peerDependecies(稍微讲)或者 dependencies。
举例:react-router-dom 的源码 src 使用到了 react-dom,则 react-dom 应该放到 dependencies。
特点
对于 npm 包而言,其有以下特点:
- 对于开发者而言,如果需要对外暴露
dependencies的内容,应该重新导出dependencies的 API - 对于使用者而言,在进行
npm install 包名的时候会自动把dependencies安装上 - 对于使用者而言,其不需要关注
dependencies的包的版本和内容
举例1:react-router-dom 为例,其 dependencies 依赖了 react-router:
- 对于开发者而言,如果需要暴露
react-router的内容,应该将其 API 重新导出,而不是让用户import { useHistory } from 'react-router'
react-router/index.tsx at main · remix-run/react-router · GitHub
- 对于使用者而言,在进行
yarn add react-router-dom的时候会自动把dependencies安装上
在安装 react-router-dom 的时候会自动安装 react-router,使用者不需要关心 react-router 的版本和 API。
- 对于使用者而言,我们不需要关注
react-router的 API 和版本,仅需要关注react-router-dom
举例2:nestjs 依赖了 express。
我们使用 nestjs 的时候并不需要知道它使用了哪个版本的 express,只需要关注 nestjs 的 API 即可。
依赖重导出
如果你采用这种方式处理依赖,那么就需要关注的一个问题就是依赖重导出。
所谓依赖重导出就是 A 包依赖了 B 包,如果 B 里面有一些函数或者方法不需要做任何修改直接暴露给外部,则你需要重新导出一下,这样外部才能直接用,并且你还需在文档中将这些函数和方法进行说明。
案例1: MemoRouter 这个 API 就是 react-router 定义和实现的,由 react-router-dom 导出,并且 react-router-dom 的文档要对这个文档做说明。
案例2:koa 依赖了 http-errors 这个包,并且想对外暴露,所以直接导出了。
上面的注释也写的很明白,导出 http-errors这样消费者就不用自己再安装一个 http=errors。
devDependencies
devDenpendencies 有 2 个场景:
- 一个是
peerDependencies + devDependencies,这个稍后会讲 - 另一个则是仅开发时用的包,src 不会引入到,那么就应该放到
devDependencies
举例:我们使用 vite 打包 npm 包,vite API 是不会在 src 的源码中出现的,它只是一个开发时打包的工具,我想这个应该比较好理解,这里就不做过多解释了。
peerDependencies
peerDependencies 的作用主要是用来做版本检查的,例如 react-router 的 peerDependencies 是 react>=16.8.0,那么就会出现 3 种情况:
- 项目下压根没有安装
react - 项目下的
react符合要求,确实>=16.8.0,比如17.0.2 - 项目下的
react不符合版本要求,比如16.0.0
项目下没有预先安装 peerDependencies 包时,不同的包管理工具表现有所不同
- Yarn:不会安装
peerDependencies的依赖 - npm:会安装
peerDependencies的依赖,并且提取到 node_modules 下 - pnpm:会安装
peerDependencies的依赖,但不会提取到 node_modules 下
以上结论,我们在上面关于包管理工具的截图中已明显可看出。
项目下已有 peerDependencies 所需的包,且版本符合要求
各个包管理工具表现一致,都是按照项目下的包来的,比如上面 react 就是会是 17.0.2 并且自己项目的 node_modules 下也不会有另一个 react。
项目下已有 peerDependencies 所需的包,但版本符合不要求
npm 安装会直接报错,并且安装不成功。
yarn 会报一个不够明显的警告,但安装是成功的。
pnpm 会报警告,但安装是成功的。
peerDependencies + devDependencies
场景说明
讲完了三者的作用和特点,还留了一个问题就是当源码 src 中使用了某个包,但是我们却不将其放到 dependencies 里面,而是放到 devDependencies + peerDependencies 的组合里。
那到底如何才能区分这两种情形呢?
我个人理解的是,你如果是基于某个包做开发的,或者说项目主体肯定会有的,那么就应该放到 devDependencies + peerDependencies。
举例1:我要封装一个基于 react 的 hooks 并发布 npm 包,那么 react 就应该是 peerDependencies 而非是 dependencies,虽然包的源码里会使用到 react 的 API,但我知道项目的主体是一定会安装 react 的。
举例2:我要封装一个基于 antd 的业务组件库并发布 npm 包,那么 react 和 antd 都是要作为 peerDependencies 而非是 dependencies,虽然包的源码里会使用到 antd 的组件,但我知道项目主体一定会先安装 antd 的。
为什么要加上 devDependencies?
加到 peerDependencies 的包,在 yarn 包管理工具下就不会自动安装,所以在 npm 开发过程中,为了在开发过程中所需要的依赖一定存在,我们需要将其添加到 devDependencies。
举例,我们是基于 antd v4 封装组件,那么我们的 package.json 就应该这样配置:
{
"devDependencies": {
"antd": "4.24.0"
},
"peerDependencies": {
"antd": ">=4.0.0 <5.0.0"
}
}
同时需要一定要注意打包不要 peerDependencies 源码打进去,例如 vite 打包的时候,一定要配置 external 属性。
dependencies VS devDependencies + peerDependencies
在某些场景下两者使用场景区分十分明显,例如:
- 各种插件 npm 包的开发,对于其依赖核心一定是
devDependencies + peerDependencies,例如 babel 插件依赖的@babel/core、antd 自定义组件依赖的antd、react 组件库对react的依赖都是这种场景。 - 直接依赖,外部使用者完全不需要知道。例如
nestjs对express和fastify,你只需要看 nest.js 的 API 文档即可,完全不需要了解什么是express和fastify,这种场景就适合dependencies。
但是有些场景就有些拎不清:
案例1:我今天基于 antd 开发一套新的组件库,那么到底是直接将 antd 放到 dependencies 还是放到 devDependencies + peerDependencies?
案例2:对于 formily 的使用者而言,如果它想使用 antd + formily 的组合,可能就需要安装 @formily/core、@formily/react、@formily/antd,难道它不能做到只安装一个 @formily/antd 把 @formily/core 和 @formily/react 放到 dependencies 里面吗?
针对上述两个案例,我想说的是都行,并且也都有这么做的,核心看你怎么设计,比如:
- chakra-ui 开发了一套跨技术栈 UI 框架,其中 ark ui 就是对 zag ui 的封装,外部并不会感受到 zag UI ,所以它把 zag 依赖放到了 dependencies。
- pro-components 是对 antd 的二次封装,但是仅是部分封装和增强,所以它将 antd 依赖放到了 devDependencies + peerDependencies。
个人总结:
- 如果你希望别人无需了解你依赖的依赖,文档都从你这里看,则放 dependencies;
- 如果你希望别人了解你依赖的依赖,依赖的文档还看它自己的,那就放 devDependencies + peerDependencies。
依赖更新问题
当然如果你采用 devDependencies + peerDependencies 还有一个额外的问题需要注意,就是依赖问题。
假定 pro-components 的 peerDependencies 设置的是 antd>=5.0.0,并且它开发的时候 devDependencies 安装的是 5.2,开发完发布 npm 也完全没问题。
但是过段时间 antd 升级了 5.6 引入了一个 break change,而用户项目安装就是 5.6,是满足 peerDependencies 的,但是确实也会导致你的 pro-components 出现 BUG,此时你唯一能做的就是发布一个新版本,依赖最新版的 antd 进行开发并修复。
当然如果是 dependencies 就不会出现这个问题,因为你直接用的就是开发时候的那个版本,用户安装的也是,所以永远不会错。
resolutions 和 alias
把上面所有知识都了解了是否一定能解析对,也不一定,因为 resolutions 和 alias 能够逆天改命,不遵循上面的过程。
resolutions
resolutions 是 package.json 中的一个配置,用于强制覆盖版本,例如整个项目里 lodash 有 4.17.21、4.0.5 和 4.0.0 三个版本,如果我们并不想安装三个版本的 lodash,所有的版本统一为 4.17.21,那么我们可以这样写:
{
"resolutions": {
"lodash": "4.17.21"
}
}
这样会把整个项目所有的 lodash 改为同一个版本 4.17.21。
alias
现在打包工具,例如 webpack、vite、rollup 都提供了 alias 的配置,通过这一个配置可以达到上面 resolutions 一样的效果,也就是将一个包或者文件,修改为指定的目录。例如 umi.js 下面这些包的处理:
比如你想升级 react-router,抱歉那是肯定不行的,因为它已经固定为自己目录下的某个版本了。
结语
通过上面的讲解如果还没很明白的话,建议多看看一些有名的开源项目他们的 package.json 是怎么写的,同时如果是新项目的话,建议使用 pnpm 进行包管理,能够有效避免影子依赖等问题。