REACT · Vol.V · LESSON 33 · 路由与数据获取
TanStack Query:服务端缓存管理
第 32 篇欠下的账,该还了
上一篇第 32 篇用 useEffect + fetch 手写取数,写到最后你也觉得别扭:一个「拿到列表、渲染出来」的需求,真正和业务相关的代码不到三分之一,其余全在伺候请求本身。把痛点列成清单:
- 竞态:关键词快速变化,两个请求乱序返回,慢的反杀了快的;
- 无缓存:列表页进详情页再返回,列表重新请求一遍,白屏重来;
- 三态手写:每个组件都要 useState 一对 loading/error,复制粘贴;
- 重复请求:三个组件同时挂载,同一个接口被打三次。
这些痛点的共同根因,是你在手搓一个缓存客户端。这事后端有历史版本:没有 Spring Cache 的年代,大家也自己 new 一个 ConcurrentHashMap 当缓存,过期、并发全要自己处理。后来 @Cacheable 一出,这些逻辑被抽进框架,业务代码里只剩一个注解。
前端同样的抽离发生在 TanStack Query(npm 包名 @tanstack/react-query)身上:它把自己定位成「服务端状态管理库」,接口数据的缓存、去重、失效、重取,全部收编。第 17 篇说过服务端状态不该塞进 useState,第 18 篇又叮嘱过别塞进 Zustand——一直欠着的那块拼图,就是它。
第 32 篇的手写取数 = 手写 ConcurrentHashMap 缓存:能用,但过期、并发、重试全要自己管。TanStack Query 是前端的 Spring Cache,也是 MyBatis 二级缓存的前端版:你只声明「这个 key 的数据怎么取」(queryFn),缓存多久、何时失效、失败重试,框架按统一策略处理——可配置、全局一致。
装配:QueryClient 就是前端的缓存容器
用之前先装配。两步:创建一个 QueryClient 全局实例,再用 Provider 把它注入组件树——这套路数你在 Spring 里熟透了:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
// 1. 缓存客户端:整个应用就一份,所有缓存条目都存在它肚子里
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 数据 30 秒内视为新鲜,不重复请求
retry: 1, // 失败重试 1 次(库默认 3 次)
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
// 2. 注入:组件树内任何 useQuery 都能拿到同一个实例
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);
QueryClient 相当于亲手 new 出来的 CacheManager 单例;QueryClientProvider 把它放进容器供全局注入;defaultOptions 是 application.yml 里的全局缓存配置,单个 useQuery 里写的参数就是方法级覆盖——全局默认、局部覆盖、优先级就近,Spring 里天天用。
装配完,取数组件长这样:
import { useQuery } from "@tanstack/react-query";
interface Todo { id: number; title: string; done: boolean; }
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch("/api/todos");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function TodoList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["todos"], // 这份数据的缓存 key
queryFn: fetchTodos, // 缓存未命中时怎么取
});
if (isPending) return <p>加载中…</p>;
if (isError) return <p>加载失败:{error.message}</p>;
return (
<ul>
{data.map((t) => <li key={t.id}>{t.title}</li>)}
</ul>
);
}
注意两件事。第一,fetchTodos 抛出的异常不会被吞掉——Query 接住它放进 error,isError 变 true,你不需要 try-catch。第二,v5 把「还没有任何数据」的标志位改名为 isPending(老教程的 isLoading 在 v5 是 isPending 与 isFetching 的组合),看文档先对版本。三态标志位全由库供给,第 32 篇那对 useState 可以删了。
queryKey:缓存世界的唯一身份证
@Cacheable 你写过:@Cacheable(cacheNames = "user", key = "'user:' + #id")。Query 里的 queryKey 扮演同一个角色——key 唯一确定一份缓存。但它比字符串 key 强在「层级」:key 是数组,从粗到细排列,像文件夹路径:
useQuery({ queryKey: ["todos"], ... }) // 全量列表
useQuery({ queryKey: ["todo", id], ... }) // 详情:id 变 = 换一条缓存
useQuery({
queryKey: ["todos", { page, keyword }], // 分页、搜索参数也进 key
queryFn: () => fetchTodos({ page, keyword }),
});
数组元素做深度比较决定是不是同一条缓存:id 从 1 变 2,key 不同,自动换缓存、重新请求——不用写任何「key 变了重新取」的代码。失效时还有福利:invalidateQueries 按前缀匹配,首项是 "todos" 的所有缓存(列表、各种分页)一次全失效,像按 cacheNames 整组清理。key 的设计原则和包结构一样:从粗到细、可序列化、参数全进 key。
一条缓存条目的完整一生,用一张图看懂:
控制这条生命线的旋钮一共五个,覆盖 90% 的场景:
| 配置 | 默认值 | 管什么 | 后端对应物 |
|---|---|---|---|
staleTime | 0 | 数据多久内算「新鲜」,新鲜期内直接用缓存不发请求 | 缓存 TTL |
gcTime | 5 分钟 | 没有组件订阅后,缓存再留多久才回收 | 空闲键驻留时间 |
refetchOnWindowFocus | true | 切回浏览器标签页时,自动重取已过期的数据 | 主动刷新策略 |
retry | 3 次 | 失败自动重试,指数退避 | Resilience4j / Feign 重试 |
enabled | true | false 时不发起请求,等条件满足再发 | 条件装配 |
一句话记住最易混的两个:staleTime 管「要不要重新请求」,gcTime 管「缓存还留不留」——前者是新鲜度,后者是内存回收。默认值整体偏激进(staleTime=0 意味着每次挂载都后台重取),后端出身的你大概想调大 staleTime 换稳定,defaultOptions 一行搞定。
写操作:useMutation 与失效重取
读用 useQuery,写用 useMutation。关键动作在 onSuccess 里:写库成功后,把相关查询缓存标记为失效——正在挂载的相关组件自动重取:
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addTodoApi } from "./api";
function AddTodo() {
const [title, setTitle] = useState("");
const queryClient = useQueryClient(); // 从「容器」拿到那个全局 client
const addTodo = useMutation({
mutationFn: (title: string) => addTodoApi(title),
onSuccess: () => {
// 等价于「更新 DB 后 @CacheEvict("todos")」:
// 前缀匹配,所有以 "todos" 开头的缓存一起失效
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!title.trim()) return;
addTodo.mutate(title);
setTitle("");
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button disabled={addTodo.isPending}>
{addTodo.isPending ? "提交中…" : "添加"}
</button>
</form>
);
}
为什么失效、而不是手动把新数据塞进缓存?因为服务端才是数据的唯一真源。手动改缓存意味着前端要复刻一遍服务端逻辑(排序、分页总数、统计字段),复刻错了,界面就和数据库悄悄分叉;失效重取永远对齐服务端——与「@CachePut 容易脏、@CacheEvict 粗暴但不会错」是同一个权衡。
产品经理若要求「点添加立即看到新条目、不等重取」,那是乐观更新:onMutate 里先用 setQueryData 把假想结果写进缓存(UI 立即变化),失败在 onError 里用快照回滚,onSettled 里统一 invalidate 兜底。三段式套路固定——先改缓存、失败回滚、终态对账——记住思路即可,用到再查官方文档 Optimistic Updates 一节。
缓存领域的老原则「invalidate 优先于 update」:多端写同一份数据时,主动计算缓存新值容易算错;先失效、让下一次读来重建,永远收敛到正确值。invalidateQueries 就是这个原则的前端实现——提交成功不猜结果,把缓存作废,让真源重新说话。
销账:第 32 篇的痛点清单逐条对账
回到第 1 站那张痛点清单,逐条销账:
| 第 32 篇的痛点 | 手写时代的方案 | TanStack Query 的答案 |
|---|---|---|
| 竞态:慢请求反杀 | ignore 标志 + 闭包判断 | queryKey 变化自动隔离,配合 signal 真取消请求(第 34 篇细讲) |
| 无缓存:返回页白屏重来 | 没人写,或手搓 Map | queryKey 命中缓存:先渲染旧数据,再按 staleTime 决定后台刷新 |
| 三态手写:每个组件一对 useState | 复制粘贴 loading / error | isPending / isError / isSuccess 标志位统一供给 |
| 重复请求:同接口打多次 | 防抖、手写去重 Set | 相同 key 的并发请求自动合并成一次 |
| 失败重试:几乎没人写 | 裸奔 | retry 默认 3 次指数退避,可全局配置 |
| 数据过期:完全没管 | 裸奔 | 窗口聚焦、断网重连时自动重取过期数据 |
清完账,第 17 篇悬着的问题也该收尾了:「服务端状态到底放哪」——答案是哪也不放,交给 Query。Zustand / Redux 从此只放真正的客户端状态:登录态、购物车、主题、抽屉开关。判断标准还是第 17 篇那句话:真源在服务端数据库吗?是,就别进 store、也别进 useState——用 useQuery 声明一份,缓存、失效、重试全部外包。
- TanStack Query 是服务端状态的缓存客户端:装配全局 QueryClient、Provider 注入,组件只声明 queryKey + queryFn。
- queryKey 是数组层级的缓存 key(类比 @Cacheable key):key 变即换缓存自动重取;invalidateQueries 按前缀整组失效。
- 五要素:staleTime 管新鲜度、gcTime 管回收、refetchOnWindowFocus 管聚焦刷新、retry 管重试、enabled 管条件发起。
- 写操作 useMutation + onSuccess 里 invalidateQueries,等价于「更新 DB 后 @CacheEvict」;乐观更新 onMutate 先改缓存、失败回滚。
- 服务端状态不再进 Redux / Zustand / useState——store 只留客户端状态,第 17 篇分类的最终落地。
本章回顾
第 32 篇手写取数暴露的所有痛点——竞态、缓存、三态、重复请求——在本篇被一个声明式缓存客户端批量销账:读用 useQuery,写用 useMutation + invalidateQueries,你从「伺候请求」回到「描述数据」。但标志位给了,不代表 UI 就讲究了:骨架屏还是转圈?错误怎么给重试出口?竞态怎么被 key 隔离?下一篇第 34 篇,把「请求的三种状态」在界面上建模清楚,你的前端代码会稳妥一个档次。
Comments · 评论