搜索缓存设计与实现的那些坑和优化策略
这玩意儿到底缓不缓,我纠结了三天
上周上线了个搜索功能,用户一搜“手机”,出来一堆结果。再搜一次?照样发请求。产品经理看不下去了:同一个词你为啥反复查数据库?服务器不要钱啊?
行吧,加缓存。但怎么加,真得选一下。我对比了三种方案:内存对象缓存、localStorage 和 WeakMap + 请求拦截。都是实战里能用上的,不是理论派。
先说结论:我一般选内存对象缓存,简单直接,维护成本低。但如果要持久化或者跨页面共享,那 localStorage 更合适。WeakMap 那种花活儿,除非你项目已经上了 axios 拦截器体系,不然别折腾。
谁更灵活?谁更省事?
先看最简单的——用一个 JS 对象当缓存池。
const searchCache = {};
async function search(keyword) {
if (searchCache[keyword]) {
console.log('命中缓存', keyword);
return searchCache[keyword];
}
try {
const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(keyword)});
const data = await res.json();
searchCache[keyword] = data; // 存入缓存
return data;
} catch (err) {
console.error('搜索失败', err);
throw err;
}
}
就这么几行,搞定。keyword 当 key,结果当 value,下次进来先查表。优点是快、干净、不依赖外部存储。缺点也很明显:刷新就没了,多标签页不共享。
但我项目多数是单页应用,用户不会开十个标签去搜同一个词。所以这个方案对我来说够用了。而且你可以加个过期机制:
const searchCache = {};
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
function isCacheValid(cacheItem) {
return Date.now() - cacheItem.timestamp < CACHE_TTL;
}
async function search(keyword) {
const cached = searchCache[keyword];
if (cached && isCacheValid(cached)) {
console.log('命中缓存');
return cached.data;
}
try {
const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(keyword)});
const data = await res.json();
searchCache[keyword] = {
data,
timestamp: Date.now()
};
return data;
} catch (err) {
console.error('搜索失败', err);
throw err;
}
}
这里注意我踩过好几次坑:时间戳更新必须在赋值时做,不能放在外面;另外别用 new Date() 去比对,用 Date.now() 更准,避免时区问题。
localStorage:想持久就它了
如果你希望关了浏览器再打开还能用缓存,就得上 localStorage。代码其实差不多,就是存的地方换了:
function getCache(key) {
const item = localStorage.getItem(key);
if (!item) return null;
const parsed = JSON.parse(item);
return isCacheValid(parsed) ? parsed.data : null;
}
function setCache(key, data) {
localStorage.setItem(key, JSON.stringify({
data,
timestamp: Date.now()
}));
}
async function searchWithLocalCache(keyword) {
const cached = getCache(search_${keyword});
if (cached) {
console.log('本地缓存命中');
return cached;
}
try {
const res = await fetch(https://jztheme.com/api/search?q=${encodeURIComponent(keyword)});
const data = await res.json();
setCache(search_${keyword}, data);
return data;
} catch (err) {
console.error('搜索失败', err);
throw err;
}
}
看起来没问题,但有几个坑我得提醒你:
- localStorage 是同步的,大量读写会阻塞主线程,尤其是数据大的时候
- 容量限制约 5-10MB,搜多了容易爆
- 不同域名隔离,iframe 或微前端环境下可能拿不到
- JSON.stringify 可能挂掉,比如遇到 BigInt 或循环引用
所以我现在都加个 try-catch 包一下序列化过程,不然线上报错一脸懵。
还有,别忘了加前缀(比如 search_),不然和其他功能冲突了不好查。
高级玩家才碰的 WeakMap + 拦截器
这个是我之前在某个大型项目里看到的,结合了 axios 拦截器和 WeakMap 实现请求级缓存。听着高大上,实际用起来……挺累的。
const pendingRequests = new WeakMap();
// 这里假设 config 是可序列化的配置对象
function getRequestKey(config) {
return ${config.method}_${config.url};
}
axios.interceptors.request.use(config => {
if (config.cache) {
const key = getRequestKey(config);
const cached = pendingRequests.get(key);
if (cached) {
return cached; // 返回之前的 promise
}
const request = axios(config); // 发起请求
pendingRequests.set(key, request);
request.finally(() => {
pendingRequests.delete(key); // 完成后清理
});
return request;
}
return config;
});
问题是 WeakMap 的 key 必须是对象,而我们这里 key 是字符串,还得绕一层。后来改用 Map 就好了:
const requestCache = new Map();
axios.interceptors.request.use(config => {
if (config.cache) {
const key = ${config.method}://${config.baseURL}${config.url}?${config.params.q};
if (requestCache.has(key)) {
return requestCache.get(key);
}
const promise = axios(config);
requestCache.set(key, promise);
promise.finally(() => {
requestCache.delete(key);
});
return promise;
}
return config;
});
这么搞的好处是统一管理,所有带 cache 字段的请求自动走缓存。坏处是你得动拦截器,万一别人也在改,容易打架。而且缓存生命周期不好控,比如用户登出后要不要清?不清的话可能拿到旧数据。
所以我只在那种内部系统、API 调用特别规范的项目里用。小项目上这个,纯属给自己找事。
性能对比:差距比我想象的小
本来以为 localStorage 最慢,毕竟涉及序列化和磁盘 IO。结果压测下来,三者响应时间差别不大——因为瓶颈根本不在缓存逻辑,而在网络请求本身。
真正影响体验的是:有没有重复 loading 动画。如果用户点了两次“搜索”,哪怕第二次走缓存,也弹两个 loading,那就很傻。
所以我在 UI 层做了防抖 + 状态锁:
let isSearching = false;
async function handleSearch(keyword) {
if (isSearching) return;
isSearching = true;
setLoading(true);
try {
const result = await search(keyword);
updateResults(result);
} finally {
setLoading(false);
isSearching = false;
}
}
这一招比任何缓存都管用。别让用户感觉卡,比啥都强。
我的选型逻辑
看场景,我一般选内存对象缓存。理由很简单:
- 实现快,十分钟搞定
- 调试方便,console 一下对象全出来
- 不影响 localStorage 容量
- 不需要处理跨域、安全策略等问题
只有当你明确需要“关了页面再打开还能缓存”,我才会上 localStorage。而且我会限制最多存 50 条,超过就删最早的,防止膨胀。
至于拦截器那种全局方案,除非团队有统一规范,否则我不推荐。改一处,处处受影响。有时候你想临时绕过去都难。
还有一点很多人忽略:缓存不是越多越好。搜了一堆冷门词,全留着?不如定期清掉。我现在是每次进搜索页,自动清掉超过 30 分钟的缓存项。
以上是我的对比总结,有不同看法欢迎评论区交流
这个技巧的拓展用法还有很多,比如结合 Intersection Observer 做预加载缓存,后续会继续分享这类博客。目前这套方案改完后仍有小问题:连续快速切换关键词时偶尔 miss 缓存,但无大碍。我已经懒得修了,优先级不高。
以上是我个人对这个搜索缓存实现的完整讲解,有更优的实现方式欢迎评论区交流。毕竟每个项目情况不一样,也许你的场景正好适合 WeakMap 方案呢?

暂无评论