译:微型库早该消亡了

原文:https://bvisness.me/microlibraries/
作者:Ben Visness
译者:ChatGPT 4 Turbo

编者注:作者列举了使用依赖库的优点和缺点。优点是 1)节省开发时间,2)代码(希望)更健壮,3)可升级获取修复新功能和安全更新。缺点则更多,1)问题不匹配,2)库可能比你写的还糟糕,3)潜在的恶意风险,4)供应链攻击,5)更新成本,6)大量间接依赖。所以,微型库,作者建议直接 copy 到代码里,而不是依赖他。

当前是 2024 年。自从 left-pad 首次让人们意识到,嘿,或许将琐碎的功能外包给互联网上的随机人员并不是一个好主意,已经过去了八年。

但在 2024 年,我仍然看到一些人争论说,实际上,微型库是好的,我们应该做更多这样的库。我们应该制作更小的包,并使用更多的依赖项。npm 的问题真正在于,我们还没有使包变得足够小。

这种观点如此难以置信的错误,甚至不应该成为一个争论的话题。但因为无论如何还是有争论,所以有人需要详尽而痛苦地解释为什么坏习惯是坏的。

这里是我的论点:微型库永远不应该被使用。它们应该被复制粘贴进你的代码库中,或者根本不使用。

然而,我写这篇文章的实际目标是,分解我思考依赖关系的成本和收益的方式。我不会对这些成本和收益进行量化,但我希望通过解释我对依赖关系的思考方式,可以清楚地表明我认为微型库全是缺点,没有优点。

成本与收益

每个人都知道,编程完全是关于权衡。你知道的,你得为这工作选对工具,是吧?

嗯,你不能做出合理的权衡,除非你能实际说明成本和收益。所以,让我们从库的好处开始,检查一下库的利弊:

  • 它节省了开发时间。 这是库的最明显好处,尤其是如果它解决的问题很复杂的话。
  • 代码(希望)更健壮。 库的作者可能已经对这个问题思考了许多,如果他们的实现是成熟的,它可能会处理更多边缘情况和微妙的陷阱。他们的实现也可能更具“未来证明性”,预测未来的使用情况。当库有大量用户时,这个属性最为强大——它可能不是_好的_,但它不太可能是_错误的_。
  • 您可以升级以获取功能、修复错误或安全更新。 这扩展了第一点:不仅其他人为您编写代码,而且其他人为您 维护 它。在最好的情况下,升级简单地使您的生活变得更好,而不破坏兼容性。

这就是关于好处的全部内容。不幸的是,与依赖关系相关的成本也很多——比大多数人预计的要多:

  • 库可能不适合您的问题。 这通常会抵消掉库的主要好处。不,您不必编写代码,但您 确实 需要调整您的问题以适应库,并再次调整库的结果以适应您的应用。这个成本可以非常极端!

    例如,在我上一份工作中,我们尝试使用亚马逊的 Simple Notification Service 向 iOS 和 Android 设备发送推送通知。理论上,我们本可以通过只针对一个 API 而节省时间。但在处理 AWS 认证、设备注册和 SQS 队列以及 SNS 主题和 API 不兼容性之后,直接针对苹果和谷歌推送 API 证明要容易得多。

  • 库可能编写得很糟糕。 程序员通常假设库代码的质量比他们的代码高。这通常根本不是真的。任何随机的人都可以发布一个 npm 包,许多 npm 包就是糟糕的。即便是非常受欢迎的包也是如此;受欢迎程度和质量之间的相关性极其微弱。

    使用库也经常导致性能损失,即使是“写得很好”的情况。库是面向所有人的,因此它们没有为任何人优化。

  • 第三方代码本质上是有风险的。 库可能有严重的错误,或者作者可能公开恶意。很难适当地审计一切,库越复杂,出错或遭受攻击的机会就越多。另一方面,如果您自己编写代码,您知道它没有恶意之处,并且您有机会为其排查错误。

  • 每个依赖都是一个供应链攻击向量。 任何包,从最大的框架到最小的工具,都可能被破坏,且对敏感资源具有同等的访问权限。你拥有的包越多,维护者越多,被黑的机会就越多。

  • 库可能有很大的占用空间。 库通常远远大于你所需要的。这种臃肿可能来自多个来源:你从未使用的功能、node_modules 中的元数据、相同包的重复版本,当然还有大量大量的间接依赖。此外,这个占用空间是高度可变的——常规更新可以无形中将一个包的占用空间增加四倍。

    这种占用空间对整个过程的所有阶段都有负面影响:增加安装时间、增加构建时间、为用户增加更大的包大小。这个问题在 JavaScript 生态中如此普遍,以至于许多常见包的总占用空间达到了数百兆字节,这真是一种惊人的浪费。问题变得如此严重,以至于现在有一个名为 e18e 的倡议正在尝试清理这些乱象。

  • 更新并非无代价。 理论上,只要版本兼容,更新一个包应该是没问题的。但实际上,更新会引起各种问题:破坏性变更、弃用的功能(及相关重写)、性能回退、包大小膨胀、新的错误。这个成本是不可预测的;你永远不会真正知道上游可能发生了什么变化。

  • 库可能有许多间接依赖。 间接依赖也是依赖。你的 package_lock.json 中的所有内容都是真正的依赖,具有你不能忽视的真实成本。间接依赖增加了糟糕代码的风险,增加了安全问题的风险,当然也增加了你的应用程序的占用空间。

