《React Query 笔记》

《React Query 笔记》

要开始做请求方案的调研,第一个深入看的是 React Query(下面简称为 RQ)。本文是 Practical React Query | TkDodo’s blog 系列文章的阅读笔记。

1. 为啥很多人会认为 apollo 会取代 redux?

apollo 是请求方案,而 redux 是全局状态库,看起来八竿子打不着,但如果我们能在客户端通过缓存的方式访问服务端的数据,那对于 80% 的应用来说,剩下需要处理的客户端状态其实很少。所以,apollo 会取代 redux 吗?It Depends。而 apollo 是处理 graphql 的,RQ 则把 apollo 的很多功能带到了 restful 的 api 场景(且不限于此)。

2. 一些文档外的使用技巧

  1. 默认值的选择很好,但对于新手用户也会有时不时的措手不及,需要理解他们,
  2. 使用 React Query DevTools,会告诉你缓存的数据状态,同时也要结合 Chrome DevTool 的网络面板,因为开发模式请求通常比较快,
  3. query keyuseEffect 的依赖数组使用,两者非常相似,
  4. 通过传入 initialData 可以让切换状态时先用缓存中的数据做预填充,提升用户体验,比如 todo app 的 filter 从全部切换到完成时的场景,
  5. 保持服务端和客户端状态分离,useQuery() 得到的数据不要存本地 copy,以避免拿到老数据,
  6. enable 配置项非常强大,可以做很多事,比如依赖另一个查询的数据、modal 打开时关闭数据轮询、等待用户输入时禁用、用户输入信息后禁用默认数据的查询等,
  7. 不要尝试通过 queryClient.setQueryData 修改 query cache 使他成为本地状态管理器,因为每次后台重新获取都可能覆盖他们,
  8. useQuery 包起来用会有额外好处,比如 ui 和逻辑分离等。

3. 如果后端没有返回期望的数据格式,那就需要做格式化了。这时有多个选择,

  1. 后端转,缺点是不一定能实现,
  2. queryFn 里转,缺点是每次 fetch 时需要跑一遍,同时如果你有一个不能自由修改的 service 层可能行不通(不能通过 openapi 生成的),
  3. render 函数中转,缺点是语法复杂,需借助 useMemo 提升性能,同时数据可能 undefined,
  4. select option 转,没啥明显缺点。所以,相对来说,方案 4 会更好,最佳优化(调用次数最少)、同时允许部分订阅。

4. RQ 内置了一些渲染优化的能力。

  1. notifyOnChangeProps 配置,从版本 4 起默认开启 track 模式,跟踪你使用了哪些 props,然后只在这些 props 变更后做 notify,可选配置是 allString 数组,选 String 数组 要注意 props 的同步手动更新,避免出现该渲染没渲染然后使用过期数据的问题,
  2. RQ 做了结构共享,比如 [{id:1,text:1},{id:2,text:2}],如果更新数据后只变更了 id 1,那通过 useItems(/*id*/2) 拿的数据会保持引用一致性,不会 notify 更新,详见 replaceEqualDeep 的测试。。

5. RQ 有 3 种状态:

successerrorloading(idle 在版本 4 里去掉了),以及另一个维度的 isFetching
应该是怎样的状态检测顺序?通常大家会写 loadingerrordata(即 success)这样的顺序,但这在 refetch 时,会导致的问题是,loading 或 error 状态时看不到数据。所以更推荐的判断顺序是 data、error、loading。

6. 如何做测试?

这里有个例子这里有个例子。

  1. 不要 mock fetch,用 MSW,
  2. 给每个测试一个 QueryClientProvider 并创建新的 QueryClient,好处是完全隔离(备选方案是每次测试完清 QueryClient 的缓存,缺点是并行运行测试时可能会出错),
  3. 自定义 hook 用 react-hooks-testing-library 做测试,
  4. 测试使用 useQuery 的组件时记得用 QueryClientProvider 包一下,参考 react-query 内部的测试用例,
  5. 设置 retryfalse,否则可能会因为默认要做 3 次 exponential backoff 重试而导致超时,同时注意不要在 useQuery 里写死 retry 参数,改用 queryClient.setQueryDefaults('todos', { retry: 5 }) 会对测试更友好,
  6. 由于 useQuery 是异步的,记得要加 await waitFor(() => result.current.isSuccess) 等待请求完成,
  7. 如果命令行有太多日志,可以通过设置 QueryClientlogger 参数避免冗余报错,logger: { error: () => {}, log: console.log, warn: console.warn: console.warn }

