React-Query-状态管理-二-

90 阅读1小时+

React Query 状态管理(二)

原文:zh.annas-archive.org/md5/7a61313ab658bb102a1a310c5d41c0f9

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:使用 React Query 执行数据突变

在构建应用程序时,你并不总是需要获取数据。有时,你可能想要创建、更新或删除数据。在这些操作中,你的服务器状态将需要改变。

React Query 允许你通过使用突变来更改你的服务器状态。要执行突变,你可以利用 React Query 的另一个自定义钩子,称为useMutation

在本章中,你将介绍useMutation钩子,并了解 React Query 如何允许你创建、更新和删除你的服务器状态。类似于第四章,在这个过程中,你将了解你在突变中使用的所有默认值。你还将了解一些可以用来改进你的useMutation体验的选项。

一旦你熟悉了useMutation,你将了解如何利用它的一些选项来执行一些副作用模式,例如手动更新数据或强制查询在执行突变后更新。

在本章结束时,我们将把到目前为止所学的一切整合起来,并将其应用于做一些可能显著提高用户体验的事情:乐观更新。

在本章中,我们将涵盖以下主题:

  • useMutation是什么以及它是如何工作的?

  • 突变后的副作用模式

  • 执行乐观更新

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址为github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_6

useMutation是什么以及它是如何工作的?

到现在为止,你必须已经意识到突变允许你对服务器状态进行更新。这些更新可以是创建数据、删除数据或编辑数据等操作。

为了让你能够在服务器数据上执行突变,React Query 创建了一个名为useMutation的钩子。

现在,与默认情况下会自动运行查询的useQuery不同,useMutation只有在调用从钩子实例化返回的一个函数(称为mutate)时才会运行你的突变。

要使用useMutation钩子,你必须像这样导入它:

import { useMutation } from '@tanstack/react-query';

一旦导入,你就可以用它来定义你的突变。以下是useMutation的语法:

const mutation = useMutation({
    mutationFn: <InsertMutationFunction>
})

如您从前面的代码片段中看到的,useMutation钩子只需要一个必需参数才能工作,即突变函数。

突变函数是什么?

突变函数是一个返回负责执行异步任务的 promise 的函数。在这种情况下,这个异步任务将是我们的突变。

我们之前看到的与查询函数相同的原理也适用于 mutation 函数。这意味着,正如我们看到的查询函数一样,由于这个函数只需要返回一个 promise,它再次允许我们使用我们选择的任何异步客户端。这意味着 REST 和 GraphQL 仍然受支持,所以如果你愿意,你可以同时使用这两个选项。

现在我们来看一个使用 GraphQL 和 REST 的 mutation 函数的示例。这些 mutation 函数将被用来在我们的服务器状态中创建新用户:

使用 GraphQL 的 mutation

import { useMutation } from "@tanstack/react-query";
import { gql, GraphQLClient } from "graphql-request";
const customMutation = gql`
mutation AddUser($user: String!, $age: Int!) {
  insert_user(object: { user: $user, age: $age }) {
    user
    age
  }
}
`
const createUserGQL = async (user) => {
  const endpoint = <add_endpoint_here>;
  const client = new GraphQLClient(endpoint)
  return client.request(customMutation, user);
  return data;
};
 ...
const mutation = useMutation({
    mutationFn: createUserGQL
  });

前面的代码片段展示了使用 React Query 创建 GraphQL mutation 的示例。以下是我们的操作:

  1. 我们首先创建我们的 GraphQL mutation,并将其分配给我们的customQuery变量。

  2. 然后我们创建createUserGQL函数,这个函数将成为我们的 mutation 函数。这个函数也将接收作为参数的user数据,这些数据将被我们的 mutation 用于在服务器上创建数据。

  3. 在我们的useMutation钩子中,我们将createUserGQL函数作为 mutation 函数传递给钩子。

让我们看看如何使用 REST 来完成这个操作:

使用 REST 的 mutation

import axios from "axios";
import {useMutation} from "@tanstack/react-query";
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
 …
const mutation = useMutation({
    mutationFn: createUser
  });

在前面的代码片段中,我们可以看到一个使用 React Query 创建 REST 的 mutation 的示例。以下是我们的操作:

  1. 我们首先创建createUser函数,这个函数将成为我们的 mutation 函数。这个函数将接收作为参数的user数据,这些数据用于我们的 mutation 在服务器上创建数据。在这里,我们知道我们将使用POST方法在服务器上创建数据。

  2. 在我们的useMutation钩子中,我们将createUser函数作为 mutation 函数传递给钩子。

在前面的例子中,我们使用了axios,但如果你更喜欢使用fetch而不是axios,你只需要在createUser函数内部将axios替换为fetch,并对fetch进行必要的修改以使其工作。以下是一个使用fetch的示例:

const createUserFetch = async (user) => {
  return fetch
    (`https://danieljcafonso.builtwithdark.com/name-api`, {
    method: "POST",
    body: JSON.stringify(user),
    headers: {
      "Content-type": "application/json; charset=UTF-8",
    },
  });
};
const mutation = useMutation({
    mutationFn: createUserFetch
});

在前面的代码片段中,我们可以看到之前展示的createUser函数的示例,但这次我们使用了fetch而不是axios

现在我们已经熟悉了 mutation 函数,我们需要了解useMutation钩子如何利用这个函数来允许我们执行 mutations。在下一节中,我们将学习mutate函数如何使我们能够这样做,以及useMutation返回的其他内容。

useMutation 返回什么?

useQuery一样,当使用useMutation钩子时,它返回几个值。

如本章前面所述,要执行 mutations,我们需要利用mutate。现在,mutate不是执行 mutations 的唯一方式,也不是useMutation返回的唯一内容。

在本节中,我们将回顾useMutation钩子的以下返回值:

  • mutate

  • mutateAsync

  • 数据

  • 错误

  • 重置

  • 状态

  • isPaused

mutate

在使用你的 useMutation 钩子创建你的变更后,你需要一种方法来触发它。mutate 是你几乎每次都需要用来做到这一点的函数。

这就是如何使用 mutate

const { mutate } = useMutation({
    mutationFn: createUser
  });
mutate({ name: "username", age: 25 })

在这个代码片段中,我们做以下操作:

  1. 我们从 useMutation 钩子中解构出我们的 mutate 函数。

  2. 我们使用 mutate 函数并传递变量,这些变量是我们期望 mutate 函数接收以执行我们的变更。

就这样;这就是你如何使用 React Query 来执行变更。你创建你的变更函数,将其传递给你的 useMutation 钩子,从其中解构出 mutate,并使用所需的参数调用它以执行你的变更。

现在,前面的代码片段旨在展示如何通过使用 mutate 来触发变更,但这不是一个非常实用的例子。为了帮助你构建如何使用 mutate 来执行变更的心理模型,你可以参考以下代码片段:

import axios from "axios";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们可以看到一个使用受控组件的简单表单示例。这就是前面代码片段中发生的事情:

  1. 我们创建一个 createUser 变更函数,它将接收一个包含一些数据的 user 对象。

在这个函数内部,我们返回 axios 客户端的 post 方法的调用,这将返回 useMutation 期望接收的用于变更函数的承诺。

  1. 在我们的 SimpleMutation 组件内部,我们做以下操作:

    1. 我们创建一个状态变量来控制输入的状态。

    2. 我们使用 createUser 函数作为变更函数来创建我们的变更,并从其中解构出 mutate

    3. 我们创建一个 submitForm 函数。这个函数将接收表单的事件并阻止其传播,这样你的页面就不会刷新。在处理事件后,它通过调用 mutate 触发变更,并将 name 状态变量作为 user 对象的一部分传递。

  2. 在我们的表单内部,我们创建我们的输入来处理 name 并让 React 控制其状态。

  3. 我们创建一个带有 onClick 事件的按钮来触发我们的 submitForm 函数。

正如你应该从前面的解释和代码中理解的那样,每当我们点击使用当前输入值的 POST 请求到我们的 URL 时。

在继续本章的过程中,你还会看到 mutate 也可以接收一些选项来执行副作用,如果你需要的话。但让我们把这些细节留到以后再说。

虽然 mutate 是在 React Query 中执行变更的基础,但如果你愿意,你也可以使用另一个函数:mutateAsync

mutateAsync

在大多数情况下,你会使用 mutate,但有时你可能想访问包含你变更结果的承诺。在这些情况下,你可以使用 mutateAsync

在使用 mutateAsync 时,需要注意的一点是你需要自己处理承诺。这意味着在错误场景中,你需要捕获错误。

这就是如何使用 mutateAsync 函数:

const { mutateAsync } = useMutation({
  mutationFn: createUser,
});
try {
  const user = await mutateAsync({ name: "username", age:
    25 });
} catch (error) {
  console.error(error);
}

在前面的代码片段中,我们从useMutation钩子中解构了mutateAsync函数:

  • 我们需要处理潜在的错误场景,因此我们将mutateAsync调用用try-catch语句包裹起来。由于这是一个异步函数,我们必须等待数据返回。

  • 如果出现错误,我们会捕获它并在我们的控制台中显示错误。

前面的代码片段显示了如何使用mutateAsync触发突变;正如我们在mutate中所示,这似乎不是一个非常实用的例子。为了帮助你创建如何使用mutateAsync执行突变的心理模型,你可以看到以下代码片段:

const ConcurrentMutations = () => {
  const [name, setName] = useState("");
  const { mutateAsync: mutateAsyncOne } = useMutation({
    mutationFn: createUser,
  });
  const { mutateAsync: mutateAsyncTwo } = useMutation({
    mutationFn: registerUser,
  });
  const submitForm = async (e) => {
    e.preventDefault()
    const mutationOne = mutateAsyncOne({ name })
    const mutationTwo = mutateAsyncTwo({ name })
     try {
      const data = await Promise.all([mutationOne,
        mutationTwo]);
      // do something with data
    } catch (error) {
      console.error(error);
    }
  }
  return (
    <div>
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们可以看到一个使用受控组件的简单表单示例,其中我们利用mutateAsync执行并发突变。这就是代码中发生的事情:

  1. 我们创建一个状态变量来控制输入的状态。

  2. 我们使用createUser函数作为突变函数来创建我们的第一个突变,并从其中解构mutateAsyncmutateAsyncOne

  3. 我们使用registerUser函数作为突变函数来创建我们的第二个突变,并从其中解构mutateAsyncmutateAsyncTwo

  4. 我们创建一个submitForm函数:

    1. 这个函数将接收来自表单的事件并阻止其传播,这样你的页面就不会刷新。

    2. 我们将调用mutationAsyncOne并传递name作为参数返回的承诺分配给我们的mutationOne变量。

    3. 我们将调用mutationAsyncTwo并传递name作为参数返回的承诺分配给我们的mutationTwo变量。

    4. 我们利用Promise.all方法并将其传递给我们的mutationOnemutationTwo承诺,以便它们可以并发执行。

  5. 在我们的表单内部,我们创建输入来处理我们的名称,并让 React 控制其状态。

  6. 我们创建一个带有onClick事件的按钮来触发我们的submitForm函数。

现在,你已经熟悉了如何执行突变,让我们回顾一下受突变成功影响的变量,data

data

这个变量是突变函数返回的最后成功解析的data

这里是如何使用data变量的示例:

const SimpleMutation = () => {
  const { mutate, data } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {data && <p>{data.data.user}</p>}
      ...
    </div>
  );
}

在这个代码片段中,我们做了以下操作:

  1. 我们从useMutation钩子中解构了我们的data变量。

  2. 在我们的组件返回时,我们检查是否已经从我们的突变中获取了data。如果是,我们就渲染它。

当钩子最初渲染时,这个data将是未定义的。一旦突变触发并完成执行,并且突变函数返回的承诺成功解析了我们的数据,我们就可以访问data。如果由于某种原因突变函数的承诺被拒绝,我们可以使用下一个变量,即error变量。

error

error变量让你可以访问突变函数返回的失败后的error对象。

这里是如何使用error变量的示例:

const SimpleMutation = () => {
  const { mutate, error } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {error && <p>{error.message}</p>}
  ...
    </div>
  );
};

在前面的代码片段中,我们做了以下操作:

  1. 我们从useQuery钩子中解构我们的error变量。

  2. 在我们的组件返回时,我们检查是否有任何错误。如果有,我们将渲染错误信息。

当钩子最初渲染时,error值将是 null。如果在突变之后,由于某种原因突变函数拒绝并抛出错误,那么这个错误将被分配给我们的error变量。在这里重要的是要提到,这仅适用于你使用mutate的情况。如果你使用mutateAsync,你必须自己捕获错误并处理它。

当使用error变量时,有时为了用户体验,你可能想要清除你的错误。在这些情况下,reset函数将成为你的最佳选择。

reset

reset函数允许你将errordata重置到它们的初始状态。

这个函数在你需要在运行突变后清除当前数据或错误值时很有用。

这是你可以使用reset函数的方法:

const SimpleMutation = () => {
  const { mutate, data, error, reset } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {error && <p>{error.message}</p>}
        {data && <p>{data.data.user}</p>}
        <button onClick={() => reset()}>Clear errors and
          data</button>
        ...
   </div>
  );
};

