
做前端这几年我越来越觉得前端工程里真正复杂的事往往不是组件树的组织也不是那些花里花哨的交互动画而是“服务端状态的管理”。TanStack Query这个名字你可能听过它以前的曾用名叫React Query2022年改组成TanStack系列之后正式把支持面扩展到了Vue、Solid、Svelte等框架。这个库的核心任务只有一个把异步请求的数据当作一等公民来管理让我们从手动维护loading、error、data的泥潭里抽出身来。今天这篇内容我会从“查询到底是怎么跑起来的”这个角度切入把TanStack Query的查询机制拆开揉碎讲一遍。全文不追求百科全书式的API罗列而是按照我实际项目里的使用路径讲清楚查询键的设计、状态机的运转、缓存和失效的时机、以及分页和无限加载这些高频场景。适合刚接触TanStack Query、想搞懂它内部逻辑的开发者也适合已经用过但总感觉“不太放心”的朋友。这库的价值很大但如果你只是照着文档抄用法不去理解查询状态的流转逻辑那它带给你的就只有黑魔法和无尽的困惑。1. 为什么需要TanStack Query它解决的是哪一类问题1.1 先看没有它的时候我们是怎么被逼疯的我接手过一个中后台项目页面结构不复杂无非就是订单列表、订单详情、客户信息、统计面板这几块。但代码里到处都是useEffect里发请求、手动setLoading、setData的模式。一开始还好等到列表页需要和详情页共享数据、筛选条件要联动、翻页要保留之前的数据时代码开始失控。举个具体的例子订单列表页有筛选条件用户选了“待付款”状态点进一个订单详情再返回列表要重新请求。这个过程中我们得手动判断“要不要显示loading”“数据要不要清空”“返回的时候列表要不要重新拉一遍”。如果用户点进详情页后改了订单状态返回列表时还得想办法让列表刷新。这些逻辑写到最后全是在维护一组组flag和临时状态稍有遗漏就出现“列表数据跟详情状态对不上”的bug。这类问题的本质是什么是服务端状态和客户端状态被混在一起管理了。服务端状态有一个非常重要的特征它是“源”不是“副本”客户端拿到的只是某个时间点的快照。既然是快照就存在新鲜度的问题既然有新鲜度问题就需要缓存、失效、重取这一整套策略。而大多数前端项目都把这件事简化为“发个请求拿个返回值”完全没有给缓存和同步留出设计空间。1.2 TanStack Query给的答案把请求变成“声明式”TanStack Query的解题思路其实有点像关系型数据库里的缓存视图。它不替代fetch或axios也不管你请求怎么写它管的是请求之后的那一层查询的发起时机、返回数据的缓存、缓存的新鲜度、失效后的自动重取。用它的方式写请求代码大致长这样const { data, isPending, error } useQuery({ queryKey: [order, orderId], queryFn: () fetchOrder(orderId), });对比一下useEffect手写版本少了loading状态的定义、少了请求函数的封装、少了组件卸载时的忽略逻辑、少了错误状态的映射。你只需要告诉TanStack Query这两件事第一这个查询的“标识”是什么第二怎么把数据拿回来。剩下的一切包括重复请求的合并、缓存数据的复用、窗口重新聚焦时的刷新它全部接管。我个人的理解是TanStack Query让我们从“命令式地写请求”变成了“声明式地描述数据依赖”。组件不再关心“什么时候去取数据”而是声明“我依赖这个标识的数据”至于数据在缓存里有没有、是不是新鲜、要不要重新拉取这些是TanStack Query的调度逻辑决定的。这个思想上的转变是它和普通请求封装库最大的区别。2. 从零到跑通搭建与核心概念2.1 安装和Provider配置老项目接入TanStack Query的成本非常低两步就走完了。第一步装包npm install tanstack/react-query # 或 pnpm add tanstack/react-query第二步在应用根部包一个Provider传入一个QueryClient实例import { QueryClient, QueryClientProvider } from tanstack/react-query; const queryClient new QueryClient(); export default function App() { return ( QueryClientProvider client{queryClient} YourApp / /QueryClientProvider ); }这个QueryClient是整个缓存的中枢神经所有查询的缓存数据都存在它内部。你可以把它理解成应用里的“缓存数据库连接池”组件树里任何useQuery调用最终都会找到这个client来读写缓存。如果一个应用里有多个QueryClient它们之间的缓存是完全隔离的——实际项目中一般不需要这么做但微前端场景下可能会遇到到时候记住这个机制就行。2.2 一个最小可用的查询到底长什么样接下来写一个最简单的查询。假设我们有一个获取用户信息的接口import { useQuery } from tanstack/react-query; async function fetchUser(id: number) { const res await fetch(/api/user/${id}); if (!res.ok) throw new Error(Network error); return res.json(); } function UserProfile({ userId }: { userId: number }) { const { data, isPending, error } useQuery({ queryKey: [user, userId], queryFn: () fetchUser(userId), }); if (isPending) return div加载中.../div; if (error) return div加载失败{error.message}/div; return div{data.name}/div; }这里面有两个必填参数queryKey和queryFn。queryFn负责实际的数据请求它的返回值会直接成为data的值。这里有一个容易被忽视的点queryFn里如果throw了ErrorTanStack Query会把错误放进error状态里同时自动触发重试机制。所以接口层的错误处理尽量不要吞掉异常至少要throw出来否则UI层拿不到错误信息。2.3 查询键的设计直接影响缓存命中率queryKey是TanStack Query里最容易被低估的设计。它的作用不仅仅是“给查询起个名字”而是整个缓存系统的索引。你可以把它类比成数据库表的主键或者Map的key。同一个queryKey对应一份缓存数据queryKey变则缓存分家。TanStack Query内部对queryKey做序列化时会先把对象里的key排序再做稳定的JSON序列化最后生成一个哈希值。这意味着下面两个写法是等价的useQuery({ queryKey: [user, { id: 1, role: admin }], queryFn: ... }) useQuery({ queryKey: [user, { role: admin, id: 1 }], queryFn: ... })对象属性顺序不影响缓存命中。这个设计很贴心但也带来了一个常见的坑如果你在queryKey里用了Date对象、undefined这类无法稳定序列化的值序列化结果可能不符合预期导致缓存无法命中。我一般要求团队里所有queryKey都由“字符串常量 基础类型参数”组成对象也必须是纯数据对象不要塞函数、类实例进去。一个具体的设计案例订单列表支持按状态筛选那么queryKey就该把筛选条件带进去// 每个筛选组合都是独立的缓存 useQuery({ queryKey: [orders, { status, page, pageSize }], queryFn: () fetchOrders({ status, page, pageSize }), });这样用户从“待付款”切到“已完成”再切回“待付款”只需要第一次拉接口后续直接走缓存。如果把筛选条件漏在queryKey外面就会出现一个很隐蔽的bug筛选条件变了请求也发了但由于queryKey没变UI拿到的还是上一次的数据。这个问题排查起来极其浪费时间后面我会在问题篇里专门展开。3. 查询状态机loading、error、success背后发生了什么3.1 五个状态与两个获取子状态别再傻傻分不清任何一个查询在任意时刻都处于一个确定的状态。TanStack Query v5的useQuery返回值里有几个关键状态字段isPending、isError、isSuccess、isFetching、isStale。前三个好理解对应UI的三态。容易绕晕的是isFetching和isPending的区别。isFetching表示“当前有没有请求正在跑到路上”它关注的是网络行为本身。哪怕数据已经在缓存里了只要它触发了重新请求isFetching就会变成true。而isPending表示“当前有没有可用的数据”首次加载且还没拿到数据时isPending为true一旦data有值了isPending就变成false。这两个字段单独看都不复杂但组合起来能表达四种场景场景isPendingisFetching说明首次加载truetrue没数据请求还没回来缓存命中后后台刷新falsetrue有旧数据先展示新数据在路上缓存命中且新鲜falsefalse数据直接可用不需要请求首次请求失败truefalse可能重试中或已放弃这个状态模型是TanStack Query体验好的根基。它让UI可以做到“有旧数据先用旧数据新数据到了再静默更新”也就是常说的stale-while-revalidate策略。在v5版本里还多了一个isLoading字段它是isPending isFetching的语法糖只在“无数据且加载中”时为true方便兼容旧习惯。3.2 数据什么时候会重新请求stale、focus、reconnectTanStack Query不会无缘无故地重复请求。它触发重新请求的时机完全由“数据新鲜度”和“观察者状态”共同决定。先说新鲜度。每个查询的数据都有一个staleTime默认是0。0的意思是只要请求成功返回数据立刻就是“过期”的。过期不意味着马上重新请求它只意味着“下次碰到重新请求的触发条件时我需要再拉一遍”。如果staleTime设成30000那30秒内的重复访问都会直接命中缓存不会发请求。再说触发条件。最常见的有四个新的组件挂载且useQuery的queryKey在缓存中没有对应数据页面窗口重新获得焦点refetchOnWindowFocus默认开启网络断线重连refetchOnReconnect默认开启手动调用refetch方法其中窗口聚焦这个默认行为很多人刚接触时会觉得“怎么切个tab回来就发请求了”但它其实是TanStack Query在帮我们做数据同步。想想看用户切走再切回来这个页面可能已经展示了几分钟前的旧数据了刷新一下往往是对的。如果你的查询数据特别静态比如下拉框里的配置项那可以单独给这个查询关掉聚焦刷新。这些机制合在一起构成了TanStack Query的“查询时钟”。理解了这个时钟你才知道什么时候该调staleTime、什么时候该关自动刷新而不是遇到问题就一律refetchOnWindowFocus: false。4. 查询参数调优这些配置直接决定用户体验4.1 staleTime和gcTime别再傻傻分不清这两个参数是我在实际交流中发现最容易混淆的一对。staleTime管的是“数据从什么时候开始算过期”gcTime管的是“缓存里的数据在多久没被使用后从内存里清掉”。它们的默认值分别是0和5分钟。我打一个比方staleTime就像冰箱里酸奶的保质期保质期内你喝着放心过期了你可能看一眼再决定要不要喝gcTime就像冰箱的清洁周期放太久的酸奶不管过没过期都会被扔掉腾地方。在实际项目里这两个参数的调优思路完全不同。像用户列表这种可能随时变化的数据staleTime设短一点比如10到30秒像国家列表、字典项这种几乎不变的数据staleTime可以直接设成5分钟甚至1小时省掉无谓的请求。而gcTime的调整场景主要是“返回上一页时要保留数据多久”。比如订单列表进详情详情页改状态后返回列表我们希望列表数据还在那gcTime就得大于用户停留在详情页的时间默认5分钟通常够用但如果你知道用户会长时间停留可以适当调大。一个实测场景我们有个报表页面切到昨日的分页数据再切回今天因为gcTime还没到期列表瞬间展示配合placeholderData保留旧数据用户完全感受不到加载过程。4.2 retry、retryDelay和请求取消默认情况下TanStack Query会自动重试失败的查询三次重试间隔采用指数退避策略从1秒开始依次翻倍最多不超过30秒。这个默认行为适合网络条件不稳定时的列表查询但如果你的接口是鉴权类、校验类、或者写操作重试就不太合适了。我处理过的一个案例登录接口用了useQuery去获取用户信息token过期后接口返回401TanStack Query默认重试了三次每次用户都看到“加载失败”的报错弹窗闪三次。后来在queryFn里对401做了特殊处理把错误抛出后设置retry: false才解决了这个问题。超时控制则是另一件容易踩坑的事。fetch自带的AbortSignal可以配合TanStack Query的queryFn参数使用const { data } useQuery({ queryKey: [order, orderId], queryFn: ({ signal }) fetch(/api/order/${orderId}, { signal }), });当组件卸载、查询被取消时TanStack Query会自动调用signal.abort()帮我们中断正在进行的请求避免在页面离开后收到返回来更新已卸载组件的状态。手动封装的请求库如果支持signal都建议传一下。4.3 全局默认配置和单查询覆盖实际项目里很少会每个查询单独配参数。我一般会在创建QueryClient的时候统一设一套默认值个别查询再单独覆盖const queryClient new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, gcTime: 10 * 60 * 1000, retry: 2, refetchOnWindowFocus: true, refetchOnReconnect: true, }, }, });这套全局配置的优先级是useQuery的参数 QueryClient的defaultOptions TanStack Query的默认值。比如全局关掉了窗口聚焦刷新但某个实时性要求高的查询单独开了refetchOnWindowFocus: true那这个查询依然会在聚焦时刷新。这里也要提醒一句全局参数不是设得越大越好。staleTime设太长会导致用户看到的数据长时间不更新retry设太多接口真的挂了时用户的等待时间会异常久。配置的核心思路是“按数据的实时性需求来分档”而不是一刀切。5. 查询的进阶实战派生、分页、无限加载5.1 useQueries一次性并行发起多个请求有些页面天然需要一次性并发拉取多个数据。比如一个用户中心首页顶部要显示用户信息侧边要显示订单统计底部要显示最近的消息。三个数据互相独立但UI需要它们全部就绪后一起渲染。如果手动写可能会这样const [user, setUser] useState(null); const [stats, setStats] useState(null); const [messages, setMessages] useState(null); useEffect(() { fetchUser().then(setUser); }, []); useEffect(() { fetchStats().then(setStats); }, []); useEffect(() { fetchMessages().then(setMessages); }, []);三个loading状态三个error状态代码冗长不说还要处理“其中一个失败其他要不要展示”的边界情况。用useQueries就干净很多const results useQueries({ queries: [ { queryKey: [user], queryFn: fetchUser }, { queryKey: [orderStats], queryFn: fetchOrderStats }, { queryKey: [recentMessages], queryFn: fetchRecentMessages }, ], }); const isAllReady results.every((r) r.isSuccess);useQueries返回的是一个数组每一项的结构和useQuery的返回值一致。它解决了两个问题一是代码上的平铺不再为每个请求写一个hook二是状态上的聚合虽然还是要自己写every判断但至少比手动管理三个loading要清晰得多。如果你的项目里有多张图表同时刷新这个API几乎是必备。5.2 分页查询的缓存保活让用户翻页不等待分页是后台管理系统的日常。用TanStack Query做分页核心思路是把页码塞进queryKeyconst { data, isPending } useQuery({ queryKey: [orders, { page, pageSize }], queryFn: () fetchOrders({ page, pageSize }), placeholderData: (previousData) previousData ?? undefined, });注意到那个placeholderData了吗它的作用是当页码变化、新数据还没回来时先用上一页的数据占位。没有它用户从第1页点到第2页界面会立刻变成loading体验很割裂。有了它第2页的数据还在请求中时界面继续展示第1页的内容等第2页数据到了再替换。在TanStack Query v4里这个功能叫keepPreviousDatav5里改成了placeholderData回调函数。这不只是命名变化语义也更准确了——它不只是“保留上一页”而是“用任意占位数据先顶着渲染”。比如你可以用一个空数组占位或者用固定的本地数据。跟staleTime配合起来用户在某页停留久了再翻到相邻页请求可能根本不会发生直接走缓存。5.3 useInfiniteQuery无限滚动列表的推荐姿势资讯流、动态列表、评论列表这种“往下滑就加载更多”的交互手写起来非常繁琐。要记录当前页游标、要把新数据拼进数组、要判断还有没有下一页。useInfiniteQuery把这些逻辑都收编了const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } useInfiniteQuery({ queryKey: [feed], queryFn: ({ pageParam }) fetchFeed({ cursor: pageParam }), initialPageParam: 0, getNextPageParam: (lastPage) lastPage.nextCursor ?? undefined, });这里面的关键是getNextPageParam。它接收上一次请求的返回值从中提取下一次请求需要的游标参数返回undefined就代表没有更多数据了。data.pages是一个数组每一页的数据在里头渲染时用flatMap展开即可。实际项目里无限滚动还需要配合一个观察者来触发加载更多。比如用IntersectionObserver监听列表底部的哨兵元素进入可视区域就调用fetchNextPage。组合起来的效果是用户滚到底部自动加载下一页加载过程中底部的loading小菊花转圈所有页拉完了hasNextPage变为false不再触发请求。配合staleTime和gcTime用户的浏览历史在会话内还可以保持体验提升非常明显。6. 常见问题与排查技巧实录6.1 数据不更新第一反应去查queryKey我这个经验是用加班换来的。那次是筛选条件从“全部”切到“待付款”列表数据纹丝不动。第一反应是接口没调打开Network一看接口确实发了但渲染的还是旧数据。折腾了半小时最后发现queryKey里写的是useQuery({ queryKey: [orders], queryFn: () fetchOrders({ status }), });status在queryKey外面意味着任意status变化queryKey都是[orders]自然命中同一份缓存。这就是我前面强调“queryKey必须包含所有影响数据的变量”的真实代价。排查手段其实可以很快打开TanStack Query的DevTools面板或者直接在代码里把queryKey打出来看看它到底因为什么而变化。6.2 mutation之后界面没刷新忘了invalidateQueries查询是读mutation是写。读的数据缓存了写完如果不让缓存失效UI自然还是旧数据。正确做法是在mutation成功后把相关查询标记为过期const queryClient useQueryClient(); const updateOrderMutation useMutation({ mutationFn: updateOrder, onSuccess: () { queryClient.invalidateQueries({ queryKey: [orders] }); }, });invalidateQueries会把queryKey匹配到的查询标记为stale并触发重新拉取。注意匹配规则是“前缀匹配”所以[orders]能命中[orders, { status: pending }]这样的子查询。如果你的列表页有多个筛选组合一个invalidateQueries就能把它们全部刷新。6.3 页面离开后还收到请求结果善用signal排查过一个偶发报错信息是“Cannot read properties of null”出现在用户快速切换路由的时候。原因是组件已经卸载但请求结果在Promise回调里setState了。React 18之前的版本会直接警告18之后虽然不报warning但依然可能导致内存泄漏和状态错乱。TanStack Query恰好帮我们规避了这个问题——queryFn里的signal会随着查询取消而触发abort只要在fetch里传了signal请求就会被正确中断。如果你的请求库不支持signal那至少要在组件卸载时手动调用queryClient.cancelQueries来取消查询。6.4 SSR场景下页面闪现加载态hydrate缓存Next.js这类SSR框架里服务端已经取到的数据客户端如果不做处理会先走一遍isPending状态再重新发一次请求这就是那种“页面数据闪一下loading”的体验来源。解法是服务端用dehydrate序列化缓存客户端用hydrate还原// 服务端 const queryClient new QueryClient(); await queryClient.prefetchQuery({ queryKey: [user], queryFn: fetchUser }); const dehydratedState dehydrate(queryClient); // 客户端 HydrationBoundary state{dehydratedState} App / /HydrationBoundaryHydrationBoundary会把服务端缓存的数据注入客户端的QueryClient首屏就能直接使用不会再发重复请求。这是TanStack Query在SSR场景下最容易忽略但收益最高的一步。7. 一些经验和思考TanStack Query这套工具用熟之后你会慢慢意识到它表面上是把请求封装了一层本质上是在强制你思考“数据从哪来、什么时候该更新、什么时候可以复用”。这套思维带来的收益远比API本身大。我个人在项目里会定期做一次查询的“卫生检查”查一遍每个useQuery的queryKey是否包含所有依赖变量staleTime是否合理mutation之后是否都invalidate了关联查询已经写进代码的refetchOnWindowFocus: false是不是真的有必要。每次检查都能发现一两个潜在的问题尤其是那种“看起来能用但换个场景就会出bug”的状态。最后分享一个我在团队里定的小规范自定义请求hooks统一返回TanStack Query的原始结果对象不要只返回data。这样调用方可以自由决定用isPending还是isFetching、要不要展示刷新状态、有没有错误信息可展示。宁可调用方多解构几个字段也不要把查询状态抹平成一个简化版的结果。状态信息是调试时最直接的线索保留它们比省那几行解构代码划算得多。