React-和-TypeScript-学习指南-五-

57 阅读1小时+

React 和 TypeScript 学习指南(五)

原文:zh.annas-archive.org/md5/5da49be498b161721792aaa3c885dee9

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:状态管理

在本章中,我们将了解 共享状态,这是由多个不同组件使用的状态。我们将探讨管理共享状态的三个方法,并讨论每种方法的优缺点。

为了实现这一点,我们将构建一个简单的应用程序,其中包含一个显示用户名的头部,主内容也会引用用户名。用户名将存储在需要由多个组件访问的状态中。

我们将从最简单的状态解决方案开始。这是使用 React 的一个状态钩子来存储状态,并通过属性将其传递给其他组件。这种方法通常被称为 属性钻取

我们将要了解的第二种方法是 React 中的一个特性,称为 上下文。我们将学习如何创建一个包含状态的上下文,并允许其他组件访问它。

我们将要介绍的最后一个方法是流行的库 Redux。在重构应用程序以使用 Redux 之前,我们将花时间了解 Redux 是什么以及其概念。

因此,我们将涵盖以下主题:

  • 创建项目

  • 使用属性钻取

  • 使用 React 上下文

  • 使用 Redux

技术要求

在本章中,我们将使用以下技术:

本章中所有的代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter8

创建项目

我们将使用 Visual Studio Code 和一个新的基于 Create React App 的项目设置来开发我们的表单。我们之前已经多次介绍过这一点,所以本章中不会介绍步骤——相反,请参阅 第三章设置 React 和 TypeScript

我们将使用 Tailwind CSS 来设计表单样式。我们之前也介绍了如何在 Create React App 中安装和配置 Tailwind,请参阅 第五章前端设计方法。因此,在创建 React 和 TypeScript 项目之后,安装并配置 Tailwind。

我们还将使用 @tailwindcss/forms 插件来设计表单样式。因此,也要安装这个插件——有关如何操作的更多信息,请参阅 第七章与表单一起工作

我们将要构建的应用程序将包含一个头部和其下的某些内容。以下是我们将创建的组件结构:

图 8.1 – 应用组件结构

图 8.1 – 应用组件结构

该头部将包含一个登录按钮,用于验证和授权用户以获取其姓名和权限。一旦验证通过,用户的姓名将在应用头部显示,并在内容中欢迎用户。如果用户具有管理员权限,将显示重要内容。

因此,执行以下步骤以创建应用中所需的初始文件版本,而不进行任何语句管理(一些代码片段可能较长 - 不要忘记您可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter8/prop-drilling复制它们):

  1. 我们将首先创建一个包含验证用户功能的文件。在src文件夹中创建一个名为api的文件夹。然后,在api文件夹中创建一个名为authenticate.ts的文件,并添加以下内容:

    export type User = {
    
      id: string;
    
      name: string;
    
    };
    
    export function authenticate(): Promise<User | undefined> {
    
      return new Promise((resolve) =>
    
        setTimeout(() => resolve({ id: "1", name: "Bob" }),       1000)
    
      );
    
    }
    

该函数模拟了名为 Bob 的用户成功验证。

  1. 接下来,我们将创建一个包含授权用户功能的文件。因此,在api文件夹中创建一个名为authorize.ts的文件,并添加以下内容:

    export function authorize(id: string): Promise<string[]> {
    
      return new Promise((resolve) =>
    
        setTimeout(() => resolve(["admin"]), 1000)
    
      );
    
    }
    

该函数模拟了用户被授权具有管理员权限。

  1. 接下来,我们将创建一个用于应用头部的组件。在src文件夹中创建一个名为Header.tsx的文件,并添加以下内容:

    import { User } from './api/authenticate';
    
    type Props = {
    
      user: undefined | User;
    
      onSignInClick: () => void;
    
      loading: boolean;
    
    };
    

组件有一个用于用户的属性,如果用户尚未验证,则该属性为undefined。组件还有一个名为onSignInClick的属性,用于loading,它确定当用户验证或授权时应用是否处于加载状态。

  1. 将以下组件实现添加到Header.tsx中:

    export function Header({
    
      user,
    
      onSignInClick,
    
      loading,
    
    }: Props) {
    
      return (
    
        <header className="flex justify-between items-center       border-b-2 border-gray-100 py-6">
    
          {user ? (
    
            <span className="ml-auto font-bold">
    
              {user.name} has signed in
    
            </span>
    
          ) : (
    
            <button
    
              onClick={onSignInClick}
    
              className="whitespace-nowrap inline-flex items-            center justify-center ml-auto px-4 py-2 w-36             border border-transparent rounded-md             shadow-sm text-base font-medium text-white             bg-indigo-600 hover:bg-indigo-700"
    
              disabled={loading}
    
            >
    
              {loading ? '...' : 'Sign in'}
    
            </button>
    
          )}
    
        </header>
    
      );
    
    }
    

如果用户已验证,组件会通知用户他们已登录。如果用户未经验证,组件会显示一个登录按钮。

  1. 接下来,我们将实现一个用于主应用内容的组件。在src文件夹中创建一个名为Main.tsx的文件,并添加以下内容:

    import { User } from './api/authenticate';
    
    import { Content } from './Content';
    
    type Props = {
    
      user: undefined | User;
    
      permissions: undefined | string[];
    
    };
    

组件有一个用于用户及其权限的属性。我们已导入一个名为Content的组件,我们将在第 7 步中创建它。

  1. 现在,将以下组件实现添加到Main.tsx中:

    export function Main({ user, permissions }: Props) {
    
      return (
    
        <main className="py-8">
    
          <h1 className="text-3xl text-center font-bold         underline">Welcome</h1>
    
          <p className="mt-8 text-xl text-center">
    
            {user ? `Hello ${user.name}!` : "Please sign in"}
    
          </p>
    
          <Content permissions={permissions} />
    
        </main>
    
      );
    
    }
    

组件指示用户登录,如果他们未经验证或显示传递用户权限的Content组件。

  1. src文件夹中创建的最后一个文件被命名为Content.tsx。将以下内容添加到该文件中:

    type Props = {
    
      permissions: undefined | string[];
    
    };
    
    export function Content({ permissions }: Props) {
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return permissions.includes('admin') ? (
    
        <p className="mt-4 text-l text-center">
    
          Some important stuff that only an admin can do
    
        </p>
    
      ) : (
    
        <p className="mt-4 text-l text-center">
    
          Insufficient permissions
    
        </p>
    
      );
    
    }
    

如果用户未授权,组件不显示任何内容。如果用户具有管理员权限,则显示一些重要内容。否则,它会通知用户他们缺少权限。

这样就完成了项目设置。应用将编译并运行,但不会显示我们创建的任何组件,因为我们还没有在App组件中引用它们。我们将在分享用户和权限信息到多个组件时进行此操作。

使用钻探法

在这种第一种状态管理方法中,我们将userpermissionsloading状态存储在App组件中。然后,App组件将使用 props 将此状态传递给HeaderMain组件。

因此,这种方法使用了我们已知的 React 特性。这种方法被称为属性钻取,因为状态是通过 props 向下传递给组件树的。

执行以下步骤来重构App组件,以存储userpermissionsloading状态,并将此状态传递给HeaderMain组件:

  1. 打开App.tsx,首先删除所有现有代码,并添加以下导入语句:

    import { useReducer } from 'react';
    
    import { Header } from './Header';
    
    import { Main } from './Main';
    
    import { authenticate, User } from './api/authenticate';
    
    import { authorize } from './api/authorize';
    

我们从 React 中导入了useReducer来存储状态。我们还导入了HeaderMain组件,以便我们可以使用状态值来渲染它们。最后,我们导入了authenticateauthorize函数,因为我们将在该组件中创建登录处理程序。

  1. 在导入语句之后,添加一个状态类型并创建一个初始状态值的变量:

    type State = {
    
      user: undefined | User,
    
      permissions: undefined | string[],
    
      loading: boolean,
    
    };
    
    const initialState: State = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 接下来,为可以更新状态的不同的动作创建一个类型:

    type Action =
    
      | {
    
          type: "authenticate",
    
        }
    
      | {
    
          type: "authenticated",
    
          user: User | undefined,
    
        }
    
      | {
    
          type: "authorize",
    
        }
    
      | {
    
          type: "authorized",
    
          permissions: string[],
    
        };
    

"authenticate"动作将启动认证过程,当完成时发生"authenticated"。同样,"authorize"动作将启动授权过程,当完成时发生"authorized"

  1. 接下来,添加一个更新状态的reducer函数:

    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case "authenticate":
    
          return { ...state, loading: true };
    
        case "authenticated":
    
          return { ...state, loading: false, user: action.        user };
    
        case "authorize":
    
          return { ...state, loading: true };
    
        case "authorized":
    
          return {
    
            ...state,
    
            loading: false,
    
            permissions: action.permissions,
    
          };
    
        default:
    
          return state;
    
      }
    
    }
    

该函数接受现有状态和动作作为参数。该函数使用动作类型的 switch 语句在每个分支中创建状态的新版本。

  1. 现在,让我们按照以下方式定义App组件:

    function App() {
    
      const [{ user, permissions, loading }, dispatch] =
    
        useReducer(reducer, initialState);
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <Header
    
            user={user}
    
            onSignInClick={handleSignInClick}
    
            loading={loading}
    
          />
    
          <Main user={user} permissions={permissions} />
    
        </div>
    
      );
    
    }
    
    export default App;
    

该组件使用我们之前定义的reducer函数和initialState变量来useReducer。我们从useReducer中解构了userpermissionsloading状态值。在 JSX 中,我们渲染了HeaderMain组件,并将适当的状态值作为 props 传递。

  1. JSX 中的Header元素引用了一个名为handleSignInClick的处理程序,需要实现。在返回语句上方创建此处理程序,如下所示:

    async function handleSignInClick() {
    
      dispatch({ type: "authenticate" });
    
      const authenticatedUser = await authenticate();
    
      dispatch({
    
        type: "authenticated",
    
        user: authenticatedUser,
    
      });
    
      if (authenticatedUser !== undefined) {
    
        dispatch({ type: "authorize" });
    
        const authorizedPermissions = await authorize(
    
          authenticatedUser.id
    
        );
    
        dispatch({
    
          type: "authorized",
    
          permissions: authorizedPermissions,
    
        });
    
      }
    
    }
    

登录处理程序在过程中验证和授权用户,并分派必要的动作。

  1. 通过在终端中运行npm start来以开发模式运行应用程序。应用程序如图所示:

图 8.2 – 登录前的应用

图 8.2 – 登录前的应用

  1. 点击登录按钮。然后发生认证和授权过程,几秒钟后,出现以下屏幕:

图 8.3 – 登录后的应用

图 8.3 – 登录后的应用

这就完成了属性钻取方法。

这种方法的优点是简单,并且使用了我们已熟悉的 React 特性。这种方法的缺点是它强制所有在提供状态和访问状态的组件之间的组件都必须有一个该状态的 prop。因此,一些不需要访问状态的组件被迫访问它。例如,Main 组件 – permissions 状态被迫通过它传递到 Content 组件。

