一张图搞懂 HTTP 缓存:强缓存、协商缓存与最佳 Cache-Control 配置

前言

上周排查一个线上问题:用户反馈页面样式错乱,但刷新后就恢复正常。最后定位到原因是发版后,用户浏览器命中了旧版本 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。

完整缓存决策流程图

下面这张流程图展示了浏览器请求一个资源时的完整缓存决策过程:

关键决策路径总结:

  1. no-store -> 完全不缓存,每次都请求
  2. max-age 未过期 -> 强缓存,直接使用本地副本
  3. max-age 已过期 + 有验证头 -> 协商缓存,发条件请求
  4. 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 配置注意事项:

  1. add_header 在有 ifproxy_pass 的 location 中可能失效,需要注意继承规则
  2. etag on; 是 Nginx 的默认行为,无需显式配置
  3. 使用 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 -> 内容变化 -> 文件名变化 -> 浏览器请求新文件
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 验证步骤

  1. 打开 Network 面板(F12 -> Network)

  2. 观察 Size 列

    • (from memory cache) -- 命中内存中的强缓存
    • (from disk cache) -- 命中磁盘上的强缓存
    • (from ServiceWorker) -- 由 Service Worker 返回
    • 具体数字(如 45.2 kB) -- 从服务器获取
  3. 观察 Status 列

    • 200 + 有具体 Size -- 服务器返回了完整资源
    • 200 + from cache -- 强缓存命中
    • 304 -- 协商缓存命中,使用本地副本
  4. 查看响应头

    点击具体请求,在 Headers 标签下查看:

    yaml 复制代码
    Response 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 缓存不是一个"配上就行"的功能,而是需要针对不同资源类型精心设计的策略。核心要点回顾:

  1. 强缓存 通过 Cache-Control: max-age 让浏览器直接使用本地副本,零网络开销
  2. 协商缓存 通过 ETag / Last-Modified 验证资源是否更新,未更新时只返回 304 头部,节省传输
  3. HTML 用 no-cache,带 hash 的静态资源用 max-age + immutable,这是最基本也是最重要的策略
  4. no-cache 不是不缓存,no-store 才是
  5. 善用 stale-while-revalidate 提升体验
  6. 敏感数据用 privateno-store,防止 CDN 泄露
  7. 部署后用 DevTools 和 curl 验证缓存是否按预期生效

如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。

相关推荐
程序员码歌1 小时前
别再让 AI 自由发挥了:OpenSpec 才是团队协作不跑偏的关键
android·前端·人工智能
用户11481867894841 小时前
Vue 开发者快速上手 Flutter(二)
前端
用户11481867894841 小时前
Vue 开发者快速上手 Flutter(三)
前端
JavaAgent架构师1 小时前
前端AI工程化(六):Function Calling与RAG前端实践
前端·人工智能
用户11481867894841 小时前
Vue 开发者快速上手 Flutter(一)
前端
鹏多多2 小时前
Trae cn里使用Pencil来制作设计图的手把手教程
前端·ai编程·trae
客场消音器2 小时前
如何使用codex进行UI重构,让AI开发的前端页面不再千篇一律
前端·后端·微信小程序
大家的林语冰2 小时前
Canvas 文艺复兴,HTML-in-Canvas 炫酷特效摆拍走红,Canvas 中也能渲染交互式的 HTML 元素了
前端·javascript·html