这就是我看待依赖的方式——很明显,在我看来,成本远远大于收益。收益可以非常强大,但在没有考虑成本的情况下,你不能做出明智的决定。特别是初学者往往会忽视成本——但他们的库最终会背叛他们。

is-number:一个案例研究

让我们来检查一个流行的微型库,is-number。这是一个 npm 包,只有一个函数,isNumber。它接受一个值,并告诉你这是不是一个有限数,或者一个非空的有限数字字符串。这个极其有用的函数体现了 JS 生态中微型库的所有问题。

让我们来看看这个包的好处:

  • 你可以写 isNumber(foo) 代替 typeof foo === "number"

真是长长的清单。

但认真地,用我们之前可能从中获益的测试来对比:

  • 它节省开发时间了吗?几乎没有。 假设你确实需要检查一个值是否是有限数或者非空的有限数字字符串,这个库可能为你节省几分钟。但实际上,这个函数几乎完全没用,正如我们下面将看到的。
  • 它比你能写的更健壮吗?不。 代码极其简单,易于验证。
  • 将来的更新会有用吗?不。 这个库如此简单,任何对逻辑的改变都会是破坏性的,并且已经明确没有任何错误。

现在我们来看看成本:

  • 它适合你的问题吗?几乎肯定不是。 99% 的时间,你需要的只是 typeof foo = = = 'number'。剩下 0.9% 的时间,你只需要 foo == Number(foo)(这将包含数字字符串并排除 NaN)。最多 0.1% 的时间你_还_需要排除空字符串和 Infinity。这些都是简单的写法,任何 JavaScript 程序员都应该熟悉。因此,is-number 几乎总是只是臃肿,进行不必要的检查出于偏执,并且可能打破 JS 引擎本可以做出的一些优化。
  • 更新会破坏吗?是的。 难以置信,is-number 已经是主要版本 7.0.0 了。对于这样一个简单的函数来说,这是一些惊人的破坏性变更。

为什么有这么多新版本?许多原因,没有一个是好的。有时作者随意改变他对什么是“数字”的看法——例如,NaN 曾经被认为是一个数字;现在不是了。一个破坏性变更只是将最低支持的 Node 版本从 0.10.0 升级到了 0.12.0 并且_除此之外什么也没改变_。有时他只是觉得像是一个破坏性变更因为他就是这么想。

  • 它膨胀了吗?有点。 虽然实际代码只有 245 字节,但安装后的大小是 9.62 kB。也就是说,它在你的电脑上的占用空间比必要的大了 39 倍,这是因为像 README、LICENSE 和 package.json 这样的元数据。幸运的是,这不应该影响构建时间或包大小,但这种惊人的浪费在人们的 node_modules 中安装的成千上万的包中累积起来。此外,因为作者发布了太多的主要版本,你会发现你的 node_modules 中没有好的理由就包含了这个库的多份拷贝。
  • 它有风险吗?有。 它就像任何其他的供应链攻击机会一样,而且由于它相当频繁地更新,更新不太可能引起审查。

