不用框架,自己管好前端请求:缓存、竞态与取消的一般解法
上一篇《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=true 且 error 有值、loading=true 且 data 有值——这些非法组合全靠你「记得按顺序 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 支持传入 AbortSignal,abort() 一调,请求立刻中断:
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;
}
三个要点:
- 取消时机:
useEffect的 cleanup 里abort(),组件卸载或url依赖变化时都会触发。这同时解决了「竞态」——url从「a」变「ab」,cleanup 先把「a」的请求掐了,再发「ab」的,从根上杜绝旧响应后到。 - 区分取消和错误:
abort()会让 fetch reject 一个name === 'AbortError'的DOMException。它不是你代码的 bug,必须单独拦截、静默处理,否则会误报一堆错误。 - 取消不是万能:老浏览器、某些非 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 Query | ahooks useRequest |
|---|---|---|
Map 缓存 + updatedAt | queryKey + staleTime / gcTime | cacheKey + staleTime |
| in-flight 去重 | 同 key 只发一次 | cacheKey 共享 |
| 序号 / AbortController | cancelQueries + 内部竞态处理 | 内部处理 |
| 状态机 | isLoading / isFetching / isError | loading / data / error |
stale-while-revalidate | 后台刷新 + isFetching | staleTime 语义 |
| 手动失效 | invalidateQueries(前缀匹配) | refresh() / refreshDeps |
| 重试 | retry + 指数退避 | retryCount |
所以结论是:库不是魔法,它只是把这套东西工程化了——边界 case 全覆盖、引用计数解决去重/取消冲突、前缀匹配做失效、DevTools 可视化。手写能让你看懂库的每一项配置「在解决哪个问题」;反过来说,当你手写开始要处理「去重和取消的冲突」「前缀失效」「SSR 水合」这些深水区时,就是该上框架的信号。
小结
不引框架,管好前端请求,记住这五条:
- 状态用状态机:别让三个布尔各自为政;区分「首屏 loading」和「后台 refetching」,数据才不闪烁。
- 缓存用 Map + 过期时间:key 要含参数、排序序列化;过期了先给旧数据再后台刷(stale-while-revalidate)。
- 去重用 in-flight Map:同 key 共享同一个 Promise,别重复发。
- 竞态用序号 + 取消:序号「验明正身」兜底,AbortController「从源头掐断」省资源,两者配合。
- 取消要区分 AbortError:主动取消不是错误,别误报。
把这五条串起来,你就有了一个迷你 useRequest;也真正读懂了 TanStack Query / ahooks 每一行配置背后的动机。
