网络与资源加载优化
问题
如何从 HTML、HTTP 缓存、资源优先级、CDN、压缩和数据请求等层面系统优化页面加载?
网络优化不是“把所有资源都 preload”。先看请求瀑布,找出关键资源什么时候被发现、为什么排队、下载多久、是否阻塞渲染:
- HTML 要尽快返回,文本资源使用 Brotli/gzip,静态资源部署到合适的 CDN。
- 带内容哈希的 JS/CSS/图片可长期缓存并设
immutable;HTML 通常短缓存或协商更新,避免新旧版本错配。 - LCP 图片应尽量在初始 HTML 可发现,不要懒加载;必要时对唯一高概率 LCP 使用
fetchpriority="high",CSS 背景图等晚发现资源再考虑 preload。 preconnect、preload、prefetch都会消耗连接或带宽,只给有数据证明的关键资源使用。- HTTP/2/3 下不要继续做大量域名分片;连接复用、缓存命中和优先级通常比“请求数量越少越好”更重要。
- 数据请求避免串行 waterfall,能并行就并行;服务端用
Server-Timing暴露网关、缓存、数据库等阶段。
一、先读懂资源加载链路
排查时不要只看 duration,还要回答:
- 资源是否足够早被发现?
- 浏览器是否给了合适的优先级?
- 是否在等待连接、缓存验证或更高优先级资源?
- 下载慢是文件大、网络慢还是 CDN 节点慢?
- 下载后是否还有解析、执行、字体整形或图片解码成本?
二、HTML 与 TTFB
TTFB 高可能来自重定向、CDN 回源、应用渲染、数据库、第三方接口或冷启动。只说“加 CDN”不足以定位。
const navigation = performance.getEntriesByType(
'navigation',
)[0] as PerformanceNavigationTiming | undefined;
if (navigation) {
console.table({
redirect: navigation.redirectEnd - navigation.redirectStart,
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
connect: navigation.connectEnd - navigation.connectStart,
tls: navigation.secureConnectionStart > 0
? navigation.connectEnd - navigation.secureConnectionStart
: 0,
ttfb: navigation.responseStart - navigation.startTime,
downloadHtml: navigation.responseEnd - navigation.responseStart,
});
}
服务端优化方向:
- 减少重定向和跨地域回源。
- 数据请求并行化,避免服务端 waterfall。
- 为公共 HTML 或 RSC 结果设计可验证的边缘缓存。
- 缓慢依赖设置超时、fallback 和熔断,不让一个非关键模块阻塞整页。
- 能流式返回时先发送静态壳与关键内容,不必等所有区域完成。
三、HTTP 缓存与版本一致性
3.1 推荐按资源类型制定策略
| 资源 | 常见策略 | 原因 |
|---|---|---|
| HTML | no-cache 或短 max-age + revalidate | 及时获得新资源映射和页面内容 |
| 带内容哈希 JS/CSS | public, max-age=31536000, immutable | URL 变化即新版本,可长期缓存 |
| 图片/字体 | 内容哈希或稳定版本 URL + 长缓存 | 复用率高,减少重复传输 |
| 用户私有接口 | private/no-store 或明确的客户端缓存 | 避免公共缓存串用户 |
| 可共享公共接口 | s-maxage、ETag、stale-while-revalidate | 由业务新鲜度决定 |
# 内容哈希静态资源:例如 app.a1b2c3.js
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML:允许缓存但每次使用前验证;也可按业务设置短 max-age
location = /index.html {
add_header Cache-Control "no-cache";
}
no-cache 不等于“不存储”no-cache 允许缓存,但复用前要向服务器验证;no-store 才是不应存储。对所有 HTML 一律 no-store 会失去验证缓存能力,对敏感内容又不能只用 no-cache,必须根据数据性质选择。
3.2 防止部署后白屏
- HTML 和静态资源不要使用同一个永久 Cache First 策略。
- 新版本上线后保留旧 hash 资源一段时间,覆盖已打开标签和渐进发布窗口。
- 动态 import 失败时识别 chunk/version 错误,受控刷新一次并防止循环。
- Service Worker 激活、缓存清理和客户端接管要有明确版本协议。
- 发布系统应验证 HTML 引用的所有资源在 CDN 上真实存在后再切流。
四、压缩与传输体积
HTML、CSS、JavaScript、JSON 和 SVG 等文本资源应使用 Brotli 或 gzip。Brotli 常比 gzip 进一步减少约 15%–20%,但实际收益取决于内容和压缩级别;构建期高压缩等级适合静态资源,动态响应要平衡 CPU 延迟。
需要区分三种体积:
- 源文件大小:开发源码,不代表用户下载量。
- 压缩传输大小:Network 中的 transferred,适合网络预算。
- 解压/解析后大小:影响 JS parse/compile、内存和执行成本。
一个 200KB gzip 的 JavaScript 在浏览器中可能展开为更大的源码和对象,因此“网络很快”不代表主线程也快。
五、资源发现与优先级
5.1 资源提示对比
| 能力 | 做什么 | 适用场景 | 主要风险 |
|---|---|---|---|
dns-prefetch | 提前 DNS 查询 | 低概率但可能访问的跨域 | 收益有限 |
preconnect | 提前 DNS/TCP/TLS | 很快会用到的关键跨域 | 连接和 socket 浪费 |
preload | 强制提前请求当前页资源 | 晚发现但确定需要的关键资源 | 抢带宽、重复下载 |
prefetch | 低优先级获取未来资源 | 高概率下一步导航 | 用户未导航时浪费流量 |
fetchpriority | 提示相对请求优先级 | 高概率 LCP 图、低优先级缩略图 | 设置太多后失去区分度 |
<!-- 关键跨域图片 CDN:只对真正关键的 origin 预连接 -->
<link rel="preconnect" href="https://img.example.com" crossorigin />
<!-- CSS 背景 LCP 图在外部 CSS 中才会被发现,可选择性预加载 -->
<link
rel="preload"
as="image"
href="/hero-1280.avif"
fetchpriority="high"
/>
5.2 响应式图片预加载
直接 preload 一个固定 href 可能让小屏设备下载错误尺寸。响应式图片应带 imagesrcset 和 imagesizes:
<link
rel="preload"
as="image"
imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
imagesizes="100vw"
fetchpriority="high"
/>
<img
src="/hero-640.avif"
srcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
sizes="100vw"
width="1280"
height="720"
fetchpriority="high"
alt="产品主视觉"
/>
通常一个页面只给 1 个、最多极少数高概率 LCP 资源设置 high。首屏以外图片使用 loading="lazy",LCP 图不要懒加载。
5.3 JavaScript 加载顺序
| 写法 | HTML 解析 | 执行顺序 | 适用场景 |
|---|---|---|---|
| 普通 script | 阻塞 | 立即 | 极少数必须同步的启动脚本 |
defer | 并行下载 | DOM 解析后按文档顺序 | 传统应用主脚本 |
async | 并行下载 | 下载完立即执行,顺序不保证 | 独立第三方脚本 |
type="module" | 默认 defer 语义 | 按模块依赖执行 | 现代 ESM 应用 |
动态 import 能减少首屏 JavaScript,但过度拆分会增加请求、模块调度、运行时和缓存失效成本。应按路由、重功能和稳定依赖边界分包,而不是“一组件一 chunk”。
六、LCP 资源加载
LCP 可以拆成四段:
| 阶段 | 优化问题 |
|---|---|
| TTFB | HTML 为什么还没到? |
| Resource Load Delay | LCP 资源为什么这么晚才开始? |
| Resource Load Duration | 文件为什么下载这么久? |
| Element Render Delay | 文件到了,为什么还没画出来? |
常见反模式:
- Hero 图片由客户端 JavaScript 请求数据后才创建。
- LCP 图写在外部 CSS 背景中,却没有必要的 preload。
- LCP 图错误设置
loading="lazy"。 - 同时 preload 多张轮播图、所有字体和大量 chunk,关键图反而排队。
- 图片下载很快,但主线程被 Hydration 或第三方脚本阻塞,render delay 很高。
七、HTTP/2、HTTP/3 与连接复用
现代协议支持同连接多路复用,不应继续机械应用 HTTP/1.1 时代的域名分片和超大雪碧图策略。
- 同源或少量稳定 CDN origin 更容易复用连接、TLS 和拥塞窗口。
- HTTP/2 多路复用仍会受服务端优先级、带宽竞争和 TCP 丢包影响。
- HTTP/3 使用 QUIC,改善连接建立和部分丢包场景,但不会让大 JS 免于解析执行。
- 合并资源和拆分资源都要围绕缓存、关键路径和运行时成本做权衡。
八、数据请求与瀑布流
8.1 能并行的请求不要串行
// ❌ 无依赖却串行
const profile = await fetchProfile();
const recommendations = await fetchRecommendations();
// ✅ 同时启动
const [profile, recommendations] = await Promise.all([
fetchProfile(),
fetchRecommendations(),
]);
有依赖的请求可以考虑:
- 后端聚合/BFF,减少客户端往返。
- 服务端在同一数据中心并行取数。
- 将稳定公共数据缓存到边缘节点。
- 流式返回先到的区域,而不是等待全部数据。
- 对搜索、自动补全等过期请求使用 AbortController。
8.2 重试、超时和幂等
网络优化不能靠无限重试“提高成功率”。
async function fetchWithTimeout(
input: RequestInfo | URL,
timeoutMs = 5_000,
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs);
try {
return await fetch(input, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
这里的 5 秒是假设普通 API 的示例值;支付、搜索、报表和大模型请求应使用不同超时。只对网络错误、429、部分 5xx 等可恢复错误按策略重试,并使用指数退避与抖动;写请求必须确认幂等性。
九、第三方资源治理
第三方脚本既消耗网络,也可能在下载后产生长任务:
- 列出每个第三方的业务负责人、加载页面、触发条件、体积和 CPU 时间。
- 同意管理、广告和客服脚本按用户授权与页面需要加载。
async/defer只影响加载/执行顺序,不会减少脚本执行成本。- 对供应商做 request blocking 实验,比较 LCP、TBT、INP 和业务指标。
- 自托管可能改善连接与缓存,也会承担更新、安全和合规责任,不能一概而论。
十、让前后端耗时可以关联
Server-Timing: edge;dur=18, cache;desc="MISS";dur=3, app;dur=95, db;dur=41
Timing-Allow-Origin: https://www.example.com
const navigation = performance.getEntriesByType(
'navigation',
)[0] as PerformanceNavigationTiming;
for (const metric of navigation.serverTiming) {
console.log(metric.name, metric.duration, metric.description);
}
跨域 Resource Timing 需要资源响应提供 Timing-Allow-Origin,否则部分阶段和传输大小会显示为 0。这个 0 不能直接当作缓存命中或请求失败。
十一、资源加载检查清单
- HTML TTFB 已按边缘、应用、数据库拆解
- HTML、hash 静态资源、API 使用不同缓存策略
- 发布流程能防止 HTML 与 chunk 版本错配
- 文本资源已压缩,并同时查看传输和解压后体积
- LCP 资源在初始 HTML 可发现且没有懒加载
- preload/preconnect/fetchpriority 数量少且有瀑布图证据
- 数据请求没有无意义的串行 waterfall
- 第三方脚本有负责人和网络/CPU 预算
- Server-Timing 与 traceId 能关联后端阶段
- 优化在冷/热缓存、不同网络和真实用户数据中验证
常见面试问题
Q1: preload 和 fetchpriority 有什么区别?
答案:
preload 解决“浏览器发现得太晚”,会强制提前发起请求;fetchpriority 只提示同类资源的相对优先级,不负责让浏览器提前发现。CSS 背景 LCP 图可能两者一起用;初始 HTML 中已有的 img 通常先评估只加 fetchpriority="high"。
Q2: 为什么不能把所有首屏资源都 preload?
答案:
preload 会抢占连接和带宽。预加载字体、图片和 chunk 太多时,真正的 LCP 图、CSS 或主脚本反而排队;未使用的 preload 还会浪费流量。应只用于晚发现且当前页面确定需要的少量关键资源。
Q3: no-cache 和 no-store 有什么区别?
答案:
no-cache 允许存储,但每次复用前必须验证;no-store 要求缓存不要存储响应。频繁更新但不敏感的 HTML可使用 no-cache,敏感私有数据才考虑 no-store,并结合 private、鉴权和代理缓存规则。
Q4: 为什么带 hash 的静态资源可以缓存一年?
答案:
内容改变后 URL 也改变,旧 URL 的内容不会被覆盖,因此可以设置长 max-age 和 immutable。HTML 负责引用新 URL,所以 HTML 自身不能用同样的永久缓存策略。
Q5: HTTP/2 下请求数量还重要吗?
答案:
仍然重要,但不再是简单的“越少越好”。每个请求仍有 header、调度和服务端成本,过度分包也会增加模块执行;反过来,超大 bundle 又损害缓存和首屏。应围绕关键路径、缓存复用和执行成本选择粒度。
Q6: LCP 图片已经压得很小,为什么 LCP 仍然慢?
答案:
可能慢在 TTFB、资源发现、优先级或元素渲染。图片由 JS 晚创建、被懒加载、排在大量 preload 后面,或者下载完成后主线程忙于 Hydration,都可能让 LCP 继续很高。
Q7: async 是否一定比 defer 更快?
答案:
不是。async 下载完立即执行,可能在关键渲染阶段打断主线程,且多个脚本顺序不保证;defer 在 DOM 解析后按文档顺序执行,更适合有依赖的应用入口。独立第三方脚本才常使用 async,并仍要治理执行成本。
Q8: Brotli 比 gzip 好,是否所有响应都用最高压缩级别?
答案:
静态资源可在构建阶段使用较高压缩级别;动态响应的高压缩级别会增加服务端 CPU 和 TTFB。还要考虑 CDN 是否已压缩、极小文件是否值得压缩,以及客户端兼容回退。
Q9: 如何排查跨域资源 Timing 全是 0?
答案:
先检查资源响应是否设置 Timing-Allow-Origin。没有 TAO 时浏览器会隐藏详细阶段和大小,transferSize 为 0 不能直接推断请求失败或命中缓存。
Q10: 为什么 Service Worker 可能导致白屏?
答案:
如果 SW 返回旧 HTML 或旧入口脚本,而 CDN 已删除对应 chunk,就会产生版本错配。应按资源类型制定缓存策略、保留旧 hash 资源、正确处理 activate/client claim,并提供受控更新与恢复。
Q11: 前端如何避免接口 waterfall?
答案:
无依赖请求同时启动;有依赖请求考虑 BFF 聚合、服务端并行取数、预取或流式返回。还要避免组件层级导致“父组件取完才挂子组件、子组件再取”的隐式 waterfall。
Q12: 如何判断第三方脚本值不值得保留?
答案:
记录它的网络体积、主线程时间、长任务和业务收益,通过 request blocking 或灰度实验比较性能与转化。没有负责人、没有收益指标、长期超预算的第三方应删除、替换或只在必要页面按条件加载。