ASP-NET-Core5-和-React-全-
ASP.NET Core5 和 React(全)
零、序言
ASP.NET Core 是由 Microsoft 构建的开源跨平台 web 应用框架。ASP.NET Core 是构建与 SQL Server 交互并托管在 Azure 中的高性能后端的最佳选择。
React 由 Facebook 构建,旨在提高其代码库的可伸缩性,并最终于 2013 年开源。React 现在是一个非常流行的库,用于构建基于组件的前端,并且与许多后端技术(包括 ASP.NET Core)配合得非常好。
本书将帮助您构建一个利用这两种技术的真实应用。您将一章一章地逐步构建应用,以积极学习这些技术中的关键主题。每章结尾都有一个总结和一些关于内容的问题,以加强您的学习。在本书的结尾,您将拥有一个安全、高性能且可维护的单页应用(SPA),该应用托管在 Azure 中,并具备构建下一个 ASP.NET Core 和 React 应用的知识。
这本书是给谁的
如果您是一名 web 开发人员,希望使用.NET Core 和 React 快速开发全堆栈 web 应用,那么本书适合您。尽管本书没有假定您对 React 有任何了解,但对.NETCore 的基本理解将帮助您掌握所涵盖的概念。
这本书涵盖的内容
第 1 节:开始
第 1 章了解 ASP.NET 5 React 模板,涵盖 Visual Studio 为 ASP.NET Core 和 React 应用提供的标准 SPA 模板。它涵盖了前端和后端的编程入口点,以及它们如何在 VisualStudio 解决方案中协同工作。
第 2 章创建解耦的 React 和 ASP.NET 5 应用,逐步介绍如何创建最新的 ASP.NET Core 和 React 解决方案。本章包括 TypeScript 的使用,这在创建大型复杂前端时非常有用。
*## 第 2 节:使用 React 和 TypeScript 构建前端
第 3 章React 和 TypeScript入门,涵盖 React 的基础知识,如 JSX、道具、状态和事件。本章还介绍了如何使用 TypeScript 创建强类型组件。
第 4 章用 Emotion 塑造 React 组件涵盖了塑造 React 组件的不同方法。本章在介绍 JS 中的 CSS 之前,先介绍使用普通 CSS 的样式,然后介绍 CSS 模块。
第 5 章路由与 React 路由介绍了一个库,可以高效创建具有多个页面的应用。它包括如何声明应用中的所有路由,以及这些路由如何映射到 React 组件,包括带有参数的路由。本章还介绍了如何根据需要从路由加载组件,以优化性能。
第 6 章使用表单*介绍如何在 React 中构建表单。本章介绍了如何在利用流行的第三方库使表单构建过程更加高效之前,在 plain React 中构建表单。
第 7 章使用 Redux管理状态,逐步介绍此流行库如何帮助管理应用中的状态。在 TypeScript 的帮助下,构建了一个强类型 Redux 存储以及操作和减缩器。
第 3 节:构建 ASP.NET 后端
第 8 章通过 Dapper与数据库交互,介绍了一个库,使我们能够以高效的方式与 SQL Server 数据库交互。包括对数据库的读写,包括从 C#类映射 SQL 参数和将结果映射到 C#类。
第 9 章创建 REST API 端点介绍了如何创建与数据存储库交互的 REST API。在此过程中,将介绍依赖项注入、模型绑定和模型验证。
第 10 章提高性能和可扩展性介绍了几种提高后端性能和可扩展性的方法,包括减少数据库往返、使 API 异步和数据缓存。在这一过程中,使用了几种工具来衡量改进的影响。
第 11 章保护后端利用 ASP.NET 标识和 JSON web 令牌向 ASP.NET Core 后端添加身份验证。本章还介绍了使用标准和自定义授权策略对 RESTAPI 端点的保护。
第 12 章与 RESTful API 的交互介绍了 React 前端如何使用 JavaScript 获取功能与 ASP.NET Core 后端对话。本章还介绍 React 前端如何使用 JSON web 令牌访问受保护的 REST API 端点。
第 4 节:投入生产
第 13 章增加自动测试介绍了如何使用 xUnit 在 ASP.NET Core 后端创建单元测试和集成测试。本章还介绍了如何使用 Jest 在纯 JavaScript 函数和 React 组件上创建测试。
第 14 章配置并部署到 Azure,介绍 Azure,然后逐步部署后端和前端以分离 Azure 应用服务。本章还介绍了 SQL Server 数据库到 SQL Azure 的部署。
第 15 章使用 Azure DevOps实现 CI 和 CD,在逐步创建构建管道之前引入 Azure DevOps,该管道在代码推送到源代码存储库时自动触发。然后,本章逐步设置一个发布管道,将构件从构建部署到 Azure 中。
充分利用这本书
您需要了解 C#的基本知识,包括以下内容:
- 了解如何创建变量并引用它们,包括数组和对象
- 了解如何创建和使用类
- 了解如何使用
if和else关键字创建条件语句
您需要了解 JavaScript 的基础知识,包括以下内容:
- 了解如何创建变量并引用它们,包括数组和对象
- 了解如何创建函数并调用它们
- 了解如何使用
if和else关键字创建条件语句
您需要了解 HTML 的基本知识,包括以下内容:
- 了解基本 HTML 标记,如
div、ul、p、a、h1和h2,以及如何将它们组合在一起创建网页 - 了解如何引用 CSS 类来设置 HTML 元素的样式
您需要了解基本 CSS,包括以下内容:
- 如何调整元素大小并包括边距和填充
- 如何定位元素
- 如何为元素着色
了解基本 SQL 会有所帮助,但不是必需的。
您需要在计算机上安装以下设备:
- 谷歌浏览器:可以从安装 https://www.google.com/chrome/ 。
- Visual Studio 2019:可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET Core 5:可从下载安装 https://dotnet.microsoft.com/download/dotnet-core 。
- Visual Studio 代码:可从下载并安装 https://code.visualstudio.com/ 。
- Node.js 和 npm:可从下载安装 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版
- SQL Server Express Edition:可从下载并安装 https://www.microsoft.com/en-gb/sql-server/sql-server-editions-express 。
- SQL Server Management Studio:可从下载并安装 https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms 。
如果您使用的是本书的数字版本,我们建议您自己键入代码或通过 GitHub 存储库访问代码(下一节提供链接)。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 的下载本书的示例代码文件 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。如果代码有更新,它将在现有 GitHub 存储库中更新。
我们的丰富书籍和视频目录中还有其他代码包,请访问https://github.com/PacktPublishing/ 。看看他们!
运行中的代码
本书的行动代码视频可在查看 http://bit.ly/3mB8KuU 。
使用的约定
本书中使用了许多文本约定。
Code in text:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个示例:“让我们用以下代码在frontend中创建一个名为.eslintrc.json的文件。”
代码块设置如下:
{
"extends": "react-app"
}
当我们希望提请您注意代码块的特定部分时,相关行或项目以粗体显示:
function App() {
const unused = 'something';
return (
...
);
};
任何命令行输入或输出的编写方式如下:
> cd frontend
> npm start
粗体:表示一个新术语、一个重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词出现在文本中,如下所示。下面是一个例子:“点击安装安装扩展,然后点击重新加载按钮完成安装。”
提示或重要提示
看起来像这样。
联系
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中注明书名,并发送电子邮件至customercare@packtpub.com。
勘误表:尽管我们已尽一切努力确保内容的准确性,但还是会出现错误。如果您在本书中发现错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,单击 errata 提交表单链接,然后输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法复制品,请您提供我们的位置地址或网站名称,我们将不胜感激。请致电与我们联系 copyright@packt.com带有指向该材料的链接。
如果您有兴趣成为一名作家:如果您对某个主题有专业知识,并且您有兴趣撰写或贡献一本书,请访问authors.packtpub.com。
审查
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在读者可以看到并使用您的无偏见意见做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。非常感谢。
有关 Packt 的更多信息,请访问Packt.com。**
一、了解 ASP.NET 5 React 模板
React 是 Facebook 帮助更多人使用 Facebook 代码库并更快交付功能的答案。React 在 Facebook 上运行得非常好,以至于他们最终将其开源(https://github.com/facebook/react )。如今,React 是构建基于组件的前端(在浏览器中运行的客户端代码)的成熟库;它非常受欢迎,拥有庞大的社区和生态系统。在撰写本文时,React 每周的下载量超过了 880 万次,比一年前同期多了 200 万次
ASP.NET Core 于 2016 年首次发布,现在是一个成熟的开源和跨平台 web 应用框架。它是构建后端(在服务器上运行的应用代码)与数据库(如 SQL server)交互的最佳选择。它在微软 Azure 等云平台上也能很好地工作。
在第一章中,我们将从学习单页应用(SPA架构开始。然后,我们将使用 Visual Studio 中的标准模板创建一个 ASP.NET Core 和 React 应用。我们将使用此工具来回顾和理解 React 和 ASP.NET Core 应用的关键部分。然后,我们将了解 ASP.NET Core 应用和 React 应用的入口点以及它们如何相互集成。我们还将学习 Visual Studio 如何在开发模式下同时运行前端和后端,以及如何将它们打包,以备生产。在本章结束时,我们将获得基础知识,以便开始构建一个使用这两种优秀技术的应用,我们将在这本书中逐步建立的东西。
在本章中,我们将介绍以下主题:
- 温泉建筑
- 了解 ASP.NET Core 后端
- 理解 React 前端
让我们开始吧!
技术要求
在本章中,我们需要使用以下工具:
-
Visual Studio 2019: This can be downloaded and installed from https://visualstudio.microsoft.com/vs/. Make sure that the following features are selected in the installer:
a) ASP.NET 与 web 开发
b) Azure 开发
c) Node.js 开发
-
.NET 5.0:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
-
Node.js 和 npm:可从下载 https://nodejs.org/ 。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
查看以下视频以查看代码的运行:https://bit.ly/3riGWib 。
温泉建筑
在本部分中,我们将开始了解单页应用(SPA架构。
SPA 是一个 web 应用,它加载单个 HTML 页面,当用户与该应用交互时,该页面由 JavaScript 动态更新。设想一个简单的注册表单,用户可以在其中输入姓名和电子邮件地址。当用户填写并提交表单时,不会刷新整个页面。相反,浏览器中的一些 JavaScript 使用 HTTPPOST请求处理表单提交,然后使用请求的结果更新页面。请参阅下图:

图 1.1–SPA 中的表格
因此,在返回单个 HTML 页面的第一个 HTTP 请求之后,后续的 HTTP 请求只针对数据,而不是 HTML 标记。所有页面都通过 JavaScript 在客户端浏览器中呈现。
那么,如何处理具有不同 URL 路径的不同页面呢?例如,如果我在浏览器的地址栏中输入https://qanda/questions/32139,它如何进入应用中的正确页面?浏览器的历史 API 允许我们更改浏览器的 URL 并处理 JavaScript 中的更改。这个过程通常被称为路由,在第 5 章与 React Router的路由中,我们将学习如何使用不同的页面构建应用。
我们将在本书中使用 SPA 架构。我们将使用 React 来呈现前端,并使用 ASP.NET Core 作为后端 API
现在,我们已经对 SPA 体系结构有了基本的了解,我们将进一步了解 VisualStudio 可以为我们创建的 SPA 模板应用。
了解 ASP.NET Core 后端
在本节中,我们将首先使用 Visual Studio 中的标准模板创建一个 ASP.NET Core 和 React 应用。此模板非常适合我们查看和理解 ASP.NET Core SPA 中的基本后端组件。
一旦我们使用 Visual Studio 模板构建了应用,我们将从 ASP.NET Core 代码的入口点开始检查它。在我们的检查过程中,我们将了解如何配置请求/响应管道,以及如何处理对端点的请求。
创建 ASP.NET Core 和 React 模板应用
让我们打开 Visual Studio 并执行以下步骤来创建我们的模板化应用:
-
In the start-up dialog, choose Create a new project:
![Figure 1.2 – Visual Studio start-up dialog]()
图 1.2–Visual Studio 启动对话框
-
Next, choose ASP.NET Core Web Application in the wizard that opens and click the Next button:
![Figure 1.3 – Creating a new web app in Visual Studio]()
图 1.3–在 Visual Studio 中创建新的 web 应用
-
Give the project a name of your choice and choose an appropriate location to save the project to. Then, click the Create button to create the project:
![Figure 1.4 – Specifying a project name and location]()
图 1.4–指定项目名称和位置
另一个对话框将出现,允许我们指定要使用的 ASP.NET 核心版本,以及要创建的特定项目类型。
-
Select ASP.NET Core 5.0 as the version and React.js in the dialog. Then, click the Create button, which will create the project:
![Figure 1.5 – The project template and ASP.NET Core version]()
图 1.5–项目模板和 ASP.NET Core 版本
重要提示
如果未列出 ASP.NET Core 5.0,请确保安装了最新版本的 Visual Studio。这可以通过从帮助菜单中选择检查更新选项来完成。
-
既然项目已经创建,按F5运行应用。大约一分钟后,应用将出现在浏览器中:

图 1.6–应用的主页
我们将在本章稍后部分了解为什么应用第一次运行时花费了这么长时间。目前,我们已经创建了 ASP.NET Core。现在,让我们检查一下后端代码
了解后端入口点
ASP.NET Core 应用是创建 web 服务器的控制台应用。应用的入口点是一个名为Program的类中名为Main的方法,可以在项目根目录下的Program.cs文件中找到该方法:
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[]
args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
此方法使用Host.CreateDefaultBuilder创建一个 web 主机,该主机配置如下项:
- web 内容的根的位置
- 其中,设置用于项目,例如数据库连接字符串
- 日志记录级别和日志输出的位置
我们可以使用以Use开头的 fluent API 覆盖默认构建器。例如,要调整 web 内容的根目录,我们可以在以下代码段中添加高亮显示的行:
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseContentRoot("some-path");
webBuilder.UseStartup<Startup>();
});
构建器中指定的最后一件事是Startup类,我们将在下一节中介绍它。
了解创业课程
Startup类位于Startup.cs中的,用于配置应用使用的服务以及请求/响应管道。在本小节中,我们将了解这个类中的两个主要方法。
ConfigureServices 方法
使用名为ConfigureServices的方法配置服务。此方法用于注册以下项目:
- 我们的控制器,将处理请求
- 我们的授权政策
- 我们的 CORS 政策
- 我们自己的类,需要在依赖注入中可用
通过调用services参数上的方法添加服务,通常从Add开始。注意以下代码片段中对AddSpaStaticFiles方法的调用:
public void ConfigureServices(IServiceCollection services)
{0
services.AddControllersWithViews();
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/build";
});
}
这是在生产中如何将 React 应用集成到 ASP.NET Core 中的关键部分,因为这指定了 React 应用的位置。
重要提示
了解 ASP.NET Core 应用在服务器上运行,而 React 应用在浏览器的客户端上运行,这一点很重要。ASP.NET Core 应用只提供ClientApp/Build文件夹中的文件,而不进行任何解释或操作。
不过,ClientApp/Build文件仅在生产模式下使用。接下来,我们将了解 React 应用是如何在开发模式下集成到 ASP.NET Core 中的。
配置方法
当一个请求进入 ASP.NET Core 时,会通过所谓的请求/响应管道,在这里执行一些中间件代码。此管道是使用名为Configure的方法配置的。我们将使用此方法精确定义执行哪个中间件以及执行顺序。中间件代码由通常在app参数中以Use开头的方法调用。因此,我们通常会在Configure方法的早期指定诸如身份验证之类的中间件,最后在 MVC 中间件中指定。模板创建的管道如下所示:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseRouting();
app.UseEndpoints( ... );
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript:
"start");
}
});
}
请注意,在路由和端点设置之前,管道中调用了一个名为UseSpaStaticFiles的方法。这允许主机为 React 应用和 web API 提供服务。
另外,请注意,在端点中间件之后调用了一个UseSpa方法。这是一个中间件,它将处理对 React 应用的请求,而 React 应用只为 React 应用中的单个页面提供服务。它位于UseEndpoints之后,因此对 web API 的请求优先于对 React 应用的请求。
UseSpa方法有一个参数,该参数实际上是应用第一次运行时执行的函数。此函数包含一个逻辑分支,如果您处于开发模式,它将调用spa.UseReactDevelopmentServer(npmScript: "start")。这将告诉 ASP.NET Core 通过运行npm start来使用开发服务器。我们将在本章后面深入研究npm start命令。因此,在开发模式下,React 应用将在开发服务器上运行,而不是让 ASP.NET Core 为来自ClientApp/Build的文件提供服务。我们将在本章后面了解有关此开发服务器的更多信息。
接下来,我们将学习如何将自定义中间件添加到 ASP.NET Core 请求/响应管道中。
定制中间件
我们可以使用如下类创建自己的中间件。该中间件记录 ASP.NET Core 应用处理的每个请求的信息:
public class CustomLogger
{
private readonly RequestDelegate _next;
public CustomLogger(RequestDelegate next)
{
_next = next ?? throw new
ArgumentNullException(nameof(next));
}
public async Task Invoke(HttpContext httpContext)
{
if (httpContext == null) throw new
ArgumentNullException(nameof(httpContext));
// TODO - log the request
await _next(httpContext);
// TODO - log the response
}
}
此类包含一个名为Invoke的方法,该方法是在请求/响应管道中执行的代码。管道中要调用的下一个方法被传递到类中并保存在_next变量中,我们需要在Invoke方法中的适当点调用该变量。前面的示例是自定义记录器的骨架类。我们将在Invoke方法开始时记录请求详细信息,并在_next委托执行后记录响应详细信息,这将在管道的其余部分执行时进行。
下图是请求/响应管道的可视化,显示了如何调用管道中的每个中间件:

图 1.7–请求/响应管道的可视化
我们在IApplicationBuilder接口上以一个新的源文件的形式提供我们的中间件作为扩展方法:
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseCustomLogger(this
IApplicationBuilder app)
{
return app.UseMiddleware<CustomLogger>();
}
}
IApplicationBuilder中的UseMiddleware方法用于注册中间件类。中间件现在将在名为UseCustomLogger的方法中的IApplicationBuilder实例中可用。
因此,可以在Startup类的Configure方法中将中间件添加到管道中,如下所示:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCustomLogger();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseMvc(...);
app.UseSpa(...);
}
在前面的示例中,在管道的开始处调用自定义记录器,以便在任何其他中间件处理请求之前记录请求。我们的中间件中记录的响应也将由所有其他中间件处理。
因此,Startup类允许我们配置如何处理所有请求。在 web API 中,当向特定资源发出请求时,我们如何准确地指定发生了什么?让我们看看。
了解控制器
Web API 资源使用控制器实现。让我们看看模板项目通过在Controllers文件夹中打开WeatherForecastController.cs创建的控制器。它包含一个名为WeatherForecastController的类,该类继承自ControllerBase并带有Route注释:
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
...
}
注释指定控制器处理的 web API 资源 URL。[controller]对象是控制器名称的占位符,减去单词Controller。该控制器将处理对weatherforecast的请求。
类中的Get方法称为动作方法。操作方法处理特定 HTTP 方法和子路径对资源的特定请求。我们用一个属性来修饰该方法,以指定 HTTP 方法和该方法处理的子路径。在我们的示例中,我们正在处理对资源上根路径(weatherforecast的 HTTPGET请求:
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
...
}
让我们通过执行以下步骤,在运行时更仔细地了解 web API:
-
在 Visual Studio 中按F5运行应用。
-
当应用在我们的浏览器中打开后,按F12打开浏览器开发者工具并选择网络面板。
-
Select the Fetch data option from the top navigation bar. An HTTP
GETrequest toweatherforecastwill be shown:![Figure 1.8 – A request to the weatherforecast endpoint in the browser developer tools]()
图 1.8–对浏览器开发人员工具中 weatherforecast 端点的请求
-
返回状态代码为
200的 HTTP 响应,其中包含 JSON 内容:

图 1.9–浏览器开发人员工具中 weatherforecast 端点的响应主体
回顾Getaction 方法,我们返回的是IEnumerable<WeatherForecast>类型的对象,MVC 中间件会自动将该对象转换为 JSON,并将其放入响应体中,并为我们提供200状态码。
所以,这是模板为我们搭建的后端的快速查看。请求/响应管道在Startup类中配置,端点处理程序使用控制器类实现。
在下一节中,我们将介绍 React 前端。
了解 React 前端
是时候把我们的注意力转向 React 前端了。在本节中,我们将检查前端代码,从入口点开始,入口点是一个 HTML 页面。我们将探讨如何在开发模式下执行前端,以及如何构建前端以准备部署。然后,我们将了解如何管理前端依赖项,并了解为什么第一次运行应用需要一分钟的时间。最后,我们将探讨 React 组件如何组合在一起,以及它们如何访问 ASP.NET Core 后端。
了解前端入口点
我们对 ASP.NET Core 后端中的Startup类进行了检查,从中可以很好地了解到入口点在哪里。在Configure方法中,SPA 中间件的源路径设置为ClientApp:
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
如果我们查看ClientApp文件夹,我们将看到一个名为package.json的文件。这是一个经常在 React 应用中使用的文件,包含有关项目、其npm依赖项以及可运行以执行任务的脚本的信息
重要提示
npm是一款流行的 JavaScript 包管理器。package.json中的依赖项引用npm注册表中的包。
如果我们打开package.json文件,我们将看到react列为依赖项:
"dependencies": {
"react": "^16.0.0",
...
"react-scripts": "^3.4.1",
...
},
针对每个包名指定了一个版本。package.json文件中的版本可能与前面代码段中显示的版本不同。根据语义版本控制,版本前面的^符号表示可以安全安装最新的次要版本。
重要提示
语义版本分为三部分:Major.Minor.Patch。当进行 API 破坏性更改时,会出现主要版本增量。添加向后兼容的功能时,会出现较小的版本增量。最后,当添加向后兼容的 bug 修复时,就会出现补丁版本。更多信息请访问https://semver.org 。
因此,react 16.14.0可以安全安装,因为这是撰写本书时 React 16 的最新次要版本。
react-scripts依赖项为我们提供了一个关于 React 是如何构建的重要线索。react-scripts是由 Facebook 开发者开发的流行创建 React 应用(CRA工具)中的一组脚本。这个工具为我们做了大量的配置,包括创建开发服务器、绑定、linting 和单元测试。我们将在下一章中进一步了解 CRA。
CRA 构建的应用的根 HTML 页面为index.html,可在ClientApp文件夹的public文件夹中找到。这个页面承载着 React 应用。CRA 构建的应用执行的根 JavaScript 文件是index.js,它位于ClientApp文件夹中。我们将在本章后面检查index.html和index.js文件
接下来,我们将学习如何在开发模式下执行 React 前端。
以开发模式运行
在以下步骤中,我们将检查 ASP.NET Core 项目文件,查看应用在开发模式下运行时会发生什么情况:
-
We can open the project file by right-clicking on the web application project in Solution Explorer and selecting the Edit Project File option:
![Figure 1.10 – Opening the project file in Visual Studio]()
图 1.10–在 Visual Studio 中打开项目文件
这是一个 XML 文件,其中包含有关 VisualStudio 项目的信息。
-
Let's look at the
Targetelement, which has aNameattribute ofDebugEnsureNodeEnv:<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') "> <!-- Ensure Node.js is installed --> <Exec Command="node --version" ContinueOnError="true"> <Output TaskParameter="ExitCode" PropertyName="ErrorCode" /> </Exec> <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." /> <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." /> <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" /> </Target>当
ClientApp/node-modules文件夹不存在且 Visual Studio 项目在调试模式下运行时,它会执行任务,这是我们按F5时使用的模式。 -
The first task that is run in the
Targetelement is the execution of the following command via anExectask:> node --version此命令返回已安装的节点的版本。这样做似乎很奇怪,但其目的是确定是否安装了节点。如果未安装节点,该命令将出错并被
Error任务捕获,该任务将通知用户节点需要安装该节点以及从何处安装该节点。 -
The next task in the
Targetelement uses aMessagecommand, which outputsRestoring dependencies using 'npm'. This may take several minutes...to the Output window. We'll see this message when we run the project for the first time:![Figure 1.11 – Restoring npm dependencies message when running a project for the first time]()
图 1.11–首次运行项目时恢复 npm 依赖项消息
-
在调试模式下运行项目时执行的最终任务是另一个
Exec任务。执行以下npm命令:> npm install
此命令将package.json中列为依赖项的所有包下载到名为node_modules的文件夹中:

图 1.12–节点 _ 模块文件夹
如果启用了显示所有文件选项,我们可以在解决方案资源管理器窗口中看到这一点。请注意,node_modules中的文件夹比package.json中列出的依赖项多得多。这是因为依赖项将具有依赖项。所以,node_modules中的包都是依赖树中的依赖项。
在部分开始时,我们问自己,为什么项目第一次运行应用花了这么长时间。答案是最后一个任务需要一段时间,因为有很多依赖项需要下载和安装。在后续的运行中,node_modules将被创建,因此这些任务集不会被调用。
在本章前面,我们了解到 ASP.NET Core 在应用处于开发模式时调用npm start命令。如果我们看一下package.json中的scripts部分,就会看到这个命令的定义:
"scripts": {
"start": "rimraf ./build && react-scripts start",
...
}
此命令删除名为build的文件夹,并运行网页包开发服务器。
重要提示
Webpack 是一种转换、捆绑和打包文件以供浏览器使用的工具。Webpack 还有一个开发服务器。CRA 工具已经为我们配置了 Webpack,因此所有的转换和捆绑配置都已经为我们设置好了。
当我们的 ASP.NET Core 后端已经在 IIS Express 中运行时,为什么要使用 Webpack 开发服务器?答案是缩短反馈循环,这将提高我们的生产率。稍后,我们将看到我们可以对运行在 Webpack 开发服务器中的 React 应用进行更改,并且这些更改会自动加载。没有停止和重新启动应用,因此有一个非常快速的反馈循环和巨大的生产力。
出版流程
发布过程是构建工件以在生产环境中运行应用的过程
让我们通过查看Target元素继续检查 XML ASP.NET Core 项目文件,该元素的Name属性为PublishRunWebPack。发布 Visual Studio 项目时,以下代码执行一组任务:
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are
freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install"
/>
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run
build" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="$(SpaRoot)build\**" />
<ResolvedFileToPublish Include="@(DistFiles-
>'%(FullPath)')"
Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest
</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
运行的第一个任务是通过Exec任务执行npm install命令。这将确保下载并安装所有依赖项。显然,如果我们已经在调试模式下运行了项目,那么依赖项应该已经存在。
下一个任务是一个运行以下npm命令的Exec任务:
> npm run build
此任务将运行名为build的npm脚本。如果我们再次查看package.json文件,我们将在scripts部分看到此脚本:
"scripts": {
"start": "rimraf ./build && react-scripts start",
"build": "react-scripts build",
"test": "cross-env CI=true react-scripts test --
env=jsdom",
"eject": "react-scripts eject",
"lint": "eslint ./src/"
}
这引用了create-react-app脚本,它将 React 应用捆绑起来准备生产,优化它以获得更好的性能,并将内容输出到名为build的文件夹中
ItemGroup元素中定义的下一组任务从build文件夹中获取其内容,并将其与要发布的其余内容一起放置在发布位置。
让我们尝试一下,发布我们的应用:
-
在解决方案资源管理器窗口中,右键单击项目并选择发布。。。选项。
-
Choose Folder as the target and click Next:
![Figure 1.13 – Publishing to a folder]()
图 1.13–发布到文件夹
-
Enter a folder location to output the content to and click Finish:
![Figure 1.14 – Publish location]()
图 1.14–发布位置
-
然后创建发布配置文件。点击发布按钮,在出现的屏幕上开始发布过程:

图 1.15–发布配置文件屏幕
过一会儿,我们将看到内容出现在指定的文件夹中,包括一个ClientApp文件夹。如果我们查看这个ClientApp文件夹,我们将看到一个build文件夹,其中包含 React 应用,可以在生产环境中运行。请注意,build文件夹包含index.html,这是将在生产中托管 React 应用的单个页面。
重要提示
需要注意的是,从开发人员的机器上发布并不理想。相反,最好在构建服务器上执行此过程,以确保构建的应用是一致的,并且提交到存储库的代码进入构建。我们将在第 15 章用 Azure DevOps实现 CI 和 CD 中介绍这一点。
了解前端依赖关系
前面我们了解到,前端依赖项在package.json中定义。为什么不将所有依赖项列为index.html中的script标记?为什么我们需要在我们的项目中增加npm包管理的复杂性?答案是很难管理一长串的依赖关系。如果我们使用script标签,我们需要确保这些标签订购正确。我们还负责下载软件包,将它们放在本地项目中,并使它们保持最新。我们已经在搭建的项目中列出了大量的依赖项,而我们的应用中没有任何功能。由于这些原因,使用npm管理依赖项已成为行业标准。
我们再打开package.json看看dependencies部分:
"dependencies": {
"bootstrap": "^4.1.3",
"jquery": "3.4.1",
"merge": "^1.2.1",
"oidc-client": "^1.9.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-router-bootstrap": "^0.24.4",
"react-router-dom": "^4.2.2",
"react-scripts": "^3.0.1",
"reactstrap": "^6.3.0",
"rimraf": "^2.6.2"
},
我们已经观察到了react依赖,但是react-dom依赖是什么?React 不仅仅针对网络;它还针对本地移动应用。这意味着react是用于 web 和移动设备的核心 React 库,react-dom是指定用于针对 web 的库。
react-router-dom包是React Router的npm包,帮助我们在 React 前端管理应用中的不同页面,而无需我们往返服务器。我们将在第 5 章中了解更多关于 React Router 的信息路由与 React 路由。该react-router-bootstrap软件包允许引导与 React 路由很好地协同工作。
我们可以看到,这个 React 应用对Bootstrap 4.1和bootstrap``npm包有依赖关系,所以在我们的项目中可以引用 Bootstrap CSS 类和组件来构建前端。reactstrap包是一个额外的包,允许我们在 React 应用中很好地使用引导。Bootstrap4.1 依赖于 jQuery,这就是我们拥有jquery包依赖性的原因
merge包包含一个将对象合并在一起的功能,oidc-client是一个与OpenID Connect(OIDC)和 OAuth2 交互的包。
我们尚未涉及的最后一个依赖项是rimraf。这只允许删除文件,而不考虑主机操作系统。我们可以看到,start脚本中引用了这一点:
"scripts": {
"start": "rimraf ./build && react-scripts start",
...
}
在本章前面,我们了解到,当我们的应用在开发模式下运行时,会调用此脚本。因此,rimraf ./build在开发服务器启动之前删除build文件夹及其内容。
如果我们再往下看,我们会看到一个名为devDependencies的部分。这些依赖项仅在开发过程中使用,不在生产中使用:
"devDependencies": {
"ajv": "^6.9.1",
"cross-env": "^5.2.0",
"eslint": "^6.8.0",
"eslint-config-react-app": "^5.2.1",
"eslint-plugin-flowtype": "^4.6.0",
"eslint-plugin-import": "^2.20.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.18.3"
},
以下是对这些依赖关系的简要说明:
ajv允许我们验证 JSON 文件。cross-env允许我们设置环境变量,而不考虑主机操作系统。如果您查看package.json文件的scripts部分中的test脚本,您将看到它使用cross-env来设置CI环境变量。- 其余的依赖项都被设计为使用ESLint启用 linting。linting 过程根据一组规则检查代码中有问题的模式。我们将在第 3 章中了解更多关于 ESLint 的信息,学习 React 和 TypeScript入门。
让我们继续学习如何为单个页面提供服务,以及如何将 React 应用注入其中。
了解单页的服务方式
我们知道承载 React 应用的单页是index.html,所以让我们检查一下这个文件。该文件可在ClientApp文件夹的public文件夹中找到。React app 将被注入div标签,标签的id为root:
<div id="root"></div>
让我们在 Visual Studio 中再次运行我们的应用,通过按F5确认情况是否属实。如果我们在打开的浏览器页面中打开开发工具,并在元素面板中检查 DOM,我们将看到这个div标记,其中包含 React 内容:

图 1.16–根 div 元素和脚本元素
注意body元素底部的script元素。这包含我们的 React 应用的所有 JavaScript 代码,包括 React 库本身。然而,这些script元素在源index.html文件中不存在,那么它们是如何在服务页面中到达那里的呢?Webpack 在将所有 JavaScript 捆绑在一起并将其拆分为可按需加载的最佳块后添加了它们。如果我们查看ClientApp文件夹和子文件夹,就会发现static文件夹不存在。JavaScript 文件也不存在。发生什么事?这些是由 Webpack 开发服务器创建的虚拟文件。请记住,当我们使用 VisualStudio 调试器运行应用时,Webpack 开发服务器为index.html服务。因此,JavaScript 文件是 Webpack 开发服务器创建的虚拟文件。
现在,当 Webpack 开发服务器没有运行时,在生产模式下会发生什么?让我们仔细看看我们在本章前面发布的应用。让我们看看Build文件夹中的index.html文件,它可以在ClientApp文件夹中找到。body元素底部的script元素看起来如下所示:
<script>
!function(e){...}([])
</script>
<script src="/static/js/2.f6873cc5.chunk.js"></script>
<script src="/static/js/main.61537c83.chunk.js"></script>
前面的代码段中添加了回车符,以使其更具可读性。每次发布应用时,文件名的突出显示部分可能会有所不同。为了打破浏览器缓存,文件名是唯一的。如果我们在项目中查找这些 JavaScript 文件,我们会发现它们确实存在。因此,在生产模式下,web 服务器将为这个物理 JavaScript 文件提供服务。
如果我们打开这个 JavaScript 文件,我们将看到它包含我们应用的所有 JavaScript。此 JavaScript 已缩小,因此可以将文件轻松快速地下载到浏览器中
重要提示
缩小是在不影响浏览器处理的情况下删除文件中不必要字符的过程。这包括代码注释和格式、未使用的代码、使用较短的变量和函数名等
但是,该文件并不小,并且包含大量 JavaScript。这是怎么回事?这个文件不仅包含我们的 JavaScript 应用代码,还包含所有依赖项的代码,包括 React 本身。
了解组件如何装配在一起
现在,是时候开始研究 React 应用代码以及组件是如何实现的了。请记住,根 JavaScript 文件位于ClientApp文件夹中的index.js。让我们打开此文件,仔细查看以下代码块:
const rootElement = document.getElementById('root');
ReactDOM.render(
<BrowserRouter basename={baseUrl}>
<App />
</BrowserRouter>,
rootElement);
第一条语句选择我们前面发现的div元素,该元素包含rootID,并将其存储在一个名为rootElement的变量中。
下一条语句扩展到多行,并从 React DOM 库调用render函数。正是此函数将 React app 内容注入根div元素。包含对根元素div的引用的rootElement变量作为第二个参数传递到此函数中。
传递到render函数的第一个参数更有趣。事实上,它看起来甚至不像是合法的 JavaScript!这实际上是JSX,我们将在第三章开始使用 React 和 TypeScript中详细了解。
重要提示
JSX 通过 Webpack 使用名为Babel的工具转换为常规 JavaScript。这是 CRA 在构建应用时为我们配置的众多任务之一。
因此,第一个参数传入根 React 组件BrowserRouter,该组件来自 React 路由库。我们将在第 5 章与 React 路由的路由中了解更多关于该组件的信息。
嵌套在BrowserRouter组件中的是一个名为App的组件。如果我们查看index.js文件的顶部,我们将看到App组件是从名为App.js的文件导入的:
import App from './App';
重要提示
import语句用于导入其他 JavaScript 模块导出的项目。模块由其文件位置指定,省略了扩展名js
从npm包导入项目的import语句不需要指定路径。这是因为 CRA 在网页包中配置了一个resolver,在捆绑过程中自动查找node_modules文件夹。
因此,App组件包含在App.js文件中。让我们快速看一下。此文件中定义了一个名为App的类:
export default class App extends Component {
static displayName = App.name;
render () {
return (
<Layout>
<Route exact path='/' component={Home} />
<Route path='/counter' component={Counter} />
<Route path='/fetch-data' component={FetchData} />
</Layout>
);
}
}
注意class关键字前面的export和default关键字。
重要提示
export关键字用于从 JavaScript 模块导出项目。default关键字将导出定义为默认导出,这意味着可以不使用大括号导入。因此,默认导出可以作为import App from './App'而不是import {App} from './App'导入。
名为render的方法定义组件的输出。此方法返回 JSX,在本例中,JSX 引用我们应用代码中的Layout组件和 React Router 中的Route组件。
因此,我们开始了解如何将 React 组件组合在一起以形成 UI。
现在,让我们通过做一个简单的更改来了解 React 开发体验:
-
如果应用尚未运行,请在 Visual Studio 中按F5运行该应用。
-
打开
Home.js文件,可在ClientApp\src\components找到。它包含呈现主页的组件。 -
在应用仍在运行的情况下,在
render方法中,更改 JSX 中的h1标记,使其呈现不同的字符串:render () { return ( <div> <h1>Hello, React!</h1> <p>Welcome to your new single-page application, built with: </p> ... </div> ); } -
保存文件并查看正在运行的应用:

图 1.17–主页在浏览器中自动更新
应用会自动更新我们的更改。保存文件时,Webpack 开发服务器会自动使用更改更新正在运行的应用。在开发 React 前端时,看到我们的更改几乎立即得到实施的体验给了我们真正富有成效的体验。
了解组件如何访问后端 web API
本章最后一个主题是 React 前端如何使用后端 web API。如果应用未运行,请在 Visual Studio 中按F5运行。如果我们点击浏览器中打开的应用顶部导航栏中的获取数据选项,我们将看到一个显示天气预报的页面:

图 1.18–天气预报数据
如果我们回想一下本章前面的内容,在理解控制器一节中,我们看了一个 ASP.NET Core 控制器,它出现了一个 web API,在weatherforecast中公开了数据。因此,这是一个快速了解 React 应用如何调用 ASP.NET Core web API 的好地方。
呈现此页面的组件在FetchData.js中。让我们打开这个文件,看看constructor类:
constructor (props) {
super(props);
this.state = { forecasts: [], loading: true };
}
JavaScript 类中的constructor类是一种特殊的方法,在创建类实例时自动调用。因此,它是初始化类级变量的好地方。
构造函数初始化一个组件状态,其中包含天气预报数据和一个标志,以指示是否正在获取数据。我们将在第 3 章中了解更多关于组件状态的信息,并开始使用 React 和 TypeScript。
我们来看看componentDidMount方法:
componentDidMount() {
this.populateWeatherData();
}
当组件插入到树中时,React 将调用此方法,它是加载数据的最佳位置。这个方法调用了一个populateWeatherData方法,我们来看看:
async populateWeatherData() {
const response = await fetch('weatherforecast');
const data = await response.json();
this.setState({ forecasts: data, loading: false });
}
注意populateWeatherData函数名前的async关键字,同时注意函数内的await关键字。
重要提示
await关键字用于等待异步函数完成。一个函数必须声明为异步,我们才能在其中使用await关键字。这可以通过在函数名前面放置async关键字来实现。这与.NET 中的async和await非常相似。
我们可以看到,在这个方法中使用了一个名为fetch的函数。
重要提示
fetch函数是用于与 web API 交互的本机 JavaScript 函数。fetch函数取代了XMLHttpRequest,在基于 JSON 的 web API 中工作得更好。
传递给fetch函数的参数是 web API 资源的路径;也就是说,weatherforecast。可以使用相对路径,因为 React 应用和 web API 的来源相同。
从 web API 获取天气预报数据并解析响应后,数据将置于组件的状态。
请稍等,本机的fetch功能没有在Internet Explorer(IE中实现。这是否意味着我们的应用无法在 IE 中运行?嗯,fetch功能在 IE 中不可用,但 CRA 已经为此设置了一个 polyfill,因此它工作得非常好
重要提示
polyfill是一段代码,它实现了我们期望浏览器本机提供的功能。Polyfills 允许我们针对所有浏览器尚不支持的功能进行开发。
现在我们来关注一下render方法:
render () {
let contents = this.state.loading
? <p><em>Loading...</em></p>
: FetchData.renderForecastsTable(this.state.forecasts);
return (
<div>
<h1 id="tabelLabel">Weather forecast</h1>
<p>This component demonstrates fetching data from the
server.</p>
{contents}
</div>
);
}
代码中可能包含您不熟悉的概念,因此,如果此时您觉得这没有意义,请不要担心。我保证在我们阅读这本书的过程中,这将是有意义的!
我们已经知道 React 组件中的render方法返回 JSX,我们可以看到 JSX 也在这个render方法中返回。请注意 JSX 中的{contents}引用,它将contentsJavaScript 变量注入div标记底部p标记下方的标记中。在render方法的第一条语句中设置contents变量,并将其设置为加载。。。当 web API 请求发生时显示,当请求完成时显示FetchData.renderForecastsTable的结果。现在,我们将快速了解这一点:
static renderForecastsTable (forecasts) {
return (
<table className='table table-striped' aria-
labelledby="tabelLabel">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{forecasts.map(forecast =>
<tr key={forecast.dateFormatted}>
<td>{forecast.dateFormatted}</td>
<td>{forecast.temperatureC}</td>
<td>{forecast.temperatureF}</td>
<td>{forecast.summary}</td>
</tr>
)}
</tbody>
</table>
);
}
此函数返回 JSX,其中包含一个 HTML 表,其中包含从forecasts数据数组中注入的数据。forecasts数组上的map方法用于迭代数组中的项,并在包含数据的 HTML 表中呈现tr标记。
重要提示
map方法是在数组中可用的本机 JavaScript 方法。它接受为每个数组元素调用的函数参数。然后,函数调用的返回值组成一个新数组。当需要迭代时,JSX 中通常使用map方法。
请注意,我们已经将一个属性应用于每个tr标记。这是干什么用的?这不是 HTML 表行上的标准属性,是吗?
重要提示
key属性有助于检测元素何时更改或添加或删除。因此,它不是标准的 HTML 表行属性。当我们在循环中输出内容时,最好应用此属性并将其设置为循环中的唯一值,以便 React 可以将其与其他元素区分开来。省略键也可能导致大型数据集上的性能问题,因为 React 将在不需要时不必要地更新 DOM。
再说一次,在这一点上需要考虑的东西很多,所以如果有一些地方你没有完全理解,不要担心。到本书结束时,这一切都将成为你的第二天性。
总结
在本章中,我们首先了解到 SPA 中的所有页面都是在诸如 React 之类的框架的帮助下以 JavaScript 呈现的,同时还有数据请求。这是由后端 API 在 ASP.NET Core 等框架的帮助下处理的。我们现在了解到,Startup类配置 ASP.NET Core 后端中使用的服务,以及请求/响应管道。对特定后端 API 资源的请求由控制器类处理。
我们还了解了 ASP.NET Core React 模板如何利用 CRA 创建 React 应用。该工具为我们进行了大量的设置和配置,包括创建开发服务器、捆绑、筛选,甚至为 IE 创建密钥多边形填充。我们了解到,React 应用位于 ASP.NET Core React 模板项目的ClientApp文件夹中,名为index.html的文件是单个页面。名为package.json的文件定义 React 应用的关键项目信息,包括其依赖项以及用于运行和构建 React 应用的任务。
本章对 ASP.NET Core React 应用的所有基本部分以及它们如何协同工作进行了详细的概述。在本书中,我们将更深入地探讨本章所涵盖的许多主题。
根据我们从本章中获得的知识,我们现在准备开始创建我们将通过本书构建的应用,我们将在下一章中开始。
问题
尝试回答以下问题,以测试您在本章中获得的知识:
-
ASP.NET Core 应用中的入口点方法是什么?
-
由模板创建的 ASP.NET Core React 应用中的单个 HTML 页面文件名是什么?此文件夹位于哪个文件夹中?
-
React 应用依赖项定义在哪个文件中?
-
什么
npm命令将在 Webpack 开发服务器中运行 React 应用? -
什么
npm命令构建 React 应用,以便它可以投入生产? -
呈现组件的 React 类组件中的方法名是什么?
-
Have a look at the following code snippet, which configures the request/response pipeline in an ASP.NET Core app:
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseAuthentication(); app.UseHttpsRedirection(); app.UseMvc(); }在请求/响应管道-身份验证或 MVC 控制器中首先调用哪个?
-
配置服务和请求/响应管道的类是否需要调用
Startup?我们能给它取个不同的名字吗? -
CRA 创建的 React 应用支持哪些浏览器?
答案
-
Program类中名为Main的方法是 ASP.NET Core 应用中的入口点方法。 -
名为
index.html的文件是单个 HTML 页面文件名。这位于public文件夹中,可在ClientApp文件夹中找到。 -
React 应用依赖项在
ClientApp文件夹中名为package.json的文件中定义。 -
npm start是将在 WebPack 开发服务器中运行 React 应用的命令。 -
npm run build是构建 React 应用的命令,以使其可用于生产。 -
render方法呈现 React 类组件。 -
身份验证将首先在请求/响应管道中调用。
-
通过在
IHostBuilder中定义Startup类,我们可以给Startup类一个不同的名称,如下例所示:public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<MyStartup>(); }); -
包括 IE 在内的所有现代浏览器都由 CRA 创建的 React 应用支持。
进一步阅读
以下是一些有用的链接,您可以了解有关本章所涵盖主题的更多信息:
- ASP.NET Core 启动:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup
- ASP.NET Core web API 控制器:https://docs.microsoft.com/en-us/aspnet/core/web-api
- 创建 React app:https://facebook.github.io/create-react-app/
- 网页包开发服务器:https://webpack.js.org/configuration/dev-server/
- npm:https://docs.npmjs.com/
- JSX:https://reactjs.org/docs/introducing-jsx.html
- JavaScript 模块导入:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
- JavaScript 模块导出:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export
- JavaScript 获取:https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- JavaScript 数组映射:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
- React 列表及键:https://reactjs.org/docs/lists-and-keys.html
二、创建解耦的 React 和 ASP.NET 5 应用
在本书中,我们将开发一个问答应用;我们将其称为问答应用。用户可以提交问题,其他用户可以提交答案。他们还可以搜索以前的问题并查看为他们提供的答案。在本章中,我们将通过创建 ASP.NET Core 和 React 项目开始构建此应用。
在上一章中,我们学习了如何在 Visual Studio 中使用模板创建 ASP.NET Core 和 React 应用。然而,在本章中,我们将以稍微不同的方式创建我们的应用,并理解此决定背后的原因。
我们的 React 应用将使用 TypeScript,因此我们将了解 TypeScript 的好处以及如何创建 React 和 TypeScript 应用。
本章首先创建一个 ASP.NET Web API 项目,然后再创建一个使用 React 和 TypeScript 的独立前端项目。然后,我们将向前端项目添加一个工具,用于识别潜在的问题代码,以及一个自动格式化代码的工具。
本章将介绍以下主题:
- 创建 ASP.NET Core Web API 项目
- 创建 React 和 TypeScript 应用
- 为 React 和 TypeScript 添加 linting
- 为 React 和 TypeScript 添加自动代码格式
在本章结束时,我们将准备好开始使用 React 和 TypeScript 构建问答应用的前端。
技术要求
在本章中,我们需要以下工具:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET Core 代码。可从下载 https://visualstudio.microsoft.com/vs/ 。
- .NET 5.0:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节中还原代码,请下载相关源代码存储库并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/2J7rc0k 。
创建 ASP.NET Core Web API 项目
在本章中,我们将分别创建 ASP.NET Core 和 React 项目。在第 1 章了解 ASP.NET 5 React 模板时,我们发现使用了 React 和create-react-app的旧版本。单独创建 React 项目允许我们使用 React 和create-react-app的最新版本。单独创建 React 项目还允许我们将 TypeScript 与 React 一起使用,这将帮助我们随着代码库的增长提高工作效率。
在本节中,我们将在 Visual Studio 中创建 ASP.NET Core 后端。
让我们打开 Visual Studio 并执行以下步骤:
-
In the startup dialog, select Create a new project:
![Figure 2.1 – Creating a new project]()
图 2.1–创建新项目
-
Choose ASP.NET Core Web Application in the wizard that opens and click the Next button:
![Figure 2.2 – Selecting a web application project]()
图 2.2–选择 web 应用项目
-
在适当的位置创建一个名为
backend的文件夹。 -
Name the project
QandAand choose thebackendfolder location to save the project. Tick Place solution and project in the same directory and click the Create button to create the project:![Figure 2.3 – Naming the project]()
图 2.3–项目命名
现在,将出现另一个对话框,允许我们指定要使用的 ASP.NET Core 版本,以及要创建的特定项目类型。
-
Select ASP.NET Core 5.0 as the version and ASP.NET Core Web API in the dialog. Then, click the Create button, which will create the project:
![Figure 2.4 – Selecting an API project]()
图 2.4–选择 API 项目
-
创建项目后,打开
Startup.cs并移动app.UseHttpsRedirection()代码行,以便在开发过程中不使用它:public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { ... } else { app.UseHttpsRedirection(); } app.UseRouting(); ... }
我们之所以做出这种改变,是因为在开发模式中,我们的前端将使用 HTTP 协议。默认情况下,Firefox 浏览器不允许对后端具有不同协议的应用进行网络请求。因此,我们希望前端和后端在开发模式下使用 HTTP 协议。
这是本章中我们将对后端进行的唯一更改。在下一节中,我们将创建 React 前端项目。
创建 React 和 TypeScript 应用
在第一章理解 ASP.NET 5 React 模板时,我们发现创建 React 应用(CRA)被 VisualStudio 模板利用来创建 React 应用。我们还了解到 CRA 为我们做了很多有价值的设置和配置。我们将在本节中利用 CRA 创建 React 应用。CRA 是npm注册表中的一个包,我们将执行它来构建 React 和 TypeScript 项目。首先,我们将花时间了解使用 TypeScript 的好处。
了解 TypeScript 的好处
TypeScript 在 JavaScript 之上添加了一个可选的静态类型层,我们可以在开发过程中使用它。静态类型允许我们在开发过程的早期发现某些问题。例如,如果我们在引用变量时出错,TypeScript 将在错误输入变量后立即发现,如以下屏幕截图所示:

图 2.5–捕获未知变量的 TypeScript
另一个例子是,如果我们在引用 React 组件时忘记传递必需的属性,TypeScript 会立即通知我们错误:

图 2.6–捕获缺少的 React 组件属性的 TypeScript
这意味着我们得到的是构建时错误,而不是运行时错误。
这也有助于 VisualStudio 代码等工具提供准确的智能感知;健壮的重构功能,如重命名类;还有很棒的代码导航。
当我们开始构建前端时,我们将很快体验到让我们更高效的各种好处。
现在我们开始了解 TypeScript 的好处,是时候在下一小节中创建一个使用 TypeScript 的 React 项目了。
使用 CRA 创建应用
让我们通过执行以下步骤,使用 CRA 创建 React 和 TypeScript 应用:
-
在前面创建的
QandA文件夹中打开 Visual Studio 代码。请注意,我们应该与backend文件夹处于同一级别,而不是在其中。 -
Open the Terminal in Visual Studio Code, which can be found in the View menu or by pressing Ctrl + '. Execute the following command in the Terminal:
> npx create-react-app frontend --template typescriptnpx工具是 npm 的一部分,它临时安装create-react-appnpm 包并使用它创建我们的项目。我们已经告诉
create-react-appnpm 包在名为frontend的文件夹中创建我们的项目。–-template typescript选项已使用 TypeScript 创建了我们的 React 项目。 -
如果我们在文件夹中查看,我们将看到
App组件有一个tsx扩展名。这意味着这是一个 TypeScript 组件。 -
让我们通过在终端中执行以下命令来验证应用是否正常运行:
> cd frontend > npm start -
The app will appear in our browser after a few seconds:
![Figure 2.7 – App component in our React app]()
图 2.7–React 应用中的应用组件
-
按Ctrl+C停止正在运行的应用,当要求终止作业时,按Y。
那么,为什么我们使用 Visual Studio 代码来开发 React 应用而不是 Visual Studio?嗯,使用 VisualStudio 代码开发前端代码时,总体体验会更好更快一些。
因此,我们现在有了一个使用最新版本 CRA 的 React 和 TypeScript 应用。在下一节中,我们将通过在我们的项目中引入linting来为我们的代码添加更多的自动检查。
添加绒线 React 并打字
Linting 是一个系列检查,其中用于识别可能存在问题的代码。linter 是执行 linting 的工具,它可以在我们的代码编辑器中运行,也可以在持续集成(CI过程中运行。因此,linting 帮助我们在编写过程中编写一致且高质量的代码。
ESLint 是 React 社区中最流行的 linter,CRA 已经在我们的项目中为我们安装了它。因此,我们将使用 ESLint 作为我们应用的 linting 工具。
重要提示
TSLint 是 ESLint 的流行替代品,用于 linting TypeScript 代码,但现在已被弃用。更多信息请参见https://medium.com/palantir/tslint-in-2019-1a144c2317a9 。
在以下小节中,我们将学习如何配置 ESLints 规则,以及如何配置 Visual Studio 代码以突出显示冲突。
将 Visual Studio 代码配置为 lint TypeScript 代码
CRA 已经安装了 ESLint,并为我们配置了。
重要提示
请注意,ESLint 没有出现在我们的package.json文件中。相反,它是 CRA 一揽子计划的一部分。这可以通过打开node_modules\react-scripts中的package.json文件来确认。
我们需要将 VisualStudio 代码告知 lint TypeScript 代码。让我们执行以下步骤来执行此操作:
-
首先,让我们重新打开
frontend文件夹中的 Visual Studio 代码。这是我们将在后面的步骤中安装的扩展所必需的。 -
Go to the Extensions area in Visual Studio Code (Ctrl + Shift + X) and type
eslintinto the search box in the top-left corner. The extension we are looking for is called ESLint and is published by Dirk Baeumer:![Figure 2.9 – Visual Studio Code ESLint extension]()
图 2.9–Visual Studio 代码 ESLint 扩展
-
点击的安装按钮安装扩展。
-
在文件菜单上的首选项菜单中打开设置。打开设置的快捷键为Ctrl+、。
-
Enter
eslintin the search box and scroll down to the Eslint: Probe setting:![Figure 2.8 – ESLint: Probe setting]()
图 2.8–ESLint:探头设置
此设置告诉 Visual Studio 代码在验证代码时要通过 ESLint 运行哪些语言。
-
Make sure that
typescriptandtypescriptreactare in the list. If not, add them using the Add Item button.重要提示
前面的屏幕截图显示了当前用户的所有项目中添加的设置,因为它位于用户选项卡中。如果我们只想更改当前项目中的设置,我们可以在工作区选项卡中找到并调整它。
-
Now, we can go to the Extensions area in Visual Studio Code (Ctrl + Shift + X) and type
eslintinto the search box in the top-left corner. The extension we are looking for is called ESLint and is published by Dirk Baeumer:![Figure 2.9 – Visual Studio Code ESLint extension]()
图 2.9–Visual Studio 代码 ESLint 扩展
-
点击安装按钮安装扩展。
现在,VisualStudio 代码将使用 ESLint 来验证我们的代码。接下来,我们将学习如何配置 ESLint。
配置起毛规则
既然 Visual Studio 代码正在筛选我们的代码,那么让我们执行以下步骤来了解如何配置 ESLint 执行的规则:
-
Let's create a file called
.eslintrc.jsonin thefrontendfolder with the following code:{ "extends": "react-app" }此文件定义 ESLint 执行的规则。我们刚刚告诉它执行 CRA 中配置的所有规则。
-
Let's check that Visual Studio Code is linting our code by adding the following highlighted line to
App.tsx, just before thereturnstatement:const App: React.FC = () => { const unused = 'something'; return ( ... ); };我们将看到 ESLint 立即将该行标记为未使用:
![Figure 2.10 – ESLint catching an unused variable]()
图 2.10–捕捉未使用变量的 ESLint
这太好了——这意味着我们的代码被删除了。
-
Now, let's add a rule that CRA hasn't been configured to apply. In the
.eslintrc.jsonfile, add the following highlighted lines:{ "extends": "react-app", "rules": { "no-debugger":"warn" } }在这里,我们已经告诉 ESLint 警告我们使用
debugger语句。重要提示
可用 ESLint 规则列表可在找到 https://eslint.org/docs/rules/ 。
-
Let's add a
debuggerstatement below our unused variable inApp.tsx, like so:const App: React.FC = () => { const unused = 'something'; debugger; return ( ... ); };我们将立即看到 ESLint 标记这一点:

图 2.11–ESLint 捕获调试器语句
现在,我们在项目中配置了 linting。让我们通过执行以下步骤来清理代码:
- 从
App.tsx中删除未使用的代码行和debugger语句。 - 从
.eslintrc.json文件中删除no-debugger规则。
为了快速回顾,CRA 为我们安装并配置了 ESLint。我们可以使用.eslintrc.json文件调整配置。
在下一节中,我们将了解如何自动格式化代码。
添加自动代码格式化以 React 并键入脚本
强制执行一致的代码样式可以提高代码库的可读性,但即使 ESLint 提醒我们这样做,这也可能是一件痛苦的事情。如果我们忘记在语句末尾添加的分号只是自动为我们添加的,那不是很好吗?嗯,这就是自动代码格式化工具可以为我们做的,而Prettier就是这些伟大的工具之一。
我们将首先安装 Prettier,然后再将其配置为与 ESLint 和 visualstudio 代码配合使用。
添加更漂亮的
通过在 Visual Studio 代码中执行以下步骤,我们将为我们的项目添加更漂亮:
-
确保您在
frontend目录中。执行以下命令安装 Prettier:> npm install prettier --save-dev -
Now, we want Prettier to take responsibility for the style rules from ESLint. Let's install some
npmpackages that will do this:> npm install eslint-config-prettier eslint-plugin-prettier --save-deveslint-config-prettier禁用与 Prettier 冲突的 ESLint 规则。eslint-plugin-prettier是一个 ESLint 规则,它使用 Prettier 格式化代码。 -
现在,让我们告诉 ESLint 让 Prettier 处理代码格式,将以下突出显示的更改添加到
.eslintrc.json:{ "extends": ["react-app","plugin:prettier/recommended"], "rules": { "prettier/prettier": [ "error", { "endOfLine": "auto" } ] } } -
Now, let's specify the formatting rules we want in a
.prettierrcfile in thefrontendfolder. Create a file with the following content:{ "printWidth": 80, "singleQuote": true, "semi": true, "tabWidth": 2, "trailingComma": "all" "endOfLine": "auto" }这些规则将导致长度超过 80 个字符的行被合理地包装,双引号自动转换为单引号,分号自动添加到语句末尾,缩进自动设置为两个空格,以及在可能的情况下自动将尾随逗号添加到多行数组等项中。
-
Now, go to the Extensions area in Visual Studio Code (Ctrl + Shift + X) and type
prettierinto the search box in the top-left corner. The extension we are looking for is called Prettier – Code formatter and is published by Esben Petersen:![Figure 2.12 – Visual Studio Code Prettier extension]()
图 2.12–Visual Studio 代码 Prettier 扩展
-
点击安装按钮安装扩展。
-
当一个文件通过一些设置保存在 VisualStudio 代码中时,我们可以更好地格式化代码。在文件菜单上的首选项菜单中打开设置。在搜索框中输入
format并确保默认格式化程序设置为esbenp.prettier-vscode,并勾选保存时的格式:

图 2.13–保存时 Prettier to format 的设置
所以,这是更漂亮的设置。每当我们在 Visual Studio 代码中保存文件时,它都会自动格式化。
解决错误
安装 Prettier 后,React 导入上可能会出现以下错误:

图 2.14–安装 Prettier 后 React 的错误
要解决此问题,请运行以下命令:
> npm install
命令运行完成后,问题将得到解决。
有些文件可能没有按照我们的设置格式化。通过运行以下命令启动前端:
> npm start
浏览器中将出现一些错误:

图 2.15–更漂亮的错误
要解决这些错误,只需转到每个问题文件并按Ctrl+S保存即可。然后,每个文件将按照我们的规则进行格式化。
为了快速回顾,我们安装了 Prettier,用eslint-config-prettier和eslint-plugin-prettier包自动格式化前端代码,使其能够很好地与 ESLint 配合使用。可以在名为.prettierrc的文件中配置格式。
总结
在本章中,我们为问答应用创建了项目,我们将在本书中构建该应用。我们使用 Web API ASP.NET Core 模板创建后端,使用 Create React App 创建前端。我们包含了 TypeScript,因此前端代码是强类型的,这将帮助我们更早地发现问题,并帮助 VisualStudio 代码提供更好的开发体验。
我们在前端代码中添加了 linting,以提高代码库的质量和一致性。ESLint 是我们的 linter,其规则在名为.eslintrc.json的文件中配置。我们还为前端代码添加了 Prettier,它可以自动格式化代码。这在代码审查中非常有用。然后,我们在一个.prettierrc文件中配置了格式化规则,并使用eslint-config-prettier来阻止 ESLint 与 Prettier 冲突。
因此,与 SPA 模板不同,我们现在有两个独立的前端和后端项目。这是有意义的,主要是因为我们将使用 VisualStudio 开发后端,而 VisualStudio 代码开发前端。因此,不需要从 VisualStudio 中同时启动前端和后端。
在下一章中,我们将开始在 React 和 TypeScript 中构建前端。
问题
尝试回答以下问题,以测试您在本章学到的知识:
- 我们使用
create-react-app命令中的哪个选项来创建带有 TypeScript 项目的 React 应用? - 我们可以使用什么样的 ESLint 规则来帮助防止
console.log语句被添加到我们的代码中? - 在
.prettierrc中,我们可以设置哪些设置来在代码中使用单引号? - VisualStudio 代码中的什么设置告诉 ESLint 扩展检查 React 和 TypeScript 代码?
- Visual Studio 中的哪些设置告诉它在保存代码时使用更漂亮的扩展来自动格式化代码?
答案
- 我们使用
create-react-app命令上的--template typescript选项创建了一个带有 TypeScript 项目的 React 应用。 - 我们可以使用
no-console规则来防止console.log语句被添加到我们的代码中。 - 我们可以使用
.prettierrc中的"singleQuote": true设置在代码中使用单引号。 - Visual Studio 代码中的Eslint:Probe设置告诉 Eslint 扩展检查 React 和 TypeScript 代码是否包含
typescript和typescriptreact。 - 默认格式化程序必须设置为esbenp.prettier-vscode,且 prettier 必须勾选保存时的格式才能在保存时自动格式化代码。
进一步阅读
以下是一些有用的链接,可用于了解有关本章所涵盖主题的更多信息:
- ASP.NET Core API 控制器:https://docs.microsoft.com/en-us/aspnet/core/web-api
- npx:https://www.npmjs.com/package/npx
- 创建 React app:https://create-react-app.dev/docs/getting-started
- ESLint:https://eslint.org/
- 更漂亮:https://prettier.io/
三、开始使用 React 和 TypeScript
在本章中,我们将通过创建一个显示应用主页的基于函数的组件,开始使用 TypeScript 构建 Q&A React 前端。这将在列表中显示最近提出的问题。作为本文的一部分,我们将花时间了解 strict 模式和 JSX。然后,我们将继续使用道具创建更多组件,以便在它们之间传递数据。在本章末尾,我们将开始了解组件状态,以及它如何使组件与事件交互。
本章将介绍以下主题:
- 理解 JSX
- 理解并启用 React 严格模式
- 创建基于功能的组件
- 实现组件道具
- 实现组件状态
让我们开始吧!
技术要求
在本章中,我们需要以下工具:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果您已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。安装步骤见https://treehouse.github.io/installation-guides/windows/node-windows.html 。
- 巴别塔回复:我们将简要地使用这个在线工具来探索 JSX。这可以在找到 https://babeljs.io/repl 。
- Q&A:我们将从本章的 Q&A 前端启动器项目开始。这是我们在第 2 章中完成的项目,创建了解耦的 React 和 ASP.NET 5 应用,并提供了本章需要的图标。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-03/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中还原代码,您可以下载源代码库并在相关编辑器中打开相关文件夹。如果代码是前端代码,则可以使用npm install在终端中恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/3mzfoSp 。
了解 JSX
在本节中,我们将了解 JSX,我们在第 1 章了解 ASP.NET 5 React 模板中简要介绍了 JSX。我们已经知道 JSX 不是有效的 JavaScript,我们需要一个预处理器步骤将其转换为 JavaScript。我们将使用 Babel REPL 来使用 JSX,通过执行以下步骤来了解它如何映射到 JavaScript:
-
Open a browser, go to https://babeljs.io/repl, and enter the following JSX in the left-hand pane:
<span>Q and A</span>右侧窗格中显示以下内容,这是我们的 JSX 编译的结果:
React.createElement("span", null, "Q and A"); -
我们可以看到它编译成一个对
React.createElement的调用,它有三个参数:- 元素类型,可以是 HTML 标记名(如
span)、React 组件类型或 React 片段类型。 - 包含要应用于元素的属性的对象。
- 元素的子元素。
- 元素类型,可以是 HTML 标记名(如
-
让我们通过在
span:<header><span>Q and A</span></header>周围放置一个
header标记来扩展我们的示例 -
This compiles down to two calls with
React.createElement, withspanbeing passed in as a child to theheaderelement that's created:React.createElement( "header", null, React.createElement( "span", null, "Q and A" ) );请注意,代码片段的格式与 Babel REPL 中显示的格式略有不同。前面的代码片段更具可读性,使我们能够清楚地看到嵌套的
React.createElement语句。 -
让我们将
span标记更改为锚定标记,并添加href属性:<header><a href="/">Q and A</a></header> -
在已编译的 JavaScript 中,我们可以看到嵌套的
React.createElement调用已更改为将"a"作为元素类型传入,同时还有一个包含href作为第二个参数的 properties 对象:React.createElement( "header", null, React.createElement( "a", { href: "/" }, "Q and A" ) ); -
This is starting to make sense, but so far, our JSX only contains HTML. Let's start to mix in some JavaScript. We'll do this by declaring and initializing a variable and referencing it inside the anchor tag:
var appName = "Q and A"; <header><a href="/">{appName}</a></header>我们可以看到,这通过 JavaScript 代码编译为以下内容:
var appName = "Q and A"; React.createElement( "header", null, React.createElement( "a", { href: "/" }, appName ) );因此,
appName变量在第一条语句中声明,与我们定义它的方式完全相同,并作为嵌套的React.createElement调用中的子参数传入。 -
The key point to note here is that we can inject JavaScript into HTML in JSX by using curly braces. To further illustrate this point, let's add the word
appto the end ofappName:const appName = "Q and A"; <header><a href="/">{appName + " app"}</a></header>这可归结为以下几点:
var appName = "Q and A"; React.createElement( "header", null, React.createElement( "a", { href: "/" }, appName + " app" ) );
因此,JSX 可以被认为是 HTML 与 JavaScript 混合使用大括号。这使得它非常强大,因为可以使用常规 JavaScript 有条件地呈现元素,以及循环中的呈现元素。
现在我们已经了解了 JSX,我们将在下一节学习 React strict 模式
理解并启用严格模式
React严格模式通过执行某些检查,帮助我们编写更好的 React 组件。这包括对类组件生命周期方法的检查。
React 组件可以使用类或函数实现。类组件具有称为生命周期方法的特殊方法,可以在组件生命周期的特定时间执行逻辑。
严格模式检查生命周期方法在 React并发模式下是否正常工作。
重要提示
React 并发模式是一组功能,可帮助 React 应用保持响应速度,即使网络速度较慢。有关并发模式的更多信息,请参见https://reactjs.org/docs/concurrent-mode-intro.html 。
严格模式检查第三方库中的生命周期方法,以及我们编写的生命周期方法。因此,即使我们使用功能组件构建应用,也可能会收到关于有问题的生命周期方法的警告。
严格的模式检查还警告使用旧的 API,例如旧的上下文 API。我们将在第 12 章与 RESTful API 交互中了解推荐的上下文 API。
strict mode 执行的最后一类检查是检查意外副作用。这些检查还包括内存泄漏和无效应用状态。
重要提示
严格的模式检查只发生在开发模式中——它们不会影响生产构建。
严格模式可通过使用 React 中的StrictMode组件开启。Create React 应用已在index.tsx中为我们的整个应用启用严格模式:
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
StrictMode组件被包裹在将要检查的组件树中的所有 React 组件周围。因此,StrictMode组件通常位于组件树的顶部。
让我们暂时将旧 API 的用法添加到App.tsx。如果前端项目未在 Visual Studio 代码中打开,请打开它并执行以下步骤:
-
Add the following code at the bottom of
App.tsx:class ProblemComponent extends React.Component { render() { return <div ref="div" />; } }这是一个类组件,它在 React 中使用了一个旧的refsAPI。参考文献是参考文献的缩写,但在 React 社区中更常被称为参考文献。不要担心完全理解这个组件的语法——关键是它使用 API,这是不推荐的。
重要提示
React ref 是一个特性,允许我们访问 DOM 节点。有关 React REF 的更多信息可在中找到 https://reactjs.org/docs/refs-and-the-dom.html 。
-
在
App组件<div className="App"> <header className="App-header"> <ProblemComponent /> … </header> </div>中引用该组件
-
在终端中运行以下命令启动应用:
npm start -
Open the browser console; the following message will be displayed:
![Figure 3.1 – Strict mode warning]()
图 3.1–严格模式警告
Strict 模式向控制台输出了一条关于正在使用旧 API 的警告。
-
从
App.tsx中删除ProblemComponent及其引用。 -
当提示停止应用运行时,按Ctrl+C并按Y。
现在我们已经对严格模式有了很好的理解,我们将开始在我们的应用中为主页创建组件。
创建基于功能的组件
在本节中,我们将首先为我们的应用标题创建一个组件,其中将包含我们的应用名称和搜索问题的能力。然后,我们将实现一些组件,以便开始构建应用的主页以及一些模拟数据。
创建表头组件
我们可以创建一个基本Header组件,并通过执行以下步骤将其引用到我们的App组件中:
-
在
src文件夹中创建一个名为Header.tsx的新文件。 -
Import
Reactinto the file with the followingimportstatement:import React from 'react';我们需要导入
React,因为正如我们在本章开头所了解到的,JSX 被转换成 JavaScriptReact.createElement语句。因此,如果没有React,这些语句将出错。 -
我们的组件将首先呈现单词
header。因此,输入以下内容作为我们的初始Header组件:export const Header = () => <div>header</div>;
祝贺我们已经实现了第一个基于功能的 React 组件!
前面的组件实际上是一个设置为Header变量的箭头函数。
重要提示
箭头函数是 ES6 中引入的另一种函数语法。箭头函数语法比原始语法略短,同时保留了this的词法范围。函数参数在括号中定义,函数执行的代码遵循=>,通常被称为胖箭头。更多信息可在中找到 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions 。
请注意,没有花括号或return关键字。相反,我们只定义函数应该在 fat 箭头之后直接返回的 JSX。这就是所谓的隐性回报
我们使用const关键字来声明和初始化Header变量。
重要提示
const关键字可用于声明和初始化一个变量,该变量的引用在以后的程序中不会改变。或者,let关键字可用于声明一个变量,该变量的引用可在以后的程序中更改。更多信息请访问https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/const 。
我们现在可以在App组件中使用Header组件。
-
export关键字允许在其他文件中使用该组件。那么,让我们在App组件中使用它,将其导入App.tsx。在App.tsx中的其他import语句下方添加以下import语句:import { Header } from './Header'; -
现在,我们可以在
App组件的return语句中引用Header组件。让我们用Header组件替换为我们创建 React 应用的header标记。让我们同时删除冗余的logo导入:import React from 'react'; import './App.css'; import { Header } from './Header'; function App() { return ( <div className="App"> <Header /> </div> ); }; export default App; -
在 Visual Studio 代码终端中,输入
npm start运行应用。我们将看到,标题一词出现在页面顶部,居中如下:

图 3.2–收割台组件
再次祝贺您–我们刚刚消耗了第一个 React 组件!
因此,arrow 函数语法是实现基于函数的组件的一种非常好的方法。隐式返回特性减少了我们需要输入的字符数。在本书中,我们将大量使用带有隐式返回的箭头函数。
向收割台组件添加元素
我们将进一步对Header组件进行工作,使其最终看起来如下所示:

图 3.3–标题组件中的所有元素
因此,Header组件将包含应用名称,即Q&A、搜索输入和登录链接。
在应用仍在运行的情况下,执行以下步骤修改Header组件:
-
Add the app name inside an anchor tag inside the
divtag by replacing the wordheader, which was previously used insidediv:export const Header = () => ( <div> <a href="./">Q & A</a> </div> );请注意,包含 JSX 的隐式返回语句现在位于括号中
重要提示
当隐式 return 语句位于多行上时,需要使用括号。当隐式返回仅在一行上时,我们可以不使用括号。
如果需要的话,Prettier 会自动在隐式返回中添加括号,所以我们不必担心记住这个规则。
-
添加一个
input允许用户进行搜索:<div> <a href="./">Q & A</a> <input type="text" placeholder="Search..." /> </div> -
添加允许用户登录的链接:
<div> <a href="./">Q & A</a> <input type="text" placeholder="Search..." /> <a href="./signin"><span>Sign In</span></a> </div> -
The Sign In link needs a user icon next to it. We're going to use
user.svgfor this, which should already be in our project.重要提示
user.svg在本章的启动项目中。您可以从下载 https://github.com/PacktPublishing/NET-5-and-React-17---Second-Edition/blob/master/chapter-03/start/frontend/src/user.svg 如果您没有以启动项目开始本章。 -
We are going to create a component to host this icon, so create a file called
Icons.tsxin thesrcfolder and enter the following content into it:import React from 'react'; import user from './user.svg'; export const UserIcon = () => ( <img src={user} alt="User" width="12px" /> );在这里,我们创建了一个名为
UserIcon的组件,它呈现一个img标记,将src属性设置为我们从user.svg导入的svg文件。 -
让我们回到
Header.tsx并导入我们刚刚创建的图标组件:import { UserIcon } from './Icons'; -
现在,我们可以将
UserIcon组件的一个实例放置在button内部的Header组件中span:export const Header = () => ( <div> <a href="./">Q & A</a> <input type="text" placeholder="Search..." /> <a href="./signin"> <UserIcon /> <span>Sign In</span> </a> </div> );之前
-
让我们看看 running 应用中的标题:

图 3.4–更新的标题组件
我们的标题看起来不太好,但是我们可以看到我们刚刚创建的Header组件中的元素。我们将在下一章中设计我们的Header组件,第 4 章,设计 EmotionReact 组件。
创建主页组件
让我们创建另一个组件来更熟悉这个过程。这次,我们将通过执行以下步骤为主页创建一个组件:
-
Create a file called
HomePage.tsxin thesrcfolder with the following content:import React from 'react'; export const HomePage = () => ( <div> <div> <h2>Unanswered Questions</h2> <button>Ask a question</button> </div> </div> );我们的主页只包含一个包含文本的标题、未回答的问题和一个提交问题的按钮。
-
打开
App.tsx并导入我们的HomePage组件:import { HomePage } from './HomePage'; -
现在,我们可以在
render方法<div className="App"> <Header /> <HomePage /> </div>中的
Header组件下添加一个HomePage实例 -
如果我们看一下 running app,我们会看到
Header组件内容下的标题和按钮:

图 3.5–带有提问按钮的页面标题
我们在HomePage组件上有了一个良好的开端。在下一节中,我们将创建一些将在其中使用的模拟数据。
创建模拟数据
我们迫切需要一些数据,以便开发前端。在本节中,我们将在前端创建一些模拟数据。我们还将创建一个函数,组件将调用该函数来获取数据。最终,该函数将调用真正的 ASP.NET Core 后端。遵循以下步骤:
-
Create a new file in the
srcfolder calledQuestionsData.tswith the following interface:export interface QuestionData { questionId: number; title: string; content: string; userName: string; created: Date; }在继续之前,让我们先了解一下刚刚输入的代码,因为我们刚刚编写了一些 TypeScript。
重要提示
接口是定义对象结构的类型,包括对象的所有属性和方法。JavaScript 中不存在接口,因此 TypeScript 编译器在类型检查过程中纯粹使用这些接口。我们用
interface关键字创建一个接口,后面是它的名称,后面是组成该接口的属性和方法(用大括号括起来)。更多信息请访问https://www.typescriptlang.org/docs/handbook/interfaces.html 。因此,我们的接口被称为
QuestionData,它定义了我们希望处理的问题的结构。我们已经导出了界面,以便在我们与问题数据交互时可以在整个应用中使用。请注意在接口中属性名称之后显示的类型。这些被称为类型注释,是 JavaScript 中不存在的 TypeScript 特性。
重要提示
类型注释允许我们使用特定类型声明变量、属性和函数参数。这允许 TypeScript 编译器检查代码是否符合这些类型。简言之,类型注释允许 TypeScript 在代码使用错误类型的情况下捕获 bug,这比我们使用 JavaScript 编写代码的时间要早得多
请注意,我们已经指定了
created属性具有Date类型。重要提示
Date类型是 TypeScript 中的一种特殊类型,表示DateJavaScript 对象。此Date对象表示时间上的单个时刻,并指定为自 UTC 1970 年 1 月 1 日午夜以来的毫秒数。更多信息请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date 。 -
在
QuestionData下,让我们为预期答案的结构创建另一个界面:export interface AnswerData { answerId: number; content: string; userName: string; created: Date; } -
Now, we can adjust the
QuestionDatainterface so that it includes an array of answers:export interface QuestionData { questionId: number; title: string; content: string; userName: string; created: Date; answers: AnswerData[]; }注意
answers属性的类型注释中的方括号。重要提示
类型后的方括号表示该类型的数组。更多信息请参见https://www.typescriptlang.org/docs/handbook/basic-types.html #数组。
-
Let's create some mock questions below the interfaces. You can copy the code at https://github.com/PacktPublishing/NET-5-and-React-17---Second-Edition/blob/master/chapter-03/finish/frontend/src/QuestionsData.ts to save yourself typing it all out:
const questions: QuestionData[] = [ { questionId: 1, title: 'Why should I learn TypeScript?', content: 'TypeScript seems to be getting popular so I wondered whether it is worth my time learning it? What benefits does it give over JavaScript?', userName: 'Bob', created: new Date(), answers: [ { answerId: 1, content: 'To catch problems earlier speeding up your developments', userName: 'Jane', created: new Date(), }, { answerId: 2, content: 'So, that you can use the JavaScript features of tomorrow, today', userName: 'Fred', created: new Date(), }, ], }, { questionId: 2, title: 'Which state management tool should I use?', content: 'There seem to be a fair few state management tools around for React - React, Unstated, ... Which one should I use?', userName: 'Bob', created: new Date(), answers: [], }, ];注意,我们输入了
questions变量,其中包含我们刚刚创建的QuestionData接口的数组。如果我们遗漏了一个属性或拼写错误,TypeScript 编译器会抱怨。 -
Let's create a function that returns unanswered questions:
export const getUnansweredQuestions = (): QuestionData[] => { return questions.filter(q => q.answers.length === 0); };此函数使用
array.filter方法返回我们刚刚创建的问题数组项,其中不包含答案重要提示
数组中的
array.filter方法执行为每个数组项传递给它的函数,然后使用从函数返回的所有元素创建一个新数组。真实值是除false、0、""、null、undefined或NaN以外的任何值。更多信息请访问https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter 。
注意,我们在函数参数之后为函数定义了返回类型QuestionData[]。
在下一节中,我们将使用getUnansweredQuestions函数为主页提供数据。
实现组件道具
组件可以有属性,允许使用者将参数传递给它们,就像我们将参数传递给 JavaScript 函数一样。React 函数组件接受一个名为props的参数,该参数保存其属性。道具是属性的缩写。
在本节中,我们将学习如何实现强类型的道具,包括可选和默认道具。然后,我们将实现主页的其余部分以帮助学习。
创建主页子组件
我们将实现HomePage组件将使用的一些子组件。我们将通过道具将未回答的问题数据传递给子组件。
创建问题列表组件
让我们通过以下步骤来实现QuestionList组件:
-
让我们在
src文件夹中创建一个名为QuestionList.tsx的文件,并添加以下import语句:import React from 'react'; import { QuestionData } from './QuestionsData'; -
Now, let's define the interface for the component props underneath the
importstatements:interface Props { data: QuestionData[]; }我们已经调用了 props 接口
Props,它包含一个单独的属性来保存问题数组。 -
Let's start by implementing the
QuestionListcomponent:export const QuestionList = (props: Props) => <ul></ul>;注意函数组件中的参数
props。我们给了它一个带有类型注释的Props类型。这意味着当我们在 JSX 中引用data道具时,我们可以将其传递到QuestionList。 -
Now, we can inject the data into the list:
export const QuestionList = (props: Props) => ( <ul> {props.data.map((question) => ( <li key={question.questionId}> </li> ))} </ul> );我们在
data数组中使用map方法迭代传递到组件中的数据。重要提示
map是 JavaScript 数组中可用的标准方法。该方法遍历数组中的项,为每个数组项调用传递给它的函数。函数应返回一个将形成新数组的项。总之,这是一种将一个数组映射到一个新数组的方法。更多信息请访问https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map 。因此,我们迭代传递到
QuestionList中的问题,并为每个数组项呈现一个liHTML 元素。注意我们传递到
li元素的key道具。重要提示
key道具帮助 React 检测元素何时更改、添加或删除。当我们在 React 中输出循环中的内容时,最好应用此道具并将其设置为循环中的唯一值。这有助于在渲染过程中将其与其他元素区分开来。如果我们不提供关键道具,React 将对 DOM 进行不必要的更改,从而影响性能。更多信息请访问https://reactjs.org/docs/lists-and-keys.html 。 -
Our
QuestionListcomponent will work perfectly fine, but we are going to make one small change that will make the implementation a little more succinct. Here, we are going to destructure the props into adatavariable in the function parameter:export const QuestionList = ({ data }: Props) => ( <ul> {data.map((question) => ( <li key={question.questionId} > </li> ))} </ul> );重要提示
解构是一种特殊的语法,允许我们将对象或数组解包为变量。有关解构的更多信息,请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment 。
请注意,我们直接在 JSX 中引用数据变量,而不是通过 props 变量,就像我们在前面的示例中所做的那样。这是一个很好的模式使用,特别是当有更多的道具。
在完成QuestionList组件之前,我们必须先创建它的子组件Question,我们接下来会做这个。
创建问题组件
按照以下步骤实现Question组件:
-
在
src文件夹中创建一个名为Question.tsx的文件,其中包含以下import语句:import React from 'react'; import { QuestionData } from './QuestionsData'; -
让我们为
Question组件创建道具类型,它只包含一个用于问题数据的道具:interface Props { data: QuestionData; } -
现在,我们可以创建组件:
export const Question = ({ data }: Props) => ( <div> <div> {data.title} </div> <div> {`Asked by ${data.userName} on ${data.created.toLocaleDateString()} ${data. created.toLocaleTimeString()}`} </div> </div> );
因此,我们呈现了问题的标题,是谁提出的问题,以及何时提出的问题。
请注意,当询问问题时,我们正在使用data.created Date对象上的toLocaleDateString和toLocaleTimeString函数进行输出。
重要提示
在不同的国家,日期通常以不同的格式显示。例如,2021 年 2 月 1 日可以在不同的国家/地区显示为 02/01/21 或 01/02/21。toLocaleDateString和toLocaleTimeString是Date对象上的方法,根据浏览器的区域设置格式化日期和时间。更多信息请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString 和https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString 。
这很好地完成了我们的Question组件。
给部件布线
现在,我们可以用我们的道具将我们刚刚创建的组件连接起来,这样我们就可以在主页上看到未回答的问题。请按照以下步骤执行此操作:
-
让我们回到
QuestionList.tsx并导入我们刚刚创建的Question组件:import { Question } from './Question'; -
现在,我们可以在嵌套在
li元素{data.map((question) => ( <li key={question.questionId}> <Question data={question} /> </li> ))}中的
QuestionListJSX 中放置一个Question组件的实例 -
转到
HomePage.tsx中的HomePage组件,我们导入QuestionList组件。我们还导入前面创建的getUnansweredQuestions函数,该函数返回未回答的问题:import { QuestionList } from './QuestionList'; import { getUnansweredQuestions } from './QuestionsData'; -
Now, we can place an instance of
QuestionListinside theHomePagecomponent JSX, inside the outermostdivtag:<div> <div> <h2>Unanswered Questions</h2> <button>Ask a question</button> </div> <QuestionList data={getUnansweredQuestions()} /> </div>请注意,我们通过调用本章前面创建并导入的
getUnansweredQuestions函数,将问题数组传递到data道具中。 -
如果我们现在查看正在运行的应用,我们将看到一个未回答的问题输出:

图 3.6–未回答的问题
如果我们的模拟数据中有多个未回答的问题,它们将是我们主页上的输出。
我们将通过了解可选和默认道具来完成关于道具的部分,这可以使我们的组件对消费者来说更加灵活。
可选和默认道具
道具可以是可选的,这样消费者就不必将道具传递到组件中。例如,我们可以在Question组件中有一个可选的道具,允许消费者更改是否呈现问题的内容。我们现在将执行此操作:
-
We need to add the content to the
Questioncomponent, so add the following code beneath the question title in the JSX:export const Question = ({ data }: Props) => ( <div> <div> {data.title} </div> <div> {data.content.length > 50 ? `${data.content.substring(0, 50)}...` : data.content} </div> <div> {`Asked by ${data.userName} on ${data.created.toLocaleDateString()} ${data. created.toLocaleTimeString()}`} </div> </div> );这里,我们使用了一个 JavaScript三元运算符来截断长度超过 50 个字符的内容。
重要提示
JavaScript 三元语句是实现条件语句的一种简单方法,它会导致执行逻辑的两个分支之一。该语句包含三个操作数,由问号(
?和冒号(:)分隔。第一个操作数是条件,第二个是条件为true时返回的操作数,第三个是条件为false时返回的操作数。三元运算符是 JSX 中实现条件逻辑的常用方法。更多信息请参见https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator 。在这个代码片段中,我们还使用了模板文本和互极化。
重要提示
JavaScript 模板文本是 backticks(```cs 中包含的字符串。模板文本可以包含将数据注入字符串的表达式。表达式包含在美元符号后的花括号中。这通常被称为插值。更多信息请访问https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals 。
-
在
Question.tsx的Props界面中创建一个附加属性,表示问题的内容是否显示:interface Props { data: QuestionData; showContent: boolean; } ```cs -
我们来分解
Question组件参数export const Question = ({ data, showContent }: Props) => ```cs 中的`showContent`道具 -
Let's change where we render the question content to the following:
<div> {data.title} </div> {showContent && ( <div> {data.content.length > 50 ? `${data.content.substring(0, 50)}...` : data.content} </div> )} <div> {`Asked by ${data.userName} on ${data.created.toLocaleDateString()} ${data.created. toLocaleTimeString()}`} </div> ```cs 我们刚刚更改了组件,以便仅当`showContent`道具是使用短路运算符`&&`的`true`时,它才会呈现问题的内容。 重要提示 短路运算符(`&&`是表示条件逻辑的另一种方式。它有两个操作数,第一个是条件,第二个是条件求值为`true`时要执行的逻辑。在 JSX 中,如果条件为`true`,则通常使用它有条件地呈现元素。 -
If we go back to
QuestionList.tsx, we'll see a TypeScript compilation error where theQuestioncomponent is referenced:![Figure 3.7 – TypeScript compilation error on the Question component]()
图 3.7–问题组件上的 TypeScript 编译错误
这是,因为
showContent是Question组件中必需的道具,我们还没有将其传入。在添加道具时,总是要更新消费组件,这可能是一种痛苦。如果我们不传递它,showContent就不能默认为false吗?这正是我们下一步要做的。 -
Move back into
Question.tsxand make theshowContentprop optional by adding a question mark after the name of the prop in the interface:interface Props { data: QuestionData; showContent?: boolean; } ```cs 重要提示 可选属性实际上是一个 TypeScript 功能。通过在类型注释之前的参数名称末尾添加问号,也可以使函数参数成为可选参数;例如,`(duration?:number)`。 现在,`QuestionList.tsx`中的编译错误已经消失,应用将呈现没有内容的未回答问题。 如果我们希望在默认情况下显示问题的内容,并允许消费者在需要时抑制该内容,该怎么办?我们将使用两种不同的默认道具方法来实现这一点。 -
We can set a special object literal called
defaultPropson the component to define the default values:export const Question = ({ data, showContent }: Props) => ( ... ); Question.defaultProps = { showContent: true, }; ```cs 如果我们查看正在运行的应用,我们将看到问题内容按预期呈现:  图 3.8–未回答的内容问题 -
还有另一种设置默认道具的方法,可以说更整洁。让我们删除
defaultProps对象文字,并在解构组件的showContent参数export const Question = ({ data, showContent = true }: Props) => ( ... ) ```cs 后指定默认值
这可以使代码更具可读性,因为默认值就在其参数旁边。这意味着我们的眼睛不需要扫描到函数的底部,就可以看到是参数的默认值。
因此,我们的主页在代码结构方面看起来不错。然而,HomePage.tsx中有几个组件可以提取,这样我们可以在开发应用的其余部分时重用它们。我们下一步做这个。
儿童道具
children道具是所有 React 组件自动拥有的魔法道具。它可用于渲染子元素。它很神奇,因为它自动存在,我们不需要做任何事情,而且非常强大。在以下步骤中,我们将在创建Page和PageTitle组件时使用children道具:
-
First, let's create a file called
PageTitle.tsxin thesrcfolder with the following content:import React from 'react'; interface Props { children: React.ReactNode; } export const PageTitle = ({ children, }: Props) => <h2>{children}</h2>; ```cs 我们使用类型注释`ReactNode`定义`children`道具。这将允许我们使用广泛的子元素,例如其他 React 组件和纯文本。 我们在`h2`元素中引用了`children`道具。这意味着消费组件指定的子元素将被放置在`h2`元素中。 -
Let's create a file called
Page.tsxwith the following content:import React from 'react'; import { PageTitle } from './PageTitle'; interface Props { title?: string; children: React.ReactNode; } export const Page = ({ title, children }: Props) => ( <div> {title && <PageTitle>{title}</PageTitle>} {children} </div> ); ```cs 在这里,组件接受一个可选的`title`道具,并在`PageTitle`组件内呈现该道具 该组件还包含一个`children`道具。在消费组件中,`Page`组件中嵌套的内容将呈现在我们刚刚放置`children`道具的位置。 -
现在我们回到
HomePage.tsx,导入Page和PageTitle组件:import { Page } from './Page'; import { PageTitle } from './PageTitle'; ```cs -
让我们在
HomePage组件中使用Page和PageTitle组件,如下所示:export const HomePage = () => ( <Page> <div> <PageTitle>Unanswered Questions</PageTitle> <button>Ask a question</button> </div> <QuestionList data={getUnansweredQuestions()} /> </Page> ); ```cs
请注意,我们没有利用HomePage中Page组件中的title道具。这是,因为此页面需要在标题右侧有提问按钮,所以我们在HomePage中呈现。然而,我们实现的其他页面将利用我们已经实现的title道具。
因此,children道具允许消费者在组件内呈现自定义内容。这赋予了组件灵活性,并使其高度可重用,我们将在整个应用中使用Page组件时发现这一点。但是,您可能不知道,children道具实际上是一个函数道具。我们将在下一节学习功能道具。
功能道具
道具可以由基本类型组成,例如我们在Question组件中实现的boolean``showContent道具。道具也可以是对象和阵列,正如我们在Question和QuestionList组件中看到的。这本身就是强大的。然而,道具也可以是功能,这允许我们实现极其灵活的组件。
使用以下步骤,我们将在QuestionList组件上实现一个函数道具,允许使用者呈现问题,作为QuestionList呈现问题的替代方案:
-
在
QuestionList.tsx中,在Props界面增加renderItem功能道具,如下:interface Props { data: QuestionData[]; renderItem?: (item: QuestionData) => JSX.Element; } ```cs -
因此,
renderItemprop 是一个函数,它接受一个包含问题的参数并返回一个 JSX 元素。请注意,我们已将其作为可选道具,以便我们的应用将继续像以前一样运行。 -
让我们将函数参数分解为
renderItem变量:export const QuestionList = ({ data, renderItem }: Props) => … ```cs -
现在,我们可以在 JSX 中调用
renderItem函数 prop(如果已传递),如果未传递,则呈现Question组件:{data.map((question) => ( <li key={question.questionId} > {renderItem ? renderItem(question) : <Question data={question} />} </li> ))} ```cs -
Notice that we are using
renderItemin the ternary condition, even though it isn't a boolean.重要提示
if语句和三元组中的条件如果计算结果为truthy,将执行第二个操作数,如果计算结果为falsy。true只是众多 truthy 值中的一个。事实上,false、0、""、null、undefined,而NaN是虚假的价值观,其他一切都是真实的。因此,
renderItem将是真实的,如果它作为道具传递,则将执行。 -
Our app will render the unanswered questions, just like it did previously, by rendering the
Questioncomponent. Let's try ourrenderItemprop out by openingHomePage.tsxand setting this to the following in theQuestionListelement:<QuestionList data={getUnansweredQuestions()} renderItem={(question) => <div>{question.title}</div>} /> ```cs 如果我们看一下 running 应用,我们会看到这样的效果:  图 3.9–自定义呈现问题 重要提示 实现函数道具以允许使用者呈现组件的内部部分的模式通常被称为**呈现道具**。它使组件非常灵活,可用于许多不同的场景。 -
现在我们了解了渲染道具是什么,我们将恢复此更改,并让
QuestionList收回对渲染问题的控制。因此,从 JSX 中删除以下突出显示的行:<QuestionList data={getUnansweredQuestions()} renderItem={(question) => <div>{question.title}</div>} /> ```cs
我们已经看到功能道具非常强大。在本章后面介绍处理事件时,我们将再次使用这些。然而,在我们研究事件之前,我们将讨论组件的另一个基本部分,即状态。
实现组件状态
组件可以使用所称的状态在组件中的变量发生变化时重新呈现组件。这对于实现交互式组件至关重要。例如,在填写表单时,如果字段值有问题,我们可以使用状态来呈现有关该问题的信息。当外部事物与组件(如 web API)交互时,状态也可用于实现行为。在更改getUnansweredQuestions函数以模拟 web API 调用之后,我们将在本节中进行此操作。
更改 getUnansweredQuestions 以使其异步
getUnansweredQuestions函数不能很好地模拟 web API 调用,因为它不是异步的。在本节中,我们将改变这一点。请按照以下步骤执行此操作:
-
Open
QuestionsData.tsand create an asynchronouswaitfunction that we can use in ourgetUnansweredQuestionsfunction:const wait = (ms: number): Promise<void> => { return new Promise(resolve => setTimeout(resolve, ms)); }; ```cs 此函数将异步等待传入它的毫秒数。该函数在内部使用本机 JavaScript`setTimeout`函数,以便在指定的毫秒数后返回。请注意,该函数返回一个`Promise`对象。 重要提示 **承诺**是一个 JavaScript 对象,表示异步操作的最终完成(或失败)及其结果值。TypeScript 中的`Promise`类型与.NET 中的`Task`类型类似。更多信息请访问[https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) 。 注意返回类型注释中的`Promise`类型后面的`<void>`。TypeScript 类型后的尖括号表示这是泛型类型。 重要提示 **泛型类型**是一种机制,用于在泛型类型的内部实现中使用使用者自己的类型。尖括号允许将使用者类型作为参数传入。TypeScript 中的泛型与.NET 中的泛型非常相似。更多信息请访问[https://www.typescriptlang.org/docs/handbook/generics.html](https://www.typescriptlang.org/docs/handbook/generics.html) 。 我们正在将一个`void`类型传递到泛型`Promise`类型。但是什么是`void`类型? `void`类型是另一种特定于 TypeScript 的类型,用于表示非返回函数。所以,TypeScript 中的`void`就像.NET 中的`void`。 -
Now, we can use the
waitfunction in ourgetUnansweredQuestionsfunction to wait half a second:export const getUnansweredQuestions = async (): Promise<QuestionData[]> => { await wait(500); return questions.filter(q => q.answers.length === 0); }; ```cs 注意调用`wait`函数之前的`await`关键字和函数签名之前的`async`关键字。 `async`和`await`是两个 JavaScript 关键字,我们可以使用它们使异步代码的读取与同步代码几乎相同。`await`停止下一行的执行,直到异步语句完成,而`async`只是指示函数包含异步语句。因此,这些关键字与.NET 中的`async`和`await`非常相似。 我们返回`Promise<QuestionData[]>`而不是`QuestionData[]`,因为函数不会立即返回问题。相反,它最终会返回问题。 -
So, the
getUnansweredQuestionsfunction is now asynchronous. If we openHomePage.tsx, which is where this function is consumed, we'll see a compilation error:![Figure 3.10 – Type error on the data prop]()
图 3.10–数据属性上的类型错误
这是因为函数的返回类型已更改,不再与我们在
QuestionListprops 接口中定义的类型匹配。 -
For now, let's comment the instance of
QuestionListout so that our app compiles:{/* <QuestionList data={getUnansweredQuestions()} /> */} ```cs 重要提示 通过高亮显示代码行并按*Ctrl+/*(正斜杠),可以在 Visual Studio 代码中注释掉代码行。
最后,我们将更改HomePage,以便我们可以在本地状态中存储问题,然后在本地状态中使用该值传递给QuestionList。为此,我们需要在组件首次呈现时调用getUnansweredQuestions,并设置返回到状态的值。我们将在下一节中进行此操作。
使用 useEffect 执行逻辑
那么,当呈现基于函数的组件时,我们如何执行逻辑?那么,我们可以在 React 中使用一个useEffect钩子,这就是我们将在以下步骤中执行的操作:
-
我们需要更改
HomePage以便它有一个显式的return语句,因为我们希望在组件中编写一些 JavaScript 逻辑,并返回 JSX:export const HomePage = () => { return ( <Page> ... </Page> ); }; ```cs -
Now, we can call the
useEffecthook before we return the JSX:export const HomePage = () => { React.useEffect(() => { console.log('first rendered'); }, []); return ( ... ); }; ```cs 重要提示 `useEffect`钩子是一个允许在组件中执行副作用(如获取数据)的函数。该函数包含两个参数,第一个参数是要执行的函数。第二个参数确定第一个参数中的函数应在何时执行。这是在变量数组中定义的,如果更改,将导致执行第一个参数函数。如果数组为空,则仅在第一次呈现组件后才执行该函数。更多信息请访问[https://reactjs.org/docs/hooks-effect.html](https://reactjs.org/docs/hooks-effect.html) 。 所以,当`HomePage`组件第一次渲染时,我们将**第一次渲染**输出到控制台。 -
在正在运行的应用中,让我们打开浏览器开发人员工具并检查控制台:

图 3.11–正在执行的 useEffect
因此,我们的代码是在组件第一次呈现时执行的,这非常好。
注意,我们不应该担心有关未使用的QuestionList组件和getUnansweredQuestions变量的 ESLint 警告。这是因为当我们取消注释对QuestionList组件的引用时,将使用这些。
使用 useState 实现组件状态
已经到了在HomePage组件中实现状态的时候了,这样我们就可以存储任何未回答的问题。但是我们如何在基于功能的组件中做到这一点呢?答案是使用另一个名为useState的 React 钩子。按照HomePage.tsx中列出的步骤进行:
-
将
QuestionData接口添加到QuestionsData导入:import { getUnansweredQuestions, QuestionData } from './QuestionsData'; ```cs -
We'll use this hook just above the
useEffectstatement in theHomePagecomponent to declare the state variable:const [ questions, setQuestions, ] = React.useState<QuestionData[]>([]); React.useEffect(() => { console.log('first rendered'); }, []); ```cs 重要提示 `useState`函数返回一个数组,该数组包含第一个元素中的状态变量,并返回一个函数来设置第二个元素中的状态。状态变量的初始值作为参数传递到函数中。状态变量的 TypeScript 类型可以作为泛型类型参数传递给函数。更多信息请访问[https://reactjs.org/docs/hooks-state.html](https://reactjs.org/docs/hooks-state.html) 。 请注意,我们已经将从`useState`返回的数组分解为一个名为`questions`的状态变量(最初是一个空数组)和一个名为`setQuestions`的设置状态的函数。我们可以对数组进行分解以解压缩其内容,就像我们以前对对象所做的那样。 因此,`questions`状态变量的类型是`QuestionData`的数组。 -
Let's add a second piece of state called
questionsLoadingto indicate whether the questions are being fetched:const [ questions, setQuestions, ] = React.useState<QuestionData[]>([]); const [ questionsLoading, setQuestionsLoading, ] = React.useState(true); ```cs 我们已将此状态初始化为`true`,因为问题将在第一个渲染周期中立即提取。请注意,我们没有将类型传递到泛型参数中。这是因为,在本例中,TypeScript 可以巧妙地从我们传递到`useState`参数的默认值`true`推断出这是一个`boolean`状态。 -
Now, we need to set these pieces of state when we fetch the unanswered questions. First, we need to call the
getUnansweredQuestionsfunction asynchronously in theuseEffecthook. Let's add this and remove theconsole.logstatement:React.useEffect(() => { const questions = await getUnansweredQuestions(); },[]); ```cs 我们立即得到一个编译错误:  图 3.12–使用效果误差 -
发生此错误是因为
useEffect函数回调未标记为async。那么,让我们试着去做async:React.useEffect(async () => { const questions = await getUnansweredQuestions(); }, []); ```cs -
Unfortunately, we get another error:
![Figure 3.13 – Another useEffect error]()
图 3.13–另一个 useEffect 错误
不幸的是,我们无法在
useEffect参数中指定异步回调。 -
错误消息将引导我们找到解决方案。我们可以创建一个异步调用
getUnansweredQuestions的函数,并在useEffect回调函数React.useEffect(() => { const doGetUnansweredQuestions = async () => { const unansweredQuestions = await getUnansweredQuestions(); }; doGetUnansweredQuestions(); }, []); ```cs 中调用该函数 -
现在我们需要设置
questions和questionsLoading状态,一旦我们检索到数据:useEffect(() => { const doGetUnansweredQuestions = async () => { const unansweredQuestions = await getUnansweredQuestions(); setQuestions(unansweredQuestions); setQuestionsLoading(false); }; doGetUnansweredQuestions(); }, []); ```cs -
In the
HomePageJSX, we can uncomment theQuestionListreference and pass in ourquestionstate:<Page> <div ... > ... </div> <QuestionList data={questions} /> </Page> ```cs 如果我们看一下 running 应用,我们会发现问题又被很好地呈现出来了 -
We haven't made use of the
questionsLoadingstate yet. So, let's change theHomePageJSX to the following:
```
<Page>
<div>
...
</div>
{questionsLoading ? (
<div>Loading…</div>
) : (
<QuestionList data={questions || []} />
)}
</Page>
```cs
这里,我们正在呈现一个**加载。。。**获取问题时发送消息。我们的主页将在 running 应用中重新呈现,我们将看到一个**加载。。。**在提取问题时发送消息。
- Before we move on, let's take some time to understand when components are re-rendered. Still in
HomePage.tsx, let's add aconsole.logstatement before thereturnstatement and comment outuseEffect:
```
// React.useEffect(() => {
// ...
// }, []);
console.log('rendered');
return ...
```cs
每次呈现`HomePage`组件时,我们都会在控制台中看到一条呈现的消息:

图 3.14–在无状态更改的情况下渲染两次
因此,当未设置状态时,组件将渲染两次。
在开发模式下,如果处于严格模式,并且组件包含状态,则组件将呈现两次。这样 React 就可以检测到意外的副作用。
- Comment
useEffectback in but leave one of the state setter functions commented out:
```
React.useEffect(() => {
const doGetUnansweredQuestions = async () => {
const unansweredQuestions = await
getUnansweredQuestions();
setQuestions(unansweredQuestions);
// setQuestionsLoading(false);
};
doGetUnansweredQuestions();
}, []);
```cs
组件渲染四次:

图 3.15–状态更改时渲染四次
React 在状态更改时渲染组件,因为我们处于严格模式,所以会得到双重渲染。
我们在组件首次加载时获得双重渲染,在状态更改后获得双重渲染。因此,我们总共得到四个渲染。
- Comment the other state setter back in:
```
React.useEffect(() => {
const doGetUnansweredQuestions = async () => {
const unansweredQuestions = await
getUnansweredQuestions();
setQuestions(unansweredQuestions);
setQuestionsLoading(false);
};
doGetUnansweredQuestions();
}, []);
```cs
组件被渲染六次:

图 3.16–当两个状态发生变化时渲染六次
- 让我们先删除
console.log语句,然后再继续。
因此,我们开始了解如何使用状态来控制外部事物(如用户或 web API)与组件交互时呈现的内容。我们需要了解的一个关键点是,当我们更改组件中的状态时,React 将自动重新渲染组件。
重要提示
HomePage组件称为容器组件,其中QuestionList和Question为表象组件。容器组件负责工作方式、从 web API 获取任何数据以及管理状态。呈现组件负责事物的外观。呈现组件通过其道具接收数据,并且还具有属性事件处理程序,以便其容器可以管理用户交互。
将 React 应用结构化为容器和呈现组件通常允许在不同的场景中使用呈现组件。在本书的后面部分,我们将看到我们可以在应用的其他页面上轻松重用QuestionList
在下一节中,我们将学习当用户使用事件与组件交互时如何实现逻辑。
事件处理
当用户与 web 应用交互时,会调用 JavaScript 事件。例如,当用户单击按钮时,将从该按钮引发一个click事件。我们可以实现一个 JavaScript 函数,在引发事件时执行一些逻辑。此函数通常被称为作为事件侦听器。
重要提示
在 JavaScript 中,事件侦听器使用其addEventListener方法连接到元素,并使用其removeEventListener方法删除。
React 允许我们使用函数道具在 JSX 中声明性地附加事件,而无需使用addEventListener和removeEventListener。在本节中,我们将在 React 中实现两个事件侦听器。
处理按钮点击事件
在本节中,我们将在HomePage组件中的提问按钮上实现一个事件侦听器。请按照以下步骤执行此操作:
-
Open
HomePage.tsxand add aclickevent listener to thebuttonelement in the JSX:<button onClick={handleAskQuestionClick}> Ask a question </button> ```cs 重要提示 JSX 中的事件监听器可以使用一个函数 prop 进行连接,该函数 prop 在 camel case 中的本机 JavaScript 事件名称之前以`on`命名。因此,可以使用`onClick`函数 prop 附加本机`click`事件。React 将在元素销毁之前自动为我们删除事件侦听器。 -
让我们实现的
handleAskQuestionClick函数,就在HomePage组件const handleAskQuestionClick = () => { console.log('TODO - move to the AskPage'); }; return ... ```cs 中`return`语句的上方 -
如果我们点击 running app 中的提问按钮,我们将在控制台中看到以下消息:

图 3.17–点击事件
因此,在 React 中处理事件非常简单!在第 5 章中,通过 React 路由进行路由,我们将完成handleAskQuestionClick功能的实现,并导航到页面,在该页面中可以提问。
处理输入更改事件
在本节中,我们将处理input元素上的change事件,并与事件侦听器中的事件参数进行交互。请按照以下步骤执行此操作:
-
打开
Header.tsx并将change事件侦听器添加到 JSX:<input type="text" placeholder="Search..." onChange={handleSearchInputChange} /> ```cs 中的`input`元素中 -
让我们更改
Header组件,使其具有显式的return语句,并在其上方实现handleSearchInputChange函数:export const Header = () => { const handleSearchInputChange = ( e: React.ChangeEvent<HTMLInputElement> ) => { console.log(e.currentTarget.value); }; return ( ... ); }; ```cs -
注意事件参数的类型注释
React.ChangeEvent<HTMLInputElement>。这确保了与事件参数的交互是强类型的。 -
如果我们在 running 应用的搜索框中键入内容,我们将在控制台中看到每个更改:

图 3.18–变更事件
在本节中,我们了解到我们可以实现强类型事件侦听器,这将帮助我们避免在使用event参数时出错。我们将在第 6 章中完成搜索输入的实现,使用表单。
总结
在本章中,我们了解到 JSX 将 JavaScript 嵌套调用编译为 React 中的createElement函数,这允许我们混合使用 HTML 和 JavaScript。
我们了解到,我们可以使用带有作为参数传入的强类型道具的函数来创建 React 组件。现在,我们知道一个道具可以是一个函数,它是如何处理事件的。
组件状态用于实现用户或其他外部事物与之交互时的行为。因此,我们了解,当状态更改时,组件及其子组件将重新呈现。
通过完成本章,我们了解 React 可以帮助我们发现应用在严格模式下运行时出现的问题。我们还了解,当组件包含状态时,它在严格模式下是双重呈现的。
在下一章中,我们将设计主页的样式。
问题
尝试回答以下问题以测试您对本章的知识:
-
当一个组件的道具改变时,它会重新渲染吗?
-
我们如何创建一个名为
rating的状态,该状态最初设置为0? -
我们将使用什么函数 prop name 来添加
keydown事件侦听器? -
A component has the following props interface:
interface Props { name: string; active: boolean; } ```cs 如何解构`Props`参数并将`active`默认为`true` -
当我们将
category传递到getItems时,一个名为category的状态发生变化时,我们如何使用useEffect钩子调用一个名为getItems的同步函数?
答案
-
是的,当一个组件的道具改变时,它将重新播放。
-
状态定义如下:
const [rating, setRating] = React.useState(0); ```cs -
我们将使用
onKeyDown道具来处理keydown事件。 -
我们可以对 props 参数进行分解,并将
active设置为true,如下所示:export const myComponent = ({name, active = true}: Props) => … ```cs -
useEffect吊钩如下所示:React.useEffect(() => { getItems(category) }, [category]);
进一步阅读
以下是一些有用的链接,您可以了解有关本章所涵盖主题的更多信息:
- 开始时的 React:https://reactjs.org/docs/getting-started.html 。
- React 严格模式:https://reactjs.org/docs/strict-mode.html 。
- 打字稿:https://www.typescriptlang.org/ 。
- 组件和道具:https://reactjs.org/docs/components-and-props.html 。
- React 列表和键:https://reactjs.org/docs/lists-and-keys.html 。
useState吊钩:https://reactjs.org/docs/hooks-state.html 。useEffect吊钩:https://reactjs.org/docs/hooks-effect.html 。
四、使用 Emotion React 组件定义样式
在本章中,我们将使用 JS 库中一个名为“Emotion”的流行 CSS 来设计我们迄今为止构建的问答应用。我们将从了解如何使用纯 CSS 设计组件及其缺点开始。接下来,我们将继续了解 JS 中的 CSS 如何解决普通 CSS 在安装 Emotion 之前遇到的问题。最后,在创建一些可重用的样式化组件之前,我们将使用 Emotion 的css道具对组件进行样式化。
本章将介绍以下主题:
- 使用 CSS 设置组件的样式
- 使用 CSS 模块设置组件样式
- 带 Emotion 的造型组件
- 使用 Emotion 设置伪类和嵌套元素的样式
- 使用 Emotion 创建可重用的样式化组件
- 完成主页样式设置
技术要求
本章需要以下工具:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少是版本 8.2,
npm至少是版本 5.2。 - Q&A:我们将从本章的 Q&A 前端启动器项目开始。这是我们在第三章中完成的项目,开始使用 React 和 TypeScript。可在 GitHub 的上获取 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-04/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中恢复代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可以在终端中使用npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/2WALbb2
使用 CSS 设置组件样式
在本节中,我们将使用常规 CSS 设计主体、应用容器和标题容器的样式,并了解这种方法的缺点。
设置文档正文的样式
我们将使用传统方法来设计文档正文的样式。按照以下步骤进行操作:
-
Open
index.tsx. Remember thatindex.tsxis the root of the React component tree. Notice how the CSS file is referenced:import './index.css';为了在 React 组件中引用 CSS 文件,我们在
import语句之后指定文件的位置。index.css与index.tsx在同一文件夹中,因此导入路径为./。 -
打开
index.css。注意,我们已经为body标记准备了 CSS。让我们删除除margin之外的所有内容,并添加背景色:body { margin: 0; background-color: #f7f8fa; } -
Let's also remove the redundant
codeCSS class inindex.css.祝贺您,我们刚刚在应用中应用了一些样式!
-
Let's run the app by entering the following command in the terminal:
> npm start该应用如下所示:

图 4.1–样式化 HTML 正文
应用的背景色现在是浅灰色。在我们设计App组件时,让应用继续运行。
设置应用组件的样式
我们将在步骤中对App组件应用 CSS 类:
-
Open
App.tsxand change the class name tocontaineron thedivelement:function App() { return ( <div className="container"> <Header /> <HomePage /> </div> ); }为什么
className属性用于引用 CSS 类?我们不应该使用class属性吗?我们已经知道 JSX 可以编译成 JavaScript,因为class是 JavaScript 中的一个关键字,所以 React 使用className属性。当向 HTML DOM 添加元素时,React 将className转换为class。重要提示
React 团队目前正致力于允许使用
class属性而不是className。参见https://github.com/facebook/react/issues/13525 了解更多信息。 -
打开
App.css,删除此文件中的所有 CSS,并添加以下 CSS 类:.container { font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; font-size: 16px; color: #5c5a5a; } -
在浏览器中查看应用。如以下屏幕截图所示:

图 4.2–使用 CSS 设计的应用组件
我们看到应用中的文本内容具有我们指定的字体、大小和颜色。在下一小节中应用更多 CSS 时,保持应用运行。
设置收割台组件的样式
我们将在步骤中对Header组件应用 CSS 类:
-
Create a file called
Header.cssin thesrcfolder and add the following CSS class:.container { position: fixed; box-sizing: border-box; top: 0; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 10px 20px; background-color: #fff; border-bottom: 1px solid #e3e2e2; box-shadow: 0 3px 7px 0 rgba(110, 112, 114, 0.21); }此样式将修复应用于页面顶部的元素。其中的元素将在页面上水平流动,并被很好地定位。
-
打开
Header.tsx并导入我们刚刚创建的 CSS 文件:import './Header.css'; -
在 JSX 中的
div元素中添加container类:<div className="container"> -
查看正在运行的应用,打开浏览器的 DevTools,进入元素面板。
-
We can see that the container style has leaked out of the
Headercomponent into theAppcomponent because thedivelement in theAppcomponent has a drop shadow:![Figure 4.3 – Header component styled with CSS]()
图 4.3–使用 CSS 设置样式的标题组件
-
在终端中按Ctrl+C,如果提示停止应用运行,则按Y。
-
我们刚刚遇到了 CSS 的一个常见问题。CSS 本质上是全球性的。因此,如果我们在一个组件中使用一个名为
container的 CSS 类名称,那么如果一个页面同时引用两个 CSS 文件,它将与另一个 CSS 文件中名为container的 CSS 类发生冲突。
我们可以通过使用诸如 BEM 之类的东西来小心地命名和构造 CSS 来解决这个问题。例如,我们可以将 CSS 类命名为app-container和header-container,这样它们就不会发生冲突。然而,仍然有一些被选择的名字发生冲突的风险,特别是在大型应用中以及当新成员加入团队时。
重要提示
BEM代表块、元素、修饰符,是 CSS 类名的常用命名约定。更多信息请访问https://css-tricks.com/bem-101/ 。
在下一节中,我们将学习如何使用 CSS 模块解决此问题。
使用 CSS 模块设置组件样式
CSS 模块是确定 CSS 类名范围的一种机制。范围界定作为构建步骤而不是在浏览器中进行。事实上,CSS 模块已经在我们的应用中可用,因为 CreateReact 应用已经在 webpack 中配置了它们。
我们将通过以下步骤将Header和App组件上的样式更新为 CSS 模块:
-
将
Header.css重命名为Header.module.css,将App.css重命名为App.module.css。Create React 应用配置为将以module.css结尾的文件视为 CSS 模块。 -
打开
App.tsx并将App.css``import语句更改为以下内容:import styles from './App.module.css' -
Update the
classNameprop on thedivelement to the following:<div className={styles.container}>这引用了带有
AppCSS 模块的容器类。 -
打开
Header.tsx并将Header.css``import语句更改为以下内容:import styles from './Header.module.css' -
将
div元素上的className道具更新为以下内容:<div className={styles.container}> -
让我们在终端中输入以下命令来运行应用:
npm start -
Open the browser's DevTools to the Elements panel:
![Figure 4.4 – Styling with CSS modules]()
图 4.4–CSS 模块的样式
我们可以看到
Header组件中的样式不再泄漏到App组件中。我们可以看到 CSS 模块已经更新了元素上的类名,用组件名作为前缀,并在末尾添加了随机字符。 -
Look in the HTML
headtag. We can see the CSS from the CSS modules instyletags:![Figure 4.5 – CSS modules in a head tag]()
图 4.5-head 标签中的 CSS 模块
-
在终端中按Ctrl+C,如果提示停止应用运行,则按Y。
这太棒了!CSS 模块自动对应用于 React 组件的 CSS 进行范围限定,而无需我们小心类命名。
在下一节中,我们将学习另一种设计 React 组件样式的方法,它可以说更强大。
带 Emotion 的造型组件
在部分中,我们将使用 JS 库中一个名为 Emotion 的流行 CSS 对App、Header和HomePage组件进行样式化。在此过程中,我们将发现 JS 中 CSS 相对于 CSS 模块的优势。
安装 Emotion
随着前端项目在 Visual Studio 代码中打开,让我们通过执行以下步骤将 Emotion 安装到项目中:
-
打开终端,确认您在
frontend文件夹中,执行以下命令:> npm install @emotion/react @emotion/styled -
There is a nice Visual Studio Code extension that will provide CSS syntax highlighting and IntelliSense for Emotion. Open the Extensions area (Ctrl + Shift + X on Windows or Cmd + Shift + X on Mac) and type
styled componentsin the search box at the top left. The extension we are looking for is calledvscode-styled-componentsand was published by Julien Poissonnier:![Figure 4.6 – Styled components Visual Studio Code extension]()
图 4.6–样式化组件 Visual Studio 代码扩展
重要提示
这个扩展主要是为 JS 库中的样式化组件 CSS 开发的。不过,CSS 高亮显示和 IntelliSense 也适用于 Emotion。
-
点击安装按钮安装扩展。
-
要再次显示浏览器面板,请在 Windows 上按Ctrl+Shift+E或在 Mac 上按Cmd+Shift+E。
这是安装在我们项目中的 Emotion,并在 VisualStudio 代码中进行了很好的设置。
设置应用组件的样式
让我们通过执行以下步骤,用 Emotion 设计App组件:
-
首先,通过右键单击
App.module.css文件并选择删除选项来删除该文件。 -
在
App.tsx中,删除表示import styles from './App.module.css'的行。 -
Remove the React
importstatement fromApp.tsx.重要提示
在 React 17 及更高版本中,不再需要包含 React
import语句来呈现 JSX,就像在App.tsx中一样。但是,如果我们想使用库中的函数,比如useState和useEffect,仍然需要 Reactimport语句。 -
Add the following imports from the Emotion library at the top of
App.tsx:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react';css函数是我们用来设计 HTML 元素样式的函数。import语句上方的注释告诉巴贝尔使用jsx函数将 JSX 转换为 JavaScript。重要提示
重要的是要包括
/** @jsxImportSource @emotion/react */注释;否则,透明过程将出错。同样重要的是,将其放在文件的顶部。 -
On the
Appcomponent'sdivelement, remove theclassNameprop and use thecssfunction to specify the style:<div css={css` font-family: 'Segoe UI', 'Helvetica Neue', sans-serif; font-size: 16px; color: #5c5a5a; `}> <Header /> <HomePage /> </div>我们将样式放在 HTML 元素的
css道具中,称为标记模板文字。重要提示
模板文本是一个由倒勾(```cs
)括起来的字符串,它可以跨越多行,并且可以在大括号中包含一个 JavaScript 表达式,前缀为美元符号(${expression}`。当我们需要将静态文本与变量合并时,模板文本非常有用。标记的模板文字是通过在模板文字字符串之前指定的函数执行的模板字符串。在浏览器中呈现字符串之前,将对模板文本执行该函数。
因此,Emotion 的
css函数被用于标记的模板文本中,以呈现 HTML 元素上 backticks(````中定义的样式。 -
We actually want to specify the font family, size, and color in various components in our app. To do this, we are going to extract these values into variables in a separate file. Let's create a file called
Styles.tsin thesrcfolder that contains the following variables:export const gray1 = '#383737'; export const gray2 = '#5c5a5a'; export const gray3 = '#857c81'; export const gray4 = '#b9b9b9'; export const gray5 = '#e3e2e2'; export const gray6 = '#f7f8fa'; export const primary1 = '#681c41'; export const primary2 = '#824c67'; export const accent1 = '#dbb365'; export const accent2 = '#efd197'; export const fontFamily = "'Segoe UI', 'Helvetica Neue',sans-serif"; export const fontSize = '16px';在这里,我们定义了六种灰度、两种应用原色、两种强调色,以及我们将用于标准字体大小的字体系列。
-
让我们将需要的变量导入
App.tsx:import { fontFamily, fontSize, gray2 } from './Styles'; -
现在,我们可以使用插值在 CSS 模板文本中使用这些变量:
<div css={css` font-family: ${fontFamily}; font-size: ${fontSize}; color: ${gray2}; `} > <Header /> <HomePage /> </div>
祝贺您–我们刚刚用 Emotion 设计了我们的第一个组件!
让我们探索一下 running 应用中的样式。这将有助于我们理解 Emotion 是如何运用风格的:
-
让我们在终端中通过执行
npm start来运行应用。 -
Let's inspect the DOM on the browser page by pressing F12:
![Figure 4.7 – App component styled with Emotion]()
图 4.7–使用 Emotion 设计的应用组件
我们可以看到,我们所设计的 AUT0T0 元素有一个类名,从类名 T1 开始,以组件名结尾,中间有一个唯一的名称。CSS 类中的样式是我们在组件中使用 Emotion 定义的样式。所以,Emotion 并不像我们想象的那样生成内联样式。相反,Emotion 生成的样式保存在独特的 CSS 类中。如果我们查看 HTML 标题,我们将看到在
style标记中定义的 CSS 类:![Figure 4.8 – Emotion styles in the head tag]()
图 4.8–head 标签中的 Emotion 风格
因此,在应用的构建过程中,Emotion 已经将样式转换为真正的 CSS 类。
-
在终端中按Ctrl+C,如果提示停止应用运行,则按Y。
-
与 CSS 模块相比,使用 Emotion 的优势在于能够在 CSS 中使用变量。我们刚刚使用它来定义一些常见的样式属性,并在
App组件中使用它们。在本书中,我们将使用样式中的变量,其中逻辑驱动组件上的样式。
设置收割台组件的样式
我们可以通过执行以下步骤,为Header组件设置 Emotion 样式:
-
首先删除
Header.module.css文件。 -
在
Header.tsx中,删除Header.module.css``import语句,并为 Emotion 和我们之前设置的一些样式变量添加以下导入。将这些import语句添加到文件顶部:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import { fontFamily, fontSize, gray1, gray2, gray5 } from './Styles'; -
Now, we can remove the
classNameproperty and define the following style on thedivcontainer element:<div css={css` position: fixed; box-sizing: border-box; top: 0; width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 10px 20px; background-color: #fff; border-bottom: 1px solid ${gray5}; box-shadow: 0 3px 7px 0 rgba(110, 112, 114, 0.21); `} > ... </div>我们正在应用与 CSS 模块中相同的样式。
-
App和Header组件现在的样式与 CSS 模块方法相同。在下一节中,我们将继续设计Header组件,学习 Emotion 的更多特征。
使用 Emotion 设置伪类和嵌套元素的样式
在这一节中,我们将学习如何用 Emotion 风格化伪类。然后,我们将继续学习如何设置嵌套元素的样式:
-
In
Header.tsx, implement the following styles on the anchor tag:<a href="./" css={css` font-size: 24px; font-weight: bold; color: ${gray1}; text-decoration: none; `} > Q & A </a>在这里,我们将应用名称设置为相当大、粗体和深灰色,并删除下划线。
-
Let's move on and style the search box:
<input type="text" placeholder="Search..." onChange={handleSearchInputChange} css={css` box-sizing: border-box; font-family: ${fontFamily}; font-size: ${fontSize}; padding: 8px 10px; border: 1px solid ${gray5}; border-radius: 3px; color: ${gray2}; background-color: white; width: 200px; height: 30px; `} />在这里,我们使用标准字体系列和大小,并为搜索框提供浅灰色圆形边框。
-
We are going to continue to style the
inputelement. We want to change its outline color for when it has focus to a gray color. We can achieve this with afocuspseudo-class selector, as follows:<input type="text" placeholder="Search..." css={css` … :focus { outline-color: ${gray5}; } `} />伪类通过嵌套在输入的 CSS 中来定义。该语法与在常规 CSS 中相同,在伪类名及其 CSS 属性前加一个冒号(
:),放在花括号内。 -
Let's move on to the Sign In link element now. Let's start to style it as follows:
<a href="./signin" css={css` font-family: ${fontFamily}; font-size: ${fontSize}; padding: 5px 10px; background-color: transparent; color: ${gray2}; text-decoration: none; cursor: pointer; :focus { outline-color: ${gray5}; } `} > <UserIcon /> <span>Sign In</span> </a>这些样式在图标和链接内的文本周围添加了一些空间,并删除了其中的下划线。当链接具有焦点时,我们还使用伪类选择器更改了链接周围线条的颜色。
-
Let's style the
spanelement within the Sign In link by adding the following to the end of the template literal:<a href="./signin" css={css` … span { margin-left: 7px; } `} > <UserIcon /> <span>Sign In</span> </a>我们选择在锚标记上使用嵌套元素选择器来设置
span元素的样式。这相当于直接在span元素上应用样式,如下所示:<span css={css` margin-left: 7px; `} > Sign In </span> -
下一步是在
Icons.tsx文件中设置UserIcon组件的样式。让我们将以下内容添加到文件的顶部:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -
Now, we can define the styles on the
imgelement, replacing thewidthattribute:<img src={user} alt="User" css={css` width: 12px; opacity: 0.6; `} />我们已经将宽度从
img标记上的属性移到了它的 CSS 样式中。现在,图标是一个很好的大小和似乎是一个小的颜色较轻。 -
让我们在终端中执行
npm start来运行应用。 -
如果我们看看 running 应用,我们会发现我们的应用标题现在看起来好多了:

图 4.9–全样式标题
我们正在掌握情绪的窍门。
定义样式属性的语法与在 CSS 中定义属性完全相同,如果我们已经很了解 CSS,这很好。我们甚至可以用类似于在 SCS 中嵌套 CSS 属性的方式来嵌套 CSS 属性。
样式的剩余部分是HomePage——我们将在下一步讨论它。
创建具有 Emotion 的可重用样式组件
在本节中,我们将学习如何在对HomePage组件进行样式化的同时创建可重用的样式的组件。让我们执行以下步骤:
-
我们将从
Page.tsx中的Page组件开始,并在文件顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -
让我们设计
div元素的样式,并将页面内容放在屏幕中央:export const Page = ({ title, children }: Props) => ( <div css={css` margin: 50px auto 20px auto; padding: 30px 20px; max-width: 600px; `} > … </div> ); -
让我们转到
HomePage.tsx并在文件顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -
Next, we will style the
divelement that wraps the page title and the Ask a question button:<Page> <div css={css` display: flex; align-items: center; justify-content: space-between; `} > <PageTitle>Unanswered Questions</PageTitle> <button onClick={handleAskQuestonClick}>Ask a question</button> </div> </Page>这会将页面标题和提问按钮放在同一行。
-
现在,让我们打开
PageTitle.tsx并设计页面标题。在文件顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; -
We can then apply the following styles to the
h2element:<h2 css={css` font-size: 15px; font-weight: bold; margin: 10px 0px 5px; text-align: center; text-transform: uppercase; `} > {children} </h2>这将减小页面标题的大小并使其大写,从而使页面内容更加突出。
-
最后,我们有提问按钮,这是主页上的主要按钮。最终,我们将在几个页面上设置主按钮,因此让我们在
Styles.ts文件中创建一个可重用PrimaryButton样式的组件。首先,我们需要从 Emotion 中导入样式化的函数。因此,将这一行添加到文件的顶部:import styled from '@emotion/styled'; -
Now, we can create the primary button styled component:
export const PrimaryButton = styled.button` background-color: ${primary2}; border-color: ${primary2}; border-style: solid; border-radius: 5px; font-family: ${fontFamily}; font-size: ${fontSize}; padding: 5px 10px; color: white; cursor: pointer; :hover { background-color: ${primary1}; } :focus { outline-color: ${primary2}; } :disabled { opacity: 0.5; cursor: not-allowed; } `;在这里,我们通过使用标记的模板文本在 Emotion 中创建了一个样式化组件。
重要提示
标记的模板文字是要用函数解析的模板文字。模板文本包含在 backticks(```cs`)中,解析函数放在它的前面。更多的信息可在上找到 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals 。
在一个样式化的组件中,背景符号(````之前的解析函数引用了 Emotion 的
styled函数中的一个函数。该函数使用将创建的 HTML 元素名命名,并使用提供的样式设置样式。这是本例中的button。因此,这个样式化的组件创建了一个带有我们选择的原色的平面、略圆的按钮。
-
让我们将其导入到
HomePage.tsx文件中:import { PrimaryButton } from './Styles'; -
现在,我们可以用我们的
PrimaryButton样式的组件
```cs
<Page>
<div ... >
<PageTitle>…</PageTitle>
<PrimaryButton onClick={handleAskQuestionClick}>
Ask a question
</PrimaryButton>
</div>
</Page>
```
替换`HomePage`JSX 中的`button`标记
- If we look at the running app, we'll see that it's looking much nicer:

图 4.10–样式化页面标题和主按钮
在主页的样式设计方面还有很多工作要做,例如呈现未回答问题的列表。我们将在下一节中进行此操作。
完成主页样式设置
在本节中,我们将完成主页上的样式设置。
设置问题列表组件的样式
让我们通过以下步骤对QuestionList组件进行样式设计:
-
打开
QuestionList.tsx并在文件顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react; -
同时导入以下常用样式:
import { accent2, gray5 } from './Styles'; -
Style the
ulelement in the JSX as follows:<ul css={css` list-style: none; margin: 10px 0 0 0; padding: 0px 20px; background-color: #fff; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; border-top: 3px solid ${accent2}; box-shadow: 0 3px 5px 0 rgba(0, 0, 0, 0.16); `} > … </ul>因此,
ul元素将显示为没有项目符号的圆形边框。顶部边框将略厚,并采用强调色。我们添加了一个方框阴影,使列表稍微突出一点。 -
Now, let's style the list items:
<ul ... > <li key={question.questionId} css={css` border-top: 1px solid ${gray5}; :first-of-type { border-top: none; } `} > … </li> </ul>列表项上的样式将添加浅灰色上边框。这将作为每个列表项之间的行分隔符。
-
如果我们看一下正在运行的应用,未回答的问题容器看起来不错:

图 4.11–样式化未回答问题容器
接下来,我们将为问题添加样式。
设置问题组件的样式
按照以下步骤设置组件的样式:
-
打开
Question.tsx并在文件顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import { gray2, gray3 } from './Styles'; -
让我们为最外层的
div元素添加一些填充,如下所示:<div css={css` padding: 10px 0px; `} > <div> {data.title} </div> … </div> -
在问题标题中添加一些填充,并增加其字体大小:
<div css={css` padding: 10px 0px; font-size: 19px; `} > {data.title} </div> -
在自选题内容中增加以下样式:
{showContent && ( <div css={css` padding-bottom: 10px; font-size: 15px; color: ${gray2}; `} > … </div> )} -
最后,在提问者的文本周围添加以下样式:
<div css={css` font-size: 12px; font-style: italic; color: ${gray3}; `} > {`Asked by ${data.userName} on ${data.created.toLocaleDateString()} ${data.created. toLocaleTimeString()}`} </div> -
如果我们现在查看 running 应用,我们将在主页上看到以下样式:

图 4.12–完成的主页样式
这就完成了主页上的样式设置。现在看起来好多了。
总结
在本章中,我们学习了设计 React 应用的不同方法。我们现在了解到,JS 库中的 CSS 自动将样式范围限定到组件,并允许我们在样式中使用动态变量。
我们了解如何使用 JS 库中的 Emotion CSS 使用其css道具为 React 组件设计样式。我们可以通过将伪类嵌套在cssprop 样式字符串中来设置伪类的样式。
我们知道如何使用 Emotion 中的styled函数创建可重用样式的组件。这些可以在我们的应用中作为常规 React 组件使用。
在下一章中,我们将向我们的应用添加更多页面,并学习如何实现这些页面的客户端路由。
问题
尝试回答以下问题以测试您对本章的知识:
-
在 CreateReact 应用中,CSS 模块的文件名约定是什么?
-
下面代码块中的 submit button 组件的背景颜色是从
Color.ts文件中的常量设置的。但是,在页面上呈现组件时,没有设置按钮背景色。有什么问题?import React from 'react'; /** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import { primaryAccent1 } from './Colors'; interface Props { children: React.ReactNode } export const SubmitButton = ({ children }: Props) => { return ( <button css={css` background-color: primaryAccent1; `} > {children} </button> ); }; -
在上一个问题的
SubmitButton部分中,当提纲有焦点时,我们如何删除提纲? -
在我们的问答应用中,我们创建了一个只包含样式的
PageTitle组件。我们如何将其更改为可重用的样式化组件? -
我们在一些 JSX 中有以下输入元素。我们如何设置占位符文本的样式,使其具有
#dcdada颜色?<input type="text" placeholder="Enter your name" />
答案
-
文件命名约定为
ComponentName.module.css。例如,Footer.module.css将是Footer组件的 CSS 模块。 -
问题是背景颜色设置为
'primaryAccent'字符串,而不是常量。css道具应设置如下:<button css={css` background-color: ${primaryAccent1}; `} > {children} </button> -
我们可以使用如下的
focus伪类:<button css={css` background-color: ${primaryAccent1}; :focus { outline: none; } `} > {children} </button> -
我们可以使用以下样式化组件:
export const PageTitle = styled.h2` font-size: 15px; font-weight: bold; margin: 10px 0px 5px; text-align: center; text-transform: uppercase; `; -
我们可以按如下方式设置占位符伪元素的样式:
<input type="text" placeholder="Enter your name" css={css` ::placeholder { color: #dcdada; } `} />
进一步阅读
以下是一些有用的链接,您可以了解有关本章所涵盖主题的更多信息:
- CSS 模块:https://github.com/css-modules/css-modules
- 创建 React App中的 CSS 模块 https://create-react-app.dev/docs/adding-a-css-modules-stylesheet
- 情绪:https://emotion.sh/docs/introduction
五、将 React 路由用于路由
到目前为止,我们的 Q&A 应用只包含一个页面,所以是时候向该应用添加更多页面了。在第 1 章理解 ASP.NET 5 React 模板中,我们了解到单页应用(SPA中的页面是在浏览器中构建的,不需要向服务器发送任何 HTML 请求。
React Router 是一个很棒的库,它可以帮助我们实现客户端页面以及它们之间的导航。因此,我们将在本章中把它引入我们的项目中。
在本章中,我们将声明性地定义应用中可用的路由。我们学习如何在用户导航到不存在的路径时向他们提供反馈。我们将实现一个页面,显示问题的详细信息及其答案。这是我们将学习如何实现路由参数的地方。我们将从实现问题搜索功能开始,在这里我们将学习如何处理查询参数。我们还将开始实现用于提问的页面,并对其进行优化,使其 JavaScript 按需加载,而不是在应用加载时加载。
本章将介绍以下主题:
- 安装 React 路由
- 申报路线
- 未找到处理路线
- 实施链接
- 使用路线参数
- 使用查询参数
- 延迟加载路径
技术要求
在本章中,我们将使用以下工具:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。
- Q&A:我们将从本章的 Q&A 前端启动器项目开始。这是我们在第四章中完成的项目造型用 EmotionReact 组件。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-05/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中恢复代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:http://bit.ly/34XoKyz
安装 React 路由
在本节中,我们将通过执行以下步骤来安装具有相应类型脚本的 React Router:
-
Make sure the frontend project is open in Visual Studio Code and enter the following command to install React Router in the terminal:
> npm install react-router-dom重要提示
确保已安装
react-router-dom版本 6+,并在package.json中列出。如果已安装版本 5,则可通过运行npm install react-router-dom@next安装版本 6。 -
React router has a peer dependency on the
historypackage, so let's install this using the terminal as well:> npm install history对等依赖项是 npm 不会自动安装的依赖项。这就是为什么我们在项目中安装了它。
就这样简单又好!我们将在下一节开始在应用中声明路由。
申报航线
我们使用BrowserRouter、Routes和Route组件在应用中声明页面。BrowserRouter是执行页面间导航的顶级组件。页面路径在嵌套在Routes组件中的Route组件中定义。Routes组件决定应为当前浏览器位置呈现哪个Route组件。
我们将从创建空白页开始这一部分,我们最终将在本书中实现这些空白页。然后,我们将使用BrowserRouter、Routes和Route组件在我们的应用中声明这些页面。
创建一些空白页
让我们创建空白页面,用于登录、提问、查看搜索结果以及查看问题及其答案,具体步骤如下:
-
Create a file called
SignInPage.tsxwith the following content:import React from 'react'; import { Page } from './Page'; export const SignInPage = () => ( <Page title="Sign In">{null}</Page> );在这里,我们使用上一章中创建的
Page组件创建了一个标题为登录的空页面。我们将对需要创建的其他页面使用类似的方法。请注意,我们目前正在
Page组件的内容中呈现null。这是一种告诉 React to render nothing 的方法。 -
创建一个名为
AskPage.tsx的文件,内容如下:import React from 'react'; import { Page } from './Page'; export const AskPage = () => ( <Page title="Ask a question">{null}</Page> ); -
创建一个名为
SearchPage.tsx的文件,其内容如下:import React from 'react'; import { Page } from './Page'; export const SearchPage = () => ( <Page title="Search Results">{null}</Page> ); -
创建一个名为
QuestionPage.tsx的文件,其内容如下:import React from 'react'; import { Page } from './Page'; export const QuestionPage = () => ( <Page>Question Page</Page> );
问题页面上的标题将采用不同的样式,这就是为什么我们不在Page组件上使用title道具的原因。我们只是暂时在页面上添加了一些文本,以便能够将此页面与其他页面区分开来。
这就是我们创建的页面。现在,是定义这些页面的所有路由的时候了。
创建包含路由的组件
我们将通过执行以下步骤来定义到我们创建的页面的所有路由:
-
打开
App.tsx并在现有import语句下添加以下import语句:import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AskPage } from './AskPage'; import { SearchPage } from './SearchPage'; import { SignInPage } from './SignInPage'; -
在
App组件的 JSX 中,添加BrowserRouter作为最外层元素:<BrowserRouter> <div css={ ... } > <Header /> <HomePage /> </div> </BrowserRouter> -
Let's define the routes in our app under the
Headercomponent, replacing the previous reference toHomePage:<BrowserRouter> <div css={ ... } > <Header /> <Routes> <Route path="" element={<HomePage/>} /> <Route path="search" element={<SearchPage/>} /> <Route path="ask" element={<AskPage/>} /> <Route path="signin" element={<SignInPage/>} /> </Routes> </div> </BrowserRouter>每个路由在
Route组件中定义,该组件定义了element道具中针对path道具中给定路径应呈现的内容。将渲染路径与浏览器位置最匹配的管线。例如,如果浏览器位置为http://localhost:3000/search ,则第二个
Route组件(将path设置为"search"的组件)将是最佳匹配。这意味着SearchPage组件被渲染。请注意,路径上不需要前面的斜杠(
/,因为 React Router 默认执行相对匹配。 -
通过在 Visual Studio 代码终端中输入
npm start命令来运行应用。我们将看到主页呈现与以前一样,这非常好。 -
现在,在浏览器位置路径的末尾输入
/search:

图 5.1–搜索页面
在这里,我们可以看到 React Router 已经确定最佳匹配是路径为"search"的Route组件,因此呈现SearchPage组件。
也可以随意访问其他页面–它们现在将呈现良好效果。
这就是我们的基本路由配置。如果用户在浏览器中输入的路径在我们的应用中不存在,会发生什么情况?我们将在下一节中找到答案。
未找到处理路线
在本节中,我们将处理任何Route组件都无法处理的路径。通过以下步骤,我们将首先了解如果在浏览器中放置未处理的路径会发生什么:
-
Enter a path that isn't handled in the browser and see what happens:
![Figure 5.2 – Unhandled path]()
图 5.2–未处理的路径
因此,当我们浏览到一个不由
Route组件处理的路径时,标题下不会呈现任何内容。如果我们仔细想想,这是有道理的。 -
We'd like to improve the user experience of routes not found and inform the user that this is the case. Let's add the following highlighted route inside the
Routescomponent:<Routes> <Route path="" element={<HomePage/>} /> <Route path="search" element={<SearchPage/>} /> <Route path="ask" element={<AskPage/>} /> <Route path="signin" element={<SignInPage/>} /> <Route path="*" element={<NotFoundPage/>} /> </Routes>为了理解这是如何工作的,让我们再考虑一下
Routes组件的作用–它呈现出与浏览器位置最匹配的Route组件。路径*将匹配任何浏览器位置,但不是非常具体。因此,*将不是/、/search、/ask或/signin浏览器位置的最佳匹配,但将捕获无效路由。 -
NotFoundPage尚未实现,我们创建一个名为NotFoundPage.tsx的文件,内容如下:import React from 'react'; import { Page } from './Page'; export const NotFoundPage = () => ( <Page title="Page Not Found">{null}</Page> ); -
回到
App.tsx,让我们导入NotFoundPage组件:import { NotFoundPage } from './NotFoundPage'; -
现在,如果我们在浏览器中输入一个
/invalid路径,我们将看到我们的NotFoundPage组件已经呈现:

图 5.3–未处理的路径
因此,一旦我们了解了Routes组件的工作原理,实现一个未找到的页面就非常容易了。我们只需在Routes组件内部使用路径为*的Route组件。
目前,我们正在通过手动更改浏览器中的位置来导航到应用中的不同页面。在下一节中,我们将学习如何实现链接以在应用本身中执行导航。
实施环节
在本节中,我们将使用 React Router 中的组件Link在点击应用标题中的应用名称时声明性地执行导航。然后,单击提问按钮进入提问页面时,我们将继续以编程方式执行导航。
使用链路组件
此时,当我们点击应用左上角的Q 和时,它正在执行一个 HTTP 请求,返回整个 React 应用,然后呈现主页。我们将通过使用 React 路由的Link组件来改变这一点,以便在浏览器中进行导航,而无需 HTTP 请求。我们还将使用Link组件作为登录页面的链接。我们将通过执行以下步骤了解如何实现这一点:
-
在
Header.tsx中,从 React 路由导入Link组件。在现有的import语句下放置以下行:import { Link } from 'react-router-dom'; -
让我们将
Q & A文本周围的锚定标记更改为Link元素。href属性也需要更改为to属性:<Link to="/" css={ ... } > Q & A </Link> -
我们还将登录链接更改为以下内容:
<Link to="signin" css={ ... } > <UserIcon /> <span>Sign In</span> </Link> -
如果我们转到 running 应用并单击登录链接,我们将看到登录页面。现在,点击应用标题中的Q&A。我们将被带回主页,就像我们想要的那样。
-
再次执行步骤 4,但这次打开浏览器开发工具,查看网络选项卡。我们会发现,当点击登录和Q&A链接时,没有网络请求。
因此,Link组件是在 JSX 中声明性地提供客户端导航选项的一种好方法。我们在上一步中执行的任务确认,所有导航都在浏览器中进行,没有任何服务器请求,这对性能非常有利。
编程导航
有时,有必要以编程方式进行导航。单击提问按钮时,按照以下步骤以编程方式导航至提问页面:
-
Import
useNavigatefrom React Router intoHomePage.tsx:import { useNavigate } from 'react-router-dom';这是一个钩子,它返回一个我们可以用来执行导航的函数。
-
将
useNavigate钩子分配给handleAskQuestionClick事件处理程序const navigate = useNavigate(); const handleAskQuestionClick = () => { ... };前面名为
navigate的函数 -
在
handleAskQuestionClick中,我们可以将console.log语句替换为导航:const handleAskQuestionClick = () => { navigate('ask'); }; -
在 running 应用中,如果我们尝试一下并单击提问按钮,它将成功导航到提问页面。
因此,我们可以使用Link组件以声明方式导航,并使用 React Router 中的useNavigate钩子以编程方式导航。我们将在下一节继续使用Link组件。
使用路线参数
在本节中,我们将定义一个Route组件,用于导航到问题页面。这将在路径的末尾包含一个名为questionId的变量,因此我们需要使用一个名为的路由参数。在本节中,我们还将实现更多的问题页面内容。
添加问题页面路径
让我们执行以下步骤来添加问题页面路径:
-
在
App.tsx中,导入我们在本章前面创建的QuestionPage组件:import { QuestionPage } from './QuestionPage'; -
In the
Appcomponent's JSX, add aRoutecomponent for navigation to the question page inside theRoutescomponent just above the wildcard route:<Routes> … <Route path="questions/:questionId" element={<QuestionPage />} /> <Route path="*" element={<NotFoundPage/>} /> </Routes>请注意,我们输入的路径末尾包含
:questionId。重要提示
路由参数在路径中定义,前面有一个冒号。然后,该参数的值可用于在
useParams钩子中解构。Route组件可以放置在Routes组件内的任何位置。可以说,将通配符路由保留在底部更具可读性,因为这是最不特定的路径,因此将是最后一个要匹配的路径。 -
我们转到
QuestionPage.tsx并从 React 路由import { useParams } from 'react-router-dom';导入
useParams -
We can destructure the value of the
questionIdroute parameter from theuseParamshook:export const QuestionPage = () => { const { questionId } = useParams(); return <Page>Question Page</Page>; };我们还将
QuestionPage更改为具有显式返回语句。 -
For now, we are going to output
questionIdon the page as follows in the JSX:<Page>Question Page {questionId}</Page>;我们将返回并全面实施第 6 章中的问题页面,使用表单。现在,我们将从
Question组件链接到此页面。 -
因此,在
Question.tsx中,添加以下import语句来导入Link组件:import { Link } from 'react-router-dom'; -
现在,我们可以在
QuestionJSX 中的标题文本周围包装一个Link组件,同时指定导航到的路径:<div css={css` padding: 10px 0px; font-size: 19px; `} > <Link css={css` text-decoration: none; color: ${gray2}; `} to={`/questions/${data.questionId}`} > {data.title} </Link> </div> -
转到 running 应用并尝试点击我应该使用哪个状态管理工具?问题。它将成功导航到问题页面,显示正确的
questionId:

图 5.4–带路线参数的问题页面
因此,我们通过在路由路径中使用冒号定义变量,然后使用useParams钩子拾取值来实现路由参数。
实现更多问题页面
让我们再执行一些步骤来进一步实现问题页面:
-
In
QuestionsData.ts, add a function that will simulate a web request to get a question:export const getQuestion = async ( questionId: number ): Promise<QuestionData | null> => { await wait(500); const results = questions.filter(q => q.questionId === questionId); return results.length === 0 ? null : results[0]; };我们已经使用数组
filter方法为传入的questionId获取问题。请注意函数返回类型的类型注释。传入
Promise泛型类型的类型为Question | null,称为联合类型。重要提示
联合类型是一种机制,用于定义包含多个类型值的类型。如果我们将一个类型看作一组值,那么多个类型的并集与值集的并集是相同的。更多信息请访问https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-类型。
因此,函数应该异步返回一个
QuestionData或null类型的对象。 -
转到
QuestionPage.tsx,我们导入刚才创建的函数,以及问题界面:import { QuestionData, getQuestion } from './QuestionsData'; -
添加 Emotion
import语句,并从我们的标准颜色中导入一些灰色。在QuestionPage.tsx顶部添加以下行:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import { gray3, gray6 } from './Styles'; -
In the
QuestionPagecomponent, create a state for the question:export const QuestionPage = () => { const [ question, setQuestion, ] = React.useState<QuestionData | null>(null); const { questionId } = useParams(); return <Page>Question Page {questionId}</Page>; };我们将把问题存储在组件最初渲染时的状态中。
请注意,我们正在为状态使用联合类型,因为在获取问题时,状态最初是
null,如果找不到问题,状态也是null。 -
We want to call the
getQuestionfunction during the initial render, so let's call it inside a call to theuseEffecthook:export const QuestionPage = () => { … const { questionId } = useParams(); React.useEffect(() => { const doGetQuestion = async ( questionId: number, ) => { const foundQuestion = await getQuestion( questionId, ); setQuestion(foundQuestion); }; if (questionId) { doGetQuestion(Number(questionId)); } }, [questionId]); return ... };因此,当第一次呈现时,问题组件将获取问题并将其设置为导致组件第二次呈现的状态。请注意,我们使用
Number构造函数将questionId从string转换为number。另外,请注意,
useEffect函数中的第二个参数在数组中具有questionId。这是因为useEffect运行的函数(第一个参数)依赖于questionId值,如果该值发生变化,则应重新运行。如果没有提供[questionId],它将进入无限循环,因为每次调用setQuestion,它都会导致重新渲染,如果没有[questionId],它将始终重新运行该方法。 -
Let's start to implement the JSX for the
QuestionPagecomponent by adding a container element for the page and the question title:<Page> <div css={css` background-color: white; padding: 15px 20px 20px 20px; border-radius: 4px; border: 1px solid ${gray6}; box-shadow: 0 3px 5px 0 rgba(0, 0, 0, 0.16); `} > <div css={css` font-size: 19px; font-weight: bold; margin: 10px 0px 5px; `} > {question === null ? '' : question.title} </div> </div> </Page>在
question状态设置完成之前,我们不会呈现标题。获取问题时,question状态为空,如果找不到问题,则状态为空。请注意,我们使用三重等于(===来检查question变量是否为null而不是双等于(==。重要提示
当使用三重相等(
===时,我们检查严格相等。这意味着我们要比较的类型和值必须相同。使用双等于(==时,不检查类型。通常,使用三重相等(===来执行严格的相等检查是一种良好的做法。如果我们查看 running 应用,我们将看到问题标题已呈现在一张漂亮的白卡中:
![Figure 5.5 – Question page title]()
图 5.5–问题页面标题
-
Let's now implement the question content:
<Page> <div ... > <div ... > {question === null ? '' : question.title} </div> {question !== null && ( <React.Fragment> <p css={css` margin-top: 0px; background-color: white; `} > {question.content} </p> </React.Fragment> )} </div> </Page>因此,如果从获取的数据中设置了
question状态,我们将在 JSX 中显示问题的输出内容。请注意,这是嵌套在Fragment组件中的。这是做什么用的?重要提示
在 React 中,组件只能返回单个元素。此规则适用于条件呈现逻辑,其中只能呈现单个父元素。React
Fragment允许我们绕过此规则,因为我们可以在其中嵌套多个元素,而无需创建 DOM 节点。如果我们尝试在短路操作符之后返回两个元素,我们可以看到
Fragment解决的问题:![Figure 5.6 – Reason for react fragment]()
图 5.6–React 碎片的原因
-
让我们在
Fragment:{question !== null && ( <React.Fragment> <p ... > {question.content} </p> <div css={css` font-size: 12px; font-style: italic; color: ${gray3}; `} > {`Asked by ${question.userName} on ${question.created.toLocaleDateString()} ${question.created.toLocaleTimeString()}`} </div> </React.Fragment> )}中添加提问时间和提问者
现在,问题的所有细节将在问题页面上的跑步应用中呈现在一张漂亮的白卡中:

图 5.7–问题页面
因此,问题页面现在看起来不错。我们还没有给出任何答案,所以让我们接下来看看。
创建应答器列表组件
按照以下步骤创建一个将呈现答案列表的组件:
-
Create a new file called
AnswerList.tsxwith the followingimportstatements:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import React from 'react'; import { AnswerData } from './QuestionsData'; import { Answer } from './Answer'; import { gray5 } from './Styles';因此,我们将使用无序列表来呈现没有要点的答案。我们已经引用了一个组件
Answer,稍后我们将在这些步骤中创建它。 -
让我们定义接口,使其包含用于答案数组的
data道具:interface Props { data: AnswerData[]; } -
Let's create the
AnswerListcomponent, which outputs the answers:export const AnswerList = ({ data }: Props) => ( <ul css={css` list-style: none; margin: 10px 0 0 0; padding: 0; `} > {data.map(answer => ( <li css={css` border-top: 1px solid ${gray5}; `} key={answer.answerId} > <Answer data={answer} /> </li> ))} </ul> );每个答案都输出到
Answer组件中的无序列表中,我们将在下一步实现该组件。 -
让我们继续往下看,通过创建一个名为
Answer.tsx的文件并使用以下import语句来实现组件:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react'; import React from 'react'; import { AnswerData } from './QuestionsData'; import { gray3 } from './Styles'; -
Answer组件的接口将只包含应答数据:interface Props { data: AnswerData; } -
现在,
Answer组件将简单地呈现答案内容,以及回答者和回答时间:export const Answer = ({ data }: Props) => ( <div css={css` padding: 10px 0px; `} > <div css={css` padding: 10px 0px; font-size: 13px; `} > {data.content} </div> <div css={css` font-size: 12px; font-style: italic; color: ${gray3}; `} > {`Answered by ${data.userName} on ${data.created.toLocaleDateString()} ${data.created.toLocaleTimeString()}`} </div> </div> ); -
现在我们回到
QuestionPage.tsx并导入AnswerList:import { AnswerList } from './AnswerList'; -
Now, we can add
AnswerListto theFragmentelement:{question !== null && ( <React.Fragment> <p ... > {question.content} </p> <div ... > {`Asked by ${question.userName} on ${question.created.toLocaleDateString()} ${question.created.toLocaleTimeString()}`} </div> <AnswerList data={question.answers} /> </React.Fragment> )}如果我们查看
questions/1问题页面上的 running 应用,我们会看到答案呈现得很好:

图 5.8–带答案的问题页面
这就完成了我们在本章问题页面上需要做的工作。但是,我们需要允许用户提交问题的答案,我们将在第 6 章、处理表单中介绍。
接下来,我们将了解如何使用 React Router 处理查询参数。
使用查询参数
查询参数是允许将其他参数传递到路径的 URL 的一部分。例如,/search?criteria=typescript有一个名为criteria的查询参数,其值为typescript。查询参数有时称为搜索参数。
在本节中,我们将在搜索页面上实现一个名为criteria的查询参数,该参数将驱动搜索。我们将在此过程中实现搜索页面。让我们执行以下步骤来执行此操作:
-
We are going to start in
QuestionsData.tsby creating a function to simulate a search via a web request:export const searchQuestions = async ( criteria: string, ): Promise<QuestionData[]> => { await wait(500); return questions.filter( q => q.title.toLowerCase() .indexOf(criteria.toLowerCase()) >= 0 || q.content.toLowerCase() .indexOf(criteria.toLowerCase()) >= 0, ); };因此,函数使用数组
filter方法,并将标准与问题标题或内容的任何部分相匹配。 -
Let's import this function, along with the other items we need, into
SearchPage.tsx. Place these statements above the existingimportstatements inSearchPage.tsx:/** @jsxImportSource @emotion/react */ import { css } from '@emotion/react' import { useSearchParams } from 'react-router-dom'; import { QuestionList } from './QuestionList'; import { searchQuestions, QuestionData } from './QuestionsData';React 路由的
useSearchParams钩子用于访问查询参数。 -
Add an explicit
returnstatement to theSearchPagecomponent and destructure the object fromuseSearchParamsthat contains the search parameters:export const SearchPage = () => { const [searchParams] = useSearchParams(); return ( <Page title="Search Results">{null}</Page> ); };useSearchParams钩子返回一个包含两个元素的数组。第一个元素是包含搜索参数的对象,第二个元素是更新查询参数的函数。我们只分解了代码中的第一个元素,因为我们不需要更新这个组件中的查询参数。 -
我们现在将创建一些状态来保存搜索中匹配的问题:
export const SearchPage = () => { const [searchParams] = useSearchParams(); const [ questions, setQuestions, ] = React.useState<QuestionData[]>([]); return … }; -
Next, we are going to get the
criteriaquery parameter value:export const SearchPage = () => { const [searchParams] = useSearchParams(); const [ questions, setQuestions, ] = React.useState<QuestionData[]>([]); const search = searchParams.get('criteria') || ""; return … };searchParams对象包含一个get方法,可用于获取查询参数的值。 -
我们将在组件第一次呈现时以及当
search变量使用useEffect钩子const search = searchParams.get('criteria') || ''; React.useEffect(() => { const doSearch = async (criteria: string) => { const foundResults = await searchQuestions( criteria, ); setQuestions(foundResults); }; doSearch(search); }, [search]);更改时调用搜索
-
我们现在可以在页面标题下呈现搜索条件。将
{null}替换为突出显示的代码:<Page title="Search Results"> {search && ( <p css={css` font-size: 16px; font-style: italic; margin-top: 0px; `} > for "{search}" </p> )} </Page> -
The last task is to use the
QuestionListcomponent to render the questions that are returned from the search:<Page title="Search Results"> {search && ( <p ... > for "{search}" </p> )} <QuestionList data={questions} /> </Page>我们的
QuestionList组件现在用于具有不同数据源的主页和搜索页面。这个组件的可重用性已经成为可能,因为我们遵循了第 3 章中简要提到的容器模式,即【React 和 TypeScript 入门】。 -
在 running 应用中,在浏览器中输入
/search?criteria=type。搜索将被调用,结果将按预期呈现:

图 5.9–搜索结果
因此,React 路由中的useSearchParams钩子使得与查询参数的交互变得轻松愉快。
在第 6 章处理表单中,我们将把标题中的搜索框连接到我们的搜索表单。
在下一节中,我们将学习如何按需加载组件。
延迟装载路线
目前,我们应用的所有 JavaScript 都是在应用首次加载时加载的。这对于小型应用来说很好,但对于大型应用来说,这可能会对性能产生负面影响。可能有一些在应用中很少使用的大页面,我们希望加载 JavaScript 以便按需使用。这个过程被称为延迟加载。
我们将在本节中延迟加载 ask 页面。延迟加载的用处不大,因为这可能是我们应用中的一个流行页面,但它将帮助我们了解如何实现这一点。让我们执行以下步骤:
-
首先,我们将向
AskPage.tsx:export const AskPage = () => <Page title="Ask a question" />; export default AskPage;中的
AskPage组件添加一个默认导出 -
打开
App.tsx并删除AskPage组件的当前import语句。 -
为 React:
import React from 'react';添加一条
import语句 -
Add a new
importstatement for theAskPagecomponent after all the otherimportstatements:const AskPage = React.lazy(() => import('./AskPage'));这是文件中最后一条
import语句,这一点很重要,因为否则,ESLint 可能会抱怨它下面的import语句位于模块主体中。React 中的
lazy函数允许我们将动态导入作为常规组件呈现。动态导入为请求的模块返回一个承诺,该承诺在获取、实例化和评估模块后解析。 -
So, the
AskPagecomponent is being loaded on demand now, but theAppcomponent is expecting this component to be loaded immediately. If we enter theaskpath in the browser's address bar and press the Enter key, we may receive an error with a clue of how to resolve this:![Figure 5.10 – No Suspense component warning]()
图 5.10–无悬念组件警告
-
As suggested by the error message, we are going to use the
Suspensecomponent from React to resolve this issue. ForaskRoute, we wrap theSuspensecomponent around theAskPagecomponent:<Route path="ask" element={ <React.Suspense fallback={ <div css={css` margin-top: 100px; text-align: center; `} > Loading... </div> } > <AskPage /> </React.Suspense> } />Suspense``fallback道具允许我们在AskPage加载时渲染组件。所以,我们正在呈现加载。。。加载AskPage组件时的消息。 -
让我们转到主页上的 running 应用,按F12打开浏览器开发者工具。
-
On the Network tab, let's clear the previous network activity by clicking the no entry icon. Then, if we click the Ask a question button, we will see confirmation that additional JavaScript has been downloaded in order to render the
AskPagecomponent:![Figure 5.11 – AskPage component loaded on demand]()
图 5.11–按需加载的 AskPage 组件
-
The
AskPagecomponent loads so fast that we are unlikely to see theLoadingcomponent being rendered. In the Chrome browser developer tools, there is an option to simulate a Slow 3G network in the Network tab:![Figure 5.12 – Slow 3G option]()
图 5.12–慢速 3G 选项
-
如果我们打开此功能,按主页上的F5再次加载应用,然后单击提问按钮,我们将看到加载。。。暂时呈现的消息:

图 5.13–悬念回退
在本例中,AskPage组件的大小很小,因此这种方法实际上不会对性能产生积极影响。但是,按需加载较大的组件确实可以提高性能,特别是在连接速度较慢的情况下。
总结
React Router 为我们提供了一套全面的组件,用于管理应用中页面之间的导航。我们了解到,顶级组件是BrowserRouter,它在其下方的Routes组件中查找Route组件,我们在其中定义应为某些路径呈现哪些组件。Route组件中与当前浏览器位置最匹配的path是渲染的组件。
useParams钩子让我们访问路由参数,useSearchParams钩子让我们访问查询参数。这些挂钩可用于组件树中BrowserRouter下的任何 React 组件。
我们了解到,Reactlazy功能及其Suspense组件可用于用户很少使用的大型组件,以便按需加载它们。这有助于提高应用启动时间的性能。
在下一章中,我们将继续构建问答应用的前端,这一次将重点放在实现表单上。
问题
以下问题将巩固您在本章中刚刚学到的知识:
-
We have the following routes defined:
<BrowserRouter> <Routes> <Route path="search" element={<SearchPage/>} /> <Route path="" element={<HomePage/>} /> </Routes> </BrowserRouter>回答以下问题:
- 在浏览器中输入
/位置时,将呈现哪些组件? - 当在浏览器中输入
/search位置时,情况如何?
- 在浏览器中输入
-
在我们的问答应用中,我们需要一个
/login路径来导航到登录页面,以及/signin路径。我们如何实施这一点? -
We have the following routes defined:
<BrowserRouter> <Routes> <Route path="search" element={<SearchPage/>} /> <Route path="" element ={<HomePage/>} /> <Route path="*" element={<NotFoundPage/>} /> </Routes> </BrowserRouter>在浏览器中输入
/signin位置时,将呈现哪个组件? -
We have the following route defined:
<Route path="users/:userId" component={UserPage} />如何在组件中引用
userId路由参数? -
如何从
/users?id=1等路径获取id查询参数的值? -
We have an option that navigates to another page when clicked. The JSX for this option is as follows:
<a href="/products">Products</a>此时,导航会发出服务器请求。我们如何改变这一点,使导航只在浏览器中进行?
-
我们需要在流程完成时以编程方式导航到
/success路径。我们怎样才能做到这一点?
答案
-
当浏览器位置为
/时呈现HomePage组件,当浏览器位置为/search时呈现SearchPage组件。 -
为了使
/login的路径能够呈现登录页面,我们可以定义一个额外的Route组件,如下所示:<Route path="signin" element={<SignInPage />} /> <Route path="login" element={<SignInPage />} /> -
将呈现
NotFoundPage组件。 -
我们可以使用
useParams钩子参照userId路由参数如下:const { userId } = useParams(); -
我们可以使用
useSearchParams钩子引用id查询参数,如下所示:const [searchParams] = useSearchParams(); const id = searchParams.get('id'); -
可以使用
Link组件,以便只在客户端<Link to="products">Products</Link>上进行导航
-
In order to programmatically navigate, we first need to get a function from the
useNavigatehook that can perform the navigation:const navigate = useNavigate();然后,我们可以在代码中的适当位置使用此功能导航到
/success路径:navigate('success');
进一步阅读
以下是一些有用的链接,可用于了解有关本章所涵盖主题的更多信息:
- React 路由:https://reacttraining.com/react-router
- JavaScript 数组过滤器:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
- 类型脚本联合类型:https://www.typescriptlang.org/docs/handbook/advanced-types.html
- React 片段:https://reactjs.org/docs/fragments.html
- React 迟缓:https://reactjs.org/docs/code-splitting.html#reactlazy
六、使用表单
表单是一个重要的主题,因为它们在我们构建的应用中非常常见。在本章中,我们将学习如何使用 React 控制的组件构建表单,并发现其中涉及大量样板代码。我们将使用一个流行的库来减少样板代码。这也将帮助我们在应用中构建多个表单。
客户端验证对于我们构建的表单的用户体验至关重要,因此我们将深入讨论这个主题。我们还将介绍如何提交表格。
本章将介绍以下主题:
- 了解受控组件
- 用 React 钩子形式减少样板代码
- 实施验证
- 提交表格
在本章结束时,您将学习如何有效地创建具有关键组件的表单。
技术要求
在本章中,我们将使用以下工具:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js和npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少是版本 8.2,
npm至少是版本 5.2。 - Q 和 A:我们将从第 5 章与 React 路由的路由中完成的 Q 和前端项目开始。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-06/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可以在终端中输入npm install来恢复所需的依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/3pherQp 。
了解受控部件
在本节中,我们将学习如何使用所谓的受控组件来实现表单。受控组件的值与 React 中的状态同步。当我们实现第一个受控组件时,这将更有意义。
让我们在 Visual Studio 代码中打开我们的项目,并将应用标题中的搜索框更改为受控组件。遵循以下步骤:
-
打开
Header.tsx并添加以下导入:import { Link, useSearchParams, } from 'react-router-dom'; -
搜索框的默认值是
criteria路由查询参数。那么,让我们使用 React Router 的useSearchParams钩子来获得这个:export const Header = () => { const [searchParams] = useSearchParams(); const criteria = searchParams.get('criteria') || ''; const handleSearchInputChange = ... } -
让我们创建一个可以存储搜索值的状态,将其默认为刚才设置的
criteria变量:const searchParams = new URLSearchParams(location.search); const criteria = searchParams.get('criteria') || ''; const [search, setSearch] = React.useState(criteria); -
现在,让我们从这个
search状态驱动搜索框值:<input type="text" placeholder="Search..." value={search} onChange={handleSearchChange} css={ ... } /> -
通过在 Visual Studio 代码的终端中运行
npm start命令来启动应用。 -
Try to type something into the search box in the app header.
你会注意到似乎什么都没有发生;有些东西阻止我们输入值。我们刚刚将该值设置为某个 React 状态,因此 React 现在控制搜索框的值。这就是为什么我们似乎不再能够输入它。
我们正在创建第一个受控输入。然而,如果用户不能在受控输入中输入任何内容,那么受控输入就没有多大用处。那么,我们如何使我们的
input再次可编辑?答案是我们需要监听对input值所做的更改,并相应地更新状态。React 然后将呈现状态中的新值。 -
We are already listening to changes with the
handleSearchInputChangefunction. So, all we need to do is update the state in the following function, replacing the previousconsole.logstatement with the following:const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => { setSearch(e.currentTarget.value); };现在,如果我们转到 running 应用并在搜索框中输入一些内容,这一次,它将按预期运行,允许我们在其中输入字符。
-
Now, add a
formelement that's wrapped around theinputelement:<form> <input type="text" placeholder="Search..." onChange={handleSearchInputChange} value={search} css={ ... } /> </form>最终,这将允许用户在按下Enter键时调用搜索。
-
将以下
onSubmit道具添加到表单元素:<form onSubmit={handleSubmit}> -
在
return语句
```cs
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(search);
};
return …
```
上方添加提交处理程序的实现
- In the running app, type something into the search box in the app header and press Enter. Open the browser's DevTools and look at the console:

图 6.1–受控部件
现在,如果我们在搜索框中输入字符并按*回车*,我们将在控制台中看到提交的搜索条件。
- 在终端中按Ctrl+C键,提示停止 app 运行时按Y。
总之,受控组件的值由 React 的状态管理。实现一个更新状态的变更处理程序是很重要的;否则,我们的用户将无法与组件交互。
如果我们要实现一个包含多个受控组件的表单,我们必须创建状态和一个更改事件侦听器来更新每个字段的状态。在实现表单时,需要编写大量的样板代码。是否有一个表单库可以用来减少我们必须编写的重复代码的数量?对我们将在下一节中进行介绍。
用 React 钩形式减少样板代码
在部分中,我们将使用一个名为React Hook Form的流行表单库。这个库减少了我们在实现表单时需要编写的代码量。安装 React 钩子表单后,我们将重构在上一节中创建的搜索表单。然后,我们将使用 React-Hook 表单实现用于提问和回答问题的表单。
安装 React 钩模板
让我们通过在终端中输入以下命令来安装 React Hook Form:
> npm install react-hook-form
几秒钟后,将安装 React 钩形。
react-hook-form包包含 TypeScript 类型,因此这些类型不在我们需要安装的单独包中。
接下来,我们将开始在Header组件中使用 React Hook 表单作为搜索表单。
重构 Header 组件以使用 React Hook 表单
我们将使用使用 React Hook 表单来减少Header组件中的代码量。打开Header.tsx并按照以下步骤操作:
-
Remove the line where the
searchstate is declared. The line you must remove is shown here:const [search, setSearch] = React.useState(criteria);React 钩子窗体将管理字段状态,因此我们不必为此编写显式代码。
-
Add the following name property to the
inputelement:<input name="search" type="text" … />name属性是 React Hook 表单所必需的,并且对于给定表单必须具有唯一的值。我们最终将能够使用此名称从input元素访问此值。 -
Remove the
valueandonChangeproperties from theinputelement and add adefaultValueproperty, which is set to thecriteriaquery parameter value:<input name="search" type="text" placeholder="Search..." defaultValue={criteria} css={ ... } />React 表单钩子最终将管理输入的值。
注意,
defaultValue是input元素用于设置其初始值的属性。 -
删除输入的更改事件的
handleSearchInputChange函数处理程序。 -
从
handleSubmit函数中删除console.log语句:const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); }; -
现在我们已经删除了样板表单代码,现在是使用 React 钩子表单的时候了。让我们从 React hook 表单
import { useForm } from 'react-hook-form';导入
useForm钩子开始 -
在
Header组件定义上方和import语句下方添加一个表示表单数据的类型:type FormData = { search: string; }; -
将表单数据类型传递到
useForm钩子中,并从中解构函数:export const Header = () => { const { register } = useForm<FormData>(); const [searchParams] = useSearchParams(); ... -
Add a
refproperty to theinputelement in theHeadercomponent's JSX and set this to theregisterfunction from React Hook Form:<input ref={register} name="search" type="text" placeholder="Search..." defaultValue={criteria} css={ ... } />register函数允许input元素注册到 React Hook 表单,然后由其管理。需要将其设置为元素的ref属性。重要提示
ref属性是一个特殊的属性,它添加到允许访问底层 DOM 节点的元素中。
我们表格的代码现在短了很多。这是因为 React 钩子表单保存字段状态并管理对其的更新。
我们使用useForm钩子中的register函数告诉 React 钩子表单要管理哪些字段。useForm钩子中还有其他有用的函数和对象,我们将在本章中学习和使用。
React Form Hook 现在控制搜索输入字段。我们将回到这里,在提交表单部分实现搜索提交功能。
下一步,我们将把注意力转向形式的设计。
创建表单样式的组件
在本节中,我们将创建一些样式的组件,这些组件可以在我们最终实现的表单中使用。打开Styles.ts并执行以下步骤:
-
从 Emotion 导入
css功能:import { css } from '@emotion/react'; -
Add a styled component for a
fieldsetelement:export const Fieldset = styled.fieldset` margin: 10px auto 0 auto; padding: 30px; width: 350px; background-color: ${gray6}; border-radius: 4px; border: 1px solid ${gray5}; box-shadow: 0 3px 5px 0 rgba(0, 0, 0, 0.16); `;我们最终会在表单中使用
fieldset元素。 -
为字段容器添加样式化组件:
export const FieldContainer = styled.div` margin-bottom: 10px; `; -
为元素
export const FieldLabel = styled.label` font-weight: bold; `;添加一个样式化的组件
-
字段编辑器元素将具有许多常见的 CSS 属性。创建包含以下代码的变量:
const baseFieldCSS = css` box-sizing: border-box; font-family: ${fontFamily}; font-size: ${fontSize}; margin-bottom: 5px; padding: 8px 10px; border: 1px solid ${gray5}; border-radius: 3px; color: ${gray2}; background-color: white; width: 100%; :focus { outline-color: ${gray5}; } :disabled { background-color: ${gray6}; } `; -
Use the following variable in a styled component for an
inputelement:export const FieldInput = styled.input` ${baseFieldCSS} `;这导致输入元素在我们正在创建的新样式组件中包含来自
baseFieldCSS变量的 CSS。 -
现在,为
textarea元素export const FieldTextArea = styled.textarea` ${baseFieldCSS} height: 100px; `;创建一个样式化组件
-
为验证错误消息添加样式化组件:
export const FieldError = styled.div` font-size: 12px; color: red; `; -
Add a styled component for a container for the form submit button:
export const FormButtonContainer = styled.div` margin: 30px 0px 0px 0px; padding: 20px 0px 0px 0px; border-top: 1px solid ${gray5}; `;我们将要创建的最后一个样式化的组件是提交消息:
export const SubmissionSuccess = styled.div` margin-top: 10px; color: green; `; export const SubmissionFailure = styled.div` margin-top: 10px; color: red; `;
有了这些,我们已经实现了我们将在表单中使用的所有样式化组件。
现在我们已经实现了这些样式化组件,我们将使用它们来实现下一个表单。
实施 ask 表格
现在,是时候实施表单了,这样我们的用户就可以提问了。我们将通过利用 React 钩子表单和表单的样式化组件来实现这一点。遵循以下步骤:
-
打开
AskPage.tsx并添加以下import语句:import { Fieldset, FieldContainer, FieldLabel, FieldInput, FieldTextArea, FormButtonContainer, PrimaryButton, } from './Styles'; import { useForm } from 'react-hook-form'; -
添加一个类型来表示
AskPage组件定义正上方和import语句正下方的表单数据:type FormData = { title: string; content: string; }; -
向
AskPage组件添加显式的return语句,将FormData类型传递给useForm钩子,并对寄存器函数进行解构 -
从中:
export const AskPage = () => { const { register } = useForm<FormData>(); return ( <Page title="Ask a question"> {null} </Page> ); } -
增加的
form和Fieldset元素,替换的null输出:<Page title="Ask a question"> <form> <Fieldset> </Fieldset> </form> </Page> -
Now, let's add a field that will capture the question's title:
<Fieldset> <FieldContainer> <FieldLabel htmlFor="title"> Title </FieldLabel> <FieldInput id="title" name="title" type="text" ref={register} /> </FieldContainer> </Fieldset>注意如何使用
htmlFor属性将标签绑定到输入。此表示当输入具有焦点时,屏幕阅读器将读取标签。此外,单击标签将自动设置输入的焦点。 -
添加将捕获问题内容的字段:
<Fieldset> <FieldContainer> ... </FieldContainer> <FieldContainer> <FieldLabel htmlFor="content"> Content </FieldLabel> <FieldTextArea id="content" name="content" ref={register} /> </FieldContainer> </Fieldset> -
最后,添加一个按钮,用于使用
FormButtonContainer和PrimaryButton样式的组件提交问题:<Fieldset> <FieldContainer> ... </FieldContainer> <FormButtonContainer> <PrimaryButton type="submit"> Submit Your Question </PrimaryButton> </FormButtonContainer> </Fieldset> -
通过在 Visual Studio 代码的终端中运行
npm start命令来启动应用。 -
让我们在 running 应用中尝试一下,点击主页上的提问按钮:

图 6.2–提问表格
我们的表单按预期呈现。
React-Hook 表单和样式化表单组件使这项工作变得非常简单。现在
让我们尝试实现另一个表单,它正是答案表单。
实施答疑表
让我们在问题页面上实现答案表单。遵循以下步骤:
-
打开
QuestionPage.tsx并更新以下import语句:import { gray3, gray6, Fieldset, FieldContainer, FieldLabel, FieldTextArea, FormButtonContainer, PrimaryButton, } from './Styles'; -
为 React Hook 表单
import { useForm } from 'react-hook-form';添加导入语句
-
添加表示表单数据的类型:
type FormData = { content: string; }; -
将表单数据类型传递给
useForm钩子,并从中解构register函数:export const QuestionPage = () => { ... const { register } = useForm<FormData>(); return ... } -
Let's create our form in the JSX, just beneath the list of answers:
<AnswerList data={question.answers} /> <form css={css` margin-top: 20px; `} > <Fieldset> <FieldContainer> <FieldLabel htmlFor="content"> Your Answer </FieldLabel> <FieldTextArea id="content" name="content" ref={register} /> </FieldContainer> <FormButtonContainer> <PrimaryButton type="submit"> Submit Your Answer </PrimaryButton> </FormButtonContainer> </Fieldset> </form>因此,表单将包含一个字段作为答案内容,提交按钮将有标题提交您的答案。
-
让我们在 running 应用中尝试一下,单击主页上的一个问题:

图 6.3–答案表
我们的表单按预期呈现。
我们现在已经用 React-Hook 表单构建了三个表单,并亲身体验了它如何简化构建字段。在此过程中,我们还构建了一组方便的样式化表单组件。
我们的表格看起来不错,但还没有验证。例如,我们可以对一个问题提交一个空白答案,但由于目前还没有实现这样的机制,因此无法对其进行验证。在下一节中,我们将通过验证来增强表单。
实施验证
在表单上包含验证可以改善用户体验,因为您可以立即反馈输入的信息是否有效。在本节中,我们将向表单中添加用于提问和回答问题的验证规则。这些验证规则将包括检查,以确保字段已填充且包含一定数量的字符。
在 ask 表单上实施验证
我们将通过以下步骤对 ask 表进行验证:
-
在
AskPage.tsx中,我们将确保用户使用最少的字符数填充标题和内容字段。首先,导入FieldError样式的组件:import { ... FieldError, } from './Styles'; -
分解来自
useForm钩子的表单错误消息:const { register, errors } = useForm<FormData>(); -
Next, configure the form so that it invokes the validation rules when the field elements lose focus (that is, the element's
blurevent):const { register, errors } = useForm<FormData>({ mode: 'onBlur', });请务必注意,提交表单时也会验证字段。
-
Specify the validation rules in the register function, as follows:
<FieldInput id="title" name="title" type="text" ref={register({ required: true, minLength: 10, })} />突出显示的代码表明,
title字段是必需的,必须至少有 10 个字符长。 -
Let's conditionally render the validation error messages for both these rules beneath the
inputelement:<FieldInput ... /> {errors.title && errors.title.type === 'required' && ( <FieldError> You must enter the question title </FieldError> )} {errors.title && errors.title.type === 'minLength' && ( <FieldError> The title must be at least 10 characters </FieldError> )} </FieldContainer>errors是钩形为我们维护的对象。对象中的键对应于FieldInput中的 name 属性。每个错误中的type属性指定错误用于哪个规则。 -
将验证添加到
content字段,使其成为必填项。至少包含 50 个字符:<FieldTextArea id="content" name="content" ref={register({ required: true, minLength: 50, })} /> -
Add a validation error message to the
contentfield:<FieldTextArea ... /> {errors.content && errors.content.type === 'required' && ( <FieldError> You must enter the question content </FieldError> )} {errors.content && errors.content.type === 'minLength' && ( <FieldError> The content must be at least 50 characters </FieldError> )} </FieldContainer>当内容字段未通过验证检查时,
errors对象将包含一个content属性。content属性中的 type 属性表示违反了哪条规则。因此,我们在errors对象中使用此信息来呈现适当的验证消息。 -
Let's give this a try. In the running app, go to the ask page by clicking on the Ask a question button at the bottom of the home screen.
不在表单中输入任何内容,单击进入和退出字段。您将看到,表单呈现了验证错误,这意味着我们实现的机制已经成功运行。不要在标题字段中键入任何内容,然后输入少于 50 个字符的内容:

图 6.4–ask 表格上的验证
在这里,我们可以看到,验证错误在我们从字段中移出时呈现。
对答题表进行验证
让我们对答案表进行验证。我们将验证内容是否已填入至少 50 个字符。要执行此操作,请执行以下步骤:
-
打开
QuestionPage.tsx导入FieldError样式组件:import { ... FieldError, } from './Styles'; -
在
useForm钩子中,对errors对象进行解构,并将表单配置为在其字段失去焦点时进行验证:const { register, errors } = useForm<FormData>({ mode: 'onBlur', }); -
Specify the validation rules on the answer field, as follows:
<FieldTextArea id="content" name="content" ref={register({ required: true, minLength: 50, })} />在这里,我们已经指定答案必须是强制性的,并且长度必须至少为 50 个字符。
-
让我们有条件地在文本区域元素
<FieldTextArea ... /> {errors.content && errors.content.type === 'required' && ( <FieldError> You must enter the answer </FieldError> )} {errors.content && errors.content.type === 'minLength' && ( <FieldError> The answer must be at least 50 characters </FieldError> )} </FieldContainer>下呈现这两个规则的验证错误消息
-
在 running app 中,我们可以通过点击主页上的一个问题并输入
Some answer来检查这是否按预期工作:

图 6.5–答案表上的验证
这样,我们就完成了表单验证的实现。React Hook 表单有一组有用的验证规则,可以将应用于其register函数。React Hook 表单中的errors对象为我们提供了输出信息性验证错误消息所需的所有信息。更多关于 React Hook 表单验证的信息,请访问https://react-hook-form.com/get-started#Applyvalidation 。
我们的最终任务是在用户提交表单时执行提交逻辑。我们将在下一节中进行此操作。
提交表格
提交表格是表格执行的最后一部分。我们将在所有三个表单中实现表单提交逻辑,从搜索表单开始。
提交逻辑是使用表单中的数据执行任务的逻辑。通常,此任务将涉及将数据发布到 web API 以执行服务器端任务,例如将数据保存到数据库表。在本节中,我们的提交逻辑将简单地调用模拟 web API 调用的函数。
在搜索表单中实现表单提交
在Header.tsx中,执行以下步骤在搜索表单上实现表单提交:
-
从 React 路由导入
useNavigate钩子:import { Link, useSearchParams, useNavigate, } from 'react-router-dom'; -
在
Header组件的第一行,为useNavigate钩子的结果分配一个函数:const navigate = useNavigate(); -
Destructure a
handleSubmitfunction from theuseFormhook:const { register, handleSubmit } = useForm<FormData>();这与现有的
handleSubmit冲突,我们将在步骤 5中解决。 -
Use the
handleSubmitfunction to handle thesubmitevent in the form:<form onSubmit={handleSubmit(submitForm)}>React Hook 表单的
handleSubmit函数包含样板代码,例如停止浏览器将表单发布到服务器。请注意,我们已经将
submitForm传递到handleSubmit。这是一个包含提交逻辑的函数,我们将在下一步实现它。 -
Overwrite the existing
handleSubmitfunction with the followingsubmitFormfunction:const submitForm = ({ search }: FormData) => { navigate(`search?criteria=${search}`); };React 钩子表单将表单数据传递给函数。我们从表单数据中解构
search字段值。提交逻辑以编程方式导航到搜索页面,将
criteria查询参数设置为search字段值。 -
让我们在 running 应用中尝试一下。在搜索框中,输入单词
typescript并按输入,如下所示:

图 6.6-搜索提交
浏览器位置查询参数按预期设置,搜索表单呈现结果正确。
这是在我们的第一个表单中实现的提交。现在,我们将继续以其他形式实现提交逻辑。
在 ask 表单中实现表单提交
让我们执行以下步骤在 ask 表单中实现提交:
-
In
QuestionsData.ts, create a function that will simulate posting a question:export interface PostQuestionData { title: string; content: string; userName: string; created: Date; } export const postQuestion = async ( question: PostQuestionData, ): Promise<QuestionData | undefined> => { await wait(500); const questionId = Math.max(...questions.map(q => q.questionId)) + 1; const newQuestion: QuestionData = { ...question, questionId, answers: [], }; questions.push(newQuestion); return newQuestion; };此函数使用
Math.max方法将问题添加到questions数组中,以将questionId设置为下一个数字。 -
In
AskPage.tsx, import the function we just added toQuestionData.ts:import { postQuestion } from './QuestionsData';另外,导入
SubmissionSuccess消息样式组件:import { ..., SubmissionSuccess, } from './Styles'; -
为在
AskPage组件const [ successfullySubmitted, setSuccessfullySubmitted, ] = React.useState(false);中是否已成功提交表单添加一些状态
-
从
useForm钩子const { register, errors, handleSubmit, } = useForm<FormData>({ mode: 'onBlur', });解构
handleSubmit函数 -
Also, destructure
formStatefrom theuseFormhook:const { register, errors, handleSubmit, formState, } = useForm<FormData>({ mode: 'onBlur', });formState包含表单是否提交、表单是否有效等信息。 -
使用
handleSubmit功能处理submit事件,格式为:<form onSubmit={handleSubmit(submitForm)}> -
Create a
submitFormfunction just above the component'sreturnstatement, as follows:const submitForm = async (data: FormData) => { const result = await postQuestion({ title: data.title, content: data.content, userName: 'Fred', created: new Date() }); setSuccessfullySubmitted(result ? true : false); };前面的代码异步调用
postQuestion函数,使用硬编码的用户名和创建日期从表单数据传入标题和内容。 -
Disable the form if the submission is in progress or successfully completed:
<Fieldset disabled={ formState.isSubmitting || successfullySubmitted } >isSubmitting是formState中的一个标志,指示是否正在进行表单提交。您可以注意到
formState内有isSubmitted标志。此表示表格是否已提交且为true,即使表格无效。这就是为什么我们使用自己的状态(successfullySubmitted)来表示已提交有效的表格。 -
After
FormButtonContainerin the JSX, add the following submission success message:{successfullySubmitted && ( <SubmissionSuccess> Your question was successfully submitted </SubmissionSuccess> )}表单成功提交后,将呈现此消息。
-
在跑步应用中,在首页点击提问按钮并填写问题表。然后点击提交问题按钮:

图 6.7–提交问题
表单在成功提交期间和之后被禁用,我们收到预期成功消息。
接下来,我们将在应答表单中实现表单提交。
在应答表中执行表单提交
在应答表中执行以下步骤进行表单提交:
-
In
QuestionsData.ts, create a function that simulates posting an answer:export interface PostAnswerData { questionId: number; content: string; userName: string; created: Date; } export const postAnswer = async ( answer: PostAnswerData, ): Promise<AnswerData | undefined> => { await wait(500); const question = questions.filter( q => q.questionId === answer.questionId, )[0]; const answerInQuestion: AnswerData = { answerId: 99, ...answer, }; question.answers.push(answerInQuestion); return answerInQuestion; };函数在
questions数组中找到问题并添加答案。前面代码的其余部分包含 post 答案和函数结果的简单类型。 -
在
QuestionPage.tsx中,导入我们刚刚创建的函数,同时导入Styles组件以获得成功消息:import { …, postAnswer } from './QuestionsData'; import { …, SubmissionSuccess, } from './Styles'; -
在
QuestionPage组件const [ successfullySubmitted, setSuccessfullySubmitted, ] = React.useState(false);中增加表单是否已成功提交的状态
-
从
useForm吊钩const { register, errors, handleSubmit, formState } = useForm<FormData>({ mode: 'onBlur', });上拆下
handleSubmit和formState -
使用
handleSubmit功能处理submit事件,格式为:<form onSubmit={handleSubmit(submitForm)} css={…} > -
Create a
submitFormfunction just above the component'sreturnstatement, as follows:const submitForm = async (data: FormData) => { const result = await postAnswer({ questionId: question!.questionId, content: data.content, userName: 'Fred', created: new Date(), }); setSuccessfullySubmitted( result ? true : false, ); };因此,这将调用
postAnswer函数,使用硬编码用户名和创建日期从字段值异步传递内容。在引用
question状态变量后注意!。这是一个非空断言运算符。重要提示
非空的断言运算符(
!)告诉 TypeScript 编译器前面的变量不能是null或undefined。这在 TypeScript 编译器不够聪明,无法自行解决这一问题的情况下非常有用。因此,
question!.questionId中的!阻止 TypeScript 抱怨question可能是null。 -
如果提交正在进行或成功完成,请禁用表单:
<Fieldset disabled={ formState.isSubmitting || successfullySubmitted } > -
在 JSX 中
FormButtonContainer之后,添加以下提交成功消息:{successfullySubmitted && ( <SubmissionSuccess> Your answer was successfully submitted </SubmissionSuccess> )} -
在 running 应用的主页上,单击问题。填写答案并提交:

图 6.8–答案提交
与 ask 表单一样,answer 表单在提交期间和提交之后被禁用,我们将收到预期的成功消息。
这是我们的三个表格,很完整,运行良好。
总结
在本章中,我们了解到可以使用 React 中的受控组件实现表单。对于受控组件,React 通过状态控制字段组件值,我们需要实现样板代码来管理此状态。
React Hook 表单是 React 社区中流行的表单库。这就消除了对样板代码的需要,而这正是受控组件所需要的。
我们现在了解到,register函数可以设置为 React 元素的ref属性,以允许 React 钩子表单管理该元素。验证规则可以在register函数参数中指定。
我们可以将表单提交逻辑从 React Hook 表单传递到handleSubmit函数中。我们了解到,isSubmitting是formState中的一个有用标志,我们可以使用它在提交时禁用表单。
在下一章中,我们将重点关注应用中的状态管理并利用 Redux。
问题
通过回答以下问题,检查是否所有关于表单的信息都被卡住了:
-
非受控
input元素的哪些属性可用于设置其初始值? -
The following JSX is a controlled
inputelement:<input id="firstName" value={firstName} />但是,用户无法在输入中输入字符。这里有什么问题?
-
当我们实现如下表单字段时,为什么要使用
htmlFor属性将label与input绑定?<label htmlFor="title">{label}</label> <input id="title" … /> -
React Hook 表单中的哪个对象允许我们访问验证错误?
-
我们可以从 React Hook 表单中使用什么来确定表单是否正在提交?
-
我们为什么不使用带有
formState的isSubmitted标志来确定表单是否已成功提交?
答案
-
input元素的defaultValue属性可用于设置其初始值。 -
问题是对
firstName状态所做的更改需要管理:<input id="firstName" value={firstName} onChange={e => setFirstName(e.currentTarget.value)} /> -
当我们使用
htmlFor属性将label绑定到input时,屏幕阅读器等工具就可以访问它。此外,当用户单击标签时,焦点将自动设置在输入上。 -
React Hook 表单中的
errors对象允许我们访问验证错误。 -
我们可以使用
formState中的isSubmitting标志来确定是否正在提交表单。 -
带有
formState的isSubmitted标志表示是否已提交表单,无论提交是否成功。
进一步阅读
以下是一些有用的链接,您可以了解有关本章所涵盖主题的更多信息:
- React 形式:https://reactjs.org/docs/forms.html
- React 钩形式:https://react-hook-form.com/
七、使用 Redux 管理状态
到目前为止,在我们的应用中,状态在我们的 React 组件中本地保持。这种方法适用于简单的应用。React-Redux 帮助我们稳健地处理复杂的状态场景。当用户交互导致对状态的若干更改时(可能有些更改是有条件的),以及主要当交互导致 web 服务调用时,它会发光。当应用中有很多共享状态时,这也很好。
我们将从理解 Redux 模式和不同的术语开始本章,例如动作和减速器。我们将遵循 Redux 的原则及其带来的好处。
我们将更改应用的实现,并使用 Redux 来管理未回答的问题。我们将实现一个 Redux 存储,其状态包含未回答的问题、已搜索的问题和正在查看的问题。我们将在主页、搜索和问题页面中与商店进行交互。这些实现将让我们很好地掌握如何在 React 应用中使用 Redux。
在本章中,我们将介绍以下主题:
- 理解 Redux 模式
- 安装 Redux
- 创建国家
- 创建操作
- 创建减速器
- 创建商店
- 将组件连接到存储
在本章结束时,我们将了解 Redux 模式,并将在 React 应用中使用它轻松实现状态。
技术要求
在本章中,我们将使用以下工具:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。如果您已经安装了它,请确保它至少是 1.52 版。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。
- Q 和 A:我们将从第 6 章中完成的&前端项目开始。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-07/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/3h5fjVc
了解 Redux 模式
Redux 是一个可预测状态容器,可用于 React 应用。在本节中,我们将首先介绍 Redux 中的三个原则,然后再了解 Redux 的好处以及它在哪些情况下工作良好。然后,我们将深入研究核心概念,以便理解术语和更新state时发生的步骤。通过这样做,我们将能够很好地在应用中实现 Redux。
原则
让我们来看看 ReDux 的三个原则:
- 单一真相来源:这意味着整个应用状态存储在单个对象中。在实际应用中,此对象可能包含嵌套对象的复杂树。
- 状态为只读:表示
state不能直接更改。在 Redux 中,更改state的唯一方法是发送所谓的动作。 - 使用纯函数进行更改:负责更改状态的函数称为减速机。
当许多组件需要访问相同的数据时,Redux 会发光,因为state及其交互存储在一个地方。通过函数将state设置为只读且仅可更新,使交互更易于理解和调试。当许多组件与状态交互并且某些交互是异步的时,它特别有用。
在接下来的部分中,我们将进一步深入研究操作和减缩器,以及管理它们的东西,称为存储。
关键概念
应用的整个状态存在于所谓的存储中。状态存储在一个 JavaScript 对象中,如下所示:
{
questions: {
loading: false,
unanswered: [{
questionId: 1, title: ...
}, {
questionId: 2, title: ...
}]
}
}
在本例中,单个对象包含一系列未回答的问题,以及这些问题是否从 web API 获取。
state将不包含任何函数或 setter 或 getter。它是一个简单的 JavaScript 对象。商店还协调 Redux 中的所有移动部件。这包括通过减速器推动操作以更新状态。
因此,更新商店中的state首先需要发生的事情是发送动作。动作是另一个简单的 JavaScript 对象,如以下代码片段中的对象:
{ type: 'GettingUnansweredQuestions' }
type属性确定需要执行的操作类型。type属性是操作的重要部分,因为没有它,还原程序将不知道如何更改状态。在上一个示例中,操作只包含type属性。这是因为减速器不需要任何更多信息来更改此操作的state。以下示例是另一个操作:
{
type: 'GotUnansweredQuestions',
questions: [{
questionId: 1, title: ...
}, {
questionId: 2, title: ...
}]
}
这一次,questions属性中的操作包含了额外的信息位。减速器需要此附加信息来更改此类操作的状态。
减速器为纯功能,可使实际状态发生变化。
重要提示
对于给定的一组参数,纯函数总是返回相同的结果。因此,这些函数不依赖于函数范围之外的任何未传递到函数中的变量。纯函数也不会更改函数范围之外的任何变量。
以下是减速器的示例:
const questionsReducer = (state, action) => {
switch (action.type) {
case 'GettingUnansweredQuestions': {
return {
...state,
loading: true
};
}
case 'GotUnansweredQuestions': {
return {
...state,
unanswered: action.questions,
loading: false
};
}
}
};
以下是有关减速器的一些要点:
- 减速器在当前状态和正在执行的操作的两个参数中采用。
switch语句用于操作类型,并为其每个分支中的每个操作类型适当地创建一个新的状态对象。- 要创建新状态,我们将当前状态分散到一个新对象中,然后用已更改的属性覆盖它。
- 从减速器返回新状态。
您会注意到,我们刚才看到的 actions 和 reducer 没有 TypeScript 类型。显然,在下面的部分中实现这些时,我们将包括必要的类型。
下图显示了我们刚刚了解的 Redux 片段,以及组件如何与它们交互以获取和更新状态:

图 7.1–组件如何与 Redux 交互以获取和更新状态
组件从存储中获取状态。组件通过调度一个操作来更新状态,该操作被馈送到更新状态的 reducer 中。当组件更新时,存储将新状态传递给组件。
现在我们已经开始了解什么是 Redux,是时候在我们的应用中实践它了。
安装 Redux
在使用 Redux 之前,我们需要安装它以及 TypeScript 类型。让我们执行以下步骤来安装 Redux:
-
If we haven't already done so, let's open our project in Visual Studio Code from where we left off in the previous chapter. We can install the core Redux library via the terminal with the following command:
> npm install redux请注意,核心 Redux 库中包含 TypeScript 类型,因此不需要对这些类型进行额外安装。
-
Now, let's install the React-specific bits for Redux in the terminal with the following command:
> npm install react-redux这些位允许我们将 React 组件连接到 Redux 存储。
-
让我们也为 React Redux 安装 TypeScript 类型:
> npm install @types/react-redux --save-dev
现在安装了所有 Redux 位,我们就可以开始构建 Redux 商店了。
创建状态
在本节中,我们将实现存储中状态对象的类型,以及状态的初始值。执行以下步骤以执行此操作:
-
在
src文件夹中创建一个名为Store.ts的新文件,并使用以下import语句:import { QuestionData } from './QuestionsData'; -
Let's create the TypeScript types for the state of our store:
interface QuestionsState { readonly loading: boolean; readonly unanswered: QuestionData[]; readonly viewing: QuestionData | null; readonly searched: QuestionData[]; } export interface AppState { readonly questions: QuestionsState; }因此,我们的商店将有一个
questions属性,它是一个包含以下属性的对象:loading:是否正在进行服务器请求unanswered:包含未回答问题的数组viewing:用户正在查看的问题searched:包含搜索中匹配问题的数组
-
让我们定义存储的初始状态,以便它有一个空的未回答问题数组:
const initialQuestionState: QuestionsState = { loading: false, unanswered: [], viewing: null, searched: [], };
因此,我们定义了 state 对象的类型并创建了初始 state 对象。我们通过在 state 属性名称之前使用readonly关键字将状态设置为只读。
现在让我们继续并定义类型来表示我们的操作。
创造动作
操作启动对我们的存储状态的更改。在本节中,我们将创建函数来创建存储中的所有操作。我们将从了解商店中需要的所有操作开始。
了解店内的动作
将与门店交互的三个流程如下:
- 在主页上获取和呈现未回答的问题
- 获取并呈现问题页面上正在查看的问题
- 搜索问题并在搜索页面上显示匹配项
每个过程包括以下步骤:
- 流程启动时,存储的
loading状态设置为true。 - 然后向服务器发出请求。
- 当接收到来自服务器的响应时,数据将以存储的状态放入适当的位置,
loading被设置为false。
每个进程有两个状态更改。这意味着每个流程需要两个操作:
- 表示流程开始的操作
- 表示进程结束的操作,该操作将包含来自服务器请求的数据
因此,我们的商店将总共有六项活动。
未回答的问题
我们将在Store.ts中创建动作。让我们为获得未回答问题的流程创建两个操作。执行以下步骤:
-
让我们首先创建一个常量来保存第一个操作的操作类型,这表示正在从服务器获取未回答的问题:
export const GETTINGUNANSWEREDQUESTIONS = 'GettingUnansweredQuestions'; -
Create a function that returns this action:
export const gettingUnansweredQuestionsAction = () => ({ type: GETTINGUNANSWEREDQUESTIONS, } as const);注意在返回对象之后的as const关键字。这是一个类型脚本常量断言。
重要提示
对象上的常量断言将为提供一个不可变类型。它还将导致字符串属性具有窄字符串文字类型,而不是更宽的字符串类型。
没有 const 断言的此操作的类型如下所示:
{ type: string }具有类型断言的此操作的类型如下:
{ readonly type: 'GettingUnansweredQuestions' }因此,
type属性只能是'GettingUnansweredQuestions'而不能是其他字符串值,因为我们已经将其键入到特定的字符串文本中。此外,type属性值是只读的,因此无法更改。 -
Create a function that returns the action for when the unanswered questions have been retrieved from the server:
export const GOTUNANSWEREDQUESTIONS = 'GotUnansweredQuestions'; export const gotUnansweredQuestionsAction = ( questions: QuestionData[], ) => ({ type: GOTUNANSWEREDQUESTIONS, questions: questions, } as const);这一次,动作包含一个名为
questions的属性来保存未回答的问题,以及固定的type属性。我们希望问题被传递到questions参数中的函数中。
这就完成了获取未回答问题的操作类型的实现。
看问题
让我们添加两个操作,以使用类似的方法查看问题:
export const GETTINGQUESTION = 'GettingQuestion';
export const gettingQuestionAction = () =>
({
type: GETTINGQUESTION,
} as const);
export const GOTQUESTION = 'GotQuestion';
export const gotQuestionAction = (
question: QuestionData | null,
) =>
({
type: GOTQUESTION,
question: question,
} as const);
请注意,actiontype属性被赋予了唯一的值。这是必需的,以便 reducer 可以确定要对存储的状态进行哪些更改。
我们还确保type属性被赋予一个有意义的值。这有助于提高代码的可读性。
从服务器返回的数据可以是问题,如果没有找到问题,也可以是null。这就是使用联合类型的原因。
搜索问题
商店中的最后动作是搜索问题。现在让我们添加以下内容:
export const SEARCHINGQUESTIONS =
'SearchingQuestions';
export const searchingQuestionsAction = () =>
({
type: SEARCHINGQUESTIONS,
} as const);
export const SEARCHEDQUESTIONS =
'SearchedQuestions';
export const searchedQuestionsAction = (
questions: QuestionData[],
) =>
({
type: SEARCHEDQUESTIONS,
questions,
} as const);
动作类型再次被赋予唯一且有意义的值。服务器搜索返回的数据是一系列问题。
总之,我们使用了 Redux 中的Action类型为我们的六个操作创建接口。这确保操作包含所需的类型属性。
我们的 Redux 商店现在发展得很好。让我们继续创建一个减速器。
创建减速器
减速器是对状态进行必要更改的功能。它将当前状态和正在处理的操作作为参数,并返回新状态。在本节中,我们将实现一个减速机。让我们执行以下步骤:
-
One of the parameters in the reducer is the action that invoked the state change. Let's create a union type containing all the action types that will represent the reducer action parameter:
type QuestionsActions = | ReturnType<typeof gettingUnansweredQuestionsAction> | ReturnType<typeof gotUnansweredQuestionsAction> | ReturnType<typeof gettingQuestionAction> | ReturnType<typeof gotQuestionAction> | ReturnType<typeof searchingQuestionsAction> | ReturnType<typeof searchedQuestionsAction>;我们使用了
ReturnType实用程序类型来获取操作函数的返回类型。ReturnType希望传入一个函数类型,所以我们使用typeof关键字来获取每个函数的类型。重要提示
当
typeof用于类型时,TypeScript 将根据typeof关键字后的变量推断类型。 -
Next, create the skeleton reducer function:
const questionsReducer = ( state = initialQuestionState, action: QuestionsActions ) => { // TODO - Handle the different actions and return // new state return state; };减速器接收两个参数,一个用于当前状态,另一个用于正在处理的动作。第一次调用 reducer 时,状态将为
undefined,因此我们将其默认为先前创建的初始状态。减速器需要返回给定动作的新状态对象。我们现在只是返回初始状态。
重要的是,一个 reducer 总是返回一个值,因为一个存储可能有多个 reducer。在这种情况下,将调用所有的还原器,但不一定处理该操作。
-
Now, add a
switchstatement to handle the different actions:const questionsReducer = ( state = initialQuestionState, action: QuestionsActions, ) => { switch (action.type) { case GETTINGUNANSWEREDQUESTIONS: { } case GOTUNANSWEREDQUESTIONS: { } case GETTINGQUESTION: { } case GOTQUESTION: { } case SEARCHINGQUESTIONS: { } case SEARCHEDQUESTIONS: { } } return state; };请注意,
action参数中的type属性是强类型的,我们只能处理前面定义的六个动作。我们先来处理
GettingUnansweredQuestions问题:case GETTINGUNANSWEREDQUESTIONS: { return { ...state, loading: true, }; }我们使用扩展语法将以前的状态复制到一个新对象中,然后将
loading状态设置为true。重要提示
扩展语法允许对象扩展到需要键值对的地方。语法由三个点组成,后跟要展开的对象。更多信息请访问https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax 。
扩展语法通常用于 reducer,将旧状态复制到新状态对象中,而不改变传递到 reducer 中的状态。这一点很重要,因为减速器必须是一个纯函数,不能在其范围外更改值。
-
Let's now move on to the
GotUnansweredQuestionsaction:case GOTUNANSWEREDQUESTIONS: { return { ...state, unanswered: action.questions, loading: false, }; }我们使用扩展语法将以前的状态复制到一个新对象中,并设置
unanswered和loading属性。请注意,我们是如何仅为GotUnansweredQuestions操作中的属性获取 IntelliSense 的:![Figure 7.2 – Narrowed action type]()
图 7.2–窄动作类型
TypeScript 巧妙地缩小了 switch 分支中的类型,而不是传递到
action参数的 reducer 中的 union 类型。 -
Handle the action getting a question by using the same approach:
case GETTINGQUESTION: { return { ...state, viewing: null, loading: true, }; }当服务器请求发出时,正在查看的问题被重置为
null,并且loading状态被设置为true。 -
Handle the action for receiving a question:
case GOTQUESTION: { return { ...state, viewing: action.question, loading: false, }; }正在查看的问题被设置为来自动作的问题,
loading状态被重置为false。 -
Handle the action for searching questions:
case SEARCHINGQUESTIONS: { return { ...state, searched: [], loading: true, }; }搜索结果初始化为空数组,
loading状态设置为true,同时发出服务器请求。 -
让我们来处理最后一个操作,用于接收来自搜索的匹配问题:
case SEARCHEDQUESTIONS: { return { ...state, searched: action.questions, loading: false, }; }
这就是减速器。我们使用switch语句来处理不同的动作类型。在交换机分支中,我们使用扩展语法复制以前的状态并更新相关值。
现在,我们已经为 Redux 存储实现了所有不同的部分,因此我们将在下一节中创建一个函数来创建存储。
创建店铺
Store.ts中的最后一个任务是创建一个函数,该函数创建 Redux 存储,以便将其提供给 React 组件。我们还需要将所有存储减缩器都输入到这个函数中。让我们通过执行以下步骤来完成此操作:
-
First, let's import the
Storetype and thecreateStoreandcombineReducersfunctions from Redux:import { Store, createStore, combineReducers } from 'redux';Store是代表 Redux 商店的顶级类型。稍后我们将使用
createStore函数创建存储。combineReducers是一个函数,我们可以使用它将多个减速机组合成createStore函数所需的格式。 -
Let's use the
combineReducersfunction to create what is called a root reducer:const rootReducer = combineReducers<AppState>({ questions: questionsReducer });一个对象文本被传递到
combineReducers,其中包含我们的应用状态中的属性,以及负责该状态的 reducer。我们的应用状态中只有一个名为questions的属性,还有一个名为questionsReducer的减速机管理对该状态的更改。 -
Create a function to create the store:
export function configureStore(): Store<AppState> { const store = createStore( rootReducer, undefined ); return store; }该功能使用 Redux 的
createStore功能,通过组合减速器和undefined作为初始状态。我们使用泛型
Store类型作为传入应用状态接口的函数的返回类型,即AppState。
这就是我们创建商店所需要做的一切。
我们在一个名为Store.ts的文件中创建了商店中的所有零碎信息。对于较大的存储,跨不同文件构建存储可能有助于维护。构建按功能存储,在文件中包含每个功能的所有操作和缩减器,效果很好,因为我们通常按功能读取和编写代码。
在下一节中,我们将将我们的存储连接到我们在前几章中实现的组件。
将组件连接到商店
在本节中,我们将将应用中的现有组件连接到我们的商店。我们将首先在组件树的根目录中添加一个所谓的存储提供者,它允许树中较低的组件使用存储。然后,我们将使用 React Redux 中的挂钩将主页、问题和搜索页面连接到 Redux 存储。
添加门店提供商
让我们将存储提供给组件树的根。为此,请执行以下步骤:
-
In
App.tsx, import theProvidercomponent from React Redux and theconfigureStorefunction we created in the previous section. Add theseimportstatements just after the Reactimportstatement:import React from 'react'; import { Provider } from 'react-redux'; import { configureStore } from './Store';这是我们第一次引用 React Redux 中的任何内容。请记住,此库有助于 React 组件与 Redux 存储交互。
-
在定义
App组件之前,使用configureStore函数const store = configureStore(); function App() { ... }创建我们商店的实例
-
在
App组件的 JSX 中,通过传入我们的商店实例return ( <Provider store={store}> <BrowserRouter> ... </BrowserRouter> </Provider> );将
Provider组件包裹在BrowserRouter组件上
组件树中较低的组件现在可以连接到存储。
连接主页
让我们将主页连接到商店。为此,请执行以下步骤:
-
In
HomePage.tsx, let's add the followingimportstatement:import { useSelector, useDispatch } from 'react-redux';我们最终将使用
useSelector函数从存储获取状态。useDispatch函数将用于调用操作。 -
对于未回答的问题,我们将使用 Redux 存储,因此,让我们为其导入操作函数,以及存储状态的类型:
import { gettingUnansweredQuestionsAction, gotUnansweredQuestionsAction, AppState, } from './Store'; -
Let's remove the
QuestionDatatype from theimportstatement fromQuestionsData.tsso that it now looks like the following:import { getUnansweredQuestions, } from './QuestionsData';QuestionData类型将在我们修订的实现中推断出来。 -
来自 React Redux 的
useDispatch钩子返回一个函数,我们可以使用该函数来分派操作。让我们将其分配给一个名为dispatch:export const HomePage = () => { const dispatch = useDispatch(); ... }的函数
-
The
useSelectorhook from React Redux returns state from the store if we pass it a function that selects the state. Use theuseSelectorhook to get the unanswered questions state from the store:export const HomePage = () => { const dispatch = useDispatch(); const questions = useSelector( (state: AppState) => state.questions.unanswered, ); ... }传递给
useSelector的功能通常被称为一个选择器。它接收存储的状态对象,并包含从存储返回所需状态部分的逻辑。 -
再次使用
useSelector钩子从门店获取loading状态:const questions = useSelector( (state: AppState) => state.questions.unanswered, ); const questionsLoading = useSelector( (state: AppState) => state.questions.loading, ); -
我们的本地组件状态现在是冗余的,所以让我们通过删除突出显示的行来删除它:
const questionsLoading = useSelector( (state: AppState) => state.questions.loading, ); const [ questions, setQuestions, ] = React.useState<QuestionData[]>([]); const [ questionsLoading, setQuestionsLoading, ] = React.useState(true); -
Invoke the action for getting unanswered questions at the start of the
useEffectfunction:React.useEffect(() => { const doGetUnansweredQuestions = async () => { dispatch(gettingUnansweredQuestionsAction()); const unansweredQuestions = await getUnansweredQuestions(); setQuestions(unansweredQuestions); setQuestionsLoading(false); }; doGetUnansweredQuestions(); }, []);我们使用
dispatch函数将操作发送到商店。 -
Invoke the action for receiving unanswered questions after the call to
getUnansweredQuestions:React.useEffect(() => { const doGetUnansweredQuestions = async () => { dispatch(gettingUnansweredQuestionsAction()); const unansweredQuestions = await getUnansweredQuestions(); dispatch(gotUnansweredQuestionsAction (unansweredQuesti ons)); setQuestions(unansweredQuestions); setQuestionsLoading(false); }; doGetUnansweredQuestions(); }, []);我们将未回答的问题传递给创建此操作的函数。然后,我们使用
dispatch函数分派操作。 -
通过删除突出显示的行
```cs
React.useEffect(() => {
const doGetUnansweredQuestions = async () => {
…
setQuestions(unansweredQuestions);
setQuestionsLoading(false);
};
doGetUnansweredQuestions();
}, []);
```
,从`useEffect`函数中删除设置本地状态的引用
- ESLint 警告我们,
dispatch函数可能是useEffect钩子中缺少的依赖项。我们只希望在首次安装组件时触发useEffect功能,而不是在dispatch功能的引用发生变化时触发。因此,我们通过添加以下行来抑制此警告:
```cs
React.useEffect(() => {
…
// eslint-disable-next-line react
hooks/exhaustive-deps
}, []);
```
- 如果应用未运行,请在终端中键入
npm start以启动它。应用将正常运行,未回答的问题将显示在主页上,就像我们添加 Redux 商店之前一样:

图 7.3–连接到 Redux 商店的主页组件
祝贺我们刚刚将第一个组件连接到 Redux 商店!
将组件连接到商店的关键部分是使用useSelector钩子选择所需的状态和使用useDispatch钩子调度动作。
我们将使用类似的方法将另一个组件连接到存储。
连接问题页面
让我们将问题页面连接到商店。为此,请执行以下步骤:
-
在
QuestionPage.tsx中,我们添加以下import语句,从 React Redux 导入我们需要的钩子,并从我们的商店导入动作函数:import { useSelector, useDispatch, } from 'react-redux'; import { AppState, gettingQuestionAction, gotQuestionAction, } from './Store'; -
从
QuestionsData.ts的import语句中删除QuestionData类型,使其看起来如下:import { getQuestion, postAnswer } from './QuestionsData'; -
为
useDispatch挂钩export const QuestionPage = () => { const dispatch = useDispatch(); ... }分配
dispatch功能 -
使用带有选择器的
useSelector钩子,从存储区的状态获取正在查看的问题:const dispatch = useDispatch(); const question = useSelector( (state: AppState) => state.questions.viewing, ); -
我们的本地
question状态现在是冗余的,所以让我们通过删除突出显示的行来删除它:const [ question, setQuestion, ] = React.useState<QuestionData | null>(null); -
在
useEffect功能中,移除对本地question状态的引用,并从存储中调度适当的动作:React.useEffect(() => { const doGetQuestion = async ( questionId: number, ) => { dispatch(gettingQuestionAction()); const foundQuestion = await getQuestion(questionId); dispatch(gotQuestionAction(foundQuestion)); }; if (questionId) { doGetQuestion(Number(questionId)); } // eslint-disable-next-line react hooks/exhaustive-deps }, [questionId]); -
在 running 应用中,浏览至问题页面,如下所示:

图 7.4–连接到 Redux 存储的 QuestionPage 组件
页面将正确呈现。
问题页面现在已连接到商店。接下来,我们将把最后一个组件连接到商店。
连接搜索页面
让我们将搜索页面连接到商店。为此,请执行以下步骤:
-
在
SearchPage.tsx中,我们添加以下import语句,从 React Redux 导入我们需要的钩子,并从我们的商店导入类型:import { useSelector, useDispatch } from 'react-redux'; import { AppState, searchingQuestionsAction, searchedQuestionsAction, } from './Store'; -
从
QuestionsData.ts:的import语句中删除QuestionData类型,使其看起来如下:import { searchQuestions } from './QuestionsData'; -
为
useDispatch挂钩export const SearchPage = () => { const dispatch = useDispatch(); ... }分配
dispatch功能 -
使用带有选择器的
useSelector钩子从存储中获取搜索的问题状态:const dispatch = useDispatch(); const questions = useSelector( (state: AppState) => state.questions.searched, ); -
我们的本地
questions状态现在是冗余的,所以让我们通过删除突出显示的行来删除它:const [ questions, setQuestions, ] = React.useState<QuestionData[] >([]); -
在
useEffect函数中,删除对本地question状态的引用,并从存储中调度适当的操作:React.useEffect(() => { const doSearch = async (criteria: string) => { dispatch(searchingQuestionsAction()); const foundResults = await searchQuestions( criteria, ); dispatch(searchedQuestionsAction(foundResults)); }; doSearch(search); // eslint-disable-next-line react hooks/exhaustive-deps }, [search]); -
在运行中的 app 中,输入
typescript进行搜索操作,如下图所示:

图 7.5–连接到 Redux 商店的 SearchPage 组件
页面将正确呈现。
搜索页面现在已连接到商店。
这就完成了将所需组件连接到我们的 Redux 商店的工作。
我们使用 React Redux 的useSelector钩子访问 Redux 存储状态。我们向其中传递了一个函数,该函数检索 React 组件中所需的适当状态。
为了开始状态更改的过程,我们使用 React Redux 的useDispatch钩子返回的函数调用了一个操作。我们将相关的操作对象传递到此函数中,其中包含更改状态的信息。
请注意,我们没有更改任何连接组件中的 JSX,因为我们使用了相同的状态变量名。我们只是将此状态移动到 Redux 商店。
总结
在本章中,我们了解到 Redux 存储中的状态存储在单个位置,是只读的,并使用称为 reducer 的纯函数进行更改。我们的部件不直接与减速器对话;相反,它们分派称为操作的对象,这些对象描述对减速器的更改。现在我们知道了如何创建一个强类型 Redux 存储,其中包含一个只读状态对象和必要的 reducer 函数。
我们了解到,如果 React 组件是 ReduxProvider组件的子组件,那么它们可以访问 Redux 存储。我们还知道如何使用useSelector钩子从组件的存储中获取状态,并创建一个调度程序,以useDispatch作为钩子来调度操作。
在 React 应用中实现 Redux 时,我们需要了解很多细节。它确实在状态管理复杂的场景中大放异彩,因为 Redux 迫使我们将逻辑分解为易于理解和维护的独立部分。它对于管理全局状态(如用户信息)也非常有用,因为它在Provider组件下面很容易访问。
现在,我们已经在应用中构建了大部分前端,这意味着是时候将注意力转向后端了。在下一章中,我们将重点介绍如何在 ASP.NET 中与数据库交互。
问题
在结束本章之前,让我们用一些问题来测试我们的知识:
-
实现动作对象时,它可以包含多少属性?
-
我们是如何将存储中的状态设置为只读的?
-
React Redux 的
Provider组件是否需要放置在组件树的顶部? -
React Redux 的哪个钩子允许组件从 Redux 存储选择状态?
-
以下向存储区发送操作的代码有什么问题?
useDispatch(gettingQuestionAction); -
是否允许使用 Redux 存储的组件具有本地状态?
答案
-
一个动作可以包含任意多的属性!对于
type属性,它需要至少包含一个。然后,它可以包含减速器改变状态所需的任意多个其他属性,但这通常被归为一个通常称为payload的附加属性。因此,一般来说,一个动作会有一个或两个属性。 -
我们在接口的属性中为状态使用了
readonly关键字,使其成为只读。 -
Provider组件需要放置在需要进入仓库的组件上方。所以,它不需要就在树的顶端。 -
useSelector钩子允许组件从存储中选择状态。 -
useDispatch钩子返回一个可以用来分派动作的函数——它不能直接用来分派动作。以下是发送操作的正确方法:const dispatch = useDispatch(); ... dispatch(gettingQuestionAction); -
是的,组件可以具有本地状态和 Redux 状态。如果该状态在组件外部不可用,则完全可以在组件内部本地使用该状态。
进一步阅读
以下是一些有用的链接,您可以了解有关本章所涵盖主题的更多信息:
- Redux 入门:https://redux.js.org/introduction/getting-started
- React 还原:https://react-redux.js.org/
- 从不输入:https://www.typescriptlang.org/docs/handbook/basic-types.html
八、与数据库的交互
是时候开始我们问答应用的后端工作了。在本章中,我们将为应用构建数据库,并使用名为 Dapper 的库从 ASP.NET Core 与之交互。
我们将从了解什么是 Dapper 以及它给实体框架带来的好处开始。我们将通过学习如何使用 Dapper 将数据从数据库读入模型类,在应用中创建数据访问层。然后,我们将继续从模型类向数据库写入数据。
在我们的应用发布期间部署数据库更改是一项非常重要的任务。因此,在本章末尾,我们将使用一个名为DbUp的库来设置数据库迁移的管理。
在本章中,我们将介绍以下主题:
- 实现数据库
- 了解什么是 Dapper 及其好处
- 安装和配置 Dapper
- 使用 Dapper 读取数据
- 使用 Dapper 写入数据
- 使用
DbUp管理迁移
到本章结束时,我们将创建一个 SQL Server 数据库,用于存储问题和答案以及与之交互的已实现性能数据层。
技术要求
在本章中,我们需要使用以下工具:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET Core 代码。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5.0:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- SQL Server 2019 速成版:我们将使用它作为我们的数据库。可从下载并安装 https://www.microsoft.com/en-gb/sql-server/sql-server-editions-express 。
- SQL Server Management Studio:我们将使用它来创建数据库。可从下载并安装 https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms 。
- Q 和 A:我们将从第 2 章创建解耦 React 和 ASP.NET 5 应用中创建并完成的 QandA 后端项目开始。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节中还原代码,可以下载必要的源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可以在终端中输入npm install来恢复任何依赖关系。
查看以下视频以查看代码的运行:http://bit.ly/2EVDsv6 。
数据库的实现
在本节中,我们将为我们的 Q&a 应用创建一个 SQL Server 数据库。然后,我们将在数据库中创建存储问题和答案的表。之后,我们将创建存储过程来读取和写入这些表中的记录。
创建数据库
我们将使用SQL Server Management Studio(SSMS创建数据库,具体步骤如下:
-
Open SSMS and connect to the SQL Server instance:
![Figure 8.1 – Connecting to SQL Server Express]()
图 8.1–连接到 SQL Server Express
-
在对象浏览器中,右键点击数据库并点击新数据库。。。选项。
-
Enter
QandAfor the name of the database and click OK:![Figure 8.2 – Creating the Q&A database]()
图 8.2–创建问答数据库
-
一旦创建了数据库,我们将在对象浏览器中看到它的列表:

图 8.3–对象资源管理器中的问答数据库
又好又简单!我们将为下一节中的问题和答案创建数据库表。
创建数据库表
让我们在 SSMS 的新数据库中为用户、问题和答案创建一些表:
- 复制处 SQL 脚本的内容 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition/blob/master/chapter-08/start/backend/SQLScripts/01-Tables.sql 。
- 在 SSMS 中,突出显示QandA数据库,单击工具栏上的新建查询创建新的 SQL 查询,并粘贴复制脚本的内容。
- 点击工具栏上的执行选项或按F5执行查询。
- 如果我们在对象资源管理器中的表下查看,应该会看到已经创建了几个表:

图 8.4–对象资源管理器中的问答数据库
这里已经创建了一个Question表。这包含已提出的问题,并包含以下字段:
- 一个名为
QuestionId的基于整数的字段,它是基于 Unicode 的Title和Content字段的主键。 UserId和UserName字段,引用提出问题的用户。- 一个名为
Created的字段,它将保存提问的日期和时间。
还创建了一个Answer表。这包含问题的答案,并包含以下字段:
- 基于整数的
AnswerId字段,是主键。 - 一个基于整数的
QuestionId字段,引用正在回答的问题。 - 基于 Unicode 的
Content字段。 UserId和UserName字段,引用回答问题的用户。- 一个名为
Created的字段,它将保存提交答案的日期和时间。
SQL 脚本添加了一些示例数据。如果我们右键点击对象浏览器中的问题表格,选择编辑前 200 行选项,我们将看到表格中的数据:

图 8.5–问答数据库中的问题
所以,我们现在有一个包含我们的表的数据库,并且有一些好的数据可以处理。
创建存储过程
让我们创建一些存储过程,我们的应用将使用这些存储过程与数据库表进行交互。
-
点击新建查询新建 SQL 查询,粘贴复制脚本内容。
-
点击工具栏上的执行选项。
-
If we look under Stored Procedures under Programmability in Object Explorer, we should see that several stored procedures have been created:
![Figure 8.6 – Stored procedures in the Q&A database]()
图 8.6–问答数据库中的存储过程
我们将使用这些存储过程与 ASP.NET Core 应用中的数据库进行交互。
-
Before we finish this section, let's try to run one of the stored procedures. Click New Query to create a new SQL query and enter the following:
EXEC dbo.Question_GetMany_BySearch @Search = 'type'因此,此 SQL 命令将通过传入带有
type值的@Search参数来执行Question_GetMany_BySearch存储过程。此存储过程返回的问题在其内容的标题中具有@Search参数的值。 -
点击工具栏上的执行选项。我们应该得到以下结果:

图 8.7–运行存储过程的结果
有了 SQL 服务器数据库,我们现在可以将注意力转向 Dapper 了。
了解什么是 Dapper 及其好处
Dapper 是一个针对.NET 的以性能为中心的简单对象映射器,它帮助将 SQL 查询输出映射到 C#类的实例。它由 Stack Overflow 团队构建和维护,已作为开源发布,是 Microsoft 实体框架的流行替代方案。
那么,为什么要使用简洁而不是实体框架呢?实体框架的目标是抽象出数据库,因此它将学习 SQL 转换为实体框架特定的对象,如DBSet和DataContext。我们通常不使用实体框架编写 SQL,而是编写 LINQ 查询,通过实体框架将其转换为 SQL。
如果我们要实现一个服务于大量用户的大型数据库,实体框架可能是一个挑战,因为它生成的查询可能效率低下。我们需要很好地理解实体框架,使其具有可扩展性,这可能是一项重大投资。当我们发现实体框架查询速度较慢时,我们需要理解 SQL 以正确理解根本原因。因此,将时间投入到真正学好 SQL 而不是实体框架提供的抽象上是有意义的。此外,如果我们有一个拥有良好数据库和 SQL 技能的团队,那么不使用它们是没有意义的。
Dapper 比实体框架简单得多。在本章后面,我们将看到,我们只需几行 C#代码就可以从 SQL 数据库读取和写入数据。这允许我们与数据库中的存储过程进行交互,从而自动将 C#类实例与查询结果一起映射到 SQL 参数。在下一节中,我们将安装并开始使用 Dapper 访问数据。
安装和配置短节
在部分中,我们将安装并配置 Dapper。我们还将安装 Dapper 使用的 Microsoft SQL Server 客户端包。让我们执行以下步骤:
-
Let's open the backend project in Visual Studio. Go to the Tools menu and then NuGet Package Manager and choose Manage NuGet Packages for Solution....
重要提示
NuGet 是一个下载第三方和 Microsoft 库并管理对它们的引用的工具,以便可以轻松地更新库。
-
在浏览选项卡上,在搜索框中输入
Dapper。 -
Select the Dapper package by Sam Saffron, Marc Gravell, and Nick Craver. Tick our project and click the Install button with the latest stable version selected. Refer to the following screenshot:
![Figure 8.8 – Installing Dapper in the NuGet manager]()
图 8.8–在 NuGet manager 中安装 Dapper
在 Dapper 可以下载并安装到我们的项目中之前,我们可能会被要求接受许可协议。
-
Still in the NuGet package manager, search for the
Microsoft.Data.SqlClientpackage and install the latest stable version. Refer to the following screenshot:![Figure 8.9 – Installing Microsoft.Data.Client]()
图 8.9–安装 Microsoft.Data.Client
-
Next, we are going to define a connection string in our ASP.NET Core project that goes to our database. In Solution Explorer, open up a file called
appsettings.jsonto add aConnectionStringsfield that contains our connection string:{ "ConnectionStrings": { "DefaultConnection": "Server=localhost\\SQLEXPRESS;Database=QandA; Trusted_Connection=True;" }, ... }重要提示
appsettings.json文件是一个 JSON 格式的文件,包含 ASP.NET Core 应用的各种配置设置。
显然,更改连接字符串,使其引用 SQL Server 和数据库。
这就是安装的 Dapper 以及到位置的数据库的连接字符串。接下来,我们将学习如何使用 Dapper 从数据库中读取数据。
使用简洁的方式读取数据
在部分中,我们将编写一些从数据库读取数据的 C#代码。
我们将使用流行的存储库设计模式来构造数据访问代码。这将允许我们对数据层提供一个很好的、集中的抽象。
我们将首先创建一个数据存储库类,该类将保存我们将对数据进行的所有查询。我们将创建 C#类,这些类保存我们从数据库获得的数据,称为模型。
我们将实现获取所有问题、从搜索中获取问题、获取未回答的问题、获取单个问题、获取说明问题是否存在的信息以及获取答案的方法。
创建存储库类
让我们创建一个类,它将保存与数据库交互的所有方法:
-
在解决方案浏览器中,右键点击我们的项目,选择添加菜单,然后选择新建文件夹选项。
-
将在解决方案树中创建一个新文件夹。将文件夹命名为
Data。 -
右键点击
Data文件夹,选择添加菜单。然后,选择类。。。选项。 -
在出现的对话框中,输入
DataRepository作为文件名,然后单击添加按钮。 -
A skeleton
DataRepositoryclass will be created:![Figure 8.10 – Skeleton DataRepository class]()
图 8.10–骨架数据存储库类
-
现在,我们将为数据存储库创建一个接口,以便在编写单元测试时对其进行模拟。右键点击
Data文件夹,选择添加菜单。然后,选择类。。。选项。 -
这一次,在出现的对话框中选择界面选项,并将其命名为
IDataRepository,然后按下添加按钮。 -
Change the modifier for the interface to
publicand add the following methods:public interface IDataRepository { IEnumerable<QuestionGetManyResponse> GetQuestions(); IEnumerable<QuestionGetManyResponse> GetQuestionsBySearch(string search); IEnumerable<QuestionGetManyResponse> GetUnansweredQuestions(); QuestionGetSingleResponse GetQuestion(int questionId); bool QuestionExists(int questionId); AnswerGetResponse GetAnswer(int answerId); }在这里,我们将在数据存储库中有六个方法,它们将从我们的数据库中读取不同的数据位。请注意,由于我们引用的是不存在的类,所以这还不能编译。
-
回到
DataRepository.cs,指定类必须实现我们刚刚创建的接口:public class DataRepository: IDataRepository { } -
If we click on the class name, a light bulb icon will appear. Click on the light bulb menu and choose Implement interface:

图 8.11–自动实现 IDataRepository 接口
骨架方法将添加到满足接口的 repository 类中。
- Create a class-level private variable called
_connectionStringthat will store the database connection string:
```cs
public class DataRepository : IDataRepository
{
private readonly string _connectionString;
...
}
```
重要提示
`readonly`关键字防止在类构造函数之外更改变量,这就是我们在本例中想要的。
- Let's create a constructor for the repository class that will set the value of the connection string from the
appsettings.jsonfile:
```cs
public class DataRepository : IDataRepository
{
private readonly string _connectionString;
public DataRepository(IConfiguration configuration)
{
_connectionString =
configuration["ConnectionStrings:DefaultConnection"];
}
...
}
```
构造函数中的`configuration`参数允许我们访问`appsettings.json`文件中的项目。访问`configuration`对象时使用的键是从`appsettings.json`文件中指向所需项的路径,冒号用于在 JSON 中导航字段。
`configuration`参数是如何传递给构造函数的?答案是依赖注入,我们将在下一章中介绍。
- 我们班还不认识
IConfiguration,所以,让我们点击它,点击出现的灯泡菜单,然后使用 Microsoft.Extensions.Configuration 选择;:

图 8.12–引用 Microsoft.Extensions.Configuration 命名空间
我们在 repository 课程上有了一个良好的开端。我们确实有编译错误,但当我们完全实现这些方法时,这些错误就会消失。
创建存储库方法获取问题
我们先来实施的GetQuestions方法:
-
让我们在 Microsoft SQL 客户端库的文件顶部添加两个
using语句,以及简洁的:using Microsoft.Data.SqlClient;; using Dapper; -
In the
GetQuestionsmethod, overwrite the statement that throws aNotImplementedExceptionby declaring a new database connection:public IEnumerable<QuestionGetManyResponse> GetQuestions() { using (var connection = new SqlConnection(_connectionString)) { } }请注意,我们使用了一个
using块来声明数据库连接。重要提示
当程序退出块的作用域时,
using块自动处理块中定义的对象。这包括是否在块内调用了return语句,以及块内发生的错误。因此,
using语句是确保连接被处理的方便方法。请注意,我们使用的是来自 Microsoft SQL 客户机库的SqlConnection,因为这是 Dapper 库的扩展。 -
接下来,让我们打开连接:
public IEnumerable<QuestionGetManyResponse> GetQuestions() { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); } } -
Now, we can execute the query:
public IEnumerable<QuestionGetManyResponse> GetQuestions() { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); return connection.Query<QuestionGetManyResponse>( @"EXEC dbo.Question_GetMany" ); } }我们在
connection对象上使用了 Dapper 的Query扩展方法来执行Question_GetMany存储过程。然后,我们只需从方法返回此查询的结果。好简单!注意我们如何将类
QuestionGetManyResponse传递到Query方法的泛型参数中。这定义了查询结果应存储在其中的模型类。我们将在下一步中定义QuestionGetManyResponse。 -
在解决方案浏览器中,右键点击
Data文件夹,选择添加,然后选择新建文件夹选项。输入Models作为新文件夹的名称。我们将把所有的模型都放在这里。 -
在解决方案浏览器中,右键点击模型文件夹,选择添加。然后,选择类。。。选项。
-
在出现的对话框中,输入
QuestionGetManyResponse作为将要创建的文件的名称,然后单击添加按钮。将为我们创建一个骨架类。 -
Add the following properties to the class:
public class QuestionGetManyResponse { public int QuestionId { get; set; } public string Title { get; set; } public string Content { get; set; } public string UserName { get; set; } public DateTime Created { get; set; } }属性名称与从
Question_GetMany存储过程输出的字段匹配。这允许 Dapper 自动将数据库中的数据映射到此类。还仔细选择了属性类型,以便此简洁的映射过程能够正常工作。重要提示
请注意,该类不需要包含存储过程输出的所有字段的属性。Dapper 将忽略类中没有相应属性的字段。
-
回到
DataRepository.cs,添加using语句,以便类可以访问模型:using QandA.Data.Models; -
我们还要将这个
using语句添加到IDataRepository.cs:
```cs
using QandA.Data.Models;
```
中
祝贺您–我们已经实现了第一个存储库方法!这只包括几行代码,它们打开了数据库连接并执行了查询。这向我们展示了用 Dapper 编写数据访问代码是非常简单的。
创建存储库方法,通过搜索获取问题
让我们实现方法,类似于GetQuestions方法,但是这次方法和存储过程都有一个参数。让我们执行以下步骤:
-
开始创建和打开连接的方式与我们实现上一个方法时的方式相同:
public IEnumerable<QuestionGetManyResponse> GetQuestionsBySearch(string search) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); // TODO - execute Question_GetMany_BySearch stored // procedure } } -
现在,我们可以执行
Question_GetMany_BySearch存储过程:public IEnumerable<QuestionGetManyResponse> GetQuestionsBySearch(string search) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); return connection.Query<QuestionGetManyResponse>( @"EXEC dbo.Question_GetMany_BySearch @Search = @Search", new { Search = search } ); } }
注意我们如何传入存储过程参数值。
重要提示
参数值通过一个对象传递到 Dapper 查询,该对象的属性名与参数名匹配。然后 Dapper 将创建并执行一个参数化查询。
在本例中,我们使用一个匿名对象作为参数来保存我们为该对象定义的类。
为什么我们必须将参数传递给 Dapper?为什么我们不能做下面的事情?
return connection.Query<QuestionGetManyResponse>($"EXEC dbo.Question_GetMany_BySearch '{search}'");
嗯,有几个原因,但主要原因是前面的代码容易受到 SQL 注入攻击。因此,最好将参数传递到 Dapper 而不是中,而不是自己构造 SQL。
这是我们完成的第二个存储库方法。好简单!
创建存储库方法以获取未回答的问题
让我们来实现的GetUnansweredQuestions方法,它与GetQuestions方法非常相似:
public IEnumerable<QuestionGetManyResponse> GetUnansweredQuestions()
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
return connection.Query<QuestionGetManyResponse>(
"EXEC dbo.Question_GetUnanswered"
);
}
}
在这里,我们打开了连接,执行了Question_GetUnanswered存储过程,并在我们已经创建的QuestionGetManyResponse类中返回了结果。
创建存储库方法以获取单个问题
现在让我们实施的GetQuestion方法:
-
Start by opening the connection and executing the
Question_GetSinglestored procedure:public QuestionGetSingleResponse GetQuestion(int questionId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var question = connection.QueryFirstOrDefault< QuestionGetSingleResponse>( @"EXEC dbo.Question_GetSingle @QuestionId = @QuestionId", new { QuestionId = questionId } ); // TODO - Get the answers for the question return question; } }此方法与之前的方法稍有不同,因为我们使用
QueryFirstOrDefaultDapper 方法返回单个记录(如果未找到记录,则返回null,而不是记录集合。 -
我们需要执行第二个存储过程来获取问题的答案,现在我们就这样做:
public QuestionGetSingleResponse GetQuestion(int questionId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var question = connection.QueryFirstOrDefault< QuestionGetSingleResponse>( @"EXEC dbo.Question_GetSingle @QuestionId = @QuestionId", new { QuestionId = questionId } ); question.Answers = connection.Query<AnswerGetResponse>( @"EXEC dbo.Answer_Get_ByQuestionId @QuestionId = @QuestionId", new { QuestionId = questionId } ); return question; } } -
问题可能找不到,返回
null,所以我们来处理这个案例,只有找到问题时才添加答案:public QuestionGetSingleResponse GetQuestion(int questionId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var question = connection.QueryFirstOrDefault< QuestionGetSingleResponse>( @"EXEC dbo.Question_GetSingle @QuestionId = @QuestionId", new { QuestionId = questionId } ); if (question != null) { question.Answers = connection.Query<AnswerGetResponse>( @"EXEC dbo.Answer_Get_ByQuestionId @QuestionId = @QuestionId", new { QuestionId = questionId } ); } return question; } } -
Let's create the
QuestionGetSingleResponseclass we referenced in the method in a file calledQuestionGetSingleResponse.csin theModelsfolder:public class QuestionGetSingleResponse { public int QuestionId { get; set; } public string Title { get; set; } public string Content { get; set; } public string UserName { get; set; } public string UserId { get; set; } public DateTime Created { get; set; } public IEnumerable<AnswerGetResponse> Answers { get; set; } }这些属性与从
Question_GetSingle存储过程返回的数据匹配。 -
Let's also create the
AnswerGetResponseclass we referenced in the method in a file calledAnswerGetResponse.csin theModelsfolder:public class AnswerGetResponse { public int AnswerId { get; set; } public string Content { get; set; } public string UserName { get; set; } public DateTime Created { get; set; } }这些属性与从
Answer_Get_ByQuestionId存储过程返回的数据匹配。
GetQuestion方法现在应该编译良好。
创建存储库方法检查是否存在问题
现在,让我们按照与前面方法相同的方法来实现的方法:
public bool QuestionExists(int questionId)
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
return connection.QueryFirst<bool>(
@"EXEC dbo.Question_Exists @QuestionId =
@QuestionId",
new { QuestionId = questionId }
);
}
}
我们使用的是简洁的QueryFirst方法,而不是QueryFirstOrDefault,因为存储过程总是返回一条记录。
创建存储库方法以获取答案
本节我们将实施的最后一种方法是GetAnswer:
public AnswerGetResponse GetAnswer(int answerId)
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
return connection.QueryFirstOrDefault<
AnswerGetResponse>(
@"EXEC dbo.Answer_Get_ByAnswerId @AnswerId =
@AnswerId",
new { AnswerId = answerId }
);
}
}
这里没有什么新东西——实现遵循与前面的方法相同的模式。
我们现在已经实现了数据存储库中用于读取数据的所有方法。在下一节中,我们将把注意力转向编写数据。
使用简洁的方式写入数据
在本节中,我们将在数据存储库中实现写入数据库的方法。我们将首先扩展存储库的接口,然后进行实际实现。
执行写入操作的存储过程已在数据库中。我们将使用 Dapper 与这些存储过程交互。
添加向存储库接口写入数据的方法
我们将在开始时向存储库界面添加必要的方法:
public interface IDataRepository
{
...
QuestionGetSingleResponse
PostQuestion(QuestionPostRequest question);
QuestionGetSingleResponse
PutQuestion(int questionId, QuestionPutRequest
question);
void DeleteQuestion(int questionId);
AnswerGetResponse PostAnswer(AnswerPostRequest answer);
}
在这里,我们必须实现一些添加、更改和删除问题以及添加答案的方法。
创建存储库方法以添加新问题
让我们在DataRepository.cs中创建方法,添加一个新问题:
public QuestionGetSingleResponse PostQuestion(QuestionPostRequest question)
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
var questionId = connection.QueryFirst<int>(
@"EXEC dbo.Question_Post
@Title = @Title, @Content = @Content,
@UserId = @UserId, @UserName = @UserName,
@Created = @Created",
question
);
return GetQuestion(questionId);
}
}
这与读取数据的方法非常相似。我们使用的是QueryFirstDapper 方法,因为存储过程在将新问题插入数据库表后返回其 ID。我们的方法通过调用从Question_Post存储过程返回的带有questionId的GetQuestion方法来返回保存的问题。
我们已经为 Dapper 使用了一个名为QuestionPostRequest的模型类来映射到 SQL 参数。让我们在models文件夹中创建这个类:
public class QuestionPostRequest
{
public string Title { get; set; }
public string Content { get; set; }
public string UserId { get; set; }
public string UserName { get; set; }
public DateTime Created { get; set; }
}
好东西!这是我们创建的第一个 write 方法。
创建存储库方法以更改问题
让我们在DataRepository.cs中创建这个PutQuestion方法来改变一个问题。这与我们刚刚实现的PostQuestion方法非常相似:
public QuestionGetSingleResponse PutQuestion(int questionId, QuestionPutRequest question)
{
using (var connection = new SqlConnection(_connectionString))
{
connection.Open();
connection.Execute(
@"EXEC dbo.Question_Put
@QuestionId = @QuestionId, @Title = @Title,
@Content = @Content",
new { QuestionId = questionId, question.Title,
question.Content }
);
return GetQuestion(questionId);
}
}
请注意,我们使用的是 DapperExecute方法,因为我们只是执行一个存储过程,不返回任何内容。
我们已经从名为QuestionPutRequest的模型类和传递到方法中的questionId参数创建了 SQL 参数。让我们在models文件夹中创建QuestionPutRequest类:
public class QuestionPutRequest
{
public string Title { get; set; }
public string Content { get; set; }
}
这是实现的另一种方法。
创建存储库方法删除问题
继续,让我们实现一个删除问题的方法:
public void DeleteQuestion(int questionId)
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
connection.Execute(
@"EXEC dbo.Question_Delete
@QuestionId = @QuestionId",
new { QuestionId = questionId }
);
}
}
同样,我们使用的是 DapperExecute方法,因为存储过程没有返回任何内容。
创建存储库方法以添加答案
我们将要实施的最后一个方法将允许我们添加一个问题的答案:
public AnswerGetResponse PostAnswer(AnswerPostRequest answer)
{
using (var connection = new
SqlConnection(_connectionString))
{
connection.Open();
return connection.QueryFirst<AnswerGetResponse>(
@"EXEC dbo.Answer_Post
@QuestionId = @QuestionId, @Content = @Content,
@UserId = @UserId, @UserName = @UserName,
@Created = @Created",
answer
);
}
}
除了将答案插入数据库表之外,存储过程还返回保存的答案。这里,我们使用 DapperQueryFirst方法执行存储过程并返回保存的答案。
我们还需要在models文件夹中创建AnswerPostRequest模型类:
public class AnswerPostRequest
{
public int QuestionId { get; set; }
public string Content { get; set; }
public string UserId { get; set; }
public string UserName { get; set; }
public DateTime Created { get; set; }
}
完成了我们的数据存储库。我们选择了一个包含所有读写数据的方法的方法。当然,我们可以为数据库的不同区域创建多个存储库,如果应用更大,这将是一个好主意。
当我们向应用添加涉及数据库更改的功能时,我们需要一种机制来部署这些数据库更改。我们将在下一节中介绍这一点。
使用 DbUp 管理迁移
DbUp 是一个开放的源代码库,帮助我们将更改部署到 SQL Server 数据库。它跟踪 ASP.NET Core 项目中嵌入的 SQL 脚本,以及在数据库上执行的脚本。它包含一些方法,我们可以使用这些方法来执行尚未在数据库上执行的 SQL 脚本。
在本节中,我们将向项目中添加 DbUp,并将其配置为在应用启动时执行数据库迁移。
将 DbUp 安装到我们的项目中
让我们从在 Visual Studio 中的后端项目中执行以下步骤开始安装 DbUp:
- 进入工具菜单,然后进入NuGet Package Manager。然后,选择管理解决方案的 NuGet 包。。。。
- 在浏览选项卡上,在搜索框中输入
DbUp。 - 选择 Paul Stovell、Jim Burger、Jake Ginnivan 和 Damian Maclennan 的dbup套餐。勾选我们的项目,点击安装按钮,选择最新稳定版本:

图 8.13–在 NuGet Manager 中添加 DbUp
在我们的项目中下载和安装 DbUp 之前,可能会要求我们接受许可协议。
配置 DbUp 以在应用启动时执行迁移
现在我们已经在项目中安装了 DbUp,让它在应用启动时进行数据库迁移:
-
打开
Startup.cs。从第 1 章了解 ASP.NET 5 React 模板可知,当 ASP.NET Core 应用运行时,此文件中的代码执行。我们将首先添加一个using语句,以便可以引用DbUp库:using DbUp; -
At the top of the
ConfigureServicesmethod, add the following two lines:public void ConfigureServices(IServiceCollection services) { var connectionString = Configuration.GetConnectionString("DefaultConnection"); EnsureDatabase.For.SqlDatabase(connectionString); // TODO - Create and configure an instance of the // DbUp upgrader // TODO - Do a database migration if there are any // pending SQL //Scripts ... }这将从
appsettings.json文件获取数据库连接,并在数据库不存在时创建数据库。 -
Let's create and configure an instance of the
DbUpupgrader:public void ConfigureServices(IServiceCollection services) { var connectionString = Configuration.GetConnectionString("DefaultConnection"); EnsureDatabase.For.SqlDatabase(connectionString); var upgrader = DeployChanges.To .SqlDatabase(connectionString, null) .WithScriptsEmbeddedInAssembly( System.Reflection.Assembly.GetExecutingAssembly() ) .WithTransaction() .Build(); // TODO - Do a database migration if there are any pending SQL //Scripts ... }我们已经告诉
DbUp数据库在哪里,并寻找嵌入到我们项目中的 SQL 脚本。我们还告诉DbUp在事务中进行数据库迁移。 -
最后一步是,如果存在任何挂起的 SQL 脚本,则让
DbUp执行数据库迁移:public void ConfigureServices(IServiceCollection services) { var connectionString = Configuration.GetConnectionString("DefaultConnection"); EnsureDatabase.For.SqlDatabase(connectionString); var upgrader = DeployChanges.To .SqlDatabase(connectionString, null) .WithScriptsEmbeddedInAssembly( System.Reflection.Assembly.GetExecutingAssembly() ) .WithTransaction() .LogToConsole() .Build(); if (upgrader.IsUpgradeRequired()) { upgrader.PerformUpgrade(); } ... }
我们在DbUp升级中使用IsUpgradeRequired方法检查是否有挂起的 SQL 脚本,并使用PerformUpgrade方法进行实际迁移。
在我们的项目中嵌入 SQL 脚本
在前面的小节中,我们告诉 DbUp 寻找嵌入到我们项目中的 SQL 脚本。现在,我们将在项目中嵌入表和存储过程的 SQL 脚本,以便 DbUp 在应用加载时执行尚未执行的 SQL 脚本:
-
在解决方案资源管理器中,右键点击项目,选择添加【新文件夹】。输入
SQLScripts作为文件夹名称。 -
右键点击
SQLScripts文件夹,选择添加|新项目。。。。 -
In the dialog box that appears, select the General tab and then Text File and enter
01-Tables.sqlas the filename:![Figure 8.14 – Adding a SQL file to a Visual Studio project]()
图 8.14–将 SQL 文件添加到 Visual Studio 项目
-
从复制脚本的内容 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition/blob/master/chapter-08/start/backend/SQLScripts/01-Tables.sql 并将其粘贴到我们刚刚创建的文件中。
-
右键点击解决方案资源管理器中的01-Tables.sql,选择属性查看该文件的属性。
-
Change the Build Action property to Embedded resource:
![Figure 8.15 – Changing a file to an embedded resource]()
图 8.15–将文件更改为嵌入式资源
这将在我们的项目中嵌入 SQL 脚本,以便
DbUp可以找到它。 -
Let's repeat this process for the stored procedures by creating a file called
02-Sprocs.sqlin theSQLScriptsfolder with the content from https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition/blob/master/chapter-08/start/backend/SQLScripts/02-Sprocs.sql. Let's not forget to embed this file as a project resource.重要提示
DbUp将按名称顺序运行 SQL 脚本,因此有一个脚本命名约定来满足这一点很重要。在我们的示例中,我们使用两位数作为脚本名称的前缀。
因此,这些是构成我们数据库的 SQL 脚本。它们已保存在我们的项目中。
执行数据库迁移
既然数据库迁移代码已经就绪,现在是测试迁移的时间了。为此,我们将删除数据库表和存储过程,并期望在 API 运行时重新创建它们。
让我们执行以下步骤:
-
The database that we are working with already contains the tables and stored procedures in our scripts, so we are going to be brave and delete our database. In SSMS, in Object Explorer, right-click the database and choose Delete:
![Figure 8.16 – Deleting a database]()
图 8.16–删除数据库
-
We are going to create the database again with the same name. So, in Object Explorer, right-click on Databases and click on the New Database... option. Enter
QandAfor the name of the database and click OK:![Figure 8.17 – Adding a database]()
图 8.17–添加数据库
-
返回 Visual Studio 中的,按F5运行应用。
-
Once the app has started, go to SSMS. In Object Explorer, we'll see that the tables and stored procedures have been created. We'll also see a table called
SchemaVersions:![Figure 8.18 – The SchemaVersions table in Object Explorer]()
图 8.18–对象资源管理器中的 SchemaVersions 表
-
Right-click on dbo.SchemaVersions and choose Edit Top 200 Rows:
![Figure 8.19 – SchemaVersions data]()
图 8.19–方案规避数据
这是 DbUp 用来管理已执行脚本的表。因此,我们将在这个表中看到我们的两个脚本。
-
返回 Visual Studio,按Shift+F5停止应用。
-
再次运行应用。该应用将运行良好。
-
检查 SSMS 中对象浏览器中的数据库对象。对象将保持不变。
-
检查
SchemaVersions表的内容。我们将发现没有添加新脚本。 -
我们现在可以在 Visual Studio 中再次停止该应用。
有了这些,我们的项目就可以处理数据库迁移了。我们需要做的就是在SQLScripts文件夹中添加必要的 SQL 脚本文件,记住将它们作为资源嵌入。当应用再次运行时,DbUp 将执行迁移。
总结
我们现在了解到 Dapper 是一种以性能方式与数据库交互的简单方式。当我们的团队已经具备 SQL Server 技能时,这是一个很好的选择,因为它不会将数据库从我们身边抽象出来。
在本章中,我们了解到 Dapper 向 MicrosoftSqlConnection对象添加了各种扩展方法,用于读取和写入数据库。Dapper 通过将查询结果中的字段名与类属性匹配,自动将查询结果映射到 C#类的实例。查询参数可以使用 C#类传入,Dapper 会自动将 C#类中的属性映射到 SQL 参数。
然后我们发现DbUp是一个简单的开源工具,可用于管理数据库迁移。我们可以在项目中嵌入 SQL 脚本,并编写应用加载时执行的代码,以指示 DbUp 检查并执行任何必要的迁移。
在下一章中,我们将利用本章中编写的数据访问代码为我们的应用创建 RESTful API。
问题
回答以下问题以测试您从本章中获得的知识:
-
哪种简洁的方法可以用来执行不返回结果的存储过程?
-
在保证记录存在的情况下,可以使用哪种简洁的方法读取单个数据记录?
-
哪种简洁的方法可以用来读取一组记录?
-
下面的语句调用了 Dapper
Query方法,有什么不对?return connection.Query<BuildingGetManyResponse>( @"EXEC dbo.Building_GetMany_BySearch @Search = @Search", new { Criteria = "Fred"} ); -
We have the following stored procedure:
CREATE PROC dbo.Building_GetMany AS BEGIN SET NOCOUNT ON SELECT BuildingId, Name FROM dbo.Building END我们有以下语句,它调用 Dapper
Query方法:return connection.Query<BuildingGetManyResponse>( "EXEC dbo.Building_GetMany" );我们还有以下模型,在前面的语句中引用了该模型:
public class BuildingGetManyResponse { public int Id{ get; set; } public string Name { get; set; } }当我们的应用运行时,我们发现
BuildingGetManyResponse类实例中的Id属性没有填充。你能发现问题吗? -
DbUp 可以用于在表中部署新的引用数据吗?
答案
Execute是执行存储过程的简洁方法,不返回任何结果。QueryFirst是一种简洁的方法,用于读取保证存在记录的单个数据记录。Query是读取记录集合的简洁方法。- 查询的问题是它需要一个名为
Search的参数,但我们已经向它传递了一个名为Criteria的参数。因此,Dapper 将无法映射 SQL 参数。 - 问题是存储过程返回一个名为
BuildingId的字段,该字段不会自动映射到类中的Id属性,因为名称不同。 - 对
DbUp可以执行任意 SQL 脚本,也可以为表部署新的引用数据。
进一步阅读
如果您希望了解有关本章所涵盖主题的更多信息,请访问以下有用的链接:
- 创建 SQL Server 数据库:https://docs.microsoft.com/en-us/sql/relational-databases/databases/create-a-database
- 创建 SQL Server 表:https://docs.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql
- 创建 SQL Server 存储过程:https://docs.microsoft.com/en-us/sql/relational-databases/stored-procedures/create-a-stored-procedure
- C#
using声明https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-statement - 整洁:https://github.com/StackExchange/Dapper
- DbUp:https://dbup.readthedocs.io/en/latest/
九、创建 REST API 端点
在第 1 章理解 ASP.NET 5 React 模板中,我们了解到 RESTful 端点是使用 ASP.NET 中的 API 控制器实现的。在本章中,我们将为我们的 Q&A 应用实现一个 API 控制器,它最终将允许前端读写问题和答案。我们将实现一系列控制器操作方法,这些方法处理返回适当响应的不同 HTTP 请求方法。
我们将学习依赖注入,并使用它将我们在前一章中创建的数据存储库注入 API 控制器。我们将验证请求,以便在数据到达数据存储库之前确保数据有效。
在本章的最后,我们将确保在 API 请求中不会要求不必要的信息。这将防止潜在的安全问题,并改善 API 消费者的体验。
在本章中,我们将介绍以下主题:
- 创建 API 控制器
- 创建控制器操作方法
- 添加模型验证
- 删除不必要的请求字段
技术要求
在本章中,我们将使用以下工具:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET 代码。可从下载 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- 邮递员:我们将使用它来尝试在本章中实现的 REST API 端点。可从下载 https://www.getpostman.com/downloads/ 。
- Q&A:我们将从上一章中完成的 Q&A 后端项目开始。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-09/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中恢复代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可以在终端中输入npm install来恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/34xLwzq 。
创建 API 控制器
API 控制器是一个类,它处理 REST API 中端点的 HTTP 请求,并将响应发送回调用方。
在本节中,我们将创建一个 API 控制器来处理对api/questions端点的请求。控制器将调用我们在上一章中创建的数据存储库。我们还将使用依赖项注入在 API 控制器中创建数据存储库的实例。
为问题创建 API 控制器
让我们为api/questions端点创建一个控制器。如果我们没有在 Visual Studio 中打开后端项目,让我们这样做并执行以下步骤:
-
在解决方案浏览器中,右键点击
Controllers文件夹,选择添加,然后点击类。。。。 -
In the left-hand panel, find and select ASP.NET Core and then API Controller - Empty in the middle panel. Enter
QuestionsController.csfor the name of the file and click Add:![Figure 9.1 – Creating a new API controller]()
图 9.1–创建新的 API 控制器
-
A
QuestionsController.csfile will appear in theControllersfolder in Solution Explorer. Open this file by double-clicking on it in Solution Explorer. The file will contain the following class in theQandA.Controllersnamespace:[Route("api/[controller]")] [ApiController] public class QuestionsController : ControllerBase { }Route属性定义控制器将处理的路径。在我们的例子中,路径将是api/questions,因为[controller]被替换为控制器名称减去单词Controller。ApiController属性包括诸如自动模型验证之类的行为,我们将在本章后面利用这些行为。该类也继承自
ControllerBase。这使我们能够在控制器中访问更多特定于 API 的方法。
接下来,我们将学习如何从 API 控制器与数据存储库交互。
将数据存储库注入 API 控制器
我们希望与我们在上一章中创建的数据存储库的实例交互,并将其放入我们的 API 控制器中。让我们执行以下步骤来执行此操作:
-
我们首先将
using语句添加到QuestionsController.cs文件中,以便可以引用数据存储库及其模型。在自动生成的using语句下添加以下语句:using QandA.Data; using QandA.Data.Models; -
Create a
privateclass-level variable to hold a reference to our repository:[Route("api/[controller]")] [ApiController] public class QuestionsController : ControllerBase { private readonly IDataRepository _dataRepository; }我们使用了
readonly关键字来确保变量的引用不会在构造函数之外发生更改。 -
Let's create the constructor as follows beneath the
_dataRepositoryvariable declaration:private readonly IDataRepository _dataRepository; public QuestionsController() { // TODO - set reference to _dataRepository }我们需要在构造函数中设置对
_dataRepository的引用。我们可以尝试以下方法:public QuestionsController() { _dataRepository = new DataRepository(); }但是,
DataRepository构造函数要求传入连接字符串。回想一下,我们在上一章中使用了一种称为依赖项注入的方法,将configuration对象注入到数据存储库构造函数中,让我们能够访问连接字符串。也许我们可以使用依赖注入将数据存储库注入我们的 API 控制器?是的,这正是我们要做的。重要提示
依赖注入是将一个类的实例注入另一个对象的过程。依赖项注入的目标是将类与其依赖项解耦,以便在不更改类的情况下更改依赖项。ASP.NET 有自己的依赖项注入功能,允许在应用启动时定义类依赖项。然后,这些依赖项可以被注入到其他类构造函数中。
-
Change the constructor to the following:
public QuestionsController(IDataRepository dataRepository) { _dataRepository = dataRepository; }因此,我们的构造函数现在期望数据存储库作为参数传递给构造函数。然后,我们只需将私有类级别变量设置为传入的数据存储库。
与注入到数据存储库中的
configuration对象不同,数据存储库不会自动用于依赖项注入。ASP.NET 已经为我们设置了依赖注入的configuration对象,因为它负责这个类。然而,DataRepository是我们的类,所以我们必须注册它以进行依赖注入。 -
让我们转到
startup.cs并添加using语句,以便我们可以引用我们的数据存储库。在已有的using语句后增加以下语句:using QandA.Data; -
Enter the following highlighted line at the bottom of the
ConfigureServicesclass to make the data repository available for dependency injection:public void ConfigureServices(IServiceCollection services) { ... services.AddScoped<IDataRepository, DataRepository>(); }这告诉 ASP.NET,只要构造函数中引用了
IDataRepository,就替换DataRepository类的实例。重要提示
AddScoped方法意味着在给定的 HTTP 请求中只创建DataRepository类的一个实例。这意味着创建的类的生存期将持续整个 HTTP 请求。
因此,如果 ASP.NET 在同一 HTTP 请求中遇到引用IDataRepository的第二个构造函数,它将使用之前创建的DataRepository类的实例。
重要提示
除了AddScoped之外,还有其他用于注册依赖项的方法,这些方法会导致生成的类的不同生存期。AddTransient将在每次请求时生成该类的新实例。AddSingleton在整个应用的生命周期内只生成一个类实例。
总而言之,我们可以使用依赖注入将依赖类实例注入到 API 控制器的构造函数中。依赖项注入中使用的类需要在StartUp类的ConfigureServices方法中注册。
因此,在依赖项注入的帮助下,我们现在可以访问 API 控制器中的数据存储库。接下来,我们将实现处理特定 HTTP 请求的方法。
创建控制器动作方法
操作方法是我们可以编写代码来处理对资源的请求的地方。在本节中,我们将实现处理对问题资源的请求的操作方法。我们将介绍GET、POST、PUT和DELETEHTTP 方法。
创建获取问题的行动方法
让我们实现我们的第一个动作方法,它将返回所有问题的数组。打开QuestionsController.cs并执行以下步骤:
-
Let's create a method called
GetQuestionsat the bottom of theQuestionsControllerclass:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions() { // TODO - get questions from data repository // TODO - return questions in the response }我们用
HttpGet属性修饰该方法,告诉 ASP.NET 它将处理对该资源的 HTTPGET请求。我们使用特定的
IEnumerable<QuestionGetManyResponse>类型作为返回类型。 -
我们可以使用
GetQuestions方法从数据存储库中获取问题,如下所示:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions() { var questions = _dataRepository.GetQuestions(); // TODO - return questions in the response } -
Let's return the questions in the response:
[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions() { var questions = _dataRepository.GetQuestions(); return questions; }ASP.NET 将自动将
questions对象转换为 JSON 格式,并将其放入响应正文中。也会自动返回200作为 HTTP 状态码。美好的 -
让我们先在 VisualStudio 中按下F5来启动我们的应用。
-
In the browser that opens, change the path to end with
api/questions:![Figure 9.2 – Getting all questions]()
图 9.2–获取所有问题
我们将看到 JSON 格式的数据库输出中的问题。太好了,这是我们实施的第一个行动方法!
-
We are going to change the default path that invokes when the app is run to the
api/questionspath in the next step. First, we need to make sure thePropertiesfolder is available in Solution Explorer. If thePropertiesfolder isn't visible in Solution Explorer, then switch to Show All Files in the toolbar:![Figure 9.3 – Show all files in Solution Explorer]()
图 9.3–显示解决方案资源管理器中的所有文件
-
打开解决方案资源管理器中
Properties文件夹中的文件,将launchUrl字段更改为api/questions:... "profiles": { "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, "launchUrl": "api/questions", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "QandA": { "commandName": "Project", "launchBrowser": true, "launchUrl": "api/questions", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } ... -
按Shift+F5停止应用,然后按F5再次启动。我们的
api/questions路径现在将在浏览器中默认调用。 -
再次按Shift+F5停止应用。现在,我们准备在下一个任务中实现更多代码。
这就完成了将处理到api/questions的GET请求的操作方法。
总之,GET请求操作方法有一个HttpGet属性修饰符。方法的返回类型是我们希望在响应体中使用的数据类型,它会自动为我们转换为 JSON。
在下面的小节中,我们将继续实现更多 HTTP 方法的处理程序。
扩展 GetQuestions 动作方法进行搜索
我们并不总是希望所有的问题都返回到api/questions端点。回想一下,我们的前端有一个搜索功能,返回符合搜索条件的问题。让我们扩展我们的GetQuestions方法来处理搜索请求。为此,请执行以下步骤:
-
在
GetQuestions方法[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions(string search) { var questions = _dataRepository.GetQuestions(); return questions; }中增加
search参数 -
Put a breakpoint on the statement that gets the questions from the repository and press F5 to run the app:
![Figure 9.4 – Model binding with no search query parameter]()
图 9.4–无搜索查询参数的模型绑定
我们将看到搜索参数是
null。按F5让应用继续。 -
With the breakpoint still in place, change the URL in the browser to end with
questions?search=type:![Figure 9.5 – Model binding with a search query parameter value]()
图 9.5–带有搜索查询参数值的模型绑定
这次将
search参数设置为我们在浏览器 URL 中输入的search查询参数的值。这个过程被称为模型绑定。重要提示
模型绑定是 ASP.NET 中的一个进程,它将数据从 HTTP 请求映射到操作方法参数。查询参数中的数据将自动映射到具有相同名称的操作方法参数。我们将在本节后面看到,模型绑定还可以映射来自 HTTP 请求体的数据。因此,可以在 action method 参数前面放置一个
[FromQuery]属性,以指示 ASP.NET 仅从查询参数进行映射。 -
现在按Shift+F5停止运行 app。
-
Let's branch our code on whether the
searchparameter contains a value and get and return all the questions if it doesn't. Replace the code insideGetQuestionswith the following highlighted code:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions(string search) { if (string.IsNullOrEmpty(search)) { return _dataRepository.GetQuestions(); } else { // TODO - call data repository question search } }如果没有搜索值,我们会像以前一样获取并返回所有问题,但这次是在一个语句中。
-
如果我们有一个
search值:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions(string search) { if (string.IsNullOrEmpty(search)) { return _dataRepository.GetQuestions(); } else { return _dataRepository.GetQuestionsBySearch(search); } },那么让我们添加对数据存储库问题搜索方法的调用
-
Let's run the app and give this a try. All the questions will be returned in the browser when it opens up. Let's add a
searchquery parameter with a value oftype:![Figure 9.6 – Searching questions]()
图 9.6–搜索问题
我们将看到 TypeScript 问题像我们预期的那样返回。
-
按Shift+F5停止应用运行,这样我们可以为下一个任务编写更多代码。
我们已经开始利用 ASP.NET 中的模型绑定。模型绑定自动将请求中的查询参数绑定到操作方法参数。在本章中,我们将继续使用模型绑定。
创建获取未回答问题的行动方法
回想一下,在第三章**React 和 TypeScript入门中,我们应用的主屏幕显示了未回答的问题。我们将创建一个操作方法来处理api/questions/unanswered路径并返回未回答的问题。按照这里给出的步骤操作。
*让我们实现一个提供此功能的操作方法:
-
Add the following action method below the
GetQuestionsaction method:[HttpGet("unanswered")] public IEnumerable<QuestionGetManyResponse> GetUnansweredQuestions() { return _dataRepository.GetUnansweredQuestions(); }实现只需调用数据存储库
GetUnansweredQuestions方法并返回结果。请注意,
HttpGet属性包含字符串"unanswered"。这是连接到控制器根路径的附加路径。因此,此操作方法将处理对api/questions/unanswered路径的GET请求。 -
Let's give this a try by running the app in a browser by entering the
api/questions/unansweredpath:![Figure 9.7 – Unanswered questions]()
图 9.7–未回答的问题
我们得到了一个关于国家管理的未回答的问题。
-
按Shift+F5停止我们应用的运行,这样我们就可以编写另一个动作方法了。
这就完成了对api/questions/unanswered的GET请求进行处理的动作方法的实现。要在 action 方法中处理子路径,我们在HttpGet属性参数中传递子路径。
创建获取单个问题的行动方法
让我们继续实现获取单个问题的行动方法。为此,请执行以下步骤:
-
Add the following skeleton method underneath the
GetUnansweredQuestionsmethod:[HttpGet("{questionId}")] public ActionResult<QuestionGetSingleResponse> GetQuestion(int questionId) { // TODO - call the data repository to get the // question // TODO - return HTTP status code 404 if the // question isn't found // TODO - return question in response with status // code 200 }注意
HttpGet属性参数。重要提示
花括号告诉 ASP.NET 将端点子路径放入可作为方法参数引用的变量中。
在此方法中,
questionId参数将设置为端点上的子路径。因此,对于api/questions/3路径,questionId将被设置为3。请注意,返回类型是
ActionResult<QuestionGetSingleResponse>,而不仅仅是QuestionGetSingleResponse。这是因为我们的动作方法不会返回QuestionGetSingleResponse在所有情况下,当问题找不到时,会有一个案例返回NotFoundResult。ActionResult使我们能够灵活地返回这些不同的类型。 -
让我们打电话到存储库获取问题:
[HttpGet("{questionId}")] public ActionResult<QuestionGetSingleResponse> GetQuestion(int questionId) { var question = _dataRepository.GetQuestion(questionId); // TODO - return HTTP status code 404 if the // question isn't found // TODO - return question in response with status // code 200 } -
Next, we can check whether the question has been found and return HTTP status code
404if it hasn't:[HttpGet("{questionId}")] public ActionResult<QuestionGetSingleResponse> GetQuestion(int questionId) { var question = _dataRepository.GetQuestion(questionId); if (question == null) { return NotFound(); } // TODO - return question in response with status // code 200 }如果未找到问题,则存储库调用的结果将为
null。因此,我们检查中的null并返回对ControllerBase中的NotFound方法的调用,该方法返回 HTTP 状态码404。 -
The last implementation step is to
returnthe question that has been found:[HttpGet("{questionId}")] public ActionResult<QuestionGetSingleResponse> GetQuestion(int questionId) { var question = _dataRepository.GetQuestion(questionId); if (question == null) { return NotFound(); } return question; }这将导致在响应中返回 HTTP 状态代码
200,并在响应正文中返回 JSON 格式的问题。 -
Let's give this a try by running the app and requesting question
1:![Figure 9.8 – Getting a question]()
图 9.8–获取问题
问题按预期返回。
-
Let's try requesting a question that doesn't exist by putting
1000as the requested question number:![Figure 9.9 – Requesting a question that doesn't exist]()
图 9.9–请求一个不存在的问题
我们可以通过按F12打开 DevTools 并查看网络面板来确认返回了
404状态码。 -
停止我们的应用运行,以便我们准备实施另一个操作方法。
完成了获取问题的动作方法。
现在我们了解到,端点子路径参数可以通过将参数名放在 HTTP 方法属性装饰器的花括号内来实现。我们还了解到在ControllerBase中有一个方便的NotFound方法,它返回一个 HTTP 状态码404,我们可以将其用于请求的不存在的资源。
我们已经实现了一系列处理GET请求的操作方法。接下来是为其他 HTTP 方法实现操作方法的时候了。
创建用于发布问题的操作方法
让我们实现一个发布问题的操作方法:
-
We'll start with the skeleton method. Add the following after the
GetQuestionmethod:[HttpPost] public ActionResult<QuestionGetSingleResponse> PostQuestion(QuestionPostRequest questionPostRequest) { // TODO - call the data repository to save the // question // TODO - return HTTP status code 201 }注意,我们使用一个
HttpPost属性告诉 ASP.NET 该方法处理 HTTPPOST请求。注意,
questionPostRequest的方法参数类型是一个类而不是一个基元类型。前面,在扩展用于搜索的 GetQuestions 操作方法一节中,我们介绍了模型绑定,并解释了它如何将 HTTP 请求中的数据映射到方法参数。那么,模型绑定可以映射来自 HTTP 主体的数据以及查询参数。模型绑定也可以映射到参数中的属性。这意味着 HTTP 请求主体中的数据将映射到QuestionPostRequest类实例中的属性。 -
让我们将呼叫到数据存储库中发布问题:
[HttpPost] public ActionResult<QuestionGetSingleResponse> PostQuestion(QuestionPostRequest questionPostRequest) { var savedQuestion = _dataRepository. PostQuestion(questionPostRequest); // TODO - return HTTP status code 201 } -
The last step in the implementation is to return status code
201to signify that the resource has been created:[HttpPost] public ActionResult<QuestionGetSingleResponse> PostQuestion(QuestionPostRequest questionPostRequest) { var savedQuestion = _dataRepository.PostQuestion(questionPostRequest); return CreatedAtAction(nameof(GetQuestion), new { questionId = savedQuestion.QuestionId }, savedQuestion); }我们从
ControllerBase返回对CreatedAtAction的呼叫,该呼叫将返回状态代码201,并在响应中包含问题。此外,它还包括一个LocationHTTP 头,其中包含获取问题的路径。 -
让我们试试这个。首先,我们将通过按F5来运行应用。
-
This time we'll use Postman to check whether the action method is working. Postman is a great tool for testing REST APIs. Open Postman and create a new request by clicking the + icon on the tabs bar:
![Figure 9.10 – Creating a new request]()
图 9.10–创建新请求
-
Set the HTTP method to
POSTand enter the path to the questions resource:![Figure 9.11 – Setting the HTTP method and path in Postman]()
图 9.11–在 Postman 中设置 HTTP 方法和路径
-
Go to the Body tab, select raw, and then select JSON to specify the request body type:
![Figure 9.12 – Setting the request body type to JSON in Postman]()
图 9.12–在 Postman 中将请求主体类型设置为 JSON
-
Enter the request body in the box provided, as shown in the following screenshot:
![Figure 9.13 – Adding the request body in Postman]()
图 9.13–在 Postman 中添加请求正文
-
Click the Send button to send the request and look at the response panel underneath the request body:
![Figure 9.14 – Response body from posting a question]()
图 9.14–发布问题的回复正文
返回预期的
201HTTP 状态码,并在响应中保存问题。注意响应中的问题如何生成
questionId,这对消费者在与问题交互时非常有用。 -
If we look at the response headers, we can see that ASP.NET has also included a
LocationHTTP header that contains the path to get the question:

图 9.15–发布问题的回复标题
这是一个很好的触摸。
- 停止我们的应用运行,以便我们准备实施另一个操作方法。
这就完成了操作方法的实现,该操作方法将处理对api/questions的POST请求。
我们使用HttpPost属性装饰器来允许操作方法处理POST请求。在 action 方法的 return 语句中,从ControllerBase执行CreatedAtAction方法将自动添加一个包含获取资源路径的 HTTP 位置头,并将 HTTP 状态码201添加到响应中。
创建更新问题的动作方法
让我们继续更新一个问题。为此,请执行以下步骤:
-
Add the following skeleton for the action method:
[HttpPut("{questionId}")] public ActionResult<QuestionGetSingleResponse> PutQuestion(int questionId, QuestionPutRequest questionPutRequest) { // TODO - get the question from the data // repository // TODO - return HTTP status code 404 if the // question isn't found // TODO - update the question model // TODO - call the data repository with the // updated question model to update the question // in the database // TODO - return the saved question }我们使用
HttpPut属性告诉 ASP.NET 该方法处理 HTTPPUT请求。我们还在questionId方法参数中加入问题 ID 的路由参数。ASP.NET 模型绑定将从 HTTP 请求主体填充
QuestionPutRequest类实例。 -
让我们从数据存储库中获取问题,如果没有找到该问题,则返回 HTTP 状态代码
404:[HttpPut("{questionId}")] public ActionResult<QuestionGetSingleResponse> PutQuestion(int questionId, QuestionPutRequest questionPutRequest) { var question = _dataRepository. GetQuestion(questionId); if (question == null) { return NotFound(); } // TODO - update the question model // TODO - call the data repository with the // updated question //model to update the question in the database // TODO - return the saved question } -
Now let's update the
questionmodel:[HttpPut("{questionId}")] public ActionResult<QuestionGetSingleResponse> PutQuestion(int questionId, QuestionPutRequest questionPutRequest) { var question = _dataRepository. GetQuestion(questionId); if (question == null) { return NotFound(); } questionPutRequest.Title = string.IsNullOrEmpty(questionPutRequest.Title) ? question.Title : questionPutRequest.Title; questionPutRequest.Content = string.IsNullOrEmpty(questionPutRequest.Content) ? question.Content : questionPutRequest.Content; // TODO - call the data repository with the // updated question model to update the question // in the database // TODO - return the saved question }如果请求中没有提供现有问题的数据,我们使用三元表达式更新请求模型。
重要提示
允许 API 的使用者只提交需要更新的信息(而不是完整记录),使我们的 API 易于使用。
-
实现中的最后步骤是调用数据存储库更新问题,然后在响应中返回保存的问题:
[HttpPut("{questionId}")] public ActionResult<QuestionGetSingleResponse> PutQuestion(int questionId, QuestionPutRequest questionPutRequest) { var question = _dataRepository.GetQuestion(questionId); if (question == null) { return NotFound(); } questionPutRequest.Title = string.IsNullOrEmpty(questionPutRequest.Title) ? question.Title : questionPutRequest.Title; questionPutRequest.Content = string.IsNullOrEmpty(questionPutRequest.Content) ? question.Content : questionPutRequest.Content; var savedQuestion = _dataRepository.PutQuestion(questionId, questionPutRequest); return savedQuestion; } -
让我们通过运行应用和使用 Postman 来尝试一下。创建一个新请求,并将 HTTP 方法设置为
PUT,然后输入我们需要的问题 3 的路径 -
recently added:
![Figure 9.16 – PUT request path]()
图 9.16–PUT 请求路径
-
进入主体页签,选择原始,然后选择JSON指定请求主体类型。
-
Enter the request body in the box provided, as shown in the following screenshot:
![Figure 9.17 – PUT request body]()
图 9.17–PUT 请求正文
因此,我们要求用我们提供的新内容更新问题 3。
-
Click the Send button to send the request:
![Figure 9.18 – PUT response body]()
图 9.18–PUT 响应主体
正如我们预期的那样,问题得到了更新。
-
停止我们的应用运行,以便我们准备实施另一个操作方法。
我们实现的PutQuestion操作方法可以说是PATCH请求的处理程序,因为它不需要提交完整记录。要处理PATCH请求,HttpPut属性修饰符可以更改为HttpPatch。请注意,正确处理PATCH请求需要NewtonsoftJsonNuGet 包并注册特殊的输入格式化程序。更多信息请访问https://docs.microsoft.com/en-us/aspnet/core/web-api/jsonpatch 。
为了同时处理PUT和PATCH请求,该方法可以同时使用HttpPut和HttpPatch属性修饰符进行修饰。我们将只使用HttpPut保留我们的实现。
这就完成了操作方法的实现,该操作方法将处理对api/questions的PUT请求。
创建删除问题的动作方法
让我们执行删除一个问题。这遵循与前面方法类似的模式:
-
We'll add the action method in a single step as it's similar to what we've done before:
[HttpDelete("{questionId}")] public ActionResult DeleteQuestion(int questionId) { var question = _dataRepository.GetQuestion(questionId); if (question == null) { return NotFound(); } _dataRepository.DeleteQuestion(questionId); return NoContent(); }我们使用
HttpDelete属性告诉 ASP.NET 该方法处理 HTTPDELETE请求。该方法希望问题 ID 包含在路径的末尾。该方法在删除问题之前检查问题是否存在,如果不存在,则返回 HTTP
404状态码。如果删除成功,则返回 HTTP 状态码
204。 -
Let's try this out by running the app and using Postman. Set the HTTP method to
DELETEand enter the path to question 3. Click the Send button to send the request:![Figure 9.19 – DELETE request]()
图 9.19–删除请求
按预期返回 HTTP 状态代码为
204的响应。 -
停止我们的应用运行,以便我们准备好实施最终操作方法。
这就完成了操作方法的实现,该操作方法将处理对api/questions的DELETE请求。
创建发布答案的操作方法
我们将要实施的最终行动方法是发布问题答案的方法:
-
This method will handle an HTTP
POSTrequest to theapi/question/answerpath:[HttpPost("answer")] public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest) { var questionExists = _dataRepository.QuestionExists( answerPostRequest.QuestionId); if (!questionExists) { return NotFound(); } var savedAnswer = _dataRepository.PostAnswer(answerPostRequest); return savedAnswer; }该方法检查问题是否存在,如果不存在,则返回一个
404HTTP 状态码。然后将答案传递到数据存储库以插入数据库。保存的答案将从数据存储库返回,并在响应中返回。另一种方法是将
questionId放入 URL(api/question/{questionId}/answer),而不是请求主体中。这可以通过将 decorator 和方法签名更改为以下内容来实现:[HttpPost("{questionId}/answer")] public ActionResult<AnswerGetResponse> PostAnswer(int questionId, AnswerPostRequest answerPostRequest)QuestionId属性也可以从AnswerPostRequest模型中删除。 -
Let's try this out by running the app and using Postman. Set the HTTP method to
POSTand enter theapi/questions/answerpath. Add a request body containing an answer for question 1 and then click the Send button to send the request:![Figure 9.20 – Submitting an answer]()
图 9.20–提交答案
答案将按预期保存并在响应中返回。
-
Remove the
contentfield from the request body and try sending the request again. An error occurs in the data repository when the request is sent:![Figure 9.21 – SQL error when adding an answer with no content]()
图 9.21–添加没有内容的答案时的 SQL 错误
这是因为存储过程希望将内容参数传递给它,如果不传递,则会提出抗议。
-
让我们停止应用,以便在下一节中解决此问题。
没有任何内容的答案是无效答案。理想情况下,我们应该停止将无效请求传递到数据存储库,并将 HTTP 状态代码400返回给客户端,详细说明请求的错误。我们如何在 ASP.NET 中实现这一点?让我们在下一节中找到答案。
增加模型验证
在本节中,我们将在请求模型上添加一些验证检查。然后,ASP.NET 将自动发送 HTTP 状态代码400(错误请求)以及问题的详细信息。
验证对于防止坏数据进入数据库或发生意外的数据库错误至关重要,正如我们在上一节中所经历的那样。向客户提供错误请求的详细信息也可以确保开发体验良好,因为这将有助于纠正错误。
向发布问题添加验证
我们可以通过将验证属性添加到模型中的属性来向模型添加验证,这些属性指定了应该遵守的规则。让我们在发布问题的请求中添加验证:
-
Open
QuestionPostRequest.csand add the followingusingstatement underneath the existingusingstatements:using System.ComponentModel.DataAnnotations;该名称空间使我们能够访问验证属性。
-
Add a
Requiredattribute just above theTitleproperty:[Required] public string Title { get; set; }Required属性将检查Title属性是否为空字符串或null。 -
在尝试此操作之前,请在
QuestionsController.cs中的PostQuestion操作方法中的第一条语句上设置断点。 -
Let's run the app and try to post a question without a title in Postman:
![Figure 9.22 – Validation error when submitting a question with no title]()
图 9.22–提交无标题问题时的验证错误
我们得到了一个 HTTP 状态码为
400的响应,响应中包含了关于问题的大量信息。还请注意,未到达断点。这是因为 ASP.NET 检查了该模型,确定该模型无效,并在调用 action 方法之前返回了错误的请求响应。
-
Let's stop the app from running and implement another validation check on the
Titleproperty in theQuestionPostRequestclass:[Required] [StringLength(100)] public string Title { get; set; }此检查将确保标题不超过
100个字符。包含超过 100 个字符的标题将导致数据库错误,因此这是一个有价值的检查。 -
一个问题也必须有一些内容,所以让我们在此添加一个
Required属性:[Required] public string Content { get; set; } -
我们可以将自定义错误消息添加到验证属性。让我们在
Content属性[Required(ErrorMessage = "Please include some content for the question")] public string Content { get; set; }的验证中添加一条错误消息
-
Let's run the app and try posting a new question without any content:
![Figure 9.23 – Validation error when submitting a question with no content]()
图 9.23–提交无内容问题时的验证错误
我们在响应中得到了预期的自定义消息。
-
让我们停止应用运行。
UserId、UserName和Created属性也应该是必需的属性。但是,我们不打算向它们添加验证属性,因为我们将在本章后面的部分中对它们进行研究。
添加验证以更新问题
让我们在更新问题的请求中添加验证:
-
打开
QuestionPutRequest.cs并添加以下using语句:using System.ComponentModel.DataAnnotations; -
Add the following validation attribute to the
Titleproperty:public class QuestionPutRequest { [StringLength(100)] public string Title { get; set; } public string Content { get; set; } }我们正在确保新标题不超过 100 个字符。
-
Let's run the app and give this a try by updating a question to have a very long title:
![Figure 9.24 – Validation error when updating a question with a long title]()
图 9.24–更新长标题问题时的验证错误
验证错误按预期返回。
-
停止应用运行,以便我们准备添加下一个验证。
这就完成了对api/questions的PUT请求的模型验证的实现。
在发布答案时添加验证
让我们在发布答案的请求中添加验证:
-
打开
AnswerPostRequest.cs并添加以下using语句:using System.ComponentModel.DataAnnotations; -
添加以下验证属性,使
QuestionId和Content属性成为必填项:public class AnswerPostRequest { [Required] public int QuestionId { get; set; } [Required] public string Content { get; set; } ... } -
Make the
QuestionIdproperty nullable by putting a question mark after theinttype:public class AnswerPostRequest { [Required] public int? QuestionId { get; set; } [Required] public string Content { get; set; } ... }重要提示
?允许属性具有null值和声明的类型。T?是Nullable<T>的快捷语法。那么,为什么
QuestionId需要能够保持null值呢?这是因为int类型默认为0,所以如果请求体中没有QuestionId,则AnswerPostRequest将退出模型绑定流程,将QuestionId设置为0,并通过所需的验证检查。这意味着Required属性不会捕获没有QuestionId的请求正文。如果QuestionId类型可为空,那么它将在模型绑定处理中以null值出现(如果它不在请求正文中),并将通过所需的验证检查,这正是我们想要的。 -
We need to change the
PostAnswermethod inQuestionsController.csso that it now references theValueproperty inQuestionId:[HttpPost("answer")] public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest) { var questionExists = _dataRepository.QuestionExists( answerPostRequest.QuestionId.Value); if (!questionExists) { return NotFound(); } var savedAnswer = _dataRepository.PostAnswer(answerPostRequest); return savedAnswer; }这就完成了对
api/questions/answer的POST请求的模型验证的实现。
我们已经体验到,模型验证在我们的请求模型中非常容易实现。我们只需在需要使用适当属性进行验证的模型中修饰属性。我们在实现中使用了Required和StringLength属性,但 ASP.NET 中还有其他属性,其中一些属性如下:
[Range]:检查属性值是否在给定范围内[RegularExpression]:检查数据是否与指定的正则表达式匹配[Compare]:检查模型中的两个属性是否匹配[CreditCard]:检查财产是否具有信用卡格式[EmailAddress]:检查属性是否具有电子邮件格式[Phone]:检查属性是否具有电话格式[Url]:检查属性是否具有 URL 格式
在我们的请求模型中,我们没有向UserId、UserName或Created属性添加任何验证。在下一节中,我们将找出原因并正确处理这些属性。
删除不必要的请求字段
目前,我们正在允许消费者提交我们的数据存储库所需的所有属性,包括userId、userName和created。但是,可以在服务器上设置这些属性。事实上,客户不需要知道或关心userId。
将客户端暴露于超出其需要的更多属性会影响 API 的可用性,还可能导致安全问题。例如,客户机可以假装是任何使用当前 API 提交问题和答案的用户。
在下面的小节中,我们将收紧一些请求,以便它们不包含不必要的信息。我们将首先从发布问题中删除userId、userName和created字段,然后再从发布答案中删除userId和created字段。
从发布问题中删除不必要的请求字段
我们的QuestionPostRequest模型在数据存储库中用于将数据传递到存储过程,在 API 控制器中用于捕获请求体中的信息。这个单一的模型不能很好地满足这两种情况,所以我们将创建并使用单独的模型。执行以下步骤:
-
In the
modelsfolder, create a new model calledQuestionPostFullRequestas follows:public class QuestionPostFullRequest { public string Title { get; set; } public string Content { get; set; } public string UserId { get; set; } public string UserName { get; set; } public DateTime Created { get; set; } }其中包含数据存储库保存问题所需的所有属性。
-
然后我们可以从
QuestionPostRequest类中删除UserId、UserName和Created属性。因此,QuestionPostRequest类现在应该如下:public class QuestionPostRequest { [Required] [StringLength(100)] public string Title { get; set; } [Required(ErrorMessage = "Please include some content for the question")] public string Content { get; set; } } -
在数据仓库界面,将
PostQuestion方法更改为使用QuestionPostFullRequest模型:QuestionGetSingleResponse PostQuestion(QuestionPostFullRequest question); -
在数据仓库中,将
PostQuestion方法更改为使用QuestionPostFullRequest模型:public QuestionGetSingleResponse PostQuestion(QuestionPostFullRequest question) { ... } -
We now need to map the
QuestionPostRequestreceived inQuestionsControllerto theQuestionFullPostRequestthat our data repository expects:[HttpPost] public ActionResult<QuestionGetSingleResponse> PostQuestion(QuestionPostRequest questionPostRequest) { var savedQuestion = _dataRepository.PostQuestion(new QuestionPostFullRequest { Title = questionPostRequest.Title, Content = questionPostRequest.Content, UserId = "1", UserName = "bob.test@test.com", Created = DateTime.UtcNow }); return CreatedAtAction(nameof(GetQuestion), new { questionId = savedQuestion.QuestionId }, savedQuestion); }我们现在已经对
UserId和UserName值进行了硬编码。在第 11 章**保护后端中,我们将从我们的身份提供商处获取它们。我们还将
Created属性设置为当前日期和时间。
** Let's run our app and give it a try:

图 9.25–提交问题
用户和创建日期按预期设置并在响应中返回。
* 最后,停止应用运行。*
*这就完成了 HTTP 请求模型和添加问题的数据存储库的分离。这意味着我们只要求提供POST请求api/questions所需的信息。
从发布答案中删除不必要的请求字段
让我们加紧发布答案:
-
In the
modelsfolder, create a new model calledAnswerPostFullRequestas follows:public class AnswerPostFullRequest { public int QuestionId { get; set; } public string Content { get; set; } public string UserId { get; set; } public string UserName { get; set; } public DateTime Created { get; set; } }它包含数据存储库保存答案所需的所有属性。
-
然后我们可以从
AnswerPostRequest类中删除UserId和Created属性。因此,AnswerPostRequest类现在将如下:public class AnswerPostRequest { [Required] public int? QuestionId { get; set; } [Required] public string Content { get; set; } } -
在数据仓库界面,将
PostAnswer方法更改为使用AnswerPostFullRequest模型:AnswerGetResponse PostAnswer(AnswerPostFullRequest answer); -
在数据仓库中,将
PostAnswer方法更改为使用AnswerPostFullRequest模型:public AnswerGetResponse PostAnswer(AnswerPostFullRequest answer) { ... } -
我们现在需要将
QuestionsController中接收到的AnswerPostRequest映射到我们的数据存储库期望的AnswerPostFullRequest:[HttpPost("answer")] public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest) { var questionExists = _dataRepository.QuestionExists( answerPostRequest.QuestionId.Value); if (!questionExists) { return NotFound(); } var savedAnswer = _dataRepository.PostAnswer(new AnswerPostFullRequest { QuestionId = answerPostRequest. QuestionId.Value, Content = answerPostRequest.Content, UserId = "1", UserName = "bob.test@test.com", Created = DateTime.UtcNow } ); return savedAnswer; } -
让我们运行我们的应用并试一试:

图 9.26–提交答案
用户和创建日期按预期设置并在响应中返回。
所以,这就是我们的 RESTAPI 收紧了一点。
在本节中,我们手动将请求模型映射到数据存储库中使用的模型。对于大型模型,使用映射库(如AutoMapper)来帮助我们将数据从一个对象复制到另一个对象可能是有益的。更多关于AutoMapper的信息可在找到 https://automapper.org/ 。
总结
在本章中,我们学习了如何实现 API 控制器来处理对 REST API 端点的请求。我们发现继承ControllerBase并用ApiController属性装饰控制器类为我们提供了很好的特性,例如自动模型验证处理和一组返回 HTTP 状态代码的方法。
我们使用AddScoped注册数据存储库依赖项,以便 ASP.NET 在请求/响应周期中使用它的单个实例。然后,我们能够在 API 控制器类的构造函数中注入对数据存储库的引用。
我们了解了 ASP.NET 中强大的模型绑定过程,以及它如何将 HTTP 请求中的数据映射到操作方法参数。我们发现,在某些情况下,对 HTTP 请求和数据存储库使用单独的模型是可取的,因为一些数据可以在服务器上设置,请求中需要较少的数据有助于可用性,有时还有助于安全性。
我们使用 ASP.NET 验证属性来验证模型。这是一种非常简单的方法,可以确保数据库不会被坏数据感染。
我们现在可以构建健壮且对开发人员友好的 restapi,这些 restapi 可以与所有常见的 HTTP 方法一起工作,并使用适当的 HTTP 状态代码返回响应。
在下一章中,我们将重点讨论 RESTAPI 的性能和可伸缩性。
问题
回答以下问题以测试您在本章中获得的知识:
-
我们有一个类要注册以进行依赖项注入。在处理请求期间,其他类会多次引用该类。我们希望在注入类时创建该类的新实例,而不是使用现有实例。在
IServiceCollection中,我们应该使用什么方法来注册依赖关系? -
在控制器操作方法中,如果找不到资源,我们可以在
ControllerBase中使用什么方法返回状态码404? -
In a controller action method to post a new building, we implement some validation that requires a database call to check whether the building already exists. If the building does already exist, we want to return HTTP status code
400:[HttpPost] public ActionResult<BuildingResponse> PostBuilding(BuildingPostRequest buildingPostRequest) { var buildingExists = _dataRepository.BuildingExists(buildingPostRequest. Code); if (buildingExists) { // TODO - return status code 400 } ... }从
ControllerBase可以使用什么方法返回状态码400? -
The model for the preceding action method is as follows:
public class BuildingPostRequest { public string Code { get; set; } public string Name { get; set; } public string Description { get; set; } }我们向具有以下主体的资源发送 HTTP
POST请求:{ "code": "BTOW", "name": "Blackpool Tower", "buildingDescription": "Blackpool Tower is a tourist attraction in Blackpool" }请求期间未填充模型中的
Description属性。有什么问题? -
在前面的请求模型中,我们希望验证是否填充了
code和name字段。我们如何使用验证属性来实现这一点? -
我们可以使用什么验证属性来验证数值属性是否介于 1 和 10 之间?
-
我们可以使用什么
Http属性告诉 ASP.NET 操作方法处理 HTTPPATCH请求?
答案
-
我们可以使用
AddTransient方法。 -
我们可以使用
NotFound方法。 -
我们可以使用
BadRequest方法。 -
问题是请求中的
buildingDescription与模型中Description属性的名称不匹配。如果请求更改为具有description字段,则这将解决问题。 -
我们可以在
Code和Name中添加Required属性如下:public class BuildingPostRequest { [Required] public string Code { get; set; } [Required] public string Name { get; set; } public string Description { get; set; } } -
我们可以使用如下的
Range属性:[Range(1, 10)] -
HttpPatch属性可用于处理 HTTPPATCH请求。
进一步阅读
以下是一些有用的链接,可用于了解有关本章所涵盖主题的更多信息:
- 使用 ASP.NET:创建 web APIhttps://docs.microsoft.com/en-us/aspnet/core/web-api
- 依赖注入:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection
- 模型绑定:https://docs.microsoft.com/en-us/aspnet/core/mvc/models/model-binding
- 模型验证:https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation
- 邮递员:https://learning.getpostman.com/docs/postman/launching_postman/installation_and_updates/**
十、提高性能和可扩展性
在本章中,我们将改进 RESTAPI 的性能和可伸缩性。当我们进行每项改进时,我们将使用负载测试工具来验证是否有改进。
我们将首先关注数据库调用以及如何减少调用次数以提高性能。然后,我们将继续使用数据分页来请求更少的数据。我们还将研究在内存中缓存数据对性能的影响。
然后,我们将学习如何使 API 控制器和数据存储库异步。我们最终将了解这是使 RESTAPI 更具性能还是更具可伸缩性。
在本章中,我们将介绍以下主题:
- 减少数据库往返
- 分页数据
- 使 API 控制器异步
- 缓存数据
在本章的最后,我们将了解如何实现在负载下性能良好的快速 RESTAPI。
技术要求
在本章中,我们将使用以下工具:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET 代码。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- SQL Server Management Studio:我们将使用它在数据库中执行一个存储过程。可从下载并安装 https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms?view=sql-服务器-2017。
- 邮递员:我们将使用它来尝试对 RESTAPI 端点所做的更改。可从下载 https://www.getpostman.com/downloads/ 。
- WebSurge:这是一个负载测试工具,我们可以从下载 https://websurge.west-wind.com/ 。
- Q&A:我们将从上一章中完成的 Q&A 后端项目开始。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 在第 10 章/开始文件夹中。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节中还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/3piyUEx 。
减少数据库往返
数据库往返是 web API 对数据库的请求。数据库往返费用昂贵。web API 和数据库之间的距离越远,往返成本就越高。因此,我们希望将从 web API 到数据库的行程保持在最低限度,以获得最大的性能。
本节开始时,我们将了解 N+1 问题,并体验它如何对性能产生负面影响。然后,我们将学习如何在单个数据库往返中执行多个查询。
了解 N+1 问题
N+1 问题是一个经典的查询问题,其中是父子数据模型关系。检索此模型的数据时,将在查询中获取父项,然后执行单独的查询以获取每个子项的数据。因此,有N个查询用于子查询,还有一个查询用于父查询,因此术语 N+1。
我们将向questionsREST API 端点添加在GET请求中返回答案和问题的功能。在我们的第一个实现中,我们将陷入 N+1 陷阱。让我们在 Visual Studio 中打开后端项目,并执行以下步骤:
-
首先,让我们在
QuestionGetManyResponse模型的底部添加一个Answers属性:public class QuestionGetManyResponse { public int QuestionId { get; set; } public string Title { get; set; } public string Content { get; set; } public string UserName { get; set; } public DateTime Created { get; set; } public List<AnswerGetResponse> Answers { get; set; } } -
Add a new method to our data repository interface just below the
GetQuestionsmethod:public interface IDataRepository { IEnumerable<QuestionGetManyResponse> GetQuestions(); IEnumerable<QuestionGetManyResponse> GetQuestionsWithAnswers(); ... }此方法将获取数据库中的所有问题,包括每个问题的答案。
-
Now, add the implementation for the
GetQuestionsWithAnswersmethod in the data repository just below theGetQuestionsmethod:public IEnumerable<QuestionGetManyResponse> GetQuestionsWithAnswers() { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var questions = connection.Query<QuestionGetManyResponse>( "EXEC dbo.Question_GetMany"); foreach (var question in questions) { question.Answers = connection.Query<AnswerGetResponse>( @"EXEC dbo.Answer_Get_ByQuestionId @QuestionId = @QuestionId", new { QuestionId = question.QuestionId }) .ToList(); } return questions; } }因此,这会调用数据库获取所有问题,然后再调用其他调用获取每个问题的答案。我们落入了经典的 N+1 陷阱!
-
Let's move on to
QuestionsControllernow and add the ability to include answers with the questions:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions(string search, bool includeAnswers) { if (string.IsNullOrEmpty(search)) { if (includeAnswers) { return _dataRepository.GetQuestionsWithAnswers(); } else { return _dataRepository.GetQuestions(); } } else { return _dataRepository.GetQuestionsBySearch(search); } }我们已经添加了一个
includeAnswers查询参数的功能,如果设置该参数,将调用我们刚才添加的GetQuestionsWithAnswers数据存储库方法。如果定义了一个search查询参数,一个更完整的实现将允许包含答案,但是这个实现将足以让我们看到 N+1 问题以及如何解决它。 -
现在,按F5 运行 REST API。
-
在《邮递员》中,让我们试着提问并给出答案:

图 10.1–邮递员中的问题和答案
答案随每个问题一起返回,正如我们预期的那样。
不过,这似乎不是什么大问题。请求仅用了174 毫秒就完成了。嗯,目前我们的数据库中只有几个答案。如果我们有更多的问题,请求会慢一点。另外,我们刚刚做的测试是针对单个用户的。当多个用户发出此请求时会发生什么情况?我们将在下一节中找到答案。
使用 WebSurge 加载测试我们的端点
我们必须对 API 端点进行负载测试,以验证它们在负载下是否正常运行。在开发过程中,在用户之前发现性能问题要好得多。WebSurge 是一个简单的负载测试工具,我们将使用它来测试具有 N+1 问题的questions端点。我们将在我们的开发环境中执行负载测试,这对于我们看到 N+1 问题的影响是很好的。显然,在生产环境中,我们将看到的负载测试结果要快得多。要使用 WebSurge 进行负载测试,请执行以下步骤:
-
如果 REST API 尚未运行,请按F5运行 REST API。
-
Open WebSurge and click the New Request option on the Session tab:
![Figure 10.2 – New WebSurge request]()
图 10.2–新 WebSurge 请求
-
Fill in the request details on the Request tab in the right-hand pane for a
GETrequest toapi/questions/includeanswers=true:![Figure 10.3 – Setting the WebSurge request path]()
图 10.3–设置 WebSurge 请求路径
-
To check that the request is correct, press the Test button at the bottom of the right-hand pane. We'll see the response we expect in the Preview tab:
![Figure 10.4 – WebSurge test response]()
图 10.4–WebSurge 测试响应
-
We are nearly ready to do the load test now. Now specify that the test will run for
30seconds with5threads by filling in the relevant boxes under the toolbar:![Figure 10.5 – Load test duration and threads]()
图 10.5–负载测试持续时间和线程
-
Run the load test by clicking the Start button. We'll immediately see requests being made in the Output tab in the right-hand pane:
![Figure 10.6 – Load test output]()
图 10.6–负载测试输出
-
When the test has finished, we'll see the test results in the Output tab in the right-hand pane:
![Figure 10.7 – Load test results]()
图 10.7–负载测试结果
因此,我们通过这个获取带答案的问题的实现,设法每秒获取975个请求。显然,你得到的结果会有所不同。
-
按Shift+F5停止 ASP.NET 应用运行,这样您就可以使实现更高效。
记下结果,我们将使用这些结果与解决 N+1 问题的实现进行比较。
使用简洁的多重映射解决 N+1 问题
如果我们可以在一个数据库查询中获得问题和答案,然后将这些数据映射到我们的数据存储库中所需的层次结构,这不是很好吗?好的,这正是我们可以用 Dapper 中名为多重映射的功能所做的。让我们看看如何使用它。遵循以下步骤:
-
In the data repository, let's change the implementation of the
GetQuestionsWithAnswersmethod to call a single stored procedure:public IEnumerable<QuestionGetManyResponse> GetQuestionsWithAnswers() { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); return connection.Query<QuestionGetManyResponse>( "EXEC dbo.Question_GetMany_WithAnswers"); } }这是一个很好的开始,但是存储过程返回表格式的数据,我们需要将其映射到
QuestionGetManyResponse模型中的问答层次结构:![Figure 10.8 – Tabular data from a stored procedure]()
图 10.8–存储过程中的表格数据
这就是 Dapper 的多重映射功能派上用场的地方。
-
Change the implementation to the following:
public IEnumerable<QuestionGetManyResponse> GetQuestionsWithAnswers() { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); var questionDictionary = new Dictionary<int, QuestionGetManyResponse>(); return connection .Query< QuestionGetManyResponse, AnswerGetResponse, QuestionGetManyResponse>( "EXEC dbo.Question_GetMany_WithAnswers", map: (q, a) => { QuestionGetManyResponse question; if (!questionDictionary.TryGetValue (q.QuestionId, out question)) { question = q; question.Answers = new List<AnswerGetResponse>(); questionDictionary.Add(question. QuestionId, question); } question.Answers.Add(a); return question; }, splitOn: "QuestionId" ) .Distinct() .ToList(); } }在 Dapper
Query方法中,我们提供了一个 Lambda 函数,帮助 Dapper 映射每个问题。该函数接受 Dapper 从存储过程结果映射的问题和答案,并将其映射到所需的结构。我们使用一个名为questionDictionary的Dictionary来跟踪我们已经创建的问题,以便可以为新问题的答案创建一个new List<AnswerGetResponse>实例。我们通过
Query方法中的前两个通用参数QuestionGetManyResponse和AnswerGetResponse告诉 Dapper 要映射到哪些模型,但是 Dapper 如何知道哪些字段已从存储过程映射到模型中的哪些属性?答案是,我们使用splitOn参数告诉 Dapper,QuestionId之前的所有内容进入QuestionGetManyResponse模型,之后的所有内容,包括QuestionId进入AnswerGetResponse模型。我们告诉 Dapper 最终结果应该与
Query方法中的最后一个泛型参数对应的模型,在本例中为QuestionGetManyResponse。我们使用
Distinct方法对从 Dapper 获得的结果进行删除重复问题,然后使用ToList方法将结果转换为列表。 -
修改后的实现完成后,让我们按F5 运行应用。
-
在 WebSurge 中,单击开始按钮,运行与之前相同的负载测试。30 秒后,我们将看到结果:

图 10.9–负载测试结果
这一次,我们的 RESTAPI 设法每秒接收1035个请求,这比以前稍微好一点。
因此,Dapper 的多重映射功能可以用来解决的 N+1 问题,并且通常可以获得更好的性能。但是,我们确实需要小心使用这种方法,因为由于重复的父记录,我们正在从数据库请求大量数据。在 web 服务器中处理大量数据可能效率低下,并导致垃圾收集过程的减速。
使用 Dapper 的多结果功能
Dapper 中还有一个功能可以帮助我们减少数据库往返量,称为多结果。我们将使用此功能来提高端点的性能,该端点只会得到一个问题,目前正在进行两个数据库调用。为此,请执行以下步骤:
-
First, let's load test the current implementation by using WebSurge. Select the Session tab, highlight the first request in the list, and click the Edit option:
![Figure 10.10 – Option to edit a request]()
图 10.10–编辑请求的选项
-
Enter a path to a single question in the Request tab as in the following screenshot:
![Figure 10.11 – Path to a single question]()
图 10.11–单个问题的路径
-
We'll leave the duration of the test at 30 seconds with 5 threads. Press the Start option to run the load test. When the test has finished, we'll get our results:
![Figure 10.12 – Results of load testing getting a question]()
图 10.12–负载测试结果获得问题
因此,当前的实现每秒可以接受1092 个请求。
-
现在,停止应用运行并开始修改实现,在
DataRepository.cs中的其他using语句之后添加以下using语句:using static Dapper.SqlMapper; -
Now, we can change the implementation of
QuestionGetSingleResponseto the following:public QuestionGetSingleResponse GetQuestion(int questionId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); using (GridReader results = connection.QueryMultiple( @"EXEC dbo.Question_GetSingle @QuestionId = @QuestionId; EXEC dbo.Answer_Get_ByQuestionId @QuestionId = @QuestionId", new { QuestionId = questionId } ) ) { var question = results.Read< QuestionGetSingleResponse>().FirstOrDefault(); if (question != null) { question.Answers = results.Read<AnswerGetResponse>().ToList(); } return question; } } }我们使用 Dapper 中的
QueryMultiple方法在一个数据库往返中执行两个存储过程。结果被添加到一个results变量中,通过在泛型参数中传递适当的类型,可以使用Read方法检索结果。 -
让我们在 Visual Studio 中再次启动应用,并执行相同的负载测试:

图 10.13–获得问题负载测试–改进的结果
我们改进的 API 现在可以每秒处理 1205 个请求。
在本节中,我们学习了如何使用 Dapper 中的多重映射功能在一次往返中获取父子数据。我们还学习了如何使用 Dapper 中的多结果功能在一次往返中执行多个查询。我们还学习了如何使用 WebSurge 加载测试 RESTAPI 端点。
正如我们在多重映射示例中提到的,处理大量数据可能会有问题。我们如何减少从数据库读取的数据量和在 web 服务器上处理的数据量?我们将在下一节中找到答案。
寻呼数据
在本节中,我们将强制questions端点的使用者在使用search查询参数执行请求时指定数据的页面。因此,我们将只返回部分数据,而不是全部数据。
分页有助于的性能和可扩展性,具体表现在以下几个方面:
- SQL Server 获取数据时,页面读取 I/O 的数量会减少。
- 减少了从数据库服务器传输到 web 服务器的数据量。
- 在我们的模型中,用于在 web 服务器上存储数据的内存量减少了。
- 从 web 服务器传输到客户端的数据量减少了。
这一切加起来可能会产生重大的积极影响,特别是对于大型数据收集而言。
我们将从负载测试questions端点的当前实现开始本节。然后,我们将实现分页,并查看这对负载测试的影响。
负荷测试增加试题
让我们执行以下步骤,向我们的数据库中添加大量问题。这将使我们能够看到数据分页的影响:
-
我们打开 SQL Server Management Studio,右键点击对象浏览器中的QandA数据库,选择新建查询。
-
In the query window that opens, add the following command:
EXEC Question_AddForLoadTest这将执行一个存储过程,该过程将向我们的数据库添加 10000 个问题。
-
按F5运行存储过程,需要几秒钟才能完成。
现在我们有了问题,让我们测试一下当前的实现。
负载测试当前实现
在实现数据分页之前,让我们看看当前的实现在负载下是如何执行的。要检查并验证,请执行以下步骤:
-
让我们在 VisualStudio 中按F5启动 RESTAPI,如果它还没有运行。
-
现在,我们可以使用 WebSurge 对当前实现进行负载测试。让我们将请求 URL 路径设置为
/api/questions?search=question,并使用 5 个线程保持 30 秒的持续时间。 -
Before running the load test, check that the request works okay by clicking the Test option. We may get an error in the response body like the following one:
![Figure 10.14 – Response body error]()
图 10.14–响应主体错误
将会话选项选项卡上的MaxResponseSize设置更改为
0即可解决此错误:![Figure 10.15 – Removing the maximum response size]()
图 10.15–删除最大响应大小
-
Start the test. When the test has finished, we'll get our results as follows:
![Figure 10.16 – Searching questions load test result]()
图 10.16-搜索问题负载测试结果
-
停止 RESTAPI 的运行。
因此,每秒要拍的请求值为37。
实现数据分页
现在,让我们使用search查询参数修改对questions端点的实现,以便使用数据分页。为了实现这一点,让我们完成以下步骤:
-
Let's start by adding a new method that will search using paging in our data repository interface. Add the highlighted method interface after
GetQuestionsBySearchinIDataRepository.cs:public interface IDataRepository { ... IEnumerable<QuestionGetManyResponse> GetQuestionsBySearch(string search); IEnumerable<QuestionGetManyResponse> GetQuestionsBySearchWithPaging( string search, int pageNumber, int pageSize); ... }因此,该方法将以页码和大小作为参数。
-
Now, we can add the following method implementation in the
DataRepository.csfile afterGetQuestionsBySearch:public IEnumerable<QuestionGetManyResponse> GetQuestionsBySearchWithPaging( string search, int pageNumber, int pageSize ) { using (var connection = new SqlConnection(_ connectionString)) { connection.Open(); var parameters = new { Search = search, PageNumber = pageNumber, PageSize = pageSize }; return connection.Query<QuestionGetManyResponse>( @"EXEC dbo.Question_GetMany_BySearch_WithPaging @Search = @Search, @PageNumber = @PageNumber, @PageSize = @PageSize", parameters ); } }因此,我们调用名为
Question_GetMany_BySearch_WithPaging的存储过程来获取数据的页面,并传入搜索条件、页码和页面大小作为参数。 -
Let's change the implementation of the
GetQuestionsaction method inQuestionsController.csso that we can call the new repository method:[HttpGet] public IEnumerable<QuestionGetManyResponse> GetQuestions( string search, bool includeAnswers, int page = 1, int pageSize = 20 ) { if (string.IsNullOrEmpty(search)) { if (includeAnswers) { return _dataRepository.GetQuestionsWithAnswers(); } else { return _dataRepository.GetQuestions(); } } else { return _dataRepository.GetQuestionsBySearchWithPaging( search, page, pageSize ); } }请注意,我们还接受页码和页面大小的查询参数,它们分别默认为 1 和 20。
-
让我们在 VisualStudio 中按F5开始 RESTAPI。
-
现在,我们可以使用 WebSurge 对新实现进行负载测试。使用5线程持续30秒,然后开始测试。测试完成后,我们将得到结果:

图 10.17-改进的搜索问题负载测试结果
我们得到了我们所希望的性能改进,端点现在能够每秒接收100个请求。
总之,我们通过接受端点上的查询参数页码和页面大小来实现数据分页。这些参数被传递给数据库查询,以便它只获取相关的数据页。对于返回数据集合的 API 来说,数据分页非常值得考虑,特别是在数据集合很大的情况下。
在下一节中,我们将讨论异步代码的主题,以及异步代码如何有助于提高可伸缩性。
使 API 控制器异步
在本节中,我们将使未回答的问题端点异步,以使其更具可伸缩性。
目前,我们所有的 API 代码都是同步的。对于同步 API 代码,当向 API 发出请求时,线程池中的线程将处理该请求。如果代码同步进行 I/O 调用(如数据库调用),线程将阻塞,直到 I/O 调用完成。被阻塞的线程不能用于任何其他工作,它只是什么也不做,等待 I/O 任务完成。如果在另一个线程被阻塞时向我们的 API 发出其他请求,那么线程池中的不同线程将用于其他请求。下图显示了 ASP.NET 中的同步请求:

图 10.18–同步请求
使用一个线程会有一些开销——一个线程会消耗内存,启动一个新线程需要时间。所以,实际上,我们希望我们的 API 使用尽可能少的线程。
如果 API 以异步方式工作,那么当向我们的 API 发出请求时,线程池中的线程将处理该请求(如同步情况)。如果代码进行异步 I/O 调用,线程将在 I/O 调用开始时返回到线程池,并可用于其他请求。下图显示了 ASP.NET 中的异步请求:

图 10.19–异步请求
因此,如果我们使 API 异步,它将能够更有效地处理请求并提高可伸缩性。需要注意的是,使 API 异步并不会使其性能提高,因为单个请求所需的时间大致相同。我们将要做的改进是使我们的 API 能够更有效地使用服务器的资源。
在本节中,我们将把未回答问题的操作方法转换为异步。我们将分析此转换前后的性能,以发现其影响。我们还将发现异步代码同步进行 I/O 调用时会发生什么。
测试当前实现
在我们更改未回答的问题端点之前,让我们测试当前的实现,并收集一些数据与异步实现进行比较。我们将在 VisualStudio 中使用性能分析器和 WebSurge。执行以下步骤:
-
Start by switching to the Release configuration. This can be done by choosing Release from the Solution Configurations option on the toolbar:
![Figure 10.20 – Release configuration]()
图 10.20–发布配置
这将使测试更加真实。
-
Press Alt + F2 to open the Performance Profiler and tick the .NET Async tool:
![Figure 10.21 – Performance profiler]()
图 10.21–性能分析器
-
点击启动按钮。
-
Now, we can simulate some load using WebSurge. Set the request URL path to
/api/questions/unansweredand set the duration to10seconds with5threads:![Figure 10.22 – Configuration for load testing unanswered questions]()
图 10.22–负载测试未回答问题的配置
-
开始负载测试。
-
10 秒后,切换到 Visual Studio,点击性能分析器中的停止采集选项。
-
After a few seconds, data is presented in a hierarchical grid. Open up the details under the unanswered questions requests. We can see the average time it took to execute this code. Note this down:
![Figure 10.23 – Performance on the synchronous version of unanswered questions]()
图 10.23–未回答问题的同步版本的性能
-
单击选项卡上的关闭图标关闭报告。
-
切换回 WebSurge,并在此处记录结果:

图 10.24–未回答问题同步版本的负载测试结果
因此,我们现在从同步实现未回答问题中获得了一些性能指标。
接下来,我们将发现将此代码更改为异步代码是如何影响性能的。
实现异步控制器动作方法
现在,我们将更改未回答问题端点的实现,使其异步:
-
We are going to start by creating an asynchronous version of the data repository method that gets unanswered questions. So, let's create a new method in the data repository interface underneath
GetUnansweredQuestionsinIDataRepository.cs:public interface IDataRepository { ... IEnumerable<QuestionGetManyResponse> GetUnansweredQuestions(); Task<IEnumerable<QuestionGetManyResponse>> GetUnansweredQuestionsAsync(); ... }异步方法的关键区别在于它返回最终将返回的类型的
Task。 -
Let's create the data repository method implementation in
DataRepository.csunderneathGetUnansweredQuestions:public async Task<IEnumerable<QuestionGetManyResponse>> GetUnansweredQuestionsAsync() { using (var connection = new SqlConnection(_connectionString)) { await connection.OpenAsync(); return await connection.QueryAsync<QuestionGetManyResponse>( "EXEC dbo.Question_GetUnanswered"); } }返回类型前的
async关键字表示该方法是异步的。该实现与同步版本非常相似,只是我们使用异步的 Dapper 版本,即打开连接并使用await关键字执行查询。重要提示
使代码异步时,调用堆栈中的所有 I/O 调用都必须是异步的。如果任何 I/O 调用都是同步的,那么线程将被阻塞,而不是返回到线程池,因此线程将无法得到有效管理。
-
Let's change the
GetUnansweredQuestionsmethod inQuestionsController.csto call the method in the data repository we have just added:[HttpGet("unanswered")] public async Task<IEnumerable<QuestionGetManyResponse>> GetUnansweredQuestions() { return await _dataRepository. GetUnansweredQuestionsAsync(); }我们使用
async关键字将方法标记为异步,并返回最终想要返回的类型Task。我们还使用await关键字调用数据存储库方法的异步版本。我们的未回答问题端点现在是异步的。
-
按Alt+F2打开性能档案器,确认.NET 异步工具仍被勾选。点击启动按钮。
-
切换到 WebSurge,检查请求 URL 路径是否仍然为
/api/questions/unanswered,持续时间设置为10秒和5线程。 -
开始负载测试。
-
10 秒后,切换到 Visual Studio,点击性能分析器中的停止采集选项。
-
After a few seconds, the results will appear:
![Figure 10.25 – Performance on the asynchronous version of unanswered questions]()
图 10.25–未回答问题的异步版本的性能
将结果与同步实现结果进行比较。在我的测试中,异步的稍微快一点。请注意为处理异步代码而发生的额外活动,这会占用一些执行时间。
-
点击选项卡上的关闭图标关闭报告。
-
切换回 WebSurge,并在此处记录结果:

图 10.26–未回答问题异步版本的负载测试结果
结果表明,性能略有提高。事实上,您的结果可能显示性能略有下降。由于处理异步代码所需的开销,异步代码可能比同步代码慢。
异步代码的好处是在负载下更有效地使用 web 服务器的资源。因此,异步 RESTAPI 比同步 RESTAPI 具有更好的可扩展性。
在异步 API 控制器方法中使用同步数据库调用时会发生什么?我们下一步会知道的。
混合异步和同步代码
一个容易犯的错误是将异步代码与同步代码混合。让我们通过改变GetUnansweredQuestions动作方式来了解发生这种情况时会发生什么:
-
将此方法更改为调用数据库中
GetUnansweredQuestions的同步版本:[HttpGet("unanswered")] public async Task<IEnumerable<QuestionGetManyResponse>> GetUnansweredQuestions() { return _dataRepository.GetUnansweredQuestions(); } -
按Alt+F2打开性能档案器,确认.NET 异步工具仍被勾选。点击启动按钮。
-
切换到 WebSurge,检查请求 URL 路径是否仍然为
/api/questions/unanswered,持续时间设置为10秒和5线程。 -
开始负载测试。
-
10 秒后,切换到 Visual Studio,点击性能分析器中的停止采集选项。
-
After a few seconds, the results will appear:
![Figure 10.27 – Results when sync and async code is mixed]()
图 10.27–同步和异步代码混合时的结果
即使操作方法是非同步的,代码的功能也如同它是同步代码一样。
-
点击选项卡上的关闭图标关闭报告。
-
还原此更改,使
GetUnansweredQuestions方法如下:[HttpGet("unanswered")] public async Task<IEnumerable<QuestionGetManyResponse>> GetUnansweredQuestions() { return await _dataRepository.GetUnansweredQuestionsAsync(); } -
切换回调试配置。这可以通过在工具栏上的解决方案配置选项中选择
Debug来实现:

图 10.28–调试配置
因此,当同步和异步代码混合使用时,它的行为将类似于同步代码,并且线程池的使用效率低下。在异步方法中,重要的是检查所有 I/O 调用是否都是异步的,并且是否包括子方法中的任何 I/O 调用。
在下一节中,我们将研究如何通过缓存数据来优化数据请求。
缓存数据
在本节中,我们将缓存获取问题的请求。目前,每个请求都会查询数据库以获取问题。如果我们缓存一个问题,并且可以从缓存中获取后续的问题请求,这应该会更快,并减少数据库的负载。我们将通过负载测试来证明这一点。
负载测试当前实现
在我们实现缓存之前,我们将使用以下步骤对获取单个问题的当前实现进行负载测试:
- 让我们在 VisualStudio 中按F5开始 RESTAPI。
- 现在,我们可以使用 WebSurge 对当前实现进行负载测试。让我们将请求 URL 路径设置为
/api/questions/1,并使用5线程将持续时间更改为30秒。 - 开始测试。测试完成后,我们将得到结果:

图 10.29–在没有缓存的情况下获取问题–负载测试结果
因此,我们在没有缓存的情况下每秒获得1113个请求。
停止 RESTAPI 的运行,以便我们可以实现和使用数据缓存。
实现数据缓存
我们将使用 ASP.NET 中的内存缓存实现问题缓存:
-
First, let's create an interface in the
Datafolder calledIQuestionCache:using QandA.Data.Models; namespace QandA.Data { public interface IQuestionCache { QuestionGetSingleResponse Get(int questionId); void Remove(int questionId); void Set(QuestionGetSingleResponse question); } }因此,我们需要缓存实现具有获取、删除和更新缓存中项目的方法。
-
Now, we can create a class in the
Datafolder calledQuestionCache:using Microsoft.Extensions.Caching.Memory; using QandA.Data.Models; namespace QandA.Data { public class QuestionCache: IQuestionCache { // TODO - create a memory cache // TODO - method to get a cached question // TODO - method to add a cached question // TODO - method to remove a cached question } }请注意,我们引用了
Microsoft.Extensions.Caching.Memory,以便使用标准 ASP.NET 内存缓存。 -
Let's create a constructor that creates an instance of the memory cache:
public class QuestionCache: IQuestionCache { private MemoryCache _cache { get; set; } public QuestionCache() { _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 }); } // TODO - method to get a cached question // TODO - method to add a cached question // TODO - method to remove a cached question }请注意,我们已将缓存限制设置为
100项。这是为了限制缓存在 web 服务器上占用的内存量。 -
Let's implement a method to get a question from the cache:
public class QuestionCache: IQuestionCache { ... private string GetCacheKey(int questionId) => $"Question-{questionId}"; public QuestionGetSingleResponse Get(int questionId) { QuestionGetSingleResponse question; _cache.TryGetValue( GetCacheKey(questionId), out question); return question; } // TODO - method to add a cached question // TODO - method to remove a cached question }我们创建了一个表达式,为缓存项提供一个键,这个键是带连字符的单词
Question,后跟问题 ID。我们在内存缓存中使用
TryGetValue方法来检索缓存的问题。因此,如果问题在缓存中不存在,我们的方法将返回null。 -
Now, we can implement a method to add a question to the cache. We can add an item to the cache using the
Setmethod in the ASP.NET memory cache:public class QuestionCache: IQuestionCache { ... public void Set(QuestionGetSingleResponse question) { var cacheEntryOptions = new MemoryCacheEntryOptions().SetSize(1); _cache.Set( GetCacheKey(question.QuestionId), question, cacheEntryOptions); } // TODO - method to remove a cached question }请注意,在设置缓存值时,我们在选项中指定问题的大小。这与我们在缓存上设置的大小限制相关联,因此当缓存中有 100 个问题时,缓存将开始从缓存中删除问题。
-
The last method we need to implement is a method to remove questions from the cache:
public class QuestionCache: IQuestionCache { ... public void Remove(int questionId) { _cache.Remove(GetCacheKey(questionId)); } }请注意,如果问题在缓存中不存在,则不会发生任何事情,也不会引发异常。
这就完成了问题缓存的实现。
在 API 控制器动作方法中使用数据缓存
现在,我们将使用 API 控制器的GetQuestion方法中的问题缓存。完成以下步骤:
-
First, we need to make the cache available for dependency injection so that we can inject it into
QuestionsController. So, let's registerQuestionCachefor dependency injection in theStartupclass after enabling the ASP.NET memory cache:public void ConfigureServices(IServiceCollection services) { ... services.AddMemoryCache(); services.AddSingleton<IQuestionCache, QuestionCache>(); }我们在依赖注入系统中将我们的缓存注册为单例。这意味着我们的类的一个实例将在应用的生命周期内创建。因此,单独的 HTTP 请求将访问同一个类实例,从而访问相同的缓存数据。这正是我们想要的缓存。
-
在
QuestionsController.cs中,我们将缓存注入QuestionsController:... private readonly IQuestionCache _cache; public QuestionsController(..., IQuestionCache questionCache) { ... _cache = questionCache; } -
Let's change the implementation of
GetQuestionto the following:[HttpGet("{questionId}")] public ActionResult<QuestionGetSingleResponse> GetQuestion(int questionId) { var question = _cache.Get(questionId); if (question == null) { question = _dataRepository.GetQuestion(questionId); if (question == null) { return NotFound(); } _cache.Set(question); } return question; }如果问题不在缓存中,那么我们从数据存储库中获取它并将其放入缓存中。
-
当问题发生变化时,我们需要将缓存中存在的项目从缓存中删除。这样,问题的后续请求将从数据库中获取更新的问题。将突出显示的代码行添加到
return语句前面的PutQuestion:[HttpPut("{questionId}")] public ActionResult<QuestionGetSingleResponse> PutQuestion(int questionId, QuestionPutRequest questionPutRequest) { ... _cache.Remove(savedQuestion.QuestionId); return savedQuestion; } -
类似地,删除问题时,如果问题存在于缓存中,则需要将其从缓存中删除。将突出显示的代码行添加到
return语句前面的DeleteQuestion:HttpDelete("{questionId}")] public ActionResult DeleteQuestion(int questionId) { ... _cache.Remove(questionId); return NoContent(); } -
我们还需要在发布答案时从缓存中删除问题。将突出显示的代码行添加到
return语句前面的PostAnswer:[HttpPost("answer")] public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest) { ... _cache.Remove(answerPostRequest.QuestionId.Value); return savedAnswer; } -
让我们在 VisualStudio 中按F5开始 RESTAPI。
-
让我们使用改进的实现再次对
/api/questions/1端点进行负载测试,将持续时间保持在 30 秒,线程数保持在 5。 -
When the test has finished, we'll get our results, confirming the improvement:
![Figure 10.30 – Getting a question with cache load test results]()
图 10.30–获取有关缓存负载测试结果的问题
-
在 VisualStudio 中按Shift+F5 停止 REST API。
这就完成了带数据缓存的问题端点的实现。
重要的是要记住在数据更改时使缓存无效。在我们的示例中,这很简单,但可能更复杂,特别是在 RESTAPI 之外有其他进程更改数据的情况下。因此,如果我们不能完全控制 RESTAPI 中的数据更改,那么缓存可能不值得实现。
是否使用缓存的另一个考虑因素是数据是否经常更改。在这种情况下,缓存过程实际上会对性能产生负面影响,因为大量的请求无论如何都会导致数据库调用,而且我们有管理缓存的所有开销。
但是,如果端点后面的数据很少更改,并且我们可以控制这些更改,那么缓存是积极影响性能的好方法。
如果 RESTAPI 分布在多个服务器上会怎么样?嗯,因为内存缓存是每个 web 服务器的本地缓存,这可能会导致数据库调用,其中数据缓存在不同的服务器上。一个解决方案是在 ASP.NET 中使用IDistributedCache实现分布式缓存,它的实现与我们的内存缓存非常相似。复杂的是,这需要连接到第三方缓存如 Redis,这增加了财务成本和解决方案的复杂性。不过,对于高流量 RESTAPI,分布式缓存非常值得考虑。
总结
在本章中,我们了解到可以使用 Dapper 的多映射和多结果特性来减少数据库往返,从而对性能产生积极影响,并允许 RESTAPI 每秒接受更多请求。我们还了解到,强制客户机翻阅他们需要使用的数据也有助于提高性能。
我们学习了如何使控制器操作方法异步,以及这如何对 ASP.NET 中构建的 REST API 的可伸缩性产生积极影响。我们还了解到,一个方法及其子方法中的所有 I/O 调用都需要是异步的,以实现可伸缩性优势。
我们还学习了如何在内存中缓存数据,以减少昂贵的数据库调用次数。我们知道,经常读取且很少更改的数据是使用缓存的好例子。
在下一章中,我们将继续关注 RESTAPI,并将注意力转向安全性主题。我们将要求用户进行身份验证,以便使用 RESTAPI 访问某些端点。
问题
尝试回答以下问题,以检查您在本章中获得的知识:
-
We have the following code in a data repository that uses Dapper's multi-results feature to return a single order with the many related detail lines in a single database call:
using (var connection = new SqlConnection(_connectionString)) { connection.Open(); using (GridReader results = connection.QueryMultiple( @"EXEC dbo.Order_GetHeader @OrderId = @OrderId; EXEC dbo.OrderDetails_Get_ByOrderId @OrderId = @OrderId", new { OrderId = orderId })) { // TODO - Read the order and details from the // query result return order; } }哪些遗漏的语句将从结果中读取订单及其详细信息,并将详细信息放入订单模型中?订单模型为
OrderGetSingleResponse类型,包含IEnumerable<OrderDetailGetResponse>类型的Details属性。 -
在一次数据库调用中从多对一的相关表中读取数据时,使用 Dapper 的多重映射功能的缺点是什么?
-
数据分页如何帮助提高性能?
-
使代码异步会使它更快吗?
-
以下异步方法有什么问题?
public async AnswerGetResponse GetAnswer(int answerId) { using (var connection = new SqlConnection(_connectionString)) { connection.Open(); return await connection .QueryFirstOrDefaultAsync<AnswerGetResponse>( "EXEC dbo.Answer_Get_ByAnswerId @AnswerId = @AnswerId", new { AnswerId = answerId }); } } -
为什么对内存缓存设置大小限制是个好主意?
-
在我们的
QuestionCache实现中,当向缓存添加问题时,如何在 30 分钟后使缓存中的该项无效? -
当我们注册
QuestionCache类进行依赖注入时,为什么要使用AddSingleton方法而不是下面代码中的AddScoped方法?services.AddScoped<QuestionCache>();
答案
-
我们可以添加以下突出显示的代码行,以从结果中读取订单及其详细信息:
using (var connection = new SqlConnection(_connectionString)) { connection.Open(); using (GridReader results = connection.QueryMultiple( @"EXEC dbo.Order_GetHeader @OrderId = @OrderId; EXEC dbo.OrderDetails_Get_ByOrderId @OrderId = @OrderId", new { OrderId = orderId })) { var order = results.Read< OrderGetSingleResponse>().FirstOrDefault(); if (order != null) { order.Details = results.Read< OrderDetailGetResponse>().ToList(); } return order; } } -
使用 Dapper 的多重映射功能的代价是,更多的数据在数据库和 web 服务器之间传输,然后在 web 服务器上处理,这可能会影响性能。
-
Paging increases performance in the following ways:
a) SQL Server 获取数据时,页面读取 I/O 的数量会减少。
b) 从数据库服务器传输到 web 服务器的数据量减少了。
c) 在我们的模型中,用于在 web 服务器上存储数据的内存量减少了。
d) 从 web 服务器传输到客户端的数据量减少了。
-
异步代码并不比同步代码快。相反,它通过更有效地使用线程池使其更具可伸缩性。
-
The problem with the asynchronous method implementation is that opening the connection is synchronous. This means the thread is blocked and not returned to the thread pool until the connection is opened. So, the whole code will have the same thread pool inefficiency as synchronous code but will have the overhead of asynchronous code as well.
以下是正确的实现:
public async AnswerGetResponse GetAnswer(int answerId) { using (var connection = new SqlConnection(_connectionString)) { await connection.OpenAsync(); return await connection .QueryFirstOrDefaultAsync<AnswerGetResponse>( "EXEC dbo.Answer_Get_ByAnswerId @AnswerId = @AnswerId", new { AnswerId = answerId }); } } -
最好对内存缓存设置大小限制,以防止缓存占用 web 服务器上过多的内存。
-
我们可以在
30分钟后执行以下操作使缓存中的项无效:public void Set(QuestionGetSingleResponse question) { var cacheEntryOptions = new MemoryCacheEntryOptions() .SetSize(1) .SetSlidingExpiration(TimeSpan.FromMinutes(30)); _cache.Set(GetCacheKey(question.QuestionId), question, cacheEntryOptions); } -
我们向
AddSingleton注册了QuestionCache类以进行依赖项注入,这样缓存将在应用的生命周期内持续。使用AddScoped会为每个请求创建一个新的缓存实例,这意味着每个请求后缓存都会丢失。
进一步阅读
如果您想了解有关本章所涵盖主题的更多信息,请访问以下有用的链接:
- 简洁的多重映射:https://dapper-tutorial.net/result-multi-mapping
- 简洁的多结果:https://dapper-tutorial.net/result-multi-result
- 与
async和await异步编程 https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/ - ASP.NET 内存缓存:https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory
- ASP.NET 分布式缓存:https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed
十一、后端安全
在本章中,我们将在问答应用中实现身份验证和授权。我们将使用一个名为 Auth0 的流行服务来帮助我们做到这一点,该服务实现了OpenID Connect(OIDC)。在让我们的应用与 Auth0 交互之前,我们将首先了解什么是 OIDC 以及为什么它是一个好的选择。
目前,未经验证的用户可以访问我们的 web API,这是一个安全漏洞。我们将通过使用简单授权保护必要的端点来解决此漏洞。这意味着只有经过身份验证的用户才能访问受保护的资源。
不过,经过身份验证的用户不应该访问所有内容。我们将学习如何确保经过身份验证的用户只通过使用自定义授权策略访问他们被允许访问的内容。
我们还将学习如何获取经过身份验证的用户的详细信息,以便在将问题和答案保存到数据库时将其包括在内。
我们将通过启用跨源请求来结束本章,为允许前端访问 RESTAPI 做准备。
在本章中,我们将介绍以下主题:
- 理解 OIDC
- 使用我们的 ASP.NET 后端设置 Auth0
- 保护端点
- 发布问题和答案时使用经过身份验证的用户
- 添加 CORS
技术要求
在本章中,我们将使用以下工具和服务:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET 代码。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- Auth0:我们将使用它来验证和管理用户。该服务可以免费尝试并进行一些测试,并且可以在上创建一个帐户 https://auth0.com/signup 。
- 邮递员:在本章中,我们将使用它来尝试对 RESTAPI 的更改。可从下载 https://www.getpostman.com/downloads/ 。
- Q&A:我们将从本章的 Q&A 启动项目开始。这是我们在上一章中完成的后端项目,所有控制器方法都是异步的。这可在 GitHub 的上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-11/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:http://bit.ly/2EPQ8DY
理解 OIDC
在我们介绍 OIDC 之前,让我们确保了解身份验证和授权。身份验证验证验证用户是否就是他们所说的用户。在我们的应用中,用户将输入他们的电子邮件和密码以证明他们是谁。授权决定用户是否具有访问资源的权限。在我们的应用中,一些 RESTAPI 端点(如发布问题)最终将受到授权检查的保护。
OIDC 是处理身份验证和授权以及其他用户相关操作的行业标准方法。这适用于各种各样的架构,包括单页应用(SPA),比如我们的架构,其中有一个 JavaScript 客户端和一个服务器端 REST API 需要保护。
下图显示了我们的应用的用户经过身份验证,然后在 REST API 中访问受保护资源的高级流程:

图 11.1–OIDC 身份验证流程
以下是一些发生的步骤的更多细节:
- 客户端向身份提供者发出授权请求,因为它希望访问 RESTAPI 中受保护的资源。
- 客户端被重定向到身份提供程序,以便用户可以输入他们的凭据来证明他们是谁。
- 然后,身份提供程序生成一个代码,并将代码重定向回客户端。
- 然后,客户机发出包含生成代码的 web API 请求以获取访问代码。身份提供者验证代码并使用访问令牌进行响应。
- 然后,客户端可以通过在请求中包含访问令牌来访问 REST API 中受保护的资源。
请注意,我们的应用从不处理用户凭据。当需要用户身份验证时,用户将被重定向到身份提供商以执行此过程。我们的应用只处理一个安全令牌,称为访问令牌,是一个长编码字符串。此令牌采用JSON Web 令牌(JWT格式),这也是行业标准。
可使用检查 JWT 的含量 https://jwt.io/ 网站。我们可以将一个 JWT 粘贴到编码的框中,然后站点将解码后的 JWT 放入解码的框中,如下图所示:

图 11.2–JWT.io 中的 JWT
JWT 有三个部分,由点分隔,它们在jwt.io中显示为不同的颜色:
- 表头
- 有效载荷
- 签字
标头通常包含typ字段中的令牌类型和alg字段中使用的签名算法。因此,前面的令牌是使用 RSA 签名和 SHA-256 非对称算法的 JWT。标头中还有一个kid字段,它是一个不透明标识符,可用于标识用于签署 JWT 的密钥。
JWT 的有效载荷各不相同,但通常包括以下字段:
iss:这是颁发令牌的身份提供者。sub:是subject的缩写,是用户的标识符。这将是我们应用的UserId。aud:这是目标受众。对于我们的应用,这将包含 RESTAPI 的名称。iat:这是 JWT 发布的时间。这是 Unix epoch 时间格式,即自 1970 年 1 月 1 日以来经过的秒数。exp:这是令牌到期时,并且再次是 Unix 历元时间格式。azp:这是向其颁发令牌的一方,这是使用 JWT 的客户端的唯一标识符。这将是我们案例中 React 应用的客户端 ID。scope:这是客户可以访问的。对于我们的应用,这是 RESTAPI,以及用户配置文件信息和他们的电子邮件地址。openid范围允许客户端验证用户的身份。
OIDC 负责安全地存储密码、验证用户、生成访问令牌等。能够利用行业标准技术(如 OIDC)不仅为我们节省了大量时间,而且让我们放心,该实现非常安全,并且会随着攻击者变得更聪明而收到更新。
我们刚刚学到的是由 Auth0 实现的。我们将在下一节中开始使用 Auth0。
使用我们的 ASP.NET 后端设置 Auth0
我们将在我们的应用中使用名为Auth0的现成身份服务。Auth0 实现了 OIDC,并且对于少量用户也是免费的。使用 Auth0 将使我们能够专注于与身份服务集成,而不是花时间构建自己的身份服务。
在本节中,我们将设置 Auth0 并将其集成到我们的 ASP.NET 后端。
设置 Auth0
让我们执行以下步骤将 Auth0 设置为我们的身份提供者:
-
如果您尚未拥有 Auth0 帐户,请在注册 https://auth0.com/signup 。
-
Once we have an Auth0 account and have logged in, we need to change the default audience in our tenant settings. To get to your tenant settings, click on the user avatar and choose Settings:
![Figure 11.3 – Auth0 tenant settings option]()
图 11.3–Auth0 租户设置选项
默认受众选项位于API 授权设置部分。将此更改为
https://qanda:![Figure 11.4 – Auth0 Default Audience setting]()
图 11.4–Auth0 默认观众设置
这告诉 Auth0 将
https://qanda添加到它生成的 JWT 中的aud有效负载字段中。此设置触发 Auth0 以 JWT 格式生成访问令牌。我们的 ASP.NET 后端还将在授予对受保护资源的访问权限之前检查访问令牌是否包含此数据。 -
接下来,我们将告诉 Auth0 我们的 React 前端。在左侧导航菜单上,点击应用,然后点击创建应用按钮。
-
Select the Single Page Web Applications application type and click the CREATE button:
![Figure 11.5 – Creating a SPA Auth0 client]()
图 11.5–创建 SPA Auth0 客户端
然后将创建我们的 SPA 客户端配置。
-
我们需要更改 SPA 客户端配置中的一些设置,因此选择设置选项卡并设置以下设置。
-
该名称将出现在登录屏幕上,因此将其更改为
QandA。 -
在允许的 Web 原点设置中指定前端原点。那么,让我们将其设置为
http://localhost:3000。 -
我们需要在允许的回调 URL设置中指定成功登录后 Auth0 将重定向回的页面。因此,将其设置为
http://localhost:3000/signin-callback。我们将在第 12 章中的前端实现signin-callback页面,与 RESTful API交互。 -
同样,我们需要在允许的注销 URL设置中指定成功注销后 Auth0 将重定向回的页面。因此,将其设置为
http://localhost:3000/signout-callback。我们将在第 12 章中的前端实现signout-callback页面,与 RESTful API交互。 -
输入这些设置后,不要忘记滚动到页面的底部,点击保存更改按钮。
-
我们现在需要告诉 Auth0 关于我们的 ASP.NET 后端。在左侧导航菜单中,点击API,然后点击创建 API按钮:

图 11.6–创建 API Auth0 客户端
名称可以是我们选择的任何内容,但标识符设置必须与我们在租户上设置的默认访问群体匹配。确保签名算法设置为RS256,然后点击创建按钮。
这就完成了 Auth0 的设置。
接下来,我们将把 ASP.NET 后端与 Auth0 集成。
将我们的 ASP.NET 后端配置为使用 Auth0 进行身份验证
我们现在可以将 ASP.NET 后端更改为使用 Auth0 进行身份验证。让我们在 Visual Studio 中打开后端项目,并执行以下步骤:
-
Install the following NuGet package:
Microsoft.AspNetCore.Authentication.JwtBearer重要提示
确保所选软件包的版本受所用.NET 版本的支持。因此,例如,如果您的目标是.NET 5.0,则选择软件包版本
5.0.*。 -
Add the following
usingstatement to theStartupclass:using Microsoft.AspNetCore.Authentication.JwtBearer;在
Startup类中的ConfigureServices方法中添加以下行:public void ConfigureServices(IServiceCollection services) { ... services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(options => { options.Authority = Configuration["Auth0:Authority"]; options.Audience = Configuration["Auth0:Audience"]; }); }这增加了基于 JWT 的身份验证,将权限和预期受众指定为
appsettings.json设置。 -
Let's add the authentication middleware to the
Configuremethod. It needs to be placed between the routing and authorization middleware:public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { ... app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); ... }这将验证每个请求中的访问令牌(如果存在)。如果检查成功,将设置请求上下文中的用户。
-
The final step is to add the settings in
appsettings.json, which we have referenced:{ ..., "Auth0": { "Authority": "https://your-tentant-id.auth0.com/", "Audience": "https://qanda" } }我们需要将 Auth0 租户 ID 替换为
Authority字段。租户 ID 可以在用户头像左侧的 Auth0 中找到:

图 11.7–Auth0 用户化身
因此,前面承租人的Authority为https://your-tenant-id.auth0.com/。Audience字段需要匹配我们在 Auth0 中指定的受众。
我们的 web API 现在正在验证请求中的访问令牌。
让我们快速回顾一下我们在本节中所做的工作。我们告诉我们的身份提供者到前端的路径以及登录和注销的路径。身份提供者通常为我们提供一个管理页面来提供这些信息。我们还告诉 ASP.NET 使用Startup类中Configure方法中的UseAuthentication方法验证请求中的承载令牌。使用ConfigureServices中的AddAuthentication方法配置验证。
我们将在下一节中开始保护一些端点。
保护端点
我们将通过保护questions端点以添加、更新和删除问题以及发布答案来开始本节,以便只有经过身份验证的用户才能执行这些操作。然后,我们将继续实现和使用自定义授权策略,以便只有问题的作者才能更新或删除它。
通过简单授权保护端点
让我们通过执行以下步骤来保护POST、PUT和DELETEHTTP 方法的questions端点:
-
打开
QuestionsController并添加以下using语句:using Microsoft.AspNetCore.Authorization; -
为了确保操作的安全,我们用一个
Authorize属性装饰它们。将此属性添加到PostQuestion、PutQuestion、DeleteQuestion和PostAnswer方法中:[Authorize] [HttpPost] public async ... PostQuestion(QuestionPostRequest questionPostRequest) ... [Authorize] [HttpPut("{questionId}")] public async ... PutQuestion(int questionId, QuestionPutRequest questionPutRequest) ... [Authorize] [HttpDelete("{questionId}")] public async ... DeleteQuestion(int questionId) ... [Authorize] [HttpPost("answer")] public async ... PostAnswer(AnswerPostRequest answerPostRequest) ... -
按F5运行 Visual Studio 项目。当浏览器以
api/questions路径打开时,我们会注意到数据已成功返回。这意味着GetQuestions操作方法是不受保护的,正如我们预期的那样。 -
Open Postman now and try to post a question:
![Figure 11.8 – Accessing a protected endpoint in Postman without being authenticated]()
图 11.8–在未经身份验证的情况下访问 Postman 中的受保护端点
我们收到状态代码为401的响应。这表明此操作方法现在受到保护。
-
我们可以从 Auth0 获取测试访问令牌,以检查是否可以使用有效令牌发布问题。在 Auth0 中,点击左侧导航菜单中的API,然后点击我们的QandAAPI。
-
点击测试选项卡,我们将看到一个可用于测试目的的令牌。
-
Click the COPY TOKEN option to copy the access token to the clipboard:
![Figure 11.9 – Getting a test token from Auth0]()
图 11.9–从 Auth0 获取测试令牌
-
Back in Postman, we need to add this token to an
AuthorizationHTTP header after thebearerword and a space:![Figure 11.10 – Adding the Auth0 bearer token to an Authorization HTTP header in Postman]()
图 11.10–将 Auth0 承载令牌添加到 Postman 中的授权 HTTP 头中
-
If we send the request, it will now be successful:
![Figure 11.11 – Successfully accessing a protected endpoint in Postman]()
图 11.11–成功访问 Postman 中的受保护端点
-
按Shift+F5停止 Visual Studio 项目运行,以便我们可以添加更多代码。
因此,一旦认证中间件就位,Authorize属性将保护动作方法。如果需要保护整个控制器,Authorize属性可以修饰controller类:
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class QuestionsController : ControllerBase
控制器中的所有动作方法都将受到保护,而无需指定Authorize属性。我们还可以使用AllowAnonymous属性取消受保护控制器中的操作方法的保护:
[AllowAnonymous]
[HttpGet]
public IEnumerable<QuestionGetManyResponse> GetQuestions(string search, bool includeAnswers, int page = 1, int pageSize = 20)
因此,在我们的示例中,我们可以使用Authorize属性保护整个控制器,并使用AllowAnonymous属性取消对GetQuestions、GetUnansweredQuestions和GetQuestion动作方法的保护,以实现我们想要的行为。
接下来,我们将学习如何使用端点授权实现策略检查。
使用自定义授权策略保护端点
目前,任何经过身份验证的用户都可以更新或删除问题。我们将实现并使用自定义授权策略,并使用它强制执行只有问题的作者才能执行这些操作。让我们执行以下步骤:
-
In the
Startupclass, let's add the followingusingstatements:using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Authorization; using QandA.Authorization;注意,
QandA.Authorization名称空间的引用还不存在。我们将在后面的步骤中实现这一点。 -
We'll need to eventually call an Auth0 web service, so let's make the HTTP client available in the
ConfigureServicesmethod:public void ConfigureServices(IServiceCollection services) { ... services.AddHttpClient(); }授权策略在名为
MustBeQuestionAuthorRequirement的类中定义了其需求,我们将在后面的步骤中实现。 -
Let's also add an authorization policy called
MustBeQuestionAuthor:public void ConfigureServices(IServiceCollection services) { ... services.AddHttpClient(); services.AddAuthorization(options => options.AddPolicy("MustBeQuestionAuthor", policy => policy.Requirements .Add(new MustBeQuestionAuthorRequirement()))); }授权策略在名为
MustBeQuestionAuthorRequirement的类中定义了其需求,我们将在后面的步骤中实现。 -
We also need to have a handler for the requirement, so let's register this for dependency injection:
public void ConfigureServices(IServiceCollection services) { ... services.AddHttpClient(); services.AddAuthorization(...); services.AddScoped< IAuthorizationHandler, MustBeQuestionAuthorHandler>(); }因此,
MustBeQuestionAuthorRequirement的处理程序将在名为MustBeQuestionAuthorHandler的类中实现。 -
Our
MustBeQuestionAuthorHandlerclass will need access to the HTTP requests to find out the question that is being requested. We need to registerHttpContextAccessorfor dependency injection to get access to the HTTP request information in a class. Let's do this now:public void ConfigureServices(IServiceCollection services) { ... services.AddHttpClient(); services.AddAuthorization(...); services.AddScoped< IAuthorizationHandler, MustBeQuestionAuthorHandler>(); services.AddHttpContextAccessor(); }注意,
AddHttpContextAccessor是AddSingleton<IHttpContextAccessor,HttpContextAccessor>的一种方便方法。 -
我们现在要创建
MustBeQuestionAuthorRequirement类。让我们在项目的根目录中创建一个名为Authorization的文件夹,然后创建一个名为MustBeQuestionAuthorRequirement的类,其中包含以下内容:using Microsoft.AspNetCore.Authorization; namespace QandA.Authorization { public class MustBeQuestionAuthorRequirement: IAuthorizationRequirement { public MustBeQuestionAuthorRequirement() { } } } -
Next, we'll create the handler class for this requirement. Create a class called
MustBeQuestionAuthorHandlerwith the following content in theAuthorizationfolder:using System; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using QandA.Data; namespace QandA.Authorization { public class MustBeQuestionAuthorHandler: AuthorizationHandler<MustBeQuestionAuthorRequirement> { private readonly IDataRepository _dataRepository; private readonly IHttpContextAccessor _httpContextAccessor; public MustBeQuestionAuthorHandler( IDataRepository dataRepository, IHttpContextAccessor httpContextAccessor) { _dataRepository = dataRepository; _httpContextAccessor = httpContextAccessor; } protected async override Task HandleRequirementAsync( AuthorizationHandlerContext context, MustBeQuestionAuthorRequirement requirement) { // TODO - check that the user is authenticated // TODO - get the question id from the request // TODO - get the user id from the name // identifier claim // TODO - get the question from the data // repository // TODO - if the question can't be found go to // the next piece of middleware // TODO - return failure if the user id in the // question from the data repository is // different to the user id in the request // TODO - return success if we manage to get // here } } }这继承自
AuthorizationHandler类,该类将其正在处理的需求作为通用参数。我们已经将数据存储库和 HTTP 上下文注入到该类中。 -
We now need to implement the
HandleRequirementAsyncmethod. The first task is to check that the user is authenticated:protected async override Task HandleRequirementAsync( AuthorizationHandlerContext context, MustBeQuestionAuthorRequirement requirement) { if (!context.User.Identity.IsAuthenticated) { context.Fail(); return; } // TODO - get the question id from the request // TODO - get the user id from the name identifier // claim // TODO - get the question from the data repository // TODO - if the question can't be found go to the // next piece of middleware // TODO - return failure if the user id in the // question from the data repository is different // to the user id in the request // TODO - return success if we manage to get here }方法中的
context参数包含Identity属性中的用户身份信息。我们使用Identity对象中的IsAuthenticated属性来确定用户是否经过身份验证。我们在context参数上调用Fail方法,告诉它需求失败。 -
Next, we need to get
questionIdfrom the request path:protected async override Task HandleRequirementAsync( AuthorizationHandlerContext context, MustBeQuestionAuthorRequirement requirement) { if (!context.User.Identity.IsAuthenticated) { context.Fail(); return; } var questionId = _httpContextAccessor.HttpContext.Request .RouteValues["questionId"]; int questionIdAsInt = Convert.ToInt32(questionId); // TODO - get the user id from the name identifier // claim // TODO - get the question from the data repository // TODO - if the question can't be found go to the //next piece of middleware // TODO - return failure if the user id in the //question from the data repository is different // to the user id in the request // TODO - return success if we manage to get here }我们在 HTTP 上下文请求中使用
RouteValues字典来获取访问权限,以获取问题 ID。RoutesValues字典包含控制器名称、动作方法名称以及动作方法的参数。 -
Next, we need to get
userIdfrom the user's identity claims:
```cs
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var questionId =
_httpContextAccessor.HttpContext.Request
.RouteValues["questionId"];
int questionIdAsInt = Convert.ToInt32(questionId);
var userId =
context.User.FindFirst(ClaimTypes.NameIdentifier).
Value;
// TODO - get the question from the data repository
// TODO - if the question can't be found go to the
// next piece of middleware
// TODO - return failure if the user id in the
//question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
```
`userId`存储在名称标识符声明中。
重要提示
声明是来自可信来源的有关用户的信息。索赔代表的是主体是什么,而不是主体能做什么。ASP.NET 身份验证中间件会自动为我们将`userId`放入名称标识符声明中。
我们在`context`参数中的`User`对象上使用了`FindFirst`方法来获取名称标识符声明的值。`User`对象在读取访问令牌后,在请求管道的前面由身份验证中间件填充声明。
- 我们现在可以从数据存储库中获取问题。如果没有找到问题,我们希望通过该要求,因为我们希望返回 HTTP 状态码 404(未找到),而不是 401(未授权)。控制器中的操作方法将能够执行并返回 HTTP 404 状态代码:
```cs
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var userId =
context.User.FindFirst(ClaimTypes.NameIdentifier).Value;
var question =
await _dataRepository.GetQuestion(questionIdAsInt);
if (question == null)
{
// let it through so the controller can return a 404
context.Succeed(requirement);
return;
}
// TODO - return failure if the user id in the
//question from the data repository is different
// to the user id in the request
// TODO - return success if we manage to get here
}
```
- 现在,检查请求中的
userId是否与数据库中的问题匹配,如果不匹配,则返回Fail:
```cs
protected async override Task
HandleRequirementAsync(
AuthorizationHandlerContext context,
MustBeQuestionAuthorRequirement requirement)
{
...
var question =
await _dataRepository.GetQuestion(questionIdAsInt);
if (question == null)
{
// let it through so the controller can return
// a 404
context.Succeed(requirement);
return;
}
if (question.UserId != userId)
{
context.Fail();
return;
}
context.Succeed(requirement);
}
```
- The final task is to add the policy we have just created to the
Authorizeattribute on the relevant action methods inQuestionsController:
```cs
[Authorize(Policy = "MustBeQuestionAuthor")]
[HttpPut("{questionId}")]
public ... PutQuestion(int questionId, QuestionPutRequest questionPutRequest)
...
[Authorize(Policy = "MustBeQuestionAuthor")]
[HttpDelete("{questionId}")]
public ... DeleteQuestion(int questionId)
...
```
现在,我们已将授权策略应用于更新和删除问题。
不幸的是,我们不能使用 Auth0 提供的测试访问令牌来尝试这个方法,但我们将返回到这里,并确认它在[*第 12 章*](12.html#_idTextAnchor257)*与 RESTful API 交互*中起作用。
自定义授权策略为我们实现复杂的授权规则提供了很大的灵活性和能力。正如我们在示例中所经历的那样,单个策略可以集中实施并用于不同的操作方法。
让我们快速回顾一下我们在本节学到的内容:
- 我们通过使用
Authorize属性装饰控制器类或操作方法来保护端点或端点内的特定 HTTP 方法。 - 我们可以在
Authorize属性中引用自定义授权策略,并通过扩展AuthorizationHandler类来实现其逻辑。
在下一节中,我们将学习如何在 API 控制器中引用经过身份验证的用户的信息。
发布问题和答案时使用认证用户
既然我们的 RESTAPI 知道用户与之交互,我们就可以用它来发布正确的用户问答。让我们执行以下步骤:
-
我们首先在
QuestionsController.cs中添加以下using语句:using System.Security.Claims; using Microsoft.Extensions.Configuration; using System.Net.Http; using System.Text.Json; -
Let's focus on posting a question first by posting it with the authenticated user's ID:
public async ... PostQuestion(QuestionPostRequest questionPostRequest) { var savedQuestion = await _dataRepository.PostQuestion(new QuestionPostFullRequest { Title = questionPostRequest.Title, Content = questionPostRequest.Content, UserId = User.FindFirst(ClaimTypes.NameIdentifier).Value, UserName = "bob.test@test.com", Created = DateTime.UtcNow }); ... }ControllerBase包含一个User属性,该属性向我们提供有关经过身份验证的用户的信息,包括声明。因此,我们使用FindFirst方法来获取名称标识符声明的值。 -
Unfortunately, the username isn't in the JWT, so we are going to need to get this from Auth0. Let's create a model that will represent an Auth0 user. Create a new class called
Userin theModelsfolder with the following content:namespace QandA.Data.Models { public class User { public string Name { get; set; } } }请注意,我们可以从 Auth0 获得更多用户信息,但我们只对应用中的用户名感兴趣。
-
现在,将 HTTP 客户端以及从 Auth0 获取用户信息的路径注入
QuestionsController:... private readonly IHttpClientFactory _clientFactory; private readonly string _auth0UserInfo; public QuestionsController( ..., IHttpClientFactory clientFactory, IConfiguration configuration) { ... _clientFactory = clientFactory; _auth0UserInfo = $"{configuration["Auth0:Authority"]}userinfo"; } -
Let's create a method that will call Auth0 to get the username. So, add the following method at the bottom of
QuestionsController:private async Task<string> GetUserName() { var request = new HttpRequestMessage( HttpMethod.Get, _auth0UserInfo); request.Headers.Add( "Authorization", Request.Headers["Authorization"].First()); var client = _clientFactory.CreateClient(); var response = await client.SendAsync(request); if (response.IsSuccessStatusCode) { var jsonContent = await response.Content.ReadAsStringAsync(); var user = JsonSerializer.Deserialize<User>( jsonContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return user.Name; } else { return ""; } }从当前请求到 ASP.NET 后端,我们使用
AuthorizationHTTP 头向 Auth0 用户信息端点发出 GET HTTP 请求。此 HTTP 标头将包含访问令牌,该令牌将使我们能够访问 Auth0 端点。如果请求成功,我们将响应体解析到
User模型中。请注意,我们在.NET 中使用了新的 JSON 序列化程序。还要注意,我们指定了不区分大小写的属性映射,以便响应中的 camel case 字段正确映射到类中的 title case 属性。 -
现在使用
PostQuestion方法中的用户名:public async ... PostQuestion(QuestionPostRequest questionPostRequest) { var savedQuestion = await _dataRepository.PostQuestion(new QuestionPostFullRequest { Title = questionPostRequest.Title, Content = questionPostRequest.Content, UserId = User.FindFirst(ClaimTypes.NameIdentifier).Value, UserName = await GetUserName(), Created = DateTime.UtcNow }); ... } -
在
PostAnswer动作方式[Authorize] [HttpPost("answer")] public ActionResult<AnswerGetResponse> PostAnswer(AnswerPostRequest answerPostRequest) { ... var savedAnswer = _dataRepository.PostAnswer(new AnswerPostFullRequest { QuestionId = answerPostRequest.QuestionId.Value, Content = answerPostRequest.Content, UserId = User.FindFirst(ClaimTypes.NameIdentifier).Value, UserName = await GetUserName(), Created = DateTime.UtcNow }); ... }中也做同样的动作
不幸的是,我们无法使用 Auth0 提供的测试访问令牌来尝试此操作,因为它没有与之关联的用户。但是,我们将返回到这里,并确认它在第 12 章与 RESTful API 交互中起作用。
我们的问题控制器现在正在与经过身份验证的用户进行良好的交互。
综上所述,关于经过身份验证的用户的信息可以在 API 控制器的User属性中找到。User属性中的信息仅限于 JWT 中包含的信息。通过从身份服务提供商中的相关端点请求,可以获得其他信息。
添加 CORS
CORS代表跨源资源共享,是一种使用 HTTP 头通知浏览器让 web 应用在特定的源(域)上运行的机制,以便它有权访问不同源服务器上的特定资源。
在本节中,我们将从尝试从浏览器应用访问 REST API 开始,并发现它不可访问。然后,我们将在 RESTAPI 中添加和配置 CORS,并验证它是否可以从浏览器应用访问。
让我们执行以下步骤:
-
在 Visual Studio 中按F5运行后端项目。
-
在浏览器中,浏览至https://resttesttest.com/ 地址。这是一个浏览器应用,我们可以使用它来检查 REST API 是否可以从浏览器访问。
-
Enter the path to the
questionsendpoint and press the Ajax request button. We see that the request is unsuccessful:![Figure 11.12 – CORS error when accessing the REST API from the browser]()
图 11.12–从浏览器访问 REST API 时出现 CORS 错误
-
Stop the backend from running by pressing Shift + F5 in Visual Studio and enter the following statement at the bottom of the
ConfigureServicesmethod inStartup.cs:public void ConfigureServices(IServiceCollection services) { ... services.AddCors(options => options.AddPolicy("CorsPolicy", builder => builder .AllowAnyMethod() .AllowAnyHeader() .WithOrigins(Configuration["Frontend"]))); }这定义了一个 CORS 策略,允许
appsettings.json中指定的源访问 REST API。它还允许使用任何 HTTP 方法和任何 HTTP 头进行请求。 -
现在,我们可以在
Configure方法中启用此策略的使用。让我们在Configure方法中的路由和身份验证之间添加以下语句:public void Configure(IApplicationBuilder app, IHostingEnvironment env) { ... app.Routing(); app.UseCors("CorsPolicy"); app.UseAuthentication(); ... } -
在
appsettings.json中,添加以下设置,允许浏览器应用访问 REST API:{ ..., "Frontend": "https://resttesttest.com" } -
按F5再次运行后端项目。
-
In the browser app, press the Ajax request button again. We see that the request is successful this time:
![Figure 11.13 – Successful cross-origin request]()
图 11.13–成功的跨来源请求
-
在 Visual Studio 中按Shift+F5停止后端运行。在
appsettings.json中,将Frontend设置更改为我们前端的本地地址:{ ..., "Frontend": "http://localhost:3000" }
CORS 可以直接添加到 ASP.NET 中。首先,我们创建一个策略并在请求管道中使用它。重要的是,将UseCors方法置于Configure方法中的UseRouting和UseEndpoint方法之间,以使其正确运行。
总结
Auth0 是一个 OIDC 身份提供程序,我们可以利用它对客户端进行身份验证和授权。成功登录后,身份提供商可以提供 JWT 格式的访问令牌。可以在访问受保护资源的请求中使用访问令牌。
ASP.NET 可以先在Startup类的ConfigureServices方法中使用AddAuthentication方法,然后在Configure方法中使用UseAuthentication方法来验证 JWTs。
将身份验证添加到请求管道后,可以通过使用Authorize属性修饰控制器和操作方法来保护 REST API 资源。然后可以使用AllowAnonymous属性取消受保护的操作方法的保护。我们可以通过控制器的User属性访问有关用户的信息,例如他们的声明。
自定义策略是允许某一组用户访问受保护资源的强大方式。必须实现定义策略逻辑的需求和处理程序类。通过将策略名称作为参数传入,可以使用Authorize属性将策略应用于端点。
ASP.NET 不允许开箱即用的跨源请求。我们需要为需要访问 RESTAPI 的 web 客户端添加并启用 CORS 策略。
我们的后端现在接近完成。在下一章中,我们将把注意力转回前端,并开始与我们构建的后端进行交互。
问题
让我们回答以下问题来实践本章所学内容:
-
在
Startup类中的Configure方法中,以下有什么问题?public void Configure(...) { ... app.UseEndpoints(...); app.UseAuthentication(); } -
可以向受保护的操作方法添加哪些属性以允许未经身份验证的用户访问它?
-
We are building an app with an ASP.NET backend and using an identity provider to authenticate users. The default audience has been set to
http://my-appin the identity provider, and we have configured the authentication service in our ASP.NET backend as follows:services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }).AddJwtBearer(options => { ... options.Audience = "https://myapp"; });当我们尝试访问 ASP.NET 后端中受保护的资源时,会收到 HTTP 401 状态代码。这里有什么问题?
-
A JWT has the following decoded payload data. On what date and time does it expire?
{ "nbf": 1609671475, "auth_time": 1609671475, "exp": 1609757875, ... }提示:您可以使用以下网站解码 Unix 日期:https://www.unixtimestamp.com/index.php 。
-
We have a valid access token from an identity provider and are using it to access a protected resource. We have set the following HTTP header in the request:
Authorisation: bearer some-access-token不过,我们从请求中收到一个 HTTP401 状态代码。有什么问题?
-
我们如何在 API 控制器之外的类中访问 HTTP 请求信息?
-
在 API 控制器中,我们如何访问经过身份验证的用户 ID?
答案
-
问题是,在请求管道中处理端点之后才进行身份验证,这意味着即使请求具有有效的访问令牌,用户也将始终在控制器操作方法中未经身份验证。这意味着永远无法访问受保护的资源。在
Configure方法中UseAuthentication应该在UseEndpoints之前。 -
可以向受保护的操作方法添加一个
AllowAnonymous属性,以允许未经身份验证的用户访问该方法。 -
问题是 ASP.NET Core 后端验证 JWT 中的访问群体是否为
https://myapp,但身份提供程序已配置为将访问群体设置为http://my-app。这会导致请求未经授权。 -
exp字段给出了失效日期,即 1970 年 1 月 1 日之后的1609757875秒,反过来,失效日期是 2021 年 1 月 4 日 10:57:55(GMT)。 -
问题是 HTTP 头名称必须是
Authorization——也就是说,我们将其拼写为s而不是z。 -
Request information can be accessed by injecting
IHttpContextAccessorinto a class as follows:private readonly IHttpContextAccessor _httpContextAccessor; public MyClass(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public SomeMethod() { var request = _httpContextAccessor.HttpContext.Request; }HttpContextAccessor服务必须添加到Startup类中的ConfigureServices方法中,如下所示:services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); -
我们可以访问控制器的
User属性中的用户 ID,如下所示:User.FindFirst(ClaimTypes.NameIdentifier).Value
进一步阅读
以下是一些有用的链接,以了解有关本章所涵盖主题的更多信息:
- 开放 ID 连接体 C:https://openid.net/connect/
- ASP.NET 安全与身份:https://docs.microsoft.com/en-us/aspnet/core/security
- JSON 网络令牌:https://jwt.io/introduction/
- 认证 0:https://auth0.com/docs
十二、与 RESTful API 交互
完成 RESTAPI 后,现在是在 React 前端应用中与之交互的时候了。首先,我们将使用浏览器的fetch功能与未经验证的端点进行交互,以获取问题。我们将处理用户在获取数据之前离开页面的情况,以防止状态错误。
我们将利用上一章中设置的 Auth0 租户安全地让用户登录和退出我们的应用。然后,我们将使用 Auth0 中的访问令牌访问受保护的端点。我们还将确保经过身份验证的用户只能看到他们有权执行的选项。
到本章结束时,我们的前端将与后端充分、安全、可靠地交互。
在本章中,我们将介绍以下主题:
- 使用
fetch与未经验证的 REST API 端点交互 - 从前端与 Auth0 交互
- 控制已验证的选项
- 使用
fetch与经过身份验证的 REST API 端点交互 - 中止数据提取
技术要求
在本章中,我们将使用以下工具和服务:
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少是版本 8.2,
npm至少是版本 5.2。 - Visual Studio 2019:我们将使用它来运行我们的 ASP.NET Core 代码后端。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- Auth0:我们将使用上一章设置的租户对用户进行身份验证和管理。
- Q 和 A:我们将从 Q 和一个前端项目开始,该项目将在的 GitHub 上提供 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-12/start文件夹中的。从这个项目开始,这一章中的所有代码都能正常工作,这一点很重要。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/37CQqNx
使用 fetch 与未经验证的 REST API 端点交互
在部分中,我们将使用本机fetch函数从真正的 REST API 中获取未回答的问题。然后,我们将在fetch上使用包装器函数,以使与后端的交互更加容易。这种方法还将集中与 RESTAPI 交互的代码,当我们想要对其进行改进时,这是有益的。然后我们将继续使用真正的 REST API 来获取单个问题并搜索问题。
从 REST API 获取未回答的问题
当显示未回答问题列表时,我们将开始与主页上的 REST API 交互。HomePage组件实际上不会改变,但QuestionsData.ts中的getUnansweredQuestions功能会改变。在getUnansweredQuestions中,我们将利用本机浏览器fetch功能与 REST API 交互。如果您还没有,让我们打开 VisualStudio 代码并执行以下步骤。
打开QuestionsData.ts,找到getUnansweredQuestions功能,将实现替换为以下内容:
export const getUnansweredQuestions = async (): Promise<
QuestionData[]
> => {
let unansweredQuestions: QuestionData[] = [];
// TODO - call api/questions/unanswered
// TODO - put response body in unansweredQuestions
return unansweredQuestions;
};
该函数采用与以前完全相同的参数并返回相同的类型,因此使用该函数的组件不应受到我们即将进行的更改的影响。按照此处给出的步骤进行操作:
-
Let's call
fetchto request unanswered questions from our backend:export const getUnansweredQuestions = async (): Promise< QuestionData[] > => { let unansweredQuestions: QuestionData[] = []; const response = await fetch( 'http://localhost:17525/api/questions/unanswered' ) // TODO - put response body in unansweredQuestions return unansweredQuestions; };因此,对于
GET请求,我们只需将请求的路径放在fetch参数中。如果您的 REST API 运行在不同的端口上,那么不要忘记更改路径,以便它调用您的 REST API。注意
fetch调用之前的await关键字。这是因为它是一个异步函数,我们希望在执行下一条语句之前,等待它的承诺得到解决。我们已经为从
fetch函数返回的 HTTP 响应对象分配了一个response变量。以下是我们可以与之交互的response对象的一些有用属性:ok:响应是否成功(即 HTTP 状态码是否在 200-299 范围内)status:响应的 HTTP 状态码headers:允许访问 HTTP 响应中的头的对象
-
响应对象上还有一个名为
json的方法。这可用于请求已解析的 JSON 主体。将调用下面突出显示的行添加到fetch函数:export const getUnansweredQuestions = async (): Promise< QuestionData[] > => { let unansweredQuestions: QuestionData[] = []; const response = await fetch( "http://localhost:17525/api/questions/unanswered" ); unansweredQuestions = await response.json(); return unansweredQuestions; }; -
Before we give this a try, open the backend project in Visual Studio and substitute the Auth0 tenant ID into the
Authoritysetting inappsettings.json.在上一章中,我们已经发现了在哪里可以找到 Auth0 租户,但作为提醒,它位于用户头像的左侧:
![Figure 12.1 – Auth0 tenant ID]()
图 12.1–Auth0 租户 ID
-
按F5启动后端运行。我们将在本章的整个章节中继续运行。
-
Back in Visual Studio Code, start our frontend by typing
npm startin the Terminal. When the app runs, we get the following error:![Figure 12.2 – Error on question created date]()
图 12.2–问题创建日期错误
这里的问题是,
created属性被反序列化为字符串,而不是Question组件所期望的Date对象。 -
Let's resolve this by mapping the
createdproperty to aDateobject in thereturnstatement:export const getUnansweredQuestions = async (): Promise< QuestionData[] > => { let unansweredQuestions: QuestionData[] = []; const response = await fetch( 'http://localhost:17525/api/questions/unanswered', ); unansweredQuestions = await response.json(); return unansweredQuestions.map((question) => ({ ...question, created: new Date(question.created), })); };我们使用数组
map函数迭代所有问题,返回原始问题的副本(使用扩展语法),然后从string日期开始用Date对象覆盖created属性。 -
如果我们保存文件并查看正在运行的应用,我们将看到未回答的问题正确输出:

图 12.3:未回答的问题正确输出
好东西!我们的 React 应用现在正在与 REST API 交互!
提取出一个通用 HTTP 函数
我们需要在每个需要与 RESTAPI 交互的函数中使用fetch函数。因此,我们将创建一个通用的http函数,用于发出所有 HTTP 请求。这将很好地集中调用 RESTAPI 的代码。让我们执行以下步骤:
-
Create a new file called
http.tswith the following content:import { webAPIUrl } from './AppSettings'; export interface HttpRequest<REQB> { path: string; } export interface HttpResponse<RESB> { ok: boolean; body?: RESB; }我们已经开始从
AppSettings.ts导入根路径到 RESTAPI,这是在我们的初学者项目中设置的。AppSettings.ts文件是我们构建所有不同路径的地方,这些路径在开发和生产之间会有所不同。确保webAPIUrl包含 REST API 的正确路径。我们还为请求和响应定义了接口。请注意,接口包含请求和响应中主体类型的通用参数。
-
Let's use these interfaces to implement a generic
httpfunction that we'll use to make HTTP requests:export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { };我们已经将请求主体的类型默认为
undefined,因此函数的使用者不需要传递它。 -
Use
fetchto invoke the request:export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}` ); const response = await fetch(request); };请注意,我们创建了一个
Request对象的新实例,并将其传递到fetch,而不仅仅是将请求路径传递到fetch。这将在本章后面的部分中非常有用,因为我们为不同的 HTTP 方法和身份验证扩展了此功能。 -
我们通过检查
response对象export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}`, ); const response = await fetch(request); if (response.ok) { } else { } };中的
ok属性来检查请求是否成功 -
如果请求成功,我们可以使用响应对象
export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}`, ); const response = await fetch(request); if (response.ok) { const body = await response.json(); } else { } };中的
json方法获取主体 -
Return the response object with its parsed body for a successful request and just the response object for an unsuccessful request:
export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}`, ); const response = await fetch(request); if (response.ok) { const body = await response.json(); return { ok: response.ok, body }; } else { return { ok: response.ok }; } };如果响应不成功,我们将记录 HTTP 错误。
-
让我们调用一个名为
logError的函数来实现这一点,我们将在下一步中实现它。在返回不成功响应之前进行函数调用,并将request和response对象传递给它:export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}`, ); const response = await fetch(request); if (response.ok) { const body = await response.json(); return { ok: response.ok, body }; } else { logError(request, response); return { ok: response.ok }; } }; -
Add the following
logErrorfunction below thehttpfunction:const logError = async ( request: Request, response: Response, ) => { const contentType = response.headers.get( 'content-type', ); let body: any; if ( contentType && contentType.indexOf('application/json') !== -1 ) { body = await response.json(); } else { body = await response.text(); } console.error( `Error requesting ${request.method} ${request.url}`, body, ); };函数检查响应是否为 JSON 格式,如果是,则调用响应对象上的
json方法获取 JSON 正文。如果响应不是 JSON 格式,则使用响应对象上的text方法检索主体。然后,响应主体连同 HTTP 请求方法和路径一起输出到控制台。 -
回到
QuestionData.ts并利用我们刚刚在getUnansweredQuestions中实现的http功能。首先,我们需要导入它:import { http } from './http'; -
We can now refactor
getUnansweredQuestions:
```cs
export const getUnansweredQuestions = async (): Promise<
QuestionData[]
> => {
const result = await http<
QuestionDataFromServer[]
>({
path: '/questions/unanswered',
});
if (result.ok && result.body) {
return result.body.map(mapQuestionFromServer);
} else {
return [];
}
};
```
我们将`QuestionDataFromServer[]`作为预期的`response`传递到`http`函数作为预期的响应主体类型。`QuestionDataFromServer`是添加到本章初学者项目中的一个接口,`created`日期是一个字符串,与 REST API 的到达方式完全相同。
如果有响应主体,我们使用映射函数返回解析后的响应主体,并将`created`属性设置为正确的日期。否则,我们返回一个空数组。本章的起始项目中增加了`mapQuestionFromServer`映射功能。
这将在保存这些更改时呈现未回答的问题,就像以前一样:

图 12.4–未回答的问题正确输出
我们修改后的getUnansweredQuestions实现要好一点,因为 REST API 的根路径没有在其中硬编码,我们处理 HTTP 错误的能力也更好。在本章中,我们将继续使用并扩展我们的通用http函数。
从 REST API 获取问题
在本小节中,我们将重构现有的getQuestion函数,使用http函数从 RESTAPI 中获取一个问题。在QuestionsData.ts中执行以下步骤:
-
我们将首先清除当前的实现,如下所示:
export const getQuestion = async ( questionId: number, ): Promise<QuestionData | null> => { }; -
让我们发出 HTTP 请求以获取问题:
export const getQuestion = async ( questionId: number, ): Promise<QuestionData | null> => { const result = await http< QuestionDataFromServer >({ path: `/questions/${questionId}`, }); }; -
如果请求成功,则返回具有正确键入日期的响应正文;如果响应不成功,则返回
null:export const getQuestion = async ( questionId: number, ): Promise<QuestionData | null> => { const result = await http< QuestionDataFromServer >({ path: `/questions/${questionId}`, }); if (result.ok && result.body) { return mapQuestionFromServer(result.body); } else { return null; } }; -
当我们保存更改并转到 running app 中的问题页面时,我们将在屏幕上看到正确呈现的问题:

图 12.5–问题页面
我们没有对任何前端组件进行任何更改。美好的
使用 REST API 搜索问题
在本小节中,我们将重构现有的searchQuestion函数,使用http函数使用 RESTAPI 搜索问题。这与我们刚才所做的非常相似,因此我们将一次性完成:
export const searchQuestions = async (
criteria: string,
): Promise<QuestionData[]> => {
const result = await http<
QuestionDataFromServer[]
>({
path: `/questions?search=${criteria}`,
});
if (result.ok && result.body) {
return result.body.map(mapQuestionFromServer);
} else {
return [];
}
};
我们使用包含条件的search查询参数向questions端点发出请求。如果请求成功,我们返回带有创建的Date对象的响应体;如果请求失败,则返回空数组。
searchQuestions参数和返回类型没有改变。因此,当我们保存更改并在 running app 中搜索问题时,匹配的问题将正确呈现:

图 12.6–搜索页面
在下一节中,我们将暂停实现我们的通用http函数,并实现代码,以便用户通过 Auth0 登录我们的应用。
从前端与 Auth0 交互
在本节中,我们将从我们的 React 前端全面实施登录和注销流程。作为这些过程的一部分,我们将与 Auth0 进行交互。
我们将首先安装 auth0javascript 客户端,然后再创建 React 路由路由和逻辑来处理 Auth0 登录和注销过程。
在本节中,我们还将学习 React 上下文。我们将使用此 React 功能来集中信息和功能,以便组件可以轻松访问身份验证。
安装 Auth0 JavaScript 客户端
有一个用于单页应用的标准 Auth0 JavaScript 库,我们可以利用它与 Auth0 进行良好的交互。库的npm包称为@auth0/auth0-spa-js。让我们通过在 Visual Studio 代码终端中运行以下命令来安装它:
> npm install @auth0/auth0-spa-js
TypeScript 类型包含在此库中,因此单页应用的 Auth0 客户端库现在已安装在我们的项目中。
回顾登录和注销流程
让我们快速回顾一下我们的应用和 Auth0 之间的登录流程:
- 我们的应用重定向到 Auth0,以允许用户输入其凭据。
- 用户成功登录后,Auth0 将使用代码重定向回我们的应用。
- 然后,我们的应用可以使用代码从 Auth0 请求访问令牌。
签出流程如下所示:
- 我们的应用重定向到 Auth0 以执行注销。
- 然后重定向回我们的应用。
因此,我们的前端应用中将有以下路线:
/signin:我们的应用将导航到此页面以启动登录过程。此页面将调用 Auth0 客户端中的方法,该方法将重定向到 Auth0 中的页面。/signin-callback:这是我们应用中的页面,Auth0 将使用代码重定向回该页面。/signout:我们的应用将导航到此页面以启动注销流程。此页面将调用 Auth0 客户端中的方法,该方法将重定向到 Auth0 中的页面。/signout-callback:这是我们应用中的页面,在注销完成后,Auth0 将重定向回。
创建登录和注销路由
我们现在了解到,我们的应用中需要四条路径来处理登录和注销过程。SignInPage组件将处理signin和signin-callback两条路由。SignOutPage组件将处理signout和signout-callback两条路线。
我们的应用已经知道SignInPage组件和我们在App.tsx中声明的路线。但是,它没有处理来自 Auth0 的登录回调。我们的应用也没有处理注销。让我们通过以下步骤在App.tsx中实现所有这些:
-
我们首先将
SignOutPage组件导入App.tsx。在SignInPage的import语句下添加以下import语句:import { SignOutPage } from './SignOutPage'; -
We will start with the sign-in route. Instead of just referencing the
SignInPagecomponent, we need to render it and pass'signin'in theactionprop:<Route path="signin" element={<SignInPage action="signin" />} />SignInPage组件上还不存在action道具;因此,我们的应用目前不会编译。稍后我们将实现action道具。 -
接下来,我们在
<Route path="/signin-callback" element={<SignInPage action="signin-callback" />} />登录路径下添加一条登录回调路径
-
最后,我们将实现注销流程的路由:
<Route path="signout" element={ <SignOutPage action="signout" /> } /> <Route path="/signout-callback" element={ <SignOutPage action="signout-callback" /> } />
所有路线现在都已准备就绪,可用于登录、注册和注销流程。
实现中央认证上下文
我们将将身份验证的状态和函数放在代码的中心位置。我们可以使用 Redux,但我们将借此机会在 React 中使用上下文。
重要提示
React上下文是 React 中的标准功能,允许组件共享数据,而无需通过组件属性传递数据。有关 React 上下文的更多信息,请参见https://reactjs.org/docs/context.html 。
我们将把我们的身份验证状态和功能放在一个 React 上下文中,我们将提供给应用中的所有组件。让我们执行以下步骤:
-
在名为
Auth.tsx的src文件夹中使用以下import语句创建一个新文件:import React from 'react'; import createAuth0Client from '@auth0/auth0-spa-js'; import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client'; import { authSettings } from './AppSettings'; -
We'll start the implementation by creating a strongly typed context. Add the following code under the
importstatements inAuth.tsx:interface Auth0User { name: string; email: string; } interface IAuth0Context { isAuthenticated: boolean; user?: Auth0User; signIn: () => void; signOut: () => void; loading: boolean; } export const Auth0Context = React.createContext<IAuth0Context>({ isAuthenticated: false, signIn: () => {}, signOut: () => {}, loading: true });因此,我们的上下文为用户是否经过身份验证、用户的配置文件信息、登录和注销功能以及上下文是否正在加载提供了属性。
createContext函数需要上下文的默认值,因此我们传入了一个具有适当初始属性值的对象和用于登录和注销的空函数。 -
Let's provide a function that components can use to get access to the state and functions in the authentication context:
export const useAuth = () => React.useContext(Auth0Context);此为 React 中的定制挂钩。
重要提示
自定义挂钩是在组件中共享逻辑的机制。它们允许在组件外部使用 React 组件特性,例如
useState、useEffect和useContext。有关定制挂钩的更多信息,请访问https://reactjs.org/docs/hooks-custom.html 。自定义挂钩的通用命名约定是前缀为
use。所以,我们称我们的定制挂钩为useAuth。 -
Next, implement a provider component for the context:
export const AuthProvider: React.FC = ({ children, }) => { const [ isAuthenticated, setIsAuthenticated, ] = React.useState<boolean>(false); const [user, setUser] = React.useState< Auth0User | undefined >(undefined); const [ auth0Client, setAuth0Client, ] = React.useState<Auth0Client>(); const [loading, setLoading] = React.useState< boolean >(true); };我们使用了标准类型
FC,从 React 类型到组件支柱类型。这包含我们正在使用的children道具的类型。我们已经声明了一个状态,用于保存用户是否经过身份验证、用户的配置文件信息、来自 Auth0 的客户端对象以及上下文是否正在加载。
-
Add the following JSX to the
Providercomponent:export const AuthProvider: React.FC = ({ children, }) => { ... return ( <Auth0Context.Provider value={{ isAuthenticated, user, signIn: () => getAuth0ClientFromState().loginWithRedirect(), signOut: () => getAuth0ClientFromState().logout({ client_id: authSettings.client_id, returnTo: window.location.origin + '/signout-callback', }), loading, }} > {children} </Auth0Context.Provider> ); };这将从 React 返回上下文的
Provider组件。我们在value属性中传递的对象将对我们正在创建的上下文的使用者可用。因此,我们为上下文消费者提供了用户是否经过身份验证、用户配置文件以及登录和注销功能的访问权限。 -
The functions for signing in and out reference a function called
getAuth0ClientFromState, which isn't implemented yet. So, let's add this inside our provider component:export const AuthProvider: FC = ({ children }) => { ... const getAuth0ClientFromState = () => { if (auth0Client === undefined) { throw new Error('Auth0 client not set'); } return auth0Client; }; return ( <Auth0Context.Provider ... </Auth0Context.Provider> ); };因此,此函数从状态返回 Auth0 客户端,但如果是
undefined,则抛出错误。 -
When the provider is loaded, we want to create the instance of the Auth0 client and set the state values. Let's implement this using a
useEffectHook:export const AuthProvider: FC = ({ children }) => { ... React.useEffect(() => { const initAuth0 = async () => { setLoading(true); const auth0FromHook = await createAuth0Client(authSettings); setAuth0Client(auth0FromHook); const isAuthenticatedFromHook = await auth0FromHook.isAuthenticated(); if (isAuthenticatedFromHook) { const user = await auth0FromHook.getUser(); setUser(user); } setIsAuthenticated(isAuthenticatedFromHook); setLoading(false); }; initAuth0(); }, []); ... return ( <Auth0Context.Provider ... </Auth0Context.Provider> ); };我们将逻辑放在一个嵌套的
initAuth0函数中并调用它,因为逻辑是异步的。我们使用 Auth0 中的
createAuth0Client函数来创建 Auth0 客户端实例。我们使用authSettings变量传入一些设置,该变量位于名为AppSettings.ts的文件中。我们将在本章后面更改这些设置,以引用特定的 Auth0 实例。我们调用 Auth0 客户端中的
isAuthenticated函数来确定用户是否经过身份验证,并设置我们的isAuthenticated状态值。如果用户通过身份验证,我们调用 Auth0 客户端中的getUser函数获取用户配置文件并设置我们的user状态。 -
We want to handle the sign-in callback when the provider loads, so let's add a branch of code to do that:
const initAuth0 = async () => { setLoading(true); const auth0FromHook = await createAuth0Client(authSettings); setAuth0Client(auth0FromHook); if ( window.location.pathname === '/signin-callback' && window.location.search.indexOf('code=') > -1 ) { await auth0FromHook.handleRedirectCallback(); window.location.replace(window.location.origin); } const isAuthenticatedFromHook = await auth0FromHook. isAuthenticated(); if (isAuthenticatedFromHook) { const user = await auth0FromHook.getUser(); setUser(user); } setIsAuthenticated(isAuthenticatedFromHook); setLoading(false); };我们调用 Auth0 client
handleRedirectCallback函数,该函数将解析 URL,提取代码,并将其存储在内部变量中。完成后,我们还将用户重定向到主页。这是我们的身份验证提供程序组件完成。
-
The last item we are going to implement in
Auth.tsxis a function that gets the access token:export const getAccessToken = async () => { const auth0FromHook = await createAuth0Client(authSettings); const accessToken = await auth0FromHook.getTokenSilently(); return accessToken; };这将调用 Auth0 客户端
getTokenSilently函数,该函数将向 Auth0token端点发出请求,以安全地获取访问令牌。我们将在本章后面使用
getAccessToken函数向受保护的资源发出 REST API 请求。 -
让我们转到
App.tsx并导入我们的身份验证提供程序组件:
```cs
import { AuthProvider } from './Auth';
```
- 现在,我们将为应用中的所有组件提供身份验证上下文:
```cs
function App() {
return (
<AuthProvider>
<BrowserRouter>
...
</BrowserRouter>
</AuthProvider>
);
};
```
这是我们的中心身份验证上下文。我们将在整个本章中广泛使用这一点。
由于SignInPage和SignOutPage组件上缺少action道具,因此App组件仍然没有编译。我们将在下一步解决这些问题。
实施签到流程
我们将在SignInPage.tsx中实现登录页面如下:
-
We'll start by adding the following
importstatements:import { StatusText } from './Styles'; import { useAuth } from './Auth';StatusText是一种共享样式,我们将在通知用户我们正在重定向到 Auth0 或从 Auth0 重定向时使用。useAuth是我们之前实现的自定义钩子,它将允许我们访问身份验证上下文。 -
Let's define the
Propstype for the page component:type SigninAction = 'signin' | 'signin-callback'; interface Props { action: SigninAction; }组件接受一个
action道具,该道具给出登录过程的当前阶段。 -
我们现在可以开始实现该组件了。将当前的
SignInPage部件更换为以下部件:export const SignInPage = ({ action }: Props) => { }; -
让我们从身份验证上下文中获取
signIn函数:export const SignInPage = ({ action }: Props) => { const { signIn } = useAuth(); }; -
如果我们正在登录,现在可以调用
signIn函数:export const SignInPage = ({ action }: Props) => { const { signIn } = useAuth(); if (action === 'signin') { signIn(); } };
我们的最终任务是返回 JSX:
export const SignInPage = ({ action }: Props) => {
const { signIn } = useAuth();
if (action === 'signin') {
signIn();
}
return (
<Page title="Sign In">
<StatusText>Signing in ...</StatusText>
</Page>
);
};
我们呈现页面,通知用户正在进行登录过程。
执行注销流程
让我们在SignOutPage.tsx中实现注销页面,该在结构上与SignInPage组件类似。将SignOutPage.tsx中的当前内容替换为以下代码:
import React from 'react';
import { Page } from './Page';
import { StatusText } from './Styles';
import { useAuth } from './Auth';
type SignoutAction = 'signout' | 'signout-callback';
interface Props {
action: SignoutAction;
}
export const SignOutPage = ({ action }: Props) => {
let message = 'Signing out ...';
const { signOut } = useAuth();
switch (action) {
case 'signout':
signOut();
break;
case 'signout-callback':
message = 'You successfully signed out!';
break;
}
return (
<Page title="Sign out">
<StatusText>{message}</StatusText>
</Page>
);
};
稍有不同的是,当组件收到回调时,该组件将保持在视图中,并显示一条消息,通知他们已成功注销。
在我们的前端配置 Auth0 设置
我们几乎准备好尝试登录和注销流程。首先,我们需要将前端配置为与正确的 Auth0 租户交互。这些在AppSettings.ts中配置:
export const authSettings = {
domain: 'your-domain',
client_id: 'your-clientid',
redirect_uri: window.location.origin + '/signin-
callback',
scope: 'openid profile QandAAPI email',
audience: 'https://qanda',
};
我们需要在此设置文件中替换特定的 Auth0 域和客户端 ID。我们在上一章中从 Auth0 中找到了这些详细信息的位置,但作为提醒,以下是步骤:
-
On the Auth0 website, go to the Applications section. The client ID is against our Q and A single-page application:
![Figure 12.7 – Auth0 client ID]()
图 12.7–Auth0 客户端 ID
-
Click on the Q and A single-page application list item and go to the Settings tab. The domain is available in one of the fields in this tab:
![Figure 12.8 – Auth0 domain]()
图 12.8–Auth0 域
重要提示
domain设置不包括前面的https://。 -
转到左面板中的API部分。观众可以通过 Q 和 A API 看到:

图 12.9–Auth0 API 受众
我们现在已经准备好尝试登录和注销过程。
测试登录和注销流程
所有的都已到位,可以尝试登录和注销流程。让我们执行以下步骤:
-
First, we need to create an Auth0 user to sign in with. In Auth0, on the left-hand navigation menu, choose Users & Roles | Users and then click the Create User button. Fill in the form with the user we want to create and click the CREATE button:
![Figure 12.10 – Adding a new user in Auth0]()
图 12.10–在 Auth0 中添加新用户
-
Let's make sure both the backend and frontend are running. Then, we can click the Sign In button in the header of the frontend. We are redirected to Auth0 to log in:
![Figure 12.11 – Auth0 login form]()
图 12.11–Auth0 登录表单
-
After entering the user's credentials, click the LOG IN button. We are then asked to authorize the Q and A app to access the profile and email data:
![Figure 12.12 – App authorization in Auth0]()
图 12.12–Auth0 中的应用授权
发生此授权过程是因为这是该用户的第一次登录。
-
点击勾号图标后,我们将成功登录并重定向回前端。
-
现在,让我们点击退出按钮。浏览器短暂导航到 Auth0 以注销用户,然后重定向到我们的注销回调页面:

图 12.13:注销确认消息
这就完成了登录和注销过程的实现。
目前,无论用户是否经过身份验证,我们应用中的所有选项都是可见的。但是,某些选项只有在用户登录后才能正常工作。以为例,如果我们尝试在未登录的情况下提交问题,则会失败。我们将在下一节中对此进行清理。
控制认证选项
在本节中,我们将只对经过身份验证的用户显示相关选项。我们将使用上一节中创建的useAuth钩子中的isAuthenticated标志来完成此操作。
我们将首先在Header组件中显示登录选项或退出选项。然后,我们只允许经过身份验证的用户在HomePage组件中提问,并在QuestionPage组件中回答问题。作为这项工作的一部分,我们将创建一个可重用的AuthorizedPage组件,该组件可用于页面组件,以确保只有经过身份验证的用户才能访问这些组件。
在表头显示相关选项
在时刻,Header组件显示登录和注销选项,但登录选项仅在用户未登录时才相关。注销选项仅在用户通过身份验证时才相关。让我们按照以下步骤在Header.tsx中进行清理:
-
我们将从导入身份验证上下文挂钩开始:
import { useAuth } from './Auth'; -
让我们钩住身份验证上下文并返回
user对象,用户是否经过身份验证,以及在返回 JSX 之前是否加载了上下文:export const Header = () => { ... const { isAuthenticated, user, loading } = useAuth(); return ( ... ); }; -
We can use the
loadingandisAuthenticatedproperties to show the relevant options in the JSX:<div ...> <Link ...> Q & A </Link> <form onSubmit={handleSearchSubmit}> ... </form> <div> {!loading && (isAuthenticated ? ( <div> <span>{user!.name}</span> <Link to="/signout" css={buttonStyle}> <UserIcon /> <span>Sign Out</span> </Link> </div> ) : ( <Link to="/signin" css={buttonStyle}> <UserIcon /> <span>Sign In</span> </Link> ))} </div> </div>我们使用短路表达式来确保在加载上下文时无法访问登录和注销按钮。我们使用三元表达式来显示用户名和注销按钮(如果用户已通过身份验证),以及登录按钮(如果未通过身份验证)。
-
Let's give this a try by first making sure the frontend and backend are running. We should see the Sign In button before the user has signed in:
![Figure 12.14 – Header for an unauthenticated user]()
图 12.14–未经验证用户的标题
-
点击登录按钮进行用户身份验证。在用户通过身份验证后,我们应该看到用户名和注销按钮:

图 12.15–经过身份验证的用户的标题
这就完成了Header组件中所需的更改。
接下来,我们将再次使用useAuth钩子来控制用户是否可以提问。
仅允许认证用户提问
让我们移动到HomePage组件,如果用户经过身份验证,则只显示提问按钮:
-
我们将从导入身份验证挂钩开始:
import { useAuth } from './Auth'; -
让我们连接到身份验证上下文,并返回在返回 JSX 之前是否对用户进行了身份验证:
export const HomePage = () => { ... const { isAuthenticated } = useAuth(); return ( ... ); }; -
We can then use the
isAuthenticatedproperty and a short-circuit operator to only render the Ask a question button if the user is signed in:<Page> <div ... > <PageTitle>Unanswered Questions</PageTitle> {isAuthenticated && ( <PrimaryButton onClick={handleAskQuestionClick}> Ask a question </PrimaryButton> )} </div> ... </Page>这就完成了对主页的更改。但是,用户仍然可以通过在浏览器中手动放置相关路径来访问 ask 页面。
-
Let's stop unauthenticated users from manually navigating to the ask page and asking a question in
AskPage.tsx. We are going to create anAuthorizedPagecomponent to help us to do this that will only render its child components if the user is authenticated. Let's create a file calledAuthorizedPage.tsxin thesrcfolder with the following content:import React from 'react'; import { Page } from './Page'; import { useAuth } from './Auth'; export const AuthorizedPage: React.FC = ({ children }) => { const { isAuthenticated } = useAuth(); if (isAuthenticated) { return <>{children}</>; } else { return ( <Page title="You do not have access to this page"> {null} </Page> ); } };如果用户经过身份验证,我们使用我们的
useAuth钩子并呈现子组件。如果用户未通过身份验证,我们会通知他们他们无权访问该页面。 -
让我们转到
App.tsx并导入AuthorizedPage:import { AuthorizedPage } from './AuthorizedPage'; -
然后我们可以将
AuthorizedPage组件包裹在App组件 JSX:<Route path="ask" element={ <React.Suspense ... > <AuthorizedPage> <AskPage /> </AuthorizedPage> </React.Suspense> } />中的
AskPage组件上 -
Let's give all this a try in the running app. Make sure the user is signed out and go to the home page:
![Figure 12.16 – No Ask button for the unauthenticated user]()
图 12.16–未经验证用户的“禁止询问”按钮
我们将看到,正如我们预期的那样,没有按钮可以提问。
-
Try to go to the ask page by manually putting the
/askpath into the browser:![Figure 12.17 – Protected page for the unauthenticated user]()
图 12.17–未经验证用户的受保护页面
我们被告知,我们没有权限查看该页面,正如我们预期的那样。
-
让我们现在登录:

图 12.18–认证用户的询问按钮
如我们所料,提问按钮现在可用。
这就结束了我们在提问时需要做的改变。
仅允许经过身份验证的用户回答问题
现在让我们关注一下组件QuestionPage组件,只有在用户经过身份验证的情况下才允许提交答案:
-
我们首先在
QuestionPage.tsx:import { useAuth } from './Auth';中导入身份验证钩子
-
让我们连接到身份验证上下文,并返回在返回 JSX 之前是否对用户进行了身份验证:
export const QuestionPage: ... = ( ... ) => { ... const { isAuthenticated } = useAuth(); return ( ... ); }; -
然后,我们可以使用
isAuthenticated属性和短路运算符仅在用户登录<AnswerList data={question.answers} /> {isAuthenticated && ( <form ... > ... </form> )}时呈现应答表单
-
Let's give all this a try in the running app. Make sure the user is signed out and go to the question page:
![Figure 12.19 – No answer form for the unauthenticated user]()
图 12.19–未经验证用户的无应答表
正如我们预期的那样,没有答案表格。
-
让我们登录并再次转到问题页面:

图 12.20–认证用户的回答表
答案表是可用的,正如我们预期的那样。
这就完成了对问题页面的更改。
在下一节中,我们将与 RESTAPI 端点交互,这些端点要求经过身份验证的用户执行任务,例如提交问题。
使用 fetch 与经过身份验证的 REST API 端点交互
在本节中,我们将正确地连接 RESTAPI 的问题和答案。作为这项工作的一部分,我们将增强我们的http功能,以便在 HTTP 请求中使用来自 Auth0 的承载令牌。这是因为用于发布问题和答案的端点在 RESTAPI 中受到保护,并且需要有效的承载令牌。
我们所有的更改都将在QuestionsData.ts中—我们的用户界面组件将不变。
向 REST API 发布问题
我们将将发布问题的实现更改为使用来自 Auth0 的访问令牌:
-
首先,我们将从 Auth0 获取访问令牌的函数导入到
QuestionsData.ts:import { getAccessToken } from './Auth'; -
Let's change the implementation of the
postQuestionfunction to the following:export const postQuestion = async ( question: PostQuestionData, ): Promise<QuestionData | undefined> => { const accessToken = await getAccessToken(); const result = await http< QuestionDataFromServer, PostQuestionData >({ path: '/questions', method: 'post', body: question, accessToken, }); if (result.ok && result.body) { return mapQuestionFromServer( result.body, ); } else { return undefined; } };我们从 Auth0 获取访问令牌,并将其传递到泛型
http函数中。如果请求成功,我们将从响应主体返回问题,并返回创建日期的正确类型;否则返回undefined。 -
The ability to do
POSTrequests in ourhttpfunction is not supported yet. Access tokens aren't supported as well. So, let's move tohttp.tsand start to implement these features:export interface HttpRequest<REQB> { path: string; method?: string; body?: REQB; accessToken?: string; }我们首先将 HTTP 方法、主体和访问令牌添加到请求接口。
-
Let's move on to the changes we need to make in the
httpfunction:export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( `${webAPIUrl}${config.path}`, { method: config.method || 'get', headers: { 'Content-Type': 'application/json', }, body: config.body ? JSON.stringify(config.body) : undefined, } ); ... };我们为
Request构造函数提供第二个参数,该构造函数定义 HTTP 请求方法、头和主体。请注意,我们使用
JSON.stringify将请求主体转换为字符串。这是因为fetch函数没有为我们将请求主体转换为字符串。 -
Now, let's add support for the access token:
export const http = async < RESB, REQB = undefined >( config: HttpRequest<REQB>, ): Promise<HttpResponse<RESB>> => { const request = new Request( ... ); if (config.accessToken) { request.headers.set( 'authorization', `bearer ${config.accessToken}`, ); } ... };如果提供了访问令牌,我们将其添加到一个名为
authorization的 HTTP 请求头中,位于单词bearer和空格之后。重要提示
authorization是一个标准 HTTP 标头,其中包含验证用户身份的凭据。该值设置为身份验证类型,后跟空格和凭据。因此,在我们的例子中,bearer一词表示身份验证的类型。 -
让我们尝试一下,首先确保前端和后端正在运行。让我们以用户身份登录,打开浏览器的开发工具,进入网络面板。让我们提交一个新问题:

图 12.21–HTTP 请求中包含的承载令牌
如我们所料,问题已成功保存。我们还可以看到 HTTPauthorization头中随请求发送的访问令牌。
在上一章中,我们无法检查的一件事是,是否针对该问题保存了正确的用户。如果我们查看数据库中的问题,我们将看到针对该问题存储的正确用户 ID 和用户名:

图 12.22–与问题一起存储的正确用户 ID 和用户名
这就完成了发布一个问题。不需要对AskPage部件进行任何更改。
向 REST API 发布答案
我们将更改发布答案的实现,以使用访问令牌和我们的通用http功能。我们将postAnswer功能的实现修改为:
export const postAnswer = async (
answer: PostAnswerData,
): Promise<AnswerData | undefined> => {
const accessToken = await getAccessToken();
const result = await http<
AnswerData,
PostAnswerData
>({
path: '/questions/answer',
method: 'post',
body: answer,
accessToken,
});
if (result.ok) {
return result.body;
} else {
return undefined;
}
};
这遵循与postQuestion函数相同的模式,从 Auth0 获取访问令牌,并使用http函数与 JWT 发出 HTTPPOST请求。
这就完成了发布答案所需的更改。
我们现在可以从QuestionsData.ts中删除questions数组模拟数据,因为它不再使用。wait功能也可以移除。
这就完成了关于与受保护的 REST API 端点交互的本节。
正在中止数据提取
页面组件在请求数据并将其设置为状态时出现轻微的问题。问题是,如果用户在仍在提取数据时离开页面,则会尝试在不再存在的组件上设置状态。我们将使用卸载组件时设置的cancelled标志来解决HomePage、QuestionPage和SearchPage组件上的此问题。我们将在返回数据和即将设置状态后检查此标志。
让我们执行以下步骤:
-
In
HomePage.tsx, let's change theuseEffectcall to the following:React.useEffect(() => { let cancelled = false; const doGetUnansweredQuestions = async () => { const unansweredQuestions = await getUnansweredQuestions(); if (!cancelled) { setQuestions(unansweredQuestions); setQuestionsLoading(false); } }; doGetUnansweredQuestions(); return () => { cancelled = true; }; }, []);我们使用一个
cancelled变量来跟踪用户是否已经离开页面,如果这是true,我们不会设置任何状态。我们将知道用户是否已经离开页面,因为将调用return函数,该函数设置cancelled标志。 -
让我们对
QuestionPage组件遵循相同的模式:React.useEffect(() => { let cancelled = false; const doGetQuestion = async (questionId: number) => { const foundQuestion = await getQuestion(questionId); if (!cancelled) { setQuestion(foundQuestion); } }; ... return () => { cancelled = true; }; }, [questionId]); -
最后,让我们对
SearchPage组件遵循相同的模式:React.useEffect(() => { let cancelled = false; const doSearch = async (criteria: string) => { const foundResults = await searchQuestions(criteria); if (!cancelled) { setQuestions(foundResults); } }; doSearch(search); return () => { cancelled = true; }; }, [search]);
这就完成了对页面组件的更改。页面组件中的数据获取过程现在更加健壮。
总结
在本章中,我们了解到浏览器有一个方便的fetch功能,允许我们与 REST API 交互。这允许我们指定 HTTP 头,例如authorization,我们使用它来提供用户的访问令牌,以便访问受保护的端点。
利用标准的 Auth0 JavaScript 库,单页应用可以与 Auth0 身份提供程序交互。它以安全的方式发出所有必需的请求并重定向到 Auth0。
使用 React 上下文向组件共享有关用户的信息,使组件能够呈现仅与用户相关的信息和选项。
我们在本章中构建的AuthProvider和AuthorizedPage组件是通用组件,可用于其他应用,以帮助实现前端授权逻辑。
我们的应用现在几乎完成了。在下一章中,我们将通过一些自动化测试来检验前端和后端的性能。
问题
以下问题将测试我们对刚刚学到的知识的了解:
-
下面使用
fetch函数的 HTTPPOST请求有什么问题?fetch('http://localhost:17525/api/person', { method: 'post', headers: { 'Content-Type': 'application/json', }, body: { firstName: 'Fred' surname: 'Smith' } }) -
以下使用
fetch功能的请求有什么问题?const res = await fetch('http://localhost:17525/api/person/1'); console.log('firstName', res.body.firstName); -
以下使用
fetch功能的请求有什么问题?fetch('http://localhost:17525/api/person/21312') .then(res => res.json()) .catch(res => { if (res.status === 404) { console.log('person not found') } }); -
我们有一个用于删除只有管理员有权使用的用户的端点。我们在名为
jwt的变量中有用户的访问令牌。以下请求有什么问题?fetch('http://localhost:17525/api/person/1', { method: 'delete', headers: { 'Content-Type': 'application/json', 'authorization': jwt }); -
在本章中,我们实现了一个
AuthorizedPage组件,我们可以将其包装在页面组件上,以便仅为经过身份验证的用户呈现。我们可以实现一个类似的组件来包装页面中的组件,以便只为经过身份验证的用户呈现这些组件。试一试这个。
答案
-
问题在于
fetch函数期望主体采用string格式。更正的呼叫如下:fetch('http://localhost:17525/api/person', { method: 'post', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ firstName: 'Fred' surname: 'Smith' }) }) -
问题是响应主体不能像这样在响应中直接访问。相反,应该使用响应的
json异步方法:const res = await fetch('http://localhost:17525/api/person/1'); const body = await res.json(); console.log('firstName', body.firstName); -
问题在于
catch方法用于网络错误,而不是 HTTP 请求错误。HTTP 请求错误可以通过then方法处理:fetch('http://localhost:17525/api/person/21312') .then(res => { if (res.status === 404) { console.log('person not found') } else { return res.json(); } }); -
问题在于
authorizationHTTP 头中缺少后跟空格的单词bearer。更正的呼叫如下:fetch('http://localhost:17525/api/person/1', { method: 'delete', headers: { 'Content-Type': 'application/json', 'authorization': `bearer ${jwt}` }); -
The component implementation is as follows:
import React from 'react'; import { useAuth } from './Auth'; export const AuthorizedElement: React.FC = ({ children }) => { const auth = useAuth(); if (auth.isAuthenticated) { return < >{children}</ >; } else { return null; } };该组件的消耗情况如下:
<AuthorizedElement> <PrimaryButton ...> Ask a question </PrimaryButton> </AuthorizedElement>
进一步阅读
以下是一些有用的链接,以了解有关本章所涵盖主题的更多信息:
十三、增加自动测试
现在,是时候让我们的QandA应用做好生产准备了。在本章中,我们将在应用的前端和后端添加自动测试,这将使我们有信心采取下一步:将我们的应用投入生产。
首先,我们将关注后端,并使用 xUnit 在没有依赖关系的纯函数上实现单元测试。然后,我们将继续测试我们的QuestionsController,它确实具有依赖性。我们还将学习如何使用 Moq 将依赖关系的真实实现替换为伪实现。
接下来,我们将把注意力转向使用流行的 Jest 工具测试我们应用的前端。我们将学习如何利用神奇的 React 测试库对纯函数进行单元测试,并对 React 组件进行集成测试。
然后,我们将学习如何使用 Cypress 实现端到端测试。我们将使用它来测试通过应用的关键路径,其中前端和后端将一起工作。
到本章结束时,我们的测试将使我们更有信心,在开发和发布新版本的应用时,我们不会破坏现有的功能。
在本章中,我们将介绍以下主题:
- 了解不同类型的自动化测试
- 用 xUnit 实现.NET 测试
- 用 Jest 实现 React 测试
- 测试 React 组分
- 使用 Cypress 实现端到端测试
让我们开始吧!
技术要求
在本章中,我们需要以下工具和服务:
- Visual Studio 2019:我们将使用它为我们的 ASP.NET Core 代码后端编写测试。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- VisualStudio 代码:我们将使用它来实现对 React 代码的测试。可从下载并安装 https://code.visualstudio.com/ 。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果您已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。
- Q 和 A:我们将从在 GitHub 上提供的 QandA 前端项目开始 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-13/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中还原代码,您可以下载源代码库并在相关编辑器中打开相关文件夹。如果代码是前端代码,则npm install可以输入终端以恢复依赖关系。您还需要在后端项目的appsettings.json文件以及前端项目的AppSettings.ts文件中替换 Auth0 租户 ID 和客户端 ID。
查看以下视频以查看代码的运行:https://bit.ly/3h3Aib6 。
了解不同类型的自动测试
一套强大的自动化测试帮助我们更快地交付软件,而不牺牲其质量。有各种类型的测试,尽管每种类型都有自己的优点和挑战。在本节中,我们将了解不同类型的测试以及它们给单页应用带来的好处。
下图显示了三种不同类型的测试:

图 13.1–试验类型
在下面的小节中,我们将检查每种类型的测试及其优缺点。
单元测试
单元测试验证应用的各个独立部分是否按预期工作。这些测试通常执行得非常快,因此给了我们一个非常紧密的反馈循环,这样我们就知道我们正在开发的应用部分工作正常。
这些测试可以很快实现,但如果我们需要模拟我们正在测试的单元的依赖关系,则不一定如此。当单元测试 React 前端时,通常会出现这种情况,因为组件上的真正单元测试需要模拟其 JSX 中引用的所有子组件。
也许这些测试的最大缺点是,它们让我们对整个应用是否正常工作的信心最小。我们可以有一个大型的单元测试套件,涵盖我们应用的所有不同部分,但这并不能保证所有部分都能按预期协同工作。
以下是在Counter类的increment方法上执行单元测试的示例:
[Fact]
public void Increment_WhenCurrentCountIs1_ShouldReturn2()
{
var counter = new Counter(1);
var result = counter.increment();
Assert.Equal(2, result);
}
对Counter类或increment方法没有外部依赖,因此这是单元测试的一个很好的候选者。
端到端测试
端到端测试验证关键路径按预期协同工作。应用的任何部分都不会被隔离和模仿。这些测试就像用户一样运行一个功能齐全的应用,因此这让我们对应用是否正常运行有最大的信心。
然而,这些测试执行起来很慢,这可能会延迟开发过程中的反馈循环;它们也是编写和维护成本最高的。这是因为测试所依赖的一切,例如数据库中的数据,都需要在每次执行测试时保持一致,这在我们实现具有不同数据需求的多个测试时是一个挑战。
以下是用于捕获订阅电子邮件地址的端到端测试的代码片段:
cy.findByLabelText('Email')
.type('carl.rippon@googlemail.com')
.should('have.value', 'carl.rippon@googlemail.com');
cy.get('form').submit();
cy.contains('Thanks for subscribing!');
这些语句驱动 web 页面上的交互,并检查页面上元素的内容,这些元素在过程中被更新。
集成测试
集成测试验证应用的多个部分正确地协同工作。在确保整个应用按预期工作方面,它们比单元测试给我们带来更多信心。这些测试提供了测试内容的最大范围,因为我们可以选择测试许多应用部件组合。
这些测试通常执行得很快,因为诸如数据库和网络请求之类的慢组件通常会被模拟出来。编写和维护这些测试所需的时间也很短。
对于单页应用,如果我们明智地选择测试,集成测试的投资回报(ROI)可以说比其他两种测试类型更高。这就是为什么上图中的相关框比其他测试类型大的原因。
以下是对 ReactCard组件进行集成测试的示例:
test('When the Card component is rendered with a title
prop, it should contain the correct title', () => {
const { queryByText } = render(
<Card title="Title test" />
);
const titleText = queryByText('Title test');
expect(titleText).not.toBeNull();
});
测试验证通过title道具后,文本呈现正确。Card组件可能包含子组件,这些子组件将在测试中执行和呈现。这就是为什么将其归类为集成测试而不是单元测试的原因。
现在我们了解了不同类型的测试,我们将开始在QandA应用上实现它们。我们将首先对.NET 后端进行单元测试。
使用 xUnit 实现.NET 测试
在本节中,我们将使用名为xUnit的库在我们的问题控制器上实现一些后端单元测试。在此之前,我们将通过在没有依赖项的类上实现一些单元测试来熟悉 xUnit。
与 xUnit 开始接触
在本节中,我们将在后端 Visual Studio 解决方案中创建一个新项目,并开始实施简单的单元测试,以熟悉 xUnit,这是我们将用于运行后端测试的工具。因此,让我们打开后端项目并执行以下步骤:
-
打开解决方案浏览器窗口,右键点击解决方案,选择添加,然后选择新建项目。。。。
-
Select xUnit Test Project from the dialog box that opens and click on the Next button:
![Figure 13.2 – Creating a new xUnit project]()
图 13.2–创建新的 xUnit 项目
-
输入BackendTests作为项目名称,并将位置设置到解决方案所在的文件夹中。点击创建创建项目。
-
在解决方案浏览器中,右键点击后端测试项目,选择编辑项目文件。确保 XML 中的
TargetFramework节点设置为至少net5.0:<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net5.0</TargetFramework> ... </PropertyGroup> ... </Project> -
We are going to create a simple class so that we can write some unit tests for it. This will get us comfortable with xUnit. Create a static class in our unit test project called
Calcwith the following content:using System; namespace BackendTests { public static class Calc { public static decimal Add(decimal a, decimal b) { return a + b; } } }该类包含一个名为
Add的方法,该方法简单地将在其参数中传递的两个数字相加。Add是一个纯函数,这意味着对于给定的一组参数,返回值总是一致的,并且不会产生任何副作用。纯函数非常容易测试,我们将在下面看到。 -
We are going to create some unit tests for the
Addmethod in theCalcclass. Let's create a new class in the unit test project calledCalcTestswith the following content:using Xunit; namespace BackendTests { public class CalcTests { [Fact] public void Add_When2Integers_ShouldReturnCorrectInteger() { // TODO - call the Calc.Add method with 2 // integers // TODO - check the result is as expected } } }我们已将我们的测试方法命名为
Add_When2Integers_ShouldReturnCorrectInteger。重要信息
有一个好的测试命名约定是很有用的。当我们看到失败的测试报告时,如果测试的名称描述了正在测试的内容,我们就可以立即开始了解问题。在本例中,名称以我们正在测试的方法开头,然后是测试条件的简要描述以及我们期望发生的事情。
请注意,测试方法用
Fact属性修饰。重要信息
Fact属性表示该方法是 xUnit 的单元测试。表示单元测试的另一个属性称为Theory。这可用于向方法提供一系列参数值。 -
Let's implement the unit test:
[Fact] public void Add_When2Integers_ShouldReturnCorrectInteger() { var result = Calc.Add(1, 1); Assert.Equal(2, result); }我们调用正在测试的方法,并将返回值放入一个
result变量中。然后,我们使用 xUnit 的Assert类及其Equal方法检查结果是否等于2。 -
Let's run our test by right-clicking inside the test method and choosing Debug Test(s) from the menu:
![Figure 13.3 – Debugging a test]()
图 13.3–调试测试
-
几秒钟后,测试将运行,结果将出现在测试浏览器窗口中:

图 13.4–试验结果
正如我们所料,测试通过了。祝贺您–您刚刚创建了第一个单元测试!
在本次测试中,我们在Assert类中使用了Equal方法。以下是我们可以在此类中使用的一些其他有用方法:
True:检查值是否为trueNotNull:检查值是否不是nullContains:检查该值是否在string中InRange:检查该值是否在范围内Throws:检查是否引发了异常
现在,我们开始了解如何编写单元测试。我们还没有在 Q 和 A 应用上编写任何测试,但我们将在下一步进行。
测试控制器动作方式
在本节中,我们将为一些问题控制器操作创建测试。
我们的 API 控制器依赖于缓存和数据存储库。我们不希望我们的测试执行真正的缓存和数据存储库,因为我们要求缓存和数据存储库中的数据是可预测的。这有助于我们获得可以检查的可预测结果。此外,如果测试在真实数据库上运行,那么测试执行将慢得多。因此,我们将使用一个名为 Moq 的库来帮助我们用提供可预测结果的伪实现替换真正的缓存和数据存储库。
让我们开始:
-
First, we need to reference the
QandAproject from theBackendTestsproject. We can do this by right-clicking on the Dependencies node in Solution Explorer in theBackendTestsproject and choosing Add Project Reference...:![Figure 13.5 – Adding a project reference]()
图 13.5–添加项目参考
-
Then, we need to tick the QandA project and click the OK button:
![Figure 13.6 – Adding a reference to the QandA project]()
图 13.6-添加对 QandA 项目的参考
-
让我们使用NuGet Package Manager将Moq安装到我们的测试项目中:

图 13.7–安装 Moq
BackendTests项目现在已经建立,准备进行我们的第一次测试。
测试获取问题的行动方法
按照以下步骤对GetQuestions方法执行两项测试:
-
首先,我们将在
BackendTests项目中创建一个名为QuestionsControllerTests的新类,其 the 的内容如下:using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Xunit; using Moq; using QandA.Controllers; using QandA.Data; using QandA.Data.Models; namespace BackendTests { public class QuestionsControllerTests { } } -
We are going to verify that calling
GetQuestionswith no parameters returns all the questions. Let's create the test method for this and 10 mock questions:[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { var mockQuestions = new List<QuestionGetManyResponse>(); for (int i = 1; i <= 10; i++) { mockQuestions.Add(new QuestionGetManyResponse { QuestionId = 1, Title = $"Test title {i}", Content = $"Test content {i}", UserName = "User1", Answers = new List<AnswerGetResponse>() }); } }请注意,由于我们正在测试的操作方法是异步的,因此该方法被标记为带有
async关键字的异步方法。 -
Let's create a mock data repository definition using Moq:
[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { ... var mockDataRepository = new Mock<IDataRepository>(); mockDataRepository .Setup(repo => repo.GetQuestions()) .Returns(() => Task.FromResult(mockQuestions. AsEnumerable())); }我们可以使用 Moq 中的
Mock类从IDataRepository接口创建一个模拟对象。然后,我们可以在模拟对象上使用Setup和Returns方法来定义GetQuestions方法应该返回模拟问题。我们正在测试的方法是异步的,因此我们需要在模拟结果中用Task.FromResult包装模拟问题。 -
We need to mock the configuration object that reads
appsettings.json. This is what the controller depends on:[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { ... var mockConfigurationRoot = new Mock<IConfigurationRoot>(); mockConfigurationRoot.SetupGet(config => config[It.IsAny<string>()]).Returns("some setting"); }前面的代码在读取
appsettings.json时将返回任何字符串,这对于我们的测试来说是好的。 -
Next, we need to create an instance of the API controller by passing in an instance of the mock data repository and mock configuration settings:
[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { ... var questionsController = new QuestionsController( mockDataRepository.Object, null, null, mockConfigurationRoot.Object ); }模拟数据存储库定义上的
Object属性为我们提供了一个要使用的模拟数据存储库实例。请注意,我们可以为缓存和 HTTP 客户端工厂依赖项传递
null。这是因为我们正在测试的操作方法实现中没有使用它们 -
Now, we can call the action method we are testing:
[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { ... var result = await questionsController.GetQuestions(null, false); }我们将
null作为search参数传入,false作为includeAnswers参数传入。其他参数是可选的,因此我们不传入这些参数。 -
Now, we can check the result is as expected:
[Fact] public async void GetQuestions_WhenNoParameters_ReturnsAllQuestions() { ... Assert.Equal(10, result.Count()); mockDataRepository.Verify( mock => mock.GetQuestions(), Times.Once() ); }在这里,我们已经检查了 10 件物品是否被退回。
我们还检查了数据存储库中的
GetQuestions方法是否调用过一次 -
Let's give this a try by right-clicking the test in Test Explorer and selecting Run Selected Tests:
![Figure 13.8 – Running a test in Test Explorer]()
图 13.8–在测试资源管理器中运行测试
正如我们预期的那样,测试通过了。
-
现在,我们将创建第二个测试,以验证使用
search参数调用GetQuestions调用数据存储库中的GetQuestionsBySearchWithPaging方法。在我们的QuestionsControllerTests类中添加以下方法:[Fact] public async void GetQuestions_WhenHaveSearchParameter_ReturnsCorrectQue stions() { var mockQuestions = new List<QuestionGetManyResponse>(); mockQuestions.Add(new QuestionGetManyResponse { QuestionId = 1, Title = "Test", Content = "Test content", UserName = "User1", Answers = new List<AnswerGetResponse>() }); var mockDataRepository = new Mock<IDataRepository>(); mockDataRepository .Setup(repo => repo.GetQuestionsBySearchWithPaging("Test", 1, 20)) .Returns(() => Task.FromResult(mockQuestions.AsEnumerable())); var mockConfigurationRoot = new Mock<IConfigurationRoot>(); mockConfigurationRoot.SetupGet(config => config[It.IsAny<string>()]).Returns("some setting"); var questionsController = new QuestionsController( mockDataRepository.Object, null, null, mockConfigurationRoot.Object ); var result = await questionsController.GetQuestions("Test", false); Assert.Single(result); mockDataRepository.Verify(mock => mock.GetQuestionsBySearchWithPaging("Test", 1, 20), Times.Once()); }
这与前面的测试遵循相同的模式,但这次,我们模拟了数据存储库中的GetQuestionsBySearchWithPaging方法,并检查是否调用了。如果我们运行测试,它将按预期通过。
这就完成了对GetQuestions方法的测试。
测试动作方法以获得单个问题
按照以下步骤对GetQuestion方法进行几项测试:
-
Let's add the following test to the
QuestionsControllerTestsclass to verify that we get the correct result when the question isn't found:[Fact] public async void GetQuestion_WhenQuestionNotFound_Returns404() { var mockDataRepository = new Mock<IDataRepository>(); mockDataRepository .Setup(repo => repo.GetQuestion(1)) .Returns(() => Task.FromResult(default(QuestionGetSingleResponse))); var mockQuestionCache = new Mock<IQuestionCache>(); mockQuestionCache .Setup(cache => cache.Get(1)) .Returns(() => null); var mockConfigurationRoot = new Mock<IConfigurationRoot>(); mockConfigurationRoot.SetupGet(config => config[It.IsAny<string>()]).Returns("some setting"); var questionsController = new QuestionsController( mockDataRepository.Object, mockQuestionCache.Object, null, mockConfigurationRoot.Object ); var result = await questionsController.GetQuestion(1); var actionResult = Assert.IsType< ActionResult<QuestionGetSingleResponse> >(result); Assert.IsType<NotFoundResult>(actionResult.Result); }这遵循与先前测试相同的模式。这个测试中的一个不同之处是,我们在这个测试中模拟了缓存,因为这是在
GetQuestion方法中使用的。我们的模拟将从假缓存返回null,这是当问题不在缓存中时我们所期望的。在这里,我们检查结果是否为
NotFoundResult类型。 -
让我们添加另一个测试,以验证当请求的一个确实存在时返回的问题:
[Fact] public async void GetQuestion_WhenQuestionIsFound_ReturnsQuestion() { var mockQuestion = new QuestionGetSingleResponse { QuestionId = 1, Title = "test" }; var mockDataRepository = new Mock<IDataRepository>(); mockDataRepository .Setup(repo => repo.GetQuestion(1)) .Returns(() => Task.FromResult(mockQuestion)); var mockQuestionCache = new Mock<IQuestionCache>(); mockQuestionCache .Setup(cache => cache.Get(1)) .Returns(() => mockQuestion); var mockConfigurationRoot = new Mock<IConfigurationRoot>(); mockConfigurationRoot.SetupGet(config => config[It.IsAny<string>()]).Returns("some setting"); var questionsController = new QuestionsController( mockDataRepository.Object, mockQuestionCache.Object, null, mockConfigurationRoot.Object ); var result = await questionsController.GetQuestion(1); var actionResult = Assert.IsType< ActionResult<QuestionGetSingleResponse> >(result); var questionResult = Assert.IsType<QuestionGetSingleResponse>(actionResult. Value); Assert.Equal(1, questionResult.QuestionId); }
这一次,我们检查结果是否为QuestionGetSingleResponse类型,并通过检查问题 ID 返回正确的问题。
这就完成了我们将在GetQuestion动作方法上执行的测试。
同样的方法和模式也可以用于添加我们尚未介绍的控制器逻辑测试。我们可以使用 Moq 来实现这一点,Moq 模拟出该方法所依赖的任何依赖项。在下一节中,我们将开始在前端实现测试。
用笑话实现 React 测试
在本节中,我们将把注意力转向使用 Jest 为前端创建自动测试。Jest 是 React 社区中事实上的测试工具,由 Facebook 维护。Jest 包含在Create React App(CRA项目中,这意味着它已经在我们的项目中安装和配置。
我们将从测试一个简单的函数开始,这样我们就可以在继续测试 React 组件之前熟悉 Jest。
开始开玩笑
我们将通过在QuestionsData.ts中的mapQuestionFromServer函数中添加一些单元测试来开始熟悉 Jest。因此,让我们在 Visual Studio 代码中打开前端项目,并执行以下步骤:
-
通过删除
src文件夹中的App.test.tsx文件,删除使用 Create React App 创建项目时安装的示例测试。 -
Create a new file called
QuestionsData.test.tsin thesrcfolder that contains the following content:import { mapQuestionFromServer } from './QuestionsData'; test('When mapQuestionFromServer is called with question, created should be turned into a Date', () => { });请注意,该文件的扩展名为
test.ts。重要信息
test.ts扩展名很重要,因为 Jest 在搜索要执行的测试时会自动查找具有此扩展名的文件。请注意,如果我们的测试包含 JSX,则需要使用test.tsx扩展。Jest 中的
test函数包含两个参数:-
第一个参数是测试输出中显示的测试描述。
-
The second parameter is an arrow function, which will contain our test.
测试将检查
mapQuestionFromServer功能是否正确,并将created属性映射到question对象。
-
-
让我们用一个问题调用
mapQuestionFromServer函数,并将返回的对象分配给一个result变量:test('When mapQuestionFromServer is called with question, created should be turned into a Date', () => { const result = mapQuestionFromServer({ questionId: 1, title: "test", content: "test", userName: "test", created: "2021-01-01T00:00:00.000Z", answers: [] }); }); -
Add the following highlighted code to test that the
resultvariable is as we expect:test('When mapQuestionFromServer is called with question, created should be turned into a Date', () => { const result = mapQuestionFromServer({ questionId: 1, title: "test", content: "test", userName: "test", created: "2021-01-01T00:00:00.000Z", answers: [] }); expect(result).toEqual({ questionId: 1, title: "test", content: "test", userName: "test", created: new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0)), answers: [] }); });我们传递正在检查 Jest
expect函数的result变量。然后,我们将一个toEqual匹配器函数链接到该函数上,该函数检查result对象是否具有与我们传递给它的对象相同的属性值。toEqual是我们可以用来检查变量值的许多 Jest matcher 函数之一。功能的完整列表可在中找到 https://jestjs.io/docs/en/expect 。 -
让我们在
mapQuestionFromServer函数上创建另一个测试,以检查answers中的created属性是否正确映射:test('When mapQuestionFromServer is called with question and answers, created should be turned into a Date', () => { const result = mapQuestionFromServer({ questionId: 1, title: "test", content: "test", userName: "test", created: "2021-01-01T00:00:00.000Z", answers: [{ answerId: 1, content: "test", userName: "test", created: "2021-01-01T00:00:00.000Z" }] }); expect(result).toEqual({ questionId: 1, title: "test", content: "test", userName: "test", created: new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0)), answers: [{ answerId: 1, content: "test", userName: "test", created: new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0)), }] }); }); -
是时候检查一下我们的测试是否通过了。在终端中输入以下命令:
> npm test
Jest 将运行在项目中找到的测试,并输出结果:

图 13.9–Jest 测试结果
所以,Jest 找到了我们的两个测试,他们都通过了——这是个好消息!
mapQuestionFromServer函数很容易测试,因为它没有依赖项。但是,我们如何测试具有许多依赖项的 React 组件,例如浏览器的 DOM 和 React 本身?我们将在下一节中找到答案。
测试 React 组件
在本节中,我们将对Page、Question和HomePage组件进行测试。React 组件测试具有挑战性,因为它们具有依赖性,例如浏览器的 DOM,有时还有 HTTP 请求。因此,我们将利用 React 测试库和 Jest 的模拟功能来帮助我们实现测试。
测试页面组件
执行以下步骤,测试Page组件渲染是否正确:
-
Create a file for the tests called
Page.test.tsxwith the following content:import React from 'react'; import { render, cleanup } from '@testing-library/react'; import { Page } from './Page'; test('When the Page component is rendered, it should contain the correct title and content', () => { });我们使用
Page组件导入了 React,以及 React 测试库中的一些有用函数。React Testing 库是在我们创建前端项目时由 Create React App 安装的。这个库将帮助我们选择要检查的元素,而不使用内部实现细节,如元素 ID 或 CSS 类名。
-
Let's render the
Pagecomponent in the test by adding the following highlighted lines of code:test('When the Page component is rendered, it should contain the correct title and content', () => { const { queryByText } = render( <Page title="Title test"> <span>Test content</span> </Page>, ); });我们使用 React 测试库中的
render函数通过传入 JSX 来呈现Page组件。render函数返回各种有用的项。其中一项是queryByText函数,它将帮助我们选择我们将在下一步中使用和理解的元素。 -
Now, we can check that the page title has been rendered:
test('When the Page component is rendered, it should contain the correct title and content', () => { const { queryByText } = render( <Page title="Title test"> <span>Test content</span> </Page>, ); const title = queryByText('Title test'); expect(title).not.toBeNull(); });在这里,我们使用 React 测试库中的函数
queryByText来查找文本内容中包含"Title test"的元素,该函数是从render函数返回的。注意我们是如何使用用户可以看到的东西(元素文本)来定位元素,而不是任何实现细节。这意味着,如果 DOM 结构或 DOM ID 等实现细节发生变化,我们的测试不会中断。找到 title 元素后,我们使用 Jest 的
expect函数通过断言该元素不是null来检查是否找到了该元素。 -
我们可以对页面内容进行类似的检查:
test('When the Page component is rendered, it should contain the correct title and content', () => { const { queryByText } = render( <Page title="Title test"> <span>Test content</span> </Page>, ); const title = queryByText('Title test'); expect(title).not.toBeNull(); const content = queryByText('Test content'); expect(content).not.toBeNull(); }); -
我们需要做的最后一件事是在执行测试后清理 DOM。我们可以使用 Jest 中的
afterEach函数和 React 测试库中的cleanup函数对文件中的所有测试执行此操作。让我们在import语句afterEach(cleanup);之后添加此内容
-
如果保存文件后 Jest 仍在运行,则新测试将运行。如果我们已经杀死了 Jest,那么我们可以通过在终端中执行
npm test再次启动它:

图 13.10–Jest 测试结果
我们的测试按预期通过,总共通过了三次测试。
测试问题组件
执行以下步骤,测试Question组件渲染是否正确:
-
Let's start by creating a new file called
Question.test.tsxwith the following content:import React from 'react'; import { render, cleanup } from '@testing-library/react'; import { QuestionData } from './QuestionsData'; import { Question } from './Question'; import { BrowserRouter } from 'react-router-dom'; afterEach(cleanup); test('When the Question component is rendered, it should contain the correct data', () => { });这将导入测试所需的所有项目。我们还实现了
cleanup功能,该功能将在测试后运行。 -
Now, let's try to render the component:
test('When the Question component is rendered, it should contain the correct data', () => { const question: QuestionData = { questionId: 1, title: 'Title test', content: 'Content test', userName: 'User1', created: new Date(2019, 1, 1), answers: [], }; const { queryByText } = render( <Question data={question} />, ); });我们使用
render函数通过传入模拟的dataprop 值来呈现Question组件。不过有个问题。如果我们运行测试,我们将收到一条错误消息,说明
Error: useHref() may be used only in the context of a <Router> component。这里的问题是Question组件使用Link组件,它期望Router组件在组件树中处于较高的位置。但是,它不在我们的测试中。 -
解决方案是在我们的测试中包括
BrowserRouter:test('When the Question component is rendered, it should contain the correct data', () => { ... const { queryByText } = render( <BrowserRouter> <Question data={question} /> </BrowserRouter> ); }); -
现在,我们可以通过向测试中添加以下突出显示的语句来断言呈现了正确的数据:
test('When the Question component is rendered, it should contain the correct data', () => { ... const titleText = queryByText('Title test'); expect(titleText).not.toBeNull(); const contentText = queryByText('Content test'); expect(contentText).not.toBeNull(); const userText = queryByText(/User1/); expect(userText).not.toBeNull(); const dateText = queryByText(/2019/); expect(dateText).not.toBeNull(); });
我们在这里再次使用queryByText方法来定位渲染元素,并检查找到的元素是否不是null。请注意,在查找包含用户名和日期的元素时,我们传入一个正则表达式以进行部分匹配。
测试首页组件
我们要为其执行测试的最后一个组件是HomePage组件。为此,请执行以下步骤:
-
Let's create a file called
HomePage.test.tsxwith the following content:import React from 'react'; import { render, cleanup } from '@testing-library/react'; import { HomePage } from './HomePage'; import { BrowserRouter } from 'react-router-dom'; afterEach(cleanup); test('When HomePage first rendered, loading indicator should show', async () => { const { findByText } = render( <BrowserRouter> <HomePage /> </BrowserRouter>, ); const loading = await findByText('Loading...'); expect(loading).not.toBeNull(); });测试验证了加载。。。首次呈现时,消息出现在
HomePage组件中。我们使用findByText函数等待并查找包含加载文本的元素。 -
Let's implement another test to check that unanswered questions are rendered okay:
test('When HomePage data returned, it should render questions', async () => { const { findByText } = render( <BrowserRouter> <HomePage /> </BrowserRouter>, ); expect(await findByText('Title1 test')).toBeInTheDocument(); expect(await findByText('Title2 test')).toBeInTheDocument(); });我们再次使用
findByText函数等待问题被呈现。然后,我们使用toBeInTheDocument函数检查找到的元素是否在文档中。然而,测试失败了。这是因为
HomePage组件正在发出 HTTP 请求以获取数据,但没有 REST API 来处理该请求。 -
我们将用一个笑话模拟
getUnansweredQuestions函数。让我们在测试上面添加以下代码:jest.mock('./QuestionsData', () => ({ getUnansweredQuestions: () => { return Promise.resolve([ { questionId: 1, title: 'Title1 test', content: 'Content2 test', userName: 'User1', created: new Date(2019, 1, 1), answers: [], }, { questionId: 2, title: 'Title2 test', content: 'Content2 test', userName: 'User2', created: new Date(2019, 1, 1), answers: [], }, ]); }, })); test('When HomePage first rendered, loading indicator should show', async () => ...
模拟函数返回我们在测试断言中使用的两个问题。
现在,测试将在运行时通过。
这就完成了我们的组件测试。
正如我们所看到的,编写组件测试比编写纯函数测试更具挑战性,但 React 测试库和 Jest Mock 使编写变得相当简单。
在下一节中,我们将通过实现端到端测试来完成我们的测试套件。
使用 Cypress 实现端到端测试
Cypress 是一个端到端测试工具,非常适合单页应用(SPAs)像我们这样的应用。Cypress 可以运行整个应用,模拟与之交互的用户,并在过程中检查用户界面的状态。因此,Cypress 是在 SPA 上进行端到端测试的理想选择。
在本节中,我们将实现一个用于登录和提问的端到端测试。
柏树入门
Cypress 在我们的前端中执行,所以让我们执行以下步骤在我们的前端项目中安装和配置 Cypress:
-
我们将从终端安装
cypress开始:> npm install cypress --save-dev -
我们将添加一个
npm脚本来打开 Cypress,方法是在package.json"scripts": { ..., "cy:open": "cypress open" },中添加以下行:
-
Let's open Cypress by executing our
npmscript in the Terminal:> npm run cy:open几秒钟后,Cypress 将打开,显示刚刚安装的示例测试文件列表:
![Figure 13.11 – Cypress example tests]()
图 13.11–Cypress 示例测试
这些例子可以在我们项目的
cypress/integration/examples文件夹中找到。如果我们打开其中一个测试文件,我们将看到它们是用 JavaScript 编写的。这些例子是我们学习 Cypress 并了解其最新情况的重要参考资料。 -
In the Cypress browser window, click the
actions.spec.jsitem. This will open this test and execute it:![Figure 13.12 – Test output in Cypress]()
图 13.12–Cypress 中的测试输出
我们可以看到左侧的测试,并检查右侧正在测试的应用是否通过了测试。
-
If we click the submit() - submit a form test, we'll see all the steps in the test. If we click on a step, we'll see the app on the right in the state it was in at that juncture:
![Figure 13.13 – Cypress test result step details]()
图 13.13–Cypress 测试结果步骤详情
这在调试测试失败时非常有用。
-
Let's close Cypress for now and return to the Terminal to install the Cypress Testing Library:
> npm install @testing-library/cypress --save-devCypress 测试库与 React 测试库类似,它帮助我们选择要检查的元素,而无需使用内部实现细节。
-
要添加 Cypress 测试库命令,我们需要在
commands.js文件的顶部插入以下行,可以在cypress文件夹的support文件夹中找到:import '@testing-library/cypress/add-commands'; -
Let's add some Cypress configuration settings by opening the
cypress.jsonfile in the root of the project and adding the following settings:{ "baseUrl": "http://localhost:3000", "chromeWebSecurity": false }baseUrl设置是我们正在测试的应用的根 URL。我们的测试将使用 Auth0 和我们的应用,因此它将在两个不同的来源上工作。我们需要使用
chromeWebSecurity设置禁用 Chrome 安全性,以允许测试跨不同来源工作。Cypress 在 IFrame 中运行我们的应用并验证 0。为了防止点击劫持攻击,默认情况下在 Auth0 中禁用在 IFrame 中运行。
-
Disable clickjacking protection in Auth0 by selecting the Settings option under our user avatar menu and then selecting the Advanced tab. An option called Disable clickjacking protection for Classic Universal Login can be found toward the bottom of the Advanced tab. We need to turn this option on:
![Figure 13.14 – Disable clickjacking protection option in Auth0]()
图 13.14–在 Auth0 中禁用 clickjacking 保护选项
-
当我们编写测试时,我们将从 Cypress 访问一个全局
cy对象。让我们通过在.eslintrc.json文件中添加以下内容来告诉 ESLintcy没有问题:
```cs
{
...,
"globals": {
"cy": true
}
}
```
现在,Cypress 已经安装并配置好了,这样我们就可以在我们的 Q 和 a 应用上进行测试了。
测试提问
在本节中,我们将使用 Cypress 对我们的应用进行测试;测试登录,然后问一个问题。为此,请执行以下步骤:
-
Let's create a new file called
qanda.jsin theintegrationfolder, which can be found in thecypressfolder, with the following content:describe('Ask question', () => { beforeEach(() => { cy.visit('/'); }); it('When signed in and ask a valid question, the question should successfully save', () => { }); });describe功能允许我们对一个特性的测试集合进行分组。第一个参数是组的标题,而第二个参数是包含组中测试的函数。it函数允许我们定义实际测试。第一个参数是测试的标题,第二个参数是包含测试步骤的函数。beforeEach函数允许我们在每次测试运行之前定义要执行的步骤。在我们的例子中,我们使用visit命令导航到应用的根目录。请记住,应用的根 URL 是在cypress.json文件的baseUrl设置中定义的。 -
Let's add the following step to our test:
it('When signed in and ask a valid question, the question should successfully save', () => { cy.contains('Q & A'); });在这里,我们使用
containsCypress 命令检查页面是否包含Q & A文本。我们可以从全局cy对象访问 Cypress 命令。如果 Cypress 命令没有找到预期的结果,那么它们就会失败。因此,我们不需要添加
assert语句。整洁的 -
让我们试一试。我们需要在 VisualStudio 项目中运行后端。我们还需要通过在终端中执行
npm start来运行前端。在另一个终端窗口中,输入以下内容以打开 Cypress:> npm run cy:open -
Cypress will detect our test and list it underneath the example tests:
![Figure 13.15 – QandA test in Cypress]()
图 13.15–柏树中的 QandA 试验
-
Click on the test to execute it:
![Figure 13.16 – Our test passing in Cypress]()
图 13.16–我们通过 Cypress 测试
测试成功执行并通过。我们将使测试运行程序保持打开状态,因为它将在我们实现和保存测试时自动重新运行。
-
Let's add the following additional step to our test:
cy.contains('UNANSWERED QUESTIONS');这里,我们正在检查页面是否包含正确的标题。如果保存测试并查看测试运行程序,我们将看到测试失败:
![Figure 13.17 – Failing test in Cypress]()
图 13.17–柏树试验失败
这是因为标题的文本实际上不是大写的——CSS 规则将文本转换为大写。
请注意 Cypress 用于通知我们测试失败的消息:超时重试。Cypress 将继续尝试命令,直到命令通过或出现超时。这种行为对我们来说非常方便,因为它允许我们编写同步风格的代码,即使我们正在测试的操作是异步的。Cypress 从我们这里抽象出这种复杂性
-
让我们更正这个有问题的测试语句,将其更改为检查正确大小写中的标题:
cy.contains('Unanswered Questions'); -
Let's add some code for going to the sign-in page:
cy.contains('Sign In').click(); cy.url().should('include', 'auth0');在这里,我们使用 Cypress
contains命令定位登录按钮,并将click命令链接到此按钮以单击该按钮。然后,我们使用
url命令获取浏览器的 URL,并将should命令链接到此语句,以验证它是否包含正确的路径。如果我们查看测试运行程序,我们将看到测试成功地导航到 Auth0。
让我们考虑一下 Cypress 正在执行的这些步骤。到 Auth0 的导航是一个异步操作,但是我们的测试代码似乎不是异步的。我们没有添加特殊的等待功能来等待页面导航完成。Cypress 使测试具有异步用户界面的单页应用变得轻而易举,因为它为我们处理了这种复杂性!
接下来,我们将实施一些步骤,以便填写登录表单:
cy.findByLabelText('Email') .type('your username') .should('have.value', 'your username'); cy.findByLabelText('Password') .type('your password') .should('have.value', 'your password');在这里,我们使用 Cypress 测试库中的
findByLabelText命令来定位我们的input。它通过查找包含我们指定的文本的标签,然后查找相关的input(在标签的for属性中引用)来实现这一点。这是另一个简洁的函数,它将测试从实现细节(如元素 ID 和类名)中解放出来。我们链接 Cypress
type命令,以便在input和should命令中输入字符,以验证输入的value属性设置是否正确。重要信息
适当地替换您的测试用户名和密码。
-
Let's submit the sign-in form and check that we are taken back to the Q and A app:
cy.get('form').submit(); cy.contains('Unanswered Questions');我们使用 Cypress
get命令定位表单,然后提交表单。然后,我们检查页面是否包含Unanswered Questions文本,以验证我们是否回到 Q 和 A 应用中。Cypress 为我们处理这些步骤的异步性。 -
接下来我们点击提问按钮进入提问页面:
```cs
cy.contains('Ask a question').click();
cy.contains('Ask a question');
```
- Then, we'll fill in the ask form:
```cs
var title = 'title test';
var content = 'Lots and lots and lots and lots and lots of content test';
cy.findByLabelText('Title')
.type(title)
.should('have.value', title);
cy.findByLabelText('Content')
.type(content)
.should('have.value', content);
```
我们使用与登录表单相同的命令填写标题和内容字段。标题必须至少为 10 个字符,内容必须至少为 50 个字符,以满足验证规则。
- 接下来,我们将提交问题并检查提交是否正确:
```cs
cy.contains('Submit Your Question').click();
cy.contains('Your question was successfully submitted');
```
- 为了完成测试,我们将注销并检查是否已重定向到正确的页面:
```cs
cy.contains('Sign Out').click();
cy.contains('You successfully signed out!');
```
如果我们查看测试运行程序,我们会发现我们的测试成功运行并通过:

图 13.18–试运行
如果测试失败,可能是因为用户在测试开始前已登录到浏览器会话。如果是这种情况,请单击退出按钮并重新运行测试。
这就完成了我们的端到端测试以及本章将创建的所有测试。现在我们已经编写了适当的单元测试、集成测试和端到端测试,我们对每种类型的好处和挑战以及如何实现它们有了感觉。
总结
Cypress 的端到端测试使我们能够快速覆盖应用的各个领域。然而,它们需要一个完全可操作的前端和后端,包括一个数据库。Cypress 抽象了单页应用的异步性质的复杂性,使我们的测试变得漂亮且易于编写
单元测试可以在.NET 中使用 xUnit 编写,也可以放在 xUnit 项目中,与主应用分离。xUnit 测试方法用Fact属性修饰,我们可以使用Assert类对正在测试的项进行检查。
单元测试可以使用 Jest 为 React 应用编写,并包含在扩展名为test.ts或test.tsx的文件中。Jest 的expect函数为我们提供了许多有用的匹配器函数,例如toBe,我们可以使用这些函数进行测试断言。
单元测试通常需要模拟依赖项。Moq 是.NET 社区中一种流行的模拟工具,有一个Mock类,可用于模拟依赖项。在前端,Jest 具有一系列强大的模拟功能,我们可以使用这些功能模拟依赖项,如 REST API 调用。
一个页面通常由多个组件组成,有时,只需在页面组件上编写集成测试而不模拟子组件是很方便的。我们可以使用 Jest 实现这些测试,方法与实现单元测试完全相同。
React 测试库和 Cypress 测试库允许我们以不依赖于实现细节的方式定位元素,从而帮助我们编写健壮的测试。这意味着,如果实现发生了变化,而其特性和行为保持不变,则测试不太可能中断。这种方法降低了测试套件的维护成本。
现在,我们的应用已经构建完毕,我们已经完成了自动化测试,是时候将其部署到 Azure 了。我们将在下一章中这样做。
问题
以下问题将测试您对本章所涵盖主题的知识:
-
我们有下面的 xUnit 测试方法,但是测试运行人员没有使用它。发生了什么?
public void Minus_When2Integers_ShouldReturnCorrectInteger() { var result = Calc.Add(2, 1); Assert.Equal(1, result); } -
在 xUnit 测试中,我们有一个名为
successMessage的string变量,我们需要检查它是否包含单词"success"。在Assert类中我们可以使用什么方法? -
我们已经在名为
ListTests.tsx的文件中的List组件上创建了一些 Jest 单元测试。但是,当 Jest 测试运行程序运行时,测试不会被拾取。为什么会这样? -
我们正在 Jest 中实现一个测试,我们有一个名为
result的变量,我们要检查它不是null。我们可以使用哪个 Jest matcher 函数? -
Let's say we have a variable called
personthat is of thePersontype:interface Person { id: number; firstName: string; surname: string }我们要检查 person 变量是否为
{ id: 1, firstName: "Tom", surname: "Smith" }。我们可以使用什么 Jest matcher 函数? -
我们正在使用 Cypress 为页面编写端到端测试。页面标题为登录。我们可以使用哪一个 Cypress 命令来检查此渲染是否正常?
-
我们正在编写一个端到端测试,使用 Cypress 对呈现一些文本的页面进行测试,加载。。。,正在提取数据时。我们如何断言该文本正在呈现,然后在获取数据时消失?
答案
-
测试方法中缺少
Fact属性。 -
我们将在
Assert类中使用Assert.Contains方法。 -
测试文件名需要以
.test.tsx结尾。因此,如果我们重命名文件List.test.tsx,那么测试将被拾取。 -
我们可以使用以下代码检查对象是否为
null:expect(result).not.toBeNull(); -
我们可以使用
toEqualJest matcher 函数来比较对象:expect(person).toEqual({ id: 1, firstName: "Tom", surname: "Smith" }); -
我们可以使用以下 Cypress 命令检查页面标题:
cy.contains('Sign In'); -
We can use the following Cypress command to check that Loading… only appears while data is being fetched:
cy.contains('Loading...'); cy.contains('Loading...').should('not.exist');第一个命令将检查页面在初始呈现时是否呈现
Loading...。第二个命令将等待Loading...消失,即数据已被提取。
进一步阅读
如果您想了解有关使用 xUnit 和 Jest 进行测试的更多信息,以下资源非常有用:
- 在.NET Core 中进行单元测试:https://docs.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-dotnet-test
- 迅特:https://xunit.net/
- 最小起订量:https://github.com/moq/moq
- 开玩笑:https://jestjs.io/
- React 测试库:https://testing-library.com/docs/react-testing-library/intro
- 柏树:https://docs.cypress.io
- 柏树测试库:https://testing-library.com/docs/cypress-testing-library/intro
十四、配置并部署到 Azure
在本章中,我们将在 Microsoft Azure 中将应用部署到生产环境中,以便所有用户都可以开始使用它。我们将首先关注后端,对代码进行必要的更改,以便它可以在 Azure 的生产和登台环境中工作。然后,我们将从 Visual Studio 内部将后端应用编程接口(API)以及结构化查询语言(SQL)数据库部署到登台和制作中。在第一次部署之后,单击 VisualStudio 中的按钮即可完成后续部署。
然后,我们将把注意力转向前端,再次对代码进行更改,以支持开发、登台和生产环境。然后,我们将把我们的前端部署到 Azure,同时部署到暂存和生产环境。
在本章中,我们将介绍以下主题:
- Azure 入门
- 为登台和生产配置 ASP.NET Core 后端
- 将我们的 ASP.NET Core 后端发布到 Azure
- 为登台和生产配置 React 前端
- 将 React 前端发布到 Azure
技术要求
在本章中,我们将使用以下工具和服务:
- Visual Studio 2019:我们将使用它编辑我们的 ASP.NET Core 代码。可从下载并安装 https://visualstudio.microsoft.com/vs/ 。
- .NET 5:可从下载 https://dotnet.microsoft.com/download/dotnet/5.0 。
- Visual Studio 代码:我们将使用它来编辑我们的 React 代码。可从下载并安装 https://code.visualstudio.com/ 。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少是版本 8.2,
npm至少是版本 5.2。 - Microsoft Azure:我们将为我们的应用使用多个 Azure 应用服务和 SQL 数据库。可在创建账户 https://azure.microsoft.com/en-us/free/ 。
- Q 和 A:我们将从上一章中完成的 Q 和 A 前端和后端项目开始,这些项目可在上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 在第 14 章/开始文件夹中。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。要从章节还原代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/34u28bd
开始使用 Azure
在本节中,如果我们还没有帐户,我们将为 Azure 注册。然后,我们将快速浏览 Azure 门户,了解我们将用于运行应用的服务。
注册 Azure
如果你已经拥有 Azure 帐户,那么现在是注册并尝试 Azure 的最佳时机。在撰写本书时,您可以注册 Azure 并通过以下链接获得 12 个月的免费服务:https://azure.microsoft.com/en-us/free/ 。
我们需要一个 Microsoft 帐户来注册 Azure,如果您还没有帐户,可以免费创建。然后,您需要填写一份包含以下个人信息的注册表格:
- 原籍国
- 名称
- 电子邮件地址
- 电话号码
然后,您需要经历两个不同的验证过程。第一种是通过短信或电话进行验证。第二是核实你的信用卡详细信息。
重要提示
请注意,除非您从免费试用升级,否则您的信用卡将不收费。
注册过程的最后一步是同意条款和条件。
了解我们将要使用的 Azure 服务
在我们拥有 Azure 帐户后,我们可以使用 Microsoft 帐户登录 Azure 门户。门户的统一资源定位器(URL为https://portal.azure.com 。
当我们登录 Azure 门户时,我们将看到它包含各种各样的服务,如以下屏幕截图所示:

图 14.1–Azure 主页
我们将仅使用以下几项出色的服务:
- 应用服务:我们将使用此服务托管我们的 ASP.NET Core 后端 API 以及我们的 React 前端。
- SQL 数据库:我们将使用此服务托管我们的 SQL Server 数据库。
我们将把所有这些资源放入所谓的资源组。现在我们创建资源组,如下所示:
-
Click on the Resource groups option. A list of resource groups appears, which of course will be empty if we have just signed up to Azure. Click on the Add option, as illustrated in the following screenshot:
![Figure 14.2 – Resource groups page]()
图 14.2–资源组页面
-
Fill in the form that opens. Choose an appropriate name for the resource group. We'll need to use this name later in this chapter, so make sure you remember it. Click the Review + create button, as illustrated in the following screenshot:
![Figure 14.3 – Creating a resource group]()
图 14.3–创建资源组
-
点击打开的查看屏幕上的创建按钮。我们的资源组最终将显示在资源组列表中,如以下屏幕截图所示:

图 14.4–资源组列表和我们的新资源组
重要提示
如果几秒钟后资源组没有显示,点击刷新选项刷新资源组。
我们的资源组现在已经准备好提供其他服务。在提供任何其他服务之前,我们将在下一节为生产配置后端。
为登台和生产配置 ASP.NET Core 后端
在本节中,我们将创建单独的appsettings.json文件,用于暂存和生产以及开发中的本地工作。让我们在 Visual Studio 中打开后端项目,并执行以下步骤:
-
Let's now go to Solution Explorer, as illustrated in the following screenshot:
![Figure 14.5 – The appsettings files in Solution Explorer]()
图 14.5–解决方案资源管理器中的 appsettings 文件
请注意,有两个设置文件以单词
appsettings开头。重要提示
我们可以为不同的环境提供不同的设置文件。
appsettings.json文件是默认设置文件,可以包含所有环境通用的设置。当我们在 Visual Studio 中运行后端并覆盖appsettings.json文件中的任何重复设置时,在开发过程中使用appsettings.Development.json。文件名的中间部分需要匹配名为ASPNETCORE_ENVIRONMENT的环境变量,该变量在 Visual Studio 中默认设置为Development,在 Azure 中默认设置为Production。因此,appsettings.Production.json可用于 Azure 中特定于生产环境的设置。 -
At the moment, all of our settings are in the default
appsettings.jsonfile. Let's add ourConnectionStringssetting and also a frontend setting to theappsettings.Development.jsonfile, as follows:{ "ConnectionStrings": { "DefaultConnection": "Server=localhost\\SQLEXPRESS;Database= QandA;Trusted_Connection=True;" }, "Frontend": "http://localhost:3000" }我们将在默认的
appsettings.json文件中保留 Auth0 设置,因为这些设置将应用于所有环境。 -
从默认的
appsettings.json文件中删除ConnectionStrings设置。 -
Let's add an
appsettings.Production.jsonfile now by right-clicking the QandA project in Solution Explorer, choosing Add | New Item..., selecting the App Settings File item, naming the fileappsettings.Production.json, and then clicking the Add button, as illustrated in the following screenshot:![Figure 14.6 – Adding an appsettings file for production]()
图 14.6–为生产添加 appsettings 文件
-
Replace the content in the
appsettings.Production.jsonfile with the following:{ "Frontend": "https://your- frontend.azurewebsites.net" }因此,这包含我们将在 Azure 中创建的生产前端 URL。请注意此设置,因为在 Azure 中配置前端时需要它。
-
类似地,让我们添加一个具有以下 c 内容的
appsettings.Staging.json文件:{ "Frontend": "https://your-frontend- staging.azurewebsites.net" }
我们尚未指定生产或暂存连接字符串,因为我们将在 Azure 中存储这些字符串。这是因为这些连接字符串存储秘密用户名和密码,在 Azure 中比我们的源代码更安全。
我们现在已经准备好开始创建 Azure 服务并部署我们的后端。我们将在下一节中进行此操作。
将我们的 ASP.NET Core 后端发布到 Azure
在本节中,我们将使用 VisualStudio 将数据库和后端 API 部署到 Azure。我们将创建发布配置文件,以便部署到生产环境和暂存环境。在创建概要文件的过程中,我们将创建所需的 Azure 应用服务和 SQL 数据库。在本节的最后,我们将有两个概要文件,可用于快速部署到我们的暂存和生产环境。
从出版到生产
让我们执行以下步骤来创建生产部署配置文件,并使用它将后端部署到生产:
-
在解决方案浏览器中,右键点击QandA项目,选择发布。。。。
-
The Publish dialog opens, which asks us to choose a publish target. Choose Azure and click Next, as illustrated in the following screenshot:
![Figure 14.7 – Selecting Azure as the publish target]()
图 14.7–选择 Azure 作为发布目标
-
We are then asked which service we want to deploy to in Azure. Select the Azure App Service (Windows) option and click Next, as illustrated in the following screenshot:
![Figure 14.8 – Selecting Azure App Service as the publish specific target]()
图 14.8–选择 Azure 应用服务作为发布特定目标
-
The next step is to specify our Microsoft account. We could then search for and select an existing app service to deploy to. However, we are going to create a new app service, so click the green plus icon, as illustrated in the following screenshot:
![Figure 14.9 – Selecting or creating a new app service]()
图 14.9–选择或创建新的应用服务
-
Fill in the name for the production app service. This name will form part of the URL to the REpresentational State Transfer (REST) API, so the name will need to be globally unique. In the example in the following screenshot, the URL will be https://qanda2021.azurewebsite.net. Note down the name you choose, because we'll eventually reference this in the frontend project:
![Figure 14.10 – Creating the app service]()
图 14.10–创建应用服务
-
您可以选择应用服务的默认托管计划。或者,您可以通过单击新建…选项将其与新的关联。
-
单击创建以创建应用服务。这将需要几分钟时间完成。
-
After the app service has been created, the Create New App Service dialog will close and we will see the app service in the App Service instances list on the Publish dialog. Select the new app service and click Next, as illustrated in the following screenshot:
![Figure 14.11 – Selecting the app service to deploy to]()
图 14.11–选择要部署到的应用服务
-
We will skip the next step for API Management. Check the Skip this step checkbox and click Finish. The profile for our production deployment is now saved and we are taken to a screen that summarizes it, as illustrated in the following screenshot:
![Figure 14.12 – Summary of publish configuration]()
图 14.12–发布配置摘要
-
Our app service is created but our backend still isn't deployed. We can confirm this by browsing to the link to the right of Site URL, which results in the following screen:

图 14.13–未部署站点的应用服务 URL
- 我们还没有设置任何东西来托管 SQL 数据库。现在,我们将通过在 Visual Studio 中单击概要文件摘要中服务依赖项部分的添加选项来完成此操作。
- The Add dependency dialog opens. Choose Azure SQL Database and click Next, as illustrated in the following screenshot:

图 14.14–选择 Azure SQL 数据库
- The dialog then shows the SQL databases in our Azure subscription. Click the green plus icon to create a new SQL database, as illustrated in the following screenshot:

图 14.15–创建新 SQL 数据库的选项
- 在打开的对话框中,输入您选择的数据库名称。我们以后会需要这个,所以请记下来。
- 我们需要创建一个新的数据库服务器,所以点击新建。。。数据库服务器字段右侧的选项。
- Fill in the server details in the dialog that appears. Choose your own server name, username, and password. Take note of these details because we will need these again in a later step. Click the OK button to confirm the server details, as illustrated in the following screenshot:

图 14.16–新建 SQL Server 对话框
- Click the Create button on the Azure SQL Database dialog, as illustrated in the following screenshot. This will create the database in Azure, so it may take a few minutes to complete:

图 14.17–新建 SQL 数据库对话框
- After the database has been created, the Azure SQL Database dialog will close. The database will appear in the SQL databases list in the Configure Azure SQL Database dialog. Click on the database we have just created to select it, and click Next, as illustrated in the following screenshot:

图 14.18–SQL 数据库列表
- Next, we are asked to define our database connection string. Make sure the connection name is
DefaultConnection, and fill in the username and password we entered earlier when creating our database. Select Azure App Settings for where to save the connection string, and click Next, as illustrated in the following screenshot:

图 14.19–连接字符串配置
此连接字符串现在将存储在 Azure 应用服务的**应用设置**部分。
- 在出现的摘要对话框中按完成,然后按关闭。
- We are taken back to the summary of the publish configuration, with confirmation that the SQL database has been configured, as illustrated in the following screenshot:

图 14.20–发布配置摘要
- The last thing to do before we deploy is to configure the deployment so that the .NET Core runtime and libraries are included in the deployment. We do this by clicking the pencil icon against Deployment mode, setting Deployment Mode to be Self-Contained in the dialog that appears, and clicking Save, as illustrated in the following screenshot:

图 14.21–将部署模式设置为自包含
- 单击发布按钮将我们的代码部署到 Azure。这需要几分钟才能完成。
- 最终,将打开一个浏览器窗口,其中包含到已部署后端的路径。在浏览器中的路径中添加
/api/questions,如下图所示:

图 14.22–Azure 中的 REST API
我们将看到数据库中的默认问题。祝贺我们刚刚在 Azure 中部署了我们的第一个 SQL 数据库和 ASP.NET Core 应用!
让我们导航到进入 Azure 门户 https://portal.azure.com 。选择所有资源选项,将出现以下屏幕:

图 14.23–Azure 中提供的服务
正如预期的那样,我们看到了刚才提供的服务。
杰出的我们刚刚在 Azure 中成功部署了我们的后端!
随着后端的进一步开发,我们可以返回此配置文件并使用发布按钮快速部署更新的后端。
接下来,让我们按照类似的流程部署到临时环境。
发布到登台
让我们执行以下步骤将后端部署到暂存环境:
- 在解决方案浏览器中,右键点击QandA项目,选择发布。。。。这将打开发布屏幕,在那里我们将看到我们的产品发布配置文件。
- 选择新建选项创建新的发布配置文件。
- 选择Azure作为目标,点击下一步。
- 选择Azure 应用服务(Windows)作为具体目标,点击下一步。
- 单击绿色加号图标添加新的 Azure 应用服务。
- 在出现的对话框中,输入新应用服务的名称。这将是在登台环境中承载后端的服务。记下您选择的名称,因为我们最终将在前端项目中引用此名称。使用与生产环境相同的资源组和托管计划。单击创建在 Azure 中创建应用服务。这需要几分钟的时间。
- 当App 服务对话框关闭后,我们将返回发布对话框。确保在应用服务实例列表中选择了我们的暂存应用服务,然后单击下一步。
- 勾选跳过此步骤复选框,跳过API 管理步骤。
- 按完成保存发布配置文件。
- 我们想创建我们的暂存数据库,所以点击服务依赖项部分中的配置选项。
- 在出现的对话框中选择Azure SQL 数据库,点击下一步。
- 单击绿色加号图标以创建数据库。此时会打开Azure SQL 数据库对话框。
- 输入您选择的数据库名称。我们以后会需要这个,所以请记下来。
- 我们将为登台环境创建一个新的数据库服务器,因此单击新建。。。数据库服务器字段右侧的选项。
- 在出现的对话框中填写服务器详细信息。选择您自己的服务器名称、用户名和密码。请注意这些细节,因为我们将在稍后的步骤中再次需要这些细节。点击确定按钮确认这些细节。
- 点击Azure SQL 数据库对话框上的创建按钮。这将在 Azure 中创建数据库,因此可能需要几分钟才能完成。
- 当Azure SQL 数据库对话框关闭后,我们将返回配置 Azure SQL 数据库对话框。确保选择了我们的暂存数据库,然后单击下一步。
- 接下来,要求我们定义数据库连接字符串。将连接名称保留为
DefaultConnection,并填写我们之前在创建临时数据库时输入的用户名和密码。选择Azure 应用设置并单击下一步。 - 在出现的摘要对话框上按完成,然后按关闭。
- 在发布摘要中,点击部署模式对应的铅笔图标,并在出现的对话框中将部署模式设置为独立。点击保存。
- 然后,我们可以通过单击发布按钮将代码发布到 Azure 服务。同样,这需要几分钟才能完成。
- 最终将打开一个浏览器窗口,指向新的暂存应用服务。如果我们在浏览器中的路径中添加
/api/questions,我们将看到从暂存数据库返回的数据。 - 我们需要告诉我们的新应用服务,它是暂存环境,而不是生产环境。这是因为它使用了
appsettings.Staging.json文件,其中包含跨源资源共享(CORS的Frontend设置。默认情况下,Azure 假定环境是生产环境,这意味着目前正在使用appsettings.Production.json文件。让我们转到 Azure 门户并在应用服务区域中选择登台应用服务。 - 在设置区域,选择配置进入应用设置页签。
- 在应用设置下,点击新建应用设置选项,输入
ASPNETCORE_ENVIRONMENT作为名称,Staging作为值,然后点击确定按钮,然后点击保存按钮。这将创建一个名为ASPNETCORE_ENVIRONMENT的环境变量,该变量具有Staging值。ASP.NET Core 将查看此变量,然后使用appsettings.Staging.json文件进行配置设置,如以下屏幕截图所示:

图 14.24–Azure 应用服务应用设置
这就完成了将我们的应用部署到登台环境的过程。
这是巨大的进步!Azure 与 VisualStudio 完美配合。在下一节中,我们将把注意力转向前端,并对其进行更改,使其能够在 Azure 暂存和生产环境以及开发中工作。
配置 React 前端进行暂存和生产
在本节中,我们将更改前端,以便它在登台和生产中向正确的后端 API 发出请求。目前,restapi 有一个硬编码路径设置为 localhost。我们将像在后端一样使用环境变量来区分不同的环境。让我们在 Visual Studio 代码中打开前端项目,并执行以下步骤:
-
首先,我们将安装一个名为
cross-env的库,它允许我们设置环境变量。让我们在终端中执行以下命令:> npm install cross-env --save-dev -
让我们在
package.json中添加以下脚本来执行暂存和生产构建:"scripts": { ..., "build": "react-scripts build", "build:production": "cross-env REACT_APP_ENV=production npm run build", "build:staging": "cross-env REACT_APP_ENV=staging npm run build", ... }, -
These scripts use the
cross-envlibrary to set an environment variable calledREACT_APP_ENVtostagingandproductionbefore doing an optimized build.因此,
npm run build:staging将执行一个分段构建,npm run build:production将执行一个生产构建。 -
在
AppSettings.ts文件中设置server变量时,让我们使用REACT_APP_ENV环境变量。打开AppSettings.ts并进行以下更改:export const server = process.env.REACT_APP_ENV === 'production' ? 'https://your-backend.azurewebsites.net' : process.env.REACT_APP_ENV === 'staging' ? 'https://your-backend-staging.azurewebsites.net' : 'http://localhost:17525';
我们使用一个三元表达式来设置正确的后端位置,这取决于应用运行的环境。生产服务器设置为https://your-backend.azurewebsites.net,暂存服务器设置为https://your-backend-staging.azurewebsites.net。
确保输入的暂存和生产位置与已部署后端的位置匹配:
-
为了让深层链接在 Azure 中工作,我们需要指定 URL 重写规则,将所有请求重定向到前端到我们的
index.html文件。我们可以通过在public文件夹中添加一个web.config文件,该文件的内容如下:<?xml version="1.0" encoding="utf-8"?> <configuration> <system.webServer> <rewrite> <rules> <rule name="React Routes" stopProcessing="true"> <match url=".*" /> <conditions logicalGrouping="MatchAll"> <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> </conditions> <action type="Rewrite" url="/" appendQueryString="true" /> </rule> </rules> </rewrite> </system.webServer> </configuration> -
现在,让我们为部署前端做最后一件事。让我们更改应用以呈现我们所处的环境。让我们打开
Header.tsx并在首页链接中的应用名称后添加环境名称,如下:<Link to="/" css={ ... } > Q & A <span css={css` margin-left: 5px; font-size: 14px; font-weight: normal; `} > {process.env.REACT_APP_ENV || 'dev'} </span> </Link>
如果没有填充环境变量,我们假设我们处于开发环境中。
这就完成了我们需要对前端进行的更改。在下一节中,我们将把前端部署到 Azure。
将 React 前端发布到 Azure
在本节中,我们将把我们的 React 前端部署到 Azure,同时部署到登台和生产环境。
从出版到生产
让我们执行以下步骤将我们的前端发布到生产环境:
-
我们将从配置 Azure 应用服务开始。因此,让我们在浏览器中转到 Azure 门户,转到应用服务区域,然后单击添加选项。
-
选择现有资源组,选择应用名称,选择
.NET 5作为运行时堆栈,选择Windows作为操作系统,完成打开的表单。请注意,我们选择的应用名称需要反映在后端项目的appsettings.Production.json文件中的Frontend设置中。点击查看+创建按钮,然后点击创建按钮创建应用服务。 -
Let's move to Visual Studio Code now and create a production build by running the following command in the Terminal:
> npm run build:production构建完成后,生产构建将包含
build文件夹中的所有文件。 -
We are going to use the Azure App Service extension to perform the Azure deployment. So, go to the Extensions area in Visual Studio Code (Ctrl + Shift + X), search
Azure App Service, and install the extension shown in the following screenshot:![Figure 14.25 – Azure App Service extension in Visual Studio Code]()
图 14.25–Visual Studio 代码中的 Azure 应用服务扩展
-
Click the Azure icon in the left-hand navigation options to open the Azure App Service panel, as illustrated in the following screenshot:
![Figure 14.26 – Azure App Service panel]()
图 14.26–Azure 应用服务面板
-
点击登录 Azure。。。选项。系统会提示我们输入 Microsoft 帐户凭据,因此让我们输入这些凭据。
-
We should see the frontend app service listed in the tree. Right-click on this and choose the Deploy to Web App... option, as illustrated in the following screenshot:
![Figure 14.27 – Deploying an app to an Azure app service]()
图 14.27–将应用部署到 Azure 应用服务
-
当提示部署文件夹时,我们应该选择我们的
build文件夹。 -
We are then asked to confirm the deployment, which we do by clicking the Deploy button, as illustrated in the following screenshot:
![Figure 14.28 – Deployment confirmation]()
图 14.28–部署确认
-
Deployment will take a minute or so before we get confirmation that it is complete, as illustrated in the following screenshot:

图 14.29–部署完成确认
- 如果我们点击浏览网站选项,我们在 Azure 中的前端将显示在浏览器中,如以下屏幕截图所示:

图 14.30–生产中运行的问答应用
我们的前端现在很好地部署到了生产环境中。我们将无法成功登录,但我们将在将前端发布到登台环境后解决此问题。
发布到登台
让我们执行以下步骤将前端部署到暂存环境:
-
我们将从提供另一个 Azure 应用服务开始。因此,让我们在浏览器中转到 Azure 门户,转到应用服务区域,然后单击添加选项。
-
输入应用名称并选择现有资源组。请记住,我们选择的应用名称需要反映在后端项目中
appsettings.Staging.json文件的Frontend设置中。还要记住,运行时堆栈应该是.NET 5,而Windows应该是操作系统。点击查看+创建按钮,然后点击创建按钮创建应用服务。 -
Let's move to Visual Studio Code now and create a staging build by running the following command in the Terminal:
> npm run build:staging构建完成后,暂存构建将由覆盖生产构建的
build文件夹中的所有文件组成。 -
在 Visual Studio 代码中的Azure 应用服务部分,我们应该看到树中列出的前端登台应用服务。请注意,我们可能需要单击刷新工具栏选项,它才会出现。右键单击前端登台应用服务并选择部署到 Web 应用。。。选项。
-
我们应该在提示部署文件夹时选择我们的
build文件夹,然后在提示时确认部署。 -
After a minute or so, we'll get confirmation that the deployment is complete. If we click on the Browse Website option, our staging frontend in Azure will show in a browser, as illustrated in the following screenshot:
![Figure 14.31 – Q&A app running in staging]()
图 14.31–在暂存中运行的问答应用
-
Next, let's tell Auth0 about the Azure staging and production URLs it should trust. In Auth0, we need to update the following settings against our Q&A application. Refer to Chapter 11, Securing the Backend, if you can't remember how to do this.
允许的回调 URL-如下截图所示:

图 14.32–Auth0 允许的回调 URL
允许的网络来源-如下截图所示:

图 14.33–Auth0 允许的 web 来源
允许的注销 URL-如下截图所示:

图 14.34–Auth0 允许的注销 URL
我们可以通过点击左侧导航菜单中的应用项,然后点击Q 和应用来找到这些设置。在开发环境 URL 之后,我们为登台和生产环境添加了额外的 URL。不同环境的 URL 需要用逗号分隔。
我们现在应该能够成功登录到我们的生产和登台问答应用。
这就完成了前端在生产环境和暂存环境中的部署。
总结
Azure 与 React 和 ASP.NET Core 应用完美配合。在 ASP.NET Core 中,我们可以使用不同的appsettings.json文件来存储不同环境的不同设置,例如 CORS 的前端位置。在 React 代码中,我们可以使用环境变量向适当的后端发出请求。我们还需要在我们的 React 应用中包含一个web.config文件,以便将深层链接重定向到index.html页面,然后由 React 路由处理。可以在每个环境的特定构建npm脚本中设置环境变量。本章中我们使用了三种环境,但前端和后端都可以轻松配置以支持更多环境。
Azure 集成了 Visual Studio 和 Visual Studio 代码,使部署 React 和 ASP.NET Core 应用变得轻而易举。我们使用内置的发布。。。Visual Studio 中的选项,为 SQL 数据库提供应用服务,然后执行部署。我们还可以在 Azure 门户中提供应用服务,这是我们为前端所做的。然后,我们可以使用Azure 应用服务 Visual Studio 代码扩展将前端部署到应用服务。
虽然将我们的应用部署到 Azure 非常简单,但当我们将代码签入源代码管理时,我们可以通过自动化部署使之更加容易。我们将在下一章中这样做。
问题
以下问题将测试我们在本章学到的知识:
-
在 ASP.NET Core 中,存储特定于生产环境的任何设置的文件名是什么?
-
我们的 ASP.NET Core 后端需要
Frontend设置的原因是什么? -
Let's pretend we have introduced a QA environment and have created the following
npmscript to execute a build for this environment:"build:qa": "cross-env REACT_APP_ENV=qa npm run build"我们将使用哪个
npm命令生成 QA 构建? -
如果我们的 React 前端没有包含
web.config文件,会有什么问题? -
为什么我们不将生产和暂存连接字符串存储在
appsettings.Product.json或appsettings.Staging.json文件中?
答案
- 在 ASP.NET Core 中,存储特定于生产环境的任何设置的文件名称为
appsettings.Production.json。 - 我们的 ASP.NET Core 后端需要
Frontend设置的原因是在 CORS 策略中设置允许的来源。 - 我们将使用
npm run build:qa生成 QA 构建。 - 如果我们的 React 前端没有包含
web.config文件,我们将无法深入链接到我们的应用,例如,将问题的路径(例如https://qandafrontend.z19.web.core.windows.net/questions/1)直接放在浏览器的地址栏中,然后按Enter将导致返回页面未找到错误。 - 连接字符串包含一个秘密用户名和密码。将这些存储在 Azure 中比存储在源代码中更安全。
进一步阅读
以下资源可用于查找有关将 ASP.NET Core 和 React 应用部署到 Azure 的更多信息:
- 在 ASP.NET Core:中使用多种环境 https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments
- 将 ASP.NET Core 应用部署到 Azure 应用服务:https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/azure-apps
- 从 VS 代码:向 Azure 部署静态网站 https://code.visualstudio.com/tutorials/static-website/getting-started
十五、使用 Azure DevOps 实现 CI 和 CD
在本章中,我们将使用 Azure DevOps 为我们的 Q&A 应用实现持续集成(CI)和持续交付(CD)。在进入 Azure DevOps 之前,我们将首先确切了解 CI 和 CD 是什么。
在 Azure DevOps 中,我们将使用构建管道为前端和后端实现 CI。当开发人员将代码推送到源代码存储库时,将触发 CI 过程。然后,我们将使用一个发布管道为前端和后端实现 CD,该发布管道将在 CI 构建成功完成时自动触发。发布管道将自动向登台环境进行部署,运行后端集成测试,然后将登台部署升级到生产环境。
到本章结束时,我们将拥有一个强大的过程,以难以置信的速度向用户提供功能,并具有极高的可靠性,从而使我们的团队非常高效。
在本章中,我们将介绍以下主题:
- 开始使用 CI 和 CD
- 实施 CI
- 实施 CD
技术要求
在本章中,我们将使用以下工具和服务:
- GitHub:本章假设我们应用的源代码托管在 GitHub 上。可在免费设置账户和存储库 https://github.com 。
- Azure DevOps:我们将使用它来实现和托管 CI 和 CD 流程。这可以在找到 https://dev.azure.com/ 。
- Microsoft Azure:我们将使用上一章中设置的 Azure 应用服务和 SQL 数据库。Azure 门户可在找到 https://portal.azure.com 。
- Visual Studio 代码:可从下载安装 https://code.visualstudio.com/ 。
- Node.js 和 npm:可从下载 https://nodejs.org/ 。如果已经安装了这些,请确保 Node.js 至少为 8.2 版,npm 至少为 5.2 版。
- Q 和 A:我们将从上一章中完成的 Q 和 A 前端和后端项目开始,这些项目可在上获得 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition
chapter-15/start文件夹中的。
本章中的所有代码片段可在网上找到 https://github.com/PacktPublishing/ASP.NET-Core-5-and-React-Second-Edition 。为了从章节中恢复代码,可以下载源代码存储库,并在相关编辑器中打开相关文件夹。如果代码为前端代码,则可在终端中输入npm install恢复依赖关系。
查看以下视频以查看代码的运行:https://bit.ly/3mE6Qta 。
开始使用 CI 和 CD
在本节中,我们将首先了解 CI 和 CD 是什么,然后再更改前端代码,以允许前端测试在 CI 中工作。然后,我们将创建 Azure DevOps 项目,它将承载我们的构建和发布管道。
理解 CI 和 CD
CI 是开发人员工作副本每天数次合并到源代码系统中的共享主代码分支的过程,自动触发即所谓的构建。构建是自动生成成功部署、测试和运行生产软件所需的所有工件的过程。CI 的好处是,它会自动向团队反馈所做更改的质量。
CD 是开发人员以可持续的方式定期、安全地将软件更改投入生产的过程。因此,这是从 CI 获取构建并将其部署到生产环境的过程。CI 构建可以部署到登台环境,在登台环境中,端到端测试在部署到生产环境之前执行并通过。在最极端的情况下,CD 过程是完全自动化的,并在 CI 构建完成时触发。通常,团队成员必须批准将软件部署到生产环境的最后一步,这一步应该已经通过了一系列的自动测试。CD 也不总是在 CI 构建完成时自动触发;有时,它会在一天中的特定时间自动触发。CD 的好处是开发团队更快、更可靠地向软件用户提供价值。
下图显示了我们将要设置的高级 CI 和 CD 流:

图 15.1–高级 CI 和 CD 流程
当代码被推送到源代码存储库时,我们将构建所有后端和前端构件,并执行 xUnit 和 Jest 测试。如果构建和测试成功,这将自动启动临时部署。Cypress 测试将在临时部署上执行,如果通过,将触发生产部署。
使我们的测试能够在 CI 和 CD 中运行
我们需要对前端测试和端到端测试的配置进行一些更改,以便它们在构建和部署管道中正确执行。让我们在 Visual Studio 代码中打开前端项目,并进行以下更改:
-
First, we'll add a script named
test:ciin thepackage.jsonfile, which will run the Jest tests in CI mode, as follows:... "scripts": { ... "test": "react-scripts test", "test:ci": "cross-env CI=true react-scripts test", ... }, ...在运行 Jest 测试之前,该脚本将名为
CI的环境变量设置为true。 -
Our Cypress tests are going to execute in the deployment pipeline on the staging app after it has been deployed. We need to do a few things to ensure that our Cypress tests run in the deployment pipeline. First, let's create a
cypress.jsonfile in thecypressfolder with the following content:{ "baseUrl": "https://your-frontend- staging.azurewebsites.net", "integrationFolder": "integration", "pluginsFile": "plugins/index.js", "supportFile": "support/index.js", "chromeWebSecurity": false }这将是部署后在登台应用上运行测试的
cypress.json文件。以下是我们添加的设置说明:baseUrl:这是应用的根路径,应该是我们暂存应用的统一资源定位符(URL)。为您部署的暂存应用适当更改此选项。integrationFolder:相对于cypress.json文件,这是我们端到端测试所在的文件夹。在我们的例子中,这是一个名为integration的文件夹。pluginsFile:这是一个包含与cypress.json文件相关的任何插件的文件。在我们的例子中,这是一个名为index.js的文件,可以在plugins文件夹中找到。supportFile:这是一个相对于cypress.json文件的文件,其中包含测试运行前要执行的代码。在我们的例子中,这是一个名为index.js的文件,可以在support文件夹中找到。chromeWebSecurity:将其设置为false允许 Q&A 应用导航到 Auth0 进行身份验证。
-
Next, let's create a
package.jsonfile in thecypressfolder with the following content:{ "name": "cypress-app-tests", "version": "0.1.0", "private": true, "scripts": { "cy:run": "cypress run" }, "devDependencies": { "@testing-library/cypress": "^7.0.1", "cypress": "^5.4.0" } }该文件中的关键项是将 Cypress 和 Cypress 测试库声明为开发依赖项和
cy:run脚本,稍后我们将使用该脚本来运行 Cypress 测试。 -
接下来,我们将删除 Cypress 最初为我们安装的所有示例测试。那么,让我们从
integration文件夹中删除examples文件夹,它可以在cypress文件夹中找到。现在,integration文件夹中唯一的文件应该是我们的qanda.js文件。
现在,我们的 Jest 和 Cypress 测试将能够在构建和部署期间执行。
创建 Azure DevOps 项目
Azure DevOps 可在找到 https://dev.azure.com/ 。如果我们还没有账户,我们可以免费创建一个账户。
要创建新项目,请单击主页上的新建项目按钮,并在出现的面板中输入项目名称。在点击创建按钮之前,我们可以选择将我们的项目公开或私有,如下图所示:

图 15.2–创建新的 Azure DevOps 项目
这就是我们创建的 AzureDevOps 项目。在下一节中,我们将在 Azure DevOps 项目中为 Q&a 应用创建一个构建管道。
实施 CI
在本节中,我们将使用 Azure DevOps 中的构建管道为我们的 Q&A 应用实现 CI。我们将从一个模板创建一个构建管道开始,并添加额外的步骤来构建 Q&a 应用的所有工件。当代码被推送到源代码存储库时,我们还将观察构建触发器。
创建构建管道
让我们通过执行以下步骤从模板创建构建管道:
-
点击左侧导航菜单中的管道,然后点击创建管道。
-
We will be asked to specify where our code repository is hosted, as illustrated in the following screenshot:
![Figure 15.3 – Selecting the code repository host for the new build pipeline]()
图 15.3–为新构建管道选择代码存储库主机
-
单击相应的选项。Azure DevOps 将通过授权过程来允许 Azure DevOps 访问我们的存储库。
-
Then, we will be prompted to choose a specific repository for our code and authorize access to it, as illustrated in the following screenshot:
![Figure 15.4 – Selecting code repository for the new build pipeline]()
图 15.4–为新构建管道选择代码存储库
-
Azure DevOps 将检查存储库中的代码,以便为正在使用的技术建议合适的 CI 模板。选择ASP.NET Core模板。不要选择ASP.NET Core(.NET Framework)模板。您可能需要单击显示更多按钮来查找ASP.NET Core模板。
-
Then, a build pipeline is created for us from the template. The steps in the pipeline are defined in an
azure-pipelines.ymlfile, which will be added to our source code repository. We will make changes to this file in the next section, Implementing CD, but for now, let's click the Save and run button, as illustrated in the following screenshot:![Figure 15.5 – Build pipeline code review step]()
图 15.5–构建管道代码审查步骤
-
点击出现的确认面板中的保存并运行按钮。将保存管道,并触发生成。构建将失败,但不要担心我们将在下一节实现 CD中解决此问题。
-
After a minute or so, click on the Pipelines option in the Pipelines section. This lists the build pipelines in our project as well as useful information about when it was last run. We'll see confirmation that the pipeline failed, as illustrated in the following screenshot:
![Figure 15.6 – Build pipeline list]()
图 15.6–建造管线清单
-
单击列表中的此生成管道。然后,我们进入构建管道页面,该页面显示了它的运行历史。编辑选项允许我们更改构建管道。运行管道选项允许我们手动运行构建管道。以下屏幕截图显示了这些选项:

图 15.7–构建管道页面
这就是我们创建的基本构建管道。在下一节中,我们将完全实现 Q&A 应用的构建管道。
为我们的 Q&a 应用实现构建管道
我们现在将更改构建管道,以便它构建并发布我们的 Q&A 应用中的所有工件。我们需要的已发布工件如下所示:
backend:这将包含我们的.NET Core 后端,它将用于暂存和生产环境。frontend-production:包含我们针对生产环境的 React 前端。frontend-staging:这包含了我们针对登台环境的 React 前端。
让我们执行以下步骤:
-
In our Azure DevOps project, on our build pipeline, click the Edit button to edit the pipeline. The build pipeline is defined in a YAML Ain't Markup Language (YAML) file called
azure-pipelines. Azure DevOps lets us edit this file in its YAML editor.重要提示
YAML 通常被用于配置文件,因为它比JavaScript 对象表示法(JSON)更紧凑,并且可以包含注释。
以下 YAML 文件由 ASP.NET Core 构建管道模板生成:
# ASP.NET Core # Build and test ASP.NET Core projects targeting .NET Core. # Add steps that run tests, create a NuGet package, deploy, and more: # https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core trigger: - main pool: vmImage: 'ubuntu-latest' variables: buildConfiguration: 'Release' steps: - script: dotnet build --configuration $(buildConfiguration) displayName: 'dotnet build $(buildConfiguration)'重要提示
构建中的步骤是在
steps:关键字之后定义的。每个步骤在连字符(-后定义。script:关键字允许执行命令,而displayName:关键字是我们将在日志文件中看到的步骤描述。步骤中使用的变量在variables:关键字后声明。trigger:关键字决定何时启动构建。因此,构建包含一个步骤,该步骤执行
dotnet build命令,并将Release传递到--configuration参数中。 -
The reason our build failed was that the agent couldn't find a .NET solution to build because it isn't in the root directory in our source code repository—it is in a folder called
backend. So, let's change this step to the following:steps: - script: dotnet build --configuration $(buildConfiguration) workingDirectory: backend displayName: 'backend build'我们已经指定工作目录为
backend文件夹,并稍微更改了步骤名称。 -
We are also going to make sure the build is using the correct version of .NET Core. Add the following highlighted lines as the first step, before the build step:
steps: - task: UseDotNet@2 inputs: packageType: 'sdk' version: '5.0.100' - script: dotnet build --configuration $(buildConfiguration) workingDirectory: backend displayName: 'backend build'如果您使用的是不同版本的.NET Core,请根据需要更改版本。
-
构建管道的触发器目前设置为一个名为
main的分支。将其更改为master,如下所示:trigger: - master -
让我们点击保存按钮来保存构建配置。
-
A confirmation dialog appears that allows us to change the Git commit message and branch. Commit this to the master branch and click Save, as illustrated in the following screenshot:
![Figure 15.8 – Build pipeline save confirmation]()
图 15.8–构建管道保存确认
-
A build will automatically be triggered because the
azure-pipelines.ymlfile has changed in our repository. After a few minutes, go to the build pipeline page again. We'll see that the pipeline has succeeded this time, as illustrated in the following screenshot:![Figure 15.9 – Successful build pipeline execution]()
图 15.9–成功构建管道执行
-
We need to do more work in our build configuration before it is complete. So, let's edit the build pipeline again and add a step to run the .NET tests, as follows:
steps: - task: UseDotNet@2 inputs: packageType: 'sdk' version: '5.0.100' - script: dotnet build --configuration $(buildConfiguration) workingDirectory: backend displayName: 'backend build' - script: dotnet test workingDirectory: backend displayName: 'backend tests'在这里,我们使用命令来运行自动测试。
-
Next, let's add a step so that we can publish the .NET backend, as follows:
steps: ... - script: dotnet publish -c $(buildConfiguration) --self-contained true -r win-x86 workingDirectory: backend displayName: 'backend publish'在这里,我们使用
dotnet publish命令来发布代码。dotnet build和dotnet publish有什么区别?嗯,dotnet build命令只是从我们编写的代码中输出工件,而不是任何第三方库,比如 Dapper。我们正在 win-86 体系结构下以自包含模式部署后端,就像我们在上一章使用 Visual Studio 时所做的那样。
-
现在,我们需要使用
ArchiveFile@2任务对发布的文件进行压缩,如下所示:
```cs
steps:
...
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: 'backend/bin/Release/net5.0/win-
x86/publish'
includeRootFolder: false
archiveType: zip
archiveFile: '$(Build.ArtifactStagingDirectory)/
backend/$(Build.BuildId).zip'
replaceExistingArchive: true
displayName: 'backend zip files'
```
- The last step for our backend build is to publish the ZIP file we have just created to the build pipeline so that it can be picked up by the release pipeline, which we'll configure in the next section. The code for this is illustrated in the following snippet:
```cs
steps:
...
- task: PublishBuildArtifacts@1
inputs:
pathtoPublish: '$(Build.ArtifactStagingDirectory)/
backend'
artifactName: 'backend'
displayName: 'backend publish to pipeline'
```
这里,我们使用`PublishBuildArtifacts@1`任务将 ZIP 文件发布到管道中。我们把它命名为`backend`。
这就完成了后端的构建配置。现在让我们转到前端。
- In the same YAML file, add the following command to install the frontend dependencies:
```cs
steps:
...
- script: npm install
workingDirectory: frontend
displayName: 'frontend install dependencies'
```
在这里,我们使用命令来安装依赖项。请注意,我们已经将工作目录设置为`frontend`,这就是前端代码所在的位置。
- The next step is to run the frontend tests, as follows:
```cs
steps:
...
- script: npm run test:ci
workingDirectory: frontend
displayName: 'frontend tests'
```
在这里,我们使用`npm run test:ci`命令来运行测试,而不是`npm run test`,因为`CI`环境变量被设置为`true`,这意味着测试将在我们的构建中正确运行。
- In the next block of steps, we will produce a frontend build for the staging environment, zip up the files in this build, zip up the Cypress tests, and then publish this to the pipeline, like this:
```cs
steps:
...
- script: npm run build:staging
workingDirectory: frontend
displayName: 'frontend staging build'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: 'frontend/build'
includeRootFolder: false
archiveType: zip
archiveFile: '$(Build.ArtifactStagingDirectory)/
frontend-staging/build.zip'
replaceExistingArchive: true
displayName: 'frontend staging zip files'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: 'frontend/cypress'
includeRootFolder: false
archiveType: zip
archiveFile: '$(Build.ArtifactStagingDirectory)/
frontend-staging/tests.zip'
replaceExistingArchive: true
displayName: 'frontend cypress zip files'
- task: PublishBuildArtifacts@1
inputs:
pathtoPublish: '$(Build.ArtifactStagingDirectory)/
frontend-staging'
artifactName: 'frontend-staging'
displayName: 'frontend staging publish to pipeline'
```
在这里,我们使用`npm run build:staging`命令生成登台构建,它将`REACT_APP_ENV`环境变量设置为`staging`。我们使用前面使用的`ArchiveFiles@2`任务压缩前端构建和 Cypress 测试,然后使用`PublishBuildArtifacts@1`任务将压缩文件发布到管道中。
- Next, we'll produce a build for the production environment, zip it up, and then publish this to the pipeline, as follows:
```cs
steps:
...
- script: npm run build:production
workingDirectory: frontend
displayName: 'frontend production build'
- task: ArchiveFiles@2
inputs:
rootFolderOrFile: 'frontend/build'
includeRootFolder: false
archiveType: zip
archiveFile: '$(Build.ArtifactStagingDirectory)/
frontend-production/build.zip'
replaceExistingArchive: true
displayName: 'frontend production zip files'
- task: PublishBuildArtifacts@1
inputs:
pathtoPublish: '$(Build.ArtifactStagingDirectory)/
frontend-production'
artifactName: 'frontend-production'
displayName: 'frontend production publish to pipeline'
```
这里,我们使用`npm run build:production`命令生成构建,它将`REACT_APP_ENV`环境变量设置为`production`。我们使用前面使用的`ArchiveFiles@2`任务压缩构建,并使用`PublishBuildArtifacts@1`任务将压缩文件发布到管道中。
- That completes the build configuration. So, let's save the build pipeline by clicking the Save button and then confirming this. The build will trigger and succeed, as illustrated in the following screenshot:

图 15.10–另一个成功的管道执行
- 让我们单击最近的管道运行以查看执行的详细信息。单击成功的作业,我们可以看到有关运行的每个步骤的信息,包括花费的时间,如以下屏幕截图所示:

图 15.11–管道步骤执行详细信息
这是我们的建造管道完成。我们将在下一节中使用发布的构建工件,当我们使用发布的管道将这些工件部署到 Azure 时。
实施 CD
在本节中,我们将通过为我们的应用实现 CD 流程,在 Azure DevOps 中实现发布管道。此过程包括部署到登台环境,然后在部署升级到生产环境之前执行 Cypress 端到端测试。
部署到暂存
在 Azure DevOps 门户中执行以下步骤,以将构建部署到登台环境:
-
In the Pipelines section in the left-hand bar, select Releases, as illustrated in the following screenshot:
![Figure 15.12 – Release pipelines]()
图 15.12–释放管道
-
点击新建管线按钮。
-
We will be prompted to select a template for the release pipeline. Let's choose the Azure App Service deployment template and click Apply, as illustrated in the following screenshot:
![Figure 15.13 – Release pipeline template selection]()
图 15.13–发布管道模板选择
-
A nice visual representation of the release pipeline will appear, along with a panel to the right, where we can set some properties of the first stage. Let's call the stage
Stagingsince this is where we will deploy our app to the staging environment and execute the automated integration tests. We can close the right-hand panel by clicking the cross icon at the top right of the panel. The process is illustrated in the following screenshot:![Figure 15.14 – Visual representation of release pipeline]()
图 15.14–释放管道的视觉表示
-
We can change the pipeline name by clicking on it in the breadcrumb section and changing New release pipeline to the name of our choice, as illustrated in the following screenshot:
![Figure 15.15 – QandA pipeline]()
图 15.15–昆达管道
-
我们需要指定管道将使用的工件。点击工件部分的添加选项。
-
In the dialog that appears, make sure our Azure DevOps project is selected. Set the source to our build pipeline and click Add, as illustrated in the following screenshot:
![Figure 15.16 – Adding an artifact]()
图 15.16–添加工件
-
We are now going to specify the tasks required to deploy the artifacts to the staging environment. Let's click on the Tasks tab. We need to deploy to two different app services for the frontend and backend. So, we are going to remove the parameters by clicking the Unlink all option, as illustrated in the following screenshot:
![Figure 15.17 – Unlinking parameters]()
图 15.17–取消链接参数
-
We already have a task from the template to deploy to Azure App Service, but we need to specify some additional information. We are going to use this task to deploy the backend, so let's change the display name to
Backend App Service. We'll need to specify our Azure subscription and then authorize it. We also need to specify the service name, which is the backend staging service we created in the last chapter. Lastly, we need to specify where the build ZIP file is, which is$(System.DefaultWorkingDirectory)/**/backend/*.zip. The process is illustrated in the following screenshot:![Figure 15.18 – Backend staging release]()
图 15.18–后端暂存版本
-
点击的保存选项,保存对任务的更改。
-
Click the + icon at the top of the task list to add a new task. Select the Azure App Service deploy task and click Add, as illustrated in the following screenshot:

图 15.19–添加 Azure 应用服务部署任务
- Now, we need to set the different properties of the task, just like we did in the backend service. This time, we'll call the task
Frontend App Serviceand set the app service and the build ZIP file to the frontend staging ones, as illustrated in the following screenshot:

图 15.20–前端暂存版本
- 点击保存选项保存对任务的更改。
- Click the + icon at the top of the task list to add a new task. Select the Extract files task and click Add, as illustrated in the following screenshot:

图 15.21–添加提取文件任务
- This task is going to extract the Cypress test files so that they're ready for when the tests are executed in the next task. So, let's call the task
Extract Cypress test files, set the ZIP file patterns to$(System.DefaultWorkingDirectory)/**/frontend-staging/tests.zip, and set the destination folder to$(System.DefaultWorkingDirectory)/cypress, as illustrated in the following screenshot:

图 15.22-提取柏树试验
- 点击保存选项保存对任务的更改。
- Click the + icon at the top of the task list to add a new task. Select the Command line task and click Add, as illustrated in the following screenshot:

图 15.23–添加命令行任务
- This task is going to install the Cypress tests, so let's call it
Install Cypress tests. The script to execute is shown here:
```cs
> npm install
```
我们需要将工作目录设置为`$(System.DefaultWorkingDirectory)/cypress`,如下图所示:

图 15.24–安装 Cypress 测试的任务
- 点击的保存选项,将这些更改保存到任务中。
- 点击任务列表顶部的+图标添加新任务。选择命令行任务,点击添加。
- This task is going to execute the Cypress tests, so let's call it
Run Cypress tests. The script to execute is shown here:
```cs
> npm run cy:run
```
我们需要将工作目录设置为`$(System.DefaultWorkingDirectory)/cypress`,如下图所示:

图 15.25–运行 Cypress 测试的任务
- 单击保存选项保存对任务的这些更改。
这就完成了阶段部署配置和端到端测试的执行。接下来,我们将添加任务以执行生产部署。
部署到生产中
在发布管道中执行以下步骤,将构建工件部署到生产环境中:
-
如果尚未打开,则打开释放管道,选择管道选项卡,并显示视觉示意图。
-
Here, we are going to add a stage for the production deployment. Hover over the Staging card, and click on the Clone option, as illustrated in the following screenshot:
![Figure 15.26 – Cloning a release pipeline stage]()
图 15.26–克隆发布管道阶段
-
Let's click on the stage we have just created and call it
Production, as illustrated in the following screenshot:![Figure 15.27 – Naming the production stage]()
图 15.27–命名生产阶段
-
Select the Tasks tab so that we can change the tasks for the Production stage, as illustrated in the following screenshot:
![Figure 15.28 – Selecting the production tasks]()
图 15.28–选择生产任务
-
The last two tasks can be removed because we don't need to run any tests. To remove a task, click on it and click the Remove option, as illustrated in the following screenshot:
![Figure 15.29 – Removing a task]()
图 15.29–删除任务
-
We need to change the Backend App Service task so that it deploys the backend to the production app service, as illustrated in the following screenshot:
![Figure 15.30 – Changing the production backend app service]()
图 15.30–更改生产后端应用服务
-
We also need to change the Frontend App Service task so that it deploys to the production frontend app service from the production ZIP file, as illustrated in the following screenshot:
![Figure 15.31 – Changing the production frontend app service and package]()
图 15.31–更改生产前端应用服务和包
-
We want a release to be triggered when a new build has been completed. Click on the lightning icon in the Artifacts card and turn the Enabled switch on, as illustrated in the following screenshot:
![Figure 15.32 – Enabling continuous deployment]()
图 15.32–支持连续部署
-
点击保存保存所有更改。
这就完成了生产部署配置。接下来,我们将测试我们的自动部署。
测试自动部署
我们现在将进行代码更改,并将其推送到源代码存储库。这将触发构建和部署。让我们尝试一下,如下所示:
-
打开前端代码,打开
Header.tsx。在应用名称后添加感叹号,如以下代码段所示:<Link ... > Q & A! … </Link> -
提交更改并将其推送到源代码存储库。
-
In Azure DevOps, if we go to the build pipelines, we'll see that a build has been triggered, as illustrated in the following screenshot:
![Figure 15.33 – Build in progress]()
图 15.33–在建工程
-
When the build has successfully completed, go to the Releases section. We will see the release in progress, as illustrated in the following screenshot:
![Figure 15.34 – Release in progress]()
图 15.34–正在发布
-
最后,当暂存部署成功完成时,触发生产部署。成功发布将出现在发布历史记录中,如以下屏幕截图所示:

图 15.35–成功完成的发布
这就完成了我们的 CD 管道。
总结
在最后一章中,我们了解到 CI 和 CD 是自动化的过程,开发人员在生产中进行代码更改。实施这些过程可以提高我们软件的质量,并帮助我们以极快的速度向软件用户交付价值。
在 Azure DevOps 中实现 CI 和 CD 进程非常简单。CI 是使用构建管道实现的,Azure DevOps 为我们提供了大量用于不同技术的优秀模板。CI 过程在 YAML 文件中编写脚本,我们在其中执行一系列步骤,包括命令行命令和其他任务,如压缩文件。YAML 文件中的步骤必须包括将构建工件发布到构建管道的任务,以便它们可以在 CD 过程中使用。
CD 过程是使用发布管道和可视化编辑器实现的。同样,有很多很好的模板可以让我们开始。我们在管道中定义了阶段,这些阶段对从构建管道发布的工件执行任务。我们可以将多个阶段部署到不同的环境中。我们可以使每个阶段自动执行,或者仅在团队中受信任的成员批准时执行。可以执行许多任务类型,包括部署到 Azure 服务(如应用服务)和运行.NET 测试。
所以,我们已经到了这本书的结尾。我们已经创建了一个性能和安全的表示状态传输(REST)应用编程接口(API),它使用 Dapper 与 SQL Server 数据库交互。我们的 React 前端与此 API 进行了完美的交互,并且经过结构化处理,通过在整个过程中使用 TypeScript,它可以扩展复杂性。
我们已经学习了如何管理简单和复杂的前端状态需求,并学习了如何构建可重用组件以帮助加快构建前端的过程。我们通过添加自动化测试完成了应用的开发,并使用 Azure DevOps 将其部署到 Azure 的 CI 和 CD 进程中。
问题
以下问题将测试您对本章所涵盖主题的知识:
- 为使 Jest 测试在 CI 环境中正常工作,需要设置哪个环境变量?
- 当我们更改
azure-pipelines.yml文件时,为什么会触发构建? - 哪个 YAML 步骤任务可用于执行 npm 命令?
- 哪个 YAML 步骤任务可用于将工件发布到管道?
- 为什么我们要为不同的环境构建多个 React 前端?
- 发布管道阶段中的哪种任务类型可用于将构建构件部署到 Azure 应用服务?
答案
- 一个名为
CI的环境变量需要设置为true,Jest 测试才能在 CI 环境中正常工作。 - 当我们更改
azure-pipelines.yml文件时,它会自动提交并推送到源代码存储库中的主分支。文件中的trigger选项指定在将代码推送到主分支时应触发构建。因此,发生这种情况时会触发生成。 -script任务可用于执行 npm 命令。PublishBuildArtifacts@1任务可用于将工件发布到管道。- 前端构建设置了一个名为
REACT_APP_ENV的环境变量,代码使用该变量来确定它所处的环境。这就是我们有不同前端构建的原因。 - 发布管道阶段中的
Azure App Service Deploy任务类型可用于将构建构件部署到 Azure 应用服务。
进一步阅读
如果您想了解有关使用 Azure DevOps 实现 CI 和 CD 的更多信息,以下资源非常有用:https://docs.microsoft.com/en-us/azure/devops/pipelines/?view=azure-devops。
第一部分:开始
本节从较高的层次介绍 ASP.NET Core 和 React,并解释如何创建使它们能够很好地协同工作的项目。我们将为我们将在本书中构建的应用创建一个项目,该项目将允许用户提交问题,其他用户向他们提交答案——一个问答应用。
本节包括以下章节:
第二部分:使用 React 和 TypeScript 构建前端
在本节中,我们将使用 React 和 TypeScript 构建问答应用的前端。我们将学习不同的样式设计方法,如何实现客户端路由,如何高效地实现表单,以及如何管理复杂状态。
本节包括以下章节:
- 第三章开始使用 React 和 TypeScript
- 第 4 章、带 Emotion 造型组件
- 第 5 章、与 React 路由路由
- 第 6 章、使用表格
- 第 7 章用 Redux 管理状态
第三部分:构建 ASP.NET 后端
在本节中,我们将通过创建一个 RESTAPI 来与问答交互,从而构建问答应用的后端。我们将使用 RESTAPI 后面的 Dapper 与 SQL Server 数据库交互。我们将学习技术,使我们的后端性能和规模良好。我们将学习如何在前端使用 RESTAPI 之前保护它。
本节包括以下章节:
第四部分:投入生产
在最后一节中,我们将向 ASP.NET Core 和 React 应用添加自动测试。我们将使用 Visual Studio 和 Visual Studio 代码将应用部署到 Azure,然后通过在 Azure DevOps 中实现构建和发布管道来完全自动化部署。
本节包括以下章节:

































































































































































浙公网安备 33010602011771号