TanStack Query Angular 缓存机制详解:从 injectQuery 初始化到垃圾收集的完整生命周期
本文以 Angular 框架的 injectQuery 为例,完整拆解 TanStack Query 中查询缓存(Query Cache)的生命周期:数据首次加载与写入缓存、同 key 多实例共享与后台刷新、实例销毁后进入 inactive 状态、gcTime 超时触发的垃圾收集。读完本文,你将清楚默认 staleTime: 0 与 gcTime: 5 分钟 如何共同决定每一次缓存读写行为,并能在 packages/angular-query-experimental 与 packages/query-core 的源码中定位到对应的实现证据。
在深入之前,请务必先了解 Important Defaults(重要默认值) 中声明的“激进但合理”的默认配置,它们直接决定了本文所有场景的行为:
- 通过
injectQuery/injectInfiniteQuery创建的查询实例,默认把缓存数据视为 stale(陈旧),即staleTime默认为0——数据一写入缓存立刻进入 stale 状态。 - 没有
useQuery/injectQuery实例在观察的查询会被标记为 inactive,并默认在 5 分钟后(gcTime默认为1000 * 60 * 5毫秒)被垃圾收集。 - 失败的查询会静默重试 3 次,采用指数退避延迟。
要改变这些行为,可以通过全局
defaultOptions或逐查询配置staleTime、gcTime等选项。
缓存生命周期:一个 queryKey 的完整故事
下面按照 Caching Examples 官方文档 的叙事,假设使用默认 gcTime 为 5 分钟、默认 staleTime 为 0,追踪 ['todos'] 这个 query key 从诞生到消亡的全过程。
阶段一:第一个实例初始化——硬加载状态与首次网络请求
class TodosService {
todosQuery = injectQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodos,
}))
}
当第一个 injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })) 实例初始化时:
- 由于此前没有任何查询使用过
['todos']这个 key,缓存中没有数据,查询进入硬加载(hard loading)状态,并立即发起网络请求。 - 网络请求完成后,返回的数据以
['todos']为键写入缓存(Query Cache 内部实际按queryHash字符串索引)。 - 由于
staleTime默认为0,这份数据在写入缓存后立即被标记为 stale——这就是“缓存命中但会触发后台刷新”的根源。
阶段二:第二个实例初始化——缓存命中 + 后台刷新
当应用的另一个位置创建第二个同 key 的实例时:
- 缓存中已经存在
['todos']的数据,新实例立即从缓存返回旧数据,不会阻塞在 loading 状态; - 新实例同时触发一次新的网络请求(因为数据是 stale 的,
refetchOnMount默认行为会刷新); - 无论两处的
fetchTodos函数引用是否相同,由于两个查询的 query key 相同,它们底层共享同一个Query对象,status相关状态(isFetching、isPending等)会在两个实例间同步更新; - 请求成功完成后,缓存中
['todos']的数据被更新为最新值,两个实例同时收到新数据。
这一点在 Angular 的实现中有直接体现:injectQuery 内部通过 createBaseQuery 为每个实例创建一个 QueryObserver,多个 observer 订阅同一个 QueryClient 管理的缓存,见 create-base-query.ts。observer 订阅后,状态更新通过 notifyManager.batchCalls 批处理并经 ngZone.run 回到 Angular 变更检测周期内:
// packages/angular-query-experimental/src/create-base-query.ts(节选)
const unsubscribe = isRestoring()
? () => undefined
: untracked(() =>
ngZone.runOutsideAngular(() => {
return observer.subscribe(
notifyManager.batchCalls((state) => {
ngZone.run(() => {
// fetchStatus 为 'fetching' 时登记 pendingTask,
// 为 'idle' 时释放——与 Angular Router 的 pendingTasks 集成
...
resultFromSubscriberSignal.set(state)
})
}),
)
}),
)
也就是说,文档中“两个实例的状态都更新”的结论,落到实现层就是:同一个 Query 的 state 变更会通知所有订阅它的 observer,而 Angular 适配器负责把这次通知转写为 Signal 更新。
阶段三:实例销毁——进入 inactive 并启动 gcTime 计时器
当两处 injectQuery 实例都从注入上下文中被销毁(组件卸载、依赖它们的 injector 被销毁)后:
- 缓存中的查询不会立刻被删除。由于不再有任何活跃的 observer,该查询被标记为 inactive;
- 此时使用
gcTime设置一个垃圾收集超时(默认 5 分钟),超时后数据被删除、查询被回收。
在核心层,这个行为由 Query 类直接实现。query.ts 中,构造函数在初始化末尾就调用了 this.scheduleGc()(约第 188 行),setOptions 中每次都会 this.updateGcTime(this.options.gcTime) 来按最新配置重排计时(约第 211 行):
// packages/query-core/src/query.ts(节选)
setOptions(options?: QueryOptions<...>): void {
this.options = { ...this.#defaultOptions, ...options }
...
this.updateGcTime(this.options.gcTime)
...
}
protected optionalRemove() {
// 只有「没有任何 observer」且「不在抓取中」时,缓存才会真正移除该查询
if (!this.observers.length && this.state.fetchStatus === 'idle') {
this.#cache.remove(this)
}
}
注意 optionalRemove 的两个保护条件:只要有 observer 存在、或查询还在 fetching(后台请求未结束),gc 计时到期也不会删除该查询。这保证了「组件瞬间卸载又立刻回来」时数据不会丢失。core 的测试也验证了这一行为:query.test.tsx 中有 “queries with gcTime 0 should be removed immediately after unsubscribing” 用例,queryClient.test.tsx 中有 “should be garbage collected after gcTime if unused” 用例。
阶段四:超时前重新挂载——立即返回缓存 + 后台刷新
如果在缓存被清理之前(5 分钟窗口内),又有新的同 key 实例挂载(比如用户从列表页回到待办页):
- 查询立即返回当前缓存中可用的数据,UI 不会闪 loading;
- 同时
fetchTodos在后台重新执行(stale 数据触发 refetch); - 后台请求成功后,缓存被更新为最新数据,页面无缝刷新。
这正是 staleTime: 0 默认的“乐观复用 + 后台校正”策略带来的体验:旧数据秒出、新数据到位后自动替换,全程无阻塞。
阶段五:垃圾收集——数据最终被删除
如果最后的实例销毁后 5 分钟内再没有任何同 key 的实例出现:
- 缓存中
['todos']的数据被删除,查询对象被垃圾收集,缓存恢复干净。 - 之后再有实例挂载时,将回到阶段一:硬加载状态 + 全新的网络请求。
源码视角:Angular 侧如何“接到”这个缓存体系
理解生命周期后,把视角拉到 inject-query.ts 可以看到入口层非常薄:
// packages/angular-query-experimental/src/inject-query.ts(节选)
export function injectQuery(injectQueryFn: () => CreateQueryOptions, options?: InjectQueryOptions) {
!options?.injector && assertInInjectionContext(injectQuery)
return runInInjectionContext(options?.injector ?? inject(Injector), () =>
createBaseQuery(injectQueryFn, QueryObserver),
) as unknown as CreateQueryResult
}
关键点:
injectQuery必须在注入上下文中调用(或显式传入injector),缓存的生命周期因此与 Angular 的 DI 生命周期绑定——组件/服务销毁,observer 随之取消订阅,查询才可能进入 inactive;- 传入的选项函数会被放进
computed中求值,先经过queryClient.defaultQueryOptions(...)合并全局默认值,再创建QueryObserver。这意味着全局defaultOptions(如统一的staleTime、gcTime)在这一层被统一注入,与文档中“可全局或逐查询配置”的说法一致; - 返回结果是
signalProxy包裹的 Signal 化查询结果,data、status、isFetching等属性在模板中以信号方式响应式读取,refetch等方法在调用前会先observer.setOptions(defaultedOptionsSignal())确保使用最新选项。
injectQuery 的 JSDoc 中还给出了响应式用法示例(信号驱动 queryKey 与 enabled),可用于理解“不同 key 对应不同缓存条目”的前提:
class TodosService {
filter = signal('')
todosQuery = injectQuery(() => ({
queryKey: ['todos', this.filter()],
queryFn: () => fetchTodos(this.filter()),
enabled: !!this.filter(),
}))
}
更完整的选项类型与 queryOptions 工厂见 query-options.ts,其中 queryOptions 允许你在服务中声明式地共享、类型安全地复用查询配置(queryKey 会带上 queryFn 的数据类型标签)。
实战要点:基于缓存生命周期调整行为
结合上述生命周期,几个常用调优方向(均与默认值文档一致):
| 场景 | 推荐配置 | 效果 |
|---|---|---|
| 数据很少变化,想彻底避免重复请求 | 逐查询或全局 staleTime: 2 * 60 * 1000 |
2 分钟内读缓存且不触发任何刷新(直至手动失效) |
| 应用运行期间数据不可变(如启动时拉取的 feature flag、登录时加载的权限) | staleTime: 'static' |
连手动 invalidateQueries 都不会触发重取 |
| 仍希望手动失效生效 | staleTime: Infinity |
阻止基于陈旧度的重取,但 invalidateQueries() 依然有效 |
| 缓存条目占用敏感、希望更快回收 | 调小 gcTime(如 gcTime: 60_000) |
inactive 查询 1 分钟后即被清理 |
| 让页面返回时先展示占位而不是空 | placeholderData |
缓存无数据时先给占位,避免硬 loading 闪烁 |
需要强调两点限制:
staleTime与gcTime只控制“陈旧判定”与“回收时机”,不控制请求本身;周期性轮询应使用独立的refetchInterval,与staleTime互不干扰;- 结构化共享(structural sharing)默认开启:新数据与旧数据逐层比对,未变化的部分保留引用,因此“数据更新”不等于“引用一定变化”。只支持 JSON 兼容值,非 JSON 值可自定义
structuralSharing函数或关闭该特性。
延伸阅读与仓库路径索引
- Caching Examples 原文档:本文扩写的直接来源;
- Important Defaults:
staleTime/gcTime/retry默认值依据; - injectQuery API 参考:完整参数与返回结果说明;
- Angular queries 指南:查询基础用法;
- 源码入口:inject-query.ts、create-base-query.ts、query.ts;
- 行为验证测试:query.test.tsx、queryClient.test.tsx。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00