首页
/ TanStack Query 中 injectIsFetching 详解:在 Angular 里构建全局加载指示器

TanStack Query 中 injectIsFetching 详解:在 Angular 里构建全局加载指示器

2026-09-06 23:17:11作者:牧宁李

在 Angular 应用中,单个查询的加载状态用 injectQuery 返回的 isPending/isFetching 信号即可覆盖,但"应用内此刻有多少个查询正在后台请求数据"这类全局视图,需要专门的工具函数来获取。本篇围绕 @tanstack/angular-query-experimental 包的 injectIsFetching 函数展开:它的签名、两个可选参数(filtersoptions)、返回的 Signal<number> 的语义、典型的"全局加载指示器"用法,以及从 inject-is-fetching.ts 源码中可以读到的注入上下文校验、NgZone 调度、批量通知与订阅清理等实现细节。读完本篇,你将掌握如何在 Angular 组件中订阅整个应用的"正在请求中的查询数量",并理解其背后与 QueryClient.isFetching 的调用关系。

函数签名与返回值的语义

官方参考文档给出的签名非常简洁:

function injectIsFetching(filters?, options?): Signal<number>;
  • 参数 filters?:类型为 QueryFilters,用于限定只统计符合过滤条件的查询数量;
  • 参数 options?:类型为 InjectIsFetchingOptions,目前只提供一个可选的 injector 字段,用于指定信号所挂载的 Injector
  • 返回值Signal<number>,即"当前正在 loading 或后台 fetching 的查询数量"。文档原话是:"Injects a signal that tracks the number of queries that your application is loading or fetching in the background. Can be used for app-wide loading indicators"(注入一个信号,追踪你的应用正在加载或在后台获取数据的查询数量,可用于全局加载指示器)。

这里的关键语义是:它统计的是"数量"而非布尔值。返回 0 表示没有任何查询在请求中,大于 0 则表示有相应数量的查询处于 fetchStatus === 'fetching' 状态。这一语义可以直接从 query-corequeryClient.ts 得到印证:

isFetching<TQueryFilters extends QueryFilters<any> = QueryFilters>(
  filters?: TQueryFilters,
): number {
  return this.#queryCache.findAll({ ...filters, fetchStatus: 'fetching' })
    .length
}

也就是说,QueryClient.isFetching 是在查询缓存中查找所有"匹配 filters 且 fetchStatus'fetching'"的查询并返回其条数。injectIsFetching 正是把这个命令式的数字包装成了响应式的 Angular 信号。

filters 参数:复用 query-core 的 QueryFilters

filters 的类型定义在 utils.ts,它复用了 query-core 的通用过滤结构,可用字段包括:

export interface QueryFilters<TQueryKey extends QueryKey = QueryKey> {
  type?: QueryTypeFilter          // active | inactive | all
  exact?: boolean                 // queryKey 是否精确匹配
  predicate?: (query: Query) => boolean  // 自定义谓词
  queryKey?: TQueryKey | TuplePrefixes<TQueryKey> // 按 key 匹配(含前缀匹配)
  stale?: boolean                 // 是否限定 stale 状态
  fetchStatus?: FetchStatus       // 按 fetchStatus 过滤
}

注意两点:

  1. fetchStatus: 'fetching'QueryClient.isFetching 内部自动补上,你不需要在 filters 里再传;
  2. queryKey 支持前缀匹配——由于类型上允许 TuplePrefixes<TQueryKey>,传 ['todos'] 可以匹配 ['todos']['todos', 1] 这类更长的 key(除非设置 exact: true)。这使得"只统计某模块的查询是否在请求中"成为可能。

官方的 Background Fetching Indicators 指南 展示了最简用法——不传任何参数,统计全部查询:

import { injectIsFetching } from '@tanstack/angular-query-experimental'

@Component({
  selector: 'global-loading-indicator',
  template: `
    @if (isFetching()) {
      <div>Queries are fetching in the background...</div>
    }
  `,
})
export class GlobalLoadingIndicatorComponent {
  isFetching = injectIsFetching()
}

