如何评价 node_modules 的设计?

24 岁,学生

439 👍 / 83 💬

问题描述

比如冗余问题还是不小的,路径过长无解,特别是当A -> node_modules -> B -> node_modules -> C -> node_modules -> A -> node_modules -> B ...这样的循环依赖的时候

node_modules 的问题(尤其是大小问题),主要其实不是技术问题,而是生态问题兼容性问题

实际上,依赖目录占用过多硬盘空间不止存在于 JS 生态中。像是 Python,如果你正经要做个业务项目的话,venv 也是避免不了的,它和 npm 采用相似的方式,也是为每个项目创建一个单独的依赖文件夹,同样有这个硬盘空间占用问题(其实 Python Poetry 也是会在你的 AppData 里边创建 venv,逻辑是一样的,只是不放在你的项目目录下而已)。而 Ruby 的 Bundler 也是同理。存在类似问题的编程语言其实非常多,绝对不止 JS 一个,包括 PHP 的 Composer 也有一个类似的 vendor 目录存放单个项目依赖,甚至说更小众的 Crystal 语言的 Shards 也是如此。

我敢说 node_modules 占用空间大的问题被讨论得这么多,很大程度上只是因为有太多人不得不用 JS,而在国内用 Java 的人又尤其多,如果习惯了 Maven 的全局依赖方式,对比之下就觉得好像 node_modules 很蠢——至于 Python 和(国内一直没什么人用的)Ruby,大多数人只是拿来写脚本,很多人可能都不知道 Python 是有 venv 的,所以压根不知道其实 pip 的逻辑比 npm 还蠢得多,自然也就没太多人在骂……


但是 Maven 也有自己的问题,也就是有些回答已经谈及的多版本依赖问题。这里说的多版本依赖是个什么情况呢:如果 Package B 依赖于 C@v1,Package A 依赖于 C@v2,假如不支持多版本依赖的话,同一项目就不可以同时安装 A 和 B——或者至少说同时安装 A 和 B 的话,Dependency resolution 会出现潜在的问题,A 和 B 不会分别 Resolve 到它们指定的独立的 C 版本上去,而是一股脑 Resolve 到最新的版本上去(比如这里都 Resolve 到 C@v2 上去),这样假如 C 从 C@v1 到 C@v2 出现了 API 上的 Breaking change,就可能导致代码跑不起来。Maven 就有这个问题,它默认是不直接支持多版本依赖的,尽管的确有一些不太常用的插件可以解决这个问题。相反的,反而 npm 是很早就支持多版本依赖的,至少在这点上你没法黑 npm.

防杠声明:这里指的显然不是说全局能否给同一个 package 的多个版本做缓存,那肯定是可以的,关键是同一个项目能否引用多个依赖冲突的 package. 我希望有人不会在这一点上误解……

实际上不止 Maven 一个,上面提到的一众编程语言的包管理器(pip、RubyGems、Composer、Shards)都不支持多版本依赖,甚至 C# 的 NuGet 也是不支持的(它实际上只会 Resolve 到路径最短的那个 Package 上去,而不是真的给每个 Package 维护完全独立的依赖)。你可以看到大多数出现较早的编程语言包管理器都未认真考虑多版本依赖的问题,而 npm 在这其中反而是比较先进的。


甚至在题主刚提出这个问题的时候,npm 还因为对解决多版本依赖问题的执着而导致了题述的冗余问题——当时的 npm 为每一个依赖维护一份完全独立的 node_modules 以避免任何潜在的兼容性问题,但这就导致了同一份依赖往往会在你的 node_modules 中存在很多份——比如假设你安装的 Package 中有 10% 都依赖了 Lodash,那么 Lodash 就需要复制这么多份,如果有某个常用依赖又依赖于其他 Package,这个问题会以指数级进一步放大,从而造成的硬盘开销简直是不可接受的。

不过好在,npm 解决这个问题(冗余问题)已经快有十年了。现在不管 npm 和 Yarn 的思路都差不多,会优先把目录打平。比如下面这个例子,bindings 依赖于 file-uri-to-path,但是 npm 并不会在 bindings/node_modules 下安装 file-uri-to-path,而是会优先在根目录下安装,尽可能使多个 Package 共享依赖:

