深入理解 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实例从挂载到销毁的全过程,并能在实践中用gcTime、staleTime、refetchOnMount等选项精确控制缓存行为,避免"数据迟迟不更新"或"缓存无限膨胀"两类典型问题。
阅读前提:先理解缓存相关的"重要默认值"
官方在阅读缓存指南之前明确要求先通读 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 的核心叙事。假设我们使用默认的gcTime(5 分钟)与默认的staleTime(0,即数据一旦写入缓存就立即被视为 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及相关值(包括isFetching、isPending等)都会一起更新,因为它们背后是同一个被多个 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类继承自Removable。Removable(见 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: Infinity:isValidTimeout会返回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 订阅同一个Query,Query的每次状态变更(#dispatch)都会批量通知全部 observer(query.ts)。
5. "stale"的判定:isStaleByTime与timeUntilStale
"数据是否 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) }判定规则依次为:
- 没有数据 → 永远 stale;
staleTime === 'static'→ 永远 fresh;- 已被手动失效(invalidated)→ stale,无论
staleTime多长; - 否则用
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。
四、配置示例:全局与查询级两种写法
全局设置(作用于所有查询)——通过QueryClient的defaultOptions:
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: Infinity而gcTime默认 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(多久算新鲜)这对互补参数,再结合refetchOnMount、refetchOnWindowFocus、refetchOnReconnect三个时机开关,你就能为任何数据源设计出既流畅又省流量的缓存策略。
【免费下载链接】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),仅供参考