请求缓存
问题
实现一个请求缓存函数,相同的请求只发送一次,后续请求直接返回缓存结果。同时支持请求去重(相同请求正在进行中时,共用同一个 Promise)。
答案
请求缓存可以减少重复请求,提升性能。关键点是需要缓存 Promise 本身而不仅是结果。
基础缓存实现
function createCachedFetch() {
const cache = new Map<string, unknown>();
return async function cachedFetch<T>(url: string): Promise<T> {
if (cache.has(url)) {
console.log('命中缓存:', url);
return cache.get(url) as T;
}
console.log('发起请求:', url);
const response = await fetch(url);
const data = await response.json();
cache.set(url, data);
return data;
};
}
// 使用
const cachedFetch = createCachedFetch();
await cachedFetch('/api/user/1'); // 发起请求
await cachedFetch('/api/user/1'); // 命中缓存
await cachedFetch('/api/user/2'); // 发起请求
请求去重(防止重复请求)
核心:缓存 Promise 而不是结果,相同请求共用同一个 Promise。
function createDedupeFetch() {
const pendingRequests = new Map<string, Promise<unknown>>();
const cache = new Map<string, unknown>();
return async function dedupeFetch<T>(url: string): Promise<T> {
// 检查缓存
if (cache.has(url)) {
return cache.get(url) as T;
}
// 检查进行中的请求
if (pendingRequests.has(url)) {
console.log('复用进行中的请求:', url);
return pendingRequests.get(url) as Promise<T>;
}
// 发起新请求
console.log('发起新请求:', url);
const promise = fetch(url)
.then((res) => res.json())
.then((data) => {
cache.set(url, data);
return data;
})
.finally(() => {
pendingRequests.delete(url);
});
pendingRequests.set(url, promise);
return promise;
};
}
// 测试
const dedupeFetch = createDedupeFetch();
// 同时发起两个相同请求,只会发一次
const p1 = dedupeFetch('/api/user/1');
const p2 = dedupeFetch('/api/user/1');
console.log(p1 === p2); // true,共用同一个 Promise
带过期时间的缓存
interface CacheItem<T> {
data: T;
expireAt: number;
}
function createCacheWithTTL(defaultTTL = 60000) {
const cache = new Map<string, CacheItem<unknown>>();
const pending = new Map<string, Promise<unknown>>();
return async function cachedFetch<T>(
url: string,
ttl = defaultTTL
): Promise<T> {
const now = Date.now();
// 检查缓存
if (cache.has(url)) {
const item = cache.get(url)!;
if (now < item.expireAt) {
return item.data as T;
}
cache.delete(url); // 过期删除
}
// 检查进行中的请求
if (pending.has(url)) {
return pending.get(url) as Promise<T>;
}
// 发起请求
const promise = fetch(url)
.then((res) => res.json())
.then((data) => {
cache.set(url, {
data,
expireAt: Date.now() + ttl,
});
return data;
})
.finally(() => {
pending.delete(url);
});
pending.set(url, promise);
return promise;
};
}
完整版实现
interface CacheOptions {
ttl?: number; // 缓存时间
maxSize?: number; // 最大缓存数量
getKey?: (...args: unknown[]) => string; // 自定义缓存键
shouldCache?: (result: unknown) => boolean; // 是否缓存结果
}
interface CacheEntry {
value: unknown;
expireAt: number;
promise?: Promise<unknown>;
}
function createRequestCache(options: CacheOptions = {}) {
const {
ttl = 5 * 60 * 1000, // 默认 5 分钟
maxSize = 100,
getKey = (...args) => JSON.stringify(args),
shouldCache = () => true,
} = options;
const cache = new Map<string, CacheEntry>();
// LRU 淘汰
function evict(): void {
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
}
// 清理过期缓存
function cleanup(): void {
const now = Date.now();
for (const [key, entry] of cache) {
if (now > entry.expireAt && !entry.promise) {
cache.delete(key);
}
}
}
// 定期清理
setInterval(cleanup, 60000);
return function <T>(
fn: (...args: unknown[]) => Promise<T>
): (...args: unknown[]) => Promise<T> {
return async (...args: unknown[]): Promise<T> => {
const key = getKey(...args);
const now = Date.now();
// 检查缓存
const cached = cache.get(key);
if (cached) {
// 有正在进行的请求
if (cached.promise) {
return cached.promise as Promise<T>;
}
// 未过期的缓存
if (now < cached.expireAt) {
// 更新为最近使用(LRU)
cache.delete(key);
cache.set(key, cached);
return cached.value as T;
}
cache.delete(key);
}
// 发起请求
evict();
const promise = fn(...args)
.then((result) => {
if (shouldCache(result)) {
const entry = cache.get(key);
if (entry) {
entry.value = result;
entry.expireAt = Date.now() + ttl;
delete entry.promise;
}
} else {
cache.delete(key);
}
return result;
})
.catch((error) => {
cache.delete(key);
throw error;
});
cache.set(key, {
value: undefined,
expireAt: Date.now() + ttl,
promise,
});
return promise;
};
};
}
// 使用
const withCache = createRequestCache({
ttl: 10000,
maxSize: 50,
getKey: (url, options) => `${url}-${JSON.stringify(options)}`,
shouldCache: (result) => result !== null,
});
const cachedFetchUser = withCache(async (userId: string) => {
const res = await fetch(`/api/user/${userId}`);
return res.json();
});
await cachedFetchUser('1');
await cachedFetchUser('1'); // 命中缓存
React Hook 版本
import { useState, useEffect, useRef, useCallback } from 'react';
interface UseCachedFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
const globalCache = new Map<string, { data: unknown; timestamp: number }>();
function useCachedFetch<T>(
url: string,
ttl = 60000
): UseCachedFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async (skipCache = false) => {
// 检查缓存
if (!skipCache && globalCache.has(url)) {
const cached = globalCache.get(url)!;
if (Date.now() - cached.timestamp < ttl) {
setData(cached.data as T);
setLoading(false);
return;
}
}
// 取消之前的请求
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal,
});
const result = await response.json();
globalCache.set(url, {
data: result,
timestamp: Date.now(),
});
setData(result);
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError(err as Error);
}
} finally {
setLoading(false);
}
}, [url, ttl]);
useEffect(() => {
fetchData();
return () => {
abortControllerRef.current?.abort();
};
}, [fetchData]);
const refetch = useCallback(() => {
fetchData(true);
}, [fetchData]);
return { data, loading, error, refetch };
}
// 使用
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error, refetch } = useCachedFetch<User>(
`/api/user/${userId}`
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data?.name}</h1>
<button onClick={refetch}>刷新</button>
</div>
);
}
通用 memoize 函数
function memoize<T extends (...args: unknown[]) => unknown>(
fn: T,
options: {
getKey?: (...args: Parameters<T>) => string;
ttl?: number;
} = {}
): T {
const {
getKey = (...args) => JSON.stringify(args),
ttl = Infinity,
} = options;
const cache = new Map<string, { value: ReturnType<T>; expireAt: number }>();
return ((...args: Parameters<T>): ReturnType<T> => {
const key = getKey(...args);
const now = Date.now();
if (cache.has(key)) {
const cached = cache.get(key)!;
if (now < cached.expireAt) {
return cached.value;
}
cache.delete(key);
}
const result = fn(...args);
cache.set(key, {
value: result as ReturnType<T>,
expireAt: now + ttl,
});
return result as ReturnType<T>;
}) as T;
}
// 使用
const expensiveCalculation = memoize(
(n: number) => {
console.log('计算中...');
return n * n;
},
{ ttl: 10000 }
);
expensiveCalculation(5); // 计算中... 25
expensiveCalculation(5); // 25 (缓存)
常见面试问题
Q1: 为什么要缓存 Promise 而不是结果?
答案:
// ❌ 只缓存结果:无法处理并发请求
cache.set(key, data);
// 问题:两个请求同时发起
const p1 = fetch('/api'); // 请求 1
const p2 = fetch('/api'); // 请求 2,此时 p1 还没完成,cache 为空
// ✅ 缓存 Promise:可以复用进行中的请求
cache.set(key, promise);
// 效果:两个请求共用同一个 Promise
const promise = fetch('/api');
const p1 = promise;
const p2 = promise; // 复用
Q2: 如何处理缓存失效?
答案:
| 策略 | 实现 |
|---|---|
| TTL 过期 | 记录过期时间,请求时检查 |
| 手动清除 | 提供 clear/invalidate 方法 |
| LRU 淘汰 | 缓存满时删除最久未使用的 |
| 版本标记 | 数据变化时更新版本号 |
// 提供清除方法
const cache = {
data: new Map(),
clear(key?: string) {
if (key) {
this.data.delete(key);
} else {
this.data.clear();
}
},
invalidate(pattern: RegExp) {
for (const key of this.data.keys()) {
if (pattern.test(key)) {
this.data.delete(key);
}
}
},
};
Q3: 如何处理缓存穿透和雪崩?
答案:
| 问题 | 描述 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 缓存空值、布隆过滤器 |
| 缓存雪崩 | 大量缓存同时过期 | 过期时间加随机值 |
| 缓存击穿 | 热点数据过期瞬间 | 互斥锁、永不过期 |
// 缓存空值防止穿透
cache.set(key, { value: null, isNull: true });
// 随机过期时间防止雪崩
const ttl = baseTTL + Math.random() * baseTTL * 0.1;