网络请求优化
网络请求优化
网络请求是前端性能的关键瓶颈。一次典型的页面加载,用户看到内容的快慢,往往不取决于 JavaScript 执行了多少毫秒,而取决于"数据从服务器到浏览器"这条链路走得快不快。优化网络请求可以显著提升页面加载速度和用户体验。
概念:什么是"网络请求优化"
网络请求优化,指的是围绕"浏览器向服务器要资源"这个动作,从建立连接、传输数据、缓存复用、调度优先级四个维度做的一整套工程实践。
可以把浏览器加载一个页面类比成"点外卖":
任何一环慢了,整顿饭都晚。网络优化就是把这五件事分别做到极致。
为什么重要:一个真实的数字账
先看一组被反复引用的行业数据,理解"为什么值得投入":
| 场景 | 数据来源 | 结论 |
| --- | --- | --- |
| 页面加载时间从 1s 增加到 3s | Google/SOASTA 研究 | 跳出率概率提升约 32% |
| 加载时间从 1s 到 5s | Google/SOASTA 研究 | 跳出率概率提升约 90% |
| 首页加载每慢 100ms | Amazon 经典结论 | 销售额下降约 1% |
| 搜索延迟增加 400ms | Google 实验 | 搜索量下降约 0.6% |
| 页面加载加快 850ms | Pinterest 重构 | 注册转化提升 15%,SEO 流量提升 15% |
这些数字说明:网络性能不是"技术洁癖",而是直接和留存、转化、营收挂钩的商业指标。
原理层:一次请求到底慢在哪
在优化之前,必须先看清一次 HTTPS 请求的时间构成。用 Chrome DevTools 的 Timing 面板,或用 Navigation Timing API 可以量化每一段:
// 用 PerformanceNavigationTiming 拆解一次导航的各阶段耗时
const [nav] = performance.getEntriesByType('navigation');
const timings = {
DNS: nav.domainLookupEnd - nav.domainLookupStart, // DNS 解析
TCP: nav.connectEnd - nav.connectStart, // TCP + TLS 握手
TLS: nav.connectEnd - nav.secureConnectionStart, // 仅 TLS 部分
TTFB: nav.responseStart - nav.requestStart, // 首字节时间
下载: nav.responseEnd - nav.responseStart, // 内容传输
DOM: nav.domContentLoadedEventEnd - nav.responseEnd,
};
console.table(timings);一次全新的 HTTPS 请求,在 4G 网络(单程 RTT 约 50ms)下,光是"建立连接"就要付出的往返代价大致如下:
| 阶段 | 往返次数(RTT) | 4G 下耗时(约) | 说明 |
| --- | --- | --- | --- |
| DNS 查询 | 1 RTT | 50ms | 递归解析可能更久 |
| TCP 三次握手 | 1 RTT | 50ms | SYN / SYN-ACK / ACK |
| TLS 1.2 握手 | 2 RTT | 100ms | 完整握手 |
| TLS 1.3 握手 | 1 RTT | 50ms | 简化后省一次往返 |
| 首个请求响应 | 1 RTT | 50ms | 发请求到收首字节 |
也就是说,在 TLS 1.2 下,还没开始传第一个字节的正文,就已经花掉了约 250ms。这就是为什么"减少连接建立次数""复用连接""升级协议"如此关键。
HTTP 协议演进:1.1 vs 2 vs 3
HTTP/1.1 的核心问题
HTTP/1.1 是文本协议,每个 TCP 连接同一时刻只能处理一个请求-响应。虽然浏览器为同一域名开了 6 个并发连接来"曲线救国",但依然存在两大痛点:
HTTP/2 的三大武器
1)多路复用(Multiplexing):HTTP/2 引入二进制分帧层,把请求和响应拆成带有流 ID 的帧,在单个 TCP 连接上交错传输。突破了 HTTP/1.1 应用层的队头阻塞,浏览器不再需要开 6 个连接,任意数量的请求可以在一条连接上并行,这是 HTTP/2 性能提升的核心。
2)头部压缩(HPACK):使用静态表(常见头部字段)、动态表(本连接出现过的字段)加哈夫曼编码,把重复头部压缩成索引。HTTP/1.1 里每个请求重复传的 Cookie,在 HTTP/2 里只需传一个索引号,大幅减少开销。
3)服务器推送(Server Push):服务器在返回 HTML 时可主动推送 CSS/JS。不过实践中命中率低、容易推送浪费,Chrome 已在 2022 年移除对它的支持,现代方案改用后文的 \`103 Early Hints\` + \`preload\`。
HTTP/3 与 QUIC
HTTP/2 解决了应用层队头阻塞,但它跑在 TCP 上——一旦某个 TCP 分片丢包,整条连接的所有流都要等重传,这叫TCP 层队头阻塞。
HTTP/3 把传输层从 TCP 换成了基于 UDP 的 QUIC:
三代协议对比
| 维度 | HTTP/1.1 | HTTP/2 | HTTP/3 |
| --- | --- | --- | --- |
| 传输层 | TCP | TCP | QUIC(UDP) |
| 多路复用 | 无(需多连接) | 有 | 有 |
| 队头阻塞 | 应用层有 | 应用层无、TCP层有 | 基本消除 |
| 头部压缩 | 无 | HPACK | QPACK |
| 连接建立(首次) | TCP+TLS 3RTT | TCP+TLS 3RTT | QUIC 1RTT |
| 连接恢复 | 需重新握手 | TLS会话复用 | 0-RTT |
| 弱网表现 | 差 | 一般 | 优秀 |
| 连接迁移 | 不支持 | 不支持 | 支持 |
真实案例:Cloudflare 在启用 HTTP/3 后,在高丢包的移动网络下,页面加载时间中位数改善明显,弱网环境下 P95 加载时间下降幅度尤为突出。Google、YouTube、Facebook 主站均已大规模部署 HTTP/3。
开启 HTTP/2 与 HTTP/3 的服务端配置
# Nginx 开启 HTTP/2(需 nginx 1.9.5+)
server {
listen 443 ssl;
http2 on; # nginx 1.25+ 新写法
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.crt;
ssl_certificate_key /etc/nginx/ssl/example.key;
ssl_protocols TLSv1.2 TLSv1.3;
# 开启 HTTP/3 (QUIC),需 nginx 1.25.0+ 且编译了 quic 模块
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400'; # 告知浏览器可升级到 h3
}\`Alt-Svc\` 响应头是关键:浏览器首次用 HTTP/2 访问,看到这个头后,后续请求才会尝试升级到 HTTP/3。
缓存策略:最快的请求是不发请求
缓存是网络优化里性价比最高的一环。命中缓存意味着零网络往返。缓存分两大类:强缓存与协商缓存。
强缓存:完全不问服务器
强缓存命中时,浏览器直接用本地副本,Network 面板显示 \`(disk cache)\` 或 \`(memory cache)\`,状态码不发起真实请求。
# 服务器响应头(强缓存,一年)
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
Content-Length: 84210强缓存必须配合文件名哈希才安全:\`app.3f9a2c.js\`,内容一变哈希就变,文件名变了浏览器自然会请求新文件,从而实现"内容不变永久缓存、内容变了立即更新"。
协商缓存:问一句"变了没"
对 HTML 这类会变的资源,用协商缓存。浏览器带上标识去问服务器,没变就回 304(不带响应体),变了才回 200 带新内容。
# 首次响应:服务器下发校验标识
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "5d8c72a5edda8-2c1e"
Last-Modified: Mon, 03 Aug 2026 08:00:00 GMT
# 再次请求:浏览器带上校验条件
GET /index.html HTTP/1.1
If-None-Match: "5d8c72a5edda8-2c1e"
If-Modified-Since: Mon, 03 Aug 2026 08:00:00 GMT
# 未变化:服务器回 304,无响应体,省下传输
HTTP/1.1 304 Not Modified
ETag: "5d8c72a5edda8-2c1e"ETag(内容指纹,精确到字节)优先级高于 Last-Modified(精确到秒,秒内多次修改会漏判)。注意 \`Cache-Control: no-cache\` 不是"不缓存",而是"每次都要协商验证";真正的不缓存是 \`no-store\`。
强缓存 vs 协商缓存对比
| 维度 | 强缓存 | 协商缓存 |
| --- | --- | --- |
| 是否发请求 | 否 | 是(条件请求) |
| 相关头部 | Cache-Control / Expires | ETag / Last-Modified |
| 命中状态码 | 200 (from cache) | 304 Not Modified |
| 网络往返 | 0 | 1(但无响应体) |
| 适用资源 | 带哈希的静态资源 | HTML、频繁变动接口 |
Nginx 与 Node 缓存配置示例
# 带哈希的静态资源用强缓存 + immutable
location ~* \.(js|css|woff2|png|jpg|webp|avif)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# HTML 入口用协商缓存,保证发版后立即生效
location = /index.html {
add_header Cache-Control "no-cache";
etag on;
}// Node/Express 手动实现协商缓存
const crypto = require('crypto');
app.get('/api/config', (req, res) => {
const body = JSON.stringify(getConfig());
const etag = '"' + crypto.createHash('md5').update(body).digest('hex') + '"';
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // 命中,无需重传
}
res.type('json').send(body);
});压缩:让每个字节都值钱
减少传输体积最直接的手段是文本压缩。目前主流是 gzip 与 Brotli。Brotli(\`br\`)是 Google 推出的算法,在文本资源上通常比 gzip 再小 15%~25%。
# gzip
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Brotli(需 ngx_brotli 模块)
brotli on;
brotli_comp_level 6; # 动态压缩建议 4-6,兼顾 CPU 与压缩率
brotli_static on; # 优先使用预压缩好的 .br 文件
brotli_types text/plain text/css application/javascript application/json image/svg+xml;一个 300KB 的未压缩 JS bundle,实测各方式对比(数字随内容浮动,仅示意量级):
| 压缩方式 | 体积 | 相对原始 | 4G(5Mbps)下载耗时 |
| --- | --- | --- | --- |
| 不压缩 | 300 KB | 100% | 约 480ms |
| gzip -6 | 92 KB | 31% | 约 147ms |
| gzip -9 | 88 KB | 29% | 约 141ms |
| brotli -6 | 78 KB | 26% | 约 125ms |
| brotli -11 | 71 KB | 24% | 约 114ms |
最佳实践:静态资源在构建期预压缩(生成 \`.br\` 与 \`.gz\`),运行时由 \`brotli_static\` 直接下发,既拿到 brotli -11 的极致压缩率,又不消耗线上 CPU。动态内容再用等级 4~6 实时压缩。注意图片、视频等已压缩的二进制不要再套 gzip/brotli,白费 CPU。
资源提示:提前告诉浏览器该干什么
浏览器默认按发现顺序加载资源,但很多关键资源"发现得太晚"。资源提示(Resource Hints)让开发者显式指导浏览器。
<!-- dns-prefetch:提前解析第三方域名的 DNS,最轻量 -->
<link rel="dns-prefetch" href="//cdn.example.com" />
<!-- preconnect:提前完成 DNS + TCP + TLS 握手,为关键第三方省下整段连接时间 -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- preload:高优先级预加载当前页必需的关键资源 -->
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/css/critical.css" as="style" />
<!-- modulepreload:预加载 ES 模块(含依赖解析),比 preload as=script 更贴合 ESM -->
<link rel="modulepreload" href="/js/app.js" />
<!-- prefetch:低优先级预取"下一个页面"可能用到的资源,空闲时下载 -->
<link rel="prefetch" href="/js/dashboard.chunk.js" />选型速查:
| 提示 | 作用范围 | 优先级 | 典型场景 |
| --- | --- | --- | --- |
| dns-prefetch | 仅 DNS | 最低 | 众多第三方域名 |
| preconnect | DNS+TCP+TLS | 中 | 少数关键第三方(字体/CDN) |
| preload | 当前页资源 | 高 | 首屏字体、关键 CSS、LCP 图片 |
| modulepreload | 当前页 ES 模块 | 高 | 入口 JS 及其依赖 |
| prefetch | 未来页资源 | 最低(空闲) | 预判用户下一步 |
注意:\`preconnect\` 别滥用,每个连接都占资源,通常只对 2~4 个最关键的第三方域名使用;\`preload\` 的 \`as\` 必须写对,否则浏览器可能重复下载。
请求优先级 fetchpriority
即便是同类资源,重要性也不同。首屏的 LCP 图片应比页脚 logo 更早下载。\`fetchpriority\` 让开发者微调:
<!-- 首屏主图:拉高优先级,加速 LCP -->
<img src="/hero.avif" fetchpriority="high" alt="首屏主图" />
<!-- 折叠下方的次要图:降低优先级,把带宽让给关键资源 -->
<img src="/footer-logo.png" fetchpriority="low" loading="lazy" alt="logo" />// fetch 同样支持 priority
fetch('/api/critical-data', { priority: 'high' });
fetch('/api/analytics', { priority: 'low' });103 Early Hints
现代替代 Server Push 的方案:服务器在生成完整 HTML 前,先回一个 \`103 Early Hints\` 响应,携带 \`preload\`/\`preconnect\`,让浏览器在后端还在渲染时就并行下载资源。
HTTP/1.1 103 Early Hints
Link: </css/critical.css>; rel=preload; as=style
Link: </js/app.js>; rel=modulepreload
HTTP/1.1 200 OK
Content-Type: text/html
...完整 HTML...减少请求与减少往返
减少请求数量
请求去重与并发控制
前端常见问题:同一份数据被多个组件同时请求,产生重复请求;或短时间内发起上百个请求打爆浏览器连接数。
// 请求去重:相同 key 的进行中请求共享同一个 Promise
const inflight = new Map();
function dedupeFetch(url, options) {
const key = url + JSON.stringify(options || {});
if (inflight.has(key)) return inflight.get(key);
const p = fetch(url, options)
.then((r) => r.json())
.finally(() => inflight.delete(key)); // 完成后清理,避免拿到过期数据
inflight.set(key, p);
return p;
}// 并发控制:限制同时进行的请求数,避免瞬时打满连接
async function runWithConcurrency(tasks, limit = 6) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = Promise.resolve().then(() => task());
results.push(p);
executing.add(p);
p.finally(() => executing.delete(p));
if (executing.size >= limit) {
await Promise.race(executing); // 等最快的一个完成再继续投放
}
}
return Promise.all(results);
}
// 用法:1000 个请求,最多 6 个并发
const urls = Array.from({ length: 1000 }, (_, i) => '/api/item/' + i);
await runWithConcurrency(urls.map((u) => () => fetch(u)), 6);批量合并请求(Batching)
把短时间内的多个细粒度请求合并成一个批量请求,减少往返次数:
// 简易 DataLoader:同一 tick 内的 id 合并成一次批量查询
function createBatcher(batchFn, wait = 10) {
let queue = [];
let timer = null;
return function load(id) {
return new Promise((resolve, reject) => {
queue.push({ id, resolve, reject });
if (!timer) {
timer = setTimeout(async () => {
const current = queue; queue = []; timer = null;
try {
const ids = current.map((q) => q.id);
const data = await batchFn(ids); // 一次请求拿回全部
current.forEach((q, i) => q.resolve(data[i]));
} catch (e) {
current.forEach((q) => q.reject(e));
}
}, wait);
}
});
};
}
const loadUser = createBatcher((ids) => fetch('/api/users?ids=' + ids.join(',')).then((r) => r.json()));
// 组件各自 loadUser(1)/loadUser(2)/loadUser(3) → 实际只发一次 /api/users?ids=1,2,3Service Worker 缓存策略
Service Worker 是运行在浏览器后台的代理,可拦截所有网络请求,实现离线可用与精细缓存。三种经典策略要按资源特性选择。
// sw.js
const CACHE = 'v1';
self.addEventListener('fetch', (event) => {
const { request } = event;
// 策略1:Cache First —— 静态资源(带哈希),命中即返回,最快
if (/\.(js|css|woff2|png|webp|avif)$/.test(request.url)) {
event.respondWith(
caches.match(request).then((cached) =>
cached || fetch(request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(request, copy));
return res;
})
)
);
return;
}
// 策略2:Network First —— API 数据,优先新鲜,断网降级到缓存
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(request, copy));
return res;
})
.catch(() => caches.match(request)) // 离线兜底
);
return;
}
// 策略3:Stale-While-Revalidate —— 先回缓存(秒开),后台静默更新
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request).then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(request, copy));
return res;
});
return cached || network; // 有缓存立即用,同时后台刷新下次用
})
);
});策略选型:
| 策略 | 首字节速度 | 数据新鲜度 | 离线可用 | 适用资源 |
| --- | --- | --- | --- | --- |
| Cache First | 最快 | 低 | 是 | 带哈希静态资源 |
| Network First | 慢 | 最高 | 是(降级) | 实时性强的 API |
| Stale-While-Revalidate | 快 | 中 | 是 | 头像、列表等可容忍轻微陈旧 |
连接层优化
DNS 优化
TCP 优化
# 开启长连接并调优
keepalive_timeout 65;
keepalive_requests 1000;
# 对上游(反向代理场景)也复用连接
upstream backend {
server 127.0.0.1:3000;
keepalive 32; # 连接池,避免每次请求新建到上游的连接
}TLS 优化
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_stapling on;
ssl_stapling_verify on;
ssl_early_data on; # 开启 TLS 1.3 0-RTTTTFB 优化
首字节时间(TTFB)反映"服务器出餐"速度,受后端处理、数据库查询、缓存命中共同影响。前端侧能做的:用 CDN 就近命中边缘缓存、开启 Early Hints、静态化可缓存的 HTML;后端侧:加数据库索引、加内存缓存、异步化重逻辑。经验阈值:TTFB 建议控制在 200ms 以内,超过 600ms 需要重点排查。
CDN 与边缘计算
CDN(内容分发网络):把静态资源缓存到全球边缘节点,用户就近访问。核心收益是缩短物理距离带来的 RTT,同时分担源站压力、抵御流量洪峰。
边缘计算:不只是缓存静态文件,而是把计算逻辑(如 A/B 分流、鉴权、个性化 HTML 拼装、图片实时裁剪)下沉到离用户最近的边缘节点执行,兼顾动态内容的低延迟。代表产品有 Cloudflare Workers、Vercel Edge Functions、AWS Lambda@Edge。
// Cloudflare Workers:在边缘节点做缓存与改写
export default {
async fetch(request, env, ctx) {
const cache = caches.default;
let response = await cache.match(request);
if (response) return response; // 边缘命中,零回源
response = await fetch(request);
response = new Response(response.body, response);
response.headers.set('Cache-Control', 'public, max-age=3600');
ctx.waitUntil(cache.put(request, response.clone())); // 异步写边缘缓存
return response;
},
};真实案例:某电商把图片与静态资源迁移到 CDN 并开启边缘图片格式协商(按 \`Accept\` 头动态返回 AVIF/WebP/JPEG),首屏图片体积下降约 40%,海外用户 LCP 中位数从 3.8s 降到 1.9s。
监控与分析
只有可量化才能持续优化。工具链与关键指标:
| 类别 | 工具/API | 用途 |
| --- | --- | --- |
| 实验室数据 | Lighthouse、WebPageTest | 可复现的评分与瀑布图 |
| 现场数据(RUM) | web-vitals、Navigation Timing、Resource Timing | 真实用户网络表现 |
| 手动排查 | Chrome DevTools Network 面板 | 逐请求看 Timing 分段 |
| 服务端 | 访问日志、APM(New Relic 等) | TTFB、错误率、慢请求 |
// 采集真实用户的核心网络指标并上报
import { onLCP, onTTFB, onINP } from 'web-vitals';
function report(metric) {
navigator.sendBeacon('/rum', JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
id: metric.id,
}));
}
onTTFB(report); // 首字节
onLCP(report); // 最大内容绘制
onINP(report); // 交互到下次绘制关键指标清单:DNS 解析时间、TCP 连接时间、TLS 握手时间、首字节时间(TTFB)、内容传输时间、总加载时间。