本节的关键点是,使用 props 在几个相邻组件之间共享状态是可以的,但不是在组件树中相隔甚远的许多组件之间共享的最佳选择。

接下来,保持应用运行,我们将探讨一个更适合在许多组件之间共享状态的解决方案。

使用 React 上下文

在本节中,我们将学习 React 中称为 上下文 的一个特性。然后,我们将从上一节重构应用以使用 React 上下文。

理解 React 上下文

React 上下文是一个对象,组件可以访问它。该对象可以包含状态值,因此它提供了一种在组件之间共享状态的机制。

使用 createContext 函数创建上下文,如下所示:

const SomeContext = createContext<ContextType>(defaultValue);

必须将上下文的默认值传递给 createContext。它还有一个泛型类型参数,用于表示由 createContext 创建的对象的类型。

上下文还包含一个 Provider 组件,需要将其放置在组件树中需要访问上下文对象的组件之上。可以创建一个包装器组件来存储共享状态,并将其传递给上下文 Provider 组件,如下所示:

export function SomeProvider({ children }: Props) {
  const [someState, setSomeState] = useState(initialState);
  return (
    <SomeContext.Provider value={{ someState }}>
      {children}
    </SomeContext.Provider>
  );
}

在前面的例子中使用了 useState 来处理状态,但也可以使用 useReducer

提供者包装器组件可以适当地放置在组件树中,位于需要共享状态的组件之上:

function App() {
  return (
    <SomeProvider>
      <Header />
      <Main />
    </SomeProvider>
  );
}

React 还包含一个 useContext 钩子,可以用来使上下文值可以作为钩子被消费,如下所示:

const { someState } = useContext(SomeContext);

必须将上下文传递给 useContext,并可以从上下文对象的结果中解构属性。

因此,想要访问共享状态的组件可以使用 useContext 如下访问:

export function SomeComponent() {
  const { someState } = useContext(SomeContext);
  return <div>I have access to {someState}</div>;
}

关于 React 上下文的更多信息,请参阅以下链接:reactjs.org/docs/context.html

现在我们已经了解了 React 上下文,我们将在上一节创建的应用中使用它。

使用 React 上下文

我们将从上一节开始重构应用,以使用 React 上下文。我们首先创建一个包含上下文和提供者包装器的文件。然后,在提供者包装器中使用 useReducer 来存储状态。我们还将创建一个 useContext 的包装器,以便更容易地消费它。

因此,要完成此操作,请执行以下步骤:

  1. 首先在 src 文件夹中创建一个名为 AppContext.tsx 的文件。这将包含上下文、提供者包装器和 useContext 包装器。

  2. 将以下导入语句添加到 AppContext.tsx 中:

    import {
    
      createContext,
    
      useContext,
    
      useReducer,
    
      ReactNode,
    
    } from 'react';
    
    import { User } from './api/authenticate';
    

我们已经从 React 导入了我们需要的所有函数,包括我们将需要的用于提供者包装器 children 属性的 ReactNode 类型。我们还导入了 User 类型,这是我们需要的用户状态类型。

  1. 我们需要添加一个状态类型和一个初始状态值的变量。我们已经在 App.tsx 中有了这些,所以以下行可以从 App.tsx 移动到 AppContext.tsx

    type State = {
    
      user: undefined | User,
    
      permissions: undefined | string[],
    
      loading: boolean,
    
    };
    
    const initialState = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 类似地,Action 类型以及 reducer 函数可以从 App.tsx 移动到 AppContext.tsx。以下是移动的代码行:

    type Action =
    
      | {
    
          type: "authenticate",
    
        }
    
      | {
    
          type: "authenticated",
    
          user: User | undefined,
    
        }
    
      | {
    
          type: "authorize",
    
        }
    
      | {
    
          type: "authorized",
    
          permissions: string[],
    
        };
    
    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case "authenticate":
    
          return { ...state, loading: true };
    
        case "authenticated":
    
          return { ...state, loading: false, user: action.        user };
    
        case "authorize":
    
          return { ...state, loading: true };
    
        case "authorized":
    
          return { ...state, loading: false, permissions:         action.permissions };
    
        default:
    
          return state;
    
      }
    
    }
    

注意,在移动此函数后,App.tsx 文件将引发编译错误。我们将在下一组指令中解决这个问题。

  1. 接下来,我们将在 AppContext.tsx 中创建一个上下文类型:

    type AppContextType = State & {
    
      dispatch: React.Dispatch<Action>,
    
    };
    

上下文将包含状态值和一个用于分发操作的 dispatch 函数。

  1. 现在,我们可以创建上下文,如下所示:

    const AppContext = createContext<AppContextType>({
    
      ...initialState,
    
      dispatch: () => {},
    
    });
    

我们将上下文命名为 AppContext。我们使用 initialState 变量和虚拟的 dispatch 函数作为默认上下文值。

  1. 接下来,我们可以实现提供者包装器,如下所示:

    type Props = {
    
      children: ReactNode;
    
    };
    
    export function AppProvider({ children }: Props) {
    
      const [{ user, permissions, loading }, dispatch] =
    
        useReducer(reducer, initialState);
    
      return (
    
        <AppContext.Provider
    
          value={{
    
            user,
    
            permissions,
    
            loading,
    
            dispatch,
    
          }}
    
        >
    
          {children}
    
        </AppContext.Provider>
    
      );
    
    }
    

我们将组件命名为 AppProvider,它返回上下文的 Provider 组件,包含状态值和 dispatch 函数。

  1. AppContext.tsx 中最后要做的就是创建一个 useContext 的包装器,如下所示:

    export const useAppContext = () => useContext(AppContext);
    

这就完成了我们在 AppContext.tsx 中需要做的所有工作。

因此,AppContext.tsx 导出 AppProvider 组件,可以放置在组件树中的 HeaderMain 之上,以便它们可以访问用户和权限信息。AppContext.tsx 还导出 useAppContext,以便 HeaderMainContent 组件可以使用它来获取访问用户和权限信息。

现在,执行以下步骤以对 AppHeaderMainContent 组件进行必要的更改,以便从 AppContext 访问用户和权限信息:

  1. 我们将从 Header.tsx 开始。首先导入 authenticateauthorizeuseAppContext 函数。同时,移除 User 类型以及 Header 组件的属性:

    import { authenticate } from './api/authenticate';
    
    import { authorize } from './api/authorize';
    
    import { useAppContext } from './AppContext';
    
    export function Header() {
    
      return ...
    
    }
    
  2. Header 将现在处理登录过程,而不是 App。因此,将 App.tsx 中的 handleSignInClick 处理器移动到 Header.tsx,并将其放置在返回语句之上,如下所示:

    export function Header() {
    
      async function handleSignInClick() {
    
        dispatch({ type: 'authenticate' });
    
        const authenticatedUser = await authenticate();
    
        dispatch({
    
          type: 'authenticated',
    
          user: authenticatedUser,
    
        });
    
        if (authenticatedUser !== undefined) {
    
          dispatch({ type: 'authorize' });
    
          const authorizedPermissions = await authorize(
    
            authenticatedUser.id
    
          );
    
          dispatch({
    
            type: 'authorized',
    
            permissions: authorizedPermissions,
    
          });
    
        }
    
      }
    
      return ...
    
    }
    
  3. 更新登录点击处理器以引用我们刚刚添加的函数:

    <button
    
      onClick={handleSignInClick}
    
      className=...
    
      disabled={loading}
    
    >
    
      {loading ? '...' : 'Sign in'}
    
    </button>
    
  4. Header.tsx 中最后要做的就是从上下文中获取 userloadingdispatch。在组件顶部添加以下对 useAppContext 的调用:

    export function Header() {
    
      const { user, loading, dispatch } = useAppContext();
    
      ...
    
    }
    
  5. 让我们转到 Main.tsx。移除对 User 类型的导入语句,并添加对 useAppContext 的导入语句:

    import { Content } from './Content';
    
    import { useAppContext } from './AppContext';
    
  6. 移除 Main 组件的属性,并从 useAppContext 获取 user

    export function Main() {
    
      const { user } = useAppContext();
    
      return ...
    
    }
    
  7. Main 中的 JSX 中,移除 Content 元素的 permissions 属性:

    <Content />
    
  8. 现在,打开 Content.tsx 并添加一个 useAppContext 的导入语句:

    import { useAppContext } from './AppContext';
    
  9. 移除 Content 组件的属性,并从 useAppContext 获取 permissions

    export function Content() {
    
      const { permissions } = useAppContext();
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return ...
    
    }
    
  10. 最后,我们将修改 App.tsx。移除除了 HeaderMain 之外的所有导入语句,并添加一个 AppProvider 的导入语句:

    import { Header } from './Header';
    
    import { Main } from './Main';
    
    import { AppProvider } from './AppContext';
    
  11. 仍然在 App.tsx 中,移除对 useReducer 的调用,并移除传递给 HeaderMain 的所有属性:

    function App() {
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <Header />
    
          <Main />
    
        </div>
    
      );
    
    }
    
  12. AppProvider 包裹在 HeaderMain 旁边,以便它们可以访问上下文:

    function App() {
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <AppProvider>
    
            <Header />
    
            <Main />
    
          </AppProvider>
    
        </div>
    
      );
    
    }
    

现在编译错误将被解决,运行中的应用将看起来和表现如前。

  1. 通过按 Ctrl + C 停止应用运行。

这完成了将应用重构为使用 React 上下文而不是属性钻取的过程。

与属性钻取相比,React 上下文需要编写更多的代码。然而,它允许组件使用钩子而不是通过属性在组件之间传递来访问共享状态。这是一个优雅的共享状态解决方案,尤其是在许多组件共享状态时。

接下来,我们将了解一个流行的第三方库,它可以用来共享状态。

使用 Redux

在本节中,我们将在使用 Redux 之前了解 Redux,并将其用于重构我们一直在工作的应用。

理解 Redux

Redux 是一个成熟的州管理库,它最初于 2015 年发布。它在 React 上下文之前发布,并成为共享状态管理的一种流行方法。

创建存储

在 Redux 中,状态存在于一个称为 useReducer 的集中式不可变对象中,存储中的状态通过分发 reducer 函数来更新,该函数创建状态的新版本。

在过去,需要大量的代码来设置 Redux 存储,并在 React 组件中消耗它。今天,一个名为 Redux Toolkit 的伴侣库减少了使用 Redux 所需的代码。可以使用 Redux Toolkit 的 configureStore 函数创建 Redux 存储,如下所示:

export const store = configureStore({
  reducer: {
    someFeature: someFeatureReducer,
    anotherFeature: anotherFeatureReducer
  },
});

configureStore 函数接收存储的还原器。应用中的每个功能都可以有自己的状态区域和还原器来改变状态。不同的状态区域通常被称为 someFeatureanotherFeature

Redux Toolkit 有一个用于创建切片的函数,称为 createSlice

export const someSlice = createSlice({
  name: "someFeature",
  initialState,
  reducers: {
    someAction: (state) => {
      state.someValue = "something";
    },
    anotherAction: (state) => {
      state.someOtherValue = "something else";
    },
  },
});

