用自定义React Hooks改进异步网络状态处理

815 阅读6分钟

React为我们提供了灵活性,使我们可以选择如何解决应用程序中的问题(如状态、网络和风格管理)。一个伟大的代码库有问题点,并以标准和一致的可重复模式来解决。

而且,作为前端工程师,正确地将网络状态的变化信息转达给用户是至关重要的,因为我们建立的大多数应用程序需要与一个或多个服务器进行交互。我们可以通过使用自定义React Hooks来实现这些目标。

在这篇文章中,我将介绍网络请求存在的各种状态,并告诉你如何在自定义Hooks中保持请求管理。我还会指导你建立一个采用这些Hooks的小程序。

什么是网络请求?

一个网络请求通常存在于这些状态中。

  • idle
  • loading/processing/in-flight
  • success
  • error

idle 网络请求状态是网络请求的默认(和结束)阶段。在loading 阶段,客户端等待服务器的确认和数据包,然后过渡到successerror 状态。

Diagram Demonstrating The Network Request Process

网络状态的转换。

使用自定义React Hooks对网络请求进行本地化

为了保持网络请求的可测试性并与业务逻辑解耦,最好用自定义Hooks来管理请求。这可以保持你的代码精简,并使你容易执行特殊的一次性操作,如对网络响应进行数据转换。

例如,一个获取博客文章列表的请求可以保存在一个usePostsQuery 自定义Hook中,就像下面这个。

import { useState, useEffect } from 'react'

const api = {
  GET: async (url) => {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  }
} 

export default function usePostsQuery() {
  const [error, setError] = useState()
  const [status, setStatus] = useState('idle')
  const [data, setData] =useState()

  const startFetch = async () => {
    try {
      let data = await api.GET('/posts')

      setError()
      setStatus('success')
      setData(data)
    } catch (error) {
      setError(error)
      setStatus('error')
    }
  }

  useEffect(() => {
    startFetch()
  }, []);  

  return {
    data,
    error,
    isSuccess: status === 'success',
    isError: status === 'error'
    refetch: startFetch
  }
}

通过利用React Query(我的首选工具),这个Hook可以变得更加简洁。

import { useQuery } from "react-query";

export default function usePostsQuery() {
  return useQuery("posts", () =>
    api.GET("/posts")
  );
}

创建项目

让我们建立一个名为Betflix 的小应用,你既可以访问也可以克隆它。这个应用将允许朋友们从一组赛程中选择体育队并进行预测。

注意:为了简洁起见,我将跳过解释这个概念验证中使用的比较平凡的组件。欢迎你来探索这个的全部代码

Betflix App Showing Sport Teams To Be Chosen

首先,我们将创建一个新的React项目并启动开发服务器。

npx create-react-app betflix
cd betflix
npm start

我们需要安装HTTP请求的依赖项、无服务器函数代理和管理数据库(用于保存固定装置和其他记录)。

npm install react-query react-toast-notifications http-proxy-middleware @supabase/supabase-js --save

我还会把Netlify Lambda和CLI作为开发依赖项。

npm install netlify-lambda netlify -D

当我们完成的时候,你应该有一个像下面这样的目录结构。

Betflix's Directory Structure Displaying Various Folders

用自定义的React Hooks显示固定装置列表

我们将更新<App /> 组件,以显示从无服务器的后端获取的固定装置列表。我们将按顺序创建和处理对赌注列表和固定设备列表的请求。

import "./App.css";

import Fixture from "./components/Fixture";
import Loader from "./components/Loader";

import useBetsQuery from "./hooks/queries/useBetsQuery";
import useFixturesQuery from "./hooks/queries/useFixturesQuery";

function App() {
  const { data, isLoading, isError } = useFixturesQuery();
  const {
    data: bets,
    isLoading: betsLoading,
    isError: betsErrored,
  } = useBetsQuery();

  if (isLoading || betsLoading) return <Loader />;
  if (isError || betsErrored)
    return <p>We encountered an error fetching data</p>;

  const sortFixtures = (fixtureA, fixtureB) => {
    return (
      bets.hasOwnProperty(fixtureB.fixture.id) -
        bets.hasOwnProperty(fixtureA.fixture.id) ||
      fixtureB.fixture.status.elapsed - fixtureA.fixture.status.elapsed
    );
  };

  return (
    <div className="App">
      <header className="App-header">
        <h1 className="App-header__title">Upcoming fixtures</h1>
      </header>
      <section className="Fixtures">
        {data.results.response.length ? (
          <>
            {data.results.response
              .sort(sortFixtures)
              .map(({ fixture, teams: { away, home } }) => (
                <Fixture
                  key={fixture.id}
                  fixture={fixture}
                  away={away}
                  home={home}
                  isBetPlaced={bets.hasOwnProperty(fixture.id)}
                  defaultSelectedTeam={bets[fixture.id].choice}
                  defaultAmount={bets[fixture.id].amount}
                />
              ))}
          </>
        ) : (
          <div>No fixtures at the moment</div>
        )}
      </section>
    </div>
  );
}

