news 2026/9/9 20:41:35

深入理解 TanStack Query 缓存生命周期:从首次挂载、后台刷新到垃圾回收的完整推演

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解 TanStack Query 缓存生命周期:从首次挂载、后台刷新到垃圾回收的完整推演

深入理解 TanStack Query 缓存生命周期:从首次挂载、后台刷新到垃圾回收的完整推演

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

本文以官方指南 Caching Examples 为骨架,结合 query-core 包中的真实源码与测试用例,系统梳理 TanStack Query(React Query)中查询数据的缓存生命周期:什么是"命中缓存"、什么是"后台刷新"、查询何时被标记为 inactive、垃圾回收(Garbage Collection)的计时器又是如何被启动与取消的。读完本文,你将能准确推演任意一个useQuery实例从挂载到销毁的全过程,并能在实践中用gcTimestaleTimerefetchOnMount等选项精确控制缓存行为,避免"数据迟迟不更新"或"缓存无限膨胀"两类典型问题。

阅读前提:先理解缓存相关的"重要默认值"

官方在阅读缓存指南之前明确要求先通读 Important Defaults。其中与缓存生命周期强相关的默认值如下,它们是后面整段推演的前提:

  • 通过useQuery/useInfiniteQuery创建的新查询实例,默认把缓存数据视为 stale(过期)
  • 设置了staleTime的查询在计时未走完前被视为fresh(新鲜),期间不会再触发任何基于过期的刷新;
  • stale 的查询会在以下三类时机被后台自动重新请求:新的查询实例挂载时、浏览器窗口重新获得焦点时、网络重新连接时;
  • 不再有任何活跃实例(observer)的查询会被标记为inactive,但仍保留在缓存中供后续复用;
  • 默认情况下,inactive 查询会在5 分钟后被垃圾回收(可配置gcTime);
  • 失败的查询默认会以指数退避延迟静默重试 3 次

值得特别留意的是staleTime的两种特殊取值(来自 important-defaults.md):

取值行为是否仍可被invalidateQueries手动失效
数字(毫秒)计时内数据视为 fresh,不触发基于过期的刷新可以
Infinity永不因过期触发刷新可以,手动失效依然生效
'static'永不因过期触发刷新,refetchOnMount/refetchOnWindowFocus/refetchOnReconnect设为"always"也会被屏蔽不可以,手动失效无效

'static'适合"应用运行期间不可能变化"的数据(如启动时拉取的功能开关、登录时加载的用户权限、静态参照表);而Infinity适合"仍然希望通过手动失效来控制刷新"的数据。

一、一条查询的生命周期:五阶段完整推演

下文是 caching.md 的核心叙事。假设我们使用默认的gcTime5 分钟)与默认的staleTime0,即数据一旦写入缓存就立即被视为 stale),查询体为:

useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

阶段 1:首个实例挂载——硬加载态与首次网络请求

当第一个useQuery({ queryKey: ['todos'], queryFn: fetchTodos })实例挂载时:

  • 由于此前从未有人用['todos']这个 key 发起过查询,缓存中没有任何对应数据,因此该查询会进入硬加载状态(hard loading state),即status === 'pending',并立即发起一次网络请求;
  • 网络请求成功后,返回的数据会被写入缓存,挂到['todos']key 之下;
  • 数据被标记为 stale 的时间点由staleTime决定——默认0意味着写入即过期

这里"硬加载"的准确定义是:缓存里没有数据data === undefined),而不是"正在请求"。区分这一点是理解后续阶段的前提。

阶段 2:第二个实例挂载——缓存命中与后台刷新

在应用其他位置,第二个useQuery({ queryKey: ['todos'], queryFn: fetchTodos })实例挂载:

  • 由于第一个查询已经把数据写入了缓存,第二个实例会立即从缓存同步返回已有数据,用户看不到闪烁的加载态;
  • 但因为数据此时是 stale(staleTime默认为 0),新实例会用自己的queryFn触发一次新的网络请求,在后台完成刷新;
  • 关键点:无论两个实例的fetchTodos函数是否完全相同,只要 query key 相同,它们就共享同一条缓存记录。两个查询的status及相关值(包括isFetchingisPending等)都会一起更新,因为它们背后是同一个被多个 observer 订阅的 Query 对象;
  • 当后台请求成功时,['todos']key 下的缓存数据被新数据覆盖,两个实例同时拿到最新数据并重新渲染。

