【翻译】JavaScript 臃肿的三大支柱

0 阅读14分钟

JavaScript 臃肿的三大支柱

发布时间:2026-03-12。

过去几年里,我们看到 e18e 社区显著增长,也因此出现了更多聚焦性能的贡献。这里面很大一部分来自“cleanup”行动:社区一直在修剪那些冗余、过时或无人维护的包。

这个过程里最常被提到的话题之一就是“依赖臃肿”(dependency bloat)——也就是 npm 依赖树随着时间越来越大,而其中常常包含平台早已原生提供的、早就显得冗余的代码。

这篇文章里,我想简要看看我认为依赖树臃肿最主要的三种类型,它们为什么会存在,以及我们如何开始处理这些问题。

1. 更老运行时支持(含安全与跨 realm)

is-string 依赖图

上面这张图在很多 npm 依赖树里都很常见:一个看起来本应原生可用的小工具函数,后面再接一串类似的小型深层依赖。

那为什么会这样?为什么我们需要 is-string,而不是 typeof 判断?为什么我们需要 hasown,而不是 Object.hasOwn(或者 Object.prototype.hasOwnProperty)?主要有三点:

  1. 支持非常老的引擎
  2. 防御全局命名空间被篡改
  3. 跨 realm 的值

支持非常老的引擎

在这个世界的某些地方,确实有人需要支持 ES3——比如 IE6/7,或非常早期版本的 Node.js。1

对这些人来说,我们今天习以为常的很多东西并不存在。比如,他们没有下面这些能力:

  • Array.prototype.forEach
  • Array.prototype.reduce
  • Object.keys
  • Object.defineProperty

这些都是 ES5 特性,也就是说它们在 ES3 引擎里根本不存在。

对这些还在跑老引擎的不幸人群来说,他们只能自己把这些东西重写一遍,或者依赖别人提供 polyfill。

当然,更理想的情况是他们能升级。

防御全局命名空间被篡改

第二个原因是有些包强调“安全性”。

基本上,在 Node 内部有一个 “primordials” 的概念。它本质上是把全局对象在启动时先包一层并保存起来,后续 Node 自己都用这份保存值,以免有人篡改全局命名空间后把 Node 本体搞坏。

例如,如果 Node 自己要用 Map,而我们又把 Map 重新定义了,就可能把 Node 弄崩。为了避免这种情况,Node 会保留原始 Map 的引用并导入使用,而不是每次都去访问全局。

你可以在 Node 仓库这份文档 里看到更多细节。

这对 引擎自身 来说非常合理,因为引擎不应该因为某个脚本污染了全局就直接挂掉。

有些维护者也认为这同样是构建 的正确方式。这也是为什么上图里会出现 math-intrinsics 这类依赖:它基本是在重新导出各个 Math.* 函数,以避免被篡改。

跨 realm 的值

最后是跨 realm 的值。简单说,就是把值从一个 realm 传到另一个 realm,例如从网页传给子 <iframe>,或者反过来。

在这种情况下,iframe 里的 new RegExp(pattern),和父页面里的 RegExp不是 同一个。也就是说 window.RegExp !== iframeWindow.RegExp,因此如果值来自 iframe(另一个 realm),val instanceof RegExp 就会是 false

举个例子,我是 chai 的维护者之一,我们就遇到过这个问题。我们需要支持跨 realm 的断言(因为测试运行器可能在 VM 或 iframe 里跑测试),所以不能依赖 instanceof。因此我们会用 Object.prototype.toString.call(val) === '[object RegExp]' 来判断一个值是不是正则;这种方式跨 realm 也成立,因为它不依赖构造函数本身。

在上面那张图里,is-string 本质上也是在做类似的事:应对 new String(val) 从一个 realm 传到另一个 realm 的情况。

为什么这会成为问题

上述这些做法,对非常小的一部分人来说都很合理。如果你要支持非常老的引擎、要跨 realm 传值,或者要防御环境被篡改,这些包确实是你需要的。

问题在于,绝大多数人并不需要这些。我们运行的是近十年的 Node 版本,或者常青浏览器。我们不需要支持 ES5 之前的环境,不会跨 frame 传这些值,环境被污染会破坏包的话我们通常会直接卸掉它。2

这些面向小众兼容性的层层包装,不知不觉进了日常包的“热路径”。

真正需要它们的小群体,本应是主动去找这些特殊包的人;但现实反过来了,变成 所有人都在为此买单

2. 原子化架构

有些人认为,包应该尽可能拆到接近原子级别,形成一组小构件,以便后续复用并拼成更高层能力。

这种架构会让我们得到像下面这样的依赖图:

execa 依赖图

正如你看到的,最细粒度的代码片段也会拥有自己的包。例如在写这篇文章时,shebang-regex 的内容就是:

const shebangRegex = /^#!(.*)/;
export default shebangRegex;

按这种原子粒度拆分后,理论上我们就能通过“连线拼装”轻松组装高层包。