export default App;

从上面的代码中,我们已经声明了对useBetsQueryuseFixturesQuery 的依赖,所以我们现在要定义它们。

useBetsQuery 是一个自定义的Hook,用于获取投注列表并将其转换为一个键入对象的映射,我们可以用它来跟踪一个固定装置的投注状态。

让我们在/src/hooks/queries 中创建useBetsQuery.js ,并更新它。

import { useQuery } from "react-query";

const ENDPOINT = "/.netlify/functions/fetchBets";

// Normalize the bets payload into a keyed map with `fixture_id` as the key
function normalizeBets(betsList) {
  return betsList.reduce(
    (acc, curr) => ({
      ...acc,
      [curr.fixture_id]: curr,
    }),
    {}
  );
}

// Because we'll use the fetch API (instead of Axios), we need to explicitly return a 
// Promise when an error occurs so React Query can change the status.
const getBets = async (url) => {
  const response = await fetch(url);
  const data = await response.json();
  if (response.ok) {
    return normalizeBets(data.results);
  }
  return Promise.reject(new Error(data.message));
};

export default function useBetsQuery() {
  return useQuery("bets", () => getBets(ENDPOINT));
}

完成这些后,我们还需要创建我们要获取的自定义Hook。在src/hooks/queries 中创建useFixturesQuery.js 钩子,并添加下面的代码。

import { useQuery } from "react-query";

const getFixtures = async (url) => {
  const response = await fetch(url);
  const data = await response.json();
  return data;
};

export default function useFixturesQuery() {
  return useQuery("fixtures", () =>
    getFixtures("/.netlify/functions/fetchFixtures")
  );
}

我们现在准备定义显示单个灯具信息的组件。

创建<Fixture /> 组件

我们将在src/components/Fixture.js 中创建<Fixture/> 组件,显示主队和客队的信息。我们还引入了两个新的React Hooks,即useMutationNotificationusePlaceBetMutation Hooks。

useMutationNotification 是一个有趣的自定义Hook,它允许我们以可预测的符合人体工程学的方式处理网络状态变化,因此我们可以直接对用户发起的行动提供反馈。

import { useEffect, useState } from "react";
import { useToasts } from "react-toast-notifications";
import { ReactComponent as ArrowLeft } from "../assets/svg/arrowLeft.svg";
import { ReactComponent as ChevronRight } from "../assets/svg/chevronRight.svg";
import TeamCard from "./TeamCard";
import FormInput from "./FormInput";
import useMutationNotification from "../hooks/useMutationNotification";
import usePlaceBetMutation from "../hooks/queries/usePlaceBetMutation";
import Loader from "./Loader";

