【Translation】微型库已经过时了 Micro-libralies need to die already

"Ben Visness 的博文 Micro-libralies need to die already 的翻译。他给出了我们为什么不该使用微型库的理由。"


来自于 Ben Visness 的博文 Micro-libralies need to die already 。原作者的标题是"微型库早该死死了",攻击性颇强。

下面内容主要来自大模型机翻,部分内容由我个人修改润色。

引言

现在是 2024 年。在八年前,left-pad 事件让人们首次意识到“也许将琐碎的功能外包给网络上的任意人员并不是一个好主意”。但是,现在,2024年,我仍然能看到有些人在争论说:“其实微型库很好,我们应该多做一些。我们应该让包更小,使用更多的依赖项。npm的问题实际上是我们还没有把包做得足够小。”

译注:left-pad 是一个只有一个字符串处理函数的 npm 包,被各种项目广泛引用。在 2017 年 left-pad 的作者因为自己其他包的名称被 npm 强制让给了其他商业公司,一怒之下把自己所有的包都从 npm 撤下来,结果导致当时大量的项目因为无法找到该包构建失败。

所谓微型库便是 left-pad 这样的、只提供了一个或寥寥几个函数的封装库。这种库往往是一些我们经常会用到的、但是语言自身没有内置的功能函数。

这简直荒谬至极,根本不值得讨论。但是既然有人提出了质疑,就必须有人详细而痛苦地解释为什么这些不良做法是错误的。

因此,我写了这篇文章。我的观点便是:微型库不应该被使用。它们要么直接复制粘贴到代码库中,要么根本不使用

当然,我写这篇文章的真正目的是分解我对 依赖成本和收益(costs and benefits of dependencies) 的看法。我不会量化这些成本和收益,但我希望通过解释我对依赖的看法,清楚地说明为什么我认为微型库只有缺点而没有优点。

成本和收益

每个人都知道编程就是在权衡利弊。你必须选择适合工作的工具,你应该知道吧?

嗯,除非你能清楚地阐述成本和收益,否则无法做出合理的权衡。那么,让我们先从微型库的优点开始,分析一下其利弊:

  • 它节省了开发时间 。这是这些库带来的最明显的好处,尤其是如果它要解决的问题比较复杂。
  • 代码更加健壮(呵,希望如此) 。库的作者显然对这个问题进行了深入思考,如果他们的实现已经成熟,那么它可能能够处理更多的边缘情况,避开那些微妙的语言陷阱。他们的实现还可能更加“面向未来”,预见未来的使用场景。当库有大量用户时,这种特性最为明显——它可能并不完美,但出错的可能性更小。
  • 你可以升级以获得新功能、修复程序错误或安全更新。 这进一步说明了第一点:不仅其他人为你编写代码,而且其他人还为你维护代码。在最好的情况下,升级可以使你的生活变得更好,而不会破坏兼容性。

好处就这些了。不幸的是,依赖也会带来许多成本,而这些成本往往超出大多数人的预期:

  • 微型库可能并不适合你的问题 。这往往会抵消微型库的主要好处。“你不需要编写代码!”——是的,你不需要编写代码,但你必须将你的问题与微型库相适应,然后再将微型库的结果与你的程序相适应。这种成本可能非常高昂!

    例如,在我上一份工作中,我们尝试使用过亚马逊的Simple Notification Service(SNS)向iOS和Android设备发送推送通知。理论上,我们只需针对一个API进行处理,如此就可以节省时间。但在处理AWS认证、设备注册、SQS队列、SNS主题和API兼容性问题之后,我们发现直接针对苹果和谷歌的推送API进行开发要简单得多——有的时候,单一性并不意味着它是最优解。

  • 库的代码可能写得并不好 。程序员通常认为库的代码质量比他们自己的代码高。但这并不总是正确的。任何人都可以发布一个npm包,而许多npm包的质量很差。即使是非常受欢迎的包也是如此;受欢迎程度代码质量 之间并不存在显著的相关性。 同样,使用库也常常会导致性能下降,即使库本身是“写得很好”的。库是为所有人设计的,因此它们不会针对特定用户进行优化。

  • 第三方代码本身就存在风险 。该库可能存在关键性漏洞,或者作者可能故意恶意攻击。很难对所有内容进行彻底的审核,库越复杂,出现错误或攻击的机会就越多。另一方面,如果你自己编写代码,就可以确保代码中不存在恶意内容,并且有机会排查其中的漏洞。

  • 每个依赖项都是供应链攻击的切入点。从最大的框架到最小的实用工具,任何包都可能被攻破,并拥有一样的访问敏感资源的权限。拥有的包越多,维护者的数量越多,被攻破的可能性就越大。

  • 库的体积可能很大 。库通常比你需要的要大得多。这种臃肿可能来自多个来源:你从未使用过的功能、标记文件中的元数据、相同包的冗余版本,当然还有成堆的依赖项。此外,库的体积变化很大——一个常规更新可能会在无形中将一个包的体积扩大四倍。

    这种脚印对整个过程的所有阶段都有负面影响:安装时间增加、构建时间增加、用户下载的包体积增大。这个问题在JavaScript生态系统中非常普遍,许多常用包的总脚印高达几百兆字节,这是真正令人震惊的浪费。这个问题已经变得非常严重,现在有一个名为 e18e 的倡议正在试图解决这个问题。

  • 更新并非免费的午餐。理论上,只要版本兼容,更新包应该没问题。但在实践中,更新会引发各种问题:破坏性更改、废弃的功能(以及相关的重写)、性能退化、包大小膨胀、新出现的问题。这些成本不可预测;你永远无法真正知道上游发生了什么变化

  • 库可能包含许多传递依赖。传递依赖也是依赖。你在项目中的所有内容都是真正的依赖,具有真实的成本,你不能忽视。传递依赖会增加不良代码的风险,增加安全问题的风险,当然也会增加应用程序的占用空间。

