React-核心概念-全-

React 核心概念(全)

原文:zh.annas-archive.org/md5/751c075f5f8c25e4f99f8fc75bd4f4d2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

作为构建现代、交互式用户界面的最受欢迎的 JavaScript 库,React 是一个需求旺盛的框架,将为您的职业生涯或下一个项目带来真正的价值。但就像任何技术一样,学习 React 可能会有点棘手,找到合适的老师可以使事情变得容易得多。

Maximilian Schwarzmüller 是一位畅销书作者,他帮助全球超过三百万名学生学习如何编程,他的最新 React 视频课程(React—The Complete Guide)在 Udemy 上有超过八十万名学生。

Max 编写了这本深入浅出的参考书,旨在帮助您掌握 React 编程的世界。简单的解释、相关的示例和清晰、简洁的方法使这本快速指南成为忙碌开发者的理想资源。

本书提炼了 React 的核心概念,并以其整洁的总结将关键特性汇集在一起,因此完美地补充了其他深入教学资源。因此,无论您刚刚完成 Max 的 React 视频课程并正在寻找一个实用的参考工具,还是您已经使用过各种其他学习材料,现在需要一个单一的学习指南来整合所有内容,这本书都是您在下一个 React 项目中理想的伴侣。此外,本书完全更新至 React 19 版本,因此您可以确信您已经准备好使用最新版本。

本书面向对象

本书是为已经对 React 基础知识有一定了解的开发者设计的。它可以作为一个独立资源来巩固理解,也可以作为更深入课程的配套指南。为了从本书中获得最大价值,建议您对 JavaScript、HTML 和 CSS 的基本原理有所了解。

本书涵盖内容

第一章React – 是什么以及为什么,将重新介绍 React.js。假设 React.js 对您来说并不陌生,本章将阐明 React 解决的问题、存在的替代方案、React 通常的工作方式以及如何创建 React 项目。

第二章理解 React 组件和 JSX,将解释 React 应用的总体结构(组件树)以及如何在 React 应用中创建和使用组件。

第三章组件和属性,将确保您能够通过使用一个称为“属性”的关键概念来构建可重用组件。

第四章处理事件和状态,将涵盖如何在 React 组件中处理状态,有哪些不同的选项(单状态与多状态切片)以及如何执行和利用状态变化进行 UI 更新。

第五章渲染列表和条件内容,将解释 React 应用如何渲染内容列表(例如,用户帖子列表)和条件内容(例如,如果输入字段中输入了不正确的值,则发出警报)。

第六章样式化 React 应用,将阐明 React 组件如何被样式化,以及如何动态或条件性地应用样式,涉及流行的样式解决方案,如 vanilla CSS、Tailwind CSS、styled components 和 CSS modules 用于作用域样式。

第七章Portals 和 Refs,将解释如何通过 React 内置的“refs”功能实现直接的 DOM 访问和操作。此外,你还将学习如何使用 Portals 来优化渲染的 DOM 元素结构。

第八章处理副作用,将讨论useEffect钩子,解释它是如何工作的,如何根据不同的用例和场景进行配置,以及如何使用这个 React 钩子最优地处理副作用。

第九章使用表单操作处理用户输入和表单,将探讨 React 如何通过允许你定义在提交时触发的客户端表单操作来简化处理表单的过程。

第十章React 幕后和优化机会,将深入了解 React 的幕后,并深入探讨核心主题,如虚拟 DOM、状态更新批处理和关键优化技术,这些技术有助于你避免不必要的重新渲染周期(从而提高性能)。

第十一章处理复杂状态,将解释高级 React 钩子useReducer是如何工作的,何时以及为什么你可能想要使用它,以及如何在 React 组件中使用它来管理更复杂的组件状态。此外,还将深入探讨和讨论 React 的 Context API,让你能够轻松地管理全局状态。

第十二章构建自定义 React 钩子,将在前几章的基础上,探讨你如何构建自己的自定义 React 钩子,以及这样做的好处是什么。

第十三章使用 React Router 构建多页应用,将解释 React Router 是什么,以及这个额外的库如何被用来在 React 单页应用程序中构建多页体验。

第十四章使用 React Router 管理数据,将深入探讨 React Router,并探索这个包如何帮助获取和管理数据。

第十五章使用 Next.js 构建服务器端渲染和全栈应用,将帮助你理解服务器端渲染SSR)的概念,并帮助你使用流行的 Next.js 框架结合你的 React 知识来构建跨越前后端的应用程序。

第十六章React 服务器组件和服务器操作,将基于构建全栈 React 应用的想法,并解释你如何在服务器端渲染组件和处理表单提交。

第十七章理解 React Suspense 和 use()钩子,将解释 React 如何通过在数据获取时显示回退内容来帮助你提供更好的用户体验。

第十八章下一步和进一步资源,将涵盖核心和扩展的 React 生态系统以及哪些资源可能对下一步有帮助。

本书还附带以下可下载的补充内容:

  • 每章都附带的速查表

  • 一段视频中,作者 Maximilian 在本书完成后为你提供了他的下一步建议

  • 一段视频中,作者 Maximilian 分享了他对 React 未来的看法

前言的末尾可以找到获取此内容的说明。

与本书保持同步

这本书的版本是在 React 19 发布时编写的,尽管本书中解释的大部分核心概念自 React 18 甚至更早以来就已经存在。因此,本书涵盖的大多数功能都可以被认为是极其稳定且在近期内不太可能发生变化的。

但本书也会涵盖一些相对较新的 React 功能,如服务器组件或服务器操作。虽然这些概念的大幅更改也不太可能,但已经创建了一个 GitHub 文档来跟踪你在阅读本书时应注意的任何更正或偏差:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/main/CHANGELOG.md

跟随本书学习

在你能够成功在你的系统上创建和运行 React.js 项目之前,你需要确保你已经安装了Node.jsnpm(默认包含在你的安装中)。

这些可以在nodejs.org/en/下载。

本站点的首页应自动为你提供适用于你平台和系统的最新安装选项。更多选项,请选择网站导航栏中的下载。这将打开一个新页面,你可以通过该页面探索所有主要平台的安装选择,如下面的截图所示:

img

安装 React.js

React.js 项目可以通过多种方式创建,包括包含 webpack、babel 和其他工具的自定义项目设置。本书推荐的方式是使用 Vite 工具。这个工具以及创建 React 应用的流程将在第一章React – 什么是以及为什么中介绍,但你也可以参考本节以获取此任务的逐步说明。

执行以下步骤在你的系统上创建 React.js 项目:

  1. 打开你的终端(Windows 的 Powershell/命令提示符;Linux 的 bash)。

  2. 使用创建目录命令创建一个名为你选择的新的项目文件夹(例如,mkdir react-projects),然后使用更改目录命令(例如,cd react-projects)进入该目录。

  3. 在此文件夹内创建新项目目录的命令如下:

    npm create vite@latest my-app 
    

运行此命令后,当提示输入时,请选择ReactJavaScript

  1. 完成后,使用cd命令导航到您的新的目录:

    cd my-app 
    
  2. 在这个新项目目录中打开终端窗口,并运行以下命令来安装所有必需的依赖项:

    npm install 
    
  3. 完成此命令后,在相同的终端中运行以下命令以启动 Node.js 开发服务器:

    npm run dev 
    
  4. 此命令输出一个您可以访问以预览 React 应用程序的服务器地址。默认地址是http://localhost:5173。在地址/位置栏中输入该地址以导航到localhost:5173,如下面的截图所示:

img

  1. 当您准备暂时停止开发时,在步骤 5中的同一终端中使用Ctrl + C来退出正在运行的服务器。要重新启动它,只需在该终端中再次运行npm run dev命令。在开发过程中,保持npm run dev启动并运行,因为它会自动更新加载在localhost:5173上的网站,以反映您所做的任何更改。

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/mschwarzmueller/book-react-key-concepts-e2。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781836202271

使用的约定

在本书中使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“一旦定义了根入口点,就可以在通过createRoot()创建的root对象上调用名为render()的方法。”

代码块按以下方式设置:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

**import** **{ memo }** **from****'react'****;**
import classes from './Error.module.css';
function Error({ message }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
}
export default **memo****(****Error****);** 

任何命令行输入或输出都按以下方式编写:

npm create vite@latest my-react-project 

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“React 通过从命令式方法转换为声明式方法,简化了此类 UI 的创建和管理。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata ,点击提交勘误,并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《React 核心概念,第二版》,我们很乐意听听您的想法!请点击此处直接进入亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载免费 PDF 和补充内容

感谢您购买这本书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

此外,通过这本书,您还可以获得补充/额外内容,以便您了解更多。您可以使用这些内容来补充您在书中学到的知识。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接:

img

packt.link/supplementary-content-9781836202271

  1. 提交您的购买证明。

  2. 提交您的书籍代码。您可以在书的第 169 页找到代码。

  3. 就这些!我们将直接将您的免费 PDF、补充内容和其它福利发送到您的邮箱

补充内容的描述

这本书附带以下额外材料(可通过上述机制获取):

  • 每章都附带的速查表

  • 一段作者 Maximilian 在您完成这本书后为您提供的下一步推荐视频

  • 一段作者 Maximilian 分享他对 React 未来看法的视频

第一章:React – 什么是 React 以及为什么使用它

学习目标

到本章结束时,你将能够做到以下内容:

  • 描述 React 是什么以及为什么你会使用它

  • 将 React 与仅使用 JavaScript 构建的 Web 项目进行比较

  • 解释命令式和声明式代码之间的区别

  • 区分单页应用程序SPAs)和多页应用程序

  • 创建新的 React 项目

简介

React.js(或简称React,本书中也将这样称呼,并将在大多数情况下使用此称呼)是最受欢迎的前端 JavaScript 库之一——根据 2023 年 Stack Overflow 开发者调查,可能甚至是最受欢迎的一个。目前,它被前 1000 个顶级网站中的超过 5% 使用,与其他流行的前端 JavaScript 库和框架(如 Angular)相比,React 在关键指标(如通过 npm 的每周包下载量)上领先巨大,npm 是一个常用于下载和管理 JavaScript 包的工具。

虽然当然可以在不完全理解 React 的工作原理以及为什么使用它的前提下编写好的 React 代码,但你可能会更快地学习高级概念,并在尝试理解你正在使用的工具以及最初选择该工具的原因时避免错误。

因此,在考虑其核心概念和理念或审查示例代码之前,你首先需要了解 React 究竟是什么以及为什么它存在。这将帮助你理解 React 内部是如何工作的以及为什么它提供了这些功能。

如果你已经知道为什么你使用 React,为什么像 React 这样的解决方案通常会被用于代替纯 JavaScript(即没有框架或库的 JavaScript,更多内容将在下一节中介绍),以及 React 及其语法的理念是什么,你当然可以跳过这一节,直接跳到本书后面更注重实践的章节。

但如果你只是认为自己了解它,并且并不完全确定,你绝对应该首先阅读这一章。

什么是 React?

React 是一个 JavaScript 库,如果你查看官方网页(官方 React 网站和文档可在以下链接找到:react.dev/),你会了解到创造者称其为“用于 Web 和原生用户界面的库。”

但这究竟意味着什么?

首先,重要的是要理解 React 是一个 JavaScript 库。作为本书的读者,你知道 JavaScript 是什么以及为什么你在浏览器中使用 JavaScript。JavaScript 允许你在页面加载后添加交互性,因为你可以通过 JavaScript 对用户事件做出反应并操作页面。这非常有价值,因为它允许你构建高度交互的 Web 用户界面UIs)。

但“库”是什么?React 又是如何帮助构建用户界面的?

虽然你可以就库是什么(以及它与框架的区别)进行哲学讨论,但库的实用定义是它是一组你可以用于代码中的功能,以实现通常需要更多代码和工作的结果。库可以帮助你编写更简洁的代码,可能也更不容易出错,并使你能够更快地实现某些功能。

React 就是这样的一个库——它专注于提供帮助你创建交互性和响应性 UI 的功能。确实,React 不仅处理 Web 界面(即浏览器中加载的网站),你还可以使用 React 和 React Native(这是一个在底层使用 React 的库)为移动设备构建原生应用。本书中涵盖的 React 概念,无论选择哪个目标平台都适用。但示例将专注于 React Web 浏览器。不过,无论你针对哪个平台,仅使用 JavaScript 创建交互式 UI 可能会迅速变得非常复杂和令人不知所措。

“纯 JavaScript”的问题

纯 JavaScript 是 Web 开发中常用术语,指的是没有框架或库的 JavaScript。这意味着你将所有 JavaScript 代码都自己编写,而不依赖任何提供额外实用功能的库或框架。当使用纯 JavaScript 时,你尤其不使用像 React 或 Angular 这样的主要前端框架或库。

使用纯 JavaScript 通常具有这样的优势,即网站的访问者需要下载的 JavaScript 代码更少(因为主要的框架和库通常相当庞大,并且可以快速添加 50+ KB 的额外 JavaScript 代码,这些代码必须下载)。

依赖于纯 JavaScript 的缺点是,作为开发者,你必须从头开始自己实现所有功能。这可能会导致错误,并且非常耗时。因此,特别是对于更复杂的 UI 和网站,使用纯 JavaScript 很快就会变得非常难以管理。

React 通过从命令式方法转向声明式方法来简化此类 UI 的创建和管理。尽管这是一个不错的句子,但如果之前没有使用过 React 或类似框架,可能会很难理解。为了理解它,了解“命令式与声明式方法”背后的理念,以及为什么你可能想使用 React 而不是仅仅使用纯 JavaScript,退一步评估纯 JavaScript 的工作方式是有帮助的。

让我们看看一个简短的代码片段,展示你如何使用纯 JavaScript 处理以下 UI 操作:

  1. 为按钮添加事件监听器以监听click事件。

  2. 一旦点击按钮,就用新文本替换段落中的文本。

    const buttonElement = document.querySelector('button');
    const paragraphElement = document.querySelector('p');
    function updateTextHandler() {
      paragraphElement.textContent = 'Text was changed!';
    }
    buttonElement.addEventListener('click', updateTextHandler); 
    

这个例子故意保持简单,所以它看起来可能并不太糟糕或令人不知所措。它只是一个基本的例子,用来展示代码通常是如何用纯 JavaScript 编写的(稍后会讨论一个更复杂的例子)。但即使这个例子很容易理解,使用纯 JavaScript 处理功能丰富的 UI 以及相应处理各种用户交互的代码也会很快达到其极限。代码可以迅速增长,因此维护它可能成为一个挑战。

在前面的例子中,代码是用纯 JavaScript 编写的,并且是命令式的。这意味着你一条条地写下指令,并详细描述需要采取的每一步。

之前显示的代码可以翻译成以下更易于阅读的指令:

  1. 寻找页面上的第一个button类型的HTML元素以获取对该按钮的引用。

  2. 创建一个名为buttonElement的常量(即数据容器),其中包含按钮引用。

  3. 重复步骤 1,但获取类型为p的第一个元素的引用。

  4. 将段落元素引用存储在名为paragraphElement的常量中。

  5. buttonElement添加一个事件监听器,该监听器监听click事件,并在发生此类click事件时触发updateTextHandler函数。

  6. updateTextHandler函数内部,使用paragraphElement将其textContent设置为"Text was changed!"

你是否看到每个需要采取的步骤都在代码中被清晰地定义和写出?

这并不令人惊讶,因为大多数编程语言都是这样工作的:你定义一系列必须按顺序执行的步骤。这是一个很有意义的做法,因为代码执行的顺序不应该随机或不可预测。

然而,当与 UI 一起工作时,这种命令式方法并不理想。实际上,它可能会很快变得繁琐,因为作为开发者,你必须添加很多指令,尽管这些指令增加的价值很少,但它们不能简单地被省略。你需要编写所有允许你的代码与元素交互、添加元素、操作元素等的文档对象模型DOM)指令。

因此,你的核心业务逻辑(例如,在点击后推导和定义应设置的文本)通常只占整体代码的一小部分。当用 JavaScript 控制和管理 Web UI 时,大量的代码(通常是大多数)经常由 DOM 指令、事件监听器、HTML 元素操作和 UI 状态管理组成。

因此,你最终需要描述所有与 UI 技术交互所需的步骤,以及所有推导输出数据(即 UI 的期望最终状态)所需的步骤。

注意

本书假设你已经熟悉 DOM。简而言之,DOM 是你 JavaScript 代码和想要与之交互的网站 HTML 代码之间的“桥梁”。通过内置的DOM API,JavaScript 能够创建、插入、操作、删除和读取 HTML 元素及其内容。

你可以在这篇文章中了解更多关于 DOM 的信息:academind.com/tutorials/what-is-the-dom

现代 Web UI 通常相当复杂,幕后有很多交互性。你的网站可能需要在输入字段中监听用户输入,将输入的数据发送到服务器进行验证,在屏幕上输出验证反馈消息,如果提交了错误数据,则显示错误覆盖模态。

按钮点击示例在一般情况下并不是一个复杂的例子,但实现此类场景的原生 JavaScript 代码可能会让人感到压倒性。你最终会进行大量的 DOM 选择、插入和操作操作,以及多行代码,这些代码除了管理事件监听器之外什么都不做。此外,保持 DOM 更新,不引入错误或 bug,可能是一个噩梦,因为你必须确保在正确的时间更新正确的 DOM 元素和正确的值。在这里,你可以找到描述的使用案例的一些示例代码的截图。

注意

完整的、可工作的代码可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/01-what-is-react/examples/example-1/vanilla-javascript

如果你查看截图中的 JavaScript 代码(或链接的仓库),你可能会想象出一个更复杂的 UI 可能看起来是什么样子。

计算机程序截图 自动生成描述

图 1.1:一个包含超过 100 行代码的示例 JavaScript 代码文件,用于一个相当简单的 UI

这个示例 JavaScript 文件已经包含了大约 110 行代码。即使经过压缩(“压缩”意味着代码会自动缩短,例如通过用较短的变量名替换较长的变量名和删除冗余空格;在这种情况下,通过www.toptal.com/developers/javascript-minifier)并随后将代码拆分到多行(以计算原始代码行数),它仍然有大约 80 行代码。这是一个简单 UI 的完整 80 行代码,只具有基本功能。实际的业务逻辑(即输入验证、确定何时显示覆盖层以及定义输出文本)只占整个代码库的一小部分——在这个例子中大约 20 到 30 行代码,经过压缩后大约 20 行。

这大约是 75%的代码用于纯 DOM 交互、DOM 状态管理和类似样板任务。

如这些示例和数字所示,控制所有 UI 元素及其不同状态(例如,信息框是否可见)是一项具有挑战性的任务,而仅使用 JavaScript 尝试创建此类界面通常会导致代码复杂,甚至可能包含错误。

正是因为在这种情况中,必须定义并写下每个单独步骤的命令式方法有其局限性,这就是为什么 React 提供了允许您以不同的方式编写代码的实用功能:使用声明式方法。

注意

这不是一篇科学论文,前面的例子也不是作为精确的科学研究的意图。根据您如何计算行数以及您认为哪种代码是“核心业务逻辑”,您最终会得到更高的或更低的百分比值。不过,关键信息并没有改变:大量的代码(在这种情况下,大部分)处理 DOM 和 DOM 操作——而不是定义您的网站及其关键功能的实际逻辑。

React 和声明式代码

回到之前的第一段简单代码片段,这里是相同的代码片段,这次使用 React:

import { useState } from 'react';
function App() {
  const [outputText, setOutputText] = useState('Initial text');
  function updateTextHandler() {
    setOutputText('Text was changed!');
  }
  return (
    <>
      <button onClick={updateTextHandler}>
        Click to change text
      </button>
      <p>{outputText}</p>
    </>
  );
} 

这个片段执行与第一个相同的操作,只是使用了纯 JavaScript:

  1. 向按钮添加事件监听器以监听click事件(现在带有一些 React 特定的语法:onClick={…})

  2. 一旦点击按钮,就用新文本替换段落的文本。

尽管如此,这段代码看起来完全不同——就像 JavaScript 和 HTML 的混合体。确实,React 使用一种名为JSX(即扩展 JavaScript 以包含类似 XML 的语法)的语法扩展。目前,只需理解这种 JSX 代码能够工作,是因为它包含在每个 React 项目构建工作流程中的预处理器(或转译)步骤。

预处理器意味着某些工具,它们是 React 项目的一部分,会在代码部署之前分析和转换代码。这允许使用仅适用于开发的语法,如 JSX,这在浏览器中无法工作,因此在部署之前将其转换为常规 JavaScript。(您将在第二章理解 React 组件和 JSX中详细了解 JSX。)

此外,之前显示的代码片段包含一个 React 特有的功能:状态。本书稍后将对状态进行更详细的讨论(第四章处理事件和状态,将专注于使用 React 处理事件和状态)。目前,您可以将其视为一个变量,当它改变时,将触发 React 更新浏览器中的 UI。

在前面的示例中,你所看到的是 React 使用的“声明式方法”:你编写你的 JavaScript 逻辑(例如,最终应该被执行的函数),并将该逻辑与触发它的或受它影响的 HTML 代码结合起来。你不需要编写选择某些 DOM 元素或更改某些 DOM 元素的文本内容的指令。相反,使用 React 和 JSX,你专注于你的 JavaScript 业务逻辑,并定义最终应该达到的期望 HTML 输出。这个输出可以,并且通常将,包含在主 JavaScript 代码内部推导出的动态值。

在前面的示例中,outputText 是由 React 管理的一些状态。在代码中,updateTextHandler 函数在点击时触发,并使用 setOutputText 函数将 outputText 状态值设置为一个新的字符串值('Text was changed!')。这里发生的确切细节将在 第四章 中探讨。

然而,总体思路是状态值发生变化,由于它在最后一段(<p>{outputText}</p>)中被引用,React 在实际的 DOM(以及因此,在实际的网页)中的那个位置输出当前状态值。React 将保持段落更新,因此,每当 outputText 发生变化时,React 将再次选择这个段落元素并自动更新其 textContent

这就是声明式方法的应用。作为一个开发者,你不需要担心技术细节(例如,选择段落并更新其 textContent)。相反,你将这项工作交给 React。你只需要关注期望的最终状态,目标仅仅是输出 outputText 的当前值在页面的特定位置(即在这个例子中的第二个段落)。这是 React 的任务,在幕后完成达到该结果的工作。

结果表明,这个代码片段并不比纯 JavaScript 的代码片段短;实际上,它甚至更长。但这仅仅是因为这个第一个片段被故意保持简单和简洁。在这种情况下,React 实际上添加了一些开销代码。如果那将是你的整个 UI,使用 React 确实没有太多意义。再次强调,这个片段被选择是因为它使我们能够一目了然地看到差异。如果你看一下之前更复杂的纯 JavaScript 示例,并将其与它的 React 选项进行比较,情况就会发生变化。

注意

参考代码可以在 GitHub 上找到,分别位于 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/01-what-is-react/examples/example-1/vanilla-javascriptgithub.com/mschwarzmueller/book-react-key-concepts-e2/tree/01-what-is-react/examples/example-1/reactjs

计算机程序截图,描述自动生成

图 1.2:之前的代码片段现在通过 React 实现

它仍然不短,因为所有的 JSX 代码(即,HTML 输出)都包含在 JavaScript 文件中。如果你几乎忽略那个截图的整个右侧(因为 HTML 也不是纯 JavaScript 文件的一部分),React 代码会变得更加简洁。然而,最重要的是,如果你仔细查看所有的 React 代码(也包括第一个较短的片段),你会注意到绝对没有选择 DOM 元素、创建或插入 DOM 元素或编辑 DOM 元素的操作。

这是 React 的核心思想。你不需要写下所有单独的步骤和指令;相反,你专注于“大局”和页面内容的期望最终状态。使用 React,你可以合并你的 JavaScript 和标记代码,而无需处理与 DOM 交互的低级指令,如通过 document.getElementById() 或类似操作选择元素。

使用这种声明式方法而不是纯 JavaScript 的命令式方法,可以让开发者专注于核心业务逻辑和 HTML 代码的不同状态。你不需要定义所有必须采取的单独步骤(如“添加事件监听器”、“选择段落”等),这极大地简化了复杂 UI 的开发。

注意

需要强调的是,如果你正在处理一个非常简单的 UI,React 并不是一个很好的解决方案。如果你可以用几行纯 JavaScript 代码解决问题,那么将 React 集成到项目中可能没有强烈的理由。

第一次看 React 代码时,它可能看起来非常陌生和奇怪。它不是你从 JavaScript 中习惯看到的样子。然而,它仍然是 JavaScript – 只是通过 JSX 功能和各种 React 特定功能(如状态)进行了增强。如果你记得你通常使用 HTML 定义你的 UI(即,你的内容和其结构),可能会更容易理解。你不会在那里写一步一步的指令,而是使用 HTML 标签创建一个嵌套的树结构。你通过使用不同的 HTML 元素和嵌套 HTML 标签来表达你的内容、不同元素的意义以及你 UI 的层次结构。

如果你记住这一点,那么“传统”的(纯 JavaScript)操作 UI 的方法看起来可能相当奇怪。如果你在 HTML 中根本不做这样的低级指令,比如“在这个按钮下方插入一个段落元素并设置其文本为<某些文本>”,你会怎么做呢?最终,React 恢复了 HTML 语法,这在定义内容和结构时更为方便。使用 React,你可以将动态 JavaScript 代码与受其影响的 UI 代码(即 HTML 代码)并排编写。

React 如何操作 DOM

如前所述,在编写 React 代码时,你通常按照前面所示的方式编写:通过使用 JSX 语法扩展将 HTML 与 JavaScript 代码混合。

值得指出的是,JSX 代码在浏览器中不会像这样运行。它需要在部署之前进行预处理。JSX 代码必须在发送到浏览器之前被转换成常规 JavaScript 代码。下一章将更详细地探讨 JSX 及其转换后的内容。不过,目前只需记住 JSX 代码必须被转换。

尽管如此,了解以下信息是很有价值的:JSX 将被转换成的代码也不会包含任何 DOM 指令。相反,转换后的代码将执行各种内置在 React 中的实用方法和函数(换句话说,那些由 React 包提供,需要添加到每个 React 项目中的函数)。在内部,React 创建了一个类似于虚拟 DOM 的树结构,反映了 UI 的当前状态。本书将更深入地探讨这个抽象的虚拟 DOM,以及 React 在第十章React 幕后和优化机会中的工作方式。这就是为什么 React(库)将其核心逻辑分布在两个主要包之间:

  • 主要的react

  • react-dom

主要的react包是一个第三方 JavaScript 库,需要将其导入到项目中才能使用 React 的功能(如 JSX 或状态)。是这个包创建了虚拟 DOM 并推导出当前的 UI 状态。但如果你想在项目中使用 React 操作 DOM,你还需要react-dom包。

react-dom包,特别是该包的react-dom/client部分,充当了 React 代码、内部生成的虚拟 DOM 以及需要更新的浏览器实际 DOM 之间的“翻译桥”。是react-dom包将生成实际的 DOM 指令,用于选择、更新、删除和创建 DOM 元素。

这种分割存在是因为你还可以在其他目标环境中使用 React。一个非常流行且广为人知的 DOM(即浏览器)的替代方案是 React Native,它允许开发者借助 React 构建原生移动应用。使用 React Native 时,你也会在你的项目中包含 react 包,但会使用react-native包代替react-dom。在这本书中,“React”既指react包也指“桥接”包(如react-dom)。

注意

如前所述,这本书主要关注 React 本身。因此,书中解释的概念将适用于网络浏览器、网站以及移动设备。尽管如此,所有示例都将关注网络和react-dom,因为这可以避免引入额外的复杂性。

介绍单页应用(SPAs)

React 可以用来简化复杂 UI 的创建,主要有两种方式:

  • 管理网站的部分(例如,左下角的聊天框)。

  • 管理整个页面以及在该页面上发生的所有用户交互。

这两种方法都是可行的,但更流行和常见的场景是第二种:使用 React 来管理整个网页,而不是仅仅管理其部分。这种方法之所以更受欢迎,是因为大多数具有复杂 UI 的网站在其页面上不仅有单个复杂元素,还有多个复杂元素。实际上,如果你开始使用 React 处理网站的部分区域而不处理其他区域,复杂性实际上会增加。因此,使用 React 来管理整个网站是非常常见的。

这甚至在使用 React 处理网站的一个特定页面后也不会停止。实际上,React 可以用来处理 URL 路径变化,并更新需要更新的页面部分,以反映应该加载的新页面。这种功能被称为路由,与 React 集成的第三方包(如react-router-dom,见第十三章,使用 React Router 的多页应用)允许你创建一个整个 UI 都通过 React 控制的网站。

一个不仅在其页面部分使用 React,而是对所有子页面和路由都使用 React 的网站通常被构建为单页应用(SPA),因为创建只包含一个 HTML 文件(通常命名为index.html)的 React 项目是很常见的,该文件用于最初加载 React JavaScript 代码。之后,React 库和你的 React 代码接管并控制实际的 UI。这意味着整个 UI 都是由 JavaScript 通过 React 和你的 React 代码创建和管理的。

话虽如此,构建全栈 React 应用也越来越受欢迎,其中前端和后端代码被合并。现代 React 框架如Next.js简化了构建此类 Web 应用的过程。虽然核心概念相同,无论构建哪种类型的应用,本书将在第十五章服务器端渲染与使用 Next.js 构建全栈应用、第十六章React 服务器组件和服务器操作以及第十七章理解 React Suspense 和 use() Hook 的使用中更详细地探讨全栈 React 应用开发。

最终,这本书为你准备在所有类型的 React 项目中使用 React,因为核心构建块和关键概念始终相同。

使用 Vite 创建 React 项目

要使用 React,第一步是创建一个 React 项目。官方文档建议使用像 Next.js 这样的框架。但对于复杂 Web 应用来说,这可能是有意义的,但对于 React 入门和探索 React 概念来说可能会感到压倒性。Next.js 和其他框架引入了它们自己的概念和语法。因此,学习 React 可能会很快变得令人沮丧,因为很难区分 React 功能和框架功能。此外,并非所有 React 应用都需要构建为全栈 Web 应用 – 因此,使用像 Next.js 这样的框架可能会增加不必要的复杂性。

正因如此,基于 Vite 的 React 项目已成为一个流行的替代方案。Vite是一个开源的开发和构建工具,可以用于创建和运行基于所有类型库和框架的 Web 开发项目 – React 只是众多选项之一。

Vite 创建的项目自带预配置的构建过程,在 React 项目中,它会处理 JSX 代码的转译。它还提供了一个本地运行的开发 Web 服务器,允许你在开发过程中预览 React 应用。

你需要一个这样的项目设置,因为 React 项目通常使用 JSX 这样的特性,如果没有先前的代码转换,这些特性在浏览器中是无法工作的。因此,正如之前提到的,需要一个预处理步骤。

要使用 Vite 创建项目,你必须安装 Node.js –最好是最新版(或最新LTS版)。你可以从nodejs.org/获取所有操作系统的官方 Node.js 安装程序。一旦安装了 Node.js,你也将获得内置的npm命令的访问权限,你可以使用它来利用 Vite 包创建一个新的 React 项目。

你可以在命令提示符(Windows)、bash(Linux)或终端(macOS)程序中运行以下命令。只需确保你已经导航(通过cd)到你想创建新项目的文件夹中:

npm create vite@latest my-react-project 

执行此命令后,系统将提示你选择你想要用于此新项目的框架或库。你应该选择 React 和 JavaScript。

此命令将在你运行它的位置创建一个新的子文件夹,其中包含基本的 React 项目设置(即,包含各种文件和文件夹)。你应该在系统中的某个路径上运行它,你拥有完整的读写权限,并且不会与任何系统或其他项目文件冲突。

值得注意的是,项目创建命令不会安装任何必需的依赖项,例如 React 库包。因此,你必须进入系统终端或命令提示符中创建的文件夹(通过 cd my-react-project)并运行以下命令来安装这些包:

npm install 

一旦安装成功,项目设置过程就完成了。

要查看创建的 React 应用程序,你可以在你的机器上通过此命令启动开发服务器:

npm run dev 

这将调用 Vite 提供的脚本,该脚本将启动一个本地运行的 Web 服务器,该服务器会预处理、构建并托管你的 React 驱动的 SPA - 默认为 localhost:5173。因此,在编写代码时,你通常需要保持这个开发服务器运行,因为它允许你预览和测试代码更改。

最好的是,这个本地开发服务器将自动更新网站,每当保存任何代码更改时,因此允许你几乎瞬间预览你的更改。

当你一天的工作完成时,你可以通过在执行 npm run dev 的终端或命令提示符中按 Ctrl + C 来退出此服务器。

当你准备好再次开始工作在项目上时,你可以通过 npm run dev 命令重新启动服务器。

注意

如果你在创建 React 项目时遇到任何问题,你也可以下载并使用以下起始项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/01-what-is-react/react-starting-project。这是一个通过 Vite 创建的项目,可以像使用前面的命令创建的项目一样使用。

当使用此起始项目(或者,实际上,任何属于此书的 GitHub 托管代码快照)时,你需要在项目文件夹中首先运行 npm install,然后再运行 npm run dev

具体的项目结构(即,文件名和文件夹名)可能会随时间而变化,但通常,每个新的基于 Vite 的 React 项目都包含一些关键文件和文件夹:

  • 一个 src/ 文件夹,其中包含项目的源代码文件:

    • 一个 main.jsx 文件,这是首先执行的入口脚本文件

    • 一个 App.jsx 文件,其中包含应用程序的根组件(你将在下一章中了解更多关于组件的内容)

    • 各种样式( *.css )文件,这些文件被 JavaScript 文件导入

    • 一个 assets/ 文件夹,可以用来存储在 React 代码中使用的图像或其他资产

  • 一个 public/ 文件夹,其中包含将成为最终网站一部分的静态文件(例如,一个图标)

  • 一个 index.html 文件,这是本网站的单一 HTML 页面

  • package.jsonpackage-lock.json 是列出和定义项目第三方依赖项的文件:

    • 生产依赖项,如 reactreact-dom

    • 开发依赖项,如 eslint 用于自动代码质量检查

  • 其他项目配置文件(例如,.gitignore 用于管理 Git 文件跟踪)

  • 一个 node_modules 文件夹,其中包含已安装的第三方包的实际代码

值得注意的是,App.jsxmain.jsx 使用 .jsx 作为文件扩展名,而不是 .js。这是一个由 Vite 强制执行的文件扩展名,用于包含标准 JavaScript 以及 JSX 代码的文件。当在 Vite 项目中工作时,大多数项目文件将相应地使用 .jsx 作为扩展名。

几乎所有的 React 特定代码都将写在 App.jsx 文件或将被添加到项目中的自定义组件文件中。我们将在下一章探讨组件。

注意

package.json 是你实际管理包及其版本的文件。package-lock.json 是自动创建的(由 Node.js 创建)。它锁定确切的依赖项和子依赖项版本,而 package.json 只指定版本范围。你可以在 docs.npmjs.com/ 上了解更多关于这些文件和包版本的信息。

项目依赖项的代码存储在 node_modules 文件夹中。由于它包含所有已安装包及其依赖项的代码,这个文件夹可能会变得非常大。因此,当项目与其他开发者共享或推送到 GitHub 时,通常不会包含它。你只需要 package.json 文件。通过运行 npm installnode_modules 文件夹将在本地重新创建。

摘要和关键要点

  • React 是一个库,尽管它实际上是两个主要包的组合:reactreact-dom

  • 虽然没有 React 也可以构建非平凡的 UI,但仅使用纯 JavaScript 来做这样的事情可能会很繁琐、容易出错且难以维护。

  • React 通过提供一种声明式的方式来定义 UI 的期望最终状态,简化了复杂 UI 的创建。

  • 声明式意味着你定义目标 UI 内容和结构,结合不同的状态(例如,“模态是打开还是关闭?”),然后将其留给 React 来确定适当的 DOM 指令。

  • 本身,react 包导出 UI 状态并管理虚拟 DOM。它是一个“桥梁”,类似于 react-domreact-native,将这个虚拟 DOM 转换为实际的 UI(DOM)指令。

  • 使用 React,你可以构建单页应用(SPAs),这意味着 React 用于控制所有页面上的整个 UI 以及页面间的路由。

  • 你还可以结合 Next.js 等框架使用 React 来构建全栈 Web 应用程序,其中服务器端和客户端代码是连接在一起的。

  • 可以使用 Vite 包来创建 React 项目,它提供了一个预先配置好的项目文件夹和一个实时预览的开发服务器。

接下来是什么?

到目前为止,你应该对 React 是什么以及为什么你可能考虑使用它有一个基本的了解,尤其是对于构建非平凡的用户界面。你学习了如何使用 Vite 创建新的 React 项目,现在你准备好更深入地了解 React 以及它提供的实际关键特性。

在下一章中,你将学习一个名为 组件 的概念,它们是 React 应用的基本构建块。你将了解组件是如何用来组合 UI 的,以及为什么最初需要这些组件。下一章还将更深入地探讨 JSX,并探索它如何被转换成常规 JavaScript 代码,以及你可以用其他什么类型的代码来替代 JSX。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后你可以将你的答案与这里可以找到的示例答案进行比较:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/01-what-is-react/exercises/questions-answers.md

  1. 什么是 React?

  2. React 相比纯 JavaScript 项目有哪些优势?

  3. 命令式代码和声明式代码有什么区别?

  4. 什么是 单页应用程序SPA)?

  5. 你如何创建新的 React 项目,为什么你需要这样一个复杂的项目设置?

加入我们的 Discord

与其他用户、AI 专家和作者本人一起阅读这本书。

提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,还有更多。

扫描二维码或访问链接加入社区。

packt.link/ReactKeyConcepts2e

img

第二章:理解 React 组件和 JSX

学习目标

到本章结束时,你将能够做到以下几点:

  • 定义组件的确切含义

  • 有效地构建和使用组件

  • 利用常见的命名约定和代码模式

  • 描述组件和 JSX 之间的关系

  • 编写 JSX 代码并理解其用途

  • 不使用 JSX 代码编写 React 组件

  • 编写你的第一个 React 应用程序

简介

在上一章中,你了解了 React 的基础知识,它是什么,以及为什么你应该考虑使用它来构建用户界面。你还学习了如何使用 Vite 创建 React 项目,通过运行 npm create vite@latest <your-project-name> 来实现。

在本章中,你将了解 React 最重要概念和构建块之一。你将了解到组件是可重用的构建块,用于构建用户界面。此外,还将更详细地讨论 JSX 代码,以便你能够使用组件和 JSX 的概念来构建你自己的第一个基本 React 应用程序。

组件是什么?

React 的一个关键概念是所谓的组件的使用。组件是可重用的构建块,它们被组合起来构成最终的用户界面。例如,一个基本的网站可以由包含导航项的侧边栏和一个包含添加和查看任务元素的主体部分组成。

img

图 2.1:一个包含侧边栏和主区域的示例任务管理屏幕

如果你查看这个示例页面,你可能能够识别出各种构建块(即组件)。其中一些组件甚至被重复使用:

  • 侧边栏及其导航项

  • 主页面区域

  • 在主区域,包含标题和截止日期的页眉

  • 添加任务的表单

  • 任务列表

请注意,一些组件嵌套在其他组件内部——即,组件也由其他组件组成。这是 React 和类似库的关键特性。

为什么需要组件?

无论你查看哪个网页,它们都是由这样的构建块组成的。这不是一个特定于 React 的概念或想法。实际上,如果你仔细观察,HTML 本身“认为”在组件中。你有一些元素,如 <img><header><nav> 等,你将这些元素组合起来描述和结构化你的网站内容。

但 React 接受将网页分解为可重用构建块的想法,因为这是一种允许开发者对小块、可管理的代码进行工作的方法。与处理单个、巨大的 HTML(或 React 代码)文件相比,这更容易且更易于维护。

因此,其他库——无论是前端库如 React 或 Angular,还是后端库和模板引擎如 EJS嵌入式 JavaScript 模板)——也接受组件(尽管名称可能不同,你也会找到 “部分”“包含” 作为常见名称)。

注意

EJS 是 JavaScript 的一个流行模板引擎。它特别适合与 Node.js 一起进行后端 Web 开发。

当使用 React 时,保持你的代码可管理并与小型、可重用组件一起工作尤为重要,因为 React 组件不仅仅是 HTML 代码的集合。相反,React 组件还封装了 JavaScript 逻辑,通常还封装了 CSS 样式。对于复杂的用户界面,标记(JSX)、逻辑(JavaScript)和样式(CSS)的组合可能会迅速导致大量代码,这使得维护这些代码变得困难。想象一下一个包含 JavaScript 和 CSS 代码的大型 HTML 文件。在这样的代码文件中工作不会很有趣。

简而言之,当你在 React 项目中工作时,你将处理很多组件。你会将你的代码分割成小而可管理的构建块,然后将这些组件组合起来形成整体的用户界面。这是 React 的一个关键特性。

注意

当使用 React 时,你应该接受这种与组件一起工作的想法。但从技术上讲,这是可选的。理论上,你可以仅使用一个组件构建非常复杂的网页。这不会很有趣,也不太实用,但从技术上讲,没有任何问题。

组件的解剖结构

组件很重要。但一个 React 组件究竟是什么样子?你如何自己编写 React 组件?

这里有一个示例组件:

import { useState } from 'react';
function SubmitButton() {
  const [isSubmitted, setIsSubmitted] = useState(false);

  function handleSubmit() {
    setIsSubmitted(true);
  };
  return (
    <button onClick={handleSubmit}>
      { isSubmitted ? 'Loading…' : 'Submit' }
    </button>
  );
};
export default SubmitButton; 

通常,你会将这样的代码片段存储在一个单独的文件中(例如,一个名为SubmitButton.jsx的文件,存储在/components文件夹中,该文件夹位于你的 React 项目的/src文件夹中),并将其导入需要此组件的其他组件文件中。.jsx被用作扩展名,因为该文件包含 JSX 代码。Vite 强制使用.jsx作为文件扩展名,如果你正在编写 JSX 代码——在 Vite 项目中不允许将此类代码存储在.js文件中(尽管在其他 React 项目设置中可能可行)。

以下组件导入上面定义的组件,并在其return语句中使用它来输出SubmitButton组件:

import SubmitButton from './submit-button.jsx';
function AuthForm() {
  return (
    <form>
      <input type="text" />
      <SubmitButton />
    </form>
  );
};
export default AuthForm; 

你在这些示例中看到的import语句是标准的 JavaScript import语句。理论上,在基于 Vite 的项目中,你可以在import语句中省略文件扩展名(在这个例子中是.jsx)。然而,包含扩展名可能是个好主意,因为这符合标准 JavaScript。当你从第三方包(如react包中的useState)导入时,不需要添加文件扩展名——你只需使用包名。importexport是标准的 JavaScript 关键字,有助于将相关代码分割到多个文件中。像变量、常量、类或函数这样的东西可以通过exportexport default导出,以便在导入后可以在其他文件中使用。

注意

如果将代码拆分为多个文件并使用importexport的概念对你来说完全陌生,你可能首先需要深入了解有关此主题的基本 JavaScript 资源。例如,MDN 有一篇优秀的文章解释了基础知识,您可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules找到。

当然,这些示例中展示的组件非常简化,也包含了一些你尚未学习到的功能(例如,useState())。然而,拥有可以组合的独立构建块的一般想法应该是清晰的。

当使用 React 时,有两种不同的方式来定义组件:

  • 基于类的组件(或“类组件”):通过class关键字定义的组件

  • 函数组件(或“函数组件”):通过常规 JavaScript 函数定义的组件

在本书涵盖的所有示例中,组件都是作为 JavaScript 函数构建的。作为一名 React 开发者,你必须使用这两种方法之一,因为 React 期望组件是函数或类。

注意

直到 2018 年底,你必须使用基于类的组件来完成某些类型的任务——特别是对于使用内部状态的组件。(状态将在第四章处理事件和状态中介绍)。然而,在 2018 年底,引入了一个新的概念:React Hooks。这允许你使用函数组件执行所有操作和任务。因此,尽管仍然由 React 支持,但基于类的组件正在逐渐淘汰,本书中不会涉及。

在上面的示例中,还有一些其他值得注意的事情:

  • 组件函数具有大写命名的名称(例如,SubmitButton

  • 在组件函数内部,可以定义其他“内部”函数(例如,handleSubmit,通常使用camelCase编写)

  • 组件函数返回类似HTML的代码(JSX 代码)

  • 类似于useState()这样的功能可以在组件函数内部使用

  • 组件函数通过export default导出

  • 某些功能(如useState或自定义组件SubmitButton)通过import关键字导入

以下章节将更深入地探讨构成组件及其代码的不同概念。

组件函数究竟是什么?

在 React 中,组件是函数(或类,但如上所述,那些不再相关了)。

函数是一个常规的 JavaScript 构造,不是一个 React 特定的概念。这一点很重要。React 是一个 JavaScript 库,因此使用 JavaScript 功能(如函数);React不是一个全新的编程语言

当使用 React 时,可以使用常规 JavaScript 函数来封装属于该标记代码的 HTML(或者更准确地说,是 JSX)代码和 JavaScript 逻辑。然而,一个函数是否可以被视为 React 组件,取决于你在这个函数中编写的代码。例如,在上面的代码片段中,handleSubmit 函数也是一个常规 JavaScript 函数,但它不是 React 组件。以下示例展示了另一个不符合 React 组件资格的常规 JavaScript 函数:

function calculate(a, b) {
  return {sum: a + b};
}; 

事实上,如果一个函数返回一个 可渲染 的值(通常是 JSX 代码),它将被视为一个组件,因此可以像 HTML 元素一样在 JSX 代码中使用。这非常重要。你只能在 JSX 代码中使用函数作为 React 组件,如果它返回的是 React 可以渲染的东西。返回的值在技术上不必是 JSX 代码,但在大多数情况下,它将是。你将在 第七章 中看到一个返回非 JSX 代码的例子,Portals 和 Refs

在定义名为 SubmitButtonAuthForm 的函数的代码片段中,这两个函数符合 React 组件的资格,因为它们都返回了 JSX 代码(这是 React 可以渲染的代码,使其可渲染)。一旦一个函数符合 React 组件的资格,它就可以像 HTML 元素一样在 JSX 代码中使用,就像 <SubmitButton /> 被用作(自闭合的)HTML 元素一样。

当使用纯 JavaScript 时,你当然通常调用函数来执行它们。对于函数组件,情况不同。React 代表你调用这些函数,因此,作为一个开发者,你将它们用作 JSX 代码中的 HTML 元素。

注意

当提到可渲染值时,值得注意的是,到目前为止,最常见返回或使用的值类型确实是 JSX 代码——即通过 JSX 定义的标记。这应该是有道理的,因为使用 JSX,你可以定义你内容和使用界面的类似 HTML 的结构。

除了 JSX 标记之外,还有一些其他关键值也符合可渲染的资格,因此也可以由自定义组件返回(而不是 JSX 代码)。最值得注意的是,你也可以返回字符串或数字,以及包含 JSX 元素、字符串或数字的数组。

React 都用这些组件做了些什么?

如果你追踪所有组件及其 importexport 语句到顶部,你将在 React 项目的入口脚本中找到一个 root.render(...) 指令。通常,这个主入口脚本可以在项目的 src/ 文件夹中的 main.jsx 文件中找到。这个由 React 库(确切地说,由 react-dom 包)提供的 render() 方法接受一段 JSX 代码,并为你解释和执行它。

你在根入口文件(main.jsx)中找到的完整代码片段通常如下所示:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); 

你在新的 React 项目中找到的确切代码可能看起来略有不同。

例如,它可能包含一个额外的 <StrictMode> 元素,该元素被 <App> 包围。<StrictMode> 会开启额外的检查,这有助于捕捉 React 代码中的细微错误。但这也可能导致令人困惑的行为和意外的错误信息,尤其是在实验 React 或学习 React 时。由于本书主要关注 React 核心功能和关键概念的覆盖,因此不会使用 <StrictMode>

虽然在这里省略了,但严格模式将在第十章“React 背后场景和优化机会”中介绍。如果你现在想了解更多关于它的信息,可以深入了解官方文档:react.dev/reference/react/StrictMode。但请注意,在阅读更多本书内容之后,严格模式触发的某些效果将更容易理解。

因此,为了顺利地跟随,清理一个新创建的 main.jsx 文件,使其看起来像上面的代码片段是一个好主意。

createRoot() 方法指示 React 创建一个新的 入口点,该入口点将用于将生成的用户界面注入要提供给网站访问者的实际 HTML 文档中。因此传递给 createRoot() 的参数是指向可以在 index.html 中找到的 DOM 元素的指针——这是将被提供给网站访问者的单个页面。

在许多情况下,document.getElementById('root') 被用作参数。这个内置的纯 JavaScript 方法返回一个指向 index.html 文档中已存在的 DOM 元素的引用。因此,作为开发者,你必须确保具有提供的 id 属性值(在这个例子中是 root)的此类元素存在于加载 React 应用脚本的那个 HTML 文件中。在一个通过 npm create vite@latest 创建的默认 React 项目中,情况就是这样。你可以在根项目文件夹中的 index.html 文件中找到一个 <div id="root"> 元素。

这个 index.html 文件是一个相对空白的文件,它仅作为 React 应用的外壳。React 只需要一个入口点(通过 createRoot() 定义),该入口点将用于将生成的用户界面附加到显示的网站上。因此,HTML 文件及其内容并不直接定义网站内容。相反,该文件仅作为 React 应用的起点,允许 React 然后接管并控制实际的用户界面。

一旦定义了根入口点,就可以在通过 createRoot() 创建的 root 对象上调用 render() 方法:

root.render(<App />); 

这个 render() 方法告诉 React 应将哪个内容(即哪个 React 组件)注入到该根入口点中。在大多数 React 应用中,这是一个名为 App 的组件。React 将生成适当的 DOM 操作指令,以反映 App 组件在网页上通过 JSX 定义的标记。

这个App组件是一个从其他文件导入的组件函数。在一个默认的 React 项目中,App组件函数在App.jsx文件中定义并导出,该文件也位于src/文件夹中。

这个传递给render()(通常是<App />)的组件也被称为 React 应用的根组件。它是渲染到 DOM 中的主要组件。所有其他组件都嵌套在App组件的 JSX 代码中,或者嵌套在更多嵌套的子组件的 JSX 代码中。你可以将这些组件视为由 React 评估并转换为实际 DOM 操作指令的组件树。

产品图描述自动生成

图 2.2:嵌套的 React 组件形成一个组件树

注意

如前一章所述,React 可以在各种平台上使用。使用react-native包,它可以用来为 iOS 和 Android 构建原生移动应用。react-dom包,它提供了createRoot()方法(因此隐式地提供了render()方法),专注于浏览器。它提供了 React 的能力和浏览器指令之间的“桥梁”,这些指令是必需的,以便在浏览器中将 UI(通过 JSX 和 React 组件描述)呈现出来。如果你为不同的平台构建,则需要ReactDOM.createRoot()render()的替代品(当然,这样的替代品确实存在)。

无论哪种方式,无论你是否在 JSX 代码中像 HTML 元素一样使用组件函数,或者将其作为参数传递给render()方法,React 都会为你处理解释和执行组件函数。

当然,这并不是一个新概念。在 JavaScript 中,函数是一等对象,这意味着你可以将函数作为参数传递给其他函数。这里基本上就是这样做的,只是额外地使用了这种 JSX 语法,这不是 JavaScript 的默认功能。

React 为你执行这些组件函数,并将返回的 JSX 代码转换为 DOM 指令。更准确地说,React 遍历返回的 JSX 代码,并深入到该 JSX 代码中可能使用的任何其他自定义组件,直到它最终得到只由原生、内置 HTML 元素组成的 JSX 代码(技术上,它并不是真正的 HTML,但这将在本章后面的内容中讨论)。

以这两个组件为例:

function Greeting() {
  return <p>Welcome to this book!</p>;
};
function App() {
  return (
    <div>
      <h2>Hello World!</h2>
      <Greeting />
    </div>
  );
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); 

App组件在其 JSX 代码中使用了Greeting组件。React 会遍历整个 JSX 标记结构,并推导出以下最终的 JSX 代码:

root.render((
  <div>
    <h2>Hello World!</h2>
    <p>Welcome to this book!</p>
  </div>
), document.getElementById('root')); 

这段代码将指示 React 和 ReactDOM 执行以下 DOM 操作:

  1. 创建一个<div>元素

  2. 在那个<div>内部,创建两个子元素:<h2><p>

  3. <h2>元素的文本内容设置为'Hello World!'

  4. <p> 元素的文本内容设置为 '欢迎来到这本书!'

  5. <div> 及其子元素插入到已经存在的 DOM 元素中,该元素的 ID 为 'root'

这有点简化,但你可以将 React 处理组件和 JSX 代码的方式想象成上述描述的那样。

注意

React 实际上并不在内部使用 JSX 代码。它只是对开发者来说更容易使用。在本章的后面,你将学习 JSX 代码会被转换成什么,以及 React 实际上使用的代码是什么样的。

内置组件

如前例所示,你可以通过创建返回 JSX 代码的函数来创建自己的自定义组件。确实,作为 React 开发者,你将一直要做的主要事情之一就是创建组件函数——大量的组件函数。

但,最终,如果你要将所有 JSX 代码合并成一个大片段,就像在最后一个例子中展示的那样,你将得到一个只包含标准 HTML 元素(如 <div><h2><p> 等)的 JSX 代码块。

当使用 React 时,你不会创建浏览器能够显示和处理的全新 HTML 元素。相反,你创建的组件 仅在 React 环境中工作。在它们到达浏览器之前,它们已经被 React 评估并“转换”成 DOM 操作的 JavaScript 指令(如 document.append(…))。

但请记住,所有这些 JSX 代码都是 React 库和用于编写 React 代码的项目设置提供的一个特性,而不是 JavaScript 语言本身的一部分。它基本上是 语法糖(即关于代码语法的简化)的提供。因此,当在 JSX 代码中使用 <div> 等元素时,它们也不是常规的 HTML 元素,因为你 没有编写 HTML 代码。它看起来可能像那样,但它是在 .jsx 文件中,并且它不是 HTML 标记。相反,它是这种特殊的 JSX 代码。这一点很重要。

因此,在这些所有例子中看到的 <div><h2> 元素最终也只是 React 组件。但它们不是你构建的组件,而是由 React(或更准确地说,由 ReactDOM)提供的。

当使用 React 时,你最终总是得到这些原语——这些内置的组件函数,这些函数最终会被转换成浏览器指令,用于生成和附加或删除常规 DOM 元素。构建自定义组件背后的想法是将这些元素组合在一起,这样你最终得到的是可重用的构建块,可以用来构建整体 UI。但是,最终,这个 UI 是由常规 HTML 元素组成的。

注意

根据你对前端 Web 开发知识的了解程度,你可能已经听说过一个名为 Web Components 的 Web 特性。这个特性的想法是,你确实可以使用纯 JavaScript 构建全新的 HTML 元素。

如前所述,React 并不识别这个特性;你不会用 React 构建新的自定义 HTML 元素。

命名约定

本书中所能找到的所有组件函数的名称都像 SubmitButtonAuthFormGreeting 这样。

你可以随意命名你的 React 函数——至少在你定义它们的文件中是这样。但使用 PascalCase 命名约定是一种常见的惯例,其中第一个字符是大写,多个单词组合成一个单词(例如 SubmitButton 而不是 Submit Button),然后每个“子词”都以另一个大写字母开头。

在定义你的组件函数的地方,这只是一种命名约定,而不是一个硬性规则。然而,在你使用组件函数的地方——即嵌入你自定义组件的 JSX 代码中——这是一个硬性规则。

你不能像这样将自定义组件函数用作组件:

<greeting /> 

React 强制你在 JSX 代码中使用大写字母开头来自定义组件名称。这个规则存在是为了让 React 有一个清晰且简单的方法来区分自定义组件和内置组件(如 <div> 等)。React 只需要查看起始字符就能确定它是一个内置元素还是一个自定义组件。

除了实际组件函数的名称外,了解文件命名约定也很重要。自定义组件通常存储在 src/components/ 文件夹内的单独文件中。然而,这并不是一个硬性规则。确切的放置位置以及文件夹名称由你决定,但应该在 src/ 文件夹内。使用名为 components/ 的文件夹是标准做法。

虽然使用 PascalCase 命名组件函数是标准,但对于文件名来说并没有一个通用的默认约定。一些开发者也倾向于使用 PascalCase 命名文件;实际上,按照本书描述创建的新 React 项目中,App 组件可以在名为 App.jsx 的文件中找到。然而,你也会遇到许多 React 项目,其中组件存储在遵循 kebab-case 命名约定的文件中。(所有小写字母和多个单词通过连字符合并成一个单词)。按照这种约定,组件函数可以存储在名为 submit-button.jsx 的文件中,例如。

最终,选择哪种文件命名约定取决于你(以及你的团队)。在这本书中,我们将使用 PascalCase 来命名文件。

JSX 与 HTML 与纯 JavaScript

如上所述,React 项目通常包含大量的 JSX 代码。大多数自定义组件将返回 JSX 代码片段。你可以在迄今为止分享的所有示例中看到这一点,你将在探索的几乎所有 React 项目中看到这一点,无论你是使用 React 为浏览器还是其他平台如 react-native

但 JSX 代码究竟是什么?它与 HTML 有何不同?它与纯 JavaScript 有何关联?

JSX 是一个不属于纯 JavaScript 的功能。但令人困惑的是,它也不是 React 库的直接部分。

相反,JSX 是构建工作流程提供的一种语法糖,它是 React 项目整体的一部分。当你通过npm run dev启动开发 Web 服务器或通过npm run build构建 React 应用程序以进行生产(即部署)时,你启动了一个将此 JSX 代码转换回常规 JavaScript 指令的过程。作为开发者,你不会看到那些最终指令,但 React 库实际上接收并评估它们。

那么,JSX 代码会被转换成什么?

在现代 React 项目中,它被转换成相当复杂、不直观的代码,看起来像这样:

function Ld() {
  return St.jsx('p', { children: 'Welcome to this book!' });
} 

当然,这段代码对开发者来说并不友好。这不是你想要编写的代码类型。相反,这是 Vite(即底层构建过程)为浏览器执行生成的代码。

但从理论上讲,你可以用这样的代码代替 JSX——如果你出于某种原因想避免编写 JSX 代码。React 有一个内置的方法可以用来代替 JSX:你可以使用 React 的createElement(…)方法。

这里有一个具体的例子,首先是 JSX:

function Greeting() {
  return <p>Hello World!</p>;
}; 

如果你不想使用 JSX,你也可以这样编写组件代码:

function Greeting() {
  return React.createElement('p', {}, 'Hello World!');
}; 

createElement() 是 React 库中内置的一个方法。它指示 React 创建一个段落元素,其子内容为'Hello World!'(即作为内部、嵌套内容)。然后这个段落元素首先在内部创建(通过一个称为虚拟 DOM的概念,本书将在第十章React 和优化机会背后的场景中稍后讨论)。之后,一旦所有 JSX 元素的所有元素都已创建,虚拟 DOM 将被转换为浏览器执行的真正 DOM 操作指令。

注意

之前已经提到,React(在浏览器中)实际上是由两个包组成的:reactreact-dom

随着React.createElement(…)的引入,现在更容易解释这两个包是如何协同工作的:React 在内部创建这个虚拟 DOM,然后将其传递给react-dom包。然后这个包生成必须执行的真正 DOM 操作指令,以便更新网页,显示所需的用户界面。

正如提到的,这将在第十章中更详细地介绍。

中间参数值({},在示例中)是一个 JavaScript 对象,它可能包含要创建的元素的额外配置。

这里有一个例子,其中这个中间论点变得很重要:

function Advertisement() {
  return <a href="https://my-website.com">Visit my website</a>;
}; 

这将被转换为以下内容:

function Advertisement() {
  return React.createElement(
    'a',
    { href: ' https://my-website.com ' },
    'Visit my website' 
  );
}; 

传递给 React.createElement(…) 的最后一个参数是元素的子内容——即应该在元素的开标签和闭标签之间的内容。对于嵌套的 JSX 元素,将生成嵌套的 React.createElement(…) 调用:

function Alert() {
  return (
    <div>
      <h2>This is an alert!</h2>
    </div>
  );
}; 

这将被转换成这样:

function Alert() {
  return React.createElement(
    'div', {}, React.createElement('h2', {}, 'This is an alert!')
  );
}; 

不使用 JSX 使用 React

由于所有 JSX 代码最终都会被转换成这些原生 JavaScript 方法调用,因此你实际上可以使用 React 而不使用 JSX 来构建 React 应用和用户界面。

如果你愿意,完全可以跳过 JSX。你不需要在你的组件和 JSX 期望的所有地方编写 JSX 代码,你只需简单地调用 React.createElement(…) 即可。

例如,以下两个代码片段将在浏览器中产生完全相同的外观界面:

function App() {
  return (
    <p>Please visit my <a href="https://my-blog-site.com">Blog</a></p>
  );
}; 

前面的代码片段最终将与以下代码相同:

function App() {
  return React.createElement(
    'p',
    {},
    [
      'Please visit my ',
      React.createElement(
        'a',
        { href: 'https://my-blog-site.com' },
        'Blog'
      )
    ]
  );
}; 

当然,你是否想这样做是另一个问题。正如你在本例中可以看到的,仅仅依赖 React.createElement(…) 会非常繁琐。你最终会写很多代码,深度嵌套的元素结构会导致代码几乎无法阅读。

这就是为什么,通常,React 开发者使用 JSX。这是一个使使用 React 构建用户界面变得更加愉快的出色功能。但重要的是要理解,它既不是 HTML 也不是纯 JavaScript 功能,而是一种在幕后转换为函数调用的语法糖。

JSX 元素被当作常规 JavaScript 值处理

因为 JSX 只是一种会被转换的语法糖,所以有一些值得注意的概念和规则你应该知道:

  • JSX 元素最终只是 常规 JavaScript 值(更准确地说,是函数)。

  • 适用于所有 JavaScript 值的规则也适用于 JSX 元素。

  • 因此,在只期望一个值的地方(例如,在 return 关键字之后),你只能有一个 JSX 元素。

这段代码会导致错误:

function App() {
  return (
    <p>Hello World!</p>
    <p>Let's learn React!</p>
  );
}; 

这段代码可能看起来是有效的,但实际上是错误的。在这个例子中,你会返回两个值而不是一个。在 JavaScript 中这是不允许的。

例如,以下非 React 代码也将无效:

function calculate(a, b) {
  return (
    a + b
    a - b
  );
}; 

你不能返回超过一个值。无论你如何写它。

当然,你可以返回一个数组或一个对象。例如,以下代码将是有效的:

function calculate(a, b) {
  return [
    a + b,
    a - b
  ];
}; 

这将是有效的,因为你只返回一个值:一个数组。这个数组包含多个值,就像数组通常那样。这将是可行的,如果你使用 JSX 代码也是同样的情况:

function App() {
  return [
    <p>Hello World!</p>,
    <p>Let's learn React!</p>
  ];
}; 

这种类型的代码将被允许,因为你返回了一个包含两个元素的数组。在这种情况下,这两个元素是 JSX 元素,但如前所述,JSX 元素只是常规 JavaScript 值。因此,你可以在任何期望值的地方使用它们。

当使用 JSX 时,你不会经常看到这种数组方法——仅仅是因为通过方括号包装 JSX 元素可能会变得令人烦恼,而且它看起来也不太像 HTML,这有点违背了 JSX 的目的和核心思想(它被发明出来是为了允许开发者在 JavaScript 文件中编写 HTML 代码)。

相反,如果需要兄弟元素,就像这些例子中一样,会使用一种特殊的包装组件:一个 React 片段。这是一个内置组件,其目的是允许你返回或定义兄弟 JSX 元素:

function App() {
  return (
    <>
      <p>Hello World!</p>
      <p>Let's learn React!</p>
    </>
  );
}; 

这个特殊的<>…</>元素在大多数现代 React 项目中都是可用的(例如,通过 Vite 创建的项目),你可以将其想象为在幕后用数组包装你的 JSX 元素。或者,你也可以使用<React.Fragment>…</React.Fragment>。由于一些 React 项目可能不支持较短的<>…</>语法,这个内置组件始终可用。

在所有这些例子中围绕 JSX 代码的括号(())是必需的,以便允许进行多行格式化。技术上,你可以将所有的 JSX 代码放在一行中,但那样会非常难以阅读。为了将 JSX 元素拆分到多行,就像你在.html文件中通常对常规 HTML 代码所做的那样,你需要那些括号;它们告诉 JavaScript 返回值的开始和结束位置。

由于 JSX 元素是常规 JavaScript 值(至少在构建过程中被转换后),你还可以在所有可以使用值的地方使用 JSX 元素。

到目前为止,所有这些return语句都是这样,但你也可以将 JSX 元素存储在变量中或将它们作为参数传递给其他函数:

function App() {
  const content = <p>Stored in a variable!</p>; // this works!
  return content;
}; 

一旦你深入研究到更高级的概念,比如条件或重复内容——这些内容将在第五章渲染列表和条件内容中介绍——这将会变得很重要。

JSX 元素必须有闭合标签

与 JSX 元素相关的重要规则之一是它们必须始终有一个闭合标签。因此,如果开标签和闭合标签之间没有内容,JSX 元素必须是自闭合的:

function App() {
  return <img src="img/some-image.png" />;
}; 

在常规 HTML 中,你不需要在末尾使用那个前向反斜杠。相反,常规 HTML 支持空元素(即,<img src="img/…">)。你同样可以在那里添加那个前向斜杠,但这不是强制的。

当使用 JSX 时,如果你的元素不包含任何子内容,这些前向斜杠是强制的。

超越静态内容

到目前为止,在所有这些例子中,返回的内容都是静态的。它像<p>Hello World!</p>这样的内容——当然,这是永远不会改变的内容。它总是会输出一个显示'Hello World!'的段落。

但当然,大多数网站都需要输出可能改变的内容(例如,由于用户输入)。同样,你也很难找到没有任何图片的网站。

因此,作为一名 React 开发者,了解如何输出动态内容(以及“动态内容”实际上是什么意思)以及如何在 React 应用中显示图像是很重要的。

输出动态内容

在本书的这一部分,你还没有任何工具来使内容更加动态。更准确地说,React 需要状态概念(将在 第四章处理事件和状态 中介绍)来改变显示的内容(例如,在用户输入或其他事件发生时)。

尽管如此,由于本章是关于 JSX 的,因此深入了解输出动态内容的语法是值得的,即使它还不是真正的动态:

function App() {
  const userName = 'Max';
  return <p>Hi, my name is {userName}!</p>;
}; 

这个例子在技术上仍然产生静态输出,因为 userName 从未改变,但你已经可以看到输出动态内容作为 JSX 代码一部分的语法。你使用开闭花括号({…})和其中的 JavaScript 表达式(如变量或常量的名称,就像这里的情况一样)。

你可以在那些花括号之间放置任何有效的 JavaScript 表达式。例如,你也可以调用一个函数(例如,{getMyName()})或进行简单的内联计算(例如,{1 + 1})。

你不能在那些花括号之间添加复杂的语句,如循环或 if 语句。再次强调,标准 JavaScript 规则适用。你输出一个(可能是)动态值,因此,任何产生单个值的任何东西都可以放在那个位置。然而,值得注意的是,一些值类型不能用于在 JSX 中输出值。例如,尝试在 JSX 中输出 JavaScript 对象将导致错误。

还值得注意的是,你不仅限于在元素标签之间输出动态内容。相反,你还可以为属性设置动态值:

function App() {
  const userName = 'Max';
  return <input type="text" value={userName} />;
}; 

渲染图像

大多数网站不仅仅显示纯文本。相反,你通常还需要渲染图像。

当然,当使用 React 时,你可以像在其他任何 Web 项目中一样使用默认的 <img /> 元素。但在显示 React 项目中的图像时,有两个重要的事情需要记住:

  1. <img /> 必须是自闭合标签。

  2. 当显示存储在 src/ 文件夹中的本地图像时,你必须将它们导入到你的 .jsx 文件中。

如上所述,在 JSX 元素必须有闭合标签 这一部分,你不能有空的 JSX 元素,即没有任何闭合标签的元素。

此外,当输出本地存储的图像(即存储在项目的 src/ 文件夹中的图像,而不是某个远程服务器上的图像)时,你通常不需要在代码中设置图像的字符串路径。

你可能习惯于输出如下所示的图像:

<img src="img/wave.jpg"> 

但 React 项目(例如,使用 Vite 创建时)确实涉及某种构建过程。在大多数项目中,最终部署到服务器上的项目结构将与你在开发期间工作的项目结构大不相同。

既然如此,如果你在一个基于 Vite 的 React 项目中将图像存储在 src/assets 文件夹中,并使用该路径(<img src="img/my-image.jpg" />),那么在部署的网站上图像将无法加载。它无法加载是因为可部署的文件夹结构将不再包含 src/assets 文件夹。

事实上,你可以通过运行 npm run build 来了解生产就绪的文件夹结构。这将构建用于部署的项目,并在你的项目目录中产生一个新的 dist 文件夹。将部署到某个服务器上的内容就是那个 dist 文件夹的内容。如果你检查那个文件夹,你不会在那里找到一个 src 文件夹。

计算机屏幕截图  自动生成描述

图 2.3:dist 文件夹包含不同的结构

换句话说:你无法提前知道本地存储的图像的确切路径。这就是为什么你应该将图像文件导入到你的 .jsx 文件中。结果,你将得到一个包含实际路径的字符串值(这在生产中是有效的)。然后,可以将此值设置为 <img /> 元素的 src 属性的动态值:

import myImage from './assets/my-image.png';
function App() {
  return <img src={myImage} />;
}; 

这可能一开始看起来有些奇怪,但这是在几乎所有 React 项目中都能正常工作的代码。幕后,这个导入被底层的构建过程分析。然后,import 语句被移除,图像路径被硬编码到生产就绪的输出代码中(即存储在 dist 文件夹中的代码)。

尽管如此,有一个重要的例外:如果你将图像文件(实际上,任何资产)存储在你的项目 public/ 文件夹中,你可以直接引用其路径。

例如,一个存储在 public/images/demo.jpgdemo.jpg 图像文件可以像这样渲染和显示:

function App() {
  return <img src="img/demo.jpg" />;
}; 

这之所以可行,是因为 public/ 文件夹的内容被简单地复制到 dist/ 文件夹中。与 src/ 文件夹及其嵌套文件不同,public/ 文件夹的文件跳过了转译步骤。

请注意,公共文件夹名称本身不是引用路径的一部分——它是 src="img/demo.jpg" ,而不是 src="img/demo.jpg"

那么,你应该使用哪种方法?将图像存储在 src/ 还是 public/

对于大多数图像,src/ 是一个合理的选择,因为预处理步骤为每个导入的文件分配一个唯一的文件名。因此,一旦应用程序部署,文件可以更有效地缓存。

任何导入到根 index.html 文件中的文件,或者文件名必须永远不变的文件(例如,因为它也被其他应用程序引用,该应用程序在某个其他服务器上运行)通常应该放入 public/ 文件夹中。

因此,在大多数情况下,当你输出存储在你项目中的本地图像时,你应该将它们存储在 src/ 文件夹中,然后导入到你的 JSX 文件中。当使用存储在某个远程服务器上的图像时,你会使用完整的图像 URL:

function App() {
  return <img src="img/my-image.jpg" />;
}; 

你应该在何时拆分组件?

随着你使用 React 并对其了解更多,以及你深入到更具挑战性的 React 项目中,你很可能会提出一个非常常见的问题:我应该何时将单个 React 组件拆分为多个单独的组件?

如本章前面所述,React 的一切都是关于组件的,因此在单个 React 项目中拥有数十、数百甚至数千个 React 组件是非常常见的。

当涉及到将单个 React 组件拆分为多个较小的组件时,并没有必须遵循的硬性规则。如前所述,你可以将所有的 UI 代码放入一个单独的、较大的组件中。或者,你也可以为 UI 中的每一个 HTML 元素和内容创建一个单独的定制组件。这两种方法可能都不是很好。相反,一个好的经验法则是为每个可识别的数据实体创建一个单独的 React 组件。

例如,如果你正在输出一个“待办事项”列表,你可以识别出两个主要实体:单个待办事项和整体列表。在这种情况下,创建两个单独的组件而不是编写一个更大的组件是有意义的。

将代码拆分为多个组件的优势在于,由于每个组件和组件文件中的代码更少,因此单个组件更容易管理。

然而,当涉及到将组件拆分为多个组件时,会出现一个新的问题:如何使你的组件可重用和可配置?

import Todo from './todo.jsx';
function TodoList() {
  return (
    <ul>
      <Todo />
      <Todo />
    </ul>
  );
}; 

在这个例子中,所有的“待办事项”都是相同的,因为我们使用的是相同的<Todo />组件,它无法配置。你可能希望通过添加自定义属性(<Todo text="Learn React!" />)或在开闭标签之间传递内容((<Todo>Learn React!</Todo>)来使其可配置。

当然,React 支持这一点。在下一章中,你将学习一个关键概念,称为props,它允许你使你的组件像这样可配置。

摘要和关键要点

  • React 拥抱组件:可重用的构建块,它们被组合起来定义最终的用户界面

  • 组件必须返回可渲染的内容——通常是定义最终应生成的 HTML 代码的 JSX 代码。

  • React 提供了许多内置组件:除了特殊的<>…</>组件之外,你还可以获得所有标准 HTML 元素的组件。

  • 为了让 React 能够区分自定义组件和内置组件,自定义组件名称在 JSX 代码中使用时必须以大写字母开头(通常使用 PascalCase 命名法)。

  • JSX 既不是 HTML 也不是标准的 JavaScript 特性——相反,它是所有 React 项目中构建工作流程提供的语法糖

  • 你可以用React.createElement(…)调用替换 JSX 代码,但由于这会导致代码的可读性显著降低,通常避免这样做。

  • 当使用 JSX 元素时,在期望单个值的地方(例如,直接在return关键字之后)不允许有兄弟元素

  • 如果在开标签和闭标签之间没有内容,JSX 元素必须始终自闭合

  • 动态内容可以通过花括号输出(例如,<p>{someText}</p>

  • 可以通过引用它们的路径(如果存储在远程或public/文件夹中)或通过将图像文件导入 JSX 文件并使用动态内容语法输出它们来渲染图像

  • 在大多数 React 项目中,你将 UI 代码拆分到数十个或数百个组件中,然后导出并导入以再次组合

接下来是什么?

在本章中,你学到了很多关于组件和 JSX 的知识。下一章将在此基础上,解释如何通过使组件可配置来使组件可重用。

在你继续之前,你也可以通过下面的问题和练习来练习你到目前为止所学到的内容。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与这里可以找到的示例答案进行比较:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/02-components-jsx/exercises/questions-answers.md

  1. 使用组件背后的想法是什么?

  2. 你如何创建一个 React 组件?

  3. 什么将一个普通函数转换成一个 React 组件函数?

  4. 在 JSX 元素方面,你应该记住哪些核心规则?

  5. JSX 代码是如何被 React 和 ReactDOM 处理的?

应用你所学的知识

通过本章和上一章,你已经拥有了创建 React 项目并填充一些基本组件所需的所有知识。

在下面,你将找到这本书的第一个两个实际活动。

活动二.1:创建一个 React App 来展示自己

假设你正在创建你的个人作品集页面,作为该页面的部分,你想要输出一些关于你自己的基本信息(例如,你的名字或年龄)。你可以使用 React 并构建一个 React 组件来输出这类信息,如以下活动概述。

目标是创建一个 React 应用,就像你在上一章中学到的那样(即通过npm create vite@latest <your-project-name>创建,并通过npm run dev启动开发服务器)并编辑App.jsx文件,以便输出一些关于你自己的基本信息。例如,你可以输出你的全名、地址、职位或其他类型的信息。最后,输出什么内容以及选择哪些 HTML 元素取决于你。

这个第一个练习的目的是练习项目创建和与 JSX 代码一起工作。

步骤如下:

  1. 通过npm create vite@latest <project>创建一个新的 React 项目。或者,你也可以使用这里提供的起始项目快照:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-1-start

  2. 编辑创建的项目中的/src文件夹下的App.jsx文件,并返回带有任何 HTML 元素的 JSX 代码以输出关于你自己的基本信息。你可以使用起始项目快照中的index.css文件中的样式来应用一些样式。

  3. 此外,将图片存储在src/assets文件夹中,并在App组件中输出它。

最终你应该得到如下输出:

img

图 2.4:最终活动结果——一些用户信息被输出到屏幕上

注意

样式当然会有所不同。要获得截图中所显示的相同样式,请使用我准备的起始项目,你可以在以下位置找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-1-start

分析该项目的index.css文件,以确定如何构建你的 JSX 代码来应用样式。

你可以在 GitHub 上找到一个示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/02-components-jsx/activities/practice-1/SOLUTION-INSTRUCTIONS.md

除了链接的说明外,你还会在包含SOLUTIONS-INSTRUCTIONS.md文件的工程文件夹中找到完成的示例解决方案代码。

然而,在你探索这个解决方案之前,你应该考虑先尝试自己解决这个问题。即使你的结果与示例解决方案不同,或者如果你无法创建一个可工作的应用程序,至少尝试一下你也会学到更多,因为,就像生活中始终一样,只有实践才能使完美。

活动二.2:创建一个 React 应用来记录这本书的目标

假设你正在为你的作品集网站添加一个新章节,你计划在这里跟踪你的学习进度。作为这个页面的一个部分,你计划定义并输出这本书的主要目标(例如,“了解关键 React 特性”“完成所有练习”等)。

这个活动的目的是创建另一个新的 React 项目,在这个项目中添加多个新组件。每个目标将代表一个单独的组件,所有这些目标组件将组合成另一个组件,列出所有主要目标。此外,你可以添加一个额外的标题组件,包含网页的主要标题。

完成这个活动的步骤如下:

  1. 通过npm create vite@latest <project>创建一个新的 React 项目,或者使用这里提供的项目起始快照:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-2-start

  2. 在新项目中,创建一个包含多个组件文件(包括单个目标、目标列表和页面标题)的components文件夹。

  3. 在不同的组件文件中,为不同的目标(每个文件一个组件)定义并导出多个组件函数(FirstGoalSecondGoalThirdGoal等)。

  4. 此外,定义一个组件用于整体目标列表(GoalList)和另一个组件用于页面标题(Header)。

  5. 在各个目标组件中,返回包含目标文本和适合的 HTML 元素结构的 JSX 代码来包含此内容。

  6. GoalList组件中,导入并输出各个目标组件。

  7. 在根App组件中导入并输出GoalListHeader组件(替换现有的 JSX 代码)。

应用你喜欢的任何样式。你也可以使用起始项目快照中的index.css文件来获取灵感。

最终你应该得到以下输出:

img

图 2.5:最终页面输出,显示目标列表

你还可以在 GitHub 上找到这个活动的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/02-components-jsx/activities/practice-2/SOLUTION-INSTRUCTIONS.md

如前所述,除了链接的说明外,你还会在包含SOLUTIONS-INSTRUCTIONS.md文件的工程文件夹中找到完成的示例解决方案代码。

第三章:组件和 Props

学习目标

到本章结束时,你将能够做到以下事情:

  • 构建可重用的 React 组件

  • 利用名为props的概念来使组件可配置

  • 通过组合组件和 props 构建灵活的用户界面

简介

在上一章中,你学习了任何基于 React 的用户界面的关键构建块:组件。你学习了组件的重要性、如何使用它们以及如何自己构建组件。

你还学习了 JSX,这是一种类似于 HTML 的标记,通常由组件函数返回。正是这种标记定义了最终网页上应该渲染的内容(换句话说,应该将哪个 HTML 标记放在最终提供给访客的网页上)。

组件能做更多吗?

然而,到目前为止,这些组件并没有太多用处。虽然你可以使用它们将网页内容分割成更小的构建块,但这些组件的实际可重用性相当有限。例如,作为整体课程目标列表的一部分,你可能有的每个课程目标都会进入其自己的组件(如果你最初决定将网页内容分割成多个组件)。

如果你这么想,这并不太有帮助;如果不同的列表项可以共享一个共同的组件,而你只需用不同的内容或属性来配置这个组件——就像 HTML 那样,那就更好了。

当编写纯 HTML 代码并使用它来描述内容时,你使用可重用的 HTML 元素,并通过不同的内容或属性来配置它们。例如,你有一个<a> HTML 元素,但由于href属性和元素子内容,你可以构建无限数量的不同锚点元素,它们指向不同的资源,如下面的代码片段所示:

<a href="https://google.com">Use Google</a>
<a href="https://academind.com">Browse Free Tutorials</a> 

这两个元素使用完全相同的 HTML 元素(<a>),但会导致网页上完全不同的链接,最终指向两个完全不同的网站。

要完全释放 React 组件的潜力,如果你能够像常规 HTML 元素一样配置它们,那将非常有用。实际上,你可以通过另一个关键的 React 概念props来实现这一点。

在组件中使用 Props

你如何在组件中使用 props?你何时需要它们?

第二个问题将在稍后更详细地回答。目前,只需知道你通常会有一些可重用的组件,因此需要 props,以及一些独特的组件,可能不需要 props。

在这个阶段,问题的“如何”部分更为重要,这部分可以分解为两个互补的问题:

  1. 将 Props 传递给组件

  2. 在组件中消费 Props

将 Props 传递给组件

如果你要从头设计 React,你希望 props 和组件的可配置性如何工作?

当然,可能会有各种各样的可能解决方案,但有一个伟大的榜样可以考虑:HTML。如上所述,当使用 HTML 时,你可以在元素标签之间或通过属性传递内容和管理配置。

幸运的是,当配置组件时,React 组件的工作方式与 HTML 元素类似。Props 作为属性(传递给你的组件)或作为组件标签之间的子数据传递,你还可以混合这两种方法:

  • <Product id="abc1" price="12.99" />

  • <FancyLink target="https://some-website.com">点击我</FancyLink>

因此,配置组件相当直接——至少,如果你从消费者的角度看待它们(换句话说,在 JSX 中使用它们的角度)的话。

在组件中消费属性

当编写组件的内部代码时,如何获取传递给组件的 prop 值?

想象你正在构建一个GoalItem组件,该组件负责输出单个目标项(例如,课程目标或项目目标),这将作为整体目标列表的一部分。

父组件 JSX 标记可能看起来像这样:

<ul>
  <GoalItem />
  <GoalItem />
  <GoalItem />
</ul> 

GoalItem中,目标(无意中开玩笑)是接受不同的目标标题,以便相同的组件(GoalItem)可以用来输出这些不同的标题,作为显示给网站访客的最终列表的一部分。也许该组件还应接受其他一些数据(例如,用于内部的一个唯一 ID)。

正如以下示例所示,这就是GoalItem组件在 JSX 中的使用方式:

<ul>
  <GoalItem id="g1" title="Finish the book!" />
  <GoalItem id="g2" title="Learn all about React!" />
</ul> 

GoalItem组件函数内部,计划可能是输出动态内容(换句话说,通过属性接收到的数据),如下所示:

function GoalItem() {
  return <li>{title} (ID: {id})</li>;
} 

但这个组件函数不会工作。它有一个问题:titleid在该组件函数内部从未被定义。因此,这段代码会导致错误,因为你正在使用一个未定义的变量。

当然,这些属性不应该在GoalItem组件内部定义,因为初衷是使GoalItem组件可重用,并从组件外部(即渲染<GoalItem>组件列表的组件)接收不同的titleid(即未在组件内部定义的变量)

React 为这个问题提供了一个解决方案:一个特殊的参数值,React 自动将其传递给每个组件函数。这是一个特殊的参数,它包含在 JSX 代码中设置在组件上的额外配置数据,称为props参数。

上述组件函数可以(并且应该)重写如下:

function GoalItem(props) {
  return <li>{props.title} (ID: {props.id})</li>;
} 

参数名称(props)由你决定,但使用props作为名称是一种约定,因为整体概念被称为props

要理解这个概念,重要的是要记住,这些组件函数不是你在代码的其他地方调用的,相反,React 会代表你调用这些函数。由于 React 调用这些函数,它可以在调用它们时向它们传递额外的参数。

这个props参数确实是一个额外的参数。React 会将其传递到每个组件函数中,无论你是否在组件函数定义中将它定义为额外的参数。然而,如果你没有在组件函数中定义那个props参数,你当然无法在那个组件中处理props数据。

这个自动提供的props参数将始终包含一个对象(因为 React 将对象作为此参数的值传递),而这个对象的属性将是你在 JSX 代码中添加到组件的“属性”(例如titleid)。

正因如此,在这个GoalItem组件示例中,可以通过属性(<GoalItem id="g1" … />)传递自定义数据,并通过props对象及其属性(<li>{props.title}</li>)来消费这些数据。

组件、Props 和可复用性

多亏了这个 props 概念,组件实际上变得可复用,而不仅仅是理论上可复用。

不进行任何额外配置地输出三个<GoalItem>组件,只能渲染相同的目标三次,因为目标文本(以及你可能需要的任何其他数据)必须硬编码到组件函数中。

通过如上所述使用 props,相同的组件可以多次使用,具有不同的配置。这允许你一次性(在组件函数中)定义一些通用的标记结构和逻辑,然后根据需要以不同的配置多次使用。

如果这听起来很熟悉,那确实正是适用于常规 JavaScript(或任何其他编程语言)函数的相同想法。你定义逻辑一次,然后可以多次调用它,以不同的输入接收不同的结果。对于组件来说也是如此——至少当你接受这个 props 概念时。

特殊的“子组件”属性

之前提到过,React 会自动将这个props对象传递到组件函数中。这确实是事实,正如描述的那样,这个对象包含你在组件(在 JSX 中)上设置的属性的所有属性。

但 React 不仅将你的属性打包到这个对象中;它还向props对象添加了另一个额外的属性:特殊的children属性(一个名称固定的内置属性,这意味着你不能更改它)。

children属性包含一个非常重要的数据片段:你可能在组件的起始和结束标签之间提供的内容。

到目前为止,在上述示例中,组件大多是自闭合的。<GoalItem id="…" title="…" />在组件标签之间没有内容。所有数据都是通过属性传递到组件中的。

这种方法没有问题。你可以仅使用属性来配置你的组件。但对于某些数据和某些组件,坚持使用常规 HTML 约定,在组件标签之间传递这些数据可能更有意义,也更符合逻辑。GoalItem组件实际上是一个很好的例子。

哪种方法看起来更直观?

  1. <GoalItem id="g1" title="Learn React" />

  2. <GoalItem id="g1">Learn React</GoalItem>

你可能会觉得第二个选项看起来更直观,并且与常规 HTML 保持一致,因为在那里,你也会像这样配置一个普通的列表项:<li id="li1">Some list item</li>

当你处理常规 HTML 元素时(你不能仅仅因为想要添加一个goal属性到<li>标签),你与 React 和自己的组件一起工作时确实有选择权。这完全取决于你在组件函数内部如何消费 props。两种方法都可以工作,具体取决于组件的内部代码。

然而,你可能仍然想要在组件标签之间传递某些数据片段,而特殊的children属性允许你做到这一点。它包含你在组件开始和结束标签之间定义的任何内容。因此,在上述列表中的示例 2 中,children将包含字符串"Learn React"

在你的组件函数中,你可以像处理任何其他 prop 值一样处理children值:

function GoalItem(props) {
  return <li>{props.children} (ID: {props.id})</li>;
} 

哪些组件需要 Props?

之前已经提到过,但这一点非常重要:props 是可选的!

React 总是会将prop数据传递到你的组件中,但你不必处理那个prop参数。如果你不打算使用它,甚至不需要在你的组件函数中定义它。

没有硬性规则可以定义哪些组件需要props,哪些不需要。这取决于经验,并且简单地取决于组件的角色。

你可能有一个通用的Header组件,用于显示静态标题(带有标志、标题等),这样的组件可能不需要外部配置(换句话说,没有“属性”或其他类型的数据传递给它)。它可以自包含,所有必需的值都硬编码到组件中。

但你也会经常构建和使用像GoalItem组件这样的组件(换句话说,需要外部数据才能有用的组件)。每当一个组件在你的 React 应用中被多次使用时,它很可能将利用属性。然而,反过来不一定成立。虽然你会有一次性的组件不使用属性,但你绝对也会有一些在整个应用中只使用一次但仍利用属性的组件。正如之前提到的,这取决于具体的使用案例和组件。

在这本书的整个过程中,你将看到许多示例和练习,这些将帮助你更深入地理解如何构建组件和使用属性。

如何处理多个属性

如前例所示,你不仅限于每个组件只有一个属性。实际上,你可以传递和使用组件需要的任何数量的属性——无论是一、一百(或更多)属性。

一旦你创建了具有两个或三个以上属性的组件,可能会出现一个新的问题:你是否必须逐个添加所有这些属性(换句话说,作为单独的属性),或者你可以传递包含分组数据(如数组或对象)的更少的属性?

确实如此。React 允许你将数组和对象作为属性值传递。事实上,任何有效的 JavaScript 值都可以作为属性值传递!

这让你可以决定你是否想要一个包含 20 个单独属性(“属性”)的组件,或者只是一个“大”属性。以下是一个示例,展示了同一个组件以两种不同的方式配置:

<Product title="A book" price={29.99} id="p1" />
// or
const productData = {title: 'A book', price: 29.99, id: 'p1'};
<Product data={productData} /> 

当然,组件也必须在内部(换句话说,在组件函数中)进行调整,以期望单个或分组属性。但既然你是开发者,当然,这是你的选择。

在组件函数内部,你还可以使自己的生活变得更简单。

通过props.XYZ访问属性值没有问题,但如果你的组件接收多个属性,反复重复props.XYZ可能会变得繁琐,并使代码难以阅读。

你可以使用默认的 JavaScript 功能来提高可读性:对象解构

对象解构允许你从对象中提取值,并将这些值一次性分配给变量或常量:

const user = {name: 'Max', age: 29};
const {name, age} = user; // <-- object destructuring in action
console.log(name); // outputs 'Max' 

因此,你可以使用这种语法在组件函数的开始处直接提取所有属性值并将它们分配给同名的变量:

function Product({title, price, id}) { // destructuring in action
  … // title, price, id are now available as variables inside this function
} 

你不必使用这种语法,但它可以使你的生活变得更简单。

注意

关于对象解构的更多信息,MDN 是一个深入了解的好地方。你可以在这里访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

属性展开

想象你正在构建一个自定义组件,该组件应作为其他组件的“包装器”——可能是内置组件。

例如,你可以构建一个自定义的Link组件,该组件应返回一个带有一些自定义样式或逻辑的标准<a>元素:

function Link({children}) {
  return <a target="_blank" rel="noopener noreferrer">{children}</a>;
}; 

这个非常简单的示例组件返回一个预配置的<a>元素。这个自定义的Link组件配置了锚元素,使得新页面总是在新标签页中打开。你可以用这个Link组件替换标准<a>元素,在你的 React 应用中为所有链接提供开箱即用的行为。

但这个自定义组件存在一个问题:它是核心元素的包装器,但通过创建自己的组件,你移除了核心元素的配置性。如果你要在你的应用中使用这个Link组件,你将如何设置href属性来配置链接目标?

你可以尝试以下方法:

<Link href="https://some-site.com">Click here</Link> 

然而,这个示例代码不会工作,因为Link不接受或使用href属性。

当然,你可以调整Link组件函数,使其使用href属性:

function Link({children, href}) {
  return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
}; 

但如果你还想确保在需要时可以添加download属性呢?

嗯,确实你可以接受越来越多的属性(并将它们传递到组件内的<a>元素),但这会降低自定义组件的可重用性和可维护性。

一个更好的解决方案是使用标准的 JavaScript展开操作符(即...操作符)以及 React 对该操作符的支持,当与组件一起工作时。

例如,以下组件代码是有效的:

function Link({children, config}) {
  return <a {...config} target="_blank" rel="noopener noreferrer">{children}</a>;
}; 

在这个例子中,config预期是一个 JavaScript 对象(即一组键值对)。当在 JSX 代码中的 JSX 元素上使用展开操作符(...)时,它会将该对象转换为多个属性。

考虑这个config值的例子:

const config = { href: 'https://some-site.com', download: true }; 

在这种情况下,当在<a>上展开时(即<a {...config}>),结果将与以下代码相同:

<a href="https://some-site.com" download={true}> 

另一种更常见的模式使用了另一个 JavaScript 特性:剩余属性。这是一个 JavaScript 模式,允许你将未解构的属性组合到一个新对象中(该对象只包含那些属性)。

function Link({children, **...props**}) {
  return <a {...props} target="_blank" rel="noopener noreferrer">{children}</a>;
}; 

在这个例子中,当解构属性时,只有children属性被解构;其他属性存储在一个名为props的新对象中。语法与展开操作符语法非常相似:你使用三个点(...)。但在这里,你使用操作符在应包含所有剩余属性的前面。因此,这就是你使用该操作符的地方,它定义了它所做的事情。

你可以使用那个剩余属性(例如示例中的props)就像使用任何其他对象一样。在上面的例子中,它再次被用来将属性作为属性展开到<a>元素上。

使用这种模式可以使您更自然地使用Link组件,您不需要创建和使用单独的配置对象:

<Link href="https://google.com">Can you google that for me?</Link> 

这些行为和模式可以用来构建可重用的组件,同时仍然保持它们可能包装的核心元素的可配置性。这有助于您避免长列表的预定义、接受的 props,并提高组件的可重用性。

Prop Chains/Prop Drilling

在学习关于 props 时,还有一个值得注意的最后现象:prop drillingprop chains

这是每个 React 开发者迟早会遇到的问题。当您构建一个稍微复杂一些的 React 应用程序,其中包含多层嵌套组件,这些组件需要相互发送数据时,就会发生这种情况。

例如,假设您有一个NavItem组件,它应该输出一个导航链接。在这个组件内部,您可能还有一个嵌套组件AnimatedLink,它输出实际的链接(可能带有一些漂亮的动画样式)。

NavItem组件可能看起来像这样:

function NavItem(props) {
  return <div><AnimatedLink target={props.target} text="Some text" /></div>;
} 

AnimatedLink可以这样定义:

function AnimatedLink(props) {
  return <a href={props.target}>{props.text}</a>;
} 

在这个例子中,target prop 通过NavItem组件传递到AnimatedLink组件。NavItem组件必须接受target prop,因为它必须传递给AnimatedLink

这就是 prop drilling/prop chains 的实质:您从一个实际上并不需要它的组件中转发一个 prop 到另一个确实需要它的组件。

在您的应用程序中存在一些 prop drilling 并不一定不好,您当然可以接受它。但是,如果您最终得到更长的 props 链(换句话说,多个传递组件),您可以使用将在第十一章处理复杂状态中讨论的解决方案。

摘要和关键要点

  • Props 是 React 的一个关键概念,它使组件可配置,因此可重用。

  • React 会自动收集并传递 props 到组件函数中。

  • 您决定(针对每个组件)是否想要使用 props 数据(一个对象)。

  • Props 像属性一样传递到组件中,或者通过特殊的children prop 在开标签和闭标签之间传递。

  • 您可以使用 JavaScript 功能,如解构、剩余属性或扩展运算符来编写简洁、灵活的代码。

  • 由于您正在编写代码,所以您决定如何通过 props 传递数据。是在标签之间还是作为属性?是一个分组属性还是多个单值属性?这取决于您。

接下来是什么?

Props 允许您使组件可配置和可重用。尽管如此,它们相对静态。数据和因此 UI 输出不会改变。您无法对用户事件(如按钮点击)做出反应。

但 React 的真正力量只有在您添加事件(以及对其的反应)之后才会显现出来。

在下一章中,你将学习如何在处理 React 时添加事件监听器,以及你将学习如何对事件做出反应(无意中开玩笑),并改变应用程序的(不可见和可见)状态。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/03-components-props/exercises/questions-answers.md上可以找到的示例答案进行比较:

  1. 属性解决了哪些“问题”?

  2. 属性是如何传递到组件中的?

  3. 属性是如何在组件函数内部消费的?

  4. 有哪些选项可以用于将(多个)属性传递到组件中?

应用你所学的知识

通过本章和上一章,你现在已经有了足够的基础知识来构建真正可重用的组件。

下面,你将找到一个活动,它允许你应用到目前为止所获得的所有知识,包括新的属性知识。

活动三.1:创建一个应用来输出本书的目标

本活动基于上一章的活动 2.2创建一个 React 应用来记录本书的目标,并在此基础上进行。如果你跟随了那里的内容,你可以使用你现有的代码,并通过添加属性来增强它。或者,你也可以使用以下链接中可访问的解决方案作为起点:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-2

本活动的目的是构建可配置的GoalItem组件,这些组件可以通过属性进行配置。每个GoalItem组件都应该接收并输出一个目标标题和简短描述文本,以及关于目标的其他信息。

步骤如下:

  1. 完成上一章的第二项活动。

  2. 用一个新的可配置组件替换硬编码的目标组件。

  3. 通过属性输出具有不同标题的多个目标组件。

  4. 在目标组件的打开和关闭标签之间设置每个目标的详细文本描述。

最终的用户界面可能看起来像这样:

img

图 3.1:最终结果:每个目标输出在下方

注意

你可以在以下链接中找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/03-components-props/activities/practice-1

第四章:与事件和状态一起工作

学习目标

到本章结束时,你将能够做到以下几点:

  • 向 React 应用添加用户事件处理器(例如,用于对按钮点击做出反应)

  • 通过一个称为状态的概念来更新用户界面UI

  • 构建真正的动态和交互式 UI(即它们不再是静态的)

简介

在前面的章节中,你学习了如何在 React 组件 的帮助下构建 UI。你也了解了属性——这是一个概念和功能,它使 React 开发者能够构建和重用可配置的组件。

这些都是重要的 React 特性和构建块,但仅凭这些特性,你只能构建静态的 React 应用(即永远不会改变的 Web 应用)。如果你只能访问这些特性,你就无法更改或更新屏幕上的内容。你也不能对任何用户事件做出反应,并更新 UI 以响应这些事件(例如,在按钮点击时显示覆盖窗口)。

用其他的话说,如果你仅仅局限于组件和属性,你就无法构建真正的网站和 Web 应用。

因此,在本章中,引入了一个全新的概念:状态。状态是 React 的一个功能,允许开发者更新内部数据,并根据这些数据调整触发 UI 更新。此外,你还将学习如何对用户事件(如按钮点击或输入字段中输入的文本)做出反应。

问题是什么?

如前所述,在本书的这个阶段,你可能会遇到所有构建的 React 应用和网站的问题:它们是静态的。UI 无法改变。

为了更好地理解这个问题,看看一个典型的 React 组件,就像你现在能够构建到本书的这个阶段一样:

function EmailInput() {
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p>The entered email address is invalid.</p>
    </div>
  );
}; 

这个组件可能看起来有点奇怪。为什么会有一个 <p> 元素通知用户关于不正确的电子邮件地址?

好吧,目标可能是只有在用户确实输入了不正确的电子邮件地址时才显示那个段落。也就是说,Web 应用应该等待用户开始输入,并在用户完成输入(即输入失去焦点)后评估用户输入。然后,如果电子邮件地址被认为无效(例如,空输入字段或缺少@符号),则应显示错误消息。

但目前,凭借到目前为止学到的 React 技能,这是你无法构建的。相反,错误消息总是会显示,因为没有方法可以根据用户事件和动态条件来更改它。换句话说,这个 React 应用是一个静态应用,不是动态的。UI 无法改变。

当然,改变 UI 和动态 Web 应用是你可能想要构建的事情。几乎每个存在的网站都包含一些动态 UI 元素和功能。因此,这就是本章要解决的问题。

如何不解决问题

如何使之前展示的组件更加动态?

以下是你可能想到的一个解决方案(剧透,这段代码将无法工作,所以你不需要尝试运行它):

function EmailInput() {
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p></p>
    </div>
  );
};
const input = document.querySelector('input');
const errorParagraph = document.querySelector('p');
function evaluateEmail(event) {
  const enteredEmail = event.target.value;
  if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
    errorParagraph.textContent = ' The entered email address is invalid.';
  } else {
    errorParagraph.textContent = '';
  }
};
input.addEventListener('blur', evaluateEmail); 

这段代码将无法工作,因为你不能以这种方式从同一组件文件内部选择 React 渲染的 DOM 元素。这只是一个示例,说明你可以尝试如何解决这个问题。话虽如此,你可以在组件函数下方某个地方放置下面的代码,以便它能够成功执行(例如,放入setTimeout()回调函数中,在 1 秒后触发,允许 React 应用将所有元素渲染到屏幕上)。

将代码放在正确的位置,这段代码将添加本章前面描述的电子邮件验证行为。在内置的blur事件发生时,evaluateEmail函数被触发。这个函数接收event对象作为参数(由浏览器自动提供),因此evaluateEmail函数能够通过event.target.value解析从该event对象中输入的值。然后,可以使用if检查来有条件地显示或删除错误消息。

注意

所有处理blur事件(如addEventListener)和event对象的代码,包括if检查中的代码,都是标准的 JavaScript 代码。它以任何方式都不特定于 React。

如果你发现自己在这段非 React 代码上遇到了困难,强烈建议你首先深入研究更多纯 JavaScript 资源(例如,MDN 网站上的指南developer.mozilla.org/en-US/docs/Web/JavaScript)。

但如果这段代码在某些地方的工作正常,它有什么问题呢?

这是命令式代码!这意味着你正在写下浏览器应该按步骤执行的指令。你并没有声明所需的最终状态;相反,你描述了一种达到该状态的方式;而且这不是使用 React。

请记住,React 的全部内容都是关于控制 UI,编写 React 代码是关于编写声明式代码——而不是命令式代码。如果你觉得这一点听起来很新鲜,请重新阅读第二章理解 React 组件和 JSX

你可以通过引入这种类型的代码来实现你的目标,但你会与 React 及其哲学作对(React 的哲学是声明你的所需最终状态,让 React 找出如何达到那里)。一个明显的迹象是,你将被迫找到这种代码的正确位置,以便它能够工作。

这不是一个哲学问题,也不仅仅是一些奇怪的硬性规则,你应该遵循。相反,通过这样与 React 作对,你将使作为开发者的生活变得不必要地艰难。你既没有使用 React 提供的工具,也没有让 React 找出如何实现所需(UI)状态的方法。

这不仅意味着你花费时间解决你本不必解决的问题。这也意味着你放弃了 React 可能在底层执行的可能优化。你的解决方案很可能不仅导致你做更多的工作(也就是说,写更多的代码),还可能导致有缺陷的结果,也可能遭受性能不佳的问题。

之前展示的例子是一个简单的例子。想想更复杂的网站和 Web 应用,比如在线商店、度假租赁网站,或者像 Google Docs 这样的 Web 应用。在那里,你可能会有数十个或数百个(动态)UI 功能和元素。用 React 代码和标准 vanilla JavaScript 代码的混合来管理它们将很快变成一场噩梦。再次参考本书的第二章,理解 React 组件和 JSX,以了解 React 的优点。

更好的错误解决方案

之前讨论的简单方法效果不佳。它迫使你找出如何使代码正确运行(例如,通过将部分代码包裹在某个setTimeout()调用中以延迟执行)并导致你的代码四处散落(也就是说,在 React 组件函数内部,外部,也许也在完全不相关的文件中)。那么,一个拥抱 React 的解决方案如何呢?

function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = ' The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  const input = document.querySelector('input');
  input.addEventListener('blur', evaluateEmail);
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p>{errorMessage}</p>
    </div>
  );
}; 

这段代码再次无法工作(尽管它在技术上有效的 JavaScript 代码)。选择 JSX 元素的方式不是这样的。它无法工作是因为document.querySelector('input')在将任何内容渲染到 DOM 之前执行(当组件函数第一次执行时)。再次强调,你必须将这段代码的执行延迟到第一次渲染周期结束(因此你又一次与 React 作对)。

尽管它仍然不会工作,但它更接近正确的解决方案。

它更接近理想的实现,因为它比第一次尝试的解决方案更多地采用了 React 的方式。所有代码都包含在它所属的组件函数中。错误信息通过一个作为 JSX 代码一部分输出的errorMessage变量来处理。

这个可能解决方案背后的想法是,控制某个 UI 功能或元素的 React 组件也负责其状态和事件。你可能会在这个章节中识别出两个重要的关键词!

这种方法肯定是在正确的方向上,但仍有两个原因它不会工作:

  • 通过document.querySelector('input')选择 JSX <input>元素将失败。

  • 即使输入可以被选择,UI 也不会按预期更新。

下一个将要解决这两个问题——最终实现一个完全拥抱 React 及其特性的实现。即将到来的解决方案将避免混合 React 和非 React 代码。正如你将看到的,结果将是更简单的代码,你不需要做更多的工作(也就是说,写更少的代码)。

通过正确响应事件改进解决方案

与将命令式 JavaScript 代码(如 document.querySelector('input'))与 React 特定代码混合相比,您应该完全拥抱 React 及其功能。

由于监听事件并在事件上触发动作是一个极其常见的需求,React 提供了一个内置的解决方案。您可以直接将事件监听器附加到它们所属的 JSX 元素上。

前面的示例将重写如下:

function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = 'The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        **onBlur****=****{evaluateEmail}** />
      <p>{errorMessage}</p>
    </div>
  );
}; 

此代码仍然不会更新 UI,但至少事件得到了适当的处理。

onBlur 属性被添加到内置的输入元素。这个属性是由 React 提供的,就像所有这些基础 HTML 元素(如 <input><p>)都是由 React 作为组件提供的。实际上,所有这些内置的 HTML 组件都带有它们的标准 HTML 属性作为 React 属性(加上一些额外的属性,如 onBlur 事件处理属性)。

React 将所有可以连接到 DOM 元素的标准事件都暴露为 onXYZ 属性(其中 XYZ 是事件名称,例如 blurclick,以大写字母开头)。您可以通过添加 onBlur 属性来响应 blur 事件。您可以通过 onClick 属性来监听 click 事件。您应该已经明白了。

注意

更多有关标准事件的信息,请参阅 developer.mozilla.org/en-US/docs/Web/Events#event_listing

这些属性需要值来履行其职责。更准确地说,它们需要一个指向在事件发生时应执行的函数的指针。在上面的例子中,onBlur 属性接收一个指向 evaluateEmail 函数的指针作为值。

注意

evaluateEmailevaluateEmail() 之间有一个细微的差别。前者是指向函数的指针;后者实际上执行了函数(如果有的话,则返回其返回值)。再次强调,这并不是 React 特有的,而是标准的 JavaScript 概念。如果还不清楚,这个资源可以更详细地解释它:developer.mozilla.org/en-US/docs/Web/Events#event_listing

通过使用这些事件属性,前面的示例代码现在最终将执行而不会抛出任何错误。您可以通过在 evaluateEmail 函数内添加 console.log('Hello'); 语句来验证这一点。这将显示 'Hello' 文本在浏览器开发者工具的控制台中,每当输入失去焦点时:

function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    console.log('Hello');
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = 'The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        onBlur={evaluateEmail} />
      <p>{errorMessage}</p>
    </div>
  );
}; 

在浏览器控制台中,它看起来如下:

计算机屏幕截图  自动生成的描述

图 4.1:在从输入字段移除焦点时在浏览器控制台中显示一些文本

这确实是一个向最佳可能实现迈进的一步,但它仍然不会产生动态更新页面内容所需的结果。

正确更新状态

到现在为止,你已经了解了如何正确设置事件监听器并在某些事件上执行函数。缺少的是一种强制 React 更新屏幕上可见的 UI 和显示给应用用户的内容的特性。

正是 React 的状态概念在这里发挥作用。与 props 一样,状态是 React 的关键概念,但与 props 关于在组件内部接收外部数据不同,状态是关于管理和更新内部数据。最重要的是,每当状态更新时,React 都会更新受状态变化影响的 UI 部分。

在 React 中如何使用状态(当然,代码将在之后详细解释):

**import** **{ useState }** **from****'react'****;**
function EmailInput() {
  **const** **[errorMessage, setErrorMessage] =** **useState****(****''****);**
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      **setErrorMessage****(****'The entered email address is invalid.'****);**
    } else {
      **setErrorMessage****(****''****);**
    }
  };
  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        onBlur={evaluateEmail} />
      <p>{errorMessage}</p>
    </div>
  );
}; 

与本章前面讨论的示例代码相比,这段代码看起来并没有太大的不同。但有一个关键的区别:useState() Hook 的使用。

Hooks是 React 的另一个关键概念。这些是只能在 React 组件内部(或在其他 Hooks 内部,如第十二章构建自定义 React Hooks所述)使用的特殊函数。Hooks 为它们所使用的 React 组件添加了特殊的功能和行为。例如,useState() Hook 允许组件(以及隐式地 React)设置和管理与该组件相关联的一些状态。React 提供了各种内置的 Hooks,它们并不都专注于状态管理。你将在本书中了解其他 Hooks 及其用途。

useState() Hook 是一个极其重要且常用的 Hook,因为它允许你在组件内部管理数据,当数据更新时,它会告诉 React 相应地更新 UI。

这就是状态管理和状态概念背后的核心思想:状态是数据,当它改变时,应该迫使 React 重新评估组件,并在需要时更新 UI。

使用 Hooks,如useState(),相当简单:你从'react'导入它们,然后在组件函数内部像调用函数一样调用它们。你像调用函数一样调用它们,因为如前所述,React Hooks 是函数——只是特殊的函数(从 React 的角度来看)。

深入了解 useState()

useState() Hook 究竟是如何工作的,它内部做了什么?

通过在组件函数内部调用useState(),你向 React 注册了一些数据。这有点像在纯 JavaScript 中定义一个变量或常量。但有一个特别之处:React 会内部跟踪注册的值,每当更新它时,React 都会重新评估注册状态的组件函数。

React 通过检查组件中使用的数据是否发生变化来完成此操作。最重要的是,React 验证 UI 是否需要因为数据的变化而更改(例如,因为 JSX 代码中输出了一个值)。如果 React 确定 UI 需要更改,它将更新需要更新的真实 DOM 中的位置(例如,更改屏幕上显示的文本)。如果不需要更新,React 将在更新 DOM 之前结束组件的重新评估。

React 的内部工作原理将在第十章 React 和优化机会的幕后 中详细讨论。

整个过程从在组件内部调用 useState() 开始。这创建了一个状态值(将由 React 存储和管理)并将其绑定到特定的组件。通过简单地将它作为参数值传递给 useState() 来注册初始状态值。在先前的例子中,一个空字符串('')被注册为第一个值:

const [errorMessage, setErrorMessage] = useState(''); 

如你所见,useState() 不仅接受一个参数值,它还返回一个值:一个包含恰好两个元素的数组。

先前的例子使用了 数组解构,这是 JavaScript 的一个标准特性,允许开发者从数组中检索值并将其立即分配给变量或常量。在例子中,组成 useState() 返回的数组的两个元素被从该数组中提取出来并存储在两个常量(errorMessagesetErrorMessage)中。尽管如此,在处理 React 或 useState() 时,你不必使用数组解构。

你也可以这样编写代码:

const stateData = useState('');
const errorMessage = stateData[0];
const setErrorMessage = stateData[1]; 

这完全没问题,但使用数组解构时,代码会更加简洁。这就是为什么在浏览 React 应用和示例时,你通常看到使用数组解构的语法。你也不必使用常量;通过 let 的变量也可以。然而,正如你将在本章和本书的其余部分看到的那样,变量不会被重新赋值,因此使用常量是有意义的(但并非必须)。

注意

如果你对于数组解构或变量与常量的区别感到陌生,强烈建议你在继续阅读本书之前先复习一下 JavaScript 的基础知识。正如往常一样,MDN 提供了很好的资源(有关数组解构,请参阅 packt.link/3B8Ct,有关 let 变量的信息,请参阅 packt.link/hGjqL,有关 const 的使用指南,请参阅 packt.link/TdPPS)。

如前所述,useState() 返回一个包含恰好两个元素的数组。它总是恰好两个元素——并且总是同一种类的元素。第一个元素总是当前状态值,第二个元素是一个你可以调用的函数,用于将状态设置为新的值。

但这两个值(状态值和状态更新函数)是如何一起工作的呢?React 在内部如何使用它们?这两个数组元素是如何(由 React)用来更新 UI 的?

深入了解 React 的内部机制

React 为你管理状态值,在某个你,即开发者,无法直接访问的内部存储中。由于你经常需要访问状态值(例如,一些输入的电子邮件地址,如前例所示),React 提供了一种读取状态值的方法:useState()返回的数组中的第一个元素。返回数组的第一个元素包含当前状态值。因此,你可以在任何需要使用状态值的地方使用此元素(例如,在 JSX 代码中输出它)。

此外,你通常还需要更新状态——例如,因为用户输入了新的电子邮件地址。由于你无法自行管理状态值,React 提供了一个你可以调用的函数来通知 React 关于新的状态值。这就是返回数组中的第二个元素。

在前面显示的示例中,你调用setErrorMessage('Error!')来将errorMessage状态值设置为一个新的字符串('Error!')。

但为什么是这样管理的呢?为什么不直接使用一个标准的 JavaScript 变量,根据需要分配和重新分配呢?

因为每当有影响 UI 变化的状态时,React 都必须被告知。否则,可见的 UI 根本不会改变,即使在应该改变的情况下。React 不跟踪常规变量及其值的变化,因此它们对 UI 的状态没有影响。

React 暴露的状态更新函数(useState()返回的第二个数组元素)确实会触发一些内部 UI 更新效果。这个状态更新函数不仅设置了一个新值;它还通知 React 状态值已更改,因此 UI 可能需要更新。

因此,每次你调用setErrorMessage('Error!')时,React 不仅更新它内部存储的值;它还会检查 UI 并在需要时更新它。UI 更新可能涉及从简单的文本更改到各种 DOM 元素的完全删除和添加。任何情况都是可能的!

React 通过重新运行(也称为重新评估)受状态变化影响的任何组件函数来确定新的目标 UI。这包括执行了useState()函数并返回了状态更新函数的组件函数。但也包括任何子组件,因为父组件的更新可能会导致新的状态数据,这些数据也被某些子组件使用(状态值可以通过 props 传递给子组件)。

如果你需要一个如何将这些内容组合在一起的视觉表示,请考虑以下图表:

系统图解,描述自动生成

图 4.2:React 状态更新流程

重要的是理解和记住,如果在组件函数或某些父组件函数中调用了状态更新函数,React 将会重新执行(重新评估)组件函数。这也解释了为什么 useState() 返回的状态值(即第一个数组元素)可以是一个常量,尽管你可以通过调用状态更新函数(第二个数组元素)来分配新值。由于整个组件函数都会重新执行,useState() 也会再次被调用(因为所有组件函数的代码都会再次执行),因此 React 会返回一个新的包含两个新元素的数组。第一个数组元素仍然是当前的状态值。

然而,由于组件函数是因为状态更新而被调用的,当前的状态值现在是更新后的值。

这可能有点难以理解,但这就是 React 内部的工作方式。最终,这只是 React 多次调用组件函数,就像任何 JavaScript 函数都可以被多次调用一样。

命名约定

useState() 钩子通常与数组解构一起使用,如下所示:

const [enteredEmail, setEnteredEmail] = useState(''); 

但当使用数组解构时,变量或常量的名称(在这种情况下为 enteredEmailsetEnteredEmail)由你,即开发者来决定。因此,一个合理的问题是,你应该如何命名这些变量或常量。幸运的是,在 React 和 useState() 方面有一个明确的约定,以及这些变量或常量的命名。

第一个元素(即当前状态值)的命名应该能够描述状态值的内容。例如,可以是 enteredEmailuserEmailprovidedEmail、仅仅是 email 或类似的名字。你应该避免使用通用名称,如 avalue,或者误导性的名称,如 setValue(听起来像是一个函数——但实际上不是)。

第二个元素(即状态更新函数)的命名应该能够清楚地表明它是一个函数,并且它执行了什么操作。例如,可以是 setEnteredEmailsetEmail。一般来说,这个函数的约定是命名为 setXYZ,其中 XYZ 是你为第一个元素,当前状态值变量所选择的名称。(注意,尽管如此,你应该以大写字母开头,就像 setEnteredEmail 而不是 setenteredEmail。)

允许的状态值类型

管理输入的电子邮件地址(或一般用户输入)确实是处理状态的一个常见用例和示例。然而,你并不局限于这种场景和值类型。

在处理用户输入的情况下,你通常会处理诸如电子邮件地址、密码、博客文章或类似值的字符串。但任何有效的 JavaScript 值类型都可以通过 useState() 的帮助来管理。例如,你可以管理多个购物车项目的总价——即一个数字——或者一个布尔值(例如,“用户是否确认了使用条款?”)。

除了管理原始值类型外,你还可以存储和更新引用数据类型,如对象和数组。

注意

如果你对原始数据类型和引用数据类型之间的区别并不完全清楚,强烈建议你在继续阅读本书之前,通过以下链接深入了解这个核心 JavaScript 概念:academind.com/tutorials/reference-vs-primitive-values

React 允许你将所有这些值类型作为状态来管理。你甚至可以在运行时切换值类型(就像在纯 JavaScript 中一样)。将数字作为初始状态值,并在稍后将其更新为字符串是完全没问题的。

就像在纯 JavaScript 中一样,当然你应该确保你的程序适当地处理这种行为,尽管在技术上切换类型并没有错误。

与多个状态值一起工作

当构建除了非常简单的 Web 应用或 UI 之外的应用时,你需要多个状态值。也许用户不仅能够输入他们的电子邮件,还可以输入用户名或地址。也许你还需要跟踪一些错误状态或保存购物车项目。也许用户可以点击一个“喜欢”按钮,其状态应该被保存并反映在 UI 上。有许多值会频繁变化,并且其变化应该在 UI 中体现出来。

考虑这个具体的场景:你有一个需要管理用户在电子邮件输入字段中输入的值和密码字段中插入的值的组件。每个值都应该在字段失去焦点时被捕获。

由于你有两个包含不同值的输入字段,因此你有两个状态值:输入的电子邮件和输入的密码。即使你可能在某个时刻使用这两个值(例如,用于登录用户),这些值并不是同时提供的。此外,你可能还需要每个值独立存在,因为你在用户输入数据时使用它来显示潜在的错误消息(例如,“密码太短”)。

这种情况非常常见,因此,你也可以使用useState() Hook 来管理多个状态值。主要有两种方法:

  1. 使用多个状态片段(多个状态值)

  2. 使用一个单一的、大的状态对象

使用多个状态片段

你可以通过在组件函数中多次调用useState()来管理多个状态值(也常被称为状态片段)。

对于之前描述的例子,一个(简化的)组件函数可能看起来像这样:

function LoginForm() {
  const [enteredEmail, setEnteredEmail] = useState(''); 
  const [enteredPassword, setEnteredPassword] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  };
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  };
  // Below, props are split across multiple lines for better readability
  // This is allowed when using JSX, just as it is allowed in standard HTML
  return (
    <form>
      <input
        type="email"
        placeholder="Your email"
        onBlur={handleUpdateEmail} />
      <input
        type="password"
        placeholder="Your password"
        onBlur={handleUpdatePassword} />
    </form>
  );
}; 

在这个例子中,通过两次调用useState()来管理两个状态片段。因此,React 内部注册并管理两个状态值。这两个值可以独立于彼此进行读取和更新。

注意

在这个例子中,触发事件时调用的函数以 handle 开头(handleUpdateEmailhandleUpdatePassword)。这是一些 React 开发者使用的约定。事件处理函数以 handle… 开头,以清楚地表明这些函数处理某些(用户触发的)事件。这不是你必须遵循的约定。函数也可以命名为 updateEmailupdatePasswordemailUpdateHandlerpasswordUpdateHandler 或其他任何名称。如果名称有意义并且遵循某些严格的约定,那么它是一个有效的选择。

你可以在组件中注册任意数量的状态切片(通过多次调用 useState()),以满足你的需要。你可能只有一个状态值,但也可能有几十个甚至几百个。通常情况下,你将只为每个组件拥有几个状态切片,因为你应该尝试将较大的组件(可能执行许多不同的操作)拆分成多个较小的组件,以保持其可管理性。

以这种方式管理多个状态值的优势在于你可以独立地更新它们。如果用户输入了一个新的电子邮件地址,你只需要更新那个电子邮件状态值。对于你的目的来说,密码状态值并不重要。

可能的缺点是,多个状态切片——因此是多个 useState() 调用——会导致大量的代码行,这可能会膨胀你的组件。然而,正如之前提到的,你通常应该尝试将大组件(处理许多不同的状态切片)拆分成多个较小的组件。

然而,管理多个状态值还有另一种选择:你也可以管理一个单一的、合并的状态值对象。

管理合并状态对象

而不是为每个单独的状态切片调用 useState(),你可以选择一个 大的 状态对象,它结合了所有不同的状态值:

function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 
  function handleUpdateEmail(event) {
    setUserData({
      email: event.target.value,
      password: userData.password
    });
  };
  function handleUpdatePassword(event) {
    setUserData({
      email: userData.email,
      password: event.target.value
    });
  };
  // ... code omitted, because the returned JSX code is the same as before
}; 

在这个例子中,useState() 只被调用了一次(即只有一个状态切片),传递给 useState() 的初始值是一个 JavaScript 对象。该对象包含两个属性:emailpassword。属性名由你决定,但它们应该描述将存储在属性中的值。

useState() 仍然返回一个包含恰好两个元素的数组。初始值是一个对象并不会改变这一点。现在返回数组的第一个元素是一个对象,而不是一个字符串(正如之前展示的例子中那样)。正如之前提到的,当使用 useState() 时,可以使用任何有效的 JavaScript 值类型。原始值类型,如字符串或数字,可以像引用值类型(如对象或数组,技术上它们当然也是对象)一样使用。

状态更新函数(例如前一个示例中的 setUserData)仍然是 React 创建的函数,您可以调用它来将状态设置为新的值。尽管通常情况下您不需要再次将其设置为对象,但这通常是默认行为。除非您有充分的理由,否则在更新状态时不要更改值类型(尽管,技术上,您可以在任何时候切换到不同的类型)。

注意

在前一个示例中,使用状态更新函数的方式并不完全正确。它将工作,但它违反了推荐的最佳实践。您将在本章后面学习为什么这是这种情况以及如何使用状态更新函数。

在管理状态对象,如前一个示例所示,有一件至关重要的事情您必须记住:您必须始终设置对象包含的所有属性,即使是没有更改的属性。这是必需的,因为,在调用状态更新函数时,您“告诉”React 应该存储哪个新的状态值。

因此,您传递给状态更新函数的任何值都将覆盖之前存储的值。如果您提供一个只包含已更改属性的对象,所有其他属性都将丢失,因为上一个状态对象被新的一个替换,而新的对象包含的属性更少。

这是一个常见的陷阱,因此您必须注意这一点。因此,在前面示例中,未更改的属性被设置为上一个状态值——例如,email: userData.email,其中userData是当前状态快照,也是useState()返回的数组的第一个元素,同时将password设置为event.target.value

您完全可以根据自己的喜好来管理一个状态值(即,将多个值组合在一起的对象)或多个状态切片(即,多个useState()调用)。没有正确或错误的方法,两种方法都有其优点和缺点。

然而,值得注意的是,您通常应该尝试将大型组件拆分成更小的组件。就像常规 JavaScript 函数不应该在单个函数中做太多工作(被认为是一种良好的实践,为不同的任务有单独的函数)一样,组件也应该专注于每个组件的一个或几个任务。而不是有一个巨大的<App />组件,该组件直接在一个组件中处理多个表单、用户身份验证和购物车,最好是将其代码拆分成多个较小的组件,然后将这些组件组合起来构建整个应用程序。

遵循那条建议时,大多数组件实际上不需要管理太多状态,因为管理许多状态值是组件做“太多工作”的指标。这就是为什么您可能会在每个组件中使用几个状态切片,而不是大型状态对象。

正确基于前一个状态更新状态

当学习对象作为状态值时,你了解到很容易不小心覆盖(并丢失)数据,因为你可能将新状态设置为只包含已更改属性的对象——而不是未更改的属性。这就是为什么在处理对象或数组作为状态值时,始终将现有属性和元素添加到新状态值中很重要的原因。

此外,通常,将状态值设置为基于前一个状态的新值(至少部分基于前一个状态)是一个常见任务。你可能将password设置为event.target.value,但也将email设置为userData.email,以确保存储的电子邮件地址不会因为更新整体状态的一部分(即,因为更新密码为新输入的值)而丢失。

但这并不是新状态值可能基于前一个状态的唯一场景。另一个例子是一个counter组件——例如,一个像这样的组件:

function Counter() {
  const [counter, setCounter] = useState(0);
  function handleIncrement() {
    setCounter(counter + 1);
  };
  return (
    <>
      <p>Counter Value: {counter}</p>
      <button onClick={handleIncrement}>Increment</button>
    </>
  );
}; 

在这个例子中,为<button>元素注册了一个click事件处理程序(通过onClick属性)。每次点击时,计数器的状态值都会增加1

这个组件可以正常工作,但示例代码片段中展示的代码实际上违反了一个重要的最佳实践和推荐:依赖于某些先前状态的状态更新应该通过传递给状态更新函数的函数来完成。更准确地说,示例应该这样重写:

function Counter() {
  const [counter, setCounter] = useState(0);
  function handleIncrement() {
    setCounter(function(prevCounter) { return prevCounter + 1; });
    // alternatively, JS arrow functions could be used:
    // setCounter(prevCounter => prevCounter + 1);
  };
  return (
    <>
      <p>Counter Value: {counter}</p>
      <button onClick={handleIncrement}>Increment</button>
    </>
  );
}; 

这可能看起来有点奇怪。它可能看起来像是现在将一个函数作为新的状态值传递给了状态更新函数(即,counter中存储的数字被一个函数所替代)。但实际上并非如此。

技术上,确实是将一个函数作为参数传递给了状态更新函数,但 React 不会将这个函数存储为新状态值。相反,当状态更新函数接收到一个函数作为新的状态值时,React 会为你调用这个函数,并将最新的状态值传递给它。因此,你应该提供一个至少接受一个参数的函数:前一个状态值。这个值将由 React 在执行函数时(它将在内部执行)自动传递给函数。

这个函数应该返回一个值——React 应该存储的新状态值。此外,由于函数接收到了前一个状态值,你现在可以根据前一个状态值推导出新状态值(例如,通过将其与数字 1 相加,但这里可以执行任何操作)。

为什么在这次更改之前应用运行正常的情况下还需要这样做呢?这是因为,在更复杂的 React 应用和 UI 中,React 可能会同时处理多个状态更新——这些更新可能来自不同的来源,在不同的时间触发。

使用上一段中讨论的方法时,状态更新的顺序可能不是预期的,并且可能会在应用中引入错误。即使你知道你的用例不会受到影响,并且应用在没有问题的状态下完成其工作,也建议简单地遵循讨论过的最佳实践,并在新状态依赖于前一个状态时将函数传递给状态更新函数。

在心中牢记这一新获得的知识,再看看之前的代码示例:

function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 
  function handleUpdateEmail(event) {
    setUserData({
      email: event.target.value,
      password: userData.password
    });
  };
  function handleUpdatePassword(event) {
    setUserData({
      email: userData.email,
      password: event.target.value
    });
  };
  // ... code omitted, because the returned JSX code is the same as before
}; 

你能在这段代码中找到错误吗?

这不是一个技术错误;代码将正常执行,应用将按预期工作。但尽管如此,这段代码还是有问题。它违反了讨论过的最佳实践。在代码片段中,两个处理函数中的状态都是通过userData.passworduserData.email分别引用当前状态快照来更新的。

代码片段应该这样重写:

function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 
  function handleUpdateEmail(event) {
    setUserData(prevData => ({
      email: event.target.value,
      password: prevData.password
    }));
  };
  function handleUpdatePassword(event) {
    setUserData(prevData => ({
      email: prevData.email,
      password: event.target.value
    }));
  };
  // ... code omitted, because the returned JSX code is the same as before
  // userData is not actively used here, hence you could get a warning
  // regarding that. Simply ignore it or start using userData
  // (e.g., via console.log(userData))
}; 

通过将箭头函数作为setUserData的参数传递,你允许 React 调用该函数。React 会自动这样做(也就是说,如果它在这一点上收到一个函数,React 会调用它),并且它会自动提供前一个状态(prevState)。然后返回的值(存储更新后的emailpassword以及当前存储的emailpassword的对象)被设置为新的状态。在这种情况下,结果可能与之前相同,但现在代码遵循了推荐的最佳实践。

注意

在之前的例子中,使用了箭头函数而不是“常规”函数。两种方法都很好,你可以使用这两种函数类型中的任何一种;结果将是相同的。

总结来说,如果你新状态依赖于前一个状态,你应该始终将函数传递给状态更新函数。否则,如果新状态依赖于其他值(例如,用户输入),直接将新状态值作为函数参数传递是完全没问题且推荐的。

双向绑定

值得讨论的是 React 状态概念的一个特殊用法:双向绑定

双向绑定是一个概念,如果你有一个输入源(通常是<input>元素),它在用户输入时(例如,在change事件上)设置一些状态,并同时输出输入。

这里有一个例子:

function NewsletterField() {
  const [email, setEmail] = useState('');
  function handleUpdateEmail(event) {
    setEmail(event.target.value);
  };
  return (
    <>
      <input
        type="email"
        placeholder="Your email address"
        value={email}
        onChange={handleUpdateEmail} />
    </>    
  );
}; 

与其他代码片段和示例相比,这里的区别在于组件不仅仅在change事件(在这种情况下)上存储用户输入,而且输入的值还会在之后的<input>元素中输出(通过默认的value属性)。

这可能看起来像是一个无限循环,但 React 会处理这种情况,确保它不会变成一个无限循环。相反,这通常被称为双向绑定,因为值既从同一源设置又从同一源读取。

你可能会想知道为什么这个问题在这里被讨论,但重要的是要知道,编写这样的代码是完全有效的。此外,如果你不仅想在 <input> 字段的用户输入时设置值(在这个例子中,是 email 值),还希望从其他来源设置值,这种代码可能是必要的。例如,你可能有一个按钮在组件中,当点击时,应该清除输入的电子邮件地址。

它可能看起来像这样:

function NewsletterField() {
  const [email, setEmail] = useState('');
  function handleUpdateEmail(event) {
    setEmail(event.target.value);
  };
  function handleClearInput() {
    setEmail(''); // reset email input (back to an empty string)
  };
  return (
    <>
      <input
        type="email"
        placeholder="Your email address"
        value={email}
        onChange={handleUpdateEmail} />
      <button onClick={handleClearInput}>Reset</button>
    </>
  );
}; 

在这个更新的示例中,当点击 <button> 时会执行 handleClearInput 函数。在函数内部,email 状态被设置回空字符串。如果没有双向绑定,状态会更新,但变化不会反映在 <input> 元素上。在那里,用户仍然会看到他们最后的输入。UI(网站)上反映的状态和 React 内部管理的状态将不同——这是一个绝对必须避免的错误。

从状态中推导值

如你至今可能已经注意到的,状态是 React 中的一个关键概念。状态允许你管理数据,当数据改变时,它会迫使 React 重新评估组件,并最终更新 UI。

作为开发者,你可以在组件的任何地方(以及通过 props 将状态传递给子组件)使用状态值。例如,你可以像这样重复用户输入的内容:

function Repeater() {
  const [userInput, setUserInput] = useState('');
  function handleChange(event) {
    setUserInput(event.target.value);
  };
  return (
    <>
      <input type="text" onChange={handleChange} />
      <p>You entered: {userInput}</p>
    </>
  );
}; 

这个组件可能不是非常有用,但它会工作,并且它确实使用了状态。

通常,为了做更有用的事情,你需要使用状态值作为基础来推导一个新的(通常是更复杂的)值。例如,你不仅可以简单地重复用户输入的内容,还可以计算输入的字符数,并将该信息显示给用户:

function CharCounter() {
  const [userInput, setUserInput] = useState('');
  function handleChange(event) {
    setUserInput(event.target.value);
  };
  const numChars = userInput.length;
  return (
    <>
      <input type="text" onChange={handleChange} />
      <p>Characters entered: {numChars}</p>
    </>
  );
}; 

注意新添加的 numChars 常量(它也可以通过 let 变量)。这个常量是通过访问存储在 userInput 状态中的字符串值的 length 属性来从 userInput 状态推导出来的。

这很重要!你不仅限于只与状态值一起工作。你可以将某些关键值作为状态(即会改变的值)来管理,并基于该状态值推导其他值——例如,在这个例子中,用户输入的字符数。实际上,作为 React 开发者,你将经常这样做。

你可能还在想为什么 numChars 是一个常量,并且位于 handleChange 函数之外。毕竟,那是用户输入时(即用户每次按键时)执行的功能。

请记住你学到的关于 React 如何处理内部状态的知识。当你调用状态更新函数(在这个例子中是 setUserInput)时,React 会重新评估属于该状态组件。这意味着 React 会再次调用 CharCounter 组件函数。因此,该函数中的所有代码都会再次执行。

组件图  描述自动生成

图 4.3:当组件函数再次执行时,numChars 值是从状态中派生出来的

React 会重新执行组件函数以确定在状态更新后 UI 应该是什么样子;如果它检测到与当前渲染 UI 的差异,React 将相应地更新浏览器 UI(即 DOM)。否则,不会发生任何事。

由于 React 会再次调用组件函数,useState()将产生其值数组(当前状态值和状态更新函数)。当前状态值将是setUserInput被调用时设置的状态。因此,这个新的userInput值可以在组件函数的任何地方使用来执行其他计算——例如,通过访问userInputlength属性来推导numChars(如图 4.3所示)。

因此,numChars可以是一个常量。对于此组件执行,它不会被重新分配。只有在组件函数在未来再次执行时(即如果再次调用setUserInput),才可能派生新的值。在这种情况下,将创建一个新的numChars常量(而旧的将被丢弃)。

与表单和表单提交一起工作

在处理表单和用户输入时,通常使用状态。实际上,本章中的大多数示例都涉及某种形式的用户输入。

到目前为止,所有示例都集中在监听直接附加到单个输入元素的用户事件上。这很有意义,因为你会经常想要监听诸如按键或输入失去焦点等事件。特别是当添加输入验证(即检查输入值)时,你可能想使用输入事件在用户输入时提供有用的反馈。

但对整体表单提交做出反应也是很常见的。例如,目标可能是将来自各个输入字段的输入组合起来,并将数据发送到某个后端服务器。你该如何实现这一点?你该如何监听并响应表单的提交?

你可以在标准 JavaScript 事件和 React 提供的适当事件处理属性的帮助下完成所有这些操作。具体来说,可以将onSubmit属性添加到<form>元素中,以分配一个在表单提交时应执行的功能。为了使用 React 和 JavaScript 处理提交,你必须确保浏览器不会执行其默认操作并自动生成(并发送)HTTP 请求。

与纯 JavaScript 一样,这可以通过在自动生成的事件对象上调用preventDefault()方法来实现。

下面是一个完整的示例:

function NewsletterSignup() {
  const [email, setEmail] = useState('');
  const [agreed, setAgreed] = useState(false);
  function handleUpdateEmail(event) {
    // could add email validation here
    setEmail(event.target.value);
  };
  function handleUpdateAgreement(event) {
    setAgreed(event.target.checked); // checked is a default JS boolean property
  };
  function handleSignup(event) {
    event.preventDefault(); // prevent browser default of sending a Http request
    const userData = {userEmail: email, userAgrees: agreed};
    // doWhateverYouWant(userData);
  };
  return (
    <form onSubmit={handleSignup}>
      <div>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" onChange={handleUpdateEmail}/>
      </div>
      <div>
        <input type="checkbox" id="agree" onChange={handleUpdateAgreement}/>
        <label htmlFor="agree">Agree to terms and conditions</label>
      </div>
    </form>
  );
}; 

这个代码片段通过将handleSignup()函数分配给内置的onSubmit属性来处理表单提交。用户输入仍然通过两个状态片段(emailagreed)获取,这些状态片段在输入更改事件发生时更新。

注意

在前面的代码示例中,你可能注意到了一个之前在这本书中没有使用过的新属性:htmlFor。这是一个特殊的属性,它是 React 及其提供的核心 JSX 元素内置的。它可以添加到<label>元素中,以便为这些元素设置for属性。之所以称为htmlFor而不是仅仅for,是因为,正如本书前面所解释的,JSX 看起来像 HTML,但实际上不是 HTML。它是底层的 JavaScript。在 JavaScript 中,for是一个用于for循环的保留关键字。为了避免问题,因此将属性命名为htmlFor

使用onSubmit(结合preventDefault())处理表单提交是处理 React 中用户输入和表单的一种非常常见的方式。但是,当你在使用 React 19 或更高版本的项目上工作时,你也可以使用另一种处理表单提交的方法:你可以使用一个称为表单操作的 React 功能,这将在第九章使用表单操作处理用户输入和表单中详细讨论。

提升状态

这是一个常见的场景和问题:在你的 React 应用程序中有两个组件,组件 A 中的更改或事件应该改变组件 B 中的状态。为了使这个问题更具体,考虑以下简单的例子:

function SearchBar() {
  const [searchTerm, setSearchTerm] = useState('');
  function handleUpdateSearchTerm(event) {
    setSearchTerm(event.target.value);
  };
  return <input type="search" onChange={handleUpdateSearchTerm} />;
};
function Overview() {
  return <p>Currently searching for {searchTerm}</p>;
};
function App() {
  return (
    <>
      <SearchBar />
      <Overview />
    </>
  );
}; 

在这个例子中,Overview组件应该输出输入的搜索词。然而,搜索词实际上是在另一个组件中管理的——即SearchBar组件。在这个简单的例子中,当然可以将这两个组件合并成一个单独的组件,问题也就解决了。但在构建更现实的应用程序时,你可能会遇到类似的场景,但组件会更复杂。将组件拆分成更小的部分被认为是良好的实践,因为它保持了各个组件的可管理性。

因此,在处理 React 时,多个组件依赖于某些共享的状态是一个你将经常遇到的场景。

这个问题可以通过提升状态来解决。当提升状态时,状态不是在两个使用它的组件中管理——既不在读取状态的Overview组件中,也不在设置状态的SearchBar组件中——而是在一个共享的祖先组件中。更准确地说,它是在最近的共享祖先组件中管理的。记住,组件是嵌套的,因此最终会构建出一个“组件树”(其中App组件是根组件)。

img

图 4.4:一个示例组件树

在前面的简单代码示例中,App组件是SearchBarOverview两个组件最近的(在这个例子中,也是唯一的)祖先组件。如果应用程序的结构如图所示,状态设置在一个Product组件中并在Cart中使用,Products将是最近的祖先组件。

通过在需要操作(即设置)或读取状态的组件中使用属性,以及通过在两个其他组件共享的祖先组件中注册状态,来提升状态。以下是之前更新的示例:

function SearchBar({onUpdateSearch}) {
  return <input type="search" onChange={onUpdateSearch} />;
};
function Overview({currentTerm}) {
  return <p>Currently searching for {currentTerm}</p>;
};
function App() {
  const [searchTerm, setSearchTerm] = useState('');
  function handleUpdateSearchTerm(event) {
    setSearchTerm(event.target.value);
  };
  return (
    <>
      <SearchBar onUpdateSearch={handleUpdateSearchTerm} />
      <Overview currentTerm={searchTerm} />
    </>
  );
}; 

代码实际上并没有改变多少;它主要只是移动了一下位置。现在状态是在共享的祖先和App组件内部管理的,而其他两个组件通过属性访问它。

在这个例子中有三个关键的事情发生:

  1. SearchBar组件接收一个名为onUpdateSearch的属性,其值是一个函数——这是一个在App组件中创建并从App传递到SearchBar的函数。

  2. 然后,将onUpdateSearch属性设置为SearchBar组件内部<input>元素的onChange属性的值。

  3. searchTerm状态(即其当前值)通过名为currentTerm的属性从App传递到Overview

前两点可能有些令人困惑。但请记住,在 JavaScript 中,函数是一等对象和常规值。你可以将函数存储在变量中,并在使用 React 时,将函数作为属性值传递。实际上,你已经在本章的开头看到了这一点。在介绍事件和事件处理时,函数被作为值传递给了所有这些onXYZ属性(onChangeonBlur等等)。

在这个代码片段中,一个函数被作为自定义属性(即,你创建的组件中期望的属性,而不是 React 内置的属性)的值传递。onUpdateSearch属性期望一个函数作为值,因为该属性本身被用作<input>元素上的onChange属性的值。

该属性被命名为onUpdateSearch,以使其明确期望一个函数作为值,并且它将被连接到事件。当然,可以选择任何名称;它不必以on开头。但这是一个常见的约定,为期望函数作为值并且打算连接到类似事件的属性命名。

当然,updateSearch不是一个默认事件,但由于该函数将在<input>元素的change事件上被调用,因此该属性的行为就像一个自定义事件。

使用这种结构,状态被提升到了App组件。该组件注册并管理状态。然而,它还通过handleUpdateSearchTerm函数(在这种情况下,间接地)将状态更新函数暴露给SearchBar组件。它还通过currentTerm属性将当前状态值(searchTerm)传递给Overview组件。

由于子组件和后代组件在组件状态变化时也会被 React 重新评估,因此App组件的变化也会导致SearchBarOverview组件被重新评估。因此,searchTerm的新属性值将被获取,React 将通过 UI 更新。

对于此操作不需要新的 React 功能。这只是一个状态和 props 的组合。然而,根据这些功能如何连接以及它们在哪里使用,可以实现简单和更复杂的 app 模式。

概括和要点

  • 可以通过on[EventName]属性(例如,onClickonChange)将事件处理器添加到 JSX 元素中。

  • 任何函数都可以在(用户)事件上执行。

  • 为了强制 React 重新评估组件和(可能)更新渲染的 UI,必须使用状态。

  • 状态是指 React 内部管理的数据,可以通过useState() Hook 定义状态值。

  • React Hooks 是 JavaScript 函数,它们为 React 组件添加特殊功能(例如,本章中的状态功能)。

  • useState()总是返回一个包含恰好两个元素的数组:

    • 第一个元素是当前状态值。

    • 第二个元素是一个将状态设置为新的值(即状态更新函数)的函数。

  • 当设置状态为新值,该值依赖于前一个值时,应将一个函数传递给状态更新函数。然后,该函数接收前一个状态作为参数(将由 React 自动提供)并返回应设置的新状态。

  • 任何有效的 JavaScript 值都可以设置为状态——除了字符串或数字等原始值。这还包括对象和数组等引用值。

  • 如果状态需要因为另一个组件中发生的事件而改变,你应该提升状态并在更高、共享的级别(即公共祖先组件)上管理它。

接下来是什么?

状态是一个极其重要的构建块,因为它使你能够构建真正动态的应用程序。在这个关键概念解决之后,下一章将深入探讨利用状态(以及迄今为止学到的其他概念)来条件性地渲染内容和渲染内容列表。

这些是在几乎任何你正在构建的 UI 或 Web 应用中都需要执行的任务,无论它是关于显示警告覆盖层还是显示产品列表。下一章将帮助你向你的 React 应用添加这些功能。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/04-state-events/exercises/questions-answers.md找到的示例进行比较。

  1. 状态解决了哪个“问题”?

  2. props 和状态之间的区别是什么?

  3. 状态是如何在组件中注册的?

  4. useState() Hook 提供了哪些值?

  5. 单个组件可以注册多少个状态值?

  6. 状态是否也会影响其他组件(而不仅仅是注册状态的组件)?

  7. 如果新状态依赖于前一个状态,应该如何更新状态?

  8. 怎样才能在多个组件之间共享状态?

应用所学知识

在本章中获得的新知识使你最终能够构建真正动态的 UI 和 React 应用程序。现在,你不再局限于硬编码的静态内容和页面,可以使用状态来设置和更新值,并强制 React 重新评估组件和 UI。

在这里,你可以找到一个活动,让你应用到目前为止所获得的所有知识,包括新的状态知识。

活动四.1:构建简单计算器

在这个活动中,你将构建一个非常基本的计算器,允许用户将两个数字相加、相减、相乘和相除。

步骤如下:

  1. 使用 React 组件构建 UI。确保为四个数学运算构建四个单独的组件,即使有很多代码可以复用。

  2. 当用户在两个相关输入字段之一中输入值时,收集用户输入并更新结果。

注意,当与数字打交道并从用户输入中获取这些数字时,你需要确保输入的值被当作数字处理,而不是字符串。

计算器的最终结果和 UI 应该看起来像这样:

img

图 4.5:计算器 UI

注意

样式当然会有所不同。要获得截图中所显示的相同样式,请使用我准备的起始项目,你可以在这里找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/04-state-events/activities/practice-1-start

分析那个项目中的index.css文件,以确定如何结构化你的 JSX 代码来应用样式。

注意

你可以在这里找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/04-state-events/activities/practice-1

活动四.2:增强计算器

在这个活动中,你将基于活动 4.1来构建一个稍微复杂一点的计算器。目标是减少组件数量,并构建一个单组件,用户可以通过下拉元素选择数学运算。此外,结果应该输出到不同的组件中——也就是说,不是在收集用户输入的组件中。

步骤如下:

  1. 从上一个活动中移除四个组件中的三个,并使用一个单组件来处理所有数学运算。

  2. 在剩余的组件(两个输入之间)添加一个下拉元素(<select>元素),并将四个数学运算作为选项(<option>元素)添加到其中。

  3. 使用状态来收集用户输入的数字和通过下拉菜单选择的数学运算(是否喜欢一个单一的状态对象或多个状态片段由你决定)。

  4. 在另一个组件中输出结果。(提示:选择一个合适的地方来注册和管理状态。)

计算器的结果和 UI 应该看起来像这样:

img

图 4.6:增强计算器的 UI

注意

你可以在这里找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/04-state-events/activities/practice-2 .

第五章:渲染列表和条件内容

学习目标

到本章结束时,你将能够做到以下几件事情:

  • 条件输出动态内容

  • 渲染数据列表并将列表项映射到 JSX 元素

  • 优化列表,以便 React 在需要时能够高效地更新用户界面

简介

到这本书的这一部分,你已经熟悉了几个关键概念,包括组件、属性、状态和事件,这些是构建各种不同 React 应用和网站所需的所有核心工具。你也已经学会了如何将动态值和结果作为用户界面的一部分输出。

然而,有两个与输出动态数据相关的话题尚未深入讨论:条件输出内容和渲染列表数据。由于你构建的大多数(如果不是所有)网站和 Web 应用都将需要这两个概念中的至少一个,因此了解如何处理条件内容和列表数据至关重要。

因此,在本章中,你将学习如何根据动态条件渲染和显示不同的用户界面元素(甚至整个用户界面部分)。此外,你还将学习如何输出数据列表(如待办事项列表及其条目)并动态渲染构成列表的 JSX 元素。本章还将探讨与输出列表和条件内容相关的重要最佳实践。

条件内容和列表数据是什么?

在深入研究输出条件内容或列表数据的技巧之前,了解这些术语的确切含义非常重要。

条件内容简单来说就是任何只在特定情况下应该显示的内容。以下是一些示例:

  • 仅在用户在表单中提交错误数据时显示的错误覆盖层

  • 用户选择输入额外详细信息(如业务详情)时出现的附加表单输入字段

  • 在向或从后端服务器发送或获取数据时显示的加载旋转器

  • 当用户点击菜单按钮时滑入视图的侧导航菜单

这只是一个包含几个示例的非常简短的列表。当然,你可以想出数百个额外的例子。但最终应该清楚所有这些示例都是关于什么的:仅在满足某些条件时才显示的视觉元素或用户界面的整个部分。

在第一个示例(错误覆盖层)中,条件是用户在表单中输入了错误数据。然后,条件显示的内容将是错误覆盖层。

条件内容非常常见,因为几乎所有的网站和 Web 应用都有一些与前面示例相似或可比的内容。

除了条件性内容外,许多网站还会输出数据列表。这不一定总是立即明显,但如果你仔细想想,几乎没有任何网站不显示某种类型的列表数据。再次,这里有一些可能在网站上输出的列表数据的例子:

  • 显示产品网格或列表的在线商店

  • 显示活动列表的活动预订网站

  • 显示购物车中商品列表的购物车

  • 显示订单列表的订单页面

  • 显示博客文章列表——以及可能位于博客文章下方的评论列表

  • 页眉中的导航项列表

一个没有恶意的无尽列表(例子)可以在这里创建。列表在网络上无处不在。正如前面的例子所示,许多(可能甚至大多数)网站在同一网站上都有多个列表,包含各种类型的数据。

以一个在线商店为例。在这里,你会有一个产品列表(或者一个网格,实际上它只是另一种列表形式),购物车商品列表,订单列表,页眉中的导航项列表,以及当然还有很多其他的列表。这就是为什么了解如何在 React 驱动的用户界面中输出任何类型的数据的任何类型的列表变得非常重要。

条件性渲染内容

想象以下场景。你有一个按钮,点击后应该显示一个额外的文本框,如下所示:

一个黑白矩形标志 自动生成的描述

图 5.1:最初,屏幕上只显示按钮

点击按钮后,另一个框被显示:

计算机屏幕截图 自动生成的描述

图 5.2:点击按钮后,信息框被显示

这是一个非常简单的例子,但并非不切实际。许多网站的用户界面部分都像这样工作。在按钮点击(或类似交互)时显示额外信息是一种常见模式。只需想想食品订单网站上餐点下方的营养成分信息或是在选择问题后显示答案的常见问题解答部分。

那么,这个场景如何在 React 应用中实现呢?

如果你忽略了渲染某些内容条件性的要求,整个 React 组件可能看起来像这样:

function TermsOfUse() {
  return (
    <section>
      <button>Show Terms of Use Summary</button>
      <p>By continuing, you accept that we will not indemnify you for any
      damage or harm caused by our products.</p>
    </section>
  );
} 

这个组件中完全没有条件性代码,因此按钮和额外的信息框总是显示出来。

在这个例子中,如何有条件地显示包含使用条款摘要文本的段落(即,仅在按钮点击后显示)?

通过前面章节中获得的知识,特别是第四章处理事件和状态,你已经拥有了在按钮点击后仅显示文本所需的所有技能。以下代码显示了组件如何被重写,以便仅在按钮点击后显示完整文本:

import { useState } from 'react';

function TermsOfUse() {
  const [showTerms, setShowTerms] = useState(false);
  function handleShowTermsSummary() {
    setShowTerms(true);
  }
  let paragraphText = '';
  if (showTerms) {
    paragraphText = 'By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.';
  }
  return (
    <section>
      <button onClick={handleShowTermsSummary}>
         Show Terms of Use Summary
      </button>
      <p>{paragraphText}</p>
    </section>
  );
} 

在此代码片段中显示的代码部分已经符合条件内容的资格。paragraphText值是根据存储在showTerms状态中的值有条件地设置的,借助一个if语句。

然而,<p>元素本身实际上不是条件性的。它始终存在,无论它包含一个完整的句子还是一个空字符串。如果你打开浏览器开发者工具并检查该页面的该区域,你会看到一个空的段落元素,如下面的图所示:

img

图 5.3:一个空的段落元素作为 DOM 的一部分被渲染

在 DOM 中保留那个空的<p>元素并不是理想的做法。虽然它对用户来说是不可见的,但它是一个需要浏览器渲染的额外元素。性能影响可能非常小,但仍然是你应该避免的事情。网页不会从包含无内容的空元素中受益。

您可以将关于条件值(例如段落文本)的知识翻译成条件元素。除了在变量中存储标准值,如文本或数字外,您还可以在变量中存储 JSX 元素。这是因为,如第一章React – What and Why中提到的,JSX 只是一种语法糖。在幕后,一个 JSX 元素是一个由 React 执行的标准 JavaScript 函数。当然,函数调用的返回值也可以存储在变量或常量中。

考虑到这一点,以下代码可以用来有条件地渲染整个段落:

import { useState } from 'react';
function TermsOfUse() {
  const [showTerms, setShowTerms] = useState(false);
  function handleShowTermsSummary() {
    setShowTerms(true);
  }
  let paragraph;
  if (showTerms) {
    paragraph = <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p>;
  }
  return (
    <section>
      <button onClick={handleShowTermsSummary}>
         Show Terms of Use Summary
      </button>
      {paragraph}
    </section>
  );
} 

在这个例子中,如果showTermstrueparagraph变量不存储文本,而是存储一个完整的 JSX 元素(<p>元素)。在返回的 JSX 代码中,存储在paragraph变量中的值通过{paragraph}动态输出。如果showTermsfalseparagraph存储的值为undefined,并且不会将任何内容渲染到 DOM 中。因此,在 JSX 代码中插入nullundefined会导致 React 不输出任何内容。但如果showTermstrue,整个段落作为一个值保存并输出到 DOM 中。

这就是如何动态渲染整个 JSX 元素的方法。当然,你不仅限于单个元素。你可以在变量或常量中存储整个 JSX 树结构(如多个、嵌套或兄弟 JSX 元素)。作为一个简单的规则,任何可以由组件函数返回的内容都可以存储在变量中。

有条件地渲染内容的不同方式

在前面显示的示例中,内容是通过使用变量有条件地渲染的,该变量通过if语句设置,然后在 JSX 代码中动态输出。这是一种常见的(并且完全可行的)有条件渲染内容的技术,但并不是你唯一可以使用的方案。

或者,你也可以这样做:

  • 利用三元表达式。

  • 滥用 JavaScript 逻辑运算符。

  • 使用任何其他有效的 JavaScript 条件选择值的方式。

以下各节将详细探讨每种方法。

利用三元表达式

在 JavaScript(以及许多其他编程语言)中,你可以使用 三元表达式(也称为 条件三元运算符)作为 if 语句的替代。三元表达式可以节省代码行数,尤其是在简单条件中,主要目标是条件性地分配一些变量值。

这里是一个直接的比较——首先从一个普通的 if 语句开始:

let a = 1;
if (someCondition) {
  a = 2;
} 

这里是相同的逻辑,使用三元表达式实现:

const a = someCondition ? 2 : 1; 

这段代码是标准的 JavaScript 代码,并非特定于 React。然而,理解这个核心 JavaScript 功能对于理解如何在 React 应用程序中使用它非常重要。

将其翻译到先前的 React 示例中,段落内容可以通过以下三元表达式有条件地设置和输出:

import { useState } from 'react';
function TermsOfUse() {
  const [showTerms, setShowTerms] = useState(false);
  function handleShowTermsSummary() {
    setShowTerms(true);
  }
  const paragraph = showTerms ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null;
  return (
    <section>
      <button onClick={handleShowTermsSummary}>
         Show Terms of Use Summary
      </button>
      {paragraph}
    </section>
  );
} 

正如你所见,整体代码比之前使用 if 语句时更短。段落常量包含段落(包括文本内容)或 nullnull 被用作替代值,因为 null 可以安全地插入到 JSX 代码中,它只会导致在该位置不渲染任何内容。

三元表达式的一个缺点是可读性和可理解性可能会受到影响——尤其是在使用嵌套的三元表达式时,如下面的例子所示:

const paragraph = !showTerms ? null : someOtherCondition ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null; 

这段代码难以阅读,甚至更难以理解。因此,通常你应该避免编写嵌套的三元表达式,并在这种情况下回退到 if 语句。

然而,尽管存在这些潜在的缺点,三元表达式可以帮助你在 React 应用程序中编写更少的代码,尤其是在内联使用时,直接在某个 JSX 代码内部:

import { useState } from 'react';
function TermsOfUse() {
  const [showTerms, setShowTerms] = useState(false);
  function handleShowTermsSummary() {
    setShowTerms(true);
  }
  return (
    <section>
      <button onClick={handleShowTermsSummary}>
        Show Terms of Use Summary
      </button>
      {showTerms ? <p>By continuing, you accept that we will not indemnify you for any damage or harm caused by our products.</p> : null}
    </section>
  );
} 

这与之前的例子相同,但现在它更短,因为在这里你通过在 JSX 片段中直接使用三元表达式来避免使用 paragraph 常量。这使得组件代码相对简洁,因此在 React 应用程序中,在 JSX 代码中使用三元表达式以利用这一点是非常常见的。

滥用 JavaScript 逻辑运算符

三元表达式之所以受欢迎,是因为它们允许你编写更少的代码,当在正确的地方使用(并避免嵌套多个三元表达式)时,可以帮助提高整体的可读性。

尤其是在 React 应用程序中,在 JSX 代码中你经常会写出类似这样的三元表达式:

<div>
  {showDetails ? <h1>Product Details</h1> : null}
</div> 

或者,像这样:

<div>
  {showTerms ? <p>Our terms of use …</p> : null}
</div> 

这两个代码片段有什么共同之处?

它们是不必要的长,因为在两个例子中,即使它对最终用户界面没有任何贡献,也必须指定 else 情况(: null)。毕竟,这些三元表达式的首要目的是渲染 JSX 元素(在先前的例子中是 <h1><p>)。else 情况(: null)仅仅意味着如果条件(showDetailsshowTerms)不满足,则不渲染任何内容。

这就是为什么在 React 开发者中流行另一种模式:

<div>
  {showDetails && <h1>Product Details</h1>}
</div> 

这是实现预期结果的最短方式,如果showDetailstrue,则仅渲染<h1>元素及其内容。

此代码使用(或滥用)了 JavaScript 逻辑运算符的一个有趣的行为,特别是&&(逻辑与)运算符。在 JavaScript 中,如果第一个值(即&&前面的值)是true或真值(即不是falseundefinednull0等),则&&运算符返回第二个值(即&&后面的值)。通常,你会在if语句或三元表达式中使用&&运算符。然而,当与 React 和 JSX 一起工作时,你可以利用前面描述的行为有条件地输出真值。这种技术也称为短路

例如,以下代码将输出'Hello'

console.log(1 === 1 && 'Hello'); 

这种行为可以用来编写非常短的检查条件并输出另一个值的表达式,如前例所示。

注意

值得注意的是,如果你使用非布尔条件值(即&&前面的值持有非布尔值)与&&一起使用,可能会导致意外结果。如果showDetails0而不是false(无论什么原因),屏幕上会显示数字0。因此,你应该确保作为条件的值产生nullfalse而不是任意假值。例如,你可以通过添加!!(例如,!!showDetails)强制转换为布尔值。如果你的条件值已经持有nullfalse,则不需要这样做。

发挥创意!

到目前为止,你已经学习了三种不同的定义和有条件输出内容的方法(常规if语句、三元表达式和使用&&运算符)。然而,最重要的观点是 React 代码最终只是常规 JavaScript 代码。因此,任何选择条件值的方案都将有效。

如果在你的特定用例和 React 应用中合理,你也可以有一个组件,它像这样有条件地选择和输出内容:

const languages = {
  de: 'de-DE',
  us: 'en-US',
  uk: 'en-GB'
};
function LanguageSelector({country}) {
  return <p>Selected Language: {languages[country]}</p>
} 

该组件根据country属性的值输出'de-DE''en-US''en-GB'。这个结果是通过使用 JavaScript 的动态属性选择语法实现的。你不需要通过点符号选择特定的属性(例如person.name),而是可以通过括号符号选择属性值。使用这种符号,你可以传递一个特定的属性名(languages['de-DE'])或者一个产生属性名的表达式(languages[country])。

以这种方式动态选择属性值是选择值从值映射中的另一种常见模式。因此,它是指定多个if语句或三元表达式的替代方案。

此外,通常你可以使用任何在标准 JavaScript 中有效的方法——因为毕竟 React 在其核心上只是标准 JavaScript。

哪种方法最好?

已经讨论了各种设置和有条件输出内容的方法,但哪种方法最好?

这完全取决于你(如果适用,还有你的团队)。最重要的优缺点已经突出显示,但最终,这是你的决定。如果你更喜欢三元表达式,选择它们而不是逻辑&&运算符也没有什么不妥。

这也将取决于你试图解决的特定问题。如果你有一个值的映射(例如国家列表及其国家语言代码),选择动态属性选择而不是多个if语句可能更可取。另一方面,如果你有一个单一的true/false条件(例如age > 18),使用标准的if语句或逻辑&&运算符可能最好。

有条件地设置元素标签

有条件地输出内容是一个非常常见的场景。但有时,你也会想要选择将要输出的 HTML 标签的类型。通常情况下,这会在你构建主要任务是对内置组件进行包装和增强的组件时发生。

这里有一个例子:

function Button({isButton, config, children}) {
  if (isButton) {
    return <button {...config}>{children}</button>;
  }
  return <a {...config}>{children}</a>;
}; 

这个Button组件检查isButton属性值是否为真值,如果是这样,就返回一个<button>元素。config属性预期是一个 JavaScript 对象,并且使用标准的 JavaScript 扩展运算符(...)将config对象的所有键值对作为属性添加到<button>元素。如果isButton不是真值(可能是因为没有为isButton提供值,或者值是false),则else条件变为活动状态。而不是<button>元素,返回一个<a>元素。

注意

使用扩展运算符(...)将对象的属性(键值对)转换为组件属性是另一个常见的 React 模式(并在第三章组件和属性中介绍)。扩展运算符不是一个 React 特定的运算符,但用于这个特殊目的

当将类似于{link: 'https://some-url.com', isButton: false}的对象扩展到<a>元素上(通过<a {...obj}>),结果将与所有属性单独设置时相同(即<a link="https://some-url.com" isButton={false}>)。

这种模式在构建自定义包装组件的情况下特别受欢迎,这些组件包装一个常见的核心组件(例如<button><input><a>)以添加某些样式或行为,同时仍然允许组件以与内置组件相同的方式使用(即,你可以设置所有默认属性)。

上一示例中的Button组件根据isButton属性值的差异返回两个完全不同的 JSX 元素。这是一种检查条件并返回不同内容(即条件内容)的好方法。

然而,通过使用特殊的 React 行为,这个组件可以用更少的代码编写:

function Button({isButton, config, children}) {
  const Tag = isButton ? 'button' : 'a';
  return <Tag {...config}>{children}</Tag>;
}; 

特殊行为在于可以将标签名(作为字符串值)存储在变量或常量中,然后这些变量或常量可以在 JSX 代码中用作 JSX 元素(只要变量或常量的名称以大写字母开头,就像所有你的自定义组件一样)。

上一示例中的Tag常量存储的是'button''a'字符串。由于它以大写字母开头(Tag,而不是tag),因此它可以在 JSX 代码片段中用作自定义组件。React 将其接受为一个组件,即使它不是一个组件函数。这是因为存储了一个标准的 HTML 元素标签名,所以 React 可以渲染相应的内置组件。相同的模式也可以用于自定义组件。不是存储字符串值,而是通过以下方式存储指向你的自定义组件函数的指针:

import MyComponent from './my-component.jsx';
import MyOtherComponent from './my-other-component.jsx';
const Tag = someCondition ? MyComponent : MyOtherComponent; 

这是一种非常有用的模式,可以帮助节省代码,从而使得组件更加精简。

输出列表数据

除了输出条件数据外,你还会经常处理需要在页面上输出的列表数据。如本章前面所述,一些例子包括产品列表、交易和导航项。

通常,在 React 应用中,此类列表数据以值的数组形式接收。例如,一个组件可能通过 props(从可能从后端 API 获取数据的另一个组件内部传递到组件中)接收产品数组:

function ProductsList({products}) {
  // … todo!
}; 

在这个例子中,产品数组可能看起来像这样:

const products = [
  {id: 'p1', title: 'A Book', price: 59.99},
  {id: 'p2', title: 'A Carpet', price: 129.49},
  {id: 'p3', title: 'Another Book', price: 39.99},
]; 

然而,数据不能这样输出。相反,通常的目标是将它转换成适合的 JSX 元素列表。例如,期望的结果可能是以下这样:

<ul>
  <li>
    <h2>A Book</h2>
    <p>$59.99</p>
  </li>
  <li>
    <h2>A Carpet</h2>
    <p>$129.49</p>
  </li>
  <li>
    <h2>Another Book</h2>
    <p>$39.99</p>
  </li>
</ul> 

如何实现这种转换?

再次强调,忽略 React 并找到一种使用标准 JavaScript 转换列表数据的方法是个好主意。实现这一目标的一种可能方式是使用for...of循环,如下所示:

const transformedProducts = [];
for (const product of products) {
  transformedProducts.push(product.title);
} 

在这个例子中,产品对象列表(products)被转换成产品标题列表(即字符串值的列表)。这是通过遍历products中的所有产品项并从每个产品中提取title属性来实现的。然后,这个title属性值被推入新的transformedProducts数组中。

可以使用类似的方法将对象列表转换成 JSX 元素列表:

const productElements = [];
for (const product of products) {
  productElements.push((
    <li>
      <h2>{product.title}</h2>
      <p>${product.price}</p>
    </li>
  ));
} 

第一次看到这样的代码时,可能会觉得有点奇怪。但请记住,JSX 代码可以在任何可以使用常规 JavaScript 值(即数字、字符串、对象等)的地方使用。因此,你也可以将 JSX 值 push 到一个值数组中。由于它是 JSX 代码,你还可以在那些 JSX 元素中动态输出内容(例如 <h2>{product.title}</h2>)。

这段代码是有效的,并且是输出列表数据的重要第一步。但这是第一步,因为当前的数据已经进行了转换,但还没有通过组件返回。

那么这样的 JSX 元素数组是如何返回的呢?

答案是它可以不使用任何特殊技巧或代码而返回。实际上,JSX 接受数组值作为动态输出的值。

你可以这样输出 productElements 数组:

return (
  <ul>
    {productElements}
  </ul>
); 

当将 JSX 元素数组插入到 JSX 代码中时,该数组内的所有 JSX 元素都会相邻输出。因此,以下两个代码片段会产生相同的输出:

return (
  <div>
    {[<p>Hi there</p>, <p>Another item</p>]}
  </div>
);
return (
  <div>
    <p>Hi there</p>
    <p>Another item</p>
  </div>
); 

考虑到这一点,ProductsList 组件可以写成这样:

function ProductsList({products}) {
  const productElements = [];
  for (const product of products) {
    productElements.push((
      <li>
        <h2>{product.title}</h2>
        <p>${product.price}</p>
      </li>
    ));
  }
  return (
    <ul>
      {productElements}
    </ul>
  );
}; 

这是输出列表数据的一种可能方法。正如本章前面所解释的,这完全关于使用标准的 JavaScript 功能,并将这些功能与 JSX 结合起来。

然而,这并不一定是 React 应用中输出列表数据最常见的方式。在大多数项目中,你会遇到不同的解决方案。

映射列表数据

使用 for 循环输出列表数据是可行的,正如前面示例中所见。然而,就像 if 语句和三元表达式一样,你可以用替代语法替换 for 循环,以编写更少的代码并提高组件的可读性。

JavaScript 提供了一个内置的数组方法,可以用来转换数组项:map() 方法。map() 是一个默认方法,可以在任何 JavaScript 数组上调用。它接受一个函数作为参数,并为每个数组项执行该函数。该函数的返回值应该是转换后的值。map() 然后将所有这些返回的转换值组合成一个新的数组,然后由 map() 返回这个新数组。

你可以这样使用 map()

const users = [
  {id: 'u1', name: 'Max', age: 35},
  {id: 'u2', name: 'Anna', age: 32}
];
const userNames = users.map(user => user.name);
// userNames = ['Max', 'Anna'] 

在这个例子中,map() 被用来将用户对象数组转换成用户名数组(即字符串值数组)。

map() 方法通常可以用更少的代码产生与 for 循环相同的结果。

因此,map() 也可以用来生成一个 JSX 元素数组,以及之前提到的 ProductsList 组件,可以重写如下:

function ProductsList({products}) {
  const productElements = products.map(product => (
      <li>
        <h2>{product.title}</h2>
        <p>${product.price}</p>
      </li>
    )
  );
  return (
    <ul>
      {productElements}
    </ul>
  );
}; 

这已经比之前的 for 循环示例更短了。然而,就像三元表达式一样,代码可以通过将逻辑直接移动到 JSX 代码中来进一步缩短:

function ProductsList({products}) {
  return (
    <ul>
      {products.map(product => (
          <li>
            <h2>{product.title}</h2>
            <p>${product.price}</p>
          </li>
        )
      )}
    </ul>
  );
}; 

根据转换的复杂性(即传递给 map() 方法的内部函数中执行的代码的复杂性),出于可读性的原因,你可能想要考虑不使用这种 内联 方法(例如,当将数组元素映射到某些复杂的 JSX 结构或在进行映射过程中的额外计算时)。最终,这取决于个人偏好和判断。

由于它非常简洁,使用 map() 方法(无论是通过额外的变量或常量,还是直接在 JSX 代码中 内联)是 React 应用和 JSX 中输出列表数据的既定标准方法。

更新列表

想象一下,你有一个数据列表映射到 JSX 元素,并在某个时刻添加了一个新的列表项。或者,考虑一个场景,其中你有一个列表,其中两个列表项交换了位置(即列表被重新排序)。如何将这些更新反映在 DOM 中?

好消息是,如果你以有状态的方式(即使用 React 的状态概念,如第四章中解释的 工作与事件和状态)执行更新,React 会为你处理这些。

然而,在更新(有状态的)列表时,有几个重要的方面你应该注意。

这里有一个简单的例子,它不会按预期工作:

import { useState } from 'react';
function Todos() {
  const [todos, setTodos] = useState(['Learn React', 'Recommend this book']);
  function handleAddTodo() {
    todos.push('A new todo');
  };
  return (
    <div>
      <button onClick={handleAddTodo}>Add Todo</button>
      <ul>
        {todos.map(todo => <li>{todo}</li>)}
      </ul>
    </div>
  );
}; 

初始时,屏幕上会显示两个待办事项(<li>学习 React</li><li>推荐这本书</li>)。但是一旦点击按钮并执行 handleAddTodo,预期显示另一个待办事项的结果将不会实现。

这是因为执行 todos.push('一个新的待办事项') 会更新 todos 数组,但 React 不会注意到这一点。请记住,你必须只通过 useState() 返回的状态更新函数来更新状态;否则,React 不会重新评估组件函数。

那么,这个代码怎么样:

function handleAddTodo() {
  setTodos(todos.push('A new todo'));
}; 

这也是不正确的,因为状态更新函数(在这个例子中是 setTodos)应该接收新的状态(即应该设置的状态)作为参数。然而,push() 方法不会返回更新后的数组。相反,它会原地修改现有的数组。即使 push() 会返回更新后的数组,使用前面的代码仍然是不正确的,因为状态更新函数执行之前,数据(在幕后)已经被改变(修改)。由于数组是对象,因此是引用数据类型,技术上,数据会在通知 React 之前被改变。遵循 React 的最佳实践,应该避免这种情况。

因此,在更新数组(或者,作为一个旁注,一般对象)时,你应该以不可变的方式(即不改变原始数组或对象)进行更新。相反,应该创建一个新的数组或对象。这个新数组可以基于旧数组,并包含所有旧数据,以及任何新的或更新的数据。

因此,todos数组应该这样更新:

function handleAddTodo() {
  setTodos(curTodos => [...curTodos, 'A new todo']);
  // alternative: Use concat() instead of the spread operator:
  // concat(), unlike push(), returns a new array
  // setTodos(curTodos => curTodos.concat('A new todo'));
}; 

通过使用concat()或新数组,结合扩展运算符,可以为状态更新函数提供一个全新的数组。注意,由于新状态依赖于前一个状态,因此将函数传递给状态更新函数。

当更新数组(或任何对象)状态值时,React 能够检测到这些变化。因此,React 将重新评估组件函数,并将任何必要的更改应用到 DOM 上。

注意

不变性不是一个 React 特有的概念,但在 React 应用中它仍然是一个关键概念。当与状态和引用值(即对象和数组)一起工作时,不变性对于确保 React 能够检测到变化以及没有“不可见”的(即不被 React 识别)状态变化执行至关重要。

更新对象和数组的不变性的方法有很多,但一种流行的方法是创建新的对象或数组,然后使用扩展运算符(...)将现有数据合并到这些新的数组或对象中。

列表项的问题

如果你正在跟随自己的代码,并且按照前几节所述输出列表数据,你可能已经注意到 React 实际上在浏览器开发者工具控制台中显示了一个警告,如下面的截图所示:

img

图 5.4:React 有时会生成关于缺少唯一键的警告

React 正在抱怨缺少键。

要理解这个警告和键背后的理念,探索一个特定的用例和该场景的潜在问题是有帮助的。假设你有一个负责显示项目列表的 React 组件——可能是一个待办事项列表。此外,假设这些列表项可以重新排序,并且列表可以通过其他方式编辑(例如,可以添加新项,更新或删除现有项等)。换句话说,列表不是静态的。

考虑这个示例用户界面,其中向待办事项列表中添加了一个新项:

img

图 5.5:通过在顶部插入新项来更新列表

在前面的图中,你可以看到最初渲染的列表(1),然后在用户输入并提交新的待办事项值后更新(2)。一个新的待办事项被添加到列表的顶部(即列表的第一个项目)(3)。

注意

这个演示应用的示例源代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/examples/02-keys找到。

如果你在这个应用上工作并打开浏览器开发者工具(然后是 JavaScript 控制台),你会看到之前提到的“缺少键”警告。这个应用也有助于理解这个警告的来源。

在 Chrome DevTools 中,导航到元素选项卡并选择一个待办项或空待办列表(即<ul>元素)。一旦添加一个新的待办项,任何插入或更新的 DOM 元素都会在元素选项卡中由 Chrome 突出显示(通过短暂闪烁)。参考以下截图:

img

图 5.6:更新的 DOM 项在 Chrome DevTools 中被突出显示

有趣的是,不仅新添加的待办元素(即新插入的<li>元素)会闪烁。相反,所有现有的<li>元素,即使它们反映的待办项没有变化,也会被 Chrome 突出显示。这表明所有这些其他的<li>元素在 DOM 中也被更新了——尽管没有必要进行这种更新。这些项之前就存在,它们的内容(待办文本)并没有改变。

由于某种原因,React 似乎会销毁现有的 DOM 节点(即现有的<li>项),然后立即重新创建它们。这发生在列表中添加的每个新待办项上。正如你可能想象的那样,这并不非常高效,可能会为渲染多个列表中数十或数百项的更复杂的应用程序造成性能问题。

这是因为 React 无法知道只有一个 DOM 节点应该被插入。它无法判断所有其他 DOM 节点是否应该保持不变,因为 React 只收到了一个新的状态值:一个新的数组,其中填充了新的 JavaScript 对象。即使这些对象的内容没有改变,它们在技术上仍然是新的对象(内存中的新值)。

作为开发者,你知道你的应用是如何工作的,以及待办数组的内容实际上并没有发生太多变化。但是 React 并不知道这一点。因此,React 确定所有现有的列表项(<li>元素)都必须被丢弃,并由反映新提供的数据(作为状态更新的部分)的新项所替换。这就是为什么每次状态更新时,所有与列表相关的 DOM 节点都会被更新(即销毁并重新创建)。

键的拯救!

之前概述的问题是一个非常常见的问题。大多数列表更新都是增量更新,而不是批量更改。但是 React 无法判断你的用例和你的列表是否属于这种情况。

这就是为什么 React 在处理列表数据和渲染列表项时使用的概念。键只是可以(并且应该)在渲染列表数据时附加到 JSX 元素上的唯一标识符值。键帮助 React 识别之前渲染且未更改的元素。通过允许对所有列表元素进行唯一标识,键还帮助 React 有效地移动(列表项)DOM 元素。

通过特殊的内置key属性将键添加到 JSX 元素中,该属性被每个组件接受:

<li key={todo.id}>{todo.text}</li> 

这个特殊的属性可以添加到所有组件中,无论是内置的还是自定义的。你不需要在你的自定义组件中以任何方式接受或处理 key 属性;React 会自动为你处理。

key 属性需要一个对每个列表项都是唯一的值。没有任何两个列表项应该有相同的键。此外,良好的键直接附加到构成列表项的底层数据。因此,列表项索引是较差的键,因为索引没有附加到列表项数据。如果你在列表中重新排列项目,索引将保持不变(数组始终从索引 0 开始,然后是 1,依此类推),但数据会发生变化。

考虑以下示例:

const hobbies = ['Sports', 'Cooking'];
const reversed = hobbies.reverse(); // ['Cooking', 'Sports'] 

在这个例子中,'Sports'hobbies 数组中的索引是 0。在 reversed 数组中,它的索引将是 1(因为它现在是第二个项目)。在这种情况下,如果使用索引作为键,数据将不会附加到它上。

良好的键是唯一的 id 值,每个 id 只对应一个值。如果该值移动或被删除,其 id 应该随之移动或消失。

通常找到良好的 id 值并不是一个大问题,因为大多数列表数据都是从数据库中获取的。无论你是在处理产品、订单、用户还是购物车项,这些数据通常都会存储在数据库中。这种数据已经具有唯一的 id 值,因为你在将数据存储在数据库中时始终有一些唯一的识别标准。

有时,值本身也可以用作键。考虑以下示例:

const hobbies = ['Sports', 'Cooking']; 

爱好是 string 类型的值,并且没有唯一的 id 值附加到个别爱好上。每个爱好都是一个原始值(一个 string)。然而,在这种情况下,你通常不会有重复的值,因为在这个数组中列出爱好一次以上是没有意义的。因此,这些值本身就符合良好的键的条件:

hobbies.map(hobby => <li key={hobby}>{hobby}</li>); 

在无法使用值本身且没有其他可能的键值的情况下,你可以在你的 React 应用代码中直接生成唯一的 id 值。作为最后的手段,你也可以回退到使用索引;但请注意,如果你重新排列列表项,这可能会导致意外的错误和副作用。

在列表项元素中添加键后,React 能够正确地识别所有项目。当组件状态发生变化时,它能够识别之前已经渲染的 JSX 元素。因此,这些元素不再被销毁或重新创建。

你可以通过再次打开浏览器 DevTools 来确认,检查在底层列表数据发生变化时哪些 DOM 元素被更新:

img

图 5.7:从多个列表项中,只有一个是 DOM 元素被更新

添加键后,在更新列表状态时,只有新的 DOM 项在 Chrome DevTools 中被突出显示。其他项被 React (正确地)忽略。

摘要和关键要点

  • 与任何其他 JavaScript 值一样,JSX 元素可以根据不同的条件动态设置和更改。

  • 内容可以通过if语句、三元表达式、逻辑“与”运算符(&&)或任何在 JavaScript 中可行的其他方式来设置条件。

  • 处理条件内容有多种方法——任何在纯 JavaScript 中可行的方案也可以在 React 应用中使用。

  • JSX 元素数组可以被插入到 JSX 代码中,这将导致数组元素作为同级 DOM 元素被输出。

  • 列表数据可以通过for循环、map()方法或任何其他导致类似转换的 JavaScript 方法转换为 JSX 元素数组。

  • 使用map()方法是将列表数据转换为 JSX 元素列表的最常见方式。

  • (通过key属性)应该将键添加到列表 JSX 元素中,以帮助 React 高效地更新 DOM。

接下来是什么?

通过条件内容和列表,你现在拥有了构建简单和更复杂用户界面所需的所有关键工具,使用 React 你可以根据需要隐藏和显示元素或元素组,并且可以动态渲染和更新元素列表以输出产品列表、订单或用户列表。

当然,这并不是构建真实用户界面所需的所有内容。添加动态更改内容的逻辑是一回事,但大多数 Web 应用还需要应用于各种 DOM 元素的 CSS 样式。本书不涉及 CSS,但下一章仍将探讨 React 应用如何被样式化。特别是当涉及到动态设置和更改样式或将样式范围限定到特定组件时,有各种 React 特定的概念,每个 React 开发者都应该熟悉。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/05-lists-conditional-code/exercises/questions-answers.md找到的示例进行比较。:

  1. “条件内容”是什么?

  2. 至少列举两种渲染 JSX 元素的条件方式。

  3. 哪种优雅的方法可以用来条件性地定义元素标签?

  4. 仅使用三元表达式(对于条件内容)的潜在缺点是什么?

  5. 如何将数据列表渲染为 JSX 元素?

  6. 为什么应该在渲染的列表项中添加键?

  7. 分别给出一个良好和不良键的示例。

应用你所学的知识

你现在能够使用你的 React 知识以各种方式改变动态用户界面。除了能够更改显示的文本值和数字之外,你现在也可以隐藏或显示整个元素(或元素块)并显示数据列表。

在以下章节中,你将找到两个活动,这些活动允许你应用你新获得的知识(结合在其他书籍章节中获得的知识)。

活动 5.1:显示条件错误消息

在这个活动中,你将构建一个基本表单,允许用户输入他们的电子邮件地址。在表单提交后,用户输入应该被验证,无效的电子邮件地址(为了简单起见,这里指的是不包含@符号的电子邮件地址)应该导致在表单下方显示错误消息。当无效的电子邮件地址变为有效时,可能可见的错误消息应该再次被移除。

执行以下步骤以完成此活动:

  1. 构建一个包含标签、输入字段(文本类型——为了演示目的,使输入错误的电子邮件地址更容易)和提交按钮的用户界面,该按钮会导致表单提交。

  2. 收集输入的电子邮件地址,如果电子邮件地址不包含@符号,则在表单下方显示错误消息。

最终用户界面应该看起来和工作方式如下所示:

img

图 5.8:此活动的最终用户界面

注意

样式当然会有所不同。要获得截图中所显示的相同样式,请使用我准备的起始项目,你可以在这里找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-1-start

分析该项目的index.css文件,以确定如何结构化你的 JSX 代码以应用样式。

注意

你可以在这里找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-1

活动 5.2:输出产品列表

在这个活动中,你将构建一个用户界面,其中在屏幕上显示产品列表((虚拟)产品)。界面还应包含一个按钮,当点击时,将另一个新的(虚拟)项目添加到现有的产品列表中。

执行以下步骤以完成此活动:

  1. 将一组虚拟产品对象(每个对象应具有 ID、标题和价格)添加到 React 组件中,并添加代码以输出这些产品项作为 JSX 元素。

  2. 向用户界面添加一个按钮。当按钮被点击时,应该向产品数据列表中添加一个新的产品对象。这应该导致用户界面更新并显示更新后的产品元素列表。

最终用户界面应该看起来和工作方式如下所示:

img

图 5.9:此活动的最终用户界面

注意

当然,样式会有所不同。要获得与截图相同的样式,请使用我准备的起始项目,您可以在以下链接找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-2-start .

分析该项目中的index.css文件,以确定如何构建您的 JSX 代码以应用样式。

注意

您可以在以下链接找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/05-lists-conditional-code/activities/practice-2 .

第六章:样式化 React 应用

学习目标

到本章结束时,你将能够做到以下几件事情:

  • 通过内联样式赋值或使用 CSS 类来样式化 JSX 元素

  • 设置内联和类样式,无论是静态的、动态的还是条件性的

  • 构建可重用的组件,允许进行样式定制

  • 利用 CSS 模块将样式限制在组件范围内

  • 理解styled-components这个第三方 CSS-in-JS 库背后的核心思想

  • 使用 Tailwind CSS 来样式化 React 应用

简介

React.js 是一个前端 JavaScript 库。这意味着它全部关于构建(Web)用户界面和处理用户交互。

到目前为止,本书已经广泛探讨了如何使用 React 为 Web 应用添加交互性。状态、事件处理和动态内容是与这一主题相关的关键概念。

当然,网站和 Web 应用不仅仅是关于交互性的。你可以构建一个提供交互性和吸引人的功能的出色 Web 应用,但如果它缺乏吸引人的视觉元素,它可能仍然不受欢迎。展示是关键,网络也不例外。

因此,就像所有其他应用和网站一样,React 应用和网站需要适当的样式,并且在处理 Web 技术时,层叠样式表CSS)是首选的语言。

然而,这本书不是关于 CSS 的。它不会解释或教你如何使用 CSS,因为已经有针对这一主题的专用、更好的资源(例如,developer.mozilla.org/en-US/docs/Learn/CSS 上的免费 CSS 指南)。但本章将教你如何将 CSS 代码与 JSX 和 React 概念(如状态和属性)结合使用。你将学习如何为你的 JSX 元素添加样式,样式自定义组件,并使这些组件的样式可配置。本章还将教你如何动态和条件性地设置样式,并探索流行的第三方库,如 styled-components 和 Tailwind CSS,它们可用于样式化。

React 应用中的样式是如何工作的?

到目前为止,本书中展示的应用和示例都只有最基本的美化。但至少它们有一些基本的美化,而不是完全没有美化。

但是,那种样式是如何添加的?在使用 React 时,如何将样式添加到用户界面元素(如 DOM 元素)中?

简短的回答是,“就像你对非 React 应用所做的那样。”你可以像对常规 HTML 元素一样,将 CSS 样式和类添加到 JSX 元素中。在你的 CSS 代码中,你可以使用你从 CSS 中知道的所有特性和选择器。在编写 CSS 代码时,你不需要做出任何特定的 React 更改。

到目前为止使用的代码示例(即 GitHub 上托管的活动或其他示例)总是使用常规 CSS 样式,借助 CSS 选择器,将一些基本样式应用到最终用户界面。这些 CSS 规则定义在一个index.css文件中,它是每个新创建的 React 项目的一部分(当使用 Vite 创建项目时,如第一章React – 什么和为什么所示)。

例如,以下是前一章(第五章渲染列表和条件内容)的活动 5.2中使用的index.css文件:

@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
body {
  margin: 0;
  padding: 3rem;
  font-family: 'Poppins', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  background-color: #dff8fb;
  color: #212324;
}
button {
  padding: 0.5rem 1rem;
  font-family: 'Rubik', sans-serif;
  font-size: 1rem;
  border: none;
  border-radius: 4px;
  background-color: #212324;
  color: #fff;
  cursor: pointer;
}
button:hover {
  background-color: #3f3e40;
}
ul {
  max-width: 35rem;
  list-style-type: none;
  padding: 0;
  margin: 2rem auto;
}
li {
  margin: 1rem 0;
  padding: 1rem;
  background-color: #5ef0fd;
  border: 2px solid #212324;
  border-radius: 4px;
} 

实际的 CSS 代码及其含义并不重要(如前所述,这本书不是关于 CSS 的)。然而,重要的是这个代码完全不包含 JavaScript 或 React 代码。如前所述,你编写的 CSS 代码完全独立于你在应用中使用 React 的事实。

更有趣的问题是,这些代码实际上是如何应用到渲染的网页上的?它是如何导入到该页面的?

通常,你会在提供的 HTML 文件内部期望找到样式文件导入(通过<link href="…">)。由于 React 应用通常是关于构建单页应用(见第一章React – 什么和为什么),你只有一个 HTML 文件——index.html文件。但如果你检查该文件,你不会找到任何指向index.css文件的<link href="…">导入(只有一些其他导入 favicon 的<link>元素),如下面的截图所示:

img

图 6.1:index.html文件的<head>部分不包含指向 index.css 文件的<link>导入

那么,index.css中的样式是如何导入并应用的?

在根入口文件(这是通过Vite生成的项目中的main.jsx文件)中,你可以找到一个import语句:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
**import****'./index.css'****;**
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
); 

import './index.css';语句导致 CSS 文件被导入,并且定义的 CSS 代码被应用到渲染的网页上。

值得注意的是,这不是标准的 JavaScript 行为。你不能将 CSS 文件导入到 JavaScript 中——至少,如果你只是使用纯 JavaScript 的话。

在 React 应用中,CSS 以这种方式工作,因为代码在加载到浏览器之前会被转换。因此,你不会在浏览器中执行的最终 JavaScript 代码中找到那个import语句。相反,在转换过程中,转换器识别 CSS 导入,将其从 JavaScript 文件中移除,并将 CSS 代码(或指向可能捆绑和优化的 CSS 文件的适当链接)注入到index.html文件中。

你可以通过在浏览器中检查加载的网页的渲染文档对象模型DOM)内容来确认这一点。

要做到这一点,请选择 Chrome 开发者工具中的元素选项卡,如下所示:

img

图 6.2:在运行时 DOM 中可以找到注入的 CSS <style> 元素

你可以直接在index.css文件中,或在由index.css文件导入的任何其他 CSS 文件中定义要应用于你的 HTML 元素(即你的组件中的 JSX 元素)的任何样式。

你也可以将额外的 CSS 导入语句添加到main.jsx文件或任何其他 JavaScript 文件(包括存储组件的文件)中。然而,重要的是要记住,CSS 样式始终是全局的。无论你是否将 CSS 文件导入到main.jsx或组件特定的 JavaScript 文件中,该 CSS 文件中定义的样式都将应用于全局。

这意味着在goal-list.css文件中定义的样式,即使可能被导入到GoalList.jsx文件中,也可能影响在完全不同的组件中定义的 JSX 元素。在本章的后面部分,你将了解到一些技术,这些技术可以帮助你防止意外的样式冲突并实现样式作用域。

使用内联样式

你可以使用 CSS 文件来定义全局 CSS 样式,并使用不同的 CSS 选择器来针对不同的 JSX 元素(或元素组)。

尽管通常不建议这样做,但你也可以通过style属性直接在 JSX 元素上设置内联样式。

注意

如果你想知道为什么不建议使用内联样式,Stack Overflow 上的以下讨论提供了许多反对内联样式的论点:stackoverflow.com/questions/2612483/whats-so-bad-about-in-line-css

在 JSX 代码中设置内联样式的方式如下:

function TodoItem() {
  return <li style={{color: 'red', fontSize: '18px'}}>Learn React!</li>;
}; 

在这个例子中,向<li>元素(所有 JSX 元素都支持style属性)添加了style属性,并通过 CSS 设置了文本的colorsize属性。

这种方法与仅使用 HTML(而不是 JSX)设置内联样式的方法不同。当使用纯 HTML 时,你会这样设置内联样式:

<li style="color: red; font-size: 18px">Learn React!</li> 

不同之处在于,style属性期望接收一个包含样式设置的 JavaScript 对象——而不是一个普通的字符串。这是必须记住的,因为,如前所述,内联样式通常不常用。

由于style对象是一个对象而不是一个普通字符串,它被作为值放在大括号之间——就像数组、数字或任何其他非字符串值一样必须在大括号之间设置(双引号或单引号之间的任何内容都被视为字符串值)。因此,值得注意的是,前面的例子没有使用任何特殊的“双大括号”语法,而是使用一对大括号来包围非字符串值,另一对大括号来包围对象数据。

style对象内部,可以设置底层 DOM 元素支持的任何 CSS 样式属性。属性名称是针对 HTML 元素定义的(即,与你可以用纯 JavaScript 针对和设置的目标和设置的 CSS 属性名称相同),当修改 HTML 元素时。

当在 JavaScript 代码中设置样式(如上面显示的style属性)时,必须使用 JavaScript CSS 属性名称。这些名称与你在 CSS 代码中使用的 CSS 属性名称相似,但并不完全相同。当针对由多个单词组成的属性名称(例如,font-size)时,会出现差异。在 JavaScript 中针对此类属性时,必须使用驼峰式命名法(fontSize而不是font-size),因为 JavaScript 属性不能包含破折号。或者,你也可以用引号包裹属性名称('font-size')。

注意

你可以在这里找到有关 HTML 元素样式属性和 JavaScript CSS 属性名称的更多信息:developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style

通过 CSS 类设置样式

如前所述,通常不建议使用内联样式,因此,在 CSS 文件中定义的 CSS 样式(或在文档<head>部分的<style>标签之间)更受欢迎。

在这些 CSS 代码块中,你可以编写常规 CSS 代码并使用 CSS 选择器将 CSS 样式应用于特定元素。例如,你可以这样设置页面上的所有<li>元素(无论哪个组件可能渲染了它们)的样式:

li {
  color: red;
  font-size: 18px;
} 

只要此代码被添加到页面中(因为定义它的 CSS 文件被导入到main.jsx等),样式就会被应用。

开发者经常试图针对特定的元素或元素组。而不是将某些样式应用于页面上的所有<li>元素,目标可能是仅针对属于特定列表的<li>元素。考虑以下渲染到页面的 HTML 结构(它可能分布在多个组件中,但这在这里并不重要):

<nav>
  <ul>
    <li><a href="…">Home</a></li>
    <li><a href="…">New Goals</a></li>
  </ul>
</nav>
...
<h2>My Course Goals</h2>
<ul>
  <li>Learn React!</li>
  <li>Master React!</li>
</ul> 

在这个例子中,导航列表项很可能不会收到与course goal列表项相同的样式(反之亦然)。

通常,这个问题会借助 CSS 类和类选择器来解决。你可以像这样调整 HTML 代码:

<nav>
  <ul>
    <li><a href="…">Home</a></li>
    <li><a href="…">New Goals</a></li>
  </ul>
</nav>
...
<h2>My Course Goals</h2>
<ul>
  <li **class****=****"goal-item"**>Learn React!</li>
  <li **class****=****"goal-item"**>Master React!</li>
</ul> 

以下 CSS 代码只会针对课程目标列表项,而不会针对导航列表项:

.goal-item {
  color: red;
  font-size: 18px;
} 

这种方法在 React 应用中也几乎同样适用。

然而,如果你尝试向 JSX 元素添加 CSS 类,如前一个示例所示,你将在浏览器开发者工具中遇到警告:

带有红色文字的白色背景,描述自动生成

图 6.3:React 输出的警告

如前图所示,你不应该将class作为属性添加,而应该使用className。实际上,如果你将class替换为className作为属性名,警告就会消失,并且类 CSS 样式将被应用。因此,正确的 JSX 代码如下:

<ul>
  <li **className**="goal-item">Learn React!</li>
  <li **className**="goal-item">Master React!</li>
</ul> 

但为什么 React 建议你使用className而不是class

这与在处理<label>对象时使用htmlFor而不是for类似(如第四章处理事件和状态中讨论的)。就像for一样,class是 JavaScript 中的一个关键字,因此,className被用作属性名。

动态设置样式

使用内联样式和 CSS 类(以及通常的全局 CSS 样式),有各种方法可以将样式应用于元素。到目前为止,所有示例都显示了静态样式——也就是说,一旦页面加载完成,样式就不会改变。

虽然大多数页面元素在页面加载后不会改变它们的样式,但你通常也有一些元素应该动态或条件性地设置样式。以下是一些示例:

  • 一个待办事项应用,其中不同的待办事项优先级会收到不同的颜色

  • 一个输入表单,其中无效的表单元素应在表单提交失败后突出显示

  • 一个基于 Web 的游戏,玩家可以为他们的头像选择颜色

在这种情况下,应用静态样式是不够的,应该使用动态样式。动态设置样式很简单。再次强调,这只是应用之前覆盖的 React 关键概念(最重要的是第二章理解 React 组件和 JSX和第四章处理事件和状态中关于设置动态值的内容)。

这里有一个例子,其中段落的颜色会动态设置为用户在输入字段中输入的颜色:

function ColoredText() {
  const [enteredColor, setEnteredColor] = useState('');
  function handleUpdateTextColor(event) {
    setEnteredColor(event.target.value);
  };
  return (
    <>
      <input type="text" onChange={handleUpdateTextColor}/>
      <p style={{color: enteredColor}}>This text's color changes dynamically!</p>
    </>
  );
}; 

<input>字段中输入的文本存储在enteredColor状态中。然后使用此状态动态设置<p>元素的color CSS 属性。这是通过传递一个style对象来实现的,其中color属性设置为enteredColor的值,作为<p>元素的style属性的值。因此,段落的文本颜色会动态设置为用户输入的值(假设用户将有效的 CSS 颜色值输入到<input>字段中)。

你不仅限于内联样式;CSS 类也可以动态设置,如下面的代码片段所示:

function TodoPriority() {
  const [chosenPriority, setChosenPriority] = useState('low-prio');
  function handleChoosePriority(event) {
    setChosenPriority(event.target.value);
  };
  return (
    <>
      <p className={chosenPriority}>Chosen Priority: {chosenPriority}</p>
      <select onChange={handleChoosePriority}>
        <option value="low-prio">Low</option>
        <option value="high-prio">High</option>
      </select>
    </>
  );
}; 

在这个例子中,chosenPriority状态将在low-priohigh-prio之间交替,取决于下拉选择。然后状态值作为段落内的文本输出,也用作动态 CSS 类名,应用于<p>元素。当然,为了产生任何视觉效果,必须在某个 CSS 文件或<style>块中定义low-priohigh-prio CSS 类。例如,考虑以下index.css中的代码:

.low-prio {
  background-color: blue;
  color: white;
}
.high-prio {
  background-color: red;
  color: white;
} 

条件样式

动态样式密切相关的是条件样式。实际上,它们最终只是动态样式的特殊案例。在先前的例子中,内联样式值和类名被设置为等于用户选择或输入的值。

然而,你也可以根据不同的条件动态地派生样式或类名,如下所示:

function TextInput({isValid, isRecommended, ...props}) {
  let cssClass = 'input-default';
  if (isRecommended) {
    cssClass = 'input-recommended';
  }

  if (!isValid) {
    cssClass = 'input-invalid';
  }
  return <input className={cssClass} {...props} />
}; 

在这个例子中,围绕标准<input>元素构建了一个包装组件。(有关包装组件的更多信息,请参阅第三章组件和属性。)这个包装组件的主要目的是为包装的<input>元素设置一些默认样式。包装组件被构建为提供可以用于应用中任何位置的预样式输入元素。实际上,提供预样式元素是构建包装组件最常见和最受欢迎的使用场景之一。

在这个具体的例子中,默认样式是通过 CSS 类应用的。如果isValid属性值为trueisRecommended属性值为false,则input-default CSS 类将应用于<input>元素,因为两个if语句都没有激活。

如果isRecommendedtrue(但isValidfalse),则应用input-recommended CSS 类。如果isValidfalse,则添加input-invalid类。当然,CSS 类必须在某些导入的 CSS 文件中定义(例如,在index.css中)。

内联样式也可以以类似的方式设置,如下面的代码片段所示:

function TextInput({isValid, isRecommended, ...props}) {
  let bgColor = 'black';
  if (isRecommended) {
    bgColor = 'blue';
  }
  if (!isValid) {
    bgColor = 'red';
  }
  return <input style={{backgroundColor: bgColor}} {...props} />
}; 

在这个例子中,<input>元素的背景颜色是基于通过isValidisRecommended属性接收到的值有条件地设置的。

结合多个动态 CSS 类

在先前的例子中,一次只能动态设置一个 CSS 类。然而,遇到需要合并和添加到元素中的多个动态派生 CSS 类的情况并不少见。

考虑以下示例:

function ExplanationText({children, isImportant}) {
  const defaultClasses = 'text-default text-expl';
  return <p className={defaultClasses}>{children}</p>;
} 

在这里,通过简单地将它们组合成一个字符串,就可以向<p>元素添加两个 CSS 类。或者,你也可以直接添加包含两个类的字符串,如下所示:

return <p className="text-default text-expl">{children}</p>; 

这段代码将能正常工作,但如果目标是基于isImportant属性值(在先前的例子中被忽略)向类列表中添加另一个类名呢?

替换默认的类列表很容易,正如你所学到的:

function ExplanationText({children, isImportant}) {
  let cssClasses = 'text-default text-expl';
  if (isImportant) {
    cssClasses = 'text-important';
  }
  return <p className={cssClasses}>{children}</p>;
} 

但如果目标不是替换默认类列表呢?如果text-important应该作为类添加到<p>元素中,除了text-defaulttext-expl呢?

className属性期望接收一个字符串值,因此传递一个类数组不是一种选择。然而,你可以简单地合并多个类成一个字符串,并且有几种不同的方法可以做到这一点:

  • 字符串连接:

    cssClasses = cssClasses + ' text-important'; 
    
  • 使用模板字符串:

    cssClasses = `${cssClasses} text-important`; 
    
  • 数组连接:

    cssClasses = [cssClasses, 'text-important'].join(' '); 
    

这些示例都可以在if语句(if (isImportant))中使用,根据isImportant属性值有条件地添加text-important类。所有这三种方法以及这些方法的变体都将工作,因为所有这些方法都产生一个字符串。一般来说,任何产生字符串的方法都可以用来生成className的值。

合并多个内联样式对象

当处理内联样式时,除了 CSS 类,您还可以合并多个样式对象。主要区别在于您不生成包含所有值的字符串,而是一个包含所有组合样式值的对象。

这可以通过使用标准的 JavaScript 技术将多个对象合并为一个对象来实现。最流行的技术涉及使用扩展运算符,如下例所示:

function ExplanationText({children, isImportant}) {
  let defaultStyle = { color: 'black' };
  if (isImportant) {
    defaultStyle = { ...defaultStyle, backgroundColor: 'red' };
  }
  return <p style={defaultStyle}>{children}</p>;
} 

在这里,您会注意到defaultStyle是一个具有color属性的对象。如果isImportanttrue,它将被替换为一个包含所有先前属性(通过扩展运算符...defaultStyle)以及backgroundColor属性的对象。

注意

关于扩展运算符的功能和使用,请参阅第五章渲染列表和条件内容

使用可定制样式构建组件

如您现在所知,组件可以被重用。这一点得到了支持,因为它们可以通过属性进行配置。同一个组件可以在页面的不同位置使用不同的配置来产生不同的输出。

由于样式可以静态和动态设置,您也可以使组件的样式可定制。前面的示例已经展示了这种定制的作用;例如,在先前的示例中,isImportant属性被用来有条件地向段落添加红色背景色。因此,ExplanationText组件已经通过isImportant属性允许间接的样式定制。

除了这种形式的定制外,您还可以构建接受已持有 CSS 类名或样式对象的属性的组件。例如,以下包装组件接受一个className属性,该属性与默认 CSS 类(btn)合并:

function Button({children, config, className}) {
  return <button {...config} className={`btn ${className}`}>{children}</button>;
}; 

此组件可以用以下方式在另一个组件中使用:

<Button config={{onClick: doSomething}} className="btn-alert">Click me!</Button> 

如果这样使用,最终的<button>元素将同时接收btnbtn-alert类。

您不必使用className作为属性名;任何名称都可以使用,因为它是您的组件。然而,使用className并不是一个坏主意,因为这样您可以保持通过className设置 CSS 类的心理模型(对于内置组件,您将没有这样的选择)。

与将属性值与默认 CSS 类名或样式对象合并不同,您可以覆盖默认值。这允许您构建一些带有默认样式的组件,而无需强制使用该样式:

function Button({children, config, className}) {
  let cssClasses = 'btn';
  if (className) {
    cssClasses = className;
  }
  return <button {...config} className={cssClasses}>{children}</button>;
}; 

你可以看到,本书中涵盖的所有不同概念是如何在这里汇聚的:属性允许定制,值可以设置、交换和动态条件地更改,因此可以构建高度可重用和可配置的组件。

使用固定配置选项进行定制

除了暴露classNamestyle等属性,这些属性会与组件函数内部定义的其他类或样式合并外,你还可以构建基于其他属性值应用不同样式或类名的组件。

这在前面的示例中已经展示过,其中使用了isValidisImportant等属性来有条件地应用某些样式。因此,这种应用样式的做法可以被称为“间接样式”(尽管这不是一个官方术语)。

两种方法在不同的环境中都能发挥作用。例如,对于包装组件,接受classNamestyle属性(这些可以在组件内部与其他样式合并)使得组件可以像内置组件一样使用(例如,像它所包装的组件)。另一方面,如果你想要构建提供一些预定义变体的组件,间接样式可能非常有用。

一个很好的例子是,一个文本框提供了两个内置主题,可以通过特定的属性进行选择。

手机的截图  自动生成的描述

图 6.4:根据“mode”属性的值对 TextBox 进行样式化

TextBox组件的代码可能看起来像这样:

function TextBox({children, mode}) {
  let cssClasses;
  if (mode === 'alert') {
    cssClasses = 'box-alert';
  } else if (mode === 'info') {
    cssClasses = 'box-info';
  }
  return <p className={cssClasses}>{children}</p>;
}; 

这个TextBox组件始终返回一个段落元素。如果mode属性设置为除'alert''info'之外的任何值,则段落不会接收任何特殊样式。但如果mode等于'alert''info',则会向段落添加特定的 CSS 类。

因此,这个组件不允许通过某些classNamestyle属性进行直接样式化,但它确实提供了不同的变体或主题,可以通过特定的属性(在这种情况下是mode属性)来设置。

未限定样式的问题

如果你考虑本章中迄今为止处理的不同示例,那么有一个特定的用例出现得相当频繁:样式仅与特定组件相关。

例如,在前一节的TextBox组件中,'box-alert''box-info'是可能只与这个特定组件及其标记相关的 CSS 类。如果应用了'box-alert'类的任何其他 JSX 元素(尽管这不太可能),那么它可能不应该与TextBox组件中的<p>元素以相同的样式进行样式化。

来自不同组件的样式可能会相互冲突并覆盖彼此,因为样式不是限定(即,限制)在特定组件内的。CSS 样式始终是全局的,除非使用内联样式(如前所述,这是不推荐的)。

当与 React 等基于组件的库一起工作时,这种作用域缺失是一个常见问题。随着应用规模和复杂性的增长(或者说,随着越来越多的组件被添加到 React 应用的代码库中),很容易编写冲突的样式。

因此,React 社区成员已经开发了各种解决方案来解决这个问题。以下是最受欢迎的三种解决方案:

  • CSS Modules(在用 Vite 创建的 React 项目中默认支持)

  • 样式化组件(使用名为styled-components的第三方库)

  • Tailwind CSS(一个流行的 CSS 库)

CSS Modules 的作用域样式

CSS Modules是一种方法,其中单个 CSS 文件与特定的 JavaScript 文件相关联,并且这些文件中定义的组件。这种链接是通过转换 CSS 类名来建立的,使得每个 JavaScript 文件都接收自己的、唯一的 CSS 类名。这种转换作为代码构建工作流的一部分自动执行。因此,给定的项目设置必须通过执行所描述的 CSS 类名转换来支持 CSS Modules。通过 Vite 创建的项目默认支持 CSS Modules。

程序图解 自动生成描述

图 6.5:CSS 模块在实际应用中的表现。在构建工作流中,CSS 类名被转换成唯一的名称

CSS Modules 通过以非常具体和明确的方式命名 CSS 文件来启用和使用:<anything>.module.css<anything>是你选择的任何值,但文件扩展名前的.module部分是必需的,因为它向项目构建工作流发出信号,即此 CSS 文件应根据 CSS Modules 方法进行转换。

因此,像这样命名的 CSS 文件必须以特定的方式导入到组件中:

import classes from './file.module.css'; 

这种import语法与本节开头为index.css展示的import语法不同:

import './index.css'; 

当像第二个代码片段中那样导入 CSS 文件时,CSS 代码会被简单地合并到index.html文件中并全局应用。当使用 CSS Modules(第一个代码片段)时,导入的 CSS 文件中定义的 CSS 类名会被转换,使得它们对于导入 CSS 文件的 JS 文件来说是唯一的。

由于 CSS 类名被转换,因此它们不再等于你在 CSS 文件中定义的类名,所以你从 CSS 文件中导入一个对象(前例中的classes),这个对象通过匹配你在 CSS 文件中定义的 CSS 类名作为键,暴露了所有转换后的 CSS 类名。这些属性的值是转换后的类名(字符串)。

下面是一个完整的示例,从一个特定组件的 CSS 文件(TextBox.module.css)开始:

.alert {
  padding: 1rem;
  border-radius: 6px;
  background-color: #f9bcb5;
  color: #480c0c;
}
.info {
  padding: 1rem;
  border-radius: 6px;
  background-color: #d6aafa;
  color: #410474;
} 

应该将 CSS 代码归属的组件的 JavaScript 文件(TextBox.jsx)看起来像这样:

import classes from './TextBox.module.css';
function TextBox({ children, mode }) {
  let cssClasses;
  if (mode === 'alert') {
    cssClasses = classes.alert;
  } else if (mode === 'info') {
    cssClasses = classes.info;
  }
  return <p className={cssClasses}>{children}</p>;
}
export default TextBox; 

注意

完整的示例代码也可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/01-css-modules-intro 找到。

如果你使用浏览器开发者工具检查渲染的文本元素,你会注意到应用到 <p> 元素的 CSS 类名并不匹配 TextBox.module.css 文件中指定的类名:

img

图 6.6:CSS 类名因为 CSS 模块的使用而转换

这是因为,如前所述,类名在构建过程中被转换成唯一的。如果其他任何 CSS 文件(由另一个 JavaScript 文件导入),定义了一个具有相同名称的类(在这个例子中是 info),那么样式就不会冲突,也不会相互覆盖,因为干扰的类名在应用到 DOM 元素之前会被转换成不同的类名。

实际上,在 GitHub 上提供的示例中,你还可以找到在 index.css 文件中定义的另一个 info CSS 类:

.info {
  border: 5px solid red;
} 

该文件仍然被导入到 main.jsx 中,因此其样式被应用到整个文档的全局范围内。尽管如此,.info 样式显然没有影响到由 TextBox 渲染的 <p> 元素(在 图 6.6 中文本框周围没有红色边框)。它们没有影响到该元素,因为该元素不再有 info 类;该类被构建工作流程重命名为 _info_1mtzh_8(尽管你看到的名称将不同,因为它包含一个随机元素)。

值得注意的是,index.css 文件仍然被导入到 main.jsx 中,正如本章开头所示。import 语句没有被改为 import classes from './index.css';,CSS 文件也没有被命名为 index.module.css

注意,你还可以使用 CSS 模块将样式范围限定到组件,并且可以将 CSS 模块的使用与常规 CSS 文件混合,这些常规 CSS 文件被导入到 JavaScript 文件中而不使用 CSS 模块(即,不进行范围限定)。

使用 CSS 模块的另一个重要方面是,你只能使用 CSS 类选择器(即,在你的 .module.css 文件中),因为 CSS 模块依赖于 CSS 类。你可以编写结合类和其他选择器的选择器,例如 input.invalid,但你不能在你的 .module.css 文件中添加不使用类的选择器。例如,input { ... }#some-id { ... } 选择器在这里将不起作用。

CSS 模块是将样式范围限定到(React)组件的一种非常流行的方式,本书的后续许多示例都将使用这种方式。

样式组件库

styled-components 库是一种所谓的 CSS-in-JS 解决方案。CSS-in-JS 解决方案旨在通过将它们合并到同一个文件中来消除 CSS 代码和 JavaScript 代码之间的分离。组件样式将直接定义在组件逻辑旁边。是否偏好分离(如通过使用 CSS 文件强制执行)或保持两种语言紧密相邻,这取决于个人喜好。

由于 styled-components 是一个不在新创建的 React 项目中预安装的第三方库,如果你想使用它,你必须将其作为第一步安装。这可以通过 npm(在 第一章React – 什么和为什么 中与 Node.js 自动安装)来完成:

npm install styled-components 

styled-components 库本质上提供了所有内置核心组件的包装组件(例如,围绕 pabuttoninput 等)。它将这些包装组件作为 标记模板 暴露出来——JavaScript 函数,它们不像常规函数那样被调用,而是通过在函数名后添加反引号(模板字面量)来执行,例如,doSomething`text data`

注意

当你第一次看到标记模板时,可能会感到困惑,尤其是考虑到它是一个不太常用的 JavaScript 功能。你不太可能经常使用它们。更有可能的是,你以前从未构建过自定义标记模板。你可以在 MDN 的这篇优秀的文档中了解更多关于标记模板的信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates

这里是一个导入并使用 styled-components 来设置和作用域样式的组件:

import styled from 'styled-components';
const Button = styled.button`
  background-color: #370566;
  color: white;
  border: none;
  padding: 1rem;
  border-radius: 4px;
`;
export default Button; 

这个组件不是一个组件函数,而是一个常量,它存储了执行 styled.button 标记模板返回的值。该标记模板返回一个组件函数,该函数生成一个 <button> 元素。通过标记模板(即模板字面量内)传递的样式应用于该返回的按钮元素。如果你在浏览器开发者工具中检查按钮,就可以看到这一点:

计算机程序截图  自动生成的描述

图 6.7:渲染的按钮元素接收定义的组件样式

图 6.7 中,你还可以看到 styled-components 库如何将你的样式应用到元素上。它从标记模板字符串中提取你的样式定义,并将它们注入到文档 <head> 部分的 <style> 元素中。然后,通过由 styled-components 库生成(并命名)的类选择器应用注入的样式。最后,库将自动生成的 CSS 类名添加到元素(在这种情况下是 <button>)上。

styled-components库暴露的组件会将你传递给组件的任何额外属性传播到包装的核心组件上。此外,任何插入在开标签和闭标签之间的内容也会插入到包装组件的标签之间。

这就是为什么之前创建的Button可以像这样使用,而不需要添加任何额外的逻辑:

import Button from './components/button.jsx';
function App() {
  function handleClick() {
    console.log('This button was clicked!');
  }
  return <Button onClick={handleClick}>Click me!</Button>;
}
export default App; 

注意

完整的示例代码可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/02-styled-components-intro

你可以使用styled-components库做更多的事情。例如,你可以动态和有条件地设置样式。不过,这本书并不是主要关于这个库的。它只是 CSS Modules 的许多替代方案之一。因此,如果你想要了解更多,建议你探索官方的styled-components文档,你可以在这里找到styled-components.com/

使用 Tailwind CSS 库进行样式设计

使用 CSS 模块或styled-component库来范围样式是一种非常有用且流行的技术。

但无论你使用哪种方法,你都必须自己编写所有的 CSS 代码。因此,当然你需要了解 CSS。

但如果你不喜欢写 CSS 代码呢?或者你根本就不想写?

在这种情况下,你可以使用许多可用的 CSS 库和框架之一——例如,Bootstrap CSS 框架或Tailwind CSS库。Tailwind 已经成为 React 项目(对于不想编写自定义 CSS 代码的开发者)非常流行的样式解决方案。

请记住,Tailwind 是一个 CSS 库,实际上并不专注于 React。相反,你可以在任何 Web 项目中使用 Tailwind 来样式化你的 HTML 代码——无论在那里使用的是哪种 JavaScript 库或框架(如果有的话)。

但 Tailwind 是 React 应用的常见选择,因为它的核心哲学与 React 的组件化模型相得益彰。这是因为当使用 Tailwind 进行样式设计时,你通常会通过将许多小的 CSS 类应用到单个 JSX 元素上来组合整体样式:

function App() {
  return (
    <main 
      className="bg-gray-200 text-gray-900 h-screen p-12 text-center">
      <h1 className="font-bold text-4xl">Tailwind CSS is amazing!</h1>
      <p className="text-gray-600">
        It may take a while to get used to it. But it's great for people who don't want to write custom CSS code.
      </p>
    </main>
  );
}
export default App; 

当第一次遇到使用 Tailwind CSS 的代码时,长长的 CSS 类列表可能会看起来令人畏惧且混乱。但当你与 Tailwind 一起工作时,你通常会很快习惯它。

此外,因为 Tailwind 的方法提供了许多优势:

  • 你不需要详细了解 CSS——理解 Tailwind 语法就足够了,它比从头开始写 CSS 要简单。

  • 你通过组合 CSS 类来编写样式——类似于你在 React 中从组件组合用户界面。

  • 你不需要在 JSX 文件和 CSS 文件之间切换。

  • 样式更改可以非常快速地应用和测试。

如上代码片段所示,Tailwind 的核心思想是它提供了许多可组合的 CSS 类,每个类只做很少的事情。例如,bg-gray-200类仅将背景颜色设置为某种灰度的色调,没有其他作用。

因此,所有这些 CSS 类的组合才能达到某种外观,Tailwind CSS 提供了许多这样的类,你可以使用并组合。你可以在官方文档中找到完整的列表,网址为tailwindcss.com/docs/utility-first

当你在 React 项目中使用 Tailwind 时,你可以构建 React 组件,不仅是为了重用逻辑或 JSX 标记,还可以重用样式:

**function****Item****(****{ children }****) {**
**return****<****li****className****=****'p-1 my-2 bg-stone-100'****>****{children}****</****li****>****;**
**}**
function App() {
  return (
    <main className="bg-gray-200 text-gray-900 h-screen p-12 text-center">
      <h1 className="font-bold text-4xl">Tailwind CSS is amazing!</h1>
      <p className="text-gray-600">
        It may take a while to get used to it. But it's great for people who
        don't want to write custom CSS code.
      </p>
      <section className="mt-10 border border-gray-600 max-w-3xl mx-auto p-4 rounded-md bg-gray-300">
        <h2 className="font-bold text-xl">Tailwind CSS Advantages</h2>
        <ul className="mt-4">
          **<****Item****>****No CSS knowledge required****</****Item****>**
          **<****Item****>****Compose styles by combining "small" CSS classes****</****Item****>**
          **<****Item****>**
            **Never leave your JSX code - no need to fiddle around in extra CSS files**
          **</****Item****>**
          **<****Item****>****Quickly test and apply changes****</****Item****>**
        </ul>
      </section>
    </main>
  );
}
export default App; 

在这个例子中,Item组件被构建为重用应用于<li>元素的 Tailwind 样式。

注意

你还可以在 GitHub 上找到这个示例项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/examples/03-tailwind

如果你计划在 React 项目中使用 Tailwind,你必须将其作为第一步安装。官方文档中提供了针对各种项目设置的详细安装说明——这包括 Vite 项目的说明:tailwindcss.com/docs/guides/vite

安装过程不仅仅是导入一个 CSS 文件那么简单,但仍然相对直接。由于 Tailwind 需要连接到项目构建过程以分析你的 JSX 文件并生成包含所有使用过的类名和样式规则的 CSS 代码,因此它确实需要几个设置步骤。

除了提供许多可组合的实用样式外,Tailwind 还提供了大量的自定义机会和配置选项。因此,关于 Tailwind 的书籍可以写满整本书。然而,当然这不是本书的主题。因此,如果你对在 React 项目中使用 Tailwind 感兴趣,Tailwind 的官方文档(见上面的链接)是一个学习更多的好地方。

使用其他 CSS 或 JavaScript 样式库和框架

显然,是否编写自定义 CSS 代码(可能使用 CSS Modules 或styled-components进行范围限定)或者是否使用第三方 CSS 库,如 Tailwind CSS,取决于个人喜好。没有对错之分,你会在不同的 React 项目中看到各种方法被使用。

本章中介绍的选择也不是详尽的——还有其他类型的 CSS 和 JavaScript 库:

  • 解决非常具体的 CSS 问题的实用库——无论你是在 React 项目中使用它们(例如,Animate.css,它有助于添加动画)

  • 其他 CSS 框架或库提供了广泛的预建 CSS 类,可以应用于元素以快速实现某种外观(例如,Bootstrap

  • 帮助进行样式或特定样式方面(例如,动画)的 JavaScript 库(例如,Framer Motion

一些库和框架有针对 React 的特定扩展或专门支持 React,但这并不意味着你不能使用没有这种扩展的库。

概括和关键要点

  • 可以使用标准 CSS 来样式化 React 组件和 JSX 元素。

  • CSS 文件通常直接导入到 JavaScript 文件中,这得益于项目构建过程,它提取 CSS 代码并将其注入到文档中(HTML 文件)。

  • 作为全局 CSS 样式(使用elementidclass或其他选择器)的替代方案,内联样式可以用来为 JSX 元素应用样式。

  • 当使用 CSS 类进行样式化时,你必须使用className属性(而不是class)。

  • 样式可以静态设置,也可以使用与将其他动态或条件值注入 JSX 代码相同的语法动态或条件设置——一对大括号。

  • 可以通过设置样式(或 CSS 类)基于 prop 值,或者通过合并接收到的 prop 值与其他样式或类名字符串来构建高度可配置的自定义组件。

  • 当仅使用 CSS 时,CSS 类名冲突可能是一个问题。

  • CSS Modules 通过在构建工作流程中将类名转换为唯一的名称(每个组件一个)来解决此问题。

  • 或者,可以使用如styled-components之类的第三方库。这个库是一个 CSS-in-JS 库,它也有一个优点或缺点(取决于你的偏好),即消除了 JS 和 CSS 代码之间的分离。

  • Tailwind CSS 是 React 项目的另一种流行的样式选择——这是一个允许你通过组合许多小的 CSS 类来编写样式的库。

  • 也可以使用其他 CSS 库或框架;React 在这方面没有施加任何限制。

接下来是什么?

在样式处理完毕后,你现在能够构建不仅功能性强而且视觉上吸引人的用户界面。即使你经常与专门的网页设计师或 CSS 专家合作,你通常也需要能够设置和分配样式(动态地)并将其传递给你。

由于样式是一个相对独立于 React 的一般概念,下一章将回到更多 React 特定的功能和主题。你将了解** portalsrefs**,这两个是 React 内置的关键概念。你将发现这些概念解决了哪些问题,以及这两个功能是如何使用的。

测试你的知识!

通过回答以下问题来测试您对本章涵盖的概念的理解。您可以将您的答案与以下示例进行比较:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/06-styling/exercises/questions-answers.md

  1. React 组件的样式是用哪种语言定义的?

  2. 与没有 React 的项目相比,在为元素分配类时,需要牢记哪些重要差异?

  3. 如何动态和条件性地分配样式?

  4. 在样式上下文中,“限定”是什么意思?

  5. 样式如何限定到组件中?简要解释至少一个有助于限定的概念。

应用所学知识

现在,您不仅能够构建交互式用户界面,还能够以引人入胜的方式对用户界面元素进行样式设计。您可以根据条件动态地设置和更改这些样式。

在本节中,您将找到两个活动,这些活动允许您将新获得的知识与之前章节中学到的知识相结合来应用。

活动六.1:在表单提交时提供输入有效性反馈

在本活动中,您将构建一个基本的表单,允许用户输入电子邮件地址和密码。每个输入字段的输入都会进行验证,并且验证结果会被存储(针对每个单独的输入字段)。

本活动的目的是添加一些通用的表单样式和一些条件样式,一旦提交了无效表单,这些样式就会生效。具体的样式由您决定,但为了突出显示无效的输入字段,必须更改受影响输入字段的背景颜色、边框颜色以及相关标签的文本颜色。

步骤如下:

  1. 创建一个新的 React 项目,并向其中添加一个表单组件。

  2. 在项目的根组件中输出表单组件。

  3. 在表单组件中,输出包含两个输入字段的表单:一个用于输入电子邮件地址,另一个用于输入密码。

  4. 为输入字段添加标签。

  5. 在表单提交时存储输入的值并检查它们的有效性(您可以在形成自己的验证逻辑方面发挥创意)。

  6. 从提供的index.css文件中选择合适的 CSS 类(或者您也可以编写自己的类)。

  7. 一旦提交了无效值,就将它们添加到无效的输入字段及其标签上。

最终用户界面应如下所示:

登录屏幕的截图 自动生成的描述

图 6.8:最终用户界面,无效输入值以红色突出显示

由于本书不涉及 CSS,并且您可能不是 CSS 专家,您可以使用解决方案中的index.css文件,并专注于 React 逻辑来将适当的 CSS 类应用到 JSX 元素上。

注意

所有用于此活动的代码文件以及完整解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/activities/practice-1找到。

活动六.2:使用 CSS 模块进行样式作用域

在这个活动中,你将使用活动 6.1中构建的最终应用程序,并调整它以使用 CSS 模块。目标是迁移所有特定于组件的样式到一个特定于组件的 CSS 文件中,该文件使用 CSS 模块进行样式作用域。

因此,最终的用户界面看起来与上一个活动相同。然而,样式将被限制在Form组件中,这样冲突的类名就不会干扰样式。

步骤如下:

  1. 完成上一个活动或从 GitHub 获取完成的代码。

  2. 识别属于Form组件的特定样式,并将它们移动到新的、特定于组件的 CSS 文件中。

  3. 将 CSS 选择器更改为类名选择器,并根据需要将类添加到 JSX 元素中(这是因为 CSS 模块需要类名选择器)。

  4. 使用本章中解释的特定于组件的 CSS 文件,并将 CSS 类分配给适当的 JSX 元素。

注意

所有用于此活动的代码文件以及完整解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/06-styling/activities/practice-2找到。

第七章:门户和引用

学习目标

到本章结束时,你将能够做到以下几点:

  • 使用直接 DOM 元素访问来与元素交互

  • 将你的组件的函数和数据暴露给其他组件

  • 控制渲染的 JSX 元素在 DOM 中的位置

简介

React.js 是关于构建用户界面的,在本书的上下文中,它特别是指构建网络用户界面。

网络用户界面最终都是关于文档对象模型DOM)。你可以使用 JavaScript 来读取或操作 DOM。这就是允许你构建交互式网站的原因:你可以在页面加载后添加、删除或编辑 DOM 元素。这可以用来添加或删除覆盖窗口或读取输入字段中输入的值。

这在第一章React – 什么是和为什么中已经讨论过了,正如你所学的,React 用于简化这个过程。你不需要手动操作 DOM 或从 DOM 元素中读取值,你可以使用 React 来描述所需的状态。然后 React 负责完成达到这个所需状态的步骤。

然而,在某些场景和用例中,尽管使用了 React,你仍然希望能够直接访问特定的 DOM 元素——例如,读取用户输入到输入字段中的值,或者如果你对 React 选择的 DOM 中新插入元素的位置不满意。

React 提供了一些功能,可以帮助你在这些情况下:门户引用。尽管直接操作 DOM 仍然不是一个好主意,但正如你将在本章中学习的,这些工具可以帮助读取 DOM 元素值或更改 DOM 结构,而不会与 React 作对。

没有引用的世界

考虑以下示例:你有一个网站,它渲染一个输入字段,请求用户的电子邮件地址。它可能看起来像这样:

一个带有矩形和紫色矩形的白色框,描述自动生成

图 7.1:一个带有电子邮件输入字段的示例表单

负责渲染表单并处理输入的电子邮件地址值的组件的代码可能看起来像这样:

function EmailForm() {
  const [enteredEmail, setEnteredEmail] = useState('');
  console.log(enteredEmail);
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmitForm(event) {
    event.preventDefault();
    // could send enteredEmail to a backend server
  }
  return (
    <form className={classes.form} onSubmit={handleSubmitForm}>
      <label htmlFor="email">Your email</label>
      <input type="email" id="email" onChange={handleUpdateEmail} />
      <button>Save</button>
    </form>
  );
} 

如你所见,这个示例使用useState()钩子,结合change事件,来注册email输入字段中的按键,并存储输入的值。

这段代码运行良好,在你的应用程序中包含这种类型的代码也没有什么问题。但是,添加额外的事件监听器和状态,以及添加在change事件触发时更新状态的函数,对于这样一个简单的任务——获取输入的电子邮件地址——来说,是一段相当多的样板代码。

上述代码片段除了提交电子邮件地址外,没有做其他任何事情。换句话说,在示例中使用enteredEmail状态的唯一原因就是读取输入的值。

即使enteredEmail只在handleSubmitForm()函数中需要,React 也会为每次enteredEmail状态更新重新执行EmailForm组件函数,即每次在<input>字段中的按键输入。这也不是理想的,因为它会导致大量的不必要的代码执行,从而可能引起性能问题。

在这种情况下,如果你退回到一些纯 JavaScript 逻辑,可以节省大量的代码(也许还有性能):

const emailInputEl = document.getElementById('email');
const enteredEmailVal = emailInputEl.value; 

这两行代码(理论上可以合并为一行)允许你获取一个 DOM 元素并读取当前存储的值。

这种代码的问题在于它没有使用 React。如果你正在构建 React 应用程序,你应该真正坚持使用 React 来处理 DOM。不要开始将你自己的纯 JavaScript 代码(访问 DOM 的代码)混合到 React 代码中。

这可能会导致意外的行为或错误,特别是如果你开始操作 DOM。它可能导致错误,因为在这种情况下 React 不会意识到你的更改;实际的渲染 UI 不会与 React 假设的 UI 同步。即使你只是从 DOM 中读取,也不应该将纯 JavaScript DOM 访问方法与你的 React 代码合并。

为了仍然允许你获取 DOM 元素并读取值,如上所示,React 为你提供了一个特殊的概念,你可以使用:引用

Ref 代表引用,这是一个允许你存储对值引用的功能——例如,从 React 组件内部对 DOM 元素的引用。前面的纯 JavaScript 代码会做同样的事情(它也让你能够访问渲染后的元素),但是当使用引用时,你可以在不将纯 JavaScript 代码混合到 React 代码中的情况下获取访问权限。

可以使用一个特殊的 React Hook,称为useRef() Hook 来创建引用。

这个 Hook 可以被调用以生成一个ref对象:

import { useRef } from 'react';
function EmailForm() {
  const emailRef = useRef(null);
  // other code ...
}; 

在前面的例子中,这个生成的引用对象emailRef最初被设置为 null,但随后可以被分配给任何 JSX 元素。这个分配是通过一个特殊的属性(ref属性)完成的,这个属性被每个 JSX 元素自动支持:

return (
  <form className={classes.form} onSubmit={handleSubmitForm}>
    <label htmlFor="email">Your email</label>
    <input
      **ref****=****{emailRef}**
      type="email"
      id="email"
    />
    <button>Save</button>
  </form>
); 

就像在第五章中引入的key属性一样,渲染列表和条件内容ref属性是由 React 提供的。ref属性需要一个引用对象,即通过useRef()创建的。

在这个例子中,useRef()接收null作为初始值,因为当组件函数第一次执行时,它技术上还没有分配给 DOM 元素。只有在最初的组件渲染周期之后,连接才会建立。因此,在第一次组件函数执行之后,存储在引用中的值将是这个例子中<input>元素的底层 DOM 对象。

创建并分配了那个 Ref 对象之后,你可以使用它来获取连接的 JSX 元素(在这个例子中是 <input> 元素)。需要注意的是:要获取连接的元素,你必须访问创建的 Ref 对象上的特殊 current 属性。这是必需的,因为 React 将分配给 Ref 对象的值存储在一个嵌套对象中,可以通过 current 属性访问,如下所示:

function handleSubmitForm(event) {
  event.preventDefault();
  **const** **enteredEmail = emailRef.****current****.****value****;** // .current is mandatory!
  // could send enteredEmail to a backend server
}; 

emailRef.current 返回为连接的 JSX 元素渲染的底层 DOM 对象。在这种情况下,因此它允许访问输入元素 DOM 对象。由于该 DOM 对象有一个 value 属性,因此可以无问题地访问这个 value 属性。

注意

关于这个主题的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attributes

使用这种代码,你可以从 DOM 元素中读取值,而无需使用 useState() 和事件监听器。因此,最终的组件代码变得更加简洁:

function EmailForm() {
  const emailRef = useRef(null);
  function handleSubmitForm(event) {
    event.preventDefault();
    const enteredEmail = emailRef.current.value;
    // could send enteredEmail to a backend server
  }
  return (
    <form className={classes.form} onSubmit={handleSubmitForm}>
      <label htmlFor="email">Your email</label>
      <input
        ref={emailRef}
        type="email"
        id="email"
      />
      <button>Save</button>
    </form>
  );
} 

Refs 与 State 的比较

由于 Refs 可以用来快速轻松地访问 DOM 元素,因此可能会出现的问题是是否应该始终使用 Refs 而不是状态。

对这个问题的明确答案是“不。”

在需要读取元素的情况,如上面所示的使用案例中,Refs 可以是一个非常好的替代品。在处理用户输入时,这种情况通常很常见。一般来说,如果你只是访问一些值来在某个函数(例如表单提交处理函数)执行时读取它,Refs 可以替代状态。一旦你需要更改值并且这些更改必须在 UI 中反映(例如,通过渲染一些条件内容),Refs 就不再适用。

在上面的例子中,如果你除了获取输入的值之外,还希望在表单提交后重置(即清除)电子邮件输入,你应该再次使用状态。虽然你可以借助 Ref 重置输入,但你不应这样做。你会开始操作 DOM,而只有 React 应该这样做——使用它自己的、内部的方法,基于你提供给 React 的声明性代码。

你应该避免像这样重置电子邮件输入:

function EmailForm() {
  const emailRef = useRef(null);
  function handleSubmitForm(event) {
    event.preventDefault();
    const enteredEmail = emailRef.current.value;
    // could send enteredEmail to a backend server
    **emailRef.****current****.****value** **=** **''****;** **// resetting the input value**
  }
  return (
    <form className={classes.form} onSubmit={handleSubmitForm}>
      <label htmlFor="email">Your email</label>
      <input
        ref={emailRef}
        type="email"
        id="email"
      />
      <button>Save</button>
    </form>
  );
} 

相反,你应该通过使用 React 的状态概念和遵循 React 所采用的声明性方法来重置它:

function EmailForm() {
  const [enteredEmail, setEnteredEmail] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmitForm(event) {
    event.preventDefault();
    // could send enteredEmail to a backend server
    // reset by setting the state + using the value prop below
    **setEnteredEmail**('');
  }
  return (
    <form className={classes.form} onSubmit={handleSubmitForm}>
      <label htmlFor="email">Your email</label>
      <input
        type="email"
        id="email"
        onChange={handleUpdateEmail}
        **value****=****{enteredEmail}**
      />
      <button>Save</button>
    </form>
  );
} 

注意

作为一个规则,你应该简单地尝试在 React 项目中避免编写命令式代码。相反,告诉 React 最终 UI 应该是什么样子,然后让 React 想出如何到达那里。

通过 Refs 读取值是一个可接受的例外,并且操作 DOM 元素(无论是否使用 Refs,例如,通过直接选择 DOM 节点 document.getElementById() 或类似方法)应该避免。一个罕见的例外是调用输入元素 DOM 对象上的 focus() 方法,因为像 focus() 这样的方法通常不会引起任何可能导致 React 应用程序中断的 DOM 变化。

使用 Refs 进行更多操作

访问 DOM 元素(用于读取值)是使用 Refs 的最常见用例之一。如上所示,它可以在某些情况下帮助您减少代码。

但 Refs 不仅仅是“元素连接桥梁”;它们是可以用来存储各种值的对象——不仅仅是 DOM 对象的指针。例如,您还可以在 Ref 中存储字符串、数字或其他任何类型的值:

const passwordRetries = useRef(0); 

您可以向 useRef() 传递一个初始值(本例中的 0),然后在任何时候访问或更改 Ref 所属组件中的该值:

passwordRetries.current = 1; 

然而,您仍然必须使用 current 属性来读取和更改存储的值,因为,如上所述,这是 React 存储属于 Ref 的实际值的地方。

这对于存储应该“生存”组件重新评估的数据很有用。正如你在 第四章 中学到的,与事件和状态一起工作,React 每当组件状态发生变化时都会执行组件函数。由于函数再次执行,存储在函数作用域变量中的任何数据都会丢失。考虑以下示例:

function Counters() {
  const [counter1, setCounter1] = useState(0);
  const counterRef = useRef(0);
  let counter2 = 0;
  function handleChangeCounters() {
    setCounter1(1);
    counter2 = 1;
    counterRef.current = 1;
  };
  return (
    <>
      <button onClick={handleChangeCounters}>Change Counters</button>
      <ul>
        <li>Counter 1: {counter1}</li>
        <li>Counter 2: {counter2}</li>
        <li>Counter 3: {counterRef.current}</li>
      </ul>
    </>
  );
}; 

在这个例子中,当按钮被点击时,计数器 1 和 3 会变为 1。然而,计数器 2 将保持为零,尽管在 handleChangeCounterscounter2 变量也被更改为值 1

计算机的截图  描述由中等置信度自动生成

图 7.2:只有三个计数器值中的两个发生了变化

在这个例子中,应该预期状态值发生变化,并且新值会反映在更新的用户界面中。毕竟,这就是状态的全部理念。

Ref(counterRef)也会在组件重新评估之间保持其更新值。这就是上面描述的行为:当周围组件函数再次执行时,Refs 不会被重置或清除。纯 JavaScript 变量(counter2)不会保持其值。尽管它在 handleChangeCounters 中被更改,但当组件函数再次执行时,会初始化一个新的变量;因此,更新值(1)会丢失。

在这个例子中,它可能看起来像 Refs 可以替代状态,但实际上这个例子很好地说明了这不是情况。尝试将 counter1 替换为另一个 Ref(这样组件中就没有剩余的状态值)并点击按钮:

import { useRef } from 'react';
function Counters() {
  const counterRef1 = useRef(0);
  const counterRef2 = useRef(0);
  let counter2 = 0;
  function handleChangeCounters() {
    counterRef1.current = 1;
    counter2 = 1;
    counterRef2.current = 1;
  }
  return (
    <>
      <button onClick={handleChangeCounters}>Change Counters</button>
      <ul>
        <li>Counter 1: {counterRef1.current}</li>
        <li>Counter 2: {counter2}</li>
        <li>Counter 3: {counterRef2.current}</li>
      </ul>
    </>
  }
);
export default Counters; 

页面上不会发生任何变化,因为虽然按钮点击被记录并且 handleChangeCounters 函数被执行,但没有发起状态变化,状态变化(通过 setXYZ 状态更新函数调用发起)是触发 React 重新评估组件的触发器。改变 Ref 值不会这样做。

数字与数字的比较  描述由中等置信度自动生成

图 7.3:计数器值没有变化

正如你可以看到的,更改 Ref 值不会触发组件函数再次执行——另一方面,状态会。然而,如果一个组件函数再次运行(由于状态变化),Ref 值会被保留而不是丢弃。

因此,如果你有应该能够生存组件重新评估但不应作为状态管理的数据(因为该数据的变化不应导致组件在变化时重新评估),你可以使用一个 Ref:

const passwordRetries = useRef(0);
// later in the component ...
passwordRetries.current = 1; // changed from 0 to 1
// later ...
console.log(passwordRetries.current); // prints 1, even if the component changed 

这不是一个经常使用的功能,但有时可能会有所帮助。在其他所有情况下,使用正常的状态值。

自定义组件中的 Refs

Refs 不仅可以用来访问 DOM 元素,还可以用来访问 React 组件——包括你自己的组件。

这有时可能很有用。考虑这个例子:你有一个<Form>组件,它包含一个嵌套的<Preferences>组件。后者组件负责显示两个复选框,询问用户的新闻通讯偏好:

计算机屏幕截图 自动生成描述

图 7.4:一个显示两个复选框以设置新闻通讯偏好的新闻通讯注册表单

Preferences组件的代码可能看起来像这样:

function Preferences() {
  const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
  const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
  function handleChangeNewProdPref() {
    setWantsNewProdInfo((prevPref) => !prevPref);
  }
  function handleChangeUpdateProdPref() {
    setWantsProdUpdateInfo((prevPref) => !prevPref);
  }
  return (
    <div className={classes.preferences}>
      <label>
        <input
          type="checkbox"
          id="pref-new"
          checked={wantsNewProdInfo}
          onChange={handleChangeNewProdPref}
        />
        <span>New Products</span>
      </label>
      <label>
        <input
          type="checkbox"
          id="pref-updates"
          checked={wantsProdUpdateInfo}
          onChange={handleChangeUpdateProdPref}
        />
        <span>Product Updates</span>
      </label>
    </div>
  );
}; 

正如你所看到的,这是一个基本组件,它本质上输出两个复选框,添加一些样式,并通过状态跟踪选定的复选框。

Form组件的代码可能看起来像这样:

function Form() {
  function handleSubmit(event) {
    event.preventDefault();
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.formControl}>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" />
      </div>
      <Preferences />
      <button>Submit</button>
    </form>
  );
} 

现在想象一下,在表单提交(在handleSubmit函数内部)时,应该重置Preferences(即不再选择任何复选框)。此外,在重置之前,应该读取选定的值并在handleSubmit函数中使用它们。

如果复选框没有被放入一个单独的组件中,这将很简单。如果整个代码和 JSX 标记都位于Form组件中,那么可以在该组件中使用状态来读取和更改值。但在这个例子中并非如此,仅仅因为这个问题而重写代码听起来像是一个不必要的限制。

幸运的是,Refs 可以帮助这种情况。

你可以通过 Refs 将组件的功能(例如,函数或状态值)暴露给其他组件。本质上,Refs 可以用作两个组件之间的通信设备,就像它们在前面章节中用作与 DOM 元素的通信设备一样。

便利的是,你的自定义组件可以接收一个 ref 作为常规属性:

function Preferences(props) { // or function Preferences({ ref }) {}
  // can use props.ref in here
  // component code ...
};
export default Preferences; 

因此,你可以使用这个Preferences组件并将其传递一个ref给它:

function Form() {
  const preferencesRef = useRef(null);
  return <Preferences ref={preferencesRef} />;
} 

重要的是要注意,这段代码仅在 React 19 或更高版本中使用时才有效。当使用较旧的 React 版本时,将 Refs 作为常规属性传递给组件是不支持的。在这种情况下,你将不得不使用 React 提供的特殊forwardRef()函数将应该接收 Ref 的组件函数包装起来。

因此,在 React 18 或更早版本的 React 项目中,要接收和使用 Refs,你必须将接收组件(例如,在这个例子中的Preferences)包裹在forwardRef()中。

这可以这样操作:

const Preferences = forwardRef((props, ref) => {
  // component code ...
});
export default Preferences; 

这与其他本书中的所有组件看起来略有不同,因为这里使用的是箭头函数而不是function关键字。你始终可以使用箭头函数而不是“普通函数”,但在这里切换是有帮助的,因为它使得用forwardRef()包裹函数变得非常容易。或者,你也可以坚持使用function关键字,并像这样包裹函数:

function Preferences(props, ref) {
 // component code ...
};
export default forwardRef(Preferences); 

你可以选择你喜欢的语法。两者都有效,并且在 React 项目中都常用。

这段代码有趣的部分是,组件函数现在接收两个参数而不是一个。除了接收props,组件函数始终会这样做之外,它现在还接收一个特殊的ref参数。而这个参数之所以被接收,是因为组件函数被forwardRef()包裹。

这个ref参数将包含使用Preferences组件设置的任何ref值。例如,Form组件可以在Preferences上设置一个ref参数,如下所示:

function Form() {
  **const** **preferencesRef =** **useRef****({});**
  function handleSubmit(event) {
    // other code ...
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.formControl}>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" />
      </div>
      <Preferences **ref****=****{preferencesRef**} />
      <button>Submit</button>
    </form>
  );
} 

再次强调,useRef()用于创建一个ref对象(preferencesRef),然后通过特殊的ref属性将其传递给Preferences组件。创建的 Ref 接收一个默认值为空对象({})的值;正是这个对象可以通过ref.current访问。在Preferences组件中,ref值可以像常规属性一样接收和提取(React >= 19)或必须使用 React 的forwardRef()函数来访问。在这种情况下,它通过第二个ref参数接收,这是由于forwardRef()的存在。

但这有什么好处呢?现在如何在这个preferencesRef对象内部使用Preferences来启用跨组件交互?

由于ref是一个永远不会被替换的对象,即使通过useRef()创建它的组件被重新评估(参见上面的前几节),接收组件可以将属性和方法分配给该对象,创建组件然后可以使用这些方法和属性。因此,ref对象被用作通信工具。

在这个例子中,Preferences组件可以像这样更改以使用ref对象:

function Preferences(props) { // wrap with forwardRef() for React < 19
  const { ref } = props; // Extracting ref prop
  const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
  const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
  function handleChangeNewProdPref () {
    setWantsNewProdInfo((prevPref) => !prevPref);
  }
  function handleChangeUpdateProdPref() {
    setWantsProdUpdateInfo((prevPref) => !prevPref);
  }
  function reset() {
    setWantsNewProdInfo(false);
    setWantsProdUpdateInfo(false);
  }
  **ref.****current****.****reset** **= reset;**
  **ref.****current****.****selectedPreferences** **= {**
    **newProductInfo****: wantsNewProdInfo,**
    **productUpdateInfo****: wantsProdUpdateInfo**,
  };
  // also return JSX code (has not changed) ...
}); 

Preferences中,状态值和指向新添加的reset函数的指针都存储在接收到的ref对象中。使用ref.current是因为 React(在使用useRef()时)创建的对象始终具有这样的current属性,并且应该使用该属性来在ref中存储实际值。

由于PreferencesForm操作的是存储在ref对象中的同一个对象,因此在Preferences中分配给该对象的属性和方法也可以在Form中使用:

function Form() {
  const preferencesRef = useRef({});
  function handleSubmit(event) {
    event.preventDefault();
    **console****.****log****(preferencesRef.****current****.****selectedPreferences**); // reading a value
    **preferencesRef.****current****.****reset**(); // executing a function stored in Preferences
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.formControl}>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" />
      </div>
      <Preferences ref={preferencesRef} />
      <button>Submit</button>
    </form>
  );
} 

通过这种方式使用 Refs,父组件(在这种情况下是 Form)能够以命令式的方式与某些子组件(例如,Preferences)进行交互——这意味着可以访问属性并调用方法来操作子组件(或者更准确地说,触发子组件内部的一些函数和行为)。

注意

React 还提供了一个 useImperativeHandle() 钩子,它可以用来从自定义组件中暴露数据或函数。

从技术上讲,您不需要使用这个钩子,因为上面的例子已经证明了这一点。您可以通过 Refs 在组件之间进行通信,而不需要任何额外的钩子。

但您可能需要考虑使用 useImperativeHandle(),因为它将处理像缺少 ref 值(即没有提供 ref 值)这样的场景。您可以在官方文档中了解更多关于这个(可以说是小众的)钩子的使用方法:react.dev/reference/react/useImperativeHandle

受控组件与不受控组件

将 Refs 传递给自定义组件(通过 props 或 forwardRef())是一种方法,可以用来允许 FormPreferences 组件协同工作。但尽管这最初可能看起来像是一个优雅的解决方案,但对于这类问题,它通常不应成为您的默认解决方案。

如上面示例所示,使用 Refs 最终会导致更多的命令式代码。这是因为,而不是通过 JSX(这将是一种声明式方法)定义所需的用户界面状态,JavaScript 中添加了单个的逐步指令。

如果您回顾 第一章React – 什么和为什么JavaScript 的弊端部分),您会看到像 preferencesRef.current.reset()(来自上面的例子)这样的代码看起来与 buttonElement.addEventListener(…)(来自 第一章 的例子)这样的指令非常相似。这两个例子都使用了命令式代码,并且应该避免,正如 第一章 中提到的理由(逐步编写指令会导致低效的微观管理,并且通常会产生不必要的复杂代码)。

Form 组件内部,调用了 Preferencesreset() 函数。因此,代码描述了应该执行的动作(而不是预期的结果)。通常,当使用 React 时,您应该努力描述所需的(UI)状态。记住,当使用 React 时,您应该编写声明式代码,而不是命令式代码。

当使用 Refs 来读取或操作数据,如本章前面的部分所示,您正在构建所谓的不受控组件。这些组件被认为是“不受控”的,因为 React 并没有直接控制 UI 状态。相反,值是从其他组件或 DOM 中读取的。因此,DOM 控制着状态(例如,用户输入到输入字段中的值这样的状态)。

作为 React 开发者,你应该尽量减少使用非受控组件。如果你只需要收集一些输入值,使用 Refs 来节省一些代码是完全可行的。但是,一旦你的 UI 逻辑变得更加复杂(例如,如果你还想清除用户输入),你应该选择 受控组件

这样做相当简单:组件一旦被 React 管理状态,就变为受控组件。在本章开头提到的 EmailForm 组件的例子中,在引入 Refs 之前已经展示了受控组件的方法。使用 useState() 存储用户的输入(并且每次按键更新状态)意味着 React 完全控制了输入的值。

对于前面的例子,FormPreferences 组件,切换到受控组件方法可能看起来像这样:

function Preferences({newProdInfo, prodUpdateInfo, onUpdateInfo}) {
  return (
    <div className={classes.preferences}>
      <label>
        <input
          type="checkbox"
          id="pref-new"
          checked={newProdInfo}
          onChange={onUpdateInfo.bind(null, 'pref-new')}
        />
        <span>New Products</span>
      </label>
      <label>
        <input
          type="checkbox"
          id="pref-updates"
          checked={prodUpdateInfo}
          onChange={onUpdateInfo.bind(null, 'pref-updates')}
        />
        <span>Product Updates</span>
      </label>
    </div>
  );
}; 

在这个例子中,Preferences 组件停止管理复选框状态,而是从其父组件(Form 组件)接收属性。

onUpdateInfo 属性(它将接收一个函数作为值)上使用 bind()预先配置 该函数以供将来执行。bind() 是一个默认的 JavaScript 方法,可以在任何 JavaScript 函数上调用,以控制在将来调用该函数时将传递哪些参数。

注意

你可以在 academind.com/tutorials/function-bind-event-execution 上了解更多关于这个 JavaScript 功能的信息。

Form 组件现在管理复选框状态,即使它不直接包含复选框元素。但现在它开始控制 Preferences 组件及其内部状态,因此将 Preferences 转换为受控组件而不是非受控组件:

function Form() {
  const [wantsNewProdInfo, setWantsNewProdInfo] = useState(false);
  const [wantsProdUpdateInfo, setWantsProdUpdateInfo] = useState(false);
  function handleUpdateProdInfo(selection) {
    // using one shared update handler function is optional
    // you could also use two separate functions (passed to Preferences) as props
    if (selection === 'pref-new') {
      setWantsNewProdInfo((prevPref) => !prevPref);
    } else if (selection === 'pref-updates') {
      setWantsProdUpdateInfo((prevPref) => !prevPref);
    }
  }
  function reset() {
    setWantsNewProdInfo(false);
    setWantsProdUpdateInfo(false);
  }
  function handleSubmit(event) {
    event.preventDefault();
    // state values can be used here
    reset();
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.formControl}>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" />
      </div>
      <Preferences
        newProdInfo={wantsNewProdInfo}
        prodUpdateInfo={wantsProdUpdateInfo}
        onUpdateInfo={handleUpdateProdInfo}
      />
      <button>Submit</button>
    </form>
  );
} 

Form 组件管理复选框的选择状态,包括通过 reset() 函数重置状态,并将管理的状态值(wantsNewProdInfowantsProdUpdateInfo)以及 handleUpdateProdInfo 函数(用于更新状态值)传递给 Preferences。现在 Form 组件控制 Preferences 组件。

如果你阅读了上面的两个代码片段,你会注意到最终的代码再次是纯声明式的。在所有组件中,状态被管理和使用来声明预期的用户界面。

在大多数情况下,使用受控组件被认为是一种良好的实践。然而,如果你只是提取一些输入的用户值,那么使用 Refs 并创建一个非受控组件是完全可行的。

React 和 DOM 中的元素位置

离开 Refs 的主题,还有一个其他重要的 React 功能可以帮助影响(间接)DOM 交互:Portals

在构建用户界面时,有时需要条件性地显示元素和内容。这已经在第五章渲染列表和条件内容中讨论过了。当渲染条件性内容时,React 会将该内容注入到包含条件性内容的整体组件在 DOM 中的位置。

例如,当在输入字段下方显示条件性错误信息时,该错误信息在 DOM 中正好位于输入字段下方:

计算机屏幕截图  自动生成的描述

图 7.5:错误信息 DOM 元素位于其所属的元素下方

这种行为是有意义的。确实,如果 React 开始在随机位置插入 DOM 元素,那将会非常令人烦恼。但在某些场景中,你可能希望(条件性)DOM 元素被插入到 DOM 中的不同位置——例如,当处理如错误对话框之类的覆盖元素时。

在前面的示例中,你可以添加逻辑以确保如果表单提交了无效的电子邮件地址,则向用户显示错误对话框。这可以通过类似于“无效的电子邮件地址!”的错误信息逻辑来实现,因此对话框元素当然也会被动态注入到 DOM 中:

计算机屏幕截图  自动生成的描述

图 7.6:错误对话框及其背景被注入到 DOM 中

在此屏幕截图中,错误对话框作为一个覆盖层在背景元素上方打开,而背景元素本身被添加是为了使其作为用户界面的覆盖层。

注意

外观完全由 CSS 处理,你可以在这里查看完整的项目(包括样式):github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/examples/05-portals-problem .

这个示例工作得很好,看起来也很不错。然而,还有改进的空间。

从语义上讲,将覆盖元素注入到 DOM 中,嵌套在元素旁边,并不完全合理。覆盖元素更接近 DOM 的根(换句话说,是

或甚至是的直接子元素)会更合理,而不是成为
的子元素。而且,这不仅仅是一个语义问题。如果示例应用包含其他覆盖元素,这些元素可能会相互冲突,如下所示:

计算机屏幕截图  自动生成的描述

图 7.7:底部的

元素在背景之上可见

在这个例子中,底部(“一个示例项目”)的<footer>元素没有被错误对话框的背景隐藏或变灰。原因是 footer 也附加了一些 CSS 样式,使其成为事实上的覆盖层(因为使用了position: fixedleft + bottom)。

作为这个问题的解决方案,你可以调整一些 CSS 样式,例如,使用z-index CSS 属性来控制覆盖层级。然而,如果覆盖元素(即,<div>背景和<dialog>错误元素)被插入到 DOM 的不同位置——例如,在<body>元素的末尾(但作为<body>的直接子元素)——将会是一个更干净的解决方案。

这正是 React 门户可以帮助你解决的问题。

门户救援

在 React 的世界里,门户是一个允许你指示 React 将 DOM 元素插入到不同于其通常插入位置的功能。

考虑到上面显示的例子,这个门户功能可以用来指示 React 不要在<form>元素内部插入属于对话框的<dialog>错误和<div>背景,而是将这些元素插入到<body>元素的末尾。

要使用这个门户功能,你首先必须定义一个可以插入元素的位置(一个“注入钩子”)。这可以在属于 React 应用的 HTML 文件中完成(例如,index.html)。在那里,你可以在<body>元素中的某个位置添加一个新元素(例如,一个<div>元素):

<body>
  <div id="root"></div>
  **<****div****id****=****"dialogs"****></****div****>**
  <script type="module" src="img/main.jsx"></script>
</body> 

在这种情况下,在<div id="root">元素之后在<body>部分添加了一个<div id="dialogs">元素,以确保插入该元素的任何组件(及其样式)都是最后评估的。这将确保它们的样式具有更高的优先级,并且插入到<div id="dialogs">中的覆盖元素不会被 DOM 中较早出现的内容覆盖。添加和使用多个钩子是可能的,但在这个例子中只需要一个注入点。你也可以使用除<div>元素之外的 HTML 元素。

调整了index.html文件后,可以通过react-domcreatePortal()函数指示 React 在指定的注入点渲染某些 JSX 元素(即,组件):

import { createPortal } from 'react-dom';
import classes from './ErrorDialog.module.css';
function ErrorDialog({ onClose }) {
  return createPortal(
    <>
      <div className={classes.backdrop}></div>
      <dialog className={classes.dialog} open>
        <p>
          This form contains invalid values. Please fix those errors before
          submitting the form again!
        </p>
        <button onClick={onClose}>Okay</button>
      </dialog>
    </>,
    document.getElementById('dialogs')
  );
}
export default ErrorDialog; 

在这个ErrorDialog组件内部,它由另一个组件(例如,GitHub 上的示例代码EmailForm组件)条件性地渲染,返回的 JSX 代码被createPortal()包裹。createPortal()接受两个参数:应该在 DOM 中渲染的 JSX 代码以及在index.html中内容应注入的元素的指针。

在这个例子中,新添加的<div id="dialogs">是通过document.getElementById('dialogs')选择的。因此,createPortal()确保由ErrorDialog生成的 JSX 代码在 HTML 文档的该位置渲染:

img

图 7.8:覆盖元素被插入到<div id="dialogs">

在此屏幕截图中,您可以看到覆盖元素(<div>背景和<dialog>错误)确实被插入到<div id="dialogs">元素中,而不是<form>元素(如之前那样)。

由于这次更改,无需修改任何 CSS 代码,<footer>元素不再覆盖错误对话框的背景。从语义上讲,最终的 DOM 结构也更加合理,因为通常期望覆盖元素更接近根 DOM 节点。

尽管如此,使用此门户功能是可选的。通过更改一些 CSS 样式,同样可以达到相同的效果(尽管不是 DOM 结构)。不过,追求干净的 DOM 结构是一个值得追求的目标,避免不必要的复杂 CSS 代码也是一个不错的选择。

概述和关键要点

  • Refs 可用于直接访问 DOM 元素或存储在周围组件重新评估时不会被重置或更改的值。

  • 仅使用此直接访问来读取值,而不是操纵 DOM 元素(让 React 来处理)。

  • 通过 Refs 而不是状态和其他 React 功能获得 DOM 访问的组件被认为是未受控组件(因为 React 没有直接控制)。

  • 除非您正在执行非常简单的任务,例如读取输入的值,否则请优先使用受控组件(使用状态和严格的声明式方法)而不是不受控组件。

  • 使用 Refs,您还可以暴露您自己的组件功能,以便它们可以被命令式地使用。

  • 当使用 React 19 或更高版本时,您可以在自定义组件上设置和使用ref属性。

  • 当使用 React < 19 时,必须在自定义组件上使用 React 的forwardRef()函数来接收 Refs。

  • 门户可以用来指示 React 在 DOM 的不同位置渲染 JSX 元素,而不是它们通常的位置。

接下来是什么?

在本书的这一部分,您已经遇到了许多可以用来构建交互式和引人入胜的用户界面的关键工具和概念。多亏了 Refs,您可以在不使用状态的情况下读取 DOM 值(从而避免不必要的组件重新评估),或者管理在组件更新之间持续存在的值。多亏了 Portals,您能够控制组件标记在 DOM 中确切的位置。

因此,您获得了一些可以用来微调您的 React 应用的新工具。您可能能够通过避免组件重新评估来提高性能,或者提高 DOM 元素的架构和语义。最终,正是所有这些工具的组合,使您能够使用 React 构建引人入胜、交互式且性能良好的 Web 应用。

但是,正如您将在下一章中了解到的那样,React 还有更多有用的核心概念可以提供:例如,处理副作用的方法。

下一章将探讨副作用究竟是什么,为什么需要特殊处理,以及 React 如何帮助您处理这些。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的理解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/07-portals-refs/exercises/questions-answers.md中可以找到的示例进行比较。

  1. Refs 如何帮助处理表单中的用户输入?

  2. 什么是无状态组件?

  3. 什么是受控组件?

  4. 你应该在什么情况下使用 Refs?

  5. 端口背后的主要思想是什么?

应用你所学的知识

在学习了关于 Refs 和端口的新的知识之后,又是时候练习你所学的内容了。

下面,你将找到两个活动,允许你练习使用 Refs 和端口。一如既往,你当然还需要一些之前章节中涵盖的概念(例如,处理状态)。

活动 7.1:提取用户输入值

在这个活动中,你必须向现有的 React 组件添加逻辑以从表单中提取值。该表单包含一个输入字段和一个下拉菜单,你应该确保在表单提交时,两个值都被读取,并且为了这个模拟应用程序,输出到浏览器控制台。

使用你对 Refs 和无状态组件的了解来实现一个不使用 React 状态的解决方案。

注意

你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-1-start找到这个活动的起始代码。下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。

在项目文件夹中下载代码并运行npm install(安装所有必需的依赖项)之后,解决方案步骤如下:

  1. 创建两个 Refs,一个用于每个需要读取的输入元素(输入字段和下拉菜单)。

  2. 将 Refs 连接到输入元素。

  3. 在提交处理函数中,通过 Refs 访问连接的 DOM 元素并读取当前输入或选择的值。

  4. 将值输出到浏览器控制台。

img

预期的结果(用户界面)应如下所示:

计算机屏幕截图  自动生成描述

图 7.9:浏览器开发者工具控制台输出所选值

注意

你将在这个活动中找到所有用于此活动的代码文件以及解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-1找到。

活动 7.2:添加侧边抽屉

在这个活动中,你将连接一个已经存在的SideDrawer组件与主导航栏中的按钮,以便在点击按钮时打开侧边抽屉(即显示它)。侧边抽屉打开后,点击背景应再次关闭抽屉。

除了实现上述的一般逻辑外,你的目标将是确保在最终 DOM 中的正确定位,以便没有其他元素覆盖在SideDrawer之上(无需编辑任何 CSS 代码)。SideDrawer也不应嵌套在任何其他组件或 JSX 元素中。

注意

此活动附带一些起始代码,可以在以下位置找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-2-start

下载代码并运行npm install以安装所有必需的依赖项后,解决方案步骤如下:

  1. MainNavigation组件中添加逻辑,以有条件地显示或隐藏SideDrawer组件。

  2. 在 HTML 文档中为侧边抽屉添加一个注入钩子

  3. 使用 React 的 portal 功能在新增的钩子中渲染SideDrawer的 JSX 元素。

最终的用户界面应看起来和表现如下:

img

图 7.10:点击菜单按钮打开侧边抽屉

点击菜单按钮后,侧边抽屉打开。如果点击侧边抽屉背后的背景,它应该再次关闭。

最终的 DOM 结构(侧边抽屉已打开)应如下所示:

img

图 7.11:与抽屉相关的元素在 DOM 中插入到单独的位置

与侧边抽屉相关的 DOM 元素(背景<div><aside>)被插入到一个单独的 DOM 节点中(<div id="drawer">)。

注意

你将在这个活动中找到所有用于此活动的代码文件,以及解决方案,请访问github.com/mschwarzmueller/book-react-key-concepts-e2/tree/07-portals-refs/activities/practice-2

第八章:处理副作用

学习目标

到本章结束时,你将能够做到以下几件事情:

  • 识别你的 React 应用程序中的副作用

  • 理解和使用 useEffect() 钩子

  • 利用与 useEffect() 钩子相关的不同特性和概念,以避免错误并优化你的代码

  • 处理与状态变化相关和无关的副作用

简介

尽管本书之前涵盖的所有 React 示例都相对简单,并且介绍了许多关键 React 概念,但仅凭这些概念很难构建出许多真实的应用程序。

你作为 React 开发者将构建的大多数真实应用程序也需要发送 HTTP 请求,访问浏览器存储和日志分析数据,或执行任何其他类似任务,而仅凭组件、属性、事件和状态,你通常在尝试向应用程序添加此类功能时会遇到问题。详细的解释和示例将在本章后面讨论,但核心问题是这类任务通常会干扰 React 的组件渲染周期,导致意外的错误,甚至破坏应用程序。

本章将更深入地探讨这类操作,分析它们的共同点,最重要的是,教你如何在 React 应用程序中正确处理这类任务。

问题是什么?

在探索解决方案之前,首先理解具体问题是重要的。

与生成(新)用户界面状态无关的操作通常与 React 的组件渲染周期冲突。它们可能会引入错误,甚至破坏整个 Web 应用程序。

考虑以下示例代码片段(重要:不要执行此代码,因为它将导致无限循环并在幕后发送大量 HTTP 请求):

import { useState } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

那么,这段代码有什么问题?为什么它会创建一个无限循环?

在这个例子中,创建了一个 React 组件(BlogPosts)。此外,还定义了一个非组件函数(fetchPosts())。该函数使用浏览器提供的内置 fetch() 函数发送 HTTP 请求到外部 应用程序编程接口API)并获取一些数据。

注意

fetch() 函数由浏览器提供(所有现代浏览器都支持此功能)。你可以在academind.com/tutorials/xhr-fetch-axios-the-fetch-api了解更多关于 fetch() 的信息。

fetch() 函数返回一个 promise,在这个例子中,它通过 async / await 来处理。就像 fetch() 一样,promises 是一个关键的 Web 开发概念,你可以在这里了解更多信息(包括 async / await):developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

在这个上下文中,API 是一个公开各种路径的网站,可以发送请求——无论是提交还是获取数据。jsonplaceholder.typicode.com 是一个模拟 API,响应模拟数据。它可以用于像前面的例子那样的场景,你只需要一个 API 来发送请求。你可以用它来测试一些概念或代码,而无需连接或创建真实的后端 API。在这种情况下,它被用来探索一些 React 问题和解。对于本章和本书整体,预期你将具备使用 fetch() 发送 HTTP 请求和了解 API 的基本知识。如果需要,你可以使用 MDN(developer.mozilla.org/)等页面来加强你对这些核心概念的了解。

在前面的代码片段中,BlogPosts 组件使用 useState() 注册了一个 loadedPosts 状态值。这个状态用于输出一系列博客帖子。尽管这些帖子并没有在应用本身中定义,而是从注释框中提到的外部 API 中获取的。

fetchPosts(),这是一个包含使用内置 fetch() 函数从后端 API 获取博客帖子数据的代码的实用函数,在组件函数体中被直接调用。由于 fetchPosts() 是一个 async 函数(使用 async / await),它返回一个承诺。在 BlogPosts 中,一旦承诺解决,应该执行的代码是通过内置的 then() 方法注册的。

注意

async / await 不会直接在组件函数体中使用,因为常规的 React 组件不能是 async 函数。这样的函数会自动返回一个承诺作为值(即使没有显式的 return 语句),这对于 React 组件来说是一个无效的返回值。

话虽如此,确实存在允许使用 async / await 并返回承诺的 React 组件。所谓的 React 服务器组件 并不局限于返回 JSX 代码、字符串等。这一特性将在 第十六章React 服务器组件与服务器操作 中详细讨论。

一旦 fetchPosts() 的承诺得到解决,提取的帖子数据(fetchedPosts)就被设置为新的 loadedPosts 状态(通过 setLoadedPosts(fetchedPosts))。

如果你运行前面的代码(你不应该这样做!),它最初似乎可以工作。但实际上,它会在幕后启动一个无限循环,不断地用 HTTP 请求打击 API。这是因为,由于从 HTTP 请求中得到了响应,setLoadedPosts() 被用来设置新的状态。

在本书的早期(在第 第四章处理事件和状态 中),你了解到每当组件的状态发生变化时,React 会重新评估该状态所属的组件。“重新评估”简单来说就是组件函数再次被执行(由 React 自动执行)。

由于这个BlogPosts组件在组件函数体内直接调用fetchPosts()(它发送 HTTP 请求),因此每次执行组件函数时都会发送这个 HTTP 请求。并且由于从该 HTTP 请求中获取响应而更新状态(loadedPosts),这个过程再次开始,从而创建了一个无限循环。

在这种情况下,根本问题是发送 HTTP 请求是一个副作用——一个将在下一节中更详细探讨的概念。

理解副作用

副作用是指除了另一个主要过程之外发生的动作或过程。至少,这是一个简洁的定义,有助于在 React 应用程序的上下文中理解副作用。

注意

如果你想深入了解副作用的概念,你还可以探索 Stack Overflow 上的以下关于副作用的讨论:softwareengineering.stackexchange.com/questions/40297/what-is-a-side-effect

在 React 组件的情况下,主过程将是组件渲染周期,其中组件的主要任务是渲染在组件函数中定义的用户界面(返回的 JSX 代码)。React 组件应该返回最终的 JSX 代码,然后将其转换为 DOM 操作指令。

因此,React 将状态变化视为更新用户界面的触发器。注册事件处理器(如onClick)、添加 refs 或渲染子组件(可能通过使用 props)将是属于这个主过程的另一个元素——因为这些概念都与渲染所需用户界面的主要任务直接相关。

正如前例所示,发送 HTTP 请求并不属于这个主过程,它不会直接影响用户界面。虽然响应数据最终可能会显示在屏幕上,但它肯定不会在发送请求的同一个组件渲染周期中被使用(因为 HTTP 请求是异步任务)。

由于发送 HTTP 请求不是由组件函数(渲染用户界面)执行的主过程的一部分,因此它被认为是副作用。它是由同一个函数(BlogPosts组件函数)调用的,而这个函数的主要目标不同。

如果 HTTP 请求是在点击按钮时发送,而不是作为主组件函数体的一部分,那么它就不会是副作用。考虑以下示例:

import { useState } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  **function****handleFetchPosts****() {**
    **fetchPosts****().****then****(****(****fetchedPosts****) =>****setLoadedPosts****(fetchedPosts));**
  **}**
  return (
    <>
      **<****button****onClick****=****{handleFetchPosts}****>****Fetch Posts****</****button****>**
      <ul className={classes.posts}>
        {loadedPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}
export default BlogPosts; 

这段代码几乎与前一个示例相同,但它有一个重要的区别:JSX 代码中添加了一个<button>。正是这个按钮调用了新添加的handleFetchPosts()函数,然后发送 HTTP 请求(并更新状态)。

进行了此更改后,每次组件函数重新渲染(即,再次执行)时,都不会发送 HTTP 请求。相反,只有在按钮被点击时才会发送,因此,这不会创建无限循环。在这种情况下,HTTP 请求也不假设存在副作用,因为 handleFetchPosts()(即,主要过程)的主要目标是获取新帖子并更新状态。

副作用不仅仅是关于 HTTP 请求

在上一个例子中,你了解到了组件函数中可能发生的一种潜在副作用:HTTP 请求。你也了解到 HTTP 请求并不总是副作用,这取决于它们是在哪里创建的。

通常,任何在执行 React 组件函数时启动的动作,如果该动作与渲染组件用户界面的主要任务没有直接关系,则是一个副作用。

这里是一个副作用示例的非详尽列表:

  • 发送 HTTP 请求(如前所述)

  • 将数据存储到或从浏览器存储中获取数据(例如,通过内置的 localStorage 对象)

  • 设置定时器(通过 setTimeout())或间隔(通过 setInterval()

  • 通过 console.log() 将数据记录到控制台

然而,并非所有副作用都会导致无限循环。只有当副作用导致状态更新时,才会发生这样的循环。

这里是一个不会导致无限循环的副作用示例:

function ControlCenter() {
  function handleStart() {
    // do something ...
  }
  console.log('Component is rendering!'); // this is a side effect!
  return (
    <div>
      <p>Press button to start the review process</p>
      <button onClick={handleStart}>Start</button>
    </div>
  );
} 

在这个例子中,console.log(…) 是一个副作用,因为它作为每个组件函数执行的一部分执行,并且不会影响渲染的用户界面(在这种情况下,既不是针对这个特定的渲染周期,也不是间接地针对任何未来的渲染周期,与之前带有 HTTP 请求的例子不同)。

当然,像这样使用 console.log() 不会引起任何问题。在开发过程中,为了调试目的记录消息或数据是非常正常的。副作用并不一定是问题,实际上,这种副作用可以被使用或容忍。

但你也经常需要处理如之前所述的 HTTP 请求等副作用。有时,当组件渲染时需要获取数据——可能不是每个渲染周期,但通常是第一次执行时(即,当其生成的用户界面首次出现在屏幕上时)。

React 也为此类问题提供了一个解决方案。

使用 useEffect() Hook 处理副作用

为了以安全的方式(即,不创建无限循环)处理如前所述的 HTTP 请求等副作用,React 提供了另一个核心 Hook:useEffect() Hook。

第一个例子可以修复并重写如下:

import { useState, **useEffect** } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  **useEffect****(****function** **() {**
    **fetchPosts****().****then****(****(****fetchedPosts****) =>****setLoadedPosts****(fetchedPosts));**
  **}, []);**
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

在这个例子中,导入了 useEffect() 钩子并使用它来控制副作用(因此钩子的名字叫 useEffect(),因为它处理 React 组件中的副作用)。确切的语法和用法将在下一节中探讨,但如果你使用这个钩子,你可以安全地运行示例并得到一些类似以下的输出:

计算机屏幕截图  自动生成的描述

图 8.1:一组示例博客文章列表,没有无限循环的 HTTP 请求

在前面的屏幕截图中,你可以看到示例博客文章标题的列表,最重要的是,在检查发送的网络请求时,你找不到无限请求列表。

因此,useEffect() 是解决之前概述的问题的解决方案。它帮助你处理副作用,以便你可以避免无限循环并将它们从组件函数的主要流程中提取出来。

useEffect() 是如何工作的,以及如何正确使用它?

如何使用 useEffect()

如前一个示例代码片段所示,useEffect(),像所有 React 钩子一样,作为组件函数(在这种情况下是 BlogPosts)内部的一个函数执行。

虽然,与 useState()useRef() 不同,useEffect() 不返回值,尽管它接受一个参数(或者实际上,两个参数)像那些其他钩子一样。第一个参数 总是 一个函数。在这种情况下,传递给 useEffect() 的函数是一个匿名函数,通过 function 关键字创建的。

或者,你也可以提供一个作为箭头函数创建的匿名函数(useEffect(() => { … }))或指向某个命名函数(useEffect(doSomething))。唯一重要的是,传递给 useEffect() 的第一个参数 必须 是一个函数。它不能是任何其他类型的值。

在前面的例子中,useEffect() 还接收第二个参数:一个空数组([])。第二个参数必须是一个数组,但提供它是 可选的。你也可以省略第二个参数,只传递第一个参数(函数)给 useEffect()。然而,在大多数情况下,第二个参数是必要的,以实现正确的行为。以下将更详细地探讨这两个参数及其用途。

第一个参数是一个函数,它将由 React 执行。它将在每个组件渲染周期之后执行(即,在每个组件函数执行之后)。

在前面的例子中,如果你只提供这个第一个参数并省略第二个,你仍然会创建一个无限循环。由于 HTTP 请求现在将在每次组件函数执行后发送(而不是作为它的一部分),因此会有一个(不可见的)时间差,但你仍然会触发状态变化,这仍然会触发组件函数再次执行。因此,效果函数将再次运行,并创建一个无限循环。在这种情况下,副作用在技术上是从组件函数中提取出来的,但无限循环的问题并没有得到解决:

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}); // this would cause an infinite loop again! 

将副作用从 React 组件函数中提取出来是useEffect()的主要任务,因此只有第一个参数(包含副作用代码的函数)是必需的。但是,如前所述,你通常还需要第二个参数来控制效果代码执行的频率,因为这就是第二个参数(一个数组)的作用。

useEffect()接收到的第二个参数总是一个数组(除非省略)。这个数组指定了效果函数的依赖项。任何在这个数组中指定的依赖项,一旦它发生变化,就会导致效果函数再次执行。如果没有指定数组(即省略第二个参数),效果函数将在每次组件函数执行时再次执行:

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, []); 

在前面的例子中,第二个参数没有被省略,但它是一个空数组。这告诉 React 这个效果函数没有依赖项。因此,效果函数将不会再次执行。相反,它只会在组件首次渲染时执行一次。如果你设置没有依赖项(通过提供一个空数组),React 将只执行一次效果函数——直接在组件函数首次执行之后。

重要的是要注意,指定一个空数组与省略它非常不同。如果省略了它,就不会向 React 提供任何依赖信息。因此,React 会在每次组件重新评估后执行效果函数。如果提供了空数组,你明确表示这个效果没有依赖项,因此应该只运行一次。

尽管如此,这又引出了另一个重要的问题:你何时应该添加依赖项?以及依赖项是如何添加或指定的?

影响及其依赖关系

省略useEffect()的第二个参数会导致效果函数(第一个参数)在每次组件函数执行后执行。提供一个空数组会导致效果函数只运行一次(在首次调用组件函数之后)。但这是你能控制的全部吗?

不,不是的。

传递给useEffect()的数组可以也应该包含在效果函数内部使用的所有变量、常量或函数——如果这些变量、常量或函数是在组件函数内部(或在某些父组件函数中,通过 props 传递下来)定义的。

考虑这个例子:

import { useState, useEffect } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts(url) {
  const response = await fetch(url);
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts(**{ url }**) {
  const [loadedPosts, setLoadedPosts] = useState([]);
  useEffect(function () {
    fetchPosts(**url**)
     .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
  }, **[url]**);
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

这个例子基于前面的例子,但在一个重要地方进行了调整:BlogPosts现在接受一个url属性。

因此,这个组件现在可以被其他组件使用和配置。当然,如果其他组件设置了一个不会返回博客文章列表的 URL,应用程序将无法按预期工作。因此,这个组件可能在实际应用中有限制,但它确实很好地展示了效果依赖的重要性。

但如果其他组件更改了 URL(例如,由于某些用户输入),当然应该发送一个新的请求。因此,每当url属性值发生变化时,BlogPosts应该发送另一个获取请求。

这就是为什么将url添加到useEffect()的依赖项数组中的原因。如果数组保持为空,效果函数将只运行一次(如前节所述)。因此,对url的任何更改都不会对效果函数或作为该函数一部分执行的 HTTP 请求产生影响。不会发送新的 HTTP 请求。

通过将url添加到依赖项数组中,React 注册了这个值(在这种情况下,是一个属性值,但任何值都可以注册),并且每当该值发生变化时(即,每当使用BlogPosts的组件设置新的url属性值时),都会重新执行效果函数。

最常见的效果依赖类型是状态值、属性以及可能在效果函数内部执行的函数。后者将在本章后面进行更深入的分析。

根据规则,你应该将效果函数内部使用的所有值(包括函数)添加到效果依赖项数组中。

在这个新知识的基础上,如果你再次查看前面的useEffect()示例代码,可能会发现一些缺失的依赖项:

useEffect(function () {
  fetchPosts(url)
    .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, [url]); 

为什么fetchPostsfetchedPostssetLoadedPosts没有被添加为依赖项?毕竟,这些都是效果函数内部使用的值和函数。下一节将详细说明这一点。

不必要的依赖项

在前面的例子中,可能会觉得应该将fetchPostsfetchedPostssetLoadedPosts作为依赖项添加到useEffect()中,如下所示:

useEffect(function () {
  fetchPosts(url)
    .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, [url, fetchPosts, fetchedPosts, setLoadedPosts]); 

然而,对于fetchPostsfetchedPosts,这将是不正确的。对于setLoadedPosts,这将是不必要的。

不应该将fetchedPosts添加,因为它不是一个外部依赖。它是一个局部变量(或更准确地说,是参数),在效果函数内部定义和使用。它没有在属于效果函数的组件函数中定义。如果你尝试将其作为依赖项添加,你会得到一个错误:

计算机屏幕截图 自动生成的描述

图 8.2:发生错误——无法找到 fetchedPosts

发送实际 HTTP 请求的 fetchPosts 函数不是在 effect 函数内部定义的函数。但仍然不应该添加,因为它是在组件函数外部定义的。

因此,这个函数无法改变。它只定义了一次(在 BlogPosts.jsx 文件中),并且无法改变。话虽如此,如果它在组件函数内部定义,情况就不同了。在这种情况下,每当组件函数再次执行时,fetchPosts 函数也会被重新创建。这种情况将在本章后面的部分(在 函数作为依赖 部分中)进行讨论。

然而,在这个例子中,fetchPosts 无法改变。因此,不需要将其作为依赖项添加(并且因此不应该添加)。对于浏览器或第三方包提供的函数,或任何类型的值,只要不是在组件函数内部定义的,都不应该添加到依赖项数组中。

注意

可能会让人困惑,一个函数可能会改变——毕竟,逻辑是硬编码的,对吧?但在 JavaScript 中,函数实际上只是对象,因此可能会改变。当包含函数的代码再次执行时(例如,React 再次执行组件函数),内存中会创建一个新的函数对象。

如果你对这个不熟悉,以下资源可能会有所帮助:academind.com/tutorials/javascript-functions-are-objects .

因此,fetchedPostsfetchPosts 都不应该添加(出于不同的原因)。那么 setLoadedPosts 呢?

setLoadedPosts 是由 useState() 返回用于更新 loadedPosts 状态值的函数。因此,像 fetchPosts 一样,它是一个函数。不过,与 fetchPosts 不同的是,它是一个在组件函数内部定义的函数(因为 useState() 是在组件函数内部调用的)。它是由 React 创建的函数(因为它是由 useState() 返回的),但它仍然是一个函数。因此,理论上应该将其添加为依赖项。实际上,你可以添加它而不会产生任何负面影响。

useState() 返回的状态更新函数是一个特殊情况:React 保证这些函数永远不会改变或被重新创建。当周围的组件函数(BlogPosts)再次执行时,useState() 也会再次执行。然而,只有在组件函数第一次被 React 调用时才会创建一个新的状态更新函数。随后的执行不会导致创建新的状态更新函数。

由于这种特殊行为(即,React 保证函数本身永远不会改变),状态更新函数可以(实际上也应该)省略在依赖项数组中。

由于所有这些原因,fetchedPostsfetchPostssetLoadedPosts 都不应添加到 useEffect() 的依赖项数组中。url 是效果函数使用的唯一可能变化的依赖项(即,当用户在输入字段中输入新的 URL 时),因此应列在数组中。

总结一下,当涉及到向效果依赖项数组添加值时,有三种类型的异常:

  • 在效果内部定义并使用的内部值(或函数)(例如 fetchedPosts

  • 组件函数内部未定义的外部值(例如 fetchPosts

  • 状态更新函数(例如 setLoadedPosts

在所有其他情况下,如果效果函数中使用了某个值,必须将其添加 到依赖项数组中!错误地省略值可能导致意外的效果执行(即,效果执行得太频繁或不够频繁)。

效果后的清理

要执行特定任务(例如,发送 HTTP 请求),许多效果应该在它们的依赖项发生变化时简单地触发。虽然某些效果可以多次重新执行而不会出现问题,但也有效果,如果在之前的任务完成之前再次执行,则表明执行的任务需要取消。或者,也许在相同的效果再次执行时,应该执行一些其他类型的清理工作。

这里有一个示例,其中效果设置了一个计时器:

import { useState, useEffect } from 'react';
function Alert() {
  const [alertDone, setAlertDone] = useState(false);
  useEffect(function () {
    console.log('Starting Alert Timer!');
    setTimeout(function () {
      console.log('Timer expired!');
      setAlertDone(true);
    }, 2000);
  }, []);
  return (
    <>
      {!alertDone && <p>Relax, you still got some time!</p>}
      {alertDone && <p>Time to get up!</p>}
    </>
  );
}
export default Alert; 

Alert 组件在 App 组件中使用:

import { useState } from 'react';
import Alert from './components/Alert.jsx';
function App() {
  const [showAlert, setShowAlert] = useState(false);
  function handleShowAlert() {
    // state updating is done by passing a function to setShowAlert
    // because the new state depends on the previous state (it's the opposite)
    setShowAlert((isShowing) => !isShowing);
  }
  return (
    <>
      <button onClick={handleShowAlert}>
        {showAlert ? 'Hide' : 'Show'} Alert
      </button>
      {showAlert && <Alert />}
    </>
  );
}
export default App; 

App 组件中,Alert 组件是条件性地显示的。showAlert 状态通过 handleShowAlert 函数切换(该函数在按钮点击时触发)。

Alert 组件中,使用 useEffect() 设置了一个计时器。如果没有 useEffect(),将会创建一个无限循环,因为计时器在到期时通过 setAlertDone 状态更新函数更改了一些组件状态(alertDone 状态)。

依赖项数组是一个空数组,因为此效果函数没有使用任何组件值、变量或函数。console.log()setTimeout() 是浏览器内置的函数(因此是外部函数),而 setAlertDone() 可以省略,因为前文提到的理由。

如果你运行此应用并开始切换警报(通过点击按钮),你会注意到奇怪的行为。计时器每次 Alert 组件渲染时都会设置。但它没有清除现有的计时器。这是因为同时运行了多个计时器,如果你查看浏览器开发者工具中的 JavaScript 控制台,可以清楚地看到这一点:

计算机屏幕截图  自动生成的描述

图 8.3:启动了多个计时器

这个示例故意保持简单,但还有其他场景,你可能需要在发送新的请求之前取消当前的 HTTP 请求。在这种情况下,应该先清理效果再重新运行。

React 也为这些情况提供了一个解决方案:传递给 useEffect() 的第一个参数的效果函数可以返回一个可选的清理函数。如果你在效果函数内部返回一个函数,React 将在每次再次运行效果之前执行该函数。

这是带有返回清理函数的 useEffect() 调用的 Alert 组件:

useEffect(function () {
  **let** **timer;**
  console.log('Starting Alert Timer!');
  **timer =** setTimeout(function () {
    console.log('Timer expired!');
    setAlertDone(true);
  }, 2000);
  **return****function****() {**
    **clearTimeout****(timer);**
  **}**
}, []); 

在这个更新的示例中,添加了一个新的 timer 变量(一个仅在效果函数内部可访问的局部变量)。该变量存储由 setTimeout() 创建的计时器的引用。然后可以使用这个引用与 clearTimeout() 一起使用来移除一个计时器。

计时器是在效果函数返回的函数中被移除的——这就是将在 React 下一次调用效果函数之前自动执行清理函数的清理函数。

如果你给它添加一个 console.log() 语句,你就可以看到清理函数的实际效果:

return function() {
  console.log('Cleanup!');
  clearTimeout(timer);
} 

在你的 JavaScript 控制台中,这看起来如下所示:

计算机屏幕截图  自动生成的描述

图 8.4:清理函数在效果再次运行之前执行

在前面的屏幕截图中,你可以看到清理函数是在效果函数再次执行之前执行的(由 Cleanup! 日志表示)。你还可以看到计时器已被成功清除:第一个计时器永远不会过期(屏幕截图中第一个计时器没有 Timer expired! 日志)。

当效果函数第一次被调用时,清理函数不会被执行。然而,每当包含效果的组件卸载(即从 DOM 中移除)时,React 都会调用它。

如果一个效果有多个依赖项,那么每当任何依赖项值发生变化时,效果函数都会被执行。因此,清理函数也会在每次某些依赖项发生变化时被调用。

处理多个效果

到目前为止,本章中的所有示例都只处理了一个 useEffect() 调用。尽管如此,你并不局限于每个组件只调用一次。你可以根据需要多次调用 useEffect()——因此可以注册所需数量的效果函数。

但你需要多少个效果函数呢?

你可以将每个副作用都放入它自己的 useEffect() 包装器中。你可以将每个 HTTP 请求、每个 console.log() 语句和每个计时器放入单独的效果函数中。

话虽如此,正如你在一些之前的示例中看到的那样——特别是前一个部分中的代码片段——这并不是必要的。在那里,你可以在一个 useEffect() 调用中实现多个效果(三个 console.log() 语句和一个计时器)。

一种更好的方法是按照依赖关系拆分你的效果函数。如果一个副作用依赖于状态 A,而另一个副作用依赖于状态 B,你可以将它们放入不同的效果函数中(除非这两个状态相关),如下所示:

function Demo() {
  const [a, setA] = useState(0); // state updating functions aren't called
  const [b, setB] = useState(0); // in this example
  useEffect(function() {
    console.log(a);
  }, [a]);  

  useEffect(function() {
    console.log(b);
  }, [b]);
  // return some JSX code ...
} 

但最好的方法是按照逻辑拆分你的效果函数。如果一个效果涉及通过 HTTP 请求获取数据,而另一个效果是设置计时器,那么将它们放入不同的效果函数(即不同的 useEffect() 调用)通常是有意义的。

作为依赖项的函数

不同的效果有不同的依赖类型,其中一种常见的依赖类型是函数。

如前所述,JavaScript 中的函数只是对象。因此,每当执行包含函数定义的代码时,就会创建一个新的函数对象并将其存储在内存中。调用函数时,执行的是内存中特定的函数对象。在某些情况下(例如,对于在组件函数中定义的函数),可能存在基于相同函数代码的多个对象在内存中。

由于这种行为,即使在基于相同的函数定义,代码中引用的函数也不一定是相等的。

考虑以下示例:

function Alert() {
  function setAlert() {
    setTimeout(function() {
      console.log('Alert expired!');
    }, 2000);
  }
  useEffect(function() {
    setAlert();
  }, [setAlert]);
  // return some JSX code ...
} 

在这个例子中,不是在效果函数内部直接创建计时器,而是在组件函数中创建一个单独的 setAlert() 函数。然后,在传递给 useEffect() 的效果函数中使用该 setAlert() 函数。由于该函数在那里使用,并且因为它是在组件函数中定义的,所以它应该被添加为 useEffect() 的依赖项。

另一个原因是,每当 Alert 组件函数再次执行(例如,因为某些状态或属性值发生变化)时,就会创建一个新的 setAlert 函数对象。在这个例子中,这不会成为问题,因为 setAlert 只包含静态代码。为 setAlert 创建的新函数对象将像上一个一样工作;因此,这不会产生影响。

但现在考虑这个调整后的示例:

注意

完整的应用可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/function-dependencies

function Alert() {
  **const** **[alertMsg, setAlertMsg] =** **useState****(****'Expired!'****);**
  **function****handleChangeAlertMsg****(****event****) {**
    **setAlertMsg****(event.****target****.****value****);**
  **}**
  function setAlert() {
    setTimeout(function () {
      console.log(**alertMsg**);
    }, 2000);
  }
  useEffect(
    function () {
      setAlert();
    },
    []
  );
  **return****<****input****type****=****"text"****onChange****=****{handleChangeAlertMsg}** **/>****;**
}
export default Alert; 

现在,使用一个新的 alertMsg 状态来设置实际记录到控制台的警告消息。此外,setAlert 依赖关系已从 useEffect() 中移除。

如果你运行此代码,你会得到以下输出:

计算机屏幕截图  自动生成的描述

图 8.5:控制台日志没有反映输入的值

在这个屏幕截图中,你可以看到,尽管输入字段中输入了不同的值,但仍然输出了原始的警告消息。

这种行为的原因是新警报消息没有被捕获。它没有被使用,因为尽管组件函数再次执行(因为状态发生了变化),但效果并没有再次执行。并且原始的效果执行仍然使用旧的setAlert函数版本——旧的setAlert函数对象,其中锁定了旧的警报消息。这就是 JavaScript 函数的工作方式,这就是为什么在这种情况下,期望的结果没有实现。

解决这个问题的方法很简单:将setAlert作为依赖项添加到useEffect()中。你应该始终将效果中使用的所有值、变量或函数作为依赖项添加,这个例子展示了为什么你应该这样做。即使是函数也可以改变。

如果你将setAlert添加到效果依赖数组中,你会得到不同的输出:

useEffect(
  function () {
    setAlert();
  },
  [setAlert]
); 

请注意,只添加了setAlert函数的指针。你不需要在依赖项数组中执行函数(这会将函数的返回值作为依赖项添加,这通常不是目标)。

计算机截图  自动生成的描述

图 8.6:启动多个计时器

现在,每按一个键都会启动一个新的计时器,因此输入的消息会在控制台输出。

当然,这可能也不是你期望的结果。你可能只对最后输入的最终错误消息感兴趣。这可以通过向效果添加清理函数(并对setAlert进行一点调整)来实现:

function setAlert() {
  return setTimeout(function () {
    console.log(alertMsg);
  }, 2000);
}
useEffect(
  function () {
    **const** **timer =** **setAlert****();**
    **return****function** **() {**
      **clearTimeout****(timer);**
    **};**
  },
  [setAlert]
); 

效果清理部分所示,计时器是通过计时器引用和效果清理函数中的clearTimeout()来清除的。

调整代码后,只有最后输入的最终警报消息会被输出。

再次看到清理函数的作用是有帮助的;主要的启示是添加所有依赖项的重要性——包括函数依赖项。

将函数作为依赖项包括的替代方案是将整个函数定义移动到效果函数中,因为任何在效果函数内部定义并使用的值都不应该作为依赖项添加:

useEffect(
  function () {
    **function****setAlert****() {**
      **return****setTimeout****(****function** **() {**
        **console****.****log****(alertMsg);**
      **},** **2000****);**
    **}**
    const timer = setAlert();
    return function () {
      clearTimeout(timer);
    };
  },
  []
); 

当然,你也可以完全去掉setAlert函数,然后将函数的代码移动到效果函数中。

无论哪种方式,你都需要添加一个新的依赖项,alertMsg,现在它被用于效果函数内部。即使setAlert函数不再是依赖项,你仍然必须添加任何使用的值(现在alertMsg被用于效果函数):

useEffect(
  function () {
    function setAlert() {
      return setTimeout(function () {
        console.log(alertMsg);
      }, 2000);
    }
    const timer = setAlert();
    return function () {
      clearTimeout(timer);
    };
  },
  **[alertMsg]**
); 

因此,这种编写代码的替代方法只是个人偏好的问题。它并不会减少依赖项的数量。

如果你将函数移出组件函数,你就可以消除函数依赖。这是因为,如 不必要的依赖项 部分所述,外部依赖项(例如,内置在浏览器中或定义在组件函数之外的)不应作为依赖项添加。

然而,对于 setAlert 函数来说,这是不可能的,因为 setAlert 使用了 alertMsg。由于 alertMsg 是组件状态值,使用它的函数必须在组件函数内部定义;否则,它将无法访问该状态值。

这听起来可能相当复杂,但归结为两个简单的规则:

  • 总是添加所有非外部依赖项——无论它们是变量还是函数。

  • 函数只是对象,如果它们的周围代码再次执行,它们可能会发生变化。

避免不必要的副作用执行

由于所有依赖项都应该添加到 useEffect() 中,有时你最终会得到一些导致不必要的副作用执行的代码。

考虑以下示例组件:

注意

完整的示例可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/unnecessary-executions

import { useState, useEffect } from 'react';
function Alert() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [enteredPassword, setEnteredPassword] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  }
  function validateEmail() {
    if (!enteredEmail.includes('@')) {
      console.log('Invalid email!');
    }
  }
  useEffect(function () {
    validateEmail();
  }, [validateEmail]);
  return (
    <form>
      <div>
        <label>Email</label>
        <input type="email" onChange={handleUpdateEmail} />
      </div>
      <div>
        <label>Password</label>
        <input type="password" onChange={handleUpdatePassword} />
      </div>
      <button>Save</button>
    </form>
  );
}
export default Alert; 

此组件包含一个带有两个输入字段的形式。输入的值存储在两个不同的状态值中(enteredEmailenteredPassword)。然后 validateEmail() 函数执行一些电子邮件验证,如果电子邮件地址无效,则将消息记录到控制台。validateEmail() 是通过 useEffect() 执行的。

此代码的问题在于,每当 validateEmail 发生变化时,副作用函数都会执行,因为正确地,validateEmail 被添加为依赖项。但是,每当组件函数再次执行时,validateEmail 都会发生变化。这不仅适用于 enteredEmail 的状态变化,也适用于 enteredPassword 的任何变化——即使这个状态值在 validateEmail 内部根本未使用。

可以通过各种解决方案避免这种不必要的副作用执行:

  • 你可以将 validateEmail 中的代码直接移动到副作用函数中(这样 enteredEmail 就会成为副作用的唯一依赖项,避免在任何其他状态变化时执行副作用)。

  • 你可以完全避免使用 useEffect(),因为你可以将电子邮件验证放在 handleUpdateEmail 中执行。其中包含 console.log()(副作用)是可以接受的,因为它不会造成任何伤害。

  • 你可以直接在组件函数中调用 validateEmail()——因为它不会改变任何状态,所以不会触发无限循环。

注意

官方 React 文档中有一篇文章强调了可能不需要 useEffect() 的场景:react.dev/learn/you-might-not-need-an-effect

此外,我创建了一个视频,总结了你需要或不需要 useEffect() 的最重要的情况:www.youtube.com/watch?v=V1f8MOQiHRw

当然,在某些其他场景中,你可能需要使用 useEffect()。幸运的是,React 也为这种情况提供了解决方案:你可以用另一个 React Hook,即 useCallback() Hook,包裹用作依赖项的函数。

调整后的代码将看起来像这样:

import { useState, useEffect, useCallback } from 'react';
function Alert() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [enteredPassword, setEnteredPassword] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  }
  **const** **validateEmail =** **useCallback****(**
    **function** **() {**
      **if** **(!enteredEmail.****includes****(****'@'****)) {**
        **console****.****log****(****'Invalid email!'****);**
      **}**
    **},**
    **[enteredEmail]**
  **);**
  useEffect(
    function() {
      validateEmail();
    },
    [validateEmail]
  );
  // return JSX code ...
}
export default Alert; 

useCallback(),像所有 React Hooks 一样,是一个在组件函数内部直接执行的功能。像 useEffect() 一样,它接受两个参数:另一个函数(可以是匿名函数或命名函数)和一个依赖项数组。

然而,与 useEffect() 不同,useCallback() 不会执行接收到的函数。相反,useCallback() 确保只有在指定的依赖项之一发生变化时,函数才会被重新创建。默认的 JavaScript 行为是在周围代码再次执行时创建一个新的函数对象(合成地)被禁用。

useCallback() 返回最新的保存的函数对象。因此,返回的值(它是一个函数)被保存在一个变量或常量中(在前面的例子中是 validateEmail)。

由于 useCallback() 包装的函数现在只有在依赖项之一发生变化时才会改变,因此返回的函数可以用作 useEffect() 的依赖项,而无需为所有类型的州变化或组件更新执行该效果。

在前一个例子的情况下,副作用函数只有在 enteredEmail 发生变化时才会执行——因为这是唯一会导致创建新的 validateEmail 函数对象的变化。

另一个导致不必要的副作用执行的原因是使用对象作为依赖项,如下例所示:

import { useEffect } from 'react';
function Error(props) {
  useEffect(
    function () {
      // performing some error logging
      // in a real app, a HTTP request might be sent to some analytics API
      console.log('An error occurred!');
      console.log(props.message);
    },
    [props]
  );
  return <p>{props.message}</p>;
}
export default Error; 

这个 Error 组件被用于另一个组件,即 Form 组件,如下所示:

import { useState } from 'react';
import Error from './Error.jsx';
function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmitForm(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Only email addresses ending with .com are accepted!');
    }
  }
  return (
    <form onSubmit={handleSubmitForm}>
      <div>
        <label>Email</label>
        <input type="email" onChange={handleUpdateEmail} />
      </div>
      {errorMessage && <Error message={errorMessage} />}
      <button>Submit</button>
    </form>
  );
}
export default Form; 

Error 组件通过 props(props.message)接收错误消息并在屏幕上显示它。此外,借助 useEffect(),它进行一些错误记录。在这个例子中,错误只是简单地输出到 JavaScript 控制台。在实际应用中,错误可能会通过 HTTP 请求发送到某个分析 API。无论如何,都会执行一个依赖于错误消息的副作用。

Form 组件包含两个状态值,跟踪输入的电子邮件地址以及输入的错误状态。如果提交了无效的输入值,errorMessage 将被设置,并且会显示 Error 组件。

这个例子中有趣的部分是Error组件内部的useEffect()的依赖数组。它包含props对象作为依赖项(props始终是一个对象,将所有属性值组合在一起)。当使用对象(props 或任何其他对象;这并不重要)作为useEffect()的依赖项时,可能会出现不必要的效果函数执行。

你可以在这个例子中看到这个问题。如果你运行应用程序并输入一个无效的电子邮件地址(例如,test@test.de),你会注意到在电子邮件输入字段中的后续按键会导致错误信息被记录(通过效果函数)。

注意

完整代码可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/objects-as-dependencies

计算机屏幕截图 自动生成描述

图 8.7:每次按键都会记录一条新的错误信息

这些额外的执行可能发生,因为组件重新评估(即,组件函数再次被 React 调用)将产生全新的 JavaScript 对象。即使这些对象的属性值没有改变(如前例所示),技术上,JavaScript 会创建一个新的对象。由于效果依赖于整个对象,React 只“看到”该对象有一个新版本,因此会再次运行效果。

在前面的例子中,每当 React 调用Form组件函数时,就会创建一个新的props对象(用于Error组件)——即使错误信息(唯一设置的属性值)没有改变。

在这个例子中,这只会让人感到烦恼,因为它会弄乱开发者工具中的 JavaScript 控制台。然而,如果你向某个分析后端 API 发送 HTTP 请求,这可能会引起带宽问题并使应用程序变慢。因此,最好养成避免不必要的效果执行的惯例。

在对象依赖的情况下,避免不必要的执行的最佳方法是将对象解构,以便只传递那些效果所需的属性作为依赖项:

function Error(props) {
  const { message } = props; // destructure to extract required properties
  useEffect(
    function () {
      console.log('An error occurred!');
      console.log(message);
    },
    // [props] // don't use the entire props object!
    [message]
  );
  return <p>{message}</p>;
} 

在属性的情况下,你还可以在组件函数参数列表中直接解构对象:

function Error({message}) {
  // ...
} 

使用这种方法,你可以确保只有所需的属性值被设置为依赖项。因此,即使对象被重新创建,属性值(在这种情况下,message属性的值)是唯一重要的事情。如果它没有改变,效果函数将不会再次执行。

效果和异步代码

一些副作用处理异步代码(发送 HTTP 请求是一个典型的例子)。在效果函数中执行异步任务时,有一个重要的规则需要记住:效果函数本身不应该异步,也不应该返回承诺。这并不意味着你无法在副作用中处理承诺——你只是不能返回承诺。

你可能想使用async / await来简化异步代码,但在效果函数内部这样做时,很容易意外地返回一个承诺。例如,以下代码可以工作,但不符合最佳实践:

useEffect(async function () {
  const fetchedPosts = await fetchPosts();
  setLoadedPosts(fetchedPosts);
}, []); 

function前添加async关键字可以解锁函数内await的使用——这使得处理异步代码(即,处理承诺)更加方便。

但传递给useEffect()的效果函数应该只返回一个普通函数,如果有的话。它不应该返回承诺。实际上,当尝试运行前面代码片段中的代码时,React 会发出警告:

计算机代码的截图  自动生成的描述

图 8.8:React 显示关于在效果函数中使用异步的警告

为了避免这个警告,你可以像这样使用承诺而不使用async / await

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, []); 

这之所以有效,是因为效果函数没有返回承诺。

或者,如果你想使用async / await,你可以在效果函数内部创建一个单独的包装函数,然后在该效果中执行:

useEffect(function () {
  async function loadData() {
    const fetchedPosts = await fetchPosts();
    setLoadedPosts(fetchedPosts);
  }

  loadData();
}, []); 

通过这样做,效果函数本身不是异步的(它不返回承诺),但你仍然可以使用async / await

Hooks 规则

在本章中,介绍了两个新的 Hooks:useEffect()useCallback()。这两个 Hooks 都非常重要——useEffect()尤其重要,因为这是你通常会大量使用的 Hooks。与在第四章处理事件和状态中引入的useState()第七章Portals 和 Refs中引入的useRef()一起,你现在有一套坚实的核心 React Hooks。

当使用 React Hooks 时,你必须遵循两条规则(所谓的Hooks 规则):

  • 只在组件函数的最顶层调用 Hooks。不要在if语句、循环或嵌套函数内部调用它们。

  • 只能在 React 组件或自定义 Hook(自定义 Hook 将在第十二章构建自定义 React Hook)内部调用 Hooks。

这些规则存在的原因是,如果以不符合规定的方式使用,React Hooks 将无法按预期工作。幸运的是,如果你违反了这些规则之一,React 会生成一个警告消息;因此,如果你不小心这样做,你会注意到。

摘要和关键要点

  • 与函数的主要流程不直接相关的操作可以被认为是副作用。

  • 副作用可以是异步任务(例如,发送 HTTP 请求),但也可以是同步的(例如,console.log() 或访问浏览器存储)。

  • 副作用通常是为了实现某个目标而需要的,但将它们从函数的主要流程中分离出来是个好主意。

  • 如果副作用导致无限循环(因为效果和状态之间的更新周期),它们可能会变得有问题。

  • useEffect() 是一个 React 钩子,应该用于包装副作用并以安全的方式执行它们。

  • useEffect() 接收一个效果函数和一个效果依赖项数组。

  • 效果函数在组件函数调用后直接执行(不是同时执行)。

  • 在效果内部使用的任何值、变量或函数都应该添加到依赖数组中。

  • 依赖数组异常是外部值(在组件函数外部定义的)、状态更新函数或在效果函数内部定义和使用的值。

  • 如果没有指定依赖数组,效果函数在每次组件函数调用后执行。

  • 如果指定了一个空的依赖数组,效果函数将在组件首次挂载时运行一次(即,当它第一次被创建时)。

  • 效果函数还可以返回可选的清理函数,这些函数在效果函数再次执行之前(以及组件从 DOM 中移除之前)被调用。

  • 效果函数不得返回承诺。

  • 对于函数依赖项,useCallback() 可以帮助减少效果执行的次数。

  • 对于对象依赖项,解构可以有助于减少效果执行的次数。

接下来是什么?

在构建应用程序时处理副作用是一个常见问题,因为大多数应用程序需要某种形式的副作用(例如,发送 HTTP 请求)才能正确工作。因此,副作用本身并不是问题,但如果处理不当,它们可能会引起问题(例如,无限循环)。

通过本章获得的知识,你知道如何使用 useEffect() 和相关关键概念高效地处理副作用。

许多副作用都是由于用户输入或交互触发的——例如,因为某个表单已提交。下一章将通过探索 React 的 表单操作 功能来回顾表单提交的概念。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/08-effects/exercises/questions-answers.md 找到的示例进行比较。:

  1. 你会如何定义副作用?

  2. 在 React 组件中,某些副作用可能会出现什么潜在问题?

  3. useEffect() 钩子是如何工作的?

  4. 应该将哪些值添加到 useEffect() 依赖项数组中?

  5. 效果函数可以返回哪种值?以及哪种类型的值不得返回?

应用你所学的知识

现在你已经了解了效果,你可以在你的 React 应用中添加更多令人兴奋的功能。在组件渲染时通过 HTTP 获取数据与在状态变化时访问浏览器存储一样简单。

在下一节中,你将找到一个活动,让你练习使用效果和useEffect()。像往常一样,你需要应用前面章节中介绍的一些概念(例如,处理状态)。

活动第 8.1 节:构建基本博客

在这个活动中,你必须向现有的 React 应用添加逻辑,以渲染从后端 Web API 获取的博客文章列表,并将新添加的博客文章提交到同一个 API。使用的后端 API 是jsonplaceholder.typicode.com/,这是一个模拟 API,实际上不会存储你发送给它的任何数据。它总是会返回相同的模拟数据,但它非常适合练习发送 HTTP 请求。

作为奖励,你还可以添加逻辑来在保存新博客文章的 HTTP 请求进行时更改提交按钮的文本。

使用你关于效果和浏览器端 HTTP 请求的知识来实现解决方案。

注意

你可以在以下位置找到这个活动的起始代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/activities/practice-1-start。下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这个例子中是activities/practice-1-start),以使用正确的代码快照。

对于这个活动,你需要知道如何通过 JavaScript 发送 HTTP 请求(例如,通过fetch()函数或使用第三方库)。如果你还没有这方面的知识,这个资源可以帮助你入门:packt.link/DJ6Hx

下载代码并在项目文件夹中运行npm install以安装所有必需的依赖项后,解决方案步骤如下:

  1. 向模拟 API 发送GET HTTP 请求,在App组件内部获取博客文章(当组件首次渲染时)。

  2. 在屏幕上显示获取到的模拟博客文章。

  3. 处理表单提交并向模拟后端 API 发送POST HTTP 请求(带有一些模拟数据)。

  4. 奖励:在请求进行时将按钮标题设置为Saving…(当请求完成时设置为Save)。

预期的结果应该是一个看起来像这样的用户界面:

img

图 8.9:最终用户界面

注意

你可以在以下位置找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/activities/practice-1

第九章:使用表单操作处理用户输入和表单

学习目标

到本章结束时,你将能够做到以下几点:

  • 描述 React 表单操作的目的

  • 构建和使用自定义表单操作来处理表单提交

  • 使用 useActionState() 钩子管理表单相关的状态

  • 通过 useFormStatus() 钩子渲染提交期间的挂起 UI

  • 使用 useOptimistic() 钩子执行乐观状态更新

  • 实现同步和异步操作

简介

第四章与事件和状态一起工作 中,你学习了如何在 React 应用程序中处理表单提交。虽然那里展示的方法绝对没有问题——实际上,这可能是你在大多数 React 项目中找到的方法——当在使用 React 19 或更高版本的项目中工作时,React 提供了一种处理表单提交的替代方法。React 19 引入了一个名为 actions(在本章中也将称为 表单 actions)的新功能,它可以简化处理表单提交、提取用户输入和提供验证反馈的过程。

本章将首先回顾 第四章 中介绍的表单提交,并探讨如何提取和验证用户输入。之后,本章将介绍表单操作,并解释如何使用该功能执行相同的步骤(处理提交、提取值和验证值)。你还将了解与操作相关的 React 钩子,如 useActionState()

处理不带操作的表单提交

如你在 第四章与事件和状态一起工作 中所学,在不使用操作的情况下,你可以通过在 <form> 元素的 onSubmit 属性上监听 submit 事件来处理表单提交。

考虑以下示例代码片段:

function App() {
  **function****handleSubmit****(****event****) {**
    **event.****preventDefault****();**
    **console****.****log****(****'Submitted!'****);**
  **}**
  return (
    <form **onSubmit****=****{handleSubmit}**>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

你可以在 GitHub 上找到完整的示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/examples/01-form-submission-without-actions

此代码显示了一个表单,并通过 handleSubmit() 函数处理其提交。此函数自动接收一个 event 对象,用于防止浏览器向托管网站的服务器发送 HTTP 请求的默认行为。

但是,当然,仅仅处理提交并不太有用。通常,你还需要提取并使用网站用户输入的值。

提取用户输入

当涉及到提取表单中输入的值时,你有几种选择:

  • 通过状态(即,使用 useState())跟踪值,如 第四章 中所述。

  • 第七章Portals 和 Refs 中所述,通过 useRef() 依赖 Refs。

  • 利用自动创建的 event 对象。

跟踪状态

您可以通过 useState() 管理的状态跟踪用户输入的值,如 第四章 中所述。例如,可以从上一个代码片段中跟踪和使用表单输入值,如下面的示例所示:

function App() {
  **const** **[email, setEmail] =** **useState****(****''****);**
  **const** **[password, setPassword] =** **useState****(****''****);**
  function handleSubmit(event) {
    event.preventDefault();
    **const** **credentials = { email, password };**
    **console****.****log****(credentials);**
  }
  **function****handleEmailChange****(****event****) {**
    **setEmail****(event.****target****.****value****);**
  **}**
  **function****handlePasswordChange****(****event****) {**
    **setPassword****(event.****target****.****value****);**
  **}**
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          id="email"
          **value****=****{email}**
          **onChange****=****{handleEmailChange}**
        />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          **value****=****{password}**
          **onChange****=****{handlePasswordChange}**
        />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

在这个更新的代码片段中,useState() 钩子用于管理 emailpassword 状态值。每当输入字段上的键入时,状态值都会更新。因此,当表单提交时,handleSubmit() 中可以获取到最新的输入值。

这种方法效果很好,并且将在许多 React 项目中找到。然而,使用状态来跟踪输入值有一些潜在的缺点:

  • 由于状态在每次键入时都会更新,并且组件函数会在某个状态值更改时重新执行,因此应用程序的性能可能会受到影响。

  • 当处理具有更多输入字段的更复杂表单时,可能需要管理许多不同的状态值。

您可以通过实现代码优化(将在 第十章React 的幕后场景和优化机会 中讨论)以及按照 第十一章处理复杂状态 中解释的方式将状态作为对象来管理,来绕过这些问题。

但您也可以考虑使用 Refs 来提取输入值。

依赖 Refs

如果您正在构建一个不打算设置输入值,而只想在表单提交时读取这些值的表单,使用 React 的 ref 功能(在 第七章 中介绍)可能是有意义的:

function App() {
  **const** **emailRef =** **useRef****(****null****);**
  **const** **passwordRef =** **useRef****(****null****);**
  function handleSubmit(event) {
    event.preventDefault();
    const credentials = {
      **email****: emailRef.****current****.****value****,**
      **password****: passwordRef.****current****.****value****,**
    };
    console.log(credentials);
  }
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" **ref****=****{emailRef}** />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" **ref****=****{passwordRef}** />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

在这个代码块中,useRef() 钩子用于创建两个与电子邮件和密码输入字段连接的 Refs。然后,这些 Refs 被用于在 handleSubmit() 中读取输入值。

当使用这种方法时,App 组件函数不再会在每次键入时执行。但您仍然需要编写通过 useRef() 创建 Refs 的代码,以及通过 ref 属性将它们连接到 JSX 元素的代码。

正因如此,您可以考虑依赖浏览器和自动创建的 event 对象(在 handleSubmit() 中接收),而不是使用 React 特性来提取这些输入值。

利用事件对象的优势

第四章处理事件和状态 中,您了解到当表单提交时,浏览器会尝试发送一个 HTTP 请求。这就是为什么在 handleSubmit() 中调用 event.preventDefault() 的原因——这个函数调用确保这个请求不会被发送。

然而,event 对象不仅仅用于防止默认行为。它还携带有关发生的 submit 事件的 重要信息。例如,您可以通过 event.currentTarget 获取底层表单 DOM 对象(即一个描述渲染的 <form> 元素、其配置及其当前状态的 JavaScript 对象)。

这非常有用,因为你可以将该表单 DOM 对象传递给浏览器提供的FormData构造函数。这个接口可以用来提取表单的输入字段值。

以下示例展示了该功能的具体用法:

function App() {
  function handleSubmit(event) {
    event.preventDefault();
    **const** **fd =** **new****FormData****(event.****currentTarget****);**
    const credentials = {
      email: **fd.****get****(****'email'****)**,
      password: **fd.****get****(****'password'****)**,
    };
    console.log(credentials);
  }
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" **name****=****"email"** />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" **name****=****"password"** />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

如上代码片段所示,表单数据对象fd是通过实例化FormData来构建的。如前所述,FormData接口由浏览器提供;因此,不需要从 React 或任何其他库中导入。

这个表单数据对象提供了各种方法来帮助访问表单字段值——例如,get()方法用于提取特定输入字段的值。为了确定你想要获取值的输入字段,get()方法需要一个输入字段名称作为参数。这就是为什么你必须在表单控件元素(即上面示例中的<input>元素)上设置name属性的原因。

这种方法的优势在于你不需要状态或 refs;因此,需要编写的代码略少。此外,由于几乎不使用任何 React 特性,这段代码不太可能因为未来的 React 变化而出现错误。

因此,这种方法可能看起来是处理表单提交的最佳方式。但这是真的吗?

哪个解决方案最好?

处理表单提交没有正确或错误的方式。除了个人偏好外,应用程序的要求也可能使一种方法优于其他方法。

例如,如果你的应用程序需要更改输入值,仅使用上面显示的FormData可能不是最佳选择,因为你将不得不编写命令式代码来更新输入字段。

这是一个问题,因为,如第一章中所述,React – 什么是和为什么?,你应该避免在你的 React 应用程序中编写这样的代码:

function clearInput() {
  document.getElementById('email').value = ''; // imperative code :(
} 

因此,如果你需要编辑输入值,使用状态(即useState())是首选:

const [email, setEmail] = useState('');
// ... other code
function clearInput() {
  setEmail('');
}
// simplified JSX code below
return (
  <form>
    <input 
      value={email} 
      onChange={event => setEmail(event.target.value)} />
  </form>
); 

即使你不需要更新任何输入字段,仅使用event对象和FormData可能也不够。

例如,如果你需要在handleSubmit()之外访问输入字段,则event对象不可用。结果,通过event对象与表单元素及其子元素交互是不可能的。在这种情况下,使用直接连接到单个输入元素的 refs 可能会简化问题。

以下示例使用 ref 来在函数内部调用<input>元素的内置focus()方法:

const emailRef = useRef(null);
function showForm() {
  // other code ...
  emailRef.current.focus(); 
}
// simplified JSX code below
return (
  <form>
    <input ref={emailRef} />
  </form>
); 

因此,正如你所见,没有一劳永逸的解决方案。所有这些 React 特性和处理表单提交的不同方式都存在合理的理由。你可以根据需要混合使用它们;因此,了解这些不同的选项是有帮助的。

尽管已经有几种处理表单提交的方法,但 React 19 又提供了一种新的方法。

使用动作处理表单提交

React 19 引入了(表单)动作的概念——这个概念实际上包含两种类型的动作:客户端动作服务器动作。这两种类型的动作都可以帮助处理表单提交,但为了本章节的目的,术语表单动作将用于描述客户端动作(即,在网站用户的浏览器中执行的表单动作)。服务器动作将在第十六章React 服务器组件与服务器动作中单独介绍。

表单动作的引入是为了简化处理表单提交和数据提取的过程——尤其是在构建带有服务器动作的全栈应用程序时。此外,当与一些新的 React Hooks 结合使用时,它们也非常有用,这些 Hooks 将在本章的后面讨论。

下面是如何通过客户端表单动作处理表单提交的示例:

function App() {
  function **submitAction****(****formData****)** {
    const credentials = {
      email: formData.get('email'),
      password: formData.get('password'),
    };
    console.log(credentials);
  }
  return (
    <form **action****=****{submitAction}**>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" name="password" />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

初看,这个例子可能看起来与使用 event 对象和 currentTarget 来推导 FormData 的代码片段非常相似。但如果你仔细观察,你会发现一些关键的区别:

  • handleSubmit 已更名为 submitAction,并接受一个名为 formData 的参数,而不是 event

  • <form> 元素不再有 onSubmit 属性——相反,现在它有一个指向 submitAction 函数的 action 属性。

函数名称更改是可选的;没有技术要求必须将此函数命名为 submitAction 或类似名称。但更改名称是有意义的,因为该函数不再直接处理 submit 事件。相反,它被用作新添加的 action 属性的值。

这正是 React 的表单动作功能的核心所在:将 <form> 元素的 action 属性设置为函数,当表单提交时,React 将代表你调用该函数。然而,与使用 onSubmit 属性不同,React 将阻止浏览器默认行为,并为你创建一个表单数据对象(并将该对象作为参数传递给动作函数)。

你不再需要手动执行这些步骤,因此,表单提交可以用最少的代码来处理。

当然,如果你需要手动设置和管理输入值,或者在某些时候需要与表单字段交互(例如,调用 focus()),你仍然需要与状态或 Refs 一起工作。但如果你只是尝试处理提交并获取输入值,使用表单动作功能将非常方便。

但表单动作之所以有用,不仅仅是因为它们可能需要更少的代码。

同步动作与异步动作

客户端表单动作可以是同步的,也可以是异步的,这意味着你还可以在动作函数中使用并返回一个 Promise。因此,你还可以使用 async / await 与该函数一起使用。

例如,如果你有一个旨在将一些任务数据存储在浏览器存储中的表单(通过localStorage API),你可以使用同步操作来完成(因为localStorage是一个同步 API):

function storeTaskAction(formData) {
  const task = {
    title: formData.get('title'),
    body: formData.get('body'),
    dueDate: formData.get('date')
  };
  localStorage.setItem('daily-task', JSON.stringify(task));
} 

这个操作函数是同步的,因为它不返回Promise或使用async / await。因此,正如你所看到的,迄今为止的所有表单操作示例都使用了同步操作。

但是,如果你正在开发一个需要通过 HTTP 请求将输入数据提交到后端的项目,你可以利用对异步代码的支持:

**async** function storeTodoAction(formData) {
  const todoTitle = formData.get('title');
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/todos', 
    {
      method: 'POST',
      body: JSON.stringify({ title: todoTitle }),
      headers: {
        'Content-type': 'application/json; charset=UTF-8',
      },
    }
  );
  const todo = await response.json();
  console.log(todo);
} 

在这个例子中,在函数前添加了async关键字。这会将函数转换为异步函数,该函数将返回一个Promise

React 表单操作功能提供的这种灵活性非常有用,因为它允许你在表单提交时执行各种操作。然而,重要的是要记住,目前所有这些操作都是在客户端执行的,即在网站访问者的浏览器中。服务器端操作将在第十六章中探讨。

底层:操作是过渡

在深入研究表单操作之前,简要地看看底层可能有所帮助。

这是因为,从技术上讲,React 中的操作(即客户端和服务器操作)被称为所谓的过渡。更准确地说,它们是异步过渡。

因此,问题是,React 中的过渡是什么?

在 React 应用中,过渡是一个概念,React 将确保一些可能耗时的状态更新不会阻塞 UI 更新。

表单操作可以被认为是(潜在的)耗时的状态更新;因此,在底层,React 以使其剩余 UI 保持响应性的方式处理它们。

因此,你在一个表单操作函数内部做出的任何状态更新调用都只会在该表单操作完成后由 React 处理。例如,以下代码可能会出乎意料地只更新 UI 三秒后:

import { useState } from 'react';
function App() {
  const [error, setError] = useState(null);
  async function storeTodoAction(formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      **setError****(****'Title is required.'****);** **// state update BEFORE delay**
    }
    **// 3s delay to simulate a slow process**
    **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));** 
    console.log('Submission done!');
  }
  return (
    <>
      <form action={storeTodoAction}>
        <p>
          <label htmlFor="title">Title</label>
          <input type="text" id="title" name="title" />
        </p>
        {error && <p className="errors">{error}</p>}
        <p className="actions">
          <button>Store Todo</button>
        </p>
      </form>
    </>
  );
} 

即使在延迟开始之前更新了error状态,React 也不会在表单操作整体完成之前重新执行组件函数(因此,更新 UI)。因此,错误信息只会在三秒后出现在屏幕上。

计算机屏幕截图  自动生成的描述

图 9.1:错误信息会延迟显示

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/examples/08-transition

基于表单提交管理状态

在处理表单提交时,您可能还希望在提交后更新 UI。对于异步操作,执行的操作可能需要几秒钟(当然,这取决于操作),您甚至可能希望在提交过程中更新 UI,显示一些挂起状态,同时提交的表单正在处理中。

React 旨在通过提供两个特定的表单操作相关的 Hooks 来帮助您满足这两个要求:useActionState()useFormStatus()

使用 useActionState()更新 UI 状态

React 提供了一个名为useActionState()的 Hook,它旨在与表单操作一起使用——无论您是处理客户端还是服务器操作。

这个 Hook 的目标是帮助您根据表单操作的结果更新应用程序的 UI。

例如,这可以帮助验证表单输入值,并在输入无效时显示错误消息。为了执行此任务,可以从react包中导入useActionState() Hook 并按如下方式使用:

**import** **{ useActionState }** **from****'react'****;**
function App() {
  async function storeTodoAction(**prevState**, formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      **return** **{**
        **error****:** **'Title must not be empty.'****,**
      **};**
    }
    // sending HTTP request etc...
    **return** **{**
      **error****:** **null****,**
    **};**
  }
  **const** **[formState, formAction] =** **useActionState****(storeTodoAction, {**
    **error****:** **null****,**
  **});**
  return (
    <form **action****=****{formAction}**>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      **{formState.error &&** **<****p****className****=****'errors'****>**
        **{formState.error}**
      **</****p****>****}**
      <p className="actions">
        <button>Store Todo</button>
      </p>
    </form>
  );
} 

当运行此示例应用程序时,如果存在无效输入,用户将看到验证错误消息。

计算机屏幕截图  自动生成的描述

图 9.2:提交空输入字段时显示错误消息

在这个代码示例中发生了一些事情:

  • 表单操作函数已被修改为接受两个参数而不是一个:前一个状态(prevState)和提交的数据(formData)。

  • 现在表单操作也返回一个值:一个包含名为error的键的对象,其中包含错误消息或null

  • useActionState() Hook 被导入并使用:它接收表单操作函数(storeTodoAction)作为第一个参数,以及一些初始状态对象(在这种情况下为{error: null})作为第二个参数。

  • useActionState() Hook 也返回一个值:一个数组,从中解构出两个元素(formStateformAction)。

  • 解构的formAction取代了storeTodoAction作为<form>action属性的值。

  • formState用于有条件地显示存储在formStateerror键中的值。

因此,如您所见,useActionState()是一个 Hook,它期望一个表单操作函数(同步或异步)作为第一个参数,以及一个初始状态作为第二个输入。这个初始状态需要有一些状态可用,如果表单尚未提交。在表单提交后,初始状态将被表单操作函数返回的新状态值所取代。

由于useActionState()的目的在于提供一些可以用来更新(部分)UI 的状态值,因此这个派生状态通过useActionState()返回的值暴露出来:

const [**formState**, formAction] = useActionState(storeTodoAction, {
    error: null,
  }
); 

返回的值是一个包含恰好三个元素的数组,顺序如下:

  1. 当前状态值,要么是初始状态(如果表单尚未提交),要么是表单操作函数返回的状态值。

  2. 一个更新的表单操作函数,本质上就是你的操作函数,由 React 包装。这是必要的,以便 React 能够访问你的操作函数返回的值(即新状态)。

  3. 一个布尔值,表示表单当前是否正在提交。这个第三个元素在之前的代码示例中没有使用,将在本章的“管理待处理 UI 状态”部分进行讨论。

因此,当使用useActionState()时,你不再将你的操作函数绑定到<form>元素的action属性上。相反,你使用由useActionState()创建的操作函数——即你使用包装你的操作函数的操作函数。

当使用useActionState()时,你还必须调整你的表单操作函数,因为 React 将使用两个参数调用你的函数,而不是一个:前一个状态和提交的表单数据:

async function storeTodoAction(**prevState**, formData) {
  // ...
} 

将前一个表单状态传递给你的操作函数,这样你就可以使用它来从它推导出你的新状态(与提交的表单数据结合使用)。在上面的示例中,这实际上并不是这样——前一个状态参数在那里没有被使用。尽管如此,它仍然必须作为参数接受。

然而,对表单操作函数所做的更改不止这些。相反,现在它还应该返回一个新的状态值,然后通过useActionState()(通过useActionState()返回的数组中的第一个元素)暴露给组件函数:

async function storeTodoAction(**prevState**, formData) {
  // ...
  return {
    error: 'Title must not be empty.'
  };
} 

该状态值可以是任何东西——一个字符串、一个数字、一个数组、一个对象等。在之前的代码示例中,它是一个具有名为error的键的对象,该键包含null或一个字符串错误消息。

每当表单提交时,因此表单操作函数被执行或返回一个值,useActionState()将触发 React 重新执行周围的组件函数。因此,更新后的状态变得可用。如果你觉得这与useState()相似,你是对的!useActionState()本质上就像useState(),但经过微调,可以从操作中推导状态。

因此,useActionState()肯定是一个重要的 Hook,尽管它实际上并不仅限于仅将你的操作函数返回的值暴露给组件函数。

使用useActionState()管理待处理 UI 状态

考虑一个场景,你有一个表单操作需要几秒钟才能完成其操作。例如,你可以有一个向慢速服务器或通过慢速互联网连接发送请求的操作。在这种情况下,你可能想在表单提交期间更新 UI,以向用户显示正在发生某些事情。

在下面的示例中,从表单操作内部调用了名为saveTodo()的函数。该函数故意延迟三秒钟来模拟缓慢的网络或服务器:

**async****function****saveTodo****(****todo****) {**
  **// dummy function that simulates a slow backend which manages todos**
  **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));** **// delay**
  **const** **response =** **await****fetch****(**
    **'https://jsonplaceholder.typicode.com/todos'****, {**
      **method****:** **'POST'****,**
      **body****:** **JSON****.****stringify****(todo),**
      **headers****: {**
        **'Content-type'****:** **'application/json; charset=UTF-8'****,**
      **},**
    **}**
  **);**
  **const** **fetchedTodo =** **await** **response.****json****();**
  **console****.****log****(fetchedTodo);**
**}**
function App() {
  async function storeTodoAction(prevState, formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      return {
        error: 'Title must not be empty.',
      };
    }
    **await****saveTodo****({** **title****: todoTitle });**
    return {
      error: null,
    };
  }
  // same code as before, hence omitted
} 

当使用表单操作,如本例所示,在处理表单提交时更新 UI 相对容易,因为useActionState()在其返回的数组中暴露了第三个元素:一个布尔值,指示操作是否正在执行。

因此,上述示例可以调整如下,以利用该布尔值:

function App() {
  async function storeTodoAction(prevState, formData) {
    // same code as before, hence omitted
  }
  const [formState, formAction, **pending**] = useActionState(
    storeTodoAction, 
    {
      error: null,
    }
  );
  return (
    <form action={formAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      {formState.error && 
        <p className="errors">{formState.error}</p>}
      <p className="actions">
        <button **disabled****=****{pending}**>
          **{pending ? 'Saving' : 'Store'} Todo**
        </button>
      </p>
    </form>
  );
} 

通过解构从数组中检索pending元素,然后使用它来禁用<button>并更新按钮文本。

因此,一旦表单提交,UI 就会发生变化——直到三秒后完成(在这种情况下,由于之前在saveTodo()函数中添加的延迟)。

计算机截图,描述自动生成

图 9.3:按钮在表单提交期间被禁用,并显示“保存待办”回退文本

使用useFormStatus()处理待处理 UI 状态

useActionState()返回的pending元素是一个简单直接的方法,但不是唯一的方法,在表单操作执行时更新 UI。

React 还提供了一个useFormStatus() Hook,它提供了有关当前表单提交状态的信息。更准确地说,这是react-dom包(而不是react!)导出的useFormStatus() Hook。

useActionState()不同,useFormStatus()必须在某个嵌套组件中调用,该组件被包裹在您感兴趣的提交状态的<form>元素中。

例如,您可以构建一个SubmitButton组件,如以下代码片段所示:

**import** **{ useFormStatus }** **from****'react-dom'****;**
import { saveTodo } from './todos.js';
function SubmitButton() {
  **const** **{ pending } =** **useFormStatus****();**
  return (
    <button disabled={pending}>
      **{pending ? 'Saving' : 'Store'} Todo**
    </button>
  );
}
function App() {
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    await saveTodo(todo);
  }
  return (
    <form action={storeTodoAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p className="actions">
        **<****SubmitButton** **/>**
      </p>
    </form>
  );
} 

在此示例中,将待办事项发送到后端服务器的实际代码被提取到一个单独的saveTodo()函数中,该函数存储在todo.js文件中。该函数包含与之前示例中相同的代码(即,它向 JSONPlaceholder 发送 HTTP 请求)。此外,移除了useActionState()以使代码更短、更简单。然而,您绝对可以在useFormStatus()useActionState()结合使用。例如,您可以使用useActionState()输出验证错误,同时在单独的嵌套组件中通过useFormStatus()管理提交按钮的disabled状态。

useFormStatus()react-dom导入,并在SubmitButton组件函数内部调用。它返回一个包含一个pending属性,该属性产生一个布尔值的对象。

如前所述,useFormStatus()不能用于渲染<form>元素的组件中。相反,它必须在嵌套组件中使用——这就是为什么<SubmitButton>组件被放置在<form>标签之间。

除了pending之外,useFormStatus()返回的对象还包含三个其他属性:

  • data:一个FormData对象,包含提交父<form>时使用的数据(即,与表单操作函数接收的数据相同)。

  • method:一个字符串值,可以是 'get''post' ,反映 <form> 元素的 method 属性设置的值。默认情况下,它是 'get'

  • action:指向与 <form> 相连的表单操作函数的指针。

如果你只关心待处理状态,当然你可以使用 useActionState()useFormStatus() 。使用 useActionState() 的优点是无需构建单独的嵌套组件。另一方面,如果你在页面上有多个表单,创建这样一个额外的组件并依赖于 useFormStatus() 可能是有用的——例如,你可以在所有这些表单中重用 <SubmitButton>

执行乐观更新

除了 useActionState()useFormStatus() ,React 还提供了一个与表单和表单操作相关的重要的最后一个 Hook:useOptimistic() Hook。

这个 Hook 背后的想法是,你可以用它来显示一些临时的、乐观的 UI,同时异步表单操作(可能需要几秒钟)正在进行中。“乐观”意味着你可以使用这个 Hook 来渲染通常只有在表单提交完成后才存在的 UI(例如,已经包括新提交的任务的待办事项列表)。

以下示例代码使用 <form> 和表单操作管理待办事项列表,但没有使用 useOptimistic()

import { useFormStatus } from 'react-dom';
import { useState } from 'react';
let storedTodos = [];
export async function saveTodo(todo) {
  // dummy function that simulates a slow backend which manages todos
  **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));**
  const newTodo = { ...todo, id: new Date().getTime() };
  storedTodos = [...storedTodos, newTodo];
  return storedTodos;
}
function SubmitButton() {
  // same as before, didn't change, hence omitted here
}
function App() {
  **const** **[todos, setTodos] =** **useState****(storedTodos);**
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    const updatedTodos = await saveTodo(todo); // takes 3s
    **setTodos****(updatedTodos);**
  }
  return (
    <>
      <form action={storeTodoAction}>
        <p>
          <label htmlFor="title">Title</label>
          <input type="text" id="title" name="title" />
        </p>
        <p className="actions">
          <SubmitButton />
       </p>
      </form>
      <div id="todos">
        <h2>My Todos</h2>
        **{todos.length === 0 &&** **<****p****>****No todos found.****</****p****>****}**
        **{todos.length > 0 && (**
          **<****ul****>**
            **{todos.map((todo) => (**
              **<****li****key****=****{todo.id}****>****{todo.title}****</****li****>**
            **))}**
          **</****ul****>**
        **)}**
      </div>
    </>
  );
} 

在这个例子中,由于 saveTodo() 函数再次内置了三秒钟的故意延迟,网站用户会看到过时的待办事项列表,直到表单提交过程完成。

img

图 9.4:没有乐观更新时,UI 更新被延迟

因此,可以通过引入 useOptimistic() Hook 来提高用户体验。

这个 Hook 需要两个参数,并返回一个包含恰好两个元素的数组:

const [optimisticState, addOptimistic] = useOptimistic(
  state, updateFunction
); 
  • state(第一个参数)是初始时应处于活动状态或没有待处理的表单操作时的组件状态。

  • updateFunction(第二个参数)是你定义的函数,它控制状态应该如何乐观地更新。

  • optimisticState 是在表单操作执行期间将处于活动状态的乐观更新状态。

  • addOptimistic 触发 updateFunction 并允许你向该函数传递一个值。

应用到上述示例中,useOptimistic() 可以用来管理一个替代的、乐观更新的待办事项数组,只要表单操作正在执行,这个数组就会是活动的。之后,常规状态将再次变得活跃(并相应地更新 UI):

**import** **{ useOptimistic }** **from****'react'****;**
import { saveTodo, getTodos } from './todos.js';
import { useState } from 'react';
function SubmitButton() {
  // same code as before, hence omitted
}
function App() {
  const loadedTodos = getTodos(); // initial fetch
  const [todos, setTodos] = useState(loadedTodos);
  **const** **[optimisticTodos, addOptimisticTodo] =** **useOptimistic****(**
    **todos,**
    **(****currentState, optimisticValue****) =>** **{**
      **return** **[...currentState, { ...optimisticValue,** **id****:** **'temp'** **}];**
    **}**
  **);**
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    **addOptimisticTodo****(todo);**
    const updatedTodos = await saveTodo(todo);
    setTodos(updatedTodos);
  }
  return (
    <form action={storeTodoAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p className="actions">
        <SubmitButton />
      </p>
    </form>
    <div id="todos">
      <h2>My Todos</h2>
      {**optimisticTodos**.length === 0 && <p>No todos found.</p>}
      {**optimisticTodos**.length > 0 && (
        <ul>
          {**optimisticTodos**.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
} 

如此例所示,optimisticTodos 状态现在被用于 JSX 代码中。该常量中存储的值要么是正常的 todos 状态(由 useState() 管理),如果 storeTodoAction() 表单操作没有执行,要么是传递给 useOptimistic() 的函数生成的数组(作为第二个参数)。

img

图 9.5:使用 useOptimistic() 后,提交后 UI 立即更新

使用 useOptimistic() 钩子可以帮助构建一个出色的用户体验,即使某些慢速进程可能仍在后台运行,您的应用程序也能提供即时反馈。由于一旦表单提交完成,临时的乐观状态总会被常规状态(即 todos 状态)所取代,因此也不会有显示不正确用户界面的风险。如果操作失败,React 将会自动将暂时不正确的用户界面替换为正确的界面,当它回退到使用常规状态时。

摘要和关键要点

  • 表单提交可以通过手动监听 submit 事件通过 onSubmit 属性来处理。

  • 或者,可以使用表单操作——即绑定到 <form> 元素的 action 属性的函数。

  • 当手动处理表单提交(通过 onSubmit)时,您可以使用状态(useState())、Refs(useRef())或从 event.currentTarget 创建一个 FormData 对象来提取表单字段值。

  • 当使用表单操作时,一个包含表单字段输入值的表单数据对象会自动作为参数传递给操作函数。

  • useActionState() 钩子可以用来管理与表单相关的状态(例如,验证错误消息)。

  • useActionState() 也提供了一个待定布尔值,可以在表单操作处理时用于更新用户界面。

  • 在嵌套组件(嵌套在 <form> 内)中,可以调用 useFormStatus() 钩子来获取和使用有关父表单提交状态的信息。

  • 为了在处理慢速后台进程(例如,慢速 HTTP 请求)时提供快速的用户界面更新,useOptimistic() 钩子可能有所帮助。

接下来是什么?

处理表单和处理用户输入是大多数网络应用程序中一个非常常见的任务。当然,React 应用程序也不例外。

正因如此,React 提供了广泛的方法和可能的模式,您可以使用它们来处理表单提交和提取用户输入。本章探讨了并比较了两种主要的方法:使用 onSubmit 属性或依赖表单操作(仅从 React 19 开始可用)。

正如本章中解释和展示的那样,这两种方法都是有效的,并且各有用例。个人偏好以及应用程序需求都很重要,并将影响您的决策。

到这本书的这一部分,您已经了解了构建功能丰富的网络应用程序所需的所有关键 React 概念。下一章将深入 React 的幕后,探索它是如何内部工作的。您还将了解一些常见的优化技术,这些技术可以使您的应用程序性能更佳。

测试你的知识!

通过回答以下问题来测试您对本章所涵盖概念的了解。然后,您可以比较您的答案与可在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/09-form-actions/exercises/questions-answers.md 找到的示例:

  1. “表单操作”是什么?

  2. 如何在表单操作内部访问用户输入?

  3. useActionState() 钩子的目的是什么?它是如何使用的?

  4. useFormStatus() 钩子的目的是什么?它是如何使用的?

  5. useActionState()useFormStatus() 之间的区别是什么?

  6. useOptimistic() 钩子的目的是什么?它是如何使用的?

应用所学知识

在您的 React 工具包中添加表单操作,您又有另一种强大的处理表单提交和提取用户输入的方法。

在以下部分,您将找到一个活动,允许您练习使用表单操作和 React 提供的与表单相关的钩子。一如既往,您还需要应用之前章节中介绍的一些概念(例如处理状态或输出列表)。

活动九.1:管理反馈表单

在这个活动中,您的任务是构建一个现有的、基本的反馈表单应用程序,并使用表单操作处理表单提交。作为此活动的一部分,您应该验证提交的标题和反馈文本,并在提交空值时显示错误消息。您还应该乐观地更新提交的反馈项列表,并在表单操作进行时禁用提交按钮。

注意

您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/activities/practice-1-start 找到此活动的起始代码。在下载此代码时,您将始终下载整个存储库。请确保导航到包含起始代码的子文件夹(在这种情况下为 activities/practice-1-start)以使用正确的代码快照。

下载代码后,在项目文件夹中运行 npm install 以安装所有必需的依赖项,解决方案步骤如下:

  1. 将现有的 onSubmit 处理器函数替换为表单操作—之后清理并删除不再需要的任何代码。

  2. 在表单操作处理过程中禁用表单提交按钮。

  3. 使用 useActionState() 钩子验证用户输入并输出任何错误消息。

  4. 通过利用 useOptimistic() 钩子乐观地更新提交的反馈项列表。

预期结果应类似于以下截图:

img

图 9.6:在表单提交期间,按钮被禁用,但提交的项目立即显示

img

图 9.7:当提交无效值时,会显示适当的错误信息

注意

您可以在此处找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/activities/practice-1 .

第十章:React 背后的场景和优化机会

学习目标

在本章结束时,你将能够做到以下事项:

  • 通过useMemo()useCallback()钩子避免不必要的代码执行

  • 通过 React 的lazy()函数按需加载可选代码,仅在需要时

  • 使用 React 的开发者工具分析和优化你的应用

  • 探索 React 编译器以实现自动性能提升

简介

使用到目前为止所涵盖的所有功能,你可以构建非平凡的 React 应用,因此可以构建高度交互和响应式的 UI。

本章虽然介绍了一些新的函数和概念,但不会提供让你能够构建更高级 Web 应用的工具。你将不会学习到像状态或属性这样的突破性、关键概念(尽管你将在后面的章节中学习到更高级的概念)。

相反,本章让你能够深入了解 React 背后的场景。你将学习 React 如何计算所需的 DOM 更新,以及它是如何确保这些更新不会以不可接受的方式影响性能。你还将了解 React 使用的其他一些优化技术——所有这些技术都是为了确保你的 React 应用尽可能流畅地运行。

除了这个幕后场景之外,你还将了解各种内置函数和概念,这些函数和概念可以用来进一步优化应用性能。本章不仅将介绍这些概念,还将解释为什么它们存在,如何使用它们,以及何时使用哪个功能。

重新审视组件评估和更新

在探索 React 的内部工作原理之前,简要回顾 React 执行组件函数的逻辑是有意义的。

组件函数会在某些状态(通过useState()管理)改变或其父组件函数再次执行时执行。后者发生是因为,如果调用父组件函数,其整个 JSX 代码(指向子组件函数)将被重新评估。因此,在该 JSX 代码中引用的任何组件函数也将再次被调用。

考虑以下组件结构:

function NestedChild() {
  console.log('<NestedChild /> is called.');
  return (
    <p id="nested-child">
      A component, deeply nested into the component tree.
    </p>
  );
}
function Child() {
  console.log('<Child /> is called.');
  return (
    <div id="child">
      <p>
        A component, rendered inside another component, 
        containing yet another component.
      </p>
      <NestedChild />
    </div>
  );
}
function Parent() {
  console.log('<Parent /> is called.');
  const [counter, setCounter] = useState(0);
  function handleIncCounter() {
    setCounter((prevCounter) => prevCounter + 1);
  }
  return (
    <div id="parent">
      <p>
        A component, nested into App, 
        containing another component (Child).
      </p>
      <p>Counter: {counter}</p>
      <button onClick={handleIncCounter}>Increment</button>
      <Child />
    </div>
  );
} 

在这个示例结构中,Parent组件渲染一个包含两个段落、一个按钮和另一个组件的<div>Child组件。该组件随后输出一个包含一个段落和另一个组件的<div>NestedChild组件(然后只输出一个段落)。

Parent组件还管理一些状态(一个虚拟计数器),每当按钮被点击时,该状态就会改变。所有三个组件通过console.log()打印一条消息,只是为了方便在 React 调用每个组件时识别。

以下截图显示了这些组件在按钮点击后的动作:

计算机的截图 自动生成描述

图 10.1:每个组件函数都会执行

在这个屏幕截图中,你可以不仅看到组件是如何嵌套在一起的,还可以看到当点击“增加”按钮时,React 是如何调用所有组件的。即使“子”和“嵌套子”组件没有管理或使用任何状态,它们也会被调用。但既然它们是“父”组件(Child)或后代组件(NestedChild),而“父”组件确实接收到了状态变化,因此嵌套的组件函数也会被调用。

理解组件函数执行流程的重要性在于,这个流程意味着任何组件函数的调用也会影响其子组件。它还展示了 React 如何频繁地调用组件函数,以及单个状态变化可能影响多少组件函数。

因此,有一个重要的问题需要回答:当调用一个或多个组件函数时,实际的页面 DOM(即浏览器中加载和渲染的网站)会发生什么?DOM 是否被重新创建?渲染的 UI 是否被更新?

组件函数被调用时会发生什么

每当组件函数执行时,React 都会评估渲染的 UI(即加载页面的 DOM)是否需要更新。

这很重要:React 会评估是否需要更新。它不会自动强制更新!

内部,React 不会用组件(或多个组件)返回的 JSX 代码替换页面 DOM。

这是可以做到的,但这意味着每次组件函数执行都会导致某种形式的 DOM 操作——即使只是用新的、类似的内容替换旧的 DOM 内容。在上面的示例中,每次执行那些组件函数时,都会使用“子”和“嵌套子”JSX 代码来替换当前渲染的 DOM。

正如你在上面的示例中看到的,那些组件函数执行得相当频繁。但返回的 JSX 代码始终相同,因为它是静态的。它不包含任何动态值(例如状态或属性)。

如果实际的页面 DOM 被替换为返回 JSX 代码所表示的 DOM 元素,视觉结果将始终相同。但幕后仍然会有一些 DOM 操作。这是一个问题,因为操作 DOM 是一项性能密集型任务——尤其是在高频操作时。因此,只有在需要时才应该进行 DOM 的删除、添加或更新——而不是不必要的操作。

由于这个原因,React 不会因为组件函数的执行而丢弃当前的 DOM 并替换成新的 DOM(由 JSX 代码表示)。相反,React 首先检查是否需要更新。如果需要,只有需要更改的 DOM 部分才会被替换或更新。

为了确定是否需要更新(以及在哪里),React 使用了一个称为虚拟 DOM的概念。

虚拟 DOM 与真实 DOM

为了确定是否(以及在哪里)可能需要 DOM 更新,React(特别是react-dom包)将当前 DOM 结构与由执行组件函数返回的 JSX 代码隐含的结构进行比较。如果有差异,DOM 将相应更新;否则,保持不变。

然而,正如操作 DOM 相对性能开销较大一样,读取 DOM 也是如此。即使不更改 DOM 中的任何内容,访问它、遍历 DOM 元素并从中推导结构也是您通常希望减少到最小的事情。

如果多个组件函数被执行,并且每个函数都触发一个过程,其中渲染的 DOM 元素被读取并与由调用组件函数隐含的 JSX 结构进行比较,那么在非常短的时间内,渲染的 DOM 将被多次执行读取操作。

对于由数十、数百甚至数千个组件组成的较大 React 应用,在单个秒内可能发生数十次组件函数执行的可能性非常高。如果这导致相同数量的 DOM 读取操作,那么 web 应用对用户来说可能会感觉缓慢或滞后。

这就是为什么 React 不使用真实 DOM 来确定是否需要任何 UI 更新。相反,它内部构建并管理一个虚拟 DOM——这是在浏览器中渲染的 DOM 的内存表示。虚拟 DOM 不是浏览器功能,而是 React 功能。您可以将它想象为一个深度嵌套的 JavaScript 对象,它反映了您的 web 应用的组件,包括所有内置的 HTML 组件,如<div><p>等。(即最终应在页面上显示的实际 HTML 元素)。

计算机程序图  自动生成描述

图 10.2:React 管理预期元素结构的虚拟表示

在上面的图中,您可以看到预期的元素结构(换句话说,预期的最终 DOM)实际上存储为一个 JavaScript 对象(或一个包含对象列表的数组)。这是虚拟 DOM,由 React 管理并用于识别所需的 DOM 更新。

注意

请注意,虚拟 DOM 的实际结构比图中显示的结构更复杂。上面的图表旨在让您了解虚拟 DOM 是什么以及它可能看起来像什么。它不是 React 管理的 JavaScript 数据结构的精确技术表示。

React 管理这个虚拟 DOM,因为将这个虚拟 DOM 与预期的 UI 状态进行比较,比访问真实 DOM 要少得多,性能开销更小。

每当调用组件函数时,React 会将返回的 JSX 代码与虚拟 DOM 中存储的相关虚拟 DOM 节点进行比较。如果检测到差异,React 将确定需要更新的 DOM 更改。一旦推导出所需的调整,这些更改将应用于虚拟和真实 DOM。这确保了真实 DOM 反映了预期的 UI 状态,而无需不断访问或更新它。

计算机程序图  自动生成的描述

图 10.3:React 通过虚拟 DOM 检测所需的更新

在上面的图中,你可以看到 React 如何首先使用虚拟 DOM 比较当前的 DOM 和预期的结构,然后再去操作真实的 DOM。

作为 React 开发者,你不需要主动与虚拟 DOM 交互。技术上,你甚至不需要知道它的存在以及 React 在内部使用它。但了解你正在使用的工具(在这种情况下是 React)总是有帮助的。了解 React 为你做了各种性能优化,并且你可以在许多其他使你的开发者生活(希望)更轻松的功能之上获得这些优化,这是很好的。

状态批处理

由于 React 使用虚拟 DOM 的概念,频繁的组件函数执行并不是一个大问题。但当然,即使比较是在虚拟层面上进行的,仍然有一些内部代码必须执行。即使在虚拟 DOM 的情况下,如果必须进行大量的不必要的(同时相当复杂的)虚拟 DOM 比较,性能可能会下降。

在执行多个连续状态更新时,进行不必要的比较的一个场景是。由于每个状态更新都会导致组件函数再次执行(以及所有潜在的嵌套组件),一起执行(例如,在同一个事件处理函数中)的多个状态更新将导致多次组件函数调用。

考虑这个例子:

function App() {
  const [counter, setCounter] = useState(0);
  const [showCounter, setShowCounter] = useState(false);
  function handleIncCounter() {
    setCounter((prevCounter) => prevCounter + 1);
    setShowCounter(true);
  }
  return (
    <>
      <p>Click to increment + show or hide the counter</p>
      <button onClick={handleIncCounter}>Increment</button>
      {showCounter && <p>Counter: {counter}</p>}
    </>
  );
} 

此组件包含两个状态值:countershowCounter。当按钮被点击时,计数器增加1。此外,showCounter被设置为true。因此,第一次点击按钮时,countershowCounter状态都会发生变化(因为showCounter最初为false)。

由于有两个状态值被更改,预期 React 会调用App组件函数两次——因为每次状态更新都会导致连接的组件函数再次被调用。

然而,如果你在App组件函数中添加一个console.log()语句(用于跟踪其执行频率),你会看到它只被调用一次,当点击Increment按钮时:

计算机截图  自动生成的描述

图 10.4:只显示一条控制台日志消息

注意

如果你看到两条日志消息而不是一条,请确保你没有使用 React 的“严格模式”。在开发期间运行严格模式时,React 会比通常情况下更频繁地执行组件函数。

如果需要,你可以通过从你的 main.jsx 文件中移除 <React.StrictMode> 组件来禁用严格模式。你将在本章的末尾了解更多关于 React 严格模式的内容。

这种行为被称为 状态批处理。当你的代码中的同一位置(例如,在同一个事件处理函数内部)发起多个状态更新时,React 会执行状态批处理。

这是一个内置的功能,确保你的组件函数不会被调用得比需要的更频繁。这防止了不必要的虚拟 DOM 比较。

状态批处理是一个非常有用的机制。但是,它无法防止另一种不必要的组件评估:当父组件函数被调用时执行的子组件函数。

避免不必要的子组件评估

每当组件函数被调用(例如,因为其状态改变),任何嵌套的组件函数也将被调用。请参阅本章的第一部分以获取更多详细信息。

正如你在本章第一部分的例子中所看到的,通常情况下,那些嵌套的组件实际上并不需要再次评估。它们可能不依赖于父组件中改变的状态值。它们甚至可能不依赖于父组件的任何值。

这里有一个例子,其中父组件函数包含一些子组件不使用的状态:

function Error({ message }) {
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
}
function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState();
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmit(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Email must end with .com.');
    }
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.control}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={enteredEmail}
          onChange={handleUpdateEmail}
        />
      </div>
      <Error message={errorMessage} />
      <button>Sign Up</button>
    </form>
  );
} 

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/03-memo

在这个例子中,Error 组件依赖于 message 属性,该属性设置为 Form 组件中 errorMessage 状态存储的值。然而,Form 组件还管理一个 enteredEmail 状态,该状态没有被 Error 组件使用(未通过属性接收)。因此,enteredEmail 状态的更改将导致 Error 组件再次执行,尽管该组件不需要那个值。

你可以通过向该组件函数添加 console.log() 语句来跟踪不必要的 Error 组件函数调用:

function Error({ message }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
} 

计算机屏幕截图  描述自动生成

图 10.5:Error 组件函数在输入字段上的每次按键都会执行

在前面的屏幕截图中,你可以看到 Error 组件函数在输入字段上的每次按键都会执行(即,每次 enteredEmail 状态改变时执行一次)。

这与之前你所学的相符,但这也是不必要的。Error组件确实依赖于errorMessage状态,并且每当该状态发生变化时,都应该重新评估该组件,但显然不需要因为enteredEmail状态值更新而执行Error组件函数。

正因如此,React 提供了一个内置的函数,你可以用它来控制(并防止)这种行为:memo()函数。

memo是从react导入的,并像这样使用:

**import** **{ memo }** **from****'react'****;**
import classes from './Error.module.css';
function Error({ message }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
}
export default **memo****(****Error****);** 

你用memo()包裹应该避免不必要的、由父组件触发的重新评估的组件函数。这会导致 React 检查组件的 props 是否与上一次调用组件函数时有所不同。如果 prop 值相等,React 知道组件函数不需要再次执行。

通过添加memo(),可以避免不必要的组件函数调用,如下所示:

计算机截图  自动生成的描述

图 10.6:控制台没有出现日志消息

如图中所示,没有消息打印到控制台。这证明了避免了不必要的组件执行(记住:在添加memo()之前,许多消息都打印到了控制台)。

memo()还接受一个可选的第二个参数,可以用来添加自己的逻辑以确定 prop 值是否已更改。如果你处理的是更复杂的 prop 值(例如,对象或数组),并且可能需要自定义比较逻辑,这可能会很有用,如下面的示例所示:

memo(SomeComponent, function(prevProps, nextProps) {
  return prevProps.user.firstName !== nextProps.user.firstName;
}); 

传递给memo()的(可选)第二个参数必须是一个函数,该函数自动接收前一个 props 对象和下一个 props 对象。然后,该函数必须返回true,如果组件(例如,本例中的SomeComponent)应该重新评估,如果不应重新评估则返回false

通常,第二个参数是不需要的,因为memo()的默认行为(比较所有 props 的不等性)正是你所需要的。但如果需要更多的定制或控制,memo()允许你添加自己的逻辑。

在你的工具箱中有memo()后,你会倾向于用memo()包裹每一个 React 组件函数。为什么不这样做呢?毕竟,它避免了不必要的组件函数执行。

你当然可以在所有组件上使用它——但这并不一定有帮助,因为使用memo()避免不必要的组件重新评估是有代价的:比较 props(旧值与新值)也需要运行一些代码。它不是“免费的”。但这并不是一个巨大的成本。在许多(或所有)组件上使用memo()可能不会显著减慢你的应用程序。但如果你有需要大量重新评估的组件,这仍然是多余的。对于接收大量变化的 props 的组件使用memo()没有任何实际作用。

因此,如果你有相对简单的属性(即没有需要手动与自定义比较函数比较的深度嵌套对象的属性)并且大多数父组件的状态变化不会影响这些子组件的属性,那么 memo() 就非常有意义。即使在那些情况下,如果你有一个相对简单的组件函数(即没有复杂逻辑的函数),使用 memo() 仍然可能不会带来任何可衡量的好处。

上面的示例代码(Error 组件)是一个很好的例子:从理论上讲,在这里使用 memo() 是有意义的。父组件中的大多数状态变化不会影响 Error,而且属性比较将会非常简单,因为它只涉及一个属性(message 属性,它包含一个字符串)需要比较。但尽管如此,使用 memo() 包装 Error 很可能并不值得。Error 是一个非常基础的组件,其中没有任何复杂的逻辑。如果组件函数频繁调用,这根本无关紧要。因此,在这个位置使用 memo() 完全是可以接受的——同样,不使用它也是可以的。

另一个非常适合使用 memo() 的地方是位于组件树顶部(或组件树中深度嵌套的组件分支)的组件。如果你能够通过 memo() 避免执行该组件的不必要调用,那么你也会隐式地避免执行该组件下所有嵌套组件的不必要调用。这一点在下图中得到了说明:

img

图 10.7:在组件树分支的起始处使用 memo()

在前面的图中,memo() 被用于 Shop 组件,它有多个嵌套的子组件。没有 memo() 的情况下,每当 Shop 组件函数被调用时,ProductsProdItemCart 等也会被执行。有了 memo(),假设它能够避免一些 Shop 组件函数的不必要调用,所有这些子组件就不再需要评估。

避免昂贵的计算

memo() 函数可以帮助避免不必要的组件函数执行。正如前文所述,如果组件函数执行了大量工作(例如,对长列表进行排序),这一点尤其有价值。

但作为一个 React 开发者,你也会遇到一些情况,其中你有一个需要因为某些属性值变化而再次执行的工作密集型组件。在这种情况下,使用 memo() 无法阻止组件函数再次执行。然而,变化的属性可能并不需要用于组件中作为性能密集型任务执行的部分。

考虑以下示例:

function sortItems(items) {
  console.log('Sorting');
  return items.sort(function (a, b) {
    if (a.id > b.id) {
      return 1;
    } else if (a.id < b.id) {
      return -1;
    }
    return 0;
  });
}
function List({ items, maxNumber }) {
  const sortedItems = sortItems(items);
  const listItems = sortedItems.slice(0, maxNumber);
  return (
    <ul>
      {listItems.map((item) => (
        <li key={item.id}>
          {item.title} (ID: {item.id})
        </li>
      ))}
    </ul>
  );
}
export default List; 

List组件接收两个 prop 值:itemsmaxNumber。然后它调用sortItems()id对项目进行排序。之后,排序后的列表限制为一定数量的项目(maxNumber)。最后一步,通过 JSX 代码中的map()将排序和缩短后的列表渲染到屏幕上。

注意

一个完整的示例应用程序可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/04-usememo

根据传递给List组件的项目数量,排序可能需要相当长的时间(对于非常长的列表,甚至可能长达几秒钟)。这绝对不是你希望不必要或过于频繁执行的操作。每当items发生变化时,都需要对列表进行排序,但如果maxNumber发生变化,则不应进行排序——因为这不影响列表中的项目(即,不影响顺序)。但是,根据上面共享的代码片段,sortItems()将在两个 prop 值中的任何一个发生变化时执行,无论它是items还是maxNumber

因此,当运行应用程序并更改显示的项目数量时,你可以看到多个"Sorting"日志消息——这意味着每次更改项目数量时都会执行sortItems()

计算机屏幕截图  自动生成描述

图 10.8:控制台中出现多个“Sorting”日志消息

memo()函数在这里无济于事,因为List组件函数应该在(并且将会)itemsmaxNumber发生变化时执行。memo()不能帮助控制组件函数内部的局部代码执行。

为了实现这一点,你可以使用 React 提供的另一个功能:useMemo() Hook。

useMemo()可以用来包装计算密集型操作。为了正确工作,你还必须定义一个列表,其中包含应导致操作再次执行依赖项。在某种程度上,它与useEffect()(它也包装操作并定义依赖项列表)类似,但关键区别在于useMemo()与组件函数中的其余代码同时运行,而useEffect()在组件函数执行完成后执行包装逻辑。不应使用useEffect()来优化计算密集型任务,而应用于副作用。

另一方面,useMemo()存在是为了控制性能密集型任务的执行。应用于上述示例,代码可以调整如下:

import { useMemo } from 'react';
function List({ items, maxNumber }) {
  const sortedItems = useMemo(
    function() {
      console.log('Sorting');
      return items.sort(function (a, b) {
        if (a.id > b.id) {
          return 1;
        } else if (a.id < b.id) {
          return -1;
        }
        return 0;
      });
    },
    [items]
  );
  const listItems = sortedItems.slice(0, maxNumber);
  return (
    <ul>
      {listItems.map((item) => (
        <li key={item.id}>
          {item.title} (ID: {item.id})
        </li>
      ))}
    </ul>
  );
}
export default List; 

useMemo() 包装了一个匿名函数(之前作为命名函数 sortItems 存在的函数),其中包含整个排序代码。传递给 useMemo() 的第二个参数是函数应再次执行的依赖项数组(当依赖项值发生变化时)。在这种情况下,items 是包装函数的唯一依赖项,因此该值被添加到数组中。

使用 useMemo() 如此,排序逻辑仅在项目发生变化时执行,而不是在 maxNumber(或任何其他内容)发生变化时执行。因此,你会在开发者工具控制台中只看到一次输出“Sorting”:

计算机屏幕截图  自动生成描述

图 10.9:控制台只有一个“排序”输出

useMemo() 在控制组件函数内部的代码执行方面非常有用。它可以作为 memo()(控制整体组件函数执行)的一个很好的补充。但是,就像 memo() 一样,你不应该开始用 useMemo() 包装所有的逻辑。仅在使用非常性能密集的计算时使用它,因为检查依赖项变化以及存储和检索过去计算结果(useMemo() 内部执行的操作)也会带来性能成本。

利用 useCallback()

在前面的章节中,你学习了关于 useCallback() 的内容。就像 useMemo() 可以用于“昂贵”的计算一样,useCallback() 可以用来防止不必要的函数重新创建。在本章的上下文中,useCallback() 可能很有帮助,因为与 memo()useMemo() 结合使用时,它可以帮助你避免不必要的代码执行。它可以帮助你处理函数作为属性传递的情况(即你可能使用 memo() 的情况)或作为某些“昂贵”计算中的依赖项(即可能通过 useMemo() 解决)。

这里有一个例子,说明 useCallback() 可以与 memo() 结合使用,以防止不必要的组件函数执行:

import { memo } from 'react';
import classes from './Error.module.css';
function Error({ message, onClearError }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return (
    <div className={classes.error}>
      <p>{message}</p>
      <button className={classes.errorBtn} onClick={onClearError}>X</button>
    </div>
  );
}
export default memo(Error); 

Error 组件被 memo() 函数包装,因此只有在接收到的属性值之一发生变化时才会执行。

Error 组件被另一个组件,即 Form 组件,这样使用:

function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState();
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmit(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Email must end with .com.');
    }
  }
  function handleClearError() {
    setErrorMessage(null);
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.control}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={enteredEmail}
          onChange={handleUpdateEmail}
        />
      </div>
      <Error message={errorMessage} onClearError={handleClearError} />
      <button>Sign Up</button>
    </form>
  );
} 

在这个组件中,Error 组件接收对 handleClearError 函数的指针(作为 onClearError 属性的值)。你可能还记得本章早期(在 避免不必要的子组件评估 部分中)的一个非常类似的例子。在那里,memo() 被用来确保当 enteredEmail 发生变化时,Error 组件函数不会被调用(因为它的值在 Error 组件函数中根本未使用)。

现在,随着调整后的示例和将 handleClearError 函数指针传递给 Errormemo() 很遗憾不再阻止组件函数的执行了。为什么?因为在 JavaScript 中,函数是对象,而 handleClearError 函数在每次 Form 组件函数执行时都会被重新创建(这发生在每次状态变化时,包括 enteredEmail 状态的变化)。

由于每次状态变化都会创建一个新的函数对象,因此 handleClearError 在技术上对于 Form 组件的每次执行都是一个不同的值。因此,每当 Form 组件函数被调用时,Error 组件都会接收到一个新的 onClearError 属性值。对于 memo() 来说,旧的和新旧的 handleClearError 函数对象是不同的,因此它不会阻止 Error 组件函数再次运行。

这正是 useCallback() 可以帮助的地方:

const handleClearError = useCallback(() => {
  setErrorMessage(null);
}, []); 

通过使用 useCallback() 包装 handleClearError,可以防止函数的重新创建,因此不会将新的函数对象传递给 Error 组件。因此,memo() 能够检测旧的和新的 onClearError 属性值之间的相等性,并再次防止不必要的函数组件执行。

同样,useCallback() 可以与 useMemo() 结合使用。如果 useMemo() 包装的计算密集型操作使用函数作为依赖项,你可以使用 useCallback() 来确保这个依赖函数不会被不必要地重新创建。

使用 React 编译器

考虑和使用 memo()useMemo()useCallback() 来防止不必要的组件重新评估可能是一项繁琐的工作。尽管性能优化很重要,但作为一名 React 开发者,你通常希望专注于构建出色的 UI 并在其中实现有用的功能。

正是因此,React 团队开发了一个旨在为你优化代码的编译器——一个可以添加到 React 项目中的独立工具,该工具将自动使用 memo() 包装你的组件,在需要时使用 useMemo(),并使用 useCallback() 包装函数。

因此,当使用此编译器时,你不必再考虑或使用这些优化函数和 Hook 了。

换句话说,React 编译器会为你优化代码。至少,这是理论上的。

然而,在撰写本文时,此编译器仅以实验模式提供。这意味着你不应该将其用于生产,并且可能存在错误或次优编译结果。

尽管如此,你可以在使用 React 19 或更高版本的项目上尝试它(编译器不适用于旧版本的 React)。

将编译器添加到项目中很容易,因为它只是一个额外的依赖项,必须在你的项目中安装:

npm install babel-plugin-react-compiler 

注意

由于编译器尚未稳定,安装步骤和使用说明可能会随时间而变化。

因此,你应该访问官方 React 编译器文档页面以获取最新细节和说明:react.dev/learn/react-compiler

安装了编译器插件后,你必须调整你的构建过程配置,以便使用编译器。当在一个基于 Vite 的项目上工作时,你只需编辑vite.config.js文件,该文件应位于你的根项目文件夹中:

// vite.config.js
const ReactCompilerConfig = { /* ... */ };
export default defineConfig(() => {
  return {
    plugins: [
      react({
        babel: {
          plugins: [
            ["babel-plugin-react-compiler", ReactCompilerConfig],
          ],
        },
      }),
    ],
    // ...
  };
}); 

如果你使用的是其他项目设置,可以遵循官方编译器文档页面上的安装说明。

安装了编译器后,它将自动执行以分析和调整你的代码,包括memo()useMemo()等优化。请记住,这些优化是在运行npm run devnpm run build触发的构建过程中执行的。因此,你的原始源代码不会改变——相反,编译器会在幕后优化你的代码。

一旦 React 编译器稳定,它很可能成为每个 React 项目构建过程的一部分的标准工具。因此,你将不再需要在代码中手动使用memo()useMemo()useCallback()。但在那之前,或者在无法使用编译器的 React 项目中,你仍然需要手动优化代码。

避免不必要的代码下载

到目前为止,这一章主要讨论了避免不必要的代码执行的战略。但问题不仅在于代码的执行。如果你的网站访客需要下载大量可能根本不会执行的代码,那也不是什么好事。因为每下载一千字节 JavaScript 代码都会减慢你网页的初始加载时间——这不仅是因为下载代码包所需的时间(如果用户在慢速网络且代码包很大,这可能会非常显著),而且因为浏览器必须在你的页面变得交互之前解析所有下载的代码。

因此,社区和生态系统投入了大量努力来减少 JavaScript 代码包的大小。最小化(自动缩短变量名和其他减少最终代码的措施)和压缩可以极大地帮助,因此这是一种常见的技巧。实际上,使用 Vite 创建的项目已经自带了构建工作流程(通过运行npm run build启动),这将生成尽可能小的生产优化代码包。

但你也可以采取一些步骤来减少整体代码包的大小:

  1. 尽量编写简短和简洁的代码。

  2. 在包含第三方库时要深思熟虑,除非你真的需要,否则不要使用它们。

  3. 考虑使用代码拆分技术。

第一点应该是相当明显的。如果你写的代码越少,你的网站访客需要下载的代码也就越少。因此,尽量简洁并编写优化后的代码是有意义的。

第二点也应该是有意义的。对于某些任务,你实际上可以通过包含可能比你自己编写的代码更复杂的第三方库来节省代码。但也有一些情况和任务,你可能可以通过编写自己的代码或使用一些内置函数来避免包含第三方库。你至少应该始终考虑这种替代方案,并且只包含你绝对需要的第三方库。

最后一点是 React 可以帮助解决的问题。

通过代码拆分(懒加载)减少包大小

React 提供了一个 lazy() 函数,可以用来有条件地加载组件代码——这意味着只有在实际需要时(而不是一开始)才会加载。

考虑以下由两个组件协同工作的例子。

DateCalculator 组件的定义如下:

import { useState } from 'react';
import { add, differenceInDays, format, parseISO } from 'date-fns';
import classes from './DateCalculator.module.css';
const initialStartDate = new Date();
const initialEndDate = add(initialStartDate, { days: 1 });
function DateCalculator() {
  const [startDate, setStartDate] = useState(
    format(initialStartDate, 'yyyy-MM-dd')
  );
  const [endDate, setEndDate] = useState(
    format(initialEndDate, 'yyyy-MM-dd')
  );
  const daysDiff = differenceInDays(
    parseISO(endDate), 
    parseISO(startDate)
  );
  function handleUpdateStartDate(event) {
    setStartDate(event.target.value);
  }
  function handleUpdateEndDate(event) {
    setEndDate(event.target.value);
  }
  return (
    <div className={classes.calculator}>
      <p>Calculate the difference (in days) between two dates.</p>
      <div className={classes.control}>
        <label htmlFor="start">Start Date</label>
        <input
          id="start"
          type="date"
          value={startDate}
          onChange={handleUpdateStartDate}
        />
      </div>
      <div className={classes.control}>
        <label htmlFor="end">End Date</label>
        <input
          id="end"
          type="date"
          value={endDate}
          onChange={handleUpdateEndDate}
        />
      </div>
      <p className={classes.difference}>
        Difference: {daysDiff} days
      </p>
    </div>
  );
}
export default DateCalculator; 

然后,DateCalculator 组件由 App 组件有条件地渲染:

import { useState } from 'react';
import DateCalculator from './components/DateCalculator.jsx';
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      **{showDateCalc &&** **<****DateCalculator** **/>****}**
    </>
  );
}
export default App; 

在这个例子中,DateCalculator 组件使用第三方库(date-fns 库)来访问各种日期相关实用函数(例如,计算两个日期之间差异的函数,或 differenceInDays)。

该组件接受两个日期值,并计算这两个日期之间的天数差异——尽管组件的实际逻辑在这里并不重要。重要的是,使用了第三方库和各种实用函数。这给整体代码包增加了相当多的 JavaScript 代码,并且所有这些代码都必须在第一次加载整个网站时下载,即使那时日期计算器甚至都不可见(因为它是有条件渲染的)。

在构建用于生产的应用程序(通过 npm run build)后,当预览该生产版本(通过 npm run preview)时,你可以在以下屏幕截图中看到下载了一个主要的代码包文件:

img

图 10.10:下载了一个主要的包文件

浏览器开发者工具中的“网络”选项卡揭示了发出的网络请求。正如你在屏幕截图中看到的,下载了一个主要的 JavaScript 包文件。当点击按钮时,你不会看到任何额外的请求被发送。这表明所有代码,包括 DateCalculator 所需的代码,都是一开始就下载的。

这就是 React 的 lazy() 函数进行代码拆分变得有用的地方。

这个函数可以围绕动态导入包装,以便仅在需要时加载导入的组件。

注意

动态导入是原生 JavaScript 功能,允许动态导入 JavaScript 代码文件。有关此主题的更多信息,请访问 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import

在前面的例子中,它将在 App 组件文件中这样使用:

import { lazy, useState } from 'react';
**const****DateCalculator** **=** **lazy****(****() =>****import****(**
**'./components/DateCalculator.jsx'**
 **)**
**);**
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      {showDateCalc && <DateCalculator />}
    </>
  );
}
export default App; 

仅此还不够。你还必须将条件 JSX 代码(其中使用了动态导入的组件)包装在 React 提供的另一个组件 <Suspense> 中,如下所示:

import { lazy, **Suspense**, useState } from 'react';
const DateCalculator = lazy(() => import(
    './components/DateCalculator.jsx'
  )
);
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      **<****Suspense****fallback****=****{****<****p****>****Loading...****</****p****>****}>**
        {showDateCalc && <DateCalculator />}
      **</****Suspense****>**
    </>
  );
}
export default App; 

注意

你可以在 GitHub 上找到完成的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/06-code-splitting

Suspense 是 React 内置的一个组件,旨在在加载某些资源或数据时显示回退内容。因此,当用于懒加载时,你必须将其包装在任何使用 React 的 lazy() 函数的条件的代码周围。Suspense 还有一个必须提供的强制属性,即 fallback 属性,它期望一个 JSX 值,该值将在动态加载的内容可用之前作为回退内容渲染。

lazy() 导致整体 JavaScript 代码被拆分为多个包。包含 DateCalculator 组件(及其依赖项,如 date-fns 库代码)的包只有在需要时才会下载——也就是说,当在 App 组件中点击按钮时。如果下载需要更长的时间,那么在 Suspense 的同时,屏幕上会显示回退内容。

注意

React 的 Suspense 组件不仅限于与 lazy() 函数一起使用。第十四章,使用 React Router 管理数据,和第十七章,理解 React Suspense 与 use() 钩子,将探讨如何使用 Suspense 组件在加载数据时显示回退内容。

添加 lazy()Suspense 组件后,最初下载的包会更小。此外,如果点击按钮,还会下载更多的代码文件:

img

图 10.11:点击按钮后,会下载额外的代码文件

就像迄今为止描述的所有其他优化技术一样,lazy() 函数并不是你应该开始围绕所有导入进行包装的函数。如果一个导入的组件非常小且简单(并且不使用任何第三方代码),拆分代码实际上并不值得,尤其是考虑到下载额外包所需的额外 HTTP 请求也带来了一些开销。

在那些一开始就会加载的组件上使用 lazy() 也没有意义。只有考虑在条件加载的组件上使用它。

Strict Mode

在本章中,你学习了关于 React 内部结构和各种优化技术的很多内容。虽然这不是一种优化技术,但仍然相关,React 还提供了一个名为 Strict Mode 的功能。

你可能之前遇到过这样的代码:

import React from 'react';
// ... other code ...
root.render(<React.StrictMode><App /></React.StrictMode >); 

<React.StrictMode> 是 React 提供的另一个内置组件。它不会渲染视觉元素,但它将启用一些额外的检查,这些检查由 React 在幕后执行。

大多数检查都与识别不安全或过时代码(即未来将被移除的功能)的使用相关。但也有一些检查旨在帮助你识别代码中可能存在的问题。

例如,当使用严格模式时,React 将执行组件函数两次,并在组件首次挂载时卸载和重新挂载每个组件。这样做是为了确保你以一致和正确的方式管理你的状态和副作用(例如,确保你的副作用函数中有清理函数)。

注意

严格模式(Strict Mode)仅影响你的应用程序及其在开发过程中的行为。一旦你为生产环境构建了应用程序,它就不会再影响你的应用程序。在生产环境中,不会执行额外的检查,例如双重组件函数执行。

启用严格模式构建 React 应用程序有时可能会导致混淆或令人烦恼的错误消息。例如,你可能会想知道为什么你的组件副作用执行得太频繁。

因此,是否使用严格模式是你个人的决定。启用它可以帮助你及早捕获和修复错误。

调试代码和 React 开发者工具

在本章的早期,你了解到组件函数可能会非常频繁地执行,并且你可以使用 memo()useMemo()(以及你不应该总是阻止它们)来防止不必要的执行。

通过在组件函数内部添加 console.log() 来识别组件执行是获取组件洞察的一种方法。这是本章使用的方法。然而,对于拥有数十、数百甚至数千个组件的大型 React 应用程序,使用 console.log() 可能会变得繁琐。

正因如此,React 团队也构建了一个官方工具来帮助获取应用程序洞察。React 开发者工具是一个可以安装在所有主要浏览器(Chrome、Firefox 和 Edge)上的扩展程序。你可以通过在网络上搜索 "<你的浏览器> react 开发者工具"(例如,chrome react 开发者工具)来查找并安装该扩展程序。

安装扩展程序后,你可以直接从浏览器内部访问它。例如,当使用 Chrome 时,你可以直接从 Chrome 的开发者工具(可以通过 Chrome 的菜单打开)中访问 React 开发者工具扩展程序。探索特定扩展程序的文档(在你的浏览器扩展商店中)以获取有关如何访问它的详细信息。

React 开发者工具扩展提供了两个区域:一个 Components 页面和一个 Profile 页面:

计算机屏幕截图  描述自动生成

图 10.12:React 开发者工具可以通过浏览器开发者工具访问

Components页面可以用来分析当前渲染页面的组件结构。你可以使用这个页面来了解你的组件结构(即“组件树”),组件是如何嵌套在一起的,甚至组件的配置(属性、状态)。

计算机屏幕截图  自动生成的描述

图 10.13:组件关系和数据展示

当尝试理解组件的当前状态、组件与其他组件的关系以及哪些其他组件可能因此影响组件(例如,导致它重新评估)时,这个页面非常有用。

然而,在本章的上下文中,更有用的页面是Profiler页面:

计算机屏幕截图  自动生成的描述

图 10.14:分析器页面(没有收集任何数据)

在这个页面上,你可以开始记录组件评估(即组件函数执行)。你可以通过简单地点击左上角的Record按钮(蓝色圆圈)来完成此操作。然后,此按钮将被Stop按钮替换,你可以点击它来结束记录。

在记录 React 应用几秒钟(并在该期间与之交互)后,一个示例结果可能看起来像这样:

计算机屏幕截图  自动生成的描述

图 10.15:记录完成后,分析器页面显示了各种条形图

这个结果由两个主要区域组成:

  • 一系列条形图,表示组件重新评估的次数(每个条形图反映一个影响了零个或多个组件的重新评估周期)。你可以点击这些条形图来探索特定周期更详细的信息。

  • 对于所选的评估周期,会显示受影响组件的列表。你可以很容易地识别受影响的组件,因为它们的条形图被着色,并且会显示它们的计时信息。

你可以从1(在这种情况下,这个记录会话有两个)选择任何渲染周期来查看哪些组件受到了影响。窗口的底部部分(2)通过突出显示它们并用某种颜色标记,显示了所有受影响的组件,并输出了组件重新评估所花费的总时间(例如,0.1ms of 0.3ms)。

注意

值得注意的是,这个工具还证明组件评估非常快——重新评估一个组件的0.1ms对于任何人类来说都太快,以至于无法意识到幕后发生了什么。

在窗口的右侧,你还可以了解更多关于这个组件评估周期的信息。例如,你可以了解它是如何被触发的。在这种情况下,它是由Form组件触发的(这与本章前面在避免不必要的子组件评估部分讨论的例子相同)。

因此,Profiler页面也可以帮助你识别组件评估周期并确定哪些组件受到影响。在这个例子中,如果你将memo()函数包裹在Error组件周围,你可以看到差异:

计算机屏幕截图 自动生成的描述

图 10.16:只有表单组件受到影响,而不是错误组件

在将memo()函数作为包装器重新添加到Error组件(如本章前面所述)之后,你可以使用 React 开发者工具的Profiler页面来确认Error组件不再被不必要地评估。为此,你应该开始一个新的录制会话并重现之前没有memo()Error组件会被再次调用的情景。

Profiler窗口中,Error组件上对角线的灰色线条表示该组件未受到其他组件函数调用的任何影响。

因此,可以使用 React 开发者工具来深入了解你的 React 应用和组件。你可以在组件函数中调用console.log()的同时使用它们,或者完全替代调用console.log()

摘要和关键要点

  • 当 React 组件的状态发生变化或父组件被评估时,它们会被重新评估(执行)。

  • React 通过首先使用虚拟 DOM 来计算所需的 UI 更改,从而优化组件评估。

  • 同时在同一位置发生的多个状态更新会被 React 批处理在一起。这确保了避免不必要的组件评估。

  • memo()函数可以用来控制组件函数的执行。

  • memo()函数会查找属性值的变化(旧属性与新属性之间的差异),以确定组件函数是否需要再次执行。

  • useMemo()可以用来包装性能密集型计算,并且只有在关键依赖项发生变化时才执行这些计算。

  • 由于memo()useMemo()也会带来成本(比较操作),因此应谨慎使用它们。

  • 当使用 React 19 或更高版本时,你可以安装并启用(实验性的)React 编译器,以在构建过程中自动优化你的代码。

  • 可以通过lazy()函数(与内置的Suspense组件结合使用)的帮助,通过代码拆分来减少初始代码下载的大小。

  • 可以通过内置的<React.StrictMode>组件启用 React 的严格模式,以执行各种额外检查并检测应用程序中的潜在错误。

  • 可以使用 React 开发者工具来深入了解你的 React 应用(例如,组件结构和重新评估周期)。

接下来是什么?

作为一名开发者,你应该始终了解并理解你所使用的工具——在本例中是 React。

本章使您更好地了解了 React 在底层的工作原理以及自动实现的优化。此外,您还了解了您可以实施的多种优化技术。

下一章将回到解决您在尝试构建 React 应用程序时可能遇到的实际问题。您将学习更多关于可以用于解决与组件和应用状态管理相关的更复杂问题的技术和功能,而不是优化 React 应用程序。

测试您的知识!

通过回答以下问题来测试您对本章涵盖的概念的了解。然后,您可以比较您的答案与可在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/10-behind-scenes/exercises/questions-answers.md找到的示例:

  1. 为什么 React 使用虚拟 DOM 来检测所需的 DOM 更新?

  2. 当组件函数执行时,真实 DOM 会受到什么影响?

  3. 哪些组件是memo()函数的优秀候选者?哪些组件是不合适的候选者?

  4. useMemo()memo()有何不同?

  5. 代码拆分和lazy()函数背后的理念是什么?

应用您所学到的知识

在您对 React 内部结构和可以用于改进您的应用程序的一些优化技术有了新的了解之后,您现在可以在以下活动中应用这些知识。

活动十.1:优化现有应用程序

在这个活动中,您将获得一个可以优化多个位置的现有 React 应用程序。您的任务是识别优化机会并实施适当的解决方案。请记住,过多的优化实际上可能导致结果更差。

注意

您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/activities/practice-1-start找到这个活动的起始代码。在下载此代码时,您将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。

提供的项目还使用了之前章节中介绍的一些许多功能。花时间分析它并理解提供的代码。这是一个很好的实践,让您看到许多关键概念的实际应用。

下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,您可以通过npm run dev启动开发服务器。结果,当访问localhost:5173时,您应该看到以下 UI:

img

图 10.17:运行中的起始项目

仔细熟悉提供的项目。在 UI 中尝试不同的按钮,在表单输入字段中填写一些示例数据,并分析提供的代码。请注意,此示例项目不会向任何服务器发送任何 HTTP 请求。所有输入的数据一旦输入即被丢弃。

要完成此活动,解决方案步骤如下:

  1. 通过寻找不必要的组件函数执行来寻找优化机会。

  2. 还应识别组件函数内部不必要的代码执行(其中无法阻止整个组件函数的调用)。

  3. 确定哪些代码可以懒加载而不是立即加载。

  4. 使用memo()函数、useMemo() Hook 和 React 的lazy()函数来改进代码。

如果你能在浏览器开发者工具的网络标签页中看到点击重置密码创建新账户按钮时额外的代码获取网络请求,那么你可以知道你提出了一个好的解决方案和合理的调整:

计算机截图  自动生成的描述

图 10.18:在最终解决方案中,一些代码是懒加载的

此外,当在注册表单(即点击创建新账户时切换到的表单)的电子邮件输入字段(电子邮件确认电子邮件)中输入时,不应看到任何Validated password.控制台消息:

计算机截图  自动生成的描述

图 10.19:控制台没有“验证密码。”输出

点击更多信息按钮时,也不应该有任何控制台输出:

计算机截图  自动生成的描述

图 10.20:点击“更多信息”时没有控制台消息

注意

所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/activities/practice-1找到。

第十一章:处理复杂状态

学习目标

到本章结束时,你将能够做到以下几点:

  • 管理跨组件或甚至应用级别的状态(而不仅仅是组件特定的状态)

  • 在多个组件间分配数据

  • 处理复杂的状态值和变化

简介

状态是您必须理解(并与之合作)以有效使用 React 的核心概念之一。基本上,每个 React 应用都会在许多组件间使用(许多)状态值来呈现动态、反应式的用户界面。

从包含变化的计数器或用户输入的值的简单状态值,到更复杂的状态值,如多个表单输入的组合或用户身份验证信息,状态无处不在。在 React 应用中,它通常借助useState()钩子来管理。

然而,一旦你开始构建更复杂的 React 应用(例如,在线商店、管理仪表板和类似网站),你可能会面临与状态相关的各种挑战。状态值可能在组件 A 中使用,但在组件 B 中更改,或者由多个可能因多种原因而变化的动态值组成(例如,在线商店中的购物车,它是由产品组合而成的,每个产品都有数量、价格,以及可能单独更改的其他属性)。

你可以使用useState()、props 以及本书迄今为止涵盖的其他概念来处理所有这些问题。但你会注意到,仅基于useState()的解决方案会变得复杂,难以理解和维护。这就是为什么 React 提供了更多工具——为这类问题创建的工具,本章将突出和讨论这些工具。

跨组件状态的问题

你甚至不需要构建一个高度复杂的 React 应用,就可能会遇到一个常见问题:跨越多个组件的状态。

例如,你可能正在构建一个新闻应用,用户可以标记某些文章。一个简单的用户界面可能看起来像这样:

img

图 11.1:一个示例用户界面

正如前图所示,文章列表在左侧,而已标记文章的摘要可以在右侧的侧边栏中找到。

一种常见的解决方案是将这个用户界面拆分成多个组件。具体来说,文章列表可能将作为一个独立的组件——就像书签摘要侧边栏一样。

然而,在这种情况下,这两个组件都需要访问相同的共享状态——即已标记文章的列表。文章列表组件需要访问权限以便添加(或删除)文章。书签摘要侧边栏组件也需要它,因为它需要显示已标记的文章。

这种类型的应用的组件树和状态使用可能看起来像这样:

img

图 11.2:两个兄弟组件共享相同的状态

在这个图中,你可以看到状态在这两个组件之间是共享的。你还可以看到这两个组件有一个共享的父组件(在这个例子中是 News 组件)。

由于状态被两个组件使用,你不会在任何一个组件中管理它。相反,它被提升,如第四章与事件和状态一起工作(在提升状态部分)中所述。当提升状态时,状态值和指向操作状态值的函数的指针通过 props 传递给实际需要访问的组件。

这可行,并且是一个常见的模式。你可以(并且应该)继续使用它。但如果需要访问某些共享状态的组件深深嵌套在其他组件中怎么办?如果前一个例子中的应用组件树看起来像这样?

img

图 11.3:具有多层状态相关组件的组件树

在这个图中,你可以看到 BookmarkSummary 组件是一个深层嵌套的组件。在它和 News 组件(管理共享状态)之间,还有两个其他组件:InfoSidebar 组件和 BookmarkInformation 组件。在更复杂的 React 应用中,像这个例子中这样有多个组件嵌套层级是非常常见的。

当然,即使有这些额外的组件,状态值仍然可以通过 props 传递。你只需要在持有状态的组件和需要状态的组件之间添加 props。例如,你必须通过 props 将 bookmarkedArticles 状态值传递给 InfoSidebar 组件,这样该组件就可以将其转发给 BookmarkInformation

import BookmarkInformation from
  '../BookmarkSummary/BookmarkInformation.jsx';
import classes from './InfoSidebar.module.css';
function InfoSidebar({ bookmarkedArticles }) {
  return (
    <aside className={classes.sidebar}>
      <BookmarkInformation bookmarkedArticles={bookmarkedArticles} />
    </aside>
  );
}
export default InfoSidebar; 

同样的过程在 BookmarkInformation 组件内部重复。

注意

你可以在 GitHub 上找到完整的示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/01-cross-cmp-state

这种模式被称为属性钻取。属性钻取意味着状态值通过 props 在多个组件之间传递。并且它通过根本不需要状态的组件传递——除了将其转发给子组件(如前一个例子中的 InfoSidebarBookmarkInformation 组件所做的那样)。

作为一名开发者,你通常会想避免这种模式,因为属性钻取有几个弱点:

  • 属于属性钻取的组件(例如 InfoSidebarBookmarkInformation)实际上已经不能再重用了,因为任何想要使用它们的组件都必须为转发状态属性提供一个值。

  • 属性钻取还会导致大量的开销代码需要编写(接受 props 和转发 props 的代码)。

  • 重构组件变得更加困难,因为必须添加或删除状态 props。

因此,只有当所有涉及的组件仅用于整个 React 应用的特定部分,并且重用或重构它们的可能性很低时,才接受属性钻探。

由于在大多数情况下应避免使用开孔钻探,React 提供了一个替代方案:上下文 API。

使用上下文处理多组件状态

React 的上下文功能允许你创建一个可以轻松地在所需组件之间共享的值,而无需使用属性。

一个网站示意图  自动生成的描述

图 11.4:React 上下文附加到组件上,以便将其暴露给所有子组件——无需属性钻探

使用上下文 API 是一个多步骤的过程,其步骤在此处描述:

  1. 你必须创建一个应该共享的上下文值。

  2. 需要访问上下文对象的组件必须在父组件中提供上下文。

  3. 需要访问(读取或写入)的组件必须订阅上下文。

React 内部管理和自动分发上下文值(及其更改)到所有已订阅上下文的组件。

然而,在任何一个组件可以订阅之前,第一步是创建一个上下文对象。这是通过 React 的 createContext() 函数完成的:

import { createContext } from 'react';
createContext('Hello Context'); // a context with an initial string value
createContext({}); // a context with an initial (empty) object as a value 

此函数接受一个初始值,该值应该被共享。它可以任何类型的值(例如,字符串或数字),但通常是一个对象。这是因为大多数共享值都是实际值和应该操作这些值的函数的组合。所有这些都被组合成一个单独的上下文对象。

当然,如果需要,初始上下文值也可以是一个空值(例如,nullundefined、空字符串等)。

createContext() 还返回一个值:一个上下文对象,应该存储在一个大写变量(或常量)中,因为它实际上是一个 React 组件(React 组件应以大写字母开头)。

这是如何调用 createContext() 函数来为本章前面讨论的示例创建上下文对象的方法:

import { createContext } from 'react';
const BookmarkContext = createContext({
  bookmarkedArticles: []
});
export default BookmarkContext; 

在这里,初始值是一个包含 bookmarkedArticles 属性的对象,该属性包含一个(空的)数组。你也可以只将数组作为初始值存储(即,createContext([])),但对象更好,因为在本章的后面部分还会添加更多内容。

这段代码通常放置在一个单独的上下文代码文件中(例如,bookmark-context.jsx),通常存储在名为 store 的文件夹中(因为上下文功能可以用作中央状态存储)或 context 文件夹。然而,这只是一种约定,并不是技术上必需的。你可以在你的 React 应用程序的任何地方放置这段代码。

如果文件只包含上述代码,它可能使用 .js 作为文件扩展名,因为它不包含任何 JSX 代码。在本章的后面部分,这将会改变——因此你现在可以使用 .jsx 作为扩展名。

当然,这个初始值不是状态的替代品;它是一个永远不会改变的静态值。但这只是与上下文相关的三个步骤中的第一个。下一步是提供上下文。

提供和管理上下文值

为了在其他组件中使用上下文值,你必须首先提供该值。这是通过使用createContext()返回的值来完成的。

当使用 React 19 或更高版本时,该函数返回一个 React 组件,该组件应包裹所有需要访问上下文值的其他组件。

当使用较旧的 React 版本(即 React 18 或更早版本)时,createContext()返回的值是一个包含嵌套Provider属性的对象。该属性然后包含一个 React 组件,该组件应包裹所有需要访问上下文值的其他组件。

因此,无论是哪种方式,关键都是用上下文提供者组件包裹组件。

在前面的示例中,使用 React 19 或更高版本时,createContext()返回的BookmarkContext组件可以在News组件中使用,将其包裹在ArticlesInfoSidebar组件周围:

import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  return (
    <BookmarkContext>
      <Articles />
      <InfoSidebar />
    </BookmarkContext>
  );
} 

或者,如果使用 React 18 或更低版本:

import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  return (
    <BookmarkContext.Provider>
      <Articles />
      <InfoSidebar />
    </BookmarkContext.Provider>
  );
} 

然而,这段代码无法正常工作,因为缺少了一个重要的事项:组件期望一个value属性,该属性应包含要分发给感兴趣组件的当前上下文值。虽然你提供了初始上下文值(可能为空),但你还需要通知 React 关于当前上下文值的信息,因为上下文值经常发生变化(毕竟它们经常被用作跨组件状态的替代品)。

因此,当使用 React 19 或更高版本时,代码可以修改如下所示:

import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  const bookmarkCtxValue = {
    bookmarkedArticles: []
  }; // for now, it's the same value as used before, for the initial context
  return (
    <BookmarkContext value={bookmarkCtxValue}>
      <Articles />
      <InfoSidebar />
    </BookmarkContext>
  );
} 

或者,如果使用 React 18 或更低版本:

import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  const bookmarkCtxValue = {
    bookmarkedArticles: []
  }; // for now, it's the same value as used before, for the initial context
  return (
    <BookmarkContext.Provider value={bookmarkCtxValue}>
      <Articles />
      <InfoSidebar />
    </BookmarkContext.Provider>
  );
} 

使用此代码,一个包含已标记文章列表的对象被分发给感兴趣的子组件。

列表仍然是静态的。但你可以使用你已知的工具来更改这一点:useState()钩子。在News组件内部,你可以使用useState()钩子来管理已标记文章的列表,如下所示:

import { useState } from 'react';
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  const [savedArticles, setSavedArticles] = useState([]);
  const bookmarkCtxValue = {
    bookmarkedArticles: savedArticles // using the state as a value
  };
  return (
    <BookmarkContext value={bookmarkCtxValue}>
      <Articles />
      <InfoSidebar />
    </BookmarkContext>
  );
} 

通过这个更改,上下文从静态变为动态。每当savedArticles状态发生变化时,上下文值也会发生变化。

因此,这就是提供上下文时缺失的部分。如果上下文应该是动态的(并且可以从某个嵌套子组件内部更改),上下文值也应包括指向触发状态更新的函数的指针。

对于前面的示例,代码因此调整为如下所示:

import { useState } from 'react';
import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import BookmarkContext from '../../store/bookmark-context.jsx';
function News() {
  const [savedArticles, setSavedArticles] = useState([]);
  function addArticle(article) {
    setSavedArticles(
     (prevSavedArticles) => [...prevSavedArticles, article]
    );
  }
  function removeArticle(articleId) {
    setSavedArticles(
      (prevSavedArticles) => prevSavedArticles
        .filter(
          (article) => article.id !== articleId
        )
    );
  }
  const bookmarkCtxValue = {
    bookmarkedArticles: savedArticles,
    bookmarkArticle: addArticle,
    unbookmarkArticle: removeArticle
  };
  return (
    <BookmarkContext value={bookmarkCtxValue}>
      <Articles />
      <InfoSidebar />
    </BookmarkContext>
  );
} 

以下是代码片段中更改的两个重要事项:

  • 添加了两个新函数:addArticleremoveArticle

  • 将指向这些函数的属性添加到了bookmarkCtxValuebookmarkArticleunbookmarkArticle方法。

addArticle 函数将一篇新的文章(应该被书签)添加到 savedArticles 状态中。由于新的状态值依赖于前一个状态值(已书签的文章被添加到已书签文章的列表中),因此使用了更新状态值的函数形式。

同样,removeArticle 函数通过过滤现有列表,保留所有除具有匹配 id 值之外的项目,从而从 savedArticles 列表中删除一篇文章。

如果 News 组件没有使用新的上下文功能,它将是一个使用状态的组件,就像你在本书中多次看到的。但现在,通过使用 React 的上下文 API,这些现有功能与一个新特性(上下文)结合,创建了一个动态的、可分发的值。

嵌套在 ArticlesInfoSidebar 组件(或其子组件)中的任何组件都将能够访问这个动态上下文值,以及上下文对象中的 bookmarkArticleunbookmarkArticle 方法,而无需任何属性传递。

注意

你不必创建动态上下文值。你也可以将静态值分发给嵌套组件。这种情况是可能的,但很少见,因为大多数 React 应用通常需要可以在组件间变化的动态状态值。

在嵌套组件中使用 Context

上下文创建并提供了之后,它就准备好被需要访问或更改上下文值的组件使用了。

为了使上下文值对上下文组件内部的组件(例如,前一个示例中的 BookmarkContext)可用,React 提供了一个 use() 钩子,它可以被使用。

然而,这个钩子仅在 React 19 或更高版本中可用。在使用较旧 React 版本的项目中,你将使用 useContext() 钩子来访问某些上下文值。该钩子也在 React 19 中仍然得到支持,因此你可以使用这两个钩子中的任何一个来获取上下文值。

use() 钩子比 useContext() 钩子更灵活一些,因为与其他任何钩子不同,它实际上也可以在 if 语句或循环内部使用。此外,钩子不仅可以用来获取上下文值——因此你将在 第十七章 中再次看到 use(),即 理解 React Suspense 与 use() 钩子

如前所述,当使用 React 19 时,如果你试图获取上下文值,可以使用 use()useContext()use()useContext() 都需要一个参数:通过 createContext() 创建的上下文对象,即该函数返回的值。因此,你将得到传递给上下文提供组件的值(即为其 value 属性设置的值)。当使用 React 19 或更高版本时,由于 use() 更灵活,并且输入更少,你可以忽略 useContext() 并使用 use() 钩子来访问上下文值。

对于前面的例子,上下文值可以在BookmarkSummary组件中使用,如下所示:

import { use } from 'react'; // or import useContext for React < 19
import BookmarkContext from '../../store/bookmark-context.jsx';
import classes from './BookmarkSummary.module.css';
function BookmarkSummary() {
  const bookmarkCtx = use(BookmarkContext);
  // React < 19: const bookmarkCtx = useContext(BookmarkContext);
  const numberOfArticles = bookmarkCtx.bookmarkedArticles.length;
  return (
    <>
      <p className={classes.summary}>
        {numberOfArticles} articles bookmarked
      </p>
      <ul className={classes.list}>
        {bookmarkCtx.bookmarkedArticles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </>
  );
}
export default BookmarkSummary; 

在这个代码中,use()接收从store/bookmark-context.jsx文件导入的BookmarkContext值。然后它返回上下文中存储的值,即在前面的代码示例中找到的bookmarkCtxValue。正如你可以在那个片段中看到的那样,bookmarkCtxValue是一个具有三个属性的对象:bookmarkedArticlesbookmarkArticle(一个方法)和unbookmarkArticle(也是一个方法)。

这个返回的对象被存储在bookmarkCtx常量中。每当上下文值改变(因为News组件中的setSavedArticles状态更新函数被执行),React 将再次执行这个BookmarkSummary组件,因此bookmarkCtx将持有最新的状态值。

最后,在BookmarkSummary组件中,通过bookmarkCtx对象访问bookmarkedArticles属性。然后使用这篇文章列表来计算已标记文章的数量,输出简短摘要,并在屏幕上显示列表。

类似地,BookmarkContext可以通过use()Articles组件中使用:

import { use } from 'react';
// other imports
function Articles() {
  const bookmarkCtx = use(BookmarkContext); 
  // or: const bookmarkCtx = useContext(BookmarkContext)
  return (
    <ul>
      {dummyArticles.map((article) => {
        const isBookmarked = bookmarkCtx.bookmarkedArticles.some(
          (bArticle) => bArticle.id === article.id
        );
        // default icon: Empty bookmark icon, because not bookmarked
        let buttonIcon = <FaRegBookmark />;
        if (isBookmarked) {
          buttonIcon = <FaBookmark />;
        }
        return (
          <li key={article.id}>
            <h2>{article.title}</h2>
            <p>{article.description}</p>
            <button>{buttonIcon}</button>
          </li>
        );
      })}
    </ul>
  );
} 

在这个组件中,上下文被用来确定给定的文章是否当前被书签标记(为了改变按钮的图标和功能,需要这个信息)。

这就是上下文值(无论是静态的还是动态的)如何在组件中被读取。当然,它们也可以像下一节讨论的那样被更改。

从嵌套组件更改上下文

React 的上下文特性通常用于在多个组件之间共享数据,而不使用 props。因此,一些组件必须操纵这些数据也是相当常见的。例如,购物车的上下文值必须可以从显示产品项的组件内部进行调整(因为这些可能有一个“添加到购物车”按钮)。

然而,要从嵌套组件内部更改上下文值,你不能简单地覆盖存储的上下文值。以下代码不会按预期工作:

const bookmarkCtx = use(BookmarkContext);
// Note: This does NOT work
bookmarkCtx.bookmarkedArticles = []; // setting the articles to an empty array 

这段代码不起作用。正如你不应该尝试通过简单地分配一个新值来更新状态一样,你也不能通过分配新值来更新上下文值。这就是为什么在提供和管理上下文值部分添加了两个方法(bookmarkArticleunbookmarkArticle)到上下文值中。这两个方法指向触发状态更新的函数(通过useState()提供的状态更新函数)。

因此,在可以通过按钮点击进行标记或取消标记文章的Articles组件中,应该调用这些方法:

// This code is part of the Article component function
// default action => bookmark article, because not bookmarked yet
let buttonAction = () => bookmarkCtx.bookmarkArticle(article);
// default button icon: Empty bookmark icon, because not bookmarked
let buttonIcon = <FaRegBookmark />;
if (isBookmarked) {
  buttonAction = () => bookmarkCtx.unbookmarkArticle(article.id);
  buttonIcon = <FaBookmark />;
} 

bookmarkArticleunbookmarkArticle方法在存储在buttonAction变量中的匿名函数内部被调用。这个变量被分配给<button>onClick属性(参见前面的代码片段)。

使用此代码,可以成功更改上下文值。多亏了上一节(在嵌套组件中使用上下文)中采取的步骤,每当上下文值更新时,它也会自动反映在用户界面上。

注意

完成的示例代码可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/02-cross-cmp-state-with-context

高效使用上下文 API

能够创建、提供、访问和更改上下文很重要——最终,正是这些事情允许您在应用程序中使用 React 的上下文 API。但随着您的应用程序(以及因此可能也是您的上下文值)变得更加复杂,设置和管理您的上下文高效也很重要,例如,通过获得适当的 IDE 支持。

获得更好的代码自动完成

使用上下文处理多组件状态 这一部分,通过createContext()创建了一个上下文对象。该函数接收一个初始上下文值——一个包含bookmarkedArticles属性的对象,在前面的例子中。

在这个例子中,初始上下文值并不重要。它不常使用,因为无论怎样,它都会在News组件内部被新的值覆盖。然而,根据您使用的集成开发环境(IDE),在定义具有与最终上下文值相同形状和结构的初始上下文值时,您可以得到更好的代码自动完成。

因此,由于在 提供和管理上下文值 这一部分中向上下文值添加了两种方法,因此这些方法也应添加到store/bookmark-context.jsx中的初始上下文值中:

const BookmarkContext = createContext({
  bookmarkedArticles: [],
  bookmarkArticle: () => {},
  unbookmarkArticle: () => {}
});
export default BookmarkContext; 

这两种方法被添加为空函数,因为实际的逻辑是在News组件中设置的。这些方法仅添加到这个初始上下文值中,以提供更好的 IDE 自动完成。因此,这一步是可选的。

上下文或提升状态?

到目前为止,您现在有两个管理跨组件状态的工具:

  • 您可以提升状态,正如本书前面所描述的(在第四章 与事件和状态一起工作提升状态 部分)。

  • 或者,您可以使用本章中解释的 React 的上下文 API。

在每种情况下,您应该使用哪种方法?

最终,如何管理这取决于您,但有一些简单的规则您可以遵循:

  • 如果您只需要在组件嵌套的一到两层之间共享状态,请提升状态。

  • 如果您有长链组件(即组件的深层嵌套)并且有共享状态,请使用上下文 API。一旦您开始使用大量的属性传递,就是考虑 React 的上下文功能的时候了。

  • 如果你有一个相对扁平的组件树但想要重用组件(即,你不想使用 props 将状态传递给组件),也可以使用上下文 API。

将上下文逻辑外包到单独的组件中

通过之前解释的步骤,你已经拥有了通过上下文管理跨组件状态所需的一切。

但你可以考虑一种模式来管理你的动态上下文值和状态:创建一个专门的组件来提供(和管理)上下文值。

在前面的例子中,News 组件被用来提供上下文并管理其(动态的、基于状态的)值。虽然这样做是可行的,但如果组件需要处理上下文管理,它们可能会变得不必要地复杂。因此,创建一个专门的组件来处理这一点可以导致代码更容易理解和维护。

对于前面的例子,这意味着在 store/bookmark-context.jsx 文件中,你可以创建一个看起来像这样的 BookmarkContextProvider 组件:

export function BookmarkContextProvider({ children }) {
  const [savedArticles, setSavedArticles] = useState([]);
  function addArticle(article) {
    setSavedArticles(
      (prevSavedArticles) => [...prevSavedArticles, article]
    );
  }
  function removeArticle(articleId) {
    setSavedArticles((prevSavedArticles) =>
      prevSavedArticles.filter((article) => article.id !== articleId)
    );
  }
  const bookmarkCtxValue = {
    bookmarkedArticles: savedArticles,
    bookmarkArticle: addArticle,
    unbookmarkArticle: removeArticle,
  };
  return (
    <BookmarkContext value={bookmarkCtxValue}>
      {children}
    </BookmarkContext>
  );
} 

此组件包含与通过状态管理一系列书签文章相关的所有逻辑。它创建了与之前相同的上下文值(包含文章列表以及更新该列表的两个方法)。

BookmarkContextProvider 组件做了一件事。它使用特殊的 children prop(在第三章的 Components and Props 部分的 特殊的children prop 小节中介绍)来包裹在 BookmarkContextProvider 组件标签之间传递的任何内容,用 BookmarkContext 包裹。

这允许在 News 组件中使用 BookmarkContextProvider 组件,如下所示:

import Articles from '../Articles/Articles.jsx';
import InfoSidebar from '../InfoSidebar/InfoSidebar.jsx';
import { BookmarkContextProvider } from '../../store/bookmark-context.jsx';
function News() {
  return (
    <BookmarkContextProvider>
      <Articles />
      <InfoSidebar />
    </BookmarkContextProvider>
  );
}
export default News; 

News 组件现在不再管理整个上下文值,而是简单地导入 BookmarkContextProvider 组件,并将该组件包裹在 ArticlesInfoSidebar 组件周围。因此,News 组件更加精简。

注意

这种模式完全是可选的。它既不是官方的最佳实践,也不会带来任何性能上的好处。它仅仅是一个可以帮助你保持组件函数简洁和精炼的模式。

值得注意的是,还有一个相关的模式用于消费上下文。然而,该模式依赖于构建自定义的 React Hook——这个概念将在下一章中介绍。因此,提到的上下文消费模式也将放在下一章中介绍。

结合多个上下文

尤其是在更大、功能更丰富的 React 应用程序中,你可能会需要处理多个可能彼此无关的上下文值。例如,一个在线商店可能会使用一个上下文来管理购物车,另一个上下文来管理用户认证状态,还有另一个上下文值来跟踪页面分析。

React 完全支持这种用例。你可以根据需要创建、管理、提供和使用尽可能多的上下文值。你可以在单个上下文中管理多个(相关或不相关的)值,或者使用多个上下文。你可以在同一个组件或不同组件中提供多个上下文。这完全取决于你和你应用程序的需求。

你也可以在同一个组件中使用多个上下文(这意味着你可以多次调用use()useContext(),并使用不同的上下文值)。

useState()的局限性

到目前为止,本章已经探讨了跨组件状态复杂性。但在某些情况下,一些状态仅在单个组件内部使用时,状态管理也可能具有挑战性。

在大多数情况下(当然,目前它也是唯一被介绍的工具),useState()是状态管理的一个很好的工具。因此,useState()应该是你管理状态的首选。但是,如果你需要根据另一个状态变量的值推导出新的状态值,例如在这个例子中,useState()可能会达到其极限:

setIsLoading(fetchedPosts ? false : true); 

这个简短的片段是从一个组件中提取的,该组件向服务器发送 HTTP 请求以获取一些博客文章:

function App() {
  const [fetchedPosts, setFetchedPosts] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState();
  const fetchPosts = useCallback(async function fetchPosts() {
    **setIsLoading****(fetchedPosts ?** **false** **:** **true****);**
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      setIsLoading(false);
      setError(null);
      setFetchedPosts(posts);
    } catch (error) {
      setIsLoading(false);
      setError(error.message);
      setFetchedPosts(null);
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  return (
    <>
      {isLoading && <p>Loading...</p>}
      {error && <p>{error}</p>}
      {fetchedPosts && <BlogPosts posts={fetchedPosts} />}
    </>
  );
} 

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/examples/04-complex-usestate

在初始化请求时,只有在没有获取到数据之前,isLoading状态值(负责在屏幕上显示加载指示器)才应该设置为true。如果之前已经获取了数据(即fetchedPosts不是null),那么应该仍然在屏幕上显示这些数据,而不是某个加载指示器。

初看之下,这段代码可能看似没有问题。但实际上,它违反了与useState()相关的一个重要规则:你不应该引用当前状态来设置新的状态值。如果你需要这样做,你应该使用状态更新函数的函数形式(参见第四章“基于前一个状态正确更新状态”部分)。

然而,在前面的例子中,这个解决方案不起作用。如果你切换到函数状态更新形式,你只能访问你试图更新的状态的当前值。你不能(安全地)访问其他状态的当前值。在前面的例子中,另一个状态(fetchedPosts而不是isLoading)被引用。因此,你必须违反上述规则。

这种违规也有实际后果(在这个例子中)。以下代码片段是fetchPosts函数的一部分,该函数被useCallback()包装:

const fetchPosts = useCallback(async function fetchPosts() {
  setIsLoading(fetchedPosts ? false : true);
  setError(null);
  try {
    const response = await fetch(
      'https://jsonplaceholder.typicode.com/posts'
    );
    if (!response.ok) {
      throw new Error('Failed to fetch posts.');
    }
    const posts = await response.json();
    setIsLoading(false);
    setError(null);
    setFetchedPosts(posts);
  } catch (error) {
    setIsLoading(false);
    setError(error.message);
    setFetchedPosts(null);
  }
}, []); 

这个函数发送 HTTP 请求,并根据请求的状态更改多个状态值。

useCallback() 用于避免与 useEffect() 相关的无限循环(参见 第八章处理副作用 ,了解更多关于 useEffect() ,无限循环和 useCallback() 作为补救措施的信息)。通常,fetchedPosts 应该作为依赖项添加到传递给 useCallback() 函数的第二个参数 dependencies 数组中。然而,在这个例子中,这不可能完成,因为 fetchedPostsuseCallback() 包装的函数内部被更改,因此状态值不仅是一个依赖项,而且还被积极更改。这导致无限循环。

因此,在终端中显示了一个警告,并且没有达到在数据之前获取数据时不显示加载指示器的预期行为:

img

图 11.5:在终端中输出关于缺少依赖项的警告

如果你有多个人相关联的状态值相互依赖,那么像刚才描述的问题很常见。

一个可能的解决方案是将多个单独的状态片段(fetchedPostsisLoadingerror)移动到一个单一的组合状态值(即一个对象)。这将确保所有状态值都组合在一起,并且因此在使用功能状态更新形式时可以安全地访问。状态更新代码可以如下所示:

setHttpState(prevState => ({
  fetchedPosts: prevState.fetchedPosts,
  isLoading: prevState.fetchedPosts ? false : true,
  error: null
})); 

这个解决方案将有效。然而,通过 useState() 管理的越来越复杂(且嵌套)的状态对象通常是不希望的,因为它可以使状态管理变得有点困难,并膨胀你的组件代码。

因此,React 提供了 useState() 的一个替代方案:useReducer() Hook。

使用 useReducer() 管理状态

就像 useState() 一样,useReducer() 也是一个 React Hook。并且就像 useState() 一样,它是一个可以触发组件函数重新评估的 Hook。但是,当然,它的工作方式略有不同;否则,它将是一个多余的 Hook。

useReducer() 是一个 Hook,旨在用于管理复杂的状态对象。你很少(可能永远)会使用它来管理简单的字符串或数字值。

这个 Hook 采取两个主要参数:

  • Reducer 函数

  • 一个初始状态值

这引发了一个重要问题:什么是 reducer 函数?

理解 Reducer 函数

useReducer() 的上下文中,reducer 函数是一个接收两个参数的函数:

  • 当前状态值

  • 已分发的动作

除了接收参数外,reducer 函数还必须返回一个值:新的状态。它被称为 reducer 函数,因为它将旧状态(与一个动作结合)减少到新状态。

为了使这一切更容易理解并推理,以下代码片段展示了如何与这样的 reducer 函数结合使用 useReducer()

const initialHttpState = {
  data: null,
  isLoading: false,
  error: null,
};
function httpReducer(state, action) {
  if (action.type === 'FETCH_START') {
    return {
      ...state, // copying the existing state
      isLoading: state.data ? false : true,
      error: null,
    };
  }
  if (action.type === 'FETCH_ERROR') {
    return {
      data: null,
      isLoading: false,
      error: action.payload,
    };
  }
  if (action.type === 'FETCH_SUCCESS') {
    return {
      data: action.payload,
      isLoading: false,
      error: null,
    };
  }
  return initialHttpState; // default value for unknown actions
}
function App() {
  useReducer(httpReducer, initialHttpState);
  // more component code, not relevant for this snippet / explanation
} 

在这个片段的底部,你可以看到useReducer()是在App组件函数内部被调用的。像所有 React Hooks 一样,它必须在组件函数或其他 Hooks 内部调用。你还可以看到之前提到的两个参数(reducer 函数和初始状态值)被传递给useReducer()

httpReducer是 reducer 函数。该函数接受两个参数(state,即旧状态,和action,即发送的动作)并为不同的动作类型返回不同的状态对象。

这个 reducer 函数负责处理所有可能的状态更新。因此,整个状态更新逻辑都是从组件中外包出去的(注意,httpReducer是在组件函数外部定义的)。

但组件函数当然必须能够触发定义的状态更新。这就是动作变得重要的地方。

注意

在这个例子中,reducer 函数是在组件函数外部创建的。你也可以在组件函数内部创建它,但这样做并不推荐。如果你在组件函数内部创建 reducer 函数,它将每次组件函数执行时都会被重新创建。这会不必要地影响性能,因为 reducer 函数不需要访问任何组件函数的值(状态或属性)。

发送动作

之前显示的代码是不完整的。在组件函数中调用useReducer()时,它不仅仅接受两个参数。相反,这个 Hook 还返回一个值——一个恰好有两个元素的数组(就像useState()一样,尽管元素是不同的)。

因此,useReducer() 应该这样使用(在App组件中):

const [httpState, dispatch] = useReducer(
  httpReducer, 
  initialHttpState
); 

在这个片段中,使用数组解构将两个元素(总是恰好两个!)存储在两个不同的常量中:httpStatedispatch

返回数组中的第一个元素(在这个例子中是httpState)是 reducer 函数返回的状态值。每当 reducer 函数再次执行时,它就会被更新(这意味着 React 会调用组件函数)。在这个例子中,元素被称作httpState,因为它包含与 HTTP 请求相关的状态值。但说到底,你如何命名这个元素取决于你。

第二个元素(在这个例子中是dispatch)是一个函数。这是一个可以调用来触发状态更新(即再次执行 reducer 函数)的函数。当执行时,dispatch函数必须接收一个参数——即将在 reducer 函数内部(通过 reducer 函数的第二个参数)可用的动作值。以下是如何在组件中使用dispatch的示例:

dispatch({ type: 'FETCH_START' }); 

在这个例子中,元素被称作dispatch,因为它是一个用于向 reducer 函数发送动作的函数。就像之前一样,名称由你决定,但dispatch是一个常用的名称。

那个动作值的形状和结构完全取决于你,但通常设置为一个包含type属性的对象。type属性在 reducer 函数中用于执行不同类型的动作。因此,type充当动作标识符。你可以在httpReducer函数内部看到type属性的使用:

function httpReducer(state, action) {
  if (action.type === 'FETCH_START') {
    return {
      ...state, // copying the existing state
      isLoading: state.data ? false : true,
      error: null,
    };
  }
  if (action.type === 'FETCH_ERROR') {
    return {
      data: null,
      isLoading: false,
      error: action.payload,
    };
  }
  if (action.type === 'FETCH_SUCCESS') {
    return {
      data: action.payload,
      isLoading: false,
      error: null,
    };
  }
  return initialHttpState; // default value for unknown actions
} 

你可以根据需要向动作对象添加任意多的属性。在前面的例子中,一些状态更新通过访问action.payload从动作对象中提取一些额外数据。在一个组件内部,你会像这样传递数据与动作:

dispatch({ type: 'FETCH_SUCCESS', payload: posts }); 

再次强调,属性名(payload)由你决定,但将额外数据与动作一起传递允许你执行依赖于组件函数生成数据的状态更新。

这是整个App组件函数的完整、最终代码:

// code for httpReducer etc. did not change
function App() {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  // Using useCallback() to prevent an infinite loop in useEffect() 
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  return (
    <>
      <header>
        <h1>Complex State Blog</h1>
        <button onClick={fetchPosts}>Load Posts</button>
      </header>
      {httpState.isLoading && <p>Loading...</p>}
      {httpState.error && <p>{httpState.error}</p>}
      {httpState.data && <BlogPosts posts={httpState.data} />}
    </>
  );
} 

在这个代码片段中,你可以看到如何派发不同的动作(具有不同的type和有时payload属性)。你还可以看到httpState值被用来根据状态显示不同的用户界面元素(例如,如果httpState.isLoadingtrue,则显示<p>Loading…</p>)。

摘要和关键要点

  • 状态管理可能有其挑战——尤其是在处理跨组件(或全局)状态或复杂状态值时。

  • 跨组件状态可以通过提升状态或使用 React 的上下文 API 来管理。

  • 当你进行大量的属性钻探(通过 props 在多个组件层之间传递状态值)时,上下文 API 通常是首选的。

  • 当使用上下文 API 时,你使用createContext()来创建一个新的上下文对象。

  • 创建的上下文对象是一个必须包裹在应该访问上下文的组件树部分的组件。

  • 当使用 React 18 或更早版本时,上下文对象本身不是一个组件,而是一个提供嵌套Provider属性的对象,该属性是一个组件。

  • 组件可以通过use()(在 React 19 或更高版本中)或useContext()钩子访问上下文值。

  • 对于管理复杂状态值,useReducer()可以是一个比useState()更好的替代方案。

  • useReducer()利用一个将当前状态和已派发的动作转换为新的状态值的 reducer 函数。

  • useReducer()返回一个包含恰好两个元素的数组:状态值和一个派发函数,用于派发动作。

接下来是什么?

能够有效地管理简单和复杂的状态值非常重要。本章介绍了两个帮助完成这项任务的关键工具。

通过上下文 API 的use()useContext()useReducer()钩子,引入了三个新的 React 钩子。结合本书迄今为止涵盖的所有其他钩子,这些标志着作为 React 开发者日常工作中所需的最后一个 React 钩子。

尽管作为 React 开发者,你不仅限于内置的 Hooks,你还可以构建自己的 Hooks。下一章将最终探讨这是如何工作的,以及为什么你可能想要首先构建自定义 Hooks。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的理解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/11-complex-state/exercises/questions-answers.md中可以找到的示例进行比较:

  1. 使用 React 的上下文 API 可以解决哪些问题?

  2. 使用上下文 API 时,必须采取哪三个主要步骤?

  3. 在什么情况下,useReducer()可能比useState()更可取?

  4. 当使用useReducer()时,动作的作用是什么?

应用所学知识

将你对上下文 API 和useReducer() Hook 的知识应用于一些实际问题。

活动内容 11.1:将应用迁移到上下文 API

在这个活动中,你的任务是改进现有的 React 项目。目前,该应用没有使用上下文 API 构建,因此跨组件状态是通过提升状态来管理的。在这个项目中,某些组件出现了 prop drilling(属性钻取)的问题。因此,目标是调整应用,使其使用上下文 API 进行跨组件状态管理。

注意

您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-1-start找到此活动的起始代码。下载此代码时,您将始终下载整个存储库。请确保导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。

提供的项目还使用了之前章节中介绍的一些功能。花时间分析它并理解提供的代码。这是很好的实践,让你能够看到许多关键概念的实际应用。

下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,您可以通过npm run dev启动开发服务器。因此,访问localhost:5173时,您应该看到以下用户界面:

img

图 11.6:运行中的起始项目

要完成此活动,步骤如下:

  1. 为购物车项目创建一个新的上下文。

  2. 为上下文创建一个Provider组件,并在那里处理所有与上下文相关的状态变化。

  3. 提供上下文(借助Provider组件),并确保所有需要访问上下文的组件都能访问到。

  4. 移除旧逻辑(状态提升的地方)。

  5. 在所有需要访问上下文的组件中使用上下文。

完成活动后,用户界面应与图 11.6中所示的用户界面相同。确保用户界面与实现 React 的上下文功能之前完全一样。

注意

所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-1找到。

活动 11.2:用 useReducer()替换 useState()

在这个活动中,你的任务是替换Form组件中的useState()钩子为useReducer()。只使用一个单独的 reducer 函数(因此只调用一次useReducer()),并将所有相关的状态值合并到一个状态对象中。

注意

你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-2-start找到这个活动的起始代码。在下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这个例子中是activities/practice-2-start),以使用正确的代码快照。

提供的项目还使用了前面章节中介绍的一些功能。花时间分析它并理解提供的代码。这是很好的练习,让你能够看到许多关键概念的实际应用。

下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,你可以通过npm run dev启动开发服务器。结果,当你访问localhost:5173时,你应该看到以下用户界面:

img

图 11.7:运行中的起始项目

在提供的起始项目中,用户在点击提交按钮时会得到三个结果之一:

  1. 如果一个或两个输入字段没有接收任何输入,错误消息会告诉用户填写表单。

  2. 如果用户在两个输入字段中输入了值,但至少有一个输入包含无效值,则会显示不同的错误消息。

  3. 如果用户在两个输入字段中输入了有效的值,则输入的值将在开发者工具的 JavaScript 控制台中打印出来。

要完成这个活动,解决方案步骤如下:

  1. 删除(或注释掉)Form组件中使用useState()钩子进行状态管理的现有逻辑。

  2. 添加一个处理两个动作(电子邮件更改和密码更改)的 reducer 函数,并返回一个默认值。

  3. 根据派发的动作类型(如果需要,还有负载)更新状态对象。

  4. 使用useReducer()钩子与 reducer 函数。

  5. Form组件中派发适当的动作(带有适当的数据)。

  6. 在需要的地方使用状态值。

完成活动后,用户界面应与图 11.7中显示的相同。确保用户界面与您实现 React 的上下文功能之前完全一致。

注意

所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/11-complex-state/activities/practice-2找到。

第十二章:构建 Custom React 钩子

学习目标

到本章结束时,您将能够做到以下几件事情:

  • 构建您自己的 React 钩子

  • 在您的组件中使用自定义和默认 React 钩子

简介

在本书中,一个关键的 React 功能被反复以许多不同的变体引用。这个功能就是 React 钩子。

钩子几乎提供了 React 所有的核心功能和概念——从单个组件中的状态管理到在多个组件中访问跨组件状态(上下文)。它们允许您通过 refs 访问 JSX 元素,并允许您在组件函数内部处理副作用。

没有钩子,现代 React 就无法工作,构建功能丰富的应用程序将是不可能的。

到目前为止,只介绍了内置钩子并使用了它们。然而,您也可以构建自己的自定义钩子——或者您可以使用其他开发者构建的自定义钩子(例如,通过使用第三方库)。在本章中,您将了解为什么您可能想要这样做以及它是如何工作的。

介绍 Custom 钩子

在开始构建自定义钩子之前,了解自定义钩子究竟是什么非常重要。

在 React 应用中,自定义钩子是满足以下条件的常规 JavaScript 函数:

  • 函数名称以 use 开头(就像所有内置钩子一样以 use 开头:useState()useReducer() 等)。

  • 函数调用另一个 React 钩子(一个内置的或自定义的——无关紧要)。

  • 此函数不仅返回 JSX 代码(否则,它本质上就是一个 React 组件),尽管它可以返回一些 JSX 代码——只要这不是返回的唯一值。

如果一个函数满足以下三个条件,它可以(并且应该)被称为自定义(React)钩子。因此,自定义钩子实际上只是具有特殊名称(以 use 开头)的正常函数,这些函数调用其他(自定义或内置)钩子,并且不(仅)返回 JSX 代码。如果您在其他地方(例如,在函数外部或在常规的非钩子函数中)尝试调用(自定义或内置)钩子,您可能会收到警告(取决于您的项目设置;见下文)。

例如,以下函数使用了 useEffect() 钩子,但其名称不以 use 开头。因此,它不符合官方的命名建议:

function sendAnalyticsEvent(event) {
  useEffect(() => {
    fetch('https://my-analytics-backend.com/events', {
      method: 'POST',
      body: JSON.stringify(event)
    })
  }, []);
} 

在执行代码检查以查找规则违规的项目中,此代码将产生警告,因为此函数不符合自定义钩子的资格(由于其名称)。

img

图 12.1:React 会抱怨你在错误的位置调用钩子函数

正如警告所述,无论是自定义还是内置的钩子,都必须仅在组件函数内部调用。尽管警告消息没有明确提到,它们也可以在自定义钩子内部调用。

因此,如果将 sendAnalyticsEvent() 函数重命名为 useSendAnalyticsEvent() ,警告就会消失,因为现在该函数符合自定义钩子的资格。

尽管从技术上讲,这不是 React 本身强制执行的规定规则,但强烈建议遵循此命名约定。

能够构建自定义钩子是一个极其重要的功能,因为它意味着你可以构建包含状态逻辑的可重用非组件函数(通过useState()useReducer()),在你的可重用自定义钩子函数中处理副作用(通过useEffect()),或使用任何其他 React 钩子。使用正常的非钩子函数,这些都不可能实现,因此你将无法将这些涉及 React 钩子的任何逻辑外包到这样的函数中。

以这种方式,自定义钩子补充了 React 组件的概念。虽然 React 组件是可重用的 UI 构建块(可能包含状态逻辑),但自定义钩子是可重用的逻辑片段,可以在你的组件函数中使用。因此,自定义钩子帮助你跨组件重用共享逻辑。例如,自定义钩子使你能够将发送 HTTP 请求和处理相关状态(加载、错误等)的逻辑外包出去。

为什么你会构建自定义钩子?

在上一章(第十一章处理复杂状态)中,当介绍了useReducer()钩子时,提供了一个示例,其中钩子被用于发送 HTTP 请求。这里再次提供相关的最终代码:

const initialHttpState = {
  data: null,
  isLoading: false,
  error: null,
};
function httpReducer(state, action) {
  if (action.type === 'FETCH_START') {
    return {
      ...state, // copying the existing state
      isLoading: state.data ? false : true,
      error: null,
    };
  }
  if (action.type === 'FETCH_ERROR') {
    return {
      data: null,
      isLoading: false,
      error: action.payload,
    };
  }
  if (action.type === 'FETCH_SUCCESS') {
    return {
      data: action.payload,
      isLoading: false,
      error: null,
    };
  }
  return initialHttpState; // default value for unknown actions
}
function App() {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  return (
    <>
      <header>
        <h1>Complex State Blog</h1>
        <button onClick={fetchPosts}>Load Posts</button>
      </header>
      {httpState.isLoading && <p>Loading...</p>}
      {httpState.error && <p>{httpState.error}</p>}
      {httpState.data && <BlogPosts posts={httpState.data} />}
    </>
  );
}; 

在这个代码示例中,每当App组件首次渲染时,就会发送一个 HTTP 请求。HTTP 请求获取一个(虚拟的)帖子列表。在请求完成之前,向用户显示一个加载消息(<p>Loading…</p>)。如果有错误,则显示错误消息。

如您所见,必须编写相当多的代码来处理这个相对基本的使用场景。特别是在更大的 React 应用程序中,很可能多个组件需要发送 HTTP 请求。它们可能不需要发送到相同 URL(例如,https://jsonplaceholder.typicode.com/posts)的完全相同的请求,但肯定有可能不同的组件会从不同的 URL 获取不同的数据。

因此,几乎完全相同的代码必须在多个组件中反复编写。这不仅仅是发送 HTTP 请求的代码(即由useCallback()包装的函数)。相反,HTTP 相关的状态管理(在本例中通过useReducer()完成),以及通过useEffect()进行的请求初始化,必须在所有这些组件中重复。

正是自定义钩子(Custom Hooks)在这里拯救了局面。自定义钩子通过允许你构建可重用、可能具有状态的“逻辑片段”,这些片段可以在组件之间共享,帮助你避免这种重复。

第一个自定义钩子

在探索高级场景和解决之前提到的 HTTP 请求问题之前,这里有一个更基本的第一个自定义钩子的例子:

import { useState } from 'react';
function useCounter() {
  const [counter, setCounter] = useState(0);
  function increment() {
    setCounter(oldCounter => oldCounter + 1);
  };
  function decrement() {
    setCounter(oldCounter => oldCounter - 1);
  };
  return { counter, increment, decrement };
};
export default useCounter; 

这段代码可以存储在 hooks/ 文件夹内的一个名为 use-counter.js 的文件中——尽管这两个名称完全由您决定。关于文件名或文件夹名(或一般而言,存储此代码的位置)没有规则。由于此文件不包含 JSX 代码,因此文件扩展名是 .js 而不是 .jsx

如您所见,useCounter 是一个常规的 JavaScript 函数。函数名以 use 开头,因此这个函数符合自定义钩子的标准(这意味着在它内部使用其他钩子时,您不会收到任何警告信息)。

useCounter() 内部,通过 useState() 管理一个 counter 状态。状态通过两个嵌套函数(incrementdecrement)进行更改,状态以及函数都由 useCounter 返回(在 JavaScript 对象中一起分组)。

注意

counterincrementdecrement 一起分组的语法使用了常规的 JavaScript 功能:简写属性名。

如果一个对象的属性名与分配给该属性的变量的名称完全匹配,您可以使用这种简短的表示法。

而不是编写 { counter: counter, increment: increment, decrement: decrement },您可以使用上面片段中显示的简写表示法 { counter, increment, decrement }

这个自定义钩子可以存储在单独的文件中(例如,在 React 项目的 hooks 文件夹中,如 src/hooks/use-counter.js)。之后,它可以在任何 React 组件中使用,并且您可以在所需的任何数量的 React 组件中使用它。

例如,以下两个组件(Demo1Demo2)可以这样使用这个 useCounter 钩子:

import useCounter from './hooks/use-counter.js';
function Demo1() {
  const { counter, increment, decrement } = useCounter();
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
};
function Demo2() {
  const { counter, increment, decrement } = useCounter();
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
};
function App() {
  return (
    <main>
      <Demo1 />
      <Demo2 />
    </main>
  );
};
export default App; 

注意

您可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/01-first-hook 找到完整的示例代码。

Demo1Demo2 组件都在它们的组件函数内部执行 useCounter()useCounter() 函数被称为普通函数,因为它是一个常规的 JavaScript 函数。

由于 useCounter 钩子返回一个包含三个属性(counterincrementdecrement)的对象,Demo1Demo2 使用对象解构将属性值存储在局部常量中。然后,这些值在 JSX 代码中用于输出 counter 值,并将两个 <button> 元素连接到 incrementdecrement 函数。

按钮按了几次之后,最终的用户界面可能看起来像这样:

日历的截图 自动生成描述

图 12.2:两个独立的计数器

在这个屏幕截图上,你还可以看到一个非常有趣且重要的自定义钩子行为。那就是,如果多个组件中使用了相同的具有状态的自定义钩子,每个组件都会得到自己的状态。counter状态是不共享的。Demo1组件通过useCounter()自定义钩子管理自己的counter状态,Demo2组件也是如此。

自定义钩子:一个灵活的功能

Demo1Demo2的两个独立状态展示了自定义钩子的一个非常重要的特性:你使用它们来共享逻辑,而不是状态。如果你需要在组件之间共享状态,你将使用 React 上下文(参见上一章)。

当使用钩子时,每个组件都会使用该钩子的自己的“实例”(或“版本”)。逻辑始终相同,但钩子处理的任何状态或副作用都是基于每个组件的。

还有一点也值得注意,自定义钩子可以有状态,但不一定必须有状态。它们可以通过useState()useReducer()来管理状态,但你也可以构建只处理副作用(没有任何状态管理)的自定义钩子。

在自定义钩子中,你隐式地必须做的一件事是:你必须使用其他 React 钩子(自定义或内置的)。这是因为如果你没有包含任何其他钩子,那么最初就没有必要构建自定义钩子。自定义钩子只是一个普通的 JavaScript 函数(以use开头命名),允许你使用其他钩子。如果你不需要使用任何其他钩子,你可以简单地构建一个不以use开头的普通 JavaScript 函数。

你在钩子内部的逻辑、其参数以及它返回的值方面也有很多灵活性。关于钩子逻辑,你可以添加所需的任何逻辑。你可以管理没有状态或多个状态值。你可以包含其他自定义钩子或仅使用内置钩子。你可以管理多个副作用,与 refs 一起工作,或执行复杂的计算。在自定义钩子中可以做的事情没有限制。

自定义钩子和参数

你也可以在自定义钩子函数中接受和使用参数。例如,第一个自定义钩子部分的useCounter钩子可以被调整以接受一个初始计数器值和计数器应该增加或减少的单独值,如下面的代码片段所示:

import { useState } from 'react';
function useCounter(initialValue, incVal, decVal) {
  const [counter, setCounter] = useState(initialValue);
  function increment() {
    setCounter(oldCounter => oldCounter + incVal);
  };
  function decrement() {
    setCounter(oldCounter => oldCounter - decVal);
  };
  return { counter, increment, decrement };
};
export default useCounter; 

在这个调整后的示例中,initialValue参数用于通过useState(initialValue)设置初始状态。incValdecVal参数用于incrementdecrement函数中,以不同的值改变counter状态。

当然,一旦在自定义钩子中使用了参数,在组件函数(或在另一个自定义钩子)中调用自定义钩子时,必须提供合适的参数值。因此,Demo1Demo2组件的代码也必须进行调整——例如,如下所示:

function Demo1() {
  const { counter, increment, decrement } = useCounter(1, 2, 1);
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
};
function Demo2() {
  const { counter, increment, decrement } = useCounter(0, 1, 2);
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
}; 

注意

您也可以在 GitHub 上找到此代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/02-parameters

现在,两个组件将不同的参数值传递给useCounter钩子函数。因此,它们可以动态地重用相同的钩子和其内部逻辑。

自定义钩子和返回值

useCounter所示,自定义钩子可以返回值。而且这是重要的:它们可以返回值,但不必这样做。如果您构建了一个仅处理一些副作用(通过useEffect())的自定义钩子,您不必返回任何值(因为可能没有应该返回的值)。

但如果您确实需要返回一个值,您决定您想要返回哪种类型的值。您可以返回一个单独的数字或字符串。如果您的钩子必须返回多个值(如useCounter所做的那样),您可以将这些值组合成一个数组或对象。您还可以返回包含对象的数组或反之亦然。简而言之,您可以返回任何内容。毕竟,它是一个正常的 JavaScript 函数。

一些内置钩子,如useState()useReducer(),返回数组(具有固定数量的元素)。另一方面,useRef()返回一个对象(它始终具有一个current属性)。useEffect()不返回任何值。因此,您的钩子可以返回您想要的任何内容。

例如,之前提到的useCounter钩子可以被重写为返回一个数组:

import { useState } from 'react';
function useCounter(initialValue, incVal, decVal) {
  const [counter, setCounter] = useState(initialValue);
  function increment() {
    setCounter((oldCounter) => oldCounter + incVal);
  }
  function decrement() {
    setCounter((oldCounter) => oldCounter - decVal);
  }
  return [counter, increment, decrement];
}
export default useCounter; 

要使用返回的值,Demo1Demo2组件需要从对象解构切换到数组解构,如下所示:

function Demo1() {
  const [counter, increment, decrement] = useCounter(1, 2, 1);
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
}
function Demo2() {
  const [counter, increment, decrement] = useCounter(0, 1, 2);
  return (
    <>
      <p>{counter}</p>
      <button onClick={increment}>Inc</button>
      <button onClick={decrement}>Dec</button>
    </>
  );
} 

两个组件的行为与之前相同,因此您可以决定您更喜欢哪种返回值。

注意

这段完成后的代码也可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/examples/03-return-values

一个更复杂的例子

之前的例子故意比较简单。现在,自定义钩子的基础知识已经清楚,深入一个稍微复杂和现实的例子是有意义的。

考虑本章开头提到的 HTTP 请求示例:

const initialHttpState = {
  data: null,
  isLoading: false,
  error: null,
};
function httpReducer(state, action) {
  if (action.type === 'FETCH_START') {
    return {
      ...state, // copying the existing state
      isLoading: state.data ? false : true,
      error: null,
    };
  }
  if (action.type === 'FETCH_ERROR') {
    return {
      data: null,
      isLoading: false,
      error: action.payload,
    };
  }
  if (action.type === 'FETCH_SUCCESS') {
    return {
      data: action.payload,
      isLoading: false,
      error: null,
    };
  }
  return initialHttpState; // default value for unknown actions
}
function App() {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  return (
    <>
      <header>
        <h1>Complex State Blog</h1>
        <button onClick={fetchPosts}>Load Posts</button>
      </header>
      {httpState.isLoading && <p>Loading...</p>}
      {httpState.error && <p>{httpState.error}</p>}
      {httpState.data && <BlogPosts posts={httpState.data} />}
    </>
  );
}; 

在那个例子中,整个useReducer()逻辑(包括 reducer 函数httpReducer)和useEffect()调用都可以外包到一个自定义钩子中。结果将是一个非常精简的App组件和一个可重用的钩子,它也可以在其他组件中使用。

构建自定义钩子的第一个版本

这个自定义钩子可以命名为useFetch(因为它用于获取数据),并且它可以存储在hooks/use-fetch.js中。当然,钩子名称以及文件存储路径由您决定。以下是useFetch的第一个版本可能看起来像这样:

import { useCallback, useEffect, useReducer } from 'react';
const initialHttpState = {
  data: null,
  isLoading: false,
  error: null,
};
function httpReducer(state, action) {
  // same reducer code as before
}
function useFetch() {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
}
export default useFetch; 

请注意,这并非最终版本。

在这个第一个版本中,useFetch 钩子包含了 useReducer()useEffect() 逻辑。值得注意的是,httpReducer 函数是在 useFetch 外部创建的。这确保了当 useFetch() 重新执行时(这将在使用此钩子的组件重新评估时经常发生),函数不会被不必要地重新创建。因此,httpReducer 函数将只创建一次(对于整个应用程序的生命周期),并且相同的函数实例将由所有使用 useFetch 的组件共享。

由于 httpReducer 是一个纯函数(即,它总是基于参数值产生新的返回值),共享这个函数实例是可以的,并且不会引起任何意外的错误。如果 httpReducer 要存储或操作任何不是基于函数输入的值,它应该创建在 useFetch 内部。这样,你可以避免多个组件意外地操作和使用共享值。

然而,这个版本的 useFetch 钩子有两个主要问题:

  • 目前没有返回任何值。因此,使用这个钩子的组件将无法访问获取的数据或加载状态。

  • HTTP 请求 URL 被硬编码到 useFetch 中。因此,所有使用这个钩子的组件都会向相同的 URL 发送相同类型的请求。

因此,为了改进这个钩子,必须解决这两个问题——从第一个问题开始。

通过返回值使钩子变得有用

第一个问题可以通过返回获取的数据(如果没有获取数据,则为 undefined)、加载状态值和错误值来解决。由于这些值正好是 useReducer() 返回的 httpState 对象的组成部分,useFetch 可以简单地返回整个 httpState 对象,如下所示:

// httpReducer function and initial state did not change, 
// hence omitted here
function useFetch() {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, []);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  **return** **httpState;**
} 

在这个代码片段中,唯一改变的是 useFetch 函数的最后一行。通过 return httpStateuseReducer()(因此是 httpReducer 函数)管理的状态由自定义钩子返回。

在解决了第一个问题之后,下一步是也要使钩子更具可重用性。

通过接受输入参数提高可重用性

为了修复第二个问题(即硬编码的 URL),应向 useFetch 添加一个参数:

// httpReducer function and initial state did not change, hence omitted here
function useFetch(**url**) {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
  );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(**url**);
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, [**url**]);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  return httpState;
} 

在这个片段中,url 参数被添加到了 useFetch 中。这个参数值随后在调用 fetch(url) 时在 try 块内使用。请注意,url 也被添加到了 useCallback() 依赖数组中。

由于useCallback()被包装在获取函数周围(以防止useEffect()造成的无限循环),useCallback()内部使用的任何外部值都必须添加到其依赖项数组中。由于url是外部值(意味着它不在包装函数内部定义),因此必须添加。这在逻辑上也是合理的:如果url参数发生变化(即,如果使用useFetch的组件更改它),则应发送新的 HTTP 请求。

useFetch钩子的最终版本现在可以在所有组件中使用,以向不同的 URL 发送 HTTP 请求,并按组件所需使用 HTTP 状态值。

例如,App组件可以这样使用useFetch

import BlogPosts from './components/BlogPosts.jsx';
import useFetch from './hooks/use-fetch.js';
function App() {
  const { data, isLoading, error } = useFetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  return (
    <>
      <header>
        <h1>Complex State Blog</h1>
      </header>
      {isLoading && <p>Loading...</p>}
      {error && <p>{error}</p>}
      {data && <BlogPosts posts={data} />}
    </>
  );
}
export default App; 

组件导入并调用useFetch()(以适当的 URL 作为参数),并使用对象解构从httpState对象中获取dataisLoadingerror属性。然后,这些值在 JSX 代码中使用。

当然,useFetch钩子也可以返回指向fetchPosts函数的指针(除了httpState),以允许像App组件这样的组件手动触发新的请求,如下所示:

// httpReducer function and initial state did not change, hence omitted here
function useFetch(url) {
  const [httpState, dispatch] = useReducer(
    httpReducer, 
    initialHttpState
   );
  const fetchPosts = useCallback(async function fetchPosts() {
    dispatch({ type: 'FETCH_START' });
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error('Failed to fetch posts.');
      }
      const posts = await response.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, [url]);
  useEffect(
    function () {
      fetchPosts();
    },
    [fetchPosts]
  );
  **return** **[ httpState, fetchPosts ];**
} 

在此示例中,return语句已更改。现在useFetch返回一个包含httpState对象和指向fetchPosts函数的指针的数组。或者,httpStatefetchPosts可以合并到一个对象中(而不是数组)。

App组件中,现在可以这样使用useFetch

import BlogPosts from './components/BlogPosts.jsx';
import useFetch from './hooks/use-fetch.js';
function App() {
  const [{ data, isLoading, error }, **fetchPosts**] = useFetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  return (
    <>
      <header>
        <h1>Complex State Blog</h1>
        **<****button****onClick****=****{fetchPosts}****>****Load Posts****</****button****>**
      </header>
      {isLoading && <p>Loading...</p>}
      {error && <p>{error}</p>}
      {data && <BlogPosts posts={data} />}
    </>
  );
}
export default App; 

App组件使用数组和对象解构结合提取返回的值(以及嵌套在httpState对象中的值)。然后,添加了一个新的<button>元素来触发fetchPosts函数。

此示例有效地展示了自定义钩子如何通过允许轻松的逻辑重用,无论是否有状态或副作用,从而使得组件函数更加精简。

此外,钩子还可以启用一些有趣的模式——例如,与 React 的 Context API 相关。

使用自定义钩子进行上下文访问

如前一章中所述,在将上下文逻辑外包到单独组件中部分,你可以使用自定义钩子来改进在组件中消费上下文值的过程。

例如,如果你提供一些名为BookmarkContext的上下文(例如,通过<BookmarkContextProvider>组件),你可以在组件内部这样访问此上下文值:

import { use } from 'react';
import BookmarkContext from '../../store/bookmark-context.jsx';
function BookmarkSummary() {
  const bookmarkCtx = use(BookmarkContext);
  // other component code, including returned JSX code
} 

然而,你不必像这样直接访问上下文值,也可以构建以下自定义钩子(例如,存储在store/use-bookmark-context.js文件中):

import { use } from 'react';
import BookmarkContext from './bookmark-context.jsx';
function useBookmarkContext() {
  const bookmarkCtx = use(BookmarkContext);
  return bookmarkCtx;
}
export default useBookmarkContext; 

但,当然,这个钩子与通过use()在组件中直接消费上下文值相比,并没有提供任何优势。

一旦你用更多有用的逻辑丰富了此自定义钩子——例如,如果它在没有上下文可用的地方使用,则包含错误处理:

function useBookmarkContext() {
  const bookmarkCtx = use(BookmarkContext);
  if(!bookmarkCtx) {
    throw new Error('BookmarkContext must be provided!')
  }
  return bookmarkCtx;
} 

这个钩子然后可以在你的组件中使用,以获取上下文值,如下所示:

import useBookmarkContext from '../../store/use-bookmark-context.js';
function BookmarkSummary() {
  const bookmarkCtx = useBookmarkContext();
  // other component code, including returned JSX code
} 

因此,这不仅仅是一个自定义钩子的例子,而且是一个你应该了解的常见模式。这是一个在许多 React 项目中使用的模式,因为它确保你不会意外地在一个无法访问上下文值的地方尝试使用上下文值(即在未被BookmarkContextProvider包裹的组件中)。

当然,这不是你必须使用的模式。但这是你可以考虑使用以在错误位置访问上下文时获得早期错误的一种方法。如果你正在分发暴露一些上下文的库,那么这是一个特别有用的模式,因为它会在你的库用户忘记提供上下文时发出警告。

摘要和关键要点

  • 你可以创建自定义钩子来外包和重用依赖于其他内置或自定义钩子的逻辑。

  • 自定义钩子是名称以use开头的常规 JavaScript 函数。

  • 自定义钩子可以调用任何其他钩子。

  • 因此,自定义钩子可以管理状态或执行副作用。

  • 所有组件都可以通过像调用任何其他(内置)钩子一样调用它们来使用自定义钩子。

  • 当多个组件使用相同的自定义钩子时,每个组件都会接收到自己的“实例”(即,自己的状态值等)。

  • 在自定义钩子内部,你可以接受任何参数值并返回你选择的任何值。

接下来是什么?

自定义钩子是 React 的一个关键特性,因为它们帮助你编写更精简的组件,并在它们之间重用(有状态的)逻辑。尤其是在构建更复杂的 React 应用(由数十个甚至数百个组件组成)时,自定义钩子可以使代码更加易于管理。

结合组件、属性、状态(通过useState()useReducer())、副作用以及在本章和前几章中涵盖的所有其他概念,你现在拥有了一个非常坚实的基础,这使你能够构建生产就绪的 React 应用。因此,你现在准备深入更高级的 React 概念以及你应该了解的关键第三方包。

例如,大多数 React 应用不仅仅由一个单独的页面组成——相反,至少在大多数网站上,用户应该能够在多个页面之间切换。例如,在线商店有一个产品列表、产品详情页面、购物车页面以及许多其他页面。

因此,下一章将探讨如何使用 React 和流行的 React Router 第三方包构建这样的多页应用。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的理解。然后你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/12-custom-hooks/exercises/questions-answers.md找到的示例进行比较。:

  1. 自定义钩子的定义是什么?

  2. 在自定义钩子内部可以使用哪些特殊功能?

  3. 当多个组件使用相同的自定义钩子时会发生什么?

  4. 如何使自定义钩子更具可重用性?

应用所学知识

应用你对自定义钩子的知识。

活动第 12.1 节:构建自定义键盘输入钩子

在这个活动中,你的任务是重构一个提供的组件,使其更加精简,不再包含任何状态或副作用逻辑。相反,你应该创建一个包含该逻辑的自定义钩子。这个钩子随后可能也可以在 React 应用程序的其他区域使用。

注意

你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/activities/practice-1-start找到这个活动的起始代码。在下载此代码时,你将始终下载整个仓库。请确保导航到包含起始代码的子文件夹(在这个例子中是activities/practice-1-start),以使用正确的代码快照。

提供的项目还使用了前面章节中介绍的一些许多功能。花时间分析它并理解提供的代码。这是一个很好的练习,让你看到许多关键概念的实际应用。

下载代码后,在项目文件夹中运行npm install以安装所有必需的依赖项,然后可以通过npm run dev启动开发服务器。结果,访问localhost:5173时,你应该看到以下用户界面:

一个标志的特写  自动生成的描述

图 12.3:正在运行的项目起始状态

要完成这个活动,解决方案步骤如下:

  1. src/hooks文件夹中创建一个新的自定义钩子文件,并在该文件中创建一个钩子函数。

  2. 将副作用和状态管理逻辑移动到那个新的钩子函数中。

  3. 通过接受和使用一个控制允许哪些键的参数来使自定义钩子更具可重用性。

  4. 返回自定义钩子管理的状态。

  5. App组件中使用自定义钩子和其返回的值。

完成活动后,用户界面应保持不变,但App组件的代码应发生变化。完成活动后,App应只包含以下代码:

function App() {
  const pressedKey = useKeyEvent(['s', 'c', 'p']); // this is your Hook!
  let output = '';
  if (pressedKey === 's') {
    output = '';
  } else if (pressedKey === 'c') {
    output = '';
  } else if (pressedKey === 'p') {
    output = '';
  }
  return (
    <main>
      <h1>Press a key!</h1>
      <p>
        Supported keys: <kbd>s</kbd>, <kbd>c</kbd>, <kbd>p</kbd>
      </p>
      <p id="output">{output}</p>
    </main>
  );
} 

注意

所有用于此活动的代码文件以及一个示例解决方案,可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/12-custom-hooks/activities/practice-1找到。

第十三章:使用 React Router 的多页应用程序

学习目标

到本章结束时,你将能够做到以下事情:

  • 构建多页单页应用程序(以及理解这并不是一个矛盾的说法)

  • 使用 React Router 包为不同的 URL 路径加载不同的 React 组件

  • 创建静态和动态路由(以及首先了解什么是路由)

  • 通过链接和程序性命令导航网站

  • 构建嵌套页面布局

简介

在完成本书的前十二章后,你现在应该知道如何构建 React 组件和 Web 应用程序,以及如何管理组件和全局状态,以及如何在组件之间共享数据(通过 props 或 context)。

尽管你知道如何从多个组件中组合 React 网站,但所有这些组件都在同一个单页网站上。当然,你可以有条件地显示组件和内容,但用户永远不会切换到不同的页面。这意味着 URL 路径永远不会改变;用户将始终停留在 your-domain.com。此外,到目前为止,你的 React 应用程序不支持任何路径,如 your-domain.com/productsyour-domain.com/blog/latest

注意

统一资源定位符URLs)是网络资源的引用。例如,academind.com/courses 是一个指向作者网站特定页面的 URL。在这个例子中,academind.com 是网站的 域名/courses 是指向特定网站页面的 路径

对于 React 应用程序来说,加载的网站路径从不改变可能是有意义的。毕竟,在 第一章 中,React – 什么是以及为什么,你学习了使用 React 构建 单页应用程序SPAs)。

但尽管这可能是有意义的,但它也是一个相当严重的限制。

一页不够

只有一个页面意味着那些通常由多个页面组成(例如,包含产品、订单等页面的在线商店)的复杂网站很难用 React 构建。没有多个页面,你不得不退而求其次,使用状态和条件值在屏幕上显示不同的内容。

但如果没有改变 URL 路径,你的网站访客只能分享指向网站起始页的链接。此外,当新访客访问该起始页时,任何有条件加载的内容都会丢失。如果用户简单地重新加载他们当前所在的页面,情况也是如此。重新加载会获取页面的新版本,因此任何状态(以及因此用户界面)的变化都会丢失。

由于这些原因,对于大多数 React 网站,你绝对需要在单个 React 应用程序中包含多个页面(具有不同的 URL 路径)。多亏了现代浏览器功能和高度流行的第三方包,这确实可能实现(并且对于大多数 React 应用程序来说是默认的)。

通过 React Router 包,你的 React 应用可以监听 URL 路径的变化,并为不同的路径显示不同的组件。例如,你可以定义以下路径-组件映射:

  • <domain>/ => 加载 <Home /> 组件。

  • <domain>/products => 加载 <ProductList /> 组件。

  • <domain>/products/p1 => 加载 <ProductDetail /> 组件。

  • <domain>/about => 加载 <AboutUs /> 组件。

从技术上讲,它仍然是一个单页应用(SPA),因为仍然只向网站用户发送了一个 HTML 页面。但在那个单页 React 应用中,不同的组件是由 React Router 包根据访问的具体 URL 路径条件性地渲染的。作为应用的开发者,你不需要手动管理这种状态或条件性地渲染内容——React Router 会为你处理。此外,你的网站能够处理不同的 URL 路径,因此,单个页面可以被共享或重新加载。

React Router 入门与定义路由

React Router 是一个可以在任何 React 项目中安装的第三方 React 库。一旦安装,你就可以在你的代码中使用各种组件来启用上述功能。

在你的 React 项目内部,通过以下命令安装该包:

npm install react-router-dom 

安装完成后,你可以导入并使用该库中的各种组件(和 Hooks)。

要开始在你的 React 应用中支持多页,你需要通过以下步骤设置 路由

  1. 为你的不同页面创建不同的组件(例如,DashboardOrders 组件)。

  2. 使用 React Router 库中的 createBrowserRouter() 函数和 RouterProvider 组件来启用路由并定义 React 应用应支持的 路由

在这个上下文中,术语 路由 指的是 React 应用能够为不同的 URL 路径加载不同的组件(例如,为 //orders 路径加载不同的组件)。路由是一个添加到 React 应用的定义,它定义了应该渲染预定义 JSX 片段的 URL 路径(例如,对于 /orders 路径,应该加载 Orders 组件)。

在一个包含 DashboardOrders 组件的示例 React 应用中,并且通过 npm install 安装了 React Router 库,你可以通过编辑根组件(在 src/App.jsx 中)来启用这两个组件之间的路由和导航,如下所示:

**import** **{** 
  **createBrowserRouter,** 
  **RouterProvider**
**}** **from****'react-router-dom'****;**
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
**const** **router =** **createBrowserRouter****([**
  **{** **path****:** **'/'****,** **element****:** **<****Dashboard** **/>** **},**
  **{** **path****:** **'/orders'****,** **element****:** **<****Orders** **/>** **}**
**]);**
function App() {
  return **<****RouterProvider****router****=****{router}** **/>**;
}
export default App; 

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/01-getting-started-with-routing

在前面的代码片段中,React Router 的 createBrowserRouter() 函数被调用以创建一个包含应用程序的路由配置(可用路由列表)的 router 对象。传递给 createBrowserRouter() 的数组包含路由定义对象,其中每个对象定义了一个应匹配的 path 以及应渲染的 element

然后,使用 React Router 的 RouterProvider 组件来设置 router 配置并定义一个用于渲染活动路由元素的位置。

您可以将 <RouterProvider /> 元素视为一旦路由变为活动状态,就被通过 element 属性定义的内容所替换。因此,RouterProvider 组件的位置很重要。在这种情况下(以及可能的大多数 React 应用程序),它应该是根应用程序组件——即 React Router,它应该控制整个应用程序组件树。

如果您运行提供的示例 React 应用程序(通过 npm run dev),您将在屏幕上看到以下输出:

img

图 13.1:仪表板组件内容已加载

如果您访问 localhost:5173,屏幕上会显示 Dashboard 组件的内容。请注意,可见的页面内容并未在 App 组件(在之前共享的代码片段中)中定义。相反,只添加了两个路由定义:一个用于 / 路径(即 localhost:5173/ 或仅 localhost:5173,不带尾随正斜杠——它以相同的方式处理)和一个用于 /orders 路径(localhost:5173/orders)。

注意

localhost 是一个通常用于开发的本地地址。当您部署您的 React 应用程序(即,您将其上传到 Web 服务器)时,您将收到不同的域名——或者分配一个自定义域名。无论如何,部署后它将不再是 localhost

localhost 之后的部分(:5173)定义了请求将被发送到的网络端口。如果没有额外的端口信息,将自动使用端口 80443(作为默认的 HTTP(S) 端口)。然而,在开发期间,这些并不是您想要的端口。相反,您通常会使用 517380008080 这样的端口,因为这些端口通常不会被任何其他系统进程占用,因此可以安全使用。通过 Vite 创建的项目通常使用端口 5173

由于 localhost:5173 默认加载(当运行 npm run dev 时),第一个路由定义({ path: '/', element: <Dashboard /> })变为活动状态。此路由处于活动状态是因为其路径('/')与 localhost:5173 的路径匹配(因为这与 localhost:5173/ 相同)。

因此,通过 element 定义的 JSX 代码替换了 <RouterProvider> 组件。在这种情况下,这意味着 Dashboard 组件的内容被显示,因为此路由定义的 element 属性值是 <Dashboard />。在示例中,使用单个组件(如 <Dashboard />)是很常见的,但你也可以将任何 JSX 内容设置为 element 属性的值。

在前面的例子中,没有显示复杂的页面。相反,屏幕上只显示了一些文本。不过,在本章的后面部分,这将会改变。

但如果你在浏览器地址栏中将 URL 从 localhost:5173 手动更改为 localhost:5173/orders,这会变得有趣。在任何前面的章节中,这都不会改变页面内容。但现在,由于启用了路由并且定义了适当的路由,页面内容确实发生了变化,如下所示:

img

图 13.2:对于 /orders,显示 Orders 组件的内容

一旦 URL 发生变化,Orders 组件的内容就会显示在屏幕上。在这个第一个例子中,它仍然是基本的文本,但它表明对于不同的 URL 路径,会渲染不同的代码。

然而,这个基本例子有一个主要的缺陷(除了相当无聊的页面内容)。目前,用户必须手动输入 URL。但当然,这不是通常使用网站的方式。

添加页面导航

为了允许用户在不手动编辑浏览器地址栏的情况下在网站的不同页面之间切换,网站通常包含链接,通常通过 <a> HTML 元素(锚元素)添加,如下所示:

<a href="/orders">Past Orders</a> 

对于这个例子,可以通过修改 Dashboard 组件代码来添加页面导航,如下所示:

function Dashboard() {
  return (
    <>
      <h1>The "Dashboard" route component</h1>
      **<****p****>****Go to the** **<****a****href****=****"/orders"****>****Orders page****</****a****>****.****</****p****>**
      {/* <p> elements omitted */}
    </>
  );
}
export default Dashboard; 

在这个代码片段中,已添加了对 /orders 路由的链接。因此,网站访客现在看到的是这个页面:

img

图 13.3:添加了导航链接

因此,当网站用户点击这个链接时,他们会进入 /orders 路由,并且 Orders 组件的内容会显示在屏幕上。

这种方法可行,但有一个主要的缺陷:每次用户点击链接时,网站都会重新加载。你可以通过点击链接时浏览器刷新图标变为一个叉号(短暂地)来判断页面正在重新加载。

这是因为每当点击链接时,浏览器都会向服务器发送一个新的 HTTP 请求。尽管服务器总是返回相同的单个 HTML 页面,但在那个过程中页面会重新加载(因为发送了新的 HTTP 请求)。

虽然在这个简单的演示页面上这并不是问题,但如果你有某些共享状态(例如,通过上下文管理的全局状态)不应该在页面更改时重置,那么这就会成为一个问题。此外,每次新的请求都会花费时间,并迫使浏览器重新下载所有网站资源(例如,脚本文件)。即使这些文件可能被缓存,这也是一个不必要的步骤,可能会影响网站性能。

下面的略微调整后的 App 组件示例说明了状态重置问题:

**import** **{ useState }** **from****'react'****;**
import { 
  createBrowserRouter, 
  RouterProvider 
} from 'react-router-dom';
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
  { path: '/', element: <Dashboard /> },
  { path: '/orders', element: <Orders /> },
]);
function App() {
  **const** **[counter, setCounter] =** **useState****(****0****);**
  **function****handleIncCounter****() {**
    **setCounter****(****(****prevCounter****) =>** **prevCounter +** **1****);**
  **}**
  return (
    <>
      **<****p****>**
        **<****button****onClick****=****{handleIncCounter}****>****Increase Counter****</****button****>**
      **</****p****>**
      **<****p****>****Current Counter:** **<****strong****>****{counter}****</****strong****></****p****>**
      <RouterProvider router={router} />
    </>
  );
}
export default App; 

注意

本例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/03-naive-navigation-problem 找到。

在本例中,一个简单的计数器被添加到了 App 组件中。由于 <RouterProvider> 在同一个组件中被渲染,在计数器下方,当用户访问不同的页面时,不应替换 App 组件(相反,应该替换 <RouterProvider> 而不是整个 App 组件的 JSX 代码)。

至少,这是理论上的情况。但是,正如你在下面的屏幕截图中所看到的,每次点击任何链接时,counter 状态都会丢失:

img

图 13.4:切换页面时计数器状态被重置

在屏幕截图中,你可以看到计数器最初被设置为 3(因为按钮被点击了三次)。在从 Dashboard 页面导航到 Orders 页面(通过点击 Orders page 链接)后,计数器变为 0

这是因为页面因浏览器发送的 HTTP 请求而重新加载。

为了解决这个问题并避免这种意外的页面重新加载,你必须阻止浏览器默认行为。而不是发送新的 HTTP 请求,浏览器 URL 地址应该只更新(从 localhost:5173 更新到 localhost:5173/orders),并且应该加载目标组件(Orders)。因此,对于网站用户来说,这看起来就像加载了不同的页面。但在幕后,只是页面文档(DOM)被更新了。

幸运的是,你不必自己实现这个逻辑。相反,React Router 库公开了一个特殊的 Link 组件,应该用它来代替锚 <a> 元素。

要使用这个新组件,src/routes/Dashboard.jsx 中的代码必须进行调整如下:

**import** **{** **Link** **}** **from****'react-router-dom'****;**
function Dashboard() {
  return (
    <>
      <h1>The "Dashboard" route component</h1>
      <p>Go to the **<****Link****to****=****"/orders"****>****Orders page****</****Link****>**.</p>
      <p>
        This component could display the user dashboard 
        of some web shop.
      </p>
      <p>It's just a dummy example here, but you get the point.</p>
      <p>
        It's worth noting, that it's a regular React component 
        that's activated by React Router because of the 
        active route configuration.
      </p>
    </>
  );
}
export default Dashboard; 

注意

本例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/04-react-router-navigation 找到。

在这个更新后的示例中,使用了新的 Link 组件。该组件需要一个 to 属性,用于定义应该加载的 URL 路径。

通过使用此组件代替 <a> 锚点元素,计数器状态不再重置。这是因为 React Router 现在阻止了浏览器的默认行为(即上述描述的不希望的页面重新加载)并显示了正确的页面内容。

在底层,Link 组件仍然渲染内置的 <a> 元素。但 React Router 控制它并实现了上述描述的行为。

因此,Link 组件是用于内部链接的默认组件。对于外部链接,应使用标准的 <a> 元素,因为链接会离开网站,因此没有需要保留的状态或页面重新加载来防止。

使用布局和嵌套路由

大多数网站都需要某种形式的页面宽范围导航(以及相应的导航链接)或其他应在某些或所有路由之间共享的页面部分。

考虑到之前的示例网站,它有 //orders 路由。该示例网站也将从有一个允许用户在起始页面(即 Dashboard 路由)和 Orders 页面之间切换的顶部导航栏中受益。

因此,可以将 App.jsx 调整为在 <RouterProvider> 上方的 <header> 中包含一个顶部导航栏:

import { 
  createBrowserRouter, 
  RouterProvider, 
  Link 
} from 'react-router-dom';
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
  { path: '/', element: <Dashboard /> },
  { path: '/orders', element: <Orders /> },
]);
function App() {
  return (
    <>
      **<****header****>**
        **<****nav****>**
          **<****ul****>**
            **<****li****>**
              **<****Link****to****=****"/"****>****My Dashboard****</****Link****>**
            **</****li****>**
            **<****li****>**
              **<****Link****to****=****"/orders"****>****Past Orders****</****Link****>**
            **</****li****>**
          **</****ul****>**
        **</****nav****>**
      **</****header****>**
      <RouterProvider router={router} />
    </>
  );
}
export default App; 

但如果你尝试运行此应用程序,你将看到一个空白页面,并在浏览器开发者工具的 JavaScript 控制台中遇到错误信息。

计算机屏幕截图  自动生成的描述

图 13.5:React Router 好像在抱怨某些事情

错误信息有点晦涩难懂,但问题在于上述代码试图在由 React Router 控制的组件之外使用 <Link>

只有通过 <RouterProvider> 加载的组件才受 React Router 控制,因此 React Router 的功能,如其 Link 组件,只能在路由组件(或其子组件)中使用。

因此,在 App 组件(不是由 React Router 加载)内部设置主要导航不起作用。

要使用某个共享组件和 JSX 标记来包装或增强多个路由组件,必须定义一个新的路由来包装现有路由。这样的路由有时也被称为 布局路由,因为它可以用来提供一些共享布局。被此路由包装的路由将被称为 嵌套路由

布局路由的定义方式与路由定义数组内的任何其他路由相同。然后,通过使用 React Router 接受的特殊 children 属性来包装其他路由,它就变成了一个布局路由。这个 children 属性接收一个嵌套路由的数组——包装父路由的子路由。

这是此示例应用程序调整后的路由定义代码:

**import****Root****from****'./routes/Root.jsx'****;**
import Dashboard from './routes/Dashboard.jsx';
import Orders from './routes/Orders.jsx';
const router = createBrowserRouter([
  {
    **path****:** **'/'****,**
    **element****:** **<****Root** **/>****,**
    **children****:** [
      { **index****:** **true**, element: <Dashboard /> },
      { path: '/orders', element: <Orders /> },
    ],
  },
]); 

在这个更新的代码片段中,定义了一个新的根布局路由——一个注册现有路由(DashboardOrders组件)为子路由的路由。因此,这种设置允许Root组件与DashboardOrders路由组件同时激活。

你可能还会注意到,Dashboard路由不再有path。相反,它现在有一个index属性,设置为true。这个index属性是在处理嵌套路由时可以使用的属性。它告诉 React Router 在父路由路径完全匹配时激活哪个嵌套路由(因此加载哪个组件)。

在这个例子中,当/路径处于活动状态(即,如果用户访问<domain>/),RootDashboard组件将被渲染。对于<domain>/ordersRootOrders将变得可见。

Root组件是这个例子中新增的组件。它是一个标准组件(如DashboardOrders),具有一个特殊功能:它通过 React Router 提供的特殊Outlet组件定义了子路由组件应该插入的位置:

import { Link, **Outlet** } from 'react-router-dom';
function Root() {
  return (
    <>
      <header>
        <nav>
          <ul>
            <li>
              <Link to="/">My Dashboard</Link>
            </li>
            <li>
              <Link to="/orders">Past Orders</Link>
            </li>
          </ul>
        </nav>
      </header>
      **<****Outlet** **/>**
    </>
  );
}
export default Root; 

<Outlet />占位符是必需的,因为 React Router 必须知道在哪里渲染传递给children属性的路由组件的路由组件。

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/05-layouts-nested-routes

由于Root组件本身也是由 React Router 渲染的,它现在是一个可以访问<Link>标签的组件。因此,这个Root组件可以用来在所有嵌套路由之间共享通用标记(如导航<header>)。

计算机的截图  自动生成的描述

图 13.6:顶部显示了一个共享的导航栏(适用于所有路由)

因此,嵌套路由和布局路由(或包装路由)是 React Router 提供的关键特性。

还值得注意的是,你可以根据应用程序的需要添加任意级别的路由嵌套——你仅限于只有一个包裹子路由的布局路由。

在上一章中设置的共享导航中,你通常希望突出显示导致当前活动页面的链接。例如,如果用户点击了Past Orders链接(因此导航到/orders),该链接应该改变其外观(例如,其颜色)。

考虑之前的例子(图 13.6)——在那里,在顶部导航栏中,用户是否在Dashboard页面或Orders页面并不立即明显。当然,URL 地址和主页内容确实会改变,但导航项在视觉上并没有调整。

为了证明这一点,比较之前的截图和下面的截图:

img

图 13.7:高亮的“历史订单”导航链接被下划线并改变颜色

在这个版本的网站上,用户立即就能清楚地看到他们位于“订单”页面,因为“历史订单”导航链接被突出显示。正是这样的细微之处使得网站更加易用,并最终可能导致更高的用户参与度。

但这是如何实现的呢?

要做到这一点,你不会使用 Link 组件,而是使用 react-router-dom 提供的特别替代组件:NavLink 组件:

import { **NavLink**, Outlet } from 'react-router-dom';
function Root() {
  return (
    <>
      <header>
        <nav>
          <ul>
            <li>
              **<****NavLink****to****=****"/"****>****My Dashboard****</****NavLink****>**
            </li>
            <li>
              **<****NavLink****to****=****"/orders"****>****Past Orders****</****NavLink****>**
            </li>
          </ul>
        </nav>
      </header>
      <Outlet />
    </>
  );
}
export default Root; 

NavLink 组件的使用方式与 Link 组件非常相似。你将其包裹在一段文本(链接的标题)周围,并通过 to 属性定义目标路径。然而,NavLink 组件有一些额外的与样式相关的功能,这是常规 Link 组件所不具备的。

严格来说,当链接处于活动状态时,NavLink 组件默认将一个名为 active 的 CSS 类应用到渲染的锚点元素上。

计算机屏幕截图  描述自动生成

图 13.8:渲染的 <a> 元素接收了一个“active” CSS 类

如果你想在链接变为活动状态时应用不同的 CSS 类名或内联样式,NavLink 也允许你这样做。因为 NavLinkclassNamestyle 属性在行为上与其他元素略有不同。除了接受字符串值(className)或样式对象(style)之外,这两个属性还接受函数,这些函数将由 React Router 在每次导航操作时自动调用。例如,以下代码可以用来确保应用特定的 CSS 类或样式:

<NavLink
  className={({ isActive }) => isActive ? 'loaded' : ''}
  style={({ isActive }) => isActive ? { color: 'red' } : undefined}>
    Some Link
</NavLink> 

在上述代码片段中,classNamestyle 都利用了 React Router 将要执行的功能。这个函数自动接收一个对象作为输入参数——这个对象是由 React Router 创建并提供的,它包含一个 isActive 属性。当链接指向当前活动路由时,React Router 将 isActive 设置为 true,否则设置为 false

因此,你可以在这些函数中返回任何你选择的 CSS 类名或样式对象。然后 React Router 将它们应用到渲染的 <a> 元素上。

注意

你可以在 GitHub 上找到这个示例的完整代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/06-navlinks

一个重要的注意事项是,NavLink会将路径与当前 URL 路径匹配或以当前 URL 路径开头的情况视为活动路由。例如,如果你有一个/blog/all-posts路由,一个指向/blogNavLink组件如果当前路由是/blog/all-posts,则会被视为活动状态(因为该路由路径以/blog开头)。如果你不希望这种行为,你可以在NavLink组件中添加特殊的end属性,如下所示:

<NavLink
  to="/blog"
  style={({ isActive }) => isActive ? { color: 'red' } : undefined}
  **end**>
    Blog
</NavLink> 

添加了这个特殊属性后,这个NavLink只有在当前路由正好是/blog时才会被视为活动状态——对于/blog/all-posts,链接则不会是活动状态。

例外情况是链接到/。由于所有路由在技术上都是以这个“空路径”开始的,React Router 默认情况下只将<NavLink to="/">视为活动状态,如果用户当前位于<domain>/。对于其他路径(例如,/orders),<NavLink to="/">则不会被标记为活动状态。

当链接的样式依赖于当前活动路由时,NavLink始终是首选的选择。对于所有其他内部链接,使用Link。对于外部链接,<a>是首选元素。

路由组件与“常规”组件的比较

值得注意的是,在之前的示例中,DashboardOrders组件是常规的 React 组件。你可以在你的 React 应用的任何地方使用这些组件——而不仅仅是作为路由定义的element属性的值。

然而,这两个组件是特殊的,因为它们都存储在项目目录下的src/routes文件夹中。它们没有存储在src/components文件夹中,而这本书中使用的组件都是存储在这个文件夹中的。

虽然这不是你必须做的事情。实际上,文件夹名称完全由你决定。这两个组件可以存储在src/components中。你也可以将它们存储在src/elements文件夹中。但使用src/routes对于仅用于路由的组件来说是非常常见的。流行的替代方案有src/screenssrc/viewssrc/pages(同样,这取决于你)。

如果你的应用包含任何其他不是作为路由元素的组件,你仍然会将这些组件存储在src/components(即,在不同的路径下)。这并不是一个硬性规则或技术要求,但它确实有助于保持你的 React 项目可管理。将你的组件分散存储在多个文件夹中,可以更容易地快速理解项目中的哪些组件实现了哪些功能。

在之前提到的示例项目中,例如,你可以重构代码,使得导航代码存储在一个单独的组件中(例如,一个MainNavigation组件,存储在src/components/shared/MainNavigation.jsx)。组件文件代码如下:

import { NavLink } from 'react-router-dom';
import classes from './MainNavigation.module.css';
function MainNavigation() {
  return (
    <header className={classes.header}>
      <nav>
        <ul>
          <li>
            <NavLink
              to="/"
              className={({ isActive }) =>
                isActive ? classes.active : undefined
              }
              end
            >
              My Dashboard
            </NavLink>
          </li>
          <li>
            <NavLink
              to="/orders"
              className={({ isActive }) =>
                isActive ? classes.active : undefined
              }
            >
              Past Orders
            </NavLink>
          </li>
        </ul>
      </nav>
    </header>
  );
}
export default MainNavigation; 

在这个代码片段中,NavLink组件被调整以将名为active的 CSS 类分配给属于当前活动路由的任何链接。这是在使用 CSS Modules 时必需的,因为类名在构建过程中会发生变化,正如在第六章为 React 应用添加样式中讨论的那样。除此之外,它基本上与本章早期使用的相同导航菜单代码。

然后,这个MainNavigation组件可以被导入并像这样在Root.jsx文件中使用:

import { Outlet } from 'react-router-dom';
**import****MainNavigation****from****'../components/shared/MainNavigation.jsx'****;**
function Root() {
  return (
    <>
      **<****MainNavigation** **/>**
      <Outlet />
    </>
  );
}
export default Root; 

导入和使用MainNavigation组件会导致Root组件更加精简,同时仍然保留之前的功能。

这些更改显示了你可以如何组合仅用于路由的组件(DashboardOrders)以及用于路由外部的组件(MainNavigation)。

注意

你可以在 GitHub 上找到这个示例的完成代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/07-routing-and-normal-cmp

即使有了那些标记和样式的改进,演示应用程序仍然存在一个重要问题:它只支持静态、预定义的路由。但对于大多数网站来说,这类路由是不够的。

从静态路由到动态路由

到目前为止,所有示例都有两个路由:/用于Dashboard组件,/orders用于Orders组件。但你可以,当然,添加所需的路由数量。如果你的网站有 20 个不同的页面,你(应该)为App组件添加 20 个路由定义(即 20 个Route组件)。

然而,在大多数网站上,你也会有一些无法手动定义的路由——因为并非所有路由及其确切路径都是预先知道的。

考虑之前的示例,增加了额外的组件和一些模拟数据:

img

图 13.9:订单项列表

注意

你可以在 GitHub 上找到这个示例的代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/08-dynamic-routes-problem。在代码中,你会注意到添加了许多新的组件和样式文件。尽管如此,代码并没有使用任何新特性。它只是用来显示一个更真实的用户界面并输出一些模拟数据。

在前面的屏幕截图图 13.9中,你可以看到订单项列表在Past Orders页面(即由Orders组件)上输出。

在底层代码中,每个订单项都被一个Link组件包裹,以便为每个项目加载一个包含更多详细信息的单独页面:

function OrdersList() {
  return (
    <ul className={classes.list}>
      {orders.map((order) => (
        <li key={order.id}>
          **<****Link****to****=****'/orders'****><****OrderItem****order****=****{order}** **/></****Link****>**
        </li>
      ))}
    </ul>
  );
} 

在这个代码片段中,Link组件的路径被设置为/orders。然而,这并不是应该分配的最终值。相反,这个例子突出了一个重要问题:虽然对于每个订单项都应该加载相同的路由和组件(即显示所选订单详细数据的组件),但该组件输出的确切内容取决于选择了哪个订单项。这是相同的路由和组件,但数据不同。

除了路由之外,你会使用 props 来重复使用具有不同数据的相同组件。但是,在路由的情况下,这不仅仅关乎组件。你还必须支持不同的路径——因为不同订单的详细信息应该通过不同的路径(例如,/orders/o1/orders/o2等)来加载。否则,你又会得到不可共享或重新加载的 URL。

因此,路径必须包含不仅是一些静态标识符(例如/orders),而且对于每个订单项都是不同的动态值。对于具有ido1o2o3的三个订单项,目标可能是支持/orders/o1/orders/o2/order/o3路径。

因此,以下三个路由定义可以添加:

{ path: '/orders/o1', element: <OrderDetail id="o1" /> },
{ path: '/orders/o2', element: <OrderDetail id="o2" /> },
{ path: '/orders/o3', element: <OrderDetail id="o3" /> } 

但这个解决方案有一个主要的缺陷。手动添加所有这些路由是一项巨大的工作量。而且这还不是最大的问题。你通常甚至不知道所有值。在这个例子中,当放置一个新订单时,必须添加一个新的路由。但你不能每次访客下单时都调整你网站的源代码。

显然,因此需要一个更好的解决方案。React Router 提供了这个更好的解决方案,因为它支持动态路由

动态路由的定义方式与其他路由相同,只是在定义它们的path值时,你需要包含一个或多个你选择的动态路径段

因此,OrderDetail路由定义看起来是这样的:

{ path: '/orders/:id', element: <OrderDetail /> } 

以下三个关键事物已经改变:

  • 这只是一个路由定义,而不是一个(可能)无限的路由定义列表。

  • path包含一个动态路径段(:id)。

  • OrderDetail不再接收idprop。

:id语法是 React Router 支持的特殊语法。每当路径的一个部分以冒号开头时,React Router 将其视为动态段。这意味着它将在实际的 URL 路径中被不同的值替换。对于/orders/:id路由路径,/orders/o1/orders/o2/orders/abc路径都会匹配,因此激活路由。

当然,你不必使用:id。你可以使用任何你选择的标识符。对于前面的例子,:orderId:order:oid也是有意义的。

标识符将帮助你的应用程序访问页面组件中应加载的动态路由的正确数据(即上面代码片段中的OrderDetail路由组件)。这就是为什么在上一个代码片段中从OrderDetail中移除了id属性。由于只定义了一个路由,因此只能通过属性传递一个特定的id值。这不会有所帮助。因此,必须使用不同的方式来加载特定订单的数据。

提取路由参数

在前面的例子中,当网站用户访问/orders/o1/orders/o2(或任何其他订单 ID 的相同路径)时,会加载OrderDetail组件。然后,该组件应该输出有关所选特定订单的更多信息(即 ID 编码在 URL 路径中的订单)。

顺便说一下,这不仅仅适用于这个例子;你也可以考虑许多其他类型的网站。例如,你也可以有一个在线商店,其中包含产品路由(/products/p1/products/p2等),或者一个旅游博客,用户可以访问单个博客文章(/blog/post1/blog/post2等)。

在所有这些情况下,问题是如何获取应加载到特定标识符(例如,ID)中的数据,该标识符包含在 URL 路径中?由于总是加载相同的组件,你需要一种动态识别顺序、产品或博客文章的方法,以便获取相应的详细数据。

一种可能的解决方案是使用属性。每当构建一个应该可重用且可配置和动态的组件时,可以使用属性来接受不同的值。例如,OrderDetail组件可以接受一个id属性,然后在组件函数体内加载该特定订单 ID 的数据。

然而,如前所述,当通过路由加载组件时,这不是一个可行的解决方案。记住,OrderDetail组件是在定义路由时创建的:

{ path: '/orders/:id', element: <OrderDetail /> } 

由于组件是在App组件中定义路由时创建的,因此无法传递任何动态的、ID 特定的属性值。

幸运的是,这并不是必要的。React Router 为你提供了一个解决方案,允许你从屏幕上显示的组件内部(当路由变为活动状态时)提取编码在 URL 路径中的数据:useParams()钩子。

这个钩子可以用来获取当前活动路由的路由参数。路由参数仅仅是编码在 URL 路径中的动态值——在本例的OrderDetail中是id

因此,在OrderDetail组件内部,可以使用useParams()来提取特定的订单 ID 并加载相应的订单数据,如下所示:

import { useParams } from 'react-router-dom';
import Details from '../components/orders/Details.jsx';
import { getOrderById } from '../data/orders.js';
function OrderDetail() {
  const params = useParams();
  const orderId = params.id; // orderId is "o1", "o2" etc.
  const order = getOrderById(orderId);
  return <Details order={order} />;
}
export default OrderDetail; 

如您在这段代码片段中所见,useParams() 返回一个对象,该对象包含当前活动路由的所有路由参数作为属性。由于路由路径被定义为 /orders/:id,因此 params 对象包含一个 id 属性。该属性的值是实际编码在 URL 路径中的值(例如,o1)。如果您在路由定义中选择不同的标识符名称(例如,/orders/:orderId 而不是 /orders/:id),则必须使用该属性名称来访问 params 对象中的值(即访问 params.orderId)。

注意

您可以在 GitHub 上找到完整的代码,地址为 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/09-dynamic-routes

因此,通过使用路由参数,您可以轻松创建动态路由,这些路由可以加载不同的数据。但是,当然,如果您没有指向动态路由的链接,那么定义路由和处理路由激活并没有那么有用。

创建动态链接

如在本章前面(在 添加页面导航 部分中)所述,网站访客应该能够点击链接,然后这些链接应该将他们带到构成整个网站的各个页面——这意味着,这些链接应该激活使用 React Router 定义的各个路由。

如在 添加页面导航从链接到 NavLink 部分中所述,对于内部链接(即指向 React 应用内部定义的路由的链接),使用 LinkNavLink 组件。

因此,对于像 /orders 这样的静态路由,链接是这样创建的:

<Link to="/orders">Past Orders</Link> // or use <NavLink> instead 

因此,当构建指向如 /orders/:id 这样的动态路由的链接时,您可以简单地创建一个如下所示的链接:

<Link to="/orders/o1">Past Orders</Link> 

此特定链接加载了 ID 为 o1OrderDetails 组件。

按如下方式构建链接是不正确的:

<Link to="/orders/:id">Past Orders</Link> 

动态路径段语法(:id)仅在定义路由时使用——在创建链接时不使用。链接必须指向特定的资源(在这种情况下是特定的订单)。

然而,正如之前所示,创建指向特定订单的链接并不太实用。正如在 从静态路由到动态路由 部分中定义所有动态路由单独来说没有意义一样,手动创建相应的链接也没有意义。

以订单为例,由于您已经在单页(在这种情况下是 Orders 组件)上输出了订单列表,因此无需创建此类链接。同样,您可以在在线商店中有一个产品列表。在这些所有情况下,单个项目(订单、产品等)应该是可点击的,并链接到包含更多信息的详细信息页面。

img

图 13.10:可点击的订单项列表

因此,在渲染 JSX 元素列表时可以动态生成链接。在订单示例的情况下,代码看起来是这样的:

function OrdersList() {
  return (
    <ul className={classes.list}>
      {orders.map((order) => (
        <li key={order.id}>
          **<****Link**
            **to****=****{****`/****orders****/${****order.id****}`}>**
            <OrderItem order={order} />
          </Link>
        </li>
      ))}
    </ul>
  );
} 

在此代码示例中,to属性的值被动态设置为包含order.id值的字符串。因此,每个列表项都接收一个独特的链接,该链接指向不同的详情页面。或者,更准确地说,链接始终指向同一个组件,但具有不同的order id值,因此加载不同的订单数据。

注意

在此代码片段(可在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/10-dynamic-links )中,字符串被创建为模板字面量。这是一个默认的 JavaScript 功能,它简化了包含动态值的字符串的创建。

您可以在 MDN 上了解更多关于模板字面量的信息,网址为developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

程序化导航

在上一节以及本章前面的内容中,用户导航是通过向网站添加链接来实现的。确实,链接是向网站添加导航的默认方式。但有些情况下需要使用程序化导航。

程序化导航意味着新页面应通过 JavaScript 代码加载(而不是使用链接)。这种导航通常在活动页面因某些操作而改变时需要——例如,在表单提交时。

如果以表单提交为例,您通常希望提取并保存提交的数据。但在此之后,有时需要将用户重定向到不同的页面。例如,在处理输入的信用卡详情后,让用户留在结账页面是没有意义的。您可能希望将用户重定向到成功页面。

在本章讨论的示例中,历史订单页面可以包括一个输入字段,允许用户直接输入订单 ID,并在点击查找按钮后加载相应的订单数据。

img

图 13.11:一个可以快速加载特定订单的输入字段

在此示例中,首先处理并验证输入的订单 ID,然后用户被发送到相应的详情页面。如果提供的 ID 无效,则显示错误消息。代码如下:

import orders, { getOrdersSummaryData } from '../../data/orders.js';
import classes from './OrdersSummary.module.css';
function OrdersSummary() {
  const { quantity, total } = getOrdersSummaryData();
  const formattedTotal = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(total);
  function findOrderAction(formData) {
    const orderId = formData.get('order-id');
    const orderExists = orders.some((order) => order.id === orderId);
    if (!orderExists) {
      alert('Could not find an order for the entered id.');
      return;
    }
  }
  return (
    <div className={classes.row}>
      <p className={classes.summary}>
        {formattedTotal} | {orders.length} Orders | 
          {quantity} Products
      </p>
      <form className={classes.form} action={findOrderAction}>
        <input
          type="text"
          name="order-id"
          placeholder="Enter order id"
          aria-label="Find an order by id."
        />
        <button>Find</button>
      </form>
    </div>
  );
}
export default OrdersSummary; 

代码片段尚未包括实际触发页面更改的代码,但它显示了如何读取和验证用户输入。

因此,这是一个使用程序化导航的完美场景。在这里不能使用链接,因为它会立即触发页面更改——在允许您首先验证用户输入之前(至少在点击链接之后不会)。

React Router 库还支持此类情况下的程序化导航。你可以导入并使用特殊的 useNavigate() 钩子来获取一个可以用来触发导航操作(即页面更改)的导航函数:

import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
navigate('/orders'); 
// programmatic alternative to <Link to="/orders"> 

因此,之前提到的 OrdersSummary 组件可以调整如下以使用这个新的钩子:

function OrdersSummary() {
  **const** **navigate =** **useNavigate****();**
  const { quantity, total } = getOrdersSummaryData();
  const formattedTotal = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(total);
  function findOrderAction(formData) {
    const orderId = formData.get('order-id');
    const orderExists = orders.some((order) => order.id === orderId);
    if (!orderExists) {
      alert('Could not find an order for the entered id.');
      return;
    }
    **navigate****(****`/orders/****${orderId}****`****);**
  }
  // returned JSX code did not change, hence omitted
} 

值得注意的是,传递给 navigate() 的值是一个动态构造的字符串。程序化导航支持静态和动态路径。

注意

此示例的代码可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/11-programmatic-navigation 找到。

重定向

到目前为止,所有探索过的导航选项(链接和程序化导航)都将用户转发到特定页面。

在大多数情况下,这是预期的行为。但在某些情况下,目标是重定向用户而不是转发他们。

这种区别细微但很重要。当用户被转发时,他们可以使用浏览器的导航按钮(后退前进)返回到上一页或跳转到他们来的页面。对于重定向,这是不可能的。无论何时用户被重定向到特定页面(而不是被转发),他们都不能使用 后退 按钮返回到上一页。

重定向用户,例如,可以确保用户在成功认证后无法返回登录页面。

当使用 React Router 时,默认行为是转发用户。但你可以通过向 Link(或 NavLink)组件添加特殊的 replace 属性来轻松切换到重定向,如下所示:

<Link to="/success" **replace**>Confirm Checkout</Link> 

当使用程序化导航时,你可以向 navigate() 函数传递第二个可选参数。该第二个参数值必须是一个对象,该对象可以包含一个 replace 属性,如果你想重定向用户,则应将其设置为 true

navigate('/dashboard', **{** **replace****:** **true** **}**); 

能够重定向或转发用户,让你能够构建高度用户友好的网络应用程序,为不同场景提供最佳的用户体验。

处理未定义的路由

本章前面的部分都假设你已经有预定义的路由,这些路由应该可以被网站访客访问。但如果是访客输入了一个根本不支持 URL 呢?

例如,本章中使用的演示网站支持 //orders/orders/<some-id> 路径。但它不支持 /home/products/p1/abc 或任何不是定义的路由路径的其他路径。

要显示自定义的 未找到 页面,你可以定义一个具有特殊路径的“捕获所有”路由——* 路径:

{ path: '*', element: <NotFound /> } 

当将此路由添加到 App 组件的路由定义列表中时,如果没有其他路由与输入或生成的 URL 路径匹配,屏幕上将显示 NotFound 组件。

懒加载

在第十章 React 和优化机会背后的场景 中,你学习了懒加载——一种仅在需要时加载 React 应用程序代码片段的技术。

如果某些组件将条件性加载并且可能根本不需要,代码拆分就非常有意义。因此,路由是懒加载的完美场景。当应用程序有多个路由时,一些路由可能永远不会被用户访问。即使所有路由都被访问,也不必在应用程序加载时立即下载所有应用路由(即它们的组件)的代码。相反,当它们实际变为活动状态时,只下载单个路由的代码是有意义的。

幸运的是,React Router 内置了对懒加载和基于路由的代码拆分的支持。它提供了一个可以添加到路由定义中的 lazy 属性。该属性期望一个函数,该函数动态导入要懒加载的文件(其中包含应渲染的组件)。然后 React Router 负责其余工作——例如,你不需要将 Suspense 包装在任何组件周围:

import { 
  createBrowserRouter, 
  RouterProvider 
} from 'react-router-dom';
import Root from './routes/Root.jsx';
import Dashboard from './routes/Dashboard.jsx';
// Removed static imports of Orders.jsx and OrderDetail.jsx
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      { index: true, element: <Dashboard /> },
      { 
        path: '/orders', 
        **lazy****:** **() =>****import****(****'./routes/Orders.jsx'****)** 
      },
      { 
        path: '/orders/:id', 
        **lazy****:** **() =>****import****(****'./routes/OrderDetail.jsx'****)** 
      },
    ],
  },
]);
function App() {
  return <RouterProvider router={router} />;
}
export default App; 

在这个例子中,/orders/orders/:id 路由都设置为懒加载它们各自组件。

为了使上述代码正常工作,当使用此内置懒加载支持时,你必须对你的路由组件文件进行一项重要调整:你必须将默认组件函数导出(export default SomeComponent)替换为命名导出,其中组件函数被命名为 Component

例如,Orders 组件代码需要修改为如下所示:

import OrdersList from '../components/orders/OrdersList.jsx';
import OrdersSummary from '../components/orders/OrdersSummary.jsx';
function Orders() {
  return (
    <>
      <OrdersSummary />
      <OrdersList />
    </>
  );
}
**export****const****Component** **=** **Orders****;** // named export as "Component" 

在此代码片段中,Orders 组件函数被导出为 Component。由于 React Router 在激活懒加载路由时会寻找名为 Component 的组件函数,因此这个名称是必需的。

注意

该示例的代码可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/examples/12-lazy-loading 找到。

如在第十章 React 和优化机会背后的场景 中所述,添加懒加载可以显著提高 React 应用程序的性能。你应该始终考虑使用懒加载,但不应为每个路由都使用它。对于那些保证会早期加载的路由,例如,这样做尤其没有逻辑。在前面的例子中,由于这是默认路由(路径为 /),对 Dashboard 组件进行懒加载并没有太多意义。

但对于那些根本不会被访问的路由(或者至少不是在网站加载后立即访问)是懒加载的理想候选者。

摘要和关键要点

  • 路由是许多 React 应用程序的关键特性。

  • 使用路由,用户可以在单页应用(SPA)中访问多个页面。

  • 最常用的帮助路由的包是 React Router 库(react-router-dom)。

  • 路由是通过createBrowserRouter()函数和RouterProvider组件定义的(通常在App组件或main.jsx文件中,但你可以在任何地方做这件事)。

  • 路由定义对象通常通过一个path(路由应该变得活跃的路径)和一个element(应该显示的内容)属性来设置。

  • 内容和标记可以通过设置布局路由来在多个路由之间共享——即包裹其他嵌套路由的路由。

  • 用户可以通过手动更改 URL 路径、点击链接或程序性导航在路由之间导航。

  • 内部链接(即指向你定义的应用程序路由的链接)应通过LinkNavLink组件创建,而指向外部资源的链接则使用标准的<a>元素。

  • 程序性导航是通过由useNavigate()钩子提供的navigate()函数触发的。

  • 你可以定义静态和动态路由:静态路由是默认的,而动态路由是路径(在路由定义中)包含动态段的路由(由冒号表示,例如:id)。

  • 动态路径段的实际值可以通过useParams()钩子提取。

  • 你可以使用懒加载来仅在用户实际访问路由时加载特定路由的代码。

接下来是什么?

路由是 React 默认不支持的功能,但对于大多数 React 应用来说仍然很重要。这就是为什么它包含在这本书中,以及为什么存在 React Router 库。路由是一个关键概念,它完善了你关于最基本 React 想法和概念的知识,使你能够构建简单和复杂的 React 应用。

下一章基于本章内容,并更深入地探讨 React Router,探索其数据获取和处理能力。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/exercises/questions-answers.md找到的示例进行比较。:

  1. 路由与条件性加载内容有何不同?

  2. 路由是如何定义的?

  3. 你应该如何将链接添加到你的页面上的不同路由?

  4. 如何将动态路由(例如,许多产品之一的产品详情)添加到你的应用中?

  5. 如何提取动态路由参数值(例如,加载产品数据)?

  6. 嵌套路由的目的是什么?

应用所学知识

将你对路由的知识应用到以下活动中。

活动第 13.1 节:创建一个基本的三个页面网站

在此活动中,你的任务是创建一个全新的在线商店网站的基本初稿。该网站必须支持三个主要页面:

  • 欢迎页面

  • 一个显示可用产品列表的产品概览页面

  • 一个产品详情页面,允许用户探索产品详情

最终网站的风格、内容和数据将由其他团队添加,但你应提供一些占位符数据和默认样式。你还必须在顶部添加一个共享的主导航栏并实现基于路由的懒加载。

完成的页面应如下所示:

img

图 13.12:欢迎页面。

img

图 13.13:显示一些占位符产品占位符的页面。

img

图 13.14:带有一些占位符数据和样式的最终产品详情页面。

注意

对于此活动,你当然可以自己编写所有 CSS 样式。但如果你想要专注于 React 和 JavaScript 逻辑,你也可以使用解决方案中的完成 CSS 文件,位置在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/13-routing/activities/practice-1/src/index.css

如果你使用那个文件,请仔细探索以确保你理解可能需要添加到解决方案中某些 JSX 元素的哪些 ID 或 CSS 类。你也可以使用解决方案的占位符数据而不是创建自己的占位符产品数据。你可以在这个位置找到这些数据:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/13-routing/activities/practice-1/src/data/products.js

要完成此活动,解决方案步骤如下:

  1. 创建一个新的 React 项目并安装 React Router 包。

  2. 创建组件(如前一个屏幕截图所示),这些组件将被加载到三个必需的页面中。

  3. 启用路由并添加三个页面的路由定义。

  4. 添加一个对所有页面都可见的主导航栏。

  5. 添加所有必要的链接并确保导航栏链接反映页面是否处于活动状态。

  6. 实现懒加载(对于有意义的路由)。

注意

此活动的完整代码和解决方案可以在以下位置找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/activities/practice-1

第十四章:使用 React Router 管理数据

学习目标

到本章结束时,你将能够做到以下几点:

  • 使用 React Router 获取或发送数据,而不使用 useEffect()useState()

  • 在不使用 React 的 context 功能的情况下,在不同路由间共享数据

  • 根据当前数据提交状态更新 UI

  • 创建页面和动作路由

  • 通过延迟加载非关键数据来提高用户体验

简介

在上一章中,你学习了如何使用 React Router 为不同的 URL 路径加载不同的组件。这是一个重要的特性,因为它允许你在使用 React 的同时构建多页面网站。

路由对于许多 Web 应用来说是一个关键特性,因此 React Router 是一个非常重要的包。但就像大多数网站需要路由一样,几乎所有的网站都需要获取和操作数据。例如,在大多数网站中,HTTP 请求是用来加载数据(例如产品列表或博客文章)或修改数据(例如创建产品或博客文章)的。

第八章处理副作用 中,你学习了可以使用 useEffect() Hook 和其他各种 React 特性在 React 应用内部发送 HTTP 请求。但如果你使用 React Router,你将获得一些新的、甚至更强大的工具来处理数据。

本章将探讨 React Router 提供了哪些新功能,以及如何使用这些功能简化数据获取或发送的过程。

数据获取和路由紧密耦合

如前所述,大多数网站确实需要获取(或发送)数据,并且大多数网站确实需要多个页面。但重要的是要认识到这两个概念通常是紧密相关的。

当用户访问新页面(例如 /posts )时,很可能需要获取一些数据。在 /posts 页面的情况下,所需的数据可能是一份从后端服务器检索到的博客文章列表。因此,渲染的 React 组件(例如 Posts )必须向后端服务器发送 HTTP 请求,等待响应,处理响应(以及潜在的错误),并最终显示获取到的数据。

当然,并非所有页面都需要获取数据。着陆页、“关于我们”页面和“条款与使用”页面在用户访问时可能不需要获取数据。相反,这些页面上的数据可能是静态的。甚至可能包含在源代码中,因为它不经常改变。

但许多页面确实需要在每次加载时从后端获取数据——例如,“产品”、“新闻”、“活动”或其他不经常更新的页面,如“用户资料”。

数据获取并非一切。大多数网站还包含需要提交数据的功能——无论是可以创建或更新的博客文章,管理的产品数据,还是可以添加的用户评论。因此,向后端发送数据也是一个非常常见的用例。

除了请求之外,组件可能还需要与其他浏览器 API 交互,例如 localStorage。例如,用户设置可能需要在页面加载时从存储中检索。

自然地,所有这些交互都在页面上发生。但可能并不立即明显数据获取和提交与路由是如何紧密相连的。

大多数情况下,数据是在路由变为活动状态时获取的,即当组件(页面组件)首次渲染时。当然,用户也可能能够点击按钮来刷新数据,但尽管这是可选的,在页面初始加载时获取数据几乎是必需的。

当涉及到发送数据时,它与路由也有密切的联系。乍一看,可能不清楚它们是如何相关的,因为虽然页面加载时获取数据是有意义的,但立即发送一些数据的需求可能较少(除非可能是跟踪或分析数据)。

但是,在发送数据后,你很可能想要导航到不同的页面,这意味着实际上情况正好相反,你希望在发送一些数据后加载不同的页面。例如,在管理员输入一些产品数据并提交表单后,他们通常会被重定向到不同的页面(例如,从 /products/new/products 页面)。

因此,数据获取、提交和路由之间的关系可以总结如下:

  • 数据获取通常应该在路由变为活动状态时启动(如果该页面需要数据)

  • 提交数据后,用户通常会被重定向到另一个路由

由于这些概念紧密相连,React Router 提供了额外的功能,极大地简化了与数据交互的过程。

不使用 React Router 发送 HTTP 请求

与数据交互不仅仅是发送 HTTP 请求。如前所述,你可能还需要通过 localStorage 或执行其他操作来存储或检索数据,当页面加载时。但是,发送 HTTP 请求是一个特别常见的场景,因此将是本章主要考虑的使用案例。然而,重要的是要记住,本章所学的内容并不仅限于发送 HTTP 请求。

正如你将看到的,React Router 提供了各种功能来帮助发送 HTTP 请求(或使用其他数据获取和操作 API),但你也可以在没有这些功能的情况下发送 HTTP 请求(或与 localStorage 或其他 API 交互)。实际上,第八章处理副作用,已经教你如何使用 useEffect() 在 React 组件内部发送 HTTP 请求。

当使用 React Router 的数据获取功能时,你可以摆脱 useEffect() 和手动状态管理。

注意

除了回到这本书中,你还可以通过 GitHub 上的这个代码示例回顾使用useEffect()进行数据获取的方式:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/14-routing-data/examples/01-data-fetching-classic

使用 React Router 加载数据

使用 React Router,可以简化数据获取到这个更短、更简洁的代码片段:

import { useLoaderData } from 'react-router-dom';
function Posts() {
  const loadedPosts = useLoaderData();
  return (
    <main>
      <h1>Your Posts</h1>
      <ul className="posts">
        {loadedPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}
export default Posts;
export async function loader() {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  if (!response.ok) {
    throw new Error('Could not fetch posts');
  }
  return response;
} 

信不信由你,这确实比第八章中展示的例子少很多代码。当时,当使用useEffect()时,必须管理单独的状态片段来处理加载、错误状态以及接收到的数据。不过,公平地说,这里缺少了错误情况下应显示的内容。它在一个单独的文件中(稍后将会展示),但这只会增加三行额外的代码。

在前面的代码片段中,你可以看到一些尚未在书中介绍的新功能。loader()函数和useLoaderData() Hook 是由 React Router 添加的。这些功能,以及本章中将探讨的许多其他功能,都是由 React Router 包提供的。

安装了这个库后,你可以在路由定义上设置一个额外的loader属性。这个属性接受一个函数,该函数将由 React Router 在激活此路由(或其定义的子路由,如果有的话)时执行:

{ path: '/posts', element: <Posts />, **loader****:** **() =>** **{...}** } 

这个函数可以用来执行任何数据获取或其他任务,以成功显示页面组件。因此,获取所需数据的逻辑可以从组件中提取出来,并移动到单独的函数中。

由于许多网站有数十个甚至数百个路由,将加载函数内联添加到路由定义对象中会迅速导致复杂且令人困惑的路由定义。因此,你通常会(并导出)在包含需要数据的组件的同一文件中添加loader()函数。

在设置路由定义时,你可以导入组件及其loader函数,并像这样使用它:

import Posts, **{ loader** **as** **postsLoader }** from './components/Posts.jsx';
// … other code …
const router = createBrowserRouter([
  { path: '/posts', element: <Posts />, **loader****: postsLoader** }
]); 

在这个例子中,将导入的loader函数分配一个别名(例如postsLoader)是可选的,但推荐这样做,因为你很可能有来自不同组件的多个loader函数,否则可能会导致名称冲突。

注意

技术上,你不需要将你的函数命名为loader。你可以使用任何名称,并将它们作为路由定义中loader属性的值。

但将loader用作函数名不仅遵循了惯例,而且还有这样的优势:React Router 的内置懒加载支持(在上一章中介绍)在需要时懒加载loader函数。如果你选择任何其他名称,它将无法做到这一点。

在定义了此loader之后,React Router 将在激活任何路由时执行loader()函数。更准确地说,loader()函数是在组件函数执行之前被调用的(即,在组件渲染之前)。

img

图 14.1:在loader执行之后渲染Posts组件

这也解释了为什么本节开头提到的Posts组件示例中没有处理任何加载状态的代码。因为实际上根本就没有加载状态,因为组件函数只有在它的loader完成(并且数据可用)之后才会执行。React Router 不会完成页面转换,直到loader()函数完成其工作(尽管,如你将在本章末尾学到的那样,有一种方法可以改变这种行为)。

loader()函数可以执行任何你选择的操作(例如发送 HTTP 请求,或通过localStorage API 访问浏览器存储)。在该函数内部,你应该返回应该暴露给组件函数的数据。还值得注意的是,loader()函数可以返回任何类型的数据。它也可能返回一个Promise对象,该对象随后解析为任何类型的数据。在这种情况下,React Router 将自动等待Promise得到解决,在useLoaderData()被调用时提供解析后的数据。因此,loader()函数可以执行异步和同步任务。

注意

重要的是要理解,loader()函数,就像构成你的 React 应用的其它所有代码一样,在客户端执行(即在网站访问者的浏览器中)。因此,你可以在你的 React 应用中执行任何可以在任何其他地方执行的操作(例如,在useEffect()中)。

你绝对不应该尝试运行属于服务器端的代码。直接访问数据库、写入文件系统或执行任何其他服务器端任务都会失败或引入安全风险,这意味着你可能会意外地在客户端暴露数据库凭证。

获取加载的数据

当然,属于loader的组件(即属于同一路由定义的组件)需要loader返回的数据。这就是为什么 React Router 提供了一个新的 Hook 来访问这些数据:useLoaderData() Hook。

当在组件函数内部调用此 Hook 时,它将返回属于活动路由的loader返回的数据。如果返回的数据是一个Promise,React Router(如前所述)将自动等待该Promise解决,并在useLoaderData()被调用时提供解析后的数据。

loader() 函数也可能返回一个 HTTP 响应对象(或一个解析为 ResponsePromise)。在先前的例子中就是这样,因为 fetch() 函数产生一个解析为 Response 类型的对象的 Promise。在这种情况下,React Router 自动提取响应体,并提供直接访问附加到响应中的数据(通过 useLoaderData())。

注意

如果应该返回响应,返回的对象必须遵循这里定义的标准 Response 接口:developer.mozilla.org/en-US/docs/Web/API/Response

返回响应可能一开始会显得有些奇怪。毕竟,loader() 代码仍然在浏览器内部执行(而不是在服务器上)。因此,技术上没有发送请求,也不应该需要响应(因为整个代码都在同一个环境中执行,即浏览器)。

因此,您可以返回响应,但不必这样做;您可以返回任何类型的值。React Router 也支持响应作为可能的返回值类型之一。

useLoaderData() 可以在任何由当前活动路由组件渲染的组件中调用。这可能就是路由组件本身(在先前的例子中是 Posts),也可能是任何嵌套组件。

例如,useLoaderData() 也可以在包含在 Posts 组件中的 PostsList 组件中使用(该组件在其路由定义中添加了 loader):

import { useLoaderData } from 'react-router-dom';
function PostsList() {
  const loadedPosts = useLoaderData();
  return (
    <main>
      <h1>Your Posts</h1>
      <ul className="posts">
        {loadedPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}
export default PostsList; 

对于这个例子,Posts 组件文件看起来是这样的:

import PostsList from '../components/PostsList.jsx';
function Posts() {
  return (
    <main>
      <h1>Your Posts</h1>
      <PostsList />
    </main>
  );
}
export default Posts;
export async function loader() {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  if (!response.ok) {
    throw new Error('Could not fetch posts');
  }
  return response;
} 

这意味着 useLoaderData() 可以在您需要数据的确切位置使用。loader() 函数也可以定义在任何您想要的地方,但它必须添加到需要数据的路由中。

注意

根据使用的 React Router 版本,您可能会收到有关“未提供 'No HydrateFallback' 元素”的警告。您可以忽略这个警告,因为它仅在服务器端渲染时才有意义。

注意

您还可以在 GitHub 上探索这个代码示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/14-routing-data/examples/02-data-fetching-react-router

动态路由的数据加载

对于大多数网站来说,仅使用静态、预定义的路由很可能不足以满足您的需求。例如,如果您创建了一个仅使用静态路由的博客网站,您将仅限于在 /posts 路径上的简单博客文章列表。要添加关于在 /posts/1/posts/2(对于具有不同 id 值的文章)等路径上所选博客文章的更多详细信息,您需要包含动态路由。

当然,React Router 也支持通过 loader() 函数帮助动态路由进行数据获取:

{ 
  path: "/posts/:id",
  element: <PostDetails />, 
  loader: postDetailsLoader 
} 

PostDetails 组件及其 loader 函数可以像这样实现:

import { useLoaderData } from 'react-router-dom';
function PostDetails() {
  const post = useLoaderData();
  return (
    <div id="post-details">
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
}
export default PostDetails;
export async function loader({ params, request }) {
  console.log(request); 
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts/' + params.id
  );
  if (!response.ok) {
    throw new Error('Could not fetch post for id ' + params.id);
  }
  return response;
} 

如果它与 使用 React Router 加载数据 部分的 Posts 组件看起来非常相似,这并非巧合。因为 loader() 函数以完全相同的方式工作,只是使用了一个额外的功能来获取动态路径段值:由 React Router 提供的 params 对象。

注意

你还可以在 GitHub 上探索这个代码示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/14-routing-data/examples/03-dynamic-routes

当向路由定义添加 loader() 函数时,React Router 在组件渲染之前,即路由变为活跃状态时调用该函数。在执行该函数时,React Router 将包含额外信息的对象作为参数传递给 loader()

传递给 loader() 的对象包括两个主要属性:

  • 一个包含导致路由激活的请求更多详细信息的 request 属性

  • 一个返回包含所有动态路由参数键值映射的对象的 params 属性

对于这个例子来说,request 对象并不重要,将在下一节中进行讨论。但 params 对象包含一个 id 属性,它携带了加载该路由的帖子的 id 值。该属性被命名为 id,因为在路由定义中,/posts/:id 被选为路径。如果选择了不同的占位符名称,那么在 params 上将会有一个具有该名称的属性可用(例如,对于 /posts/:postId,这将是一个 params.postId)。这种行为与在 第十三章使用 React Router 的多页应用 中解释的 useParams() 返回的 params 对象类似。

通过 params 对象和帖子 id,可以在出站请求 URL(对于 fetch() 请求)中包含适当的帖子 id,从而从后端 API 加载正确的帖子数据。一旦数据到达,React Router 将渲染 PostDetails 组件,并通过 useLoaderData() 钩子公开加载的帖子。

加载器、请求和客户端代码

在前一节中,你学习了 loader() 函数提供的 request 对象。获取这样的 request 对象可能会令人困惑,因为 React Router 是一个客户端库——所有代码都在浏览器中执行,而不是在服务器上。因此,不应该有请求到达 React 应用(因为 HTTP 请求是从客户端发送到服务器的,而不是客户端 JavaScript 函数之间的请求)。

事实上,并没有通过 HTTP 发送任何请求。相反,React Router 通过浏览器内置的 Request 接口创建一个请求对象,用作“数据载体”。这个请求不是通过 HTTP 发送的,但它被用作传递给 loader() 函数的数据对象上的 request 属性的值。

注意

关于内置的 Request 接口更多信息,请访问 developer.mozilla.org/en-US/docs/Web/API/Request

这个 request 对象在许多 loader 函数中将不再需要,但在某些情况下,你可以从该对象中提取有用的信息——这些信息可能在 loader 中用于获取正确数据。

例如,你可以使用 request 对象及其 url 属性来获取访问任何可能包含在当前活动页面 URL 中的搜索参数(查询参数):

export async function loader({ request }) {
  // e.g. for localhost:5173/posts?sort=desc
  const sortDirection = new URL(request.url).searchParams.get('sort');
  // Fetch sorted posts, based on local 'sort' query param value
  const response = await fetch(
    'https://example.com/posts?sorting=' + sortDirection
  );
  return response;
} 

在此代码片段中,使用 request 值获取用于 React 应用程序 URL 中的查询参数值。然后,该值用于出站请求。

然而,你必须牢记,你的 loader() 函数内部的代码,就像你所有的其他 React 代码一样,总是在客户端执行。如果你想在服务器上执行代码(例如,在服务器端获取数据),你需要使用 服务器端渲染SSR)或实现 SSR 的某些 React 框架,如 Next.js。SSR 和 Next.js 将在下一章,第十五章服务器端渲染与使用 Next.js 构建全栈应用程序,以及随后的章节中介绍。

重新审视布局

React Router 支持布局路由的概念。这些是包含其他路由并将这些其他路由作为嵌套子路由渲染的路由。如您所回忆的,这个概念在第十三章,使用 React Router 的多页应用程序 中介绍。

方便的是,布局路由也可以用于在嵌套路由之间共享数据。考虑以下示例网站:

img

图 14.2:一个包含页眉、侧边栏和一些主要内容的网站

该网站有一个包含导航栏的页眉,一个显示可用帖子列表的侧边栏,以及一个显示当前所选博客文章的主要区域。

此示例包括两个嵌套的布局路由:

  • 根布局路由,它包括跨越所有页面的顶部导航栏

  • 一个包含侧边栏及其子路由(例如,所选帖子的详细信息)主要内容的帖子布局路由

路由定义代码看起来像这样:

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />, // main layout, adds navigation bar
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        element: <PostsLayout />, // posts layout, adds posts sidebar
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { 
            path: ':id', 
            element: <PostDetails />, 
            loader: postDetailsLoader 
          },
        ],
      },
    ],
  },
]); 

使用这种设置,<Posts /><PostDetails /> 组件都渲染在侧边栏旁边(因为侧边栏是 <PostsLayout /> 元素的一部分)。

有趣的是,/posts 路径(即布局路径)会加载帖子数据,因为它被分配了 postsLoader,因此 PostsLayout 组件文件看起来像这样:

import { Outlet, useLoaderData } from 'react-router-dom';
import PostsList from '../components/PostsList.jsx';
function PostsLayout() {
  const loadedPosts = useLoaderData();
  return (
    <div id="posts-layout">
      <nav>
        <PostsList posts={loadedPosts} />
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}
export default PostsLayout;
export async function loader() {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  if (!response.ok) {
    throw new Error('Could not fetch posts');
  }
  return response;
} 

由于布局路由也是常规路由,因此您可以添加 loader() 函数并使用 useLoaderData(),就像在任意其他路由中一样。然而,由于布局路由为多个子路由激活,它们的数据也会显示在不同的路由上。在前面的例子中,无论用户访问 /posts 还是 /posts/10,博客帖子列表始终显示在屏幕的左侧:

img

图 14.3:相同的布局和数据用于不同的子路由

在此屏幕截图中,随着不同的子路由被激活,所使用的布局和数据不会改变。React Router 还避免了在切换子路由时进行不必要的数据重新获取(对于博客帖子列表数据)。它足够智能,能够意识到周围的布局没有改变。

在不同路由间复用数据

布局路由不仅可以帮助您共享组件和标记。它们还允许您在布局路由及其子路由之间加载和共享数据。

例如,PostDetails 组件(即渲染 /posts/:id 路由的组件)需要一个帖子的数据,并且可以通过附加到 /posts/:id 路由的 loader 来检索这些数据:

export async function loader({ params }) {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts/' + params.id
  );
  if (!response.ok) {
    throw new Error('Could not fetch post for id ' + params.id);
  }
  return response;
} 

这个例子在本书的 为动态路由加载数据 部分已经讨论过。这种方法是可行的,但在某些情况下,可以避免额外的 HTTP 请求。例如,以下路由配置可以简化,并且可以避免在子路由上的额外 postDetailsLoader

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />, // main layout, adds navigation bar
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        element: <PostsLayout />, // posts layout, adds posts sidebar
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { 
            path: ':id', 
            element: <PostDetails />, 
            **loader****: postDetailsLoader** // can be removed
          },
        ],
      },
    ],
  },
]); 

在本例中,PostsLayout 路由已经获取了所有帖子的列表。该布局组件也对 PostDetails 路由有效。在这种情况下,获取单个帖子是不必要的,因为已经为帖子列表获取了所有数据。当然,如果帖子列表的请求(在 PostsLayout 路由上的 postsLoader)没有提供 PostDetails 所需的所有数据,则需要为 PostDetails 子路由提供一个特定的 postDetailsLoader 加载器。

但如果所有数据都已可用,React Router 允许您通过 useRouteLoaderData() 钩子访问父路由组件的加载器数据。

此钩子可以这样使用:

const posts = useRouteLoaderData('posts'); 

useRouteLoaderData() 需要一个路由标识符作为参数。它需要一个分配给包含应复用数据的祖先路由的标识符。您可以通过将 id 属性分配给路由作为路由定义代码的一部分来指定此类标识符:

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />, // main layout, adds navigation bar
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        **id****:** **'posts'****,** **// the id value is up to you**
        element: <PostsLayout />, // posts layout, adds posts sidebar
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { 
            path: ':id', 
            element: <PostDetails />, // details loader was removed
          },
        ],
      },
    ],
  },
]); 

然后 useRouteLoaderData() 钩子返回与您添加 id 的路由中 useLoaderData() 产生的相同数据。在本例中,它将提供一个博客帖子列表。

PostDetails 中,此钩子可以这样使用:

import { **useParams****useRouteLoaderData**,  } from 'react-router-dom';
function PostDetails() {
  **const** **params =** **useParams****();**
  **const** **posts =** **useRouteLoaderData****(****'posts'****);**
  **const** **post = posts.****find****(****(****post****) =>** **post.****id****.****toString****() === params.****id****);**
  return (
    <div id="post-details">
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
}
export default PostDetails; 

使用useParams()钩子来获取动态路由参数值,并在帖子列表上使用find()方法来识别具有合适id属性的单一帖子。在这个例子中,你将避免发送不必要的 HTTP 请求,通过重用已经可用的数据。

因此,/posts/:id路由定义中作为postDetailsLoader的一部分可以删除。

错误处理

在本章开头非常早期的第一个例子(其中使用useEffect()发送了 HTTP 请求),代码不仅处理了成功情况,还处理了可能的错误。从那时起,所有基于 React Router 的例子中,错误处理都被省略了。错误处理尚未讨论,因为虽然 React Router 在错误处理中扮演着重要角色,但首先必须牢固地理解 React Router 的一般工作原理以及它如何帮助数据获取。但是,当然,错误并不总是可以避免的,并且绝对不应该被忽视。

幸运的是,当使用 React Router 的数据功能时,错误处理也非常简单和容易。你可以在你的路由定义上设置一个errorElement属性,并定义当发生错误时应渲染的元素:

// ... other imports
**import****Error****from****'./components/Error.jsx'****;**
const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    **errorElement****:** **<****Error** **/>****,**
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        id: 'posts',
        element: <PostsLayout />,
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { path: ':id', element: <PostDetails /> },
        ],
      },
    ],
  },
]); 

这个errorElement属性可以设置在任何你选择的路由定义上,甚至可以同时设置多个路由定义。React Router 将会渲染最接近错误抛出位置的路由的errorElement

在前面的代码片段中,无论哪个路由产生了错误,都会显示根路由的errorElement(因为这是唯一带有errorElement的路由定义)。但是,如果你也向/posts路由添加了一个errorElement,并且:id路由产生了错误,那么屏幕上显示的将是/posts路由的errorElement,如下所示:

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    **errorElement****:** **<****Error** **/>****,** **// for all errors not handled elsewhere**
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        id: 'posts',
        element: <PostsLayout />,
        // used if /posts or /posts/:id throws an error
        **errorElement****:** **<****PostsError** **/>****,** **// handles /posts related errors**
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { path: ':id', element: <PostDetails /> },
        ],
      },
    ],
  },
]); 

这允许你,作为开发者,设置精细的错误处理。

在用作errorElement值的组件内部,你可以通过useRouteError()钩子访问抛出的错误:

import { useRouteError } from 'react-router-dom';
function Error() {
  const error = useRouteError();
  return (
    <>
      <h1>Oh no!</h1>
      <p>An error occurred</p>
      <p>{error.message}</p>
    </>
  );
}
export default Error; 

通过这个简单而有效的错误处理解决方案,React Router 允许你避免自己管理错误状态。相反,你只需定义一个标准的 React 元素(通过element属性),当一切顺利时应该显示,以及一个errorElement,当事情出错时应该显示。

数据提交之旅

到目前为止,你已经学到了很多关于数据获取的知识。但如本章前面所述,React Router 也帮助处理数据提交。

考虑以下示例组件:

function NewPost() {
  return (
    <form id="post-form">
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p>
        <label htmlFor="text">Text</label>
        <textarea id="text" name="text" rows={3} />
      </p>
      <button>Save Post</button>
    </form>
  );
}
export default NewPost; 

这个组件渲染一个<form>元素,允许用户输入新帖子的详细信息。由于以下路由配置,组件在/posts/new路由变为活动状态时显示:

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    errorElement: <Error />,
    children: [
      { index: true, element: <Welcome /> },
      {
        path: '/posts',
        id: 'posts',
        element: <PostsLayout />,
        loader: postsLoader,
        children: [
          { index: true, element: <Posts /> },
          { path: ':id', element: <PostDetails /> },
          **{** **path****:** **'new'****,** **element****: <****NewPost** **/> },**
        ],
      },
    ],
  },
]); 

在没有 React Router 的数据相关功能的情况下,你可能这样处理表单提交:

function NewPost() {
  const navigate = useNavigate();
  async function submitAction(formData) {
    const enteredTitle = formData.get('title');
    const enteredText = formData.get('text');
    const postData = {
      title: enteredTitle,
      text: enteredText
    };
    await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      body: JSON.stringify(postData),
      headers: {'Content-Type': 'application/json'}
    });
    navigate('/posts');
  } 
  return (
    <form action={submitAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p>
        <label htmlFor="text">Text</label>
        <textarea id="text" rows={3} name="text" />
      </p>
      <button>Save Post</button>
    </form>
  );
} 

就像之前在获取数据时一样,这需要在组件函数中添加相当多的代码和逻辑。你必须手动提取提交的数据,发送 HTTP 请求,并在收到 HTTP 响应后导航到不同的页面。

此外,你可能还需要管理加载状态和潜在的错误(在先前的示例中省略)。

再次强调,React Router 提供了一些帮助。当可以添加 loader() 函数来处理数据加载时,可以定义 action() 函数来处理数据提交。

当使用新的 action() 函数时,前面的示例组件看起来像这样:

**import** **{** **Form****, redirect }** **from****'react-router-dom'****;**
function NewPost() {
  return (
    <**Form****method****=****"post"** id="post-form">
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title"/>
      </p>
      <p>
        <label htmlFor="text">Text</label>
        <textarea id="text" rows={3} name="text" />
      </p>
      <button>Save Post</button>
    </Form>
  );
}
export default NewPost;
**export****async****function****action****(****{ request }****) {**
  **const** **formData =** **await** **request.****formData****();**
  **const** **enteredTitle = formData.****get****(****'****title'****);**
  **const** **enteredText = formData.****get****(****'text'****);**
  **const** **postData = {**
    **title****: enteredTitle,**
    **text****: enteredText**
  **};**
  **await****fetch****(****'https://jsonplaceholder.typicode.com/posts'****, {**
    **method****:** **'POST'****,**
    **body****:** **JSON****.****stringify****(postData),**
    **headers****: {** **'Content-Type'****:** **'application/json'** **},**
  **});**
  **return****redirect****(****'****/posts'****);**
**}** 

这段代码的长度可能相似,但它有一个优点,就是将所有数据提交逻辑从组件函数移动到特殊的 action() 函数中。

除了添加 action() 函数之外,示例代码片段还包括以下重要更改和功能:

  • 使用 <Form> 组件代替 <form>

  • method 属性被设置在 <Form> 元素上(设置为 "post")。

  • 提交的数据通过调用 request.formData() 被提取为 FormData

  • 用户通过新添加的 redirect() 函数进行重定向(而不是 useNavigate()navigate())。

但这些元素是什么?

与 action() 和表单数据一起工作

就像 loader() 一样,action() 是一个可以添加到路由定义中的特殊函数,如下所示:

**import****NewPost****, { action** **as** **newPostAction }** **from****'./components/NewPost.jsx'****;**
**// ...**
**{** **path****:** **'new'****,** **element****:** **<****NewPost** **/>****,** **action****: newPostAction },** 

当在路由定义上设置 action 属性时,指定的函数会在提交目标此路由的 <Form>(不是 <form>!)时自动调用。Form 是 React Router 提供的一个组件,应代替默认的 <form> 元素使用。

内部,Form 使用默认的 <form> 元素,但阻止浏览器在表单提交时创建和发送 HTTP 请求。相反,React Router 创建一个 FormData 对象,并调用为 <Form> 目标路由定义的 action() 函数,传递一个基于内置 Request 接口的对象,并将其传递给它。传递的对象包含由 React Router 生成的表单数据。在本章的 控制哪个 触发哪个动作 部分,你将学习如何控制 React Router 将执行哪个路由的 action() 函数。

注意

使用“actions”来处理表单提交可能听起来很熟悉——第九章使用表单动作处理用户输入和表单,讨论了类似的概念。

但 whereas 第九章 讨论了内置在 React 中的功能(这与路由无关或依赖),本章探讨了 React Router 的核心概念。

最终,你可以使用任何一种处理表单提交的方法。或者你也可以不使用这两种方法,而是通过 onSubmit 手动处理 submit 事件。

但当使用 React Router 进行路由时,你通常会得到更干净、更简洁的代码,这些代码可以与其他路由功能(如使用 React Router 的 <Form> 组件和 action() 函数时的重定向)无缝集成。

通过调用 request.formData() 创建的表单数据对象包括提交的表单中输入的所有表单输入值。为了注册,输入元素(如 <input><select><textarea>)必须分配一个 name 属性。为这些 name 属性设置的值可以后来用来提取输入的数据。

action() 函数接收到的 request 对象(包含表单数据)是由 React Router 在表单提交时创建的。

Form 组件定义了请求对象的 HTTP 方法。通过将 Formmethod 属性设置为 "get"(默认值)或 "post",你可以控制表单提交时会发生什么。当设置 method="get"(或根本不设置 method)时,将发生常规的 URL 导航——就像点击了指向某个路径的链接一样。在这种情况下,任何输入的表单值都将作为 URL 查询参数进行编码。要触发 action() 函数,<Form>method 必须设置为 "post"

然而,重要的是要理解,请求不是通过 HTTP 发送的,因为 action(),就像 loader() 或组件函数一样,仍然在浏览器中执行,而不是在服务器上执行。

然后,action() 函数接收一个包含 request 属性的对象,该属性包含包含表单数据的创建请求对象。此 request 对象可以用来提取表单输入字段中输入的值,如下所示:

**export****async****function****action****(****{ request }****) {**
  **const** **formData =** **await** **request.****formData****();**
  **const** **postData =** **Object****.****fromEntries****(formData);**
  **// ...**
**}** 

内置的 formData() 方法返回一个解析为 FormData 对象的 Promise,该对象提供了一个 get() 方法,可以用来通过其标识符(即,通过在输入元素上设置的 name 属性值)获取输入的值。例如,输入到 <input name="title"> 中的值可以通过 formData.get('title') 来检索。

或者,你可以遵循前面代码片段中选择的方案,通过 Object.fromEntries(formData)formData 对象转换为简单的键值对象。此对象(在前面的例子中为 postData)包含在表单输入元素上设置的名称作为属性,以及这些属性的值(这意味着 postData.title 会返回 <input name="title"> 中输入的值)。

注意

React Router 也支持其他主要的 HTTP 动词("patch""put""delete"),将 method 设置为这些动词之一确实也会触发 action() 函数。

当处理多个应触发相同 action() 的表单时,这很有用。通过使用不同的方法,你可以使用单个操作来根据在 action() 函数内部从 request.method 提取的值运行不同的代码。

但值得注意的是,使用除 'get''post' 之外的方法不符合 HTML 标准。因此,React Router 可能会在未来移除对这些方法的支持。

因此,当处理触发相同 action() 的多个表单时,一个更稳定的解决方案是包含一个具有唯一标识符的隐藏输入字段(例如,<input type="hidden" name="_method" value="DELETE">)。然后可以提取并使用这个值(例如,在 if 语句中)在 action() 函数中。

提取的数据可以用于你选择的任何操作。这可能是一个额外的验证步骤,或者发送到某个后端 API 的 HTTP 请求,其中数据可能被存储在数据库或文件中:

export async function action({ request }) {
  const formData = await request.formData();
  const postData = Object.fromEntries(formData);
  await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: JSON.stringify(postData),
    headers: { 'Content-Type': 'application/json' },
  });
  return redirect('/posts');
} 

最后,一旦执行了所有预期的步骤,action() 函数必须返回一个值——任何类型的任何值,但至少是 null。不返回任何内容(即省略 return 语句)是不允许的。尽管如此,与 loader() 函数一样,你也可以返回一个响应,例如,像这样的重定向响应:

**export****async****function****action****(****{ request }****) {**
  **// action logic …**
  **return****new****Response****(****""****, {**
    **status****:** **302****,**
    **headers****: {**
      **Location****:** **'/posts'**
    **}**
  **});**
**}** 

事实上,对于操作来说,你很可能希望在操作执行后导航到不同的页面(例如,一旦向 API 发送了 HTTP 请求)。这可能需要将用户从数据输入页面导航到显示所有可用数据条目的页面(例如,从 /posts/new/posts)。

为了简化这个常见的模式,React Router 提供了一个 redirect() 函数,它返回一个响应对象,导致 React Router 切换到不同的路由。因此,你可以在 action() 函数中返回调用 redirect() 的结果,以确保用户被导航到不同的页面。这相当于在手动处理表单提交时调用 navigate()(通过 useNavigate())。

**export****async****function****action****(****{ request }****) {**
  **// action logic …**
  **return****redirect****(****'/posts'****)**
**}** 

在这个片段中,React Router 的 redirect() 函数被用来代替手动构造 Response 对象。

返回数据而不是重定向

如前所述,你的 action() 函数可以返回任何内容。你不必返回响应对象。虽然返回重定向响应相当常见,但你偶尔可能想返回一些原始数据。

有一种情况下你可能不希望重定向用户,那就是在验证用户输入之后。在 action() 函数中,在将输入的数据发送到某个 API 之前,你可能希望首先验证提供的值。如果检测到无效的值(例如,空标题),通常通过保持用户在 <Form> 路由上以获得良好的用户体验。用户输入的值不应被清除和丢失;相反,表单应更新以向用户提供有用的验证错误信息。这些信息可以从 action() 传递到组件函数,以便在那里显示(例如,在表单输入字段旁边)。

在这种情况下,您可以从 action() 函数返回一个“正常”值(即不是重定向响应):

export async function action({ request }) {
  const formData = await request.formData();
  const postData = Object.fromEntries(formData);
  **let** **validationErrors = [];**
  **if** **(postData.****title****.****trim****().****length** **===** **0****) {**
    **validationErrors.****push****(****'Invalid post title provided.'****)**
  **}**
  **if** **(postData.****text****.****trim****().****length** **===** **0****) {**
    **validationErrors.****push****(****'Invalid post text provided.'****)**
  **}**
  **if** **(validationErrors.****length** **>** **0****) {**
    **return** **validationErrors;**
  **}**
  await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: JSON.stringify(postData),
    headers: { 'Content-Type': 'application/json' },
  });
  return redirect('/posts');
} 

在此示例中,如果输入的 titletext 值为空,则返回 validationErrors 数组。

action() 函数返回的数据可以通过 useActionData() 钩子在路由组件(或任何其他嵌套组件)中使用:

import { Form, redirect, **useActionData** } from 'react-router-dom';
function NewPost() {
  **const** **validationErrors =** **useActionData****();**
  return (
    <Form method="post" id="post-form">
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p>
        <label htmlFor="text">Text</label>
        <textarea id="text" name="text" rows={3} />
      </p>
      **<****ul****>**
        **{validationErrors &&**
          **validationErrors.map((err) =>** **<****li****key****=****{err}****>****{err}****</****li****>****)}**
      **</****ul****>**
      <button>Save Post</button>
    </Form>
  );
} 

useActionData() 很像 useLoaderData(),但与 useLoaderData() 不同,它不保证产生任何数据。这是因为虽然 loader() 函数总是在路由组件渲染之前被调用,但 action() 函数只有在 <Form> 提交后才会被调用。

在此示例中,useActionData() 用于获取 action() 返回的 validationErrors 的访问权限。如果 validationErrors 是真值(即不是 undefined),则数组将被映射到显示给用户的错误项列表:

img

图 14.4:验证错误输出在输入字段下方

因此,action() 函数非常灵活,您可以使用它执行操作并重定向,也可以执行多个操作并为不同的用例返回不同的值。

控制哪个 触发哪个操作

在本章前面的部分,在 使用 action() 和表单数据 的部分,您学习了当使用 <Form> 而不是 <form> 时,React Router 将执行目标 action() 函数。但 <Form> 针对的是哪个 action() 函数?

默认情况下,渲染表单的 action() 函数(无论是直接还是通过某些子组件)也是分配给路由的。考虑以下路由定义:

{ path: '/posts/new', element: <NewPost />, action: newPostAction } 

使用此定义,当 NewPost 组件(或任何嵌套组件)中的任何 <Form> 被提交时,将触发 newPostAction() 函数。

在许多情况下,这种默认行为正是您想要的。但您也可以通过在 <Form> 上设置 action 属性为目标路由的路径来针对其他路由上定义的 action() 函数:

// form rendered in a component that belongs to /posts
<Form method="post" action="/save-data">
  ...
</Form> 

此表单将导致 React Router 执行属于 /save-data 路由的 action,即使 <Form> 组件可能是属于不同路由(例如,/posts)的组件的一部分。

然而,值得注意的是,针对不同的路由会导致页面过渡到该路由的路径,即使您的操作没有返回重定向响应。在本章的后续部分,标题为 幕后数据获取和提交 的部分,您将了解如何避免这种行为。

反映当前导航状态

提交表单后,触发的 action() 函数可能需要一些时间来执行所有预期的操作。特别是向 API 发送 HTTP 请求可能需要几秒钟。

当然,如果用户得不到关于当前数据提交状态的任何反馈,那么用户体验就不会很好。点击提交按钮后,是否发生了任何操作并不立即明了。

因此,你可能想在action()函数运行期间显示一个加载指示器或更新按钮的标题。确实,提供用户反馈的一种常见方式是禁用提交按钮并更改其标题,如下所示:

img

图 14.5:提交按钮变灰

你可以通过useNavigation() Hook 获取当前的 React Router 状态(即它是否正在过渡到另一个路由或执行action()函数)。这个 Hook 提供了一个包含各种与路由相关的信息的导航对象。

最重要的是,这个对象有一个state属性,它返回一个描述当前导航状态的字符串。此属性被设置为以下三个可能值之一:

  • submitting:如果当前正在执行action()函数

  • loading:如果当前正在执行loader()函数(例如,由于redirect()响应)

  • idle:如果没有action()loader()函数正在执行

因此,你可以使用这个state属性来找出 React Router 是否正在导航到不同的页面或执行action()。因此,可以通过前面的截图中的代码更新提交按钮:

import { 
  Form, 
  redirect, 
  useActionData, 
  **useNavigation** 
} from 'react-router-dom';
function NewPost() {
  const validationErrors = useActionData();
  **const** **navigation =** **useNavigation****();**
  **const** **isSubmitting = navigation.****state** **!==** **'idle'****;**
  return (
    <Form method="post" id="post-form">
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p>
        <label htmlFor="text">Text</label>
        <textarea id="text" name="text" rows={3} />
      </p>
      <ul>
        {validationErrors &&
          validationErrors.map((err) => <li key={err}>{err}</li>)}
      </ul>
      <button **disabled****=****{isSubmitting}**>
        **{isSubmitting ? 'Saving...' : 'Save Post'}**
      </button>
    </Form>
  );
} 

在这个例子中,如果当前导航状态不是'idle',则isSubmitting常量是true。然后使用这个常量通过disabled属性禁用提交按钮并调整按钮的标题。

以编程方式提交表单

在某些情况下,你可能不希望在表单提交时立即触发action(),例如,当你需要先请求用户确认时,比如触发删除或更新数据的操作。

对于此类场景,React Router 允许你以编程方式提交表单(因此触发action()函数)。你不需要使用 React Router 提供的Form组件,而是使用默认的<form>元素手动处理表单提交。作为你代码的一部分,你可以使用 React Router 的useSubmit() Hook 提供的submit()函数手动触发action()

考虑以下例子:

import {
  redirect,
  useParams,
  useRouteLoaderData,
  **useSubmit,**
} from 'react-router-dom';
function PostDetails() {
  const params = useParams();
  const posts = useRouteLoaderData('posts');
  const post = posts.find((post) => post.id.toString() === params.id);
  **const** **submit =** **useSubmit****();**
  **function****handleSubmit****(****event****) {**
    **event.****preventDefault****();**
    **const** **proceed =** **window****.****confirm****(****'Are you sure?'****);**
    **if** **(proceed) {**
      **submit****(**
        **{** **message****:** **'Your submitted data, if needed'** **},**
        **{**
          **method****:** **'post'****,**
        **}**
      **);**
    **}**
  **}**
  return (
    <div id="post-details">
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      **<****form****onSubmit****=****{handleSubmit}****>**
        **<****button****>****Delete****</****button****>**
      **</****form****>**
    </div>
  );
}
export default PostDetails;
// action must be added to route definition!
**export****async****function****action****(****{ request }****) {**
  **const** **formData =** **await** **request.****formData****();**
  **console****.****log****(formData.****get****(****'message'****));**
  **console****.****log****(request.****method****);**
  **return****redirect****(****'/posts'****);**
**}** 

在这个例子中,action()是通过useSubmit()提供的submit()函数以编程方式提交数据手动触发的。这种做法是必需的,否则将无法通过浏览器的window.confirm()方法请求用户确认。

由于数据是以编程方式提交的,应该使用默认的<form>元素,并手动处理submit事件。作为这个过程的一部分,必须手动阻止浏览器发送 HTTP 请求的默认行为。

通常情况下,使用 <Form> 而不是程序化提交更为可取。但在某些情况下,例如前述示例中,能够手动控制表单提交可能是有用的。

背景数据获取和提交

在某些情况下,你可能需要触发一个操作或加载数据,而不引起页面转换。

点赞按钮就是一个例子。当点击时,应在后台触发一个过程(例如,存储有关用户和点赞帖子的信息),但用户不应被导向到不同的页面:

img

图 14.6:帖子下方的点赞按钮

要实现这种行为,你可以将按钮包裹在一个 <Form> 中,并在 action() 函数的末尾简单地重定向回已活跃的页面。

但从技术上讲,这仍然会导致额外的导航操作。因此,loader() 函数将被执行,并可能发生其他可能的副作用(例如,当前的滚动位置可能会丢失)。因此,你可能想要避免这种行为。

幸运的是,React Router 提供了一个解决方案:useFetcher() 钩子,它产生一个包含 submit() 方法的对象。与 useSubmit() 提供的 submit() 函数不同,useFetcher() 产生的 submit() 方法旨在触发操作(或 loader() 函数)而不启动页面转换。

如前所述,点赞按钮可以像这样实现(借助 useFetcher()):

import {
  // ... other imports
  **useFetcher,**
} from 'react-router-dom';
import { FaHeart } from 'react-icons/fa';
function PostDetails() {
  // ... other code & logic
  **const** **fetcher =** **useFetcher****();**
  **function****handleLikePost****() {**
    **fetcher.****submit****(****null****, {**
      **method****:** **'post'****,**
      **action****:** **`/posts/****${post.id}****/like`****,** 
      **// targeting an action on another route**
    **});**
  **}**
  return (
    <div id="post-details">
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <div className="actions">
        **<****button****className****=****"icon-btn"****onClick****=****{handleLikePost}****>**
          <FaHeart />
          <span>Like this post</span>
        </button>
        <form onSubmit={handleSubmit}>
          <button>Delete</button>
        </form>
      </div>
    </div>
  );
} 

useFetcher() 返回的 fetcher 对象具有各种属性。例如,它还包含提供有关触发操作或加载程序当前状态的属性(包括可能已返回的任何数据)。

但此对象还包括两个重要的方法:

  • load():触发路由的 loader() 函数(例如,fetcher.load('/route-path')

  • submit():使用提供的数据和配置触发一个 action() 函数

在上面的代码片段中,通过调用 submit() 方法来触发 /posts/<post-id>/like 路由上定义的操作。在没有 useFetcher()(即使用 useSubmit()<Form>)的情况下,React Router 会切换到选定的路由路径以触发其操作。使用 useFetcher() 时,可以避免这种情况,并且可以从另一个路由内部调用该路由的操作(这意味着在 /posts/<post-id> 路由活跃时,会调用为 /posts/<post-id>/like 定义的操作)。

这还允许你定义不渲染任何元素的路由(即没有页面组件的路由),而是只包含一个 loader()action() 函数。例如,/posts/<post-id>/like 路由文件(pages/like.js)看起来是这样的:

// there is no component function in this file!
export function action({ params }) {
  console.log('Triggered like action.');
  console.log(`Liking post with id ${params.id}.`);
  // Do anything else
  // May return data or response, including redirect() if needed
  return null; // something must be returned, even if it's just null
} 

如代码片段中所述,任何数据都可以在这个操作中返回。但你至少必须返回 null ——避免 return 语句并且不返回任何内容是不允许的,并且会导致错误。

它被注册为以下路由:

import { action as likeAction } from './pages/like.js';
// ...
{ path: ':id/like', action: likeAction }, 

这之所以有效,是因为这个 action() 只通过 useFetcher() 提供的 submit() 方法触发。<Form>useSubmit() 产生的 submit() 函数将启动一个路由转换到 /posts/<post-id>/like 。如果没有在路由定义上设置 element 属性,这个转换将导致一个空页面,如下所示:

img

图 14.7:显示了一个空(嵌套)页面,以及一条警告信息

由于它提供的额外灵活性,useFetcher() 在构建高度交互的用户界面时非常有用。它不是作为 useSubmit()<Form> 的替代品,而是一个额外的工具,用于不需要或不需要路由转换的情况。

延迟数据加载

到目前为止,本章中的所有数据获取示例都假设页面应该只在所有数据都获取到后才显示。这就是为什么从未有任何加载状态需要管理(因此也没有需要显示的加载重试内容)。

在许多情况下,这正是你想要的这种行为,因为仅仅为了在几秒钟内显示一个加载指示器或类似的重试内容,然后再用实际页面数据替换它,通常是没有意义的。

但也有可能需要相反的行为——例如,如果你知道某个页面将需要相当长的时间来加载数据(可能是因为必须在后端执行的一个复杂的数据库查询)或者如果你有一个页面,它加载不同的数据块,其中一些块比其他块慢得多。

在这种情况下,即使某些数据仍然缺失,渲染页面组件可能也是有意义的。React Router 也支持这种用例,允许你延迟数据加载,这反过来又使得页面组件可以在数据可用之前被渲染。

延迟数据加载就像从加载器返回一个承诺(而不是在那里等待它)一样简单:

// ... other imports
export async function loader() {
  return **{**
    **posts****:** **getPosts****()**
  **};**
} 

在这个例子中,getPosts() 是一个返回(慢速)Promise 的函数:

async function getPosts() {
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  await wait(3); // utility function, simulating a slow response
  if (!response.ok) {
    throw new Error('Could not fetch posts');
  }
  const data = await response.json();
  return data;
} 

React Router 允许你返回原始承诺。这样做时,你可以在客户端代码中等待那些承诺产生的实际值。

在使用 useLoaderData() 的组件函数内部,你还必须使用 React Router 提供的新组件:Await 组件。它的使用方式如下:

**import** **{** **Suspense** **}** **from****'react'****;**
**import** **{** **Await** **}** **from****'react-router-dom'****;**
// ... other imports
function PostsLayout() {
  const data = useLoaderData();
  return (
    <div id="posts-layout">
      <nav>
        **<****Suspense****fallback****=****{****<****p****>****Loading posts...****</****p****>****}>**
          **<****Await****resolve****=****{data.posts}****>**
            **{(loadedPosts) =>** **<****PostsList****posts****=****{loadedPosts}** **/>****}**
          **</****Await****>**
        **</****Suspense****>**
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
} 

<Await> 元素接受一个 resolve 属性,该属性从加载器数据接收类型为 Promise 的值。它被 React 提供的 <Suspense> 组件包裹。

传递给解决的是存储在 loader() 函数返回的对象中的 Promise。在那里,一个名为 posts 的键被用来保存那个 Promise。那个键的值是 getPosts() 返回的 Promise。这就是通过 <Await resolve={data.posts}> 传递给 resolvePromise。如果使用了不同的键名(例如,blogPosts),在设置 resolve 时必须引用那个键名(例如,<Await resolve={data.blogPosts}>)。

Await 在调用作为 <Await> 子组件(即在 <Await> 开启和关闭标签之间传递的函数)之前,会自动等待 Promise 解决。这个函数由 React Router 在延迟操作的数据可用时执行。因此,在这个函数内部,loadedPosts 作为参数接收,最终的用户界面元素可以被渲染。

用作 <Await> 包装器的 Suspense 组件定义了一些后备内容,在延迟数据尚未可用时渲染。在第十章React 背后的场景和优化机会中,Suspense 组件被用来显示一些后备内容,直到缺失的代码下载完成。现在,它被用来填补直到所需数据可用的时间。

图 14.8所示,当返回一个 Promise(并使用 <Await>)时,网站的其他部分,那些不是通过 <Await> 加载的,在等待帖子数据的同时已经渲染并显示出来。

img

图 14.8:在帖子列表加载时,帖子详情已经可见

返回 Promise 并在客户端代码中等待它的另一个大优点是,你可以轻松地组合多个获取过程,并控制哪些过程应该延迟,哪些不应该。例如,一个路由可能正在获取不同的数据片段。如果只有一个过程比较慢,你可以像这样只延迟慢的那个:

export async function loader() {
  return {
      posts: getPosts(), // slow operation => deferred
      userData: await getUserData() // fast operation => NOT deferred
    };
} 

在这个例子中,getUserData() 没有被延迟,因为在其前面添加了 await 关键字。因此,JavaScript 会在从 loader() 返回之前等待那个 PromisegetUserData() 返回的 Promise)解决。因此,当 getUserData() 完成时,路由组件就会被渲染,但在 getPosts() 完成之前。

摘要和关键要点

  • React Router 可以帮助你处理数据获取和提交。

  • 你可以为你的路由注册 loader() 函数,使得数据获取在路由变得活跃时初始化。

  • loader() 函数返回数据(或包含数据的响应)可以在你的组件函数中使用 useLoaderData() 访问。

  • loader() 数据可以通过 useRouteLoaderData() 在组件间共享。

  • 你也可以在你的路由上注册 action() 函数,这些函数在表单提交时被触发。

  • 要触发action()函数,你必须使用 React Router 的<Form>组件或通过useSubmit()useFetcher()程序化地提交数据。

  • useFetcher()可用于在不启动路由转换的情况下加载数据或提交数据。

  • 在获取慢速数据时,你可以在loader()中返回承诺而不等待,以延迟加载路由的一些或全部数据。

接下来是什么?

获取和提交数据是极其常见的任务,尤其是在构建更复杂的 React 应用程序时。

通常,这些任务与路由转换紧密相关,React Router 是处理此类操作的完美工具。这就是为什么 React Router 包提供了强大的数据管理功能,极大地简化了这些过程。

在本章中,你学习了 React Router 如何帮助你获取或提交数据,以及哪些高级特性有助于你处理基本和更复杂的数据操作场景。

因此,本章总结了你需要了解的核心 React Router 特性列表。

下一章将探讨 React 的服务器端功能以及如何使用 React 构建全栈应用程序,在服务器上加载数据,并使用 Next.js 框架。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/14-routing-data/exercises/questions-answers.md中找到的示例进行比较。

  1. 数据获取和提交与路由有何关联?

  2. loader()函数的目的是什么?

  3. action()函数的目的是什么?

  4. <Form><form>之间有什么区别?

  5. useSubmit()useFetcher()之间有什么区别?

  6. 返回承诺而不是在loader()中等待背后的想法是什么?

应用你所学的知识

将你对路由的知识与数据操作相结合,应用于以下活动。

活动十四点一:待办事项应用程序

在这个活动中,你的任务是创建一个基本的待办事项列表 Web 应用程序,允许用户管理他们的日常待办任务。完成的页面必须允许用户添加待办事项、更新待办事项、删除待办事项和查看待办事项列表。

以下路径必须得到支持:

  • /:主页面,负责加载和显示待办事项列表

  • /new:一个页面,以模态形式在主页面上方打开,允许用户添加新的待办事项

  • /:id:一个页面,也可以在主页面上方以模态形式打开,允许用户更新或删除选定的待办事项

如果还没有待办事项,应在/页面上显示一条合适的信息。如果用户尝试访问无效的待办事项 ID 的/:id,应显示一个错误模态。

注意

对于这个活动,没有可用的后端 API。相反,使用 localStorage 来管理待办事项数据。请注意,loader()action() 函数是在客户端执行的,因此可以使用任何浏览器 API,包括 localStorage

你可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/14-routing-data/activities/practice-1/src/data/todos.js 找到添加、更新、删除和从 localStorage 获取待办事项的示例实现。

另外,不要被打开在其他页面之上的模态页面弄混。最终,这些只是嵌套页面,被设计成模态覆盖样式。如果你遇到困难,可以使用在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/14-routing-data/activities/practice-1/src/components/Modal.jsx 找到的示例 Modal 包装组件。

对于这个活动,如果你愿意,可以自己编写所有的 CSS 样式。但如果你想专注于 React 和 JavaScript 逻辑,你也可以使用解决方案中的完成 CSS 文件,见 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/14-routing-data/activities/practice-1/src/index.css

如果你使用那个文件,请仔细探索它,以确保你理解哪些 ID 或 CSS 类可能需要添加到你的解决方案的某些 JSX 元素中。

要完成这个活动,请执行以下步骤:

  1. 创建一个新的 React 项目并安装 React Router 包。

  2. 创建(如以下截图所示)的内容组件,这些组件将被加载到所需的三个页面中。同时,在这些页面之间添加链接(或程序性导航)。

  3. 启用路由并添加三个页面的路由定义。

  4. 创建 loader() 函数来加载(并使用)单个页面所需的所有数据。

  5. 为添加、更新和删除待办事项添加 action() 函数。

提示: 如果你需要从同一页面提交多个表单以执行不同的操作,你可以在 action() 函数中包含一个隐藏的输入字段来设置一些你可以检查的值,例如 <input type="hidden" name="_method" value="DELETE">。或者,你也可以设置 <Form method="delete">(或将它设置为 "patch""put" 或其他 HTTP 动词)并在 action() 函数中检查 request.method

  1. 在数据加载或保存失败的情况下添加错误处理。

完成的页面应该看起来像这样:

img

图 14.9:主页面显示待办事项列表

img

图 14.10:/new 页面,以模态形式打开,允许用户添加新的待办事项

img

图 14.11:/:id 页面,也可以作为模态打开,允许用户编辑或删除待办事项

img

图 14.12:如果没有找到待办事项,将显示一条信息消息

注意

本活动的完整代码和解决方案可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/14-routing-data/activities/practice-1找到。

第十五章:服务器端渲染与使用 Next.js 构建全栈应用程序

学习目标

到本章结束时,你将能够做到以下几件事情:

  • 描述客户端和服务器端 React 之间的区别

  • 确定要构建哪种类型的 React 应用程序

  • 使用 Next.js 框架构建全栈 React 应用程序

  • 解释 Next.js 的关键特性和优势

简介

到目前为止,在这本书中,你已经学到了很多关于构建客户端 React 应用程序的知识,即,在网站访问者的浏览器中执行(转换后的)React 代码的应用程序。

这是有意义的,因为 React 最初是为了通过在客户端运行 JavaScript 代码来简化构建交互性和反应性 UI 的过程而创建的。到目前为止,大多数 React 特性,包括本书中介绍到的(例如,状态、上下文和路由),都是为了实现这一目的。

但是,正如你将在本章和接下来的章节中了解到的那样,你实际上也可以在服务器端执行 React 代码。有一些 React 特性可能只能在服务器端使用——例如,将在第十六章中详细介绍的 React 服务器组件,即React 服务器组件与服务器操作

本章将带你开始学习服务器端 React,简要解释什么是服务器端渲染SSR),并介绍 Next.js,这是一个流行的、功能丰富的 React 全栈框架,允许你混合后端和前端代码。你将学习如何创建 Next.js 应用程序以及如何使用核心 Next.js 功能,如基于文件的路由。

客户端 React 应用程序的问题是什么?

单页应用程序SPAs)和客户端 React 的大优势在于,你可以构建高度反应性和交互式的 Web UI。UI 可以几乎瞬间更新,可以避免可见的页面重新加载和切换,因此你的用户可以享受到类似移动应用的用户体验。

但是,这种对客户端代码(以及因此 JavaScript)的依赖也有潜在的缺点:

  • 如果用户禁用了 JavaScript,网站将几乎无法使用。

  • 初始获取的 HTML 文档几乎是空的——数据获取和内容渲染仅在最初的 HTTP 请求和响应之后进行。

第一个点可能不是很重要,因为只有一小部分用户会禁用 JavaScript,你可以通过<noscript>标签显示适当的警告信息。

但是,第二个点可能具有重大影响。由于初始 HTML 文档几乎是空的,用户在所有 JavaScript 代码被获取并执行之前将看不到任何内容。虽然大多数用户可能不会注意到明显的延迟,但根据用户的设备和互联网连接,这可能会花费一些用户几秒钟的时间。

此外,搜索引擎爬虫(例如,谷歌的爬虫)在索引你的页面时,并不一定会等待所有客户端 JavaScript 代码被获取和执行。因此,这些爬虫可能会看到一个几乎空白的页面,从而对你的网站进行较差的排名(或者根本不进行索引)。

计算机屏幕截图  自动生成的描述

图 15.1:页面内容在页面源代码中无处可寻(即,获取的 HTML 文档)

图 15.1显示了典型 React 应用的页面源代码(可以通过右键点击网站进行检查)。如图所示,在<body>标签之间几乎没有内容。标题("Hello World!")及其下的文本在该源代码中缺失。内容之所以缺失,是因为它不是初始 HTTP 响应的一部分。相反,它是在页面加载后(以及从服务器下载了该代码之后)由转换后的 React 代码渲染的。

当然,这些缺点在所有情况下可能并不重要。如果你正在构建一些公司内部的应用程序,或者一个隐藏在登录之后(因此无论如何都不会被索引)的 UI,或者如果你只针对拥有快速设备和网络连接的用户,你可能不需要担心这些潜在的问题。

但如果你正在构建一个面向公众的网站,其中搜索引擎索引很重要,或者可能会被使用慢速设备或网络连接的用户访问,你可能想要考虑消除这些缺点。这正是SSR能够提供帮助的地方。

理解服务器端渲染(SSR)

当使用 React 时,SSR 指的是在用户访问你的网站时,在处理传入 HTTP 请求的服务器上渲染网页和因此你的 React 组件的过程。

启用 SSR 后,服务器将渲染你的 React 组件树,从而生成由你的组件及其 JSX 指令产生的实际 HTML 代码。正是这完成的 HTML 代码随后被发送回客户端。因此,网站访客将收到一个不再空白的 HTML 文件,而是包含实际页面内容。搜索引擎爬虫也会看到这些内容并相应地索引页面。

软件开发流程图  自动生成的描述

图 15.2:React SSR 的实际应用

最好的是,你不会失去 React 的客户端优势,因为,当启用 SSR 时,React 仍然像以前一样在客户端工作!一旦收到那个初始 HTML 文档,它将接管控制权,并为用户提供与没有 SSR 时相同的 SPA 体验。尽管技术上讲,在使用 SSR 时,React 将在客户端以略微不同的方式初始化。它不会在那里重新渲染整个 DOM,而是会水合服务器上渲染的页面内容。水合意味着 React 将将你的组件结构连接到基于该结构渲染的 HTML 代码(当然,是基于相同的结构渲染的),并使其具有交互性。

软件开发流程图  自动生成的描述

图 15.3:在收到渲染的 HTML 代码后,React 在客户端水合代码

因此,你将获得两者的最佳之处:对于浏览器发送的初始 HTTP 请求,将获得非空、预渲染的页面,并且用户将享受到高度反应性的 Web 应用程序。

在 React 应用程序中添加 SSR

极其重要的是要理解,启用了 SSR 的 React 应用程序需要在两个环境(服务器和浏览器)中执行代码,而客户端 React 应用程序仅依赖于浏览器。因此,要使用 SSR,必须将服务器端环境添加到 React 项目中——仅仅调整几个地方的 React 代码是不够的。

例如,标准的基于 Vite 的项目默认不支持 SSR。因此,如果你想支持 SSR,你必须编辑你的 Vite 项目设置(以及一些项目代码文件)以启用在客户端和服务器端执行 React 代码。例如,你必须添加一些处理传入 HTTP 请求并在服务器端触发 React 代码执行的代码。

注意

手动启用 SSR 需要后端开发和构建过程配置知识——除了你需要了解的 React 知识之外。

幸运的是,正如你将在本章中了解到的那样,你通常不需要经历那个设置过程。相反,你可以依赖像 Next.js 这样的框架来为你做繁重的工作。

如果你对手动配置基于 Vite 的项目中的 SSR 感兴趣,官方的 Vite SSR 文档是一个学习更多知识的好地方:vitejs.dev/guide/ssr

此外,你可以探索以下根据官方 Vite SSR 指令设置的演示项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/examples/02-ssr-enabled

手动启用 SSR 是一个非平凡的过程,需要高级的 Node.js 和后端开发知识,这是官方 React 文档建议使用 Next.js 等框架创建新 React 项目的原因之一(见react.dev/learn/start-a-new-react-project)。

但这并非唯一原因。

服务器端数据获取并非易事

除了非平凡的设置过程之外,启用 SSR 的项目还可能遇到另一个可能的问题:服务器端数据获取困难。

如果你正在构建一个需要在某些组件中获取数据(例如,通过useEffect(),如第八章处理副作用中所示)的 React 应用,你会发现当组件在服务器上渲染时,数据并未被获取。相反,数据获取只会发生在客户端。服务器端渲染的 HTML 标记将不包含依赖于获取数据的内 容。

这种行为的原因是 React 组件函数仅在服务器端执行一次——即,仅在服务器上执行了第一次组件渲染周期。你可以将 SSR 视为仅生成初始页面快照。后续的状态更新将被忽略,并且通过useEffect()触发的影响函数(副作用)在服务器端也不会执行。因此,依赖于影响函数和后续状态更新的数据获取在服务器端将不会工作。

考虑这个例子,其中Todos组件函数使用useEffect()jsonplaceholder.typicode.com/获取一些(模拟)待办事项数据:

import { **useEffect**, useState } from 'react';
import { loadTodos, saveTodo } from '../todos.js';
function Todos() {
  const [todos, setTodos] = useState();
  **useEffect****(****() =>** **{**
    **async****function****fetchTodos****() {**
      **// sends HTTP request to jsonplaceholder.typicode.com**
      **const** **todos =** **await****loadTodos****();**
      **setTodos****(todos);**
    **}**
    **fetchTodos****();**
  **}, []);**
  async function addTodoAction(fd) {
    const todo = {
      title: fd.get('title'),
    };
    const savedTodo = await saveTodo(todo);
    setTodos((prevTodos) => [savedTodo, ...prevTodos]);
  }
  return (
    <section>
      <h2>Manage your todos</h2>
      <form action={addTodoAction}>
        <input type="text" name="title" />
        <button type="submit">Add Todo</button>
      </form>
      {(!todos || todos.length === 0) && (
        <p>No todos found.</p>
      )}
      {todos && todos.length > 0 && (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      )}
    </section>
  );
} 

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/examples/03-ssr-data-fetching

当在服务器上运行此代码时,不会出现任何错误。相反,应用将按预期运行并从后端服务器获取模拟的待办事项。

然而,在服务器上生成的 HTML 文档将不包含获取的待办事项。相反,它只会包含回退文本("No todos found")。

带有文本的计算机屏幕,描述自动生成

图 15.4:渲染的 HTML 不包含实际的待办事项

生成的标记不包含获取的待办事项,因为,如上所述,React 组件函数仅在服务器端执行一次(并且传递给useEffect()的函数根本不执行)。

由于这种行为,你无法轻松执行异步操作,例如,在启用 SSR 时,在 React 组件中使用useEffect()获取数据。因此,服务器端渲染的 HTML 内容将永远不会包含那些数据。

虽然您可以想出解决该问题的方法(例如,在执行组件函数之前在服务器上执行数据获取操作),但这将是 Next.js 和一个称为 React 服务器组件RSC)的概念解决的问题。

介绍 Next.js

Next.js 是一个 React 框架——即建立在 React 之上并添加额外功能和模式的框架。具体来说,Next.js 添加了诸如基于文件的路由、内置 SSR 或自动缓存以改进性能等功能。尽管如此,最重要的是,它解锁了两个关键的 React 概念:React 服务器组件RSC)和 服务器操作。正如您将学到的,这些功能使服务器端 React 代码能够执行异步操作,例如在服务器上获取和渲染数据。

因此,Next.js 可以节省您手动启用 SSR 的麻烦,并且额外解锁了其他有助于在服务器上获取数据的强大功能。

注意

此外,还有一些替代的 React 框架,如 Remix/React Router(它们被合并以将可选的全栈 React 框架功能引入 React Router)或 TanStack Start。

Next.js 不仅存在了很长时间,而且还是本书撰写时最受欢迎(按使用量衡量)的全栈框架。

本章将引导您开始使用 Next.js,并提供其核心概念的简要概述。接下来的第十六章(第十六章React 服务器组件与服务器操作)将在此基础上进一步深入探讨。

创建 Next.js 项目

要使用 Next.js,您必须首先创建一个 Next.js 项目。技术上,它仍然是一个 React 项目,这意味着您将能够使用 React 功能,如组件、属性、状态、Hooks 或 JSX。但这是一个已经安装了 next 包的项目,并且需要 Next.js 的特定文件夹结构。您不能将 Next.js 安装到现有的(基于 Vite 的)React 项目中并开始在那里使用它。需要对项目配置和结构进行重大调整。Next.js 带有自己的构建过程,并且底层不使用 Vite。因此,创建一个全新的项目更有意义。

要开始一个新的 Next.js 项目,您应该在系统终端或命令提示符(在您希望创建新项目文件夹的系统位置)中运行以下命令:

npx create-next-app@latest first-next-app 

运行此命令后,您需要在终端中进行一些选择(例如,如果您想使用 TypeScript)。

您可以通过简单地按 Enter 键来确认所有这些选择,从而接受默认选项。但是,您应该确保您选择 No 以用于 TypeScript(除非您知道如何使用它)和 Yes 以用于 App Router。您可以在 GitHub 上找到一个(略微清理过的)起始项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/examples/04-nextjs-intro

在创建的项目文件夹内,可以通过以下方式启动开发服务器:

npm run dev 

虽然命令与 Vite 项目相同,但服务器默认会针对不同的端口。Next.js 项目使用 localhost:3000 而不是 localhost:5173(Vite)作为预览开发服务器。

就像在基于 Vite 的项目中一样,当您在项目代码上工作时,应保持此过程运行。底层构建过程会自动重新加载并更新预览网站,当您对代码进行更改时。

注意

Next.js 是一个成熟、稳定的框架,它从未停止创新和变化。

到 2022 年底,所谓的 App Router 被引入作为一种新的构建和结构化 Next.js 应用程序的方法(旧的方法现在被称为 Pages Router)。当然,这本书涵盖了新的 App Router 方法。

截至 2024 年中(本书撰写时),尽管 App Router 方法被标记为稳定,但它仍然经常收到新功能和更改。

因此,尽管可能性不大,但本书中解释的概念和代码可能会随着时间的推移而改变或损坏。上述设置过程也可能发生变化。在这种情况下,将在 GitHub 的 Changelog 文档中添加一条注释(包含如何调整代码的说明):github.com/mschwarzmueller/book-react-key-concepts-e2/blob/main/CHANGELOG.md

一个新创建的 Next.js 项目会自动安装所有依赖(npm install 是项目创建过程的一部分自动执行)并具有如下项目结构:

  • 一个包含与路由相关的文件的 app/ 文件夹(参见下一节)

  • 一个 public/ 文件夹,可以用来存储应该以静态方式提供(即,在构建过程中不被更改)的资产

  • 用于配置项目和 Next.js 特定行为的 jsconfig.jsonnextjs.config.mjs 文件

  • 用于管理项目依赖的 package.jsonpackage-lock.json 文件

因此,除了 app/ 文件夹外,它与您从 Vite 知道的结构并没有太大的不同。然而,值得注意的是,Next.js 与 Vite 不同,它不强制要求 .jsx 作为包含 JSX 代码的 JavaScript 文件的文件扩展名。您可以使用它,但不必使用。例如,起始项目使用 page.jslayout.js,而不是 page.jsxlayout.jsx,尽管这些文件包含 JSX 代码。

就像基于 Vite 的项目一样,Next.js 项目自带一个构建工作流程,当运行开发服务器或构建生产版本(你可以通过 npm run build 来做)时,它会自动处理和转换你的代码文件。

就像几乎所有现代的 React 项目设置一样,Next.js 项目因此支持将样式文件(如 globals.css)或图片导入到 JavaScript 文件中。它还允许你在导入时省略或设置文件扩展名。此外,Next.js 还支持 CSS Modules。

换句话说:你可以在 Next.js 项目中以几乎与在 Vite 基于的项目中相同的方式进行工作。

使用基于文件的路线

在基于 Vite 的项目中,当涉及到项目结构时,你拥有很高的灵活性。在 src/ 文件夹内,你可以创建任何你选择的子文件夹和文件。这些文件和文件夹的名称实际上并不重要(只要它们是有效的并且使用了正确的扩展名)。

当使用 React Router 时,你会在你的 JSX 代码文件中设置路由,并为任何路由加载和渲染存储在任何文件中的任何组件(参见 第十三章使用 React Router 的多页应用)。

在 Next.js 项目中,这略有不同,因为 Next.js 使用文件系统来定义路由——你不需要在代码中设置路由。因此,尽管你仍然拥有很多灵活性,但必须遵循一些关于项目结构和文件名的路由相关规则——否则,应用将崩溃并且无法按预期工作。

Next.js 通过其内置的路由器实现了基于文件的路由。这个路由器分析你的文件系统,根据你在项目中的文件和文件夹结构推导出支持的路线、它们的 URL 路径以及要加载和渲染的 React 组件。

当使用 App Router 方法时,因此你必须将所有应作为页面加载的组件存储在 app/ 文件夹(或嵌套文件夹)内的 page.js 文件中。由于所有路线组件文件都必须命名为 page.js,因此父文件夹的名称定义了组件将被加载的路线路径。

例如,你可能有一个如图 图 15.5 所示的文件和文件夹结构:

一个网站图示  自动生成的描述

图 15.5:在 Next.js 中,page.js 文件包含路线组件。文件夹名称决定了路径

图 15.5 中,你可以看到通过文件系统定义了四个路线:根路线(/)、/about/users/terms/en 路线。对于每个路线,存储在相应 page.js 中的组件将被加载并渲染到屏幕上。

例如,你可能有一个像这样的 app/page.js 文件:

export default function Home() {
  return (
    <main>
      <h1>Hello Next.js World ![img](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/rct-key-ccpt/img/B31339_15_001.png)</h1>
      <p>Build fullstack React applications with ease!</p>
      <p>
        Learn more about Next.js in{' '}
        <a href="https://www.udemy.com/course/nextjs-react-the-complete-guide/">
          my course
        </a>{' '}
        or the <a href="https://nextjs.org/">official documentation</a>.
      </p>
    </main>
  );
} 

正如你所见,一个常规的 React 组件函数存储在这个 page.js 文件中。组件函数的名称并不重要——重要的是它是一个文件 page.js 内导出的组件函数。因此,如果用户访问 <domain>/(或只是 <domain>,不带斜杠),屏幕上将会显示以下内容:

计算机截图  自动生成的描述

图 15.6:Next.js 路由器加载存储在 app/page.js 文件中的组件,并渲染其内容

因此,你可以轻松地添加所需数量,可能嵌套的,路由——只需创建文件夹、子文件夹和 page.js 文件即可。

使用 Next.js 进行服务器端渲染

除了提供内置的基于文件的路由器(以及将在本章和下一章中探讨的许多其他功能)之外,Next.js 还有一个其他关键优势:它默认实现 SSR。你不需要添加任何文件,更改任何配置,或调整任何代码来在服务器上渲染 React 组件——相反,它从一开始就工作。

因此,当用户访问 <domain>/ 时,app/page.js 文件组件(例如上述示例中的 Home 组件)将在服务器端进行评估和渲染。发送到浏览器的就是完成的 HTML 代码。而且,就像基于 Vite 的具有自定义 SSR 的项目一样,Next.js 也会在服务器上渲染 page.js 内可能使用的所有子组件。

此外,当使用 Next.js 构建网站时,你仍然在构建 React 应用程序。这就是为什么一旦完成 SSR,Next.js 应用程序在客户端就会变得交互式。技术上,正如你将在下一章(React 服务器组件 & 服务器操作)中了解到的那样,它们将以与基于 Vite 的 SSR 启用的 React 应用程序(其中 React 在客户端刷新服务器端渲染的标记)不同的方式变得交互式,但最终,你的网站用户将获得类似 SPA 的用户体验。

因此,如果你想构建一个支持 SSR 的 React 应用程序,建议依靠像 Next.js 这样的框架,而不是手动设置 SSR。

此外,你将能够使用其他有用的功能,例如基于文件的路由系统,特别是因为它不仅限于通过 page.js 文件定义路由。例如,它还简化了定义布局的过程。

与布局一起工作

正如之前提到的,当涉及到路由时,文件名和存储这些文件的位置很重要。

例如,你还会在上述示例的 app/ 文件夹中找到 page.js 文件旁边的 layout.js 文件。

黑屏截图  自动生成的描述

图 15.7:除了 page.js 文件、样式文件和 favicon 之外,在 app/ 文件夹中还可以找到 layout.js 文件

就像 page.js 一样,layout.js 是一个保留的文件名——即该文件以特殊方式由 Next.js 处理。

这个layout.js文件也导出一个组件函数,但创建的组件不会为特定的路径渲染。相反,它被用作所有同级或嵌套页面的包装器。因此,layout.js文件可以用来定义将在多个页面之间共享的 JSX 代码。

由于它被设计为包装组件,layout.js导出的组件函数必须使用特殊的children属性(见第三章组件与属性)来定义被包装页面内容应该显示的位置。

例如,你可以使用app/layout.js文件来定义一个全局布局,在<main>内容上方添加导航栏:

export default function RootLayout(**{ children }**) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <ul>
              <li><a href="/">Home</a></li>
              <li><a href="/events">Events</a></li>
            </ul>
          </nav>
        </header>
        <main>**{children}**</main>
      </body>
    </html>
  );
} 

在这个示例代码片段中,还值得注意的是,RootLayout组件渲染了<html><body>元素。在基于 Vite 的项目中,你不会这样做。在那里,你而是在index.html文件中定义一个位置,以便将渲染的 HTML 注入其中(通过react-dom包暴露的createRoot()函数;见第二章理解 React 组件与 JSX)。

Next.js 不依赖于这样的index.html文件;相反,它强制你在app/文件夹的顶层定义一个根layout.js文件。然后,这个根布局必须定义渲染的 HTML 页面的总体结构。然而,该文件中没有<head>部分,因为 Next.js 将在幕后管理和注入该部分。此外,Next.js 还会将 JavaScript 和 CSS 导入插入到渲染的 HTML 文档中。

如果你想要有嵌套布局,并且这些布局只包装一些页面,你可以添加更多(嵌套)layout.js文件。这样的布局是可选的;然而,根布局(app/layout.js)是必需的。

在一个包含app/page.js文件和app/events/page.js文件的项目中,使用类似于前一个代码示例中的layout.js文件,网站用户可以访问这两个页面并看到共享的导航。

计算机屏幕截图  自动生成的描述

图 15.8:当用户从/到/events 导航时,共享的标题保持不变

图 15.8中,主要内容(由page.js文件定义)发生变化,但共享的导航(在layout.js中设置)保持不变。

虽然共享 JSX 标记是使用布局的最常见用例,但你也可以通过将 CSS 文件导入到layout.js文件中来共享样式:

**import****'./globals.css'****;**
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      Unchanged JSX code…
    </html>
  );
} 

在这个和上面的示例中,组件函数被命名为RootLayout——这个名称并不重要,但它必须是一个导出的组件。

当然,如果你在共享导航栏的布局中添加了有效链接,那么这些布局将变得更加有用...

管理内部导航

在前面的代码示例中,使用<a>元素创建了不同 Next.js 应用程序页面之间的链接。

然而,就像其他 React 应用程序一样,Next.js 应用程序在完成初始页面加载后将成为单页应用程序(SPA)。因此,出于与在基于 Vite 的 React 项目中使用 React Router 时相同的原因,不建议使用 <a> 标签创建内部链接(比较 第十三章使用 React Router 的多页应用程序)。

与 React Router 类似,Next.js(负责 Next.js 项目的路由)提供了一个特殊的 Link 组件,你应该用于内部链接(而不是 <a> 元素):

**import****Link****from****'next/link'****;**
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <ul>
              <li>**<****Link****href****=****"/"****>****Home****</****Link****>**</li>
              <li>**<****Link****href****=****"/events"****>****Events****</****Link****>**</li>
            </ul>
          </nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
} 

<Link> 组件接受一个 href 属性,该属性设置为目标路径。内部,Next.js 将捕获链接点击,并通过加载和渲染所需的 page.js 组件来相应地更新浏览器地址栏和网站 UI。

突出显示活动链接和使用“use client”指令

如果你想在链接指向当前激活页面时以不同的方式样式化链接,你不会在 React Router 中找到内置的 NavLink 组件。相反,你必须通过根据当前激活路径动态设置 Link 组件的 className 属性来添加自己的逻辑。

要找出当前激活的路径,你可以使用 Next.js 提供的 usePathname() 钩子:

import { usePathname } from 'next/navigation';
const path = usePathname(); 

例如,你可以调整 layout.js 文件,使其看起来像这样:

import Link from 'next/link';
**import** **{ usePathname }** **from****'next/navigation'****;**
import './globals.css';
export default function RootLayout({ children }) {
  **const** **path =** **usePathname****();**
  return (
    <html lang="en">
      <body>
        <header>
          <nav>
            <ul>
              <li>
                <Link 
                  href="/" 
                  **className****=****{path** **===** **'/'** **? '****active****'** **:** **''}>**
                  Home
                </Link>
              </li>
              <li>
                <Link
                  href="/events"
                  **className****=****{path.startsWith(**
                    **'/****events****'**
                  **) ? '****active****'** **:** **''}**
                >
                  Events
                </Link>
              </li>
            </ul>
          </nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
} 

然而,如果你运行这段代码,你会得到一个错误消息:

计算机屏幕截图  描述自动生成

图 15.9:Next.js 对服务器组件中 Hook 的使用提出警告

这个错误消息听起来相当晦涩,因为它提到了 Client ComponentServer Components。这两个都是将在下一章(React 服务器组件与服务器操作)中探讨的关键 React 概念。

对于当前章节,了解这个问题的修复方法就足够了,即在 app/layout.js 文件的顶部添加 "use client" 指令:

**"use client"****;**
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import './globals.css';
export default function RootLayout({ children }) {
  const path = usePathname();
  // return JSX code
} 

"use client" 是一个所谓的 指令,即一个“告诉”React 和 Next.js 该文件必须以特殊方式处理的指令。添加它将消除 图 15.9 中显示的错误消息,从而启用路径感知的 Link 样式。正如之前提到的,这个指令的具体影响将在下一章中探讨。

在 Next.js 项目中,无论你计划在组件中使用 React 或 Next.js 提供的哪个 Hook,都必须添加 "use client" 指令。

注意

你可能会想知道为什么对于使用 Hooks 的组件来说需要 "use client"。毕竟,在使用基于 Vite 的项目中的 SSR 时,这个指令并不是必需的。

原因是 Next.js 在本节开头介绍的技术上并不使用 SSR。相反,Next.js(当使用 App Router 时)使用一个名为 React Server Components 的 React 功能。这个关键特性将在下一章中详细探讨。在那里,你还将了解到为什么在某些组件中需要 "use client"

创建和使用常规组件

在前几节中提到的 Link 组件是 Next.js 提供的组件。但当然,你也可以构建你自己的组件——毕竟它仍然是一个 React 应用。

除了作为页面(page.js)或布局(layout.js)暴露的组件外,你可以在你选择的任何文件(任何名称)中创建和使用组件函数。

例如,你可以在 app/ 文件夹旁边添加一个 components/ 文件夹,并在其中添加一个 MainNavigation.js 文件。然后,这个文件可以包含一个新的 MainNavigation 组件,该组件返回与导航相关的 JSX 代码:

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export default function MainNavigation() {
  const path = usePathname();

  return (
    <header>
      <nav>
        <ul>
          <li>
            <Link href="/" className={path === '/' ? 'active' : ''}>
              Home
            </Link>
          </li>
          <li>
            <Link
              href="/events"
              className={path === '/events' ? 'active' : ''}
            >
              Events
            </Link>
          </li>
        </ul>
      </nav>
    </header>
  );
} 

请注意,必须在 MainNavigation.js 文件顶部添加 "use client",因为组件函数中使用了 usePathname() 钩子。

将代码移动到这个新添加的 MainNavigation 组件中后,在 layout.js 文件中,可以移除 "use client",因为该文件中不再使用 usePathname() 钩子。它在子组件(在 <MainNavigation/> 内部)中使用,但 React 并不关心这一点。

因此,更新后的 layout.js 文件看起来是这样的:

import './globals.css';
import MainNavigation from '../components/MainNavigation';
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <MainNavigation />
        <main>{children}</main>
      </body>
    </html>
  );
} 

多亏了构建、外包和使用自定义的 MainNavigation 组件,因此更新后的 layout.js 文件再次包含了一个精简且简洁的组件函数。

注意

除了与路由相关的文件外,如何构建你的 Next.js 项目以及如何命名你的文件完全取决于你。

如前所述,你可以在你选择的任何位置(任何名称的文件夹)中存储自定义(非页面)组件在 components/ 文件夹中。你可以将这个 components/ 文件夹放入 app/ 文件夹或根项目文件夹中。

你也可以完全不使用 components/ 文件夹,而是将组件存储在位于你的 page.js 文件旁边的文件中。因为如果一个文件不是命名为 page.js,它就不会被视为页面——所以不会意外地在你的项目中创建你不想要的路线。如果你有 app/components/MainNavigation.js 但没有 app/components/page.js 文件,就不会有 /components 路由。未命名为 page.js(或其他保留文件名——见即将到来的部分 其他文件名约定)的文件将被 Next.js(用于路由目的)简单地忽略。

你可以在官方文档中找到更多关于 Next.js 项目组织的信息和想法:nextjs.org/docs/app/building-your-application/routing/colocation

处理动态路由

正如你在 第十三章 中学到的,使用 React Router 的多页应用,在 从静态到动态路由 部分中,许多 React 应用也需要支持动态路由。

例如,你可能希望允许你的用户访问 /events/e1 来查看 ID 为 e1 的活动的详细信息,以及 /events/e2 来查看 ID 为 e2 的活动(等等)。

这是一个如此常见的需求,Next.js 当然支持它。你可以在 Next.js 应用程序中通过创建一个文件夹(在app/文件夹中的某个位置)并使用方括号包裹其名称来添加动态路由——例如,app/events/[eventId]。当然,你仍然需要在那个文件夹中创建一个page.js文件来实际创建路由。

方括号之间的部分(例如,eventId)完全由你决定。但方括号告诉 Next.js 你正在设置一个动态路由。

方括号之间的文件夹名称充当一个标识符,可以用来检索 URL 中编码的具体值(例如,检索/events/e1中的e1)。

每个用作页面(或布局)的组件都会接收到一个由 Next.js 自动设置的params属性。如果它是一个动态路由文件夹或某些嵌套子文件夹中的页面或布局,则params属性将包含一个解析为包含所选标识符(如eventId)作为键和具体 URL 路径值(如e1)作为这些键的值的对象。由于params包含一个Promise,因此必须对它使用await来访问其底层对象。

例如,app/events/[eventId]/page.js文件将确保在访问/events/e1/events/e2等时,在page.js文件中导出的组件被渲染。然后,该页面组件可以使用以下代码输出事件详情:

// getEventById is a custom dummy function to load event data
import { getEventById } from '@/lib/events'; 
export default async function EventDetailsPage(**{ params }**) {
  **// params.eventId exists because of folder name => [eventId]**
  **const** **{ eventId } =** **await** **params;** 
  const event = getEventById(eventId);
  return (
    <div id="event-details">
      <header>
        <img src={`/${event.image}`} alt={event.title} />
        <h1>{event.title}</h1>
        <p>
          {event.location} | {event.date}
        </p>
      </header>
      <p>{event.description}</p>
      <p>
        <button>Register</button>
      </p>
    </div>
  );
} 

在这个例子中,自动提供的params属性用于获取访问 URL 中编码的eventId。如果文件夹名称中使用的是除eventId之外的标识符,则将使用该替代名称来访问路径值(例如,对于[id]/page.js,你会访问(awaitparams.id)。

因此,用户可以访问这个动态路由,探索所选事件 ID 的事件详情。

计算机屏幕截图  自动生成的描述

图 15.10:为/events/e1 加载并显示事件详情

注意

你可以在 GitHub 上找到完整的示例代码,包括lib/events.js文件:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/examples/08-nextjs-dynamic-routes

当然,当使用动态路由时,你通常还需要在应用程序的一些部分中使用指向这些动态路径的链接。因此,在这个例子中,app/events/page.js文件包含代码,动态渲染一个事件项列表,其中每个项都有一个链接到其详情页面:

import Link from 'next/link';
import { getEvents } from '@/lib/events';
export default function EventsPage() {
  const events = getEvents();
  return (
    <div id="events">
      <h2>Browse available events</h2>
      <ul>
        {events.map((event) => (
          <li key={event.id}>
            <img src={event.image} alt={event.title} />
            <div>
              <h2>{event.title}</h2>
              <p>{event.description}</p>
              <p>
                <Link 
                  **href****=****{****`/****events****/${****event.id****}`**}>Explore Event</Link>
              </p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
} 

点击这些链接将用户带到特定事件 ID 的事件详情页面。

注意

当使用 Next.js 时,静态路由、动态路由和嵌套路由是你需要了解的最重要路由类型。你将在大多数路由中使用它们。

此外,Next.js 还提供其他(更高级和特定)的路由类型和功能,如果你决定深入研究 Next.js,这些内容值得探索:nextjs.org/docs/app/building-your-application/routing

除了通过使用适当的文件夹名称启用不同的路由类型之外,Next.js 还提供额外的保留文件名。

其他文件命名约定

Next.js 不仅提供各种路由类型和与路由相关的功能,还提供了比 page.jslayout.js 更多的保留文件名。

因此,当使用 Next.js App Router 时,你应该也意识到以下保留文件名也存在:

  • loading.js 文件可以添加到 page.jslayout.js 文件旁边或上方,以定义在页面(或布局)组件获取数据时应显示的组件。

  • error.js 文件可以添加到与 loading.js 文件相同的位置,以在兄弟或子页面中抛出错误时渲染回退错误组件。

  • not-found.js 文件可以被添加以显示回退内容,以防网站访客尝试加载不存在的路由或资源。

  • route.js 文件可以被添加来设置不渲染组件而是返回数据(例如,在 JSON 格式)的路由。

你可以在官方文档中了解更多关于这些文件类型以及更多的文件命名约定:nextjs.org/docs/app/building-your-application/routing#file-conventions

你也将在下一章中看到一些这些文件类型的实际应用。

深入学习 Next.js

到目前为止,你已经有了坚实的 Next.js 基础,但如前所述,你可以借助官方文档深入了解 Next.js。

在那里,除了了解更多关于路由、路由类型和文件名之外,你还可以探索 Next.js 如何帮助缓存、样式或管理页面元数据。由于这本书主要关于 React 本身,而不是 Next.js,在这里涵盖所有这些主题会迅速使这本书膨胀。

因此,本章重点在于建立一个坚实的 React SSR 和 Next.js 基础。本章涵盖的基本内容将有助于理解下一章中更高级的 React 和 Next.js 功能,如 React 服务器组件。此外,得益于这些基础知识,你还将能够借助官方文档或专门的 Next.js 书籍或课程快速学习更多关于 Next.js 的知识。

摘要和关键要点

  • 默认情况下,基于 Vite 的 React 应用(如大多数不使用 Next.js 或类似框架的 React 应用)仅支持客户端渲染。

  • 没有服务器端渲染(SSR)时,一个相对空白的 index.html 文件会被发送到客户端。

  • 这可能会导致不良的用户体验(如果用户长时间看到空页面)或次优的搜索引擎排名。

  • 您可以通过手动调整 React 项目(代码和构建过程)以支持组件在服务器端执行来启用 SSR。

  • 为了避免自定义 SSR 设置工作并利用许多其他好处,您可以使用像 Next.js 这样的框架。

  • Next.js 项目自带内置 SSR 支持,可以通过npx create-next-app命令创建。

  • 现代 Next.js 使用 App Router 方法,该方法利用一个app/目录,该目录用于通过文件系统设置路由。

  • app/目录内,您通过创建包含page.js文件的文件夹来定义页面(例如,app/about/page.js添加了对/about路由的支持)。

  • 要在页面之间共享 JSX 代码(以及逻辑或样式),您可以添加layout.js文件。

  • Next.js 还提供其他保留的文件名来处理加载数据时显示的回退内容或处理错误。

  • 您可以通过 Next.js 的Link组件在页面之间建立链接。

  • 当使用 React Hooks(如useState()或 Next.js 的useRouter())时,您必须在使用 Hook 的文件顶部添加"use client"指令。

  • 除了静态页面(如app/events/page.jsapp/about/page.js),您还可以通过将文件夹名称用方括号括起来来设置动态页面(例如,app/events/[eventId]/page.js)。

  • 动态路径参数值可以通过 Next.js 在加载的页面组件中使用特殊params属性来提取,该属性由 Next.js 设置在组件上。

  • 在使用 SSR 时,异步操作可能会出现问题——或者至少,它们不能在服务器上渲染的组件中执行,这迫使客户端代码执行它们。至少当不使用 React Server Components 时是这样。

接下来是什么?

到目前为止,您已经了解了关于 React 应用中的 SSR 和 Next.js 的很多知识。您能够创建 Next.js 项目,定义路由,渲染页面组件,添加导航,并处理动态路径。

您还了解到 Next.js 自带内置 SSR。因此,当网站访客发送请求时,所有 React 组件(内置和自定义,页面和非页面)都在服务器上渲染。

然而,现代 Next.js 并没有止步于此——与本章开头介绍的定制 SSR 设置不同,使用 App Router 的 Next.js 项目通过解锁 React 的React Server Component功能帮助在服务器端进行异步数据获取。下一章将详细介绍这个功能和Server Actions

测试您的知识!

通过回答以下问题来测试您对本章涵盖的概念的理解。然后,您可以比较您的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/15-ssr-next-intro/exercises/questions-answers.md找到的示例:

  1. SSR 可以提供哪两个主要优势?

  2. SSR 有哪些潜在的缺点或弱点?

  3. Next.js 如何帮助进行 SSR?

  4. 在使用“App Router”时,Next.js 中是如何配置路由的?

  5. Next.js 中的页面组件有什么特别之处?

  6. 布局组件在 Next.js 中的用途是什么?

  7. 在 Next.js 项目中,您可以在哪里存储非页面(和非布局)React 组件?

  8. 何时何地需要添加 "use client" 指令?

应用所学知识

在了解了关于 Next.js 的所有新知识后,是时候将其应用到实际演示项目中了——一个将在服务器上渲染的演示应用程序。

在下一节中,您将找到一个活动,让您练习使用 Next.js。一如既往,您还需要运用之前章节中介绍的一些概念。

活动 15.1:迁移基于 Vite 的 React Router 应用

在这个活动中,您的任务是建立在 活动 13.1 中的基于 Vite 的应用程序之上。该应用程序是用 Vite 和 React Router 构建的。您的任务是将其从 Vite 和 React Router 迁移到 Next.js。

因此,您应该创建一个新的 Next.js 项目(使用 App Router),并在该项目中重新构建相同的应用程序。

注意

您可以在以下位置找到此活动的起始代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/activities/practice-1-start 。在下载此代码时,您将始终下载整个存储库。请确保然后导航到包含起始代码的子文件夹(在这种情况下是 activities/practice-1-start)以使用正确的代码快照。

由于您的任务是迁移在 活动 13.1 中构建的项目,您可能还想使用该活动的完成代码。您可以在以下位置找到它:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/13-routing/activities/practice-1

下载代码并在项目文件夹中运行 npm install 以安装所有必需的依赖项后,解决方案步骤如下:

  1. 如果您创建了一个新的 Next.js 项目(即,如果您没有使用提供的起始快照),请清理 layout.jspage.js 文件,以删除除组件函数之外的所有内容。

  2. 创建两条新路由:一条是 /products 路由,另一条是 /products/<some-id> 路由。

  3. data.js 文件迁移到 Next.js 项目中(例如,在根项目文件夹中的 lib/ 文件夹中)。

  4. 更新页面组件以加载和显示 data.js 文件提供的数据。

  5. 创建一个新的 components/ 文件夹,并将 MainNavigation 组件迁移(复制)到这个文件夹中。

  6. 更新 MainNavigation 组件(以及任何需要它的其他组件)以使用 Next.js 的 Link 组件。

  7. 使用 usePathname() 钩子来突出显示活动链接——别忘了 "use client" 指令!

  8. index.css文件中的样式迁移到globals.css文件中。确保该文件被导入到根布局文件中。

预期的结果应如图下所示截图所示:

img

图 15.11:主页内容

img

图 15.12:/products 页面内容

img

图 15.13:/products/页面内容

注意

您可以在此处找到此活动的完整代码以及一个示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/15-ssr-next-intro/activities/practice-1 .

第十六章:React 服务器组件 & 服务器操作

学习目标

到本章结束时,你将能够做到以下几点:

  • 创建和使用 React 服务器组件 ( RSCs )

  • 描述 RSCs 如何(以及何时)执行并渲染到屏幕上

  • 在 RSCs 的帮助下获取数据并执行异步操作

  • 通过构建和使用客户端组件绘制服务器-客户端边界

  • 在服务器端使用服务器操作执行数据突变

  • 根据服务器操作更新 用户界面 ( UI )

简介

在前一章中,你了解到你可以使用 服务器端渲染 ( SSR ) 在服务器上渲染 React 组件。SSR 确保用户在他们的初始 HTTP 请求时收到一个完全填充的 HTML 文档,而不是一个几乎空白的页面外壳。你还被介绍了 Next.js,并学习了如何使用该框架构建带有 SSR(以及许多其他有用功能)的 React 应用程序。

本章基于前一章的内容——特别是,你将了解由 Next.js 解锁的两个关键 React 功能:React 服务器组件 ( RSCs ) 和 服务器操作 .

在本章中,你将了解这两个功能如何帮助数据获取和突变,以及为什么你无法在每一个 React 项目中使用它们,尽管它们在技术上属于 React——而不是 Next.js。

注意

RSCs 和服务器操作是相对较新的 React 功能。在本章中,你将了解到在自定义 React 项目中支持它们是棘手的。

虽然不太可能,但有关 RSCs 或服务器操作的概念或功能可能会发生变化。也有可能支持这些功能在自定义项目中变得更加容易。

因此,这本书附带了一份专门的文档,跟踪你应该注意的任何重大变化:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/main/CHANGELOG.md .

服务器端数据获取的问题

如果你有一个启用了 SSR 的 React 应用程序,无论是通过手动启用它,例如在一个基于 Vite 的项目中,还是通过使用像 Next.js 这样的框架,你的 React 组件函数将在服务器上执行。因此,这些组件所需的所有数据都应该在服务器上获取。

但正如前一章中解释的,在 服务器端数据获取并非易事 部分中,使用 useEffect() 发送 HTTP 请求或尝试通过 useState() 更新 UI 在使用 SSR 时不起作用。在服务器上,React 只调用组件函数一次——当状态改变时不会重新执行它们。它也不会调用你的效果函数。

这是一个严重的限制,因为许多 React 应用需要从某些后端或数据库中获取数据。无法在服务器上获取和渲染这些数据意味着网站访客将再次收到不完整的 HTML 文档(并等待在客户端获取数据),而搜索引擎爬虫将看不到网页的最重要内容。

这也是 React 引入RSCs的原因之一。

介绍 RSCs

尽管名为 RSCs,但它们并不一定是运行在服务器上的组件。相反,它们的定义特征是,在任何情况下,它们的组件功能都不会在客户端执行!

因此,RSCs 可能会在服务器上执行,但它们也可能在构建过程中被调用,因此在构建时预先生成组件。不过,它们肯定不会在浏览器中执行。

服务器图示 自动生成描述

图 16.1:RSCs 不能从客户端调用

但 RSCs 的目的是什么?它们是如何创建和使用的?

理解 RSCs

RSCs 背后的核心思想是你可以构建在浏览器之外渲染的组件(例如,在服务器上)。因此,这些组件可以执行在浏览器中无法运行的代码——例如,因为使用了 Node.js 特定的 API,或者依赖于必须不向客户端暴露的凭据(例如,数据库凭据)的代码。

与通过 SSR 渲染的“正常”组件(客户端组件)不同,RSCs 可以在初始页面加载后(在服务器上)进行渲染。因此,RSCs 不仅仅是关于渲染初始页面快照。此外,RSCs 还可以在服务器端获取数据。在本章的后面部分,“RSCs 与服务器端渲染”部分将更详细地探讨 RSCs 与通过 SSR 渲染的“正常”组件之间的关系。

因此,RSCs 解决了重要问题:它们允许你将前端和后端 React 代码交织在一起。在过去,在 RSCs 之前,你通常必须构建单独的后端和前端 Web 应用程序,而现在你可以构建集成的全栈应用程序,将服务器端和客户端的 React 代码融合在一起。

因此,使用 RSCs 提供了各种优势:

  • 构建完全集成的全栈应用程序变得更加容易,其中后端和前端紧密相连并使用相同的服务器。

  • 在组件内部进行异步服务器端数据获取成为可能:与客户端(或使用 SSR)不同,React 允许你在组件函数中使用async/await并返回Promise值。

  • 网站访客下载的客户端 JavaScript 包更小,因为 RSCs 的代码被省略了。

  • 由于操作及其代码可以外包给服务器(或构建过程),因此运行计算密集型操作或使用大型第三方库变得更加容易。

  • 不应被网站用户访问的代码或凭据可以移动到 RSCs 中。

例如,多亏了 RSC,您可以创建如下组件:

import pg from 'pg'; // pg package (more info: node-postgres.com)
const { Client } = pg

const client = new Client({
  user: 'username',
  password: 'your-password',
  host: 'my.database-server.com',
  port: 5334,
  database: 'demo',
});
async function ProductsPage() {
  await client.connect();
  const res = await client.query('SELECT * FROM products'); 
  await client.end();
  return (
    <ul>
      {res.rows.map(row => <li key={row.id}>{row.title}</li>)}
    </ul>
  );
} 

ProductsPage 组件包含代码,该代码会连接到 PostgreSQL 数据库以从那里获取产品数据。

没有 RSC,这种组件将无法构建和使用。您将不允许使用 async/awaitpg 包可能依赖于浏览器中不可用的某些 API,并且您会在客户端代码捆绑中暴露数据库凭证。

在构建 RSC 时,所有这些操作都是允许的。React 明确允许您在构建 RSC 时返回一个 Promise(因此可以使用 async/await)。由于代码保证永远不会出现在客户端,因此连接到数据库也是安全的。

因此,您可以轻松构建完全集成的全栈应用程序,其中后端和前端代码无缝融合。

然而,使用 RSC 既是简单又是复杂的,下一节将进行解释。

创建和使用 RSC

在使用 App Router 的 Next.js 项目中,所有 React 组件,无论它们是作为页面使用还是嵌套在其他组件中,默认情况下都是 RSC。

如您在 Next.js 项目中检查任何 React 组件函数时可以看出的,它们实际上并没有什么特别之处。它们看起来就像普通的 React 组件:

export default function ServerComponentInfo() {
  return <p>This is a React Server Component.</p>;
} 

您可以使用 async/await 与它们一起使用,但您不必这样做。您可以使用服务器端 API 和包,但您不必这样做。因此,创建 RSC 是简单的——它们毕竟只是普通组件。

使用它们也是一样——您像使用 React 组件一样使用它们:作为自定义 JSX 元素:

<ServerComponentInfo /> 

如您所见,您无法分辨出这是一个特殊类型的组件。它是按照您在这本书中学到的全部内容创建和使用的。

尽管如此,本书其他章节中使用的所有其他组件,在基于 Vite 的 React 项目中,都不是 RSC。它们是普通组件或客户端组件

那么,是什么让 Next.js 项目的组件变得特殊?为什么 React 提供的功能在 Next.js 项目中可用,但在其他 React 项目(例如基于 Vite 的项目)中不一定可用?

在 React 项目中解锁 RSC

RSC(React Server Components)是 React 提供的功能,而不是 Next.js。然而,并非所有 React 项目都能使用这个功能。

原因在于此,以及为什么 RSC 在 Next.js 项目中可用,是因为 Next.js 的构建过程以及 Next.js 在幕后对这些组件(以及整个 React 项目代码)所做的事情。从高层次来看,您可以认为 Next.js 做了以下事情:

  • 构建工作流程和捆绑过程将服务器和客户端组件分离,以确保没有 RSC 代码出现在客户端。

  • 当需要时,Next.js 会调用这些 API 端点(即客户端代码可以发送请求的 URL 地址),在服务器上触发 RSC 组件函数,并返回允许客户端 React 代码更新 UI 的指令。

  • 当导航到新页面时,Next.js 会调用这些端点。

  • Next.js 将 API 响应(其中包含这些渲染指令)传递给 React,React 使用返回的指令按需更新 UI。

img

图 16.2:客户端和服务器组件代码是分开的;通信通过 HTTP 请求进行

从技术上讲,这要复杂一些,但为了本书的目的和为了使用此功能,不需要深入了解内部机制——就像您在使用useState()时不需要了解内部具体发生了什么一样。

您可以通过运行您在此处找到的演示 Next.js 项目来验证所提到的点:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/16-rsc-server-actions/examples/01-rsc-intro

此演示应用程序由两个基本的页面组件文件组成:app/page.jsapp/info/page.js。主页面组件(位于app/page.js中的Home组件)输出一个ServerComponentInfo组件:

import ServerComponentInfo from '../components/ServerComponentInfo';
export default function Home() {
  return <ServerComponentInfo />;
} 

该组件随后简单地输出一些静态、硬编码的内容:

import Link from 'next/link';
export default function ServerComponentInfo() {
  return (
    <div id="rsc-info">
      <p>This is a React Server Component.</p>
      <p><Link href="/info">Learn More</Link></p>
    </div>
  );
} 

HomeServerComponentInfo组件都是 RSC——仅仅因为它们是 Next.js 项目中的组件。如前所述,Next.js 项目中的所有组件默认都是服务器组件。如果这些组件是 Vite-based React 项目的一部分,并且该项目未设置以支持 RSCs,则这些组件将变为“普通”组件(客户端组件)。

在相同的演示项目中,还有一个用于/info页面的组件。该组件包含一些在非 RSC 组件中无法工作的代码:

import fs from 'node:fs/promises';
export default async function InfoPage() {
  const info = await fs.readFile('data/rsc-info.json', 'utf-8');
  const { summary } = JSON.parse(info);

  return (
    <div id="info-page">
      <h1>Understanding React Server Components</h1>
      <p>
        {summary}
      </p>
    </div>
  );
} 

由于以下原因,此代码在本书之前看到的任何(基于 Vite 的)React 项目中都无法工作:

  • InfoPage组件使用 Node 的fs包从rsc-info.json文件(项目的一部分)加载数据。

  • 该组件使用async/await,因此返回一个Promise,最终产生 JSX 代码(即 React 元素)。

在不支持 RSCs 的项目中,由于所有代码都在浏览器中运行,因此无法使用服务器端 API。同样,也不允许在组件中返回Promise。在非 RSCs 中,这不会被视为有效的组件函数返回值。尽管如此,当与 RSCs 一起工作时,这两者都是允许且可能的。

如同在理解 RSCs部分所述,使用服务器端功能(如 Node.js API)是因为InfoPage组件,就像 Next.js 项目中的所有组件一样,是一个 RSC。对于 RSCs,React 也支持使用async/await

因此,正如预期的那样,您在客户端 JavaScript 代码包中找不到InfoPage组件的代码。您可以通过访问/info页面来验证这一点。如果您在浏览器开发者工具中打开网络选项卡,然后重新加载页面,您将看到发送到服务器的所有 HTTP 请求。这包括所有在 React 应用的客户端需要的 JavaScript 代码文件的请求。

计算机截图  自动生成的描述

图 16.3:访问/info 时,会向服务器发送对 CSS、JS 和一些其他文件的请求

如果您浏览所有请求的 JavaScript 文件并搜索下载的代码文件中的rsc-info.json,您在任何文件中都不会找到匹配项。这证明了这部分代码,它是InfoPage组件函数的一部分,并没有出现在任何客户端代码包中。

计算机截图  自动生成的描述

图 16.4:RSC 代码中包含的数据源文件名在客户端找不到

那么,从rsc-info.json文件获取的内容是如何显示在屏幕上的呢?

如果您使用不同于 Chrome 或 Edge 的浏览器,这个问题就会得到解答。这是必需的,因为 Chrome/Edge 的开发者工具中的网络选项卡存在一个 bug,在某些情况下会导致请求的响应被隐藏。

相反,例如,您可以使用 Firefox 访问根页面(/)。在那里,点击页面上的链接导航到/info页面。在此过程中,将发送一个新的 HTTP 请求。如果您检查该请求及其响应(在 Firefox 的浏览器开发者工具中),您将看到服务器返回的序列化 RSC 指令。

img

图 16.5:客户端 React 的序列化指令从服务器接收

如您所见,接收到的响应不是 HTML 内容。相反,是一系列序列化指令,由 React 在客户端(即在浏览器中)转换为 DOM 元素。

因此,正如您所了解的,构建和使用 RSC 很简单,但准备项目来处理它们则不是。相反,您需要一个将客户端和服务器代码分离的构建过程,以及调用服务器组件函数的服务器端 API 端点。您还需要客户端代码,当服务器组件应该被渲染时,它会向这些 API 端点发送请求。

RSC 和服务器操作不能在所有项目中使用

到目前为止,在这本书中,每当介绍一个新的 React 特性时,您都可以简单地将其用于您的 React 项目,无论该项目是由 Vite 创建和管理,还是由其他工具(例如create-react-app)创建。

使用 RSCs(React Server Components)和服务器操作,情况就不同了。由于幕后有许多必须完成的事情(参见上一节),尽管这些是 React 提供的功能,但你不能直接在任何 React 项目中使用它们。相反,为了解锁这些功能,你必须有一个配置了支持这些功能的项目的。

因此,在本书撰写之时,RSCs 和服务器操作实际上只能借助集成并积极支持这些功能的框架来使用——例如,Next.js 框架。

当然,从技术上讲,你可以自己设置一个支持这两个功能的项目,但这需要关于后端开发和构建工作流程配置的高级知识。因此,大多数需要这些功能的 React 项目都依赖于像 Next.js 这样的框架。由于无论在哪种项目中使用 RSCs 和服务器操作,使用方式总是相同的,因此本书将忽略自定义设置部分,而是专注于如何使用这两个核心概念。

RSCs 与服务器端渲染对比

初看起来,使用 RSCs 可能看起来与 SSR(服务器端渲染)的 React 组件相似。毕竟,这两个概念都是关于在浏览器之外运行一些代码。

但尽管这些概念听起来相似,它们实际上相当不同。

SSR 主要是在接收到请求时将组件树渲染成 HTML。它最终是创建一个初始页面快照。

此外,在构建交互式 Web 应用时,SSR 的一个关键部分是预渲染的 HTML 快照在客户端被激活——如前一章所述(参见“理解服务器端渲染(SSR)”部分和“图 15.3”)。

因此,在使用 SSR 时,整个组件树及其所有组件函数在服务器端以及客户端都会被评估。服务器端和客户端代码之间没有分割——它是在两边都是同一个应用和同一个组件树。因此,你也不能在 React 组件中有任何仅服务器端的代码。

使用 RSCs 时,情况就不同了。它们组件函数的代码,如前几节所述,永远不会出现在客户端。

软件应用程序的示意图  自动生成的描述

图 16.6:RSCs 不会被激活;相反,它们的输出是通过 HTTP 请求请求的

这就是为什么启用了 SSR 的项目并不自动支持 RSCs。另一方面,你可以设置一个支持 RSCs 但同时也为某些组件使用 SSR 的项目——这些组件应该在服务器上预渲染,但同时也需要在客户端上使用(例如,因为它们增加了页面的交互性)。这些类型的组件将在下一节中探讨。

值得注意的是,RSCs,就像 SSR(服务器端渲染)项目中的服务器端渲染组件一样,每个请求只执行一次。然而,与通过 SSR 渲染的“正常”组件不同,RSCs 可以在应用程序运行时按需执行。它们不仅限于被调用以创建初始页面快照。

尽管如此,有一个重要的问题:在所有组件都在服务器上渲染的 React 应用程序中,你如何添加交互性,例如处理用户输入?毕竟,用户交互是在浏览器中发生的。

RSCs 与客户端组件

RSCs(React Server Components)提供了一些令人信服的优势(参见理解 RSCs部分),但它们也引入了一个可能的大问题:如果所有组件代码都“生活”并在服务器上执行,那么就没有空间进行客户端交互。

并非所有组件都应该是 RSCs

如果你有一个需要管理某些状态(例如,仅在用户交互时显示的购物车)的组件,那么该状态和 UI 必须由客户端 React 进行管理和更新。因为那(以及现在)是 React 的主要卖点之一:你可以用它来构建高度反应性和交互式的 UI。但这个目标显然与 RSCs 的理念相冲突,在 RSCs 中,没有组件代码到达浏览器,并且组件在每个请求中只渲染一次。

正因如此,React 允许你通过在包含应在客户端运行的组件函数的文件顶部添加 'use client' 指令来定义所谓的服务器-客户端边界

img

图 16.7:'use client' 指令在服务器端和客户端代码之间创建了一个边界

你已经在上一章的突出显示活动链接和使用'use client'指令部分遇到了 'use client'。当时,这个指令并没有太多意义。现在,随着你对 RSCs 的新认识,这个指令背后的目的将变得更加清晰。

在组件文件中添加 'use client' 后,该文件中定义的组件将成为客户端组件。客户端组件在服务器上也会预先渲染,但它们的代码也会在客户端上执行。正如前一章所述,它们会被激活。因此,与服务器组件的代码不同,客户端组件的代码会到达客户端:

'use client';
import { useState } from 'react';
export default function Cart() {
  const [isVisible, setIsVisible] = useState(false);
  function handleCartVisibility() {
    setIsVisible((prevState) => !prevState);
  }
  return (
    <div id="cart">
      <button onClick={handleCartVisibility}>
        {isVisible ? 'Hide Cart' : 'Show Cart'}
      </button>
      {isVisible && <p>Cart Items</p>}
    </div>
  );
} 

在这个例子中,Cart 组件是一个客户端组件,因为文件顶部添加了 'use client'。这是必需的,因为 Cart 组件使用了 useState() 钩子,它只能在浏览器中工作。

每当你向组件文件添加 'use client' 指令时,该文件中的组件函数将被包含在客户端代码包中。因此,组件函数可以在浏览器中(并且将会)执行——因此你可以使用依赖于在该处运行的功能,如 useState() 或在用户输入时运行的代码(例如,如果按下了 <button>)。

这也是为什么 Next.js 会在你尝试在一个未通过'use client'标记为客户端组件的组件中使用 Hook 时显示错误。

img

图 16.8:Next.js 对在 RSC 中使用 useState() Hook 表示不满

这个错误发生是因为你试图构建一个不可能的东西:一个只在服务器上评估但也能响应用户输入并更新一些状态的组件。由于后者,正如你在第四章与事件和状态一起工作中学到的,通常会导致 UI 更新,代码需要在客户端执行——这显然与只在服务器上运行组件代码的目标相冲突。

因此,每当你有需要在浏览器中运行的组件时,都必须添加'use client'

注意

当然,在未实现 RSC 的项目中,你不需要添加'use client'指令。这就是为什么在早期章节中你未在其他任何 React 项目中看到它。

‘use client’也会影响子组件!

在组件文件中使用'use client'指令有一个非常重要的含义:所有嵌套组件也会变成客户端组件——即使你不在它们的组件文件中使用'use client'

这在技术上是有必要的,因为客户端组件的 JSX 代码在每次客户端组件函数再次调用时(例如,由于某些状态变化)都会重新评估,并且那里使用的所有自定义组件都会重新执行——这是你在第十章React 幕后和优化机会中学到的。

结果,所有嵌套在客户端组件内部的组件都必须是客户端组件,因为否则它们的代码在客户端不可用。

一个产品图的示意图,描述自动生成

图 16.9:客户端组件的子组件也变成了客户端组件

为了保持客户端代码包小而高效,通常最好最大化服务器组件的数量,从而最小化客户端组件的数量。由于客户端组件的嵌套组件会自动成为客户端组件,因此你应该尽量将服务器-客户端边界(即'use client'的使用)尽可能向下移动到组件树中。理想情况下,只有你的组件树的叶子使用 React Hooks 或处理用户输入。换句话说:只有在你必须的时候才使用'use client',并尽量减少它影响组件的数量。

一个产品图的示意图,描述自动生成

图 16.10:大多数组件都是 RSC

图 16.10显示了一个示例组件树,其中只有所有组件的一小部分是客户端组件。

因此问题是这样的:如何在支持 RSC 的 React 项目中结合和优化服务器和客户端组件的使用?

结合 RSC 和客户端组件

通常,你最终会得到大多数组件不需要是客户端组件的 React 项目(因此,它们应该是 RSCs),但有些组件函数需要在浏览器中运行(即,它们确实需要 'use client')。

你可以将 'use client' 标记视为组件树中组件类型从服务器组件切换到客户端组件的点(参见 图 16.9图 16.10)。

因此,React 允许你在同一个项目中结合这两种组件,尽管你需要遵循一些重要的规则:

  • 服务器组件可以导入并渲染客户端组件(即在它们的 JSX 代码中输出客户端组件)。

  • 客户端组件不得直接导入并渲染依赖于服务器功能的服务器组件。

  • 客户端组件可以通过 props 隐式渲染服务器组件(例如,通过 children prop)。

为了使这些规则更加具体,每个情况都将通过一个具体的例子来展示。

在服务器组件中输出客户端组件

你可以在服务器组件的 JSX 代码中使用客户端组件而不会出现问题。

考虑以下 UserTodos 组件的例子,它允许用户通过 localStorage 本地存储管理一系列待办事项:

'use client';
import { useEffect, useRef, useState } from 'react';
export default function UserTodos() {
  const todoRef = useRef(null);
  const [todos, setTodos] = useState([]);
  useEffect(() => {
    const storedTodos = localStorage.getItem('todos');
    setTodos(storedTodos ? JSON.parse(storedTodos) : []);
  }, []);
  function handleAddTodo(event) {
    event.preventDefault();
    const todo = todoRef.current.value.trim();
    const newTodo = {
      id: new Date().getTime(),
      text: todo,
    };
    setTodos((prevTodos) => [...prevTodos, newTodo]);
    const storedTodos = localStorage.getItem('todos');
    localStorage.setItem(
      'todos',
      JSON.stringify(
        storedTodos 
          ? [...JSON.parse(storedTodos), newTodo] 
          : [newTodo]
      )
    );
  }
  return (
    <>
      <form onSubmit={handleAddTodo}>
        <input type="text" placeholder="Your to-do" ref={todoRef} />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
} 

由于使用了 localStorage(一个浏览器 API)、refs、状态(通过 useState()todos)和事件监听器(通过 onSubmitsubmit),这必须是一个客户端组件。这就是为什么在文件顶部添加了 'use client' 的原因。

然而,这个组件可以在服务器组件中使用而不会出现问题:

import UserTodos from '../components/UserTodos';
export default function Home() {
  return (
    <main>
      <h1>Manage your to-dos with ease!</h1>
      <UserTodos />
    </main>
  );
} 

这是因为客户端组件也可以在服务器上渲染——它们只是不是专属于那个环境(与 RSCs 不同,RSCs 是)。换句话说:客户端组件在服务器上的渲染方式与所有组件在不支持 RSCs 的 SSR 项目中一样(例如,前一个章节中的基于 Vite 的 SSR 启用项目)。在第一次请求时渲染一个初始快照,之后客户端 React 接管并使组件活动化。

注意

在前面的例子中,数据是通过 useEffect()localStorage 加载的。这样做是为了确保代码在服务器上运行。由于 localStorage 在那里不可用,如果不使用 useEffect() 包装访问它,将会导致错误。

由于 useEffect() 在服务器上被忽略,这是一种安全地使用浏览器专用 API 的方法。

在客户端组件中输出服务器组件

如同在 “use client” 也会影响子组件 部分所述,你不能将服务器组件导入客户端组件并在那里渲染它们。

尽管在许多情况下,你不会得到错误。例如,你可能有一个如下定义的客户端 Cart 组件:

**'use client'****;**
import { useState } from 'react';
import CartItem from './CartItem';
export default function Cart() {
  const [isVisible, setIsVisible] = useState(false);
  function handleCartVisibility() {
    setIsVisible((prevState) => !prevState);
  }
  return (
    <div id="cart">
      <button onClick={handleCartVisibility}>
        {isVisible ? 'Hide Cart' : 'Show Cart'}
      </button>
      {isVisible && (
        <ul>
          **<****CartItem****title****=****{****'****Book****'****}****quantity****=****{1}** **/>**
          **<****CartItem****title****=****{****'****Pen****'****}****quantity****=****{2}** **/>**
          **<****CartItem****title****=****{****'****Pencil****'****}****quantity****=****{5}** **/>**
        </ul>
      )}
    </div>
  );
} 

Cart 不同,CartItem 组件函数可能是一个服务器组件(即,它没有通过 'use client' 标记):

export default function CartItem({ title, quantity }) {
  return (
    <li>
      <article>
        <h2>{title}</h2>
        <p>Quantity: {quantity}</p>
      </article>
    </li>
  );
} 

这段代码之所以能正常工作,是因为之前作为服务器组件(CartItem)的组件,一旦被导入并用于客户端组件文件中,就简单地变成了客户端组件。

然而,如果你尝试导入并使用一个使用服务器组件特定功能(如 Node.js API 或 async/await)的服务器组件,你将会遇到错误信息。

例如,以下调整后的 DynamicCartItem 组件试图使用 Node 的 fs 包从文件中加载购物车项:

import fs from 'node:fs/promises';
export default async function DyncamicCartItem({ id }) {
  const data = await fs.readFile(`data/cart.json`, 'utf8');
  const storedCart = JSON.parse(data);
  const cartItem = storedCart.find((item) => item.id === id);
  return (
    <li>
      <article>
        <h2>{cartItem.title}</h2>
        <p>Quantity: {cartItem.quantity}</p>
      </article>
    </li>
  );
} 

Cart 组件中导入和使用此组件将导致错误。

尝试运行此代码会导致屏幕上显示错误信息,因为 React 无法自动将 CartItem 转换为客户端组件(由于使用了 RSC 独有的功能)。因此,它会抱怨你在客户端侧尝试使用的某些服务器端代码(例如某些 Node.js API)。

img

图 16.11:React 对在浏览器中使用 Node.js API 的使用表示不满

因此,在这种情况下,你需要重新构建你的应用程序,以便再次得到有效的组件组合。例如,通过将服务器组件作为 prop 传递给客户端组件,而不是直接导入和渲染它们。

通过 Props 渲染服务器组件

你不能在客户端组件中导入和使用执行某些服务器端专用操作(如使用 Node.js API)的服务器组件。

但你可以修改你的客户端组件代码,使其不直接导入和使用服务器组件。相反,你可以期待从服务器组件作为 prop 获取——例如,通过你在 第三章 中学到的特殊 children prop:

'use client';
import { useState } from 'react';
export default function Cart(**{ children }**) {
  const [isVisible, setIsVisible] = useState(false);
  function handleCartVisibility() {
    setIsVisible((prevState) => !prevState);
  }
  return (
    <div id="cart">
      <button onClick={handleCartVisibility}>
        {isVisible ? 'Hide Cart' : 'Show Cart'}
      </button>
      {isVisible && **<****ul****>****{children}****</****ul****>**}
    </div>
  );
} 

这个调整后的 Cart 组件仍然是一个客户端组件。然而,由于它不再直接导入和渲染 DynamicCartItem 服务器组件,React 才会满意。

相反,DynamicCartItem 组件现在是这样导入并在 Home 组件中输出的:

import DyncamicCartItem from '../components/DynamicCartItem';
import Cart from '../components/Cart';
export default function Home() {
  return (
    <>
      <header>
        <Cart>
          <DyncamicCartItem id={1} />
          <DyncamicCartItem id={2} />
          <DyncamicCartItem id={3} />
        </Cart>
      </header>
      <main>
        <h1>Some dummy app</h1>
      </main>
    </>
  );
} 

DynamicCartItem 元素作为 children prop 的值传递给 Cart 组件。

这可能一开始看起来不太直观,但理解这一点至关重要,这是因为 DynamicCartItem 组件现在作为另一个服务器组件(Home 组件)的一部分被渲染。这是渲染过程的结果,然后作为值传递给 Cart 组件。因此,该组件在其组件树的部分中不包括 DynamicCartItem 组件。相反,CartDynamicCartItem 都是 Home 组件的直接子组件。

整个应用程序组件树看起来会是这样:

img

图 16.12:DynamicCartItemCart 都是 Home 组件的直接子组件

尽管在完成后的 UI 中,DynamicCartItem 可能看起来像是 Cart 的子组件,但从技术上讲,它并不是。

理解这一点很重要:将一个组件包裹在另一个组件中(<Cart><DynamicCartItem /></Cart>)会导致与在另一个组件内部渲染组件不同的组件树结构。

因此,这是一个在可能需要将服务器组件包含在客户端组件中的情况下有用的模式。

总体来说,你可以根据需要组合 RSCs 和客户端组件。此外,Next.js 还提供了一些额外的功能,可以帮助处理 RSCs 和通过 RSCs 的数据获取。

使用 Next.js 进行高级数据获取

如前所述,在 理解 RSCs 部分中,通过 RSCs 进行数据获取与在客户端组件中进行数据获取相比提供了各种优势。你不必使用 useEffect() 向单独的后端 API 发送 HTTP 请求,你可以直接访问数据库,可以使用 async/await 等。因此,在可能的情况下,绝对建议通过 RSCs 获取数据。

当使用 Next.js 时,基于 RSC 的数据获取变得更加容易,因为 Next.js 帮助在等待数据到达时显示回退内容。

使用 Next.js 管理加载状态

当使用 Next.js(带有 App Router)时,你可以在 app/ 文件夹内定义 loading.js 文件来设置在兄弟或嵌套服务器组件加载数据时将被渲染的组件。Next.js 通过检查组件是否返回一个尚未解决的 Promise 来确定组件是否正在加载数据。

注意

下一章将更深入地探讨处理加载状态和显示回退内容。它将探索 React 的 Suspense 功能,该功能允许在数据流进时进行细粒度的加载状态管理。

考虑这个 GoalsPage 组件的例子,它从文件中获取数据:

import fs from 'node:fs/promises';
async function fetchGoals() {
  await new Promise((resolve) => setTimeout(resolve, 3000)); // delay
  const goals = await fs.readFile('./data/user-goals.json', 'utf-8');
  return JSON.parse(goals);
}
export default async function GoalsPage() {
  const fetchedGoals = await fetchGoals();
  return (
    <>
    <h1>Top User Goals</h1>
    <ul>
      {fetchedGoals.map((goal) => (
        <li key={goal}>{goal}</li>
      ))}
    </ul>
    </>
  );
} 

执行实际数据获取的功能(fetchGoals())内置了延迟来模拟缓慢的数据库或网络连接。

如果项目中没有添加 loading.js 文件,用户将在请求的页面渲染之前盯着一个空白或过时的页面几秒钟。

计算机屏幕截图  描述自动生成

图 16.13:点击链接后,新页面需要三秒钟才能加载

这种行为发生是因为新页面尚未准备好,无法渲染,因为它仍在获取数据。

为了提高用户体验,可以在缓慢的 app/goals/page.js 文件旁边添加一个 loading.js 文件(如果需要,也可以在某个父文件夹中,因为 loading.js 也会为子路由显示其内容)。

在新创建的 app/goals/loading.js 文件中,创建了一个常规的 React 组件。像 Next.js 项目中的所有组件一样,这是一个默认的 RSC:

export default function LoadingGoals() {
  return <p id="fallback">Loading user goals, please wait...</p>;
} 

组件名称(LoadingGoals)并不重要。但这个组件现在确保在用户等待GoalsPage加载和渲染时,屏幕上显示Loading user goals, please wait…的回退文本。

img

图 16.14:在页面过渡期间显示加载回退内容

当然,你可以显示任何你选择的回退内容——它不必像这个例子中那样只是简单的文本。

因此,当使用 Next.js 时,添加loading.js文件来定义回退组件可以极大地提高网站用户的体验。

除了获取数据外,许多 React 应用程序在某个时候也需要修改数据。

从数据获取到数据修改

到目前为止,你已经学到了很多关于 RSCs、客户端组件以及它们如何(以及如何不能)一起工作的知识。在理解 RSCs部分,你还了解了一些 RSCs 提供的优势。

当然,你也可能想修改数据,而不仅仅是加载和显示它。

使用服务器操作处理数据修改

React 不仅提供对 RSCs 的支持;它还允许你向应用程序添加所谓的服务器操作

服务器操作建立在客户端(表单)操作相同的概念之上,这些操作在第九章使用表单操作处理用户输入和表单中介绍并解释了。然而,正如其名称所暗示的,服务器操作将在服务器端执行,而不是在客户端。

因此,你可以使用服务器操作在服务器上检索提交的用户输入并对其进行处理。例如,你可以将提交的数据存储在文件或数据库中。

因此,当目标是为构建完全集成的全栈 React 应用程序时,服务器操作是一个重要的构建块。通常,仅数据获取是不够的,这就是为什么存在服务器操作功能。通过同时拥有 RSCs 和服务器操作,你能够在服务器上获取和修改数据,同时在需要时仍然启用交互式的客户端用户体验。

在 React 项目中解锁服务器操作

与 RSCs 一样,你无法在所有 React 项目中使用服务器操作。相反,需要特殊的项目设置才能使用此功能。例如,Next.js 项目支持服务器操作(当使用 App Router 时)。就像 RSCs 一样,你可以认为 Next.js 做以下事情:

  • 构建工作流程和捆绑过程将属于服务器操作的代码分离出来,以便它不会最终出现在客户端捆绑中。

  • Next.js 设置 API 端点,触发服务器操作函数,并返回这些函数中定义的任何返回值。

  • 当需要时(例如,提交与服务器操作连接的表单时——如下一节所示),Next.js 会调用这些端点。

因此,对于不使用 Next.js 的自定义项目来说,服务器操作(如 RSCs)可能难以支持。虽然完全有可能创建既支持服务器操作也支持 RSCs 的自定义项目,但这并非易事。

幸运的是,在使用服务器操作(在支持它们的项目中)时并不复杂。

定义和触发服务器操作

如同在处理数据突变与服务器操作章节中提到的,服务器操作与你在第九章中已经了解的客户表单操作非常相似。

但在创建服务器操作时,有两个关键差异必须考虑:

  • 服务器操作函数必须是异步的——(即,它必须使用async/await)。没有同步的服务器操作。

  • 在服务器操作函数中,在函数体开始时,你必须添加'use server'指令。

因此,一个有效的服务器操作可以像这样定义和使用:

export default function UserFeedback() {
  **async****function****saveFeedback****(****formData****) {**
    **'use server'****;**
    **const** **feedback = formData.****get****(****'feedback'****);**
    **console****.****log****(feedback);**
  **}**
  return (
    <form **action****=****{saveFeedback}**>
      <p>
        <label htmlFor="feedback">Your feedback</label>
        <textarea id="feedback" name="feedback" rows={3} />
      </p>
      <p><button>Submit</button></p>
    </form>
  );
} 

如你所见,除了它必须是异步的并且使用'use server'指令之外,这个动作函数看起来与你在第九章中看到的类似。它接收一个formData对象,该对象将由 React 在表单提交时提供,并且你将action函数作为<form>元素上action属性的值设置。

如前文所述,如果你在浏览器下载的代码文件中搜索这段代码,你将找不到它——这段代码实际上只在服务器端运行。

注意

前面的示例中的UserFeedback组件是一个 RSC。

如果你仔细想想,这可能会有些奇怪。毕竟,这个组件确实处理了一些用户输入和交互。那么为什么它不需要'use client'就能工作呢?

由于服务器操作(绑定到<form>action属性)是特殊的。React 在 RSCs 内部明确支持这种模式。'use client'确实对于任何其他类型的用户输入处理(例如,如果你依赖于onSubmitonChange属性)都是必需的。但是,通过action属性绑定服务器操作是支持的。

此外,重要的是要理解'use server'指令仅用于标记操作为服务器操作。例如,你不能用它来标记组件为服务器组件。

当然,前面的示例服务器操作目前仅将输入记录到控制台。更实际的操作可能会将数据存储到某个地方,并将用户重定向到其他页面。

处理用户输入和更新 UI

考虑这个前面示例的更新版本:

**import** **{ storeFeedback }** **from****'../lib/feedback-db'****;**
function UserFeedback() {
  async function saveFeedback(formData) {
    'use server';
    const feedback = formData.get('feedback');
    **storeFeedback****(feedback);**
  }
  return (
    <form **action****=****{saveFeedback}**>
      <p>
        <label htmlFor="feedback">Your feedback</label>
        <textarea id="feedback" name="feedback" rows={3} />
      </p>
      <p><button>Submit</button></p>
    </form>
  );
} 

saveFeedback()服务器操作现在通过storeFeedback()函数存储提取的反馈。

这个函数定义如下:

import fs from 'node:fs/promises';
export async function storeFeedback(text) {
  const storedFeedback = await fs.readFile('data/user-feedback.json');
  const feedback = JSON.parse(storedFeedback);
  feedback.push({ id: new Date().getTime(), text });
  await fs.writeFile(
    'data/user-feedback.json', 
    JSON.stringify(feedback)
  );
} 

在实际应用中,数据可能会存储在数据库中。在这个简单的例子中,它只是简单地存储在user-feedback.json文件中,该文件是 Next.js 项目的一部分。

正如你可以从图中看出,你可以在 RSC 内部直接访问文件或数据库,同样地,你也能在 Server Action 内部直接编辑文件或发送数据库查询。

你也可以通过编程方式在之后更新 UI,将用户导航到不同的页面。在一个 Next.js 应用中,你可以使用 Next.js 提供的redirect()函数来触发这种导航操作——例如,在存储提交的反馈文本后:

**import** **{ redirect }** **from****'next/navigation'****;**
import { storeFeedback } from '../lib/feedback-db';
export default function UserFeedback() {
  async function saveFeedback(formData) {
    'use server';
    const feedback = formData.get('feedback');
    await storeFeedback(feedback);
    **redirect****(****'/thanks'****)**
  }
  // same JSX code as before, hence omitted
} 

当构建全栈应用时,这是一个非常常见的模式,因为你通常希望用户提交数据后导航到不同的页面。

但你也可以使用不同的模式,根据表单提交更新包含表单的 UI。

Server Actions 和 useActionState()

你可能还记得第九章中的useActionState() Hook,使用表单操作处理用户输入和表单。这个 Hook 可以用来从一个(表单)操作中推导出一些组件状态。这个状态反过来可以用来根据操作的结果更新 UI。

由于 Server Action 是一种特殊的表单操作,你可以使用相同的 Hook 根据 Server Action 及其返回值更新 UI。

例如,你可以在UserFeedback组件中使用useActionState(),如下所示:

**import** **{ useActionState }** **from****'react'****;**
import { redirect } from 'next/navigation';
import { storeFeedback } from '../lib/feedback-db';
import FeedbackForm from './FeedbackForm';
export default function UserFeedback() {
  async function saveFeedback(prevState, formData) {
    'use server';
    const feedback = formData.get('feedback');
    **if** **(!feedback || feedback.****trim****() ===** **''****) {**
      **return** **{** **error****:** **'Please provide some feedback!'** **};**
    **}**
    await storeFeedback(feedback);
    redirect('/thanks');
  }
  **const** **[formState, formAction] =** **useActionState****(saveFeedback, {**
    **error****:** **null****,**
  **});**
  return (
    <form action={formAction}>
      <p>
        <label htmlFor="feedback">Your feedback</label>
        <textarea id="feedback" name="feedback" rows={3} />
      </p>
      **{formState.error &&** **<****p****id****=****"error"****>****{formState.error}****</****p****>****}**
      <p>
        <button>Submit</button>
      </p>
    </form>
  );
} 

然而,使用此代码会导致错误:

img

图 16.15:React 对在 RSC 中使用 Hook 表示抱怨

这是一个你已经从Not All Components Should Be RSCs部分和图 16.8中知道的错误信息。React 不允许在 RSC 中使用 Hooks——而UserFeedback是一个 RSC。

解决方案当然是直接的:只需在UserFeedback.js文件的顶部添加'use client'指令:

**'use client'****;**
import { useActionState } from 'react';
import { redirect } from 'next/navigation';
import { storeFeedback } from '../lib/feedback-db';
import FeedbackForm from './FeedbackForm';
export default function UserFeedback() {
  // component code didn't change, hence omitted
} 

但应用了这个更改后,你会遇到另一个错误信息:

img

图 16.16:React 现在对同一文件中同时使用‘use server’和‘use client’表示抱怨

这个错误信息发生是因为UserFeedback组件文件当前正在使用'use client''use server'指令——在不同的地方,但同一个文件中。

换句话说:你只能在 RSC 内部定义 Server Action(因此使用'use server'),而不能在客户端组件内部。

解决这个问题的可能方法是将反馈表单和useActionState() Hook 移动到一个新的组件中,该组件将作为UserFeedbackForm的子组件使用。然后,可以将 Server Action 函数通过 props 传递给这个新添加的组件。

例如,你可以创建一个看起来像这样的FeedbackForm组件:

'use client';
import { useActionState } from 'react';
export default function FeedbackForm(**{action}**) {
  const [formState, formAction] = useActionState(**action**, {
    error: null,
  });
  return (
    <form action={formAction}>
      <p>
        <label htmlFor="feedback">Your feedback</label>
        <textarea id="feedback" name="feedback" rows={3} />
      </p>
      {formState.error && <p id="error">{formState.error}</p>}
      <p>
        <button>Submit</button>
      </p>
    </form>
  );
} 

这个FeedbackForm组件期望一个action属性,然后将其作为值传递给useActionState()。因此,FeedbackForm组件可以在UserFeedback组件中使用,如下所示:

import { redirect } from 'next/navigation';
import { storeFeedback } from '../lib/feedback-db';
**import****FeedbackForm****from****'./FeedbackForm'****;**
export default function UserFeedback() {
  async function saveFeedback(prevState, formData) {
    'use server';
    const feedback = formData.get('feedback');
    if (!feedback || feedback.trim() === '') {
      return { error: 'Please provide some feedback!' };
    }
    await storeFeedback(feedback);
    redirect('/thanks');
  }
  return **<****FeedbackForm****action****=****{saveFeedback}** **/>**;
} 

如果你运行这段代码,应用程序将没有任何问题运行。所以,就像 RSCs 一样,关键在于提出一个有效的组件结构。

这是一种绝对有效的解决问题的方式。但如果你不想将 UserFeedback 组件拆分成多个组件并将表单外包给 FeedbackForm,也有另一种可能的解决方案。

将服务器操作存储在单独的文件中

你可以直接在 RSCs 中定义服务器操作。正如你在上一章中学到的,你还可以通过 props 将它们传递。

作为一种替代方案,React 也允许将它们存储在单独的文件中。这样做可以让你构建更精简的组件,因为服务器操作代码被移出了组件函数。此外,React 允许将存储在单独文件中的服务器操作导入到客户端组件文件中。

考虑到之前的代码示例,你可以在 Next.js 项目文件夹中将 saveFeedback() 服务器操作移动到单独的 actions/feedback.js 文件中——尽管,文件和文件夹的名称完全由你决定。然后,你可以在该文件中将 'use server' 指令从服务器操作中移出,并将其放在文件顶部:

'use server';
import { redirect } from 'next/navigation';
import { storeFeedback } from '../lib/feedback-db';
export async function saveFeedback(prevState, formData) {
  const feedback = formData.get('feedback');
  if (!feedback || feedback.trim() === '') {
    return { error: 'Please provide some feedback!' };
  }
  await storeFeedback(feedback);
  redirect('/thanks');
} 

在文件顶部添加 'use server' 指令可以让你在同一文件中创建多个服务器操作函数。然后你可以将它们导出并用于可能需要的任何其他文件中。

例如,你可以将 saveFeedback() 操作导入到 UserFeedback 组件中,这样现在就不需要单独的 FeedbackForm 子组件了。由于外部存储的服务器操作可以无问题地导入到客户端组件文件中,最终的 UserFeedback.js 文件看起来像这样:

'use client';
**import** **{ saveFeedback }** **from****'../actions/feedback'****;**
import { useActionState } from 'react';
export default function UserFeedback() {
  const [formState, formAction] = useActionState(**saveFeedback**, {
    error: null,
  });
  return (
    <form action={formAction}>
      <p>
        <label htmlFor="feedback">Your feedback</label>
        <textarea id="feedback" name="feedback" rows={3} />
      </p>
      {formState.error && <p id="error">{formState.error}</p>}
      <p>
        <button>Submit</button>
      </p>
    </form>
  );
} 

因此,将服务器操作存储在单独的文件中不仅会导致组件更精简,还可以帮助防止不必要的组件重构。

尽管如此,无论你选择哪种方法,你都可以使用服务器操作来处理服务器上的表单提交。因此,结合 RSCs,你可以构建无缝融合客户端和服务器代码的全栈应用程序。

摘要和关键要点

  • React 支持两种特殊的服务器端功能:RSCs 和服务器操作。

  • 这两个功能在 React 项目中不可用,除非项目被特别配置以支持它们——通常,因此你需要使用支持这些功能的框架(例如 Next.js)。

  • RSCs 是那些永远不会在客户端渲染的组件——相反,它们可能在服务器上(通过 HTTP 请求启动)或构建过程中渲染。

  • RSCs 返回客户端 React 会获取的渲染指令。

  • 由于 RSCs 从不在客户端运行,你可以在其中使用专用于服务器的 API 和功能。

  • React 还允许 RSCs 返回 Promise 值,因此你可以在 RSCs 中使用 async/await 并无问题地异步获取数据。

  • 为了构建在渲染后 UI 可能发生变化的交互式网站,你也可以通过使用'use client'指令将组件标记为客户端组件。

  • 只有客户端组件可以使用像useState()这样的 Hooks 或设置事件监听器。

  • 客户端组件也会在服务器上预渲染,但与 RSCs 不同,它们也可能在客户端执行。

  • 你可以在 RSCs 内部导入和使用客户端组件。

  • 当将服务器组件导入客户端组件时,如果可能,服务器组件会自动成为客户端组件。

  • 如果 RSC 无法转换为客户端组件(例如,因为它使用了async/await),你需要重新构建组件树。

  • 你可以通过 props 将服务器组件传递给客户端组件(无需转换)。

  • React 通过服务器操作帮助处理服务器上的表单提交。

  • 服务器操作类似于客户端操作(见第九章)但必须是异步的(async/await)并使用'use server'指令。

  • 你可以在 RSCs 内部或单独的文件中定义服务器操作——在后一种情况下,你可以将'use server'指令移动到文件顶部以在同一个文件中定义多个服务器操作。

接下来是什么?

在本章中,你了解了 RSCs 和服务器操作。你了解到创建和使用它们相对简单,但在项目中支持它们并不容易——因此,像 Next.js 这样的框架通常被用来利用这些功能。

本章让你了解了 RSCs 和服务器操作在幕后是如何工作的,以及这些功能提供了哪些优势。在本章中,你还了解了客户端组件以及如何结合服务器和客户端组件。最后,讨论了服务器操作,并展示了定义和使用服务器操作的不同方法。

下一章将在此基础上构建,并探讨 React 的Suspense功能如何在数据流式传输时显示回退内容方面可能有所帮助。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与可以在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/16-rsc-server-actions/exercises/questions-answers.md找到的示例进行比较。:

  1. React 服务器组件的标志性特征是什么?

  2. React 服务器组件解决了哪些问题?

  3. 如何在 Next.js 项目中创建和使用 React 服务器组件?

  4. 为什么 React 服务器组件和服务器操作不能在所有 React 项目中使用?

  5. 服务器端渲染(SSR)和 React 服务器组件(RSCs)之间的关键区别是什么?

  6. 'use client'指令的目的是什么?

  7. 'use client'指令如何影响子组件?

  8. 服务器组件和客户端组件的组合规则是什么?

  9. 你如何在 Next.js 中使用 RSCs 获取数据的同时处理加载状态?

  10. React 中的服务器动作是什么,它们与客户端动作有何不同?

  11. 如何触发服务器动作?

  12. 如何在服务器动作之后更新 UI?

  13. 你能否在单独的文件中定义服务器动作?

应用所学知识

在了解了关于 Next.js 的所有新知识后,是时候将其应用到实际演示项目中——一个将在服务器上渲染的演示应用程序。

在下一节中,你将找到一个活动,让你练习使用 Next.js。一如既往,你还需要应用前面章节中介绍的一些概念。

活动十六点一:构建一个迷你博客

在这个活动中,你的任务是构建一个非常简单的博客网站(使用 Next.js),允许用户创建和查看博客文章。每篇博客文章应包括标题、日期和正文文本。博客文章标题和日期列表应在起始页(/)上渲染;点击文章后,用户应被带到详情页(/blog/<some-id>),显示完整的博客文章数据。/blog/new页应显示一个表单,可以用来创建新文章。

文章应存储在posts.json文件中(可能只是存储文章对象的数组)。创建新文章后,用户应被重定向到该文章的详情页。如果用户留空标题或正文字段(或两者都留空),则应在表单下方显示错误消息。

注意

你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/16-rsc-server-actions/activities/practice-1-start找到这个活动的起始项目快照。下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这个例子中是activities/practice-1-start),以使用正确的代码快照。

在提供的起始项目中,你可以探索globals.css文件,以了解你可能想要使用的元素和元素结构,以利用提供的样式。当然,你也可以设置并使用你自己的样式。

下载代码并在项目文件夹中运行npm install以安装所有必需的依赖项后,解决方案步骤如下:

  1. 为三个页面添加三个新的page.js文件(以及适当的文件夹结构)://blog/new/blog/<some-id>

  2. 在根项目文件夹的data/文件夹中添加一个新的posts.json文件。该文件最初应存储一个空数组。

  3. /blog/new页面上输出一个包含标题和正文输入字段的<form>

  4. 在单独的文件中创建一个新的服务器操作,并将其导入并“连接”到<form>。服务器操作应检索输入的标题和正文文本,创建一个新的对象,该对象还包括 ID 和创建日期快照,并将这些数据存储在posts.json文件中。数据必须以不会丢失现有博客文章的方式存储。

  5. 更新服务器操作以实现输入验证,并在提交按钮上方输出验证结果。

  6. 在起始页面获取博客文章,并输出博客文章列表(标题和日期)。每篇文章都应该是可点击的,并带用户到详情页面。

  7. img

  8. 最后,在创建博客文章后,从服务器操作内部将用户重定向到适当的详情页面。

最终页面应如下截图所示:

在详情页面上,通过使用 ID 获取并输出博客文章的详细信息。

图 16.17:主页,显示博客文章列表

img

图 16.18:/blog/new 页面,等待用户输入

img

img

注意

您可以在此处找到此活动的完整代码和一个示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/16-rsc-server-actions/activities/practice-1

第十七章:理解 React Suspense 与 use() 钩子

学习目标

到本章结束时,你将能够做到以下内容:

  • 描述 React 的 Suspense 功能的目的和功能

  • 使用 RSCs 与 Suspense 一起显示细粒度的回退内容

  • 使用 React 的 use() 钩子为客户端组件提供 Suspense

  • 为数据获取和回退内容应用不同的 Suspense 策略

简介

第十章React 和优化机会背后的场景 中,在 通过代码拆分(懒加载)减少包大小 部分中,你学习了 React 的 <Suspense> 组件及其如何在懒加载和代码拆分的上下文中使用,以在下载代码包时显示回退内容。

如该处所述,Suspense 组件的目的是简化显示回退内容的过程,这反过来可以提高用户体验。由于大多数用户都不喜欢盯着过时的内容或空白页面,因此拥有一个显示替代内容的内置功能非常方便。

在本章中,你将了解到 React 的 Suspense 组件不仅限于用于代码拆分。相反,它还可以用于数据获取,在数据加载时显示一些临时内容(例如,从数据库中)。然而,正如你也将学到的,Suspense 只能在以特定方式获取数据时用于数据获取。

此外,本章将重新探讨在 第十一章处理复杂状态 中引入的 use() 钩子。正如你将学到的,除了用于获取上下文值之外,这个钩子还可以与 Suspense 一起使用。

使用 Suspense 显示细粒度的回退内容

当获取数据或下载资源(例如,代码文件)时,可能会出现加载延迟——这些延迟可能导致糟糕的用户体验。因此,你应该考虑在等待请求的资源时显示一些临时的回退内容。

因此,为了简化在等待某些资源时渲染回退内容的过程,React 提供了其 Suspense 组件。如 第十章React 和优化机会背后的场景 所示,你可以将 Suspense 组件用作围绕 React 元素的包装器,这些元素会获取一些代码或数据。例如,当在代码拆分的情况下使用它时,你可以显示一些临时的回退内容,如下所示:

import { lazy, **Suspense**, useState } from 'react';
const DateCalculator = lazy(() => import(
    './components/DateCalculator.jsx'
  )
);
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      **<****Suspense****fallback****=****{****<****p****>****Loading...****</****p****>****}>**
        {showDateCalc && <DateCalculator />}
      **</****Suspense****>**
    </>
  );
} 

在这个例子(它来自一个基于 Vite 的常规 React 项目)中,React 的 Suspense 组件被包裹在条件渲染的 DateCalculator 组件周围。DateCalculator 是通过 React 的 lazy() 函数创建的,该函数用于按需(即按需)加载属于此组件的代码包。

因此,整个其他页面的内容从一开始就全部显示出来。在获取代码的过程中,只有条件性显示的DateCalculator组件被替换为回退内容(<p>Loading...</p>),而其他内容保持不变。因此,Suspense在非常细粒度级别上渲染一些回退 JSX 代码。与用临时内容替换整个页面或组件标记不同,这里只替换了 UI 的一小部分。

当然,Suspense因此提供了一种在获取数据时也很希望拥有的功能——毕竟,延迟在那里也经常发生。

使用 Next.js 进行数据获取的 Suspend

如前一章中所述,在使用 Next.js 管理加载状态部分,数据获取的过程也常常伴随着等待时间,这可能会对用户体验产生负面影响。这就是为什么,在同一部分中,你学习了 Next.js 允许你定义一个loading.js文件,该文件包含一些在延迟期间渲染的回退组件。

然而,使用这种方法实际上是用加载回退组件内容替换了整个页面(或该页面的主要区域)。但这并不总是理想的——你可能在获取数据时更希望在一个更细粒度级别上显示一些加载回退内容。

幸运的是,在 Next.js 项目中,你可以像前一个示例中那样使用Suspense,将其包裹在获取数据的组件周围。由于 Next.js 支持 HTTP 响应流,它能够在数据可用时立即渲染页面的其余部分,并将依赖于获取数据的内 容流式传输到客户端。在数据加载并可用之前,Suspense将渲染其定义的回退内容。

因此,回到第十六章React 服务器组件与服务器操作使用 Next.js 管理加载状态的例子,你可以通过将数据获取代码外包给一个单独的UserGoals组件来利用Suspense

import fs from 'node:fs/promises';
async function fetchGoals() {
  await new Promise((resolve) => setTimeout(resolve, 3000)); // delay
  const goals = await fs.readFile('./data/user-goals.json', 'utf-8');
  return JSON.parse(goals);
}
export default async function UserGoals() {
  const fetchedGoals = await fetchGoals();
  return (
    <ul>
      {fetchedGoals.map((goal) => (
        <li key={goal}>{goal}</li>
      ))}
    </ul>
  );
} 

然后,可以在GoalsPage组件中将UserGoals组件包裹在Suspense中,如下所示:

import { Suspense } from 'react';
import UserGoals from '../../components/UserGoals';
export default async function GoalsPage() {
  return (
    <>
      <h1>Top User Goals</h1>
      <Suspense fallback={
        <p id="fallback">Fetching user goals...</p>}
      >
        <UserGoals />
      </Suspense>
    </>
  );
} 

此代码现在利用 React 的Suspense组件在UserGoals组件获取数据时显示回退段落。

注意

你可以在 GitHub 上找到完整的演示项目代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/17-suspense-use/examples/02-data-fetching-suspense

因此,当用户导航到/goals时,他们立即看到标题(<h1>元素)与回退内容的组合。不再需要单独的loading.js文件。

计算机屏幕截图 自动生成描述

图 17.1:回退内容作为目标页面的一部分显示,而不是完全替换它

然而,在这种情况下使用 Suspense 的优势不仅仅是 loading.js 文件不再需要。相反,现在可以非常细致地管理数据获取和回退内容。

例如,在一个更复杂的在线商店应用程序中,你可能有一个这样的组件:

function ShopOverviewPage() {
  return (
    <>
      <header>
        <h1>Find your next deal!</h1>
        <MainNavigation />
      </header>
      <main>
        <Suspense fallback={<DailyDealSkeleton />}>
          <DailyDeal />
        </Suspense>
        <section id="search">
          <h2>Looking for something specific?</h2>
          <Search />
        </section>
        <Suspense fallback={<p>Fetching products...</p>}>
          <Products />
        </Suspense>
      </main>
    </>
  );
} 

在这个例子中,<header><section id="search"> 元素始终可见并渲染。另一方面,<DailyDeal /><Products /> 只在它们的数据被获取后渲染。在此之前,将显示各自的回退内容。

一个网站示意图  自动生成描述

图 17.2:最初显示占位符,直到加载的数据流进并渲染到屏幕上

<DailyDeal /><Products /> 将独立于彼此加载和渲染,因为它们被两个不同的 Suspense 块包裹。因此,用户将立即看到页眉和搜索区域,然后最终看到每日特价和产品——尽管这两个中的任何一个都可能先加载和渲染。

这些示例中重要的是,被 Suspense 包裹的组件是使用 async/await 的 RSCs。正如你将在下一节中学习的,并非所有 React 组件都会与 Suspense 组件交互。但在 Next.js 项目中,React Server Components 会。

在其他 React 项目中使用 Suspense—可能,但棘手

上一节探讨了如何在 Next.js 项目中使用 Suspense 来利用 RSCs 进行数据获取。

然而,Suspense 不是一个 Next.js 特有的功能或概念——相反,它是 React 本身提供的。因此,你可以在任何 React 项目中使用它来在数据获取时显示回退内容。

至少,这是理论上的。但实际情况是,你无法与所有组件和数据获取策略一起使用它。

SuspenseuseEffect() 不兼容

由于通过 useEffect() 获取数据是一种常见策略,你可能会倾向于将 Suspense 与此 Hook 结合使用,在数据通过效果函数加载时显示一些回退内容。

例如,以下 BlogPosts 组件使用 useEffect() 来加载和显示一些博客文章:

import { useEffect, useState } from 'react';
function BlogPosts() {
  const [posts, setPosts] = useState([]);
  useEffect(() => {
    async function fetchBlogPosts() {
      // simulate slow network
      await new Promise((resolve) => setTimeout(resolve, 3000));
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/posts'
      );
      const posts = await response.json();
      setPosts(posts);
    }
    fetchBlogPosts();
  }, []);
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
} 

你可以将这个组件用 Suspense 包裹,如下所示:

import { Suspense } from 'react';
import BlogPosts from './components/BlogPosts.jsx';
function App() {
  return (
    <>
      <h1>All posts</h1>
      <Suspense fallback={<p>Fetching blog posts...</p>}>
        <BlogPosts />
      </Suspense>
    </>
  );
} 

但不幸的是,这不会按预期工作。在数据获取时,不会渲染任何内容,而是显示回退内容。

这种行为的原因是 Suspense 的目的是在组件渲染过程中获取数据时挂起——而不是在某个效果函数内部获取数据时。

这有助于回忆 useEffect() 的工作原理(来自 第八章处理副作用):效果函数在组件函数执行之后执行,即,在第一个组件渲染周期完成后。

因此,在通过 useEffect() 获取数据时,你不能使用 Suspense 来显示回退内容。相反,在这些情况下,你需要手动管理并使用执行数据获取的组件中的某些加载状态(即通过手动管理不同的状态片段,如 isLoading ——例如,如在第十一章 处理复杂状态 中所述,在 useState() 的局限性使用 useReducer() 管理状态 部分中展示)。

在渲染过程中获取数据——错误的方式

由于 Suspense 的目的是在组件在渲染过程中获取数据时显示回退内容,你可以尝试重新编写 BlogPosts 组件,使其看起来像这样:

async function BlogPosts() {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  const posts = await response.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
} 

但尝试使用此代码将在浏览器开发者工具中产生错误:

消息的特写  自动生成的描述

图 17.3:React 在客户端对异步组件发出警告

React 不支持在客户端组件中使用 async/await。只有 React Server Components 可以使用该语法(因此返回承诺)。因此,未设置以支持 RSCs 的常规 React 项目无法使用此解决方案。

当然,你可以想出一个(有问题的)替代方案,如下所示:

function BlogPosts() {
  const [posts, setPosts] = useState([]);
  new Promise(() => setTimeout(() => {
    return fetch(
      'https://jsonplaceholder.typicode.com/posts'
    ).then(response => response.json())
     .then(fetchedPosts => setPosts(fetchedPosts));
  }, 3000));
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
} 

但这种方法已经在第八章 处理副作用 中的 问题是什么? 部分中被弃用——该代码创建了一个无限循环。

所以,如果不与 RSCs(React Server Components)一起工作,将数据获取作为组件渲染过程的一部分是非常困难的。

获取 Suspense 支持颇具挑战性

由于 Suspense 需要在渲染过程中进行数据获取,这很难手动设置,因此 React 文档(react.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading)本身提到:“只有启用 Suspense 的数据源才会激活 Suspense 组件”,进一步说明这些数据源包括:

  • 使用像 Relay 和 Next.js 这样的 Suspense 启用框架进行数据获取

  • 使用 lazy() 懒加载组件代码

  • 使用 use() 读取 Promise 的值

在同一页面上,官方文档强调:“尚未支持使用非意见化框架启用 Suspense 的数据获取。”

注意

文档可能会随着时间的推移而改变——React 也是如此。但即使在你阅读此内容时,确切的措辞可能有所不同,使用 Suspense 的方式以及它不能在没有特殊库或 lazy() 等功能的情况下使用的事实,极不可能改变。

这章是在 React 19 发布时编写的。你可以访问这本书的官方变更日志,以了解自那时以来是否有什么重大变化:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/main/CHANGELOG.md

因此,除非你打算构建自己的具有 Suspense 功能的库,否则你必须坚持使用 Suspense 进行代码拆分(通过 lazy()),使用与 Suspense 集成的第三方框架或库,或者探索 use() 钩子的使用。

当然,lazy() 函数(以及如何与 Suspense 一起使用)已经在 第十章Behind the Scenes of React and Optimization Opportunities 部分的 Reducing Bundle Sizes via Code Splitting (Lazy Loading) 中进行了介绍。但其他两个选项——具有 Suspense 功能的库和 use() 钩子——又是如何的呢?

使用支持库进行数据获取的 Suspense

如你在 Using Suspense for Data Fetching with Next.js 部分所学,当使用 Next.js 时,你可以使用 Suspense 进行数据获取。但尽管 Next.js 是支持 Suspense 的最受欢迎的 React 框架之一,但它并不是你唯一的选择。

例如,TanStack Query(之前称为 React Query)是另一个流行的第三方库,它为数据获取解锁了 Suspense。这个库与 Next.js 不同,不是一个旨在帮助构建全栈 React 应用或运行服务器端代码的库。相反,TanStack Query 是一个专注于帮助客户端数据获取、数据变更和异步状态管理的库。由于它在客户端运行,因此它也适用于没有集成 SSR 和 RSC 的 React 项目——尽管你也可以在这样项目中使用它。

TanStack Query 是一个复杂且功能丰富的库——我们可能可以写一本书来专门介绍它。但以下简短的代码片段(来自一个基于 Vite 的项目,而不是 Next.js 项目)展示了如何借助该库获取数据:

import { useSuspenseQuery } from '@tanstack/react-query';
async function fetchPosts() {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();
  return posts;
}
function BlogPosts() {
  const {data} = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  });
  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
} 

在这个例子中,BlogPosts 组件使用 TanStack Query 的 useSuspenseQuery() 钩子,结合自定义的 fetchPosts() 函数,通过 HTTP 请求获取数据。正如钩子的名字所暗示的,它与 React 的 Suspense 组件集成。

因此,BlogPosts 组件可以像这样被 Suspense 包裹:

import { Suspense } from 'react';
import BlogPosts from './components/BlogPosts.jsx';
function App() {
  return (
    <>
      <h1>All posts</h1>
      <Suspense fallback={<p>Fetching blog posts...</p>}>
        <BlogPosts />
      </Suspense>
    </>
  );
} 

正如你所知,Suspense 的使用方式与 lazy() 或 Next.js 中的使用方式相同。因此,其功能和使用方式没有改变——如果你正在将 Suspense 包裹在一个与 Suspense 集成的组件周围(例如 BlogPost 通过 TanStack Query 的 useSuspenseQuery() 钩子),则可以使用 Suspense 在数据获取过程进行时输出一些后备内容。

注意

你可以在 GitHub 上找到完整的示例项目:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/17-suspense-use/examples/05-tanstack-query

当然,这只是一个简单的例子。你可以用 TanStack Query 做更多的事情,还有其他可以与 Suspense 一起使用的库。重要的是要理解,除了 Next.js 之外,还有其他选择。但也要牢记,并非所有代码(以及并非所有库)都适用于 Suspense

除了使用直接与 Suspense 集成的库(如通过 useSuspenseQuery() 钩子的 TanStack Query),你还可以借助 React 的内置 use() 钩子使用 Suspense 进行数据获取。

渲染时使用数据

React 提供的 use() 钩子不仅限于访问上下文值,如第十一章 处理复杂状态 中所示——相反,它还可以用来从承诺中读取值。

因此,你可以在组件的渲染过程中使用 use() 钩子来提取和使用承诺的值。use() 将自动与任何包装的 Suspense 组件交互,并让它了解数据获取过程的当前状态(即承诺是否已解决)。

因此,可以从 渲染时获取数据——错误方式 部分的示例调整为使用 use() 钩子,如下所示:

**import** **{ use }** **from****'react'****;**
async function fetchPosts() {
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/posts'
  );
  const posts = await response.json();
  return posts;
}
function BlogPosts() {
  **const** **posts =** **use****(****fetchPosts****());**
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
} 

BlogPosts 组件现在不再是一个使用 async/await 的组件。相反,它使用导入的 use() 钩子来读取调用 fetchPosts() 生成的承诺的值。

如前所述,use()Suspense 交互,因此 BlogPosts 可以像这样被 Suspense 包装:

import { Suspense } from 'react';
import BlogPosts from './components/BlogPosts.jsx';
function App() {
  return (
    <>
      <h1>All posts</h1>
      <Suspense fallback={<p>Fetching blog posts...</p>}>
        <BlogPosts />
      </Suspense>
    </>
  );
} 

当运行此代码时,它可能按预期工作(取决于你使用的 React 版本),但更有可能不会产生任何结果,甚至在浏览器开发者工具中显示错误消息:

计算机错误的特写  自动生成的描述

图 17.4:use() 钩子仅与由 Suspense 兼容库创建的承诺一起工作

如此错误消息所述,use() 钩子不打算与像上一个例子中创建的常规承诺一起使用。相反,它应该用于由 Suspense 兼容 库或框架提供的承诺。

注意

如果你想要违背官方建议并尝试构建支持 use()Suspense 的承诺,你可以探索官方 React 文档中链接的官方 Suspense 示例项目(19.react.dev/reference/react/Suspense)——例如,这个项目:codesandbox.io/p/sandbox/strange-black-6j7nnj

请注意,正如文档中提到的,该演示项目使用的是不稳定 API,可能无法与未来的 React 版本兼容。

因此,再次强调,需要第三方框架或库的支持。无论你尝试使用Suspense与在渲染过程中获取数据的组件(无论是否使用use())一起使用,你最终都需要帮助。

换句话说:为了利用Suspense,你需要直接通过一个与Suspense兼容的库或框架获取数据,或者你需要在一个由与Suspense兼容的库或框架生成的 promise 上使用use() Hook。

其中一个这样的框架又是 Next.js。除了在 RSC 周围使用Suspense(如使用 Next.js 进行数据获取的 suspense部分所示),你还可以将Suspense与 Next.js 生成的 promise 的use() Hook 结合使用。

使用 Next.js 创建的 promise 与 use()结合使用

Next.js 项目能够创建与use()Suspense一起工作的 promise。更准确地说,你在 RSC 中创建并传递给(客户端)组件的任何 promise 都符合use()能用的 promise。

考虑以下示例代码:

import fs from 'node:fs/promises';
import UserGoals from '../../components/UserGoals';
async function fetchGoals() {
  await new Promise((resolve) => setTimeout(resolve, 3000)); // delay
  const goals = await fs.readFile('./data/user-goals.json', 'utf-8');
  return JSON.parse(goals);
}
export default function GoalsPage() {
  **const** **fetchGoalsPromise =** **fetchGoals****();**
  return (
    <>
      <h1>Top User Goals</h1>
      <UserGoals **promise****=****{fetchGoalsPromise}** />
    </>
  );
} 

在这个代码片段中,通过调用fetchGoals()创建了一个 promise,并将其存储在一个名为fetchGoalsPromise的常量中。然后,创建的 promise(fetchGoalsPromise)被作为promise prop 的值传递给UserGoals组件。

此外,这个UserGoals组件与另一个组件一起定义在UserGoals.js文件中,如下所示:

import { use, Suspense } from 'react';
function Goals({ fetchGoalsPromise }) {
  const goals = use(fetchGoalsPromise);
  return (
    <ul>
      {goals.map((goal) => (
        <li key={goal}>{goal}</li>
      ))}
    </ul>
  );
}
export default function UserGoals({ promise }) {
  return (
    <Suspense fallback={<p id="fallback">Fetching user goals...</p>}>
      <Goals fetchGoalsPromise={promise} />
    </Suspense>
  );
} 

在这个代码示例中,UserGoals组件使用Suspense包裹Goals组件,并将接收到的promise prop 值(通过fetchGoalsPromise prop)转发给该组件。然后,Goals组件通过use() Hook 读取该 promise 值。

由于 promise 是在由 Next.js 管理的 RSC(GoalsPage)中创建的,React 不会对此代码提出异议——Next.js 创建了与use()一起工作的 promise。相反,它会在数据获取时显示后备内容(<p id="fallback">Fetching user goals...</p>),一旦数据到达并被流式传输到客户端,就会渲染最终的用户界面。

如前所述,任何未被Suspense包裹的元素(例如,本例中的<h1>元素)将立即显示。

计算机屏幕截图  自动生成的描述

图 17.5:当通过use()获取数据时,后备文本显示在标题旁边

值得注意的是,UserGoalsGoals也都是 RSC(React Server Components),尽管如此,它们仍然可以使用use() Hook。

通常,Hooks 不能在 RSC 中使用,但use() Hook 是特殊的。正如它可以在if语句或循环中使用(如第十一章处理复杂状态中所述),它可以在服务器和客户端组件中执行。

然而,当与服务器组件一起工作时,你也可以简单地使用async/await而不是use()。因此,use()钩子实际上只有在客户端组件中读取 promise 值时才真正有用——在那里,async/await不可用。

在客户端组件中使用 use()

除了用于访问上下文之外,use()钩子被引入是为了帮助在客户端组件中读取 promise 值——即在你不能使用async/await的情况下。

考虑这个更新的用户目标示例,其中管理了一些状态并触发了一个副作用:

**'use client'****;**
import { use, Suspense, **useEffect,****useState**  } from 'react';
// sendAnalytics() is a dummy function that just logs to the console
import { sendAnalytics } from '../lib/analytics';
function Goals({ fetchGoalsPromise }) {
  **const** **[mainGoal, setMainGoal] =** **useState****();**
  const goals = use(fetchGoalsPromise);
  function handleSetMainGoal(goal) {
    setMainGoal(goal);
  }
  return (
    <ul>
      {goals.map((goal) => (
        <li
          key={goal}
          id={goal === mainGoal ? 'main-goal' : undefined}
          onClick={() => handleSetMainGoal(goal)}
        >
          {goal}
        </li>
      ))}
    </ul>
  );
}
export default function UserGoals({ promise }) {
  **useEffect****(****() =>** **{**
    **sendAnalytics****(****'user-goals-loaded'****, navigator.****userAgent****);**
  **}, []);**
  return (
    <Suspense fallback={<p id="fallback">Fetching user goals...</p>}>
      <Goals fetchGoalsPromise={promise} />
    </Suspense>
  );
} 

在这个例子中,Goals组件使用useState()来管理用户标记为主要目标的目标信息。此外,UserGoals组件(使用Suspense)利用useEffect()钩子在一旦组件渲染时发送一个分析事件(即在挂起的Goals组件显示之前)。由于使用了所有这些客户端特有的功能,需要use client指令。

因此,async/await不能在GoalsUserGoals组件中使用。但由于use()钩子可以在客户端组件中使用,它为这种情况提供了一种可能的解决方案。而且,由于这个例子来自 Next.js 应用程序,React 不会对use()消耗的 promise 类型提出异议。相反,这段示例代码会导致在获取目标数据时显示后备内容。

Suspense 使用模式

如你所学,Suspense组件可以包裹在那些在渲染过程中获取数据的组件周围——只要它们以合规的方式进行。

当然,在许多项目中,你可能会有多个组件需要获取数据,并且在获取数据的同时应该显示一些后备内容。幸运的是,你可以根据需要频繁地使用Suspense组件——你甚至可以将多个Suspense组件相互组合。

一起揭示内容

到目前为止,在所有示例中,Suspense总是包裹在恰好一个组件周围。但没有任何规则阻止你将Suspense包裹在多个组件周围。

例如,以下代码是有效的:

function Shop() {
  return (
    <>
      <h1>Welcome to our shop!</h1>
      <Suspense fallback={<p>Fetching shop data...</p>}>
        <DailyDeal />
        <Products />
      </Suspense>
    </>
  );
} 

在这个代码片段中,DailyDealProducts组件的数据获取是同时开始的。由于这两个组件都被一个单一的Suspense组件包裹,后备内容在两个组件完成数据获取之前会显示。所以,如果一个组件(例如DailyDeal)在一秒后完成,而另一个组件(Products)需要五秒,这两个组件只有在五秒后才会被揭示(并替换后备内容)。

计算机屏幕截图  自动生成的描述

图 17.6:数据并行获取,并通过 Suspense 显示后备内容,直到所有组件完成

尽快揭示内容

当然,有些情况下,您可能希望为多个组件显示后备内容,但又不希望等待所有组件完成数据获取后才显示任何获取的内容。

在这种情况下,您可以使用 Suspense 多次:

function Shop() {
  return (
    <>
      <h1>Welcome to our shop!</h1>
      <Suspense fallback={<p>Fetching daily deal data...</p>}>
        <DailyDeal />
      </Suspense>
      <Suspense fallback={<p>Fetching products data...</p>}>
        <Products />
      </Suspense>
    </>
  );
} 

在这个调整后的代码示例中,DailyDealProducts 分别被两个不同的 Suspense 组件包裹。因此,每个组件的内容将在可用时被揭示,独立于其他组件的数据获取状态。

计算机屏幕截图  自动生成描述

图 17.7:每个组件在完成数据获取后用最终内容替换其后备内容

嵌套挂起内容

除了并行获取数据外,您还可以使用嵌套的 Suspense 组件创建更复杂的加载顺序。

考虑这个例子:

function Shop() {
  return (
    <>
      <h1>Welcome to our shop!</h1>
      <Suspense fallback={<p>Fetching shop data...</p>}>
        <DailyDeal />
        <Suspense fallback={<p>Fetching products data...</p>}>
          <Products />
        </Suspense>
      </Suspense>
    </>
  );
} 

在这种情况下,最初,显示文本为“获取商店数据”的段落。幕后,DailyDealProducts 组件中的数据获取开始。

一旦 DailyDeal 组件完成数据获取,其内容将被显示。同时,在 DailyDeal 下方,如果 Products 组件仍在获取数据,则渲染嵌套 Suspense 块的后备。

最后,一旦 Products 获取了其数据,内部 Suspense 组件的后备内容将被移除,并渲染 Products 组件。

计算机屏幕截图  自动生成描述

图 17.8:嵌套的 Suspense 块导致顺序数据获取和内容揭示

因此,如您所见,您可以使用 Suspense 多次。此外,您可以将不同的 Suspense 组件组合起来,以便您可以创建所需的精确加载顺序和用户体验。

您应该通过 Suspense 还是 useEffect() 获取数据?

如您在本章中学习到的,您可以使用 Suspense 与 RSCs、Suspense 启用的库或 use() 钩子(这也需要支持库)一起获取数据,并在数据获取过程中显示一些后备内容。

或者,如第十一章处理复杂状态中所述,您也可以通过 useEffect()useState()useReducer() 手动获取数据并显示后备内容。在这种情况下,您实际上管理着决定是否在您的应用中显示某些加载后备内容的自身状态;使用 Suspense,React 会为您完成这项工作。

因此,选择哪种方法取决于你。使用 Suspense 可以节省相当多的代码,因为你不需要手动管理这些不同的状态片段。结合 Next.js 或 TanStack Query 这样的框架或库,数据获取可以比通过 useEffect() 手动进行时变得显著更容易。此外,Suspense 与 RSCs 和 SSR 集成,因此可以用于在服务器端获取数据——与 useEffect() 不同,它对服务器端没有影响(没有开玩笑)。

然而,如果你没有使用支持 Suspenseuse() -启用承诺的库或框架,你除了回退到 useEffect()(因此不使用 Suspense 进行数据获取)之外,没有太多选择。随着未来 React 版本的更新,它们可能会提供帮助构建与 use() 一起工作的承诺的工具。但到目前为止,这基本上是在使用(正确的)库和 Suspense 或不使用库和 useEffect() 之间做出决定。

摘要和关键要点

  • Suspense 组件可用于在数据获取或代码下载时显示回退内容。

  • 对于数据获取,Suspense 仅与在渲染过程中通过 Suspense 启用的数据源获取数据的组件一起工作。

  • 类似于 TanStack Query 和 Next.js 这样的库和框架支持使用 Suspense 进行数据获取。

  • 使用 Next.js,你可以将 Suspense 包裹在使用 async/await 的服务器组件周围。

  • 或者,Suspense 可以包裹在使用 React 的 use() 钩子读取承诺值的组件周围。

  • use() 应仅用于读取以 Suspense 为目的解决的承诺的值——例如,由与 Suspense 兼容的第三方库创建的承诺。

  • 当使用 Next.js 时,在 RSCs 中创建并通过 props 传递给(客户端)组件的承诺可以通过 use() 消费。

  • use() 钩子有助于在需要使用客户端特定功能(如 useState())的组件中读取值和使用 Suspense

  • Suspense 可以包裹任意数量的组件以同时获取数据和显示内容。

  • Suspense 也可以嵌套以创建复杂的加载序列。

接下来是什么?

React 的 Suspense 功能非常有用,因为它有助于在代码或数据正在获取时精确地显示回退内容。同时,当涉及到数据获取时,使用 Suspense 可能很棘手,因为它仅与在渲染过程中通过 use() 钩子以正确方式获取数据的组件一起工作(例如,如果传递给钩子的承诺是 Suspense 兼容的)。

正因如此,本章还探讨了如何使用 Suspenseuse() 与 Next.js 一起,以及该框架如何简化使用 Suspenseuse() 获取数据和显示回退内容的过程。

尽管可能很复杂,但Suspense可以帮助创建出色的用户体验,因为它允许你在资源挂起时轻松显示后备内容。

本章还总结了作为 React 开发者你必须知道的 React 核心功能列表。当然,你总是可以深入研究,探索更多模式和第三方库。下一章(也是最后一章)将分享一些资源和可能的下一步行动,你可以在完成这本书后深入研究。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与github.com/mschwarzmueller/book-react-key-concepts-e2/blob/17-suspense-use/exercises/questions-answers.md中可以找到的示例进行比较:

  1. React 的Suspense组件的目的是什么?

  2. 组件需要如何获取数据才能与Suspense一起工作?

  3. 在使用 Next.js 时,Suspense可以如何使用?

  4. use()钩子的目的是什么?

  5. use()钩子可以读取哪种类型的承诺?

  6. 列出三种使用多个组件的Suspense方法。

应用你所学的知识

在了解了关于 Next.js 的所有新知识之后,是时候将其应用到实际的演示项目中了。

在下一节中,你将找到一个活动,让你练习使用 Next.js 和Suspense。一如既往,你还需要应用前面章节中介绍的一些概念。

活动十七点一:在迷你博客中实现 Suspense

在这个活动中,你的任务是建立在活动 16.1完成的工程之上。在那里,建立了一个非常简单的博客。现在,你的任务是增强这个博客,以便在博客文章列表或单个博客文章的详细信息加载时显示一些后备内容。为了证明你的知识,你应该在起始页面(/)上通过async/await获取数据,并在blog/<some-id>页面上通过use()钩子获取数据。

此外,还应将可用的博客文章列表显示在单个博客文章的详细信息下方。当然,在获取该列表数据时,必须显示一些后备文本——尽管,该文本应独立于博客文章详细信息的后备内容显示。

注意

你可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/17-suspense-use/activities/practice-1-start找到这个活动的起始项目快照。在下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这种情况下是activities/practice-1-start),以使用正确的代码快照。

在提供的起始项目中,您将找到用于获取所有博客帖子和一个单个帖子的函数。这些函数包含人工延迟以模拟缓慢的服务器。

在项目文件夹中下载代码并运行 npm install 以安装所有必需的依赖项后,解决方案步骤如下:

  1. 将获取和显示博客帖子列表的逻辑外包到一个单独的组件中。

  2. 在起始页面上使用该组件,并使用 React 的 Suspense 组件在获取博客帖子时显示一些合适的后备内容。

  3. 此外,将获取和渲染单个博客帖子详情的逻辑外包到一个单独的客户端 (!) 组件中。在 /blog/<some-id> 页面上输出这个新创建的组件。

  4. 将获取博客详情的承诺传递给新创建的组件,并使用 use() 钩子来读取其值。同时,利用 Suspense 组件输出一些后备内容。

  5. 重新使用获取并渲染博客帖子列表的组件,并在 /blog/<some-id> 页面下方输出它。使用 Suspense 来显示一些后备内容,独立于博客帖子详情的数据获取状态。

最终页面应如以下截图所示:

img

图 17.9:在获取博客帖子时显示后备内容

img

图 17.10:在获取博客帖子详情和博客帖子列表时显示后备内容

注意

您可以在此处找到此活动的完整代码和示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/17-suspense-use/activities/practice-1 .

第十八章:下一步和进一步资源

学习目标

到本章结束时,你将了解以下内容:

  • 如何从阅读书籍到应用你的知识

  • 如何最好地实践本书中学到的内容

  • 你可以探索的下一个 React 主题

  • 哪些流行的第三方 React 包可能值得仔细研究

简介

通过这本书,你已经全面(重新)了解了你必须知道的、为了成功使用 React 的关键 React 概念,为组件、属性、状态、上下文、React Hooks、路由、服务器端 React 以及许多其他关键概念提供了理论和实践指导。

但 React 不仅仅是一系列概念和想法的集合。它支持一个完整的第三方库生态系统,帮助解决许多常见的 React 特定问题。还有一个庞大的 React 社区,分享解决常见问题或流行模式的解决方案。

在这个最后的、简短的章节中,你将了解一些你可能想要探索的最重要和最受欢迎的第三方库。你还将被介绍到其他有助于学习 React 的优质资源。此外,本章还将分享一些关于如何在完成本书后作为 React 开发者继续前进和成长的建议。

你应该如何进行?

以本书中获得的知识为基础,进一步深入探索 Next.js,研究其他流行的 React 库,或者了解更多关于 React 替代品如 Angular 或 Vue 的内容。Web 开发提供了广泛的技术、语言、库、模式和概念。虽然这有时可能让人感到不知所措,但它也是一个巨大的机会池,可以帮助开发者成长并更好地解决复杂问题。

除了学习更多关于 React 和相关包的知识外,应用你的知识和实践你所学的也很重要。不要只是读一本书又一本书。相反,利用你新获得的知识来构建一些演示项目。

你不必构建下一个亚马逊或 TikTok。这些应用程序之所以由大型团队构建,是有原因的。但你应该构建一些关注几个核心问题的简单演示项目。例如,你可以构建一个非常基础的网站,让用户能够存储和查看他们的每日目标,或者构建一个基本的 Meetups 页面,让访客可以组织和参加聚会活动。

简单来说:实践是关键。你必须应用你所学的知识并构建一些东西。因为通过构建演示项目,你将自动遇到需要解决的问题,而你手头并没有解决方案。你必须尝试不同的方法,并在互联网上搜索可能的(部分)解决方案。最终,这就是你学习最多和培养解决问题的技能的方式。

你在这本书中找不到所有问题的解决方案,但这本书确实提供了基本的工具和构建块,这将帮助你解决这些问题。解决方案是通过组合这些构建块,并在本书中收集到的知识基础上构建而成的。

成为全栈 React 开发者

本书已经涵盖了开始基于 React 的后端开发所需的关键概念。第 15 章、16 章和 17 章探讨了服务器端渲染、Next.js、服务器组件和动作以及构建全栈 React 应用所需的相关功能。

因此,深入研究 Next.js 可能是下一步有趣的选择。借助官方文档或像我的 Next.js & React – The Complete Guide 在线课程,你可以获得成为全栈 React 开发者所需的知识。

而不仅仅局限于 Next.js:你还可以探索像 Remix 和 React Router(它正在获得更多的全栈功能)或 TanStack Start 这样的替代方案。如果你不介意没有像 Next.js 提供的完全集成的全栈开发体验,你还可以了解更多关于将解耦的后端连接到 React 前端的知识——即,你可以学习如何使用 Node.js 或任何其他后端语言构建和连接一个独立的后端(REST 或 GraphQL)API。

成为全栈开发者并不是你必须做的事情。这是一个选择,但根据你个人的偏好或你在团队中的角色,这可能不是最适合你的选择。重要的是要知道,使用 React 构建全栈应用是一条可能的探索路径——而且随着 Next.js 和类似框架的出现,这条路径变得相当容易。无论如何,如前所述,你应该通过构建演示项目来应用你的 React 知识和实践,无论你是否在深入研究全栈开发。

值得探索的有趣问题

那么,你可以探索和尝试构建哪些问题和演示应用?

通常,你可以尝试构建(简化版)流行的网络应用的克隆(例如亚马逊的高度简化版)。最终,你的想象力是无限的,但在接下来的几节中,你将找到三个项目想法的详细信息和相关挑战的建议。

构建购物车

一种非常常见的网站类型是在线商店。你可以找到各种产品的在线商店——从书籍、服装或家具等实体商品到视频游戏或电影等数字产品——构建这样的在线商店将是一个有趣的项目想法和挑战。

当然,在线商店确实拥有许多仅凭客户端 React 无法构建的功能。例如,整个支付过程主要是一个后端任务,其中请求必须由服务器处理。库存管理将是另一个在数据库和服务器上发生,而不是在网站访客的浏览器中发生的功能。因此,你可以使用 Next.js(或本章前面提到的替代方案之一)来处理这些后端功能,从而构建一个全栈 React 应用程序。但即使你不想深入全栈开发,在线商店也包含许多需要交互式用户界面的功能(因此,使用 React 的客户端功能会受益)。例如,你可以设置不同的页面来显示可用的产品列表、产品详情或订单的当前状态,正如你在第十三章使用 React Router 的多页应用程序中学到的。你通常在网站上还有购物车。构建这样一个购物车,结合添加和删除项目的功能,同样会利用到多个 React 功能——例如,状态管理,如第四章处理事件和状态中解释的那样。

所有这些都始于拥有几个用于虚拟产品(这些产品硬编码在前端代码中,而不是从某些后端获取)的页面(路由)、产品详情以及购物车本身。购物车显示需要通过应用程序全局状态(例如,通过上下文,如第十一章处理复杂状态中所述)管理的项目,因为网站访客必须能够从产品详情页面添加项目到购物车。你还需要各种各样的 React 组件——其中许多必须是可重用的(例如,显示的单独购物车项目)。你对 React 组件和属性的了解,来自第二章理解 React 组件和 JSX,以及第三章组件和属性,将有助于此。

购物车状态也是一个非平凡的状态。一个简单的产品列表通常不足以解决问题——尽管你当然至少可以应用你在第五章渲染列表和条件内容中学到的知识。相反,你必须检查一个项目是否已经是购物车的一部分,或者它是否是第一次添加。如果它已经是购物车的一部分,你必须更新购物车中项目的数量。当然,你还需要确保用户能够从购物车中删除项目或减少项目的数量。而且,如果你想更加复杂,你甚至可以模拟在更新购物车状态时必须考虑的价格变化。

如您所见,这个极其简单的虚拟在线商店已经提供了相当多的复杂性。当然,正如之前提到的,您也可以添加后端功能并将虚拟产品存储在数据库中。如果您愿意,可以更深入地学习 Next.js,构建一个基于 React 的更复杂的全栈应用程序。这使您能够应用在第十五章服务器端渲染与构建 Next.js 全栈应用程序,以及第十六章React 服务器组件与服务器操作中学到的知识。

构建应用程序的认证系统(用户注册和登录)

许多网站允许用户注册或登录。对于许多网站来说,在执行某些任务之前需要用户认证。例如,您必须创建一个 Google 账户,才能上传视频到 YouTube 或使用 Gmail(以及许多其他 Google 服务)。同样,在在线购买(数字)视频游戏或参加付费在线课程之前通常也需要一个账户。您不登录也无法进行在线银行操作。这只是简短的一览;还有更多例子可以添加,但您已经明白了。在许多网站上,出于各种原因都需要用户认证。

在更多网站上,这通常是可选的。例如,您可能可以以访客身份订购产品,但创建账户时您将获得额外的优势(例如,您可以跟踪您的订单历史或收集奖励积分)。

当然,构建自己的 YouTube 版本挑战性太强,不适合作为良好的实践项目。谷歌之所以雇佣了数千名开发者,是有原因的。然而,您可以识别并复制单个功能,例如用户认证。

使用 React 构建自己的用户认证系统。确保用户可以注册和登录。向您的网站添加一些示例页面(路由),并找到一种方法使某些页面仅对已登录用户可用。这些目标可能听起来不多,但实际上您在实现过程中会遇到很多挑战——这些挑战迫使您为全新的问题找到解决方案。

虽然您可以在 React 应用程序代码中使用一些虚拟(客户端)逻辑来模拟发送到您后台服务器的 HTTP 请求,但您也可以添加一个真实的演示后端。该后端需要在数据库中存储用户账户,验证登录请求,并发送回认证令牌,告知 React 前端用户的当前认证状态。在您的 React 应用程序中,这些 HTTP 请求将被视为副作用,如第八章处理副作用中所述。

再次强调,如果你想要使用真正的后端,你还需要深入了解后端开发,要么构建一个单独的服务器端应用程序,要么使用 Next.js(或任何类似的完整栈 React 框架)。或者,你也可以使用 Firebase、Supabase、Auth0 或许多其他提供前端应用程序认证后端的服务。无论哪种方式,你都可以探索如何将你的 React 应用程序连接到这样的后端。

正如你所见,这个“简单”的项目想法(或者更确切地说,功能想法)提出了许多挑战,并将要求你基于 React 知识构建,并为广泛的问题找到解决方案。

构建一个活动管理网站

如果你首先尝试构建自己的购物车系统并开始用户认证,然后你可以更进一步,构建一个结合这些功能(并提供新的、额外的功能)的更复杂的网站。

其中一个项目想法就是一个活动管理网站。这是一个用户可以创建账户,一旦登录,就可以创建活动的网站。所有访客都可以浏览这些活动并注册。是否允许作为访客注册(而不先创建账户)取决于你。

你也可以选择是否要添加后端逻辑(即处理请求并将用户和活动存储在数据库中的服务器)或者你将简单地通过应用范围内的状态存储所有数据。如果不添加后端,所有数据将在页面重新加载时丢失,你将无法在其他机器上看到其他用户创建的活动,但你仍然可以练习所有这些关键的 React 功能。

对于这种类型的模拟网站,需要许多 React 功能:可重用组件、页面(路由)、组件特定的和全局状态、处理和验证用户输入、显示条件性和列表数据等等。

再次强调,这显然不是示例的完整列表。你可以构建任何你想要的东西。发挥创意,进行实验,因为只有当你用它来解决问题时,你才能真正掌握 React。

常见且流行的 React 库

无论你正在构建哪种类型的 React 应用程序,你都会在过程中遇到许多问题和挑战。从处理和验证用户输入到发送 HTTP 请求,复杂的应用程序伴随着许多挑战。

你可以自己解决所有挑战,甚至自己编写所有需要的(React)代码。而且,为了练习,这确实是一个好主意。但随着你构建越来越复杂的应用程序,外包某些问题可能是有意义的。

幸运的是,React 拥有丰富而充满活力的生态系统,提供了解决各种常见问题的第三方包。以下是一个简要的、非详尽的列表,列出了一些可能有所帮助的流行第三方库:

  • TanStack Query:一个在 React 应用程序中帮助数据获取、缓存和管理的非常流行的库(tanstack.com/query/latest)。

  • Framer Motion:一个针对 React 的特定库,允许你在 React 应用程序中构建和实现强大且视觉上令人愉悦的动画(www.framer.com/motion/)。

  • React Hook Form:一个简化处理和验证用户输入过程的库(react-hook-form.com/)。

  • Formik:另一个流行的库,有助于处理和验证表单输入(formik.org/)。

  • Axios:一个通用的 JavaScript 库,简化了发送 HTTP 请求和处理响应的过程(axios-http.com/)。

  • Redux:在过去,这是一个必不可少的 React 库。如今,它仍然很重要,因为它可以极大地简化(复杂)跨组件或应用程序范围状态的管理(redux.js.org/)。

  • Zustand:如果你需要额外的库来帮助管理 React 应用程序中的状态,你还可以探索 Zustand——这是 Redux 的一个非常受欢迎的替代品(zustand-demo.pmnd.rs/)。

这只是一个关于一些有用和流行库的简短列表。由于潜在的挑战数不胜数,你还可以编制一个无限的库列表。在寻找解决其他问题的库时,搜索引擎和 Stack Overflow(一个开发者论坛)是你的好朋友。

使用 TypeScript

你也可以考虑在你的 React 项目中使用 TypeScript,而不是纯 JavaScript。

TypeScript 是一种 JavaScript 超集,它增加了强类型和严格类型。因此,使用 TypeScript 可以帮助你捕捉并避免与缺失值或错误值类型相关的某些错误。

你可以通过官方文档(react.dev/learn/typescript)或专门的在线课程或教程开始使用 TypeScript 进行 React 开发。

其他资源

正如之前提到的,React 确实拥有一个高度活跃的生态系统——这不仅仅体现在第三方库方面。你还会发现成千上万的博客文章,讨论各种最佳实践、模式、想法以及可能的解决方案。通过搜索正确的关键词(例如 使用 Hooks 的 React 表单验证),几乎总能找到有趣的文章或有用的库。

你还会找到大量的付费在线课程,例如 www.udemy.com/course/react-the-complete-guide-incl-redux/ 上的 React – The Complete Guide 课程,以及 YouTube 上的免费教程。

官方文档是另一个很好的探索之地,因为它包含了深入核心主题的深入探讨以及更多教程文章:react.dev/

超越 React 网络应用

本书专注于使用 React 来构建网站。这有几个原因。首先,React 最初是为了简化构建复杂网络用户界面的过程而创建的,而且 React 正在为越来越多的网站提供动力。它是使用最广泛的客户端网络开发库之一,而且比以往任何时候都更受欢迎。

但是,学习如何使用 React 进行网页开发也是有意义的,因为你不需要额外的工具——只需一个文本编辑器和浏览器即可。

话虽如此,React 也可以用来构建浏览器外部的用户界面以及网站。借助 React Native 和为 React 定制的 Ionic,你拥有两个非常流行的项目库,它们使用 React 来构建针对 iOS 和 Android 的原生移动应用。

因此,在学完所有这些 React 基础知识之后,探索这些项目也很有意义。挑选一些 React Native 或 Ionic 课程(或使用官方文档),了解你如何使用本书中涵盖的所有 React 概念来构建可以分发到平台应用商店的真正原生移动应用。

React 可以用于构建各种平台的各种交互式用户界面。现在你已经完成了这本书,你拥有了使用 React 构建下一个项目的工具——无论它针对哪个平台。

最后的话

通过本书中讨论的所有概念,以及深入学习的额外资源和起点,你已充分准备好使用 React 构建功能丰富且高度用户友好的网络应用。

无论是一个简单的博客还是一个复杂的软件即服务解决方案,你现在都知道了构建用户喜爱的 React 驱动的网络应用所需的关键 React 概念。

我希望你能从这本书中获得很多收获。请分享你的任何反馈,例如,通过 X (@maxedapps) 或发送电子邮件至 customercare@packt.com

加入我们的 Discord 社区

与其他用户、AI 专家和作者本人一起阅读这本书。

提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,等等。

扫描二维码或访问链接加入社区。

packt.link/ReactKeyConcepts2e

img

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报