react-query 核心基础

593 阅读8分钟

一、背景 & 简介

react-query 是一个基于react-hook的服务端状态管理库,负责管理服务器和客户端之间的异步操作。

  • 应用的状态按存储位置可分为两类:
    • 客户端状态:多数用于控制客户端的 UI 展示,存在于客户端。
    • 服务端状态:客户端通过异步请求获得的数据,存在于服务端。
  • 服务端状态有以下特点
    • 存储在远端,本地无法直接控制
    • 需要异步 API 来查询和更新
    • 可能在不知情的情况下,被另一个请求方更改了数据,导致数据不同步

常见的Redux,MobX,包括我们熟悉的dva都属于客户端状态管理库, 他们的作用有两个,一个是管理客户端状态、一个是管理从服务端获取的异步数据。但它们并不关心客户端是如何异步请求远端数据的,所以他们并不适合处理异步的、来自服务端的状态。

React Query 就是为了解决服务端状态带来的上述问题而出现的,除此之外它还带来了以下特性

  • 缓存网络请求,将所有的服务端状态维护在全局,并配合它的缓存策略来执行数据的存储和更新(同样的请求在一定时间内再此请求就会从缓存里读取,对服务器压力会小很多,用户体验也会更好
  • 比如真分页场景,点过某一页,就会缓存下来,同一个key相同的接口,第一次请求之后就会被缓存下来,其他页面再发网络请求页也会从缓存去取
  • 把对于相同数据的多个请求简化成一个
  • 更方便地控制缓存
  • 在后台更新过期数据
  • 知道数据什么时候会「过期」
  • 对于数据的变化尽可能快得做出响应
  • 分页查询和懒加载等请求性能优化
  • 管理服务器状态的内存和垃圾回收
  • 通过结构共享(structural sharing)来缓存查询结果

二、缓存过程

2.1 缓存的生命周期

  1. 有缓存数据和没有缓存数据的查询实例
  2. 后台重新获取
  3. 非活跃查询
  4. 垃圾回收

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和概念务必看一下

重要程度 ⭐️⭐️⭐️

    • 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)
        • 优点:更符合之前的使用习惯
        • 缺点:直接更新是更新缓存,如果有并发操作,可能会显示不正确
  • 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...'

写法二的特点:如果有一次请求成功,页面就始终有数据,除非缓存清空