搜索工具的常见体验问题:每次进页面都要重新查一遍(慢、费 credits)、刷新后历史没了、断网直接白屏。解法是把查询结果存到浏览器本地------IndexedDB。这篇用极简封装(idb-keyval)把它做出来:缓存带 TTL、历史列表、容量清理,一个下午就能上线。
为什么不用 localStorage
| localStorage | IndexedDB | |
|---|---|---|
| 容量 | ~5MB | 几十 MB 到 GB 级 |
| 数据形态 | 只有字符串 | 结构化对象(不用 JSON.stringify) |
| 同步/异步 | 同步(大对象会卡) | 异步(不卡渲染) |
搜索结果 JSON 动辄几十 KB,一次查 50 个词就超 localStorage 了。数据量一大,IndexedDB 是唯一合理选择。
第一步:装极简封装
IndexedDB 原生 API 又啰嗦又有回调历史包袱,用 idb-keyval:
bash
npm install idb-keyval
ts
import { get, set, keys, del } from "idb-keyval";
第二步:带 TTL 的查询缓存
ts
const TTL = 60 * 60 * 1000; // 1 小时
async function fetchWithCache(keyword: string, hl: string, gl: string) {
const cacheKey = `serp:${hl}:${gl}:${keyword}`;
const cached = await get<{ data: SerpResult; savedAt: number }>(cacheKey);
// 缓存未过期直接用
if (cached && Date.now() - cached.savedAt < TTL) {
return cached.data;
}
const data = await fetchSerp(keyword, hl, gl);
await set(cacheKey, { data, savedAt: Date.now() });
return data;
}
三个设计点 :缓存键含全部参数(hl/gl 都要进键,否则串市场)、TTL 按数据新鲜度定(排名小时级、新闻分钟级)、savedAt 自己带(别依赖系统时间做业务逻辑------但 TTL 判断够用)。
第三步:查询历史列表
ts
// 最近查过的词(倒序展示)
async function history() {
const allKeys = (await keys()).filter((k) => String(k).startsWith("serp:"));
const items = await Promise.all(
allKeys.map(async (k) => ({ key: k, val: await get<{ savedAt: number }>(k) }))
);
return items.sort((a, b) => (b.val?.savedAt ?? 0) - (a.val?.savedAt ?? 0));
}
历史的价值不只是「回看」------用户切回上一个查过的词时,直接命中缓存,零请求、零等待。
第四步:容量清理(别把用户磁盘撑爆)
IndexedDB 不是无限的。两个策略:
策略一:只存投影字段。 别把整个原始响应塞进去------存你真正用的字段:
ts
await set(key, {
savedAt: Date.now(),
data: {
organic: data.organic.slice(0, 10).map((r) => ({
rank: r.rank,
title: r.title,
link: r.link,
})),
},
});
策略二:条数上限 + 最旧淘汰(LRU 简化版):
ts
async function prune(maxItems = 200) {
const list = await history();
if (list.length <= maxItems) return;
for (const item of list.slice(maxItems)) {
await del(item.key); // 删最旧的
}
}
查询后调用 prune(),用户无感知。
第五步:异常降级
无痕模式、隐私模式、部分浏览器配置下 IndexedDB 可能不可用。包一层降级:
ts
let useIDB = true;
try {
await set("__ping", 1);
} catch {
useIDB = false; // 降级:用内存 Map(会话内有效)
}
const memoryCache = new Map();
降级到内存缓存:至少当前会话内快,不报错。
踩坑记录
坑 1:直接存整个原始响应。 单次几十 KB、几百次就是几十 MB,用户的磁盘配额无底洞。存投影字段。
坑 2:用 localStorage 硬塞。 5MB 上限 + 同步 API 卡主线程。结构化存储用 IDB。
坑 3:缓存没有 TTL。 用户三天后进来,看到三天前的「当前排名」。TTL 按场景定,过期必须重新查。
坑 4:忘记异步。 const v = get(key) 拿到的是 Promise 不是值。IDB 全异步,await 写全。
坑 5:无痕模式直接崩。 IndexedDB 在某些模式下抛异常。try/catch 降级到内存。
坑 6:没有清理策略。 历史无限增长,直到浏览器逐出整个站点的存储(更糟)。条数上限 + 最旧淘汰。
工程清单
- 数据量大选 IndexedDB,别用 localStorage
- 缓存键含全部参数(关键词 + 市场 + 语言)
- TTL 按新鲜度定,过期重查
- 只存投影字段,别存整个原始响应
- 条数上限 + LRU 淘汰 + 异常降级内存缓存
- 查询历史列表命中缓存时零请求
IndexedDB 是搜索工具「从一次性网页变成顺手工具」的关键一环:第二次打开同一个词,就该是瞬时的。配合请求层缓存(SWR 式)和 PWA(上期讲过),本地体验就完整了。
接口字段(organic、rank 等)说明见 SerpBase 官方文档,做投影时对照文档挑字段。你们的前端缓存用的是什么方案?评论区聊聊。