createSlice 函数接收一个包含切片名称、初始状态以及处理不同动作和更新状态的函数的对象参数。

createSlice 创建的切片包含一个包装动作处理器的 reducer 函数。当创建存储时,可以在 configureStorereducer 属性中引用此 reducer 函数:

export const store = configureStore({
  reducer: {
    someFeature: someSlice.reducer,
    ...
  },
});

在前面的代码片段中,someSlice 的还原器已被添加到存储中。

向 React 组件提供存储

Redux 存储通过其 Provider 组件在组件树中定义。Provider 组件上的 value 需要指定 Redux 存储(来自 configureStore)。Provider 组件必须放置在需要访问存储的组件之上:

<Provider store={store}>
  <SomeComponent />
  <AnotherComponent />
</Provider>

在前面的例子中,SomeComponentAnotherComponent 可以访问存储。

从组件中访问存储

组件可以使用 React Redux 的 useSelector 钩子从 Redux 存储访问状态。一个选择存储中相关状态的功能被传递到 useSelector

const someValue = useSelector(
  (state: RootState) => state.someFeature.someValue
);

在前面的例子中,someValue是从存储中的someFeature切片中选择的。

从组件向存储分发动作

React Redux 还有一个名为 useDispatch 的钩子,它返回一个 dispatch 函数,可以用来分发动作。动作是从使用 createSlice 创建的切片中创建的函数:

const dispatch = useDispatch();
return (
  <button onClick={() => dispatch(someSlice.actions.someAction())}>
    Some button
  </button>
);

在前面的例子中,当按钮被点击时,someSlice 中的 someAction 被分发。

更多关于 Redux 的信息,请参阅以下链接:redux.js.org/。有关 Redux Toolkit 的更多信息,请参阅以下链接:redux-toolkit.js.org/

现在我们已经了解了 Redux,我们将在上一节创建的应用中使用它。

安装 Redux

首先,我们必须将 Redux 和 Redux Toolkit 安装到我们的项目中。在终端中运行以下命令:

npm i @reduxjs/toolkit react-redux

这将安装我们需要的所有 Redux 组件,包括其 TypeScript 类型。

使用 Redux

现在,我们可以重构应用程序以使用 Redux 而不是 React 上下文。首先,我们将创建一个用于用户信息的 Redux slice,然后再创建一个包含此切片的 Redux 存储。然后,我们将继续将存储添加到 React 组件树中,并在 HeaderMainContent 组件中消费它。

创建 Redux Slice

我们将首先创建一个用于用户状态的 Redux slice。执行以下步骤:

  1. src文件夹中创建一个名为store的文件夹,然后在其中创建一个名为userSlice.ts的文件。

  2. 将以下导入语句添加到 userSlice.ts

    import { createSlice } from '@reduxjs/toolkit';
    
    import type { PayloadAction } from '@reduxjs/toolkit';
    
    import { User } from '../api/authenticate';
    

我们最终将使用 createSlice 创建 Redux slice。PayloadAction 是一个我们可以用于动作对象的类型。在定义状态类型时,我们需要 User 类型。

  1. 将以下 State 类型及其初始状态值从 AppContext.tsx 复制到 userSlice.ts

    type State = {
    
      user: undefined | User;
    
      permissions: undefined | string[];
    
      loading: boolean;
    
    };
    
    const initialState: State = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 接下来,按照以下方式在 userSlice.ts 中开始创建切片:

    export const userSlice = createSlice({
    
      name: 'user',
    
      initialState,
    
      reducers: {
    
      }
    
    });
    

我们已将切片命名为 user 并传递了初始状态值。我们导出切片,以便以后可以用来创建 Redux 存储。

  1. 现在,在 reducers 对象内部定义以下动作处理程序:

    reducers: {
    
      authenticateAction: (state) => {
    
        state.loading = true;
    
      },
    
      authenticatedAction: (
    
        state,
    
        action: PayloadAction<User | undefined>
    
      ) => {
    
        state.user = action.payload;
    
        state.loading = false;
    
      },
    
      authorizeAction: (state) => {
    
        state.loading = true;
    
      },
    
      authorizedAction: (
    
        state,
    
        action: PayloadAction<string[]>
    
      ) => {
    
        state.permissions = action.payload;
    
        state.loading = false;
    
      }
    
    }
    

每个动作处理程序都会更新所需的状态。PayloadAction 用于动作参数的类型。PayloadAction 是一个带有动作有效负载类型的泛型类型。

  1. 最后,从切片中导出动作处理程序和reducer函数:

    export const {
    
      authenticateAction,
    
      authenticatedAction,
    
      authorizeAction,
    
      authorizedAction,
    
    } = userSlice.actions;
    
    export default userSlice.reducer;
    

reducer函数使用了默认导出,因此消费者可以按需命名它。

这样就完成了 Redux 切片的实现。

创建 Redux 存储

接下来,让我们创建 Redux 存储。执行以下步骤:

  1. store文件夹中创建一个名为store.ts的文件,包含以下导入语句:

    import { configureStore } from '@reduxjs/toolkit';
    
    import userReducer from './userSlice';
    
  2. 接下来,使用configureStore函数创建存储,引用我们之前创建的 reducer:

    export const store = configureStore({
    
      reducer: { user: userReducer }
    
    });
    

我们导出store变量,以便我们可以在以后在 React Redux 的Provider组件中使用它。

  1. store.ts中最后要做的就是在 Redux 的全状态对象中导出类型,我们最终会在消费 Redux 存储的组件中的useSelector钩子中需要这个类型:

    export type RootState = ReturnType<typeof store.getState>;
    

ReturnType是 TypeScript 的一个标准实用工具类型,它返回传递给它的函数类型的返回类型。Redux 存储中的getState函数返回完整的状态对象。因此,我们使用ReturnType来推断完整状态对象的类型,而不是显式地定义它。

这样就完成了 Redux 存储的实现。

将 Redux 存储添加到组件树中

接下来,我们将使用 React Redux 的Provider组件在组件树中的适当位置添加存储。遵循以下步骤:

  1. 打开App.tsx并移除AppContext导入语句。同时移除AppContext.tsx文件,因为现在不再需要它。

  2. 从 React Redux 导入Provider组件和我们创建的 Redux 存储的导入语句:

    import { Provider } from 'react-redux';
    
    import { store } from './store/store';
    
  3. 在 JSX 中将AppProvider替换为Provider,如下所示:

    <div className="max-w-7xl mx-auto px-4">
    
      <Provider store={store}>
    
        <Header />
    
        <Main />
    
      </Provider>
    
    </div>
    

我们将导入的 Redux 存储传递给Provider

现在 Redux 存储对HeaderMainContent组件都是可访问的。

在组件中消费 Redux 存储

我们现在将 Redux 存储集成到HeaderMainContent组件中。这将替换之前的 React 上下文消费代码。遵循以下步骤:

  1. 首先打开Header.tsx并移除AppContext导入语句。

  2. Header.tsx中添加以下导入语句:

    import { useSelector, useDispatch } from 'react-redux';
    
    import type { RootState } from './store/store';
    
    import {
    
      authenticateAction,
    
      authenticatedAction,
    
      authorizeAction,
    
      authorizedAction,
    
    } from './store/userSlice';
    

我们将引用 Redux 中的状态以及分发动作,因此我们导入了useSelectoruseDispatchRootState类型是我们最终将传递给useSelector的函数中所需的。我们还导入了我们创建的切片中的所有动作,因为我们将在修订后的登录处理程序中需要它们。

  1. Header组件内部,将useAppContext调用替换为useSelector调用以获取所需的状态:

    export function Header() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      const loading = useSelector(
    
        (state: RootState) => state.user.loading
    
      );
    
      async function handleSignInClick() {
    
        ...
    
      }
    
      return ...
    
    }
    
  2. 同时,调用useDispatch来获取dispatch函数:

    export function Header() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      const loading = useSelector(
    
        (state: RootState) => state.user.loading
    
      );
    
      const dispatch = useDispatch();
    
      async function handleSignInClick() {
    
        ...
    
      }
    
      return ...
    
    }
    
  3. Header.tsx中最后要做的就是在handleSignInClick中修改以引用 Redux 切片中的动作函数:

    async function handleSignInClick() {
    
      dispatch(authenticateAction());
    
      const authenticatedUser = await authenticate();
    
      dispatch(authenticatedAction(authenticatedUser));
    
      if (authenticatedUser !== undefined) {
    
        dispatch(authorizeAction());
    
        const authorizedPermissions = await authorize(
    
          authenticatedUser.id
    
        );
    
        dispatch(authorizedAction(authorizedPermissions));
    
      }
    
    }
    
  4. 现在,打开Main.tsx并将AppContext导入语句替换为useSelectorRootState类型的导入语句:

    import { useSelector } from 'react-redux';
    
    import { RootState } from './store/store';
    
  5. 将对useAppContext的调用替换为对useSelector的调用以获取user状态值:

    export function Main() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      return ...
    
    }
    
  6. 接下来,打开Content.tsx,并将AppContext导入语句替换为对useSelectorRootState类型的导入语句:

    import { useSelector } from 'react-redux';
    
    import { RootState } from './store/store';
    
  7. 将对useAppContext的调用替换为对useSelector的调用,以获取permissions状态值:

    export function Content() {
    
      const permissions = useSelector(
    
        (state: RootState) => state.user.permissions
    
      );
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return ...
    
    }
    
  8. 通过在终端中运行npm start来运行应用程序。应用程序的外观和行为将与之前一样。

这就完成了将应用程序重构为使用 Redux 而不是 React 上下文的过程。

这里是使用 Redux 的关键点的回顾:

  • 状态存储在中央存储中

  • 状态通过分发由 reducer 处理的动作来更新

  • 需要适当地在组件树中放置Provider组件,以便组件可以访问 Redux 存储

  • 组件可以使用useSelector钩子选择状态,并使用useDispatch钩子分发动作

正如您所经历的,即使使用 Redux Toolkit,在使用 Redux 管理状态时也需要许多步骤。对于简单的状态管理需求来说,这有点过度,但在有大量共享应用程序级状态时却非常出色。

摘要

在本章中,我们构建了一个包含需要共享状态的组件的小型单页应用程序。我们首先使用现有的知识,并使用属性在组件之间传递状态。我们了解到这种方法的一个问题是,不需要访问状态的组件被迫访问它,如果其子组件需要访问它的话。

我们继续学习 React 上下文,并将应用程序重构为使用它。我们了解到 React 上下文可以使用useStateuseReducer存储状态。然后,可以通过上下文的Provider组件将状态提供给树中的组件。然后,组件通过useContext钩子访问上下文状态。我们发现这比通过属性传递状态要好得多,尤其是当许多组件需要访问状态时。

接下来,我们学习了 Redux,它与 React 上下文类似。一个区别是,只能有一个包含状态的 Redux 存储,但可以有多个 React 上下文。我们了解到需要将Provider组件添加到组件树中,以便组件可以访问 Redux 存储。组件使用useSelector钩子选择状态,并使用useDispatch钩子分发动作。然后,reducer 处理动作并相应地更新状态。

