功能说明
该示例在原生 Map 上增加逐条 TTL。set 可为单个键覆盖默认存活时间,get 与 has 在读取时检查是否过期并顺手删除,sweep 主动清理所有到期条目,size 读取前也会先清扫。可选 onExpire 回调可用于记录指标或触发刷新。适用于缓存验证码、短期会话、去重窗口等需要自动失效的数据。
关键代码
每个键保存值与到期时间戳,时间源可注入便于测试。set 在写入已存在的键前先判断旧值是否已过期,若过期就先触发过期回调;写入新键且达到 maxSize 时,按 Map 的插入顺序删除最旧条目。读取路径用惰性删除避免为每个条目启动定时器。sweep 在遍历中删除当前键,JavaScript Map 允许这种操作;命中时调用回调并返回清理数量。
使用注意
该实现不会后台自动清理,若长期没有读取或 sweep,过期条目会占用内存,应配合定时任务或请求间隙调用 sweep。size 带副作用并会触发过期回调,不要在高频路径频繁读取。maxSize 按插入顺序而非访问顺序淘汰,行为接近 FIFO,不是 LRU。过期回调抛错会中断 sweep,重要场景应在回调内部自行捕获异常。
验证方式
使用假时钟:写入后未到期时 get 返回值,推进到到期点后 get 返回 undefined 且 size 减少;测试逐条 ttlMs 覆盖默认值;写入 maxSize 加一个键,断言最旧键被淘汰;注册 onExpire 统计回调次数,分别触发读取过期与 sweep;测试非正 ttlMs、非法 maxSize 以及已过期键被重新 set 时的回调行为。
代码示例
export interface TtlMapOptions<K, V> {
ttlMs: number;
now?: () => number;
maxSize?: number;
onExpire?: (key: K, value: V) => void;
}
interface Entry<V> {
value: V;
expiresAt: number;
}
export class TtlMap<K, V> {
private readonly entries = new Map<K, Entry<V>>();
private readonly ttlMs: number;
private readonly now: () => number;
private readonly maxSize: number;
private readonly onExpire?: (key: K, value: V) => void;
constructor(options: TtlMapOptions<K, V>) {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
const maxSize = options.maxSize ?? Number.POSITIVE_INFINITY;
if (maxSize !== Number.POSITIVE_INFINITY && (!Number.isInteger(maxSize) || maxSize < 1)) {
throw new RangeError("maxSize must be a positive integer");
}
this.ttlMs = options.ttlMs;
this.now = options.now ?? Date.now;
this.maxSize = maxSize;
this.onExpire = options.onExpire;
}
set(key: K, value: V, ttlMs: number = this.ttlMs): this {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
if (this.entries.has(key)) {
this.removeIfExpired(key);
} else if (this.entries.size >= this.maxSize) {
this.evictOldest();
}
this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
return this;
}
get(key: K): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (this.isExpired(entry)) {
this.expire(key, entry);
return undefined;
}
return entry.value;
}
has(key: K): boolean {
const entry = this.entries.get(key);
if (!entry) return false;
if (this.isExpired(entry)) {
this.expire(key, entry);
return false;
}
return true;
}
delete(key: K): boolean {
return this.entries.delete(key);
}
sweep(): number {
const now = this.now();
let removed = 0;
for (const [key, entry] of this.entries) {
if (now >= entry.expiresAt) {
this.entries.delete(key);
this.notify(key, entry);
removed += 1;
}
}
return removed;
}
get size(): number {
this.sweep();
return this.entries.size;
}
private isExpired(entry: Entry<V>): boolean {
return this.now() >= entry.expiresAt;
}
private expire(key: K, entry: Entry<V>): void {
this.entries.delete(key);
this.notify(key, entry);
}
private notify(key: K, entry: Entry<V>): void {
this.onExpire?.(key, entry.value);
}
private removeIfExpired(key: K): void {
const entry = this.entries.get(key);
if (entry && this.isExpired(entry)) this.expire(key, entry);
}
private evictOldest(): void {
const first = this.entries.keys().next();
if (!first.done) this.entries.delete(first.value);
}
}