React-和-ReactNative-第五版-二-

79 阅读1小时+

React 和 ReactNative 第五版(二)

原文:zh.annas-archive.org/md5/47e218557a614bce0d999181bbb2b76b

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:使用路由处理导航

几乎每个 Web 应用程序都需要路由,这是根据一组路由处理声明对 URL 进行响应的过程。换句话说,这是 URL 到渲染内容的映射。然而,这项任务比最初看起来要复杂得多,因为管理不同的 URL 模式并将它们映射到适当的内容渲染涉及到许多复杂性。这包括处理嵌套路由、动态参数以及确保正确的导航流程。这些任务的复杂性是为什么在本章中,你将利用 react-router 包,这是 React 的既定路由工具。

首先,你将学习使用 JSX 语法声明路由的基础知识。然后,你将了解路由的动态方面,例如动态路径段和查询参数。接下来,你将使用 react-router 的组件实现链接。

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

  • 声明路由

  • 处理路由参数

  • 使用链接组件

技术要求

你可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter07

声明路由

使用 react-router,你可以将路由与它们渲染的内容进行组合。通过使用 JSX 语法定义与组件关联的路由,react-router 使开发者能够为他们的 React 应用程序创建一个清晰且逻辑的结构。这种组合使得理解应用程序的不同部分是如何连接和导航的变得更加容易,从而提高了代码库的可读性和可维护性。

在本章中,我们将使用 react-router 探索 React 应用程序中路由的基础知识。我们将从创建一个基本的示例路由开始,以便熟悉路由声明的语法和结构。然后,我们将更深入地研究按功能组织路由,而不是依赖于一个单一的路由模块。最后,我们将实现一个常见的父子路由模式,以展示如何处理更复杂的路由场景。

嗨,路由

在我们开始编写代码之前,让我们设置 react-router 项目。运行以下命令以将 react-router-dom 添加到依赖项中:

npm install react-router-dom 

让我们创建一个简单的路由,它渲染一个简单的组件:

  1. 首先,我们有一个小的 React 组件,当路由被激活时我们想要渲染它:

    function MyComponent() {
      return <p>Hello Route!</p>;
    } 
    
  2. 接下来,让我们看看路由定义:

    import React from "react";
    import ReactDOM from "react-dom/client";
    import { createBrowserRouter, RouterProvider } from "react-router-dom";
    import MyComponent from "./MyComponent";
    const router = createBrowserRouter([
      {
        path: "/",
        element: <MyComponent />,
      },
    ]);
    ReactDOM.createRoot(document.getElementById("root")!).render(
      <React.StrictMode>
        <RouterProvider router={router} />
      </React.StrictMode>
    ); 
    

RouterProvider 组件是应用程序的最高级组件。让我们分解它,以了解路由器内部发生了什么。

你在createBrowserRouter函数中声明了实际的路线。任何路由都有两个关键属性:pathelement。当path属性与活动 URL 匹配时,组件将被渲染。但它在哪里渲染呢?实际上,路由器并不渲染任何内容;它负责根据当前 URL 管理其他组件的连接。换句话说,路由器检查当前 URL,并从createBrowserRouter声明中返回相应的组件。确实,当你在一个浏览器中查看这个例子时,<MyComponent>如预期那样被渲染:

图 7.1:我们组件的渲染输出

path属性与当前 URL 匹配时,路由组件会被element属性值替换。在这个例子中,路由返回<MyComponent>。如果给定的路由不匹配,则不会渲染任何内容。

这个例子展示了 React 中路由的基础。声明路由非常简单直观。为了进一步巩固你对react-router的理解,我鼓励你尝试实验我们覆盖的概念。尝试自己创建更多路由,并观察它们如何影响你应用程序的行为。之后,你可以尝试更高级的技术,比如使用 React.lazy 和 Suspense 来懒加载组件(你将在下一章中了解更多关于这些的内容),并实现基于路由的代码拆分以优化你应用程序的性能。通过深入研究这些主题并将它们应用到自己的项目中,你将更加欣赏react-router的能力及其在现代、高效和用户友好的 React 应用程序构建中的作用。

解耦路由声明

路由的困难在于当你的应用程序在单个模块中声明了数十个路由时,因为将路由映射到功能上在心理上更困难。

为了帮助解决这个问题,应用程序的每个顶级功能都可以定义自己的路由。这样,就可以清楚地知道哪些路由属于哪个功能。所以,让我们从App组件开始:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        index: true,
        element: <h1>Nesting Routes</h1>,
      },
      routeOne,
      routeTwo,
    ],
  },
]);
export const App = () => <RouterProvider router={router} />; 

在这个例子中,应用程序有两个路由:onetwo。这些作为路由对象导入,并放置在createBrowserRouter内部。这个路由器中的第一个element<Layout />组件,它渲染一个带有永不改变的数据的页面模板,并作为我们路由数据的位置。让我们看看<Layout />组件:

function Layout() {
  return (
    <main>
      <nav>
        <Link to="/">Main</Link>
        <span> | </span>
        <Link to="/one">One</Link>
        <span> | </span>
        <Link to="/two">Two</Link>
      </nav>
      <Outlet />
    </main>
  );
} 

这个组件包含一个带有链接和<Outlet />组件的小型导航工具栏。它是一个内置的react-router组件,它将被匹配的路由元素替换。

路由的大小仅与应用程序的功能数量相关,而不是路由的数量,这可能会大得多。让我们看看其中一个功能路由:

 const routes: RouteObject = {
  path: "/one",
  element: <Outlet />,
  children: [
    {
      index: true,
      element: <Redirect path="/one/1" />,
    },
    {
      path: "1",
      element: <First />,
    },
    {
      path: "2",
      element: <Second />,
    },
  ],
}; 

这个模块,one/index.js,导出了一个包含三个路由的配置对象:

  • /one路径匹配时,重定向到/one/1

  • 当匹配到/one/1路径时,渲染First组件。

  • 当匹配到/one/2路径时,渲染Second组件。

这意味着当应用程序加载 URL/one时,<Redirect>组件将用户发送到/one/1。与RouterProvider一样,Redirect组件内部没有 UI 元素;它仅管理逻辑。

这与 React 将组件嵌入布局以处理特定功能的做法相一致。这种方法允许实现关注点的清晰分离,组件专注于渲染 UI 元素,而其他如Redirect等组件则专注于处理路由逻辑。react-router中的Redirect组件负责将用户程序性地导航到不同的路由。它通常用于根据某些条件(如身份验证状态或路由参数)将用户从一个 URL 重定向到另一个 URL。通过将导航逻辑抽象到单独的组件中,它促进了应用程序中的代码重用和可维护性。

您在这里使用Redirect是因为我们在根路由上没有内容。通常,您的应用程序实际上在功能的根或应用程序本身的根处没有要渲染的内容。这种模式允许您将用户发送到适当的路由和内容。以下是您打开应用程序并点击One链接时将看到的内容:

图 7.2:第 1 页的内容

第二个功能遵循与第一个完全相同的模式。以下是First组件的示例:

export default function First() {
  return <p>Feature 1, page 1</p>;
} 

在这个例子中,每个功能都使用相同的最小渲染内容。这些组件是用户在导航到特定路由时最终需要看到的内容。通过这种方式组织路由,您已经使功能在路由方面具有自包含性。

在下一节中,您将学习如何进一步将路由组织成父子关系。

处理路由参数

本章中您所看到的 URL 都是静态的。大多数应用程序将同时使用静态动态路由。在本节中,您将学习如何将动态 URL 段传递给组件,如何使这些段可选,以及如何获取查询字符串参数。

路由中的资源 ID

一个常见的用例是将资源的 ID 作为 URL 的一部分。这使得您的代码能够获取 ID,然后执行一个API调用以获取相关资源数据。让我们实现一个渲染用户详情页的路由。这需要一个包含用户 ID 的路由,然后需要以某种方式将用户 ID 传递给组件,以便它可以获取用户信息。

让我们从声明路由的App组件开始:

const router = createBrowserRouter([
  {
    path: "/",
    element: <UsersContainer />,
    errorElement: <p>Route not found</p>,
  },
  {
    path: "/users/:id",
    element: <UserContainer />,
    errorElement: <p>User not found</p>,
    loader: async ({ params }) => {
      const user = await fetchUser(Number(params.id));
      return { user };
    },
  },
]);
function App() {
  return <RouterProvider router={router} />;
} 

: 语法标记了 URL 变量的开始。id 变量将被传递给 UserContainer 组件。在显示组件之前,loader 函数被触发,异步获取指定用户 ID 的数据。在数据加载错误的情况下,errorElement 属性提供了一个回退来有效地处理这种情况。以下是 UserContainer 的实现方式:

function UserContainer() {
  const params = useParams();
  const { user } = useLoaderData() as { user: User };
  return (
    <div>
      User ID: {params.id}
      <UserData user={user} />
    </div>
  );
} 

useParams() 钩子用于获取 URL 的任何动态部分。在这种情况下,您对 id 参数感兴趣。然后,我们使用 useLoaderData 钩子从 loader 函数中获取 user。如果 URL 完全缺少该部分,则此代码根本不会运行;路由器将使我们回退到 errorElement 组件。

现在,让我们看看在这个示例中使用的 API 函数:

export type User = {
  first: string;
  last: string;
  age: number;
};
const users: User[] = [
  { first: "John", last: "Snow", age: 40 },
  { first: "Peter", last: "Parker", age: 30 },
];
export function fetchUsers(): Promise<User[]> {
  return new Promise((resolve) => {
    resolve(users);
  });
}
export function fetchUser(id: number): Promise<User> {
  return new Promise((resolve, reject) => {
    const user = users[id];
    if (user === undefined) {
      reject('User ${id} not found');
    } else {
      resolve(user);
    }
  });
} 

fetchUsers() 函数由 UsersContainer 组件用于填充用户链接列表。fetchUser() 函数将从模拟数据的 users 数组中查找并解析一个值。

这是 User 组件,它负责渲染用户详细信息:

type UserDataProps = {
  user: User;
};
function UserData({ user }: UserDataProps) {
  return (
    <section>
      <p>{user.first}</p>
      <p>{user.last}</p>
      <p>{user.age}</p>
    </section>
  );
} 

当您运行此应用程序并导航到 / 时,您应该会看到一个用户列表,看起来像这样:

图 7.3:应用程序主页的内容

点击第一个链接应将您带到 /users/0,看起来像这样:

图 7.4:用户页面的内容

如果您导航到一个不存在的用户,例如 /users/2,您将看到以下内容:

图 7.5:当找不到用户时

您得到此错误消息而不是 500 错误的原因是 API 端点知道如何处理缺失的资源:

if (user === undefined) {
  reject('User ${id} not found');
} 

此拒绝将由 react-router 使用提供的 errorElement 组件来处理。

在下一节中,我们将探讨定义可选路由参数。

查询参数

有时,我们需要可选的 URL 路径值或查询参数。对于简单的选项,URL 效果最好;如果组件可以使用许多值,则查询参数效果最好。

让我们实现一个用户列表组件,用于渲染用户列表。可选地,您希望能够按降序排序列表。让我们使用可以接受查询字符串的路由来实现这一点:

const router = createBrowserRouter([
  {
    path: "/",
    element: <UsersContainer />,
  },
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
); 

路由器中没有特殊设置用于处理查询参数。处理任何提供的查询字符串的责任在于组件。因此,虽然路由声明没有提供定义接受查询字符串的机制,但路由器仍然会将查询参数传递给组件。让我们看看用户列表容器组件:

export type SortOrder = "asc" | "desc";
function UsersContainer() {
  const [users, setUsers] = useState<string[]>([]);
  const [search] = useSearchParams();
  useEffect(() => {
    const order = search.get("order") as SortOrder;
    fetchUsers(order).then((users) => {
      setUsers(users);
    });
  }, [search]);
  return <Users users={users} />;
} 

此组件查找任一 order 查询字符串。它使用此作为 fetchUsers() API 的参数来确定排序顺序。

这是 Users 组件的外观:

type UsersProps = {
  users: string[];
};
function Users({ users }: UsersProps) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user}>{user}</li>
      ))}
    </ul>
  );
} 

当您导航到 / 时,以下是渲染的内容:

图 7.6:按默认顺序渲染用户列表

如果你通过导航到/?order=desc包含order查询参数,你将得到以下内容:

图片

图 7.7:按降序渲染用户列表

在本节中,你学习了关于路由中的参数。可能最常见的一种模式是将应用中资源的 ID 作为 URL 的一部分,这意味着组件需要能够解析出这些信息以便与 API 交互。你还学习了关于路由中的查询参数,这对于动态内容、过滤或组件之间传递临时数据非常有用。接下来,你将学习关于链接组件的内容。

