Angular Query 后台获取指示器:用 injectQuery 与 injectIsFetching 实现细粒度与全局加载状态
在 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 版本的差异:
- 它返回的是
Signal<number>而不是布尔值——数字是"当前正在 fetching 的查询数量"。在模板中@if (isFetching())依然成立,因为0为 falsy,非零为 truthy;需要更精确判断时可以写@if (isFetching() > 0)。 - 它遵循 Angular 的注入上下文约定,必须在注入上下文中调用(或显式传入
Injector)。
支持的参数
injectIsFetching 的完整签名为 injectIsFetching(filters?, options?):
| 参数 | 类型 | 说明 |
|---|---|---|
filters |
QueryFilters(可选) |
对统计范围进行过滤,例如 { queryKey: ['todos'] }、{ predicate: (query) => ... },只统计匹配条件的查询 |
options.injector |
Injector(可选) |
显式指定创建信号的注入器。不传时使用当前注入上下文(通过 inject 隐式获取) |
过滤能力在 测试用例 "should be able to filter by queryKey" 中得到了验证:同时发起 key1、key2 两个查询,injectIsFetching({ queryKey: key1 }) 只在 key1 处于 fetching 时返回 1,key1 完成后回到 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
}
逐条解析:
- 注入上下文校验:未传
options.injector时调用assertInInjectionContext,在构造器/字段注入场景之外的时机调用会抛出NG0203错误。测试用例 明确验证了这两条路径:无上下文调用抛出匹配NG0203(.*?)injectIsFetching的异常;显式传入Injector则可以在任意位置安全调用。 - 数据来源是
QueryClient.isFetching:它并不自己遍历查询,而是委托给 QueryClient.isFetching,其实现是对queryCache.findAll({ ...filters, fetchStatus: 'fetching' })的结果取length。也就是说,统计口径是fetchStatus === 'fetching'的查询数量,filters会原样并入查询条件。 - 通过
QueryCache订阅驱动更新:每次任何查询状态变化,QueryCache都会广播;订阅回调里重新执行queryClient.isFetching(filters),只有当数值确实变化时才写信号,避免无意义的变更检测。 - Zone 边界处理:订阅在
ngZone.runOutsideAngular中建立,避免高频的状态广播进入 Zone 的调度;但真正result.set(...)时用ngZone.run包回 Zone 内,保证 Zone 驱动应用的变更检测正确触发。测试中使用了provideZonelessChangeDetection(),结合ngZone.run的双向处理,从源码结构看该实现对 zone-based 与 zoneless 两种应用形态均可工作。 notifyManager.batchCalls批处理:多个查询在同一微任务内并发变化时(例如同时重新聚焦触发的批量 refetch),通知会被合并,信号只写一次,减少模板更新次数。- 生命周期自动清理:
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"的轨迹与 QueryClient 中 fetchStatus 的生命周期完全吻合,也直观说明了为什么全局指示器是"数量"语义——多个查询并发时它就是并发数。
五、实践要点与注意事项
结合文档与源码,使用后台获取指示器时建议遵循以下要点:
- 首次加载用
isPending(),刷新提示用isFetching():两者组合即可覆盖"硬加载 / 错误 / 成功 + 后台刷新"全部四种 UI 形态,这也是 Angular 版指南示例 推荐的模板结构。 - 全局指示器放在布局组件中:
injectIsFetching返回的信号天然适合挂到app-shell之类的根布局组件上,用@if (isFetching())控制顶部进度条。 - 需要局部统计时传
filters:如{ queryKey: ['todos'] }只统计 todos 相关查询;filters的语义与QueryClient.isFetching完全一致(findAll加fetchStatus: 'fetching')。 - 注意注入上下文限制:只能在注入上下文(组件/服务字段初始化等)中调用,否则传
{ injector }选项。 - 与 React 版 API 的映射关系:本指南对应 React 版 Background Fetching Indicators,核心差异是 Angular 侧返回
Signal<number>(需以isFetching()方式调用),而非 hook 返回的普通数值。
参考
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 StartedRust0624
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