TanStack Query Angular 分页查询实战:injectQuery + keepPreviousData 实现不闪跳的分页与预取
本篇基于 Angular 框架下的「Paginated / Lagged Queries」官方指南,讲清分页查询在 TanStack Query 中的标准解法:把页码写入 queryKey 让每一页成为独立缓存条目,再用 placeholderData: keepPreviousData 消除页码切换时 UI 在 pending 与 success 之间的闪跳,并借助 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 会在 success 和 pending 状态之间来回跳动,因为每一个新页码都被当作一个全新查询来处理。
这个体验并不理想,可惜许多工具至今仍坚持这样工作。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))
}
}
逐段拆解这个组件的关键设计:
queryKey: ['projects', this.page()]:页码变化触发新查询,而每一页(含其hasMore标记)都成为独立缓存条目;placeholderData: keepPreviousData:翻到新一页的瞬间,UI 继续展示上一页数据而不是进入pending,因此@if (query.status() === 'pending')只在真正的冷启动时出现;- Next Page 按钮的
[disabled]条件:query.isPlaceholderData() || !query.data()?.hasMore—— 在展示的是占位数据(还不知道新一页有没有后续页)或服务端明确返回hasMore: false时,按钮都保持禁用; isFetching()背景加载指示:由于旧页数据在页间切换后依然驻留,status不会变成'pending',所以用isFetching来表达"后台正在拉取"这一更细腻的状态;staleTime: 5000:数据在 5 秒内被视为新鲜,短时间内来回翻页不会触发后台重取(回退旧页时表现为"瞬时展示 + 视情况后台静默刷新")。
源码级印证:keepPreviousData 的实现与语义
keepPreviousData 定义在核心包中,实现非常简洁——直接原样返回上一次的数据,从而保证查询切换瞬间 data 非空且可渲染:
export function keepPreviousData<T>(/* ... */): T
- 实现位置:utils.ts,并从 核心包入口 导出;
- 行为由单测锁定:utils.test.tsx 中
describe('keepPreviousData')断言expect(keepPreviousData(x)).toBe(x),即对传入的既有数据做同一性透传(x为undefined时返回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 }),queryFn 用 lastValueFrom 桥接为 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 版文档经 useInfiniteQuery → injectInfiniteQuery 的映射生成,语义一致):当无限查询的查询键随时间变化(例如筛选条件、会话切换)时,可以借助 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.query在effect中预取下一页,把"点击 → 等待"变成"点击 → 命中"; - 该模式同样适用于
injectInfiniteQuery换键时的缓存延续。
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 StartedRust0624
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