使用链接组件

在本节中,你将学习如何创建链接。你可能倾向于使用标准的<a>元素来链接到由react-router控制的页面。这种方法的缺点是,从简单来说,这些链接将尝试通过发送GET请求在后台定位页面。这不是你想要的,因为路由配置已经在应用中,并且我们可以本地处理路由。

首先,你将看到一个示例,说明<Link>组件的行为与<a>元素类似,但它们是本地工作的。然后,你将学习如何构建使用 URL 参数和查询参数的链接。

基本链接

React 应用中链接的概念是它们指向指向组件的路由,这些组件渲染新的内容。Link组件还负责浏览器历史 API 并查找路由-组件映射。以下是一个渲染两个链接的应用组件:

function Layout() {
  return (
    <>
      <nav>
        <p>
          <Link to="first">First</Link>
        </p>
        <p>
          <Link to="second">Second</Link>
        </p>
      </nav>
      <main>
        <Outlet />
      </main>
    </>
  );
}
const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        path: "/first",
        element: <First />,
      },
      {
        path: "/second",
        element: <Second />,
      },
    ],
  },
]);
function App() {
  return <RouterProvider router={router} />;
} 

to属性指定了点击时激活的路由。在这种情况下,应用程序有两个路由:/first/second。以下是渲染的链接的外观:

图片

图 7.8:应用的第一页和第二页的链接

当你点击第一个链接时,页面内容将改变,看起来像这样:

图片

图 7.9:应用渲染时的第一页

现在你已经可以使用Link组件渲染到基本路径的链接,是时候学习如何使用参数构建动态链接了。

URL 和查询参数

构建传递给<Link>的路径的动态段涉及字符串操作。路径的任何部分都发送到to属性。这意味着你需要编写更多的代码来构建字符串,但也意味着在路由器中幕后发生的魔法更少。

让我们创建一个简单的组件,它将回显传递给 echo URL 段或 echo 查询参数的内容:

function Echo() {
  const params = useParams();
  const [searchParams] = useSearchParams();
  return <h1>{params.msg || searchParams.get("msg")}</h1>;
} 

为了获取传递给路由的搜索参数,你可以使用useSearchParams()钩子,它给你一个URLSearchParams对象。在这种情况下,我们可以调用searchParams.get("msg")来获取所需的参数。

现在,让我们看看渲染两个链接的App组件。第一个将构建一个使用动态值作为 URL 参数的字符串。第二个将使用URLSearchParams构建 URL 的查询字符串部分:

const param = "From Param";
const query = new URLSearchParams({ msg: "From Query" });
export default function App() {
  return (
    <section>
      <p>
        <Link to={'echo/${param}'}>Echo param</Link>
      </p>
      <p>
        <Link to={'echo?${query.toString()}'}>Echo query</Link>
      </p>
    </section>
  );
} 

下面是两个链接渲染后的样子:

图片

图 7.10:不同类型的链接参数

参数链接将你带到/echo/From%20Param,看起来像这样:

图片

图 7.11:页面的参数版本

查询链接将你带到/echo?msg=From+Query,看起来像这样:

图片

图 7.12:页面的查询版本

在了解Link组件和动态链接构建的过程中,你解锁了更互动和可导航的网页体验,使用户能够通过包含丰富旅程的 URL 和查询参数在应用程序中移动。

摘要

在本章中,你学习了 React 应用程序中的路由。路由器的工作是渲染与 URL 相对应的内容。react-router包是完成这项工作的标准工具。你学习了路由是如何像它们渲染的组件一样是 JSX 元素。有时,你需要将路由拆分成基于功能的模块。结构页面内容的一个常见模式是有一个父组件,它根据 URL 的变化渲染动态部分。然后,你学习了如何处理 URL 段和查询字符串的动态部分。你还学习了如何使用<Link>元素在你的应用程序中构建链接。

理解 React 应用程序中的路由为构建具有高效导航的复杂应用程序奠定了基础,为后续章节深入性能优化、状态管理和集成外部 API 做好了准备,确保了无缝的用户体验。

在下一章中,你将学习如何使用懒组件将你的代码拆分成更小的块。

第八章:使用懒组件和 Suspense 进行代码拆分

代码拆分在 React 应用程序中已经是一个重要的部分,甚至在官方支持被包含在React API中之前就已经存在。React 的演变带来了专门设计用于帮助代码拆分场景的 API。当处理包含大量需要发送到浏览器的 JavaScript 代码的大型应用程序时,代码拆分变得至关重要。

在过去,包含整个应用的单体 JavaScript 包可能会因为页面加载时间过长而导致可用性问题。多亏了代码拆分,我们现在可以更细致地控制代码从服务器传输到浏览器的方式。这为我们提供了大量优化加载时间用户体验UX)的机会。

在本章中,我们将回顾如何在 React 应用程序中使用lazy() API 和Suspense组件来实现这一点。这些功能是 React 工具箱中非常强大的工具。通过深入了解这些组件的工作原理,你将完全准备好无缝地将代码拆分集成到你的应用程序中。

本章将涵盖以下主题:

  • 使用lazy() API

  • 使用Suspense组件

  • 避免使用懒组件

  • 探索懒加载页面路由

技术要求

你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter08

使用懒 API

在 React 中使用lazy() API 涉及两个部分。首先,将组件打包到它们自己的单独文件中,以便浏览器可以单独从应用程序的其他部分下载它们。其次,一旦创建了这些包,你就可以构建懒加载的 React 组件:它们在需要之前不会下载任何内容。让我们看看这两个方面。

动态导入和包

本书中的代码示例使用Vite工具创建包。这种方法的优点是,你不需要维护任何包配置。相反,根据你如何导入模块,包会自动为你创建。如果你在所有地方都使用普通的import语句(不要与import方法混淆),你的应用程序将一次性下载在一个包中。当你的应用程序变大时,可能会出现一些用户可能永远不会使用或不如其他用户频繁使用的功能。你可以使用import()函数按需导入模块。通过使用此函数,你是在告诉 Vite 为动态导入的代码创建一个单独的包。

让我们看看一个我们可能希望与应用程序其他部分分开打包的简单组件:

export default function MyComponent() {
  return <p>My Component</p>;
} 

现在,让我们看看如何使用import()函数动态导入这个模块,从而创建一个单独的包:

function App() {
  const [MyComponent, setMyComponent] = React.useState<() => React.ReactNode>(
    () => () => null
  );
  React.useEffect(() => {
    import("./MyComponent").then((module) => {
      setMyComponent(() => module.default);
    });
  }, []);
  return <MyComponent />;
} 

当你运行这个示例时,你会立即看到<p>文本被渲染。如果你打开浏览器开发者工具并查看网络请求,你会注意到有一个单独的调用去获取包含MyComponent代码的包。这是因为对import("./MyComponent")的调用。import()函数返回一个解析为模块对象的 promise。由于我们需要默认导出以访问MyComponent,我们在调用setMyComponent()时引用module.default

我们将组件设置为MyComponent状态的原因是,当App组件首次渲染时,我们还没有加载MyComponent的代码。一旦加载,MyComponent将引用正确的值,从而渲染出正确的文本。

现在你已经了解了包是如何被创建并由应用获取的,是时候看看lazy() API 如何极大地简化了这个过程。

使组件懒加载

你不需要手动处理import()返回的 promise,通过返回默认导出和设置状态,而是可以依赖lazy() API。这个函数接收一个返回import() promise 的函数。返回值是一个懒加载组件,你可以直接渲染。让我们修改App组件以使用这个 API:

import * as React from "react";
const MyComponent = React.lazy(() => import("./MyComponent"));
function App() {
  return <MyComponent />;
} 

MyComponent的值是通过调用lazy()创建的,将动态模块导入作为参数传递。现在,你有一个为你的组件创建的单独包和一个在首次渲染时加载这个包的懒加载组件。

在本节中,你学习了代码拆分的工作原理。你了解到import()函数为你处理了包的创建。你还了解到lazy() API 使你的组件变得懒加载,并为你处理了导入组件的所有繁琐工作。但我们需要最后一件事,即Suspense组件,以帮助在组件加载时显示占位符。

使用Suspense组件

在本节中,我们将探讨Suspense组件的一些更常见的使用场景。我们将查看在组件树中放置Suspense组件的位置,如何在获取包时模拟延迟,以及我们可以用作回退内容的选项。

最高层级的Suspense组件

懒加载组件需要被渲染在Suspense组件内部。然而,它们不必是Suspense的直接子组件,这很重要,因为这意味着你可以有一个Suspense组件来处理你应用中的所有懒加载组件。让我们用一个例子来说明这个概念。这是一个我们希望单独打包并懒加载的组件:

export default function MyFeature() {
  return <p>My Feature</p>;
} 

接下来,让我们将MyFeature组件懒加载,并在MyPage组件中渲染它:

const MyFeature = React.lazy(() => import("./MyFeature"));
function MyPage() {
  return (
    <>
      <h1>My Page</h1>
      <MyFeature />
    </>
  );
} 

在这里,我们使用lazy() API 使MyFeature组件变为懒加载。这意味着当MyPage组件被渲染时,包含MyFeature的代码包将会被下载,因为MyFeature也被渲染了。需要注意的是,对于MyPage组件来说,它正在渲染一个懒加载组件(MyFeature),但它没有渲染一个Suspense组件。这是因为我们的假设应用有许多页面组件,每个页面都有自己的懒加载组件。让每个组件都渲染自己的Suspense组件将是多余的。相反,我们可以在App组件内部渲染一个Suspense组件,如下所示:

function App() {
  return (
    <React.Suspense fallback={"loading..."}>
      <MyPage />
    </React.Suspense>
  );
} 

MyFeature代码包正在下载时,<MyPage>会被替换为传递给Suspense的回退文本。所以,即使MyPage本身不是懒加载的,它也会渲染一个Suspense所知的懒加载组件,并在这一过程中用回退内容替换其子组件。

到目前为止,我们还没有真正看到在懒加载组件加载代码包时显示的回退内容。这是因为当本地开发时,这些包几乎会立即加载。为了能够看到回退组件和加载过程,你可以在开发者工具的网络选项卡中启用限制:

图 8.1:在浏览器中启用限制

这个设置模拟了慢速的互联网连接。页面不会立即加载,而是会渲染几秒钟,你将看到一个**加载中…**的回退。

在下一节中,我们将探讨使用加载spinner作为回退组件的方法。

使用spinner回退

你可以使用Suspense组件的最简单的回退是一些指示用户正在发生什么的文本。回退属性可以是任何有效的 React 元素,这意味着我们可以增强回退,使其更具视觉吸引力。例如,react-spinners包提供了一系列spinner组件,所有这些都可以作为Suspense的回退使用。

让我们将上一节中的App组件修改一下,以包含来自react-spinners包的spinner作为Suspense的回退:

import * as React from "react";
import { FadeLoader } from "react-spinners";
import MyPage from "./MyPage";
function App() {
  return (
    <React.Suspense fallback={<FadeLoader color="lightblue" />}>
      <MyPage />
    </React.Suspense>
  );
} 

FadeLoader组件将渲染一个我们配置了lightblue颜色的spinnerFadeLoader组件的渲染元素被传递到fallback属性。使用慢速 3G 限制,你应该能在首次加载应用时看到spinner

图 8.2:加载组件渲染的图像

现在,我们不再显示文本,而是显示一个动画spinner。这很可能会提供一个用户更习惯的用户体验。react-spinners包提供了几个spinner供你选择,每个spinner都有几个配置选项。你也可以使用其他spinner库,或者自己实现。

在本节中,你学习了如何使用单个 Suspense 组件来显示其回退内容,这对于树中任何较低级别的懒加载组件都是有效的。你学习了如何在本地开发期间模拟延迟,以便你可以体验你的用户将如何体验你的 Suspense 回退内容。最后,你学习了如何使用来自其他库的组件作为回退内容,以提供比纯文本看起来更好的东西。

在下一节中,你将了解到为什么将应用中的每个组件都做成懒加载组件是没有意义的。

避免使用懒加载组件

可能会很有诱惑力将大多数 React 组件做成懒加载组件,这些组件各自存在于自己的包中。毕竟,设置单独的包和创建懒加载组件并不需要做太多额外的工作。然而,这样做也有一些缺点。如果你有太多的懒加载组件,你的应用最终会同时发起多个 HTTP 请求来获取它们:这并没有为在应用同一部分使用的组件使用单独的包带来任何好处。你最好尝试以某种方式将组件打包在一起,使得只需一个 HTTP 请求就能加载当前页面上所需的内容。

