
一句话:服务端状态是「别人拥有的数据的本地快照」,它需要「缓存、去重、失效、重试、重新验证」——这些用
useEffect手写会写出一堆样板,所以应该交给 TanStack Query / SWR。
一、为什么需要专门的方案
// ❌ 手写 useFetch:看起来能用,但要自己补齐 8 件事
function useUsers() {
const [data, setData] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUsers()
.then((d) => { if (!cancelled) { setData(d); setError(null); } })
.catch((e) => { if (!cancelled) setError(e); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { data, loading, error };
}
这个手写版本缺失的能力:
① 缓存 —— 两个组件用同一个 hook → 发两次请求
② 去重 —— 同时挂载的多个组件都会各自请求
③ 失效 —— 数据变了怎么重新拉?手动 refetch?
④ 重试 —— 失败要不要重试?退避策略?
⑤ 窗口聚焦重验证 —— 用户切回标签页要不要刷新?
⑥ 分页/无限滚动 —— 多页数据怎么合并?
⑦ 乐观更新 —— 提交后立刻反映,失败回滚?
⑧ 内存回收 —— 无人订阅的缓存什么时候清?
⑨ 竞态处理 —— 见 7.2(要自己写 AbortController)
⑩ 全局状态 —— 在「非组件」的地方读写缓存
⭐ 核心结论:
服务端状态的生命周期(缓存 → 新鲜 → 过期 → 失效 → 回收)
与客户端状态完全不同。
用「客户端状态工具」(useState/Redux)管理它,等于要自己重写一遍查询库。
二、TanStack Query 的核心概念
| 概念 | 含义 |
|---|---|
queryKey |
查询的唯一标识(也是依赖声明:key 变 → 自动重新请求) |
queryFn |
返回 Promise 的取数函数(接收 { queryKey, signal, meta }) |
staleTime |
数据「新鲜期」——期间内不会重新请求(默认 0,即「立即过期」) |
gcTime |
无订阅后缓存的保留时间(默认 5 分钟),超时被回收 |
isLoading / isFetching |
首次加载 / 任意时刻正在请求(含后台刷新) |
refetch |
手动重新请求 |
invalidateQueries |
让匹配的查询「失效并重新获取」 |
QueryClient |
缓存的容器(用 QueryClientProvider 提供) |
queryKey 的三种写法:
['todos'] —— 静态 key
['todos', { status, page }] —— 带参数的 key(参数变化自动重新请求)
['todos', id] —— 详情
⚠️ 规范:
① 把「影响结果的所有参数」放进 key
② 只用【原始值】(不要放「每次渲染都新建的对象」)
③ key 应该是【可序列化】且【稳定】的
三、useQuery 用法
import { useQuery } from '@tanstack/react-query';
function UserList({ page, keyword }: { page: number; keyword: string }) {
const {
data,
isLoading, // 首次加载(没有缓存数据)
isFetching, // 任意请求进行中(含后台刷新)
isError,
error,
refetch,
isPlaceholderData,
} = useQuery({
queryKey: ['users', { page, keyword }], // ⭐ key 决定缓存与依赖
queryFn: ({ signal }) => api.getUsers({ page, keyword }, { signal }),
staleTime: 60_000, // 1 分钟内认为数据新鲜
gcTime: 5 * 60_000, // 5 分钟无订阅后回收
enabled: keyword.length > 0, // 条件执行(为 false 时不请求)
placeholderData: (prev) => prev, // ⭐ 参数变化时保留上一份数据(避免闪白)
select: (data) => data.items, // 转换(带记忆化)
retry: 2, // 失败重试 2 次
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorView error={error} onRetry={refetch} />;
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
{isFetching && <TopProgressBar />}
{data.length === 0 ? <EmptyState /> : <List items={data} />}
</div>
);
}
// ✅ 组件外预取(如路由 loader / 鼠标悬停)
import { useQueryClient } from '@tanstack/react-query';
function NavWithPrefetch() {
const queryClient = useQueryClient();
return (
<Link
to="/users"
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: ['users', { page: 1, keyword: '' }],
queryFn: () => api.getUsers({ page: 1, keyword: '' }),
staleTime: 60_000,
})
}
>
用户
</Link>
);
}
四、useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateUserInput) => api.createUser(data),
// ① 乐观更新(见 7.6)
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] }); // 取消进行中的查询
const previous = queryClient.getQueryData<User[]>(['users']);
queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, { ...newUser, id: 'temp' } as User]);
return { previous }; // 传给 onError
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['users'], context?.previous); // ⭐ 回滚
toast.error('创建失败');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // ⭐ 无论成败都重新拉取
},
});
}
// 使用
function CreateUserForm() {
const { mutate, isPending } = useCreateUser();
const handleSubmit = (data: CreateUserInput) => {
mutate(data, {
onSuccess: () => navigate('/users'),
});
};
return <Form onSubmit={handleSubmit} loading={isPending} />;
}
| 回调 | 时机 | 典型用途 |
|---|---|---|
onMutate |
请求发出前 | 乐观更新(保存旧数据供回滚) |
onError |
失败 | 回滚 + 提示 |
onSuccess |
成功 | 提示、跳转 |
onSettled |
无论成败 | 失效查询(最常用) |
五、QueryClient 与 Provider
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 全局默认:1 分钟新鲜期
gcTime: 5 * 60_000,
retry: (failureCount, error) => {
// ⭐ 只重试网络错误与 5xx
const status = (error as { status?: number }).status;
if (status && status >= 400 && status < 500) return false;
return failureCount < 2;
},
refetchOnWindowFocus: true, // 切回标签页时重新验证
},
},
});
createRoot(root).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools /> {/* 开发环境调试面板 */}
</QueryClientProvider>
);
// ✅ SSR 场景下要「每个请求一个 QueryClient」(避免请求间串数据)
// Next.js App Router 的常见写法
function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
}));
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ⚠️ 不要定义成「模块级单例」(会在多个用户/请求间共享缓存)
六、与 SWR 的对比
| 维度 | TanStack Query | SWR |
|---|---|---|
| 定位 | 完整的「服务端状态管理」 | 轻量的「数据请求 Hook」(Vercel) |
| 功能 | 更全(mutation、无限查询、乐观更新、离线支持) | 更简洁、体积更小 |
| DevTools | ✅ 完整 | 基础 |
| 无限滚动 | useInfiniteQuery |
useSWRInfinite |
| 生态 | 框架无关(React/Vue/Solid/Svelte) | 主要 React |
| 学习成本 | 中 | 低 |
| 适合 | 中大型项目、复杂缓存需求 | 简单场景、Next.js 项目 |
// SWR 的写法(更简洁)
import useSWR from 'swr';
function UserList() {
const { data, error, isLoading, mutate } = useSWR('/api/users', fetcher, {
revalidateOnFocus: true,
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorView error={error} onRetry={() => mutate()} />;
return <List items={data} />;
}
七、与 useEffect 手写的对比
| 能力 | 手写 useEffect |
TanStack Query |
|---|---|---|
| 加载/错误状态 | 手动 | ✅ 内置 |
| 缓存(跨组件共享) | ❌ | ✅ 按 queryKey |
| 请求去重 | ❌ | ✅ 自动 |
| 竞态处理 | 手动(AbortController) |
✅ 内置(丢弃过期结果) |
| 失效与重新验证 | 手动 | ✅ invalidateQueries + 聚焦/重连 |
| 重试与退避 | 手动 | ✅ retry + retryDelay |
| 分页/无限滚动 | 手动合并 | ✅ useInfiniteQuery |
| 乐观更新 | 手动 | ✅ onMutate |
| 内存回收 | ❌ | ✅ gcTime |
| 组件外读写 | ❌ | ✅ queryClient |
| 代码量 | 多 | 少 |
// ✅ 从手写迁移到 Query 的对照
// 手写:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => { /* 请求 + 竞态处理 + 状态管理 */ }, [id]);
// Query:
const { data, isLoading } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// ⭐ 竞态、缓存、去重、重试、内存回收全部由库处理
八、常见坑
// ① queryKey 没包含「影响结果的参数」→ 参数变了但数据不更新
useQuery({ queryKey: ['user'], queryFn: () => fetchUser(id) }); // ❌ id 没进 key
useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) }); // ✅
// ② queryKey 里有「每次渲染新建的对象」
useQuery({ queryKey: ['user', { filters: { a: 1 } }] }); // ❌ 每次都变
// ✅ 只放原始值
// ③ 把 `staleTime` 当成「缓存时间」
// → staleTime 是「新鲜期」;缓存回收由 gcTime 控制
// ④ 在 queryFn 里用「闭包捕获的外部变量」而没放进 key
// ⑤ 用 Redux/Zustand 管服务端数据(等于重写查询库)
// ⑥ mutation 后忘记 invalidate → 列表不更新
// ⑦ 在 onSuccess 里手动 setQueryData(不如 invalidate 简单可靠)
// ⑧ 把 QueryClient 定义成「模块级单例」并在 SSR 里共用(串数据)
// ⑨ queryFn 返回的不是 Promise(如忘记 return)
useQuery({ queryKey: ['x'], queryFn: () => { api.get() } }); // ❌ 少 return
// ⑩ 忘记 `enabled`(依赖参数但参数还为空时就发请求)
// ⑪ 分页用 useQuery 而不是 useInfiniteQuery(自己合并缓存很麻烦)
// ⑫ 在「非 React 环境」里直接用 hook(应该用 queryClient 的 API)
// ⑬ 认为「Query 能替代全局状态」(客户端状态仍需 Zustand/Redux)
// ⑭ 忘记配置 `retry` 的「不重试 4xx」(白白重试参数错误)
// ⑮ 对「实时性要求高」的数据用默认 staleTime(应该设 0 或加轮询)
// ⑯ 在测试里忘记包 QueryClientProvider(报错)
// ⑰ 把「大对象」存进缓存但不设 gcTime(内存增长)
// ⑱ 忽略 `isFetching` 与 `isLoading` 的区别(用错导致闪烁)
// ⑨ 的正确写法
useQuery({
queryKey: ['users'],
queryFn: () => api.getUsers(), // ✅ 返回 Promise(箭头函数直接返回)
});
// ⑩ 的写法
useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id, // ⭐ id 存在才请求
});
九、面试延伸
- 「什么是服务端状态?为什么需要专门的管理方案?」
服务端状态指「服务端拥有的数据在本地的快照」(列表、详情、统计),特点是「可能随时过期」(被别的用户/设备/后台改掉)。它需要的能力与客户端状态完全不同:缓存(跨组件共享)、去重(同时请求只发一次)、失效与重新验证(什么时候重新拉)、重试与退避、分页缓存合并、乐观更新与回滚、内存回收。这些用useEffect手写会写一堆样板且容易漏,所以应该交给 TanStack Query / SWR。 -
「
queryKey的作用是什么?」
三个:① 缓存标识——相同 key 共享同一份缓存(多组件不会重复请求);② 依赖声明——key 里任意值变化就自动重新请求(这是「参数变化自动更新」的机制);③ 缓存定位——setQueryData/invalidateQueries/getQueryData都靠 key 找到目标。所以规范是「把影响结果的所有参数放进 key,且只放原始值」(不要放「每次渲染新建的对象」),否则会出现「参数变了数据没更新」或「每次都重新请求」。 -
「
staleTime和gcTime有什么区别?」
staleTime是「新鲜期」——数据在新鲜期内不会因为「组件挂载/重新挂载/窗口聚焦」而重新请求(直接用缓存)。gcTime是「回收时间」——当某个查询没有任何组件订阅时,缓存还能保留多久(超过就被删除,下次请求重新开始)。所以:staleTime短 → 数据更实时但请求更多;gcTime短 → 内存占用低但「返回页面要重新请求」。默认staleTime = 0(立即过期)、gcTime = 5 分钟。 -
「
isLoading和isFetching有什么区别?」
isLoading表示「首次加载」——即「没有缓存数据 + 正在请求」(只有这一种情况为true);isFetching表示「任意时刻正在请求」——包括「后台刷新」「参数变化后的重新请求」「手动 refetch」等。所以:isLoading用来决定「是否显示骨架屏」(首次才会没数据);isFetching用来显示「顶部进度条/半透明」这类轻提示(因为旧数据还在,不该清空界面)。 -
「
mutation的四个回调分别在什么时候执行?」
onMutate——请求发出前执行,用于「乐观更新」(先改本地缓存,并返回旧数据供回滚);onError——请求失败时执行,用于「回滚 + 提示」(接收onMutate的返回值);onSuccess——请求成功时执行,用于「提示/跳转」;onSettled——无论成功失败都执行,最常用于「invalidateQueries(失效并重新拉取)」。实践中的标准组合是「onMutate乐观更新 →onError回滚 →onSettled失效」。 -
「Query 能替代 Redux 吗?」
部分可以(服务端数据部分),但不能完全替代。Query 管的是「服务端状态」;客户端状态(主题、UI 状态、购物车草稿、多步表单)仍然需要useState/ Context / Zustand / Redux。而且 Query 的缓存是「按 queryKey 组织的」,不适合表达「复杂的客户端业务逻辑」(如「编辑器状态机」「撤销重做」)。所以正确的划分是「服务端状态用 Query,客户端状态用 store」,两者互补(见 3.10)。 -
「怎么在路由切换时预取数据?」
三种方式:①queryClient.prefetchQuery——在onMouseEnter(悬停链接)或路由loader里调用,提前把数据放进缓存,目标页面渲染时直接命中;② 数据路由的loader——loader: () => queryClient.ensureQueryData({ queryKey, queryFn })(既有「渲染前就绪」又有 Query 缓存,见 6.9);③<Suspense>+useSuspenseQuery——让「数据未就绪」时显示 fallback(而不是组件内部判断 loading)。 -
「什么数据不适合用 Query?」
① 客户端状态(UI 状态、偏好、编辑器内容)——它不是「服务端数据的快照」,用 store;② 实时性极高且变化频繁的数据(如 WebSocket 推流、协同编辑的字符)——Query 的「请求-缓存」模型不适用,应该用「订阅 + 本地状态」;③ 一次性、不需要缓存的请求(如埋点上报、日志)——直接调用请求层即可;④ 需要复杂本地变换的数据(多步编辑后再保存)——应该用「草稿状态 + mutation 提交」。
一句话速记
服务端状态是「别人数据的本地快照」(随时可能过期),需要缓存/去重/失效/重试/重新验证/分页/乐观更新/GC → 用 TanStack Query / SWR(不要手写
useEffect,也不要塞进 Redux);queryKey是「缓存标识 + 依赖声明」(参数必须进 key、只放原始值);staleTime管「新鲜期」、gcTime管「无订阅后保留多久」,默认staleTime = 0;isLoading是首次加载(决定骨架)、isFetching是任意请求中(决定轻提示);useMutation的标准组合是「onMutate乐观更新 →onError回滚 →onSettled失效」;客户端状态仍用 store(两者互补);预取用prefetchQuery/ loader 里ensureQueryData;SSR 下 每个请求一个QueryClient(别用模块级单例)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!