首页
/ TanStack Query Angular 分页查询实战:injectQuery + keepPreviousData 实现不闪跳的分页与预取

TanStack Query Angular 分页查询实战:injectQuery + keepPreviousData 实现不闪跳的分页与预取

2026-09-06 15:58:48作者:魏献源Searcher

本篇基于 Angular 框架下的「Paginated / Lagged Queries」官方指南,讲清分页查询在 TanStack Query 中的标准解法:把页码写入 queryKey 让每一页成为独立缓存条目,再用 placeholderData: keepPreviousData 消除页码切换时 UI 在 pendingsuccess 之间的闪跳,并借助 queryClient.query 预取下一页。读完你可以直接在 Angular 项目(或仓库自带示例)中复制出一套带背景加载指示、按钮联动、下一页预取的完整分页交互。

核心思路:页码进 queryKey,每页就是一个独立查询

渲染分页数据是非常常见的 UI 模式。在 TanStack Query 中它"开箱即用"——只要把页码信息放进查询键,框架就会为每一页维护一个独立的缓存条目:

const result = injectQuery(() => ({
  queryKey: ['projects', page()],
  queryFn: fetchProjects,
}))

这里 page 是一个 Angular signal,page() 的取值会参与生成查询键。页码变化时 injectQuery 自动切换到对应页的查询,而旧页的数据不会被丢弃,仍在 QueryCache 中,切回前页时可瞬时命中缓存。

朴素写法的缺陷:UI 在 pending 和 success 之间跳动

直接运行上面的简单示例,你会注意到一个奇怪的现象:UI 会在 successpending 状态之间来回跳动,因为每一个新页码都被当作一个全新查询来处理

这个体验并不理想,可惜许多工具至今仍坚持这样工作。TanStack Query 提供了 placeholderData 选项来优雅地绕过它。

用 placeholderData 与 keepPreviousData 实现平滑分页

考虑一个典型场景:随着用户点击"下一页",pageIndex(或游标 cursor)不断递增。如果只用 injectQuery,功能上"技术上仍然能正常工作",但每当创建/切换不同的页查询时,UI 都会跳入 pending 状态。把 placeholderData 设置为 (previousData) => previousData,或使用框架导出的 keepPreviousData 函数,可以额外获得三个能力:

  • 在新数据请求期间,上一次成功获取的数据依然可用,尽管查询键已经变化
  • 新数据到达时,之前的 data 会被无缝替换为新数据;
  • isPlaceholderData 信号可用来判断当前查询返回的到底是"本应属于当前页的数据"还是"占位用的上一页数据"。

在 Angular 中,injectQuery 的返回结果是 signal 化的(query.data()query.status()query.isFetching() 等),因此整个模式与 Angular 的响应式体系天然贴合:

query = injectQuery(() => ({
  queryKey: ['projects', this.page()],
  queryFn: () => lastValueFrom(fetchProjects(this.page())),
  placeholderData: keepPreviousData,
  staleTime: 5000,
}))

queryFn 需要返回 Promise。由于 Angular 的 HttpClient 返回的是 Observable,示例中用 RxJS 的 lastValueFrom 将其转换为 Promise 交给 TanStack Query 处理。

完整示例:带预取的分页组件

下面是指南给出的完整分页组件(内联模板版本),展示了"每页数据在下一页加载期间保持可见、按钮在游标未知时被抑制、回退到旧页时瞬时命中缓存并在后台静默重新拉取"的完整交互:

@Component({
  selector: 'pagination-example',
  template: `
    <div>
      <p>
        In this example, each page of data remains visible as the next page is
        fetched. The buttons and capability to proceed to the next page are also
        suppressed until the next page cursor is known. Each page is cached as a
        normal query too, so when going to previous pages, you'll see them
        instantaneously while they are also re-fetched invisibly in the
        background.
      </p>
      @if (query.status() === 'pending') {
        <div>Loading...</div>
      } @else if (query.status() === 'error') {
        <div>Error: {{ query.error().message }}</div>
      } @else {
        <!-- 'data' will either resolve to the latest page's data -->
        <!-- or if fetching a new page, the last successful page's data -->
        <div>
          @for (project of query.data().projects; track project.id) {
            <p>{{ project.name }}</p>
          }
        </div>
      }

      <div>Current Page: {{ page() + 1 }}</div>
      <button (click)="previousPage()" [disabled]="page() === 0">
        Previous Page
      </button>
      <button
        (click)="nextPage()"
        [disabled]="query.isPlaceholderData() || !query.data()?.hasMore"
      >
        Next Page
      </button>
      <!-- Since the last page's data potentially sticks around between page requests, -->
      <!-- we can use 'isFetching' to show a background loading -->
      <!-- indicator since our status === 'pending' state won't be triggered -->
      @if (query.isFetching()) {
        <span> Loading...</span>
      }
    </div>
  `,
})
export class PaginationExampleComponent {
  page = signal(0)
  #queryClient = inject(QueryClient)

  query = injectQuery(() => ({
    queryKey: ['projects', this.page()],
    queryFn: () => lastValueFrom(fetchProjects(this.page())),
    placeholderData: keepPreviousData,
    staleTime: 5000,
  }))

  constructor() {
    effect(() => {
      // Prefetch the next page!
      if (!this.query.isPlaceholderData() && this.query.data()?.hasMore) {
        void this.#queryClient
          .query({
            queryKey: ['projects', this.page() + 1],
            queryFn: () => lastValueFrom(fetchProjects(this.page() + 1)),
          })
          .catch(noop)
      }
    })
  }

  previousPage() {
    this.page.update((old) => Math.max(old - 1, 0))
  }

  nextPage() {
    this.page.update((old) => (this.query.data()?.hasMore ? old + 1 : old))
  }
}

逐段拆解这个组件的关键设计:

  1. queryKey: ['projects', this.page()]:页码变化触发新查询,而每一页(含其 hasMore 标记)都成为独立缓存条目;
  2. placeholderData: keepPreviousData:翻到新一页的瞬间,UI 继续展示上一页数据而不是进入 pending,因此 @if (query.status() === 'pending') 只在真正的冷启动时出现;
  3. Next Page 按钮的 [disabled] 条件query.isPlaceholderData() || !query.data()?.hasMore —— 在展示的是占位数据(还不知道新一页有没有后续页)或服务端明确返回 hasMore: false 时,按钮都保持禁用;
  4. isFetching() 背景加载指示:由于旧页数据在页间切换后依然驻留,status 不会变成 'pending',所以用 isFetching 来表达"后台正在拉取"这一更细腻的状态;
  5. staleTime: 5000:数据在 5 秒内被视为新鲜,短时间内来回翻页不会触发后台重取(回退旧页时表现为"瞬时展示 + 视情况后台静默刷新")。

源码级印证:keepPreviousData 的实现与语义

keepPreviousData 定义在核心包中,实现非常简洁——直接原样返回上一次的数据,从而保证查询切换瞬间 data 非空且可渲染:

export function keepPreviousData<T>(/* ... */): T
  • 实现位置:utils.ts,并从 核心包入口 导出;
  • 行为由单测锁定:utils.test.tsxdescribe('keepPreviousData') 断言 expect(keepPreviousData(x)).toBe(x),即对传入的既有数据做同一性透传(xundefined 时返回 undefined,此时查询会正常进入 pending)。

由于 Angular 适配层直接复用核心包的这一导出,行为与 React/Solid 等框架完全一致,指南中"placeholderData 同样适用于无限查询"的结论也无需额外验证。

仓库示例工程:examples/angular/pagination

仓库内置了与本指南一一对应的可运行示例,适合逐文件对照阅读与本地验证:

文件 作用
example.component.ts 分页组件:injectQuery + keepPreviousData + effect 预取
example.component.html 模板:状态分支渲染、上一页/下一页按钮、isFetching 背景加载
projects.service.ts 通过 HttpClient 请求 /api/projects?page=${page}ProjectsService
projects-mock.interceptor.ts 拦截 /api/projects,本地伪造分页数据
app.config.ts provideTanStackQuery(new QueryClient(), withDevtools()) 提供 QueryClient

