跳到主要内容

不用框架,自己管好前端请求:缓存、竞态与取消的一般解法

· 阅读需 11 分钟

上一篇《TanStack Query 实战》里,一个收藏星标靠 useQuery / useMutation 把「缓存、竞态、取消、同步」全接管了,一行都没手写。但那篇留下一个反向的问题:如果不引入框架,这些事自己怎么写?

「库帮你做了」和「你知道它做了什么」是两回事。这篇把前端请求里最头疼的几件事——状态管理、缓存、去重、竞态、取消、重试——用纯手写的方式逐层拆开,讲清每一种的一般解法。理解了这些,再回头看 TanStack Query / ahooks,你就能看懂它们「为什么这么设计」。

一、问题从哪冒出来:一段朴素 fetch 的六处硬伤

先看一个最朴素的请求组件:

function NoteList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
fetch('/api/notes')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch((e) => { setError(e); setLoading(false); });
}, []);

if (loading) return <Spinner />;
if (error) return <Error />;
return <List data={data} />;
}

能跑,但问题一戳就破。把它们分门别类:

类别具体问题朴素写法的后果
状态loading / data / error 三个布尔散落,可能自相矛盾忘 reset 时 loading 和 error 同时为真
缓存每次挂载都重新请求,切页面数据就丢慢 + 闪白屏
去重同一份数据多个组件各发各的浪费流量 + 数据不一致
竞态快速切换参数,旧响应后到覆盖新响应搜索结果错乱
取消组件已卸载,请求还在飞setState 打在已卸载组件上 + 泄漏
重试网络一抖就永久失败体验差

下面逐个给「不引框架」的一般解法。

二、请求状态:别用三个 useState,用一个状态机

三个独立的 useState 有个隐蔽问题:它们的组合空间比合法状态多loading=trueerror 有值、loading=truedata 有值——这些非法组合全靠你「记得按顺序 set」来避免,忘了就出 bug。

更稳的做法是把请求状态当成一个状态机,用一个 useReducer 管:

const initialState = { status: 'idle', data: null, error: null };

function reducer(state, action) {
switch (action.type) {
case 'start':
// 关键:有旧数据就「保留旧数据进入后台刷新」,没有才是真正的首屏 loading
return { ...state, status: state.data ? 'refetching' : 'loading', error: null };
case 'success':
return { status: 'success', data: action.data, error: null };
case 'error':
// 有旧数据时,错误也保留旧数据(可叠加一个轻提示),别把页面砸成白屏
return { ...state, status: 'error', error: action.error };
default:
return state;
}
}

这里埋了一个很重要的区分,和 TanStack Query 的 isLoading vs isFetching 是同一件事:

  • status === 'loading':首次加载、还没有任何数据——这才该显示大转圈。
  • status === 'refetching':后台刷新、手上已有旧数据——内容照常显示,最多加个「刷新中」的小标记。

这个区分就是「数据不闪烁」的来源:朴素写法每次重新请求都把 data 清空、重新转圈,用户每切一次页面就白屏一次;状态机里「有旧数据就先展示旧数据」直接消掉了这个问题。

三、请求缓存:一个 Map 管起来,加个「过期时间」

缓存的本质一句话:把响应按 key 存起来,下次同 key 先读缓存,再决定要不要重新请求。

3.1 最朴素的 Map 缓存

const cache = new Map(); // key -> { data, updatedAt }

function getCache(key, staleTime) {
const entry = cache.get(key);
if (!entry) return null;
const fresh = Date.now() - entry.updatedAt < staleTime; // 未过期 = 新鲜
return { data: entry.data, fresh };
}

function setCache(key, data) {
cache.set(key, { data, updatedAt: Date.now() });
}

function invalidate(key) {
cache.delete(key); // 精确失效;前缀失效要自己遍历 key 做匹配
}

staleTime 是「新鲜期」:数据在 staleTime 内算新鲜,直接用;过了就过期。

3.2 缓存键(key)怎么设计

key 不能只写 URL,得把影响结果的参数都算进去。最稳的是「URL + 参数序列化」:

