首页
/ TanStack Query Angular 缓存机制详解:从 injectQuery 初始化到垃圾收集的完整生命周期

TanStack Query Angular 缓存机制详解:从 injectQuery 初始化到垃圾收集的完整生命周期

2026-09-05 18:59:51作者:申梦珏Efrain

本文以 Angular 框架的 injectQuery 为例,完整拆解 TanStack Query 中查询缓存(Query Cache)的生命周期:数据首次加载与写入缓存、同 key 多实例共享与后台刷新、实例销毁后进入 inactive 状态、gcTime 超时触发的垃圾收集。读完本文,你将清楚默认 staleTime: 0gcTime: 5 分钟 如何共同决定每一次缓存读写行为,并能在 packages/angular-query-experimentalpackages/query-core 的源码中定位到对应的实现证据。

在深入之前,请务必先了解 Important Defaults(重要默认值) 中声明的“激进但合理”的默认配置,它们直接决定了本文所有场景的行为:

  • 通过 injectQuery / injectInfiniteQuery 创建的查询实例,默认把缓存数据视为 stale(陈旧),即 staleTime 默认为 0——数据一写入缓存立刻进入 stale 状态。
  • 没有 useQuery/injectQuery 实例在观察的查询会被标记为 inactive,并默认在 5 分钟后(gcTime 默认为 1000 * 60 * 5 毫秒)被垃圾收集。
  • 失败的查询会静默重试 3 次,采用指数退避延迟。

要改变这些行为,可以通过全局 defaultOptions 或逐查询配置 staleTimegcTime 等选项。

缓存生命周期:一个 queryKey 的完整故事

下面按照 Caching Examples 官方文档 的叙事,假设使用默认 gcTime5 分钟、默认 staleTime0,追踪 ['todos'] 这个 query key 从诞生到消亡的全过程。

阶段一:第一个实例初始化——硬加载状态与首次网络请求

class TodosService {
  todosQuery = injectQuery(() => ({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  }))
}

当第一个 injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos })) 实例初始化时:

  1. 由于此前没有任何查询使用过 ['todos'] 这个 key,缓存中没有数据,查询进入硬加载(hard loading)状态,并立即发起网络请求。
  2. 网络请求完成后,返回的数据以 ['todos'] 为键写入缓存(Query Cache 内部实际按 queryHash 字符串索引)。
  3. 由于 staleTime 默认为 0,这份数据在写入缓存后立即被标记为 stale——这就是“缓存命中但会触发后台刷新”的根源。

阶段二:第二个实例初始化——缓存命中 + 后台刷新

当应用的另一个位置创建第二个同 key 的实例时:

  1. 缓存中已经存在 ['todos'] 的数据,新实例立即从缓存返回旧数据,不会阻塞在 loading 状态;
  2. 新实例同时触发一次新的网络请求(因为数据是 stale 的,refetchOnMount 默认行为会刷新);
  3. 无论两处的 fetchTodos 函数引用是否相同,由于两个查询的 query key 相同,它们底层共享同一个 Query 对象,status 相关状态(isFetchingisPending 等)会在两个实例间同步更新;
  4. 请求成功完成后,缓存中 ['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 实例挂载(比如用户从列表页回到待办页):

  1. 查询立即返回当前缓存中可用的数据,UI 不会闪 loading;
  2. 同时 fetchTodos后台重新执行(stale 数据触发 refetch);
  3. 后台请求成功后,缓存被更新为最新数据,页面无缝刷新。

这正是 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(如统一的 staleTimegcTime)在这一层被统一注入,与文档中“可全局或逐查询配置”的说法一致;
  • 返回结果是 signalProxy 包裹的 Signal 化查询结果,datastatusisFetching 等属性在模板中以信号方式响应式读取,refetch 等方法在调用前会先 observer.setOptions(defaultedOptionsSignal()) 确保使用最新选项。

injectQuery 的 JSDoc 中还给出了响应式用法示例(信号驱动 queryKeyenabled),可用于理解“不同 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 闪烁

需要强调两点限制:

  1. staleTimegcTime 只控制“陈旧判定”与“回收时机”,不控制请求本身;周期性轮询应使用独立的 refetchInterval,与 staleTime 互不干扰;
  2. 结构化共享(structural sharing)默认开启:新数据与旧数据逐层比对,未变化的部分保留引用,因此“数据更新”不等于“引用一定变化”。只支持 JSON 兼容值,非 JSON 值可自定义 structuralSharing 函数或关闭该特性。

延伸阅读与仓库路径索引

登录后查看全文
热门项目推荐
相关项目推荐