Angular Query 的 CreateInfiniteQueryResult 类型全解析:从注入无限查询到 Signal 化结果对象
本篇文章围绕 Angular Query(@tanstack/angular-query-experimental)核心公开类型之一——CreateInfiniteQueryResult 展开。它是由 injectInfiniteQuery() 返回的结果类型,用于承载无限滚动 / 分页加载查询的完整状态。阅读本文后,你将理解该类型“三段式组合”的构成原理(状态窄化守卫 + Signal 映射 + query-core 观察者结果),掌握 hasNextPage、fetchNextPage、data.pages 等成员的精确语义,并能在真实组件中正确使用 CreateInfiniteQueryResult / DefinedCreateInfiniteQueryResult 编写类型安全的无限查询。
CreateInfiniteQueryResult 是什么
在 type-aliases/CreateInfiniteQueryResult.md 中,该类型的完整定义只有一行,却浓缩了 Angular Query 结果体系的全部设计:
type CreateInfiniteQueryResult<TData, TError> =
BaseQueryNarrowing<TData, TError> &
MapToSignals<InfiniteQueryObserverResult<TData, TError>>
它由三部分通过交叉类型(&)组合而成:
| 组成 | 来源 | 职责 |
|---|---|---|
BaseQueryNarrowing<TData, TError> |
types.ts | 提供 isSuccess / isError / isPending 三个状态窄化类型守卫 |
MapToSignals<...> |
signal-proxy.ts | 把对象中的每个字段递归包装为 Angular Signal,函数字段原样透传 |
InfiniteQueryObserverResult<TData, TError> |
query-core/src/types.ts | 无限查询观察者底层结果(含分页字段与状态判别联合) |
换句话说:CreateInfiniteQueryResult 就是 query-core 中 InfiniteQueryObserverResult 的 Angular 化形态——原本的普通属性值变成可响应式读取的 Signal,而方法与状态守卫保持函数语义不变。
类型参数 TData 与 TError
定义中声明的两个类型参数及其默认值:
TData(默认unknown):查询成功时data的数据类型。对于无限查询,泛型上界并非单页数据,而是整个分页聚合结构InfiniteData<TQueryFnData, TPageParam>(见下文“pages 结构”)。TError(默认DefaultError):查询失败时error的类型。DefaultError定义于@tanstack/query-core,是允许通过Register接口做模块增强替换的默认错误类型,不显式声明时即按默认错误语义处理。
从注入函数看返回类型的来源
CreateInfiniteQueryResult 不是凭空定义的抽象类型,而是 injectInfiniteQuery 的实际返回值。在 inject-infinite-query.ts 的实现中:
export function injectInfiniteQuery(
injectInfiniteQueryFn: () => CreateInfiniteQueryOptions,
options?: InjectInfiniteQueryOptions,
) {
!options?.injector && assertInInjectionContext(injectInfiniteQuery)
const injector = options?.injector ?? inject(Injector)
return runInInjectionContext(injector, () =>
createBaseQuery(
injectInfiniteQueryFn,
InfiniteQueryObserver as typeof QueryObserver,
),
)
}
可以看到:它把选项函数与 InfiniteQueryObserver 一起交给通用的 createBaseQuery 创建观察者实例,因此结果中的 data、status、fetchStatus 等字段天然带有无限查询(分页)特有语义,而非普通 injectQuery 的返回结果。
三个重载:按 initialData 情况区分返回类型
injectInfiniteQuery 针对“选项函数返回的 options 是否保证携带 initialData”提供了三种重载签名:
- 传入返回
DefinedInitialDataInfiniteOptions的选项函数 → 返回DefinedCreateInfiniteQueryResult<TData, TError> - 传入返回
UndefinedInitialDataInfiniteOptions的选项函数 → 返回CreateInfiniteQueryResult<TData, TError> - 传入返回
CreateInfiniteQueryOptions的选项函数 → 同样返回CreateInfiniteQueryResult<TData, TError>
这一设计让 TS 编译器能够根据选项形态自动推断结果:一旦提供 initialData,data 字段即为“有值”类型;未提供时则保留 data: undefined 的可能,需由开发者在模板或逻辑中处理加载态。
底层 InfiniteQueryObserverResult:与普通查询结果的分页差异
MapToSignals 所包裹的对象本体是 query-core 的 InfiniteQueryObserverResult。它由多个按 status 区分的联合成员构成:
export type InfiniteQueryObserverResult<TData, TError> =
| DefinedInfiniteQueryObserverResult<TData, TError> // refetchError | success
| InfiniteQueryObserverLoadingErrorResult<TData, TError>
| InfiniteQueryObserverLoadingResult<TData, TError>
| InfiniteQueryObserverPendingResult<TData, TError>
| InfiniteQueryObserverPlaceholderResult<TData, TError>
每个联合分支都扩展自 InfiniteQueryObserverBaseResult,在普通查询结果基础上额外追加了六个无限查询专属成员:
| 成员 | 类型 | 语义 |
|---|---|---|
fetchNextPage(options?) |
(...) => Promise<InfiniteQueryObserverResult> |
触发加载“下一页” |
fetchPreviousPage(options?) |
(...) => Promise<InfiniteQueryObserverResult> |
触发加载“上一页” |
hasNextPage |
boolean |
依据 getNextPageParam 判断是否还有下一页 |
hasPreviousPage |
boolean |
依据 getPreviousPageParam 判断是否还有上一页 |
isFetchNextPageError |
boolean |
加载下一页期间是否出错 |
isFetchingNextPage |
boolean |
是否正在加载下一页(用于禁用“加载更多”按钮) |
isFetchPreviousPageError |
boolean |
加载上一页期间是否出错 |
isFetchingPreviousPage |
boolean |
是否正在加载上一页 |
正是由于底层 InfiniteQueryObserverResult 是状态判别联合,不同状态下字段的取值会收窄:例如 pending 分支 data: undefined、error: null、isSuccess: false;success 分支则 data: TData、isSuccess: true(详见 query-core/src/types.ts)。
data 的结构:InfiniteData
无限查询的 data 不是单页数据,而是聚合容器 InfiniteData:
export interface InfiniteData<TData, TPageParam = unknown> {
pages: Array<TData> // 已加载的各页数据,按顺序排列
pageParams: Array<TPageParam> // 每页对应的 pageParam
}
因此在模板中遍历数据时,往往需要两层循环:先遍历 data().pages,再遍历每个 page 内部的数据项(见下文完整示例)。
BaseQueryNarrowing:三个状态窄化守卫
结果对象上的状态判断并非普通 boolean,而是 TS 类型谓词(type predicate)。BaseQueryNarrowing 定义在 types.ts:
export interface BaseQueryNarrowing<TData = unknown, TError = DefaultError> {
isSuccess: (
this: CreateBaseQueryResult<TData, TError>,
) => this is CreateBaseQueryResult<TData, TError, CreateStatusBasedQueryResult<'success', TData, TError>>
isError: (
this: CreateBaseQueryResult<TData, TError>,
) => this is CreateBaseQueryResult<TData, TError, CreateStatusBasedQueryResult<'error', TData, TError>>
isPending: (
this: CreateBaseQueryResult<TData, TError>,
) => this is CreateBaseQueryResult<TData, TError, CreateStatusBasedQueryResult<'pending', TData, TError>>
}
三个方法都声明了 this is ... 谓词,配合内部工具类型 CreateStatusBasedQueryResult(通过 Extract 从结果联合中取出对应 status 的分支),实现条件收窄:当 isSuccess() 返回 true 时,编译器自动知道当前对象的 data 非空、status: 'success'。这是 Angular 模板中 @if (query.isSuccess()) { ... } 分支内能安全解引用 query.data() 的类型学依据。
在 Angular Query 的上下文中,这些守卫函数是方法调用形式(query.isSuccess()),与 React Query 中属性式 isSuccess 的布尔判断存在明显差别——调用后返回类型谓词布尔值,与模板语法天然契合。
MapToSignals:字段到 Signal 的映射机制
CreateInfiniteQueryResult 与核心结果类型最大的差异在 MapToSignals。其定义位于 signal-proxy.ts:
export type MapToSignals<T> = {
[K in keyof T]: T[K] extends Function ? T[K] : Signal<T[K]>
}
映射规则很直接:
- 函数类型字段(
extends Function)原样保留——例如fetchNextPage、fetchPreviousPage、refetch、isSuccess等仍是普通方法,可直接以query.fetchNextPage()调用; - 其余字段包装为
Signal<T[K]>——例如data变成Signal<InfiniteData<...>>、status变成Signal<'pending' | 'success' | 'error'>、hasNextPage变成Signal<boolean>。
运行时实现:signalProxy
类型层面由 MapToSignals 完成,运行时则由 signalProxy 用 Proxy 实现:
export function signalProxy<TInput extends Record<string | symbol, any>>(
inputSignal: Signal<TInput>,
) {
const internalState = {} as MapToSignals<TInput>
return new Proxy<MapToSignals<TInput>>(internalState, {
get(target, prop) {
const computedField = target[prop]
if (computedField) return computedField
const targetField = untracked(inputSignal)[prop]
if (typeof targetField === 'function') return targetField
return (target[prop] = computed(() => inputSignal()[prop]))
},
// has / ownKeys / getOwnPropertyDescriptor ...
})
}
该实现的关键行为是惰性 + 缓存:首次访问某字段时,通过 computed(() => inputSignal()[prop]) 生成一个 Computed signal 并缓存进 internalState;之后读取直接命中缓存。函数字段则从底层结果对象中取原引用透传返回,保证调用时 this 与真实方法一致。untracked 的运用使“方法是否函数”的判断不建立响应式依赖,避免不必要的重算。
模板中的读取方式
映射完成后,模板里的访问必须区分两种语法:
query.data().pages——data是 Signal,用()调用读取;query.fetchNextPage()——fetchNextPage是方法,直接调用即可。
若误写 query.data.pages(漏掉括号)则拿不到数据,这是从 Signal 化结果对象迁移时最常见的错误。
完整实战:基于 CreateInfiniteQueryResult 的分页组件
结合 guides/infinite-queries.md 中的官方示例,一个完整的分页加载组件如下。注意类型推断:未提供 initialData 时,query 的静态类型即为 CreateInfiniteQueryResult<InfiniteData<Project>, Error>。
import { Component, computed, inject } from '@angular/core'
import { injectInfiniteQuery } from '@tanstack/angular-query-experimental'
import { lastValueFrom } from 'rxjs'
import { ProjectsService } from './projects-service'
@Component({
selector: 'example',
templateUrl: './example.component.html',
})
export class Example {
projectsService = inject(ProjectsService)
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
maxPages: 3,
}))
// hasNextPage / isFetchingNextPage 是 Signal,需用 () 读取
nextButtonDisabled = computed(
() => !this.query.hasNextPage() || this.query.isFetchingNextPage(),
)
nextButtonText = computed(() =>
this.query.isFetchingNextPage()
? 'Loading more...'
: this.query.hasNextPage()
? 'Load newer'
: 'Nothing more to load',
)
}
模板利用 isPending() / isError() / isSuccess() 的窄化能力,配合两层 @for 遍历 data().pages:
<div>
@if (query.isPending()) {
<p>Loading...</p>
} @else if (query.isError()) {
<span>Error: {{ query.error()?.message }}</span>
} @else {
@for (page of query.data().pages; track $index) {
@for (project of page.data; track project.id) {
<p>{{ project.name }} {{ project.id }}</p>
}
}
<div>
<button (click)="query.fetchNextPage()" [disabled]="nextButtonDisabled()">
{{ nextButtonText() }}
</button>
</div>
}
</div>
组件中 nextButtonDisabled 用 computed() 把多个 Signal(hasNextPage、isFetchingNextPage)组合成派生 Signal,避免直接在模板中书写复杂条件,这正是 Signal 化结果对象在 Angular 响应式体系下的推荐用法。
滚动触底自动加载场景
若是“无限滚动”而非按钮点击,可在触底事件回调中先做防抖判断再取页。文档 infinite-queries.md 也给出了基于 isFetching() 守卫的范式:
@Component({
template: ` <list-component (endReached)="fetchNextPage()" /> `,
})
export class Example {
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
}))
fetchNextPage() {
// Do nothing if already fetching
if (this.query.isFetching()) return
this.query.fetchNextPage()
}
}
与 DefinedCreateInfiniteQueryResult 的关系与取舍
当选项显式携带 initialData(或 placeholderData 的相关形态)时,injectInfiniteQuery 返回的是兄弟类型 DefinedCreateInfiniteQueryResult:
type DefinedCreateInfiniteQueryResult<
TData, TError,
TDefinedInfiniteQueryObserver = DefinedInfiniteQueryObserverResult<TData, TError>,
> = MapToSignals<TDefinedInfiniteQueryObserver>
两者关键差异有两点:
- 底层联合范围不同:
DefinedCreateInfiniteQueryResult包裹的是 DefinedInfiniteQueryObserverResult,仅含success与refetchError两个分支(外加 placeholder 语义),不含pending/loading分支。因此它不再需要isPending/isSuccess这类BaseQueryNarrowing守卫——data恒有值。 - 构成不同:
CreateInfiniteQueryResult= 窄化守卫&Signal 化结果;DefinedCreateInfiniteQueryResult= 纯 Signal 化结果。
从实现看,create-base-query.ts 内部先通过 observerSignal().getOptimisticResult(...) 计算“乐观结果”,再由订阅回调更新状态。是否“已定义(defined)”由选项是否保证 initialData 决定,最终体现为返回类型在 CreateInfiniteQueryResult 与 DefinedCreateInfiniteQueryResult 间的切换。这提示开发实践:在查询初始化阶段能够提供初始页数据(如 SSR 注入或缓存回填)时,尽量使用 initialData 形态,可获得免去 loading 分支的更窄类型;否则使用 CreateInfiniteQueryResult 并在模板/代码中显式处理 isPending。
结果对象在 Angular 响应式系统中的定位小结
综合 types.ts、signal-proxy.ts 与 query-core/src/types.ts 的实现,可以归纳 CreateInfiniteQueryResult 的三层价值:
- 状态完整:完整继承 query-core
InfiniteQueryObserverResult的状态判别联合,pending / error / success及分页状态一应俱全; - 形态响应式:经
MapToSignals将值字段转成Signal,与 Angular 组件模板、computed派生状态无缝协作; - 类型安全:
BaseQueryNarrowing的谓词守卫让条件分支内的data、error自动收窄,配合InfiniteData的pages / pageParams结构,让“无限滚动 / 加载更多”这类高频需求获得端到端的编译期保障。
在实际项目中,理解该类型即可顺手推及 CreateQueryResult(普通查询)等兄弟类型:它们共用 MapToSignals 与 BaseQueryNarrowing,仅在底层观察者结果上有所差异。建议在查询逻辑层为“返回的结果对象”显式声明 CreateInfiniteQueryResult<InfiniteData<T>> 之类别名,既能提升可读性,也有助于团队统一分页查询的契约。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00