所以,据我所知,我们有 零优势多个缺点。这种折衷真是太糟了。

复制-粘贴:一个案例研究

假设,出于某种难以理解的原因,我们确实需要检查一个 JS 值是否是有限数或非空有限数值字符串。我们可以不安装 npm 包,而是直接将 is-number 的全部内容复制粘贴到我们的程序中:

function isNumber(num) {
  if (typeof num === 'number') {
    return num - num === 0;
  }

  if (typeof num === 'string' && num.trim() !== '') {
    return Number.isFinite ? Number.isFinite(+num) : isFinite(+num);
  }

  return false;
}

这减少了所有剩余的缺点。通过复制粘贴,我们仍然 节省了开发时间。它 不会在将来造成破坏,因为它永远不会改变。它有一个 更小的占用空间,因为它不包含不必要的元数据,并且它永远不会神秘地自我复制。它 不具有风险,因为其功能显而易见,且不可能通过供应链被攻击。

当然,我们可能本来就可以直接写 typeof foo === "number"

重复使用呢?

微小库的一个声称的好处是减少了整个应用的重复。假设你的应用有一个像 isNumber 这样的工具函数,而且几个库都有类似 isNumber 的工具函数——减少所有这些重复并让它们共享同一个版本的工具函数难道不是更好吗?

在实践中,显然情况并非如此。看看流行项目的依赖图,你会发现惊人的重复数量。经常有多个包执行相似的功能,但也经常有 相同包 的多个大版本。

这种情况发生的原因应该是显而易见的:并不是所有用户都有完全相同的需求。只要需求有所不同,就会有不同的实现。我们不会期望每个复制粘贴的实用程序都完全相同,那么为什么我们会期望在使用包管理器时重复会消失呢?

但实际上,情况比这更糟,因为当 node_modules 中安装了多个主要版本的 is-number 时,显然还有其他问题。语义版本控制根本就不是人们认为的那样尖锐的工具。如果你使用的是 Node 20,那么库将最低 Node 版本从 0.10.0 提升到 0.12.0 并不是一个破坏性更改。不需要重复的版本,但你的工具不知道这一点。同样,如果包作者因为一个边缘情况而发布一个主要版本。技术上对 某些 用户是破坏性的,但对你来说不是。

最后 — 许多微型库的用例实际上可以用一行代码替代。在这种情况下,你根本不必担心重复问题。你的代码仍然会很小。它将是少量的源代码和少数的字节码操作。你不需要包管理器来为你解决这个问题。

够了!

我八年前就可以对 left-pad 或今天的许多 其他 进行同样的分析。微型实用程序根本不应该成为库。

将代码复制粘贴到你的项目中完全没有问题。有时候从 Stack Overflow 上获取一段代码片段确实很有用,但通过包管理器安装这些东西实际上没有任何好处。你正在将自己暴露于一整个痛苦的世界中,而这完全可以通过简单地复制粘贴来避免。

我已经谈了很多关于库成本的问题,我确实希望人们在这方面能更加谨慎。但是,有一个因素我在之前的讨论中遗漏了。我认为人们使用库的另一个原因是恐惧

程序员害怕引发错误。害怕犯错。害怕遗漏边缘情况。害怕他们无法理解事物的工作原理。在恐惧中,他们依赖于库。 “谢天谢地有人已经解决了这个问题;我肯定永远不可能做到。”

但他们不应该害怕!库并不是魔法。它们只是别人写的代码。毕竟,我上面粘贴了 is-number 的全部内容,里面没有什么太神秘的。而且超出库之外 —— 编程语言不是魔法,操作系统不是魔法,没有什么是魔法。深入源代码,你会发现你能阅读和理解的代码。这种态度是 Handmade 精神 的基础,我完全支持它。

如果你是微型库的支持者,我鼓励你克服恐惧,尝试自己编写代码。你比你想象的更有能力。