这里的"双实例状态联动"是 TanStack Query 去重能力(deduping)的直接体现:相同 key 的多个订阅者只对应一个网络请求实例,但状态变更会对所有订阅者广播。

阶段 3:两个实例相继卸载——inactive 与 GC 计时器启动

当使用['todos']key 的这两个实例都被卸载、不再有任何活跃订阅者时:

  • 该查询不再有活跃实例,被标记为inactive
  • 此时会依据gcTime启动一个垃圾回收计时器(默认5 分钟),到时后删除并回收这条查询及其缓存数据。

注意:inactive ≠ 立即删除。缓存仍然保留着数据,这是为了让用户返回页面(如从列表页跳到详情页再返回)时能够瞬间渲染旧数据。

阶段 4:GC 计时器到期之前重新挂载——缓存复活与后台填充

如果在 5 分钟的 GC 计时器走完之前,又有一个新的useQuery({ queryKey: ['todos'], queryFn: fetchTodos })实例挂载:

  • 查询会立即返回缓存中仍然存在的数据,页面秒开;
  • 同时fetchTodos在后台运行,成功完成后会用全新数据填充缓存(也同步给当前所有订阅者);
  • 挂载这个新实例会取消(clear)垃圾回收计时器——这条查询重新恢复为 active 状态。

阶段 5:再无实例且超时——缓存被删除并回收

最后一个实例卸载后,若在5 分钟内再也没有任何['todos']的实例出现:

  • 计时器触发,['todos']key 下的缓存数据被删除并完成垃圾回收
  • 下次再有人挂载同 key 的查询时,将重新回到阶段 1 的硬加载态——仿佛这条查询从未存在过。

二、源码印证:缓存生命周期在底层是如何实现的

上面的叙事并非文档的抽象描述,query-core 中确实存在一整套与之对应的机制。理解这些源码,能帮你更精确地预估各种边界场景。

1.Query继承Removable:gcTime 与 GC 计时器

在 query.ts 中,Query类继承自RemovableRemovable(见 removable.ts)定义了gcTime字段与四个核心方法:

  • updateGcTime(newGcTime):更新 GC 时间。当没有显式传入gcTime时,默认取5 * 60 * 1000毫秒(5 分钟);而在服务端环境中默认值为Infinity,即服务端渲染期间查询不会被垃圾回收(见 removable.ts);
  • scheduleGc():先用timeoutManager.setTimeout登记一个延迟gcTime的定时器,到期后回调optionalRemove()
  • clearGcTimeout():取消已登记的计时器;
  • optionalRemove():抽象方法,由Query实现。

Query.optionalRemove()的实现(query.ts)包含一个双重守卫

