首页
/ Angular Query 后台获取指示器:用 injectQuery 与 injectIsFetching 实现细粒度与全局加载状态

Angular Query 后台获取指示器:用 injectQuery 与 injectIsFetching 实现细粒度与全局加载状态

2026-09-06 21:11:07作者:宗隆裙

在 Angular Query(@tanstack/angular-query-experimental)中,status === 'pending' 只能表达"首次硬加载",而查询在已有数据时发起的后台重新获取(background refetching)同样需要可视化的进度提示。本文围绕仓库中的 Background Fetching Indicators 指南,讲解如何用 injectQuery 返回的 isFetching() 信号在组件内展示"正在刷新"提示,以及如何用 injectIsFetching 注入全局信号来提示"应用中有任何查询正在后台获取",并结合 injectIsFetching 的源码实现测试用例 深入剖析其响应式原理。

一、单查询级别:用 isFetching() 信号区分"加载中"与"刷新中"

Angular Query 的 injectQuery 返回一个信号化的查询结果对象(signal proxy)。其中的 isFetching() 是一个布尔信号,表示该查询当前是否处于"正在获取数据"状态——无论 status'pending''error' 还是 'success'。这正是它区别于 isPending() 的关键:

  • isPending()status === 'pending',即首次加载、还没有任何数据,适合展示骨架屏或全量 Loading;
  • isFetching():查询正在发起网络请求。当已有缓存数据时(如 staleTime 到期后的后台刷新、窗口重新聚焦触发的 refetch、手动调用 refetch()),status 可能仍是 'success',但 isFetching()true,此时适合展示一个不打断阅读的"Refreshing..."小角标。

下面是仓库指南给出的完整示例,展示了在 Angular 控制流模板中组合三种状态:

// 组件内定义查询
@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,
  }))
}

createBaseQuery 的源码 可以看到,injectQuery / injectInfiniteQuery 共用同一个基础工厂:它创建 QueryObserver,在 ngZone.runOutsideAngular 中订阅 observer 结果,并通过 notifyManager.batchCalls 把每次状态更新批处理后写回内部信号 resultFromSubscriberSignal。因此 todosQuery.isFetching() 的每次变化都会触发模板重新评估——这是信号化(signal-based)架构带来的细粒度变更检测能力。

另一个值得注意的细节:createBaseQuery 在订阅回调中会根据 state.fetchStatus === 'fetching' 调用 pendingTasks.add(),把进行中的请求注册到 Angular 的 pending tasks 机制中(见 create-base-query.ts 第 117-127 行)。这意味着 Router 的 waitForIdle 等能力能感知到正在后台获取的查询,加载指示器与路由守卫之间是相互一致的。

二、全局级别:injectIsFetching 提示"任何查询正在后台获取"

除了单个查询的加载状态,很多应用希望在任意查询开始后台获取时都展示一个全局加载条或顶部进度指示器。此时应使用 injectIsFetching,它是 React 版 useIsFetching hook 在 Angular 中的对应物:

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()
}

注意两点与 React 版本的差异:

  1. 它返回的是 Signal<number> 而不是布尔值——数字是"当前正在 fetching 的查询数量"。在模板中 @if (isFetching()) 依然成立,因为 0 为 falsy,非零为 truthy;需要更精确判断时可以写 @if (isFetching() > 0)
  2. 它遵循 Angular 的注入上下文约定,必须在注入上下文中调用(或显式传入 Injector)。

支持的参数

injectIsFetching 的完整签名为 injectIsFetching(filters?, options?)

参数 类型 说明
filters QueryFilters(可选) 对统计范围进行过滤,例如 { queryKey: ['todos'] }{ predicate: (query) => ... },只统计匹配条件的查询
options.injector Injector(可选) 显式指定创建信号的注入器。不传时使用当前注入上下文(通过 inject 隐式获取)

过滤能力在 测试用例 "should be able to filter by queryKey" 中得到了验证:同时发起 key1key2 两个查询,injectIsFetching({ queryKey: key1 }) 只在 key1 处于 fetching 时返回 1key1 完成后回到 0,完全不受 key2 影响。

三、源码级原理:injectIsFetching 如何保持响应

通读 inject-is-fetching.ts 的实现,可以拆解出以下关键机制:

