《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