function Fixture({
  fixture,
  away,
  home,
  isBetPlaced,
  defaultAmount,
  defaultSelectedTeam,
}) {
  const [amount, setAmount] = useState(defaultAmount || 0);
  const [selectedTeam, setSelectedTeam] = useState(defaultSelectedTeam);
  const [betPlaced, setBetPlaced] = useState(isBetPlaced);
  const { addToast } = useToasts();
  const [doPlaceBetRequest, placeBetState] = usePlaceBetMutation();

  useMutationNotification({
    ...placeBetState,
    useServerMessage: false,
    entity: "bet",
    actionType: "place",
  });
  useEffect(() => {
    if (placeBetState.isSuccess) setBetPlaced(true);
  }, [placeBetState.isSuccess]);
  const teams = {
    away,
    home,
  };

  const status = !fixture.status.elapsed ? "Up next" : "In progress";

  const doAmountUpdate = (e) => setAmount(e.target.value);
  const doTeamUpdate = (team) => {
    if (betPlaced) return;
    setSelectedTeam(team);
  };
  const doPlaceBet = () => {
    if (!selectedTeam || amount <= 0) {
      addToast("Please select a team and add an amount", {
        appearance: "info",
        autoDismiss: true,
      });
      return;
    }
    doPlaceBetRequest({
      amount,
      choice: selectedTeam,
      fixture_id: fixture.id,
    });
  };

  return (
    <div className="Fixture">
      <section className="Fixture__teams">
        <TeamCard
          name={home.name}
          logo={home.logo}
          id={home.id}
          type={"home"}
          selected={selectedTeam === "home"}
          onTeamChange={doTeamUpdate}
        />
        <div className="Fixture__separator">vs</div>
        <TeamCard
          name={away.name}
          logo={away.logo}
          id={away.id}
          type={"away"}
          selected={selectedTeam === "away"}
          onTeamChange={doTeamUpdate}
        />
      </section>

      {!betPlaced ? (
        <>
          <section className="Fixture__controls">
            <div className="Fixture__control">
              <FormInput
                label={"Amount"}
                name={`amount-${fixture.id}`}
                type="number"
                value={amount}
                onChange={doAmountUpdate}
              />
            </div>
            <div className="Fixture__controls__separator">
              <ArrowLeft />
            </div>
            <div className="Fixture__control">
              <FormInput
                label={"Potential Winnings"}
                name={`potential-winnings-${fixture.id}`}
                value={amount * 2 || 0}
                disabled
              />
            </div>
          </section>
          <section className="Fixture__footer">
            <div className="Fixture__status">
              <span className="Fixture__status__dot"></span>
              {status}
            </div>
            {!placeBetState.isLoading ? (
              <button className="Button" onClick={doPlaceBet}>
                Place bet <ChevronRight />
              </button>
            ) : (
              <Loader />
            )}
          </section>
        </>
      ) : (
        <section className="Fixture__controls">
          <p>
            You placed a <b>${amount}</b> bet on{" "}
            <b className="u-text-primary">{teams[selectedTeam]?.name}</b> to
            potentially win <b className="u-text-primary">${amount * 2}</b>
          </p>
        </section>
      )}
    </div>
  );
}

Fixture.defaultProps = {
  isBetPlaced: false,
};

export default Fixture;

在上面的代码中,我们声明了对一些Hooks的依赖性。

useMutationNotification 将接受网络请求状态选项(isErrorisSuccess),并允许我们显示来自服务器的错误信息(如果我们将useServerMessage 设置为true ),或者将entityactionType 字符串传入,以向用户提供一个通用信息。

让我们在src/hooks 中创建useMutationNotification.js ,并用下面的代码来更新它。

import { useEffect, useState } from "react";
import useShowToast from "./useShowToast";

function capFirst(string) {
  return string.charAt(0).toUpperCase() + string.slice(1);
}

function useMutationNotification({
  isError,
  isSuccess,
  actionType = "create",
  entity,
  data,
  error,
  useServerMessage = true,
}) {
  const [notificationConfig, setNotificationConfig] = useState(null);
  const showToast = useShowToast();

  useEffect(() => {
    if (isError) {
      setNotificationConfig({
        type: "error",
        message: useServerMessage
          ? error.message
          : `${entity} could not be ${actionType}d`,
      });
    }
  }, [
    useServerMessage,
    isError,
    setNotificationConfig,
    entity,
    actionType,
    error,
  ]);

  useEffect(() => {
    if (isSuccess) {
      setNotificationConfig({
        type: "success",
        message: useServerMessage
          ? data.message
          : `${entity} successfully ${actionType}d`,
      });
    }
  }, [
    useServerMessage,
    isSuccess,
    setNotificationConfig,
    entity,
    actionType,
    data,
  ]);

  useEffect(() => {
    if (notificationConfig) {
      const { type, message } = notificationConfig;
      showToast({ type, message: capFirst(message) });
    }
  }, [notificationConfig, showToast]);
}

export default useMutationNotification;

然后我们将定义我们打算在下注时使用的usePlaceBet 变异。我们将返回突变动作和它的状态。在src/hooks/queries 中创建usePlaceBetMutation ,并将其更新为下面的代码。

import { useMutation } from "react-query";

const ENDPOINT = "/.netlify/functions/placeBet";

export default function usePlaceBetMutation() {
  const request = async (payload) => {
    const res = await fetch(ENDPOINT, {
      method: "POST",
      body: JSON.stringify(payload),
    });
    const data = await res.json();
    if (!res.ok) return Promise.reject(new Error(data.message));
    return data;
  };
  const { mutate, ...mutationState } = useMutation(request);
  return [mutate, mutationState];
}

有了这些更新,我们现在可以用简单、易读的方式处理突变的网络状态变化。

Betflix App Showing Sport Teams To Be Chosen

结论

对网络状态变化做出反应可能是一种挑战,但它也是为用户提供更有意义的体验的一个巨大机会。

你可以查看React Query文档,了解更多关于在构建React应用时为用户增强网络状态体验的信息。你可以在GitHub上找到这个演示概念验证的完整源代码。

The postImprove async network state handling with custom React Hooksappeared first onLogRocket Blog.