在下一章中,我们将学习如何在 React 中与 REST API 一起工作。

问题

回答以下问题以检查您在本章中学到的内容:

  1. 我们定义了一个上下文,如下所示,以保存应用程序的主题状态:

    type Theme = {
    
      name: string;
    
      color: 'dark' | 'light';
    
    };
    
    type ThemeContextType = Theme & {
    
      changeTheme: (
    
        name: string,
    
        color: 'dark' | 'light'
    
      ) => void;
    
    };
    
    const ThemeContext = createContext<ThemeContextType>();
    

尽管代码可以编译,但问题是什么?

  1. 问题 1 的上下文中有一个名为ThemeProvider的提供者包装器,它被添加到组件树中,如下所示:

    <ThemeProvider>
    
      <Header />
    
      <Main />
    
    </ThemeProvider>
    
    <Footer />
    

当在Footer组件中使用useContext解构时,主题状态是undefined。问题是什么?

  1. 在应用程序中是否可以有两个 React 上下文?

  2. 在应用程序中是否可以有两个 Redux 存储?

  3. 以下代码分发了一个动作来更改主题:

    function handleChangeTheme({ name, color }: Theme) {
    
      useDispatch(changeThemeAction(name, color));
    
    }
    

这段代码存在问题。问题是什么?

  1. 在一个 React 组件中,是否可以使用 useState 以及来自 Redux 存储的状态来仅使用本组件所需的状态?

  2. 在本章中,当我们实现 Redux 切片时,动作处理程序似乎直接更新了状态,如下例所示:

    authorizedAction: (
    
      state,
    
      action: PayloadAction<string[]>
    
    ) => {
    
      state.permissions = action.payload;
    
      state.loading = false;
    
    }
    

为什么我们可以修改状态?我以为 React 中的状态必须是不可变的?

答案

  1. 在使用 TypeScript 时,createContext 必须传递一个默认值。以下是修正后的代码:

    const ThemeContext = createContext<ThemeContextType>({
    
      name: 'standard',
    
      color: 'light',
    
      changeTheme: (name: string, color: 'dark' | 'light') => {},
    
    });
    
  2. Footer 必须按照以下方式放置在 ThemeProvider 内:

    <ThemeProvider>
    
      <Header />
    
      <Main />
    
      <Footer />
    
    </ThemeProvider>
    
  3. 是的,在应用中 React 上下文的数量没有限制。

  4. 不,一个应用中只能添加一个 Redux 存储。

  5. useDispatch 不能直接用来分发动作——它返回一个函数,可以用来分发动作:

    const dispatch = useDispatch();
    
    function handleChangeTheme({ name, color }: Theme) {
    
      dispatch(changeThemeAction(name, color));
    
    }
    
  6. 是的,使用 useStateuseReducer 定义的本地状态可以与来自 Redux 存储的共享状态一起使用。

  7. Redux Toolkit 使用一个名为 state 对象的库,而不对其进行修改。有关 immer 的更多信息,请参阅以下链接:github.com/immerjs/immer

第九章:与 RESTful API 交互

在本章中,我们将构建一个页面,该页面列出从 REST API 获取的博客文章,以及一个表单,用于将博客文章提交到 REST API。通过这种方式,我们将了解从 React 组件与 REST API 交互的各种方法。

第一种方法将是使用 React 的 useEffect 钩子和浏览器的 fetch 函数。作为此过程的一部分,我们将学习如何使用类型断言函数来为来自 REST API 的数据提供强类型。然后,我们将使用 React Router 的数据加载功能并体验其优势。之后,我们将转向使用一个流行的库,即 React Query,并体验其优势,最后我们将结合使用 React Query 和 React Router 以获得这两个库的最佳效果。

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

  • 设置环境

  • 使用 effect 钩子与 fetch 结合使用

  • 使用 fetch 发送数据

  • 使用 React Router

  • 使用 React Query

  • 使用 React Router 和 React Query 结合

技术要求

本章我们将使用以下技术:

本章中所有的代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter9.

创建项目

在本节中,我们将首先创建我们将要构建的应用程序项目。然后,我们将为应用程序创建一个 REST API 以供其消费。

设置项目

我们将使用 Visual Studio Code 开发应用程序,并需要一个基于 Create React App 的新项目设置。我们之前已经多次介绍过这一点,因此在本章中我们将不介绍这些步骤——相反,请参阅 第三章设置 React 和 TypeScript

我们将使用 Tailwind CSS 来设计应用程序的样式。我们之前在 第五章前端样式方法 中介绍了如何安装和配置 Tailwind。因此,在您创建了 React 和 TypeScript 项目之后,请安装并配置 Tailwind。

我们将使用 React Router 来加载数据,因此请参阅第六章使用 React Router 进行路由,了解如何进行此操作。

我们将使用 @tailwindcss/forms 插件来设计表单。请参阅 第七章与表单一起工作,以回顾如何实现这些。

理解组件结构

应用程序将是一个单页应用程序,其中包含一个位于现有文章列表上方的添加新文章的表单。应用程序将分为以下组件:

图 9.1 – 应用组件结构

图 9.1 – 应用组件结构

这里是这些组件的描述:

  • PostsPage 将通过引用 NewPostFormPostsLists 组件来渲染整个页面。它还将与 REST API 交互。

  • NewPostForm 将渲染一个表单,允许用户输入新的博客文章。这将使用 ValidationError 组件来渲染验证错误消息。ValidationError 组件将与在 第七章 中创建的相同。

  • PostsList 将渲染博客文章列表。

好的,现在我们知道了组件结构,让我们创建 REST API。

创建 REST API

我们将使用一个名为 JSON Server 的工具创建 REST API,它允许快速创建 REST API。通过运行以下命令安装 JSON Server:

npm i -D json-server

然后,我们在 JSON 文件中定义 API 后面的数据。在项目的根目录中创建一个名为 db.json 的文件,包含以下内容:

{
  "posts": [
    {
      "title": "Getting started with fetch",
      "description": "How to interact with backend APIs using         fetch",
      "id": 1
    },
    {
      "title": "Getting started with useEffect",
      "description": "How to use React's useEffect hook for         interacting with backend APIs",
      "id": 2
    }
  ]
}

前面的 JSON 表示 API 后面的数据最初将包含两篇博客文章(此代码片段可以从 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter9/useEffect-fetch/db.json 复制)。

现在我们需要定义一个 npm 脚本来启动 JSON 服务器并处理请求。打开 package.json 并添加一个名为 server 的脚本,如下所示:

{
  ...,
  "scripts": {
    ...,
    "server": "json-server --watch db.json --port 3001 --delay       2000"
  },
  ...
}

该脚本启动 JSON 服务器并监视我们刚刚创建的 JSON 文件。我们指定 API 在端口 3001 上运行,以免与在端口 3000 上运行的 app 冲突。我们还通过添加 2 秒的延迟来减缓 API 响应,这将帮助我们看到数据何时从 React 应用程序中获取。

在终端中,通过运行我们刚刚创建的脚本启动 API,如下所示:

npm run server

几秒钟后,API 启动。为了检查 API 是否正常工作,打开浏览器并输入以下地址:http://localhost:3001/posts。博客文章数据应如下显示在浏览器中:

图 9.2 – 博客文章 REST API

图 9.2 – 博客文章 REST API

更多关于 JSON Server 的信息,请参阅以下链接:github.com/typicode/json-server

现在项目已经设置好了 REST API,保持 API 运行,接下来,我们将学习如何使用 useEffect 与 REST API 交互。

使用 effect 钩子与 fetch 一起使用

在本节中,我们将创建一个页面,列出我们从刚刚创建的 REST API 返回的博客文章。我们将使用浏览器的 fetch 函数和 React 的 useEffect 钩子与 REST API 交互。

使用 fetch 获取博客文章

我们将首先创建一个函数,使用浏览器的 fetch 函数从 REST API 获取博客文章;我们将 API URL 存储在一个环境变量中。为此,执行以下步骤:

  1. 将使用相同的 URL 来获取以及保存新的博客文章到 REST API。我们将把这个 URL 存储在一个环境变量中。因此,在项目的根目录中创建一个名为 .env 的文件,包含以下变量:

    REACT_APP_API_URL = http://localhost:3001/posts/
    

这个环境变量在构建时注入到代码中,可以通过 process.env.REACT_APP_API_URL 被代码访问。Create React App 项目的环境变量必须以 React_APP_ 为前缀。有关环境变量的更多信息,请参阅以下链接:create-react-app.dev/docs/adding-custom-environment-variables/

  1. 现在,在 src 文件夹中创建一个名为 posts 的文件夹,用于存放所有博客文章功能的文件。

  2. posts 文件夹中创建一个名为 getPosts.ts 的文件。在这个文件中,添加以下获取博客文章的函数:

    export async function getPosts() {
    
      const response = await fetch(
    
        process.env.REACT_APP_API_URL!
    
      );
    
      const body = await response.json()
    
      return body;
    
    }
    

fetch 函数有一个用于 REST API URL 的参数。我们使用了 REACT_APP_API_URL 环境变量来指定这个 URL。环境变量值可以是 undefined,但我们知道这不是情况,所以我们在其后添加了一个 !)。

注意

非空断言操作符是 TypeScript 中的一个特殊操作符。它用于通知 TypeScript 编译器它前面的表达式不能是 nullundefined

fetch 返回一个 Response 对象,我们调用它的 json 方法来获取以 JSON 格式的响应体。json 方法是异步的,因此我们需要 await 它。

关于 fetch 的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/API/Fetch_API

这完成了 getPosts 的初始版本。然而,getPosts 的返回值类型目前是 any,这意味着不会对其进行类型检查。我们将在下一步改进这一点。

强类型响应数据

第二章 介绍 TypeScript 中,我们学习了如何使用 unknown 类型和使用类型谓词来对未知数据进行强类型化。我们将使用 unknown 类型与一个名为 getPosts 的 TypeScript 特性一起使用。执行以下步骤:

  1. 向 JSON 响应添加类型断言,以便 body 变量具有 unknown 类型:

    export async function getPosts() {
    
      const response = await fetch(postsUrl);
    
      const body = (await response.json()) as unknown;
    
      return body;
    
    }
    
  2. 接下来,在 getPosts 下方添加以下类型断言函数:

    export function assertIsPosts(
    
      postsData: unknown
    
        ): asserts postsData is PostData[] {
    
    }
    

注意返回类型注解:asserts postsData is PostData[]。如果没有错误发生,这意味着 postsData 参数是 PostData[] 类型。

不要担心 PostData 被引用时的编译错误 - 我们将在第 8 步创建 PostData 类型。

  1. 让我们继续实现 assertIsPosts。它将对 postsData 参数进行一系列检查,如果检查失败,它将抛出异常。通过检查 postsData 是否为数组来开始实现:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      if (!Array.isArray(postsData)) {
    
        throw new Error("posts isn't an array");
    
      }
    
      if (postsData.length === 0) {
    
        return;
    
      }
    
    }
    
  2. 现在,让我们检查数组项是否具有 id 属性:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      ...
    
      postsData.forEach((post) => {
    
        if (!('id' in post)) {
    
          throw new Error("post doesn't contain id");
    
        }
    
        if (typeof post.id !== 'number') {
    
          throw new Error('id is not a number');
    
        }
    
      });
    
    }
    

