React 核心概念(四)
原文:
zh.annas-archive.org/md5/751c075f5f8c25e4f99f8fc75bd4f4d2译者:飞龙
第十一章:处理复杂状态
学习目标
到本章结束时,你将能够做到以下几点:
-
管理跨组件或甚至应用级别的状态(而不仅仅是组件特定的状态)
-
在多个组件间分配数据
-
处理复杂的状态值和变化
简介
状态是您必须理解(并与之合作)以有效使用 React 的核心概念之一。基本上,每个 React 应用都会在许多组件间使用(许多)状态值来呈现动态、反应式的用户界面。
从包含变化的计数器或用户输入的值的简单状态值,到更复杂的状态值,如多个表单输入的组合或用户身份验证信息,状态无处不在。在 React 应用中,它通常借助useState()钩子来管理。
然而,一旦你开始构建更复杂的 React 应用(例如,在线商店、管理仪表板和类似网站),你可能会面临与状态相关的各种挑战。状态值可能在组件 A 中使用,但在组件 B 中更改,或者由多个可能因多种原因而变化的动态值组成(例如,在线商店中的购物车,它是由产品组合而成的,每个产品都有数量、价格,以及可能单独更改的其他属性)。
你可以使用useState()、props 以及本书迄今为止涵盖的其他概念来处理所有这些问题。但你会注意到,仅基于useState()的解决方案会变得复杂,难以理解和维护。这就是为什么 React 提供了更多工具——为这类问题创建的工具,本章将突出和讨论这些工具。
跨组件状态的问题
你甚至不需要构建一个高度复杂的 React 应用,就可能会遇到一个常见问题:跨越多个组件的状态。
例如,你可能正在构建一个新闻应用,用户可以标记某些文章。一个简单的用户界面可能看起来像这样:
图 11.1:一个示例用户界面
正如前图所示,文章列表在左侧,而已标记文章的摘要可以在右侧的侧边栏中找到。
一种常见的解决方案是将这个用户界面拆分成多个组件。具体来说,文章列表可能将作为一个独立的组件——就像书签摘要侧边栏一样。
然而,在这种情况下,这两个组件都需要访问相同的共享状态——即已标记文章的列表。文章列表组件需要访问权限以便添加(或删除)文章。书签摘要侧边栏组件也需要它,因为它需要显示已标记的文章。
这种类型的应用的组件树和状态使用可能看起来像这样:
图 11.2:两个兄弟组件共享相同的状态
在这个图中,你可以看到状态在这两个组件之间是共享的。你还可以看到这两个组件有一个共享的父组件(在这个例子中是 News 组件)。
由于状态被两个组件使用,你不会在任何一个组件中管理它。相反,它被提升,如第四章,与事件和状态一起工作(在提升状态部分)中所述。当提升状态时,状态值和指向操作状态值的函数的指针通过 props 传递给实际需要访问的组件。
这可行,并且是一个常见的模式。你可以(并且应该)继续使用它。但如果需要访问某些共享状态的组件深深嵌套在其他组件中怎么办?如果前一个例子中的应用组件树看起来像这样?
图 11.3:具有多层状态相关组件的组件树
在这个图中,你可以看到 BookmarkSummary 组件是一个深层嵌套的组件。在它和 News 组件(管理共享状态)之间,还有两个其他组件:InfoSidebar 组件和 BookmarkInformation 组件。在更复杂的 React 应用中,像这个例子中这样有多个组件嵌套层级是非常常见的。
当然,即使有这些额外的组件,状态值仍然可以通过 props 传递。你只需要在持有状态的组件和需要状态的组件之间添加 props。例如,你必须通过 props 将 bookmarkedArticles 状态值传递给 InfoSidebar 组件,这样该组件就可以将其转发给 BookmarkInformation:
import BookmarkInformation from
'../BookmarkSummary/BookmarkInformation.jsx';
import classes from './InfoSidebar.module.css';
function InfoSidebar({ bookmarkedArticles }) {
return (
<aside className={classes.sidebar}>
<BookmarkInformation bookmarkedArticles={bookmarkedArticles} />
</aside>
);
}
export default InfoSidebar;
同样的过程在 BookmarkInformation 组件内部重复。
注意
你可以在 GitHub 上找到完整的示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/01-cross-cmp-state。
这种模式被称为属性钻取。属性钻取意味着状态值通过 props 在多个组件之间传递。并且它通过根本不需要状态的组件传递——除了将其转发给子组件(如前一个例子中的 InfoSidebar 和 BookmarkInformation 组件所做的那样)。
作为一名开发者,你通常会想避免这种模式,因为属性钻取有几个弱点:
-
属于属性钻取的组件(例如
InfoSidebar或BookmarkInformation)实际上已经不能再重用了,因为任何想要使用它们的组件都必须为转发状态属性提供一个值。 -
属性钻取还会导致大量的开销代码需要编写(接受 props 和转发 props 的代码)。
-
重构组件变得更加困难,因为必须添加或删除状态 props。
因此,只有当所有涉及的组件仅用于整个 React 应用的特定部分,并且重用或重构它们的可能性很低时,才接受属性钻探。
由于在大多数情况下应避免使用开孔钻探,React 提供了一个替代方案:上下文 API。
使用上下文处理多组件状态
React 的上下文功能允许你创建一个可以轻松地在所需组件之间共享的值,而无需使用属性。
图 11.4:React 上下文附加到组件上,以便将其暴露给所有子组件——无需属性钻探
使用上下文 API 是一个多步骤的过程,其步骤在此处描述:
-
你必须创建一个应该共享的上下文值。
-
需要访问上下文对象的组件必须在父组件中提供上下文。
-
需要访问(读取或写入)的组件必须订阅上下文。
React 内部管理和自动分发上下文值(及其更改)到所有已订阅上下文的组件。
然而,在任何一个组件可以订阅之前,第一步是创建一个上下文对象。这是通过 React 的 createContext() 函数完成的:
import { createContext } from 'react';
createContext('Hello Context'); // a context with an initial string value
createContext({}); // a context with an initial (empty) object as a value
此函数接受一个初始值,该值应该被共享。它可以任何类型的值(例如,字符串或数字),但通常是一个对象。这是因为大多数共享值都是实际值和应该操作这些值的函数的组合。所有这些都被组合成一个单独的上下文对象。
当然,如果需要,初始上下文值也可以是一个空值(例如,null、undefined、空字符串等)。
createContext() 还返回一个值:一个上下文对象,应该存储在一个大写变量(或常量)中,因为它实际上是一个 React 组件(React 组件应以大写字母开头)。
这是如何调用 createContext() 函数来为本章前面讨论的示例创建上下文对象的方法:
import { createContext } from 'react';
const BookmarkContext = createContext({
bookmarkedArticles: []
});
export default BookmarkContext;
在这里,初始值是一个包含 bookmarkedArticles 属性的对象,该属性包含一个(空的)数组。你也可以只将数组作为初始值存储(即,createContext([])),但对象更好,因为在本章的后面部分还会添加更多内容。
这段代码通常放置在一个单独的上下文代码文件中(例如,bookmark-context.jsx),通常存储在名为 store 的文件夹中(因为上下文功能可以用作中央状态存储)或 context 文件夹。然而,这只是一种约定,并不是技术上必需的。你可以在你的 React 应用程序的任何地方放置这段代码。
如果文件只包含上述代码,它可能使用 .js 作为文件扩展名,因为它不包含任何 JSX 代码。在本章的后面部分,这将会改变——因此你现在可以使用 .jsx 作为扩展名。
当然,这个初始值不是状态的替代品;它是一个永远不会改变的静态值。但这只是与上下文相关的三个步骤中的第一个。下一步是提供上下文。
提供和管理上下文值
为了在其他组件中使用上下文值,你必须首先提供该值。这是通过使用createContext()返回的值来完成的。
当使用 React 19 或更高版本时,该函数返回一个 React 组件,该组件应包裹所有需要访问上下文值的其他组件。
当使用较旧的 React 版本(即 React 18 或更早版本)时,createContext()返回的值是一个包含嵌套Provider属性的对象。该属性然后包含一个 React 组件,该组件应包裹所有需要访问上下文值的其他组件。
因此,无论是哪种方式,关键都是用上下文提供者组件包裹组件。
在前面的示例中,使用 React 19 或更高版本时,createContext()返回的BookmarkContext组件可以在News组件中使用,将其包裹在Articles和InfoSidebar组件周围:
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
return (
<BookmarkContext>
<Articles />
<InfoSidebar />
</BookmarkContext>
);
}
或者,如果使用 React 18 或更低版本:
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
return (
<BookmarkContext.Provider>
<Articles />
<InfoSidebar />
</BookmarkContext.Provider>
);
}
然而,这段代码无法正常工作,因为缺少了一个重要的事项:组件期望一个value属性,该属性应包含要分发给感兴趣组件的当前上下文值。虽然你提供了初始上下文值(可能为空),但你还需要通知 React 关于当前上下文值的信息,因为上下文值经常发生变化(毕竟它们经常被用作跨组件状态的替代品)。
因此,当使用 React 19 或更高版本时,代码可以修改如下所示:
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
const bookmarkCtxValue = {
bookmarkedArticles: []
}; // for now, it's the same value as used before, for the initial context
return (
<BookmarkContext value={bookmarkCtxValue}>
<Articles />
<InfoSidebar />
</BookmarkContext>
);
}
或者,如果使用 React 18 或更低版本:
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
const bookmarkCtxValue = {
bookmarkedArticles: []
}; // for now, it's the same value as used before, for the initial context
return (
<BookmarkContext.Provider value={bookmarkCtxValue}>
<Articles />
<InfoSidebar />
</BookmarkContext.Provider>
);
}
使用此代码,一个包含已标记文章列表的对象被分发给感兴趣的子组件。
列表仍然是静态的。但你可以使用你已知的工具来更改这一点:useState()钩子。在News组件内部,你可以使用useState()钩子来管理已标记文章的列表,如下所示:
import { useState } from 'react';
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
const [savedArticles, setSavedArticles] = useState([]);
const bookmarkCtxValue = {
bookmarkedArticles: savedArticles // using the state as a value
};
return (
<BookmarkContext value={bookmarkCtxValue}>
<Articles />
<InfoSidebar />
</BookmarkContext>
);
}
通过这个更改,上下文从静态变为动态。每当savedArticles状态发生变化时,上下文值也会发生变化。
因此,这就是提供上下文时缺失的部分。如果上下文应该是动态的(并且可以从某个嵌套子组件内部更改),上下文值也应包括指向触发状态更新的函数的指针。
对于前面的示例,代码因此调整为如下所示:
import { useState } from 'react';
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
const [savedArticles, setSavedArticles] = useState([]);
function addArticle(article) {
setSavedArticles(
(prevSavedArticles) => [...prevSavedArticles, article]
);
}
function removeArticle(articleId) {
setSavedArticles(
(prevSavedArticles) => prevSavedArticles
.filter(
(article) => article.id !== articleId
)
);
}
const bookmarkCtxValue = {
bookmarkedArticles: savedArticles,
bookmarkArticle: addArticle,
unbookmarkArticle: removeArticle
};
return (
<BookmarkContext value={bookmarkCtxValue}>
<Articles />
<InfoSidebar />
</BookmarkContext>
);
}
以下是代码片段中更改的两个重要事项:
-
添加了两个新函数:
addArticle和removeArticle。 -
将指向这些函数的属性添加到了
bookmarkCtxValue:bookmarkArticle和unbookmarkArticle方法。
addArticle 函数将一篇新的文章(应该被书签)添加到 savedArticles 状态中。由于新的状态值依赖于前一个状态值(已书签的文章被添加到已书签文章的列表中),因此使用了更新状态值的函数形式。
同样,removeArticle 函数通过过滤现有列表,保留所有除具有匹配 id 值之外的项目,从而从 savedArticles 列表中删除一篇文章。
如果 News 组件没有使用新的上下文功能,它将是一个使用状态的组件,就像你在本书中多次看到的。但现在,通过使用 React 的上下文 API,这些现有功能与一个新特性(上下文)结合,创建了一个动态的、可分发的值。
嵌套在 Articles 或 InfoSidebar 组件(或其子组件)中的任何组件都将能够访问这个动态上下文值,以及上下文对象中的 bookmarkArticle 和 unbookmarkArticle 方法,而无需任何属性传递。
注意
你不必创建动态上下文值。你也可以将静态值分发给嵌套组件。这种情况是可能的,但很少见,因为大多数 React 应用通常需要可以在组件间变化的动态状态值。
在嵌套组件中使用 Context
上下文创建并提供了之后,它就准备好被需要访问或更改上下文值的组件使用了。
为了使上下文值对上下文组件内部的组件(例如,前一个示例中的 BookmarkContext)可用,React 提供了一个 use() 钩子,它可以被使用。
然而,这个钩子仅在 React 19 或更高版本中可用。在使用较旧 React 版本的项目中,你将使用 useContext() 钩子来访问某些上下文值。该钩子也在 React 19 中仍然得到支持,因此你可以使用这两个钩子中的任何一个来获取上下文值。
use() 钩子比 useContext() 钩子更灵活一些,因为与其他任何钩子不同,它实际上也可以在 if 语句或循环内部使用。此外,钩子不仅可以用来获取上下文值——因此你将在 第十七章 中再次看到 use(),即 理解 React Suspense 与 use() 钩子。
如前所述,当使用 React 19 时,如果你试图获取上下文值,可以使用 use() 和 useContext()。use() 和 useContext() 都需要一个参数:通过 createContext() 创建的上下文对象,即该函数返回的值。因此,你将得到传递给上下文提供组件的值(即为其 value 属性设置的值)。当使用 React 19 或更高版本时,由于 use() 更灵活,并且输入更少,你可以忽略 useContext() 并使用 use() 钩子来访问上下文值。
对于前面的例子,上下文值可以在BookmarkSummary组件中使用,如下所示:
import { use } from 'react'; // or import useContext for React < 19
import BookmarkContext from '../../store/bookmark-context.jsx';
import classes from './BookmarkSummary.module.css';
function BookmarkSummary() {
const bookmarkCtx = use(BookmarkContext);
// React < 19: const bookmarkCtx = useContext(BookmarkContext);
const numberOfArticles = bookmarkCtx.bookmarkedArticles.length;
return (
<>
<p className={classes.summary}>
{numberOfArticles} articles bookmarked
</p>
<ul className={classes.list}>
{bookmarkCtx.bookmarkedArticles.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
</>
);
}
export default BookmarkSummary;
在这个代码中,use()接收从store/bookmark-context.jsx文件导入的BookmarkContext值。然后它返回上下文中存储的值,即在前面的代码示例中找到的bookmarkCtxValue。正如你可以在那个片段中看到的那样,bookmarkCtxValue是一个具有三个属性的对象:bookmarkedArticles、bookmarkArticle(一个方法)和unbookmarkArticle(也是一个方法)。
这个返回的对象被存储在bookmarkCtx常量中。每当上下文值改变(因为News组件中的setSavedArticles状态更新函数被执行),React 将再次执行这个BookmarkSummary组件,因此bookmarkCtx将持有最新的状态值。
最后,在BookmarkSummary组件中,通过bookmarkCtx对象访问bookmarkedArticles属性。然后使用这篇文章列表来计算已标记文章的数量,输出简短摘要,并在屏幕上显示列表。
类似地,BookmarkContext可以通过use()在Articles组件中使用:
import { use } from 'react';
// other imports
function Articles() {
const bookmarkCtx = use(BookmarkContext);
// or: const bookmarkCtx = useContext(BookmarkContext)
return (
<ul>
{dummyArticles.map((article) => {
const isBookmarked = bookmarkCtx.bookmarkedArticles.some(
(bArticle) => bArticle.id === article.id
);
// default icon: Empty bookmark icon, because not bookmarked
let buttonIcon = <FaRegBookmark />;
if (isBookmarked) {
buttonIcon = <FaBookmark />;
}
return (
<li key={article.id}>
<h2>{article.title}</h2>
<p>{article.description}</p>
<button>{buttonIcon}</button>
</li>
);
})}
</ul>
);
}
在这个组件中,上下文被用来确定给定的文章是否当前被书签标记(为了改变按钮的图标和功能,需要这个信息)。
这就是上下文值(无论是静态的还是动态的)如何在组件中被读取。当然,它们也可以像下一节讨论的那样被更改。
从嵌套组件更改上下文
React 的上下文特性通常用于在多个组件之间共享数据,而不使用 props。因此,一些组件必须操纵这些数据也是相当常见的。例如,购物车的上下文值必须可以从显示产品项的组件内部进行调整(因为这些可能有一个“添加到购物车”按钮)。
然而,要从嵌套组件内部更改上下文值,你不能简单地覆盖存储的上下文值。以下代码不会按预期工作:
const bookmarkCtx = use(BookmarkContext);
// Note: This does NOT work
bookmarkCtx.bookmarkedArticles = []; // setting the articles to an empty array
这段代码不起作用。正如你不应该尝试通过简单地分配一个新值来更新状态一样,你也不能通过分配新值来更新上下文值。这就是为什么在提供和管理上下文值部分添加了两个方法(bookmarkArticle和unbookmarkArticle)到上下文值中。这两个方法指向触发状态更新的函数(通过useState()提供的状态更新函数)。
因此,在可以通过按钮点击进行标记或取消标记文章的Articles组件中,应该调用这些方法:
// This code is part of the Article component function
// default action => bookmark article, because not bookmarked yet
let buttonAction = () => bookmarkCtx.bookmarkArticle(article);
// default button icon: Empty bookmark icon, because not bookmarked
let buttonIcon = <FaRegBookmark />;
if (isBookmarked) {
buttonAction = () => bookmarkCtx.unbookmarkArticle(article.id);
buttonIcon = <FaBookmark />;
}
bookmarkArticle和unbookmarkArticle方法在存储在buttonAction变量中的匿名函数内部被调用。这个变量被分配给<button>的onClick属性(参见前面的代码片段)。
使用此代码,可以成功更改上下文值。多亏了上一节(在嵌套组件中使用上下文)中采取的步骤,每当上下文值更新时,它也会自动反映在用户界面上。
注意
完成的示例代码可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/02-cross-cmp-state-with-context。
高效使用上下文 API
能够创建、提供、访问和更改上下文很重要——最终,正是这些事情允许您在应用程序中使用 React 的上下文 API。但随着您的应用程序(以及因此可能也是您的上下文值)变得更加复杂,设置和管理您的上下文高效也很重要,例如,通过获得适当的 IDE 支持。
获得更好的代码自动完成
在 使用上下文处理多组件状态 这一部分,通过createContext()创建了一个上下文对象。该函数接收一个初始上下文值——一个包含bookmarkedArticles属性的对象,在前面的例子中。
在这个例子中,初始上下文值并不重要。它不常使用,因为无论怎样,它都会在News组件内部被新的值覆盖。然而,根据您使用的集成开发环境(IDE),在定义具有与最终上下文值相同形状和结构的初始上下文值时,您可以得到更好的代码自动完成。
因此,由于在 提供和管理上下文值 这一部分中向上下文值添加了两种方法,因此这些方法也应添加到store/bookmark-context.jsx中的初始上下文值中:
const BookmarkContext = createContext({
bookmarkedArticles: [],
bookmarkArticle: () => {},
unbookmarkArticle: () => {}
});
export default BookmarkContext;
这两种方法被添加为空函数,因为实际的逻辑是在News组件中设置的。这些方法仅添加到这个初始上下文值中,以提供更好的 IDE 自动完成。因此,这一步是可选的。
上下文或提升状态?
到目前为止,您现在有两个管理跨组件状态的工具:
-
您可以提升状态,正如本书前面所描述的(在第四章 与事件和状态一起工作 的 提升状态 部分)。
-
或者,您可以使用本章中解释的 React 的上下文 API。
在每种情况下,您应该使用哪种方法?
最终,如何管理这取决于您,但有一些简单的规则您可以遵循:
-
如果您只需要在组件嵌套的一到两层之间共享状态,请提升状态。
-
如果您有长链组件(即组件的深层嵌套)并且有共享状态,请使用上下文 API。一旦您开始使用大量的属性传递,就是考虑 React 的上下文功能的时候了。
-
如果你有一个相对扁平的组件树但想要重用组件(即,你不想使用 props 将状态传递给组件),也可以使用上下文 API。
将上下文逻辑外包到单独的组件中
通过之前解释的步骤,你已经拥有了通过上下文管理跨组件状态所需的一切。
但你可以考虑一种模式来管理你的动态上下文值和状态:创建一个专门的组件来提供(和管理)上下文值。
在前面的例子中,News 组件被用来提供上下文并管理其(动态的、基于状态的)值。虽然这样做是可行的,但如果组件需要处理上下文管理,它们可能会变得不必要地复杂。因此,创建一个专门的组件来处理这一点可以导致代码更容易理解和维护。
对于前面的例子,这意味着在 store/bookmark-context.jsx 文件中,你可以创建一个看起来像这样的 BookmarkContextProvider 组件:
export function BookmarkContextProvider({ children }) {
const [savedArticles, setSavedArticles] = useState([]);
function addArticle(article) {
setSavedArticles(
(prevSavedArticles) => [...prevSavedArticles, article]
);
}
function removeArticle(articleId) {
setSavedArticles((prevSavedArticles) =>
prevSavedArticles.filter((article) => article.id !== articleId)
);
}
const bookmarkCtxValue = {
bookmarkedArticles: savedArticles,
bookmarkArticle: addArticle,
unbookmarkArticle: removeArticle,
};
return (
<BookmarkContext value={bookmarkCtxValue}>
{children}
</BookmarkContext>
);
}
此组件包含与通过状态管理一系列书签文章相关的所有逻辑。它创建了与之前相同的上下文值(包含文章列表以及更新该列表的两个方法)。
BookmarkContextProvider 组件做了一件事。它使用特殊的 children prop(在第三章的 Components and Props 部分的 特殊的children prop 小节中介绍)来包裹在 BookmarkContextProvider 组件标签之间传递的任何内容,用 BookmarkContext 包裹。
这允许在 News 组件中使用 BookmarkContextProvider 组件,如下所示:
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import { BookmarkContextProvider } from '../../store/bookmark-context.jsx';
function News() {
return (
<BookmarkContextProvider>
<Articles />
<InfoSidebar />
</BookmarkContextProvider>
);
}
export default News;
News 组件现在不再管理整个上下文值,而是简单地导入 BookmarkContextProvider 组件,并将该组件包裹在 Articles 和 InfoSidebar 组件周围。因此,News 组件更加精简。
注意
这种模式完全是可选的。它既不是官方的最佳实践,也不会带来任何性能上的好处。它仅仅是一个可以帮助你保持组件函数简洁和精炼的模式。
值得注意的是,还有一个相关的模式用于消费上下文。然而,该模式依赖于构建自定义的 React Hook——这个概念将在下一章中介绍。因此,提到的上下文消费模式也将放在下一章中介绍。
结合多个上下文
尤其是在更大、功能更丰富的 React 应用程序中,你可能会需要处理多个可能彼此无关的上下文值。例如,一个在线商店可能会使用一个上下文来管理购物车,另一个上下文来管理用户认证状态,还有另一个上下文值来跟踪页面分析。
React 完全支持这种用例。你可以根据需要创建、管理、提供和使用尽可能多的上下文值。你可以在单个上下文中管理多个(相关或不相关的)值,或者使用多个上下文。你可以在同一个组件或不同组件中提供多个上下文。这完全取决于你和你应用程序的需求。
你也可以在同一个组件中使用多个上下文(这意味着你可以多次调用use()或useContext(),并使用不同的上下文值)。
useState()的局限性
到目前为止,本章已经探讨了跨组件状态复杂性。但在某些情况下,一些状态仅在单个组件内部使用时,状态管理也可能具有挑战性。
在大多数情况下(当然,目前它也是唯一被介绍的工具),useState()是状态管理的一个很好的工具。因此,useState()应该是你管理状态的首选。但是,如果你需要根据另一个状态变量的值推导出新的状态值,例如在这个例子中,useState()可能会达到其极限:
setIsLoading(fetchedPosts ? false : true);
这个简短的片段是从一个组件中提取的,该组件向服务器发送 HTTP 请求以获取一些博客文章:
function App() {
const [fetchedPosts, setFetchedPosts] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
const fetchPosts = useCallback(async function fetchPosts() {
**setIsLoading****(fetchedPosts ?** **false** **:** **true****);**
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
setIsLoading(false);
setError(null);
setFetchedPosts(posts);
} catch (error) {
setIsLoading(false);
setError(error.message);
setFetchedPosts(null);
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
return (
<>
{isLoading && <p>Loading...</p>}
{error && <p>{error}</p>}
{fetchedPosts && <BlogPosts posts={fetchedPosts} />}
</>
);
}
注意
你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/04-complex-usestate。
在初始化请求时,只有在没有获取到数据之前,isLoading状态值(负责在屏幕上显示加载指示器)才应该设置为true。如果之前已经获取了数据(即fetchedPosts不是null),那么应该仍然在屏幕上显示这些数据,而不是某个加载指示器。
初看之下,这段代码可能看似没有问题。但实际上,它违反了与useState()相关的一个重要规则:你不应该引用当前状态来设置新的状态值。如果你需要这样做,你应该使用状态更新函数的函数形式(参见第四章“基于前一个状态正确更新状态”部分)。
然而,在前面的例子中,这个解决方案不起作用。如果你切换到函数状态更新形式,你只能访问你试图更新的状态的当前值。你不能(安全地)访问其他状态的当前值。在前面的例子中,另一个状态(fetchedPosts而不是isLoading)被引用。因此,你必须违反上述规则。
这种违规也有实际后果(在这个例子中)。以下代码片段是fetchPosts函数的一部分,该函数被useCallback()包装:
const fetchPosts = useCallback(async function fetchPosts() {
setIsLoading(fetchedPosts ? false : true);
setError(null);
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
setIsLoading(false);
setError(null);
setFetchedPosts(posts);
} catch (error) {
setIsLoading(false);
setError(error.message);
setFetchedPosts(null);
}
}, []);
这个函数发送 HTTP 请求,并根据请求的状态更改多个状态值。
useCallback() 用于避免与 useEffect() 相关的无限循环(参见 第八章 ,处理副作用 ,了解更多关于 useEffect() ,无限循环和 useCallback() 作为补救措施的信息)。通常,fetchedPosts 应该作为依赖项添加到传递给 useCallback() 函数的第二个参数 dependencies 数组中。然而,在这个例子中,这不可能完成,因为 fetchedPosts 在 useCallback() 包装的函数内部被更改,因此状态值不仅是一个依赖项,而且还被积极更改。这导致无限循环。
因此,在终端中显示了一个警告,并且没有达到在数据之前获取数据时不显示加载指示器的预期行为:
图 11.5:在终端中输出关于缺少依赖项的警告
如果你有多个人相关联的状态值相互依赖,那么像刚才描述的问题很常见。
一个可能的解决方案是将多个单独的状态片段(fetchedPosts,isLoading 和 error)移动到一个单一的组合状态值(即一个对象)。这将确保所有状态值都组合在一起,并且因此在使用功能状态更新形式时可以安全地访问。状态更新代码可以如下所示:
setHttpState(prevState => ({
fetchedPosts: prevState.fetchedPosts,
isLoading: prevState.fetchedPosts ? false : true,
error: null
}));
这个解决方案将有效。然而,通过 useState() 管理的越来越复杂(且嵌套)的状态对象通常是不希望的,因为它可以使状态管理变得有点困难,并膨胀你的组件代码。
因此,React 提供了 useState() 的一个替代方案:useReducer() Hook。
使用 useReducer() 管理状态
就像 useState() 一样,useReducer() 也是一个 React Hook。并且就像 useState() 一样,它是一个可以触发组件函数重新评估的 Hook。但是,当然,它的工作方式略有不同;否则,它将是一个多余的 Hook。
useReducer() 是一个 Hook,旨在用于管理复杂的状态对象。你很少(可能永远)会使用它来管理简单的字符串或数字值。
这个 Hook 采取两个主要参数:
-
Reducer 函数
-
一个初始状态值
这引发了一个重要问题:什么是 reducer 函数?
理解 Reducer 函数
在 useReducer() 的上下文中,reducer 函数是一个接收两个参数的函数:
-
当前状态值
-
已分发的动作
除了接收参数外,reducer 函数还必须返回一个值:新的状态。它被称为 reducer 函数,因为它将旧状态(与一个动作结合)减少到新状态。
为了使这一切更容易理解并推理,以下代码片段展示了如何与这样的 reducer 函数结合使用 useReducer():
const initialHttpState = {
data: null,
isLoading: false,
error: null,
};
function httpReducer(state, action) {
if (action.type === 'FETCH_START') {
return {
...state, // copying the existing state
isLoading: state.data ? false : true,
error: null,
};
}
if (action.type === 'FETCH_ERROR') {
return {
data: null,
isLoading: false,
error: action.payload,
};
}
if (action.type === 'FETCH_SUCCESS') {
return {
data: action.payload,
isLoading: false,
error: null,
};
}
return initialHttpState; // default value for unknown actions
}
function App() {
useReducer(httpReducer, initialHttpState);
// more component code, not relevant for this snippet / explanation
}
在这个片段的底部,你可以看到useReducer()是在App组件函数内部被调用的。像所有 React Hooks 一样,它必须在组件函数或其他 Hooks 内部调用。你还可以看到之前提到的两个参数(reducer 函数和初始状态值)被传递给useReducer()。
httpReducer是 reducer 函数。该函数接受两个参数(state,即旧状态,和action,即发送的动作)并为不同的动作类型返回不同的状态对象。
这个 reducer 函数负责处理所有可能的状态更新。因此,整个状态更新逻辑都是从组件中外包出去的(注意,httpReducer是在组件函数外部定义的)。
但组件函数当然必须能够触发定义的状态更新。这就是动作变得重要的地方。
注意
在这个例子中,reducer 函数是在组件函数外部创建的。你也可以在组件函数内部创建它,但这样做并不推荐。如果你在组件函数内部创建 reducer 函数,它将每次组件函数执行时都会被重新创建。这会不必要地影响性能,因为 reducer 函数不需要访问任何组件函数的值(状态或属性)。
发送动作
之前显示的代码是不完整的。在组件函数中调用useReducer()时,它不仅仅接受两个参数。相反,这个 Hook 还返回一个值——一个恰好有两个元素的数组(就像useState()一样,尽管元素是不同的)。
因此,useReducer() 应该这样使用(在App组件中):
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
在这个片段中,使用数组解构将两个元素(总是恰好两个!)存储在两个不同的常量中:httpState和dispatch。
返回数组中的第一个元素(在这个例子中是httpState)是 reducer 函数返回的状态值。每当 reducer 函数再次执行时,它就会被更新(这意味着 React 会调用组件函数)。在这个例子中,元素被称作httpState,因为它包含与 HTTP 请求相关的状态值。但说到底,你如何命名这个元素取决于你。
第二个元素(在这个例子中是dispatch)是一个函数。这是一个可以调用来触发状态更新(即再次执行 reducer 函数)的函数。当执行时,dispatch函数必须接收一个参数——即将在 reducer 函数内部(通过 reducer 函数的第二个参数)可用的动作值。以下是如何在组件中使用dispatch的示例:
dispatch({ type: 'FETCH_START' });
在这个例子中,元素被称作dispatch,因为它是一个用于向 reducer 函数发送动作的函数。就像之前一样,名称由你决定,但dispatch是一个常用的名称。
那个动作值的形状和结构完全取决于你,但通常设置为一个包含type属性的对象。type属性在 reducer 函数中用于执行不同类型的动作。因此,type充当动作标识符。你可以在httpReducer函数内部看到type属性的使用:
function httpReducer(state, action) {
if (action.type === 'FETCH_START') {
return {
...state, // copying the existing state
isLoading: state.data ? false : true,
error: null,
};
}
if (action.type === 'FETCH_ERROR') {
return {
data: null,
isLoading: false,
error: action.payload,
};
}
if (action.type === 'FETCH_SUCCESS') {
return {
data: action.payload,
isLoading: false,
error: null,
};
}
return initialHttpState; // default value for unknown actions
}
你可以根据需要向动作对象添加任意多的属性。在前面的例子中,一些状态更新通过访问action.payload从动作对象中提取一些额外数据。在一个组件内部,你会像这样传递数据与动作:
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
再次强调,属性名(payload)由你决定,但将额外数据与动作一起传递允许你执行依赖于组件函数生成数据的状态更新。
这是整个App组件函数的完整、最终代码:
// code for httpReducer etc. did not change
function App() {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
// Using useCallback() to prevent an infinite loop in useEffect()
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
return (
<>
<header>
<h1>Complex State Blog</h1>
<button onClick={fetchPosts}>Load Posts</button>
</header>
{httpState.isLoading && <p>Loading...</p>}
{httpState.error && <p>{httpState.error}</p>}
{httpState.data && <BlogPosts posts={httpState.data} />}
</>
);
}
在这个代码片段中,你可以看到如何派发不同的动作(具有不同的type和有时payload属性)。你还可以看到httpState值被用来根据状态显示不同的用户界面元素(例如,如果httpState.isLoading为true,则显示<p>Loading…</p>)。
摘要和关键要点
-
状态管理可能有其挑战——尤其是在处理跨组件(或全局)状态或复杂状态值时。
-
跨组件状态可以通过提升状态或使用 React 的上下文 API 来管理。
-
当你进行大量的属性钻探(通过 props 在多个组件层之间传递状态值)时,上下文 API 通常是首选的。
-
当使用上下文 API 时,你使用
createContext()来创建一个新的上下文对象。 -
创建的上下文对象是一个必须包裹在应该访问上下文的组件树部分的组件。
-
当使用 React 18 或更早版本时,上下文对象本身不是一个组件,而是一个提供嵌套
Provider属性的对象,该属性是一个组件。 -
组件可以通过
use()(在 React 19 或更高版本中)或useContext()钩子访问上下文值。 -
对于管理复杂状态值,
useReducer()可以是一个比useState()更好的替代方案。 -
useReducer()利用一个将当前状态和已派发的动作转换为新的状态值的 reducer 函数。 -
useReducer()返回一个包含恰好两个元素的数组:状态值和一个派发函数,用于派发动作。
接下来是什么?
能够有效地管理简单和复杂的状态值非常重要。本章介绍了两个帮助完成这项任务的关键工具。
通过上下文 API 的use()、useContext()和useReducer()钩子,引入了三个新的 React 钩子。结合本书迄今为止涵盖的所有其他钩子,这些标志着作为 React 开发者日常工作中所需的最后一个 React 钩子。
尽管作为 React 开发者,你不仅限于内置的 Hooks,你还可以构建自己的 Hooks。下一章将最终探讨这是如何工作的,以及为什么你可能想要首先构建自定义 Hooks。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的理解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/11-complex-state/exercises/questions-answers.md中可以找到的示例进行比较:
-
使用 React 的上下文 API 可以解决哪些问题?
-
使用上下文 API 时,必须采取哪三个主要步骤?
-
在什么情况下,
useReducer()可能比useState()更可取? -
当使用
useReducer()时,动作的作用是什么?
应用所学知识
将你对上下文 API 和useReducer() Hook 的知识应用于一些实际问题。
活动内容 11.1:将应用迁移到上下文 API
在这个活动中,你的任务是改进现有的 React 项目。目前,该应用没有使用上下文 API 构建,因此跨组件状态是通过提升状态来管理的。在这个项目中,某些组件出现了 prop drilling(属性钻取)的问题。因此,目标是调整应用,使其使用上下文 API 进行跨组件状态管理。
注意
您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-1-start找到此活动的起始代码。下载此代码时,您将始终下载整个存储库。请确保导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。
提供的项目还使用了之前章节中介绍的一些功能。花时间分析它并理解提供的代码。这是很好的实践,让你能够看到许多关键概念的实际应用。
下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,您可以通过npm run dev启动开发服务器。因此,访问localhost:5173时,您应该看到以下用户界面:
图 11.6:运行中的起始项目
要完成此活动,步骤如下:
-
为购物车项目创建一个新的上下文。
-
为上下文创建一个
Provider组件,并在那里处理所有与上下文相关的状态变化。 -
提供上下文(借助
Provider组件),并确保所有需要访问上下文的组件都能访问到。 -
移除旧逻辑(状态提升的地方)。
-
在所有需要访问上下文的组件中使用上下文。
完成活动后,用户界面应与图 11.6中所示的用户界面相同。确保用户界面与实现 React 的上下文功能之前完全一样。
注意
所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-1找到。
活动 11.2:用 useReducer()替换 useState()
在这个活动中,你的任务是替换Form组件中的useState()钩子为useReducer()。只使用一个单独的 reducer 函数(因此只调用一次useReducer()),并将所有相关的状态值合并到一个状态对象中。
注意
你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-2-start找到这个活动的起始代码。在下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这个例子中是activities/practice-2-start),以使用正确的代码快照。
提供的项目还使用了前面章节中介绍的一些功能。花时间分析它并理解提供的代码。这是很好的练习,让你能够看到许多关键概念的实际应用。
下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,你可以通过npm run dev启动开发服务器。结果,当你访问localhost:5173时,你应该看到以下用户界面:
图 11.7:运行中的起始项目
在提供的起始项目中,用户在点击提交按钮时会得到三个结果之一:
-
如果一个或两个输入字段没有接收任何输入,错误消息会告诉用户填写表单。
-
如果用户在两个输入字段中输入了值,但至少有一个输入包含无效值,则会显示不同的错误消息。
-
如果用户在两个输入字段中输入了有效的值,则输入的值将在开发者工具的 JavaScript 控制台中打印出来。
要完成这个活动,解决方案步骤如下:
-
删除(或注释掉)
Form组件中使用useState()钩子进行状态管理的现有逻辑。 -
添加一个处理两个动作(电子邮件更改和密码更改)的 reducer 函数,并返回一个默认值。
-
根据派发的动作类型(如果需要,还有负载)更新状态对象。
-
使用
useReducer()钩子与 reducer 函数。 -
在
Form组件中派发适当的动作(带有适当的数据)。 -
在需要的地方使用状态值。
完成活动后,用户界面应与图 11.7中显示的相同。确保用户界面与您实现 React 的上下文功能之前完全一致。
注意
所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-2找到。
第十二章:构建 Custom React 钩子
学习目标
到本章结束时,您将能够做到以下几件事情:
-
构建您自己的 React 钩子
-
在您的组件中使用自定义和默认 React 钩子
简介
在本书中,一个关键的 React 功能被反复以许多不同的变体引用。这个功能就是 React 钩子。
钩子几乎提供了 React 所有的核心功能和概念——从单个组件中的状态管理到在多个组件中访问跨组件状态(上下文)。它们允许您通过 refs 访问 JSX 元素,并允许您在组件函数内部处理副作用。
没有钩子,现代 React 就无法工作,构建功能丰富的应用程序将是不可能的。
到目前为止,只介绍了内置钩子并使用了它们。然而,您也可以构建自己的自定义钩子——或者您可以使用其他开发者构建的自定义钩子(例如,通过使用第三方库)。在本章中,您将了解为什么您可能想要这样做以及它是如何工作的。
介绍 Custom 钩子
在开始构建自定义钩子之前,了解自定义钩子究竟是什么非常重要。
在 React 应用中,自定义钩子是满足以下条件的常规 JavaScript 函数:
-
函数名称以
use开头(就像所有内置钩子一样以use开头:useState()、useReducer()等)。 -
函数调用另一个 React 钩子(一个内置的或自定义的——无关紧要)。
-
此函数不仅返回 JSX 代码(否则,它本质上就是一个 React 组件),尽管它可以返回一些 JSX 代码——只要这不是返回的唯一值。
如果一个函数满足以下三个条件,它可以(并且应该)被称为自定义(React)钩子。因此,自定义钩子实际上只是具有特殊名称(以 use 开头)的正常函数,这些函数调用其他(自定义或内置)钩子,并且不(仅)返回 JSX 代码。如果您在其他地方(例如,在函数外部或在常规的非钩子函数中)尝试调用(自定义或内置)钩子,您可能会收到警告(取决于您的项目设置;见下文)。
例如,以下函数使用了 useEffect() 钩子,但其名称不以 use 开头。因此,它不符合官方的命名建议:
function sendAnalyticsEvent(event) {
useEffect(() => {
fetch('https://my-analytics-backend.com/events', {
method: 'POST',
body: JSON.stringify(event)
})
}, []);
}
在执行代码检查以查找规则违规的项目中,此代码将产生警告,因为此函数不符合自定义钩子的资格(由于其名称)。
图 12.1:React 会抱怨你在错误的位置调用钩子函数
正如警告所述,无论是自定义还是内置的钩子,都必须仅在组件函数内部调用。尽管警告消息没有明确提到,它们也可以在自定义钩子内部调用。
因此,如果将 sendAnalyticsEvent() 函数重命名为 useSendAnalyticsEvent() ,警告就会消失,因为现在该函数符合自定义钩子的资格。
尽管从技术上讲,这不是 React 本身强制执行的规定规则,但强烈建议遵循此命名约定。
能够构建自定义钩子是一个极其重要的功能,因为它意味着你可以构建包含状态逻辑的可重用非组件函数(通过useState()或useReducer()),在你的可重用自定义钩子函数中处理副作用(通过useEffect()),或使用任何其他 React 钩子。使用正常的非钩子函数,这些都不可能实现,因此你将无法将这些涉及 React 钩子的任何逻辑外包到这样的函数中。
以这种方式,自定义钩子补充了 React 组件的概念。虽然 React 组件是可重用的 UI 构建块(可能包含状态逻辑),但自定义钩子是可重用的逻辑片段,可以在你的组件函数中使用。因此,自定义钩子帮助你跨组件重用共享逻辑。例如,自定义钩子使你能够将发送 HTTP 请求和处理相关状态(加载、错误等)的逻辑外包出去。
为什么你会构建自定义钩子?
在上一章(第十一章,处理复杂状态)中,当介绍了useReducer()钩子时,提供了一个示例,其中钩子被用于发送 HTTP 请求。这里再次提供相关的最终代码:
const initialHttpState = {
data: null,
isLoading: false,
error: null,
};
function httpReducer(state, action) {
if (action.type === 'FETCH_START') {
return {
...state, // copying the existing state
isLoading: state.data ? false : true,
error: null,
};
}
if (action.type === 'FETCH_ERROR') {
return {
data: null,
isLoading: false,
error: action.payload,
};
}
if (action.type === 'FETCH_SUCCESS') {
return {
data: action.payload,
isLoading: false,
error: null,
};
}
return initialHttpState; // default value for unknown actions
}
function App() {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
return (
<>
<header>
<h1>Complex State Blog</h1>
<button onClick={fetchPosts}>Load Posts</button>
</header>
{httpState.isLoading && <p>Loading...</p>}
{httpState.error && <p>{httpState.error}</p>}
{httpState.data && <BlogPosts posts={httpState.data} />}
</>
);
};
在这个代码示例中,每当App组件首次渲染时,就会发送一个 HTTP 请求。HTTP 请求获取一个(虚拟的)帖子列表。在请求完成之前,向用户显示一个加载消息(<p>Loading…</p>)。如果有错误,则显示错误消息。
如您所见,必须编写相当多的代码来处理这个相对基本的使用场景。特别是在更大的 React 应用程序中,很可能多个组件需要发送 HTTP 请求。它们可能不需要发送到相同 URL(例如,https://jsonplaceholder.typicode.com/posts)的完全相同的请求,但肯定有可能不同的组件会从不同的 URL 获取不同的数据。
因此,几乎完全相同的代码必须在多个组件中反复编写。这不仅仅是发送 HTTP 请求的代码(即由useCallback()包装的函数)。相反,HTTP 相关的状态管理(在本例中通过useReducer()完成),以及通过useEffect()进行的请求初始化,必须在所有这些组件中重复。
正是自定义钩子(Custom Hooks)在这里拯救了局面。自定义钩子通过允许你构建可重用、可能具有状态的“逻辑片段”,这些片段可以在组件之间共享,帮助你避免这种重复。
第一个自定义钩子
在探索高级场景和解决之前提到的 HTTP 请求问题之前,这里有一个更基本的第一个自定义钩子的例子:
import { useState } from 'react';
function useCounter() {
const [counter, setCounter] = useState(0);
function increment() {
setCounter(oldCounter => oldCounter + 1);
};
function decrement() {
setCounter(oldCounter => oldCounter - 1);
};
return { counter, increment, decrement };
};
export default useCounter;
这段代码可以存储在 hooks/ 文件夹内的一个名为 use-counter.js 的文件中——尽管这两个名称完全由您决定。关于文件名或文件夹名(或一般而言,存储此代码的位置)没有规则。由于此文件不包含 JSX 代码,因此文件扩展名是 .js 而不是 .jsx。
如您所见,useCounter 是一个常规的 JavaScript 函数。函数名以 use 开头,因此这个函数符合自定义钩子的标准(这意味着在它内部使用其他钩子时,您不会收到任何警告信息)。
在 useCounter() 内部,通过 useState() 管理一个 counter 状态。状态通过两个嵌套函数(increment 和 decrement)进行更改,状态以及函数都由 useCounter 返回(在 JavaScript 对象中一起分组)。
注意
将 counter、increment 和 decrement 一起分组的语法使用了常规的 JavaScript 功能:简写属性名。
如果一个对象的属性名与分配给该属性的变量的名称完全匹配,您可以使用这种简短的表示法。
而不是编写 { counter: counter, increment: increment, decrement: decrement },您可以使用上面片段中显示的简写表示法 { counter, increment, decrement }。
这个自定义钩子可以存储在单独的文件中(例如,在 React 项目的 hooks 文件夹中,如 src/hooks/use-counter.js)。之后,它可以在任何 React 组件中使用,并且您可以在所需的任何数量的 React 组件中使用它。
例如,以下两个组件(Demo1 和 Demo2)可以这样使用这个 useCounter 钩子:
import useCounter from './hooks/use-counter.js';
function Demo1() {
const { counter, increment, decrement } = useCounter();
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
};
function Demo2() {
const { counter, increment, decrement } = useCounter();
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
};
function App() {
return (
<main>
<Demo1 />
<Demo2 />
</main>
);
};
export default App;
注意
您可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/01-first-hook 找到完整的示例代码。
Demo1 和 Demo2 组件都在它们的组件函数内部执行 useCounter()。useCounter() 函数被称为普通函数,因为它是一个常规的 JavaScript 函数。
由于 useCounter 钩子返回一个包含三个属性(counter、increment 和 decrement)的对象,Demo1 和 Demo2 使用对象解构将属性值存储在局部常量中。然后,这些值在 JSX 代码中用于输出 counter 值,并将两个 <button> 元素连接到 increment 和 decrement 函数。
按钮按了几次之后,最终的用户界面可能看起来像这样:
图 12.2:两个独立的计数器
在这个屏幕截图上,你还可以看到一个非常有趣且重要的自定义钩子行为。那就是,如果多个组件中使用了相同的具有状态的自定义钩子,每个组件都会得到自己的状态。counter状态是不共享的。Demo1组件通过useCounter()自定义钩子管理自己的counter状态,Demo2组件也是如此。
自定义钩子:一个灵活的功能
Demo1和Demo2的两个独立状态展示了自定义钩子的一个非常重要的特性:你使用它们来共享逻辑,而不是状态。如果你需要在组件之间共享状态,你将使用 React 上下文(参见上一章)。
当使用钩子时,每个组件都会使用该钩子的自己的“实例”(或“版本”)。逻辑始终相同,但钩子处理的任何状态或副作用都是基于每个组件的。
还有一点也值得注意,自定义钩子可以有状态,但不一定必须有状态。它们可以通过useState()或useReducer()来管理状态,但你也可以构建只处理副作用(没有任何状态管理)的自定义钩子。
在自定义钩子中,你隐式地必须做的一件事是:你必须使用其他 React 钩子(自定义或内置的)。这是因为如果你没有包含任何其他钩子,那么最初就没有必要构建自定义钩子。自定义钩子只是一个普通的 JavaScript 函数(以use开头命名),允许你使用其他钩子。如果你不需要使用任何其他钩子,你可以简单地构建一个不以use开头的普通 JavaScript 函数。
你在钩子内部的逻辑、其参数以及它返回的值方面也有很多灵活性。关于钩子逻辑,你可以添加所需的任何逻辑。你可以管理没有状态或多个状态值。你可以包含其他自定义钩子或仅使用内置钩子。你可以管理多个副作用,与 refs 一起工作,或执行复杂的计算。在自定义钩子中可以做的事情没有限制。
自定义钩子和参数
你也可以在自定义钩子函数中接受和使用参数。例如,第一个自定义钩子部分的useCounter钩子可以被调整以接受一个初始计数器值和计数器应该增加或减少的单独值,如下面的代码片段所示:
import { useState } from 'react';
function useCounter(initialValue, incVal, decVal) {
const [counter, setCounter] = useState(initialValue);
function increment() {
setCounter(oldCounter => oldCounter + incVal);
};
function decrement() {
setCounter(oldCounter => oldCounter - decVal);
};
return { counter, increment, decrement };
};
export default useCounter;
在这个调整后的示例中,initialValue参数用于通过useState(initialValue)设置初始状态。incVal和decVal参数用于increment和decrement函数中,以不同的值改变counter状态。
当然,一旦在自定义钩子中使用了参数,在组件函数(或在另一个自定义钩子)中调用自定义钩子时,必须提供合适的参数值。因此,Demo1和Demo2组件的代码也必须进行调整——例如,如下所示:
function Demo1() {
const { counter, increment, decrement } = useCounter(1, 2, 1);
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
};
function Demo2() {
const { counter, increment, decrement } = useCounter(0, 1, 2);
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
};
注意
您也可以在 GitHub 上找到此代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/02-parameters。
现在,两个组件将不同的参数值传递给useCounter钩子函数。因此,它们可以动态地重用相同的钩子和其内部逻辑。
自定义钩子和返回值
如useCounter所示,自定义钩子可以返回值。而且这是重要的:它们可以返回值,但不必这样做。如果您构建了一个仅处理一些副作用(通过useEffect())的自定义钩子,您不必返回任何值(因为可能没有应该返回的值)。
但如果您确实需要返回一个值,您决定您想要返回哪种类型的值。您可以返回一个单独的数字或字符串。如果您的钩子必须返回多个值(如useCounter所做的那样),您可以将这些值组合成一个数组或对象。您还可以返回包含对象的数组或反之亦然。简而言之,您可以返回任何内容。毕竟,它是一个正常的 JavaScript 函数。
一些内置钩子,如useState()和useReducer(),返回数组(具有固定数量的元素)。另一方面,useRef()返回一个对象(它始终具有一个current属性)。useEffect()不返回任何值。因此,您的钩子可以返回您想要的任何内容。
例如,之前提到的useCounter钩子可以被重写为返回一个数组:
import { useState } from 'react';
function useCounter(initialValue, incVal, decVal) {
const [counter, setCounter] = useState(initialValue);
function increment() {
setCounter((oldCounter) => oldCounter + incVal);
}
function decrement() {
setCounter((oldCounter) => oldCounter - decVal);
}
return [counter, increment, decrement];
}
export default useCounter;
要使用返回的值,Demo1和Demo2组件需要从对象解构切换到数组解构,如下所示:
function Demo1() {
const [counter, increment, decrement] = useCounter(1, 2, 1);
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
}
function Demo2() {
const [counter, increment, decrement] = useCounter(0, 1, 2);
return (
<>
<p>{counter}</p>
<button onClick={increment}>Inc</button>
<button onClick={decrement}>Dec</button>
</>
);
}
两个组件的行为与之前相同,因此您可以决定您更喜欢哪种返回值。
注意
这段完成后的代码也可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/03-return-values。
一个更复杂的例子
之前的例子故意比较简单。现在,自定义钩子的基础知识已经清楚,深入一个稍微复杂和现实的例子是有意义的。
考虑本章开头提到的 HTTP 请求示例:
const initialHttpState = {
data: null,
isLoading: false,
error: null,
};
function httpReducer(state, action) {
if (action.type === 'FETCH_START') {
return {
...state, // copying the existing state
isLoading: state.data ? false : true,
error: null,
};
}
if (action.type === 'FETCH_ERROR') {
return {
data: null,
isLoading: false,
error: action.payload,
};
}
if (action.type === 'FETCH_SUCCESS') {
return {
data: action.payload,
isLoading: false,
error: null,
};
}
return initialHttpState; // default value for unknown actions
}
function App() {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
return (
<>
<header>
<h1>Complex State Blog</h1>
<button onClick={fetchPosts}>Load Posts</button>
</header>
{httpState.isLoading && <p>Loading...</p>}
{httpState.error && <p>{httpState.error}</p>}
{httpState.data && <BlogPosts posts={httpState.data} />}
</>
);
};
在那个例子中,整个useReducer()逻辑(包括 reducer 函数httpReducer)和useEffect()调用都可以外包到一个自定义钩子中。结果将是一个非常精简的App组件和一个可重用的钩子,它也可以在其他组件中使用。
构建自定义钩子的第一个版本
这个自定义钩子可以命名为useFetch(因为它用于获取数据),并且它可以存储在hooks/use-fetch.js中。当然,钩子名称以及文件存储路径由您决定。以下是useFetch的第一个版本可能看起来像这样:
import { useCallback, useEffect, useReducer } from 'react';
const initialHttpState = {
data: null,
isLoading: false,
error: null,
};
function httpReducer(state, action) {
// same reducer code as before
}
function useFetch() {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
}
export default useFetch;
请注意,这并非最终版本。
在这个第一个版本中,useFetch 钩子包含了 useReducer() 和 useEffect() 逻辑。值得注意的是,httpReducer 函数是在 useFetch 外部创建的。这确保了当 useFetch() 重新执行时(这将在使用此钩子的组件重新评估时经常发生),函数不会被不必要地重新创建。因此,httpReducer 函数将只创建一次(对于整个应用程序的生命周期),并且相同的函数实例将由所有使用 useFetch 的组件共享。
由于 httpReducer 是一个纯函数(即,它总是基于参数值产生新的返回值),共享这个函数实例是可以的,并且不会引起任何意外的错误。如果 httpReducer 要存储或操作任何不是基于函数输入的值,它应该创建在 useFetch 内部。这样,你可以避免多个组件意外地操作和使用共享值。
然而,这个版本的 useFetch 钩子有两个主要问题:
-
目前没有返回任何值。因此,使用这个钩子的组件将无法访问获取的数据或加载状态。
-
HTTP 请求 URL 被硬编码到
useFetch中。因此,所有使用这个钩子的组件都会向相同的 URL 发送相同类型的请求。
因此,为了改进这个钩子,必须解决这两个问题——从第一个问题开始。
通过返回值使钩子变得有用
第一个问题可以通过返回获取的数据(如果没有获取数据,则为 undefined)、加载状态值和错误值来解决。由于这些值正好是 useReducer() 返回的 httpState 对象的组成部分,useFetch 可以简单地返回整个 httpState 对象,如下所示:
// httpReducer function and initial state did not change,
// hence omitted here
function useFetch() {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/posts'
);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, []);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
**return** **httpState;**
}
在这个代码片段中,唯一改变的是 useFetch 函数的最后一行。通过 return httpState,useReducer()(因此是 httpReducer 函数)管理的状态由自定义钩子返回。
在解决了第一个问题之后,下一步是也要使钩子更具可重用性。
通过接受输入参数提高可重用性
为了修复第二个问题(即硬编码的 URL),应向 useFetch 添加一个参数:
// httpReducer function and initial state did not change, hence omitted here
function useFetch(**url**) {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(**url**);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, [**url**]);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
return httpState;
}
在这个片段中,url 参数被添加到了 useFetch 中。这个参数值随后在调用 fetch(url) 时在 try 块内使用。请注意,url 也被添加到了 useCallback() 依赖数组中。
由于useCallback()被包装在获取函数周围(以防止useEffect()造成的无限循环),useCallback()内部使用的任何外部值都必须添加到其依赖项数组中。由于url是外部值(意味着它不在包装函数内部定义),因此必须添加。这在逻辑上也是合理的:如果url参数发生变化(即,如果使用useFetch的组件更改它),则应发送新的 HTTP 请求。
此useFetch钩子的最终版本现在可以在所有组件中使用,以向不同的 URL 发送 HTTP 请求,并按组件所需使用 HTTP 状态值。
例如,App组件可以这样使用useFetch:
import BlogPosts from './components/BlogPosts.jsx';
import useFetch from './hooks/use-fetch.js';
function App() {
const { data, isLoading, error } = useFetch(
'https://jsonplaceholder.typicode.com/posts'
);
return (
<>
<header>
<h1>Complex State Blog</h1>
</header>
{isLoading && <p>Loading...</p>}
{error && <p>{error}</p>}
{data && <BlogPosts posts={data} />}
</>
);
}
export default App;
组件导入并调用useFetch()(以适当的 URL 作为参数),并使用对象解构从httpState对象中获取data、isLoading和error属性。然后,这些值在 JSX 代码中使用。
当然,useFetch钩子也可以返回指向fetchPosts函数的指针(除了httpState),以允许像App组件这样的组件手动触发新的请求,如下所示:
// httpReducer function and initial state did not change, hence omitted here
function useFetch(url) {
const [httpState, dispatch] = useReducer(
httpReducer,
initialHttpState
);
const fetchPosts = useCallback(async function fetchPosts() {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch posts.');
}
const posts = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: posts });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}, [url]);
useEffect(
function () {
fetchPosts();
},
[fetchPosts]
);
**return** **[ httpState, fetchPosts ];**
}
在此示例中,return语句已更改。现在useFetch返回一个包含httpState对象和指向fetchPosts函数的指针的数组。或者,httpState和fetchPosts可以合并到一个对象中(而不是数组)。
在App组件中,现在可以这样使用useFetch:
import BlogPosts from './components/BlogPosts.jsx';
import useFetch from './hooks/use-fetch.js';
function App() {
const [{ data, isLoading, error }, **fetchPosts**] = useFetch(
'https://jsonplaceholder.typicode.com/posts'
);
return (
<>
<header>
<h1>Complex State Blog</h1>
**<****button****onClick****=****{fetchPosts}****>****Load Posts****</****button****>**
</header>
{isLoading && <p>Loading...</p>}
{error && <p>{error}</p>}
{data && <BlogPosts posts={data} />}
</>
);
}
export default App;
App组件使用数组和对象解构结合提取返回的值(以及嵌套在httpState对象中的值)。然后,添加了一个新的<button>元素来触发fetchPosts函数。
此示例有效地展示了自定义钩子如何通过允许轻松的逻辑重用,无论是否有状态或副作用,从而使得组件函数更加精简。
此外,钩子还可以启用一些有趣的模式——例如,与 React 的 Context API 相关。
使用自定义钩子进行上下文访问
如前一章中所述,在将上下文逻辑外包到单独组件中部分,你可以使用自定义钩子来改进在组件中消费上下文值的过程。
例如,如果你提供一些名为BookmarkContext的上下文(例如,通过<BookmarkContextProvider>组件),你可以在组件内部这样访问此上下文值:
import { use } from 'react';
import BookmarkContext from '../../store/bookmark-context.jsx';
function BookmarkSummary() {
const bookmarkCtx = use(BookmarkContext);
// other component code, including returned JSX code
}
然而,你不必像这样直接访问上下文值,也可以构建以下自定义钩子(例如,存储在store/use-bookmark-context.js文件中):
import { use } from 'react';
import BookmarkContext from './bookmark-context.jsx';
function useBookmarkContext() {
const bookmarkCtx = use(BookmarkContext);
return bookmarkCtx;
}
export default useBookmarkContext;
但,当然,这个钩子与通过use()在组件中直接消费上下文值相比,并没有提供任何优势。
一旦你用更多有用的逻辑丰富了此自定义钩子——例如,如果它在没有上下文可用的地方使用,则包含错误处理:
function useBookmarkContext() {
const bookmarkCtx = use(BookmarkContext);
if(!bookmarkCtx) {
throw new Error('BookmarkContext must be provided!')
}
return bookmarkCtx;
}
这个钩子然后可以在你的组件中使用,以获取上下文值,如下所示:
import useBookmarkContext from '../../store/use-bookmark-context.js';
function BookmarkSummary() {
const bookmarkCtx = useBookmarkContext();
// other component code, including returned JSX code
}
因此,这不仅仅是一个自定义钩子的例子,而且是一个你应该了解的常见模式。这是一个在许多 React 项目中使用的模式,因为它确保你不会意外地在一个无法访问上下文值的地方尝试使用上下文值(即在未被BookmarkContextProvider包裹的组件中)。
当然,这不是你必须使用的模式。但这是你可以考虑使用以在错误位置访问上下文时获得早期错误的一种方法。如果你正在分发暴露一些上下文的库,那么这是一个特别有用的模式,因为它会在你的库用户忘记提供上下文时发出警告。
摘要和关键要点
-
你可以创建自定义钩子来外包和重用依赖于其他内置或自定义钩子的逻辑。
-
自定义钩子是名称以
use开头的常规 JavaScript 函数。 -
自定义钩子可以调用任何其他钩子。
-
因此,自定义钩子可以管理状态或执行副作用。
-
所有组件都可以通过像调用任何其他(内置)钩子一样调用它们来使用自定义钩子。
-
当多个组件使用相同的自定义钩子时,每个组件都会接收到自己的“实例”(即,自己的状态值等)。
-
在自定义钩子内部,你可以接受任何参数值并返回你选择的任何值。
接下来是什么?
自定义钩子是 React 的一个关键特性,因为它们帮助你编写更精简的组件,并在它们之间重用(有状态的)逻辑。尤其是在构建更复杂的 React 应用(由数十个甚至数百个组件组成)时,自定义钩子可以使代码更加易于管理。
结合组件、属性、状态(通过useState()或useReducer())、副作用以及在本章和前几章中涵盖的所有其他概念,你现在拥有了一个非常坚实的基础,这使你能够构建生产就绪的 React 应用。因此,你现在准备深入更高级的 React 概念以及你应该了解的关键第三方包。
例如,大多数 React 应用不仅仅由一个单独的页面组成——相反,至少在大多数网站上,用户应该能够在多个页面之间切换。例如,在线商店有一个产品列表、产品详情页面、购物车页面以及许多其他页面。
因此,下一章将探讨如何使用 React 和流行的 React Router 第三方包构建这样的多页应用。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的理解。然后你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/12-custom-hooks/exercises/questions-answers.md找到的示例进行比较。:
-
自定义钩子的定义是什么?
-
在自定义钩子内部可以使用哪些特殊功能?
-
当多个组件使用相同的自定义钩子时会发生什么?
-
如何使自定义钩子更具可重用性?
应用所学知识
应用你对自定义钩子的知识。
活动第 12.1 节:构建自定义键盘输入钩子
在这个活动中,你的任务是重构一个提供的组件,使其更加精简,不再包含任何状态或副作用逻辑。相反,你应该创建一个包含该逻辑的自定义钩子。这个钩子随后可能也可以在 React 应用程序的其他区域使用。
注意
你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/activities/practice-1-start找到这个活动的起始代码。在下载此代码时,你将始终下载整个仓库。请确保导航到包含起始代码的子文件夹(在这个例子中是activities/practice-1-start),以使用正确的代码快照。
提供的项目还使用了前面章节中介绍的一些许多功能。花时间分析它并理解提供的代码。这是一个很好的练习,让你看到许多关键概念的实际应用。
下载代码后,在项目文件夹中运行npm install以安装所有必需的依赖项,然后可以通过npm run dev启动开发服务器。结果,访问localhost:5173时,你应该看到以下用户界面:
图 12.3:正在运行的项目起始状态
要完成这个活动,解决方案步骤如下:
-
在
src/hooks文件夹中创建一个新的自定义钩子文件,并在该文件中创建一个钩子函数。 -
将副作用和状态管理逻辑移动到那个新的钩子函数中。
-
通过接受和使用一个控制允许哪些键的参数来使自定义钩子更具可重用性。
-
返回自定义钩子管理的状态。
-
在
App组件中使用自定义钩子和其返回的值。
完成活动后,用户界面应保持不变,但App组件的代码应发生变化。完成活动后,App应只包含以下代码:
function App() {
const pressedKey = useKeyEvent(['s', 'c', 'p']); // this is your Hook!
let output = '';
if (pressedKey === 's') {
output = '';
} else if (pressedKey === 'c') {
output = '';
} else if (pressedKey === 'p') {
output = '';
}
return (
<main>
<h1>Press a key!</h1>
<p>
Supported keys: <kbd>s</kbd>, <kbd>c</kbd>, <kbd>p</kbd>
</p>
<p id="output">{output}</p>
</main>
);
}
注意
所有用于此活动的代码文件以及一个示例解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/activities/practice-1找到。
第十三章:使用 React Router 的多页应用程序
学习目标
到本章结束时,你将能够做到以下事情:
-
构建多页单页应用程序(以及理解这并不是一个矛盾的说法)
-
使用 React Router 包为不同的 URL 路径加载不同的 React 组件
-
创建静态和动态路由(以及首先了解什么是路由)
-
通过链接和程序性命令导航网站
-
构建嵌套页面布局
简介
在完成本书的前十二章后,你现在应该知道如何构建 React 组件和 Web 应用程序,以及如何管理组件和全局状态,以及如何在组件之间共享数据(通过 props 或 context)。
尽管你知道如何从多个组件中组合 React 网站,但所有这些组件都在同一个单页网站上。当然,你可以有条件地显示组件和内容,但用户永远不会切换到不同的页面。这意味着 URL 路径永远不会改变;用户将始终停留在 your-domain.com。此外,到目前为止,你的 React 应用程序不支持任何路径,如 your-domain.com/products 或 your-domain.com/blog/latest。
注意
统一资源定位符( URLs)是网络资源的引用。例如,academind.com/courses 是一个指向作者网站特定页面的 URL。在这个例子中,academind.com 是网站的 域名,/courses 是指向特定网站页面的 路径。
对于 React 应用程序来说,加载的网站路径从不改变可能是有意义的。毕竟,在 第一章 中,React – 什么是以及为什么,你学习了使用 React 构建 单页应用程序( SPAs)。
但尽管这可能是有意义的,但它也是一个相当严重的限制。
一页不够
只有一个页面意味着那些通常由多个页面组成(例如,包含产品、订单等页面的在线商店)的复杂网站很难用 React 构建。没有多个页面,你不得不退而求其次,使用状态和条件值在屏幕上显示不同的内容。
但如果没有改变 URL 路径,你的网站访客只能分享指向网站起始页的链接。此外,当新访客访问该起始页时,任何有条件加载的内容都会丢失。如果用户简单地重新加载他们当前所在的页面,情况也是如此。重新加载会获取页面的新版本,因此任何状态(以及因此用户界面)的变化都会丢失。
由于这些原因,对于大多数 React 网站,你绝对需要在单个 React 应用程序中包含多个页面(具有不同的 URL 路径)。多亏了现代浏览器功能和高度流行的第三方包,这确实可能实现(并且对于大多数 React 应用程序来说是默认的)。
通过 React Router 包,你的 React 应用可以监听 URL 路径的变化,并为不同的路径显示不同的组件。例如,你可以定义以下路径-组件映射:
-
<domain>/=> 加载<Home />组件。 -
<domain>/products=> 加载<ProductList />组件。 -
<domain>/products/p1=> 加载<ProductDetail />组件。 -
<domain>/about=> 加载<AboutUs />组件。
从技术上讲,它仍然是一个单页应用(SPA),因为仍然只向网站用户发送了一个 HTML 页面。但在那个单页 React 应用中,不同的组件是由 React Router 包根据访问的具体 URL 路径条件性地渲染的。作为应用的开发者,你不需要手动管理这种状态或条件性地渲染内容——React Router 会为你处理。此外,你的网站能够处理不同的 URL 路径,因此,单个页面可以被共享或重新加载。
React Router 入门与定义路由
React Router 是一个可以在任何 React 项目中安装的第三方 React 库。一旦安装,你就可以在你的代码中使用各种组件来启用上述功能。
在你的 React 项目内部,通过以下命令安装该包:
npm install react-router-dom
安装完成后,你可以导入并使用该库中的各种组件(和 Hooks)。
要开始在你的 React 应用中支持多页,你需要通过以下步骤设置 路由:
-
为你的不同页面创建不同的组件(例如,
Dashboard和Orders组件)。 -
使用 React Router 库中的
createBrowserRouter()函数和RouterProvider组件来启用路由并定义 React 应用应支持的 路由。
在这个上下文中,术语 路由 指的是 React 应用能够为不同的 URL 路径加载不同的组件(例如,为 / 和 /orders 路径加载不同的组件)。路由是一个添加到 React 应用的定义,它定义了应该渲染预定义 JSX 片段的 URL 路径(例如,对于 /orders 路径,应该加载 Orders 组件)。
在一个包含 Dashboard 和 Orders 组件的示例 React 应用中,并且通过 npm install 安装了 React Router 库,你可以通过编辑根组件(在 src/App.jsx 中)来启用这两个组件之间的路由和导航,如下所示:
**import** **{**
**createBrowserRouter,**
**RouterProvider**
**}** **from****'react-router-dom'****;**
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
**const** **router =** **createBrowserRouter****([**
**{** **path****:** **'/'****,** **element****:** **<****Dashboard** **/>** **},**
**{** **path****:** **'/orders'****,** **element****:** **<****Orders** **/>** **}**
**]);**
function App() {
return **<****RouterProvider****router****=****{router}** **/>**;
}
export default App;
注意
你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/01-getting-started-with-routing。
在前面的代码片段中,React Router 的 createBrowserRouter() 函数被调用以创建一个包含应用程序的路由配置(可用路由列表)的 router 对象。传递给 createBrowserRouter() 的数组包含路由定义对象,其中每个对象定义了一个应匹配的 path 以及应渲染的 element。
然后,使用 React Router 的 RouterProvider 组件来设置 router 配置并定义一个用于渲染活动路由元素的位置。
您可以将 <RouterProvider /> 元素视为一旦路由变为活动状态,就被通过 element 属性定义的内容所替换。因此,RouterProvider 组件的位置很重要。在这种情况下(以及可能的大多数 React 应用程序),它应该是根应用程序组件——即 React Router,它应该控制整个应用程序组件树。
如果您运行提供的示例 React 应用程序(通过 npm run dev),您将在屏幕上看到以下输出:
图 13.1:仪表板组件内容已加载
如果您访问 localhost:5173,屏幕上会显示 Dashboard 组件的内容。请注意,可见的页面内容并未在 App 组件(在之前共享的代码片段中)中定义。相反,只添加了两个路由定义:一个用于 / 路径(即 localhost:5173/ 或仅 localhost:5173,不带尾随正斜杠——它以相同的方式处理)和一个用于 /orders 路径(localhost:5173/orders)。
注意
localhost 是一个通常用于开发的本地地址。当您部署您的 React 应用程序(即,您将其上传到 Web 服务器)时,您将收到不同的域名——或者分配一个自定义域名。无论如何,部署后它将不再是 localhost。
localhost 之后的部分(:5173)定义了请求将被发送到的网络端口。如果没有额外的端口信息,将自动使用端口 80 或 443(作为默认的 HTTP(S) 端口)。然而,在开发期间,这些并不是您想要的端口。相反,您通常会使用 5173、8000 或 8080 这样的端口,因为这些端口通常不会被任何其他系统进程占用,因此可以安全使用。通过 Vite 创建的项目通常使用端口 5173。
由于 localhost:5173 默认加载(当运行 npm run dev 时),第一个路由定义({ path: '/', element: <Dashboard /> })变为活动状态。此路由处于活动状态是因为其路径('/')与 localhost:5173 的路径匹配(因为这与 localhost:5173/ 相同)。
因此,通过 element 定义的 JSX 代码替换了 <RouterProvider> 组件。在这种情况下,这意味着 Dashboard 组件的内容被显示,因为此路由定义的 element 属性值是 <Dashboard />。在示例中,使用单个组件(如 <Dashboard />)是很常见的,但你也可以将任何 JSX 内容设置为 element 属性的值。
在前面的例子中,没有显示复杂的页面。相反,屏幕上只显示了一些文本。不过,在本章的后面部分,这将会改变。
但如果你在浏览器地址栏中将 URL 从 localhost:5173 手动更改为 localhost:5173/orders,这会变得有趣。在任何前面的章节中,这都不会改变页面内容。但现在,由于启用了路由并且定义了适当的路由,页面内容确实发生了变化,如下所示:
图 13.2:对于 /orders,显示 Orders 组件的内容
一旦 URL 发生变化,Orders 组件的内容就会显示在屏幕上。在这个第一个例子中,它仍然是基本的文本,但它表明对于不同的 URL 路径,会渲染不同的代码。
然而,这个基本例子有一个主要的缺陷(除了相当无聊的页面内容)。目前,用户必须手动输入 URL。但当然,这不是通常使用网站的方式。
添加页面导航
为了允许用户在不手动编辑浏览器地址栏的情况下在网站的不同页面之间切换,网站通常包含链接,通常通过 <a> HTML 元素(锚元素)添加,如下所示:
<a href="/orders">Past Orders</a>
对于这个例子,可以通过修改 Dashboard 组件代码来添加页面导航,如下所示:
function Dashboard() {
return (
<>
<h1>The "Dashboard" route component</h1>
**<****p****>****Go to the** **<****a****href****=****"/orders"****>****Orders page****</****a****>****.****</****p****>**
{/* <p> elements omitted */}
</>
);
}
export default Dashboard;
在这个代码片段中,已添加了对 /orders 路由的链接。因此,网站访客现在看到的是这个页面:
图 13.3:添加了导航链接
因此,当网站用户点击这个链接时,他们会进入 /orders 路由,并且 Orders 组件的内容会显示在屏幕上。
这种方法可行,但有一个主要的缺陷:每次用户点击链接时,网站都会重新加载。你可以通过点击链接时浏览器刷新图标变为一个叉号(短暂地)来判断页面正在重新加载。
这是因为每当点击链接时,浏览器都会向服务器发送一个新的 HTTP 请求。尽管服务器总是返回相同的单个 HTML 页面,但在那个过程中页面会重新加载(因为发送了新的 HTTP 请求)。
虽然在这个简单的演示页面上这并不是问题,但如果你有某些共享状态(例如,通过上下文管理的全局状态)不应该在页面更改时重置,那么这就会成为一个问题。此外,每次新的请求都会花费时间,并迫使浏览器重新下载所有网站资源(例如,脚本文件)。即使这些文件可能被缓存,这也是一个不必要的步骤,可能会影响网站性能。
下面的略微调整后的 App 组件示例说明了状态重置问题:
**import** **{ useState }** **from****'react'****;**
import {
createBrowserRouter,
RouterProvider
} from 'react-router-dom';
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
{ path: '/', element: <Dashboard /> },
{ path: '/orders', element: <Orders /> },
]);
function App() {
**const** **[counter, setCounter] =** **useState****(****0****);**
**function****handleIncCounter****() {**
**setCounter****(****(****prevCounter****) =>** **prevCounter +** **1****);**
**}**
return (
<>
**<****p****>**
**<****button****onClick****=****{handleIncCounter}****>****Increase Counter****</****button****>**
**</****p****>**
**<****p****>****Current Counter:** **<****strong****>****{counter}****</****strong****></****p****>**
<RouterProvider router={router} />
</>
);
}
export default App;
注意
本例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/03-naive-navigation-problem 找到。
在本例中,一个简单的计数器被添加到了 App 组件中。由于 <RouterProvider> 在同一个组件中被渲染,在计数器下方,当用户访问不同的页面时,不应替换 App 组件(相反,应该替换 <RouterProvider> 而不是整个 App 组件的 JSX 代码)。
至少,这是理论上的情况。但是,正如你在下面的屏幕截图中所看到的,每次点击任何链接时,counter 状态都会丢失:
图 13.4:切换页面时计数器状态被重置
在屏幕截图中,你可以看到计数器最初被设置为 3(因为按钮被点击了三次)。在从 Dashboard 页面导航到 Orders 页面(通过点击 Orders page 链接)后,计数器变为 0。
这是因为页面因浏览器发送的 HTTP 请求而重新加载。
为了解决这个问题并避免这种意外的页面重新加载,你必须阻止浏览器默认行为。而不是发送新的 HTTP 请求,浏览器 URL 地址应该只更新(从 localhost:5173 更新到 localhost:5173/orders),并且应该加载目标组件(Orders)。因此,对于网站用户来说,这看起来就像加载了不同的页面。但在幕后,只是页面文档(DOM)被更新了。
幸运的是,你不必自己实现这个逻辑。相反,React Router 库公开了一个特殊的 Link 组件,应该用它来代替锚 <a> 元素。
要使用这个新组件,src/routes/Dashboard.jsx 中的代码必须进行调整如下:
**import** **{** **Link** **}** **from****'react-router-dom'****;**
function Dashboard() {
return (
<>
<h1>The "Dashboard" route component</h1>
<p>Go to the **<****Link****to****=****"/orders"****>****Orders page****</****Link****>**.</p>
<p>
This component could display the user dashboard
of some web shop.
</p>
<p>It's just a dummy example here, but you get the point.</p>
<p>
It's worth noting, that it's a regular React component
that's activated by React Router because of the
active route configuration.
</p>
</>
);
}
export default Dashboard;
注意
本例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/04-react-router-navigation 找到。
在这个更新后的示例中,使用了新的 Link 组件。该组件需要一个 to 属性,用于定义应该加载的 URL 路径。
通过使用此组件代替 <a> 锚点元素,计数器状态不再重置。这是因为 React Router 现在阻止了浏览器的默认行为(即上述描述的不希望的页面重新加载)并显示了正确的页面内容。
在底层,Link 组件仍然渲染内置的 <a> 元素。但 React Router 控制它并实现了上述描述的行为。
因此,Link 组件是用于内部链接的默认组件。对于外部链接,应使用标准的 <a> 元素,因为链接会离开网站,因此没有需要保留的状态或页面重新加载来防止。
使用布局和嵌套路由
大多数网站都需要某种形式的页面宽范围导航(以及相应的导航链接)或其他应在某些或所有路由之间共享的页面部分。
考虑到之前的示例网站,它有 / 和 /orders 路由。该示例网站也将从有一个允许用户在起始页面(即 Dashboard 路由)和 Orders 页面之间切换的顶部导航栏中受益。
因此,可以将 App.jsx 调整为在 <RouterProvider> 上方的 <header> 中包含一个顶部导航栏:
import {
createBrowserRouter,
RouterProvider,
Link
} from 'react-router-dom';
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
{ path: '/', element: <Dashboard /> },
{ path: '/orders', element: <Orders /> },
]);
function App() {
return (
<>
**<****header****>**
**<****nav****>**
**<****ul****>**
**<****li****>**
**<****Link****to****=****"/"****>****My Dashboard****</****Link****>**
**</****li****>**
**<****li****>**
**<****Link****to****=****"/orders"****>****Past Orders****</****Link****>**
**</****li****>**
**</****ul****>**
**</****nav****>**
**</****header****>**
<RouterProvider router={router} />
</>
);
}
export default App;
但如果你尝试运行此应用程序,你将看到一个空白页面,并在浏览器开发者工具的 JavaScript 控制台中遇到错误信息。
图 13.5:React Router 好像在抱怨某些事情
错误信息有点晦涩难懂,但问题在于上述代码试图在由 React Router 控制的组件之外使用 <Link>。
只有通过 <RouterProvider> 加载的组件才受 React Router 控制,因此 React Router 的功能,如其 Link 组件,只能在路由组件(或其子组件)中使用。
因此,在 App 组件(不是由 React Router 加载)内部设置主要导航不起作用。
要使用某个共享组件和 JSX 标记来包装或增强多个路由组件,必须定义一个新的路由来包装现有路由。这样的路由有时也被称为 布局路由,因为它可以用来提供一些共享布局。被此路由包装的路由将被称为 嵌套路由。
布局路由的定义方式与路由定义数组内的任何其他路由相同。然后,通过使用 React Router 接受的特殊 children 属性来包装其他路由,它就变成了一个布局路由。这个 children 属性接收一个嵌套路由的数组——包装父路由的子路由。
这是此示例应用程序调整后的路由定义代码:
**import****Root****from****'./routes/Root.jsx'****;**
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
{
**path****:** **'/'****,**
**element****:** **<****Root** **/>****,**
**children****:** [
{ **index****:** **true**, element: <Dashboard /> },
{ path: '/orders', element: <Orders /> },
],
},
]);
在这个更新的代码片段中,定义了一个新的根布局路由——一个注册现有路由(Dashboard和Orders组件)为子路由的路由。因此,这种设置允许Root组件与Dashboard或Orders路由组件同时激活。
你可能还会注意到,Dashboard路由不再有path。相反,它现在有一个index属性,设置为true。这个index属性是在处理嵌套路由时可以使用的属性。它告诉 React Router 在父路由路径完全匹配时激活哪个嵌套路由(因此加载哪个组件)。
在这个例子中,当/路径处于活动状态(即,如果用户访问<domain>/),Root和Dashboard组件将被渲染。对于<domain>/orders,Root和Orders将变得可见。
Root组件是这个例子中新增的组件。它是一个标准组件(如Dashboard或Orders),具有一个特殊功能:它通过 React Router 提供的特殊Outlet组件定义了子路由组件应该插入的位置:
import { Link, **Outlet** } from 'react-router-dom';
function Root() {
return (
<>
<header>
<nav>
<ul>
<li>
<Link to="/">My Dashboard</Link>
</li>
<li>
<Link to="/orders">Past Orders</Link>
</li>
</ul>
</nav>
</header>
**<****Outlet** **/>**
</>
);
}
export default Root;
<Outlet />占位符是必需的,因为 React Router 必须知道在哪里渲染传递给children属性的路由组件的路由组件。
注意
你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/05-layouts-nested-routes。
由于Root组件本身也是由 React Router 渲染的,它现在是一个可以访问<Link>标签的组件。因此,这个Root组件可以用来在所有嵌套路由之间共享通用标记(如导航<header>)。
图 13.6:顶部显示了一个共享的导航栏(适用于所有路由)
因此,嵌套路由和布局路由(或包装路由)是 React Router 提供的关键特性。
还值得注意的是,你可以根据应用程序的需要添加任意级别的路由嵌套——你不仅限于只有一个包裹子路由的布局路由。
从 Link 到 NavLink
在上一章中设置的共享导航中,你通常希望突出显示导致当前活动页面的链接。例如,如果用户点击了Past Orders链接(因此导航到/orders),该链接应该改变其外观(例如,其颜色)。
考虑之前的例子(图 13.6)——在那里,在顶部导航栏中,用户是否在Dashboard页面或Orders页面并不立即明显。当然,URL 地址和主页内容确实会改变,但导航项在视觉上并没有调整。
为了证明这一点,比较之前的截图和下面的截图:
图 13.7:高亮的“历史订单”导航链接被下划线并改变颜色
在这个版本的网站上,用户立即就能清楚地看到他们位于“订单”页面,因为“历史订单”导航链接被突出显示。正是这样的细微之处使得网站更加易用,并最终可能导致更高的用户参与度。
但这是如何实现的呢?
要做到这一点,你不会使用 Link 组件,而是使用 react-router-dom 提供的特别替代组件:NavLink 组件:
import { **NavLink**, Outlet } from 'react-router-dom';
function Root() {
return (
<>
<header>
<nav>
<ul>
<li>
**<****NavLink****to****=****"/"****>****My Dashboard****</****NavLink****>**
</li>
<li>
**<****NavLink****to****=****"/orders"****>****Past Orders****</****NavLink****>**
</li>
</ul>
</nav>
</header>
<Outlet />
</>
);
}
export default Root;
NavLink 组件的使用方式与 Link 组件非常相似。你将其包裹在一段文本(链接的标题)周围,并通过 to 属性定义目标路径。然而,NavLink 组件有一些额外的与样式相关的功能,这是常规 Link 组件所不具备的。
严格来说,当链接处于活动状态时,NavLink 组件默认将一个名为 active 的 CSS 类应用到渲染的锚点元素上。
图 13.8:渲染的 <a> 元素接收了一个“active” CSS 类
如果你想在链接变为活动状态时应用不同的 CSS 类名或内联样式,NavLink 也允许你这样做。因为 NavLink 的 className 和 style 属性在行为上与其他元素略有不同。除了接受字符串值(className)或样式对象(style)之外,这两个属性还接受函数,这些函数将由 React Router 在每次导航操作时自动调用。例如,以下代码可以用来确保应用特定的 CSS 类或样式:
<NavLink
className={({ isActive }) => isActive ? 'loaded' : ''}
style={({ isActive }) => isActive ? { color: 'red' } : undefined}>
Some Link
</NavLink>
在上述代码片段中,className 和 style 都利用了 React Router 将要执行的功能。这个函数自动接收一个对象作为输入参数——这个对象是由 React Router 创建并提供的,它包含一个 isActive 属性。当链接指向当前活动路由时,React Router 将 isActive 设置为 true,否则设置为 false。
因此,你可以在这些函数中返回任何你选择的 CSS 类名或样式对象。然后 React Router 将它们应用到渲染的 <a> 元素上。
注意
你可以在 GitHub 上找到这个示例的完整代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/06-navlinks。
一个重要的注意事项是,NavLink会将路径与当前 URL 路径匹配或以当前 URL 路径开头的情况视为活动路由。例如,如果你有一个/blog/all-posts路由,一个指向/blog的NavLink组件如果当前路由是/blog/all-posts,则会被视为活动状态(因为该路由路径以/blog开头)。如果你不希望这种行为,你可以在NavLink组件中添加特殊的end属性,如下所示:
<NavLink
to="/blog"
style={({ isActive }) => isActive ? { color: 'red' } : undefined}
**end**>
Blog
</NavLink>
添加了这个特殊属性后,这个NavLink只有在当前路由正好是/blog时才会被视为活动状态——对于/blog/all-posts,链接则不会是活动状态。
例外情况是链接到/。由于所有路由在技术上都是以这个“空路径”开始的,React Router 默认情况下只将<NavLink to="/">视为活动状态,如果用户当前位于<domain>/。对于其他路径(例如,/orders),<NavLink to="/">则不会被标记为活动状态。
当链接的样式依赖于当前活动路由时,NavLink始终是首选的选择。对于所有其他内部链接,使用Link。对于外部链接,<a>是首选元素。
路由组件与“常规”组件的比较
值得注意的是,在之前的示例中,Dashboard和Orders组件是常规的 React 组件。你可以在你的 React 应用的任何地方使用这些组件——而不仅仅是作为路由定义的element属性的值。
然而,这两个组件是特殊的,因为它们都存储在项目目录下的src/routes文件夹中。它们没有存储在src/components文件夹中,而这本书中使用的组件都是存储在这个文件夹中的。
虽然这不是你必须做的事情。实际上,文件夹名称完全由你决定。这两个组件可以存储在src/components中。你也可以将它们存储在src/elements文件夹中。但使用src/routes对于仅用于路由的组件来说是非常常见的。流行的替代方案有src/screens、src/views和src/pages(同样,这取决于你)。
如果你的应用包含任何其他不是作为路由元素的组件,你仍然会将这些组件存储在src/components(即,在不同的路径下)。这并不是一个硬性规则或技术要求,但它确实有助于保持你的 React 项目可管理。将你的组件分散存储在多个文件夹中,可以更容易地快速理解项目中的哪些组件实现了哪些功能。
在之前提到的示例项目中,例如,你可以重构代码,使得导航代码存储在一个单独的组件中(例如,一个MainNavigation组件,存储在src/components/shared/MainNavigation.jsx)。组件文件代码如下:
import { NavLink } from 'react-router-dom';
import classes from './MainNavigation.module.css';
function MainNavigation() {
return (
<header className={classes.header}>
<nav>
<ul>
<li>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
end
>
My Dashboard
</NavLink>
</li>
<li>
<NavLink
to="/orders"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
>
Past Orders
</NavLink>
</li>
</ul>
</nav>
</header>
);
}
export default MainNavigation;
在这个代码片段中,NavLink组件被调整以将名为active的 CSS 类分配给属于当前活动路由的任何链接。这是在使用 CSS Modules 时必需的,因为类名在构建过程中会发生变化,正如在第六章为 React 应用添加样式中讨论的那样。除此之外,它基本上与本章早期使用的相同导航菜单代码。
然后,这个MainNavigation组件可以被导入并像这样在Root.jsx文件中使用:
import { Outlet } from 'react-router-dom';
**import****MainNavigation****from****'../components/shared/MainNavigation.jsx'****;**
function Root() {
return (
<>
**<****MainNavigation** **/>**
<Outlet />
</>
);
}
export default Root;
导入和使用MainNavigation组件会导致Root组件更加精简,同时仍然保留之前的功能。
这些更改显示了你可以如何组合仅用于路由的组件(Dashboard和Orders)以及用于路由外部的组件(MainNavigation)。
注意
你可以在 GitHub 上找到这个示例的完成代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/07-routing-and-normal-cmp。
即使有了那些标记和样式的改进,演示应用程序仍然存在一个重要问题:它只支持静态、预定义的路由。但对于大多数网站来说,这类路由是不够的。
从静态路由到动态路由
到目前为止,所有示例都有两个路由:/用于Dashboard组件,/orders用于Orders组件。但你可以,当然,添加所需的路由数量。如果你的网站有 20 个不同的页面,你(应该)为App组件添加 20 个路由定义(即 20 个Route组件)。
然而,在大多数网站上,你也会有一些无法手动定义的路由——因为并非所有路由及其确切路径都是预先知道的。
考虑之前的示例,增加了额外的组件和一些模拟数据:
图 13.9:订单项列表
注意
你可以在 GitHub 上找到这个示例的代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/08-dynamic-routes-problem。在代码中,你会注意到添加了许多新的组件和样式文件。尽管如此,代码并没有使用任何新特性。它只是用来显示一个更真实的用户界面并输出一些模拟数据。
在前面的屏幕截图图 13.9中,你可以看到订单项列表在Past Orders页面(即由Orders组件)上输出。
在底层代码中,每个订单项都被一个Link组件包裹,以便为每个项目加载一个包含更多详细信息的单独页面:
function OrdersList() {
return (
<ul className={classes.list}>
{orders.map((order) => (
<li key={order.id}>
**<****Link****to****=****'/orders'****><****OrderItem****order****=****{order}** **/></****Link****>**
</li>
))}
</ul>
);
}
在这个代码片段中,Link组件的路径被设置为/orders。然而,这并不是应该分配的最终值。相反,这个例子突出了一个重要问题:虽然对于每个订单项都应该加载相同的路由和组件(即显示所选订单详细数据的组件),但该组件输出的确切内容取决于选择了哪个订单项。这是相同的路由和组件,但数据不同。
除了路由之外,你会使用 props 来重复使用具有不同数据的相同组件。但是,在路由的情况下,这不仅仅关乎组件。你还必须支持不同的路径——因为不同订单的详细信息应该通过不同的路径(例如,/orders/o1、/orders/o2等)来加载。否则,你又会得到不可共享或重新加载的 URL。
因此,路径必须包含不仅是一些静态标识符(例如/orders),而且对于每个订单项都是不同的动态值。对于具有id值o1、o2和o3的三个订单项,目标可能是支持/orders/o1、/orders/o2和/order/o3路径。
因此,以下三个路由定义可以添加:
{ path: '/orders/o1', element: <OrderDetail id="o1" /> },
{ path: '/orders/o2', element: <OrderDetail id="o2" /> },
{ path: '/orders/o3', element: <OrderDetail id="o3" /> }
但这个解决方案有一个主要的缺陷。手动添加所有这些路由是一项巨大的工作量。而且这还不是最大的问题。你通常甚至不知道所有值。在这个例子中,当放置一个新订单时,必须添加一个新的路由。但你不能每次访客下单时都调整你网站的源代码。
显然,因此需要一个更好的解决方案。React Router 提供了这个更好的解决方案,因为它支持动态路由。
动态路由的定义方式与其他路由相同,只是在定义它们的path值时,你需要包含一个或多个你选择的动态路径段。
因此,OrderDetail路由定义看起来是这样的:
{ path: '/orders/:id', element: <OrderDetail /> }
以下三个关键事物已经改变:
-
这只是一个路由定义,而不是一个(可能)无限的路由定义列表。
-
path包含一个动态路径段(:id)。 -
OrderDetail不再接收idprop。
:id语法是 React Router 支持的特殊语法。每当路径的一个部分以冒号开头时,React Router 将其视为动态段。这意味着它将在实际的 URL 路径中被不同的值替换。对于/orders/:id路由路径,/orders/o1、/orders/o2和/orders/abc路径都会匹配,因此激活路由。
当然,你不必使用:id。你可以使用任何你选择的标识符。对于前面的例子,:orderId、:order或:oid也是有意义的。
标识符将帮助你的应用程序访问页面组件中应加载的动态路由的正确数据(即上面代码片段中的OrderDetail路由组件)。这就是为什么在上一个代码片段中从OrderDetail中移除了id属性。由于只定义了一个路由,因此只能通过属性传递一个特定的id值。这不会有所帮助。因此,必须使用不同的方式来加载特定订单的数据。
提取路由参数
在前面的例子中,当网站用户访问/orders/o1或/orders/o2(或任何其他订单 ID 的相同路径)时,会加载OrderDetail组件。然后,该组件应该输出有关所选特定订单的更多信息(即 ID 编码在 URL 路径中的订单)。
顺便说一下,这不仅仅适用于这个例子;你也可以考虑许多其他类型的网站。例如,你也可以有一个在线商店,其中包含产品路由(/products/p1、/products/p2等),或者一个旅游博客,用户可以访问单个博客文章(/blog/post1、/blog/post2等)。
在所有这些情况下,问题是如何获取应加载到特定标识符(例如,ID)中的数据,该标识符包含在 URL 路径中?由于总是加载相同的组件,你需要一种动态识别顺序、产品或博客文章的方法,以便获取相应的详细数据。
一种可能的解决方案是使用属性。每当构建一个应该可重用且可配置和动态的组件时,可以使用属性来接受不同的值。例如,OrderDetail组件可以接受一个id属性,然后在组件函数体内加载该特定订单 ID 的数据。
然而,如前所述,当通过路由加载组件时,这不是一个可行的解决方案。记住,OrderDetail组件是在定义路由时创建的:
{ path: '/orders/:id', element: <OrderDetail /> }
由于组件是在App组件中定义路由时创建的,因此无法传递任何动态的、ID 特定的属性值。
幸运的是,这并不是必要的。React Router 为你提供了一个解决方案,允许你从屏幕上显示的组件内部(当路由变为活动状态时)提取编码在 URL 路径中的数据:useParams()钩子。
这个钩子可以用来获取当前活动路由的路由参数。路由参数仅仅是编码在 URL 路径中的动态值——在本例的OrderDetail中是id。
因此,在OrderDetail组件内部,可以使用useParams()来提取特定的订单 ID 并加载相应的订单数据,如下所示:
import { useParams } from 'react-router-dom';
import Details from '../components/orders/Details.jsx';
import { getOrderById } from '../data/orders.js';
function OrderDetail() {
const params = useParams();
const orderId = params.id; // orderId is "o1", "o2" etc.
const order = getOrderById(orderId);
return <Details order={order} />;
}
export default OrderDetail;
如您在这段代码片段中所见,useParams() 返回一个对象,该对象包含当前活动路由的所有路由参数作为属性。由于路由路径被定义为 /orders/:id,因此 params 对象包含一个 id 属性。该属性的值是实际编码在 URL 路径中的值(例如,o1)。如果您在路由定义中选择不同的标识符名称(例如,/orders/:orderId 而不是 /orders/:id),则必须使用该属性名称来访问 params 对象中的值(即访问 params.orderId)。
注意
您可以在 GitHub 上找到完整的代码,地址为 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/09-dynamic-routes。
因此,通过使用路由参数,您可以轻松创建动态路由,这些路由可以加载不同的数据。但是,当然,如果您没有指向动态路由的链接,那么定义路由和处理路由激活并没有那么有用。
创建动态链接
如在本章前面(在 添加页面导航 部分中)所述,网站访客应该能够点击链接,然后这些链接应该将他们带到构成整个网站的各个页面——这意味着,这些链接应该激活使用 React Router 定义的各个路由。
如在 添加页面导航 和 从链接到 NavLink 部分中所述,对于内部链接(即指向 React 应用内部定义的路由的链接),使用 Link 或 NavLink 组件。
因此,对于像 /orders 这样的静态路由,链接是这样创建的:
<Link to="/orders">Past Orders</Link> // or use <NavLink> instead
因此,当构建指向如 /orders/:id 这样的动态路由的链接时,您可以简单地创建一个如下所示的链接:
<Link to="/orders/o1">Past Orders</Link>
此特定链接加载了 ID 为 o1 的 OrderDetails 组件。
按如下方式构建链接是不正确的:
<Link to="/orders/:id">Past Orders</Link>
动态路径段语法(:id)仅在定义路由时使用——在创建链接时不使用。链接必须指向特定的资源(在这种情况下是特定的订单)。
然而,正如之前所示,创建指向特定订单的链接并不太实用。正如在 从静态路由到动态路由 部分中定义所有动态路由单独来说没有意义一样,手动创建相应的链接也没有意义。
以订单为例,由于您已经在单页(在这种情况下是 Orders 组件)上输出了订单列表,因此无需创建此类链接。同样,您可以在在线商店中有一个产品列表。在这些所有情况下,单个项目(订单、产品等)应该是可点击的,并链接到包含更多信息的详细信息页面。
图 13.10:可点击的订单项列表
因此,在渲染 JSX 元素列表时可以动态生成链接。在订单示例的情况下,代码看起来是这样的:
function OrdersList() {
return (
<ul className={classes.list}>
{orders.map((order) => (
<li key={order.id}>
**<****Link**
**to****=****{****`/****orders****/${****order.id****}`}>**
<OrderItem order={order} />
</Link>
</li>
))}
</ul>
);
}
在此代码示例中,to属性的值被动态设置为包含order.id值的字符串。因此,每个列表项都接收一个独特的链接,该链接指向不同的详情页面。或者,更准确地说,链接始终指向同一个组件,但具有不同的order id值,因此加载不同的订单数据。
注意
在此代码片段(可在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/10-dynamic-links )中,字符串被创建为模板字面量。这是一个默认的 JavaScript 功能,它简化了包含动态值的字符串的创建。
您可以在 MDN 上了解更多关于模板字面量的信息,网址为developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals 。
程序化导航
在上一节以及本章前面的内容中,用户导航是通过向网站添加链接来实现的。确实,链接是向网站添加导航的默认方式。但有些情况下需要使用程序化导航。
程序化导航意味着新页面应通过 JavaScript 代码加载(而不是使用链接)。这种导航通常在活动页面因某些操作而改变时需要——例如,在表单提交时。
如果以表单提交为例,您通常希望提取并保存提交的数据。但在此之后,有时需要将用户重定向到不同的页面。例如,在处理输入的信用卡详情后,让用户留在结账页面是没有意义的。您可能希望将用户重定向到成功页面。
在本章讨论的示例中,历史订单页面可以包括一个输入字段,允许用户直接输入订单 ID,并在点击查找按钮后加载相应的订单数据。
图 13.11:一个可以快速加载特定订单的输入字段
在此示例中,首先处理并验证输入的订单 ID,然后用户被发送到相应的详情页面。如果提供的 ID 无效,则显示错误消息。代码如下:
import orders, { getOrdersSummaryData } from '../../data/orders.js';
import classes from './OrdersSummary.module.css';
function OrdersSummary() {
const { quantity, total } = getOrdersSummaryData();
const formattedTotal = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(total);
function findOrderAction(formData) {
const orderId = formData.get('order-id');
const orderExists = orders.some((order) => order.id === orderId);
if (!orderExists) {
alert('Could not find an order for the entered id.');
return;
}
}
return (
<div className={classes.row}>
<p className={classes.summary}>
{formattedTotal} | {orders.length} Orders |
{quantity} Products
</p>
<form className={classes.form} action={findOrderAction}>
<input
type="text"
name="order-id"
placeholder="Enter order id"
aria-label="Find an order by id."
/>
<button>Find</button>
</form>
</div>
);
}
export default OrdersSummary;
代码片段尚未包括实际触发页面更改的代码,但它显示了如何读取和验证用户输入。
因此,这是一个使用程序化导航的完美场景。在这里不能使用链接,因为它会立即触发页面更改——在允许您首先验证用户输入之前(至少在点击链接之后不会)。
React Router 库还支持此类情况下的程序化导航。你可以导入并使用特殊的 useNavigate() 钩子来获取一个可以用来触发导航操作(即页面更改)的导航函数:
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
navigate('/orders');
// programmatic alternative to <Link to="/orders">
因此,之前提到的 OrdersSummary 组件可以调整如下以使用这个新的钩子:
function OrdersSummary() {
**const** **navigate =** **useNavigate****();**
const { quantity, total } = getOrdersSummaryData();
const formattedTotal = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(total);
function findOrderAction(formData) {
const orderId = formData.get('order-id');
const orderExists = orders.some((order) => order.id === orderId);
if (!orderExists) {
alert('Could not find an order for the entered id.');
return;
}
**navigate****(****`/orders/****${orderId}****`****);**
}
// returned JSX code did not change, hence omitted
}
值得注意的是,传递给 navigate() 的值是一个动态构造的字符串。程序化导航支持静态和动态路径。
注意
此示例的代码可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/11-programmatic-navigation 找到。
重定向
到目前为止,所有探索过的导航选项(链接和程序化导航)都将用户转发到特定页面。
在大多数情况下,这是预期的行为。但在某些情况下,目标是重定向用户而不是转发他们。
这种区别细微但很重要。当用户被转发时,他们可以使用浏览器的导航按钮(后退 和 前进)返回到上一页或跳转到他们来的页面。对于重定向,这是不可能的。无论何时用户被重定向到特定页面(而不是被转发),他们都不能使用 后退 按钮返回到上一页。
重定向用户,例如,可以确保用户在成功认证后无法返回登录页面。
当使用 React Router 时,默认行为是转发用户。但你可以通过向 Link(或 NavLink)组件添加特殊的 replace 属性来轻松切换到重定向,如下所示:
<Link to="/success" **replace**>Confirm Checkout</Link>
当使用程序化导航时,你可以向 navigate() 函数传递第二个可选参数。该第二个参数值必须是一个对象,该对象可以包含一个 replace 属性,如果你想重定向用户,则应将其设置为 true:
navigate('/dashboard', **{** **replace****:** **true** **}**);
能够重定向或转发用户,让你能够构建高度用户友好的网络应用程序,为不同场景提供最佳的用户体验。
处理未定义的路由
本章前面的部分都假设你已经有预定义的路由,这些路由应该可以被网站访客访问。但如果是访客输入了一个根本不支持 URL 呢?
例如,本章中使用的演示网站支持 /、/orders 和 /orders/<some-id> 路径。但它不支持 /home、/products/p1、/abc 或任何不是定义的路由路径的其他路径。
要显示自定义的 未找到 页面,你可以定义一个具有特殊路径的“捕获所有”路由——* 路径:
{ path: '*', element: <NotFound /> }
当将此路由添加到 App 组件的路由定义列表中时,如果没有其他路由与输入或生成的 URL 路径匹配,屏幕上将显示 NotFound 组件。
懒加载
在第十章 React 和优化机会背后的场景 中,你学习了懒加载——一种仅在需要时加载 React 应用程序代码片段的技术。
如果某些组件将条件性加载并且可能根本不需要,代码拆分就非常有意义。因此,路由是懒加载的完美场景。当应用程序有多个路由时,一些路由可能永远不会被用户访问。即使所有路由都被访问,也不必在应用程序加载时立即下载所有应用路由(即它们的组件)的代码。相反,当它们实际变为活动状态时,只下载单个路由的代码是有意义的。
幸运的是,React Router 内置了对懒加载和基于路由的代码拆分的支持。它提供了一个可以添加到路由定义中的 lazy 属性。该属性期望一个函数,该函数动态导入要懒加载的文件(其中包含应渲染的组件)。然后 React Router 负责其余工作——例如,你不需要将 Suspense 包装在任何组件周围:
import {
createBrowserRouter,
RouterProvider
} from 'react-router-dom';
import Root from './routes/Root.jsx';
import Dashboard from './routes/Dashboard.jsx';
// Removed static imports of Orders.jsx and OrderDetail.jsx
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{ index: true, element: <Dashboard /> },
{
path: '/orders',
**lazy****:** **() =>****import****(****'./routes/Orders.jsx'****)**
},
{
path: '/orders/:id',
**lazy****:** **() =>****import****(****'./routes/OrderDetail.jsx'****)**
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
export default App;
在这个例子中,/orders 和 /orders/:id 路由都设置为懒加载它们各自组件。
为了使上述代码正常工作,当使用此内置懒加载支持时,你必须对你的路由组件文件进行一项重要调整:你必须将默认组件函数导出(export default SomeComponent)替换为命名导出,其中组件函数被命名为 Component。
例如,Orders 组件代码需要修改为如下所示:
import OrdersList from '../components/orders/OrdersList.jsx';
import OrdersSummary from '../components/orders/OrdersSummary.jsx';
function Orders() {
return (
<>
<OrdersSummary />
<OrdersList />
</>
);
}
**export****const****Component** **=** **Orders****;** // named export as "Component"
在此代码片段中,Orders 组件函数被导出为 Component。由于 React Router 在激活懒加载路由时会寻找名为 Component 的组件函数,因此这个名称是必需的。
注意
该示例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/12-lazy-loading 找到。
如在第十章 React 和优化机会背后的场景 中所述,添加懒加载可以显著提高 React 应用程序的性能。你应该始终考虑使用懒加载,但不应为每个路由都使用它。对于那些保证会早期加载的路由,例如,这样做尤其没有逻辑。在前面的例子中,由于这是默认路由(路径为 /),对 Dashboard 组件进行懒加载并没有太多意义。
但对于那些根本不会被访问的路由(或者至少不是在网站加载后立即访问)是懒加载的理想候选者。
摘要和关键要点
-
路由是许多 React 应用程序的关键特性。
-
使用路由,用户可以在单页应用(SPA)中访问多个页面。
-
最常用的帮助路由的包是 React Router 库(
react-router-dom)。 -
路由是通过
createBrowserRouter()函数和RouterProvider组件定义的(通常在App组件或main.jsx文件中,但你可以在任何地方做这件事)。 -
路由定义对象通常通过一个
path(路由应该变得活跃的路径)和一个element(应该显示的内容)属性来设置。 -
内容和标记可以通过设置布局路由来在多个路由之间共享——即包裹其他嵌套路由的路由。
-
用户可以通过手动更改 URL 路径、点击链接或程序性导航在路由之间导航。
-
内部链接(即指向你定义的应用程序路由的链接)应通过
Link或NavLink组件创建,而指向外部资源的链接则使用标准的<a>元素。 -
程序性导航是通过由
useNavigate()钩子提供的navigate()函数触发的。 -
你可以定义静态和动态路由:静态路由是默认的,而动态路由是路径(在路由定义中)包含动态段的路由(由冒号表示,例如
:id)。 -
动态路径段的实际值可以通过
useParams()钩子提取。 -
你可以使用懒加载来仅在用户实际访问路由时加载特定路由的代码。
接下来是什么?
路由是 React 默认不支持的功能,但对于大多数 React 应用来说仍然很重要。这就是为什么它包含在这本书中,以及为什么存在 React Router 库。路由是一个关键概念,它完善了你关于最基本 React 想法和概念的知识,使你能够构建简单和复杂的 React 应用。
下一章基于本章内容,并更深入地探讨 React Router,探索其数据获取和处理能力。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/exercises/questions-answers.md找到的示例进行比较。:
-
路由与条件性加载内容有何不同?
-
路由是如何定义的?
-
你应该如何将链接添加到你的页面上的不同路由?
-
如何将动态路由(例如,许多产品之一的产品详情)添加到你的应用中?
-
如何提取动态路由参数值(例如,加载产品数据)?
-
嵌套路由的目的是什么?
应用所学知识
将你对路由的知识应用到以下活动中。
活动第 13.1 节:创建一个基本的三个页面网站
在此活动中,你的任务是创建一个全新的在线商店网站的基本初稿。该网站必须支持三个主要页面:
-
欢迎页面
-
一个显示可用产品列表的产品概览页面
-
一个产品详情页面,允许用户探索产品详情
最终网站的风格、内容和数据将由其他团队添加,但你应提供一些占位符数据和默认样式。你还必须在顶部添加一个共享的主导航栏并实现基于路由的懒加载。
完成的页面应如下所示:
图 13.12:欢迎页面。
图 13.13:显示一些占位符产品占位符的页面。
图 13.14:带有一些占位符数据和样式的最终产品详情页面。
注意
对于此活动,你当然可以自己编写所有 CSS 样式。但如果你想要专注于 React 和 JavaScript 逻辑,你也可以使用解决方案中的完成 CSS 文件,位置在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/13-routing/activities/practice-1/src/index.css。
如果你使用那个文件,请仔细探索以确保你理解可能需要添加到解决方案中某些 JSX 元素的哪些 ID 或 CSS 类。你也可以使用解决方案的占位符数据而不是创建自己的占位符产品数据。你可以在这个位置找到这些数据:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/13-routing/activities/practice-1/src/data/products.js。
要完成此活动,解决方案步骤如下:
-
创建一个新的 React 项目并安装 React Router 包。
-
创建组件(如前一个屏幕截图所示),这些组件将被加载到三个必需的页面中。
-
启用路由并添加三个页面的路由定义。
-
添加一个对所有页面都可见的主导航栏。
-
添加所有必要的链接并确保导航栏链接反映页面是否处于活动状态。
-
实现懒加载(对于有意义的路由)。
注意
此活动的完整代码和解决方案可以在以下位置找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/activities/practice-1。