为什么你的网站应该发送更少请求,而不是只缩小文件
小文件并不是免费的。现代 HTTP 降低了请求开销,但没有让它变得无关紧要。
目录
“把每个文件都变小就行”的舒适迷思
多年来,Web 性能建议听起来很简单:压缩一切,minify 一切,让每个资源都更小。
这个建议在大方向上仍然正确。40 KB 的脚本通常比 400 KB 的脚本更好。优化过的图片比原始导出更好。Brotli、AVIF、CSS minification、tree shaking 和字体子集化都很重要。
但在许多生产网站上,更大的问题不再是某个文件过大,而是在页面感觉可用之前,浏览器必须请求的东西太多。
一个页面的文件大小看起来可能很克制,却仍然很慢,因为它发送了 120 个请求:CSS 片段、JavaScript 分块、第三方标签、字体文件、tracking pixels、图标 sprites、JSON endpoints、preloads、analytics beacons 和缓存重新验证。每一个都可能“很小”。合在一起,它们会形成一条漫长而脆弱的 waterfall。
实用规则是:当单个资源已经得到合理压缩后,减少请求数量通常比从每个文件里再削掉几个 KB 更能改善用户体验。
请求不只是字节
一次网络请求不只是数据传输。它是一连串工作。
浏览器必须发现资源,决定何时获取它,将它与其他资源一起调度,发送 headers,等待服务器,接收 headers,解析响应,通常还要解压,然后用它做一些有用的事。
那个“有用的事”可能很昂贵。JavaScript 文件必须被解析、编译和执行。CSS 文件可能阻塞渲染。字体文件可能延迟文本可读时间,或导致布局偏移。图片可能影响 Largest Contentful Paint 元素。第三方脚本可能带来自己的依赖链。
这就是为什么即使文件很小,请求数量仍然重要。一个 3 KB 的脚本,如果它阻塞渲染、到达很晚,并且在错误的时刻于主线程执行,可能比一张 30 KB 的图片更糟。
如果你正在阅读 Lighthouse 报告,并觉得自己被几十条分散的警告惩罚了,先看请求 waterfall,而不是单个分数。我们在如何不慌张地阅读 Lighthouse 报告中有一个实用演练,但简短版本是:找出什么阻塞了首次渲染,什么延迟了主要内容。
“但 HTTP/2 已经解决了这个问题”,大多并没有
HTTP/2 和 HTTP/3 改变了请求的成本结构。它们引入了 multiplexing、header compression 和更好的连接行为。简单来说,浏览器变得更擅长通过更少的连接发送多个请求。
这是一次真正的改进。它也终结了一些旧习惯,比如极端的 CSS sprites,以及仅仅为了规避连接数限制而构建的巨大 concatenated bundles。
但 HTTP/2 并没有让请求变得免费。
当许多资源共享一个连接时,multiplexing 会有帮助,但浏览器仍然必须为它们确定优先级。服务器仍然必须响应。客户端仍然必须处理每个响应。拥塞、丢包、TLS negotiation、DNS lookup、缓存未命中和主线程压力仍然存在。
HTTP/3 改进了一些传输行为,尤其是连接迁移和传输层 head-of-line blocking 方面。它并没有移除发现、调度、下载、解析和执行资源的成本。
因此,现代目标不是“把所有东西打进一个巨大的文件”。而是“发送更少的关键请求,并让剩下的请求都有明确目的”。
waterfall 才是真相所在
性能问题很少以单一指标的形式宣布自己。它们会以形状呈现出来。
打开浏览器的 network panel,观察最初几秒。问问自己:
- 主内容出现之前,有多少请求已经开始?
- 哪些请求阻塞了渲染?
- 重要资源是否被发现得太晚?
- 第三方脚本是否在与第一方 CSS、字体或图片竞争?
- 是否有许多文件返回 304 响应,而不是直接从缓存提供?
- 图标、字体或 UI 片段是否被拆成了比页面实际需要更多的文件?
一个快速页面通常有一条乏味的早期 waterfall。少量关键资源很早到达。非关键资源等待。第三方脚本被延迟、限制或移除。浏览器不必在能够绘制页面之前被迫同时处理二十个优先级。
一个慢页面往往有一条紧张的 waterfall:许多小文件、许多来源,以及许多迟来的发现。
更小的文件仍然重要——只是重要性并不相同
这不是反对压缩或优化。这是在反对只优化字节,却忽视协同。
当资源很大、阻塞渲染,或位于主内容路径上时,更小的文件最重要。例如:
- hero image 应该使用合适的尺寸和编码。
- 阻塞渲染的 CSS 应该保持精简。
- 首次交互所需的 JavaScript 应该最小化。
- 字体应该进行子集化、压缩,并限制为实际使用的字重。
字体是一个常见例子。团队常常纠结一个字体文件是 24 KB 还是 31 KB,却同时发布六种字重、两种样式和多个字体族。更好的修复方式不是从一个文件里削掉 7 KB,而是发送更少的字体文件。如果排版是你性能工作的一部分,web fonts 仍然是大多数网站上最容易获得的性能收益之一。
图片也遵循同样的模式。AVIF 或 WebP 可以节省有意义的字节,但在首屏发送十张装饰性图片仍然不是好计划。选择更好的格式当然重要,但也要质疑每张图片是否真的需要被请求。关于格式选择,我们的指南 AVIF 何时胜过 WebP,以及何时不会 可以作为这项请求数量工作的有用补充。
许多小文件的隐藏成本
许多小请求往往会制造一些问题;如果你只看总传输字节数,这些问题不会显现出来。
1. 发现太晚
浏览器无法请求它尚未发现的东西。CSS 文件可以引用字体。脚本可以 import 另一个脚本。组件可以在 hydration 之后请求 JSON。每个依赖都会在链条中增加一步。
链条越深,重要工作开始得越晚。
2. Header 开销
每个请求和响应都包含 headers。Header compression 会有帮助,尤其是在 HTTP/2 和 HTTP/3 上,但它不会消除开销。Cookies 会让情况更糟。如果你的网站在每个请求中都发送大型 cookies,小资源在实践中就不再那么小。
这也是静态资源通常应该放在无 cookie 的路径或域名上的原因之一,也是 cache headers 值得关注的原因。如果 headers 在生产环境中的行为很奇怪,调试 redirects 和 HTTP headers 通常比猜测更快。
3. 主线程打断
许多 JavaScript chunks 可能造成反复的解析和执行工作。即使每个 chunk 都很小,浏览器也可能不断停下来评估代码。这会伤害 Interaction to Next Paint,并让页面感觉抖动。
用户并不在乎每个文件都很小。他们在乎的是点击菜单花了 600 毫秒。
4. 缓存复杂性
在谨慎处理时,拆分资源可以改善缓存。一个稳定的 vendor bundle 加上一个变化的 app bundle,可能是不错的拆分。
但过度 chunking 可能适得其反。更多文件意味着更多缓存查找、更多重新验证机会、更多版本协调,以及更多意外使本不需要变更的资源失效的方式。
Bundling 回来了,但需要判断力
Web 性能的第一个时代喜欢 bundling,因为浏览器有严格的连接限制。随后 HTTP/2 到来,许多团队又猛烈转向激进的 code splitting。其中一些是有用的,另一些则变成了迷信。
理性的中间路径是 route-aware bundling。
对于典型的营销网站或内容网站:
- Inline 或仅加载初始渲染所需的 CSS。
- 保持全局 JavaScript 很小。
- 避免把微小模块拆成单独的网络请求。
- 延迟不需要立即使用的交互功能。
- 移除无法证明其成本合理的第三方脚本。
对于应用:
- 按 route 或主要功能拆分,而不是按每个组件拆分。
- 让共享依赖保持稳定且可缓存。
- 只 preload 很快就一定会需要的资源。
- 避免在公开页面加载 admin、dashboard、editor 或 experiment 代码。
- 衡量交互成本,而不只是 bundle size。
Bundling 并不自动就是好的。Code splitting 也不自动就是好的。真正有用的问题是:这个拆分是否帮助浏览器更早交付下一个有意义的用户体验?
第三方请求值得额外怀疑
第一方请求至少在你的控制之下。第三方请求往往比看起来更慢、更不可预测,也更昂贵。
一个 tag manager 就可能触发 analytics、ads、heatmaps、chat widgets、A/B testing、consent tools 和 personalization scripts。每个 vendor 都可能带来更多请求。有些会很早运行。有些会阻塞主线程。有些会在不经过你发布流程的情况下改变。
最好的第三方优化是删除。第二好的方式是延迟。
在添加第三方脚本之前,问问:
- 它是否需要在用户看到页面之前加载?
- 它是否需要在每个页面加载?
- 它能否在 consent、interaction 或 idle time 之后加载?
- 内部由谁负责它?
- 哪个指标能证明它值得付出性能成本?
这就是性能成为治理问题的地方。必须有人被允许说不。
一个实用的请求减少检查清单
从最重要的页面开始:首页、定价页、产品页、结账页、注册页或主要落地页。然后逐项检查 waterfall。
移除
- 删除未使用的 JavaScript 和 CSS。
- 移除旧实验、废弃的 pixels 和重复的 analytics。
- 去掉未使用的字体字重和图标库。
- 在合适的情况下,用 CSS 替代装饰性图片。
有判断地合并
- 将总是一起加载的微小 JavaScript 模块打包。
- 合并阻塞同一渲染路径的小 CSS 文件。
- 当 SVG sprites 或 inline SVG 能减少请求且不损害可维护性时,用于重复图标。
延后
- Lazy-load 首屏以下的图片。
- 将非关键脚本延迟到 first paint 或用户交互之后。
- 仅在需要时加载评论、embeds、地图、聊天和视频播放器。
正确缓存
- 对带版本的静态资源使用长期缓存。
- 避免对很少变化的文件进行不必要的重新验证。
- 保持 HTML 新鲜,但让 hashed assets 保持缓存。
重新测量
每次变更后,再次检查 waterfall。目标不是完美分数。目标是更少的关键请求、更早的有用渲染,以及更少的主线程干扰。
好的状态是什么样
一个健康的页面不一定拥有尽可能少的请求。它拥有一条小而明确的关键路径。
浏览器拿到 HTML、必要的 CSS、如果有的话还包括主内容图片,也许还有一个导航或首屏交互所需的小脚本,以及让文本可读所需的最小字体集合。其他一切都等待自己的时机。
这就是一个只是被优化过的页面,和一个感觉很快的页面之间的区别。
缩小文件仍然值得做。但如果网站已经得到了合理压缩,下一个性能收益通常不是从某个 bundle 中再省下 2 KB,而是少一个阻塞请求、少一个字体文件、少一个第三方脚本、少一条依赖链。
更少的请求会让浏览器的工作更简单。简单往往意味着更快,只是我们并不总愿意承认这一点。