Published on

Compare npm and yarn

Authors
  • avatar
    Name
    Pursue
    Twitter

目录

1 现状

在谈起 Node.js 的包管理器时,npm 和 yarn 肯定是讨论最多的。npm 往往被认为有些“小问题”且速度有些慢,但社区资源和支持强大; 而 yarn 由于其速度快,则被认为是比 npm 更好的替代品。但其实这个“刻板印象”早已经不正确了:事实上 yarn 和 npm 在速度上也没有什么优势,而 npm 以前 node_module 嵌套的问题也早 在 npm3 之后已经解决。所以,在 2023 年的今天,npm 和 yarn 到底还有什么差别,而我们又应该选择什么工具作为 Node.js 最佳的包管理器呢?

2 npm 依赖树的演进

2.1 臃肿的 npm 2

考虑以下依赖:

有三个 package,分别是 A(v1.0),B(v1.0)和 C(v1.0),A 依赖于 B,而 B 又依赖于 C。

如果此时把 A v1.0 引入到项目中,则项目的依赖树会呈现为如下结构:

这就是 npm 2 的依赖树解析。

这种结构的最大好处就是树的解析是完全按照“依赖谁就安装谁”的原则,清晰明了的还原了“依赖”和“依赖的依赖”之前的关系,即单纯从文件夹的结构,就能看出第三方依赖都有哪些依赖。

但就是这样简单“暴力”的解析算法,带来了两个严重的问题:

  • 过长的文件路径

如果依赖的层级过深,则嵌套的层级也就越深,这在 windows 系统下会因为文件路径过深导致报错。

  • 重复安装,臃肿的 node_modules

“依赖谁就安装谁”的原则导致任意依赖都会毫无保留的安装自己的依赖,即时在兄弟节点已经有过类似的安装。

尤其是第一个缺点,是致命的。因此,就有了 npm 3 的优化算法。

2.2 深思熟虑的 npm 3

  • 扁平化的依赖树

如果拿上面的例子举例子的话,npm 3 下的依赖树深度不再是三层,而是一层:扁平化的树结构会“尽量”让依赖都平铺在第一层。

  • 不可避免的嵌套

上面的例子比较简单,扁平化的结构看起来很清晰。但 npm 3 的策略只能是尽量扁平,因为很多时候同一个依赖在项目中会有多个版本,在这种 “冲突”下,npm 3 还是会视情况而继续嵌套。

假设现在多了一个依赖 D,它和 B 一样,也依赖于 C,但是是 v2.0。此时的依赖树会被解析如下:

因为 C v1.0 已经被平铺到了第一层,而 D 虽然也依赖于 C,但由于是不同版本,所以无法继续平铺到第一层(文件夹重名),因此只能选择嵌套。

不难看出,npm 3 的平铺策略并不是一定的,有时也会选择嵌套。

  • 不可预估的依赖树

假设再次引入两个新的依赖 E,F,它们分别依赖于 C v1.0 和 v2.0:

再次梳理下 C 依赖的关系:

C v1.0 在第一层是因为 A v1.0 和 E v1.0 都依赖于它。 而 C v2.0 分别被嵌套在了 D v1.0 和 F v1.0 里面。由于 C v2.0 无法被扁平化,所以出现了重复。

假设将 E 升级到 2.0 版本,而新版本的 C 依赖也是 2.0,由于 C v1.0 依然被 B 所以依赖,所以 E v2.0 只能继续重复安装 C v2.0:

到了这里我们需要暂停思考一下:有三个依赖都依赖于 C v2.0,而只有一个依赖依赖于 C v1.0,但是被扁平化的居然不是 C v2.0,这是不是有点不合理?

实际上这完全是因为我们这里的 demo 是一步一步从 ABC,再到 EF 这样的顺序来安装的,假设先安装 E v2.0 和 F v1.0,那么 C v2.0 一定会被放入第一层作为共享依赖。

所以,本地安装好的依赖树和线上的依赖树是有可能不同的,为了避免这种情况,npm 3 在解析依赖的时候会有严格的依赖解析顺序,保证只要是新项目,无论在哪里,根据 package.json 安装得到的依赖树一定是一致的。

  • 依赖去重

接着上面的例子,如果再次对 B 升级到 v2.0,那么就会出现下面的情况:

之前第一层的 C v1.0 由于 B 的升级,变为了 C v2.0,而第二层其他的 C v2.0 则继续重复嵌套。

此时使用npm dedupe就可以去重优化,得到一个干净的依赖树:

这里手动去重的原因还是因为构建顺序的问题:我们是渐进式的安装依赖和升级依赖,如果此时删除 node_modules 然后重新一次性安装, 则自然可以一次性得到最终优化过后的依赖树。

到了这里,我们可以总结下优化过后的 npm 3:

优点:

  • 解决了无限嵌套导致 windows 下过长路径的问题
  • 扁平化依赖,使得可以最大程度的被复用

缺点:

  • 由于被扁平化的依赖很有可能不是直接依赖(在 package.json 里的),而在项目中有可以直接访问,大大增加了误用的可能。
  • 树的算法过于复杂,扁平化的同时又不得不嵌套,复杂情况下还无法去重。

3 npm 3 的特有

3.1 严格的 peerDependencies

假设有如下 package.json:

{
  "dependencies": {
    "react-dom": "^16.8.0"
  }
}

之行 npm install 后的依赖树如下:

node_modules
├── js-tokens
├── loose-envify
├── object-assign
├── prop-types
├── react
├── react-dom
├── react-is
└── scheduler

可以看到,npm 自动安装了 react。这是因为 react-dom 的 peerDependencies 里声明了 react,换句话说,npm 默认会安装 peerDependencies。

所以当 peerDependencies 出现冲突时,npm install 也会被阻塞:

package.json
{
  "dependencies": {
    "react-dom": "^18.0.0",
    "react-redux": "^5.0.0"
  }
}

npm ERR! While resolving: module-compare@1.0.0
npm ERR! Found: react@18.2.0
npm ERR! node_modules/react
npm ERR!   peer react@">=17" from @reactflow/background@11.3.6
npm ERR!   node_modules/@reactflow/background
npm ERR!     @reactflow/background@"11.3.6" from reactflow@11.10.1
npm ERR!     node_modules/reactflow
npm ERR!   peer react@">=17" from @reactflow/controls@11.2.6
npm ERR!   node_modules/@reactflow/controls
npm ERR!     @reactflow/controls@"11.2.6" from reactflow@11.10.1
npm ERR!     node_modules/reactflow
npm ERR!   8 more (@reactflow/core, @reactflow/minimap, ...)
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! react-redux@"^5.0.0" from the root project
npm ERR!
npm ERR! Conflicting peer dependency: react@16.14.0
npm ERR! node_modules/react
npm ERR!   peer react@"^0.14.0 || ^15.0.0-0 || ^16.0.0-0" from react-redux@5.1.2
npm ERR!   node_modules/react-redux
npm ERR!     react-redux@"^5.0.0" from the root project
npm ERR!
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.

该如何修复呢?

  • --legacy-peer-deps

会让 npm 的 peerDependencies 策略回归到 npm 3 之前,即不自动安装 peerDependencies,需要由开发者自己安装。

  • --force

强制安装依赖,往往最终的安装结果不可预估,根据笔者的测试,发现它往往会安装最新版本的依赖。

比如上面的例子,冲突发生在 react 17 和 react 18 之间,则npm i --force之后,react 18 胜出:

npm ls react
npm ERR! code ELSPROBLEMS
npm ERR! invalid: react@18.2.0 /Users/xiaofei/projects/node-module-compare/node_modules/react
module-compare@1.0.0 /Users/xiaofei/projects/node-module-compare
├─┬ react-dom@18.2.0
│ └── react@18.2.0 invalid: "^0.14.0 || ^15.0.0-0 || ^16.0.0-0" from node_modules/react-redux
└─┬ react-redux@5.1.2
  └── react@18.2.0 deduped invalid: "^0.14.0 || ^15.0.0-0 || ^16.0.0-0" from node_modules/react-redux

3.2 依赖漏洞检测与修复

npm 在检测依赖安全漏洞和和修复漏洞的功能上还算比较完善。

npm audit用于检测漏洞。它会把当前项目的依赖上传至远端仓库源,根据源给出的反馈来生成报表。

npm audit fix则会根据远端的修复反馈进行依赖安装,没有任何参数的默认情况下,只会进行兼容性依赖的修复。如果漏洞较大需要修复 主版本和兼容性版本则需要指定参数--force

各版本号的作用如下:

主版本号(major):当进行不兼容的 API 更改时增加。

次版本号(minor):当向后兼容地添加功能时增加。

修订号(patch):当进行向后兼容的缺陷修复时增加。

4 yarn 的尴尬

yarn 在创建之初,也许的确有着某些优势,比如"下载支持并行,让安装更快", "依赖解析算法更加高效,减少不必要的冲突", "支持 workspaces,对 mono 项目更加友好"等。

但截至到 npm v7 和 yarn v1,两者之间在其实在体验上,yarn 并没有很显著的优势(也许下载能稍微快点)。 在解析树上,yarn 默认采用了扁平算法,而 npm 3 以后(含 3)也是这种类似的算法并没有什么大的区别。 在漏洞修复上,yarn 本身并不支持 audit fix,官方给出的回答是:

但是感觉很小众,start 不多。

  • 建议用 renovate 之类的工具自动处理

缺点很明显,本地无法修复,依赖 CI/CD。

  • 用 npm 来修复,修复后删掉 npm 的 package-lock.json 文件

疑问:那为什么不用 npm?

最后,一张表格来对比 npm 和 yarn,就会发现,yarn 的地位在现今来说还是比较尴尬的。

cachespeedworkspaceflatpeer dependencyaudit
npm v7支持较慢,非并行支持严格模式,默认安装可以检测漏洞并修复
yarn v1支持较快,并行支持不安全,有警告只能检测不能直接修复(需要依赖工具)

5 总结

本篇用了大部分章节来描述 npm,而对 yarn 的描述却很少。主要原因还是因为 npm 目前还是 Node.js 默认的 pacakge manager,再者笔者在整理这篇对比时,也并没发现 yarn 的“过人之处”(除了在安装时确实稍微一点儿)。 因此截止到 2023 年,如果要在 npm 和 yarn 里选择一个,我还是很比较看好 npm。

6 参考

npm 是如何工作的

npm 如何处理对等依赖