TanStack Query Angular 的 DefinedInitialDataOptions 类型解析:从"有初始数据"的类型契约到 injectQuery 结果收窄
导读
DefinedInitialDataOptions 是 TanStack Query Angular 适配层(angular-query-experimental)中描述"在声明时已经携带确定初始数据"的查询配置选项的类型别名。理解它,就能理解为什么 injectQuery 在提供 initialData 后会返回 data 永不包含 undefined 的 DefinedCreateQueryResult,从而在模板与 TS 严格模式中获得免判空的安全访问。本文以该类型别名的完整声明为骨架,结合其定义源码、injectQuery 重载机制以及 query-core 底层状态初始化逻辑,逐层拆解这一类型契约的含义与实战用法。
一个类型,先回答"查询是否有初始数据"
在 query-options.ts 中,Angular 适配层把查询选项在类型层面分成了三种形态:
| 类型别名 | initialData | 语义 |
|---|---|---|
UndefinedInitialDataOptions |
可选(可缺省、可为 undefined) |
查询可能没有任何缓存数据,status 可能为 pending |
UnusedSkipTokenOptions |
不含(queryFn 排除 SkipToken) |
用于确定 queryFn 必然被执行的场景 |
DefinedInitialDataOptions |
必填,且取值不允许为 undefined |
查询在创建时就保证有数据可展示 |
DefinedInitialDataOptions 正是第三种:"定义即确定"。它通过把 initialData 设为必填属性,在编译期向 TypeScript 承诺——该查询从诞生那一刻起就拥有可渲染的数据,后续的 injectQuery 返回值也会相应收窄。
完整类型声明与逐项解读
别名骨架
type DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> = Omit<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryFn'
> & object;
含义拆解:
CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>是 Angular 侧的"标准查询选项"基底。它定义在 types.ts,本质是CreateBaseQueryOptions去除suspense键的结果,而CreateBaseQueryOptions又继承自 query-core 的QueryObserverOptions——也就是说queryKey、queryFn、staleTime、gcTime、enabled、refetchInterval等全部查询选项都来自这里。Omit<..., 'queryFn'>先移除queryFn(因为"依赖纯初始数据即可运行"的场景未必需要真正的请求函数)。& object后的块重新声明了两个受约束的属性(见下)。
initialData(必填)
initialData:
| NonUndefinedGuard<TQueryFnData>
| (() => NonUndefinedGuard<TQueryFnData>);
两个要点:
- 它是必填的(无
?)。对比UndefinedInitialDataOptions中initialData?的可选声明,这正是二者在类型层面的分水岭。 - 取值永不可能是
undefined。NonUndefinedGuard<T>定义在 query-core/src/types.ts:
export type NonUndefinedGuard<T> = T extends undefined ? never : T
即:若泛型 TQueryFnData 恰好推断为 undefined,则整个类型被折叠为 never,赋值直接报错。它既接受一个确定值 TQueryFnData,也接受一个返回该值的惰性函数 () => TQueryFnData。
queryFn?(可选)
optional queryFn: QueryFunction<TQueryFnData, TQueryKey>;
与 UndefinedInitialDataOptions 等一致,queryFn 被保留为可选:当你只想把预置数据当作"瞬时缓存种子"、等后台异步刷新时才需要请求函数;若数据已完整且永远无需联网,queryFn 甚至可以省略。
四个类型参数及默认值
类型别名携带四个泛型参数,含义与 query-core 保持完全一致:
| 类型参数 | 默认值 | 约束 | 说明 |
|---|---|---|---|
TQueryFnData |
unknown |
— | queryFn 原始返回值类型 |
TError |
DefaultError |
— | 错误类型,默认由 query-core 的 Register 接口 决定(通常为 Error) |
TData |
TQueryFnData |
— | 经过 select 变换后真正暴露给 UI 的数据类型 |
TQueryKey |
QueryKey |
extends QueryKey |
查询键类型,默认为只读数组 ReadonlyArray<unknown>,见 types.ts |
关于 DefaultError,其实现通过 Register extends { defaultError: infer TError } 的模块增强(module augmentation)机制取值:开发者可扩展 Register 接口全局替换默认错误类型,未扩展时回退到 Error。
为什么叫 "Defined":injectQuery 重载与返回类型收窄
DefinedInitialDataOptions 的真正价值体现在 inject-query.ts 的同名重载里:
export function injectQuery<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
injectQueryFn: () => DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
options?: InjectQueryOptions,
): DefinedCreateQueryResult<TData, TError>
而另一个重载(inject-query.ts)接受 UndefinedInitialDataOptions,返回的是普通 CreateQueryResult<TData, TError>。
因此,只要你传入的 options 函数返回带必填 initialData 的配置,TypeScript 便自动命中第一个重载,得到 DefinedCreateQueryResult。定义见 types.ts:
export type DefinedCreateQueryResult<
TData = unknown,
TError = DefaultError,
TState = DefinedQueryObserverResult<TData, TError>,
> = BaseQueryNarrowing<TData, TError> &
MapToSignals<OmitKeyof<TState, keyof BaseQueryNarrowing, 'safely'>>
底层的 DefinedQueryObserverResult(见 query-core/src/types.ts)保证其 data 属性不为 undefined,并经由 MapToSignals 映射为 Angular 的 signal 形态。结果是模板或组件里可以放心书写 query.data(),不再需要 if (query.data() === undefined) 之类的守卫。
类型推断实战对照
// 不提供 initialData —— 走 UndefinedInitialDataOptions 重载
query1 = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetchTodos(),
}))
// query1.data(): Todo[] | undefined,需要判空
// 提供必填 initialData —— 命中 Defined 重载
query2 = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetchTodos(),
initialData: [],
}))
// query2.data(): Todo[],可直接访问,无需判空
这正是类型别名名称 "Defined"(已确定)的含义:它不仅描述"配置里有一个初值",更把这种确定性一路传递到查询结果类型。
实战用法:initialData 的四种打开方式
Angular 官方指南 initial-query-data.md 给出了完整的配套用法,下面全部是 injectQuery 内的直接示例。
1. 直接提供静态初值
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
}))
效果:组件/服务实例一创建就立即显示 initialTodos,同时后台立刻发起一次 refetch 用服务器数据替换它。
2. 配合 staleTime 延迟刷新
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
staleTime: 1000, // 1 秒内数据视为新鲜,不触发后台刷新
}))
在 staleTime 窗口内,查询认为自身数据仍是新鲜的,fetchStatus 不会进入 fetching,避免"一进来就闪一次加载"的抖动。指南中还给出 staleTime: 60 * 1000 配合 initialDataUpdatedAt 的更精细版本。
3. 使用函数形式延迟计算
result = injectQuery(() => ({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: () => getExpensiveTodos(), // 惰性计算,仅在该查询真正初始化时才求值
}))
函数形式对应类型声明中 (() => NonUndefinedGuard<TQueryFnData>) 的分支,适合初始化成本较高、希望推迟到查询创建时才执行的场景(例如从内存缓存或本地存储读取)。
4. 跨查询派生:把列表数据当作详情页初值
这是"优化第二屏体验"的经典做法——把某个已就绪查询的数据当作另一个查询的 initialData:
result = injectQuery(() => ({
queryKey: ['todo', this.todoId()],
queryFn: () => fetch(`/todos/${this.todoId()}`),
initialData: () =>
queryClient.getQueryData(['todos'])?.find((d) => d.id === this.todoId()),
}))
配合 initialDataUpdatedAt 让时间戳也一并透传:
result = injectQuery(() => ({
queryKey: ['todo', this.todoId()],
queryFn: () => fetch(`/todos/${this.todoId()}`),
initialData: () =>
queryClient.getQueryData(['todos'])?.find((d) => d.id === this.todoId()),
initialDataUpdatedAt: () => queryClient.getQueryState(['todos'])?.dataUpdatedAt,
}))
若想只接受"10 秒内"的旧数据,过期则放弃初值、退回硬加载态,可以在函数内部访问 getQueryState 并返回 undefined——但注意此时类型上会与 DefinedInitialDataOptions 的 never 约束冲突,实际项目中这类"可能返回 undefined 的初值函数"应选择 UndefinedInitialDataOptions 一方(即不声明 initialData,或走允许 undefined 的重载),这也反向印证了两类类型别名分工的必要性。
底层原理:getDefaultState 如何消费 initialData
类型声明承诺"有初值",运行时则由 query-core 的查询状态工厂兑现。在 query.ts 的 getDefaultState 中可以看到完整逻辑:
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,
...
}
三个关键点:
- 统一函数分支:无论类型声明里
initialData是值还是函数,运行时都先经typeof === 'function'判断后求值——与类型联合TQueryFnData | (() => TQueryFnData)一一对应(InitialDataFunction<T>定义见 types.ts)。 - 有数据即
success起点:只要求值结果!== undefined,默认状态就携带真实data,因此观察者初始便处于"有数据"状态,UI 首次渲染不会落入 loading。 initialDataUpdatedAt决定陈旧性:该字段可为时间戳数字或返回时间戳的函数(types.ts);未提供时回退为Date.now()。由于默认状态把dataUpdatedAt记为当前时间,若不显式提供旧时间戳,staleTime的判断会认为初值"刚刚新鲜"。query-core 的测试用例也验证了这一点,例如 query.test.tsx 中"函数形式initialDataUpdatedAt会被调用"以及"initialDataUpdatedAt为 0 时也正常工作"。
initialData 与 placeholderData:别混淆两种"提前展示"
类型上 DefinedInitialDataOptions 只约束 initialData,但与之经常被对比的 placeholderData(见 types.ts 注释:placeholder 用于查询仍处于 loading 且未提供 initialData 时)也在 CreateQueryOptions 基座中可用。两者在语义与运行时行为上有本质区别:
| 维度 | initialData(Defined) | placeholderData |
|---|---|---|
| 是否"真正的数据" | 是,会写入查询缓存,作为状态初始值 | 否,仅临时展示层占位,不进入缓存 |
| 状态推导 | 有值时初始 status 即 success |
查询本身仍可能 pending |
| 结果类型 | 收窄为 DefinedCreateQueryResult,data 非空 |
不影响结果类型收窄 |
| 典型用途 | 从缓存/持久层"种子化"一个真实查询 | 分页等场景保留上一页内容(如 keepPreviousData) |
占位数据的完整用法可参考 placeholder-query-data.md,其中包含 placeholderData: (previousData, previousQuery) => previousData 这样的"上一页数据续显"模式。
配套设施:queryOptions 复用同一份类型
DefinedInitialDataOptions 还出现在 queryOptions() 的重载签名中——该工具函数用于把某份查询配置抽离为可共享、可复用且类型安全的选项对象,其第一组重载恰好接收 DefinedInitialDataOptions。换言之,把带 initialData 的查询配置放进 queryOptions()(或用 prefetchQuery/setQueryData 预填充缓存)后,injectQuery(() => sharedOptions) 依然能获得定义即确定的类型体验,这一模式非常适合多个组件共享同一数据源的场景。
小结
DefinedInitialDataOptions 的价值不在"多了一个必填字段",而在于它把"我有初始数据"这一业务事实编码进了 TypeScript 类型系统:injectQuery 依据它命中重载、返回 data 非空的 DefinedCreateQueryResult,query-core 的 getDefaultState 又依据它让查询从 success 而非 pending 起步。前后端(类型契约与运行时)互为印证,这正是 TanStack Query 以类型驱动 API 设计的缩影。当你在 Angular 应用中希望"首帧即有数据、无 undefined 噪音"时,它就是你需要的那一层类型契约。
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 StartedRust0627
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