为了直观看出这些原子包有多细,这里给几个例子:

  • arrify - 把一个值转成数组(Array.isArray(val) ? val : [val]
  • slash - 把文件路径里的反斜杠替换为 /
  • cli-boxes - 一个 JSON 文件,存的是字符框边缘字符
  • path-key - 获取当前平台下 PATH 环境变量的键名(Unix 是 PATH,Windows 是 Path
  • onetime - 保证一个函数只被调用一次
  • is-wsl - 判断 process.platform 是否为 linuxos.release() 是否包含 microsoft
  • is-windows - 判断 process.platform 是否为 win32

例如如果我们想构建一个新 CLI,可以拉几个这样的包就开工,不用关心具体实现。像 env['PATH'] || env['Path'] 这种判断,我们不必自己写,直接装个包即可。

为什么这会成为问题

现实里,这些包大多并没有成为预期中的可复用构件。它们要么在更大依赖树里以多个版本重复出现,要么是单用途包,仅被另一个包使用一次。

单用途包

我们看看一些最细粒度包的实际使用:

  • shebang-regex 几乎只被同一维护者的 shebang-command 使用
  • cli-boxes 几乎只被同一维护者的 boxenink 使用
  • onetime 几乎只被同一维护者的 restore-cursor 使用

每个都只有一个消费者,意味着它们在逻辑上几乎等同于内联代码。

但它们却带来了额外获取成本(npm 请求、tar 解包、带宽等)。

重复

看一下 nuxt 的依赖树,我们会看到一些这类构件出现重复版本:

  • is-docker(2 个版本)
  • is-stream(2 个版本)
  • is-wsl(2 个版本)
  • isexe(2 个版本)
  • npm-run-path(2 个版本)
  • path-key(2 个版本)
  • path-scurry(2 个版本)

把这些逻辑内联并不意味着代码不再重复,但至少不会再支付版本解析、冲突处理、下载获取等包装成本。

内联让“重复”几乎免费,而打包让“重复”变贵。

更大的供应链攻击面

包越多,供应链攻击面就越大。

每个包都可能成为维护、稳定性、安全方面的故障点。

例如,去年这些小包中的一位维护者账号被入侵,结果是数百个微型构件同时受影响,最终连我们真正安装的高层包也被波及。

Array.isArray(val) ? val : [val] 这么简单的逻辑,大概率并不需要一个独立包来承担安全和维护风险。直接内联即可,同时也规避被供应链投毒的风险。

和第一支柱类似,这种理念也进入了“热路径”,而且很可能不该在那里。最终还是那句话:大家都在为几乎没有收益的事情买单。

3. “迟迟不退场”的 Ponyfill

eslint-plugin-react polyfills

3

如果你在构建应用,可能会想使用某些目标引擎尚未支持的“未来”特性。这种情况下,polyfill 会很有帮助——它会在对应位置提供回退实现,让你像用原生功能一样使用它。

例如,temporal-polyfill 会为新的 Temporal API 提供 polyfill,这样无论引擎是否支持,我们都可以用 Temporal

那如果你是在写一个库呢?该怎么办?

通常来说,库不应主动加载 polyfill,因为这属于消费者职责,库也不应该去改动它所处环境。作为替代,一些维护者会用所谓 ponyfill(继续保持“独角兽、闪光和彩虹”的命名风格)。

Ponyfill 本质上是“通过导入来使用”的 polyfill,而不是“修改全局环境”的 polyfill。

这个思路在某种程度上成立:库作者可以导入一个实现,若原生存在就透传原生,否则走回退实现。整个过程不污染环境,因此对库来说更安全。

比如 fastly 提供了 @fastly/performance-observer-polyfill,其中同时包含 PerformanceObserver 的 polyfill 与 ponyfill。

为什么这会成为问题

这些 ponyfill 在当年确实发挥了作用——让库作者既能用未来特性,又不污染环境,还不用要求消费者自己搞清楚要装哪些 polyfill。

问题出在它们“超期服役”。当它们补的那个能力已经被我们关心的所有引擎支持后,ponyfill 本应被移除;但这一步往往没有发生,结果是它们在“已经不需要”之后依然长期留在依赖里。

于是我们现在仍背着大量包,只是为了兼容那些我们十年前就普遍拥有的能力。

比如:

  • globalthis - globalThis 的 ponyfill(2019 年已广泛支持,每周 4900 万下载)
  • indexof - Array.prototype.indexOf 的 ponyfill(2010 年已广泛支持,每周 230 万下载)
  • object.entries - Object.entries 的 ponyfill(2017 年已广泛支持,每周 3500 万下载)

除非这些包是因为 第一支柱 的原因才必须保留,否则大多数情况下它们继续存在,只是因为没人认真去把它们移除。

当所有长期支持版本引擎都已有该能力时,ponyfill 就应当下线。4

我们能做什么?

这些臃肿如今深埋在依赖树中,要完全梳理并修正到理想状态并不轻松。这件事需要时间,也需要维护者和使用者都投入大量精力。

但我确实认为,只要大家一起做,这件事可以有明显进展。

从问自己两个问题开始:“我为什么会有这个包?”以及“我真的需要它吗?”

如果你发现某个依赖显得多余,就向维护者提 issue,询问是否可以移除。

如果你发现某个直接依赖有大量此类问题,可以找替代方案。一个不错的起点是 module-replacements 项目。

用 knip 清理未使用依赖

knip 是个很优秀的项目,可以帮助你找出并移除未使用依赖、死代码等问题。在这个话题里,它可以作为清理依赖树的良好起点。

它不一定能直接解决上面三类问题,但在进入更复杂的优化工作前,先用它做一轮清理通常很有价值。

你可以在它的文档里查看更多关于未使用依赖检测的说明。

用 e18e CLI 检测可替换依赖

e18e CLI 有一个非常有用的 analyze 模式,可以判断哪些依赖已经不再必要,或者已有社区推荐替代方案。

例如,你可能会看到这样的输出:

npx @e18e/cli analyze

...

│  Warnings:
│    • Module "chalk" can be replaced with native functionality. You can read more at
│      https://nodejs.org/docs/latest/api/util.html#utilstyletextformat-text-options. See more at
│      https://github.com/es-tooling/module-replacements/blob/main/docs/modules/chalk.md.

...

借助这个结果,我们可以很快定位可清理的直接依赖。然后还可以用 migrate 命令自动完成部分迁移:

npx @e18e/cli migrate --all

e18e (cli v0.0.1)

┌  Migrating packages...
│
│  Targets: chalk
│
◆  /code/main.js (1 migrated)
│
└  Migration complete - 1 files migrated.

在这个例子里,它会把 chalk 迁到 picocolors,后者更小但功能相同。

未来这个 CLI 还会根据你的环境给建议——例如当你的 Node 足够新时,它可能会建议直接使用原生 styleText,而非再装颜色库。

用 npmgraph 分析依赖树

npmgraph 是一个很好的可视化工具,用来定位依赖臃肿来源。

比如看一下文章发布当时 ESLint 的依赖图底部

eslint 依赖图

我们可以看到这里的 find-up 分支是孤立的,也就是其他分支并不复用它的深层依赖。对于“向上查找文件系统路径”这么简单的能力,也许不需要 6 个包。接着我们就可以找替代方案,比如 empathic:它的依赖图更小,但能达成同样目标。

Module replacements

module replacements 项目正在被社区当作中心数据集,用于记录哪些包可以被原生功能,或更高性能替代方案,所替换。

如果你想找替代方案,或者只是想检查自己的依赖,这个数据集非常有用。

同样地,如果你在依赖树里发现某些包已被原生能力淘汰,或者有更成熟的替代实现,也非常值得把这些信息贡献回这个项目,让其他人也能受益。

配合这份数据,还有一个 codemods project,提供 codemod 来自动把部分包迁移到推荐替代项。

结语

我们所有人都在为一个极小群体的特殊架构偏好,或其所需的极端向后兼容级别,持续买单。

这不完全是那些包作者的错,毕竟每个人都可以按自己的方式构建。

很多作者属于更早一代、非常有影响力的 JavaScript 开发者:他们构建这些包时,平台能力远不如今天,很多 API 与跨端兼容性当时都还不存在。

在那个时代,他们的做法很可能就是最合理的。

问题在于我们后来没有“迁移到新时代”。

即使这些能力已经存在多年,我们今天仍在下载这些臃肿依赖。

我认为解法是把成本反转回来:让这小群真正需要特殊兼容的人承担这部分复杂度,形成他们自己的特殊栈。

而其他人使用现代、轻量、广泛支持的代码路径。

希望像 e18enpmx 这样的项目,能通过文档与工具继续推动这件事。

你也可以从重新审视自己的依赖开始,多问一句“为什么”。

向你的依赖维护者提 issue,询问它们是否真的还需要这些包,以及原因是什么。

这件事是可以修好的。

脚注

我相信确实有人需要这么老的引擎,但我也很想看到一些真实案例。

这些臃肿大多来自过去那个“当时确实有必要”的阶段,因为那时平台显然还没有今天这么完善。我认为在当时,那样的决策/架构大概率是正确的。

文中提到的大多数“广泛支持年份”来自 MDN,若早于 MDN 则来自兼容性数据。

“Ponyfill” 话题整体上仍有争议。我认为达到 LTS 后就应移除,但也有人不同意,主张它应“永久保留”。

  1. 我相信确实有人需要这么老的引擎,但我也很想看到一些真实案例。
  2. 这些臃肿大多来自过去那个“当时确实有必要”的阶段,因为那时平台显然还没有今天这么完善。我认为在当时,那样的决策/架构大概率是正确的。
  3. 文中提到的大多数“广泛支持年份”来自 MDN,若早于 MDN 则来自兼容性数据。
  4. “Ponyfill” 话题整体上仍有争议。我认为达到 LTS 后就应移除,但也有人不同意,主张它应“永久保留”。