← 中文资源

简体中文版本

陈旧可用并后台刷新的缓存

新鲜数据立即返回,过期但仍在陈旧窗口时先返回旧值并后台刷新,刷新失败不影响读取。

阅读英文原文

功能说明

该示例实现 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;
  }
}