我们使用数组的 forEach 方法遍历所有文章。在循环内部,我们使用 in 操作符检查 id 属性是否存在。我们还使用 typeof 操作符检查 id 值是否为 number 类型。

  1. 我们可以对 titledescription 属性执行类似的检查:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      …
    
      postsData.forEach((post) => {
    
        ...
    
        if '!('ti'le' in post)) {
    
          throw new Err"r("post do'sn't contain ti"le");
    
        }
    
        if (typeof post.title !'= 'str'ng') {
    
          throw new Err'r('title is not a str'ng');
    
        }
    
        if '!('descript'on' in post)) {
    
          throw new Err"r("post do'sn't contain         descript"on");
    
        }
    
        if (typeof post.description !'= 'str'ng') {
    
          throw new Err'r('description is not a str'ng');
    
        }
    
      });
    
    }
    

这完成了类型断言函数的实现。

  1. 返回到 getPosts,添加对 assert 函数的调用:

    export async function getPosts() {
    
      const response = await fetch(postsUrl);
    
      const body = (await response.json()) as unknown;
    
      assertIsPosts(body);
    
      return body;
    
    }
    

在成功调用 assertIsPosts 之后,body 变量现在将是 PostData[] 类型。您可以在返回语句中悬停在 body 变量上以确认这一点。

  1. 最终步骤是添加 PostData 类型。在 getPosts.ts 的顶部添加以下导入语句以导入 PostData

    import { PostData } from './types';
    

由于 types 文件尚不存在,文件仍将存在编译错误 - 我们将在下一步中完成此操作。

  1. posts 文件夹中添加一个名为 types.ts 的文件,并包含以下 PostData 类型的定义:

    export type PostData = {
    
      id: number;
    
      title: string;
    
      description: string;
    
    };
    

这种类型表示来自 REST API 的博客文章。

现在,我们有一个强类型函数,可以从 REST API 获取博客文章。接下来,我们将创建一个 React 组件来列出博客文章。

创建博客文章列表组件

我们将创建一个 React 组件,它接受博客文章数据并将其以列表形式渲染。执行以下步骤:

  1. posts 文件夹中创建一个名为 PostsList.tsx 的文件,并包含以下导入语句:

    import { PostData } from './types';
    
  2. 接下来,开始实现组件,如下所示:

    type Props = {
    
      posts: PostData[];
    
    };
    
    export function PostsList({ posts }: Props) {
    
    }
    

组件有一个名为 posts 的属性,它将包含博客文章。

  1. 现在,按照以下方式在无序列表中渲染博客文章:

    export function PostsList({ posts }: Props) {
    
      return (
    
        <ul className="list-none">
    
          {posts.map((post) => (
    
            <li key={post.id} className="border-b py-4">
    
              <h3 className="text-slate-900 font-bold">
    
                {post.title}
    
              </h3>
    
              <p className=" text-slate-900 ">{post.description}</p>
    
            </li>
    
          ))}
    
        </ul>
    
      );
    
    }
    

Tailwind CSS 类在具有粗体标题的博客文章之间添加灰色线条。

这完成了 PostsList 组件。接下来,我们将创建一个引用 PostsList 组件的页面组件。

创建博客文章页面组件

我们将创建一个博客文章页面组件,该组件使用 getPosts 函数获取博客文章数据,并使用我们刚刚创建的 PostsList 组件进行渲染。执行以下步骤:

  1. posts 文件夹的组件中创建一个名为 PostsPage.tsx 的文件,并包含以下导入语句:

    import { useEffect, useState } from 'react';
    
    import { getPosts } from './getPosts';
    
    import { PostData } from './types';
    
    import { PostsList } from './PostsList';
    

我们已导入 getPosts 函数、PostList 组件以及我们在上一节中创建的 PostData 类型。我们还导入了来自 React 的 useStateuseEffect 钩子。我们将使用 React 状态来存储博客文章,并使用 useEffect 在页面组件挂载时调用 getPosts

  1. 通过定义博客文章的状态以及它们是否正在被获取来开始实现页面组件。

    export function PostsPage() {
    
      const [isLoading, setIsLoading] = useState(true);
    
      const [posts, setPosts] = useState<PostData[]>([]);
    
    }
    
  2. 接下来,使用 useEffect 钩子调用 getPosts 函数,如下所示:

    export function PostsPage() {
    
      …
    
      useEffect(() => {
    
        let cancel = false;
    
        getPosts().then((data) => {
    
          if (!cancel) {
    
            setPosts(data);
    
            setIsLoading(false);
    
          }
    
        });
    
        return () => {
    
          cancel = true;
    
        };
    
      }, []);
    
    }
    

我们在调用 getPosts 时使用较旧的 promise 语法,因为较新的 async/await 语法不能直接在 useEffect 中使用。

如果在 getPosts 调用仍在进行时卸载 PostsPage 组件,设置 dataisLoading 状态变量将导致错误。因此,我们使用了一个 cancel 标志来确保在设置 dataisLoading 状态变量时组件仍然挂载。

我们还指定了一个空数组作为效果依赖项,以便效果仅在组件挂载时运行。

  1. useEffect 调用之后,在数据获取期间添加一个加载指示器:

    export function PostsPage() {
    
      ...
    
      useEffect(...);
    
      if (isLoading) {
    
        return (
    
          <div className="w-96 mx-auto mt-6">
    
            Loading ...
    
          </div>
    
        );
    
      }
    
    }
    

Tailwind CSS 类将加载指示器水平放置在页面中心,并在其上方留有一点边距。

  1. 最后,在条件加载指示器之后渲染页面标题和帖子列表:

    export function PostsPage() {
    
      ...
    
      if (isLoading) {
    
        return (
    
          <div className="w-96 mx-auto mt-6">
    
            Loading ...
    
          </div>
    
        );
    
      }
    
      return (
    
        <div className="w-96 mx-auto mt-6">
    
          <h2 className="text-xl text-slate-900 font-bold">Posts</h2>
    
          <PostsList posts={posts} />
    
        </div>
    
      );
    
    }
    

Tailwind CSS 类将列表放置在页面中心,并在其上方留有一点边距。一个大的 帖子 标题也以深灰色渲染在列表上方。

  1. 现在,打开 App.tsx 并将其内容替换为以下内容,以便渲染我们刚刚创建的页面组件:

    import { PostsPage } from './posts/PostsPage';
    
    function App() {
    
      return <PostsPage />;
    
    }
    
    export default App;
    
  2. 通过在新的终端中运行 npm start 来运行应用程序,该终端与运行 REST API 的终端分开。在数据被获取时,加载指示器将短暂出现:

图 9.3 – 加载指示器

图 9.3 – 加载指示器

博客帖子列表将如下显示:

图 9.4 – 博客帖子列表

图 9.4 – 博客帖子列表

这就完成了 PostsPage 组件的这个版本。

在本节中,我们学习了如何使用 fetchuseEffect 在 REST API 中与 HTTP GET 请求交互的关键点:

  • fetch 将执行实际的 HTTP 请求,该请求将 REST API 的 URL 作为参数

  • 可以使用类型断言函数来为响应数据添加强类型

  • useEffect 可以在包含状态数据的组件挂载时触发 fetch 调用

  • 可以在 useEffect 内部使用一个标志来检查在设置数据状态之前组件是否在 HTTP 请求期间被卸载

在保持应用程序和 REST API 运行的情况下,在下一节中,我们将学习如何使用 fetch 将数据发布到 REST API。

使用 fetch 发送数据

在本节中,我们将创建一个表单,该表单将新的博客帖子提交到我们的 REST API。我们将创建一个使用 fetch 向 REST API 发送数据的函数。该函数将在表单的提交处理程序中被调用。

使用 fetch 创建新的博客帖子

我们将首先创建一个函数,该函数将新的博客帖子发送到 REST API。这将使用浏览器的 fetch 函数,但这次使用 HTTP POST 请求。执行以下步骤:

  1. 我们将首先在posts文件夹中的 types.ts 文件中打开,并添加以下两个类型:

    export type NewPostData = {
    
      title: string;
    
      description: string;
    
    };
    
    export type SavedPostData = {
    
      id: number;
    
    };
    

第一个类型代表一个新的博客帖子,第二个类型代表当博客帖子成功保存时从 API 获取的数据。

  1. posts 文件夹中创建一个名为 savePost.ts 的新文件,并添加以下导入语句:

    import { NewPostData, SavedPostData } from './types';
    

我们还导入了我们刚刚创建的类型。

  1. 开始实现savePost函数如下:

    export async function savePost(
    
      newPostData: NewPostData
    
    ) {
    
      const response = await fetch(
    
        process.env.REACT_APP_API_URL!,
    
        {
    
          method: 'POST',
    
          body: JSON.stringify(newPostData),
    
          headers: {
    
            'Content-Type': 'application/json',
    
          },
    
        }
    
      );
    
    }
    

savePost函数有一个参数newPostData,包含新博客帖子的标题和描述,并使用fetch将其发送到 REST API。在fetch调用中已指定第二个参数,指定应使用 HTTP POST请求,并将新博客帖子数据包含在请求体中。请求体还声明为 JSON 格式。

  1. 接下来,将响应体强类型化如下:

    export async function savePost(newPostData: NewPostData) {
    
      const response = await fetch( ... );
    
      const body = (await response.json()) as unknown;
    
      assertIsSavedPost(body);
    
    }
    

我们将响应体设置为具有unknown类型,然后使用类型断言函数给它一个特定的类型。这将引发编译错误,直到我们在第 6 步中实现assertIsSavedPost

  1. 通过合并响应中的博客帖子 ID 与函数提供的博客帖子标题和描述来完成savePost的实现:

    export async function savePost(newPostData: NewPostData) {
    
      ...
    
      return { ...newPostData, ...body };
    
    }
    

因此,该函数返回的对象将是一个带有 REST API ID 的新博客帖子。

  1. 最后一步是实现类型断言函数:

    function assertIsSavedPost(
    
      post: any
    
    ): asserts post is SavedPostData {
    
      if (!('id' in post)) {
    
        throw new Error("post doesn't contain id");
    
      }
    
      if (typeof post.id !== 'number') {
    
        throw new Error('id is not a number');
    
      }
    
    }
    

该函数检查响应数据是否包含一个数字id属性,如果包含,则断言数据是SavedPostData类型。

这样就完成了savePost函数的实现。接下来,我们将添加一个表单组件,允许用户输入新的博客帖子。

创建博客帖子表单组件

我们将创建一个包含表单的组件,该表单用于捕获新的博客帖子。当表单提交时,它将调用我们刚刚创建的savePost函数。

我们将使用 React Hook Form 实现表单,以及一个ValidationError组件。我们在第七章中详细介绍了 React Hook Form 和ValidationError组件,因此实现步骤不会详细说明。

