浏览器缓存策略深度解析
一句话概括
浏览器缓存策略是浏览器与服务器之间通过 HTTP 头部协商的一套资源复用规则,核心分为”强缓存”(本地直接使用,不请求服务器)和”协商缓存”(发送验证请求,服务器决定是否使用缓存)两层机制,配合 Cache-Control、ETag、Last-Modified 等字段实现精准的资源生命周期管理。
背景与意义
一个页面,200 次请求
你打开一个内容丰富的新闻网站,首页加载了:HTML 文档(1次)、CSS 文件(12个)、JavaScript 文件(35个)、字体文件(5个)、图片资源(147个)、视频广告(2个)。总共约 202 个 HTTP 请求,合计约 8.2MB。
如果没有缓存,每次访问都需要下载全部 8.2MB。一个活跃用户一天访问该网站 10 次,就是 82MB 流量。10 万用户,一天就是 8.2TB。对 CDN 和源站来说,这不仅是带宽成本,更是服务器负载的灾难。
缓存的威力:启用合理缓存策略后,同一个用户的后续访问只需要下载 20-100KB 的变化资源,流量降低 95% 以上。
这不是理论推算。2019 年 Akamai 的报告指出,HTTP 缓存每天可以拦截全球 52% 以上的 HTTP 请求。换句话说,互联网上超过一半的网络请求从未到达源站就被回答了。
HTTP 缓存的两个核心价值
- 加速用户体验:从内存缓存(200ms)到磁盘缓存(20ms)到网络请求(300ms+),缓存让页面加载速度提升数十倍
- 节省服务端资源:缓存绕过源站,减少负载、带宽和成本
概念与定义
缓存分类一览
1
2
3
4
5
6
7
8
9
10
11
12
13
HTTP 缓存
|
+--------------------+--------------------+
| |
强缓存 协商缓存
| |
直接从缓存读取,不请求 发请求带缓存标识
服务器,返回 200 (from disk cache) 服务器判断是否修改
未修改返回 304
已修改返回 200 + 新资源
|
优先级更高,由 Expires / Cache-Control
控制,命中即终止缓存查询
| 属性 | 强缓存 | 协商缓存 |
|---|---|---|
| 是否发请求 | 不发送 | 发送(带缓存的标识) |
| 状态码 | 200 (from memory/disk cache) | 304 Not Modified |
| 控制头 | Expires, Cache-Control: max-age | ETag, Last-Modified |
| 生效时机 | 缓存未过期 | 缓存过期后,验证是否更新 |
| 网络开销 | 0(纯本地) | 1 次请求(响应体为 0) |
关键 HTTP 头部速览
| 头部字段 | 方向 | 作用 |
|---|---|---|
Cache-Control | 双向 | 最强大的缓存控制指令集 |
Expires | 响应 | 绝对过期时间(HTTP/1.0 产物) |
Last-Modified | 响应 | 资源最后修改时间 |
ETag | 响应 | 资源唯一标识(哈希/版本号) |
If-Modified-Since | 请求 | 携带上次的 Last-Modified 值 |
If-None-Match | 请求 | 携带上次的 ETag 值 |
Pragma | 双向 | Pragma: no-cache(HTTP/1.0 产物) |
Age | 响应 | 代理缓存已存在的时间(秒) |
最小示例
用 Node.js 实现完整的缓存策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// cache-server.mjs — 演示完整缓存策略的 HTTP 服务器
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
// 模拟资源数据库
const DB = {
avatar: { content: Buffer.from('avatar-data'), mtime: Date.parse('2026-05-15') },
styles: { content: Buffer.from('.app{color:red}'), mtime: Date.parse('2026-06-01') },
bundle: { content: Buffer.from('console.log("hello");'), mtime: Date.parse('2026-06-10') },
version: 1, // 用于 ETag 版本号策略
};
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
switch (url.pathname) {
case '/avatar.png':
handleAvatar(req, res);
break;
case '/styles.css':
handleStyles(req, res);
break;
case '/bundle.js':
handleBundle(req, res);
break;
default:
serveHTML(req, res);
}
});
// ========== 策略一: 强缓存 (一年不变) ==========
function handleAvatar(req, res) {
// 头像图片几乎不变,设置一年缓存
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Length': DB.avatar.content.length,
});
res.end(DB.avatar.content);
console.log(' → ✅ 强缓存: avatar.png (一年缓存, immutable)');
}
// ========== 策略二: 强缓存 + ETag 协商 ==========
function handleStyles(req, res) {
const etag = `"styles-v1-${DB.styles.mtime}"`;
// 检查 ETag 是否匹配 (协商缓存)
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, {
'ETag': etag,
'Cache-Control': 'public, max-age=3600'
});
console.log(' → ✅ 协商缓存: styles.css (304, ETag 未变)');
return res.end();
}
res.writeHead(200, {
'Content-Type': 'text/css',
'ETag': etag,
'Cache-Control': 'public, max-age=3600',
'Last-Modified': new Date(DB.styles.mtime).toUTCString()
});
res.end(DB.styles.content);
console.log(' → 📦 完整返回: styles.css (200)');
}
// ========== 策略三: ETag + Last-Modified 双重验证 ==========
function handleBundle(req, res) {
const content = DB.bundle.content;
const etag = `"bundle-${crypto.createHash('md5').update(content).digest('hex')}"`;
const lastModified = new Date(DB.bundle.mtime).toUTCString();
// 同时检查 ETag 和 Last-Modified
const ifNoneMatch = req.headers['if-none-match'];
const ifModifiedSince = req.headers['if-modified-since'];
if (ifNoneMatch === etag) {
// ETag 相同 → 未修改
res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'public, max-age=600' });
console.log(' → ✅ 协商缓存: bundle.js (ETag 匹配, 304)');
return res.end();
}
if (ifModifiedSince && new Date(ifModifiedSince) >= DB.bundle.mtime) {
// Last-Modified 检查 → 未修改
res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'public, max-age=600', 'Last-Modified': lastModified });
console.log(' → ✅ 协商缓存: bundle.js (Last-Modified 匹配, 304)');
return res.end();
}
res.writeHead(200, {
'Content-Type': 'application/javascript',
'ETag': etag,
'Cache-Control': 'public, max-age=600',
'Last-Modified': lastModified
});
res.end(content);
console.log(' → 📦 完整返回: bundle.js (200)');
}
function serveHTML(req, res) {
// HTML 页面:禁止缓存(不同用户看到的内容可能不同)
res.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache' // 每次都要验证
});
res.end(`<!DOCTYPE html>
<html>
<body>
<img src="/avatar.png" alt="头像">
<link rel="stylesheet" href="/styles.css">
<script src="/bundle.js"></script>
<p>缓存演示 — 查看网络面板的请求状态</p>
</body>
</html>`);
}
server.listen(8080, () => {
console.log('\n🚀 缓存策略演示服务器运行在 http://localhost:8080\n');
console.log('请按以下步骤测试:');
console.log('1. 打开浏览器访问 http://localhost:8080');
console.log('2. 打开开发者工具 → Network 面板');
console.log('3. 刷新页面 (F5) 观察第二次访问的缓存命中\n');
});
用 curl 测试缓存策略
1
2
3
4
5
6
7
8
9
10
11
12
13
# 第一次请求 (观察响应头)
curl -I http://localhost:8080/styles.css
# 第二次请求: 带 ETag (if-none-match) 验证缓存
curl -I -H 'If-None-Match: "styles-v1-1717171200000"' http://localhost:8080/styles.css
# 输出看到 304 说明协商缓存生效
# 模拟浏览器强制刷新: 带 Cache-Control: no-cache
curl -I -H 'Cache-Control: no-cache' http://localhost:8080/styles.css
# 模拟浏览器 Ctrl+Shift+R (硬刷新): 带 Pragma: no-cache
curl -I -H 'Pragma: no-cache' http://localhost:8080/styles.css
核心知识点拆解
1. Cache-Control 指令完全解析
Cache-Control 是 HTTP/1.1 定义的标准缓存控制头,一个头包含多个指令组合:
请求端(浏览器 → 服务器):
| 指令 | 含义 | 使用场景 |
|---|---|---|
no-cache | 必须在提交请求时验证缓存 | Ctrl+F5 强制刷新 |
no-store | 不使用任何缓存 | 敏感数据(银行交易) |
max-age=0 | 等同于 no-cache | 离开页面再回来时的请求 |
min-fresh=10 | 缓存至少还有 10 秒新鲜 | 时间敏感内容 |
only-if-cached | 只要缓存,不要网络 | 离线应用 |
响应端(服务器 → 浏览器):
| 指令 | 含义 | 最佳实践 |
|---|---|---|
max-age=3600 | 缓存有效期为 3600 秒 | 静态资源 1 年,HTML 10 分钟 |
s-maxage=3600 | 仅对中间代理缓存生效 | CDN 专用缓存时间 |
public | 任何节点均可缓存 | 静态公开资源 |
private | 仅用户浏览器可缓存 | 用户个人数据 |
no-cache | 缓存但要验证 | HTML 文件 |
no-store | 完全禁止缓存 | Token/支付页面 |
must-revalidate | 过期后必须验证 | 重要业务数据 |
proxy-revalidate | 代理必须验证 | 与 must-revalidate 类似但仅对代理 |
immutable | 资源永不变化,无需验证 | JS/CSS 哈希指纹文件 |
stale-while-revalidate=60 | 60 秒内用旧缓存,后台更新 | 用户无感知更新 |
stale-if-error=86400 | 源站出错时可用旧缓存 1 天 | 高可用场景 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 不同资源类型的推荐策略
const cachingPolicies = {
// 📸 静态资源(图片、字体、带有哈希的 JS/CSS)
'static': {
// 指纹文件: 文件名包含 hash,内容变化后 URL 不同
'Cache-Control': 'public, max-age=31536000, immutable',
example: '/logo.a1b2c3d4.png'
},
// 📄 HTML 页面
'html': {
// 每次都要验证,但如果有缓存可以返回 304
'Cache-Control': 'no-cache',
example: '/index.html'
},
// 🗃️ API 数据
'api': {
// 根据数据特性设置短缓存
'Cache-Control': 'private, max-age=60',
example: '/api/user/profile'
},
// 🚨 敏感信息
'sensitive': {
'Cache-Control': 'no-store',
example: '/api/transfer'
},
// 📦 版本化 API
'versioned-api': {
// 允许 CDN 缓存,但客户端不自动缓存
'Cache-Control': 'public, s-maxage=86400, max-age=0',
example: '/api/v1/products'
}
};
2. ETag 的工作原理
ETag(Entity Tag)是资源的”指纹”——通常是资源内容的哈希值或版本号。
1
2
3
4
5
6
7
8
9
服务器逻辑:
ETag = MD5(文件内容) 或 "版本号-最后修改时间"
客户端逻辑:
1. 首次请求 → 拿到 ETag 并缓存
2. 再次请求 → 请求头带 If-None-Match: "xxx"
3. 服务器比较:
a. 一致 → 304 (响应体为空)
b. 不一致 → 200 + 新资源 + 新 ETag
强 ETag vs 弱 ETag:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 强 ETag: 内容的一丁点变化都会改变值
const strongETag = crypto
.createHash('sha256')
.update(fileContent)
.digest('base64');
// 弱 ETag: 只对语义变化敏感,仅用于字节级别的等价比较
// 以 W/ 开头的 ETag
const weakETag = `W/"v1-${fileSize}`;
// 使用场景
// 强 ETag: 精确控制 CDN 缓存
// 弱 ETag: gzip 运输编码场景(解压前后字节不同但语义相同)
重要细节:如果服务器使用 Last-Modified 而非 ETag,存在秒级精度限制——同一秒内修改的内容无法感知。ETag 是更精确的方案。
3. 缓存优先级与决策树
浏览器处理缓存的优先级决策流程(可使用 Service Worker 加强制):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
用户请求 URL
|
v
[Service Worker 拦截] (如果注册)
|
↓ 无 SW 或未拦截
[查询内存缓存 (memory cache)]
|
↓ 未命中
[查询磁盘缓存 (disk cache)]
|
↓ 未命中
[检查 Cache-Control / Expires]
|
+---> 未过期 → 强缓存命中 → 返回 200 (from cache)
|
+---> 已过期 → 发起条件请求
|
+---> 带 If-None-Match / If-Modified-Since
|
v
[服务器判断]
|
+---> 未修改 → 304 → 使用缓存
|
+---> 已修改 → 200 + 新资源
浏览器刷新行为的差异:
| 操作 | 请求头 | 缓存行为 |
|---|---|---|
| 普通导航(地址栏输入) | 无特殊头 | 正常命中缓存 |
| F5 / 刷新按钮 | Cache-Control: max-age=0 | 跳过强缓存,走协商缓存 |
| Ctrl+F5 / 硬刷新 | Cache-Control: no-cache + Pragma: no-cache | 跳过所有缓存,直接发新请求 |
| 前进/后退 | 无特殊头 | 从 bfcache (Back-Forward Cache) 恢复,甚至不发起请求 |
4. CDN 的缓存层次
1
2
3
4
5
6
7
8
9
10
11
12
13
客户端
|
↓
浏览器缓存 (disk cache) — 第一层 (用户私有的)
|
↓ 未命中
CDN 边缘节点 (Edge Cache) — 第二层 (区域共享)
|
↓ 未命中
CDN 父节点 (Parent / Shield) — 第三层 (区域聚合)
|
↓ 未命中
源站服务器 (Origin Server) — 最后一层
CDN 缓存与浏览器缓存独立管理。CDN 通常使用 s-maxage 和 CDN-Cache-Control 头:
1
2
3
4
5
6
7
8
9
10
11
# Nginx CDN 缓存配置
location /static/ {
# 源站响应头控制 CDN 缓存
add_header Cache-Control "public, max-age=2592000";
# 特定 CDN (Cloudflare / Akamai)
# 设置 CDN 专用缓存时间,与浏览器缓存独立
add_header CDN-Cache-Control "public, max-age=86400";
proxy_pass http://origin;
}
实战案例
案例:大型电商平台的缓存架构
搭建一个简化的 E-commerce 商品详情页的缓存系统,包含多层缓存策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// product-server.mjs — 商品详情 API 缓存演示
import http from 'node:http';
import crypto from 'node:crypto';
// 模拟数据库
const productDB = new Map();
// 初始化商品数据
for (let i = 1; i <= 1000; i++) {
const price = Math.random() * 10000;
const stock = Math.floor(Math.random() * 1000);
// 模拟数据版本
productDB.set(i, {
id: i,
name: `商品 ${i}`,
price,
description: `这是第 ${i} 号商品的详细描述,包含规格参数...`,
category: `分类-${i % 10}`,
imageUrls: [`/img/${i}/main.jpg`, `/img/${i}/detail.jpg`],
updatedAt: Date.now() - Math.random() * 86400000,
dataVersion: 1
});
}
// 内存缓存层(模拟 Redis)
class ProductCache {
constructor(ttl = 60000) { // 默认 60 秒
this.store = new Map();
this.ttl = ttl;
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.data;
}
set(key, data, customTTL = null) {
this.store.set(key, {
data,
expiresAt: Date.now() + (customTTL || this.ttl)
});
}
// 使用 ETag 的"缓存标签"方式失效
invalidateByTag(tag) {
const prefix = `tag:${tag}:`;
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
}
const cache = new ProductCache();
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname.startsWith('/api/product/')) {
const productId = parseInt(url.pathname.split('/')[3]);
handleProductRequest(req, res, productId);
} else if (url.pathname === '/api/products') {
handleProductList(req, res, url);
} else if (url.pathname === '/api/admin/update-price') {
// 模拟后台更新价格
handlePriceUpdate(req, res);
} else {
servePage(req, res);
}
});
// 商品详情 — 多级缓存策略
function handleProductRequest(req, res, productId) {
const product = productDB.get(productId);
if (!product) {
res.writeHead(404).end('Not found');
return;
}
// 1️⃣ 生成 ETag — 基于数据和版本号
const etagValue = crypto
.createHash('md5')
.update(`${product.dataVersion}-${product.price}-${product.updatedAt}`)
.digest('hex');
const etag = `"${etagValue}"`;
// 2️⃣ 协商缓存检查
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag) {
// ETag 一致 → 304
res.writeHead(304, {
'ETag': etag,
'Cache-Control': 'private, max-age=30, stale-while-revalidate=300',
'X-Cache': 'HIT (etag)'
});
return res.end();
}
// 3️⃣ 应用层缓存检查 (模拟多级缓存)
const cacheKey = `product:${productId}:${etagValue}`;
const cached = cache.get(cacheKey);
if (cached) {
res.writeHead(200, {
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'private, max-age=30, stale-while-revalidate=300',
'X-Cache': 'HIT (local)',
'Age': Math.floor((Date.now() - cached.cachedAt) / 1000).toString()
});
return res.end(JSON.stringify(cached.data));
}
// 4️⃣ 构造响应(模拟复杂计算)
const response = {
id: product.id,
name: product.name,
price: product.price.toFixed(2),
description: product.description.substring(0, 50) + '...',
category: product.category,
images: product.imageUrls,
variants: [
{ color: '红色', sku: `${productId}-R`, stock: Math.min(stock, 100) },
{ color: '蓝色', sku: `${productId}-B`, stock: Math.min(stock + 50, 100) },
],
reviews: {
count: Math.floor(Math.random() * 500),
rating: (3.5 + Math.random() * 1.5).toFixed(1)
},
// 缓存元数据
_cache: {
computedAt: new Date().toISOString(),
ttl: 30,
etag
}
};
// 写入应用缓存
cache.set(cacheKey, { data: response, cachedAt: Date.now() });
res.writeHead(200, {
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'private, max-age=30, stale-while-revalidate=300',
'X-Cache': 'MISS'
});
res.end(JSON.stringify(response));
}
// 商品列表 — CDN 友好缓存
function handleProductList(req, res, url) {
// 列表页用长缓存,适合 CDN
const page = parseInt(url.searchParams.get('page') || '1');
const category = url.searchParams.get('category') || 'all';
// 查询数据库
const filtered = Array.from(productDB.values())
.filter(p => category === 'all' || p.category === category);
const totalPages = Math.ceil(filtered.length / 20);
const items = filtered.slice((page - 1) * 20, page * 20);
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=600, max-age=60',
'Vary': 'Accept-Encoding'
});
res.end(JSON.stringify({ items, totalPages, page }));
}
// 价格更新 — 缓存失效
function handlePriceUpdate(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const { productId, newPrice } = JSON.parse(body);
const product = productDB.get(productId);
if (!product) {
res.writeHead(404).end('Not found');
return;
}
// 更新数据版本号 → 所有 ETag 自动变
product.dataVersion++;
product.price = newPrice;
product.updatedAt = Date.now();
// 使相关缓存失效
cache.invalidateByTag(`product:${productId}`);
console.log(`💰 价格更新: 商品 ${productId} → ¥${newPrice}`);
res.writeHead(200).end(JSON.stringify({ success: true }));
});
}
server.listen(8080, () => {
console.log('🏪 电商缓存演示服务器: http://localhost:8080');
console.log(' 商品详情: http://localhost:8080/api/product/1');
console.log(' 商品列表: http://localhost:8080/api/products?page=1');
console.log(' 更新价格: POST /api/admin/update-price { productId, newPrice }\n');
});
运行后,用工具测试缓存行为:
1
2
3
4
5
6
7
8
9
10
11
# 测试商品详情缓存
curl -v http://localhost:8080/api/product/1 2>&1 | grep -E "(ETag|Cache-Control|X-Cache)"
# 带 ETag 验证
ETAG=$(curl -sI http://localhost:8080/api/product/1 | grep -i etag | awk '{print $2}')
curl -v -H "If-None-Match: $ETAG" http://localhost:8080/api/product/1 2>&1 | grep "HTTP/"
# → 输出 304
# 测试 CDN 友好页面
curl -I http://localhost:8080/api/products?page=1
# → Cache-Control: public, s-maxage=600
底层原理
Chromium 磁盘缓存实现
Chromium 的磁盘缓存使用了一种称为”Simple Cache”的实现,数据存储在 ~Library/Caches/Chromium/ 下的 Cache 和 Code Cache 目录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 简化自 Chromium: net/disk_cache/backend.cc
// 磁盘缓存索引结构
class DiskCacheEntry {
// 缓存的键 = URL (经过规范化处理)
std::string key_;
// 缓存的元数据
HttpResponseInfo response_info_; // 包含所有响应头
int64_t response_time_; // 缓存写入时间
int64_t expiration_time_; // 过期时间 (从 Cache-Control 计算)
// 数据块
std::vector<disk_cache::Entry*> data_streams_;
// 状态标记
enum State {
NORMAL, // 正常可用
EVICTED, // 已被淘汰
DOOMED // 等待删除
} state_;
};
// 缓存淘汰算法: LRU (Least Recently Used)
// Chromium 的 LRU 使用两个队列:
// - 活跃队列: 最近被访问的条目
// - 不活跃队列: 长时间未访问的条目
// 当缓存空间不足时,从不活跃队列尾部开始删除
class SimpleBackendImpl {
// 缓存限制: 根据设备内存决定
// 低端设备: ~80MB
// 中端设备: ~320MB
// 高端设备: ~1GB
int max_size_; // 最大缓存字节数
int current_size_; // 当前缓存字节数
LRUQueue lru_queue_; // LRU 排序队列
void EvictIfNeeded() {
while (current_size_ > max_size_) {
// 从不活跃队列尾部取出
auto entry = lru_queue_.PopLast();
current_size_ -= entry->GetSize();
entry->Doom(); // 标记删除
}
}
};
HTTP 缓存与 Service Worker 的互动
Service Worker 位于缓存策略的最前端,可以拦截所有 HTTP 请求并自定义响应策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// Service Worker 中的缓存策略 — 比 HTTP 头部优先级更高
// 缓存名称和版本
const CACHE_NAME = 'my-app-v1';
const DYNAMIC_CACHE = 'dynamic-v1';
// 安装阶段:预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/app.bundle.js',
'/styles.css',
'/logo.a1b2c3d4.png'
]);
})
);
});
// 拦截请求:五种缓存策略
self.addEventListener('fetch', (event) => {
const request = event.request;
// 策略1: Cache First (先查缓存)
if (request.url.includes('/static/')) {
event.respondWith(cacheFirst(request));
}
// 策略2: Network First (先发请求,失败使用缓存)
else if (request.url.includes('/api/product/')) {
event.respondWith(networkFirst(request));
}
// 策略3: Stale While Revalidate
else if (request.url.includes('/api/products')) {
event.respondWith(staleWhileRevalidate(request));
}
// 策略4: Network Only
else if (request.url.includes('/api/user')) {
event.respondWith(fetch(request));
}
// 策略5: Cache Only (离线时使用)
else {
event.respondWith(cacheFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) {
// 检查是否过期 (从 Cache-Control 读取)
const cacheAge = getCacheAge(cached);
if (cacheAge < 3600) {
return cached;
}
// 过期了,仍然返回但后台发起更新请求
updateInBackground(request);
return cached;
}
const response = await fetch(request);
updateCache(request, response.clone());
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
updateCache(request, response.clone());
}
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) return cached;
// 离线且无缓存 — 返回兜底页面
return new Response('离线模式', { status: 503 });
}
}
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request).then(response => {
if (response.ok) updateCache(request, response.clone());
return response;
});
if (cached) {
// 返回缓存,但后台更新
fetchPromise.catch(() => {}); // 静默处理
return cached;
}
return fetchPromise;
}
function updateInBackground(request) {
fetch(request).then(response => {
if (response.ok) updateCache(request, response);
}).catch(() => {});
}
async function updateCache(request, response) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, response);
}
function getCacheAge(response) {
const dateHeader = response.headers.get('date');
const cacheControl = response.headers.get('cache-control');
const maxAgeMatch = cacheControl?.match(/max-age=(\d+)/);
if (!maxAgeMatch) return 0;
const maxAge = parseInt(maxAgeMatch[1]);
const cachedAt = dateHeader ? Date.parse(dateHeader) : Date.now();
return (Date.now() - cachedAt) / 1000;
}
高频面试题解析
面试题 1:Cache-Control: no-cache 和 no-store 有什么区别?实际业务中如何选择?
答案要点:
这是一个高频被误解的问题。很多人以为”no-cache = 不缓存”——这是错的。
no-cache:缓存前必须到服务器验证(通过 ETag/Last-Modified)。如果验证通过(304),可以使用缓存。“Use cache but check first”。no-store:彻底禁止缓存。不仅不存储响应体,连请求路径、查询参数等元信息也不可以存。“Don’t store anything”。
1
2
3
4
5
6
7
8
9
10
11
// 实际应用选择
const cachePolicies = {
// ✅ no-cache 场景
'/index.html': 'no-cache', // HTML 需要每次验证,但流量很小
'/api/user/profile': 'no-cache', // 用户信息可能被其他页面推送更新
// ❌ no-store 场景
'/api/payment': 'no-store', // 支付数据—绝对不能存入磁盘
'/api/logout': 'no-store', // Token 失效操作
'/api/send-code': 'no-store', // 验证码
};
真实世界故事:2019 年,某大型银行的移动 App 错误地将敏感交易接口设置为 no-cache。安全审计发现,用户的交易页面可以在离线模式下用缓存展示(虽然数据过期)。银行紧急将其改为 no-store,因为磁盘缓存是可被攻击者物理读取的。
面试题 2:资源更新时如何让用户立即看到新版本?说说你的方案。
答案要点:
这是一个工程实战题,关键在于意识到:强缓存是”被动的”——浏览器过期之前根本不会问服务器。
方案一:文件指纹 + URL 变更(最可靠)
1
2
3
// 构建工具 (webpack/vite) 自动添加内容哈希
style.a1b2c3d4.css → 内容变了 → style.e5f6g7h8.css
// HTML 引用的 URL 也变了 → 浏览器自然不会用旧的缓存
方案二:版本号路径
1
/static/v1/app.js → /static/v2/app.js
方案三:Cache Busting(缓存破坏)
1
2
3
4
5
// 在文件名或参数中加入版本号
const script = document.createElement('script');
script.src = `/app.js?v=${BUILD_VERSION}`;
// 这是最弱的方式 — 代理/CDN 可能忽略 query string
方案四:Service Worker 主动更新
1
2
3
4
5
6
7
8
9
10
11
12
self.addEventListener('activate', (event) => {
// 删除旧缓存,强制使用新版本
const cacheWhitelist = [CACHE_NAME]; // 只保留新版本的缓存
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(name => !cacheWhitelist.includes(name))
.map(name => caches.delete(name))
);
})
);
});
推荐方案:第一方资源 + 文件指纹 + immutable 标记 → 长缓存 + URL 变更;HTML 用 no-cache → 每次验证。
面试题 3:解释 stale-while-revalidate 的工作机制及适用场景。
答案要点:
1
// Cache-Control: max-age=60, stale-while-revalidate=3600
浏览器时间线:
1
2
3
4
5
6
t=0 t=60 t=3060 t=3660
|----------|------------|--------------|
缓存创建 过期时间 可用过期 强制过期
↑ ↑ ↑
60s 60s+3600s 60s+3600s=3660s
新鲜期 "陈旧可用"期 必须请求源站
- 0-60s(新鲜期):强缓存命中,直接使用
- 60-3660s(陈旧可用期):立即返回缓存内容,同时在后台发条件请求验证,收到新数据后更新缓存
- 3660s+(强制过期):必须等网络响应才能显示内容
适用场景:
- 首页/信息流:用户可以接受看”稍微旧一点”的内容,但不能忍受加载白屏
- 文章内容页:内容更新不频繁,用户流畅度优先级高于内容的新鲜度
- 非实时排行榜:昨日排行榜显示旧数据没关系,用户交互过程不被打断
1
2
# 最佳实践
Cache-Control: public, max-age=60, stale-while-revalidate=86400, stale-if-error=86400
这个组合告诉浏览器:新鲜期 60 秒,1 天内可以用旧数据并在后台更新,如果源站挂了用旧数据兜底 1 天。
总结与扩展
浏览器缓存策略是前端性能优化中最具性价比的投入——不需要改造架构,只需要调整 HTTP 响应头就能大幅提升加载速度:
战略原则:
- 分层缓存:不可变资源(指纹文件)→
immutable一年缓存;可变资源(HTML)→no-cache每次验证;API 数据 → 短缓存 + ETag - 缓存为王,验证为辅:能用强缓存解决的问题不用协商缓存,能用协商缓存解决的问题不走完整请求
- 控制失效:用 URL 而非清缓存来部署新版本,做好缓存失效策略的测量
资源类型速查表:
| 资源类型 | Cache-Control | ETag | CDN |
|---|---|---|---|
| 指纹 JS/CSS | public, max-age=31536000, immutable | ✅ | ✅ |
| 非指纹 JS/CSS | public, max-age=3600 | ✅ | ✅ |
| 图片/字体 | public, max-age=2592000 | ✅ | ✅ |
| HTML | no-cache | ✅ | ❌ |
| API GET | private, max-age=60 | ✅ | ❌ |
| API POST | no-store | ❌ | ❌ |
缓存优化不是”设置一个头就行”——它是贯穿应用的架构级决策。一个好的缓存策略让你的应用在秒级内完成加载,一个坏的策略让你的用户看了一天前的旧数据。理解 max-age 的一次性设置和 stale-while-revalidate 的持续验证之间的差异,决定了你的用户体验是从 9 分变成 10 分,还是从 9 分掉到 6 分。