在这个片段中,我们做了以下操作:

  1. 我们从useMutation钩子中解构dataerror变量和reset函数。

  2. 在我们的组件返回时,我们检查是否已经从我们的突变中获得了数据或错误。当且仅当我们这样做时,我们将渲染它们。

  3. 我们还渲染了一个带有onClick事件的按钮。当点击这个按钮时,它将触发我们的reset函数来清除我们的dataerror值。

现在,为了使用errordata变量,我们只需在代码中检查它们是否已定义,以便我们可以渲染它们。为了使这更容易,并且再次帮助你为你的应用程序制作更好的用户体验,你可以求助于使用status变量。

status

就像查询一样,当执行突变时,突变可以经过几个状态。这些状态帮助你向用户提供更多反馈。为了知道你的突变当前的状态,我们创建了status变量。

status变量可以具有以下状态:

  • idle:这是你的突变在执行之前的初始状态。

  • loading:这表示你的突变是否正在执行。

  • error:这表示在执行最后一个突变时出现了错误。每当这是状态时,error属性将接收从突变函数返回的错误。

  • success:你的最后一个突变是成功的,并且它已经返回了数据。每当这是状态时,data属性将接收从突变函数返回的成功数据。

这是你可以使用status变量的方法:

 const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, status, error, data } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {status === "idle" && <p> Mutation hasn't run </p>}
      {status === "error" && <p> There was an error:
        {error.message} </p>}
      {status === "success" && <p> Mutation was successful:
        {data.name} </p>}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button disabled={status === "loading"}
          onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的片段中,我们正在利用status变量来为我们的用户提供更好的用户体验。以下是我们在做些什么:

  1. 我们创建了一个状态变量来处理我们的受控表单。

  2. 我们创建我们的突变并从useMutation钩子中解构status

  3. 我们创建了一个submitForm函数来处理我们的突变提交。

  4. 我们利用我们的status变量在我们的组件返回中执行以下操作:

    1. 如果statusidle,我们将渲染一条消息,让用户知道我们的突变尚未运行。

    2. 如果status等于error,我们必须解构我们的error变量并显示错误消息。

    3. 如果status等于success,我们必须解构我们的data变量并将其显示给我们的用户。

    4. 如果status等于loading,这意味着我们正在执行一个突变,因此我们使用这个选项来确保我们禁用我们的添加按钮,避免在突变运行期间用户再次点击它。

现在,您知道了如何使用status变量。为了方便,React Query 还引入了一些布尔变体,以帮助识别每个状态。它们如下所示:

  • isIdle:您的status变量处于空闲状态

  • isLoading:您的status变量处于加载状态

  • isError:您的status变量处于错误状态

  • isSuccess:您的status变量处于成功状态

让我们现在重写我们之前的代码片段,利用我们的status布尔变体:

 const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, isIdle, isError, isSuccess, isLoading,
    error, data } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {isIdle && <p> Mutation hasn't run </p>}
      {isError && <p> There was an error: {error.message}
        </p>}
      {isSuccess && <p> Mutation was successful:
        {data.name} </p>}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={€ => setName(e.target.value)}
          value={name}
        />
        <button disabled={isLoading} onClick={submitForm}>
          Add</button>
      </form>
    </div>
  );
};

如您所见,代码是相似的。我们只需在解构部分将我们的status变量替换为isLoadingisErrorisSuccessisIdle,然后在相应的状态检查中使用这些变量。

与查询不同,突变没有fetchStatus变量。这并不意味着您的突变不能因突然断开互联网连接而受到影响。为了给用户提供更多反馈,我们创建了isPaused变量。

isPaused

如您应从第四章中记住,React Query 引入了一个名为networkMode的新属性。当在线模式下使用时,您可以在useMutation钩子中访问一个新变量,称为isPaused

这个布尔变量标识您的突变是否因断开连接而当前暂停。

让我们看看如何使用isPaused变量:

const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, isPaused } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = € => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {isPaused && <p> Waiting for network to come back </p>}
      <form>
        <input
          na"e="n"me"
          typ"={"t"xt"}
          onChang€(e) => setName(e.target.value)}
          value={name}
        />
        <button disabled={isPaused} onClick={submitForm}>
          Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们利用isPaused变量来在我们的应用程序中创建更好的用户体验:

  1. 我们从useMutation钩子中解构我们的isPaused变量。

  2. 在我们的组件返回中,我们检查isPaused是否为true。如果是,我们渲染一条消息让我们的用户知道。我们还将其分配给禁用我们的添加按钮,以避免用户意外触发另一个突变。

现在我们知道了useMutation钩子返回的一些值,让我们看看我们如何使用一些选项来自定义这个钩子。

常用突变选项解释

useQuery钩子类似,我们可以向useMutation钩子传递比其突变函数更多的选项。这些选项也将帮助我们创建更好的开发者和用户体验。

在本节中,我们将看到一些更常见且非常重要的选项。

这里是我们将要查看的选项:

  • cacheTime

  • mutationKey

  • retry

  • retryDelay

  • onMutate

  • onSuccess

  • onError

  • onSettled

cacheTime

cacheTime选项是您的缓存中不活跃数据在内存中保持的时间(以毫秒为单位)。一旦这个时间过去,数据将被垃圾回收。请注意,这与查询的方式不同。如果您执行了一个突变,返回的数据将被缓存,但如果在突变挂起期间再次执行相同的突变,useMutation将不会返回之前的突变数据。在突变中,此选项主要用于防止之前的突变数据无限期地保留在MutationCache中。

下面是如何使用cacheTime选项:

useMutation({
  cacheTime: 60000,
});

在这个代码片段中,我们定义了在突变不活跃一分钟之后,数据将被垃圾回收。

mutationKey

有时您会想通过利用您的queryClientsetMutationDefaults来为所有突变设置一些默认值。

mutationKey选项允许 React Query 知道是否需要将之前配置的默认值应用于此突变。

下面是如何使用mutationKey选项:

useMutation({
  mutationKey: ["myUserMutation"],
});

在前面的代码片段中,我们使用["myUserMutation"]作为突变键创建了一个突变。如果为任何具有["myUserMutation"]作为突变键的突变配置了默认值,它们现在将被应用。

重试

retry选项是一个值,表示当突变失败时,您的突变是否会重试。当为true时,它会重试直到成功。当为false时,它不会重试。

此属性也可以是一个数字。当它是一个数字时,突变将重试指定次数。

默认情况下,React Query 不会在出错时重试突变。

下面是如何使用retry选项:

useMutation({
  retry: 2,
});

在这个代码片段中,我们将retry选项设置为2。这意味着当执行突变失败时,此钩子将重试执行突变两次。

retryDelay

retryDelay选项是在下一次重试尝试之前应用的延迟(以毫秒为单位)。

默认情况下,React Query 使用指数退避延迟算法来定义重试之间的时间间隔。

下面是如何使用retryDelay选项:

useMutation({
  retryDelay: (attempt) => attempt * 2000,
});

在代码片段中,我们定义了一个线性退避函数作为我们的retryDelay选项。每次重试时,此函数都会接收尝试次数并将其乘以 2,000。这意味着每次重试之间的时间将增加两秒。

onMutate

onMutate选项是一个在您的突变函数被触发之前会调用的函数。此函数还会接收您的突变函数将接收的变量。

您可以从这个函数返回值,这些值将被传递到您的onErroronSettled回调函数中:

useMutation({
  onMutate: (variables) => showNotification("Updating the
    following data:", variables),
});

在这个代码片段中,我们将一个箭头函数传递到我们的onMutate选项中。当我们的突变被触发时,分配给onMutate选项的函数将使用这些变量调用,然后我们使用这些变量向用户显示有关挂起突变的提示。

onSuccess

onSuccess选项是一个函数,当你的突变成功时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onSuccess选项的方法:

useMutation({
  onSuccess: (data) => console.log("mutation was
    successful", data),
});

在代码片段中,我们向onSuccess选项传递一个箭头函数。当我们的突变成功执行时,分配给onSuccess选项的此函数将使用我们的数据被调用。然后我们使用这些数据在我们的控制台中记录一条消息。

onError

onError选项是一个函数,当你的突变失败时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onError选项的方法:

useMutation({
  onError: (error) => console.log("mutation was
    unsuccessful", error.message),
});

在代码片段中,我们向onError选项传递一个箭头函数。当突变失败时,分配给onError选项的此函数将使用抛出的错误被调用。然后我们在我们的控制台中记录错误。

onSettled

onSettled选项是一个函数,当你的突变成功或失败时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onSettled选项的方法:

useMutation({
  onSettled : (data, error) => console.log("mutation has
    settled"),
});

在代码片段中,我们向onSettled选项传递一个箭头函数。当突变成功或失败时,分配给onSettled选项的此函数将使用抛出的错误或解决的数据被调用。然后我们在我们的控制台中记录一条消息。

到目前为止,你应该已经熟悉了useMutation钩子的用法,并且应该能够开始使用它来创建、更新或删除你的服务器状态数据。现在,让我们看看我们如何利用这个钩子和一些选项来执行一些常见的副作用模式。

在突变之后执行副作用模式

当你阅读这个标题时,你可能想知道你是否以前见过如何在突变之后执行副作用。答案是肯定的,你已经做到了。要执行突变后的副作用,你可以利用这些选项中的任何一个:

  • onMutate

  • onSuccess

  • onError

  • onSettled

现在,你可能还没有看到如何利用这些副作用来做一些可能改善用户体验的惊人事情,比如执行多个副作用、重新获取查询,甚至在突变后更新查询数据。

在本节中,我们将回顾一些我们如何利用useMutation钩子的回调函数以及更多内容来执行之前提到的副作用。

如何执行额外的副作用

在开发过程中,可能会出现一个场景,如果你能够执行两个 onSuccess 回调将会很有用。现在,你当然可以在你的 useMutation 钩子回调中添加你想要的任何逻辑,但如果你想要拆分逻辑或者只在一个单独的变异上执行这个特定的逻辑呢?这确实很有用,因为你可以分离关注点和逻辑。嗯,你当然可以做到!

mutate 函数允许你创建自己的回调函数,这些函数将在你的 useMutation 回调之后执行。

你只需要意识到你的 useMutation 回调先执行,然后是你的 mutate 函数回调。这一点很重要,因为有时候如果你在 useMutation 回调中做了某些导致钩子卸载的操作,你的 mutate 函数回调可能不会执行。

下面是一个如何使用 mutate 回调函数的例子:

 const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      showToast(`${data.data.name} was created
        successfuly`)
    }
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name }, {
      onSuccess: (data) => {
        const userId = data.data.userID
        goToRoute(`/user/${userId}`)
      }
    })
  }
  ...

在前面的代码片段中,我们利用 mutate 回调函数来执行一些额外的副作用。以下是我们的操作:

  1. 我们使用 useMutation 创建我们的变异。

  2. 在这个变异内部,我们利用 onSuccess 回调,它将接收解析后的数据并向用户显示一个提示,告诉他们数据已被创建。

  3. 我们随后创建一个 submitForm 函数,该函数将在我们的代码中的某个 onSubmit 事件中被提供。

  4. 当被触发时,这个函数将阻止接收的事件传播。

  5. 这个函数将通过调用 mutate 来触发我们的变异。在这个 mutate 中,我们利用它的 onSuccess 回调来触发路由更改。

现在我们知道了如何使用 mutate 回调函数来执行一些额外的副作用,让我们看看如何在执行变异后重新触发查询。

如何在变异后重新触发查询的重新获取

当执行将改变你当前向用户显示的查询数据的变异时,建议你重新获取该查询。这是因为,在这个时候,你知道数据已经改变,但如果你的查询仍然被标记为内部新鲜,React Query 不会重新获取它;因此,你必须自己来做。

在阅读了前面的两个章节后,当你阅读这个标题时,你肯定会有所思考,那就是查询无效化!

下面是如何利用 onSuccess 回调来重新触发查询的重新获取:

  const queryClient = useQueryClient()
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["allUsers"],
      })
    }
  });

在前面的代码片段中,我们利用 onSuccess 回调在变异成功后重新触发查询。以下是我们的操作:

  1. 我们可以访问 queryClient

  2. 我们使用 ["allUsers"] 作为查询键创建我们的查询。

  3. 我们创建我们的变异。在这个变异的 onSuccess 回调中,我们利用我们的 queryClientinvalidateQueries 方法来触发带有 ["allUsers"] 作为查询键的查询的重新获取。

如本节开头所述,这是一个推荐的做法,每次你正在变异用户在页面上看到的数据时,都应该这样做。现在,你可能正在想:如果我们的变异是成功的,它可能已经返回了新数据,所以我们不能只是手动更新我们的查询数据并避免额外的请求吗?

如何在变异后更新我们的查询数据

你当然可以手动更新你的查询数据。你所需要的是访问queryClient以及你想要更新的查询的查询键。

虽然这可能在用户端节省一些带宽,但它并不能保证你最终显示给用户的数据是准确的。如果其他人使用相同的应用程序更改了你的数据怎么办?

现在,如果有保证没有其他人能够更新这个服务器状态,那么请随意尝试。但请确保你的查询在某个地方重新获取,以确保所有数据都是最新的。

这是如何在成功变异后更新你的查询数据的方法:

 const queryClient = useQueryClient()
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      const user = data.data
      queryClient.setQueryData(["allUsers"], (prevData) =>
        [user, ...prevData]);
    }
  });