这便是我眼中的 开发依赖(dependencies)——在我看来,其弊大于利。它可能存在极大的好处,但不考虑成本就无法做出明智的决定。特别是初学者往往会忽视成本——然而大多数情况下,他们使用的依赖库最终会背叛他们。

针对微型库 is-number 的个例研究

让我们来看看一个流行的微型库—— is-number 。这是一个仅包含一个函数的npm包, isNumber 。它接受一个值,并告诉你它是否是一个有限数(finite number),或者是一个有限的非空数字字符串。这个非常有用的函数体现了JS生态系统中微型库所存在的所有问题。

译注:在 Javascript 中,数值的结果可能会存在 Infinity(无限)和 NaN(不是一个数字),对它们使用类型判断运算符 typeof 得到的结果总会是 number ,因此存在了 is-number 这个库函数判断是否是一个 number 类型的值是否是真正的数值。 很奇怪对吧~这就是JS的历史包袱。

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

  • 你可以使用 isNumber(foo)  代替 typeof foo === "number" 。

哇,它的好处可是真的多啊!

好吧,认真来讲,让我们把这个方法与我们之前可能获得的好处进行对比:

  • "它能节省开发时间吗?" ——几乎不能。假设出于某种原因,你确实需要检查一个值是否为有限数值或有限非空数字字符串,那么这个库可能会为你节省几分钟的时间。但在实践中,这个函数几乎毫无用处,下面我们将详细说明。
  • “它比您能编写的代码更健壮吗?” —— 不,代码非常简单明了,容易验证。
  • “这个库未来的更新会有用吗?”——不会。这个库非常简单,对逻辑的任何更改都会破坏其功能,而且很明显它没有bug。

那么,现在让我们来谈谈成本问题:

  • 它是否适合你的问题?几乎肯定不适合。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。也就是说,由于诸如README、LICENSE和package.json等元数据的存在,该软件在您的计算机上的占用空间是必要的39倍。幸运的是,这不应该影响构建时间或打包大小,但这种惊人的浪费在人们安装的数千个包中累积起来。此外,由于作者频繁发布主要版本,因此在您的项目中经常可以毫无理由地找到多个该库的副本。
  • 它有风险吗?是的,有的。这与其他供应链攻击机会一样,因为它更新频率较高,因此不太可能引起审查。

所以,在我看来,我们既没有好处,反而有好几处坏处。这个权衡得不偿失。

如果我们只是将代码复制黏贴一下呢?

假设出于某种不可理解的原因,我们确实需要检查一个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"。这取决于你对接受到的数值的范围的区间,由你取舍。如果你遇不到 Infinity 这样的值出现,那么没有必要这么做。

重复又怎么样呢?

小型库的一个声称的好处是减少整个程序中的重复代码。假设您的应用程序有一个名为 isNumber 的实用工具,而多个库都有名为 isNumber 的实用工具——将所有这些重复代码减少并让它们共享一个版本的实用工具不是更好吗?

实际上,情况并非如此。看看热门项目的依赖图,你会发现惊人的重复现象。通常会有多个包执行相似的功能,而且同一包的多个主要版本也很常见。

这显然是有原因的:并不是所有用户都有完全相同的需求。只要需求不同,就会有不同的实现方式。我们不会期望每个复制粘贴的程序都完全相同,那么为什么在使用包管理器时,我们会期望复制现象消失呢?

但实际上情况比这更糟糕,因为当在你的环境中安装多个主要版本的 is-number 时,显然还有其他问题存在。语义版本控制(Semantic versioning,npm便是基于语义的版本控制)并不如人们想象中的那般犀利。如果你使用的是Node 20,那么将该库的最低Node版本要求从0.10.0提高到0.12.0并不是一个破坏性的更改。虽然你并不需要创建一个冗余的版本,但版本控制工具并不知道这一点,它会把你依赖中的不同版本的的依赖库全部都安装下来。

同样,如果包作者因为一个边缘情况而发布一个主要版本,从技术上讲可能会对某些用户造成破坏。但如果你是简简单单地复制黏贴代码,那么对你来说万事OK。

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

好吧,收个尾

我可以用同样的分析来描述八年前的 left-pad ,或者今天许多其他包的情况。微小的工具类或工具函数不应该成为库

将代码复制粘贴到项目中是完全没有问题的。有时候,从Stack Overflow上获取一段代码确实很有用,但通过包管理器安装这些东西实际上没有任何好处。你这样做实际上是在给自己带来无尽的痛苦,而这些痛苦完全可以通过简单的复制粘贴来避免。

我之前谈论了很多微型库的成本问题,我希望人们能够更加谨慎地对待它们。但是,在我之前的讨论中,我遗漏了一个因素。我认为人们使用微型库的另一个原因是恐惧。

程序员害怕编写出有缺陷的代码。害怕犯错误。害怕遗漏特殊情况。害怕无法理解程序是如何工作的。出于恐惧,他们会依赖库。“谢天谢地,有人已经解决了这个问题;我肯定无法做到。”

但他们不应该害怕!微型库并不是魔法。它们只是别人编写的代码而已。毕竟,我把整个 is-number 都粘贴了过来,里面没有神秘的东西。除了库之外,语言也不是魔法,操作系统也不是魔法,没有什么是魔法。深入到源代码中,你会发现你可以阅读和理解的代码。这种态度是手工制作理念的基础,我完全赞同。

如果你是微型库的支持者,我鼓励你克服恐惧,亲自尝试编写代码。请记住,你的能力永远比你想象的要强。

【END】

Comments is loading... / 评论区正在加载中...