首页
/ TanStack Query Angular:用 injectIsMutating 信号追踪全局变更(Mutation)加载状态

TanStack Query Angular:用 injectIsMutating 信号追踪全局变更(Mutation)加载状态

2026-09-06 17:56:56作者:庞眉杨Will

本文围绕 TanStack Query 的 Angular 适配器包 @tanstack/angular-query-experimental 中的 injectIsMutating 函数展开:它如何以 Angular Signal 的形式返回“当前正在执行中的 mutation 数量”,参数如何配置,以及底层如何订阅 MutationCache、配合 NgZonenotifyManager 实现响应式更新。读完本文,你可以掌握在 Angular 应用中构建全局或按 mutationKey 过滤的“提交中”加载指示器,并理解该 API 的注入上下文约束与源码级实现原理。

一、API 概览

injectIsMutating 定义于 inject-is-mutating.ts 中,其签名如下:

function injectIsMutating(filters?, options?): Signal<number>;

它的职责是:注入一个信号(Signal),追踪你的应用中正在发起(fetching/pending)的 mutation 数量。典型用途是构建应用级的加载指示器(app-wide loading indicators)——例如当有任意表单提交、删除操作正在进行时,在页面顶部显示一条“正在提交…”的提示条。

需要注意的适用前提:该 API 属于 @tanstack/angular-query-experimental 包(对应仓库目录 packages/angular-query-experimental),即 Angular 适配器当前处于实验(experimental)阶段。它与同族的 injectIsFetching(追踪 query 的 fetch 数量)、injectIsRestoring(追踪持久化恢复状态)构成一组“全局状态信号”API。

二、参数详解

2.1 filters? — 过滤条件

filters?: MutationFilters<unknown, Error, unknown, unknown>

应用于 mutation 的过滤条件,类型来自 @tanstack/query-core 导出的 MutationFilters。最常见的用法是按 mutationKey 过滤,只统计某一类变更操作。例如:

// 只统计 mutationKey 为 ['deleteTodo'] 的 mutation 数量
readonly deleteMutating = injectIsMutating({ mutationKey: ['deleteTodo'] })

从源码结构看,这些过滤条件最终会透传给 MutationCachefindAll 方法——在 mutationCache.ts 中可以看到 findAll(filters: MutationFilters = {}): Array<Mutation> 负责按条件筛选出匹配的 mutation 实例。

2.2 options? — 额外配置

options?: InjectIsMutatingOptions

对应接口定义在 InjectIsMutatingOptions.md,源码见 inject-is-mutating.ts#L13-L20

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

唯一的可选属性 injector 用于指定创建该信号所挂靠的 Injector如果不提供,函数会通过 inject(Injector) 使用当前注入上下文——这要求调用点必须处于合法的注入上下文中(组件/服务构造函数、字段初始化器、runInInjectionContext 回调等),否则 Angular 会抛出 NG0203 错误。显式传入 injector 则是唯一允许脱离注入上下文调用该 API 的方式。

2.3 返回值

Signal<number> —— 一个只读信号,值为当前处于 pending 状态的 mutation 数量。调用 isMutating() 即可在模板或逻辑中读取最新值,信号变化会自动驱动 Angular 变更检测。

三、实战用法

3.1 应用级“提交中”指示器

下面的示例展示了 injectIsMutating 的典型用法:在根组件中注入信号,当计数大于 0 时展示全局提示:

import { Component } from '@angular/core'
import { injectIsMutating } from '@tanstack/angular-query-experimental'

@Component({
  selector: 'app-global-busy-indicator',
  template: `
    @if (mutatingCount() > 0) {
      <div class="global-busy">正在处理 {{ mutatingCount() }} 个请求…</div>
    }
  `,
})
export class GlobalBusyIndicatorComponent {
  // 字段初始化器中调用,处于注入上下文,合法
  readonly mutatingCount = injectIsMutating()
}

其思想与 Background Fetching Indicators 指南中用 injectIsFetching 构建全局 query 加载指示器的方式完全对称——一个面向 query 读取,一个面向 mutation 写入。

3.2 按 mutationKey 过滤

当你只关心某类操作(如“删除”)的进行状态时,传入 MutationFilters

import { injectIsMutating, injectMutation } from '@tanstack/angular-query-experimental'

@Component({
  selector: 'todos',
  template: `
    <button [disabled]="deleting()">删除</button>
  `,
})
export class TodosComponent {
  // 按 mutationKey 过滤:仅统计 ['deleteTodo'] 类 mutation
  readonly deleting = injectIsMutating({ mutationKey: ['deleteTodo'] })

  readonly deleteMutation = injectMutation(() => ({
    mutationKey: ['deleteTodo'],
    mutationFn: (id: number) => deleteTodo(id),
  }))
}

仓库中的单元测试 inject-is-mutating.test.ts 验证了该过滤行为:同时发起两个不同 mutationKey 的 mutation 后,按 key1 过滤的信号只计到 1,key1 对应的 mutation 完成后回落到 0,不受 key2 慢请求的影响。

3.3 测试环境中的调用

TestBed 单测中,若不在注入上下文里(例如普通 it 回调中),需要包一层 runInInjectionContext

const [mutation, isMutating] = TestBed.runInInjectionContext(() => [
  injectMutation(() => mutationOptions),
  injectIsMutating(),
])

这是 mutation-options.test.ts 中实际采用的写法。

四、源码级实现解析