有助于思考的一种方式是将 页面 相关联。如果你有懒加载的页面组件,该页面上的一切也将是懒加载的,并且与其他页面上的组件打包在一起。让我们构建一个示例,演示如何组织我们的懒加载组件。假设你的应用有几个页面,每个页面上都有一些功能。如果当页面加载时这些功能都需要,我们就不一定想使这些功能成为懒加载的。以下是显示用户选择要加载哪个页面的 App 组件:

const First = React.lazy(() => import("./First"));
const Second = React.lazy(() => import("./Second"));
function ShowComponent({ name }: { name: string }) {
  switch (name) {
    case "first":
      return <First />;
    case "second":
      return <Second />;
    default:
      return null;
  }
} 

FirstSecond 组件是我们应用中的页面,因此我们希望它们成为按需加载其包的懒加载组件。当用户更改选择器时,ShowComponent 组件会渲染适当的页面:

function App() {
  const [component, setComponent] = React.useState("");
  return (
    <>
      <label>
        Load Component:{" "}
        <select
          value={component}
          onChange={(e) => setComponent(e.target.value)}
        >
          <option value="">None</option>
          <option value="first">First</option>
          <option value="second">Second</option>
        </select>
      </label>
      <React.Suspense fallback={<p>loading...</p>}>
        <ShowComponent name={component} />
      </React.Suspense>
    </>
  );
} 

接下来,让我们看看第一页,看看它是如何组成的,从 First 组件开始:

import One from "./One";
import Two from "./Two";
import Three from "./Three";
export default function First() {
  return (
    <>
      <One />
      <Two />
      <Three />
    </>
  );
} 

First 组件会引入三个组件并将它们渲染出来:OneTwoThree。这三个组件将构成同一个包。虽然我们可以使它们成为懒加载的,但这并没有什么意义,因为我们所做的只是同时发起三个 HTTP 请求来获取包,而不是一个。

现在你已经更好地理解了如何将应用页面的结构映射到包上,让我们看看另一个用例,其中我们使用路由组件在应用中导航。

探索懒加载页面和路由

Avoiding lazy components(避免使用懒加载组件)部分,你看到了在没有好处的情况下应避免使组件成为懒加载的地方。当你在使用 react-router 作为在应用中导航的机制时,可以应用相同的模式。让我们看看一个例子。以下是我们需要导入的内容:

const First = React.lazy(() => import("./First"));
const Second = React.lazy(() => import("./Second"));
function Layout() {
  return (
    <section>
      <nav>
        <span>
          <Link to="first">First</Link>
        </span>
        <span> | </span>
        <span>
          <Link to="second">Second</Link>
        </span>
      </nav>
      <section>
        <React.Suspense fallback={<FadeLoader color="lightblue" />}>
          <Outlet />
        </React.Suspense>
      </section>
    </section>
  );
}
export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route path="/first" element={<First />} />
          <Route path="/second" element={<Second />} />
        </Route>
      </Routes>
    </Router>
  );
} 

在前面的代码中,我们有两个将单独打包的懒加载页面组件。在这个例子中,回退内容使用了在使用旋转器回退部分中引入的相同的FadeLoader spinner组件。

注意,Suspense组件放置在导航链接之下。这意味着当内容加载时,将在这个位置渲染出最终显示的页面内容。Suspense组件的子元素是Route组件,它们将渲染我们的懒加载页面组件:例如,当/first路由被激活时,First组件将首次渲染,触发包下载。

这就带我们结束了这一章。

摘要

本章全部关于代码拆分和打包,这些是大型 React 应用程序中的重要概念。我们首先通过使用import()函数查看如何在你的 React 应用程序中将代码拆分成包。然后,我们探讨了lazy() React API 以及它是如何帮助简化首次渲染组件时的包加载。接下来,我们更深入地研究了Suspense组件,该组件用于在组件包被检索时管理内容。fallback属性是我们指定在加载包时显示的内容的方式。只要你的应用程序遵循一致的打包模式,通常你不需要在应用程序中使用超过一个Suspense组件。

在下一章中,你将学习如何使用Next.js框架来处理在服务器上渲染 React 组件。Next.js 框架允许你创建作为 React 组件的页面,这些页面可以在服务器和浏览器上渲染。这对于需要良好的初始页面加载性能的应用程序来说是一个重要的功能:也就是说,所有应用程序。

第九章:用户界面框架组件

当你开发 React 应用程序时,通常依赖于现有的UI 库而不是从头开始构建。有许多 React UI 组件库可供选择,只要组件使你的生活变得更简单,就没有错误的选择。

在本章中,我们将深入研究 Material UI React 库,这是 React 开发的流行选择。Material UI 因其全面的定制组件套件、遵循 Google 的 Material Design 原则以及广泛的文档而脱颖而出,使其成为寻求 UI 设计效率和美学一致性的开发者的最佳选择。以下是我们将涵盖的具体主题:

  • 布局和 UI 组织

  • 使用导航组件

  • 收集用户输入

  • 样式主题一起工作

技术要求

你可以在 GitHub 上找到本章中存在的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter09

你也可以在mui.com/material-ui/找到更多关于 Material UI 组件及其 API 的信息。

布局和组织

Material UI在简化应用程序布局的复杂过程中表现出色。通过提供强大的组件集,特别是容器网格,它使开发者能够高效地构建和组织 UI 元素。容器作为基础,提供了一种灵活的方式来封装和定位整体布局中的内容。另一方面,网格允许更精细的控制,能够精确地放置和对齐不同屏幕尺寸下的组件,确保响应性和一致性。

本节旨在解开 Material UI 中容器和网格的功能。我们将探讨如何利用这些工具创建直观且美观的布局,这对于提升用户体验至关重要。

使用容器

在页面上水平对齐组件通常是一个重大挑战,因为这需要在间距、对齐和响应性之间保持复杂的平衡。这种复杂性源于需要在各种屏幕尺寸上保持视觉吸引力和功能性布局的需求,确保元素均匀分布,并保持其预期的外观,避免意外的重叠或间隙。Material UI 的Container组件是一个简单但功能强大的布局工具。它控制其子元素的横向宽度。让我们看看一个例子,看看可能实现什么:

import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
export default function MyApp() {
  const textStyle = {
    backgroundColor: "#cfe8fc",
    margin: 1,
    textAlign: "center",
  };
  return (
    <>
      <Container maxWidth="sm">
        <Typography sx={textStyle}>sm</Typography>
      </Container>
      <Container maxWidth="md">
        <Typography sx={textStyle}>md</Typography>
      </Container>
      <Container maxWidth="lg">
        <Typography sx={textStyle}>lg</Typography>
      </Container>
    </>
  );
} 

这个例子有三个Container组件,每个组件都包裹一个Typography组件。Typography组件用于在 Material UI 应用程序中渲染文本。在这个例子中使用的每个Container组件都包含一个maxWidth属性。它接受一个断点字符串值。这些断点代表常见的屏幕尺寸。这个例子使用了小(sm)、中(md)和大型(lg)。当屏幕达到这些断点大小时,容器宽度将停止增长。以下是当宽度小于sm断点时页面看起来像什么:

图片

图 9.1:sm 断点

现在,如果我们调整屏幕大小,使其大于md断点,但小于lg断点,它看起来会是这样:

图片

图 9.2:lg 断点

注意,现在我们已经超过了其maxWidth断点,第一个容器保持固定宽度。mdlg容器将继续随着屏幕增长,直到它们的断点被超过。

让我们看看当屏幕宽度超过所有断点时这些Container组件看起来像什么:

图片

图 9.3:所有断点

Container组件让您控制页面元素如何水平增长。它们也是响应式的,因此当屏幕尺寸变化时,您的布局将得到更新。

在下一节中,我们将探讨使用 Material UI 组件构建更复杂和响应式布局。

构建响应式网格布局

Material UI 有一个Grid组件,我们可以用它来组合响应式复杂布局。从高层次来看,一个Grid组件可以是容器或容器内的一个项目。通过结合这两个角色,我们可以为我们的应用程序实现任何类型的布局。为了熟悉 Material UI 网格布局,让我们放在一起一个例子,它使用我们在许多 Web 应用程序中常见的常见布局模式。这是结果看起来像什么:

图片

图 9.4:一个示例响应式网格布局

如您所见,这个布局有在许多 Web 应用程序中常见的熟悉部分。这只是一个示例布局;您可以使用Grid组件构建任何您能想象到的布局。让我们看看创建这个布局的代码:

const headerFooterStyle = {
  textAlign: "center",
  height: 50,
};
const mainStyle = {
  textAlign: "center",
  padding: "8px 16px",
};
const Item = styled(Paper)(() => ({
  height: "100%",
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
}));
export default function App() {
  return (
    <Grid container spacing={2} sx={{ backgroundColor: "#F3F6F9" }}>
      <Grid xs={12}>
        <Item sx={headerFooterStyle}>
          <Typography sx={mainStyle}>Header</Typography>
        </Item>
      </Grid>
      <Grid xs="auto">
        <Item>
          <Stack spacing={1}>
            <Typography sx={mainStyle}>Nav Item 1</Typography>
            <Typography sx={mainStyle}>Nav Item 2</Typography>
            <Typography sx={mainStyle}>Nav Item 3</Typography>
            <Typography sx={mainStyle}>Nav Item 4</Typography>
          </Stack>
        </Item>
      </Grid>
      <Grid xs>
        <Item>
          <Typography sx={mainStyle}>Main content</Typography>
        </Item>
      </Grid>
      <Grid xs={12}>
        <Item sx={headerFooterStyle}>
          <Typography sx={mainStyle}>Footer</Typography>
        </Item>
      </Grid>
    </Grid>
  );
} 

让我们分析这个布局中的部分是如何创建的。我们将从页眉部分开始:

<Grid xs={12}>
  <Item sx={headerFooterStyle}>
    <Typography sx={mainStyle}>Header</Typography>
  </Item>
</Grid> 

xs断点属性值12意味着页眉将始终占据整个屏幕宽度,因为12是这里可以使用的最高值。接下来,让我们看看导航项:

<Grid xs="auto">
  <Item>
    <Stack spacing={1}>
      <Typography sx={mainStyle}>Nav Item 1</Typography>
      <Typography sx={mainStyle}>Nav Item 2</Typography>
      <Typography sx={mainStyle}>Nav Item 3</Typography>
      <Typography sx={mainStyle}>Nav Item 4</Typography>
    </Stack>
  </Item>
</Grid> 

在导航部分,我们有一个带有xs="auto"属性的网格。它使列的大小与其内容的宽度相匹配。此外,您还可以看到我们使用Stack组件以垂直方向和间距放置组件。

接下来,我们将查看主要内容部分:

<Grid xs>
  <Item>
    <Typography sx={mainStyle}>Main content</Typography>
  </Item>
</Grid> 

xs断点是一个用于在网格中填充导航部分之后所有空闲空间的真值。

在本节中,你了解了 Material UI 在布局方面能提供什么。你可以使用Container组件来控制节宽以及它们如何响应屏幕尺寸变化。然后你了解到Grid组件用于组合更复杂的网格布局。

在下一节中,我们将查看 Material UI 中的一些导航组件。

使用导航组件

一旦我们有了我们应用程序布局的外观和工作方式的初步想法,我们就可以开始考虑导航了。这是我们的 UI 的一个重要部分,因为它是用户在应用程序中导航的方式,并且会被频繁使用。在本节中,我们将了解 Material UI 提供的两个导航组件。

使用抽屉导航

Drawer组件就像一个物理抽屉一样,滑动打开以显示易于访问的内容。当我们完成时,抽屉再次关闭。这对于导航来说效果很好,因为它不会妨碍,允许屏幕上有更多空间用于用户正在参与的活动。让我们看一个例子,从App组件开始:

<BrowserRouter>
  <Button onClick={toggleDrawer}>Open Nav</Button>
  <section>
    <Routes>
      <Route path="/first" element={<First />} />
      <Route path="/second" element={<Second />} />
      <Route path="/third" element={<Third />} />
    </Routes>
  </section>
  <Drawer open={open} onClose={toggleDrawer}>
    <div
      style={{ width: 250 }}
      role="presentation"
      onClick={toggleDrawer}
      onKeyDown={toggleDrawer}
    >
      <List component="nav">
        {links.map((link) => (
          <NavLink
            key={link.url}
            to={link.url}
            style={{ color: "black", textDecoration: "none" }}
          >
            {({ isActive }) => (
              <ListItemButton selected={isActive}>
                <ListItemText primary={link.name} />
              </ListItemButton>
            )}
          </NavLink>
        ))}
      </List>
    </div>
  </Drawer>
