深入解析 React Query 的 useQuery:签名重载、返回状态机与源码级实现

深入解析 React Query 的 useQuery:签名重载、返回状态机与源码级实现 深入解析 React Query 的 useQuery签名重载、返回状态机与源码级实现【免费下载链接】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/queryuseQuery是 TanStack Query 在 React 框架下的核心 Hook它把一个服务端状态异步数据请求、缓存、后台刷新声明式地接入组件渲染。本文以 docs/framework/react/reference/functions/useQuery.md 为主骨架完整讲解其三个重载签名、类型参数、返回的状态对象与initialData/select/enabled/skipToken/keepPreviousData等实战用法并结合 useQuery.ts 与 useBaseQuery.ts 的源码还原它从 Hook 到QueryObserver订阅的底层链路。读完后你能看懂官方 API 文档的每个签名写出类型安全的查询代码并理解 disabled/依赖查询、缓存播种、分页占位等场景背后的设计动机。useQuery 是什么一个 Hook三个重载签名useQuery并非只有一个函数签名而是通过 TypeScript 重载overload对外暴露三种调用形态。它们共享同一套运行时实现区别在于类型层面对data的收窄程度重载触发条件options 类型返回类型第一个设置了initialDataDefinedInitialDataOptionsDefinedUseQueryResult第二个未设置initialDataUndefinedInitialDataOptionsUseQueryResult第三个一般通用形态UseQueryOptionsUseQueryResult三个重载在源码 useQuery.ts 中按顺序声明function useQueryTQueryFnData, TError, TData, TQueryKey(options, queryClient?): DefinedUseQueryResultTData, TError; function useQueryTQueryFnData, TError, TData, TQueryKey(options, queryClient?): UseQueryResultTData, TError; function useQueryTQueryFnData, TError, TData, TQueryKey(options, queryClient?): UseQueryResultTData, TError;第一个重载的 JSDoc 明确指出当设置了initialData时编译器会选择该重载因此其返回类型DefinedUseQueryResult中data永远不是undefined——即便后台 refetch 失败已有数据仍会保留并伴随error一起呈现status在类型层面永远不会落到pending因为initialData保证数据从一开始就存在。其余两个重载返回UseQueryResultdata在pending阶段可能为undefined。这一点贯穿整个 API 的体验设计类型系统把你的数据确定性转化为可检查的编译期约束。类型参数四个泛型各自代表什么所有重载共享同样的四个泛型参数默认值如下TQueryFnDataunknown你的queryFn解析resolve出的原始数据类型。官方文档写法为unknown而实现层实际默认TQueryFnData unknownTErrorqueryFn可能抛出的错误类型。文档标注的默认值是Error在源码 useQuery.ts 中对应别名DefaultError其解析结果即ErrorTDataTQueryFnDatadata在经过select之后的最终类型。当没有使用select时它就等于TQueryFnData见 UseQueryOptions 文档TQueryKeyextendsreadonly unknown[]readonly unknown[]你的queryKey的类型。文档中表述为extendsreadonly unknown[]它约束了查询键必须是一个只读元组/数组。参数options 与可选的 queryClient每个重载都接收两个参数options完整配置对象。三个重载分别接收DefinedInitialDataOptions等价于传入useQuery的一切外加initialData、UndefinedInitialDataOptions与UseQueryOptions。其中UseQueryOptions与UseBaseQueryOptions一致只是移除了suspense——因为react-query根据你调用的是useQuery还是useSuspenseQuery来推导是否开启 suspense而不是把它暴露为配置项。UseQueryOptions还继承了subscribed?: boolean属性默认true设为false可以取消该 observer 对查询缓存更新的订阅见 UseQueryOptions 文档queryClient?类型为QueryClient。传入自定义QueryClient时会使用它否则使用**最近上下文nearest context**中的那一个。也就是说默认情况下你无需手动传入——QueryClientProvider会把它注入上下文useQuery内部通过useQueryClient(queryClient)自动获取。返回值query 状态机与派生布尔值useQuery返回当前 query 结果其核心字段语义status枚举pending | error | success。当没有缓存数据可显示时为pending最后一次请求失败为error当 query 有数据可显示时为successisPending/isSuccess/isError与status对应的派生布尔值纯粹为了读写方便data查询数据未设置initialData时在pending阶段可能为undefinederror最近一次请求失败的错误对象isFetching是否有请求正在进行包括后台刷新isLoading、isPlaceholderData等其它派生标志在具体场景中使用详见下文用例。这种核心枚举 派生布尔的设计在 DefinedUseQueryResult 文档与 UseQueryResult 文档中都有直接说明——前者是useQuery设置initialData后或useSuspenseQuery的isPlaceholderData省略前的返回类型data绝不可能是undefined后者等价于UseBaseQueryResult即未设置initialData时的返回类型。用status做分支最基本的写法是按status三态渲染。注意这里引入了isFetching用来区分首次加载与后台更新import { useQuery } from tanstack/react-query function Posts() { const { status, data, error, isFetching } useQuery({ queryKey: [posts], queryFn: fetchPosts, }) if (status pending) return Loading... if (status error) return spanError: {error.message}/span return ( div ul {data.map((post) ( li key{post.id}{post.title}/li ))} /ul div{isFetching ? Background Updating... : }/div /div ) }用布尔标志做分支同一个查询也可以用isPending/isError取代status——文档的原话是pick whichever reads better to you选择对你读起来更顺的一种import { useQuery } from tanstack/react-query function Posts() { const { isPending, isError, data, error } useQuery({ queryKey: [posts], queryFn: fetchPosts, }) if (isPending) return Loading... if (isError) return spanError: {error.message}/span return ( ul {data.map((post) li key{post.id}{post.title}/li)} /ul ) }两种写法渲染结果一致选哪种取决于团队风格与分支复杂度。实战用例逐个拆解官方文档按场景提供了六类高价值范例下面逐一展开。示例中的fetchPosts/fetchPost为返回 Promise 的数据获取函数对应queryFn。场景一initialData—— 让data从类型上绝不 undefined给 query 一个初始数据useQuery就会自动选中defined重载data的类型不再含undefined即便后台 refetch 失败列表也始终可见import { useQuery } from tanstack/react-query function Posts() { // data 是 Post[]永远不会是 undefined得益于 initialData—— // 即使 refetch 失败列表也会与错误提示一起保留在界面上。 const { data, isError, error } useQuery({ queryKey: [posts], queryFn: fetchPosts, initialData: [], }) return ( div {isError ? spanError: {error.message}/span : null} ul {data.map((post) li key{post.id}{post.title}/li)} /ul /div ) }关于initialData的语义UndefinedInitialDataOptions 文档给出了精确说明值得仔细读若 query 尚未被创建或缓存该值会被写入 query 缓存作为初始数据如果传入的是函数该函数会在共享/根 query 初始化期间只被调用一次且需要同步返回初始数据初始数据默认被视为已过期stale除非设置了staleTime并且initialData会持久化进缓存。注意它与下面placeholderData仅占位、不入缓存有本质区别。场景二select—— 从缓存数据中派生组件所需的数据select用缓存值派生出组件真正需要的东西而不改变缓存中实际存储的内容——缓存仍持有完整的Post[]但此处data的类型是一个numberimport { useQuery } from tanstack/react-query function PostCount() { const { data, isPending, isError, error } useQuery({ queryKey: [posts], queryFn: fetchPosts, select: (posts) posts.length, }) if (isPending) return Loading... if (isError) return spanError: {error.message}/span return span{data} posts/span }由于select的入参类型会推导进TData第二个泛型参数的意义在这里体现得最直观TData默认为TQueryFnData一旦提供了selectdata的最终类型由select的返回值决定。场景三依赖查询dependent queries—— 用enabled控制开关只有当postId被设置后 query 才启用。文档特别强调这种情况下要用isLoading而不是isPending以免 query 处于禁用状态时错误地显示 loadingimport { useQuery } from tanstack/react-query function Post({ postId }: { postId: number | undefined }) { const { data, isLoading, isError, error } useQuery({ queryKey: [post, postId], queryFn: () fetchPost(postId!), enabled: postId ! null, }) if (postId null) return Select a post if (isLoading) return Loading... if (isError) return spanError: {error.message}/span return h1{data?.title}/h1 }代码里出现了postId!的非空断言——这是因为enabled: false时queryFn实际上不会被调用但编译器无法从enabled推断这一点。下面skipToken就是为了消除这类非空断言而生。场景四skipToken—— 类型安全的禁用写法把同一个依赖查询改写成类型安全版本queryFn只在postId有定义时才真正传入函数否则传skipToken。skipToken从tanstack/react-query顶层导出import { skipToken, useQuery } from tanstack/react-query function Post({ postId }: { postId: number | undefined }) { const { data, isLoading, isError, error } useQuery({ queryKey: [post, postId], queryFn: postId ! null ? () fetchPost(postId) : skipToken, }) if (postId null) return Select a post if (isLoading) return Loading... if (isError) return spanError: {error.message}/span return h1{data?.title}/h1 }文档同时提醒了一个关键约束当queryFn是skipToken时refetch不生效如果你需要手动触发这个 query请改用enabled: false的写法。场景五从已缓存的列表播种详情 query —— 跳过 loading用initialData函数 useQueryClient从已有的[posts]缓存里找到对应文章作为详情页初始数据从而在跳转时直接跳过加载态。这里正好体现了initialData若为函数则只调用一次的语义——它只在初始时执行查找import { useQuery, useQueryClient } from tanstack/react-query function Post({ postId }: { postId: number }) { const queryClient useQueryClient() const { data, isError, error } useQuery({ queryKey: [post, postId], queryFn: () fetchPost(postId), initialData: () queryClient .getQueryDataArrayPost([posts]) ?.find((post) post.id postId), }) if (isError) return spanError: {error.message}/span return h1{data?.title}/h1 }场景六分页 —— 用placeholderData: keepPreviousData保留上一页数据分页时让旧页数据在下一页加载期间保持可见并用isPlaceholderData禁用下一页按钮避免用户基于占位数据连点import { keepPreviousData, useQuery } from tanstack/react-query import { useState } from react function Posts() { const [page, setPage] useState(0) const { data, isPlaceholderData, isError, error } useQuery({ queryKey: [posts, page], queryFn: () fetchPosts(page), placeholderData: keepPreviousData, }) if (isError) return spanError: {error.message}/span return ( div ul {data?.map((post) li key{post.id}{post.title}/li)} /ul button disabled{isPlaceholderData} onClick{() setPage((old) old 1)} Next Page /button /div ) }这里placeholderData与前面initialData的区别再次出现占位数据不会写入缓存因此data可能为undefined需要通过data?.map兜底它只影响当前渲染并用isPlaceholderData布尔值把当前显示的是占位数据暴露给开发者。源码走读useQuery 的实现与订阅链路抛开类型重载useQuery的运行时实现极其简短——useQuery.ts 最后一行就是全部export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) { return useBaseQuery(options, QueryObserver, queryClient) }也就是说useQuery只是把具体 observer 类型QueryObserver来自tanstack/query-core注入通用基座useBaseQuery。useSuspenseQuery、preact-query 等其它框架实现也复用了同一套useBaseQuery逻辑只是传入不同的 observer 与推导方式。真正干活的是 useBaseQuery.ts其关键链路如下获取 client 并做默认值合并useQueryClient(queryClient)拿到上下文中的QueryClient随后client.defaultQueryOptions(options)把你在QueryClientProvider上配置的全局默认值如默认staleTime、retry与本次options合并按 hash 定位已存在的 queryclient.getQueryCache().get(defaultedOptions.queryHash)根据 hash 找到共享缓存中的 query为后续错误边界重试与 suspense 判定做准备开发期告警非生产环境下若没有queryFn且没有默认 query 函数会打印未提供 queryFn的console.error提示乐观结果通过_optimisticResults让结果在真正订阅前就进入乐观的 fetching 状态isRestoring恢复中则标记为isRestoringobserver 单例React.useState(() new Observer(client, defaultedOptions))惰性创建且只创建一次observer 内部持有订阅回调与当前结果订阅useSyncExternalStore订阅 observerobserver.subscribe(notifyManager.batchCalls(onStoreChange))把 React 的变更通知批量调度同时先调用observer.updateResult()弥补创建 observer 到真正订阅之间可能遗漏的缓存更新subscribed false时跳过订阅退化为noop选项热更新React.useEffect中每次defaultedOptions变化都会observer.setOptions(defaultedOptions)——这正是依赖查询里queryKey/enabled变化能驱动重查的机制suspense 与错误边界shouldSuspend为真时throw fetchOptimistic(...)交给上层 SuspensethrowOnError/suspense命中时抛出result.error交给 Error Boundary属性级依赖追踪当没有显式设置notifyOnChangeProps时返回observer.trackResult(result)它按组件实际读取的属性做细粒度追踪避免不必要的重渲染显式设置了notifyOnChangeProps则直接返回结果。从源码结构还可以推断出两个工程细节文件顶部声明了use client保证该模块在 React Server Components / Next.js App Router 中作为客户端组件边界使用同时useBaseQuery开发期的参数校验明确抛错——从 v5 起只允许单个对象调用形式useQuery({ queryKey, queryFn })任何把参数拆开传的老写法都会在非生产环境抛出Bad argument type异常这是向 v5 迁移时最常见的报错之一。与 queryOptions、useQueryClient 的协同在官方文档每个重载的 See also 中都固定指向同一个建议使用queryOptions把这些配置在useQuery与命令式 API如queryClient.query之间共享。它的价值在于让类型在声明点就固化下来而不是在使用点各自推导——比如把一组queryKey/queryFn/staleTime定义成常量后既能在组件里useQuery(queryOptions)也能在事件回调或预取逻辑里调用queryClient.fetchQuery(queryOptions)两处的类型完全一致。与useQueryClient的协同则体现在缓存播种类场景上文场景五useQueryClient()返回离当前组件最近的 clientgetQueryData按 key 读缓存。另外如果你需要脱离最近上下文、在局部指定其它 client直接给useQuery传第二个参数queryClient即可——文档对它的注释是Use this to use a customQueryClient. Otherwise, the one from the nearest context will be used.用它来指定自定义QueryClient否则使用最近上下文中的那个。常见误区与最佳实践小结把官方文档、类型说明与源码对照后可归纳出几条高价值的实践结论v5 只接受对象参数源码 useBaseQuery.ts 在非生产环境对非对象/数组参数直接抛Bad argument type迁移老代码务必改为单对象形式initialData与placeholderData不要混淆前者写缓存、函数只执行一次、默认视为 stale、且触发DefinedUseQueryResult类型收窄data非undefined后者不写缓存、是渲染层占位用isPlaceholderData标记参见 UndefinedInitialDataOptions 文档禁用态查询用isLoading判断enabled: false或skipToken的查询不会真正加载此时isPending仍为真直接用isLoading可避免误渲染 loadingskipToken下refetch无效需要手动触发被禁用的查询时改回enabled: false写法select不改缓存派生计算发生在 observer 层缓存里始终是TQueryFnData形态的完整数据避免为了渲染把变形后的数据写回缓存返回类型的选择由你决定想让data从类型上保证可用无undefined就显式提供initialData让编译器自动切到DefinedUseQueryResult。参考路径速查本文主体文档docs/framework/react/reference/functions/useQuery.md运行时实现packages/react-query/src/useQuery.ts重载声明见 useQuery.ts#L50-L289实现见 useQuery.ts#L291-L293通用基座packages/react-query/src/useBaseQuery.ts类型文档UseQueryOptions、DefinedInitialDataOptions、UndefinedInitialDataOptions、UseQueryResult、DefinedUseQueryResult相关 HookuseQueryClient、useSuspenseQuery上手示例docs/framework/react/quick-start.md 及 examples/react/basic、examples/react/pagination对应keepPreviousData分页用例【免费下载链接】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),仅供参考