injectIsMutating 的完整实现非常紧凑,位于 inject-is-mutating.ts#L30-L64。逐段拆解:

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

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

  const result = signal(isMutating)

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

  destroyRef.onDestroy(unsubscribe)

  return result.asReadonly()
}

可以提炼出五个关键设计点:

  1. 注入上下文守卫!options?.injector && assertInInjectionContext(injectIsMutating) —— 只有在未显式传入 injector 时才要求处于注入上下文。测试用例 inject-is-mutating.test.ts#L98-L112 验证了两种边界:脱离上下文直接调用会抛出包含 NG0203injectIsMutating 描述的错误;传入 injector 后则可以在任意位置安全调用。

  2. 初始值来自当前缓存状态:信号初始值不是固定的 0,而是 queryClient.isMutating(filters) 的即时计算结果。这意味着即使组件在“已有 mutation 正在执行”时才挂载,信号初始值也会正确反映真实数量(源码注释 isMutating is the prev value initialized on mount 即此意)。

  3. 在 Angular 变更检测之外订阅缓存cache.subscribe 被包裹在 ngZone.runOutsideAngular 中——mutation 状态变更本身不触发 Angular 变更检测,避免不必要的渲染开销;只有当计数值真正发生变化时,才通过 ngZone.run(() => result.set(isMutating)) 回到 Zone 内更新信号,由信号机制精准驱动 UI 刷新。

  4. notifyManager.batchCalls 批处理:订阅回调经过 notifyManager.batchCalls 包裹(定义于 notifyManager.ts)。当一次操作引发多个 mutation 状态变更(如并发提交多个请求)时,通知会被批处理合并,信号只需一次重算与一次更新。

  5. 生命周期自动清理destroyRef.onDestroy(unsubscribe) 把取消订阅绑定到宿主注入器(DestroyRef 取自 injector.get(DestroyRef))的销毁周期。组件销毁后,对 MutationCache 的订阅自动移除,不会泄漏。返回的 result.asReadonly() 则保证外部拿到的只能是只读信号,无法绕过组件直接 set 污染内部状态。

4.1 计数逻辑到底数什么

queryClient.isMutating(filters) 是核心计数入口,其实现位于 queryClient.ts#L116-L120

isMutating<
  TMutationFilters extends MutationFilters<any, any> = MutationFilters,
>(filters?: TMutationFilters): number {
  return this.#mutationCache.findAll({ ...filters, status: 'pending' }).length
}

也就是说:它返回的是缓存中所有满足你传入的 filters 且状态为 pending(已发起、尚未成功/失败)的 mutation 数量。与之对称,queryClient.isFetching 返回的是 fetchStatus: 'fetching' 的 query 数量(queryClient.ts#L109-L114)。

这里有一个值得注意的区别:queryClient.isMutating命令式(非响应)的一次性快照,适合在回调中读取;injectIsMutating 是在其之上构建的响应式版本——把“快照 + 订阅变更”封装成了生命周期安全的 Signal。仓库的类型测试 mutation-options.test-d.ts#L179-L207 也确认了两者的类型一致性:injectIsMutating() 的读取结果与 queryClient.isMutating(...) 一致,均为 number

五、测试用例中的行为验证

单元测试 inject-is-mutating.test.tsprovideZonelessChangeDetection() + provideTanStackQuery(queryClient) 的 TestBed 配置下,验证了以下行为链路(测试用例 L36-L63):

阶段 操作 模板渲染结果
初始 组件渲染,无进行中 mutation mutating: 0
发起 mutation.mutate({ par1: 'par1' }) 后推进 fake timer mutating: 1
完成 mutationFnsleep(10))解析完成后 mutating: 0

该测试同时印证了与 injectMutation 的协作:mutation 的 pending/成功/失败状态变化会实时反映到 injectIsMutating 信号中。多 mutation 并发的计数行为(1 个、2 个、按 key 过滤后各计 1 个)则分别在 mutation-options.test.ts#L78-L90#L103-L119 中有对应的断言覆盖。

六、选型建议与注意事项

  • 要“响应式信号”还是“一次性读数”:在模板/信号驱动的组件内使用,选 injectIsMutating;在事件回调、命令式代码中临时取数,直接用 queryClient.isMutating(filters) 即可,无需建立订阅。
  • 必须处于注入上下文:忘记传 injector 又在非注入上下文调用,会直接抛 NG0203。测试代码中 TestBed.runInInjectionContext 的包裹方式是标准解法。
  • 信号是只读的:返回 asReadonly() 后的信号不接受外部 set,这与 injectIsFetching.md 等同类 API 的行为一致。
  • 计数语义是“pending”:只有处于 pending 状态的 mutation 会被计入;已失败或成功的 mutation 立即不再计数。如果你的 UI 需要“失败后仍短暂提示”,应结合 injectMutation 自身的状态或 MutationCache 的其他订阅方式处理。
  • 过滤透传filtersqueryClient.isMutating 使用同一套 MutationFilters,二者对同一缓存的计数结果在任何时刻都保持一致,可以互相交叉验证。

综上,injectIsMutating 是 TanStack Query Angular 适配器中把“mutation 并发状态”响应式地暴露给模板层的核心工具:它以内联的 Injector/DestroyRef/NgZone 三件套保证生命周期与变更检测的正确性,以 MutationCache 订阅 + batchCalls 批处理保证更新的及时性与高效性,是构建全局提交指示器、并发操作计数器等 UI 场景的首选 API。

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