npm 包开发者必修课:彻底搞懂 dependencies、devDependencies和peerDependencies

9,556 阅读11分钟

背景描述

很多人第一次开发 NPM 包时因为对 dependenciesdevDependenciespeerDependencies 三者了解不是很清楚,导致自己的 NPM 包出现了很多依赖的问题。

本文意在从根本上让大家了解 dependenciesdevDependenciespeerDependencies 的来龙去脉和区别,让各位 NPM 包开发者明白其原理。

从结构上说会先介绍 NPM 包查找规则,然后深入各个包管理工具在安装包后的表现,然后引出影子依赖和多版本的问题,最后再介绍 dependenciesdevDependenciespeerDependencies 三者区别以及如何正确使用。

NPM 包查找规则

dependenciesdevDependenciespeerDependencies 和 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)

image.png

  • npm 包管理器项目下有 5 个 npm 包,对应我们上面的分析。
  • 把所有的依赖都打平到 node_modules 下面
  • 安装了 peerDependencies

Yarn(1.22.19)

image.png

  • Yarn 包管理器项目下有 2 个 npm 包。
  • 把所有的依赖都打平到 node_modules 下面
  • 没有安装 peerDependencies

pnpm(8.5.0)

image.png

  • 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

image.png

它的真身如果需要查找自身依赖 @remix-run/router,按照 Node.JS 的模块查找规则,就会向父级目录查找,父级目录下的 node_modules 是有这个包的,也就能够查找到自己的依赖。

回答最开始的问题,为什么我们没有安装某个包,但却可以直接引用,就是因为使用 yarnnpm 做包管理工具。

影子依赖问题

但是没有安装却能够引用,这样真的好吗?

按到是否定的,它会产生所谓的影子依赖问题。

影子依赖的危害

编辑器无引入提示

image.png

我们再安装一个 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/router1.x 版本,我们在项目下再安装一个 0.x 的版本。

npm 应对多版本

image.png

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 应对多版本

image.png

Yarn 的解决方案同上。

pnpm 应对多版本

image.png

pnpm 对于项目下的,按照查找规则,会找到 node_modules 下 0.x 的版本,0.x 的版本再 link 到 .pnpm 对应正确的目录。

对于 react-router 里面依赖的 @remix-run/router,按照查找规则,自己目录下没有,向父级目录查找的过程中就找到了对应正确版本的 @remix-run/router

总体而言,包管理器默认情况下都能正确解决多版本问题。

dependenciesdevDependenciespeerDependencies 的特点

以下对于 npm 开发者简称开发者,对于 npm 包的使用的项目简称使用者。例如尤雨溪是 VueJS 的作者(开发者),公司某个项目 npm install vue 了 Vue,那么这个项目就是使用者。

注意对于我们的业务项目而言,是不需要区分 dependenciesdevDependencies,实际情况是安装到哪里都可以的,也不需要设置 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'

image.png

react-router/index.tsx at main · remix-run/react-router · GitHub

  • 对于使用者而言,在进行 yarn add react-router-dom 的时候会自动把 dependencies 安装上

image.png

在安装 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 的文档要对这个文档做说明。

image.png

案例2:koa 依赖了 http-errors 这个包,并且想对外暴露,所以直接导出了。

image.png

上面的注释也写的很明白,导出 http-errors这样消费者就不用自己再安装一个 http=errors

devDependencies

devDenpendencies 有 2 个场景:

  • 一个是 peerDependencies + devDependencies,这个稍后会讲
  • 另一个则是仅开发时用的包,src 不会引入到,那么就应该放到 devDependencies

举例:我们使用 vite 打包 npm 包,vite API 是不会在 src 的源码中出现的,它只是一个开发时打包的工具,我想这个应该比较好理解,这里就不做过多解释了。

peerDependencies

peerDependencies 的作用主要是用来做版本检查的,例如 react-routerpeerDependenciesreact>=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 安装会直接报错,并且安装不成功。

image.png

yarn 会报一个不够明显的警告,但安装是成功的。

image.png

pnpm 会报警告,但安装是成功的。

image.png

peerDependencies + devDependencies

场景说明

讲完了三者的作用和特点,还留了一个问题就是当源码 src 中使用了某个包,但是我们却不将其放到 dependencies 里面,而是放到 devDependencies + peerDependencies 的组合里。

那到底如何才能区分这两种情形呢?

我个人理解的是,你如果是基于某个包做开发的,或者说项目主体肯定会有的,那么就应该放到 devDependencies + peerDependencies

举例1:我要封装一个基于 reacthooks 并发布 npm 包,那么 react 就应该是 peerDependencies 而非是 dependencies,虽然包的源码里会使用到 react 的 API,但我知道项目的主体是一定会安装 react 的。

举例2:我要封装一个基于 antd 的业务组件库并发布 npm 包,那么 reactantd 都是要作为 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 的依赖都是这种场景。
  • 直接依赖,外部使用者完全不需要知道。例如 nestjsexpressfastify,你只需要看 nest.js 的 API 文档即可,完全不需要了解什么是 expressfastify,这种场景就适合 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

把上面所有知识都了解了是否一定能解析对,也不一定,因为 resolutionsalias 能够逆天改命,不遵循上面的过程。

resolutions

resolutions 是 package.json 中的一个配置,用于强制覆盖版本,例如整个项目里 lodash 有 4.17.214.0.54.0.0 三个版本,如果我们并不想安装三个版本的 lodash,所有的版本统一为 4.17.21,那么我们可以这样写:

{
  "resolutions": {
      "lodash": "4.17.21"
  }
}

这样会把整个项目所有的 lodash 改为同一个版本 4.17.21。

alias

现在打包工具,例如 webpackviterollup 都提供了 alias 的配置,通过这一个配置可以达到上面 resolutions 一样的效果,也就是将一个包或者文件,修改为指定的目录。例如 umi.js 下面这些包的处理:

image.png

比如你想升级 react-router,抱歉那是肯定不行的,因为它已经固定为自己目录下的某个版本了。

结语

通过上面的讲解如果还没很明白的话,建议多看看一些有名的开源项目他们的 package.json 是怎么写的,同时如果是新项目的话,建议使用 pnpm 进行包管理,能够有效避免影子依赖等问题。