我数了一遍 node_modules,然后沉默了

上周末我想把一个报错查清楚,一路追进了 node_modules。问题最后没查出来,但我顺手敲了个命令:

$ du -sh node_modules src
571M    node_modules
281M    src

盯着这两个数字看了好一会儿。

src 里装的是这个博客从 2018 年到现在的所有东西——文章、配置、自己改的 VuePress 主题,八年的积累。node_modules 里装的是我为了把那些文章变成静态页面而装的东西。后者是前者的两倍。

然后我又手贱多敲了几个。

望不到头的仓库货架,一个人走在中间

# 数字摊开是这样

项目 数字
package.json 里的直接依赖 32 个(5 生产 + 27 开发)
node_modules 里的包 1140 个
文件总数 47772 个
磁盘占用 571 MB
依赖图最深 8 层

32 个名字进去,1140 个包出来。

我从这 32 个出发写了个 BFS,看看到底连着多少个包:能走到的有 531 个,最深的一条链 8 层。也就是说,我装了 A,A 装了 B,B 装了 C……一直到第八层那个包,我连名字都没见过,但它在我的硬盘上,也会跑在我的构建里。

其中最让我笑不出来的是这个:

$ ls node_modules | grep -c '^is-'
46

46 个包的名字以 is- 开头。isarray 的全部实现是 5 行。

# 1 比 102

真正让我沉默的是行数。

$ find src -type f \( -name '*.md' -o -name '*.js' -o -name '*.vue' \) | xargs cat | wc -l
34041

$ find node_modules -type f \( -name '*.js' -o -name '*.ts' \) | xargs cat | wc -l
3480965

三万四对三百四十八万。102 倍。

而且这个算法已经很给我面子了——那 34041 行里绝大部分是 Markdown,是我写的文章,不是代码。真正算得上「我写的代码」的,可能只有几千行。

也就是说,这个博客能跑起来,靠的是几千行我写的、和三百多万行我一个字都没读过的。

一个小小的人站在一堵由无数小方块垒成的高墙前面

# 先别急着吓自己

写到这里我得诚实地拐个弯:这个比例本身没什么可吓人的。

现代软件本来就是这么造的。没有人从冶炼矿石开始造汽车,你按下启动键的时候发动机里发生的事你一无所知,照样开了十年车。抽象层存在的全部意义,就是让你不必知道下面那一层是什么。

那 102 倍不是病态,是分工。它甚至是好事——正是因为我不用自己写解析器、写压缩算法、写 Markdown 的边界处理,我才能花一个下午写一篇博客而不是花半年造工具。

真正的问题不在「多」,在「不知道」。

我可以接受项目里有三百多万行别人的代码。我接受不了的是我完全说不出里面有什么。要是现在有人问我一句「你的依赖里有没有包会读取环境变量往外发」,我答不上来。这不是假设性的问题,left-pad 之后它就是真的。

# 那 5 行到底配不配单独发个包

关于小包,社区里有个流传很久的嘲笑:连 isarray 都要发个 npm 包,前端真是完了。

我一直觉得这个嘲笑搞错了重点。

var toString = {}.toString;

module.exports = Array.isArray || function (arr) {
  return toString.call(arr) == '[object Array]';
};

5 行,但它解决的是一个真实存在过、而且会咬人的问题:ES5 之前没有 Array.isArray,从 iframe 里传出来的数组用 instanceof 判断会失效。那 5 行里打包着一个具体的兼容性判断,不是作者懒得想。

更要紧的是,如果它不独立成包,这 5 行就会复制进几百个库里,每个库各自维护一份,其中一部分还是写错的。抽出来之后,bug 只需要修一次,修完所有人一起受益。

小包不是偷懒,是把同一个坑收敛到一个地方。

left-pad 事件经常被当成「小包危险」的证据,但我读出来的意思正好相反:一个 11 行的包被作者下架,半个互联网挂了——这恰恰说明它被验证过、被广泛信任。出事的不是那 11 行,是「一个维护者可以单方面让半个互联网瘫痪」这个结构。

水面上一小角,水下是一整座冰山

# 噪音也是真的

话说回来,我不打算把这事全说成好事。

那 1140 个包里有相当一部分是纯噪音。同一个功能被三个库各自实现一遍,A 要的版本和 B 要的版本对不上,npm 就老实给你装三份。du 排第一的是 @vuepress,29M,合理。排第二第三的是 leancloud-storageleancloud-realtime,加起来快 50M——它们是我为了右下角那个能留言的小框(valine)拖进来的。

为了一个留言框,50M。

这个账要认。不是每个包都配得上它的体积,也不是每个依赖都是深思熟虑的结果。有相当一部分只是三年前某个下午我 npm install 了一下,然后就忘了。它们不是「架构决策」,是「当时手快」。

# AI 让这条曲线更陡

最后说点跟现在有关的。

这 1140 个包,好歹是别人写的、被别人在 production 里跑过、有 issue 列表、有版本号的东西。某种意义上它们比我自己写的代码更可靠,至少经过检验。

但现在我的项目里开始出现另一类代码:我让 AI 写的,我大致扫了一眼觉得对,就提交了。这类代码没有 issue 列表,没被任何人用过,唯一的验证是我那大致一眼。

依赖的风险,本质上是「代码量增长的速度超过了理解它的速度」。以前这个缺口好歹有一部分被下载量、star 数和 issue 记录填着——那是陌生人替我做过的检验。现在这块补丁没了。生成一千行的成本几乎降到了零,读完一千行的成本一点没变。

传送带把箱子不断送进一条黑暗的隧道

# 我什么都没删

沉默完之后,我一个依赖都没删。

那 571MB 还躺在那儿,isarray 的 5 行也还在。冷静想想,删掉它们的收益(省几百 MB 磁盘)远小于风险(某个角落的插件突然不认这个版本)。我不是不知道里面有什么,我是懒得知道,而这两件事在短期内后果一样。

变的只有一点:我不再把「这不是我写的代码」当成一句免责声明。

以前线上出事,如果最后查出来是某个依赖的 bug,我会有点如释重负——不是我的锅。现在我觉得这句话是错的。是我把它装进来的,我享受了它省下的时间,就得认下它带来的不确定性。那 102 倍的三百多万行,是我签字画押借来的。

下次 npm install 之前,大概率还是会照装。

但至少我知道自己在装什么了。