网络协议与浏览器通信
网络协议与浏览器通信
网络协议是浏览器与服务器通信的基础。从你在地址栏敲下回车,到页面第一个像素显示出来,中间要经历 DNS 解析、TCP 连接、TLS 握手、HTTP 请求响应、浏览器渲染等一整套流程。理解这些环节,既能帮你排查"为什么这个接口这么慢",也能帮你在面试中把"输入 URL 到页面展示"这道经典题讲清楚。
打个比方:浏览器请求一个网页,就像你要给一个陌生人寄一封重要的信——
下面按照这个流程逐层展开,每个环节都配代码示例、对比表格和常见坑。
🗺️ 完整网络请求流程总览
在浏览器地址栏输入 \`https://www.example.com\` 并回车后,大致会发生:
下面这张表概括了每个阶段大致的耗时量级(数值会因网络环境剧烈波动,仅作直观参考):
| 阶段 | 典型耗时(无缓存、跨运营商) | 说明 |
| --- | --- | --- |
| DNS 解析 | 20ms ~ 120ms | 命中缓存可低至 0ms |
| TCP 三次握手 | 1 个 RTT(约 20ms ~ 100ms) | 受物理距离影响很大 |
| TLS 握手 | TLS1.2 为 2 个 RTT,TLS1.3 为 1 个 RTT(甚至 0-RTT) | 详见下文 TLS 章节 |
| TTFB(首字节时间) | 100ms ~ 600ms | 包含服务器处理时间 |
| 内容下载 | 视资源大小和带宽而定 | 可通过压缩、CDN 优化 |
| 渲染 | 与网络无关,取决于页面复杂度 | 见《浏览器架构与渲染流程》 |
🔎 DNS 解析详解
DNS(Domain Name System)负责把人类容易记忆的域名转换成机器能理解的 IP 地址,本质上是一个分布式的"域名电话簿"。
查询过程(假设完全没有缓存):
常见 DNS 记录类型:
| 记录类型 | 作用 |
| --- | --- |
| A | 域名指向 IPv4 地址 |
| AAAA | 域名指向 IPv6 地址 |
| CNAME | 别名记录,指向另一个域名 |
| MX | 邮件服务器记录 |
| TXT | 文本记录,常用于域名验证、SPF 反垃圾邮件配置 |
| NS | 指定该域名由哪些域名服务器解析 |
优化 DNS 的手段:
🤝 TCP 连接:三次握手与四次挥手
HTTP 建立在 TCP 之上(HTTP/3 除外,它基于 QUIC/UDP),TCP 是一个面向连接、可靠的传输层协议。"面向连接"意味着通信双方要先协商好,才能正式发数据,这就是三次握手的由来。
三次握手(建立连接):
\`\`\`text
客户端 服务器
|--- SYN (seq=x) --------------------->| "我想和你建立连接,我的序号从 x 开始"
|<-- SYN-ACK (seq=y, ack=x+1) ----------| "收到,我同意,我的序号从 y 开始"
|--- ACK (ack=y+1) --------------------->| "收到,连接建立成功"
\`\`\`
为什么是三次而不是两次?因为服务器发出 SYN-ACK 后,还需要客户端确认自己的 ACK 已经被服务器收到,否则会出现"服务器以为连接建立了,客户端却不知道"的半开连接,浪费服务器资源。这也是为什么可以用类比理解:
四次挥手(关闭连接):
\`\`\`text
客户端 服务器
|--- FIN (seq=u) ----------------------->| "我这边数据发完了,准备关闭"
|<-- ACK (ack=u+1) ----------------------| "收到,稍等,我这边可能还有数据要发"
|<-- FIN (seq=v) -------------------------| "我这边也发完了,可以关了"
|--- ACK (ack=v+1) ----------------------->| "收到,关闭"
\`\`\`
四次挥手比三次握手多一次,是因为 TCP 是全双工的:客户端说"我说完了"之后,服务器可能还有数据没发完,所以服务器的 ACK 和 FIN 通常是分开发送的(也存在合并成三次挥手的情况,取决于是否有数据待发送)。
拥塞控制与流量控制的区别:
| 机制 | 解决的问题 | 核心手段 |
| --- | --- | --- |
| 流量控制 | 防止发送方发送速度超过接收方处理能力 | 滑动窗口(接收方通告窗口大小) |
| 拥塞控制 | 防止网络整体过载 | 慢启动、拥塞避免、快重传、快恢复 |
慢启动简述:连接刚建立时,发送方并不知道网络能承受多大流量,于是从一个很小的拥塞窗口开始,每收到一个 ACK 就成倍增大窗口(指数增长),直到达到阈值后转为线性增长(拥塞避免),一旦检测到丢包就大幅减小窗口,这也是为什么"新建连接的前几个请求往往比复用连接的请求慢"的原因之一——这正是 HTTP/2、HTTP/3 强调"复用连接、减少连接数"的重要原因。
📨 HTTP 请求与响应结构
一个完整的 HTTP 请求由三部分组成:请求行、请求头、请求体(GET 请求通常没有请求体)。
\`\`\`text
GET /api/users?id=123 HTTP/1.1
Host: api.example.com
User-Agent: Mozilla/5.0
Accept: application/json
Authorization: Bearer xxx.yyy.zzz
Cache-Control: no-cache
(GET 请求通常无请求体)
\`\`\`
响应同样由状态行、响应头、响应体组成:
\`\`\`text
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 128
Cache-Control: max-age=3600
ETag: "33a64df551"
{"id":123,"name":"张三"}
\`\`\`
常见请求方法:
| 方法 | 用途 | 是否幂等 | 是否有请求体 |
| --- | --- | --- | --- |
| GET | 获取资源 | 是 | 通常没有 |
| POST | 创建资源/提交数据 | 否 | 有 |
| PUT | 完整替换资源 | 是 | 有 |
| PATCH | 局部更新资源 | 否(约定上通常也做成幂等) | 有 |
| DELETE | 删除资源 | 是 | 通常没有 |
| HEAD | 只获取响应头,不要响应体 | 是 | 没有 |
| OPTIONS | 询问服务器支持的方法(CORS 预检核心) | 是 | 没有 |
常见状态码:
| 状态码 | 类别 | 含义 | 常见场景 |
| --- | --- | --- | --- |
| 200 | 成功 | 请求成功 | 正常返回数据 |
| 201 | 成功 | 已创建 | POST 创建资源成功 |
| 204 | 成功 | 无内容 | 删除成功、OPTIONS 预检响应 |
| 301 | 重定向 | 永久重定向 | 域名迁移、强制 HTTPS |
| 302 | 重定向 | 临时重定向 | 登录后跳转 |
| 304 | 重定向 | 未修改,使用协商缓存 | ETag/Last-Modified 验证命中 |
| 400 | 客户端错误 | 请求参数错误 | 参数校验失败 |
| 401 | 客户端错误 | 未认证 | 未登录或 token 失效 |
| 403 | 客户端错误 | 无权限 | 已登录但权限不足 |
| 404 | 客户端错误 | 资源不存在 | 路径错误 |
| 429 | 客户端错误 | 请求过于频繁 | 触发限流 |
| 500 | 服务端错误 | 服务器内部错误 | 代码异常、未捕获的报错 |
| 502 | 服务端错误 | 网关错误 | 上游服务无响应 |
| 503 | 服务端错误 | 服务不可用 | 服务器过载或维护中 |
| 504 | 服务端错误 | 网关超时 | 上游服务处理超时 |
代码示例:fetch 完整用法(超时、重试、错误处理)
真实项目里很少直接裸用 \`fetch\`,通常需要封装超时取消、失败重试、统一错误处理:
\`\`\`javascript
async function fetchWithRetry(url, options = {}, retries = 3, timeout = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timer);
if (!response.ok) {
// 4xx/5xx 不会让 fetch 走 catch 分支,需要手动判断
const errorBody = await response.text().catch(() => '');
throw new Error(\`HTTP ${response.status}: ${errorBody || response.statusText}\`);
}
return await response.json();
} catch (error) {
clearTimeout(timer);
// 超时或被取消不重试,避免无意义的重复请求
if (error.name === 'AbortError') {
throw new Error(\`请求超时(超过 ${timeout}ms)\`);
}
if (retries > 0) {
console.warn(\`请求失败,${retries} 次重试机会剩余:\`, error.message);
// 指数退避,避免瞬间打满服务器
const delay = (4 - retries) * 500;
await new Promise(resolve => setTimeout(resolve, delay));
return fetchWithRetry(url, options, retries - 1, timeout);
}
throw error;
}
}
// 使用示例
fetchWithRetry('/api/users', { method: 'GET' })
.then(data => console.log('获取成功:', data))
.catch(err => console.error('最终失败:', err.message));
\`\`\`
代码示例:统一的请求封装(携带 token、区分 JSON/表单)
\`\`\`javascript
class ApiClient {
constructor(baseURL, getToken) {
this.baseURL = baseURL;
this.getToken = getToken;
}
async request(path, { method = 'GET', body, headers = {}, ...rest } = {}) {
const token = this.getToken?.();
const finalHeaders = {
'Content-Type': 'application/json',
...(token ? { Authorization: \`Bearer ${token}\` } : {}),
...headers
};
const response = await fetch(\`${this.baseURL}${path}\`, {
method,
headers: finalHeaders,
body: body ? JSON.stringify(body) : undefined,
...rest
});
if (response.status === 401) {
// 统一处理登录失效
window.location.href = '/login';
throw new Error('登录已过期');
}
if (!response.ok) {
throw new Error(\`请求失败: ${response.status}\`);
}
return response.status === 204 ? null : response.json();
}
get(path, options) { return this.request(path, { ...options, method: 'GET' }); }
post(path, body, options) { return this.request(path, { ...options, method: 'POST', body }); }
}
const api = new ApiClient('https://api.example.com', () => localStorage.getItem('token'));
api.get('/user/profile').then(console.log);
\`\`\`
⚡ HTTP/1.1 vs HTTP/2 vs HTTP/3
这是前端面试和性能优化里绕不开的话题。三代协议解决的核心问题一脉相承:如何在一条(或几条)连接上,更快、更并发地传输更多资源。
HTTP/1.1 的问题:队头阻塞(Head-of-Line Blocking)
HTTP/1.1 虽然支持持久连接(Keep-Alive)和管道化(Pipelining),但同一条 TCP 连接上的请求必须严格按顺序返回响应——如果排在前面的请求迟迟没有响应,后面即使已经处理完了,也必须排队等待。为了缓解这个问题,浏览器通常对同一域名开启 6~8 条并行 TCP 连接,但这又带来了额外的握手开销、以及服务器资源压力,也是"域名分片"(把资源分散到多个子域名)这种老技巧的由来。
HTTP/2 的解决方案:多路复用
HTTP/2 引入了二进制分帧层,把请求和响应拆分成更小的帧(Frame),同一个 TCP 连接上可以并行交错传输多个请求/响应的帧,接收方再根据帧头的流 ID(Stream ID)重新组装。这样一条连接就能真正并发处理多个请求,不再需要开多条 TCP 连接。
HTTP/2 还带来:
但 HTTP/2 依然跑在 TCP 之上,这带来一个新问题:TCP 层面的队头阻塞。TCP 保证字节流有序到达,一旦某个 TCP 包丢失,即使 HTTP/2 层面的其他流数据已经到达,也必须等待丢失的包重传,所有流都会被阻塞——这是 HTTP/2 无法从应用层解决的问题,因为它出在传输层。
HTTP/3 的解决方案:基于 QUIC
HTTP/3 放弃 TCP,改用QUIC协议(构建在 UDP 之上),从根本上解决了 TCP 层队头阻塞:
三代协议对比表:
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
| --- | --- | --- | --- |
| 传输层协议 | TCP | TCP | QUIC(基于 UDP) |
| 数据格式 | 文本 | 二进制分帧 | 二进制分帧 |
| 多路复用 | 不支持(需多条连接) | 支持(单连接内) | 支持,且流之间互不阻塞 |
| 队头阻塞 | 应用层存在 | 应用层解决,传输层仍存在 | 完全解决 |
| 头部压缩 | 无 | HPACK | QPACK |
| 服务器推送 | 不支持 | 支持(已趋于废弃) | 支持 |
| 连接迁移 | 不支持 | 不支持 | 支持(网络切换不断连) |
| 建连速度 | TCP 握手 + TLS 握手,串行 | 同 HTTP/1.1 | 传输层与 TLS1.3 握手合并 |
| 部署难度 | 简单 | 简单,多数 CDN 已默认开启 | 需要服务器与 CDN 支持 QUIC,部分网络环境屏蔽 UDP |
代码示例:检测当前请求使用的协议版本
\`\`\`javascript
// 通过 Resource Timing API 查看协议
performance.getEntriesByType('resource').forEach(entry => {
console.log(entry.name, entry.nextHopProtocol);
// 常见取值: "http/1.1"、"h2"、"h3"
});
// 也可以在 Network 面板中新增 "Protocol" 列直接查看
\`\`\`
🔒 HTTPS 与 TLS
HTTP 是明文传输的,任何中间节点(路由器、运营商、公共 WiFi 的攻击者)都能窃听甚至篡改数据。HTTPS = HTTP + TLS(Transport Layer Security,其前身是 SSL),通过加密解决了三个核心问题:
对称加密 vs 非对称加密
TLS 握手的核心目标,是让客户端和服务器安全地协商出一份只有双方知道的对称密钥,之后正式传输数据时用这份对称密钥加解密(因为对称加密速度快得多)。而协商密钥的过程本身,则依赖非对称加密(公钥加密、私钥解密)来防止密钥被窃听。
| 加密方式 | 特点 | 在 TLS 中的角色 |
| --- | --- | --- |
| 对称加密(如 AES) | 加解密用同一把密钥,速度快 | 用于实际数据传输的加解密 |
| 非对称加密(如 RSA、ECDHE) | 公钥加密、私钥解密(或反过来),速度慢但能安全交换密钥 | 用于握手阶段协商对称密钥、验证身份 |
数字证书与证书链
服务器需要向客户端证明"我就是 example.com",这靠数字证书完成:
\`\`\`bash
# 查看某个网站的证书链
openssl s_client -connect example.com:443 -showcerts
# 查看证书的有效期、颁发者等信息
openssl x509 -in cert.pem -noout -dates -issuer -subject
\`\`\`
TLS 1.2 vs TLS 1.3:握手往返次数的巨大差异
这是一个非常值得记住的性能优化点:TLS 版本升级能实打实地减少首次连接的延迟。
TLS 1.2 握手(需要 2 个 RTT):
\`\`\`text
客户端 服务器
|--- ClientHello(支持的加密套件、随机数)------->|
|<-- ServerHello + 证书 + ServerKeyExchange ------|
|<-- ServerHelloDone -----------------------------|
|--- ClientKeyExchange + ChangeCipherSpec -------->|
|--- Finished ------------------------------------->|
|<-- ChangeCipherSpec + Finished ------------------|
(此时才能开始传输加密后的应用数据,共耗费 2 个 RTT)
\`\`\`
TLS 1.3 握手(只需 1 个 RTT,支持场景下甚至 0-RTT):
\`\`\`text
客户端 服务器
|--- ClientHello(直接带上猜测的密钥交换参数)---->|
|<-- ServerHello + 密钥交换参数 + 加密后的证书 -----|
|<-- Finished --------------------------------------|
|--- Finished ---------------------------------------->|
(1 个 RTT 后即可开始传输应用数据)
如果是"回头客"(此前连接过,服务器颁发了 Session Ticket):
|--- ClientHello + 提前预测的应用数据(0-RTT)----->|
(无需等待往返,直接携带数据,但 0-RTT 数据存在重放攻击风险,仅适合幂等请求)
\`\`\`
握手往返对比表:
| 版本 | 握手往返(首次连接) | 握手往返(会话恢复) | 支持的密钥交换 | 安全性 |
| --- | --- | --- | --- | --- |
| TLS 1.0 / 1.1 | 2-RTT | 1-RTT | RSA、DH | 已被主流浏览器淘汰 |
| TLS 1.2 | 2-RTT | 1-RTT(Session ID/Ticket) | RSA、ECDHE | 仍广泛使用,但握手较慢 |
| TLS 1.3 | 1-RTT | 0-RTT(PSK 恢复) | 仅前向安全的 ECDHE 类 | 更快、更安全,移除了不安全的算法 |
代码示例:Node.js 服务端强制使用 TLS 1.3,并查看协商结果
\`\`\`javascript
const https = require('https');
const fs = require('fs');
const server = https.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
minVersion: 'TLSv1.2', // 建议至少 1.2,有条件应设为 1.3
ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384'
}, (req, res) => {
res.end('hello https');
});
server.on('secureConnection', (tlsSocket) => {
console.log('协商的 TLS 版本:', tlsSocket.getProtocol());
console.log('加密套件:', tlsSocket.getCipher());
});
server.listen(443);
\`\`\`
HTTPS 握手 + HTTP 请求的完整时序(首次访问):
也就是说,同样的网络条件下,从 TLS1.2 升级到 TLS1.3,理论上首次访问能省下整整 1 个 RTT——在跨国网络中,1 个 RTT 可能就是 100ms 以上,这对首屏体验的提升是非常可观的。
🔌 WebSocket、SSE 与长轮询
当业务需要"服务器主动、持续地向客户端推送数据"(比如聊天消息、股票行情、直播弹幕),普通的一问一答式 HTTP 就不够用了,常见的三种方案各有取舍:
| 方案 | 通信方向 | 连接方式 | 实时性 | 适用场景 | 兼容性 |
| --- | --- | --- | --- | --- | --- |
| 长轮询(Long Polling) | 客户端发起,服务器 hold 住直到有数据 | 每次请求后立即发起下一次 | 较好,但有请求间隙 | 兼容性要求极高的老系统 | 极好,基于普通 HTTP |
| SSE(Server-Sent Events) | 服务器 → 客户端单向推送 | 一条长连接,基于 HTTP | 好 | 通知、实时日志、行情播报等单向场景 | 良好(IE 不支持,需 polyfill) |
| WebSocket | 全双工,双向都能主动发 | 独立的持久连接,握手后升级协议 | 最好 | 聊天、协作编辑、实时游戏、弹幕 | 良好 |
WebSocket 握手过程
WebSocket 巧妙地"借用"了 HTTP 完成握手,再"升级"为独立的 WebSocket 协议:
\`\`\`text
客户端请求:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务器响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
(之后这条 TCP 连接不再走 HTTP 语义,改为 WebSocket 帧协议,双方都能随时主动发消息)
\`\`\`
代码示例:带心跳与断线重连的 WebSocket 客户端封装
真实项目里裸用原生 WebSocket 会遇到网络抖动断连、服务端假死等问题,通常需要封装心跳检测和自动重连:
\`\`\`javascript
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.heartbeatInterval = options.heartbeatInterval || 30000;
this.reconnectDelay = options.reconnectDelay || 3000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
this.reconnectAttempts = 0;
this.listeners = {};
this.manualClose = false;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket 已连接');
this.reconnectAttempts = 0;
this.startHeartbeat();
this.emit('open');
};
this.ws.onmessage = (event) => {
if (event.data === 'pong') {
return; // 心跳响应,不向业务层暴露
}
try {
this.emit('message', JSON.parse(event.data));
} catch {
this.emit('message', event.data);
}
};
this.ws.onclose = () => {
this.stopHeartbeat();
this.emit('close');
if (!this.manualClose) {
this.reconnect();
}
};
this.ws.onerror = (error) => {
this.emit('error', error);
this.ws.close();
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('已达最大重连次数,停止重连');
this.emit('reconnect-failed');
return;
}
this.reconnectAttempts++;
// 简单的退避策略:重连次数越多,等待时间越长(有上限)
const delay = this.reconnectDelay * Math.min(this.reconnectAttempts, 5);
console.log(\`${delay}ms 后进行第 ${this.reconnectAttempts} 次重连\`);
setTimeout(() => this.connect(), delay);
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send('ping');
}
}, this.heartbeatInterval);
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
} else {
console.warn('WebSocket 未连接,无法发送数据');
}
}
close() {
this.manualClose = true;
this.stopHeartbeat();
this.ws.close();
}
on(event, callback) {
(this.listeners[event] = this.listeners[event] || []).push(callback);
}
emit(event, data) {
(this.listeners[event] || []).forEach(cb => cb(data));
}
}
// 使用示例:直播弹幕接收
const danmuSocket = new ReconnectingWebSocket('wss://live.example.com/danmu');
danmuSocket.on('open', () => console.log('弹幕通道已连接'));
danmuSocket.on('message', (data) => renderDanmu(data));
danmuSocket.on('reconnect-failed', () => showToast('弹幕连接失败,请刷新页面'));
\`\`\`
代码示例:EventSource(SSE)用法
SSE 天然支持断线自动重连(浏览器内置),非常适合"服务器 → 客户端"的单向推送场景,比如价格变动通知:
\`\`\`javascript
const evtSource = new EventSource('/api/notifications');
// 默认的 message 事件
evtSource.onmessage = (event) => {
console.log('收到通知:', event.data);
};
// 自定义事件名(服务端通过 event: price-update 指定)
evtSource.addEventListener('price-update', (event) => {
const data = JSON.parse(event.data);
updatePriceUI(data);
});
evtSource.onerror = (error) => {
if (evtSource.readyState === EventSource.CLOSED) {
console.log('SSE 连接已关闭');
} else {
console.warn('SSE 连接出错,浏览器将按规范自动重连', error);
}
};
// 服务端对应的响应格式(Node/Express 示例):
// res.writeHead(200, {
// 'Content-Type': 'text/event-stream',
// 'Cache-Control': 'no-cache',
// Connection: 'keep-alive'
// });
// res.write('event: price-update\\n');
// res.write(\`data: ${JSON.stringify({ price: 123.45 })}\\n\\n\`);
// 主动关闭连接
// evtSource.close();
\`\`\`
代码示例:长轮询实现
\`\`\`javascript
async function longPolling(url, onData) {
try {
// 服务端会 hold 住这个请求,直到有新数据或超时
const response = await fetch(url, { method: 'GET' });
if (response.ok) {
const data = await response.json();
onData(data);
}
} catch (error) {
console.error('长轮询请求失败,1 秒后重试:', error);
} finally {
// 无论成功、失败还是超时,都立即发起下一轮请求
setTimeout(() => longPolling(url, onData), 1000);
}
}
longPolling('/api/poll/messages', (data) => {
console.log('收到新消息:', data);
});
\`\`\`
真实案例:直播弹幕系统为什么选 WebSocket
某直播平台早期用 HTTP 短轮询(每 2 秒轮询一次弹幕列表)实现弹幕功能,随着并发观众数增长到十万级别,服务器被高频轮询请求压垮,且弹幕延迟高达 2 秒以上,观感很差。切换到 WebSocket 后:
而对于类似"限时秒杀库存变化通知"这种单向、低频的场景,很多团队会优先选择 SSE,因为它实现更简单、能复用 HTTP 基础设施(如网关、负载均衡),不需要额外维护 WebSocket 的连接状态。
💾 浏览器缓存机制
缓存是前端性能优化里投入产出比最高的手段之一。浏览器缓存分为强缓存和协商缓存两大类,二者的核心区别是:强缓存命中时完全不发请求,协商缓存命中时仍发请求,但服务器返回 304 且不带响应体。
强缓存:Cache-Control 与 Expires
- \`max-age=3600\`:缓存 3600 秒内有效,用相对时间,不受本地时钟影响
- \`no-cache\`:不是"不缓存",而是"缓存了,但每次都要向服务器验证是否过期"(走协商缓存流程)
- \`no-store\`:真正的完全不缓存,每次都重新请求完整资源
- \`public\`:允许被 CDN、代理服务器等中间节点缓存
- \`private\`:只允许浏览器本地缓存,不允许中间代理缓存(常用于带用户身份的响应)
- \`immutable\`:告诉浏览器该资源永不改变,即使用户手动刷新也不必重新验证(配合文件名带 hash 使用效果最佳)
协商缓存:ETag 与 Last-Modified
强缓存 vs 协商缓存对比表:
| 对比项 | 强缓存 | 协商缓存 |
| --- | --- | --- |
| 是否发请求 | 否,直接用本地缓存 | 是,但只传头部信息,不传响应体 |
| 相关响应头 | Cache-Control、Expires | ETag/If-None-Match、Last-Modified/If-Modified-Since |
| 命中时状态码 | 200(from disk/memory cache,DevTools 中可见) | 304 Not Modified |
| 精确度 | 依赖设定的过期时间,不感知内容是否真变化 | 可以精确判断内容是否变化 |
| 典型使用场景 | 带 hash 文件名的静态资源(js/css/图片) | HTML 入口文件、API 数据接口 |
缓存存放位置:
| 位置 | 特点 |
| --- | --- |
| Memory Cache(内存缓存) | 速度最快,但随进程/标签页关闭而失效,容量小 |
| Disk Cache(磁盘缓存) | 速度慢于内存缓存,但容量大、持久性好,多数资源最终会落到这里 |
| Service Worker Cache | 由开发者用代码完全控制的缓存,离线场景的核心能力 |
| Push Cache | HTTP/2 Server Push 推送资源的临时缓存,生命周期很短(仅在同一个 session 内) |
代码示例:Service Worker 实现自定义缓存策略
Service Worker 允许你像写中间件一样拦截页面的每一个网络请求,自由决定缓存策略(这是普通 Cache-Control 做不到的灵活度):
\`\`\`javascript
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];
// 安装阶段:预缓存核心静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// 激活阶段:清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
)
)
);
self.clients.claim();
});
// 拦截请求:不同类型资源采用不同策略
self.addEventListener('fetch', (event) => {
const { request } = event;
// 图片:缓存优先(Cache First),命中直接返回,未命中再请求网络并写入缓存
if (request.destination === 'image') {
event.respondWith(
caches.match(request).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
});
})
);
return;
}
// 页面导航:网络优先(Network First),失败时降级到缓存的离线页
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
// 其他请求:先查缓存,没有再走网络(Stale-While-Revalidate 的简化版)
event.respondWith(
caches.match(request).then(cached => cached || fetch(request))
);
});
\`\`\`
代码示例:注册 Service Worker
\`\`\`javascript
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW 注册成功,作用域:', reg.scope))
.catch(err => console.error('SW 注册失败:', err));
});
}
\`\`\`
🌐 CORS 跨域资源共享
同源策略回顾:浏览器出于安全考虑,默认限制一个源(协议 + 域名 + 端口三者完全一致才算同源)的脚本访问另一个源的资源。CORS(Cross-Origin Resource Sharing)就是浏览器和服务器之间的一套"白名单协商机制",让服务器可以显式声明"我允许哪些源访问我"。
简单请求 vs 预检请求
浏览器会先判断这个跨域请求是不是"简单请求",如果不是,就要先发一个 \`OPTIONS\` 预检请求确认服务器允许后,再发真正的请求。
判定为简单请求需要同时满足:
只要不满足上述任意一条(比如用 \`Content-Type: application/json\`、自定义了 \`Authorization\` 头、或者用了 \`PUT\`/\`DELETE\` 方法),就会触发预检请求:
\`\`\`text
简单请求流程:
浏览器 ---(直接发送真实请求,带 Origin 头)---> 服务器
浏览器 <---(响应带 Access-Control-Allow-Origin)--- 服务器
浏览器根据响应头判断是否允许读取响应内容
预检请求流程:
浏览器 ---(OPTIONS 预检,询问是否允许)---> 服务器
浏览器 <---(204/200,带 Access-Control-Allow-* 一系列头)--- 服务器
浏览器确认允许后,才真正发出请求
浏览器 ---(真实请求)---> 服务器
浏览器 <---(真实响应)--- 服务器
\`\`\`
关键响应头一览:
| 响应头 | 作用 |
| --- | --- |
| Access-Control-Allow-Origin | 允许访问的源,可以是具体域名或 \`\`(但携带凭证时不能用 \`\`) |
| Access-Control-Allow-Methods | 允许的 HTTP 方法列表 |
| Access-Control-Allow-Headers | 允许携带的自定义请求头 |
| Access-Control-Allow-Credentials | 是否允许携带 Cookie 等凭证,值只能是 \`true\` |
| Access-Control-Max-Age | 预检请求结果的缓存时间(秒),减少重复预检 |
| Access-Control-Expose-Headers | 允许 JS 通过 \`response.headers.get()\` 读取的额外响应头(默认只能读取几个基础头) |
withCredentials:跨域携带 Cookie
默认情况下,跨域请求不会携带 Cookie。如果需要携带(比如跨域调用同公司不同子域名下的登录态接口),需要客户端和服务端同时配置:
\`\`\`javascript
// 客户端:fetch 需要显式声明携带凭证
fetch('https://api.example.com/user/profile', {
method: 'GET',
credentials: 'include' // 对应 XMLHttpRequest 里的 withCredentials = true
});
// XMLHttpRequest 写法
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/user/profile');
xhr.withCredentials = true;
xhr.send();
\`\`\`
代码示例:Node/Express 服务端 CORS 配置
\`\`\`javascript
const express = require('express');
const app = express();
// 手动实现(不依赖第三方库),便于理解每个头的作用
app.use((req, res, next) => {
const allowedOrigins = ['https://example.com', 'https://admin.example.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
// 携带凭证时,Allow-Origin 不能写 *,必须回显具体的源
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400'); // 预检结果缓存一天,减少重复 OPTIONS 请求
if (req.method === 'OPTIONS') {
return res.sendStatus(204); // 预检请求直接返回,不进入业务逻辑
}
next();
});
// 使用官方 cors 中间件的等价写法
// const cors = require('cors');
// app.use(cors({
// origin: ['https://example.com', 'https://admin.example.com'],
// credentials: true,
// maxAge: 86400
// }));
\`\`\`
常见坑:
🚀 网络性能优化
减少请求数量:
压缩:gzip vs brotli
对文本类资源(HTML/CSS/JS/JSON)开启压缩,通常能带来数量级的体积下降:
| 压缩算法 | 典型压缩率(相对原始文本) | 压缩速度 | 浏览器支持 | 说明 |
| --- | --- | --- | --- | --- |
| 不压缩 | 0%(原始大小) | - | - | 基线 |
| gzip | 通常减少 60%~70% 体积 | 快 | 几乎全部浏览器 | 兼容性最好,是长期以来的默认选择 |
| brotli | 通常比 gzip 再多减少 15%~20% 体积 | 压缩较慢(可预压缩静态资源缓解),解压快 | 现代浏览器(IE 不支持) | 对文本类资源效果尤其好,是目前的推荐首选 |
代码示例:Nginx 同时开启 gzip 与 brotli
\`\`\`text
# gzip 配置(兼容性兜底)
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 6;
# brotli 配置(需要 ngx_brotli 模块,优先于 gzip 被现代浏览器使用)
brotli on;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
brotli_comp_level 6;
# 对于构建时已生成的静态资源,也可以预先生成 .br/.gz 文件,运行时直接命中,
# 避免每次请求都实时压缩消耗 CPU(static_gzip / static_brotli 模块)
\`\`\`
CDN:就近分发,大幅降低 TTFB
CDN(内容分发网络)把静态资源缓存到全球各地的边缘节点,用户请求会被调度到"距离最近、速度最快"的节点,而不是每次都要跨国访问源站。
| 场景 | 平均 TTFB(示例数据,仅作量级参考) |
| --- | --- |
| 用户直接访问源站(跨国,如国内用户访问美国服务器) | 300ms ~ 800ms |
| 用户访问就近 CDN 边缘节点(命中缓存) | 20ms ~ 80ms |
| CDN 节点未命中缓存,需回源 | 与直接访问源站相近,仅首次请求或缓存失效后发生 |
真实案例:大型电商首屏加速
某电商平台大促前对首页做过一轮网络层优化,主要动作包括:接入 CDN 分发首屏图片和静态资源、把 API 网关升级到 HTTP/2、对文本资源全面开启 brotli 压缩、给核心静态资源加上 \`Cache-Control: public, max-age=31536000, immutable\`(配合文件名 hash)。上线后主要收益:
资源提示(Resource Hints)
浏览器提供了一组 \`\` 提示,让开发者可以"剧透"接下来即将发生的网络请求,提前完成部分工作:
| 提示 | 提前完成的工作 | 开销 | 适用场景 |
| --- | --- | --- | --- |
| dns-prefetch | 仅 DNS 解析 | 最小 | 会用到但优先级不高的第三方域名 |
| preconnect | DNS + TCP 握手 + TLS 握手 | 较小,但同时开太多连接反而占用资源 | 明确接下来一定会请求的关键域名(如接口网关、字体 CDN) |
| preload | 完整下载该资源,且提高其加载优先级 | 较大,会抢占带宽 | 当前页面一定会用到的关键资源(字体、首屏图、关键 JS) |
| prefetch | 在浏览器空闲时下载资源 | 较小,优先级最低 | 用户大概率要跳转的下一页资源 |
| prerender | 后台完整渲染整个页面 | 最大 | 极高置信度的下一步跳转(如搜索结果第一条) |
代码示例:资源提示的实际写法
\`\`\`html
\`\`\`
常见坑:
📊 网络监控与关键性能指标
要优化网络性能,第一步永远是"先测量"。浏览器提供的 Navigation Timing API 和 Resource Timing API,能精确拿到 DNS、TCP、TLS、TTFB 等每个阶段的耗时。
代码示例:用 Navigation Timing API 采集关键网络指标
\`\`\`javascript
function getNetworkTimings() {
const [nav] = performance.getEntriesByType('navigation');
if (!nav) return null;
return {
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
tcp: Math.round(nav.connectEnd - nav.connectStart),
// secureConnectionStart 为 0 表示没有走 TLS(即普通 HTTP)
tls: nav.secureConnectionStart > 0
? Math.round(nav.connectEnd - nav.secureConnectionStart)
: 0,
ttfb: Math.round(nav.responseStart - nav.requestStart),
contentDownload: Math.round(nav.responseEnd - nav.responseStart),
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
fullLoad: Math.round(nav.loadEventEnd - nav.startTime)
};
}
console.table(getNetworkTimings());
// 示例输出:
// { dns: 12, tcp: 28, tls: 45, ttfb: 180, contentDownload: 35, domReady: 620, fullLoad: 980 }
\`\`\`
代码示例:用 Resource Timing API 找出慢资源
\`\`\`javascript
function findSlowResources(thresholdMs = 500) {
return performance.getEntriesByType('resource')
.filter(entry => entry.duration > thresholdMs)
.map(entry => ({
url: entry.name,
type: entry.initiatorType,
duration: Math.round(entry.duration),
transferSize: entry.transferSize,
protocol: entry.nextHopProtocol
}))
.sort((a, b) => b.duration - a.duration);
}
console.table(findSlowResources());
// 持续监控新加载的资源(适合 SPA 场景,页面运行期间动态请求也能捕获)
const resourceObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 500) {
console.warn(\`慢资源: ${entry.name}, 耗时 ${entry.duration.toFixed(0)}ms\`);
}
// 上报到监控平台
reportToAnalytics({
name: entry.name,
type: entry.initiatorType,
duration: entry.duration,
transferSize: entry.transferSize,
protocol: entry.nextHopProtocol
});
});
});
resourceObserver.observe({ entryTypes: ['resource'], buffered: true });
function reportToAnalytics(data) {
// 用 sendBeacon 上报,不阻塞页面卸载,也不影响主流程性能
navigator.sendBeacon('/api/perf-report', JSON.stringify(data));
}
\`\`\`
常用网络监控工具:
| 工具 | 定位 |
| --- | --- |
| Chrome DevTools Network 面板 | 开发调试阶段查看单次请求的详细时序瀑布图 |
| Lighthouse | 自动化审计工具,给出性能评分与具体优化建议 |
| WebPageTest | 支持多地域、多网络环境(弱网模拟)的详细性能测试 |
| Real User Monitoring(RUM,如 New Relic、Sentry Performance) | 采集真实用户的网络数据,反映线上真实情况,而不仅是实验室数据 |
网络错误处理与降级策略:
✅ 最佳实践总结
协议选择:
缓存策略:
性能优化:
安全实践:
📚 学习资源
网络协议:
性能优化:
📌 核心概念速查表
| 主题 | 一句话总结 |
| --- | --- |
| DNS 解析 | 域名转 IP,逐级缓存可大幅减少查询延迟 |
| TCP 三次握手 | 双向确认收发能力,避免半开连接浪费资源 |
| TCP 四次挥手 | 全双工连接需双方各自确认关闭 |
| HTTP/1.1 | 持久连接但存在队头阻塞,靠开多条连接缓解 |
| HTTP/2 | 二进制分帧 + 多路复用,单连接解决应用层队头阻塞 |
| HTTP/3 | 基于 QUIC/UDP,彻底解决传输层队头阻塞,支持连接迁移 |
| TLS 1.2 | 握手需 2-RTT,仍广泛使用 |
| TLS 1.3 | 握手仅需 1-RTT,支持场景下 0-RTT,是当前推荐版本 |
| 证书链 | 服务器证书由中间证书担保,最终追溯到浏览器信任的根证书 |
| WebSocket | 全双工持久连接,适合高频双向实时通信 |
| SSE | 服务器单向推送,基于 HTTP,实现简单、复用基础设施 |
| 强缓存 | 命中时完全不发请求,靠 Cache-Control/Expires 控制 |
| 协商缓存 | 命中时返回 304,靠 ETag/Last-Modified 验证 |
| CORS | 浏览器与服务器协商跨域访问权限,简单请求直发、复杂请求先预检 |
| 资源提示 | dns-prefetch/preconnect/preload/prefetch 按需提前完成部分网络工作 |
| 网络监控 | 用 Navigation/Resource Timing API 量化 DNS/TCP/TLS/TTFB,先测量再优化 |