首页
/ TanStack Query Angular 无限查询实战:injectInfiniteQuery、maxPages 与双向分页

TanStack Query Angular 无限查询实战:injectInfiniteQuery、maxPages 与双向分页

2026-09-05 20:37:54作者:吴年前Myrtle

在 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 → injectQueryuseInfiniteQuery → injectInfiniteQuery):

  • 返回的 data 不再是单份数据,而是无限查询数据结构 InfiniteData
    • data.pages:已加载页面的数组;
    • data.pageParams:与页面一一对应的分页参数数组。
  • 可用 fetchNextPagefetchPreviousPage 方法加载下一页/上一页(fetchNextPage 为单向场景的必需项)。
  • 必须提供 initialPageParam,指定第一页使用的分页参数;queryFn 的入参会以 { pageParam } 的形式收到它。
  • getNextPageParam / getPreviousPageParam 选项用于"判断是否还有更多数据"并"计算下一/上一页的参数"。
  • hasNextPage 信号为 true,当且仅当 getNextPageParam 返回的值既不是 null 也不是 undefinedhasPreviousPage 同理。
  • isFetchingNextPageisFetchingPreviousPage 信号可用来区分"后台刷新"与"加载更多"两种抓取状态。

注意:initialDataplaceholderData 选项(如有提供)必须符合 { pages, pageParams } 的数据结构。

在 Angular 实现中,这些状态不是响应式属性而是信号:模板里通过 query.isPending()query.data()query.hasNextPage() 的方式读取,组件内则可以用 computed 组合它们。从源码看,inject-infinite-query.ts 内部将 InfiniteQueryObserver(来自 @tanstack/query-core)交给 createBaseQuery 构建,最终结果经由 signalProxy 包装为信号集合返回(见 create-base-query.ts),这就是为什么 dataerrorstatus 都是"可调用"的形式。

基础示例:用游标实现 "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" 界面的思路是:

  1. injectInfiniteQuery 默认请求第一组数据(initialPageParam 指定的第一页);
  2. getNextPageParam 中返回下一组的游标;
  3. 点击按钮时调用 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 / hasNextPagefalse
  • 按钮文案与禁用状态由 computedisFetchingNextPagehasNextPage 两个信号派生,避免在模板中堆砌逻辑。

仓库中有一个与上面几乎一致的完整可运行工程 examples/angular/infinite-query-with-max-pages(每页 4 条、最多 3 页、支持向前/向后加载),其组件实现在 example.component.ts,其中 maxPages: 3 即下文"限制页数"一节的用法;服务层 projects.service.ts 展示了 HttpClientlastValueFrom 的配合方式,模拟接口 projects-mock.interceptor.ts 则返回 nextId / previousId 作为游标(当 nextIdnull 时触发 ?? undefined,列表即停止)。该示例基于 Angular 20 + @tanstack/angular-query-experimental,运行方式为 pnpm installpnpm 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 方法与 hasPreviousPageisFetchingPreviousPage 信号:

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 即有),又作为下一次 queryFnpageParam。仓库示例 example.component.html 中,"Load Older" 按钮绑定 query.fetchPreviousPage(),禁用与文案逻辑与"Load newer"完全对称(previousButtonDisabled / previousButtonText)。

用 select 反转页面顺序

某些 UI(例如"新消息在上"的列表)希望展示顺序与加载顺序相反。此时不必改动抓取逻辑,用 select 选项同时反转 pagespageParams 即可:

query = injectInfiniteQuery(() => ({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
}))

注意 pagespageParams 必须同时反转,保持两者的下标对齐关系不变。

手动更新无限查询的缓存

通过 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),
}))

务必保持 pagespageParams 的数据结构一致,否则后续 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 本身作为"游标"——因为 getNextPageParamgetPreviousPageParam 的第三个参数就是当前页的 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: 0getPreviousPageParam 的边界判断是 firstPageParam <= 1,即页码约定从 1 开始计数、初始值 0 仅作为占位起点——按自己的 API 页码约定调整边界即可。"返回 undefined 即停止"的约定在所有场景中一致:这是 hasNextPage / hasPreviousPage 信号唯一的判定来源。

复用选项:infiniteQueryOptions 与可运行示例

@tanstack/angular-query-experimental 还导出 infiniteQueryOptions,用于以类型安全的方式把无限查询选项提取为可复用、可共享的对象(例如放在 Service 中);它会将 queryKey 打上来自 queryFn 的数据类型标签,使后续 setQueryData 等操作获得精确类型。指南中的组件式写法则直接使用 injectInfiniteQuery(() => ({...}))

更多可运行参考:

小结

能力 关键选项 / 信号 说明
数据形态 data.pages / data.pageParams 页与参数一一对应,手动改缓存时须保持同构
起点 initialPageParam 必填,作为首次 queryFnpageParam
单向加载 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 工程调参,即可覆盖绝大多数列表分页场景。

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