那么这样的搞的话 npm 要怎么解决多版本依赖问题呢?毕竟根目录下肯定只能存一个版本的 Package——答案就是如果某个 Package 的依赖版本和根目录不同的话,就再在它的 node_modules 下存一份依赖。一个典型例子是 glob——虽然 glob 已经更新到 v11 了,但现在常用的库里,依赖于 glob@^7~glob@^11 的库都有。比如下面这个例子,根目录下的全局 glob 是 glob@7.2.3,而 rimraf 依赖于 glob@10.4.2,所以会在 rimraf/node_modules 下单独维护一份 glob@10.4.2:

当初 Yarn 能流行起来就是因为 Yarn 采用这个思路解决了当年 npm 的冗余问题,但自从快十年前的 npm v3 解决了这个问题,并且后续 npm v5 引入了 package-lock.json 之后,npm 和 Yarn 的体验差异已经不太明显了。即使后续 Yarn 引入了 workspace,也很难与如今的 pnpm workspace 拉开差距,并且 npm 本身也有了一个轻量级(但不太好用)的 workspace,似乎已经找不到什么理由继续坚持 Yarn 了,无论是 Yarn v4 还是早已过时的 Yarn v1——当然,这些都是题外话了。


比较令人高兴的是最近的一些较新编程语言的包管理器都考虑到了上面提到的两个问题:硬盘空间占用问题多版本依赖问题。其中做得最好的当属 Rust 的 Cargo——实至名归。其实 Go 和 Elixir 的 Mix 也做得很不错,至少较好地解决了这两个问题。

其实后来的 pnpm 也已经解决了硬盘空间占用问题——pnpm 其实用的不是什么新思路,就是把 node_modules 创建为某个指向全局 Package 缓存的符号链接而已,至少十年前就有人提出过这思路了,并且你本来就可以自己手动创建符号链接来减少硬盘空间占用。只是近些年随着 pnpm 流行起来,这种做法才被广泛使用而已。

但是回过头来说,为什么 npm 至今仍不愿意切换到 pnpm 的符号链接方案,而是要在每个项目目录下维护一份独立的 node_modules?另一个问题是,为什么 npm 好像格外关注多版本依赖问题,而其他编程语言的包管理器似乎就算不支持多版本依赖,我们常常也不会遇到什么问题?还有一个,为什么我们似乎仍经常观察到 node_modules 占用的硬盘空间相比其他编程语言的包管理器往往要更多


首先我想谈论一个并非技术问题,但我却认为是最主要的原因——社区习惯

npm 毫无疑问地坐拥着全世界最大的单一编程语言包仓库,以至于几乎所有你想得到想不到的问题,都能在某个犄角旮旯找到某个默默维持着每周几千下载量的老轮子,它们说不定已经有十年甚至九年没有更新了,但仍旧稳定可靠,能奇迹般地解决问题——与其他具有巨大标准库的编程语言(如 Python)不同,JS 语言本身(不考虑 Web API 或 Node 等定制运行时)一直维持着一个很小而且几乎不会有破坏性改动的标准库,这使得 JS 代码在多数时候能维持良好的向后兼容性——直到今天你还可以期待一个依赖于 12 年前某个版本 Lodash 的项目仍能正常运行,这种稳定性在我遇到过的所有编程语言中实际上是很罕有的事情。相比之下可以看看隔壁的 Python,几乎每个版本更新都会遇到库的兼容性问题,尤见于 distutils、imp、setuptools 这几个“老朋友”……