组件中的查询定义与指南一致,并把 queryFn 委托给了服务层:

readonly query = injectQuery(() => ({
  queryKey: ['projects', this.page()],
  queryFn: () => {
    return lastValueFrom(this.projectsService.getProjects(this.page()))
  },
  placeholderData: keepPreviousData,
  staleTime: 5000,
}))

ProjectsService 使用 Angular HttpClient 返回 Observable<ProjectResponse>ProjectResponse 形如 { projects: Array<Project>, hasMore: boolean }),queryFnlastValueFrom 桥接为 Promise——这是 Angular 侧使用 TanStack Query 的典型写法。

mock 拦截器完整模拟了分页后端的关键语义,方便离线复现指南中的交互:

const pageSize = 10
// 每页 10 条,id 从 page * pageSize + 1 递增
return of(new HttpResponse({
  status: 200,
  body: {
    projects,
    hasMore: page < 9, // 共 10 页,第 9 页(下标)起无下一页
  },
})).pipe(delay(1000)) // 模拟 1s 网络延迟,便于观察 placeholder 与背景加载

应用启动配置见 app.config.ts

providers: [
  provideHttpClient(withInterceptors([projectsMockInterceptor]), withFetch()),
  provideTanStackQuery(new QueryClient(), withDevtools()),
]

示例中的下一页预取细节

与指南的构造函数 effect 版本不同,示例把预取写成了 readonly prefetchEffect = effect(...),并且对副作用部分包了一层 untracked

readonly prefetchEffect = effect(() => {
  const data = this.query.data()
  const isPlaceholderData = this.query.isPlaceholderData()
  const newPage = this.page() + 1

  untracked(() => {
    if (!isPlaceholderData && data?.hasMore) {
      void this.queryClient
        .query({
          queryKey: ['projects', newPage],
          queryFn: () =>
            lastValueFrom(this.projectsService.getProjects(newPage)),
        })
        .catch(noop)
    }
  })
})

预取的触发条件是"当前页是真实数据(非占位)且 hasMore 为真",此时用注入的 QueryClient.query 以"下一页的查询键"发起预取请求,结果直接进入缓存;.catch(noop)noop 同样由 Angular 适配包导出)吞掉预取失败,避免未处理的 Promise 拒绝。从源码结构看,untracked 用于隔离"读取信号"(建立响应式依赖)与"执行副作用"(发起预取):依赖在 untracked 外建立,预取动作本身不再被跟踪,这符合 Angular 对 effect 内副作用的推荐用法。

与 injectInfiniteQuery 的组合:滞后的无限滚动

指南还指出,placeholderData 同样无缝适用于 injectInfiniteQuery(指南由 React 版文档经 useInfiniteQueryinjectInfiniteQuery 的映射生成,语义一致):当无限查询的查询键随时间变化(例如筛选条件、会话切换)时,可以借助 placeholderData 让用户继续看到缓存数据,实现"滞后的无限查询结果",而不是每次换键都回到空白加载态。

运行示例验证

仓库示例(examples/angular/pagination)的运行方式很简单:

pnpm install
pnpm start

启动后点击 Next Page,可以依次观察到:旧页数据在 1 秒网络延迟期间保持可见(keepPreviousData 生效)、isFetching 触发的背景 "Loading..."、下一页因预取而几乎瞬时出现、以及最后一页 hasMore: false 后按钮被禁用;回退 Previous Page 时旧页瞬时命中缓存。

小结

  • 分页查询的最简形态就是把页码放进 queryKey,每页独立缓存、切回即命中;
  • placeholderData: keepPreviousData 消除翻页时的 pending 闪跳,并提供 isPlaceholderData 区分真实数据与占位数据;
  • isFetching 负责"后台加载中"的细腻表达,staleTime 控制旧页的后台静默刷新窗口;
  • 通过 queryClient.queryeffect 中预取下一页,把"点击 → 等待"变成"点击 → 命中";
  • 该模式同样适用于 injectInfiniteQuery 换键时的缓存延续。
登录后查看全文
热门项目推荐
相关项目推荐