首页
/ TanStack Query for Angular:injectQuery 查询、信号化结果与 status/fetchStatus 双状态模型详解

TanStack Query for Angular:injectQuery 查询、信号化结果与 status/fetchStatus 双状态模型详解

2026-09-06 11:12:30作者:柏廷章Berta

本文基于仓库中 Angular 版 Queries 指南,系统讲解如何在 Angular 应用中用 injectQuery 订阅声明式查询:如何组织唯一 key 与 queryFn、如何消费信号化(Signal)的查询结果、如何在模板中完成 pending → error → success 的状态分支渲染,并结合 angular-query-experimental 包源码剖析结果对象背后的 computed 代理、类型收窄与 NgZone 调度机制。读完后,你将掌握在 Angular 项目中接入 TanStack Query 查询能力所需的完整链路:提供 QueryClient、编写响应式查询选项、在模板中消费结果信号,并理解 statusfetchStatus 两个维度的状态模型。

什么是 Query:绑定唯一 key 的声明式数据依赖

Query 是对一个异步数据源的声明式依赖,并且始终绑定到一个唯一的 key(unique key)。它可以配合任意基于 Promise 的方法使用(包括 GET、POST 等请求)从服务器获取数据;如果你的方法会修改服务器上的数据,则应改用 Mutations

这个“唯一 key”不是摆设:它在内部被用于重新获取(refetching)、缓存(caching)以及在整个应用中共享查询。相同 key 的查询会命中同一份缓存,组件间共享同一数据源。

用 injectQuery 订阅一个查询

在组件或 service 中订阅查询,调用 injectQuery,它至少需要两样东西:

  • 一个唯一的查询 key
  • 一个返回 Promise 或 Observable 的函数(即 queryFn),该 Promise 要么解析出数据,要么抛出错误:
import { injectQuery } from '@tanstack/angular-query-experimental'

export class TodosComponent {
  info = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodoList }))
}

injectQuery 的第一个参数是返回查询选项的函数(而非选项对象本身)。从源码看,这个设计刻意为之:在 inject-query.ts 中,实现内部会执行 createBaseQuery(injectQueryFn, QueryObserver),而选项函数在 create-base-query.ts 中被包进 computed 求值——这意味着函数内部的信号读取会建立响应式依赖,信号变化时查询选项会被重新计算。源码中的 JSDoc 示例展示了这一点:当 filter 信号变为真值时,enabled: !!this.filter() 会自动启用并执行查询;当它变回假值时,查询会被禁用,且 queryKey: ['todos', this.filter()] 会随之切换到新的 key。

因此你可以把信号直接“内嵌”进查询选项里,无需手动管理 queryClient.setQueryData 或手动取消/重发请求,这是 Angular 版本区别于 React 版本 useQuery 的核心特性。

injectQuery 还有一个可选的第二参数,见 InjectQueryOptions

export interface InjectQueryOptions {
  /**
   * 创建查询所使用的 `Injector`。
   * 如果未提供,则使用当前注入上下文(通过 `inject`)。
   */
  injector?: Injector
}

当不传 injector 时,实现通过 assertInInjectionContext(injectQuery) + inject(Injector) 使用当前注入上下文;传入 Injector 则可以在自定义注入器中创建查询(例如懒加载路由下独立作用域的场景)。

queryFn 也可以返回 Observable。仓库中的 basic 示例 展示了与 RxJS 桥接的常见写法——用 lastValueFrom 把服务里的 Observable 收敛为 Promise:

readonly postsQuery = injectQuery(() => ({
  queryKey: ['posts'],
  queryFn: () => lastValueFrom(this.#postsService.allPosts$()),
}))

返回的查询结果对象

injectQuery 返回的 result 对象包含了你为模板和其他用途所需的全部查询信息:

result = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodoList }))

需要注意的是,result 上的字段是信号(Signal),而方法保持为函数。从 signal-proxy.ts 的类型定义可以直接确认这一约定:

export type MapToSignals<T> = {
  [K in keyof T]: T[K] extends Function ? T[K] : Signal<T[K]>
}

即:状态字段(datastatuserrorisPending 等)都被包装成 computed 信号,在模板和组件中需要加括号调用(data()isPending());而 refetchrefetchInBackground 等函数型字段按原样透传,直接当函数调用即可。代理的 get 拦截器会对每个字段懒创建并缓存一个 computed(见 signal-proxy.ts),因此访问结果字段本身不会触发额外计算,只有上游观察者状态变化时才重新求值。

查询的三种主要状态

一个查询在任意时刻只能处于以下三种状态之一:

状态判断 等价 status 含义
isPending() / status() === 'pending' 'pending' 查询还没有数据
isError() / status() === 'error' 'error' 查询遇到了错误
isSuccess() / status() === 'success' 'success' 查询成功,数据可用

在这些主要状态之外,还有随状态变化的补充信息:

  • error() —— 查询处于 isError 状态时,错误对象可通过 error 属性访问;
  • data() —— 查询处于 isSuccess 状态时,数据可通过 data 属性访问;
  • isFetching() —— 在任何状态下,只要查询此刻正在获取数据(包括后台重取,background refetching),isFetching 都会是 true

对于大多数查询,检查 isPending 状态、然后检查 isError 状态、最后假定数据可用并渲染成功态,就足够了:

@Component({
  selector: 'todos',
  template: `
    @if (todos.isPending()) {
      <span>Loading...</span>
    } @else if (todos.isError()) {
      <span>Error: {{ todos.error()?.message }}</span>
    } @else {
      <!-- 走到这一步,可以认为 status === 'success' -->
      @for (todo of todos.data(); track todo.id) {
        <li>{{ todo.title }}</li>
      } @empty {
        <li>No todos found</li>
      }
    }
  `,
})
export class PostsComponent {
  todos = injectQuery(() => ({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
  }))
}

用 status 做状态分支的另一种写法

如果你不习惯布尔判断,也可以直接使用 status 状态,配合 Angular 控制流指令的 @switch

@Component({
  selector: 'todos',
  template: `
    @switch (todos.status()) {
      @case ('pending') {
        <span>Loading...</span>
      }
      @case ('error') {
        <span>Error: {{ todos.error()?.message }}</span>
      }
      <!-- 也包含 status === 'success' 的情况,但 "else" 逻辑同样可行 -->
      @default {
        <ul>
          @for (todo of todos.data(); track todo.id) {
            <li>{{ todo.title }}</li>
          } @empty {
            <li>No todos found</li>
          }
        </ul>
      }
    }
  `,
})
class TodosComponent {}

TypeScript 类型收窄

在访问 data() 之前先检查过 pendingerror(或 status)后,TypeScript 会正确收窄 data 的类型。这一点与 React 版本略有不同:如 Queries 指南所述,TypeScript 只在检查 isPendingisError 这类布尔信号时才进行收窄

这一能力由 types.ts 中的 BaseQueryNarrowing 接口实现——isSuccessisErrorisPending 都被声明为 this is ... 类型守卫,返回值被收窄为对应 status 子集的结果对象。因此上面 @else 分支里 todos.data() 不会是 undefined,可以直接安全渲染;而在未检查状态的分支中,data() 的类型则仍包含 undefined

fetchStatus:查询的第二维度状态

除了 status 字段外,结果中还有一个 fetchStatus 属性(信号),取值如下:

  • fetchStatus() === 'fetching' —— 查询当前正在获取数据;
  • fetchStatus() === 'paused' —— 查询想获取数据但被暂停了,详见 Network Mode 指南
  • fetchStatus() === 'idle' —— 查询当前什么都没做。

为什么需要两套状态?

后台重取和 stale-while-revalidate 逻辑使得 statusfetchStatus所有组合都有可能。例如:

  • 处于 success 状态的查询通常 fetchStatusidle,但如果在做后台重取,它也可能是 fetching
  • 刚挂载且没有数据的查询,通常处于 pending 状态且 fetchStatusfetching,但如果没有网络连接,它也可能是 paused