模板里 isFetching()number 类型,@if 直接利用"0 为 falsy、非零为 truthy"的语义即可。若想要更精确的文案(比如显示具体数字),也可以写成 {{ isFetching() }} 个查询正在请求

如果你只想关心某一组查询,例如 todos 模块,则传入 queryKey 过滤条件:

export class TodosComponent {
  isFetchingTodos = injectIsFetching({ queryKey: ['todos'] })
}

包内自带的 测试用例 就验证了这种行为:组件中同时创建两个查询(key1key2),用 injectIsFetching({ queryKey: key1 }) 订阅时,key2 正在请求也不会让信号大于 0——当只有 key1 在请求时显示 fetching: 1key1 完成后立即回到 fetching: 0,而耗时更长的 key2 并不影响该计数。

options 参数:injector 与注入上下文约束

InjectIsFetchingOptions 只有一个可选字段,其接口定义直接写在 inject-is-fetching.ts 中:

export interface InjectIsFetchingOptions {
  /**
   * The `Injector` in which to create the isFetching signal.
   *
   * If this is not provided, the current injection context will be used instead (via `inject`).
   */
  injector?: Injector
}

对应的运行时逻辑(inject-is-fetching.ts):

export function injectIsFetching(
  filters?: QueryFilters,
  options?: InjectIsFetchingOptions,
): Signal<number> {
  !options?.injector && assertInInjectionContext(injectIsFetching)
  const injector = options?.injector ?? inject(Injector)
  ...
}

这带来两条使用规则,且都被 inject-is-fetching.test.ts 中的"injection context"用例显式验证过:

  • 默认要求在注入上下文中调用(组件/服务字段初始化器、工厂函数等)。若在普通函数中直接调用 injectIsFetching() 且未提供 injector,会抛出 Angular 的 NG0203 错误,错误信息中会包含 injectIsFetching 函数名,方便定位;
  • 显式传入 injector 后可以脱离注入上下文使用,测试中 injectIsFetching(undefined, { injector: TestBed.inject(Injector) }) 不会抛错。这一能力适合在自定义服务或测试环境中手动控制信号的生命周期归属。

实现细节:NgZone、批量通知与自动清理

injectIsFetching 的完整实现只有约 30 行,但每一处细节都对应一个真实的工程问题。逐段拆解 inject-is-fetching.ts

const cache = queryClient.getQueryCache()
// isFetching is the prev value initialized on mount *
let isFetching = queryClient.isFetching(filters)

const result = signal(isFetching)

const unsubscribe = ngZone.runOutsideAngular(() =>
  cache.subscribe(
    notifyManager.batchCalls(() => {
      const newIsFetching = queryClient.isFetching(filters)
      if (isFetching !== newIsFetching) {
        // * and update with each change
        isFetching = newIsFetching
        ngZone.run(() => {
          result.set(isFetching)
        })
      }
    }),
  ),
)

destroyRef.onDestroy(unsubscribe)

return result
  1. 初始值来自同步计算signal(isFetching) 的初值不是 0,而是挂载时立刻调用一次 queryClient.isFetching(filters)。这保证信号创建时如果已有查询在请求中,组件首次渲染就能拿到正确数字,而不是等下一次状态变更。

  2. 订阅建在 QueryCache 上,而不是每个查询对象上queryClient.getQueryCache() 是所有查询的容器,任何查询状态的变更(创建、开始 fetch、完成、失败)都会触发缓存级的事件。因此无论应用里有多少查询、后续动态新增多少,injectIsFetching 的订阅数量始终是 1,代价是每次事件都要重新统计一遍符合条件的查询数。

  3. ngZone.runOutsideAngular + notifyManager.batchCalls 的组合:订阅回调在 Angular 变更检测区之外建立,避免每个查询事件都触发一次变更检测;batchCalls 则把同一微任务批次内的多次通知合并成一次执行。这两层优化共同作用,使得多个查询同时完成请求时,信号最多只被 set 一次。

  4. 值相等才不更新if (isFetching !== newIsFetching) 做了显式去重:缓存事件频繁触发,但只有"正在请求的查询数"真正变化时才会 result.set(...),从而避免无意义的信号版本变更。

  5. 只有 set 操作回到 NgZone 内ngZone.run(() => result.set(isFetching)))。在 Zone.js 应用中,信号写入发生在 zone 内以触发变更检测;而在 Zoneless 应用(如 provideZonelessChangeDetection,测试中正是这么配置的)里,该信号属于可追踪的 reactive 输入,同样能驱动更新。

  6. DestroyRef 保证清理destroyRef.onDestroy(unsubscribe) 把取消订阅绑定到当前注入器的销毁生命周期上——组件/服务销毁后不再持有 QueryCache 的监听,不会造成内存泄漏。

