前言
上周排查一个线上问题:用户反馈页面样式错乱,但刷新后就恢复正常。最后定位到原因是发版后,用户浏览器命中了旧版本 CSS 的强缓存,导致新 HTML 加载了旧样式。
这类问题在前端开发中非常常见,根源在于 HTTP 缓存配置不合理。很多开发者对缓存的理解停留在"加个 Cache-Control 就行",但实际上缓存策略的选择直接影响用户体验和服务器负载。
看一组真实数据:
- 一个中等规模的电商网站,合理配置缓存后,服务器带宽消耗降低了 60%
- 首屏加载时间从 3.2s 降到 1.1s(二次访问)
- CDN 回源率从 45% 降到 8%
这篇文章会用一张完整的流程图,把强缓存和协商缓存的决策过程讲清楚,并给出不同资源类型的最佳实践配置。
HTTP 缓存的本质
HTTP 缓存的核心思想很简单:避免重复传输相同的资源。
浏览器第一次请求资源时,服务器在响应头中携带缓存策略;后续再次请求同一资源时,浏览器根据这些策略决定是直接使用本地副本,还是向服务器验证资源是否更新。
按照决策方式的不同,HTTP 缓存分为两大类:
| 类型 | 核心机制 | 是否发送请求 | 状态码 | 适用场景 |
|---|---|---|---|---|
| 强缓存 | Cache-Control / Expires | 否,直接读本地 | 200 (from cache) | 不常变化的静态资源 |
| 协商缓存 | ETag / Last-Modified | 是,但可能不传输 body | 304 Not Modified | 需要保证最新的资源 |
Cache-Control 详解
Cache-Control 是 HTTP/1.1 引入的缓存控制头,功能强大且灵活。下面逐一讲解常用指令。
max-age
设置资源的最大缓存时间,单位为秒。在这段时间内,浏览器直接使用本地缓存,不会向服务器发送任何请求。
ini
Cache-Control: max-age=31536000
上面的配置表示资源缓存一年。适用于文件名带 hash 的静态资源(如 app.a1b2c3.js),因为内容变化时文件名也会变,不存在缓存不更新的问题。
no-cache
注意:no-cache 不是"不缓存"的意思。
no-cache 表示浏览器可以缓存资源,但每次使用前必须向服务器验证(走协商缓存流程)。这是很多人容易搞混的地方。
yaml
Cache-Control: no-cache
适用于 HTML 文件。浏览器会缓存 HTML,但每次都会发请求验证是否有更新,如果没更新就返回 304,有更新就返回新内容。
no-store
这才是真正的"不缓存"。浏览器不会存储任何响应副本,每次都从服务器获取完整资源。
yaml
Cache-Control: no-store
适用于包含敏感信息的页面,比如银行账户页面、含有个人隐私的 API 响应。
immutable
告诉浏览器资源永远不会改变,即使用户手动刷新页面也不需要重新验证。
ini
Cache-Control: max-age=31536000, immutable
这解决了一个实际问题:用户按 F5 刷新时,即使 max-age 未过期,浏览器默认也会发送条件请求进行验证。加上 immutable 后,刷新也不会发请求。适用于文件名带内容 hash 的资源。
stale-while-revalidate
允许浏览器在缓存过期后,先使用过期的缓存响应用户,同时在后台异步重新验证。
arduino
Cache-Control: max-age=60, stale-while-revalidate=30
含义:资源在 60 秒内直接使用缓存;60-90 秒之间,先返回旧缓存给用户(用户无感知),同时后台请求新资源更新缓存;超过 90 秒后,等待服务器返回新资源。
这在对实时性要求不那么高、但对性能敏感的场景非常有用,比如新闻列表、商品推荐等。
指令组合速查表
| 指令组合 | 含义 | 典型场景 |
|---|---|---|
max-age=31536000, immutable |
长期缓存,刷新也不验证 | 带 hash 的 JS/CSS/图片 |
no-cache |
每次验证,可用 304 | HTML 入口文件 |
no-store |
完全不缓存 | 敏感数据、实时 API |
max-age=0, must-revalidate |
等价于 no-cache | HTML 入口文件(兼容写法) |
max-age=60, stale-while-revalidate=30 |
短期缓存 + 异步更新 | 接口数据、非关键资源 |
private, max-age=600 |
仅浏览器缓存,不走 CDN | 用户个性化内容 |
public, max-age=86400 |
允许 CDN 缓存 | 公共静态资源 |
强缓存:Cache-Control 与 Expires
强缓存意味着浏览器直接使用本地缓存,完全不与服务器通信。判断依据有两个响应头:
Cache-Control: max-age(优先级高)
HTTP/1.1 规范,使用相对时间。
ini
Cache-Control: max-age=86400
表示从响应生成时刻起,86400 秒(1 天)内缓存有效。
Expires(优先级低)
HTTP/1.0 规范,使用绝对时间。
yaml
Expires: Wed, 14 May 2026 08:00:00 GMT
缺点明显:依赖客户端本地时间,如果用户手动修改了系统时间,缓存判断就会出错。
两者同时存在时,Cache-Control 优先。 现代项目中,Expires 基本只作为降级兼容使用。
强缓存命中时的表现
浏览器直接从本地读取资源,DevTools 中显示:
- Status:
200 - Size:
(from disk cache)或(from memory cache)
其中 memory cache 是内存缓存(关闭标签页后消失),disk cache 是硬盘缓存(持久化)。一般规律是:当前页面已加载过的资源走 memory cache,其余走 disk cache。
协商缓存:ETag 与 Last-Modified
当强缓存失效(过期或被设为 no-cache),浏览器会向服务器发起条件请求,询问资源是否更新。这就是协商缓存。
Last-Modified / If-Modified-Since
基于文件最后修改时间的验证方式。
首次请求,服务器返回:
yaml
Last-Modified: Mon, 12 May 2026 10:00:00 GMT
再次请求,浏览器携带:
yaml
If-Modified-Since: Mon, 12 May 2026 10:00:00 GMT
服务器对比文件修改时间:
- 没变化 -> 返回
304 Not Modified(不传输 body,节省带宽) - 有变化 -> 返回
200并携带新资源
局限性:
- 精度只到秒级,1 秒内多次修改无法识别
- 文件被 touch 但内容没变,也会被判定为"已修改"
- 负载均衡下,不同服务器的修改时间可能不一致
ETag / If-None-Match(优先级高)
基于文件内容生成的唯一标识(通常是内容的 hash 值)。
首次请求,服务器返回:
vbnet
ETag: "a1b2c3d4e5f6"
再次请求,浏览器携带:
sql
If-None-Match: "a1b2c3d4e5f6"
服务器重新计算资源的 ETag 并对比:
- 一致 -> 返回
304 - 不一致 -> 返回
200并携带新资源
ETag 的两种形式:
| 类型 | 格式 | 示例 | 说明 |
|---|---|---|---|
| 强 ETag | "hash" |
"a1b2c3" |
字节级别完全一致 |
| 弱 ETag | W/"hash" |
W/"a1b2c3" |
语义等价即可 |
Nginx 默认生成弱 ETag(基于修改时间和文件大小),格式类似 W/"664c8f00-1a2b"。
两者同时存在时,ETag 优先于 Last-Modified。
完整缓存决策流程图
下面这张流程图展示了浏览器请求一个资源时的完整缓存决策过程:

关键决策路径总结:
no-store-> 完全不缓存,每次都请求max-age未过期 -> 强缓存,直接使用本地副本max-age已过期 + 有验证头 -> 协商缓存,发条件请求no-cache-> 跳过强缓存判断,直接走协商缓存
不同资源类型的最佳实践
HTML 入口文件
HTML 是整个应用的入口,它引用了 JS、CSS 等资源。如果 HTML 被强缓存,发版后用户可能继续使用旧 HTML,导致加载旧版本的 JS/CSS。
yaml
Cache-Control: no-cache
策略要点:
- 使用
no-cache,每次都验证是否有更新 - 配合 ETag 实现协商缓存,内容没变就返回 304
- 确保 HTML 中引用的静态资源带有内容 hash
JS / CSS(带内容 hash)
现代构建工具(Webpack、Vite)会在文件名中加入内容 hash,如 main.a1b2c3.js。内容变化时文件名也变,所以可以大胆缓存。
ini
Cache-Control: max-age=31536000, immutable
策略要点:
- 缓存一年(实际上是"永久"缓存)
- 加
immutable避免用户刷新时的条件请求 - 确保构建工具配置了 contenthash
图片和字体
与 JS/CSS 类似,如果文件名带 hash 或版本号,使用长期缓存:
ini
Cache-Control: max-age=31536000, immutable
如果文件名不带 hash(如 logo.png),使用较短的缓存时间配合协商缓存:
ini
Cache-Control: max-age=86400
API 响应
根据接口数据的实时性要求选择策略:
| 接口类型 | 推荐策略 | 说明 |
|---|---|---|
| 用户个人信息 | private, no-cache |
每次验证,不走 CDN |
| 商品列表 | max-age=60, stale-while-revalidate=30 |
允许短暂不一致 |
| 搜索结果 | private, max-age=300 |
缓存 5 分钟 |
| 支付/订单 | no-store |
绝不缓存 |
| 配置信息 | max-age=3600 |
变化不频繁 |
资源缓存策略总览
lua
+------------------+
| HTML 入口 |
| no-cache |
| 每次验证 |
+--------+---------+
|
+--------------+--------------+
| | |
+-----v-----+ +----v------+ +----v------+
| JS/CSS | | 图片/字体 | | API 数据 |
| (带hash) | | | | |
| max-age= | | 视情况而定 | | 视接口而定 |
| 31536000 | | | | |
| immutable | | | | |
+-----------+ +-----------+ +-----------+
Nginx 配置示例
下面是一份实际可用的 Nginx 缓存配置:
nginx
server {
listen 80;
server_name example.com;
root /var/www/html;
# HTML 文件:协商缓存,每次验证
location ~* \.html$ {
add_header Cache-Control "no-cache";
# Nginx 默认开启 ETag,无需额外配置
}
# 带 hash 的静态资源:长期强缓存
# 匹配类似 main.a1b2c3.js / style.d4e5f6.css 的文件名
location ~* \.[a-f0-9]{6,}\.(js|css|woff2?|ttf|eot)$ {
add_header Cache-Control "max-age=31536000, immutable";
# 关闭 ETag 和 Last-Modified,强缓存不需要
etag off;
add_header Last-Modified "";
}
# 普通静态资源(图片等):中等缓存时间
location ~* \.(png|jpe?g|gif|svg|ico|webp)$ {
add_header Cache-Control "max-age=86400";
}
# API 代理
location /api/ {
proxy_pass http://backend;
# 不在 Nginx 层设置缓存,由后端应用控制
# 确保不覆盖后端的 Cache-Control 头
proxy_pass_header Cache-Control;
}
# Service Worker 文件:绝对不能缓存
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
Nginx 配置注意事项:
add_header在有if或proxy_pass的 location 中可能失效,需要注意继承规则etag on;是 Nginx 的默认行为,无需显式配置- 使用
location的正则匹配时,注意优先级:精确匹配 > 前缀匹配 > 正则匹配
Express 配置示例
Node.js 项目中使用 Express 的配置方式:
javascript
const express = require('express');
const path = require('path');
const app = express();
// HTML 文件:协商缓存
app.get('*.html', (req, res, next) => {
res.set('Cache-Control', 'no-cache');
next();
});
// 带 hash 的静态资源:长期强缓存
// 假设构建产物在 dist/assets 目录下,文件名包含 hash
app.use('/assets', express.static(path.join(__dirname, 'dist/assets'), {
maxAge: '1y',
immutable: true,
etag: false,
lastModified: false,
}));
// 普通静态资源
app.use('/images', express.static(path.join(__dirname, 'public/images'), {
maxAge: '1d',
etag: true,
}));
// API 路由示例
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'max-age=60, stale-while-revalidate=30');
res.json({ products: [] });
});
app.get('/api/user/profile', (req, res) => {
res.set('Cache-Control', 'private, no-cache');
res.json({ name: '...' });
});
app.get('/api/payment/*', (req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// 兜底:其他静态资源
app.use(express.static(path.join(__dirname, 'dist'), {
maxAge: '1h',
etag: true,
}));
app.listen(3000);
Vite 项目的配置参考
如果使用 Vite,开发服务器默认不做缓存配置。生产环境下,Vite 会自动在构建产物文件名中加入 hash,部署时只需在 Web 服务器层面配置缓存策略即可。
确保 vite.config.js 中的构建配置开启了文件名 hash:
javascript
// vite.config.js
export default {
build: {
// 默认就会加 hash,确认没有被关闭
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'assets/[name].[hash].js',
// 代码分割的 chunk
chunkFileNames: 'assets/[name].[hash].js',
// 静态资源(图片、字体等)
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
};
常见错误与陷阱
1. 混淆 no-cache 和 no-store
这是最常见的认知错误。
yaml
# 错误理解:以为 no-cache 是不缓存
Cache-Control: no-cache -> 实际含义:缓存但每次验证
# 真正不缓存的写法
Cache-Control: no-store -> 完全不缓存
2. HTML 使用强缓存
yaml
# 危险操作
# HTML 设置了长期强缓存,发版后用户无法获取新版本
Cache-Control: max-age=86400
# 正确做法
Cache-Control: no-cache
HTML 是资源引用的入口。如果 HTML 被强缓存,即使 JS/CSS 文件名已经更新(hash 变了),用户拿到的还是旧 HTML,引用的还是旧文件名。
3. 文件名没有 hash 却设置长期缓存
bash
# 危险:bundle.js 没有 hash,设置了一年缓存
# 更新内容后,用户还是加载旧版本
location /js/bundle.js {
add_header Cache-Control "max-age=31536000";
}
# 正确:确保文件名包含内容 hash
# bundle.a1b2c3.js -> 内容变化 -> 文件名变化 -> 浏览器请求新文件
4. CDN 缓存了带 Set-Cookie 的响应
ini
# 危险:public 允许 CDN 缓存,但响应包含 Set-Cookie
# 可能导致用户 A 拿到用户 B 的 Cookie
Cache-Control: public, max-age=3600
# 正确:用户相关的响应必须标记为 private
Cache-Control: private, max-age=3600
5. Service Worker 被缓存
yaml
# 危险:sw.js 被缓存后,即使服务器更新了也不生效
# 这会导致整个 PWA 更新机制失效
# 正确:Service Worker 文件必须设置不缓存
Cache-Control: no-cache, no-store, must-revalidate
6. 忽略 Vary 头
当同一个 URL 可能返回不同内容时(比如根据 Accept-Encoding 返回不同压缩格式),需要配置 Vary 头:
makefile
# 告诉缓存:不同的 Accept-Encoding 要分别缓存
Vary: Accept-Encoding
# 常见于需要内容协商的 API
Vary: Accept, Authorization
不设置 Vary 可能导致 CDN 把 gzip 压缩的内容返回给不支持 gzip 的客户端。
如何在 DevTools 中验证缓存
Chrome DevTools 验证步骤
-
打开 Network 面板(F12 -> Network)
-
观察 Size 列
(from memory cache)-- 命中内存中的强缓存(from disk cache)-- 命中磁盘上的强缓存(from ServiceWorker)-- 由 Service Worker 返回- 具体数字(如
45.2 kB) -- 从服务器获取
-
观察 Status 列
200+ 有具体 Size -- 服务器返回了完整资源200+ from cache -- 强缓存命中304-- 协商缓存命中,使用本地副本
-
查看响应头
点击具体请求,在 Headers 标签下查看:
yamlResponse Headers: Cache-Control: max-age=31536000, immutable ETag: "a1b2c3" Last-Modified: Mon, 12 May 2026 10:00:00 GMT Request Headers: If-None-Match: "a1b2c3" If-Modified-Since: Mon, 12 May 2026 10:00:00 GMT
不同刷新方式的缓存行为
| 操作 | 强缓存 | 协商缓存 | 说明 |
|---|---|---|---|
| 地址栏回车 / 点击链接 | 生效 | 生效 | 正常使用缓存 |
| F5 / Ctrl+R | 跳过(除非 immutable) | 生效 | 发送条件请求验证 |
| Ctrl+Shift+R / 硬刷新 | 跳过 | 跳过 | 完全从服务器获取 |
| 勾选 Disable cache | 跳过 | 跳过 | 开发调试用 |
使用 curl 验证
bash
# 查看完整响应头
curl -I https://example.com/assets/main.a1b2c3.js
# 模拟条件请求
curl -I -H 'If-None-Match: "a1b2c3"' https://example.com/index.html
# 期望看到 304 响应
# HTTP/1.1 304 Not Modified
使用 Lighthouse 审计
Lighthouse 的性能审计中有一项 "Serve static assets with an efficient cache policy",它会列出所有缓存策略不合理的静态资源,并给出优化建议。
实战:一个完整的缓存方案
综合以上内容,一个典型的前端项目缓存方案如下:
ini
项目结构 缓存策略
-----------------------------------------------------------
dist/
index.html no-cache (每次验证)
assets/
main.a1b2c3.js max-age=31536000, immutable
style.d4e5f6.css max-age=31536000, immutable
vendor.g7h8i9.js max-age=31536000, immutable
logo.j0k1l2.png max-age=31536000, immutable
favicon.ico max-age=86400
manifest.json no-cache
sw.js no-store
用一句话总结这个方案的核心思路:HTML 走协商缓存保证及时更新,静态资源用内容 hash + 长期强缓存消除重复传输。
总结
HTTP 缓存不是一个"配上就行"的功能,而是需要针对不同资源类型精心设计的策略。核心要点回顾:
- 强缓存 通过
Cache-Control: max-age让浏览器直接使用本地副本,零网络开销 - 协商缓存 通过
ETag/Last-Modified验证资源是否更新,未更新时只返回 304 头部,节省传输 - HTML 用 no-cache,带 hash 的静态资源用 max-age + immutable,这是最基本也是最重要的策略
no-cache不是不缓存,no-store才是- 善用
stale-while-revalidate提升体验 - 敏感数据用
private或no-store,防止 CDN 泄露 - 部署后用 DevTools 和 curl 验证缓存是否按预期生效
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。