7. 关于类型。

  1. useQuery 有两种写法,泛型和类型推导,推荐后者,让 queryFn 返回正确的类型,剩下的全部走推导,注意这里会缺 error 的类型,写的时候可以用 error instanceof Error 确保下,
  2. useQuery 加上 enabled 参数时就可能是 undefined 了,需在 queryFn 里处理 undefined 时抛错,
  3. 使用 useInfiniteQuery 时记得给 queryFnpageParam 加类型,
  4. 使用 default queryFn 时要注意 queryKey 的参数类型。

8. RQ + WebSocket 有两种用法。

  1. 推模式,服务端推送数据,客户端通过 queryClient.setQueriesData 更新数据,缺点是推送了并不需要的数据,比如你并不在需要数据的那个页面,

  2. 拉模式,服务端只发送事件,告知客户端哪些数据更新了,然后客户端通过 queryClient.invalidateQueries 让那些请求缓存失效,在需要的时候再去服务端拉数据。

    ※个人建议无脑用第二种。同时要注意修改 staleTimeInfinity,WebSocket 会保证本地数据是最新的,无需通过 staleTime 设置过期时间。

9. query key 应该如何组织?

一些背景知识:

  1. useQueryuseInfiniteQuery 共享同一个缓存,所以 key 不能重复,
  2. 重新获取不同参数的数据时,不能用 refetch(xxx)refetch 不能重新获取相同 key 的数据,解法是给 useQuery 传不同的 key,
  3. 与查询缓存交互有多种方法,比如 invalidateQueriessetQueriesData 等。

那么如何高效地组织 query key?

  1. 用比如 ['todos', 'list', { filter: 'all' }] 这样的结构,好处是在 invalidate 或突变操作时会更灵活,比如同时操作所有的 todos > list
  2. 使用 query key factory,避免手写容易导致的错误,比如 todoKeys.alltodoKeys.detail(id)

10. 和 query key 相关的是,queryFn 应该怎么写?

  1. 内联,比如 useQuery(['todos', p1, p2], () => fetchXXX(p1, p2)),缺点是 keyfn 里的参数必须同步更新,一旦遗漏比如忘记更新 key 里的参数,会导致请求数据不刷新,
  2. QueryFunctionContext,比如
p1, p2]
1
({ queryKey }) => fetchXXX(queryKey[1], queryKey[2]))

,只只需维护 query key 里的参数即可,缺点是内联函数才有类型,同时 queryKey 是数组格式,前面几项通常会用不到, 3. 在 2 的基础上,支持独立函数,QueryFunctionContext<ReturnType<typeof queryKey>>, 4. 在 3 的基础上,把 key 由数组改成 object,比如 useQuery({ scope: 'todos', entity: 'list', state, sorting }, fn) ,看起来是目前最完美的版本,类型也安全。

11. RQ 预填充缓存数据有两种方法

placeholderDatainitialData

相同的是:

  1. 都会直接跳过 loading 状态进入 success 状态,
  2. 都支持值或者返回值的函数两种类型。

不同的是:

  1. initialData 在缓存层处理,直接放入缓存,尊重 staleTime,一个 query key 只能由一个 initialDatarefetch 错误时数据还在,
  2. placeholderData 在观察者层处理,不放入缓存,用于临时占位,可以为相同的 query key 创建多个不同的 placeholderDatarefetch 错误时数据消失。

12. RN 是啥?

他不是请求库,因为不内置 axios、fetch 之类的;他是异步的状态管理器,或者是服务端状态的同步工具。
在 RN 之前,通常有两种数据获取的方法:

  1. 一次获取、全局分发、很少更新;
  2. 每次获取(比如每次 modal 打开时都请求一遍数据)。这两种都不太好,前者更新太少,后者更新太多。RN 通过 Stale While Revalidate 和机制的 refetch 机制来解这个问题。refetch 触发时机包括 refetchOnMountrefetchOnWindowFocusrefetchOnReconnect,以及手动的 queryClient.invalidateQueries。同时可针对不同场景设置不同的 staleTime 来控制 refetch 频率。

13. RN 处理错误有 3 种方法。

  1. useQuery 返回值的 isErrorstatus === 'error' 判断;
  2. onError 回调,可以是 query 级,也可以是全局用 queryCache 配,query 级的要当心避免多次触发;
  3. 基于 Error Boundary,配置 useErrorBoundary:true 即可。

三种方法可以搭配使用,作者建议针对 refetch 错误用全局 onError + toast 显示,其他的用 1 或 3 的方法处理。另外,如果有 fetch,要在 queryFn 里处理 4xx 和 5xx 请求,他不像 axios 一样会 reject。

14. RN 通过 useMutation 实现突变(更新数据)。

