index

一文讲透 peerDependencies

· 12min

前言

这是笔者撰写的第一篇前端相关的文章,如有错误,欢迎在评论区指正!

初识 --legacy-peer-deps

在前端开发中,我们肯定遇到过如下场景:

某一天,小豪接手了一个同事的项目,他将项目代码从gitlab上拉了下来,然后执行npm install大法,等待着依赖安装完毕。但是,没过多久,控制台便出现了一大片刺眼的红色:

_image-20240526200003496.png

小豪很清楚,这是由于版本问题导致的依赖冲突。不过,控制台的报错也给了对应的解决方案:npm i --force或是npm i --legacy-peer-deps

但是,我们都知道,npm i --force并不是一个很好的解决方案,它会忽略冲突和警告,以及版本锁定策略(package-lock.json),重新生成依赖树。并且这份新的依赖关系很可能只在你的开发环境下可以运行,既不利于团队协作,也可能影响CI/CD环境的版本不一致。从长远来看,这并不是推荐的解法。因此,小豪选择了另一个:npm i --legacy-peer-deps。果不其然,在执行完这行命令后,依赖被顺利的安装了下来,也没有造成依赖版本的大范围重写。

那么,为什么使用--legacy-peer-deps就可以解决上述的依赖冲突呢?它是否会引入其它的潜在问题呢?它又与我们本文要聊的peerDependencies又有什么关系呢?以及为什么npm要有peerDependencies这个字段呢?我们将在下文中一个个揭晓。

什么是peerDependencies(同级依赖)

用比较官方的话术来讲,peerDependencies是一种在npm包中声明的依赖关系。它用来表示当前包的消费者(即依赖该包的另一个包/项目)应该安装的依赖包版本。而当前包与当前包的消费者即构成了“同级依赖”:两个包必须共享同一个依赖版本。

抽象的概念并不便于理解,我们直接上代码示例:

{
  "name": "my-package",
  "version": "1.0.0",
  "peerDependencies": {
    "vue": ">=3.0.0"
  }
}

如上代码意味着,使用my-package的项目也必须安装兼容版本的vue(版本大于等于3.0.0)。而此时vue也就是我们说的同级依赖

当我们在安装项目依赖时,npm会检查peerDependencies与当前的项目依赖是否符合,若不符合且无法兼容则会抛出错误,并终止此次依赖安装。

具体作用

业务开发的前端同学可能很少会去指定peerDependencies字段。而peerDependencies也主要是为库/插件作者提供帮助。

我们来看如下场景:

某一天小豪接到了一个需求,需求的主要内容是开发一个webpack插件(我们暂且将其命名为awsome-webpack-plugin)。而这个插件依赖了webpack5的一些特性,因此需要确保使用awsome-webpack-plugin的项目安装了webpack5。于是小豪在插件包的package.json中添加了如下代码:

// awesome-webpack-plugin/package.json
{
  "name": "awesome-webpack-plugin",
  "version": "1.0.0",
  "peerDependencies": {
    "webpack": "^5.0.0"
  }
}

这样就避免了插件在不兼容的版本中运行时可能出现的问题。

再遇--legacy-peer-deps

但是,插件的作者有时候或许不是方方面面都能考虑到的。接下来再看另一种有点特殊的情况:

小豪这次又接到了一个需求,同样是做webpack插件。但这次插件的功能十分简单,只是做一些简单的文本复制功能(我们暂且将其命名为simple-copy-plugin)。可以看出该插件其实是与webpack版本无关的。但小豪作为插件作者,深知该插件当前仅被使用在一个技术栈较老的项目中,而该项目的webpack版本是4.x。不过小豪出于稳妥起见,还是指定了同级依赖:

// simple-copy-plugin/package.json
{
  "name": "simple-copy-plugin",
  "version": "1.0.0",
  "peerDependencies": {
    "webpack": "^4.0.0"
  }
}

这样导致的后果就是,非兼容版本的项目无法安装该插件。但该插件本不应被指定版本(因为在webpack4和5中都可以正常运行)。

那么,怎么解决这种情况呢?回到本文的开头:--legacy-peer-deps

peerDependencies的处理策略

在此之前,先介绍一下不同的npm版本对于peerDependencies的处理策略。

npm 7 之前

  • 声明但不自动安装peerDependencies 仅作为一种声明,npm 不会自动安装这些依赖。开发者需要手动安装并管理这些依赖。
  • 安装警告:如果 peerDependencies 没有满足要求,npm 会在安装时发出警告,但不会阻止安装过程。

这种方式的优点是避免了自动安装引起的冲突,但需要开发者手动管理依赖,增加了管理的复杂性。