我可以举个我遇到过的例子。我曾经接手过某个后台项目的前端部分,主要是为一个公司内部使用的测试平台构建一些简单的交互页面。某天新增了一个需求,很简单,就是希望能用列表展示一下测试平台运行过程中产生的一些测试文件和下载链接,可以直接点进去下载,后端也提供了接口,只是个 CRUD 的简单活。我很快就做完了这个需求。有趣的是,后来大概一个星期都没有来什么新的需求,我寻思这段时间绩效要完,感觉要不要试着做个在线文件预览的页面?后端已经提供了下载链接,一般是个 .tar.gz 压缩包,其中存放着若干数据文件。我以为这会是个比较复杂的事情,随便试试,如果做不了的话就当它不存在,也别给自己没事揽活——但神奇的是,我几乎只是花了两三个小时就完成了这个需求,比我预想的工作量少了很多:

安全起见,我给页面打码了,但是应该能够看出这是个 JSON 文件。而上面有若干个 Tab,每个 Tab 代表一个压缩包内的文件名。后端提供的东西只是个文件的下载链接,文件解压和 untar 都是在浏览器上完成的——我找到了 pacojs-untar 这两个库来完成我的需求,前者是 Zlib 的纯 JS 移植版本,并且奇迹般地可以在浏览器里工作,而后者是一个五六年前的老轮子,GitHub Star 也并不多,但非常可靠地完成了 untar 这个本职工作。在浏览器中完成文件的解压和 untar 之后,我用 highlight.js 对文件内容进行渲染。说实话,我本以为浏览器不适合完成这种相对繁重的文件处理工作,我们往往会觉得这是后端的工作——但没想到这件事情做下来完全可行,几乎没遇到一点困难。

这种庞大和可靠的第三方库生态也自然造就了我刚才提到的“社区习惯”——如果有现成的且经过时间考验的第三方库可以完成某个功能,JS 生态中的开发者就很少会考虑自己造轮子。如果你曾有空看一眼你的 node_modules 里到底包含了什么,经常会看到这样的图景(以下这个项目一个 dependency 都没有,全是 devDependency):

这其中大量的从名称就能看得出做什么的库甚至会让你觉得 JS 生态里的这些开发者是不是有些毛病,连这么多简单的事情都需要引入一个库,难道他们就不担心导致生成产物变大吗——呃,但答案是这么做的确不会引入产物代码膨胀问题(现在都有 tree-shaking),而且 JS 生态中包管理器的成熟和对多版本依赖的支持也不会因为引入其他第三方库导致你的库有什么兼容性问题,甚至如果你引入的只是 micromatch 或 lodash.clonedeep 这样功能很专一的库,都不会使用户的 node_modules 体积增长多少。

这种习惯是如此盛行,以至于你甚至能看到一个名为 is-odd 的包每周有好几十万的下载量(这点经常被人拿出来黑,当然更多时候其实是自己人在自嘲):

其实也不用尬黑,大多数人下这个库只是为了做个测试,确认包管理器能否正常联网之类的,我不觉得这世界上真的有人连判断奇数都需要装一个库……顺便如果有人想要一个库来测试用,我其实更推荐 cowsay 而非 is-odd

于是我们谈到了 node_modules 的体积问题——刚才谈到了一个说法,如果有大量库都一致引用同一个库实现某个功能的话,由于 npm 会打平 node_modules 从而共享大部分依赖,这不仅不会产生代码膨胀问题,甚至理想情况下还能减少 node_modules 中存放的代码量(相比于每个库都独立实现一遍这个功能)。可现实是,上面提到的这种习惯于“不造轮子”的“社区习惯”往往造成了 node_modules 的膨胀——为什么会这样?

一个问题在于 npm 下载下来的不只是你程序执行所需要的那部分代码(相比于只下载 .jar 的 Maven),甚至不只是源代码本身(相比于 Python 的 wheel)——一个典型的 npm Package 至少也得包括一个 package.json,一般还包括一份 README.md(甚至 CHANGELOG.md,有些发布者还会带上完整的 doc,甚至有时会包含文档中引用的图片),而且多半会带上 LICENSE——如果是 Apache License 那种需要两百行的 LICENSE,即使只有 5% 的项目采用了该 LICENSE,也是相当巨大的一份存储空间开销。

有些粗心的开发者还会带上 test/ 目录(有些甚至是故意带上的)或 .eslintrc.editorconfig 这样的东西,还有很多决定自带 TS 支持的项目选择直接包含 .d.ts 文件(而不是考虑另外发布 @types/xxx),甚至会考虑包含 .d.ts.map 用于方便调试,还有一些会包含 .github/FUNDING.yml 以寻求捐助。

