TanStack Query Angular 无限查询实战:injectInfiniteQuery、maxPages 与双向分页
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
在 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返回 RxJSObservable时,用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 工程调参,即可覆盖绝大多数列表分页场景。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考