TanStack Query Angular 实战:用 placeholderData 让查询在数据到达前即刻渲染
在 Angular 中使用 injectQuery 时,查询初始处于 pending 状态,界面往往只能显示加载指示器。placeholderData 选项允许查询在真实数据尚未取回前就表现得像"已有数据"——它类似 initialData,但不会被持久化到缓存,非常适合拥有部分数据(甚至模拟数据)先行渲染、真实数据在后台继续拉取的场景。读完本篇,你将掌握 Angular 中三种占位数据写法(静态值、记忆化值、函数式、从缓存派生)的完整用法与底层状态机原理,并知道如何用 isPlaceholderData 标志区分占位数据与真实数据。
什么是占位数据
占位数据的核心价值在于:让查询跳过 pending 状态、直接以 success 状态启动,因为此时查询已经有可以展示的 data——哪怕这只是"占位"数据。为了区分占位数据与真实数据,查询结果上会额外携带 isPlaceholderData: true 标志。
一个典型场景来自官方文档的说明:单篇博客文章的查询,可以从"博客列表"父查询中取出只包含标题和正文片段的"预览版"数据,作为详情页查询的占位数据。你不会希望把这份不完整的数据持久化到详情页查询的结果里,但它可以让内容布局尽可能快地上屏,而完整对象仍在后台获取。
在 TanStack Query 中,有两种方式为查询提供占位数据:
- 声明式:在查询配置中直接传入
placeholderData,当缓存为空时预填充查询结果; - 命令式:通过
queryClient使用placeholderData选项进行预取(Prefetch)。
从源码看,占位逻辑集中在核心包的 QueryObserver 中:只有当 options.placeholderData !== undefined、当前 data === undefined 且 status === 'pending' 三个条件同时满足时,占位数据才会被应用;一旦应用,状态会被改写为 success,并设置 isPlaceholderData = true。这解释了为什么占位查询不会触发 isLoading 分支。
占位数据作为静态值
最简单的用法是直接把一个已有数据传给 placeholderData:
class TodosComponent {
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
placeholderData: placeholderTodos,
}))
}
查询挂载后立即以 placeholderTodos 渲染,queryFn 在后台执行;一旦解析完成,data 被替换为真实数据,isPlaceholderData 变为 false。仓库中的测试用例 inject-query.test.ts 完整验证了这一生命周期:
it('should show placeholderData until queryFn resolves and then expose real data', async () => {
// ...
readonly query = injectQuery(() => ({
queryKey: key,
queryFn: () => sleep(10).then(() => 'real-data'),
placeholderData: 'placeholder',
}))
// 断言:初始 data: placeholder、isPlaceholderData: true、isSuccess: true
// 等待 queryFn 解析后:data: real-data、isPlaceholderData: false
})
这证实了文档中"占位数据期间查询处于 success 状态"的描述在 Angular 适配器中同样成立。
占位数据的记忆化
如果生成占位数据的过程开销较大(例如构造大量模拟对象),不想每次渲染都重新执行,可以在组件中先把值计算一次再传入。Angular 中没有 React 的 useMemo,但从源码结构看,injectQuery 的回调运行在与 computed 类似的响应式上下文中,因此可以使用信号来缓存一次性计算结果:
class TodosComponent {
// 仅在信号首次被读取时生成一次
readonly placeholder = computed(() => generateFakeTodos())
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
placeholderData: this.placeholder(),
}))
}
更进一步,核心层本身就内置了占位数据的记忆化机制:在 queryObserver.ts 中,若上一次结果也是占位数据(prevResult?.isPlaceholderData)且 placeholderData 选项引用未变,则直接复用 prevResult.data 并跳过 select 重新执行,避免对同一份占位数据重复计算。
占位数据作为函数
placeholderData 还可以是函数形式,可以接收"上一个"成功查询的数据与查询元信息:
class TodosComponent {
result = injectQuery(() => ({
queryKey: ['todos', id()],
queryFn: () => fetch(`/todos/${id}`),
placeholderData: (previousData, previousQuery) => previousData,
}))
}
这里的 id 是一个信号,读取它的当前值作为查询键的一部分。这个模式在 queryKey 发生变化(例如从 ['todos', 1] 变为 ['todos', 2])时尤为有用:新 key 没有缓存数据,但函数式占位数据可以返回旧 key 的 previousData,从而在数据"过渡期"继续展示旧内容,而不是显示加载转圈。这正是 分页查询指南 中翻页过渡的常用技巧。
函数两个参数的来源可以在 queryObserver.ts 中确认:核心会调用 placeholderData(this.#lastQueryWithDefinedData?.state.data, this.#lastQueryWithDefinedData),即把"最近一个有数据的查询"的 state.data 和查询实例本身传入。
注意:
previousData来自上一个有数据的查询,如果应用里同时存在多个查询,务必确认该函数被调用时"上一个查询"确实是你要复用的那个,否则会拿到意料之外的数据。
从缓存派生占位数据
在某些场景下,可以直接从另一个已缓存查询的结果中查找、派生出占位数据。典型例子是"博客列表 → 博客详情":列表查询只缓存每篇文章的标题和摘要(预览版),详情页查询则可以用它作为占位数据:
export class BlogPostComponent {
postId = input.required<number>()
queryClient = inject(QueryClient)
result = injectQuery(() => ({
queryKey: ['blogPost', this.postId()],
queryFn: () => fetch(`/blogPosts/${this.postId()}`),
placeholderData: () => {
// 使用 'blogPosts' 查询中较小的/预览版 blogPost
// 作为本 blogPost 查询的占位数据
return this.queryClient
.getQueryData(['blogPosts'])
?.find((d) => d.id === this.postId())
},
}))
}
实现要点:
- 通过
inject(QueryClient)注入客户端实例(可参考 queryOptions 指南 与 查询指南 了解QueryClient的提供方式); queryClient.getQueryData(['blogPosts'])按 key 同步读取缓存,若列表查询尚未完成则返回undefined,函数式占位数据返回undefined时查询回落到普通pending状态,不会报错;postId用input.required<number>()声明并作为信号读取,保证路由参数变化时查询自动重建。
占位数据与 initialData 的边界
两者的关键差异在于缓存持久化:
initialData会写入缓存并参与缓存生命周期(可被其他观察者复用、被持久化插件持久化);placeholderData只影响当前观察者(observer 级选项,参见 query-core 类型定义),真实数据取回后即被替换,不会污染缓存。
因此在"临时展示用、但不想让半截数据冒充缓存结果"的场景(如本文的列表预览 → 详情全文),应优先选择 placeholderData。
小结
placeholderData使 Angular 中的injectQuery跳过pending,直接以success+isPlaceholderData: true启动,让界面即刻可渲染;- 静态值适合固定的骨架数据;函数形式
(previousData, previousQuery)适合 queryKey 切换时平滑过渡; - 借助
queryClient.getQueryData可以从缓存中的"兄弟查询"派生预览数据; - 核心层对占位数据有内置记忆化(queryObserver.ts),且
placeholderData是观察者级选项、不进入缓存; - 模板中可用
query.isPlaceholderData()区分占位与真实数据,据此控制骨架屏或"内容加载中"的视觉提示。
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