perf: optimize web data resources

This commit is contained in:
ZL-Q
2026-05-10 20:01:14 +08:00
parent a9739cddce
commit 1e4871e337
24 changed files with 1304 additions and 252 deletions
+149
View File
@@ -0,0 +1,149 @@
export type CacheKey = readonly string[];
interface CacheEntry<T> {
data?: T;
error?: unknown;
updatedAt: number;
expiresAt: number;
promise?: Promise<T>;
}
export interface QueryOptions<T> {
key: CacheKey;
ttlMs: number;
fetcher: () => Promise<T>;
staleWhileRevalidate?: boolean;
force?: boolean;
}
type Listener = () => void;
const cache = new Map<string, CacheEntry<unknown>>();
const listeners = new Map<string, Set<Listener>>();
function keyToString(key: CacheKey): string {
return JSON.stringify(key);
}
function isPrefix(key: CacheKey, prefix: CacheKey): boolean {
return prefix.every((part, index) => key[index] === part);
}
function notify(serializedKey: string): void {
listeners.get(serializedKey)?.forEach((listener) => listener());
}
function notifyPrefix(prefix: CacheKey): void {
for (const serializedKey of listeners.keys()) {
const parsedKey = JSON.parse(serializedKey) as string[];
if (isPrefix(parsedKey, prefix)) notify(serializedKey);
}
}
function startFetch<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const now = Date.now();
const existing = cache.get(serializedKey) as CacheEntry<T> | undefined;
const promise = fetcher()
.then((data) => {
cache.set(serializedKey, {
data,
updatedAt: Date.now(),
expiresAt: Date.now() + ttlMs,
});
notify(serializedKey);
return data;
})
.catch((error) => {
cache.set(serializedKey, {
data: existing?.data,
error,
updatedAt: existing?.updatedAt ?? now,
expiresAt: existing?.data === undefined ? now : existing.expiresAt,
});
notify(serializedKey);
throw error;
});
cache.set(serializedKey, {
...existing,
updatedAt: existing?.updatedAt ?? now,
expiresAt: existing?.expiresAt ?? now,
promise,
});
notify(serializedKey);
return promise;
}
export function query<T>({
key,
ttlMs,
fetcher,
staleWhileRevalidate = true,
force = false,
}: QueryOptions<T>): Promise<T> {
const serializedKey = keyToString(key);
const entry = cache.get(serializedKey) as CacheEntry<T> | undefined;
const now = Date.now();
if (!force && entry?.promise) return entry.promise;
if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data);
if (!force && staleWhileRevalidate && entry?.data !== undefined) {
void startFetch(serializedKey, ttlMs, fetcher).catch((error) => {
console.debug('[data-client] Background refresh failed', error);
});
return Promise.resolve(entry.data);
}
return startFetch(serializedKey, ttlMs, fetcher);
}
export function prefetch<T>(options: QueryOptions<T>): void {
void query(options).catch((error) => {
console.debug('[data-client] Prefetch failed', error);
});
}
export function peek<T>(key: CacheKey): T | undefined {
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
}
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
}
export function set<T>(key: CacheKey, data: T, ttlMs: number): void {
cache.set(keyToString(key), {
data,
updatedAt: Date.now(),
expiresAt: Date.now() + ttlMs,
});
notify(keyToString(key));
}
export function invalidate(prefix: CacheKey): void {
for (const serializedKey of Array.from(cache.keys())) {
const parsedKey = JSON.parse(serializedKey) as string[];
if (isPrefix(parsedKey, prefix)) {
cache.delete(serializedKey);
notify(serializedKey);
}
}
notifyPrefix(prefix);
}
export function clearAll(): void {
cache.clear();
for (const serializedKey of listeners.keys()) notify(serializedKey);
}
export function subscribe(key: CacheKey, listener: Listener): () => void {
const serializedKey = keyToString(key);
const keyListeners = listeners.get(serializedKey) ?? new Set<Listener>();
keyListeners.add(listener);
listeners.set(serializedKey, keyListeners);
return () => {
keyListeners.delete(listener);
if (keyListeners.size === 0) listeners.delete(serializedKey);
};
}