在优化 Web 应用性能时,我发现一个有趣的矛盾:用户希望看到最新的内容,但同时又期望页面加载飞快。这个矛盾的解决方案,就藏在 HTTP 缓存机制中。
那么,HTTP 缓存到底是如何工作的?强缓存和协商缓存有什么区别?如何为不同类型的资源设置合适的缓存策略?
问题的起源
为什么需要缓存?最直接的原因是性能。网络请求的延迟远高于本地读取,尤其在移动网络环境下。如果每次访问都要重新下载所有资源,用户体验会很差。
但缓存又带来了新的问题:新鲜度。如果资源被缓存了,用户如何获取更新后的版本?
HTTP 缓存机制就是在这两个目标之间寻找平衡:既要快,又要新。
核心概念探索
1. 浏览器缓存的层级结构
在深入 HTTP 缓存之前,先了解浏览器的完整缓存体系:
浏览器请求资源的缓存查找顺序:
-
Memory Cache(内存缓存)
- 特点:最快,但容量小,tab 关闭即清空
- 存储:当前页面的资源(图片、脚本、样式)
-
Service Worker Cache
- 特点:可编程,离线可用
- 存储:开发者主动缓存的资源
-
Disk Cache(磁盘缓存)
- 特点:容量大,持久化
- 存储:根据 HTTP 缓存头决定
-
Push Cache(HTTP/2 推送缓存)
- 特点:短暂存在,只在会话期间
- 存储:服务器推送的资源
-
网络请求
- 最后的选择:如果以上都没有,发起网络请求
今天我们主要关注的是 Disk Cache 层面的 HTTP 缓存。
2. 强缓存(Strong Cache)
强缓存是指浏览器直接从本地缓存读取资源,不发送任何网络请求到服务器。
Expires(HTTP/1.0)
HTTP/1.0 200 OK
Content-Type: text/css
Expires: Wed, 21 Oct 2026 07:28:00 GMT
/* CSS 内容 */
Expires 的问题:
-
使用的是绝对时间:如果服务器和客户端时间不同步,缓存会失效
-
优先级低于 Cache-Control:如果两者同时存在,Expires 会被忽略
Cache-Control(HTTP/1.1,推荐)
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: max-age=31536000
/* JavaScript 内容 */
Cache-Control 常用指令:
HTTP 响应头 + Cache-Control 指令详解
-
max-age=<seconds>- 指定资源缓存的最大时长(相对时间,单位:秒)
Cache-Control: max-age=3600// 缓存 1 小时
-
no-cache- 不是"不缓存"!而是"需要验证"
- 浏览器会缓存资源,但每次使用前必须向服务器验证是否过期
Cache-Control: no-cache
-
no-store- 真正的"不缓存":浏览器不缓存,每次都重新请求
Cache-Control: no-store
-
public- 允许中间代理(CDN)缓存
Cache-Control: public, max-age=86400
-
private- 只允许浏览器缓存,中间代理不能缓存(如包含用户隐私信息的响应)
Cache-Control: private, max-age=3600
-
immutable- 表示资源永远不会改变,即使用户刷新页面也不重新验证
Cache-Control: max-age=31536000, immutable
-
must-revalidate- 缓存过期后必须向服务器验证,不能使用过期缓存
Cache-Control: max-age=3600, must-revalidate
常见组合:
# 场景 1:永久缓存(适合带 hash 的静态资源)
Cache-Control: public, max-age=31536000, immutable
# 场景 2:不缓存(适合 HTML 入口文件)
Cache-Control: no-cache
# 场景 3:私密内容(适合用户个人信息)
Cache-Control: private, max-age=0, must-revalidate
# 场景 4:完全不存储(适合敏感数据)
Cache-Control: no-store
3. 协商缓存(Negotiation Cache)
当强缓存失效后,浏览器会发送请求到服务器,但可以通过协商来判断资源是否需要重新下载。
Last-Modified / If-Modified-Since
# 首次请求响应:
HTTP/1.1 200 OK
Last-Modified: Mon, 10 Jan 2026 10:00:00 GMT
Cache-Control: no-cache
/* 资源内容 */
# 再次请求时,浏览器携带:
GET /style.css HTTP/1.1
If-Modified-Since: Mon, 10 Jan 2026 10:00:00 GMT
# 如果资源未修改,服务器返回:
HTTP/1.1 304 Not Modified
# 没有响应体,浏览器使用本地缓存
# 如果资源已修改,服务器返回:
HTTP/1.1 200 OK
Last-Modified: Tue, 11 Jan 2026 14:30:00 GMT
/* 新的资源内容 */
Last-Modified 的局限性:
问题 1:精度只到秒:如果文件在 1 秒内修改多次,无法检测到
问题 2:基于修改时间:即使文件内容没变,只是修改了时间戳(如重新编译),也会被认为是"已修改"
问题 3:某些服务器无法准确获取文件修改时间
ETag / If-None-Match(推荐)
ETag 是资源的唯一标识(通常是文件内容的 hash 值)。
# 首次请求响应:
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache
/* 资源内容 */
# 再次请求时,浏览器携带:
GET /app.js HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# 如果 ETag 匹配(内容未改变),服务器返回:
HTTP/1.1 304 Not Modified
# 如果 ETag 不匹配(内容已改变),服务器返回:
HTTP/1.1 200 OK
ETag: "7f8c9d2e1a3b4c5d6e7f8g9h0i1j2k3l4m5n6o7p"
/* 新的资源内容 */
ETag vs Last-Modified:
| 特性 | ETag | Last-Modified |
|---|---|---|
| 精度 | 基于内容 hash,精度高 | 基于时间,精度到秒 |
| 优先级 | 高(如果同时存在,优先使用 ETag) | 低 |
| 服务器开销 | 需要计算 hash,开销大 | 开销小 |
| 适用场景 | 内容频繁变化,需要精确控制 | 一般场景 |
4. 缓存决策流程
浏览器请求资源时的完整决策过程:
// 环境:浏览器内部逻辑
// 场景:缓存决策流程(伪代码)
function fetchResource(url) {
// 1. 检查 Memory Cache
if (memoryCache.has(url)) {
return memoryCache.get(url);
}
// 2. 检查 Service Worker Cache
if (serviceWorkerCache.has(url)) {
return serviceWorkerCache.get(url);
}
// 3. 检查 Disk Cache(HTTP 缓存)
const cached = diskCache.get(url);
if (cached) {
// 3.1 检查是否有 Cache-Control: no-store
if (cached.headers['cache-control'].includes('no-store')) {
// 不使用缓存,直接请求
return fetchFromNetwork(url);
}
// 3.2 检查强缓存是否有效
const maxAge = getCacheMaxAge(cached.headers);
const age = Date.now() - cached.timestamp;
if (age < maxAge) {
// 强缓存有效,直接返回
console.log('from disk cache');
return cached.data;
}
// 3.3 强缓存失效,检查是否需要协商缓存
if (cached.headers['cache-control'].includes('no-cache') || cached.headers.etag || cached.headers['last-modified']) {
// 发起协商缓存请求
return revalidateCache(url, cached);
}
}
// 4. 没有缓存,发起网络请求
return fetchFromNetwork(url);
}
function revalidateCache(url, cached) {
const headers = {};
// 添加协商缓存请求头
if (cached.headers.etag) {
headers['If-None-Match'] = cached.headers.etag;
}
if (cached.headers['last-modified']) {
headers['If-Modified-Since'] = cached.headers['last-modified'];
}
const response = fetch(url, { headers });
if (response.status === 304) {
// 资源未修改,使用本地缓存
console.log('304 Not Modified');
return cached.data;
}
// 资源已修改,使用新内容并更新缓存
return response.data;
}
用 Mermaid 图表表示:
graph TD
A[请求资源] --> B{Memory Cache?}
B -->|有| C[返回缓存]
B -->|无| D{Service Worker?}
D -->|有| C
D -->|无| E{Disk Cache?}
E -->|无| F[网络请求]
E -->|有| G{no-store?}
G -->|是| F
G -->|否| H{强缓存有效?}
H -->|是| C
H -->|否| I{支持协商缓存?}
I -->|否| F
I -->|是| J[发起验证请求]
J --> K{304?}
K -->|是| C
K -->|否| L[下载新资源]
实际场景思考
场景 1:SPA 应用的缓存策略
单页应用(SPA)通常有这样的文件结构:
dist/
├── index.html # 入口文件
├── main.[hash].js # 应用主逻辑
├── vendor.[hash].js # 第三方库
├── style.[hash].css # 样式文件
└── assets/
└── logo.[hash].png # 静态资源
推荐的缓存策略:
// 环境:Nginx / Node.js 服务器
// 场景:为不同类型文件设置缓存
// 1. index.html:永远不缓存(或协商缓存)
// 原因:作为入口,必须获取最新版本来引用正确的 hash 文件
location = /index.html {
add_header Cache-Control "no-cache";
# 或者
# add_header Cache-Control "no-store";
}
// 2. 带 hash 的资源文件:永久缓存
// 原因:文件名包含内容 hash,内容变化文件名就变,可以放心长缓存
location ~* .(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$ {
# 如果文件名包含 hash
if ($request_filename ~* .[a-f0-9]{8,}.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$) {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
// 3. 不带 hash 的资源:短期缓存 + 协商缓存
location ~* .(js|css)$ {
add_header Cache-Control "public, max-age=3600";
# 浏览器会自动处理 ETag/Last-Modified
}
Webpack 配置生成 hash 文件名:
// 环境:Node.js
// 场景:Webpack 配置
// 依赖:webpack
module.exports = {
output: {
filename: '[name].[contenthash:8].js',
chunkFilename: '[name].[contenthash:8].chunk.js',
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].chunk.css',
}),
],
};
// contenthash:基于文件内容生成 hash
// 只有内容改变,hash 才会变
// 用户访问 index.html 时,会看到:
// <script src="/main.a1b2c3d4.js"></script>
// 如果 main.js 内容改变,变成:
// <script src="/main.e5f6g7h8.js"></script>
// 浏览器会请求新文件,而不是使用旧的缓存
场景 2:强制用户更新资源
即使设置了正确的缓存策略,有时仍需要强制用户更新:
// 问题场景:
// 用户已经访问过旧版本,浏览器缓存了 index.html
// 即使部署了新版本,用户刷新页面仍然看到旧的 index.html
// 旧的 index.html 引用旧的 js 文件
// 解决方案 1:index.html 使用 no-cache(推荐)
// 每次都向服务器验证,确保获取最新版本
// 解决方案 2:index.html 添加版本号查询参数
// 通过修改 URL 强制浏览器请求新资源
<script src="/app.js?v=1.2.3"></script>
// 解决方案 3:使用 Service Worker 控制缓存
// Service Worker 可以主动清除旧缓存
self.addEventListener('activate', event => {
const cacheWhitelist = ['v2'];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
场景 3:开发环境 vs 生产环境的缓存差异
// 环境:Webpack DevServer
// 场景:开发环境禁用缓存
// 开发环境配置
module.exports = {
devServer: {
headers: {
// 禁用缓存,确保每次都获取最新代码
'Cache-Control': 'no-store',
},
},
};
// 为什么开发环境要禁用缓存?
// 1. 代码频繁修改,需要实时看到效果
// 2. 避免改了代码但浏览器使用旧缓存的困惑
// 3. 开发环境不关心性能,关心开发体验
// 生产环境配置(Nginx)
// 需要精细的缓存策略,平衡性能和新鲜度
场景 4:CDN 缓存失效
CDN 有自己的缓存层,如何处理?
// 问题:部署了新版本,但 CDN 仍然返回旧内容
// 解决方案 1:CDN Purge API(手动清除缓存)
// 大多数 CDN 提供了清除缓存的 API
// 例如 Cloudflare:
const response = await fetch('https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache', {
method: 'POST',
headers: {
'X-Auth-Email': 'user@example.com',
'X-Auth-Key': 'your-api-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [
'https://example.com/style.css',
'https://example.com/app.js',
],
}),
});
// 解决方案 2:使用带 hash 的文件名(最佳实践)
// 文件内容变化 → hash 变化 → URL 变化 → CDN 缓存失效
// 这样就不需要手动清除 CDN 缓存了
// 解决方案 3:设置合适的 Cache-Control
// 对于 CDN,可以使用 s-maxage 单独控制 CDN 缓存时长
Cache-Control: public, max-age=3600, s-maxage=86400
// max-age:浏览器缓存 1 小时
// s-maxage:CDN 缓存 24 小时
场景 5:Cookie 与缓存
Cookie 会影响缓存行为:
// 问题:包含 Cookie 的请求默认不会被 CDN 缓存
// 请求:
GET /api/user HTTP/1.1
Cookie: session_id=abc123
// CDN 通常不会缓存这个响应,因为它可能包含用户特定的内容
// 解决方案 1:静态资源使用独立域名(Cookie-free domain)
// HTML:https://www.example.com (可能有 Cookie)
// 静态资源:https://static.example.com (无 Cookie)
// 解决方案 2:使用 Vary 响应头
HTTP/1.1 200 OK
Vary: Cookie
Cache-Control: public, max-age=3600
// Vary: Cookie 告诉缓存服务器:
// 不同 Cookie 的请求应该分别缓存
知识点快速回顾
(30 秒版本)
Q: 什么是强缓存和协商缓存?
A: 强缓存是浏览器直接从本地读取资源,不发送请求到服务器,通过 Cache-Control(如 max-age)控制;协商缓存是浏览器向服务器验证资源是否过期,如果未过期返回 304,使用本地缓存,通过 ETag/Last-Modified 控制。
Q: Cache-Control 的常用指令有哪些?
A:
max-age=<seconds>:缓存时长no-cache:需要验证(不是不缓存)no-store:不缓存public:允许 CDN 缓存private:只允许浏览器缓存immutable:资源不会变化
Q: ETag 和 Last-Modified 有什么区别?
A: ETag 基于内容 hash,精度高,优先级高,但服务器开销大;Last-Modified 基于修改时间,精度到秒,优先级低,开销小。如果两者都存在,优先使用 ETag。
(2 分钟版本)
Q: SPA 应用如何设置缓存策略?
A: 典型策略是:
index.html:no-cache(每次验证,确保获取最新版本)- 带 hash 的资源(
app.[hash].js):max-age=31536000, immutable(永久缓存) - 不带 hash 的资源:短期缓存(如
max-age=3600)
原理是:index.html 作为入口必须最新,它引用的资源文件名包含 hash,内容变化时 hash 就变,URL 变了缓存自然失效。
Q: 为什么有些资源显示 "from disk cache",有些显示 "from memory cache"?
A: Memory Cache 是内存缓存,速度最快但容量小,tab 关闭即清空,通常缓存当前页面的资源;Disk Cache 是磁盘缓存,容量大、持久化,根据 HTTP 缓存头控制。浏览器会优先查找 Memory Cache,没有再查找 Disk Cache。
Q: no-cache 和 no-store 的区别?
A:
no-cache:浏览器会缓存资源,但每次使用前必须向服务器验证(协商缓存),如果服务器返回 304,使用本地缓存no-store:完全不缓存,每次都重新下载
no-cache 的命名容易误解,它不是"不缓存",而是"缓存但需验证"。
Q: 304 状态码的完整流程是什么?
A:
- 浏览器发现强缓存过期(或设置了 no-cache)
- 发起请求,携带 If-None-Match(ETag)或 If-Modified-Since(时间戳)
- 服务器比对 ETag 或修改时间
- 如果资源未改变,返回 304 Not Modified(无响应体)
- 浏览器使用本地缓存
304 响应虽然也有网络请求,但没有响应体,节省了带宽。
Q: 如何强制用户更新缓存的资源?
A: 常见方法:
- 文件名加 hash(最佳):
app.[contenthash].js - URL 加版本号:
style.css?v=1.2.3 - 设置
no-cache:每次验证 - CDN Purge:手动清除 CDN 缓存
- Service Worker:主动清除旧缓存
推荐第 1 种,因为它自动化、可靠、不需要手动操作。
有关 HTTP 缓存策略的高频关键概念
- 强缓存 / 协商缓存
- Cache-Control / Expires
- ETag / If-None-Match
- Last-Modified / If-Modified-Since
- 304 Not Modified
- max-age / no-cache / no-store
- public / private / immutable
- Memory Cache / Disk Cache
- contenthash(Webpack)
- CDN 缓存
- Stale-While-Revalidate
容易踩的坑
- 混淆 no-cache 和 no-store:no-cache 会缓存但需验证,no-store 才是完全不缓存
- 忘记 index.html 也会被缓存:用户可能看到旧的 index.html,即使资源文件都更新了
- 过度依赖手动清除 CDN 缓存:应该使用带 hash 的文件名实现自动失效
- 静态资源域名包含 Cookie:Cookie 会阻止 CDN 缓存,应使用独立的无 Cookie 域名
- 开发环境忘记禁用缓存:导致改了代码但浏览器使用旧缓存
缓存策略决策树
资源类型?
├─ HTML 入口文件 → no-cache(或 no-store)
├─ 带 hash 的 JS/CSS/图片 → max-age=31536000, immutable
├─ 不带 hash 的静态资源 → max-age=3600(短期缓存)
├─ API 响应
│ ├─ 用户特定数据 → private, no-cache
│ ├─ 公共数据(不常变)→ public, max-age=60
│ └─ 实时数据 → no-store
└─ 字体文件 → public, max-age=31536000
小结
HTTP 缓存是 Web 性能优化的基石。理解强缓存和协商缓存的区别、Cache-Control 的各种指令、ETag 的工作原理,能帮助我们为不同类型的资源设置合适的缓存策略,在性能和新鲜度之间找到平衡。
这篇文章主要探讨了:
- 浏览器缓存的层级结构
- 强缓存(Cache-Control / Expires)
- 协商缓存(ETag / Last-Modified)
- 缓存决策流程
- SPA 应用的缓存最佳实践
- CDN 缓存处理
参考资料
- HTTP Caching - MDN - HTTP 缓存官方文档
- Prevent unnecessary network requests with the HTTP Cache - web.dev - Google 的 HTTP 缓存指南
- Cache-Control - MDN - Cache-Control 详细说明
- A Tale of Four Caches - Calendar.perfplanet.com - 浏览器四层缓存机制
- Caching best practices - web.dev - 缓存最佳实践
- Stale-While-Revalidate - RFC 5861 - SWR 策略规范