</BrowserRouter> 

让我们看看这里发生了什么。这个组件渲染的任何内容都在BrowserRouter组件内部,因为抽屉中的项目是路由的链接:

<Button onClick={toggleDrawer}>Open Nav</Button>
<section>
  <Routes>
    <Route path="/first" element={<First />} />
    <Route path="/second" element={<Second />} />
    <Route path="/third" element={<Third />} />
  </Routes>
</section> 

FirstSecondThird组件用于在用户点击抽屉中的链接时渲染主要应用程序内容。当点击打开导航按钮时,抽屉本身会打开。让我们更仔细地看看用于控制此状态的变量:

const [open, setOpen] = useState(false);
const toggleDrawer = ({ type, key }: { type?: string; key?: string }) => {
  if (type === "keydown" && (key === "Tab" || key === "Shift")) {
    return;
  }
  setOpen(!open);
}; 

open状态控制抽屉的可见性。Drawer组件的onClose属性也会调用此函数,这意味着当抽屉内的任何链接被激活时,抽屉会关闭。接下来,让我们看看抽屉内的链接是如何生成的:

<List component="nav">
  {links.map((link) => (
    <NavLink
      key={link.url}
      to={link.url}
      style={{ color: "black", textDecoration: "none" }}
    >
      {({ isActive }) => (
        <ListItemButton selected={isActive}>
          <ListItemText primary={link.name} />
        </ListItemButton>
      )}
    </NavLink>
  ))}
</List> 

Drawer组件中显示的项目实际上是列表项,正如你在这里可以看到的。links属性包含所有具有urlname属性的链接对象。items 数组中的每个项目都映射到NavLink,用于处理导航并突出显示活动路由。在NavLink内部,我们有ListItemButton组件,它通过渲染ListItemText组件来生成带有文本的列表项。

最后,让我们看看links属性的默认值:

const links = [
  { url: "/first", name: "First Page" },
  { url: "/second", name: "Second Page" },
  { url: "/third", name: "Third Page" },
]; 

这是屏幕首次加载后打开抽屉的样子:

图片

图 9.5:显示到我们页面链接的抽屉

尝试点击第一页链接。抽屉关闭并渲染/first路由的内容。然后,当你再次打开抽屉时,你会注意到第一页链接被渲染为活动链接:

图片

图 9.6:在抽屉中,第一页链接被样式化为活动链接

在本节中,你学习了如何使用Drawer组件作为应用程序的主要导航。在下一节中,我们将探讨Tabs组件。

使用标签导航

标签是现代网络应用中另一种常见的导航模式。Material UI 的Tabs组件允许我们使用标签作为链接并将它们连接到路由器。让我们看看如何做到这一点的示例。以下是App组件:

export default function App() {
  return <RouterProvider router={router} />;
}
const router = createBrowserRouter([
  {
    path: "/",
    element: <RouteLayout />,
    children: [
      {
        path: "/page1",
        element: <Typography>Item One</Typography>,
      }, // same routes for /page2 and /page3
    ],
  },
]);
function RouteLayout() {
  const routeMatch = useRouteMatch(["/", "/page1", "/page2", "/page3"]);
  const currentTab = routeMatch?.pattern?.path;
  return (
    <Box>
      <Tabs value={currentTab}>
        <Tab label="Item One" component={Link} to="/page1" value="/page1" />
        <Tab label="Item Two" component={Link} to="/page2" value="/page2" />
        <Tab label="Item Three" component={Link} to="/page3" value="/page3" />
      </Tabs>
      <Outlet />
    </Box>
  );
} 

为了节省空间,我省略了/page2/page3的路由配置;它们的模式与/page1相同。Material UI 中的TabsTab组件实际上不会在选中的标签下渲染任何内容。这取决于我们提供内容,因为Tabs组件只负责显示标签并标记其中一个为选中状态。本例旨在让Tab组件使用Link组件,这些组件链接到由路由渲染的内容。

现在我们来仔细看看RouteLayout组件。每个Tab组件都使用Link组件,这样当它被点击时,路由器就会激活to属性中指定的路由。然后使用Outlet组件作为路由内容的子组件。为了匹配激活的标签,我们使用useRouteMatch来处理当前路由的简单方法:

function useRouteMatch(patterns: readonly string[]) {
  const { pathname } = useLocation();
  for (let i = 0; i < patterns.length; i += 1) {
    const pattern = patterns[i];
    const possibleMatch = matchPath(pattern, pathname);
    if (possibleMatch !== null) {
      return possibleMatch;
    }
  }
  return null;
} 

useRouteMatch钩子使用useLocation获取当前的pathname,然后检查它是否与我们的模式匹配。

这是页面首次加载时的样子:

图 9.7:第一个选项处于激活状态

如果你点击项目二标签,URL 将更新,激活的标签将改变,标签下面的页面内容也会改变:

图 9.8:第二个选项处于激活状态

到目前为止,你已经了解了在 Material UI 应用程序中可以使用的两种导航方法。第一种是使用仅在用户需要访问导航链接时显示的Drawer。第二种是使用始终可见的Tabs。在下一节中,你将学习如何收集用户输入。

收集用户输入

从用户那里收集输入可能很困难。如果我们想要提供良好的用户体验,我们需要考虑每个字段许多细微之处。幸运的是,Material UI 中可用的Form组件为我们处理了许多可用性问题。在本节中,你将简要了解你可以使用的输入控件。

复选框和单选按钮

复选框用于从用户那里收集true/false答案,而单选按钮用于让用户从少量选项中选择一个。让我们看看 Material UI 中这些组件的示例:

export default function Checkboxes() {
  const [checkbox, setCheckbox] = React.useState(false);
  const [radio, setRadio] = React.useState("First");
  return (
    <div>
      <FormControlLabel
        label={'Checkbox ${checkbox ? "(checked)" : ""}'}
        control={
          <Checkbox
            checked={checkbox}
            onChange={() => setCheckbox(!checkbox)}
          />
        }
      />
      <FormControl component="fieldset">
        <FormLabel component="legend">{radio}</FormLabel>
        <RadioGroup value={radio} onChange={(e) => setRadio(e.target.value)}>
          <FormControlLabel value="First" label="First" control={<Radio />} />
          <FormControlLabel value="Second" label="Second" control={<Radio />} />
          <FormControlLabel value="Third" label="Third" control={<Radio />} />
        </RadioGroup>
      </FormControl>
    </div>
  );
} 

此示例包含两件状态信息。checkbox状态控制Checkbox组件的值,而radio值控制RadioGroup组件的状态。checkbox状态传递给Checkbox组件的checked属性,而radio状态传递给RadioGroup组件的value属性。这两个组件都有onChange处理程序,它们调用它们各自的状态设置函数:setCheckbox()setRadio()。你会注意到许多其他 Material UI 组件都参与了这些控件显示。例如,checkbox的标签使用FormControlLabel组件显示,而单选控件使用FormControl组件和FormLabel组件。

下面是这两个输入控件的外观:

图片

图 9.9:复选框和单选组

这两个控件标签都更新以反映组件的状态变化。复选框标签显示复选框是否被选中,而单选按钮标签显示当前选定的值。在下一节中,我们将查看文本输入和选择组件。

文本输入和选择输入

文本字段允许我们的用户输入文本,而选择允许他们从几个选项中进行选择。选择和单选按钮之间的区别在于,由于选项仅在用户打开选项菜单时才显示,因此选择在屏幕上占用的空间更少。

现在让我们看看Select组件:

import { useState } from "react";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
export default function MySelect() {
  const [value, setValue] = useState<string | undefined>();
  return (
    <FormControl>
      <InputLabel id="select-label">My Select</InputLabel>
      <Select
        labelId="select-label"
        id="select"
        label="My Select"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        inputProps={{ id: "my-select" }}
      >
        <MenuItem value="first">First</MenuItem>
        <MenuItem value="second">Second</MenuItem>
        <MenuItem value="third">Third</MenuItem>
      </Select>
    </FormControl>
  );
} 

在此示例中使用的值状态控制Select组件中的选定值。当用户更改他们的选择时,setValue()函数会更改值。

MenuItem组件用于指定select字段中可用的选项;当选择给定项时,value属性设置为值状态。以下是菜单显示时的select字段外观:

图片

图 9.10:第一个项目处于活动状态的菜单

接下来,让我们看看一个TextField组件的示例:

export default function MyTextInput() {
  const [value, setValue] = useState("");
  return (
    <TextField
      label="Name"
      value={value}
      onChange={(e) => setValue(e.target.value)}
      margin="normal"
    />
  );
} 

值状态控制文本输入的值,并随着用户的输入而改变。下面是text字段的外观:

图片

图 9.11:带有用户提供的文本的文本字段

与其他FormControl组件不同,TextField组件不需要其他几个支持组件。我们所需的一切都可以通过属性来指定。在下一节中,我们将查看Button组件。

与按钮一起工作

Material UI 按钮与 HTML 按钮元素非常相似。区别在于它们是 React 组件,与 Material UI 的其他方面(如主题和布局)配合得很好。让我们看看一个渲染不同样式按钮的示例:

type ButtonColor = "primary" | "secondary";
export default function App() {
  const [color, setColor] = useState<ButtonColor>("secondary");
  const updateColor = () => {
    setColor(color === "secondary" ? "primary" : "secondary");
  };
  return (
    <Stack direction="row" spacing={2}>
      <Button variant="contained" color={color} onClick={updateColor}>
        Contained
      </Button>
      <Button color={color} onClick={updateColor}>
        Text
      </Button>
      <Button variant="outlined" color={color} onClick={updateColor}>
        Outlined
      </Button>
      <IconButton color={color} onClick={updateColor}>
        <AndroidIcon />
      </IconButton>
    </Stack>
  );
} 

此示例渲染了四种不同的按钮样式。我们使用Stack组件来渲染按钮行。当按钮被点击时,状态会在主要和次要之间切换。

这是按钮首次渲染时的样子:

图 9.12:四种 Material UI 按钮样式

这是每个按钮被点击后的样子:

图 9.13:按钮被点击后的样子

在本节中,你了解了 Material UI 中一些可用的用户输入控件。复选框单选按钮在用户需要开启或关闭某个功能或选择一个选项时非常有用。当用户需要输入一些文本时,文本输入是必要的,而选择框在您有一系列选项可供选择但显示空间有限时非常有用。最后,你了解到 Material UI 有几种按钮样式,当用户需要启动一个动作时可以使用。在下一节中,我们将探讨在 Material UI 中样式和主题是如何工作的。

与样式和主题一起工作

Material UI 包含用于扩展 UI 组件样式和扩展应用于所有组件的主题样式的系统。在本节中,你将了解如何使用这两个系统。

制作样式

Material UI 自带一个styled()函数,可以用来基于 JavaScript 对象创建样式化组件。这个函数的返回值是一个应用了新样式的新的组件。

让我们更详细地看看这种方法:

const StyledButton = styled(Button)(({ theme }) => ({
  "&.MuiButton-root": { margin: theme.spacing(1) },
  "&.MuiButton-contained": { borderRadius: 50 },
  "&.MuiButton-sizeSmall": { fontWeight: theme.typography.fontWeightLight },
}));
export default function App() {
  return (
    <>
      <StyledButton>First</StyledButton>
      <StyledButton variant="contained">Second</StyledButton>
      <StyledButton size="small" variant="outlined">
        Third
      </StyledButton>
    </>
  );
} 

在这个样式中使用的名字(MuiButton-rootMuiButton-containedMuiButton-sizeSmall)并不是我们想出来的。这些都是按钮 CSS API的一部分。根样式应用于所有按钮,因此在这个例子中,所有三个按钮都将具有我们在这里应用的边距值。contained样式应用于使用包含变体的按钮。sizeSmall样式应用于具有小尺寸属性值的按钮。

这是自定义按钮样式的外观:

图 9.14:使用自定义样式的按钮

现在你已经知道了如何更改单个组件的外观和感觉,是时候考虑如何自定义整个应用程序的外观和感觉了。

自定义主题

Material UI 自带默认主题。我们可以以此为基础创建自己的主题。在 Material UI 中创建新主题主要有两个步骤:

  1. 使用createTheme()函数来自定义默认主题设置,并返回一个新的主题对象。

  2. 使用ThemeProvider组件包裹我们的应用程序,以便应用适当的主题。

让我们看看这个流程在实际中是如何工作的:

import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { ThemeProvider, createTheme } from "@mui/material/styles";
const theme = createTheme({
  typography: {
    fontSize: 11,
  },
  components: {
    MuiMenuItem: {
      styleOverrides: {
        root: {
          marginLeft: 15,
          marginRight: 15,
        },
      },
    },
  },
});
export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Menu anchorEl={document.body} open={true}>
        <MenuItem>First Item</MenuItem>
        <MenuItem>Second Item</MenuItem>
        <MenuItem>Third Item</MenuItem>
      </Menu>
    </ThemeProvider>
  );
} 

我们在这里创建的自定义主题做了两件事:

  • 它将所有组件的默认字体大小更改为11

  • 它更新了MenuItem组件的左右边距值。

在 Material UI 主题中可以设置许多值;更多自定义信息请参考自定义文档。components 部分用于组件特定的自定义。当你需要为应用中每个组件实例设置样式时,这非常有用。

摘要

本章是对 Material UI 的非常简要介绍,它是最受欢迎的 React UI 框架。我们首先查看用于帮助布局我们页面的组件。然后我们查看可以帮助用户在应用中导航的组件。接下来,你学习了如何使用 Material UI 表单组件收集用户输入。最后,你学习了如何通过样式和修改主题来设置你的 Material UI。

从本章中获得的认识使你能够在不从头开发 UI 组件的情况下构建复杂界面,从而加速你的开发过程。此外,React 应用开发本质上依赖于各种辅助库的协同使用。对 React 生态系统及其关键库的深入了解使开发者能够快速原型设计和迭代他们的应用,使开发更有效。

在下一章中,我们将探讨使用 React 最新版本中提供的最新功能来提高组件状态更新效率的方法。

第十章:高性能状态更新

状态代表了你的 React 应用程序的动态方面。当状态发生变化时,你的组件会对这些变化做出反应。没有状态,你将只有一些花哨的 HTML 模板语言。通常,执行状态更新并在屏幕上渲染更改所需的时间几乎不明显,如果有的话。然而,有时复杂的状态变化可能导致用户注意到明显的延迟。本章的目标是解决这些问题,并找出我们如何避免这些延迟。

在本章中,你将学习以下内容:

  • 将你的状态更改批量处理在一起以实现最小化重新渲染

  • 优先更新状态以渲染对用户体验至关重要的内容

  • 批量处理优先处理状态更新时开发执行异步操作的战略

技术要求

对于本章,你需要你的代码编辑器(Visual Studio Code)。我们将遵循的代码可以在以下位置找到:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter10

你可以在 Visual Studio Code 中的终端运行npm install,以确保你能够随着阅读本章的例子而跟进。

批量处理状态更新

在本节中,你将了解 React 如何将状态更新批量处理在一起,以防止在多个状态变化同时发生时进行不必要的渲染。特别是,我们将探讨在React 18中引入的更改,这些更改使得状态更新的自动批量处理变得普遍。

当你的 React 组件发出状态变化时,这会导致 React 内部重新渲染由于这种状态更新而视觉上发生变化的部分。例如,想象你有一个包含名称状态的组件,该状态渲染在<span>元素内部,并将名称状态从Adam更改为Ashley。这是一个简单的更改,导致重新渲染得太快,以至于用户甚至注意不到。不幸的是,Web 应用程序中的状态更新很少如此简单。相反,可能会有数十个状态变化在 10 毫秒内发生。例如,名称状态可能会跟随以下变化:

  1. Adam

  2. Ashley

  3. Andrew

  4. Ashley

  5. Aaron

  6. Adam

在这里,我们短时间内发生了六个名为 state 的变化。这意味着 React 会重新渲染DOM六次,每次设置一个值作为 name state。关于这个场景有趣的是最终的状态更新:我们回到了起点,Adam。这意味着我们无端地重新渲染了 DOM 五次。现在,想象一下在 Web 应用规模上的这些浪费的重新渲染,以及这些类型的状态更新可能会对性能造成的问题。例如,当应用使用复杂的动画、用户交互如拖放、超时和间隔时,都可能导致不必要的重新渲染,从而对性能产生负面影响。

解决这个问题的答案是批处理。这就是 React 如何将我们在组件代码中做出的几个状态更新视为一个单一的状态更新。而不是逐个处理每个状态更新,在每次更新之间重新渲染 DOM,状态更改都被合并,从而只导致一次 DOM 重新渲染。总的来说,这大大减少了我们的 Web 应用需要执行的工作量。

React 17中,状态更新的自动批处理仅在事件处理函数内部发生。例如,假设你有一个带有onClick()处理器的按钮,该处理器执行五个状态更新。React 会将所有这些状态更新一起批处理,从而只需要一次重新渲染。问题出现在你的事件处理器进行异步调用,通常是为了获取一些数据,然后在异步调用完成后进行状态更新。这些状态更改不再自动批处理,因为它们不是直接在事件处理器函数中运行的。相反,它们运行在异步操作的回调代码中,React 17 不会批处理这些更新。这是一个挑战,因为我们的 React 组件异步获取数据并在事件响应中执行状态更新是很常见的!

现在我们知道了如何处理最常见的不必要重新渲染问题,即短时间内对状态进行多次更改。现在,让我们通过例子来理解它。

React 18 批处理

现在,让我们将注意力转向一些代码,看看React 18是如何解决我们刚刚概述的批处理问题的。在这个例子中,我们将渲染一个按钮,当点击时,将执行 100 次状态更新。我们将使用setTimeout()来确保更新是异步执行的,在事件处理函数之外。目的是展示两种不同的 React 版本处理此代码的方式之间的差异。为此,我们可以在浏览器开发者工具中打开React 分析器,在按下按钮执行我们的状态更改之前点击记录。下面是代码的样子:

import * as React from "react";
export default function BatchingUpdates() {
  let [value, setValue] = React.useState("loading...");
  function onStart() {
    setTimeout(() => {
      for (let i = 0; i < 100; i++) {
        setValue('value ${i + 1}');
      }
    }, 1);
  }
  return (
    <div>
      <p>
        Value: <em>{value}</em>
      </p>
      <button onClick={onStart}>Start</button>
    </div>
  );
} 

通过点击该组件渲染的按钮,我们调用由我们的组件定义的 onStart() 事件处理器函数。然后,我们的处理器在循环中调用 setValue() 100 次。理想情况下,我们不想进行 100 次重新渲染,因为这会损害我们应用程序的性能,而且也不需要这样做。这里只关心 setValue() 的最终调用。

首先,让我们看看使用 React 17 捕获的该组件的配置文件:

图 13.1 – 使用 React 开发工具查看每次状态更新时进行的重新渲染

图 10.1:使用 React 开发工具查看每次状态更新时进行的重新渲染

通过按下与我们事件处理器相关联的按钮,我们进行了 100 次状态更新调用。由于这是在 setTimeout() 函数外部完成的,所以不会发生自动批处理。我们可以在 BactchingUpdates 组件的配置文件输出中看到这一点,其中有一长串的渲染。其中大部分是不必要的,并增加了 React 需要执行以响应用户交互的工作量,从而损害了我们应用程序的整体性能。

让我们捕获使用 React 18 渲染的相同组件的配置文件:

图 13.2 – React 开发工具显示启用自动批处理时仅有一个渲染

图 10.2:React 开发工具显示启用自动批处理时仅有一个渲染

自动批处理应用于所有进行状态更新的地方,甚至在像这种情况这样的常见异步场景中也是如此。正如配置文件所示,当我们点击按钮时,只有一个重新渲染,而不是 100 个。我们也不必对我们的组件代码进行任何调整来实现这一点。然而,为了使状态更新自动批处理,我们需要进行一个更改。假设你使用了 ReactDOM.render() 来渲染你的根组件,如下所示:

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
); 

相反,你可以使用 ReactDOM.createRoot() 并渲染它:

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
); 

通过这种方式创建和渲染你的根节点,你可以确保在 React 18 中,你将获得整个应用程序的批处理状态更新。你不再需要担心手动优化状态更新以确保它们立即发生:React 现在为你这样做。然而,有时你会有比其他状态更新优先级更高的状态更新。在这种情况下,我们需要一种方法来告诉 React 优先处理某些状态更新,而不是将所有内容一起批处理。

优先处理状态更新

当我们的 React 应用程序发生某些事件时,我们通常会进行多次状态更新,以便 UI 可以反映这些变化。通常,你可以做出这些状态变化而不必过多考虑渲染性能的影响。例如,假设你有一个需要渲染的长列表项目。这可能会对 UI 产生一些影响:当列表正在渲染时,用户可能无法与某些页面元素交互,因为 JavaScript 引擎在短时间内 100%被占用。

然而,当昂贵的渲染干扰了用户期望的正常浏览器行为时,这可能会成为一个问题。例如,如果用户在文本框中输入文本,他们期望刚刚输入的字符立即显示出来。但如果你组件正忙于渲染一个大型项目列表,文本框的状态无法立即更新。这就是新的 React 状态更新优先级 API 派上用场的地方。

startTransition() API 用于标记某些状态更新为过渡性,这意味着更新被视为低优先级。如果你考虑一个项目列表要么是首次渲染,要么是改变为另一个项目列表,这种转换不需要立即进行。另一方面,如更改文本框中的值这样的状态更新应该尽可能接近立即。通过使用startTransition(),你告诉 React,如果存在更重要的更新,任何状态更新都可以等待。

对于startTransition()的一个好的经验法则是用于以下情况:

  • 任何可能执行大量渲染工作的内容

  • 任何不需要用户对其交互立即反馈的内容

让我们通过一个例子来了解,当用户在文本框中输入以过滤列表时,如何渲染大量项目列表。

这个组件将渲染一个用户可以输入以过滤 25000 个项目列表的文本框。我选择这个数字是基于我编写此代码时所使用的笔记本电脑的性能:如果你没有延迟,你可能需要调整它,如果渲染任何东西都花费太长时间,你可能需要将其降低。当页面首次加载时,你应该看到一个看起来像这样的过滤器文本框:

图片

图 10.3:用户输入任何内容之前的过滤器框

当你开始在过滤器文本框中输入时,过滤后的项目将显示在其下方。由于需要渲染的项目很多,可能需要一秒钟或两秒钟:

图片

图 10.4:当用户开始输入时,过滤器输入下方的过滤项目

现在,让我们从一组大量项目开始,逐步分析代码:

let unfilteredItems = new Array(25000)
  .fill(null)
  .map((_, i) => ({ id: i, name: 'Item ${i}' })); 

数组的尺寸是在数组构造函数中指定的,然后它被填充了我们可以通过其进行过滤的编号字符串值。

接下来,让我们看看这个组件使用的状态:

let [filter, setFilter] = React.useState("");
let [items, setItems] = React.useState([]); 

filter状态表示过滤器文本框的值,默认为空字符串。items状态表示来自我们的unfilteredItems数组的过滤项。当用户在过滤器文本框中输入时,此数组被填充。

接下来,让我们看看这个组件渲染的标记:

<div>
  <div>
    <input
      type="text"
      placeholder="Filter"
      value={filter}
      onChange={onChange}
    />
  </div>
  <div>
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  </div>
</div> 

过滤器文本框由一个<input>元素渲染,而过滤结果通过遍历items数组以列表形式渲染。

最后,让我们看看当用户在过滤器文本框中输入时触发的事件处理函数:

const onChange = (e) => {
  setFilter(e.target.value);
  setItems(
    e.target.value === ""
      ? []
      : unfilteredItems.filter((item) => item.name.includes(e.target.value))
  );
}; 

当用户在过滤器文本框中输入时,会调用onChange()函数,并设置两个状态值。首先,它使用setFilter()来设置过滤器文本框的值。然后,它调用setItems()来设置要渲染的过滤项,除非过滤器文本为空,在这种情况下,我们不渲染任何内容。

当与这个示例交互时,你可能会注意到在输入时文本框的响应性问题。这是因为在这个函数中,我们不仅设置了文本框的值,还设置了过滤项。这意味着在文本值可以渲染之前,我们必须等待数千个项被渲染。

尽管这些是两个独立的状态更新(setFilter()setItems()),但它们被批处理并被视为单一状态更新。同样,当渲染开始时,React 会一次性执行所有更改,这意味着 CPU 不会让用户与文本框交互,因为它完全被利用,渲染出长长的过滤结果列表。理想情况下,我们希望优先处理文本框的状态更新,同时允许项在之后渲染。换句话说,我们希望降低项渲染的优先级,因为它成本高昂,并且用户不会直接与之交互。

这就是startTransition() API 发挥作用的地方。传递给startTransition()函数内部发生的任何状态更新都将被赋予比其外部发生的任何状态更新更低的优先级。在我们的过滤示例中,我们可以通过将setItems()状态更改移动到startTransition()内部来修复文本框的响应性问题。

