前端老鸟血泪史:搞定缓存管理让页面秒开,告别用户投诉与性能焦虑
- 前端老鸟血泪史:搞定缓存管理让页面秒开,告别用户投诉与性能焦虑
前端老鸟血泪史:搞定缓存管理让页面秒开,告别用户投诉与性能焦虑
说实话,干前端这些年,最让我血压飙升的不是IE兼容,也不是CSS居中,而是每次发完版用户群里突然炸出一堆"怎么还是老界面"、“按钮点了没反应"的哀嚎。你这边刚部署完热乎的新代码,那边用户浏览器里还躺着三天前的"陈年老缓存”,跟博物馆展品似的纹丝不动。
最气人的是啥?是你明明在群里@全体成员说"清一下缓存再试",结果人家回你一句"怎么清?我不会啊"。那一刻你真的想顺着网线爬过去帮他按F5。所以今天咱们就把这破缓存的底裤扒干净,从HTTP协议底层到浏览器DevTools排查,从Webpack配置到Service Worker骚操作,全是我在生产环境摔过跤、流过泪总结出来的真东西。
用户喊没更新?先搞懂浏览器到底在缓存个啥
咱们先从一个真实的惨案说起。去年双十一,我们电商团队搞了个大促页面,凌晨两点紧急修复了一个支付按钮点不了的致命Bug。代码推上去了,CDN刷新了,测试环境验证三遍没问题,结果早上八点客服工单炸了——“支付按钮还是灰的”。
我远程连上用户电脑一看,Chrome控制台里那个JS文件名还是main.a3f2b1c.js,而我们新版本应该是main.8d9e4f2.js。用户浏览器里躺着的那个a3f2b1c,是三天前的"老古董"。那一刻我悟了:浏览器缓存不是 feature,是 bug 的温床。
但话说回来,没有缓存也不行。你想啊,每次打开淘宝都要重新下载那几MB的JS代码?用户流量不要钱的吗?服务器带宽是无限的吗?所以缓存这玩意儿就是个双刃剑,用好了是"德芙般的丝滑",用不好就是"线上事故的罪魁祸首"。
浏览器缓存其实分好几层,你得搞清楚每层管啥、存多久、怎么清。最底层是HTTP缓存,这是服务器和浏览器之间的"君子协定";中间层是存储API,LocalStorage、SessionStorage这些,相当于浏览器给你的"小仓库";最顶层是Service Worker,这货就是个"中间商",能拦截请求、离线缓存,甚至能在你断网时假装一切正常。
HTTP缓存协议:Cache-Control、ETag那些不得不说的爱恨情仇
先聊HTTP缓存,这是基础中的基础。服务器通过响应头告诉浏览器:“这玩意儿你可以存多久”,浏览器下次请求时先看本地有没有、过期没过期,再决定要不要找服务器要新数据。
强缓存:max-age说了算,服务器都懒得理你
强缓存就是浏览器"自作主张"——我看本地有,而且没过期,那就直接用,连请求都不会发给服务器。这时候你看Network面板,状态码是200 (from disk cache)或者200 (from memory cache),后面那个括号里的内容就是强缓存的标志。
怎么控制强缓存?主要靠这两个响应头:
// 服务器返回的响应头示例
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: max-age=31536000 // 一年,单位是秒
Expires: Wed, 21 Oct 2026 07:28:00 GMT // 过期时间点,老规范了,现在基本用Cache-Control
max-age是个相对时间,从浏览器收到响应那一刻开始倒计时。比如max-age=3600就是缓存一小时。这里有个坑:如果你设置了max-age,但用户电脑时间不准(比如快了两天),那缓存可能提前失效或者该失效没失效。所以现代浏览器都推荐用Cache-Control,不要用Expires。
还有几个常用的Cache-Control指令得记牢:
// 这些指令可以组合使用
Cache-Control: public // 任何人都能缓存,包括CDN等中间代理
Cache-Control: private // 只有浏览器能缓存,中间代理不能存
Cache-Control: no-cache // 可以缓存,但每次用之前必须问服务器能不能用(协商缓存)
Cache-Control: no-store // 绝对禁止缓存,每次都从服务器拿新的
Cache-Control: immutable // 这资源永远不会变,浏览器你放心缓存,连刷新都不用重新验证
注意no-cache和no-store的区别,很多人搞混。no-cache不是不能缓存,而是缓存了但要先问服务器;no-store才是压根儿不许存,适合那种敏感数据或者实时性极强的接口。
协商缓存:带着ETag去服务器"对暗号"
如果强缓存过期了,浏览器不会直接抛弃缓存,而是抱着一丝希望去问服务器:"我这儿有个去年的文件,还能用不?"这就是协商缓存。这时候浏览器会带上两个请求头之一:
// 浏览器发送的请求头
GET /api/user/profile HTTP/1.1
Host: example.com
If-None-Match: "33a64df5" // 对应ETag,优先级更高
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT // 对应Last-Modified,备用
服务器收到后,对比ETag(资源的唯一标识,通常是文件内容的hash)或者最后修改时间。如果资源没变,就返回304 Not Modified,告诉浏览器:“你手里那份还能用,接着缓存吧”。这时候虽然没传文件内容,但好歹还是发了一次HTTP请求,比强缓存慢那么一丢丢。
如果资源变了,服务器就正常返回200和新内容,浏览器更新缓存。
// Node.js里生成ETag的简单示例
const crypto = require('crypto');
const fs = require('fs');
function generateETag(filePath) {
const content = fs.readFileSync(filePath);
// 用MD5算hash,生产环境建议用更安全的算法
const hash = crypto.createHash('md5').update(content).digest('hex');
return `"${hash}"`; // ETag标准格式要带引号
}
// Express中间件示例
app.get('/static/*', (req, res) => {
const filePath = path.join(__dirname, req.params[0]);
const etag = generateETag(filePath);
// 检查客户端发来的If-None-Match
if (req.headers['if-none-match'] === etag) {
res.status(304).end(); // 没变化,别传了
return;
}
// 有变化,发新的,带上ETag
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'max-age=3600');
res.sendFile(filePath);
});
这里有个细节:ETag优先级高于Last-Modified。因为文件修改时间可能不准(比如只是touch了一下文件,内容没变),而且精度只能到秒,高并发场景下可能出问题。所以现代Web应用基本都用ETag。
缓存策略怎么配?看资源类型下菜碟
不同类型的资源,缓存策略完全不同,别一刀切:
// HTML入口文件 - 千万别缓存,或者只缓存很短时间
// 因为HTML里引用的JS/CSS文件名可能变了(带hash的),HTML本身得是最新的
Cache-Control: no-cache // 或者 max-age=0,每次都要协商验证
// 带hash的静态资源(JS/CSS/图片)- 永久缓存,因为文件名变了就是新资源
Cache-Control: max-age=31536000, immutable // 一年,且标记为不可变
// API接口 - 看业务场景
// 用户个人信息 - 短时间缓存或协商缓存
Cache-Control: private, max-age=60 // 1分钟,且只有浏览器能存
// 实时数据 - 别缓存
Cache-Control: no-store
// 配置类接口 - 可以缓存久一点
Cache-Control: max-age=3600
为啥HTML不能长期缓存?假设你配了max-age=86400(一天),用户今天访问过,明天再来,浏览器直接拿缓存的HTML,里面引用的还是昨天的main.a3f2b1c.js,而你服务器上早就换成main.8d9e4f2.js了。用户看到的就是"旧界面",因为HTML里的script src没更新。
本地存储三兄弟:LocalStorage、SessionStorage、IndexedDB
HTTP缓存是浏览器自动管的,但有时候咱们需要手动存点数据,这时候就得请出本地存储三兄弟。
LocalStorage:永久仓库,但别塞太满
LocalStorage就是个简单的key-value存储,永久有效,除非用户手动清或者代码删除。容量一般是5MB左右(不同浏览器有差异),存字符串。
适合存啥?用户偏好设置、主题颜色、JWT token(虽然安全性有争议,但短时效token可以存)、一些不常变的配置数据。
// LocalStorage基础操作,简单到令人发指
// 存数据 - 只能存字符串,对象得JSON化
const userPrefs = { theme: 'dark', fontSize: 14, sidebarCollapsed: false };
localStorage.setItem('user_prefs', JSON.stringify(userPrefs));
// 取数据 - 记得做异常处理,用户可能手贱在控制台瞎改
try {
const stored = localStorage.getItem('user_prefs');
const prefs = stored ? JSON.parse(stored) : null;
console.log('用户偏好:', prefs);
} catch (e) {
console.error('LocalStorage数据解析失败,可能是用户手动改坏了', e);
localStorage.removeItem('user_prefs'); // 坏了就清掉,别留着害人
}
// 删单个
localStorage.removeItem('user_prefs');
// 清空所有(慎用!)
// localStorage.clear();
// 检查容量用了多少(粗略估算)
function getLocalStorageSize() {
let total = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
total += localStorage[key].length + key.length;
}
}
return (total / 1024).toFixed(2) + ' KB';
}
console.log('LocalStorage已用:', getLocalStorageSize());
坑点预警:LocalStorage是同步阻塞的,存取大数据会卡主线程。而且它是同源策略,a.com和b.com的LocalStorage完全不互通,子域名也不行(比如a.example.com和b.example.com默认不共享,得特殊处理)。
SessionStorage:会话级临时工
SessionStorage和LocalStorage API一模一样,但生命周期不同——页面关闭就清空。适合存临时状态,比如表单填写到一半的数据、当前页面的筛选条件,用户不小心刷新页面还能恢复,但关了标签页就自动清理,不污染长期存储。
// 表单自动保存草稿功能
const form = document.getElementById('long-form');
const saveDraft = () => {
const formData = new FormData(form);
const data = Object.fromEntries(formData);
sessionStorage.setItem('form_draft', JSON.stringify(data));
console.log('草稿已保存到SessionStorage');
};
// 用户输入时自动保存,别用input事件太频繁,debounce一下
let timeout;
form.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(saveDraft, 1000); // 停输入1秒后保存
});
// 页面加载时恢复草稿
window.addEventListener('load', () => {
const draft = sessionStorage.getItem('form_draft');
if (draft) {
const data = JSON.parse(draft);
// 回填表单...
Object.keys(data).forEach(key => {
const input = form.elements[key];
if (input) input.value = data[key];
});
// 提示用户恢复了草稿
showToast('已恢复上次未提交的草稿');
}
});
// 提交成功后清理草稿
form.addEventListener('submit', () => {
sessionStorage.removeItem('form_draft');
});
IndexedDB:大文件专用仓库
LocalStorage那5MB塞张大图就满了,这时候得请出IndexedDB。这是浏览器里的NoSQL数据库,可以存结构化数据、二进制文件(Blob、ArrayBuffer),容量通常是硬盘空间的50%左右,相当大。
适合存啥?离线可用的图片、视频缓存、大量结构化数据(比如离线可用的通讯录)、PWA的离线资源。
// IndexedDB操作比较繁琐,封装个工具类
class IndexedDBHelper {
constructor(dbName, version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
// 打开数据库
open(storeName, keyPath = 'id') {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
// 首次创建或版本升级时触发
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建对象存储空间(类似表),指定主键
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath });
console.log(`创建对象存储: ${storeName}`);
}
};
});
}
// 添加或更新数据
put(storeName, data) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(data); // put是upsert,add是仅新增
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 查询单条
get(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 查询所有
getAll(storeName) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// 删除
delete(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
// 使用示例:缓存图片文件
const imageDB = new IndexedDBHelper('ImageCacheDB', 1);
async function cacheImage(imageId, blob) {
await imageDB.open('images');
await imageDB.put('images', {
id: imageId,
blob: blob,
timestamp: Date.now(),
size: blob.size
});
console.log(`图片 ${imageId} 已缓存到IndexedDB`);
}
async function getCachedImage(imageId) {
await imageDB.open('images');
const record = await imageDB.get('images', imageId);
if (!record) return null;
// 检查是否过期(比如缓存7天)
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - record.timestamp > sevenDays) {
await imageDB.delete('images', imageId);
console.log(`图片 ${imageId} 已过期,清理掉`);
return null;
}
// 返回Blob URL供img标签使用
return URL.createObjectURL(record.blob);
}
// 实际使用:先查缓存,没有再fetch
async function loadProductImage(productId) {
const img = document.getElementById(`product-img-${productId}`);
// 1. 先查IndexedDB
const cachedUrl = await getCachedImage(productId);
if (cachedUrl) {
img.src = cachedUrl;
console.log('从IndexedDB缓存加载图片');
return;
}
// 2. 缓存没有,fetch网络
try {
const response = await fetch(`/api/products/${productId}/image`);
const blob = await response.blob();
// 3. 存到IndexedDB供下次使用
await cacheImage(productId, blob);
// 4. 显示图片
img.src = URL.createObjectURL(blob);
} catch (err) {
console.error('图片加载失败:', err);
img.src = '/assets/fallback-image.png';
}
}
IndexedDB虽然强大,但API确实啰嗦,都是事件回调风格。建议生产环境用封装库比如idb(一个Promise封装的轻量库),或者localForage(类似LocalStorage的API但底层用IndexedDB)。
Service Worker:离线缓存的"中间商"
如果说HTTP缓存和LocalStorage是"正规军",那Service Worker就是能离线耍大牌的"特种兵"。这货是个独立的JS线程,能拦截网络请求、操作缓存、甚至在你断网时返回缓存数据假装一切正常。
基础生命周期:注册、安装、激活
Service Worker有自己的生命周期,得先注册,然后安装,最后激活。而且首次注册后需要刷新页面才能生效(或者等之前的SW失效)。
// 主页面代码:注册Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/' // 控制范围,默认是sw.js所在目录
});
console.log('SW注册成功:', registration.scope);
// 监听SW更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('发现新版本SW:', newWorker);
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用,提示用户刷新
showUpdateNotification(newWorker);
}
});
});
} catch (err) {
console.error('SW注册失败:', err);
}
});
}
// 提示用户刷新以更新版本
function showUpdateNotification(worker) {
const toast = document.createElement('div');
toast.innerHTML = `
<div style="position:fixed;top:20px;right:20px;background:#1890ff;color:white;padding:16px;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:9999;">
新版本已就绪,<button id="refresh-sw" style="background:white;color:#1890ff;border:none;padding:4px 8px;border-radius:2px;cursor:pointer;">点击刷新</button> 更新
</div>
`;
document.body.appendChild(toast);
document.getElementById('refresh-sw').addEventListener('click', () => {
worker.postMessage({ type: 'SKIP_WAITING' }); // 告诉SW跳过等待
window.location.reload();
});
}
SW脚本:拦截请求、操作缓存
// sw.js - Service Worker脚本
const CACHE_NAME = 'my-app-v1'; // 版本号很重要,更新时改名字
const STATIC_ASSETS = [
'/',
'/index.html',
'/static/js/main.js',
'/static/css/style.css',
'/assets/logo.png',
'/offline.html' // 离线 fallback 页面
];
// 安装阶段:预缓存核心资源
self.addEventListener('install', (event) => {
console.log('SW安装中...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('预缓存静态资源:', STATIC_ASSETS);
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting()) // 跳过等待,立即激活
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
console.log('SW激活中...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME) // 找到旧版本缓存
.map(name => {
console.log('清理旧缓存:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim()) // 立即控制所有客户端
);
});
// 拦截请求:缓存优先 or 网络优先?
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 策略1:导航请求(HTML页面)- 网络优先,失败回退缓存
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then(response => {
// 网络成功,更新缓存
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
})
.catch(() => {
// 网络失败,尝试缓存,再失败就显示离线页面
return caches.match(request)
.then(cached => cached || caches.match('/offline.html'));
})
);
return;
}
// 策略2:静态资源(JS/CSS/图片)- 缓存优先,失败再网络
if (url.pathname.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
event.respondWith(
caches.match(request).then(cached => {
if (cached) {
console.log('SW缓存命中:', url.pathname);
return cached;
}
// 缓存没有,去网络拿
return fetch(request).then(response => {
// 存到缓存供下次使用
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return response;
});
})
);
return;
}
// 策略3:API请求 - 网络优先,但超时后使用缓存(Stale-While-Revalidate模式)
if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
// 先尝试网络,但设置超时
const networkFetch = fetch(request).then(response => {
// 网络成功,更新缓存
cache.put(request, response.clone());
return response;
}).catch(() => {
// 网络失败,返回缓存(如果有)
return cache.match(request).then(cached => {
if (cached) {
console.log('API网络失败,使用缓存数据:', url.pathname);
// 标记为来自缓存,前端可以知道数据可能过期
const headers = new Headers(cached.headers);
headers.append('X-From-Cache', 'true');
return new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers
});
}
throw new Error('Network and cache both failed');
});
});
// 设置3秒超时
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 3000)
);
return Promise.race([networkFetch, timeout]).catch(() =>
cache.match(request) // 超时后也尝试缓存
);
})
);
return;
}
// 其他请求:默认走网络
event.respondWith(fetch(request));
});
// 监听主页面发来的消息
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
这里有几个关键点:
-
版本管理:
CACHE_NAME带版本号,更新SW时改名字,激活阶段自动清理旧缓存。别用同一个名字,否则新资源永远进不去旧缓存。 -
缓存策略选择:
- Cache First:适合静态资源,不常变的
- Network First:适合HTML和API,要最新的
- Stale-While-Revalidate:先返回缓存(快),同时后台更新(准),下次用新的
-
CORS问题:如果缓存的是跨域资源(比如CDN上的图片),要确保响应头有
Access-Control-Allow-Origin,否则cache.add()会报错。
构建工具配合:Webpack/Vite的文件名哈希策略
前面说了这么多缓存策略,但有个根本问题没解决:我怎么让用户拿到最新的JS/CSS文件?如果文件名一直是main.js,浏览器怎么知道这是新的还是旧的?
现代构建工具的解决方案是文件名哈希——根据文件内容生成hash,内容变了文件名就变,内容没变文件名不变。这样你可以放心给这些带hash的文件配Cache-Control: immutable,永久缓存,因为文件名变了自然就是新请求。
Webpack配置
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
// [contenthash] 就是根据文件内容生成的hash,8位长度
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js', // 动态导入的代码块
assetModuleFilename: 'assets/[name].[contenthash:8][ext]', // 图片字体等
// 清理旧文件
clean: true
},
optimization: {
// 分离runtime代码(webpack模块加载器),因为这部分经常变
runtimeChunk: 'single',
// 代码分割,把第三方库单独打包
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
// 第三方库不常变,可以配更激进的缓存
priority: 10
},
common: {
minChunks: 2, // 被2个以上chunk引用就抽离
chunks: 'all',
enforce: true
}
}
}
},
plugins: [
// 自动生成HTML,并注入带hash的资源路径
new HtmlWebpackPlugin({
template: './public/index.html',
// HTML本身不要缓存太久,或者配no-cache
filename: 'index.html'
}),
// 清理旧的hash文件
new CleanWebpackPlugin()
]
};
Vite配置(更简单)
// vite.config.js
import { defineConfig } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html';
export default defineConfig({
build: {
// 输出目录
outDir: 'dist',
// 资源文件名带hash
rollupOptions: {
output: {
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: (assetInfo) => {
const info = assetInfo.name.split('.');
const ext = info[info.length - 1];
// 按类型分目录
if (/\.(png|jpe?g|gif|svg|webp|ico)$/i.test(assetInfo.name)) {
return 'images/[name]-[hash][extname]';
}
if (/\.(woff2?|eot|ttf|otf)$/i.test(assetInfo.name)) {
return 'fonts/[name]-[hash][extname]';
}
if (/\.css$/i.test(assetInfo.name)) {
return 'css/[name]-[hash][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
},
// 小于此值的资源内联为base64,减少请求数
assetsInlineLimit: 4096, // 4kb
},
plugins: [
createHtmlPlugin({
minify: true,
// 确保HTML注入的资源路径正确
inject: {
data: {
title: 'My App'
}
}
})
]
});
服务器配合:Nginx缓存配置
构建工具生成了带hash的文件,服务器也得配合好缓存策略:
# nginx.conf
server {
listen 80;
server_name example.com;
root /var/www/dist;
# HTML入口文件 - 不缓存或短时间缓存
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# 带hash的静态资源 - 永久缓存(1年)
# 正则匹配含有hash特征的文件名(8-16位十六进制)
location ~* \.[a-f0-9]{8,16}\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
# 开启gzip压缩
gzip_static on;
}
# 不带hash的资源(比如public目录直接copy的)- 短时间缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg)$ {
add_header Cache-Control "public, max-age=3600";
expires 1h;
}
# API接口 - 不缓存
location /api/ {
add_header Cache-Control "no-store";
proxy_pass http://backend;
}
# 处理前端路由(SPA)
location / {
try_files $uri $uri/ /index.html;
}
}
这套组合拳下来:HTML每次最新(no-cache),带hash的JS/CSS永久缓存(文件名变了自动失效),API不缓存。完美解决"发版了用户还是旧界面"的问题。
SPA路由缓存坑:History模式和刷新问题
单页应用(SPA)用React Router或Vue Router时,有个经典坑:用户访问/user/profile,刷新页面时浏览器实际请求的是服务器上的/user/profile路径,但服务器上只有index.html,没有user/profile这个目录,直接404。
解决方案就是上面Nginx配置里的try_files,把所有路由都指回index.html,让前端路由接管。但这里又有个缓存问题:如果用户之前访问过/user/profile,浏览器可能缓存了那个404响应!
// 确保SPA路由返回的HTML也不缓存,否则用户可能拿到旧的index.html
// 在Nginx里统一处理:
location / {
try_files $uri $uri/ /index.html;
# 关键:index.html本身不能缓存太久!
add_header Cache-Control "no-cache";
}
还有种更隐蔽的坑:有些CDN或云服务商(比如AWS CloudFront)默认会缓存404响应,你得显式配置不缓存错误状态码,或者给SPA路由单独配行为。
状态管理里的伪缓存:Redux/Pinia数据持久化
除了HTTP和浏览器缓存,咱们前端自己也可以在状态管理层做"伪缓存"——把已经请求过的数据存起来,避免重复请求。
Redux Toolkit + RTK Query
// store.js
import { configureStore } from '@reduxjs/toolkit';
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// 创建API slice,自带缓存机制
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
// 自定义fetch,可以加上统一错误处理
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) headers.set('authorization', `Bearer ${token}`);
return headers;
}
}),
// 缓存标签,用于手动失效缓存
tagTypes: ['User', 'Post', 'Comment'],
endpoints: (builder) => ({
// 查询用户列表 - 默认缓存5分钟
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'],
// 自定义缓存时间(秒)
keepUnusedDataFor: 300,
}),
// 查询单个用户 - 参数变化会重新请求,相同参数走缓存
getUserById: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
// 更新用户 - 自动失效相关缓存
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
// 更新成功后,标记User标签失效,下次自动重新获取
invalidatesTags: (result, error, { id }) => [
{ type: 'User', id },
'User' // 也失效列表缓存
],
}),
// 条件查询:跳过缓存的情况
getRealTimeData: builder.query({
query: () => '/realtime',
// 完全跳过缓存,每次都要新数据
keepUnusedDataFor: 0,
}),
}),
});
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useUpdateUserMutation
} = apiSlice;
// 配置store
export const store = configureStore({
reducer: {
[apiSlice.reducerPath]: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware),
});
RTK Query的缓存策略很智能:组件unmount后数据还会保留一段时间(默认60秒),如果另一个组件马上需要同样数据,直接从缓存拿。你可以通过keepUnusedDataFor调整这个时间。
Pinia + 持久化插件
Vue生态用Pinia更爽,配合持久化插件可以把状态存到LocalStorage:
// stores/user.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
// State
const profile = ref(null);
const lastFetchTime = ref(0);
const isLoading = ref(false);
// 计算属性:数据是否过期(5分钟内有效)
const isCacheValid = computed(() => {
if (!profile.value) return false;
return Date.now() - lastFetchTime.value < 5 * 60 * 1000;
});
// Actions
async function fetchProfile(force = false) {
// 缓存有效且不强刷,直接返回
if (isCacheValid.value && !force) {
console.log('使用Pinia缓存的用户数据');
return profile.value;
}
isLoading.value = true;
try {
const res = await fetch('/api/user/profile');
const data = await res.json();
profile.value = data;
lastFetchTime.value = Date.now();
return data;
} finally {
isLoading.value = false;
}
}
function clearCache() {
profile.value = null;
lastFetchTime.value = 0;
}
return {
profile,
isLoading,
isCacheValid,
fetchProfile,
clearCache
};
}, {
// 使用pinia-plugin-persistedstate持久化到LocalStorage
persist: {
key: 'user-store',
// 只持久化profile,不存lastFetchTime(时间戳持久化没意义)
paths: ['profile'],
// 自定义序列化,可以加密敏感数据
serializer: {
serialize: (state) => {
// 简单Base64编码,生产环境建议用更安全的加密
const json = JSON.stringify(state);
return btoa(encodeURIComponent(json));
},
deserialize: (str) => {
try {
const json = decodeURIComponent(atob(str));
return JSON.parse(json);
} catch {
return {};
}
}
}
}
});
这种"伪缓存"适合用户数据、配置信息等不常变的数据,减少API请求次数,同时通过lastFetchTime控制过期时间,避免数据太陈旧。
排查玄学问题:DevTools实战技巧
用户说"我清了缓存就好了",但你不可能让每个用户都去清缓存。得学会在DevTools里精准定位问题。
Network面板:看穿缓存的真身
打开F12 -> Network,勾选"Disable cache"(只在DevTools打开时生效,不影响正常用户),然后刷新页面。看这几列关键信息:
- Status:
200是全新请求,304是协商缓存命中,200 (from memory cache)或200 (from disk cache)是强缓存。 - Size:
from disk cache显示(disk cache),from memory cache显示(memory cache),如果是网络请求显示具体大小。 - Time:缓存命中的请求通常<10ms,网络请求看具体耗时。
关键操作:右键表头 -> Response Headers -> 勾选Cache-Control和ETag,直接看响应头。
强制刷新:绕过缓存的几种姿势
- 普通刷新:F5 或 Cmd+R —— 会检查协商缓存,可能发304请求
- 强制刷新:Ctrl+F5 或 Cmd+Shift+R —— 跳过所有缓存,强制重新下载
- 清空缓存并硬性重新加载:右键刷新按钮(Chrome)-> 选这个,清理当前站点的所有缓存再刷新
清除特定域名的缓存
如果用户不会操作,你可以给他一段代码,在控制台执行:
// 清理当前域名所有缓存(Service Worker + Cache API + LocalStorage)
async function clearSiteCache() {
console.log('开始清理站点缓存...');
// 1. 注销Service Worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(r => {
console.log('注销SW:', r.scope);
return r.unregister();
}));
}
// 2. 清理Cache API
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => {
console.log('删除Cache:', name);
return caches.delete(name);
}));
// 3. 清理LocalStorage
localStorage.clear();
console.log('LocalStorage已清空');
// 4. 清理SessionStorage
sessionStorage.clear();
console.log('SessionStorage已清空');
// 5. 清理IndexedDB(谨慎!)
const dbs = await indexedDB.databases();
await Promise.all(dbs.map(db => {
console.log('删除IndexedDB:', db.name);
return indexedDB.deleteDatabase(db.name);
}));
console.log('所有缓存清理完毕,建议刷新页面');
alert('缓存清理完成!请刷新页面');
}
// 执行
clearSiteCache().catch(console.error);
把这段代码做成一个"一键修复"按钮,藏在页面的某个角落(比如按特定组合键触发),或者放在客服发给用户的帮助文档里,能大幅减少工单量。
模拟用户环境:Disable JavaScript和3G网络
有时候缓存问题只在特定网络条件下出现。DevTools -> Network面板:
- Throttling:选"Slow 3G"模拟弱网,看缓存是否按预期降级
- Offline:勾选Offline,测试Service Worker的离线能力
- Disable JavaScript:测试SSR或静态页面的纯HTML缓存
野路子技巧:预加载、版本检测、IndexedDB大图存储
利用Service Worker预加载下一页数据
用户还在看商品列表时,偷偷把详情页的数据缓存好,点进去秒开:
// sw.js 中添加预加载逻辑
self.addEventListener('message', (event) => {
// 主页面发来"用户hover了某个商品链接"的消息
if (event.data.type === 'PREFETCH_PRODUCT') {
const productId = event.data.productId;
const url = `/api/products/${productId}`;
// 提前fetch并缓存
caches.open(CACHE_NAME).then(cache => {
fetch(url).then(response => {
if (response.ok) {
cache.put(url, response.clone());
console.log('预加载商品数据:', productId);
}
}).catch(() => {
// 预加载失败静默处理,不影响主流程
});
});
}
});
主页面配合:
// 商品列表组件
document.querySelectorAll('.product-link').forEach(link => {
// 鼠标悬停时通知SW预加载
link.addEventListener('mouseenter', () => {
const productId = link.dataset.id;
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'PREFETCH_PRODUCT',
productId
});
}
});
});
版本检测机制:自动提示用户刷新
前面提到的SW更新提示比较被动,还可以主动轮询检测:
// version-check.js
const CURRENT_VERSION = '1.2.3'; // 每次发版更新这个
const CHECK_INTERVAL = 5 * 60 * 1000; // 5分钟检查一次
async function checkVersion() {
try {
// 请求一个带版本号的接口,或者请求version.json
const res = await fetch('/version.json?t=' + Date.now(), {
cache: 'no-store' // 确保拿到最新的
});
const { version } = await res.json();
if (version !== CURRENT_VERSION) {
console.log(`发现新版本: ${version} (当前: ${CURRENT_VERSION})`);
showUpdatePrompt(version);
}
} catch (err) {
console.error('版本检查失败:', err);
}
}
function showUpdatePrompt(newVersion) {
// 搞个醒目的提示条
const banner = document.createElement('div');
banner.innerHTML = `
<div style="position:fixed;top:0;left:0;right:0;background:#ff4d4f;color:white;padding:12px;text-align:center;z-index:10000;font-family:sans-serif;">
🎉 新版本 ${newVersion} 已发布!
<button onclick="location.reload(true)" style="background:white;color:#ff4d4f;border:none;padding:6px 16px;border-radius:4px;margin-left:10px;cursor:pointer;font-weight:bold;">
立即刷新更新
</button>
<button onclick="this.parentElement.remove()" style="background:transparent;color:white;border:1px solid white;padding:6px 16px;border-radius:4px;margin-left:10px;cursor:pointer;">
稍后提醒
</button>
</div>
`;
document.body.appendChild(banner);
}
// 页面可见性变化时检查(用户切回页面)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkVersion();
}
});
// 定时检查
setInterval(checkVersion, CHECK_INTERVAL);
// 首次检查
checkVersion();
后端配合,在部署时生成version.json:
// deploy.js 部署脚本片段
const fs = require('fs');
const packageJson = require('./package.json');
// 写入版本信息
fs.writeFileSync('./dist/version.json', JSON.stringify({
version: packageJson.version,
buildTime: new Date().toISOString(),
gitCommit: process.env.GIT_COMMIT || 'unknown'
}));
IndexedDB存大图,解放LocalStorage
前面提过LocalStorage只有5MB,存不了大图。用IndexedDB配合Compression Stream API还能进一步压缩:
// 图片压缩存储工具
class ImageCacheManager {
constructor() {
this.dbName = 'ImageCache';
this.storeName = 'compressedImages';
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
// 用url做主键,自动去重
db.createObjectStore(this.storeName, { keyPath: 'url' });
}
};
request.onsuccess = (e) => {
this.db = e.target.result;
resolve();
};
request.onerror = reject;
});
}
// 压缩图片再存储
async cacheImage(url, imageBlob) {
// 用CompressionStream压缩(Chrome 80+支持)
const compressedStream = imageBlob.stream().pipeThrough(
new CompressionStream('gzip')
);
// 流转Blob
const compressedBlob = await new Response(compressedStream).blob();
const record = {
url,
blob: compressedBlob,
originalSize: imageBlob.size,
compressedSize: compressedBlob.size,
timestamp: Date.now(),
contentType: imageBlob.type
};
const tx = this.db.transaction([this.storeName], 'readwrite');
const store = tx.objectStore(this.storeName);
await store.put(record);
console.log(`图片缓存完成: ${url}, 原大小:${(imageBlob.size/1024).toFixed(1)}KB, 压缩后:${(compressedBlob.size/1024).toFixed(1)}KB`);
}
// 获取并解压
async getImage(url) {
const tx = this.db.transaction([this.storeName], 'readonly');
const store = tx.objectStore(this.storeName);
const record = await store.get(url);
if (!record) return null;
// 检查过期(30天)
if (Date.now() - record.timestamp > 30 * 24 * 60 * 60 * 1000) {
await this.deleteImage(url);
return null;
}
// 解压
const decompressedStream = record.blob.stream().pipeThrough(
new DecompressionStream('gzip')
);
const decompressedBlob = await new Response(decompressedStream).blob();
return {
blob: decompressedBlob,
url: URL.createObjectURL(decompressedBlob)
};
}
async deleteImage(url) {
const tx = this.db.transaction([this.storeName], 'readwrite');
const store = tx.objectStore(this.storeName);
await store.delete(url);
}
}
// 使用
const imageCache = new ImageCacheManager();
await imageCache.init();
// 缓存图片
const response = await fetch('https://example.com/large-image.jpg');
const blob = await response.blob();
await imageCache.cacheImage('https://example.com/large-image.jpg', blob);
// 读取使用
const cached = await imageCache.getImage('https://example.com/large-image.jpg');
if (cached) {
document.getElementById('img').src = cached.url;
}
这套组合拳下来,原本5MB只能存两三张原图,压缩后能存十几二十张,配合LRU清理策略(太久没访问的自动删),基本能满足离线相册、商品大图缓存的需求。
写在最后:缓存是门妥协的艺术
说实话,写了这么多,但缓存这坑根本写不完。每个团队、每个业务场景都有独特的坑点。你可能今天配好了完美的缓存策略,明天产品经理说"这个页面要实时",后天运营说"那个按钮文案要随时改",大后天用户投诉"我明明买了会员怎么还显示普通用户"——一查,是接口缓存了用户状态。
所以记住几个原则:
- 不可变资源(带hash的)放心永久缓存,这是性能提升的大头
- 可变资源(HTML、API)谨慎缓存,宁可牺牲一点性能也别让用户看到旧数据
- 给用户留后路,一键清理、版本检测、强制刷新机制都要有
- 监控和日志,接入Sentry或自研埋点,第一时间发现"缓存导致的数据不一致"
最后说句掏心窝子的:没有人在缓存上能不踩坑,那些看起来云淡风轻的大神,背后都是被凌晨告警炸醒、被客服工单淹没、被老板指着鼻子问"为什么用户看到三天前的价格"的血泪史。咱们能做的就是在每次事故后复盘,把教训变成配置文档、变成监控告警、变成自动化脚本。
行了,今天就唠到这儿。你要是刚踩了什么新鲜热乎的缓存坑,或者有更骚的操作技巧,赶紧在评论区吼一声。咱们一起把这破缓存治得服服帖帖,让用户丝滑得像德芙,让自己睡得像个婴儿——半夜不醒,醒了也能马上接着睡。


1153

被折叠的 条评论
为什么被折叠?



