React-核心概念-全-
React 核心概念(全)
原文:
zh.annas-archive.org/md5/751c075f5f8c25e4f99f8fc75bd4f4d2译者:飞龙
前言
作为构建现代、交互式用户界面的最受欢迎的 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.js和npm(默认包含在你的安装中)。
这些可以在nodejs.org/en/下载。
本站点的首页应自动为你提供适用于你平台和系统的最新安装选项。更多选项,请选择网站导航栏中的下载。这将打开一个新页面,你可以通过该页面探索所有主要平台的安装选择,如下面的截图所示:

安装 React.js
React.js 项目可以通过多种方式创建,包括包含 webpack、babel 和其他工具的自定义项目设置。本书推荐的方式是使用 Vite 工具。这个工具以及创建 React 应用的流程将在第一章,React – 什么是以及为什么中介绍,但你也可以参考本节以获取此任务的逐步说明。
执行以下步骤在你的系统上创建 React.js 项目:
-
打开你的终端(Windows 的 Powershell/命令提示符;Linux 的 bash)。
-
使用创建目录命令创建一个名为你选择的新的项目文件夹(例如,
mkdir react-projects),然后使用更改目录命令(例如,cd react-projects)进入该目录。 -
在此文件夹内创建新项目目录的命令如下:
npm create vite@latest my-app
运行此命令后,当提示输入时,请选择React和JavaScript。
-
完成后,使用
cd命令导航到您的新的目录:cd my-app -
在这个新项目目录中打开终端窗口,并运行以下命令来安装所有必需的依赖项:
npm install -
完成此命令后,在相同的终端中运行以下命令以启动 Node.js 开发服务器:
npm run dev -
此命令输出一个您可以访问以预览 React 应用程序的服务器地址。默认地址是
http://localhost:5173。在地址/位置栏中输入该地址以导航到localhost:5173,如下面的截图所示:

- 当您准备暂时停止开发时,在步骤 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 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
此外,通过这本书,您还可以获得补充/额外内容,以便您了解更多。您可以使用这些内容来补充您在书中学到的知识。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。
按照以下简单步骤获取福利:
- 扫描二维码或访问以下链接:
_3_(1).png)
packt.link/supplementary-content-9781836202271
-
提交您的购买证明。
-
提交您的书籍代码。您可以在书的第 169 页找到代码。
-
就这些!我们将直接将您的免费 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 操作:
-
为按钮添加事件监听器以监听
click事件。 -
一旦点击按钮,就用新文本替换段落中的文本。
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 编写的,并且是命令式的。这意味着你一条条地写下指令,并详细描述需要采取的每一步。
之前显示的代码可以翻译成以下更易于阅读的指令:
-
寻找页面上的第一个
button类型的HTML元素以获取对该按钮的引用。 -
创建一个名为
buttonElement的常量(即数据容器),其中包含按钮引用。 -
重复步骤 1,但获取类型为
p的第一个元素的引用。 -
将段落元素引用存储在名为
paragraphElement的常量中。 -
向
buttonElement添加一个事件监听器,该监听器监听click事件,并在发生此类click事件时触发updateTextHandler函数。 -
在
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:
-
向按钮添加事件监听器以监听
click事件(现在带有一些 React 特定的语法:onClick={…})。 -
一旦点击按钮,就用新文本替换段落的文本。
尽管如此,这段代码看起来完全不同——就像 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-javascript 和 github.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.json和package-lock.json是列出和定义项目第三方依赖项的文件:-
生产依赖项,如
react或react-dom -
开发依赖项,如
eslint用于自动代码质量检查
-
-
其他项目配置文件(例如,
.gitignore用于管理 Git 文件跟踪) -
一个
node_modules文件夹,其中包含已安装的第三方包的实际代码
值得注意的是,App.jsx 和 main.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 install,node_modules 文件夹将在本地重新创建。
摘要和关键要点
-
React 是一个库,尽管它实际上是两个主要包的组合:
react和react-dom。 -
虽然没有 React 也可以构建非平凡的 UI,但仅使用纯 JavaScript 来做这样的事情可能会很繁琐、容易出错且难以维护。
-
React 通过提供一种声明式的方式来定义 UI 的期望最终状态,简化了复杂 UI 的创建。
-
声明式意味着你定义目标 UI 内容和结构,结合不同的状态(例如,“模态是打开还是关闭?”),然后将其留给 React 来确定适当的 DOM 指令。
-
本身,react 包导出 UI 状态并管理虚拟 DOM。它是一个“桥梁”,类似于
react-dom或react-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。
-
什么是 React?
-
React 相比纯 JavaScript 项目有哪些优势?
-
命令式代码和声明式代码有什么区别?
-
什么是 单页应用程序(SPA)?
-
你如何创建新的 React 项目,为什么你需要这样一个复杂的项目设置?
加入我们的 Discord
与其他用户、AI 专家和作者本人一起阅读这本书。
提出问题,为其他读者提供解决方案,通过“问我任何问题”的环节与作者聊天,还有更多。
扫描二维码或访问链接加入社区。