这是我们的新onChange()事件处理器的样子:

const onChange = (e) => {
  setFilter(e.target.value);
  React.startTransition(() => {
    setItems(
      e.target.value === ""
        ? []
        : unfilteredItems.filter((item) => item.name.includes(e.target.value))
    );
  });
}; 

注意,我们不需要对项的状态更新方式做出任何更改:相同的代码被移动到一个传递给startTransition()的函数中。这告诉 React 仅在所有其他状态更改完成后执行此状态更改。在我们的情况下,这允许文本框在setItems()状态更改运行之前更新和渲染。如果你现在运行示例,你会看到文本框的响应性不再受渲染长列表所需时间的影响。

在这个新 API 介绍之前,你可以通过使用 setTimeout() 的变通方法来实现状态更新优先级。这种方法的主要缺点是,React 内部调度器对您的状态更新及其优先级一无所知。例如,通过使用 startTransitiion(),React 可以在状态更改再次发生之前或组件卸载时取消整个更新。

在实际应用中,这不仅仅是一个优先考虑哪个状态更新应该首先运行的问题。相反,它是在确保优先级得到考虑的同时异步获取数据。在本章的最后部分,我们将把这些内容串联起来。

处理异步状态更新

在本章的最后部分,我们将探讨异步获取数据和设置渲染优先级的常见场景。我们想要解决的关键场景是确保用户在输入或进行任何需要即时反馈的交互时不会被中断。这需要适当的优先级处理和从服务器处理异步响应。让我们首先看看可以帮助这个场景的 React API。

startTransition() API 可以用作 钩子。当我们这样做时,我们也会得到一个布尔值,我们可以检查它以确定转换是否仍在挂起。这有助于向用户显示正在加载。让我们修改上一节中的示例,使用异步数据获取函数来获取我们的项目。我们还将使用 useTransition() 钩子,并给组件的输出添加加载行为:

let unfilteredItems = new Array(25000)
  .fill(null)
  .map((_, i) => ({ id: i, name: 'Item ${i}' }));
function filterItems(filter: string) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(unfilteredItems.filter((item) => item.name.includes(filter)));
    }, 1000);
  });
}
export default function AsyncUpdates() {
  const [isPending, startTransition] = React.useTransition();
  const [isLoading, setIsLoading] = React.useState(false);
  const [filter, setFilter] = React.useState("");
  const [items, setItems] = React.useState<{ id: number; name: string }[]>([]);
  const onChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
    setFilter(e.target.value);
    startTransition(() => {
      if (e.target.value === "") {
        setItems([]);
      } else {
        filterItems(e.target.value).then((result) => {
          setItems(result);
        });
      }
    });
  };
  return (...);
} 

这个例子表明,一旦你在过滤文本框中开始输入,它将触发 onChange() 处理程序,这将调用 filterItems() 函数。我们还有一个 isLoading 值,我们可以用它来向用户显示后台正在发生某些事情:

<div>
  <div>
    <input
      type="text"
      placeholder="Filter"
      value={filter}
      onChange={onChange}
    />
  </div>
  <div>
    {isPending && <em>loading...</em>}
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  </div>
</div> 

isLoadingtrue 时,用户将看到以下内容:

图片

图 10.5:状态转换挂起时的加载指示器

然而,我们的方法存在一个小问题。你可能已经注意到,在文本框中输入时,加载信息会短暂闪烁。但随后,你可能有一个更长的时间段,项目仍然不可见,加载信息消失了。这里发生了什么?嗯,来自 useTransition() 钩子的 isPending 值可能会误导。我们设计组件的方式是,以下情况下 isPending 将为 true

  • 如果 filterItems() 函数仍在获取我们的数据

  • 如果 setItems() 状态更新仍在执行一个昂贵的渲染,并且有很多项目

很不幸,isPending 并不是这样工作的。这个值只有在我们将函数传递给 startTransition() 之前是 true 的。这就是为什么你会在数据获取操作和渲染操作期间看到加载指示器短暂闪烁而不是一直显示的原因。记住,React 在内部调度状态更新,通过使用 startTransition(),我们已经调度了 setItems() 在其他状态更新之后运行。

另一种思考 isPending 的方式是,它在高优先级更新仍在运行时是 true 的。我们可以称之为 highPriorityUpdatesPending 以避免混淆。尽管如此,这个值的用途很窄,但它们确实偶尔会发生。对于我们的更常见情况,即获取数据和执行昂贵的渲染,我们需要考虑另一种解决方案。让我们审查我们的代码,并以一种方式重构它,使得在获取和更高优先级的更新发生时显示加载指示器。首先,让我们引入一个新的 isLoading 状态,默认为 false

const [isLoading, setIsLoading] = React.useState(false);
const [filter, setFilter] = React.useState("");
const [items, setItems] = React.useState([]); 

现在,在我们的 onChange() 处理程序内部,我们可以将状态设置为 true。在数据获取完成后运行的转换中,我们将其设置回 false

const onChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
  setFilter(e.target.value);
  setIsLoading(true);
  React.startTransition(() => {
    if (e.target.value === "") {
      setItems([]);
      setIsLoading(false);
    } else {
      filterItems(e.target.value).then((result) => {
        setItems(result);
        setIsLoading(false);
      });
    }
  });
}; 

现在我们正在跟踪 isLoading 状态,我们知道所有重负载何时完成,并且可以隐藏加载指示器。最后的更改是将指示器的显示基于 isLoading 而不是 isPending

<div>
  {isLoading && <em>loading...</em>}
  <ul>
    {items.map((item) => (
      <li key={item.id}>{item.name}</li>
    ))}
  </ul>
</div> 

当你运行这些更改的示例时,结果应该会更加可预测。setLoading()setFilter() 状态更新是高优先级的,并且会立即执行。使用 filterItems() 获取数据的调用直到高优先级状态更新完成后才会进行。

只有在我们获取到数据后,我们才会隐藏加载指示器。

摘要

本章向您介绍了 React 18 中可用的新 API,这些 API 有助于您实现高性能状态更新。我们从 React 18 中自动状态更新批处理的变化开始,并探讨了如何最好地利用它们。然后我们探讨了新的 startTransition() API 以及如何将其用于标记某些状态更新为比那些需要即时用户交互反馈的状态更新具有更低优先级。最后,我们探讨了如何将状态更新优先级与异步数据获取相结合。

在下一章中,我们将介绍从服务器获取数据。

第十一章:从服务器获取数据

网络技术的发展使得浏览器与服务器之间的交互以及服务器数据的处理成为网络开发的一个核心部分。如今,很难在传统网页和完整的网络应用之间划清界限。这一变革的核心是浏览器中 JavaScript 的能力,它能够向服务器发起请求,高效地处理接收到的数据,并在页面上动态显示。这个过程已成为创建我们今天所看到的交互式和响应式网络应用的基础。在本章中,我们将探讨从服务器获取数据的各种方法和途径,讨论它们对网络应用架构的影响,并熟悉这一领域的现代实践。

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

  • 处理远程数据

  • 使用 Fetch API

  • 使用 Axios

  • 使用 TanStack Query

  • 使用 GraphQL

技术要求

您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter11

处理远程数据

在网络开发领域,从服务器获取数据的过程经历了显著的变革。在 20 世纪 90 年代初,随着HTTP 1.0的出现,标志着服务器通信的开始。网页是静态的,HTTP 请求也很基础,仅用于获取整个页面或静态资源。每次请求都需要建立新的连接,交互性非常有限,主要限于 HTML 表单。安全性也很基础,反映了网络的初级阶段。

千禧年的转折点见证了异步 JavaScript 和 XMLAJAX)的兴起,这带来了增强的交互性,允许网络应用在后台与服务器通信,而无需重新加载整个页面。它由XMLHttpRequest对象驱动。以下是一个使用XMLHttpRequest获取数据的简单示例:

var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if (xhr.readyState == XMLHttpRequest.DONE) {
    if (xhr.status === 200) {
      console.log(xhr.responseText);
    } else {
      console.error('Error fetching data');
    }
  }
};
xhr.open('GET', 'http://example.com', true);
xhr.send(); 

这个例子说明了典型的XHR 请求。成功和错误响应通过回调函数管理。这反映了异步代码严重依赖回调的时代。

随着我们的进步,HTTP 演变为1.1版本,通过持久连接增强了效率,并标准化了RESTful API。这些 API 使用标准的 HTTP 方法,并围绕可识别的资源设计,大大提高了可扩展性和开发者的生产力。

Fetch API的出现提供了一种现代的、基于 Promise 的机制来发起网络请求。Fetch 比XMLHttpRequest更强大、更灵活。以下是一个使用 Fetch 的示例:

fetch('http://example.com/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error)); 

此外,还有许多基于 Fetch API 和 XHR 开发的社区工具。例如,Axios、GraphQL 和 React Query 进一步简化了服务器通信和数据获取,增强了开发者的体验。

Axios是一个现代的 HTTP 客户端库,通过基于 Promise 的 API 和一系列有用的功能(如拦截请求和响应)进一步简化了数据获取。以下是如何使用 Axios 进行GET请求的示例:

axios.get('http://example.com/data')
  .then(response => console.log(response.data))
  .catch(error => console.error('Error:', error)); 

这个例子可能看起来与Fetch API 相同,但在实际项目中,当你设置了拦截器时,它就变成了一个节省大量时间的游戏改变者。拦截器允许你在请求发送之前拦截并修改请求,在响应处理之前拦截并修改响应。一个常见的用例是在访问令牌过期时刷新访问令牌。拦截器可以将新令牌添加到所有后续请求中。通过使用像 Axios 这样的库,许多低级网络代码被抽象化,让你可以专注于发送请求和处理响应。拦截器、错误处理和其他功能以可重用的方式解决跨切面问题,从而产生更干净的代码。

接下来是GraphQL,它通过允许客户端请求他们确切需要的数据,从而彻底改变了数据获取方式,消除了过度获取和不足获取的问题。它提供了一种灵活且高效的方式从服务器检索数据。而不是预定义的端点,客户端指定他们的数据需求,服务器则返回精确请求的数据。这减少了网络负载并提高了应用程序的性能。

import { GraphQLClient, gql } from 'graphql-request';
const endpoint = 'http://example.com/graphql';
const client = new GraphQLClient(endpoint);
const query = gql'
  query {
    user(id: 123) {
      name
      email
    }
  }
';
client.request(query)
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error)); 

在这里,我们通过ID请求用户,只指定了两个字段:nameemail。无论用户对象的大小如何,GraphQL 服务器都能高效地处理它,只向客户端发送请求的数据。

我还想探讨另一个工具React Query。这个库旨在简化 React 应用程序中的数据获取和状态管理。它抽象掉了获取和缓存数据的复杂性,处理后台更新,并提供 Hooks 以方便与组件集成。React Query 通过使以高效和可维护的方式与服务器数据一起工作变得简单,从而提高了开发过程。

import { useQuery } from 'react-query';
function UserProfile({ userId }) {
  const { data, error, isLoading } = useQuery(userId, fetchUser);
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div>
      <h1>{data.name}</h1>
      <p>Email: {data.email}</p>
    </div>
  );
} 

如您所见,我们甚至不需要处理错误或手动设置和更新加载状态。所有这些都是由一个 Hook 提供的。

服务器通信的另一个显著发展是WebSockets,它实现了实时双向通信。这对于需要实时数据更新的应用程序,如聊天应用或交易平台来说是一个游戏改变者。以下是一个使用 WebSockets 的基本示例:

const socket = new WebSocket('ws://example.com');
socket.onopen = function(event) {
  console.log('Connection established');
};
socket.onmessage = function(event) {
  console.log('Message from server ', event.data);
};
socket.onerror = function(error) {
  console.error('WebSocket Error ', error);
}; 

在这里,我们仍然使用回调方法,因为双向通信的心理模型。

总之,服务器通信在 Web 开发中的演变对于提升用户体验和开发者生产力至关重要。从HTTP 1.0的初级阶段到今天的复杂工具,我们见证了巨大的转变。Ajax、Fetch API、Axios、GraphQL 和 React Query 等技术的引入不仅简化了服务器交互,还标准化了应用程序中的异步行为。这些进步对于高效管理加载、错误和离线场景等状态至关重要。这些工具在现代 Web 应用程序中的集成标志着在构建更响应、更健壮和用户友好的界面方面迈出了重要一步。这是对技术不断演变及其对网络内容创建和消费深远影响的证明。

在下一节中,我们将探讨如何使用 Fetch API 从服务器获取数据的真实示例。

