缩小 JavaScript 代码:掌握 Bundler 优化

介绍

在过去 15 年中,JavaScript 生态系统迅速发展,推出了无数简化开发的工具。但这些工具的代价是:包大小增加。事实上,HTTP Archive 的数据显示,每页传输的 JavaScript 平均大小已从 2010 年的 90 KB 激增至 2024 年的 650 KB(来源)。

尽管采用率不断提高且压缩技术不断进步,但这一趋势仍未显示出放缓的迹象。随着我们不断添加功能,挑战依然存在:**我们如何才能减少 JavaScript 的发布?**

奇怪的是,解决方案既简单又困难。简单的部分是可以快速见效的项目级调整。困难的部分是产生持久的影响,这需要整个社区的变革来改进打包器、库和工具。

本文重点介绍项目的可行改进,涵盖:

  • 捆绑器:优化构建工具以减少输出大小。
  • 库:明智地选择和使用外部依赖项。
  • 您的项目:缩小捆绑包的实用步骤。
  • 未来的文章将讨论我们可以进行的整个生态系统的改进,但现在,让我们来讨论一下这些因素如何导致捆绑软件臃肿——以及如何管理它们。

    为什么优化 JavaScript 很重要

    JavaScript 是现代网络互动的引擎,但它并非免费。**JavaScript 是浏览器必须处理的计算成本最高的资源**。它通常是决定页面速度快慢的瓶颈,因为臃肿的包会阻碍渲染并降低整体性能。

    JavaScript 包越大,加载、解析、编译和运行所需的时间就越长。这会延迟其他所有操作 — — 比如显示内容或让用户与页面交互。对于使用光纤连接的高端笔记本电脑的用户来说,这可能是一个小小的烦恼。但对于使用低功率手机或网络不稳定的用户来说,这可能是留下或完全离开您的网站的区别。

    减少 JavaScript 包大小的第一步是 tree shake(或“死代码消除”),大多数打包器都会立即执行此操作。但所有打包器都一样吗?

    打包工具

    JavaScript 中的打包技术已经取得了长足进步 — 从手动连接和任务运行器到复杂的打包器。如今,打包器性能是重点关注点,开发人员优先考虑更快的构建速度。但是,**构建速度并不是一切**。他们生成的打包器的大小也同样重要,因为较小的打包器意味着用户的加载时间更快。

    为了追求更好的性能,我们已从使用 JavaScript 编写打包器转向使用 Rust 和 Go 等语言编写打包器。这种转变需要从头开始编写,因此必须重新实现旧打包器中存在的每个功能和优化。从长远来看,这可能会带来回报。然而,从短期来看,这意味着它们缺少 JavaScript 打包器多年来开发的一些功能,例如良好的 **tree shake** 或 **minification**。而这些功能正是可以帮助我们最小化打包器大小的功能。

    基准

    当然,说起来容易做起来难,那么我们就来看看数字吧,好吗?

    让我们比较八个流行的库,并将它们与七个流行的打包器进行打包。为了公平起见,我使用了:

  • 节点 22.12.0
  • 使用 node --run 运行 10 次(其中 2 次预热运行)的平均构建时间
  • 配置删除所有注释,包括许可证,因为打包器对它们的处理方式不同
  • 您可以查看基准设置存储库以了解确切的配置。

    已测试的捆绑器:

  • esbuild(0.24.0)带有内置压缩器
  • 带有内置压缩器的 Parcel (2.13.2)
  • 使用 rollup-plugin-esbuild minifier 进行 Rolldown(0.15.1)
  • Rollup(4.28.0)与 rollup-plugin-esbuild minifier
  • 带有内置压缩器的 Rspack (1.1.5)
  • Vite (6.0.3) 带有内置压缩器
  • webpack (5.97.1) 搭配 swc minifier
  • 请注意,在撰写本文时,Rolldown 仍处于 alpha 阶段,因此它处于劣势,并且其结果可能会随着时间的推移而改善。

    测试的库:

  • 图表.js
  • ckeditor5
  • d3
  • 手持式
  • 卢克森
  • 摩比克斯
  • tippy.js
  • 佐德
  • 这些库的大小和功能各不相同——有些库的功能几乎像独立应用程序一样。

    构建速度

    让我们从构建速度开始,因为这似乎是开发人员非常关心的事情。将所有这些库捆绑在一起时,esbuild 是赢家,构建时间为 229 毫秒。与最慢的构建时间 7.78 秒相比,它快了 34 倍以上。

    Build speed benchmark results

    根据这些结果,我们可以将打包工具分为三类:

  • 速度极快™:esbuild、Rolldown、启用缓存的 Parcel(<1 秒,esbuild 在 250 毫秒以下)。
  • 更快:Rspack(~2.5 秒)。
  • 慢:无缓存的 Parcel、Vite、Rollup、webpack(每个 5+ 秒)。
  • 差异非常明显。例如,Rolldown 和 Rspack 分别比旧版本 Rollup 和 webpack 快 7 倍和 3 倍,同时保持理论上的向后兼容性。切换到这些较新的打包工具可以显著提高大型项目的生产力。

    输出尺寸

    当谈到输出大小时,差异并不像构建时间那么大,但它们仍然很重要。

    汇总结果

    当将所有八个库捆绑在一起时,Rollup 和 Vite 是赢家,输出大小为 2081 KiB。与最大输出大小 2491 KiB 相比,输出小了 19.5% 以上。

    **输出大小的 19.5% 差异是相当大的**:在较慢的 3G 连接上,最小的包可能需要大约 5.7 秒才能下载,而最大的包则需要接近 7 秒。解析和执行时间也会随包大小而变化,因此实际差异可能更加明显。

    Build size benchmark results

    根据这些结果,我们可以将捆绑器的输出分为三类:

  • 最小:Rollup、Vite、esbuild、Rolldown 和 Parcel(~2081-2162 KiB)。
  • 好的:webpack(~2317 KiB)。
  • 大:Rspack(~2491 KiB)。
  • 借助这组特定的库,webpack 和 Rspack 生成的包最大。但这是有原因的。在后面的部分中,我们将更详细地介绍它,并介绍一种优化方法,它将有助于减少 webpack 和 Rspack 中的包大小,**使 webpack 包与 Rollup 和 Vite 相媲美**。

    个别图书馆

    汇总结果并不能说明全部情况,因为您不太可能在项目中使用上面列出的所有库。更有趣的是这些打包器如何处理单个库。

    Build size benchmark results for individual libraries

    对于像“chart.js”这样的库,打包器的选择会极大地影响输出大小,差异可达 70%。这凸显了使用特定依赖项测试打包器的重要性。在大多数其他情况下,差异要小得多,最高可达 22%。

    此外,虽然总体而言 webpack 处于中间位置,但在 8 个案例中有 4 个表现最佳。但是,由于在捆绑“handsontable”和“chart.js”时表现更差,因此最终还是处于中间位置。这意味着,根据您使用的库,webpack 可能是一个不错的选择。

    另一方面,我们有 Rspack。它在 8 种情况中的 5 种情况下表现最差,并且生成的打包文件比其他打包工具大得多。

    捆包大小与输出速度

    如图所示,一些捆绑器可能会产生比其他捆绑器更大的构建,并且结果也可能取决于所使用的库。

    在为您的项目选择打包器或从一个打包器迁移到另一个打包器时,不要只比较构建时间,还要比较生成的打包器大小。您可能会发现自己为了更大的打包器而牺牲了更快的构建速度。您还应该**使用您使用的库进行测试**,以确保它在您的特定情况和设置中运行良好。

    例如,在 Angular 从 webpack 切换到 esbuild 后,一些开发人员报告说空的 Angular 应用程序的大小增加了约 20 KB。这完美地凸显了构建速度与包大小之间的权衡。

    这并不是说你不应该关注构建速度,因为它对开发人员的生产力和幸福感很重要。CI 构建时间和合并代码所需的时间之间也存在相关性。

    Correlation between build speed and merge time

    选择打包器时,首先要看它提供的功能。然后力求在 **构建速度和打包大小之间取得平衡**。选择能够在您满意的时间内生成最小打包器的打包器。

    测试项目中的一些代表性库。如果您的依赖项构成了代码库的大部分,那么您在这些基准测试中看到的差异可以很好地预测您的情况。

    图书馆

    接下来是外部库,它们通常构成 JavaScript 包的大部分。在我开发的许多(如果不是大多数)应用程序中,它们占据了包大小的大部分。这就是为什么明智地选择(和使用)它们如此重要。

    黄金但古老

    我们中的许多人安装了“lodash”、“axios”或“moment”等库,只是为了使用一个功能——这导致应用程序臃肿。这些库非常棒,具有重要的历史意义,但随着它们变得越来越流行,人们创建了更轻量的替代方案,并且它们的一些功能被添加到语言本身中。

    我们可以利用这一点。我可以列出这些库的原生 API 或更新、更小的替代方案,但已经有很多文章涉及这些内容。而且还有太多其他库,不可能全部涵盖。

    这就是为什么我只会给你一个一般性的建议,让你看看你使用的库,看看你是否可以用原生 API 或更小的替代方案删除或替换它们。你可能不需要 * 网站是一个很好的入门资源。

    Bundler 特定的优化

    打包器支持不同的功能,这也会对输出大小产生重大影响。我们已经在初始基准测试中看到了这一点,其中 webpack 和 Rspack 生成了最大的打包文件。但这是有原因的。

    在内部,`handsontable` 库使用 `moment` 来处理日期,它使用动态 `require()` 调用来加载语言环境文件。由于动态 `require()` 调用依赖于运行时值,因此 webpack 和 Rspack 会捆绑所有语言环境文件以备不时之需。根据您的设置,这可能是您想要的。

    在大多数情况下,您不需要所有语言环境,只需要您选择的语言环境。这就是为什么我们可以配置 webpack 和 Rspack 不打包它们,这大大减少了打包文件的大小。

    // webpack.config.mjs / rspack.config.mjs
    
    export default {
      module: {
        noParse: [ /moment.js/ ]
      }
    };

    这个小小的改变将两个包中的“handsontable”大小减少了近 20%,并使包含所有依赖项的 webpack 包与 Rollup 和 Vite 达到一致。

    Build size benchmark results without locales

    优化安装路径

    大多数库默认未针对大小进行优化,但有些库提供特殊安装路径或部分构建。即使在我们测试的库中,`chart.js`、`handsontable` 和 `ckeditor5` 也提供了一种通过仅包含您需要的部分来减小库大小的方法。让我们以 `ckeditor5` 为例。

    Comparison between the normal and optimized builds

    默认安装路径会导致软件包大小在 666 到 786 KiB 之间。但是,如果我们使用优化的安装路径,软件包大小会降至 603 到 659 KiB 之间。根据打包器的不同,软件包大小可减少 7% 到 23%。

    重复的依赖项

    另一件需要注意的事情是重复依赖项。这是 JavaScript 应用程序中一个令人惊讶的常见问题。例如,Bluesky 嵌入小部件有两个版本的“zod”验证库。删除重复项可将包大小减少约 9%。

    发生此问题通常不是因为您拉取了同一个库的两个不同版本,而是因为您和其中一个外部库依赖同一个库,但版本不同。通常可以通过更新您依赖的库来解决此问题。

    您的项目

    考虑到所有这些,我们终于可以转到拼图的最后一块——您的项目。以下是您可以采取的措施来缩小您的软件包并提高性能。

    检查您的捆绑包

    第一步是**可视性**。如果不了解包里面的内容,减小包大小就变成了一场猜谜游戏。为此,您可以使用我创建的名为 Sonda 的包分析器和可视化工具。它适用于上述大多数打包器(Parcel 除外),并准确显示构成包的各个文件的大小。

    您可以先将其安装到您的项目中,然后目视检查您的捆绑包的各个部分。

    Tree map chart for Sonda project itself

    一旦您充分了解了软件包中的内容并确定了可以优化的部分,您就可以点击图形图块来查看:

  • 压缩前后的文件大小,
  • 导入所选文件的文件列表,
  • 甚至检查捆绑包中包含的源代码部分。
  • Sonda 还会警告您有关重复依赖项,以便您可以快速识别并修复问题的根源。

    理想情况下,您不仅应该进行一次性检查,还应将持续监控作为 CI 管道的一部分。跟踪随时间推移的变化(尤其是在大型项目中),可以帮助您防止小变化随着时间的推移而滚雪球般发展为严重膨胀。

    删除或优化外部库

    最快的代码是你**不**发送的代码。尽可能:

  • 删除可以用本机 API 替换的库。
  • 将重量级的库替换为较小的替代库。
  • 如果库支持,请使用优化的安装路径。
  • 使用代码分割

    如果您无法删除应用程序的某些部分,请尝试代码拆分。代码拆分允许您推迟加载应用程序的某些部分,直到需要它们为止,从而缩短初始加载时间。

    使用动态“import()”按需加载模块。例如,如果某个特定功能在用户单击按钮之前不需要,则将其加载推迟到那时。

    现代前端框架支持开箱即用的延迟加载,使得将代码拆分集成到您的工作流程中变得比以往更加容易。

    遵循最佳实践

    这是一般性建议,但值得重复。请遵循最佳做法,例如:

  • 尽量使用最新的目标,这样代码就不会被不必要地转译或填充。一些填充可能会添加大量现代浏览器中根本不需要的代码,但许多环境仍然默认添加它们。您还可以设置提醒以每年更新目标。
  • 定期更新依赖项,因为新版本有时会更小或更快。这也可以防止您处理安全漏洞或重复依赖项。
  • 评估您已有的或正在考虑添加的每个依赖项。如果您无法证明其大小合理,请不要添加它或寻找较小的替代方案。
  • 加入生态系统绩效 (e18e) 社区

    如果您有兴趣让网络运行得更快,或者只是想学习新知识,那么您应该考虑加入生态系统性能社区。我们专注于三个主要领域:

  • 清理——通过删除多余的依赖项或用现代替代品替换它们来改进软件包。
  • 加速——提高广泛使用的软件包的性能。
  • 升级——构建过时软件包的现代替代品。
  • 结论

    我希望本文能说明,您可以用更少的代码实现相同的功能。如果不加以管理,包大小可能会变得无法控制,但即使是很小的更改也可以显著提高性能。

    从今天开始:分析您的软件包、测试新工具或替换重量级库。其影响将令您大吃一惊。

    希望您喜欢这篇文章。如果您有任何问题或意见,或者想了解有关特定主题的更多信息,请在下面的评论中告诉我。如果您想了解有关 JavaScript 性能、捆绑和 tree-shaking 主题的更多信息,您可以在这里或在 BlueSky 上关注我并加入 e18e 社区。