第二章:理解 React 组件和 JSX
学习目标
到本章结束时,你将能够做到以下几点:
-
定义组件的确切含义
-
有效地构建和使用组件
-
利用常见的命名约定和代码模式
-
描述组件和 JSX 之间的关系
-
编写 JSX 代码并理解其用途
-
不使用 JSX 代码编写 React 组件
-
编写你的第一个 React 应用程序
简介
在上一章中,你了解了 React 的基础知识,它是什么,以及为什么你应该考虑使用它来构建用户界面。你还学习了如何使用 Vite 创建 React 项目,通过运行 npm create vite@latest <your-project-name> 来实现。
在本章中,你将了解 React 最重要概念和构建块之一。你将了解到组件是可重用的构建块,用于构建用户界面。此外,还将更详细地讨论 JSX 代码,以便你能够使用组件和 JSX 的概念来构建你自己的第一个基本 React 应用程序。
组件是什么?
React 的一个关键概念是所谓的组件的使用。组件是可重用的构建块,它们被组合起来构成最终的用户界面。例如,一个基本的网站可以由包含导航项的侧边栏和一个包含添加和查看任务元素的主体部分组成。

图 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)导入时,不需要添加文件扩展名——你只需使用包名。import和export是标准的 JavaScript 关键字,有助于将相关代码分割到多个文件中。像变量、常量、类或函数这样的东西可以通过export或export default导出,以便在导入后可以在其他文件中使用。
注意
如果将代码拆分为多个文件并使用import和export的概念对你来说完全陌生,你可能首先需要深入了解有关此主题的基本 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。
在定义名为 SubmitButton 和 AuthForm 的函数的代码片段中,这两个函数符合 React 组件的资格,因为它们都返回了 JSX 代码(这是 React 可以渲染的代码,使其可渲染)。一旦一个函数符合 React 组件的资格,它就可以像 HTML 元素一样在 JSX 代码中使用,就像 <SubmitButton /> 被用作(自闭合的)HTML 元素一样。
当使用纯 JavaScript 时,你当然通常调用函数来执行它们。对于函数组件,情况不同。React 代表你调用这些函数,因此,作为一个开发者,你将它们用作 JSX 代码中的 HTML 元素。
注意
当提到可渲染值时,值得注意的是,到目前为止,最常见返回或使用的值类型确实是 JSX 代码——即通过 JSX 定义的标记。这应该是有道理的,因为使用 JSX,你可以定义你内容和使用界面的类似 HTML 的结构。
除了 JSX 标记之外,还有一些其他关键值也符合可渲染的资格,因此也可以由自定义组件返回(而不是 JSX 代码)。最值得注意的是,你也可以返回字符串或数字,以及包含 JSX 元素、字符串或数字的数组。
React 都用这些组件做了些什么?
如果你追踪所有组件及其 import 和 export 语句到顶部,你将在 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 操作:
-
创建一个
<div>元素 -
在那个
<div>内部,创建两个子元素:<h2>和<p> -
将
<h2>元素的文本内容设置为'Hello World!' -
将
<p>元素的文本内容设置为'欢迎来到这本书!' -
将
<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 元素。
命名约定
本书中所能找到的所有组件函数的名称都像 SubmitButton、AuthForm 或 Greeting 这样。
你可以随意命名你的 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(在浏览器中)实际上是由两个包组成的:react和react-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 项目中的图像时,有两个重要的事情需要记住:
-
<img />必须是自闭合标签。 -
当显示存储在
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.jpg 的 demo.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。
-
使用组件背后的想法是什么?
-
你如何创建一个 React 组件?
-
什么将一个普通函数转换成一个 React 组件函数?
-
在 JSX 元素方面,你应该记住哪些核心规则?
-
JSX 代码是如何被 React 和 ReactDOM 处理的?
应用你所学的知识
通过本章和上一章,你已经拥有了创建 React 项目并填充一些基本组件所需的所有知识。
在下面,你将找到这本书的第一个两个实际活动。
活动二.1:创建一个 React App 来展示自己
假设你正在创建你的个人作品集页面,作为该页面的部分,你想要输出一些关于你自己的基本信息(例如,你的名字或年龄)。你可以使用 React 并构建一个 React 组件来输出这类信息,如以下活动概述。
目标是创建一个 React 应用,就像你在上一章中学到的那样(即通过npm create vite@latest <your-project-name>创建,并通过npm run dev启动开发服务器)并编辑App.jsx文件,以便输出一些关于你自己的基本信息。例如,你可以输出你的全名、地址、职位或其他类型的信息。最后,输出什么内容以及选择哪些 HTML 元素取决于你。
这个第一个练习的目的是练习项目创建和与 JSX 代码一起工作。
步骤如下:
-
通过
npm create vite@latest <project>创建一个新的 React 项目。或者,你也可以使用这里提供的起始项目快照:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-1-start。 -
编辑创建的项目中的
/src文件夹下的App.jsx文件,并返回带有任何 HTML 元素的 JSX 代码以输出关于你自己的基本信息。你可以使用起始项目快照中的index.css文件中的样式来应用一些样式。 -
此外,将图片存储在
src/assets文件夹中,并在App组件中输出它。
最终你应该得到如下输出:

图 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 项目,在这个项目中添加多个新组件。每个目标将代表一个单独的组件,所有这些目标组件将组合成另一个组件,列出所有主要目标。此外,你可以添加一个额外的标题组件,包含网页的主要标题。
完成这个活动的步骤如下:
-
通过
npm create vite@latest <project>创建一个新的 React 项目,或者使用这里提供的项目起始快照:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-2-start。 -
在新项目中,创建一个包含多个组件文件(包括单个目标、目标列表和页面标题)的
components文件夹。 -
在不同的组件文件中,为不同的目标(每个文件一个组件)定义并导出多个组件函数(
FirstGoal、SecondGoal、ThirdGoal等)。 -
此外,定义一个组件用于整体目标列表(
GoalList)和另一个组件用于页面标题(Header)。 -
在各个目标组件中,返回包含目标文本和适合的 HTML 元素结构的 JSX 代码来包含此内容。
-
在
GoalList组件中,导入并输出各个目标组件。 -
在根
App组件中导入并输出GoalList和Header组件(替换现有的 JSX 代码)。
应用你喜欢的任何样式。你也可以使用起始项目快照中的index.css文件来获取灵感。
最终你应该得到以下输出:

图 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。
在这个阶段,问题的“如何”部分更为重要,这部分可以分解为两个互补的问题:
-
将 Props 传递给组件
-
在组件中消费 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>;
}
但这个组件函数不会工作。它有一个问题:title和id在该组件函数内部从未被定义。因此,这段代码会导致错误,因为你正在使用一个未定义的变量。
当然,这些属性不应该在GoalItem组件内部定义,因为初衷是使GoalItem组件可重用,并从组件外部(即渲染<GoalItem>组件列表的组件)接收不同的title和id值 (即未在组件内部定义的变量)。
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 代码中添加到组件的“属性”(例如title或id)。
正因如此,在这个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组件实际上是一个很好的例子。
哪种方法看起来更直观?
-
<GoalItem id="g1" title="Learn React" /> -
<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 drilling或prop 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 像属性一样传递到组件中,或者通过特殊的
childrenprop 在开标签和闭标签之间传递。 -
您可以使用 JavaScript 功能,如解构、剩余属性或扩展运算符来编写简洁、灵活的代码。
-
由于您正在编写代码,所以您决定如何通过 props 传递数据。是在标签之间还是作为属性?是一个分组属性还是多个单值属性?这取决于您。
接下来是什么?
Props 允许您使组件可配置和可重用。尽管如此,它们相对静态。数据和因此 UI 输出不会改变。您无法对用户事件(如按钮点击)做出反应。
但 React 的真正力量只有在您添加事件(以及对其的反应)之后才会显现出来。
在下一章中,你将学习如何在处理 React 时添加事件监听器,以及你将学习如何对事件做出反应(无意中开玩笑),并改变应用程序的(不可见和可见)状态。
测试你的知识!
通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/03-components-props/exercises/questions-answers.md上可以找到的示例答案进行比较:
-
属性解决了哪些“问题”?
-
属性是如何传递到组件中的?
-
属性是如何在组件函数内部消费的?
-
有哪些选项可以用于将(多个)属性传递到组件中?
应用你所学的知识
通过本章和上一章,你现在已经有了足够的基础知识来构建真正可重用的组件。
下面,你将找到一个活动,它允许你应用到目前为止所获得的所有知识,包括新的属性知识。
活动三.1:创建一个应用来输出本书的目标
本活动基于上一章的活动 2.2,创建一个 React 应用来记录本书的目标,并在此基础上进行。如果你跟随了那里的内容,你可以使用你现有的代码,并通过添加属性来增强它。或者,你也可以使用以下链接中可访问的解决方案作为起点:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/02-components-jsx/activities/practice-2。
本活动的目的是构建可配置的GoalItem组件,这些组件可以通过属性进行配置。每个GoalItem组件都应该接收并输出一个目标标题和简短描述文本,以及关于目标的其他信息。
步骤如下:
-
完成上一章的第二项活动。
-
用一个新的可配置组件替换硬编码的目标组件。
-
通过属性输出具有不同标题的多个目标组件。
-
在目标组件的打开和关闭标签之间设置每个目标的详细文本描述。
最终的用户界面可能看起来像这样:

图 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 是事件名称,例如 blur 或 click,以大写字母开头)。您可以通过添加 onBlur 属性来响应 blur 事件。您可以通过 onClick 属性来监听 click 事件。您应该已经明白了。
注意
更多有关标准事件的信息,请参阅 developer.mozilla.org/en-US/docs/Web/Events#event_listing。
这些属性需要值来履行其职责。更准确地说,它们需要一个指向在事件发生时应执行的函数的指针。在上面的例子中,onBlur 属性接收一个指向 evaluateEmail 函数的指针作为值。
注意
evaluateEmail 和 evaluateEmail() 之间有一个细微的差别。前者是指向函数的指针;后者实际上执行了函数(如果有的话,则返回其返回值)。再次强调,这并不是 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() 返回的数组的两个元素被从该数组中提取出来并存储在两个常量(errorMessage 和 setErrorMessage)中。尽管如此,在处理 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('');
但当使用数组解构时,变量或常量的名称(在这种情况下为 enteredEmail 和 setEnteredEmail)由你,即开发者来决定。因此,一个合理的问题是,你应该如何命名这些变量或常量。幸运的是,在 React 和 useState() 方面有一个明确的约定,以及这些变量或常量的命名。
第一个元素(即当前状态值)的命名应该能够描述状态值的内容。例如,可以是 enteredEmail、userEmail、providedEmail、仅仅是 email 或类似的名字。你应该避免使用通用名称,如 a 或 value,或者误导性的名称,如 setValue(听起来像是一个函数——但实际上不是)。
第二个元素(即状态更新函数)的命名应该能够清楚地表明它是一个函数,并且它执行了什么操作。例如,可以是 setEnteredEmail 或 setEmail。一般来说,这个函数的约定是命名为 setXYZ,其中 XYZ 是你为第一个元素,当前状态值变量所选择的名称。(注意,尽管如此,你应该以大写字母开头,就像 setEnteredEmail 而不是 setenteredEmail。)
允许的状态值类型
管理输入的电子邮件地址(或一般用户输入)确实是处理状态的一个常见用例和示例。然而,你并不局限于这种场景和值类型。
在处理用户输入的情况下,你通常会处理诸如电子邮件地址、密码、博客文章或类似值的字符串。但任何有效的 JavaScript 值类型都可以通过 useState() 的帮助来管理。例如,你可以管理多个购物车项目的总价——即一个数字——或者一个布尔值(例如,“用户是否确认了使用条款?”)。
除了管理原始值类型外,你还可以存储和更新引用数据类型,如对象和数组。
注意
如果你对原始数据类型和引用数据类型之间的区别并不完全清楚,强烈建议你在继续阅读本书之前,通过以下链接深入了解这个核心 JavaScript 概念:academind.com/tutorials/reference-vs-primitive-values。
React 允许你将所有这些值类型作为状态来管理。你甚至可以在运行时切换值类型(就像在纯 JavaScript 中一样)。将数字作为初始状态值,并在稍后将其更新为字符串是完全没问题的。
就像在纯 JavaScript 中一样,当然你应该确保你的程序适当地处理这种行为,尽管在技术上切换类型并没有错误。
与多个状态值一起工作
当构建除了非常简单的 Web 应用或 UI 之外的应用时,你需要多个状态值。也许用户不仅能够输入他们的电子邮件,还可以输入用户名或地址。也许你还需要跟踪一些错误状态或保存购物车项目。也许用户可以点击一个“喜欢”按钮,其状态应该被保存并反映在 UI 上。有许多值会频繁变化,并且其变化应该在 UI 中体现出来。
考虑这个具体的场景:你有一个需要管理用户在电子邮件输入字段中输入的值和密码字段中插入的值的组件。每个值都应该在字段失去焦点时被捕获。
由于你有两个包含不同值的输入字段,因此你有两个状态值:输入的电子邮件和输入的密码。即使你可能在某个时刻使用这两个值(例如,用于登录用户),这些值并不是同时提供的。此外,你可能还需要每个值独立存在,因为你在用户输入数据时使用它来显示潜在的错误消息(例如,“密码太短”)。
这种情况非常常见,因此,你也可以使用useState() Hook 来管理多个状态值。主要有两种方法:
-
使用多个状态片段(多个状态值)
-
使用一个单一的、大的状态对象
使用多个状态片段
你可以通过在组件函数中多次调用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 开头(handleUpdateEmail 和 handleUpdatePassword)。这是一些 React 开发者使用的约定。事件处理函数以 handle… 开头,以清楚地表明这些函数处理某些(用户触发的)事件。这不是你必须遵循的约定。函数也可以命名为 updateEmail、updatePassword、emailUpdateHandler、passwordUpdateHandler 或其他任何名称。如果名称有意义并且遵循某些严格的约定,那么它是一个有效的选择。
你可以在组件中注册任意数量的状态切片(通过多次调用 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 对象。该对象包含两个属性:email 和 password。属性名由你决定,但它们应该描述将存储在属性中的值。
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.password和userData.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)。然后返回的值(存储更新后的email或password以及当前存储的email或password的对象)被设置为新的状态。在这种情况下,结果可能与之前相同,但现在代码遵循了推荐的最佳实践。
注意
在之前的例子中,使用了箭头函数而不是“常规”函数。两种方法都很好,你可以使用这两种函数类型中的任何一种;结果将是相同的。
总结来说,如果你新状态依赖于前一个状态,你应该始终将函数传递给状态更新函数。否则,如果新状态依赖于其他值(例如,用户输入),直接将新状态值作为函数参数传递是完全没问题且推荐的。
双向绑定
值得讨论的是 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值可以在组件函数的任何地方使用来执行其他计算——例如,通过访问userInput的length属性来推导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属性来处理表单提交。用户输入仍然通过两个状态片段(email和agreed)获取,这些状态片段在输入更改事件发生时更新。
注意
在前面的代码示例中,你可能注意到了一个之前在这本书中没有使用过的新属性: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组件是根组件)。