export function injectIsFetching(
  filters?: QueryFilters,
  options?: InjectIsFetchingOptions,
): Signal<number> {
  !options?.injector && assertInInjectionContext(injectIsFetching)
  const injector = options?.injector ?? inject(Injector)
  const destroyRef = injector.get(DestroyRef)
  const ngZone = injector.get(NgZone)
  const queryClient = injector.get(QueryClient)

  const cache = queryClient.getQueryCache()
  // 初始值:挂载时刻正在 fetching 的查询数
  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) {
          isFetching = newIsFetching
          ngZone.run(() => {
            result.set(isFetching)
          })
        }
      }),
    ),
  )

  destroyRef.onDestroy(unsubscribe)

  return result
}

逐条解析:

  1. 注入上下文校验:未传 options.injector 时调用 assertInInjectionContext,在构造器/字段注入场景之外的时机调用会抛出 NG0203 错误。测试用例 明确验证了这两条路径:无上下文调用抛出匹配 NG0203(.*?)injectIsFetching 的异常;显式传入 Injector 则可以在任意位置安全调用。
  2. 数据来源是 QueryClient.isFetching:它并不自己遍历查询,而是委托给 QueryClient.isFetching,其实现是对 queryCache.findAll({ ...filters, fetchStatus: 'fetching' }) 的结果取 length。也就是说,统计口径是 fetchStatus === 'fetching' 的查询数量,filters 会原样并入查询条件。
  3. 通过 QueryCache 订阅驱动更新:每次任何查询状态变化,QueryCache 都会广播;订阅回调里重新执行 queryClient.isFetching(filters),只有当数值确实变化时才写信号,避免无意义的变更检测。
  4. Zone 边界处理:订阅在 ngZone.runOutsideAngular 中建立,避免高频的状态广播进入 Zone 的调度;但真正 result.set(...) 时用 ngZone.run 包回 Zone 内,保证 Zone 驱动应用的变更检测正确触发。测试中使用了 provideZonelessChangeDetection(),结合 ngZone.run 的双向处理,从源码结构看该实现对 zone-based 与 zoneless 两种应用形态均可工作。
  5. notifyManager.batchCalls 批处理:多个查询在同一微任务内并发变化时(例如同时重新聚焦触发的批量 refetch),通知会被合并,信号只写一次,减少模板更新次数。
  6. 生命周期自动清理destroyRef.onDestroy(unsubscribe) 保证宿主注入器销毁时自动退订,不需要手动管理。

四、行为验证:端到端测试如何确认正确性

inject-is-fetching.test.ts 提供了一个最小可复现的行为基准:

class Page {
  readonly query = injectQuery(() => ({
    queryKey: key,
    queryFn: () => sleep(100).then(() => 'Some data'),
  }))
  readonly isFetching = injectIsFetching()
}
// 模板:<div>fetching: {{ isFetching() }}</div>

测试用 vi.useFakeTimers() 驱动时间:挂载后渲染 fetching: 0,推进 0ms(查询启动)后变为 fetching: 1,推进 101ms(queryFn 完成)后回到 fetching: 0。这条"0 → 1 → 0"的轨迹与 QueryClientfetchStatus 的生命周期完全吻合,也直观说明了为什么全局指示器是"数量"语义——多个查询并发时它就是并发数。

五、实践要点与注意事项

结合文档与源码,使用后台获取指示器时建议遵循以下要点:

  • 首次加载用 isPending(),刷新提示用 isFetching():两者组合即可覆盖"硬加载 / 错误 / 成功 + 后台刷新"全部四种 UI 形态,这也是 Angular 版指南示例 推荐的模板结构。
  • 全局指示器放在布局组件中injectIsFetching 返回的信号天然适合挂到 app-shell 之类的根布局组件上,用 @if (isFetching()) 控制顶部进度条。
  • 需要局部统计时传 filters:如 { queryKey: ['todos'] } 只统计 todos 相关查询;filters 的语义与 QueryClient.isFetching 完全一致(findAllfetchStatus: 'fetching')。
  • 注意注入上下文限制:只能在注入上下文(组件/服务字段初始化等)中调用,否则传 { injector } 选项。
  • 与 React 版 API 的映射关系:本指南对应 React 版 Background Fetching Indicators,核心差异是 Angular 侧返回 Signal<number>(需以 isFetching() 方式调用),而非 hook 返回的普通数值。

参考

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