TanStack Query 中 injectIsFetching 详解:在 Angular 里构建全局加载指示器
在 Angular 应用中,单个查询的加载状态用 injectQuery 返回的 isPending/isFetching 信号即可覆盖,但"应用内此刻有多少个查询正在后台请求数据"这类全局视图,需要专门的工具函数来获取。本篇围绕 @tanstack/angular-query-experimental 包的 injectIsFetching 函数展开:它的签名、两个可选参数(filters 与 options)、返回的 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-core 的 queryClient.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 过滤
}
注意两点:
fetchStatus: 'fetching'由QueryClient.isFetching内部自动补上,你不需要在filters里再传;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'] })
}
包内自带的 测试用例 就验证了这种行为:组件中同时创建两个查询(key1、key2),用 injectIsFetching({ queryKey: key1 }) 订阅时,key2 正在请求也不会让信号大于 0——当只有 key1 在请求时显示 fetching: 1,key1 完成后立即回到 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
-
初始值来自同步计算。
signal(isFetching)的初值不是 0,而是挂载时立刻调用一次queryClient.isFetching(filters)。这保证信号创建时如果已有查询在请求中,组件首次渲染就能拿到正确数字,而不是等下一次状态变更。 -
订阅建在
QueryCache上,而不是每个查询对象上。queryClient.getQueryCache()是所有查询的容器,任何查询状态的变更(创建、开始 fetch、完成、失败)都会触发缓存级的事件。因此无论应用里有多少查询、后续动态新增多少,injectIsFetching的订阅数量始终是 1,代价是每次事件都要重新统计一遍符合条件的查询数。 -
ngZone.runOutsideAngular+notifyManager.batchCalls的组合:订阅回调在 Angular 变更检测区之外建立,避免每个查询事件都触发一次变更检测;batchCalls则把同一微任务批次内的多次通知合并成一次执行。这两层优化共同作用,使得多个查询同时完成请求时,信号最多只被set一次。 -
值相等才不更新。
if (isFetching !== newIsFetching)做了显式去重:缓存事件频繁触发,但只有"正在请求的查询数"真正变化时才会result.set(...),从而避免无意义的信号版本变更。 -
只有
set操作回到 NgZone 内(ngZone.run(() => result.set(isFetching)))。在 Zone.js 应用中,信号写入发生在 zone 内以触发变更检测;而在 Zoneless 应用(如provideZonelessChangeDetection,测试中正是这么配置的)里,该信号属于可追踪的 reactive 输入,同样能驱动更新。 -
DestroyRef保证清理。destroyRef.onDestroy(unsubscribe)把取消订阅绑定到当前注入器的销毁生命周期上——组件/服务销毁后不再持有QueryCache的监听,不会造成内存泄漏。
从源码结构看,injectIsFetching 是 angular-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.ts 与 queryClient.ts 的实际内容为准。
小结
injectIsFetching 是 Angular 版 TanStack Query 中"全局请求计数"的官方入口:一行代码得到一个响应式信号,数字大于 0 即有查询在请求中。理解它的三个层次——API 层(filters 复用 QueryFilters、options.injector 控制挂载点)、语义层(底层就是 QueryClient.isFetching 对 fetchStatus === 'fetching' 查询的计数)、实现层(QueryCache 订阅 + runOutsideAngular + batchCalls + 去重 set + DestroyRef 清理)——之后,你就可以放心地把它用于全局加载横幅、路由级进度条乃至条件渲染的门控逻辑,而不必担心订阅泄漏或无谓的变更检测开销。
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 StartedRust0627
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