图 4.4:一个示例组件树
在前面的简单代码示例中,App组件是SearchBar和Overview两个组件最近的(在这个例子中,也是唯一的)祖先组件。如果应用程序的结构如图所示,状态设置在一个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组件内部管理的,而其他两个组件通过属性访问它。
在这个例子中有三个关键的事情发生:
-
SearchBar组件接收一个名为onUpdateSearch的属性,其值是一个函数——这是一个在App组件中创建并从App传递到SearchBar的函数。 -
然后,将
onUpdateSearch属性设置为SearchBar组件内部<input>元素的onChange属性的值。 -
searchTerm状态(即其当前值)通过名为currentTerm的属性从App传递到Overview。
前两点可能有些令人困惑。但请记住,在 JavaScript 中,函数是一等对象和常规值。你可以将函数存储在变量中,并在使用 React 时,将函数作为属性值传递。实际上,你已经在本章的开头看到了这一点。在介绍事件和事件处理时,函数被作为值传递给了所有这些onXYZ属性(onChange、onBlur等等)。
在这个代码片段中,一个函数被作为自定义属性(即,你创建的组件中期望的属性,而不是 React 内置的属性)的值传递。onUpdateSearch属性期望一个函数作为值,因为该属性本身被用作<input>元素上的onChange属性的值。
该属性被命名为onUpdateSearch,以使其明确期望一个函数作为值,并且它将被连接到事件。当然,可以选择任何名称;它不必以on开头。但这是一个常见的约定,为期望函数作为值并且打算连接到类似事件的属性命名。
当然,updateSearch不是一个默认事件,但由于该函数将在<input>元素的change事件上被调用,因此该属性的行为就像一个自定义事件。
使用这种结构,状态被提升到了App组件。该组件注册并管理状态。然而,它还通过handleUpdateSearchTerm函数(在这种情况下,间接地)将状态更新函数暴露给SearchBar组件。它还通过currentTerm属性将当前状态值(searchTerm)传递给Overview组件。
由于子组件和后代组件在组件状态变化时也会被 React 重新评估,因此App组件的变化也会导致SearchBar和Overview组件被重新评估。因此,searchTerm的新属性值将被获取,React 将通过 UI 更新。
对于此操作不需要新的 React 功能。这只是一个状态和 props 的组合。然而,根据这些功能如何连接以及它们在哪里使用,可以实现简单和更复杂的 app 模式。
概括和要点
-
可以通过
on[EventName]属性(例如,onClick,onChange)将事件处理器添加到 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找到的示例进行比较。
-
状态解决了哪个“问题”?
-
props 和状态之间的区别是什么?
-
状态是如何在组件中注册的?
-
useState()Hook 提供了哪些值? -
单个组件可以注册多少个状态值?
-
状态是否也会影响其他组件(而不仅仅是注册状态的组件)?
-
如果新状态依赖于前一个状态,应该如何更新状态?
-
怎样才能在多个组件之间共享状态?
应用所学知识
在本章中获得的新知识使你最终能够构建真正动态的 UI 和 React 应用程序。现在,你不再局限于硬编码的静态内容和页面,可以使用状态来设置和更新值,并强制 React 重新评估组件和 UI。
在这里,你可以找到一个活动,让你应用到目前为止所获得的所有知识,包括新的状态知识。
活动四.1:构建简单计算器
在这个活动中,你将构建一个非常基本的计算器,允许用户将两个数字相加、相减、相乘和相除。
步骤如下:
-
使用 React 组件构建 UI。确保为四个数学运算构建四个单独的组件,即使有很多代码可以复用。
-
当用户在两个相关输入字段之一中输入值时,收集用户输入并更新结果。
注意,当与数字打交道并从用户输入中获取这些数字时,你需要确保输入的值被当作数字处理,而不是字符串。
计算器的最终结果和 UI 应该看起来像这样:

图 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来构建一个稍微复杂一点的计算器。目标是减少组件数量,并构建一个单组件,用户可以通过下拉元素选择数学运算。此外,结果应该输出到不同的组件中——也就是说,不是在收集用户输入的组件中。
步骤如下:
-
从上一个活动中移除四个组件中的三个,并使用一个单组件来处理所有数学运算。
-
在剩余的组件(两个输入之间)添加一个下拉元素(
<select>元素),并将四个数学运算作为选项(<option>元素)添加到其中。 -
使用状态来收集用户输入的数字和通过下拉菜单选择的数学运算(是否喜欢一个单一的状态对象或多个状态片段由你决定)。
-
在另一个组件中输出结果。(提示:选择一个合适的地方来注册和管理状态。)
计算器的结果和 UI 应该看起来像这样:

图 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>元素本身实际上不是条件性的。它始终存在,无论它包含一个完整的句子还是一个空字符串。如果你打开浏览器开发者工具并检查该页面的该区域,你会看到一个空的段落元素,如下面的图所示:

图 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>
);
}
在这个例子中,如果showTerms为true,paragraph变量不存储文本,而是存储一个完整的 JSX 元素(<p>元素)。在返回的 JSX 代码中,存储在paragraph变量中的值通过{paragraph}动态输出。如果showTerms为false,paragraph存储的值为undefined,并且不会将任何内容渲染到 DOM 中。因此,在 JSX 代码中插入null或undefined会导致 React 不输出任何内容。但如果showTerms为true,整个段落作为一个值保存并输出到 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 语句时更短。段落常量包含段落(包括文本内容)或 null。null 被用作替代值,因为 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)仅仅意味着如果条件(showDetails 和 showTerms)不满足,则不渲染任何内容。
这就是为什么在 React 开发者中流行另一种模式:
<div>
{showDetails && <h1>Product Details</h1>}
</div>
这是实现预期结果的最短方式,如果showDetails是true,则仅渲染<h1>元素及其内容。
此代码使用(或滥用)了 JavaScript 逻辑运算符的一个有趣的行为,特别是&&(逻辑与)运算符。在 JavaScript 中,如果第一个值(即&&前面的值)是true或真值(即不是false、undefined、null、0等),则&&运算符返回第二个值(即&&后面的值)。通常,你会在if语句或三元表达式中使用&&运算符。然而,当与 React 和 JSX 一起工作时,你可以利用前面描述的行为有条件地输出真值。这种技术也称为短路。
例如,以下代码将输出'Hello':
console.log(1 === 1 && 'Hello');
这种行为可以用来编写非常短的检查条件并输出另一个值的表达式,如前例所示。
注意
值得注意的是,如果你使用非布尔条件值(即&&前面的值持有非布尔值)与&&一起使用,可能会导致意外结果。如果showDetails是0而不是false(无论什么原因),屏幕上会显示数字0。因此,你应该确保作为条件的值产生null或false而不是任意假值。例如,你可以通过添加!!(例如,!!showDetails)强制转换为布尔值。如果你的条件值已经持有null或false,则不需要这样做。
发挥创意!
到目前为止,你已经学习了三种不同的定义和有条件输出内容的方法(常规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 实际上在浏览器开发者工具控制台中显示了一个警告,如下面的截图所示:

图 5.4:React 有时会生成关于缺少唯一键的警告
React 正在抱怨缺少键。
要理解这个警告和键背后的理念,探索一个特定的用例和该场景的潜在问题是有帮助的。假设你有一个负责显示项目列表的 React 组件——可能是一个待办事项列表。此外,假设这些列表项可以重新排序,并且列表可以通过其他方式编辑(例如,可以添加新项,更新或删除现有项等)。换句话说,列表不是静态的。
考虑这个示例用户界面,其中向待办事项列表中添加了一个新项:

图 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 突出显示(通过短暂闪烁)。参考以下截图:

图 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 元素被更新:

图 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找到的示例进行比较。:
-
“条件内容”是什么?
-
至少列举两种渲染 JSX 元素的条件方式。
-
哪种优雅的方法可以用来条件性地定义元素标签?
-
仅使用三元表达式(对于条件内容)的潜在缺点是什么?
-
如何将数据列表渲染为 JSX 元素?
-
为什么应该在渲染的列表项中添加键?
-
分别给出一个良好和不良键的示例。
应用你所学的知识
你现在能够使用你的 React 知识以各种方式改变动态用户界面。除了能够更改显示的文本值和数字之外,你现在也可以隐藏或显示整个元素(或元素块)并显示数据列表。
在以下章节中,你将找到两个活动,这些活动允许你应用你新获得的知识(结合在其他书籍章节中获得的知识)。
活动 5.1:显示条件错误消息
在这个活动中,你将构建一个基本表单,允许用户输入他们的电子邮件地址。在表单提交后,用户输入应该被验证,无效的电子邮件地址(为了简单起见,这里指的是不包含@符号的电子邮件地址)应该导致在表单下方显示错误消息。当无效的电子邮件地址变为有效时,可能可见的错误消息应该再次被移除。
执行以下步骤以完成此活动:
-
构建一个包含标签、输入字段(文本类型——为了演示目的,使输入错误的电子邮件地址更容易)和提交按钮的用户界面,该按钮会导致表单提交。
-
收集输入的电子邮件地址,如果电子邮件地址不包含
@符号,则在表单下方显示错误消息。
最终用户界面应该看起来和工作方式如下所示:

图 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:输出产品列表
在这个活动中,你将构建一个用户界面,其中在屏幕上显示产品列表((虚拟)产品)。界面还应包含一个按钮,当点击时,将另一个新的(虚拟)项目添加到现有的产品列表中。
执行以下步骤以完成此活动:
-
将一组虚拟产品对象(每个对象应具有 ID、标题和价格)添加到 React 组件中,并添加代码以输出这些产品项作为 JSX 元素。
-
向用户界面添加一个按钮。当按钮被点击时,应该向产品数据列表中添加一个新的产品对象。这应该导致用户界面更新并显示更新后的产品元素列表。
最终用户界面应该看起来和工作方式如下所示:

图 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>元素),如下面的截图所示:

图 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 开发者工具中的元素选项卡,如下所示:

图 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 设置了文本的color和size属性。
这种方法与仅使用 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-prio和high-prio之间交替,取决于下拉选择。然后状态值作为段落内的文本输出,也用作动态 CSS 类名,应用于<p>元素。当然,为了产生任何视觉效果,必须在某个 CSS 文件或<style>块中定义low-prio和high-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属性值为true且isRecommended属性值为false,则input-default CSS 类将应用于<input>元素,因为两个if语句都没有激活。
如果isRecommended为true(但isValid为false),则应用input-recommended CSS 类。如果isValid为false,则添加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>元素的背景颜色是基于通过isValid和isRecommended属性接收到的值有条件地设置的。
结合多个动态 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-default和text-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属性的对象。如果isImportant为true,它将被替换为一个包含所有先前属性(通过扩展运算符...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>元素将同时接收btn和btn-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>;
};
你可以看到,本书中涵盖的所有不同概念是如何在这里汇聚的:属性允许定制,值可以设置、交换和动态条件地更改,因此可以构建高度可重用和可配置的组件。
使用固定配置选项进行定制
除了暴露className或style等属性,这些属性会与组件函数内部定义的其他类或样式合并外,你还可以构建基于其他属性值应用不同样式或类名的组件。
这在前面的示例中已经展示过,其中使用了isValid或isImportant等属性来有条件地应用某些样式。因此,这种应用样式的做法可以被称为“间接样式”(尽管这不是一个官方术语)。
两种方法在不同的环境中都能发挥作用。例如,对于包装组件,接受className或style属性(这些可以在组件内部与其他样式合并)使得组件可以像内置组件一样使用(例如,像它所包装的组件)。另一方面,如果你想要构建提供一些预定义变体的组件,间接样式可能非常有用。
一个很好的例子是,一个文本框提供了两个内置主题,可以通过特定的属性进行选择。

