《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. 一些文档外的使用技巧
- 默认值的选择很好,但对于新手用户也会有时不时的措手不及,需要理解他们,
- 使用 React Query DevTools,会告诉你缓存的数据状态,同时也要结合 Chrome DevTool 的网络面板,因为开发模式请求通常比较快,
- 把
query key当useEffect的依赖数组使用,两者非常相似, - 通过传入
initialData可以让切换状态时先用缓存中的数据做预填充,提升用户体验,比如 todo app 的filter从全部切换到完成时的场景, - 保持服务端和客户端状态分离,
useQuery()得到的数据不要存本地 copy,以避免拿到老数据, enable配置项非常强大,可以做很多事,比如依赖另一个查询的数据、modal 打开时关闭数据轮询、等待用户输入时禁用、用户输入信息后禁用默认数据的查询等,- 不要尝试通过
queryClient.setQueryData修改query cache使他成为本地状态管理器,因为每次后台重新获取都可能覆盖他们, - 把
useQuery包起来用会有额外好处,比如 ui 和逻辑分离等。
3. 如果后端没有返回期望的数据格式,那就需要做格式化了。这时有多个选择,
- 后端转,缺点是不一定能实现,
queryFn里转,缺点是每次 fetch 时需要跑一遍,同时如果你有一个不能自由修改的 service 层可能行不通(不能通过 openapi 生成的),render函数中转,缺点是语法复杂,需借助 useMemo 提升性能,同时数据可能 undefined,- 用
select option转,没啥明显缺点。所以,相对来说,方案 4 会更好,最佳优化(调用次数最少)、同时允许部分订阅。
4. RQ 内置了一些渲染优化的能力。
notifyOnChangeProps配置,从版本 4 起默认开启track模式,跟踪你使用了哪些props,然后只在这些props变更后做notify,可选配置是all或String 数组,选String 数组要注意props的同步手动更新,避免出现该渲染没渲染然后使用过期数据的问题,- RQ 做了结构共享,比如
[{id:1,text:1},{id:2,text:2}],如果更新数据后只变更了 id 1,那通过useItems(/*id*/2)拿的数据会保持引用一致性,不会 notify 更新,详见 replaceEqualDeep 的测试。。
5. RQ 有 3 种状态:
success、error 和 loading(idle 在版本 4 里去掉了),以及另一个维度的 isFetching。
应该是怎样的状态检测顺序?通常大家会写 loading、error、data(即 success)这样的顺序,但这在 refetch 时,会导致的问题是,loading 或 error 状态时看不到数据。所以更推荐的判断顺序是 data、error、loading。
6. 如何做测试?
这里有个例子这里有个例子。
- 不要
mock fetch,用 MSW, - 给每个测试一个
QueryClientProvider并创建新的QueryClient,好处是完全隔离(备选方案是每次测试完清 QueryClient 的缓存,缺点是并行运行测试时可能会出错), - 自定义 hook 用 react-hooks-testing-library 做测试,
- 测试使用
useQuery的组件时记得用QueryClientProvider包一下,参考react-query内部的测试用例, - 设置
retry为false,否则可能会因为默认要做 3 次exponential backoff重试而导致超时,同时注意不要在useQuery里写死retry参数,改用queryClient.setQueryDefaults('todos', { retry: 5 })会对测试更友好, - 由于
useQuery是异步的,记得要加await waitFor(() => result.current.isSuccess)等待请求完成, - 如果命令行有太多日志,可以通过设置
QueryClient的logger参数避免冗余报错,logger: { error: () => {}, log: console.log, warn: console.warn: console.warn }
7. 关于类型。
useQuery有两种写法,泛型和类型推导,推荐后者,让queryFn返回正确的类型,剩下的全部走推导,注意这里会缺error的类型,写的时候可以用error instanceof Error确保下,useQuery加上enabled参数时就可能是 undefined 了,需在queryFn里处理 undefined 时抛错,- 使用
useInfiniteQuery时记得给queryFn的pageParam加类型, - 使用
default queryFn时要注意queryKey的参数类型。
8. RQ + WebSocket 有两种用法。
推模式,服务端推送数据,客户端通过
queryClient.setQueriesData更新数据,缺点是推送了并不需要的数据,比如你并不在需要数据的那个页面,拉模式,服务端只发送事件,告知客户端哪些数据更新了,然后客户端通过
queryClient.invalidateQueries让那些请求缓存失效,在需要的时候再去服务端拉数据。※个人建议无脑用第二种。同时要注意修改
staleTime为Infinity,WebSocket 会保证本地数据是最新的,无需通过staleTime设置过期时间。
9. query key 应该如何组织?
一些背景知识:
useQuery和useInfiniteQuery共享同一个缓存,所以 key 不能重复,- 重新获取不同参数的数据时,不能用
refetch(xxx),refetch不能重新获取相同 key 的数据,解法是给useQuery传不同的 key, - 与查询缓存交互有多种方法,比如
invalidateQueries和setQueriesData等。
那么如何高效地组织 query key?
- 用比如
['todos', 'list', { filter: 'all' }]这样的结构,好处是在invalidate或突变操作时会更灵活,比如同时操作所有的todos > list, - 使用
query key factory,避免手写容易导致的错误,比如todoKeys.all、todoKeys.detail(id)。
10. 和 query key 相关的是,queryFn 应该怎么写?
- 内联,比如
useQuery(['todos', p1, p2], () => fetchXXX(p1, p2)),缺点是key和fn里的参数必须同步更新,一旦遗漏比如忘记更新key里的参数,会导致请求数据不刷新, QueryFunctionContext,比如
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 预填充缓存数据有两种方法
placeholderData 和 initialData。
相同的是:
- 都会直接跳过
loading状态进入success状态, - 都支持值或者返回值的函数两种类型。
不同的是:
initialData在缓存层处理,直接放入缓存,尊重staleTime,一个query key只能由一个initialData,refetch错误时数据还在,placeholderData在观察者层处理,不放入缓存,用于临时占位,可以为相同的query key创建多个不同的placeholderData,refetch错误时数据消失。
12. RN 是啥?
他不是请求库,因为不内置 axios、fetch 之类的;他是异步的状态管理器,或者是服务端状态的同步工具。
在 RN 之前,通常有两种数据获取的方法:
- 一次获取、全局分发、很少更新;
- 每次获取(比如每次 modal 打开时都请求一遍数据)。这两种都不太好,前者更新太少,后者更新太多。RN 通过
Stale While Revalidate和机制的refetch机制来解这个问题。refetch触发时机包括refetchOnMount、refetchOnWindowFocus、refetchOnReconnect,以及手动的queryClient.invalidateQueries。同时可针对不同场景设置不同的 staleTime 来控制 refetch 频率。
13. RN 处理错误有 3 种方法。
useQuery返回值的isError或status === 'error'判断;onError回调,可以是query级,也可以是全局用queryCache配,query级的要当心避免多次触发;- 基于
Error Boundary,配置useErrorBoundary:true即可。
三种方法可以搭配使用,作者建议针对 refetch 错误用全局 onError + toast 显示,其他的用 1 或 3 的方法处理。另外,如果有 fetch,要在 queryFn 里处理 4xx 和 5xx 请求,他不像 axios 一样会 reject。
14. RN 通过 useMutation 实现突变(更新数据)。
和 useQuery 相同的是都会提供 loading、error、status 数据,也支持 onSuccess、onError 和 onSettled 回调。和 useQuery 不同的是,useQuery 是声明式,而 useMutation 是命令式,同时 useMutation 也不会共享状态。突变后要更新数据有三种思路,
invalidate query让他refetch,注意只有活跃的query会refetch;- 通过
queryClient.setQueryData直接更新,适用于知道全部数据的场景,但可能不安全,比如遇到排序、新增后新 id 数据时会变得很复杂; - 乐观更新,适用于小交互。一些常见问题:
onSuccess回调支持Promise,适用于比如你需要在invalidate操作完成前保持useMutation的loading状态时;useMutation有mutate和mutateAsync两个方法,通常更推荐mutate,因为mutateAsync要自行try...catch处理异常场景,mutateAsync的场景是有多个并行或串行依赖性突变时;useMutation fn只支持一个参数,可以用Object;useMutation和mutation都有回调,前者肯定会执行,适用于逻辑,后者组件销毁后不执行,适用于 UI。
15. 关于离线。
- 有一个
networkMode设置,可以选择三种模式:
online(默认模式,假设有网络时才能用,没网络会进入暂停状态)always(不关心网络状态,永远会启用查询,适用于数据获取以外的事)offlineFirst(总是会发出第一个请求,失败时进入暂停状态),
useQuery()会返回fetchStatus,包括fetching、paused、idle三种状态,可以和status结合使用,两者不互斥。
16. 关于表单。
- 作者推荐了
react-hook-form和 RN 搭配使用, - 最简方案是
useQuery请求数据作为默认值然后useMutation突变更新,但存在两个问题,data可能为空和没有后台更新,data可能为空壳通过提取组件的方式解,没有后台更新可通过受控组件+优先用受控组件的值的方式解, - 防止重复提交可基于
useMutation()返回的isLoading,给提交按钮加disabled={isLoading}来解, - 如果提交后没有跳转到其他页面,通常需要在
mutate之后invalidate query(更新数据)并 reset 表单。
17. RQ 和 React Router(简称 RR)6.4 的关系。
- RR 没有做
cache而 RQ 有,RR 是关于 when(时机)而 RQ 是关于 what(具体请求方案); - 只有 RR 可以做到提前获取,但问题是由于没有缓存从而会到请求过于频繁;
- RQ 和 RR 可结合使用,在
loader里通过queryClient.fetchQuery发起请求,组件里用useQuery正常拿数据即可,出于 ts 类型考虑默认值可以填useLoaderData的数据; - 如果用 RR 的
action做突变,redirect之前记得invalidate相关的query,同时invalidate可以根据场景选择是否await。
18. 关于请求瀑布流。
- 依赖类
query的瀑布流是避免不了的,比如/user/1/project依赖/user的数据,非依赖的写多个useQuery或者用useQueries解效果相同; Suspense会在Promise为pending状态时用fallback渲染,缺点是fallback时间过长从而影响渲染,渲染迟了进而影响子组件的数据请求,从而导致瀑布流;queryClient.prefetchQuery可以缓解这个问题,一种用法是放在组件外,可以在代码下载解析时即执行;useQueries暂不支持suspense;- 如果列表页包含详情页的所有数据,可以用列表页缓存的数据填充详情页缓存,这里也分拉和推两种方式。
FAQ。
1. 如何给 refetch 传递参数?
不能传递。
2. 如何做同一个 useQuery 但 key 变更时的过渡?
用 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
