打开浏览器开发者工具的 Network 面板,刷新几次页面,你会看到资源的状态五花八门:
- 有的写着
200 (from disk cache),根本没发请求 - 有的是
304 Not Modified,发了请求但响应很小 - 有的老老实实重新下载了一遍
这三种情况,分别对应缓存的三种结局:命中强缓存、命中协商缓存、未命中。理解它们的区别,是做前端性能优化绕不开的一课。
缓存的两大类:强缓存与协商缓存
HTTP 缓存可以分成两层:
- 强缓存:浏览器直接用本地副本,完全不发请求
- 协商缓存:浏览器发请求问服务器"我这份还能用吗",服务器说"能用"就返回
304,不重传内容
浏览器的判断顺序是:先看强缓存,强缓存失效了再走协商缓存。
强缓存:连请求都不发
强缓存由两个响应头控制。
Expires(旧)
Expires 是 HTTP/1.0 的产物,值是一个绝对时间:
Expires: Wed, 18 Jun 2026 10:00:00 GMT含义是"在这个时间点之前,直接用本地缓存"。
它的问题很明显:依赖客户端本地时间。用户把电脑时间改了,缓存判断就乱了。所以现在基本被 Cache-Control 取代。
Cache-Control(现代标准)
Cache-Control 用相对时间,更可靠:
Cache-Control: max-age=31536000max-age=31536000 表示"从拿到响应开始,31536000 秒(一年)内直接用本地缓存"。
常见指令:
max-age=<秒>:缓存有效期no-cache:可以缓存,但每次都要走协商缓存问一下服务器(名字有迷惑性,它不是"不缓存")no-store:真正的不缓存,每次都重新下载public:允许中间代理(CDN 等)也缓存private:只允许浏览器缓存,代理不能缓存
当 Cache-Control 和 Expires 同时存在时,Cache-Control 优先。
命中强缓存时,你在 Network 里看到的就是 200 (from disk cache) 或 (from memory cache),请求根本没到服务器。
协商缓存:发请求,但可能不传内容
当强缓存过期(比如 max-age 到了),浏览器不会立刻重新下载,而是带上"校验信息"去问服务器:资源变了没有?
如果没变,服务器回 304 Not Modified,响应体是空的,浏览器继续用本地副本。这样省下了重传内容的流量。
协商缓存也有两套机制。
Last-Modified / If-Modified-Since(基于时间)
第一次响应时,服务器带上资源的最后修改时间:
Last-Modified: Wed, 11 Jun 2026 08:00:00 GMT下次请求时,浏览器自动带上:
If-Modified-Since: Wed, 11 Jun 2026 08:00:00 GMT服务器比较这个时间和文件实际的修改时间。没变就返回 304,变了就返回 200 + 新内容。
它的局限:
- 只能精确到秒,一秒内多次修改感知不到
- 文件内容没变、只是重新生成(修改时间变了),会误判为"变了"
ETag / If-None-Match(基于内容指纹)
ETag 是服务器给资源算的一个指纹(通常是内容的 hash):
ETag: "a1b2c3d4"下次请求,浏览器带上:
If-None-Match: "a1b2c3d4"服务器比较指纹。内容没变指纹就一样,返回 304;变了就返回新内容和新 ETag。
ETag 比 Last-Modified 更准,因为它看的是内容本身,不受修改时间影响。当两者同时存在时,ETag 优先。
一张图理清决策流程
把整个流程串起来:
浏览器要用某个资源
│
▼
有本地缓存吗? ──否──→ 发请求下载(200)
│是
▼
强缓存还在有效期吗?(Cache-Control / Expires)
│
是 ──┴── 否
│ │
▼ ▼
直接用本地 带 If-None-Match / If-Modified-Since 问服务器
(200 cache) │
┌────┴────┐
变了 没变
│ │
▼ ▼
200 新内容 304,用本地副本实战:一套常见的缓存策略
理论说完,真正落地时大多数项目会这样配。
带 hash 的静态资源:强缓存拉满
现代构建工具(Vite、Webpack 等)打包出来的 JS/CSS 文件名都带内容 hash,比如 app.4f3a9b.js。内容一变,文件名就变。
既然文件名能保证唯一,就可以放心地给这些文件设置超长强缓存:
Cache-Control: public, max-age=31536000, immutableimmutable 是个加分项:它告诉浏览器"这个文件永远不会变,连刷新时的协商请求都不用发"。
逻辑很简单:文件没变,文件名就不变,缓存一直命中;文件变了,文件名也变了,浏览器会当成一个新资源去下载。
HTML 入口:不强缓存,走协商
HTML 文件(尤其是 SPA 的 index.html)不能强缓存太久,否则用户拿不到指向新版静态资源的引用,等于没更新。
通常给 HTML 配:
Cache-Control: no-cacheno-cache 让浏览器每次都带 ETag 去问一下。内容没变就 304(很快),内容变了(发版了)就立刻拿到新 HTML,进而引用到新的带 hash 的静态资源。
这套组合的效果是:发版后,HTML 立刻更新,而没改动的静态资源继续命中长缓存,既保证了更新及时,又最大化了缓存收益。
接口数据:大多 no-store 或短缓存
动态接口数据通常用 Cache-Control: no-store,或者根据业务给很短的 max-age。涉及用户隐私的接口记得加 private,别让 CDN 缓存了。
真实代码里长什么样
以这个博客的图床静态服务为例,文件名带 hash,所以直接配长强缓存 + 协商兜底:
// 文件名带 hash,30 天强缓存
setHeader(event, 'cache-control', 'public, max-age=2592000, immutable')
setHeader(event, 'last-modified', stat.mtime.toUTCString())
// 命中 If-Modified-Since 返回 304
const ifModifiedSince = getHeader(event, 'if-modified-since')
if (ifModifiedSince) {
const since = Date.parse(ifModifiedSince)
if (!isNaN(since) && since >= stat.mtime.getTime() - 1000) {
setResponseStatus(event, 304)
return null
}
}思路就是上面讲的两层:强缓存优先,过期了用 Last-Modified 走协商,命中就回 304。
常见坑
第一,把 no-cache 当成"不缓存"。它其实是"缓存了但每次都要协商验证一下"。真正不缓存是 no-store。
第二,给 HTML 设了长强缓存。结果发版后用户死活刷不出新版本,只能手动清缓存。HTML 入口务必走协商或短缓存。
第三,静态资源文件名不带 hash 却设了长强缓存。改了代码,文件名没变,浏览器认为缓存有效,用户拿到的还是旧文件。要么文件名带 hash,要么别设长强缓存。
第四,忘了 private。把含用户信息的响应设成 public,可能被中间 CDN 缓存后串给其他用户。
第五,只依赖 Last-Modified。如果文件会被重新生成(修改时间变但内容没变),会触发不必要的重新下载。对准确性要求高的场景优先用 ETag。
总结
HTTP 缓存的全貌可以这样记:
- 强缓存(
Cache-Control: max-age)命中就不发请求,最省 - 强缓存过期走协商缓存,靠
ETag/Last-Modified校验,没变就304不传内容 ETag比Last-Modified准,Cache-Control比Expires可靠- 实战策略:带 hash 的静态资源长强缓存 + immutable,HTML 入口 no-cache 走协商
- 区分清楚
no-cache(要协商)和no-store(真不缓存)
把这套策略配对,既能让用户更新及时,又能把缓存的性能收益吃满。