图 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 类。
因此,这个组件不允许通过某些className或style属性进行直接样式化,但它确实提供了不同的变体或主题,可以通过特定的属性(在这种情况下是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 文件中指定的类名:

图 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 库本质上提供了所有内置核心组件的包装组件(例如,围绕 p、a、button、input 等)。它将这些包装组件作为 标记模板 暴露出来——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 样式(使用
element、id、class或其他选择器)的替代方案,内联样式可以用来为 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 特定的功能和主题。你将了解** portals和refs**,这两个是 React 内置的关键概念。你将发现这些概念解决了哪些问题,以及这两个功能是如何使用的。
测试你的知识!
通过回答以下问题来测试您对本章涵盖的概念的理解。您可以将您的答案与以下示例进行比较:github.com/mschwarzmueller/book-react-key-concepts-e2/blob/06-styling/exercises/questions-answers.md。
-
React 组件的样式是用哪种语言定义的?
-
与没有 React 的项目相比,在为元素分配类时,需要牢记哪些重要差异?
-
如何动态和条件性地分配样式?
-
在样式上下文中,“限定”是什么意思?
-
样式如何限定到组件中?简要解释至少一个有助于限定的概念。
应用所学知识
现在,您不仅能够构建交互式用户界面,还能够以引人入胜的方式对用户界面元素进行样式设计。您可以根据条件动态地设置和更改这些样式。
在本节中,您将找到两个活动,这些活动允许您将新获得的知识与之前章节中学到的知识相结合来应用。
活动六.1:在表单提交时提供输入有效性反馈
在本活动中,您将构建一个基本的表单,允许用户输入电子邮件地址和密码。每个输入字段的输入都会进行验证,并且验证结果会被存储(针对每个单独的输入字段)。
本活动的目的是添加一些通用的表单样式和一些条件样式,一旦提交了无效表单,这些样式就会生效。具体的样式由您决定,但为了突出显示无效的输入字段,必须更改受影响输入字段的背景颜色、边框颜色以及相关标签的文本颜色。
步骤如下:
-
创建一个新的 React 项目,并向其中添加一个表单组件。
-
在项目的根组件中输出表单组件。
-
在表单组件中,输出包含两个输入字段的表单:一个用于输入电子邮件地址,另一个用于输入密码。
-
为输入字段添加标签。
-
在表单提交时存储输入的值并检查它们的有效性(您可以在形成自己的验证逻辑方面发挥创意)。
-
从提供的
index.css文件中选择合适的 CSS 类(或者您也可以编写自己的类)。 -
一旦提交了无效值,就将它们添加到无效的输入字段及其标签上。
最终用户界面应如下所示:

图 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组件中,这样冲突的类名就不会干扰样式。
步骤如下:
-
完成上一个活动或从 GitHub 获取完成的代码。
-
识别属于
Form组件的特定样式,并将它们移动到新的、特定于组件的 CSS 文件中。 -
将 CSS 选择器更改为类名选择器,并根据需要将类添加到 JSX 元素中(这是因为 CSS 模块需要类名选择器)。
-
使用本章中解释的特定于组件的 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 将保持为零,尽管在 handleChangeCounters 中 counter2 变量也被更改为值 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中存储实际值。
由于Preferences和Form操作的是存储在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())是一种方法,可以用来允许 Form 和 Preferences 组件协同工作。但尽管这最初可能看起来像是一个优雅的解决方案,但对于这类问题,它通常不应成为您的默认解决方案。
如上面示例所示,使用 Refs 最终会导致更多的命令式代码。这是因为,而不是通过 JSX(这将是一种声明式方法)定义所需的用户界面状态,JavaScript 中添加了单个的逐步指令。
如果您回顾 第一章 ,React – 什么和为什么(JavaScript 的弊端部分),您会看到像 preferencesRef.current.reset()(来自上面的例子)这样的代码看起来与 buttonElement.addEventListener(…)(来自 第一章 的例子)这样的指令非常相似。这两个例子都使用了命令式代码,并且应该避免,正如 第一章 中提到的理由(逐步编写指令会导致低效的微观管理,并且通常会产生不必要的复杂代码)。
在 Form 组件内部,调用了 Preferences 的 reset() 函数。因此,代码描述了应该执行的动作(而不是预期的结果)。通常,当使用 React 时,您应该努力描述所需的(UI)状态。记住,当使用 React 时,您应该编写声明式代码,而不是命令式代码。
当使用 Refs 来读取或操作数据,如本章前面的部分所示,您正在构建所谓的不受控组件。这些组件被认为是“不受控”的,因为 React 并没有直接控制 UI 状态。相反,值是从其他组件或 DOM 中读取的。因此,DOM 控制着状态(例如,用户输入到输入字段中的值这样的状态)。
作为 React 开发者,你应该尽量减少使用非受控组件。如果你只需要收集一些输入值,使用 Refs 来节省一些代码是完全可行的。但是,一旦你的 UI 逻辑变得更加复杂(例如,如果你还想清除用户输入),你应该选择 受控组件。
这样做相当简单:组件一旦被 React 管理状态,就变为受控组件。在本章开头提到的 EmailForm 组件的例子中,在引入 Refs 之前已经展示了受控组件的方法。使用 useState() 存储用户的输入(并且每次按键更新状态)意味着 React 完全控制了输入的值。
对于前面的例子,Form 和 Preferences 组件,切换到受控组件方法可能看起来像这样:
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() 函数重置状态,并将管理的状态值(wantsNewProdInfo 和 wantsProdUpdateInfo)以及 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 的根(换句话说,是


浙公网安备 33010602011771号