TanStack Query Angular 实战:用 initialData 为 injectQuery 预置初始数据,跳过首屏 Loading
在 Angular 中使用 TanStack Query(@tanstack/angular-query 的 injectQuery)时,"查询挂上就立刻转圈"是常见的首屏体验问题。本文围绕 Angular 框架文档 initial-query-data 展开,系统讲解如何通过 initialData、staleTime、initialDataUpdatedAt 三个配置项,为查询预置初始数据、精确控制"何时重新拉取",并进一步演示懒求值的 initialData 函数、从缓存派生初始数据、以及按数据新鲜度做条件预置等进阶用法。读完本文,你能在 Angular 服务或组件中完整落地"有缓存就秒出、过期才回源"的数据初始化方案,并理解其背后 @tanstack/query-core 的状态初始化与陈旧度判定机制。
给查询预先准备数据的三种途径
TanStack Query 提供了"声明式"与"命令式"两类提前备数据的思路:
- 声明式:在
injectQuery的配置中直接提供initialData,当该 key 在缓存中为空时用它预填充缓存,从而跳过初始 loading 状态; - 命令式:通过
queryClient的 API 在需要之前手动操作缓存——用queryClient.query预取数据,或用queryClient.setQueryData直接把数据放入缓存。相关 API 详见 QueryClient 参考文档。
声明式的 initialData 适合"应用里已经持有这份数据"的场景(例如路由状态、父查询结果、SSR 注入等)。
用 initialData 预置数据并跳过 loading 状态
当你的应用已经拥有某份数据时,可以直接把它交给 initialData,查询创建后不会进入 loading 状态,result.data() 立即可用:
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
}))
在 Angular 中,这段代码写在服务或组件实例内即可执行(injectQuery 依赖注入上下文,参见 inject-query.ts)。一个值得注意的类型层细节:injectQuery 针对提供了 initialData 的重载会返回 DefinedCreateQueryResult<TData, TError>,而非普通的 CreateQueryResult(见 inject-query.ts#L65-L78)。也就是说,一旦声明了 initialData,TypeScript 会推断出"数据一定存在",data 信号的类型不再是可空——这正是"跳过初始 loading"在类型系统上的体现。
重要:
initialData会被持久化写入缓存。因此不推荐用它塞入占位符、部分数据或不完整数据;如果只是想在加载期间显示占位内容,应使用 placeholderQueryData 方案。
staleTime 与 initialDataUpdatedAt:决定预置数据"多新鲜"
initialData 默认被视为"刚刚拉取的最新数据",因此它会直接影响 staleTime 的判定。围绕这一点,有三种典型配置:
配置一:只有 initialData,没有 staleTime。 默认 staleTime: 0 意味着数据立即可判定为陈旧,因此组件或服务实例一旦创建,查询会立即重新拉取——预置数据只是"先展示一帧":
// Will show initialTodos immediately, but also immediately refetch todos
// when an instance of the component or service is created
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
}))
配置二:加上 staleTime。 数据会被视为"新鲜"同样长的时间,仿佛是刚刚从 queryFn 拿到的。例如 staleTime: 1000 时,1 秒之内即使重新挂载也不会触发 refetch,只有遇到交互事件(窗口聚焦、网络恢复、组件重新挂载等)且数据已过新鲜期才会重新拉取:
// Show initialTodos immediately, but won't refetch until
// another interaction event is encountered after 1000 ms
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
staleTime: 1000,
}))
配置三:initialDataUpdatedAt 精确标注数据时间戳。 如果你的 initialData 其实并不新鲜,staleTime alone 就不够准确——这时应传入 initialDataUpdatedAt:一个毫秒级 JS 时间戳(Date.now() 同款),标明这份初始数据本身的最后更新时间。注意如果你的数据源给的是 Unix 秒级时间戳,需要乘以 1000 转换:
// Show initialTodos immediately, but won't refetch until
// another interaction event is encountered after 1000 ms
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
staleTime: 60 * 1000, // 1 minute
// This could be 10 seconds ago or 10 minutes ago
initialDataUpdatedAt: initialTodosUpdatedTimestamp, // eg. 1608412420052
}))
这个选项让 staleTime 回归本职——定义"数据需要多新"——同时让查询自行决定:如果 initialData 的时间戳已经比 staleTime 更老,挂载时就会直接 refetch。上面的例子中,数据必须在 1 分钟内是新鲜的;若初始数据是 10 分钟前更新的,查询会判定其过期并立即回源。
如果你希望把数据当预取数据处理(而不是初始数据),更推荐先用 queryClient 的查询 API 把缓存填好,这样 staleTime 的语义就与 initialData 解耦了。
源码视角:dataUpdatedAt 是怎么初始化的
从源码结构看,上述行为的实现集中在 @tanstack/query-core 的状态构造函数 getDefaultState 中(query.ts#L745-L780):
const data =
typeof options.initialData === 'function'
? (options.initialData as InitialDataFunction<TData>)()
: options.initialData
const hasData = data !== undefined
const initialDataUpdatedAt = hasData
? typeof options.initialDataUpdatedAt === 'function'
? options.initialDataUpdatedAt()
: options.initialDataUpdatedAt
: 0
return {
data,
dataUpdatedAt: hasData ? (initialDataUpdatedAt ?? Date.now()) : 0,
...
status: hasData ? 'success' : 'pending',
fetchStatus: 'idle',
}
可以印证两点:其一,有 initialData 时初始 status 直接是 'success'、dataUpdatedAt 默认取 Date.now()(即"视为刚刚获取"),所以 staleTime: 0 时挂载即 refetch;其二,提供了 initialDataUpdatedAt 时,它会被写入 dataUpdatedAt。而陈旧度判定 isStaleByTime 正是基于该时间戳计算(query.ts#L314-L330):return !timeUntilStale(this.state.dataUpdatedAt, staleTime)——dataUpdatedAt 距今超过 staleTime 即为陈旧,查询随即在下次观察时回源。相关类型定义见 types.ts#L259-L260:initialData 可以是值或函数,initialDataUpdatedAt 可以是数字或返回数字的函数。
用函数形式的 initialData 做懒计算
如果获取初始数据的开销较大(读取大缓存、解析存储、做复杂查找),又不想在每次响应式执行时重复付出代价,可以把 initialData 写成函数。该函数只在查询初始化、创建默认状态时执行一次(即上面源码中 getDefaultState 的调用时机),从而节省内存与 CPU:
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: () => getExpensiveTodos(),
}))
从其他查询的缓存派生初始数据
一个常见场景:你已有 ['todos'] 列表查询的缓存,现在要打开某个 todo 的详情查询。与其让详情查询从零开始加载,可以直接从列表缓存中找出那条记录作为详情查询的初始数据:
result = injectQuery(() => ({
queryKey: ['todo', this.todoId()],
queryFn: () => fetch('/todos'),
initialData: () => {
// Use a todo from the 'todos' query as the initial data for this todo query
return this.queryClient
.getQueryData(['todos'])
?.find((d) => d.id === this.todoId())
},
}))
带上 initialDataUpdatedAt 的缓存派生
从缓存派生数据时,源查询的数据往往已经偏旧。此时不建议用人为拉高的 staleTime 来"骗"过立即 refetch,而是把源查询的 dataUpdatedAt 原样传给 initialDataUpdatedAt,让详情查询基于真实的新鲜度自行判断是否回源:
result = injectQuery(() => ({
queryKey: ['todos', this.todoId()],
queryFn: () => fetch(`/todos/${this.todoId()}`),
initialData: () =>
queryClient.getQueryData(['todos'])?.find((d) => d.id === this.todoId()),
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
}))
条件式初始数据:旧到一定程度就不用缓存了
如果源查询的数据实在太旧,你宁愿干脆放弃缓存、从"硬加载"状态开始拉取。这时改用 queryClient.getQueryState 拿到完整的查询状态,用 state.dataUpdatedAt 做新鲜度门槛判断:
result = injectQuery(() => ({
queryKey: ['todo', this.todoId()],
queryFn: () => fetch(`/todos/${this.todoId()}`),
initialData: () => {
// Get the query state
const state = queryClient.getQueryState(['todos'])
// If the query exists and has data that is no older than 10 seconds...
if (state && Date.now() - state.dataUpdatedAt <= 10 * 1000) {
// return the individual todo
return state.data.find((d) => d.id === this.todoId())
}
// Otherwise, return undefined and let it fetch from a hard loading state!
},
}))
注意这里返回 undefined 是刻意为之:initialData 求值结果为 undefined 时,getDefaultState 会走 hasData === false 分支,状态回到 'pending',查询进入正常的加载流程(参见上文 query.ts#L758 附近的 hasData 逻辑)。
Angular 集成要点与验证依据
injectQuery的实现在 inject-query.ts:它通过runInInjectionContext在给定(或当前)注入上下文中创建QueryObserver,因此示例中this.todoId()、this.queryClient这类依赖实例状态的写法在信号化的 Angular 应用中天然成立;injectQuery传入的选项函数运行在响应式上下文中(文档注释中说明其类似computed),queryKey、enabled等都可以引用信号;initialData的求值则只发生在查询初始化时,两者时机不同,写懒函数时不必担心被响应式重算反复触发;- 提供的选项类型
DefinedInitialDataOptions/UndefinedInitialDataOptions定义在 query-options.ts,分别对应"有初始数据(返回 Defined 结果)"和"无初始数据(返回普通结果)"两种重载; - 行为验证可参考测试文件 inject-query.test.ts 中围绕
initialData的用例,以及 query-options.test-d.ts 中对initialData类型的编译期断言。
小结
| 场景 | 配置要点 | 效果 |
|---|---|---|
| 跳过首屏 loading | initialData |
立即展示数据,status 直接为 success |
| 立即展示但挂载即回源 | 仅 initialData(staleTime 默认 0) |
显示一帧后立即 refetch |
| 展示并延迟回源 | initialData + staleTime: 1000 |
数据视为刚拉取,1 秒内不 refetch |
| 数据本身可能偏旧 | initialData + staleTime + initialDataUpdatedAt |
按真实时间戳判定新鲜度,过旧则挂载即回源 |
| 初始数据获取昂贵 | initialData: () => ... |
仅初始化时求值一次 |
| 用其他查询的缓存派生 | getQueryData / getQueryState + initialDataUpdatedAt |
继承源查询的新鲜度,可加新鲜度门槛做条件预置 |
再次强调核心原则:initialData 会持久化进缓存,只应放真实、完整的数据;占位展示请用 placeholderData(见 placeholder-query-data)。掌握以上配置组合,你就能在 Angular 应用中把"初始数据 + 陈旧度"的控制权完全握在自己手里。
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