《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 key 当 useEffect 的依赖数组使用,两者非常相似,
  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,可选配置是 all 或 String 数组,选 String 数组 要注意 props 的同步手动更新,避免出现该渲染没渲染然后使用过期数据的问题,
  2. 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. 如何做测试?

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

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

8. RQ + WebSocket 有两种用法。

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

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

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

9. query key 应该如何组织?

一些背景知识:

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

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

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

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

  1. 内联,比如 useQuery(['todos', p1, p2], () => fetchXXX(p1, p2)),缺点是 key 和 fn 里的参数必须同步更新,一旦遗漏比如忘记更新 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 预填充缓存数据有两种方法

placeholderData 和 initialData。

相同的是:

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

不同的是:

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

12. RN 是啥?

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

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

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

  1. useQuery 返回值的 isError 或 status === '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 相同的是都会提供 loading、error、status 数据,也支持 onSuccess、onError 和 onSettled 回调。和 useQuery 不同的是,useQuery 是声明式,而 useMutation 是命令式,同时 useMutation 也不会共享状态。突变后要更新数据有三种思路,

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

15. 关于离线。

  1. 有一个 networkMode 设置,可以选择三种模式:
  • online(默认模式,假设有网络时才能用,没网络会进入暂停状态)
  • always(不关心网络状态,永远会启用查询,适用于数据获取以外的事)
  • offlineFirst(总是会发出第一个请求,失败时进入暂停状态),
  1. useQuery() 会返回 fetchStatus,包括 fetching、paused、idle 三种状态,可以和 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 会在 Promise 为 pending 状态时用 fallback 渲染,缺点是 fallback 时间过长从而影响渲染,渲染迟了进而影响子组件的数据请求,从而导致瀑布流;
  3. queryClient.prefetchQuery 可以缓解这个问题,一种用法是放在组件外,可以在代码下载解析时即执行;
  4. useQueries 暂不支持 suspense;
  5. 如果列表页包含详情页的所有数据,可以用列表页缓存的数据填充详情页缓存,这里也分拉和推两种方式。

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

作者: 张熠
文章链接: 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.