搜索缓存设计与实现的那些坑和优化策略

皇甫书瑜 交互 阅读 1,883
赞 20 收藏
二维码
手机扫码查看
反馈

这玩意儿到底缓不缓,我纠结了三天

上周上线了个搜索功能,用户一搜“手机”,出来一堆结果。再搜一次?照样发请求。产品经理看不下去了:同一个词你为啥反复查数据库?服务器不要钱啊?

搜索缓存设计与实现的那些坑和优化策略

行吧,加缓存。但怎么加,真得选一下。我对比了三种方案:内存对象缓存、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 方案呢?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论