执行以下步骤:

  1. 我们将首先创建一个ValidationError组件,该组件将渲染表单验证错误。在posts文件夹中创建一个名为ValidationError.tsx的文件,内容如下:

    import { FieldError } from 'react-hook-form';
    
    type Props = {
    
      fieldError: FieldError | undefined,
    
    };
    
    export function ValidationError({ fieldError }: Props) {
    
      if (!fieldError) {
    
        return null;
    
      }
    
      return (
    
        <div role="alert" className="text-red-500 text-xs       mt-1">
    
          {fieldError.message}
    
        </div>
    
      );
    
    }
    
  2. posts文件夹中创建一个名为NewPostForm.tsx的新文件。这个文件将包含一个用于捕获新博客帖子标题和描述的表单。将该文件中的以下导入语句添加到文件中:

    import { FieldError, useForm } from 'react-hook-form';
    
    import { ValidationError } from './ValidationError';
    
    import { NewPostData } from './types';
    
  3. 开始实现表单组件如下:

    type Props = {
    
      onSave: (newPost: NewPostData) => void;
    
    };
    
    export function NewPostForm({ onSave }: Props) {
    
    }
    

该组件有一个用于保存新博客帖子的 prop,以便可以在表单组件之外处理与 REST API 的交互。

  1. 现在,从 React Hook Form 的useForm钩子中解构registerhandleSubmit函数以及有用的状态变量:

    type Props = {
    
      onSave: (newPost: NewPostData) => void;
    
    };
    
    export function NewPostForm({ onSave }: Props) {
    
      const {
    
        register,
    
        handleSubmit,
    
        formState: { errors, isSubmitting, isSubmitSuccessful },
    
      } = useForm<NewPostData>();
    
    }
    

我们将新帖数据的类型传递给useForm钩子,以便它知道要捕获的数据的形状。

  1. 为字段容器样式创建一个变量,为编辑器样式创建一个函数:

    export function NewPostForm({ onSave }: Props) {
    
      ...
    
      const fieldStyle = 'flex flex-col mb-2';
    
      function getEditorStyle(
    
        fieldError: FieldError | undefined
    
      ) {
    
        return fieldError ? 'border-red-500' : '';
    
      }
    
    }
    
  2. form元素中按如下方式渲染titledescription字段:

    export function NewPostForm({ onSave }: Props) {
    
      ...
    
      return (
    
        <form
    
          noValidate
    
          className="border-b py-4"
    
          onSubmit={handleSubmit(onSave)}
    
        >
    
          <div className={fieldStyle}>
    
            <label htmlFor="title">Title</label>
    
            <input
    
              type="text"
    
              id="title"
    
              {...register('title', {
    
                required: 'You must enter a title',
    
              })}
    
              className={getEditorStyle(errors.title)}
    
            />
    
            <ValidationError fieldError={errors.title} />
    
          </div>
    
          <div className={fieldStyle}>
    
            <label htmlFor="description">Description</label>
    
            <textarea
    
              id="description"
    
              {...register('description', {
    
                required: 'You must enter the description',
    
              })}
    
              className={getEditorStyle(errors.description)}
    
            />
    
            <ValidationError fieldError={errors.description}           />
    
          </div>
    
        </form>
    
      );
    
    }
    
  3. 最后,渲染一个保存按钮和成功消息:

    <form
    
      noValidate
    
      className="border-b py-4"
    
      onSubmit={handleSubmit(onSave)}
    
    >
    
      <div className={fieldStyle}> ... </div>
    
      <div className={fieldStyle}> ... </div>
    
      <div className={fieldStyle}>
    
        <button
    
          type="submit"
    
          disabled={isSubmitting}
    
          className="mt-2 h-10 px-6 font-semibold bg-black         text-white"
    
        >
    
          Save
    
       </button>
    
        {isSubmitSuccessful && (
    
          <div
    
            role="alert"
    
            className="text-green-500 text-xs mt-1"
    
          >
    
            The post was successfully saved
    
         </div>
    
        )}
    
      </div>
    
    </form>
    

这样就完成了NewPostForm组件的实现。

  1. 现在打开 PostPage.tsx 文件并导入我们之前创建的 NewPostForm 组件和 savePost 函数。同时,导入 NewPostData 类型:

    import { useEffect, useState } from 'react';
    
    import { getPosts } from './getPosts';
    
    import { PostData, NewPostData } from './types';
    
    import { PostsList } from './PostsList';
    
    import { savePost } from './savePost';
    
    import { NewPostForm } from './NewPostForm';
    
  2. PostPage JSX 中,将 NewPostForm 表单添加到 PostsList 之上:

    <div className="w-96 mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-    bold">Posts</h2>
    
      <NewPostForm onSave={handleSave} />
    
      <PostsList posts={posts} />
    
    </div>;
    
  3. 在获取博客文章的效果下方添加保存处理函数:

    useEffect(() => {
    
      ...
    
    }, []);
    
    async function handleSave(newPostData: NewPostData) {
    
      const newPost = await savePost(newPostData);
    
      setPosts([newPost, ...posts]);
    
    }
    
  4. 处理函数调用 savePost 并传入表单中的数据。文章保存后,它将被添加到 posts 数组的开头。

  5. 在运行的应用程序中,新的博客文章表单将出现在博客文章列表上方,如下所示:

图 9.5 – 新博客文章表单位于文章列表上方

图 9.5 – 新博客文章表单位于文章列表上方

  1. 用一篇新的博客文章填写表单并按下 保存 按钮。几秒钟后,新文章应该出现在列表的顶部。

图 9.6 – 新博客文章添加到文章列表中

图 9.6 – 新博客文章添加到文章列表中

这样就完成了表单的实现及其与博客文章页面的集成。

在本节关于使用 fetch 发送数据的几个关键点如下:

  • fetch 函数的第二个参数允许指定 HTTP 方法。在本节中,我们使用此参数进行 HTTP POST 请求。

  • fetch 函数的第二个参数还允许提供请求体。

再次保持应用程序和 REST API 运行,在下一节中,我们将使用 React Router 的数据获取功能来简化我们的数据获取代码。

使用 React Router

在本节中,我们将了解 React Router 如何与数据获取过程集成。我们将使用这些知识来简化我们应用程序中获取博客文章的代码。

理解 React Router 数据加载

React Router 的数据加载与 React Router 表单类似,我们在 第七章 中学习过。我们不是定义一个处理表单提交的动作,而是定义一个 some-page 路由:

const router = createBrowserRouter([
  ...,
  {
    path: '/some-page',
    element: <SomePage />,
    loader: async () => {
      const response = fetch('https://somewhere');
      return await response.json();
    }
  },
  ...
]);

React Router 在渲染路由上定义的组件之前调用加载器以获取数据。然后,数据通过 useLoaderData 钩子可用在组件中:

export function SomePage() {
  const data = useLoaderData();
  ...
}

这种方法效率很高,因为路由组件只渲染一次,因为数据在第一次渲染时就已经可用。

更多关于 React Router 加载器的信息,请参阅以下链接:reactrouter.com/en/main/route/loader。更多关于 useLoaderData 钩子的信息,请参阅以下链接:reactrouter.com/en/main/hooks/use-loader-data

现在我们开始理解 React Router 中的数据加载,我们将在我们的应用程序中使用它。

使用 React Router 进行数据加载

执行以下步骤以在我们的应用程序中使用 React Router 数据加载器:

  1. 打开 App.tsx 文件并添加以下导入语句:

    import {
    
      createBrowserRouter,
    
      RouterProvider
    
    } from 'react-router-dom';
    
  2. 此外,导入 getPosts 函数:

    import { getPosts } from './posts/getPosts';
    

getPosts 将是加载函数。

  1. App 组件上方添加以下路由定义:

    const router = createBrowserRouter([
    
      {
    
        path: "/",
    
        element: <PostsPage />,
    
        loader: getPosts
    
      }
    
    ]);
    
  2. App 组件中,将 PostsPage 替换为 RouterProvider

    function App() {
    
      return <RouterProvider router={router} />;
    
    }
    
  3. 打开 PostsPage.tsx 并移除 React 导入语句,因为在这个组件中不再需要它。

  4. 此外,将 assertIsPosts 添加到 getPosts 导入语句中,并移除 getPosts

    import { assertIsPosts } from './getPosts';
    

我们最终需要 assertIsPosts 来类型化数据。

  1. 仍然在 PostsPage.tsx 中,添加以下导入语句,以便使用 React Router 中的一个钩子,该钩子允许我们访问加载器数据:

    import { useLoaderData } from 'react-router-dom';
    
  2. PostsPage 组件内部,移除 isLoadingposts 状态定义。这些将不再需要,因为我们将从 React Router 获取数据,而无需等待。

  3. 移除当前获取数据的 useEffect 调用。

  4. 移除 handleSave 函数的第二行,该行设置 posts 状态。handleSave 现在应如下所示:

    async function handleSave(newPostData: NewPostData) {
    
      await savePost(newPostData);
    
    }
    
  5. 同时移除加载指示器。

  6. 现在,在 PostsPage 组件的顶部,调用 useLoaderData 并将结果分配给 posts 变量:

    export function PostsPage() {
    
      const posts = useLoaderData();
    
      …
    
    }
    
  7. 很不幸,postsunknown 类型,因此在传递给 PostsLists 组件时存在类型错误。使用 assertsIsPosts 函数将数据类型化为 PostData[]

    const posts = useLoaderData();
    
    assertIsPosts(posts);
    

类型错误现在已解决。

注意,从 types 导入语句中导入的 PostData 未使用。保持其完整性,因为我们将在下一节再次使用它。

  1. 运行的应用程序应该看起来和表现与之前相似。你可能注意到的一点是,当使用表单添加新的博客文章时,它不会出现在列表中——你必须手动刷新页面才能看到它。当我们在本章后面使用 React Query 时,这将被解决。

注意我们刚刚移除了多少代码——这表明代码现在变得更加简单。使用 React Router 加载数据的另一个好处是,在数据获取后,PostsPage 不会重新渲染——数据是在 PostsPage 渲染之前获取的。

接下来,我们将改进数据获取过程的用户体验。

延迟 React Router 数据获取

如果数据获取过程缓慢,React Router 渲染组件之前会有明显的延迟。幸运的是,我们可以通过使用 React Router 的 defer 函数和 Await 组件,以及 React 的 Suspense 组件来解决这个问题。执行以下步骤将它们添加到我们的应用程序中:

  1. 首先打开 App.tsx 并将 defer 函数添加到 React Router 导入语句中:

    import {
    
      createBrowserRouter,
    
      RouterProvider,
    
      defer
    
    } from 'react-router-dom';
    
  2. 在路由定义中按照以下方式更新 loader 函数:

    const router = createBrowserRouter([
    
      {
    
        path: "/",
    
        element: ...,
    
        loader: async () => defer({ posts: getPosts() })
    
      }
    
    ]);
    

React Router 的 defer 函数接收一个包含承诺数据的对象。对象中的属性名是数据的唯一键,在我们的例子中是 posts。值是获取数据的函数,在我们的例子中是 getPosts

