功能说明
该示例实现 stale-while-revalidate 缓存策略。条目在 ttlMs 内视为新鲜,直接返回;超过新鲜期但仍在 staleMs 窗口内时,立即返回旧值,同时后台执行一次刷新,下一次请求即可拿到新值;超过陈旧窗口则等待加载器返回,失败会直接抛给调用方。适用于价格、排行榜、配置等允许短暂陈旧的读取场景。
关键代码
缓存条目同时记录 freshUntil 与 staleUntil,后者等于新鲜截止时间加陈旧窗口。refresh 用 inflight Map 按 key 去重,多个并发请求共享同一个加载 Promise,完成后重新计算两个时间点并写入缓存,最后在 finally 中移除进行中标记。陈旧读取路径对后台 Promise 单独 catch,并通过 onError 上报,避免后台失败变成未处理拒绝或影响已经返回的旧值。
使用注意
陈旧读取返回旧值时不会告知调用方数据已过期,需要可观测性时应利用 onError 或自行扩展返回元信息。invalidate 只删除已有条目,不会取消正在进行的刷新,刷新完成后仍可能把旧请求的结果写回缓存。后台刷新失败时旧条目继续保留,直到超过 staleMs。加载器必须返回新 Promise,不能依赖调用参数区分请求。
验证方式
用假时钟与可控加载器:首次 get 等待加载器并写入缓存;新鲜期内再次 get 不调用加载器;推进到陈旧窗口后 get 立即返回旧值并触发后台刷新,等待微任务后确认缓存已更新;刷新失败时旧值仍返回且 onError 被调用;超过陈旧窗口后 get 等待加载器并传播错误;并发 get 只触发一次加载。
代码示例
export interface SwrOptions<K> {
ttlMs: number;
staleMs: number;
now?: () => number;
onError?: (error: unknown, key: K) => void;
}
interface CacheEntry<V> {
value: V;
freshUntil: number;
staleUntil: number;
}
export class SwrCache<K, V> {
private readonly entries = new Map<K, CacheEntry<V>>();
private readonly inflight = new Map<K, Promise<V>>();
private readonly ttlMs: number;
private readonly staleMs: number;
private readonly now: () => number;
private readonly onError?: (error: unknown, key: K) => void;
constructor(options: SwrOptions<K>) {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
if (!Number.isFinite(options.staleMs) || options.staleMs < 0) {
throw new RangeError("staleMs must be a non-negative finite number");
}
this.ttlMs = options.ttlMs;
this.staleMs = options.staleMs;
this.now = options.now ?? Date.now;
this.onError = options.onError;
}
get(key: K, loader: () => Promise<V>): Promise<V> {
if (typeof loader !== "function") {
return Promise.reject(new TypeError("loader must be a function"));
}
const now = this.now();
const entry = this.entries.get(key);
if (entry && now < entry.freshUntil) return Promise.resolve(entry.value);
if (entry && now < entry.staleUntil) {
void this.refresh(key, loader).catch((error) => {
try {
this.onError?.(error, key);
} catch {
// Observers must not break stale reads.
}
});
return Promise.resolve(entry.value);
}
return this.refresh(key, loader);
}
invalidate(key?: K): void {
if (key === undefined) this.entries.clear();
else this.entries.delete(key);
}
private refresh(key: K, loader: () => Promise<V>): Promise<V> {
const existing = this.inflight.get(key);
if (existing) return existing;
const promise = Promise.resolve()
.then(loader)
.then((value) => {
const now = this.now();
this.entries.set(key, {
value,
freshUntil: now + this.ttlMs,
staleUntil: now + this.ttlMs + this.staleMs,
});
return value;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return promise;
}
}