使用 Fetch API

让我们探索如何在实践中从服务器检索数据。我们将从最常见和基础的Fetch API开始。

在我们开始之前,让我们创建一个小型应用程序,该应用程序从 GitHub 获取用户数据并在屏幕上显示他们的头像和基本信息。为此,我们需要一个空的Vite项目,并使用 React。您可以使用以下命令创建它:

npm create vite@latest 

由于我们在示例中使用TypeScript,让我们首先定义GitHubUser接口和所有必要的参数。

为了找出服务器返回的数据,我们通常需要参考文档,通常由后端开发者提供。在我们的案例中,由于我们使用 GitHub REST API,我们可以在官方 GitHub 文档中找到用户信息,链接如下:docs.github.com/en/rest/users/users?apiVersion=2022-11-28

让我们按照以下方式创建GitHubUser接口:

export interface GitHubUser {
  login: string;
  id: number;
  avatar_url: string;
  html_url: string;
  gists_url: string;
  repos_url: string;
  name: string;
  company: string | null;
  location: string | null;
  bio: string | null;
  public_repos: number;
  public_gists: number;
  followers: number;
  following: number;
} 

这些是我们将在应用程序中使用的必要字段。实际上,user对象中还有更多字段,但我只包括了我们将要使用的那些。

现在我们知道了用户将拥有的字段,让我们创建一个组件,该组件将在屏幕上显示用户数据:

const UserInfo = ({ user }: GitHubUserProps) => {
  return (
    <div>
      <img src={user.avatar_url} alt={user.login} width="100" height="100" />
      <h2>{user.name || user.login}</h2>
      <p>{user.bio}</p>
      <p>Location: {user.location || "Not specified"}</p>
      <p>Company: {user.company || "Not specified"}</p>
      <p>Followers: {user.followers}</p>
      <p>Following: {user.following}</p>
      <p>Public Repos: {user.public_repos}</p>
      <p>Public Gists: {user.public_gists}</p>
      <p>
        GitHub Profile:{" "}
        <a href={user.html_url} target="_blank" rel="noopener noreferrer">
          {user.login}
        </a>
      </p>
    </div>
  );
}; 

在这里,我们将用户的头像和一些有用的信息以及一个打开他们 GitHub 个人资料页面的链接一起展示。

现在让我们看看App组件,在那里我们处理服务器数据检索逻辑:

function App() {
  const [user, setUser] = useState<GitHubUser>();
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setLoading(true);
    fetch("https://api.github.com/users/sakhnyuk")
      .then((response) => response.json())
      .then((data) => setUser(data))
      .catch((error) => console.log(error))
      .finally(() => setLoading(false));
  }, []); 

我们使用useState钩子来存储user数据和加载状态。在useEffect中,我们通过 Fetch API 请求从 GitHub API 获取数据。如您所见,fetch函数接受一个 URL 作为参数。我们处理响应,将其保存到状态中,使用catch块处理错误,并最终使用finally块关闭加载过程。

为了完成应用程序,我们展示检索到的用户数据:

 return (
    <div>
      {loading && <p>Loading...</p>}
      {!loading && !user && <p>No user found.</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

您可以使用以下命令运行您的应用程序:

npm run dev 

打开终端中出现的链接,你会看到:

图 11.1:由 Fetch API 请求的 GitHub 用户

现在你已经知道了如何使用 Fetch API 获取数据。让我们探索一个类似的应用程序的实施,其中我们使用其他工具请求数据。

使用 Axios

在本节中,我们将探索一个用于与服务器交互的最受欢迎的库,称为 Axios。这个库类似于 Fetch API,但也提供了额外的功能,使其成为处理请求的强大工具。

让我们把我们的前一个项目拿来做一些修改。首先,让我们将 Axios 作为依赖项安装:

npm install axios 

Axios 的一个特性是能够创建具有特定配置的实例,例如头部信息、基本 URL、拦截器等。这使得我们可以拥有一个预先配置的实例,以满足我们的需求,减少代码重复,并使其更具可扩展性。

让我们创建一个 API 类,它封装了与服务器交互所需的所有必要逻辑:

class API {
  private apiInstance: AxiosInstance;
  constructor() {
    this.apiInstance = axios.create({
      baseURL: "https://api.github.com",
    });
    this.apiInstance.interceptors.request.use((config) => {
      console.log("Request:", '${config.method?.toUpperCase()} ${config.url}');
      return config;
    });
    this.apiInstance.interceptors.response.use(
      (response) => {
        console.log("Response:", response.data);
        return response;
      },
      (error) => {
        console.log("Error:", error);
        return Promise.reject(error);
      }
    );
  }
  getProfile(username: string) {
    return this.apiInstance.get<GitHubUser>('/users/${username}');
  }
}
export default new API(); 

在这个类的构造函数中,我们创建并存储一个 Axios 实例,并设置基本 URL,从而消除在未来的请求中重复此域的需要。接下来,我们为每个请求和响应配置拦截器。这是为了演示目的,所以当我们运行应用程序时,我们可以在控制台日志中看到所有的请求和响应:

图 11.2:Axios 拦截器日志

现在,让我们看看使用我们新的 API 类的 App 组件将是什么样子:

function App() {
  const [user, setUser] = useState<GitHubUser>();
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    setLoading(true);
    api
      .getProfile("sakhnyuk")
      .then((res) => setUser(res.data))
      .finally(() => setLoading(false));
  }, []);
  return (
    <div>
      {loading && <p>Loading...</p>}
      {!loading && !user && <p>No user found.</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

如前所述,Axios 与 Fetch API 并无显著差异,但它提供了更强大的功能,使得创建更复杂的用于处理服务器数据的解决方案变得容易。

在下一节中,我们将探索使用 TanStack Query 实现的相同应用程序。

使用 TanStack Query

TanStack Query,更常被称为 React Query,是一个将服务器交互提升到新高度的库。这个库允许我们请求数据并将其缓存。因此,我们可以在一次渲染期间多次调用相同的 useQuery 钩子,但只需向服务器发送一个请求。该库还包括内置的加载和错误状态,简化了请求状态的处理。

要开始,让我们将库作为我们项目的依赖项安装:

npm install @tanstack/react-query 

接下来,我们需要通过添加 QueryClientProvider 来配置库:

const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
); 

在此设置之后,我们可以开始工作在应用程序上。这个库的一个独特特性是它对用于数据获取的工具是中立的。你只需要提供一个返回数据的承诺函数。让我们使用 Fetch API 创建这样一个函数:

const userFetcher = (username: string) =>
  fetch("https://api.github.com/users/sakhnyuk")
  .then((response) => response.json()); 

现在,让我们看看我们的 App 组件变得多么简单:

function App() {
  const {
    data: user,
    isPending,
    isError,
  } = useQuery({
    queryKey: ["githubUser"],
    queryFn: () => userFetcher("sakhnyuk"),
  });
  return (
    <div>
      {isPending && <p>Loading...</p>}
      {isError && <p>Error fetching data</p>}
      {user && <UserInfo user={user} />}
    </div>
  );
} 

现在,所有制作请求和处理加载和错误状态的逻辑都包含在一个单一的 useQuery 钩子中。

在下一节中,我们将探索一个更强大的用于数据获取的工具,即 GraphQL。

使用 GraphQL

在本章早期,我们讨论了 GraphQL 是什么以及它如何允许我们指定从服务器获取的确切数据,从而减少传输的数据量并加快数据获取速度。

在这个例子中,我们将探索与 @apollo/client 库结合使用的 GraphQL,该库提供了与 React Query 类似的功能,但与 GraphQL 查询一起工作。

首先,让我们使用以下命令安装必要的依赖项:

npm install @apollo/client graphql 

接下来,我们需要在我们的应用程序中添加一个提供者:

const client = new ApolloClient({
  uri: "https://api.github.com/graphql",
  cache: new InMemoryCache(),
  headers: {
    Authorization: 'Bearer YOUR_PAT', // Put your GitHub personal access token here
  },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
); 

在这个阶段,在客户端设置过程中,我们指定我们想要与之工作的服务器 URL、缓存设置和身份验证。在早期示例中,我们使用了公共 GitHub API,但 GitHub 也支持 GraphQL。为此,我们需要提供一个 GitHub 个人访问令牌,您可以在您的 GitHub 个人资料设置中获取。

对于我们的示例,为了演示我们如何仅选择所需的字段,让我们缩减用户数据。组件中的 GraphQL 查询将如下所示:

const GET_GITHUB_USER = gql'
  query GetGithubUser($username: String!) {
    user(login: $username) {
      login
      id
      avatarUrl
      bio
      name
      company
      location
    }
  }
'; 

现在一切准备就绪,让我们看看 App 组件将是什么样子:

function App() {
  const { data, loading, error } = useQuery(GET_GITHUB_USER, {
    variables: { username: "sakhnyuk" },
  });  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error fetching data</p>;
  const user = data.user;
  return (
    <div>
      <UserInfo user={user} />
    </div>
  );
} 

与 React Query 类似,我们有访问加载状态、错误和实际数据的能力。当我们打开应用程序时,我们将看到结果:

图片 B19636_11_03

图 11.3:通过 GraphQL 请求的 GitHub 用户

为了确保服务器返回我们请求的确切数据,我们可以打开 Chrome 开发者工具,转到 网络 选项卡,并检查我们的请求:

图片 B19636_11_04

图 11.4:GraphQL 请求

图 11.4 所示,服务器发送给我们我们在查询中指定的精确数据。你可以通过实验查询参数来查看差异。

摘要

在本章中,我们探讨了如何从服务器获取数据。我们首先简要回顾了客户端-服务器通信的历史,并强调了与服务器交互的主要方法。接下来,我们构建了一个应用程序,使用 Fetch API、Axios、TanStack Query 和 Apollo GraphQL 来检索 GitHub 用户数据。

本章中你学到的技术将使你能够显著扩展你自己的 Web 应用程序的功能。通过从服务器高效地获取数据,你可以为用户创建动态、数据驱动的体验。无论你是构建一个显示实时流的社交媒体应用程序,一个提供最新产品信息的电子商务网站,还是一个可视化实时数据的仪表板,你获得的技术将证明是无价的。

在下一章中,我们将深入探讨使用状态管理库来管理应用程序状态。

第十二章:React 中的状态管理

在前面的章节中,我们探讨了 React 中的状态概念,并掌握了使用useState钩子与之交互的基础。现在,我们需要深入探讨应用的全局状态管理。在本章中,我们将关注全局状态:我们将定义它是什么,它的关键优势以及有效管理的策略。

本章将涵盖以下主题:

  • 什么是全局状态?

  • React 上下文 API 和 useReducer

  • Redux

  • Mobx

什么是全局状态?

在开发 React 应用程序时,需要特别注意的一个关键方面是状态管理。我们已经熟悉了useState钩子,它允许我们在组件内部创建和管理状态。这种类型的状态通常被称为局部,它在一个组件内部非常有效,简单且易于使用。

为了更清晰地说明,考虑一个具有小型表单组件的例子,其中我们有两个输入元素,并为每个输入创建了两个状态:

图 12.1:带有局部状态的表单组件

在这个例子中,一切都很简单:用户在input中输入一些内容,这会触发一个onChange事件,我们通常在这里改变我们的state,导致表单的全局重新渲染,然后我们在屏幕上看到输入的结果。

然而,随着应用程序的复杂性和规模的增加,不可避免地需要一种更可扩展和灵活的状态管理方法。让我们进一步考虑我们的例子,并想象在填写表单信息后,我们需要向服务器发送用户授权请求并获取会话键。然后,使用这个键,我们需要请求用户数据:姓名、姓氏和头像。

在这里,我们立即遇到了困难:会话键和用户数据应该存储在哪里?也许我们可以在表单内部直接检索数据,然后将其传递给父组件,因为它是更全局的并且负责。好吧,让我们来展示这一点并看看:

图 12.2:带有表单组件的登录页面

因此,现在我们有一个登录页面,其中我们为会话用户对象创建了局部状态。使用 props,我们可以将像onSessionChangeonUserChange这样的函数传递给表单组件,这最终使我们能够将数据从表单传输到登录页面。此外,在表单中,我们现在有getSessionKeygetUser这样的函数。这些方法与服务器交互,在成功响应后,它们不会在本地存储数据,而是调用上述的onSessionChangeonUserChange

有些人可能会认为数据存储问题已经解决,但很可能在用户授权并获得他们的数据后,我们需要将用户重定向到我们应用程序的某个主页。我们可能再次重复将数据提升到更高层次的小把戏,但在这样做之前,让我们提前思考并想象获取用户数据可能不仅仅是授权表单的工作,这种功能可能在其他页面上也需要。

最终,我们理解到,除了数据本身之外,我们还需要将处理数据的逻辑保持在组件树的上层:

图片

图 12.3:应用程序根组件

这张图片清楚地展示了当我们需要将所有必要的数据和方法从应用程序的最顶层组件传递到所有页面和组件时,应用程序如何变得更加复杂。

除了实现和维护这种组织应用程序状态的方法的复杂性之外,还存在一个重大的性能问题。例如,在根组件中通过useState创建的状态,每次我们更新它时,整个应用程序都会重新渲染,因为应用程序的根组件将被重新绘制。

因此,我们已经识别出在大型应用程序的组件中组织本地状态的主要问题:

  • 组件树过于复杂,所有重要数据都必须通过 props 从上到下传递。这紧密耦合了组件,使代码及其维护变得复杂。

  • 性能问题,当应用程序不需要时,可能会不必要地重新渲染。

看到最后一张图,人们可能会想是否可以切断我们组件的连接,并将所有数据和逻辑提取到组件之外。这就是全局状态概念发挥作用的地方。

全局状态是一种数据管理方法,它允许状态在应用程序的不同层次和组件之间可访问和可修改。这种解决方案克服了本地状态的局限性,促进了组件之间的数据交换,并提高了大规模项目中的状态可管理性。

为了清楚地了解全局状态在我们例子中的样子,请看下面的图片:

图片

图 12.4:应用程序根组件和全局状态

在这个例子中,我们有一个位于组件和整个树之外的全局状态。只有那些实际上需要从状态中获取数据的组件可以直接访问它并订阅其变化。

通过实现全局状态,我们可以一次解决两个问题:

  • 简化了组件树和依赖关系,从而扩展并支持了应用程序。

  • 提高了应用程序的性能,因为现在,只有订阅了全局状态数据的组件在状态变化时才会重新渲染。

然而,重要的是要理解,局部状态仍然是一个非常强大的工具,不应该为了全局状态而放弃。我们只有在状态需要在应用组件的不同层级之间使用时才能获得优势。否则,如果我们开始将所有变量和状态转移到全局状态,我们只会使应用复杂化而不会获得任何好处。

既然我们知道全局状态仅仅是组织数据的一种方式,我们该如何管理全局状态呢?状态管理器 是一个帮助组织和管理应用状态的工具,尤其是在处理复杂交互和大量数据时。它为应用的所有状态提供了一个集中式存储库,并以有序和可预测的方式管理其更新。在实践中,状态管理器通常以 npm 包的形式表示,作为项目依赖项安装。然而,也可以使用 React 的 API 独立管理全局状态,而不使用任何库。我们将在稍后探讨这种方法。

React Context API 和 useReducer

要自己组织全局状态,你可以使用 React 生态系统中的现有工具,即 Context APIuseReducer。它们是一对强大的状态管理工具,尤其是在使用第三方状态管理器显得过多的情况下。这些工具非常适合在更紧凑的应用中创建和管理全局状态。

React Context API 的设计是为了在组件树中传递数据,而不需要在每个层级传递 props。这简化了深层嵌套组件中数据的访问,并减少了 prop 传递(通过多个层级传递 props),如图 12.4 所示。React Context API 对于像主题设置、语言偏好或用户信息这样的数据尤其有用。

这里是一个如何使用上下文来存储主题设置的示例:

const ThemeContext = createContext();
const ThemeProvider = ({ children }) => {
  const theme = 'dark';
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
};
const useTheme = () => useContext(ThemeContext);
export { ThemeProvider, useTheme }; 

在这个例子中,我们使用 createContext 函数创建了 ThemeContext。然后,我们创建了一个 ThemeProvider 组件,它应该包裹应用的根组件。这将允许使用 useTheme 钩子在任何嵌套组件的层级上访问,该钩子是用 useContext 钩子创建的:

const MyComponent = () => {
  const theme = useTheme();
  return (
    <div>
      <p>Current theme: {theme}</p>
    </div>
  );
}; 

在组件树的任何层级上,我们都可以使用 useTheme 钩子访问当前的主题。

接下来,让我们看看这对中的一员,即那个将帮助我们构建全局状态的特殊钩子。useReducer 是一个钩子,允许你使用还原器(reducer)来管理复杂的状态:还原器是接受当前状态和动作,然后返回新状态的函数。useReducer 对于需要复杂逻辑或多个子状态的状态管理来说非常理想。让我们考虑一个使用 useReducer 的简单计数器示例:

import React, { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </>
  );
} 

在这个例子中,实现了一个具有两个动作的还原器:增加减少 计数器。

Context API 和useReducer的组合为创建和管理应用程序的全局状态提供了一个强大的机制。这种方法对于小型应用程序来说很方便,因为现成的和更大的状态管理解决方案可能显得多余。然而,也值得注意,这种解决方案并没有完全解决性能问题,因为在useTheme示例中的主题更改或计数器示例中的计数器更改都会导致提供者,进而导致整个组件树重新渲染。这可以通过额外的逻辑和编码来避免。

因此,更复杂的应用程序需要更强大的工具。为此,有几个现成的和流行的解决方案用于处理状态,每个解决方案都有其独特的功能和适用于不同的用例。

Redux

这些工具中的第一个当然是Redux。它是管理复杂 JavaScript 应用程序状态最受欢迎的工具之一,尤其是在与 React 一起使用时。Redux 通过维护单个全局对象中的应用程序状态来提供可预测的状态管理,简化了更改跟踪和数据管理。

Redux 基于三个核心原则:单一事实来源(一个全局状态)、状态是只读的(不可变)和更改是通过纯函数(还原器)进行的。这些原则确保了有序和受控的数据流。

function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}
const store = createStore(counterReducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'DECREMENT' }); 

