前端缓存策略完全指南
🎯 从 HTTP 缓存到 Service Worker,掌握 SPA 缓存最佳实践
1. 缓存体系总览
1.1 浏览器缓存层级
┌─────────────────────────────────────────────────────────────────────────┐
│ 浏览器缓存层级 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 优先级 (从高到低): │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. Memory Cache (内存缓存) │ │
│ │ • 最快,页面关闭后清除 │ │
│ │ • 存储: JS、CSS、小图片 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ 未命中 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 2. Service Worker Cache │ │
│ │ • 可编程控制 │ │
│ │ • 支持离线访问 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ 未命中 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 3. Disk Cache (磁盘缓存) │ │
│ │ • HTTP 缓存 (强缓存/协商缓存) │ │
│ │ • 持久化存储 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ 未命中 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 4. Push Cache (HTTP/2 推送缓存) │ │
│ │ • 会话级别,连接关闭后清除 │ │
│ │ • 仅存在于 HTTP/2 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ 未命中 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 5. Network (网络请求) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘1.2 HTTP 缓存分类
┌─────────────────────────────────────────────────────────────────────────┐
│ HTTP 缓存分类 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ 强缓存 │ │ 协商缓存 │ │
│ │ (不发送请求) │ │ (发送请求验证) │ │
│ ├────────────────────────────┤ ├────────────────────────────┤ │
│ │ Cache-Control: max-age │ │ Last-Modified / If-M-S │ │
│ │ Expires (已弃用) │ │ ETag / If-None-Match │ │
│ ├────────────────────────────┤ ├────────────────────────────┤ │
│ │ 命中: 200 (from cache) │ │ 命中: 304 Not Modified │ │
│ │ 不命中: 发起请求 │ │ 不命中: 200 + 新资源 │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ │
│ 执行顺序: 先检查强缓存 → 过期后走协商缓存 │
│ │
└─────────────────────────────────────────────────────────────────────────┘2. 强缓存详解
2.1 Cache-Control
http
# 常用指令
Cache-Control: max-age=31536000 # 缓存 1 年
Cache-Control: no-cache # 每次需协商验证
Cache-Control: no-store # 禁止缓存
Cache-Control: private # 仅浏览器缓存
Cache-Control: public # CDN 也可缓存
Cache-Control: immutable # 资源永不变化
Cache-Control: stale-while-revalidate=60 # 后台刷新
# 组合使用
Cache-Control: public, max-age=31536000, immutable # 静态资源
Cache-Control: private, no-cache # 动态 HTML
Cache-Control: no-store # 敏感数据2.2 指令详解
┌─────────────────────────────────────────────────────────────────────────┐
│ Cache-Control 指令详解 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 指令 │ 说明 │
│ ─────────────────────────┼─────────────────────────────────────────── │
│ max-age=<seconds> │ 缓存有效期 (相对时间) │
│ s-maxage=<seconds> │ CDN/代理缓存有效期 (覆盖 max-age) │
│ no-cache │ 可缓存,但每次必须向服务器验证 │
│ no-store │ 完全不缓存 │
│ private │ 仅浏览器缓存 (不允许 CDN) │
│ public │ 允许 CDN 缓存 │
│ immutable │ 资源不会变化 (避免刷新时重新验证) │
│ must-revalidate │ 过期后必须验证,不能使用过期缓存 │
│ stale-while-revalidate │ 返回过期缓存,同时后台刷新 │
│ stale-if-error │ 出错时可使用过期缓存 │
│ │
└─────────────────────────────────────────────────────────────────────────┘2.3 缓存决策流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ 浏览器缓存决策流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 请求资源 │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ 有本地缓存? │ │
│ └───────┬───────┘ │
│ Yes │ │ No │
│ ┌────────────┘ └────────────┐ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Cache-Control │ │ 发起请求 │ │
│ │ max-age 过期? │ │ 200 OK │ │
│ └───────┬───────┘ └───────────────┘ │
│ No │ │ Yes │
│ ┌────────┘ └────────┐ │
│ ▼ ▼ │
│ 强缓存命中 协商缓存 │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ 200 (from │ │ If-None-Match │ │
│ │ memory/disk │ │ If-Modified- │ │
│ │ cache) │ │ Since │ │
│ └───────────────┘ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ 资源变化? │ │
│ └───────┬───────┘ │
│ Yes │ │ No │
│ ┌────────┘ └────────┐ │
│ ▼ ▼ │
│ 200 OK 304 Not Modified │
│ 新资源 使用本地缓存 │
│ │
└─────────────────────────────────────────────────────────────────────────┘3. 协商缓存详解
3.1 Last-Modified / If-Modified-Since
http
# 首次请求响应
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
Cache-Control: no-cache
# 后续请求
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT
# 未修改
HTTP/1.1 304 Not Modified
# 已修改
HTTP/1.1 200 OK
Last-Modified: Thu, 22 Oct 2023 10:30:00 GMT3.2 ETag / If-None-Match
http
# 首次请求响应
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache
# 后续请求
GET /style.css HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# 未修改
HTTP/1.1 304 Not Modified
# 已修改
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6g7h8i9j0"3.3 ETag vs Last-Modified
┌─────────────────────────────────────────────────────────────────────────┐
│ ETag vs Last-Modified │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 维度 │ Last-Modified │ ETag │
│ ─────────────┼───────────────────────┼─────────────────────────────── │
│ 精度 │ 秒级 │ 字节级 │
│ 生成方式 │ 文件修改时间 │ 内容哈希/版本号 │
│ 性能 │ 高 (直接读取) │ 较低 (需计算) │
│ 可靠性 │ 可能不准确* │ 精确 │
│ 优先级 │ 低 │ 高 (同时存在时优先) │
│ │
│ * Last-Modified 不准确的情况: │
│ 1. 文件编辑后撤销,时间变了但内容没变 │
│ 2. 1 秒内多次修改,时间相同但内容不同 │
│ 3. 负载均衡下,不同服务器文件时间可能不一致 │
│ │
│ 最佳实践: 同时使用 ETag + Last-Modified │
│ │
└─────────────────────────────────────────────────────────────────────────┘4. SPA 缓存最佳实践
4.1 资源缓存策略
┌─────────────────────────────────────────────────────────────────────────┐
│ SPA 资源缓存策略 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 资源类型 │ 缓存策略 │
│ ─────────────────────┼─────────────────────────────────────────────── │
│ index.html │ no-cache (每次验证) │
│ JS/CSS (带 hash) │ max-age=31536000, immutable (永久缓存) │
│ 图片 (带 hash) │ max-age=31536000, immutable │
│ 字体 │ max-age=31536000 │
│ API 响应 │ no-store 或 短期缓存 │
│ │
│ 关键原则: │
│ 1. HTML 不能强缓存 (否则无法更新 JS/CSS 引用) │
│ 2. 静态资源加 hash,配合长期缓存 │
│ 3. API 根据业务需求决定是否缓存 │
│ │
└─────────────────────────────────────────────────────────────────────────┘4.2 Nginx 配置示例
nginx
server {
listen 80;
server_name app.example.com;
root /var/www/app/dist;
# HTML - 不缓存,每次验证
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
# 或者使用协商缓存
# add_header Cache-Control "no-cache, must-revalidate";
# 安全头
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
}
# 带 hash 的静态资源 - 永久缓存
location ~* \.(js|css)$ {
# 检查文件名是否包含 hash (如 main.a1b2c3d4.js)
if ($uri ~* "\.([a-f0-9]{8,})\.") {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 不带 hash 的 JS/CSS (兜底)
add_header Cache-Control "public, max-age=86400";
}
# 图片/字体 - 长期缓存
location ~* \.(ico|gif|jpg|jpeg|png|webp|svg|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=31536000";
# 启用 gzip
gzip on;
gzip_types image/svg+xml application/font-woff application/font-woff2;
}
# API 代理 - 不缓存
location /api/ {
proxy_pass http://backend;
add_header Cache-Control "no-store";
}
}4.3 Vite/Webpack 文件名 Hash
javascript
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'assets/[name].[hash].js',
// 代码分割
chunkFileNames: 'assets/[name].[hash].js',
// 资源文件
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
});
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
},
module: {
rules: [
{
test: /\.(png|jpg|gif|svg)$/,
type: 'asset/resource',
generator: {
filename: 'images/[name].[hash][ext]',
},
},
],
},
};5. Service Worker 缓存
5.1 缓存策略
┌─────────────────────────────────────────────────────────────────────────┐
│ Service Worker 缓存策略 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Cache First (缓存优先) │
│ ───────────────────────── │
│ 请求 → 缓存命中? → 是 → 返回缓存 │
│ → 否 → 网络请求 → 缓存 → 返回 │
│ 适用: 静态资源 (JS/CSS/图片) │
│ │
│ 2. Network First (网络优先) │
│ ───────────────────────── │
│ 请求 → 网络请求 → 成功 → 缓存 → 返回 │
│ → 失败 → 返回缓存 │
│ 适用: API 请求、需要实时数据的场景 │
│ │
│ 3. Stale While Revalidate (先返回缓存,后台更新) │
│ ──────────────────────────────────────────── │
│ 请求 → 返回缓存 (立即) → 同时发起网络请求 → 更新缓存 │
│ 适用: 用户头像、非关键内容 │
│ │
│ 4. Cache Only (仅缓存) │
│ ───────────────────── │
│ 请求 → 返回缓存 (无网络请求) │
│ 适用: 离线应用的静态资源 │
│ │
│ 5. Network Only (仅网络) │
│ ───────────────────── │
│ 请求 → 网络请求 (不使用缓存) │
│ 适用: 敏感数据、不能缓存的内容 │
│ │
└─────────────────────────────────────────────────────────────────────────┘5.2 Workbox 实现
javascript
// sw.js (使用 Workbox)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// 预缓存 (构建时注入)
precacheAndRoute(self.__WB_MANIFEST);
// 静态资源 - Cache First
registerRoute(
({ request }) =>
request.destination === 'script' ||
request.destination === 'style' ||
request.destination === 'image',
new CacheFirst({
cacheName: 'static-resources',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
}),
],
})
);
// API 请求 - Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 分钟
}),
],
})
);
// 字体 - Stale While Revalidate
registerRoute(
({ request }) => request.destination === 'font',
new StaleWhileRevalidate({
cacheName: 'fonts',
})
);
// HTML - Network First (确保获取最新版本)
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages',
networkTimeoutSeconds: 3,
})
);5.3 手动实现缓存策略
javascript
// sw.js (手动实现)
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/main.js',
'/style.css',
];
// 安装 - 预缓存
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// 激活 - 清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// 请求拦截
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API 请求 - Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// 静态资源 - Cache First
event.respondWith(cacheFirst(request));
});
// Cache First 策略
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return new Response('Offline', { status: 503 });
}
}
// Network First 策略
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
return cached || new Response('Offline', { status: 503 });
}
}6. CDN 缓存
6.1 CDN 缓存配置
┌─────────────────────────────────────────────────────────────────────────┐
│ CDN 缓存架构 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ Origin │ │
│ │ Server │ │
│ └────┬─────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ │
│ │ CDN Edge │ │ CDN Edge │ │ CDN Edge │ │
│ │ (北京) │ │ (上海) │ │ (深圳) │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
│ │ │ │ │
│ ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ │
│ │ User A │ │ User B │ │ User C │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 缓存生效顺序: │
│ 1. 用户请求 → 最近的 CDN Edge 节点 │
│ 2. CDN 节点检查本地缓存 │
│ 3. 缓存命中 → 直接返回 │
│ 4. 缓存未命中 → 回源到 Origin → 缓存 → 返回 │
│ │
└─────────────────────────────────────────────────────────────────────────┘6.2 CDN 缓存 Header
http
# 源站响应 (控制 CDN 缓存)
Cache-Control: public, s-maxage=86400, max-age=3600
# s-maxage: CDN 缓存 1 天
# max-age: 浏览器缓存 1 小时
# Vary: 根据不同 Header 缓存不同版本
Vary: Accept-Encoding
# CDN 会分别缓存 gzip 和 br 压缩版本
# CDN 厂商特定 Header
# Cloudflare
Cloudflare-CDN-Cache-Control: max-age=86400
# Fastly
Surrogate-Control: max-age=864006.3 CDN 缓存刷新
javascript
// 方案 1: 版本化 URL (推荐)
// 文件名带 hash,无需刷新 CDN
<script src="/main.a1b2c3d4.js"></script>
// 方案 2: Query String
// 可能被某些 CDN 忽略
<script src="/main.js?v=20231023"></script>
// 方案 3: API 刷新
// Cloudflare API
await fetch('https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache', {
method: 'POST',
headers: {
'Authorization': 'Bearer {api_token}',
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [
'https://example.com/main.js',
'https://example.com/style.css',
],
}),
});7. 缓存问题排查
7.1 DevTools 查看缓存
Chrome DevTools → Network:
- Size 列: 显示 "(memory cache)" 或 "(disk cache)"
- 右键 → Clear browser cache: 清除缓存
- Disable cache: 禁用缓存 (调试用)
Application → Cache Storage:
- 查看 Service Worker 缓存内容
Application → Storage:
- Clear site data: 清除所有缓存7.2 常见问题
┌─────────────────────────────────────────────────────────────────────────┐
│ 缓存常见问题排查 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 问题: 更新后用户看到旧版本 │
│ ───────────────────────────── │
│ 原因: │
│ 1. HTML 被强缓存 (应该用 no-cache) │
│ 2. CDN 缓存未刷新 │
│ 3. Service Worker 返回旧缓存 │
│ │
│ 解决: │
│ 1. HTML 使用 Cache-Control: no-cache │
│ 2. 静态资源文件名加 hash │
│ 3. 更新 Service Worker 版本号 │
│ │
│ 问题: 协商缓存不生效 │
│ ─────────────────────── │
│ 原因: │
│ 1. 服务器未返回 ETag/Last-Modified │
│ 2. Cache-Control: no-store 禁止了缓存 │
│ 3. 请求带了 Cache-Control: no-cache (强制请求) │
│ │
│ 问题: 缓存导致的白屏 │
│ ─────────────────────── │
│ 原因: │
│ 1. HTML 缓存了旧的 JS 引用路径 │
│ 2. 新 JS 文件已上传,但 HTML 还引用旧的 hash │
│ │
│ 解决: │
│ 1. 先上传新的 JS/CSS,再上传 HTML │
│ 2. 保留旧版本文件一段时间 │
│ │
└─────────────────────────────────────────────────────────────────────────┘8. 面试高频问题
Q1: 强缓存和协商缓存的区别?
| 类型 | 请求 | Header | 状态码 |
|---|---|---|---|
| 强缓存 | 不发送 | Cache-Control, Expires | 200 (from cache) |
| 协商缓存 | 发送验证 | ETag, Last-Modified | 304 Not Modified |
Q2: no-cache 和 no-store 的区别?
- no-cache: 可以缓存,但每次使用前必须向服务器验证
- no-store: 完全不缓存,每次都从服务器获取
Q3: ETag 和 Last-Modified 哪个优先级高?
ETag 优先级高。当同时存在时,服务器会先检查 ETag,再检查 Last-Modified。
Q4: SPA 为什么 HTML 不能强缓存?
HTML 包含对 JS/CSS 的引用。如果 HTML 被强缓存,更新后用户可能加载旧 HTML,引用不存在的旧 JS 文件,导致白屏。
Q5: 如何实现"更新后用户立即看到新版本"?
- HTML 使用
Cache-Control: no-cache - JS/CSS 文件名带 contenthash
- 每次构建生成新 hash
- 用户刷新时获取新 HTML,自动加载新 JS/CSS
Q6: Service Worker 缓存和 HTTP 缓存的关系?
Service Worker 缓存优先级高于 HTTP 缓存。请求会先经过 SW,SW 可以决定使用缓存还是发起网络请求。网络请求仍然遵循 HTTP 缓存规则。