在之前的代码片段中,我们利用onSuccess回调来更新我们的查询数据,并避免重新获取它。以下是我们的操作:

  1. 我们获取对queryClient的访问权限。

  2. 我们使用["allUsers"]作为查询键创建我们的查询。

  3. 我们创建我们的变异。在这个变异的onSuccess回调中,我们利用queryClientsetQueryData函数来手动更新带有["allUsers"]作为查询键的查询数据。

  4. 在这次更新中,我们创建了一个新数组,该数组结合了我们创建的数据和之前的数据,以创建新的查询数据。

如你所见,你可以应用一些模式来改善在执行变异后的用户体验。现在,当经常提到变异时,每次都会出现一个话题,这个话题将结束本章:乐观更新!

执行乐观更新

正如我们在第二章中看到的,乐观更新是在一个正在进行的变异期间使用的一种模式,我们更新我们的 UI 以显示变异完成后将如何显示,尽管我们的变异尚未被确认完成。

好吧,React Query 允许你执行乐观更新,并且这使得它变得极其简单。你所需要做的就是使用我们在上一节中看到的回调函数。

这是如何使用useMutation钩子执行乐观更新的方法:

import axios from "axios";
import { useQuery, useMutation, useQueryClient } from
  "@tanstack/react-query";
import { useState } from "react";
const fetchAllData = async () => {
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/name-api`
  );
  return data;
};
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
const Mutation = () => {
  const queryClient = useQueryClient();
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const mutation = useMutation({
    mutationFn: createUser,
    onMutate: async (user) => {
      await queryClient.cancelQueries({
        queryKey: ["allUsers"],
      });
      const previousUsers = queryClient.getQueryData({
        queryKey: ["allUsers"],
      });
      queryClient.setQueryData(["allUsers"], (prevData) =>
        [user, ...prevData]);
      return { previousUsers };
    },
    onError: (error, user, context) => {
      showToast("Something went wrong...")
      queryClient.setQueryData(["allUsers"], context.
        previousUsers);
    },
    onSettled: () =>
      queryClient.invalidateQueries({
        queryKey: ["allUsers"],
      }),
  });
  return (
    <div>
     {data?.map((user) => (
        <div key={user.userID}>
          Name: {user.name} Age: {user.age}
        </div>
      ))}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <input
          name="number"
          type={"number"}
          onChange={(e) => setAge(Number(e.target.value))}
          value={age}
        />
        <button
          type="button"
          onClick={(e) => {
            e.preventDefault()
            mutation.mutate({ name, age })
          }}
        >
          Add
        </button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们将我们对变异的知识应用到实践中,以创建更好的用户体验。以下是我们的操作:

  1. 我们为我们的代码定义所需的导入。

  2. 创建fetchAllData查询函数。这个函数将触发一个GET请求到我们的端点以获取用户数据。

  3. 创建createUser变异函数。这个函数将接收用户并执行一个POST请求到我们的端点以创建它。

  4. 在我们的Mutation组件内部,我们执行以下操作:

    1. 我们获取对queryClient的访问权限。

    2. 创建姓名和年龄输入的状态变量及其相应的设置器。

    3. 使用["allUsers"]作为查询键和fetchAllData作为查询函数创建我们的查询。

    4. 使用createUser作为突变函数创建我们的突变。在这个突变内部,我们定义了一些回调:

      1. onMutate回调中,我们执行乐观更新:

        • 我们确保取消任何针对我们的查询(以["allUsers"]作为查询键)的正在进行中的查询。为此,我们使用我们的queryClient cancelQueries方法。

        • 我们将之前缓存的数据保存在["allUsers"]查询键下,以防需要回滚。为此,我们利用我们的queryClient getQueryData函数。

        • 我们通过合并我们的新数据与我们的旧数据,并更新["allUsers"]查询键下的缓存数据来执行乐观更新。为此,我们利用我们的queryClient setQueryData函数。

        • 如果需要回滚,我们返回我们的previousUsers数据。

      2. onError回调中,如果发生错误,我们需要回滚我们的数据:

        • 作为一种良好的实践,我们让我们的用户知道我们的突变操作出现了问题。在这种情况下,我们显示一个吐司通知。

        • 为了进行回滚,我们访问我们的上下文参数,并利用onMutate回调返回的previousUsers数据。然后我们使用这个变量来覆盖["allUsers"]查询键下的缓存数据。为此,我们使用我们的queryClient setQueryData函数。

      3. onSettled回调中,当我们的突变完成时,我们需要重新获取我们的数据:

        • 为了重新获取我们的数据,我们利用我们的queryClient invalidateQueries并使用["allUsers"]作为查询键来使查询无效。
      4. 在我们的组件返回中,我们创建一个div元素,如下所示:

    • 我们使用查询的data变量来显示用户的资料。

    • 我们使用我们的姓名和年龄输入创建受控表单。

    • 我们还创建了一个按钮,当按下时,会触发它的onClick事件,从而触发我们的带有姓名和年龄值的突变。

看过你如何构建乐观更新后,以下是我们的创建的乐观更新的流程:

  1. 我们的组件渲染,我们的查询获取我们的数据并将其缓存。

  2. 当我们点击添加按钮时,查询返回的数据会自动更新,包括新用户,并且立即在 UI 上反映这一变化。

  3. 如果发生错误,我们将回滚到之前的数据。

  4. 当我们的突变完成时,我们重新获取我们刚刚执行乐观更新的查询的数据,以确保我们的查询已更新。

拥有这些知识,你现在拥有了所有你需要用你的新盟友useMutation将突变游戏提升到下一个级别的知识!

摘要

在本章中,我们学习了 React Query 如何通过使用useMutation钩子来执行突变。到目前为止,你应该能够创建、删除或更新你的服务器状态。为了进行这些更改,你求助于突变函数,这个函数就像你的查询函数一样,支持任何客户端,并允许你使用 GraphQL 或 REST,只要它返回一个承诺即可。

你了解了一些useMutation钩子返回的内容,例如mutatemutateAsync函数。与useQuery类似,useMutation也返回突变dataerror变量,并为你提供一些你可以用来构建更好用户体验的状态。为了你的方便,useMutation还返回一个reset函数来清除你的状态,以及一个isPaused变量,以防你的突变进入暂停状态。

为了让你能够自定义开发者体验,你了解了一些常用的选项,这些选项允许你自定义useMutation钩子的体验。然后我们利用这四个选项中的四个来教你如何在突变运行后执行一些副作用。

最后,你使用了一些你学到的知识来执行乐观更新,并为你的应用程序用户提供更好的体验。

第七章 使用 Next.js 或 Remix 进行服务器端渲染中,我们将了解我们如何在即使我们使用服务器端框架的情况下利用 React Query。你将学习你如何在服务器上获取数据,并在客户端配置 React Query 以使其工作并构建更好的体验。

第七章:使用 Next.js 或 Remix 进行服务器端渲染

并非所有我们的应用程序都在客户端渲染。如今,使用利用 服务器端渲染SSR)的框架是很常见的。这些框架有助于提高应用程序的性能,并且它们的采用率每天都在增长。

现在,当使用这些框架时,大多数情况下,我们倾向于在服务器端执行数据获取或突变,这引发了一个问题:

我在使用 SSR 框架时还需要 React Query 吗?

在本章中,您将了解 React Query 如何与 initialDatahydrate 等框架相结合。

一旦您熟悉了这些模式,您将了解如何将它们应用到您的 Next.js 和 Remix 应用程序中。

在本章中,我们将涵盖以下主题:

  • 为什么我应该使用 React Query 与服务器端渲染框架一起使用?

  • 使用 initialData 模式

  • 使用 hydrate 模式

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_7

为什么我应该使用 React Query 与服务器端渲染框架一起使用?

SSR 已经被证明是网络开发者的好帮手。随着全栈框架如 Next.js 和最近出现的 Remix 的流行,React 生态系统已经发生了变化,导致新的模式被应用。

什么是服务器端渲染(SSR)?

SSR 是一个允许你在服务器上而不是在浏览器上渲染应用程序的过程。在这个过程中,服务器将渲染的页面发送到客户端。然后客户端通过一个称为“水合”的过程使页面完全交互式。

由于可以使用 SSR,可能值得做的事情之一是在服务器上获取数据。这有许多优点,但其中最好的是向用户提供已经加载了初始数据的页面。现在,仅仅因为你在服务器端加载数据,并不意味着你不需要在客户端获取数据的情况。如果你的页面包含客户端上频繁更新的数据,React Query 仍然是你最好的朋友。

但 React Query 是如何与 Next.js 或 Remix 等框架结合到我们的代码中的?我们会在服务器上获取数据,然后再在客户端获取吗?

简短的回答是不。如果我们那样做,我们只是在服务器上浪费内存,而没有利用 SSR 的优势。我们可以做的是在服务器端预取数据,并将其提供给 React Query,以便它在客户端管理。这样,当用户获取页面时,页面已经包含了用户所需的数据,从那时起,React Query 就会负责一切。

我们可以将两种模式应用于服务器上的预取数据,并将其发送到客户端的 React Query。它们如下:

  • initialData 模式

  • hydrate 模式

在下一节中,我们将学习如何利用initialData模式并将其应用到所提到的框架中:Next.js 和 Remix。

使用 initialData 模式

initialData模式是你可以在useQuery钩子中设置的选项。使用这个选项,你可以向useQuery提供它将用于初始化特定查询的数据。

这就是如何利用服务器端框架的最佳功能和 React Query 的initialData选项的过程:

  1. 你首先在服务器端预先获取你的数据并将其发送到你的组件。

  2. 在你的组件内部,你使用useQuery钩子渲染你的查询。

  3. 在这个钩子内部,你添加initialData选项,并将你在服务器端预先获取的数据传递给它。

现在我们来看看如何在 Next.js 中使用这个模式。

在 Next.js 中应用 initialData 模式

在下面的代码片段中,我们将使用 Next.js 的getServerSideProps在服务器上获取一些数据,然后利用initialData模式将数据传递给 React Query:

import axios from "axios";
import { useQuery } from "@tanstack/react-query";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function getServerSideProps() {
  const user = await fetchData({ queryKey: [{ username:
    "danieljcafonso" }] });
  return { props: { user } };
}
export default function InitialData (props) {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
    initialData: props.user,
  });
  return <div>This page is server side generated
    {data.hello}</div>;
}

在前面的代码片段中,我们将initialData模式应用到 Next.js 应用程序中。这里,我们有一个将服务器端生成的组件。这就是我们在这里所做的事情:

  1. 我们为这个组件做必要的导入。在这个场景中,是axios和我们的useQuery钩子。

  2. 我们创建我们的查询函数。在这个函数中,我们获取到我们的查询键,并从查询键中解构出用户名以执行我们的GET请求。然后我们返回我们的查询数据。

  3. 由于我们希望这个页面是服务器端渲染的,所以我们将其中的getServerSideProps函数包含在内。这个函数将在服务器端运行,在其中,我们调用我们的fetchData函数来获取我们的服务器状态数据并将其作为 props 返回,这些 props 将被发送到我们的InitialData组件。

  4. 在我们的InitialData组件中,我们获取到我们的props。在这些props中,我们可以访问从我们的getServerSideProps函数返回的数据。然后我们将这些数据传递给我们的创建的useQuery实例作为initialData选项。这意味着这个钩子在重新获取之前将具有我们在构建时获取的数据作为其初始数据。

现在你已经知道如何在 Next.js 中应用这个模式,让我们在 Remix 中做同样的事情。

在 Remix 中应用 initialData 模式

在下面的代码片段中,我们将使用 Remix 的loader在服务器上获取一些数据,然后利用initialData模式将数据传递给 React Query:

import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import { useLoaderData } from "@remix-run/react";
import { json } from "@remix-run/node";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function loader() {
  const user = await fetchData({ queryKey: [{ username:
    "danieljcafonso" }] });
  return json({ user });
}
export default function InitialData() {
  const { user } = useLoaderData();
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
    initialData: user,
  });
  return <div>This page is server side rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们将initialData模式应用到 Remix 应用程序中。这就是我们在这里所做的事情:

  1. 我们为这个组件做必要的导入。在这个场景中,是axios、我们的useQuery钩子、Remix 的useLoaderData钩子和一个json函数。

  2. 我们创建我们的查询函数。在这个函数中,我们获取到我们的查询键,并从查询键中解构出用户名以执行我们的GET请求。然后我们返回我们的查询数据。

  3. 我们接下来创建我们的 loader 函数。这是 Remix 用来允许你在服务器端加载将在你的组件中需要的数据的函数。在其内部,我们获取我们的数据,然后使用 json 函数发送一个带有 application/json 内容类型头和包含数据的 HTTP 响应。

  4. 在我们的 InitialData 组件中,我们利用 useLoaderData 来获取 loader 返回的数据。然后我们将这些数据传递给我们的 useQuery 实例作为 initialData 选项。这意味着这个钩子在重新获取之前将构建时获取的数据作为其初始数据。

到现在为止,你应该能够使用 initialData 模式。为了更有效地使用它,你需要注意以下几点:

  • 如果你在不同位置有相同查询的多个实例,你必须始终向它们传递 initialData。这意味着即使你在顶层和子组件中利用查询,你也必须通过 prop-drill 将 initialData 传递到需要数据的期望组件。

  • 由于你在服务器上获取数据并将其传递到你的 hook,React Query 将基于初始页面加载时而不是服务器上获取数据的时间来识别你的查询何时被渲染。

让我们看看当你使用支持服务器端渲染的框架与 React Query 结合时可以利用的第二种模式:hydrate 模式。

使用 hydrate 模式

使用 hydrate 模式,你可以使用之前预取的查询来脱水你的 QueryClient 并将其发送到客户端。在客户端,一旦页面加载并且 JavaScript 可用,React Query 将使用现有数据来 hydrate 你的 QueryClient。在此过程之后,React Query 也会确保你的查询是最新的。

这是如何利用 hydrate 模式结合你的服务器端框架和 React Query 的最佳实践的过程:

  1. 你首先要做的是创建一个 QueryClient 实例。

  2. 使用之前创建的 QueryClient 实例,你利用其 prefetchQuery 方法来预取给定查询键的数据。

  3. 你将你的 QueryClient 脱水并发送到客户端。

  4. 你的客户端接收脱水状态,将其 hydrate 并与正在使用的 QueryClient 合并。

  5. 在你的组件内部,你使用 useQuery 钩子以与你在 步骤 2 中添加的相同查询键渲染你的查询。你的查询将已经包含其数据。

在下一节中,我们将学习如何利用 hydrate 模式并将其应用于所提到的框架:Next.js 和 Remix。

在 Next.js 中应用 hydrate 模式

Next.js 使用 _app 组件来初始化所有页面,并允许你在页面变化之间保持一些共享状态或持久化布局。由于这个原因,我们可以利用它来用 Hydrate 包装所有我们的组件。Hydrate 包装器负责接收 dehydratedState 并将其 hydrate。

让我们看看如何应用这个包装器:

import { useState } from "react";
import {
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
export default function App({ Component, pageProps }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />{" "}
      </Hydrate>
    </QueryClientProvider>
  );
}

在前面的代码片段中,我们执行以下操作:

  1. 我们进行所有必要的导入以设置我们的组件。在这个场景中,我们从 React 获取 useState 函数,并从 React Query 获取 HydrateQueryClientQueryClientProvider

  2. 在我们的 App 组件内部,我们执行以下操作:

    1. 我们首先创建一个新的 QueryClient 实例,并使用 useState 钩子将其分配为 state 变量。这是因为我们需要确保这些数据不会被我们应用程序的不同用户和请求共享。这将确保我们只创建一次 QueryClient

    2. 我们然后将我们的 queryClient 传递给 QueryClientProvider 以初始化它,并允许它被我们的 React Query 钩子访问。QueryClientProvider 还将包裹我们的 Component

    3. 最后,我们还用 Hydrate 包裹了我们的 Component。由于 Hydrate 需要接收 dehydratedState,无论它是否存在,我们从我们的 App 中获取 pageProps 并将其传递给我们的 Hydrate 状态属性。这意味着对于每个接收 dehydratedState 作为 props 的组件,这些 props 将被传递给我们的 Hydrate 包装器。

现在,我们已经准备好开始脱水数据。让我们看看我们如何做到这一点:

import axios from "axios";
import { dehydrate, QueryClient, useQuery } from
  "@tanstack/react-query";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function getServerSideProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(
    [{ queryIdentifier: "api", username: "danieljcafonso" }],
    fetchData
  );
  return { props: { dehydratedState: dehydrate(queryClient) } };
}
export default function SSR() {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
  });
  return <div>This page is server-side-rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们预取了一些数据,这些数据将被 React Query 脱水和再水化。以下是我们的操作:

  1. 我们为此组件进行必要的导入。在这个场景中,它是 axios,并且从 React Query 方面,是 dehydrate 函数、QueryClientuseQuery 钩子。

  2. 我们创建我们的查询函数。在这个函数中,我们获取对查询键的访问权限,并从查询键中解构 username 以执行我们的 GET 请求。然后我们返回我们的查询数据。

  3. getServerSideProps 中,我们执行以下操作:

    1. 我们创建一个新的 QueryClient 实例。

    2. 然后,我们利用之前创建的实例来预取一个查询,该查询将在 [{ queryIdentifier: "api", username: "danieljcafonso" }] 查询键下缓存,并使用 fetchData 作为查询函数。

    3. 我们在 queryClient 上使用 dehydrate 并将其作为 props 返回,以便它可以在我们的 App 组件中被捕获。

  4. 在我们的 SSR 组件中,我们使用 [{ queryIdentifier: "api", username: "danieljcafonso" }] 作为查询键和 fetchData 作为查询函数创建了一个 useQuery 钩子。

由于我们从 getServerSideProps 函数中返回了 dehydratedState,这将作为 pageProps 传递并被 Hydrate 包装器捕获,包裹我们的组件。这意味着 React Query 将捕获我们的脱水状态,对其进行水化,并将这些新数据与 QueryClient 中的当前数据合并。这意味着当 SSR 内部的钩子第一次运行时,它将已经从 getServerSidePros 预取了数据。

现在你已经知道如何将此模式应用于 Next.js,让我们在 Remix 中这样做。

在 Remix 中应用 hydrate 模式

Remix 使用 root 组件来定义所有页面的根布局,并允许你在页面变化之间保持一些共享状态。这是通过使用 Outlet 组件来实现的。由于这个组件和根级别的 Outlet,我们可以利用它来用 Hydrate 包装所有我们的组件。

现在,与 Next.js 不同,没有方法可以访问 pageProps 来在根级别访问 dehydratedState。因此,我们需要安装一个名为 use-dehydrated-state 的第三方包。

以下是向你的项目添加 use-dehydrated-state 的方法:

  • 如果你正在你的项目中运行 npm,请运行以下命令:

    npm i use-dehydrated-state
    
  • 如果你正在使用 Yarn,请运行以下命令:

    yarn add use-dehydrated-state
    
  • 如果你正在使用 pnpm,请运行以下命令:

    pnpm add use-dehydrated-state
    

use-dehydrated-state 允许我们在根级组件中访问我们的脱水状态。

现在,我们可以进行必要的设置以利用 HydrateQueryClientProvider 包装器:

import {
  ...
  Outlet,
} from "@remix-run/react";
import { useState } from "react";
import {
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { useDehydratedState } from "use-dehydrated-state";
export default function App() {
  const [queryClient] = useState(() => new QueryClient());
  const dehydratedState = useDehydratedState();
  return (
    ...
       <QueryClientProvider client={queryClient}>
         <Hydrate state={dehydratedState}>
           <Outlet />
         </Hydrate>
       </QueryClientProvider>
     ...
  );
}

在前面的代码片段中,我们执行以下操作:

  1. 我们进行所有必要的导入以设置我们的组件。在这个场景中,我们得到以下内容:

    1. Remix 的 Outlet

    2. 来自 React 的 useState 函数

    3. 来自 React Query 的 HydrateQueryClientQueryClientProvider

    4. 来自 use-dehydrated-stateuseDehydratedState 钩子

  2. 在我们的 App 组件内部,我们执行以下操作:

    1. 我们首先创建一个新的 QueryClient 实例,并使用 useState 钩子将其分配为 state 变量。这是因为我们需要确保这些数据不会被我们的应用程序的不同用户和请求共享。这将确保我们只创建一次 QueryClient

    2. 然后,我们将 queryClient 传递给 QueryClientProvider 以启动它,并允许它被我们的 React Query 钩子访问。QueryClientProvider 还会包装由 Outlet 渲染的组件。

    3. 最后,我们还将 OutletHydrate 包装起来。由于 Hydrate 需要在从服务器接收到 dehydratedState 时接收它,我们从 useDehydratedState 钩子中获取它。这意味着对于从其 loader 接收 dehydratedState 的每个组件,这些数据将被传递给我们的 Hydrate 包装器。

现在,我们已经准备好开始脱水数据。让我们看看如何操作:

import axios from "axios";
import { dehydrate, QueryClient, useQuery } from "@tanstack/react-query";
import { json } from "@remix-run/node";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function loader() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(
    [{ queryIdentifier: "api", username: "danieljcafonso" }],
    fetchData
  );
  return json({ dehydratedState: dehydrate(queryClient) });
}
export default function Index() {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
  });
  return <div>This page is server side rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们正在预取一些数据,这些数据将被 React Query 脱水和再水化。以下是我们的操作:

  1. 我们为这个组件进行必要的导入。在这个场景中,它们如下所示:

    1. axios 客户端

    2. 来自 React Query 的 dehydrate 函数、QueryClientuseQuery 钩子

    3. Remix 的 json 函数

  2. 我们创建我们的查询函数。在这个函数中,我们获取查询键的访问权限,并从查询键中解构用户名以执行我们的 GET 请求。然后我们返回我们的查询数据。

  3. loader 中,我们执行以下操作:

    1. 我们创建一个新的 QueryClient 实例。

    2. 我们随后利用先前创建的实例来预取一个查询,该查询将在[{ queryIdentifier: "api", username: "danieljcafonso" }]查询键下缓存,并使用fetchData作为查询函数。

    3. 然后,我们使用dehydratequeryClient进行操作,并将其作为HTTP响应返回。

  4. 在我们的Index组件中,我们使用[{ queryIdentifier: "api", username: "danieljcafonso" }]作为查询键,并使用fetchData作为查询函数创建一个useQuery钩子。

由于我们从loader函数中返回了dehydratedState,这将由useDehydratedState捕获并传递给我们的Hydrate包装器,包裹我们的组件。这意味着 React Query 将捕获dehydratedState,对其进行解冻,并将这些新数据与QueryClient中当前的数据合并。由于这个过程,当Index内部的钩子第一次运行时,它已经拥有了从loader中预取的数据。

摘要

本章教会了我们如何使用 React Query 补充我们的服务器端渲染应用程序。

你学习了如何使用 React Query 在服务器端预取数据并将其发送到客户端的 React Query。为此,你需要了解两种模式,initialDatahydrate。在initialData模式中,你在服务器端预取数据并将其传递到客户端useQuery钩子的initialData选项中。在hydrate模式中,你在服务器端预取查询,解冻查询缓存,并在客户端进行解冻。

第八章 测试 React Query 钩子和组件中,我们将关注帮助你夜晚睡得更好的事情之一:测试。你将了解如何测试你的组件,即使用 React Query,以及一些自定义钩子来提高你的开发者体验。

第八章:测试 React Query 钩子和组件

你几乎已经掌握了 React Query!到目前为止,你已经非常清楚查询和突变是如何工作的,并且准备好在服务器端渲染的项目中利用 React Query。现在,我们将探讨你需要成为真正的 React Query 英雄的最后一种技能——使用代码测试 React Query。

本章将教你如何使用组件和钩子测试useQueryuseMutation。但在那之前,你将了解一个非常有用的库,它可以帮助你测试 React Query 代码,称为 Mock Service Worker。

然后,你将学习一些重构技巧和窍门,你可以利用它们使你的 React Query 代码更易于阅读和重用。

在掌握了这些知识之后,你就可以开始测试你的代码了。你将从测试利用 React Query 的组件开始,看看从以用户为中心的角度进行查询和突变测试是什么样的。

最后,我们将深入了解实现细节,看看我们应该何时以及如何测试使用 React Query 的钩子。

本章我们将涵盖以下主题:

  • 配置 Mock Service Worker

  • 代码组织

  • 测试使用 React Query 的组件

  • 测试使用 React Query 的自定义钩子

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址为github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_8

配置 Mock Service Worker

在测试 React 应用程序时,人们经常问的一个问题是如何测试 API 调用。这个问题通常会导致一个后续问题:“我如何确保我的网络请求返回我期望的数据,以便我的测试总是接收到相同的数据,不会变得不可靠?”有许多方法可以回答这些问题,我们可以遵循许多实现。最常用的实现通常是模拟你的数据获取客户端。

虽然这种方法可行,但我在我所参与的所有采用这种方法的项目中经常看到的一个问题是:你写的测试越多,它们就越难以维护。这是因为模拟像fetchaxios这样的东西需要大量的样板代码来处理不同路由被击中、同一路由的不同响应以及清理客户端模拟以避免测试相互泄漏等问题。我们不要忘记,如果我们在一个应用程序中使用 GraphQL 和 REST,我们必须根据你正在测试的组件模拟额外的客户端。

如果我告诉你有一个可以用来拦截你的网络请求并返回预定义数据而无需模拟任何客户端的替代方案,你会怎么想?如果我说这个替代方案支持 REST 和 GraphQL,你会怎么想?如果我说这个替代方案还可以用于你的应用程序,为你的后端团队尚未实现的某个路由提供一些模拟数据,你会怎么想?你可以用 Mock Service Worker (MSW) 做到所有这些。

如 MSW 文档所述:“Mock Service Worker 是一个使用 Service Worker API 来拦截实际 请求 的 API 模拟库” (mswjs.io/docs/)。

MSW 利用服务工作者在网络级别拦截请求,并为该特定请求返回一些预定义数据。这意味着,只要有一个定义好的 API 合同,你就可以在端点存在之前返回模拟数据。此外,利用这些预定义数据在你的测试中意味着你不再需要模拟 axiosfetch。重要的是要提到,服务工作者仅在浏览器中工作。在你的测试中,MSW 使用请求拦截器库,允许你重用你在浏览器中已有的相同模拟定义。

虽然 MSW 在浏览器中使用非常有帮助,但它超出了本章的范围。在本章中,我们只会使用 MSW 在我们的测试中。

这是将 MSW 添加到你的项目的方法:

  • 如果你正在你的项目中运行 npm,请运行以下命令:

    npm install msw --save-dev
    
  • 如果你使用的是 Yarn,请运行以下命令:

    yarn add msw --dev
    
  • 如果你使用的是 pnpm,请运行以下命令:

    pnpm add msw --save-dev
    

一旦 MSW 安装完成,我们必须创建我们的请求处理器和响应解析器。

请求处理器允许你在处理请求时指定方法、路径和响应。它们通常与响应解析器配对。响应解析器是一个传递给请求处理器的函数,它允许你在拦截请求时指定模拟的响应。

让我们现在创建一些处理器来处理一些路由。以下是我们要做的事情。

src/mocks 文件夹中,创建一个 handlers.js 文件。

handlers.js 文件中,添加以下代码:

import { rest } from "msw";
export const handlers = [
  rest.get("*/api/*", (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        data: "value"
      })
    );
  }),
];

在前面的代码片段中,我们做了以下操作:

  1. 我们导入包含一组请求处理器的 rest 命名空间,用于处理 REST 请求。

  2. 我们创建一个 handlers 数组,它将包含我们所有的请求处理器。

我们创建的第一个模拟是一个针对包含 /api/ 的任何路由的 GET 请求。

当请求击中这个请求处理器时,它将返回一个响应,该响应将返回一个包含 "value" 字符串的 200 OK 响应代码的对象。

现在我们已经创建了我们的 handlers,我们需要确保 MSW 将使用我们之前创建的 handlers 来拦截我们的请求。

这是我们需要做的事情。

src/mocks 文件夹中,创建一个 server.js 文件。

server.js 文件中,添加以下代码:

import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

在前面的片段中,我们利用 setupServer 函数和我们的创建的 handlers 数组来创建一个对象,该对象负责拦截我们的请求并使用我们提供的 handlers

现在我们已经创建了我们的服务器文件,我们需要确保 Jest 使用它们。为此,在我们的 setupTests.js 文件中,添加以下代码:

import { server } from "./mocks/server.js";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

这就是我们前面片段中所做的:

  1. 我们导入我们创建的 server 对象。

  2. 我们利用 beforeAll 全局钩子来确保 MSW 在我们的任何测试执行之前拦截我们的请求。

  3. 我们随后利用 afterEach 全局钩子,确保在每次测试之后重置我们的处理程序。这考虑了一种场景,即我们为我们的某个测试添加一个自定义处理程序,以防止它们泄漏到另一个测试中。

  4. 最后,我们利用 afterAll 全局钩子,以确保在我们所有的测试运行之后,我们清理并停止拦截请求。

现在,我们的测试所做的任何 API 请求都将被 MSW 拦截。

在看到我们如何使用挂钩测试我们的组件和 React Query 之前,让我们看看我们可以应用的一些模式,以使我们的代码更加结构化和易于测试。

组织代码

你可以以许多方式组织你的代码。现在,我们需要注意的一件事是选择可以节省你时间并使你的代码在长期内更好的模式。本节将讨论三种我们可以共同或独立利用的模式,以使我们的代码更加结构化、可读和组织。以下是本节我们将讨论的内容:

  • 创建一个 API 文件

  • 利用查询键工厂

  • 创建一个 hooks 文件夹

创建一个 API 文件

创建一个 API 文件来包含我对特定域的所有请求,这是我遵循的模式。

在这个文件中,我利用我的 API 客户端创建负责向给定路由发送请求并返回请求数据的函数。

这特别有用,因为它避免了在代码中重复相同的请求逻辑,并将所有特定域的请求集中在同一个文件中。

对于本书范围内所做的所有请求,我更愿意为我的用户域创建一个文件,因为范围似乎集中在用户上。所以,在我们的 api 文件夹中,我们将创建一个 userAPI.js 文件。

图 8.1 – 将 userAPI.js 添加到我们的 API 文件夹

图 8.1 – 将 userAPI.js 添加到我们的 API 文件夹

在那个文件中,我们现在可以将所有请求移动到我们的代码中。这可能看起来是这样的:

import axios from "axios";
export const axiosInstance = axios.create({
  baseURL: "https://danieljcafonso.builtwithdark.com",
});
export const getUser = async (username, signal) => {
  const { data } = await axiosInstance.get
    (`/react-query-api/${username}`, {
    signal,
  });
  return data;
};
export const createUser = async (user) => {
  return axiosInstance.post(`/name-api`, user);
};

在前面的片段中,我们可以看到一个 userAPI 文件的例子,其中包含我们的 axios 客户端实例、一个 getUser 函数(用于从给定用户获取数据)和一个 createUser 函数(用于创建用户)。

如您所见,这种模式提高了最终使用我们 API 文件中函数的组件的代码可重用性和可读性。

你可以做的另一件事是我们之前片段中没有做的,那就是添加来自你的查询函数的特定逻辑。如果你只使用 React Query,这将使这些函数在你的应用程序中更容易访问。我更喜欢将我的查询函数和这些 API 函数分开,因为我经常使用不同的查询函数与相同的 API 函数。不过,如果你选择使用它,这也会提高你的代码可读性。

利用查询键工厂

管理查询键通常是一件麻烦事。我们忘记了我们已经使用了哪些,需要浏览我们的大部分查询来记住它们。这就是查询键工厂大放异彩的地方。

查询键工厂可以是一个包含函数的对象,每个属性中都有一个负责生成查询键的函数。这样,你就可以将所有的查询键放在同一个地方,并停止浪费时间试图记住它们。

这就是你的查询键工厂可能的样子:

export const userKeys = {
    all: () => ["allUsers"],
    api: () => [{queryIdentifier: "api"}],
    withUsername: (username = "username") =>
      [{ ...userKeys.api[0], username }],
    paginated: (page) => [{ ...userKeys.api, page }]
}

如前文片段所示,我们创建了一个userKey对象,它将成为我们的查询键工厂。在每一个属性中,我们都有一个负责返回我们的查询键的函数。

创建一个钩子文件夹

这里的名字也足以说明一切。我喜欢的代码组织建议之一是创建一个钩子文件夹。

我喜欢在这个文件夹中创建自定义钩子,其中包含一些我经常重复的查询和突变,或者那些最终包含太多逻辑并影响我的代码可读性的钩子。这使得我可以更容易地在隔离状态下测试特定的钩子,并使使用它们的组件更具可读性。

例如,还记得我们在第六章中执行乐观更新吗?我们创建的useMutation钩子是一个很好的候选者,可以移动到自定义钩子。我将创建一个useOptimisticUpdateUserCreation自定义钩子,并将我的代码移到那里。这个钩子看起来是这样的:

import { useMutation, useQueryClient } from
  "@tanstack/react-query";
import { userKeys } from "../utils/queryKeyFactories";
import { createUser } from "../api/userAPI";
const useOptimisticUpdateUserCreation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createUser,
    retry: 0,
    onSettled: () => queryClient.invalidateQueries
      (userKeys.all()),
    onMutate: async (user) => {
      await queryClient.cancelQueries(userKeys.all());
      const previousUsers = queryClient.getQueryData
        (userKeys.all());
      queryClient.setQueryData(userKeys.all(), (prevData)
        => [
        user,
        ...prevData,
      ]);
      return { previousUsers };
    },
    onError: (error, user, context) => {
      queryClient.setQueryData(userKeys.all(),
        context.previousUsers);
    },
  });
};
export default useOptimisticUpdateUserCreation;

在前文片段中,我们创建了useOptimisticUpdateUserCreation钩子,并将OptimisticMutation组件中的代码移到那里。从代码中,你也可以看到,我们已经应用了我们的 API 文件和查询工厂模式。

在使用我们的钩子的组件中,我们现在只需要导入钩子并像这样使用它:

const mutation = useOptimisticUpdateUserCreation();

应用本节的所有模式,你的项目结构最终可能看起来是这样的:

图 8.2 – 遵循这三个模式后项目结构可能的样子

图 8.2 – 遵循这三个模式后项目结构可能的样子

现在我们已经看到了这些模式,让我们最终开始测试我们的代码。我们将从一个最推荐的方法开始——使用 React Query 钩子测试组件。

测试使用 React Query 的组件

当 React Testing Library 首次推出时,它遵循一个主要指导原则,这个原则改变了我们编写测试的方式。这个指导原则是,“你的测试越接近你的软件的使用方式,它们就能给你带来越多的信心” (testing-library.com/docs/guiding-principles/)。

从那个点开始,我们的测试中发生了许多变化。专注于以用户为中心的方法意味着不惜一切代价避免在我们的测试中包含实现细节。这意味着不再有浅渲染,不再有状态和属性引用,以及更以用户为中心的查询 DOM 的方式。

阅读最后一部分,你可能想知道如何采用以用户为中心的方法来测试你的组件。嗯,答案很简单——用户不需要知道他们正在使用的页面是否使用了 React Query。如果你像使用页面一样编写测试,这意味着你可能会意外地发现用户可能会遇到的问题,并且如果由于某种原因你更改了实现,你的测试不会中断。

在某些场景中,你可能需要将你的测试与某些实现细节绑定,以帮助你进行断言,但我们将不惜一切代价避免在本节中这样做。

在我们开始编写测试之前,我们需要做一些设置。

设置测试工具

当测试利用 React Query 的组件时,我们必须确保我们用 QueryClientProvider 包裹这些组件。现在,我们可以在每个测试中创建一个自定义包装器,并在渲染时用它来包裹我们的组件,但请记住,你很可能会得到许多以某种方式使用 React Query 的组件。

这就是设置一些测试工具可以帮助你的地方。我非常喜欢遵循的一个模式是覆盖测试库中的 render 函数,并使用这个函数自动包裹渲染的每个组件,使用我们的 React Query QueryClientProvider。为此,我在 utils 文件夹中创建了一个 test-utils.js 文件。

这是我们可以在 test-utils.js 文件中添加的内容:

import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from
  "@tanstack/react-query";
const customRender = (ui, { ...options } = {}) => {
  const queryClient = new QueryClient({
    logger: {
      log: console.log,
      warn: console.warn,
      error: () => {},
    },
    defaultOptions: {
      queries: {
        retry: 0,
        cacheTime: Infinity,
      },
    },
  });
  const CombinedProviders = ({ children }) => {
    return (
      <QueryClientProvider client={queryClient}>
        {children}</QueryClientProvider>
    );
  };
  return render(ui, { wrapper: CombinedProviders,
     ...options });
};
export * from "@testing-library/react";
export { customRender as render };

这是我们前面片段中做的事情:

  1. 我们从 React Testing Library 导入 render 函数。

  2. 我们从 React Query 导入我们的 QueryClientQueryClientProvider

  3. 我们创建了一个自定义的 render 函数 (customRender):

    1. 这个函数将接收一个 ui 参数,它将是我们要渲染的组件。它还将接收一个 options 对象,我们可以将其转发给 render 函数。

    2. 我们创建我们的 queryClient 实例。在这里,我们覆盖了我们的 loggererror 属性,以避免显示来自 React Query 的错误。这是因为我们可能想要测试错误场景,并且我们不希望 React Query 用我们预期的错误污染我们的 console。我们还定义我们的查询,在查询失败后永远不尝试重试查询,并将我们的 cacheTime 设置为 Infinity 以避免在手动设置 cacheTime 值的场景中产生 Jest 错误信息。

    3. 我们创建一个 CombinedProviders 包装器,它将负责用 QueryClientProvider 包裹我们的组件。

    4. 我们调用 React Testing Library 的 render 函数,传递给它 ui 参数,并用我们的 CombinedProviders 包裹它,然后发送我们接收到的 options

  4. 我们导出所有的 React Testing Library 和我们的 customRender 函数,这将是现在的主 render 函数。这意味着我们现在在测试中导入这个文件而不是 React Testing Library。

注意在代码片段中,我们是在 customRender 函数内部创建我们的 queryClient 而不是外部。如果您想避免在测试之间清理查询缓存,可以采用这种方法。如果您想在测试之间使用相同的 QueryClient,可以在函数外部创建 queryClient 实例。

现在既然我们的 render 函数已经准备好使用组件渲染 React Query,我们可以开始编写测试了。

测试查询

在以下小节中,我们将看到一些在您日常使用 React Query 时可能会遇到的一些常见测试场景。

检查数据是否已获取

我们必须编写的最常见的测试之一是确保我们的数据已被正确获取。让我们从这个场景开始,并重新审视我们来自 第五章 的并行查询示例。我们还将重写代码以适应本章中提到的一些实践。让我们先看看我们的 ParallelQueries 组件:

export const ParallelQueries = () => {
  const { multipleQueries } = useMultipleQueriesV2();
  return (
    <div>
      {multipleQueries.map(({ data, isFetching }, index) => (
        <p key={index}>{isFetching ? "Fetching data..." :
          data.hello}</p>
      ))}
    </div>
  );
};

如您从前面的代码片段中看到的,代码基本上与第 5 章 中展示的相同,除了我们获取数据的那部分。在这里,我们应用了本章中提到的模式之一,并将这个逻辑移动到自定义钩子文件夹内的自定义钩子中。

让我们现在看看 useMultipleQueriesV2 钩子文件内部的内容:

import { useQueries } from "@tanstack/react-query";
import { userKeys } from "../utils/queryKeyFactories";
import { getUser } from "../api/userAPI";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  return await getUser(username);
};
const usernameList = ["userOne", "userTwo", "userThree"];
const useMultipleQueriesV2 = () => {
  const multipleQueries = useQueries({
    queries: usernameList.map((username) => {
      return {
        queryKey: userKeys.withUsername(username),
        queryFn: fetchData,
      };
    }),
  });
  return { multipleQueries }
};
export default useMultipleQueriesV2

如您从前面的代码片段中看到的,我们基本上只是将组件中的内容移动到我们的 useMultipleQueriesV2 钩子中。注意,我们还利用了本章中提到的其他两个模式:

  • 我们在 userKeys 工厂中创建一个条目,并利用它来设置我们的 useQueries 钩子,queryKey

  • 我们创建一个 API 文件来收集我们的用户 API 函数,并添加我们的 getUser 函数

这就是我们的 getUser 函数的样子:

export const getUser = async (username, signal) => {
  const { data } = await axiosInstance.get
    (`/react-query-api/${username}`, {
    signal,
  });
  return data;
};

在此代码片段中显示的getUser函数负责对我们的给定端点发起GET请求,并在我们的signal告诉axios这样做时取消该请求。

现在你已经重新熟悉了这个组件及其工作方式,让我们开始测试它。

在我们编写测试之前,首先需要确保 MSW 正在拦截GET请求并返回我们想要的数据:

  rest.get("*/react-query-api/*", (req, res, ctx) => {
    return res(
      ctx.delay(500),
      ctx.status(200),
      ctx.json({
        hello: req.params[1],
      })
    );
  })

在前面的代码片段中,我们创建了一个请求处理器并将其添加到我们的handlers数组中,该处理器执行以下操作。

每当我们拦截到包含/react-query-api/路径的端点的GET请求时,我们返回一个将被延迟 500 毫秒的200 OK响应,其体中将包含一个具有hello属性的对象,该属性将包含请求参数的第二位参数。

这意味着对danieljcafonso.builtwithdark.com/react-query-api/userOne端点的GET请求将返回一个包含以下对象的200 OK响应:

{
  hello: "Hello userOne"
}

现在我们确信我们的组件在请求后总是会接收到相同的数据,我们可以编写我们的测试。

现在,我建议你从一个用户的角度来看ParallelQueries组件,并考虑你可能想要测试的场景。这里的经验法则是思考,“如果我是与这段代码交互的用户,我会与什么交互或期望发生什么?”

根据前面的分析,我想出了两个测试场景:

  • userOneuserTwouserThree

  • 为我们每个请求显示"Fetching data…"消息。

考虑到这些场景,我们可以编写我们的测试。让我们看看我们的测试文件会是什么样子:

import { ParallelQueries } from "../MultipleQueries";
import { render, screen } from "../utils/test-utils";
describe("Parallel Queries Tests", () => {
  test("component should fetch and render multiple data",
    async () => {
    render(<ParallelQueries />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
    expect(screen.getByText("userTwo")).toBeInTheDocument();
    expect(screen.getByText("userThree")).toBeInTheDocument();
  });
  test("component should show loading indicator for each
    query", () => {
    render(<ParallelQueries />);
    const isFetching = screen.getAllByText("Fetching data...");
    expect(isFetching).toHaveLength(3);
  });
});

让我们现在回顾一下前面代码片段中我们做了什么:

  1. 我们导入我们的ParallelQueries组件,以及从我们的test-utils中的自定义render函数和screen对象。

  2. 我们创建我们的测试套件,并在其中创建我们的测试:

    1. 对于"component should fetch and render multiple data"测试,我们执行以下操作:

      1. 渲染我们的ParallelQueries组件。

      2. 由于我们需要等待数据被获取,我们利用 React Testing Library 中的async查询变体(findBy)和await,直到userOne文本出现在 DOM 上。

      3. 一旦我们的查询找到userOne文本,我们断言它在 DOM 中,并对userTwouserThree重复相同的断言。在这些最后两个例子(userTwouserThree)中,我们不需要利用findBy变体,因为数据已经存在于 DOM 上,所以我们使用getBy变体。

    2. 对于"component should show loading indicator for each query"测试,我们执行以下操作:

      1. 渲染我们的ParallelQueries组件。

      2. 由于我们在模拟响应中添加了 500 毫秒的延迟,我们的数据不会立即可用以进行渲染,因此我们应该显示加载指示器。由于我们将有多个指示器,我们利用getAllBy变体来获取与我们的查询匹配的元素数组。

      3. 我们随后断言我们的元素数组长度为3,以确保每个查询都有一个"Fetching data…"消息。

通过这些测试,我们遵循了一种反映我们与组件交互时用户行为的方法,同时也在我们的ParallelQueries组件和useMultipleQueriesV2自定义钩子上实现了 100%的覆盖率。

在大多数情况下,为了处理数据获取场景,你只需要等待你获取的数据在 DOM 上被渲染。只有一个查询?等待数据在 DOM 上显示。有多个并行查询?等待数据在 DOM 上显示。有依赖查询?等待第一个查询的数据在 DOM 上显示。然后,为后续查询重复此步骤。

现在,在某些场景中,您将不得不执行一些操作以到达您的测试断言。其中一些场景甚至可能涉及查询无效化或查询取消。由于这些场景的相似性,让我们现在看看我们可以使用查询无效化进行哪些测试。

检查查询是否被无效化

正如您应该从第五章中记住的,查询无效化是指您手动标记您的查询为过时,以便 React Query 可以在渲染时重新获取它。

让我们回顾一下在第五章中看到的QueryInvalidation组件:

const fetchData = async ({ queryKey}) => {
  const { username } = queryKey[0];
  return await getUser(username);
};
const QueryInvalidation = () => {
  const { data, isFetching } = useQuery({
    queryKey: userKeys.withUsername("userOne"),
    queryFn: fetchData,
  });
  const queryClient = useQueryClient();
  return (
    <div>
      <p>{isFetching ? "Loading..." : data?.hello}</p>
      <button onClick={() => queryClient.invalidateQueries
        (userKeys.api())}>
        Invalidate Query
      </button>
    </div>
  );
};

如您从前面的代码片段中看到的,代码仍然非常类似于第五章中的代码。我们在这里所做的唯一改变是应用 API 文件模式,并利用本章之前看到的getUser函数,以及将我们的查询键更改为利用查询键工厂模式。

现在您已经重新熟悉了这个组件及其工作方式,让我们开始对其进行测试。

由于我们正在利用getUser函数,我们不需要在 MSW 中创建一个新的请求处理器,因为我们正在使用相同的端点。

现在,从以用户为中心的角度来看QueryInvalidation组件,以下是您可能识别出的三个测试场景:

  • userOne

  • "``Loading…"消息。

  • 作为用户,我希望点击无效查询按钮时重新获取我的查询:在这种情况下,我们希望我们的组件被渲染,并等待它渲染一个问候消息,点击无效查询按钮,等待问候消息消失,等待加载指示器消失,并等待问候消息再次出现。这样,我们就能确保我们的查询已被无效化。

考虑到这些场景,我们可以为我们的QueryInvalidation组件编写测试。让我们看看我们的测试文件会是什么样子:

import { QueryInvalidation } from "../QueryClientExamples";
import { fireEvent, render, screen, waitFor } from "../utils/test-utils";
describe("QueryInvalidation Tests", () => {
  test("component should display fetched data", async () => {
    render(<QueryInvalidation />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
  });
  test("component should show a loading indicator", () => {
    render(<QueryInvalidation />);
    expect(screen.getByText("Loading...")).toBeInTheDocument();
  });
  test("component should invalidate query", async () => {
    render(<QueryInvalidation />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
    const invalidateButton = screen.getByRole("button", {
      text: "Invalidate Query",
    });
    fireEvent.click(invalidateButton);
    await waitFor(() =>
      expect(screen.queryByText("userOne")).not.
        toBeInTheDocument()
    );
    await waitFor(() =>
      expect(screen.queryByText("Loading"")).not.
        toBeInTheDocument()
    );
    expect(screen.getByText("userOne")).
      toBeInTheDocument();
  });
});

现在,让我们回顾一下前面代码片段中我们在做什么:

  1. 我们导入我们的QueryInvalidation组件,并从我们的test-utils中导入我们的自定义render函数、screen对象、fireEvent实用工具和waitFor函数。

  2. 我们创建我们的测试套件,并在其中编写我们的测试:

    1. 对于"component should display fetched data"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 由于我们需要等待数据被获取,我们利用 React Testing Library 中的async查询变体(findBy)和await,直到userOne文本出现在 DOM 上。

      3. 一旦我们的查询找到userOne文本,我们断言它出现在 DOM 中。

    2. 对于"component should show a loading indicator"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 由于我们添加了 500 毫秒的延迟到模拟响应中,我们的数据不会立即可用以进行渲染,因此我们应该看到加载指示器出现。然后我们利用getBy查询变体来帮助断言"Loading…"文本出现在 DOM 中。

    3. 对于"component should invalidate query"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 我们等待数据被获取,并相应地断言它出现在 DOM 上。

      3. 我们找到了我们的getByRole查询,这将帮助我们找到带有Invalidate Query文本的按钮。

      4. 然后,我们利用fireEvent实用工具在按钮上触发一个click事件。

      5. 然后,我们利用waitFor函数等待断言评估为true。在这种情况下,我们等待我们的查询数据从 DOM 中消失。

      6. 然后,我们再次利用waitFor函数,这次是为了等待加载指示器从 DOM 中消失。

      7. 最后,我们通过检查数据是否再次出现在 DOM 上来断言我们的查询已完成重新获取。

现在,我们已经检查了如何测试查询无效化。你可能想知道查询取消与查询无效化有何不同。最终,测试查询取消会在以下方面有所不同:

  • 我们的查询函数需要接收AbortController信号并将其转发到我们的getUser函数。

  • 与从queryClient调用invalidateQuery函数不同,我们调用cancelQueries

  • 在我们的测试中,前两个场景完全相同。在第三个场景中,我们在渲染组件后立即点击取消按钮。完成此操作后,DOM 不应显示数据或加载指示器。

现在你已经知道了如何以用户为中心的方法测试大多数场景,让我们将这个知识付诸实践,看看我们如何测试一个分页场景。

测试分页查询

第五章 中,我们学习了 useQuery 如何允许我们创建分页查询,并随后用它来构建分页 UI 组件。

让我们回顾一下在 第五章 中看到的 PaginatedQuery 组件:

import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getPaginatedData } from "./api/userAPI";
import { userKeys } from "./utils/queryKeyFactories";
const fetchData = async ({ queryKey }) => {
  const { page } = queryKey[0];
  return await getPaginatedData(page);
};
const PaginatedQuery = () => {
  const [page, setPage] = useState(0);
  const { isLoading, isError, error, data, isFetching,
    isPreviousData } =
    useQuery({
      queryKey: userKeys.paginated(page),
      queryFn: fetchData,
      keepPreviousData: true,
    });
  if (isLoading) {
    return <h2>Loading initial data...</h2>;
  }
  if (isError) {
    return <h2>{error.message}</h2>;
  }
  return (
    <>
      <div>
        {data.results.map((user) => (
          <div key={user.email}>
            {user.name.first}
            {user.name.last}
          </div>
        ))}
      </div>
      <div>
        <button
          onClick={() => setPage((oldValue) =>
            Math.max(oldValue - 1, 0))}
          disabled={page === 0}
        >
          Previous Page
        </button>
        <button
          disabled={isPreviousData}
          onClick={() => setPage((old) => old + 1)}
        >
          Next Page
        </button>
      </div>
      {isFetching ? <span> Loading...</span> : null}
    </>
  );
};
export default PaginatedQuery;

如前文片段所示,它与我们在 第五章 中看到的内容几乎相同。注意,我们还利用了本章中提到的两种模式:

  • 我们在 userKeys 工厂中创建了一个条目,并利用它来设置我们的 useQuery 钩子,queryKey

  • 我们创建了一个 API 文件来收集我们的用户 API 函数,并添加了我们的 getPaginatedData 函数

这就是我们的 getPaginatedData 函数的样子:

export const getPaginatedData = async (page) => {
  const { data } = await axiosInstance.get(
    `/react-query-paginated?page=${page}&results=10`
  );
  return data;
};

前文片段中显示的 getPaginatedData 函数负责为给定页面向我们的指定端点发起 GET 请求。

既然你已经重新熟悉了这个组件及其工作原理,让我们来测试它。

我们将首先创建我们的 MSW 请求处理器:

rest.get("*/react-query-paginated", (req, res, ctx) => {
    const page = req.url.searchParams.get("page");
    const pageOneData = {
      email: "email1",
      name: {
        first: "first1",
        last: "last1",
      },
    };
    const pageTwoData = {
      email: "email2",
      name: {
        first: "first2",
        last: "last2",
      },
    };
    const data = {
      results: [page > 0 ? pageTwoData : pageOneData],
    };
    return res(ctx.status(200), ctx.json(data));
  })

在前文片段中,我们创建了一个请求处理器并将其添加到我们的 handlers 数组中,它执行以下操作。

每当我们拦截到包含 /react-query-paginated 路径的端点的 GET 请求时,我们得到 page 查询参数以帮助我们定义我们将返回哪些数据。

我们返回一个包含第一页或第二页数据的 200 OK 响应,具体取决于接收到的页面查询参数。

这意味着对 danieljcafonso.builtwithdark.com/react-query-paginated?page=0&results=10 端点的 GET 请求将返回一个包含 pageOneData 对象的 200 OK 响应,而对 danieljcafonso.builtwithdark.com/react-query-paginated?page=1&results=10 端点的 GET 请求将返回一个包含 pageTwoData 对象的 200 OK 响应。

既然我们确信我们的组件在请求后总是会接收到相同的数据,我们可以编写我们的测试,并从以用户为中心的角度查看 PaginatedQuery 组件;以下是你可能识别出的测试场景:

  • 作为用户,我希望在打开页面后看到我的数据已加载:在这种情况下,我们希望我们的组件被渲染并检查是否显示了初始加载数据消息。

  • 作为用户,我希望在数据加载失败时看到错误消息:在这种情况下,我们希望我们的组件渲染并查看请求失败时是否显示了错误消息。

  • 作为用户,我希望看到最初获取的数据:在这种情况下,我们希望我们的组件渲染并等待第一页的数据被获取。

  • 作为用户,我希望点击 下一页 按钮并看到下一页的数据:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,等待直到获取第二页的数据。

  • 作为用户,我希望在获取新数据时看到获取指示器:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,确保获取指示器被渲染。

  • 作为用户,我希望在点击 下一页 上一页 时看到数据:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,确保第二页显示出来。然后我们点击 上一页 按钮并确保第一页的数据再次被渲染。

  • 作为用户,我希望在第一页时我的 上一页 按钮被禁用:在这种情况下,我们希望组件被渲染并确保我们有初始数据。由于我们处于第一页,我们希望 上一页 按钮被禁用。

  • 作为用户,我希望我的 下一页 按钮在等待新数据出现时被禁用:在这种情况下,我们希望组件渲染并确保我们有初始数据。在点击 下一页 按钮后,我们需要确保此按钮被禁用。

考虑到这些场景,这是我们将编写的测试 PaginatedQuery 组件的代码:

import PaginatedQuery from "../PaginatedQuery";
import { render, screen } from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { server } from "../mocks/server";
import { rest } from "msw";
describe("PaginatedQuery tests", () => {
  test("should render loading indicator on start", () => {
    render(<PaginatedQuery />);
    expect(screen.getByText("Loading initial data...")).
      toBeInTheDocument();
  });
  test("should render error on failed fetching", async () => {
    server.use(rest.get("*", (req, res, ctx) =>
      res(ctx.status(403))));
    render(<PaginatedQuery />);
    expect(
      await screen.findByText("Request failed with status
        code 403")
    ).toBeInTheDocument();
  });
  test("should render first page data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
  });
  test("should render second page data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    const secondPageFirstName = await screen.findByText
      (/first2/i);
    expect(secondPageFirstName).toBeInTheDocument();
    expect(screen.getByText(/last2/i)).toBeInTheDocument();
  });
  test("should show fetching indicator while fetching
    data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    expect(screen.getByText("Loading...")).
      toBeInTheDocument();
  });
  test("should change pages back and forth and render
    expected data", async () => {
    render(<PaginatedQuery />);
    expect(await screen.findByText(/first1/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    expect(await screen.findByText(/first2/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last2/i)).toBeInTheDocument();
    const previousPageButton = screen.getByRole("button", {
      name: "Previous Page",
    });
    userEvent.click(previousPageButton);
    expect(await screen.findByText(/first1/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
  });
  test("should have previous page button disabled on first
    page", async () => {
    render(<PaginatedQuery />);
    const previousPageButton = await screen.findByRole
      ("button", {
      name: "Previous Page",
    });
    expect(previousPageButton).toBeDisabled();
  });
  test("should have next page button disabled while
    changing pages", async () => {
    render(<PaginatedQuery />);
    const nextPageButton = await screen.findByRole
      ("button", {
      name: "Next Page",
    });
    userEvent.click(nextPageButton);
    expect(nextPageButton).toBeDisabled();
  });
});
  1. 我们首先进行必要的导入:

    1. 我们的 PaginatedQuery 组件。

    2. 我们的 renderscreen 工具来自 test-utils

    3. 来自测试库的 user-event 伴侣的 userEvent 工具。在这里需要注意的一点是我们使用的是 v14 之前的用户事件版本。

    4. 我们的 MSW server,以便我们可以为我们的测试场景之一创建自定义响应模拟。

    5. MSW rest 命名空间,为我们的测试场景之一创建相关的请求处理器。

  2. 我们创建我们的测试套件,并在其中创建我们的测试:

    1. 对于 "should render loading indicator on start" 测试,我们执行以下操作:

      1. 渲染我们的 PaginatedQuery 组件。

      2. 利用 getByText 查询断言 "Loading initial data…" 消息在 DOM 上。

    2. 对于 "should render error on failed fetching" 测试,我们执行以下操作:

      1. 利用我们的 server use 函数向当前的服务器实例添加一个请求处理器。在这种情况下,我们添加一个处理器来捕获每个 GET 请求("*" 表示此处理器将匹配每个路由)并返回 403 Forbidden,以便我们的请求失败。不用担心这会影响到其他测试,因为我们确保在 setupTests 文件中调用了 resetHandlers 函数。这将确保此自定义请求处理器只会用于此测试。

      2. 渲染我们的 PaginatedQuery 组件。

      3. 利用findByText查询await直到错误消息出现在 DOM 上。

    3. 对于“应渲染第一页数据”测试,我们执行以下操作:

      1. 渲染我们的PaginatedQuery组件。

      2. 等待直到第一页的姓名属性数据出现在 DOM 上。

      3. 断言姓氏属性也出现在 DOM 上。

    4. 对于“应渲染第二页数据”测试,我们执行以下操作:

      1. 渲染我们的PaginatedQuery组件。

      2. 等待直到第一页的数据出现在 DOM 上。

      3. 利用getByRole查询获取带有文本“应在加载数据时显示获取指示器”测试,我们执行以下操作:

        1. 渲染我们的PaginatedQuery组件。

        2. 等待直到第一页的数据出现在 DOM 上。

        3. 利用getByRole查询获取带有文本getByText的按钮,使用getByText查询来检查“加载中…”指示器是否出现在 DOM 上。

      4. 对于“应来回切换页面并渲染预期数据”测试,我们执行以下操作:

        1. 渲染我们的PaginatedQuery组件。

        2. 等待直到第一页的数据出现在 DOM 上,并断言它在那里。

        3. 利用getByRole查询获取带有文本getByRole的按钮,用于获取带有文本“在第一页应禁用上一页按钮”测试,我们执行以下操作:

          1. 渲染我们的PaginatedQuery组件。

          2. 利用findByRole查询等待直到“在切换页面时应禁用下一页按钮”测试,我们执行以下操作:

            1. 渲染我们的PaginatedQuery组件。

            2. 利用findByRole查询等待直到“下一页”按钮出现在 DOM 上,并点击它。

            3. 断言“下一页”按钮现在是禁用的。

        如您所见,我们可以以完全以用户为中心的方法测试我们的查询,并忘记实现细节。现在,让我们进入突变部分,看看它如何变得稍微难以采用以用户为中心的方法。

        测试突变

        你当然可以采用以用户为中心的方法进行突变,尽管在某些场景中这可能更困难。让我们回顾一下我们在第六章中编写的组件,看看它可能为什么以用户为中心的方法测试更困难:

        export const SimpleMutation = () => {
          const [name, setName] = useState("");
          const { mutate, isPaused } = useMutation({
            mutationFn: createUser,
          });
          const submitForm = (e) => {
            e.preventDefault();
            mutate({ name, age: 0 });
          };
          return (
            <div>
              {isPaused && <p> Waiting for network to come back </p>}
              <form>
                <input
                  name="name"
                  type={"text"}
                  onChange={(e) => setName(e.target.value)}
                  value={name}
                />
                <button disabled={isPaused} onClick={submitForm}>
                  Add
                </button>
              </form>
            </div>
          );
        };
        

        在前面的代码片段中,我们可以看到我们的SimpleMutation组件。现在,让我们尝试进行以用户为中心的方法练习,并了解我们可以编写哪些测试场景:

        • 作为用户,我想在突变进入暂停状态时看到暂停指示器:在这个场景中,我们想要渲染我们的组件,当我们尝试执行我们的突变时,暂停指示器消息应该出现。

        • 作为用户,我想在服务器上创建数据:在这个场景中,我们想要渲染我们的组件,填写表单,然后执行我们的突变。但是等等——我们的用户如何断言这一点?

        如您所见,最后一个场景有一个问题——UI 中缺乏我们的突变已成功执行的信息。

        通常,这类问题可以通过添加一个通知来解决,通知用户突变已成功执行。让用户知道突变成功始终是一个好的做法。按照这种方法,我们的测试将类似于以下内容:

        • 作为用户,我想在服务器上成功创建数据:在这个场景中,我们想要渲染我们的组件,填写表单,按下 添加 按钮,并等待成功消息出现

        正如你所见,我们现在有一种以用户为中心的方式来测试我们的突变。然而,出于某种原因,让我们假设我们无法对我们的 SimpleMutation 组件进行更改。我们如何确保我们的突变被执行?我们不得不求助于实现细节。我们的测试场景将类似于以下内容:

        • 作为用户,我想执行一个突变:在这个场景中,我们想要渲染我们的组件,填写表单,按下 添加 按钮,并断言我们的突变已被触发

        在本节中,我们将向您展示如何编写在理想(以用户为中心)的方法不可行时的测试。

        在我们编写测试之前,我们首先需要确保 MSW 请求被拦截并且成功:

        rest.post("*/name-api/*", (req, res, ctx) => {
            return res(
              ctx.status(201),
              ctx.json({
                hello: "user",
              })
            );
          })
        

        在前面的代码片段中,我们创建了一个请求处理器,并将其添加到我们的 handlers 数组中,该处理器执行以下操作。

        每当我们拦截到包含 /name-api/ 路径的端点的 POST 请求时,我们返回一个包含在主体中的 201 Created 响应,该响应包含一个具有 hello 属性的字符串对象。

        我们现在可以为我们 SimpleMutation 组件编写测试。为了回顾,以下是我们将要执行的测试:

        • 作为用户,我想在我突变进入暂停状态时看到暂停指示器

        • 作为用户,我想执行一个突变

        让我们看看我们创建的测试文件:

        import { axiosInstance } from "../api/userAPI";
        import { SimpleMutation } from "../Mutation";
        import { render, screen, waitFor } from
          "../utils/test-utils";
        import userEvent from "@testing-library/user-event";
        const postSpy = jest.spyOn(axiosInstance, "post");
        describe("SimpleMutation Tests", () => {
          test("data should be sent to the server", async () => {
            const name = "Daniel";
            render(<SimpleMutation />);
            const input = screen.getByRole("textbox");
            userEvent.type(input, name);
            userEvent.click(
              screen.getByRole("button", {
                name: /add/i,
              })
            );
            await waitFor(() =>
              expect(postSpy.mock.calls[0][1]).toEqual
                ({ name, age: 0 })
            );
          });
          test("on no network should display paused information", async () => {
            jest.spyOn(navigator, "onLine", "get").mockReturnValue
              (false);
            render(<SimpleMutation />);
            userEvent.click(
              screen.getByRole("button", {
                name: /add/i,
              })
            );
            const text = await screen.findByText("Waiting for
              network to come back");
            expect(text).toBeInTheDocument();
          });
        });
        

        让我们现在回顾一下前面代码片段中我们在做什么:

        1. 我们从我们的 API 文件中导入 axiosInstance,以及我们在 第六章 中看到的 SimpleMutation 组件,我们的自定义 render 函数,screen 对象,以及来自 test-utilswaitFor 函数。最后,我们从测试库的 user-event 伴侣中导入 userEvent 工具。

        这里需要注意的一点是我们使用的是 v14 之前的用户事件版本。

        1. 由于我们将其中一个测试与实现细节绑定,我们在 axiosInstancepost 函数上创建了一个 jest spy。这意味着我们可以检查 post 函数是否被调用,而不替换其实现。

        2. 我们创建我们的测试套件,并在其中创建我们的测试:

          1. 对于 "数据应该发送到服务器" 测试,我们执行以下操作:

            1. 创建一个变量来保存我们将要在突变中使用的名称。

            2. 渲染我们的 SimpleMutation 组件。

            3. 利用 getByRole 查询来获取我们的名称输入。

            4. 利用 userEventtype 事件并在我们的输入中键入我们的名称。

            5. 利用来自 userEventclick 事件并点击我们的 axiosInstancepost 函数,该函数使用我们的突变数据被调用。

          2. 对于 "on no network should display paused information" 测试,我们做以下操作:

            1. 由于我们想确保模拟离线状态,我们利用 spyOn 函数中的 mockReturnValue 函数来确保我们的 navigator onLine 属性返回 false。这将确保我们的代码知道它处于离线状态。

            2. 渲染我们的 SimpleMutation 组件。

            3. 利用来自 userEventclick 事件并点击 isPaused 属性为 true。因此,我们等待直到出现 "Waiting for network to come back" 消息。然后我们断言它已经在 DOM 上。

        从之前的测试中,我们了解到我们可以利用 Jest 间谍来检查我们的函数是否被调用,并确保我们的突变被执行。但这并不保证我们的组件在突变成功时的行为,因为我们没有渲染任何内容来让我们知道。在第一种情况下,始终确保你有用户所需的所有信息,这样他们就可以知道你的突变是成功的。如果你这样做,你可以从用户中心的角度进行测试,并避免实现细节。

        一个可能与测试相关的突变案例是在我们执行乐观更新时。然而,由于我们在本章中应用了上述模式之一,我们将在下一节中使用 React Hooks Testing Library 来测试它。

        测试使用 React Query 的自定义钩子

        在开发过程中,有时你的自定义钩子可能太复杂,无法与使用它们的组件一起测试。这可能是由于钩子的大小、复杂的逻辑,或者太多的场景,如果专注于用户中心的方法,会增加你的测试复杂性。为了解决这个问题,创建了 React Hooks Testing Library。

        现在,可能会非常诱人去到处使用这个,但别忘了,以用户为中心的方法最终会帮助你更快地找到问题并节省时间,如果你决定重构你的钩子工作方式。无论如何,如果你的钩子没有与组件一起使用或太复杂,React Hooks Testing Library 确实是值得考虑的。

        这是将 React Hooks Testing Library 添加到你的项目的步骤:

        • 如果你正在你的项目中运行 npm,请运行以下命令:

          npm install @testing-library/react-hooks react-test-renderer --save-dev
          
        • 如果你正在使用 Yarn,请运行以下命令:

          yarn add @testing-library/react-hooks react-test-renderer --dev
          
        • 如果你正在使用 pnpm,请运行以下命令:

          pnpm add @testing-library/react-hooks react-test-renderer --save-dev
          

        如果你正在使用 React 18 版本及以上,这里有一些需要注意的事情。你不需要安装 React Hooks Testing Library,因为从 13.1.0 版本开始,React Testing Library 包含 renderHook,它的工作方式与 React Hooks Testing Library 类似。

        如上一节末所述,我们将看到如何测试乐观更新。在我们编写测试之前,让我们看看应用本章中提到的模式后我们的代码看起来如何。

        要做到这一点,我们将利用之前显示的useOptimisticUpdateUserCreation钩子:

        import { useMutation, useQueryClient } from
          "@tanstack/react-query";
        import { userKeys } from "../../utils/queryKeyFactories";
        import { createUser } from "../../api/userAPI";
        const useOptimisticUpdateUserCreation = () => {
          const queryClient = useQueryClient();
          return useMutation({
            mutationFn: createUser,
            retry: 0,
            onSettled: () => queryClient.invalidateQueries
              (userKeys.all()),
            onMutate: async (user) => {
              await queryClient.cancelQueries(userKeys.all());
              const previousUsers = queryClient.getQueryData
                (userKeys.all());
              queryClient.setQueryData(userKeys.all(), (prevData) => [
                user,
                ...prevData,
              ]);
              return { previousUsers };
            },
            onError: (error, user, context) => {
              queryClient.setQueryData(userKeys.all(),
                context.previousUsers);
            },
          });
        };
        export default useOptimisticUpdateUserCreation;
        

        考虑到我们已经在 MSW 中处理了此钩子中的路由,我们可以开始考虑我们的测试。

        这些是我们将考虑的场景:

        • 我想在触发我的变更后立即执行乐观更新:在这种情况下,我们渲染我们的钩子,触发我们的变更,并等待直到受我们的变更影响的查询数据已更新。

        • 我想在我变更失败后撤销我的乐观更新数据:在这种情况下,我们渲染我们的钩子并触发我们的变更,当我们的变更失败时,我们的查询数据必须与触发变更之前保持相同。

        • 我想在我变更稳定后使我的查询失效:在这种情况下,我们渲染我们的钩子并触发我们的变更。一旦我们的变更稳定,我们检查查询是否已失效。

        考虑到这些场景,我们可以创建我们的测试。这是我们的测试文件可能的样子:

        import useOptimisticUpdateUserCreation from
          "../useOptimisticUpdateUserCreation";
        import { QueryClient, QueryClientProvider } from
          "@tanstack/react-query";
        import { renderHook } from "@testing-library/react-hooks";
        import { userKeys } from "../../../utils/
          queryKeyFactories";
        import { server } from "../../../mocks/server";
        import { rest } from "msw";
        const queryClient = new QueryClient({
          logger: {
            log: console.log,
            warn: console.warn,
            error: jest.fn(),
          },
        });
        const wrapper = ({ children }) => (
          <QueryClientProvider client={queryClient}>{children}
            </QueryClientProvider>
        );
        describe("useOptimisticUpdateUserCreation", () => {
          test("should perform optimistic update", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(
              () => useOptimisticUpdateUserCreation(),
              {
                wrapper,
              }
            );
            result.current.mutate({ name, age });
            await waitFor(() =>
              expect(queryClient.getQueryData(userKeys.all())).
                toEqual([{ name, age }])
            );
          });
          test("should revert optimistic update", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            server.use(rest.post("*", (req, res, ctx) =>
              res(ctx.status(403))));
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(() =>
              useOptimisticUpdateUserCreation(), {
              wrapper,
            });
            result.current.mutate({ name, age });
            await waitFor(() => expect(result.current.isError).
              toBe(true));
            await waitFor(() =>
              expect(queryClient.getQueryData(userKeys.all())).
                toEqual([])
            );
          });
          test("should invalidate query on settled", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            const invalidateQueriesSpy = jest.spyOn(queryClient,
              "invalidateQueries");
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(
              () => useOptimisticUpdateUserCreation(),
              {
                wrapper,
              }
            );
            result.current.mutate({ name, age });
            await waitFor(() => expect(result.current.isSuccess).
              toBe(true));
            expect(invalidateQueriesSpy).toHaveBeenCalledWith
              (userKeys.all());
          });
        });
        

        让我们现在回顾一下我们在前面的代码片段中做了什么:

        1. 我们首先进行必要的导入:

          1. 我们的useOptimisticUpdateUserCreation自定义钩子。

          2. 我们的QueryClientQueryClientProvider。记住,我们不会使用之前创建的customRender,所以我们必须在这里创建一个新的包装器。

          3. 从 React Hooks Testing Library 导入renderHook。如果您使用 React Testing Library 中的renderHook,请在那里导入它。

          4. 我们的userKeys工厂。

          5. 我们的 MSW server,这样我们就可以为我们的测试场景之一创建一个自定义响应模拟。

          6. MSW 的rest命名空间来为我们的测试场景之一创建相关的请求处理器。

        2. 我们创建我们的QueryClient实例并将其传递给我们的wrapper。这将用于包装我们的钩子以使用 React Query。

        3. 我们创建我们的测试套件,并在其中创建我们的测试:

          1. 对于"should perform optimistic update"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 创建nameage变量以避免在测试中使用魔法数字。

            3. 渲染我们的钩子,并从其中解构waitFor函数和result对象。

            4. 我们利用我们的result对象来访问我们的mutate函数并执行我们的变更操作。

            5. 我们使用waitFor函数循环我们的断言,直到它评估为true。在这种情况下,我们等待直到查询缓存已经根据userKeys.all()查询键缓存了乐观更新的数据。

          2. 对于"should revert optimistic update"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 利用我们的server use函数向我们的当前服务器实例添加一个请求处理器。在这种情况下,我们添加一个将捕获每个POST请求("*"表示此处理器将匹配每个路由)并返回403 Forbidden以使请求失败的处理程序。不用担心这会泄漏到其他测试中,因为我们确保在setupTests文件中调用了resetHandlers函数。这将确保此自定义请求处理器只会用于此测试。

            3. 再次创建nameage变量以避免测试中的魔法数字。

            4. 渲染我们的钩子并从其中解构waitFor函数和result对象。

            5. 利用我们的result对象来访问我们的mutate函数并执行我们的变异。

            6. 使用waitFor函数等待直到我们的钩子的isError属性为true

            7. 一旦我们确认我们的变异失败,我们再次利用waitFor函数等待,直到在userKeys.all()键下缓存的查询数据是我们变异之前的空数组。

          3. 对于"should invalidate query on settled"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 由于我们没有渲染查询以确保它在变异后更新,我们在queryClientinvalidateQueries方法上创建invalidateQueriesSpy

            3. 创建nameage变量以避免测试中的魔法数字。

            4. 渲染我们的钩子并从其中解构waitFor函数和result对象。

            5. 利用我们的result对象来访问我们的mutate函数并执行我们的变异。

            6. 等待直到我们的isSuccesstrue。这意味着我们的变异是成功的。

            7. 如果我们的变异成功,我们可以断言invalidateQueriesSpy被调用时带有userKeys.all()。这意味着我们的onSettled函数被调用,并且查询将在之后被无效化。

        现在我们已经处理了如何使用 React Hooks Testing Library 测试自定义钩子的方法。这全部关于渲染你的钩子并利用其结果来访问你的钩子返回的内容以执行你的操作和断言。

        只为了方便,并且让你看到我们测试查询的场景,让我们看看我们如何测试在检查数据是否 已获取部分中看到的useMultipleQueriesV2钩子。

        对于这个钩子,我们只需要一个测试场景:

        • 我希望我的并行查询能够获取数据:在这种情况下,我们渲染我们的钩子并等待它返回它获取的三个查询的数据。

        像之前的钩子一样,我们之前已经设置了我们的 MSW 请求处理器,所以我们不需要担心它们。

        让我们看看我们的useMultipleQueriesV2钩子的测试文件:

        import useMultipleQueriesV2 from "../useMultipleQueriesV2";
        import { QueryClient, QueryClientProvider } from
          "@tanstack/react-query";
        import { renderHook } from "@testing-library/react-hooks";
        const queryClient = new QueryClient();
        const wrapper = ({ children }) => (
          <QueryClientProvider client={queryClient}>{children}
            </QueryClientProvider>
        );
        describe("useMultipleQueriesV2", () => {
          test("should fetch all data", async () => {
            const { result, waitFor } = renderHook(() =>
              useMultipleQueriesV2(), {
              wrapper,
            });
            await waitFor(() =>
              expect(result.current.multipleQueries[0].data.hello).
                toBeDefined()
            );
            expect(result.current.multipleQueries[0].data.hello).
              toBe("userOne");
            expect(result.current.multipleQueries[1].data.hello).
              toBe("userTwo");
            expect(result.current.multipleQueries[2].data.hello).
              toBe("userThree");
          });
        });
        

        让我们现在回顾一下前面片段中我们在做什么:

        1. 我们首先进行必要的导入:

          1. 我们的useMultipleQueriesV2自定义钩子。

          2. 我们的QueryClientQueryClientProvider

          3. 来自 React Hooks Testing Library 的renderHook。如果您正在使用 React Testing Library 中的renderHook,请从那里导入。

        2. 我们创建我们的QueryClient实例,并将其传递给我们的wrapper。这将用于包装我们的钩子以使用 React Query。

        3. 我们创建我们的测试套件,并在其中创建我们的测试:

          • 对于"should fetch all data"测试,我们做以下操作:

            1. 使用renderHook函数渲染我们的钩子,并从其中解构result对象和waitFor函数。

            2. 等待第一个查询的数据定义。

            3. 由于数据现在已定义,我们断言第一个查询返回的对象上的hello属性具有userOne

            4. 我们还断言第二个查询返回的对象上的hello属性具有userTwo

            5. 我们还断言第三个查询返回的对象上的hello属性具有userThree

        如您所见,测试钩子和利用查询要简单得多,因为它主要只涉及渲染和断言。这是一个测试示例,我没有测试这个钩子,因为使用该组件进行测试要容易得多。只需检查我们在检查数据是否 已获取部分所做的测试即可。

        在考虑到所有这些知识后,您应该能够编写代码,然后在夜间睡得非常香,因为您还编写了有价值的测试,确保没有任何东西会出错。

        摘要

        在本章中,我们学习了如何测试利用 React Query 的组件和钩子。恭喜!感谢本章,您已经成为了一名全栈的 React Query 大师!

        您了解到 MSW 可以通过拥有几个请求处理程序来节省您在开发和测试 React Query 代码时的大量时间。

        您遇到了三种可以使您的代码更易于阅读和重用的模式(创建 API 文件、利用查询键工厂和创建钩子文件夹),并看到了它们在适应我们之前章节中看到的代码时的价值。

        最后,您学习了何时使用 React Testing Library 和 React Hooks Testing Library 来测试您的查询和突变,并且您将在编写测试时始终将以用户为中心的方法放在首位。

        再次恭喜!现在您应该能够在任何场景下利用 React Query,并在夜间睡得更好,因为您可以为它编写有价值的测试。现在,运用这些知识,去说服您的队友关于惊人的 TanStack Query 的价值,以及其 React 适配器,即 React Query,将使他们的服务器状态管理变得容易得多。