React-挂钩学习指南第二版-全-
React 挂钩学习指南第二版(全)
原文:
zh.annas-archive.org/md5/e3f80e0bbd9c0adfcf30deda2265e9fb译者:飞龙
前言
你好——我是丹尼尔,一个企业家、软件开发顾问和全栈开发者,专注于 React 生态系统中的技术。
在我作为软件开发顾问和为企业及公共部门工作的开发者期间,我观察到的一个共同挑战是:由于缺乏深入理解,开发者往往难以掌握高级 React 概念。Hooks 尤其是一个很大的困惑来源。通常,很难知道最佳实践是什么,以及如何最好地使用 React 和 React Hooks 来构建和设计应用程序。
在这本书中,我想教你构建现代且可维护的前端所需的所有知识,我会从零开始教授 Hooks,以确保你理解它们的限制和优势所在。我会涵盖我在职业生涯中经常遇到的各种常见用例,例如管理应用程序状态、何时以及如何使用 React Contexts、数据获取、表单处理和路由。我还会教你何时以及如何构建自己的 Hooks,以保持应用程序的可维护性并高效地在多个组件之间重用逻辑。在我看来,自定义 Hooks 在项目中往往被低估,但它们可以带来巨大的价值,尤其是在大型项目中。
所有这些概念都将通过实际示例进行教学,以便你能够立即看到它们的应用,并在自己的项目中开始使用它们。我真诚地希望你喜欢阅读这本书。如果你有任何问题或反馈,请随时与我联系!
这本书面向的对象
这本书是为已经知道如何使用 React 的开发者准备的,他们想深入了解 React Hooks 以及与之相关的现代技术,如表单操作、Context 和 Suspense。即使你已经了解 Hooks,这本书也会教你它们是如何内部工作的,以便你能更深入地理解它们。此外,你还将学习一些技巧和窍门,以及如何有效地开发 React 应用程序的最佳实践。
这本书涵盖的内容
第一章,介绍 React 和 React Hooks,涵盖了 React 和 React Hooks 的基本原理,以及如何使用 React 设置一个现代项目。
第二章,使用 State Hook,通过重新实现和使用 State Hook 来深入解释 Hooks 的工作原理,在学习过程中了解 Hooks 的限制。
第三章,使用 React Hooks 编写您的第一个应用程序,将我们从前两章学到的知识付诸实践,通过创建一个使用 React Hooks 的博客应用程序来应用这些知识。
第四章,使用 Reducer 和 Effect Hooks,介绍了这两个基本 Hooks,重点关注在博客应用程序中实现它们时何时以及如何使用它们。
第五章,实现 React 上下文,解释了 React Context 及其在应用程序中的应用,以及与 Hooks 结合使用。
第六章,使用 Hooks 和 React Suspense 进行数据获取,涵盖了使用 Effect 和 State Hooks 从服务器请求资源。然后,我们学习如何使用 TanStack Query、React Suspense 和错误边界更有效地请求资源。
第七章,使用 Hooks 处理表单,深入探讨了使用 React 处理表单,特别是关注新的范式,如表单操作、过渡和乐观更新。
第八章,使用 Hooks 进行路由,介绍了 React Router,并展示了如何使用 Hooks 从路由中获取参数,以及触发动态路由变化。
第九章,React 提供的高级 Hooks,概述了 React 提供的所有内置 Hooks,重点关注书中尚未涵盖的所有高级 Hooks。
第十章,使用社区 Hooks,概述了 React 社区提供的各种有用 Hooks,以及如何找到更多 Hooks 的信息。
第十一章,Hooks 规则,教你开始构建自己的 Hooks 所需了解的规则。
第十二章,构建自己的 Hooks,介绍了如何通过将现有逻辑提取到 Hook 中来构建自己的自定义 Hooks。通过了解何时以及如何创建自定义 Hooks,你将能够以可扩展的方式重构和维护你的应用程序。
第十三章,从 React 类组件迁移,提供了一篇关于如何有效地将现有应用程序从 React 类组件迁移到 React Hooks 的指南。
为了充分利用这本书
应该已经安装了相当新版本的 Node.js。Node 包管理器(npm)也需要安装(它应该随 Node.js 一起提供)。有关如何安装 Node.js 的更多信息,请访问他们的官方网站:nodejs.org/。
我们将在本书的指南中使用Visual Studio Code(VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com。
在这本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npm v10.9.2
-
VS Code v1.97.2
上列版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在使用本书提供的代码和步骤时遇到问题,请尝试使用提到的版本。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781836209171。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“然后,我们定义componentDidMount生命周期方法,从 API 中获取数据。”
代码块设置如下:
fetchData() {
fetch(`http://my.api/${this.props.name}`)
.then(...)
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
componentDidMount() {
**this****.****fetchData****()**
}
componentDidUpdate(prevProps) {
if (this.props.name !== prevProps.name) {
**this****.****fetchData****()**
}
}
任何命令行输入或输出都应如下编写:
$ npm create vite@6.2.0 .
粗体: 表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会像这样显示。例如:“应该打开一个侧边栏,您可以在顶部看到市场中的搜索扩展。在此处输入扩展名,然后点击安装来安装它。”
警告或重要提示看起来像这样。
提示和技巧看起来像这样。
联系我们
欢迎读者反馈。
一般反馈: 请通过电子邮件发送至feedback@packtpub.com,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过电子邮件发送至questions@packtpub.com。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com/。
分享您的想法
一旦您阅读了《Learn React Hooks》第二版,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但又无法随身携带您的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,您每购买一本 Packt 书籍,都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接:

packt.link/free-ebook/9781836209171
-
提交您的购买证明。
-
就这些了!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一部分
钩子简介
在本部分中,你将学习如何设置一个现代的 React 项目,以及了解 React 和 React 钩子的基础知识。你还将学习为什么以及如何使用钩子。在本部分的最后,你将构建一个博客应用程序,这将为本书中所有后续章节奠定基础。在这个过程中,你还将了解最新的 JavaScript 和 React 功能。
本部分包含以下章节:
-
第一章, 介绍 React 和 React 钩子
-
第二章, 使用 State 钩子
-
第三章, 使用 React 钩子编写你的第一个应用程序
第一章:介绍 React 和 React Hooks
React 是一个用于构建高效和可扩展 Web 应用的 JavaScript 库。React 由 Meta 开发,并被用于许多大型 Web 应用程序中,如 Facebook、Instagram、Netflix、Shopify、Airbnb、Cloudflare 和 BBC。
在本书中,我们将学习如何使用 React 构建复杂且高效的用户界面,同时保持代码简单和可扩展。通过 React Hooks 的范式,我们可以极大地简化处理 Web 应用程序中的状态和副作用,确保应用程序未来有增长和扩展的潜力。我们还将了解 React Context、React Suspense 和 表单操作,以及它们如何与 Hooks 一起使用。最后,我们将学习如何构建自己的 Hooks 以及如何将现有应用程序从 React 类组件迁移到基于 React Hooks 的架构。
在本章中,我们将学习 React 和 React Hooks 的基本原理。我们将从了解 React 和 React Hooks 是什么以及为什么我们应该使用它们开始。然后,我们将继续学习 Hooks 的内部工作原理。最后,您将了解 React 提供的 Hooks 以及社区提供的几个 Hooks,例如数据获取和路由 Hooks。通过学习 React 和 React Hooks 的基础知识,我们将更好地理解本书中将要介绍的概念。
在本章中,我们将涵盖以下主要主题:
-
React 原理
-
使用 React Hooks 的动机
-
设置开发环境
-
开始使用 React Hooks
技术要求
应该已经安装了相当新版本的 Node.js。Node 包管理器 (npm) 也需要安装(它应该随 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请查看他们的官方网站:nodejs.org/.
在本书的指南中,我们将使用 Visual Studio Code (VS Code),但所有内容在任何其他编辑器中都应该类似。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com.
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
上列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能会有所不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter01.
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
React 原则
在我们开始学习如何设置 React 项目之前,让我们回顾一下 React 的三个基本原理。这些原理使我们能够轻松编写可扩展的 Web 应用程序。
React 基于三个基本原理:
-
声明式:我们不是告诉 React 如何做事,而是告诉它我们想要它做什么。例如,如果我们更改数据,我们不需要告诉 React 哪些组件需要重新渲染。这会很复杂且容易出错。相反,我们只需告诉 React 数据已更改,所有使用此数据的相关组件都将被高效更新并为我们重新渲染。React 会处理细节,这样我们就可以专注于手头的任务,轻松开发我们的 Web 应用程序。
-
组件化:React 封装了管理自身状态和视图的组件,然后允许我们将它们组合起来以创建复杂用户界面。
-
一次学习,到处编写:React 不对您的技术栈做出假设,并试图确保您可以在不尽可能重写现有代码的情况下开发应用程序。
React 的三个基本原理使编写代码、封装组件以及在多个平台上共享代码变得容易。React 总是试图尽可能多地利用现有的 JavaScript 功能,而不是重新发明轮子。因此,我们将学习适用于许多更多情况的软件设计模式,而不仅仅是设计用户界面。
我们刚刚了解到 React 是基于组件的。在 React 中,有两种类型的组件:
-
函数组件:接受 props 作为参数的 JavaScript 函数,并返回用户界面(通常通过 JSX,它是 JavaScript 语法的一个扩展,允许我们在 JavaScript 代码中直接编写类似 HTML 的标记)
-
类组件:提供
render方法的 JavaScript 类,该方法返回用户界面(通常通过 JSX)
虽然函数组件更容易定义和理解,但在过去,处理状态、上下文以及许多其他 React 高级功能需要类组件。然而,随着 React Hooks 的出现,我们可以使用 React 的大多数高级功能,而无需类组件!
在编写本文时,React 拥有一些特性,这些特性目前还不能通过函数组件和 Hooks 实现。例如,定义错误边界仍然需要类组件以及 componentDidCatch 和 getDerivedStateFromError 生命周期方法。
使用 React Hooks 的动机
React 总是努力使开发者体验尽可能顺畅,同时确保性能足够好,让开发者无需过多担心如何优化性能。然而,在多年的 React 使用过程中,已经识别出了一些问题。
在以下子节中的代码片段仅旨在让您了解为什么需要 Hooks,通过给出开发者以前如何处理 React 中某些问题的示例。如果您不熟悉这些旧方法,请不要担心,了解旧方法并不是继续学习所必需的。在接下来的章节中,我们将学习如何使用 React Hooks 以更好的方式处理这些问题。
现在,让我们在以下子节中查看这些问题。
令人困惑的类
在过去,我们必须使用具有特殊函数的生命周期方法(如 componentDidUpdate)和特殊的状态处理方法(如 this.setState)来处理状态变化。React 类,尤其是 this 上下文,对于开发者和机器来说都很难阅读和理解。
this 是 JavaScript 中一个特殊的保留字,它始终指向它所属的对象:
-
在方法中,
this指的是类对象(类的实例)。 -
在事件处理程序中,
this指的是接收事件的元素。 -
在函数或独立存在时,
this指的是全局对象。例如,在浏览器中,全局对象是Window对象。 -
在严格模式下,函数中的
this是undefined。 -
此外,
call()和apply()等方法可以改变this指向的对象,因此它可以指向任何对象。
对于开发者来说,类很难,因为 this 总是指向不同的事物,所以有时(例如在事件处理程序中)我们需要手动将其重新绑定到类对象上。对于机器来说,类也很难,因为它们不知道类中哪些方法会被调用以及 this 将如何被修改,这使得优化性能和删除未使用的代码变得困难。
此外,类有时要求我们在多个地方同时编写代码。例如,如果我们想在组件渲染时获取数据或组件的 props 发生变化时,我们需要使用两种方法来完成:一次在 componentDidMount 中,一次在 componentDidUpdate 中。
为了举例说明,让我们定义一个从 API 获取数据的类组件:
-
首先,我们通过扩展
React.Component类来定义一个类组件:class ExampleComponent extends React.Component { -
然后,我们定义
componentDidMount生命周期方法,在那里我们从 API 拉取数据:componentDidMount() { fetch(`http://my.api/${this.props.name}`) .then(…) } -
然而,我们还需要定义
componentDidUpdate生命周期方法,以防nameprop 发生变化。此外,我们还需要在这里添加一个手动检查,以确保我们仅在nameprop 发生变化时重新获取数据,而不是在其他 props 发生变化时:componentDidUpdate(prevProps) { if (this.props.name !== prevProps.name) { fetch(`http://my.api/${this.props.name}`) .then(...) } } } -
为了使代码不那么重复,我们可以通过创建一个名为
fetchData的单独方法来重构我们的代码,并如下获取数据:fetchData() { fetch(`http://my.api/${this.props.name}`) .then(...) } -
然后,我们可以在
componentDidMount和componentDidUpdate中调用该方法:componentDidMount() { **this****.****fetchData****()** } componentDidUpdate(prevProps) { if (this.props.name !== prevProps.name) { **this****.****fetchData****()** } }
然而,即使如此,我们仍然需要在两个地方调用方法。每当我们需要更新传递给方法的参数时,我们都需要在两个地方更新它们,这使得这种模式非常容易出错,并且可能导致未来的错误。
包装地狱
假设我们已实现了一个添加认证到我们组件之一的authenticateUser高阶组件函数,以及一个名为AuthenticationContext的上下文,通过渲染属性提供有关认证用户的信息。然后,我们会如下使用此上下文:
-
我们首先导入
authenticateUser函数,用上下文包装我们的组件,并导入AuthenticationContext组件以便能够访问上下文:import authenticateUser, { AuthenticationContext } from './auth' -
然后,我们定义一个
App组件,在其中我们使用AuthenticationContext.Consumer组件和user渲染属性:const App = () => ( <AuthenticationContext.Consumer> {user =>
渲染属性是将属性传递到组件子组件的一种方式。正如我们所见,渲染属性允许我们将user传递给AuthenticationContext.Consumer组件的子组件。
-
现在,我们根据用户是否登录显示不同的文本:
user ? `${user} logged in` : 'not logged in' }
在这里,我们使用了两个 JavaScript 概念:
-
三元运算符是
if条件的内联版本。它看起来如下:ifThisIsTrue ? returnThis : otherwiseReturnThis。 -
模板字符串可以用来在字符串中插入变量。它使用反引号(
`)而不是普通单引号(')来定义。变量可以通过${variableName}语法插入。我们也可以在${}括号内使用任何 JavaScript 表达式——例如,${someValue + 1}。
-
最后,通过使用高阶组件模式,我们在将
authenticateUser上下文包装到组件后导出组件:</AuthenticationContext.Consumer> ) export default authenticateUser(App)
高阶组件是包装组件并为其添加功能的函数。在 Hooks 出现之前,它们被用来封装和重用状态管理逻辑。
在这个例子中,我们使用了authenticateUser高阶组件函数来为我们现有的组件添加认证逻辑。然后,我们使用AuthenticationContext.Consumer通过其渲染属性将user属性注入到我们的组件中。
如你所想,使用许多高阶组件会导致一个具有许多子树的庞大树,这是一种称为包装地狱的反模式。例如,当我们想要使用三个上下文时,包装地狱看起来如下:
<AuthenticationContext.Consumer>
{user => (
<LanguageContext.Consumer>
{language => (
<StatusContext.Consumer>
{status => (
...
)}
</StatusContext.Consumer>
)}
</LanguageContext.Consumer>
)}
</AuthenticationContext.Consumer>
这并不容易阅读或编写,如果以后需要更改某些内容,也容易出错。包装地狱使得调试变得困难,因为我们需要查看一个具有许多仅作为包装器的组件的大型组件树。
现在我们已经了解了 React 的一些常见问题,让我们学习 Hook 模式,以便更好地处理这些问题!
Hooks 来拯救!
React Hooks 基于与 React 相同的基本原则。它们通过使用现有的 JavaScript 特性来封装状态管理。因此,我们不再需要学习和理解许多专门的 React 特性;我们只需利用我们现有的 JavaScript 知识来使用 Hooks。
使用 Hooks,我们可以为之前提到的问题提出更好的解决方案。Hooks 简单来说就是可以在函数组件中调用的函数。我们也不再需要使用 render props 来处理上下文,因为我们可以直接使用 Context Hook 来获取所需的数据。此外,Hooks 允许我们在组件之间重用有状态的逻辑,而无需创建高阶组件。
例如,可以使用 Effect Hook 解决前面提到的生命周期方法的问题,如下所示:
function ExampleComponent({ name }) {
**useEffect****(****() =>** **{**
**fetch****(****`http://my.api/****${name}****`****)**
**.****then****(...)**
**}, [name])**
// ...
}
这个 Effect Hook 将在组件挂载时自动触发,并且每当 name 属性发生变化时。
如前所述的包装地狱问题可以使用 Context Hooks 解决,如下所示:
const user = useContext(AuthenticationContext)
const language = useContext(LanguageContext)
const status = useContext(StatusContext)
如我们所见,通过使用 Hooks,我们可以保持代码的整洁和简洁,确保我们的代码易于阅读和维护。编写自定义 Hooks 也有助于在项目中重用应用程序逻辑。
现在我们知道了 Hooks 可以解决哪些问题,我们可以开始在实际中使用它们。然而,首先,我们需要快速设置我们的开发环境。
设置开发环境
在本书中,我们将使用 VS Code 作为我们的代码编辑器。请随意使用您偏好的任何编辑器,但请注意,您选择的编辑器中使用的扩展和配置的设置可能略有不同。
现在我们来安装 VS Code 和一些有用的扩展,然后继续设置我们开发环境所需的所有工具。
安装 VS Code 和扩展
在我们开始开发和设置其他工具之前,我们需要按照以下步骤设置我们的代码编辑器:
-
请从官方网站(截至编写时,网址为
code.visualstudio.com/)下载适用于您的操作系统的 VS Code。本书中将使用版本 1.97.2。 -
下载并安装应用程序后,打开它,您应该会看到以下窗口:

图 1.1 – 在 macOS 上 Visual Studio Code 的新安装
- 为了让事情更容易,我们将安装一些扩展,所以点击 Extensions 图标,这是截图左上角的第五个图标。
应该会打开一个侧边栏,您可以在顶部看到 Search Extensions in Marketplace。在此处输入扩展名称,然后点击 Install 来安装它。让我们先安装 ESLint 扩展:

图 1.2 – 在 Visual Studio Code 中安装 ESLint 扩展
-
确保安装以下扩展:
-
ESLint(由 Microsoft 提供)
-
Prettier – 代码格式化工具(由 Prettier 提供)
-
支持 JavaScript 和 Node.js 已经内置在 VS Code 中。
-
为这本书中制作的项目创建一个文件夹(例如,您可以将其命名为
Learn-React-Hooks-Second-Edition)。在这个文件夹内部,创建一个名为Chapter01_1的新文件夹。 -
在 VS Code 中打开空的
Chapter01_1文件夹。 -
如果出现一个对话框询问你信任此文件夹中文件的作者吗?,请选择信任父文件夹‘Learn-React-Hooks’中的所有文件的作者,然后点击是,我信任作者按钮。

图 1.3 – 允许 VS Code 在项目文件夹中执行文件
在您自己的项目中,您可以安全地忽略此警告,因为您可以确信这些项目中不包含恶意代码。当从不受信任的来源打开文件夹时,您可以按不,我不信任作者,仍然浏览代码。然而,这样做时,VS Code 的一些功能将被禁用。
我们现在已经成功设置了 VS Code,并准备好开始设置我们的项目!如果您从 GitHub 提供的代码示例中克隆了文件夹,也会弹出一个通知,告诉您找到了 Git 仓库。您可以简单地关闭它,因为我们只想打开Chapter01_1文件夹。
现在 VS Code 已经准备好了,让我们继续通过使用 Vite 设置一个新的项目。
使用 Vite 设置项目
对于这本书,我们将使用Vite来设置我们的项目,因为它是最受欢迎和最受欢迎的本地开发服务器,根据The State of JS 2024调查(2024.stateofjs.com/)。
Vite 还使得设置现代前端项目变得容易,同时如果需要,还可以稍后扩展配置。按照以下步骤使用 Vite 设置您的项目:
-
在 VS Code 菜单栏中,转到终端 | 新建终端以打开一个新的终端。
-
在终端内部,运行以下命令:
$ npm create vite@6.2.0 .
$符号表示这是一个需要输入到终端中的命令。将$符号之后的所有内容输入到终端中,并使用Return/Enter确认以运行命令。确保命令末尾有一个句点,以便在当前文件夹中创建项目,而不是创建一个新的文件夹。
为了确保即使新版本发布,本书中的说明仍然有效,我们将所有包固定到特定版本。请按照给定的版本执行说明。完成本书后,当你自己开始新项目时,你应该始终尝试使用最新版本,但请注意,可能需要进行一些更改才能使其工作。请查阅相应包的文档,并遵循从本书版本到最新版本的迁移路径。
-
当被问及是否应安装
create-vite时,只需输入y并按 Return/Enter 键继续。 -
如果被询问当前目录不为空,选择 删除现有文件并继续 选项,然后按 Return/Enter 确认。
-
当被要求输入包名时,通过按 Return/Enter 确认默认建议。
-
当被询问框架时,使用箭头键选择 React 并按 Return/Enter。
-
当被问及变体时,选择 JavaScript。
为了简单起见,并为了满足更广泛的受众,本书中我们只使用了纯 JavaScript。需要注意的是,如今 TypeScript 在许多项目中得到了广泛应用,所以你可能希望在将来的项目中考虑采用 TypeScript。然而,学习 TypeScript 超出了本书的范围。
-
编辑
package.json并确保dependencies和devDependencies的版本如下:"dependencies": { "react": "19.0.0", "react-dom": "19.0.0" }, "devDependencies": { "@eslint/js": "9.19.0", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", "@vitejs/plugin-react": "4.3.4", "eslint": "9.19.0", "eslint-plugin-react": "7.37.4", "eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-react-refresh": "0.4.18", "globals": "15.14.0", "vite": "6.1.0" } -
现在我们的项目已设置好,我们可以在终端中运行
npm install来安装依赖项。 -
之后,运行
npm run dev来启动开发服务器,如下截图所示:

图 1.4 – 使用 Vite 设置项目后的终端,在启动开发服务器之前
为了设置简单,我们直接使用了 npm。如果你更喜欢 yarn 或 pnpm,你可以分别运行 yarn create vite 或 pnpm create vite。
-
在终端中,你会看到一个 URL,告诉你你的应用正在运行的位置。你可以通过按住 Ctrl (Cmd 在 macOS 上) 并点击链接在浏览器中打开它,或者手动在浏览器中输入 URL。现在在浏览器中打开链接。
-
要测试你的应用是否是交互式的,点击带有文本 计数为 0 的按钮,每次点击它都应该增加计数。

图 1.5 – 使用 Vite 运行的我们的第一个 React 应用
Vite 的替代方案
Vite 的替代品是 webpack、Rollup 和 Parcel 等打包器。这些打包器高度可配置,但通常不提供出色的开发服务器体验。它们必须首先将所有我们的代码打包在一起,然后再将其提供给浏览器。相反,Vite 原生支持ECMAScript Module(ESM)标准。此外,Vite 启动时几乎不需要配置。Vite 的一个缺点是,用它配置某些更复杂的场景可能很困难。一个有希望的即将到来的打包器是 Rolldown (rolldown.rs);然而,在撰写本文时,它仍然非常新。
现在我们已经启动并运行了样板项目,让我们花些时间设置一些工具,这些工具将强制执行最佳实践和一致的代码风格。
设置 ESLint 和 Prettier 以强制执行最佳实践和代码风格
现在我们已经设置了 React 应用程序,我们将设置ESLint以使用 JavaScript 和 React 强制执行编码最佳实践。我们还将设置Prettier以强制执行代码风格并自动格式化我们的代码。
安装必要的依赖项
首先,我们将安装所有必要的依赖项。
-
在终端中,点击终端面板右上角的Split Terminal图标以创建一个新的终端面板(或者,在终端面板上右键单击并选择Split Terminal)。这将保持我们的应用程序运行,同时我们可以运行其他命令。
-
点击这个新打开的面板以将其聚焦。然后,输入以下命令来安装 Prettier 和 Prettier 的 ESLint 配置:
$ npm install --save-dev --save-exact prettier@3.5.1 eslint-config-prettier@10.0.1在
npm中使用--save-dev标志将那些依赖项保存为dev依赖项,这意味着它们将仅用于开发。它们不会被安装并包含在部署的应用程序中。--save-exact标志确保版本被固定为书中提供的确切版本。
依赖项安装完成后,我们需要配置 Prettier 和 ESLint。我们将从配置 Prettier 开始。
配置 Prettier
Prettier 将为我们格式化代码,并替换 VS Code 中 JavaScript 的默认代码格式化器。它将允许我们花更多的时间编写代码,在保存文件时自动为我们正确地格式化代码。按照以下步骤配置 Prettier:
-
在 VS Code 左侧侧边栏的文件列表下方(如果未打开,请点击Files图标)右键单击,然后点击New file...来创建一个新文件。将其命名为
.prettierrc.json(不要忘记文件名开头的点!)。 -
新创建的文件应自动打开;开始将以下配置写入其中。我们首先创建一个新的对象,并将
trailingComma选项设置为all,以确保跨越多行的对象和数组始终在末尾有逗号,即使是最后一个元素。这减少了通过 Git 提交更改时需要修改的行数:{ "trailingComma": "all", -
然后,我们将
tabWidth选项设置为两个空格:"tabWidth": 2, -
将
printWidth设置为每行 80 个字符,以避免代码中出现长行:"printWidth": 80, -
将
semi选项设置为false以避免在不必要的地方使用分号:"semi": false, -
最后,我们强制使用单引号而不是双引号:
"jsxSingleQuote": true, "singleQuote": true }这些 Prettier 设置只是编码风格约定的一个示例。当然,您可以自由调整以符合您的个人喜好。还有更多选项,所有这些都可以在 Prettier 文档中找到(
prettier.io/docs/en/options.html)。
配置 Prettier 扩展
现在我们已经有了 Prettier 的配置文件,我们需要确保 VS Code 扩展已正确配置以为我们格式化代码:
-
通过在 Windows/Linux 上转到文件 | 首选项... | 设置或在 macOS 上转到代码 | 设置... | 设置来打开 VS Code 设置。
-
在新打开的设置编辑器中,点击Workspace标签。这确保我们将所有设置保存在项目文件夹中的
.vscode/settings.json文件中。当其他开发者打开我们的项目时,他们也会自动使用这些设置。 -
搜索
editor format on save并勾选复选框以启用保存时格式化代码。 -
在列表中搜索
editor default formatter并选择Prettier - Code formatter。 -
要验证 Prettier 是否正常工作,打开
.prettierrc.json文件,在行首添加一些额外的空格,并保存文件。您应该注意到 Prettier 已重新格式化代码以符合定义的代码风格。它将缩进空格的数量减少到 2。
现在 Prettier 已经正确设置,我们不再需要手动格式化代码。您可以随时输入代码并保存文件,Prettier 会自动为您格式化!
创建 Prettier 忽略文件
为了提高性能并避免在不需要自动格式化的文件上运行 Prettier,我们可以通过创建 Prettier 忽略文件来忽略某些文件和文件夹。按照以下步骤操作:
-
在项目根目录中创建一个名为
.prettierignore的新文件,类似于我们创建.prettierrc.json文件的方式。 -
添加以下内容以忽略转译的源代码:
dist/
node_modules/文件夹会自动被 Prettier 忽略。
现在我们已经成功设置了 Prettier,我们将配置 ESLint 以强制执行编码最佳实践。
配置 ESLint
虽然 Prettier 关注代码的风格和格式,但 ESLint 关注实际的代码,避免常见的错误或不必要的代码。现在让我们来配置它:
-
打开自动创建的
eslint.config.js文件,并向其中添加以下导入:import prettierConfig from 'eslint-config-prettier' -
将文件滚动到末尾,并在数组末尾添加 Prettier 配置,如下所示:
'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, **prettierConfig,** ] -
此外,禁用
react/prop-types规则,如下所示:'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], **'****react/prop-types'****:** **'off'****,** }, }, prettierConfig, ]
自 React 19 以来,属性类型检查已被完全移除,并且将被静默忽略。向 props 添加类型检查的唯一方法是使用完整的类型检查解决方案,例如 TypeScript。由于我们在这本书中专注于学习带有 Hooks 的纯 React,因此使用 TypeScript 不在范围之内。然而,如果你还没有学习 TypeScript,我强烈建议你在完成这本书后自学 TypeScript。
- 保存文件,并在终端中运行
npx eslint src以运行代码检查器。你会看到没有输出,这意味着一切都被代码检查器成功检查,没有错误!
npx 命令允许我们在类似在 package.json 脚本中运行它们的环境中执行 npm 包提供的命令。它还可以运行远程包而无需永久安装。如果包尚未安装,它会询问你是否应该这样做。
添加一个新的脚本来运行我们的代码检查器
在上一节中,我们通过手动运行 npx eslint src 来调用代码检查器。我们现在将向 package.json 中添加一个 lint 脚本:
-
在终端中,运行以下命令以在
package.json文件中定义一个代码检查脚本:$ npm pkg set scripts.lint="eslint src" -
现在,在终端中运行
npm run lint。这应该会成功执行eslint src,就像之前使用npx eslint src一样:

图 1.6 – 代码检查器成功运行,没有错误
现在我们已经成功设置了我们的开发环境,让我们继续学习如何在实践中使用 React 类组件与 React Hooks!
示例代码
本节示例代码可在 Chapter01/Chapter01_1 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
React Hooks 入门
正如我们在本章前面所学,React Hooks 解决了许多问题,尤其是在大型网络应用程序中。Hooks 是在 React 16.8 中添加的,它们允许我们使用状态,以及各种其他 React 功能,而无需编写类。在本节中,我们将首先定义一个类组件,然后我们将使用 Hooks 将相同的组件编写为函数组件。然后我们将讨论 Hooks 的优点以及如何从类迁移到基于 Hooks 的解决方案。
从类组件开始
让我们先创建一个传统的 React 类组件,它允许我们输入一个名称;然后这个名称将在我们的应用中显示:
-
将
Chapter01_1文件夹复制到新的Chapter01_2文件夹中,如下所示:$ cp -R Chapter01_1 Chapter01_2在 macOS 上,运行带有大写
-R标志的命令很重要,而不是-r。-r标志对符号链接的处理方式不同,会导致node_modules/文件夹损坏。-r标志仅出于历史原因存在,不应在 macOS 上使用。始终优先使用-R标志。 -
在 VS Code 中打开新的
Chapter01_2文件夹。 -
删除
src/assets/文件夹及其所有内容。 -
删除
src/App.css和src/index.css文件。 -
打开
src/main.jsx文件,并删除以下导入:import './index.css' -
此外,将
App组件的导入从默认导入更改为命名导入,如下所示:import **{** App **}** from './App.jsx'在大多数情况下,使用命名导出/导入比使用默认导出/导入更可取。使用命名导出/导入在重构代码时更不容易出错。例如,让我们假设你有一个
Login组件,并将其复制粘贴到一个新的Register组件中,但忘记将组件重命名为Register。使用默认导入,仍然可以将其导入为Register,尽管组件内部称为Login。然而,当在 React 开发者工具中进行调试或试图在项目中找到该组件时,你会看到它命名为Login,这可能会造成混淆,尤其是在大型项目中。在处理函数时,使用命名导出甚至更有用,因为它允许你轻松地在不同的文件中移动它们。 -
打开
src/App.jsx文件,并从中删除所有现有代码。 -
接下来,我们按照以下方式导入 React:
import React from 'react' -
然后,我们开始定义一个类组件:
export class App extends React.Component { -
接下来,我们必须定义一个
constructor方法,在其中设置初始的state对象,它将是一个空字符串。在这里,我们还需要确保调用super(props),以便让React.Component构造函数了解props对象:constructor(props) { super(props) this.state = { name: '' } } -
现在,我们定义一个方法来设置
name变量,通过使用this.setState。由于我们将使用该方法处理文本字段的输入,我们需要使用evt.target.value从输入字段获取值:handleChange(evt) { this.setState({ name: evt.target.value }) } -
然后,我们定义
render方法,在其中我们将显示一个输入字段和名称:render() { -
要从
this.state对象中获取name变量,我们将使用解构:const { name } = this.state
上述语句相当于执行以下操作:
const name = this.state.name
-
然后,我们显示当前输入的
name状态变量:return ( <div> <h1>My name is: {name}</h1> -
我们显示一个输入字段,并将处理程序方法传递给它:
<input type='text' value={name} onChange={this.handleChange} /> </div> ) } }
如果我们现在运行此代码,在输入文本时会出现以下错误,因为将处理程序方法传递给onChange会改变this上下文:
Uncaught TypeError: Cannot read properties of undefined (reading 'setState')
或者,在某些浏览器上,你可能得到以下错误:
TypeError: undefined is not an object (evaluating 'this.setState')
-
因此,现在我们需要调整
constructor方法,并将我们的处理程序方法的this上下文重新绑定到类上。编辑src/App.jsx并在构造函数中添加以下行:constructor(props) { super(props) this.state = { name: '' } **this****.****handleChange** **=** **this****.****handleChange****.****bind****(****this****)** } -
通过打开终端(在 VS Code 中,选择终端 | 新终端菜单选项),并执行以下命令来运行开发服务器:
$ npm run dev -
在浏览器中打开开发服务器的链接,你应该会看到组件正在渲染。现在尝试输入一些文本,它应该可以工作!
或者,自 ES6 以来,可以使用箭头函数作为类方法来避免重新绑定this上下文。
最后,我们的组件工作正常了!正如你所见,要使状态处理在类组件中正常工作,需要编写大量的代码。我们还必须重新绑定this上下文;否则,我们的处理方法将无法工作。这并不直观,而且在开发过程中很容易忽略,导致开发者体验不佳。
示例代码
本节示例代码位于Chapter01/Chapter01_2文件夹中。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
使用 Hooks 代替
在使用传统的类组件创建我们的应用后,我们将使用 Hooks 来编写相同的应用。和之前一样,我们的应用将允许我们输入一个名字,然后我们在应用中显示这个名字。
只能在 React 函数组件中使用 Hooks。你无法在 React 类组件中使用 Hooks。
按照以下步骤开始:
-
将
Chapter01_2文件夹复制到新的Chapter01_3文件夹中,如下所示:$ cp -R Chapter01_2 Chapter01_3 -
在 VS Code 中打开新的
Chapter01_3文件夹。 -
打开
src/App.jsx文件,并删除其中的所有现有代码。 -
首先,我们按照以下方式导入
useStateHook:import { useState } from 'react' -
我们从函数定义开始。在我们的例子中,我们不传递任何参数,因为我们的组件没有任何属性:
export function App() {
下一步是从组件状态中获取name变量。然而,我们无法在函数组件中使用this.state。我们已经了解到 Hooks 只是 JavaScript 函数,但这究竟意味着什么呢?这意味着我们可以像使用任何其他 JavaScript 函数一样,简单地从函数组件中使用 Hooks!
要通过 Hooks 使用状态,我们调用useState(),并将初始状态作为参数。这个函数返回一个包含两个元素的数组:
-
当前状态
-
用于设置状态的设置函数
-
我们可以使用解构来将这些两个元素存储在单独的变量中,如下所示:
const [name, setName] = useState('')
之前的代码等同于以下代码:
const nameHook = useState('')
const name = nameHook[0]
const setName = nameHook[1]
-
现在,我们定义输入处理函数,其中我们使用了
setName设置函数:function handleChange(evt) { setName(evt.target.value) }
由于我们现在不处理类,因此不再需要重新绑定this。
-
最后,我们通过从函数中返回它来渲染用户界面:
return ( <div> <h1>My name is: {name}</h1> <input type='text' value={name} onChange={handleChange} /> </div> ) }
就这样 – 我们已经成功首次使用了 Hooks!正如你所见,useStateHook 是this.state和this.setState的简单替代品。
通过在终端中执行npm run dev并打开浏览器中的 URL 来运行我们的应用:

图 1.7 – 使用 Hooks 的我们的第一个 React 应用!
在使用类组件和函数组件实现相同的应用后,让我们比较一下解决方案。
示例代码
本节示例代码位于Chapter01/Chapter01_3文件夹中。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
比较解决方案
让我们比较我们的两种解决方案,看看类组件和函数组件使用 Hooks 之间的区别。
类组件
类组件利用 constructor 方法来定义状态,并且需要重新绑定 this 以便能够将处理方法传递给 input 字段。完整的类组件代码如下:
import React from 'react'
export class App extends React.Component {
constructor(props) {
super(props)
this.state = { name: '' }
this.handleChange = this.handleChange.bind(this)
}
handleChange(evt) {
this.setState({ name: evt.target.value })
}
render() {
const { name } = this.state
return (
<div>
<h1>My name is: {name}</h1>
<input type='text' value={name} onChange={this.handleChange} />
</div>
)
}
}
如我们所见,类组件需要大量的样板代码来初始化 state 对象和处理函数。
现在,让我们来看看函数组件。
带有 Hooks 的函数组件
函数组件利用 useState Hook,因此我们不需要处理 this 或 constructor 方法。完整的函数组件代码如下:
import { useState } from 'react'
export function App() {
const [name, setName] = useState('')
function handleChange(evt) {
setName(evt.target.value)
}
return (
<div>
<h1>My name is: {name}</h1>
<input type='text' value={name} onChange={handleChange} />
</div>
)
}
如我们所见,Hooks 使我们的代码更加简洁,并且更容易让开发者推理。我们不再需要担心内部的工作方式;我们可以简单地通过访问 useState 函数来使用状态!
Hooks 的优势
让我们再次回顾 React 的第一个原则:
声明式:我们不是告诉 React 如何去做事情,而是告诉它我们想要它做什么。因此,我们可以轻松地设计我们的应用程序,当数据发生变化时,React 将高效地更新和渲染正确的组件。
如我们在本章所学,Hooks 允许我们编写代码来告诉 React 我们想要什么。然而,对于类组件,我们需要告诉 React 如何去做事情。因此,Hooks 比类组件更加声明式,这使得它们更适合 React 生态系统。
Hooks 的声明式特性还意味着 React 可以对我们的代码进行各种优化,因为分析函数和函数调用比类和它们的复杂 this 行为更容易。此外,Hooks 使组件之间抽象和共享常见状态逻辑变得更加容易。通过使用 Hooks,我们可以避免使用渲染属性和高级组件。
我们可以看到,Hooks 不仅使我们的代码更加简洁,并且更容易让开发者推理,而且它们还使代码更容易为 React 优化。
迁移到 Hooks
现在,你可能想知道这是否意味着类组件已经过时,我们需要现在就将所有内容迁移到 Hooks。当然不是——Hooks 是完全可选的。您可以在某些组件中尝试 Hooks,而无需重写任何其他代码。React 团队目前也没有计划移除类。
目前没有必要急于将所有内容迁移到 Hooks。建议您逐步在某些组件中采用 Hooks,这些组件将最有用。例如,如果您有许多处理类似逻辑的组件,您可以将逻辑提取到 Hook 中。您还可以将带有 Hooks 的函数组件与类组件并行使用。
钩子具有向后兼容性,并提供了一个直接访问你已知的各种 React 概念的 API:props、state、context、refs 和 生命周期。此外,钩子还提供了结合这些概念的新方法,并以更好的方式封装它们的逻辑,从而不会导致包装地狱或类似问题。
钩子心态
钩子的主要目标是解耦状态逻辑和渲染逻辑。钩子允许我们在单独的函数中定义逻辑,并在多个组件之间重用它们。有了钩子,我们不需要更改组件层次结构来实现状态逻辑。不再需要定义一个单独的组件来为多个组件提供状态逻辑,我们只需使用一个钩子即可!
然而,钩子需要与传统 React 开发完全不同的心态。我们不再需要考虑组件的生命周期。相反,我们应该考虑数据流。例如,我们可以告诉钩子当某些 props 或其他钩子的值发生变化时触发。我们将在第四章 使用 Reducer 和 Effect 钩子 中了解更多关于这个概念。我们也不再需要根据生命周期方法来拆分组件。相反,我们可以使用钩子来处理常见功能,例如获取数据或设置数据订阅。
钩子规则
钩子非常灵活。然而,使用钩子有一些限制,我们应该始终牢记:
-
钩子只能在函数组件和其他钩子内部使用,不能在类组件或任意函数中使用
-
钩子定义的顺序很重要,需要保持一致;因此,我们不能在
if条件、循环或嵌套函数中放置钩子
幸运的是,Vite 已经为我们配置了一个 ESLint 插件,确保钩子规则不被违反。
我们将在本书的后续章节中更详细地讨论这些限制以及如何绕过它们。
摘要
在本书的第一章中,我们首先学习了 React 的基本原则以及它提供的组件类型。然后,我们继续学习关于类组件的常见问题,使用 React 的现有功能以及它们如何破坏基本原则。接下来,我们使用类组件和带有 Hooks 的函数组件实现了一个简单的应用程序,以便能够比较两种解决方案之间的差异。正如我们所发现的那样,带有 Hooks 的函数组件更适合 React 的基本原则;它们不会像类组件那样出现问题,并且使我们的代码更加简洁易懂!React 团队现在甚至推荐使用函数组件而不是类组件,使函数组件成为编写 React 代码的尖端方式。阅读本章后,React 和 React Hooks 的基本知识已经清楚。我们现在可以继续学习 Hooks 的详细内容。
在下一章中,我们将通过从头开始重新实现 State Hook 来深入了解其工作原理。通过这样做,我们将掌握 Hooks 的内部工作方式以及它们的局限性。之后,我们将使用 State Hook 创建一个小型博客应用程序!
问题
为了回顾我们在本章中学到的内容,尝试回答以下问题:
-
React 的三个基本原则是什么?
-
React 中有哪两种类型的组件?
-
React 中类组件有哪些问题?
-
使用高阶组件在 React 中会出现什么问题?
-
我们可以使用哪个工具来设置 React 项目,以及我们需要运行什么命令来使用它?
-
如果我们使用类组件遇到以下错误,我们应该做什么:
未捕获的类型错误:无法读取未定义的属性(读取'setState')? -
我们如何使用 Hooks 与 React 状态?
-
与类组件相比,使用带有 Hooks 的函数组件有哪些优势?
-
在更新 React 时,我们需要用带有 Hooks 的函数组件替换所有类组件吗?
进一步阅读
如果你对我们在本章中学到的概念感兴趣,请查看以下链接:
-
关于函数组件的信息:
react.dev/reference/react/Component -
React Hooks 的 RFC:
github.com/reactjs/rfcs/blob/main/text/0068-react-hooks.md -
模板字符串:
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals -
三元运算符:
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第二章:使用 State Hook
在了解了 React 的原则并对 Hooks 进行了介绍之后,我们现在将深入学习 State Hook。我们将从通过自己重新实现 State Hook 来了解其内部工作方式开始。这样做将使我们了解 Hooks 的限制以及它们存在的原因。然后,我们将学习可能的替代 Hook API 及其相关问题。最后,我们将学习如何解决由 Hooks 限制引起的常见问题。到本章结束时,您将知道如何使用 State Hook 在 React 中实现有状态的函数组件。
在本章中,我们将涵盖以下主要主题:
-
重新实现 State Hook
-
可能的替代 Hook API
-
使用 Hooks 解决常见问题
技术要求
Node.js 的相当新版本应该已经安装。Node 包管理器(npm)也需要安装(它应该包含在 Node.js 中)。有关如何安装 Node.js 的更多信息,请查看官方网站:nodejs.org/。
在这本书的指南中,我们将使用Visual Studio Code(VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅官方网站:code.visualstudio.com。
在这本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
VS Code v1.97.2
虽然安装新版本不应该有问题,但请注意,某些步骤在新版本上可能会有不同的工作方式。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter02。
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
重新实现 State Hook
为了更好地理解 Hooks 在 React 内部的工作方式,我们将从头开始重新实现useState函数。然而,我们不会将其实现为一个实际的 React Hook,而是一个简单的 JavaScript 函数——只是为了了解 Hooks 实际上在做什么。
这次的重构实现并不完全等同于 React Hooks 在内部的工作方式。实际的实现方式相似,因此具有相似的约束。然而,实际的实现比我们在这里要实现的内容更为广泛。
我们现在将开始重新实现 State Hook:
-
通过执行以下命令将
Chapter01_3文件夹复制到新的Chapter02_1文件夹:$ cp -R Chapter01_3 Chapter02_1 -
在 VS Code 中打开新的
Chapter02_1文件夹。
首先,我们需要定义一个函数来(重新)渲染应用,我们可以使用它来模拟当 Hook 状态变化时的 React 重新渲染。如果我们使用实际的 React Hooks,这将在内部处理。
-
打开
src/main.jsx并删除以下代码:createRoot(document.getElementById('root')).render( <StrictMode> <App /> </StrictMode>, )
替换为以下内容:
const root = createRoot(document.getElementById('root'))
export function renderApp() {
root.render(
<StrictMode>
<App />
</StrictMode>,
)
}
renderApp()
root for our React application to be rendered in. Then, we define a function to render the app into the root. Finally, we call the renderApp() function to initially render the app.
-
现在,打开
src/App.jsx文件并删除以下行:import { useState } from 'react'
将其替换为以下行:
import { renderApp } from './main.jsx'
-
现在,我们定义我们自己的
useState函数。正如我们已经知道的,useState函数接受initialState作为参数:function useState(initialState) { -
然后,我们定义一个值,我们将在这里存储我们的状态。最初,这个值将被设置为
initialState:let value = initialState -
接下来,我们定义
setState函数,我们将在这里设置新值,并强制重新渲染我们的应用:function setState(nextValue) { value = nextValue renderApp() } -
最后,我们将
value和setState函数作为一个数组返回:return [value, setState] } -
启动
dev服务器(保持运行)然后在浏览器中打开链接:$ npm run dev
如果你现在尝试在输入字段中输入文本,你会注意到当组件重新渲染时,状态被重置,因此无法在字段中输入任何文本。我们将在下一节中解决这个问题。
我们使用数组而不是对象的原因是我们通常想要重命名value和setState变量。使用数组可以通过解构轻松地重命名变量。例如,如果我们想要为username设置状态,我们可以这样做:
const [username, setUsername] = useState('')
虽然在对象中也可以通过解构进行重命名,但这会更冗长:
const { state: username, setState: setUsername } = useState('')
如我们所见,Hooks 是处理副作用(如设置有状态值)的简单 JavaScript 函数。
我们的 Hook 函数使用闭包来存储当前值。闭包是一个变量存在和存储的环境。在我们的情况下,函数提供了闭包,而value变量存储在这个闭包中。setState函数也是在同一个闭包中定义的,这就是为什么我们可以在该函数中访问value变量。在useState函数外部,除非我们从函数中返回它,否则我们无法直接访问value变量。
解决简单 Hook 实现中的问题
无法输入任何文本到输入字段的问题是由于每次组件渲染时都会重新初始化value变量,因为我们每次渲染组件时都会调用useState。
在接下来的部分,我们将通过使用全局变量然后将简单值转换为数组来解决此问题,这样我们就可以定义多个 Hook。
使用全局变量
正如我们所学的,value存储在由useState函数定义的闭包中。每当组件重新渲染时,闭包都会重新初始化,这意味着value变量将再次设置为initialState。为了解决这个问题,我们需要将value存储在函数之外的全局变量中。这样,value变量就会在函数的外部闭包中,这意味着当函数再次被调用时,value不会重新初始化。
我们可以定义全局变量如下:
-
首先,编辑
src/App.jsx并在useState函数定义上方添加以下行:**let** **value** function useState(initialState) { -
然后,删除函数定义中的以下第一行:
let value = initialState
用以下代码片段替换它:
if (value === undefined) {
value = initialState
}
- 再次尝试在输入字段中输入一些文本;你会看到我们的 Hook 函数现在可以正常工作了!
现在,我们的useState函数使用全局的value变量而不是在其闭包内定义value变量,因此当函数再次被调用时,它不会重新初始化。虽然我们的 Hook 函数目前运行良好,但如果我们要添加另一个 Hook,我们会遇到另一个问题:所有 Hook 都写入同一个全局value变量!让我们通过向我们的组件添加第二个 Hook 来更详细地看看这个问题。
定义多个 Hook
假设我们想要为用户的姓氏创建第二个字段。我们可以通过以下步骤实现:
-
编辑
src/App.jsx并从在App组件开始,在当前 Hook 之后定义一个新的 Hook:export function App() { const [name, setName] = useState('') **const** **[lastName, setLastName] =** **useState****(****''****)** -
然后,定义一个函数来处理姓氏的变化:
function handleLastNameChange(evt) { setLastName(evt.target.value) } -
然后,在第一个名字之后显示
lastName值:return ( <div> <h1>My name is: {name} **{lastName}**</h1> -
最后,添加另一个用于姓氏的
input字段:<input type='text' value={name} onChange={handleChange} /> **<****input****type****=****'text'****value****=****{lastName}****onChange****=****{handleLastNameChange}** **/>** -
现在尝试输入第一个名字和姓氏。
你会注意到我们重新实现的 Hook 函数使用相同的值来更新两个状态,所以我们总是同时更改两个字段。现在让我们尝试修复这个问题。
添加对多个 Hook 的支持
为了支持多个 Hook,我们需要存储一个 Hook 值的数组而不是单个全局变量。我们现在将按照以下步骤重构value变量:
-
编辑
src/App.jsx并删除以下代码行:let value
用以下代码片段替换它:
let values = []
let currentHook = 0
-
然后,编辑
useState函数的第一行,我们现在在values数组的currentHook索引处初始化值:function useState(initialState) { if (**values[currentHook]** === undefined) { **values[currentHook]** = initialState } -
我们还需要更新 setter 函数,以便只更新相应的状态值。在这里,我们需要首先将
currentHook值存储在一个单独的hookIndex变量中,因为currentHook值稍后会发生变化。这确保了在useState函数的闭包内创建了一个currentHook变量的副本。否则,useState函数将访问外层闭包中的currentHook变量,该变量会在每次调用useState时被修改:**let** **hookIndex = currentHook** function setState(nextValue) { **values[hookIndex]** = nextValue renderApp() } -
按如下方式编辑
useState函数的return语句:**const** **value = values[currentHook++]** return [**value**, setState] }
使用 values[currentHook++],我们将 currentHook 的当前值作为索引传递给 values 数组,然后增加 currentHook 的值。这意味着 currentHook 将在函数返回后增加。
如果我们想要首先增加一个值然后使用它,我们可以使用 arr[++indexToBeIncremented] 语法,它首先增加然后传递结果到数组。
-
当我们开始渲染我们的组件时,我们仍然需要重置
currentHook计数器。在组件定义后立即添加以下行:export function App() { **currentHook =** **0** -
再次尝试输入第一个名字和最后一个名字。
最后,我们简单重新实现的 useState Hook 成功了!以下截图突出了这一点:

图 2.1 – 我们自定义的 Hook 重新实现成功了
如我们所见,使用全局数组来存储我们的 Hook 值解决了我们在定义多个 Hook 时遇到的问题。
示例代码
本节的示例代码可以在 Chapter02/Chapter02_1 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
在解决我们自定义 Hook 实现中遇到的问题之后,让我们更多地了解 Hooks 的一般限制。
我们能否定义条件 Hook?
如果我们想要添加一个复选框来切换第一个名字字段的用法,让我们通过实现这样的复选框来找出答案:
-
将
Chapter02_1文件夹复制到一个新的Chapter02_2文件夹中,如下所示:$ cp -R Chapter02_1 Chapter02_2 -
在 VS Code 中打开新的
Chapter02_2文件夹。 -
编辑
src/App.jsx并向App组件添加一个新的 Hook,该 Hook 将存储复选框的状态:export function App() { currentHook = 0 **const** **[enableFirstName, setEnableFirstName] =** **useState****(****false****)** -
然后,调整
name状态的 Hook,使其仅在第一个名字被启用时使用:**// eslint-disable-next-line react-hooks/rules-of-hooks** const [name, setName] = **enableFirstName ?** useState('') **: [****''****,** **() =>** **{}]** const [lastName, setLastName] = useState('')
我们需要禁用 ESLint 这一行;否则,它会大声告诉我们不能有条件地使用 Hooks。出于演示目的,我想展示当你忽略这个警告时会发生什么。
我们还定义了一个回退到空字符串 ('') 和一个不执行任何操作的函数 (() => {}),当 Hook 未定义时。
-
接下来,定义一个用于更改复选框状态的处理器函数:
function handleEnableChange(evt) { setEnableFirstName(evt.target.checked) } -
最后,渲染复选框:
return ( <div> <h1> My name is: {name} {lastName} </h1> **<****input** **type****=****'checkbox'** **value****=****{enableFirstName}** **onChange****=****{handleEnableChange}** **/>** -
启动
dev服务器,然后在浏览器中打开链接:$ npm run dev
在这里,我们要么使用 Hook,要么如果第一个名字被禁用,则返回初始状态和一个空设置函数,这样编辑输入字段将不起作用。
如果我们现在尝试这段代码,我们会注意到编辑最后一个名字仍然可以工作,但编辑第一个名字则不行,这正是我们想要的。正如以下截图所示,现在只有编辑最后一个名字可以工作:

图 2.2 – 在勾选复选框之前的应用程序状态
当我们点击复选框时,会发生一些奇怪的事情:
-
复选框被勾选
-
名字输入字段被启用
-
现在姓氏字段的值是名字字段的值
我们可以在以下屏幕截图看到点击复选框的结果:

图 2.3 – 点击复选框后应用的状态
我们可以看到,现在姓氏状态在名字字段中。值被交换,因为 Hooks 的顺序很重要。正如我们从我们的实现中知道的那样,我们使用currentHook索引来找出每个 Hook 的状态存储位置。然而,当我们插入一个额外的 Hook 在两个现有 Hooks 之间时,顺序就会混乱。
在勾选复选框之前,values数组如下:
-
[false, ''] -
Hook 顺序:
enableFirstName,lastName
然后,我们在姓氏字段中输入了一些文本:
-
[false, 'Hook'] -
Hook 顺序:
enableFirstName,lastName
然后,我们点击了复选框,这激活了另一个 Hook:
-
[true, 'Hook', ''] -
Hook 顺序:
enableFirstName,name,lastName
如我们所见,在两个现有 Hooks 之间插入一个新的 Hook 会使name Hook 从下一个 Hook(lastName)中“窃取”状态,因为它现在具有lastName Hook 之前拥有的相同索引。现在,lastName Hook 没有值,这导致它设置初始值(一个空字符串)。
因此,切换复选框将姓氏字段的值放入名字字段,并使姓氏字段为空。
示例代码
本节示例代码位于Chapter02/Chapter02_2文件夹中。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
在了解到 Hooks 总是需要以相同的顺序调用之后,让我们将我们的自定义 Hook 实现与真正的 React Hooks 进行比较。
将我们的重新实现与真正的 Hooks 进行比较
我们简单的 Hook 实现已经让我们对 Hooks 的内部工作方式有了了解。然而,在现实中,Hooks 并不使用全局变量。相反,它们在 React 组件中存储状态。它们还内部处理 Hook 计数器,因此我们不需要在函数组件中手动重置计数。此外,真正的 Hooks 在状态变化时自动触发组件的重新渲染。然而,为了能够做到这一点,Hooks 需要从 React 函数组件中调用。React Hooks 不能在 React 外部或 React 类组件内部调用。
通过重新实现useState Hook,我们学到了以下内容:
-
Hooks 是访问 React 功能的函数
-
Hooks 处理跨渲染持续存在的副作用
-
Hook 定义的顺序很重要
最后一点尤为重要,因为它意味着我们不能有条件地定义 Hooks。我们应该始终在函数组件的开始处定义所有 Hook,并且永远不要将它们嵌套在if语句、三元运算符或类似的结构中。
因此,我们也学到了以下内容:
-
React Hooks 必须在 React 函数组件或其他 Hook 内部调用
-
React Hooks 不能在条件或循环中定义
由于我们学到了一些限制,React Hooks 还有一些额外的限制:
-
React Hooks 不能在条件
return语句之后定义 -
React Hooks 不能在事件处理程序中定义
-
React Hooks 不能在
try/catch/finally块内定义 -
React Hooks 不能在传递给
useMemo、useReducer和useEffect的函数中定义(我们将在本书中学习更多关于这三个 Hook 的内容,但请现在记住这个限制)
现在,我们将探讨一些替代的 Hook API,它们将允许条件性 Hook,但它们也有自己的缺点。
潜在的替代 Hook API
有时,定义条件性 Hook 或在循环中定义 Hook 会很好,但为什么 React 团队决定以这种方式实现 Hooks?有哪些替代方案?让我们通过探讨其中的一些方案来了解做出这一决策所涉及的权衡。
命名 Hook
我们可以给每个 Hook 起一个名字,然后将 Hook 存储在对象中而不是数组中。然而,这不会使 API 变得如此优雅,我们还需要始终为 Hook 考虑独特的名称:
// NOTE: Not the actual React Hook API
const [name, setName] = useState('nameHook', '')
此外,还有一些未解决的问题:当条件设置为false或从循环中移除一个项目时会发生什么?我们会清除 Hook 状态吗?如果我们不清除 Hook 状态,我们可能会造成内存泄漏。如果我们清除它,我们可能会无意中丢弃用户输入。
即使解决了这些问题,仍然存在名称冲突的问题。例如,如果我们创建了一个名为nameHook的 Hook,那么我们不能再在组件中调用任何其他名为nameHook的 Hook,否则将导致名称冲突。这种情况也适用于库中的 Hook 名称,因此我们需要确保避免与库定义的 Hook 发生名称冲突!
Hook 工厂
或者,我们可以创建一个 Hook 工厂函数,它内部使用Symbol来为每个 Hook 提供一个独特的键名:
function createUseState() {
const keyName = Symbol()
return function useState() {
// …use unique key name to handle hook state…
}
}
然后,我们可以这样使用工厂函数:
// NOTE: Not the actual React Hook API
const useNameState = createUseState()
export function App () {
const [name, setName] = useNameState('')
// …
}
然而,这意味着我们需要为每个 Hook 实例化两次:一次在组件外部,一次在函数组件内部。这增加了出错的可能性。例如,如果我们创建了两个 Hook 并复制粘贴样板代码,那么我们可能会在 Hook 的名称上犯错误,或者在使用组件内的 Hook 时犯错误。
这种方法也使得创建自定义 Hook 变得更加困难,迫使我们编写包装函数。此外,与调试简单函数相比,调试这些包装函数更加困难。
其他替代方案
对于 React Hooks,提出了许多替代的 API,但每个都存在类似的问题:要么使 API 更难使用,灵活性降低,更难调试,或者引入名称冲突的可能性。
最后,React 团队决定最简单的 API 是通过记录 Hook 被调用的顺序来跟踪 Hook。这种方法有其自身的缺点,例如无法有条件地调用 Hook 或在循环中调用。然而,这种方法使得创建自定义 Hook 非常容易,并且使用和调试都很简单。我们也不必担心 Hook 的命名、名称冲突或编写包装函数。最终的 Hook 方法让我们可以使用 Hook 就像使用任何其他函数一样!
现在我们已经了解了各种提案和最终的 Hook 实现,让我们学习如何解决由于选择官方 API 的限制而导致的常见问题。
解决 Hook 的常见问题
如我们所发现的,使用官方 API 实现 Hooks 也有其自身的权衡和限制。我们现在将学习如何克服这些常见问题,这些问题源于 React Hooks 的限制。
我们将探讨可以用来克服这两个问题的解决方案:
-
解决条件 Hook
-
在循环中解决 Hook
解决条件 Hook
那么,我们如何实现条件 Hook?我们不必使 Hook 有条件,我们只需总是定义 Hook 并在我们需要时使用它。如果这不是一个选项,我们需要拆分我们的组件,这通常总是更好的选择!
总是定义 Hook
对于简单的情况,例如我们之前遇到的第一个和最后一个名称示例,我们只需总是保持 Hook 定义,如下所示:
-
将
Chapter02_2文件夹复制到新的Chapter02_3文件夹中,如下所示:$ cp -R Chapter02_2 Chapter02_3 -
在 VS Code 中打开新的
Chapter02_3文件夹。 -
编辑
src/App.jsx并 删除 以下两行:// eslint-disable-next-line react-hooks/rules-of-hooks const [name, setName] = enableFirstName ? useState('') : ['', () => {}]
替换 如下:
const [name, setName] = useState('')
-
现在,我们需要将条件移动到第一个名称被渲染的地方:
return ( <div> <h1> My name is: **{enableFirstName ? name : ''}** {lastName} </h1>
如果你想重新添加第一个名称字段在未启用时甚至不能编辑的功能,只需向 <input> 字段添加以下属性:disabled={!enableFirstName}。
-
运行
dev服务器,然后在浏览器中打开链接:$ npm run dev
现在,我们的示例运行正常!总是定义 Hook 对于简单情况通常是一个好的解决方案。在更复杂的情况下,可能无法始终定义 Hook。在这种情况下,我们需要创建一个新的组件,在那里定义 Hook,然后有条件地渲染组件。
示例代码
本节的示例代码可以在 Chapter02/Chapter02_3 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
分离组件
解决条件 Hooks 的另一种方法是拆分一个组件成多个组件,然后有条件地渲染这些组件。例如,假设我们在用户登录后想要从数据库中获取用户信息。
我们不能这样做,因为使用 if 条件可能会改变 Hooks 的顺序:
function UserInfo({ username }) {
// NOTE: Do NOT do this
if (username) {
const info = useFetchUserInfo(username)
return <div>{info}</div>
}
return <div>Not logged in</div>
}
相反,我们必须为用户登录时创建一个单独的组件,如下所示:
// NOTE: Do this instead
function LoggedInUserInfo({ username }) {
const info = useFetchUserInfo(username)
return <div>{info}</div>
}
function UserInfo({ username }) {
if (username) {
return <LoggedInUserInfo username={username} />
}
return <div>Not logged in</div>
}
使用两个独立的组件来处理非登录和登录状态是有意义的,因为我们想坚持一个组件一个功能的原理。通常,如果我们坚持最佳实践,不能有条件 Hooks 并不是很大的限制。
解决循环中的 Hooks
有时候,你可能需要在循环中定义 Hooks – 例如,如果你有动态添加新输入字段的方法,并且需要为每个字段提供一个 State Hook。
要解决我们希望在循环中使用 Hooks 的问题,我们可以使用包含数组的单个 State Hook,或者再次拆分我们的组件。例如,假设我们想要显示所有在线的用户。
使用数组
我们可以简单地使用包含所有用户的数组,如下所示:
function OnlineUsers({ users }) {
const [userInfos, setUserInfos] = useState([])
// ... fetch & keep userInfos up to date ...
return (
<div>
{users.map((username) => {
const user = userInfos.find((u) => u.username === username)
return <UserInfo key={username} {...user} />
})}
</div>
)
}
然而,这并不总是有意义的。例如,我们可能不希望通过 OnlineUsers 组件来更新用户状态,因为我们必须从数组中选择正确的用户状态,然后修改数组。这可能可行,但相当繁琐。
拆分组件
一个更好的解决方案是使用 UserInfo 组件中的 Hook。这样,我们可以保持每个用户状态的最新,而无需处理数组逻辑:
function OnlineUsers({ users }) {
return (
<div>
{users.map((username) => (
<UserInfo key={username} username={username} />
))}
</div>
)
}
function UserInfo({ username }) {
const info = useFetchUserInfo(username)
// ... keep user info up to date ...
return <div>{info}</div>
}
如我们所见,使用一个组件来处理每个功能使我们的代码简单且简洁,同时也避免了 React Hooks 的限制。
摘要
在本章中,我们首先重新实现了 useState 函数,利用全局状态和闭包。然后我们了解到,为了支持多个 Hooks,我们需要使用数组来跟踪它们。然而,通过使用状态数组,我们被迫在函数调用之间保持 Hooks 的顺序一致。这种限制使得条件 Hooks 和循环中的 Hooks 变得不可能。然后我们学习了 Hook API 的潜在替代方案,它们的权衡以及为什么选择了最终的 API。最后,我们学习了如何解决由 Hooks 的限制引起的常见问题。现在我们对 Hooks 的内部工作和限制有了坚实的理解。在这个过程中,我们还深入了解了 State Hook。
在下一章中,我们将创建一个使用 State Hook 的博客应用程序,并学习如何组合多个 Hooks。
问题
为了回顾本章所学的内容,尝试回答以下问题:
-
在开发我们自己的
useStateHook 重新实现过程中,我们遇到了哪些问题?我们是如何解决这些问题的? -
为什么在 React 的钩子实现中不能使用条件钩子?
-
使用钩子时我们需要注意什么?
-
钩子替代 API 想法的常见问题有哪些?
-
我们如何实现条件钩子?
-
我们如何在循环中实现钩子?
进一步阅读
如果你对本章学到的概念感兴趣,想了解更多信息,请查看以下链接:
-
关于替代钩子 API 缺陷的更多信息:
overreacted.io/why-do-hooks-rely-on-call-order/ -
对替代钩子 API 的官方评论:
github.com/reactjs/rfcs/pull/68#issuecomment-439314884 -
关于钩子限制和规则的官方文档:
react.dev/reference/rules/rules-of-hooks -
关于
Symbol如何工作的更多信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈,向作者提问,了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第三章:使用 React Hooks 编写您的第一个应用程序
在深入了解 State Hook 之后,我们现在将利用它从头开始创建一个博客应用程序。在本章中,我们首先将学习如何以可扩展的方式结构化 React 应用程序。然后,我们将定义我们需要用到的组件,以覆盖博客应用程序的基本功能。最后,我们将使用 Hooks 将状态引入我们的应用程序!在本章中,我们还将了解 JSX 和各种 JavaScript 功能。在本章结束时,我们将拥有一个基本的博客应用程序,我们可以登录、注册和创建帖子。
本章将涵盖以下主题:
-
结构化 React 项目
-
实现静态 React 组件
-
使用 Hooks 实现有状态的组件
技术要求
应已安装一个相当新的 Node.js 版本。还需要安装 Node 包管理器 (npm)(它应随 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请访问官方网站:nodejs.org/。
在本书的指南中,我们将使用 Visual Studio Code (VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅官方网站:code.visualstudio.com。
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
上列版本是本书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能会有所不同。如果您在使用本书提供的代码和步骤时遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter03。
强烈建议您亲自编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
结构化 React 项目
在了解 React 原则、如何使用 State Hook 以及 Hooks 内部工作原理之后,我们现在将利用真实的 State Hook 来开发一个博客应用程序。在本节中,我们将以允许我们以后扩展项目的方式结构化文件夹。
文件夹结构
项目可以有多种结构,不同的结构可能适合不同的项目。通常,创建一个src/文件夹来存放所有源代码是一个好主意,以区分资源和配置文件。在这个文件夹内,一种可能的组织方式是按特性分组文件。另一种流行的项目组织方式是按路由分组文件。对于某些项目,可能还需要按文件类型进一步分离,例如src/api/和src/components/。然而,对于我们的项目,我们主要将关注用户界面(UI)。因此,我们将在src/文件夹中按特性分组文件。
首先从一个简单的结构开始是个好主意,只有在你真正需要的时候才进行更深的嵌套。在项目开始时不要花太多时间思考文件结构,因为通常你事先不知道文件应该如何分组,而且它可能以后还会改变。然而,尽量避免使用通用的文件夹和文件名,如utils、common或shared。使用尽可能具体的术语,并在结构演变时进行扩展。
定义特性
我们首先必须考虑我们将在博客应用中实现哪些特性。至少,我们想要实现以下特性:
-
注册用户
-
登录/登出
-
查看单个帖子
-
创建新帖子
-
列出帖子
制定初始结构
从我们定义的特性中,我们可以抽象出一组功能组:
-
用户(注册、登录/登出)
-
帖子(创建、查看、列出)
我们现在可以保持非常简单,在src/文件夹中创建所有组件,而不需要任何嵌套。然而,由于我们已经对博客应用将需要的特性有了相当清晰的了解,我们可以提前制定一个文件夹结构:
-
src/ -
src/user/ -
src/post/
让我们现在设置初始的文件夹结构:
-
通过执行以下命令将
Chapter01_3文件夹复制到新的Chapter03_1文件夹:$ cp -R Chapter01_3 Chapter03_1 -
在 VS Code 中打开新的
Chapter03_1文件夹。 -
在
Chapter03_1文件夹内,创建新的src/user/和src/post/文件夹。
组件结构
React 中组件的理念是让每个组件处理单个任务或 UI 元素。我们应该尽量使组件尽可能细粒度,以便能够重用代码。如果我们发现自己正在从一个组件复制粘贴代码到另一个组件,可能将这个通用代码提取到一个可以重用的单独组件中是个好主意。
通常,在开发软件时,我们首先从 UI 原型开始。对于我们的博客,它看起来如下:

图 3.1 – 我们博客应用的初始原型
在拆分组件时,我们使用单一职责原则,该原则指出每个模块应该只负责功能的一个封装部分。
在原型中,我们可以在每个组件和子组件周围绘制方框,并给它们命名。请记住,每个组件应该只有一个职责。我们从这个应用的基本组件开始:

图 3.2 – 在我们的原型中绘制基本组件
我们绘制了一个Logout组件用于登出(在登出状态下将被Login/Register组件替换),一个CreatePost组件用于渲染创建新帖子的表单,以及一个Post组件用于实际的帖子。
现在我们已经绘制了基本组件,我们将查看哪些组件在逻辑上属于一组,因此形成一个组。为此,我们现在绘制容器组件,这是我们为了将组件组合在一起所需要的:

图 3.3 – 在我们的原型中绘制容器组件
我们绘制了一个PostList组件,用于将帖子分组,然后一个UserBar组件用于处理登录/登出和注册。最后,我们绘制了一个App组件,将其他所有内容组合在一起并定义我们应用的结构。
现在我们已经完成了 React 项目的结构化,我们可以继续实现静态组件。
实现静态组件
在我们通过 Hooks 向我们的博客应用添加状态之前,我们将使用静态 React 组件来模拟我们应用的基本功能。这样做意味着我们必须处理我们应用静态视图结构。
首先处理静态结构是有意义的,因为它将避免以后需要将动态代码移动到不同的组件中。此外,首先只处理 HTML(和 CSS)更容易——帮助我们快速开始项目。然后,我们可以继续实现动态代码和处理状态。
逐步进行,而不是一次性实现所有内容,这有助于我们快速开始新项目,而不必一次性思考太多,并且减少了我们以后需要重构的量!
实现与用户相关的静态组件
我们将从静态组件中最简单的功能开始,即实现与用户相关的功能。正如我们从我们的原型中看到的,我们在这里需要四个组件:
-
一个在用户尚未登录时将要显示的
Login组件 -
一个在用户尚未登录时也将要显示的
Register组件 -
一个在用户登录后将要显示的
Logout组件 -
一个
UserBar组件,它将根据用户的登录状态有条件地显示其他组件
我们将首先定义前三个组件,它们都是独立组件。最后,我们将定义依赖于其他组件的UserBar组件。
登录组件
首先,我们将定义Login组件,我们将展示两个字段:一个用户名字段和一个密码字段。此外,我们还将展示一个登录按钮。让我们开始吧:
-
在之前设置的
Chapter03_1文件夹内,为我们的组件创建一个新文件:src/user/Login.jsx。 -
在新创建的
src/user/Login.jsx文件中,定义一个组件,目前它不接受任何属性:export function Login() { -
渲染一个
<form>,防止表单的默认提交行为和刷新页面:return ( <form onSubmit={(e) => e.preventDefault()}>
这里,我们使用一个匿名函数(也称为箭头函数)来定义onSubmit处理程序。匿名函数的定义如下:
-
如果它们没有参数,我们可以写
() => { ... },而不是function () { ... } -
有参数的情况下,我们可以写
(arg1, arg2) => { ... },而不是function (arg1, arg2) { ... }
如果我们不使用括号{ },函数体中的语句的结果也将自动从函数返回,尽管这通常在事件处理程序中不是问题。
-
然后,渲染两个输入字段来输入用户名和密码,以及一个提交登录表单的按钮:
<label htmlFor='login-username'>Username: </label> <input type='text' name='username' id='login-username' /> <br /> <label htmlFor='login-password'>Password: </label> <input type='password' name='password' id='login-password' /> <br /> <input type='submit' value='Login' /> </form> ) }
使用语义化 HTML,如<form>和<label>,可以使你的应用对使用辅助软件(如屏幕阅读器)的人更容易导航。此外,当使用语义化 HTML 时,键盘快捷键,如通过按Enter/Return键提交表单,将自动工作。我们使用了htmlFor和id属性来确保屏幕阅读器知道标签属于哪个输入字段。id属性在整个页面中必须是唯一的,但对于name属性,只要在表单内是唯一的就足够了。
现在已经实现了静态的Login组件,让我们渲染它来看看它的样子。
渲染登录组件
按照以下步骤渲染Login组件:
-
首先,编辑
src/App.jsx并删除其中的所有现有代码。 -
然后,按照以下方式导入
Login组件:import { Login } from './user/Login.jsx' -
定义并导出
App组件,目前它只是简单地渲染Login组件:export function App() { return <Login /> }
如果我们只返回一个组件,我们可以在return语句中省略括号。而不是写return (<Login />),我们可以简单地写return <Login />。
-
通过打开终端(VS Code 中的终端 | 新终端菜单选项)并执行以下命令来运行
dev服务器:$ npm run dev -
在你的浏览器中打开 dev 服务器的链接,你应该会看到
Login组件正在被渲染。如果你更改代码,它应该会自动刷新,所以你可以在这个章节中一直运行 dev 服务器。

图 3.4 – 我们博客应用的第一组件:带有用户名和密码的登录
如我们所见,静态的Login组件在 React 中渲染良好。
注册组件
静态的Register组件将与Login组件非常相似,多了一个重复密码的字段。如果它们如此相似,有人可能会想到将它们合并为一个组件,并添加一个 prop 来切换额外字段。然而,在这种情况下,最好让每个组件只处理一个功能。稍后,我们将使用动态代码扩展静态组件;然后,Register和Login将具有截然不同的逻辑,我们需要再次将它们分开。
然而,让我们开始编写Register组件的代码:
-
创建一个新的
src/user/Register.jsx文件。 -
定义一个包含用户名和密码字段的表单,类似于
Login组件:export function Register() { return ( <form onSubmit={(e) => e.preventDefault()}> <label htmlFor='register-username'>Username: </label> <input type='text' name='username' id='register-username' /> <br /> <label htmlFor='register-password'>Password: </label> <input type='password' name='password' id='register-password' /> <br />请注意,你应该优先使用 CSS 进行间距设置,而不是使用
<br />HTML 标签。然而,在这本书中,我们专注于 UI 结构和与 Hooks 的集成,因此我们尽可能简单地使用 HTML。 -
接下来,添加一个重复密码字段:
<label htmlFor='register-password-repeat'>Repeat password: </label> <input type='password' name='password-repeat' id='register-password-repeat' /> <br /> -
最后,添加一个注册按钮:
<input type='submit' value='Register' /> </form> ) } -
再次,我们可以编辑
src/App.jsx来显示我们的新组件,如下所示:import { **Register** } from './user/**Register**.jsx' export function App() { return <**Register** /> }
如我们所见,Register组件看起来与Login组件非常相似,但多了一个字段,并且按钮上的文本不同。
注销组件
接下来,我们将定义Logout组件,该组件将显示当前登录用户的名称,以及一个注销按钮:
-
创建一个名为
src/user/Logout.jsx的新文件。 -
编辑
src/user/Logout.jsx文件,并定义一个接受username属性的组件:export function Logout({ username }) {
这里,我们使用解构从props对象中提取username键。React 将所有组件 prop 作为单个对象传递给函数的第一个参数。在第一个参数上使用解构类似于在类组件中执行const { username } = this.props。
-
在其中,返回一个表单,显示当前登录用户和一个注销按钮:
return ( <form onSubmit={(e) => e.preventDefault()}> Logged in as: <b>{username}</b> <input type='submit' value='Logout' /> </form> ) } -
我们现在可以将
Register组件替换为Logout组件在src/App.jsx中,以查看我们新定义的组件(不要忘记传递usernameprop 给它!):import { **Logout** } from './user/**Logout**.jsx' export function App() { return **<****Logout****username****=****'Daniel Bugl'** **/>** }
现在已经定义了Logout组件,我们可以继续编写UserBar组件。
用户栏组件
现在,是时候将我们的用户相关组件组合成一个UserBar组件了,我们将根据用户是否已经登录,有条件地显示Login和Register组件或Logout组件。
让我们开始实现UserBar组件:
-
创建一个新的
src/user/UserBar.jsx文件。 -
在其中,导入
Login、Logout和Register组件:import { Login } from './Login.jsx' import { Logout } from './Logout.jsx' import { Register } from './Register.jsx' -
定义
UserBar组件和一个username变量。目前,我们将其设置为静态值:export function UserBar() { const username = '' -
然后,我们检查用户是否已登录。如果用户已登录,我们显示
Logout组件,并将其username传递给它:if (username) { return <Logout username={username} /> } -
否则,我们显示
Login和Register组件。在这里,我们可以使用React.Fragment(简写语法:<>和</>)而不是<div>容器。这使我们的 UI 树保持清洁,因为组件将简单地并排渲染,而不是被另一个元素包裹:return ( <> <Login /> <hr /> <Register /> </> ) } -
编辑
src/App.jsx并显示UserBar组件,如下所示:import { **UserBar** } from './user/**UserBar**.jsx' export function App() { return **<****UserBar** **/>** }
如你所见,UserBar 组件成功渲染了 Login 和 Register 组件:

图 3.5 – 用户尚未登录时的 UserBar 组件
-
你可以尝试编辑静态的
username变量,看看它是否会渲染Logout组件。编辑src/user/UserBar.jsx并按照以下方式调整:export function UserBar() { const username = '`Daniel Bugl`'
进行此更改后,UserBar 组件将渲染 Logout 组件:

图 3.6 – 定义 username 后的 UserBar 组件
在本章的后面部分,我们将向我们的应用程序添加 Hooks,这样我们就可以动态地登录并改变状态,而无需编辑代码!
实现帖子
在实现所有用户相关组件后,我们现在可以继续在我们的博客应用程序中实现帖子。我们将定义以下组件:
-
一个用于显示单个帖子的
Post组件 -
一个用于创建新帖子的
CreatePost组件 -
一个
PostList组件用于显示所有帖子的列表
Post 组件
我们在创建原型时已经考虑了帖子应该包含哪些元素。帖子应该有一个标题、内容和作者(撰写帖子的用户)。
现在让我们来实现 Post 组件:
-
创建一个新的
src/post/Post.jsx文件。 -
在其中,以类似于原型的方式渲染所有属性:
export function Post({ title, content, author }) { return ( <div> <h3>{title}</h3> <div>{content}</div> <br /> <i> Written by <b>{author}</b> </i> </div> ) } -
和往常一样,我们可以通过编辑
src/App.jsx文件来测试我们的组件:import { **Post** } from './**post**/**Post**.jsx' export function App() { **return** **(** **<****Post** **title****=****'React Hooks'** **content****=****'The greatest thing since sliced bread!'** **author****=****'****Daniel Bugl'** **/>** **)** }
现在静态的 Post 组件已经实现,我们可以继续进行 CreatePost 组件的开发。
创建帖子组件
我们需要实现一个表单来创建新帖子。在这里,我们将 username 作为属性传递给组件,因为作者始终是当前登录的用户。然后,我们显示作者并提供一个标题输入字段和一个 <textarea> 元素用于博客帖子的内容。
现在让我们来实现 CreatePost 组件:
-
创建一个新的
src/post/CreatePost.jsx文件。 -
在其中,根据原型定义组件:
export function CreatePost({ username }) { return ( <form onSubmit={(e) => e.preventDefault()}> <div> Author: <b>{username}</b> </div> <div> <label htmlFor='create-title'>Title:</label> <input type='text' name='title' id='create-title' /> </div> <textarea name='content' /> <input type='submit' value='Create' /> </form> ) } -
和往常一样,我们可以通过编辑
src/App.jsx文件来测试我们的组件,如下所示:import { **CreatePost** } from './post/**CreatePost**.jsx' export function App() { return **<****CreatePost****username****=****'****Daniel Bugl'** **/>** }
如我们所见,CreatePost 组件渲染良好。我们现在可以继续进行 PostList 组件的开发。
帖子列表组件
在实现其他与帖子相关的组件后,我们现在可以实施我们博客应用最重要的部分:博客帖子的流。目前,流将简单地显示博客帖子的列表。
现在让我们开始实现PostList组件:
-
创建一个新的
src/post/PostList.jsx文件。 -
首先,我们导入
Fragment和Post组件:import { Fragment } from 'react' import { Post } from './Post.jsx' -
然后,我们定义接受一个
posts数组作为属性的PostList函数组件。如果posts未定义,我们默认将其设置为空数组:export function PostList({ posts = [] }) { -
接下来,我们使用
.map函数和展开语法来渲染所有帖子:return ( <div> {posts.map((post, index) => ( <Post {...post} key={`post-${index}`} /> ))} </div> ) }
我们为每个帖子返回<Post>组件,并将post对象的所有键作为属性传递给组件。我们通过使用展开语法来完成此操作,这具有与手动将对象的所有键作为属性列出相同的效果,如下所示:
<Post
title={post.title}
author={post.author}
content={post.content}
/>
如果我们在渲染元素列表,我们必须为每个元素提供一个唯一的key属性。React 使用这个key属性在数据发生变化时高效地计算两个列表之间的差异。使用唯一的 ID 作为key属性的最佳实践,例如数据库 ID,这样 React 可以跟踪列表中变化的项目。然而,在这种情况下,我们没有这样的 ID,所以我们简单地回退到使用索引。
我们使用了map函数,它将一个函数应用于数组的所有元素。这与使用for循环并存储所有结果类似,但更简洁、声明性更强,更容易阅读!作为使用map函数的替代方案,我们可以这样做:
let renderedPosts = []
let index = 0
for (let post of posts) {
renderedPosts.push(<Post {...post} key={`post-${index}`} />)
index++
}
return (
<div>
{renderedPosts}
</div>
)
然而,不建议在 React 中使用这种风格。
-
在原型中,每个博客帖子后面都有一个水平线。我们可以通过使用
Fragment而不添加额外的<div>容器元素来实现这一点,如下所示:{posts.map((post, index) => ( **<****Fragment****key****=****{****`****post-****${****index****}`}>** **<****Post** **{****...post****} />** **<****hr** **/>** **</****Fragment****>** ))}
使用Fragment而不是额外的<div>容器元素可以保持 DOM 树整洁并减少嵌套的数量。
key属性必须始终添加到在map函数中渲染的最高父元素。在这种情况下,我们必须将key属性从Post组件移动到Fragment。
-
再次,我们通过编辑
src/App.jsx文件来测试我们的组件:import { **PostList** } from './post/**PostList**.jsx' **const** **posts = [** **{** **title****:** **'React Hooks'****,** **content****:** **'The greatest thing since sliced bread!'****,** **author****:** **'Daniel Bugl'****,** **},** **{** **title****:** **'Using React Fragments'****,** **content****:** **'Keeping the DOM tree clean!'****,** **author****:** **'Daniel Bugl'****,** **},** **]** export function App() { return **<****PostList****posts****=****{posts}** **/>** }
现在,我们可以看到我们的应用列出了我们在posts数组中定义的所有帖子。
如我们所见,通过PostList组件列出多个帖子工作得很好。现在我们可以继续组装应用。
组装应用
在实现所有组件以重现原型后,我们只需在App组件中将所有内容组合在一起。然后,我们就成功重现了原型!
让我们从修改App组件并组装我们的应用开始:
-
编辑
src/App.jsx并删除所有当前代码。 -
首先,导入
UserBar、CreatePost和PostList组件:import { UserBar } from './user/UserBar.jsx' import { CreatePost } from './post/CreatePost.jsx' import { PostList } from './post/PostList.jsx' -
然后,为应用定义一些模拟数据:
const username = 'Daniel Bugl' const posts = [ { title: 'React Hooks', content: 'The greatest thing since sliced bread!', author: 'Daniel Bugl', }, { title: 'Using React Fragments', content: 'Keeping the DOM tree clean!', author: 'Daniel Bugl', }, ] -
接下来,定义
App组件并返回一个带有一些填充的容器:export function App() { return ( <div style={{ padding: 8 }}> -
现在,渲染
UserBar和CreatePost组件,并将username属性传递给CreatePost组件:<UserBar /> <br /> <CreatePost username={username} /> -
最后,显示
PostList组件,并将posts属性传递给它:<hr /> <PostList posts={posts} /> </div> ) }
保存文件后,浏览器应自动刷新,我们现在可以看到完整的 UI:

图 3.7 – 根据原型完全实现我们的静态博客应用程序
如我们所见,现在,我们之前定义的所有静态组件都在一个 App 组件中一起渲染。
示例代码
本节示例代码位于 Chapter03/Chapter03_1 文件夹中。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
我们的应用程序现在看起来就像原型一样,所以,我们现在可以继续使用 Hooks 来使所有组件动态化。
使用 Hooks 实现状态化组件
现在我们已经实现了应用程序的静态结构,我们将添加状态 Hooks 来处理状态和动态交互!
首先,让我们为状态实现创建一个新的文件夹:
-
将
Chapter03_1文件夹复制到新的Chapter03_2文件夹中,如下所示:$ cp -R Chapter03_1 Chapter03_2 -
在 VS Code 中打开新的
Chapter03_2文件夹。
为用户功能添加 Hooks
为了添加用户功能的 Hooks,我们需要将静态的 username 变量替换为一个 Hook。然后,我们需要在登录、注册和注销时调整值。
调整用户栏
当我们创建 UserBar 组件时,我们静态地定义了一个 username 变量。我们现在将用状态 Hook 来替换它!
让我们开始修改 UserBar 组件,使其动态化:
-
编辑
src/user/UserBar.jsx并导入useStateHook,如下所示:import { useState } from 'react' -
删除 以下代码行:
const username = 'Daniel Bugl'
替换 它为一个使用空用户名作为默认值的 State Hook:
const [username, setUsername] = useState('')
-
然后,将
setUsername函数传递给Logout组件:if (username) { return <Logout username={username} **setUsername****=****{setUsername}** /> }
为了简化并更容易跟踪状态处理的位置,我们将直接从状态 Hook 将 username 和 setUsername 函数传递到其他组件。在实际项目中,最好使用特定的名称来命名处理程序,例如 onLogout。这减少了组件之间的耦合。
-
此外,将
setUsername函数分别传递给Login和Register组件:return ( <> <Login **setUsername****=****{setUsername}** /> <hr /> <Register **setUsername****=****{setUsername}** /> </> ) }
现在,UserBar 组件可以动态地设置用户名。然而,我们仍然需要修改其他组件以添加处理程序。
-
编辑
src/user/Logout.jsx并定义一个handleSubmit函数,如下所示:export function Logout({ username**, setUsername** }) { **function****handleSubmit****(****e****) {** **e.****preventDefault****()** **setUsername****(****''****)** **}**在 React 19 中,表单操作被引入作为一种处理表单提交的高级方式。我们将在第七章中学习更多关于表单操作的内容,使用 Hooks 处理表单。在本章中,我们将专注于使用 State Hook 和传统的使用
onSubmit处理函数处理表单的方式。 -
然后,用新定义的函数替换现有的
onSubmit处理程序:return ( <form onSubmit={**handleSubmit**}> -
编辑
src/user/Login.jsx并定义一个handleSubmit函数,如下所示:export function Login(**{ setUsername }**) { **function****handleSubmit****(****e****) {** **e.****preventDefault****()** **const** **username = e.****target****.****elements****.****username****.****value** **setUsername****(username)** **}** return ( <form onSubmit={**handleSubmit**}>
如我们所见,我们可以通过使用e.target.elements直接访问表单中username字段的值。form元素的键等同于<input>元素的name属性。
-
编辑
src/user/Register.jsx并定义一个handleSubmit函数,如下所示:export function Register(**{ setUsername }**) { **function****handleSubmit****(****e****) {** **e.****preventDefault****()** **const** **username = e.****target****.****elements****.****username****.****value** **setUsername****(username)** **}** return ( <form onSubmit={**handleSubmit**}>
现在,你可以尝试注册、登录和登出,并查看状态在组件间如何变化。
添加验证
在尝试login和register功能时,你可能已经注意到没有进行验证。对于简单的验证,如必填字段,我们可以直接使用 HTML 功能。HTML 验证将阻止用户提交表单,如果字段无效,会弹出一个提示告诉用户哪里出了问题。然而,对于更复杂的验证,如检查重复密码是否相同,我们需要使用 State Hook 来跟踪表单的错误状态。
让我们开始实现验证:
-
编辑
src/user/Login.jsx并给以下input字段添加required属性:<input type='text' name='username' id='login-username' **required** /> … <input type='password' name='password' id='login-password' **required** /> -
编辑
src/user/Register.jsx并添加required属性:<input type='text' name='username' id='register-username' **required** /> … <input type='password' name='password' id='register-password' **required** /> … <input type='password' name='password-repeat' id='register-password-repeat' **required** /> -
在
src/user/Register.jsx文件中,也导入useState函数:import { useState } from 'react' -
然后,添加一个新的 State Hook 来跟踪错误状态:
export function Register({ setUsername }) { **const** **[invalidRepeat, setInvalidRepeat] =** **useState****(****false****)**
这种状态被称为局部状态,因为它只需要在一个组件内使用。
-
在
handleSubmit函数中,检查password和password-repeat字段是否相同。如果不相同,设置错误状态并从函数中返回:function handleSubmit(e) { e.preventDefault() **if** **(** **e.****target****.****elements****.****password****.****value** **!==** **e.****target****.****elements****[****'password-repeat'****].****value** **) {** **setInvalidRepeat****(****true****)** **return** **}**
如果不满足某些条件,函数的早期返回通常比嵌套if语句更可取。早期返回使函数易于阅读,并避免代码意外执行的问题。
-
在
if语句之后,如果密码相同,重置错误状态并处理注册:**setInvalidRepeat****(****false****)** const username = e.target.elements.username.value setUsername(username) } -
在表单末尾,在注册按钮之前,如果错误状态被触发,我们插入一条错误信息:
<br /> **{invalidRepeat && (** **<****div****style****=****{{****color:** **'****red****' }}>****Passwords must** **match.****</****div****>** **)}** <input type='submit' value='Register' /> </form>
如果我们现在尝试注册但密码没有正确重复,我们可以看到以下错误信息:

图 3.8 – 使用 Hooks 实现的验证和错误信息
现在我们已经成功实现了验证,我们可以继续将用户名传递给CreatePost组件。
将用户传递给 CreatePost
如您可能已经注意到的,CreatePost组件仍然使用硬编码的用户名。为了能够在那里访问用户名,我们需要将钩子从UserBar组件移动到App组件中:
-
编辑
src/user/UserBar.jsx并剪切/删除以下钩子定义:export function UserBar() { **const** **[username, setUsername] =** **useState****(****''****)** -
然后,调整函数定义以接受这两个属性:
export function UserBar(**{ username, setUsername }**) { -
删除以下
useState导入:import { useState } from 'react' -
现在,编辑
src/App.jsx并从那里导入useState函数:import { useState } from 'react' -
删除以下代码行:
const username = 'Daniel Bugl' -
在
App函数组件内部,添加我们之前移除的钩子:export function App() { **const** **[username, setUsername] =** **useState****(****''****)**
这种状态被称为全局状态,因为它在整个博客应用中的多个组件中都需要,这也是为什么我们将状态钩子移动到App组件中的原因。
-
然后,将
username值和setUsername函数传递给UserBar组件:return ( <div style={{ padding: 8 }}> <UserBar **username****=****{username}****setUsername****=****{setUsername}** />在第五章《实现 React 上下文》中,我们将学习一个更好的解决方案来将登录状态提供给其他组件。现在,我们只是将值和函数传递下去。
-
最后,确保
CreatePost组件仅在用户登录时渲染(username已定义):<br /> **{username &&** <CreatePost username={username} />**}**
现在用户功能已完全实现,我们可以继续使用钩子来实现帖子功能!
为帖子功能添加钩子
在实现了用户功能之后,我们现在将实现帖子的动态创建。我们首先调整App组件,然后修改CreatePost组件以能够插入新帖子。
调整App组件
与username状态类似,我们将在App组件中定义posts作为全局状态,并从那里提供给其他组件。
让我们开始调整App组件:
-
编辑
src/App.jsx并将当前的posts数组重命名为defaultPosts:const **defaultPosts** = [ { title: 'React Hooks', content: 'The greatest thing since sliced bread!', author: 'Daniel Bugl', }, { title: 'Using React Fragments', content: 'Keeping the DOM tree clean!', author: 'Daniel Bugl', }, ] -
然后,在
App函数内部定义一个新的posts状态钩子:export function App() { **const** **[posts, setPosts] =** **useState****(defaultPosts)** -
现在,将
setPosts作为属性传递给CreatePost组件:{username && ( <CreatePost username={username} **setPosts****=****{setPosts}** /> )}
在将状态提供给CreatePost组件后,让我们继续调整它。
调整CreatePost组件
现在,我们需要使用setPosts函数在按下创建按钮时插入一个新的帖子,如下所示:
-
编辑
src/post/CreatePost.jsx并调整函数定义以接受setPosts属性:export function CreatePost({ username**, setPosts** }) { -
接下来,定义一个
handleSubmit函数,在其中我们首先收集所有需要的值:function handleSubmit(e) { e.preventDefault() const form = e.target const title = form.elements.title.value const content = form.elements.content.value const newPost = { title, content, author: username }
在这里,我们将{ title: title }对象赋值简写为{ title },它们具有相同的效果。
-
然后,我们将新帖子插入到数组中:
setPosts((posts) => [newPost, ...posts])
在这里,我们使用一个函数来获取状态钩子的当前值,然后返回一个新值,其中包含插入到数组中的新帖子。
-
最后,我们将重置表单以清除所有输入字段:
form.reset() } -
我们仍然需要将新定义的函数分配给
onSubmit处理程序,如下所示:return ( <form onSubmit={**handleSubmit**}>
现在,我们可以登录并创建一个新的帖子,它将被插入到动态流的开始处!

图 3.9 – 使用 Hooks 插入新帖子后的我们的博客应用的第一版
示例代码
本节示例代码可在Chapter03/Chapter03_2文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
摘要
在本章中,我们从零开始开发了自己的博客应用!我们从一个原型开始,然后创建了静态组件来模拟它。之后,我们实现了 Hooks 以允许动态行为。在整个章节中,我们学习了如何使用 Hooks 处理本地和全局状态。此外,我们学习了如何使用多个 Hooks,以及在哪些组件中定义 Hooks 和存储状态。我们还学习了如何解决常见用例,例如表单验证和提交。
在下一章,第四章,使用 Reducer 和 Effect 钩子,我们将学习 Reducer 钩子,它使我们能够更容易地处理某些状态变化。此外,我们还将学习 Effect 钩子,它允许我们运行具有副作用代码。
问题
为了回顾本章学到的内容,尝试回答以下问题:
-
在 React 中,有哪些好的文件夹结构方式?
-
在拆分 React 组件时,我们应该使用哪个原则?
-
map函数的作用是什么? -
解构是如何工作的,我们何时使用它?
-
扩展运算符是如何工作的,我们何时使用它?
-
我们如何处理表单验证和提交?
-
应该在哪里定义本地状态钩子?
-
什么是全局状态?
-
应该在哪里定义全局状态钩子?
进一步阅读
如果你对本章学到的概念有更多兴趣,请查看以下链接:
- 思考 React的官方文档:
react.dev/learn/thinking-in-react
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第二部分
使用真实世界示例中的 Hooks
在本部分,你将了解各种 Hooks 及其使用方法。我们首先介绍用于管理应用状态的 Reducer 和 Effect Hooks。然后,我们将介绍 React 上下文和 TanStack Query、React Hooks 以及 React Suspense 的数据获取。接下来,我们将学习如何使用 React 表单操作和 Action State Hook 来处理表单提交。我们将继续学习如何使用 React Router 和 Hooks 处理各种路由场景。然后,我们将概述 React 提供的所有内置 Hooks,并了解其他章节中尚未介绍的高级内置 Hooks。最后,我们将通过使用 React 社区提供的各种 Hooks,以及学习如何找到更多有用的 Hooks 来结束本部分。
本部分包含以下章节:
-
第四章, 使用 Reducer 和 Effect Hooks
-
第五章, 实现 React 上下文
-
第六章, 使用 Hooks 和 React Suspense 进行数据获取
-
第七章, 使用 Hooks 处理表单
-
第八章, 使用 Hooks 进行路由
-
第九章, React 提供的高级 Hooks
-
第十章, 使用社区 Hooks
第四章:使用 Reducer 和 Effect Hooks
在使用 State Hook 开发我们自己的博客应用程序之后,我们现在将学习 React 提供的另外两个非常重要的 Hooks——Reducer 和 Effect Hooks。我们首先将学习何时应该使用 Reducer Hook 而不是 State Hook。然后,我们将学习如何将现有的 State Hook 转换为 Reducer Hook,以便在实践中掌握这个概念。最后,我们将学习 Effect Hooks 的用途以及如何在我们的博客应用程序中实现它们。
本章将涵盖以下主题:
-
Reducer Hooks 与 State Hooks 的比较
-
使用 Reducer Hooks
-
使用 Effect Hooks
技术要求
应该已经安装了相当新的 Node.js 版本。Node 包管理器 (npm) 也需要安装(它应该与 Node.js 一起提供)。有关如何安装 Node.js 的更多信息,请查看他们的官方网站:nodejs.org/。
在这本书的指南中,我们将使用 Visual Studio Code (VS Code),但在任何其他编辑器中都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com。
在这本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npm v10.9.2
-
Visual Studio Code v1.97.2
虽然安装新版本不应该有问题,但请注意,某些步骤在较新版本上可能会有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter04。
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
Reducer Hooks 与 State Hooks 的比较
在上一章中,我们学习了处理局部和全局状态。我们在这两种情况下都使用了 State Hooks,这对于简单的状态更改来说是可行的。然而,如果我们的状态逻辑变得更加复杂,我们需要确保保持状态的一致性。为此,我们应该使用 Reducer Hook,而不是多个 State Hooks,因为维护多个相互依赖的 State Hooks 之间的同步性更困难。作为替代方案,我们可以将所有状态保存在一个 State Hook 中,但这时我们必须确保不会意外地覆盖我们的状态的一部分。
State Hook 的局限性
状态钩子已经支持传递复杂对象和数组给它,并且它可以完美地处理它们的状态变化。然而,我们始终需要直接更改状态,这意味着我们需要使用大量的解构来确保我们没有覆盖状态的其他部分。例如,想象我们有一个如下所示的状态钩子:
const [config, setConfig] = useState({
filter: 'all',
expandPosts: true,
})
现在,假设我们想按照以下方式更改过滤器:
setConfig({
filter: {
author: 'Daniel Bugl',
fromDate: '2024-10-02',
},
})
如果我们只是这样做,我们就会从状态对象中删除expandPosts设置!因此,我们需要使用扩展运算符,如下所示:
setConfig(**config** **=>** **(**{
**...config,**
filter: {
author: 'Daniel Bugl',
fromDate: '2024-10-02',
}
}**)**)
现在,如果我们想将fromDate过滤器更改为不同的日期,我们需要使用两次扩展运算符,以避免意外删除author过滤器:
setConfig(config => ({
...config,
filter: {
**...config.**filter,
fromDate: '2024-10-03',
}
}))
但如果我们这样做时filter状态仍然是字符串,就像原始对象中那样(filter: 'all'),会发生什么?我们将得到以下结果:
{
filter: {
'0': 'a',
'1': 'l',
'2': 'l',
fromDate: '2024-10-03',
},
expandPosts: true
}
什么?为什么突然出现了三个新的键——'0'、'1'和'2'?答案是,扩展运算符也适用于字符串,字符串以这种方式展开,每个字母都根据其在字符串中的索引获得一个键。
如您所想象,使用扩展运算符和直接改变状态对象对于较大的状态对象来说可能会变得非常繁琐。此外,我们始终需要确保不会引入任何错误,并且我们需要在我们的应用程序的多个地方检查错误。
Reducers
我们不是直接改变状态,而是可以创建一个处理状态变化的函数。这样的函数被称为reducer,它的工作方式如下:
const newState = reducer(currentState, action)
如您所见,我们不是直接改变状态对象,而是调用一个函数,该函数接受当前状态和动作对象,并返回一个新的状态对象。在我们定义函数之前,让我们首先更详细地看看动作。
Actions
动作是具有包含动作名称的type属性的对象,并且可以包含有关动作的一些附加信息。
让我们回顾一下之前的状态对象:
{
filter: 'all',
expandPosts: true,
}
如果我们想改变expandPosts状态,我们会使用TOGGLE_EXPAND动作,这个动作不需要任何额外的信息。动作将如下所示:
{ type: 'TOGGLE_EXPAND' }
如果我们想改变过滤器,我们会使用CHANGE_FILTER动作,该动作还包含有关应更改的过滤器的信息。例如,我们可以使用以下动作以不同的方式更改过滤器:
{ type: 'CHANGE_FILTER', all: true }
{ type: 'CHANGE_FILTER', fromDate: '2024-10-02' }
{ type: 'CHANGE_FILTER', author: 'Daniel' }
{ type: 'CHANGE_FILTER', fromDate: '2024-10-03' }
第二、第三和第四个动作会将filter状态从字符串更改为对象,并设置相应的键。如果对象已经存在,它将简单地调整动作中定义的键。在这些动作之后,状态将按以下方式改变:
{ filter: 'all' }
{ filter: { fromDate: '2024-10-02' } }
{ filter: { fromDate: '2024-10-02', author: 'Daniel' } }
{ filter: { fromDate: '2024-10-03', author: 'Daniel' } }
现在,让我们假设我们应用了以下动作:
{ type: 'CHANGE_FILTER' all: true }
在这个动作之后,过滤器将回到初始状态的'all'字符串。
如果你之前使用过 Redux 库,你将已经熟悉状态、动作和 reducer 的概念。
定义 reducer
我们定义的动作的 reducer 函数可能看起来如下:
function reducer(state, action) {
switch (action.type) {
在这里,我们定义了函数,并根据action.type决定对状态进行什么操作。首先,我们处理TOGGLE_EXPAND动作:
case 'TOGGLE_EXPAND':
return { ...state, expandPosts: !state.expandPosts }
现在,处理CHANGE_FILTER函数,其中如果动作定义了all: true过滤器,我们将过滤器重置为字符串'all':
if (action.all) {
return { ...state, filter: 'all' }
}
如果过滤器仍然是一个字符串,我们初始化一个空对象;否则,我们重用现有的对象:
let filter = typeof state.filter === 'string' ? {} : state.filter
现在我们可以根据动作中定义的设置fromDate和author过滤器:
if (action.fromDate) {
filter.fromDate = action.fromDate
}
if (action.author) {
filter.author = action.author
}
最后,状态被返回为新的过滤器:
return { ...state, filter }
}
如果动作类型未知,我们将抛出一个错误:
default:
throw new Error('unknown action')
}
}
在默认情况下抛出错误与我们在 Redux reducer 中做的不同,在 Redux reducer 中,我们会在默认情况下简单地返回当前状态。React Reducer Hooks 不会在单个全局对象中存储所有状态,我们只为某些状态对象处理某些动作,因此我们可以为未知动作类型抛出错误。
当我们仍在 reducer 函数中使用一些扩展运算符时,它并不像之前那样深层嵌套。此外,所有状态处理都在一个地方,我们通过动作一次只更改状态的一部分,这使得代码更容易维护且更少出错。
Reducer Hook
现在我们有了 reducer 函数,我们只需要定义一个初始状态:
const initialState = { filter: 'all' }
使用 reducer 函数和初始状态,我们可以创建一个 Reducer Hook:
const [state, dispatch] = useReducer(reducer, initialState)
现在可以通过从 Hook 返回的state对象访问当前状态。使用dispatch函数允许我们调用传递给 Reducer Hook 的 reducer 函数。动作可以通过dispatch函数分发。例如:
dispatch({ type: 'TOGGLE_EXPAND' })
分发一个动作将调用 reducer 函数,并带上当前状态和分发的动作,并将返回的状态设置为 Reducer Hook 的新状态。
如果我们想在动作中添加额外的信息,我们只需将其添加到对象中:
dispatch({ type: 'CHANGE_FILTER', fromDate: '2024-10-03' })
如我们所见,使用动作和 reducer 处理状态变化比直接调整状态对象更容易阅读和维护。
现在我们已经了解了 Reducer Hooks 以及何时使用它们而不是 State Hooks,让我们开始使用它们。
使用 Reducer Hooks
在了解动作、reducer 和 Reducer Hook 之后,我们将在我们的博客应用中使用它。任何现有的 State Hook 都可以在状态或状态变化变得过于复杂时转换为 Reducer Hook。
如果有多个setState函数总是在同一时间被调用,这是一个很好的提示,它们应该被组合在一个单独的 Reducer Hook 中。
全局状态通常适合使用 Reducer Hook,而不是 State Hook,因为它的更改可以在应用的任何地方发生。当状态更改只在一个函数中处理,并且组件分发动作而不是直接修改状态时,处理状态更改要容易得多。将所有状态更改逻辑放在一个地方使得维护和修复错误变得更容易,而不会因为忘记更新逻辑而在任何地方引入新的错误。
将 State Hook 转换为 Reducer Hook
在我们的博客应用中,我们有两个全局 State Hook:
-
username状态 – 包含当前登录用户的用户名 -
posts状态 – 包含我们动态中的所有帖子
username 状态相当简单,它只包含一个用户名的字符串。因此,目前将此转换为 Reducer Hook 没有意义,因为状态更改是直接的:
-
在登录/注册时:设置用户名
-
在注销时:清除用户名
对于 posts 状态,我们之前已经需要使用扩展运算符来避免在创建新帖子时意外地从动态中删除帖子。因此,它似乎是一个很好的 Reducer Hook 候选,特别是考虑到它可能在未来扩展(获取新帖子、更新帖子、删除帖子等)。
现在,让我们开始用 Reducer Hook 替换 posts State Hook。
定义动作
我们首先为我们的 Reducer Hook 定义动作。目前,我们只考虑 CREATE_POST 动作:
{
type: 'CREATE_POST',
post: {
title: 'React Hooks',
content: 'The greatest thing since sliced bread!',
author: 'Daniel Bugl',
},
}
接下来,我们将实现 reducer 函数。
实现 reducer
目前,我们将把我们的 reducer 放在 src/reducers.js 文件中。稍后,如果我们有多个 reducer,可能有必要创建一个单独的 src/reducers/ 文件夹,并为每个 reducer 函数创建单独的文件。
让我们开始实现 reducer 函数:
-
通过执行以下命令将
Chapter03_2文件夹复制到新的Chapter04_1文件夹:$ cp -R Chapter03_2 Chapter04_1 -
在 VS Code 中打开新的
Chapter04_1文件夹。 -
创建一个新的
src/reducers.js文件,在其中定义并导出postsReducer函数:export function postsReducer(state, action) { -
我们使用
switch语句来处理不同的动作类型:switch (action.type) { -
接下来,处理
CREATE_POST动作,将新帖子(来自action.post)插入数组的开头,如下所示:case 'CREATE_POST': return [action.post, ...state] -
目前,这将是我们要处理的唯一动作类型,因此我们现在可以定义
default语句,当遇到未知动作类型时抛出错误:default: throw new Error('Unknown action type') } }
在定义 reducer 函数后,我们现在可以使用它来定义 Reducer Hook。
定义 Reducer Hook
要定义 Reducer Hook,请按照以下步骤操作:
-
编辑
src/App.jsx并导入useReducer和postsReducer函数:import { useState**, useReducer** } from 'react' **import** **{ postsReducer }** **from****'./reducers.js'** -
删除 以下 State Hook:
export function App() { **const** **[posts, setPosts] =** **useState****(defaultPosts)**
替换 它为 Reducer Hook:
export function App() {
**const** **[posts, dispatch] =** **useReducer****(postsReducer, defaultPosts)**
-
然后,而不是
setPosts,将dispatch函数传递给CreatePost组件,并 删除setPosts属性:{username && <CreatePost username={username} **dispatch****=****{dispatch}** />} -
接下来,编辑
src/post/CreatePost.jsx并将setPosts属性替换为dispatch属性:export function CreatePost({ username, **dispatch** }) { -
现在不再以命令式的方式添加新帖子,而是在
handleSubmit函数内部分发一个动作:function handleSubmit(e) { e.preventDefault() const form = e.target const title = form.elements.title.value const content = form.elements.content.value const newPost = { title, content, author: username } **dispatch****({** **type****:** **'CREATE_POST'****,** **post****: newPost })** form.reset() }
对于更复杂的动作,定义将创建动作对象的函数(所谓的 动作创建器)可能是有意义的。例如,一个 createPostAction(post) 函数可以创建并返回 CREATE_POST 动作对象。动作创建器可以帮助确保动作对象的一致结构,使我们更容易创建它们,并允许在未来轻松调整这种结构。
现在的 posts 状态使用 Reducer 钩子而不是 State 钩子,但它的功能与之前相同!如果我们想在以后添加更多用于管理帖子的逻辑,例如删除和编辑帖子,现在将更容易做到这一点。
示例代码
本节的示例代码可以在 Chapter04/Chapter04_1 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
在学习了 Reducer 钩子之后,让我们继续学习 Effect 钩子。
使用 Effect 钩子
Effect 钩子是一个重要的钩子,用于将组件与外部系统(如外部 API 或浏览器 API)同步。然而,在 React 代码中它经常被过度使用。如果没有外部系统参与,你不应该使用 Effect 钩子。
在我们博客的例子中,我们将在 Logout 组件中实现一种检查用户是否有管理员角色的方法。为了简单起见,并专注于 Effect 钩子本身,我们只是简单地模拟这个检查,但想象一下这是由外部 API 完成的。
记得 componentDidMount 和 componentDidUpdate 吗?
如果你之前使用过较老的 React 版本,你可能已经使用过 componentDidMount 和 componentDidUpdate 生命周期方法。例如,如果我们想使用 React 类组件将网页标题设置为给定的属性,我们需要添加以下生命周期方法:
import React from 'react'
class App extends React.Component {
**componentDidMount****() {**
**const** **{ title } =** **this****.****props**
**document****.****title** **= title**
**}**
render() {
return <div>Example App</div>
}
}
这工作得很好。然而,当 title 属性更新时,变化并没有反映在我们的网页标题上。为了解决这个问题,我们需要定义 componentDidUpdate 生命周期方法,如下所示:
import React from 'react'
class App extends React.Component {
componentDidMount() {
const { title } = this.props
document.title = title
}
**componentDidUpdate****(****prevProps****) {**
**const** **{ title } =** **this****.**props
**if** **(title !== prevProps.****title****) {**
**document****.****title** **= title**
**}**
**}**
render() {
return <div>Example App</div>
}
}
你可能已经注意到我们正在做完全相同的事情两次;因此,我们可以创建一个新的方法来处理标题的更新,然后从两个生命周期方法中调用它。在下面的代码块中,更新的代码以粗体显示:
import React from 'react'
class App extends React.Component {
**updateTitle****() {**
**const** **{ title } =** **this****.****props**
**document****.****title** **= title**
**}**
componentDidMount() {
**this****.****updateTitle****()**
}
componentDidUpdate(prevProps) {
if (this.props.title !== prevProps.title) {
**this****.****updateTitle****()**
}
}
render() {
return <div>Example App</div>
}
}
然而,我们仍然需要调用 this.updateTitle() 两次。当我们稍后更新代码,例如向 this.updateTitle() 传递一个参数时,我们始终需要记得将其传递给函数的两个调用。如果我们忘记更新其中一个生命周期方法,我们可能会引入错误。此外,我们需要在 componentDidUpdate 中添加一个 if 条件,以避免在 title 属性没有变化时调用 this.updateTitle()。
从生命周期方法到 Effect 钩子
在 Hooks 的世界中,componentDidMount 和 componentDidUpdate 生命周期方法被合并到 useEffect 钩子中,当不指定 依赖数组 时,它会在每次重新渲染时触发。我们将在下一小节中了解更多关于依赖数组的内容。
因此,我们不再需要使用类组件,现在我们可以定义一个带有 Effect 钩子的函数组件,它将完成与之前相同的事情:
import { useEffect } from 'react'
function App({ title }) {
**useEffect****(****() =>** **{**
**document****.****title** **= title**
**})**
return <div>Example App</div>
}
这就是我们需要做的全部!每当组件重新渲染时,Effect 钩子都会调用提供的函数。
自 React 19 以来,通过在任意组件中定义 <title>(或 <link> 或 <meta>)元素,可以更改网页的标题(或任何元数据标签)。然后,这些元素将自动提升到 <head> 部分。
仅在特定 props 发生变化时触发效果
如果我们想确保我们的效果函数仅在 title prop 发生变化时被调用,我们可以指定应该触发变化的值,作为 useEffect 钩子的第二个参数——依赖数组:
useEffect(() => {
document.title = title
}**, [title]**)
依赖数组不仅限于 props;我们还可以使用从组件体内可用的任何值,包括在组件内部定义的变量和其他 Hook 的值,例如 State Hook 或 Reducer Hook:
const [title, setTitle] = useState('')
useEffect(() => {
document.title = title
}, [title])
如我们所见,使用 Effect 钩子比处理生命周期方法要简单得多。我们只需要指定 Effect 钩子应依赖于哪些值。每当这些值中的任何一个发生变化时,效果函数会自动再次被调用。
仅在挂载时触发效果
如果我们只想添加 componentDidMount 生命周期钩子的行为,而不在 props 发生变化时触发,我们可以通过将空数组作为 useEffect 钩子的第二个参数来做到这一点:
useEffect(() => {
document.title = title
}, **[]**)
传递空数组意味着我们的效果函数仅在组件挂载时触发一次,并且当 props 发生变化时不会触发。然而,与组件的挂载相比,使用 Hooks,我们应该考虑效果依赖。在这种情况下,效果没有依赖,这意味着它只会触发一次。如果一个效果有指定的依赖,那么当任何依赖发生变化时,它将再次触发。
清理效果
有时在组件卸载时需要清理效果。为此,我们可以从 Effect 钩子中返回一个函数。这个返回的函数与 componentWillUnmount 生命周期方法的工作方式类似:
useEffect(() => {
const updateInterval = setInterval(
() => console.log('fetching update'),
updateTime,
)
**return****() =>****clearInterval****(updateInterval)**
}, [updateTime])
突出的代码被称为 清理函数。当组件卸载并再次运行效果之前,清理函数会被调用。这避免了例如 updateTime prop 发生变化时的错误。在这种情况下,先前的效果将被清理,并定义一个新的 updateTime 间隔。
在我们的博客应用中实现 Effect 钩子
现在我们已经了解了 Effect Hook 的工作原理,我们将在我们的博客应用中使用它,以实现当用户已经登录时检查用户角色的方法。
让我们按照以下步骤实现一个 Effect Hook:
-
通过执行以下命令将
Chapter04_1文件夹复制到新的Chapter04_2文件夹:$ cp -R Chapter04_1 Chapter04_2 -
在 VS Code 中打开新的
Chapter04_2文件夹。 -
编辑
src/user/Logout.jsx并导入useState和useEffect函数:import { useState, useEffect } from 'react' -
然后,定义一个函数,该函数模拟一个检查用户角色的外部 API:
function getRole(username) { -
为了简化,我们说任何名为
admin的用户都具有admin角色。所有其他用户都具有user角色:if (username === 'admin') return 'admin' return 'user' } -
现在,我们需要为角色定义一个 State Hook:
export function Logout({ username, setUsername }) { **const** **[role, setRole] =** **useState****(****'user'****)** -
接下来,定义一个通过调用“外部 API”并使用其响应来设置角色的 Effect Hook:
useEffect(() => { setRole(getRole(username)) }, [username])
最好的做法是在依赖数组中列出所有在 Effect Hook 中使用的值(和函数)。这确保了在值看起来是静态的当前时刻,它们在以后变得动态时不会出现意外的错误。幸运的是,React Hooks ESLint 插件(已经在我们的项目中设置)会警告我们,如果我们忘记添加依赖项。
-
最后,我们显示用户具有特殊角色时的角色:
return ( <form onSubmit={handleSubmit}> Logged in as: <b>{username}</b> **{role !==** **'user'** **?** **` (role:****${role}****)`** **:** **''****}** -
通过执行以下命令启动博客应用:
$ npm run dev
尝试使用用户名 admin 登录。你会看到角色现在显示在用户名旁边!
示例代码
本节示例代码位于 Chapter04/Chapter04_2 文件夹中。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
摘要
在本章中,我们首先了解了动作、reducer 和 Reducer Hook。我们还学习了何时应该使用 Reducer Hook 而不是 State Hook。然后,我们将现有的 posts 状态的全局 State Hook 替换为 Reducer Hook。接下来,我们学习了 Effect Hook,以及它们如何替代 componentDidMount 和 componentDidUpdate 生命周期方法。最后,我们通过使用 Effect Hook 在我们的博客应用中实现了角色验证。
在下一章中,我们将学习 React Context 以及如何使用 Hooks。然后,我们将向我们的应用添加 Context Hooks,以避免在多个组件层传递 props。
问题
为了回顾本章所学内容,尝试回答以下问题:
-
State Hook 常见的问题有哪些?
-
动作是什么?
-
Reducer 是什么?
-
我们应该在什么情况下使用 Reducer Hook 而不是 State Hook?
-
需要哪些步骤才能将 State Hook 转换为 Reducer Hook?
-
我们如何更轻松地创建动作?
-
Effect Hook 在类组件中的等效物是什么?
-
与类组件相比,使用 Effect Hook 的优点是什么?
-
依赖数组是什么,它是如何工作的?
-
如何使用 Effect Hooks 与清理函数一起使用?
进一步阅读
如果你对本章学到的概念感兴趣,想了解更多,请查看以下链接:
-
关于 Reducer Hook 的官方文档:
react.dev/reference/react/useReducer -
使用 Effect Hooks 的官方文档和技巧:
react.dev/reference/react/hooks#effect-hooks -
关于何时不应使用 Effect Hook 的更多信息:
react.dev/learn/you-might-not-need-an-effect -
关于 Redux 的更多信息,这是一个提供更广泛动作和 reducer 版本的库:
redux.js.org
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第五章:实现 React 上下文
在前面的章节中,我们学习了 State Hook、Reducer Hook 和 Effect Hook。我们使用这些 Hooks 开发了一个小型博客应用程序。正如我们在开发博客应用程序时注意到的那样,我们必须从App组件向下传递username状态到UserBar组件,然后从UserBar组件传递到Login、Register和Logout组件。为了避免必须以这种方式传递状态,我们现在将学习 React Context 和 Context Hooks。
我们将首先通过实现主题作为上下文的示例来学习 React 上下文是什么,以及提供者和消费者是什么。然后,我们将使用 Hooks 作为上下文消费者,并讨论何时应该使用上下文。最后,我们将使用上下文实现全局状态。
本章将涵盖以下主题:
-
介绍 React 上下文
-
通过上下文实现主题
-
上下文的替代方案
-
使用上下文进行全局状态管理
技术要求
应该已经安装了相当新的 Node.js 版本。Node 包管理器(npm)也需要安装(它应该与 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请访问官方网站:nodejs.org/。
我们将在本书的指南中使用Visual Studio Code(VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅官方网站:code.visualstudio.com。
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
VS Code v1.97.2
虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter05。
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
介绍 React 上下文
在前面的章节中,我们将username状态和setUsername函数从App组件传递到UserBar组件;然后从UserBar组件传递到Logout、Login和Register组件。React Context 提供了一种解决方案,通过允许我们在组件之间共享值,而无需通过 props 显式传递它们,从而解决了在多个组件层级之间传递 props 的繁琐方式。正如我们将要看到的,上下文非常适合在整个应用程序中共享全局状态。
传递 props
在深入了解 React Context 之前,让我们回顾一下我们在前面的章节中实现的内容,以了解上下文解决的问题。在此阶段,你不需要编辑任何代码;这些步骤只是对我们已经完成的内容的回顾。只需阅读以下步骤:
-
在
src/App.jsx中,我们使用 State Hook 定义了username状态和setUsername函数:export function App() { const [posts, dispatch] = useReducer(postsReducer, defaultPosts) **const** **[username, setUsername] =** **useState****(****''****)** -
然后,我们将
username状态和setUsername函数传递给UserBar组件:return ( <div style={{ padding: 8 }}> <UserBar **username****=****{username}****setUsername****=****{setUsername}** /> -
在
src/user/UserBar.jsx文件中,我们定义了一个UserBar组件,它接受username状态作为 prop,并将其传递给Logout组件。我们还向Logout、Login和Register组件传递了setUsername函数:export function UserBar({ **username, setUsername** }) { if (username) { return <Logout **username****=****{username}****setUsername****=****{setUsername}** /> } else { return ( <> <Login **setUsername****=****{setUsername}** /> <hr /> <Register **setUsername****=****{setUsername}** /> </> ) } } -
最后,我们在
Logout、Login和Register组件中使用了setUsername函数和username状态:export function Login({ **setUsername** }) { function handleSubmit(e) { e.preventDefault() const username = e.target.elements.username.value **setUsername****(username)** }
React Context 允许我们跳过步骤 2和步骤 3,直接从步骤 1跳到步骤 4。正如你可以想象的那样,在更大的应用程序中,上下文变得更加有用,因为我们可能需要在多个层级传递 props。
在下一节中,我们首先将通过为我们的博客实现主题系统来学习上下文的工作原理。然后,我们将使用 React Context 来处理我们的博客应用程序中的username全局状态。
通过上下文实现主题
React Context用于在 React 组件树中共享值。通常,我们想要共享全局值,例如username状态、我们的应用程序的主题或选择的语言(如果应用程序支持多种语言)。
React Context 由三个部分组成:
-
上下文本身,它定义了一个默认值,并允许我们提供和消费值
-
提供者,它提供(设置)值
-
消费者,它消费(使用)值
定义上下文
首先,我们必须定义上下文。自从引入 Hooks 以来,这种方式没有改变。我们只需使用 React 中的createContext(defaultValue)函数来创建一个新的上下文对象。在这种情况下,我们将默认值设置为{ primaryColor: 'maroon' },因此当没有提供者定义时,我们的默认主颜色将是栗色。
现在,让我们开始定义上下文:
-
通过执行以下命令将
Chapter04_2文件夹复制到新的Chapter05_1文件夹:$ cp -R Chapter04_2 Chapter05_1 -
在 VS Code 中打开新的
Chapter05_1文件夹。 -
为了保持我们的项目在增长过程中保持整洁,我们现在通过首先按基础原语分组,然后在那个文件夹内按功能分组来扩展文件夹结构。现在创建一个新的
src/contexts/文件夹。 -
此外,创建一个新的
src/components/文件夹。 -
将
src/post/和src/user/文件夹移动到src/components/文件夹中。 -
编辑
src/App.jsx并调整导入,如下所示:import { UserBar } from './**components/**user/UserBar.jsx' import { CreatePost } from './**components/**post/CreatePost.jsx' import { PostList } from './**components/**post/PostList.jsx' -
创建一个新的
src/contexts/ThemeContext.js文件。在其内部,导入createContext函数:import { createContext } from 'react' -
现在,使用前面提到的默认值定义上下文:
export const ThemeContext = createContext( { primaryColor: 'maroon' } )
当上下文被消费但没有定义提供者时,它将返回此默认值。
注意我们在这里导出ThemeContext,因为我们稍后需要导入它来创建提供者,并使用 Context Hook 来消费它。
这就是我们使用 React 定义上下文所需做的全部工作。
快速转向——绝对导入
如果我们现在在组件中导入上下文,我们将不得不从../../contexts/ThemeContext.js导入。除了当文件深度嵌套时难以阅读的事实外,它还可能在稍后组织文件到子文件夹时引起问题。为了避免这些问题,我们可以使用绝对导入。绝对导入允许我们从项目的根目录导入。它们是通过 Vite 中的resolve 别名实现的。基本上,我们可以告诉 Vite 将一个特殊字符,例如@符号,解析到src文件夹的绝对路径。这意味着我们可以从@/contexts/ThemeContext.js导入上下文。
让我们现在开始配置绝对导入:
-
编辑
vite.config.js并导入path实用工具:import path from 'node:path' -
在
config对象中,添加一个resolve别名,如下所示:export default defineConfig({ plugins: [react()], **resolve****: {** **alias****: [{** **find****:** **'@'****,** **replacement****:** **path.****resolve****(****import****.****meta****.****dirname****,** **'src'****) }],** **},** }) -
此外,我们可以通过创建一个
jsconfig.json文件来改进代码编辑器或 IDE 中的自动完成。此文件将告诉编辑器有关我们的绝对导入配置,并使我们能够轻松地从它导入文件。现在创建一个新的jsconfig.json文件。 -
在内部添加以下配置:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "exclude": ["node_modules"] }
现在我们可以使用绝对导入,让我们继续定义消费者。
定义消费者
要使用上下文,我们需要一个消费者。在我们开始使用 Hooks 之前,让我们首先回顾一下定义消费者的传统方式:
-
编辑
src/components/post/Post.jsx并在其中导入ThemeContext:import { ThemeContext } from '@/contexts/ThemeContext.js' -
将整个组件包裹在一个
ThemeContext.Consumer组件中,并将其children属性设置为渲染函数,以使用上下文值:export function Post({ title, content, author }) { return ( **<****ThemeContext****.****Consumer****>** **{****(****theme****) =>** **(**
渲染函数允许我们将值传递给组件的子组件。
-
在渲染函数内部,我们现在可以利用上下文值来设置博客文章标题的颜色,如下所示:
<div> <h3 style={{ color: theme.primaryColor }}> {title} </h3> <div>{content}</div> <br /> <i> Written by <b>{author}</b> </i> </div> **)}** **</****ThemeContext.Consumer****>** ) }
使用上下文的方式是可行的,但正如我们在第一章中学到的,以这种方式使用具有渲染函数的组件会使得 React 树变得杂乱,并使得我们的应用更难调试和维护。
使用 Hooks 来消费上下文
消费上下文的一个更好的方法是使用 Context Hook!这样,我们可以像使用其他任何值一样使用上下文值。
按照以下步骤将消费者改为上下文钩子:
-
编辑
src/components/post/Post.jsx并添加以下导入:import { useContext } from 'react' -
然后,定义一个上下文钩子,如下所示:
export function Post({ title, content, author }) { **const** **theme =** **useContext****(****ThemeContext****)** -
接下来,删除以下突出显示的代码部分:
return ( **<****ThemeContext.Consumer****>** **{(theme) => (** <div> <h3 style={{ color: theme.primaryColor }}>{title}</h3> <div>{content}</div> <br /> <i> Written by <b>{author}</b> </i> </div> **)}** **</****ThemeContext.Consumer****>** ) }
如你所见,使用上下文钩子允许我们直接从上下文中消费值,并简单地渲染帖子,而无需使用包装组件。
-
通过执行以下命令启动博客应用:
$ npm run dev
我们可以看到,博客帖子的标题现在变成了朱红色:

图 5.1 – 使用上下文钩子更改我们的应用主题
如我们所见,主题上下文成功为帖子标题提供了颜色。
定义提供者
当没有定义提供者时,上下文使用传递给createContext的默认值。例如,让我们想象我们的组件使用ThemeContext并按以下方式渲染:
<Component />
然后,primaryColor将被设置为maroon(我们之前定义了它)。这可以用作后备,例如,当组件不是嵌入到应用中,而是嵌入到一个交互式样式指南(如 Storybook)中时。
当定义了提供者时,它们将使用提供者的值。让我们这样渲染组件:
<ThemeContext.Provider value={{ primaryColor: 'black' }}>
<Component />
</ThemeContext.Provider>
然后,primaryColor将被设置为black。
如果树中有多个提供者,组件将使用最近父提供者的值。例如,假设我们这样渲染组件:
<ThemeContext.Provider value={{ primaryColor: 'black' }}>
<OtherComponent />
<ThemeContext.Provider value={{ primaryColor: 'red' }}>
<Component />
</ThemeContext.Provider>
</ThemeContext.Provider>
然后,组件中的primaryColor将被设置为red,因为那是树中离组件最近的提供者。然而,在这个例子中,OtherComponent的primaryColor仍然设置为black。
如我们所见,没有提供者的上下文只是一个静态值;提供者(特别是与其他钩子结合使用时,如用于值的 State 钩子)允许我们动态地改变上下文的值。
现在让我们定义提供者。按照以下步骤开始:
-
编辑
src/App.jsx并导入ThemeContext:import { ThemeContext } from './contexts/ThemeContext.js' -
然后,将
App组件的内容包裹在ThemeContext.Provider组件中,并提供一个值:export function App() { const [posts, dispatch] = useReducer(postsReducer, defaultPosts) const [username, setUsername] = useState('') return ( **<****ThemeContext.Provider****value****=****{{****primaryColor:** **'****black****'** **}}>** <div style={{ padding: 8 }}> <UserBar username={username} setUsername={setUsername} /> <br /> {username && <CreatePost username={username} dispatch={dispatch} />} <hr /> <PostList posts={posts} /> </div> **</****ThemeContext.Provider****>** ) }
如我们所见,帖子标题现在又回到了黑色。如果我们想改变上下文的值,我们可以简单地调整传递给provider组件的value属性。例如,我们也可以使用状态钩子来动态改变上下文的值。我们将在使用上下文进行全局状态时尝试这一点。
如果我们不向提供者传递value属性,上下文的默认值将不会被使用!如果我们定义了一个没有value属性的提供者,那么上下文的值将是undefined。
现在我们已经为上下文定义了一个单独的提供者,接下来让我们继续定义多个嵌套提供者。
嵌套提供者
使用 React Context,我们也可以为同一个上下文定义多个提供者。使用这种技术,我们可以在我们应用的某些部分覆盖上下文值。例如,假设我们想在博客应用中实现一个特色帖子部分。然后,我们可以这样做:
-
编辑
src/App.jsx并定义一个新的featuredPosts数组:const featuredPosts = [ { title: 'React Context', content: 'Manage global state with ease!', author: 'Daniel Bugl', }, ] -
现在,在
App组件内部,渲染一个新的PostList组件,渲染featuredPosts数组,但将其包裹在另一个ThemeContext.Provider中:export function App() { const [posts, dispatch] = useReducer(postsReducer, defaultPosts) const [username, setUsername] = useState('') return ( <ThemeContext.Provider value={{ primaryColor: 'black' }}> <div style={{ padding: 8 }}> <UserBar username={username} setUsername={setUsername} /> <br /> {username && <CreatePost username={username} dispatch={dispatch} />} <hr /> **<****ThemeContext.Provider****value****=****{{****primaryColor:** **'****salmon****' }}>** **<****PostList****posts****=****{featuredPosts}** **/>** **</****ThemeContext.Provider****>** <PostList posts={posts} /> </div> </ThemeContext.Provider> ) }
现在,你会发现特色帖子与其他帖子颜色不同:

图 5.2 – 通过覆盖嵌套提供者的上下文值来实现特色帖子
示例代码
本节的示例代码可以在Chapter05/Chapter05_1文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
上下文替代方案
我们应该小心不要过度使用 React Context,因为它会使组件的重用变得更加困难。我们只应该在需要访问不同嵌套级别的多个组件中的数据时使用 context。此外,我们需要确保我们只使用上下文来处理非频繁变化的数据。上下文频繁变化的价值,尤其是那些在组件树中位置较高的上下文,可能会导致组件树的大部分重新渲染,从而引起性能问题。这就是为什么对于频繁变化的价值,我们应该使用像 Jotai、Redux 或 MobX 这样的状态管理解决方案。这些状态管理解决方案允许我们以细粒度的方式访问状态的小部分,从而减少重新渲染的数量。上下文的好候选包括主题和翻译(i18n)系统等特性。
如果我们只想避免传递 props,在某些情况下,我们可以传递渲染的组件而不是数据。例如,假设我们有一个Page组件,它渲染一个Header组件,该组件又渲染一个Profile组件,然后该组件再渲染一个Avatar组件。我们向Page组件传递了一个headerSize prop,我们不仅需要在Header组件中,还需要在Avatar组件中使用它:
function Page({ headerSize }) {
return <Header size={headerSize} />
}
function Header({ size }) {
// ... makes use of size ...
return <Profile size={size} />
}
function Profile({ size }) {
// ... does not use size, only passes it down ...
return <Avatar size={size} />
}
function Avatar({ size }) {
// ... makes use of size ...
}
而不是通过多个层级传递 props,我们可以这样做:
function Page({ headerSize }) {
const profile = (
<Profile>
<Avatar size={headerSize} />
</Profile>
)
return <Header size={headerSize} profile={profile} />
}
现在,只有Page组件需要知道 props,并且没有必要在树中进一步传递它们。在这种情况下,上下文是不必要的。
这种模式被称为控制反转,它可以使得你的代码比传递 props 或使用 context 更简洁。然而,我们也不应该总是使用这种模式,因为它会使高级组件变得更加复杂。
现在,让我们继续学习如何使用上下文进行全局状态管理。
使用上下文进行全局状态管理
在学习了如何在我们的博客应用中使用 React Context 来实现主题之后,我们现在将使用一个上下文来避免手动传递 username 和 setUsername 属性。用户状态是一个全局状态,这意味着它在整个应用中都被使用。它也不经常改变。因此,它是一个使用上下文的良好候选者。就像我们之前做的那样,我们首先定义上下文。
定义上下文
要定义上下文,我们需要再次使用 createContext 函数。在这种情况下,我们将默认值设置为包含空字符串和一个 no-op 函数(一个什么也不做的函数)的数组。稍后,在定义提供者时,我们将使用 State Hook 的结果提供这个数组。记住,State Hook 返回一个这样的数组:[value, setValue]。
现在,让我们开始定义上下文:
-
通过执行以下命令将
Chapter05_1文件夹复制到新的Chapter05_2文件夹:$ cp -R Chapter05_1 Chapter05_2 -
在 VS Code 中打开新的
Chapter05_2文件夹。 -
创建一个新的
src/contexts/UserContext.js文件。在文件内部,导入createContext函数:import { createContext } from 'react' -
现在,使用前面提到的默认值定义上下文,这模仿了 State Hook 的返回值,但使用空字符串和一个 no-op 函数(一个什么也不做的函数):
export const UserContext = createContext( ['', () => {}] )
当上下文被消费但没有定义提供者时,它将返回这个默认值。
现在,让我们继续定义上下文提供者。
定义上下文提供者
我们已经为 username 状态创建了一个 State Hook。现在我们可以使用这个 State Hook 的结果并将其传递给上下文提供者,这样我们的应用中的任何组件都可以使用它。
让我们开始定义上下文提供者:
-
编辑
src/App.jsx并按照以下方式导入UserContext:import { UserContext } from './contexts/UserContext.js' -
然后,将
App组件的结果包裹在UserContext.Provider中:export function App() { const [posts, dispatch] = useReducer(postsReducer, defaultPosts) const [username, setUsername] = useState('') return ( **<****UserContext****.****Provider****value****={[****username****,** **setUsername****]}>** <ThemeContext.Provider value={{ primaryColor: 'black' }}> -
现在,我们可以 移除 我们之前传递下来的以下属性:
<div style={{ padding: 8 }}> <UserBar **username****=****{username}****setUsername****=****{setUsername}** /> <br /> {username && <CreatePost **username****=****{username}** dispatch={dispatch} />} <hr /> <ThemeContext.Provider value={{ primaryColor: 'salmon' }}> <PostList posts={featuredPosts} /> </ThemeContext.Provider> <PostList posts={posts} /> </div> -
不要忘记为
UserContext.Provider添加关闭标签:</ThemeContext.Provider> **</****UserContext****.****Provider****>** ) }
当然,也可以使用类似的模式将 Reducer Hook 的结果传递到上下文中。
现在上下文提供者提供了 username 值和 setUsername 函数给我们的应用的其他部分。
将应用重构为使用 UserContext
现在我们有了上下文提供者,我们可以重构应用的其他部分以使用上下文而不是属性。
按照以下步骤开始:
-
首先,编辑
src/components/user/UserBar.jsx并向useContext函数和UserContext添加导入:import { useContext } from 'react' import { UserContext } from '@/contexts/UserContext.js' -
然后,移除 传递给组件的属性:
export function UserBar(**{ username, setUsername }**) { -
接下来,定义 Context Hook 并从其中获取
username值:const [username] = useContext(UserContext) -
现在,我们可以 移除 传递给其他组件的属性:
if (username) { return <Logout **username****=****{username}****setUsername****=****{setUsername}** /> } return ( <> <Login **setUsername****=****{setUsername}** /> <hr /> <Register **setUsername****=****{setUsername}** /> </> ) } -
编辑
src/components/user/Login.jsx并添加useContext和UserContext的导入:import { useContext } from 'react' import { UserContext } from '@/contexts/UserContext.js' -
然后,移除 组件中的属性:
export function Login(**{ setUsername }**) { -
添加 Context Hook:
const [, setUsername] = useContext(UserContext)如果我们不需要数组的第一元素,我们可以在解构时通过简单地放置一个逗号而不指定第一个变量的名称来跳过它。
-
编辑
src/components/user/Logout.jsx并添加useContext和UserContext的导入:import { useState, useEffect, **useContext** } from 'react' **import** **{** **UserContext** **}** **from****'@/contexts/UserContext.js'** -
然后,移除组件的 props:
export function Logout(**{ username, setUsername }**) { -
添加 Context Hook:
const [username, setUsername] = useContext(UserContext) -
编辑
src/components/user/Register.jsx并添加useContext和UserContext的导入:import { useState, **useContext** } from 'react' **import** **{** **UserContext** **}** **from****'@/contexts/UserContext.js'** -
然后,移除组件的 props:
export function Register(**{ setUsername }**) { -
添加 Context Hook:
const [, setUsername] = useContext(UserContext) -
编辑
src/components/post/CreatePost.jsx并添加useContext和UserContext的导入:import { useContext } from 'react' import { UserContext } from '@/contexts/UserContext.js' -
然后,移除组件的
usernameprop:export function CreatePost({ **username,** dispatch }) { -
按以下步骤添加 Context Hook:
const [username] = useContext(UserContext) -
按以下步骤启动应用:
$ npm run dev
现在,这个应用的工作方式与之前相同,但我们的代码更加整洁和简洁,这都要归功于 React Context 和 Context Hook!
示例代码
本节的示例代码可以在Chapter05/Chapter05_2文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
摘要
在本章中,我们首先了解了 React Context 作为在多个 React 组件层级传递 props 的替代方案。然后,我们学习了上下文提供者和消费者,以及通过 Hooks 定义消费者的新方法。我们通过在我们的博客应用中实现主题支持来实践所学知识。接下来,我们学习了何时不应使用上下文,以及何时应使用控制反转。最后,我们在我们的博客应用中使用了上下文来存储全局username状态。
在下一章中,我们将学习如何使用 React 和 Hooks 从服务器请求数据。然后,我们将学习 React Suspense,这样我们就不必等待请求完成后再渲染我们的应用。
问题
为了回顾本章学到的内容,尝试回答以下问题:
-
上下文避免了什么问题?
-
上下文由哪三个部分组成?
-
使用上下文是否需要定义所有部分?
-
使用 Hooks 而不是传统上下文消费者有什么优势?
-
上下文的替代方案是什么?何时应该使用它?
-
我们如何实现动态更改上下文?
-
在什么情况下使用上下文来存储状态是有意义的?
进一步阅读
如果您对本章学到的概念感兴趣,请查看以下链接:
-
React Context 的官方文档:
react.dev/learn/passing-data-deeply-with-context -
HTML 颜色代码列表(如果您想调整主题):
www.rapidtables.com/web/color/html-color-codes.html -
react-aria使用的本地状态上下文示例:react-spectrum.adobe.com/react-aria/advanced.html#contexts -
react-i18next用于全局状态的上下文示例:react.i18next.com/latest/i18nextprovider
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/wnXT0

第六章:使用 Hooks 和 React Suspense 进行数据获取
在上一章中,我们学习了如何使用 React Context 作为手动传递 props 的替代方案。我们学习了 context providers、consumers 和 Context Hook。
在本章中,我们将首先使用 json-server 工具从 JSON 文件设置一个简单的后端服务器。然后,我们将结合使用 Effect Hook 和 State Hook 从我们的服务器获取数据。接下来,我们将使用 TanStack Query,这是一个流行的 React 数据获取库,它利用 Hooks,以同样的方式完成。最后,我们将了解 React Suspense,它可以用来延迟渲染,直到内容加载完成。
本章将涵盖以下主题:
-
设置一个简单的后端服务器
-
使用 Effect 和 State Hook 请求资源
-
使用 TanStack Query 请求资源并做出更改
-
介绍 React Suspense 和错误边界
技术要求
应该已经安装了相当新的 Node.js 版本。Node 包管理器 (npm) 也需要安装(它应该与 Node.js 一起提供)。有关如何安装 Node.js 的更多信息,请查看他们的官方网站:nodejs.org/
本书中的指南将使用 Visual Studio Code (VS Code),但任何其他编辑器中都应该有类似的工作方式。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
上列出的版本是本书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能会有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter06
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
设置一个简单的后端服务器
在我们可以实现请求之前,我们需要实现一个服务器。由于我们在这本书中专注于用户界面,我们将设置一个模拟服务器,这将使我们能够测试请求。我们将使用 json-server 工具从 JSON 文件创建一个模拟的 表示状态传输 (REST) 应用程序编程接口 (API)。
创建 db.json 文件
要能够使用 json-server 工具,我们首先需要创建一个 db.json 文件,它将包含服务器的数据库。然后,json-server 工具将创建一个 REST API,允许我们访问和修改 db.json 文件,如下所示:
-
GET请求,用于从文件中查看数据 -
POST请求,用于将新数据插入文件 -
PUT和PATCH请求,用于调整文件中的现有数据 -
DELETE请求,用于从文件中删除数据
REST API 的结构是从 db.json 文件中的 JSON 对象推断出来的。对于所有修改操作(POST、PUT、PATCH 和 DELETE),工具将自动保存更新后的文件。
我们可以使用我们现有的帖子结构,我们在 App 组件中将其定义为 defaultPosts,但我们需要提供一个额外的 id 值,这样我们就可以稍后从数据库中查询帖子。此外,我们给每个帖子一个 featured 值。这将在我们实现请求时区分特色帖子和平常帖子变得很重要。
[
{
"id": "1",
"title": "React Hooks",
"content": "The greatest thing since sliced bread!",
"author": "Daniel Bugl",
"featured": false
},
{
"id": "2",
"title": "Using React Fragments",
"content": "Keeping the DOM tree clean!",
"author": "Daniel Bugl",
"featured": false
},
{
"id": "3",
"title": "React Context",
"content": "Manage global state with ease!",
"author": "Daniel Bugl",
"featured": true
}
]
对于用户,我们需要想出一个方法来存储用户名和密码。为了简单起见,我们只以纯文本形式存储密码(永远不要在生产环境中这样做!):
[
{
"id": "1",
"username": "Daniel Bugl",
"password": "hunter2"
}
]
现在剩下的任务是将这两个数组合并成一个 JSON 对象,通过将帖子数组存储在 posts 键下,将用户数组存储在 users 键下。
让我们现在开始创建后端服务器的 JSON 文件:
-
通过执行以下命令,将
Chapter05_2文件夹复制到新的Chapter06_1文件夹:$ cp -R Chapter05_2 Chapter06_1 -
在 VS Code 中打开新的
Chapter06_1文件夹。 -
在
Chapter06_1文件夹内直接创建一个新的server/文件夹。 -
创建一个包含以下内容的
server/db.json文件:{ "posts": [ { "id": "1", "title": "React Hooks", "content": "The greatest thing since sliced bread!", "author": "Daniel Bugl", "featured": false }, { "id": "2", "title": "Using React Fragments", "content": "Keeping the DOM tree clean!", "author": "Daniel Bugl", "featured": false }, { "id": "3", "title": "React Context", "content": "Manage global state with ease!", "author": "Daniel Bugl", "featured": true } ], "users": [ { "id": "1", "username": "Daniel Bugl", "password": "hunter2" } ] }
这就是我们使用 json-server 工具自动创建简单后端和 REST API 所需要的一切。让我们继续设置工具。
安装 json-server 工具
现在,我们将使用 json-server 工具安装并启动我们的后端服务器:
-
首先,按照以下步骤安装
json-server工具:$ npm install --save-exact json-server@1.0.0-beta.3 -
现在,通过执行以下命令启动后端服务器:
$ npx json-server server/db.jsonnpx命令执行在项目中本地安装的命令。我们需要在这里使用npx,因为我们没有全局安装json-server工具(通过npm install -g json-server)。
我们执行了 json-server 工具,它正在监视我们之前创建的 server/db.json 文件。
默认情况下,json-server 工具为 JSON 对象中的每个 key 定义了以下路由:
GET /posts
GET /posts/:id
POST /posts
PUT /posts/:id
PATCH /posts/:id
DELETE /posts/:id
现在,我们可以访问 http://localhost:3000/posts/1 来查看我们的帖子对象:

图 6.1 – json-server 工具通过其 REST API 提供帖子!
如我们所见,该工具已为我们从数据库 JSON 文件创建了一个完整的 REST API!现在,让我们继续配置 package.json 脚本,以便 json-server 工具与我们的前端一起启动。
配置 package.json 脚本
让我们开始调整package.json文件:
-
编辑
package.json文件,并在scripts部分插入一个新的脚本名为dev:server。我们还要确保将端口改为与 Vite 默认端口(5173)相邻:"scripts": { **"dev:server"****:** **"json-server server/db.json --port 5174"****,** -
然后,我们将
dev脚本重命名为dev:client:"scripts": { "dev:server": "json-server server/db.json --port 5174", "dev**:client**": "vite", -
保存
package.json文件,否则稍后运行npm install将覆盖我们的更改。 -
如果它仍在运行,请按Ctrl+C退出
json-server工具。 -
接下来,我们安装一个名为
concurrently的工具,它允许我们同时启动服务器和客户端:$ npm install --save-dev --save-exact concurrently@9.1.2 -
现在,我们再次编辑
package.json并定义一个新的dev脚本,使用concurrently命令,然后将其作为参数传递给该命令:"scripts": { **"dev"****:** **"concurrently \"npm run dev:server\" \"npm run dev:client\""****,** -
现在尝试执行以下命令:
$ npm run dev
你会看到这个命令正在启动服务器和客户端:

图 6.2 – concurrently 工具并行运行我们的服务器和客户端
现在我们已经同时运行了客户端和服务器,让我们继续配置代理以避免处理跨站请求。
配置代理
由于安全原因,浏览器对向不同域发起请求有限制。这种限制称为跨源资源共享(CORS),它阻止我们从具有不同源(域名和端口)的 URL 发起请求。在我们的例子中,域名是相同的(localhost),但端口不同(5173与5174)。因此,最好保持相同的域名和端口,以便从前端向后端发起请求。所以,我们需要配置一个代理,将http://localhost:5173/api/的请求转发到http://localhost:5174/。
现在,让我们开始配置代理:
-
编辑
vite.config.js并定义一个绑定到/api路径的proxy配置:export default defineConfig({ plugins: [react()], resolve: { alias: [ { find: '@', replacement: path.resolve(import.meta.dirname, 'src') }, ], }, **server****: {** **proxy****: {** **'/api'****: {** -
将目标设置为运行在
http://localhost:5174的后端服务器,并在将请求转发到我们的服务器之前,重写路径以从其中删除/api:**target****:** **'http://localhost:5174'****,** **rewrite****:** **(****path****) =>** **path.****replace****(****/^\/api/****,** **''****),** **},** **},** **},** })
此代理配置将/api链接到我们的后端服务器。
-
如果服务器和客户端已经在运行,请退出。然后,使用以下命令重新启动它们:
$ npm run dev -
现在,通过浏览器打开
http://localhost:5173/api/posts/1来访问 API。
如我们所见,POST 对象仍然被正确地提供服务,但现在是通过 Vite 中定义的代理从/api路径提供的!
示例代码
本节的示例代码可以在Chapter06/Chapter06_1文件夹中找到。请检查文件夹内的README.md文件,了解如何设置和运行示例。
现在,让我们继续使用 Effect 和 State/Reducer Hook 来请求资源。
使用 Effect 和 State/Reducer Hook 请求资源
在学习如何使用库通过 Hooks 实现请求之前,我们将手动实现它们,使用 Effect Hook 触发请求,以及使用 State/Reducer Hook 存储结果。
从服务器获取帖子
我们现在将实现一种使用 Effect Hook 获取帖子的方法。然后,我们将通过扩展已定义的 Reducer Hook 来存储它。让我们开始吧:
-
通过执行以下命令将
Chapter06_1文件夹复制到新的Chapter06_2文件夹:$ cp -R Chapter06_1 Chapter06_2 -
在 VS Code 中打开新的
Chapter06_2文件夹。 -
首先,编辑
src/reducers.js并定义一个新的FETCH_POSTS动作,它将简单地从动作返回新的帖子列表:export function postsReducer(state, action) { switch (action.type) { case 'CREATE_POST': return [action.post, ...state] **case****'FETCH_POSTS'****:** **return** **action.****posts** default: throw new Error('Unknown action type') } } -
现在,编辑
src/App.jsx并导入useEffect函数:import { useState, useReducer**, useEffect** } from 'react' -
删除
featuredPosts和defaultPosts数组。 -
调整 Reducer Hook 的默认值为一个空数组:
export function App() { const [posts, dispatch] = useReducer(postsReducer, **[]**) const [username, setUsername] = useState('') -
然后,在 App 组件中定义一个 Effect Hook,如下所示:
useEffect(() => { -
在 Hook 内部,我们调用
fetch向/api/posts端点发起请求:fetch('/api/posts') -
解析 JSON 响应以获取
posts数组:.then((response) => response.json()) -
现在,使用从服务器返回的
posts数组分发FETCH_POSTS动作:.then((posts) => dispatch({ type: 'FETCH_POSTS', posts })) -
将空数组传递给 Effect Hook 依赖数组,以确保它仅在组件挂载时触发:
}, []) -
我们仍然需要将特色帖子与非特色帖子分开,所以让我们使用
filter将数组拆分为两个数组,如下所示:const featuredPosts = posts.filter((post) => post.featured).reverse() const regularPosts = posts.filter((post) => !post.featured).reverse()
我们在这里反转顺序以确保显示最新的帖子。如果我们有一个 createdAt 属性,我们可以用它来正确地排序帖子。
-
将
regularPosts而不是posts传递给PostList组件,以确保特色帖子不会渲染两次:<PostList posts={**regularPosts**} /> -
按照以下方式启动客户端和服务器:
$ npm run dev -
现在,在浏览器中转到
http://localhost:5173/。
如我们所见,应用程序仍然以之前相同的方式工作!为了验证帖子确实来自我们的数据库,对 db.json 进行更改,然后刷新页面。您将看到更改在应用程序中可见!
在开发模式下,您将看到两个 GET 请求。这是由于 React 在严格模式下渲染组件两次,以帮助您发现可能在组件重新渲染时发生的副作用(例如,忘记清理超时/间隔)。
在生产模式下,组件只会渲染一次,因此只会发送一个 GET 请求。
快速转换:async/await 构造
常规函数定义如下:
function doSomething() {
// ...
}
常规匿名函数定义如下:
() => {
// ...
}
异步函数通过添加 async 关键字来定义:
async function doSomething() {
// ...
}
异步匿名函数也是可能的:
async () => {
// ...
}
在 async 函数中,我们可以使用 await 关键字等待承诺解决后再继续。而不是必须做以下操作:
function fetchPosts() {
return fetch('/api/posts')
.then((response) => response.json())
}
我们现在可以使用 async/await 以这种方式编写相同的函数:
async function fetchPosts() {
const response = await fetch('/api/posts')
const posts = await response.json()
return posts
}
在上一节中,我们使用了 Promise API 通过在 Effect Hook 内部使用 .then() 函数来处理异步函数的结果。Effect Hooks 不支持将异步函数传递给它们,以防止竞态条件。然而,在 Hook 内部定义一个异步函数并在之后立即调用它是可能的。因此,我们也可以将 Hook 定义如下:
useEffect(() => {
async function fetchPosts() {
const response = await fetch('/api/posts')
const posts = await response.json()
dispatch({ type: 'FETCH_POSTS', posts })
}
void fetchPosts()
}, [])
void 运算符表明我们并非无意中调用了一个没有 await 的 async 函数。在这种情况下,我们想要调用异步函数,但并不关心它何时完成。
如您所见,async/await 构造在某些情况下可以使我们的代码更容易阅读。您可以选择任一模式(then 或 async/await),取决于哪一个使代码更易读。然而,在同一个函数中混合两者并不是最佳实践。当然,如果我们有一个 State Hook 而不是 dispatch 和 Reducer Hook,我们也可以在这里调用 setPosts。
现在帖子已成功从数据库中加载,让我们实现一种通过后端服务器创建帖子的方法。
在服务器上创建新帖子
对于创建帖子,我们只需调整提交处理函数以使用 fetch 执行 POST 请求。现在让我们开始做这件事:
-
编辑
src/components/post/CreatePost.jsx并将handleSubmit函数定义为async:**async** function handleSubmit(e) { -
在函数内部,在收集值之后,创建一个到
/api/posts的 fetch 请求:e.preventDefault() const form = e.target const title = form.elements.title.value const content = form.elements.content.value const newPost = { title, content, author: username**,** **featured****:** **false** } **const** **response =** **await****fetch****(****'/api/posts'****, {** -
确保这是一个 POST 请求,并设置头信息,以便我们的后端服务器知道我们将发送一个 JSON 对象:
method: 'POST', headers: { 'Content-Type': 'application/json' }, -
现在,我们可以将我们的
post对象作为请求体传递,通过将其转换为 JSON 字符串:body: JSON.stringify(newPost), }) -
如果响应不是成功的,抛出一个错误:
if (!response.ok) { throw new Error('Unable to create post') } -
否则,我们派发
CREATE_POST动作以在客户端显示新帖子并重置表单:dispatch({ type: 'CREATE_POST', post: newPost }) form.reset() } -
使用前端创建一个新的帖子,然后检查
server/db.json文件。
如我们所见,新帖子已成功插入到数据库中:

图 6.3 – 我们成功地将一个新帖子插入到数据库中
示例代码
本节的示例代码可以在 Chapter06/Chapter06_2 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
现在我们已经成功实现了通过直接使用 Fetch API 和 Effect Hook 来获取和创建帖子,我们可以继续学习如何使用库来请求资源并做出更改。
使用 TanStack Query 请求资源并做出更改
在前面的章节中,我们使用 Effect 钩子来触发请求,并使用 Reducer 钩子来更新状态,使用请求的结果。我们不必手动实现这样的请求,而是可以使用 TanStack Query 库。这个库不仅允许我们轻松获取资源,还为我们缓存结果并提供使状态无效的方法。使状态无效允许我们在创建新帖子后从服务器重新获取帖子,而不是必须手动分发一个动作。
设置库
在我们开始使用它之前,我们需要安装和设置库。TanStack Query 是一个用于管理服务器数据状态的库。它由 3 部分组成:
-
一个 查询客户端,它管理缓存和使状态无效。
-
一个 查询客户端提供者,它将应用程序包裹起来,为所有组件提供查询客户端。
-
一系列钩子,如查询和突变钩子。查询钩子用于获取和订阅数据,而突变钩子用于您需要修改服务器上的数据时使用。
现在让我们开始设置 TanStack Query:
-
通过执行以下命令将
Chapter06_2文件夹复制到新的Chapter06_3文件夹:$ cp -R Chapter06_2 Chapter06_3 -
在 VS Code 中打开新的
Chapter06_3文件夹。 -
按照以下步骤安装 TanStack Query 库:
$ npm install --save-exact @tanstack/react-query@5.66.7 -
此外,将 ESLint 插件作为开发依赖项安装:
$ npm install --save-dev --save-exact @tanstack/eslint-plugin-query@5.66.1 -
编辑
eslint.config.js并在其中导入插件:import pluginQuery from '@tanstack/eslint-plugin-query' -
然后,按照以下步骤添加插件:
export default [ **...pluginQuery.****configs****[****'flat/recommended'****],** { ignores: ['dist'] }, -
现在,我们可以开始设置 TanStack Query 本身了。首先,创建一个新的
src/api.js文件,其中将包含查询客户端。 -
编辑
src/api.js并导入和创建查询客户端:import { QueryClient } from '@tanstack/react-query' export const queryClient = new QueryClient()
我们在这里创建查询客户端的单例实例,以确保我们应用程序的所有部分都使用相同的查询客户端(以及相同的缓存)。
-
现在,编辑
src/App.jsx,移除useReducer、useEffect和postsReducer的导入:import { useState**, useReducer, useEffect** } from 'react' **import** **{ postsReducer }** **from****'./reducers.js'**
替换它们为queryClient和QueryClientProvider的导入:
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './api.js'
-
在 App 组件内部,移除与获取帖子相关的钩子:
export function App() { **const** **[posts, dispatch] =** **useReducer****(postsReducer, [])** const [username, setUsername] = useState('') **useEffect****(****() =>** **{** **fetch****(****'/api/posts'****)** **.****then****(****(****response****) =>** **response.****json****())** **.****then****(****(****posts****) =>****dispatch****({** **type****:** **'FETCH_POSTS'****, posts }))** **}, [])** **const** **featuredPosts = posts.****filter****(****(****post****) =>** **post.****featured****).****reverse****()** **const** **regularPosts = posts.****filter****(****(****post****)****=>** **!post.****featured****).****reverse****()** -
按照以下步骤将应用程序包裹在
QueryClientProvider中:return ( **<****QueryClientProvider****client****=****{queryClient}****>** <UserContext.Provider value={[username, setUsername]}> … </UserContext.Provider> **</****QueryClientProvider****>** ) } -
移除
CreatePost组件的dispatch属性:{username && **<****CreatePost** **/>**}
到目前为止,章节中的featuredPosts和regularPosts数组不再定义,导致 ESLint 错误。现在忽略这些错误,我们很快就会修复它们。
现在,我们已经准备好使用 TanStack Query 了!
使用查询钩子获取帖子
现在库已经设置好了,我们可以开始使用它了。我们将首先使用查询钩子来获取帖子。为此,我们将创建一个新的PostFeed组件,该组件将处理获取逻辑,同时将PostList作为一个渲染组件列表的 UI 组件。我们还将定义一个函数,在src/api.js文件中为我们获取帖子。
现在让我们开始使用查询钩子获取帖子:
-
编辑
src/api.js并定义一个新的函数,该函数接受一个featured属性然后为我们获取帖子:export async function fetchPosts({ featured }) { -
我们调用 API,将
featured属性作为查询参数传递。这将导致json-server根据其featured值为我们过滤帖子:const res = await fetch(`/api/posts?featured=${featured}`) -
将响应解析为 JSON 并返回:
return await res.json() } -
创建一个新的
src/components/post/PostFeed.jsx文件。 -
在其中,导入
useQuery函数、PostList组件和fetchPosts函数:import { useQuery } from '@tanstack/react-query' import { fetchPosts } from '@/api.js' import { PostList } from './PostList.jsx' -
然后,定义组件,它接受一个
featured属性来切换是否渲染特色组件或常规组件:export function PostFeed({ featured = false }) { -
定义一个 Query Hook,从中使用
data和isLoading值:const { data, isLoading } = useQuery({ -
对于每个 Query Hook,我们需要定义一个
queryKey。queryKey用于缓存查询的结果:queryKey: ['posts', featured],
例如,如果我们使用相同的 queryKey 在另一个组件中获取,我们将得到缓存的而不是另一个请求的结果。React Query 总是会首先尝试从缓存(如果给定 queryKey 存在)中获取结果,如果它尚未存在于缓存中,它将为我们后台发送请求并缓存它。
这非常有用,因为它允许我们在组件树中更深入地获取数据,直接在我们需要的地方 – 避免了属性钻取而不影响性能。
queryKey 也可能成为错误的原因,当它意外地用于不同的请求时。例如,我们需要在这里将 featured 属性添加到 queryKey 中,否则只会获取并返回一次特色帖子或常规帖子。如果你从 Query Hooks 获取到奇怪的结果或过时的数据,请确保检查你的查询键,并确保每个请求都有一个唯一的键,并且传递给查询函数的所有参数也都添加到了查询键中。
-
接下来,我们定义
queryFn– 当查询执行时将被调用的函数。在这种情况下,我们简单地使用featured属性调用fetchPosts函数:queryFn: () => fetchPosts({ featured }), }) -
如果 Query Hook 处于加载状态,我们显示一个加载消息:
if (isLoading) { return <div>Loading posts...</div> } -
同样,如果获取数据失败,我们显示一个错误消息:
if (!data) { return <div>Could not load posts!</div> } -
否则,一切正常,我们可以渲染
PostList:return <PostList posts={data} /> } -
编辑
src/App.jsx并 删除 以下PostList导入:import { PostList } from './components/post/PostList.jsx'
替换 为 PostFeed 组件的导入:
import { PostFeed } from './components/post/PostFeed.jsx'
-
在
App组件内部,将PostList组件替换为PostFeed组件,如下所示:<hr /> <ThemeContext.Provider value={{ primaryColor: 'salmon' }}> **<****PostFeed****featured** **/>** </ThemeContext.Provider> **<****PostFeed** **/>**
在实现获取帖子方法后,让我们继续使用 Mutation Hook 来创建一个新的帖子。
使用 Mutation Hook 创建帖子
获取帖子需要我们在组件挂载时向服务器发送请求以获取数据。然而,为了创建帖子,我们希望在用户按下按钮时向服务器发送请求。为了实现这种行为,我们需要一个 Mutation Hook 而不是 Query Hook。
现在让我们开始使用 Mutation Hook 实现帖子创建:
-
编辑
src/api.js并定义一个新的创建帖子的函数:export async function createPost(post) { -
在其中,我们发送一个 POST 请求,类似于我们之前所做的那样:
const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(post), }) -
如果请求过程中出现问题,我们抛出一个错误:
if (!res.ok) { throw new Error('Unable to create post') } -
否则,如果请求成功,我们返回结果:
return await res.json() } -
编辑
src/components/post/CreatePost.jsx并导入useMutation和createPost函数,以及queryClient:import { useMutation } from '@tanstack/react-query' import { createPost, queryClient } from '@/api.js' -
移除 组件中的
dispatch属性,因为我们不再需要它:export function CreatePost(**{ dispatch }**) { -
在组件内部,添加一个新的突变钩子,将
createPost函数作为mutationFn传递:const createPostMutation = useMutation({ mutationFn: createPost, -
添加一个
onSuccess处理程序,该程序将使所有以'posts'查询键开始的查询无效:onSuccess: () => { queryClient.invalidateQueries(['posts']) }, })
当查询键无效时,所有使用它的查询钩子会自动重新执行以获取新数据,并且组件会重新渲染以显示它。在这种情况下,我们将使所有以 'posts' 开头的查询键无效,因此我们将使特色帖子源中的 ['posts', true] 和常规帖子源中的 ['posts', false] 都无效。
-
替换 整个
handleSubmit函数,如下所示:async function handleSubmit(e) { e.preventDefault() const form = e.target const title = form.elements.title.value const content = form.elements.content.value const newPost = { title, content, author: username, featured: false } -
然后,从突变钩子中调用
mutate函数,并在成功执行突变后重置表单:createPostMutation.mutate(newPost, { onSuccess: () => form.reset() }) } -
此外,我们现在可以改进组件的用户体验。例如,我们可以使用
isPending状态在突变挂起时禁用提交按钮:<input type='submit' value='Create' **disabled={createPostMutation.****isPending****}** /> -
如果突变过程中出现错误,我们还可以在表单末尾用红色显示错误消息:
**{createPostMutation.****isError** **&& (** **<****div****style****=****{{****color:** **'****red****' }}>** **{createPostMutation.error.toString()}** **</****div****>** **)}** </form> -
尝试运行应用程序,你会发现它仍然像以前一样工作,但现在使用 TanStack Query!
当插入新帖子时,你可能注意到它现在被添加到了末尾。不幸的是,我们无法控制 json-server 如何将新帖子插入数组中。如果你想再次添加这种行为,我建议给所有帖子添加一个 createdAt 时间戳,然后使用 json-server 工具提供的 _sort 查询参数按此时间戳对帖子进行排序。这样做将作为一个练习留给你。
使用我们应用程序的新结构,我们可以通过使用 React Suspense 和错误边界来进一步改进它!
示例代码
本节的示例代码可以在 Chapter06/Chapter06_3 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
介绍 React Suspense 和错误边界
在前面的部分中,我们使用了 TanStack Query 的 isLoading 状态来显示正在获取帖子时的加载消息。虽然这没问题,但处理这样的加载状态可能会有些混乱。更好地建模加载状态的方法是使用 React Suspense。React Suspense 是一个特殊组件,可以在其子组件完成加载之前显示回退内容。要使用 React Suspense,数据获取框架和库需要支持它。幸运的是,TanStack Query 支持 Suspense。像 Relay 和 Next.js 这样的框架也支持它。
设置 Suspense 边界
要使用 Suspense,我们需要定义一个带有回退的 Suspense 边界。如果边界内的任何子组件正在获取数据,回退将替换边界,并替换其所有子组件。当所有数据成功获取后,所有子组件将被渲染。这允许我们编写假设数据始终存在的代码,并在树的上层进一步处理边缘情况。
让我们现在开始为帖子源设置 Suspense 边界:
-
通过执行以下命令将
Chapter06_3文件夹复制到新的Chapter06_4文件夹:$ cp -R Chapter06_3 Chapter06_4 -
在 VS Code 中打开新的
Chapter06_4文件夹。 -
编辑
src/App.jsx并导入Suspense:import { useState**,** **Suspense** } from 'react' -
调整
App组件,使其在 Suspense 边界内渲染帖子源,提供加载消息作为回退:**<****Suspense** **fallback={****<****strong****>****Loading posts...****</****strong****>****}>** <ThemeContext.Provider value={{ primaryColor: 'salmon' }}> <PostFeed featured /> </ThemeContext.Provider> <PostFeed /> **</****Suspense****>** -
现在,我们需要调整
PostFeed组件以使用 Suspense 查询 Hook。编辑src/components/post/PostFeed.jsx并按照以下方式调整导入:import { use**Suspense**Query } from '@tanstack/react-query' -
然后,按照以下方式调整 Hook:
export function PostFeed({ featured = false }) { const **{ data }** = use**Suspense**Query({ -
我们现在可以从组件中删除以下代码:
if (isLoading) { return <div>Loading posts...</div> } if (!data) { return <div>Could not load posts!</div> } -
按照以下方式启动应用:
$ npm run dev
你会发现,我们不再看到两个加载消息(一个用于特色帖子,一个用于常规帖子),现在我们只看到一个来自 Suspense 边界的加载消息!

图 6.4 – 使用 React Suspense 前后加载消息的对比
加载消息可能消失得太快,以至于你看不见,因为我们正在本地运行后端,所以没有网络延迟。这不是一个现实场景。在生产中,我们会对每个请求都有延迟。我们可以使用 DevTools 来模拟更慢的网络连接;让我们现在就做:
-
在 Google Chrome 中,通过右键点击网站并按Inspect来打开检查器。
-
检查器将打开,在其内部,转到网络标签页。
-
在网络标签页的顶部,点击无限制下拉菜单。
-
选择3G预设。参见以下截图以供参考:

图 6.5 – 在 Google Chrome DevTools 中模拟慢速网络
-
刷新页面。你现在会看到应用正在缓慢加载帖子。
-
不要忘记将其设置回无限制,以避免请求需要等待很长时间。
接下来,让我们继续设置错误边界。
设置错误边界
正如我们所学的,Suspense 边界可以在组件获取数据时提供回退。然而,你可能已经注意到我们还删除了错误处理代码。当子组件发生错误时,我们可以使用错误边界来提供回退。错误边界的工作方式与 Suspense 边界类似,但它们对错误状态而不是加载状态做出反应。
让我们现在开始设置错误边界:
-
首先,安装
react-error-boundary包:$ npm install --save-exact react-error-boundary@5.0.0 -
然后,我们创建一个组件,当发生错误时将作为回退渲染。
-
创建一个新的
src/FetchErrorNotice.jsx文件。在文件内部,定义一个组件,它接受一个resetErrorBoundary函数:export function FetchErrorNotice({ resetErrorBoundary }) {
resetErrorBoundary 函数可以用来重置导致错误的操作。在我们的例子中,它将重试获取帖子的请求。
-
渲染一个错误信息和触发重置函数的按钮:
return ( <div> <strong>There was an error fetching data.</strong> <br /> <button onClick={resetErrorBoundary}>Try again</button> </div> ) } -
现在,编辑
src/App.jsx并导入ErrorBoundary、QueryErrorResetBoundary和FetchErrorNotice:**import** **{** **ErrorBoundary** **}** **from****'react-error-boundary'** import { QueryClientProvider, **QueryErrorResetBoundary****,** } from '@tanstack/react-query' **import** **{** **FetchErrorNotice** **}** **from****'./FetchErrorNotice.jsx'** -
在
App组件内部,将 Suspense Boundary 包裹在错误边界中,该错误边界反过来又包裹在QueryErrorResetBoundary中,它提供了reset函数来重试查询:**<****QueryErrorResetBoundary****>** **{****(****{ reset }****) =>** **(** **<****ErrorBoundary** **onReset****=****{reset}** **fallbackRender****=****{FetchErrorNotice}** **>** <Suspense fallback={<strong>Loading posts...</strong>}> <ThemeContext.Provider value={{ primaryColor: 'salmon' }}> <PostFeed featured /> </ThemeContext.Provider> <PostFeed /> </Suspense> **</****ErrorBoundary****>** **)}** **</****QueryErrorResetBoundary****>** -
如果它目前正在运行,停止应用。
-
然后,只启动客户端,如下所示:
$ npm run dev:client -
在浏览器中打开应用,你会看到加载信息。等待一段时间直到请求超时。然后,你会看到错误信息和重试按钮:

图 6.6 – 由请求超时触发的错误边界
-
现在,不退出客户端,另外启动服务器,如下所示:
$ npm run dev:server -
点击 重试 按钮。你会再次看到加载信息,然后是帖子的列表!
如我们所见,错误边界允许我们通过显示回退组件和重置导致错误的操作的功能来管理错误状态。
示例代码
本节的示例代码可以在 Chapter06/Chapter06_4 文件夹中找到。检查文件夹内的 README.md 文件以获取设置和运行示例的说明。
摘要
在本章中,我们首先学习了如何从 JSON 文件设置一个简单的 API 服务器。然后,我们学习了如何使用 Effect 和 State/Reducer Hooks 来获取和创建帖子。接下来,我们使用 TanStack Query 库实现了相同的功能,这简化了我们的代码并使我们能够利用其缓存能力。最后,我们学习了如何使用 React Suspense 处理加载状态和使用错误边界处理错误状态。
在下一章中,我们将通过使用表单操作和 Hooks(例如 useActionState Hooks 来处理表单状态和 useOptimistic Hooks 来实现乐观更新)来深入学习表单处理。
问题
为了回顾本章所学内容,尝试回答以下问题:
-
我们如何轻松地从 JSON 文件创建一个完整的 REST API 以进行模拟?
-
使用代理访问我们的后端服务器有哪些优势?
-
哪些 Hooks 的组合可以用来实现数据获取?
-
TanStack Query 相比我们简单的数据获取实现有哪些优势?
-
TanStack Query 中哪个 Hooks 用于获取数据?
-
TanStack Query 中哪个 Hooks 用于对服务器进行更改?
-
查询键在 TanStack Query 库中扮演什么角色?
-
Suspense Boundary 用于什么?
-
Error Boundaries 是用来做什么的?
进一步阅读
如果你对本章学到的概念有更多兴趣,请查看以下链接:
-
json-server工具的官方文档:github.com/typicode/json-server -
concurrently工具的官方文档:github.com/open-cli-tools/concurrently -
TanStack Query for React 的官方文档:
tanstack.com/query/latest/docs/framework/react/overview -
关于 跨源资源共享(CORS)的更多信息:
developer.mozilla.org/en-US/docs/Web/HTTP/CORS -
关于 Vite 配置中代理设置的更多信息:
vite.dev/config/server-options#server-proxy -
关于 React 严格模式的更多信息:
react.dev/reference/react/StrictMode -
关于使用 React Hooks 获取数据的博客文章,无需库:
www.robinwieruch.de/react-hooks-fetch-data/ -
关于 Suspense 的更多信息:
react.dev/reference/react/Suspense -
关于 Error Boundaries 的更多信息:
github.com/bvaughn/react-error-boundary
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第七章:使用 Hooks 处理表单
在上一章中,我们学习了如何使用 Hooks 进行数据获取和 React Suspense 在等待数据加载完成时显示回退。
在本章中,我们将学习如何使用 Hooks 来处理 React 中的表单和表单状态。我们之前已经为 CreatePost 组件实现了一个表单。然而,我们不是手动处理表单提交,而是可以使用 React 表单操作,这不仅使处理表单提交变得更容易,还允许我们使用访问表单状态的 Hooks。此外,我们还将学习如何使用 乐观钩子 来实现乐观更新,即在服务器端完成处理之前,在客户端显示初步结果。
本章将涵盖以下主题:
-
使用 Action 状态 Hook 处理表单提交
-
模拟 阻塞 UI
-
使用 过渡钩子 避免阻塞 UI
-
使用 乐观钩子 实现乐观更新
技术要求
应已安装一个相当新的 Node.js 版本。Node 包管理器 (npm) 也需要安装(它应该随 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请查看他们的官方网站:nodejs.org/.
在本书的指南中,我们将使用 Visual Studio Code (VS Code)进行编写,但任何其他编辑器也应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
上列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter07
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
使用 Action 状态 Hook 处理表单提交
React 19 引入了一个名为 Form Actions 的新功能。正如我们在前面的章节中看到的,在 Web 应用程序中,对用户操作进行数据变更是一个常见的用例。通常,这些数据变更需要发起 API 请求并处理响应,这意味着要处理加载和错误状态。例如,当我们创建 CreatePost 组件时,我们创建了一个表单,在提交时将新帖子插入到数据库中。在这种情况下,React Query 已经帮助我们很多,通过简化加载和错误状态。然而,使用 React Form Actions 现在有一个原生的方法来处理这些状态,通过使用 Action State Hook。
介绍 Action State Hook
Action State Hook 定义如下:
const [state, action, isPending] = useActionState(actionFn, initialState)
让我们稍微分解一下,以便更好地理解它。要定义一个 Action State Hook,我们需要至少提供一个函数作为参数。这个函数将在表单提交时被调用,并且具有以下签名:
function actionFn(currentState, formData) {
动作函数将动作的当前状态作为第一个参数,将表单数据(作为一个 FormData 对象)作为第二个参数。动作函数返回的任何内容都将成为 Action State Hook 的新状态。
FormData API 是一个用于表示表单字段及其值的 Web 标准。它可以用来处理表单提交并通过网络发送,例如使用 fetch()。它是一个可迭代的对象(可以使用 for … of 循环迭代)并提供 getter 和 setter 函数来访问值。更多信息可以在这里找到:developer.mozilla.org/en-US/docs/Web/API/FormData。
此外,还可以为 Action State Hook 提供一个 initialState。
该 Hook 然后返回动作的当前状态,动作本身(将被传递到 <form> 元素),以及 isPending 状态,以检查动作是否当前正在挂起(当 actionFn 正在执行时)。
使用 Action State Hook
现在,让我们开始重构 CreatePost 组件以使用 Action State Hook:
-
通过执行以下命令将
Chapter06_4文件夹复制到新的Chapter07_1文件夹:$ cp -R Chapter06_4 Chapter07_1 -
在 VS Code 中打开新的
Chapter07_1文件夹。 -
编辑
src/components/post/CreatePost.jsx并导入useActionState函数:import { useContext, **useActionState** } from 'react' -
在 CreatePost 组件内部,删除 整个
handleSubmit函数。 -
替换 为以下 Action State Hook:
const [error, submitAction, isPending] = useActionState(
在这种情况下,我们将使用动作的 state 来存储错误状态。如果有错误,我们将从动作函数中返回它。否则,我们返回 nothing,因此错误状态是 undefined。
-
定义动作函数,如下所示:
async (currentState, formData) => {
在这种情况下,我们不会使用传递给函数的 currentState,但我们仍然需要定义它,因为我们需要第二个参数来获取 formData。
-
现在,通过使用
FormDataAPI 从表单中获取标题和内容:const title = formData.get('title') const content = formData.get('content')
FormData API 使用 name 属性来识别输入字段。
-
接下来,创建帖子对象并调用突变:
const newPost = { title, content, author: username, featured: false } try { await createPostMutation.mutateAsync(newPost)
由于我们现在有一个 async 函数,我们可以使用 mutation 中的 mutateAsync 方法来能够 await 响应。
-
如果发生错误,则返回它:
} catch (err) { return err } }, )
我们不再需要手动重置表单。当使用表单操作时,一旦表单操作函数成功完成,表单中的所有未受控字段将自动重置。
-
调整
<form>元素以将action传递给它而不是onSubmit处理程序:return ( <form **action****=****{submitAction}**> -
调整提交按钮和错误信息,如下所示:
<input type='submit' value='Create' **disabled****=****{isPending}** /> {**error** && <div style={{ color: 'red' }}>{**error**.toString()}</div>} -
按照以下方式启动应用:
$ npm run dev -
在博客应用中登录并创建一篇新文章,你会看到它和之前一样工作,但现在我们正在使用 Action State Hook 来处理表单提交!
在学习如何使用 Action State Hook 处理表单提交之后,让我们继续学习关于阻塞 UI 的内容。
示例代码
本节示例代码可在 Chapter07/Chapter07_1 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
模拟阻塞 UI
在我们学习关于 Transition Hook 之前,让我们首先介绍它试图解决的问题:阻塞 UI。当某些组件计算密集时,渲染它们可能会导致整个用户界面无响应。这可能会导致糟糕的用户体验,因为用户在组件渲染时无法做任何事情。
我们现在将在我们的博客中实现一个评论部分来模拟阻塞 UI。
实现一个(故意慢的)Comment 组件
我们首先实现一个 Comment 组件,我们故意让它变慢以模拟计算密集型组件。
让我们开始实现 Comment 组件:
-
通过执行以下命令将
Chapter07_1文件夹复制到新的Chapter07_2文件夹:$ cp -R Chapter07_1 Chapter07_2 -
在 VS Code 中打开新的
Chapter07_2文件夹。 -
创建一个新的
src/components/comment/文件夹。 -
创建一个新的
src/components/comment/Comment.jsx文件。在其中,定义并导出一个Comment组件,该组件接受content和author属性:export function Comment({ content, author }) { -
在组件中,我们通过延迟渲染 1ms 来模拟计算密集型操作:
let startTime = performance.now() while (performance.now() - startTime < 1) { // do nothing for 1 ms } -
现在,按照以下方式渲染评论:
return ( <div style={{ padding: '0.5em 0' }}> <span>{content}</span> <i> ~ {author}</i> </div> ) }
实现一个 CommentList 组件
现在,我们将实现一个 CommentList 组件,它将渲染 1000 条评论:
-
创建一个新的
src/components/comment/CommentList.jsx文件。 -
在其中,导入
Comment组件:import { Comment } from './Comment.jsx' -
然后,定义并导出一个
CommentList组件,它将生成 1000 条评论:export function CommentList() { const comments = Array.from({ length: 1000 }, (_, i) => ({ id: i, content: `Comment #${i}`, author: 'test', })) -
渲染评论:
return ( <div> {comments.map((comment) => ( <Comment {...comment} key={comment.id} /> ))} </div> ) }
实现 CommentSection 组件
最后,我们将实现一个 CommentSection 组件,它将允许我们通过按按钮来显示/隐藏帖子的评论。
让我们开始实现 CommentSection 组件:
-
创建一个新的
src/components/comment/CommentSection.jsx文件 -
在其中,从 React 导入
useState函数和CommentList组件:import { useState } from 'react' import { CommentList } from './CommentList.jsx' -
接下来,定义并导出
CommentSection组件,在其中我们定义一个 State Hook 来切换评论列表的开启和关闭:export function CommentSection() { const [showComments, setShowComments] = useState(false) -
然后,定义一个
handleClick函数,该函数将切换评论列表:function handleClick() { setShowComments((prev) => !prev) } -
渲染一个按钮,并条件性地渲染
CommentList组件:return ( <div> <button onClick={handleClick}> {showComments ? 'Hide' : 'Show'} comments </button> {showComments && <CommentList />} </div> ) } -
最后,编辑
src/components/post/Post.jsx并在那里导入CommentSection组件:import { CommentSection } from '@/components/comment/CommentSection.jsx' -
在帖子的末尾渲染它,如下所示:
<i> Written by <b>{author}</b> </i> **<****br** **/>** **<****br** **/>** **<****CommentSection** **/>** </div> ) }
测试模拟的阻塞 UI
现在,我们可以测试评论部分,看看它如何导致 UI 堵塞。请按照以下步骤操作:
-
按照以下方式运行项目:
$ npm run dev -
通过访问
http://localhost:5173/在浏览器中打开前端。 -
点击其中一个 显示评论 按钮。
-
你会看到在按下按钮后,整个 UI 变得无响应。尝试按下其他 显示评论 按钮之一——它不起作用。
如我们所见,渲染计算密集型的组件可能会导致整个 UI 变得无响应。为了解决这个问题,我们需要使用过渡——我们将在下一节中学习。
示例代码
本节的示例代码可以在 Chapter07/Chapter07_2 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
使用 Transition Hook 避免阻塞 UI
Transition Hook 允许你在不阻塞 UI 的情况下通过更新状态来处理异步操作。这对于渲染计算密集型的组件树特别有用,例如渲染标签及其(可能复杂的)内容,或者当制作客户端路由器时。Transition Hook 具有以下签名:
const [isPending, startTransition] = useTransition()
可以使用 isPending 状态来处理加载状态。startTransition 函数允许我们传递一个函数来启动过渡。这个函数需要是同步的。当函数内部触发的更新(例如,设置状态)正在执行并且它们对组件的影响正在评估时,isPending 将被设置为 true。这不会以任何方式阻塞 UI,因此其他组件在过渡执行期间仍然可以正常工作。
使用 Transition Hook
我们现在将使用 Transition Hook 来避免在显示大量评论时阻塞 UI。让我们开始吧:
-
通过执行以下命令将
Chapter07_2文件夹复制到一个新的Chapter07_3文件夹:$ cp -R Chapter07_2 Chapter07_3 -
在 VS Code 中打开新的
Chapter07_3文件夹。 -
编辑
src/components/comment/CommentSection.jsx并导入useTransition函数:import { useState, **useTransition** } from 'react' -
定义一个 Transition Hook,如下所示:
export function CommentSection() { const [showComments, setShowComments] = useState(false) **const** **[isPending, startTransition] =** **useTransition****()** -
在
handleClick函数中,开始一个过渡:function handleClick() { **startTransition****(****() =>** **{** setShowComments((prev) => !prev) **})** }
转换有特定的用途和限制。例如,不要使用转换来处理受控输入状态,因为转换是非阻塞的,但我们实际上希望输入状态立即更新。此外,在转换内部,所有更新都需要立即调用。虽然通常可以在转换中等待异步函数,但在转换内部等待异步请求完成以更新状态是不可能的。如果您需要在更新状态之前等待异步请求,最好在处理函数中await它,然后启动转换。有关更多信息,请参阅 React 文档上的故障排除指南:react.dev/reference/react/useTransition#troubleshooting
-
我们现在可以在转换挂起时禁用按钮:
<button onClick={handleClick} **disabled****=****{isPending}**>
测试非阻塞转换
现在我们可以测试评论部分,看看它是否不再阻塞 UI。按照以下步骤操作:
-
按照以下步骤运行项目:
$ npm run dev -
通过访问
http://localhost:5173/在浏览器中打开前端。 -
点击一个显示评论按钮。
-
您会发现按下按钮后,UI 的其余部分仍然保持响应。尝试按下其他显示评论按钮之一——现在它工作了,并触发了另一个转换!
如我们所见,通过使用转换,我们可以在造成渲染计算密集型组件的状态更新时保持 UI 的响应性!
示例代码
本节的示例代码可以在Chapter07/Chapter07_3文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
使用乐观钩子实现乐观更新
处理更新/变异有两种方式:
-
显示加载状态并在加载期间禁用某些操作
-
进行乐观更新,这立即在客户端显示了操作的成果,而变异仍在进行中。然后,在变异完成后,从服务器状态更新本地状态。
根据您的使用场景,一个或另一个选项可能更适合。通常,乐观更新非常适合快节奏的操作,例如聊天应用。而如果没有乐观更新的加载状态,则更适合关键操作,例如银行转账。
乐观钩子的签名如下:
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn)
如我们所见,它接受一个state(通常这是一个服务器状态)和一个用于处理更新的updateFn函数。然后,它返回一个optimisticState和一个addOptimistic函数,可以用来乐观地添加一个新项目到状态中。
updateFn接受两个参数,即传递给addOptimistic函数的currentState和optimisticValue。然后,它返回一个新的乐观状态。
实现乐观评论创建
在我们的案例中,我们将实现一种使用乐观更新创建新评论的方法。让我们开始做这件事:
-
通过执行以下命令将
Chapter07_3文件夹复制到新的Chapter07_4文件夹:$ cp -R Chapter07_3 Chapter07_4 -
在 VS Code 中打开新的
Chapter07_4文件夹。 -
创建一个新的
src/components/comment/CreateComment.jsx文件并导入useContext函数和UserContext:import { useContext } from 'react' import { UserContext } from '@/contexts/UserContext.js' -
定义
CreateComment组件,它接受一个addComment函数:export function CreateComment({ addComment }) { -
从上下文中获取当前登录用户的
username:const [username] = useContext(UserContext) -
定义一个
submitAction,它调用addComment函数:async function submitAction(formData) { const content = formData.get('content') const comment = { author: username, content, } await addComment(comment) } -
定义一个
<form>并将其submitAction传递给它:return ( <form action={submitAction}> <input type='text' name='content' /> <i> ~ {username}</i> <input type='submit' value='Create' /> </form> ) }
如我们所见,也可以在不使用 Action 状态钩子的情况下定义表单操作。然而,那样我们只能得到一个简单的处理表单提交的函数,而没有任何表单状态处理功能(例如挂起和错误状态)。
-
编辑
src/components/comment/CommentList.jsx并导入以下内容:import { useContext, useState, useOptimistic } from 'react' import { UserContext } from '@/contexts/UserContext.js' import { CreateComment } from './CreateComment.jsx' -
删除 以下代码:
const comments = Array.from({ length: 1000 }, (_, i) => ({ id: i, content: `Comment #${i}`, author: 'test', })) -
然后,定义一个上下文钩子来获取当前登录用户的
username:const [username] = useContext(UserContext) -
接下来,定义一个状态钩子来存储评论:
const [comments, setComments] = useState([])
为了使本节简短并切中要点,我们只关注乐观更新,你可以在这里自行实现将评论存储在数据库中的方法。
-
现在,定义乐观钩子:
const [optimisticComments, addOptimisticComment] = useOptimistic( comments, -
在更新函数中,我们将评论添加到数组中,并将
sending属性设置为true。我们稍后会使用这个属性来在视觉上区分乐观创建的评论和真实评论:(state, comment) => [ ...state, { ...comment, sending: true, id: Date.now(), }, ], )
我们在这里还定义了一个乐观评论的临时 ID,我们稍后可以用它来作为 key 属性。
-
现在,定义
addComment函数,它首先乐观地添加评论,然后等待一秒钟,然后将它添加到“数据库”中:async function addComment(comment) { addOptimisticComment(comment) await new Promise((resolve) => setTimeout(resolve, 1000)) setComments((prev) => [...prev, comment]) } -
按照以下方式渲染乐观评论:
return ( <div> {**optimisticComments**.map((comment) => ( <Comment {...comment} key={comment.id} /> ))} -
如果还没有评论,我们可以显示一个空状态:
{optimisticComments.length === 0 && <i>No comments</i>} -
如果用户已登录,我们允许他们创建新的评论:
**{username &&** **<****CreateComment****addComment****=****{addComment}** **/>****}** </div> ) } -
最后,编辑
src/components/comment/Comment.jsx并向其中添加sending属性:export function Comment({ content, author, **sending** }) { -
然后,从其中 删除 以下代码:
let startTime = performance.now() while (performance.now() - startTime < 1) { // do nothing for 1 ms } -
现在,根据发送属性更改颜色,以灰色显示乐观插入的评论:
return ( <div style={{ padding: '0.5em 0', **color:****sending** **? '****gray****'** **:** **'****black****' }}>** -
按照以下步骤运行项目:
$ npm run dev -
通过访问
http://localhost:5173/在浏览器中打开前端。 -
使用顶部的表单登录,然后按下一个 显示评论 按钮。它应该显示 没有评论 的消息。
-
输入一条新的评论并按 创建。
你会看到评论最初以灰色颜色乐观地插入,然后一秒钟后它将以黑色出现,表示评论已成功存储在“数据库”中:

图 7.1 – 乐观地插入新评论
示例代码
本节示例代码可在Chapter07/Chapter07_4文件夹中找到。请检查文件夹内的README.md文件,了解如何设置和运行示例。
如您所见,乐观 Hook 是实现乐观更新并保持应用程序快速响应的绝佳方式!
摘要
在本章中,我们首先学习了如何使用表单动作和动作状态 Hook 处理表单提交和状态。然后,我们模拟了处理渲染计算密集型组件时可能出现的潜在问题:阻塞 UI。接下来,我们通过引入转换 Hook 以非阻塞方式更改状态来解决此问题,允许 UI 在计算密集型组件渲染时保持响应。最后,我们学习了如何实现乐观更新,以便在等待异步操作完成的同时立即显示结果。
在下一章中,我们将学习如何使用 Hooks 在我们的博客应用程序中实现客户端路由。
问题
为了回顾本章学到的内容,尝试回答以下问题:
-
我们可以使用哪个特性来处理 React 19 中的表单提交?
-
在 React 19 中处理表单数据使用的网络标准是什么?
-
哪个 Hook 用于处理不同的表单状态?
-
在渲染计算密集型组件时可能出现的潜在问题是什么?
-
我们如何避免这个问题?
-
转换的限制是什么?
-
我们可以使用哪个 Hook 在状态完成持久化到服务器之前在客户端显示状态?
进一步阅读
如果你对本章学到的概念感兴趣,想了解更多信息,请查看以下链接:
-
FormDataAPI:developer.mozilla.org/en-US/docs/Web/API/FormData -
使用 React 进行表单提交:
react.dev/reference/react-dom/components/form -
动作状态 Hook:
react.dev/reference/react/useActionState -
关于乐观更新的更多信息:
dev.to/_jhohannes/why-your-applications-need-optimistic-updates-3h62
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈,向作者提问,了解新版本——请扫描下面的二维码:

第八章:使用 Hooks 进行路由
在上一章中,我们学习了如何使用Action State Hook处理表单提交,如何使用Transition Hook避免 UI 阻塞,以及如何使用Optimistic Hook实现乐观更新。
在本章中,我们将学习如何在我们的博客应用中通过使用React Router实现客户端路由。首先,我们将学习 React Router 是如何工作的,以及它提供了哪些功能。然后,我们将创建一个新的路由来查看单个帖子,并使用Param Hook从 URL 中获取帖子 ID。接下来,我们将学习如何使用Link组件链接到不同的路由。最后,我们将学习如何使用Navigation Hook编程式实现导航以重定向到新创建的帖子。
本章将涵盖以下主题:
-
介绍 React Router
-
创建新路由并使用 Param Hook
-
使用 Link 组件链接到路由
-
使用 Navigation Hook 进行编程式重定向
技术要求
应该已经安装了相当新的 Node.js 版本。Node 包管理器(npm)也需要安装(它应该随 Node.js 一起提供)。有关如何安装 Node.js 的更多信息,请查看他们的官方网站:nodejs.org/
我们将在本书的指南中使用Visual Studio Code(VS Code),但在任何其他编辑器中一切都应该类似。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
在前面列出的版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果您在使用本书提供的代码和步骤时遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter08
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
介绍 React Router
React Router 最初是一个简单、声明式的路由库。它为我们提供了定义和管理应用程序不同路由的功能,以及在这些路由之间导航。最近,React Router 也可以用作 React 框架,提供处理布局和高级服务器端渲染的方法。然而,由于本书专注于 Hooks,我们将专注于将 React Router 作为库。
该库由三个主要组件组成:
-
BrowserRouter组件,它提供了一个上下文来使用路由 -
Routes组件,它允许我们定义一些路由并渲染当前活动路由的组件 -
Route组件,它允许我们定义一个特定的路由和要渲染的组件
此外,该库提供了创建指向特定路由的链接的组件(使用 Link 和 NavLink 组件),以及从 URL 获取参数(参数钩子)和导航(导航钩子)的钩子。
现在,让我们开始设置 React Router 和索引路由(它将包含我们博客的主页,显示博客帖子的源)。索引路由将是我们的服务器主 URL 上提供的内容,有时也称为入口点或 / 路由。
设置 React Router
按照以下步骤开始设置 React Router 库和索引路由:
-
通过执行以下命令将
Chapter07_4文件夹复制到新的Chapter08_1文件夹:$ cp -R Chapter07_4 Chapter08_1 -
在 VS Code 中打开新的
Chapter08_1文件夹。 -
打开一个终端,并按照以下方式安装
react-router库:$ npm install --save-exact react-router@7.2.0 -
创建一个新的
src/pages/文件夹,我们将把应用程序的各种页面放在其中。 -
创建一个新的
src/pages/Home.jsx文件来包含我们博客应用程序的主页(将显示我们之前已有的帖子源)。 -
在其中,导入
Suspense、PostFeed和ThemeContext:import { Suspense } from 'react' import { PostFeed } from '@/components/post/PostFeed.jsx' import { ThemeContext } from '@/contexts/ThemeContext.js' -
定义并导出
Home组件,该组件在加载帖子时显示回退,然后以特殊颜色显示特色帖子,然后显示常规帖子:export function Home() { return ( <Suspense fallback={<strong>Loading posts...</strong>}> <ThemeContext.Provider value={{ primaryColor: 'salmon' }}> <PostFeed featured /> </ThemeContext.Provider> <PostFeed /> </Suspense> ) } -
编辑
src/App.jsx并删除Suspense的导入,因为我们不再需要它了:import { useState**,** **Suspense** } from 'react' -
此外,删除
PostFeed组件的导入:import { PostFeed } from './components/post/PostFeed.jsx' -
然后,从
react-router中导入BrowserRouter、Routes和Route:import { BrowserRouter, Routes, Route } from 'react-router' -
此外,导入
Home页面组件:import { Home } from './pages/Home.jsx' -
在
App组件内部,定义BrowserRouter,确保它包装了所有组件,这样我们就可以在标题组件中使用导航钩子:export function App() { const [username, setUsername] = useState('') return ( <QueryClientProvider client={queryClient}> <UserContext.Provider value={[username, setUsername]}> <ThemeContext.Provider value={{ primaryColor: 'black' }}> **<****BrowserRouter****>** <div style={{ padding: 8 }}> <UserBar /> <br /> {username && <CreatePost />} <hr /> -
在
ErrorBoundary内部,替换Suspense组件及其所有子组件。相反,渲染Routes组件,在其中我们可以为我们的应用程序定义路由:<QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={FetchErrorNotice} > **<****Routes****>** -
定义一个索引路由,用于渲染
Home页面组件:**<****Route****index****element****=****{****<****Home** **/>****} />** **</****Routes****>** </ErrorBoundary> )} </QueryErrorResetBoundary> </div> **</****BrowserRouter****>** </ThemeContext.Provider> </UserContext.Provider> </QueryClientProvider> ) } -
按照以下方式运行应用程序:
$ npm run dev
当在浏览器中打开应用程序时,你会看到它看起来与之前完全一样,但现在主页是通过 React Router 而不是硬编码来渲染的!
示例代码
本节的示例代码可以在Chapter08/Chapter08_1文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
创建一个新的路由并使用 Param Hook
现在我们已经成功设置了 React Router,我们可以开始创建一个新的路由来查看单个帖子。这个路由看起来如下所示:/post/:id,其中:id是一个包含要查看的帖子 ID 的 URL 参数。
URL 参数是在 URL 中使用的参数,用于定义动态内容。例如,在/post/:id路由中,/post/部分将是一个静态字符串,但:id将被替换为动态帖子 ID。假设你有一个以/post/8结尾的 URL,这意味着该路由与设置为8的id参数匹配。
让我们开始设置页面和路由:
-
通过执行以下命令将
Chapter08_1文件夹复制到一个新的Chapter08_2文件夹:$ cp -R Chapter08_1 Chapter08_2 -
在 VS Code 中打开新的
Chapter08_2文件夹。 -
编辑
src/api.js并定义一个新的函数来获取单个帖子:export async function fetchPost({ id }) { const res = await fetch(`/api/posts/${id}`) return await res.json() } -
编辑
src/components/post/Post.jsx并导入useSuspenseQuery和fetchPost函数:import { useSuspenseQuery } from '@tanstack/react-query' import { fetchPost } from '@/api.js' -
将
Post组件更改为仅接受id属性:export function Post({ **id** }) { -
在
Post组件内部,添加一个Suspense Query Hook来获取帖子并获取所有数据:const { data } = useSuspenseQuery({ queryKey: ['post', id], queryFn: async () => await fetchPost({ id }), }) const { title, content, author } = data -
创建一个新的
src/pages/ViewPost.jsx文件。在文件内部,导入Suspense,从react-router导入useParams函数和Post组件:import { Suspense } from 'react' import { useParams } from 'react-router' import { Post } from '@/components/post/Post.jsx' -
定义并导出
ViewPost页面组件:export function ViewPost() { -
使用 Params Hook 从 URL 参数中获取
id:const { id } = useParams() -
使用
Suspense边界在帖子获取时提供回退,然后渲染Post组件:return ( <Suspense fallback={<strong>Loading post...</strong>}> <Post id={id} /> </Suspense> ) } -
编辑
src/App.jsx并导入ViewPost组件:import { ViewPost } from './pages/ViewPost.jsx' -
然后,为
ViewPost页面定义一个新的带有:id参数的路由:<Routes> <Route index element={<Home />} /> **<****Route****path****=****'post/:id'****element****=****{****<****ViewPost** **/>****} />** </Routes> -
按照以下方式运行应用程序,在整个章节的其余部分保持运行状态:
$ npm run dev
现在可以通过在浏览器中的 URL 后附加/post/:id来手动访问单个帖子页面(例如/post/1):

图 8.1 – 在我们新定义的路由上查看单个帖子
然而,如果我们能通过点击主页上的主帖流中的某个帖子来访问这个页面,那就太好了。让我们在下一节中通过使用Link组件来实现这个功能。
使用组件链接到路由
当处理用户可以点击以访问不同页面的链接时,最好且最简单的方法是使用Link组件。这个组件将自动为我们创建一个指向特定页面的简单链接。
让我们开始使用Link组件来提供一个指向单个帖子的链接:
-
创建一个新的
src/components/post/PostListItem.jsx文件,在其中我们将定义Post组件的简化版本,该版本将在PostList组件中显示。在文件内部,导入useContext函数,ThemeContext和Link组件从react-router:import { useContext } from 'react' import { ThemeContext } from '@/contexts/ThemeContext.js' import { Link } from 'react-router' -
定义并导出
PostListItem组件,它接受帖子id、title和author作为 props:export function PostListItem({ id, title, author }) { -
定义一个 Context Hook 来获取主题:
const theme = useContext(ThemeContext) -
渲染标题,就像我们之前做的那样:
return ( <div> <h3 style={{ color: theme.primaryColor }}>{title}</h3> -
现在,渲染一个
Link组件,它将导航到/post/:id并显示ViewPost页面:<div> <Link to={`/post/${id}`}>View Post ></Link> </div> -
然后,显示作者,但不显示内容,以避免使信息过载:
<br /> <i> Written by <b>{author}</b> </i> </div> ) } -
编辑
src/components/post/PostList.jsx并将Post导入替换为PostListItem组件的导入:import { **PostListItem** } from './**PostListItem**.jsx' -
将
PostListItem组件渲染代替Post组件:{posts.map((post) => ( <Fragment key={post.id}> <**PostListItem** {...post} />
现在可以从首页跳转到单个帖子:

图 8.2 – 链接组件渲染“查看帖子 >”链接以跳转到单个帖子页面
但仍然没有返回首页的方法。让我们在下一节中实现它。
使用<NavLink>定义导航栏
如果我们想要给链接添加样式,例如,实现一个显示我们当前所在页面的导航栏,我们可以使用NavLink组件。
让我们使用这个组件来实现一个带有返回首页链接的导航栏:
-
创建一个新的
src/components/NavBarLink.jsx文件。在其内部,导入NavLink组件:import { NavLink } from 'react-router' -
定义并导出一个组件,它接受一个
toprop,用于定义我们应该链接到哪个路由,以及一个childrenprop 来提供要放在链接上的文本或组件:export function NavBarLink({ children, to }) { return ( <NavLink to={to} -
然后,定义一个
style,在其中检查链接是否处于激活状态(当我们当前在页面上时),然后以粗体形式渲染它:style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal', })} > {children} </NavLink> ) } -
编辑
src/App.jsx并导入NavBarLink组件,如下所示:import { NavBarLink } from './components/NavBarLink.jsx' -
在我们的博客应用的头部部分,在
UserBar之前定义一个返回索引/首页的NavBarLink:<BrowserRouter> <div style={{ padding: 8 }}> **<****NavBarLink****to****=****'/'****>****Home****</****NavBarLink****>** **<****hr** **/>** <UserBar />
现在我们有了一种从首页跳转到单个帖子,然后再回到首页查看其他帖子的方法:

图 8.3 – 渲染一个“首页”NavLink,当前处于激活状态(粗体)
接下来,让我们看看在创建新帖子后如何程序化地导航到单个帖子页面。
使用 Navigation Hook 进行程序化导航
每当我们想要程序化导航而不是让用户点击链接时,我们可以使用 React Router 提供的 Navigation Hook。Navigation Hook 提供了一个用于程序化导航的函数。
让我们现在开始使用 Navigation Hook:
-
编辑
src/components/post/CreatePost.jsx并导入useNavigate函数:import { useNavigate } from 'react-router' -
在
CreatePost组件内部定义一个 Navigate Hook:export function CreatePost() { const [username] = useContext(UserContext) **const** **navigate =** **useNavigate****()** -
在 Action State Hook 内部,从 mutation 中获取结果,然后重定向到新创建的帖子的
ViewPost页面:const [error, submitAction, isPending] = useActionState( async (currentState, formData) => { const title = formData.get('title') const content = formData.get('content') const newPost = { title, content, author: username, featured: false } try { **const** **result =** await createPostMutation.mutateAsync(newPost) **navigate****(****`/post/****${result.id}****`****)** } catch (err) { return err } }, ) -
尝试在博客应用中创建一个新的帖子,你会看到你被重定向到新创建的帖子页面!
我们已经在我们的博客应用程序中成功实现了路由!作为一个练习,你现在可以尝试在单独的页面上实现登录/注册表单和创建文章表单。在这样做的时候,我建议将主页链接重构为一个新的 NavBar 组件,其中包含链接到各个页面。
示例代码
本节示例代码位于 Chapter08/Chapter08_2 文件夹中。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
概述
在本章中,我们首先学习了 React Router 库的工作原理以及它由哪些组件组成。然后,我们设置了库以及博客主页的索引路由(显示博客文章的列表)。接下来,我们定义了一个新的路由来显示单独页面上的单个文章,并使用 Params 钩子从 URL 中获取 id 值。然后,我们学习了如何使用 Link 和 NavLink 组件导航到这个新路由以及如何返回主页。最后,我们学习了如何通过使用导航钩子程序化地导航到在文章成功创建后的路由。
在下一章中,我们将学习 React 提供的更高级的内置钩子。
问题
为了回顾本章学到的内容,尝试回答以下问题:
-
React Router 库由哪些组件组成?
-
我们如何使用 React Router 库定义一个新的路由?
-
我们如何在 URL 中读取动态值(参数)?
-
使用 React Router 定义链接有哪些方法,它们有何不同?
-
哪个钩子用于使用 React Router 程序化导航?
进一步阅读
如果你对本章学到的概念有更多兴趣,请查看以下链接:
- React Router 的官方网站:
reactrouter.com/
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第九章:React 提供的高级 Hooks
在上一章中,我们学习了如何使用 React Router 实现路由。然后,我们学习了如何使用 Params Hook 实现动态路由。接下来,我们学习了如何使用 Link 组件提供不同路由的链接。最后,我们学习了如何使用 Navigation Hook 进行编程式重定向。
在本章中,我们将学习 React 提供的各种内置 Hooks。我们将首先概述内置 React Hooks,然后学习各种实用 Hooks。接下来,我们将学习如何使用 Hooks 来优化您应用程序的性能。最后,我们将学习关于高级 Effect Hooks 的内容。
到本章结束时,您将对 React 提供的所有内置 Hooks 有一个全面的了解。
本章将涵盖以下主题:
-
内置 React Hooks 概述
-
使用实用 Hooks
-
使用 Hooks 进行性能优化
-
使用 Hooks 实现高级效果
技术要求
应该已经安装了一个相当新的 Node.js 版本。Node 包管理器(npm)也需要安装(它应该与 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请查看官方网站:nodejs.org/。
在这本书的指南中,我们将使用Visual Studio Code(VS Code),但在任何其他编辑器中都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅官方网站:code.visualstudio.com。
在这本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
VS Code v1.97.2
虽然安装新版本不应该有问题,但请注意,某些步骤在新版本上可能会有所不同。如果您在这本书提供的代码和步骤中遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter09。
强烈建议您亲自编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
内置 React Hooks 概述
React 提供了一些内置 Hooks。我们已经学习了 React 提供的基本 Hooks:
-
在第二章**,使用 State Hook中,使用
useState -
在第四章**,使用 Reducer 和 Effect Hooks中,使用
useEffect -
在第五章**,实现 React Contexts中,使用
useContext
此外,React 还提供了更多高级 Hooks,在某些用例中非常有用。我们已经介绍了以下高级 Hooks:
-
useReducer在 第四章**,使用 Reducer 和 Effect Hooks 中。 -
useActionState在 第七章**,使用 Hooks 处理表单 中。 -
useFormStatus(尚未介绍,但类似于useActionState) -
useOptimistic在 第七章**,使用 Hooks 处理表单 中。 -
useTransition在 第七章**,使用 Hooks 处理表单 中。
然而,React 还提供了更多的高级 Hooks:
-
useRef -
useImperativeHandle -
useId -
useSyncExternalStore -
useDebugValue -
useDeferredValue -
useMemo -
useCallback -
useLayoutEffect -
useInsertionEffect
首先,让我们回顾和总结一下我们已经学过的 Hooks。然后,我们将简要介绍 React 提供的所有这些高级 Hooks,并学习为什么以及如何使用它们。
useState
状态 Hook 返回一个值,该值将在重新渲染之间持续存在,以及一个用于更新它的函数。可以将 initialState 的值作为参数传递给它:
const [state, setState] = useState(initialState)
调用 setState 更新值并使用更新后的值重新渲染组件。如果值没有变化,React 不会重新渲染组件。
可以将一个函数传递给 setState 函数,第一个参数是当前值。例如,考虑以下代码:
setState(val => val + 1)
此外,如果初始状态是复杂计算的结果,可以将一个函数传递给 Hook 的第一个参数。在这种情况下,该函数将在 Hook 初始化期间只调用一次:
const [state, setState] = useState(() => {
return computeInitialState()
})
状态 Hook 是 React 提供的最普遍的 Hook。
我们在 第二章**,使用状态 Hook 中使用了这个 Hook。
useEffect
Effect Hook 接受一个包含具有副作用(如计时器和订阅)的代码的函数。传递给 Hook 的函数将在渲染完成后、组件在屏幕上时运行:
useEffect(() => {
// do something
})
Hook 可以返回一个清理函数,当组件卸载时将被调用,例如,用于清理计时器或订阅:
useEffect(() => {
const interval = setInterval(() => {}, 100)
return () => {
clearInterval(interval)
}
})
如果组件在 effect 再次激活之前多次渲染,清理函数也将被调用。
为了避免在每次重新渲染时触发 effect,我们可以将值数组作为 Hook 的第二个参数。当这些值中的任何一个发生变化时,effect 将再次被触发:
useEffect(() => {
// do something when state changes
}, [state])
作为第二个参数传递的数组称为 effect 的依赖数组。如果你想使 effect 只在挂载时触发,并在卸载时清理,你可以将一个空数组作为第二个参数传递。
我们在 第四章**,使用 Reducer 和 Effect Hooks 中使用了这个 Hook。
useContext
上下文钩子接受一个上下文对象,并返回上下文的当前值。当上下文提供者更新其值时,钩子将触发重新渲染,并带有最新的值:
const value = useContext(NameOfTheContext)
我们在 第五章**,实现 React 上下文 中使用了这个钩子。
useReducer
Reducer 钩子是 useState 钩子的高级版本。它接受一个 reducer 作为第一个参数,它是一个具有两个参数的函数:state 和 action。然后,reducer 函数返回从当前状态和动作计算出的更新后的状态。如果 reducer 返回与上一个状态相同的值,React 不会重新渲染组件或触发效果:
const [state, dispatch] = useReducer(reducer, initialState, initFn)
在处理复杂状态变化时,我们应该使用 useReducer 钩子而不是 useState 钩子。处理全局状态也更容易,因为我们只需简单地传递 dispatch 函数而不是多个设置函数。
dispatch 函数是稳定的,在重新渲染时不会改变,因此可以从 useEffect 或 useCallback 依赖数组中省略它。
我们可以通过设置 initialState 值或指定一个 initFn 函数作为第三个参数来指定初始状态。当计算初始状态需要很长时间或我们想要通过动作重置状态时,指定此类函数是有意义的。
我们在 第四章**,使用 Reducer 和 Effect 钩子 中使用了这个钩子。
useActionState
动作状态钩子的定义如下:
const [state, action, isPending] = useActionState(actionFn, initialState)
要定义一个动作状态钩子,我们需要提供一个 action 函数作为第一个参数,它具有以下签名:
function actionFn(currentState, formData) {
然后我们需要将 action 属性传递给一个 <form> 元素。当这个表单被提交时,动作函数会使用钩子的当前状态和表单内提交的 FormData 被调用。
此外,还可以为钩子提供一个 initialState,并使用 isPending 值在动作处理期间显示加载状态。
我们在 第七章**,使用钩子处理表单 中使用了这个钩子。
useFormStatus
表单状态钩子的定义如下:
const { pending, data, method, action } = useFormStatus()
它用于我们未处理表单提交的情况。例如,如果我们有一个后端为我们处理表单提交,或者如果我们正在使用服务器操作来处理表单状态(在执行全栈 React 开发时相关)。
它返回一个具有以下属性的 status 对象:
-
pending:如果父<form>正在提交,则设置为true -
data:包含父表单提交的FormData -
method:设置为'get'或'post',取决于父<form>中定义了哪种方法。 -
action:如果向父<form>传递了动作函数,这将包含对该函数的引用。否则,它将是null。
例如,它可以用来实现一个在表单提交到服务器端时禁用的提交按钮:
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>Submit</button>
}
function ExampleForm() {
return (
<form>
<SubmitButton />
</form>
)
}
表单状态钩子只能用于在 <form> 内部渲染的组件中。与其他钩子不同,在撰写本文时,这是唯一从 react-dom 而不是 react 导出的钩子。
useOptimistic
乐观钩子具有以下签名:
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn)
它可以在我们等待从服务器获取远程状态更新完成时乐观地更新状态。它接受一个状态(通常来自 API 请求,例如一个查询钩子)和一个 update 函数。然后钩子返回一个乐观状态和一个添加乐观状态的功能。
例如,乐观钩子可以在我们等待服务器完成添加操作时将新对象插入数组中。在这种情况下,更新函数看起来如下:
function updateFn(state, newObject) {
return state.concat(
{ ...newObject, pending: true }
)
}
这个更新函数乐观地插入一个新对象,但给它添加一个 pending: true 标志,这样我们就可以稍后以不同的方式渲染挂起对象(例如,稍微变灰)。
我们在第七章**,使用钩子处理表单中使用了这个钩子。
useTransition
过渡钩子允许你通过更新状态而不阻塞用户界面来处理异步操作。这对于渲染计算密集型的组件树特别有用,例如渲染标签及其(可能复杂的)内容,或者当制作客户端路由器时。过渡钩子具有以下签名:
const [isPending, startTransition] = useTransition()
可以使用 isPending 状态来处理加载状态。startTransition 函数允许我们传递一个函数来启动过渡。这个函数需要是同步的。当函数内部触发更新(例如,设置状态)并评估其对组件的影响时,isPending 将被设置为 true。
这不会阻塞用户界面,因此在过渡执行期间,其他组件仍然可以正常工作。
我们在第七章**,使用钩子处理表单中使用了这个钩子。
在回顾我们已经学过的内置钩子之后,现在让我们继续学习其他高级内置钩子,这些钩子我们尚未使用,从 React 提供的内置实用钩子开始。
使用实用钩子
我们首先学习关于实用钩子的内容。这些钩子允许我们模拟某些用例或在我们开发自己的钩子时帮助我们,如在第十二章**,构建自己的钩子中所述。
我们现在将在我们的博客应用中设置一个演示页面,以便能够测试各种实用钩子。
让我们开始设置演示页面来测试这些钩子:
-
通过执行以下命令将
Chapter08_2文件夹复制到新的Chapter09_1文件夹:$ cp -R Chapter08_2 Chapter09_1 -
在 VS Code 中打开新的
Chapter09_1文件夹。 -
创建一个新的
src/components/demo/文件夹。这是我们稍后放置演示组件以尝试我们将要学习的各种 Hooks 的地方。 -
创建一个新的
src/pages/Demo.jsx文件,内容如下:export function Demo() { return <h1>Demo Page</h1> } -
编辑
src/App.jsx并导入Demo页面:import { Demo } from './pages/Demo.jsx' -
然后,为它定义一个新的
NavBarLink:<BrowserRouter> <div style={{ padding: 8 }}> <NavBarLink to='/'>Home</NavBarLink> **{' | '}** **<****NavBarLink****to****=****'/demo'****>****Demo****</****NavBarLink****>** -
最后,为它定义一个路由:
<Routes> <Route index element={<Home />} /> <Route path='post/:id' element={<ViewPost />} /> **<****Route****path****=****'demo'****element****=****{****<****Demo** **/>****} />** -
启动
dev服务器并在整章中保持其运行,如下所示:$ npm run dev -
点击导航栏中的Demo链接以打开演示页面。
现在我们有一个演示页面,我们可以开始学习 React 提供的其他内置高级 Hooks 了!

图 9.1 – 我们博客应用中的 Demo 页面
useRef
Ref Hook返回一个ref对象,可以通过ref属性将其分配给组件或元素:
const refContainer = useRef(initialValue)
在将 ref 对象分配给元素或组件后,可以通过refContainer.current访问 ref 对象。如果设置了initialValue,则在分配之前refContainer.current将被设置为这个值。
ref 对象可用于各种用例,但主要有两个:
-
获取一个元素的引用以在文档对象模型(DOM)中访问它
-
保持可变值,这些值不应受 React 生命周期的影响(例如,当值被突变时不会触发重新渲染)
使用 Ref Hook 自动聚焦输入字段
我们可以使用 Ref Hook 获取输入字段元素的引用,然后通过 DOM 访问其focus()函数来实现渲染时自动聚焦的输入字段。虽然也可以通过 HTML 为元素提供autofocus属性,但有时需要程序化地完成它——例如,如果我们想在用户完成其他操作后聚焦一个字段。
让我们现在开始使用 Ref Hook 实现自动聚焦输入字段的实现:
-
创建一个新的
src/components/demo/useRef/文件夹。 -
创建一个新的
src/components/demo/useRef/AutoFocus.jsx文件。在其内部,导入useRef和useEffect:import { useRef, useEffect } from 'react' -
然后,定义组件和一个 Ref Hook:
export function AutoFocus() { const inputRef = useRef(null) -
接下来,定义一个在渲染时被调用并导致输入字段聚焦的 Effect Hook:
useEffect(() => inputRef.current.focus(), []) -
渲染输入字段并将 Ref 传递给它:
return ( <div> <h3>AutoFocus</h3> <input ref={inputRef} type='text' /> </div> ) } -
现在,编辑
src/pages/Demo.jsx并导入AutoFocus组件:import { AutoFocus } from '@/components/demo/useRef/AutoFocus.jsx' -
通过调整组件如下,在
Demo页面上渲染它:export function Demo() { **return** **(** **<****div****>** **<****h1****>****Demo Page****</****h1****>** **<****h2****>****useRef****</****h2****>** **<****AutoFocus** **/>** **</****div****>** **)** }
刷新页面;你应该看到输入字段正在自动聚焦。

图 9.2 – 输入字段正在自动聚焦
在一个 ref 中更改状态
重要的是要注意,修改 ref 的当前值不会导致重新渲染。如果需要这样做,我们可以使用一个 ref 回调函数。这个函数将在元素加载时被调用。例如,我们可以使用这个函数来获取 DOM 中元素的初始大小。然后,我们可以在回调函数内部设置 State Hook 的状态来触发重新渲染。
如果我们不仅想要获取组件的初始宽度,还想要获取当前宽度(即使组件后来被调整大小),我们需要使用 布局 Effect Hook。我们将在本章的 使用 Hooks 进行高级效果 部分稍后介绍这个用例。
现在我们尝试在 refs 中使用回调函数来获取组件的初始宽度:
-
创建一个新的
src/components/demo/useRef/InitialWidthMeasure.jsx文件。在其中,导入useState函数:import { useState } from 'react' -
然后,定义组件和一个 State Hook 来存储组件的宽度:
export function InitialWidthMeasure() { const [width, setWidth] = useState(0) -
现在,定义一个用于
ref的回调函数,该函数接受 DOMnode作为参数:function measureRef(node) { -
检查我们是否成功获取了 DOM 节点的引用,然后使用 DOM API 获取元素的当前宽度:
if (node !== null) { setWidth(node.getBoundingClientRect().width) } } -
渲染组件并通过
ref属性添加回调函数:return ( <div> <h3>InitialWidthMeasure</h3> <div ref={measureRef}>I was initially {Math.round(width)}px wide</div> </div> ) } -
编辑
src/pages/Demo.jsx并在那里导入InitialWidthMeasure组件:import { InitialWidthMeasure } from '@/components/demo/useRef/InitialWidthMeasure.jsx' -
最后,在 Demo 页面上渲染组件:
export function Demo() { return ( <div> <h1>Demo Page</h1> <h2>useRef</h2> <AutoFocus /> **<****InitialWidthMeasure** **/>**
现在,Demo 页面应该会在您的浏览器中自动刷新并显示组件及其初始宽度!

图 9.3 – 显示组件初始宽度的组件
使用 refs 在重新渲染之间持久化可变值
Refs 可以用来访问 DOM,但也可以在组件重新渲染时保持可变值,例如存储间隔的引用。
让我们通过实现一个计算经过秒数的计时器来尝试一下:
-
创建一个新的
src/components/demo/useRef/Timer.jsx文件。在其中,导入useRef、useState和useEffect函数:import { useRef, useState, useEffect } from 'react' -
然后,定义并导出
Timer组件:export function Timer() { -
在其中,定义一个用于存储间隔的 Ref Hook 和一个用于存储当前计数的 State Hook:
const intervalRef = useRef(null) const [seconds, setSeconds] = useState(0) -
定义一个将增加计数的函数:
function increaseSeconds() { setSeconds((prevSeconds) => prevSeconds + 1) } -
现在,定义一个 Effect Hook 来定义一个新的间隔并将其存储在 ref 中:
useEffect(() => { intervalRef.current = setInterval(increaseSeconds, 1000) -
我们现在可以使用这个 ref 在组件卸载时清除间隔:
return () => clearInterval(intervalRef.current) }, []) -
渲染计时器的当前计数:
return ( <div> <h3>Timer</h3> {seconds} seconds -
最后,渲染一个按钮来取消计时器:
<button type='button' onClick={() => clearInterval(intervalRef.current)}> Cancel </button> </div> ) }
如果我们不需要在 Effect Hook 之外访问间隔 ID,我们可以在 effect 中简单地使用一个 const 而不是定义一个 Ref。虽然我们可以使用 State Hook 来存储间隔 ID,但这会导致组件重新渲染。正如我们所见,Refs 对于存储需要改变但又不用于渲染的值是理想的。
-
编辑
src/pages/Demo.jsx并在那里导入Timer组件:import { Timer } from '@/components/demo/useRef/Timer.jsx' -
最后,在 Demo 页面上渲染组件:
export function Demo() { return ( <div> <h1>Demo Page</h1> <h2>useRef</h2> <AutoFocus /> <InitialWidthMeasure /> **<****Timer** **/>**
现在 Demo 页面应该会在你的浏览器中自动刷新并显示计数秒数的组件!按下取消按钮停止计时器。
使用 refs 的方式与前面的例子相似,这使得它们类似于类中的实例变量,例如this.intervalRef。
以下截图显示了在页面打开后 42 秒,Demo 页面上的Timer组件的外观:

图 9.4 – 显示自打开页面以来经过的秒数的计时器组件
将引用作为属性传递
有时候,你可能想要获取另一个组件内部输入字段的引用(例如,当处理自定义输入字段时)。在过去,这需要forwardRef辅助函数。然而,自从 React 19 以来,我们可以简单地通过属性传递 refs。
让我们试试看:
-
创建一个新的
src/components/demo/useRef/CustomInput.jsx文件。 -
在其中定义以下自定义输入组件,接受一个引用作为属性:
export function CustomInput({ ref }) { -
我们现在可以像往常一样使用 refs:
return <input ref={ref} type='text' /> } -
现在,编辑
src/components/demo/useRef/AutoFocus.jsx文件并导入CustomInput组件:import { CustomInput } from './CustomInput.jsx' -
将输入字段替换为我们的
CustomInput组件,并将引用传递给它:return ( <div> <h3>AutoFocus</h3> **<****CustomInput****ref****=****{inputRef}** **/>**
刷新 Demo 页面,你会看到输入字段仍然在自动聚焦!
只创建一次 refs 内容
如果你有一个需要初始化的复杂算法,例如路径查找算法,你可以将其存储在 refs 中,以避免在每次渲染时创建它。这应该这样做:
function Map() {
const pathfinderRef = useRef(null)
if (pathfinderRef.current === null) {
pathfinderRef.current = createPathfinder()
}
}
通常,在渲染中像那样写入或读取ref.current在 React 中是不允许的。然而,在这种情况下,这是可以的,因为条件使得它只在一开始初始化组件时执行一次。
虽然 React 总是只保存 refs 的初始值一次,但直接在 Ref Hook 内部调用函数,如useRef(createPathfinder()),会在每次渲染时无谓地执行昂贵的函数。
正如我们所见,refs 有很多用例。通常,refs 对于以下操作很有用:
-
在重新渲染之间存储信息,因为——与常规变量不同——refs 在重新渲染时不会重置
-
在不触发重新渲染的情况下更改信息,因为——与 State Hooks 不同——refs 不会触发重新渲染
-
存储每个组件副本本地的信息,因为——与组件外部的常规变量不同——refs 在组件的不同实例之间没有共享值
useImperativeHandle
强制处理 Hook可以用来自定义当将ref指向它时暴露给其他组件的实例值。然而,应该尽可能避免这样做,因为它紧密耦合了组件,这会损害可重用性。
useImperativeHandle函数具有以下签名:
useImperativeHandle(ref, createHandle, [dependencies])
我们可以使用这个 Hook,例如,来公开一个特殊的 focus 函数,该函数不仅聚焦输入字段,还突出显示它。然后,其他组件可以通过对组件的 ref 调用此函数。现在让我们试试看:
-
创建一个新的
src/components/demo/useImperativeHandle/文件夹。 -
在其中,创建一个新的
src/components/demo/useImperativeHandle/HighlightFocusInput.jsx文件。 -
导入
useImperativeHandle、useRef和useState函数:import { useImperativeHandle, useRef, useState } from 'react' -
然后,定义一个接受
ref的组件:export function HighlightFocusInput({ ref }) { -
在组件内部,我们为输入字段定义一个 Ref Hook 和一个用于存储
highlight状态的状态 Hook:const inputRef = useRef(null) const [highlight, setHighlight] = useState(false) -
现在,定义一个 Imperative Handle Hook,将其
ref传递给它,并传递一个返回对象的函数:useImperativeHandle(ref, () => ({ -
此对象包含一个
focus函数,它将在input元素上触发focus函数,并将highlight状态设置为true一秒钟:focus: () => { inputRef.current.focus() setHighlight(true) setTimeout(() => setHighlight(false), 1000) }, })) -
最后,渲染一个输入字段并将
inputRef传递给它:return ( <input ref={inputRef} type='text' -
如果
highlight设置为true,则以yellow颜色渲染背景:style={{ backgroundColor: highlight ? 'yellow' : undefined }} /> ) } -
创建一个新的
src/components/demo/useImperativeHandle/HighlightFocus.jsx文件。 -
在其中,导入
useRef函数和HighlightFocusInput组件:import { useRef } from 'react' import { HighlightFocusInput } from './HighlightFocusInput.jsx' -
现在,定义一个组件和一个 Ref Hook:
export function HighlightFocus() { const inputRef = useRef(null) -
渲染一个按钮以触发组件中的
focus函数,然后渲染组件并将ref传递给它:return ( <div> <h3>HighlightFocus</h3> <button onClick={() => inputRef.current.focus()}>focus it</button> <HighlightFocusInput ref={inputRef} /> </div> ) } -
编辑
src/pages/Demo.jsx并导入HighlightFocus组件:import { HighlightFocus } from '@/components/demo/useImperativeHandle/HighlightFocus.jsx' -
渲染
HighlightFocus组件,如下所示:<InitialWidthMeasure /> <Timer /> **<****h2****>****useImperativeHandle****</****h2****>** **<****HighlightFocus** **/>** </div> ) }
现在,转到 Demo 页面并点击 focus it 按钮。你会看到输入字段被聚焦并高亮显示!

图 9.5 – 组件聚焦并突出显示输入字段
如我们所见,通过使用 refs 和 Imperative Handle Hook,我们可以访问其他组件中的函数。然而,这应该谨慎使用,因为它紧密耦合了组件,当我们的应用增长并且我们想在其他地方重用组件时,可能会成为一个问题。
useId
Id Hook 用于生成唯一的 ID。这可以很有用,例如,为元素提供 IDs 以提供无障碍属性(如 aria-labelledby 或 aria-describedby)。Id Hook 具有以下签名:
const uniqueId = useId()
让我们通过为复选框字段提供一个标签来尝试它:
-
创建一个新的
src/components/demo/useId/文件夹。 -
在其中,创建一个新的
src/components/demo/useId/AriaInput.jsx文件。 -
导入
useId函数:import { useId } from 'react' -
然后,定义一个使用 Id Hook 为标签生成 ID 的组件:
export function AriaInput() { const inputId = useId() -
渲染一个带有
htmlFor标签指向inputId的标签:return ( <div> <h3>AriaInput</h3> <label htmlFor={inputId}> -
使用生成的 ID 渲染一个
checkbox字段:<input id={inputId} type='checkbox' /> I agree to the Terms and Conditions. </label> </div> ) } -
编辑
src/pages/Demo.jsx并导入AriaInput组件:import { AriaInput } from '@/components/demo/useId/AriaInput.jsx' -
然后,在 Demo 页面上渲染该组件:
<Timer /> <h2>useImperativeHandle</h2> <HighlightFocus /> **<****h2****>****useId****</****h2****>** **<****AriaInput** **/>** </div> ) }
现在,转到 Demo 页面并打开输入字段的检查器;你会看到 React 为我们生成了一个 :r0: ID:

图 9.6 – React 自动生成的唯一 ID
在 React 19.1 中,ID 的格式从 :r123: 更改为 <<r123>>,以确保它们是有效的 CSS 选择器。
除了将标签连接到屏幕阅读器的输入字段(提高可访问性)外,使用 <label> 元素还有一个额外的优势,即允许我们点击标签来检查/取消选中复选框。
尽管在这种情况下我们可以手动设置一个 ID,例如,tos-check,但 ID 需要在整个页面上是唯一的。因此,如果我们想再次渲染相同的输入字段,ID 已经无效,因为它被重用了。为了防止这个问题,在这些情况下始终优先使用 Id Hook,以便组件可以在同一页面上多次重用。如果你在单个组件中有多个输入字段,最佳实践是在组件中只使用一个 Id Hook,然后通过添加标签到生成的 ID 来扩展 ID – 例如:`${id}-tos-check` 和 `${id}-username`。
useSyncExternalStore
Sync External Store Hook 用于订阅外部存储,例如状态管理库或浏览器 API。它的签名如下:
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
如我们所见,Sync External Store Hook 接受三个参数,并返回存储的当前快照,可以用来渲染其中的信息。参数如下:
-
第一个参数,
subscribe,是一个函数,它接受一个callback函数作为参数并将其订阅到存储中。当存储发生变化时,提供的函数应该被调用。subscribe函数还应该返回一个函数来清理订阅。 -
第二个参数,
getSnapshot,是一个函数,它返回存储中当前数据状态的快照。如果存储发生变化(subscribe函数中的callback函数被调用),React 会调用getSnapshot函数并检查返回的值是否不同。如果是,组件将重新渲染。 -
第三个参数,
getServerSnapshot,是一个可选的函数,它返回存储中当前数据状态的初始快照。这个函数仅在服务器渲染期间被调用,并用于在客户端恢复服务器渲染的内容。
在大多数情况下,使用 State 和 Reducer Hooks 而不是这个 Hook 会更好。大多数状态管理库也提供了它们自己的 Hooks。这个 Hook 主要在集成现有的非 React 代码时有用,但在与某些浏览器 API 交互时也有用,这正是我们现在要尝试的。
让我们通过使用 Sync External Store Hook 订阅浏览器 API 来实现一个检查网络连接是否可用的指示器:
-
创建一个新的
src/components/demo/useSyncExternalStore/文件夹。 -
在其中,创建一个新的
src/components/demo/useSyncExternalStore/OnlineIndicator.jsx文件。 -
导入
useSyncExternalStore函数:import { useSyncExternalStore } from 'react' -
定义一个接受
callback函数作为参数的subscribe函数:function subscribe(callback) { -
添加来自浏览器的
online和offline事件的监听器:window.addEventListener('online', callback) window.addEventListener('offline', callback) -
返回一个将清理那些事件监听器的函数:
return () => { window.removeEventListener('online', callback) window.removeEventListener('offline', callback) } } -
现在,定义一个
getSnapshot函数,该函数返回当前的在线状态:function getSnapshot() { return navigator.onLine } -
然后,定义组件和一个同步外部存储 Hook:
export function OnlineIndicator() { const isOnline = useSyncExternalStore(subscribe, getSnapshot) -
根据浏览器 API 的结果定义状态:
const status = isOnline ? 'online' : 'offline' -
渲染状态:
return ( <div> <h3>OnlineIndicator</h3> {status} </div> ) } -
编辑
src/pages/Demo.jsx并导入OnlineIndicator组件:import { OnlineIndicator } from '@/components/demo/useSyncExternalStore/OnlineIndicator.jsx' -
在 Demo 页面上渲染组件:
<h2>useId</h2> <AriaInput /> **<****h2****>****useSyncExternalStore****</****h2****>** **<****OnlineIndicator** **/>** </div> ) }
现在,转到 Demo 页面,如果你在线的话,它应该显示 在线。关闭所有网络连接以查看它变为 离线。

图 9.7 – 通过外部存储(浏览器 API)检测用户已离线
useDebugValue
调试值 Hook 对于开发作为共享库一部分的自定义 Hook 非常有用。它可以用于在 React DevTools 中显示某些值以进行调试。其签名如下:
useDebugValue(value, format)
第一个参数 value 是应该记录的值或消息。第二个可选的 format 参数用于提供一个格式化函数,该函数将在显示之前格式化值。
让我们通过定义一个用于 OnlineIndicator 组件的自定义 Hook 简单尝试一下:
-
编辑
src/components/demo/useSyncExternalStore/OnlineIndicator.jsx并导入useDebugValue函数:import { useSyncExternalStore**, useDebugValue** } from 'react' -
然后,在定义组件之前定义一个新的 Hook 函数:
function useOnlineStatus() { const isOnline = useSyncExternalStore(subscribe, getSnapshot) const status = isOnline ? 'online' : 'offline' -
添加调试值 Hook,如下所示:
useDebugValue(status) -
从 Hook 返回状态:
return status } -
调整组件以使用自定义 Hook:
export function OnlineIndicator() { **const** **status =** **useOnlineStatus****()** return ( <div> <h3>OnlineIndicator</h3> {status} </div> ) }
现在,转到 Demo 页面。如果你还没有安装 React 开发者工具 扩展,请为你的浏览器安装它(遵循 react.dev/learn/react-developer-tools 上的说明)。转到浏览器检查器的 Components 选项卡并选择 OnlineIndicator 组件。
你将看到自定义 Hook 的调试值在那里显示:

图 9.8 – 在 React 开发者工具中显示我们自定义 Hook 的状态
在了解了 React 提供的各种内置实用 Hook 之后,让我们继续学习如何使用内置 Hooks 进行性能优化。
使用 Hooks 进行性能优化
某些钩子可以用来优化你应用程序的性能。一般来说,一个经验法则是不要过早优化。这在 React 19 中引入的 React 编译器中尤其如此。如今,React 编译器自动为我们优化了大多数情况。所以,请记住,只有在你已经确定了应用程序中特定的性能问题时才使用这些钩子。一般来说,一个经验法则是除非你知道它将是一个昂贵的计算,否则不要过早优化。
React 编译器是一个可以手动安装的 Babel 插件,它也包含在某些框架中,如 Next.js。有关 React 编译器的更多信息,请阅读 React 文档中的以下页面:react.dev/learn/react-compiler。
useDeferredValue
延迟值钩子可以用来延迟低优先级的更新(例如过滤列表),以便先处理高优先级的更新(例如更新输入字段中输入的文本)。
例如,如果你有一个可以输入文本以过滤项目的搜索,延迟值钩子可以用来延迟过滤的更新。与设置固定时间后更新持久化的防抖不同,延迟是动态的,并且依赖于 UI 渲染的速度。在较快的机器上,它将更频繁地更新,而在较慢的机器上,更新不会减慢 UI 的其他部分。
useDeferredValue函数的签名如下所示:
const deferredValue = useDeferredValue(value, initialValue)
第一个参数是要延迟的值。例如,这个值可以来自处理用户输入的状态钩子。
第二个参数是一个可选的初始值,用于组件的初始渲染。如果没有定义初始值,钩子将在初始渲染期间不进行延迟,因为没有值可以渲染,直到value被设置(例如,用户在输入字段中键入)。
实现无延迟值的搜索
让我们先实现一个搜索页面,其中包含博客文章的搜索,不使用延迟值:
-
编辑
src/api.js并定义一个函数来产生人工延迟,以便我们可以模拟搜索操作缓慢:function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -
接下来,定义一个
searchPostsAPI 函数,该函数获取所有帖子(特色和非特色):export async function searchPosts(query) { const res = await fetch('/api/posts') const posts = await res.json() -
然后,使用简单的搜索过滤帖子(将标题和查询转换为小写,然后检查查询是否包含在标题中):
const filteredPosts = posts.filter((post) => { const title = post.title.toLowerCase() return title.includes(query.toLowerCase()) }) -
添加一个一秒的延迟:
await sleep(1000) -
然后,返回过滤后的帖子:
return filteredPosts } -
创建一个新的
src/components/post/PostSearchResults.jsx文件。在其内部,导入以下内容:import { useSuspenseQuery } from '@tanstack/react-query' import { searchPosts } from '@/api.js' import { PostList } from './PostList.jsx' -
现在,定义一个组件,该组件将使用 Suspense 查询钩子、我们的 API 函数和
PostList组件来显示给定查询的搜索结果:export function PostSearchResults({ query }) { const { data } = useSuspenseQuery({ queryKey: ['posts', query], queryFn: () => searchPosts(query), }) return <PostList posts={data} /> } -
接下来,创建一个新的
src/components/post/PostSearch.jsx文件。在其内部,导入以下内容:import { useState, Suspense } from 'react' import { PostSearchResults } from './PostSearchResults.jsx' -
定义一个
PostSearch组件,该组件使用状态钩子和输入字段来处理查询:export function PostSearch() { const [query, setQuery] = useState('') return ( <div> <input value={query} onChange={(e) =>setQuery(e.target.value)} /> -
定义一个
Suspense边界,并在其中渲染PostSearchResults组件:<Suspense fallback={<h4>loading...</h4>}> <PostSearchResults query={query} /> </Suspense> </div> ) } -
创建一个新的
src/pages/Search.jsx文件。在其内部,导入PostSearch组件:import { PostSearch } from '@/components/post/PostSearch.jsx' -
按如下方式渲染包含
PostSearch组件的页面:export function Search() { return ( <div> <h1>Search posts</h1> <PostSearch /> </div> ) } -
编辑
src/App.jsx并导入Search页面:import { Search } from './pages/Search.jsx' -
添加如下链接到页面:
<BrowserRouter> <div style={{ padding: 8 }}> <NavBarLink to='/'>Home</NavBarLink> **{' | '}** **<****NavBarLink****to****=****'/search'****>****Search****</****NavBarLink****>** {' | '} <NavBarLink to='/demo'>Demo</NavBarLink> -
最后,定义路由:
<Routes> <Route index element={<Home />} /> <Route path='post/:id' element={<ViewPost />} /> <Route path='demo' element={<Demo />} /> **<****Route****path****=****'search'****element****=****{****<****Search** **/>****} />** </Routes>
现在前往 搜索 页面并输入一个查询;你会看到在显示新结果之前,会显示 加载中… 一秒钟。

图 9.9 – 等待新结果加载
虽然这个搜索功能正常工作,但在用户输入查询时用 加载中… 消息替换所有结果并不是一个很好的用户体验。
引入延迟值
使用延迟值钩子,我们可以在新结果正在获取时显示旧查询结果,一旦它们准备好,就无缝地替换它们。
现在让我们开始使用延迟值钩子:
-
编辑
src/components/post/PostSearch.jsx并导入useDeferredValue函数:import { useState, Suspense**, useDeferredValue** } from 'react' -
在
PostSearch组件内部定义延迟值钩子:export function PostSearch() { const [query, setQuery] = useState('') **const** **deferredQuery =** **useDeferredValue****(query)** -
现在,将
SearchResults组件的query替换为deferredQuery:<Suspense fallback={<h4>loading...</h4>}> <SearchResults query={**deferredQuery**} /> </Suspense> </div> ) }
前往 搜索 页面并在搜索输入字段中输入查询;你会看到在新的结果到来之前,会显示之前的结果。现在 加载中… 消息仅在首次输入查询之前显示!

图 9.10 – 显示新结果正在加载时的过时结果
useMemo
Memo 钩子会捕获一个函数的结果并将其缓存。这意味着它不会每次都重新计算。这个钩子可以用于性能优化:
const memoizedVal = useMemo(
() => computeVal(a, b, c),
[a, b, c]
)
在上一个示例中,computeVal 是一个性能密集型函数,它从 a、b 和 c 计算出一个结果。
useMemo 在渲染期间运行,所以请确保计算函数不会引起任何副作用,例如资源请求。副作用应该放入 useEffect 钩子中。
作为第二个参数传递的数组指定了函数的依赖项。如果这些值中的任何一个发生变化,函数将被重新计算;否则,将使用存储的结果。如果没有提供数组,则每次渲染都会计算一个新的值。如果传递了一个空数组,则值只计算一次。
不要仅依赖 useMemo 来只计算一次。如果这些之前缓存的值长时间未被使用,例如为了释放内存,React 可能会忘记它们。仅将其用于性能优化。
自 React 19 以来,React 编译器尝试尽可能自动地记忆化值。在大多数情况下,不再需要手动使用useMemo包装值。只有当你发现 React 编译器没有以令人满意的方式记忆化性能问题时,才使用这个钩子。作为一个经验法则,尽量不要过早地优化你的组件,除非你有非常充分的理由这样做。
useCallback
useCallback钩子与useMemo钩子的工作方式类似。然而,它返回一个记忆化的回调函数而不是一个值:
const memoizedCallback = useCallback(
() => doSomething(a, b, c),
[a, b, c]
)
之前的代码类似于以下useMemo钩子:
const memoizedCallback = useMemo(
() => () => doSomething(a, b, c),
[a, b, c]
)
返回的函数只有在依赖数组中传递的值之一发生变化时才会重新定义。
与记忆钩子类似,回调钩子只有在确定了 React 编译器没有以令人满意的方式处理的特定性能问题时才应使用,例如无限重渲染循环或渲染次数过多。
现在我们已经了解了如何使用内置钩子进行性能优化,让我们简要介绍一下 React 提供的最后几个内置钩子:效果钩子的高级版本。
使用钩子进行高级效果
效果钩子有两个特殊版本:布局效果钩子和插入效果钩子。这些钩子仅适用于非常特定的用例,所以我们在这里只简要介绍它们。
useLayoutEffect
布局效果钩子与效果钩子相同,但它会在所有 DOM 突变完成后、组件在浏览器中渲染之前同步触发。它可以用来在渲染前从 DOM 中读取信息并调整组件的外观。在这个钩子内部进行的更新将在浏览器渲染组件之前同步处理。
除非真的需要,否则不要使用这个钩子,这通常只出现在某些边缘情况下。useLayoutEffect会阻塞浏览器中的视觉更新,因此比useEffect慢。
这里的规则是首先使用useEffect。如果你的突变改变了 DOM 节点的外观,这可能导致它闪烁,你应该使用useLayoutEffect。
useInsertionEffect
插入效果钩子与效果钩子类似,但它会在任何布局效果触发之前触发。这个钩子仅适用于 CSS-in-JS 库的作者,所以你很可能不需要它。
示例代码
本章的示例代码可以在Chapter09/Chapter09_1文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
总结
在这一章中,我们学习了 React 19.1 版本提供的所有钩子。我们首先概述了内置钩子,然后学习了各种实用钩子。接下来,我们转向学习用于性能优化的钩子。最后,我们学习了高级效果钩子。
我们现在对所有不同的内置 Hooks 有了概述。
在下一章,我们将学习关于使用 React 社区开发的各类 Hooks,以及如何找到更多这样的 Hooks。
问题
为了回顾本章所学内容,尝试回答以下问题:
-
Ref Hook 的不同用例有哪些?
-
Imperative Handle Hook 增加了哪些功能?
-
我们应该在什么时候使用 Id Hook?
-
Sync External Store Hook 覆盖了哪些用例?
-
我们如何使用 Debug Value Hook?
-
使用 Deferred Value Hook 给我们带来了哪些优势?
-
我们应该在什么时候使用 Memo 和 Callback Hooks?
-
在大多数情况下,是否仍然有必要使用 Memo 和 Callback Hooks?
进一步阅读
如果你对本章所学概念有更多信息的兴趣,请查看以下链接:
-
React 文档中的内置 React Hooks 部分:
react.dev/reference/react/hooks -
关于 React 编译器的更多信息:
react.dev/learn/react-compiler
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈,向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第十章:使用社区钩子
在上一章中,我们学习了各种内置的 React 钩子。
在本章中,我们将学习社区提供的各种钩子。首先,我们将学习如何使用钩子管理应用程序状态。然后,我们将使用钩子实现防抖。接下来,我们将学习有关各种实用钩子的内容。最后,我们将学习在哪里可以找到更多社区钩子。
本章将涵盖以下主题:
-
使用钩子管理应用程序状态
-
使用钩子进行防抖
-
了解各种实用钩子
-
寻找更多社区钩子
技术要求
应已安装相当新的 Node.js 版本。Node 包管理器 (npm) 也需要安装(它应该与 Node.js 一起提供)。有关如何安装 Node.js 的更多信息,请访问他们的官方网站:nodejs.org/
我们将在本书的指南中使用 Visual Studio Code (VS Code),但在任何其他编辑器中一切都应该类似。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
上列出的版本是本书中使用的版本。虽然安装较新版本不应有问题,但请注意,某些步骤在较新版本上可能有所不同。如果您在本书中遇到代码和步骤问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter10
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
使用钩子管理应用程序状态
在本节中,我们将学习各种社区钩子,这些钩子可以帮助您管理应用程序状态。这些钩子由 useHooks.com 提供,这是一个包含各种有用钩子的单一库。
useLocalStorage
本地存储钩子允许您使用浏览器的LocalStorage API存储和检索数据。LocalStorage API 是在用户浏览器中持久存储信息的一种方式。我们可以用它来存储有关当前登录用户的信息。
useLocalStorage 函数具有以下签名:
const [data, saveData] = useLocalStorage(key, initialValue)
如我们所见,本地存储钩子接受一个键(用于在本地存储中标识数据)和一个初始值(当本地存储中没有给定键的项时用作回退)。然后它返回一个类似于状态钩子的 API:数据本身,以及一个用于更新本地存储中数据的函数。
在我们的案例中,我们只是简单地将用户名存储在本地存储中。
在实际应用中,你应该存储一个令牌,例如JSON Web Token(JWT),并且理想情况下将其存储在 Cookie 中而不是本地存储。然而,这需要服务器和一些全栈知识。要了解更多关于使用 React 的全栈项目,包括现实世界的身份验证,请参阅我的书籍:现代全栈 React 项目。
按照以下步骤开始将用户名存储在本地存储中:
-
通过执行以下命令将
Chapter09_1文件夹复制到新的Chapter10_1文件夹:$ cp -R Chapter09_1 Chapter10_1 -
在 VS Code 中打开新的
Chapter10_1文件夹。 -
按照以下步骤安装
useHooks库:$ npm install --save-exact @uidotdev/usehooks@2.4.1 -
删除
src/contexts/UserContext.js文件。我们现在将用本地存储替换UserContext。 -
编辑
src/App.jsx并删除以下导入:import { useState } from 'react' import { UserContext } from './contexts/UserContext.js' -
相反,替换它们为以下导入:
import { useLocalStorage } from '@uidotdev/usehooks' -
删除以下状态钩子:
export function App() { **const** **[username, setUsername] =** **useState****(****''****)** -
替换它为本地存储钩子:
export function App() { **const** **[username] =** **useLocalStorage****(****'username'****,** **null****)** -
删除
UserContext通过删除以下高亮行:return ( <QueryClientProvider client={queryClient}> **<****UserContext.Provider****value****=****{[username,****setUsername****]}>** … **</****UserContext.Provider****>** </QueryClientProvider> ) } -
编辑
src/components/user/UserBar.jsx并删除以下导入:import { useContext } from 'react' import { UserContext } from '@/contexts/UserContext.js' -
替换它们为对
useLocalStorage函数的导入:import { useLocalStorage } from '@uidotdev/usehooks' -
然后,按照以下方式将上下文钩子替换为本地存储钩子:
export function UserBar() { const [username] = **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/user/Register.jsx并将所有导入替换为以下内容:import { useState } from 'react' import { useLocalStorage } from '@uidotdev/usehooks' -
现在,替换上下文钩子为本地存储钩子:
export function Register() { const [, setUsername] = **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/user/Login.jsx并将所有导入替换为以下内容:import { useLocalStorage } from '@uidotdev/usehooks' -
接下来,替换上下文钩子为本地存储钩子:
export function Login() { const [, setUsername] = **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/user/Logout.jsx并将所有导入替换为以下内容:import { useState, useEffect } from 'react' import { useLocalStorage } from '@uidotdev/usehooks' -
替换上下文钩子为本地存储钩子:
export function Logout() { **const** **[username, setUsername] =** **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/post/CreatePost.jsx并删除useContext导入:import { **useContext**, useActionState } from 'react' -
然后,删除以下导入:
import { UserContext } from '@/contexts/UserContext.js' -
替换它为
useLocalStorage的导入:import { useLocalStorage } from '@uidotdev/usehooks' -
替换上下文钩子为本地存储钩子:
export function CreatePost() { const [username] = **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/comment/CreateComment.jsx并将所有导入替换为以下内容:import { useLocalStorage } from '@uidotdev/usehooks' -
然后,替换上下文钩子为本地存储钩子:
export function CreateComment({ addComment }) { const [username] = **useLocalStorage****(****'username'****,** **null****)** -
编辑
src/components/comment/CommentList.jsx并删除useContext导入:import { useContext, useState, useOptimistic } from 'react' -
删除以下导入:
import { **UserContext** } from '@/contexts/UserContext.js' -
替换它为
useLocalStorage的导入:import { useLocalStorage } from '@uidotdev/usehooks' -
替换上下文钩子为本地存储钩子:
export function CommentList() { const [username] = **useLocalStorage****(****'username'****,** **null****)** -
现在,按照以下步骤运行博客应用:
$ npm run dev
你会发现注册、登录和注销仍然像以前一样工作,但现在有一个额外的优势:当刷新页面时,用户会保持登录状态,直到他们按下注销按钮!
正如你所见,本地存储钩子是持久化存储浏览器中信息的一种极好方式!
useHistoryState
历史状态 Hook 是状态 Hook 的扩展版本,增加了对状态撤销/重做更改的功能。它具有以下签名:
const { state, set, undo, redo, clear, canUndo, canRedo } = useHistoryState(initialState)
我们向其提供一个初始状态,并返回当前的状态,一个用于设置状态的函数,一个用于撤销状态更改的撤销函数,一个用于重做更改的重做函数,一个用于将状态重置到初始状态的清除函数,以及canUndo和canRedo标志来告知是否可以撤销/重做状态。
理解这个 Hook 的最好方法是亲自尝试,所以让我们开始为我们的CreatePost组件实现撤销/重做功能:
-
编辑
src/components/post/CreatePost.jsx并导入useHistoryState函数:import { useLocalStorage, **useHistoryState** } from '@uidotdev/usehooks' -
为帖子内容定义一个历史状态 Hook,如下所示:
export function CreatePost() { const [username] = useLocalStorage('username', null) const navigate = useNavigate() **const** **{ state, set, undo, redo, clear, canUndo, canRedo } =** **useHistoryState****(****''****)** -
定义一个处理函数,当用户更改内容时使用:
function handleContentChange(e) { const { value } = e.target set(value) } -
定义撤销/重做和清除内容的按钮:
<div> <label htmlFor='create-title'>Title:</label> <input type='text' name='title' id='create-title' /> </div> **<****div****>** **<****button****type****=****'****button'****disabled****=****{!canUndo}****onClick****=****{undo}****>** **Undo** **</****button****>** **<****button****type****=****'****button'****disabled****=****{!canRedo}****onClick****=****{redo}****>** **Redo** **</****button****>** **<****button****type****=****'****button'****onClick****=****{clear}****>** **Clear** **</****button****>** **</****div****>**
在这里为所有按钮添加type='button'属性非常重要。否则,按下这些按钮将提交表单。
-
通过提供
value和onChange处理函数将<textarea>变为受控元素:<textarea name='content' **value****=****{state}** **onChange****=****{****handleContentChange****} />** -
最后,在创建帖子成功后,在 Action State Hook 中调用
清除函数:const [error, submitAction, isPending] = useActionState( async (currentState, formData) => { const title = formData.get('title') const content = formData.get('content') const post = { title, content, author: username, featured: false } try { const result = await createPostMutation.mutateAsync(post) **clear****()** navigate(`/post/${result.id}`) } catch (err) { return err } }, )
由于我们现在处理的是一个受控元素,我们需要自己清除其内容。在表单提交时不再自动执行。
-
按照以下步骤启动博客应用:
$ npm run dev
你会看到现在有三个新的按钮,如下面的截图所示:

图 10.1 – 在创建帖子时提供撤销/重做/清除按钮
尝试在字段中输入一些文本,你将能够撤销/重做对该字段所做的更改!然而,你可能已经注意到,每次只能撤销/重做单个字符。接下来,我们将实现防抖,这意味着我们的更改将在一定时间后添加到撤销/重做历史中,而不是在输入每个字符后。
示例代码
本节的示例代码可以在Chapter10/Chapter10_1文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
防抖与 Hooks
正如我们在上一节中看到的,当我们按下撤销时,它每次撤销一个字符。有时,我们不想将每次更改都存储在撤销历史中。为了避免存储每次更改,我们需要实现防抖,这意味着将content存储到历史状态的函数只有在一定时间内没有更改后才会被调用。
use-debounce库提供了一个防抖 Hook,可以像以下这样用于简单值:
const [text, setText] = useState('')
const [value] = useDebounce(text, 1000)
现在,如果我们通过setText更改文本,text值将立即更新,但value变量将在 1000 毫秒(1 秒)后更新。
然而,对于我们的用例,这还不够。我们需要防抖回调来实现与历史状态 Hook 结合的防抖。幸运的是,use-debounce 库还提供了防抖回调 Hook,可以按照以下方式使用:
const [text, setText] = useState('')
const [debouncedSet, cancelDebounce] = useDebouncedCallback(
(value) => setText(value),
1000
)
现在,如果我们调用 debouncedSet('text'),text 值将在 1000 毫秒(1 秒)后更新。如果多次调用 debouncedSet,每次都会重置超时,只有在 1000 毫秒内没有进一步调用 debouncedSet 函数后,setText 函数才会被调用。
在帖子编辑器中防抖更改
现在我们已经了解了防抖,我们将在帖子编辑器中将它与历史状态 Hook 结合起来实现。按照以下步骤开始:
-
通过执行以下命令将
Chapter10_1文件夹复制到新的Chapter10_2文件夹:$ cp -R Chapter10_1 Chapter10_2 -
在 VS Code 中打开新的
Chapter10_2文件夹。 -
按照以下方式安装
use-debounce库:$ npm install --save-exact use-debounce@10.0.4 -
编辑
src/components/post/CreatePost.jsx并导入useState、useEffect和useDebouncedCallback函数:import { useActionState, **useState, useEffect** } from 'react' **import** **{ useDebouncedCallback }** **from****'use-debounce'** -
定义一个新的状态 Hook,它将包含受控输入值:
const { state, set, undo, redo, clear, canUndo, canRedo } = useHistoryState('') **const** **[content, setContent] =** **useState****(****''****)** -
然后,定义一个防抖回调 Hook,它将在 200 毫秒后设置历史状态:
const debounced = useDebouncedCallback((value) => set(value), 200) -
接下来,我们必须定义一个 Effect Hook,它将在历史状态更改时触发,取消当前的防抖,并将受控输入值设置为历史状态 Hook 的当前值:
useEffect(() => { debounced.cancel() setContent(state) }, [state, debounced]) -
调整处理函数以触发
setContent函数来更新受控输入值,以及debounced函数来更新历史状态:function handleContentChange(e) { const { value } = e.target **setContent****(value)** **debounced****(value)** } -
最后,调整
textarea以使用content而不是state作为其值:<textarea name='content' value={**content**} onChange={handleContentChange} /> -
按照以下方式启动博客应用:
$ npm run dev
我们现在立即设置受控输入值,但还没有将其存储到历史状态中。在防抖回调触发(200 毫秒后),我们将当前值存储到历史状态中。每当历史状态更新时,例如,当我们按下 撤销/** 重做**按钮时,我们将取消当前的防抖以避免在撤销/重做后覆盖值。然后,我们将受控输入值设置为历史状态 Hook 的新值。
如果我们现在在我们的编辑器中输入一些文本,我们可以看到 撤销 按钮需要一段时间才会激活。如果我们现在按下 撤销 按钮,我们可以看到我们不会逐字符撤销,而是会一次性撤销更多文本。正如我们所看到的,撤销/重做与防抖结合得非常好!
防抖值与延迟值之间的区别
你可能记得,在 第九章 中,React 提供的高级 Hooks,我们使用了延迟值 Hook来等待新的搜索结果到来后再显示,这样我们就可以避免在等待新结果时显示加载界面。虽然我们也可以在那里使用防抖,但使用防抖在这个用例中也有一些缺点。
消抖和延迟值之间的主要区别在于,当消抖时,我们定义一个固定的时间间隔,在此之后值被更新。然而,延迟值会在每次更改后尝试更新(如果出现新的更改,则取消更新)。因此,延迟值不是限制在固定的时间间隔内,而是限制在请求可以处理的速度上。
示例代码
本节示例代码位于Chapter10/Chapter10_2文件夹中。请检查文件夹内的README.md文件,了解如何设置和运行示例。
了解各种实用钩子
我们现在将学习一些由 useHooks 库提供的有用实用钩子。
useCopyToClipboard
复制到剪贴板钩子使得在不同浏览器之间复制文本变得容易。如果可用,它使用现代的navigator.clipboard.writeText API。否则,它回退到传统的document.execCommand("copy")方法,确保该功能对旧版和新版浏览器都有效。此钩子也由www.useHooks.com提供。
useCopyToClipboard函数具有以下签名:
const [copiedText, copyToClipboard] = useCopyToClipboard()
它提供了一个与 State Hook 类似的 API,其中copyToClipboard函数接受一个字符串并将其复制到剪贴板,同时存储在copiedText值中。此值也可以用来检查我们是否成功将文本复制到剪贴板。
现在,让我们使用钩子来实现复制博客文章链接的方法:
-
通过执行以下命令将
Chapter10_2文件夹复制到新的Chapter10_3文件夹:$ cp -R Chapter10_2 Chapter10_3 -
在 VS Code 中打开新的
Chapter10_3文件夹。 -
创建一个新的
src/components/post/CopyLink.jsx文件。 -
在其中,导入
useCopyToClipboard函数:import { useCopyToClipboard } from '@uidotdev/usehooks' -
为复制链接按钮定义一个勾选和链接表情符号:
const CHECKMARK_EMOJI = <>✅</> const LINK_EMOJI = <>🔗</> -
然后,定义一个接受
url的组件:export function CopyLink({ url }) { -
在组件内部,定义复制到剪贴板钩子,如下所示:
const [copiedText, copyToClipboard] = useCopyToClipboard() -
现在,渲染一个触发
copyToClipboard函数的按钮:return ( <button type='button' onClick={() => copyToClipboard(url)}> -
如果链接已被复制,显示勾选符号,否则显示链接符号:
{copiedText ? CHECKMARK_EMOJI : LINK_EMOJI} </button> ) } -
编辑
src/components/post/Post.jsx并导入CopyLink组件:import { CopyLink } from './CopyLink.jsx' -
在博客文章标题旁边渲染该组件,并将当前 URL 传递给它:
<h3 style={{ color: theme.primaryColor }}> {title} **<****CopyLink** **url={****window****.****location****.****href****} />** </h3> -
按照以下方式启动博客应用:
$ npm run dev -
点击一个查看帖子 >链接进入单个帖子页面。您将看到一个带有链接表情符号的按钮:

图 10.2 – 在博客文章标题旁边显示“复制链接”按钮
- 点击此按钮后,它将显示勾选表情符号并将当前 URL 复制到您的剪贴板:

图 10.3 – 成功复制链接后按钮的状态
尝试将链接粘贴到某处,看看是否成功!
useHover
悬停 Hook跟踪用户是否悬停在元素上。它具有以下签名:
const [ref, hovering] = useHover()
如我们所见,它返回一个ref,我们需要将其传递给我们要跟踪悬停状态的元素。它还返回一个hovering状态,如果用户悬停在元素上,则为true,如果没有,则为false。此 Hook 也由useHooks.com提供。
现在我们使用悬停 Hook 在用户悬停在复制链接按钮上时显示提示:
-
编辑
src/components/post/CopyLink.jsx并导入useHover函数:import { useCopyToClipboard, **useHover** } from '@uidotdev/usehooks' -
然后,定义一个悬停 Hook:
export function CopyLink({ url }) { const [copiedText, copyToClipboard] = useCopyToClipboard() **const** **[ref, hovering] =** **useHover****()** -
创建一个 Fragment,以便我们可以在按钮旁边显示一条消息:
return ( **<>** -
将悬停 Hook 的
ref传递给按钮:<button **ref={ref}** type='button' onClick={() => copyToClipboard(url)}> -
如果我们悬停在按钮上,将显示如下的小信息文本:
{copiedText ? CHECKMARK_EMOJI : LINK_EMOJI} </button> **{hovering && (** **<****small****>** **<****i****>** **{copiedText ?** **'copied!'** **:** **'click to copy a link to the** **post'****}****</****i****>** **</****small****>** **)}** **</>** ) }在实际项目中,UI 中的大多数悬停效果都会使用 CSS 完成。使用悬停 Hook 的一个实际示例是在悬停时向分析 API 发送事件。然而,这会比显示悬停文本的示例长得多。
现在尝试悬停在复制链接按钮上,您将看到信息文本:

图 10.4 – 鼠标悬停在按钮上时显示信息文本
示例代码
本节的示例代码可以在Chapter10/Chapter10_3文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
查找更多社区 Hooks
我们已经了解了由useHooks.com提供的 Hooks 集合。然而,社区还提供了许多其他的 Hooks。您可以在以下页面上找到各种 Hooks 的可搜索列表:nikgraf.github.io/react-hooks/。
为了让您了解还有哪些其他 Hooks,以下功能是由社区 Hooks 提供的。我们现在列出社区提供的几个更有趣的 Hooks。
-
use-events(github.com/sandiiarov/use-events):已转换为 Hooks 的各种 JavaScript 事件,例如鼠标位置、触摸事件、点击外部等。 -
react-use(github.com/streamich/react-use):处理传感器(useBattery、useIdle、useGeolocation等)、UI(useAudio、useCss、useFullscreen等)、动画(useSpring、useTween、useRaf等)和副作用(useAsync、useDebounce、useFavicon等)的各种 Hooks。
当然,GitHub 和 npm 上还有更多 Hooks 可以找到。
摘要
在本章中,我们首先学习了如何通过 LocalStorage API 和 Local Storage Hook 在浏览器中持久化存储数据。然后,我们使用 History State Hook 在CreatePost组件中实现了撤销/重做功能。接下来,我们学习了防抖并使用 Debounced Callback Hook 实现了它。然后,我们学习了关于一些实用 Hooks,用于复制到剪贴板和处理悬停状态。最后,我们学习了在哪里可以找到更多的社区 Hooks。
在下一章中,我们将学习 Hooks 的规则,这将教会我们在开发自己的自定义 Hooks 之前需要了解的基本知识。
问题
为了回顾我们在本章中学到的内容,尝试回答以下问题:
-
我们可以使用哪个 Hook 在浏览器中持久化存储信息?
-
我们可以使用哪个 Hook 来实现撤销/重做功能?
-
什么是防抖?为什么我们需要这样做?
-
我们可以使用哪个 Hook 进行防抖?
-
防抖值与延迟值有何不同?
-
我们在哪里可以找到更多的 Hooks?
进一步阅读
如果您对我们在本章中学到的概念感兴趣,请查看以下书籍和链接:
-
《现代全栈 React 项目》 by Daniel Bugl
-
useHooks 网站:
usehooks.com -
use-debounce 库文档:
github.com/xnimorz/use-debounce -
React Hooks 集合:
nikgraf.github.io/react-hooks/
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第三部分
重构和迁移现有代码
在本书的最后一部分,你将首先了解 Hooks 的规则,这将是创建你自己的 Hooks 的基础。然后,你将深入了解如何重构我们现有的博客应用代码,使其使用自定义 Hooks,在合理的地方这样做。最后,你将把一个现有项目从 React 类组件迁移到 React Hooks,并学习这两种解决方案如何比较。
本部分包含以下章节:
-
第十一章, Hooks 规则
-
第十二章, 构建你自己的 Hooks
-
第十三章, 从 React 类组件迁移
第十一章:Hooks 的规则
在上一章中,我们学习了使用 React 社区开发的各种 Hooks,以及如何找到更多这样的 Hooks。
在本章中,我们将学习使用 Hooks 时需要了解的所有内容以及需要注意的事项。我们还将学习我们需要了解的内容以开始开发自己的 Hooks。Hooks 在定义的位置和顺序方面有一定的限制。违反 Hooks 的规则可能会导致错误或意外行为,因此我们需要确保我们学习和执行这些规则。
本章将涵盖以下主题:
-
使用 Hooks
-
Hooks 的顺序
-
Hooks 的名称
-
执行 Hooks 的规则
使用 Hooks
Hooks 只能在以下位置使用:
-
React 函数组件
-
自定义 Hooks(我们将在下一章学习如何创建自定义 Hooks)
Hooks 不能使用:
-
在条件或循环内部
-
在条件
return语句之后 -
在事件处理程序中
-
在类组件中
-
在传递给 Memo、Reducer 或 Effect Hooks 的函数内部
-
在
try/catch/finally块内部
在某些地方,比如 React 文档中,使用 Hook 有时被称为 调用 Hook。
Hooks 是普通的 JavaScript 函数,除了 React 依赖于它们在函数组件内部被调用。当然,使用其他 Hooks 的自定义 Hooks 可以在 React 函数组件之外 定义,但在 使用 这些自定义 Hooks 时,我们始终需要确保我们在 React 组件内部调用它们。
接下来,我们将学习关于 Hooks 顺序的规则。
Hooks 的顺序
只在 顶层(非嵌套),最好在函数组件或自定义 Hooks 的开头 使用 Hooks:
function ExampleComponent() {
const [name, setName] = useState('')
// …
}
function useCustomHook() {
const [name, setName] = useState('')
return { name, setName }
}
不要在条件、循环或嵌套函数中使用 Hooks——这样做会改变 Hooks 的顺序,从而导致错误。我们已经了解到改变 Hooks 的顺序会导致多个 Hooks 之间的状态混乱。
回顾,在 第二章 使用 State Hook 的示例 2 中,我们学习了我们无法做以下操作:
const [enableFirstName, setEnableFirstName] = useState(false)
const [name, setName] = **enableFirstName**
**?** **useState****(****''****)**
**: [****''****,** **() =>** **{}]**
const [lastName, setLastName] = useState('')
我们渲染了一个复选框和两个用于第一个名字和最后一个名字的输入字段,然后我们在最后一个名字字段中输入了一些内容,如图中所示:

图 11.1 – 回顾 第二章,使用 State Hook
目前,Hooks 的顺序如下:
-
enableFirstName -
lastName
然后,我们点击了复选框以启用第一个名字字段。这样做改变了 Hooks 的顺序,因为现在我们的 Hook 定义看起来像这样:
-
enableFirstName -
firstName -
lastName
由于 React 仅依赖于 Hooks 的顺序来管理其状态,因此 firstName 字段现在是第二个 Hook,所以它从 lastName 字段获取状态,正如你在这里看到的:

图 11.2 – 从第二章,使用状态钩子中改变钩子顺序的问题
如果我们在第二章使用状态钩子的示例 2 中使用 React 的真正useState钩子,我们可以看到 React 会自动检测钩子顺序是否已更改,并且会记录一条警告,如下所示:

图 11.3 – React 在检测到钩子顺序已更改时打印警告
当以开发模式运行 React 时,如果发生这种情况,它还会抛出错误并崩溃应用程序:

图 11.4 – React 在开发模式下抛出错误
如我们所见,改变钩子的顺序或条件性地启用钩子是不可能的,因为 React 内部使用钩子的顺序来跟踪哪些数据属于哪个钩子。
为了解决这个问题,我们总是像这样定义钩子:
const [enableFirstName, setEnableFirstName] = useState(false)
**const** **[name, setName] =** **useState****(****''****)**
const [lastName, setLastName] = useState('')
然后,我们条件性地渲染了名称而不是条件性地定义钩子:
My name is: **{enableFirstName ? name :** **''****}** {lastName}
修复后的版本可以在第 3 个示例中看到,来自第二章,使用状态钩子。
在了解了钩子的顺序之后,让我们继续学习钩子的命名约定。
钩子名称
当涉及到命名钩子时,有一个约定,即钩子函数应该始终以use前缀开头,后面跟着以大写字母开头的钩子名称。例如:useState、useEffect和useQuery。这很重要,因为否则我们不知道哪些是钩子函数,哪些不是。特别是在自动强制执行钩子规则时,我们需要能够知道哪些是钩子函数,以确保它们没有被条件性地调用或在循环中调用。
最佳实践是给钩子命名,使其在语义上合理。例如,如果你想要为输入字段创建一个自定义钩子,你可以将其命名为useInputField。这确保了在使用钩子时,可以立即清楚地知道该钩子有什么用。
如我们所见,命名约定使我们的生活变得更加容易:了解普通函数和钩子之间的区别,使得自动强制执行钩子规则变得非常容易。
在下一节中,我们将学习 ESLint 如何自动强制执行钩子规则。
强制执行钩子规则
如果我们坚持使用use前缀的钩子函数的约定,我们可以自动强制执行其他两个规则:
-
只在 React 函数组件中调用钩子
-
只在顶层调用钩子(不在循环、条件或嵌套函数内部)
为了自动执行规则,React 提供了一个名为 eslint-plugin-react-hooks 的 ESLint 插件,该插件会自动检测 Hooks 的使用情况,并确保规则不被违反。ESLint 是一个代码检查工具,它分析源代码并找出诸如风格错误、潜在错误和编程错误等问题。
幸运的是,Vite 已经为我们设置了包含相关 React 插件的 ESLint。你可能记得,在第二章**,使用 State Hook中,当我们添加条件 Hook 时,必须特别禁用代码检查器,通过添加以下行:
// eslint-disable-next-line react-hooks/rules-of-hooks
如果我们删除这一行,我们会得到以下代码检查器错误:

图 11.5 – ESLint 显示违反 Hooks 规则时的错误
因此,在这整个使用 Hooks 的过程中,我们的代码检查器已经确保我们没有错误地使用它们!要回顾我们如何设置 ESLint,请参阅第一章**,介绍 React 和 React Hooks。
摘要
在本章中,我们首先了解到我们应仅从 React 函数组件中调用 Hooks,并确保 Hooks 的顺序保持一致。此外,我们还学习了 Hooks 的命名约定,即它们应该始终以 use 前缀开头。然后,我们学习了 ESLint 如何通过强制执行 Hooks 规则来帮助我们。
了解 Hooks 的规则并强制执行它们对于避免错误和意外行为非常重要。这些规则在创建我们自己的 Hooks 时尤其重要。
现在我们已经很好地掌握了 Hooks 的工作原理以及规则和约定,在下一章中,我们将学习如何创建我们自己的 Hooks!
问题
为了回顾本章我们学到的内容,尝试回答以下问题:
-
Hooks 可以在哪里被调用?
-
我们能否在 React 类组件中使用 Hooks?
-
我们需要注意 Hooks 的顺序问题有哪些?
-
我们能否在条件、循环或嵌套函数内部调用 Hooks?
-
Hooks 的命名约定是什么?
-
Hooks 的规则是如何自动执行的?
进一步阅读
如果你对本章我们学到的概念有更多兴趣,请查看以下链接:
- 官方 React 文档中 Hooks 的规则:
react.dev/reference/rules/rules-of-hooks
在 Discord 上了解更多
要加入本书的 Discord 社区 – 在那里你可以分享反馈、向作者提问,并了解新版本 – 请扫描下面的二维码:
packt.link/wnXT0

第十二章:构建您自己的钩子
在上一章中,我们学习了钩子的限制和规则。我们还学习了在哪里调用钩子,为什么顺序很重要,以及钩子的命名约定。
在本章中,我们将学习如何通过从我们的组件中提取现有代码来创建自定义钩子。我们还将学习如何使用自定义钩子以及钩子如何相互交互。最后,我们将学习如何为我们的自定义钩子编写测试。
在本章结束时,您将能够创建自定义钩子来封装和重用应用程序逻辑,使您的代码保持整洁和可维护。
本章将涵盖以下主题:
-
创建自定义主题钩子
-
创建自定义用户钩子
-
创建自定义 API 钩子
-
创建一个防抖历史状态钩子
-
测试自定义钩子
技术要求
应该已经安装了一个相当新的 Node.js 版本。还需要安装 Node 包管理器(npm)(它应该与 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请访问他们的官方网站:nodejs.org/
我们将在本书的指南中使用Visual Studio Code(VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
前面列表中提到的版本是书中使用的版本。虽然安装较新版本通常不会有问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter12
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
创建自定义主题钩子
通过学习内置的 React 钩子、社区钩子以及钩子的规则,我们对钩子的概念有了很好的掌握后,现在我们将构建我们自己的钩子。
在第五章**实现 React 上下文中,我们引入了ThemeContext来为我们的应用中的博客文章设置样式。我们使用 Context 钩子在许多组件中访问ThemeContext。通常,跨多个组件使用的功能是创建自定义钩子的好机会。正如您可能已经注意到的,我们经常做以下事情:
import { ThemeContext } from '@/contexts/ThemeContext.js'
export default function SomeComponent () {
const theme = useContext(ThemeContext)
// …
我们可以将此功能抽象成一个 useTheme 钩子,该钩子将从 ThemeContext 获取 theme 对象。
通常,首先编写组件,然后如果我们注意到我们在多个组件中使用了相似的代码,再从中提取自定义钩子,这样做最有意义。这样做可以避免过早创建自定义钩子,使我们的项目变得不必要地复杂。
现在,让我们开始创建自定义主题钩子。
创建自定义主题钩子
让我们现在开始创建自定义主题钩子,通过将现有的上下文钩子代码提取到一个单独的函数中:
-
通过执行以下命令将
Chapter10_3文件夹复制到一个新的Chapter12_1文件夹:$ cp -R Chapter10_3 Chapter12_1 -
在 VS Code 中打开新的
Chapter12_1文件夹。 -
创建一个新的
src/hooks/文件夹。 -
在其中,创建一个新的
src/hooks/theme.js文件。 -
在这个新创建的文件中,导入
useContext函数和ThemeContext:import { useContext } from 'react' import { ThemeContext } from '@/contexts/ThemeContext.js' -
现在,定义并导出一个
useTheme函数,它简单地返回上下文钩子:export function useTheme() { return useContext(ThemeContext) }
如此简单,只要我们坚持钩子和命名约定规则,我们就可以轻松创建我们自己的自定义钩子!让我们继续在博客应用中使用我们的自定义主题钩子。
使用自定义主题钩子
要开始使用我们的自定义主题钩子:
-
编辑
src/components/post/Post.jsx并 移除 以下导入:import { useContext } from 'react' import { ThemeContext } from '@/contexts/ThemeContext.js'
替换 为 useTheme 函数的导入:
import { useTheme } from '@/hooks/theme.js'
-
替换 现有的上下文钩子为我们的自定义主题钩子:
export function Post({ id }) { const theme = **useTheme****()** -
编辑
src/components/post/PostListItem.jsx并 移除 以下导入:import { useContext } from 'react' import { ThemeContext } from '@/contexts/ThemeContext.js'
替换 为 useTheme 函数的导入:
import { useTheme } from '@/hooks/theme.js'
-
用我们的主题钩子替换上下文钩子:
export function PostListItem({ id, title, author }) { const theme = **useTheme****()** -
按照以下方式运行
dev服务器:$ npm run dev
您将看到主题仍然以相同的方式工作,以不同的颜色显示特色帖子。
如我们所见,用我们的主题钩子替换上下文钩子可以使代码稍微简化(需要更少的导入)并允许我们稍后轻松调整主题系统。例如,如果我们想从用户设置中获取默认主题而不是从上下文中获取,我们可以在主题钩子中实现此功能,所有组件将自动使用这个新的主题系统。
示例代码
本节示例代码可在 Chapter12/Chapter12_1 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
现在我们已经成功创建了一个自定义主题钩子,让我们继续创建自定义用户钩子。
创建自定义用户钩子
在 第五章,实现 React 上下文,我们定义了一个 UserContext 来存储当前登录用户的用户名。在 第十章,使用社区钩子,我们用本地存储钩子替换了 UserContext。如您所记得,从上下文钩子重构到本地存储钩子需要我们调整许多组件中的代码。
为了避免未来出现此类问题,我们可以将所有用户相关信息和函数放入一个 User Hook 中,然后将其暴露给其他组件使用。
创建自定义 User Hook
让我们先提取所有与处理用户名相关的现有代码到一个自定义 User Hook 中:
-
通过执行以下命令将
Chapter12_1文件夹复制到新的Chapter12_2文件夹:$ cp -R Chapter12_1 Chapter12_2 -
在 VS Code 中打开新的
Chapter12_2文件夹。 -
创建一个新的
src/hooks/user.js文件。 -
在其中,导入
useLocalStorage函数:import { useLocalStorage } from '@uidotdev/usehooks' -
定义一个新的
useUser函数,在其中我们使用 Local Storage Hook:export function useUser() { const [username, setUsername] = useLocalStorage('username', null) -
此外,我们定义一个标志来告诉用户是否已登录:
const isLoggedIn = username !== null -
现在,定义
register、login和logout函数:function register(username) { setUsername(username) } function login(username) { setUsername(username) } function logout() { setUsername(null) } -
返回
username、isLoggedIn标志和函数:return { username, isLoggedIn, register, login, logout } }
如您所见,我们不仅返回用户名和设置用户名的函数,而是返回一个包含有关用户会话的各种信息以及我们可以调用的调整用户状态的函数的对象。现在我们已经将此功能抽象为 User Hook,我们可以轻松地扩展它以支持完整的身份验证(而不仅仅是存储用户名)。
我们的 User Hook 成功创建后,让我们在应用程序中使用它。
使用自定义 User Hook
现在让我们重构我们的博客应用程序以使用 User Hook 而不是直接从 Local Storage Hook 读取和写入:
-
编辑
src/App.jsx并移除以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
用对useUser函数的导入来替换它:
import { useUser } from './hooks/user.js'
-
将 Local Storage Hook 替换为我们的自定义 User Hook,如下所示:
export function App() { **const** **{ isLoggedIn } =** **useUser****()** -
用对
isLoggedIn标志的检查替换username检查:{**isLoggedIn** && <CreatePost />}
使用 Hook 中的isLoggedIn标志可以使代码更容易阅读——之前可能不清楚为什么我们要检查用户名,但现在很清楚我们只想在用户登录时渲染这个组件。这样做的好处是,我们可以通过调整 User Hook 来更改以后检查用户是否登录的逻辑。
-
现在编辑
src/components/user/UserBar.jsx并移除以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
用对useUser函数的导入来替换它:
import { useUser } from '@/hooks/user.js'
-
将 Local Storage Hook 替换为我们的自定义 User Hook,如下所示:
export function UserBar() { **const** **{ isLoggedIn } =** **useUser****()** -
将
username检查替换为对isLoggedIn标志的检查:if (**isLoggedIn**) { -
接下来编辑
src/components/user/Register.jsx并移除以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
用对useUser函数的导入来替换它:
import { useUser } from '@/hooks/user.js'
-
将 Local Storage Hook 替换为我们的自定义 User Hook,如下所示:
export function Register() { **const** **{ register } =** **useUser****()** -
在
handleSubmit函数中,用我们的新register函数替换setUsername函数:const username = e.target.elements.username.value **register**(username) }
同样,我们通过调用一个解释我们实际想要做什么的函数来使我们的代码更容易阅读(注册新用户)。之前,我们只是在这里调用setUsername。稍后,我们可能希望真正将其连接到数据库,因此 User Hook 中的register函数将使我们更容易稍后添加此功能。
-
编辑
src/components/user/Login.jsx并 移除 以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
替换 它为对 useUser 函数的导入:
import { useUser } from '@/hooks/user.js'
-
替换 本地存储钩子为我们的自定义用户钩子,如下所示:
export function Login() { **const** **{ login } =** **useUser****()** -
在
handleSubmit函数中,替换setUsername函数为我们的新login函数:const username = e.target.elements.username.value **login**(username) } -
编辑
src/components/user/Logout.jsx并 移除 以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
替换 它为对 useUser 函数的导入:
import { useUser } from '@/hooks/user.js'
-
替换 本地存储钩子为我们的自定义用户钩子,如下所示:
export function Logout() { **const** **{ username, logout } =** **useUser****()** -
在
handleSubmit函数中,替换setUsername函数为我们的新logout函数:function handleSubmit(e) { e.preventDefault() **logout****()** } -
编辑
src/components/post/CreatePost.jsx并 移除 对useLocalStorage函数的以下导入:import { **useLocalStorage,** useHistoryState } from '@uidotdev/usehooks' -
添加对
useUser函数的导入,如下所示:import { useUser } from '@/hooks/user.js' -
替换 本地存储钩子为我们的自定义用户钩子,如下所示:
export function CreatePost() { **const** **{ username } =** **useUser****()**
能够从用户钩子中访问 username 将组件与内部逻辑解耦。例如,我们可能稍后会在本地存储中存储整个用户对象或身份验证令牌。如果我们每个组件都使用本地存储钩子,我们就需要调整使用它的每个组件。现在,我们只需简单地调整用户钩子,只要我们仍然从它返回 username,我们就不需要更改任何组件。
-
编辑
src/components/comment/CreateComment.jsx并 移除 以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
替换 它为对 useUser 函数的导入:
import { useUser } from '@/hooks/user.js'
-
替换 本地存储钩子为我们的自定义用户钩子,如下所示:
export function CreateComment({ addComment }) { **const** **{ username } =** **useUser****()** -
编辑
src/components/comment/CommentList.jsx并 移除 以下导入:import { useLocalStorage } from '@uidotdev/usehooks'
替换 它为对 useUser 函数的导入:
import { useUser } from '@/hooks/user.js'
-
替换 本地存储钩子为我们的自定义用户钩子,如下所示:
export function CommentList() { **const** **{ isLoggedIn } =** **useUser****()** -
替换 对
username的检查为对isLoggedIn标志的检查:{**isLoggedIn** && <CreateComment addComment={addComment} />}
如我们所见,使用用户钩子重构的代码已经显著易于阅读。我们不再对 username 进行检查,而是检查 isLoggedIn 标志。此外,我们调用 login、register 和 logout 函数,抽象实现细节,使组件能够专注于其功能。这样做将应用程序逻辑的关注点分离到自定义钩子中,而组件则专注于用户交互。
我们现在可以启动开发服务器,如下所示:
$ npm run dev
你会看到我们博客的所有功能仍然与之前一样工作。
示例代码
本节示例代码可在 Chapter12/Chapter12_2 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
现在我们已经完成了自定义用户钩子的创建,让我们继续创建用于 API 调用的自定义钩子。
创建自定义 API 钩子
我们还可以为各种 API 调用创建钩子。将这些钩子放在一个文件中,使我们能够轻松地调整 API 调用。我们将使用 useAPI 作为自定义 API 钩子的前缀,以便容易区分哪些函数是 API 钩子。
提取自定义 API 钩子
让我们按照以下步骤创建我们的 API 自定义钩子:
-
通过执行以下命令将
Chapter12_2文件夹复制到新的Chapter12_3文件夹:$ cp -R Chapter12_2 Chapter12_3 -
在 VS Code 中打开新的
Chapter12_3文件夹。 -
创建一个新的
src/hooks/api.js文件。 -
编辑
src/hooks/api.js并导入以下函数:import { useSuspenseQuery, useMutation } from '@tanstack/react-query' import { fetchPosts, fetchPost, searchPosts, createPost, queryClient, } from '@/api.js' -
定义一个函数来获取帖子,从我们在
src/components/post/PostFeed.jsx中的代码复制过来:export function useAPIFetchPosts({ featured }) { const { data } = useSuspenseQuery({ queryKey: ['posts', featured], queryFn: async () => await fetchPosts({ featured }), }) return data } -
定义一个函数来获取单个帖子,从我们在
src/components/post/Post.jsx中的代码复制过来:export function useAPIFetchPost({ id }) { const { data } = useSuspenseQuery({ queryKey: ['post', id], queryFn: async () => await fetchPost({ id }), }) return data } -
定义一个函数来搜索帖子,从我们在
src/components/post/PostSearchResults.jsx中的代码复制过来:export function useAPISearchPosts({ query }) { const { data } = useSuspenseQuery({ queryKey: ['posts', query], queryFn: async () => await searchPosts(query), }) return data } -
定义一个函数来创建帖子,从我们在
src/components/post/CreatePost.jsx中的代码复制过来:export function useAPICreatePost() { const createPostMutation = useMutation({ mutationFn: createPost, onSuccess: () => { queryClient.invalidateQueries(['posts']) }, }) return createPostMutation.mutateAsync }
与用户钩子类似,API 钩子抽象了实现细节,只暴露必要的信息,例如 data 或 mutateAsync 函数。这意味着我们甚至可以在以后简单地通过调整自定义 API 钩子来替换 React Query 为不同的库。
我们现在可以将我们的博客应用程序重构为使用自定义 API 钩子。
使用自定义 API 钩子
按照以下步骤重构应用程序以使用先前定义的 API 钩子:
-
编辑
src/components/post/PostFeed.jsx并 删除 以下导入:import { useSuspenseQuery } from '@tanstack/react-query' import { fetchPosts } from '@/api.js'
替换 它们为 useAPIFetchPosts 的导入:
import { useAPIFetchPosts } from '@/hooks/api.js'
-
替换 悬挂查询钩子为我们的 API 获取帖子钩子:
export function PostFeed({ featured = false }) { **const** **posts =** **useAPIFetchPosts****({ featured })** return <PostList posts={**posts**} /> }
而不是在 如何 从 API 获取帖子实现细节上,我们现在只提供与组件相关的信息(帖子是否为特色帖子)。其余的由自定义 API 钩子内部处理,并且可以在以后更改。
-
编辑
src/components/post/Post.jsx并 删除 以下导入:import { useSuspenseQuery } from '@tanstack/react-query' import { fetchPost } from '@/api.js'
替换 它们为 useAPIFetchPost 的导入:
import { useAPIFetchPost } from '@/hooks/api.js'
-
替换 悬挂查询钩子为我们的 API 获取帖子钩子:
export function Post({ id }) { const theme = useTheme() **const** **{ title, content, author } =** **useAPIFetchPost****({ id })** return ( -
编辑
src/components/post/PostSearchResults.jsx并 删除 以下导入:import { useSuspenseQuery } from '@tanstack/react-query' import { searchPosts } from '@/api.js'
替换 它们为 useAPISearchPosts 的导入:
import { useAPISearchPosts } from '@/hooks/api.js'
-
替换 悬挂查询钩子为我们的 API 获取帖子钩子:
export function PostSearchResults({ query }) { **const** **posts =** **useAPISearchPosts****({ query })** return <PostList posts={**posts**} /> } -
编辑
src/components/post/CreatePost.jsx并 删除 以下导入:import { useMutation } from '@tanstack/react-query' import { createPost, queryClient } from '@/api.js'
替换 它们为 useAPICreatePost 的导入:
import { useAPICreatePost } from '@/hooks/api.js'
-
删除 现有的突变钩子:
const createPostMutation = useMutation({ mutationFn: createPost, onSuccess: () => { queryClient.invalidateQueries(['posts']) }, })
替换 它为我们的 API 创建帖子钩子:
const createPost = useAPICreatePost()
-
我们现在可以直接调用
createPost函数,如下所示:const newPost = { title, content, author: username, featured: false } try { const result = await **createPost**(post) clear() navigate(`/post/${result.id}`) } catch (err) { return err } -
启动开发服务器,并确保一切仍然像以前一样工作:
$ npm run dev
再次强调,重构以使用自定义钩子使我们的组件更容易阅读,使我们能够专注于用户交互逻辑,而我们的自定义钩子则处理内部的应用逻辑。
示例代码
本节的示例代码可以在 Chapter12/Chapter12_3 文件夹中找到。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
在创建自定义 API 钩子之后,让我们继续创建一个防抖历史状态钩子。
创建一个防抖历史状态钩子
我们现在将创建一个稍微更高级的 Hook,用于防抖历史状态功能。在 第十章 使用社区 Hooks 中,我们学习了 History State Hook,它允许我们在 CreatePost 组件中实现撤销/重做功能。然后我们使用 Debounce Hook 来避免将每个单独的更改存储在历史记录中,这样我们可以一次性撤销/重做更大的文本部分,而不是逐个字符。现在,我们将这个组合功能提取到一个自定义的 Debounced History State Hook 中。
虽然这个功能目前只在一个组件中使用,但它是一个通用的功能,可以在其他组件中使用。此外,将这个功能抽象成一个单独的 Hook 可以让我们保持 CreatePost 组件代码的简洁和简洁。
创建 Debounced History State Hook
现在让我们开始从 CreatePost 组件中提取代码到 Debounced History State Hook:
-
通过执行以下命令将
Chapter12_3文件夹复制到新的Chapter12_4文件夹:$ cp -R Chapter12_3 Chapter12_4 -
在 VS Code 中打开新的
Chapter12_4文件夹。 -
创建一个新的
src/hooks/debouncedHistoryState.js文件。 -
在其中,导入以下内容:
import { useState, useEffect } from 'react' import { useDebouncedCallback } from 'use-debounce' import { useHistoryState } from '@uidotdev/usehooks' -
定义一个函数,该函数接受初始状态和防抖超时值:
export function useDebouncedHistoryState(initialState, timeout) { -
现在,定义 History State Hook:
const { state, set, undo, redo, clear, canUndo, canRedo } = useHistoryState(initialState) -
接下来,定义一个用于活动编辑内容的 State Hook:
const [content, setContent] = useState(initialState) -
然后,定义一个将设置 History State Hook 值的 Debounced Callback Hook:
const debounced = useDebouncedCallback((value) => set(value), timeout) -
在
CreatePost组件中添加我们之前使用的 Effect Hook:useEffect(() => { debounced.cancel() setContent(state) }, [state, debounced])
记住,这个 Effect Hook 用于将历史状态同步回正在编辑的 content 状态,这意味着每当触发 撤销、重做 或 清除 功能时,它都会更改文本框的内容。
-
现在,定义一个设置
content状态并启动防抖回调的处理函数:function handleContentChange(e) { const { value } = e.target setContent(value) debounced(value) } -
最后,从 Hook 返回所有需要的值和函数:
return { content, handleContentChange, undo, redo, clear, canUndo, canRedo } }
现在,我们有了 Debounced History State 功能性的直接替换品,我们现在在 CreatePost 组件中使用它,所以让我们开始吧!
使用 Debounced History State Hook
按照以下步骤重构 CreatePost 组件以使用 Debounced History State Hook:
-
编辑
src/components/post/CreatePost.jsx并 删除 以下突出显示的导入:import { useActionState**, useState, useEffect** } from 'react' **import** **{ useDebouncedCallback }** **from****'use-debounce'** **import** **{ useHistoryState }** **from****'@uidotdev/usehooks'** -
添加
useDebouncedHistoryState函数的导入:import { useDebouncedHistoryState } from '@/hooks/debouncedHistoryState.js' -
删除 以下所有代码:
const { state, set, undo, redo, clear, canUndo, canRedo } = useHistoryState('') const [content, setContent] = useState('') const debounced = useDebouncedCallback((value) => set(value), 200) useEffect(() => { debounced.cancel() setContent(state) }, [state, debounced])
替换 它为 Debounced History State Hook:
const { content, handleContentChange, undo, redo, clear, canUndo,
canRedo } =
useDebouncedHistoryState('', 200)
-
删除 以下处理函数:
function handleContentChange(e) { const { value } = e.target setContent(value) debounced(value) } -
启动开发服务器并确保创建帖子以及撤销/重做功能仍然正常工作:
$ npm run dev
那就是全部了——现在 CreatePost 组件的代码变得更加简洁!
示例代码
本节示例代码位于 Chapter12/Chapter12_4 文件夹中。请检查文件夹内的 README.md 文件,了解如何设置和运行示例。
在创建 Debounced History State Hook 之后,让我们继续学习如何测试自定义 Hooks。
测试自定义 Hooks
现在,我们的博客应用完全利用了 Hooks!我们甚至为各种功能定义了自定义 Hooks,使我们的代码更具可重用性、简洁性,并且易于阅读。
当创建自定义 Hook 时,为它们编写单元测试以确保它们正常工作也是有意义的,即使我们在以后更改它们或添加更多选项。我们将使用 Vitest 来编写我们的单元测试。Vitest 和 Vite 一起使用得非常好,因为 Vitest 可以读取和使用 Vite 配置。Vitest 还提供了与 Jest 兼容的 API。Jest 是另一个非常流行的测试框架。如果你已经熟悉 Jest,学习 Vitest 将会非常容易。此外,Vitest 非常快,非常适合现代 Web 应用。
然而,由于 Hooks 的规则,我们无法在测试函数中调用 Hooks,因为它们只能在功能 React 组件的主体内部调用。由于我们不希望为每个测试创建一个特定的组件,我们将使用 React 测试库直接测试 Hooks。这个库实际上创建了一个测试组件,并提供了一些实用函数来与 Hooks 交互。
在过去,有两个库:React 测试库和 React Hooks 测试库。然而,如今 React 测试库已经内置了对渲染和测试 Hooks 的支持,因此它是测试 React 组件和 Hooks 的完美选择!React Hooks 测试库现在已被弃用,所以我们只会使用 React 测试库。
在以下情况下,我们应该特别为 Hooks 编写测试:
-
当编写定义和导出 Hooks 的库时
-
当你有在多个组件中使用的 Hooks 时
-
当一个 Hook 复杂,因此难以在以后更改/重构时
当你有特定于一个组件的 Hooks 时,通常最好是直接测试该组件。然而,测试 React 组件超出了本书的范围。有关测试组件的更多信息可以在 React 测试库网站上找到:testing-library.com/docs/react-testing-library/intro/
现在,让我们开始设置 Vitest 和 React 测试库!
设置 Vitest 和 React 测试库
在我们可以开始为我们的 Hooks 编写测试之前,我们首先需要设置 Vitest 和 React 测试库:
-
通过执行以下命令将
Chapter12_4文件夹复制到新的Chapter12_5文件夹:$ cp -R Chapter12_4 Chapter12_5 -
在 VS Code 中打开新的
Chapter12_5文件夹。 -
通过执行以下命令安装 Vitest、React 测试库和 jsdom:
$ npm install --save-exact --save-dev vitest@3.0.5 @testing-library/react@16.2.0 jsdom@26.0.0
jsdom 为 Node.js 提供了一个访问 DOM 的环境。由于我们的测试实际上并没有在浏览器中运行,因此提供这样一个环境是必要的,以便能够渲染 React 组件并测试 Hooks。
-
编辑
package.json并添加一个用于运行 Vitest 的脚本:"scripts": { **"test"****:** **"vitest"****,** -
最后,编辑
vite.config.js并在文件末尾添加一个 Vitest 配置:rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, **test****: {** **environment****:** **'jsdom'****,** **},** })
现在我们已经成功设置了 Vitest,我们可以开始测试 Hooks 了!
测试一个简单的 Hook
首先,我们将测试一个非常简单的 Hook,它不使用上下文或异步代码,如超时。为此,我们将创建一个新的 Hook,称为 useCounter。然后,我们将测试 Hook 的各个部分。
创建 Counter Hook
Counter Hook 将提供一个当前的 count 以及用于 increment 和 reset 计数的函数。按照以下步骤创建它:
-
创建一个新的
src/hooks/counter.js文件。 -
在其中,导入
useState函数:import { useState } from 'react' -
然后,定义一个接受
initialCount作为参数的 Counter Hook:export function useCounter(initialCount = 0) { -
定义一个用于计数的 State Hook:
const [count, setCount] = useState(initialCount) -
现在,定义一个函数来将计数增加 1:
function increment() { setCount((count) => count + 1) } -
接下来,定义一个函数将计数重置为初始计数:
function reset() { setCount(initialCount) } -
返回当前计数和两个函数:
return { count, increment, reset } }
现在我们已经定义了一个简单的 Hook,我们可以开始编写我们的第一个测试。
为 Counter Hook 创建单元测试
现在,让我们按照以下步骤编写 Counter Hook 的单元测试:
-
创建一个新的
src/hooks/counter.test.js文件。 -
在其中,从 Vitest 导入
describe、test和expect函数,以及从 React Testing Library 导入的renderHook和act函数:import { describe, test, expect } from 'vitest' import { renderHook, act } from '@testing-library/react' -
此外,导入
useCounter函数,我们将为其编写测试:import { useCounter } from './counter.js' -
现在,我们可以开始定义测试。在 Vitest 中,我们可以使用
describe函数来定义一组测试。第一个参数是组名,第二个参数是一个用于配置测试的选项对象(我们将其留为空对象),第三个参数是一个函数,在其中我们可以定义我们的各种测试。在这里,我们为 Counter Hook 创建一组测试,所以让我们称它为:describe('Counter Hook', {}, () => { -
在组内,我们现在可以定义我们的测试。要定义一个测试,我们使用
test函数。第一个参数是测试的名称,第二个参数是测试选项,第三个参数是要执行的测试函数。在我们的第一个测试中,我们检查 Hook 默认返回 0:test('should return 0 by default', {}, () => { -
在这个测试中,我们使用
renderHook函数来模拟 Hook 在 React 组件中被渲染。它返回一个对象给我们,其中包含一个result:const { result } = renderHook(() => useCounter()) -
我们现在可以通过从
result.current对象中获取它来访问count,并检查它是否为 0:expect(result.current.count).toBe(0) })
使用 expect 函数可以对值进行测试。它的工作方式如下:expect(actualValue).toBe(expectedValue)。如果 actualValue 与 expectedValue 匹配,测试将成功。否则,它将失败。
在 expect 中可以使用许多种匹配器 —— toBe 只是其中之一!要查看匹配器的完整列表,请查看 Vitest API 文档:vitest.dev/api/expect.html
如果你之前使用过 Jest,你会注意到 Vitest API 与它是完全兼容的,所以所有这些函数对你来说都很熟悉。
-
接下来,让我们定义一个测试来检查
initialCount参数是否工作:test('should initially return initial count', {}, () => { const { result } = renderHook(() => useCounter(123)) expect(result.current.count).toBe(123) }) -
现在,我们定义一个测试来检查增量函数是否增加计数器:
test('should increment counter when increment() is called', {}, () => { const { result } = renderHook(() => useCounter(0)) -
我们可以使用
act函数从 Hook 中触发一个动作。这个函数告诉 React Testing Library 在 Hook 内部正在触发某些操作,导致renderHook函数的result.current值被更新:act(() => result.current.increment()) -
然后,我们可以检查新的计数是否为 1:
expect(result.current.count).toBe(1) }) -
接下来,让我们进行一个测试,模拟传递给 Counter Hook 的
initialCount通过 React 组件的属性更改而改变:test('should reset to initial value', {}, () => { -
要模拟一个 React 属性,我们只需定义一个变量,然后定义一个 Hook:
let initial = 0 const { result, rerender } = renderHook(() => useCounter(initial)) -
我们现在可以通过更改变量并使用从
renderHook返回的rerender函数手动触发 React 组件的重新渲染来更改属性:initial = 123 rerender()
如我们之前所学的,React Testing Library 创建了一个虚拟组件,用于测试 Hook。我们可以强制这个虚拟组件重新渲染来模拟在真实组件中属性更改时会发生的情况。
-
现在,我们调用
reset函数并检查计数是否已重置到新的初始计数:act(() => result.current.reset()) expect(result.current.count).toBe(123) }) }) -
通过执行以下命令来运行测试:
$ npm test记住,对于特殊的脚本,如
start和test,我们不需要执行npm run test;我们可以简单地执行npm test。
以下截图显示了执行npm test后的结果:

图 12.1 – 在监视模式下运行 Vitest
你会看到 Vitest 自动运行监视模式。这意味着它将等待文件更改并自动为你重新运行测试。你可以在整个章节的其余部分保持在该模式下运行,以查看你编写的测试执行情况。
测试 Theme Hook
使用 React Hooks Testing Library,我们还可以测试更复杂的 Hooks,例如那些使用上下文的 Hooks。要测试使用上下文的 Hooks,我们首先必须创建一个上下文包装器,然后我们可以测试这个 Hook。
现在,让我们开始编写 Theme Hook 的测试:
-
创建一个新的
src/hooks/theme.test.jsx文件。请注意,文件扩展名需要是.jsx,而不是.js,因为我们将在这个文件中使用 JSX。 -
在其中,从 Vitest 导入相关函数,包括
renderHook函数、ThemeContext和useTheme函数:import { describe, test, expect } from 'vitest' import { renderHook } from '@testing-library/react' import { ThemeContext } from '@/contexts/ThemeContext.js' import { useTheme } from './theme.js' -
现在,定义一个
ThemeContextWrapper组件,它将为测试设置上下文提供者:function ThemeContextWrapper({ children }) {
包装器接受children作为属性,这是 React 组件的一个特殊属性。它将包含包装器内部定义的所有其他组件,例如<ThemeContextWrapper>{children}</ThemeContextWrapper>。
-
在
wrapper组件内部,定义上下文提供者和primaryColor的值:return ( <ThemeContext.Provider value={{ primaryColor: 'deepskyblue' }}> {children} </ThemeContext.Provider> ) } -
现在,我们可以开始编写主题钩子的测试了。我们首先创建一个测试组:
describe('Theme Hook', {}, () => { -
在组内,我们定义一个测试,用于检查主颜色:
test('should return the primaryColor defined by the context', {}, () => { -
然后渲染钩子,将包装组件传递给
renderHook函数:const { result } = renderHook(() => useTheme(), { wrapper: ThemeContextWrapper, }) -
现在,检查主颜色是否与我们定义在包装组件中的一致:
expect(result.current.primaryColor).toBe('deepskyblue') }) }) -
如果你保持 Vitest 在监视模式下运行,你应该看到它成功执行了我们刚才编写的测试!如果不是,请通过执行以下命令重新启动 Vitest:
$ npm test
以下图像显示了 Vitest 在监视模式下自动执行我们新定义的测试:

图 12.2 – Vitest 自动执行我们新定义的测试
现在我们已经成功编写了主题钩子的测试,让我们继续进行稍微复杂一些的用户钩子。
测试用户钩子
用户钩子现在内部使用本地存储钩子。幸运的是,jsdom环境已经为我们处理了模拟 LocalStorage API,因此我们不需要为此进行任何设置。
现在让我们开始编写用户钩子的测试:
-
创建一个新的
src/hooks/user.test.js文件。 -
在其中,从 Vitest 导入相关函数,以及
renderHook、act和useUser函数:import { describe, test, expect } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useUser } from './user.js' -
然后,为用户钩子定义一个测试组:
describe('User Hook', {}, () => { -
对于我们的第一次测试,我们确保用户默认未登录:
test('should not be logged in by default', {}, () => { const { result } = renderHook(() => useUser()) expect(result.current.isLoggedIn).toBe(false) expect(result.current.username).toBe(null) }) -
然后,我们测试注册功能:
test('should be logged in after registering', {}, () => { const { result } = renderHook(() => useUser()) act(() => result.current.register('testuser')) expect(result.current.isLoggedIn).toBe(true) expect(result.current.username).toBe('testuser') }) -
接下来,我们测试登录功能:
test('should be logged in after logging in', {}, () => { const { result } = renderHook(() => useUser()) act(() => result.current.login('testuser')) expect(result.current.isLoggedIn).toBe(true) expect(result.current.username).toBe('testuser') }) -
对于最后的测试,我们执行两个操作,首先调用登录,然后登出,然后检查用户是否已登出:
test('should be logged out after logout', {}, () => { const { result } = renderHook(() => useUser()) act(() => result.current.login('testuser')) act(() => result.current.logout()) expect(result.current.isLoggedIn).toBe(false) expect(result.current.username).toBe(null) }) })
你会看到 Vitest 执行了所有我们的测试,并且它们都通过了!现在,让我们继续到去抖动历史状态钩子。
测试异步钩子
有时候我们需要测试执行异步操作的钩子。这意味着我们需要等待一段时间,直到检查结果。
要为这类钩子编写测试,我们可以使用 React Testing Library 中的waitFor函数。这个函数可以用来等待条件满足,而不是立即尝试匹配。因此,它可以用来测试 React 组件和钩子中的异步操作。如果条件在一定时间后(可以通过可选的超时参数指定)仍然无法匹配,测试将失败。
在本章的早期,我们创建了去抖动的历史状态钩子,它只在一定时间后存储历史变化,因此使其成为一个异步钩子。现在,我们将使用waitFor函数来测试去抖动在去抖动历史状态钩子中的效果。
按照以下步骤开始:
-
创建一个新的
src/hooks/debouncedHistoryState.test.js文件。 -
在其中,从 Vitest 导入相关函数,以及从 React Testing Library 导入的
renderHook、act和waitFor函数,以及useDebouncedHistoryState函数:import { describe, test, expect } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' import { useDebouncedHistoryState } from './debouncedHistoryState.js' -
定义一个测试组和第一个测试,该测试仅检查初始值:
describe('Debounced History State Hook', {}, () => { test('should return initial state as content', {}, () => { const { result } = renderHook(() => useDebouncedHistoryState('', 10)) expect(result.current.content).toBe('') }) -
现在,我们定义一个测试来检查内容是否立即更新:
test('should update content immediately', {}, () => { const { result } = renderHook(() => useDebouncedHistoryState('', 10)) act(() => result.current.handleContentChange({ target: { value: 'new content' } }), ) expect(result.current.content).toBe('new content') }) -
对于最后的测试,我们检查 Hook 是否仅在去抖动后更新历史记录:
test('should only update history state after debounce', {}, async () => { -
在这个测试中,我们首先定义 Hook:
const { result } = renderHook(() => useDebouncedHistoryState('', 10))
我们将去抖动超时保持在 10ms,以避免不必要地减慢我们的测试速度。
-
现在,我们触发内容更新:
act(() => result.current.handleContentChange({ target: { value: 'new content' } }), ) -
在去抖动之前,
canUndo值应该是false,因为历史状态中还没有存储任何内容:expect(result.current.canUndo).toBe(false) -
现在我们使用
waitFor等待canUndo值变为 true,这应该在去抖动超时(10ms)之后发生:await waitFor(() => { expect(result.current.canUndo).toBe(true) }) }) })
Vitest 将自动运行我们的新测试,我们可以看到它们都成功了!
在这种情况下,我们有一个非常简单的超时。然而,可能存在更复杂的情况,我们需要等待更长的时间。在测试 Hooks 时,为了更好地控制日期和计时器,您可以使用 Vitest 中的模拟计时器来模拟系统时间。有关更多信息,请查看官方 Vitest 文档中的模拟指南:vitest.dev/guide/mocking.html#dates
我们没有测试 undo/redo/clear 功能,因为这些来自 useHooks 库的历史状态 Hook,因此它超出了我们自定义 Hook 的范围。在大多数情况下,仅测试我们自己在实现中添加的逻辑就足够了。
由于 API Hooks 主要是对 TanStack Query Hooks 的包装,并没有添加自己的逻辑,因此为它们编写测试也没有太多意义。
运行所有测试
为了验证所有测试现在是否都成功,让我们通过按 q 键退出 Vitest 的监视模式。然后,通过执行以下命令再次运行 Vitest:
$ npm test
如我们所见,Vitest 再次执行了所有我们的测试,并且它们都通过了:

图 12.3 – 所有我们的测试都通过了!
示例代码
本节的示例代码可以在 Chapter12/Chapter12_5 文件夹中找到。请检查文件夹内的 README.md 文件,以获取设置和运行示例的说明。
摘要
在本章中,我们首先学习了如何从我们的博客应用中的现有代码中提取自定义 Hooks。我们定义了主题 Hook 以轻松访问上下文,然后定义了用户 Hook,它管理用户状态并提供注册/登录/注销的函数。然后,我们创建了 API Hooks 和一个更高级的用于去抖动历史状态功能的 Hook。最后,我们学习了如何使用 Vitest 和 React 测试库为我们的自定义 Hooks 编写测试。
了解何时以及如何提取自定义 Hook 是 React 开发中非常重要的技能。在一个较大的项目中,你可能需要定义许多针对项目需求量身定制的自定义 Hooks。自定义 Hooks 还可以使维护应用程序变得更加容易,因为我们只需要在一个地方调整功能。测试自定义 Hook 非常重要,因为如果我们稍后重构自定义 Hook,我们想要确保它们仍然能够正常工作。
在下一章中,我们将学习如何从类组件迁移到基于 Hooks 的系统。我们首先使用类组件创建一个小项目,然后将其替换为 Hooks,更仔细地比较两种解决方案之间的差异。
问题
为了回顾本章所学内容,请尝试回答以下问题:
-
我们如何从现有代码中提取自定义 Hook?
-
创建自定义 Hook 的优势是什么?
-
我们应该在何时将功能提取到自定义 Hook 中?
-
我们如何使用自定义 Hooks?
-
我们可以使用哪个库来测试自定义 Hooks?
-
Hooks 执行的动作是如何被测试的?
-
我们如何测试利用 React Context 的 Hooks?
-
我们如何测试执行异步操作的 Hooks?
进一步阅读
如果你对本章所学的概念感兴趣,请查看以下链接:
-
在官方 React 文档中关于“使用自定义 Hook 重用逻辑”的指南:
react.dev/learn/reusing-logic-with-custom-hooks -
Vitest 文档:
vitest.dev/ -
React Testing Library 文档:
testing-library.com/docs/react-testing-library/intro/
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0

第十三章:从 React 类组件迁移
在上一章中,我们学习了如何通过从现有代码中提取自定义钩子来构建自己的钩子。然后,我们在博客应用中使用了我们的自定义钩子。最后,我们学习了如何使用 React 测试库为钩子编写测试,并为我们的自定义钩子编写了测试。
在本章中,我们将首先通过实现一个待办事项应用来使用 React 类组件。然后,我们将学习如何将现有的 React 类组件应用迁移到钩子。通过实际比较钩子和类组件之间的差异,我们将加深对使用这两种解决方案权衡的理解。
本章将涵盖以下主题:
-
使用 React 类组件处理状态
-
从 React 类组件迁移
-
React 类组件与 React Hooks 的权衡
技术要求
应该已经安装了相当新的 Node.js 版本。Node 包管理器(npm)也需要安装(它应该与 Node.js 一起安装)。有关如何安装 Node.js 的更多信息,请访问他们的官方网站:nodejs.org/
我们将在本书的指南中使用Visual Studio Code(VS Code),但任何其他编辑器都应该以类似的方式工作。有关如何安装 VS Code 的更多信息,请参阅他们的官方网站:code.visualstudio.com
在本书中,我们使用以下版本:
-
Node.js v22.14.0
-
npmv10.9.2 -
Visual Studio Code v1.97.2
前面列表中提到的版本是本书中使用的版本。虽然安装较新版本可能不会出现问题,但请注意,某些步骤在较新版本上可能工作方式不同。如果您在使用本书中提供的代码和步骤时遇到问题,请尝试使用提到的版本。
您可以在 GitHub 上找到本章的代码:github.com/PacktPublishing/Learn-React-Hooks-Second-Edition/tree/main/Chapter13
强烈建议您自己编写代码。不要简单地运行书中提供的代码示例。自己编写代码对于正确学习和理解代码非常重要。然而,如果您遇到任何问题,您始终可以参考代码示例。
使用 React 类组件处理状态
在我们从类组件迁移到钩子之前,我们将创建一个使用类组件的小型待办事项应用。之后,我们将使用钩子将这些类组件转换为函数组件。最后,我们将比较这两种解决方案。
设计应用结构
正如我们在之前的博客应用中所做的那样,我们将首先思考我们的待办事项应用的基本结构。在这里,我们需要以下内容:
-
一个标题
-
一种添加新待办事项的方法
-
一种在列表中显示所有待办事项的方法
-
待办事项的过滤器
总是先从原型开始是一个好主意。所以让我们开始:
- 根据之前的结构元素列表,开始绘制我们的待办事项应用界面的原型:

图 13.1 – 我们的待办事项应用的原型
- 接下来,通过围绕 UI 元素绘制并给它们命名来绘制简单的组件,类似于我们在博客应用中做的那样:

图 13.2 – 在我们的应用原型中绘制简单组件
- 现在,绘制容器组件,它们将简单的组件组合在一起:

图 13.3 – 在我们的应用原型中绘制容器组件
如我们所见,我们需要以下组件:
-
App -
Header -
AddTodo -
TodoList -
TodoItem -
TodoFilter(+TodoFilterItem)
TodoList组件使用了TodoItem组件,用于显示一个项目,包括一个复选框来完成它和一个按钮来移除它。TodoFilter组件内部使用TodoFilterItem组件来显示各种过滤器。
初始化项目
我们将使用来自*第一章**,介绍 React 和 React Hooks 的裸骨 Vite 应用来创建一个新的项目。现在让我们初始化项目:
-
将
Chapter01_3文件夹复制到一个新的Chapter13_1文件夹中,如下所示:$ cp -R Chapter01_3 Chapter13_1 -
在 VS Code 中打开新的
Chapter13_1文件夹。 -
删除当前的
src/App.jsx文件,因为我们将在下一步创建一个新的文件。
在项目初始化后,我们可以开始定义应用结构。
定义应用结构
我们已经从原型中知道了应用的基本结构,所以让我们首先定义App组件:
-
创建一个新的
src/App.jsx文件。 -
在其中,导入
React以及我们应用的所有容器组件:import React from 'react' import { Header } from './Header.jsx' import { AddTodo } from './AddTodo.jsx' import { TodoList } from './TodoList.jsx' import { TodoFilter } from './TodoFilter.jsx' -
接下来,将
App组件定义为类组件。在类组件中,我们需要定义一个render方法来渲染我们的组件:export class App extends React.Component { render() { return ( <div style={{ width: '400px' }}> <Header /> <AddTodo /> <hr /> <TodoList /> <hr /> <TodoFilter /> </div> ) } }
App组件定义了我们的应用的基本结构。它将包括一个标题、添加新待办事项的方式、待办事项列表和一个过滤器。
定义组件
现在,我们将以下组件定义为静态组件:
-
Header -
AddTodo -
TodoList -
TodoItem -
TodoFilter
在本章的后面部分,我们将向它们添加动态功能。
定义 Header 组件
我们将从最简单的组件Header开始,因为它是最简单的:
- 创建一个新的
src/Header.jsx文件。
在这个项目中,我们决定将所有组件直接放在src/文件夹中,因为组件数量很少。
-
在新创建的文件中,导入
React,并使用render方法定义类组件:import React from 'react' export class Header extends React.Component { render() { return <h1>ToDo</h1> } }
定义 AddTodo 组件
接下来,我们将定义AddTodo组件,它将渲染一个输入字段和一个按钮:
-
创建一个新的
src/AddTodo.jsx文件。 -
在其中,导入
React并定义类组件:import React from 'react' export class AddTodo extends React.Component { render() { -
在
render方法中,我们返回一个包含输入字段和提交按钮的表单:return ( <form> <input type='text' placeholder='enter new task...' style={{ width: '350px' }} /> <input type='submit' style={{ float: 'right' }} value='add' /> </form> ) } }
定义 TodoList 组件
现在,我们定义TodoList组件,它将使用TodoItem组件(我们将在下一步创建它)。现在,我们将在一个数组中静态定义两个待办事项的数据,并使用它来渲染TodoItem组件。
让我们开始定义TodoList组件:
-
创建一个新的
src/TodoList.jsx文件。 -
在其中,导入
React和TodoItem组件:import React from 'react' import { TodoItem } from './TodoItem.jsx' -
然后,定义类组件:
export class TodoList extends React.Component { render() { -
在
render方法中,我们静态地定义了两个待办事项:const items = [ { id: 1, title: 'Finish React Hooks book', completed: true }, { id: 2, title: 'Promote the book', completed: false }, ] -
最后,我们将使用
map函数渲染项目:return items.map((item) => <TodoItem {...item} key={item.id} />) } }
我们最后定义key属性,以避免在item对象展开时覆盖它。
定义 TodoItem 组件
在定义了TodoList组件之后,我们现在将定义TodoItem组件,以便渲染单个项目。让我们开始定义TodoItem组件:
-
创建一个新的
src/TodoItem.jsx文件。 -
在其中,导入
React并定义类组件:import React from 'react' export class TodoItem extends React.Component { render() { -
现在,我们将使用解构来获取
title和completed属性:const { title, completed } = this.props -
最后,我们将渲染一个复选框、标题和一个删除项目的按钮:
return ( <div style={{ width: '400px', height: '25px' }}> <input type='checkbox' checked={completed} /> {title} <button type='button' style={{ float: 'right' }}>x</button> </div> ) } }
定义 TodoFilter 组件
最后,我们将定义TodoFilter组件。在同一个文件中,我们还将定义TodoFilterItem组件,因为它们紧密相关。所以,让我们开始实现这些组件:
-
创建一个新的
src/TodoFilter.jsx文件。 -
在其中,导入
React并定义一个TodoFilterItem类组件:import React from 'react' export class TodoFilterItem extends React.Component { render() { -
我们使用解构来获取
name属性:const { name } = this.props -
然后,我们返回一个按钮来选择过滤器:
return <button type='button'>{name}</button> } } -
最后,我们定义实际的
TodoFilter组件,它将渲染三个TodoFilterItem组件:export class TodoFilter extends React.Component { render() { return ( <div> <TodoFilterItem name='all' /> <TodoFilterItem name='active' /> <TodoFilterItem name='completed' /> </div> ) } }
现在我们已经实现了所有静态组件,我们可以开始启动应用。
启动应用
要启动应用,运行以下命令:
$ npm run dev
在浏览器中打开 URL,你会看到应用看起来就像我们的模拟图一样!
然而,应用是完全静态的,当我们点击任何东西时,什么都不会发生。在下一步中,我们将使我们的应用动态化。
实现动态代码
现在我们已经定义了所有静态组件,我们的应用应该看起来就像模拟图。下一步是实现使用 React 状态、生命周期和处理器函数的动态代码。
在本节中,我们将要:
-
定义模拟 API
-
定义
StateContext -
使
App组件动态化 -
使
AddTodo组件动态化 -
使
TodoList组件动态化 -
使
TodoItem组件动态化 -
使
TodoFilter组件动态化
让我们开始吧。
定义模拟 API
首先,我们将定义一个 API 来获取待办事项。在我们的例子中,我们将在短暂的延迟后返回一个待办事项数组,以模拟网络请求,这通常需要一些时间来解决。
现在让我们开始实现模拟 API:
-
创建一个新的
src/api.js文件。 -
在其中,定义并导出一个函数,该函数在短暂的延迟后返回项目:
const mockItems = [ { id: 1, title: 'Finish React Hooks book', completed: true }, { id: 2, title: 'Promote the book', completed: false }, ] export function fetchTodos() { return new Promise((resolve) => { setTimeout(() => resolve(mockItems), 100) }) }
这里我们使用 Promise API 在等待 100ms 后解析一个包含模拟项目的数组,以模拟真实的 API 请求。
接下来,我们将定义一个上下文,它将保持我们当前的待办事项列表。
定义状态上下文
我们现在将定义一个上下文,它将保持我们待办应用的当前状态:
-
创建一个新的
src/StateContext.js文件。 -
在其中,从 React 导入
createContext函数:import { createContext } from 'react' -
然后,定义并导出一个包含空数组的上下文:
export const StateContext = createContext([])
现在我们有一个可以存储我们的待办事项数组的上下文,让我们继续使组件动态化。
使 App 组件动态化
我们将首先使App组件动态化,添加获取、添加、切换、过滤和删除待办事项的功能。为此:
-
编辑
src/App.jsx并导入StateContext和fetchTodos函数:import { StateContext } from './StateContext.js' import { fetchTodos } from './api.js' -
接下来,我们将修改我们的
App类代码,向其中添加一个constructor,它将设置初始状态:export class App extends React.Component { **constructor****(props) {** -
在这个构造函数中,我们首先需要调用
super以确保父类(React.Component)的构造函数被调用,并且组件被正确初始化:super(props) -
现在,我们可以使用
this.state设置初始状态。最初,没有待办事项,过滤器设置为all:this.state = { todos: [], filteredTodos: [], filter: 'all' } } -
然后,我们定义了
componentDidMount生命周期方法,该方法将在组件首次渲染时加载待办事项:componentDidMount() { this.loadTodos() } -
接下来,我们将定义
loadTodos方法,在我们的例子中,它将简单地设置状态,因为我们不会将这个简单的应用连接到后端。我们还将调用this.filterTodos()来更新filteredTodos数组:async loadTodos() { const todos = await fetchTodos() this.setState({ todos }) this.filterTodos() }
我们将在本节稍后定义filterTodos方法。
-
接下来,我们定义了
addTodo方法,该方法创建一个新项目并将其添加到数组中:addTodo(title) { const { todos } = this.state const newTodo = { id: Date.now(), title, completed: false } this.setState({ todos: [newTodo, ...todos] }) this.filterTodos() }为了保持简单,我们只是使用
Date.now()为每个待办事项生成一个唯一的 ID。 -
我们现在定义一个
toggleTodo方法,它使用map函数查找并修改特定的待办事项:toggleTodo(id) { const { todos } = this.state const updatedTodos = todos.map(item => { if (item.id === id) { return { ...item, completed: !item.completed } } return item }) this.setState({ todos: updatedTodos }) this.filterTodos() } -
现在,定义
removeTodo方法,它使用filter函数查找并删除特定的待办事项:removeTodo(id) { const { todos } = this.state const updatedTodos = todos.filter((item) => item.id !== id) this.setState({ todos: updatedTodos }) this.filterTodos() } -
然后,定义一个方法来应用不同的过滤器到待办事项,例如
active、completed和all:applyFilter(todos, filter) { switch (filter) { case 'active': return todos.filter((item) => item.completed === false) case 'completed': return todos.filter((item) => item.completed === true) case 'all': default: return todos } } -
现在,我们可以定义
filterTodos方法,它将调用applyFilter方法并更新filteredTodos和filter状态:filterTodos(filterArg) { this.setState(({ todos, filter }) => { const newFilter = filterArg ?? filter return { filter: newFilter, filteredTodos: this.applyFilter(todos, newFilter), } }) }
空合并 (??) 运算符用于在左侧值是 undefined 或 null 时回退到不同的值。与 || 运算符不同,?? 运算符仅针对 undefined/null 值触发,而不是针对 0、'' 或 false 这样的假值。
我们使用 filterTodos 在添加/删除后以及更改过滤器时重新过滤待办事项。为了使这两种功能都能正常工作,我们需要检查是否传入了过滤器参数 filterArg。如果没有,我们将回退到 state 对象中的当前 filter 值。
-
然后,我们调整
render方法并使用状态为StateContext提供一个值。我们还向下传递某些方法到组件:render() { **const** **{ filter, filteredTodos } =** **this****.****state** return ( **<****StateContext.Provider****value****=****{filteredTodos}****>** <div style={{ width: '400px' }}> <Header /> <AddTodo **addTodo****=****{this.addTodo}** /> <hr /> <TodoList **toggleTodo****=****{this.toggleTodo}****removeTodo****=****{this.removeTodo}** /> <hr /> <TodoFilter **filter****=****{filter}****filterTodos****=****{this.filterTodos}** /> </div> </StateContext.Provider> ) } } -
最后,我们需要重新绑定
this到类,这样我们就可以在不改变this上下文的情况下将方法传递给我们的组件。通过以下方式调整constructor方法,重新绑定所有方法中的this上下文:constructor(props) { super(props) this.state = { todos: [], filteredTodos: [], filter: 'all' } **this****.****loadTodos** **=** **this****.****loadTodos****.****bind****(****this****)** **this****.****addTodo** **=** **this****.****addTodo****.****bind****(****this****)** **this****.****toggleTodo** **=** **this****.****toggleTodo****.****bind****(****this****)** **this****.****removeTodo** **=** **this****.****removeTodo****.****bind****(****this****)** **this****.****filterTodos** **=** **this****.****filterTodos****.****bind****(****this****)** }
现在,App 组件可以动态地获取、添加、切换、删除和过滤待办事项。正如我们所看到的,当我们使用类组件时,我们需要重新绑定处理函数的 this 上下文到类。否则,当我们调用方法时,会因为它们的 this 上下文不再绑定到类而引发错误,因此它们无法调用如 this.setState 这样的方法。
使 AddTodo 组件动态化
在使 App 组件动态化之后,现在是时候使我们的所有其他组件也动态化了。我们将从顶部开始,以 AddTodo 组件为例:
-
编辑
src/AddTodo.jsx并定义一个constructor,它为输入字段设置初始状态:export class AddTodo extends React.Component { **constructor****(****props****) {** **super****(props)** **this****.****state** **= {** **input****:** **''****,** **}** **}** -
然后,定义一个处理输入字段变化的方法:
handleInput(e) { this.setState({ input: e.target.value }) } -
现在,定义一个处理表单提交的方法:
handleSubmit(e) { e.preventDefault() const { input } = this.state const { addTodo } = this.props if (input) { addTodo(input) this.setState({ input: '' }) } } -
接下来,我们可以将输入值以及输入和提交处理程序分配给相关组件。此外,当输入字段为空时,我们将禁用按钮:
render() { **const** **{ input } =** **this****.****state** return ( <form **onSubmit****=****{this.handleSubmit}****>** <input type='text' placeholder='enter new task...' style={{ width: '350px' }} **value****=****{input}** **onChange****=****{this.handleInput}** /> <input type='submit' style={{ float: 'right' }} value='add' **disabled****=****{!input}** /> </form> ) } } -
最后,我们需要通过重新绑定所有处理方法中的
this上下文来调整构造函数,如下所示:constructor(props) { super(props) this.state = { input: '', } **this****.****handleInput** **=** **this****.****handleInput****.****bind****(****this****)** **this****.****handleSubmit** **=** **this****.****handleSubmit****.****bind****(****this****)** }
现在,只要没有输入文本,AddTodo 组件将显示一个禁用的按钮。一旦我们输入一些文本并点击按钮,它将触发 App 组件的 addTodo 方法。
使 TodoList 组件动态化
在我们的待办事项应用中的下一个组件是 TodoList 组件。在这里,我们只需要从 StateContext 获取待办事项。为此:
-
编辑
src/TodoList.jsx并导入StateContext:import { StateContext } from './StateContext.js' -
然后,我们将
contextType设置为StateContext,这将允许我们通过this.context使用上下文:export class TodoList extends React.Component { **static** **contextType =** **StateContext**使用类组件时,如果您想使用多个上下文,您必须使用
StateContext.Consumer组件,如下所示:`<StateContext.Consumer>` `{value => <div>State is: {value}</div>}` `</StateContext.Consumer>`如您所想象,使用多个上下文将导致非常深的组件树,代码将难以阅读和重构。
-
现在,我们可以从
this.context获取项目,而不是静态地定义它们:render() { const items = **this****.****context** -
最后,我们将所有 props 传递给
TodoItem组件,这样我们就可以在那里使用removeTodo和toggleTodo方法:return items.map((item) => ( <TodoItem {...item} **{****...this.props****}** key={item.id} /> )) } }
现在的TodoList组件从StateContext获取项目,而不是静态定义它们。接下来,让我们继续使TodoItem组件动态化。
使 TodoItem 组件动态化
现在我们已经将removeTodo和toggleTodo方法作为 props 传递给TodoItem组件,我们可以在那里实现这些功能,并使TodoItem组件也动态化:
-
编辑
src/TodoItem.jsx,并为toggleTodo和removeTodo函数定义处理方法:handleToggle() { const { toggleTodo, id } = this.props toggleTodo(id) } handleRemove() { const { removeTodo, id } = this.props removeTodo(id) } -
然后,我们将处理函数分别分配给复选框和按钮:
render() { const { title, completed } = this.props return ( <div style={{ width: '400px', height: '25px' }}> <input type='checkbox' checked={completed} **onChange****=****{this.handleToggle}** /> {title} <button type='button' style={{ float: 'right' }} **onClick****=****{this.handleRemove}** > x </button> </div> ) } } -
最后,我们需要重新绑定处理方法的
this上下文。定义一个新的constructor方法,如下所示:export class TodoItem extends React.Component { **constructor****(****props****) {** **super****(props)** **this****.****handleToggle** **=** **this****.****handleToggle****.****bind****(****this****)** **this****.****handleRemove** **=** **this****.****handleRemove****.****bind****(****this****)** **}**
现在的TodoItem组件正确触发了切换和删除方法。接下来,我们将使TodoFilter组件动态化。
使 TodoFilter 组件动态化
最后,我们将使用filterTodos方法动态过滤待办事项列表:
-
编辑
src/TodoFilter.jsx,在TodoFilter类组件中,将所有 props 传递给TodoFilterItem组件:export class TodoFilter extends React.Component { render() { return ( <div> <TodoFilterItem **{****...this.props****}** name='all' /> <TodoFilterItem **{****...this.props****}** name='active' /> <TodoFilterItem **{****...this.props****}** name='completed' /> </div> ) } } -
在同一文件中,为
TodoFilterItem类组件添加一个handleFilter方法:export class TodoFilterItem extends React.Component { **handleFilter****() {** **const** **{ name, filterTodos } =** **this****.****props** **filterTodos****(name)** **}** -
然后,获取
filter属性,禁用具有当前选定过滤器的按钮,并在按钮被点击时调用this.handleFilter:render() { const { name, **filter =** **'all'** } = this.props return ( <button type='button' **disabled****=****{filter** **===** **name}** **onClick****=****{this.handleFilter}**> {name} </button> ) } } -
最后,我们为
TodoFilterItem类组件定义一个constructor方法,并重新绑定处理函数的this上下文:export class TodoFilterItem extends React.Component { **constructor****(****props****) {** **super****(props)** **this****.****handleFilter** **=** **this****.****handleFilter****.****bind****(****this****)** **}**
现在,TodoFilter组件触发过滤处理函数来更改过滤器。我们的整个应用现在是动态的,我们可以使用其所有功能!
启动应用
到目前为止,我们已经使所有组件动态化,可以开始应用,如下所示:
$ npm run dev
在浏览器中打开 URL,尝试添加/切换/删除/过滤待办事项,你会看到一切按预期工作!

图 13.4 – 在我们的待办事项应用中仅显示已完成的项目
示例代码
本节的示例代码可以在Chapter13/Chapter13_1文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
现在我们已经用 React 类组件使我们的应用运行良好,让我们用它来学习如何将现有应用迁移到 React Hooks。
从 React 类组件迁移
在使用 React 类组件设置好我们的示例项目后,我们现在将迁移此项目到 React Hooks。我们将展示如何迁移副作用,例如组件挂载时获取待办事项,以及将状态管理迁移到基于 Hook 的解决方案。
在本节中,我们将迁移以下组件:
-
TodoItem -
TodoList -
TodoFilterItem -
TodoFilter -
AddTodo -
App
在我们这样做之前,请创建一个新的文件夹用于迁移项目,具体如下:
-
将
Chapter13_1文件夹复制到新的Chapter13_2文件夹中,具体操作如下:$ cp -R Chapter13_1 Chapter13_2 -
在 VS Code 中打开新的
Chapter13_2文件夹。
现在,我们可以开始迁移组件。
迁移 TodoItem 组件
最简单的组件之一要迁移的是 TodoItem 组件。它不使用任何状态或副作用,因此我们可以简单地将其转换为函数组件。
现在让我们开始迁移 TodoItem 组件:
-
编辑
src/TodoItem.jsx并删除所有现有的代码。 -
现在,我们定义一个接受
title、completed、id、toggleTodo和removeTodo属性的函数组件:export function TodoItem({ title, completed, id, toggleTodo, removeTodo }) { -
然后,我们定义两个处理函数来切换和删除待办事项:
function handleToggle() { toggleTodo(id) } function handleRemove() { removeTodo(id) } -
最后,我们渲染组件:
return ( <div style={{ width: '400px', height: '25px' }}> <input type='checkbox' checked={completed} onChange={handleToggle} /> {title} <button type='button' style={{ float: 'right' }} onClick={handleRemove}> x </button> </div> ) }
尽量保持你的函数组件小巧,并通过创建使用其他函数组件的新函数组件(这种模式称为组合)来组合它们。拥有许多小组件总是一个好主意,而不是一个大组件。它们更容易维护、重用和重构。
如您所见,函数组件不需要我们重新绑定 this,或者根本不需要定义构造函数。此外,我们不需要在多个地方多次从 this.props 中解构。我们可以在函数定义中简单地解构所有 props。
如果你现在运行应用程序,你会看到它仍然可以工作。React 实际上允许你将函数组件与类组件组合,因此不需要一次性迁移整个代码库,你可以分批迁移某些部分。React 类组件甚至可以渲染使用 Hooks 的函数组件。唯一的限制是我们不能在类组件中使用 Hooks。因此,我们需要一次迁移一个组件。
现在,让我们继续迁移 TodoList 组件。
迁移 TodoList 组件
TodoList 组件渲染多个 TodoItem 组件。在这里,我们使用了上下文,这意味着我们现在可以使用 Context Hook。
现在让我们迁移 TodoList 组件:
-
编辑
src/TodoList.jsx并删除所有现有的代码。 -
导入以下内容:
import { useContext } from 'react' import { StateContext } from './StateContext.js' import { TodoItem } from './TodoItem.jsx' -
定义一个函数组件。在这种情况下,我们不需要解构 props,而是简单地获取整个对象:
export function TodoList(props) { -
现在,定义 Context Hook:
const items = useContext(StateContext) -
最后,返回渲染项的列表:
return items.map((item) => <TodoItem {...item} {...props} key={item.id} />) }
如我们所见,使用 Hooks 与上下文一起使用要简单得多。我们只需调用一个函数(Context Hook),并使用返回值。没有神奇的 this.context 赋值!
接下来,让我们迁移 TodoFilter 组件。
迁移 TodoFilter 组件
TodoFilter 组件没有使用任何 Hooks。然而,我们将用两个函数组件替换 TodoFilterItem 和 TodoFilter 类组件——一个用于 TodoFilterItem,另一个用于 TodoFilter 组件:
-
编辑
src/TodoFilter.jsx并删除所有现有的代码。 -
定义
TodoFilterItem组件,具体如下:export function TodoFilterItem({ name, filterTodos, filter = 'all' }) { function handleFilter() { filterTodos(name) } return ( <button type='button' disabled={filter === name} onClick={handleFilter}> {name} </button> ) } -
接下来,定义
TodoFilter组件,如下所示:export function TodoFilter(props) { return ( <div> <TodoFilterItem {...props} name='all' /> <TodoFilterItem {...props} name='active' /> <TodoFilterItem {...props} name='completed' /> </div> ) }
如我们所见,TodoFilter 组件静态渲染了三个 TodoFilterItem 组件,这些组件用于在显示 所有、活动 或 完成 待办事项之间切换过滤。接下来,让我们迁移 AddTodo 组件。
迁移 AddTodo 组件
对于 AddTodo 组件,我们将使用 State Hook 来处理输入字段状态。
现在,让我们迁移 AddTodo 组件:
-
编辑
src/AddTodo.jsx并 删除 所有的现有代码。 -
导入
useState函数:import { useState } from 'react' -
定义
AddTodo组件,该组件接受一个addTodo函数作为属性:export function AddTodo({ addTodo }) { -
接下来,定义一个用于输入字段状态的 State Hook:
const [input, setInput] = useState('') -
现在,定义处理函数:
function handleInput(e) { setInput(e.target.value) } function handleSubmit(e) { e.preventDefault() if (input) { addTodo(input) setInput('') } } -
最后,渲染输入字段和按钮:
return ( <form onSubmit={handleSubmit}> <input type='text' placeholder='enter new task...' style={{ width: '350px' }} value={input} onChange={handleInput} /> <input type='submit' style={{ float: 'right' }} value='add' disabled={!input} /> </form> ) }
如我们所见,使用 State Hook 使得状态管理变得更加简单。我们可以为每个状态值定义一个单独的值和设置函数,而不是必须处理一个单一的 state 对象。此外,我们不必始终从 this.state 中解构。因此,我们的代码更加整洁和简洁。
现在,让我们继续迁移 App 组件。
迁移状态管理和 App 组件
最后,剩下要做的就是迁移 App 组件。然后,我们的整个待办事项应用将迁移到 React Hooks。在这里,我们将使用 Reducer Hook 来管理状态,使用 Effect Hook 在组件挂载时获取待办事项,并使用 Memo Hook 存储过滤后的待办事项列表。
在本节中,我们将:
-
定义动作
-
定义计算器
-
迁移 App 组件
让我们开始吧。
定义动作
我们的应用将接受五个动作,让我们先花点时间想想它们会是什么样子:
-
LOAD_TODOS:用于加载新的待办事项列表{ type: 'LOAD_TODOS', todos: [] } -
ADD_TODO:用于插入一个新的待办事项{ type: 'ADD_TODO', title: 'Test todo app' } -
TOGGLE_TODO:用于切换待办事项的完成值{ type: 'TOGGLE_TODO', id: 'xxx' } -
REMOVE_TODO:用于删除待办事项{ type: 'REMOVE_TODO', id: 'xxx' } -
FILTER_TODOS:用于过滤待办事项{ type: 'FILTER_TODOS', filter: 'completed' }
定义计算器
现在我们将定义我们状态的计算器。我们需要一个应用计算器和两个子计算器:一个用于待办事项,一个用于过滤。
定义过滤计算器
我们将首先定义用于 filter 值的计算器:
-
创建一个新的
src/reducers.js文件。 -
在此文件中,定义一个用于
filterReducer的函数,该函数将处理FILTER_TODOS动作并相应地设置过滤值:function filterReducer(state, action) { if (action.type === 'FILTER_TODOS') { return action.filter } return state }
接下来,让我们继续到 todosReducer 函数。
定义 todos 减法器
现在,我们将定义用于待办事项的 todosReducer 函数。在这里,我们将处理 LOAD_TODOS、ADD_TODO、TOGGLE_TODO 和 REMOVE_TODO 动作:
-
编辑
src/reducers.js并定义一个新的函数,该函数将处理这些动作:function todosReducer(state, action) { switch (action.type) { -
对于
LOAD_TODOS动作,我们只需用新的todos数组替换当前状态:case 'LOAD_TODOS': return action.todos -
对于
ADD_TODO动作,我们将在新状态数组中插入一个新的条目:case 'ADD_TODO': { const newTodo = { id: Date.now(), title: action.title, completed: false } return [newTodo, ...state] } -
对于
TOGGLE_TODO动作,我们将使用map函数更新单个待办事项:case 'TOGGLE_TODO': { return state.map((item) => { if (item.id === action.id) { return { ...item, completed: !item.completed } } return item }) } -
对于
REMOVE_TODO动作,我们将使用filter函数删除单个待办事项:case 'REMOVE_TODO': { return state.filter((item) => item.id !== action.id) } -
默认情况下(对于所有其他动作),我们简单地返回当前状态:
default: return state } }
现在我们只需要定义appReducer,它将把其他两个 reducer 合并成一个。
定义应用 reducer
为了在一个 Reducer Hook 中处理我们的待办应用状态,我们将把创建的两个 reducer 合并成一个appReducer函数。为此:
-
编辑
src/reducers.js并定义并导出一个新的函数用于应用 reducer:export function appReducer(state, action) { -
在这个函数中,我们返回一个包含其他 reducer 值的对象。我们简单地将子状态和动作传递给其他 reducer:
return { todos: todosReducer(state.todos, action), filter: filterReducer(state.filter, action), } }
现在我们已经定义了一个用于管理应用状态的 reducer,我们可以开始迁移App组件。
迁移 App 组件
让我们开始迁移App组件:
-
编辑
src/App.jsx并删除所有现有的代码。 -
导入以下内容:
import { useReducer, useEffect, useMemo } from 'react' import { Header } from './Header.jsx' import { AddTodo } from './AddTodo.jsx' import { TodoList } from './TodoList.jsx' import { TodoFilter } from './TodoFilter.jsx' import { StateContext } from './StateContext.js' import { fetchTodos } from './api.js' import { appReducer } from './reducers.js' -
首先,我们定义一个函数组件,它不会接受任何属性:
export function App() { -
现在,我们使用
appReducer函数定义一个 Reducer Hook:const [state, dispatch] = useReducer(appReducer, { todos: [], filter: 'all' }) -
让我们也解构
state对象,以便我们以后更容易访问它:const { todos, filter } = state -
然后,我们使用 Memo Hook 实现过滤机制。这是使用 Memo Hook 实际上必要的少数情况之一。否则,我们的组件会太频繁地重新渲染,导致无限循环。我们可以使用 Memo Hook 来确保只有当
todos或filter状态改变时,filteredTodos的改变才会被触发,如下所示:const filteredTodos = useMemo(() => { switch (filter) { case 'active': return todos.filter((item) => item.completed === false) case 'completed': return todos.filter((item) => item.completed === true) case 'all': default: return todos } }, [todos, filter]) -
现在,我们定义一个 Effect Hook,它将从我们的模拟 API 获取待办事项,然后派发一个
LOAD_TODOS动作:useEffect(() => { async function loadTodos() { const todos = await fetchTodos() dispatch({ type: 'LOAD_TODOS', todos }) } void loadTodos() }, [])
Effect Hook 内部的函数需要是同步的,因此我们在这里使用一种变通方法来使 async/await 构造函数工作。当不关心等待异步函数完成时,最好在函数调用前加上void前缀,以表明我们故意没有await它。
-
接下来,我们定义各种函数,这些函数将派发动作并改变我们应用的状态:
function addTodo(title) { dispatch({ type: 'ADD_TODO', title }) } function toggleTodo(id) { dispatch({ type: 'TOGGLE_TODO', id }) } function removeTodo(id) { dispatch({ type: 'REMOVE_TODO', id }) } function filterTodos(filter) { dispatch({ type: 'FILTER_TODOS', filter }) } -
最后,我们渲染我们待办应用所需的所有组件:
return ( <StateContext.Provider value={filteredTodos}> <div style={{ width: '400px' }}> <Header /> <AddTodo addTodo={addTodo} /> <hr /> <TodoList toggleTodo={toggleTodo} removeTodo={removeTodo} /> <hr /> <TodoFilter filter={filter} filterTodos={filterTodos} /> </div> </StateContext.Provider> ) } -
现在我们应用已完全迁移,我们可以启动开发服务器并验证一切是否仍然正常工作:
$ npm run dev
如我们所见,使用 Reducer Hook 来处理复杂的状态变化使我们的代码更加简洁且易于维护。我们的应用现在已完全迁移到 Hooks,并且仍然像以前一样工作!
另一种进一步重构和改进代码的可能性是将状态和派发函数存储在一个上下文中,然后定义自定义 Hooks 来处理我们待办应用的各个功能。然而,在这个例子中,我们希望尽可能接近原始类组件代码,所以任何进一步的重构都留给读者作为练习。
示例代码
本节的示例代码可以在Chapter13/Chapter13_2文件夹中找到。请检查文件夹内的README.md文件,以获取设置和运行示例的说明。
现在,让我们通过讨论使用 React 类组件与 React Hooks 的权衡来结束本书。
React 类组件与 React Hooks 的权衡
现在我们已经完成了从类组件到 Hooks 的迁移,让我们回顾和总结我们所学的知识。
带有 Hooks 的函数组件更容易理解和测试,因为它们只是简单的 JavaScript 函数,而不是复杂的 React 结构。我们还能够将状态改变逻辑重构到单独的reducers.js文件中,从而将其从App组件中解耦,使其更容易重构和测试。我们可以安全地说,在应用程序逻辑和组件之间分离关注点显著提高了我们项目的可维护性。
现在,让我们回顾一下通过重构我们的应用程序所获得的优势。通过函数组件和 Hooks,以下这些点不再需要考虑:
-
无需处理构造函数。
-
没有令人困惑的
this上下文(this重新绑定)。 -
无需反复解构相同的值。
-
处理上下文、props 和状态时没有魔法。
-
如果我们希望在 props 变化时重新获取数据,则无需定义
componentDidMount和componentDidUpdate。
此外,函数组件还有以下优势:
-
鼓励制作小型和简单的组件。
-
更容易重构。
-
更容易测试。
-
需要的代码更少。
-
对于初学者来说更容易理解。
-
更具声明性。
-
React 服务器组件(RSCs)只能是函数组件。
然而,在以下情况下,类组件可能是可以的:
-
当坚持某些约定时。
-
当使用箭头函数来避免
this重新绑定时。 -
可能因为现有的知识而更容易被团队理解。
-
许多项目仍然使用类。对于依赖项来说,这并不是一个问题,因为它们仍然可以很容易地在函数组件中使用。然而,在遗留代码库中,你可能仍然需要与类组件一起工作。
最后,这是一个个人喜好问题,但 Hooks 确实在许多方面优于类!如果你正在启动一个新项目,绝对选择 Hooks。如果你正在处理一个现有项目,考虑是否可能有必要将某些组件重构为基于 Hooks 的组件,以使它们更简单。
然而,你不应该立即将所有项目迁移到 Hooks,因为重构可能会引入新的错误。采用 Hooks 的最佳方式是在适当的时候,慢慢地但肯定地用基于 Hooks 的函数组件替换旧的类组件。例如,如果你已经在重构一个组件,你可以将其重构为使用 Hooks!
摘要
在本章中,我们首先使用 React 类组件构建了一个待办事项应用程序。我们首先设计了应用程序结构,然后实现了静态组件,最后使它们变得动态。接下来,我们学习了如何使用 Hooks 将使用类组件的现有项目迁移到函数组件。最后,我们学习了类组件的权衡,何时应该使用类组件或 Hooks,以及如何将现有项目迁移到 Hooks。
我们现在在实践中已经看到了 React 类组件与使用 Hooks 的函数组件之间的区别。Hooks 让我们的代码更加简洁,更容易阅读和维护。我们还了解到,我们应该逐步将我们的组件从类组件迁移到使用 Hooks 的函数组件——没有必要立即将整个应用程序迁移过来。
本章标志着本书的结束。在这本书中,我们以使用 Hooks 为动机开始。我们了解到,在 React 应用程序中存在一些常见问题,没有 Hooks 很难解决。然后,我们使用 Hooks 创建了我们的第一个组件,并将其与基于类的解决方案进行了比较。接下来,我们学习了 State Hook,这是其中最普遍的一个。我们还学习了使用 Hooks 解决常见问题,例如条件 Hooks 和循环中的 Hooks。我们对 Hooks 内部的工作原理及其局限性有了深入的了解。
在深入了解了 State Hook 之后,我们使用 Hooks 开发了一个小型博客应用程序。然后,我们学习了 Reducer Hooks、Effect Hooks 和 Context Hooks,以便能够在我们的应用程序中实现更多功能。接下来,我们学习了如何使用 TanStack Query Hooks 和 React Suspense 进行数据获取。然后,我们使用 Hooks 处理表单。接下来,我们使用 React Router 在我们的博客应用程序中实现路由,并学习了 Hooks 如何使动态路由变得更加容易。我们通过使用 Hooks 开发了一个真实的、面向前端的实际应用程序,获得了经验。
我们还学习了 React 提供的各种内置 Hooks,甚至包括高级的 Hooks。然后,我们学习了社区提供的各种 Hooks,这些 Hooks 使处理应用程序状态和不同用例变得更加容易。我们对 React 提供的所有 Hooks 以及如何找到更多 Hooks 有了一个深入的了解。
在书的结尾部分,我们学习了 Hooks 的规则,如何创建我们自己的自定义 Hooks,以及如何使用 Vitest 为它们编写测试。我们还学习了如何将现有的基于类组件的应用迁移到基于 Hooks 的解决方案。因此,我们了解了如何有效地使用 Hooks 重构 React 应用程序。
现在您已经深入了解了 Hooks,您已经准备好在您的应用程序中使用它们了!您也已经学会了如何将现有项目迁移到 Hooks,因此您现在可以开始这样做。我希望您喜欢学习 React Hooks,并且期待在您的应用程序中实现 Hooks!我相信使用 Hooks 会让您在编码时感到更加愉快,就像它们对我一样。
问题
为了回顾本章所学内容,请尝试回答以下问题:
-
如何定义 React 类组件?
-
使用类组件的构造函数时,我们需要调用什么?为什么?
-
我们如何使用类组件设置初始状态?
-
我们如何使用类组件更改状态?
-
为什么我们需要在类组件方法中使用时重新绑定
this上下文? -
我们如何重新绑定
this上下文? -
我们如何使用 React Context 与类组件?
-
迁移到 Hooks 时,我们可以用什么来替换状态管理?
-
使用 Hooks 与类组件相比有哪些权衡?
-
何时以及如何将现有项目迁移到 Hooks?
进一步阅读
如果您想了解更多关于本章所学概念的信息,请查看以下链接:
-
JavaScript 中的类:
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes -
React 类组件:
www.robinwieruch.de/react-component-types/#react-class-components -
将类组件迁移到函数:
react.dev/reference/react/Component#migrating-a-simple-component-from-a-class-to-a-function
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/wnXT0


订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助您规划个人发展并提升职业生涯。更多信息,请访问我们的网站。
为什么要订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,花更少的时间学习,更多的时间编码
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,便于轻松访问关键信息
-
复制粘贴、打印和收藏内容
在 www.packtpub.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还会喜欢的其他书籍
如果你喜欢这本书,你可能对 Packt 的这些其他书籍也感兴趣:
React Key Concepts, Second Edition
Maximilian Schwarzmüller
ISBN: 978-1-83620-227-1
-
构建现代、用户友好且反应灵敏的 Web 应用
-
创建组件并使用 props 在它们之间传递数据
-
处理事件,执行状态更新,并管理条件内容
-
动态和条件地添加样式以适应现代用户界面
-
使用 React 的 Context API 等高级状态管理技术
-
利用 React Router 为不同的 URL 渲染不同的页面
-
理解关键的最佳实践和优化机会
-
了解 React 服务器组件和服务器操作
Modern Full-Stack React Projects
Daniel Bugl
ISBN: 978-1-83763-795-9
-
使用 Express 和 MongoDB 实现后端,并使用 Jest 进行单元测试
-
使用 Docker 部署全栈 Web 应用,使用 Playwright 设置 CI/CD 和端到端测试
-
使用 JSON Web Tokens (JWT) 添加身份验证
-
创建一个 GraphQL 后端,并使用 Apollo Client 与前端集成
-
基于事件驱动架构使用 Socket.IO 构建聊天应用
-
促进搜索引擎优化 (SEO) 并实现服务器端渲染
-
使用 Next.js,一个企业级全栈框架,结合 React 服务器组件和服务器操作
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一个一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
现在你已经完成了 Learn React Hooks, Second Edition,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。
你的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。


浙公网安备 33010602011771号