《前端性能优化实战手册》系列 · 第 2 篇
第 1 篇我们把 Lighthouse 从 60 优化到了 95,靠的是减少体积。但体积再小,资源加载的方式不对,用户依然会觉得"慢"。
本篇我们进入传输层:HTTP 协议怎么选、浏览器预处理怎么用、CDN 和边缘计算怎么落地、Service Worker 怎么把二次访问变成"秒开"。
前言:为什么体积优化完,还是觉得慢?
答案很简单:体积优化解决的是"下载多少",资源加载策略解决的是"什么时候下载、以什么顺序下载、从哪下载"。
举个极端例子:你把所有 CSS 都内联进了 HTML,体积确实小了,但浏览器要等 HTML 解析完才能拿到样式------首屏直接 FOUC(无样式内容闪烁)。这就是加载策略没做对。
本篇的优化清单:
| 优化项 | 解决的问题 | 典型收益 |
|---|---|---|
| HTTP/3 QUIC | 高延迟网络下的握手开销 | TTFB 降 30-50% |
| preconnect / dns-prefetch | 第三方域名的连接建立延迟 | 首屏快 100-300ms |
| preload / prefetch | 关键资源晚加载、非关键资源早占用 | LCP 提升明显 |
| fetchpriority | 浏览器资源调度顺序错误 | TBT 降低 |
| CDN + 边缘计算 | 物理距离导致的延迟 | 全球访问统一快 |
| Service Worker | 二次访问重复下载 | 二次访问"秒开" |
一、HTTP 协议层:HTTP/2 vs HTTP/3 QUIC
1.1 你还在用 HTTP/1.1 吗?
2026 年了,如果你的站点还在跑 HTTP/1.1,那资源加载这块你至少浪费了 30% 的性能。
HTTP/1.1 的核心问题是队头阻塞(Head-of-Line Blocking):同一个 TCP 连接上,前一个请求没完成,后面的请求就得排队。浏览器为了绕过这个限制,会开 6 个并发连接------但每个连接都有 TCP 握手 + TLS 握手的固定开销。
1.2 HTTP/2:多路复用
HTTP/1.1: 6 个 TCP 连接,每个连接串行请求
[conn1] ╌─ req1 ── resp1 ── req2 ── resp2
[conn2] ╌─ req3 ── resp3
...
HTTP/2: 1 个 TCP 连接,所有请求并行(多路复用)
[conn1] ╌─ stream1 ─┐
╌─ stream2 ─┤ 帧交错传输,互不阻塞
╌─ stream3 ─┘
HTTP/2 用二进制分帧解决了应用层队头阻塞,一个连接上可以并行传输多个请求/响应。
但 HTTP/2 有个隐藏陷阱 :它只解决了应用层阻塞,TCP 层的队头阻塞还在。一旦某个 TCP 包丢失,整个连接上的所有流都要等这个包重传。在丢包率高的移动网络下,HTTP/2 甚至比 HTTP/1.1 还慢。
1.3 HTTP/3:QUIC 彻底解决队头阻塞
HTTP/3 把底层从 TCP 换成 UDP + QUIC:
HTTP/3 + QUIC:
┌─────────────────────────────────────┐
│ HTTP/3 (应用层,多路复用) │
├─────────────────────────────────────┤
│ QUIC (运行在 UDP 上,每个流独立) │
│ stream1 ╌─┐ 丢包只影响 stream1 │
│ stream2 ╌─┤ 其他流继续传输 │
│ stream3 ╌─┘ │
├─────────────────────────────────────┤
│ UDP (无连接,无队头阻塞) │
└─────────────────────────────────────┘
QUIC 的核心优势:
- 0-RTT / 1-RTT 连接建立 --- TCP + TLS 需要 3 次握手(至少 1.5 个 RTT),QUIC 在已有连接信息时可做到 0-RTT 恢复
- 流级独立 --- 一个流丢包不影响其他流
- 连接迁移 --- 手机从 WiFi 切到 4G,TCP 连接会断,QUIC 基于连接 ID 可以无缝迁移
2026 年的判断:主流 CDN(Cloudflare、AWS CloudFront、阿里云 CDN)都已支持 HTTP/3。如果你的站点是面向移动端用户的,上 HTTP/3 几乎是免费的 TTFB 提升。
nginx
# Nginx 启用 HTTP/3(需 Nginx 1.25+)
server {
listen 443 quic reuseport;
listen 443 ssl;
http2 on;
ssl_protocols TLSv1.3;
ssl_early_data on; # 0-RTT 支持
add_header Alt-Svc 'h3=":443"; ma=86400'; # 告诉浏览器支持 HTTP/3
}
二、浏览器预处理:别等浏览器"想起来"才加载
浏览器很聪明,但它不是你肚子里的蛔虫。资源放在哪、什么时候加载,你不告诉它,它就按默认策略来------通常不够快。
2.1 preconnect:提前建立连接
最典型的应用:你的静态资源在 CDN 域名 cdn.example.com,但 HTML 在 www.example.com。
html
<!-- 告诉浏览器:提前和 CDN 域名建立 TCP + TLS 连接 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
ROI:preconnect 一个跨域域名通常能省 100-300ms 的首屏时间。尤其是字体、第三方 SDK 这种"发现得晚但要得急"的资源。
⚠️ 坑点:preconnect 是有成本的(占用浏览器连接池),不要 preconnect 超过 4-6 个域名,更不要 preconnect 你其实不用的域名。
2.2 dns-prefetch:比 preconnect 更轻量
如果只需要省 DNS 解析时间(不想提前做 TLS 握手),用 dns-prefetch:
html
<link rel="dns-prefetch" href="https://analytics.example.com" />
适用场景:你知道会用到、但不确定什么时候用的第三方域名(比如用户点击后才加载的聊天 SDK)。
2.3 preload:强制提前加载关键资源
preload 告诉浏览器:这个资源当前页面马上就要用,现在就加载,不要等。
html
<!-- 关键 CSS 提前加载 -->
<link rel="preload" href="/css/critical.css" as="style" />
<!-- 关键字体(配合 crossorigin,否则会重复下载) -->
<link
rel="preload"
href="/fonts/NotoSansSC-subset.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- 首屏首图 -->
<link rel="preload" as="image" href="/images/hero.avif" fetchpriority="high" />
关键:as 属性必须正确 ------as="style"、as="script"、as="font"、as="image"。如果 as 写错,浏览器会以低优先级加载,等于白做。
⚠️ 最大坑:preload 滥用会适得其反。preload 一个非关键资源 = 抢了关键资源的带宽。只 preload LCP 相关的资源。
2.4 prefetch:为"下一个页面"做准备
prefetch 用于加载将来页面可能用到的资源(用户大概率会点进去的下一页):
html
<!-- 用户大概率会进产品详情页,提前把它的 JS chunk 拉下来 -->
<link rel="prefetch" as="script" href="/assets/product-detail-chunk.js" />
js
// Vue Router 的 prefetch(webpack magic comment)
const ProductDetail = () =>
import(
/* webpackChunkName: "product-detail" */
/* webpackPrefetch: true */
'./views/ProductDetail.vue'
);
适用场景:列表页 → 详情页。用户划列表时,浏览器空闲就把详情页的代码拉好了,点进去瞬间加载。
2.5 prerender:直接渲染下一个页面
比 prefetch 更激进------直接把下一个页面在后台渲染好:
html
<link rel="prerender" href="/checkout" />
⚠️ 谨慎使用:prerender 消耗的资源(CPU、内存)远大于 prefetch。只用于转化率极高、几乎必点的下一步(如电商结算页)。
三、fetchpriority:让浏览器排对队
2023 年 Chrome 正式支持 fetchpriority 属性,这是直接告诉浏览器哪个资源更重要的杀手锏。
3.1 默认调度的问题
浏览器默认按资源类型给优先级:
- CSS、字体:高
- 同步 JS:高
- 图片:低到中
- 异步 JS:低
但现实是:你的 LCP 图片可能比某个非关键 CSS 更重要,浏览器却给它更低的优先级。
3.2 fetchpriority 实战
html
<!-- LCP 图片:强制最高优先级 -->
<img src="/hero.avif" fetchpriority="high" alt="Hero" />
<!-- 首屏下方的图片:降低优先级,别抢带宽 -->
<img src="/below-fold.jpg" fetchpriority="low" loading="lazy" alt="" />
<!-- 非关键 JS:降低优先级 -->
<script src="/analytics.js" fetchpriority="low" defer></script>
<!-- 关键 JS:提升优先级 -->
<script src="/main-app.js" fetchpriority="high"></script>
配合 preload 的经典组合(解决 LCP 图片被低优先级拖累):
html
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
实测数据:给 LCP 图片加 fetchpriority="high",LCP 时间平均下降 5-15%。
四、CDN 与边缘计算(Edge Functions)
4.1 CDN 不是"开了就完事"
很多团队 CDN 配置就是"把静态资源丢上去",但 CDN 的缓存策略直接决定你的 TTFB。
关键配置:合理的缓存 TTL
nginx
# 静态资源(带 hash 命名):缓存 1 年,永不变
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML(每次都要最新的):不缓存或极短
location / {
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# 图片
location /images/ {
add_header Cache-Control "public, max-age=86400";
}
immutable 关键字 很重要:告诉浏览器"这个文件永远不变,连 ETag 校验都省了"。带 hash 的构建产物(如 main.a1b2c3.js)天然适合。
4.2 边缘计算:把逻辑搬到离用户最近的地方
传统架构:
用户(北京) → 源站(杭州) → 数据库(杭州)
≈ 30-50ms 网络延迟
边缘计算架构(Cloudflare Workers / Vercel Edge Functions):
用户(北京) → 边缘节点(北京) → 直接响应
≈ 5ms 网络延迟
实战:用 Edge Function 做 A/B 测试分流(无需回源):
js
// Vercel Edge Middleware
import { NextResponse } from 'next/server';
export function middleware(req) {
const variant = req.cookies.get('ab-variant')?.value
|| (Math.random() < 0.5 ? 'a' : 'b');
const res = NextResponse.next();
res.cookies.set('ab-variant', variant, { maxAge: 86400 });
// 直接改写响应头,边缘完成分流
res.headers.set('x-ab-variant', variant);
return res;
}
边缘计算能做的远不止 A/B 测试:
- 边缘 SSR:在离用户最近的节点渲染 HTML
- 边缘缓存:API 响应在边缘缓存,源站压力骤降
- 边缘鉴权:JWT 校验在边缘完成,非法请求根本不进源站
五、Service Worker:让二次访问"秒开"
前面的所有优化,首次访问都能受益。但 Service Worker 是二次访问的王炸------它能让回访用户感觉页面是"本地应用"级的快。
5.1 四种核心缓存策略
| 策略 | 行为 | 适用场景 |
|---|---|---|
| Cache First | 先查缓存,没有再请求网络 | 静态资源(JS/CSS/图片) |
| Network First | 先请求网络,失败降级缓存 | 实时数据(API 响应) |
| Stale-While-Revalidate | 立即返回缓存,同时后台更新 | 产品列表、新闻 feed |
| Network Only | 只走网络 | 支付、结账等强一致性场景 |
5.2 Stale-While-Revalidate 实战
这是最常用也最优雅的策略------用户永远先拿到旧数据(快),后台悄悄更新(新):
js
// sw.js
const CACHE = 'v1';
self.addEventListener('fetch', (event) => {
const { request } = event;
// 只对 GET 请求做缓存
if (request.method !== 'GET') return;
event.respondWith(
caches.match(request).then((cached) => {
// 后台更新:无论缓存命中与否,都去网络拉最新版
const fetchPromise = fetch(request)
.then((response) => {
// 只缓存有效响应
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => cached); // 网络失败就返回缓存
// 有缓存立即返回,同时后台更新
return cached || fetchPromise;
})
);
});
5.3 用 Workbox 省掉手写样板
手写 Service Worker 容易出 bug。用 Google 的 Workbox:
js
// sw.js --- Workbox
import { registerRoute } from 'workbox-routing';
import {
StaleWhileRevalidate,
CacheFirst,
NetworkFirst,
} from 'workbox-strategies';
// 静态资源:缓存优先
registerRoute(
({ request }) =>
['style', 'script', 'worker'].includes(request.destination),
new CacheFirst({ cacheName: 'static-assets' })
);
// API 数据:Stale-While-Revalidate
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({ cacheName: 'api-cache' })
);
// 关键页面:网络优先,失败降级
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages' })
);
5.4 注册 Service Worker
js
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.error('SW registration failed:', err);
});
});
}
⚠️ 坑点 :Service Worker 只在 HTTPS(localhost 除外)下工作;更新 SW 后用户需要刷新才能加载新版本------用 skipWaiting() + clients.claim() 控制更新时机。
六、综合实战:一个真实的加载优化案例
把本篇 + 第 1 篇串起来,看一个真实项目的优化全流程:
╔══════════════════════════════════════════════╗
║ 优化前 ║
╠══════════════════════════════════════════════╣
║ TTFB: 800ms (HTTP/1.1, 源站在杭州) ║
║ FCP: 3.2s ║
║ LCP: 5.8s ║
║ 二次访问 FCP: 2.9s (每次都重新下载) ║
╚══════════════════════════════════════════════╝
↓ 第1篇:体积优化(图片/JS/CSS/字体/CLS)
↓ 本篇:资源加载策略
╔══════════════════════════════════════════════╗
║ 优化后 ║
╠══════════════════════════════════════════════╣
║ TTFB: 120ms (HTTP/3 + 边缘节点) ║
║ FCP: 1.1s (preload + preconnect) ║
║ LCP: 2.1s (fetchpriority + 图片优化)║
║ 二次访问 FCP: 0.3s (Service Worker 缓存) ║
╚══════════════════════════════════════════════╝
关键指标提升:
TTFB: 800ms → 120ms ↓ 85%
FCP: 3.2s → 1.1s ↓ 66%
LCP: 5.8s → 2.1s ↓ 64%
二次访问: 2.9s → 0.3s ↓ 90%
这个项目的完整优化清单:
html
<!-- index.html 头部 -->
<head>
<!-- 1. 提前连接 CDN 和字体域名 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<!-- 2. 预加载 LCP 图片和关键字体 -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<link
rel="preload"
as="font"
href="/fonts/NotoSansSC-subset.woff2"
type="font/woff2"
crossorigin
/>
<!-- 3. 关键 CSS 内联(见第1篇) -->
<!-- 4. 预取下一页 chunk -->
<link rel="prefetch" as="script" href="/product-detail-chunk.js" />
</head>
js
// 服务端:HTTP/3 + 边缘 + 缓存策略
// (见上文 Nginx / Edge Middleware / CDN 配置)
// 客户端:注册 Service Worker
// (见上文 Workbox 配置)
本篇总结 & 下篇预告
本篇核心要点
- 协议层优先 --- HTTP/3 QUIC 免费省 TTFB,移动端尤其明显
- preconnect 是关键 --- 跨域资源提前建连,省 100-300ms
- preload 只给 LCP --- 滥用会抢带宽,得不偿失
- fetchpriority 兜底 --- 直接告诉浏览器哪个资源更重要
- CDN 缓存策略要细 --- 带 hash 的资源缓存 1 年 + immutable
- 边缘计算把逻辑推到用户门口 --- A/B 测试、SSR、鉴权都能在边缘做
- Service Worker 是二次访问的必杀 --- Stale-While-Revalidate 最优雅
🔜 下篇预告
第 3 篇:《渲染性能深度优化》
- 长列表虚拟化(Virtual List)实战
- 防抖/节流的正确姿势与 requestIdleCallback
- Web Worker 把重计算搬出主线程
- React 并发渲染(Concurrent Mode)与 useTransition
- INP 指标优化全攻略
📌 《前端性能优化实战手册》系列
- ✅ 第 1 篇:从 Lighthouse 60 到 95 的完整路径
- ✅ 第 2 篇:资源加载策略全解(本文)
- 🔜 第 3 篇:渲染性能深度优化
- 🔜 第 4 篇:构建产物体积治理
- 🔜 第 5 篇:性能监控与 CI/CD 集成