注意,我们没有等待 getPosts,因为我们希望加载器完成,并且 PostsPage 立即渲染。

  1. 打开 PostsPage.tsx 并为 React 的 Suspense 组件添加一个导入语句:

    import { Suspense } from 'react';
    
  2. Await 组件添加到 React Router 的导入语句中:

    import { useLoaderData, Await } from 'react-router-dom';
    
  3. 在组件中,将 useLoaderData 的调用更新为将结果分配给 data 变量而不是 posts

    const data = useLoaderData();
    

加载器数据的形式现在略有不同——它将是一个包含 posts 属性的对象,其中包含博客文章。博客文章也不会立即出现,就像之前那样——data.posts 属性将包含博客文章的承诺。

  1. 此外,删除对 assertIsPosts 的调用——我们将在 步骤 9 中使用它。

  2. data 变量是 unknown 类型,因此请在组件下方添加一个类型断言函数,以便可以将其强类型化:

    type Data = {
    
      posts: PostData[];
    
    };
    
    export function assertIsData(
    
      data: unknown
    
    ): asserts data is Data {
    
      if (typeof data !== 'object') {
    
        throw new Error("Data isn't an object");
    
      }
    
      if (data === null) {
    
        throw new Error('Data is null');
    
      }
    
      if (!('posts' in data)) {
    
        throw new Error("data doesn't contain posts");
    
      }
    
    }
    

类型断言函数检查 data 参数是否是一个包含 posts 属性的对象。

  1. 我们现在可以使用断言函数来为组件中的 data 变量指定类型:

    const data = useLoaderData();
    
    assertIsData(data);
    
  2. 在 JSX 中,将 SuspenseAwait 包裹在 PostsList 旁边,如下所示:

    <Suspense fallback={<div>Fetching...</div>}>
    
      <Await resolve={data.posts}>
    
        {(posts) => {
    
          assertIsPosts(posts);
    
          return <PostsList posts={posts} />;
    
        }}
    
      </Await>
    
    </Suspense>
    

SuspenseAwait 一起工作,仅在数据已被获取时渲染 PostsLists。我们使用 Suspense 来渲染 assertIsPosts 以确保 posts 被正确类型化。

  1. 在运行的应用程序中,你现在将注意到当页面加载时会出现 Fetching… 消息:

图 9.7 – 数据获取期间的数据获取消息

图 9.7 – 数据获取期间的数据获取消息

  1. 通过在运行应用程序的终端中按 Ctrl + C 来停止应用程序的运行,但保持 API 运行。

这个解决方案的伟大之处在于,由于使用了 SuspenseAwait,当 PostsPage 被渲染时,仍然不会发生重新渲染。

我们现在将快速回顾一下我们使用 React Router 的数据获取功能所学到的东西:

  • React Router 的 loader 允许我们高效地将获取的数据加载到路由组件中

  • React Router 的 defer 允许路由组件在数据获取时不会被阻止渲染组件

  • React Router 的 useLoaderData 钩子允许组件访问路由的加载数据

  • React 的 Suspense 和 React Router 的 Await 允许组件在数据仍在获取时进行渲染

有关 React Router 中延迟数据的更多信息,请参阅以下链接:reactrouter.com/en/main/guides/deferred

在下一节中,我们将使用另一个流行的库来管理服务器数据,以进一步提高用户体验。

使用 React Query

React Query 是一个用于与 REST API 交互的流行库。它所做的关键事情是管理围绕 REST API 调用的状态。它所做的另一件事是 React Router 不做的是维护获取数据的缓存,这提高了应用程序的感知性能。

在本节中,我们将重构应用程序以使用 React Query 而不是 React Router 的加载器功能。然后,我们将再次重构应用程序以同时使用 React Query 和 React Router 的加载器,以获得这两个世界的最佳效果。

安装 React Query

我们的第一项任务是安装 React Query,我们可以在终端中运行以下命令来完成:

npm i @tanstack/react-query

此库包括 TypeScript 类型,因此不需要安装任何额外的包。

添加 React Query 提供者

React Query 需要在需要访问其管理的数据的组件树上的一个提供者组件。最终,React Query 将在我们的应用程序中保存博客文章数据。执行以下步骤以将 React Query 提供者组件添加到 App 组件中:

  1. 打开 App.tsx 并添加以下导入语句:

    import {
    
      QueryClient,
    
      QueryClientProvider,
    
    } from '@tanstack/react-query';
    

QueryClient 提供对数据的访问。QueryClientProvider 是我们需要放置在组件树中的提供者组件。

  1. 按照以下方式将 QueryClientProvider 包裹在 RouterProvider 之外:

    const queryClient = new QueryClient();
    
    const router = createBrowserRouter( ... );
    
    function App() {
    
      return (
    
        <QueryClientProvider client={queryClient}>
    
          <RouterProvider router={router} />
    
        </QueryClientProvider>
    
      );
    
    }
    

QueryClientProvider 需要一个 QueryClient 实例来传递给它,因此我们在 App 组件外部创建此实例。我们将 queryClient 变量放置在路由定义之上,因为我们最终会在路由定义中使用它。

PostsPage 组件现在可以访问 React Query。接下来,我们将在 PostsPage 中使用 React Query。

使用 React Query 获取数据

React Query 将获取数据的请求称为 useQuery 钩子以执行此操作。我们将在 PostsPage 组件中使用 React Query 的 useQuery 钩子来调用 getPosts 函数并存储它返回的数据。这将暂时替代 React Router 的加载器功能。执行以下步骤:

  1. 从 React Query 中导入 useQuery

    import { useQuery } from '@tanstack/react-query';
    
  2. getPosts 添加到 getPosts 导入语句中:

    import { assertIsPosts, getPosts } from './getPosts';
    

我们最终将使用 getPosts 来获取数据并将其存储在 React Query 中。

  1. PostPage 组件中,注释掉 data 变量:

    // const data = useLoaderData();
    
    // assertIsData(data);
    

我们将这些行注释掉而不是删除,因为我们将在下一节中使用 React Router 和 React Query 一起时再次使用它们。

  1. 现在,按照以下方式添加对 useQuery 的调用:

    export function PostsPage() {
    
      const {
    
        isLoading,
    
        isFetching,
    
        data: posts,
    
      } = useQuery(['postsData'], getPosts);
    
      // const data = useLoaderData();
    
      // assertIsData(data);
    
      ...
    
    }
    

传递给 useQuery 的第一个参数是数据的唯一键。这是因为 React Query 可以存储许多数据集,并使用键来标识每个数据集。在这种情况下,键是一个包含数据名称的数组。然而,键数组可以包括我们想要获取的特定记录的 ID 或如果我们只想获取一页记录的页码。

传递给 useQuery 的第二个参数是获取函数,即我们现有的 getPosts 函数。

我们已经解构了以下状态变量:

  • isLoading – 组件是否正在首次加载。

  • isFetching – 获取函数是否正在被调用。当 React Query 认为数据已过时,它将重新获取数据。我们将在稍后与应用程序一起玩耍时体验重新获取数据。

  • data – 已获取的数据。我们将此 posts 变量别名为 posts 以匹配之前的 posts 状态值。保持相同的名称可以最小化对组件其余部分的更改。

注意

useQuery 中可以解构出其他有用的状态变量。一个例子是 isError,它表示 fetch 函数是否出错。有关更多信息,请参阅以下链接:tanstack.com/query/v4/docs/reference/useQuery

  1. 在返回语句上方添加一个加载指示器:

    if (isLoading || posts === undefined) {
    
      return (
    
        <div className="w-96 mx-auto mt-6">
    
          Loading ...
    
        </div>
    
      );
    
    }
    
    return ...
    

检查 posts 状态是否为 undefined 表示 TypeScript 编译器知道在 JSX 中引用 posts 时它不是 undefined

  1. 在 JSX 中,注释掉 Suspense 及其子元素:

    return (
    
      <div className="w-96 mx-auto mt-6">
    
        <h2 className="text-xl text-slate-900 font-      bold">Posts</h2>
    
        <NewPostForm onSave={mutate} />
    
        {/* <Suspense fallback={<div>Fetching ...</div>}>
    
            <Await resolve={data.posts}           errorElement={<p>Error!</p>}>
    
              {(posts) => {
    
                assertIsPosts(posts);
    
                return <PostsList posts={posts} />;
    
              }}
    
            </Await>
    
          </Suspense> */}
    
      </div>
    
    );
    

我们将此代码块注释掉而不是删除它,因为我们将在下一节中使用 React Router 和 React Query 一起使用时恢复它。

  1. 当数据正在获取时,显示一个获取指示器,并在数据获取后渲染博客文章:

    <div className="w-96 mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-    bold">Posts</h2>
    
      <NewPostForm onSave={handleSave} />
    
      {isFetching ? (
    
        <div>Fetching ...</div>
    
      ) : (
    
        <PostsList posts={posts} />
    
      )}
    
      ...
    
    </div>
    
  2. 通过在终端中运行 npm start 来运行应用。博客文章页面将显示与之前相同。一个技术差异是 PostsPage 在数据获取后会被重新渲染。

  3. 离开浏览器窗口并将焦点设置到不同的窗口,例如您的代码编辑器。现在,将焦点重新设置到浏览器窗口,注意获取指示器会短暂出现:

图 9.8 – 数据重新获取时的获取指示器

图 9.8 – 数据重新获取时的获取指示器

这是因为 React Query 默认假设当浏览器恢复焦点时数据已过时。有关此行为的更多信息,请参阅 React Query 文档中的以下链接:tanstack.com/query/v4/docs/guides/window-focus-refetching

  1. React Query 的一个伟大特性是它维护数据缓存。这允许我们在获取新鲜数据的同时渲染带有缓存数据的组件。为了体验这一点,在 PostsPage JSX 中,移除 PostsList 渲染时的 isFetching 条件:

    <PostsList posts={posts} />
    

因此,即使数据已过时,PostsList 也会渲染。

  1. 在运行的应用中,按 F5 刷新页面。然后,离开浏览器窗口并将焦点设置到不同的窗口。将焦点重新设置到浏览器窗口并注意没有获取指示器出现,博客文章列表保持完整。

  2. 重复前面的步骤,但这次,观察浏览器 DevTools 中的 网络 选项卡。注意当应用重新聚焦时,会发起第二个网络请求:

图 9.9 – 两个博客文章的 API 请求

图 9.9 – 两个博客文章的 API 请求

因此,React Query 无缝地允许组件渲染旧数据,并在数据被获取后用新数据重新渲染。

接下来,我们将继续重构帖子页面,以便在将新的博客文章发送到 API 时使用 React Query。

使用 React Query 更新数据

React Query 可以使用名为useMutation钩子的功能来更新数据。在PostsPage.tsx中执行以下步骤,将保存新博客文章的保存更改为使用 React Query 突变:

  1. 按照以下方式更新 React Query 的导入:

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

useMutation钩子允许我们执行一个突变。useQueryClient钩子将使我们能够获取组件正在使用的queryClient实例,并访问和更新缓存的数据。

  1. 在调用useQuery之后添加对useMutation的调用,如下所示:

    const {
    
      isLoading,
    
      data: posts,
    
      isFetching,
    
    } = useQuery(['postsData'], getPosts);
    
    const { mutate } = useMutation(savePost);
    

