浏览器缓存机制
问题
请介绍浏览器的缓存机制,包括强缓存、协商缓存的工作原理和区别。
答案
浏览器缓存是提升网页性能的重要手段,可以减少网络请求、降低服务器压力、加快页面加载速度。浏览器缓存主要分为强缓存和协商缓存两种策略。
强缓存(Strong Cache)
强缓存指浏览器直接从本地缓存读取资源,不向服务器发送任何请求。
Expires(HTTP/1.0)
Expires 是一个绝对时间,表示资源的过期时间。
Expires: Thu, 12 Feb 2027 08:00:00 GMT
- 依赖客户端时间:如果用户修改本地时间,可能导致缓存失效
- 精度问题:只能精确到秒
- 已被 Cache-Control 取代:HTTP/1.1 推荐使用 Cache-Control
Cache-Control(HTTP/1.1)
Cache-Control 使用相对时间,是现代浏览器缓存的主要控制方式。
Cache-Control: max-age=31536000, public
常用指令
| 指令 | 说明 | 示例 |
|---|---|---|
max-age=<seconds> | 资源最大有效时间(秒) | max-age=3600(1小时) |
s-maxage=<seconds> | CDN/代理服务器缓存时间 | s-maxage=86400 |
public | 可被任何缓存存储(包括 CDN) | public, max-age=3600 |
private | 只能被浏览器缓存,不能被 CDN 缓存 | private, max-age=600 |
no-cache | 使用缓存前必须向服务器验证 | no-cache |
no-store | 完全禁止缓存 | no-store |
must-revalidate | 过期后必须向服务器验证 | max-age=3600, must-revalidate |
immutable | 资源永不改变,不需要验证 | max-age=31536000, immutable |
当 Cache-Control 和 Expires 同时存在时,Cache-Control 优先级更高。
强缓存响应示例
// Express.js 设置强缓存
import express from 'express';
const app = express();
// 静态资源设置长期缓存(1年)
app.use('/static', express.static('public', {
maxAge: 31536000000, // 毫秒
immutable: true
}));
// 或手动设置响应头
app.get('/api/config', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1小时
res.json({ version: '1.0.0' });
});
强缓存状态码
| 状态 | 说明 |
|---|---|
200 (from memory cache) | 从内存缓存读取,页面未关闭时 |
200 (from disk cache) | 从磁盘缓存读取,页面关闭后重新打开 |
协商缓存(Negotiation Cache)
协商缓存需要向服务器发送请求,由服务器判断资源是否更新。如果未更新,返回 304 Not Modified,浏览器使用本地缓存。
Last-Modified / If-Modified-Since
基于文件最后修改时间判断资源是否更新。
首次请求:服务器返回 Last-Modified 头
HTTP/1.1 200 OK
Last-Modified: Wed, 11 Feb 2026 10:00:00 GMT
Content-Type: text/html
再次请求:浏览器携带 If-Modified-Since 头
GET /index.html HTTP/1.1
If-Modified-Since: Wed, 11 Feb 2026 10:00:00 GMT
服务器响应:
- 未修改:返回
304 Not Modified(无响应体) - 已修改:返回
200 OK+ 新资源
- 精度问题:只能精确到秒,1 秒内多次修改无法检测
- 内容未变但时间变了:如重新保存文件,时间改变但内容未变
- 负载均衡问题:多台服务器的文件修改时间可能不一致
ETag / If-None-Match
基于资源内容的唯一标识符(哈希值)判断资源是否更新,解决了 Last-Modified 的局限性。
首次请求:服务器返回 ETag 头
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: text/html
再次请求:浏览器携带 If-None-Match 头
GET /index.html HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
服务器响应:
- ETag 匹配:返回
304 Not Modified - ETag 不匹配:返回
200 OK+ 新资源
ETag 的类型
| 类型 | 格式 | 说明 |
|---|---|---|
| 强 ETag | "abc123" | 内容完全一致才匹配 |
| 弱 ETag | W/"abc123" | 语义相同即可匹配(如忽略空格差异) |
- ETag 优先级更高:同时存在时,服务器优先验证 ETag
- ETag 更精确:基于内容哈希,不受时间影响
- ETag 开销更大:需要计算哈希值,增加服务器负担
协商缓存实现示例
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import fs from 'fs';
const app = express();
// 使用 ETag 实现协商缓存
app.get('/data.json', (req: Request, res: Response) => {
const data = JSON.stringify({ message: 'Hello World', timestamp: Date.now() });
const etag = crypto.createHash('md5').update(data).digest('hex');
// 设置 ETag
res.setHeader('ETag', `"${etag}"`);
res.setHeader('Cache-Control', 'no-cache'); // 每次都验证
// 检查 If-None-Match
const clientEtag = req.headers['if-none-match'];
if (clientEtag === `"${etag}"`) {
res.status(304).end(); // 资源未变化
return;
}
res.json(JSON.parse(data));
});
// 使用 Last-Modified 实现协商缓存
app.get('/file/:name', (req: Request, res: Response) => {
const filePath = `./files/${req.params.name}`;
const stat = fs.statSync(filePath);
const lastModified = stat.mtime.toUTCString();
res.setHeader('Last-Modified', lastModified);
res.setHeader('Cache-Control', 'no-cache');
const clientModified = req.headers['if-modified-since'];
if (clientModified === lastModified) {
res.status(304).end();
return;
}
res.sendFile(filePath);
});
缓存策略对比
| 特性 | 强缓存 | 协商缓存 |
|---|---|---|
| 是否发送请求 | 否 | 是 |
| HTTP 状态码 | 200 (from cache) | 304 Not Modified |
| 响应头 | Cache-Control, Expires | ETag, Last-Modified |
| 请求头 | 无 | If-None-Match, If-Modified-Since |
| 性能 | 最优(无网络请求) | 较优(只传输响应头) |
| 适用场景 | 不常变化的静态资源 | 需要验证更新的资源 |
缓存决策流程
实际应用:缓存策略配置
不同资源的缓存策略
// Nginx 配置示例转换为 Node.js
import express from 'express';
const app = express();
// HTML 文件:不缓存或协商缓存
app.get('*.html', (req, res, next) => {
res.setHeader('Cache-Control', 'no-cache');
next();
});
// JS/CSS 带 hash 的文件:长期强缓存
app.get(/\.(js|css)$/, (req, res, next) => {
if (req.url.includes('.') && /\.[a-f0-9]{8}\./.test(req.url)) {
// 带 hash 的文件,如 app.a1b2c3d4.js
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
// 不带 hash 的文件
res.setHeader('Cache-Control', 'no-cache');
}
next();
});
// 图片/字体:长期缓存
app.get(/\.(png|jpg|gif|svg|woff2?)$/, (req, res, next) => {
res.setHeader('Cache-Control', 'public, max-age=31536000');
next();
});
// API 响应:不缓存
app.get('/api/*', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
推荐的缓存策略
| 资源类型 | 缓存策略 | Cache-Control 值 |
|---|---|---|
| HTML 入口 | 协商缓存 | no-cache |
| JS/CSS(带 hash) | 强缓存 1 年 | max-age=31536000, immutable |
| JS/CSS(不带 hash) | 协商缓存 | no-cache |
| 图片/字体/视频 | 强缓存 1 年 | max-age=31536000 |
| API 响应 | 不缓存 | no-store |
| 用户敏感数据 | 不缓存 | private, no-store |
前端工程化中的缓存
现代前端构建工具(如 Webpack、Vite)通过内容哈希实现最佳缓存策略:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
// 生成带 hash 的文件名
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'assets/[name].[hash].js',
// 代码分割的 chunk
chunkFileNames: 'assets/[name].[hash].js',
// 静态资源
assetFileNames: 'assets/[name].[hash].[ext]'
}
}
}
});
// 构建输出示例
dist/
├── index.html # 不带 hash,协商缓存
├── assets/
│ ├── index.a1b2c3d4.js # 带 hash,强缓存
│ ├── vendor.e5f6g7h8.js # 带 hash,强缓存
│ └── style.i9j0k1l2.css # 带 hash,强缓存
- 内容变化 → hash 变化 → URL 变化:浏览器自动请求新资源
- 内容不变 → hash 不变 → URL 不变:继续使用强缓存
- 实现精准的缓存失效:只更新变化的文件
缓存位置
浏览器缓存资源的位置有多种,按查找优先级从高到低排序:
| 缓存位置 | 说明 | 特点 | 生命周期 |
|---|---|---|---|
| Service Worker Cache | 开发者可编程控制 | 优先级最高,支持离线 | 手动管理 |
| Memory Cache | 内存缓存 | 速度最快,容量小 | 页面关闭即清除 |
| Disk Cache | 磁盘缓存 | 容量大,速度较慢 | 根据策略清理 |
| Push Cache | HTTP/2 推送缓存 | 会话级别 | 约 5 分钟 |
1. Service Worker Cache
Service Worker 是运行在浏览器背后的独立线程,可以拦截网络请求并返回缓存资源。
特点:
- 优先级最高:在所有其他缓存之前检查
- 完全可控:开发者决定缓存什么、何时更新
- 支持离线:即使断网也能返回缓存资源
- 独立生命周期:不随页面关闭而销毁
常见缓存策略:
// sw.ts - Service Worker 文件
// 1. Cache First(缓存优先)- 适合静态资源
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
const cache = await caches.open('static-v1');
cache.put(request, response.clone());
return response;
}
// 2. Network First(网络优先)- 适合 API 请求
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open('api-v1');
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
if (cached) {
return cached;
}
throw new Error('No cached data available');
}
}
// 3. Stale While Revalidate(返回缓存同时更新)
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open('dynamic-v1');
const cached = await cache.match(request);
// 后台更新缓存
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
// 优先返回缓存,没有则等待网络
return cached || fetchPromise;
}
// 注册事件监听
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.(js|css|png|jpg)$/)) {
event.respondWith(cacheFirst(event.request));
} else {
event.respondWith(staleWhileRevalidate(event.request));
}
});
2. Memory Cache(内存缓存)
Memory Cache 将资源直接存储在内存中,读取速度最快。
特点:
- 速度极快:直接从 RAM 读取,无磁盘 I/O
- 容量有限:受可用内存限制
- 生命周期短:标签页关闭即清除
- 自动管理:浏览器自动决定缓存内容
存储优先级(浏览器倾向于将以下资源放入内存缓存):
| 资源类型 | Memory Cache 概率 | 原因 |
|---|---|---|
| preload 预加载资源 | 高 | 很快会被使用 |
| 小型 JS/CSS 文件 | 高 | 体积小,频繁使用 |
| base64 图片 | 高 | 已在内存中 |
| 大型图片/视频 | 低 | 占用内存过大 |
// 使用 preload 提示浏览器预加载到内存
// HTML 中
// <link rel="preload" href="critical.js" as="script">
// <link rel="preload" href="hero.jpg" as="image">
// 检查资源是否来自 Memory Cache(开发者工具)
// Network 面板 → Size 列显示 "(memory cache)"
- 刷新页面:优先使用 Memory Cache
- 关闭后重新打开:只能使用 Disk Cache
- 相同资源多个标签页:可能共享 Memory Cache
3. Disk Cache(磁盘缓存)
Disk Cache 将资源存储在硬盘上,容量大且持久化。
特点:
- 容量大:可存储大量资源(通常数百 MB 到数 GB)
- 持久化:关闭浏览器后仍然存在
- 速度较慢:需要磁盘 I/O
- 遵循 HTTP 缓存策略:根据 Cache-Control 等头部决定缓存时长
存储内容:
| 资源类型 | 说明 |
|---|---|
| HTML 文件 | 协商缓存时存储 |
| JS/CSS 文件 | 强缓存的静态资源 |
| 图片/字体 | 大型媒体资源 |
| 其他静态资源 | 根据响应头决定 |
// 浏览器决定存储位置的简化逻辑
function decideCacheLocation(resource: Resource): 'memory' | 'disk' {
// 1. 正在使用的资源倾向于内存
if (resource.isCurrentlyUsed) {
return 'memory';
}
// 2. 大文件倾向于磁盘
if (resource.size > 1024 * 1024) { // > 1MB
return 'disk';
}
// 3. 预加载资源倾向于内存
if (resource.isPreloaded) {
return 'memory';
}
// 4. 其他情况通常存磁盘
return 'disk';
}
查看 Disk Cache:
# Chrome 缓存位置(macOS)
~/Library/Caches/Google/Chrome/Default/Cache
# Chrome 缓存位置(Windows)
%LOCALAPPDATA%\Google\Chrome\User Data\Default\Cache
4. Push Cache(推送缓存)
Push Cache 是 HTTP/2 Server Push 的缓存,是最后一道防线。
特点:
- 生命周期极短:只存在于会话(Session)中,约 5 分钟
- 一次性使用:被使用后即从 Push Cache 中移除
- 优先级最低:只有其他缓存都未命中时才检查
- HTTP/2 专属:只有 HTTP/2 连接才有
// 服务器推送示例(Node.js with http2)
import http2 from 'http2';
import fs from 'fs';
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
const path = headers[':path'];
if (path === '/index.html') {
// 推送关联资源
stream.pushStream({ ':path': '/style.css' }, (err, pushStream) => {
if (!err) {
pushStream.respond({ ':status': 200, 'content-type': 'text/css' });
pushStream.end(fs.readFileSync('style.css'));
}
});
stream.pushStream({ ':path': '/app.js' }, (err, pushStream) => {
if (!err) {
pushStream.respond({ ':status': 200, 'content-type': 'application/javascript' });
pushStream.end(fs.readFileSync('app.js'));
}
});
// 返回 HTML
stream.respond({ ':status': 200, 'content-type': 'text/html' });
stream.end(fs.readFileSync('index.html'));
}
});
server.listen(443);
- 不同页面/标签页无法共享(不同会话)
- 浏览器可能拒绝推送(如已有缓存)
- 实际使用较少,HTTP/3 弃用了 Server Push
缓存位置总结
| 对比项 | Service Worker | Memory Cache | Disk Cache | Push Cache |
|---|---|---|---|---|
| 控制方式 | 开发者编程 | 浏览器自动 | 浏览器自动 | 服务器推送 |
| 读取速度 | 快 | 最快 | 较慢 | 快 |
| 容量 | 配额限制 | 小 | 大 | 小 |
| 生命周期 | 手动管理 | 页面级 | 持久化 | 会话级(~5分钟) |
| 离线支持 | ✅ | ❌ | ✅ | ❌ |
| 跨页面共享 | ✅ 同源 | 部分 | ✅ | ❌ |
缓存问题与解决方案
问题 1:缓存更新不及时
症状:用户看到旧版本页面
解决方案:
// 1. HTML 使用协商缓存
// Cache-Control: no-cache
// 2. 资源文件使用内容哈希
// app.a1b2c3d4.js → app.e5f6g7h8.js
// 3. 版本号查询参数(不推荐)
// app.js?v=1.0.1 → app.js?v=1.0.2
问题 2:强制刷新缓存
// 用户操作
// - 普通刷新:F5 / Cmd+R → 使用缓存
// - 强制刷新:Ctrl+F5 / Cmd+Shift+R → 跳过缓存
// - 清空缓存:DevTools → Application → Clear storage
// 代码中强制获取最新资源
fetch('/api/data', {
cache: 'no-store' // 完全跳过缓存
});
fetch('/api/data', {
cache: 'reload' // 忽略缓存,但会更新缓存
});
问题 3:CDN 缓存更新
// CDN 缓存刷新策略
// 1. 使用内容哈希(推荐)
// 2. 手动刷新 CDN 缓存
// 3. 设置合适的 s-maxage
// Nginx 配置示例
/*
location /static/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=86400";
# 浏览器缓存 1 年,CDN 缓存 1 天
}
*/
常见面试问题
Q1: 强缓存和协商缓存的区别是什么?
答案:
| 对比项 | 强缓存 | 协商缓存 |
|---|---|---|
| 是否发请求 | 否,直接使用本地缓存 | 是,向服务器验证 |
| 状态码 | 200 (from cache) | 304 Not Modified |
| 控制字段 | Cache-Control, Expires | ETag, Last-Modified |
| 性能 | 最优(0 网络请求) | 较优(只传输头部) |
| 使用场景 | 不常变化的静态资源 | 需要验证更新的资源 |
工作流程:
// 伪代码:浏览器缓存判断逻辑
function handleRequest(url: string): Response {
const cache = getLocalCache(url);
if (!cache) {
return fetchFromServer(url); // 无缓存,请求服务器
}
// 1. 检查强缓存
if (cache.cacheControl && !isExpired(cache.cacheControl.maxAge)) {
return cache.response; // 强缓存命中,直接返回
}
// 2. 强缓存失效,进入协商缓存
const headers: Record<string, string> = {};
if (cache.etag) {
headers['If-None-Match'] = cache.etag;
}
if (cache.lastModified) {
headers['If-Modified-Since'] = cache.lastModified;
}
const response = fetchFromServer(url, { headers });
if (response.status === 304) {
return cache.response; // 协商缓存命中
}
return response; // 返回新资源
}
Q2: Cache-Control 的 no-cache 和 no-store 有什么区别?
答案:
| 指令 | 含义 | 是否缓存 | 使用场景 |
|---|---|---|---|
no-cache | 使用缓存前必须向服务器验证 | ✅ 缓存 | HTML 入口文件 |
no-store | 完全禁止缓存 | ❌ 不缓存 | 敏感数据(银行页面) |
// no-cache:每次都要验证,但会存储缓存
// 适用于需要确保获取最新版本,但可以利用 304 优化的场景
res.setHeader('Cache-Control', 'no-cache');
// no-store:完全不缓存,每次都重新下载
// 适用于敏感数据,如银行账户信息
res.setHeader('Cache-Control', 'no-store');
// 常见误解
// ❌ 错误理解:no-cache 表示不缓存
// ✅ 正确理解:no-cache 表示缓存但需验证
流程对比:
Q3: 如何设计一个最优的前端缓存策略?
答案:
核心原则:
- HTML 文件:使用协商缓存(
no-cache),确保入口始终最新 - 静态资源:使用内容哈希 + 长期强缓存(
immutable) - API 响应:根据业务需求设置,敏感数据使用
no-store
完整配置示例:
// server.ts - Express 缓存配置
import express from 'express';
import path from 'path';
const app = express();
// 设置不同资源的缓存策略
app.use((req, res, next) => {
const url = req.url;
if (url.endsWith('.html') || url === '/') {
// HTML:协商缓存,确保获取最新版本
res.setHeader('Cache-Control', 'no-cache');
} else if (/\.[a-f0-9]{8,}\.(js|css)$/.test(url)) {
// 带 hash 的 JS/CSS:强缓存 1 年 + immutable
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (/\.(js|css)$/.test(url)) {
// 不带 hash 的 JS/CSS:协商缓存
res.setHeader('Cache-Control', 'no-cache');
} else if (/\.(png|jpg|gif|svg|ico|woff2?|ttf|eot)$/.test(url)) {
// 图片和字体:强缓存 1 年
res.setHeader('Cache-Control', 'public, max-age=31536000');
}
next();
});
// API 路由
app.use('/api', (req, res, next) => {
// API 默认不缓存
res.setHeader('Cache-Control', 'no-store');
next();
});
app.use(express.static('dist'));
Nginx 配置参考:
# HTML 文件
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
# 带 hash 的静态资源
location ~* \.[a-f0-9]{8}\.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 图片和字体
location ~* \.(png|jpg|gif|svg|woff2?)$ {
add_header Cache-Control "public, max-age=31536000";
}
# API
location /api/ {
add_header Cache-Control "no-store";
}
Q4: ETag 和 Last-Modified 哪个更好?应该如何选择?
答案:
对比分析:
| 特性 | ETag | Last-Modified |
|---|---|---|
| 精度 | 高(基于内容哈希) | 低(秒级时间戳) |
| 性能开销 | 高(需计算哈希) | 低(直接读取修改时间) |
| 分布式一致性 | 好(内容相同则 ETag 相同) | 差(多服务器时间可能不同) |
| 浏览器支持 | HTTP/1.1+ | HTTP/1.0+ |
| 优先级 | 高 | 低 |
选择建议:
// 推荐:同时使用两者
// ETag 作为主要验证,Last-Modified 作为兜底
app.get('/resource', (req, res) => {
const content = getResourceContent();
const stat = getResourceStat();
// 同时设置两者
res.setHeader('ETag', `"${calculateHash(content)}"`);
res.setHeader('Last-Modified', stat.mtime.toUTCString());
// 优先检查 ETag
const clientEtag = req.headers['if-none-match'];
const clientModified = req.headers['if-modified-since'];
if (clientEtag && clientEtag === res.getHeader('ETag')) {
return res.status(304).end();
}
if (clientModified && clientModified === res.getHeader('Last-Modified')) {
return res.status(304).end();
}
res.send(content);
});
使用场景:
- 优先 ETag:内容频繁变化、需要精确验证、分布式环境
- 优先 Last-Modified:大文件、计算哈希开销大、简单场景
- 两者结合:通用场景,提供最佳兼容性
Q5: 实际项目中如何设计缓存策略?(HTML no-cache + 资源文件 hash 长缓存)
答案:
现代前端项目的缓存策略遵循一个核心原则:HTML 入口文件使用协商缓存,静态资源文件通过内容哈希实现长期强缓存。这种策略能在保证用户始终获取最新版本的同时,最大化利用缓存提升加载速度。
核心思路:
分层缓存策略:
| 资源类型 | 缓存策略 | Cache-Control | 原因 |
|---|---|---|---|
index.html | 协商缓存 | no-cache | 入口必须最新,才能引用到正确的哈希资源 |
app.a1b2c3d4.js | 强缓存 1 年 | max-age=31536000, immutable | 内容变则 hash 变,URL 自然更新 |
style.e5f6g7h8.css | 强缓存 1 年 | max-age=31536000, immutable | 同上 |
logo.png | 强缓存 1 年 | max-age=31536000 | 图片通常不频繁更新 |
/api/* | 不缓存 | no-store | API 数据实时性要求高 |
| 用户隐私数据 | 不缓存 | private, no-store | 安全要求,不允许 CDN 缓存 |
完整的构建 + 部署配置:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// 内容哈希:内容变 → hash 变 → URL 变 → 缓存自动失效
entryFileNames: 'assets/js/[name].[hash].js',
chunkFileNames: 'assets/js/[name].[hash].js',
assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
// 合理分包:第三方库单独打包,变化频率低,缓存命中率高
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
ui: ['antd'],
},
},
},
},
});
server {
listen 80;
root /usr/share/nginx/html;
# HTML 入口:协商缓存,每次验证
location / {
try_files $uri /index.html;
add_header Cache-Control "no-cache";
}
# 带 hash 的静态资源:强缓存 1 年 + immutable
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 图片/字体
location ~* \.(png|jpg|gif|svg|ico|woff2?|ttf)$ {
add_header Cache-Control "public, max-age=31536000";
}
# API 代理
location /api/ {
proxy_pass http://backend;
add_header Cache-Control "no-store";
}
}
为什么这个策略有效?
发版前:
index.html → <script src="/assets/js/app.a1b2c3d4.js">
^^^^^^^^
旧 hash
发版后:
index.html → <script src="/assets/js/app.e5f6g7h8.js">
^^^^^^^^
新 hash(内容变了)
用户访问 →
1. 请求 index.html → no-cache → 服务器返回新 HTML(或 304)
2. 解析 HTML → 发现引用了 app.e5f6g7h8.js(新 URL)
3. 本地无此 URL 的缓存 → 请求服务器下载新文件
4. 旧的 app.a1b2c3d4.js 自然过期,不再被引用
将第三方库(如 React、Lodash)单独打包为 vendor.[hash].js。因为第三方库版本不常变,所以其 hash 长期不变,用户只需下载业务代码的变更,vendor 文件始终命中缓存。
Q6: Service Worker 缓存和 HTTP 缓存的关系
答案:
Service Worker 缓存和 HTTP 缓存是两套独立的缓存机制,但在浏览器请求资源时有明确的优先级关系。Service Worker 缓存的优先级高于 HTTP 缓存。
请求经过的缓存层级:
核心区别:
| 对比项 | Service Worker 缓存 | HTTP 缓存 |
|---|---|---|
| 控制方 | 开发者完全控制 | 浏览器根据响应头自动管理 |
| 优先级 | 最高 | 较低(SW 之后) |
| 缓存策略 | 代码中自定义(Cache First / Network First 等) | Cache-Control / ETag / Last-Modified |
| 离线支持 | 支持(核心优势) | 不支持 |
| 生命周期 | 手动管理(caches.delete) | 根据 HTTP 头部自动管理 |
| 作用范围 | 同源下所有页面 | 单个资源 |
| 更新机制 | SW 文件变化 → 安装新 SW → activate 时清理旧缓存 | 过期后自动协商 |
两者配合的最佳实践:
const CACHE_NAME = 'app-v2';
const STATIC_ASSETS = ['/index.html', '/offline.html'];
// 安装阶段:预缓存关键资源
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
});
// 激活阶段:清理旧版本缓存
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
});
// 请求拦截:根据资源类型选择策略
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API 请求:Network First(优先网络,失败用缓存)
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.[a-f0-9]{8}\.(js|css)$/)) {
// 带 hash 的静态资源:Cache First(优先缓存)
// 因为 HTTP 层已经有 immutable 强缓存,SW 做双重保障
event.respondWith(cacheFirst(event.request));
} else if (url.pathname.endsWith('.html') || url.pathname === '/') {
// HTML 入口:Network First(确保最新)
event.respondWith(networkFirstWithFallback(event.request));
}
});
async function cacheFirst(request: Request): Promise<Response> {
const cached = await caches.match(request);
return cached || fetch(request);
}
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
return (await caches.match(request)) || new Response('Offline', { status: 503 });
}
}
async function networkFirstWithFallback(request: Request): Promise<Response> {
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 || caches.match('/offline.html') as Promise<Response>;
}
}
SW 可能绕过 HTTP 缓存:如果 SW 使用 Cache First 策略缓存了 HTML,那么即使服务器已更新,用户仍会看到旧版本。因此 HTML 在 SW 中必须使用 Network First 策略。
// ❌ 错误:HTML 使用 Cache First,会导致永远无法更新
event.respondWith(cacheFirst(event.request)); // 对 HTML 不要这样做
// ✅ 正确:HTML 使用 Network First
event.respondWith(networkFirst(event.request));
SW 缓存与 HTTP 缓存的交互关系:
当 Service Worker 调用 fetch() 请求资源时,这个请求仍然会经过 HTTP 缓存层:
Q7: CDN 缓存和浏览器缓存如何配合?
答案:
CDN(内容分发网络)缓存和浏览器缓存是两级缓存的关系。CDN 缓存在服务端边缘节点,浏览器缓存在客户端本地。两者通过 HTTP 响应头协调工作。
请求链路:
CDN 缓存 vs 浏览器缓存:
| 对比项 | CDN 缓存 | 浏览器缓存 |
|---|---|---|
| 缓存位置 | CDN 边缘节点(服务端) | 用户设备(客户端) |
| 服务对象 | 所有用户共享 | 单个用户私有 |
| 控制字段 | s-maxage、Cache-Control: public | max-age、Cache-Control: private |
| 更新方式 | 主动刷新 CDN 缓存(Purge) | 等待过期或强制刷新 |
| 网络请求 | 需要请求,但距离近、延迟低 | 不需要网络请求(强缓存时) |
| 安全性 | 不能缓存含用户隐私的内容 | 可以缓存私有内容 |
Cache-Control 中 CDN 相关指令:
# s-maxage:CDN 专用缓存时间(覆盖 max-age)
Cache-Control: public, max-age=3600, s-maxage=86400
# 浏览器缓存 1 小时,CDN 缓存 1 天
# public:允许 CDN 缓存
Cache-Control: public, max-age=31536000
# CDN 和浏览器都可以缓存
# private:禁止 CDN 缓存
Cache-Control: private, max-age=3600
# 只有浏览器可以缓存(如:含用户信息的页面)
分层缓存策略配置:
import express from 'express';
const app = express();
app.use((req, res, next) => {
const url = req.url;
if (url.endsWith('.html') || url === '/') {
// HTML:浏览器不缓存,CDN 短期缓存(方便回源刷新)
res.setHeader('Cache-Control', 'no-cache, s-maxage=60');
// CDN 缓存 60 秒,发版时主动 Purge CDN
} else if (/\.[a-f0-9]{8}\.(js|css)$/.test(url)) {
// 带 hash 的资源:浏览器和 CDN 都长期缓存
res.setHeader('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
} else if (/\.(png|jpg|gif|svg|woff2?)$/.test(url)) {
// 图片字体:浏览器长期缓存,CDN 缓存 7 天
res.setHeader('Cache-Control', 'public, max-age=31536000, s-maxage=604800');
} else if (url.startsWith('/api/')) {
// API:都不缓存
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('CDN-Cache-Control', 'no-store');
}
next();
});
CDN 缓存刷新(Purge)的时机:
interface CDNPurgeOptions {
urls?: string[];
paths?: string[];
all?: boolean;
}
async function purgeAfterDeploy(): Promise<void> {
// 1. 部署新版本后,刷新 HTML 文件的 CDN 缓存
await purgeCDNCache({
urls: [
'https://www.example.com/',
'https://www.example.com/index.html',
],
});
// 2. 带 hash 的资源无需刷新(新 URL = 新资源,旧 URL 自然过期)
console.log('CDN 缓存刷新完成');
}
async function purgeCDNCache(options: CDNPurgeOptions): Promise<void> {
// 调用 CDN 服务商 API(如阿里云、Cloudflare、AWS CloudFront)
const response = await fetch('https://cdn-api.example.com/purge', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.CDN_TOKEN}` },
body: JSON.stringify(options),
});
if (!response.ok) {
throw new Error(`CDN 刷新失败: ${response.statusText}`);
}
}
- HTML 文件:浏览器
no-cache+ CDNs-maxage=60,发版后主动 Purge - 带 hash 的资源:浏览器和 CDN 都
max-age=31536000,无需 Purge - API 接口:
private, no-store,禁止 CDN 缓存 - 用户隐私页面:
private,确保 CDN 不缓存
Q8: 如何解决缓存导致的发版后用户看到旧页面的问题?
答案:
发版后用户看到旧页面是前端部署中最常见的问题之一。根本原因是 HTML 入口文件被缓存,导致引用的仍然是旧版本的资源。解决方案需要从构建、部署、缓存配置三个层面综合处理。
问题分析:
完整解决方案:
方案一:HTML 使用协商缓存 + 资源文件使用内容哈希(核心方案)
# HTML 文件:每次都向服务器验证
location / {
try_files $uri /index.html;
add_header Cache-Control "no-cache";
# 双重保险:旧版浏览器兼容
add_header Pragma "no-cache";
}
# 带 hash 的资源:强缓存
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
方案二:部署时自动刷新 CDN 缓存
import { execSync } from 'child_process';
interface DeployConfig {
cdnDomain: string;
purgeUrls: string[];
}
async function deploy(config: DeployConfig): Promise<void> {
// 1. 构建
console.log('构建项目...');
execSync('pnpm build', { stdio: 'inherit' });
// 2. 上传静态资源到 CDN/OSS
console.log('上传资源...');
execSync('aws s3 sync dist/ s3://my-bucket/ --cache-control "max-age=31536000"', {
stdio: 'inherit',
});
// 3. 上传 HTML(覆盖旧文件)
execSync(
'aws s3 cp dist/index.html s3://my-bucket/index.html --cache-control "no-cache"',
{ stdio: 'inherit' }
);
// 4. 刷新 CDN 缓存(关键步骤!)
console.log('刷新 CDN 缓存...');
await purgeCDN(config.purgeUrls);
console.log('部署完成!');
}
async function purgeCDN(urls: string[]): Promise<void> {
const response = await fetch('https://cdn-api.example.com/purge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.CDN_API_KEY}`,
},
body: JSON.stringify({ urls }),
});
if (!response.ok) {
throw new Error(`CDN Purge 失败: ${response.statusText}`);
}
}
deploy({
cdnDomain: 'https://cdn.example.com',
purgeUrls: [
'https://cdn.example.com/',
'https://cdn.example.com/index.html',
],
});
方案三:Service Worker 版本管理
const CACHE_VERSION = 'v20260228'; // 每次发版更新版本号
const CACHE_NAME = `app-${CACHE_VERSION}`;
// 安装新 SW 后立即激活
self.addEventListener('install', (event: ExtendableEvent) => {
self.skipWaiting(); // 跳过等待,立即激活新 SW
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
cache.addAll(['/index.html', '/offline.html'])
)
);
});
// 激活时清理旧版本缓存
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME) // 删除所有旧版本缓存
.map((name) => caches.delete(name))
)
).then(() => {
return self.clients.claim(); // 立即接管所有页面
})
);
});
// 注册 SW 并检测更新
async function registerSW(): Promise<void> {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.register('/sw.js');
// 检测到新 SW 可用时提示用户
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// 提示用户刷新页面获取最新版本
if (confirm('新版本已就绪,是否刷新页面?')) {
window.location.reload();
}
}
});
});
}
registerSW();
方案四:版本检测 + 强制刷新(兜底方案)
interface VersionInfo {
version: string;
buildTime: string;
}
class VersionChecker {
private currentVersion: string;
private checkInterval: number;
private timer: ReturnType<typeof setInterval> | null = null;
constructor(currentVersion: string, checkInterval = 5 * 60 * 1000) {
this.currentVersion = currentVersion;
this.checkInterval = checkInterval;
}
start(): void {
// 定期轮询版本文件
this.timer = setInterval(() => this.check(), this.checkInterval);
// 页面可见时也检查一次(用户切回标签页)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.check();
}
});
}
stop(): void {
if (this.timer) clearInterval(this.timer);
}
private async check(): Promise<void> {
try {
// 请求版本文件,跳过所有缓存
const res = await fetch('/version.json', { cache: 'no-store' });
const info: VersionInfo = await res.json();
if (info.version !== this.currentVersion) {
this.notifyUpdate(info.version);
}
} catch {
// 网络异常,静默失败
}
}
private notifyUpdate(newVersion: string): void {
console.log(`检测到新版本: ${newVersion},当前版本: ${this.currentVersion}`);
// 方式一:弹窗提示
if (confirm(`发现新版本 ${newVersion},是否刷新页面?`)) {
window.location.reload();
}
// 方式二:Toast 提示 + 手动刷新按钮(用户体验更好)
// showToast({ message: '发现新版本', action: () => location.reload() });
}
}
// 使用:在入口文件中启动
const checker = new VersionChecker(__APP_VERSION__);
checker.start();
{
"version": "1.2.3",
"buildTime": "2026-02-28T10:00:00Z"
}
- 只改 JS 不改 HTML:即使 JS 文件带了 hash,如果 HTML 被强缓存,用户仍然加载旧 HTML 中引用的旧 JS
- CDN 缓存未刷新:部署新版本后忘记 Purge CDN,导致 CDN 返回旧 HTML
- SW 缓存了 HTML:Service Worker 用 Cache First 缓存了 HTML,导致永远返回旧版本
?v=参数方案的缺陷:app.js?v=1.0.1方式需要手动维护版本号,且部分 CDN 不识别 query string
各方案对比:
| 方案 | 可靠性 | 实时性 | 用户体验 | 实现成本 |
|---|---|---|---|---|
| HTML no-cache + hash | 高 | 高 | 无感 | 低 |
| CDN Purge | 高 | 中(有延迟) | 无感 | 中 |
| SW 版本管理 | 高 | 中 | 需提示刷新 | 中 |
| 版本检测轮询 | 中 | 中 | 需提示刷新 | 低 |
| 组合使用(推荐) | 最高 | 高 | 较好 | 中 |
- 必选:HTML
no-cache+ 资源文件内容哈希 - 必选:部署后 Purge CDN
- 推荐:版本检测机制(兜底)
- 可选:Service Worker 版本管理(需要离线能力时)