一、背景 & 简介
react-query 是一个基于react-hook的服务端状态管理库,负责管理服务器和客户端之间的异步操作。
- 应用的
状态按存储位置可分为两类:客户端状态:多数用于控制客户端的 UI 展示,存在于客户端。服务端状态:客户端通过异步请求获得的数据,存在于服务端。
- 服务端状态有以下
特点:- 存储在远端,本地无法直接控制
- 需要异步 API 来查询和更新
- 可能在不知情的情况下,被另一个请求方更改了数据,导致数据不同步
常见的Redux,MobX,包括我们熟悉的dva都属于客户端状态管理库, 他们的作用有两个,一个是管理客户端状态、一个是管理从服务端获取的异步数据。但它们并不关心客户端是如何异步请求远端数据的,所以他们并不适合处理异步的、来自服务端的状态。
而 React Query 就是为了解决服务端状态带来的上述问题而出现的,除此之外它还带来了以下特性:
- 缓存网络请求,将所有的服务端状态维护在全局,并配合它的
缓存策略来执行数据的存储和更新(同样的请求在一定时间内再此请求就会从缓存里读取,对服务器压力会小很多,用户体验也会更好)
- 比如真分页场景,点过某一页,就会缓存下来,同一个key相同的接口,第一次请求之后就会被缓存下来,其他页面再发网络请求页也会从缓存去取
- 把对于相同数据的多个请求简化成一个
- 更方便地控制缓存
- 在后台更新过期数据
- 知道数据什么时候会「过期」
- 对于数据的变化尽可能快得做出响应
- 分页查询和懒加载等请求性能优化
- 管理服务器状态的内存和垃圾回收
- 通过结构共享(structural sharing)来缓存查询结果
二、缓存过程
2.1 缓存的生命周期
- 有缓存数据和没有缓存数据的查询实例
- 后台重新获取
- 非活跃查询
- 垃圾回收
2.2 一个缓存演示 🌰
前提:假设我们使用的默认 cacheTime(5 分钟) 和 默认 staleTime(0)
-
步骤一 使用 useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) 挂载一个实例,然后经历:
- 页面显示硬加载状态并发出网络请求以获取数据
- 当网络请求完成后,返回的数据将缓存在 ['todos'] 键下。``
- 在经历过 staleTime 时间(因为是0,所以是立即)将此数据标记为过期
- 理解下
staleTime,是指数据的失效时间,在此时间内,是不发网络请求的
- 理解下
-
步骤二 其他地方 也执行下这行代码 useQuery({ queryKey: ['todos'], queryFn: fetchTodos }),然后经历:
- 该数据会立即从缓存中返回(缓存中已经有来自第一个查询的 ['todos'] 键的数据)
- 新实例使用其 queryFn 触发新的网络请求
注意,无论两个 fetchTodos 查询函数是否相同,因为它们具有相同的查询键,所以两个查询的状态都会更新(包括 isFetching、isLoading 和其他相关值)
- 当请求成功完成时,['todos'] 键下的缓存数据将更新为新数据,并且两个实例都将更新为新数据
-
如果上面的两个实例都卸载了、不再使用了
- 经过cacheTime(5min)执行垃圾回收
-
如果在cacheTime(5min)内,又执行了一个 useQuery({ queryKey: ['todos'], queryFn: fetchTodos }),经历的过程同
步骤二 -
最后就是 实例卸载和垃圾回收
三、如何使用?
3.1 useQuery 查询 🌰
import { useQuery } from "react-query";
function Todos() {
const { status, data, error } = useQuery(["todos"], fetchTodoList);
if (status === "loading") {
return <span>Loading...</span>;
}
if (status === "error") {
return <span>Error: {error.message}</span>;
}
// `status ==='success'`,但是 “else” 逻辑也起作用
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
3.2 useMutation 修改 🌰
function App() {
const mutation = useMutation((newTodo) => axios.post("/todos", newTodo));
return (
<div>
{mutation.isLoading ? (
"Adding todo..."
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: "Do Laundry" });
}}
>
Create Todo
</button>
</>
)}
</div>
);
}
3.3 其他API
以上只是两个简单demo,具体使用方式和API以官网为准 ,以下重要的API和概念务必看一下
重要程度 ⭐️⭐️⭐️
- useQuery :发起单个请求
- useQueries:发起多个请求
- Mutations:用于创建/更新/删除数据或执行服务器命令
- 主动查询失效
-
- queryClient.invalidateQueries(); 使缓存中的每个查询都无效
- queryClient.invalidateQueries(["todos"]); // 使以
todos开头的键值的查询无效
-
- 缓存中的数据修改后,其他使用的地方可以获取最新的数据,而不用重新获取
- 使用 Query Client 的 setQueryData 方法(注意⚠️:这个方法只是直接改了缓存里数据,只要触发了相应查询,就会被最新数据覆盖掉)
-
- 修改queryClient中的 默认查询函数
重要程度 ⭐️⭐️
-
- 当重新获取功能没法正常工作时,可以回滚到上次到数据
- Suspense
- Custom Logger
- useQuery的 notifyOnChangeProps 选项
- 用于设置监听内容,决定重新渲染时机
- V4 默认开启,设置为“all”,意为关闭此功能
- 如果是默认开启,不建议使用解构写法,因为会监听所有fields变化,有一定性能开销
四、实践tips 🍮
- useQuery 和 useInfiniteQuery 不能使用相同key 参考
refetch用于——使用相同的参数重新获取- 如果同一个查询要使用不同参数,可以放到 Query key里,用state管理
- 虽然可以用字符串作key,但为了保持统一,建议始终使用
数组键 - key管理,建议维护一个多级文件
map, 以 [ {} ] 的形式返回,原因:- 可避免数组多空值解构出问题
- 可避免模糊匹配顺序问题
- 示例
const todoKeys = {
// ✅ 所以的key 都是只有object的数组
all: [{ scope: 'todos' }] as const,
lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
list: (state: State, sorting: Sorting) =>
[{ ...todoKeys.lists()[0], state, sorting }] as const,
}
const fetchTodos = async ({
// ✅ 解构 queryKey, 从中提取命名属性
queryKey: [{ state, sorting }],
}: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
const response = await axios.get(`todos/${state}?sorting=${sorting}`)
return response.data
}
export const useTodos = () => {
const { state, sorting } = useTodoParams()
return useQuery(todoKeys.list(state, sorting), fetchTodos)
}
-
通过queryFunctionContext 解构传参,可避免key值和参数不一致问题 原文
-
作为update操作之后,往往需要更新数据,有两种方式(
推荐使用第一种)- 一种是
直接失效(被动请求)- queryClient.invalidateQueries(['posts', id, 'comments']) // 写法简单,被动的方式更省心
- 一种是 直接更新
- queryClient.setQueryData(['posts', id], newPost)
- 优点:更符合之前的使用习惯
- 缺点:直接更新是更新缓存,如果有并发操作,可能会显示不正确
- queryClient.setQueryData(['posts', id], newPost)
- 一种是
-
useMutation 提供了两个方法(
推荐使用mutate)mutate——没有任何返回- 优点:内部做了error的catch,不用手动处理error
mutateAsync——返回promise- 特点:需要手动try catch,建议在真正需要返回promise的时候再使用
-
查询数据的时候显示loading还是上次的数据——
keepPreviousData
const { data, isPreviousData } = useQuery(
['item', id],
() => fetchItem({ id }),
// ⬇️ like this️
{ keepPreviousData: true }
)
- 更新的了数据但却没有显示,可能有两个原因
- 查下 queryKey 是否完全一致
- 保证 QueryClient 在 App 组件外部
五、版本要求 & 支持情况
- React v16.8+
- TypeScript 版本不小于 v4.1
- 默认类型推导
- 类型窄化
- 如果success为true,那么data一定有数据
- 错误字段的类型
- error 的类型默认是unknown
六、FQA(自问自答)
6.1 react-query只是接管了大部分客户端状态工具的能力,那非服务端交互的数据需要存全局的怎么办?
答:react-query 取代了客户端状态库(mobx,redux)的异步数据存储能力,这些通常可以覆盖绝大部分场景,但和客户端状态库的使用不冲突,有必要的话(如果是个微小的全局状态,继续使用一个客户端状态管理器值不值得?),是可以一起用的 参考
6.2 key可以设随机的吗?重复了会导致什么问题?
答:不能随机,必须唯一,React Query 用它来做缓存的键,重复会导致数据不对
6.3 窗口重新获得焦点都会重新查询,那弹窗的打开或者关闭每次是不是也会触发?
答:默认是的,但是可以通过useQuery的 enabled 选项置为false 以临时关闭自动查询 参考
6.4 isFetching和 isLoading 有什么区别
答:isFetching 派生于fetchStatus,用于描述 queryFn 是否处于查询状态。每当执行 queryFn 时为真,包括初始加载和后台重新获取。而isLoading 就是描述当前查询,没有缓存数据并且尚未完成查询尝试时为真。
七、思考
由于react-query的缓存机制,请求成功一次,数据就缓存下来,倘若第二次请求失败,其返回的结构会是既有error又有旧data的,像下面这样:
{
"status": "error",
"error": { "message": "Something went wrong" },
"data": [{ ... }]
}
因此引出一个问题,当再次请求失败时,是显示旧数据还是显示错误页面/提示?
我们就假设一个场景前提:第一次数据请求成功,切换浏览器tab,第二次请求,结果失败
- 写法一:显示错误页面写法
const todos = useTodos()
if (todos.isLoading) {
return 'Loading...'
}
// 先判断error
if (todos.error) {
return 'An error has occurred: ' + todos.error.message
}
return <div>{todos.data.map(renderTodo)}</div>
写法一可能存在的问题:由于失败尝试机制的存在,可能失败尝试、1次、2次、3次,2秒过去了,再显示错误页面,体验会差一点
- 写法二:有数据就显示,不管新的还是旧的
const todos = useTodos()
// 先验证data里有没有数据
if (todos.data) {
return <div>{todos.data.map(renderTodo)}</div>
}
if (todos.error) {
return 'An error has occurred: ' + todos.error.message
}
return 'Loading...'
写法二的特点:如果有一次请求成功,页面就始终有数据,除非缓存清空