——如此一来,对于很多功能简单的库来说,主要的空间占用其实在文档、LICENSE 之类非源码的杂七杂八的地方,而源代码占的比重非常小。一个典型的例子是被间接依赖非常多的一个库 gopd(提供了一个考虑了 IE 支持的 Object.getOwnPropertyDescriptor,它的 GitHub Star 甚至只有个位数,但下载量却高达每周数千万),让我们看看它的 npm Package 长什么样:

它的全部代码只有右图展示的那么十几行,可 Package 中却包含了 package.json、README、LICENSE、CHANGELOG、.github/FUNDING.yml,甚至还包含了测试和一个看起来对发布出去的 Package 没啥用的 .eslintrc——它实际上还没包含 .d.ts(作为 @types/gopd 单独发布了)甚至 .d.ts.map 呢。

与之相比,Maven 下载下来的是 .jar,其中包含的只是 .class 和一些 Metadata,而 Python 在 site-packages 里至少也只是包含了源码,而没有包含文档之类的东西。想象一下你的 node_modules 里也许会存在数百甚至数千个这样功能极其简单的包,你就能理解“为什么 node_modules 这么巨大”的其中一个原因了。

你说包含这么多无关文件是坏事吗?我自己倒不觉得这是什么问题——至少这样源码翻起来很方便,直接有文档可以看,不用去 npm 上找,而我不太在意硬盘空间占用的问题,项目用不上的时候把 node_modules 删掉就行,同时开发的项目也不会有多少的,也占不了多少空间。不过对于各种依赖于 CI/CD 运行构建过程的项目来说,这就完全是不必要的开销了。

你当然可以骂一句 npm 怎么会把这些不必要的东西下载下来呢,或者至少该提供一个选项能不下载这些东西吧——而且 npmjs.com 为什么不能分开用于包仓库元信息展示和用于下载的文件呢,非要放在一起?这你就得问 Isaac 和当前 npm 的维护人员为什么不加这功能了,我估摸着还是大家虽然嘴上骂着 npm,心里却没觉得这真是什么问题,所以推动起来不怎么积极。不过不管怎么说,我觉得这不算是 node_modules 的问题,怪就怪 npm 吧。

但是作为 Package 开发者,你倒是可以多少注意一下这个问题。比如说用于 publish 的包里只包含一个很简短的 README,指向你项目的 GitHub Wiki 或官网,从而减少一部分空间占用的开销——遗憾的是,大多数开发者都没有这个习惯。

当然,这也只是一部分原因,归根结底文档这种东西加起来又能占多少空间呢?大头还是在源码上。这就回到了上面说的社区习惯问题——尽管按理来说一个 Package 应该只引入刚好能解决问题的包(比如如果只需要 _.cloneDeep,那么只该引入 lodash.clonedeep)——但事情并不总是那么理想。如果你的 Package 依赖于 Lodash 提供的好几十个函数,你真的会考虑每个函数都单独在 Dependency 里写一个专用依赖吗?显然为了方便起见你会直接引入 lodash-es——但 Lodash 本身提供了好几百个工具函数,这自然就造成了更多额外存储空间的占用。而且 Lodash 中的大多数函数也依赖于其他内部实现,引用大量专用依赖(像是 lodash.clonedeeplodash.zip)反而会重复这些内部实现的代码,导致代码膨胀问题。

并且很多时候,你压根找不到“刚好能完成所需功能”的某个 Package,而为了这点功能重复造轮子又没有意义,这时你就只能为了所需的那一点功能去引入一个巨大的依赖,从而造成最终 node_modules 膨胀的问题了。况且很多开发者也不一定有这个减小依赖的意识——lodash-es 出了这么多年,不还是有很多项目在用 lodash?即使其中一些项目本身已经利用了 ES6 之后的语言特性。

