TanStack Query for Angular:injectQuery 查询、信号化结果与 status/fetchStatus 双状态模型详解
本文基于仓库中 Angular 版 Queries 指南,系统讲解如何在 Angular 应用中用 injectQuery 订阅声明式查询:如何组织唯一 key 与 queryFn、如何消费信号化(Signal)的查询结果、如何在模板中完成 pending → error → success 的状态分支渲染,并结合 angular-query-experimental 包源码剖析结果对象背后的 computed 代理、类型收窄与 NgZone 调度机制。读完后,你将掌握在 Angular 项目中接入 TanStack Query 查询能力所需的完整链路:提供 QueryClient、编写响应式查询选项、在模板中消费结果信号,并理解 status 与 fetchStatus 两个维度的状态模型。
什么是 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]>
}
即:状态字段(data、status、error、isPending 等)都被包装成 computed 信号,在模板和组件中需要加括号调用(data()、isPending());而 refetch、refetchInBackground 等函数型字段按原样透传,直接当函数调用即可。代理的 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() 之前先检查过 pending 和 error(或 status)后,TypeScript 会正确收窄 data 的类型。这一点与 React 版本略有不同:如 Queries 指南所述,TypeScript 只在检查 isPending、isError 这类布尔信号时才进行收窄。
这一能力由 types.ts 中的 BaseQueryNarrowing 接口实现——isSuccess、isError、isPending 都被声明为 this is ... 类型守卫,返回值被收窄为对应 status 子集的结果对象。因此上面 @else 分支里 todos.data() 不会是 undefined,可以直接安全渲染;而在未检查状态的分支中,data() 的类型则仍包含 undefined。
fetchStatus:查询的第二维度状态
除了 status 字段外,结果中还有一个 fetchStatus 属性(信号),取值如下:
fetchStatus() === 'fetching'—— 查询当前正在获取数据;fetchStatus() === 'paused'—— 查询想获取数据但被暂停了,详见 Network Mode 指南;fetchStatus() === 'idle'—— 查询当前什么都没做。
为什么需要两套状态?
后台重取和 stale-while-revalidate 逻辑使得 status 与 fetchStatus 的所有组合都有可能。例如:
- 处于
success状态的查询通常fetchStatus是idle,但如果在做后台重取,它也可能是fetching; - 刚挂载且没有数据的查询,通常处于
pending状态且fetchStatus为fetching,但如果没有网络连接,它也可能是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 复用同一基础实现):
- 注入依赖:依次
inject(NgZone)、inject(PENDING_TASKS)、inject(QueryClient)、injectIsRestoring()(create-base-query.ts)。QueryClient必须先由provideTanStackQuery/provideQueryClient提供,见下文。 - 默认值合并 + 响应式选项:
defaultedOptionsSignal是一个computed,对每次传入的选项执行queryClient.defaultQueryOptions(optionsFn()),即把你在 QueryClient 中配置的defaultOptions合并进来;同时根据持久化恢复状态标记_optimisticResults(create-base-query.ts)。 - 观察者创建:
observerSignal懒创建一个QueryObserver(来自@tanstack/query-core),并始终复用同一实例(create-base-query.ts)。 - 选项同步 effect:第一个
effect在选项变化时调用observer.setOptions(defaultedOptions),并把信号写入包在ngZone.run里(对 Angular < v19 会显式设置allowSignalWrites以兼容)(create-base-query.ts)。 - 订阅与错误上报:第二个
effect在ngZone.runOutsideAngular中订阅观察者,状态更新经notifyManager.batchCalls批处理后回到ngZone.run内写入resultFromSubscriberSignal;若throwOnError判定需要抛出,会通过ngZone.onError.emit上报后再throw,与 Angular 全局错误处理对接(create-base-query.ts)。 - 信号化输出:最终结果(订阅结果优先,无订阅时退回乐观结果
getOptimisticResult)被signalProxy包成代理返回;refetch被额外包装,执行前先用最新选项调用observer.setOptions,保证手动重取也用当前响应式选项(create-base-query.ts)。
initialData 与类型重载
injectQuery 提供多重载(见 inject-query.ts):当传入的选项带 initialData(对应 DefinedInitialDataOptions)时,返回 DefinedCreateQueryResult,即 data() 的类型是确定的非 undefined;未提供 initialData 时(UndefinedInitialDataOptions),data() 类型包含 undefined。
如果需要在多处共享、复用查询选项并保持类型安全,可以使用 queryOptions:它会把 queryKey 用 queryFn 的数据类型打标(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 快速上手。
延伸阅读
- Angular 版 Mutations 指南:当你的方法修改服务器数据时,应改用 mutation;
- Angular 版 Network Mode 指南:理解
fetchStatus === 'paused'的网络模式背景; - inject-query 包源码:多重载签名、
InjectQueryOptions与注入上下文断言; - create-base-query 包源码:
computed选项、双effect与 NgZone 调度; - Angular API Reference 目录:
injectQuery、queryOptions、provideTanStackQuery等 API 的完整类型文档(由 scripts/generate-docs.ts 基于 TypeDoc 生成); - examples/angular/basic:一个可直接运行的 Angular 查询示例,演示
OnPush变更检测策略与injectQuery信号结果的配合。
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