Google 的 Core Web Vitals 数据显示,首屏加载时间每增加 100ms,用户转化率下降 7%。而在所有前端性能优化手段中,HTTP 缓存的 ROI 最高——它不改代码、不换架构,只需正确配置 HTTP 响应头,就能让重复访问的页面加载速度提升 5-10 倍。然而根据 HTTP Archive 的统计,超过 60% 的网站没有正确配置缓存策略,白白浪费了带宽和用户体验。本文将从实际项目出发,系统性地讲解 HTTP 缓存的四大层级,提供可直接落地的代码方案。
📌 **记住:**缓存优化的核心原则是「不变的资源尽量缓存久,常变的资源用内容哈希区分版本」。理解这个原则,所有缓存策略都是它的变体。
🔐 一、HTTP 缓存的四大层级与执行流程
浏览器缓存的完整决策流程
很多开发者只知道「强缓存」和「协商缓存」两个概念,但实际的缓存决策远比这复杂。浏览器在发起请求时,会按照以下优先级依次检查:
- Service Worker Cache — 最高优先级,由 JS 控制
- Memory Cache — 内存缓存,读取最快,tab 关闭即失效
- Disk Cache — 磁盘缓存,持久化存储,遵守 HTTP 缓存头
- Push Cache — HTTP/2 Server Push 的缓存,仅当前会话有效
请求 → Service Worker? → Memory Cache? → Disk Cache? → 网络请求
├─ 命中 → 直接返回 ├─ 命中 → 直接返回
└─ 未命中 → 下一层 └─ 未命中 → 下一层
⚠️ **警告:**Memory Cache 是开发者最容易忽略的一层。它不遵守
Cache-Control,而是由浏览器根据资源类型、当前内存压力等因素自动决定。这意味着你不能完全控制它,但可以间接影响——比如通过preload标签将关键资源预加载到内存。
四层缓存的对比
| 层级 | 存储位置 | 容量限制 | 生命周期 | 控制方式 | 适用场景 |
|---|---|---|---|---|---|
| Memory Cache | RAM | 较小(取决于内存) | tab 关闭即失效 | preload 间接影响 |
关键 CSS/JS、图片 |
| Disk Cache | 硬盘 | 较大(浏览器可配) | 遵守 Cache-Control |
✅ HTTP 响应头控制 | 静态资源、字体文件 |
| Service Worker | Cache API(硬盘) | 无硬性限制 | 手动管理 | ✅ 完全由代码控制 | 离线资源、API 响应 |
| CDN Cache | CDN 边缘节点 | 取决于 CDN 计划 | 遵守 s-maxage |
✅ HTTP 响应头控制 | 全球分发的静态资源 |
🚀 二、强缓存与协商缓存的正确配置
强缓存:让浏览器跳过网络请求
强缓存(Strong Cache)是最简单的缓存方式——浏览器直接使用本地副本,完全不发起网络请求。控制强缓存的有两个响应头:
**Cache-Control(推荐,HTTP/1.1 标准)**和 Expires(HTTP/1.0 遗留,不推荐)。
// ❌ 错误写法:所有资源用同一个缓存策略
Cache-Control: max-age=86400 // 1 天
// ✅ 正确写法:根据资源类型区分策略
// HTML 入口文件:不缓存,确保用户总是获取最新版本
Cache-Control: no-cache
// 带内容哈希的静态资源:长期缓存
Cache-Control: public, max-age=31536000, immutable
// API 响应:根据业务需求设置
Cache-Control: private, max-age=300, stale-while-revalidate=60
💡 提示:
no-cache的命名有误导性——它不是「不缓存」,而是「每次都向服务器验证后才使用缓存」。真正不缓存的指令是no-store。
下面是 Nginx 中针对不同资源类型的完整配置:
# Nginx 缓存策略配置示例
server {
listen 443 ssl;
server_name jsjson.com;
# HTML 入口文件:不缓存,确保每次获取最新
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
add_header X-Cache-Status "HTML-NO-CACHE";
}
# 带哈希的静态资源(JS/CSS/图片):长期缓存 1 年
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Cache-Status "STATIC-LONG-CACHE";
expires 1y;
}
# 不带哈希的图片资源:缓存 7 天
location /images/ {
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400";
expires 7d;
}
# API 接口:私有缓存 5 分钟 + 过期后可用 1 分钟
location /api/ {
add_header Cache-Control "private, max-age=300, stale-while-revalidate=60";
}
}
协商缓存:当强缓存失效后
协商缓存(Negotiation Cache)发生在强缓存过期后——浏览器带上缓存标识向服务器验证,服务器决定是返回 304(使用缓存)还是 200(返回新内容)。
有两种协商缓存机制:
1. Last-Modified / If-Modified-Since(基于时间)
// 第一次请求,服务器返回
Last-Modified: Wed, 28 May 2026 10:00:00 GMT
// 第二次请求,浏览器带上
If-Modified-Since: Wed, 28 May 2026 10:00:00 GMT
// 服务器比较时间,未修改则返回 304
2. ETag / If-None-Match(基于内容哈希)
// 第一次请求,服务器返回
ETag: "a1b2c3d4e5"
// 第二次请求,浏览器带上
If-None-Match: "a1b2c3d4e5"
// 服务器比较哈希,未修改则返回 304
⚡ **关键结论:**优先使用
ETag,因为Last-Modified有两个致命缺陷:精度只到秒级(1 秒内的多次修改检测不到),以及分布式环境下不同节点的时间戳可能不一致。
下面是一个 Node.js/Express 的协商缓存完整实现:
// Node.js 协商缓存中间件实现
import crypto from 'crypto';
import { readFileSync, statSync } from 'fs';
function etagMiddleware(filePath) {
return (req, res, next) => {
const content = readFileSync(filePath, 'utf-8');
const stats = statSync(filePath);
// 生成 ETag(基于内容的 MD5 哈希)
const etag = `"${crypto.createHash('md5').update(content).digest('hex')}"`;
const lastModified = stats.mtime.toUTCString();
// 设置验证头
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', lastModified);
// 检查 If-None-Match(ETag 验证)
const clientETag = req.headers['if-none-match'];
if (clientETag === etag) {
res.status(304).end(); // 内容未变化,使用缓存
return;
}
// 检查 If-Modified-Since(时间验证)
const clientModified = req.headers['if-modified-since'];
if (clientModified && new Date(clientModified) >= stats.mtime) {
res.status(304).end(); // 内容未变化,使用缓存
return;
}
// 内容已变化,返回新内容
res.setHeader('Cache-Control', 'no-cache');
res.send(content);
};
}
stale-while-revalidate:被忽视的杀手级特性
stale-while-revalidate 是一个很多开发者不知道但非常实用的缓存指令。它的作用是:缓存过期后,浏览器先使用过期的缓存响应用户,同时在后台异步请求新资源。
Cache-Control: max-age=300, stale-while-revalidate=600
这意味着:前 5 分钟直接使用缓存(强缓存),第 5-15 分钟先返回旧缓存同时后台更新,15 分钟后才真正等待新资源。这个策略在以下场景特别有用:
- ✅ 新闻/博客页面:内容偶尔更新,但可以容忍几分钟的延迟
- ✅ API 响应:用户看到的数据可能稍旧,但页面不会卡住
- ❌ 金融/交易页面:数据必须实时,不能使用过期缓存
💡 三、Service Worker:终极缓存策略
为什么需要 Service Worker?
HTTP 缓存虽然强大,但它有几个无法解决的问题:
- 无法离线访问 — 网络断开后,即使有缓存也可能无法访问
- 无法精细控制 — HTTP 缓存是 URL 级别的,无法实现「只缓存 API 响应的一部分」
- 无法后台更新 — 无法在用户不访问页面时预取资源
Service Worker 通过 Cache API 完美解决了这些问题。它本质上是一个运行在浏览器后台的 JavaScript 代理,可以拦截所有网络请求并决定如何响应。
三种核心缓存策略
根据不同的业务场景,Service Worker 有三种常用的缓存策略:
策略一:Cache First(缓存优先)
适用于不常变化的静态资源(JS、CSS、图片)。先查缓存,有就直接返回,没有再走网络。
// Service Worker - Cache First 策略
const STATIC_CACHE = 'static-v2';
const STATIC_ASSETS = [
'/',
'/css/main.css',
'/js/app.js',
'/images/logo.svg',
'/fonts/inter.woff2'
];
// 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting(); // 立即激活,不等待旧 SW 退出
});
// 拦截请求:Cache First 策略
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 静态资源使用 Cache First
if (isStaticAsset(url.pathname)) {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(event.request).then((networkResponse) => {
const responseClone = networkResponse.clone();
caches.open(STATIC_CACHE).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
})
);
}
});
function isStaticAsset(pathname) {
return /\.(js|css|png|jpg|svg|woff2|ico)$/.test(pathname) ||
STATIC_ASSETS.includes(pathname);
}
策略二:Network First(网络优先)
适用于需要实时性的内容(API 响应、用户数据)。先走网络,失败了再用缓存。
// Service Worker - Network First 策略
const API_CACHE = 'api-v1';
const API_TIMEOUT = 3000; // 网络超时 3 秒
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API 请求使用 Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(
networkFirstWithTimeout(event.request, API_TIMEOUT)
);
}
});
async function networkFirstWithTimeout(request, timeout) {
const cache = await caches.open(API_CACHE);
try {
// 带超时的网络请求
const networkResponse = await Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Network timeout')), timeout)
)
]);
// 网络成功,更新缓存
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
// 网络失败,尝试缓存
const cachedResponse = await cache.match(request);
if (cachedResponse) {
// 添加标记,告知前端这是离线缓存
const headers = new Headers(cachedResponse.headers);
headers.set('X-Served-From', 'sw-cache');
return new Response(cachedResponse.body, {
status: cachedResponse.status,
headers
});
}
// 没有缓存也没有网络,返回离线页面
return caches.match('/offline.html');
}
}
策略三:Stale While Revalidate(后台更新)
适用于频繁读取但偶尔更新的内容。先返回缓存,同时后台更新。
// Service Worker - Stale While Revalidate 策略
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 博客文章、配置文件等使用 Stale While Revalidate
if (url.pathname.startsWith('/blog/') || url.pathname === '/config.json') {
event.respondWith(staleWhileRevalidate(event.request));
}
});
async function staleWhileRevalidate(request) {
const cache = await caches.open('swr-v1');
const cachedResponse = await cache.match(request);
// 异步更新(不等待)
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
});
// 有缓存就立即返回,同时后台更新
return cachedResponse || fetchPromise;
}
缓存策略选择决策表
| 场景 | 推荐策略 | 原因 | 典型资源 |
|---|---|---|---|
| 静态资源(JS/CSS/图片) | Cache First | 变化频率低,缓存收益最大 | app.js, style.css |
| API 数据(用户信息) | Network First | 数据必须实时 | /api/user/profile |
| API 数据(列表/推荐) | Stale While Revalidate | 可接受短暂延迟 | /api/articles |
| HTML 页面 | Network First | 内容频繁更新 | /, /about |
| 第三方资源 | Cache First | 内容不可控,缓存减少依赖 | CDN 资源 |
🔧 四、实战:构建完整的多层缓存方案
Vite + Service Worker 完整配置
现代前端构建工具(如 Vite)会自动为静态资源添加内容哈希,这为长期缓存提供了基础。下面是一个完整的生产环境缓存方案:
// vite.config.ts - 构建配置
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// 启用内容哈希(默认已启用)
rollupOptions: {
output: {
// JS 文件:内容哈希
entryFileNames: 'js/[name].[hash].js',
chunkFileNames: 'js/[name].[hash].js',
// CSS 文件:内容哈希
assetFileNames: 'assets/[name].[hash].[ext]',
}
}
}
});
// Service Worker 完整实现(含版本管理和缓存清理)
const CACHE_VERSION = 'v2.0.0';
const CACHE_NAMES = {
static: `static-${CACHE_VERSION}`,
dynamic: `dynamic-${CACHE_VERSION}`,
api: `api-${CACHE_VERSION}`,
};
// 需要预缓存的关键资源列表(由构建工具生成)
const PRECACHE_URLS = [
'/',
'/index.html',
// JS 和 CSS 由构建工具注入带哈希的文件名
];
// 安装:预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAMES.static)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// 激活:清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => !Object.values(CACHE_NAMES).includes(name))
.map(name => {
console.log(`[SW] 删除旧缓存: ${name}`);
return caches.delete(name);
})
);
}).then(() => self.clients.claim()) // 立即控制所有页面
);
});
// 拦截请求:根据资源类型选择策略
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 跳过非 GET 请求
if (request.method !== 'GET') return;
// 跳过 Chrome 扩展请求
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
if (isStaticAsset(url)) {
// 静态资源:Cache First + 长期缓存
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
} else if (isAPIRequest(url)) {
// API 请求:Network First + 超时降级
event.respondWith(networkFirst(request, CACHE_NAMES.api, 3000));
} else {
// HTML 页面:Network First
event.respondWith(networkFirst(request, CACHE_NAMES.dynamic, 5000));
}
});
function isStaticAsset(url) {
return url.pathname.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff2|ico)$/);
}
function isAPIRequest(url) {
return url.pathname.startsWith('/api/');
}
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
}
async function networkFirst(request, cacheName, timeout) {
try {
const response = await Promise.race([
fetch(request),
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), timeout))
]);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503 });
}
}
性能对比数据
以下是我在一个中型 SPA 项目(Vue 3 + Vite)上实测的缓存效果对比:
| 指标 | 无缓存 | 仅 HTTP 缓存 | HTTP + SW 缓存 | 提升幅度 |
|---|---|---|---|---|
| 首次加载(FCP) | 2.1s | 2.1s | 2.1s | — |
| 二次加载(FCP) | 2.1s | 0.4s | 0.2s | 10x |
| 离线访问 | ❌ 不可用 | ❌ 不可用 | ✅ 完全可用 | — |
| 弱网加载(3G) | 8.5s | 3.2s | 0.8s | 10x |
| API 数据加载 | 800ms | 600ms | 150ms(缓存) | 5x |
⚡ **关键结论:**在二次访问场景下,HTTP 缓存 + Service Worker 的组合可以将加载时间从 2.1 秒降低到 0.2 秒,提升 10 倍。在弱网环境下提升更为显著。
⚠️ 五、常见坑点与避坑指南
坑点一:缓存导致用户看到旧版本
这是最常见的缓存问题。用户反馈 bug 修复后仍然复现,原因是浏览器使用了缓存的旧版本 JS/CSS。
解决方案:
<!-- ❌ 错误写法:使用固定文件名 -->
<link rel="stylesheet" href="/css/style.css">
<script src="/js/app.js"></script>
<!-- ✅ 正确写法:使用带内容哈希的文件名(Vite/webpack 自动生成) -->
<link rel="stylesheet" href="/assets/style.a1b2c3d4.css">
<script src="/assets/app.e5f6g7h8.js"></script>
# ❌ 错误的 Nginx 配置:HTML 也长期缓存
location ~* \.html$ {
expires 1y; # 用户可能永远看不到新版
}
# ✅ 正确的配置:HTML 不缓存或短时间缓存
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
坑点二:Service Worker 更新不及时
Service Worker 的更新机制比较微妙——浏览器只在 SW 文件本身发生变化时才会触发更新。如果你的 SW 文件没有变化,但缓存逻辑改了,用户会一直使用旧版 SW。
解决方案:
// 确保每次构建都会更新 SW 文件
// 方法一:在 SW 文件中嵌入版本号
const VERSION = '2.0.1'; // 每次发版手动更新
// 方法二:使用构建工具注入哈希(推荐)
// 在构建脚本中替换 SW 文件中的版本占位符
// process.env.BUILD_HASH 由构建工具注入
const VERSION = '__BUILD_HASH__';
坑点三:CORS 资源缓存失败
跨域资源(CDN 上的字体、图片)默认无法被 Service Worker 缓存,因为 CORS 头配置不正确。
// ❌ 错误:fetch 不加 mode,跨域资源无法缓存
fetch(cdnUrl);
// ✅ 正确:跨域资源需要设置 mode: 'cors'
fetch(cdnUrl, { mode: 'cors' });
# CDN 服务器需要配置正确的 CORS 头
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET
坑点四:缓存雪崩
当大量资源同时过期时,所有用户会在同一时间发起大量请求,造成服务器压力骤增。
**解决方案:**在 max-age 基础上添加随机偏移。
// 在 CDN 层面为不同资源添加随机缓存时间偏移
function getRandomMaxAge(baseAge) {
// 在基础时间上增加 0-10% 的随机偏移
const jitter = Math.floor(Math.random() * baseAge * 0.1);
return baseAge + jitter;
}
// 示例:1 天 ± 2.4 小时
app.use((req, res, next) => {
const maxAge = getRandomMaxAge(86400);
res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
next();
});
📊 总结与推荐工具
HTTP 缓存优化是一个系统工程,需要从多个层面协同工作。以下是完整的缓存策略总结:
| 资源类型 | HTTP 头配置 | 是否需要 SW | 缓存时长 |
|---|---|---|---|
| HTML 入口 | no-cache |
Network First | 每次验证 |
| 带哈希的 JS/CSS | max-age=31536000, immutable |
Cache First | 1 年 |
| 图片/字体 | max-age=604800 |
Cache First | 7 天 |
| API(实时数据) | no-store |
Network First | 不缓存 |
| API(可缓存数据) | private, max-age=300 |
SWR | 5 分钟 |
| 第三方 CDN 资源 | max-age=86400 |
Cache First | 1 天 |
实用工具推荐:
- 🔧 Lighthouse — Chrome DevTools 内置,自动检测缓存配置问题
- 🔧 WebPageTest — 可视化分析缓存命中率和加载瀑布图
- 🔧 workbox — Google 的 Service Worker 工具库,内置多种缓存策略
- 🔧 Chrome DevTools → Network → Size 列 — 显示每个请求是否命中缓存(
from memory cache/from disk cache) - 🔧 Chrome DevTools → Application → Cache Storage — 查看 Service Worker 缓存内容
⚡ **关键结论:**缓存优化不是一次性工作,而是需要随着业务变化持续调整的策略。建议将缓存配置纳入 CI/CD 流程,每次发版自动更新 Service Worker 版本号,并通过 Lighthouse CI 监控缓存命中率。