protected optionalRemove() { if (!this.observers.length && this.state.fetchStatus === 'idle') { this.#cache.remove(this) } }

即只有同时满足"没有任何 observer 订阅"且"当前不在请求中"时,查询才会真正从缓存中移除。这解释了文档阶段 3 的行为:即使 GC 计时器已经触发,只要查询还在 fetching,也不会被删除——相关断言可见测试 "should be garbage collected later when unsubscribed and query is fetching"(query.test.tsx)。

2.addObserver/removeObserver:active 与 inactive 的切换开关

查询的"活跃"与"不活跃"完全由 observer 数量驱动(query.ts):

  • addObserver:有新的订阅者加入时,调用clearGcTimeout()取消垃圾回收计时器,防止查询被回收;
  • removeObserver:订阅者离开后,若 observer 列表已空,则调用scheduleGc()启动(或重置)垃圾回收计时器

值得注意的是,Query构造时(query.ts)以及每次 fetch 结束后(query.ts)也会调用scheduleGc(),目的是兜底回收"从未被订阅过"或"请求刚结束"的查询。测试 "queries should be garbage collected even if they never fetched"(query.test.tsx)正验证了这一点。

3. 关于gcTime的三个实现细节

结合 removable.ts 与工具函数 utils.ts 可以确认:

  • gcTime: 0:计时器立即到期,查询在最后一个订阅者离开后会被立刻回收。测试 "queries with gcTime 0 should be removed immediately after unsubscribing"(query.test.tsx)可验证;
  • gcTime: InfinityisValidTimeout会返回false(其实现要求"非负有限数字"),因此根本不会登记计时器,查询永远不会被自动回收;
  • gcTime 只会取"见过的最长值"updateGcTime内部通过Math.max(this.gcTime || 0, newGcTime ?? ...)合并(removable.ts)。测试 "should use the longest garbage collection time it has seen"(query.test.tsx)验证了这一点:同一 key 先后以 100/200/10ms 的gcTime创建查询,最终query.gcTime是 200。因此如果你想让数据存活更久,用较大gcTime的查询去"覆盖"较小值是不起作用的——这点在多处挂载同 key 查询时尤其容易踩坑。

4. 缓存命中与状态联动的原理:QueryCache 与 observer

"第二个实例立即拿到缓存数据"靠的是 QueryCache 的去重:build()先按 query key 哈希出queryHash,若缓存中已存在相同 hash 的查询则直接复用,否则才新建(queryCache.ts)。而remove()会先调用query.destroy()再删除记录(queryCache.ts)。

"多个实例状态一起更新"则源于共享订阅:多个useQueryobserver 订阅同一个QueryQuery的每次状态变更(#dispatch)都会批量通知全部 observer(query.ts)。

5. "stale"的判定:isStaleByTimetimeUntilStale

"数据是否 stale"并不玄学,它由 query.ts 的isStaleByTime精确计算:

isStaleByTime(staleTime: StaleTime = 0): boolean { // no data is always stale if (this.state.data === undefined) return true // static is never stale if (staleTime === 'static') return false // if the query is invalidated, it is stale if (this.state.isInvalidated) return true return !timeUntilStale(this.state.dataUpdatedAt, staleTime) }

判定规则依次为:

  1. 没有数据 → 永远 stale
  2. staleTime === 'static'→ 永远 fresh;
  3. 已被手动失效(invalidated)→ stale,无论staleTime多长;
  4. 否则用timeUntilStale(dataUpdatedAt, staleTime)(utils.ts,即updatedAt + staleTime - now)判断新鲜度剩余时间。

而"新实例挂载时是否触发后台刷新"由 observer 侧的shouldFetchOnMount决定(queryObserver.ts):只有查询已有数据enabled不为false、且staleTime不是'static'时,才会在满足refetchOnMount条件("always",或非false且查询确实 stale)时发起后台刷新。这也解释了阶段 2 为什么第二个实例会"先有数据、再后台刷新"。

三、控制缓存生命周期的核心配置项

下面是完整生命周期推演中真正起决定作用的配置项,可全局设置也可逐查询覆盖。

gcTime:缓存数据在 inactive 后保留多久

  • 类型:number | Infinity
  • 默认值:1000 * 60 * 5(5 分钟;服务端为Infinity,见 removable.ts)
  • 作用:查询失去所有活跃订阅者后,缓存数据被垃圾回收前等待的时长
  • 典型取值:列表详情场景建议保留默认 5 分钟即可;数据体积大且易变时可调小(如10 * 1000);SSR / 预取场景可设Infinity并结合手动清除

注意:v5 之前这个选项叫cacheTime,v5 起更名为gcTime,语义上强调的是"垃圾回收前的保留期"。

staleTime:数据在多久内被视为 fresh

  • 类型:number | Infinity | 'static'
  • 默认值:0(缓存数据写入即 stale)
  • 作用:在计时内,新实例挂载、窗口聚焦、网络重连都不会触发基于过期的后台刷新
  • 典型取值:短时数据(如价格)设几秒;常规业务数据设60 * 1000级;几乎不变的数据设Infinity'static'(区别见本文开头的表格)

refetchOnMount/refetchOnWindowFocus/refetchOnReconnect

三者用于覆盖"何时可以触发刷新"的时机,取值一致(文档见 important-defaults.md,焦点/网络相关细节见 window-focus-refetching.md):

取值行为
true(默认)仅当查询为 stale 时才刷新
false永不因此时机刷新
"always"无论数据是否 fresh 都刷新(会被'static'屏蔽)

相关但不直接属于生命周期的选项

  • enabled:置为false时查询不自动发起请求,但仍可能产生订阅;
  • initialData/placeholderData:分别影响"默认状态是否带数据",进而影响硬加载态,详见 initial-query-data.md 与 placeholder-query-data.md;
  • 手动失效queryClient.invalidateQueries()会把查询标记为isInvalidated,从而绕过staleTime强制使其 stale,详见 query-invalidation.md。

四、配置示例:全局与查询级两种写法

全局设置(作用于所有查询)——通过QueryClientdefaultOptions

import { QueryClient } from '@tanstack/react-query' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 60 秒内视为新鲜,不触发基于过期的刷新 gcTime: 5 * 60 * 1000, // 显式声明 inactive 后保留 5 分钟(与默认一致) refetchOnWindowFocus: true, retry: 3, // 失败默认静默重试 3 次(指数退避) }, }, })

逐查询覆盖(精确控制个别数据源的缓存策略):

// 详情页数据:挂载即可用旧数据渲染,后台 30 秒内不重复刷新 useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 30 * 1000, gcTime: 10 * 60 * 1000, }) // 启动时加载的功能开关:运行期间不允许任何形式的自动刷新 useQuery({ queryKey: ['feature-flags'], queryFn: fetchFeatureFlags, staleTime: 'static', }) // 高频易变数据:每次窗口聚焦都强制刷新 useQuery({ queryKey: ['live-stock'], queryFn: fetchStock, refetchOnWindowFocus: 'always', gcTime: 0, // 组件卸载即丢弃,避免堆积过期行情 })

还可以使用queryClient.setQueryDefaults(key, options)某一类 key单独设置默认值(核心测试即通过它设置查询级gcTime,见 query.test.tsx)。

五、实战中常见的三个误区

误区一:把staleTime当成了"缓存过期时间"。staleTime只决定"要不要后台刷新",不决定"数据是否还在"。数据是否被删除只由gcTime决定。设staleTime: InfinitygcTime默认 5 分钟,结果是:组件卸载 5 分钟后数据照样被回收,下次挂载依然要硬加载。

误区二:认为多个实例会各发各的请求。相同 key 的查询在 QueryCache 中只会存在一份,网络请求也会被去重合并;不同实例的差异只在于各自对status/isFetching的渲染。需要展示"后台正在刷新"时,请使用isFetching而非status === 'pending'(后者只代表首次硬加载),参见 background-fetching-indicators.md。

误区三:卸载后立即gcTime归零就能立刻释放内存?可以,但要记住optionalRemove的双重守卫:查询若仍处于 fetching 状态,即使计时器到期也不会被移除(query.ts),这是为了把进行中的请求结果写入缓存。真正需要强制清理时,应调用queryClient.clear()或按 filters.md 中的过滤条件配合queryClient.removeQueries()

结语

回顾整段推演:一条查询的生命周期 =首次硬加载 → 缓存命中 + 后台刷新 → 全部卸载后进入 inactive 并启动 GC 计时 → 超时前复用则缓存复活 → 超时则删除回收。其底层实现横跨 query.ts(observer 增删与 GC 调度)、removable.ts(gcTime 语义与默认值)、queryCache.ts(查询去重与移除)与 queryObserver.ts(挂载/焦点刷新判定),并有 query.test.tsx 中大量定时器用例逐条守护。理解了gcTime(保留多久)与staleTime(多久算新鲜)这对互补参数,再结合refetchOnMountrefetchOnWindowFocusrefetchOnReconnect三个时机开关,你就能为任何数据源设计出既流畅又省流量的缓存策略。

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 20:41:04

Rust never类型`!`稳定:发散函数与Result<T, !>实战解析

在 Rust 里写代码时,大家大概率都遇到过panic!()、todo!()、unreachable!()这些宏。它们有一个共同特征:一旦执行,程序就不会继续走后面的流程了。你或许听说过,这类表达式的类型叫做never类型,写作!。名字听起来很抽象…

作者头像 李华
网站建设 2026/9/9 20:39:49

食品效期管理实战:从先进先出到全流程管控指南

1. 效期的定义,比你想象中复杂得多先说一个我自己的经历。前几年我去一家连锁超市做盘点,发现一个很奇怪的现象:冷柜最里面的盒装牛奶,生产日期是十天前的,而新到的货反而被码在了最靠外的位置。店员跟我说&#xff0c…

作者头像 李华
网站建设 2026/9/9 20:34:45

Deepcoin赞助阿根廷足协:区域赞助如何撬动Web3品牌出海

Deepcoin官宣成为阿根廷足协(AFA)官方区域赞助商,消息出来当天,我身边几个做品牌和加密市场的朋友就开始讨论。大家讨论的点很一致:一个数字资产交易平台,不去硬砸世界杯全球赞助,偏偏选择AFA的…

作者头像 李华
网站建设 2026/9/9 20:32:55

K2算法详解:从贝叶斯网络结构学习到Python实现

简介:K2算法是贝叶斯网络结构学习中的经典贪心搜索方法,这份资源提供了利用K2算法从数据中学习贝叶斯网络结构的完整MATLAB实现,面向机器学习、数据挖掘方向的研究者与学生,适合需要理解结构学习原理或在项目中快速搭建K2模块的读…

作者头像 李华