useQuery 相同的是都会提供 loadingerrorstatus 数据,也支持 onSuccessonErroronSettled 回调。和 useQuery 不同的是,useQuery 是声明式,而 useMutation 是命令式,同时 useMutation 也不会共享状态。突变后要更新数据有三种思路,

  1. invalidate query 让他 refetch,注意只有活跃的 queryrefetch;
  2. 通过 queryClient.setQueryData 直接更新,适用于知道全部数据的场景,但可能不安全,比如遇到排序、新增后新 id 数据时会变得很复杂;
  3. 乐观更新,适用于小交互。一些常见问题:
  • onSuccess 回调支持 Promise,适用于比如你需要在 invalidate 操作完成前保持 useMutationloading 状态时;
  • useMutationmutatemutateAsync 两个方法,通常更推荐 mutate,因为 mutateAsync 要自行 try...catch 处理异常场景,mutateAsync 的场景是有多个并行或串行依赖性突变时;
  • useMutation fn 只支持一个参数,可以用 Object;
  • useMutationmutation 都有回调,前者肯定会执行,适用于逻辑,后者组件销毁后不执行,适用于 UI。

15. 关于离线。

  1. 有一个 networkMode 设置,可以选择三种模式:
  • online(默认模式,假设有网络时才能用,没网络会进入暂停状态)
  • always(不关心网络状态,永远会启用查询,适用于数据获取以外的事)
  • offlineFirst(总是会发出第一个请求,失败时进入暂停状态),
  1. useQuery() 会返回 fetchStatus,包括 fetchingpausedidle 三种状态,可以和 status 结合使用,两者不互斥

16. 关于表单。

  1. 作者推荐了 react-hook-form 和 RN 搭配使用,
  2. 最简方案是 useQuery 请求数据作为默认值然后 useMutation 突变更新,但存在两个问题,data 可能为空和没有后台更新,data 可能为空壳通过提取组件的方式解,没有后台更新可通过受控组件+优先用受控组件的值的方式解,
  3. 防止重复提交可基于 useMutation() 返回的 isLoading,给提交按钮加 disabled={isLoading} 来解,
  4. 如果提交后没有跳转到其他页面,通常需要在 mutate 之后 invalidate query(更新数据)并 reset 表单。

17. RQ 和 React Router(简称 RR)6.4 的关系。

  1. RR 没有做 cache 而 RQ 有,RR 是关于 when(时机)而 RQ 是关于 what(具体请求方案);
  2. 只有 RR 可以做到提前获取,但问题是由于没有缓存从而会到请求过于频繁;
  3. RQ 和 RR 可结合使用,在 loader 里通过 queryClient.fetchQuery 发起请求,组件里用 useQuery 正常拿数据即可,出于 ts 类型考虑默认值可以填 useLoaderData 的数据;
  4. 如果用 RR 的 action 做突变,redirect 之前记得 invalidate 相关的 query,同时 invalidate 可以根据场景选择是否 await

18. 关于请求瀑布流。

  1. 依赖类 query 的瀑布流是避免不了的,比如 /user/1/project 依赖 /user 的数据,非依赖的写多个 useQuery 或者用 useQueries 解效果相同;
  2. Suspense 会在 Promisepending 状态时用 fallback 渲染,缺点是 fallback 时间过长从而影响渲染,渲染迟了进而影响子组件的数据请求,从而导致瀑布流;
  3. queryClient.prefetchQuery 可以缓解这个问题,一种用法是放在组件外,可以在代码下载解析时即执行;
  4. useQueries 暂不支持 suspense;
  5. 如果列表页包含详情页的所有数据,可以用列表页缓存的数据填充详情页缓存,这里也分拉和推两种方式。

FAQ。

1. 如何给 refetch 传递参数?

不能传递。

2. 如何做同一个 useQuerykey 变更时的过渡?

placeholderData 或者配置 keepPreviousData: true 保留之前的数据

3. 为啥没有更新?

query key 不匹配(比如 1 和 ‘1’ 是不一致的),或者 query client 引用不稳定(比如在 App 里创建的由于 re-render 或路由变更会导致引用不一致)

4. 为啥要用 useQueryClient 而不是 import+export 的方式引用 queryClient?因为更准确(比如多 query client 的场景)

5. 为啥请求失败而没有收到错误信息?因为 queryFn 没有返回 reject 的 Promise(比如用 fetch 时需在 res.ok 不 ok 时主动抛个错)。

参考:
Practical React Query | TkDodo’s blog

作者: 张熠
文章链接: https://crazyoctopusdan.github.io/2023/03/07/%E3%80%8AReact%20Query%20%E7%AC%94%E8%AE%B0%E3%80%8B/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.