TanStack Query Angular 无限查询实战:injectInfiniteQuery、maxPages 与双向分页
在 Angular 项目中实现"加载更多"(Load More)或无限滚动(Infinite Scroll)列表,需要同时管理分页参数、逐页追加数据、后台刷新与双向加载等状态。本文基于 Angular 官方指南 Infinite Queries,完整讲解 @tanstack/angular-query-experimental 包中 injectInfiniteQuery 的选项、信号(Signal)API 与模板用法,并结合仓库中可运行的示例工程 examples/angular/infinite-query-with-max-pages 与核心源码,说明分页参数(pageParam)、maxPages 限制、双向加载等机制的落地细节。读完后你可以直接在自己的 Angular 应用中搭建带游标或页码分片的列表查询,并掌握手动增删缓存页的正确姿势。
injectInfiniteQuery:与普通查询的不同点
普通查询通过 injectQuery 声明,而"可追加加载"的列表查询使用 injectInfiniteQuery。两者的差异集中在返回的数据结构和新增的选项/信号上(对应 React 版 useInfiniteQuery 的文档 docs/framework/react/guides/infinite-queries.md,Angular 指南中的代码即由该文档替换而来:useQuery → injectQuery、useInfiniteQuery → injectInfiniteQuery):
- 返回的
data不再是单份数据,而是无限查询数据结构InfiniteData:data.pages:已加载页面的数组;data.pageParams:与页面一一对应的分页参数数组。
- 可用
fetchNextPage、fetchPreviousPage方法加载下一页/上一页(fetchNextPage为单向场景的必需项)。 - 必须提供
initialPageParam,指定第一页使用的分页参数;queryFn的入参会以{ pageParam }的形式收到它。 getNextPageParam/getPreviousPageParam选项用于"判断是否还有更多数据"并"计算下一/上一页的参数"。hasNextPage信号为true,当且仅当getNextPageParam返回的值既不是null也不是undefined;hasPreviousPage同理。isFetchingNextPage与isFetchingPreviousPage信号可用来区分"后台刷新"与"加载更多"两种抓取状态。
注意:
initialData、placeholderData选项(如有提供)必须符合{ pages, pageParams }的数据结构。
在 Angular 实现中,这些状态不是响应式属性而是信号:模板里通过 query.isPending()、query.data()、query.hasNextPage() 的方式读取,组件内则可以用 computed 组合它们。从源码看,inject-infinite-query.ts 内部将 InfiniteQueryObserver(来自 @tanstack/query-core)交给 createBaseQuery 构建,最终结果经由 signalProxy 包装为信号集合返回(见 create-base-query.ts),这就是为什么 data、error、status 都是"可调用"的形式。
基础示例:用游标实现 "Load More"
假设后端有一个按游标(cursor)分页的 API,每次返回 3 条 projects 并附带下次请求用的游标:
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3 }
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6 }
fetch('/api/projects?cursor=9')
// { data: [...] } // 没有 nextCursor 即到底
构建 "Load More" 界面的思路是:
injectInfiniteQuery默认请求第一组数据(initialPageParam指定的第一页);- 在
getNextPageParam中返回下一组的游标; - 点击按钮时调用
fetchNextPage。
Angular 版完整组件如下(queryFn 返回 RxJS Observable 时,用 lastValueFrom 桥接为 Promise;服务层 ProjectsService 通过 HttpClient 请求 /api/projects?cursor=${page}):
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,
}))
nextButtonDisabled = computed(
() => !this.#hasNextPage() || this.#isFetchingNextPage(),
)
nextButtonText = computed(() =>
this.#isFetchingNextPage()
? 'Loading more...'
: this.#hasNextPage()
? 'Load newer'
: 'Nothing more to load',
)
#hasNextPage = this.query.hasNextPage
#isFetchingNextPage = this.query.isFetchingNextPage
}
模板部分使用 Angular 的 @if / @for 控制流,遍历 query.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>
几个值得注意的写法细节:
- 选项放在箭头函数
() => ({...})里返回,选项中的表达式(包括信号)是响应式的; getPreviousPageParam/getNextPageParam返回undefined(?? undefined)即表示"没有更多页",对应hasPreviousPage/hasNextPage变false;- 按钮文案与禁用状态由
computed从isFetchingNextPage、hasNextPage两个信号派生,避免在模板中堆砌逻辑。
仓库中有一个与上面几乎一致的完整可运行工程 examples/angular/infinite-query-with-max-pages(每页 4 条、最多 3 页、支持向前/向后加载),其组件实现在 example.component.ts,其中 maxPages: 3 即下文"限制页数"一节的用法;服务层 projects.service.ts 展示了 HttpClient 与 lastValueFrom 的配合方式,模拟接口 projects-mock.interceptor.ts 则返回 nextId / previousId 作为游标(当 nextId 为 null 时触发 ?? undefined,列表即停止)。该示例基于 Angular 20 + @tanstack/angular-query-experimental,运行方式为 pnpm install 后 pnpm start(见其 README)。
避免并发抓取:fetchNextPage 与 isFetching
在列表渲染的同时触发 fetchNextPage,可能与正在进行的其他抓取产生冲突。无限查询在缓存中只共享一条缓存条目承载所有页面,同一时刻只能有一个进行中的 fetch;若并发触发两次抓取,后完成者可能覆盖前者的数据(例如后台刷新被新页数据覆盖)。
因此推荐在由用户间接触发抓取的场景(比如滚动到底部自动加载)中,先确认查询不在 isFetching 状态:
@Component({
template: ` <list-component (endReached)="fetchNextPage()" /> `,
})
export class Example {
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
return lastValueFrom(this.projectsService.getProjects(pageParam))
},
}))
fetchNextPage() {
// 如果已有请求在进行中则不重复触发
if (this.query.isFetching()) return
this.query.fetchNextPage()
}
}
如果确实希望允许并发抓取(比如让新页请求不打断后台刷新),可以在 fetchNextPage 调用时传入 { cancelRefetch: false } 选项(默认 true,即默认会取消正在进行的 refetch)。
无限查询刷新时的行为
当无限查询变为 stale 需要刷新时,各页是按顺序(sequentially)从第一页开始逐页抓取的。这样即使底层数据发生了变化,也不会因为使用了过期的游标而拿到重复数据或漏掉记录。如果该查询的结果从 QueryCache 中被移除,分页状态会重置回初始状态,只请求第一页。
这一点也直接影响了性能权衡:页数越多,刷新时的串行请求越多,这正是下一节"限制页数"的动机。
双向无限列表:getPreviousPageParam 与 fetchPreviousPage
如果需要"向前也能加载更多"(例如时间线、评论区向上翻页),可以使用 getPreviousPageParam 选项配合 fetchPreviousPage 方法与 hasPreviousPage、isFetchingPreviousPage 信号:
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
}))
其中 getNextPageParam / getPreviousPageParam 接收两个参数:lastPage(或 firstPage)与 pages(全部已加载页),返回值既决定"是否还有页"(非 null/undefined 即有),又作为下一次 queryFn 的 pageParam。仓库示例 example.component.html 中,"Load Older" 按钮绑定 query.fetchPreviousPage(),禁用与文案逻辑与"Load newer"完全对称(previousButtonDisabled / previousButtonText)。
用 select 反转页面顺序
某些 UI(例如"新消息在上"的列表)希望展示顺序与加载顺序相反。此时不必改动抓取逻辑,用 select 选项同时反转 pages 与 pageParams 即可:
query = injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
}))
注意 pages 和 pageParams 必须同时反转,保持两者的下标对齐关系不变。
手动更新无限查询的缓存
通过 queryClient.setQueryData(['projects'], updater) 手动改缓存时,updater 必须维持 { pages, pageParams } 结构,且两者长度始终一致。常见操作有三种:
只删掉第一页:
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
从某页中删除单条数据:
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
只保留第一页:
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
务必保持 pages 与 pageParams 的数据结构一致,否则后续 getNextPageParam / getPreviousPageParam 拿到错位参数,分页状态会悄悄损坏。
限制缓存页数:maxPages
当用户可能加载很多页(内存占用增长)、或需要刷新一个已含几十页的无限查询(所有页会串行重取,网络开销大)时,可以启用"受限无限查询":用 maxPages 选项与 getNextPageParam / getPreviousPageParam 配合,在双向加载时按需裁剪缓存中保留的页数。
injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
}))
上例中缓存的 pages 数组最多保留 3 页:加载第 4 页时第 1 页会被裁掉;若需要刷新,也只会串行重取这 3 页。指南文档的主示例与仓库示例工程 examples/angular/infinite-query-with-max-pages(见 example.component.ts)都设置了 maxPages: 3,可以直接本地运行观察裁剪与刷新行为。
API 不返回游标时:用 pageParam 自算
如果后端不提供游标字段,可以用 pageParam 本身作为"游标"——因为 getNextPageParam 与 getPreviousPageParam 的第三个参数就是当前页的 pageParam,可以基于它推算下一/上一页参数:
injectInfiniteQuery(() => ({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
if (firstPageParam <= 1) {
return undefined
}
return firstPageParam - 1
},
}))
注意此处 initialPageParam: 0 而 getPreviousPageParam 的边界判断是 firstPageParam <= 1,即页码约定从 1 开始计数、初始值 0 仅作为占位起点——按自己的 API 页码约定调整边界即可。"返回 undefined 即停止"的约定在所有场景中一致:这是 hasNextPage / hasPreviousPage 信号唯一的判定来源。
复用选项:infiniteQueryOptions 与可运行示例
@tanstack/angular-query-experimental 还导出 infiniteQueryOptions,用于以类型安全的方式把无限查询选项提取为可复用、可共享的对象(例如放在 Service 中);它会将 queryKey 打上来自 queryFn 的数据类型标签,使后续 setQueryData 等操作获得精确类型。指南中的组件式写法则直接使用 injectInfiniteQuery(() => ({...}))。
更多可运行参考:
- 无限查询 +
maxPages(本文主示例,Angular 20 + 信号控制流):examples/angular/infinite-query-with-max-pages; - 普通分页(非无限查询)对照:examples/angular/pagination;
- 依赖 RxJS 的查询写法:examples/angular/rxjs;
- 框架层其他指南:Angular 总览、快速开始、TypeScript。
小结
| 能力 | 关键选项 / 信号 | 说明 |
|---|---|---|
| 数据形态 | data.pages / data.pageParams |
页与参数一一对应,手动改缓存时须保持同构 |
| 起点 | initialPageParam |
必填,作为首次 queryFn 的 pageParam |
| 单向加载 | getNextPageParam + fetchNextPage + hasNextPage |
返回非 null/undefined 即"还有页" |
| 双向加载 | getPreviousPageParam + fetchPreviousPage + hasPreviousPage |
逻辑与向前方向对称 |
| 加载状态 | isFetchingNextPage / isFetchingPreviousPage |
与后台刷新 isFetching 区分 |
| 并发保护 | isFetching 判空,或 fetchNextPage({ cancelRefetch: false }) |
防止两次抓取互相覆盖 |
| 顺序展示 | select 反转 pages + pageParams |
仅改视图,不影响抓取 |
| 内存/刷新成本 | maxPages |
限制缓存保留页数,刷新只串行重取该页数 |
| 无游标 API | 第三个参数 pageParam 自算 |
结合空页/边界返回 undefined |
injectInfiniteQuery 把 React 版 useInfiniteQuery 的分页状态机完整移植到了 Angular 的 DI + 信号体系之上:抓取、状态更新经由 InfiniteQueryObserver 与信号代理(create-base-query.ts)驱动,组件代码只需声明选项、读取信号、调用加载方法。按本文示例起步,再对照仓库中的 examples/angular/infinite-query-with-max-pages 工程调参,即可覆盖绝大多数列表分页场景。
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 StartedRust0623
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