返回首页

HTTP 缓存机制:强缓存、协商缓存与一套实战配置

同样是刷新页面,为什么有的资源直接命中本地、有的要回服务器问一句、有的又重新下载?这篇把 Cache-Control、ETag、Last-Modified 串成一条决策链路,并给出一套静态资源 + HTML 的实战缓存策略。

20 约 5 分钟 · 4439 字 前端

打开浏览器开发者工具的 Network 面板,刷新几次页面,你会看到资源的状态五花八门:

  • 有的写着 200 (from disk cache),根本没发请求
  • 有的是 304 Not Modified,发了请求但响应很小
  • 有的老老实实重新下载了一遍

这三种情况,分别对应缓存的三种结局:命中强缓存、命中协商缓存、未命中。理解它们的区别,是做前端性能优化绕不开的一课。

缓存的两大类:强缓存与协商缓存

HTTP 缓存可以分成两层:

  1. 强缓存:浏览器直接用本地副本,完全不发请求
  2. 协商缓存:浏览器发请求问服务器"我这份还能用吗",服务器说"能用"就返回 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=31536000

max-age=31536000 表示"从拿到响应开始,31536000 秒(一年)内直接用本地缓存"。

常见指令:

  • max-age=<秒>:缓存有效期
  • no-cache:可以缓存,但每次都要走协商缓存问一下服务器(名字有迷惑性,它不是"不缓存")
  • no-store:真正的不缓存,每次都重新下载
  • public:允许中间代理(CDN 等)也缓存
  • private:只允许浏览器缓存,代理不能缓存

Cache-ControlExpires 同时存在时,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

ETagLast-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, immutable

immutable 是个加分项:它告诉浏览器"这个文件永远不会变,连刷新时的协商请求都不用发"。

逻辑很简单:文件没变,文件名就不变,缓存一直命中;文件变了,文件名也变了,浏览器会当成一个新资源去下载。

HTML 入口:不强缓存,走协商

HTML 文件(尤其是 SPA 的 index.html)不能强缓存太久,否则用户拿不到指向新版静态资源的引用,等于没更新。

通常给 HTML 配:

Cache-Control: no-cache

no-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 缓存的全貌可以这样记:

  1. 强缓存(Cache-Control: max-age)命中就不发请求,最省
  2. 强缓存过期走协商缓存,靠 ETag / Last-Modified 校验,没变就 304 不传内容
  3. ETagLast-Modified 准,Cache-ControlExpires 可靠
  4. 实战策略:带 hash 的静态资源长强缓存 + immutable,HTML 入口 no-cache 走协商
  5. 区分清楚 no-cache(要协商)和 no-store(真不缓存)

把这套策略配对,既能让用户更新及时,又能把缓存的性能收益吃满。