跳到主要内容

请求缓存

问题

实现一个请求缓存函数,相同的请求只发送一次,后续请求直接返回缓存结果。同时支持请求去重(相同请求正在进行中时,共用同一个 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;

相关链接