所以要牢记:一个查询可以处于 pending 状态,却并没有真正在获取数据。经验法则:

  • status 描述的是 data 的情况:我们有没有数据?
  • fetchStatus 描述的是 queryFn 的情况:它正在运行吗?

这个设计在源码中亦有印证:create-base-query.ts 的订阅回调里正是根据 state.fetchStatus === 'fetching'state.fetchStatus === 'idle' 的切换来登记/释放 Angular 的待处理任务(PENDING_TASKS),把正在进行的请求同步到路由导航的 pending task 机制上。

源码纵深:injectQuery 的完整工作链路

结合 create-base-query.ts 可以看到 injectQuery 内部的完整链路(injectInfiniteQuery 复用同一基础实现):

  1. 注入依赖:依次 inject(NgZone)inject(PENDING_TASKS)inject(QueryClient)injectIsRestoring()create-base-query.ts)。QueryClient 必须先由 provideTanStackQuery / provideQueryClient 提供,见下文。
  2. 默认值合并 + 响应式选项defaultedOptionsSignal 是一个 computed,对每次传入的选项执行 queryClient.defaultQueryOptions(optionsFn()),即把你在 QueryClient 中配置的 defaultOptions 合并进来;同时根据持久化恢复状态标记 _optimisticResultscreate-base-query.ts)。
  3. 观察者创建observerSignal 懒创建一个 QueryObserver(来自 @tanstack/query-core),并始终复用同一实例(create-base-query.ts)。
  4. 选项同步 effect:第一个 effect 在选项变化时调用 observer.setOptions(defaultedOptions),并把信号写入包在 ngZone.run 里(对 Angular < v19 会显式设置 allowSignalWrites 以兼容)(create-base-query.ts)。
  5. 订阅与错误上报:第二个 effectngZone.runOutsideAngular 中订阅观察者,状态更新经 notifyManager.batchCalls 批处理后回到 ngZone.run 内写入 resultFromSubscriberSignal;若 throwOnError 判定需要抛出,会通过 ngZone.onError.emit 上报后再 throw,与 Angular 全局错误处理对接(create-base-query.ts)。
  6. 信号化输出:最终结果(订阅结果优先,无订阅时退回乐观结果 getOptimisticResult)被 signalProxy 包成代理返回;refetch 被额外包装,执行前先用最新选项调用 observer.setOptions,保证手动重取也用当前响应式选项(create-base-query.ts)。

initialData 与类型重载

injectQuery 提供多重载(见 inject-query.ts):当传入的选项带 initialData(对应 DefinedInitialDataOptions)时,返回 DefinedCreateQueryResult,即 data() 的类型是确定的非 undefined;未提供 initialData 时(UndefinedInitialDataOptions),data() 类型包含 undefined

如果需要在多处共享、复用查询选项并保持类型安全,可以使用 queryOptions:它会把 queryKeyqueryFn 的数据类型打标(QueryKeyWithDataTag),之后 queryClient.getQueryData(queryKey) 的返回值类型就能精确推断出来。

前提:提供 QueryClient

injectQuery 依赖注入上下文中存在 QueryClient。典型设置方式(来自 providers.ts 的文档说明):

import {
  provideTanStackQuery,
  withDevtools,
  QueryClient,
} from '@tanstack/angular-query-experimental'

bootstrapApplication(AppComponent, {
  providers: [
    provideTanStackQuery(new QueryClient(), withDevtools()),
  ],
})

provideTanStackQuery 内部调用 provideQueryClient,后者会在注入器创建 QueryClient 时执行 client.mount(),并注册 DestroyRef.onDestroy(() => client.unmount()),使查询客户端的生命周期与 Angular 注入器对齐(providers.ts)。它也可以接受一个 InjectionToken<QueryClient>,作为“只在懒加载路由中包含 TanStack Query、但共享同一 QueryClient”的进阶优化。更多安装与初始化细节见 Angular 快速上手

延伸阅读

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