从源码结构看,injectIsFetchingangular-query-experimental 包中一组"全局状态信号"函数之一,与之并列的还有 inject-is-mutating.ts(订阅 MutationCache,统计 status === 'pending' 的 mutation 数量,实现模式完全一致,返回值额外通过 .asReadonly() 只读化)以及 injectIsRestoring(持久化恢复状态)。它们共同的套路是"取缓存 → 同步算初值 → 订阅缓存事件 → 去重后更新信号 → 绑定销毁清理"。

实战:区分局部状态与全局指示器

官方指南中给出了一个容易混淆的点:单个查询组件内的 isFetching() 与全局的 injectIsFetching() 不是一回事。前者的完整示例见 background-fetching-indicators.md

@Component({
  selector: 'todos',
  template: `
    @if (todosQuery.isPending()) {
      Loading...
    } @else if (todosQuery.isError()) {
      An error has occurred: {{ todosQuery.error().message }}
    } @else if (todosQuery.isSuccess()) {
      @if (todosQuery.isFetching()) {
        Refreshing...
      }
      @for (todos of todosQuery.data(); track todo.id) {
        <todo [todo]="todo" />
      }
    }
  `,
})
class TodosComponent {
  todosQuery = injectQuery(() => ({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  }))
}
  • 组件内需要表达"这条数据正在刷新"时,用 todosQuery.isFetching()(单查询布尔信号);
  • 需要在应用顶部展示"全站有 N 个请求在飞"这类横幅/进度条时,才引入 injectIsFetching(),并且可以配合 filters 收窄统计范围(例如只看 type: 'active' 或某个模块的 queryKey)。

两者结合,即得到"局部刷新提示 + 全局后台加载横幅"的完整加载体验,而这正是参考文档中 "Can be used for app-wide loading indicators" 所指的落地场景。

版本与适用前提

  • 该函数由 index.ts@tanstack/angular-query-experimental 主入口导出,使用前提是按 README 完成初始化:provideTanStackQuery(new QueryClient()) 注入根 Provider,且 Angular 版本要求 16 及以上;
  • 依据 README 中的声明,angular-query-experimental 目前处于 experimental 阶段,minor 和 patch 版本都可能引入破坏性变更,生产使用建议锁定到 patch 级版本;
  • 本文所有行号引用基于当前仓库快照,若仓库更新,请以 inject-is-fetching.tsqueryClient.ts 的实际内容为准。

小结

injectIsFetching 是 Angular 版 TanStack Query 中"全局请求计数"的官方入口:一行代码得到一个响应式信号,数字大于 0 即有查询在请求中。理解它的三个层次——API 层filters 复用 QueryFiltersoptions.injector 控制挂载点)、语义层(底层就是 QueryClient.isFetchingfetchStatus === 'fetching' 查询的计数)、实现层QueryCache 订阅 + runOutsideAngular + batchCalls + 去重 set + DestroyRef 清理)——之后,你就可以放心地把它用于全局加载横幅、路由级进度条乃至条件渲染的门控逻辑,而不必担心订阅泄漏或无谓的变更检测开销。

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