深入理解 TanStack Query 的重新获取机制:缓存、失效与实战
我见过很多团队从 Redux 全家桶迁到 TanStack Query之前叫 React Query第一周都很爽因为代码量肉眼可见地少了一半。但用着用着就有人开始问为什么我的列表不刷新为什么接口被疯狂请求为什么页面切回来数据还是旧的这些问题十有八九都出在“重新获取”refetch这件事上。这个标题看起来就四个字背后却牵扯出 TanStack Query 的缓存模型、失效机制、窗口焦点监听、重试策略、竞态处理一整套东西。如果你只是照着文档抄了useQuery的基本用法那“重新获取”就是你迟早要撞的墙。这篇文章我打算从底层机制讲到高频封装再讲几个我实际踩过的坑争取一篇讲透。1. 先搞懂 Query 的“缓存快照”到底是怎么运作的1.1 不是响应式数据而是带时间戳的缓存很多从 Redux 切过来的人下意识会把useQuery返回的data当成 store 里的全局状态这是第一个误区。TanStack Query 里的 Query 本质上是一个“异步数据缓存快照”它有几个关键属性data、dataUpdatedAt、status、fetchStatus、isStale。这些属性共同决定了一个 Query 实例的行为。缓存的存储位置在QueryCache中每个 Query 由queryKey唯一标识。queryKey起不到唯一标识作用时比如你只用了一个字符串todos那么所有组件里调用useQuery(todos)共享的是同一份缓存。这不算是错误但会带来全局失效、全局刷新等连锁效应。dataUpdatedAt是理解一切重新获取行为的核心时间戳。它记录的是最后一次成功获取并写入缓存的时间。这个时间戳参与三个判断判断数据多“新鲜”判断是否需要被重新获取判断缓存是否可以被直接使用可以说dataUpdatedAt是 Query 的“本地时间”没有它“重新获取”这个概念就不成立。1.2 staleTime、gcTime 和“新鲜度”的关系官方文档里有两个初学者最容易混淆的参数staleTime和gcTimev5 之前叫cacheTime。它们分别管的是“数据多久过期”和“缓存多久销毁”是完全不同的两件事。staleTime的默认值是0意思是查询一旦完成数据立刻被视为 stale过期。所以在默认配置下只要组件重新挂载、窗口重新聚焦、网络重新连接都会触发重新获取这也是很多新手觉得“怎么老是发请求”的根本原因。gcTime的默认值是5 * 60 * 10005 分钟。它管的是当没有组件在使用某个 Query 时这份缓存还能在内存里保留多久。过了这个时间才会被垃圾回收。gcTime 内如果组件又重新使用了同 key 的 Query缓存会直接恢复不会触发重新获取除非数据已经 stale。一个生活化的类比staleTime像面包的保质期过期了可以再吃但要重新热一下重新获取gcTime像冰箱的保存期限超出保存期就直接扔掉了。需要特别注意的是gcTime必须大于staleTime。如果gcTime小于staleTime就会在数据仍处于新鲜期时被销毁重新挂载后又要白请求一次反而违背了缓存的意义。1.3 重新获取不是“重新渲染”而是“重新请求”再明确一个概念重新获取refetch指的是向服务端重新发送请求并收到新数据后更新缓存。它和组件重新渲染是两个层面的事情。React 组件会因为状态变化而重新渲染但只有当 Query 触发重新获取并且数据更新后data引用发生变化组件才会因为useQuery的返回结果而再次渲染。实战中经常遇到这种场景某个筛选条件变了你更新了queryKey中的参数组件重渲染了但接口没有发出去。原因往往是你在原来的 Query 上“修改了参数”却没有用一个新的 key。TanStack Query 只会把不同 key 当作不同缓存同一个 key 的参数变化不会自动重新请求除非你手动调用refetch或者使用invalidateQueries。所以“重新获取”的真正触发源有四个维度需要分开记忆主动触发refetch()、invalidateQueries()、refetchQueries()窗口焦点变化refetchOnWindowFocus网络重新连接refetchOnReconnect组件重新挂载refetchOnMount这四个维度我下面逐个展开讲。2. 谁在什么时候触发重新获取四个触发源逐个拆解2.1 主动触发refetch 与 invalidateQueries 的本质区别主动触发是最好理解的但也是被用得最乱的。直接调用refetch就好比“强制刷新”不管数据新鲜与否无条件发起一次请求。invalidateQueries则更像是“标记作废”它把对应的 Query 标记为 stale然后由 TanStack Query 根据当前状态决定“是否立即重新获取”。invalidateQueries做标记后如果某个 Query 正处于active状态即页面上有组件在用它会立刻触发重新获取如果 Query 处于inactive状态则只标记 stale等待下次被激活时再重新获取。这个细节非常关键很多人写 mutation 成功后执行invalidateQueries但页面当时不在前台等切回来时发现数据没刷新就是因为这个 inactive 的懒惰策略。还有一点refetchQueries和invalidateQueries的行为不一样。refetchQueries是直接无条件触发指定 Query 的重新获取不管它是否 active 或 stale。invalidateQueries默认只“筛选出要作废的 Query”再根据最终观察者observer状态决定是否拉取。给我的建议是mutation 成功之后优先用invalidateQueries而不是refetchQueries。因为前者语义更清晰会让状态自然是“过期”的后续该刷新时会刷新后者更像“跳过检查直接开枪”短期看没问题时间长了容易在复杂页面上造成多余请求。2.2 refetchOnWindowFocus默认开启的“隐形请求源”TanStack Query 的默认配置里refetchOnWindowFocus是true。这意味着用户从 devtools 切回来、从别的标签页切回来、甚至点击一下浏览器窗口让它获得焦点都会触发重新获取。这个设计初衷是好的让页面回到前台时数据尽量新鲜。但实际开发中这个默认值常常给团队带来不少困扰。什么时候应该关掉比如你的接口比较昂贵、数据变动频率很低、或者你已经在用 WebSocket/SSE 做消息推送。开着它用户的每次点击都可能给服务器带去无谓的压力。什么时候必须换成函数比如对于不同的 Query 想采用不同的策略。refetchOnWindowFocus可以接收一个函数函数收到当前 Query 实例返回布尔值这样你就可以基于 queryKey 决定是否聚焦刷新。一个常见实践refetchOnWindowFocus: (query) { // 忽略用户资料等低频数据避免高频刷新 if (query.queryKey[0] user-profile) return false; // 对于列表数据聚焦时刷新 if (query.queryKey[0] todos) return true; // 其他情况保持默认 return true; }这比全局一刀切更精细。我经常看到有人直接把全局refetchOnWindowFocus设成false结果窗口焦点刷新全没了某些需要实时性的页面反而出了数据过期问题。更好的做法是先逐条判断再统一兜底。2.3 refetchOnReconnect 与 refetchOnMount容易被忽略的规则refetchOnReconnect很好理解网络从断网恢复时触发重新获取默认是true。但在移动端 Web 和桌面端浏览器里网络状态变化没有想象中频繁这个配置偶尔会被误触发比如浏览器短暂断开又重连。如果你有特别大的请求可以谨慎一点设置一个节流时间或者在网络恢复监听里自己做去重。refetchOnMount默认值是true但它有一个更精确的类型可以设为always或者布尔值。它的实际行为取决于数据是否 staletrue仅在组件挂载时数据是 stale 才重新获取false挂载时永不重新获取直接用缓存always不管数据是否新鲜挂载时都重新获取这个参数很容易被忽略但很影响体验。比如你已经用prefetchQuery预加载了数据然后跳到详情页此时如果不希望详情页再请求一次就把详情页的refetchOnMount设为false。反过来有些实时性要求高的看板页面就可以设为always保证每次进入都是最新数据。2.4 组件卸载、缓存保留与“重新获取”之间的三角关系还有一个常见的困惑组件卸载后重新进入为什么有时会触发请求有时不触发这其实要看两个时间staleTime和gcTime。组件卸载后如果 Query 进入inactive状态但缓存还没被垃圾回收再次挂载时默认情况下会直接使用原有缓存。此时是否重新获取决定因素就是staleTime里数据是否已经过期。数据没过期直接用缓存不发请求数据已过期显示旧数据的同时发起 re-fetch页面是“先有内容后静默刷新”这种“先有内容再刷新”的机制在用户体验上是友好的符合“保留上次状态”的预期。但如果你发现有的页面数据回退到旧状态再闪烁变新那就是因为重新获取成功前React 渲染了缓存里的旧数据。处理这种闪烁有几种思路用isFetching判断显示加载遮罩用placeholderData配合keepPreviousData保持上一页数据连续把staleTime调大减少这种情况出现每种思路都有自己的适用场景不存在银弹。3. 核心参数逐项调优从 0 到 1 的配置心法3.1 defaultOptions 全局配置 vs 单查询局部配置刚开始用 TanStack Query 时我习惯在每个页面里单独写useQuery配置后来发现defaultOptions才是省心的起点。在QueryClient初始化时就定义好全局默认行为个别页面再覆盖局部配置这种“全局定基调、局部做微调”的模式在团队协作里非常必要。export const queryClient new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, // 30 秒内不重新获取 gcTime: 5 * 60 * 1000, // 5 分钟缓存 retry: 2, // 失败重试 2 次 refetchOnWindowFocus: true, // 默认开启 refetchOnReconnect: true, refetchOnMount: true, }, mutations: { retry: 1, }, }, });全局配置的目的是“安全默认值”让大多数接口遵循相同的缓存规则减少心智负担。之后如果某个查询需要特殊处理再单独传入参数覆盖。比如列表页里的商品价格价格变动比较敏感可以单独调低staleTimeuseQuery({ queryKey: [product, id], queryFn: fetchProduct, staleTime: 5 * 1000, // 5 秒内不重复获取5 秒后允许重新获取 });比如用户头像这种几乎不变的可以设置staleTime: Infinity相当于“永远强制走缓存”只在你主动失效时才发请求。3.2 staleTime 的取值策略不同业务不同新鲜度staleTime没有一个万能值它取决于业务对数据“新鲜度”的要求。我一般按以下几种类型来区分用户基础信息头像、昵称staleTime: Infinity只在资料更新后手动invalidate商品详情、用户持仓staleTime: 30_000兼顾新鲜度和请求量实时行情、物流轨迹staleTime: 0默认行为保证每次切换页面或聚焦都重拉静态字典、配置项staleTime: 10 * 60 * 1000可接受十分钟内的旧数据这里有个细节staleTime: 0和staleTime: 1有本质区别吗从源码上看staleTime: 0意味着数据在now这个时刻就已经过期任何检查 refetch 的条件都会认为“需要重新获取”。而staleTime: 1意味着 1 毫秒内算新鲜实质区别不大。所以不要试图用 0 和 1 做精细控制意义不大。3.3 retry 与 retryDelay重新获取失败的“退避战术”重新获取不只会成功还会失败。TanStack Query 把请求失败后的重试也视为“重新获取”的一种不过它的机制更精细有retry和retryDelay两个参数控制。retry可以是数字表示额外重试次数也可以是true无限重试或false不重试还可以是函数根据失败次数和错误对象来决定是否继续重试。retryDelay默认是Math.min(1000 * 2 ** attempt, 30000)即指数退避第一次重试等 1 秒第二次等 2 秒第三次 4 秒……封顶 30 秒。实战里最常见的需求是非业务错误不重试。比如 401、403、400 这类因为权限或参数问题导致的失败重试多少次都没意义。你可以这么写useQuery({ queryKey: [todos], queryFn: fetchTodos, retry: (failureCount, error) { if (error instanceof AuthError) return false; return failureCount 3; }, });这里的关键是“区分错误类型”。TanStack Query 默认会把任何抛出的异常包装成Error所以建议在queryFn里就把 HTTP 状态码映射成自定义错误类才能在重试策略里做出正确判断。3.4 keepPreviousData翻页时“重新获取”的正确姿势列表页翻页是一个高频场景。传统的做法是点击下一页时清空列表并显示 loading新数据到了再渲染。TanStack Query 的优势在于可以配合placeholderData的keepPreviousData函数让旧的列表数据在请求期间继续展示。useQuery({ queryKey: [projects, page], queryFn: fetchProjects, placeholderData: keepPreviousData, });注意这里的keepPreviousData不是“新数据”而是作为 placeholder。New Data 回来后列表直接替换中间不会有空白 loading。效果上类似“乐观更新”但实际上底层逻辑完全不同TanStack Query 的data在请求期间仍然是旧数据只是isPlaceholderData标记为 true。这里要强调一点使用keepPreviousData后重新获取发生时data不会置空这对于避免页面闪烁、避免图表抖动非常有帮助。但副作用是如果你在data上做了很重的派生计算旧数据参与计算会有 1 帧到几帧的延迟一般可以忽略。我个人的习惯是分页列表基本都开keepPreviousData搜索联想结果则不需要因为搜索词变了旧结果继续展示会误导用户此时直接清空更合理。4. 窗口聚焦、网络恢复背后的循环依赖与竞态问题4.1 fetchStatus 与 status两个状态到底谁在决定 UI很多新手会被isLoading、isFetching、isPending这几个状态搞晕。我先给结论status表示数据的状态可以是pending、error、successfetchStatus表示请求的动作状态可以是fetching、paused、idleisLoading其实是isPending isFetching它是这两个维度合并后的结果。isFetching只关心“此刻有没有请求在飞”哪怕有旧数据也可能isFetching true而isLoading必须满足“还没有 data 正在请求”。举个例子一个已经成功加载过数据的列表因为窗口聚焦触发了重新获取。此刻status是success因为已有数据但fetchStatus是fetching。如果你在 UI 上根据isLoading判断是否转圈这个重新获取过程不会显示 loading如果你错误地用isFetching判断那整个列表可能频繁闪烁 loading。正确做法是首次加载用isPending或isLoading后台重新获取用isFetching且isPending false做成小 spinner 或顶部进度条错误处理用isError4.2 竞态快速切换筛选条件时旧响应覆盖新响应竞态问题在轮询和快速切换筛选条件下最容易爆出来。之前有个同事做报表切换日期范围时数据偶尔会闪现旧数据排查了半天才发现是旧请求比新请求晚返回覆盖了正确数据。TanStack Query 从 v4 开始内置了竞态保护同一 QueryKey 下的查询只有最新的请求才会被使用旧请求返回后如果发现不是最新版本会被丢弃。这解决了“同一个 key 的竞态”。但是如果两次请求因为参数变化组合成了不同的 queryKey那它们其实会被当作两个独立缓存互不干扰。这时候的危险反而是“旧 key 的请求返回后写入旧缓存”不影响新 key 的展示所以一般不会出错。但有一种情况需要自己注意手动调用refetch()时如果同时发起两个refetch其中一个最后返回另一个会被忽略。这没问题。要是你同时更新了 queryKey 又调用了refetch可能产生两个不同 key 的并发请求这时如果请求之间没有依赖关系也还好如果有依赖就要考虑先打断旧的再发新的。实践中我一般建议用 AbortController 或者在 queryFn 里传signal让 TanStack Query 自己取消过时的请求。4.3 轮询与手动刷新setInterval refetch 的正确打开方式轮询也是“重新获取”的一种高频形式。很多人直接写useEffect(() { const interval setInterval(() { refetch(); }, 5000); return () clearInterval(interval); }, [refetch]);这个写法能用但有两个问题refetch会跳过缓存判断不管数据是否新鲜直接发请求浪费请求轮询期间如果页面处于后台可能造成大量无用请求更好的轮询方式是使用refetchInterval参数。它支持数字或函数还可以根据当前状态动态返回轮询频率。当组件卸载时自动清理定时器当窗口失焦时默认会暂停轮询。useQuery({ queryKey: [ticker, symbol], queryFn: () fetchTicker(symbol), refetchInterval: (query) { if (query.state.dataUpdatedAt Date.now() - 60_000) { return 10_000; // 数据新鲜时10 秒轮询 } return 2000; // 数据较旧时加速到 2 秒轮询 }, refetchIntervalInBackground: false, });还有一点容易忽略refetchInterval触发的重新获取如果上一次请求还没有结束TanStack Query 会自动跳过本次请求不会叠加并发。默认行为不会重复触发只有请求结束后才进入下一轮计时。4.4 串行依赖请求重新获取时如何避免“先重置后请求”在一些复杂页面上经常会遇到“先请求配置再根据配置请求列表”的场景。比如进入后台先拿用户偏好再请求报表。这种依赖关系在重新获取时会带来麻烦用户点击刷新两个 query 同时被 invalidate但第二个 query 仍然会用旧的偏好参数去请求导致数据不一致。通常的解决方案是把依赖参数放进 queryKey然后保证前置 Query 数据可用时再渲染列表。这有两种经典写法方式一用enabled控制第二个请求只在第一个请求成功后触发。const userPrefs useQuery({ queryKey: [userPrefs], queryFn: fetchPrefs, }); const report useQuery({ queryKey: [report, userPrefs.data?.filters], queryFn: () fetchReport(userPrefs.data.filters), enabled: !!userPrefs.data, });方式二用一个父级 Promise 把它们“串联”将两次获取作为一个整体 queryFn 返回。这种方式更符合“把一件事作为一个原子请求”的思路。useQuery({ queryKey: [dashboard, userId], queryFn: async () { const prefs await fetchPrefs(); const data await fetchReport(prefs.filters); return { prefs, data }; }, });两种方式各有取舍。方式一更利于缓存粒度细但需要处理 enabled 的边界方式二整体性强、重新获取时不会拆散但缓存粒度粗任意子请求变化都会重新拉取全部。我个人在通知中心、报表页这类“整块数据”场景会选择方式二因为它天然避免了部分失效的烦恼。而组件化的详情页则更倾向方式一可以提高局部复用性。5. 生产环境中的几个骚操作与避坑清单5.1 强制刷新组件与重新获取怎么共存有时候用户点击“刷新”按钮我们希望清空界面执行完整的重新获取而不是静默刷新。这个需求靠refetch其实实现不了太彻底因为data还是旧的。如果想要“刷新时展示 loading”可以靠修改queryKey加一个版本号const [refreshKey, setRefreshKey] useState(0); const query useQuery({ queryKey: [table, filters, refreshKey], queryFn: fetchTable, });点击刷新时setRefreshKey((prev) prev 1);这样会导致旧缓存直接作废产生全新的请求并显示 loading 状态。它的本质是“key 改变 → 重新获取”但代价是旧缓存无法复用。适合“用户明确要求重新加载”的场景不适合自动触发。5.2 用 queryClient.setQueryData 或 removeQueries 手动治理缓存重新获取的前提是“有旧缓存要刷新”。如果不需要请求只是想直接改掉缓存可以用setQueryData。比如乐观更新某个列表项时你不想重新拉全量数据只希望本地改一处queryClient.setQueryData([todos], (old) old?.map((todo) (todo.id newTodo.id ? newTodo : todo)) );如果某些缓存你确定用不上了直接清掉防止别人切回来触发多余请求queryClient.removeQueries({ queryKey: [todos] });removeQueries和invalidateQueries的区别在于一个是物理删除缓存一个是逻辑标记作废。删除后下次使用该 key 的组件挂载时必然重新请求标记作废后active 组件会立刻重新获取inactive 的则等下次激活再刷新。这两种治理方式在你需要手动控制“什么数据留在内存里”时会特别有用。比如退出登录后清空所有用户相关的 query 缓存防止下一个账号看到上一个账号的数据。这里就需要用queryClient.clear()或按 key 批量移除。5.3 错误重试时 UI 上的“假数据”展示策略重新获取失败时TanStack Query 的处理是保留上一次成功的数据如果存在并将状态切换为error。对于 UI 来说这是最合理的降级方案——不打断用户操作而是用 toast 提示“刷新失败正在展示旧数据”。但如果你希望失败时清空数据显示一个空状态 错误提示那就要在queryFn里手动 throw并在isError时把数据置空。这种需求常见于搜索页面——搜索失败后展示上一次搜索词的结果会误导用户不如显示空状态。具体处理时我习惯借助error的reset方法。每个 query 的返回结果里都带error、isError、refetch但是error本身没有reset需要从useQueryErrorResetBoundary获取。这个细节在错误边界和 Swr 团队的开源库里也有所体现本质上都是把“错误重置”当作一种操作来管理。5.4 一个实用的全局重连监听避免页面“卡死”在旧数据前面提到refetchOnReconnect默认开启但这只对网络真的断开再恢复的场景有效。有些时候用户从后台切回来网络并没有断过但服务端数据可能已经变了这时候就需要我们自己补一个“主动重新获取”的逻辑。最简单的方式是利用visibilitychange事件在页面重新可见时判断当前是否有关键 Query 需要刷新document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { queryClient.invalidateQueries({ predicate: (query) query.queryKey[0] critical query.state.status success }); } });这能做到“页面回来立即让关键数据重新获取”比refetchOnWindowFocus更可控也避开了窗口焦点切换过于频繁的问题。同时你可以在invalidateQueries的 predicate 里精确控制哪些数据需要强制刷新哪些允许继续使用旧缓存。5.5 从 React Query v4 升级到 v5 后重新获取行为的变化最后提醒一个版本差异。很多老项目还在用 v4但新项目已经切到 v5。v5 里cacheTime改名为gcTimerefetchOnWindowFocus的默认值依然是true但useQuery的 API 做了不少调整isLoading的语义、isSuccess的组合方式等都有细微变化。更关键的是v5 对queryFn接收的参数做了一个 breaking changequeryFn上下文中的queryKey变成了queryKey数组而不是原来的queryKey数组里的第一个元素。此外retryDelay的默认实现也调优过整体重试更“耐心”。所以如果你在参考一篇 v4 的博客直接套到 v5 项目里很可能遇到配置项不生效或者类型报错。建议任何时候都以 v5 官方文档为准尤其注意gcTime和staleTime的相互约束条件。6. 实战代码模板三个场景的“重新获取”标准写法6.1 标准列表页筛选 排序 自动刷新这是最常见的业务形态。我的推荐配置是queryKey 里带上筛选参数fallback 到keepPreviousData窗口聚焦刷新不必要开得太狠因为用户主动筛选时我们本就会发起新请求。function TodoList() { const [filter, setFilter] useStateall | done(all); const [page, setPage] useState(1); const query useQuery({ queryKey: [todos, filter, page], queryFn: () fetchTodos({ filter, page }), placeholderData: keepPreviousData, staleTime: 10 * 1000, // 10 秒内不重复获取 refetchOnWindowFocus: false, // 列表页我们主动控制刷新即可 }); return ( div button onClick{() setFilter(all)}全部/button button onClick{() setFilter(done)}已完成/button {query.isPending ? Spinner / : null} {query.data?.map((todo) ( TodoItem key{todo.id} todo{todo} / ))} Pagination page{page} onChange{setPage} / /div ); }这段代码的妙处在于filter/page 变化时自动形成新 key 触发请求不需要手动调用 refetch同页内 10 秒内不会重复请求翻页时有旧数据托底不闪烁。6.2 详情页从列表进入详情的缓存复用与刷新策略详情页通常要从列表带一个 id 过去然后根据 id 拉取详情。这里最怕的是“查询 key 不一致导致每次进入都发新请求”。推荐做法是 key 以详情页实体 id 为主列表页能预取就预取。function TodoDetail({ id }: { id: string }) { const query useQuery({ queryKey: [todo, id], queryFn: () fetchTodo(id), staleTime: 5 * 60 * 1000, }); if (query.isPending) return divLoading.../div; if (query.isError) return button onClick{() query.refetch()}重试/button; return ( div h2{query.data.title}/h2 button onClick{() query.refetch()}刷新/button /div ); }这个场景下staleTime设置成 5 分钟意味着从列表进来、再返回、再进同一详情几乎不会发第二次请求。只有当用户手动点击“刷新”按钮或invalidateQueries([todo, id])被调用时数据才更新。对于详情页这种低频数据这个策略很合适。6.3 实时看板轮询 窗口聚焦 手动刷新的三重保障实时看板是我处理得最重的一个场景。数据要求接近实时但也不能每个操作都打爆接口。我的做法是三层保障refetchInterval做定时轮询refetchOnWindowFocus做切回刷新手动按钮做特例刷新function Dashboard() { const query useQuery({ queryKey: [dashboard, filters], queryFn: () fetchDashboard(filters), refetchInterval: 30 * 1000, refetchOnWindowFocus: true, }); return ( button onClick{() query.refetch()} disabled{query.isFetching} {query.isFetching ? 刷新中... : 手动刷新} /button ); }注意这里手动刷新按钮的 disabled 条件用isFetching而不是isPending。因为轮询和聚焦刷新都会让isFetching为 true用户点击手动刷新时应该禁止重复操作而isPending只有首次加载才为 true无法覆盖轮询中的状态。6.4 搜索场景防抖 更新 queryKey 清除旧缓存搜索框是另一种典型场景关键词变化触发请求但不需要缓存以防旧词污染。做法是 debounce 输入然后用新关键词作为 queryKey 的一部分。每次搜索本身都创建一条新缓存但搜索历史一多内存里的 QueryCache 就会膨胀所以别忘了清理。function Search() { const [keyword, setKeyword] useState(); const [searchKey, setSearchKey] useState(); useEffect(() { const timer setTimeout(() setSearchKey(keyword), 300); return () clearTimeout(timer); }, [keyword]); const query useQuery({ queryKey: [search, searchKey], queryFn: () searchApi(searchKey), enabled: searchKey.length 0, staleTime: 30_000, }); // 当用户清空搜索词时清掉旧缓存 useEffect(() { if (!keyword) { queryClient.removeQueries({ queryKey: [search] }); } }, [keyword]); }这里的enabled控制搜索词为空时不发请求removeQueries防止旧搜索缓存残留。整体逻辑就是“让每个查询 key 都对应一次完整的搜索过程”缓存价值不大但可以避免无效请求。7. 把“重新获取”这个动作做对远不止调参数这么简单如果要把这篇文章缩成一句话那就是重新获取不是一个“按钮”而是一套基于时间、状态和缓存快照的调度系统。理解它你才能真正用好 TanStack Query。回顾一下我今天主要讲清楚了这几个核心问题staleTime和gcTime如何决定“要不要重新获取”和“缓存能活多久”四个重新获取触发源主动、窗口聚焦、网络重连、挂载各自的行为边界invalidateQueries是“标记作废 懒刷新”refetch是“立即无条件刷新”语义完全不同状态字段status与fetchStatus不能混用UI 里判 loading、判刷新状态要分开处理翻页、搜索、详情页、轮询等场景的配置思路以及竞态、缓存残留、首屏闪烁等实战坑点在我实际操作中体验最深的一条是不要试图用一个全局配置解决所有页面的重新获取问题。业务数据有天然的温度差有的需要实时有的可以容忍五分钟旧数据。一个团队的主查询配置里应该既有全局兜底又有局部覆盖才能让这套机制真正“顺滑”。最后再分享一个小技巧每次调试重新获取行为时先打开 React Query DevTools看一眼dataUpdatedAt和staleTime再想看板里的请求记录。大多数让人觉得“奇怪”的刷新行为其实都是因为缓存里那个更新时间戳还没到“过期”的点。理解了这个时间戳你就理解了重新获取的半个世界。