深入解读 Preact Query 的 DefinedUseQueryResult:data永不为undefined的类型保证
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
DefinedUseQueryResult是@tanstack/preact-query为查询结果提供的一类“确定型”类型别名:当你在useQuery中设置initialData(或使用useSuspenseQuery)时,TypeScript 会自动把返回值收紧为该类型,从类型层面保证解构出来的data绝不可能是undefined。本文将以 DefinedUseQueryResult 类型参考文档 为主线,结合本仓库中preact-query与query-core的源码实现,讲清它的定义、触发条件、底层结构与实战价值,帮助你在 Preact 应用中编写更安全的取数代码,彻底告别手动非空断言。
一、DefinedUseQueryResult 的类型定义一览
该类型别名在 preact-query/src/types.ts:352 中定义,完整声明只有一行:
export type DefinedUseQueryResult< TData = unknown, TError = DefaultError, > = DefinedQueryObserverResult<TData, TError>从文档注释与源码注释可以提炼出它的两条关键语义:
- 它是
useQuery在initialData被设置时的返回值类型——此时data永远不会是undefined; - 它也是
useSuspenseQuery在省略isPlaceholderData字段之前的基础形态——因为 Suspense 场景下该字段恒为false,是一个“死字段”,所以被裁剪掉而非保留为活跃状态。
从本质上说,DefinedUseQueryResult并不是一个新的类型体系,它只是把@tanstack/query-core中的DefinedQueryObserverResult原样re-export(再导出)到preact-query的门面层,让框架使用者无需直接依赖query-core也能引用该结果类型。
二、它解决的核心问题:让data可被安全解构
普通useQuery(未设置initialData)的返回值类型是 UseQueryResult(本质上是UseBaseQueryResult,即 query-core 的QueryObserverResult)。在请求尚未完成时,查询处于pending状态,此时的data是undefined。因此,普通用法下 TypeScript 会认为data可能为空,访问data内容前必须做判空。
而一旦配置了initialData,查询在一开始就有可展示的数据,data从类型上就不会为undefined。对应的,useQuery的重载签名返回的就是DefinedUseQueryResult——你甚至可以直接对data调用.map、访问属性,而无需可空性检查,编译器会为你背书。
这一“有无initialData→ 不同返回类型”的设计,在 useQuery.ts 的重载签名上体现得淋漓尽致:
// packages/preact-query/src/useQuery.ts // 重载一:options 带 initialData(DefinedInitialDataOptions) export function useQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>, queryClient?: QueryClient, ): DefinedUseQueryResult<TData, TError> // ← data 非空 // 重载二:options 不带 initialData(UndefinedInitialDataOptions) export function useQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>, queryClient?: QueryClient, ): UseQueryResult<TData, TError> // ← data 可能为 undefined真正的实现只有一行——它把上述重载统一委托给useBaseQuery(options, QueryObserver, queryClient)(见 useQuery.ts 的最终实现),也就是说,“类型是否确定”是编译期由 options 形状(是否含initialData)推断出来的,运行期并不存在两套逻辑。
条件分支同样出现在 useQueries 中
这种按initialData是否存在来选择返回类型的逻辑,在批量查询 useQueries.ts 中同样存在。从源码注释 "A defined initialData setting should return a DefinedUseQueryResult rather than UseQueryResult" 可以看到,useQueries也会在检测到某项initialData被设置时,将该项结果的联合类型成员收紧为DefinedUseQueryResult<TData, TError>。
三、底层来源:query-core 的 DefinedQueryObserverResult
DefinedUseQueryResult被 re-export 自 query-core,其底层联合类型定义在 packages/query-core/src/types.ts:890-L896:
export type DefinedQueryObserverResult< TData = unknown, TError = DefaultError, > = | QueryObserverRefetchErrorResult<TData, TError> | QueryObserverSuccessResult<TData, TError>与之相对的,普通QueryObserverResult则是一个包含五种“状态子类型”的联合:
export type QueryObserverResult<TData = unknown, TError = DefaultError> = | DefinedQueryObserverResult<TData, TError> | QueryObserverLoadingErrorResult<TData, TError> | QueryObserverLoadingResult<TData, TError> | QueryObserverPendingResult<TData, TError> | QueryObserverPlaceholderResult<TData, TError>对比可以看出:DefinedQueryObserverResult只保留了五种状态子类型中的两种——refetch 失败但保留旧数据(QueryObserverRefetchErrorResult)与完全成功(QueryObserverSuccessResult)。这正是“确定性”的由来:其余三种(首次加载失败、首次加载中、完全 pending)都对应着data === undefined的窗口期,被initialData直接抹平了。
这两种成员接口的关键字段窄化情况如下:
| 状态子类型 | data | error | isPending | status | isPlaceholderData |
|---|---|---|---|---|---|
QueryObserverRefetchErrorResult(types.ts:842) | TData(保留旧数据) | TError | false | 'error' | false |
QueryObserverSuccessResult(types.ts:858) | TData | null | false | 'success' | false |
从 DefinedUseQueryResult 中能解构出哪些字段
因为DefinedQueryObserverResult是联合类型,DefinedUseQueryResult的结果对象继承了QueryObserverBaseResult的全部字段(见 packages/query-core/src/types.ts 附近的完整定义),主要包括:
data: TData——确定存在的数据,这是核心保证;error、errorUpdateCount——success分支下error为null,refetchError分支下为TError;status(联合窄化为'success' | 'error')以及派生布尔量isSuccess/isError/isPending/isLoading/isFetching/isRefetching/isStale/isPaused等;fetchStatus('idle' | 'fetching' | 'paused');refetch(options?)手动重取函数。
注意:即便带initialData,也仍可能发生后台 refetch 失败。此时结果属于QueryObserverRefetchErrorResult分支:status === 'error'、error有值,但data依然保留旧值。因此“data非空”与“请求一定成功”是两回事——类型保证的是前者,UI 上你依然需要决定是否展示error(通常做法是照常渲染列表,同时用一条提示展示错误)。
四、与相邻结果类型的亲缘关系
在 types.ts 中,围绕结果类型有一组成体系的别名,理解它们的位置有助于精准把握DefinedUseQueryResult的角色:
| 类型别名 | 对应 hook | 底层类型 | data是否可能为undefined | 备注 |
|---|---|---|---|---|
UseQueryResult(types.ts:324) | useQuery(无initialData) | QueryObserverResult | 可能 | 即UseBaseQueryResult |
DefinedUseQueryResult(types.ts:352) | useQuery+initialData | DefinedQueryObserverResult | 不会 | 本文主角 |
UseSuspenseQueryResult(types.ts:336) | useSuspenseQuery | DistributiveOmit<DefinedQueryObserverResult, 'isPlaceholderData'> | 不会 | 在DefinedUseQueryResult基础上剔除isPlaceholderData |
UseInfiniteQueryResult/DefinedUseInfiniteQueryResult(types.ts:364 / types.ts:376) | useInfiniteQuery/ 带initialData | InfiniteQueryObserverResult/DefinedInfiniteQueryObserverResult | 视是否带initialData | infinite 场景的对应物 |
值得展开的是文档中提到的 “ofuseSuspenseQuerybeforetheisPlaceholderDataomission” 这句表述。其源码实现为:
export type UseSuspenseQueryResult< TData = unknown, TError = DefaultError, > = DistributiveOmit< DefinedQueryObserverResult<TData, TError>, 'isPlaceholderData' >也就是说,useSuspenseQuery的结果类型正是在DefinedUseQueryResult的底层联合上做了一次“分布式剔除(DistributiveOmit)”,把在所有分支中恒为false的isPlaceholderData字段从类型上删掉。它的意义是双向的:既继承了data非空的确定性,又避免了让使用者误以为在 Suspense 场景下还会渲染占位数据。这层“先 Defined、后剔除死字段”的组合关系,恰恰解释了为什么文档把它描述为useSuspenseQuery“剔除isPlaceholderData之前”的形态。
五、泛型参数说明
DefinedUseQueryResult<TData, TError>携带两个类型参数,默认值与含义如下:
TData(默认unknown)
- 含义:
data在经过select转换之后呈现出的类型。 - 说明:若没有使用
select,框架会把TData默认推断为TQueryFnData(即queryFn的返回类型);一旦配置了select: (data) => derivedData,data的类型将变成select的返回值类型,而query cache 中存储的仍是未经转换的原始数据(输入select的TQueryData保持不变)。
TError(默认DefaultError)
- 含义:
queryFn可能抛出的错误类型。DefaultError是 query-core 提供的默认错误类型,通常会被自定义错误类或unknown覆盖。 - 说明:当你只关心成功数据、不渲染错误细节时,通常无需显式指定该参数,交由推断即可;需要把
error与自定义错误类关联时,再显式传入。
类型参数使用的完整示例
在useQuery场景里,TData与TError通常是从 options 中自动推断的,但把它们显式用于注解“收集状态的数组”时,能直观体现结果的确定性,例如仓库测试中常见的写法(见 preact-query 的 useQuery 测试):
import { useQuery } from '@tanstack/preact-query' import type { DefinedUseQueryResult } from '@tanstack/preact-query' // 显式声明:states 里每一项的 data 都是 number,而非 number | undefined const states: Array<DefinedUseQueryResult<number>> = [] function Page() { const [count, setCount] = useState(0) const state = useQuery({ queryKey: ['counter', count], queryFn: () => sleep(10).then(() => count), initialData: 99, // ← 触发 DefinedUseQueryResult 分支 }) states.push(state) // ... }该测试同时验证了一个容易踩坑的行为(测试名为should not show initial data from next query if placeholderData is set):当同时配置initialData: 99与placeholderData: keepPreviousData时,切页后展示的是上一页数据(placeholder),而不是下一页的 initialData 99。测试断言中isPlaceholderData会在对应状态为true(见 测试状态断言),这也说明即便data非空,你仍应留意其来源究竟是initialData、真实数据还是placeholderData。
六、实际使用方式与触发条件
方式一:直接给 useQuery 传入 initialData
这是最直接、最常见的方式。仓库 DefinedInitialDataOptions 的定义 明确指出:initialData可以是值或同步返回值的函数,它会被写入 query cache 并持久化,且默认视为 stale(过期)——除非你另行设置了staleTime。
import { useQuery } from '@tanstack/preact-query' function Posts() { // 类型上 data: Post[],永远非空 —— 无需判空即可 .map const { data, isError, error } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialData: [], // ← 使返回类型变成 DefinedUseQueryResult<Post[]> }) return ( <div> {isError ? <span>Error: {error.message}</span> : null} <ul>{data.map((post) => <li key={post.id}>{post.title}</li>)}</ul> </div> ) }注意:这个例子里isError分支只展示错误提示、不中断渲染,其背后正是“refetch 失败时data依然非空”这一类型事实(对应QueryObserverRefetchErrorResult分支)。
方式二:把 initialData 收敛进 queryOptions 统一管理
如果想在useQuery与命令式 API 之间共享“带 initialData”的同一组 options,可以用 queryOptions 把 options 提前声明好,让queryKey携带推断出的数据类型,并在多处复用它:
import { queryOptions, useQuery } from '@tanstack/preact-query' export const postsOptions = queryOptions({ queryKey: ['posts'], queryFn: fetchPosts, initialData: [], // 与上面的 useQuery 重载一致,返回“确定型” }) function Posts() { const { data } = useQuery(postsOptions) // data: Post[],非空 return <ul>{data.map((post) => <li key={post.id}>{post.title}</li>)}</ul> }这里的类型流向是:queryOptions感知到initialData被设置,从而把自身收窄为DefinedInitialDataOptions,再把该类型透传给useQuery,最终命中返回DefinedUseQueryResult的重载——整条链路在编译期自动完成。
方式三:使用 useSuspenseQuery
采用 Suspense 模式时,组件在数据就绪前不会渲染(由上层 Suspense 边界兜底),因此返回结果天然是确定型的:
import { useSuspenseQuery } from '@tanstack/preact-query' function Post() { // data: Post;且类型上不存在 isPlaceholderData 字段 const { data } = useSuspenseQuery({ queryKey: ['post'], queryFn: fetchPost, }) return <h1>{data.title}</h1> }触发条件小结
只要满足以下任一情况,查询结果就会以DefinedUseQueryResult(或与其同族的确定型)呈现:
useQuery的 options 中显式包含initialData(值或函数皆可);- 使用
useSuspenseQuery(结果为去掉isPlaceholderData的变体); - 在
useQueries中,某一查询项配置了initialData,该项结果的联合成员相应收窄。
七、实战收益与边界注意事项
收益一:删除大量非空断言
未用initialData时,依赖型查询常需要data?.title或data!这类写法。引入DefinedUseQueryResult后,类型系统替你完成了判空,组件渲染分支可显著简化,也降低了运行期undefined访问的风险。
收益二:显式表达“空数据也是合法状态”
对列表类数据使用initialData: [],能在“加载中”与“真正空列表”之间建立清晰区分:首帧即可渲染空列表占位,后台加载完成后无缝替换——这正是类型层面把data收窄为确定值的最典型收益。
注意事项
initialData会被持久化并默认视为 stale:只要不设置staleTime,挂载后通常仍会触发一次后台 refetch 以获取最新数据,表现为isFetching: true与isSuccess: true并存。isPlaceholderData只在你使用placeholderData时才有意义:即便类型上是确定型,若同时配置了placeholderData(例如keepPreviousData),isPlaceholderData === true期间展示的并非新请求的数据,禁用“下一页”按钮等交互应以此标志为准,而不是只看data是否为空。- 不要对未配置
initialData的useQuery结果做类型断言:该场景返回值本就是UseQueryResult,data可能为undefined是正确建模;若嫌判空繁琐,优先选择补initialData、改用useSuspenseQuery,而不是用as硬断言。 TData跟随select变化,而 cache 里的数据类型不变:若你自定义了select,DefinedUseQueryResult<TData>中的TData是select 之后的类型;需要访问原始缓存数据时,应通过queryClient.getQueryData<TQueryFnData>(...)获取。
八、总结
DefinedUseQueryResult看似只是preact-query类型体系中的一行 re-export,背后却承载着明确的设计意图:让“有 initialData 就有数据”这条运行时事实,上升为可由编译器强制执行的静态保证。从useQuery依据 options 形状选择重载,到 query-core 用DefinedQueryObserverResult联合类型排除pending/loading状态,再到useSuspenseQuery在此基础上剔除恒为false的isPlaceholderData,层层类型收窄最终换来的是更少的空值分支、更直观的组件代码,以及更少被漏掉的undefined边界。建议在 Preact 项目中把“列表先给initialData: []、详情页交给useSuspenseQuery、共享 options 用queryOptions收口”作为默认姿势,即可稳定享受到这套类型体系带来的确定性收益。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考