HTTP 缓存策略:新鲜度与速度的权衡艺术

在优化 Web 应用性能时,我发现一个有趣的矛盾:用户希望看到最新的内容,但同时又期望页面加载飞快。这个矛盾的解决方案,就藏在 HTTP 缓存机制中。

那么,HTTP 缓存到底是如何工作的?强缓存和协商缓存有什么区别?如何为不同类型的资源设置合适的缓存策略?

问题的起源

为什么需要缓存?最直接的原因是性能。网络请求的延迟远高于本地读取,尤其在移动网络环境下。如果每次访问都要重新下载所有资源,用户体验会很差。

但缓存又带来了新的问题:新鲜度。如果资源被缓存了,用户如何获取更新后的版本?

HTTP 缓存机制就是在这两个目标之间寻找平衡:既要快,又要新。

核心概念探索

1. 浏览器缓存的层级结构

在深入 HTTP 缓存之前,先了解浏览器的完整缓存体系:

浏览器请求资源的缓存查找顺序:

  1. Memory Cache(内存缓存)

    • 特点:最快,但容量小,tab 关闭即清空
    • 存储:当前页面的资源(图片、脚本、样式)
  2. Service Worker Cache

    • 特点:可编程,离线可用
    • 存储:开发者主动缓存的资源
  3. Disk Cache(磁盘缓存)

    • 特点:容量大,持久化
    • 存储:根据 HTTP 缓存头决定
  4. Push Cache(HTTP/2 推送缓存)

    • 特点:短暂存在,只在会话期间
    • 存储:服务器推送的资源
  5. 网络请求

    • 最后的选择:如果以上都没有,发起网络请求

今天我们主要关注的是 Disk Cache 层面的 HTTP 缓存。

2. 强缓存(Strong Cache)

强缓存是指浏览器直接从本地缓存读取资源,不发送任何网络请求到服务器。

Expires(HTTP/1.0)

javascript 复制代码
HTTP/1.0 200 OK
Content-Type: text/css
Expires: Wed, 21 Oct 2026 07:28:00 GMT

/* CSS 内容 */

Expires 的问题:

  1. 使用的是绝对时间:如果服务器和客户端时间不同步,缓存会失效

  2. 优先级低于 Cache-Control:如果两者同时存在,Expires 会被忽略

Cache-Control(HTTP/1.1,推荐)

javascript 复制代码
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: max-age=31536000

/* JavaScript 内容 */

Cache-Control 常用指令

HTTP 响应头 + Cache-Control 指令详解

  1. max-age=<seconds>

    • 指定资源缓存的最大时长(相对时间,单位:秒)
    • Cache-Control: max-age=3600 // 缓存 1 小时
  2. no-cache

    • 不是"不缓存"!而是"需要验证"
    • 浏览器会缓存资源,但每次使用前必须向服务器验证是否过期
    • Cache-Control: no-cache
  3. no-store

    • 真正的"不缓存":浏览器不缓存,每次都重新请求
    • Cache-Control: no-store
  4. public

    • 允许中间代理(CDN)缓存
    • Cache-Control: public, max-age=86400
  5. private

    • 只允许浏览器缓存,中间代理不能缓存(如包含用户隐私信息的响应)
    • Cache-Control: private, max-age=3600
  6. immutable

    • 表示资源永远不会改变,即使用户刷新页面也不重新验证
    • Cache-Control: max-age=31536000, immutable
  7. must-revalidate

    • 缓存过期后必须向服务器验证,不能使用过期缓存
    • Cache-Control: max-age=3600, must-revalidate

常见组合

javascript 复制代码
# 场景 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

javascript 复制代码
# 首次请求响应:
HTTP/1.1 200 OK
Last-Modified: Mon, 10 Jan 2026 10:00:00 GMT
Cache-Control: no-cache

/* 资源内容 */
javascript 复制代码
# 再次请求时,浏览器携带:
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 值)。

javascript 复制代码
# 首次请求响应:
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache

/* 资源内容 */
javascript 复制代码
# 再次请求时,浏览器携带:
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. 缓存决策流程

浏览器请求资源时的完整决策过程:

javascript 复制代码
// 环境:浏览器内部逻辑
// 场景:缓存决策流程(伪代码)

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)通常有这样的文件结构:

bash 复制代码
dist/
├── index.html           # 入口文件
├── main.[hash].js       # 应用主逻辑
├── vendor.[hash].js     # 第三方库
├── style.[hash].css     # 样式文件
└── assets/
    └── logo.[hash].png  # 静态资源

推荐的缓存策略

javascript 复制代码
// 环境: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 文件名

javascript 复制代码
// 环境: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:强制用户更新资源

即使设置了正确的缓存策略,有时仍需要强制用户更新:

javascript 复制代码
// 问题场景:
// 用户已经访问过旧版本,浏览器缓存了 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 生产环境的缓存差异

javascript 复制代码
// 环境:Webpack DevServer
// 场景:开发环境禁用缓存

// 开发环境配置
module.exports = {
  devServer: {
    headers: {
      // 禁用缓存,确保每次都获取最新代码
      'Cache-Control': 'no-store',
    },
  },
};

// 为什么开发环境要禁用缓存?
// 1. 代码频繁修改,需要实时看到效果
// 2. 避免改了代码但浏览器使用旧缓存的困惑
// 3. 开发环境不关心性能,关心开发体验

// 生产环境配置(Nginx)
// 需要精细的缓存策略,平衡性能和新鲜度

场景 4:CDN 缓存失效

CDN 有自己的缓存层,如何处理?

javascript 复制代码
// 问题:部署了新版本,但 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 会影响缓存行为:

javascript 复制代码
// 问题:包含 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.htmlno-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:

  1. 浏览器发现强缓存过期(或设置了 no-cache)
  2. 发起请求,携带 If-None-Match(ETag)或 If-Modified-Since(时间戳)
  3. 服务器比对 ETag 或修改时间
  4. 如果资源未改变,返回 304 Not Modified(无响应体)
  5. 浏览器使用本地缓存

304 响应虽然也有网络请求,但没有响应体,节省了带宽。

Q: 如何强制用户更新缓存的资源?

A: 常见方法:

  1. 文件名加 hash(最佳):app.[contenthash].js
  2. URL 加版本号:style.css?v=1.2.3
  3. 设置 no-cache:每次验证
  4. CDN Purge:手动清除 CDN 缓存
  5. 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

容易踩的坑

  1. 混淆 no-cache 和 no-store:no-cache 会缓存但需验证,no-store 才是完全不缓存
  2. 忘记 index.html 也会被缓存:用户可能看到旧的 index.html,即使资源文件都更新了
  3. 过度依赖手动清除 CDN 缓存:应该使用带 hash 的文件名实现自动失效
  4. 静态资源域名包含 Cookie:Cookie 会阻止 CDN 缓存,应使用独立的无 Cookie 域名
  5. 开发环境忘记禁用缓存:导致改了代码但浏览器使用旧缓存

缓存策略决策树

ini 复制代码
资源类型?
├─ 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 缓存处理

参考资料

相关推荐
哈撒Ki2 小时前
快速入门 Dart 语言
前端·flutter·dart
Ruihong2 小时前
你的 Vue TransitionGroup 组件,VuReact 会编译成什么样的 React 代码?
vue.js·react.js·面试
ZC跨境爬虫2 小时前
3D 地球卫星轨道可视化平台开发 Day5(简介接口对接+规划AI自动化卫星数据生成工作流)
前端·人工智能·3d·ai·自动化
毛骗导演2 小时前
Claude Code Agent 实现原理深度剖析
前端·架构
星晨雪海2 小时前
若依框架原有页面功能进行了点位管理模块完整改造(3)
开发语言·前端·javascript
morethanilove2 小时前
新建vue3 + ts +vite 项目
前端·javascript·vue.js
GISer_Jing2 小时前
微软AI战略全景:从基础设施到智能体生态
前端·人工智能·microsoft
发际线向北2 小时前
0x03 单元测试与Junit
前端·单元测试
忆往wu前2 小时前
搞懂 SPA 再学路由!Vue Router 从0到完善 + 嵌套路由一次性梳理
前端·vue.js