在这个例子中,应用程序的状态是从计数器示例实现的。我们有一个counterReducer,它是一个常规函数,它接受当前状态和要对其执行的操作。还原器始终返回一个新的状态。

在 Redux 世界中实现异步操作是一个复杂的问题,因为默认情况下它只提供了中间件,这是第三方解决方案使用的。其中一个解决方案是redux-thunk

redux-thunk是一个中间件,允许你调用返回函数而不是动作对象的动作创建函数。这提供了通过异步请求延迟动作分发或分发多个动作的能力。

function fetchUserData() {
  return (dispatch) => {
    dispatch({ type: 'LOADING_USER_DATA' });
    fetch('/api/user')
      .then((response) => response.json())
      .then((data) => dispatch({ type: 'FETCH_USER_DATA_SUCCESS', payload: data }))
      .catch((error) => dispatch({ type: 'FETCH_USER_DATA_ERROR', error }));
  };
}
const store = createStore(reducer, applyMiddleware(thunk));
store.dispatch(fetchUserData()); 

正如你在示例中所见,我们创建了一个函数fetchUserData,它不会立即改变状态。相反,它返回另一个带有dispatch参数的函数。这个dispatch可以根据需要多次使用来改变状态。

还有其他更强大但更复杂的异步操作解决方案。我们在这里不会讨论这些。

Redux 非常适合在应用程序中管理复杂的全局状态。它提供了强大的调试工具,例如时间旅行。由于数据与其处理之间的清晰分离,Redux 还简化了状态和逻辑的测试。

要将 Redux 与 React 集成,使用React-Redux库。它提供了Provider组件,以及useSelectoruseDispatch钩子,这些钩子允许轻松地将 Redux 存储连接到你的 React 应用程序。

function Counter() {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();
  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
} 

在上面的示例中,Counter组件通过useSelector订阅变化来与 Redux 状态交互。这种订阅更为细致,改变计数器不会导致整个应用的重新渲染,而只会导致调用此钩子的特定组件重新渲染。

然而,需要注意的是 Redux 的缺点。尽管它是最受欢迎的解决方案,但它存在一些重大问题,这些问题影响了我个人对这个解决方案的选择:

  • Redux 的语法较为繁琐。实现一个大型全局状态需要编写大量的样板代码,例如 reducers、actions、selectors 等。

  • 随着项目的增长,维护和扩展 Redux 状态复杂性的增加是不成比例的。

随着项目和全局状态的增长,应用性能显著下降。这发生是因为需要大量计算,即使只是将一个值的状态从false改为true

Redux 不支持开箱即用的异步操作,需要额外的解决方案,这进一步增加了项目理解和维护的复杂性。

将状态和业务逻辑分割成块以实现懒加载需要大量的工作。因此,应用的大小及其初始加载速度受到影响。

尽管存在这些缺点,许多公司和开发者仍然使用这个解决方案,因为它适合大多数业务任务,因此我认为了解这个工具并能够使用它非常重要。

MobX

管理全局状态的下一个流行解决方案是MobX库。这个库与 Redux 有显著的不同,在某些方面甚至可以说是相反的。

MobX 是一个状态管理库,它提供了对数据的反应性和灵活的交互。其主要思想是将应用状态尽可能简化并透明化,通过创建尽可能多次的小对象和类来实现,这些对象和类可以嵌套在一起。

技术上,这个库允许创建不仅是一个全局状态,还可以直接与某些应用功能相关联的多个小对象,这在处理大型应用时提供了显著的优势。要了解一个全局状态和 MobX 状态之间的区别,可以查看以下图表:

图片

图 12.5:MobX 状态

在 MobX 中,应用状态是通过observable方法管理的,该方法自动跟踪变化并通知相关的计算值和反应。这使得应用能够自动根据状态变化更新,简化数据流并增加灵活性。

class Store {
  @observable accessor count = 0;
  @computed get doubleCount() {
    return this.count * 2;
  }
  @action increment() {
    this.count += 1;
  }
  @action decrement() {
    this.count -= 1;
  }
}
const myStore = new Store(); 

在示例中,相同的计数器使用 MobX 实现。在一个类中,既有实际数据,也有计算数据,以及用于改变状态的操作。

谈及异步操作,MobX 在这方面没有任何问题,因为你可以在一个常规类中工作,并添加一个返回 Promise 的新方法。

class Store {
  @observable count = 0;
  @computed get doubleCount() {
    return this.count * 2;
  }
  @action increment() {
    this.count += 1;
  }
  @action decrement() {
    this.count -= 1;
  }
  @action async fetchCountFromServer() {
    const response = await fetch('/count');
    const data = await response.json();
    this.count = data.count;
  }
}
const myStore = new Store(); 

MobX 非常适合需要高性能和简单管理复杂数据依赖的应用程序。它提供了一种优雅直观的方式来处理复杂的状态,使开发者能够专注于业务逻辑而不是状态管理。

这个库的一个缺点是它在组织状态方面提供了相当大的自由度,这可能导致在不熟练的手中遇到困难和可扩展性问题。例如,MobX 允许直接操作对象数据,这可以触发组件更新,但这也可能导致大型项目中的意外状态变化和调试挑战。同样,这种自由度往往导致小的、干净的 MobX 类变得紧密耦合,使得测试和项目开发更具挑战性。

要将 MobX 与 React 集成,使用mobx-react库,它提供了observer函数。这允许 React 组件自动响应观察数据的变化。

import React from 'react';
import { observer } from 'mobx-react';
import myStore from './myStore';
const Counter = observer(() => {
  return (
    <div>
      <div>Count: {myStore.count}</div>
      <div>Double: {myStore.doubleCount}</div>
      <button onClick={() => myStore.increment()}>-</button>
      <button onClick={() => myStore.decrement()}>+</button>
    </div>
  );
}); 

在示例中,使用 MobX 实现了相同的计数器。正如你所见,我们不需要使用 hooks 来访问状态或使用 providers 将其存储在应用程序上下文中。我们只需从文件中导入变量并使用它。从Store类创建的myStore本身就是状态。在组件中使用对象的观察值非常简单,因为组件会立即订阅该值的所有变化,并且每次它变化时都会重新渲染。

只从示例中,你就可以看到 MobX 在管理状态方面的简单和方便。由于它只是一个对象,当需要时可以轻松地懒加载它,当数据不再需要时,可以清除应用程序的缓存和内存。我认为它是状态管理的一个强大工具,并强烈推荐在实际项目中尝试使用它。

摘要

在本章中,我们学习了全局状态及其管理方法。以有限局部状态为例,我们讨论了为什么在应用程序的不同层级需要共享数据时,拥有全局状态很重要。

我们通过使用 React Context API 的示例,确定了何时使用它以及何时更倾向于更强大的状态管理解决方案。接下来,我们探讨了两种这样的解决方案,即 Redux 和 MobX。

在下一章中,我们将讨论服务器端渲染及其对我们应用程序可能带来的好处。