const key = `${url}?${new URLSearchParams(params).sort()}`;

「排序后序列化」是为了让 {a:1,b:2}{b:2,a:1} 命中同一个缓存——这对应 TanStack Query 里 ['notes', page, keyword] 这种数组 key 的职责。

3.3 stale-while-revalidate:过期了也先给旧数据

这是缓存策略里最值得学的一个模式,TanStack Query 的「后台刷新」就是它:

读缓存
├─ 命中且新鲜 → 直接用,不发请求
├─ 命中但过期 → 先返回旧数据渲染,同时后台发请求刷新,回来再更新
└─ 未命中 → 发请求,等待响应

它的价值在于把「数据新鲜度」和「渲染阻塞」解耦:旧数据先兜底,新数据异步替换,页面永远有内容。

3.4 回收:gcTime

缓存不能无限涨。gcTime(垃圾回收时间)决定「不再被引用的缓存多久后删掉」。朴素实现可以按时间戳清理,或引入 LRU(最近最少使用)淘汰:

// 简单的定期清理:删掉超过 gcTime 没被读过的条目
setInterval(() => {
const now = Date.now();
for (const [k, v] of cache) {
if (now - v.updatedAt > gcTime) cache.delete(k);
}
}, gcTime);

真正的库还会区分「正在被组件引用」和「没有引用」——后者才允许回收。手写时可以先从「全局 Map + 定期清理」起步,够用再上 LRU。

四、请求去重:并发相同请求只发一次

两个组件同时挂载、都要 ['note', id] 这份数据,朴素的写法会发两次一模一样的请求。解法是 in-flight 去重:用一个 Map 记下「正在飞」的 Promise,命中就共享同一个:

const inflight = new Map(); // key -> Promise

function fetchDedup(key, fetcher) {
if (inflight.has(key)) return inflight.get(key); // 命中:共享同一个 Promise
const p = fetcher().finally(() => inflight.delete(key)); // 结束就清掉
inflight.set(key, p);
return p;
}

注意 .finally 放在 fetcher() 之后、set 之前——无论成败,在途记录都会被清掉,不会泄漏。

五、请求竞态:旧响应不许覆盖新响应

竞态(race condition) 是前端请求最隐蔽的 bug:搜索框输入「a」,请求 A 发出;继续输成「ab」,请求 B 发出;但网络不给面子,A 的响应比 B 后回来,结果列表被 A 的结果覆盖,展示的是「a」的搜索结果——错了。

解决思路有两条,一条治标、一条治本,实际是配合用的。

5.1 请求序号:响应回来时「验明正身」

给每次请求一个递增序号,响应回来时检查「我是不是还是最新那一次」,不是就丢弃:

const seqRef = useRef(0);

const load = (params) => {
const id = ++seqRef.current; // 本次请求的序号
fetcher(params).then((data) => {
if (id === seqRef.current) { // 只有最新序号才允许落地
setData(data);
}
// 不是最新 → 静默丢弃,旧数据不污染 UI
});
};

序号方案的本质是**「响应回来时校验身份」**,它不需要真的掐断底层请求(有些请求掐不断)。代价是旧请求仍在浪费带宽——它只是让 UI 不被污染,没有省资源。

5.2 AbortController:从源头掐断(治本)

更彻底的解法是「发新请求前,把旧的取消掉」,见下一节。序号是兜底,取消是省资源,两者配合最佳:即使底层不支持取消,序号也能保证 UI 正确。

六、请求取消:AbortController 从源头掐断

fetch 支持传入 AbortSignalabort() 一调,请求立刻中断:

function useFetch(url) {
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
const controller = new AbortController();
dispatch({ type: 'start' });

fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((data) => dispatch({ type: 'success', data }))
.catch((e) => {
if (e.name === 'AbortError') return; // 主动取消不是错误,静默忽略
dispatch({ type: 'error', error: e });
});

return () => controller.abort(); // 卸载 / 依赖变化时取消
}, [url]);

return state;
}