我们将执行 REST API HTTP POST请求的函数传递给useMutation。我们从useMutation的返回值中解构出mutate函数,我们将在第 4 步中使用它来触发突变。

注意

还可以从useMutation解构出其他有用的状态变量。一个例子是isError,它指示fetch函数是否出错。有关更多信息,请参阅以下链接:tanstack.com/query/v4/docs/reference/useMutation

  1. 当突变成功完成后,我们希望更新posts缓存以包含新的博客文章。进行以下突出显示的更改以实现此目的:

    const queryClient = useQueryClient();
    
    const { mutate } = useMutation(savePost, {
    
      onSuccess: (savedPost) => {
    
        queryClient.setQueryData<PostData[]>(
    
          ['postsData'],
    
          (oldPosts) => {
    
            if (oldPosts === undefined) {
    
              return [savedPost];
    
            } else {
    
              return [savedPost, ...oldPosts];
    
            }
    
          }
    
        );
    
      },
    
    });
    

useMutation的第二个参数允许配置突变。onSuccess配置选项是一个在突变成功完成后被调用的函数。

useQueryClient返回组件正在使用的查询客户端。这个查询客户端有一个名为setQueryData的方法,它允许更新缓存数据。setQueryData有缓存数据的键和要缓存的新数据副本的参数。

  1. 我们可以通过在NewPostForm JSX 元素的onSave属性上调用解构的mutate函数来在新文章保存时触发突变:

    <NewPostForm onSave={mutate} />
    
  2. 现在,我们可以移除handleSave函数,因为现在它是多余的。

  3. 导入的NewPostData类型也可以移除。现在,此类型的导入语句应如下所示:

    import { PostData } from './types';
    
  4. 在运行的应用程序中,如果您输入并保存一篇新的博客文章,它将像之前的实现一样出现在列表中:

图 9.10 – 新博客文章添加到帖子列表

图 9.10 – 新博客文章添加到帖子列表

这样就完成了将保存新博客文章重构为使用 React Query 突变的过程。这也完成了关于 React Query 的这一部分 – 这里是对关键点的回顾:

  • React Query 是一个流行的库,它通过缓存管理来自后端 API 的数据,有助于提高性能

  • React Query 实际上并不执行 HTTP 请求 – 可以使用浏览器的fetch函数来完成此操作

  • React Query 的QueryClientProvider组件需要放置在组件树的高端,在需要后端数据的地方之上

  • React Query 的useQuery钩子允许数据在状态中被检索和缓存

  • React Query 的useMutation钩子允许更新数据

想了解更多关于 React Query 的信息,请访问库的文档网站:tanstack.com/query

接下来,我们将学习如何将 React Query 集成到 React Router 的数据获取能力中。

使用 React Router 与 React Query

到目前为止,我们已经体验到了 React Router 和 React Query 数据获取的好处。React Router 减少了重新渲染的次数,而 React Query 提供了数据的客户端缓存。在本节中,我们将将这些库结合到我们的应用程序中,以便它具有这两个好处。

执行以下步骤:

  1. 首先,打开App.tsx并将路由定义上的 loader 函数更改为以下内容:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: ...,
    
        loader: async () => {
    
          const existingData = queryClient.getQueryData([
    
            'postsData',
    
          ]);
    
          if (existingData) {
    
            return defer({ posts: existingData });
    
          }
    
          return defer({
    
            posts: queryClient.fetchQuery(
    
              ['postsData'],
    
              getPosts
    
            )
    
          });
    
        }
    
      }
    
    ])
    

在 loader 内部,我们使用查询客户端上的 React Query 的getQueryData函数从其缓存中获取现有数据。如果有缓存数据,则返回;否则,数据将被检索、延迟并添加到缓存中。

  1. 打开PostsPage.tsx并移除 React Query 的useQuery的使用,因为现在 React Router 的 loader 管理数据加载过程。

  2. getPosts导入语句中移除getPosts函数,因为现在这个函数在 React Router 的 loader 中使用了。

  3. 此外,移除加载指示器,因为我们将在第 6 步中恢复使用 React Suspense。

  4. 数据将再次使用 React Router 的useLoaderData钩子检索,因此取消注释这两行代码:

    export function PostsPage() {
    
      const queryClient = useQueryClient();
    
      const { mutate } = useMutation( ... );
    
      const data = useLoaderData();
    
      assertIsData(data);
    
      return ...
    
    }
    
  5. 此外,恢复在 JSX 中使用SuspenseAwait。JSX 现在应该是这样的:

    <div className="w-96 max-w-xl mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-bold">
    
        Posts
    
      </h2>
    
      <NewPostForm onSave={mutate} />
    
      <Suspense fallback={<div>Fetching ...</div>}>
    
        <Await resolve={data.posts}>
    
          {(posts) => {
    
            assertIsPosts(posts);
    
            return <PostsList posts={posts} />;
    
          }}
    
        </Await>
    
      </Suspense>
    
    </div>
    
  6. 运行中的应用程序将像以前一样显示博客文章,但首次加载应用程序时,PostsPage将不再发生第二次渲染。然而,在通过表单添加新的博客文章后,它不会出现在列表中。我们将在下一步中解决这个问题。

  7. 在保存新的博客文章后,我们需要使路由组件重新渲染以获取最新数据。我们可以通过使路由导航到我们当前所在的页面来实现,如下所示:

    import {
    
      useLoaderData,
    
      Await,
    
      useNavigate
    
    } from 'react-router-dom';
    
    ...
    
    export function PostsPage() {
    
      const navigate = useNavigate();
    
      const queryClient = useQueryClient();
    
      const { mutate } = useMutation(savePost, {
    
        onSuccess: (savedPost) => {
    
          queryClient.setQueryData<PostData[]>(
    
            ['postsData'],
    
            (oldPosts) => {
    
              if (oldPosts === undefined) {
    
                return [savedPost];
    
              } else {
    
                return [savedPost, ...oldPosts];
    
              }
    
            }
    
          );
    
          navigate('/');
    
        },
    
      });
    
      ...
    
    }
    

在博客文章保存并添加到缓存后执行导航。这意味着路由的 loader 将执行并从缓存中填充其数据。然后PostsPage将使用useLoaderData返回的最新数据渲染。

这完成了应用程序的最终修订和本节关于使用 React Router 与 React Query 的内容。通过集成这两个库,我们获得了每个库的关键好处:

  • React Router 的数据加载器防止在页面加载数据时发生不必要的重新渲染

  • React Query 的缓存防止了对 REST API 的不必要调用

这两个库的集成方式是在 React Router 的 loader 中获取和设置数据,在 React Query 缓存中。

摘要

在本章中,我们使用了浏览器的fetch函数来发起 HTTP GETPOST请求。请求的 URL 是fetch函数的第一个参数。fetch函数的第二个参数允许指定请求选项,例如 HTTP 方法和正文。

可以使用类型断言函数来为 HTTP 请求响应体中的数据强类型。该函数接收具有unknown类型的数据。然后,该函数执行检查以验证数据的类型,如果数据无效,则抛出错误。如果没有错误发生,则在函数的类型断言签名中指定数据断言的类型。

React 的useEffect钩子可以用来在组件挂载时执行从后端 API 获取数据并存储到状态的调用。可以在useEffect内部使用一个标志来确保在设置数据状态之前,组件在 HTTP 请求后仍然挂载。

React Query 和 React Router 替换了数据获取过程中的useEffectuseState的使用,并简化了我们的代码。React Router 的 loader 函数允许数据被获取并传递到组件路由中,从而消除了不必要的重新渲染。React Query 包含一个可以在组件中使用的缓存,可以在获取最新数据的同时乐观地渲染数据。React Query 还包含一个useMutation钩子,用于启用数据的更新。

在下一章中,我们将介绍如何与 GraphQL API 交互。

问题

回答以下问题以检查你在本章中学到了什么:

  1. 以下效果尝试从 REST API 获取数据并将其存储在状态中:

    useEffect(async () => {
    
      const response = await fetch('https://some-rest-api/');
    
      const data = await response.json();
    
      setData(data);
    
    }, []);
    

这种实现有什么问题?

  1. 以下获取函数返回一个包含首字母的数组:

    export async function getFirstNames() {
    
      const response = await fetch('https://some-    firstnames/');
    
      const body = await response.json();
    
      return body;
    
    }
    

然而,该函数的返回类型是any。那么,我们如何改进实现,使其返回类型为string[]

  1. fetch函数参数中,应该指定什么method选项才能使其发起 HTTP PUT请求?

    fetch(url, {
    
      method: ???,
    
      body: JSON.stringify(data),
    
    });
    
  2. 当使用fetch向受保护的资源发起 HTTP 请求时,如何在 HTTP Authorization头中指定 bearer 令牌?

  3. 一个组件使用 React Query 的useQuery来获取数据,但组件出现以下错误:

未捕获错误:未设置 QueryClient,请使用 QueryClientProvider 设置一个

你认为问题是什么?

  1. 可以从 React Query 的useMutation中解构哪个状态变量来确定 HTTP 请求是否返回了错误?

答案

  1. 实现有两个问题:

    • useEffect不支持顶层async/await

    • 如果在 HTTP 请求期间组件卸载,则在设置data状态时将发生错误

这里是一个解决了这些问题的实现:

useEffect(() => {
  let cancel = false;
  fetch('https://some-rest-api/')
    .then((response) => data.json())
    .then((data) => {
      if (!cancel) {
        setData(data);
      }
    });
  return () => {
    cancel = true;
  };
}, []);
  1. 可以在响应体对象上使用assert函数如下:

    export async function getFirstNames() {
    
      const response = await fetch('https://some-    firstnames/');
    
      const body = await response.json();
    
      assertIsFirstNames(body);
    
      return body;
    
    }
    
    function assertIsFirstNames(
    
      firstNames: unknown
    
    ): asserts firstNames is string[] {
    
      if (!Array.isArray(firstNames)) {
    
        throw new Error('firstNames isn't an array');
    
      }
    
      if (firstNames.length === 0) {
    
        return;
    
      }
    
      firstNames.forEach((firstName) => {
    
        if (typeof firstName !== 'string') {
    
          throw new Error('firstName is not a string');
    
        }
    
      });
    
    }
    
  2. 方法选项应该是'PUT'

    fetch(url, {
    
      method: 'PUT',
    
      body: JSON.stringify(data),
    
    });
    
  3. 当使用fetch向受保护的资源发起 HTTP 请求时,可以使用headers.Authorization选项来指定 bearer 令牌:

    fetch(url, {
    
      headers: {
    
        Authorization: 'Bearer some-bearer-token',
    
        'Content-Type': 'application/json',
    
      },
    
    });
    
  4. 问题在于 React Query 的 QueryClientProvider 没有放置在 useQuery 所使用的组件之上,即在组件树中。

  5. 可以从 React Query 的 useMutation 中解构出 isError 状态变量,以确定 HTTP 请求是否返回了错误。或者,可以检查 status 状态变量是否为 'error' 值。