我觉得某种意义上,这大概是一种“幸福的烦恼”——npm 良好的生态使开发者们互相引用依赖几乎没有负担,但也造成了潜在的 node_modules 膨胀问题。看看 C++ 那边的情况,一个库为了做得容易引入几乎不能引入任何外部依赖(如果有,也一般是直接放在代码里),需要自己造大量的轮子,真有人觉得这就是好事吗?我反正不觉得……

并且相比起另一个具有庞大第三方库生态的语言,Python,JS 生态里的包管理器支持多版本依赖这点其实好处很多。现在回想一下,你自己用 npm 安装 Package 时遇到过因为依赖冲突装不上的问题吗?从来没有吧,即使真有装不上的问题也不会源于依赖冲突,而多半源于 node-gyp 这样的东西。而 Python 这边因为依赖冲突装不上库真是常有的事情,就不说那些调用 C 的第三方库了,即使纯 Python 库之间也经常因为依赖冲突装不上,比如我就遇到过很多次 click 版本冲突导致的问题……

如果你翻翻 node_modules,你几乎是可以在每个项目里都看到总会有一些库是依赖于非顶层版本的某个其他依赖的。而且你仔细一调查,会发现这样的情况其实也导致了不少 node_modules 膨胀的问题——但是我相信头脑正常的人都不会说出“不如让我们砍掉 npm 的多版本依赖支持,强迫所有库的开发者都把依赖更新到最新版本,从而避免多版本依赖造成的 node_modules 膨胀问题”,如果你真这么想,那我真觉得你是纯纯的逆天,你拿那些五六年前甚至十年前就已经停止维护的 Package 怎么办?强迫都可能已经离世的开发者从坟墓里爬起来给你更新依赖吗?


上面谈了 node_modules 为什么这么大的问题以及多版本依赖问题。下面可以谈谈全局依赖的可行性问题——既然 pnpm 已经证明了用符号链接之类的方式可行,为什么 npm 就不能支持用类似的方式管理依赖,甚至说 Node 能支持直接从某个全局目录里读取依赖呢?

这其实很大程度上源于 JS 这边构建的复杂性(当然也有一部分历史原因)。不知道大家有没有遇到过依赖于 Patching 才能正常工作的项目——这常见于你凑巧找到一个第三方库正好能满足你的需求,可是因为年久失修不能用于你当前的项目,但经过一些小的修补之后就可以(比如一个原本适用于 Vue 2 的库,但你惊喜地发现在一些小修补之后也可以用于你的 Vue 3 项目),这时你就会创建一些基于 Unix diff 格式的 .patch 文件,然后用 patch-package 这样的工具写个 postinstall 来“修正”某些依赖。

但如果你使用 pnpm 给一个采用了 patch-package 的项目安装依赖,你会发现这些 Patching 并没有正确应用到对应的源码上——问题显而易见,因为 patch-package 尝试修改的是 node_modules 目录中的代码,而 pnpm 实际上把代码先在全局的 .pnpm-store 里放一份,然后 hard link 到 node_modules/.pnpm 下,再创建 symlink 到 node_modules 目录自身中——patch-package 试图访问的始终是 symlink 而不是文件本身(hard link),因此在 pnpm 下 patch-package 始终会运行失败。

下图展示了 pnpm 实际存储依赖的方式,这里的 wrap-ansi@7.0.0 文件夹位于某个项目的 node_modules/.pnpm 下,可以看到当你用 fsutil 尝试列出其中一个文件的 hard link 时,会看到它首先在 .pnpm-store 中有一个 link,然后在当前项目的 node_modules/.pnpm 中也有一个。

——当然,至少在 Patching 这个场景下,pnpm 本身就提供了 pnpm patch 命令,所以 patch-package 在 pnpm 下无法工作不是什么大问题——尽管如此,对于不限定用户使用包管理器的项目,你大概还是需要写一个脚本用来检测用户当前是否使用 pnpm,如果使用则运行 pnpm patch,否则运行 patch-package……