npm 7 之后

  • 自动安装:npm 7 开始,peerDependencies 会自动安装。这简化了依赖管理,减少了开发者的手动操作。
  • 严格验证:npm 7 会严格验证 peerDependencies 的版本要求。如果版本不兼容,会导致安装失败,从而确保所有依赖的版本一致性。

自动安装和严格验证虽然简化了依赖管理,但在遇到版本冲突时也更容易导致安装失败。

--legacy-peer-deps参数的作用就是:告诉npm在处理 peerDependencies 时,采用旧版本(npm6及之前)的行为方式,忽略掉这些冲突,让依赖安装可以正常进行。

依赖冲突与包管理器

其实我们在日常开发中可以发现,大部分依赖报错都是在使用npm时引起的。而为什么使用yarn和pnpm则不太会发生类似的情况呢?这就不得不讲讲不同包管理器的依赖管理策略了。

npm

  • npm 尝试将所有的依赖安装在项目的根目录下,这种方式叫做“依赖扁平化”。
  • 当不同的依赖需要同一个库的不同版本时,npm 可能会将某些版本安装在子节点的 node_modules 中。这种处理方式可能会导致依赖冲突和版本不一致的问题。
  • npm v7+ 引入了自动安装 peerDependencies 的特性,这在某些情况下会导致更多的依赖冲突和安装问题。

yarn

锁文件与一致性

  • Yarn 强调确定性和一致性,它会创建一个 yarn.lock 文件来精确记录每个依赖的版本。这样确保了每次安装的依赖版本都一致。
  • Yarn 在处理 peerDependencies 时更为宽松,不会自动安装它们,而是要求用户在主项目中明确指定这些依赖,减少了冲突的发生。

package-lock.json

这时或许有的小伙伴会说了,npm安装依赖时不是也会生成 package-lock.json吗?这个文件就是用来锁定依赖版本的呀!但其实很多时候我们发现,重新执行npm i命令时,也可能引起package-lock.json文件的更新。

因为npm是遵循语义化版本规范的,在安装依赖时,会尽可能去满足规范约束的范围内的最新版本。例如,如果 package.json 中指定了某个依赖的版本为 ^1.0.0,而该依赖在更新后推出了 1.1.0,则 npm install 可能会更新到 1.1.0

如果想让npm完全按照 package-lock.json去安装依赖,可以选择npm ci,但该命令一般多用于CI/CD环境。本地开发拉取依赖时不推荐该做法。

yarn在这方面做的就比npm好很多,完全按照 yarn.lock 文件记录的版本去安装对应依赖。

依赖树优化

  • Yarn 的依赖树结构优化较好,通过更好的算法来处理依赖关系,减少版本冲突和重复安装的问题。

pnpm

全局存储(store)+ 硬链接 + 符号链接:

  • 全局存储(内容寻址):pnpm 会把下载到的包文件放进全局的 store(按内容哈希组织)。多个项目/多个版本范围只要最终解析到同一份文件内容,就可以复用这份 store 文件,节省磁盘空间。
  • 硬链接(或 reflink)复用文件:安装时,pnpm 通常不会把 store 里的文件“symlink 到项目里”,而是把 store 里的文件**硬链接(hardlink)**到项目的“虚拟仓库”目录(一般是 node_modules/.pnpm/)里;因此你在项目里看到的是真实文件(硬链接指向同一个 inode/内容),读取性能好、也更符合很多工具对“真实文件”的预期。
  • 最外层用符号链接(symlink)拼装依赖图:项目最外层的 node_modules/<pkg> 往往是一个 symlink,它指向 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>。pnpm 用这种方式把“包本体”放在 .pnpm 里,再用 symlink 在顶层把依赖关系“接出来”。

严格的模块隔离

  • 模块隔离(更接近 Node 的真实解析规则):pnpm 的 node_modules/.pnpm/<pkg>@<version>/node_modules/ 下,只会放该包的直接依赖(以及它们继续向下的依赖)。这会让“没声明在 dependencies 里却能被 require/import 到”的情况更容易暴露出来。
  • 减少幽灵依赖与版本冲突:因为依赖不会被无差别提升(hoist)到顶层,项目里不太容易出现“某个包意外用到了别的包的依赖版本”的幽灵依赖问题;同一个依赖的不同版本也能更清晰地共存。

总结

对于大型项目和复杂依赖,还是推荐使用pnpmyarn进行管理。尤其是pnpm,不仅仅在规避依赖冲突上有着很大的优势,在节省磁盘空间、依赖安装速度、天然支持Monorepo等方面都有得天独厚的优势。