JavaScript 的性能预算:在持续集成中限制代码包大小
了解为何 JavaScript 的成本远高于其下载时间,如何设定合理的性能预算,以及如何让 Webpack 在超出预算时阻止构建。
来源: The Cost of JavaScript — Performance budgets and why your bundle size is driving users away
在开发者的笔记本电脑上能瞬间加载的页面,在实际用户的手机上却可能卡顿数秒。这种差异往往并非仅由网络速度造成:当每千字节JavaScript代码传输到性能较低的设备时,都需要CPU进行处理。本指南将解释这种性能开销的来源,介绍如何将其转化为明确的性能预算,以及如何在代码进入生产环境之前,通过构建流程阻止那些超出预算的改动。
开发者设备与用户设备之间的差异
大多数前端团队都在性能极高的硬件上进行开发和测试:最新的笔记本电脑、有线或光纤连接,以及运行在本地主机的开发服务器。在这样的环境下,几乎所有的代码包都显得很快,因此人们往往认为代码包大小只是以后需要优化的问题,而非设计上的约束条件。
真实的用户使用环境与测试环境不同。很大一部分访问者使用的是中端安卓手机、信号不稳定的4G网络或拥挤的公共Wi-Fi。想象一下,一个需要大量数据的控制面板在本地浏览器中能立即响应,但在旧款iPhone上进行测试时,交互控件却需要大约三秒才能正常使用。代码本身并无问题,只是这些设备处理相同脚本所需的时间要长得多。
这种延迟会带来业务上的损失。未经优化的额外JavaScript代码会不断推迟用户开始交互的时间,而在登录或将商品加入购物车等关键操作中,这种延迟的影响尤为明显。当这些步骤显得过于缓慢时,用户就会放弃操作,这就是为什么过大的代码包会悄悄将用户驱离的原因。
为何JavaScript的成本远高于其下载时间
多年来,网络延迟一直是主要的瓶颈,性能优化工作主要集中在图像文件大小以及HTTP/2等传输方式的改进上。这些问题依然重要,但对于JavaScript而言,如今更大的挑战在于数据字节到达之后的处理过程。
下载完成后脚本还不能立即使用。浏览器必须先解析它、编译它,然后再执行它,而这些操作大多在主线程上完成,而主线程正是负责处理触摸、点击以及页面渲染的线程。即便一个400KB大小的代码包能够快速下载,页面仍可能显得反应迟缓,因为设备正忙于处理代码而非响应用户输入。
在入门级手机上,这种解析-编译-执行循环会明显影响从交互到下次绘制的时间(INP),这一指标用于衡量页面对用户操作的响应速度。它直接决定着核心网页指标的得分,而较差的核心网页指标往往会导致用户留存率下降及业务表现不佳。因此,任何旨在提升这些指标的努力都必须包括减少移动设备需要解析和运行的JavaScript代码量,而不仅仅是缩小文件下载大小。
什么是性能预算以及如何设定它
JavaScript性能预算是对页面、路由或代码块所能加载的脚本量设定的明确限制。从技术上讲它只是一个数字,但其真正价值在于组织层面:它是整个团队共同遵守的约定。这样一来,就不必在发布后发现性能问题后匆忙修复,该预算起到了护栏的作用,从源头阻止这些问题被合并进来。
实用建议:性能预算能将原本需要被动应对的性能问题,转变成如同可访问性或安全要求那样的、可在设计阶段就加以考虑的约束条件。
设定限制的第一步是确定具体且可衡量的标准。一个广泛采用的目标是将任意页面中关键路径上的 JavaScript 代码在压缩后的大小控制在约 170KB 以内,这一数值的选择是为了确保普通移动设备能够快速达到可交互状态。虽然应结合自身用户群体实际情况来验证这些数值,但基本原则是:一旦设定了限制,每一个新的依赖项和功能从一开始就必须证明其占用空间的合理性。
以下几条实用建议有助于更好地管理资源预算:
- 按每个页面路径或入口点来设定预算,而非仅针对整个应用,这样功能复杂的管理员区域就不会占用首页的预算额度。
- 每次都采用相同的方法进行测量(压缩后大小与原始大小对比),这样才能保证不同版本之间的数据具有可比性。
- 当用户群体或设备类型发生变化时,应重新审视这些限制标准,而非在每次构建失败时就擅自提高上限。
在 CI/CD 中实现防护机制自动化
那些依赖他人主动检查的预算方案很难长久维持。某个功能被推送后,审查人员只关注逻辑部分,没人查看打包报告,结果就会出现回归问题。唯一可扩展的方案是将检查步骤直接集成到流水线中,这样每次变更都能被自动检测。
注意:由于检查在 CI 中运行,每个拉取请求都会自动根据预算标准进行验证,无需任何人刻意去检查。
像 webpack 这样的构建工具可以帮你实现这一点:将过大尺寸的输出配置为视为构建失败而非警告。这样,相关反馈会立即出现,并与导致问题的变更一同显示。如果某个拉取请求引入的库生成的代码块超出了限制,整个构建流程就会显示错误状态,提交者必须在合并之前解决该问题。
下面的配置使用了 webpack 的 performance 选项。将 hints 设置为 'error' 可以把大小警告升级为会导致构建失败的错误。maxAssetSize 限制了每个单独生成的文件的大小(此处为 250 KiB),而 maxEntrypointSize 则限制了单个入口点所加载的所有资源的总大小(此处为 500 KiB);这两个数值均以字节为单位。assetFilter 函数负责决定哪些文件需要被处理,在此例中它会跳过源映射文件和 JSON 文件,以避免产生误报。
module.exports = {
// ... other webpack configuration ...
performance: {
hints: 'error',
// Emit an error whenever a bundle exceeds the size limits
maxAssetSize: 250 * 1024,
// Maximum size in bytes for any generated asset (e.g., 250 KiB)
maxEntrypointSize: 500 * 1024,
// Maximum size in bytes for an entrypoint (e.g., 500 KiB)
assetFilter: function(assetFilename) {
// Optionally exclude certain assets (e.g., source maps) from performance hints
return !/\.(map|json)$/.test(assetFilename);
}
}
};
请注意,webpack 是将这些限制与生成的文件大小进行比较的,而这些文件大小通常并非用户下载时的压缩后大小。如果您的预算是以压缩后的千字节来定义的,那么在进行此检查时需将其转换为原始大小,或者另行添加步骤来测量 gzip 或 Brotli 压缩后的输出大小。
控制代码包大小在预算范围内的策略
预算设定与自动检查能帮助您了解何时超出限制;而要始终保持在限制范围内,则需要精心设计。最有效的技巧早已为人所知,且这些技巧结合使用效果最佳。
按路由和功能拆分代码
代码拆分将一个庞大的单一包分解为多个较小的模块,按需加载。常见的应用场景是企业级应用中的复杂管理控制台:通过动态的import()语句加载,只有当管理员实际访问该页面时才会获取其代码。普通用户则不会下载这部分内容,从而能减少初始加载量数百千字节。
利用代码剔除功能删除无用代码
代码剔除功能会移除那些从未被使用的导出项,因此你自己编写的模块以及第三方库中未被使用的部分不会出现在最终输出中。该功能在ES模块语法以及以可剔除形式发布的依赖项上效果最佳,因此在依赖某个包之前,值得先查看其构建方式。
注意:为单个辅助函数引入庞大的库是导致应用体积膨胀的最常见原因之一。应寻找更轻量的替代方案,或仅导入所需的特定函数。
推迟首屏不需要的操作
对于对初始界面并非至关重要的组件,如对话框、页面下方的小部件或很少使用的面板,可采用懒加载方式,将其解析和执行成本推迟到实际需要时再处理。结合代码分割与树摇动技术,这能在用户最可能进行交互的最初几秒内保持主线程的空闲状态。
总结
- 在移动设备上,JavaScript的高开销部分在于其在主线程上的解析、编译和执行过程,这会直接影响输入延迟及其他核心网页指标。
performance.hints: 'error',可以及时发现引入新问题的拉取请求中的性能退化。