patch-package 的问题只是一个小例子,展示了一个非典型 node_modules 结构可能导致的、构建时的兼容性问题。我相信很多人在读到我这篇回答之前可能压根都不知道还有 Patching 这种操作——同样的,还有很多你无法想象的特殊情况,其项目构建或运行依赖于一个独立的、安装在项目目录里的 node_modules,这可能是由于该项目在构建时需要临时修改某些源代码,又或者某些工具依赖于特定的路径才能找到正确的可执行文件,抑或是更多我也暂时想象不到的场景。

如果你稍微在谷歌上搜一下,也很容易找到诸多 pnpm 引发的兼容问题。因此 npm 坚持每个项目维护一份单独依赖是个没什么问题的决策——作为 Node 预装的、默认的包管理器,肯定要选择最稳妥和安全的方案。其实如今 pnpm 的诸多好评很大程度上只是因为它不是 Node 默认的包管理器——pnpm 用户都是主动放弃 npm 切换到 pnpm 上去的,即使遇到了问题也会自己考虑解决,而不是怪 pnpm 有问题——但假如哪一天 pnpm 真变成了 Node 的默认包管理器,大概能想到关于它的兼容性问题肯定会是骂声一片的情况……npm 虽然安装速度远不如 Yarn / pnpm[1],在各种特性的支持上也弱于它们,但在兼容性上绝对是没什么问题的。

很大程度上,这也可以算作某种历史原因——假如 npm 一开始就采取了 pnpm 如今的方案,大概就不会有如今这各种工具的兼容性问题了,说不定也可以在 JS 生态里做一个像 Cargo 那样广受好评的包管理器。只是一切没有如果,你没法穿越回 2010 年,告诉 Isaac 应该采用 Cargo 那样既解决硬盘空间又能解决多版本依赖问题的方案——毕竟那时 Rust 连带着 Cargo 都还没发布呢,也没有一个广为人知的包管理器既能解决硬盘空间占用问题又能解决多版本依赖问题,所以当时人们能想到的最好的办法就是为每个项目(甚至早期的时候是每个库)都维护一份独立的 node_modules——这是最简单可靠也最容易实现的做法。

顺带一提,其实多版本依赖问题至今在 Cargo 中解决得也不完美,你可以参见 The Cargo Book 的 Dependency Resolution#Version-incompatibility hazards 一节[2],可以看到虽然 Cargo 能够解决多版本依赖问题,但这会导致类型导出的问题(换到 JS 这边,你可以理解为由于多个库使用了不同版本的同一依赖,但显然你在项目中只能用某一个版本该依赖库的 TS 定义,所以理所当然的,如果你恰好在代码中需要用这个库,就会造成 TS 上的版本冲突……)。

多版本依赖始终是一个没办法完美解决的问题,而像 npm 这样看似“愚蠢”地给每个项目维护一份依赖反倒是很稳妥的选择——况且这至少比起 Python 和 Ruby 那样,既不支持多版本依赖、还要靠虚拟环境因此也没节省硬盘空间的做法来得聪明不是吗?


最后我为啥想到写这么长一篇回答试图解释问题呢,因为我看到黑 node_modules 的许多人几乎已经到了不理智的地步了,而其中很多人都没有把握到 npm 复杂性的一大来源是多版本依赖问题和构建系统自身的复杂性。

并且我自己都很难认为 node_modules 的空间占用是个问题——用哪一个编程语言写项目不会在目录里出现个几百兆甚至数个 G 的文件夹啊,在 JS 这里是 node_modules,在 Python 那里是 venv,在 Rust 里是 target,在 C++ 里是 build/out——特别是 Rust 和 C++ 这样的系统级语言,弄出几个 G 的 target/build 明明是很常见的事情……而一些移动端项目(像是 Android Studio 项目或 Flutter 项目)搞出来的项目文件夹大小更是远超 node_modules 了。


参考

  1. 顺带一提,pnpm 的另一个好处是解决了幽灵依赖问题,这有助于减少一些(但说实话我从没遇到的)BUGhttps://www.kochan.io/nodejs/pnpms-strictness-helps-to-avoid-silly-bugs.html
  2. 也参考自该 Stack Overflow 回答https://stackoverflow.com/a/75782197/21418758