SpringBoot3-和-React-全栈开发-全-
SpringBoot3 和 React 全栈开发(全)
原文:
zh.annas-archive.org/md5/f75986b979689dc508f110d7a6acc600译者:飞龙
前言
如果您是一位希望成为全栈开发者或学习另一个前端框架的现有 Java 开发者,这本书是您简洁的 React 入门指南。在这个三部分的建设过程中,您将创建一个健壮的 Spring Boot 后端、一个 React 前端,然后将它们一起部署。
这本新版本已更新至 Spring Boot 3,并增加了关于安全和测试的扩展内容。首次,它还涵盖了使用热门的 TypeScript 进行 React 开发。
您将探索创建 REST API、测试、安全性和部署应用程序所需的元素。您将了解自定义 Hooks、第三方组件和 MUI。
在本书结束时,您将能够使用最新的工具和现代最佳实践构建全栈应用程序。
适合阅读本书的对象
这本书是为那些对 Spring Boot 有基本了解但不知道从何开始构建全栈应用程序的 Java 开发者而写的。基本的 JavaScript 和 HTML 知识将帮助您跟上进度。
如果您是一位了解 JavaScript 基础的前端开发者,希望学习全栈开发,或者是一位在其他技术栈中经验丰富的全栈开发者,希望学习新的技术栈,这本书将非常有用。
本书涵盖内容
第一部分:使用 Spring Boot 进行后端编程
第一章,设置环境和工具 – 后端,解释了如何安装本书中用于后端开发的软件以及如何创建您的第一个 Spring Boot 应用程序。
第二章,理解依赖注入,解释了依赖注入的基础知识以及如何在 Spring Boot 中实现它。
第三章,使用 JPA 创建和访问数据库,介绍了 JPA 并解释了如何使用 Spring Boot 创建和访问数据库。
第四章,使用 Spring Boot 创建 RESTful Web 服务,解释了如何使用 Spring Data REST 创建 RESTful Web 服务。
第五章,保护您的后端,解释了如何使用 Spring Security 和 JWTs 来保护您的后端。
第六章,测试您的后端,涵盖了 Spring Boot 中的测试。我们将为我们的后端创建一些单元和集成测试,并了解测试驱动开发。
第二部分:使用 React 进行前端编程
第七章,设置环境和工具 – 前端,解释了如何安装本书中用于前端开发的软件。
第八章,React 入门,介绍了 React 库的基础知识。
第九章,TypeScript 简介,涵盖了 TypeScript 的基础知识以及如何使用它来创建 React 应用程序。
第十章,使用 React 消费 REST API,展示了如何使用 Fetch API 通过 React 使用 REST API。
第十一章,React 的前端开发中的一些有用的第三方组件,展示了我们将使用的一些有用的组件。
第三部分:全栈开发
第十二章,为我们的 Spring Boot RESTful Web 服务设置前端,解释了如何为前端开发设置 React 应用程序和 Spring Boot 后端。
第十三章,添加 CRUD 功能,展示了如何将 CRUD 功能实现到 React 前端。
第十四章,使用 MUI 美化前端,展示了如何使用 React MUI 组件库来美化用户界面。
第十五章,测试您的前端,解释了 React 前端测试的基础知识。
第十六章,保护您的应用程序,解释了如何使用 JWT 保护前端。
第十七章,部署您的应用程序,演示了如何使用 AWS 和 Netlify 部署应用程序,以及如何使用 Docker 容器。
要充分利用本书
在本书中,您需要使用 Spring Boot 3.x 版本。所有代码示例都是在 Windows 上使用 Spring Boot 3.1 和 React 18 进行测试的。在安装任何 React 库时,您应该检查其文档中的最新安装命令,并查看是否有与本书中使用版本相关的重大变化。
每章的技术要求都在章节开头说明。
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 在github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781805122463
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将Button导入到AddCar.js文件中。”
代码块按照以下方式设置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public class Car {
@Id
**@GeneratedValue(strategy=GenerationType.AUTO)**
private long id;
private String brand, model, color, registerNumber;
private int year, price;
}
任何命令行输入或输出都按照以下方式编写:
npm install component_name
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“您可以选中运行菜单并按运行 | Java 应用程序。”
重要提示
它看起来像这样。
TIPS
它看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
读完 Full Stack Development with Spring Boot 3 and React, Fourth Edition 后,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 复印本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,每购买一本 Packt 书,您都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
好处不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781805122463
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件
第一部分
使用 Spring Boot 进行后端编程
第一章:设置环境和工具 – 后端
在本书中,我们将学习使用 Spring Boot 在后端和 React 在前端进行全栈开发。本书的第一部分侧重于后端开发。本书的第二部分侧重于使用 React 的前端编程。在第三部分,我们将实现前端。
在本章中,我们将设置用于使用 Spring Boot 进行后端编程的环境和工具。Spring Boot 是一个基于现代 Java 的后端框架,它使开发速度比传统的基于 Java 的框架更快。使用 Spring Boot,你可以创建一个具有嵌入式应用服务器的独立 Web 应用程序。
有很多不同的集成开发环境(IDE)工具可用于开发 Spring Boot 应用程序。在本章中,我们将安装Eclipse,这是一个适用于多种编程语言的开源 IDE。我们将通过使用Spring Initializr项目启动页面来创建我们的第一个 Spring Boot 项目。在开发 Spring Boot 应用程序时,阅读控制台日志是一项关键技能,我们也将涉及这一点。
在本章中,我们将探讨以下主题:
-
安装 Eclipse
-
理解 Gradle
-
使用 Spring Initializr
-
安装 MariaDB
技术要求
要与 Eclipse 和 Spring Boot 3 一起使用,需要Java 软件开发工具包(JDK),版本 17 或更高。在本书中,我们使用的是 Windows 操作系统,但所有工具也适用于 Linux 和 macOS。您可以从 Oracle([www.oracle.com/java/technologies/downloads/](https://www.oracle.com/java/technologies/downloads/))获取 JDK 安装包,或者您也可以使用 OpenJDK 版本。您可以通过在终端中输入 java –version 命令来检查已安装的 Java SDK 版本。
您可以从 GitHub 下载本章的代码:https://github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter01。
安装 Eclipse
Eclipse是由 Eclipse 基金会开发的开放源代码编程 IDE。可以从www.eclipse.org/downloads下载安装包或安装程序。Eclipse 可用于 Windows、Linux 和 macOS。如果您熟悉其他 IDE 工具,如 IntelliJ 或 VS Code,您也可以使用它们。
您可以下载 Eclipse 的 ZIP 包或执行安装向导的安装包。在安装程序中,您应选择适用于企业 Java 和 Web 开发者的 Eclipse IDE,如下面的截图所示:

图 1.1:Eclipse 安装程序
如果使用 ZIP 包,您必须将其提取到您的本地磁盘上,它将包含一个可执行的 eclipse.exe 文件,您可以通过双击文件来运行它。您应该下载 Eclipse IDE for Enterprise Java and Web Developers 包。
Eclipse 是一个支持多种编程语言的 IDE,如 Java、C++ 和 Python。Eclipse 包含不同的 视角以满足您的需求,这些视角是 Eclipse 工作台中的一组视图和编辑器。以下截图显示了 Java 开发中常见的视角:

图 1.2:Eclipse 工作台
在左侧,我们有 项目资源管理器,我们可以在这里看到我们的项目结构和资源。项目资源管理器也用于通过双击文件来打开文件。文件将在工作台中间的编辑器中打开。控制台视图可以在工作台的下半部分找到。这个视图非常重要,因为它显示了应用程序的日志消息。
重要提示
如果您想,您可以为 Eclipse 获取 Spring Tool Suite (STS),但在这本书中我们不会使用它,因为纯 Eclipse 安装就足够我们使用了。STS 是一系列插件,可以使 Spring 应用程序开发变得简单,您可以在以下链接中找到更多关于它的信息:spring.io/tools。
现在我们已经安装了 Eclipse,让我们快速了解一下 Gradle 是什么以及它如何帮助我们。
理解 Gradle
Gradle 是一个构建自动化工具,它使软件开发过程变得更简单,并统一了开发过程。它管理我们的项目依赖关系并处理构建过程。
重要提示
您还可以使用另一个名为 Maven 的项目管理工具与 Spring Boot 一起使用,但我们将专注于本书中使用 Gradle,因为它比 Maven 更快、更灵活。
在我们的 Spring Boot 项目中使用 Gradle 不需要执行任何安装,因为我们正在项目中使用 Gradle 包装器。
Gradle 配置是在项目的 build.gradle 文件中完成的。该文件可以根据项目的具体需求进行自定义,并可用于自动化构建、测试和部署软件等任务。build.gradle 文件是 Gradle 构建系统的重要组成部分,用于配置和管理软件项目的构建过程。build.gradle 文件通常包含有关项目依赖信息,如项目编译所需的外部库和框架。您可以使用 Kotlin 或 Groovy 编程语言编写 build.gradle 文件。在本书中,我们使用 Groovy。以下是一个 Spring Boot 项目 build.gradle 文件的示例:
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
}
group = 'com.packt'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-
test'
}
tasks.named('test') {
useJUnitPlatform()
}
build.gradle 文件通常包含以下部分:
-
插件:
plugins块定义了项目中使用的 Gradle 插件。在这个块中,我们可以定义 Spring Boot 的版本。 -
仓库:
repositories块定义了用于解析依赖项的依赖项仓库。我们使用 Maven Central 仓库,Gradle 从中拉取依赖项。 -
依赖项:
dependencies块指定了项目中使用的依赖项。 -
任务:
tasks块定义了构建过程中的任务,例如测试。
Gradle 通常从命令行使用,但我们使用 Gradle 包装器和 Eclipse,它们处理我们需要的所有 Gradle 操作。包装器是一个脚本,它调用声明的 Gradle 版本,并将您的项目标准化为给定的 Gradle 版本。因此,我们在此不关注 Gradle 命令行使用。最重要的是理解 build.gradle 文件的结构以及如何向其中添加新的依赖项。我们将在下一节学习如何使用 Spring Initializr 添加依赖项。在本书的后续章节中,我们还将手动将新的依赖项添加到 build.gradle 文件中。
在下一节中,我们将创建我们的第一个 Spring Boot 项目,并查看我们如何使用 Eclipse IDE 运行它。
使用 Spring Initializr
我们将使用 Spring Initializr 创建我们的后端项目,这是一个基于网络的工具,用于创建 Spring Boot 项目。然后,我们将学习如何使用 Eclipse IDE 运行我们的 Spring Boot 项目。在本节的最后,我们还将探讨如何使用 Spring Boot 日志。
创建项目
要使用 Spring Initalizr 创建我们的项目,请完成以下步骤:
- 通过您的网络浏览器导航到
start.spring.io打开 Spring Initializr。您应该看到以下页面:

图 1.3:Spring Initializr
-
我们将生成一个 Gradle - Groovy 项目,包含 Java 和最新的稳定版 Spring Boot 3.1.x。如果您使用的是更新的主要或次要版本,您应该检查发布说明以了解有哪些变化。在 组 字段中,我们将定义我们的组 ID(com.packt),这也会成为我们 Java 项目的基包。在 工件 字段中,我们将定义一个工件 ID(cardatabase),这也会成为我们在 Eclipse 中的项目名称。
重要提示
在 Spring Initializr 中选择正确的 Java 版本。在本章中,我们使用 Java 版本 17。在 Spring Boot 3 中,Java 基准是 Java 17。
-
通过点击添加依赖项…按钮,我们将选择项目中需要的启动器和依赖项。Spring Boot 提供简化 Gradle 配置的启动包。Spring Boot 启动包实际上是一组你可以包含在项目中的依赖项。我们将通过选择两个依赖项来开始我们的项目:Spring Web和Spring Boot DevTools。你可以在搜索字段中输入依赖项,或者从出现的列表中选择,如图下所示:
![]()
图 1.4:添加依赖项
Spring Boot DevTools依赖项为我们提供了 Spring Boot 开发者工具,这些工具提供了自动重启功能。这使得开发变得更快,因为当更改已保存时,应用程序会自动重启。
Spring Web启动包是全栈开发的基石,并提供了内嵌的 Tomcat 服务器。在你添加了依赖项之后,Spring Initializr 中的依赖项部分应该看起来像这样:
![]()
图 1.5:Spring Initializr 依赖项
-
最后,点击生成按钮,这将为我们生成一个项目启动 ZIP 包。
接下来,我们将学习如何使用 Eclipse IDE 运行我们的项目。
运行项目
执行以下步骤以在 Eclipse IDE 中运行 Gradle 项目:
-
提取我们在上一节中创建的项目 ZIP 包,并打开Eclipse。
-
我们将把我们的项目导入到 Eclipse IDE 中。要开始导入过程,选择文件 | 导入菜单,导入向导将被打开。以下截图显示了向导的第一页:

图 1.6:导入向导(步骤 1)
- 在第一阶段,你应该从Gradle文件夹下的列表中选择现有 Gradle 项目,然后点击下一步 >按钮。以下截图显示了导入向导的第二步:

图 1.7:导入向导(步骤 2)
-
在这个阶段,点击浏览...按钮并选择提取的项目文件夹。
-
点击完成按钮以完成导入。如果一切运行正确,你应该在 Eclipse IDE 的项目资源管理器中看到
cardatabase项目。项目准备需要一段时间,因为所有依赖项将在导入后由 Gradle 下载。你可以在 Eclipse 的右下角看到依赖项下载的进度。以下截图显示了成功导入后的 Eclipse IDE项目资源管理器:

图 1.8:项目资源管理器
-
项目资源管理器也显示了我们的项目包结构。一开始,只有一个名为
com.packt.cardatabase的包。在该包下面是我们的主应用程序类,名为CardatabaseApplication.java。 -
现在,我们的应用程序没有任何功能,但我们可以运行它并查看是否一切已成功启动。要运行项目,通过双击它打开主类,如图所示,然后在 Eclipse 工具栏中点击 运行 按钮(播放图标)。或者,您可以选择 运行 菜单并点击 运行方式 | Java 应用程序:
![]()
图 1.9:Cardatabase 项目
您可以在 Eclipse 中看到 控制台 视图已打开,其中包含有关项目执行的重要信息。正如我们之前讨论的,这是所有日志文本和错误消息出现的视图,因此当出现问题时检查视图的内容非常重要。
如果项目执行正确,您应该在控制台末尾的文本中看到已启动的
CardatabaseApplication类。以下截图显示了我们的 Spring Boot 项目启动后 Eclipse 控制台的内容:![]()
图 1.10:Eclipse 控制台
您也可以使用以下命令(在您的项目文件夹中)从命令提示符或终端运行您的 Spring Boot Gradle 项目:
gradlew bootRun
在我们的项目根目录中,有一个 build.gradle 文件,这是我们的项目 Gradle 配置文件。如果您查看文件中的依赖项,您会看到现在有我们在 Spring Initializr 页面上选择的依赖项。还有一个自动包含的测试依赖项,如下面的代码片段所示:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-
web'
developmentOnly 'org.springframework.boot:spring-boot-
devtools'
testImplementation 'org.springframework.boot:spring-boot-
starter-test'
}
在接下来的章节中,我们将向我们的应用程序添加更多功能,然后我们将手动将更多依赖项添加到 build.gradle 文件中。
让我们更仔细地看看 Spring Boot 的 main 类:
package com.packt.cardatabase;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
**@SpringBootApplication**
public class CardatabaseApplication {
public static void main(String[] args) {
SpringApplication.run(CardatabaseApplication.class, args);
}
}
在类开始处有 @SpringBootApplication 注解,这实际上是多个注解的组合:
| 注释 | 描述 |
|---|---|
@EnableAutoConfiguration |
这启用了 Spring Boot 的自动配置,因此您的项目将根据依赖项自动进行配置。例如,如果您有 spring-boot-starter-web 依赖项,Spring Boot 假设您正在开发一个 Web 应用程序,并相应地配置您的应用程序。 |
@ComponentScan |
这启用了 Spring Boot 组件扫描以找到您应用程序的所有组件。 |
@Configuration |
这定义了一个可以用作 bean 定义源的类。 |
表 1.1:SpringBootApplication 注解
应用程序的执行从 main() 方法开始,就像标准 Java 应用程序一样。
重要提示
建议您将主应用程序类定位在根包中,位于其他类之上。包含应用程序类的所有包都将由 Spring Boot 的组件扫描覆盖。应用程序无法正常工作的一个常见原因是 Spring Boot 无法找到关键类。
Spring Boot 开发工具
Spring Boot 开发工具使应用程序开发过程更简单。开发工具最重要的功能是在classpath上的文件被修改时自动重启。如果将以下依赖项添加到 Gradle 的 build.gradle 文件中,项目将包括开发工具:
developmentOnly 'org.springframework.boot:spring-boot-devtools'
当您创建应用程序的完整打包生产版本时,开发工具将被禁用。您可以通过在主类中添加一条注释行来测试自动重启,如下所示:
package com.packt.cardatabase;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CardatabaseApplication {
public static void main(String[] args) {
// After adding this comment the application is restarted
SpringApplication.run(CardatabaseApplication.class, args);
}
}
保存文件后,您可以在控制台中看到应用程序已重启。
日志和问题解决
日志可以用来监控您的应用程序流程,并且是捕获程序代码中意外错误的好方法。Spring Boot 启动包提供了 Logback,我们可以用于日志记录而无需任何配置。以下示例代码展示了如何使用日志。Logback 使用 Simple Logging Façade for Java (SLF4J)作为其原生接口:
package com.packt.cardatabase;
**import org.slf4j.Logger;**
**import org.slf4j.LoggerFactory;**
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CardatabaseApplication {
**private static** **final Logger logger = LoggerFactory.getLogger(**
**CardatabaseApplication.class**
**);**
public static void main(String[] args) {
SpringApplication.run(CardatabaseApplication.class, args);
**logger.info("Application started");**
}
}
logger.info 方法将日志消息打印到控制台。运行项目后,您可以在控制台中看到日志消息,如下截图所示:

图 1.11:日志消息
日志有七个不同的级别:TRACE、DEBUG、INFO、WARN、ERROR、FATAL和OFF。您可以在 Spring Boot 的 application.properties 文件中配置日志级别。该文件位于项目内部的 /resources 文件夹中,如下截图所示:

图 1.12:应用程序属性文件
如果我们将日志级别设置为 DEBUG,我们就可以看到来自日志级别 DEBUG 或更高(即 DEBUG、INFO、WARN 和 ERROR)的日志消息。在以下示例中,我们设置了根的日志级别,但您也可以在包级别设置它:
logging.level.root=DEBUG
现在,当您运行项目时,您将无法再看到 TRACE 消息。TRACE 级别包含所有应用程序行为细节,除非您需要完全了解应用程序中发生的事情,否则不需要。这可能是一个开发版本应用程序的好设置。如果您没有定义其他任何内容,默认日志级别是 INFO。
在运行 Spring Boot 应用程序时,您可能会遇到一个常见的失败情况。Spring Boot 默认使用 Apache Tomcat (tomcat.apache.org/) 作为应用程序服务器,默认情况下在端口 8080 上运行。您可以在 application.properties 文件中更改端口。以下设置将在端口 8081 上启动 Tomcat:
server.port=8081
如果端口被占用,应用程序将无法启动,您将在控制台中看到以下 APPLICATION FAILED TO START 消息:

图 1.13:端口已被占用
如果发生这种情况,你必须停止监听端口8080的进程或在你 Spring Boot 应用程序中使用另一个端口。你可以在运行应用程序之前点击 Eclipse 控制台中的终止按钮(红色方块)来避免这种情况。
在下一节中,我们将安装一个MariaDB数据库,作为我们后端的数据库使用。
安装 MariaDB
在第三章,使用 JPA 创建和访问数据库中,我们将使用 MariaDB,因此你需要在你的计算机上本地安装它。MariaDB 是一个广泛使用的开源关系型数据库。MariaDB 适用于 Windows、Linux 和 macOS,你可以在mariadb.com/downloads/community/下载最新的稳定社区服务器。MariaDB 是在GNU 通用公共许可证,版本 2 (GPLv2) 许可下开发的。
以下步骤指导你安装 MariaDB:
- 对于 Windows,有Microsoft Installer(MSI),我们将在这里使用它。下载安装程序并执行它。从安装向导中安装所有功能,如下面的截图所示:

图 1.14:MariaDB 安装(步骤 1)
- 在下一步中,你应该为 root 用户设置一个密码。这个密码在下一章我们将应用程序连接到数据库时需要。过程如下面的截图所示:

图 1.15:MariaDB 安装(步骤 2)
- 在下一个阶段,我们可以使用默认设置,如下面的截图所示:

图 1.16:MariaDB 安装(步骤 3)
-
现在,安装将开始,MariaDB 将安装在你的本地计算机上。安装向导将为我们安装HeidiSQL。这是一个易于使用的图形数据库客户端。我们将使用它来添加新的数据库并对我们的数据库进行查询。你也可以使用安装包中包含的命令提示符。
-
打开HeidiSQL,使用安装阶段提供的密码登录。然后你应该会看到以下屏幕:

图 1.17:HeidiSQL
重要提示
HeidiSQL 仅适用于 Windows。如果你使用 Linux 或 macOS,可以使用 DBeaver(dbeaver.io/)代替。
现在我们已经拥有了开始实现后端所需的一切。
摘要
在本章中,我们安装了使用 Spring Boot 进行后端开发所需的工具。对于 Java 开发,我们设置了广泛使用的编程 IDE Eclipse。我们使用 Spring Initializr 页面创建了一个新的 Spring Boot 项目。创建项目后,将其导入 Eclipse 并执行。我们还介绍了如何解决 Spring Boot 的常见问题以及如何查找重要的错误和日志消息。最后,我们安装了一个 MariaDB 数据库,我们将在下一章中使用它。
在下一章中,我们将了解什么是 依赖注入(DI)以及它如何与 Spring Boot 框架一起使用。
问题
-
什么是 Spring Boot?
-
什么是 Eclipse IDE?
-
什么是 Gradle?
-
我们如何创建一个 Spring Boot 项目?
-
我们如何运行 Spring Boot 项目?
-
我们如何使用 Spring Boot 进行日志记录?
-
我们如何在 Eclipse 中查找错误和日志消息?
进一步阅读
Packt 提供了其他关于学习 Spring Boot 的资源,如下所示:
-
《学习 Spring Boot 3.0 第三版》,作者:Greg L. Turnquist (
www.packtpub.com/product/learning-spring-boot-30-third-edition/9781803233307) -
《使用 Spring Boot 3 和 Spring Cloud 的微服务》第三版,作者:Magnus Larsson (
www.packtpub.com/product/microservices-with-spring-boot-3-and-spring-cloud-third-edition/9781805128694)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第二章:理解依赖注入
在本章中,我们将学习什么是依赖注入(DI)以及我们如何使用 Spring Boot 框架来实现它。Spring Boot 框架提供了 DI;因此,了解基础知识是很好的。DI 允许组件之间松散耦合,使代码更加灵活、可维护和可测试。
在本章中,我们将探讨以下内容:
-
介绍依赖注入
-
在 Spring Boot 中使用依赖注入
技术要求
本章的所有代码都可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter02。
介绍依赖注入
依赖注入是一种软件开发技术,我们可以创建依赖于其他对象的对象。DI 有助于类之间的交互,但同时也保持类的独立性。
DI 中有三种类型的类:
-
服务是一个可以使用的类(这是依赖)。
-
客户端是使用依赖的类。
-
注入器将依赖(服务)传递给依赖类(客户端)。
以下图表显示了 DI 中的三种类型类:

图 2.1:DI 类
DI 使类之间松散耦合。这意味着客户端依赖的创建与客户端的行为分离,这使得单元测试更容易。
让我们通过 Java 代码的简化示例来了解一下 DI。在以下代码中,我们没有 DI,因为Car客户端类正在创建服务类的一个对象:
public class Car {
private Owner owner;
public Car() {
owner = new Owner();
}
}
在以下代码中,服务对象不是在客户端类中直接创建的。它作为参数传递给类的构造器:
public class Car {
private Owner owner;
public Car(Owner owner) {
this.owner = owner;
}
}
服务类也可以是抽象类;我们可以在客户端类中使用该类的任何实现,并在测试时使用模拟。
有不同类型的依赖注入;让我们在这里看看两种:
-
构造器注入:依赖通过客户端类的构造器传递。在先前的
Car代码中已经展示了构造器注入的例子。对于强制依赖,建议使用构造器注入。所有依赖都通过类构造器提供,并且没有其必需依赖的对象无法创建。 -
设置器注入:依赖通过设置器提供。以下代码展示了设置器注入的例子:
public class Car { private Owner owner; public void setOwner(Owner owner) { this.owner = owner; } }
在这里,依赖现在作为参数传递给设置器。设置器注入更灵活,因为可以创建没有所有依赖的对象。这种方法允许有可选依赖。
依赖注入减少了代码中的依赖关系,并使代码更具可重用性。它还提高了代码的可测试性。我们现在已经学习了 DI 的基础知识。接下来,我们将探讨 DI 在 Spring Boot 中的应用。
在 Spring Boot 中使用依赖注入
在 Spring 框架中,依赖注入是通过 Spring ApplicationContext实现的。ApplicationContext负责创建和管理对象——bean——及其依赖关系。
Spring Boot 扫描您的应用程序类,并将带有特定注解(@Service、@Repository、@Controller等)的类注册为 Spring bean。然后,可以使用依赖注入将这些 bean 注入。
Spring Boot 支持多种依赖注入机制,其中最常见的是:
-
构造函数注入:依赖通过构造函数注入。这是最推荐的方式,因为它确保在对象创建时所有必需的依赖都可用。一个相当常见的情况是我们需要数据库访问来进行某些操作。在 Spring Boot 中,我们使用仓库类来处理这种情况。在这种情况下,我们可以使用构造函数注入来注入仓库类,并开始使用其方法,如下面的代码示例所示:
// Constructor injection public class Car { private final CarRepository carRepository; public Car(CarRepository carRepository) { this.carRepository = carRepository; } // Fetch all cars from db carRepository.findAll(); }如果您的类中有多个构造函数,您必须使用
@Autowired注解来定义用于依赖注入的构造函数:// Constructor to used for dependency injection @Autowired public Car(CarRepository carRepository) { this.carRepository = carRepository; } -
setter 注入:依赖通过 setter 方法注入。setter 注入在您有可选依赖项或希望在运行时修改依赖项时很有用。下面是 setter 注入的示例:
// Setter injection @Service public class AppUserService { private AppUserRepository userRepository; @Autowired public void setAppUserRepository( AppUserRepository userRepository) { this.userRepository = userRepository; } // Other methods that use userRepository } -
字段注入:依赖直接注入到字段中。字段注入的优点是简单,但它也有一些缺点。如果依赖不可用,它可能会导致运行时错误。此外,测试您的类也更困难,因为您不能为测试模拟依赖。以下是一个示例:
// Field injection @Service public class CarDatabaseService implements CarService { // Car database services } public class CarController { **@Autowired** private CarDatabaseService carDatabaseService; //... }
您可以在 Spring 文档中了解更多关于 Spring Boot 注入的信息:https://spring.io/guides。
摘要
在本章中,我们学习了依赖注入是什么以及如何在 Spring Boot 框架中使用它,这是我们用于后端的部分。
在下一章中,我们将探讨如何使用Java 持久化 API(JPA)与 Spring Boot 以及如何设置 MariaDB 数据库。我们还将学习创建 CRUD 仓库以及数据库表之间的一对多连接。
问题
-
什么是依赖注入?
-
Spring Boot 中的
@Autowired注解是如何工作的? -
您如何在 Spring Boot 中注入资源?
进一步阅读
Packt 提供了一些关于学习 Spring Boot 的视频资源:
-
通过简单方式学习 Spring 核心框架,作者 Karthikeya T. (
www.packtpub.com/product/learn-spring-core-framework-the-easy-way-video/9781801071680) -
精通 Spring 框架基础, 由 Matthew Speake 著 (
www.packtpub.com/product/mastering-spring-framework-fundamentals-video/9781801079525)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第三章:使用 JPA 创建和访问数据库
本章介绍了如何使用 Jakarta Persistence API(JPA)与 Spring Boot 一起工作,以及如何使用实体类定义数据库。在第一阶段,我们将使用 H2 数据库。H2 是一个内存 SQL 数据库,适用于快速开发或演示目的。在第二阶段,我们将从 H2 迁移到 MariaDB。本章还描述了 CRUD 仓库的创建以及数据库表之间的一对多连接。
在本章中,我们将涵盖以下主题:
-
ORM、JPA 和 Hibernate 的基础知识
-
创建实体类
-
创建 CRUD 仓库
-
在表之间添加关系
-
设置 MariaDB 数据库
技术要求
我们在前面章节中创建的 Spring Boot 应用程序是必需的。
创建数据库应用程序需要安装 MariaDB:downloads.mariadb.org/。我们已经在 第一章 中介绍了安装步骤。
本章的代码可以在以下 GitHub 链接中找到:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter03.
ORM、JPA 和 Hibernate 的基础知识
ORM 和 JPA 是软件开发中广泛使用的处理关系数据库的技术。您不必编写复杂的 SQL 查询;相反,您可以与对象一起工作,这对于 Java 开发者来说更自然。通过这种方式,ORM 和 JPA 可以通过减少编写和调试 SQL 代码的时间来加快您的开发过程。许多 JPA 实现还可以根据您的 Java 实体类自动生成数据库模式。简要来说:
-
对象关系映射(ORM)是一种技术,允许您通过使用面向对象编程范式从数据库中检索和操作数据。ORM 对于程序员来说非常好,因为它依赖于面向对象的概念,而不是数据库结构。它还使开发过程更快,并减少了源代码的数量。ORM 主要独立于数据库,开发者不必担心供应商特定的 SQL 语句。
-
Jakarta Persistence API(JPA,以前称为 Java Persistence API)为 Java 开发者提供了对象关系映射。JPA 实体是一个代表数据库表结构的 Java 类。实体类的字段代表数据库表的列。
-
Hibernate 是最流行的基于 Java 的 JPA 实现,并且默认用于 Spring Boot。Hibernate 是一个成熟的产品,在大型应用程序中得到广泛使用。
接下来,我们将开始使用 H2 数据库实现我们的第一个实体类。
创建实体类
实体类是一个简单的 Java 类,它被 JPA 的@Entity注解所标注。实体类使用标准的 JavaBean 命名约定,并具有适当的 getter 和 setter 方法。类字段具有私有可见性。
当应用程序初始化时,JPA 会创建一个与类名相同的数据库表。如果您想为数据库表使用其他名称,您可以在实体类中使用@Table注解。
在本章的开始,我们将使用 H2 数据库(https://www.h2database.com/),这是一个嵌入式的内存数据库。为了能够使用 JPA 和 H2 数据库,我们必须将以下依赖项添加到build.gradle文件中:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
**implementation** **'org.springframework.boot:spring-boot-starter-data-jpa'**
developmentOnly 'org.springframework.boot:spring-boot-devtools'
**runtimeOnly** **'com.h2database:h2'**
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
在您更新了build.gradle文件之后,您应该通过在 Eclipse 的项目资源管理器中选择项目并右键单击以打开上下文菜单来更新您的依赖项。然后,选择Gradle | 刷新 Gradle 项目,如下一截图所示:

图 3.1:刷新 Gradle 项目
您还可以通过打开窗口 | 首选项菜单来启用自动项目刷新。转到Gradle设置,那里有一个自动项目同步复选框,您可以勾选它。然后,如果您更改了构建脚本文件,您的项目将自动同步。这是推荐的,意味着您在更新构建脚本时不需要手动刷新项目:

图 3.2:Gradle 包装器设置
您可以从 Eclipse 的项目资源管理器中的项目和外部依赖文件夹中找到项目依赖项。现在,您应该在那里找到spring-boot-starter-data-jpa和 h2 依赖项:

图 3.3:项目依赖项
让我们看看以下步骤来创建实体类:
-
要在 Spring Boot 中创建实体类,我们必须为实体创建一个包。这个包应该创建在根包下。为了开始这个过程,在 Eclipse 的项目资源管理器中激活根包,并右键单击以出现上下文菜单。
-
从此菜单中选择新建 | 包。以下截图显示了如何为实体类创建一个包:

图 3.4:新包
- 我们将命名我们的包为
com.packt.cardatabase.domain:

图 3.5:新 Java 包
-
接下来,我们将创建我们的实体类。在 Eclipse 的项目资源管理器中激活新的
com.packt.cardatabase.domain包,右键单击它,并从菜单中选择新建 | 类。 -
由于我们将创建一个汽车数据库,实体类的名称将是
Car。在名称字段中输入Car,然后按完成按钮,如以下截图所示:

图 3.6:新 Java 类
-
通过在项目资源管理器中双击它来在编辑器中打开
Car类文件。首先,我们必须使用@Entity注解注解该类。@Entity注解是从jakarta.persistence包导入的:package com.packt.cardatabase.domain; **import** **jakarta.persistence.Entity;** **@Entity** public class Car { }你可以在 Eclipse IDE 中使用Ctrl + Shift + O快捷键自动导入缺失的包。在某些情况下,可能存在包含相同标识符的多个包,因此你必须小心选择正确的导入。例如,在下一步中,
Id可以在多个包中找到,但你应该选择jakarta.persistence.Id。 -
接下来,我们必须在我们的类中添加一些字段。实体类字段映射到数据库表列。实体类还必须包含一个唯一 ID,该 ID 在数据库中用作主键:
package com.packt.cardatabase.domain; import jakarta.persistence.Entity; **import** **jakarta.persistence.GeneratedValue;** **import** **jakarta.persistence.GenerationType;** **import** **jakarta.persistence.Id;** @Entity public class Car { **@Id** **@GeneratedValue(strategy=GenerationType.AUTO)** **private** **Long id;** **private** **String brand, model, color, registrationNumber;** **private****int modelYear, price;** }主键是通过使用
@Id注解定义的。@GeneratedValue注解定义了 ID 将由数据库自动生成。我们还可以定义自己的键生成策略;AUTO类型表示 JPA 提供者会选择特定数据库的最佳策略,这也是默认的生成类型。你可以通过在多个属性上注解@Id来创建一个复合主键。默认情况下,数据库列的命名遵循类字段命名约定。如果你想使用其他命名约定,可以使用
@Column注解。使用@Column注解,你可以定义列的长度以及列是否为nullable。以下代码展示了使用@Column注解的示例。根据此定义,数据库中列的名称为explanation,列的长度为512,且不可为空:@Column(name="explanation", nullable=false, length=512) private String description -
最后,我们必须向实体类添加 getter、setter、默认构造函数和带属性的构造函数。由于自动 ID 生成,我们不需要在构造函数中添加 ID 字段。
Car实体类构造函数的源代码如下:Eclipse 提供了自动添加 getter、setter 和构造函数的功能。将光标置于你想要添加代码的位置,然后右键单击。从菜单中选择源 | 生成 getter 和 setter...或源 | 使用字段生成构造函数...。
// Car.java constructors public Car() { } public Car(String brand, String model, String color, String registrationNumber, int modelYear, int price) { super(); this.brand = brand; this.model = model; this.color = color; this.registrationNumber = registrationNumber; this.modelYear = modelYear; this.price = price; }以下是为
Car实体类编写的 getter 和 setter 的源代码:public Long getId() { return id; } public String getBrand() { return brand; } public void setBrand(String brand) { this.brand = brand; } public String getModel() { return model; } public void setModel(String model) { this.model = model; } // Rest of the setters and getters. See the whole source code from GitHub -
我们还必须在
application.properties文件中添加新的属性。这允许我们将 SQL 语句记录到控制台。我们还需要定义数据源 URL。打开application.properties文件,并在文件中添加以下两行:spring.datasource.url=jdbc:h2:mem:testdb spring.jpa.show-sql=true当你正在编辑
application.properties文件时,你必须确保行尾没有多余的空格。否则,设置将不会生效。这可能会发生在你复制/粘贴设置时。 -
现在,当运行应用程序时,数据库将创建
car表。在此阶段,我们可以在控制台中看到表创建语句:![]()
图 3.7:汽车表 SQL 语句
如果在
application.properties文件中没有定义spring.datasource.url,Spring Boot 将创建一个随机数据源 URL,当您运行应用程序时可以在控制台中看到,例如,H2 控制台在'/h2-console'可用。数据库在'jdbc:h2:mem:b92ad05e-8af4-4c33-b22d-ccbf9ffe491e'可用。 -
H2 数据库提供了一个基于 Web 的控制台,可以用来探索数据库并执行 SQL 语句。要启用控制台,我们必须在
application.properties文件中添加以下行。第一个设置启用了 H2 控制台,而第二个定义了其路径:spring.h2.console.enabled=true spring.h2.console.path=/h2-console -
您可以通过启动应用程序并使用 Web 浏览器导航到
localhost:8080/h2-console来访问 H2 控制台。使用jdbc:h2:mem:testdb作为JDBC URL,并在登录窗口的密码字段中留空。按下连接按钮登录到控制台,如图下所示:

图 3.8:H2 控制台登录
您也可以通过在application.properties文件中使用以下设置来更改 H2 数据库的用户名和密码:spring.datasource.username和spring.datasource.password。
现在,您可以在数据库中看到我们的CAR表。您可能会注意到注册号和型号年份之间有一个下划线。下划线的原因是属性的驼峰命名(registrationNumber):

图 3.9:H2 控制台
现在,我们已经创建了我们的第一个实体类,并学习了 JPA 如何从实体类生成数据库表。接下来,我们将创建一个提供 CRUD 操作的存储库类。
创建 CRUD 存储库
Spring Boot Data JPA 提供了一个CrudRepository接口用于创建、读取、更新和删除(CRUD)操作。它为我们提供了实体类的 CRUD 功能。
让我们在domain包中创建我们的存储库,如下所示:
-
在
com.packt.cardatabase.domain包中创建一个新的接口CarRepository,并根据以下代码片段修改文件:package com.packt.cardatabase.domain; import org.springframework.data.repository.CrudRepository; public interface CarRepository extends CrudRepository<Car,Long> { }CarRepository现在扩展了 Spring Boot JPA 的CrudRepository接口。类型参数<Car, Long>定义了这是一个Car实体类的存储库,并且 ID 字段的类型是Long。CrudRepository接口提供了多个 CRUD 方法,我们现在可以开始使用了。以下表格列出了最常用的方法:方法 描述 long count()返回实体数量 Iterable<T> findAll()返回给定类型的所有项 Optional<T> findById(ID Id)通过 ID 返回一个项 void delete(T entity)删除一个实体 void deleteAll()删除存储库中的所有实体 <S extends T> save(S entity)保存一个实体 List<S> saveAll(Iterable<S> entities)保存多个实体 表 3.1:CRUD 方法
如果方法只返回一个项目,则返回
Optional<T>而不是T。Optional类是在 Java 8 SE 中引入的,它是一种单值容器,要么包含一个值,要么不包含。如果有值,isPresent()方法返回true,并且您可以通过使用get()方法获取它;否则,它返回false。通过使用Optional,我们可以防止 空指针异常。空指针可能导致 Java 程序中出现意外和通常不希望的行为。在添加
CarRepository类后,您的项目结构应该如下所示:![]()
图 3.10:项目结构
-
现在,我们准备向我们的 H2 数据库添加一些演示数据。为此,我们将使用 Spring Boot 的
CommandLineRunner接口。CommandLineRunner接口允许我们在应用程序完全启动之前执行额外的代码。因此,这是一个向数据库添加演示数据的好时机。您的 Spring Boot 应用程序的main类实现了CommandLineRunner接口。因此,我们应该实现run方法,如下面的CardatabaseApplication.java代码所示:package com.packt.cardatabase; **import** **org.springframework.boot.CommandLineRunner;** import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class CardatabaseApplication **implements****CommandLineRunner** **{** public static void main(String[] args) { SpringApplication.run (CardatabaseApplication.class, args); } **@Override** **public** **void** **run****(String... args)** **throws** **Exception {** **// Place your code here** **}** **}** -
接下来,我们必须将我们的汽车存储库注入到主类中,以便能够将新的汽车对象保存到数据库中。我们使用构造函数注入来注入
CarRepository。我们还将向我们的main类添加一个记录器(我们之前在 第一章 中看到了它的代码):package com.packt.cardatabase; **import** **org.slf4j.Logger;** **import** **org.slf4j.LoggerFactory;** import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; **import** **com.packt.cardatabase.domain.Car;** **import** **com.packt.cardatabase.domain.CarRepository;** @SpringBootApplication public class CardatabaseApplication implements CommandLineRunner { **private static final****Logger****logger** **=** **LoggerFactory.getLogger(** **CardatabaseApplication.class** **);** **private final** **CarRepository repository;** **public****CardatabaseApplication****(CarRepository repository) {** **this****.repository = repository;** **}** public static void main(String[] args) { SpringApplication.run (CardatabaseApplication.class, args); } @Override public void run(String... args) throws Exception { // Place your code here } } -
一旦我们注入了存储库类,我们就可以在
run方法中使用它提供的 CRUD 方法。以下示例代码展示了如何使用save方法将几辆汽车插入到数据库中。我们还将使用存储库的findAll()方法从数据库中检索所有汽车,并使用记录器将它们打印到控制台:// CardataseApplication.java run method @Override public void run(String... args) throws Exception { **repository.save(****new****Car****(****"Ford"****,** **"Mustang"****,** **"Red"****,** **"ADF-1121"****,** **2023****,** **59000****));** **repository.save(****new****Car****(****"Nissan"****,** **"Leaf"****,** **"White"****,** **"SSJ-3002"****,** **2020****,** **29000****));** **repository.save(****new****Car****(****"Toyota"****,** **"Prius"****,** **"Silver"****,** **"KKO-0212"****,** **2022****,** **39000****));** **// Fetch all cars and log to console** **for (Car car : repository.findAll()) {** **logger.info(****"****brand: {}, model:****{}"****,** **car.getBrand(), car.getModel());** **}** }
一旦应用程序执行完毕,您可以在 Eclipse 控制台中看到 insert 语句和记录的汽车:

图 3.11:插入语句
现在,您可以使用 H2 控制台从数据库中检索汽车,如下面的屏幕截图所示:

图 3.12:H2 控制台:选择汽车
您可以在 Spring Data 存储库中定义查询。查询必须以一个前缀开始,例如,findBy。在前面缀之后,您必须定义在查询中使用的实体类字段。以下是一些三个简单查询的示例代码:
package com.packt.cardatabase.domain;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository <Car, Long> {
**// Fetch cars by brand**
**List<Car>** **findByBrand****(String brand);**
**// Fetch cars by color**
**List<Car>** **findByColor****(String color);**
**// Fetch cars by model year**
**List<Car>** **findByModelYear****(int modelYear);**
}
在 By 关键字之后可以有多个字段,通过 And 和 Or 关键字连接:
package com.packt.cardatabase.domain;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository <Car, Long> {
**// Fetch cars by brand and model**
**List<Car>** **findByBrandAndModel****(String brand, String model);**
**// Fetch cars by brand or color**
**List<Car>** **findByBrandOrColor****(String brand, String color);**
}
可以通过在查询方法中使用 OrderBy 关键字来对查询进行排序:
package com.packt.cardatabase.domain;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository <Car, Long> {
**// Fetch cars by brand and sort by year**
**List<Car>** **findByBrandOrderByModelYearAsc****(String brand);**
}
您也可以通过使用 @Query 注解来通过 SQL 语句创建查询。以下示例展示了在 CrudRepository 中使用 SQL 查询的用法:
package com.packt.cardatabase.domain;
import java.util.List;
**import** **org.springframework.data.jpa.repository.Query;**
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository <Car, Long> {
**// Fetch cars by brand using SQL**
**@Query("select c from Car c where c.brand = ?1")**
**List<Car>** **findByBrand****(String brand);**
}
使用@Query注解,你可以使用更高级的表达式,例如like。以下示例展示了在CrudRepository中使用like查询的用法:
package com.packt.cardatabase.domain;
import java.util.List;
**import** **org.springframework.data.jpa.repository.Query;**
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository <Car, Long> {
**// Fetch cars by brand using SQL**
**@Query("select c from Car c where c.brand like %?1")**
List<Car> findByBrandEndsWith(String brand);
}
如果你使用@Query注解并在代码中编写 SQL 查询,你的应用程序可能在不同数据库系统之间的可移植性会降低。
Spring Data JPA 还提供了PagingAndSortingRepository,它扩展了CrudRepository。这提供了使用分页和排序获取实体的方法。如果你处理大量数据,这是一个很好的选择,因为你不需要从大型结果集中返回所有内容。你还可以将数据排序成有意义的顺序。PagingAndSortingRepository可以以与创建CrudRepository类似的方式创建:
package com.packt.cardatabase.domain;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface CarRepository extends
PagingAndSortingRepository <Car, Long> {
}
在这种情况下,你现在有了仓库提供的两个新方法:
| 方法 | 描述 |
|---|---|
Iterable<T> findAll(Sort sort) |
根据给定的选项返回所有已排序的实体 |
Page<T> findAll(Pageable pageable) |
根据给定的分页选项返回所有实体 |
表 3.2:PagingAndSortingRepository 方法
到目前为止,我们已经完成了第一个数据库表的创建,我们现在准备在数据库表之间添加关系。
添加表之间的关系
我们将创建一个新的表名为owner,它与car表有一个一对多关系。在这种情况下,一对多关系意味着所有者可以拥有多辆车,但一辆车只能有一个所有者。
以下统一建模语言(UML)图显示了表之间的关系:

图 3.13:一对多关系
创建新表的步骤如下:
-
首先,我们必须在
com.packt.cardatabase.domain包中创建Owner实体和仓库类。Owner实体和仓库的创建方式与Car类类似。以下为
Owner实体类的源代码:// Owner.java package com.packt.cardatabase.domain; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class Owner { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long ownerid; private String firstname, lastname; public Owner() { } public Owner(String firstname, String lastname) { super(); this.firstname = firstname; this.lastname = lastname; } public Long getOwnerid() { return ownerid; } public String getFirstname() { return firstname; } public void setFirstname(String firstname) { this.firstname = firstname; } public String getLastname() { return lastname; } public void setLastname(String lastname) { this.lastname = lastname; } }以下为
OwnerRepository的源代码:// OwnerRepository.java package com.packt.cardatabase.domain; import org.springframework.data.repository.CrudRepository; public interface OwnerRepository extends CrudRepository<Owner, Long> { } -
现在,我们应该检查一切是否正常工作。运行项目并检查是否已创建两个数据库表,并且控制台没有错误。以下截图显示了创建表时的控制台消息!
图 3.14:汽车和所有者表
现在,我们的领域包包含两个实体类和仓库:
![]()
图 3.15:项目资源管理器
-
一对多关系可以通过使用
@ManyToOne和@OneToMany注解(jakarta.persistence)来添加。在包含外键的汽车实体类中,你必须使用@ManyToOne注解来定义关系。你还应该为所有者字段添加 getter 和 setter。建议你为所有关联使用FetchType.LAZY。对于toMany关系,这是默认值,但对于toOne关系,你应该定义它。FetchType定义了从数据库获取数据的策略。值可以是EAGER或LAZY。在我们的情况下,LAZY策略意味着当所有者从数据库中检索时,与其关联的汽车将在需要时被检索。EAGER意味着汽车将立即由所有者检索。以下源代码显示了如何在Car类中定义一对多关系:// Car.java @ManyToOne(fetch=FetchType.LAZY) @JoinColumn(name="owner") private Owner owner; // Getter and setter public Owner getOwner() { return owner; } public void setOwner(Owner owner) { this.owner = owner; } -
在所有者实体上,关系是通过
@OneToMany注解定义的。字段的类型是List<Car>,因为一个所有者可能有多个汽车。添加相应的 getter 和 setter,如下所示:// Owner.java @OneToMany(cascade=CascadeType.ALL, mappedBy="owner") private List<Car> cars; public List<Car> getCars() { return cars; } public void setCars(List<Car> cars) { this.cars = cars; }@OneToMany注解有两个我们正在使用的属性。cascade属性定义了在删除或更新时级联如何影响实体。ALL属性设置意味着所有操作都会级联。例如,如果删除所有者,与该所有者关联的汽车也会被删除。mappedBy="owner"属性设置告诉我们Car类有一个owner字段,这是此关系的键。当你运行项目时,通过查看控制台,你会看到已经创建了关系:
![]()
图 3.16:控制台
-
现在,我们可以使用
CommandLineRunner向数据库添加一些所有者。让我们也修改Car实体类构造函数,并在其中添加一个owner对象:// Car.java constructor public Car(String brand, String model, String color, String registrationNumber, int modelYear, int price, **Owner owner**) { super(); this.brand = brand; this.model = model; this.color = color; this.registrationNumber = registrationNumber; this.modelYear = modelYear; this.price = price; **this****.owner = owner;** } -
首先,我们将创建两个所有者对象,并使用仓库的
saveAll方法将它们保存到数据库中,我们可以使用该方法一次性保存多个实体。为了保存所有者,我们必须将OwnerRepository注入到主类中。然后,我们必须使用Car构造函数将所有者与汽车连接起来。首先,让我们通过添加以下导入来修改CardatabaseApplication类:// CardatabaseApplication.java **import** **com.packt.cardatabase.domain.Owner;** **import** **com.packt.cardatabase.domain.OwnerRepository;** -
现在,让我们也使用构造函数注入将
OwnerRepository注入到CardatabaseApplication类中:private final CarRepository repository; **private** **final** **OwnerRepository orepository;** public CardatabaseApplication(CarRepository repository, **OwnerRepository orepository)** { this.repository = repository; **this****.orepository = orepository;** } -
在这一点上,我们必须修改
run方法以保存所有者和将所有者与汽车关联:@Override public void run(String... args) throws Exception { **// Add owner objects and save these to db** **Owner owner1** **=** **new** **Owner****(****"John"** **,** **"Johnson"****);** **Owner owner2** **=** **new** **Owner****(****"Mary"** **,** **"Robinson"****);** **orepository.saveAll(Arrays.asList(owner1, owner2));** repository.save(new Car("Ford", "Mustang", "Red", "ADF-1121", 2023, 59000**, owner1**)); repository.save(new Car("Nissan", "Leaf", "White", "SSJ-3002", 2020, 29000**, owner2**)); repository.save(new Car("Toyota", "Prius", "Silver", "KKO-0212", 2022, 39000**, owner2**)); // Fetch all cars and log to console for (Car car : repository.findAll()) { logger.info("brand: {}, model: {}", car.getBrand(), car.getModel()); } } -
现在,如果你运行应用程序并从数据库中检索汽车,你会看到所有者现在与汽车关联了:

图 3.17:OneToMany 关系
如果你想要创建一个多对多关系,这意味着在实践中,一个所有者可以有多个汽车,一辆汽车可以有多个所有者,你应该使用@ManyToMany注解。在我们的示例应用程序中,我们将使用一对多关系。你在这里完成的代码将在下一章中需要。
接下来,你将学习如何将关系更改为多对多。在多对多关系中,建议你使用Set而不是List与 Hibernate 一起使用:
-
在
Car实体类的多对多关系中,定义 getter 和 setter 的方式如下:// Car.java @ManyToMany(mappedBy="cars") private Set<Owner> owners = new HashSet<Owner>(); public Set<Owner> getOwners() { return owners; } public void setOwners(Set<Owner> owners) { this.owners = owners; } -
在
Owner实体类中,多对多关系定义如下:// Owner.java @ManyToMany(cascade=CascadeType.PERSIST) @JoinTable(name="car_owner",joinColumns = { @JoinColumn(name="ownerid") }, inverseJoinColumns = { @JoinColumn(name="id") } ) private Set<Car> cars = new HashSet<Car>(); public Set<Car> getCars() { return cars; } public void setCars(Set<Car> cars) { this.cars = cars; } -
现在,如果你运行应用程序,将在汽车和所有者表之间创建一个新的连接表,称为
car_owner。连接表是一种特殊的表,用于管理两个表之间的多对多关系。连接表是通过使用
@JoinTable注解定义的。使用这个注解,我们可以设置连接表和连接列的名称。以下截图显示了使用多对多关系时的数据库结构:![图片]()
图 3.18:多对多关系
现在,数据库 UML 图如下所示:
![图片]()
图 3.19:多对多关系
到目前为止,我们在章节中使用了内存中的 H2 数据库。在下一节中,我们将使用一对多关系,所以如果你遵循了之前的多对多示例,请将你的代码改回一对多关系。
接下来,我们将探讨如何使用 MariaDB 数据库。
设置 MariaDB 数据库
现在,我们将把正在使用的数据库从 H2 切换到 MariaDB。H2 是一个用于测试和演示的好数据库,但 MariaDB 在应用程序需要性能、可靠性和可伸缩性时,是一个更好的生产数据库选项。
在这本书中,我们使用的是 MariaDB 版本 10。数据库表仍然由 JPA 自动创建。然而,在我们运行应用程序之前,我们必须为它创建一个数据库。
在本节中,我们将使用上一节中的一对多关系。
数据库可以使用 HeidiSQL(或如果你使用 Linux 或 macOS,则使用 DBeaver)创建。打开 HeidiSQL 并按照以下步骤操作:
-
激活顶部的数据库连接名称(未命名)并右键单击。
-
然后,选择创建新 | 数据库:

图 3.20:创建新数据库
- 让我们将我们的数据库命名为
cardb。点击确定后,你应该在数据库列表中看到新的cardb数据库:

图 3.21:cardb 数据库
-
在 Spring Boot 中,将 MariaDB Java 客户端依赖项添加到
build.gradle文件中,并删除 H2 依赖项,因为我们不再需要它了。记得在修改了build.gradle文件后刷新你的 Gradle 项目:dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' developmentOnly 'org.springframework.boot:spring-boot-devtools' **runtimeOnly** **'org.mariadb.jdbc:mariadb-java-client'** testImplementation 'org.springframework.boot:spring-boot-starter-test' } -
在
application.properties文件中,你必须定义 MariaDB 的数据库连接。在这个阶段,你应该删除旧的 H2 数据库设置。首先,你必须定义数据库的 URL、用户名、密码(在 第一章 中定义)和数据库驱动类:spring.datasource.url=jdbc:mariadb://localhost:3306/cardb spring.datasource.username=root spring.datasource.password=YOUR_PASSWORD spring.datasource.driver-class-name=org.mariadb.jdbc.Driver在这个例子中,我们使用的是数据库 root 用户,但在生产环境中,你应该为你的数据库创建一个没有所有 root 数据库权限的用户。
-
添加
spring.jpa.generate-ddl设置,它定义了 JPA 是否应该初始化数据库(true/false)。还要添加spring.jpa.hibernate.ddl-auto设置,它定义了数据库初始化的行为:spring.datasource.url=jdbc:mariadb://localhost:3306/cardb spring.datasource.username=root spring.datasource.password=YOUR_PASSWORD spring.datasource.driver-class-name=org.mariadb.jdbc.Driver **spring.jpa.generate-ddl****=****true** **spring.jpa.hibernate.ddl-auto****=****create-drop**spring.jpa.hibernate.ddl-auto的可能值有none、validate、update、create和create-drop。默认值取决于你的数据库。如果你使用的是嵌入式数据库,如 H2,则默认值为create-drop;否则,默认值为none。create-drop表示数据库在应用程序启动时创建,在应用程序停止时删除。create值仅在应用程序启动时创建数据库。update值在创建数据库的同时,如果模式已更改,则更新模式。 -
检查 MariaDB 数据库服务器是否正在运行,并重新启动你的 Spring Boot 应用程序。运行应用程序后,你应该能在 MariaDB 中看到表格。你可能需要先通过按 F5 键刷新 HeidiSQL 的数据库树。以下截图显示了创建数据库后的 HeidiSQL 用户界面:

图 3.22:MariaDB cardb 你也可以在 HeidiSQL 中运行 SQL 查询。
现在,你的应用程序已经准备好与 MariaDB 一起使用了。
摘要
在本章中,我们使用 JPA 创建了我们的 Spring Boot 应用程序数据库。首先,我们创建了实体类,它们映射到数据库表。
然后,我们为我们的实体类创建了一个 CrudRepository,它为实体提供了 CRUD 操作。之后,我们通过使用 CommandLineRunner 成功地向我们的数据库添加了一些示例数据。我们还创建了两个实体之间的一对多关系。在本章的开始,我们使用了 H2 内存数据库,并在本章的结尾将数据库切换到了 MariaDB。
在下一章中,我们将为我们的后端创建一个 RESTful Web 服务。我们还将探讨使用 cURL 命令行工具和 Postman 图形用户界面测试 RESTful Web 服务。
问题
-
ORM、JPA 和 Hibernate 是什么?
-
你如何创建一个实体类?
-
你如何创建一个
CrudRepository? -
CrudRepository为你的应用程序提供了什么? -
你如何在一对多关系之间创建表?
-
你如何使用 Spring Boot 向数据库添加示例数据?
-
你如何访问 H2 控制台?
-
你如何将你的 Spring Boot 应用程序连接到 MariaDB?
进一步阅读
Packt 提供了其他关于 MariaDB、Hibernate 和 JPA 的学习资源:
-
《从入门到精通 MariaDB》,作者 Daniel Bartholomew (
www.packtpub.com/product/getting-started-with-mariadb/9781785284120) -
《100 步掌握 Spring Boot 中的 Hibernate 和 JPA [视频]》,作者 In28Minutes Official (
www.packtpub.com/product/master-hibernate-and-jpa-with-spring-boot-in-100-steps-video/9781788995320)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第四章:使用 Spring Boot 创建 RESTful Web 服务
互联网上使用 HTTP 协议进行通信的应用程序称为 Web 服务。存在许多不同类型的 Web 服务架构,但所有设计中的主要思想是相同的。在这本书中,我们将创建一个 RESTful Web 服务:如今,这是一种非常流行的设计。
在本章中,我们将首先使用控制器类创建一个 RESTful Web 服务。然后,我们将使用 Spring Data REST 创建一个提供所有 CRUD 功能的 RESTful Web 服务,并使用 OpenAPI 3 进行文档记录。在为您的应用程序创建 RESTful API 之后,您可以使用 JavaScript 库(如 React)实现前端。我们将使用前一章中创建的数据库应用程序作为起点。
在本章中,我们将涵盖以下主题:
-
REST 基础
-
使用 Spring Boot 创建 RESTful Web 服务
-
使用 Spring Data REST
-
记录 RESTful API
技术要求
需要前几章中创建的 Spring Boot 应用程序。
你还需要 Postman、cURL 或其他合适的工具,用于使用各种 HTTP 方法传输数据。
以下 GitHub 链接将需要:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter04。
REST 基础
表征状态转移(REST)是创建 Web 服务的架构风格。REST 既不依赖于语言也不依赖于平台;不同的客户端,如移动应用、浏览器和其他服务,可以相互通信。RESTful 服务可以轻松扩展以满足增加的需求。
REST 不是一个标准,而是一组由 Roy Fielding 定义的约束。约束如下:
-
无状态:服务器不应保留任何有关客户端状态的信息。
-
客户端-服务器独立性:客户端和服务器应独立行动。服务器不应在没有客户端请求的情况下发送任何信息。
-
可缓存:许多客户端经常请求相同的资源;因此,为了提高性能,应该对资源进行缓存。
-
统一接口:来自不同客户端的请求应该看起来相同。客户端可能包括,例如,浏览器、Java 应用程序和移动应用程序。
-
分层系统:组件可以添加或修改,而不会影响整个服务。这个限制影响了可扩展性。
-
代码按需:这是一个可选约束。大多数情况下,服务器以 JSON 或 XML 的形式发送静态内容。这个约束允许服务器在需要时发送可执行代码。
统一接口约束非常重要,这意味着每个 REST 架构都应该具有以下元素:
-
资源的标识:资源应由唯一的标识符进行标识,例如,基于 Web 的 REST 服务中的 URI。REST 资源应公开易于理解的目录结构 URI。因此,一个良好的资源命名策略非常重要。
-
通过表示进行资源操作:在向资源发出请求时,服务器应以资源的表示形式进行响应。通常,表示的格式是 JSON 或 XML。
-
自描述的消息:消息应包含足够的信息,以便服务器知道如何处理它们。
-
超媒体作为应用程序状态引擎(HATEOAS):响应应包含指向服务其他区域的链接。
我们将在下一节中开发的 RESTful 网络服务遵循上述 REST 架构原则。
使用 Spring Boot 创建 RESTful 网络服务
在 Spring Boot 中,所有 HTTP 请求都由 控制器类 处理。为了能够创建 RESTful 网络服务,首先,我们必须创建一个控制器类。我们将为控制器创建自己的 Java 包:
- 在 Eclipse 项目资源管理器中激活根包,然后右键单击。从菜单中选择 新建 | 包。我们将命名我们的新包为
com.packt.cardatabase.web:

图 4.1:新的 Java 包
- 接下来,我们将在新的 Web 包中创建一个新的
controller类。在 Eclipse 项目资源管理器中激活com.packt.cardatabase.web包。右键单击并从菜单中选择 新建 | 类;我们将命名我们的类为CarController:

图 4.2:新的 Java 类
-
现在,你的项目结构应该看起来像以下截图所示:
![]()
图 4.3:项目结构
如果你意外地创建了错误包中的类,你可以在项目资源管理器中拖放文件在包之间。有时,当你进行一些更改时,项目资源管理器视图可能无法正确渲染。刷新项目资源管理器有助于(激活项目资源管理器并按 F5)。
-
在编辑器窗口中打开你的控制器类,并在类定义之前添加
@RestController注解。参考以下源代码。@RestController注解标识了该类将是 RESTful 网络服务的控制器:package com.packt.cardatabase.web; **import** **org.springframework.web.bind.annotation.RestController;** **@RestController** public class CarController { } -
接下来,我们在控制器类内部添加一个新的方法。该方法使用
@GetMapping注解进行标注,该注解定义了方法映射到的端点。在下面的代码片段中,你可以看到示例源代码。在这个例子中,当用户向/cars端点发送GET请求时,getCars()方法将被执行:package com.packt.cardatabase.web; **import** **org.springframework.web.bind.annotation.GetMapping;** import org.springframework.web.bind.annotation.RestController; **import** **com.packt.cardatabase.domain.Car;** @RestController public class CarController { **@GetMapping("/cars")** **public** **Iterable<Car>** **getCars****() {** **//Fetch and return cars** **}** }getCars()方法返回所有汽车对象,然后由 Jackson 库(https://github.com/FasterXML/jackson)自动将它们序列化为 JSON 对象。现在,
getCars()方法仅处理来自/cars端点的GET请求,因为我们使用了@GetMapping注解。还有其他注解用于不同的 HTTP 方法,例如@GetMapping、@PostMapping、@DeleteMapping等等。 -
要能够从数据库中返回汽车,我们必须将
CarRepository注入到控制器中。然后,我们可以使用仓库提供的findAll()方法来获取所有汽车。由于@RestController注解,数据现在在响应中序列化为 JSON 格式。以下源代码显示了控制器代码:package com.packt.cardatabase.web; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import com.packt.cardatabase.domain.Car; **import** **com.packt.cardatabase.domain.CarRepository;** @RestController public class CarController { **private** **final** **CarRepository repository;** **public****CarController****(CarRepository repository) {** **this****.repository = repository;** **}** @GetMapping("/cars") public Iterable<Car> getCars() { **return** **repository.findAll();** } } -
现在,我们已准备好运行我们的应用程序并导航到
localhost:8080/cars。我们可以看到有些不对劲,应用程序似乎陷入了一个无限循环。这是由于我们的汽车和车主表之间的多对一关系。那么,实际情况是怎样的呢?首先,汽车被序列化,它包含一个车主,然后车主被序列化,反过来,车主又包含汽车,然后这些汽车被序列化,以此类推。有几种不同的解决方案可以避免这种情况。一种方法是在Owner类的cars字段上使用@JsonIgnore注解,在序列化过程中忽略cars字段。如果您不需要双向映射,也可以通过避免双向映射来解决这个问题。我们还将使用@JsonIgnoreProperties注解来忽略由 Hibernate 生成的字段:// Owner.java **import** **com.fasterxml.jackson.annotation.JsonIgnore;** **import** **com.fasterxml.jackson.annotation.JsonIgnoreProperties;** @Entity **@JsonIgnoreProperties({"hibernateLazyInitializer","handler"})** public class Owner { @Id @GeneratedValue(strategy=GenerationType.AUTO) private long ownerid; private String firstname, lastname; public Owner() {} public Owner(String firstname, String lastname) { super(); this.firstname = firstname; this.lastname = lastname; } **@JsonIgnore** @OneToMany(cascade=CascadeType.ALL, mappedBy="owner") private List<Car> cars; -
现在,当您运行应用程序并导航到
localhost:8080/cars时,一切应该如预期进行,并且您将以 JSON 格式从数据库中获取所有汽车,如下面的截图所示:

图 4.4:对 http://localhost:8080/cars 的 GET 请求
您的输出可能与截图不同,因为浏览器之间的差异。在这本书中,我们使用的是 Chrome 浏览器和JSON Viewer扩展程序,这使得 JSON 输出更加易于阅读。JSON Viewer 可以从 Chrome Web Store 免费下载。
我们已经编写了我们的第一个 RESTful Web 服务。通过利用 Spring Boot 的能力,我们能够快速实现一个返回我们数据库中所有汽车的服务。然而,这仅仅是 Spring Boot 为创建健壮和高效的 RESTful Web 服务所能提供的功能的一部分,我们将在下一节继续探索其功能。
使用 Spring Data REST
Spring Data REST(https://spring.io/projects/spring-data-rest)是 Spring Data 项目的一部分。它提供了一种简单快捷的方式来实现使用 Spring 的 RESTful Web 服务。Spring Data REST 提供了HATEOAS(Hypermedia as the Engine of Application State)支持,这是一种架构原则,允许客户端使用超媒体链接动态地导航 REST API。Spring Data REST 还提供了事件,您可以使用这些事件来自定义 REST API 端点的业务逻辑。
您可以在 Spring Data REST 文档中了解更多关于事件的信息:https://docs.spring.io/spring-data/rest/docs/current/reference/html/#events。
要开始使用 Spring Data REST,您必须将以下依赖项添加到build.gradle文件中:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
**implementation** **'org.springframework.boot:spring-boot-starter-data-rest'**
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
在修改了build.gradle文件后,从 Eclipse 中刷新您的 Gradle 项目。在 Eclipse 的项目资源管理器中选择项目,然后右键单击以打开上下文菜单。然后,选择Gradle | 刷新 Gradle 项目。
默认情况下,Spring Data REST 从应用程序中查找所有公共仓库,并自动为您的实体创建 RESTful Web 服务。在我们的例子中,我们有两个仓库:CarRepository和OwnerRepository;因此,Spring Data REST 自动为这些仓库创建 RESTful Web 服务。
您可以在application.properties文件中定义服务的端点,如下所示。您可能需要重新启动应用程序以使更改生效:
spring.data.rest.basePath=/api
现在,您可以从localhost:8080/api端点访问 RESTful Web 服务。通过调用服务的根端点,它返回可用的资源。Spring Data REST 以超文本应用语言(HAL)格式返回 JSON 数据。HAL 格式提供了一套在 JSON 中表示超链接的约定,这使得您的 RESTful Web 服务对前端开发者来说更容易使用:

图 4.5:Spring Boot Data REST 资源
我们可以看到有链接到汽车和所有者实体服务。Spring Data REST 服务路径名称是从实体类名称派生出来的。名称将被复数化并转换为小写。例如,实体Car服务路径名称将变为cars。profile链接是由 Spring Data REST 生成的,并包含特定于应用程序的元数据。如果您想使用不同的路径命名,您可以在您的仓库类中使用@RepositoryRestResource注解,如下一个示例所示:
package com.packt.cardatabase.domain;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
**@RepositoryRestResource(path="vehicles")**
public interface CarRepository extends CrudRepository<Car, Long> {
}
现在,如果您调用端点localhost:8080/api,您可以看到端点已从/cars更改为/vehicles。

图 4.6:Spring Boot Data REST 资源
您可以删除不同的命名,我们将继续使用默认的端点名称,/cars。
现在,我们将开始更仔细地检查不同的服务。有多种工具可用于测试和消费 RESTful Web 服务。在这本书中,我们使用的是Postman(https://www.postman.com/downloads/)桌面应用程序,但您可以使用您熟悉的工具,例如cURL。Postman 可以作为桌面应用程序或浏览器插件获取。cURL 也可以通过使用 Windows Ubuntu Bash(Windows 子系统(WSL))在 Windows 上获得。
如果您使用GET方法(注意:您可以使用网络浏览器进行GET请求)向/cars端点(http://localhost:8080/api/cars)发出请求,您将获得所有汽车列表,如下面的截图所示:

图 4.7:获取汽车
在 JSON 响应中,你可以看到有一个汽车数组,并且每辆汽车都包含特定的汽车数据。所有的汽车都有_links属性,这是一个链接集合,通过这些链接,你可以访问汽车本身或获取汽车的所有者。要访问特定的汽车,路径将是http://localhost:8080/api/cars/{id}。
向http://localhost:8080/api/cars/3/owner发出的GET请求返回了 ID 为 3 的汽车的所有者。响应现在包含所有者数据、所有者的链接以及所有者其他汽车的链接。
Spring Data REST 服务提供了所有 CRUD 操作。以下表格显示了你可以用于不同 CRUD 操作的不同 HTTP 方法:
| HTTP 方法 | CRUD |
|---|---|
GET |
读取 |
POST |
创建 |
PUT/PATCH |
更新 |
DELETE |
删除 |
表 4.1:Spring Data REST 操作
接下来,我们将探讨如何通过我们的 RESTful 网络服务从数据库中删除一辆汽车。在删除操作中,你必须使用DELETE方法和将要删除的汽车的链接(http://localhost:8080/api/cars/{id})。
以下截图显示了如何使用 Postman 桌面应用程序通过id为 3 删除一辆汽车。在 Postman 中,你必须从下拉列表中选择正确的 HTTP 方法,输入请求 URL,然后点击Send按钮:

图 4.8:删除汽车的 DELETE 请求
如果一切顺利,你将在 Postman 中看到响应状态200 OK。在成功的DELETE请求之后,如果你向http://localhost:8080/api/cars/端点发出GET请求,你将看到数据库中现在还剩下两辆汽车。如果你在DELETE响应中得到了404 Not Found状态,请检查你使用的是数据库中存在的汽车 ID。
当我们想要将一辆新车添加到数据库中时,我们必须使用POST方法,请求 URL 是http://localhost:8080/api/cars。头部必须包含带有值application/json的Content-Type字段,新的汽车对象将以 JSON 格式嵌入到请求体中。
这里有一个汽车示例:
{
"brand":"Toyota",
"model":"Corolla",
"color":"silver",
"registrationNumber":"BBA-3122",
"modelYear":2023,
"price":38000
}
如果你点击Body标签并从 Postman 中选择raw,你可以在Body标签下输入一个新的汽车 JSON 字符串。同时,从下拉列表中选择 JSON,如下面的截图所示:

图 4.9:添加新汽车的 POST 请求
你还必须在 Postman 中点击Headers标签来设置一个头部,如下面的截图所示。Postman 根据你的请求选择自动添加一些头部。请确保Content-Type头部在列表中,并且值是正确的(application/json)。如果它不存在,你应该手动添加它。自动添加的头部默认可能被隐藏,但你可以通过点击hidden按钮来查看这些头部。最后,你可以按下Send按钮:

图 4.10:POST 请求头部
如果一切顺利,响应将发送一个新创建的car对象,并且响应状态将是201 已创建。现在,如果你再次向http://localhost:8080/api/cars路径发出GET请求,你将看到新汽车存在于数据库中。
要更新实体,我们可以使用PATCH方法和我们想要更新的汽车的链接(http://localhost:8080/api/cars/{id})。头部必须包含带有值application/json的Content-Type字段,并且编辑后的car对象将包含在请求体中。
如果你使用PATCH,你必须只发送更新的字段。如果你使用PUT,你必须包含请求体中的所有字段。
让我们编辑在前一个示例中创建的汽车,将其颜色改为白色。我们使用PATCH,因此有效负载中只包含color属性:
{
"color": "white"
}
Postman 请求如下截图所示(注意:我们设置了与POST示例相同的头部,并在 URL 中使用汽车id):

图 4.11:更新现有汽车的 PATCH 请求
如果更新成功,响应状态是200 OK。现在,如果你使用GET请求获取更新的汽车,你会看到颜色已经更新。
接下来,我们将为新创建的汽车添加一个车主。我们可以使用PUT方法和http://localhost:8080/api/cars/{id}/owner路径。在这个例子中,新汽车的 ID 是4,所以链接是http://localhost:8080/api/cars/4/owner。现在请求体的内容链接到一个车主,例如,http://localhost:8080/api/owners/1。

图 4.12:更新所有者的 PUT 请求
在这种情况下,头部的Content-Type值应该是text/uri-list。如果你不能修改自动添加的头部,你可以通过取消选中它来禁用它。然后,添加一个新的,如下一图所示,并按发送按钮:

图 4.13:更新现有汽车的 PATCH 请求头部
最后,你可以为汽车发出GET请求车主,现在你应该看到车主已经与汽车链接。
在上一章中,我们为我们的存储库创建了查询。这些查询也可以包含在我们的服务中。要包含查询,你必须将@RepositoryRestResource注解添加到存储库类中。查询参数用@Param注解。以下源代码显示了带有这些注解的CarRepository:
package com.packt.cardatabase.domain;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.data.rest.core.annotation RepositoryRestResource;
**@RepositoryRestResource**
public interface CarRepository extends CrudRepository<Car, Long> {
// Fetch cars by brand
**List<Car>** **findByBrand****(****@Param("brand")** **String brand);**
// Fetch cars by color
**List<Car>** **findByColor****(****@Param("color")** **String color);**
}
现在,当你向http://localhost:8080/api/cars路径发出GET请求时,你可以看到有一个新的端点叫做/search。调用http://localhost:8080/api/cars/search路径将返回以下响应:

图 4.14:REST 查询
从响应中,你可以看到现在我们的服务中都有了这两个查询。以下 URL 演示了如何通过品牌获取汽车:http://localhost:8080/api/cars/search/findByBrand?brand=Ford。输出将只包含品牌为 Ford 的汽车。
在本章的开头,我们介绍了 REST 原则,我们可以看到我们的 RESTful API 满足了 REST 规范的一些方面。它是无状态的,来自不同客户端的请求看起来相同(统一的接口)。响应包含可以用来在相关资源之间导航的链接。我们的 RESTful API 提供了一个反映数据模型和资源之间关系的 URI 结构。
我们现在已经为我们的后端创建了 RESTful API,我们将在稍后用我们的 React 前端来消费它。
记录 RESTful API
一个 RESTful API 应该得到适当的文档,以便使用它的开发者能够理解其功能和行为。文档应包括可用的端点、接受的数据格式以及如何与 API 交互。
在这本书中,我们将使用 Spring Boot 的 OpenAPI 3 库(https://springdoc.org)来自动生成文档。OpenAPI 规范(以前称为 Swagger 规范)是 RESTful API 的 API 描述格式。还有其他替代方案,例如 RAML(https://raml.org/),也可以使用。你还可以使用一些其他文档工具来记录你的 REST API,这些工具提供了灵活性,但需要更多手动工作。使用 OpenAPI 库可以自动化这项工作,让你能专注于开发。
以下步骤演示了如何为你的 RESTful API 生成文档:
-
首先,我们必须将 OpenAPI 库添加到我们的 Spring Boot 应用程序中。将以下依赖项添加到你的
build.gradle文件中:implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.0.2' -
接下来,我们为我们的文档创建一个配置类。在你的应用程序的
com.packt.cardatabase包中创建一个名为OpenApiConfig的新类。以下是为配置类编写的代码,我们可以配置,例如,REST API 的标题、描述和版本。我们可以使用info()方法来定义这些值:package com.packt.cardatabase; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @Configuration public class OpenApiConfig { @Bean public OpenAPI carDatabaseOpenAPI() { return new OpenAPI() .info(new Info() .title("Car REST API") .description("My car stock") .version("1.0")); } } -
在
application.properties文件中,我们可以定义我们文档的路径。我们还可以启用 Swagger UI,这是一个用于可视化使用 OpenAPI 规范(https://swagger.io/tools/swagger-ui/)编写的 RESTful API 的用户友好工具。将以下设置添加到你的application.properties文件中:springdoc.api-docs.path=/api-docs springdoc.swagger-ui.path=/swagger-ui.html springdoc.swagger-ui.enabled=true -
现在,我们已经准备好运行我们的项目。当你的应用程序正在运行时,导航到
http://localhost:8080/swagger-ui.html,你将看到 Swagger UI 中的文档,如下面的截图所示:![]()
图 4.15:汽车 RESTful API 文档
你可以看到你 RESTful API 中所有可用的端点。如果你打开任何一个端点,甚至可以通过按下尝试它按钮来尝试它们。文档也以 JSON 格式在
http://localhost:8080/api-docs提供。
现在你已经为你的 RESTful API 提供了文档,开发者们使用起来就更加容易了。
在下一章中,我们将保护我们的 RESTful API,这将中断 Swagger UI 的访问。你可以通过修改你的安全配置(允许"/api-docs/**"和"/swagger-ui/**"路径)来再次允许访问。你也可以使用 Spring Profiles,但这本书的范围之外。
摘要
在本章中,我们使用 Spring Boot 创建了一个 RESTful 网络服务。首先,我们创建了一个控制器和一个返回所有汽车 JSON 格式的函数。接下来,我们使用了 Spring Data REST 来获取一个具有所有 CRUD 功能的完整网络服务。我们涵盖了使用我们创建的服务 CRUD 功能所需的不同类型的请求。我们还将在 RESTful 网络服务中包含我们的查询。最后,我们学习了如何使用 OpenAPI 3 正确地记录我们的 API。
我们将在本书的后面部分使用这个 RESTful 网络服务与我们的前端,现在你也可以轻松地为你的需求实现 REST API。
在下一章中,我们将使用 Spring Security 来保护我们的后端。我们将学习如何通过实现身份验证来保护我们的数据。然后,只有经过身份验证的用户才能访问我们创建的 RESTful API 的资源。
问题
-
什么是 REST?
-
你如何使用 Spring Boot 创建一个 RESTful 网络服务?
-
你如何使用我们的 RESTful 网络服务获取项目?
-
你如何使用我们的 RESTful 网络服务删除项目?
-
你如何使用我们的 RESTful 网络服务添加项目?
-
你如何使用我们的 RESTful 网络服务更新项目?
-
你如何使用我们的 RESTful 网络服务使用查询?
-
OpenAPI 规范是什么?
-
Swagger UI 是什么?
进一步阅读
Packt 提供了其他资源,用于学习 Spring Boot RESTful 网络服务:
-
Praveenkumar Bouna 的 Postman 教程:API 测试入门[视频] (
www.packtpub.com/product/postman-tutorial-getting-started-with-api-testing-video/9781803243351) -
Harihara Subramanian J 和 Pethuru Raj 的《动手实践 RESTful API 设计模式和最佳实践》 (
www.packtpub.com/product/hands-on-restful-api-design-patterns-and-best-practices/9781788992664)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第五章:保护您的后端
本章解释了如何保护您的 Spring Boot 后端。保护后端是代码开发的关键部分。对于保护敏感数据、遵守法规和防止未经授权的访问至关重要。后端通常处理用户认证和授权过程。正确保护这些方面确保只有授权用户可以访问应用程序并执行特定操作。我们将以前一章创建的数据库应用程序作为起点。
在本章中,我们将涵盖以下主题:
-
理解 Spring Security
-
使用 JSON Web Token 保护您的后端
-
基于角色的安全
-
使用 OAuth2 与 Spring Boot
技术要求
我们在前几章中创建的 Spring Boot 应用程序是必需的。
以下 GitHub 链接也将是必需的:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter05。
理解 Spring Security
Spring Security (https://spring.io/projects/spring-security) 为基于 Java 的 Web 应用程序提供安全服务。Spring Security 项目始于 2003 年,之前被称为 Acegi Security System for Spring。
默认情况下,Spring Security 启用以下功能:
-
一个具有内存中单个用户的
AuthenticationManager实例。用户名为user,密码打印到控制台输出。 -
忽略常见静态资源位置的路径,例如
/css和/images。其他所有端点的 HTTP 基本认证。 -
安全事件发布到 Spring 的
ApplicationEventPublisher接口。 -
默认启用的常见低级功能,包括 HTTP Strict Transport Security (HSTS), 跨站脚本 (XSS), 和 跨站请求伪造 (CSRF)。
-
默认自动生成的登录页面。
您可以通过将以下突出显示的依赖项添加到 build.gradle 文件中来将 Spring Security 包含到您的应用程序中。第一个依赖项是用于应用程序的,第二个是用于测试的:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-rest'
**implementation** **'org.springframework.boot:spring-boot-starter-security'**
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
**testImplementation** **'org.springframework.security:spring-security-test'**
}
如果您尚未启用自动刷新,请记住在修改了您的 build.gradle 文件后,从 Eclipse 中刷新 Gradle 项目。
当您启动应用程序时,您可以从控制台看到 Spring Security 已创建一个用户名为 user 的内存中用户。用户的密码可以在控制台输出中看到,如图所示:

图 5.1:Spring Security 已启用
如果控制台中没有密码,请尝试通过在控制台中按下红色 终止 按钮重新启动您的项目并重新运行它。
Eclipse 控制台输出有限,默认缓冲区大小为 80,000 个字符,因此输出可能在密码语句出现之前被截断。您可以从窗口 | 首选项 | 运行/调试 | 控制台菜单更改此设置。
现在,如果您向您的 REST API 根端点发送一个GET请求,您将看到它已被保护。打开您的网络浏览器并导航到http://localhost:8080/api。您将被重定向到 Spring Security 默认登录页面,如下面的截图所示:

图 5.2:受保护的 REST API
为了能够成功发送GET请求,我们必须对我们的 RESTful API 进行身份验证。在用户名字段中输入user,并将从控制台生成的密码复制到密码字段。通过身份验证,我们可以看到响应包含我们的 API 资源,如下面的截图所示:

图 5.3:基本身份验证
为了配置 Spring Security 的行为,我们必须为 Spring Security 添加一个新的配置类。安全配置文件可以用来定义哪些 URL 或 URL 模式对哪些角色或用户是可访问的。您还可以定义认证机制、登录过程、会话管理等。
在您的应用程序根包(com.packt.cardatabase)中创建一个名为SecurityConfig的新类。以下源代码显示了安全配置类的结构:
package com.packt.cardatabase;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.
EnableWebSecurity;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
}
@Configuration和@EnableWebSecurity注解关闭了默认的 Web 安全配置,我们可以在此类中定义自己的配置。在稍后我们将看到其作用的filterChain(HttpSecurity http)方法中,我们可以定义我们的应用程序中哪些端点是安全的,哪些不是。实际上我们目前不需要这个方法,因为我们可以在所有端点都受保护的情况下使用默认设置。
我们还可以通过使用 Spring Security 的InMemoryUserDetailsManager(它实现了UserDetailsService)将内存中的用户添加到我们的应用程序中。然后我们可以实现存储在内存中的用户/密码认证。我们还可以使用PasswordEncoder通过bcrypt算法对密码进行编码。
下面的高亮源代码将创建一个名为user、密码为password、角色为USER的内存中用户:
// SecurityConfig.java
package com.packt.cardatabase;
**import** **org.springframework.context.annotation.Bean;**
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.
EnableWebSecurity;
**import** **org.springframework.security.core.userdetails.User;**
**import** **org.springframework.security.core.userdetails.UserDetails;**
**import** **org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;**
**import** **org.springframework.security.crypto.password.PasswordEncoder;**
**import** **org.springframework.security.provisioning.****InMemoryUserDetailsManager;**
@Configuration
@EnableWebSecurity
public class SecurityConfig {
**@Bean**
**public** **InMemoryUserDetailsManager** **userDetailsService****()** **{**
**UserDetails****user****=** **User.builder().username(****"user"****).**
**password(passwordEncoder().encode(****"password"****))**
**.roles(****"USER"****).build();**
**return****new****InMemoryUserDetailsManager****(user);**
**}**
**@Bean**
**public** **PasswordEncoder** **passwordEncoder****()** **{**
**return****new****BCryptPasswordEncoder****();**
**}**
}
现在,重新启动应用程序,您将能够使用内存中的用户进行身份验证测试。在开发阶段使用内存中的用户是可以的,但一个合适的应用程序应该将用户保存在数据库中。
要将用户保存到数据库中,你必须创建一个用户实体类和存储库。密码不应以明文格式保存到数据库中。如果包含用户密码的数据库被黑客攻击,攻击者将能够直接以明文形式获取密码。Spring Security 提供了多种哈希算法,例如 bcrypt,你可以使用这些算法来哈希密码。以下步骤展示了如何实现这一点:
- 在
com.packt.cardatabase.domain包中创建一个名为AppUser的新类。激活领域包,然后右键单击它。从菜单中选择 新建 | 类,并将新类命名为User。之后,你的项目结构应该看起来像这样:

图 5.4:项目结构
-
使用
@Entity注解标注AppUser类。添加 ID、用户名、密码和角色类字段。最后,添加构造函数、获取器和设置器。我们将所有字段设置为不可为空。这意味着数据库列不能包含null值。我们还将通过在用户名的@Column注解中使用unique=true来指定用户名必须是唯一的。请参考以下AppUser.java源代码以了解字段:package com.packt.cardatabase.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class AppUser { @Id @GeneratedValue(strategy=GenerationType.AUTO) @Column(nullable=false, updatable=false) private Long id; @Column(nullable=false, unique=true) private String username; @Column(nullable=false) private String password; @Column(nullable=false) private String role; // Constructors, getters and setters }这里是
AppUser.java构造函数的源代码:public AppUser() {} public AppUser(String username, String password, String role) { super(); this.username = username; this.password = password; this.role = role; }这里是带有获取器和设置器的
AppUser.java源代码:public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } -
在领域包中创建一个名为
AppUserRepository的新接口。为此,激活领域包,然后右键单击它。从菜单中选择 新建 | 接口,并将其命名为AppUserRepository。存储库类的源代码与我们之前章节中看到的大致相同,但有一个名为
findByUsername的查询方法,这是我们接下来需要用到的。此方法用于在认证过程中从数据库中查找用户。该方法返回Optional以防止空指针异常。请参考以下AppUserRepository源代码:package com.packt.cardatabase.domain; import java.util.Optional; import org.springframework.data.repository.CrudRepository; public interface AppUserRepository extends CrudRepository <AppUser, Long> { Optional<AppUser> findByUsername(String username); } -
接下来,我们将创建一个实现 Spring Security 提供的
UserDetailsService接口的类。Spring Security 使用它来进行用户认证和授权。在根包中创建一个新的service包。为此,激活根包,然后右键单击它。从菜单中选择 新建 | 包,并将其命名为service,如图所示:

图 5.5:服务包
- 在我们刚刚创建的
service包中创建一个名为UserDetailsServiceImpl的新类。现在,你的项目结构应该看起来像这样(在 Eclipse 中,通过按 F5 刷新项目资源管理器):

图 5.6:项目结构
-
我们必须将
AppUserRepository类注入到UserDetailsServiceImpl类中,因为当 Spring Security 处理认证时需要从数据库中获取用户。我们之前实现的findByUsername方法返回Optional,因此我们可以使用isPresent()方法来检查user是否存在。如果user不存在,我们抛出UsernameNotFoundException异常。loadUserByUsername方法返回UserDetails对象,这是认证所必需的。我们使用 Spring Security 的UserBuilder类来构建用于认证的用户。以下是UserDetailsServiceImpl.java的源代码:package com.packt.cardatabase.service; import java.util.Optional; import org.springframework.security.core.userdetails.User. UserBuilder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails. UserDetailsService; import org.springframework.security.core.userdetails. UsernameNotFoundException; import org.springframework.stereotype.Service; import com.packt.cardatabase.domain.AppUser; import com.packt.cardatabase.domain.AppUserRepository; @Service public class UserDetailsServiceImpl implements UserDetailsService { private final AppUserRepository repository; public UserDetailsServiceImpl(AppUserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<AppUser> user = repository.findByUsername(username); UserBuilder builder = null; if (user.isPresent()) { AppUser currentUser = user.get(); builder = org.springframework.security.core.userdetails. User.withUsername(username); builder.password(currentUser.getPassword()); builder.roles(currentUser.getRole()); } else { throw new UsernameNotFoundException("User not found."); } return builder.build(); } }在我们的安全配置类中,我们必须指定 Spring Security 应使用数据库中的用户而不是内存中的用户。从
SecurityConfig类中删除userDetailsService()方法以禁用内存中的用户。添加一个新的configureGlobal方法以启用数据库中的用户。我们永远不应该将密码以明文形式保存到数据库中。因此,我们将在
configureGlobal方法中定义一个密码散列算法。在这个例子中,我们使用bcrypt算法。这可以通过 Spring Security 的BCryptPasswordEncoder类轻松实现,该类在认证过程中编码散列密码。以下是SecurityConfig.java的源代码:package com.packt.cardatabase; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Bean; import org.springframework.security.config.annotation. authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation. web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt. BCryptPasswordEncoder; import com.packt.cardatabase.service.UserDetailsServiceImpl; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity public class SecurityConfig { private final UserDetailsServiceImpl userDetailsService; public SecurityConfig(UserDetailsServiceImpl userDetailsService) { this.userDetailsService = userDetailsService; } public void configureGlobal (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(new BCryptPasswordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }现在,在将密码保存到数据库之前,必须使用
bcrypt对其进行散列。 -
最后,我们可以使用
CommandLineRunner接口将几个测试用户保存到数据库中。打开CardatabaseApplication.java文件,并将AppUserRepository注入到主类中:private final CarRepository repository; private final OwnerRepository orepository; **private****final** **AppUserRepository urepository;** public CardatabaseApplication(CarRepository repository, OwnerRepository orepository**, AppUserRepository urepository**) { this.repository = repository; this.orepository = orepository; **this****.urepository = urepository;** } -
让我们使用
bcrypt散列密码将两个用户保存到数据库中。你可以在互联网上找到bcrypt计算器或生成器。这些生成器允许你输入明文密码,然后它们将生成相应的bcrypt散列:@Override public void run(String... args) throws Exception { // Add owner objects and save these to db Owner owner1 = new Owner("John", "Johnson"); Owner owner2 = new Owner("Mary", "Robinson"); orepository.saveAll(Arrays.asList(owner1, owner2)); repository.save(new Car( "Ford", "Mustang", "Red", "ADF-1121", 2023, 59000, owner1)); repository.save(new Car( "Nissan", "Leaf", "White", "SSJ-3002", 2020, 29000, owner2)); repository.save(new Car( "Toyota", "Prius", "Silver", "KKO-0212", 2022, 39000, owner2)); // Fetch all cars and log to console for (Car car : repository.findAll()) { logger.info(car.getBrand() + " " + car.getModel()); } **// Username: user, password: user** **urepository.save(****new****AppUser****(****"user"****,** **"$2a$10$NVM0n8ElaRgg7zWO1CxUdei7vWoPg91Lz2aYavh9.** **f9q0e4bRadue"****,****"****USER"****));** **// Username: admin, password: admin** **urepository.save(****new****AppUser****(****"admin"****,** **"$2a$10$8cjz47bjbR4Mn8GMg9IZx.vyjhLXR/SKKMSZ9.** **mP9vpMu0ssKi8GW"****,** **"ADMIN"****));** }bcrypt是一种由 Niels Provos 和 David Mazières 设计的强大散列函数。以下是从
admin字符串生成的 bcrypt 散列的示例:$2a$10$8cjz47bjbR4Mn8GMg9IZx.vyjhLXR/SKKMSZ9.mP9vpMu0ssKi8GW$2a代表算法版本,$10代表算法的强度。Spring Security 的BcryptPasswordEncoder类的默认强度是 10。bcrypt 在散列过程中生成一个随机的盐,因此散列结果总是不同的。 -
运行你的应用程序后,你会看到现在数据库中有一个
app_user表,并且有两个用户记录以散列密码的形式保存,如下面的截图所示:

图 5.7:用户
-
现在,你应该重新启动应用程序,如果你尝试在不进行认证的情况下向
http://localhost:8080/api路径发送GET请求,你会得到一个401 未授权错误。你必须进行认证才能发送成功的请求。与上一个示例相比,区别在于我们现在使用数据库中的用户进行认证。现在,你可以通过使用浏览器向
/api端点发送GET请求来登录,或者我们可以使用 Postman 和基本认证,如下面的截图所示:![]()
图 5.8:GET 请求认证
-
你可以看到,我们目前通过在我们的 RESTful 网络服务中调用
api/appUsers端点来获取用户,这是我们想要避免的。如 第四章 中所述,Spring Data REST 默认会从所有公共仓库生成 RESTful 网络服务。我们可以使用@RepositoryRestResource注解的exported标志,并将其设置为false,这意味着以下仓库不会作为 REST 资源暴露:package com.packt.cardatabase.domain; import java.util.Optional; import org.springframework.data.repository.CrudRepository; **import** **org.springframework.data.rest.core.annotation.** **RepositoryRestResource;** **@RepositoryRestResource(exported = false)** public interface AppUserRepository extends CrudRepository <AppUser, Long> { Optional<AppUser> findByUsername(String username); } -
现在,如果你重新启动应用程序并向
/api端点发送一个GET请求,你会看到/appUsers端点不再可见。
接下来,我们将开始使用 JSON Web Token 实现认证。
使用 JSON Web Token 保护你的后端
在上一节中,我们介绍了如何使用基本认证来与 RESTful 网络服务交互。基本认证不提供处理令牌或管理会话的方法。当用户登录时,凭证会随着每个请求发送,这可能导致会话管理挑战和潜在的安全风险。当我们使用 React 开发自己的前端时,这种方法不可用,因此我们将使用 JSON Web Token(JWT)认证代替(https://jwt.io/)。这也会让你了解如何更详细地配置 Spring Security。
保护你的 RESTful 网络服务的另一种选择是 OAuth 2。OAuth2(https://oauth.net/2/)是行业标准的授权,它可以在 Spring Boot 应用程序中非常容易地使用。本章后面有一个部分将给你一个基本的概念,了解如何在你的应用程序中使用它。
JWTs 常用于 RESTful API 的认证和授权目的。它们是实现现代网络应用认证的一种紧凑方式。JWT 非常小,因此可以发送在 URL 中、POST 参数中或头信息中。它还包含有关用户的所有必要信息,例如他们的用户名和角色。
JWT 包含三个不同的部分,由点分隔:xxxxx.yyyyy.zzzzz。这些部分如下划分:
-
第一部分(
xxxxx)是 头部,它定义了令牌的类型和哈希算法。 -
第二部分(
yyyyy)是 负载,通常情况下,在认证的情况下,它包含用户信息。 -
第三部分(
zzzzz)是签名,它用于验证令牌在传输过程中没有被更改。
这是一个 JWT 的示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
以下图显示了使用 JWT 的简化认证过程表示:

图 5.9:JWT 认证过程
在认证成功后,客户端发送的请求应始终包含在认证中接收到的 JWT。
我们将使用jjwt(https://github.com/jwtk/jjwt),这是 Java 和 Android 的 JWT 库,用于创建和解析 JWT。因此,我们必须将以下依赖项添加到build.gradle文件中:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-rest'
implementation 'org.springframework.boot:spring-boot-starter-security'
**implementation** **'io.jsonwebtoken:jjwt-api:0.11.5'**
**runtimeOnly** **'io.jsonwebtoken:jjwt-impl:0.11.5'****,** **'io.jsonwebtoken:jjwt-**
**jackson:0.11.5'**
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
在你更新了依赖项之后,记得从 Eclipse 刷新 Gradle 项目。
以下步骤演示了如何在我们的后端启用 JWT 认证。
保护登录
我们将从登录功能开始:
-
首先,我们将创建一个类来生成和验证已签名的 JWT。在
com.packt.cardatabase.service包中创建一个新的类名为JwtService。在类的开头,我们将定义一些常量:EXPIRATIONTIME定义了令牌的过期时间(以毫秒为单位),PREFIX定义了令牌的前缀,通常使用"Bearer"模式。JWT 在Authorization头中发送,当使用 Bearer 模式时,头的样子如下:Authorization: Bearer <token>JwtService源代码看起来如下:package com.packt.cardatabase.service; import org.springframework.stereotype.Component; @Component public class JwtService { static final long EXPIRATIONTIME = 86400000; // 1 day in ms. Should be shorter in production. static final String PREFIX = "Bearer"; } -
我们将使用
jjwt库的secretKeyFor方法创建一个密钥。这只是为了演示目的。在生产环境中,你应该从应用程序配置中读取你的密钥。然后,getToken方法生成并返回令牌。getAuthUser方法从响应的Authorization头中获取令牌。接下来,我们将使用jjwt库提供的parserBuilder方法创建一个JwtParserBuilder实例。setSigningKey方法用于指定用于令牌验证的密钥。parseClaimsJws方法从Authorization头中移除Bearer前缀。最后,我们将使用getSubject方法获取用户名。整个JwtService源代码如下:package com.packt.cardatabase.service; **import** **io.jsonwebtoken.Jwts;** **import** **io.jsonwebtoken.SignatureAlgorithm;** **import** **io.jsonwebtoken.security.Keys;** **import** **java.security.Key;** **import** **org.springframework.http.HttpHeaders;** **import** **org.springframework.stereotype.Component;** **import** **jakarta.servlet.http.HttpServletRequest;** **import** **java.util.Date;** @Component public class JwtService { static final long EXPIRATIONTIME = 86400000; // 1 day in ms. Should be shorter in production. static final String PREFIX = "Bearer"; **// Generate secret key. Only for demonstration purposes.** **// In production, you should read it from the application ** **// configuration.** **static****final****Key****key****=** **Keys.secretKeyFor (SignatureAlgorithm.** **HS256);** **// Generate signed JWT token** **public** **String** **getToken****(String username)** **{** **String****token****=** **Jwts.builder()** **.setSubject(username)** **.setExpiration(****new****Date****(System.currentTimeMillis() +** **EXPIRATIONTIME))** **.signWith(key)** **.compact();** **return** **token;** **}** **// Get a token from request Authorization header,** **// verify the token, and get username** **public** **String** **getAuthUser****(HttpServletRequest request)** **{** **String****token****=** **request.getHeader** **(HttpHeaders.AUTHORIZATION);** **if** **(token !=** **null****) {** **String****user****=** **Jwts.parserBuilder()** **.setSigningKey(key)** **.build()** **.parseClaimsJws(token.replace(PREFIX,** **""****))** **.getBody()** **.getSubject();** **if** **(user !=** **null****)** **return** **user;** **}** **return****null****;** **}** } -
接下来,我们将添加一个新的类来存储用于认证的凭据。在这里,我们可以使用 Java record,这是在 Java 14 中引入的。如果你需要一个只持有数据的类,record 是一个不错的选择;你可以避免编写很多样板代码。在
com.packt.cardatabase.domain包中创建一个新的 record(新建 | 记录)名为AccountCredentials!图 5.10:创建新记录
记录有两个字段:
username和password。以下是记录的源代码。正如你所见,当我们使用它时,我们不需要编写 getter 和 setter:package com.packt.cardatabase.domain; public record AccountCredentials(String username, String password) {} -
现在,我们将实现登录的
controller类。登录是通过调用POST方法的/login端点并将在请求体中发送用户名和密码来完成的。在com.packt.cardatabase.web包内创建一个名为LoginController的类。我们必须将JwtService实例注入到控制器类中,因为这是在登录成功的情况下生成签名 JWT 所使用的。代码如下所示:package com.packt.cardatabase.web; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import com.packt.cardatabase.domain.AccountCredentials; import com.packt.cardatabase.service.JwtService; @RestController public class LoginController { private final JwtService jwtService; private final AuthenticationManager authenticationManager; public LoginController(JwtService jwtService, AuthenticationManager authenticationManager) { this.jwtService = jwtService; this.authenticationManager = authenticationManager; } @PostMapping("/login") public ResponseEntity<?> getToken(@RequestBody AccountCredentials credentials) { // Generate token and send it in the response Authorization // header } } -
接下来,我们将实现处理登录功能的
getToken方法。我们从请求体中获取一个包含用户名和密码的 JSON 对象。使用AuthenticationManager进行身份验证,它使用我们从请求中获取的凭据。然后,我们使用JwtService类的getToken方法生成 JWT。最后,我们构建一个包含生成的 JWT 的Authorization标头的 HTTP 响应:// LoginController.java @PostMapping("/login") public ResponseEntity<?> getToken(@RequestBody AccountCredentials credentials) { UsernamePasswordAuthenticationToken creds = new UsernamePasswordAuthenticationToken(credentials.username(), credentials.password()); Authentication auth = authenticationManager.authenticate(creds); // Generate token String jwts = jwtService.getToken(auth.getName()); // Build response with the generated token return ResponseEntity.ok().header(HttpHeaders.AUTHORIZATION, "Bearer" + jwts).header(HttpHeaders. ACCESS_CONTROL_EXPOSE_HEADERS, "Authorization").build(); } -
我们还向
LoginController类中注入了AuthenticationManager,因此我们必须向SecurityConfig类中添加以下突出显示的代码:package com.packt.cardatabase; **import** **org.springframework.context.annotation.Bean;** import org.springframework.context.annotation.Configuration; **import** **org.springframework.security.authentication.****AuthenticationManager;** **import** **org.springframework.security.config.annotation.** **authentication.configuration.AuthenticationConfiguration;** import org.springframework.security.config.annotation. authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation. authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import com.packt.cardatabase.service.UserDetailsServiceImpl; @Configuration @EnableWebSecurity public class SecurityConfig { private final UserDetailsServiceImpl userDetailsService; public SecurityConfig(UserDetailsServiceImpl userDetailsService){ this.userDetailsService = userDetailsService; } public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(new BCryptPasswordEncoder()); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } **@Bean** **public** **AuthenticationManager** **uthenticationManager****(** **AuthenticationConfiguration authConfig)****throws** **Exception {** **return** **authConfig.getAuthenticationManager();** **}** } -
在此步骤中,我们必须配置 Spring Security 功能。Spring Security 的
SecurityFilterChainbean 定义了哪些路径是受保护的,哪些不是。将以下filterChain方法添加到SecurityConfig类中。在方法中,我们定义了对/login端点的POST方法请求不需要身份验证,以及对所有其他端点的请求需要身份验证。我们还将定义 Spring Security 将永远不会创建会话,因此我们可以禁用跨站请求伪造 (csrf)。JWT 被设计为无状态,这降低了与会话相关的漏洞风险。我们将在 HTTP 安全配置中使用 Lambdas:在某些其他编程语言中,Lambdas 被称为匿名函数。Lambdas 的使用使代码更易读,并减少了样板代码。
// SecurityConfig.java // Add the following import import org.springframework.security.web.SecurityFilterChain; // Add filterChain method @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((csrf) -> csrf.disable()) .sessionManagement((sessionManagement) -> sessionManagement. sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests.requestMatchers(HttpMethod.POST, "/login").permitAll().anyRequest().authenticated()); return http.build(); } -
最后,我们准备好测试我们的登录功能。打开 Postman 并向
http://localhost:8080/loginURL 发送POST请求。在请求体中定义一个有效用户,例如,{"username":"user", "password":"user"}并从下拉列表中选择 JSON。Postman 将自动设置Content-Type标头为application/json。您应该从 Headers 选项卡检查Content-Type标头是否设置正确。现在,您应该在响应中看到一个包含签名 JWT 的Authorization标头,如下面的截图所示:

图 5.11:登录请求
您也可以通过使用错误的密码并查看响应不包含 Authorization 标头来测试登录。
保护其他请求
我们现在已经完成了登录步骤,接下来我们将继续处理其他传入请求的认证。在认证过程中,我们使用 过滤器 来在请求发送到控制器或响应发送到客户端之前执行一些操作。
以下步骤展示了认证过程的其余部分:
-
我们将使用一个过滤器类来认证所有其他传入的请求。在根包中创建一个名为
AuthenticationFilter的新类。AuthenticationFilter类扩展了 Spring Security 的OncePerRequestFilter接口,该接口提供了一个doFilterInternal方法,我们在其中实现认证。我们必须将一个JwtService实例注入到过滤器类中,因为它需要验证请求中的令牌。SecurityContextHolder是 Spring Security 存储已认证用户详情的地方。代码在下面的代码片段中展示:package com.packt.cardatabase; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import com.packt.cardatabase.service.JwtService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @Component public class AuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; public AuthenticationFilter(JwtService jwtService) { this.jwtService = jwtService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, java.io.IOException { // Get token from the Authorization header String jws = request.getHeader(HttpHeaders.AUTHORIZATION); if (jws != null) { // Verify token and get user String user = jwtService.getAuthUser(request); // Authenticate Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, java.util.Collections.emptyList()); SecurityContextHolder.getContext() .setAuthentication(authentication); } filterChain.doFilter(request, response); } } -
接下来,我们必须将我们的过滤器类添加到 Spring Security 配置中。打开
SecurityConfig类,并注入我们刚刚实现的AuthenticationFilter类,如高亮代码所示:private final UserDetailsServiceImpl userDetailsService; **private****final** **AuthenticationFilter authenticationFilter;** public SecurityConfig(UserDetailsServiceImpl userDetailsService**,****AuthenticationFilter authenticationFilter**) { this.userDetailsService = userDetailsService; **this****.authenticationFilter = authenticationFilter;** } -
然后,修改
SecurityConfig类中的filterChain方法,并添加以下代码行://Add the following import import org.springframework.security.web.authentication. UsernamePasswordAuthenticationFilter; // Modify the filterChain method @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((csrf) -> csrf.disable()) .sessionManagement((sessionManagement) -> sessionManagement. sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests.requestMatchers(HttpMethod.POST, "/login").permitAll().anyRequest().authenticated()) **.addFilterBefore(authenticationFilter,** **UsernamePasswordAuthenticationFilter.class);** return http.build(); } -
现在,我们已经准备好测试整个工作流程。在运行应用程序后,我们可以首先通过调用
POST方法的/login端点来登录,如果登录成功,我们将在Authorization标头中收到一个 JWT。请记住,如果 Postman 没有自动完成,请在请求体中添加一个有效的用户,并将Content-Type标头设置为application/json。以下屏幕截图展示了这个过程:

图 5.12:登录请求
- 在成功登录后,我们可以通过在
Authorization标头中发送从登录接收到的 JWT 来调用其他 RESTful 服务端点。从登录响应中复制令牌(不带Bearer前缀),并在VALUE列中添加带有令牌的Authorization标头。参考以下屏幕截图中的示例,其中对/cars端点执行了一个GET请求:

图 5.13:认证 GET 请求
每次应用程序重启时,你必须重新认证,因为会生成一个新的 JWT。
JWT 不是永远有效的,因为它被设置了一个过期日期。在我们的例子中,为了演示目的,我们设置了一个较长的过期时间。在生产环境中,时间应该根据用例最好设置为分钟。
处理异常
我们还应该在认证中处理异常。目前,如果你尝试使用错误的密码登录,你会收到一个没有进一步说明的 403 Forbidden 状态。Spring Security 提供了一个 AuthenticationEntryPoint 接口,可以用来处理异常。让我们看看它是如何工作的:
-
在根包中创建一个名为
AuthEntryPoint的新类,该类实现了AuthenticationEntryPoint接口。我们将实现commence方法,该方法接受一个异常作为参数。在异常的情况下,我们将响应状态设置为401 Unauthorized并将异常消息写入响应体。代码如下所示:package com.packt.cardatabase; import java.io.IOException; import java.io.PrintWriter; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.MediaType; import org.springframework.security.core. AuthenticationException; import org.springframework.security.web. AuthenticationEntryPoint; import org.springframework.stereotype.Component; @Component public class AuthEntryPoint implements AuthenticationEntryPoint { @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setStatus (HttpServletResponse.SC_UNAUTHORIZED); response.setContentType (MediaType.APPLICATION_JSON_VALUE); PrintWriter writer = response.getWriter(); writer.println("Error: " + authException.getMessage()); } } -
然后,我们必须为异常处理配置 Spring Security。将我们的
AuthEntryPoint类注入到SecurityConfig类中,如下所示的高亮代码:// SecurityConfig.java private final UserDetailsServiceImpl userDetailsService; private final AuthenticationFilter authenticationFilter; **private****final** **AuthEntryPoint exceptionHandler;** public SecurityConfig(UserDetailsServiceImpl userDetailsService, AuthenticationFilter authenticationFilter, **AuthEntryPoint ** **exceptionHandler**) { this.userDetailsService = userDetailsService; this.authenticationFilter = authenticationFilter; **this****.exceptionHandler = exceptionHandler;** } -
然后,按照以下方式修改
filterChain方法:// SecurityConfig.java @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((csrf) -> csrf.disable()) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests.requestMatchers(HttpMethod.POST, "/login").permitAll().anyRequest().authenticated()) .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class) **.exceptionHandling((exceptionHandling) -> exceptionHandling.** **authenticationEntryPoint(exceptionHandler));** return http.build(); } -
现在,如果您发送一个带有错误凭证的登录
POST请求,您将在响应中获得401 Unauthorized状态和一个错误消息,如下面的屏幕截图所示:
图 5.14:无效凭证
添加 CORS 过滤器
我们还将向我们的安全配置类添加一个跨源资源共享(CORS)过滤器。CORS 引入了一些帮助客户端和服务器决定是否允许或拒绝跨源请求的头部。CORS 过滤器对于前端是必需的,前端从其他来源发送请求。CORS 过滤器拦截请求,如果这些请求被识别为跨源,它会在请求中添加适当的头部。为此,我们将使用 Spring Security 的CorsConfigurationSource接口。
在这个示例中,我们将允许所有来源的 HTTP 方法和头。如果您需要一个更精细的定义,您可以在以下位置定义一个允许的来源、方法和头列表。让我们开始吧:
-
将以下导入和方法添加到您的
SecurityConfig类中,以启用 CORS 过滤器:// SecurityConfig.java // Add the following imports import java.util.Arrays; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; // Add Global CORS filter inside the class @Bean public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList("*")); config.setAllowedMethods(Arrays.asList("*")); config.setAllowedHeaders(Arrays.asList("*")); config.setAllowCredentials(false); config.applyPermitDefaultValues(); source.registerCorsConfiguration("/**", config); return source; }如果您想明确定义来源,您可以按照以下方式设置:
// localhost:3000 is allowed config.setAllowedOrigins(Arrays.asList ("http://localhost:3000")); -
我们还必须在
filterChain方法中添加cors()函数,如下面的代码片段所示:// SecurityConfig.java // Add the following static import import static org.springframework.security.config.Customizer.withDefaults; // Modify filterChain method @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((csrf) -> csrf.disable()) **.cors(withDefaults())** .sessionManagement((sessionManagement) -> sessionManagement. sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests.requestMatchers(HttpMethod.POST, "/login").permitAll().anyRequest().authenticated()) .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling((exceptionHandling) -> exceptionHandling. authenticationEntryPoint(exceptionHandler)); return http.build(); }
现在,我们已经确保了我们的后端安全。在下一节中,我们将介绍基于角色的安全基础,您可以使用它来在您的 Spring Boot 应用程序中获得更细粒度的访问控制。
基于角色的安全
在 Spring Security 中,角色可以用来定义粗粒度的基于角色的安全,用户可以被分配到一个或多个角色。角色通常具有层次结构,例如,ADMIN、MANAGER、USER。Spring Security 还提供了权限,可以用于更细粒度的访问控制。我们已经为我们的用户定义了简单的角色ADMIN和USER,在我们的示例后端应用程序中我们没有使用基于角色的安全。本节介绍了在您的 Spring Boot 应用程序中实现基于角色安全的不同方法。
你可以在你的安全配置类中在请求级别定义基于角色的访问控制。在下面的示例代码中,我们定义了哪些端点需要特定的角色才能访问。/admin/** 端点需要 ADMIN 角色才能访问,而 /user/** 端点需要 USER 角色才能访问。我们使用 Spring Security 的 hasRole() 方法,如果用户具有指定的角色则返回 true:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws
Exception {
http.csrf((csrf) -> csrf.disable()).cors(withDefaults())
.sessionManagement((sessionManagement) -> sessionManagement. sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests.requestMatchers("/admin/**").hasRole ("ADMIN").requestMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated())
return http.build();
}
你可以在 Spring Boot 文档中了解更多关于请求授权的信息:https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html。
Spring Security 提供了 @PreAuthorize、@PostAuthorize、@PreFilter、@PostFilter 和 @Secured 注解,用于应用方法级安全。在 spring-boot-starter-security 中默认不启用方法级安全。你必须在你的 Spring 配置类中启用它,例如,在顶级配置中,通过使用 @EnableMethodSecurity 注解:
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
**import** **org.springframework.security.config.annotation.method.****configuration.EnableMethodSecurity;**
@SpringBootApplication
**@EnableMethodSecurity**
public class CardatabaseApplication implements CommandLineRunner {
}
然后,你将能够在你的方法中使用方法级安全注解。在下面的示例中,具有 USER 角色的用户可以执行 updateCar() 方法,而具有 ADMIN 角色的用户可以执行 deleteOwner() 方法。@PreAuthorize 注解在方法执行前检查规则。如果用户没有指定的角色,Spring Security 会阻止方法执行,并抛出 AccessDeniedException:
@Service
public class CarService {
@PreAuthorize("hasRole('USER')")
public void updateCar(Car car) {
// This method can be invoked by user with USER role.
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteOwner(Car car) {
// This method can be invoked by user with ADMIN role.
}
}
@PreAuthorize 注解取代了 @Secured 注解,并建议使用它。
@PostAuthorize 注解可以在方法执行后用于检查授权。例如,你可以用它来检查用户是否有权限访问方法返回的对象,或者你可以根据用户的授权来过滤返回的数据。
@PreFilter 和 @PostFilter 注解可以用来过滤对象列表,但它们通常不用于基于角色的访问控制。与这些注解一起使用的规则更为细致。
你可以在 Spring Security 文档中了解更多关于方法安全的信息:docs.spring.io/spring-security/reference/servlet/authorization/method-security.html。
在下一节中,我们将介绍使用 Spring Boot 的 OAuth 基础知识。
使用 OAuth2 和 Spring Boot
在你的应用程序中完全实现安全的认证和授权是非常具有挑战性的。在生产环境中,建议使用 OAuth2 提供商来实现。这实际上简化了认证过程,并且提供商通常有出色的安全实践。
这些不是实现 OAuth 2.0 授权的详细说明,但它们会给你一个关于该过程的概念。
OAuth(开放授权)是用于互联网上受保护资源的安全访问的标准。OAuth 标准版本 2.0 现在普遍使用。有几个 OAuth 2.0 提供商实现了第三方应用程序的 OAuth 授权。以下列出了一些常见的提供商:
-
Auth0:
auth0.com/ -
Okta:
www.okta.com/ -
Keycloak:
www.keycloak.org/
您可以使用 OAuth2 实现社交登录,之后用户可以使用来自社交媒体平台(如 Facebook)的现有凭据登录。OAuth 还定义了撤销访问令牌和处理令牌过期的机制。
如果您想在 Spring Boot 应用程序中使用 OAuth,第一步是选择一个 OAuth 提供商。上述列表中的所有提供商都可以与您的 Spring Boot 应用程序一起使用。
在 OAuth2 过程中,术语 资源所有者 通常指最终用户,而 授权服务器 是 OAuth 提供商服务的一部分。客户端 是一个希望访问受保护资源的应用程序。资源服务器 通常指客户端希望使用的 API。
使用 REST API 的 OAuth2 认证过程的简化版本包含以下步骤:
-
认证:第三方应用程序通过请求访问受保护资源来进行认证。
-
授权:资源所有者授权访问其资源,通常通过用户登录。
-
授权服务器授权资源所有者,并使用授权码将用户重定向回客户端。
-
客户端使用授权码从授权服务器请求访问令牌。访问令牌的格式在标准中未指定,JWT 非常常用。
-
授权服务器验证访问令牌。如果令牌有效,客户端应用程序将收到访问令牌。
-
客户端可以使用访问令牌开始访问受保护资源,例如,调用 REST API 端点。
在您选择了提供商并了解了其服务的工作方式后,您必须配置您的 Spring Boot 应用程序。Spring Boot 提供了 spring-boot-starter-oauth2-client 依赖项,用于 OAuth2 认证和授权。它用于简化 Spring Boot 应用程序中的 OAuth 2.0 集成。许多 OAuth 提供商为不同的技术提供了文档,例如 Spring Boot。
实现将取决于提供商。以下是一些有用的链接:
-
Auth0 提供了一个很好的教程,介绍如何将登录添加到您的 Spring Boot 应用程序中:
auth0.com/docs/quickstart/webapp/java-spring-boot/interactive. -
Baeldung 提供了使用 Spring Boot 应用程序与 Keycloak 一起使用的快速指南:
www.baeldung.com/spring-boot-keycloak. -
Spring 还有一个关于如何使用 GitHub 实现社交登录的教程:https://spring.io/guides/tutorials/spring-boot-oauth2。
我们建议阅读这些内容,以更好地了解如何在您的应用程序中使用 OAuth 2.0。
现在,我们已经使用 JWT 完成了后端的保护,当我们开始开发前端时,我们将使用这个版本。
摘要
在本章中,我们专注于使我们的 Spring Boot 后端更加安全。我们首先通过使用 Spring Security 添加额外的保护。然后,我们实现了 JWT 认证。JWT 通常用于保护 RESTful API,它是一种轻量级的认证方法,适合我们的需求。我们还介绍了 OAuth 2.0 标准的基础知识以及如何在 Spring Boot 应用程序中开始使用它。
在下一章中,我们将学习在 Spring Boot 应用程序中进行测试的基础知识。
问题
-
Spring Security 是什么?
-
如何使用 Spring Boot 保护您的后端?
-
JWT 是什么?
-
如何使用 JWT 保护您的后端?
-
OAuth 2.0 是什么?
进一步阅读
Packt 为您提供了其他资源,以了解 Spring Security。例如:
- 《Spring Security Core:从入门到精通》,作者:约翰·汤普森 (
www.packtpub.com/product/spring-security-core-beginner-to-guru-video/9781800560000)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第六章:测试您的后端
本章解释了如何测试您的 Spring Boot 后端。应用程序的后端负责处理业务逻辑和数据存储。适当的后端测试确保应用程序按预期工作,安全,并且更容易维护。我们将创建一些与我们的后端相关的单元和集成测试,以我们之前创建的数据库应用程序作为起点。
在本章中,我们将涵盖以下主题:
-
在 Spring Boot 中进行测试
-
创建测试用例
-
测试驱动开发
技术要求
我们在前面章节中创建的 Spring Boot 应用程序是必需的。
以下 GitHub 链接也将是必需的:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter06。
在 Spring Boot 中进行测试
当我们创建项目时,Spring Initializr 会自动将 Spring Boot 测试启动器包添加到 build.gradle 文件中。测试启动器依赖项可以在以下代码片段中看到:
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Spring Boot 测试启动器提供了许多方便的库用于测试,例如 JUnit、Mockito 和 AssertJ。Mockito 是一个常与测试框架如 JUnit 一起使用的模拟框架。AssertJ 是一个流行的库,用于在 Java 测试中编写断言。在本书中,我们将使用 JUnit 5。JUnit Jupiter 模块是 JUnit 5 的一部分,提供了更灵活的测试的注解。
如果您查看项目结构,您会看到它已经为测试类创建了自己的包:

图 6.1:测试类
默认情况下,Spring Boot 使用内存数据库进行测试。在本章中,我们使用 MariaDB,但如果我们向 build.gradle 文件中添加以下依赖项,我们也可以使用 H2 进行测试:
testRuntimeOnly 'com.h2database:h2'
这指定了 H2 数据库将仅用于运行测试;否则,应用程序将使用 MariaDB 数据库。
在您更新了 build.gradle 文件后,请记住在 Eclipse 中刷新您的 Gradle 项目。
现在,我们可以开始为我们的应用程序创建测试用例。
创建测试用例
软件测试有很多不同类型,每种类型都有自己的特定目标。一些最重要的测试类型包括:
-
单元测试:单元测试关注软件的最小组件。例如,这可能是一个函数,单元测试将确保它在隔离状态下正确工作。模拟在单元测试中经常被用来替换正在被测试的单元的依赖项。
-
集成测试:集成测试关注各个组件之间的交互,确保各个组件按预期协同工作。
-
功能测试:功能测试侧重于在功能规范中定义的业务场景。测试用例旨在验证软件是否符合指定的要求。
-
回归测试:回归测试旨在验证新代码或代码更新不会破坏现有功能。
-
可用性测试:可用性测试验证软件是否易于用户使用、直观且易于从最终用户的角度使用。可用性测试更侧重于前端和用户体验。
对于单元和集成测试,我们使用JUnit,这是一个流行的基于 Java 的单元测试库。Spring Boot 内置了对 JUnit 的支持,这使得编写应用程序的测试变得容易。
以下源代码展示了 Spring Boot 测试类的示例框架。@SpringBootTest注解指定该类是一个常规测试类,用于运行基于 Spring Boot 的测试。方法前的@Test注解指定 JUnit 该方法可以作为测试用例运行:
**@SpringBootTest**
public class MyTestsClass {
**@Test**
public void testMethod() {
// Test case code
}
}
单元测试中的断言是用于验证代码单元的实际输出是否与预期输出匹配的语句。在我们的案例中,断言是通过spring-boot-starter-test工件自动包含的AssertJ库实现的。AssertJ 库提供了一个assertThat()方法,您可以使用它来编写断言。您将对象或值传递给该方法,允许您比较值与实际断言。AssertJ 库包含多种针对不同数据类型的断言。下一个示例演示了一些示例断言:
// String assertion
assertThat("Learn Spring Boot").startsWith("Learn");
// Object assertion
assertThat(myObject).isNotNull();
// Number assertion
assertThat(myNumberVariable).isEqualTo(3);
// Boolean assertion
assertThat(myBooleanVariable).isTrue();
您可以在 AssertJ 文档中找到所有不同的断言:assertj.github.io/doc。
现在,我们将创建我们的初始单元测试用例,该测试用例检查我们的控制器实例是否正确实例化且不是null。按照以下步骤进行:
-
打开 Spring Initializr 启动项目为您的应用程序创建的
CardatabaseApplicationTests测试类。这里有一个名为contextLoads的测试方法,我们将在这里添加测试。编写以下测试,该测试检查控制器实例是否已成功创建和注入。我们使用 AssertJ 断言来测试注入的控制器实例不是null:package com.packt.cardatabase; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import com.packt.cardatabase.web.CarController; @SpringBootTest class CardatabaseApplicationTests { @Autowired private CarController controller; @Test void contextLoads() { assertThat(controller).isNotNull(); } }我们在这里使用字段注入,这对于测试类来说非常适合,因为您永远不会直接实例化测试类。您可以在 Spring 文档中了解更多关于测试固定依赖注入的信息:
docs.spring.io/spring-framework/reference/testing/testcontext-framework/fixture-di.html。 -
要在 Eclipse 中运行测试,请在项目资源管理器中激活测试类,然后右键单击。从菜单中选择运行方式 | JUnit 测试。现在,您应该在 Eclipse 工作台的下部看到JUnit标签页。测试结果将显示在此标签页中,测试用例已通过,如下面的截图所示:

图 6.2:JUnit 测试运行
-
您可以使用
@DisplayName注解为您的测试用例提供一个更具描述性的名称。在@DisplayName注解中定义的名称将在 JUnit 测试运行器中显示。以下代码片段展示了如何实现:@Test **@DisplayName("First example test case")** void contextLoads() { assertThat(controller).isNotNull(); }
现在,我们将为我们的所有者存储库创建集成测试,以测试创建、读取、更新和删除(CRUD)操作。此测试验证我们的存储库是否正确与数据库交互。想法是模拟数据库交互并验证您的存储库方法是否按预期行为:
-
在根测试包中创建一个名为
OwnerRepositoryTest的新类。如果测试专注于Jakarta Persistence API(JPA)组件,则可以使用@DataJpaTest注解代替@SpringBootTest注解。使用此注解时,H2 数据库和 Spring Data 将自动配置用于测试。SQL 日志记录也被打开。以下代码片段展示了如何实现:package com.packt.cardatabase; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import com.packt.cardatabase.domain.Owner; import com.packt.cardatabase.domain.OwnerRepository; @DataJpaTest class OwnerRepositoryTest { @Autowired private OwnerRepository repository; }在此示例中,我们使用根包为所有测试类命名,并逻辑地命名我们的类。或者,您可以为您的测试类创建与我们的应用程序类类似的包结构。
-
我们将添加第一个测试用例以测试将新所有者添加到数据库中。将以下查询添加到您的
OwnerRepository.java文件中。我们将在测试用例中使用此查询:Optional<Owner> findByFirstname(String firstName); -
使用
save方法创建一个新的Owner对象并将其保存到数据库中。然后,我们检查是否可以找到所有者。将以下测试用例方法代码添加到您的OwnerRepositoryTest类中:@Test void saveOwner() { repository.save(new Owner("Lucy", "Smith")); assertThat( repository.findByFirstname("Lucy").isPresent() ).isTrue(); } -
第二个测试用例将测试从数据库中删除所有者。创建一个新的
Owner对象并将其保存到数据库中。然后,从数据库中删除所有所有者,最后count()方法应返回零。以下源代码显示了测试用例方法。将以下方法代码添加到您的OwnerRepositoryTest类中:@Test void deleteOwners() { repository.save(new Owner("Lisa", "Morrison")); repository.deleteAll(); assertThat(repository.count()).isEqualTo(0); } -
运行测试用例并检查 Eclipse 的JUnit标签页,以确定测试是否通过。以下截图显示测试确实通过了:

图 6.3:存储库测试用例
接下来,我们将演示如何测试您的 RESTful Web 服务 JWT 身份验证功能。我们将创建一个集成测试,该测试向登录端点发送实际的 HTTP 请求并验证响应:
-
在根测试包中创建一个名为
CarRestTest的新类。为了测试控制器或任何公开的端点,我们可以使用一个MockMvc对象。通过使用MockMvc对象,服务器不会启动,但测试是在 Spring 处理 HTTP 请求的层中进行的,因此它模拟了真实情况。MockMvc提供了perform方法来发送这些请求。为了测试身份验证,我们必须在请求体中添加凭据。我们使用andDo()方法将请求和响应的详细信息打印到控制台。最后,我们使用andExpect()方法检查响应状态是否为Ok。代码在下面的代码片段中展示:package com.packt.cardatabase; import static org.springframework.test.web.servlet. request.MockMvcRequestBuilders.post; import static org.springframework.test.web. servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest @AutoConfigureMockMvc class CarRestTest { @Autowired private MockMvc mockMvc; @Test public void testAuthentication() throws Exception { // Testing authentication with correct credentials this.mockMvc .perform(post("/login") .content("{\"username\":\"admin\",\"password\"" +":\"admin\"}") .header(HttpHeaders.CONTENT_TYPE,"application/json")) .andDo(print()).andExpect(status().isOk()); } } -
现在,当我们运行身份验证测试时,我们会看到测试通过,如下面的截图所确认的:

图 6.4:登录测试
- 您可以通过从项目资源管理器中选择测试包并运行 JUnit 测试(运行方式 | JUnit 测试)来一次性运行所有测试。在下面的图片中,您可以查看所有测试用例都通过的结果:

图 6.5:运行测试
使用 Gradle 进行测试
当您使用 Gradle 构建项目时,所有测试都会自动运行。我们将在本书的后面更详细地介绍构建和部署。在本节中,我们只介绍一些基础知识:
- 您可以使用 Eclipse 运行不同的预定义 Gradle 任务。打开窗口 | 显示视图 | 其他…菜单。这会打开显示视图窗口,在那里您应该选择Gradle 任务:

图 6.6:Gradle 任务
-
您应该看到 Gradle 任务列表,如下面的图片所示。打开
build文件夹,双击build任务来运行它:![]()
图 6.7:构建任务
Gradle 构建任务在您的项目中创建一个
build文件夹,Spring Boot 项目在这里构建。构建过程会运行项目中的所有测试。如果任何测试失败,构建过程也会失败。构建过程会创建一个测试摘要报告(一个index.html文件),您可以在build\reports\tests\test文件夹中找到它。如果您的任何测试失败,您可以从摘要报告中找到原因。在下面的图片中,您可以看到一个测试摘要报告的示例:![]()
图 6.8:测试摘要
-
构建任务在
\build\libs文件夹中创建一个可执行的jar文件。现在,您可以在\build\libs文件夹中使用以下命令运行构建的 Spring Boot 应用程序(您应该已经安装了 JDK):java -jar .\cardatabase-0.0.1-SNAPSHOT.jar
现在,您可以为您 Spring Boot 应用程序编写单元和集成测试。您也已经学会了如何使用 Eclipse IDE 运行测试。
测试驱动开发
测试驱动开发(TDD)是一种软件开发实践,其中在编写实际代码之前先编写测试。其理念是确保您的代码满足设定的标准或要求。让我们看看 TDD 在实际中是如何工作的一个例子。
我们的目标是实现一个服务类,用于管理我们应用程序中的消息。你可以在下面看到测试驱动开发(TDD)的常见步骤:
以下代码并不完全功能。它只是为你更好地了解测试驱动开发(TDD)过程的一个示例。
-
首先要实现的功能是添加新消息的服务。因此,在测试驱动开发(TDD)中,我们将为向消息列表添加新消息创建一个测试用例。在测试代码中,我们首先创建消息服务类的实例。然后,我们创建一个我们想要添加到列表中的测试消息。我们调用
messageService实例的addMsg方法,并将msg作为参数传递。此方法负责将消息添加到列表中。最后,断言检查添加到列表中的消息是否与预期的消息"Hello world"匹配:import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest public class MessageServiceTest { @Test public void testAddMessage() { MessageService messageService = new MessageService(); String msg = "Hello world"; Message newMsg = messageService.addMsg(msg); assertEquals(msg, newMsg.getMessage()); } } -
现在,我们可以运行测试。它应该失败,因为我们还没有实现我们的服务。
-
接下来,我们将实现
MessageService,它应该包含我们在测试用例中测试的addMsg()函数:@Service public class MessageService { private List<Message> messages = new ArrayList<>(); public Message addMsg(String msg) { Message newMsg = new Message(msg); messages.add(newMSg); return newMsg; } } -
现在,如果你再次运行测试,如果代码按预期工作,它应该通过。
-
如果测试未通过,你应该重构你的代码,直到它通过。
-
为每个新功能重复这些步骤。
测试驱动开发是一个迭代过程,有助于确保你的代码能够正常工作,并且新功能不会破坏软件的其他部分。这也被称为回归测试。通过在实现功能之前编写测试,我们可以在开发阶段早期捕捉到错误。开发者应该在实际开发之前理解功能需求和预期结果。
到目前为止,我们已经涵盖了 Spring Boot 应用程序测试的基础知识,并且你已经获得了实施更多测试用例所需的知识。
摘要
在本章中,我们专注于测试 Spring Boot 后端。我们使用了 JUnit 进行测试,并实现了针对 JPA 和 RESTful Web 服务认证的测试用例。我们为我们的所有者存储库创建了一个测试用例,以验证存储库方法是否按预期行为。我们还通过使用我们的 RESTful API 测试了认证过程。请记住,测试是整个开发生命周期中的一个持续过程。当你的应用程序发展时,你应该更新和添加测试用例以覆盖新功能和变更。测试驱动开发是实现这一目标的一种方法。
在下一章中,我们将设置与前端开发相关的环境和工具。
问题
-
如何使用 Spring Boot 创建单元测试?
-
单元测试和集成测试之间的区别是什么?
-
如何运行和检查单元测试的结果?
-
什么是测试驱动开发(TDD)?
进一步阅读
有许多其他优秀的资源可以帮助你学习关于 Spring Security 和测试的知识。这里列出了一些:
-
《Java 开发者的 JUnit 和 Mockito 单元测试》,作者 Matthew Speake (
www.packtpub.com/product/junit-and-mockito-unit-testing-for-java-developers-video/9781801078337) -
《使用 JUnit 5 掌握软件测试》,作者 Boni García (
www.packtpub.com/product/mastering-software-testing-with-junit-5/9781787285736) -
《赫尔辛基大学的 Java 编程 MOOC:测试简介》,作者 赫尔辛基大学 (
java-programming.mooc.fi/part-6/3-introduction-to-testing) -
《使用 Spring Boot 和 Mockito 掌握 Java 单元测试》,作者 In28Minutes Official (
www.packtpub.com/product/master-java-unit-testing-with-spring-boot-and-mockito-video/9781789346077)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第二部分
使用 React 进行前端编程
第七章:设置环境和工具 – 前端
本章描述了开发 React 所需的开发环境和工具,以便您可以开始前端开发。在本章中,我们将使用 Vite 前端工具创建一个简单的入门级 React 应用。
本章我们将涵盖以下主题:
-
安装 Node.js
-
安装 Visual Studio Code
-
创建和运行一个 React 应用
-
调试 React 应用
技术要求
下面的 GitHub 链接将是必需的:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter07。
安装 Node.js
Node.js 是一个开源的、基于 JavaScript 的服务器端环境。它适用于多个操作系统,如 Windows、macOS 和 Linux,并且是开发 React 应用所必需的。
Node.js 安装包可以在 nodejs.org/en/download/ 找到。为您的操作系统下载最新的 长期支持 (LTS) 版本。在本书中,我们使用的是 Windows 10 操作系统,您可以获取适用于它的 Node.js MSI 安装程序,这使得安装变得非常简单。
当您运行安装程序时,您将进入安装向导,您可以使用默认设置进行操作:

图 7.1:Node.js 安装
安装完成后,我们可以检查一切是否按预期进行。打开 PowerShell 或您正在使用的任何终端,并输入以下命令:
node --version
npm --version
这些命令应显示已安装的 Node.js 和 npm 版本:

图 7.2:Node.js 和 npm 版本
npm 随 Node.js 安装而来,是 JavaScript 的包管理器。在接下来的章节中,当我们为 React 应用安装不同的 Node.js 模块时,我们会大量使用它。
还有一个名为 Yarn 的包管理器,您也可以使用,但我们将使用 npm,因为它随 Node.js 安装而来。Yarn 有一些优点,例如由于其缓存机制而具有更好的整体性能。
接下来,我们将安装一个代码编辑器。
安装 Visual Studio Code
Visual Studio Code (VS Code) 是一个开源的多语言代码编辑器。它由微软开发。有许多不同的代码编辑器可供选择,例如 Atom 和 Sublime,如果您熟悉其他编辑器,您可以使用其他编辑器。
我们用于后端开发的 Eclipse,针对 Java 开发进行了优化。VS Code 也可以用于 Java 和 Spring Boot 开发,因此如果您愿意,可以使用一个编辑器同时进行后端和前端开发。
VS Code 可用于 Windows、macOS 和 Linux,您可以从 code.visualstudio.com/ 下载它。Windows 的安装使用 MSI 安装程序完成,并且您可以使用默认设置执行安装。
以下截图显示了 VS Code 的工作台。在左侧是活动栏,您可以使用它在不同视图之间导航。活动栏旁边是一个侧边栏,其中包含不同的视图,例如项目文件资源管理器。编辑器占据了工作台的大部分空间:

图 7.3:VS Code 工作台
VS Code 提供了一个集成的终端,您可以使用它创建和运行 React 应用程序。终端可以在 视图 | 终端 菜单中找到。您可以在后面的章节中创建更多 React 应用程序时使用它。
VS Code 扩展
可用于不同编程语言和框架的扩展有很多。如果您从活动栏打开 扩展,您可以搜索不同的扩展。
一个对 React 开发非常有用的扩展是 Reactjs 代码片段,我们推荐您安装。它为 React.js 应用程序提供了多个代码片段,这使得开发过程更快。VS Code 代码片段扩展可以通过节省时间、提高一致性和减少错误来显著提高您的效率。
以下截图显示了 Reactjs 代码片段的安装页面:

图 7.4:React js 代码片段
ESLint 扩展帮助您快速查找拼写错误和语法错误,并使格式化源代码变得更容易:

图 7.5:ESLint 扩展
ESLint (eslint.org/) 是一个开源的 JavaScript 代码检查工具,它帮助您在源代码中查找和修复问题。ESLint 可以在 VS Code 编辑器中直接突出显示错误和警告,帮助您在编写代码时识别和修复问题。错误和警告以红色或黄色下划线显示,并且当您将鼠标悬停在这些行上时,您可以看到有关特定错误或警告的信息。VS Code 还提供了一个 问题 面板,显示所有 ESLint 错误和警告。ESLint 是灵活的,并且可以使用 .eslintrc 文件进行配置。您可以定义哪些规则被启用以及错误级别。
Prettier 是一个代码格式化工具。使用 Prettier 扩展,您可以获得自动代码格式化:

图 7.6:Prettier 扩展
您可以在 VS Code 中设置它,以便在保存后自动格式化代码,方法是转到 文件 | 首选项 菜单中的 设置,然后搜索 保存时格式化。
这些只是您可以为 VS Code 获取的众多优秀扩展的几个示例。我们建议您安装所有这些扩展并亲自测试它们。
在下一节中,我们将创建我们的第一个 React 应用程序,并学习如何运行和修改它。
创建和运行 React 应用程序
现在我们已经安装了 Node.js 和我们的代码编辑器,我们准备好创建我们的第一个 React.js 应用了。我们将使用Vite前端工具(vitejs.dev/)来完成这项工作。有很好的 React 框架可用,如 Next.js 或 Remix,也可以使用,但 Vite 是学习 React 基础知识的好选择。Vite 提供了一个非常快的开发服务器,您不需要进行任何复杂的配置就可以开始编码。
在过去,Create React App(CRA)是创建 React 项目的最流行工具,但其使用量已下降,并且官方文档不再推荐使用它。Vite 相对于 CRA 提供了许多优势(例如其更快的开发服务器)。
本书使用 Vite 版本 4.3。如果您使用的是其他版本,请根据 Vite 文档验证命令。此外,检查 Node.js 版本要求,并在包管理器警告您的情况下升级您的 Node.js 安装。
以下是您需要遵循的步骤,以使用 Vite 创建您的第一个 React 项目:
-
打开 PowerShell 或您正在使用的另一个终端,并将文件夹移动到您想要创建项目的地方。
-
输入以下
npm命令,它使用 Vite 的最新版本:npm create vite@latest要使用本书中使用的相同的 Vite 主版本,您也可以在命令中指定 Vite 版本:
npm create vite@4.3命令启动项目创建向导。如果您是第一次创建 Vite 项目,您将收到一条消息,提示您安装
create-vite包。按y继续。 -
在第一阶段,输入您的项目名称——在本例中为
myapp:

图 7.7:项目名称
- 然后,您将选择一个框架。在这个阶段,选择React框架。请注意,Vite 并不局限于 React,也可以用于启动许多不同的前端框架:

图 7.8:框架选择
-
在最后一步,您将选择一个变体。我们首先将学习使用 JavaScript 的 React 基础知识,然后转向 TypeScript。因此,在这个阶段,我们将选择JavaScript:
图 7.9:项目变体SWC(快速 Web 编译器)是一个用 Rust 编写的快速 JavaScript 和 TypeScript 编译器。它是一个比通常使用的 Babel 更快的替代品。
-
一旦创建了应用,请进入您的应用文件夹:
cd myapp -
然后,使用以下命令安装依赖项:
npm install -
最后,使用以下命令运行您的应用,该命令以开发模式启动应用:
npm run dev现在,您应该在终端中看到以下消息:
![]()
图 7.10:运行您的项目
-
打开您的浏览器,导航到终端中显示的Local:文本后面的 URL(在示例中为
http://localhost:5173/,但您的可能不同):

图 7.11:React 应用
- 您可以通过在终端中按q来停止开发服务器。
要为生产环境构建您应用的压缩版本,您可以使用npm run build命令,该命令将在构建文件夹中构建您的应用。我们将在第十七章 部署您的应用 中更详细地讨论部署。
修改 React 应用
现在,我们将学习如何使用 Vite 创建的 React 应用进行修改。我们将使用之前安装的 VS Code:
-
使用 VS Code 通过选择 文件 | 打开文件夹 来打开您的 React 项目文件夹。您应该在文件资源管理器中看到应用的架构。在这个阶段,最重要的文件夹是
src文件夹,其中包含 JavaScript 源代码:![]()
图 7.12:项目结构
您也可以通过在终端中输入
code .命令来打开 VS Code。此命令将打开 VS Code 以及您所在的文件夹。 -
在代码编辑器中打开
src文件夹中的App.jsx文件。将<h1>元素内的文本修改为Hello React并保存文件。您目前不需要了解此文件的其他内容。我们将在第八章 React 入门 中更深入地探讨此主题:

图 7.13:App.js 代码
- 现在,如果您查看浏览器,应该会立即看到标题文本已更改。Vite 提供了 热模块替换 (HMR) 功能,当您在 React 项目中修改其源代码或样式时,它会自动更新 React 组件,无需手动刷新页面:

图 7.14:修改后的 React 应用
调试 React 应用
为了调试 React 应用,我们还应该安装 React 开发者工具,它适用于 Chrome、Firefox 和 Edge 浏览器。Chrome 插件可以从 Chrome 网上应用店安装(chrome.google.com/webstore/category/extensions),而 Firefox 扩展插件可以从 Firefox 扩展网站安装(addons.mozilla.org)。安装 React 开发者工具后,当您导航到您的 React 应用时,您应该在浏览器开发者工具中看到一个新的 组件 选项卡。
您可以通过在 Chrome 浏览器中按 Ctrl + Shift + I(或 F12)来打开开发者工具。以下截图显示了浏览器中的开发者工具。组件选项卡显示了 React 组件树的视觉表示,您可以使用搜索栏来查找组件。如果您在组件树中选择一个组件,您将在右侧面板中看到更多关于它的具体信息:

图 7.15:React 开发者工具
我们将看到浏览器的开发者工具非常重要,在开发期间打开它们非常有用,这样您可以立即看到错误和警告。开发者工具中的控制台是您可以从 JavaScript 或 TypeScript 代码中记录消息、警告和错误的地方。网络选项卡显示了网页发出的所有请求,包括它们的状态码、响应时间和内容。这对于优化您的 Web 应用性能和诊断网络相关问题是很有帮助的。
摘要
在本章中,我们安装了启动 React 前端开发所需的一切。首先,我们安装了 Node.js 和 VS Code 编辑器。然后,我们使用 Vite 创建了我们的第一个 React 应用。最后,我们运行了该应用,演示了如何修改它,并介绍了调试工具。在接下来的章节中,我们将继续使用 Vite。
在下一章中,我们将熟悉 React 编程的基础知识。
问题
-
Node.js 和
npm是什么? -
如何安装 Node.js?
-
什么是 VS Code?
-
如何安装 VS Code?
-
如何使用 Vite 创建 React 应用?
-
如何运行 React 应用?
-
如何对您的应用进行基本修改?
进一步阅读
这里有一些有用的资源,可以帮助我们扩展本章学到的知识:
-
React 18 设计模式和最佳实践,由 Carlos Santana Roldán 编著 (
www.packtpub.com/product/react-18-design-patterns-and-best-practices-fourth-edition/9781803233109) -
JavaScript 在 Visual Studio Code 中,由微软提供 (
code.visualstudio.com/docs/languages/javascript) -
TypeScript 在 Visual Studio Code 中,由微软提供 (
code.visualstudio.com/docs/languages/typescript)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第八章:React 入门
本章描述了 React 编程的基础。我们将涵盖创建 React 前端基本功能所需的技术。在 JavaScript 中,我们使用 ECMAScript 2015(ES6)语法,因为它提供了许多使编码更简洁的功能。
在本章中,我们将探讨以下主题:
-
创建 React 组件
-
有用的 ES6 功能
-
JSX 和样式
-
Props 和状态
-
条件渲染
-
React 钩子
-
上下文 API
-
使用 React 处理列表、事件和表单
技术要求
对于我们的工作,需要使用 React 版本 18 或更高版本。我们在第七章中正确设置了我们的环境。
您可以在本章的 GitHub 链接中找到更多资源:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter08。
创建 React 组件
React 是一个用于用户界面(UI)的 JavaScript 库。自 15 版本以来,React 一直在 MIT 许可下开发。React 是基于组件的,组件是独立且可重用的。组件是 React 的基本构建块。当你开始使用 React 开发 UI 时,最好先创建模拟界面。这样,将很容易确定你需要创建哪些类型的组件以及它们如何交互。
从以下模拟用户界面中,我们可以看到 UI 可以如何拆分为组件。在这种情况下,将有一个应用程序根组件、一个搜索栏组件、一个表格组件和一个表格行组件:

图 8.1:React 组件
组件可以按照以下截图所示的树形层次结构进行排列:

图 8.2:组件树
根组件有两个子组件:搜索组件和表格组件。表格组件有一个子组件:表格行组件。在 React 中需要理解的重要一点是数据流是从父组件流向子组件。我们将在稍后学习如何使用 props 从父组件传递数据到子组件。
React 使用 虚拟文档对象模型(VDOM)进行 UI 的选择性重新渲染,这使得它更加高效。文档对象模型(DOM)是用于表示网页为结构化对象树的编程接口。树中的每个对象都对应文档的一部分。使用 DOM,程序员可以创建文档、导航其结构,并添加、修改或删除元素和内容。VDOM 是 DOM 的轻量级副本,对 VDOM 的操作比在真实 DOM 中要快得多。在更新 VDOM 之后,React 会将其与在更新之前拍摄的 VDOM 快照进行比较。比较之后,React 将知道哪些部分已经更改,并且只有这些部分会被更新到真实 DOM。
一个 React 组件可以通过使用 JavaScript 函数——一个 函数组件——或者 ES6 JavaScript 类——一个 类组件来定义。我们将在下一节更深入地了解 ES6。
这里有一些简单的组件源代码,用于渲染 Hello World 文本。第一个代码块使用 JavaScript 函数:
// Using JavaScript function
function App() {
return <h1>Hello World</h1>;
}
React 函数组件中必须的 return 语句定义了组件的外观。
或者,以下代码使用 ES6 类来创建一个组件:
// Using ES6 class
class App extends React.Component {
render() {
return <h1>Hello World</h1>;
}
}
类组件包含必要的 render() 方法,该方法显示并更新组件的渲染输出。如果您比较函数和类 App 组件,您会看到在函数组件中不需要 render() 方法。在 React 版本 16.8 之前,您必须使用类组件才能使用状态。现在,您也可以使用 hooks 在函数组件中创建状态。我们将在本章后面学习状态和 hooks。
在这本书中,我们将使用函数创建组件,这意味着我们需要编写更少的代码。函数组件是编写 React 组件的现代方式,我们建议避免使用类。
React 组件的名称应该以大写字母开头。还建议使用 PascalCase 命名约定,即每个单词都以大写字母开头。
假设我们正在修改示例组件的 return 语句,并向其中添加一个新的 <h2> 元素,如下所示:
function App() {
return (
<h1>Hello World</h1>
<h2>This is my first React component</h2>
);
}
现在,如果运行应用程序,我们将看到一个 相邻 JSX 元素必须包裹在封装标签中的错误,如下面的屏幕截图所示:

图 8.3:相邻 JSX 元素错误
如果您的组件返回多个元素,您必须将这些元素包裹在一个父元素内部。为了修复这个错误,我们必须将标题元素包裹在一个元素中,例如一个 div,如下面的代码片段所示:
// Wrap elements inside the div
function App() {
return (
<div>
<h1>Hello World</h1>
<h2>This is my first React component</h2>
</div>
);
}
我们还可以使用 React 片段,如下面的代码片段所示。片段不会向 DOM 树添加任何额外的节点:
// Using fragments
function App() {
return (
**<React.Fragment>**
<h1>Hello World</h1>
<h2>This is my first React component</h2>
<**/React.Fragment**>
);
}
对于片段,也有更短的语法,它看起来像空的 JSX 标签。这在上面的代码片段中显示:
// Using fragments short syntax
function App() {
return (
<>
<h1>Hello World</h1>
<h2>This is my first React component</h2>
</>
);
}
检查我们的第一个 React 应用
让我们更仔细地看看我们在 第七章 中使用 Vite 创建的第一个 React 应用,设置环境和工具 – 前端。
根文件夹中的 main.jsx 文件的源代码如下:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
在文件的开头有一些 import 语句,它们将组件和资产加载到我们的文件中。例如,第二行从 node_modules 文件夹中导入 react-dom 包,第三行导入 App 组件(位于 src 文件夹中的 App.jsx 文件),第四行导入与 main.jsx 文件相同的文件夹中的 index.css 样式表。
react-dom 包为我们提供了特定的 DOM 方法。要将 React 组件渲染到 DOM 中,我们可以使用来自 react-dom 包的 render 方法。React.StrictMode 用于在您的 React 应用中查找潜在问题,这些问题会在浏览器控制台中打印出来。严格模式仅在开发模式下运行,并且会额外渲染您的组件,因此它有足够的时间来查找错误。
root API 用于在浏览器 DOM 节点内渲染 React 组件。在以下示例中,我们首先通过将 DOM 元素传递给 createRoot 方法来创建一个根。根调用 render 方法将一个元素渲染到根:
import ReactDOM from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
// Create a root
const root = ReactDOM.createRoot(container);
// Render an element to the root
root.render(<App />);
根 API 中的 container 是 <div id="root"></div> 元素,它位于项目根文件夹内的 index.html 文件中。查看以下 index.html 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
**<****div****id****=****"root"****></****div****>**
<script type="module" src="img/main.jsx"></script>
</body>
</html>
以下源代码显示了我们的第一个 React 应用中的 App.jsx 组件。您可以看到 import 也适用于资产,如图片和样式表。在源代码的末尾有一个 export default 语句,它导出组件,并且可以通过使用 import 语句使其对其他组件可用:
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Hello React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</div>
)
}
export default App
您可以看到,在 Vite 创建的 App 组件中,我们没有在语句末尾使用分号。在 JavaScript 中这是可选的,但在这本书中,当我们开始创建自己的 React 组件时,我们将使用分号来终止语句。
每个文件只能有一个 export default 语句,但可以有多个命名的 export 语句。默认导出通常用于导出 React 组件。命名导出通常用于从模块中导出特定的函数或对象。
以下示例显示了如何导入默认和命名的导出:
import React from 'react' // Import default value
import { name } from … // Import named value
导出看起来是这样的:
export default React // Default export
export { name } // Named export
现在我们已经涵盖了 React 组件的基础知识,让我们来看看 ES6 的基本特性。
有用的 ES6 特性
ES6 于 2015 年发布,并引入了许多新特性。ECMAScript 是一种标准化的脚本语言,而 JavaScript 是其一种实现。在本节中,我们将介绍 ES6 中发布的最重要特性,这些特性将在接下来的章节中使用。
常量和变量
const keyword, the variable content cannot be reassigned:
const PI = 3.14159;
现在,如果您尝试重新分配 PI 值,将会得到错误,如下面的截图所示:

图 8.4:向常量变量赋值
const 是块级作用域的。这意味着 const 变量只能在其定义的块内部使用。在实践中,块是花括号 {} 之间的区域。如果 const 在任何函数或块外部定义,它就变成了全局变量,您应尽量避免这种情况。全局变量会使代码更难以理解、维护和调试。以下示例代码展示了作用域是如何工作的:
let count = 10;
if (count > 5) {
const total = count * 2;
console.log(total); // Prints 20 to console
}
console.log(total); // Error, outside the scope
第二个 console.log 语句会报错,因为我们试图在作用域之外使用 total 变量。
以下示例演示了当 const 是对象或数组时会发生什么:
const myObj = {foo: 3};
myObj.foo = 5; // This is ok
当 const 是对象或数组时,其属性或元素可以被更新。
let 关键字允许您声明可变块级作用域变量。使用 let 声明的变量可以在其声明的块内部使用(也可以在子块内部使用)。
箭头函数
在 JavaScript 中定义函数的传统方式是使用 function 关键字。以下函数接受一个参数,并返回该参数值乘以 2:
function(x) {
return x * 2;
}
当我们使用 ES6 箭头函数时,函数看起来是这样的:
x => x * 2
如我们所见,通过使用箭头函数,我们使相同函数的声明更加紧凑。该函数是一个所谓的匿名函数,我们无法调用它。匿名函数通常用作其他函数的参数。在 JavaScript 中,函数是一等公民,您可以将函数存储在变量中,如下所示:
const calc = x => x * 2
现在,您可以使用变量名来调用函数,如下所示:
calc(5); // returns 10
当您有多个参数时,您必须将参数包裹在括号中,并用逗号分隔参数,以有效地使用箭头函数。例如,以下函数接受两个参数,并返回它们的和:
const calcSum = (x, y) => x + y
// function call
calcSum(2, 3); // returns 5
如果函数体是一个表达式,那么您不需要使用 return 关键字。表达式总是隐式地从函数中返回。当函数体有多行时,您必须使用花括号和一个 return 语句,如下所示:
const calcSum = (x, y) => {
console.log('Calculating sum');
return x + y;
}
如果函数没有参数,那么您应该使用空括号,如下所示:
const sayHello = () => "Hello"
我们将在我们的前端实现中大量使用箭头函数。
模板字符串
模板字符串可以用于连接字符串。连接字符串的传统方式是使用 + 运算符,如下所示:
let person = {firstName: 'John', lastName: 'Johnson'};
let greeting = "Hello " + ${person.firstName} + " " + ${person.lastName};
使用模板字符串时,语法如下。您必须使用反引号(`)而不是单引号或双引号:
let person = {firstName: 'John', lastName: 'Johnson'};
let greeting = `Hello ${person.firstName} ${person.lastName}`;
接下来,我们将学习如何使用对象解构。
对象解构
对象解构功能允许你从对象中提取值并将它们分配给变量。你可以使用单个语句将对象的多个属性分配给单个变量。例如,如果你有这个对象:
const person = {
firstName: 'John',
lastName: 'Johnson',
email: 'j.johnson@mail.com'
};
你可以使用以下语句对其进行解构:
const { firstName, lastName, email } = person;
它创建了三个变量,firstName、lastName 和 email,它们从 person 对象中获取它们的值。
没有对象解构的情况下,你必须单独访问每个属性,如下面的代码片段所示:
const firstName = person.firstName;
const lastName = person.lastName;
const email = person.email;
接下来,我们将学习如何使用 JavaScript ES6 语法创建类。
类和继承
ES6 中的类定义与其他面向对象的语言(如 Java 或 C#)类似。我们在之前查看如何创建 React 类组件时已经看到了 ES6 类。但是,正如我们之前所说的,类不再推荐用于创建 React 组件。
定义类的关键字是 class。一个类可以有字段、构造函数和类方法。以下示例代码展示了 ES6 类:
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
继承是通过 extends 关键字实现的。以下示例代码展示了继承自 Person 类的 Employee 类。这意味着它继承了父类的所有字段,并且可以有自己的特定于 Employee 的字段。在构造函数中,我们首先使用 super 关键字调用父类构造函数。这个调用对于其余的代码是必需的,如果它缺失,你会得到一个错误:
class Employee extends Person {
constructor(firstName, lastName, title, salary) {
super(firstName, lastName);
this.title = title;
this.salary = salary;
}
}
尽管 ES6 已经相当老了,但其中一些功能在现代网络浏览器中仍然只部分受支持。Babel 是一个 JavaScript 编译器,用于将 ES6(或更新的版本)编译成与所有浏览器兼容的旧版本。你可以在 Babel 网站上测试编译器(babeljs.io)。以下截图显示了箭头函数编译回旧版 JavaScript 语法:

图 8.5:Babel
现在我们已经了解了 ES6 的基础知识,让我们来看看 JSX 和样式都是些什么。
JSX 和样式
JavaScript XML(JSX)是 JavaScript 的语法扩展。使用 JSX 与 React 不是强制性的,但有一些使开发更简单的优点。例如,JSX 防止注入攻击,因为所有值在 JSX 渲染之前都会被转义。最有用的功能是,你可以通过用花括号包裹它们来在 JSX 中嵌入 JavaScript 表达式;这种技术将在接下来的章节中大量使用。JSX 由 Babel 编译成常规 JavaScript。
在以下示例中,我们可以在使用 JSX 时访问组件的 props:
function App(props) {
return <h1>Hello World **{props.user}**</h1>;
}
组件 props 将在下一节中介绍。
你也可以像以下代码片段所示那样将 JavaScript 表达式作为 props 传递:
<Hello count=**{****2****+****2****}** />
你可以使用内联和外部样式与 React JSX 元素一起使用。以下有两个内联样式的示例。第一个在 div 元素内部定义了样式:
<div style={{ height: 20, width: 200 }}>
Hello
</div>
第二个示例首先创建一个样式对象,然后在该 div 元素中使用。对象名称应使用 camelCase 命名约定:
const divStyle = { color: 'red', height: 30 };
const MyComponent = () => (
<div style={divStyle}>Hello</div>
);
如前一个部分所示,你可以将样式表导入到 React 组件中。要引用外部 CSS 文件中的类,你应该使用 className 属性,如下面的代码片段所示:
import './App.js';
...
<div **className**="App-header"> This is my app</div>
在下一节中,我们将学习关于 React props 和 state 的内容。
Props 和 state
Props 和 state 是渲染组件的输入数据。当 props 或 state 发生变化时,组件会重新渲染。
Props
Props 是组件的输入,它们是传递数据从父组件到子组件的机制。Props 是 JavaScript 对象,因此它们可以包含多个键值对。
Props 是不可变的,因此组件不能改变其 props。Props 是从父组件接收的。组件可以通过作为参数传递给函数组件的 props 对象来访问 props。例如,让我们看一下以下组件:
function Hello() {
return <h1>Hello John</h1>;
}
组件仅渲染一个静态消息,并且不可复用。我们可以通过使用 props 将一个名字传递给 Hello 组件,而不是使用硬编码的名字,如下所示:
function Hello**(props)** {
return <h1>Hello **{props.user}**</h1>;
}
父组件可以通过以下方式将 props 发送到 Hello 组件:
<Hello user="John" />
现在,当 Hello 组件被渲染时,它显示 Hello John 文本。
你也可以向组件传递多个 props,如下所示:
<Hello firstName="John" lastName="Johnson" />
现在,你可以使用 props 对象访问组件中的这两个 props,如下所示:
function Hello(props) {
return <h1>Hello {props.firstName} {props.lastName}</h1>;
}
现在,组件输出为 Hello John Johnson。
你也可以使用对象解构以以下方式解构 props 对象:
function Hello({ firstName, lastName }) {
return <h1>Hello {firstName} {lastName}</h1>;
}
State
在 React 中,组件 state 是一个内部数据存储,它持有随时间变化的信息。状态还影响组件的渲染。当状态更新时,React 会安排组件的重新渲染。当组件重新渲染时,状态保留其最新的值。状态允许组件动态且对用户交互或其他事件做出响应。
通常,避免在 React 组件中引入不必要的状态是一个好的实践。不必要的状态会增加组件的复杂性,并可能导致不期望的副作用。有时,局部变量可能是一个更好的选择。但你要明白,局部变量的更改不会触发重新渲染。每次组件重新渲染时,局部变量都会被重新初始化,它们的值在渲染之间不会持久化。
使用 useState 钩子函数创建状态。它接受一个参数,即状态的初始值,并返回一个包含两个元素的数组。第一个元素是状态的名字,第二个元素是一个用于更新状态值的函数。useState 函数的语法在下面的代码片段中展示:
const [state, setState] = React.useState(initialValue);
下一个代码示例创建了一个名为 name 的状态变量,其初始值为 Jim:
const [name, setName] = React.useState('Jim');
您也可以像这样从 React 导入useState函数:
import React, { useState } from 'react';
然后,您不需要像这里所示的那样键入React关键字:
const [name, setName] = useState('Jim');
现在,您可以使用setName函数来更新状态值,如下面的代码片段所示。这是修改状态值的唯一方法:
// Update name state value
setName('John');
您绝对不应该直接使用=运算符来更新状态值。如果您直接更新状态,如以下所示,React 将不会重新渲染组件,您也会得到一个错误,因为您不能重新分配const变量:
// Don't do this, UI won't re-render
name = 'John';
如果您有多个状态,您可以多次调用useState函数,如下面的代码片段所示:
// Create two states: firstName and lastName
const [firstName, setFirstName] = useState('John');
const [lastName, setLastName] = useState('Johnson');
现在,您可以使用setFirstName和setLastName函数来更新状态,如下面的代码片段所示:
// Update state values
setFirstName('Jim');
setLastName('Palmer');
您也可以使用对象来定义状态,如下所示:
const [name, setName] = useState({
firstName: 'John',
lastName: 'Johnson'
});
现在,您可以使用setName函数更新firstName和lastName状态对象参数,如下所示:
setName({ firstName: 'Jim', lastName: 'Palmer' })
如果您想对对象进行部分更新,可以使用展开运算符。在下面的示例中,我们使用了在 ES2018 中引入的对象展开语法(...),它克隆了name状态对象并将firstName的值更新为Jim:
setName({ ...name, firstName: 'Jim' })
可以通过使用状态名称来访问状态,如下一个示例所示。状态的范围是组件,因此它不能在定义它的组件外部使用:
// Renders Hello John
import React, { useState } from 'react';
function MyComponent() {
const [firstName, setFirstName] = useState('John');
return <div>Hello **{firstName}**</div>;
}
如果您的状态是一个对象,那么您可以按以下方式访问它:
const [name, setName] = useState({
firstName: 'John',
lastName: 'Johnson'
});
return <div>Hello **{name.firstName}**</div>;
我们现在已经学习了状态和属性的基础知识,我们将在本章的后面部分学习更多关于状态的内容。
无状态组件
React 的无状态组件只是一个接受 props 作为参数并返回 React 元素的纯 JavaScript 函数。以下是一个无状态组件的示例:
function HeaderText(props) {
return (
<h1>
{props.text}
</h1>
)
}
export default HeaderText;
memo():
import React, { memo } from 'react';
function HeaderText(props) {
return (
<h1>
{props.text}
</h1>
)
}
export default memo(HeaderText);
现在,组件已被渲染并缓存。在下一次渲染中,如果属性没有改变,React 将渲染缓存的结果。React.memo()短语还有一个第二个参数arePropsEqual(),您可以使用它来自定义渲染条件,但这里我们不会涉及。使用函数组件的一个好处是单元测试,因为它们的返回值对于相同的输入值始终相同。
条件渲染
如果条件为true或false,您可以使用条件语句来渲染不同的 UI。这个特性可以用来显示或隐藏某些元素,处理身份验证等等。
在下面的示例中,我们将检查props.isLoggedin是否为true。如果是这样,我们将渲染<Logout />组件;否则,我们将渲染<Login />组件。现在这是通过两个单独的return语句实现的:
function MyComponent(props) {
const isLoggedin = props.isLoggedin;
if (isLoggedin) {
return (
<Logout />
)
}
return (
<Login />
)
}
您也可以通过使用condition ? true : false逻辑运算符来实现这一点,然后您只需要一个return语句,如下所示:
function MyComponent(props) {
const isLoggedin = props.isLoggedin;
return (
<>
{ isLoggedin ? <Logout /> : <Login /> }
</>
);
}
React hooks
钩子是在 React 16.8 版本中引入的。钩子允许你在函数组件中使用状态和一些其他 React 特性。在钩子出现之前,如果需要状态或复杂的组件逻辑,你必须编写类组件。
在 React 中使用钩子有一些重要的规则。你应该始终在 React 函数组件的顶层调用钩子。你不应该在循环、条件语句或嵌套函数中调用钩子。钩子名称以单词use开头,后跟它们所服务的目的。
useState
我们已经熟悉了用于声明状态的useState钩子函数。让我们看看使用useState钩子的另一个示例。我们将创建一个包含按钮的示例计数器,当按下按钮时,计数器增加1,如下截图所示:

图 8.6:计数器组件
-
首先,我们创建一个
Counter组件,并声明一个名为count的状态,其初始值为0。可以使用setCount函数更新计数器状态的值。以下代码片段展示了这一过程:import { useState } from 'react'; function Counter() { // count state with initial value 0 const [count, setCount] = useState(0); return <div></div>; }; export default Counter; -
接下来,我们渲染一个按钮元素,每次按下按钮时将状态增加
1。我们使用onClick事件属性来调用setCount函数,新值是当前值加1。我们还将渲染计数器状态值。以下代码片段展示了这一过程:import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Counter = {count}</p> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ); }; export default Counter; -
现在,我们的
Counter组件已经准备好,每次按下按钮时计数器都会增加1。当状态更新时,React 会重新渲染组件,我们可以看到新的count值。
在 React 中,事件名称使用驼峰式命名法,例如,onClick。
注意,必须将函数传递给事件处理器,然后 React 仅在用户点击按钮时调用该函数。以下示例中我们使用箭头函数,因为它更简洁,并且可以提高代码的可读性。如果你在事件处理器中调用该函数,那么该函数将在组件渲染时被调用,这可能导致无限循环:
**// Correct -> Function is called when button is pressed**
<button onClick={() => setCount(count + 1)}>
**// Wrong -> Function is called in render -> Infinite loop**
<button onClick={setCount(count + 1)}>
状态更新是异步的,因此当新的状态值依赖于当前状态值时,你必须小心。为了确保使用最新的值,你可以将一个函数传递给更新函数。你可以在以下示例中看到这一点:
setCount(prevCount => prevCount + 1)
现在,将前一个值传递给函数,然后返回并保存到count状态中的更新值。
此外,还有一个名为useReducer的钩子函数,当你有复杂的状态时推荐使用,但在此书中我们不会涉及这一点。
批处理
React 使用批处理来更新状态以减少重新渲染。在 React 18 版本之前,批处理仅在浏览器事件期间更新的状态中起作用——例如,按钮点击。以下示例演示了批处理更新的概念:
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);
const increment = () => {
setCount(count + 1); // No re-rendering yet
setCount2(count2 + 1);
// Component re-renders after all state updates
}
return (
<>
<p>Counters: {count} {count2}</p>
<button onClick={increment}>Increment</button>
</>
);
};
export default App;
从 React 版本 18 开始,所有状态更新都将被批处理。如果你在某些情况下不想使用批处理更新,你可以使用 react-dom 库的 flushSync API 来避免批处理。例如,你可能有一个在更新下一个状态之前更新一些状态的情况。当整合第三方代码,如浏览器 API 时,这可能很有用。
这是你需要执行的代码:
import { flushSync } from "react-dom";
const increment = () => {
flushSync( () => {
setCount(count + 1); // No batch update
});
}
你应该只在需要时使用 flushSync,因为它可能会影响你的 React 应用的性能。
useEffect
useEffect 钩子函数可以用来在 React 函数组件中执行副作用。副作用可以是,例如,一个 fetch 请求。useEffect 钩子接受两个参数,如以下所示:
useEffect(callback, [dependencies])
callback 函数包含副作用逻辑,[dependencies] 是一个可选的依赖数组。
useEffect hook. Now, when the button is pressed, the count state value increases, and the component is re-rendered. After each render, the useEffect callback function is invoked and we can see Hello from useEffect in the console, as illustrated in the following code snippet:
import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// Called after every render
useEffect(() => {
console.log('Hello from useEffect')
});
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment
</button>
</>
);
};
export default Counter;
在下面的屏幕截图中,我们可以看到控制台现在的样子,并且我们可以看到 useEffect 回调在每个渲染后都会被调用。第一条日志行是在初始渲染后打印的,其余的都是在按钮被按下两次并且组件由于状态更新而重新渲染后打印的:

图 8.7:useEffect
count state value is changed (meaning that the previous and current values differ), the useEffect callback function will be invoked. We can also define multiple states in the second argument. If any of these state values are changed, the useEffect hook will be invoked:
// Runs when count value is changed and component is re-rendered
useEffect(() => {
console.log('Counter value is now ' + count);
}, [count]);
如果你将空数组作为第二个参数传递,useEffect 回调函数只会在第一次渲染后运行,如下面的代码片段所示:
// Runs only after the first render
useEffect(() => {
console.log('Hello from useEffect')
}, []);
现在,你可以看到在初始渲染后,来自 useEffect 的问候 只打印了一次,如果你按下按钮,文本就不会打印。由于 React Strict Mode,消息在第一次渲染后打印了两次。Strict Mode 在开发模式下渲染你的组件两次以查找错误,并且不会影响生产构建:

图 8.8:带有空数组的 useEffect
unmounted):
useEffect(() => {
console.log('Hello from useEffect');
return () => {
console.log('Clean up function');
});
}, [count]);
如果你使用这些更改运行计数器应用,你可以在控制台中看到发生的情况,如下面的屏幕截图所示。由于 Strict Mode,组件最初渲染了两次。在初始渲染后,组件被卸载(从 DOM 中移除),因此会调用清理函数:

图 8.9:清理函数
useRef
useRef 钩子返回一个可变的 ref 对象,可以用来访问 DOM 节点。你可以在以下操作中看到它:
const ref = useRef(initialValue)
返回的 ref 对象具有一个 current 属性,该属性使用传递的参数初始化(initialValue)。在下一个示例中,我们创建了一个名为 inputRef 的 ref 对象并将其初始化为 null。然后,我们使用 JSX 元素的 ref 属性并将我们的 ref 对象传递给它。现在,它包含我们的 input 元素,我们可以使用 current 属性来执行 input 元素的 focus 函数。现在,当按钮被按下时,输入元素将被聚焦:
import { useRef } from 'react';
import './App.css';
function App() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>
Focus input
</button>
</>
);
}
export default App;
在本节中,我们学习了 React 钩子的基础知识,当我们开始实现前端时,我们将实际使用它们。React 中还有其他有用的钩子函数可用,接下来你将学习如何创建你自己的钩子。
自定义钩子
你可以在 React 中构建自己的钩子。正如我们已经看到的,钩子的名称应该以 use 字符开头,并且它们是 JavaScript 函数。自定义钩子也可以调用其他钩子。使用自定义钩子,你可以减少组件代码的复杂性。
让我们通过一个简单的例子来创建一个自定义钩子:
-
我们将创建一个
useTitle钩子,它可以用来更新文档标题。我们将在名为useTitle.js的文件中定义它。首先,我们定义一个函数,它接受一个名为title的参数。代码如下所示:// useTitle.js function useTitle(title) { } -
接下来,我们将使用
useEffect钩子来更新文档标题,每次title参数改变时,如下所示:import { useEffect } from 'react'; function useTitle(title) { useEffect(() => { document.title = title; }, [title]); } export default useTitle; -
现在,我们可以开始使用我们的自定义钩子了。让我们在计数器示例中使用它,并将当前计数器的值打印到文档标题中。首先,我们必须将
useTitle钩子导入到我们的Counter组件中,如下所示:**import** **useTitle** **from****'./useTitle'****;** function Counter() { return ( <> </> ); }; export default Counter; -
然后,我们将使用
useTitle钩子将count状态值打印到文档标题中。我们可以在Counter组件函数的最高级别调用我们的钩子函数,每次组件渲染时,useTitle钩子函数都会被调用,我们可以在文档标题中看到当前的计数器值。代码如下所示:import React, { useState } from 'react'; import useTitle from './useTitle'; function App() { const [count, setCount] = useState(0); useTitle(`You clicked ${count} times`); return ( <> <p>Counter = {count}</p> <button onClick={ () => setCount(count + 1) }> Increment </button> </> ); }; export default App; -
现在,如果你点击按钮,
count状态值也会通过我们的自定义钩子显示在文档标题中,如下面的截图所示:

图 8.10:自定义钩子
你现在已经掌握了 React 钩子(hooks)的基本知识以及如何创建你自己的自定义钩子。
上下文 API
如果你的组件树很深且复杂,使用属性传递数据可能会很麻烦。你必须通过组件树中的所有组件传递数据。上下文 API 解决了这个问题,并且建议用于需要在整个组件树中的多个组件中使用 全局 数据——例如,主题或认证用户。
上下文是通过 createContext 方法创建的,该方法接受一个参数来定义默认值。你可以为上下文创建自己的文件,代码如下:
import React from 'react';
const AuthContext = React.createContext('');
export default AuthContext;
接下来,我们将使用上下文提供者组件,它使我们的上下文对其他组件可用。上下文提供者组件有一个 value 属性,它将被传递给消费组件。在以下示例中,我们使用上下文提供者组件包裹了 <MyComponent />,因此 userName 值在我们的组件树中的 <MyComponent /> 下可用:
import React from 'react';
import AuthContext from './AuthContext';
import MyComponent from './MyComponent';
function App() {
// User is authenticated and we get the username
const userName = 'john';
return (
<AuthContext.Provider value={userName}>
<MyComponent />
</AuthContext.Provider>
);
};
export default App;
现在,我们可以通过使用 useContext() 钩子在任何组件树中的组件中访问提供的值,如下所示:
import React from 'react';
import AuthContext from './AuthContext';
function MyComponent() {
const authContext = React.useContext(AuthContext);
return(
<>
Welcome {authContext}
</>
);
}
export default MyComponent;
组件现在渲染了 Welcome john 文本。
使用 React 处理列表
对于列表处理,我们将学习 JavaScript 的map()方法,这在您必须操作列表时非常有用。map()方法创建一个新的数组,该数组包含对原始数组中每个元素调用函数的结果。在下面的示例中,每个数组元素都乘以2:
const arr = [1, 2, 3, 4];
const resArr = arr.map(x => x * 2); // resArr = [2, 4, 6, 8]
以下示例代码演示了一个组件,该组件将整数数组转换为列表项数组,并在ul元素内渲染这些项:
import React from 'react';
function MyList() {
const data = [1, 2, 3, 4, 5];
return (
<>
<ul>
{
data.map((number) =>
<li>Listitem {number}</li>)
}
</ul>
</>
);
};
export default MyList;
以下截图显示了组件渲染后的样子。如果您打开控制台,您可以看到一个警告(列表中的每个子项都应该有一个唯一的 "key" 属性):

图 8.11:React 列表组件
React 中的列表项需要一个唯一键,该键用于检测已更新、添加或删除的行。map()方法也有index作为第二个参数,我们用它来处理警告:
function MyList() {
const data = [1, 2, 3, 4, 5];
return (
<>
<ul>
{
data.map((number, **index**) =>
<li **key****=****{index}**>Listitem {number}</li>)
}
</ul>
</>
);
};
export default MyList;
现在,添加了键之后,控制台中没有警告。
不建议使用index,因为如果列表被重新排序或添加或删除列表项,它可能会导致错误。相反,如果存在,您应该使用数据中的唯一键。还有一些库可供您使用,例如uuid(github.com/uuidjs/uuid)来生成唯一 ID。
如果数据是对象数组,以表格格式呈现会更好。我们以与列表相同的方式执行此操作,但现在我们只是将数组映射到表格行(tr元素)并在table元素内渲染这些行,如下面的组件代码所示。现在数据中有一个唯一的 ID,因此我们可以将其用作键:
function MyTable() {
const data = [
{id: 1, brand: 'Ford', model: 'Mustang'},
{id: 2, brand: 'VW', model: 'Beetle'},
{id: 3, brand: 'Tesla', model: 'Model S'}];
return (
<>
<table>
<tbody>
{
data.map((item) =>
<tr key={item.id}>
<td>{item.brand}</td><td>{item.model}</td>
</tr>)
}
</tbody>
</table>
</>
);
};
export default MyTable;
以下截图显示了组件渲染后的样子。您应该在 HTML 表格中看到数据:

图 8.12:React 表格
现在,我们已经学习了如何使用map()方法处理列表数据,以及如何使用例如 HTML table元素来渲染它。
使用 React 处理事件
React 中的事件处理类似于处理 DOM 元素事件。与 HTML 事件处理相比,区别在于 React 中事件命名使用camelCase。以下示例组件代码向按钮添加事件监听器,并在按钮被按下时显示警告消息:
function MyComponent() {
// This is called when the button is pressed
const handleClick = () => {
alert('Button pressed');
}
return (
<>
<button onClick={handleClick}>Press Me</button>
</>
);
};
export default MyComponent;
正如我们在前面的计数器示例中学到的那样,您必须将函数传递给事件处理器而不是调用它。现在,handleClick函数在return语句外部定义,我们可以通过函数名来引用它:
// Correct
<button onClick={handleClick}>Press Me</button>
// Wrong
<button onClick={handleClick()}>Press Me</button>
在 React 中,您不能从事件处理器返回false以防止默认行为。相反,您应该调用事件对象的preventDefault()方法。在以下示例中,我们使用一个form元素,我们想要防止表单提交:
function MyForm() {
// This is called when the form is submitted
const handleSubmit = (event) => {
event.preventDefault(); // Prevents default behavior
alert('Form submit');
}
return (
<form onSubmit={handleSubmit}>
<input type="submit" value="Submit" />
</form>
);
};
export default MyForm;
现在,当您按下提交按钮时,您可以看到警告,并且表单将不会提交。
使用 React 处理表单
在 React 中处理表单略有不同。一个 HTML form在提交时会导航到下一页。在 React 中,我们通常希望在提交后调用一个可以访问表单数据的 JavaScript 函数,并避免导航到下一页。我们已经在上一节中介绍了如何使用preventDefault()来避免提交。
让我们首先创建一个包含一个输入字段和一个提交按钮的最简表单。为了获取输入字段的值,我们使用onChange事件处理程序。我们使用useState钩子创建一个名为text的状态变量。当输入字段的值发生变化时,新值将被保存到状态中。这个组件被称为受控组件,因为表单数据由 React 处理。在非受控组件中,表单数据仅由 DOM 处理。
setText(event.target.value)语句从input字段获取值并将其保存到状态中。最后,当用户按下提交按钮时,我们将显示输入的值。以下是我们的第一个表单的源代码:
import { useState } from 'react';
function MyForm() {
const [text, setText] = useState('');
// Save input element value to state when it has been changed
const handleChange = (event) => {
setText(event.target.value);
}
const handleSubmit = (event) => {
alert(`You typed: ${text}`);
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChange}
value={text}/>
<input type="submit" value="Press me"/>
</form>
);
};
export default MyForm;
在按下提交按钮后,这是我们的表单组件的截图:

图 8.13:表单组件
你也可以使用 JSX 编写内联onChange处理程序函数,如下例所示。如果你有一个简单的处理程序函数,这是一种相当常见的做法,它可以使你的代码更易于阅读:
return (
<form onSubmit={handleSubmit}>
<input
type="text"
onChange={event => setText(event.target.value)}
value={text}/
>
<input type="submit" value="Press me"/>
</form>
);
现在是查看 React 开发者工具的好时机,这对于调试 React 应用非常有用。
如果你还没有安装 React 开发者工具,你可以在第七章中找到说明,设置环境和工具 – 前端。
如果我们用我们的 React 表单应用打开 React 开发者工具的组件标签,并在输入字段中输入一些内容,我们可以看到状态值是如何变化的,并且我们可以检查 props 和状态当前的值。
以下截图显示了我们在输入字段中输入内容时状态是如何变化的:

图 8.14:React 开发者工具
user state is an object with three attributes: firstName, lastName, and email:
const [user, setUser] = useState({
firstName: '',
lastName: '',
email: ''
});
处理多个输入字段的一种方法是为每个输入字段添加尽可能多的更改处理程序,但这会创建大量的样板代码,我们希望避免。因此,我们在输入字段中添加name属性。我们可以在更改处理程序中使用这些属性来识别哪个输入字段触发了更改处理程序。input元素的name属性值必须与我们想要保存值的 state 对象属性的名称相同,值属性应该是object.property,例如,在姓氏输入元素中。代码如下所示:
<input type="text" **name=****"lastName"** onChange={handleChange}
**value={user.lastName****}/**>
现在,如果触发处理器的输入字段是姓氏字段,那么 event.target.name 就是 lastName,并且输入的值将被保存到状态对象的 lastName 字段。在这里,我们还将使用在 Props 和状态 部分中引入的对象展开运算符。这样,我们可以使用一个更改处理器来处理所有输入字段:
const handleChange = (event) => {
setUser({...user, [event.target.name]:
event.target.value});
}
这是组件的完整源代码:
import { useState } from 'react';
function MyForm() {
const [user, setUser] = useState({
firstName: '',
lastName: '',
email: ''
});
// Save input box value to state when it has been changed
const handleChange = (event) => {
setUser({...user, [event.target.name]:
event.target.value});
}
const handleSubmit = (event) => {
alert(`Hello ${user.firstName} ${user.lastName}`);
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>First name </label>
<input type="text" name="firstName" onChange=
{handleChange}
value={user.firstName}/><br/>
<label>Last name </label>
<input type="text" name="lastName" onChange=
{handleChange}
value={user.lastName}/><br/>
<label>Email </label>
<input type="email" name="email" onChange=
{handleChange}
value={user.email}/><br/>
<input type="submit" value="Submit"/>
</form>
);
};
export default MyForm;
这是提交按钮被按下后我们的表单组件的截图:

图 8.15:React 表单组件
onChange event handler, we call the correct update function to save values into the states. In this case, we don’t need the name input element’s name attribute:
import { useState } from 'react';
function MyForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const handleSubmit = (event) => {
alert('Hello ${firstName} ${lastName}');
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>First name </label>
<input
onChange={e => setFirstName(e.target.value)}
value={firstName}/><br/>
<label>Last name </label>
<input
onChange={e => setLastName(e.target.value)}
value={lastName}/><br/>
<label>Email </label>
<input
onChange={e => setEmail(e.target.value)}
value={email}/><br/>
<input type="submit" value="Press me"/>
</form>
);
};
export default MyForm;
我们现在知道了如何使用 React 处理表单,我们将在实现前端时使用这些技能。
摘要
在本章中,我们开始学习 React,我们将使用它来构建我们的前端。在我们的前端开发中,我们将使用 ES6,这使得我们的代码更简洁,正如我们在本章中看到的。在开始使用 React 进行开发之前,我们涵盖了基础知识,例如 React 组件、JSX、props、state 和 hooks。然后我们探讨了进一步开发所需的功能。我们学习了条件渲染和上下文,以及如何使用 React 处理列表、表单和事件。
在下一章中,我们将专注于 TypeScript 与 React。我们将学习 TypeScript 的基础知识以及如何在我们的 React 项目中使用它。
问题
-
什么是 React 组件?
-
状态和 props 是什么?
-
React 应用程序中的数据是如何流动的?
-
无状态组件和有状态组件有什么区别?
-
JSX 是什么?
-
React 钩子是如何命名的?
-
上下文是如何工作的?
进一步阅读
这里有一些其他关于学习 React 的好资源:
-
《React 完全指南》,作者 Maximilian Schwarzmüller (
www.packtpub.com/product/react-the-complete-guide-includes-hooks-react-router-and-redux-2021-updated-second-edition-video/9781801812603) -
《2023 年终极 React 课程》,作者 Jonas Schmedtmann (
www.udemy.com/course/the-ultimate-react-course/)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新发布的内容——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第九章:TypeScript 简介
本章介绍了 TypeScript。我们将涵盖使用 TypeScript 与 React 一起使用所需的基本技能,并使用 TypeScript 创建我们的第一个 React 应用程序。
在本章中,我们将探讨以下主题:
-
理解 TypeScript
-
在 React 中使用 TypeScript 功能
-
使用 TypeScript 创建 React 应用程序
技术要求
对于我们的工作,需要 React 版本 18 或更高版本。
您可以在本章的 GitHub 链接中找到更多资源:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter09。
理解 TypeScript
TypeScript (www.typescriptlang.org/) 是由微软开发的 JavaScript 的强类型超集。近年来,TypeScript 的采用率增长了很多,并且在业界得到了广泛的应用。它有一个活跃的开发社区,并且得到了大多数流行库的支持。在 JetBrains 2022 开发者生态系统报告中,TypeScript 被命名为增长最快的编程语言 (www.jetbrains.com/lp/devecosystem-2022/)。
与 JavaScript 相比,TypeScript 提供了几个优势,这主要归功于强类型:
-
TypeScript 允许您为变量、函数和类定义 类型。这允许您在开发过程中早期捕获错误。
-
TypeScript 提高了您应用程序的可扩展性,并使您的代码更容易维护。
-
TypeScript 提高了代码的可读性,并使您的代码更具自文档化特性。
与 JavaScript 相比,如果您不熟悉静态类型,TypeScript 的学习曲线可能会更陡峭。
尝试 TypeScript 最简单的方法是使用在线环境,例如 TypeScript Playground (www.typescriptlang.org/play)。如果您想在本地编写 TypeScript 代码,可以使用 npm 在您的计算机上安装 TypeScript 编译器。这在我们 React 项目中不是必需的,因为 Vite 内置了 TypeScript 支持。TypeScript 被转换为纯 JavaScript,然后可以在 JavaScript 引擎中执行。
以下 npm 命令全局安装最新版本的 TypeScript,允许您在任何终端中使用 TypeScript:
npm install -g typescript
您可以通过检查 TypeScript 版本号来验证安装:
tsc --version
如果您正在使用 Windows PowerShell,可能会收到一个错误,表明 在此系统上已禁用脚本运行。在这种情况下,您必须更改执行策略才能运行安装命令。您可以在 go.microsoft.com/fwlink/?LinkID=135170 上了解更多信息。
与 JavaScript 类似,TypeScript 拥有良好的 IDE 支持,这使得你的编码更加高效,具有诸如代码检查和代码自动补全等特性——例如,Visual Studio Code 中的 IntelliSense。
常见类型
当你初始化变量时,TypeScript 会自动定义变量的类型。这被称为类型推断。在以下示例中,我们声明了一个message变量并将其赋值为字符串值。如果我们尝试将其重新赋值为其他类型,我们会得到一个错误,如下面的图片所示:

图 9.1:类型推断
TypeScript 有以下原始类型:string,number,和boolean。number类型代表整数和浮点数。你还可以使用以下语法为变量设置显式类型:
let variable_name: type;
以下代码演示了显式类型定义:
let email: string;
let age: number;
let isActive: boolean;
可以使用typeof关键字检查变量的类型,它返回一个表示应用到的变量类型的字符串:
// Check variable type
console.log(typeof email); // Output is "string"
typeof email === "string" // true
typeof age === "string" // false
如果你不知道变量的类型,你可以使用unknown类型。当你从外部源获取值时,例如,你不知道它的类型,可以使用它:
let externalValue: unknown;
当一个值赋给未知变量时,你可以使用typeof关键字检查其类型。
TypeScript 还提供了any类型。如果你使用any类型定义一个变量,TypeScript 不会对该变量执行类型检查或推断。你应该尽可能避免使用any类型,因为它会抵消 TypeScript 的效果。
数组可以像 JavaScript 中一样声明,但你必须定义数组中元素的类型:
let arrayOfNums: number[] = [1, 2, 3, 4];
let animals: string[] = ["Dog", "Cat", "Tiger"];
你还可以以下这种方式使用Array泛型类型(Array<TypeOfElement>):
let arrayOfNums: Array<number> = [1, 2, 3, 4];
let animals: Array<string> = ["Dog", "Cat", "Tiger"];
类型推断也适用于对象。如果你创建以下对象,TypeScript 会创建一个具有以下推断类型的对象:id: number,name: string,和email: string:
const student = {
id: 1,
name: "Lisa Smith ",
email: "lisa.s@mail.com ",
};
你也可以使用interface或type关键字定义一个对象,它描述了对象的形状。type和interface非常相似,大多数情况下你可以自由选择使用哪一个:
// Using interface
interface Student {
id: number;
name: string;
email: string;
};
// Or using type
type Student = {
id: number;
name: string;
email: string;
};
然后,你可以声明一个符合Student接口或类型的对象:
const myStudent: Student = {
id: 1,
name: "Lisa Smith ",
email: "lisa.s@mail.com ",
};
你可以在 TypeScript 文档中了解更多关于type和interface之间差异的信息:www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces。
现在,如果你尝试创建一个不符合接口或类型的对象,你会得到一个错误。在下一张图片中,我们创建了一个对象,其中id属性值是string,但在接口中定义为number:

图 9.2:接口
你可以通过在属性名末尾使用问号 (?) 来定义可选属性。在以下示例中,我们将 email 标记为可选。现在,你可以创建一个没有电子邮件的学生对象,因为它是一个可选属性:
type Student = {
id: number;
name: string;
email?: string;
};
// Student object without email
const myStudent: Student = {
id: 1,
name: "Lisa Smith"
}
可选链操作符 (?.) 可以用来安全地访问可能为 null 或 undefined 的对象属性和方法,而不会导致错误。这对于可选属性非常有用。让我们看看以下类型,其中 address 是可选的:
type Person = {
name: string,
email: string;
address?: {
street: string;
city: string;
}
}
你可以基于 Person 类型创建一个没有定义 address 属性的对象:
const person: Person = {
name: "John Johnson",
email: "j.j@mail.com"
}
现在,如果你尝试访问 address 属性,将会抛出一个错误:
// Error is thrown
console.log(person.address.street);
然而,如果你使用可选链,控制台将打印出值 undefined,而不会抛出错误:
// Output is undefined
console.log(person.address?.street);
在 TypeScript 中,有许多方式可以组合类型。你可以使用 | 运算符来创建一个 联合类型,它处理不同的类型。例如,以下示例定义了一个可以包含字符串或数字的类型:
type InputType = string | number;
// Use your type
let name: InputType = "Hello";
let age: InputType = 12;
你也可以使用联合类型来定义字符串或数字的集合,如下面的示例所示:
type Fuel = "diesel" | "gasoline" | "electric ";
type NoOfGears = 5 | 6 | 7;
现在,我们可以以以下方式使用我们的联合类型:
type Car = {
brand: string;
fuel: Fuel;
gears: NoOfGears;
}
如果你创建一个新的 Car 对象并尝试分配 Fuel 或 NoOfGears 联合类型中未定义的其他值,你会得到一个错误。
函数
当你定义函数时,你可以按以下方式设置参数类型:
function sayHello(**name: string**) {
console.log("Hello " + name);
}
如果你现在尝试使用不同的参数类型来调用函数,你会得到一个错误:

图 9.3:函数调用
如果函数参数类型未定义,它隐式地具有 any 类型。你还可以在函数参数中使用联合类型:
function checkId(**id: string | number**) {
if (typeof id === "string ")
// do something
else
// do something else
}
函数的返回类型可以声明如下:
function calcSum(x: number, y: number): **number** {
return x + y;
}
箭头函数在 TypeScript 中的工作方式相同,例如:
const calcSum = (x:number, y:number): number => x + y;
如果箭头函数没有返回任何内容,你可以使用 void 关键字:
const sayHello = (name: string): void => console.log("Hello " + name);
现在,你已经遇到了一些 TypeScript 基础知识,我们将学习如何在我们的 React 应用程序中应用这些新技能。
在 React 中使用 TypeScript 功能
TypeScript 是你 React 项目的宝贵补充,尤其是在它们变得复杂时。在本节中,我们将学习如何在我们的 React 组件中获得属性和状态类型验证,并在开发早期检测潜在的错误。
状态和属性
在 React 中,你必须定义组件属性的类型。我们已经了解到组件属性是 JavaScript 对象,因此我们可以使用 type 或 interface 来定义属性类型。
让我们看看一个例子,其中组件接收一个 name (string) 和 age (number) 属性:
function HelloComponent({ name, age }) {
return(
<>
Hello {name}, you are {age} years old!
</>
);
}
export default HelloComponent;
现在,我们可以渲染我们的 HelloComponent 并将其属性传递给它:
// imports...
function App() {
return(
<HelloComponent name="Mary" age={12} />
)
}
export default App;
如果我们使用 TypeScript,我们可以首先创建一个 type 来描述我们的属性:
type HelloProps = {
name: string;
age: number;
};
然后,我们可以在组件属性中使用我们的 HelloProps 类型:
function HelloComponent({ name, age }: **HelloProps**) {
return(
<>
Hello {name}, you are {age} years old!
</>
);
}
export default HelloComponent;
现在,如果我们使用错误的类型传递属性,我们会得到一个错误。这很好,因为现在我们可以在开发阶段捕获潜在的错误:

图 9.4:输入 props
如果我们使用了 JavaScript,在这个阶段我们不会看到错误。在 JavaScript 中,如果我们把字符串作为ageprop 而不是数字传递,它仍然可以工作,但如果我们稍后尝试对其进行数值操作,可能会遇到意外的行为或错误。
如果有可选的 props,你可以在定义 props 的类型时使用问号来标记这些 props - 例如,如果age是可选的:
type HelloProps = {
name: string;
**age?**: number;
};
现在,你可以使用你的组件,带有或不带有ageprops。
如果你想要通过 props 传递一个函数,你可以在你的type中定义它,使用以下语法:
// Function without parameters
type HelloProps = {
name: string;
age: number;
**fn****:** **() =>****void****;**
};
// Function with parameters
type HelloProps = {
name: string;
age: number;
**fn****:** **(msg: string) =>** **void****;**
};
很常见的情况是你想在应用中的多个文件中使用相同的type。在这种情况下,将类型提取到它们自己的文件并导出它们是一个好习惯:
// types.ts file
export type HelloProps = {
name: string;
age: number;
};
然后,你可以将类型导入到任何需要它的组件中:
// Import type and use it in your component
import { HelloProps } from ./types;
function HelloComponent({ name, age }: HelloProps) {
return(
<>
Hello {name}, you are {age} years old!
</>
);
}
export default HelloComponent;
正如我们在第八章中提到的,你还可以使用箭头函数来创建一个函数组件。有一个标准的 React 类型,FC(函数组件),我们可以与箭头函数一起使用。这个类型接受一个泛型参数,指定了 prop 类型,在我们的例子中是HelloProps:
import React from 'react';
import { HelloProps } from './types';
const HelloComponent: React.FC<HelloProps> = ({ name, age }) => {
return (
<>
Hello {name}, you are {age} years old!
</>
);
};
export default HelloComponent;
现在,你已经学会了如何在你的 React 应用中定义 prop 类型,所以我们将继续到状态。当你使用我们在第八章中学习的useState钩子创建状态时,类型推断会在你使用常见原始类型时处理类型。例如:
// boolean
const [isReady, setReady] = useState(false);
// string
const [message, setMessage] = useState("");
// number
const [count, setCount] = useState(0);
如果你尝试使用不同的类型来更新状态,你会得到一个错误:

图 9.5:输入状态
你还可以显式地定义状态类型。如果你想将你的状态初始化为null或undefined,你必须这样做。在这种情况下,你可以使用联合操作符,语法如下:
const [message, setMessage] = useState<string | undefined>(undefined);
如果你有一个复杂的状态,你可以使用type或interface。在下面的例子中,我们创建了一个描述用户的类型。然后,我们创建了一个状态,并用一个空的User对象初始化它。如果你想允许null值,你可以使用联合来允许User对象或null值:
type User = {
id: number;
name: string;
email: number;
};
// Use type with state, the initial value is an empty User object
const [user, setUser] = useState<User>({} as User);
// If null values are accepted
const [user, setUser] = useState<User | null>(null);
事件
在第八章中,我们学习了如何在 React 应用中读取用户输入。我们使用了输入元素的onChange事件处理程序来将输入的数据保存到状态中。当使用 TypeScript 时,我们必须定义事件类型。在下面的屏幕截图中,你可以看到如果没有定义类型,我们会得到一个错误:

图 9.6:输入事件
让我们看看我们如何处理一个输入元素的更改事件。让我们看一个例子,其中return语句中的输入元素代码如下:
<input
type="text"
onChange={handleChange}
value={name}
/>
当用户在输入元素中输入某些内容时,会调用事件处理函数,代码如下:
const handleChange = (event) => {
setName(event.target.value);
}
现在,我们必须定义事件类型。为此,我们可以使用预定义的React.ChangeEvent类型。
你可以在 React TypeScript Cheatsheet 中阅读事件类型的完整列表:react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/forms_and_events/.
我们想要处理一个输入元素的 change 事件,所以类型如下:
const handleChange = (event: **React.ChangeEvent<HTMLInputElement>**) => {
setName(event.target.value);
}
表单提交处理函数处理表单提交。这个函数应该接受一个类型为React.FormEvent<HTMLFormElement>的事件参数:
const handleSubmit = (event: **React.FormEvent<HTMLFormElement>**) => {
event.preventDefault();
alert(`Hello ${name}`);
}
现在,我们知道如何在我们的 React 应用中使用 TypeScript 处理事件。接下来,我们将学习如何实际创建一个使用 TypeScript 的 React 应用。
使用 TypeScript 创建 React 应用
现在,我们将使用 Vite 创建一个 React 应用,我们将使用 TypeScript 而不是 JavaScript。当我们在我们的汽车后端开发前端时,我们将使用 TypeScript。正如我们之前提到的,Vite 自带内置的 TypeScript 支持:
-
使用以下命令创建一个新的 React 应用:
npm create vite@latest -
接下来,将你的项目命名为
tsapp,并选择React框架和TypeScript变体:

图 9.7:React TypeScript 应用
-
然后,转到你的应用文件夹,安装依赖项,并使用以下命令运行你的应用:
cd tsapp npm install npm run dev -
在 VS Code 中打开你的应用文件夹,你会看到我们的
App组件的文件名是App.tsx:![]()
图 9.8:App.tsx
TypeScript React 文件的文件扩展名是
.tsx(将 JSX 与 TypeScript 结合)。TypeScript 文件的常规文件扩展名是.ts。 -
在你的项目根目录中找到
tsconfig.json文件。这是一个由 TypeScript 使用的配置文件,用于指定编译器选项,例如编译输出的目标版本或使用的模块系统。我们可以使用 Vite 定义的默认设置。让我们创建一个 React 应用,这是我们之前在定义事件类型时作为示例使用的。用户可以输入一个名字,当按钮被按下时,会使用 alert 显示 hello 消息:
-
首先,我们将从
App.tsx文件的返回语句中删除代码,只留下片段。在删除所有未使用的导入(除了useState导入)之后,你的代码应该看起来像以下这样:import { useState } from 'react'; import './App.css'; function App() { return ( <> </> ) } export default App; -
接下来,创建一个
state来存储用户输入到输入元素中的值:function App() { **const** **[name, setName] =** **useState****(****""****);** return ( <> </> ) } -
之后,在
return语句中添加两个输入元素,一个用于收集用户输入,另一个用于提交表单:// App.tsx return statement return ( <> <form onSubmit={handleSubmit}> <input type="text" value={name} onChange={handleChange} /> <input type="submit" value="Submit"/> </form> </> ) -
接下来,创建事件处理函数,
handleSubmit和handleChange。现在,我们还需要定义事件类型:// imports function App() { const [name, setName] = useState(""); const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setName(event.target.value); } const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); alert(`Hello ${name}`); } // continue... -
使用
npm run dev命令运行应用。 -
尝试将你的名字输入到输入元素中,然后按下提交按钮。你应该能看到显示你名字的 hello 消息:

图 9.9:React TypeScript 应用
Vite 和 TypeScript
Vite 将 TypeScript 文件转换为 JavaScript,但它不执行类型检查。Vite 使用 esbuild 来转换 TypeScript 文件,因为它的速度比 TypeScript 编译器 (tsc) 快得多。
VS Code IDE 为我们处理类型检查,你应该修复 IDE 中显示的所有错误。你还可以使用一个名为 vite-plugin-checker 的 Vite 插件 (github.com/fi3ework/vite-plugin-checker)。类型检查在我们构建 Vite 应用到生产环境时进行,所有错误应在生产构建之前修复。我们将在本书的后面部分构建我们的 Vite 应用。
摘要
在本章中,我们开始学习 TypeScript 以及如何在我们的 React 应用中使用它。现在,我们知道了如何在 TypeScript 中使用常见类型,以及如何为 React 组件的 props 和 states 定义类型。我们还学习了如何为事件定义类型,并使用 Vite 创建了我们的第一个带有 TypeScript 的 React 应用。
在下一章中,我们将专注于使用 React 进行网络编程。我们还将使用 GitHub REST API 来学习如何使用 React 消费 RESTful 网络服务。
问题
-
什么是 TypeScript?
-
我们如何定义变量类型?
-
我们如何在函数中定义类型?
-
我们如何为 React 的 props 和 states 定义类型?
-
我们如何定义事件类型?
-
我们如何使用 Vite 创建一个 React TypeScript 应用?
进一步阅读
这里有一些学习 React 与 TypeScript 的其他有用资源:
-
React TypeScript Cheatsheets (
react-typescript-cheatsheet.netlify.app/) -
《用 TypeScript 学习 React,第二版》,作者 Carl Rippon (
www.packtpub.com/product/learn-react-with-typescript-second-edition/9781804614204) -
《精通 TypeScript》,作者 Nathan Rozentals (
www.packtpub.com/product/mastering-typescript-fourth-edition/9781800564732)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十章:使用 React 消费 REST API
本章解释了使用 React 进行网络操作。这是一个在大多数 React 应用中都需要的重要技能。我们将学习承诺,它可以使异步代码更简洁、更易读。对于网络操作,我们将使用fetch和 Axios 库。作为一个例子,我们将使用 OpenWeather 和 GitHub REST API 来展示如何使用 React 消费 RESTful Web 服务。我们还将看到 React Query 库的实际应用。
在本章中,我们将涵盖以下主题:
-
承诺
-
使用
fetchAPI -
使用 Axios 库
-
实际示例
-
处理竞态条件
-
使用 React Query 库
技术要求
以下 GitHub 链接将需要:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter10。
承诺
处理异步操作的传统方式是使用回调函数来处理操作的成功或失败。如果操作成功,则调用success函数;否则,调用failure函数。以下(抽象)示例展示了使用回调函数的想法:
function doAsyncCall(success, failure) {
// Do some API call
if (SUCCEED)
success(resp);
else
failure(err);
}
success(response) {
// Do something with response
}
failure(error) {
// Handle error
}
doAsyncCall(success, failure);
现在,承诺是 JavaScript 异步编程的一个基本部分。承诺是一个表示异步操作结果的对象。使用承诺可以简化执行异步调用时的代码。承诺是非阻塞的。如果你正在使用不支持承诺的较旧库进行异步操作,代码将变得难以阅读和维护。在这种情况下,你将面临多个嵌套的回调,这些回调非常难以阅读。错误处理也将变得困难,因为你将不得不在每个回调中检查错误。
如果我们使用的 API 或库支持承诺,我们可以使用承诺执行异步调用。以下示例中,执行了一个异步调用。当响应返回时,then方法内部的回调函数将被执行,并将响应作为参数传递:
doAsyncCall()
.then(response => // Do something with the response)
then方法返回一个承诺。承诺可以处于以下三种状态之一:
-
挂起:初始状态
-
已满足(或已解决):成功运行
-
拒绝:失败操作
以下代码演示了一个简单的承诺对象,其中setTimeout模拟了一个异步操作:
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Hello");
}, 500);
});
当承诺对象被创建以及计时器正在运行时,承诺处于挂起状态。500 毫秒后,resolve函数被调用,并带有值"Hello",承诺进入已满足状态。如果有错误,承诺状态将变为拒绝,这可以通过稍后展示的catch()函数来处理。
你可以将多个 then 实例链接起来,这意味着你可以依次运行多个异步操作:
doAsyncCall()
.then(response => // Get some data from the response)
.then(data => // Do something with the data
你也可以通过使用 catch() 来向承诺添加错误处理。如果前面的 then 链中发生任何错误,catch() 将被执行:
doAsyncCall()
.then(response => // Get some data from the response)
.then(data => // Do something with data)
.catch(error => console.error(error))
async 和 await
有一种更现代的方式来处理异步调用,它涉及到 async/await,这是在 ECMAScript 2017 中引入的。async/await 方法基于承诺。要使用 async/await,你必须定义一个可以包含 await 表达式的 async() 函数。
以下是一个包含 async/await 的异步调用的示例。正如你所看到的,你可以以类似于同步代码的方式编写代码:
const doAsyncCall = **async** () => {
const response = await fetch('http ://someapi .com');
const data = **await** response.json();
// Do something with the data
}
fetch() 函数返回一个承诺,但现在它使用 await 而不是 then 方法来处理。
对于错误处理,你可以使用与 async/await 一起的 try...catch,如下面的示例所示:
const doAsyncCall = async () => {
**try** **{**
const response = await fetch('http ://someapi .com');
const data = await response.json();
// Do something with the data
**}**
**catch****(err) {**
console.error(err);
**}**
**}**
现在我们已经了解了承诺,我们可以开始学习 fetch API,我们可以使用它来在我们的 React 应用中进行请求。
使用 fetch API
使用 fetch API,你可以进行网络请求。fetch API 的理念类似于传统的 XMLHttpRequest 或 jQuery Ajax API,但 fetch API 还支持承诺,这使得它更易于使用。如果你使用 fetch,并且它被现代浏览器原生支持,你不需要安装任何库。
fetch API 提供了一个具有一个强制参数的 fetch() 方法:你正在调用的资源的路径。在网页请求的情况下,它将是服务的 URL。对于简单的 GET 方法调用,它返回一个响应,其语法如下:
fetch('http ://someapi .com')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error))
fetch() 方法返回一个包含响应的承诺。你可以使用 json() 方法从响应中提取 JSON 数据,此方法也返回一个承诺。
传递给第一个 then 语句的 response 是一个包含 ok 和 status 属性的对象,我们可以使用这些属性来检查请求是否成功。如果响应状态是 2XX 形式,则 ok 属性值为 true:
fetch('http ://someapi .com')
.then(response => {
if (response.ok)
// Successful request -> Status 2XX
else
// Something went wrong -> Error response
})
.then(data => console.log(data))
.catch(error => console.error(error))
要使用另一个 HTTP 方法,例如 POST,你必须定义它在 fetch() 方法的第二个参数中。第二个参数是一个对象,你可以在其中定义多个请求设置。以下源代码使用 POST 方法进行请求:
fetch('http ://someapi .com'**, {****method****:** **'POST'****}**)
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error))
你也可以在第二个参数中添加头信息。以下 fetch() 调用包含 'Content-Type':'application/json' 头信息。建议添加 'Content-Type' 头信息,因为这样服务器可以正确地解释请求体:
fetch('http ://someapi .com',
{
method: 'POST',
**headers****: {****'Content-Type'****:****'application/json'****}**
}
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error))
如果你必须在请求体中发送 JSON 编码的数据,这样做的方法如下:
fetch('http ://someapi. com',
{
method: 'POST',
headers: {'Content-Type':'application/json'},
**body****:** **JSON****.****stringify****(data)**
}
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error(error))
fetch API 不是在 React 应用中执行请求的唯一方式。还有其他库你可以使用。在下一节中,我们将学习如何使用这样一个流行的库:axios。
使用 Axios 库
您还可以使用其他库进行网络调用。一个非常流行的库是axios(github.com/axios/axios),您可以使用npm在您的 React 应用程序中安装它:
npm install axios
在使用它之前,您必须将以下import语句添加到您的 React 组件中:
import axios from 'axios';
axios库有一些优点,例如自动转换 JSON 数据,因此在使用axios时不需要json()函数。以下代码展示了使用axios进行的一个示例调用:
axios.get('http ://someapi .com')
.then(response => console.log(response))
.catch(error => console.log(error))
axios库为不同的 HTTP 方法提供了自己的调用方法。例如,如果您想发送一个POST请求并在正文中发送一个对象,axios提供了axios.post()方法:
axios.post('http ://someapi .com', { newObject })
.then(response => console.log(response))
.catch(error => console.log(error))
您还可以使用axios()函数并传递一个配置对象,该对象指定了请求的详细信息,例如方法、头、数据和 URL:
const response = await axios({
method: 'POST',
url: 'https ://myapi .com/api/cars',
headers: { 'Content-Type': 'application/json' },
data: { brand: 'Ford', model: 'Ranger' },
});
上面的示例代码向 https 😕/myapi .com/api/cars 端点发送了一个 POST 请求。请求体包含一个对象,Axios 会自动将数据字符串化。
现在,我们准备查看一些涉及 React 网络的实际示例。
实际示例
在本节中,我们将通过两个示例来介绍在 React 应用程序中使用公共 REST API。在第一个示例中,我们使用 OpenWeather API 获取伦敦的当前天气并在组件中渲染它。在第二个示例中,我们使用 GitHub API 并允许用户通过关键词检索仓库。
OpenWeather API
首先,我们将创建一个 React 应用程序,显示伦敦的当前天气。我们将在应用程序中显示温度、描述和天气图标。这些天气数据将从OpenWeather(openweathermap.org/)获取。
您需要注册 OpenWeather 以获取 API 密钥。免费账户将满足我们的需求。一旦注册,请导航到您的账户信息以找到API 密钥标签。在那里,您将看到用于您的 React 天气应用程序所需的 API 密钥:

图 10.1:OpenWeather API 密钥
您的 API 密钥将在成功注册后的 2 小时内自动激活,因此您可能需要等待一段时间才能在此部分使用它。
让我们使用 Vite 创建一个新的 React 应用程序:
-
在 Windows 的终端或 macOS/Linux 的终端中打开一个终端,并输入以下命令:
npm create vite@latest -
将您的应用程序命名为
weatherapp,并选择React框架和JavaScript变体。 -
导航到
weatherapp文件夹并安装依赖项:cd weatherapp npm install -
使用以下命令启动您的应用程序:
npm run dev -
使用 VS Code 打开您的项目文件夹,并在编辑视图中打开
App.jsx文件。删除片段(<></>)内的所有代码,并删除未使用的导入。现在,您的源代码应该如下所示:import './App.css' function App() { return ( <> </> ); } export default App; -
首先,我们必须添加存储响应数据的所需状态。在我们的应用中,我们将展示温度、描述和天气图标。我们有三个相关值,因此最好创建一个对象状态,而不是创建多个单独的状态:
**import** **{ useState }** **from****'react'****;** import './App.css'; function App() { **const** **[weather, setWeather] =** **useState****({** **temp****:** **''****,** **desc****:** **''****,** **icon****:** **''** **});** return ( <> </> ); } export default App; -
当你使用 REST API 时,你应该检查响应,以便你可以看到 JSON 数据的格式。以下是返回伦敦当前天气的地址:
api.openweathermap.org/data/2.5/weather?q=London&units=Metric&APIkey=YOUR_KEY。如果你将 URL 复制到浏览器中,你可以查看 JSON 响应数据:
![]()
图 10.2:按城市获取天气
从响应中,你可以看到
temp可以通过main.temp访问。你还可以看到description和icon位于weather数组中,该数组只有一个元素,我们可以通过weather[0].description和weather[0].icon来访问它。 -
我们将在接下来的几个步骤中,在
useEffect钩子函数内部实现fetch调用。从 React 导入useEffect:import { useState**, useEffect** } from 'react'; -
使用
fetch在useEffect钩子函数中执行 REST API 调用,使用空数组作为第二个参数。因此,fetch 是在第一次渲染后执行的。在成功响应后,我们将天气数据保存到状态中。一旦状态值发生变化,组件将重新渲染。以下是为useEffect钩子函数提供的源代码。它将在第一次渲染后执行一次fetch()函数(注意!请在代码中使用你自己的 API 密钥):useEffect(() => { fetch('https://api.openweathermap.org/data/2.5/ weather?q=London&APIKey=YOUR_API_KEY&units=metric') .then(response => response.json()) .then(result => { setWeather({ temp: result.main.temp, desc: result.weather[0].main, icon: result.weather[0].icon }); }) .catch(err => console.error(err)) }, []) -
一旦添加了
useEffect函数,请求将在第一次渲染后执行。我们可以使用 React Developer Tools 来检查是否一切正常。在浏览器中打开你的应用,并打开你的 React Developer Tools 组件标签。现在,你可以看到状态已经更新为响应中的值:![]()
图 10.3:天气组件
你也可以从网络标签中检查请求状态是否为
200 OK。 -
最后,我们将实现
return语句以显示天气值。这里我们将使用条件渲染;否则,由于第一次渲染调用中没有图像代码,图像上传将不会成功,我们会得到错误。要显示天气图标,我们必须在图标代码之前添加
https://openweathermap.org/img/wn/,并在图标代码之后添加@2x.png。你可以在 OpenWeather 文档中找到有关图标的信息:
openweathermap.org/weather-conditions。然后,我们可以将连接的图像 URL 设置为
img元素的src属性。温度和描述显示在段落元素中。°C是摄氏度符号:if (weather.icon) { return ( <> <p>Temperature: {weather.temp} °C</p> <p>Description: {weather.desc}</p> <img src={`http://openweathermap.org/img/wn/${weather. icon}@2x.png`} alt="Weather icon" /> </> ); } else { return <div>Loading...</div> } -
现在,你的应用应该已经准备好了。当你用浏览器打开它时,它应该看起来如下:

图 10.4:WeatherApp
整个 App.jsx 文件的源代码如下:
import { useState, useEffect } from 'react';
import './App.css'
function App() {
const [weather, setWeather] = useState({temp: '', desc: '', icon: ''});
useEffect(() => {
fetch('https://api.openweathermap.org/data/2.5/weather?q=\
London&APIKey=YOUR_API_KEY&units=metric')
.then(response => response.json())
.then(result => {
setWeather({
temp: result.main.temp,
desc: result.weather[0].main,
icon: result.weather[0].icon
});
})
.catch(err => console.error(err))
}, [])
if (weather.icon) {
return (
<>
<p>Temperature: {weather.temp} °C</p>
<p>Description: {weather.desc}</p>
<img src={
`https ://openweathermap .org/img/wn/${weather.icon}@2x.png`
}
alt="Weather icon" />
</>
);
}
else {
return <>Loading...</>
}
}
export default App
在我们的例子中,我们检查了天气图标是否加载以确认获取是否完成。这不是最佳解决方案,因为如果获取以错误结束,我们的组件仍然渲染一个加载消息。布尔状态在这种场景中被大量使用,但它也不能解决问题。最佳解决方案是一个表示请求确切状态的状态(挂起、解决、拒绝)。你可以在 Kent C. Dodds 的博客文章 Stop using isLoading Booleans 中了解更多信息:kentcdodds.com/blog/stop-using-isloading-booleans。这个问题可以通过 React Query 库来解决,我们将在本章后面使用它。
GitHub API
在第二个示例中,我们将创建一个使用 GitHub API 通过关键词获取仓库的应用。用户输入一个关键词,我们将获取包含该关键词的仓库。我们将使用 axios 库进行网络请求,并且在这个例子中我们也将练习使用 TypeScript。
让我们先看看如何使用 TypeScript 通过 axios 发送一个 GET 请求。你可以发送一个 GET 请求并使用 TypeScript 泛型指定期望的数据类型,如下面的示例所示:
import axios from 'axios';
type MyDataType = {
id: number;
name: string;
}
axios.get<MyDataType>(apiUrl)
.then(response => console.log(response.data))
.catch(err => console.error(err));
如果你尝试访问不在期望数据类型中的字段,你将在开发早期阶段得到一个错误。此时,重要的是要理解 TypeScript 被编译成 JavaScript,并且所有类型信息都被移除。因此,TypeScript 对运行时行为没有直接影响。如果 REST API 返回的数据类型与预期不同,TypeScript 不会将其作为运行时错误捕获。
现在,我们可以开始开发我们的 GitHub 应用:
-
使用 Vite 创建一个名为
restgithub的新 React 应用,选择 React 框架和 TypeScript 变体。 -
安装依赖项,启动应用,并使用 VS Code 打开项目文件夹。
-
在你的项目文件夹中使用以下
npm命令安装axios:npm install axios -
从
App.tsx文件中的片段<></>内移除额外的代码。你的App.tsx代码应如下所示:import './App.css'; function App() { return ( <> </> ); } export default App;搜索仓库的 GitHub REST API 的 URL 如下:
https://api.github.com/search/repositories?q={KEYWORD}。你可以在
docs.github.com/en/rest找到 GitHub REST API 文档。让我们通过在浏览器中输入 URL 并使用
react关键词来检查 JSON 响应:![]()
图 10.5:GitHub REST API
从响应中,我们可以看到仓库以名为
items的 JSON 数组形式返回。从单个仓库中,我们将展示full_name和html_url的值。 -
我们将在表格中展示数据,并使用
map()函数将值转换为表格行,如 第八章 中所示。id可以用作表格行的键。我们将使用用户输入的关键字执行 REST API 调用。一种实现方式是创建一个输入字段和按钮。用户将关键字输入到输入字段中,当按钮被按下时,执行 REST API 调用。
我们不能在
useEffect()钩子函数中执行 REST API 调用,因为在那个阶段,当组件第一次渲染时,用户输入是不可用的。我们将创建两个状态,一个用于用户输入,一个用于 JSON 响应中的数据。当我们使用 TypeScript 时,我们必须为仓库定义一个类型,如下面的代码所示。
repodata状态是一个Repository类型的数组,因为仓库作为 JSON 数组返回。我们只需要访问三个字段;因此,只定义了这些字段。我们还将导入axios,我们将在稍后发送请求时使用它:import { useState } from 'react'; import axios from 'axios'; import './App.css'; type Repository = { id: number; full_name: string; html_url: string; }; function App() { const [keyword, setKeyword] = useState(''); const [repodata, setRepodata] = useState<Repository[]>([]); return ( <> </> ); } export default App; -
接下来,我们将在
return语句中实现输入字段和按钮。我们还需要为我们的输入字段添加一个变化监听器,以便能够将输入值保存到名为keyword的状态中。按钮有一个点击监听器,它调用将使用给定关键字执行 REST API 调用的函数:const handleClick = () => { // REST API call } return ( <> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <button onClick={handleClick}>Fetch</button> </> ); -
在
handleClick()函数中,我们将使用模板字符串连接url和keyword状态(注意!使用反引号java ``)。我们将使用axios.get()方法发送请求。正如我们之前所学的,Axios 不需要在响应上调用.json()方法。Axios 会自动解析响应数据,然后我们将响应数据中的items数组保存到repodata状态。我们还使用catch()来处理错误。由于我们使用 TypeScript,我们将在GET请求中定义预期的数据类型。我们已经看到响应是一个包含item属性的对象。item属性的内容是一个仓库对象的数组;因此,数据类型是<{ items: Repository[] }>:const handleClick = () => { axios.get<{ items: Repository[] }> (`https://api.github.com/ search/repositories?q=${keyword}`) .then(response => setRepodata(response.data.items)) .catch(err => console.error(err)) } -
在
return语句中,我们将使用map()函数将data状态转换为表格行。仓库的url属性将是<a>元素的href值。每个仓库都有一个唯一的id属性,我们可以将其用作表格行的键。我们使用条件渲染来处理响应没有返回任何仓库的情况:return ( <> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <button onClick={handleClick}>Fetch</button> **{repodata.length === 0 ? (** **<****p****>****No data available****</****p****>** **)** **:** **(** **<****table****>** **<****tbody****>** **{repodata.map(repo => (** **<****tr****key****=****{repo.id}****>** **<****td****>****{repo.full_name}****</****td****>** **<****td****>** **<****a****href****=****{repo.html_url}****>****{repo.html_url}****</****a****>** **</****td****>** **</****tr****>** **))}** **</****tbody****>** **</****table****>** **)}** </> ); -
以下截图显示了在 REST API 调用中使用
react关键字后的最终应用:

图 10.6:GitHub REST API
App.jsx文件的源代码如下:
import { useState } from 'react';
import axios from 'axios';
import './App.css';
type Repository = {
id: number;
full_name: string;
html_url: string;
};
function App() {
const [keyword, setKeyword] = useState('');
const [repodata, setRepodata] = useState<Repository[]>([]);
const handleClick = () => {
axios.get<{ items: Repository[]
}>(`https://api.github.com/search/repositories?q=${keyword}`)
.then(response => setRepodata(response.data.items))
.catch(err => console.error(err));
}
return (
<>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<button onClick={handleClick}>Fetch</button>
{repodata.length === 0 ? (
<p>No data available</p>
) : (
<table>
<tbody>
{repodata.map((repo) => (
<tr key={repo.id}>
<td>{repo.full_name}</td>
<td>
<a href={repo.html_url}>{repo.html_url}</a>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}
export default App;
GitHub API 有一个 API 速率限制(未认证的每日请求数量),所以如果你的代码停止工作,原因可能在这里。我们使用的搜索端点每分钟限制 10 个请求。如果你超过了这个限制,你必须等待一分钟。
处理竞态条件
如果你的组件快速进行多次请求,可能会导致竞态条件,这可能会产生不可预测或错误的结果。网络请求是异步的;因此,请求不一定按照发送的顺序完成。
以下示例代码在props.carid值变化时发送网络请求:
import { useEffect, useState } from 'react';
function CarData(props) {
const [data, setData] = useState({});
useEffect(() => {
fetch(`https ://carapi .com/car/${props.carid}`) .then(response => response.json())
.then(cardata => setData(cardata))
}, [props.carid]);
if (data) {
return <div>{data.car.brand}</div>;
} else {
return null;
}
continue...
现在,如果carid快速变化多次,渲染的数据可能不是来自最后发送的请求。
我们可以使用useEffect清理函数来避免竞态条件。首先,在useEffect()内部创建一个名为ignore的布尔变量,初始值为false。然后,在清理函数中更新ignore变量的值为true。在状态更新中,我们检查ignore变量的值,并且只有当值为false时,状态才会更新,这意味着没有新的值替换props.carid,并且效果没有被清理:
import { useEffect, useState } from 'react';
function CarData(props) {
const [data, setData] = useState({});
useEffect(() => {
**let** **ignore =** **false****;**
fetch(`https ://carapi .com/car/${props.carid}`)
.then(response => response.json())
.then(cardata => {
**if** **(!ignore) {**
**setData****(cardata)**
**}**
});
**return****() =>** **{**
**ignore =** **true****;**
**};**
}, [props.carid]);
if (data) {
return <div>{data.car.brand}</div>;
} else {
return null;
}
continue...
现在,每次组件重新渲染时,清理函数都会被调用,并将ignore更新为true,效果被清理。只有最后请求的结果会被渲染,我们可以避免竞态条件。
我们接下来将开始使用的 React Query 提供了一些处理竞态条件的机制,例如并发控制。它确保对于给定的查询键,一次只发送一个请求。
使用 React Query 库
在适当的 React 应用程序中,如果你要进行大量的网络调用,建议使用第三方网络库。最受欢迎的两个库是React Query(tanstack.com/query),也称为Tanstack Query,以及SWR(swr.vercel.app/)。这些库提供了许多有用的功能,例如数据缓存和性能优化。
在本节中,我们将学习如何在 React 应用程序中使用 React Query 获取数据。我们将创建一个 React 应用程序,使用react关键字从 GitHub REST API 获取仓库:
-
首先,使用 Vite 创建一个名为
gitapi的 React 应用程序,选择React框架和JavaScript变体。安装依赖项并移动到你的项目文件夹。 -
接下来,使用以下命令在你的项目文件夹中安装 React Query 和
axios(注意!在本书中,我们使用 Tanstack Query v4):// install v4 npm install @tanstack/react-query@4 npm install axios -
从
App.jsx文件中移除片段<></>内的额外代码。你的App.jsx代码应如下所示:import './App.css'; function App() { return ( <> </> ); } export default App; -
React Query 提供了
QueryClientProvider和QueryClient组件,用于处理数据缓存。将这些组件导入到你的App组件中。然后,创建一个QueryClient实例并在我们的App组件中渲染QueryClientProvider:import './App.css'; **import** **{** **QueryClient****,** **QueryClientProvider** **}** **from** **'@tanstack/react-query'****;** **const** **queryClient =** **new****QueryClient****();** function App() { return ( <> **<****QueryClientProvider****client****=****{queryClient}****>** **</****QueryClientProvider****>** </> ) } export default App;React Query 提供了一个名为
useQuery的钩子函数,用于调用网络请求。其语法如下:const query = useQuery({ queryKey: ['repositories'], queryFn: getRepositories })注意:
-
queryKey是查询的唯一键,它用于缓存和重新获取数据。 -
queryFn是一个用于获取数据的函数,它应该返回一个 promise。
useQuery钩子返回的query对象包含重要的属性,例如查询的状态:const { isLoading, isError, isSuccess } = useQuery({ queryKey: ['repositories'], queryFn: getRepositories })可能的状态值如下:
-
isLoading:数据尚未可用。 -
isError:查询以错误结束。 -
isSuccess:查询成功结束,查询数据可用。
query对象的data属性包含响应返回的数据。使用这些信息,我们可以继续使用
useQuery使用 GitHub 示例。 -
-
我们将创建一个新的组件来获取数据。在
src文件夹中创建一个名为Repositories.jsx的新文件,并用以下启动代码填充它:function Repositories() { return ( <> </> ) } export default Repositories; -
导入
useQuery钩子,创建一个名为getRepositories()的函数,该函数在 GitHub REST API 上调用axios.get()。在这里,我们使用 Axios 的async/await。调用useQuery钩子函数,并将queryFn属性的值设置为我们的fetch函数,getRepositories:import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; function Repositories() { const getRepositories = async () => { const response = await axios.get("https://api.github\ .com/search/repositories?q=react"); return response.data.items; } const { isLoading, isError, data } = useQuery({ queryKey: ['repositories'], queryFn: getRepositories, }) return ( <></> ) } export default Repositories; -
接下来,实现条件渲染。当数据可用时渲染仓库。如果 REST API 调用以错误结束,我们也会渲染一条消息:
// Repositories.jsx if (isLoading) { return <p>Loading...</p> } if (isError) { return <p>Error...</p> } else { return ( <table> <tbody> { data.map(repo => <tr key={repo.id}> <td>{repo.full_name}</td> <td> <a href={repo.html_url}>{repo.html_url}</a> </td> </tr>) } </tbody> </table> ) } -
在最后一步,将我们的
Repositories组件导入到App组件中,并在QueryClientProvider组件内部渲染它:// App.jsx import './App.css' **import****Repositories****from****'./Repositories'** import { QueryClient, QueryClientProvider } from '@tanstack/react- query' const queryClient = new QueryClient() function App() { return ( <> <QueryClientProvider client={queryClient}> **<****Repositories** **/>** </QueryClientProvider> </> ) } export default App -
现在,您的应用程序应该看起来如下,并且使用 React Query 库获取了仓库。我们还能够轻松地使用其内置功能处理请求状态。我们不需要任何状态来存储响应数据,因为 React Query 处理数据管理和缓存:

图 10.7:Tanstack Query
你也应该看到,当浏览器 重新聚焦(当用户返回到应用程序的窗口或标签页)时,React Query 会自动重新获取。这是一个很好的功能;每次你重新聚焦浏览器时,你都可以看到更新的数据。你可以全局或按查询更改此默认行为。
当网络重新连接或查询挂载了一个新实例(组件被插入到 DOM 中)时,也会自动执行重新获取。
React Query 有一个重要的属性称为 staleTime,它定义了数据在成为陈旧并触发后台重新获取之前被认为是新鲜的时长。默认情况下,staleTime 为 0,这意味着在查询成功后数据立即变为陈旧。通过设置 staleTime 的值,如果您的数据不经常变化,您可以避免不必要的重新获取。以下示例显示了如何在您的查询中设置 staleTime:
const { isLoading, isError, data } = useQuery({
queryKey: ['repositories'],
queryFn: getRepositories,
staleTime: 60 * 1000, // in milliseconds -> 1 minute
})
还有一个 cacheTime 属性,它定义了何时回收不活跃的查询,默认时间是 5 分钟。
React Query 通过提供用于创建、更新和删除数据的useMutation钩子,简化了数据处理变动的处理,同时内置了错误处理和缓存失效功能。下面是一个添加新汽车的useMutation示例。现在,因为我们想添加一辆新车,所以我们使用axios.post()方法:
// import useMutation
import { useMutation } from '@tanstack/react-query'
// usage
const mutation = useMutation({
mutationFn: (newCar) => {
return axios.post('api/cars', newCar);
},
onError: (error, variables, context) => {
// Error in mutation
},
onSuccess: (data, variables, context) => {
// Successful mutation
},
})
在更新或删除的情况下,你可以使用axios.put()、axios.patch()或axios.delete()方法。
mutationFn属性值是一个函数,它向服务器发送POST请求并返回一个 promise。React Query 的变动也提供了副作用,如onSuccess和onError,这些可以在变动中使用。onSuccess用于定义一个回调函数,可以根据成功的变动响应执行任何必要的操作,例如更新 UI 或显示成功消息。onError用于指定一个回调函数,如果变动操作遇到错误,该函数将被执行。
然后,我们可以以下方式执行变动:
mutation.mutate(newCar);
QueryClient提供了一个invalidateQueries()方法,可以用来使缓存中的查询失效。如果查询在缓存中失效,它将被重新获取。在之前的例子中,我们使用useMutation将新车添加到服务器。如果我们有一个获取所有汽车的查询,并且查询 ID 是cars,我们可以在新车添加成功后以下方式使其失效:
import { useQuery, useMutation, **useQueryClient** } from
'@tanstack/react-query'
**const** **queryClient =** **useQueryClient****();**
// Fetch all cars
const { data } = useQuery({
queryKey: ['cars'], queryFn: fetchCars
})
// Add a new car
const mutation = useMutation({
mutationFn: (newCar) => {
return axios.post('api/cars', newCar);
},
onError: (error, variables, context) => {
// Error in mutation
},
onSuccess: (data, variables, context) => {
// Invalidate cars query -> refetch
**queryClient.****invalidateQueries****({** **queryKey****: [****'cars'****] });**
},
})
这意味着在服务器添加了一辆新车之后,汽车将被重新获取。
由于 React Query 提供的内置功能,我们不得不编写更少的代码来获得适当的错误处理、数据缓存等功能。现在我们已经学习了使用 React 进行网络操作,我们可以在我们的前端实现中利用这些技能。
摘要
在这一章中,我们专注于使用 React 进行网络操作。我们从 promise 开始,这使得异步网络调用更容易实现。这是一种更干净的方式来处理调用,比使用传统的回调函数要好得多。
在这本书中,我们使用 Axios 和 React Query 库在我们的前端进行网络操作。我们学习了使用这些库的基础知识。我们实现了两个使用fetchAPI 和 Axios 调用 REST API 的 React 示例应用,并在浏览器中展示了响应数据。我们学习了竞争条件,并探讨了如何使用 React Query 库获取数据。
在下一章中,我们将探讨一些有用的 React 组件,我们将在我们的前端中使用它们。
问题
-
什么是 promise?
-
fetch和axios是什么? -
什么是 React Query?
-
使用网络库的好处是什么?
进一步阅读
有其他一些很好的资源可以用来学习关于 promises 和异步操作的知识。以下是一些资源:
-
使用 promises,由 MDN 网络文档(
developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises) -
Fetch 标准 (
fetch.spec.whatwg.org/)
在 Discord 上了解更多信息
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十一章:React 有用的第三方组件
React 是基于组件的,我们可以找到很多有用的第三方组件,我们可以在我们的应用中使用。在本章中,我们将查看我们将要在前端使用的几个组件。我们将探讨如何找到合适的组件,以及你如何在你的应用中使用它们。
在本章中,我们将涵盖以下主题:
-
安装第三方 React 组件
-
使用 AG Grid
-
使用 Material UI 组件库
-
使用 React Router 管理路由
技术要求
必须安装 Node.js。本章所需的 GitHub 链接也将被要求:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter11.
安装第三方 React 组件
有很多不同目的的 React 组件可供使用。你可以通过不从头开始做来节省时间。知名第三方组件也得到了良好的测试,并且有良好的社区支持。
我们的首要任务是找到适合我们需求的组件。一个很好的搜索组件的网站是 JS.coach (js.coach/)。你只需要输入一个关键词,搜索,并从库列表中选择 React。
在下面的屏幕截图中,你可以看到 React 的表格组件搜索结果:

图 11.1:JS.coach
另一个优秀的 React 组件资源是 awesome-react-components:github.com/brillout/awesome-react-components.
组件通常有良好的文档,可以帮助你在自己的 React 应用中使用它们。让我们看看我们如何在应用中安装第三方组件并开始使用它:
-
导航到 JS.coach 网站,在搜索输入字段中输入
date,并通过 React 进行筛选。 -
在搜索结果中,你会看到一个名为
react-date-picker的列表组件(带有两个连字符)。点击组件链接以查看有关组件的更多详细信息。 -
你应该在信息页面上找到安装说明,以及一些如何使用组件的简单示例。你还应该检查组件的开发是否仍然活跃。信息页面通常会提供组件的网站地址或 GitHub 仓库,在那里你可以找到完整的文档。以下屏幕截图显示了
react-date-picker的信息页面:

图 11.2:react-date-picker 信息页面
-
如你所见,组件是通过
npm包安装的。安装组件的命令语法看起来像这样:npm install component_name或者,如果你使用
yarn,它看起来像这样:yarn add component_namenpm的install和yarn的add命令将组件的依赖项保存到您的 React 应用根文件夹中的package.json文件中。现在,我们将安装
react-date-picker组件到我们在 第七章,设置环境和工具 – 前端 中创建的myappReact 应用中。移动到您的应用根文件夹,并输入以下命令:npm install react-date-picker -
如果您从应用的根文件夹打开
package.json文件,您会看到组件现在已被添加到dependencies部分,如下面的代码片段所示:"dependencies": { "react": "¹⁸.2.0", "react-dom": "¹⁸.2.0" **"react-date-picker"****:****"¹⁰.0.3"****,** },如上图所示,您可以从
package.json文件中找到已安装的版本号。如果您想安装特定版本的组件,可以使用以下命令:
npm install component_name@version如果您想从 React 应用中移除已安装的组件,可以使用以下命令:
npm uninstall component_name或者,如果您使用
yarn:yarn remove component_name您可以通过在项目根目录下使用以下命令来查看过时的组件。如果输出为空,则所有组件都是最新版本:
npm outdated您可以通过在项目根目录下使用以下命令来更新所有过时的组件:
npm update您首先应该确保没有可能破坏现有代码的更改。适当的组件有变更日志或发布说明可用,您可以在其中查看新版本中有什么变化。
-
已安装的组件将保存在您应用的
node_modules文件夹中。如果您打开该文件夹,应该会找到react-date-picker文件夹,如下面的截图所示!![img/B19818_11_03.png]()
图 11.3:node_modules
您可以通过以下
npm命令获取您项目的依赖列表:npm list如果您将 React 应用的源代码推送到 GitHub,您不应该包含
node_modules文件夹,因为它包含大量的文件。Vite 项目包含一个.gitignore文件,该文件将node_modules文件夹排除在仓库之外。.gitignore文件的一部分如下所示,您可以看到node_modules出现在文件中:# Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local策略是这样的,当您从 GitHub 仓库克隆 React 应用时,您会输入
npm install命令,该命令从package.json文件中读取依赖项并将其下载到您的应用中。 -
要开始使用您已安装的组件,将其导入到您想要使用它的文件中。实现此目的的代码如下所示:
import DatePicker from 'react-date-picker';
您现在已经学会了如何在 React 应用中安装 React 组件。接下来,我们将开始在 React 应用中使用第三方组件。
使用 AG Grid
AG Grid (www.ag-grid.com/) 是一个灵活的数据网格组件,适用于 React 应用。它就像一个电子表格,您可以使用它来展示您的数据,并且它可以包含交互性。它具有许多有用的功能,如过滤、排序和透视。我们将使用免费(MIT 许可证下)的社区版。
让我们修改我们在第十章 使用 React 消费 REST API 中创建的 GitHub REST API 应用。按照以下步骤操作:
-
要安装
ag-grid社区组件,打开命令行或终端,并切换到restgithub文件夹,这是应用的根文件夹。通过输入以下命令来安装组件:npm install ag-grid-community ag-grid-react -
使用 Visual Studio Code (VS Code) 打开
App.tsx文件,并从return语句中删除table元素。现在,App.tsx文件应该看起来像这样:import { useState } from 'react'; import axios from 'axios'; import './App.css'; type Repository = { id: number; full_name: string; html_url: string; }; function App() { const [keyword, setKeyword] = useState(''); const [repodata, setRepodata] = useState<Repository[]>([]); const handleClick = () => { axios.get<{ items: Repository[] }>(`https ://api.github .com/search/repositories?q=${keyword}`) .then(response => setRepodata(response.data.items)) .catch(err => console.error(err)); } return ( <> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <button onClick={handleClick}>Fetch</button> </> ); export default App; -
在
App.tsx文件的开始处添加以下代码行以导入ag-grid组件和样式表:import { AgGridReact } from 'ag-grid-react'; import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-theme-material.css';ag-grid提供了不同的预定义样式。我们正在使用 Material Design 风格。 -
接下来,我们将把导入的
AgGridReact组件添加到return语句中。为了填充ag-grid组件中的数据,您必须将rowData属性传递给组件。数据可以是一个对象数组,因此我们可以使用我们的状态repodata。ag-grid组件应该被包裹在定义样式的div元素中。代码如下所示:return ( <div className="App"> <input value={keyword} onChange={e => setKeyword(e.target.value)} /> <button onClick={fetchData}>Fetch</button> **<****div****className****=****"ag-theme-material"** **style****=****{{height:****500****,** **width:****850****}}>** **<****AgGridReact** **rowData****=****{repodata}** **/>** **</****div****>** </div> ); -
接下来,我们将为
ag-grid定义列。我们将定义一个名为columnDefs的状态,它是一个列定义对象的数组。ag-grid提供了一个ColDef类型,我们可以在其中使用。在列对象中,您必须使用必需的field属性来定义数据访问器。field的值是列应显示的 REST API 响应数据中的属性名:// Import ColDef type import { ColDef } from 'ag-grid-community'; // Define columns const [columnDefs] = useState<ColDef[]>([ {field: 'id'}, {field: 'full_name'}, {field: 'html_url'}, ]); -
最后,我们将使用 AG Grid 的
columnDefs属性来定义这些列,如下所示:<AgGridReact rowData={data} **columnDefs****=****{columnDefs}** /> -
运行应用并在网页浏览器中打开它。默认情况下,表格看起来相当不错,如下面的截图所示:

图 11.4:ag-grid 组件
-
默认情况下,排序和筛选是禁用的,但您可以使用
ag-grid列中的sortable和filter属性来启用它们,如下所示:const [columnDefs] = useState<ColDef[]>([ {field: 'id'**,** **sortable****:** **true****,** **filter****:** **true**}, {field: 'full_name'**,** **sortable****:** **true****,** **filter****:** **true**}, {field: 'html_url'**,** **sortable****:** **true****,** **filter****:** **true**} ]);现在,您可以通过点击列标题来对网格中的任何列进行排序和筛选,如下面的截图所示:
![图片]()
图 11.5:ag-grid 筛选和排序
-
您还可以通过使用
pagination和paginationPageSize属性来在ag-grid中启用分页并设置页面大小,如下所示:<AgGridReact rowData={data} columnDefs={columnDefs} **pagination****=****{true}** **paginationPageSize****=****{****8}** />现在,您应该能在表格中看到分页,如下面的截图所示:
![图片]()
图 11.6:ag-grid 分页
您可以从 AG Grid 网站找到有关不同网格和列属性的文档:
www.ag-grid.com/react-data-grid/column-properties/。 -
可以使用
cellRenderer属性来自定义表格单元格的内容。以下示例展示了如何在网格单元格中渲染一个按钮:// Import ICellRendererParams import { ICellRendererParams } from 'ag-grid-community'; // Modify columnDefs const columnDefs = useState<ColDef[]>([ {field: 'id', sortable: true, filter: true}, {field: 'full_name', sortable: true, filter: true}, {field: 'html_url', sortable: true, filter: true}, **{** **field****:** **'full_name'****,** **cellRenderer****:** **(****params: ICellRendererParams****) =>** **(** **<****button** **onClick****=****{()** **=>** **alert(params.value)}>** **Press me** **</****button****>** **),** **},** ]);单元渲染器中的函数接受
params作为参数。params的类型是ICellRendererParams,我们必须导入它。params.value将是full_name单元的值,该值定义在列定义的field属性中。如果您需要访问行中的所有值,可以使用params.row,它是一个完整的行对象。这在您需要将整行数据传递给其他组件时非常有用。当按钮被按下时,它将打开一个弹窗,显示full_name单元的值。这是带有按钮的表格截图:
![]()
图 11.7:带有按钮的网格
如果您按下任何按钮,您应该会看到一个弹窗,显示
full_name单元的值。 -
按钮列的标题为
Full_name,因为默认情况下,字段名称用作标题名称。如果您想使用其他名称,可以在列定义中使用headerName属性,如下面的代码所示:const columnDefs: ColDef[] = [ { field: 'id', sortable: true, filter: true }, { field: 'full_name', sortable: true, filter: true }, { field: 'html_url', sortable: true, filter: true }, { **headerName****:** **'Actions'**, field: 'full_name', cellRenderer: (params: ICellRendererParams) => ( <button onClick={() => alert(params.value)}> Press me </button> ), }, ];
在下一节中,我们将开始使用 Material UI 组件库,这是最受欢迎的 React 组件库之一。
使用 Material UI 组件库
Material UI (mui.com/),或 MUI,是一个实现谷歌的 Material Design 语言的 React 组件库 (m2.material.io/design)。Material Design 是当今最受欢迎的设计系统之一。MUI 包含了许多不同的组件——如按钮、列表、表格和卡片——您可以使用它们来实现一个美观且统一的 用户界面(UI)。
在本书中,我们将使用 MUI 版本 5。如果您想使用其他版本,应遵循官方文档(mui.com/material-ui/getting-started/)。MUI 版本 5 支持 Material Design 版本 2。
在本节中,我们将创建一个小型购物清单应用程序,并使用 MUI 组件来设计用户界面。在我们的应用程序中,用户可以输入包含两个字段的购物项目:产品 和 数量。输入的购物项目将以列表形式显示在应用程序中。最终的 UI 界面如下截图所示。添加项目按钮会打开一个模态表单,用户可以在其中输入新的购物项目:

图 11.8:购物清单 UI
现在,我们已经准备好开始实施:
-
创建一个新的名为
shoppinglist的 React 应用程序(选择 React 框架和 TypeScript 变体),并通过运行以下命令安装依赖项:npm create vite@latest cd shoppinglist npm install -
使用 VS Code 打开购物清单应用程序。在 PowerShell 或任何合适的终端中,在项目根目录下输入以下命令来安装 MUI:
npm install @mui/material @emotion/react @emotion/styled -
MUI 默认使用 Roboto 字体,但该字体并非直接提供。安装 Roboto 字体的最简单方法是使用 Google Fonts。要使用 Roboto 字体,请将以下行添加到您的
index.html文件的head元素内部:<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=\ Roboto:300,400,500,700&display=swap" /> -
打开
App.tsx文件并移除片段(<></>)内的所有代码。同时,移除未使用的代码和导入。现在,你的App.tsx文件应该看起来像这样:// App.tsx import './App.css'; function App() { return ( <> </> ); } export default App;你也应该在浏览器中看到一个空页面。
-
MUI 提供了不同的布局组件,基本的布局组件是
Container。这个组件用于水平居中你的内容。你可以使用maxWidth属性指定容器的最大宽度;默认值是lg(大),这对于我们来说很合适。让我们在App.tsx文件中使用Container组件,如下所示:**import****Container****from****'@mui/material/Container'****;** import './App.css'; function App() { return ( **<****Container****>** **</****Container****>** ); } export default App; -
从
main.tsx文件中移除index.css文件导入,以便我们的应用获得全屏。我们也不希望使用 Vite 的预定义样式:// main.tsx import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' **import****'./index.css'****// REMOVE THIS LINE** ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode>, ) -
接下来,我们将使用 MUI 的
AppBar组件在我们的应用中创建工具栏。将AppBar、ToolBar和Typography组件导入到你的App.tsx文件中。同时,导入 React 的useState,我们稍后会用到。代码如下所示:**import** **{ useState }** **from****'react'****;** import Container from '@mui/material/Container'; **import****AppBar****from****'@mui/material/AppBar'****;** **import****Toolbar****from****'@mui/material/Toolbar'****;** **import****Typography****from****'@mui/material/Typography'****;** import './App.css' -
通过向
App组件的return语句中添加以下代码来渲染AppBar。Typography组件提供了预定义的文本大小,我们将在工具栏文本中使用它。variant属性可以用来定义文本大小:function App() { return ( <Container> **<****AppBar****position****=****"static"****>** **<****Toolbar****>** **<****Typography****variant****=****"h6"****>** **Shopping List** **</****Typography****>** **</****Toolbar****>** **</****AppBar****>** </Container> ); } -
启动你的应用。现在它应该看起来像这样:

图 11.9:AppBar 组件
-
在
App组件中,我们需要一个数组状态来保存购物清单项目。一个购物清单项目包含两个字段:product和amount。我们必须为购物项目创建一个类型Item,我们还将导出它,因为我们稍后需要在其他组件中使用它:// App.tsx export type Item = { product: string; amount: string; } -
接下来,我们将创建一个状态来保存我们的购物项目。创建一个名为
items的状态,其类型是Item类型的数组:const [items, setItems] = useState<Item[]>([]); -
然后,创建一个函数来向
items状态中添加新项目。在addItem函数中,我们将使用扩展运算符(...)在现有数组的开头添加一个新项目:const addItem = (item: Item) => { setItems([item, ...items]); } -
我们需要添加一个新的组件来添加购物项目。在应用的根目录中创建一个名为
AddItem.tsx的新文件,并将以下代码添加到你的AddItem.tsx文件中。AddItem组件函数从其父组件接收props。代码如下所示。我们稍后会定义 props 类型:function AddItem(props) { return( <></> ); } export default AddItem;AddItem组件将使用 MUI 模态对话框来收集数据。在表单中,我们将添加两个输入字段product和amount,以及一个调用App组件的addItem函数的按钮。为了能够调用位于App组件中的addItem函数,我们必须在渲染AddItem组件时通过props传递它。在模态Dialog组件外部,我们将添加一个按钮,该按钮打开用户可以输入新购物项目的表单。当组件最初渲染时,这是唯一的可见元素。以下步骤描述了模态形式的实现。
-
我们必须导入以下 MUI 组件用于模态表单:
Dialog、DialogActions、DialogContent和DialogTitle。对于模态表单的 UI,我们需要以下组件:Button和TextField。将以下导入添加到你的AddItem.tsx文件中:import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; -
Dialog组件有一个名为open的 prop,如果其值为true,则对话框可见。openprop 的默认值是false,对话框是隐藏的。我们将声明一个名为open的状态和两个打开和关闭模态对话框的函数。open状态的默认值是false。handleOpen函数将open状态设置为true,而handleClose函数将其设置为false。以下代码片段展示了这一过程:// AddItem.tsx // Import useState import { useState } from 'react'; // Add state, handleOpen and handleClose functions const [open, setOpen] = useState(false); const handleOpen = () => { setOpen(true); } const handleClose = () => { setOpen(false); } -
我们将在
return语句中添加Dialog和Button组件。当组件首次渲染时,我们将有一个按钮在外面可见。当按钮被按下时,它将调用handleOpen函数,这将打开对话框。在对话框内部,我们有两个按钮:一个用于取消,一个用于添加新项目。添加按钮将调用我们稍后将要实现的addItem函数。以下代码片段展示了这一过程:return( <> <Button onClick={handleOpen}> Add Item </Button> <Dialog open={open} onClose={handleClose}> <DialogTitle>New Item</DialogTitle> <DialogContent> </DialogContent> <DialogActions> <Button onClick={handleClose}> Cancel </Button> <Button onClick={addItem}> Add </Button> </DialogActions> </Dialog> </> ); -
为了收集用户数据,我们必须声明一个额外的状态。该状态用于存储用户输入的购物项目,其类型为
Item。我们可以从App组件导入Item类型:// Add the following import to AddItem.tsx import { Item } from './App'; -
将以下状态添加到
AddItem组件中。该状态类型为Item,我们将其初始化为一个空的item对象:// item state const [item, setItem] = useState<Item>({ product: '', amount: '', }); -
在
DialogContent组件内部,我们将添加两个输入框以收集用户数据。在那里,我们将使用已经导入的TextFieldMUI 组件。margin属性用于设置文本框的垂直间距,而fullwidth属性用于使输入框占据其容器全部宽度。你可以在 MUI 文档中找到所有属性。文本框的value属性必须与我们想要保存输入值的item状态相同。当用户在文本框中输入内容时,onChange事件监听器会将输入值保存到item状态中。在product字段中,值保存到item.product属性中,在amount字段中,值保存到item.amount属性中。以下代码片段展示了这一过程:<DialogContent> <TextField value={item.product} margin="dense" onChange={ e => setItem({...item, product: e.target.value}) } label="Product" fullWidth /> <TextField value={item.amount} margin="dense" onChange={ e => setItem({...item, amount: e.target.value}) } label="Amount" fullWidth /> </DialogContent> -
最后,我们必须创建一个函数来调用我们通过 props 接收到的
addItem函数。该函数接受一个新的购物项目作为参数。首先,我们定义 props 的类型。从App组件传递的addItem函数接受一个类型为Item的参数,并且该函数不返回任何内容。类型定义和 prop 类型如下所示:// AddItem.tsx type AddItemProps = { addItem: (item: Item) => void; } function AddItem(props: AddItemProps) { const [open, setOpen] = useState(false); // Continues... -
新的购物项目现在存储在
item状态中,并包含用户输入的值。因为我们从 props 中获取了addItem函数,所以我们可以使用props关键字调用它。我们还将调用handleClose函数,该函数关闭模态对话框。代码如下所示:// Calls addItem function and passes item state into it const addItem = () => { props.addItem(item); // Clear text fields and close modal dialog setItem({ product: '', amount: '' }); handleClose(); } -
我们的
AddItem组件现在已准备就绪,我们必须将其导入到App.tsx文件中并在那里渲染它。在您的App.tsx文件中添加以下import语句:import AddItem from './AddItem'; -
将
AddItem组件添加到App.tsx文件中的return语句中。将addItem函数作为 prop 传递给AddItem组件,如下所示:// App.tsx return ( <Container> <AppBar position="static"> <Toolbar> <Typography variant="h6"> Shopping List </Typography> </Toolbar> </AppBar> **<****AddItem****addItem****=****{addItem}/****>** </Container> ); -
现在,在浏览器中打开您的应用程序并按下 添加项目 按钮。您将看到模态表单打开,您可以输入一个新的项目,如图所示。当您按下 添加 按钮时,模态表单将关闭:

图 11.10:模态对话框
-
接下来,我们将向
App组件添加一个列表,显示我们的购物项目。为此,我们将使用 MUI 的List、ListItem和ListItemText组件。将组件导入到App.tsx文件中:// App.tsx import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -
然后,我们将渲染
List组件。在内部,我们将使用map函数生成ListItem组件。每个ListItem组件都应该有一个唯一的key属性,我们使用divider属性在每个列表项的末尾获取分隔符。我们将在ListItemText组件的主要文本中显示product,在次要文本中显示amount。代码如下所示:// App.tsx return ( <Container> <AppBar position="static"> <Toolbar> <Typography variant="h6"> Shopping List </Typography> </Toolbar> </AppBar> <AddItem addItem={addItem} /> **<****List****>** **{** **items.map((item, index) =>** **<****ListItem****key****=****{index}****divider****>** **<****ListItemText** **primary****=****{item.product}** **secondary****=****{item.amount}/****>** **</****ListItem****>** **)** **}** **</****List****>** </Container> ); -
现在,UI 界面看起来是这样的:

图 11.11:购物列表
MUI 的 Button 组件有三个变体:text、contained 和 outlined。text 变体是默认的,您可以使用 variant 属性来更改它。例如,如果我们想要一个轮廓的 添加项目 按钮,我们可以在 AddItem.ts 文件中更改按钮的 variant 属性,如下所示:
<Button **variant=****"outlined"** onClick={handleOpen}>
Add Item
</Button>
在本节中,我们学习了如何通过使用 Material UI 库在我们的 React 应用程序中获得一致的设计。您可以使用 MUI 轻松地为您的应用程序获得光鲜亮丽和专业的外观。接下来,我们将学习如何使用流行的路由库 React Router。
使用 React Router 管理路由
在 React 中,有几个用于路由的好库可用。例如,Next.js 和 Remix 这样的 React 框架提供了内置的路由解决方案。我们使用最流行的库是 React Router (github.com/ReactTraining/react-router)。对于 Web 应用程序,React Router 提供了一个名为 react-router-dom 的包。React Router 使用 基于 URL 的路由,这意味着我们可以根据 URL 定义要渲染哪个组件。
要开始使用 React Router,我们必须使用以下命令安装依赖项。在本书中,我们将使用 React Router 版本 6:
npm install react-router-dom@6
react-router-dom库提供了用于实现路由的组件。BrowserRouter是基于 Web 的应用程序的路由器。如果给定的位置匹配,Route组件会渲染定义的组件。
provides an example of the Route component. The element prop defines a rendered component when the user navigates to the contact endpoint that is defined in the path prop. The path is relative to the current location:
<Route path="contact" element={<Contact />} />
你可以在path属性的末尾使用一个*通配符,如下所示:
<Route path="/contact/*" element={<Contact />} />
现在,它将匹配所有在联系下的端点——例如,contact/mike、contact/john等等。
Routes组件包裹多个Route组件。Link组件提供应用内的导航。以下示例显示了Contact链接,并在点击链接时导航到/contact端点:
<Link to="/contact">Contact</Link>
让我们看看我们如何在实践中使用这些组件:
-
使用 Vite 创建一个名为
routerapp的新 React 应用,选择React框架和TypeScript变体。移动到你的项目文件夹并安装依赖项。还要安装react-router-dom库:npm create vite@latest cd routerapp npm install npm install react-router-dom@6 -
使用 VS Code 打开
src文件夹,并在编辑视图中打开App.tsx文件。从react-router-dom包中导入组件,并从return语句中删除额外的代码以及未使用的导入。经过这些修改后,你的App.tsx源代码应该看起来像这样:import { BrowserRouter, Routes, Route, Link } from 'react- router-dom'; import './App.css'; function App() { return ( <> </> ); } export default App; -
让我们首先创建两个简单的组件,我们可以在路由中使用它们。在应用的
src文件夹中创建两个新文件,Home.tsx和Contact.tsx。然后,在return语句中添加标题以显示组件的名称。组件的代码如下所示:// Home.tsx function Home() { return <h3>Home component</h3>; } export default Home; // Contact.tsx function Contact() { return <h3>Contact component</h3>; } export default Contact; -
打开
App.tsx文件,然后添加一个允许我们在组件之间导航的路由器,如下所示:import { BrowserRouter, Routes, Route, Link } from 'react- router-dom’; **import****Home****from****'./Home'****;** **import****Contact****from****'./Contact'****;** import './App.css'; function App() { return ( <> **<****BrowserRouter****>** **<****nav****>** **<****Link****to****=****"/"****>****Home****</****Link****>****{' | '}** **<****Link****to****=****"/contact"****>****Contact****</****Link****>** **</****nav****>** **<****Routes****>** **<****Route****path****=****"/"****element****=****{****<****Home** **/>****} />** **<****Route****path****=****"contact"****element****=****{****<****Contact** **/>****} />** **</****Routes****>** **</****BrowserRouter****>** </> ); } export default App; -
现在,当你启动应用时,你会看到链接和
Home组件,它显示在根端点(localhost:5173),这是在第一个Route组件中定义的。你可以在下面的屏幕截图中看到这个表示:

图 11.12:React Router
- 当你点击联系链接时,
Contact组件会被渲染,如下所示:

图 11.13:React Router(继续)
-
你可以通过在
path属性中使用*通配符来创建一个PageNotFound路由。在以下示例中,如果任何其他路由不匹配,则使用最后一个路由。首先,创建一个组件来显示页面未找到的情况:// Create PageNotFound component function PageNotFound() { return <h1>Page not found</h1>; } export default PageNotFound; -
然后,将
PageNotFound组件导入到App组件中,并创建一个新的路由:// Import PageNotFound component into App.tsx import PageNotFound from './PageNotFound'; // Add new page not found route <Routes> <Route path="/" element={<Home />} /> <Route path="contact" element={<Contact />} /> **<****Route****path****=****"*"****element****=****{****<****PageNotFound** **/>****} />** </Routes> -
你也可以有嵌套路由,如下一个示例所示。嵌套路由意味着应用的不同部分可以有自己的路由配置。在以下示例中,
Contact是父路由,它有两个子路由:<Routes> <Route path="contact" element={<Contact />}> <Route path="london" element={<ContactLondon />} /> <Route path="paris" element={<ContactParis />} /> </Route> </Routes>
你可以使用useRoutes()钩子使用 JavaScript 对象而不是 React 元素来声明路由,但本书不会涉及这一点。你可以在 React Router 文档中找到有关钩子的更多信息:reactrouter.com/en/main/start/overview。
到目前为止,你已经学会了如何使用 React 安装和使用各种第三方组件。当我们开始构建前端时,这些技能将在接下来的章节中是必需的。
摘要
在本章中,我们学习了如何使用第三方 React 组件。我们熟悉了几个将在我们的前端项目中使用的组件。ag-grid是一个具有内置功能如排序、分页和过滤的数据网格组件。MUI 是一个提供多个实现谷歌 Material Design 语言的 UI 组件的组件库。我们还学习了如何使用 React Router 在 React 应用程序中进行路由。
在下一章中,我们将创建一个用于开发现有汽车后端前端的环境。
问题
-
你如何找到 React 的组件?
-
你应该如何安装组件?
-
你如何使用
ag-grid组件? -
你如何使用 MUI 组件库?
-
你如何在 React 应用程序中实现路由?
进一步阅读
下面是一些关于学习 React 的资源:
-
Awesome React,一个寻找 React 库和组件的绝佳资源 (
github.com/enaqx/awesome-react) -
值得尝试的顶级 React 组件库,由 Technostacks 提供 (
technostacks.com/blog/react-component-libraries/)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第三部分
全栈开发
第十二章:为我们的 Spring Boot RESTful Web 服务设置前端
本章解释了开始开发我们的汽车数据库应用程序前端部分所需的步骤。我们首先将定义我们正在开发的函数。然后,我们将进行 UI 的模拟。作为后端,我们将使用第五章中提到的 Spring Boot 应用程序,即第五章,保护后端。我们将从后端的不安全版本开始开发。最后,我们将创建用于前端开发的 React 应用程序。
在本章中,我们将涵盖以下主题:
-
模拟 UI
-
准备 Spring Boot 后端
-
创建前端 React 项目
技术要求
我们在第五章中创建的第五章,保护后端中的 Spring Boot 应用程序是必需的。
还需要安装 Node.js,并且需要以下 GitHub 链接中的代码来跟随本章的示例:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter12。
模拟 UI
在本书的前几章中,我们创建了一个提供 RESTful API 的汽车数据库后端。现在,是时候开始构建我们应用程序的前端了。
我们将创建一个具有以下规格的前端:
-
它以表格形式列出数据库中的汽车,并提供分页、排序和筛选功能。
-
有一个按钮可以打开一个用于向数据库添加新汽车的模态表单。
-
在汽车表的每一行中,都有一个按钮可以编辑汽车或从数据库中删除它。
-
表格中有一个链接或按钮,可以导出数据到CSV文件。
UI 模拟通常在前端开发的早期创建,为用户提供用户界面的视觉表示。模拟通常由设计师完成,然后提供给开发者。有许多不同的应用程序可以创建模拟,例如 Figma、Balsamiq 和 Adobe XD,或者您甚至可以使用铅笔和纸。您还可以创建交互式模拟来展示多个功能。
如果您已经完成了模拟,在开始编写任何实际代码之前与客户讨论需求会容易得多。有了模拟,客户也更容易理解前端的概念,并提出对其的修改建议。与实际前端源代码的修改相比,对模拟的修改真的非常容易和快速。
以下截图显示了我们的汽车列表前端示例模拟:

图 12.1:前端模拟
当用户按下+ 创建按钮时打开的模态表单看起来如下:

图 12.2:模态表单模拟
现在我们已经准备好了我们的 UI 模拟,让我们看看如何准备我们的 Spring Boot 后端。
准备 Spring Boot 后端
我们将在本章开始前端开发,使用未加密的后端版本。然后:
-
在 第十三章,添加 CRUD 功能 中,我们将实现所有 CRUD 功能。
-
在 第十四章,使用 MUI 美化前端 中,我们将继续使用 Material UI 组件来完善我们的 UI。
-
最后,在 第十六章,保护您的应用程序 中,我们将启用后端的安全性,进行必要的修改,并实现身份验证。
在 Eclipse 中打开我们在 第五章,保护后端 中创建的 Spring Boot 应用程序。打开定义 Spring Security 配置的 SecurityConfig.java 文件。暂时注释掉当前配置,并允许所有人访问所有端点。参考以下修改:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
{
**// Add this one**
**http.csrf((csrf) -> csrf.disable()).cors(withDefaults())**
**.authorizeHttpRequests((authorizeHttpRequests) ->**
**authorizeHttpRequests.anyRequest().permitAll());**
**/* COMMENT THIS OUT**
http.csrf((csrf) -> csrf.disable())
.cors(withDefaults())
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(\
SessionCreationPolicy.STATELESS))
.authorizeHttpRequests( (authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.anyRequest().authenticated())
.addFilterBefore(authenticationFilter,
UsernamePasswordAuthenticationFilter.class)
.exceptionHandling((exceptionHandling) ->
exceptionHandling.authenticationEntryPoint(
exceptionHandler));
***/**
return http.build();
}
现在,如果您启动 MariaDB 数据库,运行后端,并向 http:/localhost:8080/api/cars 端点发送 GET 请求,您应该会在响应中获取所有汽车,如下面的截图所示:
![图片 B19818_12_03.png]
图 12.3:GET 请求
现在,我们已准备好创建前端 React 项目。
创建前端 React 项目
在我们开始编写前端代码之前,我们必须创建一个新的 React 应用程序。我们将在 React 前端使用 TypeScript:
-
打开 PowerShell 或任何其他合适的终端。通过输入以下命令创建一个新的 React 应用程序:
npm create vite@4在本书中,我们使用 Vite 版本 4.4。您也可以使用最新版本,但那时您需要检查 Vite 文档中的更改。
-
将您的项目命名为
carfront,并选择 React 框架和 TypeScript 变体:
![图片 B19818_12_04.png]
![图 12.4:前端项目]
-
切换到项目文件夹,并输入以下命令安装依赖项:
cd carfront npm install -
通过输入以下命令安装 MUI 组件库,该命令安装了 Material UI 核心库和两个 Emotion 库。Emotion 是一个用于使用 JavaScript 编写 CSS 的库 (
emotion.sh/docs/introduction):npm install @mui/material @emotion/react @emotion/styled -
此外,安装 React Query v4 和 Axios,我们将在前端使用它们进行网络操作:
npm install @tanstack/react-query@4 npm install axios -
在项目的
root文件夹中输入以下命令来运行应用程序:npm run dev -
使用 Visual Studio Code 打开
src文件夹,并从App.tsx文件中删除任何多余的代码。由于我们正在使用 TypeScript,文件扩展名现在是.tsx。此外,删除App.css样式表文件导入。我们将在App.tsx文件中使用 MUI 的AppBar组件来创建应用程序的工具栏。如果您想回顾,我们已经在 第十一章,React 的有用第三方组件 中查看过 MUI 的
AppBar。如下代码所示,将
AppBar组件包裹在 MUI 的Container组件内部,这是一个基本的布局组件,它水平居中你的应用内容。我们可以使用position属性来定义应用栏的定位行为。值static表示当用户滚动时,应用栏不会固定在顶部。如果你使用position="fixed",则将应用栏固定在页面顶部。你还需要导入我们使用的所有 MUI 组件:import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; function App() { return ( <Container maxWidth="xl"> <CssBaseline /> <AppBar position="static"> <Toolbar> <Typography variant="h6"> Car Shop </Typography> </Toolbar> </AppBar> </Container> ); } export default App;maxWidth属性定义了我们的应用的最大宽度,我们使用了最大值。我们还使用了 MUI 的CssBaseline组件,该组件用于解决跨浏览器的不一致性,确保 React 应用的外观在不同浏览器中保持一致。通常,它被包含在应用的最顶层,以确保其样式全局应用。 -
我们将移除所有预定义的样式。因此,从
main.tsx文件中移除index.css样式表导入。代码应如下所示:import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' ReactDOM.createRoot(document.getElementById('root') as HTMLElement). render( <React.StrictMode> <App /> </React.StrictMode>, )
现在,你的前端起点应如下所示:

图 12.5:汽车商店
我们已经为前端创建了 React 项目,可以继续进行进一步的开发。
摘要
在本章中,我们使用在 第五章,保护你的后端 中创建的后端开始了我们的前端开发。我们定义了前端的功能,并创建了 UI 的原型。我们以后端的不安全版本开始前端开发,因此对我们的 Spring Security 配置类进行了一些修改。我们还创建了我们在开发过程中将使用的 React 应用。
在下一章中,我们将向我们的前端添加 创建、读取、更新 和 删除 (CRUD) 功能。
问题
-
为什么你应该对 UI 进行原型设计?
-
你如何从后端禁用 Spring Security?
进一步阅读
对于学习 UI 设计和 MUI,有许多其他优秀的资源可用。这里列出了一些:
-
《别让我思考(第三版):网络可用性的常识方法》,作者 Steve Krug (
sensible.com/dont-make-me-think/) -
MUI 博客——关于 MUI 的最新信息 (
mui.com/blog/) -
Material UI GitHub 仓库 (
github.com/mui/material-ui)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十三章:添加 CRUD 功能
本章描述了如何在我们的前端中实现 创建、读取、更新 和 删除(CRUD)功能。我们将使用我们在 第十一章,React 的有用第三方组件 中学习到的组件。我们将从后端获取数据并在表格中展示数据。然后,我们将实现删除、编辑和创建功能。在本章的最后部分,我们将添加功能,以便我们可以将数据导出为 CSV 文件。
在本章中,我们将涵盖以下主题:
-
创建列表页面
-
添加删除功能
-
添加添加功能
-
添加编辑功能
-
将数据导出为 CSV
技术要求
我们在 第十二章,为我们的 Spring Boot RESTful Web 服务设置前端 中创建的 Spring Boot cardatabase 应用程序(未加密的后端)以及同一章中创建的 React 应用程序 carfront 都是必需的。
还需要以下 GitHub 链接:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter13。
创建列表页面
在本节的第一部分,我们将创建一个列表页面来显示带有分页、过滤和排序功能的汽车:
- 运行你的未加密的 Spring Boot 后端。可以通过向
http://localhost:8080/api/carsURL 发送GET请求来获取汽车,如 第四章,使用 Spring Boot 创建 RESTful Web 服务 中所示。现在,让我们检查响应中的 JSON 数据。汽车数组可以在 JSON 响应数据的_embedded.cars节点中找到:

图 13.1:获取汽车
-
使用 Visual Studio Code 打开
carfrontReact 应用程序(我们在上一章中创建的 React 应用程序)。 -
我们正在使用 React Query 进行网络操作,因此我们首先需要初始化查询提供者。
你在 第十章,使用 React 消费 REST API 中学习了 React Query 的基础知识。
使用 QueryClientProvider 组件来连接并提供 QueryClient 给你的应用程序。打开你的 App.tsx 文件,并将高亮的导入和组件添加到 App 组件中:
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import CssBaseline from '@mui/material/CssBaseline';
**import** **{** **QueryClient****,** **QueryClientProvider** **}** **from****'@tanstack/react-**
**query'****;**
**const** **queryClient =** **new****QueryClient****();**
function App() {
return (
<Container maxWidth="xl">
<CssBaseline />
<AppBar position="static">
<Toolbar>
<Typography variant="h6">
Car Shop
</Typography>
</Toolbar>
</AppBar>
**<****QueryClientProvider****client****=****{queryClient}****>**
**</****QueryClientProvider****>**
</Container>
)
}
export default App;
现在,让我们获取一些汽车。
从后端获取数据
一旦我们知道了如何从后端获取汽车,我们就可以准备实现列表页面来显示汽车。以下步骤描述了实际操作:
- 当你的应用程序有多个组件时,建议为它们创建一个文件夹。在
src文件夹中创建一个名为components的新文件夹。使用 Visual Studio Code,你可以通过在侧边栏文件资源管理器中右键单击文件夹并从菜单中选择 New Folder... 来创建文件夹:

图 13.2:新文件夹
- 在
components文件夹中创建一个名为Carlist.tsx的新文件。你的项目结构应该如下所示:

图 13.3:项目结构
-
在编辑器视图中打开
Carlist.tsx文件,并编写组件的基本代码,如下所示:function Carlist() { return( <></> ); } export default Carlist; -
现在,当我们使用 TypeScript 时,我们必须为我们的汽车数据定义类型。让我们创建一个新文件,在那里定义我们的类型。在你的项目
src文件夹中创建一个名为types.ts的文件。从响应中,你可以看到汽车对象看起来如下,它包含所有汽车属性,还包括链接:{ "brand": "Ford", "model": "Mustang", "color": "Red", "registrationNumber": "ADF-1121", "modelYear": 2023, "price": 59000, "_links": { "self": { "href": "http ://localhost :8080/api/cars/1" }, "car": { "href": "http ://localhost :8080/api/cars/1" }, "owner": { "href": "http ://localhost :8080/api/cars/1/owner" } } } -
在
types.ts文件中创建以下CarResponse类型,并导出它,以便我们可以在需要它的文件中使用:export type CarResponse = { brand: string; model: string; color: string; registrationNumber: string; modelYear: number; price: number; _links: { self: { href: string; }, car: { href: string; }, owner: { href: string; } }; } -
接下来,我们将创建一个函数,通过向我们的后端发送一个
GET请求到http://localhost:8080/api/cars端点来获取汽车。该函数返回一个包含我们定义在types.ts文件中的CarResponse对象数组的promise。我们可以使用Promise<Type>泛型,其中Type表示 promise 解析的值类型。打开Carlist.tsx文件,并添加以下导入和函数:**import** **{** **CarResponse** **}** **from****'../types'****;** **import** **axios** **from****'axios'****;** function Carlist() { **const** **getCars =** **async** **():** **Promise****<****CarResponse****[]> => {** **const** **response =** **await** **axios.****get****(****"http** **://localhost** **:8080/api/** **cars"****);** **return** **response.****data****.****_embedded****.****cars****;** **}** return( <></> ); } export default Carlist; -
接下来,我们将使用
useQuery钩子来获取汽车:**import** **{ useQuery }** **from****'@tanstack/react-query'****;** import { CarResponse } from '../types'; import axios from 'axios'; function Carlist() { const getCars = async (): Promise<CarResponse[]> => { const response = await axios.get("http ://localhost :8080/api/ cars"); return response.data._embedded.cars; } **const** **{ data, error, isSuccess } =** **useQuery****({** **queryKey****: [****"cars"****],** **queryFn****: getCars** **});** return ( <></> ); } export default Carlist;useQuery钩子使用 TypeScript 泛型,因为它不获取数据,也不知道你的数据类型。然而,React Query 可以推断数据类型,因此我们在这里不需要手动使用泛型。如果你显式设置泛型,代码看起来像这样:useQuery<CarResponse[], Error> -
我们将使用条件渲染来检查获取操作是否成功以及是否存在任何错误。如果
isSuccess为false,则表示数据获取仍在进行中,在这种情况下,会返回一个加载信息。我们还会检查error是否为true,这表示存在错误,并返回一个错误信息。当数据可用时,我们使用map函数将汽车对象转换成return语句中的表格行,并添加table元素:// Carlist.tsx if (!isSuccess) { return <span>Loading...</span> } else if (error) { return <span>Error when fetching cars...</span> } else { return ( <table> <tbody> { data.map((car: CarResponse) => <tr key={car._links.self.href}> <td>{car.brand}</td> <td>{car.model}</td> <td>{car.color}</td> <td>{car.registrationNumber}</td> <td>{car.modelYear}</td> <td>{car.price}</td> </tr>) } </tbody> </table> ); } -
最后,我们必须在
App.tsx文件中导入并渲染Carlist组件。在App.tsx文件中,添加import语句,然后渲染Carlist组件在QueryClientProvider组件内部,如高亮所示。QueryClientProvider是一个提供 React Query 上下文到你的组件的组件,它应该包裹你进行 REST API 请求的组件:import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; import { QueryClient, QueryClientProvider } from '@tanstack/react- query'; **import****Carlist****from****'./components/Carlist'****;** const queryClient = new QueryClient(); function App() { return ( <Container maxWidth="xl"> <CssBaseline /> <AppBar position="static"> <Toolbar> <Typography variant="h6"> Car shop </Typography> </Toolbar> </AppBar> <QueryClientProvider client={queryClient}> **<****Carlist** **/>** </QueryClientProvider> </Container> ) } export default App; -
现在,如果你使用
npm run dev命令启动 React 应用,你应该看到以下列表页面。请注意,你的后端也应该正在运行:

图 13.4:汽车前端
使用环境变量
在我们继续之前,让我们做一些代码重构。当我们在源代码中创建更多的 CRUD 功能时,服务器 URL 可以重复多次,并且当后端部署到非本地主机的服务器时,它将发生变化;因此,最好将其定义为 环境变量。然后,当 URL 值更改时,我们只需在一个地方修改它。
当使用 Vite 时,环境变量名称应该以文本 VITE_ 开头。只有以 VITE_ 为前缀的变量才会暴露给您的源代码:
-
在我们应用程序的根文件夹中创建一个新的
.env文件。在编辑器中打开文件,并添加以下行到文件中:VITE_API_URL=http://localhost:8080 -
我们还将所有 API 调用函数分离到它们自己的模块中。在您的项目
src文件夹中创建一个名为api的新文件夹。在api文件夹中创建一个名为carapi.ts的新文件,现在您的项目结构应该如下所示:

图 13.5:项目结构
-
将
getCars函数从Carlist.tsx文件复制到carapi.ts文件中。在函数开头添加export,以便我们可以在其他组件中使用它。在 Vite 中,环境变量通过import.meta.env作为字符串暴露给您的应用程序源代码。然后,我们可以将服务器 URL 环境变量导入到getCars函数中并在那里使用它。我们还需要将axios和CarResponse类型导入到carapi.ts文件中:// carapi.ts import { CarResponse } from '../types'; import axios from 'axios'; **export** const getCars = async (): Promise<CarResponse[]> => { const response = await axios.get(**`****${****import****.meta.env.VITE_API_URL}****/** **api/cars`**); return response.data._embedded.cars; } -
现在,我们可以从
Carlist.tsx文件中移除getCars函数和未使用的axios导入,并从carapi.ts文件中导入它。源代码应如下所示:// Carlist.tsx // Remove getCars function and import it from carapi.ts import { useQuery } from '@tanstack/react-query'; **import** **{ getCars }** **from****'../api/carapi'****;** function Carlist() { const { data, error, isSuccess } = useQuery({ queryKey: ["cars"], queryFn: getCars }); if (!isSuccess) { return <span>Loading...</span> } else if (error) { return <span>Error when fetching cars...</span> } else { return ( <table> <tbody> { data.map((car: CarResponse) => <tr key={car._links.self.href}> <td>{car.brand}</td> <td>{car.model}</td> <td>{car.color}</td> <td>{car.registrationNumber}</td> <td>{car.modelYear}</td> <td>{car.price}</td> </tr>) } </tbody> </table> ); } } export default Carlist;
在这些重构步骤之后,您应该看到与之前相同的汽车列表页面。
添加分页、过滤和排序
我们已经在 第十一章 中使用了 ag-grid 组件来实现数据网格,React 的有用第三方组件,它也可以在这里使用。相反,我们将使用新的 MUI DataGrid 组件来获得开箱即用的分页、过滤和排序功能:
-
在终端中按 Ctrl + C 停止开发服务器。
-
我们将安装 MUI 数据网格社区版。以下是在撰写本文时的安装命令,但您应该从 MUI 文档中检查最新的安装命令和用法:
npm install @mui/x-data-grid -
安装完成后,重新启动应用程序。
-
将
DataGrid组件导入到您的Carlist.tsx文件中。我们还将导入GridColDef,它是 MUI 数据网格中列定义的类型:import { DataGrid, GridColDef } from '@mui/x-data-grid'; -
网格列定义在
columns变量中,该变量具有GridColDef[]类型。列的field属性定义了列中的数据来源;我们使用我们的汽车对象属性。headerName属性可以用来设置列的标题。我们还将设置列的宽度。在Carlist组件内部添加以下列定义代码:const columns: GridColDef[] = [ {field: 'brand', headerName: 'Brand', width: 200}, {field: 'model', headerName: 'Model', width: 200}, {field: 'color', headerName: 'Color', width: 200}, {field: 'registrationNumber', headerName: 'Reg.nr.', width: 150}, {field: 'modelYear', headerName: 'Model Year', width: 150}, {field: 'price', headerName: 'Price', width: 150}, ]; -
然后,从组件的
return语句中移除table及其所有子元素,并添加DataGrid组件。同时移除在map函数中使用的未使用的CarResponse导入。数据网格的数据源是data,它包含获取的汽车,并使用rows属性定义。DataGrid组件要求所有行都具有一个唯一的 ID 属性,该属性使用getRowId属性定义。我们可以使用汽车对象的link字段,因为它包含唯一的汽车 ID(_links.self.href)。请参考以下return语句的源代码:if (!isSuccess) { return <span>Loading...</span> } else if (error) { return <span>Error when fetching cars...</span> } else { return ( **<****DataGrid** **rows****=****{data}** **columns****=****{columns}** **getRowId****=****{row** **=>** **row._links.self.href}** **/>** ); }
使用 MUI DataGrid组件,我们仅通过少量编码就实现了我们表格的所有必要功能。现在,列表页面看起来如下所示:

图 13.6:汽车前端
数据网格列可以通过列菜单和点击筛选菜单项进行筛选。您还可以从列菜单设置列的可见性:

图 13.7:列菜单
接下来,我们将实现删除功能。
添加删除功能
可以通过向http://localhost:8080/api/cars/{carId}端点发送DELETE方法请求从数据库中删除项目。如果我们查看 JSON 响应数据,我们可以看到每辆车都包含一个指向自身的链接,该链接可以从_links.self.href节点访问,如下面的截图所示:

图 13.8:汽车链接
我们已经在上一节中使用了link字段来为网格中的每一行设置一个唯一的 ID。该行 ID 可以在删除时使用,正如我们稍后将要看到的。
以下步骤演示了如何实现删除功能:
-
首先,我们将在 MUI
DataGrid的每一行创建一个按钮。当我们需要更复杂的单元格内容时,我们可以使用renderCell列属性来定义单元格内容如何渲染。让我们使用
renderCell来向表中添加一个新列,以渲染button元素。传递给函数的params参数是一个包含一行所有值的行对象。params的类型是GridCellParams,由 MUI 提供。在我们的情况下,它包含每行中的一个指向汽车的链接,这在删除时是必需的。链接位于行的_links.self.href属性中,我们将传递此值到delete函数。让我们首先在按钮被按下时显示一个带有 ID 的警告框来测试按钮是否正常工作。请参考以下源代码:// Import GridCellParams import { DataGrid, GridColDef, **GridCellParams** } from '@mui/x-data- grid'; // Add delete button column to columns const columns: GridColDef[] = [ {field: 'brand', headerName: 'Brand', width: 200}, {field: 'model', headerName: 'Model', width: 200}, {field: 'color', headerName: 'Color', width: 200}, {field: 'registrationNumber', headerName: 'Reg.nr.', width: 150}, {field: 'modelYear', headerName: 'Model Year', width: 150}, {field: 'price', headerName: 'Price', width: 150}, **{** **field****:** **'delete'****,** **headerName****:** **''****,** **width****:** **90****,** **sortable****:** **false****,** **filterable****:** **false****,** **disableColumnMenu****:** **true****,** **renderCell****:** **(****params: GridCellParams****) =>** **(** **<****button** **onClick****=****{()** **=>** **alert(params.row._links.car.href)}** **>** **Delete** **</****button****>** **),** **},** ];我们不想为
按钮列启用排序和筛选功能,因此将filterable和sortable属性设置为false。我们还将此列的列菜单禁用,通过将disableColumnMenu属性设置为true。按钮在被按下时会调用onDelClick函数,并将一个链接(row.id)作为参数传递给该函数,链接值将在一个警告框中显示。 -
现在,你应该在每个行中看到一个 删除 按钮。如果你按下任何一个按钮,你可以看到一个显示汽车链接的警告。要删除汽车,我们应该向其链接发送一个
DELETE请求:

图 13.9:删除按钮
-
接下来,我们将实现
deleteCar函数,该函数使用 Axios 的delete方法向汽车链接发送DELETE请求。向后端发送DELETE请求会返回一个已删除的汽车对象。我们将在carapi.ts文件中实现deleteCar函数并将其导出。打开carapi.ts文件并添加以下函数:// carapi.ts export const deleteCar = async (link: string): Promise<CarResponse> => { const response = await axios.delete(link); return response.data } -
我们使用 React Query 的
useMutation钩子来处理删除。我们在 第十章 中看到了一个例子。首先,我们必须将useMutation导入到Carlist.tsx文件中。我们还将从carapi.ts文件中导入deleteCar函数:// Carlist.tsx import { useQuery, **useMutation** } from '@tanstack/react-query'; import { getCars, **deleteCar** } from '../api/carapi'; -
添加
useMutation钩子,它调用我们的deleteCar函数:// Carlist.tsx const { mutate } = useMutation(deleteCar, { onSuccess: () => { // Car deleted }, onError: (err) => { console.error(err); }, }); -
然后,在我们的删除按钮中调用
mutate并将汽车链接作为参数传递:// Carlist.tsx columns { field: 'delete', headerName: '', width: 90, sortable: false, filterable: false, disableColumnMenu: true, renderCell: (params: GridCellParams) => ( <button onClick={() => **mutate(params.row._links.car.href)**} > Delete </button> ), }, }); -
现在,如果你启动应用程序并按下 删除 按钮,汽车将从数据库中删除,但在前端它仍然存在。你可以手动刷新浏览器,之后汽车将从表中消失。
-
我们还可以在删除汽车时自动刷新前端。在 React Query 中,获取的数据被保存到一个由查询客户端处理的缓存中。
QueryClient有一个 查询失效 功能,我们可以使用它来重新获取数据。首先,我们必须导入并调用useQueryClient钩子函数,它返回当前的查询客户端:// Carlist.tsx import { useQuery, useMutation, **useQueryClient** } from '@tanstack/ react-query'; import { deleteCar } from '../api/carapi'; import { DataGrid, GridColDef, GridCellParams } from '@mui/x-data- grid'; function Carlist() { **const** **queryClient =** **useQueryClient****();** // continue... -
queryClient有一个invalidateQueries方法,我们可以在成功删除后调用它来重新获取我们的数据。你可以传递你想要重新获取的查询的键。我们获取汽车的查询键是cars,我们在useQuery钩子中定义了它:// Carlist.tsx const { mutate } = useMutation(deleteCar, { onSuccess: () => { **queryClient.****invalidateQueries****({** **queryKey****: [****'cars'****] });** }, onError: (err) => { console.error(err); }, });
现在,每次删除汽车时,都会重新获取所有汽车。当按下 删除 按钮时,汽车从列表中消失。删除后,你可以重新启动后端以重新填充数据库。
你还可以看到,当你点击网格中的任何一行时,该行会被选中。你可以通过将网格的 disableRowSelectionOnClick 属性设置为 true 来禁用此功能:
<DataGrid
rows={cars}
columns={columns}
**disableRowSelectionOnClick={****true****}**
getRowId={row => row._links.self.href}
/>
显示通知消息
如果删除成功或出现任何错误,向用户显示一些反馈会很好。让我们实现一个 通知消息 来显示删除的状态。为此,我们将使用 MUI 的 Snackbar 组件:
-
首先,我们必须通过将以下
import语句添加到我们的Carlist.tsx文件中来导入Snackbar组件:import Snackbar from '@mui/material/Snackbar'; -
Snackbar组件的open属性值是一个布尔值,如果它是true,则组件显示;否则,它隐藏。让我们导入useState钩子并定义一个名为open的状态来处理我们的Snackbar组件的可见性。初始值是false,因为消息只有在删除后才会显示://Carlist.tsx **import** **{ useState }** **from****'react'****;** import { useQuery, useMutation, useQueryClient } from '@tanstack/ react-query'; import { deleteCar } from '../api/carapi'; import { DataGrid, GridColDef, GridCellParams } from '@mui/x-data- grid'; import Snackbar from '@mui/material/Snackbar'; function Carlist() { **const** **[open, setOpen] =** **useState****(****false****);** const queryClient = useQueryClient(); // continue... -
接下来,我们在 MUI
DataGrid组件之后的return语句中添加Snackbar组件。autoHideDuration属性定义了在自动调用onClose函数并消失消息之前的时间(以毫秒为单位)。message属性定义了要显示的消息。我们还需要将DataGrid和Snackbar组件包裹在片段 (<></>) 中:// Carlist.tsx if (!isSuccess) { return <span>Loading...</span> } else if (error) { return <span>Error when fetching cars...</span> } else { return ( **<>** <DataGrid rows={data} columns={columns} disableRowSelectionOnClick={true} getRowId={row => row._links.self.href} /> **<****Snackbar** **open****=****{open}** **autoHideDuration****=****{2000}** **onClose****=****{()** **=>** **setOpen(false)}** **message="Car deleted" />** **</>** ); -
最后,在
useMutation钩子中成功删除后,我们将open状态设置为true:// Carlist.tsx const { mutate } = useMutation(deleteCar, { onSuccess: () => { **setOpen****(****true****);** queryClient.invalidateQueries(["cars"]); }, onError: (err) => { console.error(err); }, });
现在,当车辆被删除时,您将看到以下截图所示的托盘消息:

图 13.10:托盘消息
添加确认对话框窗口
为了避免意外删除车辆,在按下 删除 按钮后有一个确认对话框将很有用。我们将使用 window 对象的 confirm 方法来实现这一点。它打开一个带有可选消息的对话框,如果您按下 确定 按钮,则返回 true。将 confirm 添加到删除按钮的 onClick 事件处理器:
// Carlist.tsx columns
{
field: 'delete',
headerName: '',
width: 90,
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: (params: GridCellParams) => (
<button
onClick={() => **{**
**if (window.confirm(`Are you sure you want to delete ${params.row.**
**brand} ${params.row.model}?`)) {**
**mutate(params.row._links.car.href);**
**}**
**}}**
>
Delete
</button>
),
}
在确认消息中,我们使用了 ES6 字符串插值来显示车辆的品牌和型号。(注意!记得使用反引号。)
如果您现在按下 删除 按钮,将打开确认对话框,并且只有当您按下 确定 按钮时,车辆才会被删除:

图 13.11:确认对话框
接下来,我们将开始实现添加新车的功能。
添加添加功能
下一步是为前端添加添加功能。我们将使用 MUI 模态对话框来实现这一点。
我们在 第十一章 中介绍了 MUI 模态表单,React 的有用第三方组件。
我们将在用户界面中添加一个 新车辆 按钮,当按下时将打开模态表单。模态表单包含添加新车辆所需的所有字段,以及保存和取消的按钮。
以下步骤展示了如何使用模态对话框组件创建添加功能:
-
在
components文件夹中创建一个名为AddCar.tsx的新文件,并将一些功能组件基础代码写入文件,如下所示。添加 MUIDialog组件的导入:import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; function AddCar() { return( <></> ); } export default AddCar; -
我们已经定义了我们的
Car响应数据类型(一个包含链接的车辆对象)。让我们也创建一个不包含链接的车辆对象类型,因为用户不会在表单中输入链接。我们需要这个状态来保存新车辆。将以下Car类型添加到您的types.ts文件中:export type Car = { brand: string; model: string; color: string; registrationNumber: string; modelYear: number; price: number; } -
使用
useState钩子声明一个包含所有车辆字段的Car类型状态。对于对话框,我们还需要一个布尔状态来定义对话框表单的可见性:**import** **{ useState }** **from****'react'****;** import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; **import** **{** **Car** **}** **from****'../types'****;** function AddCar() { **const** **[open, setOpen] =** **useState****(****false****);** **const** **[car, setCar] = useState<****Car****>({** **brand****:** **''****,** **model****:** **''****,** **color****:** **''****,** **registrationNumber****:** **''****,** **modelYear****:** **0****,** **price****:** **0** **});** return( <></> ); } export default AddCar; -
接下来,我们添加两个函数来关闭和打开对话框表单。
handleClose和handleOpen函数设置open状态的值,这会影响模态表单的可见性:// AddCar.tsx // Open the modal form const handleClickOpen = () => { setOpen(true); }; // Close the modal form const handleClose = () => { setOpen(false); }; -
在
AddCar组件的return语句中添加Dialog组件。表单包含带有按钮和收集汽车数据的输入字段的 MUIDialog组件。打开模态窗口的按钮必须位于Dialog组件之外。所有输入字段都应该有一个name属性,其值与将要保存到状态中的名称相同。输入字段还具有onChange属性,通过调用handleChange函数将值保存到car状态。handleChange函数通过创建一个新的对象并基于输入元素的名称和用户输入的新值更新属性来动态更新car状态:// AddCar.tsx const handleChange = (event : React.ChangeEvent<HTMLInputElement>) => { setCar({...car, [event.target.name]: event.target.value}); } return( <> <button onClick={handleClickOpen}>New Car</button> <Dialog open={open} onClose={handleClose}> <DialogTitle>New car</DialogTitle> <DialogContent> <input placeholder="Brand" name="brand" value={car.brand} onChange={handleChange}/><br/> <input placeholder="Model" name="model" value={car.model} onChange={handleChange}/><br/> <input placeholder="Color" name="color" value={car.color} onChange={handleChange}/><br/> <input placeholder="Year" name="modelYear" value={car.modelYear} onChange={handleChange}/><br/> <input placeholder="Reg.nr" name="registrationNumber" value={car.registrationNumber} onChange={handleChange}/><br/> <input placeholder="Price" name="price" value={car.price} onChange={handleChange}/><br/> </DialogContent> <DialogActions> <button onClick={handleClose}>Cancel</button> <button onClick={handleClose}>Save</button> </DialogActions> </Dialog> </> ); -
在
carapi.ts文件中实现addCar函数,该函数将向后端api/cars端点发送POST请求。我们使用 Axios 的post方法发送POST请求。请求将包括在主体内的新汽车对象和'Content-Type':'application/json'头。我们还需要导入Car类型,因为我们正在将新汽车对象作为参数传递给函数:// carapi.ts import { CarResponse, Car} from '../types'; // Add a new car export const addCar = async (car: Car): Promise<CarResponse> => { const response = await axios.post(`${import.meta.env.VITE_API_ URL}/api/cars`, car, { headers: { 'Content-Type': 'application/json', }, }); return response.data; } -
接下来,我们使用与删除功能中相同的 React Query
useMutation钩子。在汽车添加成功后,我们也使汽车查询失效。我们在useMutation钩子中使用的addCar函数是从carapi.ts文件中导入的。将以下导入和useMutation钩子添加到你的AddCar.tsx文件中。我们还需要使用useQueryClient钩子从上下文中获取查询客户端。请记住,上下文用于将查询客户端提供给组件树中深层的组件:// AddCar.tsx // Add the following imports import { useMutation, useQueryClient } from '@tanstack/react-query'; import { addCar } from '../api/carapi'; // Add inside the AddCar component function const queryClient = useQueryClient(); // Add inside the AddCar component function const { mutate } = useMutation(addCar, { onSuccess: () => { queryClient.invalidateQueries(["cars"]); }, onError: (err) => { console.error(err); }, }); -
将
AddCar组件导入到Carlist.tsx文件中:// Carlist.tsx import AddCar from './AddCar'; -
将
AddCar组件添加到Carlist.tsx文件的return语句中。你还需要导入AddCar组件。现在,Carlist.tsx文件的return语句应该如下所示:// Carlist.tsx // Add the following import import AddCar from './AddCar'; // Render the AddCar component return ( <> **<****AddCar** **/>** <DataGrid rows={data} columns={columns} disableRowSelectionOnClick={true} getRowId={row => row._links.self.href}/> <Snackbar open={open} autoHideDuration={2000} onClose={() => setOpen(false)} message="Car deleted" /> </> ); -
如果你启动了汽车商店应用,它现在应该看起来像以下这样:

图 13.12:汽车商店
如果你按下新汽车按钮,它应该打开模态表单。
-
要保存新汽车,在
AddCar.tsx文件中创建一个名为handleSave的函数。handleSave函数调用mutate。然后,我们将car状态重置为其初始状态,并关闭模态表单:// AddCar.tsx // Save car and close modal form const handleSave = () => { mutate(car); setCar({ brand: '', model: '', color: '', registrationNumber:'', modelYear: 0, price: 0 }); handleClose(); } -
最后,我们必须将
AddCar组件的onClick保存按钮更改为调用handleSave函数:// AddCar.tsx <DialogActions> <button onClick={handleClose}>Cancel</button> <button onClick={**handleSave**}>Save</button> </DialogActions> -
现在,你可以通过按下新汽车按钮打开模态表单。你会看到当字段为空时,每个字段中都有占位符文本。你可以用数据填写表单并按下保存按钮。此时,表单的外观并不美观,但我们将在下一章中对其进行样式化:

图 13.13:添加新汽车
- 保存后,列表页面会刷新,新汽车可以在列表中看到:

图 13.14:汽车商店
-
现在,我们可以进行一些代码重构。当我们开始实现编辑功能时,我们实际上需要在编辑表单中与新汽车表单中相同的字段。让我们创建一个新的组件来渲染我们的新汽车表单中的文本字段。想法是将文本字段拆分到它们自己的组件中,然后可以在新汽车和编辑表单中使用这个组件。在
components文件夹中创建一个新的文件名为CarDialogContent.tsx。我们必须使用props将car对象和handleChange函数传递给组件。为此,我们创建了一个新的类型DialogFormProps。我们可以在同一文件中定义这个类型,因为我们不需要在任何其他文件中使用它:// CarDialogContent.tsx import { Car } from '../types'; type DialogFormProps = { car: Car; handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; } function CarDialogContent({ car, handleChange }: DialogFormProps) { return ( <></> ); } export default CarDialogContent; -
然后,我们可以将
DialogContent组件从AddCar组件移动到CarDialogContent组件。您的代码应该如下所示:// CarDialogContent.tsx **import****DialogContent****from****'@mui/material/DialogContent'**; import { Car } from '../types'; type DialogFormProps = { car: Car; handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; } function CarDialogContent({ car, handleChange}: DialogFormProps) { return ( **<****DialogContent****>** **<****input****placeholder****=****"Brand"****name****=****"brand"** **value****=****{car.brand}****onChange****=****{handleChange}/****><****br****/>** **<****input****placeholder****=****"Model"****name****=****"model"** **value****=****{car.model}****onChange****=****{handleChange}/****><****br****/>** **<****input****placeholder****=****"Color"****name****=****"color"** **value****=****{car.color}****onChange****=****{handleChange}/****><****br****/>** **<****input****placeholder****=****"Year"****name****=****"****modelYear"** **value****=****{car.modelYear}****onChange****=****{handleChange}/****><****br****/>** **<****input****placeholder****=****"Reg.nr."****name****=****"registrationNumber"** **value****=****{car.registrationNumber}****onChange****=****{handleChange}/****><****br****/>** **<****input****placeholder****=****"****Price"****name****=****"price"** **value****=****{car.price}****onChange****=****{handleChange}/****><****br****/>** **</****DialogContent****>** ); } export default CarDialogContent; -
现在,我们可以将
CarDialogContent导入到AddCar组件中,并在Dialog组件内部渲染它。使用属性将car状态和handleChange函数传递给组件。同时,从AddCar组件中删除未使用的 MUIDialogContent导入:// AddCar.tsx // Add the following import // and remove unused imports: DialogContent **import****CarDialogContent****from****'./CarDialogContent'****;** // render CarDialogContent and pass props return( <div> <Button onClick={handleClickOpen}>New Car</Button> <Dialog open={open} onClose={handleClose}> <DialogTitle>New car</DialogTitle> **<****CarDialogContent****car****=****{car}****handleChange****=****{handleChange}/****>** <DialogActions> <Button onClick={handleClose}>Cancel</Button> <Button onClick={handleSave}>Save</Button> </DialogActions> </Dialog> </div> ); -
尝试添加一辆新车,它应该像重构之前一样工作。
接下来,我们将开始实现编辑功能。
添加编辑功能
我们将通过在每个表格行中添加编辑按钮来实现编辑功能。当按下行中的编辑按钮时,它将打开一个模态表单,用户可以在其中编辑现有的汽车并保存他们的更改。想法是将网格行中的汽车数据传递到编辑表单中,当表单打开时,表单字段将被填充:
-
首先,在
components文件夹中创建一个名为EditCar.tsx的文件。我们必须为我们的属性定义一个FormProps类型,并且这个类型可以在我们的组件内部定义,因为我们不需要这个类型在其他任何地方。传递给EditCar组件的数据类型是CarResponse类型。我们还将创建一个用于汽车数据的state,就像我们在添加功能部分所做的那样。EditCar.tsx文件的代码如下:// EditCar.tsx import { useState } from 'react'; import { Car, CarResponse } from '../types'; type FormProps = { cardata: CarResponse; } function EditCar({ cardata }: FormProps) { const [car, setCar] = useState<Car>({ brand: '', model: '', color: '', registrationNumber: '', modelYear: 0, price: 0 }); return( <></> ); } export default EditCar; -
我们将创建一个当按下编辑按钮时将打开的对话框。我们需要
open状态来定义对话框是可见的还是隐藏的。添加打开和关闭Dialog组件并保存更新的函数:// EditCar.tsx import { useState } from 'react'; **import****Dialog****from****'@mui/material/Dialog'****;** **import****DialogActions****from****'@mui/material/DialogActions'****;** **import****DialogTitle****from****'@mui/material/DialogTitle'****;** import { Car, CarResponse } from '../types'; type FormProps = { cardata: CarResponse; } function EditCar({ cardata }: FormProps) { const [open, setOpen] = useState(false); const [car, setCar] = useState<Car>({ brand: '', model: '', color: '', registrationNumber: '', modelYear: 0, price: 0 }); **const****handleClickOpen** **= () => {** **setOpen****(****true****);** **};** **const****handleClose** **= () => {** **setOpen****(****false****);** **};** **const****handleSave** **= () => {** **setOpen****(****false****);** **}** return( <> **<****button****onClick****=****{handleClickOpen}****>** **Edit** **</****button****>** **<****Dialog****open****=****{open}****onClose****=****{handleClose}****>** **<****DialogTitle****>****Edit car****</****DialogTitle****>** **<****DialogActions****>** **<****button****onClick****=****{handleClose}****>****Cancel****</****button****>** **<****button****onClick****=****{handleSave}****>****Save****</****button****>** **</****DialogActions****>** **</****Dialog****>** </> ); } export default EditCar; -
接下来,我们将导入
CarDialogContent组件并在Dialog组件内部渲染它。我们还需要添加handleChange函数,该函数将保存编辑的值到car状态。我们使用属性传递car状态和handleChange函数,就像我们之前在添加功能中做的那样:// EditCar.tsx // Add the following import **import****CarDialogContent****from****'./CarDialogContent'****;** // Add handleChange function const handleChange = (event : React.ChangeEvent<HTMLInputElement>) => { setCar({...car, [event.target.name]: event.target.value}); } // render CarDialogContent inside the Dialog return( <> <button onClick={handleClickOpen}> Edit </button> <Dialog open={open} onClose={handleClose}> <DialogTitle>Edit car</DialogTitle> **<****CarDialogContent****car****=****{car}****handleChange****=****{handleChange}/****>** <DialogActions> <button onClick={handleClose}>Cancel</button> <button onClick={handleSave}>Save</button> </DialogActions> </Dialog> </> ); -
现在,我们将使用
handleClickOpen函数中的属性设置car状态值:// EditCar.tsx const handleClickOpen = () => { **setCar****({** **brand****: cardata.****brand****,** **model****: cardata.****model****,** **color****: cardata.****color****,** **registrationNumber****: cardata.****registrationNumber****,** **modelYear****: cardata.****modelYear****,** **price****: cardata.****price** **});** setOpen(true); };
我们的形式将使用传递给组件的属性中的汽车对象的值进行填充。
-
在这一步中,我们将向
Carlist组件中的数据网格添加编辑功能。打开Carlist.tsx文件并导入EditCar组件。创建一个新列,使用renderCell列属性渲染EditCar组件,就像我们在删除功能部分所做的那样。我们将行对象传递给EditCar组件,该对象包含汽车对象:// Carlist.tsx // Add the following import **import****EditCar****from****'./EditCar'****;** // Add a new column const columns: GridColDef[] = [ {field: 'brand', headerName: 'Brand', width: 200}, {field: 'model', headerName: 'Model', width: 200}, {field: 'color', headerName: 'Color', width: 200}, {field: 'registrationNumber', headerName: 'Reg.nr.', width: 150}, {field: 'modelYear', headerName: 'Model Year', width: 150}, {field: 'price', headerName: 'Price', width: 150}, **{** **field****:** **'edit'****,** **headerName****:** **''****,** **width****:** **90****,** **sortable****:** **false****,** **filterable****:** **false****,** **disableColumnMenu****:** **true****,** **renderCell****:** **(****params: GridCellParams****) =>** **<****EditCar****cardata****=****{params.row}** **/>** **},** { field: 'delete', headerName: '', width: 90, sortable: false, filterable: false, disableColumnMenu: true, renderCell: (params: GridCellParams) => ( <button onClick={() => { if (window.confirm(`Are you sure you want to delete ${params.row.brand} ${params.row.model}?`)) mutate(params.row._links.car.href) }}> Delete </button> ), }, ]; -
现在,您应该在汽车列表的每一行中看到编辑按钮。当您按下编辑按钮时,它应该打开汽车表单并使用您按下的按钮所在的行的汽车填充字段:

图 13.15:编辑按钮
-
接下来,我们必须实现发送更新汽车到后端的更新请求。为了更新汽车数据,我们必须向
http://localhost:8080/api/cars/[carid]URL 发送一个PUT请求。链接将与删除功能中的链接相同。请求包含在主体中的更新汽车对象,以及我们为添加功能设置的'Content-Type':'application/json'头。对于更新功能,我们需要一个新的类型。在 React Query 中,突变函数只能接受一个参数,但在我们的情况下,我们必须发送汽车对象(Car类型)及其链接。我们可以通过传递一个包含两个值的对象来解决。打开
types.ts文件并创建以下类型,称为CarEntry:export type CarEntry = { car: Car; url: string; } -
然后,打开
carapi.ts文件,创建以下函数并导出它。该函数接受CarEntry类型的对象作为参数,并具有car和url属性,其中我们获取请求中需要的值:// carapi.ts // Add CarEntry to import import { CarResponse, Car, **CarEntry** } from '../types'; // Add updateCar function export const updateCar = async (carEntry: CarEntry): Promise<CarResponse> => { const response = await axios.put(carEntry.url, carEntry.car, { headers: { 'Content-Type': 'application/json' }, }); return response.data; } -
接下来,我们将
updateCar函数导入到EditCar组件中,并使用useMutation钩子发送请求。在编辑成功后,我们使汽车查询失效以重新获取列表;因此,我们还需要获取查询客户端:// EditCar.tsx // Add the following imports import { updateCar } from '../api/carapi'; import { useMutation, useQueryClient } from '@tanstack/react-query'; // Get query client const queryClient = useQueryClient(); // Use useMutation hook const { mutate } = useMutation(updateCar, { onSuccess: () => { queryClient.invalidateQueries(["cars"]); }, onError: (err) => { console.error(err); } }); -
然后,在
handleSave函数中调用mutate。如前所述,mutate只接受一个参数,我们必须传递汽车对象和 URL;因此,我们创建一个包含这两个值的对象,并将其传递。我们还需要导入CarEntry类型:// EditCar.tsx // Add CarEntry import import { Car, CarResponse, CarEntry } from '../types'; // Modify handleSave function const handleSave = () => { **const** **url = cardata.****_links****.****self****.****href****;** **const****carEntry****:** **CarEntry** **= {car, url}** **mutate****(carEntry);** **setCar****({** **brand****:** **''****,** **model****:** **''****,** **color****:** **''****,** **registrationNumber****:****''****,** **modelYear****:** **0****,** **price****:** **0** **});** setOpen(false); } -
最后,如果您在表中按下编辑按钮,它将打开模态表单并显示该行的汽车。当您按下保存按钮时,更新的值将保存到数据库中:
![图片]()
图 13.16:编辑汽车
类似地,如果您按下新建汽车按钮,它将打开一个空表单,并在表单填写并按下保存按钮时将新汽车保存到数据库中。我们通过使用组件属性,使用一个组件来处理这两个用例。
-
您还可以看到在编辑汽车时后端发生的情况。如果您在成功编辑后查看 Eclipse 控制台,您可以看到有一个
updateSQL 语句更新数据库:

图 13.17:更新汽车语句
现在,我们已经实现了所有 CRUD 功能。
将数据导出到 CSV
我们还将实现一个功能,即数据的 逗号分隔值(CSV)导出。我们不需要任何额外的库来进行导出,因为 MUI 数据网格提供了这个功能。我们将激活数据网格工具栏,它包含许多有用的功能:
-
将以下导入添加到
Carlist.tsx文件中。GridToolbar组件是 MUI 数据网格的工具栏,它包含许多有用的功能,如导出:import { DataGrid, GridColDef, GridCellParams, **GridToolbar** } from '@mui/x-data-grid'; -
我们需要启用我们的工具栏,其中包含 导出 按钮和其他按钮。要在 MUI 数据网格中启用工具栏,你必须使用
slots属性并将值设置为toolbar: GridToolbar。slots属性可以用来覆盖数据网格的内部组件:return( <> <AddCar /> <DataGrid rows={cars} columns={columns} disableRowSelectionOnClick={true} getRowId={row => row._links.self.href} **slots****={{ toolbar: GridToolbar }}** /> <Snackbar open={open} autoHideDuration={2000} onClose={() => setOpen(false)} message="Car deleted" /> </> ); -
现在,你将在网格中看到 导出 按钮。如果你按下按钮并选择 下载为 CSV,网格数据将被导出到 CSV 文件。你可以使用 导出 按钮打印你的网格,你将获得一个打印友好的页面版本(你也可以使用工具栏隐藏和过滤列,并设置行密度):

图 13.18:导出 CSV
-
你可以通过编辑
index.html页面来更改页面标题和图标,如下面的代码所示。图标可以在你的项目的public文件夹中找到,你可以使用自己的图标而不是 Vite 的默认图标:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> **<****link****rel****=****"icon"****type****=****"image/svg+xml"****href****=****"****/vite.svg"** **/>** <meta name="viewport" content="width=device-width, initial- scale=1.0" /> **<****title****>****Car Shop****</****title****>** </head> <body> <div id="root"></div> <script type="module" src="img/main.tsx"></script> </body> </html>
现在,所有功能都已实现。在 第十四章,使用 React MUI 美化前端 中,我们将专注于美化前端。
摘要
在本章中,我们实现了我们应用的所有功能。我们从从后端获取汽车并在 MUI DataGrid 中显示它们开始,MUI DataGrid 提供了分页、排序和过滤功能。然后,我们实现了删除功能并使用 SnackBar 组件向用户反馈。
使用 MUI 模态 dialog 组件实现了添加和编辑功能。最后,我们实现了将数据导出到 CSV 文件的能力。
在下一章中,我们将使用 React Material UI 组件库来美化我们的前端。
问题
-
你如何使用 React 和 REST API 获取并展示数据?
-
你如何使用 React 和 REST API 删除数据?
-
你如何使用 React 和 MUI 显示 toast 消息?
-
你如何使用 React 和 REST API 添加数据?
-
你如何使用 React 和 REST API 更新数据?
-
你如何使用 React 将数据导出到 CSV 文件?
进一步阅读
对于学习 React 和 React Query,还有其他很好的资源。例如:
-
实用 React 查询 – TkDoDo 的博客,由 Dominik Dorfmeister 提供 (
tkdodo.eu/blog/practical-react-query) -
Material Design Blog,由 Google 提供 (
material.io/blog/)
在 Discord 上了解更多
要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问以及了解新书发布——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十四章:使用 MUI 设计前端
本章解释了如何在我们的前端使用 Material UI (MUI)组件。我们将使用 Button 组件来显示样式化按钮。我们还将使用 MUI 图标和 IconButton 组件。我们模态表单中的输入字段将被 TextField 组件替换。
在本章中,我们将涵盖以下主题:
-
使用 MUI
Button组件 -
使用 MUI
Icon和IconButton组件 -
使用 MUI
TextField组件
在本章结束时,我们将拥有一个专业且光鲜的用户界面,React 前端中的代码更改最小。
技术要求
我们在第五章,保护后端中创建的 Spring Boot 应用程序是必需的,以及第十二章,为我们的 Spring Boot RESTful Web 服务设置前端(未加密的后端)中的修改。
我们还需要在第十三章,添加 CRUD 功能中使用的 React 应用程序。
在以下 GitHub 链接中可用的代码示例也将被需要:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter14。
使用 MUI Button 组件
我们的前端已经使用了一些 Material UI 组件,例如 AppBar 和 Dialog,但我们仍然在使用大量未加样式的 HTML 元素。首先,我们将用 Material UI Button 组件替换 HTML 按钮元素。
执行以下步骤以在我们的 新汽车 和 编辑汽车 模态表单中实现 Button 组件:
-
将 MUI
Button组件导入到AddCar.tsx和EditCar.tsx文件中:// AddCar.tsx & EditCar.tsx import Button from '@mui/material/Button'; -
将
AddCar组件中的按钮更改为使用Button组件。我们正在使用'``text``'按钮,这是Button的默认类型。如果你想要使用其他按钮类型,例如
'``outlined``',你可以通过使用variant属性来更改它(mui.com/material-ui/api/button/#Button-prop-variant)。以下代码显示了
AddCar组件的return语句及其更改:// AddCar.tsx return( <> **<****Button****onClick****=****{handleClickOpen}****>****New Car****</****Button****>** <Dialog open={open} onClose={handleClose}> <DialogTitle>New car</DialogTitle> <CarDialogContent car={car} handleChange={handleChange}/> <DialogActions> **<****Button****onClick****=****{handleClose}****>****Cancel****</****Button****>** **<****Button****onClick****=****{handleSave}****>****Save****</****Button****>** </DialogActions> </Dialog> </> ); -
将
EditCar组件中的按钮更改为Button组件。我们将设置 编辑 按钮的size为"``small``",因为按钮是在汽车网格中显示的。以下代码显示了EditCar组件的return语句及其更改:// EditCar.tsx return( <> **<****Button****size****=****"small"****onClick****=****{handleClickOpen}****>** Edit **</****Button****>** <Dialog open={open} onClose={handleClose}> <DialogTitle>Edit car</DialogTitle> <CarDialogContent car={car} handleChange={handleChange}/> <DialogActions> **<****Button****onClick****=****{handleClose}****>****Cancel****</****Button****>** **<****Button****onClick****=****{handleSave}****>****Save****</****Button****>** </DialogActions> </Dialog> </> ); -
现在,汽车列表看起来如下截图所示:

图 14.1:Carlist 按钮
模态表单按钮应如下所示:

图 14.2:表单按钮
现在,添加和编辑表单中的按钮已经使用 MUI Button 组件实现。
使用 MUI 图标和 IconButton 组件
在本节中,我们将使用IconButton组件在网格中的EDIT和DELETE按钮。MUI 提供了预构建的 SVG 图标,我们必须通过在终端中使用以下命令来安装:
npm install @mui/icons-material
让我们先在网格中实现DELETE按钮。MUI IconButton组件可以用来渲染图标按钮。我们刚刚安装的@mui/icons-material包包含许多可以与 MUI 一起使用的图标。
你可以在 MUI 文档中找到可用的图标列表(mui.com/material-ui/material-icons/)。有一个搜索功能,如果你点击列表中的任何图标,你可以找到特定图标的正确导入语句:

图 14.3:Material Icons
我们需要一个图标来为我们的DELETE按钮,所以我们将使用一个名为DeleteIcon的图标:
-
打开
Carlist.tsx文件并添加以下导入:// Carlist.tsx import IconButton from '@mui/material/IconButton'; import DeleteIcon from '@mui/icons-material/Delete'; -
接下来,我们将在我们的网格中渲染
IconButton组件。我们将修改代码中定义网格列的DELETE按钮。将button元素改为IconButton组件,并在IconButton组件内渲染DeleteIcon。将按钮和图标的大小都设置为小。图标按钮没有可访问的名称,因此我们将使用aria-label来定义一个标签我们的删除图标按钮的字符串。aria-label属性仅对辅助技术(如屏幕阅读器)可见:// Carlist.tsx const columns: GridColDef[] = [ {field: 'brand', headerName: 'Brand', width: 200}, {field: 'model', headerName: 'Model', width: 200}, {field: 'color', headerName: 'Color', width: 200}, {field: 'registrationNumber', headerName: 'Reg.nr.', width: 150}, {field: 'modelYear', headerName: 'Model Year', width: 150}, {field: 'price', headerName: 'Price', width: 150}, { field: 'edit', headerName: '', width: 90, sortable: false, filterable: false, disableColumnMenu: true, renderCell: (params: GridCellParams) => <CarForm mode="Edit" cardata={params.row} /> }, { field: 'delete', headerName: '', width: 90, sortable: false, filterable: false, disableColumnMenu: true, renderCell: (params: GridCellParams) => ( **<****IconButton****aria-label****=****"delete"****size****=****"small"** onClick={() => { if (window.confirm(`Are you sure you want to delete ${params.row.brand} ${params.row.model}?`)) mutate(params.row._links.car.href) }}> **<****DeleteIcon****fontSize****=****"small"** **/>** **</****IconButton****>** ), }, ]; -
现在,网格中的DELETE按钮应该看起来像下面的截图:

图 14.4:删除图标按钮
-
接下来,我们将使用
IconButton组件实现EDIT按钮。打开EditCar.tsx文件并导入IconButton组件和EditIcon图标:// EditCar.tsx import IconButton from '@mui/material/IconButton'; import EditIcon from '@mui/icons-material/Edit'; -
然后,在
return语句中渲染IconButton和EditIcon。按钮和图标的大小设置为小,与删除按钮相同:// EditCar.tsx return( <> **<****IconButton****aria-label****=****"edit"****size****=****"small"** **onClick****=****{handleClickOpen}****>** **<****EditIcon****fontSize****=** **"small"** **/>** **</****IconButton****>** <Dialog open={open} onClose={handleClose}> <DialogTitle>Edit car</DialogTitle> <CarDialogContent car={car} handleChange={handleChange}/> <DialogActions> <Button onClick={handleClose}>Cancel</Button> <Button onClick={handleSave}>Save</Button> </DialogActions> </Dialog> </> ); -
最后,你将看到两个按钮都作为图标渲染,如下面的截图所示:

图 14.5:图标按钮
我们还可以使用Tooltip组件为我们的编辑和删除图标按钮添加工具提示。Tooltip组件包裹你想要附加工具提示的组件。以下示例显示了如何为编辑按钮添加工具提示:
-
首先,通过在
EditCar组件中添加以下导入来导入Tooltip组件:import Tooltip from '@mui/material/Tooltip'; -
然后,使用
Tooltip组件包裹IconButton组件。title属性用于定义在工具提示中显示的文本:// EditCar.tsx **<****Tooltip** **title=****"Edit car"****>** <IconButton aria-label="edit" size="small" onClick={handleClickOpen}> <EditIcon fontSize= "small" /> </IconButton> **</****Tooltip****>** -
现在,如果你将鼠标悬停在编辑按钮上,你将看到一个工具提示,如下面的截图所示:

图 14.6:Tooltip
接下来,我们将使用 MUI TextField组件实现文本字段。
使用 MUI TextField组件
在本节中,我们将将模态表单中的文本输入字段更改为 MUI TextField和Stack组件:
-
将以下导入语句添加到
CarDialogContent.tsx文件中。Stack是一个一维 MUI 布局组件,我们可以用它来设置文本字段之间的间距:import TextField from '@mui/material/TextField'; import Stack from '@mui/material/Stack'; -
然后,将添加和编辑表单中的输入元素更改为
TextField组件。我们使用label属性来设置TextField组件的标签。有三种不同的文本输入变体(视觉样式)可用,我们使用的是outlined变体,这是默认变体。其他变体是standard和filled。你可以使用variant属性来更改值。文本字段被包裹在Stack组件中,以便在组件之间获得一些间距并设置顶部边距:// CarDialogContent.tsx return ( <DialogContent> **<****Stack****spacing****=****{2}****mt****=****{1}****>** **<****TextField****label****=****"Brand"****name****=****"brand"** **value****=****{car.brand}****onChange****=****{handleChange}/****>** **<****TextField****label****=****"Model"****name****=****"model"** **value****=****{car.model}****onChange****=****{handleChange}/****>** **<****TextField****label****=****"Color"****name****=****"color"** **value****=****{car.color}****onChange****=****{handleChange}/****>** **<****TextField****label****=****"Year"****name****=****"modelYear"** **value****=****{car.modelYear}****onChange****=****{handleChange}/****>** **<****TextField****label****=****"Reg.nr."****name****=****"registrationNumber"** **value****=****{car.registrationNumber}****onChange****=****{handleChange}/****>** **<****TextField****label****=****"Price"****name****=****"price"** **value****=****{car.price}****onChange****=****{handleChange}/****>** **</****Stack****>** </DialogContent> );你可以在
mui.com/system/spacing/上了解更多关于间距和所使用的单位的信息。 -
修改后,添加和编辑模态表单应该看起来如下,因为我们在这两个表单中都使用了
CarDialogContent组件:

图 14.7:文本字段
现在,我们已经使用 MUI 组件完成了前端样式的设计。
摘要
在本章中,我们使用 MUI 最终完成了前端的设计,MUI 是一个实现谷歌 Material Design 的 React 组件库。我们用 MUI 的 Button 和 IconButton 组件替换了按钮。我们的模态表单通过 MUI 的 TextField 组件获得了新的外观。经过这些修改后,我们的前端看起来更加专业和统一。
在下一章中,我们将专注于前端测试。
问题
-
MUI 是什么?
-
你如何使用不同的 Material UI 组件?
-
你如何使用 MUI 图标?
进一步阅读
- 另一个了解 Material UI 的好资源是 MUI 设计资源 (
mui.com/material-ui/getting-started/design-resources/.)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新发布的内容——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十五章:测试 React 应用
本章解释了测试 React 应用的基础知识。它将为我们提供一个使用 Jest 的概述,Jest 是一个 JavaScript 测试框架。我们将探讨如何创建和运行新的测试套件和测试。为了测试我们的 React Vite 项目,我们还将学习如何结合使用 React Testing Library 和 Vitest。
在本章中,我们将涵盖以下主题:
-
使用 Jest
-
使用 React 测试库
-
使用 Vitest
-
在测试中触发事件
-
端到端测试
技术要求
我们在 第五章,保护后端安全 中创建的 Spring Boot 应用程序,以及我们在 第十四章,使用 React MUI 设计前端样式 中使用的 React 应用程序都是必需的。
在以下 GitHub 链接中可用的代码示例也将需要跟随本章:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter15。
使用 Jest
Jest 是由 Meta Inc. 开发的 JavaScript 测试框架,jestjs.io/。它广泛用于 React,并为测试提供了许多有用的功能。例如,您可以创建一个 快照 测试,从中可以获取 React 树的快照,并调查状态是如何变化的。Jest 具有模拟功能,您可以使用它来测试,例如,您的异步 REST API 调用。它还提供了在测试用例中进行断言所需的函数。
为了演示语法,我们将看到如何为一个基本的 TypeScript 函数创建一个测试用例,该函数执行一些简单的计算。以下函数接受两个数字作为参数,并返回这两个数字的乘积:
// multi.ts
export const calcMulti = (x: number, y: number): number => {
return x * y;
}
以下代码片段显示了针对前面函数的 Jest 测试:
// multi.test.ts
import { calcMulti } from './multi';
test("2 * 3 equals 6", () => {
expect(calcMulti(2, 3)).toBe(6);
});
测试用例以一个 test() 方法开始,该方法运行测试用例。test() 方法需要两个必需的参数:测试名称(一个描述性字符串)和包含测试代码的匿名函数。当您想要测试值时,将使用 expect() 函数,它为您提供了访问多个 匹配器 的权限。toBe() 函数是一个匹配器,它检查函数的结果是否等于匹配器中的值。
Jest 中有许多不同的匹配器可用,您可以在文档中找到它们:jestjs.io/docs/using-matchers。
describe() 是一个在测试套件中用来将相关的测试用例组合在一起的功能。它帮助您根据功能组织测试,或者在 React 中,根据被测试的组件来组织。在下面的示例中,我们有一个包含 App 组件两个测试用例的测试套件:
describe("App component", () => {
test("App component renders", () => {
// 1st test case
})
test("Header text", () => {
// 2nd test case
})
});
使用 React 测试库
React Testing Library (testing-library.com/)是一套用于测试 React 组件的工具和 API。它可以用于 DOM 测试和查询。React Testing Library 提供了一套查询函数,帮助您根据文本内容、标签等搜索元素。它还提供了模拟用户操作的工具,例如点击按钮和输入字段。
让我们通过一些 React Testing Library 中的重要概念来学习。Testing Library 提供了一个render()方法,它将 React 元素渲染到 DOM 中,使其可用于测试:
import { render } from '@testing-library/react'
render(<MyComponent />);
查询可以用来在页面上查找元素。screen对象是一个用于查询渲染组件的实用工具。它提供了一套查询方法,可以用来在页面上查找元素。有不同类型的查询,以不同的关键词开头:getBy、findBy或queryBy。getBy和findBy查询如果没有找到元素会抛出错误。queryBy查询如果没有找到元素则返回null。
应该使用哪种查询取决于具体情况,你可以在testing-library.com/docs/dom-testing-library/cheatsheet/了解更多关于不同查询之间的差异。
例如,getByText()方法会在文档中查询包含指定文本的元素:
import { render, screen } from '@testing-library/react'
render(<MyComponent />);
// Find text Hello World (case-insensitive)
screen.getByText(/Hello World/i);
/Hello World/i中的正斜杠(/)用于定义正则表达式模式,而末尾的i标志代表不区分大小写。这意味着它正在寻找包含“Hello World”文本的内容,且不区分大小写。你也可以通过传递一个字符串作为参数来使用一个完全匹配的字符串,该字符串是区分大小写的:
screen.getByText("Hello World");
然后,我们可以使用expect来进行断言。jest-dom是 React Testing Library 的配套库,它提供了一些在测试 React 组件时非常有用的自定义匹配器。例如,它的toBeInTheDocument()匹配器检查元素是否存在于文档中。如果以下断言通过,则测试用例将通过;否则,它将失败:
import { render, screen } from '@testing-library/react'
import matchers from '@testing-library/jest-dom/matchers ';
render(<MyComponent />);
expect(screen.getByText(/Hello World/i)).toBeInTheDocument();
你可以在jest-dom文档中找到所有匹配器:github.com/testing-library/jest-dom。
现在我们已经学习了 Jest 和 React Testing Library 的基础知识。这两个库都是测试 React 应用所必需的。Jest 是一个提供测试环境和断言库的测试框架。React Testing Library 是一个专为测试 React 组件设计的实用库。接下来,我们将学习如何在 Vite 项目中开始测试。
使用 Vitest
Vitest (vitest.dev/) 是 Vite 项目的测试框架。在 Vite 项目中也可以使用 Jest,并且有一些库提供了 Jest 的 Vite 集成(例如,github.com/sodatea/vite-jest)。在这本书中,我们将使用 Vitest,因为它更容易与 Vite 一起使用。Vitest 与 Jest 类似,它提供了test、describe和expect,这些我们在 Jest 部分已经了解过。
在本节中,我们将为我们在第十四章中使用的用于使用 MUI 设计前端的前端项目创建使用 Vitest 和 React 测试库的测试。
安装和配置
第一步是将 Vitest 和 React 测试库安装到我们的项目中:
-
在 Visual Studio Code 中打开项目。在终端中移动到你的项目文件夹,并在你的项目文件夹内执行以下
npm命令:npm install -D vitest @testing-library/react @testing-library/jest- dom jsdomnpm命令中的-D标志表示应该将包保存为package.json文件devDependencies部分的开发依赖项。这些包对于开发和测试是必要的,但不是应用程序生产运行时所需的。 -
接下来,我们必须通过使用 Vite 配置文件
vite.config.ts来配置 Vitest。打开文件,并添加一个新的test属性,进行以下更改:import { defineConfig } from 'vite/config' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], **test****: {** **globals****:** **true****,** **environment****:** **'jsdom'****,** **},** })默认情况下,Vitest 不提供全局 API。
globals: true设置允许我们全局引用 API(如test、expect等),就像 Jest 一样。environment: 'jsdom '设置定义了我们正在使用浏览器环境而不是 Node.js。 -
现在,你可以在
test属性中看到一个 TypeScript 类型错误,因为test类型在 Vite 的配置中不存在。你可以从 Vitest 导入扩展的 Vite 配置来消除错误。按照以下代码修改defineConfig导入:// Modify defineConfig import import { defineConfig } from **'****vitest/config'** -
接下来,我们将向
package.json文件添加test脚本:"scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint src --ext ts,tsx --report-unused-disable- directives --max-warnings 0", "preview": "vite preview", **"test"****:****"vitest"** }, -
现在,我们可以使用以下
npm命令运行我们的测试。在这个阶段,你会得到一个错误,因为我们还没有任何测试:npm run test
如果你想要从 VS Code IDE 运行测试,也可以找到一个 Vitest 的 Visual Studio Code 扩展:marketplace.visualstudio.com/items?itemName=ZixuanChen.vitest-explorer。
默认情况下,测试运行中要包含的文件是通过以下全局模式定义的(vitest.dev/config/#include):
['**/*.{test,spec}.?(c|m)[jt]s?(x)']
我们将使用component.test.tsx命名约定来命名我们的测试文件。
运行我们的第一个测试
现在,我们将创建我们的第一个测试用例,以验证我们的App组件是否渲染,并且可以找到应用头部文本:
-
在你的 React 应用程序的
src文件夹中创建一个名为App.test.tsx的新文件,并创建一个新的测试用例。因为我们使用 Vitest,所以从vitest导入describe和test:import { describe, test } from 'vitest'; describe("App tests", () => { test("component renders", () => { // Test case code }) }); -
然后,我们可以使用 React Testing Library 的
render方法来渲染我们的App组件:import { describe, test } from 'vitest'; **import** **{ render }** **from****'@testing-library/react'****;** **import****App****from****'./App'****;** describe("App tests", () => { test("component renders", () => { **render****(****<****App** **/>****);** }) }); -
接下来,我们使用
screen对象及其查询 API 来验证应用头部文本已被渲染:import { describe, test, **expect** } from 'vitest'; import { render, **screen** } from '@testing-library/react'; import App from './App'; describe("App tests", () => { test("component renders", () => { render(<App />); **expect****(screen.****getByText****(****/Car Shop/i****)).****toBeDefined****();** }) }); -
如果你想要使用
jest-dom库的匹配器,例如我们之前使用的toBeInTheDocument(),你应该导入jest-dom/vitest包,它扩展了匹配器:import { describe, test, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import App from './App'; **import****'@testing-library/jest-dom/vitest'****;** describe("App tests", () => { test("component renders", () => { render(<App />); expect(screen.getByText(/Car Shop/i ))**.toBeInTheDocument****();** }) }); -
最后,我们可以在终端中输入以下命令来运行我们的测试:
npm run test
我们应该看到测试通过了:

图 15.1:测试运行
测试是在监视模式下运行的,这意味着每次你修改源代码时,与代码更改相关的测试都会重新运行。你可以通过按q键退出监视模式,如图所示。你也可以通过按r键手动触发测试重新运行。
如果需要,你可以创建一个测试设置文件,该文件可以用于设置运行测试所需的环境和配置。设置文件将在每个测试文件之前运行。
你必须在vite.config.ts文件中的test节点中指定测试设置文件的路径:
// vite.config.ts
test: {
**setupFiles****: [****'./src/testSetup.ts'****],**
globals: true,
environment: 'jsdom',
},
你还可以执行在测试用例前后所需的任务。Vitest 提供了beforeEach和afterEach函数,你可以使用这些函数在测试用例前后调用代码。例如,你可以在每个测试用例之后运行 React Testing Library 的cleanup函数来卸载已挂载的 React 组件。如果你只想在所有测试用例之前或之后调用一次代码,你可以使用beforeAll或afterAll函数。
测试我们的 Carlist 组件
现在我们来为我们的Carlist组件编写一个测试。我们将使用我们的后端 REST API,在这一节中,你应该运行我们在上一章中使用过的后端。在测试中使用真实 API 更接近真实世界场景,并允许进行端到端集成测试。然而,真实 API 总是有一些延迟,使得测试运行速度变慢。
你也可以使用模拟 API。如果开发者没有访问真实 API 的权限,这很常见。使用模拟 API 需要创建和维护模拟 API 的实现。对于 React,有几个库可以用来实现这一点,例如msw(模拟服务工作者)和nock。
让我们开始吧:
-
在你的
src文件夹中创建一个名为Carlist.test.tsx的新文件。我们将导入Carlist组件并将其渲染。当后端数据尚未可用时,组件会渲染'Loading...'文本。起始代码如下所示:import { describe, expect, test } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import Carlist from './components/Carlist'; describe("Carlist tests", () => { test("component renders", () => { render(<Carlist />); expect(screen.getByText(/Loading/i)).toBeInTheDocument(); }) }); -
现在,如果你运行你的测试用例,你将得到以下错误:未设置 QueryClient,使用 QueryClientProvider 设置一个。我们在
Carlist组件中使用 React Query 进行网络操作;因此,我们需要在组件中使用QueryClientProvider。下面的源代码显示了我们可以如何做到这一点。我们必须创建一个新的QueryClient并将重试设置为false。默认情况下,React Query 会重试查询三次,这可能会在你想测试错误情况时导致测试用例超时:**import** **{** **QueryClient****,** **QueryClientProvider** **}** **from** **'@tanstack/react-query'****;** import { describe, test } from 'vitest'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import Carlist from './components/Carlist'; **const** **queryClient =** **new****QueryClient****({** **defaultOptions****: {** **queries****: {** **retry****:** **false****,** **},** **},** **});** **const****wrapper** **= (****{** **children } : { children: React.ReactNode }****) => (** **<****QueryClientProvider****client****=** **{** **queryClient}****>****{children}** **</****QueryClientProvider****>****);** describe("Carlist tests", () => { test("component renders", () => { render(<Carlist />, **{ wrapper }**); expect(screen.getByText(/Loading/i)).toBeInTheDocument(); }) });我们还创建了一个返回
QueryClientProvider组件的包装器。然后,我们使用render函数的第二个参数并传递我们的wrapper,这是一个 React 组件,这样wrapper就可以包裹Carlist组件。当你想要用额外的包装器包裹你的组件时,这是一个有用的功能。最终结果是Carlist组件被包裹在QueryClientProvider内部。 -
现在,如果你重新运行你的测试,你将不会得到错误,并且你的新测试用例将通过。测试运行现在包括两个测试文件和两个测试:

图 15.2:测试运行
-
接下来,我们将测试我们的
getCars获取是否被调用,并且汽车在数据网格中渲染。网络调用是异步的,我们不知道响应何时到达。我们将使用 React Testing Library 的waitFor函数等待直到新汽车按钮被渲染,因为那时我们知道网络请求已经成功。测试将在条件满足后继续进行。最后,我们将使用匹配器来检查文档中是否可以找到
Ford文本。将以下高亮的导入添加到Carlist.test.tsx文件中:import { render, screen, waitFor } from '@testing-library/ react'; -
测试看起来如下:
describe("Carlist tests", () => { test("component renders", () => { render(<Carlist />, { wrapper }); expect(screen.getByText(/Loading/i) ).toBeInTheDocument(); }) **test****(****"Cars are fetched"****,** **async** **() => {** **render****(****<****Carlist** **/>****, { wrapper });** **await****waitFor****(****() =>** **screen.****getByText****(****/New Car/i****));** **expect****(screen.****getByText****(****/Ford/i****)).****toBeInTheDocument****();** **})** }); -
如果你重新运行测试,你现在可以看到三个测试通过了:

图 15.3:测试运行
我们现在已经学习了 Vitest 的基础知识以及如何在 Vite React 应用中创建和运行测试用例。接下来,我们将学习如何在测试用例中模拟用户操作。
测试中触发事件
React Testing Library 提供了一个fireEvent()方法,可以在测试用例中触发 DOM 事件。fireEvent()方法的使用方式如下。首先,我们必须从 React Testing Library 中导入它:
import { render, screen, fireEvent } from '@testing-library/react';
接下来,我们必须找到元素并触发其事件。以下示例显示了如何触发输入元素的更改事件和按钮的点击事件:
// Find input element by placeholder text
const input = screen.getByPlaceholderText('Name');
// Set input element's value
fireEvent.change(input, {target: {value: 'John'}});
// Find button element by text
const btn = screen.getByText('Submit');
// Click button
fireEvent.click(btn);
事件触发后,我们可以断言预期的行为。
对于 Testing Library 还有一个配套库,称为user-event。fireEvent函数触发元素事件,但浏览器做的不仅仅是触发一个事件。例如,如果用户在输入元素中输入一些文本,它首先会被聚焦,然后触发键盘和输入事件。user-event模拟完整的用户交互。
要使用user-event库,我们必须使用以下npm命令在我们的项目中安装它:
npm install -D @testing-library/user-event
接下来,我们必须在测试文件中导入userEvent:
import userEvent from '@testing-library/user-event';
然后,我们可以使用 userEvent.setup() 函数创建一个 userEvent 实例。我们也可以直接调用 API,这将在内部调用 userEvent.setup(),这就是我们将在以下示例中使用的做法。userEvent 提供了多个与 UI 交互的函数,例如 click() 和 type():
// Click a button
await userEvent.click(element);
// Type a value into an input element
await userEvent.type(element, value);
作为示例,我们将创建一个新的测试用例,模拟在 Carlist 组件中按下 新汽车 按钮,然后检查模态表单是否已打开:
-
打开
Carlist.test.tsx文件并导入userEvent:import userEvent from '@testing-library/user-event'; -
在
describe()函数中创建一个新的测试,其中包含我们的Carlist组件测试。在测试中,我们将渲染Carlist组件并等待 新汽车 按钮被渲染:test("Open new car modal", async () => { render(<Carlist />, { wrapper }); await waitFor(() => screen.getByText(/New Car/i)); }) -
然后,使用
getByText查询找到按钮,并使用userEvent.click()函数来点击按钮。使用匹配器来验证文档中是否可以找到 保存 按钮:test("Open new car modal", async () => { render(<Carlist />, { wrapper }); await waitFor(() => screen.getByText(/New Car/i)); **await** **userEvent.****click****(screen.****getByText****(****/New Car/i****));** **expect****(screen.****getByText****(****/Save/i****)).****toBeInTheDocument****();** }) -
现在,重新运行你的测试,你会看到有四个测试用例通过了:
![]()
图 15.4:测试运行
我们可以使用
getByRole查询根据元素的角色找到元素,例如按钮、链接等。以下是如何使用getByRole查询找到包含文本保存的按钮的示例。第一个参数定义了角色,第二个参数的name选项定义了按钮文本:screen.getByRole('button', { name: 'Save' }); -
我们也可以通过更改测试匹配器中的文本来测试失败的测试看起来如何,例如:
expect(screen.getByText(/Saving/i)).toBeInTheDocument();
现在,如果我们重新运行测试,我们可以看到有一个测试失败了,以及失败的原因:

图 15.5:失败的测试
现在,你已经了解了在 React 组件中测试用户交互的基础知识。
端到端测试
端到端(E2E)测试是一种专注于测试整个应用程序工作流程的方法。我们不会在本书中详细讨论它,但我们会给你一个大致的了解,并介绍一些我们可以使用的工具。
目标是模拟用户场景和与应用程序的交互,以确保所有组件都能正确协同工作。端到端测试覆盖前端、后端以及正在测试的软件的所有接口或外部依赖。端到端测试的范围也可以是 跨浏览器 或 跨平台,即使用多个不同的网络浏览器或移动设备来测试应用程序。
有几种工具可用于端到端测试,例如:
-
Cypress (
www.cypress.io/):这是一个可以用来创建 Web 应用程序端到端测试的工具。Cypress 测试易于编写和阅读。你可以在浏览器中看到测试执行期间应用程序的行为,并且它还有助于你在出现失败时进行调试。你可以免费使用 Cypress,但有一些限制。 -
Playwright (
playwright.dev/):这是一个为 E2E 测试设计的测试自动化框架,由微软开发。您可以为 Playwright 获取一个 Visual Studio Code 扩展,并在您的项目中开始使用它。使用 Playwright 编写测试的默认语言是 TypeScript,但您也可以使用 JavaScript。
E2E 测试有助于验证您的应用程序是否满足其功能需求。
摘要
在本章中,我们提供了如何测试 React 应用程序的基本概述。我们介绍了 Jest,一个 JavaScript 测试框架,以及 React Testing Library,它可以用来测试 React 组件。我们还学习了如何使用 Vitest 在我们的 Vite React 应用程序中创建和运行测试,并以对 E2E 测试的简要讨论结束。
在下一章中,我们将确保我们的应用程序的安全,并将登录功能添加到前端。
问题
-
Jest 是什么?
-
什么是 React Testing Library?
-
Vitest 是什么?
-
您如何在测试用例中触发事件?
-
E2E 测试的目的是什么?
进一步阅读
这里有一些其他关于学习 React 和测试的资源:
-
使用 React Testing Library 简化测试,由 Scottie Crump 编著 (
www.packtpub.com/product/simplify-testing-with-react-testing-library/9781800564459) -
React Testing Library 教程,由 Robin Wieruch 编著 (
www.robinwieruch.de/react-testing-library/)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十六章:保护你的应用程序
我们将在本章学习如何确保我们的应用程序的安全性。本章将解释在我们后端使用JSON Web Token(JWT)认证时,如何在我们的前端实现认证。首先,我们将开启后端的安全性以启用 JWT 认证。然后,我们将创建一个用于登录功能的组件。最后,我们将修改我们的 CRUD 功能,以便在请求的授权头中发送令牌到后端,并实现注销功能。
本章将涵盖以下主题:
-
保护后端
-
保护前端
技术要求
我们在第五章,“保护你的后端”中创建的 Spring Boot 应用程序是必需的(github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter05),以及我们在第十四章,“使用 React MUI 美化前端”中使用的 React 应用程序(github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter14)。
本章的以下 GitHub 链接也将很有用:github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter16。
保护后端
在第十三章中,我们使用未加密的后端在我们的前端实现了 CRUD 功能。现在,是时候为我们的后端开启安全性,回到我们在第五章,“保护你的后端”中创建的版本:
-
使用 Eclipse IDE 打开你的后端项目,并在编辑器视图中打开
SecurityConfig.java文件。我们已取消注释安全设置并允许所有人访问所有端点。现在,我们可以删除那行,并也从原始版本中删除注释。现在,你的SecurityConfig.java文件的filterChain()方法应该看起来像以下这样:@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf((csrf) -> csrf.disable()) .cors(withDefaults()) .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS)) .authorizeHttpRequests( (authorizeHttpRequests) -> authorizeHttpRequests.requestMatchers(HttpMethod.POST, "/ login").permitAll().anyRequest().authenticated()) .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling((exceptionHandling) -> exceptionHandling.authenticationEntryPoint(exceptionHandler)); return http.build(); } -
让我们测试当后端再次被保护时会发生什么。通过在 Eclipse 中按运行按钮来运行后端,并从控制台视图检查应用程序是否正确启动。通过在终端中输入
npm run dev命令来运行前端,浏览器应该会打开到地址localhost:5173。 -
现在,你应该会看到列表页面和汽车列表正在加载。如果你打开开发者工具并切换到网络标签,你会注意到响应状态是401 未授权。这实际上是我们想要的,因为我们还没有针对前端执行认证:

图 16.1:401 未授权
现在,我们已经准备好开始处理前端工作了。
保护前端
在第五章,保护后端中,我们创建了 JWT 认证,并允许所有人无需认证即可访问/login端点。现在,在前端登录页面上,我们必须使用用户凭证向/login端点发送POST请求以获取令牌。之后,该令牌将包含在我们发送到后端的全部请求中,如下图所示:

图 16.2:安全应用
基于这些知识,我们将开始在前端实现登录功能。我们将实现用户输入凭证的登录页面,然后我们将发送登录请求以从服务器获取令牌。我们将使用存储的令牌来发送到服务器的请求。
创建登录组件
让我们先创建一个登录组件,该组件会要求用户输入凭证以从后端获取令牌:
- 在
components文件夹中创建一个名为Login.tsx的新文件。现在,前端文件结构应该是以下这样:

图 16.3:项目结构
-
在 VS Code 编辑器视图中打开文件,并将以下基本代码添加到
Login组件中。我们需要axios来向/login端点发送POST请求:import { useState } from 'react'; import axios from 'axios'; function Login() { return( <></> ); } export default Login; -
我们需要两个状态来处理认证:一个用于凭证(用户名和密码),一个布尔值用于指示认证状态。我们还创建了一个用户状态类型。认证状态状态的初始值是
false:import { useState } from 'react'; import axios from 'axios'; **type** **User** **= {** **username****: string;** **password****: string;** **}** function Login() { **const** **[user, setUser] = useState<****User****>({** **username****:** **''****,** **password****:** **''** **});** **const** **[isAuthenticated, setAuth] =** **useState****(****false****);** return( <></> ); } export default Login; -
在用户界面中,我们将使用Material UI(MUI)组件库,就像我们处理其他用户界面一样。我们需要
TextField组件来输入凭证,Button组件来调用登录函数,以及Stack组件来进行布局。将组件导入添加到Login.tsx文件中:import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; import Stack from '@mui/material/Stack';我们已经在第十四章,使用 MUI 美化前端中使用了这三种组件类型来美化我们的 UI。
-
将导入的组件添加到
return语句中。我们需要两个TextField组件:一个用于用户名,一个用于密码。需要一个Button组件来调用我们将在本节后面实现的登录函数。我们使用Stack组件来使我们的TextField组件居中并对齐它们之间的间距:return( <Stack spacing={2} alignItems="center" mt={2}> <TextField name="username" label="Username" onChange={handleChange} /> <TextField type="password" name="password" label="Password" onChange={handleChange}/> <Button variant="outlined" color="primary" onClick={handleLogin}> Login </Button> </Stack> ); -
实现用于
TextField组件的更改处理函数,以便将输入的值保存到状态中。你必须使用扩展语法,因为它确保你保留了user对象中未修改的所有其他属性:const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { setUser({...user, [event.target.name] : event.target.value }); } -
如第五章,保护后端所示,登录是通过调用
/login端点并使用POST方法在正文中发送user对象来完成的。如果身份验证成功,我们将在响应的Authorization头中获取令牌。然后我们将令牌保存到会话存储中,并将isAuthenticated状态值设置为true。会话存储与本地存储类似,但在页面会话结束时(当页面关闭时)会被清除。
localStorage和sessionStorage是Window接口的属性。当
isAuthenticated状态值改变时,用户界面会重新渲染:const handleLogin = () => { axios.post(import.meta.env.VITE_API_URL + "/login", user, { headers: { 'Content-Type': 'application/json' } }) .then(res => { const jwtToken = res.headers.authorization; if (jwtToken !== null) { sessionStorage.setItem("jwt", jwtToken); setAuth(true); } }) .catch(err => console.error(err)); } -
我们将实现一些条件渲染,如果
isAuthenticated状态值为false,则渲染Login组件;如果isAuthenticated状态值为true,则渲染Carlist组件。首先,将Carlist组件导入到Login.tsx文件中:import Carlist from './Carlist';然后,对
return语句实现以下更改:**if** **(isAuthenticated) {** **return****<****Carlist** **/>****;** **}** **else** **{** return( <Stack spacing={2} alignItems="center" mt={2} > <TextField name="username" label="Username" onChange={handleChange} /> <TextField type="password" name="password" label="Password" onChange={handleChange}/> <Button variant="outlined" color="primary" onClick={handleLogin}> Login </Button> </Stack> ); **}** -
要显示登录表单,我们必须在
App.tsx文件中将Login组件而不是Carlist组件渲染出来。导入并渲染Login组件,并删除未使用的Carlist导入:// App.tsx import AppBar from '@mui/material/AppBar'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; **import****Login****from****'./components/Login'****;** import { QueryClient, QueryClientProvider } from '@tanstack/react- query'; const queryClient = new QueryClient(); function App() { return ( <Container maxWidth="xl"> <CssBaseline /> <AppBar position="static"> <Toolbar> <Typography variant="h6"> Carshop </Typography> </Toolbar> </AppBar> <QueryClientProvider client={queryClient}> **<****Login** **/>** </QueryClientProvider> </Container> ) } export default App;
现在,当你的前端和后端都在运行时,你的前端应该看起来像以下截图:

图 16.4:登录页面
如果你使用我们已插入数据库的user/用户或admin/管理员凭据登录,你应该看到汽车列表页面。如果你打开开发者工具的应用程序选项卡,你可以看到令牌现在已保存到会话存储中:

图 16.5:会话存储
实现 REST API 调用
在上一节结束时,汽车列表仍在加载,我们无法获取汽车。这是正确的行为,因为我们还没有在任何请求中包含令牌。这是 JWT 身份验证所必需的,我们将在下一阶段实现:
-
在 VS Code 编辑器视图中打开
carapi.ts文件。要获取汽车,我们首先必须从会话存储中读取令牌,然后将带有令牌值的Authorization头添加到GET请求中。你可以在这里看到getCars函数的源代码:// carapi.ts export const getCars = async (): Promise<CarResponse[]> => { **const** **token =** **sessionStorage****.****getItem****(****"jwt"****);** const response = await axios.get(`${import.meta.env.VITE_API_URL}/ api/cars`, **{** **headers****: {** **'Authorization'** **: token }** **}**); return response.data._embedded.cars; } -
如果你登录到你的前端,你应该看到数据库中的汽车已填充到汽车列表中。
-
检查开发者工具中的请求内容;你可以看到它包含带有令牌值的
Authorization头:

图 16.6:请求头
-
以相同的方式修改其他 CRUD 功能,以确保它们能正确工作。在修改后,
deleteCar函数的源代码如下所示:// carapi.ts export const deleteCar = async (link: string): Promise<CarResponse> => { **const** **token =** **sessionStorage****.****getItem****(****"jwt"****);** const response = await axios.delete(link, **{** **headers****: {** **'****Authorization'****: token }** **}**) return response.data }
在修改后,addCar和editCar函数的源代码如下所示:
// carapi.ts
export const addCar = async (car: Car): Promise<CarResponse> => {
**const** **token =** **sessionStorage****.****getItem****(****"jwt"****);**
const response = await axios.post(`${import.meta.env.VITE_API_
URL}/api/cars`, car, {
headers: {
'Content-Type': 'application/json',
**'Authorization'****: token**
},
});
return response.data;
}
export const updateCar = async (carEntry: CarEntry): Promise<CarResponse> => {
**const** **token =** **sessionStorage****.****getItem****(****"jwt"****);**
const response = await axios.put(carEntry.url, carEntry.car, {
headers: {
'Content-Type': 'application/json',
**'Authorization'****: token**
},
});
return response.data;
}
重构重复代码
现在,登录到应用程序后,所有 CRUD 功能都将正常工作。但是,如你所见,我们有很多重复的代码,例如我们从会话存储中检索令牌的行。我们可以进行一些重构,以避免重复相同的代码并使我们的代码更容易维护:
-
首先,我们将创建一个函数,从会话存储中检索令牌并为 Axios 请求创建一个包含带有令牌的头的配置对象。Axios 提供了
AxiosRequestConfig接口,可用于配置我们使用 Axios 发送的请求。我们还设置了content-type头的值为application/json:// carapi.ts import axios, { **AxiosRequestConfig** } from 'axios'; import { CarResponse, Car, CarEntry } from '../types'; **const** **getAxiosConfig = ():** **AxiosRequestConfig** **=>** **{** **const** **token =** **sessionStorage****.****getItem****(****"jwt"****);** **return** **{** **headers****: {** **'Authorization'****: token,** **'Content-Type'****:** **'application/json'****,** **},** **};** **};** -
然后,我们可以通过移除配置对象并调用
getAxiosConfig()函数,而不在每个函数中检索令牌来使用getAxiosConfig()函数,如下面的代码所示:// carapi.ts export const getCars = async (): Promise<CarResponse[]> => { const response = await axios.get(`${import.meta.env.VITE_API_URL}/ api/cars`, **getAxiosConfig****()**); return response.data._embedded.cars; } export const deleteCar = async (link: string): Promise<CarResponse> => { const response = await axios.delete(link, **getAxiosConfig****()**) return response.data } export const addCar = async (car: Car): Promise<CarResponse> => { const response = await axios.post(`${import.meta.env.VITE_API_ URL}/api/cars`, car, **getAxiosConfig****()**); return response.data; } export const updateCar = async (carEntry: CarEntry): Promise<CarResponse> => { const response = await axios.put(carEntry.url, carEntry.car, **getAxiosConfig****()**); return response.data; }
Axios 还提供了拦截器,可以在请求和响应被then或catch处理之前拦截和修改它们。你可以在 Axios 文档中了解更多关于拦截器的信息:axios-http.com/docs/interceptors。
显示错误消息
在这个阶段,我们将实现一个错误消息,如果认证失败,将显示给用户。我们将使用Snackbar MUI 组件来显示消息:
-
将以下导入添加到
Login.tsx文件中:import Snackbar from '@mui/material/Snackbar'; -
添加一个新的状态
open来控制Snackbar的可见性:const [open, setOpen] = useState(false); -
将
Snackbar组件添加到return语句中,位于Button组件下面的堆栈中。Snackbar组件用于显示吐司消息。如果open属性值为true,则显示该组件。autoHideDuration定义了在调用onClose函数之前等待的毫秒数:<Snackbar open={open} autoHideDuration={3000} onClose={() => setOpen(false)} message="Login failed: Check your username and password" /> -
如果认证失败,通过将
open状态值设置为true来打开Snackbar组件:const login = () => { axios.post(import.meta.env.VITE_API_URL + "/login", user, { headers: { 'Content-Type': 'application/json' } }) .then(res => { const jwtToken = res.headers.authorization; if (jwtToken !== null) { sessionStorage.setItem("jwt", jwtToken); setAuth(true); } }) .catch(**() =>****setOpen****(****true****)**); } -
如果你现在尝试使用错误的凭据登录,你将在屏幕的左下角看到以下消息:

图 16.7:登录失败
登出
在本节的最后,我们将在Login组件中实现登出功能。登出按钮渲染在车辆列表页面上。Carlist组件是Login组件的子组件;因此,我们可以通过属性将登出函数传递给车辆列表。让我们这样做:
-
首先,我们为
Login组件创建一个handleLogout()函数,该函数将isAuthenticated状态更新为false,并从会话存储中清除令牌:// Login.tsx const handleLogout = () => { setAuth(false); sessionStorage.setItem("jwt", ""); } -
接下来,我们使用属性将
handleLogout函数传递给Carlist组件,如高亮代码所示:// Login.tsx if (isAuthenticated) { return <Carlist **logOut****=****{handleLogout}**/>; } else { return( ... -
我们必须为在
Carlist组件中接收的 props 创建一个新的类型。prop 名称是logOut,它是一个不接受任何参数的函数,我们将此 prop 标记为可选。将以下类型添加到Carlist组件中,并在函数参数中接收logOutprop://Carlist.tsx **type** **CarlistProps** **= {** **logOut?****:** **() =>****void****;** **}** function Carlist(**{ logOut }: CarlistProps**) { const [open, setOpen] = useState(false); ... -
现在,我们可以调用注销函数并添加注销按钮。我们使用 Material UI 的
Stack组件来对齐按钮,使得 NEW CAR 按钮位于屏幕左侧,而 LOG OUT 按钮位于右侧:// Carlist.tsx // Add the following imports import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; // Render the Stack and Button if (!isSuccess) { return <span>Loading...</span> } else if (error) { return <span>Error when fetching cars...</span> } else { return ( <> **<****Stack****direction****=****"row"****alignItems****=****"center"** **justifyContent****=****"space-between"****>** **<****AddCar** **/>** **<****Button****onClick****=****{logOut}****>****Log out****</****Button****>** **</****Stack****>** <DataGrid rows={data} columns={columns} disableRowSelectionOnClick={true} slots={{ toolbar: GridToolbar }} getRowId={row => row._links.self.href} /> <Snackbar open={open} autoHideDuration={2000} onClose={() => setOpen(false)} message="Car deleted" /> </> ); } -
现在,如果你登录到你的前端,你可以在汽车列表页面上看到 LOG OUT 按钮,如下面的截图所示。当你点击按钮时,会渲染登录页面,因为
isAuthenticated状态被设置为false,并且令牌已从会话存储中清除:

图 16.8:注销
如果你有一个更复杂的具有多个页面的前端,明智的做法是在应用栏中渲染注销按钮,这样它就会显示在每个页面上。然后,你可以使用状态管理技术来与你的整个组件树共享状态。一个解决方案是使用我们在 第八章 中介绍的 React Context API。在这种情况下,你可以使用上下文来在应用组件树中共享 isAuthenticated 状态。
随着你的应用复杂性增长,管理状态变得至关重要,以确保你的组件可以高效地访问和更新数据。还有其他替代 React Context API 的状态管理方法,你可以研究。最常见的状态管理库是 React Redux(react-redux.js.org)和 MobX(github.com/mobxjs/mobx)。
在上一章中,我们为 CarList 组件创建了测试用例,当时应用是不安全的。在这个阶段,我们的 CarList 组件测试用例将会失败,你应该对它们进行重构。为了创建一个模拟登录过程并测试是否从后端 REST API 获取数据的 React 测试,你也可以使用像 axios-mock-adapter 这样的库(github.com/ctimmerm/axios-mock-adapter)。Mocking Axios 允许你在不进行实际网络请求的情况下模拟登录过程和数据获取。我们这里不深入细节,但建议你进一步探索。
现在,我们的汽车应用已经准备好了。
摘要
在本章中,我们学习了如何在使用 JWT 认证时为我们的前端实现登录和注销功能。在认证成功后,我们使用会话存储来保存从后端接收到的令牌。然后,我们在发送到后端的全部请求中使用该令牌;因此,我们必须修改我们的 CRUD 功能以正确地与认证一起工作。
在下一章和最后一章中,我们将部署我们的后端和前端,并演示如何创建 Docker 容器。
问题
-
您应该如何创建一个登录表单?
-
您应该如何使用 JWT 登录后端?
-
会话存储是什么?
-
在 CRUD 函数中,您应该如何向后端发送令牌?
进一步阅读
这里有一些其他关于学习 React 和状态管理的资源:
-
《使用 React Query 进行状态管理》,作者 Daniel Afonso (
www.packtpub.com/product/state-management-with-react-query/9781803231341) -
《MobX 快速入门指南》,作者 Pavan Podila 和 Michel Weststrate (
www.packtpub.com/product/mobx-quick-start-guide/9781789344837)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e

第十七章:部署您的应用程序
本章将解释如何将您的后端和前端部署到服务器上。成功的部署是软件开发过程中的关键部分,了解现代部署过程的工作方式非常重要。有多种云服务器或 PaaS(代表 平台即服务)提供商可供选择,例如 Amazon Web Services(AWS)、DigitalOcean、Microsoft Azure、Railway 和 Heroku。
在这本书中,我们使用 AWS 和 Netlify,它们支持在 Web 开发中使用的多种编程语言。我们还将向您展示如何在部署中使用 Docker 容器。
在本章中,我们将涵盖以下主题:
-
使用 AWS 部署后端
-
使用 Netlify 部署前端
-
使用 Docker 容器
技术要求
我们在 第五章,保护您的后端 中创建的 Spring Boot 应用程序是必需的 (github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter05),同样,我们在 第十六章,保护您的应用程序 中使用的 React 应用程序也是必需的 (github.com/PacktPublishing/Full-Stack-Development-with-Spring-Boot-3-and-React-Fourth-Edition/tree/main/Chapter16)。
本章的最后部分需要安装 Docker。
使用 AWS 部署后端
如果您打算使用自己的服务器,部署 Spring Boot 应用程序最简单的方法是使用可执行的 Java ARchive(JAR)文件。使用 Gradle,可以使用 Spring Boot Gradle 包装器创建可执行 JAR 文件。您可以使用以下 Gradle 包装器命令在项目文件夹中构建项目:
./gradlew build
或者,您也可以通过在项目资源管理器中右键单击 项目,导航到 窗口 | 显示视图 | 其他,然后从列表中选择 Gradle | Gradle 任务,在 Eclipse 中运行一个 Gradle 任务。这会打开一个 Gradle 任务的列表,您可以通过双击 build 任务来启动构建过程,如下面的截图所示。如果 Gradle 任务窗口为空,请点击 Eclipse 中的项目根目录:

图 17.1:Gradle 任务
这将在您的项目中创建一个新的 build/libs 文件夹,其中将包含 JAR 文件。默认情况下,创建了两个 JAR 文件:
-
扩展名为
.plain.jar的文件包含 Java 字节码和其他资源,但它不包含任何应用程序框架或依赖项。 -
另一个
.jar文件是一个完全可执行的存档,您可以使用java -jar your_appfile.jarJava 命令来运行它,如下面的截图所示:

图 17.2:运行可执行 JAR 文件
现在,云服务器是向最终用户提供应用程序的主要手段。我们将把我们的后端部署到 Amazon Web Services(AWS)(aws.amazon.com/)。AWS 免费层为用户提供免费探索产品的机会。
创建免费层账户并登录 AWS。您必须输入您的联系信息,包括一个有效的手机号码。AWS 将发送一条短信确认消息以验证您的账户。您必须为 AWS 免费层下的账户添加有效的信用卡、借记卡或其他支付方式。
您可以在 repost.aws/knowledge-center/free-tier-payment-method 阅读关于为什么需要支付方式的原因。
部署我们的 MariaDB 数据库
在本节中,我们将把我们的 MariaDB 数据库部署到 AWS。Amazon 关系数据库服务(RDS)可用于设置和运行关系数据库。Amazon RDS 支持包括 MariaDB 在内的几个流行的数据库。以下步骤将指导您完成在 RDS 中创建数据库的过程:
- 在您使用 AWS 创建了免费层账户后,登录 AWS 网站。AWS 仪表板包含一个搜索栏,您可以使用它来查找不同的服务。在搜索栏中输入
RDS并找到 RDS,如图下所示。在 服务 列表中点击 RDS:

图 17.3:RDS
- 点击 创建数据库 按钮开始数据库创建过程:

图 17.4:创建数据库
- 从数据库引擎选项中选择 MariaDB:

图 17.5:引擎选项
-
从模板中选择 免费层。
-
为您的数据库实例输入名称和数据库主用户的密码。您可以使用默认用户名(admin):

图 17.6:数据库实例名称
- 在 公共访问 部分下选择 是 以允许公共访问您的数据库:

图 17.7:公共访问
-
在页面底部的 附加配置 部分中,为您的数据库命名
cardb:![图片]()
图 17.8:附加配置
注意!如果名称留空,则不会创建数据库。
-
最后,点击 创建数据库 按钮。RDS 将开始创建您的数据库实例,这可能需要几分钟。
-
您的数据库成功创建后,您可以点击 查看连接详情 按钮打开一个窗口,显示您的数据库连接详情。端点 是您数据库的地址。复制连接详情以备后用:

图 17.9:连接详情
- 现在,我们已经准备好测试我们的数据库。在这个阶段,我们将使用我们的本地 Spring Boot 应用程序。为此,我们必须允许从外部访问我们的数据库。要更改此设置,请在 RDS 数据库列表中点击您的数据库。然后,点击VPC 安全组,如下截图所示:

图 17.10:连接性与安全性
- 在首页上,从入站规则选项卡中点击编辑入站规则按钮。点击添加规则按钮添加新规则。对于新规则,在源列下选择MySQL/Aurora类型和我的 IP目标。我的 IP目标会自动将您本地计算机的当前 IP 地址添加为允许的目标:

图 17.11:入站规则
-
在您添加了新规则后,按保存规则按钮。
-
打开我们在第五章,保护后端中创建的 Spring Boot 应用程序。将
application.properties文件中的url、username和password数据库设置更改为与您的 Amazon RDS 数据库匹配。spring.datasource.url属性值的格式为jdbc:mariadb://your_rds_db_domain:3306/your_db_name,如下截图所示:

图 17.12:application.properties 文件
- 现在,如果您运行应用程序,您可以从控制台看到数据库表已创建,并且示例数据已插入到我们的 Amazon RDS 数据库中:

图 17.13:控制台
- 在这个阶段,您应该构建您的 Spring Boot 应用程序。在 Eclipse 中通过在项目资源管理器中右键单击项目,导航到窗口 | 显示视图 | 其他,并从列表中选择Gradle | Gradle 任务来运行 Gradle 构建任务。这将打开一个 Gradle 任务列表,您可以通过双击build任务来开始构建过程。它将在
build/libs文件夹中创建一个新的 JAR 文件。
我们现在有了适当的数据库设置,并且在我们将应用程序部署到 AWS 时可以使用我们新构建的应用程序。
部署我们的 Spring Boot 应用程序
在我们将数据库部署到 Amazon RDS 之后,我们可以开始部署我们的 Spring Boot 应用程序。我们正在使用的 Amazon 服务是Elastic Beanstalk,它可以用于在 AWS 中运行和管理 Web 应用程序。还有其他替代方案,例如 AWS Amplify,也可以使用。Elastic Beanstalk 适用于免费层,并且它还支持广泛的编程语言(例如 Java、Python、Node.js 和 PHP)。
以下步骤将指导您将我们的 Spring Boot 应用程序部署到 Elastic Beanstalk 的过程:
- 首先,我们必须为我们的应用程序部署创建一个新的 角色。该角色是允许 Elastic Beanstalk 创建和管理您的环境所必需的。您可以使用 Amazon IAM(身份和访问管理)服务创建角色。使用 AWS 搜索栏导航到 IAM 服务。在 IAM 服务中,选择 角色 并点击 创建角色 按钮。选择 AWS 服务 和 EC2,如图所示,然后点击 下一步 按钮:

图 17.14:创建角色
-
在 添加权限 步骤中,选择以下权限策略:AWSElasticBeanstalkWorkerTier、AWSElasticBeanstalkWebTier 和 AWSElasticBeanstalkMulticontainerDocker,然后点击 下一步 按钮。您可以使用搜索栏查找正确的策略:
![]()
图 17.15:添加权限
您可以在
docs.aws.amazon.com/elasticbeanstalk/latest/dg/iam-instanceprofile.html阅读更多关于管理 Elastic Beanstalk 实例配置文件和策略的信息。 -
按照下一张截图所示,为您的角色输入名称,并最终点击 创建角色 按钮:
![]()
图 17.16:角色名称
我们刚刚创建的新角色允许 Elastic Beanstalk 创建和管理我们的环境。现在,我们可以开始部署我们的 Spring Boot 应用程序。
-
使用 AWS 仪表板搜索栏查找 Elastic Beanstalk 服务。点击服务以导航到 Elastic Beanstalk 页面:

图 17.17:Elastic Beanstalk 服务
- 在左侧菜单中点击 应用程序,然后点击 创建应用程序 按钮以创建一个新的应用程序。按照以下截图所示,为您的应用程序输入名称,然后点击 创建 按钮:

图 17.18:创建应用程序
- 接下来,我们必须为我们的应用程序创建一个 环境。环境是运行应用程序版本的 AWS 资源集合。您可以为一个应用程序拥有多个环境:例如,开发、生产和测试环境。点击 创建新环境 按钮以配置新环境:

图 17.19:创建新环境
- 在环境配置中,您首先必须设置平台。在 平台类型 部分,选择 Java 和分支的第一个版本 17,如图所示。平台版本 是操作系统、运行时、Web 服务器、应用程序服务器和 Elastic Beanstalk 组件的特定版本的组合。您可以使用推荐的 平台 版本:

图 17.20:平台类型
- 接下来,转到配置页面中的应用程序代码部分。选择上传你的代码和本地文件。点击选择文件按钮并选择我们之前构建的 Spring Boot
.jar文件。你还需要输入一个唯一的版本标签。最后,点击下一步按钮:

图 17.21:创建新环境
- 在配置服务访问步骤中,从EC2 实例配置文件下拉列表中选择你之前创建的角色,如图所示。然后,点击下一步按钮:

图 17.22:服务访问
-
你可以跳过可选的设置网络、数据库和标签和配置实例流量和扩展步骤。
-
接下来,转到配置更新、监控和日志步骤。在环境属性部分,我们必须添加以下环境属性。你可以在页面底部点击添加环境属性按钮来添加新属性。已经有了一些预定义的属性,你不需要修改它们(
GRADLE_HOME、M2和M2_HOME):-
SERVER_PORT:5000(弹性豆有 Nginx 反向代理,它将把传入的请求转发到内部端口5000)。 -
SPRING_DATASOURCE_URL: 这里需要使用的数据库 URL 与我们之前在测试 AWS 数据库集成时在'application.properties'文件中配置的数据库 URL 值相同。 -
SPRING_DATASOURCE_USERNAME: 你的数据库用户名。 -
SPRING_DATASOURCE_PASSWORD: 你的数据库密码。
下一个屏幕截图显示了新的属性:
![图片]()
图 17.23:环境属性
-
-
最后,在审查步骤中,点击提交按钮,你的部署将开始。你必须等待直到你的环境成功启动,如图所示的下个屏幕截图。环境概述中的域名是你部署的 REST API 的 URL:

图 17.24:环境成功启动
-
现在,我们已经部署了我们的 Spring Boot 应用程序,但应用程序还不能访问 AWS 数据库。为此,我们必须允许从部署的应用程序访问我们的数据库。要做到这一点,导航到 Amazon RDS 并从 RDS 数据库列表中选择你的数据库。然后,点击VPC 安全组并点击编辑入站规则按钮,就像我们之前做的那样。删除允许从你的本地 IP 地址访问的规则。
-
添加一个类型为MySQL/Aurora的新规则。在目标字段中,输入
sg。这将打开一个环境列表,如图所示。选择你的 Spring Boot 应用程序运行的环境(以“awseb”文本开头,副标题显示你的环境名称)并点击保存规则按钮:

图 17.25:入站规则
- 现在,您的应用程序已正确部署,您可以使用从域名中获得的 URL 使用 Postman 登录到您的已部署 REST API。以下截图显示了发送到
aws_domain_url/login端点的 POST 请求:

图 17.26:邮递员身份验证
您还可以为您的 Elastic Beanstalk 环境配置一个自定义域名,然后您可以使用 HTTPS 允许用户安全地连接到您的网站。如果您没有域名,您仍然可以使用自签名证书进行开发和测试。您可以在 AWS 文档中找到配置说明:docs.aws.amazon.com/elasticbeanstalk/latest/dg/configuring-https.html。
注意!您应该删除您创建的 AWS 资源,以避免意外收费。在您的免费试用期结束时,AWS 会提醒您删除资源。
现在,我们已经准备好部署我们的前端。
使用 Netlify 部署前端
在我们使用 Netlify 部署之前,我们将学习如何在本地构建您的 React 项目。移动到您的前端项目文件夹并执行以下 npm 命令:
npm run build
默认情况下,您的项目在 /dist 文件夹中构建。您可以通过在 Vite 配置文件中使用 build.outDir 属性来更改文件夹。
首先,构建过程会编译您的 TypeScript 代码;因此,如果您有任何 TypeScript 错误或警告,您必须修复它们。一个常见的错误是忘记删除未使用的导入,如下面的示例错误所示:
src/components/AddCar.tsx:10:1 - error TS6133: 'Snackbar' is declared but its value is never read.
10 import Snackbar from '@mui/material/Snackbar';
这表示 AddCar.tsx 文件导入了 Snackbar 组件,但实际上并没有使用该组件。因此,您应该删除这个未使用的导入。一旦所有错误都已被解决,您就可以继续重新构建您的项目。
Vite 使用 Rollup (rollupjs.org/) 来打包您的代码。测试文件和开发工具不包括在生产构建中。构建完您的应用程序后,您可以使用以下 npm 命令测试您的本地构建:
npm run preview
该命令启动一个本地静态 Web 服务器,用于提供您的构建应用程序。您可以通过在终端中显示的 URL 使用浏览器测试您的应用程序。
您也可以将前端部署到 AWS,但我们将使用 Netlify (www.netlify.com/) 进行前端部署。Netlify 是一个易于使用的现代网络开发平台。您可以使用 Netlify 的 命令行界面(CLI)或 GitHub 部署您的项目。在本节中,我们将使用 Netlify 的 GitHub 集成来部署我们的前端:
-
首先,我们必须更改我们的 REST API URL。使用 VS Code 打开你的前端项目,并在编辑器中打开
.env文件。将VITE_API_URL变量更改为匹配你的后端 URL,如下所示,并保存更改:VITE_API_URL=https:// carpackt-env.eba-whufxac5.eu-central-2. elasticbeanstalk.com -
为你的前端项目创建一个 GitHub 代码库。在你的项目文件夹中使用命令行执行以下 Git 命令。这些 Git 命令创建一个新的 Git 代码库,进行初始提交,在 GitHub 上设置远程代码库,并将代码推送到你的远程代码库:
git init git add . git commit -m "first commit" git branch -M main git remote add origin <YOUR_GITHUB_REPO_URL> git push -u origin main -
在 Netlify 上注册并登录。我们将使用具有有限功能的免费 Starter 账户。使用此账户,你可以免费构建一个并发构建,并且在带宽方面有一些限制。
你可以在
www.netlify.com/pricing/上了解更多关于 Netlify 免费账户功能的信息。 -
从左侧菜单打开 站点,你应该会看到 导入现有项目 面板,如下截图所示:

图 17.27:导入现有项目
- 点击 从 Git 导入 按钮,并选择 使用 GitHub 部署。在这个阶段,你必须授权 GitHub 以访问你的代码库。成功授权后,你应该会看到你的 GitHub 用户名和代码库搜索字段,如下截图所示:

图 17.28:GitHub 代码库
-
搜索你的前端代码库并点击它。
-
接下来,你将看到部署设置。通过按下 部署 <你的代码库名称> 按钮继续使用默认设置:

图 17.29:部署设置
- 部署完成后,你会看到以下对话框。按下如下图中所示的 查看站点部署 按钮,你将被重定向到 部署 页面。Netlify 为你生成一个随机的站点名称,但你也可以使用自己的域名:

图 17.30:部署成功
- 在 部署 页面上,你会看到你的部署网站,你可以通过点击 打开生产部署 按钮访问你的前端:

图 17.31:部署
- 现在,你应该会看到登录表单,如下所示:

图 17.32:登录屏幕
你可以从左侧菜单的 站点配置 中删除你的 Netlify 部署。
我们现在已部署了我们的前端,接下来我们可以继续学习容器。
使用 Docker 容器
Docker (www.docker.com/) 是一个容器平台,它使软件开发、部署和分发变得更加容易。容器是轻量级的可执行软件包,包含运行软件所需的一切。容器可以部署到云服务,如 AWS、Azure 和 Netlify,并且为部署应用程序提供了许多好处:
-
容器是隔离的,这意味着每个容器都独立于主机系统和其它容器运行。
-
容器之所以可移植,是因为它们包含了应用程序运行所需的一切。
-
容器还可以用来确保开发和生产环境之间的一致性。
注意!要在 Windows 上运行 Docker 容器,你需要 Windows 10 或 11 的专业版或企业版。你可以在 Docker 安装文档中了解更多信息:docs.docker.com/desktop/install/windows-install/。
在本节中,我们将创建一个用于我们的 MariaDB 数据库和 Spring Boot 应用程序的容器,如下所示:
-
在你的工作站上安装 Docker。你可以在
www.docker.com/get-docker找到多个平台的安装包。如果你使用的是 Windows 操作系统,你可以使用默认设置通过安装向导进行安装。如果你遇到安装问题,你可以阅读 Docker 故障排除文档,网址为
docs.docker.com/desktop/troubleshoot/topics。安装完成后,你可以在终端中输入以下命令来检查当前版本。注意!当你运行 Docker 命令时,如果 Docker Engine 没有运行(在 Windows 和 macOS 上,你启动 Docker Desktop):
docker --version -
首先,我们为我们的 MariaDB 数据库创建一个容器。你可以使用以下命令从 Docker Hub 拉取最新的 MariaDB 数据库镜像版本:
docker pull mariadb:latest -
在
pull命令完成后,你可以通过输入docker image ls命令来检查是否已存在一个新的mariadb镜像,输出应该如下所示。Docker 镜像是一个包含创建容器指令的模板:

图 17.33:Docker 镜像
-
接下来,我们将运行
mariadb容器。docker run命令基于给定的镜像创建并运行一个容器。以下命令设置了 root 用户的密码并创建了一个名为cardb的新数据库,这是我们 Spring Boot 应用程序所需的(注意!请使用你在 Spring Boot 应用程序中使用的自己的 MariaDB root 用户密码):docker run --name cardb -e MYSQL_ROOT_PASSWORD=your_pwd -e MYSQL_ DATABASE=cardb mariadb -
现在,我们已经创建了我们的数据库容器,我们可以开始创建 Spring Boot 应用程序的容器。首先,我们必须更改 Spring Boot 应用程序的数据源 URL。打开应用程序的
application.properties文件,将spring.datasource.url的值更改为以下内容:spring.datasource.url=jdbc:mariadb://mariadb:3306/cardb这是因为我们的数据库现在正在
cardb容器中运行,端口为3306。 -
然后,我们必须从我们的 Spring Boot 应用程序创建一个可执行的 JAR 文件,就像我们在本章开头所做的那样。你还可以通过在项目资源管理器中右键单击 Project,选择 Window | Show View | Gradle,然后从列表中选择 Gradle Tasks,在 Eclipse 中运行一个 Gradle 任务。这会打开一个 Gradle 任务列表,你可以通过双击 build 任务来启动构建过程。一旦构建完成,你可以在项目文件夹内的
build/libs文件夹中找到可执行的 JAR 文件。 -
容器是通过使用 Dockerfile 定义的。在项目的
root文件夹(cardatabase)中使用 Eclipse 创建一个新的 Dockerfile,并将其命名为Dockerfile。以下代码行显示了 Dockerfile 的内容:FROM eclipse-temurin:17-jdk-alpine VOLUME /tmp EXPOSE 8080 COPY build/libs/cardatabase-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT ["java","-jar","/app.jar"]让我们逐行检查:
-
FROM定义了 Java 开发工具包(JDK)版本,你应该使用与构建你的 JAR 文件相同的版本。我们使用 Eclipse Temurin,这是一个开源 JDK,版本为 17,这是我们开发 Spring Boot 应用程序时使用的版本。 -
体积用于存储 Docker 容器生成和使用的持久数据。
-
EXPOSE定义了应该发布到容器外部的端口。 -
COPY将 JAR 文件复制到容器的文件系统,并将其重命名为app.jar。 -
最后,
ENTRYPOINT定义了 Docker 容器运行的命令行参数。
你可以在
docs.docker.com/engine/reference/builder/上阅读更多关于 Dockerfile 语法的信息。 -
-
在你的 Dockerfile 所在的文件夹中,使用以下命令构建一个镜像。使用
-t参数,我们可以为我们的容器提供一个友好的名称:docker build -t carbackend . -
在构建结束时,你应该会看到一条 Building [...] FINISHED 消息,如下面的截图所示:

图 17.34:Docker 构建
- 使用
docker image ls命令检查镜像列表。现在你应该看到两个镜像,如下面的截图所示:

图 17.35:Docker 镜像
-
现在,我们可以运行我们的 Spring Boot 容器,并使用以下命令将其与 MariaDB 容器链接起来。此命令指定我们的 Spring Boot 容器可以使用
mariadb名称访问 MariaDB 容器:docker run -p 8080:8080 --name carapp --link cardb:mariadb -d carbackend -
当我们的应用程序和数据库正在运行时,我们可以使用以下命令访问 Spring Boot 应用程序日志:
docker logs carapp
我们可以看到,我们的应用程序正在运行:

图 17.36:应用程序日志
我们的应用程序已成功启动,演示数据已插入到 MariaDB 容器中存在的数据库中。现在,你可以使用你的后端,如下面的截图所示:

图 17.37:应用程序登录
我们已经学习了多种部署全栈应用程序的方法以及如何容器化 Spring Boot 应用程序。作为下一步,你可以研究如何部署 Docker 容器。例如,AWS 提供了在 Amazon ECS 上部署容器的指南:aws.amazon.com/getting-started/hands-on/deploy-docker-containers/。
摘要
在本章中,你学习了如何部署我们的应用程序。我们将 Spring Boot 应用程序部署到了 AWS Elastic Beanstalk。接下来,我们使用 Netlify 部署了我们的 React 前端。最后,我们使用 Docker 为 Spring Boot 应用程序和 MariaDB 数据库创建了容器。
当我们翻到这本书的最后一页时,我希望你在使用 Spring Boot 和 React 进行全栈开发的世界之旅中度过了一段激动人心的旅程。在你继续全栈开发之旅的过程中,请记住,技术总是在不断演变。对于开发者来说,生活就是持续的学习和创新——所以保持好奇心并继续构建。
问题
-
你应该如何创建一个 Spring Boot 可执行 JAR 文件?
-
你可以使用哪些 AWS 服务将数据库和 Spring Boot 应用程序部署到 AWS?
-
你可以使用什么命令来构建你的 Vite React 项目?
-
Docker 是什么?
-
你应该如何创建一个 Spring Boot 应用程序容器?
-
你应该如何创建一个 MariaDB 容器?
进一步阅读
Packt Publishing 提供其他资源,用于学习 React、Spring Boot 和 Docker。其中一些列在这里:
-
Docker 基础知识入门 [视频],由 Coding Gears | Train Your Brain 提供 (
www.packtpub.com/product/docker-fundamentals-for-beginners-video/9781803237428) -
Docker 开发者指南,由 Richard Bullington-McGuire、Andrew K. Dennis 和 Michael Schwartz 著 (
www.packtpub.com/product/docker-for-developers/9781789536058) -
AWS、JavaScript、React - 在云端部署 Web 应用 [视频],由 YouAccel Training 提供 (
www.packtpub.com/product/aws-javascript-react-deploy-web-apps-on-the-cloud-video/9781837635801)
在 Discord 上了解更多
要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:
packt.link/FullStackSpringBootReact4e


















图 7.9:项目变体















浙公网安备 33010602011771号