前端请求缓存实战:策略选择与性能优化经验分享
先看效果,再看代码
我最近在重构一个数据密集型的管理后台,页面里动不动就几十个卡片,每个卡片都要调接口拉数据。最开始直接用 fetch 一请求,结果用户来回切换 tab,同一个接口被疯狂调用,不仅浪费流量,还让后端兄弟半夜给我发消息:“你这前端是不是没做缓存?”
其实请求缓存说白了就是:同样的请求,短时间内别重复发,拿之前的结果就行。但怎么实现才靠谱?我试过好几种方式,最后总结出一套亲测有效的方案,核心代码就这几行:
const requestCache = new Map();
async function cachedFetch(url, options = {}) {
const key = JSON.stringify({ url, ...options });
if (requestCache.has(key)) {
return requestCache.get(key);
}
const promise = fetch(url, options).then(res => res.json());
requestCache.set(key, promise);
return promise;
}
就这么简单。第一次调用时发请求,后续相同参数的调用直接返回之前的 Promise。注意,这里存的是 Promise 对象本身,不是 resolved 后的数据。这样能天然处理“同时多次请求”的情况——比如两个组件同时调用同一个接口,它们会共享同一个 Promise,不会发两次请求。
这个场景最好用
上面的缓存适合「短生命周期 + 高频重复」的场景,比如:
- 用户在列表页和详情页之间反复切换
- 下拉刷新时防止手抖多点几次
- 多个组件依赖同一份基础数据(如用户信息、配置项)
但如果你要做「持久化缓存」,比如离线也能看上次的数据,那得结合 localStorage 或 IndexedDB。不过我一般不这么干,除非产品明确要求。毕竟数据可能过期,缓存反而导致用户看到脏数据,得不偿失。
举个实际例子,我在 jztheme.com 的商品管理页用的就是上面那个内存缓存。商品分类、标签这些基础数据变动不频繁,但页面里到处要用。加了缓存后,F5 刷新除外,日常操作几乎不再发多余请求,Network 面板清爽多了。
踩坑提醒:这三点一定注意
别看代码简单,我在这上面栽过三次跟头,这里重点提醒:
- 缓存 key 的生成要严谨。我一开始只用
url做 key,结果 POST 请求带不同 body,缓存全乱了。后来改成把整个 options 序列化,但要注意:如果 options 里有函数、undefined 或循环引用,JSON.stringify会出问题。所以建议只取关键字段,比如:const key = `${url}|${options.method || 'GET'}|${JSON.stringify(options.body || '')}`。 - 内存泄漏风险。Map 一直存着 Promise,如果页面长期不刷新(比如 SPA 应用),缓存会越积越多。我的做法是加个简易 TTL(Time To Live)机制,比如 5 分钟后自动清除:
const requestCache = new Map();
const cacheTimeouts = new Map();
function setCacheWithTTL(key, value, ttl = 300000) { // 默认5分钟
requestCache.set(key, value);
// 清除旧的定时器
if (cacheTimeouts.has(key)) {
clearTimeout(cacheTimeouts.get(key));
}
// 设置新定时器
const timeoutId = setTimeout(() => {
requestCache.delete(key);
cacheTimeouts.delete(key);
}, ttl);
cacheTimeouts.set(key, timeoutId);
}
async function cachedFetch(url, options = {}) {
const key = JSON.stringify({ url, ...options });
if (requestCache.has(key)) {
return requestCache.get(key);
}
const promise = fetch(url, options).then(res => res.json());
setCacheWithTTL(key, promise); // 用带TTL的设置
return promise;
}
- 错误处理要小心。如果第一次请求失败,Promise 会 reject,后续所有调用都会直接抛错。这通常不是我们想要的——可能网络抖动,用户希望重试。所以我在实际项目中会加一层 retry 和 error fallback:
async function cachedFetchWithRetry(url, options = {}, retries = 2) {
const key = JSON.stringify({ url, ...options });
if (requestCache.has(key)) {
try {
return await requestCache.get(key);
} catch (error) {
// 如果缓存的是 rejected promise,清除它并重试
requestCache.delete(key);
if (cacheTimeouts.has(key)) {
clearTimeout(cacheTimeouts.get(key));
cacheTimeouts.delete(key);
}
}
}
const promise = fetchWithRetry(url, options, retries);
setCacheWithTTL(key, promise);
return promise;
}
async function fetchWithRetry(url, options, retries) {
let lastError;
for (let i = 0; i setTimeout(resolve, 500 * (i + 1))); // 指数退避
}
}
throw lastError;
}
折腾了半天发现,缓存和错误处理必须一起考虑,否则用户体验会很奇怪——比如第一次失败后,用户点十次都报错,根本没机会重试。
高级技巧:按需清理缓存
有时候你需要手动清除缓存,比如用户提交了表单,相关数据肯定变了。这时候可以暴露一个清理方法:
function clearCacheByPrefix(prefix) {
for (const key of requestCache.keys()) {
if (key.startsWith(prefix)) {
requestCache.delete(key);
if (cacheTimeouts.has(key)) {
clearTimeout(cacheTimeouts.get(key));
cacheTimeouts.delete(key);
}
}
}
}
// 使用示例:提交订单后清掉商品相关缓存
await submitOrder();
clearCacheByPrefix('https://jztheme.com/api/products');
或者更精细一点,给每个请求加个 tag,然后按 tag 清理。不过我觉得 prefix 已经够用,太复杂反而增加维护成本。
最后说两句
请求缓存不是银弹,但对提升体验和减轻服务器压力很有帮助。我的经验是:先用最简单的内存缓存解决 80% 的问题,等真遇到性能瓶颈或产品需求再考虑持久化方案。别一上来就搞 SWR 或 React Query,小项目没必要引入额外依赖。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式,比如如何优雅处理 POST 请求的缓存,欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合 Vue 的响应式系统),后续会继续分享这类博客。