三个要点:

  1. 取消时机useEffect 的 cleanup 里 abort(),组件卸载或 url 依赖变化时都会触发。这同时解决了「竞态」——url 从「a」变「ab」,cleanup 先把「a」的请求掐了,再发「ab」的,从根上杜绝旧响应后到。
  2. 区分取消和错误abort() 会让 fetch reject 一个 name === 'AbortError'DOMException它不是你代码的 bug,必须单独拦截、静默处理,否则会误报一堆错误。
  3. 取消不是万能:老浏览器、某些非 fetch 的请求(如 JSONP)不支持 AbortController,这时就要退回 5.1 的序号兜底。

七、拼起来:一个不引框架的「迷你 useRequest」

把上面五节串起来,就是一个能用的 mini 版请求 hook——状态机 + 缓存 + 去重 + 竞态 + 取消:

const cache = new Map();
const inflight = new Map();

function useRequest(key, fetcher, { staleTime = 0 } = {}) {
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
const controller = new AbortController();
let cancelled = false; // 卸载哨兵:response 回来时先看还在不在

async function run() {
// ① 缓存:命中且新鲜,直接落地,不发请求
const entry = cache.get(key);
if (entry && Date.now() - entry.updatedAt < staleTime) {
dispatch({ type: 'success', data: entry.data });
return;
}

// ② 去重:同 key 在途就共享同一个 Promise
dispatch({ type: 'start' });
let p = inflight.get(key);
if (!p) {
p = fetcher(key, controller.signal).finally(() => inflight.delete(key));
inflight.set(key, p);
}

try {
const data = await p;
cache.set(key, { data, updatedAt: Date.now() }); // ③ 写缓存
if (!cancelled) dispatch({ type: 'success', data }); // ④ 竞态兜底
} catch (e) {
if (e.name !== 'AbortError' && !cancelled) {
dispatch({ type: 'error', error: e });
}
}
}

run();
return () => { cancelled = true; controller.abort(); }; // ⑤ 取消
}, [key]);

return state;
}

一个绕不开的张力点要摊开讲:去重和取消是冲突的。两个组件共享同一个 in-flight Promise 时,A 组件卸载触发 abort(),会连带把 B 组件正在用的请求也取消掉。真实库用「引用计数」或「把请求与消费解耦」来解决(请求只发一次,但每个消费者独立订阅、独立取消)。手写时如果只是单组件用,可以忽略;一旦多组件共享,务必意识到这个坑。

八、回到框架:这些手写对应了什么

理解了手写,再回头映射到库,一切就通了:

手写方案TanStack Queryahooks useRequest
Map 缓存 + updatedAtqueryKey + staleTime / gcTimecacheKey + staleTime
in-flight 去重同 key 只发一次cacheKey 共享
序号 / AbortControllercancelQueries + 内部竞态处理内部处理
状态机isLoading / isFetching / isErrorloading / data / error
stale-while-revalidate后台刷新 + isFetchingstaleTime 语义
手动失效invalidateQueries(前缀匹配)refresh() / refreshDeps
重试retry + 指数退避retryCount

所以结论是:库不是魔法,它只是把这套东西工程化了——边界 case 全覆盖、引用计数解决去重/取消冲突、前缀匹配做失效、DevTools 可视化。手写能让你看懂库的每一项配置「在解决哪个问题」;反过来说,当你手写开始要处理「去重和取消的冲突」「前缀失效」「SSR 水合」这些深水区时,就是该上框架的信号。

小结

不引框架,管好前端请求,记住这五条:

  1. 状态用状态机:别让三个布尔各自为政;区分「首屏 loading」和「后台 refetching」,数据才不闪烁。
  2. 缓存用 Map + 过期时间:key 要含参数、排序序列化;过期了先给旧数据再后台刷(stale-while-revalidate)。
  3. 去重用 in-flight Map:同 key 共享同一个 Promise,别重复发。
  4. 竞态用序号 + 取消:序号「验明正身」兜底,AbortController「从源头掐断」省资源,两者配合。
  5. 取消要区分 AbortError:主动取消不是错误,别误报。

把这五条串起来,你就有了一个迷你 useRequest;也真正读懂了 TanStack Query / ahooks 每一行配置背后的动机。