React-TypeScript-Node-全栈开发-全-

React TypeScript Node 全栈开发(全)

原文:zh.annas-archive.org/md5/F7C7A095AD12AA62E0C9F5A1E1F6F281

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

根据 GitHub,这是全球最大的开源软件仓库,JavaScript 仍然是世界上最流行的编程语言。比任何其他语言都有更多的项目是用 JavaScript 编写的。甚至通常不与 Web 相关的项目,如机器学习和加密货币,也经常使用 JavaScript。

JavaScript 编程语言非常强大和有能力,但除了语言本身,还有一些框架,包括 React 和 Node,可以增强语言的功能,使其变得更好。此外,TypeScript 现在已成为进行大型 JavaScript 项目的标准。它提供了使 JavaScript 编码更加高效和更适合大型应用程序的语言特性。

现代 Web 开发在多年来取得了巨大进步。过去,客户端代码通常意味着静态的 HTML 和 CSS,可能还有少量的 JavaScript。而后端通常是用完全不同的语言编写的,比如 PHP 或 CGI 脚本。然而,现在通常使用 JavaScript 及其相关框架来编写从客户端到服务器的整个应用程序。只使用一种语言来编写我们的应用程序在开发过程中提供了巨大的好处。此外,可用的强大和成熟的框架使得 JavaScript 全栈编程与任何其他平台竞争力。

在这本书中,我们将学习如何利用 JavaScript 的力量来构建完整的全栈 Web 应用程序。我们将使用 TypeScript 来增强这种力量,TypeScript 是另一种功能强大的前十语言。然后,我们将使用诸如 React、Redux、Node、Express 和 GraphQL 等框架来构建一个现实的、完全功能的最佳实践 Web 应用程序,这将为您提供构建现代全栈 Web 应用程序所需的所有知识。一旦我们的应用程序完成,我们将部署到 AWS 云服务,这是全球最受欢迎和功能丰富的云服务提供商。

这本书适合谁

这本书是为那些想要超越前端 Web 开发,进入全栈 Web 开发世界的 Web 开发人员而写的,通过学习现代 Web 技术以及它们如何结合在一起。在开始阅读本 Web 开发书之前,需要对 JavaScript 编程有很好的理解。

本书涵盖内容

[第一章]《理解 TypeScript》解释了 TypeScript 是什么,以及它为何在大型应用程序开发中是理想的选择。

[第二章]《探索 TypeScript》深入探讨了 TypeScript。我们将探索其特性,包括静态类型,以及为什么这些特性比 JavaScript 更好。我们还将研究面向对象编程的应用程序设计以及 TypeScript 特性如何实现这一重要的编程范式。

[第三章]《使用 ES6+功能构建更好的应用程序》回顾了每个开发人员都需要了解的 JavaScript 的重要功能。我们将重点关注 ES6 及更高版本中新增的最新功能。

[第四章]《学习单页应用程序概念以及 React 如何实现它们》解释了网站是如何构建的,并专注于单页应用程序风格的应用程序。然后我们将介绍 React 以及 React 如何用于创建单页应用程序。

[第五章]《使用 Hooks 进行 React 开发》深入探讨了 React。我们将了解旧的类式编写 React 应用程序的局限性,以及学习 Hooks 和函数组件以及它们如何改进旧的类式。

第六章使用 create-react-app 设置我们的项目并使用 Jest 进行测试,描述了用于开发 React 应用程序的现代方法。这包括创建 React 项目的标准create-react-app,以及使用 Jest 和 testing-library 进行客户端测试。

第七章学习 Redux 和 React Router,涵盖了 Redux 和 React Router,帮助我们构建 React 应用程序。自 React 诞生以来,这两个框架一直是管理状态和路由的首选框架。

第八章学习使用 Node.js 和 Express 进行服务器端开发,涵盖了 Node 和 Express。Node 是使 JavaScript 服务器应用程序成为可能的基础运行时。Express 是围绕 Node 的框架,使使用 Node 构建强大的服务器端应用程序变得容易。

第九章GraphQL 是什么?,回顾了 GraphQL 是什么,以及它如何使用数据模式来帮助构建 Web API。

第十章使用 TypeScript 和 GraphQL 依赖项设置 Express 项目,解释了如何使用 TypeScript、Express、GraphQL 和 Jest 创建一个生产质量的服务器端项目进行测试。

第十一章我们将学到什么-在线论坛应用,讨论了我们将要构建的应用程序。我们将回顾其特性,以及构建这样一个应用程序将如何帮助我们更详细地了解 Web 开发。

第十二章构建我们在线论坛应用的 React 客户端,解释了如何使用 React 开始编写我们应用程序的客户端。我们将使用函数组件、Hooks 和 Redux 来开始构建我们的屏幕。

第十三章使用 Express 和 Redis 设置会话状态,探讨了会话状态是什么,以及如何使用 Redis 创建服务器的会话,Redis 是世界上最强大的内存数据存储。我们还开始使用 Express 编写我们的服务器。

第十四章使用 TypeORM 设置 Postgres 和存储库层,解释了如何在 Postgres 中为我们的应用程序创建数据库,以及如何使用称为存储库层的强大设计技术访问它。

第十五章添加 GraphQL 模式-第一部分,开始将 GraphQL 集成到我们的应用程序中。我们将构建我们的模式并添加我们的查询和变异。我们还将开始向我们的 React 前端添加 GraphQL Hooks。

第十六章添加 GraphQL 模式-第二部分,通过完成将 GraphQL 集成到我们的客户端和服务器中的工作来完成我们的应用程序。

第十七章将应用程序部署到 AWS,将我们完成的应用程序部署到 AWS 云服务。我们将使用 Ubuntu Linux 和 NGINX 来托管我们的服务器和客户端代码。

为了充分利用本书

你应该至少有一年或更多的编程经验,至少掌握一种现代语言,并且具有一些构建应用程序的基础知识,尽管这不一定是为网络而做的。

本书将提供逐步使用或安装这些依赖项的说明。然而,这个列表确实给出了一些所需的想法。应用程序源代码将是最终完成版本的应用程序。本书将包括任何中间代码。

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将帮助您避免与复制和粘贴代码相关的任何潜在错误。

理想情况下,您应该始终努力自己输入代码,因为这将帮助您记住代码,并让您在出现问题时有所经验。

下载示例代码文件

您可以从 GitHub 上的github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node下载本书的示例代码文件。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这里有一个例子:“在src文件夹中创建一个名为Home.tsx的新文件,并添加以下代码。”

代码块设置如下:

let a = 5;
let b = '6';
console.log(a + b);

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

 npm install typescript

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会在文本中以这种方式出现。这里有一个例子:“从管理面板中选择系统信息。”

提示或重要说明

会以这种方式出现。

第一部分:了解 TypeScript 及其如何改进您的 JavaScript

本节将为您概述 TypeScript 的优势及其最重要的语言特性。我们还将介绍 ES6 最重要的特性,以及如何提高代码质量和可读性。

本节包括以下章节:

  • 第一章了解 TypeScript

  • 第二章探索 TypeScript

  • 第三章使用 ES6+特性构建更好的应用程序

第一章:理解 TypeScript

JavaScript 是一种非常流行和强大的语言。根据 GitHub 的数据,它是世界上最流行的语言(是的,甚至比 Python 更多),ES6+中的新功能继续增加有用的功能。然而,对于大型应用程序开发来说,其功能集被认为是不完整的。这就是为什么 TypeScript 被创建的原因。

在本章中,我们将了解 TypeScript 语言,它是如何创建的,以及它为 JavaScript 开发人员提供了什么价值。我们将了解 Microsoft 在创建 TypeScript 时使用的设计哲学,以及为什么这些设计决策为大型应用程序开发提供了重要的支持。

我们还将看到 TypeScript 如何增强和改进 JavaScript。我们将比较 JavaScript 编写代码的方式与 TypeScript 的区别。TypeScript 具有丰富的前沿功能,有利于开发人员。其中最重要的是静态类型和面向对象编程OOP)能力。这些功能可以使代码质量更高,更易于维护。

通过本章结束时,您将了解 JavaScript 的一些限制,这些限制使其在大型项目中难以使用。您还将了解 TypeScript 如何填补其中的一些空白,并使编写大型、复杂的应用程序更容易,更不容易出错。

在本章中,我们将涵盖以下主要主题:

  • 什么是 TypeScript?

  • 为什么需要 TypeScript?

技术要求

为了充分利用本章,您应该对 JavaScript 版本 ES5 或更高版本有基本了解,并具有使用 JavaScript 框架构建 Web 应用程序的经验。您还需要安装 Node 和 JavaScript 代码编辑器,如Visual Studio CodeVSCode)。

您可以在github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node找到本章的 GitHub 存储库。使用Chap1文件夹中的代码。

什么是 TypeScript?

TypeScript 实际上是两种不同但相关的技术 - 一种语言和一种编译器:

  • 该语言是一种功能丰富的静态类型编程语言,为 JavaScript 添加了真正的面向对象的能力。

  • 编译器将 TypeScript 代码转换为本机 JavaScript,但也为程序员在编写代码时提供了帮助,减少了错误。

TypeScript 使开发人员能够设计更高质量的软件。语言和编译器的结合增强了开发人员的能力。通过使用 TypeScript,开发人员可以编写更易于理解和重构、包含更少错误的代码。此外,它通过在开发过程中强制修复错误,为开发工作流程增加了纪律性。

TypeScript 是一种开发时技术。它没有运行时组件,也没有任何 TypeScript 代码在任何机器上运行。相反,TypeScript 编译器将 TypeScript 转换为 JavaScript,然后部署和运行该代码在浏览器或服务器上。微软可能考虑开发 TypeScript 的运行时。然而,与操作系统市场不同,微软并不控制 ECMAScript 标准组织(决定 JavaScript 每个版本中将包含什么内容的组织)。因此,获得该组织的支持将是困难且耗时的。因此,微软决定创建一个工具,增强 JavaScript 开发人员的生产力和代码质量。

那么,如果 TypeScript 没有运行时,开发人员如何获得运行代码呢?TypeScript 使用一种称为转译的过程。转译是一种将一种语言的代码“编译”或转换为另一种语言的方法。这意味着所有 TypeScript 代码最终都会在最终部署和运行之前转换为 JavaScript 代码。

在本节中,我们已经学习了 TypeScript 是什么以及它是如何工作的。在下一节中,我们将学习为什么这些特性对于构建大型复杂应用程序是必要的。

为什么需要 TypeScript?

JavaScript 编程语言是由 Brendan Eich 创建的,并于 1995 年添加到 Netscape 浏览器中。从那时起,JavaScript 取得了巨大的成功,现在被用于构建服务器和桌面应用程序。然而,这种流行和普及也成为了一个问题和一个好处。随着越来越大的应用程序被创建,开发人员开始注意到这种语言的局限性。

大型应用程序开发需要比 JavaScript 最初创建的浏览器开发更多的需求。在高层次上,几乎所有大型应用程序开发语言,比如 Java、C++、C#等,都提供静态类型和面向对象编程能力。在本节中,我们将讨论静态类型相对于 JavaScript 动态类型的优势。我们还将了解面向对象编程,以及为什么 JavaScript 的面向对象编程方法对于大型应用程序来说太有限。

但首先,我们需要安装一些包和程序来允许我们的示例。要做到这一点,请按照以下说明操作:

  1. 首先让我们安装 Node。你可以从这里下载 Node:nodejs.org/。Node 给我们提供了npm,这是一个 JavaScript 依赖管理器,它将允许我们安装 TypeScript。我们将在第八章中深入学习 Node,使用 Node.js 和 Express 学习服务器端开发

  2. 安装 VSCode。它是一个免费的代码编辑器,其高质量和丰富的功能使其迅速成为了在任何平台上编写 JavaScript 代码的标准开发应用程序。你可以使用任何你喜欢的代码编辑器,但我会在本书中广泛使用 VSCode。

  3. 在你的个人目录中创建一个名为HandsOnTypeScript的文件夹。我们将把所有项目代码保存在这个文件夹中。

重要提示

如果你不想自己输入代码,你可以按照技术要求部分提到的方式下载完整的源代码。

  1. HandsOnTypeScript中,创建另一个名为Chap1的文件夹。

  2. 打开 VSCode,转到文件 | 打开,然后打开你刚创建的Chap1文件夹。然后,选择视图 | 终端,在你的 VSCode 窗口内启用终端窗口。

  3. 在终端中输入以下命令。这个命令将初始化你的项目,以便它可以接受npm包依赖。你需要这个因为 TypeScript 是作为npm包下载的:

npm init

你应该看到一个像这样的屏幕:

图 1.1 – npm 初始化屏幕

图 1.1 – npm 初始化屏幕

你可以接受所有提示的默认值,因为我们现在只安装 TypeScript。

  1. 使用以下命令安装 TypeScript:
npm install typescript

在所有项目都安装完成后,你的 VSCode 屏幕应该看起来像这样:

图 1.2 – 安装完成后的 VSCode

图 1.2 – 安装完成后的 VSCode

我们已经完成了安装和设置环境。现在,我们可以看一些示例,这些示例将帮助我们更好地理解 TypeScript 的好处。

动态与静态类型

每种编程语言都有并且使用类型。类型只是描述对象并且可以被重用的一组规则。JavaScript 是一种动态类型语言。在 JavaScript 中,新变量不需要声明它们的类型,即使在设置后,它们也可以被重置为不同的类型。这个特性为语言增加了灵活性,但也是许多 bug 的根源。

TypeScript 使用了一个更好的替代方案叫做静态类型。静态类型强制开发人员在创建变量时提前指定变量的类型。这消除了歧义,并消除了许多类型之间的转换错误。在接下来的步骤中,我们将看一些动态类型的缺陷示例,以及 TypeScript 的静态类型如何消除它们:

  1. Chap1文件夹的根目录下,让我们创建一个名为string-vs-number.ts的文件。.ts文件扩展名是 TypeScript 特有的扩展名,允许 TypeScript 编译器识别该文件并将其转译为 JavaScript。接下来,将以下代码输入到文件中并保存:
let a = 5;
let b = '6';
console.log(a + b);
  1. 现在,在终端中,输入以下内容:
tsc is the command to execute the TypeScript compiler, and the filename is telling the compiler to check and transpile the file into JavaScript. 
  1. 一旦你运行了tsc命令,你应该会在同一个文件夹中看到一个新文件string-vs-number.js。让我们运行这个文件:
node command acts as a runtime environment for the JavaScript file to run. The reason why this works is that Node uses Google's Chrome browser engine, V8, to run JavaScript code. So, once you have run this script, you should see this:

将一个数字变量转换为字符串,并将其附加到变量 b。这种情况在现实世界的代码中似乎不太可能发生,但如果不加以检查,它可能会发生,因为在 Web 开发中,大多数来自 HTML 的输入都以字符串形式输入,即使用户输入的是一个数字。


  1. 现在,让我们将 TypeScript 的静态类型引入到这段代码中,看看会发生什么。首先,让我们删除.js文件,因为 TypeScript 编译器可能会认为ab变量有两个副本。看看这段代码:
let a: number = 5;
let b: number = '6';
console.log(a + b);
  1. 如果你在这段代码上运行tsc编译器,你会得到错误Type "'6'" is not assignable to the type 'number'。这正是我们想要的。编译器告诉我们代码中有一个错误,并阻止了成功编译。由于我们指示这两个变量应该是数字,编译器会检查并在发现不符合时进行投诉。因此,如果我们修复这段代码并将b设置为一个数字,让我们看看会发生什么:
let a: number = 5;
let b: number = 6;
console.log(a + b);
  1. 现在,如果你运行编译器,它将成功完成,并且运行 JavaScript 将得到值11

图 1.3 - 有效数字相加

图 1.3 - 有效数字相加

很好,当我们错误地设置b时,TypeScript 捕获了我们的错误,并阻止了它在运行时被使用。

让我们看另一个更复杂的例子,因为它就像你可能在更大的应用代码中看到的那样:

  1. 让我们创建一个名为test-age.ts的新.ts文件,并将以下代码添加到其中:
function canDrive(usr) {    
    console.log("user is", usr.name);     

    if(usr.age >= 16) {
        console.log("allow to drive");
    } else {
        console.log("do not allow to drive");
    }
} 

const tom = { 
    name: "tom"
} 
canDrive (tom); 

如你所见,代码中有一个函数,用于检查用户的年龄,并根据年龄确定他们是否被允许驾驶。在函数定义之后,我们看到创建了一个用户,但没有年龄属性。假设开发人员希望稍后根据用户输入填写该属性。现在,在用户创建下面,调用了canDrive函数,并声称用户不被允许驾驶。如果事实证明用户tom已经超过 16 岁,并且该函数触发了基于用户年龄采取其他行动,显然这可能会导致一系列问题。

在 JavaScript 中有方法来解决这个问题,或者至少部分解决。我们可以使用for循环来迭代用户对象的所有属性键名,并检查是否有age名称。然后,我们可以抛出异常或使用其他错误处理程序来处理此问题。但是,如果我们必须在每个函数中都这样做,那么效率会很低,负担也会很重。此外,我们将在代码运行时进行这些检查。显然,对于这些错误,我们更希望在它们传递给用户之前捕获它们。TypeScript 为这个问题提供了一个简单的解决方案,并在代码甚至进入生产之前捕获错误。看看下面更新的代码:

interface User {
    name: string;
    age: number;
}

function canDrive(usr: User) {     
    console.log("user is", usr.name);     

    if(usr.age >= 16) {
        console.log("allow to drive");
    } else {
        console.log("do not allow to drive");
    }
} 

const tom = { 
    name: "tom"
} 
canDrive (tom); 

让我们来看一下这个更新后的代码。在顶部,我们看到一个叫做接口的东西,它被命名为User。在 TypeScript 中,接口是一种可能的类型。我将在后面的章节中详细介绍接口和其他类型,但现在,让我们看一下这个例子。User接口有我们需要的两个字段:nameage。现在,在下面,我们看到我们的canDrive函数的usr参数有一个冒号和User类型。这被称为类型注解,它意味着我们告诉编译器只允许将User类型的参数传递给canDrive。因此,当我尝试使用 TypeScript 编译这段代码时,编译器抱怨说在调用canDrive时,传入的参数缺少age,因为我们的tom对象没有这个属性:

图 1.4 – canDrive 错误

图 1.4 – canDrive 错误

  1. 因此,编译器再次捕捉到了我们的错误。让我们通过给tom一个类型来解决这个问题:
const tom: User = { 
    name: "tom"
} 
  1. 如果我们给tom一个User类型,但没有添加必需的age属性,我们会得到以下错误:
age property, the error goes away and our canDrive function works as it should. Here's the final working code:

用户接口 {

name: string;

age: number;

}

function canDrive(usr: User) {

console.log("user is", usr.name);

if(usr.age >= 16) {

console.log("allow to drive");

} else {

console.log("do not allow to drive");

}

}

// 假设过了一段时间,其他人使用了 canDrive 函数

const tom: User = {

name: "tom",

age: 25

}

canDrive(tom);


This code provides the required `age` property in the `tom` variable so that when `canDrive` is executed, the check for `usr.age` is done correctly and the appropriate code is then run.

一旦进行了这个修复并且重新运行代码,这个输出的截图如下:

图 1.5 – canDrive 成功结果

图 1.5 – canDrive 成功结果

在本节中,我们了解了动态类型的一些缺陷,以及静态类型如何帮助消除和防止这些问题。静态类型消除了代码中的歧义,对编译器和其他开发人员都有帮助。这种清晰度可以减少错误,并产生更高质量的代码。

面向对象编程

JavaScript 被称为面向对象的语言。它确实具有一些其他面向对象语言的能力,比如继承。然而,JavaScript 的实现在可用语言特性和设计方面都是有限的。在本节中,我们将看一下 JavaScript 是如何进行面向对象编程的,以及 TypeScript 如何改进 JavaScript 的能力。

首先,让我们定义一下面向对象编程是什么。面向对象编程有四个主要原则:

  • 封装

  • 抽象

  • 继承

  • 多态

让我们来复习一下每一个。

封装

封装的另一种说法是信息隐藏。在每个程序中,你都会有数据和函数,允许你对这些数据进行操作。当我们使用封装时,我们将这些数据放入一种容器中。在大多数编程语言中,这个容器被称为类,基本上,它保护数据,使得容器外部无法修改或查看它。相反,如果你想使用数据,必须通过容器对象控制的函数来完成。这种处理对象数据的方法允许严格控制代码中发生的数据变化,而不是分散在大型应用程序中的许多位置,这可能会使维护变得困难。

有些对封装的解释主要集中在将成员分组在一个共同的容器内。然而,在封装的严格意义上,信息隐藏,JavaScript 没有内置这种能力。对于大多数面向对象编程语言,封装需要通过语言设施明确隐藏成员的能力。例如,在 TypeScript 中,您可以使用private关键字,以便属性在其类外部无法看到或修改。现在,虽然可以通过各种变通方法模拟成员的私有性,但这并不是原生代码的一部分,并增加了额外的复杂性。TypeScript 通过private等访问修饰符原生支持封装。

重要提示

ECMAScript 2020 将支持类字段的私有性。然而,由于这是一个较新的功能,在撰写本文时,并不是所有浏览器都支持。

抽象

抽象与封装有关。在使用抽象时,您隐藏了数据管理的内部实现,并为外部代码提供了更简化的接口。主要是为了实现“松耦合”。这意味着希望负责一组数据的代码独立于其他代码并分开。这样,就可以在应用程序的一个部分更改代码,而不会对另一个部分的代码造成不利影响。

大多数面向对象编程语言的抽象需要使用机制来提供对对象的简化访问,而不会揭示该对象的内部工作方式。对于大多数语言,这要么是一个接口,要么是一个抽象类。我们将在后面的章节中更深入地介绍接口,但现在,接口就像没有实际工作代码的类。您可以将它们视为仅显示对象成员的名称和类型,但隐藏它们的工作方式。这种能力在产生先前提到的“松耦合”并允许更轻松地修改和维护代码方面非常重要。JavaScript 不支持接口或抽象类,而 TypeScript 支持这两个特性。

继承

继承是关于代码重用的。例如,如果您需要为几种类型的车辆(汽车、卡车和船)创建对象,为每种车辆类型编写不同的代码是低效的。最好创建一个具有所有车辆的核心属性的基本类型,然后在每种特定的车辆类型中重用该代码。这样,我们只需编写一次所需的代码,并在每种车辆类型中共享它。

JavaScript 和 TypeScript 都支持类和继承。如果您不熟悉类,类是一种存储一组相关字段的类型,还可以具有可以操作这些字段的函数。JavaScript 通过使用原型继承系统来支持继承。基本上,这意味着在 JavaScript 中,特定类型的每个对象实例共享单个核心对象的相同实例。这个核心对象是原型,原型上创建的任何字段或函数都可以在各个对象实例之间访问。这是一种节省资源(如内存)的好方法,但它没有 TypeScript 中继承模型的灵活性或复杂性。

在 TypeScript 中,类可以继承自其他类,但也可以继承自接口和抽象类。由于 JavaScript 没有这些特性,相比之下,它的原型继承是有限的。此外,JavaScript 没有直接从多个类继承的能力,这是另一种称为多重继承的代码重用方法。但是 TypeScript 允许使用混入进行多重继承。我们将在以后深入研究所有这些特性,但基本上,关键是 TypeScript 具有更强大的继承模型,允许更多种类的继承,因此有更多的代码重用方式。

多态性

多态性与继承有关。在多态性中,可以创建一个对象,该对象可以设置为任何可能从相同基本谱系继承的多种类型之一。这种能力对于需要的类型不是立即可知的情况很有用,但可以在运行时根据适当的情况进行设置。

这个特性在面向对象编程代码中的使用频率比一些其他特性要低,但仍然可以很有用。在 JavaScript 的情况下,没有直接支持多态的语言特性,但由于它的动态类型,可以相当好地模拟(一些 JavaScript 爱好者会强烈反对这种说法,但请听我说)。

让我们来看一个例子。可以使用 JavaScript 类继承来创建一个基类,并有多个类从这个父基类继承。然后,通过使用标准的 JavaScript 变量声明,不指示类型,我们可以在运行时将类型实例设置为适当的继承类。我发现的问题是,没有办法强制变量成为特定的基本类型,因为在 JavaScript 中没有办法声明类型,因此在开发过程中没有办法强制只有从一个基本类型继承的类。因此,再次,你必须诉诸于解决方法,比如在运行时使用instanceof关键字来测试特定类型,以尝试强制类型安全。

在 TypeScript 的情况下,静态类型默认开启,并在变量首次创建时强制类型声明。此外,TypeScript 支持接口,可以由类实现。因此,声明一个变量为特定接口类型会强制所有实例化为该变量的类都是相同接口的继承者。同样,这都是在代码部署之前的开发时间完成的。这个系统比 JavaScript 中的系统更加明确、可强制执行和可靠。

在本节中,我们已经了解了面向对象编程及其在大型应用程序开发中的重要性。我们也了解了为什么 TypeScript 的面向对象编程能力比 JavaScript 更加强大和功能丰富。

总结

在本章中,我们介绍了 TypeScript,并了解了它为什么被创建。我们了解了为什么类型安全和面向对象编程能力对于构建大型应用程序如此重要。然后,我们看了一些比较动态类型和静态类型的例子,并了解了为什么静态类型可能是编写代码的更好方式。最后,我们比较了两种语言之间的面向对象编程风格,并了解了为什么 TypeScript 拥有更好、更有能力的系统。本章的信息使我们对 TypeScript 的好处有了一个良好的高层次概念理解。

在下一章中,我们将深入研究 TypeScript 语言。我们将更多地了解类型,并调查 TypeScript 的一些最重要的特性,比如类、接口和泛型。这一章应该为您在 JavaScript 生态系统中使用各种框架和库奠定坚实的基础。

第二章:探索 TypeScript

在本章中,我们将深入了解 TypeScript 语言。我们将学习 TypeScript 的显式类型声明语法,以及 TypeScript 中许多内置类型及其用途。

我们还将学习如何创建自己的类型,并构建遵循面向对象原则的应用程序。最后,我们将回顾语言中添加的一些最新功能,例如可选链和 nullish 合并。

通过本章结束时,您将对 TypeScript 语言有很好的理解,这将使您能够轻松阅读和理解现有的 TypeScript 代码。您还将了解足够多关于该语言,以便编写实现应用程序目标并且可靠的高质量代码。

在本章中,我们将涵盖以下主要主题:

  • 什么是类型?

  • 探索 TypeScript 类型

  • 理解类和接口

  • 理解继承

  • 学习泛型

  • 学习最新功能和配置编译器

技术要求

本章的要求与第一章中的理解 TypeScript相同。您应该对 JavaScript 和 Web 技术有基本的了解。您将再次使用 Node 和Visual Studio CodeVSCode)。

GitHub 存储库再次位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap2文件夹中的代码。

在继续之前,让我们为本章做好准备:

  1. 转到您的HandsOnTypeScript文件夹并创建一个名为Chap2的新文件夹。

  2. 打开 VSCode 并转到您刚创建的Chap2文件夹。然后,选择视图 | 终端并在 VSCode 窗口内启用终端窗口。

  3. 输入npm init命令,就像第一章中的理解 TypeScript一样,来初始化npm项目,并接受所有默认设置。

  4. 输入npm install typescript命令,就像第一章中的理解 TypeScript一样,来安装 TypeScript。

现在我们准备好开始了。

什么是类型?

类型是一组可重复使用的规则。类型可以包括属性和函数(能力)。它也可以被共享和重复使用。当您重用一个类型时,您正在创建它的实例。这意味着您正在创建您的类型的一个示例,该示例具有属性的特定值。在 TypeScript 中,正如其名称所示,类型非常重要。这是语言首次创建的主要原因。让我们看看 TypeScript 中类型是如何工作的。

类型如何工作?

如前所述,JavaScript 确实具有类型。数字、字符串、布尔值、数组等在 JavaScript 中都是类型。然而,在声明时这些类型并没有被明确设置;它们只是在运行时被推断出来。在 TypeScript 中,类型通常在声明时设置。也可以允许编译器推断您的类型。然而,编译器选择的类型可能不是您想要的,因为它并不总是明显的。除了 JavaScript 支持的类型外,TypeScript 还具有其自己独特的类型,并且还允许您创建自己的类型。

关于 TypeScript 中类型的第一件事是,它们是由它们的形状而不是它们的类型名称处理的。这意味着类型的名称对于 TypeScript 编译器并不重要,但它具有的属性及其类型是重要的。

让我们看一个例子:

  1. 创建一个名为shape.ts的文件,并添加以下代码:
class Person {
    name: string;
}	
const jill: { name: string } = {
    name: "jill"
};
const person: Person = jill;
console.log(person);

您应该注意到的第一件事是,我们有一个名为Person的类,其中有一个名为name的属性。在下面,您会看到我们有一个名为jill的变量,它是{ name: string }类型。这有点奇怪,因为您可以看到,这种类型声明不是实际的类型名称;它更像是类型定义。但是编译器没有任何问题,也没有抱怨。在 TypeScript 中,可以同时定义和声明类型。此外,在下面,您可以看到我们有另一个名为person的变量,它是Person类型,我们将其设置为jill。同样,编译器没有抱怨,一切似乎都很好。

  1. 让我们编译此代码并运行它,看看会发生什么。在终端中输入以下行:
tsc shape
node shape

运行命令后,您应该会看到以下内容:

图 2.1 - shape.ts 输出

图 2.1 - shape.ts 输出

如您所见,代码编译和运行都没有问题。这表明在 TypeScript 中,编译器查看类型的形状,并不关心其名称。在后面的章节中,当我们更深入地挖掘 TypeScript 类型时,您将看到了解这种行为为何如此重要。

探索 TypeScript 类型

在本节中,我们将看一些 TypeScript 中可用的核心类型。使用这些类型将为您提供错误检查和编译器警告,可以帮助改进您的代码。它们还将向其他可能在您团队上的开发人员提供有关您意图的信息。因此,让我们继续看看这些类型是如何工作的。

任何类型

“任何”类型是一种动态类型,可以设置为任何其他类型。如果将变量声明为“任何”类型,这意味着可以将其设置为任何内容,并且稍后也可以将其重置为任何其他内容。实际上,它没有类型,因为编译器不会代表您检查它。这是关于“任何”的关键事实 - 编译器不会在开发时干预并警告您的问题。因此,如果可能的话,应避免使用“任何”类型。可能会觉得奇怪,一个旨在静态类型的语言会有这样的特性,但在某些情况下这是必要的。

在大型应用程序中,开发人员并不总是能够控制进入其代码的类型。例如,如果开发人员依赖于 Web 服务 API 调用来获取数据,那么数据的类型可能由其他团队或甚至完全不同的公司控制。在互操作期间,当代码依赖于来自不同编程语言的数据时,这也是真实的情况 - 例如,当公司在另一种语言中维护遗留系统的同时,又在不同的语言中构建其新系统。这些情况需要类型的灵活性和对类型系统的逃生舱。

重要的是不要滥用“任何”类型。您应该小心,只有在知道没有其他选择时才使用它 - 例如,当类型信息不清晰或可能会更改时。但是,有一些替代方案可以使用“任何”类型。根据情况,您可能可以使用接口、泛型、联合类型或“未知”类型。我们将在后面涵盖其余这些可能性,但现在让我们接下来讨论“未知”类型。

未知类型

“未知”类型是在 TypeScript 版本 3 中发布的一种类型。它类似于any,因为一旦声明了这种类型的变量,就可以将任何类型的值设置给它。随后可以将该值更改为任何其他类型。因此,我可以首先将我的变量设置为字符串类型,然后稍后将其设置为数字。但是,您不能调用其任何成员或将变量设置为另一个变量的值,而不首先检查其真正的类型。我将如下所示地展示一个示例。您可以在不首先检查其类型的情况下将“未知”设置为其他内容的唯一时间是将“未知”类型设置为另一个“未知”或“任何”类型时。

让我们看一个any的例子,然后我们将看到为什么unknown类型比使用any类型更可取(事实上,TypeScript 团队建议使用unknown):

  1. 首先,让我们看一下使用any存在的问题的一个例子。转到 VSCode,创建一个名为any.ts的文件,然后输入以下代码:
let val: any = 22;
val = "string value";
val = new Array();
val.push(33);
console.log(val);

如果您使用以下命令运行此代码,您将看到以下结果:

图 2.2 – any 运行结果

图 2.2 – any 运行结果

  1. 由于valany类型,我们可以将其设置为任何我们喜欢的值,然后调用push进入它,因为pushArray的一个方法。然而,这只是因为我们作为开发人员知道Array中有一个叫做push的方法。如果我们意外地调用了Array上不存在的东西会怎么样?用以下代码替换上一个代码:
let val: any = 22;
val = "string value";
val = new Array();
val.doesnotexist(33);
console.log(val);
  1. 现在,再次运行 TypeScript 编译器:
any type causes the compiler to no longer check the type. Additionally, we also lost IntelliSense, the VSCode development time code highlighter and error checker. Only when we try and run the code do we get any indication that there is a problem, which is never what we want. If we now try and run the code, as shown next, it fails immediately:

图 2.3 – any 失败

图 2.3 – any 失败

对于这个简单的例子,这种错误不太可能发生,但在一个大型应用程序中,即使错误只是简单地拼错了一些东西,也很容易发生。

让我们看一个类似的使用unknown的例子:

  1. 首先,注释掉any.ts中的代码,并删除any.js文件(因为我们将使用相同的变量名,如果不这样做,将会导致冲突错误)。

重要提示

稍后我们将学习一些称为命名空间的东西,它可以消除这些冲突,但现在介绍它们还为时过早。

  1. 现在,创建一个名为unknown.ts的新文件,并将以下代码添加到其中:
let val: unknown = 22;
val = "string value";
val = new Array();
val.push(33);
console.log(val);

您会注意到 VSCode 给出了一个错误,立即抱怨push函数。这很奇怪,因为显然Array中有一个叫做push的方法。这种行为展示了unknown类型的工作原理。您可以将unknown类型视为一种标签,而不是一种类型,在该标签下是实际类型。然而,编译器无法自行确定类型,因此我们需要自己向编译器明确证明类型。

  1. 我们使用类型守卫来证明val是某种类型:
let val: unknown = 22;
val = "string value";
val = new Array();
if (val instanceof Array) {
    val.push(33);
}
console.log(val);

如您所见,我们用一个测试来包装我们的push调用,以查看val是否是Array的一个实例。

  1. 一旦我们证明这是真的,对push的调用就可以继续进行,如下所示:

图 2.4 – 未知

图 2.4 – 未知

这种机制有点繁琐,因为我们总是需要在调用成员之前测试类型。然而,与使用any类型相比,它仍然更可取,因为它由编译器检查,更安全。

交集和联合类型

还记得我们在本节开始时说过 TypeScript 编译器关注类型形状而不是名称吗?这种机制允许 TypeScript 支持所谓的obj,它与两种类型相关联。您会记得在 TypeScript 中,我们不仅可以将命名类型声明为变量的类型,还可以同时动态定义和声明类型。在以下代码中,每种类型都是不同的类型,但&关键字用于将两种类型合并为单一类型:

let obj: { name: string } & { age: number } = {
    name: 'tom',
    age: 25
}

让我们尝试运行这段代码,并在控制台上显示结果。创建一个名为intersection.ts的新文件,并将以下代码添加到其中:

let obj: { name: string } & { age: number } = {
    name: 'tom',
    age: 25
}
console.log(obj);

如果您编译并运行此代码,您将看到一个包含名称和年龄属性的对象:

图 2.5 – 交集结果

图 2.5 – 交集结果

如您所见,IntelliSense 和编译器都接受了该代码,最终对象具有两个属性。这是一个交集类型。

另一种类型类似,称为union类型。在联合的情况下,我们不是合并类型,而是以“或”的方式使用它们,即一个类型或另一个类型。让我们看一个例子。创建一个名为union.ts的新文件,并将以下代码添加到其中:

let unionObj: null | { name: string } = null;
unionObj = { name: 'jon'};
console.log(unionObj);

unionObj变量被声明为 null 类型或{ name: string },通过使用|字符。如果编译并运行这段代码,你会看到它接受两种类型的值。这意味着类型值可以是 null,也可以是{ name: string }类型的对象。

文字类型

文字类型类似于联合类型,但它们使用一组硬编码的字符串或数字值。这是一个相当简单的字符串文字示例,相当容易理解。正如你所看到的,我们有一堆硬编码的字符串作为类型。这意味着只有与这些字符串中的任何一个相同的值才会被接受为文字变量:

let literal: "tom" | "linda" | "jeff" | "sue" = "linda";
literal = "sue";
console.log(literal);

正如你所看到的,编译器很高兴接收列表中的任何值,甚至重置它们。然而,它不会允许设置不在列表中的值。这将导致编译错误。让我们看一个例子。按照将文字变量重置为john的方式更新代码:

let literal: "tom" | "linda" | "jeff" | "sue" = "linda";
literal = "sue";
literal = "john";
console.log(literal);

在这里,我们将文字变量设置为john,编译会出现以下错误:

图 2.6 – 一个文字错误

图 2.6 – 一个文字错误

数字文字也可以以相同的方式进行,但值是由数字而不是字符串组成的。

类型别名

在 TypeScript 中,类型别名被非常频繁地使用。这只是一种为类型提供不同名称的方法,大多数情况下用于为某些复杂类型提供更短的简单名称。例如,这里是一个可能的用法:

type Points = 20 | 30 | 40 | 50;
let score: Points = 20;
console.log(score);

在这段代码中,我们将一个长数字文字类型赋予一个更短的名字Points。然后,我们声明scorePoints类型,并给它一个值20,这是Points的可能值之一。当然,如果我们试图将分数设置为,比如,99,编译将失败。

另一个别名的例子是对象文字类型声明:

type ComplexPerson = {
    name: string,
    age: number,
    birthday: Date,
    married: boolean,
    address: string
}

由于类型声明非常长并且没有名称,例如类会有的,我们使用别名。在 TypeScript 中,类型别名可以用于包括函数和泛型在内的几乎任何类型,我们将在本章后面进一步探讨。

函数返回类型

为了完整起见,我想展示一个函数返回声明的例子。它与典型的变量声明非常相似。创建一个名为functionReturn.ts的新文件,并将其添加到其中:

function runMore(distance: number): number {
    return distance + 10;
}

runMore函数接受number类型的参数并返回一个数字。参数声明就像任何变量声明一样,但是函数返回在括号之后,并指示函数返回的类型。如果函数不返回任何内容,那么可以不声明返回的类型,或者可以声明void以更明确。

让我们看一个返回void的例子。注释掉runMore函数和控制台日志,然后编译并运行这段代码:

function eat(calories: number) {
    console.log("I ate " + calories + " calories");
}
function sleepIn(hours: number): void {
    console.log("I slept " + hours + " hours");
}
let ate = eat(100);
console.log(ate);
let slept = sleepIn(10);
console.log(slept);

这两个函数什么都不返回,只是将它们的参数写入控制台,如下所示:

图 2.7 – 函数 void 结果

图 2.7 – 函数 void 结果

正如你所看到的,它们的内部console.log语句确实运行。然而,尝试获取返回值会导致undefined,因为没有返回任何内容。

因此,函数返回类型声明与变量声明非常相似。现在,让我们看看如何将函数用作类型。

函数作为类型

在 TypeScript 中,类型也可以是整个函数签名。也就是说,在前面的部分中,我们看到函数可以接受基于类型的参数,并返回一个类型。嗯,这个定义也被称为函数签名。在 TypeScript 中,这个签名也可以作为对象属性的类型。

让我们看一个例子。创建一个名为functionSignature.ts的新文件,并将以下代码添加到其中:

type Run = (miles: number) => boolean;
let runner: Run = function (miles: number): boolean {
    if(miles > 10){
        return true;
    }
    return false;
}
console.log(runner(9));

第一行显示了我们将在此代码中使用的函数类型。Run类型别名只是为了使重用长函数签名更容易。实际的函数类型是(miles: number) => boolean。这看起来很奇怪,但它只是一个简化的函数签名。所以,唯一需要的是用括号表示参数,=>符号表示这是一个函数,然后是返回类型。

在函数定义行之后的代码中,您可以看到runner变量声明为Run类型,这又是一个函数。这个函数简单地检查人是否跑了超过 10 英里,并在他们跑了超过 10 英里时返回true,否则返回false。然后,在代码底部,console.log语句输出函数调用的结果。编译和运行后,您应该能看到这个结果:

图 2.8 – 函数类型结果

图 2.8 – 函数类型结果

正如您所见,使用参数9调用runner会使函数返回false,这是正确的。在静态类型中,能够对我们返回数据的所有方式进行类型标注是很重要的,这意味着不仅是变量,还有函数。

永远类型

这种类型一开始会听起来很奇怪。never类型用于指示一个永远不会返回(完成)的函数,或者一个没有设置为任何值的变量,甚至不是null。乍一看,这听起来像void类型。然而,它们根本不一样。在void中,函数确实返回,完全意义上的返回,它只是不返回任何值(返回undefined,这是没有值)。在never的情况下,函数根本不会完成。现在,这似乎完全没有用,但实际上它对于指示意图是非常有用的。

再次,让我们看一个例子。创建一个名为never.ts的文件,并添加以下代码:

function oldEnough(age: number): never | boolean {
    if(age > 59) {
        throw Error("Too old!");
    }
    if(age <=18){
        return false;
    }
    return true;
}

正如您所见,这个函数返回一个union类型,要么是never,要么是布尔值。现在,我们只能指示布尔值,代码仍然可以工作。然而,在这个函数中,如果人的年龄超过一定年龄,我们会抛出一个错误,表明这是一个意外的age值。因此,由于封装是编写高质量代码的高级原则,明确指示函数可能失败而无需开发人员了解函数工作原理的内部细节是有益的。never提供了这种沟通。

在这一部分,我们学习了 TypeScript 中许多内置类型。我们能够看到为什么使用这些类型可以提高我们的代码质量,并帮助我们在编码周期的早期发现错误。在下一部分,我们将学习如何使用 TypeScript 来创建我们自己的类型,并遵循面向对象编程原则。

理解类和接口

我们已经在之前的部分简要地看过类和接口。让我们在这一部分深入了解一下,并看看为什么这些类型可以帮助我们编写更好的代码。一旦我们完成了这一部分,我们将更好地准备好编写更易读、可重用的代码,bug 更少。

基本上,TypeScript 中的类看起来就像 JavaScript 中的类。它们是一个相关字段和方法的容器,可以被实例化和重用。然而,TypeScript 中的类支持 JavaScript 不支持的封装的额外特性。让我们看一个例子。

创建一个名为classes.ts的新文件,并输入以下代码:

class Person {
    constructor() {}
    msg: string;
    speak() {
        console.log(this.msg);
    }
}
const tom = new Person();
tom.msg = "hello";
tom.speak();

如您所见,这个例子展示了一个简单的类,除了静态类型之外,它与 JavaScript 中看到的类似。首先,您为类命名,以便可以重用。接下来,您有一个构造函数,用于初始化类可能具有的任何字段,并为类实例进行任何其他设置(再次,实例只是我们的类的特定示例,具有自己字段的唯一值)。然后,您声明了一个名为msg的变量和一个名为speak的函数,该函数将msg的值写入控制台。然后,我们创建了我们类的一个实例。最后,我们将msg字段设置为hello的值,并调用speak方法。现在,让我们看一看 TypeScript 和 JavaScript 之间类的区别。

访问修饰符

我们之前提到面向对象开发的一个主要原则是封装,或者信息隐藏。好吧,如果我们再次清楚地看一下代码,我们并没有隐藏msg变量,因为它在类外是可见和可编辑的。所以,让我们看看 TypeScript 允许我们对此做些什么。让我们像这样更新代码:

class Person {
    constructor(private msg: string) {}

    speak() {
        console.log(this.msg);
    }
}
const tom = new Person("hello");
// tom.msg = "hello";
tom.speak();

如您所见,我们使用关键字private更新了构造函数。通过声明构造函数参数并添加访问修饰符,一行代码实际上做了几件事。首先,它告诉编译器类具有一个名为msgstring类型字段,应该是private的。通常,这种声明是在构造函数上方或下方的一行中分开完成的,这样做是完全有效的,但是 TypeScript 允许我们通过将其添加到构造函数参数中来使用快捷方式。此外,通过将其添加到构造函数中,您可以看到它允许我们在实例化时使用new Person("hello")调用来设置我们的msg字段。

现在,将某些东西设置为private实际上是做了什么?通过将字段设置为private,我们使其无法从类外部访问。其结果是tom.msg = "hello"不再起作用并引发错误。尝试删除注释并重新编译。您应该会看到此消息:

图 2.9 - 类错误

图 2.9 - 类错误

如您所见,它抱怨私有成员msg无法从类外部访问。现在,我们只将修饰符应用于字段,但请注意,访问修饰符可以应用于任何成员字段或函数。

重要提示

如前所述,ECMAScript 2020 将通过#符号支持私有字段。但是,目前浏览器对其支持有限,因为它只支持字段,并且这是一个全新的标准。

现在,让我们学习readonly修饰符。这个相对简单;它使字段在构造函数中设置一次后变为只读。因此,更新代码如下,并在msg字段的声明中添加readonly

class Person {
    constructor(private readonly msg: string) {}

    speak () {
        this.msg = "speak " + this.msg;
        console.log(this.msg);
    }
}
const tom = new Person("hello");
// tom.msg = "hello";
tom.speak();

如果这样做,IntelliSense 会抱怨,因为在speak函数中,尽管msg已经通过构造函数设置了一次,我们仍然试图改变msg的值。

privatereadonly访问修饰符并不是 TypeScript 中唯一的修饰符。还有几种其他类型的访问修饰符。但是,如果我们稍后在继承的上下文中解释它们,它们会更有意义。

Getter 和 setter

类的另一个特性实际上在 TypeScript 和 JavaScript 中都可用:gettersetter

  • Getter:允许在返回相关字段之前修改或验证值的属性

  • Setter:允许在设置到相关字段之前修改或计算值的属性

在其他一些语言中,这些类型的属性被称为计算属性。让我们看一个例子。创建一个名为getSet.ts的文件,并添加以下代码:

class Speaker {
    private message: string;
    constructor(private name: string) {}

    get Message() {
        if(!this.message.includes(this.name)){
            throw Error("message is missing speaker's name");
        }
        return this.message;
    }
    set Message(val: string) {
        let tmpMessage = val;
        if(!val.includes(this.name)){
            tmpMessage = this.name + " " + val;
        }
        this.message = tmpMessage;
    }
}
const speaker = new Speaker("john");
speaker.Message = "hello";
console.log(speaker.Message);

这里发生了很多事情,所以在编译和运行之前让我们来看一下。首先,你可以看到我们的message字段不在构造函数中可用,而是一个private字段,因此不能直接从我们的类外部访问。构造函数接受的唯一初始化器是我们的name字段。之后,你可以看到Message属性是一个 getter,因为它的名称前缀带有get关键字。在 getter 中,我们测试看看我们的message字段值是否包含说话者的名字,如果不包含,我们抛出一个异常来指示一个不需要的情况。setter,也称为Message,由set关键字指示,这个属性接收一个字符串,并通过检查message字段是否缺少说话者的名字来添加它。请注意,尽管gettersetter看起来像函数,但它们并不是。当它们在后面的代码中被调用时,它们被调用就像一个字段被调用一样,不带括号。因此,在代码的最后,speaker 对象被实例化为一个名为john的新 speaker,并且它的Message属性被设置为hello。此后,消息被写入控制台。

现在,我们想要编译这段代码,以便我们可以运行它,但这次我们需要做一些不同的事情。TypeScript 编译器有一些选项,它可以接受以定制其行为。在这个例子中,getter 和 setter 以及includes函数只在 ES5 和 ES6 中才可用。如果你对此不熟悉,includes函数检查一个字符串是否是另一个较大字符串的子字符串。因此,让我们告诉 TypeScript 编译器,它需要编译到比默认的 ES3 更新的 JavaScript 目标。

这是你需要的新编译命令(我们稍后会更深入地讨论tsc编译器选项,包括使用配置文件):

tsc --target "ES6" getSet

现在,你可以运行命令。再一次,它如下所示:

node getSet

所以,你现在得到了以下输出:

图 2.10 – getSet 输出

图 2.10 – getSet 输出

为了进一步强调这一点,让我们尝试将speaker.Message = "hello"这一行切换为speaker.message = "hello"。如果你编译,你应该会看到这个错误:

图 2.11 – Message 字段错误

图 2.11 – Message 字段错误

你能猜到为什么会失败吗?是的,这是因为message是一个private字段,不能从我们的类外部访问。

也许你会想为什么我在这里提到gettersetter,当它们在常规 JavaScript 中也是可用的。如果你看一下例子,你会发现message字段是private的,而gettersetter属性是public的(注意,当你没有显式声明访问修饰符时,默认为public)。因此,为了允许良好的封装,最好的做法是隐藏我们的字段,并且只在需要时通过 getter 和/或 setter 或一些允许修改字段的函数来暴露它。还要记住,在决定成员的访问级别时,你希望从最严格的能力开始,然后根据需要变得不那么严格。此外,通过允许通过访问器访问字段,我们可以做各种检查和修改,就像我们在例子中所做的那样,这样我们就可以对进出我们的类的内容有最终的控制。

静态属性和方法

最后,让我们讨论静态属性和方法。当你在类内部将某些东西标记为静态时,你是在说这个成员是类类型的成员,而不是类实例的成员。因此,它可以在不需要创建类的实例的情况下访问,而是通过在类名前加上前缀来访问。

让我们看一个例子。创建一个名为staticMember.ts的新文件,并添加以下代码:

class ClassA {
    static typeName: string;
    constructor(){}

    static getFullName() {
        return "ClassA " + ClassA.typeName;
    }
}
const a = new ClassA();
console.log(a.typeName);

如果你尝试编译这段代码,它将失败,并声明typeNameClassA类型的静态成员。再次强调,静态成员必须使用类名调用。以下是修复后的代码版本:

class ClassA {
    static typeName: string;
    constructor(){}

    static getFullName() {
        return "ClassA " + ClassA.typeName;
    }
}
const a = new ClassA();
console.log(ClassA.typeName);

正如你所看到的,我们用类名引用了typeName。那么,为什么我要使用静态成员而不是实例成员呢?在某些情况下,跨类实例共享数据可能是有用的。例如,我可能想要做这样的事情:

class Runner {    
    static lastRunTypeName: string;
    constructor(private typeName: string) {}

    run() {        
        Runner.lastRunTypeName = this.typeName;
    }
}
const a = new Runner("a");
const b = new Runner("b");
b.run();
a.run();
console.log(Runner.lastRunTypeName);

在这个例子中,我试图确定在任何给定时间内最后调用run函数的类实例。通过使用静态成员,这可以很简单。还要注意的一点是,在类内部,静态成员可以被静态成员和实例成员调用。但是,静态成员不能调用实例成员。

现在我们已经在本节中学习了类及其特性。这将有助于我们为封装设计代码,从而提高其质量。接下来,我们将学习接口和基于合同的编码。

接口

在面向对象编程设计中,另一个重要的原则是抽象。抽象的目标是通过不暴露内部实现来减少代码的复杂性和紧密耦合(我们已经在《第一章》《理解 TypeScript》中涵盖了抽象)。这样做的一种方式是使用接口来仅显示类型的签名,而不是其内部工作方式。接口有时也被称为合同,因为对参数和返回类型进行特定类型的约束会强制执行接口的用户和创建者之间的某些期望。因此,对接口的另一种思考方式是对类型实例的输出和输入施加严格的规则。

现在,接口只是一组规则。为了使代码正常工作,我们需要对这些规则进行实现。因此,让我们展示一个带有实现的接口的示例以开始。创建一个名为interfaces.ts的新文件,并添加以下接口定义:

interface Employee {
    name: string;
    id: number;
    isManager: boolean;
    getUniqueId: () => string;
}

这个接口定义了我们稍后将创建实例的Employee类型。正如你所看到的,getUniqueId函数没有实现,只有其签名。实现将在我们定义它时进行。

现在,将实现添加到interfaces.ts文件中。插入以下代码,创建Employee接口的两个实例:

const linda: Employee = {
    name: "linda",
    id: 2,
    isManager: false,
    getUniqueId: (): string => {
        let uniqueId = linda.id + "-" + linda.name;
        if(!linda.isManager) {
            return "emp-" + uniqueId;
        }
        return uniqueId;
    }
}
console.log(linda.getUniqueId());
const pam: Employee = {
    name: "pam",
    id: 1,
    isManager: true,
    getUniqueId: (): string => {
        let uniqueId = pam.id + "-" + pam.name;
        if(pam.isManager) {
            return "mgr-" + uniqueId;
        }
        return uniqueId;
    }
}
console.log(pam.getUniqueId());

因此,我们通过实例化一个名为linda的对象文字来创建一个实例,设置两个字段名 - nameid,然后实现getUniqueId函数。稍后,我们在控制台记录linda.getUniqueId调用。之后,我们创建另一个对象,名为pam,基于相同的接口。然而,它不仅具有不同的字段值,而且其getUniqueId的实现也与linda对象不同。这是接口的主要用途:允许对象之间有一个统一的结构,但可以实现不同的实现。通过这种方式,我们对类型结构施加严格的规则,但也允许函数在执行其工作时具有一定的灵活性。以下是我们代码的输出:

图 2.12 - 员工接口结果

图 2.12 - 员工接口结果

接口的另一个可能用途是在使用第三方 API 时。有时,类型信息没有很好地记录,你得到的只是无类型的 JSON 或者对象类型非常庞大,有许多你永远不会使用的字段。在这种情况下,很容易只是使用any作为类型并完成它。然而,如果可能的话,你应该更倾向于提供类型声明。

在这种情况下,您可以创建一个只包含您知道并关心的字段的接口。然后,您可以声明您的数据类型为此类型。在开发时,TypeScript 将无法检查类型,因为 API 网络调用的数据将在运行时传入。但是,由于 TypeScript 只关心任何给定类型的形状,它将忽略未在类型声明中提到的字段,只要数据以您在接口中定义的字段传入,运行时就不会抱怨,您将保持开发时的类型安全。但是,请务必小心处理nullundefined字段,允许它们使用联合或测试这些类型。

在本节中,我们学习了接口和接口与类之间的区别。我们将能够使用接口来抽象类的实现细节,从而在我们的代码之间产生松耦合,从而提高代码质量。在下一节中,我们将学习类和接口如何允许我们执行继承,从而实现代码重用。

理解继承

在本节中,我们将学习继承。面向对象编程中的继承是一种代码重用的方法。这将缩小我们的应用程序代码大小,并使其更易读。此外,一般来说,较短的代码往往会有更少的错误。因此,一旦开始构建,所有这些因素都将提高我们应用程序的质量。

如前所述,继承主要是允许代码重用。继承在概念上也被设计成像现实生活中的继承,以便继承关系的逻辑流可以直观且更易于理解。现在让我们看一个例子。创建一个名为classInheritance.ts的文件,并添加以下代码:

class Vehicle {
    constructor(private wheelCount: number) {}
    showNumberOfWheels() {
        console.log(`moved ${this.wheelCount} miles`);
    }
}
class Motorcycle extends Vehicle {
    constructor() {
        super(2);
    }
}
class Automobile extends Vehicle {
    constructor() {
        super(4);
    }
}
const motorCycle = new Motorcycle();
motorCycle.showNumberOfWheels();
const autoMobile = new Automobile();
autoMobile.showNumberOfWheels();

重要提示

如果您以前从未见过反引号``和${},这是一个快速和简单的方法,称为字符串插值,通过嵌入对象在字符串中插入字符串值。

如您所见,有一个基类,也称为父类,名为Vehicle。这个类充当了源代码的主要容器,稍后将被从中继承的任何类重用,也称为子类。子类使用extends关键字从Vehicle继承。一个重要的事情要注意的是,在每个子类的构造函数中,您会看到第一行代码是对super的调用。super是子类继承的父类的实例的名称。因此,在这种情况下,那将是Vehicle类。现在,您可以看到,每个子类通过父类的构造函数向父类的wheelCount变量传递了不同数量的轮子。然后,在代码的末尾,创建了每个子类的实例MotorcycleAutomobile,并调用了showNumberOfWheels函数。如果我们编译并运行此代码,我们会得到以下结果:

图 2.13 - classInheritance 结果

图 2.13 - classInheritance 结果

因此,每个子类向父类的wheelCount变量提供了不同数量的轮子,尽管它们无法直接访问该变量。现在,假设子类希望直接访问父类的wheelCount变量有一个原因。例如,假设发生了爆胎,需要更新轮胎数量。我们该怎么办?好吧,让我们尝试为每个子类创建一个独特的函数,试图更新wheelCount。让我们看看会发生什么。通过向Motorcycle类添加一个新函数updateWheelCount来更新代码:

class Vehicle {
    constructor(private wheelCount: number) {}
    showNumberOfWheels() {
        console.log(`moved ${this.wheelCount} miles`);
    }
}
class Motorcycle extends Vehicle {
    constructor() {
        super(2);
    }
    updateWheelCount(newWheelCount: number){
        this.wheelCount = newWheelCount;
    }
}
class Automobile extends Vehicle {
    constructor() {
        super(4);
    }
}
const motorCycle = new Motorcycle();
motorCycle.showNumberOfWheels();
const autoMobile = new Automobile();
autoMobile.showNumberOfWheels();

作为一个测试,如果我们只更新Motorcycle类并添加一个updateWheelCount函数,如下所示,我们会得到一个错误。你能猜到为什么吗?这是因为我们试图访问父类的私有成员。因此,即使子类从父类继承其成员,它们仍然无法访问父类的private成员。这是正确的行为,再次促进封装。那么,我们该怎么办呢?好吧,让我们再次尝试编辑代码来允许这样做:

class Vehicle {
    constructor(protected wheelCount: number) {}
    showNumberOfWheels() {
        console.log(`moved ${this.wheelCount} miles`);
    }
}
class Motorcycle extends Vehicle {
    constructor() {
        super(2);
    }
    updateWheelCount(newWheelCount: number){
        this.wheelCount = newWheelCount;
    }
}
class Automobile extends Vehicle {
    constructor() {
        super(4);
    }
}
const motorCycle = new Motorcycle();
motorCycle.showNumberOfWheels();
const autoMobile = new Automobile();
autoMobile.showNumberOfWheels();

您看到我们做的小改变了吗?没错,我们将Vehicle父类构造函数中的wheelCount参数更改为protected访问器类型。protected允许类和任何继承类访问成员。

在我们继续下一个主题之前,让我们介绍namespaces.ts的概念,并添加以下代码:

namespace A {
    class FirstClass {}
}
namespace B {
    class SecondClass {}
    const test = new FirstClass();
}

从这段代码中可以看出,即使在编译之前,VSCode IntelliSense 已经抱怨找不到FirstClass。这是因为它被隐藏在namespace B中,因为它只在namespace A中定义。这就是命名空间的目的,将一个范围内的信息隐藏在其他范围之外。

在这一部分,我们学习了从类中继承。类继承是重用代码的一个非常重要的工具。在下一节中,我们将学习使用抽象类,这是一种更灵活的继承方式。

抽象类

如前所述,接口可以用于定义合同,但它们本身没有工作代码的实现。类有工作实现,但有时只需要一个签名。对于这种类型的情况,您将使用abstractClass.ts,并将我们的classInheritance.ts文件中的代码复制粘贴到其中。如果这样做,您可能会遇到一些错误,因为这两个文件都有相同的类和变量名。

因此,在我们的新的abstractClass.ts文件中,我们将使用命名空间更新它,并将Vehicle类修改为抽象类。添加命名空间并像这样更新Vehicle类:

namespace AbstractNamespace {
    abstract class Vehicle {
        constructor(protected wheelCount: number) {}
        abstract updateWheelCount(newWheelCount: number): void;
        showNumberOfWheels() {
            console.log(`moved ${this.wheelCount} miles`);
        }
    }

因此,首先,我们显然将所有代码包装在一个名为namespace AbstractNamespace的括号中(请注意,命名空间可以有任何名称;它的名称不需要在名称中包含namespace)。同样,这只是一个容器,允许我们控制作用域,以便我们的abstractClass.ts文件的成员不会泄漏到全局作用域,并影响其他文件。

如果您查看新的Vehicle代码,我们在类名abstract之前有一个new关键字。这表明该类将是一个抽象类。您还可以看到我们有一个名为updateWheelCount的新函数。这个函数在Vehicle类前面有一个abstract关键字,这表明它在Vehicle类中没有实现,需要由继承类实现。

现在,在Vehicle abstract类之后,我们想要继承它的子类。因此,在Vehicle类下面添加MotorcycleAutomobile类:

    class Motorcycle extends Vehicle {
        constructor() {
            super(2);
        }
        updateWheelCount(newWheelCount: number){
            this.wheelCount = newWheelCount;
            console.log(`Motorcycle has ${this.wheelCount}`);
        }
    }
    class Automobile extends Vehicle {
        constructor() {
            super(4);
        }
        updateWheelCount(newWheelCount: number){
            this.wheelCount = newWheelCount;
            console.log(`Automobile has ${this.wheelCount}`);
        }
        showNumberOfWheels() {
            console.log(`moved ${this.wheelCount} miles`);
        }
    }

添加类之后,我们实例化它们并调用它们各自的updateWheelCount方法,如下所示:

    const motorCycle = new Motorcycle();
    motorCycle.updateWheelCount(1);
    const autoMobile = new Automobile();
    autoMobile.updateWheelCount(3);
}

正如您所看到的,abstract成员updateWheelCount的实现在子类中。这是抽象类提供的功能。抽象类既可以作为常规类,提供成员实现,也可以作为接口,只提供子类实现的规则。请注意,由于抽象类可以有抽象成员,您不能实例化抽象类。

此外,如果您查看Automobile类,您会发现它有自己的showNumberOfWheels的实现,即使这个函数不是抽象的。这展示了一种称为覆盖的东西,即子类成员能够创建父类成员的独特实现的能力。

在本节中,我们学习了不同类型的基于类的继承。学习继承将使我们能够重用更多的代码,减少代码大小和潜在的错误。在下一节中,我们将学习如何使用接口进行继承,以及它与基于类的继承有何不同。

接口

正如前面所解释的,接口是一种为类型设置约定规则的方式。它们将允许我们将实现与定义分离,从而提供抽象,这又是一个强大的面向对象编程原则,将为我们提供更高质量的代码。让我们学习如何使用接口来明确继承并以一种良好结构的方式使用。

TypeScript 接口为接口的成员提供一组类型签名,但它们本身没有实现。现在,我们确实展示了一些使用独立接口的例子,但这次,让我们看看如何可以使用接口作为继承和代码重用的手段。创建一个名为interfaceInheritance.ts的新文件,并添加以下代码:

namespace InterfaceNamespace {
    interface Thing {
        name: string;
        getFullName: () => string;
    }
    interface Vehicle extends Thing {
        wheelCount: number;
        updateWheelCount: (newWheelCount: number) => void;
        showNumberOfWheels: () => void;
    }

在命名空间之后,您可以看到有一个名为Thing的接口,之后是定义了Vehicle接口,并使用extends关键字从Thing继承。我将这放入示例中以表明接口也可以从其他接口继承。Thing接口有两个成员 - namegetFullName - 正如您所看到的,尽管Vehicle扩展了Thing,但在Vehicle的任何地方都没有提到这些成员。这是因为Vehicle是一个接口,因此不能有任何实现。然而,如果您查看以下代码,在Motorcycle类中,您会发现,由于这个类扩展了Vehicle,实现是存在的:

    class Motorcycle implements Vehicle {
        name: string;
        wheelCount: number;
        constructor(name: string) {
            // no super for interfaces
            this.name = name;
        }
        updateWheelCount(newWheelCount: number){
            this.wheelCount = newWheelCount;
            console.log(`Automobile has ${this.wheelCount}`);
        }
        showNumberOfWheels() {
            console.log(`moved Automobile ${this.wheelCount}            miles`);
        }
        getFullName() {
            return "MC-" + this.name;
        }
    }
    const moto = new Motorcycle("beginner-cycle");
    console.log(moto.getFullName());
}

因此,如果我们编译并运行此代码,我们会得到以下结果:

图 2.14 – 接口继承结果

图 2.14 – 接口继承结果

接口本身并不直接提供代码重用的手段,因为它们没有实现。然而,它仍然有利于代码重用,因为接口的结构提供了对代码将接收和返回什么的明确期望。将实现隐藏在接口后面也有利于进行封装和抽象,这也是面向对象编程的重要原则。

重要提示

在使用 TypeScript 时,充分利用面向对象编程中可用的继承模型。使用接口来抽象实现细节。使用privateprotected来帮助封装数据。请记住,当编译并将您的代码转换为 JavaScript 时,TypeScript 编译器将为您执行任何翻译工作,以将事物重新转换为原型样式。但在开发模式下,您应该充分利用 TypeScript 提供的所有功能,以增强您的开发体验。

在本节中,我们学习了继承以及如何将其用于代码重用。我们了解了如何使用三种主要的容器类型:类、抽象类和接口进行继承。一旦我们开始编写应用程序,您将会看到为什么能够进行代码重用是大型应用程序开发中如此关键的因素。在下一节中,我们将介绍泛型,它将使用我们在本节中学到的类型。

学习泛型

泛型允许类型定义包括一个关联类型,可以由泛型类型的用户选择,而不是由类型创建者指定。这样,有一些结构和规则,但仍然有一定的灵活性。泛型在我们后面使用 React 编码时肯定会发挥作用,所以让我们在这里学习一下。

泛型可以用于函数、类和接口。让我们看一个使用函数泛型的例子。创建一个名为functionGeneric.ts的文件,并添加以下代码:

function getLength<T>(arg: T): number {
    if(arg.hasOwnProperty("length")) {
        return arg["length"];
    }
    return 0;
}
console.log(getLength<number>(22));
console.log(getLength("Hello world."));

如果我们从顶部开始,我们会看到一个名为getLength<T>的函数。这个函数使用了一个泛型,告诉编译器无论它在哪里看到T符号,它都可以期望任何可能的类型。现在,在内部,我们的函数实现检查arg参数是否有一个名为length的字段,然后尝试获取它。如果没有,它就返回0。最后,在底部,您可以看到getLength函数被调用了两次:一次是为了一个数字,另一次是为了一个字符串。此外,您可以看到对于number,它明确地有<number>类型指示符,而对于string,它没有。这只是为了表明您可以明确指定类型,但编译器通常可以根据使用情况推断出您的意图。

这个例子的问题在于为了检查length字段而需要额外的代码。这使得代码变得繁忙,比实际需要的代码更长。让我们更新这段代码,以防止调用这个函数如果参数没有length属性。首先,注释掉我们刚刚写的代码,然后在其下面添加以下新代码:

interface HasLength {
    length: number;
}
function getLength<T extends HasLength>(arg: T): number {
    return arg.length;
}
console.log(getLength<number>(22));
console.log(getLength("Hello world."));

这段代码非常相似,只是我们使用了一个HasLength接口来限制允许的类型。通过使用extends关键字来约束泛型类型。通过编写T extends HasLength,我们告诉编译器无论T是什么,它必须继承自HasLength类型,这有效地意味着它必须具有length属性。因此,当进行前两个调用时,对于number类型会失败,因为它们没有length属性,但对于string则有效。

现在,让我们看一个使用接口和类的例子。让我们创建一个名为classGeneric.ts的文件,并向其中添加以下代码:

namespace GenericNamespace {
    interface Wheels {
        count: number;
        diameter: number;
    }
    interface Vehicle<T> {
        getName(): string;
        getWheelCount: () => T;
    }

因此,我们可以看到我们有一个名为Wheels的接口,它提供了轮子信息。我们还可以看到Vehicle接口采用了类型T的泛型,表示任何特定类型。

随后,我们看到Automobile类实现了具有泛型作为Wheel类型的Vehicle接口,将Wheel关联到Automobile。然后,最后,我们看到Chevy类扩展了Automobile,提供了一些默认值:

    class Automobile implements Vehicle<Wheels> {
        constructor(private name: string, private wheels:          Wheels){}
        getName(): string {
            return this.name;
        }
        getWheelCount(): Wheels {
            return this.wheels;
        }
    }
    class Chevy extends Automobile {
        constructor() {
            super("Chevy", { count: 4, diameter: 18 });
        }
    }

在定义了所有这些类型之后,我们创建了Chevy类的一个实例,并从中记录了一些输出:

    const chevy = new Chevy();
    console.log("car name ", chevy.getName());
    console.log("wheels ", chevy.getWheelCount());
}

这段代码编译并成功运行,并给出以下结果:

图 2.15 – classGeneric.ts 的结果

图 2.15 – classGeneric.ts 的结果

您可以看到我们的继承层次结构有几个级别,但我们的代码能够成功返回有效的结果。虽然现实世界代码中的具体细节可能不同,但是在这里显示的多级类型层次结构在面向对象编程设计中是经常发生的事情。

在本节中,我们学习了如何在函数和类类型上使用泛型。泛型通常在 React 开发中使用,以及一些 Node 包中也会用到。因此,一旦我们在后面的章节开始编码,它们将会很有用。在下一节中,我们将看一些其他杂项内容来完成本章。

学习最新功能并配置编译器

在本节中,我们将学习 TypeScript 中一些较新的特性,以及如何配置 TypeScript 编译器。通过了解这些较新的特性,我们将能够编写更清晰、更易读的代码,这当然对团队中使用应用程序是有益的。通过使用 TypeScript 的配置选项,我们可以让编译器以我们认为最适合我们项目的方式工作。

Optional chaining

让我们来看看null对象。让我们创建一个名为optionalChaining.ts的文件,并将以下代码添加到其中:

namespace OptionalChainingNS {
    interface Wheels {
        count?: number;
    }
    interface Vehicle {
        wheels?: Wheels;
    }
    class Automobile implements Vehicle {
        constructor(public wheels?: Wheels) {}
    }
    const car: Automobile | null = new Automobile({
        count: undefined
    });
    console.log("car ", car);
    console.log("wheels ", car?.wheels);
    console.log("count ", car?.wheels?.count);
}

如果我们看这段代码,我们可以看到有几种类型被一起使用。car有一个wheels属性,而wheels有一个count属性。因此,稍后当我们记录时,你可以看到调用被链接在一起。例如,最后的console.log行引用了car?.wheels?.count。这被称为可选链。问号表示对象可能是nullundefined。如果它是nullundefined,那么代码将在该对象结束,返回对象或属性的任何值,并且不继续到其余的属性,但不会导致错误。

因此,如果我们以旧的方式编写底部的控制台代码,我们将不得不进行大量的代码测试,以确保我们不会通过调用可能是undefined的东西而导致错误。我们将使用三元操作符,它可能看起来像这样:

const count = !car ? 0 
    : !car.wheels ? 0 
    : !car.wheels.count ? 0
    : car.wheels.count;

显然,这既难写又难读。因此,通过使用可选链,我们允许编译器在发现nullundefined时立即停止并返回。这使我们免于编写大量冗长、可能容易出错的代码。

Nullish coalescing

Nullish coalescing 是三元运算符的简化形式。因此,它非常直接,看起来像这样:

const val1 = undefined;
const val2 = 10;
const result = val1 ?? val2;
console.log(result);

双问号从左到右工作。该语句的意思是,如果val1不是nullundefined并且有实际值,则返回该值。然而,如果val1没有值,则返回val2。因此,在这种情况下,编译和运行将导致10被写入控制台。

你可能想知道这是否与||运算符相同。它有些相似但更受限制。逻辑或运算符在这种情况下,检查“真值”。在 JavaScript 中,这是一个概念,其中大量可能的值可以被认为是“真值”或“假值”。例如,0truefalseundefined""在 JavaScript 中都有真或假的等价性。然而,在 nullish coalescing 的情况下,只有nullundefined被明确检查。

TypeScript 配置

TypeScript 配置可以通过命令行传递,或者更常见的是通过一个名为tsconfig.json的文件。如果你使用命令行,那么调用编译器就像这样:

tsc tsfile.ts –lib 'es5, dom'

这告诉 TypeScript 忽略任何tsconfig.json文件,只使用命令行选项 - 在这种情况下,-lib选项,它声明在开发过程中使用的 JavaScript 版本,并且只编译这一个文件。如果你只在命令行上输入tsc,TypeScript 将寻找一个tsconfig.json文件并使用该配置,并编译它找到的所有ts文件。

有许多选项,所以我们在这里不会涵盖所有。然而,让我们回顾一些最重要的选项(当我们开始编码时,我会提供一个示例tsconfig.json文件供使用):

  • --lib:这用于指示在开发过程中将使用哪个 JavaScript 版本。

  • --target:这表示你想要发射到.js文件中的 JavaScript 版本。

  • --noImplicitAny:不允许any类型,除非显式声明它。

  • --outDir:这是 JavaScript 文件将保存到的目录。

  • --outFile:这是最终的 JavaScript 文件名。

  • --rootDirs:这是一个存储.ts文件源代码的数组。

  • --exclude:这是一个要从编译中排除的文件夹和文件的数组。

  • --include:这是一个要包含在编译中的文件夹和文件的数组。

本节仅提供了 TypeScript 一些新特性的简要概述,以及一些与配置相关的信息。然而,这些新特性和配置 TypeScript 的能力非常重要,在我们开始编写代码的后续章节中将会被广泛使用。

总结

在本章中,我们学习了 TypeScript 语言。我们了解了语言中存在的许多不同类型,以及如何创建我们自己的类型。我们还学习了如何使用 TypeScript 来创建面向对象的代码。这是一个庞大而复杂的章节,但对于我们开始构建应用程序时将是绝对必要的知识。

在下一章中,我们将回顾一些传统 JavaScript 中最重要的特性。我们还将了解一些最新版本语言中的新特性。由于 TypeScript 是 JavaScript 的真正超集,因此了解 JavaScript 的最新情况对于充分利用 TypeScript 非常重要。

第三章:使用 ES6+功能构建更好的应用程序

在本章中,我们将回顾 JavaScript 在其最新的 ES6+形式中的一些重要特性(我添加了加号表示 ES6 及更高版本)。重要的是要理解,尽管本书使用 TypeScript,但这两种语言是互补的。换句话说,TypeScript 并不取代 JavaScript。它增强和增强了 JavaScript,添加了使其更好的功能。因此,我们将回顾 JavaScript 语言中一些最重要的特性。我们将回顾变量作用域和新的constlet关键字。此外,我们将深入研究this关键字以及在需要时如何切换它。我们还将学习 JavaScript 中许多新功能,例如新的数组函数和async await。这些知识将为我们提供一个坚实的基础,使我们能够在 TypeScript 中编码。

在本章中,我们将涵盖以下主要主题:

  • 学习 ES6 变量类型和 JavaScript 作用域

  • 学习箭头函数

  • 更改this上下文

  • 学习有关传播、解构和剩余

  • 学习新的数组函数

  • 学习新的集合类型

  • 学习async await

技术要求

本章的要求与第二章** TypeScript 探索相同。您应该对 JavaScript 和 Web 技术有基本的了解。您将再次使用 Node 和Visual Studio CodeVSCode)。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap3文件夹中的代码。

让我们设置本章的代码文件夹:

  1. 转到您的HandsOnTypescript文件夹并创建一个名为Chap3的新文件夹。

  2. 打开 VSCode 并转到您刚创建的Chap3文件夹。然后,选择View | Terminal并在 VSCode 窗口内启用终端窗口。

  3. 键入npm init命令,就像上一章那样,初始化npm项目,并接受所有默认值(您也可以使用npm init -y自动接受所有默认值)。

  4. 键入npm install typescript命令,就像上一章那样,安装 TypeScript。

现在我们准备开始了。

学习 ES6 变量类型和 JavaScript 作用域

在本节中,我们将学习 JavaScript 的作用域规则和一些新的变量类型,这有助于澄清和改进有关这些作用域规则的一些问题。这些信息很有价值,因为作为软件开发人员,您将不断地创建变量,并且了解变量可以在什么范围内访问以及在什么情况下可能会更改是很重要的。

在大多数其他语言中,变量作用域发生在任意一组括号或begin end作用域语句内。然而,在 JavaScript 中,作用域由函数体处理,这意味着当使用var关键字在函数体内声明变量时,该变量只能在该体内访问。让我们看一个例子。创建一个名为functionBody.ts的新文件,并将以下代码添加到其中:

if (true) {
    var val1 = 1;
}
function go() {
    var val2 = 2;
}
console.log(val1);
console.log(val2);

在 VSCode 中,您应该看到对console.log(val2)的调用的错误指示,而对console.log(val1)的调用却可以正常工作。您可能会认为,由于val1是在if语句的括号内声明的,所以稍后将无法访问它。然而,显然它是可以的。但另一方面,由go函数作用域的val2在外部是不可访问的。这表明就变量声明而言,使用var的函数充当作用域容器。

这个功能实际上是 JavaScript 中很多混淆的根源。因此,在 ES6 中,创建了一组新的变量声明前缀:constlet。让我们在这里回顾一下。

const变量支持一种称为块级作用域的东西。块级作用域是在任何花括号之间的作用域。例如,在我们之前的例子中,那将是if语句。此外,顾名思义,const创建一个常量变量值,一旦设置,就不能重新设置为其他值。然而,这意味着的内容与其他一些语言有点不同。在 JavaScript 中,这意味着变量的赋值不能被更改。但是,变量本身可以被编辑。这很难想象,所以让我们看一些例子。创建一个名为const.ts的新文件,并添加以下代码:

namespace constants {
    const val1 = 1;
    val1 = 2;
    const val2 = [];
    val2.push('hello');
}

在 VSCode 中,这段代码将对val1 = 2显示错误,但对于val2.push('hello')则没有问题。原因是在val1的情况下,变量实际上被重置为一个全新的值,这是不允许的。然而,对于val2,数组值保持不变,并且新元素被添加到其中。因此,这是允许的。

现在,让我们看一下let关键字。let变量与const变量一样,也是块级作用域的。然而,它们可以随意设置和重置(当然,在 TypeScript 中,类型需要保持不变)。让我们展示一个let的例子。创建一个名为let.ts的文件,并添加以下代码:

namespace lets {
    let val1 = 1;
    val1 = 2;
    if(true) {
        let val2 = 3;
        val2 = 3;
    }
    console.log(val1);
    console.log(val2);
}

因此,在这里,我们有两组let变量。val1没有在块中作用域,但val2if块中作用域。正如你所看到的,只有对console.log(val2)的调用失败了,因为val2只存在于if块内部。

那么,你会使用哪种变量声明方法?社区中目前的最佳实践是优先使用const,因为不可变性是一个有益的属性,而且使用常量还会带来微小的性能优势。然而,如果你知道需要能够稍后重置变量,那么请使用let。最后,避免使用var

我们已经了解了作用域和 ES6 中新的constlet变量类型。理解作用域并知道何时使用const和何时使用let是进行现代 JavaScript 开发的重要技能。在较新的 JavaScript 代码中,你会经常看到这些关键字。接下来,我们将回顾this上下文和箭头函数。

学习箭头函数

箭头函数是 ES6 的一个新添加。基本上,它们有两个主要目的:

  • 它们缩短了编写函数的语法。

  • 它们还会自动使立即作用域父级成为this对象,箭头函数的父级。

在继续之前,让我更详细地解释一下this,因为这对 JavaScript 开发人员来说是至关重要的知识。

在 JavaScript 中,this对象,即成员属性和方法所属的所有者对象实例,可以根据调用的上下文而改变。因此,当直接调用函数时,例如MyFunction(),父this将是函数的调用者;也就是说,当前作用域的this对象。对于浏览器来说,通常是window对象。然而,在 JavaScript 中,函数也可以用作对象构造函数,例如new MyFunction()。在这种情况下,函数内部的this对象将是从new MyFunction构造函数创建的对象实例。

让我们看一个例子来澄清一下,因为这是 JavaScript 的一个非常重要的特性。创建一个名为testThis.ts的新文件,并添加以下代码:

function MyFunction () {
    console.log(this);
}

MyFunction();
let test = new MyFunction();

如果你编译然后运行这段代码,你会看到以下结果:

图 3.1 - testThis 结果

图 3.1 - testThis 结果

因此,当直接调用MyFunction时,立即作用域父级将是 Node 的全局对象,因为我们不是在浏览器中运行。接下来,如果我们使用new MyFunction()MyFunction创建一个新对象,this对象将成为它自己的对象实例,因为该函数被用来创建一个对象,而不是直接运行。

既然我们已经了解了这一点,让我们看看箭头函数是什么样子的。创建arrowFunction.ts文件,并添加以下代码:

const myFunc = (message: string): void => {
    console.log(message);
}

myFunc('hello');

如果编译并运行此代码,您将看到打印出hello。语法与函数类型非常相似;但它们并不相同。如果我们看一下代码,您会看到参数括号后面有一个冒号,然后是参数括号后面的 void 类型。这是函数的返回类型。在函数类型的情况下,返回类型在=>符号之后表示。

关于箭头函数还有一些额外的事情需要注意。JavaScript 中的所有非箭头函数都可以访问一个称为arguments的集合。这是给定给函数的所有参数的集合。箭头函数没有自己的arguments集合。但是,它们可以访问立即函数父级的arguments集合。

箭头函数有几种主体样式。以下是三种样式的示例:

const func = () => console.log('func');
const func1 = () => ({ name: 'dave' });
const func2 = () => {
    const val = 20;
    return val;
}
console.log(func());
console.log(func1());
console.log(func2());

让我们看看这三种样式:

  • 第一个函数func显示了函数体中只使用了一行代码,没有返回任何内容,您可以看到函数体没有闭合括号或括号。

  • 第二个函数func1显示了只有一行,但返回了一些内容的情况。在这种情况下,不需要return关键字,只有在返回对象时才需要括号。

  • 最后一个案例是func2。在这种情况下,需要花括号,因为这是一个多行语句(无论是否返回)。

我们在本节中介绍了箭头函数。箭头函数在现代 JavaScript 和 TypeScript 代码中被广泛使用,因此深入了解这个特性是有益的。

更改 this 上下文

我们已经在前一节讨论了this上下文对象。如前所述,在 JavaScript 中,函数可以访问一个称为this的内部对象,该对象表示函数的调用者。现在,使用this的令人困惑的部分是,this的值可能会根据函数的调用方式而改变。因此,JavaScript 提供了一些帮助器,允许您将函数的this对象重置为您想要的对象,而不是给定的对象。有几种方法,包括applycall,但我们要学习的最重要的是bind关键字。这对我们很重要,因为在 React 基于类的组件中经常使用bind。现在展示一个完整的 React 示例还为时过早。所以,让我们从一些更简单的东西开始。创建一个名为bind.ts的新文件,并将以下代码添加到其中:

class A {
    name: string = 'A';
    go() {
        console.log(this.name);
    }
}
class B {
    name: string = 'B';
    go() {
        console.log(this.name);
    }
}
const a = new A();
a.go();
const b = new B();
b.go = b.go.bind(a);
b.go();

从这段代码中可以看出,有两个不同的类:AB。这两个类都有一个go函数,将特定的类名写入日志。现在,当我们将b对象的go函数的this对象的bind重置为a对象时,它会将console.log(this.name)语句切换为使用a作为this对象。因此,如果我们编译并运行,我们会得到这个:

图 3.2 - bind

图 3.2 - bind

正如您所看到的,a.go()写入了A,但b.go()也写入了A,而不是B,因为我们将this切换为a而不是b。请注意,除了接受this参数外,bind还可以在此后接受任意数量的参数。

您可能想知道使用bindcallapply之间的区别是什么。bind用于更改this上下文,稍后在调用函数时,它将具有更改后的this对象。但是,callapply是在调用函数时立即替换调用时的this上下文。callapply之间的区别在于,call接受不确定数量的参数,而apply接受参数数组。让我们看一些示例。创建一个名为call.js的文件,并将以下代码添加到其中:

const callerObj = {
    name: 'jon'
}
function checkMyThis(age) {    
    console.log(`What is this ${this}`)
    console.log(`Do I have a name? ${this.name}`)
    this.age = age;
    console.log(`What is my age ${this.age}`);
}
checkMyThis();
checkMyThis.call(callerObj, 25);

首先,我们创建一个名为callerObj的新对象,它有一个名为name的字段,即jon。之后,我们声明一个checkMyThis函数,测试当前的this是什么,以及它是否有一个名字。最后,我们运行两个调用。请注意,第二个调用看起来很奇怪,但checkMyThis.call实际上是对checkMyThis函数的执行。如果我们运行这段代码,我们会看到一些有趣的东西。运行以下命令:

node call

您将看到以下结果:

图 3.3 – node call

图 3.3 – node call

checkMyThis函数的第一次执行默认使用全局对象,因为它没有被覆盖。对于 Node 来说,是 Node 的全局对象,但对于浏览器来说,是window对象。我们还看到nameage字段是未定义的,因为 Node 的全局对象没有name字段,而 age 没有作为参数传递给checkMyThis。然而,在函数的第二次执行中,使用call,我们看到对象已经改变为标准对象类型,它有一个名为jonname字段,这是callerObjname字段,以及一个等于25age字段,这是我们传递给call的参数。您应该注意call的参数列表的顺序遵循被调用函数的参数列表的顺序。apply的用法是相同的;但是,它将参数作为数组。

在本节中,我们了解了处理this上下文的困难以及如何使用bind来处理这个问题。一旦我们开始创建 React 组件,我们将广泛使用bind。但即使超出了特定的用例,您会发现您的代码有时需要能够更改this上下文,可能还需要一些函数的参数。因此,这种能力是一个非常有用的功能。

学习 spread、解构和 rest

在 ES6+中,有新的方法来处理对象的复制和显示变量和参数。这些功能在使 JavaScript 代码更短、更易读方面发挥了重要作用。这些特性已经成为现代 JavaScript 的标准实践,因此我们需要了解它们并正确使用它们。

Spread、Object.assign 和 Array.concat

Object.assignArray.concat JavaScript 功能非常相似。基本上,您将多个对象或数组追加到一个对象或数组中。但严格来说,有一些区别。

在对象的情况下,有两种合并或连接对象的方法:

  • Spread—例如,{ … obja, …objb }: 您正在创建这两个对象的非修改副本,然后创建一个全新的对象。请注意,spread 可以处理不止两个对象。

  • Object.assign(obja, objb): 将objb的属性添加到obja中并返回obja。因此,obja被修改。以下是一个示例。创建一个名为spreadObj.ts的新文件,并添加以下代码:

namespace NamespaceA {
    class A {
        aname: string = 'A';
    }
    class B {
        bname: string = 'B';
    }
    const a = new A();
    const b = new B();
    c, which is set using the spread operator, …. After that, we create d from the Object.assign call. Let's try running this code. You'll need to target ES6 since Object.assign is only available on that version of JavaScript. Let's compile and then run with the following commands:

tsc spreadObj –target 'es6'

node spreadObj


Once these commands run, you will see the following:

图 3.4 – spreadObj

图 3.4 – spreadObj

如您所见,c既有anamebname属性,但它是一个独特的对象。然而,d实际上是对象a具有对象b的属性,这由a.aname = 'a1'设置后aname变量等于a1来证明。

现在,对于合并或连接数组,您还有两种方法:

  • 展开运算符:与对象的展开类似,它合并数组并返回一个新的单一数组。原始数组不会被修改。

  • Array.concat:通过将两个源数组合并成一个新数组来创建一个新数组。原始数组不会被修改。

让我们看一个使用这两种方法的示例。创建一个名为spreadArray.ts的文件,并添加以下代码:

namespace SpreadArray {
    const a = [1,2,3];
    const b = [4,5,6];
    const c = [...a, ...b];
    const d = a.concat(b);
    console.log('c before', c);
    console.log('d before', d);
    a.push(10);
    console.log('a', a);
    console.log('c after', c);
    console.log('d after', d);
}

正如您所看到的,数组c是使用 spread 从两个数组ab创建的。然后,数组d是使用a.concat(b)创建的。在这种情况下,两个结果数组都是唯一的,不引用任何原始数组。让我们像之前一样编译和运行这段代码,看看我们得到了什么:

图 3.5 – spreadArray

图 3.5 – spreadArray

您会发现a.push(10)console.log('d after', d)语句没有影响,即使数组d是从数组a创建的。这表明数组的 spread 和concat都会创建新的数组。

解构

解构是显示和直接使用对象内部属性的能力,而不仅仅依赖于对象名称。我稍后会用一个例子来解释这一点,但请注意,这是现代 JavaScript 开发中非常常用的功能,特别是在 React hooks 中,所以我们需要熟悉它。

让我们来看一个对象解构的例子。对于这个例子,让我们只使用一个 JavaScript 文件,这样例子会更清晰。创建一个名为destructuring.js的新文件,并将以下代码添加到其中:

function getEmployee(id) {
    return {
        name: 'John',
        age: 35,
        address: '123 St',
        country: 'United States'
    }
}
const { name: fullName, age } = getEmployee(22);
console.log('employee', fullName, age);

假设一下getEmployee函数去服务器并通过id检索员工的信息。现在,正如您所看到的,employee对象有很多字段,也许并不是每个调用该函数的人都需要每个字段。因此,我们使用对象解构来选择我们关心的字段。此外,请注意,我们还使用冒号给字段名称取了一个别名fullName

数组也可以进行解构。让我们将以下代码添加到这个文件中:

function getEmployeeWorkInfo(id) {
    return [
        id,
        'Office St',
        'France'
    ]
}
const [id, officeAddress] = getEmployeeWorkInfo(33);
console.log('employee', id, officeAddress);

在这个例子中,getEmployeeWorkInfo函数返回一个关于员工工作位置的事实数组;但它以数组的形式返回。因此,我们也可以对数组进行解构,但请注意,在解构时元素的顺序是很重要的。让我们看看这两个函数的结果。请注意,由于这是一个 JavaScript 文件,我们只需要调用 Node。运行以下命令:

node destructuring.js 

您将看到这两个函数的以下结果:

图 3.6 – 解构

图 3.6 – 解构

正如您所看到的,这两个函数都返回了正确的相关数据。

休息

关键字。任何 rest 参数都是数组,因此可以访问所有数组函数。rest 关键字指的是"其余的项目",而不是"暂停"或"停止"。这个关键字在创建函数签名时提供了更多的灵活性,因为它允许调用者确定他们想要传递多少参数。请注意,只有最后一个参数可以是 rest 参数。以下是使用 rest 的一个例子。创建一个名为rest.js的文件,并添加以下代码:

function doSomething(a, ...others) {
    console.log(a, others, others[others.length - 1]);
}
doSomething(1,2,3,4,5,6,7);

正如您所看到的,…others指的是a之后的其余参数。这表明 rest 参数不必是函数的唯一参数。因此,如果您运行此代码,您会得到以下结果:

图 3.7 – Rest

图 3.7 – Rest

doSomething函数接收两个参数:a变量和a参数,rest 参数(再次是参数数组),以及 rest 参数的最后一个元素。Rest 并不像 spread 和解构那样经常使用。尽管如此,您会看到它,所以您应该意识到它。

在本节中,我们学习了使代码更简洁和易读的 JavaScript 特性。这些特性在现代 JavaScript 编程中非常常见,因此学会使用这些功能将使您受益匪浅。在下一节中,我们将学习一些非常重要的数组操作技术,这些技术可以简化处理数组,并且也非常常用。

学习新的数组函数

在本节中,我们将回顾 ES6 中用于操作数组的许多方法。这是一个非常重要的部分,因为你将经常在 JavaScript 编程中处理数组,并且使用这些性能优化的方法比创建自己的方法更可取。使用这些标准方法还可以使代码更一致和易读,其他开发人员在你的团队上也会更容易理解。我们将在 React 和 Node 开发中广泛利用这些方法。让我们开始吧。

find

find关键字允许你从数组中抓取与搜索条件匹配的第一个元素。让我们看一个简单的例子。创建find.ts并添加以下代码:

const items = [
    { name: 'jon', age: 20 },
    { name: 'linda', age: 22 },
    { name: 'jon', age: 40}
]
const jon = items.find((item) => {
    return item.name === 'jon'
});
console.log(jon);

如果你看一下find的代码,你会发现它接受一个函数作为参数,这个函数是在寻找名为jon的项目。该函数进行真值检查,以判断项目的名称是否等于jon。如果项目的真值检查为真,find将返回该项目。然而,你也可以看到数组中有两个jon项目。让我们编译并运行这段代码,看看哪一个会返回。运行以下命令:

tsc find –target 'es6'
node find

编译并运行上述命令后,你应该会看到以下结果:

Figure 3.8 – find

Figure 3.8 – find

你可以在输出中看到第一个找到的jon项目被返回。这就是find的工作方式;它总是只返回一个项目——数组中找到的第一个项目。

filter

filterfind类似,只是它返回所有匹配搜索条件的项目。让我们创建一个名为filter.ts的新文件,并添加以下代码:

const filterItems = [
    { name: 'jon', age: 20 },
    { name: 'linda', age: 22 },
    { name: 'jon', age: 40}
]
const results = filterItems.filter((item, index) => {
    return item.name === 'jon'
});
console.log(results);

正如你所看到的,filter函数也可以接受数组中项目的索引号作为可选的第二个参数。但是,内部实现上,它看起来与find的工作方式相同,都是通过真值检查来判断是否找到了某个匹配项。然而,对于filter来说,所有匹配项都会被返回,如下所示:

Figure 3.9 – filter

Figure 3.9 – filter

正如你所看到的,对于filter来说,所有满足过滤条件的项目都会被返回,这在这个示例中是两个jon项目。

map

map函数是 ES6 风格编码中需要了解的更重要的数组函数之一。它经常出现在 React 组件创建中,以便从数据数组中创建一组组件元素。请注意,map函数与Map集合不同,我们将在本章后面介绍。创建一个名为map.ts的新文件,并添加以下代码:

const employees = [
    { name: 'tim', id: 1 },
    { name: 'cindy', id: 2 },
    { name: 'rob', id: 3 },
]
const elements = employees.map((item, index) => {
    return `<div>${item.id} - ${item.name}</div>`;
});
console.log(elements);

正如你所看到的,map函数有两个参数,itemindex(你可以随意命名,但顺序很重要),它将自定义的返回值映射到每个数组元素。要清楚,return意味着将每个项目返回到一个新数组中。它并不意味着返回并停止运行迭代。如果我们运行代码,结果将是以下 DOM 字符串:

Figure 3.10 – map

Figure 3.10 – map

这个函数实际上可能是最常见的 ES6 数组函数,所以你非常重要要理解它是如何工作的。尝试修改代码并练习使用它与不同的数组项目类型。

reduce

reduce函数是一个聚合器,它接受数组中的每个元素,并根据自定义逻辑创建一个最终值。让我们看一个例子。创建一个reduce.js文件——同样,我们将使用 JavaScript 文件来减少 TypeScript 编译器的一些噪音,并专注于代码——并添加以下代码:

const allTrucks = [
    2,5,7,10
]
const initialCapacity = 0;
const allTonnage = allTrucks.reduce((totalCapacity,  currentCapacity) => {
    totalCapacity = totalCapacity + currentCapacity;

    return totalCapacity;
}, initialCapacity);
console.log(allTonnage);

在这个例子中,让我们想象一下我们需要计算一家卡车公司所有卡车的总吨位容量。然后,allTrucks列出了它所有卡车的吨位。然后,我们使用allTrucks.reduce来获得所有卡车的总容量。initialCapacity变量仅用于有一个起始点,目前设置为0。然后,当我们记录最终值时,我们会看到以下结果:

图 3.11 - reduce

图 3.11 - reduce

所有卡车的总容量是24,因为每辆卡车的容量之和为 24。请注意,reducer 的逻辑可以是任何内容;它不一定要是求和。它可以是减法或者您可能需要的任何其他逻辑。核心点在于最终,您将只有一个单一的值或对象结果。这就是为什么它被称为reduce

some 和 every

这些函数旨在测试特定的条件。因此,它们只返回truefalsesome用于检查数组中是否有任何元素满足特定条件,而every用于检查所有元素是否满足特定条件。让我们来看看两者。创建一个名为someEvery.js的文件,并添加以下代码:

const widgets = [
    { id: 1, color: 'blue' },
    { id: 2, color: 'yellow' },
    { id: 3, color: 'orange' },
    { id: 4, color: 'blue' },
]
console.log('some are blue', widgets.some(item => {
    return item.color === 'blue';
}));
console.log('every one is blue', widgets.every(item => {
    return item.color === 'blue';
}));

代码非常简单,someevery的两个条件都被测试了。如果你运行这段代码,你会看到以下结果:

图 3.12 - someEvery

图 3.12 - someEvery

如您所见,结果对每个测试都是有效的。

在本节中,我们学习了 ES6 中添加的许多新函数,这些函数可以帮助我们更有效地处理和使用 JavaScript 中的数组。在构建应用程序时,您肯定会在自己的代码中使用许多这些函数。接下来,我们将学习一些可以用来替代数组的新集合类型。

学习新的集合类型

ES6 有两种新的集合类型,SetMap,它们对于特定的场景可能会很有用。在本节中,我们将学习这两种类型以及如何为它们编写代码,以便在我们开始构建应用程序时稍后使用它们。

Set

Set是一组唯一值或对象。当您只想查看一个项目是否包含在一个大型复杂列表中时,这是一个很好的函数。让我们看一个例子。创建一个名为set.js的新文件,并添加以下代码:

const userIds = [
    1,2,1,3
]
const uniqueIds = new Set(userIds);
console.log(uniqueIds);
uniqueIds.add(10);
console.log('add 10', uniqueIds);
console.log('has', uniqueIds.has(3));
console.log('size', uniqueIds.size);
for (let item of uniqueIds) {
    console.log('iterate', item);
}

Set对象有许多成员,但这些是它最重要的一些特性。正如您所看到的,Set有一个构造函数,可以接受一个数组,使该数组成为一个唯一集合。

重要提示

关于集合,size用于检查数量而不是长度。

在底部,请注意迭代Set与正常使用数组索引的方式不同。运行此文件将产生以下结果:

图 3.13 - Set

图 3.13 - Set

从概念上讲,它仍然与数组非常相似,但是针对唯一集合进行了优化。

Map

Map是键值对的集合。换句话说,它是一个字典。Map的每个成员都有一个唯一的键。让我们创建一个示例Map对象。创建一个名为mapCollection.js的新文件,并添加以下代码:

const mappedEmp = new Map();
mappedEmp.set('linda', { fullName: 'Linda Johnson', id: 1 });
mappedEmp.set('jim', { fullName: 'Jim Thomson', id: 2 });
mappedEmp.set('pam', { fullName: 'Pam Dryer', id: 4 });
console.log(mappedEmp);
console.log('get', mappedEmp.get('jim'));
console.log('size', mappedEmp.size);
for(let [key, val] of mappedEmp) {
    console.log('iterate', key, val);
}

正如您所看到的,一些调用与Set非常相似。然而,一个不同之处在于底部的迭代循环,它使用数组来指示键和值。运行此文件将产生以下输出:

图 3.14 - mapCollection

图 3.14 - mapCollection

这很简单。首先,记录了所有Map对象的列表。然后,我们使用get通过其键值获取了jim项。接下来是size,最后是对所有元素的迭代。

本节展示了 ES6 中的两种新集合类型。这些类型并不经常使用,但如果您有这些集合所需的需求,它们可能会派上用场。在下一节中,我们将讨论async await,这是一个 ES7 功能。async await已经被 JavaScript 开发者社区广泛采用,因为它使难以阅读的异步代码变得更加可读,并使其看起来像是同步的。

学习关于async await

在解释asyncawait之前,让我们解释一下什么是异步代码。在大多数语言中,代码通常是同步的,这意味着语句一个接一个地运行。如果有语句ABC,语句B在语句A完成之前无法运行,语句C在语句B完成之前无法运行。然而,在异步编程中,如果语句A是异步的,它将开始,但紧接着,语句B将立即开始。因此,语句B在运行之前不会等待A完成。这对性能来说很好,但使代码更难阅读和修复。JavaScript 中的async await试图解决其中一些困难。

因此,异步编程提供了更快的性能,因为语句可以同时运行,而无需等待彼此。然而,为了理解异步编程,我们首先需要理解回调。回调是 Node.js 编程自诞生以来的核心特性,因此理解它是很重要的。让我们看一个回调的例子。创建一个名为callback.js的新文件,并输入以下代码:

function letMeKnowWhenComplete(size, callback) {
    var reducer = 0;
    for (var i = 1; i < size; i++) {
        reducer = Math.sin(reducer * i);
    }
    callback();
}
letMeKnowWhenComplete(100000000, function () { console.log('Great it completed.'); });

如果我们看一下这段代码,我们可以看到letMeKnowWhenComplete函数有两个参数。第一个参数表示要进行数学计算的迭代的大小,第二个参数是实际的回调。从代码中可以看出,callback是一个在数学工作完成后执行的函数,因此得名。准确地说,技术上回调实际上并不是异步的。然而,它提供了实际上相同的能力,即次要工作,即回调,在主要工作完成后立即完成,而无需等待或轮询。现在,让我们看一下 JavaScript 的第一种异步完成方法。

JavaScript 获得的第一个执行异步的能力是使用setTimeoutsetInterval函数。这些函数很简单;它们接受一个回调,一旦指定的时间完成,就会执行。在setInterval的情况下,唯一的区别是它会重复。这些函数之所以真正是异步的原因是,当计时器运行时,它在当前的setTimer.js之外运行,并输入以下代码:

// 1
console.log('Let's begin.');
// 2
setTimeout(() => {
    console.log('I waited and am done now.');
}, 3000);
// 3
console.log('Did I finish yet?');

让我们回顾一下这段代码。我已经添加了注释来分隔主要部分。首先,在注释 1 下,我们有一个日志消息,指示这段代码正在开始。然后,在注释 2 下,我们有setTimeout,它将在等待 3 秒后执行我们的箭头函数回调。当回调运行时,它将记录它已经完成。在setTimeout之后,我们看到另一个日志消息,在注释 3 下,询问计时器是否已经完成。现在,当您运行这段代码时,将会发生一件奇怪的事情,如下图所示:

图 3.15 - setTimer

图 3.15 - setTimer

最后一个日志消息询问“我完成了吗?”将首先运行,然后完成日志“我等待并且现在完成了”。为什么呢?SetTimeout是一个异步函数,所以当它执行时,它允许之后写的任何代码立即执行(即使setTimeout还没有完成)。这意味着在这种情况下,注释 3 中的日志实际上在注释 2 中的回调之前运行。因此,如果我们想象注释 3 中有一些需要立即运行的重要代码,而不需要等待注释 2,我们就可以看到使用异步调用对性能有多么有帮助。现在,让我们结合对回调和异步调用的理解,来看一下 Promise。

async await之前,异步代码是使用 Promises 来处理的。Promise是一个在未来某个不确定的时间延迟完成的对象。Promise代码的一个例子可能是这样的。创建一个名为promise.js的文件,并添加以下代码:

const myPromise = new Promise((resolve, reject) => {
    setTimeout(() => {
        //resolve('I completed successfully');
        reject('I failed');
    }, 500);
});
myPromise
.then(done => {
    console.log(done);
})
.catch(err => {
    console.log(err);
});

在这段代码中,我们首先创建一个Promise对象,并在内部使用异步计时器在 500 毫秒后执行一个语句。在第一次尝试中,我们故意通过调用reject来使计时器失败,这会导致Promise定义下面的代码进入catch处理程序。现在,如果我们注释掉reject,然后取消注释resolve,底部的代码将进入then处理程序。显然,这段代码是有效的,但是如果想象一个更复杂的Promise,有许多then语句,甚至有许多 Promise,那么阅读和理解将变得越来越复杂。

这就是async await的作用。它有两个主要作用:它清理了代码,使其更简单更小,并且使代码更易于理解,因为它看起来像同步代码。让我们看一个例子。创建一个名为async.js的新文件,并添加以下代码:

async function delayedResult() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('I completed successfully');
        }, 500);
    });
}
(async function execAsyncFunc() {
    const result = await delayedResult();
    console.log(result);
})();

这段代码有一个名为delayedResult的函数,正如您所看到的,它在前面有async前缀。在函数前面加上async告诉运行时,这个函数将返回一个Promise,因此应该异步处理。在delayedResult之后,我们看到一个名为execAsyncFunc的函数,它同时声明和执行。如果您不熟悉它,这种能力被称为execAsyncFunc函数也是async-capable,并且正如您所看到的,它内部使用了await关键字。await关键字告诉运行时,我们即将执行一个异步函数,因此它应该代表我们等待,然后,一旦语句完成,给我们实际的返回值。如果我们运行这段代码,我们会看到以下内容:

图 3.16 – 异步

图 3.16 – 异步

正如您所看到的,result变量包含I completed successfully字符串,而不是delayedResult通常返回的Promise。这种语法显然比有许多嵌套的Promise then语句更短更易读。请注意,asyncawait已经在 JavaScript 社区中取代了异步开发。您必须深刻理解它,才能成功地使用现代 JavaScript。我们将看一个更多的例子来加深理解。

重要提示

我们必须为execAsyncFunc函数使用 IIFE,因为在当前的 JavaScript 中,不允许顶层的await。顶层的await基本上意味着能够运行一个不在另一个async函数内部的函数的等待调用。在 JavaScript 的 ECMAScript 2020 版本中,这是被启用的,但在撰写本文时,它尚未完全在所有浏览器中得到支持。

因为async await非常重要,让我们再看一个例子。让我们调用一个网络资源来获取一些数据。我们将使用fetch API,但由于 Node 不支持它,我们需要先安装另一个npm包。以下是步骤:

  1. 在终端中运行以下命令以安装fetch
npm i node-fetch
  1. 创建一个名为fetch.js的文件,并输入以下代码:
const fetch = require('node-fetch');
(async function getData() {
    const response = await fetch('https://pokeapi.co/api/v2/     pokemon/ditto/');
    if(response.ok) {
        const result = await response.json();
        console.log(result);
    } else {
        console.log('Failed to get anything');
    }
})();

请注意,在这个例子中,代码的易读性和自然流程。正如您所看到的,我们正在使用fetch API,它允许我们进行异步网络调用。在导入fetch之后,我们再次创建一个async包装函数来执行对我们的fetch函数的await调用。如果您想知道,URL 是一个不需要身份验证的宠物小精灵角色的公共 API。第一次调用await是为了实际的网络调用本身。一旦该调用完成,使用response.ok进行成功检查。如果成功,再次调用await将数据转换为 JSON 格式。每次调用await都会阻塞代码,直到函数完成并返回。

我们正在等待,因为没有来自网络 API 的数据,所以我们别无选择,只能等待。如果运行此代码,您将看到以下数据:

图 3.17 – 获取

图 3.17 - 获取

当这段代码运行时,你可能会注意到代码完成之前有一小段延迟。这显示了代码需要等待数据的网络调用完成。

在本节中,我们了解了什么是异步编程。我们还讨论了 Promise,这是 JavaScript 中异步编程的基础,以及async await,它为我们提供了一种简化异步代码的方法。你将会在 React 和 Node 开发中大量看到async await的使用。

总结

在这一章中,我们看了很多 JavaScript 编程的新功能,比如用async await合并对象和数组的方法,这是一种新的非常流行的处理异步代码的方式。理解这些功能非常重要,因为它们在现代 JavaScript 和 React 开发中被广泛使用。

在接下来的部分中,我们将开始深入学习使用 React 进行单页应用程序开发。我们将开始使用本章学到的许多功能。

第二部分:使用 React 学习单页面应用开发

在本节中,我们将学习如何设置和构建 React Web 应用程序。

本节包括以下章节:

  • 第四章学习单页面应用概念以及 React 如何实现它们

  • 第五章使用 Hooks 进行 React 开发

  • 第六章使用 create-react-app 设置项目并使用 Jest 进行测试

  • 第七章学习 Redux 和 React Router

第四章:学习单页应用程序的概念以及 React 如何实现它们

在本章中,我们将学习单页应用程序SPA)。这种编程 Web 应用程序的风格在 Web 开发的历史上相对较新,但近年来已经得到了广泛的应用。它的使用现在是构建需要感觉像原生桌面或移动应用程序的大型复杂 Web 应用程序的常见做法。

我们将回顾构建 Web 应用程序的以前方法以及为什么创建了 SPA 风格的应用程序。然后,我们将学习 React 如何帮助我们以高效和有效的方式构建这种应用程序风格。

在本章中,我们将涵盖以下主要主题:

  • 了解过去网站是如何构建的

  • 理解 SPA 的好处和属性

  • 了解 React 如何帮助构建 SPA 应用程序

技术要求

本章的要求与第三章的要求相似,使用 ES6+功能构建更好的应用程序。您应该对 JavaScript 以及 HTML 和 CSS 有基本的了解。我们将再次使用 Node.js 和Visual Studio CodeVSCode)。

GitHub 存储库再次位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap4文件夹中的代码。

设置本章的代码文件夹,转到您的HandsOnTypescript文件夹并创建一个名为Chap4的新文件夹。

了解过去网站是如何构建的

在本节中,我们将通过回顾设计和编写网页的原始方法来调查 SPA 风格编程的创建原因。了解这些知识将帮助我们理解转向 SPA 的原因。

最初,当 Web 开始时,没有 JavaScript 语言。最初,这只是为了在科学家之间共享文档而创建的静态 HTML 页面。一旦这种文档格式和互联网变得更受欢迎,人们意识到这些文档需要改进的样式方法来增强沟通。因此,创建了 CSS,并且它成为了 HTML 文档的样式和布局的标准。最后,网景浏览器公司决定 Web 需要一种脚本语言来使页面内容更加动态,于是他们创建了 JavaScript。

尽管有这些功能,原始的 Web 仍然非常静态。当您在浏览器中输入 URL 时,您会收到一个文档,即服务器上的实际文件,对于您输入的每个 URL 都是如此。CSS 和 JavaScript 确实有助于使 Web 看起来更好,更具动态性,但它并没有改变 Web 的面向页面的模型。

随着网站变得越来越复杂,许多网页开发人员希望更好地控制他们的网页文档。他们希望动态控制网页的布局和内容。这导致了通用网关接口CGI)的创建。CGI 是对服务器端渲染SSR)的早期尝试。这基本上意味着浏览器的请求被 Web 服务器接收,但服务器不会返回静态 HTML 页面,而是运行一个处理器,根据参数和逻辑动态生成页面,然后发送回去。

无论网站使用静态 HTML 页面还是在服务器上使用服务器端逻辑呈现其页面,在过去,重点是向浏览器发送完整的 HTML 页面作为文件。这通常是网站的工作方式。

这种单文件或基于页面的模型与本机应用程序的工作方式完全不同,无论是在桌面还是移动设备上。本机应用程序模型不同之处在于整个应用程序被下载并安装到用户的设备上。用户打开应用程序时,它已经准备好在那一刻全部使用。需要在屏幕上绘制的任何控件都是从已经存在的代码中完成的,除了发送或获取数据的调用之外,不需要额外调用后端服务器(其他调用)。这使应用程序的响应速度和速度比旧模型中不断需要刷新页面以显示新内容的经典 Web 应用程序明显更快。

SPA 应用程序的动机是使 Web 应用程序感觉更像本机设备应用程序,以便给它们相同的速度和响应性感觉。因此,SPA 风格使用各种技术和库使 Web 应用程序的功能和感觉更像本机应用程序。

在本节中,我们回顾了早期 Web 构建网站的方式。当时,重点是生成和提供单独的 HTML 文档文件。我们看到了这种编程风格的局限性,特别是与本机应用程序相比,以及 SPA 风格应用程序是试图解决这些限制并使 Web 应用程序看起来像本机应用程序的尝试。在下一节中,您将看到 SPA 应用程序是什么,以及它们如何改进原始 Web 的页面集中模型。

理解 SPA 的好处和特性

在本节中,我们将了解 SPA 应用程序的好处和特性。通过了解这些特性,它们将帮助我们理解在创建 React 时所做的一些架构决策,以及在创建 React 应用程序时使用的一些相关库和组件。

正如前面提到的,使用 SPA 风格的应用程序构建的动机是使我们的 Web 应用程序看起来和感觉上更像本机应用程序。通过使用 SPA 应用程序方法,我们将使我们的程序响应和外观看起来像是安装在设备上的。经典风格的 Web 应用程序可能会显得迟钝,因为对页面的任何更改都需要回调服务器以获取新屏幕。然而,SPA 风格的应用程序可以立即重绘屏幕的部分,而无需等待服务器返回新文件。因此,就用户而言,SPA 应用程序就像本机设备应用程序一样。

构建 SPA 应用程序非常复杂,需要使用许多组件和库。然而,无论我们使用 Angular、Vue、React 还是其他框架,SPA 应用程序始终具有某些特性和要求。

让我们了解一些要求:

  • 顾名思义,整个应用程序只存在于一个 HTML 页面上。与使用单独页面显示不同屏幕的标准 HTML 应用程序不同,第一个页面是 SPA 应用程序上唯一加载的页面。

  • 与静态 HTML 文件不同,JavaScript 动态渲染屏幕。因此,首先下载的 HTML 页面实际上几乎完全没有内容。但它将有一个根元素,位于 body 标记内,成为整个应用程序的容器,再次随着用户与应用程序的交互而实时渲染。

  • 通常在检索主 HTML 文件时,需要运行应用程序的所有脚本和文件都会被下载。然而,这种方法正在改变,越来越多的应用程序只下载一个基本级别的脚本文件,然后根据需要按需下载其他脚本。我们将在后面讨论如何使用这些技术,因为它们可以通过减少屏幕等待时间来增强用户体验。

  • 对于单页应用程序,URL 路由的处理方式有所不同。在 SPA 应用程序中,根据您选择的框架,会使用一些机制来创建虚拟路由。虚拟路由简单地意味着,尽管对用户来说,不同的调用会导致对不同的服务器端 URL 的访问,但实际上,“路由”只是在客户端浏览器上进行,以便对不同的屏幕进行逻辑转换。换句话说,不会发出对服务器的调用,URL 路由成为将应用程序逻辑上分隔成不同屏幕的手段。例如,当用户在浏览器中输入 URL 时,他们必须按下Enter才能将提交发送回 URL 的目的地服务器。然而,在 SPA 应用程序中发生路由时,URL 中并没有实际的服务器路径。它不存在。因此,提交不会被触发。相反,应用程序使用 URL 作为应用程序各部分的容器,并在给定某些 URL 时触发某些行为。话虽如此,URL 路由仍然是一个有用的功能,因为大多数用户都希望具有路由功能,并且它允许他们将屏幕加为书签。

在本节中,我们已经了解了构成 SPA 的属性。我们涵盖了处理整个应用程序只有一个文件的不同方法以及用于构建这些应用程序的方法。在下一节中,我们将深入了解 React 如何实现 SPA 以及 React 团队为创建这种应用程序风格所做的决定。

理解 React 如何帮助构建单页应用

在这一部分,我们将以高层次了解 React。这种理解将有助于我们构建更好的基于 React 的应用程序,因为我们将了解 React 在内部是如何运作的。

如前所述,网站主要只是一个 HTML 文件,这是一个基于文本的文档。这个文件包含浏览器用来创建一个称为文档对象模型DOM)的逻辑树的代码。这个树根据它们的顺序和相对于结构中其他元素的位置来表示文件中的所有 HTML 元素。所有网站都在其页面上有一个 DOM 结构,无论它们是否使用 SPA 风格。然而,React 以独特的方式利用 DOM 来帮助构建应用程序。

React 有两个主要构造:

  • React 在运行时维护自己的虚拟 DOM。这个虚拟 DOM 与浏览器的 DOM 是不同的。它是 React 根据我们的代码指令创建和维护的 DOM 的独特副本。这个虚拟 DOM 是根据 React 服务内部执行的协调过程创建和编辑的。协调过程是一个比较过程,React 会查看浏览器 DOM 并将其与自己的虚拟 DOM 进行对比。这个协调过程通常被称为渲染阶段。当发现差异时,例如虚拟 DOM 包含一个浏览器 DOM 中没有的元素时,React 将向浏览器 DOM 发送指令,以创建该元素,以使浏览器 DOM 和虚拟 DOM 匹配。这个添加、编辑或删除元素的过程被称为提交阶段

  • React 开发的另一个主要特点是它是状态驱动的。在 React 中,一个应用程序由许多组件组成,在每个组件中可能有一些本地状态(即数据)。如果由于任何原因这些数据发生变化,React 将触发其协调过程,并在需要时更改 DOM。

为了使这些概念更具体,我们应该看一个简单的 React 应用程序的例子。但在这之前,让我们回顾一下 React 应用程序是由什么组成的。

React 应用程序的属性

在其核心,现代 React 应用程序需要一些基本功能才能运行。我们需要npm来帮助我们管理应用程序的依赖关系。正如您从我们之前的练习中看到的,npm是一个允许我们从中央存储库下载开源依赖项并在我们的应用程序中使用它们的存储库。我们还需要一个称为捆绑的工具。捆绑系统是一种服务,它聚合我们所有的脚本文件和资产,例如 CSS 文件,并将它们最小化为一组文件。最小化过程会从我们的脚本中删除空格和其他不需要的文本,以便最终下载到用户浏览器上的文件尽可能小。这种较小的有效载荷大小可以提高应用程序的启动时间并改善用户体验。我们将使用的捆绑系统称为 webpack,我们选择它是因为它是捆绑 React 应用程序的行业标准。此外,我们可以使用npm的内置脚本系统并创建脚本来自动化我们的一些工作。例如,我们可以创建脚本来启动我们的测试服务器,运行我们的测试,并构建应用程序的最终生产版本。

如果我们使用create-react-app npm包,我们可以获得所有先前提到的依赖项,以及进行 React 开发的常见依赖项和一些内置脚本来管理我们的应用程序。让我们使用这个包并创建我们的第一个应用程序:

  1. 在您的终端或命令行中,转到HandsOnTypescript/Chap4文件夹并运行以下命令:
npx, instead of npm i -g, so that you don't have to install create-react-app locally.
  1. 一旦这个命令完成,打开 VSCode 并打开新创建的try-react文件夹,这是我们在本章开始时创建的。

  2. 在 VSCode 中打开终端并运行以下命令:

build. After the build completes, you should see the following structure from VSCode:

图 4.1 - try-react

图 4.1 - try-react

让我们从顶部开始看看create-react-app给我们提供了什么:

  • build文件夹是所有捆绑和最小化的最终生产文件的目的地。它们已经被缩小到尽可能小,并且调试信息也已被删除以提高性能。

  • 接下来,我们有node_modules文件夹,其中包含我们从npm存储库下载的所有依赖项。

  • 然后,我们有public文件夹,这是一个用于静态资产的文件夹,例如index.html文件,它将用于构建我们的最终应用程序。

  • 接下来,也许最重要的文件夹是src。正如缩写的名称所示,这是包含所有源脚本的文件夹。任何扩展名为.tsx的文件都表示一个 React 组件。.ts文件只是普通的 TypeScript 文件。最后,.css文件包含我们的样式属性(可能不止一个)。d.ts文件包含 TypeScript 类型信息,编译器用它来确定需要进行的静态类型检查。

  • 接下来是.gitignore文件。这个文件用于 GitHub 代码存储库,我们正在用它来保存本书的源代码。正如其名称所示,通过这个文件,我们告诉我们的git系统不要上传某些文件和文件夹,而是忽略它们。

  • package.jsonpackage-lock.json文件用于配置和设置我们的依赖关系。此外,它们还可以存储我们构建、测试和运行脚本的配置,以及 Jest 测试框架的配置。

  • 最后,我们有我们的tsconfig.json文件,我们在第二章中讨论过,探索 TypeScript。它将配置 TypeScript 编译器。请注意,默认情况下,严格模式已打开,因此我们不能使用隐式的anyundefined

现在我们已经快速盘点了我们的项目,让我们来看看一些文件的内容。首先,我们将从package.json文件开始。package.json文件有许多部分,但让我们看一些最重要的部分:

  • dependencies部分包含我们的应用程序将用于某些功能的库。这些依赖包括 React,以及用于测试的 TypeScript 和 Jest 库。@types依赖项包含 TypeScript 定义文件。TypeScript 定义文件存储了 JavaScript 编写的框架的静态类型信息。换句话说,这个文件告诉 TypeScript 编译器框架使用的类型的形状,以便进行类型声明和检查。

  • 还有另一个依赖项部分,称为devDependencies——虽然这里没有使用——通常存储开发时依赖项(与dependencies部分相对,后者通常只存储运行时依赖项)。出于某种原因,React 团队决定将两者合并为dependencies。话虽如此,你应该意识到这一点,因为你会在许多项目中看到这个部分。

  • 脚本部分用于存储管理应用程序的脚本。例如,start脚本通过调用npm run startnpm start来使用。此脚本用于使用开发服务器启动我们的应用程序。我们还可以添加自己的脚本,稍后将会这样做,用于将生产文件部署到服务器等操作。

请注意,由create-react-app创建的项目已经被 React 团队进行了大量修改。它们已经被团队优化,并且隐藏了不容易看到的脚本和配置,例如基本的 webpack 配置和脚本。如果你感兴趣,你可以运行npm run eject来查看所有这些配置和脚本。然而,请注意这是不可逆转的。因此,你将无法撤消它。我们不会使用已弹出的项目,因为这样做没有太多好处。

现在,让我们看一些脚本。从src文件夹中打开index.tsx文件,你会看到以下内容:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
// If you want your app to work offline and load faster, you 
   // can change
// unregister() to register() below. Note this comes with some 
 // pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Service workers

Service workers 是 JavaScript 中进行简单线程处理的一种方式。我们不会使用这个功能,但它作为create-react-app项目的一部分存在,所以我留下它是为了完整性。

再次强调,任何包含返回 JSX 的 React 组件的文件都将具有.tsx文件扩展名。我们从这个文件开始,因为这是 React 应用程序的入口点。这是 React 开始其运行时构建过程的地方。现在,如果我们从顶部开始,我们可以看到正在使用 ES6 语法导入依赖项。导入了 React 和相关模块,包括核心的App模块,我们很快会探索。在导入之后,我们可以看到调用了ReactDOM.render,它最终“写出”了所有组件组合的 HTML。它接受两个参数。一个是从哪个最低级的 React 组件开始渲染,另一个是用于包含渲染内容的 HTML 元素。正如你所看到的,App组件被包裹在一个名为React.StrictMode的组件中。这个组件只是开发的辅助。在生产模式下编译时,它没有影响,也不会影响性能。然而,在开发模式下,它提供了关于代码潜在问题的额外信息。这可能会随时间而改变,但这里是它目前提供的帮助列表:

  • 识别具有不安全生命周期的组件:它将向您显示是否正在使用不安全的生命周期调用,例如componentWillMountcomponentWillReceivePropscomponentWillUpdate。在使用 Hooks 编码时,这些问题不适用,但了解传统基于类的组件对它们很有好处。

  • 关于传统字符串引用 API 的警告:创建对 HTML 元素的引用的旧方法,而不是 React 组件,是使用字符串,例如<div ref="myDiv">{content}</div>。因为这种方法使用字符串,它存在问题,现在更倾向于使用React.createRef。我们将在后面的章节讨论为什么可能使用引用。

  • 关于废弃的findDOMNode用法的警告:findDOMNode现在已经被废弃,因为它违反了抽象原则。具体来说,它允许父组件在组件树中为特定子组件编写代码。这种与代码实现的关联意味着以后更改代码变得困难,因为父组件现在依赖于其组件树中存在的某些内容。我们在第二章中讨论了面向对象编程原则,包括抽象。

  • 检测意外副作用:副作用是我们代码的意外后果。例如,如果我的类组件在构造函数中从其他函数或属性初始化其状态,那么如果该状态有时接收不同的值进行初始化,这是不可接受的。为了帮助捕捉这类问题,React.StrictMode将运行某些生命周期调用,例如构造函数或getDerivedStateFromProps,两次尝试并显示是否发生了这种情况。请注意,这仅在开发过程中发生。

  • 检测旧版上下文 API:上下文 API 是 React 的一个功能,它提供了应用程序所有组件的全局状态。有一个更新版本的 API,旧版本现在已经不推荐使用。这检查您是否在使用旧版本。

大部分检查都围绕旧的基于类的组件样式进行。然而,由于您可能需要维护的现有代码绝大部分仍然是用旧样式和类编写的,因此了解这一点仍然很重要。

接下来,让我们看一下App.tsx文件:

import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}
export default App;

重要提示

请注意,这里显示的 JSX 语法实际上不是 HTML。它是自定义的 JavaScript。因此,每当可能与 JavaScript 关键字发生冲突时,React 都会使用另一个名称。例如,class是 JavaScript 中的保留关键字。因此,React 使用className来表示 CSS 类。

尽管index.tsx文件是 React 的主要起点,但我们将为应用程序构建的实际组件始于App.tsx文件。因此,这对我们来说是非常重要的文件。

让我们讨论一下这段代码中的一些项目:

  • 首先,我们从 React 的npm依赖中导入 React。如果你查看npm_modules文件夹,你会发现一个名为react的子文件夹,这个文件夹就是这个import语句所指的。我们自己没有创建的任何代码导入都将在node_modules文件夹中。

  • 接下来是logo的导入。图像资源被导入到一个 JavaScript 变量中,这种情况下是logo变量。另外,正如你所看到的,由于这不是一个npm模块,它需要一个点引用。npm模块不需要相对路径,因为系统知道从哪个文件夹开始查找,npm_modules

  • 接下来,我们导入App.css。这个文件是样式文件,因此没有与之关联的 JavaScript 变量。由于它不是一个npm包,所以它还需要一个相对路径。

  • App组件是一个函数组件,如其语法所示。App组件是整个应用程序的根父组件。该组件本身没有状态,只是渲染内容。因此,return语句是渲染的内容,它使用JSX

  • 我们将在后面的章节中详细讨论 JSX 是什么;但是,现在,JSX 是用 JavaScript 编写的类似 HTML 的语法。它是由 React 团队创建的,旨在使使用 React 组件创建 HTML 内容更容易和更清晰。需要注意的主要事项是,尽管它看起来几乎与 HTML 相同,但它实际上并不是 HTML,因此在工作方式上存在一些差异。

  • 对 CSS 类的样式引用,通常设置为class,现在设置为className,如代码所示。这是因为class是 JavaScript 关键字,因此不能在这里使用。

  • 花括号表示正在传递代码,而不是字符串。例如,img标签的src属性接受 JavaScript 变量logo作为其值,并且该值也在花括号内。要传递字符串,请使用引号。

让我们以开发模式启动我们的应用程序,看看这个基本屏幕是什么样子。运行以下命令:

npm start

运行前面的命令后,你应该在浏览器中看到以下屏幕:

图 4.2 – 应用程序启动

图 4.2 – 应用程序启动

如你所见,来自我们的App.tsx文件的文本和标志正在显示,因为这是我们应用程序的主要起始组件。一旦我们开始编码,我们将让这个服务器保持运行状态,当我们保存任何脚本文件时,页面将自动更新,让我们实时看到我们的更改。

为了更好地了解在 React 中构建组件以及 React 路由是如何工作的,让我们创建我们的第一个简单组件:

  1. src文件夹中创建一个名为Home.tsx的新文件,并添加以下代码:
import React, { FC } from "react";
const Home: FC = () => {
  return <div>Hello World! Home</div>;
};
export default Home;
  1. 现在,如你所见,我们正在创建一个名为Home的组件,它返回一个带有Hello World!字样的div标签。你还应该注意到,我们使用了FC,函数组件,声明来为我们的组件进行类型定义。在使用 React Hooks 时,函数组件是创建组件的唯一方式,而不是旧的类样式。这是因为 React 团队认为组合作为代码重用的手段比继承更有效。但请注意,无论采用何种方法,代码重用的重要性仍然存在。

  2. 现在,为了让我们的组件显示在屏幕上,我们需要将它添加到我们的App.tsx文件中。但让我们也为我们的应用程序添加路由并探索一下。首先,像这样更新index.tsx文件:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { BrowserRouter } from "react-router-dom";
ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
    <App />
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root')
);
// If you want your app to work offline and load faster, 
  // you can change
// unregister() to register() below. Note this comes with
  // some pitfalls.
// Learn more about service workers: 
   // https://bit.ly/CRA-PWA
serviceWorker.unregister();

index.tsx文件现在有一个名为BrowserRouter的组件。这个组件是 React Router 的一部分,是一个基础组件,允许整个应用程序进行路由。由于它包裹了我们的App组件,而应用程序的其余部分都存在于这个App组件内部,这意味着整个应用程序都提供了路由服务。

  1. 由于我们将使用 React Router,让我们也为第二个路由创建一个名为AnotherScreen的组件:
import React, { FC } from "react";
const AnotherScreen: FC = () => {
  return <div>Hello World! Another Screen</div>;
};
export default AnotherScreen;
  1. 现在,像这样更新App.tsx文件:
import React from "react";
import "./App.css";
import Home from "./Home";
import AnotherScreen from './AnotherScreen';
import { Switch, Route } from "react-router";
function App() {
  return (
    <div className="App">
      <header className="App-header">
        Switch. This component acts a lot like a switch statement. It tells React Router which component to display when a certain route, URL path, is given. Inside of the Switch component, we can see two Route components. The first one is for the default root route, as indicated by path being equal to "/". For this route, React Router will display the Home component (note that using exact just means the URL should be an exact match). The second route is for the "/another" path. So, when this path is in the URL box, the AnotherScreen component will be loaded. 
  1. 如果你让npm start保持运行状态,你应该会看到Hello World! Home,如下所示:图 4.3 – 主页

图 4.3 – 主页

  1. 如果你看一下 URL,你会发现它在站点的根目录上。让我们尝试将 URL 切换到http://localhost:3000/another

图 4.4 – 另一个屏幕

图 4.4 – 另一个屏幕

如你所见,它加载了AnotherScreen组件,根据我们的指示加载了该组件用于特定 URL。

此外,如果你打开 Chrome 浏览器的调试器,你会发现实际上没有网络调用到该特定路径。再次确认了 React Router 对这些路径没有进行任何后台处理,它们只存在于浏览器本地:

图 4.5 – Chrome 调试器

图 4.5 – Chrome 调试器

这只是一个快速的例子,用于构建 React 应用程序和组件,让我们开始。

在本节中,我们了解了 React 的内部工作原理以及如何设置 React 项目。随着我们开始构建我们的应用程序,这些知识将在接下来的章节中变得有价值。

摘要

在本章中,我们了解了早期网站是如何构建的。我们还了解了旧式网页开发的一些局限性,以及 SPA 应用程序是如何试图克服它们的。我们看到了 SPA 应用程序的主要驱动力是使 Web 应用程序更像本机应用程序。最后,我们对 React 开发和构建组件有了一个简介。

在下一章中,我们将在这些知识的基础上深入探讨 React 组件的构建。我们将研究基于类的组件,并将它们与更新的 Hook-style 组件进行比较和对比。到目前为止,我们所学到的关于 Web 开发和基于 React 的 Web 开发的知识将帮助我们更好地理解下一章。

第五章:使用 Hooks 进行 React 开发

在本章中,我们将学习使用 React Hooks 进行开发。我们将比较和对比使用旧的基于类的样式和使用 Hooks 进行开发的方式,看看为什么使用 Hooks 进行开发是 React 中更好的开发方式。我们还将学习在使用 Hooks 编码时的最佳实践,以便我们可以拥有最高质量的代码。

在本章中,我们将涵盖以下主要主题:

  • 了解类式组件的限制

  • 学习 React Hooks 并了解其好处

  • 比较和对比类式和 Hooks 式样

技术要求

您应该对 Web 开发和 SPA 编码风格有基本的了解。我们将再次使用 Node 和 Visual Studio Code。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap5文件夹中的代码。

设置第五章的代码文件夹,转到您的HandsOnTypescript文件夹并创建一个名为Chap5的新文件夹。

了解旧类式组件的限制和问题

在本节中,我们将回顾什么是类式组件。我们将看到为什么继承式代码重用和生命周期方法,尽管初衷良好,最终并没有提供良好的代码重用和组件结构能力。尽管我们不会用类组件编写代码,但了解基于类的组件非常重要,因为大多数现有的 React 代码使用类,因为 Hooks 仍然有些新。因此,作为专业开发人员,您将不得不阅读和维护这些代码库,直到它使用 Hooks 为止。

为了了解类式组件的限制,我们首先需要回顾一下它们是什么。一个 React 应用程序由许多称为组件的个体结构组成。在使用基于类的样式时,这些组件是继承自React.Component的 JavaScript ES6 类。组件基本上是一个可能包含数据(称为状态)的机器,并且根据这些数据的更改通过一种称为 JSX 的语言发出 HTML。尽管组件可能变得非常复杂,但在基本层面上,这就是它们。

类组件通常有自己的状态,尽管这不是必需的。此外,基于类的组件可以有子组件。子组件只是其他 React 组件,已嵌入到父组件的渲染函数中,因此在渲染父组件时也会被渲染出来。

类组件必须继承自React.Component对象。通过这样做,它将获得作为 React 组件的所有功能,包括生命周期函数。这些函数是 React 提供的事件处理程序,允许开发人员在 React 组件的生命周期中特定时间发生的事件中进行挂钩。换句话说,这些函数允许我们作为开发人员在所需的时间注入我们自己的代码和逻辑到 React 组件中。

状态

我们在第四章**中提到了状态,学习单页应用程序概念以及 React 如何实现它们。在我们学习更多关于 React 组件之前,让我们深入了解一下。React 使用 JSX 将 HTML 呈现到浏览器。然而,触发这些呈现的是组件状态,或者更准确地说,是对组件状态的任何更改。那么,什么是组件状态?在 React 类组件中,有一个名为state的字段。这个字段是一个对象,可以包含描述相关组件的任意数量的属性。函数不应用于状态,但您可以将任意数量的函数作为类组件的成员。

正如前面提到的,改变状态会导致 React 系统重新渲染您的组件。状态变化驱动了 React 中的渲染,组件只包含自己的 UI 元素,这是保持关注点分离和清晰编码实践的好方法。基于类的组件中的状态变化是由setState函数触发的。这个函数接受一个参数,即您的新状态,React 稍后会异步更新您的状态。这意味着实际的状态更改不会立即发生,而是由 React 系统控制。

除了状态之外,还可以使用 props 共享组件的状态。Props 是已传递给组件的子组件的状态属性。就像当状态改变时,如果 props 改变,子组件也会触发重新渲染。父组件的重新渲染也会触发子组件的重新渲染。请注意,重新渲染并不意味着整个 UI 都会更新。协调过程仍将运行,并且将根据状态的变化和屏幕上已有的内容来确定需要更改什么。

生命周期方法

下面的图片很好地概述了基于类的 React 组件中的生命周期调用。正如您所看到的,它非常复杂。此外,图表中还没有提到几个已弃用的函数,比如componentWillReceiveProps,它们已经完全被淘汰,因为它们会导致不必要的渲染和无限循环:

图 5.1 – React 类组件生命周期

图 5.1 – React 类组件生命周期

图片来源:projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

让我们从高层次开始审查这个图表。首先,您可以看到我们有装载更新卸载。装载只是组件的实例化和初始化,然后将初始化的组件添加到虚拟 React DOM 中。我们在第四章**,学习单页应用程序概念以及 React 如何实现它们中讨论了 React 使用的虚拟 DOM 来在自身和真实浏览器 DOM 之间协调组件。更新指的是重新渲染。当状态改变时,UI 必须更新。卸载是指组件不再使用并且将从 DOM 中移除。

现在我们将介绍生命周期方法。由于有很多方法,让我们列出它们。

装载

在装载下,我们有以下方法:

  • 构造函数:这不是一个生命周期方法,而是内置的类构造函数。传统上用于初始化状态和绑定任何自定义事件函数。您可能还记得第三章**,使用 ES6+功能构建更好的应用程序中提到,bind用于切换函数的this对象。这是在构造函数中完成的。

  • getDerivedStateFromProps(props, state): 如果您的本地状态基于父级的 props,您将使用此函数。这是一个静态函数。应该谨慎使用,因为它会触发额外的渲染。它也可以在更新中使用。

  • render:这也可以在更新时运行进行重新渲染。这个函数触发了 React 的协调过程。它应该只渲染出 JSX,也可以在数组或纯文本中。如果由于状态或 props 决定没有东西需要渲染,应该返回null。可能返回布尔值,但除了测试之外,我认为这样做没有太大的价值。

  • componentDidMount:这个函数在组件完成挂载(初始化)后触发。你可以在这里放置网络 API 调用。你也可以在这里添加事件处理程序订阅,但你必须记得在componentWillUnmount函数中取消订阅,否则会导致内存泄漏。你可以在这里调用setState来改变本地状态数据,但这样会触发第二次渲染,所以应该谨慎使用。SetState用于更新本地状态。

  • UNSAFE已弃用的方法(不要使用)是UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate

更新

让我们来看看更新下的方法:

  • shouldComponentUpdate(nextProps, nextState):用于决定是否应该进行重新渲染。它通常会比较先前的 props 和当前的 props。

  • getSnapshotBeforeUpdate(prevProps, prevState):这个函数在 DOM 渲染之前立即运行,这样你就可以在 React 改变它之前捕获 DOM 状态。如果你从这个函数返回了一些东西,它会作为参数传递给componentDidUpdate函数。

  • componentDidUpdate(prevProps, prevState, snapshot):这个函数在重新渲染完成后立即运行。你可以在这里对完成的 DOM 进行更改,或者你可以调用setState,但你必须有一个条件,以免引起无限循环错误。快照状态来自getSnapshotBeforeUpdate函数。

卸载

以下方法在这个级别上使用:

  • componentWillUnmount:这类似于 C#等语言中的dispose函数,可以用于清理工作,例如,移除事件监听器或其他订阅。

处理任何生命周期方法时的主要关注点是防止不必要或不想要的重新渲染。我们必须选择那种不太可能触发不必要重新渲染的方法,或者如果我们需要在特定时间运行代码,我们应该添加 prop 和 state 检查以减少不必要的重新渲染。重要的是要控制渲染,否则用户体验会因为慢和有 bug 的应用而受到影响。

让我们来看一些主要的调用。让我们从getDerivedStateFromProps开始。一般来说,最好避免使用这个函数,或者只是少量使用。根据经验,这使得很难弄清楚组件何时会重新渲染。一般来说,它往往会触发不必要的重新渲染,这可能会导致意外行为,而这又很难追踪。

React 团队推荐了一些替代方法,我们应该始终优先考虑这些方法,因为它们几乎总是更容易理解和行为更一致:

  • 当需要根据改变的 prop 值触发行为时。例如,获取网络数据或触发其他操作。使用componentDidUpdate。只要在引起任何改变状态之前进行检查,就不太可能触发无限循环。例如,你可以使用prevProps参数并将其与你的本地状态值进行比较,然后调用setState来改变你的状态数据。

  • 使用memoization技术(请注意,这个想法不一定是 React 的一部分;它只是一种编程技术)。Memoization基本上就像缓存,只是不是通过缓存过期来更新缓存,而是通过变量改变来更新缓存。因此,在 React 中,这只是意味着使用一个属性或函数,首先检查 props 值是否与上次不同,只有在不同的情况下才触发状态更新。

React 中有一个内置的组件包装器叫做React.memo。它只会在子组件的 props 改变时触发重新渲染,而不会在父组件重新渲染时触发重新渲染。

  • 使您的组件完全受控,这意味着它不会有自己的状态,并且在父组件的指导下渲染,每当 props 改变或父组件渲染时。Facebook 还建议使用未受控组件,方法是通过更改它们的 key(key 是组件的唯一标识符),然后触发重新渲染。然而,我不同意这个建议。正如您所记得的,我们在[第一章](B15508_01_Final_JC_ePub.xhtml#_idTextAnchor017),理解 TypeScript中讨论了封装和抽象,这意味着未受控组件的行为对父组件来说应该是未知的。这也意味着它不完全受父组件控制,也不应该受到控制。因此,让未受控组件执行父组件想要的操作可能会诱使在组件内部添加实现更改,这将使其与父组件更紧密地联系在一起。有时这是不可避免的,但如果可以避免,就应该避免。

  • 如果您的组件的渲染状态取决于网络数据,您可以使用componentDidMount在那里进行网络调用,然后更新状态(假设您只需要在加载时获取此数据)。请注意,componentDidMount仅在组件首次加载时运行一次。此外,如果您使用此函数,将会进行一次额外的渲染,但这仍然比可能导致额外不必要的渲染要好。

  • ComponentDidUpdate可用于处理由于 prop 更改而需要更改状态的情况。由于此方法在渲染后调用,因此触发任何状态更改之前将 props 与状态进行比较,不太可能导致无限渲染循环。话虽如此,最好尽量避免派生状态,并将状态保留在单个父根组件中,并通过 props 共享该状态。老实说,这是繁琐的工作,因为您需要通过 props 将状态传递给可能有几层深的子组件。这也意味着您需要很好地构建状态模式,以便可以清晰地分离为特定子组件绑定的状态。稍后当我们使用 Hooks 时,您将看到使用 Hooks 比使用单个状态对象更容易。然而,尽可能减少本地组件状态是 React 开发的最佳实践。

让我们创建一个小项目,尝试使用类组件并讨论其特性:

  1. 将您的命令行或终端切换到Chap5文件夹。

  2. 在该文件夹中运行以下命令:

npx create-react-app class-components -–template typescript
  1. 现在在您刚创建的class-components文件夹中打开 Visual Studio,并在同一文件夹中打开终端或命令行。让我们在src文件夹中创建一个名为Greeting.tsx的新文件。它应该是这样的:
import React from "react";
interface GreetingProps {
    tsx. When using TypeScript and creating a React component you must use tsx as your file's extension. Next, when we look at the code we see the import of React, which provides not only the Component to inherit from but also access to JSX syntax. Next, we see two new interfaces: GreetingProps and GreetingState. Again, because we are using TypeScript and want type safety we are creating the expected types for both any props that come into our component and the state that is being used inside of our component. Also take note that the name field in the GreetingProps interface is optional, which means it can also be set to undefined, as we'll use it later. Again, avoid having local state in your non-parent non-root components when possible. I am doing this for example purposes here.
  1. 当我们创建类时,还需要记得导出它,以便任何将使用它的组件都可以访问它。这是通过React.Component<GreetingProps>完成的。这种类型声明不仅表示这个类是一个 React 组件,还表示它接受GreetingProps类型的 prop。声明设置后,我们定义构造函数,它接受相同类型的 prop,GreetingProps

重要提示

如果您的组件接受 props,重要的是在构造函数内部进行的第一个调用是对基类构造函数super(props)的调用。这确保了 React 知道您传入的 props,因此可以在 props 改变时做出反应(无意冒犯)。在构造函数内部,我们不需要使用this.props来引用props对象,因为它作为构造函数参数传入。在其他任何地方,都需要使用this.props

  1. 接下来,我们看到stateconstructor中被实例化,变量及其类型在下一行被声明为GreetingState类型。最后,我们有我们的render函数,它声明了最终将被转换为 HTML 的 JSX。请注意,render函数具有逻辑if/else语句,根据this.props.name的值显示不同的 UI。render函数应该尽量控制正确的 UI,在没有理由渲染任何内容时不要渲染任何内容。这样做可以在一致性的情况下提高性能和内存。如果没有要render的内容,只需返回null,因为 React 理解这个值表示不要渲染任何内容。

  2. 现在我们只需要更新App.tsx文件,以便包含我们的Greeting.tsx组件。打开App.tsx文件并像这样更新它:

import React from 'react';
import logo from './logo.svg';
import './App.css';
Greeting class. Since our Greeting class is the default export of the Greeting.tsx module file (we don't need to indicate the extension) we need not use {} in between import and from. If the Greeting class was not the default export, for example, if we had many exports in the same module file, then we would need to use this syntax: import { Greeting } from "./Greeting".
  1. 正如您所看到的,我们使用Greeting组件替换了部分已经存在的 JSX。请注意,我们没有将name属性传递给Greeting。让我们看看当我们运行应用程序时会发生什么。在终端中执行此命令,确保您在class-components文件夹中:
name property to our Greeting component. As we saw, it was possible to leave this property empty because of the ? next to the field's type definition. 
  1. 现在让我们去我们的App.tsx文件,并更新Greeting以添加一个name值。用以下内容替换App.tsx中的Greeting组件:
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Greeting from "./Greeting";
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo"          />
        name with a value of my own name. Feel free to enter your name instead and then save the file. Since React includes an auto-updating test server, the browser page should update with your new code automatically. You should see your name like this on the screen:

图 5.3 - 更新屏幕

图 5.3 - 更新屏幕

好的,我们已经创建了一个简单的基于类的组件。现在让我们开始使用一些生命周期方法,并看看它们是如何工作的:

  1. 更新Greeting.tsx以包括getDerivedStateFromProps函数:
import React from "react";
interface GreetingProps {
    name?: string
}
interface GreetingState {
    message: string
}
export default class Greeting extends 
 React.Component<GreetingProps> {
    constructor(props: GreetingProps){
        super(props);
        this.state = {
            message: `Hello from, ${props.name}`
        }
    }
    state: GreetingState;
  1. 代码几乎相同,除了我们现在将getDerivedStateFromProps函数添加到render函数的上面:
    render function we are console logging the fact that the render function was called. 
  1. 现在让我们暂时保留这段代码,并更新我们的App.tsx文件,以便它可以接受一个输入,该输入获取当前用户的名字:
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Greeting from "./Greeting";
class App extends React.Component {
  constructor(props:any) {
    super(props);
    state object with a field called enteredName. We also create a new function called onChangeName and bind it to the current this class instance, like we learned in *Chapter 3**, Building Better Apps with ES6+ Features*.
  1. onChangeName中,我们将state属性enteredName设置为用户输入的值,使用setState函数。在类组件中,您绝对不能在不使用这个函数的情况下修改状态,否则您的状态将与 React 运行时失去同步:
  render() {
      console.log("rendering App");
      return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo"          />
          <input value={this.state.enteredName} 
            onChange={this.onChangeName} />
          <Greeting name={this.state.enteredName} />
        </header>
      </div>
    )
  }
}
export default App;
  1. 接下来,我们添加了一个console.log语句,以查看App.tsxrender函数何时被调用。此外,我们定义了一个新的input控件,其值为this.state.enteredName,其onChange事件与我们的onChangeName函数相关联。如果您保存此代码并打开 Chrome 开发工具,您将会看到这个:图 5.4 - 渲染问候

图 5.4 - 渲染问候

您可以看到我们的render日志消息,以及Greetingname属性和message状态值。另外,由于我们没有在input中输入值,name属性为空,因此我们的Greeting组件的name属性和message字符串的末尾也为空。您可能想知道为什么Greeting的日志运行两次。这是因为我们正在开发目的下运行在 StrictMode 中。

  1. 让我们快速删除它,以免混淆。转到您的index.tsx文件,并用以下代码替换:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
  StrictMode with Fragment. We don't actually need Fragment as it's only used to wrap a set of JSX elements that don't have a parent wrapping element such as div, but it's fine for our testing, and I want to leave a placeholder to put back the StrictMode tags. 
  1. 如果您保存并查看浏览器调试控制台,您将会看到这个:图 5.5 - 浏览器调试控制台

图 5.5 - 浏览器调试控制台

所有这些工作的原因是为了显示特定可以触发渲染调用的内容,以及我们如何更加小心谨慎地处理这些内容。

  1. 现在让我们在输入框中输入我们的名字,您会看到这个:图 5.6 - App.tsx 输入

图 5.6 - App.tsx 输入

  1. 问题是,为什么我的消息以"Hello from, "结尾?如果您查看Greeting中的代码,您会发现我们只在构造函数运行期间设置了message状态属性一次(这实际上就像使用componentDidMount)。因此,由于此事件仅在屏幕首次加载时运行一次,那时this.props.name为空,因为我们还没有输入值。那么,我们能做些什么呢?好吧,让我们尝试使用getDerivedStateFromProps函数,看看会发生什么:
export default class Greeting extends React. Component<GreetingProps> {
    constructor(props: GreetingProps){
        super(props);
        this.state = {
            message: Greeting.getNewMessage(props.name)
        }
    }
    state: GreetingState;
  1. 我只展示Greeting类,因为这是我想要为这个示例做出改变的唯一内容。因此,在下面的代码中,看一下更新的getDerivedStateFromProps
    static getDerivedStateFromProps(props: GreetingProps, 
      state:GreetingState) {
        console.log(props, state);
        if(props.name && props.name !== state.message) {
            const newState = {...state};
            newState.message =
              Greeting.getNewMessage(props.name);
            return newState;
        }
        return state;
    }
    static getNewMessage(name: string = "") {
        return `Hello from, ${name}`;
    }
    render() {
        console.log("rendering Greeting")
        if(!this.props.name) {
            return <div>no name given</div>;
        }
        return <div>
            {this.state.message}
        </div>;
    }
}

正如您所看到的,这个函数现在变得更加复杂,我正在对新的属性和我们现有的状态进行比较。然后我们克隆我们的state对象。非常重要的是要确保您这样做,以免意外直接编辑您的状态。然后我们使用一个新的静态函数getNewMessage来更新state.message的值(因为我在多个地方设置了消息)。现在让我们尝试添加我们的名字。如果您这样做,您会发现我们的名字被添加到消息中,但是每输入一个字母,我们都会得到一个GreetingApp的渲染。现在这还不算太糟糕,因为我们的代码还不多,但是您可以想象,如果我们在Greeting组件的本地状态上不断添加新属性,并且我们有一个更复杂的应用程序,事情可能会变得非常困难。

让我们重构一下这段代码,看看我们是否能稍微改进一下:

  1. 更新App.tsx
class App extends React.Component {
  constructor(props:any) {
    super(props);
    this.state = {
      enteredName: "",
      App class since that's all we're changing. As you can see, we add a new property to our state object called message (we'll be removing message from Greeting shortly) and we update it whenever the user enters a new username into the input element:

render() {

console.log("rendering App");

return (

logo

<input value=

onChange= />

<Greeting message state property to our Greeting component as a prop.


  1. 现在我们将看一下我们的Greeting组件,但为了保持清晰,让我们创建一个名为GreetingFunctional.tsx的新文件,并将以下代码放入其中:
import React from "react";
interface GreetingProps {
    message: string
}
export default function Greeting(props: GreetingProps) {
    console.log("rendering Greeting")
    return (<div>
            {props.message}
        </div>);    
}
  1. 一旦您添加了这个文件,您还需要更新您的App.tsx文件中对Greeting的导入,以便像这样引用这个文件:
import Greeting from "./GreetingFunctional";

正如您所看到的,Greeting已经大大缩短并变得更简单。它现在是一个功能组件,因为最佳实践是将没有本地状态的组件制作成函数而不是类。我们无法减少重新渲染,因为更改消息必然会触发重新渲染,但即使这种缩短和减少代码也值得这种改变。此外,即使我们将一些代码移到App.tsx中,您会注意到这段代码也比我们原来的Greeting组件中的代码少得多。

这种组件构建风格存在一个问题,即大部分状态都在一个单独的父组件中,子组件通过传递 props 来获取状态,对于复杂的多级组件层次结构,可能需要大量的样板代码来将 props 传递给多个级别的组件。对于这些情况,我们可以使用 React Context 来绕过层次结构,直接将父状态发送给子组件。但是,我不喜欢使用 Context,因为绕过自然的组件层次结构,任意向某个组件注入状态,感觉像是一种反模式(一种不应该使用的设计方法)。这很可能会引起混乱,并使以后重构代码变得更加困难。我稍后会更详细地介绍 Context,见第七章**,学习 Redux 和 React Router

在本节中,我们了解了基于类的 React 组件。由于 Hooks 仍然相对较新,大多数现有的 React 应用程序仍在使用基于类的组件,因此了解这种编码风格仍然很重要。在下一节中,我们将探索基于 Hook 的组件,然后稍后比较这两种风格。

学习 React Hooks 并了解它是如何改进类式组件的。

在本节中,我们将学习 React Hooks。我们将看一个示例项目并了解它是如何工作的。由于本书主要是关于 Hooks,至少就 React 而言,它将帮助我们以后编写我们的代码。

让我们讨论一些使用 Hooks 的原因。我们在类组件部分看到,类有生命周期方法,允许您处理组件存活时发生的某些事件。使用 React Hooks,我们没有这些生命周期方法,因为使用 Hooks 时所有组件都是功能组件。在上一节的类组件示例应用程序中创建了一个功能组件GreetingFunctional。功能组件是一个 JavaScript 函数并返回 JSX 的组件。这种变化的原因是整个设计试图摆脱面向对象编程OOP)继承模型,而是使用组合作为其主要代码重用模型。我们在第二章**,探索 TypeScript中介绍了 OOP 继承模型,但组合意味着我们不是从某个父类继承功能,而是简单地组合功能组件,有点像乐高积木,来设计我们的屏幕。

除了这些功能组件,我们还有 Hooks。Hooks 只是提供某些功能给组件的 JavaScript 函数。这些功能包括状态的创建、访问网络数据,以及组件需要的任何其他功能。此外,Hooks 不是特定于组件的,因此任何 Hook 都可以在任何组件中使用——假设它是有用的并且是合理的。如果您回顾一下我们的类组件项目,您会发现没有办法共享生命周期事件方法中的逻辑。我们不能轻松地将其提取出来,然后在其他类组件中重用。这是 React 中创建 Hooks 模型的主要原因之一。因此,这两个部分,功能组件和可重用函数(Hooks),是理解 React Hooks 的关键。

首先,让我们列出我们在代码中将要使用的一些更重要的 Hooks。我们很快会在代码中给出它们的使用示例,但现在,我们将在高层次上讨论它们:

  • useState:这个函数是使用 Hooks 进行开发的基础。它替换了类组件中的statesetState调用。useState以一个值作为参数,表示它正在尝试表示的状态属性的初始状态。它还返回一个数组。第一项是实际的状态属性,第二项是一个可以更新该属性的函数。一般来说,它用于更新单个值,而不是具有多个属性的更复杂的对象。这种类型状态的更好的 Hook 可能是useReducer,稍后会解释。

  • useEffect:这个函数在组件完成绘制到屏幕后触发。它类似于componentDidMountcomponentDidUpdate。但是,它们在绘制到屏幕之前运行。它旨在用于更新状态对象。因此,例如,如果您需要获取网络数据然后更新状态,可以在这里做。您也可以在这里订阅事件,但是您还应该通过返回一个执行取消订阅的函数来取消订阅。

您可以有多个独立的useEffect实现,每个负责执行某些独特的操作。这个函数通常在每次完成屏幕绘制后运行。因此,如果任何组件状态或 props 发生变化,它将运行。您可以通过将空数组作为参数传递来强制它只运行一次,就像componentDidMount一样。您还可以通过将它们作为数组传递到useEffect数组参数中,来强制它仅在特定的 props 或状态更改时运行。

这个函数是异步运行的,但是如果你需要知道屏幕上一些元素的值,比如滚动位置,你可能需要使用useLayoutEffect。这个函数是同步运行的,允许你以同步的方式获取屏幕上某些元素的值,然后以同步的方式对它们进行操作。但是,当然,这会阻塞你的 UI,所以你只能做一些非常快速的事情,否则用户体验会受到影响。

  • useCallback:这个函数将在一组参数发生变化时创建一个函数实例。这个函数存在是为了节省内存,否则函数的实例将在每次渲染时重新创建。它以处理函数作为第一个参数,然后以一个可能会改变的项目数组作为第二个参数。如果项目没有改变,回调函数就不会得到一个新的实例。因此,这个函数内部使用的任何属性都将是之前的值。当我第一次了解这个函数时,我觉得很难理解,所以我稍后会举个例子。

  • useMemo:这个函数旨在保存长时间运行任务的结果。它有点像缓存,但只有在参数数组发生变化时才会运行,所以在这个意义上它类似于useCallback。然而,useMemo返回的是一些重型计算的结果。

  • useReducer:这个函数与React Redux类似。它接受两个参数,reducerinitial state,并返回两个对象:一个由reducer更新的state对象和一个接收更新后的状态数据(称为action)并将其传递给reducer的分发器。reducer充当过滤机制,并确定如何使用动作数据来更新状态。我们稍后会在代码中展示一个例子。当你想要有一个具有多个可能需要更新的属性的单一复杂状态对象时,这种方法效果很好。

  • useContext:这个函数是一种具有全局状态数据的方式,可以在组件之间共享。最好谨慎使用它,因为它可以任意地将状态注入到任何子组件中,而不考虑层次结构。我们将使用React Redux而不是Context,但知道它的存在是很好的。

  • useRef:这可以用来保存当前属性中的任何值。如果它发生变化,这个值不会触发重新渲染,而且这个值的生存期与它所创建的组件的生存期一样长。这是一种保持状态的方式,对渲染没有影响。它的一个用例是保存 DOM 元素。你可能想这样做,因为在某些情况下,有必要退出标准的基于状态的 React 模型,直接访问 HTML 元素。为此,useRef用于访问元素的实例。

当然,还有许多其他的 Hooks,既有来自 React 团队的,也有第三方的。但是一旦你熟悉了,你就能看到你可能需要什么,甚至更好的是,能够创建你自己的 Hooks。我们也将为我们的项目创建自己的 Hooks。

让我们来看一些使用 Hooks 的例子。我们将在Chap5中创建一个新项目来开始:

  1. 将你的命令行或终端切换到Chap5文件夹,并在该文件夹中运行以下命令:
npx create-react-app hooks-components –template typescript
  1. 在类组件项目的最后一个例子中,我们创建了一个名为Greeting.tsx的类组件,它有自己的状态。为了演示目的,让我们将相同的组件创建为 React Hooks 函数组件。在hooks-components项目的src文件夹中,创建一个名为Greeting.tsx的新文件,并添加以下代码:
import React, { FC, useState, useEffect } from 'react';
interface GreetingProps {
    name?: string
}
const Greeting: FC<GreetingProps> = ({name}:GreetingProps) => {
    const [message, setMessage] = useState("");
    useEffect(() => {
        if(name) {
            setMessage(`Hello from, ${name}`);
        }
    }, [name])
    if(!name) {
        return <div>no name given</div>;
    }
    return <div>
        {message}
    </div>;
}
export default Greeting;

这是代码的一个版本,我们将一个名字作为 prop 并拥有我们自己的本地状态。我们应该尽量避免使用本地状态,但我正在做这个来进行演示。正如你所看到的,这比类版本要短得多。此外,我们没有生命周期函数需要重写。我们使用箭头函数是因为它比使用常规函数要短,而且我们不需要函数的特性。正如你所看到的,我们对Greeting组件进行了声明。它使用了FCGreetingProps接口。状态存储在message属性中,使用了useState函数,这是一个小的一行语句,没有构造函数,因为这是一个函数而不是一个类。注意GreetingProps在参数旁边并不是必要的;我只是为了完整性才包含它。还要注意,我们使用了参数解构,通过传递{ name }而不是props

接下来,我们有我们的useEffect函数。正如所述,这有点类似于componentDidMountcomponentDidUpdate,但是在绘制到屏幕完成后运行。每当我们的nameprop 更新时,它将更新message状态属性,因为我们将它作为参数传递给useEffect函数。由于这不是一个类,我们没有渲染函数。函数的返回值是调用渲染。

  1. 现在我们将通过将我们的状态放入App.tsx组件中来进行一些重构。让我们像我们在组件的类版本中做的那样,将GreetingFunctional.tsx组件做成这样:
import React from "react";
interface GreetingProps {
    message: string
}
export default function Greeting(props: GreetingProps) {
    console.log("rendering Greeting")
    return (<div>
            {props.message}
        </div>);    
}
  1. 现在让我们将App.tsx重构为一个函数组件,并使用我们在本节学到的useReducer Hook。我们将省略导入,因为它们是一样的:
const reducer = (state: any, action: any) => {
  console.log("enteredNameReducer");
  switch(action.type) {
    case "enteredName":
      if(state.enteredName === action.payload) {
        return state;
      }
      return { ...state, enteredName: action.payload}
    case "message":
      return { ...state, message: `Hello, ${action.       payload}` }
    default:
      throw new Error("Invalid action type " + action.       type);
  }
}
const initialState = {
  enteredName: "",
  message: "",
};

我们定义了我们的 reducer 和一个名为initialState的初始状态对象。reducer 的默认签名是any类型的参数,因为状态和动作对象都可以是任何类型。如果你看一下reducer函数,你会注意到它试图通过返回一个新的状态对象和一个适当更新的成员来处理不同类型的动作(再次强调,你绝对不能直接修改原始状态对象。复制它,然后在新对象上进行更新并返回它)。所以,这就是useReducer的预期用法。如果你的状态对象很复杂,改变属性的逻辑也很复杂,你会使用useReducer函数。你可以把它看作是对状态对象上相关逻辑的一种封装。接下来,你可以在App组件中看到对useReducer的实际调用:

function App() {  
    const [{ message, enteredName }, dispatch] = 
      useReducer(reducer, initialState);

    const onChangeName = (e: React.     ChangeEvent<HTMLInputElement>)
      => {
      dispatch ({ type: "enteredName", payload: e.target.       value 
       });
      dispatch ({ type: "message", payload: e.target.       value });
    }

    return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo"          />
        <input value={enteredName}        onChange={onChangeName} />
        <Greeting message={message} />
      </header>
    </div>
    )
  }
  export default App;

正如你所看到的,这个函数返回一个对象和一个dispatch函数。对象是 reducer 运行后的整个状态对象,但在我们的情况下,我们进行了解构,所以我们可以直接调用messageenteredName属性。在这个设置之后,定义了onChangeName事件,当触发时,运行useReducer的分发器dispatch,通过发送适当的动作来触发实际的更改。如果你运行这段代码,你会发现它和以前一样运行。

现在,所有这些的好处是,正如你所看到的,我们可以把我们的reducer函数拿来在其他函数组件中重用。我们也可以把我们的分发器传递给子组件,这样子组件也可以触发对我们状态的更新。让我们试一试:

  1. 让我们用这段代码更新我们的GreetingFunctional.tsx组件:
import React from "react";
interface GreetingProps {
    enteredName: string;
    message: string;
     greetingDispatcher: React.Dispatch<{ type: string,     payload: string }>;
}
export default function Greeting(props: GreetingProps) {
    console.log("rendering Greeting")
    const onChangeName = (e: React.      ChangeEvent<HTMLInputElement>) => {
        props. greetingDispatcher ({ type: "enteredName", 
          payload: e.target.value });
        props. greetingDispatcher ({ type: "message", 
           payload: e.target.value });
      }
    return (<div>
        <input value={props.enteredName} onChange=
          {onChangeName} />
            <div>
                {props.message}
            </div>
        </div>);    
}

正如你所看到的,我们已经将enteredNamegreetingDispatcher作为 props 传递给了我们的Greeting组件。然后我们还带入了inputonChangeName事件,以便在我们的组件中使用它们。

  1. 现在,让我们像这样更新我们的App.tsx文件:
function App() {  
const [{ message, enteredName }, dispatch] = useReducer(reducer, initialState);
  return (
  <div className="App">
    <header className="App-header">
      <img src={logo} className="App-logo" alt="logo" />

      <Greeting 
        message={message} 
        enteredName={enteredName} 
        greetingDispatcher={ dispatch } />
    </header>
  </div>
  )
}

正如你所看到的,我们已经移除了onChangeName和输入,以便我们可以在我们的GreetingFunctional.tsx组件中使用它。我们还将enteredNamemessagedispatch作为参数传递给Greeting组件。如果你运行这个,你会看到触发reducer更新的是我们的子GreetingFunctional.tsx组件。

  1. 接下来,让我们看看useCallback函数。像这样更新App.tsx
function App() {  
const [{ message, enteredName }, dispatch] = useReducer(reducer, initialState);
  const [startCount, setStartCount] = useState(0);
  const [count, setCount] = useState(0);
  const setCountCallback = useCallback(() => {
    const inc = count + 1 > startCount ? count + 1 : 
      Number(count + 1) + startCount;
    setCount(inc);
  }, [count, startCount]);
  const onWelcomeBtnClick = () => {
    setCountCallback();
  }
  const onChangeStartCount = (e: 
   React.ChangeEvent<HTMLInputElement>) => {
    setStartCount(Number(e.target.value));
  }

我们正在使用一个输入,该输入将使用startCount获取用户的初始数字值。然后,我们将通过单击setCountCallback递增该数字。但请注意,useCallback是如何将count状态作为参数的。这意味着当count更改时,setCountCallback将重新初始化为当前值。其余的代码返回了所需的 JSX,将生成最终的 HTML:

  console.log("App.tsx render");
  return (    
  <div className="App">
    <header className="App-header">
      <img src={logo} className="App-logo" alt="logo" />

      <Greeting 
        message={message} 
        enteredName={enteredName} 
        greetingDispatcher={dispatch} />
      <div style={{marginTop: '10px'}}>
        <label>Enter a number and we'll increment           it</label>
        <br/>
        <input value={startCount}          onChange={onChangeStartCount} 
          style={{width: '.75rem'}} />&nbsp;
        <label>{count}</label>
        <br/>
        <button onClick={onWelcomeBtnClick}>Increment           count</button>
      </div>
    </header>
  </div>
  )
}

返回提供了这种递增能力的 UI。

如果您运行此代码并单击增加计数按钮,您将看到它会增加,如下所示:

图 5.7 – 单击增加计数 8 次

图 5.7 – 单击增加计数 8 次

但是,尝试更改传入的数组[count, startCount],并删除count变量,使其只说[startCount]。现在,它不会继续递增,因为没有依赖于count。无论我们点击多少次,它只会计数一次,第一次运行时,无论我们点击多少次:

图 5.8 – 删除 count 后

图 5.8 – 删除 count 后

因此,即使您点击多次,它也将始终递增一次,因为该函数被缓存在内存中,并且始终以count的相同初始值运行。

让我们再看一个性能示例。我们将在此示例中使用 memo 包装器以减少重新渲染。这不是一个 Hook,但它是最近添加到 React 中的一个新功能。让我们看看步骤:

  1. 创建一个名为ListCreator.tsx的新文件,并添加以下代码:
import React, { FC, useEffect, useRef } from 'react';
export interface ListItem {
    id: number;
}
export interface ListItems {
    listItems?: Array<ListItem>;
}
const ListCreator: FC<ListItems> = ({listItems}:ListItems) => {
    let renderItems = useRef<Array<JSX.Element> |     undefined>();
    useEffect(() => {
        console.log("listItems updated");
        renderItems.current = listItems?.map((item,          index) => {
            return <div key={item.id}>
                {item.id}
            </div>;
        });
    }, [listItems]);
    console.log("ListCreator render");
    return (
        <React.Fragment>
        {renderItems.current}
        </React.Fragment>
    );
}
export default ListCreator;

此组件将接受一个项目列表并将其呈现为列表。

  1. 现在,让我们更新我们的App.tsx文件,以根据递增计数发送新的列表项。再次,我只包含了App函数。请注意,还需要一个名为ListCreator的新导入:
function App() {  
const [{ message, enteredName }, dispatch] = useReducer(reducer, initialState);
  const [startCount, setStartCount] = useState(0);
  const [count, setCount] = useState(0);
  const setCountCallback = useCallback(() => {
    const inc = count + 1 > startCount ? count + 1 :      Number(count
      + 1) + startCount;
    setCount(inc);
  }, [count, startCount]);
  listItems and a new useEffect function to populate that list. The list is updated any time count is updated:

const onWelcomeBtnClick = () ⇒ {

setCountCallback();

}

const onChangeStartCount = (e:

React.ChangeEvent) ⇒ {

setStartCount(Number(e.target.value));

}

console.log("App.tsx render");

return (

logo

问候

message=

enteredName=

greetingDispatcher= />


<input value=           onChange=

style={{width: '.75rem'}} /> 


)

}


If you run this example, you will see that not only do we get new list item elements when we increment the number, but we also get them when we type our name. This is because whenever the parent component renders, as its state was updated, so do any children.
  1. 让我们对ListCreator进行一些小的更新,以减少我们的渲染:
const ListCreator: FC<ListItems> = 
  React.memo(({listItems}:ListItems) => {
    let renderItems = useRef<Array<JSX.Element> |     undefined>();
    useEffect(() => {
        console.log("listItems updated");
        renderItems.current = listItems?.map((item,           index) => {
            return <div key={item.id}>
                {item.id}
            </div>;
        });
    }, [listItems]);
    console.log("ListCreator render");
    return (
        <React.Fragment>
        {renderItems.current}
        </React.Fragment>
    );
});

我只展示了ListCreator组件,但是您可以看到我们添加了一个名为React.memo的包装器。此包装器仅在传入的 props 发生更改时才允许组件更新。因此,我们获得了一些小的性能优势。如果这是一个具有大量元素的复杂对象,它可能会产生很大的差异。

正如您在这些示例中所看到的,对于任何给定的 Hook,我们可以在不同的组件中重用相同的 Hook,并使用不同的参数。这是 Hooks 的关键要点。代码重用现在变得更加容易。

请注意,useStateuseReducer只是可重用的函数,允许您在多个组件中使用函数。因此,在组件 A 中使用useState,然后在组件 B 中使用useState将不允许您在两个组件之间共享状态,即使状态名称相同也是如此。你只是重用功能,仅此而已。

在本节中,我们学习了 React Hooks。我们回顾了库中一些主要的 Hooks 以及如何使用其中一些。我们将在以后的章节中涵盖更多的 Hooks,并开始构建我们的应用程序。这些 Hooks 的覆盖将帮助我们以后开始构建我们的组件。

比较和对比类方式与 Hooks 方式

在本节中,我们将讨论在 React 中以类方式和 Hooks 方式编写代码之间的一些差异。我们将看到为什么 React 团队决定使用 Hooks 是前进的方式。了解这些细节将使我们对在自己的代码中使用 Hooks 更有信心。

代码重用

如果你看一下基于类的生命周期方法,不仅有许多需要记住和理解的方法,而且你还可以看到对于每个类组件,你将有一个几乎独特的生命周期函数实现。这使得使用类进行代码重用变得困难。使用 Hooks,我们还有许多不同的内置 Hooks 可以使用和需要了解。然而,它们不是组件特定的,可以随意重用于不同的组件。这是使用 Hooks 的关键动机。代码重用变得更容易,因为 Hooks 不与任何特定的类绑定。每个 Hook 都专注于提供特定的功能或功能,无论它在哪里使用。此外,如果我们努力构建自己的 Hooks,我们也可以在适当的时候重用它们。

在类组件项目中查看Greeting。我们如何在这个组件中重用代码?即使我们可以做到这一点,它也没有真正的价值或好处。除此之外,getDerivedStateFromProps增加了可能触发重新渲染的复杂性。而且我们根本没有使用任何其他生命周期方法。

Hook 组件和 React 总体上优先考虑组件化而不是继承。事实上,React 团队表示,最佳实践是使用组件在其他组件中共享代码,而不是继承。

因此,要重申一下,生命周期组件通常与特定组件绑定,但是通过一些工作,Hooks 可以跨组件使用并适当地泛化它们。

简单性

你还记得一旦我们在其中添加了getDerivedStateFromProps调用,Greeting变得多么庞大吗?此外,我们总是需要一个构造函数来实例化我们的状态,并为所有组件使用bind。由于我们的组件很简单,这并不重要。但是对于生产代码,你会看到许多函数的组件都需要进行bind调用。

在 hooks-component 项目中,Greeting要简单得多。即使该组件增长,调用的 Hooks 大部分都会重复,这还会使代码更易于阅读。

总结

本章涵盖了大量的信息。我们了解了基于类的组件以及使它们难以使用的原因。我们还了解了基于 Hook 的组件,它们更简单,更容易重用。

我们现在了解了 React 编程的基础知识。我们现在可以创建自己的 React 组件并开始构建我们的应用程序!

在下一章中,我们将学习关于 React 周围的工具。我们将结合我们在这里获得的知识和工具信息,这将帮助我们编写干净、响应迅速的代码。

第六章:使用 create-react-app 设置我们的项目,并使用 Jest 进行测试

在本章中,我们将学习帮助我们构建 React 应用程序的工具。无论语言或框架如何,高级的专业应用程序开发总是涉及使用工具来帮助更快地构建应用程序并提高代码质量。React 开发生态系统也不例外。一个社区已经围绕着某些工具和编码方法形成,并且我们将在本章中介绍这些。这些复杂的工具和方法将帮助我们编写更好的应用程序,并帮助我们重构我们的代码以使其适应新的需求。

在本章中,我们将涵盖以下主要主题:

  • 学习 React 开发方法和构建系统

  • 了解 React 的客户端测试

  • 学习 React 开发的常见工具和实践

技术要求

您应该对 Web 开发和我们在之前章节中学习的 SPA 编码风格有基本的了解。我们将再次使用 Node(npm)和 VS Code。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap6文件夹中的代码。

要在您自己的机器上设置第六章代码文件夹,请转到您的HandsOnTypescript文件夹并创建一个名为Chap6的新文件夹。

学习 React 开发方法和构建系统

在本节中,我们将学习用于编码和构建 React 应用程序的工具和实践。这些方法中的许多方法通常用于现代 JavaScript 开发,甚至在竞争框架如 Angular 和 Vue 中也是如此。

为了构建大型、复杂的应用程序,我们需要工具 - 大量的工具。其中一些工具将帮助我们编写更高质量的代码,一些将帮助我们共享和管理我们的代码,还有一些将存在只是为了增强开发人员的生产力,并使调试和测试我们的代码变得更容易。因此,通过学习用于构建现代 React 应用程序的工具,我们将确保我们的应用程序能够以最少的问题正常工作。

项目工具

正如我们从之前的章节中看到的,现代 React 开发使用许多组件来构建最终的应用程序。对于项目结构和基本依赖项,大多数开发人员将使用create-react-app,这是基于最初为 Node 开发(npm)创建的开发工具。我们已经看到了create-react-app可以做什么,但在本节中,我们将深入了解一下。

但首先,我们需要了解我们是如何使用当前的工具和编码方式的。这些知识将帮助我们更好地理解为什么要转向当前的风格以及好处是什么。

以前是如何完成的

网络实际上是由不同的技术拼凑而成的。HTML 首先出现,用于创建文本共享功能。然后是 CSS,用于更好的样式和文档结构。最后是 JavaScript,用于添加一些事件驱动的功能和编程控制。因此,难怪有时将这些技术整合到一个统一的应用程序中会感到尴尬甚至困难。让我们看一些例子,将这些部分整合在一起而不使用太多的工具:

  1. 打开您的终端或命令行到Chap6文件夹。创建一个名为OldStyleWebApp的新文件夹。

  2. 使用 VS Code 创建一个名为index.html的 HTML 文件,并将以下代码添加到其中。我们将创建一个简单的输入和显示:

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Learn React</title>
  <link rel="stylesheet" href="core.css">
</head>
<body>	
<label>Enter your name</label>
<input id="userName" />
<p id="welcomeMsg"></p>
  	<script src="img/script.js"></script>
</body>
</html>
  1. 在同一文件夹中创建一个名为core.css.css文件。

  2. 在同一文件夹中创建一个名为script.js.js文件。

现在,我们稍后会填写 CSS 和 JS 文件,但是立即我们遇到了一个问题。我怎么运行这个应用程序?换句话说,我怎么看到它运行,以便我可以检查它是否工作?让我们看看我们能做什么:

  1. 在您的 VS Code 中,右键单击index.html文件并复制其路径,如下所示:图 6.1 – 复制 index.html

图 6.1 – 复制 index.html

  1. 现在,打开您的浏览器,并将此文件路径粘贴到 URL 中。您应该会看到以下内容:图 6.2 – 浏览器中的 index.html

图 6.2 – 浏览器中的 index.html

您可能还不知道,但您不需要 HTTP 服务器才能在浏览器中查看 HTML 文件。但是,您可以看到这个过程并不是最有效的,如果能自动化,包括在我对任何相关文件进行更改时自动刷新,那将更好。

  1. 现在,让我们填写我们的 CSS 文件:
label {
    color: blue;
}
p {
    font-size: 2rem;
}

您会注意到,即使我保存了这个文件,Web 浏览器上的label元素也不会自动更新。我必须刷新浏览器,然后它才会更新。如果我在开发会话期间更新了数十个文件怎么办?每次都手动刷新将不是一个好的体验。

  1. 接下来,让我们在script.js中添加一些代码:
const inputEl = document.querySelector("#userName");
console.log("input", doesnotexist);

我们要仔细阅读这段代码,因为它存在多个问题。让我们看看这些问题是什么。如果我们保存这个文件,打开浏览器调试工具,然后刷新浏览器,您会看到在create-react-app项目工具中立即出现了这个错误。create-react-app项目具有所谓的 linter。linter 是一个代码检查工具,它在您编写代码时在后台运行。它将检查常见错误,比如我们刚刚看到的错误,以便它们不会出现在您的生产代码中。linter 还有更多功能,但我们将在以后更深入地探讨它们。关键在于我们希望在运行应用程序之前避免这些类型的错误。而create-react-app,或者在这种情况下一些内置的工具,可以帮助我们做到这一点。

  1. 让我们尝试添加正确的变量名,并再次重新加载浏览器。像这样更新script.js文件,保存它,然后重新加载浏览器:
const inputEl = document.querySelector("#userName");
console.log("input", inputEl);

正如您在调试器控制台中所看到的,日志语句找不到inputEl,因为它返回null。这是因为我们将input元素的id误写为"userNam"而不是"userName"。现在,再次运行create-react-app项目时,这种错误根本不可能发生,因为绝大多数 React 代码不会尝试查询或查找我们 HTML 页面中的元素。相反,我们直接使用 React 组件,因此我们可以完全避免这类错误。诚然,可以选择退出此行为并通过useRef使用对 HTML 元素的引用。然而,这应该是一种谨慎的做法,因为通过使用此 Hook 故意退出正常的 React 生态系统行为,从而失去其好处。

  1. 让我们修复我们的script.js文件并完成它。像这样更新它:
const inputEl = document.querySelector("#userName");
console.log("input", inputEl);
const parEl = document.querySelector("#welcomeMsg");
inputEl.addEventListener("change", (e) => {
    parEl.innerHTML = "Welcome " + e.target.value;
});

如果您通过刷新浏览器来运行此代码,您会看到如果您在输入框中输入您的姓名,然后点击输入元素外部,将显示如下消息:

图 6.4 – 欢迎显示

图 6.4 – 欢迎显示

所以,这段代码确实可以显示欢迎消息。然而,很容易出错,而且没有任何帮助指示原因。除此之外,请注意,由于浏览器不运行 TypeScript,我们没有 TypeScript。这意味着我们也缺少了类型指示器,这些指示器在避免与不正确类型相关的错误方面也很有帮助。

所以,我们已经看到了在原始的 web 方式下做事情的一些问题。但事实上,我们甚至还没有触及以这种方式进行开发的问题的表面。例如,在我们的 HTML 中嵌入脚本标签是一个合理的做法,当我们只有少量脚本要处理时。但是当我们的依赖增长时呢?对于更大的应用程序,很可能会有数百个依赖项。管理那么多脚本标签将会非常困难。而且不仅如此 - 很多 JavaScript 依赖项不再提供可以调用的 URL。

说了这么多,也许最大的问题之一是代码的高度自由形式。如果你再看一下script.js文件,你会发现代码没有模式或结构。当然,你的团队可能会自己想出一种模式,但是新加入团队的程序员呢?他们将不得不学习一种特定于你的团队的代码结构方式。

因此,工具、框架和结构提供了一致、可重复的编写和维护代码的方式。你可以把它看作是一种编程文化,每个人都接受了文化的规范和实践,因此知道该做什么和如何行事。这使得代码更容易编写、共享和重构。现在我们已经看过了自由形式的编码,让我们开始更深入地了解create-react-app

create-react-app

在之前的章节中,比如[第四章](B15508_04_Final_JC_ePub.xhtml#_idTextAnchor072),学习单页应用程序概念以及 React 如何实现它们,以及[第五章](B15508_05_Final_JC_ePub.xhtml#_idTextAnchor081),使用 Hooks 进行 React 开发,我们使用create-react-app来设置我们的基础应用程序项目。让我们更仔细地看一下create-react-app项目的内部。为了更好地理解组成create-react-app项目的部分,我们首先需要弹出它。在这里,弹出只是意味着我们将揭示所有使create-react-app工作的内部依赖项和脚本,因为通常这些是隐藏的。

警告:弹出是一个不可逆转的操作

在绝大多数情况下,你不会弹出create-react-app项目,因为这样做没有多大价值。我们在这里这样做只是为了更深入地了解这个项目是如何工作的。

让我们看一下步骤:

  1. 通过在Chap6文件夹内执行以下命令来在其中创建一个新项目:
Chap6 called ejected-app.
  1. 现在让我们弹出项目。在命令行中切换到新的ejected-app文件夹,并运行以下命令:
npm run eject

然后在提示符处输入y继续。

让我们从 VS Code 资源管理器菜单的顶部看一下这个项目:

  • config

这个文件夹包含了大部分配置文件和脚本,项目用来设置自身。需要注意的主要是,React 团队默认使用Jest进行测试和Webpack进行 JavaScript 文件的捆绑和最小化。我们将在了解 React 的客户端测试部分讨论 Jest,而 Webpack 将在本节后面讨论。

  • node_modules

正如你所知,这个文件夹包含了我们项目的依赖项。正如你所看到的,即使在我们添加自己的依赖项之前,默认的依赖项集合就已经非常庞大了。试图使用 HTML 脚本标签列出这些依赖项将会非常困难。而且在大多数情况下,这些依赖项不支持脚本标签引用。

  • public

这个文件夹包含用于生成我们的单页应用程序的静态资产。这包括我们的一个名为index.html的 HTML 文件,如果我们正在构建 PWA 应用程序,则需要的manifest.json文件。还可以添加其他文件,比如用于部署的图像文件。

  • scripts

scripts 文件夹包含用于管理项目的脚本,例如,构建、启动或启动应用程序测试的脚本。实际的测试文件不应该添加在这里。我们将在稍后的 理解 React 客户端测试 部分介绍测试。

  • src

这当然是包含我们项目源文件的文件夹。

  • .gitignore

.gitignore 是一个文件,告诉 Git 源代码仓库系统不要跟踪哪些文件和文件夹。我们将在本节后面更深入地了解 Git。

  • package.json

如前几章所述,npm 是最初为 Node 服务器框架创建的依赖管理系统。这个依赖管理器的功能和流行度最终使它成为客户端开发的标准。因此,React 团队使用 npm 作为项目创建和依赖管理的基础系统。

除了列出项目的依赖关系,它还可以列出可以运行以管理项目的脚本。

它还具有配置 Jest、ESLint 和 Babel 等功能。

  • Package-lock.json

这是一个相关文件,它有助于维护一组正确的依赖关系和子依赖关系,而不管它们安装的顺序如何。我们不需要直接处理这个文件,但知道这有助于防止不同开发人员在不同时间使用不同的现有依赖关系更新他们的 npm_modules 文件夹时出现问题是很有用的知识。

  • tsconfig.json

我们已经在 第二章 中回顾过这个文件,探索 TypeScript,并且如该章节中提到的,它包含了 TypeScript 编译器的设置。请注意,一般来说,React 团队更喜欢更严格的编译设置。还要注意目标 JavaScript 版本是 ES5。这是因为一些浏览器尚不兼容 ES6。

create-react-app 还包含两个非常重要的工具,它们使一些功能得以实现:Webpack 和 ESLint。Webpack 是一个捆绑和最小化工具,它自动完成了收集项目中所有文件的任务,移除任何多余的、未使用的部分,并将它们合并成几个文件。通过移除多余的部分,比如空格和未使用的文件或脚本,它可以大大减小用户浏览器需要下载的文件大小。当然,这会增强用户体验。除了这个核心功能,它还提供了一个“热重载”开发服务器,可以让某些脚本更改自动显示在浏览器中,而无需刷新页面(尽管大多数更改似乎会触发浏览器刷新,但至少这些是自动的)。

ESLint 也是一个重要的工具。由于 JavaScript 是一种脚本语言而不是编译语言,它没有编译器来检查语法和代码的有效性(显然,TypeScript 有,但 TypeScript 编译器主要关注类型问题)。因此,ESLint 提供了开发时代码检查,以确保它是有效的 JavaScript 语法。此外,它还允许创建自定义代码格式规则。这些规则通常用于确保团队中的每个人都使用相同的编码风格;例如,变量命名约定和括号缩进。一旦规则设置好,ESLint 服务将通过警告消息强制执行这些规则。

这些规则不仅适用于 JavaScript,还可以是关于如何为 React 等框架编写代码的规则。例如,在 create-react-app 项目中,ESLint 设置为 react-app,如 package.json 中所示,这是一组特定于 React 开发的编码规则。因此,我们将看到的许多消息并不一定是 JavaScript 错误,而是关于编写 React 应用程序的最佳实践的规则。

Webpack 虽然功能强大,但设置起来也非常困难。为 ESLint 创建自定义规则可能需要很长时间。所幸使用create-react-app的另一个好处是它为这两个工具提供了良好的默认配置。

转译

我们在第一章中介绍了转译,理解 TypeScript。然而,在这一章中,我们应该更深入地介绍它,因为create-react-app在很大程度上依赖于转译来生成其代码。create-react-app允许我们使用 TypeScript 或 Babel,以便我们可以用一种语言或语言版本开发代码,并将代码作为不同的语言或语言版本发出。下面是一个简单的图表,显示了在 TypeScript 转译过程中代码的流动。

图 6.5-从 TypeScript 到 JavaScript 的转译

图 6.5-从 TypeScript 到 JavaScript 的转译

TypeScript 编译器将搜索您的项目,并找到根代码文件夹(通常为src)中的所有tstsx文件。如果有错误,它会停止并通知我们,否则,它将解析并将 TypeScript 转换为纯 JavaScript 作为js文件,并在系统上运行。请注意,在图表中,我们还更改了 JavaScript 版本。因此,转译很像编译。代码被检查有效性和某些类别的错误,但不是转换为可以直接运行的字节码,而是转换为不同的语言或语言版本。Babel 也能够发出 JavaScript 并处理 TypeScript 开发人员的代码。但是,我更喜欢使用原始的 TypeScript 编译器,因为它是由设计 TypeScript 的同一个团队制作的,通常更加更新。

选择转译作为编译方法有多个重要的好处。首先,开发人员不需要担心他们的代码是否能在浏览器上运行,或者用户是否需要在机器上升级或安装一堆依赖。TypeScript 编译器发出 Web 标准 ECMAScript(ES3、ES5、ES6 等),因此代码可以在任何现代浏览器上运行。

转译还允许开发人员在最终发布之前利用 JavaScript 的新版本。由于 JavaScript 几乎每年都会更新一次,这个功能在利用新的语言特性或性能能力方面非常有用;例如,当考虑 JavaScript 的新功能时。ECMA 基金会,维护 JavaScript 语言的标准机构,在将更改纳入 JavaScript 的官方版本之前会经历几个阶段。但是 TypeScript 和 Babel 团队有时会在这些较早阶段之一接受新的 JavaScript 功能。这就是许多 JavaScript 开发人员在它成为官方标准之前就能在他们的代码中使用 async-await 的方式。

代码存储库

代码存储库是一个允许多个开发人员共享源代码的系统。代码可以被更新、复制和合并。对于大型团队来说,这个工具对于构建复杂的应用程序是绝对必要的。最流行的现代源代码控制和存储库是 Git。而最流行的在线存储库主机是 GitHub。

尽管彻底学习 Git 超出了本书的范围,但了解一些基本概念和命令是很重要的,因为在与其他开发人员互动和维护自己的项目时,您将需要它们。

任何代码存储库的更重要的概念之一是分支。这意味着能够指示项目的多个版本。例如,这些分支可以用于项目的版本号,如 1.0.0、1.0.1 等。也可以用于创建应用程序的不同版本,其中可能正在尝试一些实验性或高风险的代码。将这样的代码放入主分支不是一个好主意。这是 React GitHub 页面及其许多版本的一个例子:

图 6.6 – React GitHub

图 6.6 – React GitHub

如您所见,有许多分支。当前稳定的分支,虽然在此截图中看不到,通常称为主分支。

再次,要全面了解 Git 需要一本专门的书,所以在这里我只会介绍一些您每天会使用的主要命令:

  • git:此命令是 Git git命令,您正在使用存储库的本地副本;直到将更改推送到服务器之前,您不会直接在在线存储库上工作或影响您的队友的存储库。

  • 克隆:此命令允许您将存储库复制到本地计算机上。请注意,当您克隆时,通常会默认为主分支。这是一个例子:

git clone https://github.com/facebook/react.git
  • 检出:此子命令允许您将工作分支更改为不同的所需分支。因此,如果您想要在主分支之外的另一个分支中工作,您将使用此命令。这是一个例子:
git checkout <branch-name>
  • 添加:此子命令将您最近更改的文件添加为需要跟踪的文件,这表示您稍后将它们提交到存储库中。您可以使用add后的.一次性处理所有更改的文件,或者明确指定文件:
git add <file name>
  • 提交:此子命令表示您最终将使用您刚刚在本地添加的文件更新您的工作分支。如果添加-m参数,您可以内联添加标签来描述您的提交。此命令有助于团队成员跟踪每个提交中所做的更改:
git commit -m "My change to xyz"
  • 推送:此子命令将本地提交的文件实际移动到远程存储库中:
git push origin <branch name>

在本节中,我们介绍了一些适用于 React 开发人员的核心项目工具。create-react-app、ESLint、Webpack 和 npm 提供了宝贵的功能,使开发更高效,减少错误。我们还介绍了转译,以了解如何利用新的语言版本,而不影响最终用户设备的兼容性。

另外,我们快速看了一下 Git。目前,它是最受欢迎的代码共享存储库。作为专业开发人员,您肯定会在项目中使用它。

现在我们已经掌握了一些重要的核心工具知识,我们将在下一节中继续讨论测试。现代开发实践大量使用测试和测试框架。幸运的是,JavaScript 有很好的测试框架,可以帮助我们编写高质量的测试。

理解 React 的客户端测试

单元测试是开发的一个非常重要的部分。如今,没有任何大型项目会在没有一定级别的单元测试的情况下编写。测试的目的是确保您的代码始终正常工作并执行预期的操作。当代码被修改时,即重构时,这一点尤为重要。事实上,更改现有复杂代码可能比创建全新代码更困难。单元测试可以防止在重构过程中破坏现有代码。但是,如果代码出现故障,它也可以帮助准确定位代码不再起作用的确切位置,以便快速修复。

在 React 中,以前有两个常用的主要测试库:create-react-app。因此,在本书中,我们将学习 Jest 和 testing-library。

所有单元测试都以相同的方式工作。这不仅适用于 React 和 JavaScript 测试,而且适用于任何语言的测试都以相同的方式工作。那么,什么是单元测试?单元测试尝试测试代码的一个特定部分,并试图断言关于它的某些内容是真实的。基本上就是这样。换句话说,这意味着测试是在检查某些预期的东西是否确实如此。如果不是,那么测试应该失败。尽管这个目标很简单,但创建高质量的测试并不简单。因此,我们将在这里介绍一些例子,但请记住,大型应用程序的测试可能会比实际创建应用程序的代码更复杂。因此,您需要一些时间才能熟练地编写测试。

为了更清晰,让我们看一个简单的测试。请执行以下操作:

  1. 打开 VS Code 并在路径ejected-app/src/App.test.tsx中打开文件。这是对App组件的测试。我们将在接下来的内容中讨论测试的内容。

  2. 打开您的终端到ejected-app并运行以下命令:

test. Additionally, this test script is actually running our tests in a:

图 6.7 – 测试运行选项

图 6.7 – 测试运行选项

如果您的测试已经运行或者您选择了a,您应该会看到以下结果:

图 6.8 – 测试成功完成

图 6.8 – 测试成功完成

正如您所看到的,我们的测试已经被自动发现并运行(尽管目前我们只有一个)。在这次运行中,一个测试成功,这意味着预期的事情发生了。如果有任何失败,同样的 UI 将指示有多少测试失败和多少成功。

现在,让我们看一下App.test.tsx中的测试:

import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
  const { getByText } = render(<App />);
  const linkElement = getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

首先,您会注意到文件名中包含文本test。这告诉 Jest 这是一个测试文件。一些团队喜欢将所有测试放在一个文件夹中。一些团队更喜欢将测试放在被测试的实际文件旁边,就像这种情况。没有标准答案。做最适合您和您的团队的事情。在本书中,我们将把我们的测试放在被测试的文件旁边。让我们来看看我们test文件的内容:

  1. 请注意,在导入中,我们引用了@testing-library/react。如前所述,这个库将为我们提供一些额外的工具,以使组件输出的测试更容易。

  2. 现在,注意test函数。这个函数充当我们单个测试的封装包装器。这意味着与这个测试相关的所有内容都存在于这个函数内部,不能从外部访问。这确保了我们的测试不会受到其他测试的影响。

  3. 这个函数的第一个参数是一个描述。描述是完全任意的,您的团队将有自己的标准,描述应该如何编写。我们唯一需要关注的是让描述简洁明了,清楚地说明正在测试的内容。

  4. 第二个参数是运行实际测试的函数。在这种情况下,测试检查特定文本是否出现在我们的App组件的生成的 HTML 中。让我们逐行查看代码。

  5. 第 6 行,我们运行render,将App组件传递给它。这个render函数执行我们的组件,并返回一些属性和函数,允许我们测试生成的 HTML。在这种情况下,我们决定只接收getByText函数,这意味着返回一个包含特定文本的元素。

  6. 第 7 行,我们通过使用参数/learn react/i调用getByText来获取我们的 HTML DOM 元素,这是用于运行正则表达式的语法,但在这种情况下,它是针对文本的硬编码。

  7. 最后,在第 8 行,进行了一个称为expect的断言,它期望名为linkElement的元素对象使用toBeInTheDocument函数在 DOM 中。因此,理解测试的一种简单方法是将它们的断言读作一个句子。例如,我们可以这样读取这个断言,"我期望 linkElement 在文档中"(当然,文档是浏览器 DOM)。通过这种方式阅读,很清楚意图是什么。

  8. 现在,让我们看看如果我们稍微改变代码会发生什么。使用以下内容更新App.tsx(出于简洁起见,我只显示App函数):

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo"            />
        <p>
          Edit <code>src/App.tsx</code> and save to             reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          React in Learn React.
  1. 保存此文件后,您应该立即看到如下错误:

图 6.9-更改 App.tsx 后的错误

图 6.9-更改 App.tsx 后的错误

同样,测试运行程序正在观察模式下运行,因此只要保存更改,您就应该看到测试结果。正如您所看到的,我们的测试失败,因为未找到文本learn react,因此断言expect(linkElement).toBeInTheDocument()不成立。

好的,所以我们已经看了一下create-react-app提供的内置测试。现在让我们创建一个新组件,这样我们就可以从头开始编写我们自己的测试。请按照以下步骤操作:

  1. 让我们保持测试处于观察模式运行,即使它显示错误,并通过单击 VS Code 终端窗口右上角的加号按钮创建一个新的终端窗口。该按钮如下所示:图 6.10:新终端的加号标志

图 6.10:新终端的加号标志

  1. 现在,在src文件夹中创建一个名为DisplayText.tsx的新文件,并添加以下代码:
import React, { useState } from "react";
const DisplayText = () => {
    const [txt, setTxt] = useState("");
    const [msg, setMsg] = useState("");
    const onChangeTxt = (e: React.      ChangeEvent<HTMLInputElement>)
     => {
        setTxt(e.target.value);
    }
    const onClickShowMsg = (e: React.      MouseEvent<HTMLButtonElement, MouseEvent>) => {
        e.preventDefault();
        setMsg(`Welcome to React testing, ${txt}`);
    }

这个组件将在有人输入他们的名字并点击DisplayText后简单地显示一个新消息。

  1. 然后,我们创建一些组件工作所必需的状态和事件处理程序,以处理新文本和消息的显示(我们已经介绍了如何在第五章中使用 Hooks 创建 React 组件):
    return (
        <form>
            <div>
                <label>Enter your name</label>
            </div>
            <div>
                <input data-testid="user-input" 
                  value={txt} onChange={onChangeTxt} />
            </div>
            <div>
                <button data-testid="input-submit" 
                 onClick={onClickShowMsg}>Show                     Message</button>
            </div>
            <div>
                <label data-testid="final-msg" 
                   >{msg}</label>
            </div>
        </form>
    )
}
export default DisplayText;
  1. 最后,我们返回我们的 UI,其中包括一个输入和一个提交按钮。请注意data-testid属性,以便稍后可以轻松地通过我们的测试找到元素。如果您运行此代码并输入您的姓名并单击按钮,您应该会看到类似于这样的东西:

图 6.11-用于测试的新组件

图 6.11-用于测试的新组件

正如您所看到的,我们的显示只是返回输入的文本和欢迎消息。然而,即使这个简单的例子也有几个不同的测试内容。首先,我们希望确保输入框中输入了文本,并且是单词而不是数字或符号。我们还希望确保当我们单击按钮时,消息被显示,并且以字符串"Welcome to React testing"开头,并以用户输入的文本结尾。

现在我们有了我们的组件,让我们为它构建我们的测试:

  1. 我们需要注意一下我们的tsconfig.json文件中的一个小问题。正如我之前所述,您可以将测试放在一个单独的文件夹中,通常称为__test__,或者您可以将其与组件文件放在一起。为了方便起见,我们将它放在一起。如果我们这样做,我们将需要更新我们的tsconfig.json文件以包括这个compilerOption
"types": ["node", "jest"]
  1. 通过创建一个名为DisplayText.test.tsx的新文件为这个组件创建测试文件,并将初始代码添加到其中:
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import DisplayText from './DisplayText';
import "@testing-library/jest-dom/extend-expect";
describe("Test DisplayText", () => {
    it("renders without crashing", () => {
        const { baseElement } = render(<DisplayText />);
        expect(baseElement).toBeInTheDocument();
    });
    it("receives input text", () => {
        const testuser = "testuser";
        const { getByTestId } = render(<DisplayText />);
        const input = getByTestId("user-input");
        fireEvent.change(input, { target: { value:         testuser } });
        expect(input).toBeInTheDocument();
        expect(input).toHaveValue(testuser);
    })
});

从顶部开始,您会注意到我们从@testing-library/react导入了render,我们还从@testing-library/jest-dom/extend-expect导入了扩展,这使我们能够进行断言。expect关键字的扩展给了我们额外的函数,让我们能够以更多的方式进行测试。例如,我们使用toHaveValue来获取input的值。

在导入之后,您会注意到一些新的语法。describe就像其名称所示的那样,只是一种创建带有有用标签的分组容器的方法。此容器可以有多个测试,但这些测试应该都与测试特定组件或功能相关。在这种情况下,我们试图测试DisplayText组件,因此describe中的所有测试都将仅测试该组件。

因此,我们的第一个测试是使用名为it的函数开始的。此函数检查我们的组件DisplayText是否可以呈现为 HTML 而不崩溃或出错。render函数尝试进行呈现,expecttoBeInTheDocument函数通过检查它是否在 DOM 中来确定呈现是否成功。作为一个实验,在第一个测试it函数中的以const { baseElement }开头的行下面添加此代码console.log(baseElement.innerHTML)。您应该在终端中看到这个 HTML 字符串:

图 6.12-日志:结果测试 HTML

it("receive input text", () => {
        const username = "testuser";        
        const { getByTestId } = render(<DisplayText />);
        const input = getByTestId("user-input");
        fireEvent.change(input, { target: { value:           username } });
        expect(input).toBeInTheDocument();
        expect(input).toHaveValue(username);
    });
  1. 现在,让我们创建另一个测试,以显示我们组件的端到端测试。在第二个it函数之后添加以下代码:
it("shows welcome message", () => {
        const testuser = "testuser";
        const msg = `Welcome to React testing,           ${testuser}`;
        const { getByTestId } = render(<DisplayText />);
        const input = getByTestId("user-input");
        const label = getByTestId("final-msg");
        fireEvent.change(input, { target: { value:           testuser } });
        const btn = getByTestId("input-submit");
        fireEvent.click(btn);

        expect(label).toBeInTheDocument();
        expect(label.innerHTML).toBe(msg);
    });

这个测试类似于我们的第二个测试,它在我们的input中添加了一个值,然后继续获取我们的button,然后获取我们的label。然后创建一个click事件来模拟按下按钮,在常规代码中,这会导致我们的label被我们的欢迎消息填充。然后测试我们label的内容。同样,一旦保存了这个文件,我们的测试应该重新运行,所有测试都应该通过。

  1. 现在,让我们也看看快照。显然,React 开发的一个重要部分不仅是我们应用程序中可用的行为或操作,还有我们向用户呈现的实际 UI。因此,通过快照测试,我们能够检查组件确实创建了所需的 UI,HTML 元素。让我们在“呈现无崩溃”测试之后的测试中添加此代码:
it("matches snapshot", () => {
        const { baseElement } = render(<DisplayText />);
        expect(baseElement).toMatchSnapshot();
    });

正如您所看到的,我们的render函数设置为通过使用baseElement属性返回DisplayText组件的最根元素。此外,我们可以看到我们有一个名为toMatchSnapshot的新expect函数。此函数执行了一些操作:

  • 第一次运行时,它会在我们的src文件夹的根目录下创建一个名为__snapshot__的文件夹。

  • 然后,它添加或更新一个与我们的测试文件同名且以扩展名.snap结尾的文件。因此,在这种情况下,我们的测试文件快照文件将是DisplayText.test.tsx.snap

此快照文件的内容是我们组件的发出 HTML 元素。因此,您拥有的快照应该看起来像这样:

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test DisplayText matches snapshot 1`] = `
<body>
  <div>
    <form>
      <div>
        <label>
          Enter your name
        </label>
      </div>
      <div>
        <input
          data-testid="user-input"
          value=""
        />
      </div>
      <div>
        <button
          data-testid="input-submit"
        >
          Show Message
        </button>
      </div>
      <div>
        <label
          data-testid="final-msg"
        />
      </div>
    </form>
  </div>
</body>
`;

正如您所看到的,这是我们期望的 HTML 的精确副本,由我们的DisplayText组件发出。还要注意给出的描述以及指示它是“快照 1”。随着您的添加,编号将递增。

  1. 好的,现在我们有了一个快照,我们的第一次测试运行成功了。让我们看看如果我们改变我们的DisplayText JSX 会发生什么。更新DisplayText.tsx文件,而不是您的测试文件,就像这样(为了简洁起见,我只会显示组件定义):
const DisplayText = () => {
    const [txt, setTxt] = useState("");
    const [msg, setMsg] = useState("");
    const onChangeTxt = (e: React.     ChangeEvent<HTMLInputElement>)
      => {
        setTxt(e.target.value);
    }
    const onClickShowMsg = (e: 
      React.MouseEvent<HTMLButtonElement, MouseEvent>) =>      {
        e.preventDefault();
        setMsg(`Welcome to React testing, ${txt}`);
    }

前面的代码保持完全相同,但是在return中,我们添加了一个虚拟的div标签,如下所示:

    return (
        <form>
            <div>
                <label>Enter your name</label>
            </div>
            <div>
                <input data-testid="user-input"                 value={txt} 
                  onChange={onChangeTxt} />
            </div>
            <div>
                <button data-testid="input-submit" 
                 onClick={onClickShowMsg}>Show                   Message</button>
            </div>
            <div>
                <label data-testid="final-msg" >{msg}                    </label>
            </div>
            DisplayText component UI? In this case, we can force a snapshot update by entering the u character under the w character. If this does not work for you, just stop and restart your test. This is what the Watch Usage list looks like:![Figure 6.14 – Watch Usage list    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_6.14_B15508.jpg)Figure 6.14 – Watch Usage list
  1. 在选择u之后,我们的快照文件应该成功更新,我们的快照测试应该完成。如果您打开本地快照文件,您应该看到我们之前添加到组件中的相同的新div标签。

所以现在我们已经看到了一些简单的测试,帮助我们入门。接下来,我们将介绍模拟的主题。

模拟

模拟就是用默认值替换测试中的特定功能。模拟的一个例子可能是假装进行网络调用,而实际上返回一个硬编码的值。我们这样做的原因是我们只想测试单个单元或代码的一小部分。通过模拟一些与我们正在测试的内容无关的代码部分,我们避免了混淆,并确保我们的测试始终有效。例如,如果我们试图测试代码中的输入,我们不希望网络调用失败影响该测试的结果,因为网络调用与输入元素无关。当我们想进行端到端测试或集成测试时,我们可以担心网络调用。但这与单元测试是不同的(在一些团队中,集成测试由 QA 团队单独处理),我们在这里不涉及它。现在,当涉及到 React 组件时,testing-library 实际上建议不要模拟,因为这实际上使我们的测试不太像实际代码。话虽如此,有时模拟仍然是有帮助的,所以我将展示如何模拟组件。

使用 jest.fn 进行模拟

让我们学习使用 Jest 进行模拟,因为它也与 Node 开发一起使用。在 Jest 中进行模拟的第一种方法是使用fn模拟特定函数。这个函数接受另一个函数作为参数,这个函数将执行您需要执行的任何操作来设置您想要的模拟。但除了替换任意现有代码和值的能力之外,创建模拟还将使您可以访问一个名为mock的成员。这个成员提供了有关您的模拟调用的指标。这很难概念化,所以让我们创建一个例子:

  1. 让我们更新我们的DisplayText组件,以便向 Web API 发出网络调用。我们将使用DisplayText,它是一个根据用户名返回用户全名的函数。我们需要首先更新App函数文件如下:
function App() {
  const getUserFullname = async (username: string):   Promise<string> => {
    getUserFullname and then passing that as a property to our DisplayText component. As you can see, it is based on a network call to the web API of JsonPlaceholder. It calls into the users collection and then it filters the collection using the find array function. The result will get a user's full name from their username by calling userByName.name.
  1. 现在,让我们看看更新的DisplayText组件代码:
import React, { useState, FC } from "react";
DisplayTextProps to house our getUserFullname function. This function is being passed in as a prop from our App component. And then we use that function within the onClickShowMsg event handler to show the welcome message with the user's full name:

返回(

<input data-testid="user-input"                   value=

onChange= />

<button data-testid="input-submit"

onClick=>显示消息</                  按钮>

)

}

export default DisplayText;


The rest of the code is the same but is shown for completeness. So then, now if we run our app, we should see something like this:

图 6.15 - 用户的全名

图 6.15 - 用户的全名

如您所见,具有用户名bret的用户的全名为Leanne Graham

现在让我们编写我们的测试,并使用 Jest 模拟我们的网络调用:

  1. 打开DisplayText.test.tsx,注意到我们所有的测试都失败了,因为它们都没有新属性getUserFullname。所以,让我们更新我们的测试并模拟这个函数。以下是新的测试:
import React from 'react';
import { render, fireEvent, cleanup, wait from @testing-library/react. This is to handle asynchronous calls within our test items. For example, getUserFullname is an asynchronous call and so we need to await it. But if we do not await it, our test will fail because it will not have waited for the call to finish before moving to the next step: 

afterEach(cleanup);

userFullName 和 getUserFullnameMock。由于我们将在几个测试中运行我们的模拟函数,我们创建了 getUserFullnameMock 函数,以便我们可以重复使用它来给我们提供 getUserFullname 模拟函数和其他一些需要的项目。但问题可能是为什么它们看起来这么复杂?让我们浏览一下代码,弄清楚它在做什么:*在设置userFullName变量之后,我们创建了getUserFullnameMock函数。正如你所看到的,getUserFullnameMock函数接受一个username作为参数,就像真正的getUserFullname函数一样,并返回一个promise和一个Mock对象。*在getUserFullnameMock内部,定义实例化了一个promise对象,并使用jest.fn来模拟我们的getUserFullname函数。我们需要一个 promise 来模拟网络调用,并且稍后使用 testing-library 的wait调用来等待它。*如前所述,jest.fn用于实例化一个模拟,并让模拟执行我们可能需要的任何操作。在这种情况下,由于我们正在模拟的getUserFullname函数正在进行网络调用,我们需要让我们的jest.fn模拟返回一个 promise。它通过返回我们在上一行创建的promise来实现这一点。*最后,promise和新的模拟函数getUserFullname都被返回。*我们在这里做了很多工作,但在这种情况下,消除慢速和容易出错的网络调用是一个好主意。否则,如果网络调用失败,我们可能会错误地认为我们的测试和代码失败了。*接下来,让我们看看我们的模拟在测试中是如何使用的:

     it("renders without crashing", () => {
        const username = "testuser";
        getUserFullname function and pass it as a property to DisplayText. They don't otherwise use it, but it's still needed since it's a required property of DisplayText.

  1. 最后一个测试已更新,因为它测试了欢迎消息。像这样更新你的最后一个测试:
    it("shows welcome message", async () => {
        const username = "testuser";
        getUserFullname function provides the user's fullname and that is fed into the welcome message that's shown in our label. In order to test that, we do an assertion with expect and toBe. Additionally, notice the await wait call just above toBe. This call must run first because our getUserFullname function is an async function and needs therefore to be awaited in order to get its results.

因此,通过使用jest.fn,我们可以模拟出一段代码,以便它可以给我们一个一致的值。同样,这有助于我们创建一致、可重现的测试,我们只测试特定的代码单元。

组件模拟

第二种模拟的形式是完全替换整个组件,并在我们想要测试其他代码时使用它们代替真实组件。为了测试这个,按照这里给出的步骤进行:

  1. 让我们的DisplayText组件根据插入的用户名显示用户待办事项列表。更新组件如下:
import React, { useState, FC } from "react";
interface DisplayTextProps {
    getUserFullname: (username: string) =>       Promise<string>;
}
const DisplayText: FC<DisplayTextProps> = ({ getUserFullname })
  => {
    const [txt, setTxt] = useState("");
    const [msg, setMsg] = useState("");
    const [todos, setTodos] = useState<Array<JSX.     Element>>();

在这里,我们创建了一些稍后使用的状态:

    const onChangeTxt = (e: React.      ChangeEvent<HTMLInputElement>)
      => {
        setTxt(e.target.value);
    }

在这里,我们使用用户提供的用户名的值更新我们的输入:

    const onClickShowMsg = async (e: 
      React.MouseEvent<HTMLButtonElement, MouseEvent>) =>         {
        e.preventDefault();
        setMsg(`Welcome to React testing, ${await 
         getUserFullname(txt)}`);  
        setUsersTodos();      
    }   

一旦单击显示消息按钮,我们就会更新要显示的消息以及要显示的待办事项列表。

  1. 我们将接受一个属性作为我们的消息前缀使用:
const setUsersTodos = async () => {
        const usersResponse = await 
          fetch('https://jsonplaceholder.typicode.com/          users');
        if(usersResponse.ok) {
            const users = await usersResponse.json();
            const userByName = users.find((usr: any) => {
                return usr.username.toLowerCase() ===                    txt;
            });
            console.log("user by username", userByName);

类似于我们通过使用他们的username获取用户的fullname,我们通过调用 JSONPlaceholder API 来获取用户的待办事项列表。首先,我们通过调用用户集合来找到用户:

            const todosResponse = await  
             fetch('https://jsonplaceholder.typicode.com/              todos');
            if(todosResponse.ok) {
                const todos = await todosResponse.json();
                const usersTodos = todos.filter((todo:                 any) => {
                    return todo.userId === userByName.id;
                });
                const todoList = usersTodos.map((todo:                  any) => {
                    return <li key={todo.id}>
                        {todo.title}
                    </li>
                });
                setTodos(todoList);
                console.log("user todos", usersTodos);
            }
        }
    }

然后我们调用待办事项集合,并将待办事项与先前找到的用户进行匹配。

  1. 最后,我们通过 UI 返回一个未排序的待办事项列表:
    return (
        <form>
            <div>
                <label>Enter your name</label>
            </div>
            <div>
                <input data-testid="user-input"                 value={txt} 
                  onChange={onChangeTxt} />
            </div>
            <div>
                <button data-testid="input-submit" 
                 onClick={onClickShowMsg}>Show Message</                  button>
            </div>
            <div>
                <label data-testid="final-msg" >{msg}</                label>
            </div>
            bret has any todos). Note that the text that you see is *lorem ipsum*. It is just placeholder text. It is coming straight from the JSONPlaceholder API:

图 6.16 - 用户待办事项列表

图 6.16 - 用户待办事项列表

在这里,我们显示用户 bret 的待办事项列表。

现在,假设我们想要测试我们的DisplayText组件,而不测试这个待办事项列表。我们如何重构这段代码,使得我们的测试不会那么庞大?让我们重构我们的DisplayText组件,并将待办事项功能提取为自己的组件:

  1. 像这样更新DisplayText文件:
import React, { useState, FC } from "react";
import UserTodos from "./UserTodos";
interface DisplayTextProps {
    getUserFullname: (username: string) =>       Promise<string>;
}
const DisplayText: FC<DisplayTextProps> = ({ getUserFullname }) => {
    const [txt, setTxt] = useState("");
    const [msg, setMsg] = useState("");
    todoControl. The type of this state is the type of our new UserTodos component, which we'll show later. We've gotten this type by using the utility type ReturnType. As you can see, it is a simple way of creating a type definition by using an object: 

const onClickShowMsg = async (e:

React.MouseEvent<HTMLButtonElement, MouseEvent>) ⇒         {

e.preventDefault();

setTodoControl(null);

setMsg(`欢迎来到 React 测试,${await

getUserFullname(txt)}`);

onClickShowMsg 事件处理程序将调用 setTodoControl 并将我们的 UserTodos 组件传递给用户名:

    return (
        <form>
            <div>
                <label>Enter your name</label>
            </div>
            <div>
                <input data-testid="user-input"                 value={txt} 
                  onChange={onChangeTxt} />
            </div>
            <div>
                <button data-testid="input-submit" 
                 onClick={onClickShowMsg}>Show Message</                    button>
            </div>
            <div>
                <label data-testid="final-msg" >{msg}</                label>
            </div>    
            todoControl gets displayed with our UI.

  1. 现在让我们创建我们的新UserTodos组件。创建一个名为UserTodos.tsx的文件,并添加以下代码:
import React, { FC, useState, useEffect } from 'react';
interface UserTodosProps {
    username: string;
}

我们现在从父级获取用户名作为一个属性:

const UserTodos: FC<UserTodosProps> = ({ username }) => {
    const [todos, setTodos] = useState<Array<JSX.      Element>>();
    const setUsersTodos = async () => {
        const usersResponse = await 
         fetch('https://jsonplaceholder.typicode.com/          users');
        if(usersResponse) {
            const users = await usersResponse.json();
            const userByName = users.find((usr: any) => {
                return usr.username.toLowerCase() ===                  username;
            });
            console.log("user by username", userByName);

首先,我们再次从用户集合中获取我们的用户,并过滤以找到我们的一个用户,通过匹配username

            const todosResponse = await 
             fetch('https://jsonplaceholder.typicode.com/             todos');
            if(userByName && todosResponse) {
                const todos = await todosResponse.json();
                const usersTodos = todos.filter((todo:                 any) => {
                    return todo.userId === userByName.id;
                });
                const todoList = usersTodos.map((todo:                 any) => {
                    return <li key={todo.id}>
                        {todo.title}
                    </li>
                });
                setTodos(todoList);
                console.log("user todos", usersTodos);
            }
        }
    }

然后我们获取找到用户的匹配待办事项。然后我们运行 JavaScript 的map函数为每个待办事项创建一个li元素的集合:

    useEffect(() => {
        if(username) {
        setUsersTodos();
        }
    }, [username]);

通过使用useEffect,我们表明每当我们的username属性发生变化时,我们都希望更新我们的待办事项列表:

    return <ul style={{marginTop: '1rem', listStyleType: 
     'none'}}>
        {todos}
    </ul>;
}
export default UserTodos;

最后,我们将我们的待办事项输出为无序列表元素。如果你运行这段代码,当你点击显示消息时,你应该会看到这个:

图 6.17 – 重构后的待办事项

图 6.17 – 重构后的待办事项

好的,现在我们可以添加一个新的测试,模拟我们的UserTodos组件,从而允许独立测试DisplayText。还要注意,使用 Jest 有两种主要的模拟方式。我们可以进行内联调用来模拟,也可以使用一个模拟文件。在这个例子中,我们将使用一个模拟文件。让我们看看步骤:

  1. src文件夹中,创建一个新文件夹__mocks__。在该文件夹中,创建一个名为UserTodos.tsx的文件,并将以下代码添加到其中:
import React, { ReactElement } from 'react';
export default (): ReactElement => {
    return <></>;
  };

这个文件将是函数组件的模拟版本。正如你所看到的,它什么也不返回,也没有真正的成员。这意味着与真实组件不同,它不会进行任何网络调用或发出任何 HTML,这对于测试来说是我们想要的。

  1. 现在让我们用以下代码更新DisplayText.test.tsx
import React from 'react';
import { render, fireEvent, cleanup, wait } from '@testing-library/react';
import DisplayText from './DisplayText';
import "@testing-library/jest-dom/extend-expect";
jest.mock("./UserTodos");
afterEach(cleanup);
describe("Test DisplayText", () => {
    const userFullName = "John Tester";

    const getUserFullnameMock = (username: string): 
    [Promise<string>, jest.Mock<Promise<string>,         [string]>] => {        
        const promise = new Promise<string>((res, rej) => {
            res(userFullName);
        });
        const getUserFullname = jest.fn(async (username:          string):
          Promise<string> => {             
            return promise;
        });
        return [promise, getUserFullname];
    }

首先,我们可以看到我们在任何测试之外导入了我们的模拟UserTodos组件。这是必要的,因为在测试内部这样做是行不通的。

其余的测试都是一样的,但现在它们内部使用UserTodos的模拟。因此,由于没有网络调用,测试运行得更快。作为对你新学到的测试技能的试验,尝试单独为UserTodos组件创建你自己的测试。

在本节中,我们学习了使用 Jest 和 testing-library 测试 React 应用程序。单元测试是应用程序开发的一个非常重要的部分,作为专业程序员,你几乎每天都会编写测试。它可以帮助编写和重构代码。

在接下来的部分,我们将继续通过讨论在 React 应用程序开发中常用的工具来增加我们的开发者技能。

学习 React 开发的常用工具和实践

有许多工具可以帮助编写 React 应用程序。它们太多了,无法详尽列举,但我们将在这里回顾一些最常见的。这些工具对于编写和调试你的代码至关重要,所以你应该花一些时间熟悉它们。

VS Code

在整本书中,我们一直使用 VS Code 作为我们的代码编辑器。对于 JavaScript 开发,VS Code 显然是目前使用最广泛的编辑器。以下是一些你应该知道的事实,以便最大限度地利用 VS Code:

  • VS Code 有一个庞大的扩展生态系统,可以帮助编码。其中许多依赖于开发者的偏好,所以你应该快速搜索并查看一下。然而,以下是一些你应该考虑使用的常见扩展:

Visual Studio IntelliCode:提供了一个基于人工智能驱动的代码完成和语法高亮的语言服务。

阿波罗 GraphQL:GraphQL 的代码完成和格式化助手。

与 React 相关的插件:有许多与 React 相关的插件,可以通过提供代码片段或将 Hooks 集成到 NPM 等服务来帮助。以下只是其中一些:

图 6.18 – React VS Code 插件

图 6.18 – React VS Code 插件

  • VS Code 有一个内置的调试器,允许你在代码上中断(停止)并查看变量值。我不会在这里演示它,因为前端开发的标准是使用 Chrome 调试器,它也允许在代码上中断,但一旦我们开始使用 Node,我会演示它。

  • 配置文件:在 VS Code 中,有两种设置项目偏好的方式,一个是工作区,另一个是settings.json文件。关于字体、扩展、窗口等方面,VS Code 有大量的配置方式。这些配置可以在全局范围内进行,也可以在每个项目中进行。我在ejected-app项目中包含了一个.vscode/settings.json文件,用于演示目的。工作区文件基本上与设置文件相同,只是它们用于在单个文件夹中使用多个项目。工作区文件的命名为<name>.code-workspace

Prettier

在编写代码时,使用一致的风格非常重要,以提高可读性。例如,如果想象一个有许多开发人员的大团队,如果他们每个人都以自己的风格编写代码,采用不同的缩进方式、变量命名等,那将是一团混乱。此外,有行业标准的 JavaScript 格式化方式可以使其更易读,因此更易理解。这就是 Prettier 等工具提供的功能。

Prettier 将在每次保存时自动将您的代码格式化为一致且可读的格式,无论是谁在编写代码。只需记住,在安装 Prettier 后,您需要设置settings.json或您的工作区文件来使用它。同样,我在我们的ejected-app项目中包含了一个示例settings.json文件。

Chrome 调试器

Chrome 浏览器提供了用于 Web 开发的内置工具。这些工具包括查看页面的所有 HTML、查看控制台消息、在 JavaScript 代码上中断以及查看浏览器所做的网络调用。即使没有任何插件,它也非常广泛。对于许多前端开发人员来说,Chrome 是调试代码的主要工具。

让我们来看看ejected-app的调试器,并学习一些基础知识:

  1. 如果您的本地ejected-app实例没有运行,请重新启动它,并打开您的 Chrome 浏览器到默认的localhost:3000 URL。一旦到达那里,通过按下F12键或转到root div标签打开您的 Chrome 调试器,那里是我们应用程序的其余部分。在这个截图中,我们可以看到我们已经调用 Web API 来获取用户Bret的待办事项。因此,我们可以使用 Chrome 调试器来找到我们的 HTML 元素,检查它们的属性,并调整 CSS 值,使我们的 UI 精确地符合我们的要求。

  2. 接下来,转到控制台选项卡,您应该会看到类似于这样的内容:图 6.20:Chrome 调试器控制台选项卡

图 6.20:Chrome 调试器控制台选项卡

所以,在这里,我们可以检查变量和函数返回数据的值,确保它们是我们想要的并且符合预期。

  1. 使用 Chrome 调试器,可以在运行代码时中断。打开UserTodos.tsx文件,然后添加如下所示的断点:图 6.21 - Chrome 调试器源选项卡

图 6.21 - Chrome 调试器源选项卡

正如你所看到的,我们能够在我们的断点上停下来,这是由行 30旁边的点所指示的。如果你悬停在某些变量上,你将能够看到它们当前的值,即使它们包含其他组件等对象。这是一个在代码调试中非常有用的功能。这个功能是由一种叫做源映射的东西所启用的。源映射是将源代码映射或绑定到缩小后的运行时代码的文件。它们在开发时被创建并发送到浏览器,允许在运行时断点和查看变量值。

  1. 现在让我们移除断点,转到网络选项卡。这个选项卡显示了浏览器所做的所有网络连接。这不仅包括对网络资源(如数据)的调用,还可以包括获取图像或静态文件(如 HTML 文件)的调用。如果我们打开这个选项卡,然后进行调用以获取用户 Bret 的待办事项,我们应该会看到这个:

图 6.22 - Chrome 调试器网络选项卡

图 6.22 – Chrome 调试器网络选项卡

正如你所看到的,我们可以查看从 Web API 调用返回的所有数据。这是一个方便的工具,可以让我们比较来自我们网络资源的数据,并将其与我们的代码似乎正在使用的数据进行比较。当我们进行 GraphQL 调用时,我们也将在以后使用这个工具。

好的,这是对 Chrome 调试器的快速概述,但 Chrome 还提供了能够提供 React 特定帮助的扩展。React 开发者工具提供有关我们组件层次结构和每个组件的属性信息;例如,这是我们应用程序中的一个示例:

图 6.23 – React 开发者工具

图 6.23 – React 开发者工具

正如你所看到的,这个工具显示了我们的组件层次结构,并显示了当前选定组件的属性。当我们在层次结构中选择特定组件时,它还会在屏幕上显示组成我们组件的元素的高亮显示。这是一个方便的工具,可以从 React 组件结构的角度查看我们的元素,而不是 HTML 结构。Chrome 生态系统的扩展非常广泛,还有针对 Redux 和 Apollo GraphQL 的扩展。我们将在第八章中探索这些,使用 Node.js 和 Express 学习服务器端开发,以及第九章中,什么是 GraphQL?

替代 IDE

在本书中,我们使用 VS Code 作为我们的代码编辑器。它运行良好,并已成为最受欢迎的 JavaScript 和 TypeScript 编辑器。但是,你没有理由非要使用它。你应该知道还有其他选择。我只会在这里列出其中一些,这样你就知道一些选项:

  • Atom:除了 VS Code 之后可能是最受欢迎的免费编辑器。

  • Sublime Text:更快速、更响应的编辑器之一。也有免费版本。

  • Vim:Unix 文本编辑器,通常用于编辑代码。

  • Webstorm:来自 JetBrains 的商业编辑器。

尝试一些这些编辑器,因为拥有一个好的代码编辑器肯定可以提高你的生产力。

本节回顾了 React 开发中一些常用的工具。虽然这些工具并不是我们应用程序编写代码的主要工具,但它们对于帮助我们更快速、更高质量地编写代码至关重要。它们还将减少我们编写代码时的痛点,因为找到错误通常与解决错误一样具有挑战性。

总结

在本章中,我们了解了许多专业前端开发人员用来帮助编写高质量代码的工具。无论是用于编写代码的 VS Code 编辑器,还是用于共享代码的源代码存储库 Git,这里提到的所有工具在前端工程师的工作中都至关重要。

通过了解这些工具,你将成为一个更好的程序员,你的代码质量将大大提高。此外,作为开发人员,你的生活质量也会提高,因为这些工具中的许多工具可以帮助你更快速地跟踪问题,并帮助你比完全靠自己解决问题更容易地解决问题。

在下一章中,我们将通过学习 Redux 和 React Router 来扩展我们对 React 的了解。Redux 将帮助我们管理全局状态,而 React Router 将帮助我们创建客户端 URL。这两个框架在 React 社区中非常受欢迎,并提供许多功能,将帮助我们创建一个更复杂、更有能力的应用程序。

第七章:学习 Redux 和 React Router

在本章中,我们将学习 Redux 和 React Router。Redux 仍然是管理 React 应用程序中共享的全局状态的最常见方法。使用 Redux 全局状态,我们可以减少大量样板代码并简化应用程序。React Router 也是管理客户端 URL 路由的最流行框架。客户端 URL 路由允许 SPA 应用程序以用户期望的经典样式 Web 应用程序的方式行为。这两种技术对于构建外观和感觉像标准 Web 应用程序的 SPA 应用程序是必不可少的。

在本章中,我们将涵盖以下主要主题:

  • 学习 Redux 状态

  • 学习 React Router

技术要求

您应该对使用 React 进行 Web 开发有基本的了解。我们将再次使用 Node 和 Visual Studio Code。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap7文件夹中的代码。

要设置本章的代码文件夹,请转到您的HandsOnTypescript文件夹并创建一个名为Chap7的新文件夹。

学习 Redux 状态

Redux 仍然是在 React 应用程序中创建和管理全局状态的最流行的企业级框架(尽管我们可以在任何 JavaScript 应用程序中使用 Redux,而不仅仅是 React)。许多新的框架已经被创建,其中一些已经获得了相当大的追随者;然而,Redux 仍然是最常用的。您可能会发现一开始很难理解 Redux。然而,一旦我们学会了它,我们将看到它的许多好处,以及为什么它经常成为大型复杂 React 应用程序的首选框架。

我们在第四章中学习了 React 状态,学习单页应用程序概念以及 React 如何实现它们,以及第五章使用 Hooks 进行 React 开发。因此,再次强调,状态或组件的数据是 React 中所有 UI 更改的主要驱动程序。这就是为什么 React 框架的名称中有"react"一词的原因,因为它对这些状态变化做出反应(这也被称为响应式)。因此,在创建和管理状态时,我们通常希望大部分时间将本地状态与组件或组件的根父级关联起来。

基于组件的状态可能有限。有些情况下,状态不仅适用于一个组件,甚至不适用于组件层次结构。状态有时可能对多个组件或构成应用程序的其他非组件服务是必要的。除此之外,在 React 中,状态只能单向传递,从父级向子级传递作为 props。不应该向上游传递。这进一步限制了 React 中状态的使用。因此,Redux 不仅提供了一种在全局共享状态的机制,还允许根据需要从任何组件注入和更新状态。

让我们举个例子来详细说明一下。在典型的企业级应用程序中,我们总是会有身份验证。一旦用户经过身份验证,我们可能会收到关于用户的某些数据 - 例如,用户的全名、用户 ID、电子邮件等。因此,认为这些数据点可能被应用程序中的大部分组件使用并不是不合理的。因此,让每个组件调用以获取这些数据,然后在它们自己的状态中保存它,这样做将是乏味且容易出错的。这样做意味着数据会有多个副本,并且随着数据的更改,一些组件可能会保留旧版本的数据。

这种冲突可能是 bug 的来源。因此,能够在客户端的一个地方维护这些数据并与需要它的任何组件共享将是有帮助的。这样,如果这些数据有更新,我们可以确保所有组件,无论在应用程序的哪个部分,都能获得最新的有效数据。这就是 Redux 可以为我们的应用程序做的事情。我们可以把它看作是唯一的真相源

Redux 是一个数据存储服务,它在我们的 React 应用程序中维护所有全局共享的数据。Redux 不仅提供存储本身,还提供了添加、删除和共享这些数据所需的基本功能。然而,与 React 状态的一个不同之处是,Redux 状态不一定会触发 UI 更新。如果我们希望这样做,它当然可以,但并不一定需要这样做。因此,我们应该记住这一点。

让我们看看如何设置 Redux:

  1. Chap7文件夹中创建一个新的 React 项目,如下所示:
create-react-app redux-sample --template typescript
  1. 一旦我们的项目设置好了,打开它并使用命令行cdredux-sample文件夹。

  2. 我们现在将安装 Redux,实际上是几个不同的依赖项。首先,运行这个命令:

npm i redux react-redux @types/redux @types/react-redux

这个命令给我们主要的依赖项,包括 TypeScript 类型。

好的,现在我们已经完成了一些基本设置,我们需要在继续之前了解一些关于 Redux 的更多内容。Redux 使用了一对叫做 reducers 和 actions 的概念。让我们看看它们各自的作用。

Reducers 和 actions

在 Redux 中,所有数据只有一个单一的存储。因此,我们所有的全局数据都将存在于一个 Redux 对象中。现在,这种设计的问题是,由于这是全局状态,不同的应用程序功能将需要不同类型的数据,而整个数据并不总是与应用程序的所有部分相关。因此,Redux 的创建者提出了一种方案,使用 reducers 来过滤和拆分单一存储为分离的块。因此,如果组件 A 只需要特定的数据片段,它就不必处理整个存储。

这种设计是分离数据关注点的好方法。但这种设计的副作用是,我们需要一种更新相关数据部分而不影响其他部分的方法。这就是 actions 的作用。Actions 是提供特定 reducer 数据的对象。

现在我们已经对 reducers 和 actions 有了一个高层次的了解,让我们在代码中看一些例子:

  1. src下创建一个名为store的新文件夹。

  2. 然后,创建一个名为AppState.ts的文件。这个文件将存储我们的聚合 reducer 对象rootReducer,类型为AppState,它代表了全局状态。将以下代码插入文件中:

import { combineReducers } from "redux";
export const rootReducer = combineReducers({
});
export type AppState = ReturnType<typeof rootReducer>;

rootReducer代表了我们所有 reducer 的聚合对象。我们还没有任何 reducer,但是一旦我们的设置完成,我们将添加实际的 reducer。combineReducers接受我们的每个 reducer,并将它们组合成一个单一的对象。在底部,我们使用ReturnType 实用类型基于我们的rootReducer创建了一个 TypeScript 类型,然后导出了新类型AppState

注意

实用类型只是 TypeScript 团队创建的一个帮助类,用于提供特定功能。有许多不同的实用类型,可以在这里找到列表:www.typescriptlang.org/docs/handbook/utility-types.html

  1. 接下来,我们创建一个名为configureStore.ts的文件,其中包含了 Redux 和应用程序使用的实际存储对象。它应该是这样的:
import { createStore } from "redux";
import { rootReducer } from "./AppState";
const configureStore = () => {
  return createStore(rootReducer, {});
};
export default configureStore;

正如我们所看到的,Redux 的createStore方法用于基于我们的AppState对象rootReducer构建实际的存储。configureStore被导出并稍后用于执行存储的创建。

  1. 现在,我们必须更新我们的index.tsx文件,调用我们的configureStore方法并为我们的应用程序初始化 Redux。像这样更新index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { Provider } from "react-redux";
import configureStore from "./store/configureStore";
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
  <React.StrictMode>
    <Provider store={configureStore()}>
    <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

首先,我们从react-redux中导入ProviderProvider是一个 React 组件,作为所有其他组件的父组件,并且提供我们的存储数据。此外,如所示,Provider接收了通过接受configureStore函数的返回值来初始化的存储:

// If you want your app to work offline and load faster, 
  // you can change
// unregister() to register() below. Note this comes with 
  // some pitfalls.
// Learn more about service workers: 
  // https://bit.ly/CRA-PWA
serviceWorker.unregister();

这些注释的代码来自create-react-app项目。这里包含它是为了完整性。好的,现在我们已经建立了 Redux 的基本设置。因此,我们的示例将继续通过创建一个调用来获取用户对象。我们将使用我们在第六章中学到的 JSONPlaceholder API,使用 create-react-app 设置我们的项目并使用 Jest 进行测试。成功登录后,它通过将用户信息放入 Redux 作为 reducer 来共享用户信息。现在让我们来做这个:

  1. 创建一个名为UserReducer.ts的新文件,放在store文件夹中,像这样:
export const USER_TYPE = "USER_TYPE";

我们首先创建一个名为USER_TYPE的 action 类型的常量。这是可选的,但有助于我们避免诸如拼写错误之类的问题:

export interface User {
    id: string;
    username: string;
    email: string;
    city: string;
}

然后,我们创建一个表示我们的User的类型:

export interface UserAction {
    type: string;
    payload: User | null;
}

现在,按照惯例,一个 action 有两个成员:类型和有效负载。因此,我们创建了一个UserAction类型,其中包含这些成员:

export const UserReducer = ( state: User | null = null, action: 
  UserAction): User | null => {
    switch(action.type) {
        case USER_TYPE:
            console.log("user reducer", action.payload);
            return action.payload;
        default:
            return state;
    }
};

然后,最后,我们创建了名为UserReducer的 reducer。reducer 始终接受stateaction参数。请注意,state并不是整个状态,它只是与某个 reducer 相关的部分状态。这个 reducer 将根据action类型知道传入的state是否属于它自己。还要注意,原始状态永远不会被改变。这一点非常重要。绝对不要直接改变状态。你应该要么返回原状态,这在case default中完成,要么返回其他数据。在这种情况下,我们返回action.payload

  1. 现在,我们必须回到我们的AppState.ts文件中,添加这个新的 reducer。文件现在应该是这样的:
import { combineReducers } from "redux";
import { UserReducer } from "./UserReducer";
export const rootReducer = combineReducers({
  user, which is updated by UserReducer. If we had more reducers, we would simply give them a name and add them below user with their reducer, and the combineReducers Redux function would combine all of them into a single aggregate rootReducer.
  1. 现在,让我们开始使用我们的新状态。像这样更新App.tsx文件:
import React, { useState } from 'react';
import ContextTester from './ContextTester';
import './App.css';
function App() {
  const [userid, setUserid] = useState(0);
  const onChangeUserId = (e: React.   ChangeEvent<HTMLInputElement>)
   => {
    console.log("userid", e.target.value);
    setUserid(e.target.value ? Number(e.target.value) :      0);
  }
  return (
    <div className="App">
      <label>user id</label>
      <input value={userid} onChange={onChangeUserId} />
    </div>
  );
}
export default App;

我们将以userid作为参数,然后根据该 ID 从 JSON Placeholder API 中获取关联的用户。为了做到这一点,我们需要使用一些 Redux 特定的 Hooks,这样我们就可以将我们找到的用户添加到 Redux 存储中。

  1. 让我们像这样更新App组件中的App.tsx
function App() {
  const [userid, setUserid] = useState(0);
dispatch. We get an instance of dispatch with the useDispatch Hook. dispatch is a Redux function that sends our action data to Redux. Redux then sends the action to each of our reducers for processing. Then the reducer that recognizes the action type accepts it as its state payload:

通过onChangeUserId处理程序,我们调用 JSONPlaceholder API。然后我们使用usersResponse响应对象从我们的网络 API 中获取结果。然后我们通过筛选从 UI 中获取的用户 ID 来获取我们想要的用户。然后我们使用 dispatch 将我们的 action 发送给我们的 reducer。还要注意onChangeUserId现在是一个异步函数:

  }
  return (
    <div className="App">
      <label>user id</label>
      <input value={userid} onChange={onChangeUserId} />
    </div>
  );
}

这个 UI 将以userid作为输入。


现在,让我们创建一个子组件,可以显示我们所有与用户相关的数据:

  1. 创建一个名为UserDisplay.tsx的新组件,并添加这段代码:
import React from 'react';
import { AppState } from './store/AppState';
import { useSelector } from 'react-redux';
const UserDisplay = () => {
    useSelector Hook gets the specific user reducer. It takes a function as a parameter and this function takes the entire aggregated reducer state and only returns the user reducer. Also in this component, we are displaying the properties of our found user but taken from Redux and the user reducer. Notice also how we return null if no user is found.
  1. 现在,让我们将UserDisplay组件添加到我们的App组件中:
import React, { useState } from 'react';
import './App.css';
import { useDispatch } from 'react-redux';
import { USER_TYPE } from './store/UserReducer';
UserDisplay component:

function App() {

const [userid, setUserid] = useState(0);

const dispatch = useDispatch();

const onChangeUserId = async (e:

React.ChangeEvent) ⇒ {

const useridFromInput = e.target.value ?

Number(e.target.value) : 0;

console.log("userid", useridFromInput);

setUserid(useridFromInput);

const usersResponse = await

fetch('https://jsonplaceholder.typicode.com/      users');

if(usersResponse.ok) {

const users = await usersResponse.json();

const usr = users.find((userItem: any) ⇒ {

return userItem && userItem.id ===         useridFromInput;

});

dispatch({

type: USER_TYPE,

payload: {

id: usr.id,

username: usr.username,

email: usr.email,

city: usr.address.city

}

});

}

}


No real changes up to here:

return (

<React.Fragment>

在返回的 JSX UI 中使用UserDisplay,这样我们的用户信息就会显示出来。


  1. 现在,如果你在浏览器中加载http://localhost:3000并在输入框中输入1,你应该会看到这个:

图 7.1 - 来自 Redux 存储的用户对象

图 7.1 – 来自 Redux 存储的用户对象

因此,现在我们已经看到了一个简单 Redux 存储用例的示例,让我们进一步展示当我们在同一个存储中有多个 reducer 时会发生什么:

  1. 创建一个名为PostDisplay.tsx的新文件,并添加以下代码。这个组件将显示来自 JSON Placeholder API 的发布评论:
import React, { useRef } from 'react';
import { AppState } from './store/AppState';
import { useSelector } from 'react-redux';
const PostDisplay = React.memo(() => {
    const renderCount = useRef(0);
    console.log("renders PostDisplay", renderCount.     current++);
    const post = useSelector((state: AppState) => state.     post);

与我们之前的示例一样,这里我们使用useSelector设置我们想要的状态数据:

    if(post) {
        return (<React.Fragment>
            <div>
                <label>title:</label>
                &nbsp;{post.title}
            </div>
            <div>
                <label>body:</label>
                &nbsp;{post.body}
            </div>
        </React.Fragment>);
    } else {
        return null;
    }
});
export default PostDisplay

如您所见,它看起来与UserDisplay非常相似,但它显示与post相关的信息,如titlebody

  1. 现在,我们更新我们的 Redux 代码以添加我们的新 reducer。首先,在store文件夹内添加一个名为PostReducer.ts的新文件,然后添加以下代码:
export const POST_TYPE = "POST_TYPE";
export interface Post {
    id: number;
    title: string;
    body: string;
}
export interface PostAction {
    type: string;
    payload: Post | null;
}
export const PostReducer = ( state: Post | null = null, 
  action: PostAction): Post | null => {
    switch(action.type) {
        case POST_TYPE:
            return action.payload;
        default:
            return state;
    }
};

同样,这与UserReducer非常相似,但专注于帖子而不是用户。

  1. 接下来,我们想要更新AppState.tsx文件,并将我们的新 reducer 添加到其中。添加以下代码:
import { combineReducers } from "redux";
import { UserReducer } from "./UserReducer";
import { PostReducer } from "./PostReducer";
export const rootReducer = combineReducers({
  user: UserReducer,
  PostReducer.
  1. 好的,现在我们将更新我们的App组件,并添加特定于从 JSON Placeholder API 中查找特定帖子的代码。使用以下代码更新App
function App() {
  const [userid, setUserid] = useState(0);
  const dispatch = useDispatch();
  const [postid, setPostId] = useState(0);

请注意,我们没有针对任何 reducer 特定的dispatch。这是因为分派程序只是通用执行函数。该操作最终将被路由到适当的 reducer。

onChangeUserId没有改变,但出于完整性,这里显示一下:

  const onChangeUserId = async (e: 
   React.ChangeEvent<HTMLInputElement>) => {
    const useridFromInput = e.target.value ? 
     Number(e.target.value) : 0;
    console.log("userid", useridFromInput);
    setUserid(useridFromInput);
    const usersResponse = await 
      fetch('https://jsonplaceholder.typicode.com/      users');
    if(usersResponse.ok) {
      const users = await usersResponse.json();

      const usr = users.find((userItem: any) => {
        return userItem && userItem.id ===          useridFromInput;
      });

      dispatch({
        type: USER_TYPE,
        payload: {
          id: usr.id,
          username: usr.username,
          email: usr.email,
          city: usr.address.city
        }
      });
    }
  }

onChangePostId是一个新的事件处理程序,用于处理与post相关的数据更改:

  const onChangePostId = async (e: 
    React.ChangeEvent<HTMLInputElement>) => {
    const postIdFromInput = e.target.value ? 
      Number(e.target.value) : 0;
    setPostId(postIdFromInput);
    const postResponse = await 
      fetch("https://jsonplaceholder.typicode.com/posts/" 
        + postIdFromInput);
    if(postResponse.ok) {
      const post = await postResponse.json();
      console.log("post", post);
      dispatch({
        type: POST_TYPE,
        payload: {
          id: post.id,
          title: post.title,
          body: post.body
        }
      })
    }
  }

OnChangePostId通过dispatch函数分派相关的action

UI 已经稍微更新以处理新的PostDisplay组件,并将其与UserDisplay组件分开:

  return (
    <React.Fragment>
      <div style={{width: "300px"}}>
        <div className="App">
          <label>user id</label>
          <input value={userid} onChange={onChangeUserId}            />
        </div>
        <UserDisplay />
      </div>
      <br/>
      <div style={{width: "300px"}}>
        <div className="App">
          <label>post id</label>
          <input value={postid} onChange={onChangePostId}             />
        </div>
        <postid, you should see an interesting thing:

图 7.2 – PostDisplay 结果

图 7.2 – PostDisplay 结果

请注意,在控制台中,当更新postid输入时,没有UserDisplay的日志。这表明 Redux 存储不直接连接到 React 渲染管道,只有与特定状态更改相关的组件才会重新渲染。这与 React Context 的行为不同,并且可以通过减少不需要的渲染来提高性能(我们将在下一节中讨论 Context)。

在本节中,我们了解了 Redux,这是在 React 中管理全局状态的最流行方式。在更大的应用程序中,我们经常会使用全局状态管理器,因为通常会发生大量的全局数据共享。在我们的应用程序中,我们将存储有关已登录用户和其他将在整个应用程序中共享的数据的信息,因此具有这种能力将是有价值的。

React Context

Context 是在 Hooks 之前推出的一个较新的功能。Context 不是一个单独的依赖项,而是内置到 React 核心中的。它允许类似于 Redux 的功能,即允许状态存储在单一源中,然后在组件之间共享,而无需手动通过组件层次结构传递 props。

从开发人员编码的角度来看,这种能力非常高效,因为它消除了从父级到其子级传递状态所需的大量样板代码。这是一个更大的 React 应用程序中可能的一组层次结构的可视化:

图 7.3 – React 组件层次结构

图 7.3 – React 组件层次结构

在这个示例图中,我们有一个单一的父组件,它有几个子组件,它在自己的 JSX 中使用。这些子组件也有它们自己的子组件,依此类推。因此,如果我们要为每个组件层次结构配置传递 props,那将是相当多的代码,特别是知道有些层次结涉及传递可能回调到某个任意父级的函数。这种类型的 prop 关系也会给开发人员带来额外的认知负担,因为他们需要考虑数据关系以及数据在组件之间的传递方式。

当适当时,React 上下文和 Redux 都是避免这种状态传递样板代码的好方法。对于较小的项目,上下文的简单性效果很好。然而,对于较大的项目,我建议不要使用上下文。

React 上下文可以有多个父提供者,这意味着可能有多个根上下文。对于更大的应用程序,这可能会令人困惑,并增加更多样板代码。此外,全局状态提供者的混合可能会令人困惑。如果团队决定同时使用 Context 和 Redux,那么我们何时使用每一个?如果我们现在同时使用两者,那么我们必须维护两种全局状态管理样式。

此外,与 Redux 不同,上下文没有 reducers 的概念。因此,上下文的所有用户将接收整个状态数据集,这在关注点分离方面不是一个好的实践。随着时间的推移,特定组件应处理哪个数据子集可能会变得令人困惑。

拥有所有状态数据对所有组件用户都可用的一个额外副作用是,即使组件实际上没有访问特定状态成员,任何上下文更改都会触发重新渲染。例如,假设上下文状态如下{ username, userage },而我们的组件只使用username。即使仅userage发生变化,它也会触发该组件的重新渲染。即使使用了memo(我们在第五章中介绍了memo),这也是正确的。让我们看一个演示这种效果的例子:

  1. index.tsx中删除React.StrictModeProvider,以避免混淆。我们稍后会把它们放回去。现在,index.tsx文件应该是这样的:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { Provider } from "react-redux";
import configureStore from "./store/configureStore";
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
    <App />
  ,
  document.getElementById('root')
);

同样,这些注释来自create-react-app,仅出于完整性而包含在这里:

// If you want your app to work offline and load faster,  
 // you can change
// unregister() to register() below. Note this comes with
  // some pitfalls.
// Learn more about service workers:   // https://bit.ly/CRA-PWA
serviceWorker.unregister();

You can also remove any unused imports to avoid triggering warning messages.

  1. 现在,创建这两个子组件,每个组件将使用我们上下文状态的一个唯一成员。首先,创建UserAgeComp.tsx组件,并添加以下代码:
import React, { useContext } from 'react';
import { TestContext } from './ContextTester';
const UserAgeComp = () => {
    const { userage } = useContext(TestContext);

    return <div>
        {userage}
    </div>
};
export default UserAgeComp;

这段代码使用对象解构来仅使用TestContextuserage成员,通过使用稍后我们将创建的useContext Hook,并仅显示它。现在,创建UserNameComp.tsx组件,并添加以下代码:

import React, { useContext, useRef } from 'react';
import { TestContext } from './ContextTester';
const UserNameComp = React.memo(() => {
    const renders = useRef(0);
    username (note, I have it commented out), but before we can show the ramifications of using Context, I wanted to show this component working as expected first. So, this component has two main features. One is a ref that counts the number of times this component was rendered, and a variable called username that gets displayed. It will also log the renders count as well in order to show when a re-render is triggered.
  1. 现在,我们需要创建一个包含上下文的父组件。创建ContextTester.tsx文件,并添加以下代码:
import React, { createContext, useState } from 'react';
import UserNameComp from './UserNameComp';
import UserAgeComp from './UserAgeComp';

在这里,我们使用createContext来创建我们的TestContext对象,它将保存我们的状态:

export const TestContext = createContext<{ username:   string, userage: number }>({ username: "",   userage:0 });
const ContextTester = () => {
    const [userage, setUserage] = useState(20);
    const [localState, setLocalState] = useState(0);
    const onClickAge = () => {
        setUserage(
            userage + 1
        );
    }
    const onClickLocalState = () => {
        setLocalState(localState + 1);
    }
    return (<React.Fragment>
        <button onClick={onClickAge}>Update age</button>        
        <TestContext.Provider value={{ username: "dave",
          userage }}>
            localState, which is incremented by the onClickLocalState handler, and the other is the renders of the two child components, UserNameComp and UserAgeComp. Notice UserNameComp, for now, lives outside of the TestContext Context component, and therefore is not affected by TestContext changes. *This is very important to note*.
  1. 现在,如果我们点击“更新年龄”或“更新本地状态”,你会发现UserNameComp中的console.log语句从未执行过。该日志语句仅在页面首次加载时执行了一次,这是应该发生的,因为UserNameComp使用了memomemo只允许在 props 更改时重新渲染)。你应该在控制台选项卡中只看到一组日志(忽略警告,因为我们很快会重新添加我们的依赖项):图 7.4 - 上下文渲染结果

图 7.4 - 上下文渲染结果

  1. 好的,现在,让我们强制UserNameComp使用我们的TestContext中的username。所以现在,UserNameComp应该是这样的:
import React, { useContext, useRef } from 'react';
import { TestContext } from './ContextTester';
const UserNameComp = React.memo(() => {
    const renders = useRef(0);
    console.log("renders UserNameComp", renders.      current++);
    UserNameComp is using the username variable from the TestContext context. It never makes use of the userage variable and you will recall username has a hardcoded value so it never changes. So, theoretically, the username state of UserNameComp never changes and therefore should not cause a re-render. Now we need to place UserNameComp inside the TestContext tag as well. We are doing this because if a component needs to use a Context's state, it must be inside that Context's tag. Edit ContextTester like so:

const ContextTester = () ⇒ {

const [userage, setUserage] = useState(20);

const [localState, setLocalState] = useState(0);

const onClickAge = () ⇒ {

setUserage(

userage + 1

);

}

const onClickLocalState = () ⇒ {

setLocalState(localState + 1);

}

返回(<React.Fragment>

<username is hardcoded to "dave" and never changes. And as you can see, UserNameComp was moved into TestContext.


  1. 现在,如果我们运行这段代码,然后多次点击按钮,我们应该看到类似这样的结果:

图 7.5 - 使用上下文时的重新渲染

图 7.5 - 使用上下文时的重新渲染

正如你所看到的,我们的UserNameComp组件不断重新渲染,即使我们只改变了localState变量。为什么会发生这种情况?TestContext是一个像任何其他 React 组件一样的组件。它不使用memo。因此,当父组件ContextTester重新渲染时,它也会重新渲染,这对于它的任何子组件都会产生连锁效应。这就是为什么UserNameComp不断重新渲染,尽管它从不使用userage变量。

因此,正如你所看到的,上下文在使用上有一些问题,我认为对于大型的 React 应用程序,如果你必须在这两者之间做出选择,使用 Redux 可能更好,尽管更复杂。

在本节中,我们学习了有关上下文的基础知识。上下文相对来说很容易学习和使用。对于较小的项目,它非常有效。然而,由于其简单的设计,对于更复杂的项目,更复杂的全局状态管理系统可能更可取。

学习 React Router

React Router 是 React 中最常用的路由框架。它相对来说很简单学习和使用。路由,正如我们在第四章中发现的,学习单页应用程序的概念以及 React 如何实现它们,在 Web 开发中是无处不在的。这是 Web 应用程序用户所期望的功能,因此学习如何在我们的 React 应用程序中使用它是一个要求。

在 React Router 中,路由只是包含我们自己应用程序组件的 React Router 组件,而这些组件又代表我们的屏幕。换句话说,React Router 中的路由是虚拟位置的逻辑表示(通过虚拟位置,我指的是一个仅仅是标签而不实际存在于任何服务器上的 URL)。React Router 中的“路由器”充当父组件,而我们的屏幕渲染组件充当子组件。仅仅通过阅读是有点难以理解的,所以让我们创建一个例子:

  1. 通过在终端中调用这个命令,在Chap7文件夹下创建一个新的 React 项目:
create-react-app try-react-router --template typescript 
  1. 一旦它完成了创建我们的项目,cd进入新的try-react-outer文件夹,然后让我们添加一些包:
dom.
  1. 现在,让我们更新我们的index.tsx文件,以便在我们的应用程序中包含根 React Router 组件。像这样更新index.tsx
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { App component, called BrowserRouter. BrowserRouter is a bit like Redux's Provider in the sense that it is a single parent component that provides various props to child components that are relevant to doing routing. We will go over these props soon, but for now, let's finish our setup of React Router.
  1. 现在,由于这个工具为我们提供了路由,我们必须设置我们的个别路由。然而,由于路由最终只是代表我们屏幕的组件的容器,让我们首先创建两个屏幕。创建一个名为ScreenA.tsx的文件,并添加以下代码:
import React from "react";
const ScreenA = () => {
  return <div>ScreenA</div>;
};
export default ScreenA;

这是一个简单的组件,在浏览器中显示ScreenA

  1. 现在,创建一个名为ScreenB.tsx的文件,并添加以下代码:
import React from "react";
const ScreenB = () => {
  return <div>ScreenB</div>;
};
export default ScreenB;

再次,这是一个简单的组件,在浏览器中显示ScreenB

  1. 现在,让我们试试我们的路由。打开App.tsx并添加以下代码:
import React from "react";
import "./App.css";
import { Switch, Route } from "react-router-dom";
import ScreenA from "./ScreenA";
import ScreenB from "./ScreenB";
function App() {
  return (
    <Switch>
      <Route exact={true} path="/" component={Switch component indicates a parent that determines which route to choose by matching the browser URL to a path property of a Route instance. For example, if we start our app and go to the "/" route (the root of our application), we should see this:

图 7.6 - 路由到 ScreenA

图 7.6 - 路由到 ScreenA

但是,如果我们要去到路由"/b",我们应该看到ScreenB,就像这样:

图 7.7 - 路由到 ScreenB

图 7.7 - 路由到 ScreenB

所以,正如我在本节开头所述,React Router 路由是 React 组件。这可能看起来很奇怪,因为它们没有可见的 UI。尽管如此,它们是父组件,除了渲染它们的子组件之外,它们自己没有 UI。

现在,我们知道当我们的应用程序首次加载时,首先运行的是index.tsx文件。这也是核心 React Router 服务所在的地方。当这个服务遇到一个 URL 时,它会查看我们的App.tsx文件中定义的路由集,并选择一个匹配的路由。一旦选择了匹配的路由,就会渲染该路由的子组件。因此,例如,具有path="/b"的路由将渲染ScreenB组件。

让我们深入了解我们的路由代码的细节。如果我们回顾一下我们的路由,我们应该看到我们的第一个路由有一个叫做exact的属性。这告诉 React Router 不要使用正则表达式来确定路由匹配,而是要寻找一个精确匹配。接下来,我们看到一个叫做path的属性,这当然是我们在根域之后的 URL 路径。这个路径默认是一个"包含"路径,意味着任何包含与path属性相同值的 URL 都将被接受,并且将呈现第一个匹配的路由,除非我们包含了exact属性。

现在,你还会注意到我们有一个叫做component的属性,它当然是指要呈现的子组件。对于简单的场景,使用这个属性是可以的。但是如果我们需要向组件传递一些额外的 props 怎么办?React Router 提供了另一个叫做render的属性,它允许我们使用所谓的渲染属性

render属性是一个以函数作为参数的属性。当父组件进行渲染时,它将在内部调用render函数。让我们看一个例子:

  1. 创建一个名为ScreenC.tsx的新组件,并在其中添加以下代码:
import React, { FC } from "react";
interface ScreenCProps {
  message: string;
}
const ScreenC: FC<ScreenCProps> = ({ message }) => {
  return <div>{message}</div>;
};
export default ScreenC;

ScreenC组件与其他组件非常相似。但是它还接收一个叫做message的 prop,并将其用作显示。让我们看看如何通过 React Router 的render属性传递这个 prop。

  1. 现在让我们更新我们的App组件,并将这个新组件作为一个路由添加进去:
import React from "react";
import "./App.css";
import { Switch, Route } from "react-router-dom";
import ScreenA from "./ScreenA";
import ScreenB from "./ScreenB";
import ScreenC from "./ScreenC";
function App() {
  const renderScreenC, and it takes props as a parameter and then passes it to the ScreenC component and then returns that component. Along with passing props, we also have it passing the string "This is Screen C" into the message property. If we had tried to use the component property of Route, there would be no way to pass the message property and so we are using the render property instead. 
  1. 接下来,我们添加一个使用render属性的新的Route,并将其传递给renderScreenC函数。如果我们去"/c"路径,我们会看到基本上与其他屏幕相同的东西,但是有我们的消息,这是屏幕 C

图 7.8 - 路由到 ScreenC

图 7.8 - 路由到 ScreenC

但是,我还包含了一个传递给组件的 props 的日志,我们可以看到诸如historylocationmatch成员等内容。你会记得我们的渲染函数renderScreenC,它的签名是(props:any) => { … }。这个props参数是由 React Router 服务的Route组件传递进来的。我们稍后会看一下这些路由属性。

所以,现在我们知道了如何通过使用render属性更好地控制我们的屏幕组件渲染,但是一个典型的 URL 也可以有传递数据到屏幕的参数。让我们看看如何在 React Router 中实现这一点:

  1. 让我们像这样更新ScreenCRoute
<Route path="/c/:userid" render={renderScreenC} />

userid字段现在是 URL 上的一个参数。

  1. 现在让我们更新我们的ScreenC组件,接受 Route props 并处理我们的新的userid参数字段:
import React, { FC } from "react";
interface ScreenCProps {
  message: string;
  props) => {
  return (
    <div>
      <div>props member without having to write them out. And now our component takes the history and match props members as its own props and it is also handling the userid field by using the match.params.userid property. Since the history object already contains location as a member, we did not add that member to our ScreenCProps interface. The screen should look like this:

图 7.9 - 带参数路由到 ScreenC

图 7.9 - 带参数路由到 ScreenC

正如你所看到的,我们的userid参数的值为1

好的,现在我们更实际地使用了 React Router,但关于 React Router 的工作方式还有另一个重要特点需要注意。React Router 基本上就像一个 URL 的堆栈。换句话说,当用户访问站点的 URL 时,他们是以线性方式进行的。他们先去 A,然后去 B,也许回到 A,然后去 C,依此类推。由此产生的结果是用户的浏览器历史可以保存为一个堆栈,用户可以前进到一个新的 URL,或者后退到先前访问过的 URL。这种浏览器行为特性在 React Router 的history对象中大多得到了维护。

所以,再次,让我们更新我们的代码,看看history对象提供的一些功能:

  1. 更新ScreenC组件如下:
import React, { FC, useEffect } from "react";
interface ScreenCProps {
  message: string;
  history: any;
  match: any;
}
const ScreenC: FC<ScreenCProps> = (props) => {
useEffect and in this function, we are waiting 3 seconds with a timer and then by using the history.push function, we are redirecting our URL to "/", which is rendered by the ScreenA component.
  1. 让我们在history对象内部使用另一个函数。再次更新ScreenC,像这样:
import React, { FC } from "react";
interface ScreenCProps {
  message: string;
  history: any;
  match: any;
}
const ScreenC: FC<ScreenCProps> = (props) => {
  const history.goBack function. In order to test this code, we need to open the web page to URL localhost:3000/b first and then go to URL localhost:3000/c/2. Your screen should then look like this:![Figure 7.10 – Routed to ScreenC with a Go back button    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_7.10_B15508.jpg)Figure 7.10 – Routed to ScreenC with a Go back button
  1. 你可以看到我们有一个名为"/b"的路由按钮。

  2. 还有一件事要回顾一下:React Router 最近添加了 Hooks 功能。因此,我们不再需要通过子组件的 props 传递路由属性;我们可以直接使用 Hooks。以下是它的样子(我已经将非 Hooks 部分作为注释保留给您):

import React, { FC } from "react";
import { useHistory, useParams } from "react-router-dom";

在这里,我们有我们的新的useHistoryuseParams Hooks 导入:

interface ScreenCProps {
  message: string;
  history: any;
  match: any;
}
const ScreenC: FC<ScreenCProps> = (props) => {
  // useEffect(() => {
  //   setTimeout(() => {
  //     props.history.push("/");
  //   }, 3000);
  // });
  const history = useHistory();
  const { userid } = useParams();

在这里,我们调用我们的useHistoryuseParams Hooks 来获取historyuserid URL 参数:

  const onClickGoback = () => {
    // props.history.goBack();
    history.goBack();
  };
  return (
    <div>
      {/* <div>{"Your id is " + props.match.params.        userid}</div>
       */}
      <div>{"Your id is " + userid}</div>
      <div>{props.message}</div>
      <div>
        <button onClick={onClickGoback}>Go back</button>
      </div>
    </div>
  );
};
export default ScreenC;

在这里,我们使用 Hooks 对象来显示与之前相同的消息。使用起来非常简单和方便。

当然,history对象和 React Router 整体还有更多功能,但这是对这些功能的一个很好的介绍,我们将在接下来的章节中开始构建我们的应用程序时使用更多这些特性。

路由是 Web 开发的重要部分。路由帮助用户了解他们在应用程序中的位置,并提供一种上下文的感觉。路由还帮助我们作为开发人员结构化应用程序的逻辑部分,并将相关项目组合在一起。React Router 通过提供许多编程功能,使我们能够将复杂的路由集成到我们的应用程序中。

总结

本章涵盖了一些最重要的与 React 相关的框架。Redux 是一个管理全局应用程序状态的复杂工具。React Router 提供了类似经典 Web URL 的客户端 URL 管理。

使用高质量的技术,如 Redux 和 React Router,将帮助我们编写更好的代码。这反过来将帮助我们为用户提供最佳体验。

我们已经到达了重点放在客户端技术的第二部分的结尾。现在我们将开始学习第三部分的服务器端技术。

第三部分:使用 Express 和 GraphQL 理解 Web 服务开发

在本节中,我们将学习 Web 服务的作用,并了解 Express 和 GraphQL 如何帮助我们构建高性能的服务。

本节包括以下章节:

  • 第八章使用 Node.js 和 Express 学习服务器端开发

  • 第九章什么是 GraphQL?

  • 第十章使用 TypeScript 和 GraphQL 依赖项设置 Express 项目

  • 第十一章, 我们将学到什么 – 在线论坛应用

  • 第十二章, 为我们的在线论坛应用构建 React 客户端

  • 第十三章使用 Express 和 Redis 设置会话状态

  • 第十四章, 使用 TypeORM 设置 Postgres 和存储库层

  • 第十五章添加 GraphQL 模式 – 第一部分

  • 第十六章添加 GraphQL 模式 – 第二部分

  • 第十七章, 将应用部署到 AWS

第八章:学习使用 Node.js 和 Express 进行服务器端开发

在本章中,我们将学习有关 Node 和 Express 的知识。我们将了解 Node 如何帮助我们创建高性能的 Web 服务。我们还将了解 Node 和 Express 之间的关系以及如何将它们一起使用来构建我们的 Web API。

在本章中,我们将涵盖以下主要主题:

  • 理解 Node 的工作原理

  • 学习 Node 的能力

  • 理解 Express 如何改进 Node 开发

  • 学习 Express 的能力

  • 使用 Express 创建 Web API

技术要求

您应该对使用 JavaScript 进行 Web 开发有基本的了解。我们将再次使用 Node 和Visual Studio CodeVSC)。

GitHub 存储库再次位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap8文件夹中的代码。

要设置本章的代码文件夹,请转到本地的HandsOnTypescript文件夹并创建一个名为Chap8的新文件夹。

理解 Node 的工作原理

Node 是世界上最流行的 JavaScript 框架之一。它被用作数百万网站的核心技术。其原因有很多。它相对容易编码。它也非常快,当与诸如集群和工作线程之类的东西一起使用时,它非常可扩展。此外,由于它使用 JavaScript,它允许仅使用一种语言创建全栈应用程序,从前端到后端。所有这些特征使 Node 成为如果你的目标是网络的话一个绝佳选择。在本节中,我们将探讨 Node 的架构以及它如何实现强大的性能。

首先,重要的是要意识到 Node 不是一个特定于服务器的框架。它实际上是一个通用的运行时环境,而不仅仅是一个 Web 服务器。Node 为 JavaScript 提供了通常不具备的功能,例如访问文件系统和接受传入的网络连接。

为了解释 Node 的工作原理,让我们以 Web 浏览器作为类比。浏览器也是我们的 JavaScript 代码(以及 HTML 和 CSS)的运行时环境。浏览器通过具有核心 JavaScript 引擎来工作,该引擎提供基本级别的 JavaScript 语言功能。这包括一个语言解释器,用于读取我们的代码以获取有效的 JavaScript,以及一个在不同设备上运行我们的代码的虚拟机。

在这个核心之上,浏览器提供了一个安全的内存容器来运行应用程序,即沙盒。但它还提供了额外的 JavaScript 功能,通常称为 Web API(不是服务器端的,而是在浏览器级别)。Web API 增强了基本的 JavaScript 引擎,提供诸如文档对象模型DOM)访问,以便 JavaScript 代码可以访问 HTML 文档并对其进行操作。它提供了诸如 fetch 之类的调用,允许异步网络调用到其他机器,以及用于图形的 WebGL 等等。您可以在这里看到完整的列表:developer.mozilla.org/en-US/docs/Web/API

这些功能作为额外功能提供,超出了 JavaScript“开箱即用”的功能,如果你考虑一下,这是有道理的,因为在其核心,JavaScript 只是一种语言,因此不特定于任何特定平台,甚至是网络。

Node 遵循与浏览器类似的模型,因为它也使用核心 JavaScript 引擎(来自谷歌 Chrome 的 V8 引擎),并为我们的代码提供运行时容器。然而,由于它不是浏览器,它提供了不太专注于图形显示的不同附加功能。

那么,Node 是什么?Node 是一个专注于高性能和可扩展性的通用运行时环境。您可以使用 Node 构建许多类型的应用程序,包括计算机管理脚本和终端程序。但是 Node 的扩展能力也使其非常适合作为 Web 服务器。

Node 具有许多功能,使其作为编程运行时非常有能力,但其核心是libuv。Libuv 是一个用 C 编写的 Node 服务,它与操作系统内核进行接口,并提供异步输入/输出功能。为了及时访问这些服务,libuv 使用称为事件循环的东西,我们将很快解释,以处理这些任务。在 libuv 之上,Node 有一个类似于 Chrome 扩展的插件系统。它允许开发人员使用 C扩展 Node,并添加默认情况下不存在的高性能功能。此外,为了允许开发人员继续使用 JavaScript 调用 C,提供了一个称为 Addons 的 JavaScript 到 C++绑定系统。让我们更深入地探讨 libuv 和事件循环。

事件循环

Node 的核心是 libuv 和事件循环。这是使其扩展的主要功能。Libuv 的主要工作是提供对底层操作系统的异步输入/输出(I/O)功能的访问(Node 支持 Linux、macOS 和 Windows)。但是,这并不总是可能的,因此它还拥有一个线程池,可以通过在线程内运行它们来有效地使同步任务异步化。但是,Node 可扩展性的核心驱动程序是异步 I/O,而不是线程。运行计时器、允许网络连接、使用操作系统套接字和访问文件系统都来自 libuv。

那么,事件循环是什么?事件循环是 libuv 中的任务运行程序,类似于 Chrome 事件循环,以迭代方式运行异步回调任务。在高层次上,它是这样工作的。

当触发某些异步任务时,它们将由事件循环执行。事件循环以阶段或集合的形式进行处理。如下图所示,它首先运行计时器,如果已经排队了任何计时器回调,则按顺序执行它们(如果没有,它稍后返回,如果计时器已完成,则排队它们的回调)。然后,它处理任何挂起的回调(操作系统设置的回调-例如 TCP 错误),以此类推,依次进行阶段处理。请注意,如果由 libuv 执行,任务本质上是异步的,但回调本身可能不是。因此,可能会阻塞事件循环,因为它不会触发队列中的下一个回调,直到当前回调返回。以下是大致显示其工作原理的图表:

图 8.1-来自 Node 文档的节点事件循环

图 8.1-来自 Node 文档的节点事件循环

您还可以将阶段视为异步任务及其回调的类别。

所有框架都有其优势和劣势。Node 的主要优势在于异步 I/O 绑定的可扩展性。因此,Node 最适用于需要许多同时连接的高并发工作负载。在 Node 的后续版本中,从 10.5 开始,Node 团队确实引入了工作线程,以增加运行 CPU 绑定任务的多线程能力,这些任务主要是执行长时间运算。但是,这不是 Node 的主要优势。对于计算密集型工作负载,可能有更好的选择。但由于我们在 Node 的优先级是为我们的 React 前端创建一个高度可扩展的 API,Node 对我们的需求非常适用。

在下一节中,我们将开始深入挖掘 Node,编写 Node 代码,而不使用任何包装库,如 Express 或 Koa。这不仅会让我们更清楚地了解 Node 核心的工作原理,还将帮助我们更好地理解 Node 和 Express 之间的区别。

学习 Node 的能力

在上一节中,我们对 Node 是什么以及为什么它如此强大进行了高层次的概念性概述。在本节中,我们将开始利用这种可伸缩性,通过 Node 编写代码。我们将安装 Node,设置一个项目,并开始探索 Node API。

安装 Node

在我们可以使用 Node 编写代码之前,我们需要安装它。在前几章中,您可能已经这样做了,但让我们再次回顾如何安装它,因为 Node 经常更新:

  1. 前往nodejs.org。以下截图显示了本书撰写时的页面:图 8.2 – Node 网站

图 8.2 – Node 网站

对于生产使用,您可能希望选择更保守的路线,并使用npm包管理器。

  1. 一旦您点击您选择的版本,您将被要求保存一个与您的操作系统匹配的安装包。保存该包,然后启动它。然后您应该会看到以下屏幕:

图 8.3 – Node 设置

图 8.3 – Node 设置

按照设置窗口的指导完成安装。

很好,现在我们已经安装或更新了我们的 Node 运行时和npm包管理器。如前所述,Node 不仅是一个服务器框架,还是一个完整的运行时环境,允许您编写各种不同的应用程序。例如,Node 有一个名为 REPL 的命令行界面。如果您打开命令行或终端并输入node,您将看到它转换为接受 JavaScript 命令,就像这样:

图 8.4 – Node REPL

图 8.4 – Node REPL

在本书中,我们将不使用 REPL,但我在这里包含它,以便您知道它的存在,并且可能对您未来的项目有用。您可以在官方文档中了解更多关于 REPL 的信息,nodejs.org/api/repl.html#repl_design_and_features。此外,如果您好奇,undefined是因为每个命令都没有返回任何内容,在 JavaScript 中,这总是undefined

好的,现在,让我们创建我们的第一个 Node 应用程序,并探索一些 Node 的更多特性:

  1. 打开 VSCode,然后打开到Chap8文件夹的终端。

  2. 然后,在Chap8文件夹中创建一个名为try-node的新文件夹。

  3. 现在,创建一个名为app.js的文件。现在让我们暂时不使用 TypeScript,以便我们可以保持简单。

  4. 然后,在app.js中添加一个简单的控制台消息,如下所示:

console.log("hello world");

然后运行它:

node app.js

您应该会看到以下输出:

图 8.5 – 运行 app.js

图 8.5 – 运行 app.js

这不是一个特别有用的应用程序,但是正如您所看到的,Node 正在运行标准的 JavaScript 代码。现在,让我们做一些更有用的事情。让我们使用以下步骤访问文件系统:

  1. 在同一个app.js文件中,删除控制台消息并输入以下代码:
const fs = require("fs");

您可能会对这段代码感到困惑,因为它不是当前的导入风格。但我想在这里包含它,因为很多旧的 Node 代码仍然使用这种 CommonJS 风格的语法来导入依赖项。因此,您应该对此有所了解。

  1. 接下来,编写以下代码来创建一个文件,然后读取其内容:
fs.writeFile("test.txt", "Hello World", () => {
  fs.readFile("test.txt", "utf8", (err, msg) => {
    console.log(msg);
  });
});

如果您运行此代码,您将看到以下输出,并在您的try-node文件夹中创建一个名为test.txt的文件:

图 8.6 – app.js 输出

const fs = require("fs/promises");
(async function () {
  await fs.writeFile("test-promise.txt", "Hello    Promises");
  const readTxt = await fs.readFile("test-promise.txt",    "utf-8");
  console.log(readTxt);
})();

请注意,我们正在使用 IIFE 来允许我们进行顶层等待调用。

如果您使用的是较旧版本的 Node,fs/Promises 在 11 版本之后变得稳定,因此您可以使用一个名为promisify的工具来包装回调式调用,以使它们在async await风格中工作。

尽管如此,重要的是您要了解旧的回调式调用,因为这是历史上编写 Node 代码的方式,今天可能仍然有大量的 Node 代码保持这种风格。

  1. 我们在代码的顶部看到,我们使用require来进行我们的fs导入。让我们切换到更新的导入语法。我们需要做两件事:将文件扩展名从.js改为.mjs,并更新require语句如下:
import fs from "fs";

如果再次运行app.mjs,您会发现它仍然有效。我们可以在package.json中设置配置标志"type":"module",但是对于这个示例应用程序,我们没有使用npm。另外,如果我们全局设置此标志,我们将无法再使用require。这可能是一个问题,因为一些较旧的npm依赖项仍然使用require进行导入。

注意

有一个名为--experimental-modules的旧命令行标志,允许使用import,但现在已经不推荐使用,应该避免在新版本的 Node 中使用。

创建一个简单的 Node 服务器

我们了解到 Node 是基于一些较旧的 JavaScript 技术构建的,如回调和 CommonJS。Node 是在 JavaScript Promise 和 ES6 等更新版本的 JavaScript 之前创建的。尽管如此,Node 仍然运行良好,持续更新,以后,当我们添加额外的库时,我们将能够在大多数情况下使用async await和 Promise。

现在,让我们来看一个更现实的 Node 服务器示例。我们将使用npm创建一个新项目:

  1. Chap8的根目录下,创建一个名为node-server的新文件夹。

  2. 进入node-server文件夹,并使用以下命令初始化npm

npm init
  1. 让我们将我们的包名称命名为node-server,并接受其他package.json属性的默认值。

  2. 在根目录下创建一个名为server.mjs的新文件,并添加以下代码:

import http from "http";

别担心,我们很快就会开始使用 TypeScript。现在,让我们保持简单,这样我们就可以专注于学习 Node。

  1. 我们从 Node 核心导入了http库。然后我们使用createServer来创建一个服务器对象。请注意,我们的createServer函数接受一个函数作为参数,带有两个参数。参数reqres分别是RequestResponse类型。Request对象将具有与我们的用户所做的请求相关的所有成员,而响应允许我们在发送回去之前修改我们的响应。

在我们的createServer处理程序函数的末尾,我们通过使用res.end显式结束我们的调用并返回文本。如果我们没有发送end,我们的响应将永远不会完成,浏览器上也不会出现任何内容:

const server = http.createServer((req, res) => {
  console.log(req);
  res.end("hello world");
});
  1. 最后,我们使用我们的新服务器对象来等待并监听新的请求,使用带有端口号和回调函数的listen函数打印服务器已启动:
const port = 8000;
server.listen(port, () => {
  console.log(`Server started on port ${port}`);
});
  1. 通过执行我们的server.mjs脚本来运行此代码(确保使用正确的扩展名.mjs):
node server.mjs

请记住,在我们工作时,当前没有自动重新加载功能。因此,在代码更改时,我们将不得不手动停止和重新启动。随着我们继续向我们的项目添加更多功能,我们将稍后添加这个功能。

  1. 如果你打开浏览器到http://localhost:8000,你应该在浏览器中看到hello world,并在控制台中看到以下内容:

图 8.7 - 第一个 node 服务器运行

终端显示了req对象及其成员。当然,我们很快将更详细地介绍RequestResponse

另一个有趣的事情是,无论我们给出什么 URL,它总是返回相同的hello world文本。这是因为我们没有实现任何路由处理。处理路由是我们必须学习的另一项内容,以便正确使用 Node。

您可以不断刷新浏览器,服务器将继续以hello world进行响应。正如您所看到的,服务器保持运行,无论我们发送多少请求,而不像典型的脚本程序一样返回并结束。这是因为事件循环,Node 的核心,是一种无限循环,将继续等待新任务并忠实地处理它们。

恭喜,您现在已经运行了您的第一个 Node 服务器!毫无疑问,这只是一个谦卑的开始,但是您现在可以进行真正的浏览器调用,我们的服务器将做出响应。所以,您已经走上了正道。

请求和响应

当来自浏览器的请求到达服务器时,所有服务器框架通常都会有两个对象:RequestResponse。这两个对象代表了来自浏览器的请求的相关数据,以及将返回给它的响应。让我们从浏览器的角度来看看这些对象是由什么组成的。重新加载您的浏览器,但这次在Network选项卡上打开 Chrome 开发工具:

图 8.8 – Chrome 开发工具网络选项卡

图 8.8 – Chrome 开发工具网络选项卡

这个视图只是从浏览器的角度来看的,在 Node 中,这些对象中有更多的信息。然而,我们需要首先了解一个网络请求由什么组成,然后才能尝试创建任何真正的网络服务器。因此,让我们列出一些更重要的项目,并描述它们的含义。

请求 URL

显然,这代表了发送到服务器的完整 URL 路径。但服务器需要知道完整路径的原因是,URL 中通常会发送大量附加信息。例如,如果我们的 URL 是http://localhost:8000/home?userid=1,实际上这里有相当多的信息。首先,我们告诉服务器我们要在home子目录中寻找网页或 API 数据。这使得服务器能够根据 URL 返回响应,只返回 HTML 页面或特定于该 URL 的数据。此外,我们传递了一个名为userid的参数(参数在问号后开始,多个参数可以用&符号分隔),服务器可以使用该参数在请求中提供唯一的数据。

请求方法

请求方法表示所谓的 HTTP 动词。动词只是一个描述,告诉服务器客户端打算执行什么操作。默认动词是 GET,这意味着,正如名称所示,浏览器想要读取一些数据。其他动词是 POST,表示创建或插入,PUT 表示更新,然后 DELETE 表示删除。在第九章什么是 GraphQL?中,我们将看到 GraphQL 只使用 POST 方法,但这实际上不是错误,因为动词不是硬性规则,而更像是指导方针。还有一件事需要注意的是,当使用 GET 时,所需的任何参数将在 URL 中提供,就像请求 URL 的项目示例所示的那样。然而,对于 POST,参数将在请求的正文中提供。我们将在学习 Express 功能部分更详细地讨论这些差异。

状态码

所有网络请求都将返回这些代码以指示请求的结果。例如,状态码200表示成功。我不会在这里列出所有的状态码,但我们应该了解一些最常见的状态码,因为有时这可能有助于调试:

图 8.9 – 错误代码

图 8.9 – 错误代码

标头

标头提供了额外的信息,充当描述或元数据。如图所示,有多种类型的标头:通用、请求、响应和实体。再次强调,我不会涵盖所有的标头,但有一些我们应该熟悉。以下是请求标头:

图 8.10 – 请求标头

图 8.10 – 请求标头

以下是响应标头:

图 8.11 – 响应标头

图 8.11 – 响应标头

当然,这只是干燥的信息。然而,了解制作这些请求和响应所涉及的内容有助于我们更好地理解网络的工作原理,因此编写更好的网络应用程序。现在让我们更深入地看一下路由。

路由

在某种意义上,路由有点像向服务器传递参数。当服务器看到特定的路由时,它会知道响应需要以某种特定的方式进行。响应可以是返回一些特定的数据或将数据写入数据库,但有了路由,我们可以管理服务器对每个请求的行为方式。

让我们在 Node 中进行一些路由处理:

  1. 像这样在node-server项目的server.mjs文件中更新server对象:
const server = http.createServer((req, res) => {
  if (req.url === "/") {
    res.end("hello world");
  } else if (req.url === "/a") {
    res.end("welcome to route a");
  } else if (req.url === "/b") {
    res.end("welcome to route b");
  } else {
    res.end("good bye");
  }
});

如你所见,我们获取req.url字段并将其与几个 URL 进行比较。对于每一个匹配的 URL,我们用一些独特的文本结束我们的响应。

  1. 再次运行服务器并尝试每个路由。例如,如果你的路由是http://localhost:8000/a,那么你应该看到这个:图 8.12 - 路由/a

图 8.12 - 路由/a

  1. 好的,现在让我们看看如果我们收到一个 POST 请求会发生什么。像这样更新你的createServer函数:
const server = http.createServer((req, res) => {
  if (req.url === "/") {
    res.end("hello world");
  } else if (req.url === "/a") {
    res.end("welcome to route a");
  } else if (req.url === "/b") {
    res.end("welcome to route b");
  } else if (req.url === "/c" && req.method === "POST") {
    let body = [];
    req.on("data", (chunk) => {
      body.push(chunk);
    });
    req.on("end", () => {
      const params = Buffer.concat(body);
      console.log("body", params.toString());
      res.end(`You submitted these parameters: 
       ${params.toString()}`);
    });
  } else {
    res.end("good bye");
  }
});

正如你所看到的,我们添加了另一个带有/c路由和POST方法类型的if else语句。你可能会惊讶地发现,为了从我们的调用中获取发布的数据,我们需要处理data事件,然后处理end事件,以便我们可以返回调用。

让我解释一下这是怎么回事。Node 是非常低级的,这意味着它不会隐藏其复杂的细节以使事情变得更容易,以便更高效。因此,当发出请求并向服务器发送一些信息时,这些数据将作为流发送。这只是意味着数据不是一次性发送的,而是分成片段发送的。Node 不会向开发人员隐藏这一事实,并使用事件系统来接收数据的块,因为一开始不清楚有多少数据要进来。然后,一旦接收完这些数据,end事件就会触发。

在这个示例中,data事件用于将我们的数据聚合到一个数组中。然后,end事件用于将该数组放入内存缓冲区,然后可以作为一个整体进行处理。在我们的情况下,它只是 JSON,所以我们将其转换为字符串。

  1. 为了测试这个,让我们使用curl提交一个 POST 请求。curl只是一个命令行工具,允许我们在不使用浏览器的情况下进行 web 服务器请求。这对测试很有用。在你的终端中执行以下代码(如果你在 Windows 上,你可能需要先安装curl;在 macOS 上,它应该已经存在):
curl --header "Content-Type: application/json"  --request POST --data '{"userid":"1","message":"hello"}' "http://localhost:8000/c"

你应该得到以下返回:

图 8.13 - curl POST 的结果

图 8.13 - curl POST 的结果

显然,所有这些都有效,但从开发生产力的角度来看并不理想。我们不希望在单个createServer函数中有 30 个这样的if else语句。这很难阅读和维护。我们将看到 Express 如何帮助我们避免这些问题,它提供了额外的封装来加快开发速度并提高可靠性。我们将在了解 Express 如何改进 Node 开发部分看到这一点。让我们先了解一些工具来帮助我们的 Node 编码。

调试

就像我们在 React 中看到的那样,调试器是一个非常重要的工具,可以帮助我们排除代码中的问题。当然,在 Node 的情况下,我们不能使用浏览器工具,但 VSCode 确实有一个内置的调试器,可以让我们在代码上断点并查看值。让我们来看看这个,因为我们也将在 Express 中使用它:

  1. 点击 VSCode 中的调试器图标,你会看到以下屏幕。在撰写本文时的当前版本中,它看起来是这样的:图 8.14 - VSCode 调试器菜单

图 8.14 - VSCode 调试器菜单

第一个按钮运行调试器,第二个显示终端的调试器版本。运行调试器时,通常希望查看调试器控制台,因为它可以显示运行时发生的错误。

  1. 运行 VSCode 调试器时,你需要点击npm start命令:图 8.15 - Node.js 调试器选择

图 8.15 - Node.js 调试器选择

  1. 一旦启动调试器,如果您通过单击任何行号旁边设置了断点,您将能够在那里使代码暂停。然后,您可以查看与该范围相关的值:图 8.16 – 行视图中断

图 8.16 – 行视图中断

正如您所见,我们已在data事件中的第 13 行设置了断点,并且能够查看当前块。点击继续按钮或点击F5继续运行程序。

  1. 悬停在断点上的值是有用的,但并不是帮助调试我们的应用程序的唯一方法。我们还可以使用调试器屏幕来帮助我们了解我们在断点停止时的值是什么。看一下下面的截图:

图 8.17 – 调试窗口全景视图

图 8.17 – 调试窗口全景视图

看看我们的断点,截图中间。我们可以看到我们已经在end事件处理程序范围内中断。让我们看一下列出的一些功能:

  • 从左上角菜单开始,称为paramsthis。同样,我们正在查看end事件,这就是为什么我们只有这两个变量。

  • 在中间左侧,有params,我添加了。在这个部分中有一个加号,允许我们添加我们感兴趣的变量,当它们进入范围时,当前值将显示在那里。

  • 然后,在左下角,我们看到CALL STACK。调用堆栈是我们程序正在运行的调用列表。列表将以相反的顺序显示,最后一个命令位于顶部。通常,这些调用中的许多将是来自 Node 或我们自己没有编写的其他框架的代码。

  • 然后,在右下角,我们有我们的params变量和其缓冲区被显示。

  • 最后,在右上角,我们看到了调试继续按钮。左侧的第一个按钮是继续按钮,它会从上一个断点继续运行我们的应用程序。接下来是步过按钮,它将转到下一个立即行并在那里停止。接下来是步入按钮,它将在函数或类的定义内部运行。然后是步出按钮,它将使您退出并返回到父调用者。最后,方形按钮完全停止我们的应用程序。

这是对 VSCode 调试器的一个快速介绍。随着我们进入 Express,然后稍后使用 GraphQL,我们将会更多地使用它。

现在,正如您所见,每次进行任何更改时都必须手动重新启动 Node 服务有点麻烦并且会减慢开发速度。因此,让我们使用一个名为nodemon的工具,它将在保存脚本更改时自动重新启动我们的 Node 服务器:

  1. 通过运行以下命令全局安装nodemon
nodemon to our entire system. Installing it globally allows all apps to run nodemon without needing to keep installing it. Note that on macOS and Linux, you may need to prefix this command with sudo, which will elevate your rights so that you can install it globally.
  1. 现在,我们希望在应用程序启动时启动它。通过找到"scripts"部分并添加一个名为"start"的子字段,然后将以下命令添加到package.json文件中:
package.json "scripts" section should look like this now:![Figure 8.18 – package.json "scripts" section    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_8.18_B15508.jpg)Figure 8.18 – package.json "scripts" section
  1. 现在,使用以下命令运行新脚本:
npm command, you need to run npm run <file name>. However, for start scripts, we can skip the run sub-command.You should see the app start up as usual.
  1. 现在应用程序正在运行,让我们尝试更改并保存server.mjs文件。将listen函数中的字符串更改为The server started on port ${port}。保存此更改后,您应该看到 Node 重新启动并在终端上显示新文本。

  2. package.json 中的设置不会影响我们的 VSCode 调试器。因此,为了设置自动重启,我们需要进行设置。再次转到调试器菜单,点击configurations字段是一个数组,这意味着您可以继续向这个文件添加配置。但是对于我们的配置,请注意typenode,当然。我们还将name更新为"Launch node-server Program"。但是,请注意,我们将runtimeExecutable切换为nodemon而不是nodeconsole现在是集成终端。为了在调试器中使用nodemon,我们必须切换到TERMINAL选项卡,而不是调试器控制台。

  3. 现在我们至少有一个launch.json配置,我们的调试菜单将显示以下视图:图 8.20 - 从 launch.json 调试器

图 8.20 - 从 launch.json 调试器

如果您的下拉菜单没有显示启动 node-server 程序,请选择它,然后按播放按钮。然后,您应该再次看到调试器启动,只是这次它将自动重新启动。

  1. 现在,尝试进行小的更改,调试器应该会自动重新启动。我从listen函数的日志消息中删除了T图 8.21 - 调试器自动重新启动

图 8.21 - 调试器自动重新启动

  1. 太好了,现在我们可以轻松地中断和调试我们的 Node 代码!

这是一次快速介绍一些将有助于我们开发和调试的工具。

在本节中,我们学习了直接使用 Node 来编写我们的服务器。我们还学习了调试和工具,以改进我们的开发流程。直接使用 Node 进行编码可能会耗费时间,也不直观。在接下来的几节中,我们将学习 Express 以及它如何帮助我们改进 Node 开发体验。

了解 Express 如何改进 Node 开发

正如我们所见,直接使用 Node 进行编码具有一种笨拙和繁琐的感觉。拥有一个更易于使用的 API 将使我们更加高效。这就是 Express 框架尝试做的事情。在本节中,我们将学习 Express 是什么,以及它如何帮助我们更轻松地为我们的 Node 应用程序编写代码。

Express 不是一个独立的 JavaScript 服务器框架。它是一个代码层,位于 Node 之上,因此使用 Node 来使使用 Node 开发 JavaScript 服务器变得更加容易和更有能力。就像 Node 一样,它有自己的核心功能,然后通过依赖包提供一些额外的功能。Express 也有其核心能力以及提供额外功能的丰富中间件生态系统。

那么,Express 是什么?根据网站的说法,Express 只是一系列中间件调用的应用程序。让我们首先通过查看图表来解释这一点:

图 8.22 - Express 请求响应流程

图 8.22 - Express 请求响应流程

每当有新的服务器请求到来时,它都会沿着顺序路径进行处理。通常,您只会有一个请求,一旦请求被理解和处理,您就会得到一些响应。然而,当使用 Express 时,您可以有多个中间函数插入到过程中并进行一些独特的工作。

因此,在图 8.22中所示的示例中,我们首先看到添加了 CORS 功能的中间件,这是一种允许来自与服务器所在的 URL 域不同的 URL 域的请求的方式。然后,我们有处理会话和 cookie 的中间件。会话只是关于用户当前使用网站的唯一数据 - 例如,他们的登录 ID。最后,我们看到一个处理错误的处理程序,它将根据发生的错误确定将显示的一些唯一消息。当然,您可以根据需要添加更多的中间件。这里的关键点是 Express 以相当简单的方式使 Node 通常不具备的额外功能注入成为可能。

除了这个中间件的能力之外,Express 还为RequestResponse对象添加了额外的功能,进一步增强了开发人员的生产力。我们将在下一节中查看这些功能,并进一步探索 Express。

学习 Express 的能力

Express 基本上是 Node 的中间件运行器。但是,就像生活中的大多数事情一样,简单的解释很少提供必要的信息来正确使用它。因此,在本节中,我们将探索 Express,并通过示例了解其功能。

让我们将 Express 安装到我们的node-server项目中。在终端中输入以下命令:

npm I express -S

这将给你一个更新后的package.json文件,其中有一个新的依赖项部分:

图 8.23 – 更新的 package.json

图 8.23 – 更新的 package.json

现在,在我们开始编写代码之前,我们需要了解一些事情。再次提到,Express 是 Node 的封装。这意味着 Express 已经在内部使用了 Node。因此,当我们使用 Express 编写代码时,我们不会直接调用 Node。让我们看看这是什么样子的:

  1. 创建一个名为expressapp.mjs的新服务器文件,并将以下代码添加到其中:
import express from "express";
const app = express();
app.listen({ port: 8000 }, () => {
  console.log("Express Node server has loaded!");
});

正如你所看到的,我们创建了一个express实例,然后在其上调用了一个名为listen的函数。在内部,express.listen函数调用了 Node 的createServerlisten函数。如果你运行这个文件,你将会看到以下日志消息:

图 8.24 – 运行 expressapp.mjs 文件

图 8.24 – 运行 expressapp.mjs 文件

因此,现在我们有一个正在运行的 Express 服务器。但是,在添加一些中间件之前,它什么也不做。Express 的中间件运行在几个主要的伞形或部分下。有一些中间件是为整个应用程序运行的,有一些是仅在路由期间运行的,还有一些是在错误时运行的。Express 还有一些内部使用的核心中间件。当然,我们可以使用npm包提供的第三方中间件,而不是实现我们自己的中间件代码。我们已经在前一节理解 Express 如何改进 Node 开发中的图 8.22中看到了其中一些。

  1. 让我们从添加我们自己的中间件开始。使用以下代码更新expressapp.mjs
import express from "express";
const app = express();
app.use((req, res, next) => {
  console.log("First middleware.");
  next();
});
app.use((req, res, next) => {
  res.send("Hello world. I am custom middleware.");
});
app.listen({ port: 8000 }, () => {
  console.log("Express Node server has loaded!");
});

因此,对于这个第一个例子,我们决定使用app对象上的应用级中间件,通过在app对象上使用use函数。这意味着无论路由如何,对于整个应用程序的任何请求,都必须处理这两个中间件。

让我们逐个来。首先,注意所有中间件都是按照在代码中声明的顺序进行处理的。其次,除非在中间件的最后结束调用,否则我们必须调用next函数去到下一个中间件,否则处理将会停止。

第一个中间件只是记录一些文本,但第二个中间件将使用 Express 的send函数在浏览器屏幕上写入内容。send函数很像 Node 中的end函数,因为它结束了处理,但它还发送了一个text/html类型的内容类型头。如果我们使用 Node,我们将不得不自己显式地发送头。

  1. 现在,让我们为路由添加中间件。请注意,从技术上讲,你可以将路由(例如/routea路由)传递给use函数。然而,最好使用router对象,并将我们的路由包含在一个容器下。在 Express 中,路由器也是中间件。让我们看一个例子:
import express from "express";
const router = express.Router();

首先,我们从express.Router类型创建了我们的新router对象:

const app = express();
app.use((req, res, next) => {
  console.log("First middleware.");
  next();
});
app.use((req, res, next) => {
  res.send("Hello world. I am custom middleware.");
});
app.use(router);

因此,我们像之前一样将相同的一组中间件添加到了app对象中,使其在所有路由上全局运行。但是,我们还将router对象作为中间件添加到了我们的应用中。然而,路由器中间件只对定义的特定路由运行:

router.get("/a", (req, res, next) => {
  res.send("Hello this is route a");
});
router.post("/c", (req, res, next) => {
  res.send("Hello this is route c");
});

因此,我们再次向我们的router对象添加了两个中间件:一个用于/a路由,使用get方法函数,另一个用于/c路由,使用post方法函数。同样,这些函数代表了可能的 HTTP 动词。listen函数调用与之前相同:

app.listen({ port: 8000 }, () => {
  console.log("Express Node server has loaded!");
});

现在,如果我们通过访问以下 URL 运行这段代码:http://localhost:8000/a,将会发生一个奇怪的事情。所有调用都将在那里结束,不会继续到下一个中间件。

删除发送Hello world…消息的第二个app.use调用,尝试访问http://localhost:8000/a。现在你应该看到以下消息:

![图 8.25 – 路由/a 的中间件

](img/Figure_8.25_B15508.jpg)

图 8.25-路由/ a 的中间件

很好,那起作用了,但现在尝试使用浏览器转到http://localhost:8000/c。那起作用吗?不,它不起作用,您会得到/c路由只能是 POST 路由。如果您打开终端并运行我们在学习节点的功能部分中使用的最后一个 POST curl命令,您会看到这个:

![图 8.26-路由/ c

](img/Figure_8.26_B15508.jpg)

图 8.26-路由/ c

正如您所看到的,我们收到了适当的文本消息。

  1. 现在,让我们添加第三方中间件。在学习节点的功能部分,我们看到了如何解析 POST 数据以及使用 Node 可能会有多么艰难。对于我们的示例,让我们使用 body parser 中间件来使这个过程更容易。更新代码如下:
import express from "express";
/c route handler so that its text message shows the value passed in the message field:

app.use((req,res,next)= > {

控制台.log("第一个中间件。");

下一个;

});

app.use(路由器);

路由器获取("/a",(req,res,next)= > {

res.send("您好,这是路由 a");

});

路由器.post("/c",(req,res,next)= > {

res.send(`您好,这是路由 c。消息是

${数据和结束。


  1. 现在,最后,让我们做一个错误中间件。只需在bodyParser.json()中间件调用下面添加以下代码:
import express from "express";
import bodyParser from "body-parser";
const router = express.Router();
const app = express();
app.use(bodyParser.json());
app.use((req, res, next) => {
  console.log("First middleware.");
  throw new Error("A failure occurred!");
});

然后,我们从我们的第一个自定义中间件中抛出一个错误:

app.use(router);
router.get("/a", (req, res, next) => {
  res.send("Hello this is route a");
});
router.post("/c", (req, res, next) => {
  res.send(`Hello this is route c. Message is ${req.body.   message}`);
});
app.use((err, req, res, next) => {
  res.status(500).send(err.message);
});

现在,我们已经将我们的错误处理程序添加为代码中的最后一个中间件。此中间件将捕获以前未处理的所有错误并发送相同的状态和消息:

app.listen({ port: 8000 }, () => {
  console.log("Express Node server has loaded!");
});
  1. 转到http://localhost:8000/a,您应该看到以下消息:

![图 8.27-错误消息

](img/Figure_8.27_B15508.jpg)

图 8.27-错误消息

由于我们的顶级中间件抛出异常,所有路由都将抛出此异常,因此将被我们的错误处理程序中间件捕获。

这是 Express 框架及其功能的概述。正如您所看到的,它可以使使用 Node 变得更加简单和清晰。在下一节中,我们将看看如何使用 Express 和 Node 构建返回 JSON 的 Web API,这是 Web 的默认数据模式。

使用 Express 创建 Web API

在本节中,我们将学习有关 Web API 的知识。目前,它是提供 Web 上数据的最流行方式之一。在我们的最终应用程序中,我们将不使用 Web API,因为我们打算使用 GraphQL。但是,了解 Web API 设计是很好的,因为在互联网上,它非常常用,并且在 GraphQL 的内部也类似地工作。

什么是 Web API? API代表应用程序编程接口。这意味着这是一个编程系统与另一个系统进行交互的方式。因此,Web API 是使用 Web 技术向其他系统提供编程服务的 API。Web API 以字符串形式发送和接收数据,而不是二进制数据,通常以 JSON 格式。

所有 Web API 都将具有由 URI 表示的端点,基本上与 URL 相同。此路径必须是静态的,不得更改。如果需要更改,则预期 API 供应商将进行版本更新,保留旧的 URI 并创建由版本升级界定的新 URI。例如,如果 URI 从/api/v1/users开始,那么下一个迭代将是/api/v2/users

让我们为演示目的创建一个简单的 Web API:

  1. 让我们使用以下新路由更新我们的expressapp.mjs文件:
import express from "express";
import bodyParser from "body-parser";
const router = express.Router();
const app = express();
app.use(bodyParser.json());
app.use((req, res, next) => {
  console.log("First middleware.");
  /api/v1/users path. This type of pathing is fairly standard for web APIs. It indicates the version and a related container of data to query – in this case, users. For example purposes, we are using a hardcoded array of users and finding only one with a matching ID. Since id is a number and anything coming from req.query is a string, we are using == as opposed to ===. If you load the browser to the URI, you should see this:![Figure 8.28 – User GET request    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_8.28_B15508.jpg)Figure 8.28 – User GET requestAs you can see, our second user, `jon`, is returned.
  1. 接下来,对于此中间件,我们对组进行了几乎相同的操作。请注意资源路径之间的路径设置在两者之间是一致的。这是 Web API 的一个重要特性。同样,我们从数组中获取一个项目,但在这种情况下,我们使用了 POST 方法,因此参数是从正文中获取的:
router.post("/api/v1/groups", (req, res, next) => {
  const groups = [
    {
      id: 1,
      groupname: "Admins",
    },
    {
      id: 2,
      groupname: "Users",
    },
    {
      id: 3,
      groupname: "Employees",
    },
  ];
  const group = groups.find((grp) => grp.id == req.body.   groupid);
  res.send(`Group ${group.groupname}`);
});

如果您运行终端命令到此 URI,您应该会看到以下内容:

![图 8.29-组 POST 请求

](img/Figure_8.29_B15508.jpg)

图 8.29-组 POST 请求

如所示,我们返回了第一个组Admins。其余代码相同:

app.use((err, req, res, next) => {
  res.status(500).send(err.message);
});
app.listen({ port: 8000 }, () => {
  console.log("Express Node server has loaded!");
});

重要说明

由于 Web API 特定于 Web 技术,它支持使用所有的 HTTP 方法进行调用:GET、POST、PATCH、PUT 和 DELETE。

这是一个关于使用 Express 和 Node 构建 Web API 的快速介绍。我们现在对 Node 及其最重要的框架 Express 有了一个广泛的概述。

总结

在本章中,我们学习了 Node 和 Express。Node 是驱动网络服务器的核心服务器端技术,Express 是构建 Web 应用程序的最流行和经常使用的基于 Node 的框架。我们现在对前端和后端技术如何共同创建网站有了完整的了解。

在下一章中,我们将学习 GraphQL,这是一种非常流行且相对较新的标准,用于创建基于 Web 的 API 服务。一旦我们掌握了这个知识,我们就可以开始构建我们的项目了。

第九章:什么是 GraphQL?

在本章中,我们将学习 GraphQL,这是目前最热门的 web 技术之一。许多大公司已经采用了 GraphQL 作为他们的 API,包括 Facebook、Twitter、纽约时报和 GitHub 等公司。我们将学习 GraphQL 为什么如此受欢迎,它内部是如何工作的,以及我们如何利用它的特性。

在本章中,我们将涵盖以下主要主题:

  • 理解 GraphQL

  • 理解 GraphQL 模式

  • 理解类型定义和解析器

  • 理解查询、变异和订阅

技术要求

你应该对使用 Node 进行 web 开发有基本的了解。我们将再次使用 Node 和 Visual Studio Code。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap9文件夹中的代码。

要设置Chap9代码文件夹,转到你的HandsOnTypescript文件夹并创建一个名为Chap9的新文件夹。

理解 GraphQL

在本节中,我们将探讨 GraphQL 是什么,为什么它被创建以及它试图解决什么问题。了解 GraphQL 存在的根本原因很重要,因为它将帮助我们设计更好的 web API。

那么,GraphQL 是什么?让我们列举一些它的主要特点:

  • GraphQL 是 Facebook 开发的数据模式标准。

GraphQL 提供了一个标准语言来定义数据、数据类型和相关数据查询。你可以把 GraphQL 大致类比为提供合同的接口。那里没有代码,但你仍然可以看到可用的类型和查询。

  • GraphQL 跨平台、框架和语言运行。

当我们使用 GraphQL 创建 API 时,无论我们使用什么编程语言或操作系统,都将使用相同的 GraphQL 语言来描述我们的数据、类型和查询。在各种系统和平台上拥有一致可靠的数据表示当然对客户端和系统来说是一件好事。但对程序员来说也是有益的,因为我们可以继续使用我们正常的编程语言和选择的框架。

  • GraphQL 将查询的控制权交给调用者。

在标准 web 服务中,是服务器控制返回的数据字段。然而,在 GraphQL API 中,是客户端确定他们想要接收哪些字段。这给客户端更好的控制权,减少了带宽使用和成本。

广义上说,GraphQL 端点有两个主要用途。一个是作为整合其他数据服务的网关,另一个是作为直接从数据存储接收数据并提供给客户端的主要 web API 服务。下面是一个使用 GraphQL 作为其他数据网关的图表:

图 9.1 - GraphQL 作为网关

图 9.1 - GraphQL 作为网关

正如你所看到的,GraphQL 作为所有客户端的唯一真相来源。它在这方面表现良好,因为它是基于标准的语言,支持各种系统。

对于我们自己的应用程序,我们将把它用作我们整个的 web API,但也可以将其与现有的 web 服务混合在一起,以便 GraphQL 仅处理正在进行的部分服务调用。这意味着你不需要重写整个应用程序。你可以逐渐有意识地引入 GraphQL,只在有意义的地方这样做,而不会干扰你当前的应用程序服务。

在这一部分,我们从概念层面上了解了 GraphQL。GraphQL 有自己的数据语言,这意味着它可以在不同的服务器框架、应用程序编程语言或操作系统上使用。这种灵活性使得 GraphQL 成为在整个组织甚至整个网络中共享数据的强大手段。在下一部分中,我们将探索 GraphQL 模式语言并了解它是如何工作的。这将帮助我们构建我们的数据模型并了解如何设置我们的 GraphQL 服务器。

理解 GraphQL 模式

正如所述,GraphQL 是一种用于为我们的实体数据提供结构和类型信息的语言。无论服务器上使用的是哪个供应商的 GraphQL 实现,我们的客户端都可以期望返回相同的数据结构。将服务器的实现细节抽象化给客户端是 GraphQL 的优势之一。

让我们创建一个简单的 GraphQL 模式并看看它是什么样子的:

  1. Chap9文件夹中,创建一个名为graphql-schema的新文件夹。

  2. 在该文件夹中打开你的终端,然后运行这个命令,接受默认值:

npm init
  1. 现在安装这些包:
npm i express apollo-server-express @types/express
  1. 使用这个命令初始化 TypeScript:
tsconfig.json setting is strict.
  1. 创建一个名为typeDefs.ts的新的 TypeScript 文件,并将其添加到其中:
import { gql } from "apollo-server-express";

这个导入获取了gql对象,它允许对 GraphQL 模式语言进行语法格式化和高亮显示:

const typeDefs = gql`
  type User {
    id: ID!
    username: String!
    email: String
  }
  type Todo {
    id: ID!
    title: String!
    description: String
  }
  type Query {
    getUser(id: ID): User
    getTodos: [Todo!]
  }
`;

这种语言相当简单,看起来很像 TypeScript。从顶部开始,首先我们有一个User实体,如type关键字所示。type是一个 GraphQL 关键字,表示正在声明某种结构的对象。正如你所看到的,User类型有多个字段。id字段的类型是ID!ID类型是一个内置类型,表示一个唯一的值,基本上是某种 GUID。感叹号表示该字段不能为null,而没有感叹号表示它可以为null。接下来,我们看到username字段及其类型为String!,这当然意味着它是一个非空字符串类型。然后,我们有description字段,但它的类型是String,没有感叹号,所以它是可空的。

Todos类型具有类似的字段,但请注意Query类型。这表明即使查询在 GraphQL 中也是类型。因此,如果你查看两个查询,getUsergetTodos,你可以看到为什么我们创建了UserTodos类型,因为它们成为我们两个Query方法的返回值。还要注意getTodos函数返回一个非空的Todos数组,这由括号表示。最后,我们使用typeDefs变量导出我们的类型定义:

export default typeDefs;

类型定义被 Apollo GraphQL 用来描述模式文件中的模式类型。在你的服务器可以开始提供任何 GraphQL 数据之前,它必须首先有一个完整的模式文件,列出你应用程序的所有类型、它们的字段和将在其 API 中提供的查询。

另一个需要注意的是,GraphQL 有几种默认的标量类型内置到语言中。这些是IntFloatStringBooleanID。正如你在模式文件中注意到的,我们不需要为这些类型创建类型标记。

在这一部分,我们回顾了一个简单的 GraphQL 模式文件是什么样子。在下一部分中,我们将深入了解 GraphQL 语言,并学习解析器是什么。

理解类型定义和解析器

在这一部分,我们将进一步探讨 GraphQL 模式,但我们也将实现解析器,这些解析器是实际工作的函数。这一部分还将向我们介绍 Apollo GraphQL 以及如何创建一个 GraphQL 服务器实例。

解析器是什么?解析器是从我们的数据存储中获取或编辑数据的函数。然后将这些数据与 GraphQL 类型定义进行匹配。

为了更深入地了解解析器的作用,我们需要继续构建我们之前的项目。让我们看看步骤:

  1. 安装依赖 UUID。这个工具将允许我们为我们的ID类型创建一个唯一的 ID:
npm i uuid @types/uuid
  1. 创建一个名为server.ts的新文件,它将启动我们的服务器,使用这段代码:
import express from "express";
import { ApolloServer, makeExecutableSchema } from "apollo-server-express";
import typeDefs from "./typeDefs";
import resolvers from "./resolvers";

在这里,我们导入了设置服务器所需的依赖项。我们已经创建了typeDefs文件,很快我们将创建resolvers文件。

  1. 现在我们创建我们的 Express 服务器app对象:
const app = express();
  1. makeExecutableSchema从我们的typeDefs文件和resolvers文件的组合构建了一个程序化的模式:
const schema = makeExecutableSchema({ typeDefs, resolvers });
  1. 最后,我们创建了一个 GraphQL 服务器的实例:
const apolloServer = new ApolloServer({
  schema,
  context: ({ req, res }: any) => ({ req, res }),
});
apolloServer.applyMiddleware({ app, cors: false });

context由 Express 的请求和响应对象组成。然后,我们添加了我们的中间件,对于 GraphQL 来说,就是我们的 Express 服务器对象appcors选项表示禁用 GraphQL 作为我们的 CORS 服务器。随着我们构建应用程序,我们将在后面的章节中讨论 CORS。

在这段代码中,我们现在通过监听端口8000启动我们的 Express 服务器:

app.listen({ port: 8000 }, () => {
  console.log("GraphQL server ready.");
});

listen处理程序只是记录一条消息来宣布它已经启动。

现在让我们创建我们的解析器:

  1. 创建resolvers.ts文件,并将这段代码添加到其中:
import { IResolvers } from "apollo-server-express";
import { v4 } from "uuid";
import { GqlContext } from "./GqlContext";
interface User {
  id: string;
  username: string;
  description?: string;
}
interface Todo {
  id: string;
  title: string;
  description?: string;
}
  1. 由于我们使用 TypeScript,我们希望使用类型来表示我们返回的对象,这就是UserTodo代表的。这些类型将与我们在typeDefs.ts文件中创建的同名的 GraphQL 类型相匹配:
const resolvers: IResolvers = {
  Query: {
    getUser: async (
      obj: any,
      args: {
        id: string;
      },
      ctx: GqlContext,
      info: any
    ): Promise<User> => {
      return {
        id: v4(),
        username: "dave",
      };
    },

这是我们的第一个解析器函数,匹配getUser查询。请注意,参数不仅仅是id参数。这是来自 Apollo GraphQL 服务器的,为我们的调用添加了额外的信息。(请注意,为了节省时间,我硬编码了一个User对象。)另外,我们稍后将创建GqlContext类型,但基本上,它是一个容器,保存了我们在*第八章**中学到的请求和响应对象。

  1. 类似于getUser,我们的getTodos解析器接收类似的参数,并返回一个硬编码的Todo集合:
    getTodos: async (
      parent: any,
      args: null,
      ctx: GqlContext,
      info: any
    ): Promise<Array<Todo>> => {
      return [
        {
          id: v4(),
          title: "First todo",
          description: "First todo description",
        },
        {
          id: v4(),
          title: "Second todo",
          description: "Second todo description",
        },
        {
          id: v4(),
          title: "Third todo",
        },
      ];
    },
  1. 然后我们导出resolvers对象:
  },
};
export default resolvers;

正如你所看到的,我们的实际数据获取器只是普通的 TypeScript 代码。如果我们使用 Java 或 C#或任何其他语言,解析器也将是这些语言中的Create Read Update Delete (CRUD)操作。然后,GraphQL 服务器只是将数据实体模型转换为我们类型定义模式文件中的类型。

  1. 现在让我们创建我们的GqlContext类型。创建一个名为GqlContext.ts的文件,并添加这段代码:
import { Request, Response } from "express";
export interface GqlContext {
  req: Request;
  res: Response;
}

这只是一个简单的 shell 界面,允许我们在 GraphQL 解析器调用中为我们的上下文提供类型安全性。正如你所看到的,这个类型包含了 Express 的RequestResponse对象。

  1. 因此,现在我们需要将我们的代码编译成 JavaScript,因为我们使用的是 TypeScript。运行这个命令:
js versions of all the ts files.
  1. 现在我们可以运行我们的新代码;输入这个:
nodemon server.js
  1. 如果你去到 URL http://localhost: 8000/graphql,你应该会看到 GraphQL Playground 屏幕。这是 Apollo GraphQL 提供的一个查询测试页面,允许我们手动测试我们的查询。它看起来像这样:图 9.2 - GraphQL 开发客户端

图 9.2 - GraphQL 开发客户端

请注意,我已经运行了一个查询,它看起来像 JSON 并且在左边,结果也显示在右边,也是 JSON。如果你看左边的查询,我明确要求只返回id字段,这就是为什么只有id字段被返回。请注意,标准的结果格式是data > <function name> > <fields>。尝试运行getTodos查询作为测试。

  1. 另一个需要注意的是DOCS标签,它显示了所有可用的查询、变异和订阅(我们将在下一节中讨论这些)。它看起来像这样:图 9.3 - DOCS 标签

图 9.3 - DOCS 标签

  1. 最后,SCHEMA 标签显示了所有实体和查询的模式类型信息:

图 9.4 – SCHEMA 标签

图 9.4 – SCHEMA 标签

如您所见,它看起来与我们的 typeDefs.ts 文件相同。

在本节中,我们通过运行一个小型的 GraphQL 服务器来查看解析器。解析器是使 GraphQL 实际运行的另一半。我们还看到了使用 Apollo GraphQL 库相对容易地运行一个小型的 GraphQL 服务器。

在下一节中,我们将更深入地研究查询,看看 mutations 和 subscriptions。

了解查询、mutations 和 subscriptions

在创建 GraphQL API 时,我们不仅想要获取数据:我们可能还想要写入数据存储或在某些数据发生变化时收到通知。在本节中,我们将看到如何在 GraphQL 中执行这两个操作。

让我们先看看如何使用 mutations 写入数据:

  1. 我们将创建一个名为 addTodo 的 mutation,但为了使 mutation 更真实,我们需要一个临时数据存储。因此,我们将为测试目的创建一个内存数据存储。创建 db.ts 文件并将以下代码添加到其中:
import { v4 } from "uuid";
export const todos = [
  {
    id: v4(),
    title: "First todo",
    description: "First todo description",
  },
  {
    id: v4(),
    title: "Second todo",
    description: "Second todo description",
  },
  {
    id: v4(),
    title: "Third todo",
  },
];

我们刚刚将我们以前列表中的 Todos 添加到一个数组中,并将其导出。

  1. 现在我们需要更新我们的 typeDefs.ts 文件以包含我们的新 mutation。更新如下:
import { gql } from "apollo-server-express";
const typeDefs = gql`
  type User {
    id: ID!
    username: String!
    email: String
  }
  type Todo {
    id: ID!
    title: String!
    description: String
  }
  type Query {
    getUser(id: ID): User
    getTodos: [Todo!]
  }
Mutation, which is where any queries that change data will reside. We also added our new mutation called addTodo.
  1. 现在我们想要添加我们的 addTodo 解析器。将以下代码添加到您的 resolvers.ts 文件中:
Mutation: {
    addTodo: async (
      parent: any,
      args: {
        title: string;
        description: string;
      },
      ctx: GqlContext,
      info: any
    ): Promise<Todo> => {
      todos.push({
        id: v4(),
        title: args.title,
        description: args.description
      });
      return todos[todos.length - 1];
    },
  },

如您所见,我们有一个名为 Mutation 的新容器对象,里面是我们的 addTodo mutation。它具有与查询类似的参数,但此 mutation 将向 todos 数组添加一个新的 Todo。如果我们在 playground 中运行此代码,我们会看到这样:

图 9.5 – addTodo mutation 的 GraphQL playground

图 9.5 – addTodo mutation 的 GraphQL playground

当我们的查询是 Query 类型时,我们可以省略查询前缀。但是,由于这是一个 mutation,我们必须包含它。如您所见,我们只返回 idtitle,因为这是我们要求的全部内容。

现在让我们看一下订阅,这是一种在某些数据发生变化时收到通知的方式。让我们在我们的 addTodo 添加一个新的 Todo 对象时收到通知:

  1. 我们需要在 GraphQL 服务器的 context 中添加一个 PubSub 类型的对象,这个对象允许我们订阅(要求在发生变化时收到通知)和发布(在发生变化时发送通知)。更新 server.ts 文件如下:
import express from "express";
import { PubSub type. Notice we also get createServer; we'll use that later.
  1. 这是我们的 pubsub 对象,基于 PubSub 类型:
const app = express();
const pubsub = new PubSub();
  1. 现在我们将 pubsub 对象添加到 GraphQL 服务器的 context 中,以便从我们的解析器中使用:
const schema = makeExecutableSchema({ typeDefs, resolvers });
const apolloServer = new ApolloServer({
  schema,
  context: ({ req, res }: any) => ({ req, res, pubsub }),
});
  1. 从 Node 直接创建一个 httpServer 实例,然后在其上使用 installSubscription Handlers 函数。然后,当我们调用 listen 时,我们现在是在 httpServer 对象上调用 listen,而不是在 app 对象上:
apolloServer.applyMiddleware({ app, cors: false });
const httpServer = createServer(app);
apolloServer.installSubscriptionHandlers(httpServer);
httpServer.listen({ port: 8000 }, () => {
  console.log("GraphQL server ready." + 
    apolloServer.graphqlPath);
  console.log("GraphQL subs server ready." +
    apolloServer.subscriptionsPath);
});
  1. 现在让我们更新我们的 typeDefs.ts 文件以添加我们的新 mutation。只需添加此类型:
type Subscription {
    newTodo: Todo!
  }
  1. 现在我们可以用新的订阅解析器更新我们的 resolvers.ts 文件:
import { IResolvers } from "apollo-server-express";
import { v4 } from "uuid";
import { GqlContext } from "./GqlContext";
import { todos } from "./db";
interface User {
  id: string;
  username: string;
  email?: string;
}
interface Todo {
  id: string;
  title: string;
  description?: string;
}
NEW_TODO constant to act as the name of our new subscription. Subscriptions require a unique label, sort of like a unique key, so that they can be correctly subscribed to and published:

const resolvers: IResolvers = {

Query: {

getUser: async (

parent: any,

args: {

id: string;

},

ctx: GqlContext,

info: any

): Promise ⇒ {

return {

id: v4(),

用户名:"dave",

};

},


As you can see, nothing in our query changes, but it's included here for completeness:

getTodos: async (

parent: any,

args: null,

ctx: GqlContext,

info: any

): Promise<Array> ⇒ {

return [

{

id: v4(),

标题:"第一个待办事项",

描述:"第一个待办事项描述",

},

{

id: v4(),

标题:"第二个待办事项",

描述:"第二个待办事项描述",

},

{

id: v4(),

标题:"第三个待办事项",

},

];

},

},


Again, our query remains the same:

Mutation: {

addTodo: async (

parent: any,

args: {

标题: string;

描述: string;

},

ctx 对象,我们已将其解构为只使用 pubsub 对象,因为这是我们唯一需要的:

      info: any
    ): Promise<Todo> => {
      const newTodo = {
        id: v4(),
        title: args.title,
        description: args.description,
      };
      todos.push(newTodo);
      publish, which is a function to notify us when we have added a new Todo. Notice the newTodo object is being included in the publish call, so it can be provided to the subscriber later:

return todos[todos.length - 1];

},

},

Subscription: {

添加待办事项。请注意,我们的订阅 newTodo 不是一个函数。它是一个带有成员 subscribe 的对象:

     },
  },
};
export default resolvers;

其余部分与之前相同。



  1. 让我们尝试测试一下。首先,确保您已经用tsc编译了您的代码,启动了服务器,并刷新了 playground。然后,在 playground 中打开一个新的标签页,输入这个订阅,然后点击播放按钮:

图 9.6 – 新的待办事项订阅

图 9.6 – 新的待办事项订阅

当您点击播放按钮时,什么也不会发生,因为还没有添加新的Todo。所以,让我们回到我们的addTodo标签页,添加一个新的Todo。一旦你做到了,回到newTodo标签页,你应该会看到这个:

图 9.7 – 新的待办事项订阅结果

图 9.7 – 新的待办事项订阅结果

正如你所看到的,这很有效,我们得到了新添加的Todo

在本节中,我们学习了关于 GraphQL 查询、变更和订阅。我们将使用这些来构建我们的应用程序 API。因为 GraphQL 是一个行业标准,所有 GraphQL 客户端框架都可以与任何供应商的 GraphQL 服务器框架一起工作。此外,使用 GraphQL API 的客户端可以期望在服务器或供应商不同的情况下获得一致的行为和相同的查询语言。这就是 GraphQL 的力量。

总结

在本章中,我们探讨了 GraphQL 的强大和能力,这是创建 Web API 的最热门的新技术之一。GraphQL 是一种非常有能力的技术,而且,因为它是一个行业标准,我们总是可以期待在服务器、框架和语言之间获得一致的行为。

在下一章中,我们将开始整合我们迄今学到的技术,并使用 TypeScript、GraphQL 和辅助库创建一个 Express 服务器。

第十章:使用 TypeScript 和 GraphQL 依赖项设置 Express 项目

学习现代 JavaScript 编程的最大障碍之一是庞大的包和依赖项数量。尝试为项目选择正确的一组包可能会让人不知所措。在本章中,我们将学习如何设置一个配置良好的 TypeScript、Express 和 GraphQL 项目。我们将看到哪些依赖项受欢迎,以及我们如何通过使用它们来使我们的项目受益。

在本章中,我们将涵盖以下主要主题:

  • 创建一个基于 TypeScript 的 Express 项目

  • 向项目添加 GraphQL 和依赖项

  • 审查辅助包

技术要求

你应该对使用 Node、Express 和 GraphQL 进行 Web 开发有基本的了解。我们将再次使用 Node 和 Visual Studio Code。

GitHub 存储库可在github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node找到。使用Chap10文件夹中的代码。

要设置Chap10代码文件夹,进入你的HandsOnTypescript文件夹并创建一个名为Chap10的新文件夹。

创建一个基于 TypeScript 的 Express 项目

在本节中,我们将构建一个起始项目,我们将以此为基础构建我们的服务器。我们将手动审查和选择每个依赖项,并了解每个依赖项在我们的应用中扮演的角色。完成后,我们将拥有一个构建服务器应用的坚实基础。

有许多预制的项目模板可用于构建 Node 项目。对于 TypeScript 用户来说,一个常见的模板是微软的 TypeScript-Node-Starter 项目。它具有各种有用的依赖项。不幸的是,它面向的是 MongoDB 用户,但我们的应用将使用 Postgres。

另一个项目模板,来自制作 Express 的团队,是express-generator。这是一个接受参数并设置基本项目的 CLI。然而,这个模板生成器是面向使用模板引擎如pugejs进行服务器端 HTML 的服务器,对我们来说是不必要的,因为我们正在为 SPA 应用创建 API。此外,它没有 GraphQL 包来帮助我们创建我们的 API。

因此,为了消除多余的包并作为一个学习练习,让我们手动构建我们的项目。这将使我们能够看到构建我们的应用所需的每个部分,并了解每个部分的作用。按照这里给出的步骤:

  1. Chap10文件夹中创建一个新文件夹,并将其命名为node-server

  2. 在你的终端中,运行以下命令:

npm init 
  1. 接下来,我们安装 TypeScript 并初始化它:
npm i typescript
tsc -init
  1. 像这样更新tsconfig.json文件:
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "lib": ["ES6", "ES2017", "ES2018", "ES2019",       "ES2020"],
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "src",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true    
  },
  "exclude": ["node_modules"],
  "include": ["./src/**/*.tsx", "./src/**/*.ts"]
}

我们已经在第二章**,探索 TypeScript中学习了tsconfig.json文件,但让我们再次审查一下这里看到的内容:

  • 我们可以以 ES6 为目标,因为我们在自己的服务器上运行,并且可以通过使用适当的 Node 版本来控制 V8 版本。

  • 我们使用commonjs作为module系统,以避免在模块中混合使用requireimport时出现问题。

  • 我们希望使用最新的 JavaScript 版本,所以lib被设置为允许它们。

  • outDir字段表示编译后的js文件将保存在其中的文件夹。

  • rootDir代表代码源目录。

  • 我们允许emitDecoratorMetadataexperimentalDecorator作为TypeORM;访问我们的数据库的存储库层的依赖项将需要使用它们。

  • excludeinclude文件夹,如其含义,表示我们想要隐藏或向 TypeScript 编译器公开的文件夹。

  1. 现在让我们添加一些基本依赖项:
Express and its TypeScript types. 
  1. 我们安装了jest及其类型进行测试。

  2. ts-jest包允许我们用 TypeScript 编写测试。

  3. 我展示了nodemon是为了完整性起见,但我们将运行全局安装的版本,这是我们在第八章**中安装的,学习使用 Node 和 Express 进行服务器端开发

  4. faker是用于测试和模拟的虚假数据生成器。

  5. ts-node-dev将帮助我们的 Node 服务器在任何 TypeScript 代码更改时重新启动。

现在我们已经安装了基本的依赖项,让我们启动我们的普通 Express 服务器,以确保一切正常运行:

  1. 我们需要创建一个服务器设置脚本来初始化我们的服务器,就像我们在第八章**中做过的那样,学习使用 Node 和 Express 进行服务器端开发。创建一个名为src的文件夹,然后在其中创建另一个名为index.ts的文件。然后,添加这段代码:
import express from "express";
import { createServer } from "http";
const app = express();
const server = createServer(app);
server.listen({ port: 8000 }, () => {
  console.log("Our server is running!");
});

基本上,这就是我们以前做的事情:创建一个express实例,然后使用它来创建我们的服务器。

  1. 现在,我们需要在package.json文件中创建一个"start"脚本。打开该文件,找到"scripts"部分。然后,在现有的"test"条目下,添加以下代码:
"scripts": {
    "test": "echo \"Error: no test specified\" &&        exit 1",
    "start": "ts-node-dev --respawn src/index.ts"
  },

该命令使用ts-node-dev来监视 TypeScript 更改的发生,然后"respawn"。这意味着它将根据需要自动重新启动 Node。

  1. 现在,如果你输入这个命令,你的服务器应该运行:
npm start

一旦它运行起来,你应该看到类似这样的东西:

图 10.1 – 第一次运行服务器

图 10.1 – 第一次运行服务器

正如你所看到的,我们的服务器使用我们的命令启动,并发出控制台日志消息。

  1. 如果你通过更改日志消息更新了index.ts文件,你会看到服务器自动重新启动,就像这个截图显示的那样:

图 10.2 – 服务器重新启动

图 10.2 – 服务器重新启动

服务器重新启动,然后显示我们的新消息,我们的服务器运行得很好!

在这一部分,我们开始学习服务器的重要依赖项。我们将使用所有这些软件包以及其他软件包来构建我们的 GraphQL API。在下一节中,我们将添加我们的 GraphQL 依赖项。

向项目添加 GraphQL 和依赖项

我们已经在*第九章**中看过了 GraphQL。在本章中,让我们回顾这些软件包,并介绍一些我们将使用的新相关软件包。

让我们列出一些我们将在应用程序中使用的与 GraphQL 相关的软件包:

  • graphql

这个软件包是 GraphQL 的 JavaScript 参考实现。它是由 GraphQL 基金会创建的,我们将使用它来进行一些 GraphQL 查询测试。

  • graphql-middleware

这是一个允许我们在解析器运行之前或之后注入我们的代码的软件包。一些可能的操作包括但不限于身份验证检查和日志记录。

  • graphql-tools

这个软件包在需要时为我们的 GraphQL 查询提供一些测试和模拟的帮助。

  • apollo-server-express

这是我们将用来创建 Express GraphQL 服务器的主要库,我们已经在第九章**中使用过了,什么是 GraphQL?

这些是我们将用于 GraphQL 实现的主要软件包。接下来,我们将创建一个 GraphQL 服务器,并为其构建一些测试。在后面的章节中,我们将把我们在这里使用的各种软件包合并到一个项目中。让我们看看步骤:

  1. 在我们的Chap10文件夹内创建一个名为gql-server的新文件夹。然后,从终端进入其中并运行这些命令:
npm init
  1. 接受所有默认值,然后运行此命令:
npm i express graphql graphql-tools graphql-middleware apollo-server-express uuid -S
  1. 完成后,运行这个命令:
npm i @types/express typescript @types/faker @types/jest faker jest nodemon ts-jest ts-node-dev @types/uuid -D
  1. 现在用这个命令初始化 TypeScript:
tsc -init
  1. 完成后,将node-server项目中的tsconfig.json文件的内容复制到这个新的gql-server项目文件夹中的tsconfig.json文件中。

  2. 现在,在我们的package.json文件中,像这样在scripts部分中添加一个start条目:图 10.3 – 启动脚本

图 10.3 - 启动脚本

  1. 现在让我们在gql-server文件夹的根目录中创建一个新的src文件夹。然后将这些文件从Chap9/graphql-schema项目复制并粘贴到src文件夹中:db.tsGqlContext.tsresolvers.tsserver.tstypeDefs.ts

  2. 通过使用以下命令启动应用程序,测试我们的应用程序是否能够运行:

npm start

现在让我们添加一些中间件并看看它是如何运行的:

  1. src文件夹中创建一个名为Logger.ts的新文件,并将以下代码添加到其中:
export const log = async (
  resolver: any,
  parent: any,
  args: any,
  context: any,
  info: any
) => {
  If(!parent) {
      console.log("Start logging");
  }
  const result = await resolver(parent, args, context,   info);
  console.log("Finished call to resolver");
  return result;
};

在这段代码中,我们拦截任何解析器调用并在resolver函数运行之前记录它们。请注意,我们检查parent对象是否为null,这表示resolver调用尚未运行。让我们还将日志记录添加到我们的getTodos解析器中。打开resolvers.ts并在getTodos函数体的开头添加这行代码,就在return语句之前:

console.log("running getTodos");
  1. 现在我们需要更新我们的server.ts文件,以便它使用这个记录器。像这样更新server.ts
import express from "express";
import { createServer } from "http";
import {
  ApolloServer,
  makeExecutableSchema,
  PubSub,
} from "apollo-server-express";
import typeDefs from "./typeDefs";
import resolvers from "./resolvers";
import { applyMiddleware } from "graphql-middleware";
import { log } from "./Logger";

在这里,我们导入了applyMiddleware函数和我们之前创建的log中间件。请注意,这个applyMiddleware函数来自graphql-middleware包,与 Apollo 的applyMiddleware函数不同,后者仅将 Express 实例与我们的 Apollo 服务器关联:

const app = express();
const pubsub = new PubSub();
const schema = makeExecutableSchema({ typeDefs, resolvers });
const schemaWithMiddleware = applyMiddleware(schema, log);
const apolloServer = new ApolloServer({
  schema: schemaWithMiddleware,
  context: ({ req, res }: any) => ({ req, res, pubsub }),
});

在这里,我们取得了由makeExecutableSchema创建的模式,并使用applyMiddleware函数创建了一个具有中间件关联的模式。然后,我们将这个模式schemaWithMiddleware应用到我们的 Apollo 服务器上。其余的代码没有改变,所以我不会在这里包含它。

  1. 如果您尚未启动服务器,请启动服务器并打开浏览器到 GraphQL 服务器 URL。如果您运行调用getTodos,您将看到todos数据如下返回:

图 10.4 - 调用 getTodos

图 10.4 - 调用 getTodos

此外,您应该在 Visual Studio Code 终端中看到我们之前设置的console.log消息:

图 10.5 - getTodos 结果

图 10.5 - getTodos 结果

我们的中间件运行并记录。然后,实际的解析器运行并返回数据。

所以,我们现在已经看到了允许我们拦截调用并将自己的代码注入 GraphQL 过程的 GraphQL 中间件。现在让我们尝试使用 GraphQL 创建一些测试:

  1. 我们需要为测试目的创建一个 GraphQL 查询运行器。创建一个名为testGraphQLQuery.ts的新文件,并将以下代码添加到其中:
import { graphql, GraphQLSchema } from "graphql";

我们导入graphqlGraphQLSchema,以便我们可以进行手动查询和类型模式文件。

  1. 导入Maybe,因为它是一个指示参数是否可能被使用的 GraphQL 类型:
import { Maybe } from "graphql/jsutils/Maybe";
  1. 创建我们的Options接口,它将作为testGraphQLQuery函数的参数类型来运行我们的查询:
interface Options {
  schema: GraphQLSchema;
  source: string;
  variableValues?: Maybe<{ [key: string]: any }>;
}

代码[key: string]表示对象属性名称 - 例如,myObj["some name"]testGraphQLQuery函数使用所需的参数调用,并返回相关数据:

export const testGraphQLQuery = async ({
  schema,
  source,
  variableValues
}: Options) => {
  return graphql({
    schema,
    source,
    variableValues,
  });
};
  1. 现在让我们编写我们的测试。创建一个getUser.test.ts文件,并将以下代码添加到其中:
import typeDefs from "./typeDefs";
import resolvers from "./resolvers";
import { makeExecutableSchema } from "graphql-tools";
import faker from "faker";
import { testGraphQLQuery } from "./testGraphQLQuery";
import { addMockFunctionsToSchema } from "apollo-server-express";

这些导入都相当不言自明,但faker导入用于帮助我们为测试对象的字段值创建虚假条目。

  1. 我们使用describe设置我们的测试,然后我们为getUser创建我们的查询,使用我们想要的字段:
describe("Testing getting a user", () => {
  const GetUser = `
        query GetUser($id: ID!) {
            getUser(id: $id) {
                id
                username
                email
            }
        }
    `;
  1. 现在在我们的测试中,我们首先从typeDefsresolvers的合并中创建我们的schema,然后为我们模拟的User对象设置我们的假数据字段:
  it("gets the desired user", async () => {
    const schema = makeExecutableSchema({ typeDefs,     resolvers });
    const userId = faker.random.alphaNumeric(20);
    const username = faker.internet.userName();
    const email = faker.internet.email();
    const mocks = {
      User: () => ({
        id: userId,
        username,
        email,
      }),
    };

正如第六章**所示,使用 create-react-app 设置我们的项目并使用 Jest 进行测试,模拟允许我们专注于我们想要测试的代码单元,而不必担心其他项目。

  1. 使用addMockFunctionsToSchema,我们将我们模拟的User对象添加到模式中,以便在进行相关查询时返回它:
    console.log("id", userId);
    console.log("username", username);
    console.log("email", email);
    addMockFunctionsToSchema({ schema, mocks });
  1. 最后,我们运行testGraphQLQuery函数来获取我们的模拟数据:
    const queryResponse = await testGraphQLQuery({
      schema,
      source: GetUser,
      variableValues: { id: faker.random.alphaNumeric(20)       },
    });
    const result = queryResponse.data ? queryResponse.     data.getUser : null;
    console.log("result", result);
    expect(result).toEqual({
      id: userId,
      username,
      email,
    });
  });
});

如果返回的对象具有相同的字段,那么它表明查询getUser查询的逻辑是有效的,因为调用已经通过整个代码路径获取了我们的User对象。

  1. 在运行测试之前,我们需要为jestpackage.json文件中添加一个配置。将这个配置添加到配置的末尾:
"jest": {
    "transform": {
      ".(ts|tsx)": "<rootDir>/node_modules/ts-
        jest/preprocessor.js"
    },
    "testRegex": "(/__tests__/.*|\\.(test|spec))       \\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ]
  }

这个配置确保所有带有spectest名称的文件都经过测试(这是testRegex部分),并且在运行之前将任何 TypeScript 文件转换为 JavaScript(这是transform部分)。

  1. 如果您在终端上运行jest命令,您应该会看到这个结果;确保您在gql-server路径上:

图 10.6 – GraphQL 查询测试结果

图 10.6 – GraphQL 查询测试结果

如您所见,测试通过了。我添加了几个log语句,以显示模拟的User字段是相同的。您应该避免在测试中这样做,因为这样很难阅读。

注意

在您的package.json文件的脚本部分,您可以用"test" : "jest"替换"test"条目。这样,它将更符合其他 NPM 脚本。与任何 NPM 脚本一样,您可以使用npm run test命令来运行它。

在本节中,我们了解了一些可用于 GraphQL 的 NPM 包。这些包可以帮助我们构建和测试我们的服务器,使它们更加可靠。在下一节中,我们将看一些其他可以帮助我们构建服务器的包。

审查辅助包

在本节中,我们将审查项目的一些辅助依赖项。我们的服务器,本质上是 Node、Express 和 GraphQL。但是,我们的服务器还需要执行许多其他活动,才能完整和完全功能。

让我们列出一些我们将在整个应用程序中使用的包,这些包应该可以让我们编写更少的代码,更多地专注于我们的核心业务逻辑:

  • bcryptjs

每个服务器都需要加密数据以确保安全。一个明显的例子是我们用户的密码。Bcrypt 是一种行业标准的加密算法,存在于许多平台上,包括 C++和 Java。bcryptjs是该算法的 JavaScript 实现,将帮助我们保护我们的应用程序。

  • cors

网络充满了安全隐患和黑客试图破坏服务器。因此,任何 Web 服务器的标准行为都是只允许来自与服务器相同域的客户端请求。对于复杂的服务器设置,如微服务和代理,这是不可行的。因此,cors包提供了在我们的服务器上执行 CORS 的工具。

  • date-fns

JavaScript Date 对象处理起来非常麻烦,date-fns提供了许多有用的方法来解析、格式化和显示日期和时间。

  • dotenv

每个大型应用程序都需要将配置信息存储在一个中心位置,以管理和保护敏感数据和设置。使用dotenv将允许我们维护我们的敏感信息设置,而不会向最终用户透露它。

  • nodemailer

nodemailer允许我们从 Node 服务器内部发送电子邮件。例如,我们可以发送电子邮件,允许用户重置密码或通知他们网站上的活动。

  • request

这个包将允许我们从 Node 服务器内部发出 HTTP 请求。例如,当我们需要从另一个 API 获取数据时,无论是第三方还是内部,这都可能很有用。

  • querystring

querystring将允许我们轻松地从对象创建 URL 查询字符串参数,并将 POST 请求的主体解析为字段。这个包可以与request包一起使用。

  • randomstring

randomstring可用于生成随机临时密码。

在构建我们的应用程序时,我们将使用更多的软件包 - 例如,允许我们连接到我们的 Postgres 数据库和 Redis 存储的软件包。然而,我将在相关部分介绍这些软件包,因为那时会更清楚这些软件包的作用。

在本节中,我们了解了一些我们项目中将要使用的杂项软件包。尽管这些工具不是我们应用程序的主要焦点,但它们仍然非常有价值。如果我们要自己编写这些依赖关系,我们将不得不成为各种领域的专家,比如加密和日期时间管理,这对我们来说将是一个巨大的时间浪费,因为这不是我们目标的核心。

摘要

在本章中,我们了解了我们将用来构建应用程序的其他 NPM 软件包依赖关系。这些工具在社区中被广泛使用,因此经过了充分的测试和可靠。使用 Node 生态系统中的软件包是 Node 最有价值的好处之一。它使我们不必自己编写、测试和维护这些额外的代码。

在下一章中,我们将详细审查我们将要构建的内容。我们将看到我们应用程序的各个组件是什么,然后我们将开始编写我们应用程序的 React 部分。

第十一章:我们将学到什么-在线论坛应用

无论我们学习了多少书,作为开发人员,如果没有构建一个使用特定技术栈的现实应用,我们就无法真正学会如何使用它来编程。在本章中,我们将了解我们打算构建的应用程序。我们将看到我们将如何应用我们所涵盖的一些主题。我们将看到我们的应用程序将具有哪些功能以及包括这些功能的一些原因。本作者还有相当多的构建论坛式应用的经验,比如我的最新应用 DzHaven。因此,您可以放心,您将学到的是实际在现实应用中使用的生产级代码。

在本章中,我们将涵盖以下主要主题:

  • 分析我们将要构建的内容-论坛应用

  • 分析论坛认证

  • 分析主题管理

  • 分析主题积分系统

分析我们将要构建的内容-论坛应用

正如所指出的,我们正在构建一个论坛应用。它在风格上与其他论坛应用相似,比如 StackOverflow 和 Bitcointalk.org。用户将能够在应用的社区中发布主题或提问,并从其他用户那里获得回复。

为什么要构建单个论坛应用?

我们本可以通过构建几个更简单的小型应用来展示 JavaScript 开发。用简单的应用演示全栈编程的问题在于它们无法展示现代 JavaScript 应用的所有功能。换句话说,它可能会在你实现某些功能的知识上留下漏洞,比如认证或数据库访问。

当然,我们可以构建一个图形密集型的照片或视频应用,但这些应用的问题在于需要大量关注图形设计和美学。此外,编辑照片或视频很好,但这些技能并不很好地转化为通用的全栈编程。这样的应用并没有错,但在这样的书中,主要目标显然是学习如何编写全栈应用,而不一定成为图形专家。

因此,论坛应用将使我们深入了解大型全栈应用中所需的各种框架。它还将展示我们需要在一个可以为许多用户提供服务的公开网站中实现的功能。在高层次上,我们将实现以下技术特性:

  • 安全

广义上讲,Web 安全包括两个主要部分。认证是服务器验证用户是否为其所说的用户的能力,授权是控制用户对应用程序功能的访问权限的能力。

  • 会话和 cookie

服务器端会话允许服务器维护有关用户在站点上的当前活动的数据。我们将使用会话和 cookie 来识别用户,并在他们使用站点时提供更轻松的体验。

  • 对象关系映射器

对象关系映射器ORM)是一种技术,允许代码(在我们的情况下是 TypeScript)与数据库进行交互,而不是 SQL。

  • 数据库访问和存储库层

数据库访问是复杂的,因此我们将使用一种称为存储库的设计模式,将我们的数据库访问代码与应用程序的其余部分分开。

如今,应用程序默认需要支持移动设备。我们需要确保手机用户能够访问并参与我们的应用社区。因此,我们将使用响应式方法构建我们的应用程序,以便我们的应用程序可以在桌面和移动设备上运行。响应式网页设计简单地意味着我们的应用程序屏幕将根据设备屏幕大小和尺寸进行调整。我们将使用现代的 CSS 和 JavaScript 技术来实现这一点。

在这一部分,我们回顾了我们将要构建的应用程序类型以及我们选择的原因。在下一部分,我们将看一下论坛认证及其一些特性。

分析论坛认证

在任何大型多用户应用程序中,我们需要使用一个系统来识别和授权用户。我们的论坛应用程序也不例外。

用户将能够发布论坛主题和回答论坛问题。因此,他们需要能够区分自己的活动和其他用户的活动。因此,我们将构建一个登录系统,允许用户使用自己的唯一帐户进行身份验证和在网站上执行活动。因此,我们将构建以下特性:

  • 登录和注销

这个功能将不仅包括GraphQL解析器,允许登录和注销,还包括屏幕,允许用户输入他们的 ID 和密码。我们还将使用几种技术,为用户的活动在任何给定时间提供一个唯一的会话状态。

  • 注册系统

注册系统将包括屏幕和解析器,允许用户创建一个唯一的帐户,以区分他们在网站上的活动。

  • 重置密码

用户有能力在需要时以安全的方式更改他们的密码。

  • 个人资料屏幕

一个屏幕和功能,显示用户的帐户信息。这些信息将包括他们的电子邮件和用户 ID。除此之外,它还提供了用户查看他们以前的所有帖子的能力,包括主题帖和回复。

  • 类别

基于类别创建分组,允许用户只查看他们感兴趣的类别中的帖子,并减少噪音。

  • 通知电子邮件

通过电子邮件联系用户并通知他们有关网站的额外要求或新闻的系统。例如,这可能包括验证电子邮件,检查他们注册的电子邮件是否有效,并且可以被同一用户访问。

在这一部分,我们回顾了我们将要构建的功能列表,以允许用户在网站上进行身份验证并唯一地识别自己和他们的活动。在下一部分,我们将审查我们将如何实现主题,这是我们应用程序内部的主要交流方式。

分析主题管理

网站上的每篇帖子都可以被视为一个主题的开始;也就是说,最初的主题帖会引发关于一个主题的讨论,并创建一系列回复。因此,我们的应用程序需要允许用户通过添加初始主题帖来开始讨论。然后所有用户都可以看到这个帖子并对其进行回复。每个主题项,包括初始帖子,都将被唯一地标识为发帖用户。因此,为了创建这个功能,我们将需要以下特性:

  • 主题帖发布和编辑

这个功能当然会包括被任何人查看的能力,以及作者添加和编辑主题帖的能力。用户还可以从他们的用户资料屏幕上看到他们所有的帖子。

  • 回复主题帖

这个功能将包括主题发起用户和其他用户对主题帖进行回复的能力。它具有查看主题帖以及所有与该主题相关的回复的能力。

为了将应用程序的复杂性降到最低,用户将不被允许回复特定的回复,而只能回复主题。但他们可以在回复中引用其他帖子。

在这一部分,我们回顾了应用程序的主要功能。创建和回复新主题将是应用程序的核心功能,尽管我们将添加其他相关功能以增强其功能。在下一部分,我们将审查我们将为主题积分系统构建什么。

分析主题积分系统

用户应该能够标记他们喜欢的评论并对其进行点赞。显示哪些帖子受欢迎也有助于用户更加参与沟通。在本节中,我们将回顾如何使用户能够表达他们对帖子的赞同。

为了实现这一功能,我们将在我们的应用程序中包括以下功能:

  • 积分系统

将创建一个积分系统,允许用户对帖子和回复进行点赞或踩。

  • 显示查看次数

显示用户查看帖子的次数。

  • 显示回复次数

显示帖子的回复次数,以让用户知道哪些主题受欢迎或正在流行。

在本节中,我们回顾了系统如何重要,以使用户能够表达他们对某些帖子的感受并查看主题的受欢迎程度。积分系统将增强用户参与度和活动性。

摘要

在本章中,我们看了一下我们将要构建的应用程序,它将具有的功能列表,以及选择这种类型应用程序的一些原因。由于我们正在构建一个全栈应用程序,我们即将构建的代码将会非常复杂和具有挑战性。您甚至可能会对这个应用程序的最终规模和范围感到惊讶。然而,一旦完成,我们将构建一个现代、复杂和完整的端到端应用程序。

在下一章中,我们将开始编写我们应用程序的 React 客户端部分的代码。由于我们还没有开始后端,我们将无法完全完成它。但是,我们将构建其中的很大一部分,您将能够看到许多屏幕。

第十二章:为我们的在线论坛应用构建 React 客户端

我们已经走了很长的路。在本章中,我们将开始编写我们的应用程序,从 React 客户端开始。我们将利用前几章学到的一切,使用新的 Hooks API 构建我们的 React 应用。我们还将使用响应式技术构建一个移动客户端,该客户端将适应其视图以处理移动设备和桌面设备。

技术要求

现在,你应该对使用 React、Node、Express 和 GraphQL 进行 Web 开发有很好的理解。你也应该熟悉 CSS。我们将再次使用 Node 和 Visual Studio Code 来编写我们的代码。

本书的 GitHub 存储库可以在github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node找到。使用Chap12文件夹中的代码。

要设置第十二章代码文件夹,转到你的HandsOnTypescript文件夹并创建一个名为Chap12的新文件夹。

创建我们的 React 应用的初始版本

在本节中,我们将构建我们的 React 客户端。我们将无法完全完成客户端,因为它将需要我们的后端功能,比如我们的 GraphQL API、认证能力、发布主题等等。但是,我们将开始创建我们的主要屏幕并设置 Redux 和 React Router。

本节将包含大量的代码。请经常休息并控制自己的节奏。代码将在我们的构建过程中不断演变、迭代和重构多次。有时,这将是为了更好的代码重用。有时,这将是为了改进我们的设计和可读性。因此,如果你遇到困难,请参考源代码。这将是本书迄今为止最具挑战性的部分。

注意

我们不会展示每一行代码,因为那将是多余的。请下载并在你的编辑器中打开源代码以便跟随。

在本节中,我们将涵盖以下主题:

  • React 项目设置和依赖配置

  • 样式和布局

  • 核心组件和功能创建

提示

从一开始就让一切编译和工作实际上对你的学习没有任何好处。不要专注于让东西简单地编译和运行。相反,尝试实验和进行更改。换句话说,打破代码,使其无法编译,然后修复它。这是确保你理解自己在做什么的唯一方法。

让我们从使用create-react-app创建我们的基本项目开始。然后,我们将添加 Redux 和 React Router:

  1. 转到终端中的Chap12文件夹并运行以下命令:
create-react-app super-forum-client --template typescript
  1. 接下来,进入新的super-forum-client文件夹并运行start命令以确保它正常工作:
npm start
  1. 现在,让我们安装 Redux 和 React Router:
package-lock.json file and the node_modules folder. Then, do a clean install using npm install.

所以,现在,我们已经安装了我们的核心包。在我们开始编码之前,我们需要讨论如何布置我们的应用程序。在我们的情况下,我们希望我们的应用程序能在移动设备和桌面上运行。这样,我们就可以拥有一个单一的应用程序,可以在手机、桌面和笔记本电脑上运行。

有多种方法可以实现这个目标。我们可以使用诸如Bootstrap这样的库或Ionic这样的 UI 框架来帮助我们构建 UI 和布局。这些框架非常好用,但它们也隐藏了一些关于 Web 上布局和样式工作的细节。使用框架时,你也可能失去一些控制,最终得到的网站看起来与使用相同框架的其他网站类似。

CSS Grid

对于我们的应用程序,我们将使用响应式网页设计。响应式网页设计只是我们的网页应用程序适应不同的设备和屏幕尺寸的意图。在使用 Web 技术时,有许多方法可以做到这一点。其中之一就是 CSS Grid。通过这个系统,我们可以构建我们的应用程序屏幕,充分利用桌面空间,同时自动重新配置为移动设备。因此,我们将使用 CSS Grid 以及其他 Web 技术来创建我们的布局。

CSS Grid 给我们提供了大部分像 Bootstrap 这样的程序所能实现的功能。但是,CSS Grid 是 CSS 网络标准的一部分,而不是第三方库的一部分。因此,我们知道我们的布局将始终与网络一起工作,永远不会突然不受支持。

那么,什么是 CSS Grid?CSS Grid 是内置标准 CSS 的布局方法,允许我们使用行和列创建灵活的布局。它被创建来取代表格布局的使用。CSS Grid 非常强大,有许多做同样事情的方法。为了保持简单,我将向您展示一种特定的方法来做到这一点,尽管如果您认为这将有用,您可以稍后探索更多选项。让我们开始使用 CSS Grid:

  1. 首先,回到我们的项目,打开App.tsx,并删除App对象的内容。做以下事情:
import React from "react";
import "./App.css";
function App() {
  return (
    <div className="App">
      <nav className="navigation">Nav</nav>
      <div className="sidebar">Sidebar</div>
      <div className="leftmenu">Left Menu</div>
      <main className="content">Main</main>
      <div className="rightmenu">Right Menu</div>
    </div>
  );
}
export default App;

正如您所看到的,我们已经摆脱了大部分内容,并用布局占位符替换了它。当然,我们最终会将这些元素制作成组件,但现在,我们将专注于使我们的Grid布局工作。

  1. 现在,让我们替换App.css文件的内容,就像这样:
:root {
  --min-screen-height: 1000px;
}

首先,有一个:root伪类,我们将使用它作为我们应用程序主题的 CSS 变量的容器。为了使样式和主题更一致和更容易,我们将使用变量而不是硬编码的值。随着我们构建应用程序,您将看到这里添加了越来越多的变量:

.App {
margin: 0 auto;

以下的边距设置使我们的布局居中:

  max-width: 1200px;
  display: grid;
  grid-template-columns: 0.7fr 0.9fr 1.5fr 0.9fr;
  grid-template-rows: 2.75rem 3fr;
  grid-template-areas:
    "nav nav nav nav" 
    "sidebar leftmenu content rightmenu";
  gap: 0.75rem 0.4rem;
}

以下是与 Grid 相关的属性的概述:

  • display:在这里,我们声明我们的元素将是grid类型。

  • grid-template-columns:这个属性告诉我们的应用程序列的宽度是相对的。在我们的设置中,它表示我们有四列。fr值表示应该给列的可用宽度的一部分。因此,例如,在我们的情况下,我们有四列,所以如果每列的可用宽度完全相等,每列的值将是1fr。但在我们的情况下,每列将使用不同数量的宽度,比均匀分配的宽度小或大,这就是为什么我们有不同的值。可能的值可以是具体的,比如100px2rem,百分比,比如例子20%,或者是隐式的,比如.25fr

  • grid-template-rows:指示行的数量和大小。可能的值与列相同。

  • grid-template-areas:每个 Grid 都可以有称为区域的标记部分。正如这个例子所示,您只需在网格形式的列和行中为每个区域添加标签。因此,在我们的情况下,"nav nav nav nav"代表我们的两行中的第一行,有四列,而"sidebar leftmenu content rightmenu"代表我们的第二行及其每一列。

  • gap:这是在列和行之间添加填充的一种方法。第一个条目表示行,第二个表示列。

  1. 现在我们已经解释了 CSS Grid 的基本特性,让我们来看看与 Grid 相关部分的样式。剩下的样式是用于 Grid 内容区域的:
.navigation {
  grid-area: nav;
}
.sidebar {
  min-height: var(--min-screen-height);
  grid-area: sidebar;
  background-color: aliceblue;
}
.leftmenu {
  grid-area: leftmenu;
  background-color: skyblue;
}
.content {
  min-height: var(--min-screen-height);
  grid-area: content;
  background-color: blanchedalmond;
}
.rightmenu {
  grid-area: rightmenu;
  background-color: coral;
}

如您所见,它们有一个grid-area属性,指示元素属于网格的哪个区域。nav区域将用于导航。sidebar将显示用户特定设置的菜单,并且仅在桌面和笔记本电脑上显示;它将在移动设备上隐藏。leftmenu将用于存储我们的主题类别列表。content将容纳我们按类别筛选的主题列表。最后,rightmenu将显示一些热门或相关的主题列表。

注意

我暂时使用这些尴尬的background-color设置,只是为了清楚地区分每个区域。最终,我们会将它们删除。

现在,我们的应用程序在桌面和笔记本设备上都有一个基本布局。但是,我们如何使其自动重新配置以适应较小的屏幕,比如手机和平板电脑?有一种名为媒体查询的 CSS 技术可以在这种情况下提供帮助。然而,对于我们的需求来说,它单独是不够的。

我们正在动态构建我们的应用程序,使用由状态更改驱动的 React。这意味着在较小的设备上,某些屏幕组件不应该被绘制,如果它们不需要或者不能在较小的设备上显示。因此,尽管我们可以使用媒体查询在检测到较小的屏幕时隐藏元素,但让 React 渲染永远不会被用户看到或直接使用的东西将是资源的低效使用。

相反,让我们看看我们可以在代码中如何使用事件处理和 React Hooks 来解决这个问题:

  1. 首先,我们要做的是将我们的元素转换为 React 组件。让我们在src文件夹内创建一个名为components的新文件夹。

  2. 然后,在该文件夹内,为我们App组件的根div中的每个元素创建一个容器组件。现在,您的src文件夹和App.tsx文件应该是这样的:

图 12.1 – 重构后的 App.tsx 文件

图 12.1 – 重构后的 App.tsx 文件

由于篇幅限制,我不会审查我们需要在这里创建的每个文件,因为这是高度重复的代码,但这是更新后的Main组件的一个示例(当然,源代码将包含所有组件的完整应用程序代码):

import React from "react";
const Main = () => {
  return <main className="content">Main</main>;
};
export default Main;

如您所见,我们只是将我们的代码从App.tsx移动到组件的Main.tsx文件中。这意味着您需要创建剩下的组件;也就是说,NavSideBarLeftMenuRightMenu。这是 React Developer Tools 屏幕的截图,显示了我们目前的组件层次结构。React Developer Tools 在第六章,使用 create-react-app 设置我们的项目并使用 Jest 进行测试中进行了讨论:

图 12.2 – 组件层次结构视图

图 12.2 – 组件层次结构视图

请注意,这里有NavSideBarLeftMenuMainRightMenu组件。每个组件代表我们网站根目录上的应用程序区域。请注意,随着我们构建应用程序,我们将有更多的屏幕。

无论如何,我们必须进行这种组件化,因为我们正在构建一个 React 应用程序。但是这如何帮助我们实现我们的愿望,使我们的 Web 应用程序响应,以便自动配置到不同的设备屏幕?通过将网格的每个区域分离为自己的组件,我们可以允许每个组件使用一个 React Hook 来查找屏幕尺寸信息。因此,如果组件不适合某个屏幕尺寸,它将不会渲染或以不同的方式渲染。

为了使这个响应式系统工作,我们需要两个主要功能。首先,我们需要一些额外的 CSS 样式,使用媒体查询在检测到较小设备时以不同的方式布局我们的网格。此外,我们需要让我们的组件在使用特定屏幕尺寸时变得意识到,并且要么不渲染组件,要么以不同的方式渲染它。让我们看看代码是什么样子的。

首先,让我们为移动设备创建媒体查询。打开您的App.css文件,并在文件底部添加以下媒体查询:

@media screen and (orientation: portrait) and (max-width: 768px) {
  .App {
    grid-template-columns: 1fr;
    grid-template-areas:
      "nav"
      "content";
  }
}

在这里,当设备的orientation处于portrait模式且分辨率为768px或更低时,我们覆盖了原始的App类定义。如果您在 iPhone X 上使用 Chrome 开发者工具以移动模式运行应用程序,您应该会看到这个:

图 12.3 - Chrome 开发者工具中我们应用程序的移动模式视图

图 12.3 - Chrome 开发者工具中我们应用程序的移动模式视图

应用程序右侧是白色的,因为我们仍在渲染原始桌面模式中存在的元素。我们很快会解决这个问题。现在,让我们创建我们的Hook,它有助于处理基于设备尺寸的渲染:

  1. src文件夹内创建一个名为hooks的文件夹。然后,添加一个名为useWindowDimensions.ts的文件。请注意,它不是一个组件,因为它有一个ts扩展名。从本书的 GitHub 存储库中复制源代码,然后我们来看一下。

首先,我们创建一个名为WindowDimension的接口,以便我们可以为我们的 Hook 返回的内容进行类型化,这种情况下是浏览器的window对象尺寸。

然后,在第 8 行,我们命名我们的useWindowDimensions Hook。然后,在下一行,我们创建一个名为dimension的状态对象,并为heightwidth赋值为0

  1. 接下来,我们创建我们的处理函数handleResize,它将使用状态更新方法setDimension来设置我们的尺寸值。我们浏览器的window对象提供了尺寸值。

  2. 最后,从第 21 行开始,我们使用useEffect Hook 来处理窗口的resize事件。请注意,空数组[]表示这将仅在首次加载时运行。还要注意,当我们添加事件处理程序时,我们还必须返回一个事件移除器(这可以防止内存泄漏和冗余事件处理程序被添加)。

  3. 现在,我们需要更新我们的SideBarLeftMenuRightMenu组件,以便它们将使用我们的useWindowDimensions Hook,并且知道在设备宽度小于或等于768时不进行渲染(与我们的媒体查询相同)。使用 Hook 的代码在这些组件中是相同的,所以我只会在这里展示SideBar组件。请以类似的方式自行更新其他组件:

import React from "react";
import { useWindowDimensions } from "../hooks/useWindowDimensions";
const SideBar = () => {
  const { width } = useWindowDimensions();
  if (width <= 768) {
    return null;
  }
  return <div className="sidebar">Sidebar</div>;
};
export default SideBar;

如您所见,我们使用useWindowDimensions Hook 来获取width维度。然后我们检查它是否为768或更低,如果是,我们返回null;否则,我们返回正常的 JSX。其他组件将使用相同的代码来使用useWindowDimensions Hook。

如果您运行应用程序,您会看到白色间隙现在已经消失,并且这些组件不会在 HTML 中进行渲染。请注意,为了节省时间,我们只会支持 iPhone X 的桌面和移动纵向模式。支持每种可能的设备配置超出了本书的范围。这是一个关于支持多个设备屏幕的主题的好链接:developers.google.com/web/fundamentals/codelabs/your-first-multi-screen-site

在我们继续之前,让我们完善我们的客户端基本配置,如 Redux 和 React Router。

  1. 更新您的index.tsx文件,以便包含 Redux 和 React Router。我们在第七章**,学习 Redux 和 React Router中涵盖了 Redux 和 React Router。如果遇到困难,源代码始终可用。

  2. 现在,让我们在src文件夹内创建一个名为store的文件夹,并在其中添加我们的 Redux 文件。创建AppState.tsconfigureStore.ts文件,并输入源文件中显示的代码。我们现在还没有准备好UserProfileReducer,所以您可以暂时将其排除在外。我们不会使用 Redux 中间件,因为我在第七章**,学习 Redux 和 React Router中展示了这一点。

现在,在我们继续并开始创建组件之前,让我们向我们的应用程序添加一个新的 React 功能,这将帮助我们增加更多的亮点。

错误边界

错误边界很像是 React 组件的异常处理。在大型应用程序中,通常无法防止所有可能发生的错误。因此,通过在我们的组件中使用错误边界,我们可以“捕获”意外错误,并为用户提供更好的用户体验。发生错误时,我们将显示一个预先创建的错误屏幕,而不是一些看起来可怕的技术错误消息。让我们开始吧:

  1. 首先,让我们创建我们的错误边界文件。在components文件夹内,创建一个名为ErrorBoundary.tsx的文件,并将此书的 GitHub 存储库中的源代码添加到其中。请注意,错误边界仍然使用旧的类样式,因为我们需要getDerivedStateFromErrorcomponentDidCatch生命周期事件处理程序来捕获错误。React 团队确实计划最终添加 Hooks 等效功能。

在文件顶部,请注意我们还有一个匹配的 CSS 样式文件。这很琐碎,所以我不会在这里展示,但您可以在源代码中找到它。

首先,我们将为我们的错误边界的 props 创建一个类型,称为ErrorBoundaryProps

接下来,我们必须为我们的错误边界的本地状态创建另一种类型,称为ErrorBoundaryState。在ErrorBoundary类定义的开头,我们将看到一些用于设置状态的构造函数的样板。紧接着,我们将使用getDerivedStateFromError函数告诉 React 如果hasError为 true,则显示错误 UI。

第 31 行,在我们的componentDidCatch函数中,我们的组件意识到发生了某种错误,并将我们的hasError状态变量设置为 true。我们还可以在这里运行我们自己的代码来记录错误并在需要时通知支持。

最后,如果hasError为 true,我们会呈现我们的消息,以便用户不必看到可能令人困惑的奇怪技术消息。当然,您可以编写自己的自定义消息。

警告

错误边界不会捕获发生在事件处理程序、异步代码或服务器端渲染的 React 中的错误,以及错误边界本身抛出的错误。通常情况下,您必须自己处理这些错误,使用try catch

  1. 现在,让我们通过在其中一个组件中抛出错误来测试我们的错误边界。更新Main.tsx文件的Main函数,如下所示:
const Main = () => {
  const test = true;
  if (test) throw new Error("Main fail");
  else {
    return <main className="content">Main</main>;
  }
};

如您所见,我们故意抛出了一个Error

  1. 现在尝试运行应用程序。您应该会看到我们试图避免的屏幕类型。为什么会发生这种情况?这是因为我们目前处于开发模式,React 故意在此模式下显示所有错误。如果我们处于生产模式,通过运行npm run build,我们将看到错误边界消息。

然而,即使在开发模式下,我们仍然可以在 Chrome 浏览器右上角点击x按钮来查看我们的错误边界屏幕。如果您这样做,您应该会看到以下消息:

图 12.4 – 错误边界消息

图 12.4 – 错误边界消息

如您所见,我们现在显示了正常的错误消息。再次,随意根据您的喜好设置此消息的样式。为了节省时间,我们将保持原样。

数据服务层

在我们的应用程序中,我们将调用 GraphQL API 或 Web API,或者获取网络调用。但是,这些后端服务都还没有准备好。现在,我们将创建一个文件,其中包含模拟真实后端的假网络调用。一旦我们的真实后端到位,我们将删除此功能:

  1. 首先,在src内创建一个名为services的文件夹,然后在其中创建DataService.ts文件。由于这是我们很快会丢弃的代码,我不会在这里展示,但你可以从源文件中获取代码。请注意,此服务中将包含对模型类型的一些引用,因此您需要添加这些引用,并且在本章的进展中我们将对其进行讨论。

  2. 现在我们有了获取数据的方法,让我们更新我们的LeftMenu组件,以便使用它。但首先,我们需要创建我们的Category类型,因为我们使用的是 TypeScript。在src内创建一个名为model的新文件夹。然后,创建Category.ts文件并添加源代码。

  3. 现在,更新LeftMenu.tsx文件。首先,我们将通过添加名为Category的模型类型和LeftMenu.css文件来更新导入。我们稍后会在我们的代码中使用它们。

  4. 然后,在line 9上,创建我们的状态对象categories,其中包含我们的类别列表。在我们加载Category数据之前,我们需要一些默认文本,Left Menu

  5. 然后,在line 13上,我们有useEffect,在其中我们调用我们的getCategories函数并获取我们的Categories。然后,我们使用 ES6 的map函数将我们的对象转换为 JSX。

  6. 最后,在返回的 JSX 中,我们在 UI 中使用Categories状态对象。

如果您重新加载浏览器,您将看到由于我们假的DataService中的定时器而出现 2 秒的延迟,然后显示类别列表,就像这样:

图 12.5 - 加载的类别

图 12.5 - 加载的类别

一旦我们的真实服务器调用准备就绪,我们将删除DataService

导航菜单

现在我们有了基本配置和布局,我们可以开始创建我们的侧边栏菜单。我们的侧边栏菜单项的有趣之处在于它们将同时用于侧边栏和作为移动设备的下拉模态框。这样,我们可以通过只有一个组件来减少代码量。

现在,为了创建具有正确链接集的侧边栏,我们需要知道用户是否已登录。如果他们没有登录,我们将显示登录和注册菜单。如果他们已登录,我们将显示注销和用户配置文件菜单。用户配置文件菜单屏幕将显示用户的设置,以及他们发布的帖子列表。由于我们的用户的登录状态将在整个应用程序中共享,让我们将这些数据放入我们的 Redux 存储中:

  1. 我们将使用UserProfile对象实例的存在或不存在作为用户已登录的指示。首先,让我们向当前空的 reducers 集合中添加一个新的 reducer。在store内创建一个名为user的新文件夹。现在,创建一个名为Reducer.ts的文件并添加所需的源代码。

  2. 然后,创建一个名为UserProfileSetType的操作类型,以便我们的UserProfileReducer可以与其他 reducer 区分开。

  3. 接下来,我们必须创建一个名为UserProfilePayload的有效负载类型。这是我们的操作在稍后分派时将包含的数据。

  4. 然后,我们必须创建UserProfileAction接口,它是action类型的。这用于区分用户配置文件的操作与其他操作类型。

  5. 最后,我们有我们的实际 reducer,UserProfileReducer,它根据我们期望的UserProfileSetType执行过滤。再次强调,Redux 在第七章**中已经涵盖了,学习 Redux 和 React Router。

  6. 为了帮助我们样式化我们的组件,我们需要使用图标来提供更好的视觉呈现。让我们安装 Font Awesome,因为它是免费的,并提供了一个吸引人的样式和图标套件,非常受欢迎的网页开发。运行以下命令:

npm i @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome  
  1. 现在我们已经添加了我们的图标,让我们在src/components文件夹内创建一个名为sidebar的新文件夹,并将现有的SideBar.tsx文件移动到其中。现在,创建一个名为SideBarMenus.tsx的新文件,并将以下代码添加到其中。确保你已经添加了必要的导入:
const SideBarMenus = () => {
  const user = useSelector((state: AppState) => state.   user);
const dispatch = useDispatch();

我们使用useSelectoruseDispatch Hook 来访问 Redux 的功能:

useEffect(() => { 
    dispatch({
      type: UserProfileSetType,
      payload: {
        id: 1,
        userName: "testUser",
      },
    });
  }, [dispatch]);

然后,我们使用useEffect Hook 来调用、分发和更新我们的UserProfile对象。注意它现在是硬编码的,但是当我们的后端准备好时,我们将使用 GraphQL 调用:

  return (
    <React.Fragment>
      <ul>
        <FontAwesomeIcon icon={faUser} />
          <span className="menu-name">{user?.userName}           </span>
      </ul>
    </React.Fragment>
  );
};

接下来,我们必须为 UserProfile 添加一个FontAwesome字体,然后显示当前的用户名。这个菜单项最终将是可点击的,这样我们用户的个人资料屏幕就会出现:

export default SideBarMenus;

在构建我们的登录、登出、注册等屏幕时,我们将把这些菜单项添加到这个 JSX 中。

我个人觉得项目符号很分散注意力,所以让我们通过在index.css文件中添加以下样式来移除应用程序所有无序列表的项目符号:

ul {
    list-style-type: none 
}
  1. 现在,我们需要更新SideBar.tsx,使其使用SideBarMenus.tsx。像这样更新SideBar。首先,添加适当的导入,比如SideBarMenus,首先:
const SideBar = () => {
  const { width } = useWindowDimensions();
  if (width <= 768) {
    return null;
  }
  return (
    <div className="sidebar">
      <SideBarMenus />
    </div>
  );
};

现在,我们可以更新 JSX 来包含它。

请注意,我们最终会编写一些代码,以便UserProfile图标和userName只在用户实际登录时出现。我们还将使其可点击,以便点击它会打开用户的 UserProfile 屏幕。然而,没有我们的后端,我们无法做到这一点。现在,我们将其作为一个占位符。

  1. 让我们继续并重用我们的SideBarMenus组件来进行移动显示。在components文件夹内更新Nav.tsx文件。添加适当的导入:
const Nav = () => {
  const { width } = useWindowDimensions();
  const getMobileMenu = () => {
    if (width <= 768) {
      return (
        <FontAwesomeIcon icon={faBars} size="lg"          className="nav-mobile-menu" />
      );
    }
    return null;
  };

同样,我们使用我们的useWindowDimensions Hook 来确定我们是否在移动设备上。然而,这一次,我们创建了一个名为getMobileMenu的函数来处理决定返回什么 JSX 的逻辑。如果我们不是在移动设备上运行,它不返回任何东西;否则,它返回汉堡菜单的FontAwesome图标:

  return (
    <nav className="navigation">
      {getMobileMenu()}
      <strong>SuperForum</strong>
    </nav>
  );
};
export default Nav;

当在移动设备上查看屏幕时,应该是这样的:

图 12.6 - 移动模式下的导航菜单

图 12.6 - 移动模式下的导航菜单

  1. 在构建我们的应用程序时,我们需要能够显示模态框。因此,在继续之前,我们需要安装react-modal。这个包将允许我们创建一些组件模态弹出框。这使得它们在何时显示上更加灵活。像这样安装react-modal
npm i react-modal
npm i @types/react-modal -D
  1. 为了使用这个模态框并使其响应并适应不同的设备屏幕,我们需要更新我们的样式。在我们的App.css文件中,你会看到一个名为modal-menu的类被应用到了所有的模态框上。

这是我们的非移动设备将获得的模态框的默认样式。这里需要注意的主要是模态框从屏幕的 50%位置开始。然后,我们使用transform将其拉回一半(自身的 50%)。这样可以使我们的模态框居中,使其位于屏幕的中间。请注意,z-index设置得很高,以确保这个模态框始终显示在顶部。

对于移动设备,我们使用App.css文件中现有的媒体查询来保存一个更新后的modal-menu。基本上,我们正在覆盖桌面样式中的相同属性,使用移动媒体查询的样式。在这种情况下,我们使用leftrighttop来将模态框拉伸到可用屏幕的两端。这就是为什么我们的 transform 现在是 0,因为它不再需要。

  1. 接下来,我们将为汉堡图标添加点击处理程序,然后在图标被点击时显示我们的SideBarMenus组件。因此,我们需要再次更新我们的Nav.tsx文件,以便包含我们的模态框,其中显示SideBarMenus。让我们更新Nav.tsx。首先添加适当的导入。然后,添加源代码中的代码。

  2. 如果我们从第 10 行开始查看,我们会看到一个名为showMenu的新本地状态。我们将使用它来控制我们是显示还是隐藏我们的模态菜单。

  3. onClickToggle处理程序在FontAwesomeIcon中使用,在getMobileMenu函数内,用于切换showMenu本地状态,从而显示或隐藏模态框。

  4. ReactModal中,当任何关闭请求进入组件时,我们需要设置控制显示的状态,以便可以明确地将其设置为 false;否则,模态框将不会消失。这就是onRequestClose的作用。shouldCloseOnOverlayClick属性允许我们关闭模态框,即使我们在外部任何地方点击也可以。这是用户通常期望的行为,所以最好有。

  5. 最后,JSX 已经更新,以便我们可以添加我们的ReactModal,其中包括我们的SideBarMenus组件。

正如您所看到的,模态框被称为ReactModal,在其属性中,有一个名为isOpen的属性。这决定了模态框是否显示。

  1. 如果您运行代码,然后点击汉堡图标,您将看到这个:

图 12.7 – 带有 SideBarMenus 的 ReactModel

图 12.7 – 带有 SideBarMenus 的 ReactModel

再次,随着我们添加更多功能,我们将扩展此菜单。

身份验证组件

现在我们已经设置好了我们的 SideBar,让我们开始构建我们的身份验证组件。我们将首先构建我们的注册、登录和注销屏幕:

  1. 让我们首先创建注册模态框。为了做到这一点,我们需要在SideBarMenus组件内部添加一个注册链接。打开SideBarMenus.tsx文件并像这样更新它:
li to the returned JSX and included the new icon and label for the register.
  1. 现在,在创建注册组件之前,让我们创建一个辅助服务,用于验证我们的密码。我们希望确保用户输入足够长且复杂的密码,因此我们需要一个名为commonsrc,然后另一个名为validators的文件夹。在validators文件夹中,创建一个名为PasswordValidator.ts的文件,并将以下代码添加到其中。代码非常简单,所以我不会在这里展示全部,但请注意密码强度和正则表达式。正则表达式只是在字符串中搜索模式的编程方式:
const strongPassword = new RegExp(
    "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])      (?=.{8,})"
  );
  if (!strongPassword.test(password)) {
    passwordTestResult.message =
      "Password must contain at least 1 special       character, 1 cap letter, and 1 number";
    passwordTestResult.isValid = false;
  }

在这里,我们使用了正则表达式来检查适当的复杂性,确保我们的密码中既有字母、数字又有符号。括号表示一组相关表达式。因此,首先是小写字母,然后是大写字母,然后是数字,然后是符号,最后是预期长度:

  return passwordTestResult;
};

这段代码并不特别复杂,但由于我们将需要在多个组件中使用,比如注册和服务器端,将其放在一个单独的文件中更有利于代码重用。

注意

在 SPA 网页开发中,通常会对验证进行两次 – 一次在客户端,一次在服务器端。这样做可能看起来多余,但对于增加的安全性是必要的。一旦我们开始构建服务器端代码,我们将学习如何在项目之间共享这样的依赖。

  1. 由于我们正在创建多个与身份验证相关的组件,让我们在components内部创建一个名为auth的文件夹,然后将我们的身份验证相关文件放在其中。一旦创建了auth文件夹,就在其中添加一个名为Registration.tsx的文件。将以下代码添加到文件中。如果您查看源代码,您将能够看到必要的导入。确保您的App.css文件也已更新。请注意,最终,我们将把其中一些代码移到共享位置,但现在,我们将直接在我们的Registration组件中使用它:
const userReducer = (state: any, action: any) => {
  switch (action.type) {
    case "userName":
      return { ...state, userName: action.payload };
    case "password":
      return { ...state, password: action.payload };
    case "passwordConfirm":
      return { ...state, passwordConfirm: action.payload        };
    case "email":
      return { ...state, email: action.payload };
    case "resultMsg":
      return { ...state, resultMsg: action.payload };
    default:
      return { ...state, resultMsg: "A failure has        occurred." };
  }
};

在这里,我们正在创建我们的 reducer,其中有许多相关字段:

export interface RegistrationProps {
  isOpen: boolean;
  onClickToggle: (
    e: React.MouseEvent<Element, MouseEvent> | React.    KeyboardEvent<Element>
  ) => void;
}

由于这是一个模态组件,我们允许父组件通过传递 props 来控制此组件的显示方式。isOpenprop 控制模态的显示方式,而onClickToggle函数控制模态的隐藏和显示:

const Registration: FC<RegistrationProps> = ({ isOpen, onClickToggle }) => {
  const [isRegisterDisabled, setRegisterDisabled] =    useState(true);
  const [
    { userName, password, email, passwordConfirm, resultMsg },
    dispatch,
  ] = useReducer(userReducer, {
    userName: "davec",
    password: "",
    email: "admin@dzhaven.com",
    passwordConfirm: "",
    resultMsg: "",
  });

在这里,我们有isRegisterDisabled本地状态值,如果给定值不正确,则禁用注册按钮,当然还有我们的本地 reducer,userReducer

  const allowRegister = (msg: string, setDisabled:     boolean) => {
    setRegisterDisabled(setDisabled);
    dispatch({ payload: msg, type: "resultMsg" });
  };

allowRegister只是一个用于设置注册按钮为禁用并在需要时显示消息的辅助函数。

  1. 接下来,我们有一系列onChange事件处理程序,用于每个字段,比如userName字段。它们根据需要进行验证,并更新输入的文本:
const onChangeUserName = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch({ payload: e.target.value, type: "userName" });
    if (!e.target.value) allowRegister("Username cannot     be empty", true);
    else allowRegister("", false);
};

onChangeUserName函数用于设置userName并验证是否允许继续注册:

const onChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ payload: e.target.value, type: "email" });
if (!e.target.value) allowRegister("Email cannot be empty", true);
else allowRegister("", false);
};

onChangeEmail函数用于设置电子邮件并验证是否允许继续注册:

const onChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ payload: e.target.value, type: "password" });
const passwordCheck: PasswordTestResult = isPasswordValid(e.target.value);
if (!passwordCheck.isValid) {
allowRegister(passwordCheck.message, true);
return;
}
passwordsSame(passwordConfirm, e.target.value);
};

onChangePassword函数用于设置密码并验证是否允许继续注册:

const onChangePasswordConfirm = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch({ payload: e.target.value, type:     "passwordConfirm" });
    passwordsSame(password, e.target.value);
};

onChangedPasswordConfirm函数用于设置passwordConfirm并验证是否允许继续注册:

const passwordsSame = (passwordVal: string, passwordConfirmVal: string) => {
if (passwordVal !== passwordConfirmVal) {
allowRegister("Passwords do not match", true);
return false;
} else {
allowRegister("", false);
return true;
}
};

最后,由于这是一个注册组件,我们使用passwordsSame来检查密码和确认密码是否相等。

  1. 接下来,我们有onClickRegisteronClickCancelonClickRegister按钮点击处理程序将提交尝试的注册。目前,由于我们没有后端,它不会进行实际提交,但一旦服务器启动,我们将填写它。另一方面,onClickCancel处理程序退出Registration组件:
const onClickRegister = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
e.preventDefault();
onClickToggle(e);
};
const onClickCancel = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
onClickToggle(e);
};

请注意,e.preventDefault函数只是阻止了标准行为,这取决于上下文而有所不同。在表单的情况下,我们的onClickRegister处理程序与表单标签内的按钮相关联,因此默认行为是提交并导致页面刷新。页面刷新是preventDefault

  1. 现在事件处理程序已经设置好,我们返回与这些处理程序相关联的 JSX。首先,我们从ReactModal包装组件开始:
return (
    <ReactModal
        className="modal-menu"
        isOpen={isOpen}
        onRequestClose={onClickToggle}
        shouldCloseOnOverlayClick={true}
    >
    <form>
        <div className="reg-inputs">
            <div>
                <label>username</label>
                <input type="text" value={userName}                onChange={onChangeUserName} />
            </div>

同样,我们的模态是由父组件通过isOpenonClickToggleprops 来外部控制的。

<div>
        <label>email</label>
        <input type="text" value={email}          onChange={onChangeEmail} />
      </div>

在这里,我们有我们的电子邮件字段。

      <div>
        <label>password</label>
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={onChangePassword}
        />
    </div>

这是我们的密码字段。

    <div>
        <label>password confirmation</label>
        <input
            type="password"
            placeholder="Password Confirmation"
            value={passwordConfirm}
            onChange={onChangePasswordConfirm}
            />
        </div>
    </div>

这是我们的密码确认字段。

    <div className="reg-buttons">
        <div className="reg-btn-left">
            <button
                style={{ marginLeft: ".5em" }}
                className="action-btn"
                disabled={isRegisterDisabled}
                onClick={onClickRegister}
            >
            Register
            </button>

这是我们的注册按钮。

            <button
                style={{ marginLeft: ".5em" }}
                className="cancel-btn"
                onClick={onClickCancel}
            >
            Close
            </button>

这是我们的取消按钮。

        </div>
            <span className="reg-btn-right">
                <strong>{resultMsg}</strong>
            </span>
        </div>
        </form>
        </ReactModal>
    );
};
export default Registration;

最后,请注意,我们有一个消息部分,它使用resultMsg reducer 字段。如果出现问题,它将显示错误。

  1. 现在,如果您在桌面模式下运行应用程序,您应该会看到类似这样的东西:图 12.8-桌面注册模态视图

图 12.8-桌面注册模态视图

如果您运行 Chrome 调试器并切换到移动模式,然后单击汉堡图标,然后单击注册标签,您将看到以下屏幕:

图 12.9-移动注册模态视图

图 12.9-移动注册模态视图

正如您所看到的,我们能够通过使用 CSS 响应能力有效地获得两个屏幕,而只使用一个组件。

  1. 现在,让我们继续登录模态。如果我们看一下现有的Registration组件,我们会发现它包含一些代码,我们也可以在Login组件中使用。我们真的应该重构代码,以便可以重用它。例如,RegistrationLoginLogout都将使用ReactModal,因此接收控制模态显示的 props。因此,让我们看看我们可以做些什么来重用我们现有的代码。首先,让我们从Registration.tsx文件中提取RegistrationProps接口,并将其放在自己的文件中。在components内创建一个名为types的文件夹。然后,创建一个名为ModalProps.ts的文件,并添加RegistrationProps接口。将其重命名为ModalProps

如您所见,它与RegistrationProps相同,只是名称更改。现在,打开Registration.tsx文件,删除RegistrationProps,并导入ModalProps。然后,用ModalProps替换RegistrationProps。检查一下是否一切正常运行。

  1. 我们重构了ModalProps,以便它可以在组件之间重复使用。现在,让我们拿出UserReducer,因为Login使用了它的一些字段。在现有的auth文件夹内创建一个名为common的新文件夹,并创建UserReducer.ts文件。将以下代码放入其中:
const userReducer = (state: any, action: any) => {
  switch (action.type) {
    case "userName":
      return { ...state, userName: action.payload };
    case "password":
      return { ...state, password: action.payload };
    case "passwordConfirm":
      return { ...state, passwordConfirm: action.payload       };
    case "email":
      return { ...state, email: action.payload };
    case "resultMsg":
      return { ...state, resultMsg: action.payload };
    case "isSubmitDisabled. This field will replace the existing isRegisterDisabled so that it can be used to disable buttons across any authentication screens.Now, remove `userReducer` from the `Registration.tsx` file and import it from the new `UserReducer.ts` file. Also, replace `isRegisterDisabled` with `isSubmitDisabled` and include `isSubmitDisabled` in your `destructured` object, as well as the state initializer of the `useReducer` Hook call.
  1. 现在,让我们进行另一个重构。Registration中的allowRegister函数禁用了一个按钮并更新了状态消息。这也可以清楚地被重用。让我们在 common 文件夹内创建一个名为Helpers.ts的新文件,并将以下代码放入其中:
import { Dispatch } from "react";
export const allowSubmit = (
  dispatch: Dispatch<any>,
  msg: string,
  setDisabled: boolean
) => {
  dispatch({ type: "isSubmitDisabled", payload: setDisabled });
  dispatch({ payload: msg, type: "resultMsg" });
};

如您所见,我们将函数名称更改为allowSubmit,现在将dispatch作为参数。现在,从Registration中删除allowRegister,并导入新的allowSubmit函数,并将allowRegister调用更新为allowSubmit调用。检查一下您的Registration.tsx文件的代码是否与源代码一致。

我们将保持两个onClick调用不变,即使Login也将有类似的调用,因为一旦我们的后端准备好,我们可能还需要为这些调用做一些特定于组件的事情。

现在,您应该能够运行此代码。

  1. 现在,我们可以在新的Login组件中使用新提取的代码。在auth文件夹中,创建一个名为Login.tsx的新文件,并从源代码中添加相关代码。我将在这里突出显示一些项目:
  const [
    { userName, password, resultMsg, isSubmitDisabled },
    dispatch,
  ] = useReducer(userReducer, {
    userName: "",
    password: "",
    resultMsg: "",
    isSubmitDisabled: true,
  });

由于我们的Login组件与我们的Registration组件有不同的需求,我们只使用了userReducer中的一部分字段,通过对象解构来实现。

在 JSX 中,注意我们已经更新了一些 CSS 类,以便更好地对齐按钮。这些新类在App.css文件中。

  1. 最后,我们需要添加一个登录链接。更新SideBarMenu.tsx文件,如源代码所示。

由于Logout非常相似,我已经添加了该组件,但不会在这里进行介绍。随着后端的进一步完善,我们将添加代码来控制显示哪些菜单链接取决于用户的登录状态。我们还将添加额外的验证。但在此之前,我们还有很多工作要做,所以让我们继续。

路由和屏幕

现在,让我们继续创建应用程序需要的路由。到目前为止,我们的应用程序只有一个 URL。根 URL 是http://localhost:3000。现在,我们希望将我们的应用程序划分为具有特定部分的不同路由。我们将从获取现有代码开始,修改它,并将其作为我们的第一个根 React 路由。让我们开始吧:

  1. 首先,让我们将我们的网格区域相关组件移入不同的文件夹。首先,在components文件夹内创建一个名为areas的文件夹。然后,将Nav.tsxNav.cssRightMenu.tsxMain.tsxLeftMenu.tsxLeftMenu.css文件,以及整个sidebar文件夹,移入新的areas文件夹。您的文件路径导入将需要更新,包括App.tsx文件。查看源代码,了解如何操作。

  2. 完成后,在areas内创建一个名为main的新文件夹,并将Main.tsx文件移入其中。确保更新您的路径。我们将把所有与主要区域相关的组件添加到此文件夹中。

  3. 我们将在此文件夹中创建的第一个新组件是MainHeader组件。顾名思义,它将用作主区域的标题。它将显示我们当前正在查看的主题项目的类别。在main文件夹内创建MainHeader.tsx文件,并将源代码中的代码添加到其中。

此控件的唯一目的是显示当前的Category名称。

再次注意,我们在MainHeader.cssApp.css文件中有一些新的 CSS 类。

主屏幕

在继续之前,让我们为我们的新路由执行一些基本设置。在这里,我们将创建我们的新屏幕组件Home,并更新任何相关文件,例如App.tsx

  1. 当我们第一次创建App.tsx文件时,我们假设我们的应用程序只有一个屏幕。显然,这是不正确的。现在我们已经完善了我们的布局,让我们开始添加我们不同的屏幕和路由。打开App.tsx文件并像这样更新它。

在这里,我们添加了一个名为Home的新导入,代表了主页路由。我们稍后会构建这个:

import Home from "./components/routes/Home";function App() {
const renderHome = (props: any) => <Home {...props} />;

我们在这里定义一个函数,以发送到我们路由的render属性。这个函数允许所有路由的 props,以及我们想要发送的任何自定义 props,在初始化我们的Home组件时包含在内:

  return (
    <Switch>
      <Route exact={true} path="/" render={renderHome} />
      <Route
        path="/categorythreads/:categoryId"
        render={renderHome}
      />
    </Switch>
  );
}

因此,以前显示我们的网格区域的代码现在将在Home组件中,而这个组件我们稍后会构建。

第七章**所示,学习 Redux 和 React Router,我们的Switch组件允许 React Router 根据提供的 URL 更改路由屏幕的渲染。目前,我们将有两个指向相同Home屏幕的路由,但稍后我们将添加更多。根路径将显示默认类别的线程,而categorythreads路由将显示特定类别的线程。

  1. 在创建新的Home组件之前,让我们稍微重构一下我们的 CSS,并使其更具可重用性。首先,通过在App类之前添加以下类来更新App.css文件:
.screen-root-container {
  margin: 0 auto;
  max-width: 1200px;
  margin-bottom: 2em;
  border: var(--border);
  border-radius: 0.3em;
}

这将成为我们应用程序中代表路由屏幕的任何组件的根类。

  1. 接下来,在components/routes文件夹内创建一个名为Home.css的新文件。现在,从App.css中剪切整个 CSS 样式集:
.Home.css file. Once they've been copied over, change the name of the App class to home-container. We're changing the name so that the class' purpose is clearer. Now, let's create our new Home screen component and learn how to use these CSS classes.
  1. components文件夹内创建一个名为routes的文件夹,并在其中添加一个名为Home.tsx的新文件。代码很简短简单,所以您可以直接从源代码中复制。这主要是来自以前版本的App.tsx的旧代码。

我们已经更新了我们的根 CSSApp类,现在它是screen-root-container home-container。在一个类属性中使用两个类,意味着首先应用第一个类的样式,然后应用下一个类的样式,这将覆盖之前的任何设置。此外,我们现在将能够在其他屏幕中使用screen-root-container

我们已经成功地将原始的App.tsx代码移到了Home.tsx文件中。请注意,我们还将我们的Nav组件放在了一个div标签内。我们这样做是为了以后可以在其他屏幕中重用Nav组件。您现在应该从Nav.tsx组件文件中删除className="navigation"属性。

  1. 现在我们已经更新了我们的Home屏幕,我们需要更新我们的Main组件,以便列出给定类别内的线程。为了做到这一点,我们实际上需要做一些更新。首先,我们需要创建两个新模型,名为ThreadThreadItemThread是初始帖子,而ThreadItem是一个回复。让我们从我们的模型开始。

首先,在models文件夹中创建Thread.ts,如源代码所示。

这里没有太多需要解释的,因为这是相当明显的。但是,请注意,points表示点赞的总数。

接下来,让我们做ThreadItem.ts。创建所需的文件并将源代码添加到其中。这与Thread非常相似。

  1. 现在,我们将创建线程卡文件组件。这个组件将代表一个单独的线程记录,并显示诸如标题、正文和点数等内容。在components/areas/main文件夹内创建一个名为ThreadCard.tsx的文件。然后,添加代码:
import React, { FC } from "react";
import "./ThreadCard.css";
import Thread from "../../../models/Thread";
import { Link, useHistory } from "react-router-dom";
import { faEye, faHeart, faReplyAll } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useWindowDimensions } from "../../../hooks/useWindowDimensions";

首先,我们有各种导入,包括Link对象和来自 React Router 的useHistory Hook:

interface ThreadCardProps {
  Thread object as our parameter. We will use this object and its members as we render our ThreadCard UI:

const ThreadCard: FC = () ⇒ {

const history = useHistory();

const = useWindowDimensions();

const onClickShowThread = (e: React.  MouseEvent) ⇒ {

history.push("/thread/" + thread.id);

};


Here, we are using the React Router `useHistory` Hook to get the `history` object. When someone clicks on our Thread, we use the `history` object to redirect the app to a new URL by `pushing` the new URL on top of the `history` object. We will build our thread route and component later:

const getPoints = (thread: Thread) ⇒ {

如果(宽度 ⇐ 768){

return (

<label

style={{

marginRight: ".75em",

marginTop: ".25em",

}}

{线程.点数 || 0}

<FontAwesomeIcon

icon=

className="points-icon"

style={{

marginLeft: ".2em",

}}

/>

);

}

return null;

};


The `getPoints` function creates the UI for displaying "likes" on our post. However, since our UI is responsive, it does not appear in desktop mode when we check the screen's `width` property:

const getResponses = (thread: Thread) ⇒ {

如果(宽度 ⇐ 768){

return (

<label

style={{

marginRight: ".5em",

}}

{线程 && 线程.线程项目 && 线程.线程项目.length}


This function shows the response count, as indicated by the `thread.threadItems.length` property:

<FontAwesomeIcon

icon=

className="points-icon"

style={{

marginLeft: ".25em",

marginTop: "-.25em",

}}

/>

);

}

return null;

};


The `getResponses` function shows how many `ThreadItems` responses there are for this `Thread`. However, since our UI is responsive, it does not appear in desktop mode when we check the screen's `width` property:

const getPointsNonMobile = () ⇒ {

如果(宽度 > 768){

return (

{线程.点数 || 0}


className="threadcard-points-item"

style={{ marginBottom: ".75em" }}

{线程 && 线程.线程项目 && 线程.线程项目.length}


This function is getting the likes count, as indicated by the `thread.threadItems.length` property:


);

}

return null;

};


The `getPointsNonMobile` function returns the points column on the right of `ThreadCard`, but only renders it if the device is a desktop or laptop with a screen width bigger than 768 pixels.Remember that every React component that may be used multiple times on the same screen must have a unique `key` value. So, later, when we use this component, you will see that each instance has been given a unique `key` value. The following JSX is returning the `Category` name as a `Link` so that when it's clicked, the user will be sent to the screen showing the Threads for that `Category`:

return (

to={/categorythreads/${thread.category.id}}

className="link-txt"


`Link` is a React Router component that renders a URL anchor (HTTP link). Notice that `categorythreads` is the second route we created earlier and that it takes `categoryId` as a parameter:

<span className="username-header" style={{            marginLeft: ".5em" }}>

onClick=

data-thread-id=

style={{ marginBottom: ".4em" }}

className="threadcard-body"

onClick=

data-thread-id=

{thread.body}

As you can see, we use the `thread` prop extensively while rendering our UI.Here, we are using the `getPoints` and `getResponses` functions to render a subset of our UI so that it shows points and responses:

<span

样式={{

marginRight: ".5em",

}}

{getPoints(thread)}

{getResponses(thread)}

          


Here, we are using `getPointsNonMobile` to show our response count and likes:

{getPointsNonMobile()}    

);

};

export default ThreadCard;


Notice that we have referenced many CSS classes in this component, all of which can be found in the `ThreadCard.css` and `App.css` files in the source code. I won't go over every single CSS class here, but if you look at the `ThreadCard.css` file, you'll notice that there is a reference to something called `flex`. Flexbox is another method of creating a layout in CSS similar to Grids. However, Flexbox is intended to be used for single-row or single-column layouts; for example:

.threadcard-txt-container {

display: flex;

flex-direction: column;

width: 92%;

margin: 0.75em 1em 0.75em 1.2em;

border-right: solid 1px var(--border-color);

}


In this CSS, the display method is indicated as `flex` and `flex-direction` is column. This means that the layout of all the elements inside `threadcard-txt-container` will be in a single stacked column. So, even if we had elements such as labels or buttons, which are normally set in a horizontal line, if they live inside a column-based flex container, they will be laid out vertically. If we had used the row attribute, then the layout would be horizontal.
  1. 现在我们已经创建了我们的 Thread 容器,ThreadCard,让我们更新我们的Main.tsx文件,以便我们可以使用它。添加源代码中的代码。

如果您查看第 8 行,您将看到useParams函数被使用。之前,我们在App.tsx文件中为 React Router 创建了两个路由。其中一个路由categorythreads接受了一个 URL 参数。通过使用useParams Hook,我们可以获取路由参数 - 在这种情况下是categoryId - 以便我们可以使用它们。

然后,在第 9 行,我们有category状态。一旦我们从线程列表中检索到我们的类别,我们将更新此状态。

第 10 行,我们有一个状态对象,它是我们的ThreadCards列表,称为threadCards

然后,在useEffect中,如果我们获得新的categoryId,我们将更新我们的ThreadCards列表。当我们获得有效的categoryId时,我们使用我们的DataService来查询特定类别的线程列表,然后构建出ThreadCards列表。我们还取第一个线程来获取类别的名称,因为它们都属于同一类别。

最后,我们返回我们的 UI。

注意

有时,您会看到有关useEffect Hook 数组中缺少依赖项的警告。这些是我认为是有意见的警告,通过经验,您将能够判断哪些可以安全地忽略。例如,在Main.tsxuseEffect中,我故意忽略了关于category状态对象的警告,因为将对象包含在数组中会触发useEffect的不必要的双重运行(因为useEffect在其数组列表中的某些内容发生变化时运行),可能会导致双重渲染。

  1. 现在,让我们尝试在桌面模式下运行。转到http://localhost:3000/categorythreads/1。你应该看到以下内容:

图 12.10 - 类别帖子 URL 的桌面视图

图 12.10 - 类别帖子 URL 的桌面视图

在移动设备上它是这个样子的:

图 12.11 - 类别帖子 URL 的移动视图

图 12.11 - 类别帖子 URL 的移动视图

正如你所看到的,在移动模式下,我们没有右侧的积分栏。相反,这些积分在主文本部分的底部。图标显示,对于第一篇帖子,有两个人看过它。有 55 个人喜欢它,还有一个人回复了。

哇 - 我们刚刚经历了很多代码!但是,我们还没有完成!让我们继续构建我们的RightMenu组件。

在我们的RightMenu上,我们想要显示一个帖子数量最多的三个类别的列表。在每个类别中,我们将显示最多人浏览的帖子。让我们开始吧:

  1. 首先,在areas文件夹内创建一个名为rightMenuRightMenu文件夹。

  2. 现在,在那个文件夹里创建一个名为TopCategory.tsx的新文件。这个组件将代表一个单独的顶级类别及其帖子。

  3. 创建一个新的模型,代表从服务器传来的数据。让我们称之为CategoryThread。在models文件夹内创建一个名为CategoryThread.ts的文件,并输入源代码。

  4. 现在,我们需要更新我们现有的RightMenu组件,并创建一个新的组件来显示我们的CategoryThread项目。为了对我们的CategoryThread项目进行分组和组织,我们需要使用一个叫做 Lodash 的工具来帮助我们。

Lodash 是一个提供了大量 JavaScript 辅助函数库的依赖项。在这里不可能涵盖它的所有功能。然而,Lodash 特别适用于管理数组和集合。你会发现它非常容易使用,但如果你想要更多细节,这里是他们的文档链接:https://lodash.com/docs/。像这样安装 Lodash:

import _ from "lodash". You will add an enormous amount of code to your project by doing so. Only import the specific call using import groupBy from "lodash/groupBy".Now, we can update our `RightMenu.tsx` file as shown in the source code.First, notice that in addition to Lodash, we also imported a new `RightMenu.css` file, along with some minor styling. We also imported the `TopCategory` component, which we'll build after.Next, we have a new state object called `topCategories` that we will use to store our array of top categories.Then, in `useEffect`, we have our top categories from the `getTopCategories` function. Then, we group the results by category and create our array of `TopCategory` elements. The `TopCategory` component elements will display our data. Notice that the `TopCategory` component receives each group of top categories through the `topCategories` prop.The component then returns the `topCategories` elements.
  1. 现在,我们需要构建我们的TopCategory组件。在与RightMenu相同的文件夹中创建一个名为TopCategory.tsx的文件,并添加相关的源代码。

在顶部,请注意我们有一个补充的 CSS 文件叫做TopCategory.css

接下来,我们有一个名为TopCategoryProps的新接口用于接收 props。在line 10上,当准备好时,帖子状态对象将存储我们的 JSX 元素。

然后,在line 12上,我们有useEffect,我们将使用它基于传入的 prop 来构建我们的 UI 元素;也就是说,topCategories

返回的 JSX 有一个strong标题,这是找到的第一个类别元素的名称,因为顶级类别的数组总是来自一个类别。然后,我们包括了我们的帖子列表。

  1. 由于这个RightMenu在移动设备上不渲染,让我们看看在桌面上它是什么样子的:

图 12.12 - 带有顶级类别的 RightMenu

图 12.12 - 带有顶级类别的 RightMenu

好的 - 我们快要完成了!我们已经完成了大部分我们主屏幕所需的内容,但现在,我们需要我们的应用程序显示单独的帖子

帖子屏幕

这个屏幕将是多用途的。使用这个屏幕,我们将能够创建一个新的帖子或显示一个现有的帖子。我们还将在同一个屏幕上显示帖子的回复。让我们开始吧:

  1. 首先,我们需要创建我们的新路由组件。我们将称之为Thread.tsx,并将其放在一个名为thread的新文件夹中,该文件夹应放在routes文件夹内。然而,我们的Thread组件将会很复杂,所以我们应该将其拆分为称为子组件的模块化部分。在这种情况下,这样做不会给我们带来代码重用的好处。然而,这将使代码更容易阅读和重构,因为它将被分发成块,而不是一个非常庞大的单体。让我们创建一个名为ThreadHeader.tsx的新组件文件,并将源代码添加到其中。

首先,注意我们正在导入的新函数getTimePastIfLessThanDay。这个函数将查看传入的日期并适当地格式化它,以便易于阅读。

这个组件将接受字段作为参数,而不具有自己的状态。ThreadHeader充当一个仅用于显示的组件。它显示了主题的titleuserNamelastModifiedOn时间。

  1. 现在,创建Thread.tsx文件并将源代码添加到其中。

请注意,我们正在导入一个新的Thread.css文件和我们的新的ThreadHeader组件。还要注意,由于我们的组件也被称为Thread,就像我们的模型一样,我将我们的模型导入为ThreadModel。这种问题在大型项目中可能会经常发生,所以你应该知道你可以以这种方式导入。

接下来,我们必须创建我们的本地thread状态对象,它是ThreadModel类型。然后,我们必须再次使用useParams Hook 来获取路由参数的id,这是这条线程的 ID。

useEffect中,如果id路由参数存在且大于0,我们尝试获取我们的thread。稍后,一旦我们的后端准备好了,我们将编写一些代码,以便可以插入新的线程。

最后,我们返回我们的 UI,其中包括ThreadHeader。请注意,lastModifiedOn字段是非空的,所以我们使用三元检查来检查thread是否为空,如果为空则返回当前日期。

  1. 现在,我们需要为我们的Thread屏幕组件创建一个新的路由。再次打开App.tsx并更新代码,就像这样:
function App() {
  const renderHome = (props: any) => <Home {...props} />;
  const renderThread = (props: any) => <Thread {...props} />;

在这里,我们为我们的Thread组件添加了renderThread函数:

  return (
    <Switch>
      <Route exact={true} path="/" render={renderHome} />
      <Route
        path="/categorythreads/:categoryId"
        render={renderHome}
      />
      <Route
        path="/thread/:id"
        render={renderThread}
      />
    </Switch>
  );
}

请注意,我们的Thread的路由是"/thread/:id",这意味着在线程路径之后,它期望一个参数。在内部,React Router 将其标记为id

  1. 现在,我们将添加我们线程屏幕的下一部分。在这个屏幕上,我们将通过下拉菜单显示线程的类别。然而,由于 HTML 中标准的下拉菜单,称为select元素,外观丑陋且与 React 集成不佳,我们将使用一个名为react-dropdown的 NPM 包来帮助我们获得一个更具吸引力和 React 集成的控件。

像这样安装react-dropdown

ThreadCategory.tsx in the thread folder and add the source code to it.Once you've set up the imports, create the `ThreadCategoryProps` interface, which will represent our prop type.Next, we start creating our `ThreadCategory` component and set up a constant variable, `catOptions`, that contains the items that will appear as selectable options in our dropdown. Again, we are only temporarily hardcoding values until our backend is ready.Finally, we are returning the JSX with an initialized `DropDown` control.
  1. 现在,让我们创建我们的Title组件。我们将它称为ThreadTitle。在thread文件夹中创建一个名为ThreadTitle.tsx的文件,并将源代码添加到其中。

这只是一个简单的渲染器,所以我不会在这里解释它。然而,请注意,目前我们的onChangeTitle处理程序是空的。同样,一旦我们的后端准备好了,我们将区分读取和写入状态,并实现onChangeTitle函数。

  1. 现在,让我们更新我们的Thread.tsx文件,看看我们到目前为止做了什么。像这样更新Thread.tsx。请注意,随着我们添加这些与 Thread 相关的组件,我们一直在更新Thread.css文件,所以也要保持你的 CSS 文件更新。

状态和useEffect代码基本上是一样的,所以我不会在这里展示它:

  return (
    <div className="screen-root-container">
      <div className="thread-nav-container">
        <Nav />
      </div>
      <div className="thread-content-container">
        <ThreadHeader
          userName={thread?.userName}
          lastModifiedOn={thread ? thread.lastModifiedOn : new Date()}
          title={thread?.title}
        />
        <ThreadCategory categoryName={thread?.category?.name} />
        <ThreadTitle title={thread?.title} />
      </div>
    </div>
  );
};

在这里,我们已经将新的组件添加到了返回的 JSX 中。正如你所看到的,我们的代码比在Thread.tsx文件中拥有单独的元素和事件处理程序时要简短和易读得多。

如果你通过http://localhost:3000/thread/1运行应用程序,你应该会看到这个:

图 12.13 - 线程屏幕

图 12.13 - 线程屏幕

请注意右侧的空白处是我们将为线程添加喜欢和回复计数信息的地方。

现在,我们不会在这里审查每个 CSS 文件,因为我们想专注于代码,但由于这是一个重要的屏幕和路由目的地,让我们审查一下 CSS,看看我们是如何布局的。到目前为止,我们就是这样。更新你的Thread.css文件,这样我们就可以一起看一下。

就像我们之前在主屏幕上做的那样,我们将我们的导航控件放在自己的名为thread-nav-container的 div 容器中。

thread-content-container类是实际的 Thread 内容布局。正如您所见,布局是一个具有两列和不确定数量行的网格。

其余内容使用grid-column属性添加到第一列。稍后我们将添加第二列来保存我们的 Thread 的点(赞)。

  1. 现在,我们需要为我们的 Thread 帖子的正文添加一个部分。正文条目更复杂,因为我们需要添加一个富文本条目格式化程序。这个控件将允许用户格式化他们的文本并进行更复杂的编辑。

为了创建我们的正文,让我们安装一个名为 Slate.js 的 NPM 包。这将是我们的富文本编辑器和格式化程序。我们还需要安装几个依赖项,包括一个叫做 Emotion 的东西。Emotion 是一个允许我们直接在 JavaScript 中使用 CSS 的库:

editor inside our components folder and create a new file called RichTextControls.tsx. This file contains the controls that we will be using in our editor. The source code I am using is from the Slate.js project at https://github.com/ianstormtaylor/slate/blob/master/site/components.tsx. This code is fairly large, so I'll show and explain the relevant code as we use each control. 
  1. 接下来,我们需要在相同的editor文件夹中创建RichEditor.tsx文件,并将此代码添加到其中。

在我们的导入部分的顶部,我们可以看到通常的与 React 相关的导入,但也有两个 Slate.js 的导入。这些是帮助我们创建编辑器 UI 的。我稍后会更详细地解释这些。

isHotKey导入是一个帮助我们为编辑器构建键盘快捷键的工具。

withHistory导入允许编辑器保存已发生的编辑,以正确的顺序,以便在需要时可以撤消。

ButtonToolbar是可以用来构建我们的编辑器 UI 的控件。我们稍后将创建RichTextControls文件。

现在,我们可以导入我们的图标和 CSS 样式表。

HOTKEYS变量是一个包含各种快捷键到格式配对的字典。左边的[keyName: string]表示字典键;右边显示值。

第 26 行,我们有initialValue变量。我们的编辑器使用对象作为其值,而不是字符串。因此,initialValue变量表示编辑器的起始值对象。类型是来自 Slate.js 编辑器的Node数组。在 Slate.js 中,文本被表示为节点的分层树。这是为了确保文本的结构保持完整,同时也允许格式信息与文本一起存在。您可以将其视为文本和元数据一起。

LIST_TYPES数组用于区分条目是段落还是文本列表。

第 38 行,我们开始创建我们的RichEditor组件。正如我们之前提到的,在 Slate.js 中,编辑器内文本的值或内容不是普通文本。它是一个 JSON 对象,其根类型是Node。因此,我们的主文本值,称为value,是Node数组类型的状态对象。

接下来,我们有renderElement函数,它在内部用于呈现较大的文本片段。Element是一组多行文本。我们稍后将构建Element组件。

然后,我们有renderLeaf函数,用于呈现较小的文本片段。Leaf是一小段文本。我们稍后将创建这个组件。

请注意,我们在第五章**,使用 Hooks 进行 React 开发中介绍了useCallbackuseMemo等 Hooks。

然后我们有编辑器变量。编辑器是接受和显示文本的 React 组件,而不是SlateToolbarEditable组件,它们作为编辑器周围的包装器,并为其注入或修改文本格式。

useEffect函数用于获取existingBody属性并将其作为本地状态值,假设传入了existingBody。再次强调,existingBody仅在查看模式下传入,而不是创建模式。

onChangeEditorValue事件处理程序在 UI 中更改时设置本地value状态。再次注意,值类型不是文本,而是Node数组。

第 59 行开始,我们开始定义我们的 JSX。我们使用我们的editor实例、本地value状态和onChange事件初始化我们的 Slate 包装组件。

接下来,Toolbar来自RichTextControls.tsx文件,表示一个布局容器,并包含我们的格式化按钮。它们看起来像这样。我稍后会解释MarkButtonBlockButton

图 12.14 – Slate.js 工具栏按钮

图 12.14 – Slate.js 工具栏按钮

可编辑控件包含了我们编辑器的主要格式化程序、快捷键和基本设置。

请注意,为了可读性,我已经将大部分函数移到主组件之外。

第 92 行,我们有我们的MarkButton控件。MarkButton是一个生成按钮 UI 并关联实际格式化程序的函数,当特定按钮被点击时触发。通常,标记用于单词或字符,而不是通常是多行语句的块。Button来自我们的RichTextControls.tsx文件。它表示我们工具栏上的样式化按钮。

接下来,我们有isMarkActive函数。isMarkActive函数确定格式化程序是否已经应用。

接下来,toggleMark函数将根据是否已应用格式来切换格式。它将编辑器与格式关联起来。

BlockButton设置文本块的格式并创建其按钮。通常,一个块包含多个Nodes

isBlockActive函数确定是否应用了格式。

ToggleBlock切换应用的格式。

接下来,Element组件确定要使用哪种类型的 HTML。Elements在 Slate.js 中经常使用。

我们使用Leafs来确定要返回的较小的 HTML。Leafs在 Slate.js 中经常使用。

现在我们有了一个可重用的富文本编辑器。我们肯定会在我们的线程显示中使用这个组件。现在,因为它是自己的组件,我们可以在任何地方重用这段代码。

  1. 现在,我们需要将我们的新RichEditor添加到我们的ThreadBody.tsx文件中。它是一个小组件,所以只需从源代码中添加代码。

  2. 最后,我们需要从我们的Thread组件中引用我们的ThreadBody,就像这样。确保您有所有必要的导入。然后,在 JSX 中,在ThreadTitle的下面,添加以下代码:

        <ThreadBody body={thread?.body} />

再次注意,现在将它放入组件中,阅读和理解这个 JSX 是多么容易。

现在,让我们看看这是什么样子的:

图 12.15 – 线程输入屏幕及其编辑器

图 12.15 – 线程输入屏幕及其编辑器

我们的富文本编辑器提供以下选项:加粗、斜体、下划线、显示为代码、制作标题、引用、编号列表和项目符号列表。正如您所看到的,我们所有的格式化工作都很好。

在使用 Slate.js 时,您可能会想知道为什么会出现项目符号,即使我们之前已经在我们的index.css文件中添加了删除ul样式的 CSS。为了在我们的编辑器中获得适当的样式,我更新了那个样式,就像这样:

ul:not([data-slate-node="element"]) {
  list-style-type: none;
}

这是一个 CSS 选择器,表示“如果元素上有一个名为data-slate-node的自定义属性,则不应用此样式”。这是 Slate.js 用来区分自己的元素和其他标准 HTML 的方法。

哇,那是很多代码!但是,我们还没有完成。我们仍然需要在右侧创建我们的积分列,添加我们的响应功能,并允许添加ThreadItems。让我们稍后再处理积分列,先处理我们的响应系统:

  1. 我们要做的第一件事是一些重构。在我们的ThreadHeader组件中,我们显示了userNamelastModifiedOn来让用户知道谁创建了帖子以及何时创建的。我们也可以将这个显示用于我们的回复。所以,让我们将这一部分代码提取出来,放到一个单独的组件中,这样我们就可以重用它。在routes/thread文件夹中创建一个名为UserNameAndTime.tsx的文件,并添加源代码。由于我们基本上是复制了ThreadHeader的代码,我就不在这里进行审查了。

  2. 现在,我们可以通过更新我们的ThreadHeader组件代码来使用它。通过用以下代码替换title下的h3标签下的 JSX 来更新它。不要忘记添加导入语句:

      <UserNameAndTime userName={userName} lastModifiedOn={lastModifiedOn} />    

太棒了!现在,我们可以开始构建我们的ThreadItems组件。但这一次,我们会有点不同。在主题回复的情况下,可能会有多个回复。因此,这种情况在编程设计中通常需要使用一种称为工厂模式的东西。

所以,我们要做的实际上是两个组件。一个组件将充当工厂“构建”主题回复。另一个组件将定义回复实际上是什么样子的。因此,这两个组件一起可以产生任意数量的回复。请注意,我们没有使用工厂的正式设计模式,只是一个粗略的概念模型。让我们开始吧:

  1. 首先,我们需要创建我们的ThreadResponse组件,它将定义我们的ThreadItem UI 和行为是什么样子的。在routes/thread文件夹中创建一个ThreadResponse.tsx文件,并添加相关的源代码。

首先,注意我们正在导入和重用我们之前创建的RichEditorUserNameAndTime组件。你能想象如果我们没有将它们组件化,要重新创建它们需要多少工作吗?谢天谢地,我们把它们放到了它们自己的组件中!

接下来,我们有我们的ThreadResponseProps接口。请注意,我们所有的 props 都是可选的。这是为了准备当我们重构这个组件并使其能够创建新的回复条目时。

最后,我们有了返回的 JSX。这是一个非常简单的 UI - 我们只显示我们的UserNameAndTimeRichEditor

  1. 现在,让我们创建我们的ThreadResponse工厂。在同一个文件夹中创建一个名为ThreadResponseBuilder.tsx的文件,并添加相关的源代码。

首先,我们有ThreadResponsesBuilderProps接口。这个组件将接收一个包含ThreadItems列表的props。我们将不得不更新我们的Thread父组件,以便它将列表传递下去。

第 12 行开始,因为我们的构建器正在生产多个回复,我们唯一的状态responseElements是一个用于包含它们的 JSX 元素。

接下来,我们使用useEffect来创建我们的回复元素列表。每个ThreadResponse实例都有一个唯一的键,这可以防止渲染问题。每当我们的threadItems props 改变时,我们将创建一个ThreadResponsesul

最后,我们返回我们的 JSX,这是一个TheadResponse元素的列表。

  1. 我们快要完成了。让我们更新我们的Thread.tsx文件,使其现在使用我们的ThreadResponsesBuilder组件。请注意,样式已经在App.cssThread.css文件中更新。

ThreadBody下面的 JSX 中,添加以下代码中显示的突出显示的标签:

  return (
    <div className="screen-root-container">
      <div className="thread-nav-container">
        <Nav />
      </div>
      <div className="thread-content-container">
        <ThreadHeader
          userName={thread?.userName}
          lastModifiedOn={thread ? thread.lastModifiedOn : new Date()}
          title={thread?.title}
        />
        <ThreadCategory categoryName={thread?.category?.name} />
        <ThreadTitle title={thread?.title} />
        <ThreadBody body={thread?.body} />
        hr, to separate out the Thread post from any responses.Our screen should now look like this:

图 12.16 - 一个主题及其回复

图 12.16 - 一个主题及其回复

我们现在几乎有了一个完整的Thread发布和查看 UI。但是,我们还没有完成。我们仍然需要构建我们的点查看器,并启用ThreadThreadItem的发布。我们将在这里构建点查看组件,但是让我们把发布能力留到以后的章节,当我们的后端准备好时再将其联系在一起。此外,当我们的后端准备好时,为什么我们在这里做某些事情将变得更加清晰。

对于我们的categorythreads路由,你会发现我们有一个垂直条显示我们的点赞和回复计数。如果你看一下我们是如何创建这个部分的,你会发现我们将那段代码放入了一个名为getPointsNonMobile的函数中。我们可以将这个功能提取到自己的 React 组件中。显然,这将允许我们在ThreadCard组件和Thread组件以及以后可能需要的任何其他地方使用它。让我们开始吧:

  1. 创建一个名为ThreadPointsBar.tsx的新文件,并将其放在components文件夹的根目录中。我们将从ThreadCard组件中获取getPointsNonMobile函数,并将其添加到这个新组件中。

第 6 行,我们使用ThreadPointsBarProps作为我们的 props 类型。你可能会想为什么我不直接传入整个 Thread 对象。只添加需要的成员数据可以更好地保持关注点的分离。如果我们传递整个 Thread,不仅会告诉ThreadPointsBar我们正在处理哪种模型类型,还会给它一些实际上并没有使用或需要的信息。

接下来,返回的 JSX 基本上与原始函数相同,因为它执行相同的操作。现在,尝试更新ThreadCard组件,以便删除getPointsNonMobile函数。我们将在其位置添加我们的新ThreadPointsBar组件。请注意,我们稍微更新了ThreadCard.css文件,因此您应该刷新它。屏幕应该与我们的原始屏幕相同,因为我们只是移动了一些东西。

  1. 现在,让我们将新的ThreadPointsBar组件添加到我们的Thread路由组件中。JSX 的变化很小但很重要,所以让我们在这里进行解释,然后再看看我们更新后的Thread.css文件:
  return (
    <div className="screen-root-container">
      <div className="thread-nav-container">
        <Nav />
      </div>
      <div className="thread-content-container">
        <div className="thread-content-post-container">

在这里,我们改变了一些元素的顺序。现在,主要的 Thread 帖子相关元素位于thread-content-post-container类的div下面:

          <ThreadHeader
            userName={thread?.userName}
            lastModifiedOn={thread ? thread.lastModifiedOn : new Date()}
            title={thread?.title}
          />
          <ThreadCategory categoryName={thread?.category?.name} />
          <ThreadTitle title={thread?.title} />
          <ThreadBody body={thread?.body} />
        </div>
        <div className="thread-content-points-container">

在这里,我们有一个全新的带有thread-content-points-container类的div,其中包含我们的新ThreadPointsBar组件:

          <thread-content-response-container:

);

};


Let's look at our refreshed CSS `Thread.css` file to see what's going on.Near the top of the file, I've explicitly given a definition for `grid-template-rows`. The Grid now has two rows: one for posts and one for responses. Posts take up one part of available space, but responses can take up as much space as needed, which is what `auto` means, since it could have 0 or more responses.We now have this new class, `thread-content-points-container`. We need this to change the layout of our `ThreadPointsBar`, which is now different from the main screen. Notice that it puts itself into the second column start index and first Grid row. The `> div` element on the second definition means to give the `div` elements inside `ThreadPointsBar` and `threadcard-points` a specific height of all available.Now, our main Thread post items, such as `ThreadTitle` and `ThreadBody`, live inside this `thread-content-post-container`.Our responses – mainly `ThreadResponsesBuilder` – live inside this `thread-content-response-container`. Notice that `grid-row` is set to 2.After the `thread-content-response-container` class, you'll notice that all the section-related classes no longer need references to any Grid column or Grid since they all live inside `thread-content-post-container`.
  1. 现在,我们想为我们的回复给出点数总计。但是,因为我们可能会有很多回复,为每个回复显示 20 或 30 个小的垂直点条可能看起来不太好。为了使事情看起来更整洁,让我们把这些点放在与我们的userNamecreatedOn日期相同的行上。幸运的是,我们已经在ThreadCard组件中使用getPoints函数创建了大部分显示这些点的代码。所以,让我们也将其转换为一个组件。

创建一个名为ThreadPointsInline.tsx的新文件,并将相关源代码添加到其中。我们基本上只是将我们的getPoints代码复制粘贴到这里,所以没有太多解释。但是,请注意我们从ThreadPointsBar组件中重用了ThreadPointsBarProps接口。因此,我们需要将这种类型导出。

我假设你知道如何更新ThreadCard.tsx文件,因为我们之前使用ThreadPointsBar做过这个。现在,让我们更新ThreadResponse.tsx文件,以便使用我们的新ThreadPointsInline组件。尝试自己做这个;只有在卡住时才查看代码。现在我们有:

图 12.17 - 显示主题点数

图 12.17 - 显示主题点数

正如你所看到的,我们的两个点系统都可以看到。现在,我们需要实现一个最后的小技巧,以便在移动设备上正确显示这个屏幕。

  1. 打开Thread.css文件,确保它包含与源代码相同的媒体查询。

现在,打开Thread组件的代码,这样我们就可以浏览它。

第 32 行,你会看到我们的线程帖子相关的项目都位于thread-content-container内。通过媒体查询设置了 CSS 类,以便它只有一个ThreadPointsBar组件,从该区域,我们不会得到一个空白空间,因为之前有两列。

接下来,我们可以看到我们的ThreadPointsBar实际上位于thread-content-points-container内。在媒体查询中,我们使该元素不可见。这仍然是有效的,因为你可能还记得,内部,ThreadPointsBar正在使用我们的useWindowDimensions Hook 来确定它是否应该渲染自身。它不会为移动设备执行此操作。

太棒了!现在让我们在移动设备上查看我们的屏幕:

图 12.18 – 线程屏幕的移动视图

图 12.18 – 线程屏幕的移动视图

太棒了!现在,我们有一个代码库和两个屏幕。

在本章的最后一个项目中,我们将构建UserProfile屏幕。我们想在这个屏幕上做一些事情:

  • 允许用户重置他们的密码。

  • 显示所有用户生成的线程帖子。

  • 显示所有用户生成的回复(ThreadItems)。

让我们开始吧:

  1. 我们实际上要做的第一件事是对SideBarMenus组件进行更改。我们需要移出useEffect调用,以便将我们的用户发送到 Redux,然后发送到Login组件。我们这样做是为了当用户成功登录时,新的用户对象将被发送到 Redux。到目前为止,你应该已经习惯了进行这种改变。所以,继续将这段代码从SideBarMenu中移除,并添加到Login中。

提示

确保将代码放入Login时,将dispatch的名称更改为其他名称,因为Login组件中已经有一个dispatch

  1. 这个新屏幕将包括密码重置功能,但你可能还记得我们已经有很多代码来进行密码确认在我们的Register组件中。让我们尝试将该代码提取到自己的组件中,以便我们可以在Register组件和我们的新UserProfile组件中重用它。

components/auth/common文件夹内创建一个名为PasswordComparison.tsx的文件。将相关的源代码添加到其中。

这是一个相当简单的复制粘贴,但有一些要注意的地方。请注意,此组件不使用userReducer,而是使用其值的 props。特别要注意的是其中一个是dispatch函数。dispatch调用属于父级。其他一切基本上都是复制和粘贴。

尝试自己从原始的Register组件中删除这段代码。确保删除所有不必要的导入。

  1. 现在,让我们在routes文件夹内创建一个新的userProfile文件夹,这样我们就可以创建我们的新的UserProfile.tsx文件,并将相关的源代码添加到其中。

第 14 行开始,我们使用我们的userReducer,因为我们需要一些它的属性,比如userName。我们还获取 Redux 用户 reducer 并为用户的 Threads 和ThreadItems设置一些本地状态。

第 28 行useEffect函数使用DataServicegetUserThreads函数,获取用户的 Threads。我们不需要另一个调用来获取ThreadItems,因为 Threads 包含相关的ThreadItems。但是,我更新了ThreadItem类,以便它包括其父ThreadId。查看这些文件以获取该代码。

接下来,从第 38 行开始,我们将查询结果中的每个线程映射到一个li。我们还将所有ThreadItems添加到一个数组中,以便以后使用。

然后,从第 53 行开始,我们将我们的ThreadItems映射到一组li

第 77 行,我们使用了之前创建的PasswordComparison组件。

第 82 行,请注意我们的按钮使用了isSubmitDisabled。你能猜到这种禁用是如何工作的吗,即使UserProfile并不包含任何改变它的代码?没错——PasswordComparison在内部使用我们 UserProfile 的dispatch函数来做到这一点。

最后,我们的 Threads 和ThreadItems是从我们的本地状态对象渲染出来的。

  1. 对于最后的更改,让我们更新我们的App.tsx文件,以便包括我们的新路由UserProfile。请注意,我们还需要临时添加userName Redux 调用,直到Login.tsx中的相同调用完全工作(一旦我们的后端准备好,我们将在Login.tsx中完成调用)。这是因为当我们加载我们的UserProfile时,不能保证用户已经加载了他们的Login屏幕。但是,我们知道如果他们已经加载了应用程序中的任何屏幕,他们必须已经加载了App.tsx组件。从源代码中更新App.tsx

首先,我们有一个useEffect,其中有一个硬编码的userName被发送到 Redux 存储。同样,这只是临时的,直到我们的后端准备好为止。

第 26 行renderUserProfile是返回我们的UserProfile组件的函数。然后在第 33 行将该函数用作新路由的目的地;即"/userprofile/:id"

我们还需要做一个微小的改变。在我们的SideBarMenus组件中,让我们更新我们的userName标签,使其成为指向我们新的UserProfile屏幕的链接。您可以在SideBarMenus.tsx文件中找到这个 JSX:

<span className="menu-name">{user?.userName}</span>

然后,用这个替换它:

<span className="menu-name">
            <Link to={`/userprofile/${user?.id}`}>{user?.userName}</Link>
          </span>

现在,如果您运行应用程序,您将看到以下内容:

图 12.19 – 用户资料屏幕

图 12.19 – 用户资料屏幕

如果您点击任何一个 Thread 链接,您会发现它们会带我们到 thread 路由。

太棒了!在这一章中,我们经历了大量的 React 代码。我们学习了布局、文件夹结构、组件创建、代码重用、代码重构、样式等。特别是代码重构可能非常耗时甚至令人紧张。然而,现实是,大多数时候,我们不会写新代码,而是重构现有代码。因此,这是建立我们技能的一个很好的方式。

在接下来的几章中,我们将构建我们的后端,并将其与我们的客户端连接起来。现在你应该感到非常自信——你已经在这一复杂章节中付出了巨大的努力。

总结

在这一章中,我们开始了构建全栈应用程序的旅程,首先创建了 React 客户端。我们使用 Hooks 创建了组件,实现了组件层次结构,并使用 CSS Grid 设计了布局。然后我们重构了大量的代码,并尽量重用了尽可能多的代码。尽管我们还没有完成,但我们已经构建了最终应用程序的一个重要部分。

在下一章中,我们将学习关于后端服务器上的会话状态,会话状态是什么,如何使用它,以及创建和管理会话数据的最流行工具:Redis。

第十三章:使用 Express 和 Redis 设置会话状态。

在本章中,我们将学习如何使用 Express 和 Redis 数据存储创建会话状态。Redis 是最流行的内存数据存储之一。它被 Twitter、GitHub、Stack Overflow、Instagram 和 Airbnb 等公司使用。我们将使用 Express 和 Redis 来创建我们的会话状态,这将成为我们应用程序身份验证功能的基础。

在本章中,我们将涵盖以下主要主题:

  • 理解会话状态

  • 理解 Redis

  • 使用 Express 和 Redis 构建会话状态

技术要求

您应该对使用 Node.js 进行 Web 开发有很好的理解。我们将再次使用 Node 和 Visual Studio Code。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap13文件夹中的代码。

要设置第十三章代码文件夹,请转到您的HandsOnTypescript文件夹并创建一个名为Chap13的新文件夹。

理解会话状态

在本节中,我们将学习会话状态是什么以及为什么需要它。我们将重新审视网络工作的一些概念,并理解为什么我们需要会话状态。

网络实际上并不是一件事。它是许多技术的集合。网络的核心是 HTTP 协议。这是允许网络在互联网上工作的通信协议。协议只是一组用于通信的约定规则。这听起来有些简单,对于某些事情来说可能是。然而,对于我们的应用程序来说,情况就有点复杂了。

HTTP 协议是一种无连接的协议。这意味着 HTTP 连接仅在发出请求时建立,然后释放。因此,即使用户在网站上活跃使用数小时,连接也不会保持。这使得 HTTP 更具可伸缩性。然而,这也意味着在使用该协议时更难创建大型网站需要的某些功能。

让我们看一个现实世界的例子。假设我们是亚马逊,我们网站上有数百万用户试图购买物品。现在因为人们正在尝试购买物品,我们需要能够唯一标识这些用户。例如,如果我们同时在亚马逊上购物,您试图将物品添加到购物车中,我们需要确保您的物品不会出现在我的购物车中,反之亦然。这似乎应该很容易做到。然而,在像 HTTP 这样的无连接协议中,这很难。

在 HTTP 中,每个请求都会创建一个新的连接,每个新请求都不知道任何先前的请求。也就是说,它不保存状态数据。因此,回到我们的亚马逊例子,这意味着如果用户发出请求将物品添加到购物车中,没有内置的功能可以区分这个用户的请求和其他任何请求。当然,我们可以介入使用我们自己的功能,当然,这正是我们将在本章讨论的内容。但关键是,没有现成的东西可以直接使用。

需要明确的是,处理这个特定问题有许多方法。也许我们可以给每个用户一个唯一的 ID,并且他们可以在每次调用时传递它。或者我们可以将会话信息保存到数据库中,例如将购买物品保存在购物车中。当然,根据具体的要求,还有许多其他选项。然而,这些简单的想法需要详细阐述并详细说明。然后我们需要花时间测试它们。因此,实际上,无论我们在哪里,我们都希望避免自己开发功能,并且应选择行业标准解决方案。如果我们使用这些解决方案,我们知道它们已经经过了健壮性和安全性测试,并且将使用最佳实践。

我们将使用区分用户的方法将重点放在服务器端技术上,使用 Express 会话和 Redis 作为我们的数据存储。我们不会使用 JWT,因为它是客户端技术,比服务器端解决方案更容易受到安全漏洞的影响。

重要提示

每种解决方案都有其优缺点。当然,任何服务器都可能被黑客攻击。在服务器上使用安全解决方案并不能保证任何事情。然而,当涉及到您的服务器时,您至少可以保护和控制其设置,以尽量最大化其安全性。在用户的机器上,您根本无法控制。

在本节中,我们了解了会话状态是什么以及为什么它是必要的。我们了解了 HTTP 协议的一些缺失功能,以及我们如何为自己提供这些功能。在下一节中,我们将继续学习 Redis,这是我们将用来维护会话数据的数据存储。

了解 Redis

在这一部分,我们将学习关于 Redis 并安装它。我们还将简单介绍 Redis 以及它的工作原理。

Redis 是一个内存数据存储。它非常快速和可扩展。您可以使用 Redis 存储字符串、数据列表、集合等。成千上万的公司使用 Redis,它是免费和开源的。一般来说,Redis 最常用作内存数据库或缓存。

对于我们的用例,我们将使用 Redis 来作为 Express 会话的数据存储。Redis 支持 Linux 和 Mac。它在 Windows 上没有官方支持。您可以通过在 Windows 上使用 Docker 镜像来获得非官方支持,但这超出了本书的范围。然而,您通常可以在云提供商上获得免费的 Linux 虚拟机进行试用。因此,如果您使用 Windows,可以尝试其中的一项服务。

注意

Redis.conf有一个叫做 bind 的设置,它设置了 Redis 服务器将使用的本地 IP 地址,以及允许访问它的外部 IP 地址。将此设置注释将允许任何 IP 地址访问服务器。这对开发目的来说是可以的。然而,一旦进入生产阶段,您应该将其设置为特定值,并且只允许您希望访问服务器 IP 的 IP 地址。

让我们开始安装 Redis。目前,我正在使用 Mac:

  1. 转到 Redis 网站redis.io/download,并在稳定版本下选择下载。这是当前 6.0.7 版本的示例屏幕截图:

注意

请下载 6.0.x 版本,因为更高或更低版本可能会有破坏性的更改。

图 13.1 – Redis 下载

图 13.1 – Redis 下载

  1. 一旦您下载并成功解压缩文件到一个文件夹中,使用终端并进入该文件夹。例如,这是我解压缩 tar 文件后终端的样子:图 13.2 – Redis 稳定版解压缩

图 13.2 – Redis 稳定版解压缩

  1. 现在我们必须将我们的源文件制作成可运行的应用程序。只需在终端中输入make并让其运行。这将需要一些时间来完成。make命令运行的开始将如下所示:图 13.3 – 运行 make 命令

图 13.3 – 运行 make 命令

  1. 现在我们已经构建了我们的服务器,随意将其移动到任何您喜欢的位置。我将其移动到了我的Applications文件夹中。在切换到Redis文件夹后,您需要运行以下命令:
src/redis-server

这是我本地运行的 Redis 服务器的屏幕截图:

图 13.4 – 运行 Redis

图 13.4 – 运行 Redis

警告

在 Mac 上,您可能会收到一个警告,询问您是否要允许 Redis 接受传入的网络请求。您应该允许此操作。

  1. 让我们快速测试一下 Redis 是否正常工作。在 Redis 运行时,打开一个新的终端窗口,并从 Redis 的src文件夹中,输入以下命令:
ping to check that Redis is running. Then we use the set command to create a new value with the key test and value 1. Then we get that value successfully.
  1. 现在我们知道我们的服务器已经正确安装,我们需要进行一些小的配置。首先用这个命令关闭服务器:
Chapter13 source code folder and copy the contents of the redis/redis.conf file. Then, in the terminal, run the following command:

sudo 密码,输入你的密码。这是大多数 Redis 配置位置的默认文件夹。接下来,运行这个命令:

redis.conf, file into this newly created file on /etc/redis/redis.conf.If you view this file and search for the keyword `requirepass`, pressing *Ctrl* + *W* or viewing from VSCode, you will see the password we are going to use for testing purposes only. Please do not use this password in production.For any other settings, we should be fine with the defaults.

  1. 好的,现在让我们重新启动我们的 Redis 服务器,但这次指向我们的新redis.conf文件。输入这个命令:
Configuration loaded.Note that if you want to test the server again, this time you need to authenticate since we configured a password:

src/redis-cli

auth


This is what it looks like:

图 13.6 - Redis 的测试重启和 auth

图 13.6 - Redis 的测试重启和 auth

在这一部分,我们讨论了 Redis 是什么,并进行了 Redis 服务的基本安装。在下一部分中,我们将通过创建一个最基本的 Node 和 Express 服务器并设置基于 Redis 的会话状态来启动我们的后端服务器代码。

使用 Express 和 Redis 构建会话状态

在这一部分,我们将开始构建我们的后端。我们将创建我们的 Express 项目并设置基于 Redis 的会话状态。

现在我们了解了 Redis 是什么以及如何安装它。让我们来看看 Express 和 Redis 如何在我们的服务器中一起工作。正如我们在第八章中讨论的那样,使用 Node.js 和 Express 学习服务器端开发,Express 基本上是 Node 的一个包装器。这个包装器通过使用中间件为 Node 提供了额外的功能。会话状态也是 Express 的一个中间件。

在我们的应用程序中,Express 将提供一个具有相关功能的会话对象,比如在用户浏览器上创建 cookie 以及各种函数来帮助设置和维护会话。Redis 将是我们会话数据的数据存储。由于 Redis 在存储和检索数据方面非常快速,它是 Redis 的一个很好的使用案例。

现在让我们使用 Express 和 Redis 创建我们的项目:

  1. 首先,我们需要创建我们的项目文件夹super-forum-server。创建后,我们需要通过运行这个命令将其初始化为一个 NPM 项目(确保你的终端已经在super-forum-server文件夹中):
name field inside of package.json to say super-forum-server. Feel free to also update the author field to your name as well.
  1. 现在让我们安装我们的依赖项:
express package, but we also installed express-session. This package is what enables sessions in Express. We also installed connect-redis, which is what connects our Express session to a Redis data store. In addition to connect-redis, we need the ioredis package because it is the client that gives us access to the Redis server itself. I'll explain this further once we start coding. The dotenv package will allow us to use a config file, .env, to hold things like server passwords and other configurations. Then, in the second `install` command, we can see our development-related packages, which are mostly TypeScript definition packages like `@types/express`. However, notice in the end, we also install `ts-node-dev`. We use this package to help us start our server through the main `index.ts` file. The `ts-node-dev` package will trigger `tsc`, the TypeScript compiler, and get the final server up and running.WarningNever include your `dotenv` config file, `.env`, in your Git repository. It has sensitive information. You should have an offline process to maintain this file and share it with your developers.
  1. 现在让我们更新我们的package.json文件,使用ts-node-dev助手。这个包非常有用,因为它在我们更改任何脚本时也会自动重新启动我们的服务器。将这一行添加到package.jsonscripts部分中:
"start": "ts-node-dev --respawn src/index.ts"

注意在respawn之前有两个破折号。index.ts文件将是启动我们服务器的根文件。

  1. 现在我们应该在我们的项目中设置 TypeScript。我们之前已经多次看到了 TypeScript 配置文件tsconfig.json,所以我不会在这里列出它(当然你可以在我们的源文件中找到它)。但请注意,我们将target设置为ES6,并且生产文件保存在./dist文件夹中。

  2. 在项目的根目录下创建src文件夹。

  3. 现在让我们创建我们的.env文件及其条目。将这些设置复制到你自己的文件中,但使用你自己的唯一的秘密值!

  4. 现在让我们创建index.ts文件。首先让我们创建一个最基本的文件,只是为了确保我们的服务器能够运行。将这个输入到文件中:

import express from "express";

在这里,我们已经导入了 Express。

console.log(process.env.NODE_ENV);

在这里,我们展示了我们所在的环境 - 生产环境还是开发环境。如果你还没有设置你的本地环境,请在终端上使用这个命令来设置。

对于 Mac,使用这个命令:

dotenv package and set up default configurations. This is what allows our .env file to be used in our project.

const app = express();


Here, we instantiate our `app` object with `express`. So, we'll add all our middleware onto the `app` object. Since almost everything in Express is middleware, session state is also middleware.

app.listen(, () ⇒ {

console.log(服务器已准备就绪,端口为${process.env.   SERVER_PORT});

});


And here, we have initialized our server and when it is running, it will show the log message shown. Run the following command:

npm start


You should see the following log message on your terminal:![Figure 13.7 First run of the Express server    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_13.7_B15508.jpg)Figure 13.7 First run of the Express server
  1. 现在我们知道我们的基本服务器已经正确运行,让我们添加我们的 Express 会话状态和 Redis:
import express from "express";
import session from "express-session";
import connectRedis from "connect-redis";
import Redis from "ioredis";

首先,你可以看到我们导入了expression-session和我们的与 Redis 相关的包。

console.log(process.env.NODE_ENV);
require("dotenv").config();
const app = express();
const router = express.Router();

在这里,我们初始化了我们的router对象。

const redis = new Redis({
  port: Number(process.env.REDIS_PORT),
  host: process.env.REDIS_HOST,
  password: process.env.REDIS_PASSWORD,
});

redis对象是我们的 Redis 服务器的客户端。正如你所看到的,我们已经将配置信息的值隐藏在我们的.env文件后面。你可以想象一下,如果我们能够看到密码和其他安全信息硬编码到我们的代码中,那将是多么不安全。

const RedisStore = connectRedis(session);
const redisStore = new RedisStore({
  client: redis,
});

现在我们已经创建了我们的RedisStore类和redisStore对象,我们将使其成为我们 Express 会话的数据存储。

app.use(
  session({
    store: redisStore,
    name: process.env.COOKIE_NAME,
    sameSite: "Strict",
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      path: "/",
      httpOnly: true,
      secure: false,
      maxAge: 1000 * 60 * 60 * 24,
    },
  } as any)
);

会话对象有一些选项。一个选项,store,是我们添加redisStore对象的地方。sameSite值表示不允许来自其他域的 cookie,这增强了安全性。secret字段再次是我们特定会话的一种密码或唯一 ID。cookie字段设置了我们保存到用户浏览器上的 cookie。httpOnly字段意味着 cookie 无法从 JavaScript 中获取。这使得 cookie 更加安全,可以防止 XSS 攻击。secure字段是false,因为我们没有使用 HTTPS。

app.use(router);
router.get("/", (req, res, next) => {
  if (!req.session!.userid) {
    req.session!.userid = req.query.userid;
    console.log("Userid is set");
    req.session!.loadedCount = 0;
  } else {
    req.session!.loadedCount = Number(req.session!.     loadedCount) + 1;
  }

我们已经设置了我们的router对象和我们的一个路由,即 GET。基本上,我们所做的是从 URL 查询字符串中获取userid,然后用它设置我们用户的唯一session.userid字段。我们还计算调用的次数,以显示会话在调用之间保持活动状态。

  res.send(
    `userid: ${req.session!.userid}, loadedCount: 
      ${req.session!.loadedCount}`
  );

在这里,我们通过发送会话信息作为字符串返回来做出响应。

});
app.listen({ port: process.env.SERVER_PORT }, () => {
  console.log(`Server ready on port ${process.env.SERVER_   PORT}`);
});

最后,我们的express服务器在端口 5000 上监听,这是我们的SERVER_PORT设置的值。如下图所示,cookie 在第一次加载时被创建:

图 13.8 - 两个浏览器显示不同的会话状态

图 13.8 - 两个浏览器显示不同的会话状态

请注意,我们使用两个浏览器来显示创建唯一会话。如果我们使用一个浏览器,会话将不是唯一的,因为将使用相同的 cookie。

在本节中,我们利用了我们对 Express 和 Redis 的知识,并为我们的 SuperForum 应用程序实现了一个基本项目。我们看到了 Express 和 Redis 在创建会话中所起的作用。我们还看到了如何使用会话为每个访问我们网站的用户创建一个唯一的数据容器。

总结

在本章中,我们学习了会话和 Redis 数据存储服务。我们还学习了如何将 Redis 与 Express 集成,以便为我们的用户创建唯一的会话。这对于在后续章节中构建我们的身份验证服务至关重要。

在下一章中,我们将设置我们的 Postgres 服务器并创建我们的数据库架构。我们还将学习 TypeOrm,这将允许我们从我们的应用程序集成和使用 Postgres。最后,我们还将构建我们的身份验证服务并将其与我们的会话状态联系起来。

第十四章:使用 TypeORM 设置 Postgres 和存储库层

在本章中,我们将学习如何使用 Postgres 作为我们的数据库和 TypeORM 作为访问数据库的库来设置存储库层。我们将构建我们的数据库架构,并借助 TypeORM,我们将能够为我们的应用程序执行CRUD创建,读取,更新,删除)操作。这是一个关键的章节,因为我们的后端的核心活动将是检索和更新数据。

在本章中,我们将涵盖以下主要主题:

  • 设置我们的 Postgres 数据库

  • 通过使用 TypeORM 来理解对象关系映射器

  • 使用 Postgres 和 TypeORM 构建我们的存储库层

技术要求

本书不会教授关系数据库知识。因此,你应该对 SQL 有基本的了解,包括简单的查询和表结构,以及使用 Node 进行 Web 开发。我们将再次使用 Node 和 Visual Studio Code 来编写我们的代码。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap14文件夹中的代码。

要设置第十四章的代码文件夹,请转到你的HandsOnTypescript文件夹,并创建一个名为Chap14的新文件夹。

设置我们的 Postgres 数据库

在本节中,我们将安装和设置 Postgres 数据库。关系数据库仍然非常重要,而现在 NoSQL 数据库非常流行。然而,根据 StackOverflow 的说法,Postgres 仍然是世界上最受欢迎的数据库之一。此外,它的性能是世界一流的,比 MongoDB 高出很大的边际(www.enterprisedb.com/news/new-benchmarks-show-postgres-dominating-mongodb-varied-workloads)。因此,Postgres 是我们将使用的数据库技术。

让我们安装我们的 Postgres 数据库。我们将使用 EDB 提供的安装程序。EDB 是一家第三方公司,提供支持 Postgres 的工具和服务:

  1. 转到网址www.enterprisedb.com/downloads/postgres-postgresql-downloads,并选择适合你平台的下载。我将使用 Mac 的 12.4 版本,这是我写作时的最新 Mac 版本。

  2. 接受安装程序上的所有默认设置,包括要安装的组件列表,如下所示:图 14.1 - Postgres 设置屏幕

图 14.1 - Postgres 设置屏幕

  1. 安装完成后,启动pgAdmin应用程序。这个应用程序是 Postgres 的管理员应用程序。你应该会看到这样的屏幕:图 14.2 - pgAdmin 的第一个视图

图 14.2 - pgAdmin 的第一个视图

如你所见,这是一个 Web 浏览器应用程序。我在我的安装中有一些其他服务器,但如果这是你的第一个pgAdmin安装,你的安装应该没有任何服务器。

  1. 现在,让我们创建一个名为HandsOnFullStackGroup的新服务器组,这样我们就可以将我们的工作与其他人分开。服务器组只是一个容器,可以容纳多个服务器实例,每个服务器可以在其中拥有多个数据库。请注意,一个服务器并不表示一个单独的物理机器。

  2. 首先,通过右键单击Servers项目,选择Server Group选项,如下所示:图 14.3 - pgAdmin 添加服务器组

图 14.3 - pgAdmin 添加服务器组

  1. 接下来,在第一个屏幕上右键单击新的SuperForumServers,创建一个服务器,如下所示:图 14.4 - 创建 - 服务器选项卡

图 14.4 - 创建 - 服务器选项卡

  1. 现在,选择第二个选项卡,localhost作为postgres。Postgres 账户是根管理员账户,所以你需要记住这个密码。这是这个选项卡的截图:图 14.5 – 连接选项卡

图 14.5 – 连接选项卡

  1. 选择保存,你的服务器将被创建。你应该会看到以下视图:

图 14.6 – 新的 HandsOnFullStackGroup 和 SuperForumServers 视图

图 14.6 – 新的 HandsOnFullStackGroup 和 SuperForumServers 视图

请注意,那里已经有一个名为postgres的数据库。这个数据库是空的,但可以用来存储全局数据。

现在,让我们为我们的应用程序创建数据库。但是,在我们这样做之前,我们需要创建一个新的账户,专门用于与我们的新数据库相关联。使用默认管理员账户 postgres 不是一个好主意,因为如果被黑客攻击,它将给予攻击者对整个服务器的访问权限:

  1. pgAdmin中,右键单击superforumsvc。然后,在定义选项卡中,设置您自己的密码。接下来,转到权限选项卡,并确保启用登录。其余设置可以保持默认设置。

  2. 接下来,右键单击SuperForum,选择superforumsvc作为所有者图 14.7 – 创建 SuperForum 数据库

图 14.7 – 创建 SuperForum 数据库

  1. 然后,点击保存。你的视图现在应该显示如下:

图 14.8 – 新数据库和用户

图 14.8 – 新数据库和用户

太棒了!我们现在有了一个数据库。如果我们不使用 ORM,我们将不得不经历手动创建表和字段的繁琐过程。但是,正如你将看到的,TypeORM 可以帮我们省去这些苦工,同时为我们提供了很棒的语言特性来查询我们的数据库。

在下一节中,我们将深入了解 TypeORM。我们将学习它是如何工作的,以及它如何在许多层面上帮助我们与我们的数据库交互。

通过使用 TypeORM 来理解对象关系映射器

在本节中,我们将学习什么是对象关系映射器ORM)技术。我们还将了解 TypeORM,这是 JavaScript 中最流行的 ORM 框架之一。ORM 可以使与数据库的工作变得更加容易,并减少开发人员的一些认知负担。

作为程序员,你知道不同的编程语言具有不兼容的类型。例如,尽管名字相似,JavaScript 不能直接使用甚至访问 Java 类型。为了让任一语言使用另一语言的类型,我们需要进行某种形式的翻译。部分原因是有了诸如 Web API 这样的服务。Web API 以字符串格式(如 JSON)提供所有数据给调用者。这允许任何调用者使用数据,因为它可以被任何语言读取。

数据库到编程语言的转换具有类似的类型不兼容性。因此,通常在进行返回数据的查询之后,我们需要从数据库中取出每个字段的值,并手动编写代码将其转换为编程语言中的特定类型。然而,如果我们使用 ORM,大部分工作都会消失。

ORM 被设计成知道如何将数据库字段映射到代码字段,并为我们处理这些翻译工作。此外,大多数 ORM 都具有某种能力,根据在代码中创建的实体结构自动在数据库上创建表和字段。你可以将实体视为编程语言端表示与数据库端表类似对象的类型。例如,如果我们在 JavaScript 中有一个名为User的实体,那么我们期望在数据库中有一个名为Users的表与之匹配(它是复数形式,因为一个表可以容纳多个用户)。

仅此功能就可以为开发人员节省大量的时间和精力,但除此之外,一个良好的 ORM 还将具有帮助构建查询、安全插入参数(减少 SQL 注入攻击的机会)以及处理事务的功能。事务是必须完全完成的原子数据库操作,否则涉及的所有操作都将被撤消。

注意

SQL 注入攻击是恶意人员尝试插入与开发人员最初意图不同的 SQL 代码的尝试。它可能导致诸如数据丢失和应用程序失败等问题。

对于我们的应用程序,我们将使用 TypeORM。TypeORM 是一个受欢迎且备受好评的 TypeScript ORM,在 GitHub 上有超过 20,000 个赞。它提供了所有提到的功能,并且很容易入门,尽管成为高级用户需要相当大的努力。它支持多个数据库,包括 Microsoft SQL、MySQL 和 Oracle。

它将通过其丰富的功能集为我们节省大量时间,并且因为许多 JavaScript 项目使用 TypeORM,所以有一个庞大的开发人员社区可以在您使用它时提供帮助。

在本节中,我们了解了 ORM 技术。我们了解了它是什么,以及为什么使用它是重要和有价值的。在下一节中,我们将使用 TypeORM 来构建我们自己的项目。让我们开始吧。

使用 Postgres 和 TypeORM 构建我们的存储库层

在本节中,我们将了解使用存储库层的重要性。为我们的应用程序的一个重要部分设置一个单独的层可以帮助简化代码重构。从逻辑上分离主要部分也有助于理解应用程序的工作原理。

第一章中,理解 TypeScript,我们学习了面向对象编程OOP)。实现 OOP 设计的主要机制之一是使用抽象。通过在其自己的单独层中创建我们的数据库访问代码,我们正在使用抽象。正如您可能记得的那样,抽象的好处之一是它隐藏了代码的内部实现并向外部调用者公开接口。此外,因为与访问数据库相关的所有代码都在一个地方,我们不必四处寻找我们的数据库查询代码。我们知道这段代码位于我们应用程序的哪个层中。保持代码逻辑上的分离被称为关注点分离。

因此,让我们开始构建我们的存储库层:

  1. 首先,我们需要复制我们在第十三章中创建的服务器代码,使用 Express 和 Redis 设置会话状态。转到源代码中的Chapter13文件夹,并将super-forum-server文件夹复制到Chapter14文件夹中。
npm install 
  1. 接下来,我们需要安装 TypeORM 及其相关依赖项。运行以下命令:
typeorm. pg is the client to communicate with Postgres. bcryptjs is an encryption library that we will use to encrypt our passwords before inserting into the database. cors is needed to allow us to receive client-side requests from a different domain, other than our server's domain. In modern apps, it's possible the client-side code is not being served from the same server as the server-side code. This is especially true when we are creating an API such as GraphQL, which may be used by multiple clients. You'll also see this when we start integrating our client's React app with the server, as they will run on different ports.`class-validator` is a dependency for assigning decorators for validation. We'll discuss this in more detail later with the help of examples.
  1. 现在,在我们开始创建我们的实体数据库之前,我们需要创建一个配置文件,以便我们的 TypeORM 代码可以访问我们的 Postgres 数据库。这意味着我们还需要更新我们的.env文件与我们的数据库配置。打开.env文件并添加这些变量。我们的服务器是在本地安装的,所以PG_HOST的值为localhost
PG_HOST=localhost

服务器用于通信的端口如下:

PG_PORT=5432

我们的数据库帐户名称如下:

PG_ACCOUNT=superforumsvc

使用您为自己的数据库创建的密码:

PG_PASSWORD=<your-password>

我们的数据库名称如下:

PG_DATABASE=SuperForum

如前所述,TypeORM 将为我们创建表和字段,并在其更改时对其进行维护。 PG_SYNCHRONIZE启用了该功能:

PG_SYNCHRONIZE=true

当然,一旦您在生产中投入使用,您必须禁用此功能,以防止不必要的数据库更改。

我们的实体文件的位置,包括子目录,如下:

PG_ENTITIES="src/repo/**/*.*"

我们的实体的根目录如下:

PG_ENTITIES_DIR="src/repo"

PG_LOGGING确定是否在服务器上启用日志记录:

PG_LOGGING=false

在生产环境中应该启用日志以跟踪问题。但是,日志可能会创建巨大的文件,所以我们不会在开发中启用它。

  1. 现在我们可以创建我们的 TypeORM 配置文件。在我们项目的根目录Chap13/super-forum-server中,创建名为ormconfig.js的文件,并将以下代码添加到其中:
require("dotenv").config();

首先,我们通过require获取我们的.env配置:

module.exports = [
  {
    type: "postgres",

我们将连接到哪种数据库类型?由于 TypeORM 支持多个数据库,我们需要指示这一点。

其余的值使用我们的.env文件中的配置,因此它们是不言自明的:

    host: process.env.PG_HOST,
    port: process.env.PG_PORT,
    username: process.env.PG_ACCOUNT,
    password: process.env.PG_PASSWORD,
    database: process.env.PG_DATABASE,
    synchronize: process.env.PG_SYNCHRONIZE,
    logging: process.env.PG_LOGGING,
    entities: [process.env.PG_ENTITIES],
    cli: {
      entitiesDir: process.env.PG_ENTITIES_DIR
    },
  }
];

现在,我们准备开始创建我们的实体。

  1. 现在我们已经安装了依赖项并设置了数据库的配置,让我们创建我们的第一个实体,用户。将目录更改为Chap14/super-forum-server文件夹,然后在src文件夹内创建一个名为repo的文件夹。我们将把所有的存储库代码放在那里。然后,在repo内创建一个名为User.ts的文件,并在其中添加以下代码:
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

这些 TypeORM 导入将允许我们创建我们的User实体类。EntityPrimaryGeneratedColumnColumn被称为装饰器。装饰器是放置在相关代码行之前的属性,提供有关字段或对象的附加配置信息。你可以把它们看作是一种快捷方式。你可以简单地添加一个标签来设置配置,而不是编写一些长长的代码行。我们将在这段代码中看到例子:

import { Length } from "class-validator";

这是一个长度的验证器。

接下来是我们第一次使用装饰器。Entity装饰器告诉 TypeORM 即将定义的类是一个名为Users的实体。换句话说,在我们的代码中,我们将有一个称为User的对象,它直接映射到我们数据库中称为Users的表:

@Entity({ name: "Users" })

在数据库中,每个表必须有一个唯一的标识字段。这就是PrimaryGeneratedColumn的含义。字段名称将是id。请注意,id中的"""不是大写。我们稍后会解决这个问题:

export class User {
  @PrimaryGeneratedColumn({ name: "id", type: "bigint" })
  id: string;

接下来,我们将首次使用Column装饰器:

  @Column("varchar", {
    name: "Email",
    length: 120,
    unique: true,
    nullable: false,
  })
  email: string;

正如你所看到的,它用于定义数据库字段Email,在我们的 TypeScript 代码中将被称为email。因此,装饰器再次被用来将我们的代码对象映射到数据库实体。现在,让我们更仔细地看一下Column装饰器。首先,它定义了我们的列是varchar数据库类型。再次强调,数据库类型与代码类型不同,如此处所示。接下来,我们看到name字段,设置为Email。这将是Users表中此字段的确切名称。然后我们有length,它表示此字段允许的最大字符数。unique属性告诉 Postgres 强制每个User条目必须具有唯一的电子邮件。最后,我们将nullable设置为false,这意味着此字段在数据库中必须有一个值:

  @Column("varchar", {
    name: "UserName",
    length: 60,
    unique: true,
    nullable: false,
  })
  userName: string;
  @Column("varchar", { name: "Password", length: 100,   nullable: false })
@Length(8, 100)

在这里,我们使用Length装饰器来确保输入的字段具有最小和最大字符长度:

  password: string;

两个字段,userNamepassword,都将varchar作为列,具有与email类似的设置:

  @Column("boolean", { name: "Confirmed", default: false, 
    nullable: false })
  confirmed: boolean;

现在,我们看到了一个confirmed字段,它是boolean类型。confirmed字段将显示新注册用户帐户是否已经通过电子邮件验证。请注意,这是相当不言自明的,但默认设置表明,当前记录插入数据库时,除非明确设置,它将被设置为false

  @Column("boolean", { name: "IsDisabled", default:     false, nullable: false }) 
  isDisabled: boolean;
}

最后,这是isDisabled字段,它将允许我们出于管理目的禁用帐户。

  1. 太好了!现在我们可以看到 TypeORM 是否会代表我们创建新的Users表。我们需要做的最后一件事是从我们的代码连接到 Postgres 数据库。像这样更新index.ts
import express from "express";
import session from "express-session";
import connectRedis from "connect-redis";
import Redis from "ioredis";
import { createConnection } from "typeorm";
require("dotenv").config();

我们已经从 TypeORM 导入了createConnection函数:

const main = async () => {
  const app = express();
  const router = express.Router();
await createConnection();

在这里,我们调用了createConnection。但请注意,我们的代码现在包裹在一个名为mainasync函数中。我们需要这样做的原因是createConnection是一个async调用,需要一个await前缀。因此,我们不得不将其包装在一个async函数中,这就是main函数的作用。

其余的代码是一样的,如下所示:

  const redis = new Redis({
    port: Number(process.env.REDIS_PORT),
    host: process.env.REDIS_HOST,
    password: process.env.REDIS_PASSWORD,
  });
  const RedisStore = connectRedis(session);
  const redisStore = new RedisStore({
    client: redis,
  });
  app.use(
    session({
      store: redisStore,
      name: process.env.COOKIE_NAME,
      sameSite: "Strict",
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: {
        path: "/",
        httpOnly: true,
        secure: false,
        maxAge: 1000 * 60 * 60 * 24,
      },
    } as any)
);

再次,代码是一样的:

  app.use(router);
  router.get("/", (req, res, next) => {
    if (!req.session!.userId) {
      req.session!.userId = req.query.userid;
      console.log("Userid is set");
      req.session!.loadedCount = 0;
    } else {
      req.session!.loadedCount = Number(req.session!.       loadedCount) + 1;
    }
    res.send(
      `userId: ${req.session!.userId}, loadedCount: 
        ${req.session!.loadedCount}`
    );
  });
  app.listen({ port: process.env.SERVER_PORT }, () => {
    console.log(`Server ready on port 
     ${process.env.SERVER_PORT}`);
  });
};
main();

最后,我们调用了我们的main函数来执行它。

  1. 现在,通过运行以下命令来运行我们的应用程序:
pgAdmin and go to the Users table with all of its columns created for us:![Figure 14.9 – New Users table    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_14.09_B15508.jpg)Figure 14.9 – New Users tableThis is such a huge time saver! Could you imagine if we had to create each of our tables manually ourselves? With all of their fields and constraints? This would take hours.Notice that our columns have the same settings as from our decorators. For example, our email has a variety of characters, with a length of 120, and is not nullable. 
  1. 然而,我们有一个小问题。我们的id列尽管其他列都是大写,但没有使用大写。让我们来修复这个问题。再次打开User.ts文件,只需将PrimaryGeneratedColumn装饰器的名称设置更改为Id而不是id(只在装饰器中;在我们的 JavaScript 中保留id字段名称)。如果您的服务器没有运行,请重新启动。但重新启动后,刷新id列已更新为Id。这是 TypeORM 的一个很棒的功能,因为手动更改列名或约束有时可能很痛苦。

  2. 太棒了!现在我们只需要创建我们的其他实体:ThreadThreadItem。再次强调,Thread是我们论坛中的初始帖子,而ThreadItems是回复。首先,停止服务器,以免在我们准备好之前创建数据库项。现在,由于这大部分是重复的,我将在这里只显示代码而不加注释。

这两个文件的导入将是相同的,如下所示:

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import { Length } from "class-validator";

Thread实体目前看起来是这样的(一旦建立了表关系,我们将添加更多字段):

@Entity({ name: "Threads" })
export class Thread {
  @PrimaryGeneratedColumn({ name: "Id", type: "bigint" })
  id: string;
  @Column("int", { name: "Views", default: 0, nullable:    false })
  views: number;
  @Column("boolean", { name: "IsDisabled", default:     false, nullable: false }) 
  isDisabled: boolean;
  @Column("varchar", { name: "Title", length: 150,    nullable: false })
  @Length(5, 150)
  title: string;
  @Column("varchar", { name: "Body", length: 2500,    nullable: true
   })
  @Length(10, 2500)
  body: string;
}

ThreadItem看起来是这样的:

@Entity({ name: "ThreadItems" })
export class ThreadItem {
  @PrimaryGeneratedColumn({ name: "Id", type: "bigint" })
  id: string;
  @Column("int", { name: "Views", default: 0, nullable:   false })
  views: number;
  @Column("boolean", { name: "IsDisabled", default:    false, nullable: false })
  isDisabled: boolean;
  @Column("varchar", { name: "Body", length: 2500,    nullable: true
   })
  @Length(10, 2500)
  body: string;
}
  1. 如您所见,这两个实体都非常简单。现在重新启动服务器,您应该会看到两个新表:ThreadsThreadItems

图 14.10 - Threads 和 ThreadItems

图 14.10 - Threads 和 ThreadItems

我们还有许多字段要添加,比如 points 列。但首先,让我们在表之间建立一些关系。例如,每个表都应该与特定的用户有关联。让我们从添加这些关系开始:

  1. 首先,停止服务器。然后,在您的User.ts文件中,将此添加到您的类的底部。我假设您现在知道如何添加任何必需的导入,不再提及它们:
@OneToMany(() => Thread, (thread) => thread.user)
  threads: Thread[];

OneToMany装饰器显示每个单独的User可能有多个关联的Threads

  1. 现在,将这段代码添加到您的Thread.ts文件的Thread类的底部:
@ManyToOne(
    () => User,
    (user:User) => user.threads
  )
  user: User;

ManyToOne装饰器显示每个Thread只有一个与之关联的User。尽管教授 SQL 超出了本书的范围,但简单地说,这些关系作为数据库的约束,意味着我们无法插入没有意义的数据;例如,拥有多个Users拥有一个Thread

  1. 现在,让我们建立ThreadThreadItems之间的关系。将以下代码添加到Thread类中:
@OneToMany(
    () => ThreadItem,
    threadItems => threadItems.thread
  )
  threadItems: ThreadItem[];

再次,这表明一个Thread可以有多个与之关联的ThreadItems。现在,让我们更新我们的ThreadItem

@ManyToOne(() => User, (user) => user.threads)
  user: User;

ThreadItemThread一样,只能与一个User关联为所有者:

  @ManyToOne(() => Thread, (thread) => thread.   threadItems)
  thread: Thread;
  1. 每个ThreadItem只能有一个父Thread。现在,如果重新启动服务器,您应该会看到这些新的关系:

图 14.11 - 关系

图 14.11 - 关系

您将看到ThreadsThreadItems表中已添加了新列。例如,在ThreadItems中,添加了userIdthreadId以指示它们的相关关系。但是,在Users表中没有添加任何内容。这是因为Users表与Threads表具有OneToMany关系。因此,此关系由图像中CREATE TABLE public."Threads"脚本所示的约束表示。正如您所看到的,userId列有一个约束。因此,通过指示每个线程都有一个与之关联的User,它隐含地指示每个User可以拥有一个或多个自己拥有的Threads

现在,让我们设置我们的积分系统。在积分的情况下,即喜欢或不喜欢,我们需要允许用户只能投票一次。但是,没有办法在单个表的术语中指示这一点。因此,我们将创建两个新表,ThreadPointsThreadItemPoints,它们将与相关的UsersThreadsThreadItems关联。

  1. 首先,关闭服务器,然后创建ThreadPoint.ts文件。然后,将以下代码添加到其中:
@Entity({ name: "ThreadPoints" })
export class ThreadPoint {
  @PrimaryGeneratedColumn({ name: "Id", type: "bigint" }) 
    // for typeorm
  id: string;
  @Column("boolean", { name: "IsDecrement", default:    false, nullable: false })
  isDecrement: boolean;
  @ManyToOne(() => User, (user) => user.threadPoints)
  user: User;
  @ManyToOne(() => Thread, (thread) => thread.   threadPoints)
  thread: Thread;
}

因此,在这段代码中,我们在指定特定的UserThread。我们还指出,如果isDecrement字段为true,则这构成了不喜欢。这意味着积分有三种可能的状态:没有积分,喜欢或不喜欢。我们稍后将编写一些代码来处理这三种状态的存储库查询。

  1. 现在,将以下代码添加到User.ts类中:
@OneToMany(() => ThreadPoint, (threadPoint) => threadPoint.user)
  threadPoints: ThreadPoint[];

同样,此代码完成了代码中的关联。

  1. 接下来,将以下内容添加到Thread.ts类中:
@OneToMany(() => ThreadPoint, (threadPoint) => 
 threadPoint.thread)
  threadPoints: ThreadPoint[];

这也完成了与ThreadPoint的关联。

  1. 现在,我们需要为ThreadItemPoints做同样的事情。创建ThreadItemPoint.ts并添加以下代码:
@Entity({ name: "ThreadItemPoints" })
export class ThreadItemPoint {
  @PrimaryGeneratedColumn({ name: "Id", type: "bigint" }) 
    // for typeorm
  id: string;
  @Column("boolean", { name: "IsDecrement", default:   false,
   nullable: false })
  isDecrement: boolean;
  @ManyToOne(() => User, (user) => user.threadPoints)
  user: User;
  @ManyToOne(() => ThreadItem, (threadItem) => 
    threadItem.threadItemPoints)
  threadItem: ThreadItem;
}

这与ThreadPoint的设置非常相似。

  1. 现在,通过添加以下内容来更新我们的User类:
@OneToMany(() => ThreadItemPoint, (threadItemPoint) => 
 threadItemPoint.user)
  threadItemPoints: ThreadItemPoint[];

然后,通过添加以下内容来更新我们的ThreadItem类:

@OneToMany(
    () => ThreadItemPoint,
    (threadItemPoint) => threadItemPoint.threadItem
  )
  threadItemPoints: ThreadItemPoint[];

这也完成了与ThreadItemPoint相关的关联。

但我们还没有完成。您可能还记得第十一章我们将学到什么-在线论坛应用,我们的主题将有类别,因此我们还需要创建该实体及其关系:

  1. 首先,创建ThreadCategory.ts文件,并将以下代码添加到其中:
@Entity({ name: "ThreadCategories" })
export class ThreadCategory {
  @PrimaryGeneratedColumn({ name: "Id", type: "bigint" }) 
    // for typeorm
  id: string;
  @Column("varchar", {
    name: "Name",
    length: 100,
    unique: true,
    nullable: false,
  })
  name: string;
  @Column("varchar", {
    name: "Description",
    length: 150,
    nullable: true,
  })
  description: string;
  @OneToMany(() => Thread, (thread) => thread.category)
  threads: Thread[];
}

ThreadCategory与其他实体有一个非常相似的设置。

  1. 现在,将以下内容添加到Thread.ts类中:
@ManyToOne(() => ThreadCategory, (threadCategory) => 
  threadCategory.threads)
  category: ThreadCategory;

当然,这就建立了ThreadThreadCategory之间的关系。

  1. 现在,运行服务器,它应该创建表和关联。

现在我们已经创建了所需的实体及其关联。但是,每当我们向数据库添加数据时,我们希望记录其创建或更改的时间。但是,实现这一点将在所有实体中创建相同的字段,我们不希望一遍又一遍地编写相同的代码。

由于 TypeScript 允许我们在类中使用继承,因此让我们创建一个具有我们需要的这些字段的基本类型,然后让每个实体简单地从这个基类继承。此外,TypeORM 要求我们的实体必须从其自己的基类继承,以便能够连接到其 API。因此,让我们在我们自己的基类中也添加 TypeORM 基类:

  1. 创建一个名为Auditable.ts的文件,并添加以下代码:
import { Column, BaseEntity } from "typeorm";
export class Auditable extends BaseEntity {
  @Column("varchar", {
    name: "CreatedBy",
    length: 60,
    default: () => `getpgusername()`,
    nullable: false,
  })
  createdBy: string;

Getpgusername是服务账户superforumsvc,除非明确设置,否则该字段将默认为此:

  @Column("timestamp with time zone", {
    name: "CreatedOn",
    default: () => `now()`,
    nullable: false,
  })
  createdOn: Date;

除非明确设置,否则该字段将默认为当前时间和日期now()

正如您所看到的,字段的作用是相当不言自明的。但是,请注意我们的基类Auditable还扩展了名为BaseEntity的 TypeORM 基类。这种BaseEntity继承是允许我们的实体通过 TypeORM 访问 Postgres 数据库的原因:

  @Column("varchar", {
    name: "LastModifiedBy",
    length: 60,
    default: () => `getpgusername()`,
    nullable: false,
  })
  lastModifiedBy: string;
  @Column("timestamp with time zone", {
    name: "LastModifiedOn",
    default: () => `now()`,
    nullable: false,
  })
  lastModifiedOn: Date;
}
  1. 好的,这就是新的Auditable基类的内容。现在我们想让我们的实体继承它。这很简单。例如,在User类中,只需添加extends关键字并像这样添加Auditable类:
export class User extends Auditable {

对每个实体重复此过程,然后重新启动服务器(记得根据需要添加导入语句)。刷新视图后,您应该看到新的字段如下:

图 14.12-更新为可审计的用户

图 14.12-更新为可审计的用户

太棒了!现在我们可以创建实际调用我们数据库的存储库库。由于我们在上一章中创建了我们的会话状态,[第十三章](B15508_13_Final_JC_ePub.xhtml#_idTextAnchor208),使用 Express 和 Redis 设置会话状态,让我们首先创建与身份验证相关的调用:

  1. 在创建我们的主要代码之前,我们需要先做一些事情。您可能还记得[第十一章](B15508_11_Final_JC_ePub.xhtml#_idTextAnchor167),我们将学到什么-在线论坛应用,我们使用了一个名为isPasswordValid的函数来检查用户的密码是否足够长和复杂。因为,正如我当时提到的,通常应该在客户端和服务器上进行验证。因此,让我们暂时将PasswordValidator.ts文件和common/validators文件夹结构复制到我们的服务器项目中,稍后我将展示一种在多个项目之间共享代码的方法。

  2. 让我们还为电子邮件地址创建一个验证器。在相同的common/validators目录中创建一个EmailValidator.ts文件,并添加此代码:

export const isEmailValid = (email: string) => {
if (!email) return "Email cannot be empty";

在这里,我检查了一个空地址。

  if (!email.includes("@")) {
    return "Please enter valid email address.";

在这里,我检查了@符号。

  }
  if (/\s+/g.test(email)) {
    return "Email cannot have whitespaces";

最后,在这里我检查了空格。

  }
  return "";
};

如果没有发现问题,将返回一个空字符串。

  1. 创建UserRepo.ts文件并添加此代码:
import { User } from "./User";
import bcrypt from "bcryptjs";
import { isPasswordValid } from "../common/validators/PasswordValidator";
import { isEmailValid } from "../common/validators/EmailValidator";

首先,我们有我们的导入,包括我们的验证器。

const saltRounds = 10;

saltRounds用于密码加密,很快您就会看到。

export class UserResult {
  constructor(public messages?: Array<string>, public    user?:
   User) {}
}

我们将使用UserResult类型指示身份验证期间是否发生错误。正如您所看到的,它基本上是User对象的包装器。我们正在将此对象用作我们函数的返回类型。我们这样做是因为在进行网络调用或其他复杂调用时,出现问题是很常见的。因此,具有在对象中包含错误或状态消息的能力是有益的。请注意,messagesuser两个成员都是可选的。一旦我们开始使用这种类型,这将非常方便。

export const register = async (
  email: string,
  userName: string,
  password: string
): Promise<UserResult> => {

这是我们的register函数的开始。

  const result = isPasswordValid(password);
  if (!result.isValid) {
    return {
      messages: [
        "Passwords must have min length 8, 1 upper          character, 1 number, and 1 symbol",
      ],
    };
  }
  const trimmedEmail = email.trim().toLowerCase();
  const emailErrorMsg = isEmailValid(trimmedEmail);
  if (emailErrorMsg) {
    return {
      messages: [emailErrorMsg],
    };
  }

在这里,我们运行了我们的两个验证器isPasswordValidisEmailValid。请注意,我们使用对象字面量作为返回对象,而没有包含user成员。同样,TypeScript 只关心我们对象的形状是否与类型的形状匹配。因此,在这种情况下,由于我们的UserResult成员user是可选的,我们可以创建一个不包括它的UserResult对象。TypeScript 真的很灵活。

  const salt = await bcrypt.genSalt(saltRounds);
  const hashedPassword = await bcrypt.hash(password,    salt);

在这里,我们使用saltRounds常量和bcryptjs加密了我们的密码。

  const userEntity = await User.create({
    email: trimmedEmail,
    userName,
    password: hashedPassword,
  }).save();

然后,如果我们通过了验证,我们将create我们的User实体,然后立即save它。这两种方法都来自 TypeORM,请注意,当对实体数据库进行更改时,您需要使用save函数,否则它将无法在服务器上完成。

  userEntity.password = ""; // blank out for security
  return {
     user: userEntity
  };
};

然后,我们返回新实体,再次,由于我们的调用没有错误,我们只返回不包含任何messagesuser对象。

  1. 让我们尝试这个新功能register,进行真正的网络调用。像这样更新index.ts文件:
import express from "express";
import session from "express-session";
import connectRedis from "connect-redis";
import Redis from "ioredis";
import { createConnection } from "typeorm";
import { register } from "./repo/UserRepo";
import bodyParser from "body-parser";

请注意,我们现在导入了bodyParser

require("dotenv").config();
const main = async () => {
  const app = express();
  const router = express.Router();
  await createConnection();
  const redis = new Redis({
    port: Number(process.env.REDIS_PORT),
    host: process.env.REDIS_HOST,
    password: process.env.REDIS_PASSWORD,
  });
  const RedisStore = connectRedis(session);
  const redisStore = new RedisStore({
    client: redis,
  });
  app.use(bodyParser.json());	

在这里,我们设置了我们的bodyParser,这样我们就可以从帖子中读取json参数。

  app.use(
    session({
      store: redisStore,
      name: process.env.COOKIE_NAME,
      sameSite: "Strict",
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: {
        path: "/",
        httpOnly: true,
        secure: false,
        maxAge: 1000 * 60 * 60 * 24,
      },
    } as any)
  );

所有这些代码保持不变:

  app.use(router);
  router.post("/register", async (req, res, next) => {
    try {
      console.log("params", req.body);
      const userResult = await register(
        req.body.email,
        req.body.userName,
        req.body.password
      );
      if (userResult && userResult.user) {
        res.send(`new user created, userId: ${userResult.         user.id}`);
      } else if (userResult && userResult.messages) {
        res.send(userResult.messages[0]);
      } else {
        next();
      }
    } catch (ex) {
      res.send(ex.message);
    }
  });

如您所见,我们删除了以前的get路由,并在注册 URL 上用post替换它。这个调用现在运行我们的UserRepo register函数,如果成功,它会发送一个带有新用户 ID 的消息。如果不成功,它会发送回来自存储库调用的错误消息。在这种情况下,我们只使用第一条消息,因为我们将删除这些路由,并在第十五章中用 GraphQL 替换它们,添加 GraphQL 模式-第一部分

  app.listen({ port: process.env.SERVER_PORT }, () => {
    console.log(`Server ready on port
     ${process.env.SERVER_PORT}`);
  });
};
main();

现在我们将开始测试。但是,我们需要切换到使用 Postman 而不是 curl。Postman 是一个免费的应用程序,它允许我们向服务器发出GETPOST调用,并接受会话 cookie。它非常容易使用:

  1. 首先,转到www.postman.com/downloads,并下载并安装适用于您系统的 Postman。

  2. 安装后,您应该首先在 Postman 上运行站点根目录的GET调用。我在index.ts中为根目录创建了一个简单的路由,它将初始化会话及其 cookie。像这样在我们的站点上运行GET调用:

图 14.13-在站点根目录上运行 Postman

图 14.13-在站点根目录上运行 Postman

这就是您可以运行相同GET调用的方法:

  1. 在标有GET的顶部标签下,您应该看到左侧的一个下拉菜单。选择GET并添加本地 URL。没有参数,所以只需点击Send

  2. 然后,在左下角,您将看到另一个下拉菜单。选择Cookies,您应该会看到我们的名为superforum的 cookie。

现在您已经获得了维护会话状态所需的 cookie。因此,我们现在可以继续我们的测试,从register函数开始:

  1. 打开一个新标签,选择http://localhost:5000/register

  2. 点击Headers选项卡,并插入Content-Type,如下所示:图 14.14-内容类型

图 14.14-内容类型

  1. 现在,选择电子邮件,尽管它是无效的,用户名密码,也是无效的。

但是,这种失败仍然是好的,因为我们已经确认了我们的验证是有效的。

  1. 让我们修复密码,然后再试一次。将密码更新为Test123!@#,然后再次运行它:图 14.16-尝试再次注册

图 14.16-尝试再次注册

现在您应该会看到消息请输入有效的电子邮件地址。再次强调,这正是我们想要的,因为显然给出的电子邮件是无效的。

  1. 让我们再试一次。将电子邮件更新为test@test.com,并运行此操作:图 14.17-成功注册

图 14.17-成功注册

输出消息为10,因为我在准备本书时进行了一些测试。ID 字段通常将从1开始。如果您再次看不到此结果,请确保在使用GET调用时在我们网站的根目录上运行 Postman。

  1. 太棒了!成功了!现在,让我们查看我们的Users表,以确保用户确实已添加:图 14.18-向用户表添加新用户

图 14.18-向用户表添加新用户

您可以通过右键单击pgAdmin中的Users表并选择Scripts > SELECT Script来运行所示的查询。您可以通过点击顶部的播放按钮来运行脚本。但是,如您所见,我们的用户已插入到数据库中。

  1. 现在,让我们用我们的login函数更新UserRepo。将以下代码添加到UserRepo的末尾:
export const login = async (
  userName: string,
  password: string
): Promise<UserResult> => {
  const user = await User.findOne({
    where: { userName },
  });
  if (!user) {
    return {
      messages: [userNotFound(userName)],
    };
  }
  if (!user.confirmed) {
    return {
      messages: ["User has not confirmed their        registration email yet."],
    };
  }
  const passwordMatch = await bcrypt.compare(password, 
    user?.password);
  if (!passwordMatch) {
    return {
      messages: ["Password is invalid."],
    };
  }
  return {
    user: user,
  };
};

这里没有太多要展示的。 我们尝试查找具有给定userName的用户。 如果找不到,则返回一条消息,指出未找到user,使用名为userNotFound的函数。 我使用函数是因为我们稍后将重用此消息。 这是一个简单的函数,所以我不会在这里介绍它(它在源代码中)。 如果找到用户,那么我们首先看一下帐户是否已确认。 如果没有,我们会提供一个错误。 接下来,我们通过使用bcryptjs来检查他们的密码,因为我们在注册时使用了该工具对其进行加密。 如果不匹配,我们还会提供一个错误。 如果一切顺利,用户存在,我们将返回用户。

  1. 让我们也尝试运行这个。 通过在注册路线下方添加这个新路线来更新index.ts
router.post("/login", async (req, res, next) => {
    try {
      console.log("params", req.body);
      const userResult = await login(req.body.userName, 
        req.body.password);
      if (userResult && userResult.user) {
        req.session!.userId = userResult.user?.id;
        res.send(`user logged in, userId: 
         ${req.session!.userId}`);
      } else if (userResult && userResult.messages) {
        res.send(userResult.messages[0]);
      } else {
        next();
      }
    } catch (ex) {
      res.send(ex.message);
    }
  });

这与我们的register路线非常相似。 但是,在这里,我们将用户的id保存到会话状态中,然后使用该会话发送一条消息。

  1. 让我们运行这个路线,看看会发生什么。 再次在 Postman 中打开一个新标签,并按照这里显示的设置运行。 记住Headers选项卡中添加 Content-Type标头:图 14.19 - 登录路线

图 14.19 - 登录路线

同样,这是很好的,因为我们的验证正在起作用。

  1. 转到您的pgAdmin,打开您用于运行SELECT查询以查看我们第一个插入的用户的相同屏幕。 然后,运行此 SQL 以将我们的用户的confirmed列更新为true图 14.20 - 更新用户的确认字段

图 14.20 - 更新用户的确认字段

运行查询后,您应该会看到与图 14.20中显示的相同消息。

  1. 现在,让我们运行 Postman 再次尝试登录:

图 14.21 - 登录用户

图 14.21 - 登录用户

现在,我们的用户可以登录,并且根据返回的消息,我们现在可以看到我们正在使用会话状态。 我已在源代码中创建了logout函数和路线。 我不会在这里展示它,因为它很简单。

注意

如果您尝试保存到会话失败,请确保您的 Redis 服务正在运行。

太棒了! 我们已经走了很长的路。 我们现在拥有基于会话的身份验证,但我们还没有完成。 我们需要创建插入ThreadsThreadItems以及检索它们的方法。 让我们从Threads开始:

  1. 在创建新的ThreadRepo存储库之前,让我们构建一个小助手。 在UserRepo中,我们有一个名为UserResult的类型,其中包含一组消息和一个用户作为成员。 您会注意到任何ThreadsThreadItemsCategories的存储库都需要类似的构造。 它应该有一组消息和实体,尽管返回的实体将是一组项目,而不仅仅是一个。

这似乎是使用 TypeScript 泛型的好地方,这样我们可以在所有这些实体之间共享单个结果类型。 让我们创建一个名为QueryResult的新通用结果对象类型。 我们在第二章中学习了有关 TypeScript 泛型的知识,探索 TypeScript

创建一个名为QueryArrayResult.ts的文件,并将以下代码添加到其中:

export class QueryArrayResult<T> {
  constructor(public messages?: Array<string>, public    entities?: Array<T>) {}
}

如您所见,这与原始的UserResult非常相似。 但是,此类型使用类型T的通用类型来指示我们的任何实体。

警告

pg依赖项还有一个名为QueryArrayResult的类型。 在导入我们的依赖项时,请确保导入我们的文件,而不是pg

  1. 现在,让我们在ThreadRepo中使用这种新的QueryArrayResult类型。 在repo文件夹中创建一个名为ThreadRepo.ts的新文件,并添加以下代码:
export const createThread = async (
  userId: string,
  categoryId: string,
  title: string,
  body: string
): Promise<QueryArrayResult<Thread>> => {

所示的参数是必需的,因为每个“线程”必须与用户和类别相关联。 请注意,userId是从我们的会话中获取的。

  const titleMsg = isThreadTitleValid(title);
  if (titleMsg) {
    return {	
      messages: [titleMsg],
    };
  }
  const bodyMsg = isThreadBodyValid(body);
  if (bodyMsg) {
    return {
      messages: [bodyMsg],
    };
  }

在这里,我们验证我们的titlemessage

  // users must be logged in to post
  const user = await User.findOne({
    id: userId,
  });
  if (!user) {
    return {
      messages: ["User not logged in."],
    };
  }

在这里,我们获取我们提供的会话userId,并尝试找到匹配的user。 我们稍后需要这个user对象来创建我们的新Thread

  const category = await ThreadCategory.findOne({
    id: categoryId,
  });
  if (!category) {
    return {
      messages: ["category not found."],
    };
  }

在这里,我们得到一个category对象,因为我们在创建新的Thread时需要传递它。

  const thread = await Thread.create({
    title,
    body,
    user,
    category,
  }).save();
  if (!thread) {
    return {
      messages: ["Failed to create thread."],
    };
  }

正如你所看到的,我们传递titlebodyusercategory来创建我们的新Thread

  return {
    messages: ["Thread created successfully."],
  };
};

我们只返回消息,因为我们不需要返回实际的对象。此外,返回不需要的对象在 API 负载大小方面是低效的。

  1. 在我们继续之前,我们需要向数据库中添加一些ThreadCategories,这样我们才能真正使用createThread函数。去源代码中找到utils/InsertThreadCategories.txt文件。将这些insert语句复制粘贴到pgAdmin的查询屏幕中并运行。这将创建列出的ThreadCategories

  2. 接下来,我们需要添加用于创建Threads的路由。将以下代码添加到index.ts中:

router.post("/createthread", async (req, res, next) => {
    try {
      console.log("userId", req.session);
      console.log("body", req.body);
      const msg = await createThread(
        req.session!.userId, // notice this is from          session!
        req.body.categoryId,
        req.body.title,
        req.body.body
      );

在这个超级简单的调用中,我们向createThread函数传递参数。同样,我们的userId来自我们的会话,因为用户应该登录才能被允许发布,然后我们简单地返回结果消息。

      res.send(msg);
    } catch (ex) {
      console.log(ex);
      res.send(ex.message);
    }
  });
  1. 让我们尝试运行这个路由。不过,在此之前,先在 Postman 中运行登出路由。你可以在http://localhost:5000/logoutURL 中找到它。我相信你现在可以自己设置 Postman。一旦完成,让我们尝试运行createthread路由,希望它应该会失败验证:图 14.22 – 测试 createthread 路由

图 14.22 – 测试 createthread 路由

是的,它如预期般失败了验证。

  1. 现在,让我们再次登录,以便我们的会话得到创建。再次使用 Postman 进行操作,然后再次运行createthread路由。这次,它应该会显示消息,Thread created successfully

  2. 好的。现在我们需要另外两个函数,一个是根据其 ID 获取单个Thread,另一个是获取ThreadCategory的所有线程。将以下代码添加到ThreadRepo中:

export const getThreadById = async (
  id: string
): Promise<QueryOneResult<Thread>> => {
  const thread = await Thread.findOne({ id });
  if (!thread) {
    return {
      messages: ["Thread not found."],
    };
  }
  return {
    entity: thread,
  };
};

这个getThreadById函数非常简单。它只是基于 ID 查找单个线程。

export const getThreadsByCategoryId = async (
  categoryId: string
): Promise<QueryArrayResult<Thread>> => {
  const threads = await Thread.   createQueryBuilder("thread")
    .where(`thread."categoryId" = :categoryId`, {       categoryId })
    .leftJoinAndSelect("thread.category", "category")
    .orderBy("thread.createdOn", "DESC")
    .getMany();

这个getThreadsByCategoryId函数更有趣。Thread.createQueryBuilder是 TypeORM 中的一个特殊函数,允许我们构建更复杂的查询。函数的thread参数是一个别名,用于表示查询中的 Threads 表。因此,如果你看一下查询的其余部分,比如where子句,你会发现我们使用thread作为字段或关系的前缀。leftJoinAndSelect函数意味着我们要进行 SQL 左连接,但同时也要返回相关的实体,即ThreadCategory与结果集一起。OrderBy相当直观,getMany只是意味着返回所有项目。

  if (!threads) {
    return {
      messages: ["Threads of category not found."],
    };
  }
  console.log(threads);
  return {
    entities: threads,
  };
};
  1. 其余的代码非常简单。让我们测试getThreadsByCategoryId作为一个路由。将其添加到index.ts文件中:
router.post("/threadbycategory", async (req, res, next) => {
    try {
      const threadResult = await 
       getThreadsByCategoryId(req.body.categoryId);

在这里,我们使用categoryId参数调用了getThreadsByCategoryId

      if (threadResult && threadResult.entities) {
        let items = "";
        threadResult.entities.forEach((th) => {
          items += th.title + ", ";
        });
        res.send(items);
      } else if (threadResult && threadResult.messages) {
        res.send(threadResult.messages[0]);
      }

在这个if else代码中,我们要么显示所有标题,要么显示错误。

    } catch (ex) {
      console.log(ex);
      res.send(ex.message);
    }
  });
  1. 其余的代码与之前一样。在你的 Postman 客户端中运行这个,你应该会看到这个。再次提醒,你的 ID 号码可能会有所不同:

图 14.23 – 测试 threadsbycategory 路由

图 14.23 – 测试 threadsbycategory 路由

我会把getThreadById的测试留给你,因为它很容易。同样,源代码在我们的项目存储库中。

ThreadItems的代码几乎相同,并且在我们的源代码中。所以,我不会在这里进行复习。现在,我们需要一些额外的函数来获取诸如ThreadCategories之类的东西,以填充我们的 React 应用程序的LeftMenu。我们还需要检索我们的ThreadsThreadItems的积分。我们还需要UserProfile屏幕的相关Thread数据。然而,这些调用将重复我们在本节学到的许多概念,而且我们将不得不创建路由,最终我们将在开始 GraphQL 服务器代码后删除。因此,让我们把这些留到第十五章添加 GraphQL 模式-第一部分,在那里我们还可以开始将后端 GraphQL 代码与我们的 React 前端集成。

在本节中,我们学习了如何构建一个存储库层,并使用 TypeORM 进行 Postgres 查询。一旦我们开始在下一章中集成 GraphQL,我们将会重复使用我们的查询技能,因此这是我们将继续使用的重要知识。

总结

在本章中,我们学习了如何设置一个 Postgres 数据库以及如何使用 ORM TypeORM 进行查询。我们还学习了如何通过使用存储库层来保持我们的代码清晰分离。

在下一章中,我们将学习如何在我们的服务器上启用 GraphQL。我们还将完成我们的数据库查询,并将我们的后端集成到我们的 React 前端中。

第十五章:添加 GraphQL 模式第一部分

在本章中,我们将继续通过集成 GraphQL 来构建我们的应用程序。我们将在客户端和服务器上都这样做。我们还将完成构建后端 Express 服务器并将该后端与我们的 React 客户端集成。

在本章中,我们将涵盖以下主要主题:

  • 创建 GraphQL 服务器端的 typedefs 和解析器

  • 将身份验证与 GraphQL 解析器集成

  • 为查询 Apollo GraphQL 创建 React 客户端 Hooks

技术要求

您应该对 GraphQL 有基本的了解,并且对 React、Node.js、Postgres 和 Redis 有很好的了解。我们将再次使用 Node 和Visual Studio CodeVSCode)来编写我们的代码。

GitHub 存储库位于github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node。使用Chap15文件夹中的代码。

要设置第十五章代码文件夹,请执行以下操作:

  1. 转到您的HandsOnTypescript文件夹,并创建一个名为Chap15的新文件夹。

  2. 现在转到Chap14文件夹,并将super-forum-server文件夹复制到Chap15文件夹中。确保所有文件都已复制。

  3. super-forum-server文件夹中删除node_modules文件夹和package-lock.json文件。确保您在super-forum-server文件夹中,并运行此命令:

npm install 
  1. 现在确保您的 Postgres 服务器和 Redis 服务器正在运行,如第十三章中所示,使用 Express 和 Redis 设置会话状态,以及第十四章使用 TypeORM 设置 Postgres 和存储库层。然后,通过运行此命令来测试您的服务器:
npm start 
  1. 现在让我们复制我们的客户端应用。转到Chap13文件夹,将super-forum-client复制到Chap15的根目录。确保所有文件都已复制。

  2. 删除node_modules文件夹和package-lock.json文件。现在确保您在super-forum-client文件夹中,并运行此命令:

npm install
  1. 通过运行此命令测试它是否有效:
npm start 

创建 GraphQL 服务器端 typedefs 和解析器

在本节中,我们将把 GraphQL 服务添加到我们的 Express 服务器中。我们还将开始将我们在第十四章中创建的路由转换为 GraphQL 查询。我们还将完善我们需要的其余调用,作为 GraphQL 查询。

让我们首先将 GraphQL 集成到我们的 Express 应用程序中(我们在第九章中介绍了 GraphQL,什么是 GraphQL,以及第十章使用 TypeScript 和 GraphQL 依赖项设置 Express 项目):

注意

本章中将有大量的代码,不是所有代码都可以在文本中显示。请经常参考 GitHub 存储库代码,这是章节源代码。还要注意,章节源代码是最终运行的项目,只包含最终的工作代码。

  1. 让我们开始安装 GraphQL。运行这个命令:
npm i apollo-server-express graphql graphql-middleware graphql-tools
  1. 接下来,让我们创建我们的初始类型定义typeDefs。在src文件夹内创建一个名为gql的文件夹。然后在其中创建文件typeDefs.ts。现在添加此代码:
import { gql } from "apollo-server-express";
const typeDefs = gql`
  scalar Date

我们定义了一个新的自定义scalar类型,Date,在 GraphQL 中默认不可用于日期和时间:

  type EntityResult {
    messages: [String!]
  }

这种EntityResult类型将在我们的解析器返回错误或消息而不是实体时使用:

  type User {
    id: ID!
    email: String!
    userName: String!
    password: String!
    confirmed: Boolean!
    isDisabled: Boolean!
    threads: [Thread!]
    createdBy: String!
    createdOn: Date!
    lastModifiedBy: String!
    lastModifiedOn: Date!
  }

我们在这里创建了我们的User类型。注意到与ThreadThreadItem的关系。我们还使用了我们的Date类型:

  type Thread {
    id: ID!
    views: Int!
    isDisabled: Boolean!
    title: String!
    body: String!
    user: User!
    threadItems: [ThreadItem!]
    category: ThreadCategory
    createdBy: String!
    createdOn: Date!
    lastModifiedBy: String!
    lastModifiedOn: Date!
}

我们创建了我们的Thread类型及其关系:

  union ThreadResult = Thread | EntityResult

现在我们正在实现我们的真实应用程序,是时候使用一些更复杂的 GraphQL 特性了。union类型与 TypeScript 中的概念相同。它将允许我们从可能的 GraphQL 类型列表中返回任何类型。例如,在这个例子中,这个类型可以表示要么是 Thread,要么是 EntityResult,但不能同时是两者。我将很快展示这种类型的用法,它将变得更清晰。

  type ThreadItem {
    id: ID!
    views: Int!
    isDisabled: Boolean!
    body: String!
    user: User!
    thread: Thread!
    createdBy: String!
    createdOn: Date!
    lastModifiedBy: String!
    lastModifiedOn: Date!
  }

我们创建了我们的ThreadItem类型。

  type ThreadCategory {
    id: ID!
    name: String!
    description: String
    threads: [Thread!]!
    createdBy: String!
    createdOn: Date!
    lastModifiedBy: String!
    lastModifiedOn: Date!
}

ThreadCategory类型还指的是它包含的Threads

  type Query {
    getThreadById(id: ID!): ThreadResult
  }
`;

在这里,我们有一个带有getThreadById函数的Query。注意它返回我们的union ThreadResult。我们稍后会详细介绍这个。

export default typeDefs;
  1. 现在让我们创建一个简单的解析器文件,以开始使用我们的 GraphQL 安装。在gql文件夹中创建一个名为resolvers.ts的文件,并添加以下代码:
import { IResolvers } from "apollo-server-express";
interface EntityResult {
  messages: Array<string>;
}

我们将使用EntityResult作为我们的错误和状态消息的返回类型。还要将我们的类型映射添加到typeDefs文件中的相同类型:

const resolvers: IResolvers = {
  ThreadResult: {
    __resolveType(obj: any, context: GqlContext, info:      any) {
      if (obj.messages) {
        return "EntityResult";
      }
      return "Thread";
    },
},

这是我们正在使用的 GraphQL 的另一个新特性。ThreadResult是在 GraphQL 中表示两种类型ThreadEntityResultunion。这个解析器注意到即将返回一个ThreadResult,并在内部确定它是哪种类型。您使用的方法完全取决于您确定要发送的类型,但在这里,我们通过检查obj.messageEntityResult类型的message字段进行了简单的检查:

  Query: {
    getThreadById: async (
      obj: any,
      args: { id: string },
      ctx: GqlContext,
      info: any
    ): Promise<Thread | EntityResult> => {
      let thread: QueryOneResult<Thread>;
      try {
        thread = await getThreadById(args.id);
        if (thread.entity) {
          return thread.entity;
        }
        return {
          message: thread.messages ? thread.messages[0] :            "test",
        };
      } catch (ex) {
        throw ex;
      }
    },
  },
};
export default resolvers;

我们在第九章中学习了 GraphQL 查询,所以我不会在这里过多地介绍它。只需注意,在这个调用中,我接受来自getThreadById调用的结果类型QueryOneResult,并在一些处理之后,返回实际的实体本身或EntityResult。同样,由于我们的typeDefs文件将我们的查询返回为ThreadResult,它将转到ThreadResult查询并确定要返回的类型。这是我们将重复用于大多数存储库调用的模式。存储库在第十四章中有所涵盖,使用 TypeORM 设置 Postgres 和存储库层

注意

对于这个示例应用程序,我们只是重新抛出可能发生的错误。但在您的生产应用程序中,您应该根据您的应用程序适当地处理错误,通常意味着至少记录问题,以便以后查看。

我们将稍后用更多的查询和变异填充这段代码,但现在让我们专注于完成我们的基本设置。

  1. Chap10/gql-server/src文件夹中的GqlContext.ts文件复制并粘贴到gql文件夹中。正如我们在第九章中所展示的,什么是 GraphQL?,这是我们的请求和响应对象在 GraphQL 调用中的位置。

  2. 现在让我们打开我们的index.ts文件,并将 GraphQL 添加到其中。在调用listen之前添加以下代码,并确保添加必要的导入,现在您应该能够自己完成:

const schema = makeExecutableSchema({ typeDefs, resolvers });
const apolloServer = new ApolloServer({
    schema,
    context: ({ req, res }: any) => ({ req, res }),
});
apolloServer.applyMiddleware({ app });

这基本上是与第九章中相似的代码,什么是 GraphQL?,在那里我们实例化我们的ApolloServer并将其带入我们的typeDefsresolvers和 Expressapp实例。

  1. 让我们测试一下,确保它能正常工作。打开 URL http://localhost:5000/graphql。这是我们在第九章中审查过的 GraphQL playground,什么是 GraphQL?。按照所示运行它:图 15.1 – 对 GraphQL 的第一个查询

图 15.1 – 对 GraphQL 的第一个查询

所以,你可以看到,我们的调用现在可以工作了。与我们之前对 GraphQL 的一些调用的唯一区别是,由于我们的调用可能返回两种不同的类型,我们使用… on <some type>语法来决定返回时我们想要哪个实体和字段(这个功能称为内联片段)。同样,请记住你的本地 ID 号可能不会和我的一样,所以你需要发送在你的数据库中确实存在的 ID。

  1. 好的,让我们再做一个。这次,我们选择一个不返回实体的函数 - createThread函数。首先,在你的typeDefs文件末尾添加这个 mutation:
type Mutation {
    createThread(
      userId: ID!
      categoryId: ID!
      title: String!
      body: String!
    ): EntityResult
}

请注意我们没有返回ThreadResult。我们的createThread函数只返回一个字符串消息。所以这就是我们需要的全部。

  1. 现在让我们更新resolvers文件。将此函数作为一个 mutation 添加进去。同样,你需要自己导入所需的任何内容:
Mutation: {
    createThread: async (
      obj: any,
      args: { userId: string; categoryId: string; title:        string; body: string },
      ctx: GqlContext,
      info: any
    ): Promise<EntityResult> => {

再次,和往常一样的参数列表,但这次我们只返回EntityResult,因为没有必要返回整个实体:

      let result: QueryOneResult<Thread>;
      try {
        result = await createThread(
          args.userId,
          args.categoryId,
          args.title,
          args.body
        );

在这里,我们调用了存储库的createThread并得到了结果。

        return {
          messages: result.messages
            ? result.messages
            : ["An error has occurred"],
        };

现在我们正在返回可能的消息列表来指示结果的状态。

      } catch (ex) {
        throw ex;

在生产中,你不应该简单地重新抛出异常,而是应该记录或以其他方式处理错误。我们在这里重新抛出异常是为了简化并专注于手头的概念,而不要被岔开。

      }
    },
  },
  1. 所以,现在如果我们运行我们的代码,我们应该会看到这个:图 15.2 - createThread 函数

图 15.2 - createThread 函数

  1. 好的,让我们再为 Threads 做一个调用。在ThreadRepo中有一个调用getThreadsByCategoryId,它返回一个 Threads 数组。这是一个问题,因为 GraphQL 的union运算符不支持数组。所以我们需要在typeDefs文件中创建另一个新的实体来表示一个 Threads 数组,然后我们可以创建我们的 union。通过在 ThreadResult union 下面添加以下内容来更新typeDefs文件:
type ThreadArray {
    threads: [Thread!]
}
union ThreadArrayResult = ThreadArray | EntityResult

所以我们首先创建了一个返回 Threads 数组的实体。然后我们创建了我们的union,它可以返回该实体类型或EntityResult

现在在getThreadById查询之后添加这个:

getThreadsByCategoryId(categoryId: ID!): ThreadArrayResult!
  1. 现在我们可以构建我们的解析器。通过添加以下内容更新resolvers查询部分:
getThreadsByCategoryId: async (
      obj: any,
      args: { categoryId: string },
      ctx: GqlContext,
      info: any
    ): Promise<{ threads: Array<Thread> } | EntityResult>      => {
      let threads: QueryArrayResult<Thread>;
      try {
        threads = await getThreadsByCategoryId(args.         categoryId);
        if (threads.entities) {
          return {
            threads: threads.entities,
          };
        }

在这里,我们返回我们的 Threads 数组。

        return {
          messages: threads.messages
            ? threads.messages
            : ["An error has occurred"],
        };

在这里,如果没有 Threads,我们返回我们的消息。

      } catch (ex) {
        throw ex;
      }
    },
  1. 我们只缺少一个项目。当我们首次使用union时,我们必须为EntityResult类型创建一个查询。因此,我们需要为我们的新ThreadArrayResult类型做同样的事情。在resolvers文件中EntityResult定义之后输入以下代码:
ThreadArrayResult: {
    __resolveType(obj: any, context: GqlContext, info:     any) {
      if (obj.messages) {
        return "EntityResult";
      }
      return "ThreadArray";
    },
  },

这和之前的情况一样。如果obj有一个messages属性,我们返回EntityResult类型;如果没有,我们返回ThreadArray类型。

  1. 如果我们运行这个查询,我们应该会看到类似这样的结果(注意我的结果中充满了重复的测试数据):

图 15.3 - getThreadsByCategoryId 函数

图 15.3 - getThreadsByCategoryId 函数

请注意我们添加了一个额外的字段叫做__typename。这个字段将告诉我们返回的是哪种类型,如所示是ThreadArray

好的,现在我们有一个可以工作的 GraphQL 服务器,可以查询 Threads。尝试并集成第十四章中与身份验证无关的调用,使用 TypeORM 设置 Postgres 和存储库层。如果你遇到困难,可以参考源代码。但重要的是你尝试并且不要查看,因为这样你才能确切地知道你是否理解了材料。

ThreadPoint System

现在我们已经集成了现有的解析器调用,让我们创建一些我们仍然需要的调用。我们为我们的 Threads 和 ThreadItems 创建了一个点系统。现在让我们实现一种增加和减少点数的方法。如果已经有一段时间了,请在继续之前查看一下 ThreadPoint 和 ThreadItemPoint 实体。您会注意到一个名为 points 的新字段,我将在我们开始编写代码时解释:

  1. 首先,在 repo 文件夹内创建一个名为 ThreadPointRepo.ts 的文件,并将以下代码添加到其中(再次假设您知道如何添加必要的导入):
export const updateThreadPoint = async (
  userId: string,
  threadId: string,
  increment: boolean
): Promise<string> => {

请注意参数中有一个 increment 布尔值。这决定了我们是要添加还是删除一个点。

  // todo: first check user is authenticated

一旦我们创建了我们的身份验证调用,我们将重新访问这个注释,并用代码填充它。请注意,添加一个 todo 注释是跟踪剩余待完成项目的好方法。它还通知团队成员这一事实。

  let message = "Failed to increment thread point";
  const thread = await Thread.findOne({
    where: { id: threadId },
    relations: ["user"],
  });
  if (thread!.user!.id === userId) {
    message = "Error: users cannot increment their own      thread";
    return message;
}

因此,我们首先获取给定 threadIdThread。请注意,我们还检查了给定的 User 是否不是拥有该线程的相同 User。如果您的数据库中只有一个 User,您需要添加另一个 User,以便拥有 Thread 的所有者不是尝试增加其点数的相同人。您可以通过使用 SQL 插入查询或重用我们在第十四章中的注册路由来添加用户,使用 TypeORM 设置 Postgres 和存储库层

  const user = await User.findOne({ where: { id: userId } });

在这里,我们在实际需要使用它们之前稍微获取了匹配的 User。我们稍后会看到为什么我们正在做一些看起来可能效率低下的事情。

  const existingPoint = await ThreadPoint.findOne({
    where: {
      thread: { id: threadId },
      user: { id: userId },
    },
    relations: ["thread"],
});

在这里,我们正在查看现有的点实体是否已经存在。我们将使用这个对象来决定如何稍后添加或删除点:

await getManager().transaction(async (transactionEntityManager) => {

正如您所看到的,我们有一些新的 TypeORM 代码。getManager().transaction 调用正在创建一个 SQL 事务。事务是一种将多个 SQL 操作作为单个原子操作执行的方式。换句话说,要么每个操作都将成功完成,要么全部失败。因此,此范围内运行的所有内容都是事务的一部分。

另外,我们之前注意到我们提前创建了一个 User 实体。这是因为最佳实践是避免在事务内进行选择查询。这不是一个硬性规定。但一般来说,在事务内进行选择查询会使事情变慢。

    if (existingPoint) {
      if (increment) {
        if (existingPoint.isDecrement) {
          await ThreadPoint.remove(existingPoint);
          thread!.points = Number(thread!.points) + 1;
          thread!.lastModifiedOn = new Date();
          thread!.save();
        }
      } else {
        if (!existingPoint.isDecrement) {
          await ThreadPoint.remove(existingPoint);
          thread!.points = Number(thread!.points) - 1;
          thread!.lastModifiedOn = new Date();
          thread!.save();
        }
      }

在本节中,我们通过检查 existingPoint(记住 ThreadPoint 可以表示正点或负点,如 isDecrement 字段所示)来检查 ThreadPoint 是否已经存在。一旦确定了这一点,我们决定是在进行增加还是减少。如果进行增加并且存在减少的 ThreadPoint,我们将删除该实体并且不做其他操作。如果我们正在进行减少并且存在增加的 ThreadPoint,我们将删除该实体并且不做其他操作。

现在,另一件需要注意的事情是我们的 Thread 实体现在有一个名为 points 的字段,我们根据需要进行增加或减少。这个字段将作为我们的 UI 中的一种快捷方式,它将允许我们获取当前 Thread 的点总数,而无需对该 Thread 的所有 ThreadPoints 进行求和:

    } else {
      await ThreadPoint.create({
        thread,
        isDecrement: !increment,
        user,
      }).save();
      if (increment) {
        thread!.points = Number(thread!.points) + 1;
      } else {
        thread!.points = Number(thread!.points) - 1;
      }
      thread!.lastModifiedOn = new Date();
      thread!.save();
    }

否则,如果根本没有现有的点,我们只需创建一个新的点,无论是增加还是减少:

    message = `Successfully ${
      increment ? "incremented" : "decremented"
    } point.`;
  });
  return message;
};
  1. 现在像这样向 typeDefs 添加 Mutation
updateThreadPoint(userId: ID!, threadId: ID!, increment: Boolean!): String!
  1. 然后,通过将 updateThreadPoint 调用添加到 Mutation 部分来更新解析器。由于这只是对执行实际工作的存储库调用的包装器,我不会在这里显示代码。尝试看看是否可以在不查看代码的情况下创建 Mutation

注意

我们将使用的大多数解析器只是我们的存储库调用的包装器。这使我们的解析器代码与我们的数据库和存储库调用分开。因此,大多数时候,我不会显示解析器代码,因为它很少并且在源代码中可用。

  1. 运行如上所示的Mutation,然后检查您的数据库:

图 15.4 - 运行 updateThreadPoint

图 15.4 - 运行 updateThreadPoint

在这里,我们在 Postgres 数据库中的 mutation 结果,使用 pgAdmin:

图 15.5 - 运行 updateThreadPoint 数据库结果

图 15.5 - 运行 updateThreadPoint 数据库结果

因此,我们的记录已成功创建,如图所示。

现在让我们再多讨论一下我们拥有的积分系统以及它是如何工作的。喜欢积分系统可以允许正面和负面积分,就像我们的系统一样。然而,它还必须防止用户投票超过一次。为了做到这一点,我们需要将每个积分与给出它的用户以及他们放在其上的 Thread 或 ThreadItem 相关联。这就是为什么我们有 ThreadPoint 和 ThreadPointItem 实体。

在一个用户众多的流量大的网站上,随时添加或删除积分可能对服务器造成重大负载。但比这更糟糕的是,如果我们每次调用获取 Thread 或 ThreadItem 数据时都必须总结所有这些 ThreadPoints 或 ThreadItemPoints。这是不可行的。因此,对于第一个问题,我们必须将其视为“每个用户一票”的积分系统的一部分。然而,对于积分总和的问题,我们可以尝试几种不同的方法来提高性能。

最有效的方法是添加一个缓存系统,使用像 Redis 这样的辅助服务。然而,构建缓存系统并不是一件微不足道的事情,远远超出了本书的范围。我们可以争论说,在我们的网站刚刚起步之前,要取得辉煌的成功和数十亿美元,我们不会有那种流量。因此,作为一个开始,我们可以尝试一些更简单的东西。

因此,我们所做的是将积分字段添加到我们的 Thread 和 ThreadItem 实体中,并在进行添加或删除积分的调用时递增值。这不是最好的解决方案,但现在可以。随着时间的推移,可以构建出更复杂的缓存系统或其他机制。

ThreadItemPoint 的代码几乎是相同的。继续尝试看看是否可以自己构建ThreadItemPointRepo.ts文件。如果遇到困难,可以随时查看源代码。

在本节中,我们开始将我们的存储库调用与我们的 GraphQL 层集成。我们还完善了我们的 Thread 和 ThreadItem 积分系统。在下一节中,我们将继续通过集成我们的身份验证调用来构建我们的 GraphQL API。

将身份验证与 GraphQL 解析器集成

将身份验证集成到 GraphQL 中并不比添加任何其他功能有多大区别。在本节中,我们将学习如何做到这一点。

现在让我们集成我们与身份验证相关的调用。让我们从register调用开始:

  1. 您会记得我们已经在第十四章中创建了我们的register调用,使用 TypeORM 设置 Postgres 和存储库层。现在,让我们添加我们的typeDefsresolvers。首先,在Mutation部分的typeDefs文件中添加源代码中的register调用:

  2. 现在,在我们的解析器文件中,在Mutation部分,添加我们的 GitHub 源代码中的代码。

这只是我们存储库调用的一个包装器,所以没有太多需要解释的,但请注意我们没有返回User对象;我们只返回一个状态消息。这是因为我们希望减少泄露不必要信息的机会。在尝试运行之前,让我们启用 GraphQL playground 以接受 cookie,以便我们进行测试。我们需要启用 cookie,以便我们的会话状态可以被保存,这样我们的调用可以检查用户是否已经登录。

在播放器的右上角,点击齿轮图标。将request.credentials字段设置为include,然后保存并刷新屏幕。如果现在运行它,我们应该会看到这个:

图 15.6 - 注册

图 15.6 - 注册

  1. 让我们继续login函数。将登录源代码添加到您的typeDefs文件的Mutation部分。

  2. 现在添加源代码中的login解析器代码。我们的 Repository login调用正在检查用户是否存在,并确保密码匹配。然后 GraphQL 调用将user.id取出,并将其设置为 Session 对象ctx.req.session.userId,如果登录成功的话。还要注意的是,我们的解析器在成功时不返回user对象。我们稍后将创建一个新的函数来提供User信息。

  3. 现在让我们做logout函数。首先,按照源代码中所示,在Mutation部分内添加typeDefs条目。

  4. 现在从源代码中更新Mutation的解析器logout解析器代码。请注意,无论存储库logout调用返回什么响应,我们都会使用ctx.req.session?.destroydestroy session,并将ctx.req.session?.userId设置为undefined

  5. 现在我们需要添加一个新的调用和一个新的类型到我们的typeDefs中。按照源代码中的代码,在typeDefs文件的Query部分中添加me函数。接下来,在User类型下面,添加这个union

union UserResult = User | EntityResult

为什么我们需要这些?在我们调用registerlogin时,我们消除了返回的User对象,因为在这些调用之后可能会或可能不会使用User详细信息,我们不希望不必要地暴露User数据。然而,有时一旦User登录,我们可能希望查看他们的相关数据。例如,当他们访问他们的 UserProfile 屏幕时。因此,我们将使用这个me函数来处理。

  1. 现在让我们为me函数添加我们的UserRepo调用。将此函数添加到UserRepo中:
export const me = async (id: string): Promise<UserResult> => {
  const user = await User.findOne({
    where: { id },
    relations: ["threads", "threads.threadItems"],
});

首先,请注意我们找到的user对象包括属于用户的任何ThreadsThreadItems。我们将在我们的 UserProfile 屏幕中使用这些:

  if (!user) {
    return {
      messages: ["User not found."],
    };
  }
  if (!user.confirmed) {
    return {
      messages: ["User has not confirmed their       registration email yet."],
    };
  }
  return {
    user: user,
  };
};

函数的其余部分与登录函数非常相似。

  1. 现在让我们为UserResultme函数创建我们的resolvers。在const的解析器顶部,按照代码中所示添加 UserResult 解析器。这与其他 Result union解析器相同-这里没有新的内容需要解释。

  2. Query部分,按照源代码中的代码添加me函数的代码。

请注意,此解析器不接受任何参数,因为它从会话中获取userId。在第 193 行,它检查会话中是否有userId。如果没有,它会提前退出。如果会话中有userId,它将使用我们的UserRepo me函数来获取当前登录的user。其余部分基本上与返回实体的其他函数相同。

  1. 让我们尝试运行我们的me解析器。确保您已经登录过一次,并且已经按照 GraphQL playground 中Step 3的说明进行了操作。如果您按照所示运行me,您应该会得到相关的数据:

图 15.7 - 调用 me 解析器

图 15.7 - 调用 me 解析器

如您所见,我们再次使用内联片段,并且能够获取相关的 Threads 和 ThreadItems。

在本节中,我们将我们的存储库层身份验证调用与 GraphQL 联系起来,并测试它们的功能。在下一节中,我们将通过将我们几乎完成的后端与我们的前端联系起来,完成我们的应用程序。

为 Apollo GraphQL 查询创建 React 客户端端 Hooks

在本节中,我们将通过将我们的 React 客户端连接到我们的 GraphQL 后端来完成我们的应用程序。我们已经走了很长的路,我们快要到达目的地了。

为了将我们应用程序的两个部分联系起来,我们需要将 CORS 添加到我们的 Express 服务器中。CORS代表跨源资源共享。这意味着我们的服务器将被设置为允许与其自身域不同的客户端域。

在即使是相当复杂的大多数服务器配置中,托管客户端应用程序的服务器和提供 API 的服务器并不位于同一域上。通常,您会有某种代理,例如 NGINX,它将接受来自浏览器的调用。该代理将根据需要“重定向”调用。我们将在第十七章中更详细地解释反向代理的工作原理,将应用程序部署到 AWS

注意

代理是服务或某些服务的替身。当使用代理时,如果客户端调用服务,他们最终首先访问代理而不是直接访问服务。然后代理确定客户端的请求应该路由到哪里。因此,代理为公司提供了更好地控制其服务访问的能力。

启用 CORS 也是必要的,因为 React 应用程序在其自己的测试 Web 服务器上运行。在我们的情况下,它在端口3000上运行,而服务器在端口5000上运行。尽管它们都使用 localhost,但具有不同的端口实际上意味着不同的域。要更新 CORS,请执行以下操作:

  1. 首先,我们需要更新我们的.env文件,以便包含客户端开发服务器的路径:
CLIENT_URL=http://localhost:3000
  1. 打开index.ts并在const app = express();之后立即添加以下代码:
app.use(
    cors({
      credentials: true,
      origin: process.env.CLIENT_URL,
    })
);

credentials设置启用了标题 Access-Control-Allow-Credentials。这允许客户端 JavaScript 在成功提供凭据后从服务器接收响应。

  1. 还要更新 Apollo Server,以便禁用其自己的cors。在listen之前更新此行:
apolloServer.applyMiddleware({ app, cors, which is enabled by default so we want to disable it.

现在我们已经将 CORS 安装到我们的服务器上。现在让我们在自己的 VSCode 窗口中打开我们的 React 项目,并安装 GraphQL 以开始与我们的 GraphQL 服务器集成:

  1. 在自己的 VSCode 窗口中打开super-forum-client文件夹后,首先尝试运行它以确保它正常工作。如果您还没有这样做,请删除node_modules文件夹和package-lock.json文件,然后运行npm install一次。

  2. 现在让我们安装 Apollo GraphQL 客户端。打开终端到super-forum-client的根目录,并运行以下命令:

npm install @apollo/client graphql 
  1. 现在我们需要配置我们的客户端。打开index.ts并在ReactDOM.render之前添加以下代码:
const client = new ApolloClient({
  uri: 'http://localhost:5000/graphql',
  credentials: "include",
  cache: new InMemoryCache()
});

像往常一样,添加你的导入 - 这很容易理解。我们设置服务器的 URL,包括所需的任何凭据,并设置cache对象。请注意,这意味着 Apollo 会缓存我们所有的查询结果。

  1. 接下来更新ReactDOM.render,并让其包括ApolloProvider
ReactDOM.render(
  <Provider store={configureStore()}>
    <BrowserRouter>
    <ApolloProvider client={client}>
      <ErrorBoundary>{[<App key="App" />]}</       ErrorBoundary>
      </ApolloProvider>
    </BrowserRouter>
  </Provider>,
  document.getElementById("root")
);
  1. 现在让我们通过获取 ThreadCategories 来测试它是否正常工作。打开src/components/areas/LeftMenu.tsx文件并进行以下更新:
import React, { useEffect, useState } from "react";
import { useWindowDimensions } from "../../hooks/useWindowDimensions";
import "./LeftMenu.css";
import { gql, useQuery } from "@apollo/client";

我们已经从 Apollo 客户端导入了一些项目。gql允许我们为 GraphQL 查询获取语法高亮显示和格式化。UseQuery是我们的第一个与 GraphQL 相关的客户端 Hook。它允许我们执行 GraphQL 查询,而不是执行 Mutation,但它会立即运行。稍后,我将展示一个允许延迟加载的 Hook:

const GetAllCategories = gql`
  query getAllCategories {
    getAllCategories {
      id
      name
    }
  }
`;

这是我们的查询。这里没有什么需要解释的,但请注意我们获取了idname

const LeftMenu = () => {
const { loading, error, data } = useQuery(GetAllCategories);

我们的useQuery调用返回属性loadingerrordata。每个 Apollo GraphQL Hook 返回一组不同的相关属性。我们将看到这些特定属性如何在以下代码中使用:

  const { width } = useWindowDimensions();
  const [categories, setCategories] = useState<JSX.   Element>(
    <div>Left Menu</div>
  );
  useEffect(() => {
    if (loading) {
      setCategories(<span>Loading ...</span>);

在刚刚显示的代码中,我们首先检查数据是否仍在加载,方法是使用loading属性并在这种情况下提供占位文本。

    } else if (error) {
      setCategories(<span>Error occurred loading        categories ...</span>);

在此错误部分中,我们指示查询运行期间发生了错误。

    } else {
      if (data && data.getAllCategories) {
        const cats = data.getAllCategories.map((cat: any)         => {
          return <li key={cat.id}>
        <Link to={`/categorythreads/${cat.id}`}>{cat.         name}</Link>
     </li>;
        });
        setCategories(<ul className="category">{cats}        </ul>);
      }

最后,如果一切顺利,我们得到了我们的数据,然后我们显示一个无序列表,表示每个 ThreadCategory。请注意,每个li元素都有一个唯一的键标识符。在提供一组类似元素时,拥有键总是很重要的,因为它减少了不必要的渲染。此外,每个元素都是一个链接,向用户显示与特定ThreadCategory相关的所有 Threads:

    }
    // eslint-disable-next-line react-hooks/exhaustive-     //deps
  }, [data]);
  if (width <= 768) {
    return null;
  }
  return <div className="leftmenu">{categories}</div>;
};
export default LeftMenu;
  1. 在桌面模式下运行应用程序应该显示这个屏幕。请注意,我已经点击了一个具有关联 Thread 数据的 ThreadCategory 链接。但当然,我们目前仍在使用dataService返回硬编码数据:

图 15.8 – 左侧菜单线程类别列表

图 15.8 – 左侧菜单线程类别列表

太棒了 - 我们现在连接到了我们的 GraphQL 服务器!

主屏幕

恭喜 - 你已经走了很长的路。现在我们需要更新我们的 Main 组件,以便从我们的 GraphQL 服务返回真实的数据。让我们现在创建它:

  1. 转到我们的super-forum-server项目,打开typeDefs文件,并在源代码中的getThreadsByCategoryId查询下方添加函数getThreadsLatest的模式条目。在这里,我们正在创建一个新的解析器getThreadsLatest,当没有特定的 ThreadCategory 给出时,它会给我们最新的 Threads。当给出 ThreadCategory 时,我们已经有了getThreadsByCategoryId解析器。

  2. 打开ThreadRepo并添加这个函数:

export const getThreadsLatest = async (): Promise<QueryArrayResult<Thread>> => {
  const threads = await Thread.createQueryBuilder("thread")
    .leftJoinAndSelect("thread.category", "category")
    .leftJoinAndSelect("thread.threadItems",      "threadItems")
    .orderBy("thread.createdOn", "DESC")
    .take(10)
    .getMany();

我们有一个包括 ThreadCategories 和 ThreadItems 的查询 - leftJoinAndSelect,按createdOn字段排序,orderBy,并且只取最多 10 个项目(take):

  if (!threads || threads.length === 0) {
    return {
      messages: ["No threads found."],
    };
  }
  return {
    entities: threads,
  };
};

其余部分与getThreadsByCategoryId类似,不再赘述。

让我们也更新我们的getThreadsByCategoryId函数,包括 ThreadItems:

export const getThreadsByCategoryId = async (
  categoryId: string
): Promise<QueryArrayResult<Thread>> => {
  const threads = await Thread.   createQueryBuilder("thread")
    .where(`thread."categoryId" = :categoryId`, {       categoryId })
    .leftJoinAndSelect("thread.category", "category")
    .leftJoinAndSelect("thread.threadItems",       "threadItems")
    .orderBy("thread.createdOn", "DESC")
    .getMany();
  if (!threads || threads.length === 0) {
    return {
      messages: ["Threads of category not found."],
    };
  }
  return {
    entities: threads,
  };
};

它与以前一样,只是多了一个leftJoinAndSelect函数。

  1. 打开resolvers文件,并在 Query 部分的末尾添加源代码中的getThreadsLatest函数。这是一个几乎与getThreadsByCategoryId解析器相同的包装器,只是调用了getThreadsLatest

  2. 现在我们需要更新我们的MainReact 组件,使其使用我们的 GraphQL 解析器而不是来自dataService的假数据。打开Main并像这样更新文件。

const GetThreadsByCategoryId是我们的第一个查询。正如您所看到的,它使用内联片段并获取我们的 Thread 数据字段:

const GetThreadsByCategoryId = gql`
  query getThreadsByCategoryId($categoryId: ID!) {
    getThreadsByCategoryId(categoryId: $categoryId) {
      ... on EntityResult {
        messages
      }
      ... on ThreadArray {
        threads {
          id
          title
          body
          views
          threadItems {
            id
          }
          category {
            id
            name
          }
        }
      }
    }
  }
`;

GetThreadsLatest基本上与GetThreadsByCategoryId相同:

const GetThreadsLatest = gql`
  query getThreadsLatest {
    getThreadsLatest {
      ... on EntityResult {
        messages
      }
      ... on ThreadArray {
        threads {
          id
          title
          body
          views
          threadItems {
            id
          }
          category {
            id
            name
          }
        }
      }
    }
  }
`;

现在我们开始使用useLazyQuery Hooks 定义我们的Main组件:

const Main = () => {
  const [
    execGetThreadsByCat,
    {
      //error: threadsByCatErr,
      //called: threadsByCatCalled,
      data: threadsByCatData,
    },
  ] = useLazyQuery(GetThreadsByCategoryId);
  const [
    execGetThreadsLatest,
    {
      //error: threadsLatestErr,
      //called: threadsLatestCalled,
      data: threadsLatestData,
    },
] = useLazyQuery(GetThreadsLatest);

现在显示的两个 Hooks 正在使用我们的查询。请注意,这些是延迟的 GraphQL 查询。这意味着它们不会立即运行,不像useQuery,只有在进行execGetThreadsByCatexecGetThreadsLatest调用时才会运行。data属性包含我们查询的返回数据。此外,我已经注释掉了两个返回的属性,因为我们没有使用它们。但是,如果您的调用遇到错误,它们是可用的。Error包含有关失败的信息,called指示 Hook 是否已经被调用。

  const { categoryId } = useParams();
  const [category, setCategory] = useState<Category |   undefined>();
  const [threadCards, setThreadCards] =   useState<Array<JSX.Element> | null>(
    null
  );

先前的状态对象保持不变。

  useEffect(() => {
    if (categoryId && categoryId > 0) {
      execGetThreadsByCat({
        variables: {
          categoryId,
        },
      });
    } else {
      execGetThreadsLatest();
    }
    // eslint-disable-next-line react-hooks/exhaustive-    // deps
  }, [categoryId]);

这个useEffect现在更新为只在需要时执行execGetThreadsByCatexecGetThreadsLatest。如果给定了categoryId参数,应该运行execGetThreadsByCat;如果没有,应该运行另一个:

  useEffect(() => {
    if (
      threadsByCatData &&
      threadsByCatData.getThreadsByCategoryId &&
      threadsByCatData.getThreadsByCategoryId.threads
    ) {
      const threads = threadsByCatData.      getThreadsByCategoryId.threads;
      const cards = threads.map((th: any) => {
        return <ThreadCard key={`thread-${th.id}`}         thread={th} />;
      });
      setCategory(threads[0].category);
      setThreadCards(cards);
    }
}, [threadsByCatData]);

useEffect中,threadsByCatData的变化导致我们使用getThreadsByCategoryId查询的数据更新categorythreadCards

  useEffect(() => {
    if (
      threadsLatestData &&
      threadsLatestData.getThreadsLatest &&
      threadsLatestData.getThreadsLatest.threads
    ) {
      const threads = threadsLatestData.getThreadsLatest.      threads;
      const cards = threads.map((th: any) => {
        return <ThreadCard key={`thread-${th.id}`}         thread={th} />;
      });
      setCategory(new Category("0", "Latest"));
      setThreadCards(cards);
    }
  }, [threadsLatestData]);

useEffect中,threadsLatestData的变化导致我们使用getThreadsLatest查询的数据更新categorythreadCards。请注意,当没有给出categoryId时,我们只是使用一个通用的“最新”名称作为我们的 ThreadCategory。

  return (
    <main className="content">
      <MainHeader category={category} />
      <div>{threadCards}</div>
    </main>
  );
};
export default Main;

其余代码与以前相同。

  1. 现在,如果我们为categoryId运行这个,我们应该会看到这个:

图 15.9 – 有 categoryId

图 15.9 – 有 categoryId

如果我们在没有categoryId的情况下运行这个,我们应该会看到这个:

图 15.10 – 没有 categoryId

图 15.10 – 没有 categoryId

好了,现在我们的网站屏幕上有一些实际的真实数据了。在继续之前,让我们稍微清理一下我们的样式,并去掉一些占位背景颜色。我对Nav.cssHome.css文件进行了微小的更改。现在是这个样子的:

图 15.11 - 主屏幕样式更新

图 15.11 - 主屏幕样式更新

好了,这样好多了。在我们屏幕的移动版本上有一件事要注意 - 我们没有办法让用户切换到另一个类别,如下图所示:

图 15.12 - 主屏幕移动视图

图 15.12 - 主屏幕移动视图

因此,让我们添加一个下拉菜单,允许用户切换类别。这个下拉菜单应该只在移动模式下出现。在跟随之前尝试构建这个控件。提示:使用 React-DropDown 构建下拉菜单,并用下拉控件替换类别标签。例如,在图 15.12中,我们看到所选的类别是MainHeader控件。因此,只在移动模式下用下拉菜单替换该标签。请注意,我们已经在我们的 ThreadCategory 路由中使用了下拉菜单,因此我们应该将其创建为一个组件,以便它可以被重用。

如果你已经尝试过了,现在让我们一起开始构建,这样你就可以进行比较。这里有一点我说了谎。这是一个相当复杂的改变,因为它需要两个主要的事情。首先,我们希望为 ThreadCategories 添加一个新的 Reducer,因为我们知道 ThreadCategories 的列表至少在两个独立的组件中被使用。我们还需要将 ThreadCategory 组件中的下拉菜单组件化,以便它可以在多个地方使用。第二个部分相当复杂,因为新的下拉组件必须足够复杂,以便从外部接收 props,并在每次更改时发送所选的类别:

  1. 首先,让我们创建我们的新 Reducer。在store文件夹中创建一个名为categories的新文件夹。在该文件夹中,创建一个名为Reducer.ts的文件,并将源代码添加到其中。这个文件很像我们的User Reducer,只是它返回一个Category对象数组作为有效负载。

  2. 接下来,我们需要将新的 Reducer 添加到我们的AppStaterootReducer中,就像这样:

export const rootReducer = combineReducers({
  user: UserProfileReducer,
  categories: ThreadCategoriesReducer,
});

我们的新rootReducer成员将被称为Categories

  1. 现在更新App.tsx组件,以便在应用程序加载时,我们立即获取我们的 ThreadCategories 并将它们添加到 Redux 存储中。

在这里,我们添加了GetAllCategories GraphQL 查询:

const GetAllCategories = gql`
  query getAllCategories {
    getAllCategories {
      id
      name
    }
  }
`;
function App() {
  const { data } = useQuery(GetAllCategories);
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch({
      type: UserProfileSetType,
      payload: {
        id: 1,
        userName: "testUser",
      },
    });
    if (data && data.getAllCategories) {
      dispatch({
        type: ThreadCategoriesType,
        payload: data.getAllCategories,
      });

我们之前看到的大部分代码都是一样的,但这是我们将 ThreadCategories 的有效负载发送到 Redux 存储的地方:

    }
  }, [dispatch, data]);
  const renderHome = (props: any) => <Home {...props} />;
  const renderThread = (props: any) => <Thread {...props}    />;
  const renderUserProfile = (props: any) => <UserProfile    {...props} />;
  return (
    <Switch>
      <Route exact={true} path="/" render={renderHome} />
      <Route path="/categorythreads/:categoryId"       render={renderHome} />
      <Route path="/thread/:id" render={renderThread} />
      <Route path="/userprofile/:id"       render={renderUserProfile} />
    </Switch>
  );
}

其他一切都保持不变。请注意,您需要更新您的导入。

  1. LeftMenuThreadCategory组件将需要删除它们获取 ThreadCategories 和创建下拉菜单的代码。但首先,让我们创建我们的共享控件来完成所有这些。在src/components文件夹中创建一个名为CategoryDropDown.tsx的文件,并添加这段代码。确保您添加任何必要的导入:
const defaultLabel = "Select a category";
const defaultOption = {
  value: "0",
  label: defaultLabel
};

通过defaultOption,我们为我们的下拉菜单创建了一个初始值。

class CategoryDropDownProps {
  sendOutSelectedCategory?: (cat: Category) => void;
  navigate?: boolean = false;
  preselectedCategory?: Category;
}

CategoryDropDownProps将是我们的CategoryDropDown组件的参数类型。sendOutSelectedCategory是由父调用者传递的函数,将用于接收父级选择的下拉选项。Navigate是一个布尔值,确定在选择新的下拉选项时屏幕是否会移动到新的 URL。preselectedCategory允许父级在加载时强制下拉菜单选择指定的 ThreadCategory:

const CategoryDropDown: FC<CategoryDropDownProps> = ({
  sendOutSelectedCategory,
  navigate,
  preselectedCategory,
}) => {
  const categories = useSelector((state: AppState) =>   state.categories);
  const [categoryOptions, setCategoryOptions] = useState<
    Array<string | Option>
  >([defaultOption]);
  const [selectedOption, setSelectedOption] =   useState<Option>(defaultOption);
  const history = useHistory();

根据我们之前的学习,这些列出的 Hooks 的使用是非常明显的。但请注意,我们正在使用useSelector从 Redux 存储中获取 ThreadCategories 的列表。

  useEffect(() => {
    if (categories) {
      const catOptions: Array<Option> = categories.      map((cat: Category) => {
        return {
          value: cat.id,
          label: cat.name,
        };
      });

在这里,我们构建了一个选项数组,以供稍后给我们的下拉菜单。

      setCategoryOptions(catOptions);

setCategoryOptions中,我们正在接收我们的 ThreadCategory 选项元素列表并设置它们,以便稍后可以被我们的下拉菜单使用。

      setSelectedOption({
        value: preselectedCategory ? preselectedCategory.        id : "0",
        label: preselectedCategory ? preselectedCategory.        name : defaultLabel,
      });

在这里,我们已经设置了我们默认的下拉选择。

    }
  }, [categories, preselectedCategory]);
  const onChangeDropDown = (selected: Option) => {
    setSelectedOption(selected);
    if (sendOutSelectedCategory) {
      sendOutSelectedCategory(
        new Category(selected.value, selected.label?.valueOf().toString() ?? "")
      );
    }

在这里的下拉更改处理程序中,我们正在通知父级选择发生了变化。

    if (navigate) {
      history.push(`/categorythreads/${selected.value}`);
    }

如果父级请求,我们将导航到下一个 ThreadCategory 路由。

  };
  return (
    <DropDown
      className="thread-category-dropdown"
      options={categoryOptions}
      onChange={onChangeDropDown}
      value={selectedOption}
      placeholder=defaultLabel
    />
  );
};
export default CategoryDropDown;

最后,这是我们实际的 JSX,它非常容易理解。

  1. 现在我们需要像这样更新MainHeader.tsx文件:
interface MainHeaderProps {
  category?: Category;
}
const MainHeader: FC<MainHeaderProps> = ({ category }) => {
  const { width } = useWindowDimensions();

唯一重要的更改是getLabelElement函数,它决定屏幕是否为移动设备,并在是的情况下呈现CategoryDropDown

  const getLabelElement = () => {
    if (width <= 768) {
      return (
        <CategoryDropDown navigate={true}         preselectedCategory={category} />
      );
    } else {
      return <strong>{category?.name || "Placeholder"}      </strong>;
    }
  };
  return (
    <div className="main-header">
      <div
        className="title-bar"
        style={{ marginBottom: ".25em", paddingBottom:         "0" }}
      >
        {getLabelElement function.

);

};


其余的代码大部分是删除的代码,所以请尝试自己做。当然,如果需要,可以查看源代码。受影响的文件是ThreadCategory.tsxLeftMenu.tsxThread.css

与身份验证相关的功能

现在让我们继续更新与身份验证相关的功能。请记住,所有您的“用户”帐户在能够登录之前必须将其confirmed字段设置为 true:

  1. 我们首先要做的是让用户能够登录。为了做到这一点,然后能够更新我们在全局 Redux 存储中的User对象,我们将重构我们的 Redux 用户 Reducer。

首先,在models文件夹中,创建一个名为User.ts的新文件并将源代码添加到其中。请注意,我们的User类有一个名为 threads 的字段。这将包含不仅是用户拥有的 Threads,还有这些 Threads 的 ThreadItems。

  1. 现在让我们更新我们的 Reducer。打开store/user/Reducer.ts并通过删除UserProfilePayload接口并用我们刚刚创建的新User类替换其引用来更新它。如果需要,查看源代码。

  2. 现在我们可以像这样更新我们的Login组件。根据需要更新导入。

请注意,我们已经导入了 HookuseRefreshReduxMe。我们将在一会儿定义这个 Hook,但首先我想介绍一些useMutation GraphQL Hook 的特性:

const LoginMutation = gql`
  mutation Login($userName: String!, $password: String!)  {
    login(userName: $userName, password: $password)
  }
`;

这是我们的登录Mutation

const Login: FC<ModalProps> = ({ isOpen, onClickToggle }) => {
  const [execLogin] = useMutation(LoginMutation, {
    refetchQueries: [
      {
        query: Me,
      },
    ],
  });

让我解释一下这个useMutation调用。调用以 Mutation 查询LoginMutation和称为refetchQueries的东西作为参数。refetchQueries强制其中列出的任何查询重新运行,然后缓存它们的值。如果我们不使用refetchQueries并再次运行Me查询,我们最终会得到最后缓存的版本而不是最新的数据。请注意,它不会自动刷新依赖于其查询的任何调用;我们仍然必须进行这些调用以获取新数据。

输出execLogin是一个可以随后执行的函数。

const [
    { userName, password, resultMsg, isSubmitDisabled },
    dispatch,
  ] = useReducer(userReducer, {
    userName: "test1",
    password: "Test123!@#",
    resultMsg: "",
    isSubmitDisabled: false,
  });
  const { execMe, updateMe } = useRefreshReduxMe();
  const onChangeUserName = (e: React.   ChangeEvent<HTMLInputElement>) => {
    dispatch({ type: "userName", payload: e.target.value     });
    if (!e.target.value)
      allowSubmit(dispatch, "Username cannot be empty",       true);
    else allowSubmit(dispatch, "", false);
  };
  const onChangePassword = (e: React.  ChangeEvent<HTMLInputElement>) => {
    dispatch({ type: "password", payload: e.target.value     });
    if (!e.target.value)
      allowSubmit(dispatch, "Password cannot be empty",       true);
    else allowSubmit(dispatch, "", false);
  };

之前的调用与以前一样。

const onClickLogin = async (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    e.preventDefault();
    onClickToggle(e);
    const result = await execLogin({
      variables: {
        userName,
        password,
      },
    });
    execMe();
    updateMe();
  };

onClickLogin处理程序现在正在使用适当的参数调用我们的execLogin函数。在execLogin完成后,它将自动调用我们的refetchQueries查询列表。之后,我们调用来自我们的 Hook 的函数,useRefreshReduxMeexecMeupdateMeexecMe函数将获取最新的User对象,updateMe将将其添加到 Redux 存储中。其余的代码是相同的,所以我不会在这里展示它。

  1. 现在让我们定义我们的 HookuseRefreshReduxMe。我们想要创建这个 Hook,以便我们的设置或取消 ReduxUser对象的代码可以在这个单个文件中。我们将从几个组件中使用这个 Hook。在 hooks 文件夹中创建一个名为useRefreshReduxMe.ts的文件并添加源代码。

从顶部,我们可以看到Me const是用于获取用户信息的查询。EntityResult内联片段用于获取消息的字符串(如果返回的是消息)。如果我们获取实际的用户数据,那么所需的字段由User内联片段定义。

接下来,UseRefreshReduxMeResult接口是我们 Hook 的返回类型。

在第 37 行,我们已经定义了useLazyQuery,以允许我们的 Hook 用户在自己选择的时间执行对Me查询的调用。

接下来,我们定义了一个函数deleteMe,允许我们的 Hook 的用户随时销毁 ReduxUser对象。例如,当用户注销时。

最后,我们有updateMe函数,允许设置 ReduxUser对象。然后我们返回所有这些函数,以便它们可以被我们的 Hook 调用者使用。

  1. 在应用加载时,我们应立即检查我们的User是否已登录以及是谁。因此,打开App.tsx并像这样更新它:
function App() {
  const { data: categoriesData } =   useQuery(GetAllCategories);
  const { execMe, updateMe } = useRefreshReduxMe();

在这里,我们初始化了我们的useRefreshReduxMe Hook。

  const dispatch = useDispatch();
  useEffect(() => {
    execMe();
  }, [execMe]);

在这里,我们调用我们的execMe来从 GraphQL 获取User数据。

  useEffect(() => {
    updateMe();
  }, [updateMe]);

在这里,我们调用updateMe来使用User数据更新我们的 Redux 用户 Reducer。

  useEffect(() => {
    if (categoriesData && categoriesData.    getAllCategories) {
      dispatch({
        type: ThreadCategoriesType,
        payload: categoriesData.getAllCategories,
      });
    }
  }, [dispatch, categoriesData]);

我将我们原始的数据字段名称更改为categoriesData,这样它就更清楚它的用途了。其余的代码保持不变。

  1. 如果您现在登录,您会看到我们的SideBar userName更新为已登录用户:

图 15.13 - 已登录用户

图 15.13 - 已登录用户

所以,现在我们可以登录,然后显示userName

很棒,但现在让我们修复我们的SideBar,以便在适当的时间只显示适当的链接。例如,如果用户已登录,我们不应该看到登录注册链接:

  1. 为了确保用户登录或注销时显示正确的菜单,让我们首先更新我们的Logout组件。确保导入已经更新:
const LogoutMutation = gql`
  mutation logout($userName: String!) {
    logout(userName: $userName)
  }
`;

这是我们的logout mutation。

const Logout: FC<ModalProps> = ({ isOpen, onClickToggle }) => {
  const user = useSelector((state: AppState) => state.  user);
  const [execLogout] = useMutation(LogoutMutation, {
    refetchQueries: [
      {
        query: Me,
      },
    ],
  });

在这里,我们再次强制刷新我们的 GraphQL 缓存,以获取Me查询的数据。

  const { execMe, deleteMe } = useRefreshReduxMe();
  const onClickLogin = async (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>
  ) => {
    e.preventDefault();
    onClickToggle(e);
    await execLogout({
      variables: {
        userName: user?.userName ?? "",
      },
    });    
    deleteMe();
  };

我们再次使用了我们的useRefreshReduxMe Hook,但这里我们只调用了deleteMe函数,因为我们只是在注销。其余的代码保持不变,所以我不会在这里展示。

  1. 现在我们要更新SideBarMenus组件,以便在适当的时间只显示适当的菜单。打开该文件并按照以下方式更新它。

在这种情况下,我只会显示返回的 JSX,因为除了导入之外,这是唯一改变的事情:

return (
    <React.Fragment>
      <ul>
        {user ? (
          <li>
            <FontAwesomeIcon icon={faUser} />
            <span className="menu-name">
              <Link to={`/userprofile/${user?.               id}`}>{user?.userName}</Link>
            </span>
          </li>
        ) : null}

正如您所看到的,我们正在测试user对象是否有值,然后显示相同的userName UI,否则我们什么都不显示。

        {user ? null : (
          <li>
            <FontAwesomeIcon icon={faRegistered} />
            <span onClick={onClickToggleRegister}              className="menu-name">
              register
            </span>
            <Registration
              isOpen={showRegister}
              onClickToggle={onClickToggleRegister}
            />
          </li>
        )}

在这种情况下,如果用户存在,我们不想显示我们的注册 UI,这就是我们正在做的。

        {user ? null : (
          <li>
            <FontAwesomeIcon icon={faSignInAlt} />
            <span onClick={onClickToggleLogin}             className="menu-name">
              login
            </span>
            <Login isOpen={showLogin}              onClickToggle={onClickToggleLogin} />
          </li>
        )}

同样,如果user对象已经存在,我们不会显示登录,因为这表示用户已经登录。

        {user ? (
          <li>
            <FontAwesomeIcon icon={faSignOutAlt} />
            <span onClick={onClickToggleLogout}              className="menu-name">
              logout
            </span>
            <Logout isOpen={showLogout}              onClickToggle={onClickToggleLogout} />
          </li>
        ) : null}

在这里,如果user对象有值,我们会显示注销 UI。

      </ul>
    </React.Fragment>
  );
  1. 如果您现在运行此代码,当尚未登录时,您会看到这个:

图 15.14 - 未登录的 SideBarMenus

图 15.14 - 未登录的 SideBarMenus

现在,当登录时,我们应该看到这个:

图 15.15 - 已登录的 SideBarMenus

图 15.15 - 已登录的 SideBarMenus

所以我们的侧边栏现在显示正确的链接和文本。现在让我们来处理我们的用户资料屏幕。

用户资料屏幕

现在,既然我们在认证部分,让我们完成我们的用户资料屏幕。我们需要进行多个更改来配置这个屏幕:

  1. 首先,让我们通过向我们的User类型添加一个字段来更新我们的 GraphQL。通过在typeDefs文件的User类型下面添加这个字段来更新User类型:
  threadItems. Note that this is different from threadItems that's part of the threads field, as we are trying to retrieve the ThreadItem entities the user themselves has authored.
  1. 我们还需要通过添加一个新字段来更新我们的 User Entity。通过在User.ts文件中添加这个字段来更新User Entity:
  @OneToMany(() => ThreadItem, (threadItem) =>   threadItem.user)
  threadItems: ThreadItem[];

这允许我们的User实体在 ThreadItems 实体上有关联的实体。还要确保您已经在ThreadItem.ts文件中有匹配的字段,像这样:

@ManyToOne(() => User, (user) => user.threadItems)
  user: User;
  1. 现在让我们打开我们的 UserRepo Repository 文件,并更新我们的me函数,以便它包括用户的 ThreadItems。像这样更新 UserRepo User.findOne函数:
    relations: ["threads", "threads.threadItems",    threadItems and threadItems.thread relations.
  1. 您会注意到用户资料屏幕具有更改密码功能。所以现在让我们构建出来。首先,我们需要在我们的typeDefs文件中添加一个新的 Mutation。将此 Mutation 添加到 Mutation 部分:
changePassword(newPassword: String!): String!

一个相当自解释的 Mutation 定义。

  1. 现在让我们在我们的 UserRepo 中实现这个函数。在源代码中的 UserRepo 末尾添加changePassword函数。

从第 125 行开始,因为如果进行了这个调用,用户将会被登录,我们期望从解析器代码中传递用户 id。如果不存在,那么当然我们会出错。

然后我们尝试获取 User 对象,然后运行一些检查以确保用户是有效的。最后,我们使用 bcrypt 生成我们的哈希密码。

  1. 现在我们可以创建我们的解析器。打开 resolvers 文件,并将 changePassword 函数的源代码添加到 Mutation 部分。

首先,在第 389 行,我们检查一个有效的 Session 和在该 Session 中存在的 userId,因为这是指示用户已登录的标志。

最后,我们使用 Session userId 和给定的新密码调用我们的 changePassword 仓库函数。

  1. 现在让我们更新我们的 UserProfile 组件。更新代码如下:

更新导入,因为我们导入了一些新项目,gqluseMutation

const ChangePassword = gql`
  mutation ChangePassword($newPassword: String!) {
    changePassword(newPassword: $newPassword)
  }
`;

这里,我们有我们的新 Mutation,ChangePassword

const UserProfile = () => {
  const [
    { userName, password, passwordConfirm, resultMsg,    isSubmitDisabled },
    dispatch,
  ] = useReducer(userReducer, {
    userName: "",
    password: "*********",
    passwordConfirm: "*********",
    resultMsg: "",
    isSubmitDisabled: true,
  });
  const user = useSelector((state: AppState) => state.   user);
  const [threads, setThreads] = useState<JSX.Element |    undefined>();
  const [threadItems, setThreadItems] = useState<JSX.   Element | undefined>();
  const [execChangePassword] =    useMutation(ChangePassword Mutation with useMutation.The `useEffect` code shown here is the same as before:

useEffect(() ⇒ {

if (user) {

dispatch({

type: "userName",

payload: user.userName,

});

getUserThreads(user.id).then((items) ⇒ {

const threadItemsInThreadList: Array = [];

const threadList = items.map((th: Thread) ⇒ {

for (let i = 0; i < th.threadItems.length; i++) {

threadItemsInThreadList.push(th. threadItems[i]);

}

return (

  • );

    });

    setThreads(

      );

      const threadItemList = threadItemsInThreadList. map((ti: ThreadItem) ⇒ (

    • ));

      setThreadItems(

        );

        });

        }

        }, [user]);

        
        This `onClickChangePassword` function is new. It triggers the `changePassword` call and then updates the UI status message.
        
        

        const onClickChangePassword = async (

        e: React.MouseEvent<HTMLButtonElement, MouseEvent>

        ) ⇒ {

        e.preventDefault();

        const = await execChangePassword({

        variables: {

        newPassword: password,

        },

        });

        dispatch({

        type: "resultMsg",

        payload: changePasswordData ? changePasswordData. changePassword : "",

        });

        };

        return (

        用户资料

        <label style={{ marginLeft: ".75em" }}>

        <PasswordComparison

        dispatch=

        password=

        passwordConfirm=

        />

        <button

        className="action-btn"

        disabled=

        onClick=

        
        The `onClickChangePassword` handler is set here onto our Change Password button.
        
        

        修改密码


        发布的主题

        发布的主题项

        );

        };

        export default UserProfile;

        
        The remaining code is the same.
        

        现在让我们展示用户的主题和主题项:

        1. 首先,我们需要更新我们的用户模型。在 User.ts 文件中添加这个字段:
        public threadItems: Array<ThreadItem>
        
        1. 现在像这样更新 useRefreshReduxMe Hook 中的 Me 查询:
        export const Me = gql`
          query me {
            me {
              ... on EntityResult {
                messages
              }
              ... on User {
                id
                userName
                threads {
                  id
                  title
                }
                threadItems from getting the threads' threadItems to getting the user's threadItems. We also now get the threadItems' thread.
        
        1. 现在,在你的 UserProfile 组件中,像这样更新 useEffect
        useEffect(() => {
            if (user) {
              dispatch({
                type: "userName",
                payload: user.userName,
              });
        

        我们现在从 user.threads 数组中获取我们的主题,而不是我们的虚假 dataService 调用,如下所示:

              const threadList = user.threads?.map((th: Thread)      => {
                return (
                  <li key={`user-th-${th.id}`}>
                    <Link to={`/thread/${th.id}`}             className="userprofile-link">
                      {th.title}
                    </Link>
                  </li>
                );
              });
              setThreads(
                !user.threadItems || user.threadItems.length ===          0 ? undefined : (
                  <ul>{threadList}</ul>
                )
              );
        

        我们也对 threadItems 做同样的事情。注意我们的 Link to 被更新了,所以它使用 ti.thread?.id 而不是 ti.threadId

              const threadItemList = user.threadItems?.map((ti:        ThreadItem) => (
                <li key={`user-ti-${ti.id}`}>
                  <Link to={`/thread/${ti.thread?.id}`}            className="userprofile-link">
                    {ti.body.length <= 40 ? ti.body : ti.body.             substring(0, 40) + " ..."}
        

        在这里,我们添加了一点额外的逻辑来格式化可能会横向超出屏幕并换行的长文本。基本上,这意味着如果文本超过 40 个字符,我们会在文本后面添加 "…"

                  </Link>
                </li>
              ));
              setThreadItems(
                !user.threadItems || user.threadItems.length ===          0 ? undefined : (
                  <ul>{threadItemList}</ul>
                )
              );
            } else {
              dispatch({
                type: "userName",
                payload: "",
              });
              setThreads(undefined);
              setThreadItems(undefined);
            }
          }, [user]);
        

        剩下的代码是相同的。如果你运行这个,你应该会看到类似以下的东西(再次说明,你的数据将会不同):

        图 15.16 – 用户的主题和主题项

        图 15.16-用户的 Threads 和 ThreadItems

        好的,这就是我们的 UserProfile。因为这是一大堆要涵盖的材料,让我们在下一章继续我们的工作,[第十六章],添加 GraphQL 模式-第二部分

        总结

        在本章中,我们通过将前端和后端与 GraphQL 集成,几乎完成了我们的应用。这是一个庞大而复杂的章节,所以你应该为自己已经走过的路感到自豪。

        在下一章,[第十六章],添加 GraphQL 模式-第二部分,我们将通过在 Thread 屏幕上工作来完成我们应用的编码,这样我们就可以查看和发布 Threads,并且通过 Points 系统来查看用户对单个 Threads 的受欢迎程度。

        第十六章:添加 GraphQL 模式-第 II 部分

        在本章中,我们将继续完成我们的客户端和服务器代码。我们将完成我们的 Thread 屏幕,允许我们发布新的 Threads 和它们的回复,并完成网站的积分系统。请使用第十五章**,添加 GraphQL 模式-第 I 部分中的源代码来完成此操作。

        Thread 路由

        在本节中,我们将更新我们的Thread组件,该组件提供我们的线程路由。在进行此操作时,我们将涉及许多文件。请按照以下步骤进行操作:

        1. 打开typeDefs并编辑ThreadThreadItem类型。然后,在views下方添加此字段:
        points: Int!
        
        1. 现在,打开ThreadRepo文件并更新getThreadById函数,就像这样:
        export const getThreadById = async (
          id: string
        ): Promise<QueryOneResult<Thread>> => {
          const thread = await Thread.findOne({
            where: {
              id,
            },
            relations: ["user", "threadItems", "threadItems.     user", "category"],
          });
        

        我们在这里所做的只是在我们的findOne查询中添加了以下relations

          if (!thread) {
            return {
              messages: ["Thread not found."],
            };
          }
          return {
            entity: thread,
          };
        };
        
        1. 接下来,更新getThreadsByCategoryId函数调用Thread.createQueryBuilder,就像这样:
        const threads = await Thread.createQueryBuilder("thread")
            .where(`thread."categoryId" = :categoryId`, {       categoryId })
            .leftJoinAndSelect("thread.category", "category")
            .leftJoinAndSelect("thread.threadItems",       "threadItems")
            .leftJoinAndSelect("thread.user", "user")
            .orderBy("thread.createdOn", "DESC")
            .getMany();
        

        我们在这里包含了 User 实体的关系。此函数的其余代码保持不变。

        1. 现在,打开您的客户端应用中的User.ts文件,并更新threadsthreadItems字段,使它们成为可选的。我们需要这样做,以便我们可以添加一个尚未发布任何内容的User账户:
        public threads?: Array<Thread>,
        public threadItems?: Array<ThreadItem>
        
        1. 现在,打开 React 客户端项目中的models/Thread.tsmodels/ThreadItem.ts文件,并用单个字段 user 替换userNameuserId字段,就像这样:
        public user: User,
        
        1. 我们还需要在我们的DataService.ts文件中用用户对象替换userNameuserId字段的引用。在这里,我在文件顶部放置了一个对象,并在整个文件中使用它来替换这两个字段:
        const user = new User("1", "test1@test.com", "test1");
        

        如果需要帮助,请查看DataService.ts文件,尽管这应该是相当琐碎的。

        1. 现在我们已经更新了我们的User模式类型和我们的实体,我们需要更新一些查询。在Main.tsx文件中,更新GetThreadsByCategoryIdGetThreadsLatest查询,就像这样:
        const GetThreadsByCategoryId = gql`
          query getThreadsByCategoryId($categoryId: ID!) {
            getThreadsByCategoryId(categoryId: $categoryId) {
              ... on EntityResult {
                messages
              }
              ... on ThreadArray {
                threads {
                  id
                  title
                  body
                  views
                  points and user fields, as follows:
        
        

        const GetThreadsLatest = gql`

        查询 getThreadsLatest {

        getThreadsLatest {

        ... on EntityResult {

        messages

        }

        ... on ThreadArray {

        threads {

        id

        title

        body

        views

        points

        user {

        userName

        }

        threadItems {

        id

        }

        category {

        id

        name

        }

        }

        }

        }

        }

        `;

        
        
        1. 现在,在我们的ThreadCard.tsx文件中,找到以下 JSX:
        <span className="username-header" style={{ marginLeft: ".5em" }}>
           {thread.userName}
        </span>
        

        用以下内容替换它:

        <span className="username-header" style={{ marginLeft: ".5em" }}>
           {thread.user to get its userName field instead of trying to access it directly.
        
        1. 现在,我们需要对我们的RichEditor.tsx文件进行一些更改。请注意,我们的 Thread 屏幕将显示用户提交的文本。因此,一旦用户提交了他们希望发布的内容,我们将使其无法在此后进行编辑。我们将通过将只读设置为一个属性来实现这一点。

        RichEditorProps接口转换为类并更新,就像这样:

        class RichEditorProps {
          existingBody?: string;
          false is the normal setting (interfaces don't allow default values). Now, update the parameter list in the RichEditor component, like this:
        
        

        const RichEditor: FC = ({ existingBody, readOnly field as a parameter. Now, inside the Editable component, add it as an attribute, like this:

        <Editable
                className="editor"
                renderElement={renderElement}
                renderLeaf={renderLeaf}
                placeholder="Enter some rich text…"
                spellCheck
                autoFocus
                onKeyDown={(event) => {
                  for (const hotkey in HOTKEYS) {
                    if (isHotkey(hotkey, event as any)) {
                      event.preventDefault();
                      const mark = HOTKEYS[hotkey];
                      toggleMark(editor, mark);
                    }
                  }
                }}
                readOnly prop.
        
        
        
        1. 现在,打开src/components/routes/thread/Thread.tsx文件。这个文件是我们加载 Thread 路由的主要屏幕。让我们更新这个文件。

        在这里,我们添加了一个新的GetThreadById查询来获取我们相关的 Thread:

        const GetThreadById = gql`
          query GetThreadById($id: ID!) {
            getThreadById(id: $id) {
              ... on EntityResult {
                messages
              }
              ... on Thread {
                id
                user {
                  userName
                }
                lastModifiedOn
                title
                body
                points
                category {
                  id
                  name
                }
                threadItems {
                  id
                  body
                  points
                  user {
                    userName
                  }
                }
              }
            }
          }
        `;
        const Thread = () => {
          const [execGetThreadById, { data: threadData }] =   useLazyQuery(GetThreadById);
        

        在这里,我们使用我们的GetThreadById查询,以及我们的useLazyQuery Hook,并创建了一个名为execGetThreadById的执行函数,稍后我们将运行它。

          const [thread, setThread] = useState<ThreadModel |    undefined>();
        

        thread状态对象是我们将用来填充我们的 UI 并与其他组件共享的对象。

          const { id } = useParams();
        

        id是代表 Thread 的id值的 URL 参数。

          const [readOnly, setReadOnly] = useState(false);
        

        如果我们处理的是现有的 Thread 记录,我们将使用这个readOnly状态使我们的RichEditor只读。

          useEffect(() => {
            if (id && id > 0) {
              console.log("id", id);
              execGetThreadById({
                variables: {
                  id,
                },
              });
        

        在这里,我们通过使用 URL 给出的参数运行了我们的execGetThreadById调用,以获取 Thread 的id

            }
          }, [id, execGetThreadById]);
          useEffect(() => {
            console.log("threadData", threadData);
            if (threadData && threadData.getThreadById) {
              setThread(threadData.getThreadById);
            } else {
              setThread(undefined);
            }
        

        一旦我们完成了execGetThreadById调用,就会返回一个threadData对象。我们可以使用这个对象来设置我们的本地thread状态。

          }, [threadData]);
          return (
            <div className="screen-root-container">
              <div className="thread-nav-container">
                <Nav />
              </div>
              <div className="thread-content-container">
                <div className="thread-content-post-container">
                  <ThreadHeader
                    userName={thread?.user.userName}
        

        在这里,我们使用thread?.user对象来获取我们的userName字段,而不是thread?.userName,这是我们之前设置的方式。

                    lastModifiedOn={thread ? thread.             lastModifiedOn : new Date()}
                    title={thread?.title}
                  />
                  <ThreadCategory category={thread?.category} />
        

        ThreadCategory现在已经更新,这样它将把CategoryDropDown设置为提供的Category选项。我们稍后会看一下这个。

                  <ThreadTitle title={thread?.title} />
                  <ThreadBody body={thread?.body}            readOnly={readOnly} />
        

        在这里,我们将readOnly状态值传递给了ThreadBody,因为ThreadBody在内部使用了RichEditor

                </div>
                <div className="thread-content-points-container">
                  <ThreadPointsBar
                    points={thread?.points || 0}
                    responseCount={
                      thread && thread.threadItems && thread.               threadItems.length
                    }
                  />
                </div>
              </div>
              <div className="thread-content-response-container">
                <hr className="thread-section-divider" />
                <ThreadResponsesBuilder threadItems={thread?.         threadItems} readOnly={readOnly} />
        

        在这里,我们将readOnly状态值传递给了ThreadResponsesBuilder,它显示了我们的 ThreadItem responses。

              </div>
            </div>
          );
        };
        

        其余的 UI 与以前一样。

        1. 现在,让我们看看ThreadCategory组件。现在它是这样的:
        interface ThreadCategoryProps {
          category?: Category;
        }
        

        我们已经切换了接口定义,使其接受Category对象而不是字符串。这使我们可以将其传递给我们的CategoryDropDown组件:

        const ThreadCategory: FC<ThreadCategoryProps> = ({ category }) => {
          const sendOutSelectedCategory = (cat: Category) => {
            console.log("selected category", cat);
          };
          return (
            <div className="thread-category-container">
              <strong>{category?.name}</strong>
        

        在这里,我们使用了Category对象的category?.name,而以前我们使用categoryName作为必要的参数:

              <div style={{ marginTop: "1em" }}>
                <CategoryDropDown
                  preselectedCategory={category}
        

        在这里,我们明确地传递了preselectedCategory属性,从我们组件的category属性中:

        sendOutSelectedCategory={sendOutSelectedCategory}
                />
              </div>
            </div>
          );
        };
        
        1. 现在,通过传递readOnly字段来更新ThreadBody组件对RichEditor的调用,就像这样:
        interface ThreadBodyProps {
          body?: string;
          readOnly: boolean;
        }
        

        在这里,我们已经将readOnly字段添加到我们的 props 类型中;也就是ThreadBodyProps

        const ThreadBody: FC<ThreadBodyProps> = ({ body, readOnly prop to our RichEditor.
        
        1. 现在,让我们更新ThreadResponseBuilder组件,就像这样:
        interface ThreadResponsesBuilderProps {
          threadItems?: Array<ThreadItem>;
          readOnly: boolean;
        }
        

        再次,这是一个readOnly属性定义。这是因为这个组件使用了ThreadResponse,而ThreadResponse在内部使用了RichEditor

        const ThreadResponsesBuilder: FC<ThreadResponsesBuilderProps> = ({
          threadItems,
          readOnly,
        }) => {
          const [responseElements, setResponseElements] =   useState<
            JSX.Element | undefined
          >();
          useEffect(() => {
            if (threadItems) {
              const thResponses = threadItems.map((ti) => {
                return (
                  <li key={`thr-${ti.id}`}>
                    <ThreadResponse
                      body={ti.body}
                      userName={ti.user.userName}
        

        在这里,我们使用了 Thread 的user对象来获取所需的userName

                      lastModifiedOn={ti.createdOn}
                      points={ti.points}
                      readOnly={readOnly}
        

        这是我们的readOnly字段被传递到ThreadResponse中。

                    />
                  </li>
                );
              });
              setResponseElements(<ul>{thResponses}</ul>);
            }
          }, [threadItems, readOnly]);
          return (
            <div className="thread-body-container">
              <strong style={{ marginBottom: ".75em" }}>Responses</strong>
              {responseElements}
            </div>
          );
        };
        

        其余的代码与以前一样。

        最后,我们有了我们的ThreadResponse组件,它使用了readOnly属性,就像这样:

        interface ThreadResponseProps {
          body?: string;
          userName?: string;
          lastModifiedOn?: Date;
          points: number;
          readOnly: boolean;
        

        这是属性定义。

        }
        const ThreadResponse: FC<ThreadResponseProps> = ({
          body,
          userName,
          lastModifiedOn,
          points,
          readOnly prop in.
        
        

        }) ⇒ {

        return (

        <span style={{ marginLeft: "1em" }}>

        <ThreadPointsInline points={points || 0} />

        
        And here, we've passed `readOnly` into our `RichEditor` component.
        
        

        );

        };

        
        

        由于没有明显的视觉线索,有点难以看到,但你会注意到在任何现有 Thread 的线程路由上,比如http://localhost:5000/thread/1,你的 Thread 和任何响应的编辑器都将处于只读模式,这意味着它们不能被编辑。

        积分系统

        现在我们已经设置好了可以显示积分的一切,我们需要一个机制来设置它们。这就是我们现在要做的。让我们开始吧:

        1. 打开Thread.tsx文件,看一下代码。你会在 JSX 的末尾找到一个名为ThreadPointsBar的组件。这是我们的ThreadCardThread.tsx路由中显示积分垂直条的组件。

        2. 我们将添加按钮来允许增加或减少积分。我们已经构建了后端和解析器,所以我们在这里要做的工作只是将它与我们的客户端代码联系起来。

        ThreadPointsBar.tsx文件中,按照以下方式更新现有的 JSX。这是一个重大的变化,让我们来分解一下:

        import React, { FC } from "react";
        import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
        import {
          faHeart,
          faReplyAll,
          faChevronDown,
          faChevronUp,
        } from "@fortawesome/free-solid-svg-icons";
        import { useWindowDimensions } from "../../hooks/useWindowDimensions";
        import { gql, useMutation } from "@apollo/client";
        const UpdateThreadPoint = gql`
          mutation UpdateThreadPoint(
            $userId: ID!
            $threadId: ID!
            $increment: Boolean!
          ) {
            updateThreadPoint(
              userId: $userId
              threadId: $threadId
              increment: $increment
            )
          }
        `;
        

        首先,我们有我们的updateThreadPoint mutation。

        export class ThreadPointsBarProps {
          points: number = 0;
          responseCount?: number;
          userId?: string;
          threadId?: string;
          allowUpdatePoints?: boolean = false;
          refreshThread?: () => void;
        }
        

        有了这个,我们将我们的ThreadPointsBarProps接口转换成了一个类,这样我们就可以给一些字段设置默认值。请注意,在字段中,我们有一个refreshThread函数,我们将用它来强制更新我们的父 Thread,这样一旦我们更新了积分,这将反映在我们的 UI 中。我们将在使用这些字段时逐一讨论其他字段。此外,我们将不再与我们的ThreadPointsInline组件共享这个属性,我稍后会展示。

        const ThreadPointsBar: FC<ThreadPointsBarProps> = ({
          points,
          responseCount,
          userId,
          threadId,
          allowUpdatePoints,
          refreshThread,
        }) => {
          const { width } = useWindowDimensions();
          const [execUpdateThreadPoint] = useMutation(UpdateThreadPoint);
        

        请注意,我们的useMutation没有使用refetchQueries来刷新 Apollo Client。通常情况下,我会使用这种机制,但在测试中,我发现 Apollo Client 缓存默认缓存所有 GraphQL 查询,无法正确刷新线程。所有框架都会遇到这些问题。作为开发人员,你的工作之一就是找到解决这些问题的方法和解决方案。因此,我们将使用我们的refreshThread函数,而不是依赖refetchQueries,我们可以从父级那里得到它,以强制刷新。稍后我会向你展示Thread路由组件中这个函数的实现。

          const onClickIncThreadPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadPoint({
              variables: {
                userId,
                threadId,
                increment: true,
              },
            });
            refreshThread && refreshThread();
          };
          const onClickDecThreadPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadPoint({
              variables: {
                userId,
                threadId,
                increment: false,
              },
            });
            refreshThread && refreshThread();
          };
        

        这两个函数onClickIncThreadPointonClickDecThreadPoint在调用refreshThread之前执行execUpdateThreadPoint变异。refreshThread && refreshThread()语法是 JavaScript 的一种能力,它允许你写更少的代码。这种语法允许你检查这个可选函数是否存在,如果存在,就执行它。

          if (width > 768) {
            console.log("ThreadPointsBar points", points);
            return (
              <div className="threadcard-points">
                <div className="threadcard-points-item">
                  <div
                    className="threadcard-points-item-btn"
                    style={{ display: `${allowUpdatePoints ?              "block" : "none"}` }}
                  >
        

        在这里,我们有一小段逻辑,使用allowUpdatePoints属性,决定是否显示或隐藏允许用户增加点数的图标容器。我们必须对减少按钮做同样的事情:

                    <FontAwesomeIcon
                      icon={faChevronUp}
                      className="point-icon"
                      onClick={onClickIncThreadPoint}
                    />
                  </div>
                  {points}
                  <div
                    className="threadcard-points-item-btn"
                    style={{ display: `${allowUpdatePoints ?              "block" : "none"}` }}
                  >
                    <FontAwesomeIcon
                      icon={faChevronDown}
                      className="point-icon"
                      onClick={onClickDecThreadPoint}
                    />
                  </div>
                  <FontAwesomeIcon icon={faHeart}           className="points-icon" />
        

        在这里,我们添加了两个新的图标,faChevronUpfaChevronDown。当点击时,它们将增加或减少我们线程的点数。

                </div>
                <div className="threadcard-points-item">
                  {responseCount}
                  <br />
                  <FontAwesomeIcon icon={faReplyAll}            className="points-icon" />
                </div>
              </div>
            );
          }
          return null;
        };
        export default ThreadPointsBar;
        

        其余的代码保持不变。但是,请注意我们的 CSS 略有改变。我们更新了现有的threadcard-points-item类,并添加了一个名为threadcard-points-item-btn的新类。

        .threadcard-points-item {
          display: flex;
          flex-direction: column;
          justify-content: space-between;
          align-items: center;
          color: var(--point-color);
          font-size: var(--sm-med-font-size);
          text-align: center;
        }
        

        threadcard-points-item类现在是一个列的 flexbox,这样它可以垂直显示其内容。

        .threadcard-points-item-btn {
          cursor: pointer;
          margin-top: 0.35em;
          margin-bottom: 0.35em;
        }
        

        threadcard-points-item-btn类将我们的图标光标转换为指针,以便当用户悬停在上面时,光标变成手,表示可以点击。

        1. 现在我们已经做出了这些改变,我们需要更新一些其他相关的组件。我们首先要做的是在我们的ApolloClient中禁用resultCaching。打开index.tsx文件并更新client对象,像这样:
        const client = new ApolloClient({
          uri: "http://localhost:5000/graphql",
          credentials: "include",
          cache: new InMemoryCache({
            resultCaching: false,
          }),
        });
        

        正如其名称所示,此设置应该禁用查询结果的缓存。但是,它本身并不能做到这一点-我们必须向我们的查询添加另一个设置。

        1. 更新Thread.tsx文件。我们只显示已更改的代码。

        首先,getThreadById查询略有更新:

        const GetThreadById = gql`
          query GetThreadById($id: ID!) {
            getThreadById(id: $id) {
              ... on EntityResult {
                messages
              }
              ... on Thread {
                id
                user {
                  fetchPolicy, which controls the caching policy for our individual call. In this case, we want no caching at all. Again, I had to use fetchPolicy and resultCaching together to get the desired no-cache effect.
        
        

        const [thread,setThread] = useState<ThreadModel |    undefined>();

        const = useParams();

        const [readOnly,setReadOnly] = useState(false);

        刷新线程的常量=()=》{

        if(id && id > 0){

        execGetThreadById({

        variables:{

        id,

        },

        });

        }

        };

        
        Here, we have defined a function, called `refreshThread`, that calls our `execGetThreadById` executable. This function will be passed to our `ThreadPointBar` component later.
        
        

        useEffect(()=》{

        if(id && id > 0){

        execGetThreadById({

        variables:{

        id,

        },

        });

        }

        },[id,execGetThreadById]);

        
        You're probably wondering why we haven't reused `refreshThread` in the first `useEffect` call. To reuse it, we would have to include `refreshThread` in our `useEffect` call list and make an additional call to `useCallback` so that changes to `refreshThread` do not trigger a re-render. The tiny benefit this brings does not justify the extra code:
        
        

        useEffect(()=》{

        如果(threadData && threadData.getThreadById){

        setThread(threadData.getThreadById);

        setReadOnly(true);

        } else {

        setThread(undefined);

        setReadOnly(false);

        }

        },[threadData]);

        return(

        <ThreadHeader

        userName={thread?.user.userName}

        lastModifiedOn={thread?线程。             lastModifiedOn:new Date()}

        标题={线程?.标题}

        />

        
        Here, in our `ThreadPointsBar`, we are passing the new props we defined earlier:
        
        

        <ThreadPointsBar

        points={thread?.points || 0}

        responseCount={

        线程&&线程。线程项&&线程。               threadItems.length

        }

        userId={thread?.user.id || "0"}

        threadId={thread?.id || "0"}

        allowUpdatePoints=

        refreshThread=

        />


        <ThreadResponsesBuilder

        threadItems={thread?.threadItems}

        readOnly=

        />

        );

        };

        
        
        1. 现在,Thread 路由屏幕看起来像这样,我们的新点数系统已经就位:图 16.1-Thread 路由屏幕

        图 16.1-Thread 路由屏幕

        如果您尝试点击点按钮,您会注意到两件事。首先,有时,尽管我们在消除缓存问题上做了很多工作,但点数变化并没有立即显示在屏幕上。这是因为我们在 Repository 调用中有一个微妙的错误,我稍后会讨论。另一个问题是我们的用户可以一次添加或删除多个点。这是我们样式层中的另一个问题。一旦我们的客户端代码完成,我们将重新讨论这两个问题。

        1. 现在,我们需要更新我们的ThreadItemThread响应的点数功能。我们将从ThreadResponsesBuilder开始。更新useEffect,像这样:
        useEffect(() => {
            if (threadItems) {
              const thResponses = threadItems.map((ti) => {
                return (
                  <li key={`thr-${ti.id}`}>
                    <ThreadResponse
                      body={ti.body}
                      userName={ti.user.userName}
                      lastModifiedOn={ti.createdOn}
                      points={ti.points}
                      readOnly={readOnly}
                      userId={ti?.user.id || "0"}
                      threadItemId={ti?.id || "0"}
                    />
        

        我们现在传递了ThreadReponse组件,显示了 Thread 的ThreadItemuserIdthreadItemId。在这个组件中,我们有ThreadPointsInline组件,根据传入的是ThreadItem还是Thread来显示点赞点数,一旦我们到达该控件,我会澄清:

                  </li>
                );
              });
              setResponseElements(<ul>{thResponses}</ul>);
            }
          }, [threadItems, readOnly]);
        
        1. 现在,ThreadResponse组件可以更新。我只在这里显示了更改的代码。

        首先,将以下两个字段添加到ThreadResponseProps接口中:

          userId: string;
          threadItemId: string;
        

        现在,在 JSX 中,我们可以添加我们的userIdthreadItemId字段:

          return (
            <div>
              <div>
                <UserNameAndTime userName={userName}          lastModifiedOn={lastModifiedOn} />
                {threadItemId}
                <span style={{ marginLeft: "1em" }}>
                  <ThreadPointsInline
                    points={points || 0}
                    userId and threadItemId data to the ThreadPointsInline component. Note that this component will display points for either Threads or ThreadItems eventually. Also, note that I put threadItemId in there just so we could distinguish between each ThreadItem for now:
        
        

        );

        
        
        1. 现在,让我们看看我们必须对ThreadPointsInline组件进行的更改。

        将以下导入添加到现有导入列表中:

        import "./ThreadPointsInline.css";
        

        看一下源代码。在大多数情况下,它很像ThreadPointsBar的 CSS:

        const UpdateThreadItemPoint = gql`
          mutation UpdateThreadItemPoint(
            $userId: ID!
            $threadItemId: ID!
            $increment: Boolean!
          ) {
            updateThreadItemPoint(
              userId: $userId
              threadItemId: $threadItemId
              increment: $increment
            )
          }
        `;
        

        在这里,我们添加了我们的updateThreadItemPointmutation 定义。

        class ThreadPointsInlineProps {
          points: number = 0;
          userId?: string;
          threadId?: string;
          threadItemId?: string;
          allowUpdatePoints?: boolean = false;
          refreshThread?: () => void;
        }
        

        现在,这将是我们的 props 列表。请注意,我们有一个threadId字段。我们将使用这个ThreadPointsInline控件在移动屏幕上显示我们的 Thread 点数:

        const ThreadPointsInline: FC<ThreadPointsInlineProps> = ({
          points,
          userId,
          threadId,
          threadItemId,
          allowUpdatePoints,
          refreshThread,
        }) => {
          const [execUpdateThreadItemPoint] =   useMutation(UpdateThreadItemPoint);
          const onClickIncThreadItemPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadItemPoint({
              variables: {
                userId,
                threadItemId,
                increment: true,
              },
            });
            refreshThread && refreshThread();
          };
        

        这里没有什么特别特殊的地方-我们的onClickIncThreadItemPointonClickDecThreadItemPoint调用都在ThreadPointsBar组件中做着类似的事情,它们调用我们的更新 mutation 然后刷新 Thread 数据:

          const onClickDecThreadItemPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadItemPoint({
              variables: {
                userId,
                threadItemId,
                increment: false,
              },
            });
            refreshThread && refreshThread();
          };
        

        现在,在我们的 JSX 中,我们将做与我们的ThreadPointsBar组件类似的事情,并包括允许我们增加或减少实体点数的图标:

          return (
            <span className="threadpointsinline-item">
              <div
                className="threadpointsinline-item-btn"
                style={{ display: `${allowUpdatePoints ? "block"          : "none"}` }}
              >
                <FontAwesomeIcon
                  icon={faChevronUp}
                  className="point-icon"
                  onClick={onClickIncThreadItemPoint}
                />
              </div>
              {points}
              <div
                className="threadpointsinline-item-btn"
                style={{ display: `${allowUpdatePoints ? "block"         : "none"}` }}
              >
                <FontAwesomeIcon
                  icon={faChevronDown}
                  className="point-icon"
                  onClick={onClickDecThreadItemPoint}
                />
              </div>
              <div className="threadpointsinline-item-btn">
                <FontAwesomeIcon icon={faHeart}          className="points-icon" />
              </div>
            </span>
          );
        };
        export default ThreadPointsInline;
        
        1. 现在,如果您再次加载Thread路由屏幕,您应该会看到我们 Thread 的ThreadItems。再次强调,您的本地数据会有所不同,因此请确保您的 Thread 包含ThreadItem数据及其相应的点数,以及如下屏幕截图所示的图标按钮:图 16.2-ThreadItem 点

        图 16.2-ThreadItem 点

        同样,如果您点击增加和减少按钮,您会发现我们与 Thread 点数相同的问题。我们的点数得分并不总是更新,用户可以继续添加或删除点数。让我们现在解决这个问题。

        1. 转到您的服务器项目,打开ThreadItemPointRepo.ts文件,找到updateThreadItemPoint函数,并转到对threadItem.save()的第一个调用。在函数中的所有这些调用之前添加一个前缀,就像这样:
        await threadItem.save();
        

        你能猜到这将如何解决我们遇到的问题吗?通过在save调用上调用await,我们强制我们的函数等待保存完成。然后,当我们获取我们的ThreadItem数据时,我们可以确保它确实包含最新的points值。这是使用异步代码的棘手一面之一。它更快,但您必须考虑自己在做什么;否则,您可能会遇到这样的问题。

        现在,继续自己更新updateThreadPoint函数,类似于我们刚刚对updateThreadItemPoint函数所做的。确保更新每个save函数。

        现在,如果您尝试增加或减少积分,您应该会看到它们正确更新。

        1. 现在,让我们解决用户可以继续添加或删除积分的问题。在这段代码中实际上存在多个问题。我们的两个更新积分的解析器updateThreadPointupdateThreadItemPoint在尝试允许用户更新他们的积分之前没有检查用户身份验证。这显然是错误的。此外,我们的客户端代码实际上传递了ThreadThreadItemuserId值,而不是当前登录的用户。我们可以一起解决这两个问题。首先,更新updateThreadPoint解析器,就像这样:
        updateThreadPoint: async (
              obj: any,
              args: { threadId: string; increment: boolean },
              ctx: GqlContext,
              info: any
            ): Promise<string> => {
        

        我们不再将userId作为此解析器的参数。这是因为,如下面的代码所示,我们现在通过session.userId字段检查用户是否已登录。然后,当我们调用我们的updateThreadPoint存储库查询时,我们将session.userId字段作为userId参数传递:

              let result = "";
              try {
                if (!ctx.req.session || !ctx.req.session?.userId)          {
                  return "You must be logged in to set likes.";
                }
                result = await updateThreadPoint(
                  ctx.req.session!.userId,
                  args.threadId,
                  args.increment
                );
                return result;
              } catch (ex) {
                throw ex;
              }
            },
        

        对于updateThreadItemPoint解析器也进行相同的更改,因为它们几乎是相同的调用。还要不要忘记更新我们的typeDefs,以便这些调用的 Mutation 签名不再具有userId参数。我们还需要更新客户端中的代码路径,并稍后删除那里的userId参数。

        1. 现在,将以下代码添加到实现的顶部的updateThreadPoint存储库调用中:
        if (!userId || userId === "0") {
            return "User is not authenticated";
        }
        

        这将防止userId传递任何奇怪的值,并且我们认为用户已经通过身份验证,而实际上并没有。将相同的代码添加到updateThreadItemPoint存储库调用中。

        现在,让我们修复客户端代码并删除userId参数。最简单的方法是从ThreadPointsBarThreadPointsInline组件中删除调用。然后保存代码,编译器会告诉您相关调用通过userId的位置。

        1. 让我们从ThreadPointsBar开始。像这样更新它。从UpdateThreadPoint Mutation 参数中删除userId。然后,从组件的ThreadPointsBarProps类型的 props 中删除它。接下来,从ThreadPointsBar的 props 参数中删除它。最后,从对execUpdateThreadPoints的调用中删除userId

        2. 接下来,在Thread.tsx路由组件中,找到对ThreadPointsBar的调用,并简单地删除userId props。还要删除useSelector调用以获取用户 reducer,因为它不再被使用。

        ThreadPointsInline组件也需要进行相同类型的重构,但我会把这个改变留给你,因为它基本上和我们为ThreadPointsBar做的改变是一样的。同样,尝试从ThreadPointsInline组件开始进行更改并保存您的代码。编译器应该会告诉您userId的引用仍然存在的位置。

        有了这个,我们的积分应该可以正确更新。积分应该只在用户登录时更新,并且只能增加或减少一个积分。用户也不应该被允许更改他们自己的ThreadThreadItem的积分。

        现在,让我们看看其他的东西。在移动模式下查看Thread路由组件时,您会发现我们的积分计数不再可见,如下所示:

        图 16.3 - 移动模式下的 Thread 路由屏幕

        图 16.3 - 移动模式下的 Thread 路由屏幕

        当然,这是故意的,因为水平空间很小。所以,让我们把我们的ThreadPointsInline组件放在这个移动屏幕上,并更新它,使它可以为 Threads 和 ThreadItems 工作:

        1. 因为ThreadPointsInline正在重构以使用ThreadPointBar正在使用的updateThreadPoint Mutation,我们必须将这些调用移到它们自己的 Hook 中并共享它们。在 Hooks 文件夹内创建一个名为useUpdateThreadPoint.ts的新文件,并将相应的 Git 源代码添加到其中。

        通过这样做,我们只是将大部分代码从ThreadPointBar组件复制到这里。完成这一步后,我们将返回事件处理程序供我们调用的组件使用;也就是onClickIncThreadPointonClickDecThreadPoint

        1. 现在,让我们重构ThreadPointBar组件,以便它可以使用这个 Hook。将其更新如下:
        import useUpdateThreadPoint from "../../hooks/useUpdateThreadPoint";
        

        在这里,我们导入了我们的新 Hook,并移除了UpdateThreadPoint的 Mutation:

        export class ThreadPointsBarProps {
          points: number = 0;
          responseCount?: number;
          threadId?: string;
          allowUpdatePoints?: boolean = false;
          refreshThread?: () => void;
        }
        const ThreadPointsBar: FC<ThreadPointsBarProps> = ({
          points,
          responseCount,
          threadId,
          allowUpdatePoints,
          refreshThread,
        }) => {
          const { width } = useWindowDimensions();
          const { onClickDecThreadPoint, onClickIncThreadPoint }    = useUpdateThreadPoint(
            refreshThread,
            threadId
          );
        

        在这里,我们从useUpdateThreadPoint Hook 中接收了事件处理程序。其余的代码是相同的。

        1. 现在,让我们像这样重构ThreadPointsInline
        import React, { FC } from "react";
        import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
        import {
          faHeart,
          faChevronDown,
          faChevronUp,
        } from "@fortawesome/free-solid-svg-icons";
        import { gql, useMutation } from "@apollo/client";
        import "./ThreadPointsInline.css";
        import useUpdateThreadPoint from "../../hooks/useUpdateThreadPoint";
        const UpdateThreadItemPoint = gql`
          mutation UpdateThreadItemPoint($threadItemId: ID!,    $increment: Boolean!) {
            updateThreadItemPoint(threadItemId: $threadItemId,     increment: $increment)
          }
        `;
        class ThreadPointsInlineProps {
          points: number = 0;
          threadId?: string;
          threadItemId?: string;
          allowUpdatePoints?: boolean = false;
          refreshThread?: () => void;
        }
        const ThreadPointsInline: FC<ThreadPointsInlineProps> = ({
          points,
          threadId,
          threadItemId,
          allowUpdatePoints,
          refreshThread,
        }) => {
          const [execUpdateThreadItemPoint] =    useMutation(UpdateThreadItemPoint);
          const { onClickDecThreadPoint, onClickIncThreadPoint }    = useUpdateThreadPoint(
            refreshThread,
            threadId
          );
        

        在这里,我们从useUpdateThreadPoint Hook 中获取了我们的事件处理程序:

          const onClickIncThreadItemPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadItemPoint({
              variables: {
                threadItemId,
                increment: true,
              },
            });
            refreshThread && refreshThread();
          };
          const onClickDecThreadItemPoint = async (
            e: React.MouseEvent<SVGSVGElement, MouseEvent>
          ) => {
            e.preventDefault();
            await execUpdateThreadItemPoint({
              variables: {
                threadItemId,
                increment: false,
              },
            });
            refreshThread && refreshThread();
          };
          return (
            <span className="threadpointsinline-item">
              <div
                className="threadpointsinline-item-btn"
                style={{ display: `${allowUpdatePoints ? "block"          : "none"}` }}
              >
                <FontAwesomeIcon
                  icon={faChevronUp}
                  className="point-icon"
                  onClick={threadId ? onClickIncThreadPoint :            onClickIncThreadItemPoint}
        

        在以下代码中,有一小部分逻辑决定了我们是否会更新ThreadThreadItem的点:

                />
              </div>
              {points}
              <div
                className="threadpointsinline-item-btn"
                style={{ display: `${allowUpdatePoints ? "block"          : "none"}` }}
              >
                <FontAwesomeIcon
                  icon={faChevronDown}
                  className="point-icon"
                  onClick={threadId ? onClickDecThreadPoint :            onClickDecThreadItemPoint}
                />
        

        我们在这里也有相同的点选择逻辑:

              </div>
              <div className="threadpointsinline-item-btn">
                <FontAwesomeIcon icon={faHeart}          className="points-icon" />
              </div>
            </span>
          );
        };
        export default ThreadPointsInline;
        

        现在,如果你在移动模式下运行Thread路由,你会看到这个:

        图 16.4 - 移动端的 Thread 路由屏幕与我们的点增量器

        图 16.4 - 移动端的 Thread 路由屏幕与我们的点增量器

        请注意,我已对ThreadCategory组件进行了一些样式更新,以便在移动模式下在主页路由上查看它。

        现在,我们可以在屏幕上查看现有的 Threads。但是,我们还需要能够添加新的 Threads 和 ThreadItems。现在让我们添加这些功能:

        1. 首先,我们需要对我们的createThread Repository 调用进行一些小的更改。打开ThreadRepo文件,并更新createThread的最后一个return语句,如下所示:
        return { messages: ["Thread created successfully."] };
        

        将其更改为如下所示:

        return { messages: [thread.id] };
        

        现在,如果我们的createThread成功,它将只返回 ID。这样可以最小化有效载荷大小,但可以给客户端提供所需的信息。

        1. 接下来,我们必须对我们的Thread路由进行另一个小的更改。打开App.tsx,找到Thread的路由。将该路由更新如下:
        <Route path="/thread/? immediately after the id parameter. This will allow the Thread route to load with no parameters, which is what tells the screen that we want to make a new Thread post.
        
        1. 现在,我们将添加一个useHistory,以便我们可以修改我们所在的 URL:
          useEffect(() => {
            if (categoryId && categoryId > 0) {
              execGetThreadsByCat({
                variables: {
                  categoryId,
                },
              });
            } else {
              execGetThreadsLatest();
            }
            // eslint-disable-next-line react-hooks/exhaustive-     // deps
          }, [categoryId]);
          useEffect(() => {
            console.log("main threadsByCatData",      threadsByCatData);
            if (
              threadsByCatData &&
              threadsByCatData.getThreadsByCategoryId &&
              threadsByCatData.getThreadsByCategoryId.threads
            ) {
              const threads = threadsByCatData.       getThreadsByCategoryId.threads;
              const cards = threads.map((th: any) => {
                return <ThreadCard key={`thread-${th.id}`} thread={th} />;
              });
              setCategory(threads[0].category);
              setThreadCards(cards);
            } else {
              setCategory(undefined);
              setThreadCards(null);
            }
          }, [threadsByCatData]);
          useEffect(() => {
            if (
              threadsLatestData &&
              threadsLatestData.getThreadsLatest &&
              threadsLatestData.getThreadsLatest.threads
            ) {
              const threads = threadsLatestData.getThreadsLatest.       threads;
              const cards = threads.map((th: any) => {
                return <ThreadCard key={`thread-${th.id}`}          thread={th} />;
              });
              setCategory(new Category("0", "Latest"));
              setThreadCards(cards);
            }
          }, [threadsLatestData]);
          const onClickPostThread = () => {
            history.push("/thread");
          };
        

        在这里,我们有一个新的处理程序用于Post按钮点击,它会将用户重定向到没有id的线程屏幕。我会稍后告诉你为什么这很重要:

          return (
            <main className="content">
              <button className="action-btn"        onClick={onClickPostThread}>
                Post
              </button>
        

        在这里,我们有一个带有处理程序的button声明:

              <MainHeader category={category} />
              <div>{threadCards}</div>
            </main>
          );
        };
        

        其余的代码与以前一样。

        1. 现在,我们需要更新我们的Thread.tsx组件,以便当它看到我们没有id时,它知道要设置自身,以便它可以添加一个新的 Thread。但是,为了做到这一点,我们需要更新一些它的子组件。让我们从RichEditor开始。更新此组件如下。我只会在这里展示已更改的代码:
        export const getTextFromNodes = (nodes: Node[]) => {
          return nodes.map((n: Node) => Node.string(n)).   join("\n");
        };
        

        getTextFromNodes是一个新的辅助函数,它将允许我们的 Node 数组的 Slate.js 格式被转换为字符串:

        const HOTKEYS: { [keyName: string]: string } = {
          "mod+b": "bold",
          "mod+i": "italic",
          "mod+u": "underline",
          "mod+`": "code",
        };
        const initialValue = [
          {
            type: "paragraph",
            children: [{ text: "" }],
        

        InitialValue现在是一个空字符串:

          },
        ];
        const LIST_TYPES = ["numbered-list", "bulleted-list"];
        class RichEditorProps {
          existingBody?: string;
          readOnly?: boolean = false;
          sendOutBody?: (body: Node[]) => void;
        

        我们添加了这个额外的 prop,这样当我们的编辑器的文本更新时,这个变化将向上组件层次结构传递到我们的Thread.tsx组件。Thread.tsx需要知道最新的值,以便在尝试创建新的 Thread 时将其作为参数发送。我们将在这些子组件中重复这种sendOut模式:

        }
        const RichEditor: FC<RichEditorProps> = ({
          existingBody,
          readOnly,
          sendOutBody,
        }) => {
          const [value, setValue] =   useState<Node[]>(initialValue);
          const renderElement = useCallback((props) => <Element {...props} />, []);
          const renderLeaf = useCallback((props) => <Leaf {...   props} />, []);
          const editor = useMemo(() =>    withHistory(withReact(createEditor())), []);
          useEffect(() => {
            console.log("existingBody", existingBody);
            if (existingBody) {
              setValue(JSON.parse(existingBody));
        

        existingBody属性是从父组件发送过来的初始值。当从现有 Thread 加载Thread.tsx路由屏幕时,这个值将进来。这个 Thread 当然是从我们的数据库加载的,这意味着文本数据将被保存到我们的数据库中作为一个字符串。这是因为 Postgres 不理解 Slate.js 的Node类型。这样做的副作用是,在setValue可以接收这些数据之前,它必须首先以 JSON 格式进行解析,这就是为什么你可以看到setValue(JSON.parse(existingBody))

            }
            // eslint-disable-next-line react-hooks/exhaustive-    // deps
          }, [existingBody]);
          const onChangeEditorValue = (val: Node[]) => {
            setValue(val);
            sendOutBody && sendOutBody(val);
        

        在这里,我们从编辑器中设置了我们的val,但也使用sendOutBody将其发送回父组件。

          };
          return (
            <Slate editor={editor} value={value}      onChange={onChangeEditorValue}>
              <Toolbar>
                <MarkButton format="bold" icon="bold" />
                <MarkButton format="italic" icon="italic" />
                <MarkButton format="underline" icon="underlined"           />
                <MarkButton format="code" icon="code" />
                <BlockButton format="heading-one" icon="header1"           />
                <BlockButton format="block-quote" icon="in_          quotes" />
                <BlockButton format="numbered-list" icon="list_          numbered" />
                <BlockButton format="bulleted-list" icon="list_          bulleted" />
              </Toolbar>
              <Editable
                className="editor"
                renderElement={renderElement}
                renderLeaf={renderLeaf}
                placeholder="Enter your post here."
        

        以下是一个微不足道的placeholder更改。

                spellCheck
                autoFocus
                onKeyDown={(event) => {
                  for (const hotkey in HOTKEYS) {
                    if (isHotkey(hotkey, event as any)) {
                      event.preventDefault();
                      const mark = HOTKEYS[hotkey];
                      toggleMark(editor, mark);
                    }
                  }
                }}
                readOnly={readOnly}
              />
            </Slate>
          );
        };
        
        1. 现在,我们需要更新ThreadCategory组件。我只会在这里展示已更改的代码:
        interface ThreadCategoryProps {
          category?: Category;
          sendOutSelectedCategory: (cat: Category) => void;
        

        在这里,我们有sendOutSelectedCategory函数,它允许我们使用sendOut方法发送回类别选择:

        }
        const ThreadCategory: FC<ThreadCategoryProps> = ({
          category,
          sendOutSelectedCategory,
        }) => {
        
        1. 接下来,我们将更新我们的ThreadTitle组件,就像这样:
        import React, { FC, useEffect, useState } from "react";
        interface ThreadTitleProps {
          title?: string;
          readOnly: boolean;
        

        现在,当我们加载现有的主题时,我们希望使我们的标题只读:

          sendOutTitle: (title: string) => void;
        

        再次,在这里,我们使用sendOutTitle使用sendOut模式:

        }
        const ThreadTitle: FC<ThreadTitleProps> = ({
          title,
          readOnly,
          sendOutTitle,
        }) => {
          const [currentTitle, setCurrentTitle] = useState("");
          useEffect(() => {
            setCurrentTitle(title || "");
          }, [title]);
          const onChangeTitle = (e: React.   ChangeEvent<HTMLInputElement>) => {
            setCurrentTitle(e.target.value);
            sendOutTitle(e.target.value);
        

        在这里,我们已经设置了我们的标题,并将其发送到我们组件的父级:

          };
          return (
            <div className="thread-title-container">
              <strong>Title</strong>
              <div className="field">
                <input
                  type="text"
                  value={currentTitle}
                  onChange={onChangeTitle}
                  readOnly={readOnly}
        

        在这里,我们使用了我们的新 props:

                />
              </div>
            </div>
          );
        };
        export default ThreadTitle;
        
        1. 现在,让我们更新ThreadBody,就像这样:
        import React, { FC } from "react";
        import RichEditor from "../../editor/RichEditor";
        import { Node } from "slate";
        interface ThreadBodyProps {
          body?: string;
          readOnly: boolean;
          sendOutBody: (body: Node[]) => void;
        

        再次,我们需要sendOut模式用于sendOutBody函数:

        }
        const ThreadBody: FC<ThreadBodyProps> = ({ body, readOnly, sendOutBody }) => {
          return (
            <div className="thread-body-container">
              <strong>Body</strong>
              <div className="thread-body-editor">
                <RichEditor
                  existingBody={body}
                  readOnly={readOnly}
                  sendOutBody={sendOutBody}
        

        现在,我们必须将sendOutBody函数发送到我们的RichEditor,因为该控件处理正文更新:

                />
              </div>
            </div>
          );
        };
        export default ThreadBody;
        
        1. 最后,我们有Thread.tsx文件。我们必须在这里做一些更改。让我们一起看看。

        您应该能够自己添加适当的导入;例如,在这里,我们需要getTextFromNodes助手:

        const GetThreadById = gql`
          query GetThreadById($id: ID!) {
            getThreadById(id: $id) {
              ... on EntityResult {
                messages
              }
              ... on Thread {
                id
                user {
                  id
                  userName
                }
                lastModifiedOn
                title
                body
                points
                category {
                  id
                  name
                }
                threadItems {
                  id
                  body
                  points
                  user {
                    id
                    userName
                  }
                }
              }
            }
          }
        `;
        const CreateThread = gql`
          mutation createThread(
            $userId: ID!
            $categoryId: ID!
            $title: String!
            $body: String!
          ) {
            createThread(
              userId: $userId
              categoryId: $categoryId
              title: $title
              body: $body
            ) {
              messages
            }
          }
        `;
        

        这是我们的新CreateThread mutation:

        const threadReducer = (state: any, action: any) => {
          switch (action.type) {
            case "userId":
              return { ...state, userId: action.payload };
            case "category":
              return { ...state, category: action.payload };
            case "title":
              return { ...state, title: action.payload };
            case "body":
              return { ...state, body: action.payload };
            case "bodyNode":
              return { ...state, bodyNode: action.payload };
            default:
              throw new Error("Unknown action type");
          }
        };
        

        还需要添加一个新的 reducer;即threadReducer

        const Thread = () => {
          const { width } = useWindowDimensions();
          const [execGetThreadById, { data: threadData }] =    useLazyQuery(
            GetThreadById,
            { fetchPolicy: "no-cache" }
          );
          const [thread, setThread] = useState<ThreadModel |    undefined>();
          const { id } = useParams();
          const [readOnly, setReadOnly] = useState(false);
          const user = useSelector((state: AppState) => state.   user);
        

        这是我们的user对象,只有在用户登录时才会出现。我们只会在创建新主题时使用这个对象:

          const [
            { userId, category, title, body, bodyNode },
            threadReducerDispatch,
          ] = useReducer(threadReducer, {
            userId: user ? user.id : "0",
            category: undefined,
            title: "",
            body: "",
            bodyNode: undefined,
          });
        

        这是我们的 reducer。这些字段将用于在创建模式下提交新主题:

          const [postMsg, setPostMsg] = useState("");
        

        以下代码显示了我们尝试创建主题的状态:

          const [execCreateThread] = useMutation(CreateThread);
        

        这是我们实际的CreateThread Mutation 调用者,execCreateThread

          const history = useHistory();
        

        我们将使用useHistory()来切换到新创建的主题路由。例如,如果新主题的id是 25,那么路由将是"/thread/25"

          const refreshThread = () => {
            if (id && id > 0) {
              execGetThreadById({
                variables: {
                  id,
                },
              });
            }
          };
          useEffect(() => {
            console.log("id");
            if (id && id > 0) {
              execGetThreadById({
                variables: {
                  id,
                },
              });
            }
          }, [id, execGetThreadById]);
          useEffect(() => {
            threadReducerDispatch({
              type: "userId",
              payload: user ? user.id : "0",
            });
          }, [user]);
        

        在这里,我们正在更新 reducer 的userId,以防用户已登录:

          useEffect(() => {
            if (threadData && threadData.getThreadById) {
              setThread(threadData.getThreadById);
              setReadOnly(true);
            } else {
              setThread(undefined);
              setReadOnly(false);
            }
          }, [threadData]);
          const receiveSelectedCategory = (cat: Category) => {
            threadReducerDispatch({
              type: "category",
              payload: cat,
            });
          };
        

        在这里,我们已经开始为我们在子组件中使用的sendOut模式添加处理程序函数的定义。在这种情况下,receiveSelectedCategoryCategoryDropDown控件接收新设置的ThreadCategory

          const receiveTitle = (updatedTitle: string) => {
            threadReducerDispatch({
              type: "title",
              payload: updatedTitle,
            });
          };
          const receiveBody = (body: Node[]) => {
            threadReducerDispatch({
              type: "bodyNode",
              payload: body,
            });
            threadReducerDispatch({
              type: "body",
              payload: getTextFromNodes(body),
            });
          };
        

        receiveTitlereceiveBody函数还处理从它们各自的子组件进行的titlebody更新:

          const onClickPost = async (
            e: React.MouseEvent<HTMLButtonElement, MouseEvent>
          ) => {
        

        onClickPost函数处理id

              } else {
                setPostMsg(createThreadMsg.createThread.         messages[0]);
        

        如果尝试失败,我们会向用户显示服务器错误:

              }
            }
          };
          return (
            <div className="screen-root-container">
              <div className="thread-nav-container">
                <Nav />
              </div>
              <div className="thread-content-container">
                <div className="thread-content-post-container">
                  {width <= 768 && thread ? (
                    <ThreadPointsInline
                      points={thread?.points || 0}
                      threadId={thread?.id}
                      refreshThread={refreshThread}
                      allowUpdatePoints={true}
                    />
                  ) : null}
        

        如果屏幕显示在移动设备上并且主题存在,我们会显示此控件;否则,我们不会:

                  <ThreadHeader
                    userName={thread ? thread.user.userName :             user?.userName}
                    lastModifiedOn={thread ? thread.             lastModifiedOn : new Date()}
                    title={thread ? thread.title : title}
                  />
                  <ThreadCategory
                    category={thread ? thread.category :              category}
                    sendOutSelectedCategory=
                      {receiveSelectedCategory} 
                  />
                  <ThreadTitle
                    title={thread ? thread.title : ""}
                    readOnly={thread ? readOnly : false}
                    sendOutTitle={receiveTitle}
                  />
                  <ThreadBody
                    body={thread ? thread.body : ""}
                    readOnly={thread ? readOnly : false}
                    sendOutBody={receiveBody}
                  />
        

        其他子组件中包含相同的逻辑。如果thread对象存在,我们显示一个主题。如果没有,那么我们进入主题发布模式:

                  {thread ? null : (
                    <>
                      <div style={{ marginTop: ".5em" }}>
                        <button className="action-btn"                   onClick={onClickPost}>
                          Post
                        </button>
                      </div>
                      <strong>{postMsg}</strong>
                    </>
                  )}
        

        这是我们的主题发布按钮和状态消息。同样,如果我们的主题对象存在,我们不显示这些,而如果主题存在,我们就显示它们。

        其余代码与之前完全相同,所以我不会在这里展示它。

        1. 现在,如果我们在没有id的情况下运行主题路由,我们会得到以下屏幕:图 16.6 - 新主题屏幕

        图 16.6 - 新主题屏幕

        警告

        由于我们现在将 Slate.js Nodes 保存为 JSON 字符串,存储在我们的Thread表的Body字段中,因此在测试代码之前,您必须清除任何现有的ThreadThreadItem数据,然后才能再次显示它。

        这里有一个问题。由于我们现在在数据库的Body字段中有 JSON 字符串,当这些数据返回时,它将在主屏幕上看起来像这样:

        图 16.7 - 主屏幕

        图 16.7 - 主屏幕

        显然,这不是我们想要的。在这里,我们需要更新这个文本,使其成为普通字符串。幸运的是,我们可以使用现有的RichEditor来显示文本并保持所有格式不变。

        1. 通过在Toolbar上放置一个readOnly检查来更新RichEditor组件,就像这样:
        {readOnly mode, this Toolbar does not appear.
        
        1. 通过用以下内容替换 JSX 中的<div>{thread.body}</div>行来更新ThreadCard组件:
        <RichEditor existingBody={thread.body} readOnly={true} />
        

        同样,确保您的RichEditor的导入也在那里。

        1. 现在,在主屏幕上应该看到类似于这样的东西。您自己的数据将与此处显示的数据不同:

        图 16.8 - 带有正文的主屏幕

        图 16.8 - 带有正文的主屏幕

        请注意,这也会导致我们的 Thread 路由屏幕上的RichEditorreadOnly模式下隐藏工具栏。现在,我们只需要允许提交新的ThreadItem响应,然后我们就完成了这一部分。我们将重新设计ThreadResponse组件,以便它也允许提交 ThreadItems,而不仅仅是显示:

        1. 首先,我们需要对服务器端进行一些微小的调整。打开ThreadItemRepo,找到createThreadItem。在最后的return语句中,更新如下:
        return { messages: [`${threadItem.id}`] };
        

        就像我们在createThread函数中所做的那样,我们返回 ThreadItem 的id

        1. 现在,在ThreadRepo中,更新对findOne的调用,就像这样:
        const thread = await Thread.findOne({
            where: {
              id,
            },
            relations: 
              "user",
              "threadItems",
              "threadItems.user",
              ThreadItem response, we can associate it with the correct parent Thread.
        
        1. 现在,我们需要重构我们客户端代码中的ThreadItem.ts模型,以便它接受一个thread对象而不是一个threadId
        public thread: Thread
        

        通过这样做,我们从刚刚更新的查询中接收了Thread对象。

        1. 现在,根据源代码中所示,更新ThreadResponse。确保您有所有的导入。

        首先,您会看到我们的新的CreateThreadItem Mutation。

        ThreadResponseProps接口中,我们可以看到body属性是在进行任何更改之前RichEditor的初始值。如果我们要提交新的ThreadItem,我们还需要接收父threadId

        之后,我们需要从useSelector中获取user对象。我们这样做是因为当前用户将提交新的 ThreadItems。

        接下来,我们有execCreateThreadItem,这是我们的CreateThreadItem的 Mutation 执行器。

        然后,我们有我们的状态消息postMsg,当用户尝试保存时会显示。

        然后,在RichEditor中有当前编辑的 body 值bodyToSave

        接下来,useEffect用于从传入的 prop body初始化我们的bodyToSave值,以便我们有一个初始值可以开始。

        onClickPost函数允许我们在尝试提交新的ThreadItem之前进行一些验证检查。一旦我们完成了这个,我们就可以提交并刷新我们的父 Thread。

        receiveBody函数中,我们从RichEditor组件中接收到我们更新的文本。如果我们要提交新的ThreadItem,我们会使用这个。

        在返回的 JSX 中,我们决定如果不是在readOnly模式下,就不显示ThreadPointsInline。但是,如果我们处于编辑模式,我们允许显示发布按钮和状态消息。

        1. 现在,如果我们创建了一些ThreadItem帖子,我们应该会看到类似这样的东西:

        图 16.9 - 提交的 ThreadItem 响应图

        这就是 Thread 路由屏幕的全部内容。我们几乎完成了,到目前为止你做得非常出色。我们已经涵盖了很多材料和代码,以达到这个阶段。你应该为自己的进步感到很棒。我们还有一个部分要完成,然后我们就完成了我们的应用程序!

        我们需要配置的最后一项是RightMenu。在这个菜单中,我们将列出最多三个顶级 ThreadCategories,根据每个ThreadCategory所归属的 Threads 的数量。这将涉及一个更长的多部分查询,是一个很好的练习:

        1. 首先,我们需要在typeDefs文件中添加一个名为CategoryThread的新类型,就像这样:
            type CategoryThread {        
                threadId: ID!        
                categoryId: ID!        
                categoryName: String!        
                title: String!        
                titleCreatedOn: Date!      
            }    
        

        请注意,titleCreatedOn只是用于检查排序。我们不会在客户端代码中使用它。

        1. 现在,在我们的存储库文件夹中添加一个名为CategoryThread.ts的新模型,并添加以下代码。请注意,这个类不会成为我们数据库中的实体。相反,它将是一个聚合类,将包含来自多个实体的字段:
            export default class CategoryThread {      
                constructor(        
                    public threadId: string,        
                    public categoryId: string,        
                    public categoryName: string,        
                    public title: string,        
                    public titleCreatedOn: Date      
                ) {}    
            }    
        
        1. 现在,从源代码中获取代码,并创建CategoryThreadRepo.ts文件。从头开始,首先,我们通过使用ThreadCategory.createQueryBuilder("threadCategory")从数据库中获取ThreadCategory数据进行了初始查询。请注意,我们还包括了与 Threads 表的关系。

        现在,我们将对查询进行后处理,以获得我们想要的结果。我们没有在 TypeORM 查询中进行此工作,因为对于更复杂的排序和过滤,TypeORM 有时很难处理。使用标准的 JavaScript 将更容易地得到我们需要的内容。

        在第 14 行调用categories.sort时,我们根据每个ThreadCategory包含的 Thread 记录数量进行降序排序。然后,我们只取结果的前三条记录。然后,我们将获取的结果按照createdOn时间戳的降序对实际的 Thread 记录进行排序。

        通过这样做,我们最多会得到每个类别的三条 Thread 记录,按照它们的createdOn时间戳排序。

        1. 现在,让我们使用 GraphQL Playground 进行测试:

        图 16.10 – 获取热门分类主题排序结果

        图 16.10 – 获取热门分类主题排序结果

        正如你所看到的,排序和过滤都在起作用。

        1. 现在,让我们完成客户端代码。打开CategoryThread.ts并更新category,使其成为categoryName。这将与我们服务器端模型中此字段的名称匹配。

        2. 打开TopCategory.tsx并更新返回的 JSX 中显示的行:

        <strong>{topCategories[0].category}</strong>
        

        category更改为categoryName

        1. 现在,打开RightMenu.tsx并从源代码进行更新:

        在进行必要的导入后,我们需要定义我们的 GraphQL 查询GetTopCategoryThread,然后在第 20 行调用useQuery来使用该查询。在这里,我们正在使用该查询。

        然后,在第 26 行,useEffect已更新以使用结果的categoryThreadDatalodash中的groupBy方法正在将我们的数据按categoryName分组,以便更容易处理。这个操作的原始代码在第十一章**中有所涉及,我们将学到的内容 – 在线论坛应用程序

        最后,我们需要检查移动宽度,它会返回null或我们的 UI。

        1. 现在,如果我们运行我们的主屏幕,我们应该会看到我们的RightMenu中填充了数据:

        图 16.11 – 主屏幕显示热门分类

        图 16.11 – 主屏幕显示热门分类

        再次,你的本地数据会有所不同。

        至此,我们完成了!有很多代码,还有许多框架和概念。你已经做得非常出色。好好休息一下吧。

        在本节中,我们介绍了应用程序的客户端代码以及如何将其与后端 GraphQL 服务器连接起来。我们不得不调整样式并通过重构代码进行调整。我们还必须修复难以找到的错误。这正是我们在现实生活中要做的事情,所以这是很好的练习。

        总结

        在这最后的编码章节中,我们通过完成代码并将前端 React 应用程序与后端 GraphQL 服务器集成,将所有内容整合在一起。在本章和整本书中,我们学到了大量知识。你应该为自己取得的进步感到自豪。

        我建议,在进入最后一章之前,尝试对应用程序进行更改。想出自己的功能想法并尝试构建它们。最终,这是你真正学习的唯一方式。

        在本书的最后一章第十七章**,将学习如何将应用程序部署到 AWS,我们将学习如何将我们的应用程序部署到 Azure 云上的 Linux 和 NGINX。

        第十七章:将应用部署到 AWS

        一旦应用程序最终确定,就必须部署才能使用。我们有许多选择,包括使用我们自己的基础设施。然而,如今,大多数公司更倾向于使用云提供商的服务,以减少与 IT 相关的支出。

        在本章中,我们将学习如何将我们的应用部署到Amazon Web Services。(AWS)当然是云提供商的标准。我们将在 Linux VM 上设置我们的应用服务 Redis、Postgres 和 NGINX。

        在本章中,我们将涵盖以下主要主题:

        • 在 AWS 云上设置 Ubuntu Linux

        • 在 Ubuntu 上设置 Redis、Postgres 和 Node

        • 在 NGINX 上设置和部署我们的应用

        技术要求

        您现在应该对 Web 技术有了扎实的了解。虽然成为高级开发人员可能需要多年时间,但您现在应该对 TypeScript、JavaScript、React、Express 和 GraphQL 感到满意。在本章中,我们将再次使用 Node 和 Visual Studio Code。

        GitHub 存储库再次可在github.com/PacktPublishing/Full-Stack-React-TypeScript-and-Node找到。使用Chap17文件夹中的代码。

        让我们在开发机器上进行一些基本设置:

        1. 创建一个Chap17文件夹,然后从Chap15文件夹的源代码中复制super-forum-serversuper-forum-client文件夹。

        2. 如果node_modulespackage-lock.json被复制过来,那么删除这些文件夹和文件。

        3. 现在,终端进入Chap17/super-forum-server文件夹并运行此命令:

        npm install
        
        1. 现在,终端进入Chap17/super-forum-client文件夹并运行此命令:
        npm install
        

        在 AWS 云上设置 Ubuntu Linux

        在本节中,我们将学习如何在 AWS VM 上选择和设置 Ubuntu Linux 服务器。我假设您已经知道如何创建 AWS 账户。这个过程非常简单,因为现有的 Ubuntu Linux 镜像已经可以使用。让我们开始:

        1. 登录后,此处显示的截图将是当前的 AWS 门户。请注意,这些屏幕经常更改,因此您的视图可能会有所不同:图 17.1 – AWS 门户首页

        图 17.1 – AWS 门户首页

        1. 我们可以看到启动虚拟机链接。选择它,您将进入下一个屏幕:图 17.2 – 初始 VM 屏幕

        图 17.2 – 初始 VM 屏幕

        让我们选择Ubuntu 20.04 LTS的镜像。这是 Ubuntu 的最新长期支持版本。

        1. 一旦选择,您应该看到以下屏幕:图 17.3 – VM 实例类型选择器

        图 17.3 – VM 实例类型选择器

        我已经选择了一个低端的镜像,配备 1 个 vCPU 和 2GB 内存。请注意,EBS 是 AWS 特定的存储性能优化。

        让我们保持默认设置简单,并在做出选择后选择屏幕底部的审阅和启动按钮。

        1. 以下是我选择的主要细节:图 17.4 – 初始配置屏幕

        图 17.4 – 初始配置屏幕

        现在,在底部选择启动按钮继续。

        1. 接下来,您将看到以下提示:图 17.5 – 选择现有密钥对对话框

        图 17.5 – 选择现有密钥对对话框

        此屏幕创建一组用于 SSH 的加密密钥,一个用于您,一个用于 AWS,这样我们就可以远程终端进入 VM。下载这些文件并保持安全。点击启动实例按钮继续。

        警告

        您必须将 pem 文件保存在安全且可访问的位置。您将无法再次下载它。

        1. 完成后,您应该看到启动状态屏幕。只需点击底部的查看实例按钮继续访问门户网站:图 17.6 – VM 设置完成屏幕

        图 17.6 – VM 设置完成屏幕

        1. 这将是您的 VM 实例门户网站:图 17.7 – VM 门户网站

        图 17.7 – VM 门户网站

        1. 点击实例 ID,您将看到实例摘要屏幕:图 17.8 – 实例摘要

        图 17.8 – 实例摘要

        您可以看到一些快速信息,例如运行实例状态、公共 IP 地址和公共 DNS 名称。

        1. 在此屏幕的右上角附近,您将看到连接按钮。单击它以获取连接到实例屏幕:图 17.9 – 连接到实例屏幕

        图 17.9 – 连接到实例屏幕

        第一个选项卡是EC2 实例连接,这是 AWS 为我们提供的终端。单击连接按钮,我们将在浏览器内看到我们的 Ubuntu 服务器终端,如下所示:

        图 17.10 – AWS EC2 实例连接

        图 17.10 – AWS EC2 实例连接

        这是一个可选的界面,如果 SSH 由于某种原因无法工作,我们可以使用它。在本演示中,我将使用 SSH 界面。

        1. 返回到您的连接到实例屏幕,并选择第三个选项卡SSH 客户端。您应该看到类似于这样的内容。当然,您的值将是唯一的:图 17.11 – SSH 如何操作说明

        图 17.11 – SSH 如何操作说明

        1. 这是我在自己的终端上运行这些说明的示例:

        图 17.12 – 第一个 SSH 终端

        图 17.12 – 第一个 SSH 终端

        首先,根据 AWS 的说明更改了本地 pem 文件的权限。然后,我运行了如图所示的 SSH。请注意,我使用ubuntu作为用户名,您的 VM 也应该是相同的,并且我使用了我的服务器的DNS 名称

        注意

        如果这对您不起作用,请尝试打开您的网络入站规则以使 SSH 为任何来源。如果这也不起作用,您还可以恢复使用 AWS 提供的终端,如之前所示。

        这完成了我们对 Ubuntu Linux 的设置。接下来让我们安装 Redis。

        在 Ubuntu 上设置 Redis、Postgres 和 Node

        在本节中,我们将在 Linux 服务器上安装我们的主要要求。我们已经在第十三章中涵盖了 Redis 的设置和配置,使用 Express 和 Redis 设置会话状态,但让我们最后再做一次,因为我们现在都有相同的基础操作系统。

        设置 Redis

        在本节中,我们将安装我们的 Redis 服务器并为我们的应用程序进行配置:

        1. 在您的终端上,登录服务器并运行以下两个命令:
        Apt is a software dependency packaging tool for Linux distributions such as Ubuntu and Debian. Its roughly comparable to NPM. So here, we are updating our apt to the latest version and then using it to install Redis.
        
        1. 安装完成后,像这样打开redis.conf文件:
        sudo nano /etc/redis/redis.conf
        
        1. 找到requirepass条目,取消注释,并添加您自己的密码。

        警告

        源代码文件夹super-forum-server/dev-config/.env中的REDIS_PASSWORD变量的密码必须与您在redis.conf文件中输入的密码匹配。在部署时,我们将稍后包括dev-config文件夹中的文件。

        1. 接下来,找到supervised条目,并将其设置为systemd的值。这允许 Ubuntu 通过其init系统控制 Redis,该系统使用一个名为systemctl的命令。现在,保存并退出。

        2. 现在,让我们重新启动 Redis 服务器以应用新的设置:

        sudo systemctl restart redis.service
        

        如果我们想要停止服务,我们可以运行以下命令:

        sudo systemctl stop redis.service
        

        如果我们想要启动服务,我们运行以下命令:

        sudo systemctl start redis.service
        
        1. 如果您运行此命令,它将显示 Redis 是否正常运行:
        sudo systemctl status redis
        

        您应该看到类似于这样的内容:

        图 17.13 – Redis 状态

        图 17.13 – Redis 状态

        在本节中,我们在我们的 Ubuntu 服务器上安装了 Redis,并打开了根据需要启动和停止服务器的能力。现在我们将继续安装 Postgres。

        设置 Postgres

        现在,让我们为我们的应用安装 Postgres:

        1. 我们将再次使用apt。运行此命令:
        sudo apt install postgresql
        
        1. 让我们通过运行此屏幕截图中显示的命令来检查它是否正常工作:图 17.14 - psql 命令

        图 17.14 - psql 命令

        命令中显示的postgres角色是 Postgres 中默认创建的全局管理员帐户。我们基本上通过在命令中使用-i,使我们登录的 Linux 帐户临时充当postgres帐户。-u表示我们正在使用哪个角色。

        注意

        我们不使用pgAdmin,因为我们可以使用psql命令行工具获得相同的功能,并且在 AWS 上启用pgAdmin很麻烦和困难。

        1. 因此,现在我们正在以postgres@<your ip>用户身份运行,就像屏幕截图中显示的那样。如果我们不是以 Postgres 身份运行,我们需要在任何 Postgres 命令前加上sudo -u postgres前缀。但由于我们正在以 Postgres 的角色运行,我们可以像图 17.13中显示的那样运行命令。

        createuser --interactive命令根据一系列提示创建新用户。运行此命令并按照提示进行回答。

        图 17.15 - createuser

        图 17.15 - createuser

        我已将用户名设置为superforumsvc

        1. 现在,我们将给我们的新用户设置密码,就像这样:图 17.16 - 设置新用户密码

        图 17.16 - 设置新用户密码

        首先,我启用命令行工具psql。然后,我输入一个 SQL 查询来更改superforumsvc用户的密码。

        请注意,我在关键字密码后面截断了末尾,显示密码是什么,但它应该用单引号括起来,就像这样**''**。显然,您会想要创建自己的密码。

        1. 现在,让我们为应用创建数据库。首先,退出psql命令,然后像这样创建数据库:
        superforumsvc role the owner of the new database.
        
        1. 现在让我们将我们的ThreadCategory默认添加到我们的数据库中。在super-forum-server项目中,您会找到utils/InsertThreadCategories.txt文件。其中包含我们一直在使用的Categories。当然,您也可以添加自己的Categories。这是我尝试插入类别的示例:

        图 17.17 - 插入 ThreadCategory

        图 17.17 - 插入 ThreadCategory

        正如您所看到的,前几次失败了。所以,让我们深入研究一下。首先,您必须在正确的数据库上。因此,再次使用\c来执行。请注意,数据库名称区分大小写。然后,请确保您的表和字段名称周围有双引号。对于psql命令行,请不要仅使用pgAdmin

        这就是我们的 Postgres 设置。接下来,让我们设置 Node。

        设置 Node

        现在,让我们安装 Node:

        1. 运行以下命令:
        sudo apt install nodejs 
        
        1. 现在,运行此命令进行检查,您应该看到您的 Node 安装的版本号:
        node -v
        

        您的 Node 版本应该至少是 12 或更高版本。如果不是,您需要运行此命令:

        curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
        

        然后再次运行此命令:

        sudo apt install nodejs
        
        1. 现在,让我们通过运行以下命令来安装 NPM:
        sudo apt install npm
        
        1. 现在,我们需要安装一种管理我们的 Node 服务器的方法,也就是关闭它并自动重新启动它。因此,我们将使用pm2,这是目前管理 Node 最流行的方法之一。请注意,我们使用-g开关全局安装它:
        sudo npm install -g pm2
        

        在本节中,我们回顾了如何设置我们的核心服务依赖项:Redis,Postgres 和 Node。我们现在准备开始使用 NGINX 设置实际服务器。

        设置和部署我们的应用在 NGINX 上

        在本节中,我们将安装和配置我们的应用以使用 NGINX。NGINX 是一个非常受欢迎的高性能 Web 服务器,反向代理和负载均衡器。它以其强大的性能和处理使用多个服务器的站点的不同配置的能力而受到尊重。

        我们将用它来为两个站点提供服务。一个将为我们的 React 客户端提供服务,另一个将为我们的 GraphQL Express 服务器提供服务。我们所有的站点流量都将首先经过 NGINX,然后它将将这些请求重定向到我们应用程序的适当部分。让我们首先安装 NGINX:

        1. SSH 登录到您的服务器,如前面的在 AWS 云上设置 Ubuntu Linux部分所示,并运行以下命令安装 NGINX:
        sudo apt update
        sudo apt install nginx
        
        1. 现在 NGINX 已安装好,让我们创建一个文件夹来存储我们的服务器文件:
        /var/www directory is the default location for web files, as the name implies.
        

        设置超级论坛服务器

        在本节中,我们将为我们的服务器代码创建构建和部署过程。有一个标准化的部署过程是很好的,这样您的部署就会保持一致和可靠:

        1. 在我们开始复制文件之前,我们需要进行一些基本设置和构建我们的服务器项目。在 VSCode 中打开super-forum-server项目。如果您查看package.json文件的 scripts 部分,您会发现我们有一个名为build的新脚本。这将编译我们的服务器代码,并将其适当地打包到dist文件夹中进行分发。现在,为了使此命令起作用,我们需要先在您的开发机器上全局安装一些 NPM 包。在您的开发机器上运行以下命令,而不是 Ubuntu 服务器
        del-cli package is a universal command-line delete command. This means that irrespective of whether your development machine is Linux, Mac, or Windows, this command will work the same. Similarly, the cpy-cli package allows the universal copying of files and folders. We use these commands so that we can have a single NPM script command that will work the same across all developer operating systems.Let's explain this script. The build script first deletes the `dist` folder, so that we start afresh each time. It then copies the contents of `dev-config` into `dist` and separately copies the `.env` file into `dist`. And then, finally, it runs the TypeScript compiler.So, notice we also have a new folder called `dev-config`. This folder will hold configuration-related files that will ultimately be copied into our `dist` folder by the build script. The files in this folder are the `.env` file, for global configuration, the `ormconfig.js` file, for TypeORM configuration, and our `package.json` file. NoteYour `.env` file in the `dev-config` folder must have the working configuration for *your* server. This includes the passwords you are using, the account names, and the IP addresses. They must all be set correctly according to *your* configurations. If you have trouble getting your server to work, this file is the first place to look.
        
        1. 不幸的是,最新的 Express NPM 包似乎存在某种 bug,因此我们需要安装一个额外的 NPM 包依赖。在您的开发机器上运行以下命令:
        @types/express, but we are making sure the latest version is now there. If you want to learn more about this bug, refer to this link: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/47339.
        
        1. 注意一件事。在super-forum-server/src/index.ts文件中,我在文件顶部添加了一个新函数loadEnv。这个文件将使用 Node 的__dirname变量处理.env文件在您的开发和服务器环境之间的相对路径差异。

        我还调整了super-forum-server/dev-config/ormconfig.js文件,以便它使用__dirname作为 TypeORM 实体的路径。

        警告

        我们已经在ormconfig.js中启用了synchronize字段为true。此设置仅用于开发部署。在生产环境中,请勿使用此设置,因为它可能触发不需要的数据库更改。对于生产环境,您应该使用预先制作的数据库,然后直接部署,将synchronize设置为false

        1. 好的。现在让我们尝试运行我们的构建脚本。在您的开发机器上运行以下命令:
        dist folder, as shown in the following screenshot:![Figure 17.18 – The dist folder    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_17.18_B15508.jpg)Figure 17.18 – The dist folder
        
        1. 现在,让我们尝试将我们的dist文件夹复制到我们的服务器上。在您的开发机器终端上,使用适合您的配置运行以下命令:
        pscp.Now, when I run this on my machine, I get the following result:![Figure 17.19 – Attempt dist copy failure    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_17.19_B15508.jpg)Figure 17.19 – Attempt dist copy failureSo, obviously this failed. This failure is due to a lack of permissions on the destination folder on the Ubuntu server. Let's fix this now.
        
        1. 重新登录到您的 Ubuntu SSH 会话,并运行以下命令:
        sudo chmod -R 777 /var/www/superforum/server
        

        此命令将暂时打开所有访问权限,以便我们可以复制我们的文件。在复制完成后,我们将关闭它,以减少安全风险。

        1. 现在,通过使用与您的开发终端相同的scp命令来复制文件。例如,这是我从我的开发机器上运行的命令,在打开权限后:图 17.20 – Scp 复制

        图 17.20 – Scp 复制

        1. 现在,通过查看server文件夹来检查所有配置文件是否已复制到服务器中,如下所示:图 17.21 – 服务器文件夹检查
        scp -i <your pem path> <your path>/.env <username><yourserverpath>/.env
        

        再次说明,对于您的机器,确切的路径将有所不同。

        现在,我们应该使用以下命令再次关闭我们的权限:

        sudo chmod -R 755 /var/www/superforum/server
        

        此权限给予所有者完全访问权限,但只给予其他人执行和读取权限。

        注意

        如果我们陷入安全优化的兔子洞,我们可能会写另一本书。由于这将是一个可能被丢弃的开发服务器,让我们现在专注于主要任务。一旦您准备好使用您的亿美元应用程序进行生产,您将需要对安全性进行一些尽职调查,或者更好的是,雇佣至少有 10 年经验的人。

        1. 现在,在 Ubuntu 服务器上的 SSH 终端会话中,cd 进入/var/www/superforum/server文件夹,然后运行以下命令:
        npm install 
        

        当然,这将安装我们 Node 应用服务器的所有依赖。

        1. 现在,我们需要设置我们的pm2系统,以便它可以控制我们的 Node 服务器。运行此命令:
        pm2 with systemd and start our Node server when restarting the server. Systemd again is our Ubuntu services controller. After running that command, you should see something similar to this:![Figure 17.22 – pm2 startup    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_17.22_B15508.jpg)Figure 17.22 – pm2 startup
        
        1. 因此,请复制并粘贴此命令,从sudo开始,然后在 Ubuntu 服务器的 SSH 会话中运行。运行后,您应该看到类似于这样的东西:图 17.23 – pm2 启动运行的结果

        图 17.23 – pm2 启动运行的结果

        1. 接下来,我们要启动我们的 Node 服务器,如下所示:图 17.24 – Node 服务器已启动

        图 17.24 – Node 服务器已启动

        1. 我们现在可以通过运行此命令将其保存为 pm2 启动列表的一部分:
        pm2 save 
        

        运行后,您应该看到以下内容:

        图 17.25 – pm2 保存运行

        图 17.25 – pm2 保存运行

        通过执行此保存操作,我们的 Node 服务器现在将在服务器重新启动时自动启动。

        在本节中,我们创建了一个过程来构建、部署和启动我们的 Node 服务器。通过配置这个设置,我们可以确保在将来更新我们的代码时可以重复使用。

        设置超级论坛客户端

        好的,现在我们必须为我们的客户端项目执行类似的过程。您应该已经将super-forum-client复制到Chap17文件夹中,因为这是本章开头我们做的第一件事:

        1. 现在,回到您的 Ubuntu 服务器上的 SSH 终端会话,并像这样为客户端项目创建文件夹:
        sudo mkdir /var/www/superforum/client
        
        1. 现在,回到super-forum-client项目文件夹中的开发终端,以便我们可以进行客户端构建和部署。首先,我们需要对我们的项目进行一些微小的调整。您看到我们的服务器项目使用.env文件进行设置。我们不需要任何涉及我们客户端项目的东西。但是,至少我们应该能够根据部署环境的需要设置 GraphQL 服务器 URL。因此,请执行以下步骤:
        • 用 VSCode 打开index.ts,并像这样更新ApolloClient的代码:
        const client = new ApolloClient({
          uri: process.env.REACT_APP_GQL_URL, just like how we did it on our server. But where is this variable coming from? I'll show that now.
        
        • 打开package.json文件并查看脚本部分。您应该看到一个名为build-dev的新脚本,它设置了REACT_APP_GQL_URL变量。随意根据自己的需求创建多个版本的此脚本,具有不同的变量值。
        1. 因此,现在让我们运行build-dev脚本:
        create-react-app, but we tweaked it by adding the environment variable. Once it completes, you should see this folder called REACT_APP_. If this prefix is missing, the variable will be ignored.2\. They are inserted into our code at build time and the value gets deployed as part of our client script code. This means users will be able to search the browser scripts and view this data. Therefore, **never** include sensitive information in these environment variables.
        
        1. 现在,我们只需要暂时打开服务器的客户端文件夹,以便我们可以进行复制。运行以下命令:
        sudo chmod -R 777 /var/www/superforum/client
        
        1. 现在我们可以部署我们的客户端构建文件。从您的开发终端,运行这个命令,当然要用您自己正确的路径:
        scp -i <your pem path> -r <your path>/* <username><yourserverpath>
        

        结果将看起来像这样:

        图 17.27 – 复制客户端文件到服务器

        图 17.27 – 复制客户端文件到服务器

        1. 现在,按照以下步骤撤消权限:
        sudo chmod -R 755 /var/www/superforum/client
        

        配置 NGINX

        好的。我们已经对服务器构建进行了大量配置,现在我们可以继续配置我们安装的 NGINX 服务器:

        1. 我们需要在 Ubuntu 服务器启动系统时启动 NGINX。在您的 SSH 终端上运行所示的命令,然后按照所示进行身份验证:图 17.28 – 启用 NGINX 在系统启动时启动

        图 17.28 – 启用 NGINX 在系统启动时启动

        1. 现在,使用此处显示的status命令检查 NGINX 是否正在运行:图 17.29 – NGINX 状态

        图 17.29 – NGINX 状态

        1. 现在,我们需要在 AWS VM 防火墙上打开端口 80。打开浏览器到 AWS 门户,然后选择安全组,在网络和安全菜单下。然后您会看到这个:图 17.30 – 安全组

        图 17.30 – 安全组

        1. 现在,选择非默认组,您将看到以下截图中显示的屏幕。请注意入站规则在底部附近:图 17.31 – 网络选项卡;添加入站端口规则

        图 17.31 – 网络选项卡;添加入站端口规则

        1. 选择编辑入站规则按钮,然后在下一个屏幕上,单击添加规则按钮。

        选择后,您应该会看到图 17.32中显示的屏幕。添加 HTTP 的新入站规则,如下截图所示:

        图 17.32 – 新的 HTTP 入站规则

        图 17.32 – 新的 HTTP 入站规则

        通过选择0.0.0.0/0作为源,您允许任何 IP 地址,这是我们想要的。现在,点击保存规则按钮保存规则。

        1. 通常,本地 Ubuntu 防火墙未启用。但是,如果启用了,我们还需要让防火墙通过到 NGINX 的流量。如果需要,运行以下命令:
        ec2-3-16-168-210.us-east-2.compute.amazonaws.com, yours will be different and again you can find it on your VM instance screen, you should see this:![Figure 17.34 – Default NGINX load screen    ](https://gitee.com/OpenDocCN/freelearn-node-zh/raw/master/docs/flstk-react-ts-node/img/Figure_17.34_B15508.jpg)Figure 17.34 – Default NGINX load screen
        
        1. 显然,我们的 NGINX 已安装并正在运行。现在我们需要让它提供我们的网站。请注意,NGINX 似乎存在处理非常长的域名的 bug,就像我在 AWS 注册后收到的那样。因此,对于我们的网站,我们将使用 IP 地址。

        NGINX 有两种设置站点的选项。一种允许我们使用/etc/nginx/conf.d文件夹中的配置文件。另一种称为 Server Blocks,使用/etc/nginx/sites-available文件夹。我们将使用conf.d方法。

        运行此命令:

        sudo nano /etc/nginx/conf.d/superforum.conf
        
        1. 现在您的文件应该包含以下内容,再次使用您自己的文件夹路径和域名:图 17.35 – 新的 NGINX 配置文件

        图 17.35 – 新的 NGINX 配置文件

        以下是一些需要注意的事项:

        不要忘记每行结尾的分号。如果没有,将会出现错误。

        server_name是域名或 IP 地址。

        root是包含我们的 HTML 文件的文件夹。

        位置/是我们网站的根目录。

        位置/graphql是我们的 GraphQL 服务器所在的地方。我们使用proxy_pass将调用重定向到http://<domain or ip>/graphql到我们的http://localhost:5000/graphql服务器(我们的 Node 服务器)。

        <prefix>_timeout字段用于防止错误 503 网关超时问题,这在 NGINX 中有时会发生。

        1. 接下来,我们需要通过运行以下命令测试我们的配置更改是否正常:
        sudo nginx -t
        

        您应该会看到这个:

        图 17.36 – NGINX 配置文件状态

        sudo systemctl restart nginx
        
        1. 现在,让我们看看浏览器上的应用程序是否出现。首先,让我们停止我们的 Node 服务器,并重新启动它,而不使用pm2,这样我们就可以看到可能发生的任何错误。在 Ubuntu SSH 终端上运行以下命令:
        pm2 stop index
        node /var/www/superforum/server/index.js
        

        您应该会看到类似于这样的内容:

        图 17.37 – 第一次运行 Node 服务器

        图 17.37 – 第一次运行 Node 服务器

        再次说明,您的 IP 地址将不同,如果更改了路径,可能也会不同。如果看到错误,请稍后查看故障排除部分。

        1. 现在,打开浏览器,转到 AWS 给出的 IP 地址。然后,点击注册按钮,让我们注册一个新用户,如下所示:图 17.38 – 注册新用户测试

        图 17.38 – 注册新用户测试

        根据需要填写值,然后点击注册按钮。您应该会看到类似于这样的内容:

        图 17.39 – 注册成功

        图 17.39 – 注册成功

        1. 现在我们需要确认我们的新用户。在 Ubuntu SSH 终端上运行以下命令:
        sudo -u postgres psql
        \c SuperForum
        Update "Users" set "Confirmed" = true;
        

        让我们确认所有用户都已注册。完成命令后,您应该会看到确认,如下所示:

        图 17.40 – 确认注册用户

        图 17.40 – 确认注册用户

        1. 现在,让我们尝试使用我们的新用户登录:图 17.41 – 登录测试 3 用户

        图 17.41 – 登录测试 3 用户

        1. 当然,目前我们没有数据,所以现在我们将添加一个主题帖,就像这样:

        图 17.42 – 第一篇文章

        图 17.42 – 第一篇文章

        现在,这是我们的主页:

        图 17.43 – 第一篇文章后的主屏幕

        图 17.43 – 第一篇文章后的主屏幕

        就是这样。我们完成了!

        在本节中,我们使用 NGINX 和所有其他服务完成了应用程序的设置。恭喜!您做得非常出色,并且已经通过了大量高度技术性的材料。

        故障排除

        设置和使用云服务可能比仅在自己的网络上使用服务器要复杂得多。以下是处理问题的一些基本提示:

        • 每次更新客户端文件时,都必须重新启动 NGINX。

        • 每次更新服务器文件时,都必须重新启动 Node 服务器。

        • 始终验证您的.env设置是否正确,并与设置过程中选择的名称匹配;例如,您的 Postgres 数据库的名称、用户名和密码。还要确保.env文件的路径正确,并且 Node 服务器正在使用它。

        • 确保PG_ENTITIESPG_ENTITIES_DIR变量具有正确的路径。对于我们当前的应用程序,这将是以下内容:

        PG_ENTITIES="/repo/**/*.*"

        PG_ENTITIES_DIR="/repo"

        如果这些设置不正确,您可能会收到错误消息,比如“找不到的存储库”。

        • 如果您在服务器上编辑.env文件,请确保在部署过程中不会被覆盖。换句话说,不要在服务器上编辑文件!

        • 在更新任何 NGINX 的.conf文件后,始终使用sudo nginx -t命令,然后在配置更改完成后重新启动 NGINX 服务。如果出现错误,请确保所有配置行都以分号结尾。

        • 如果您在开发环境中进行更改并在那里进行测试,请确保已将NODE_ENV环境变量设置为开发。您需要永久设置这个变量,否则它将在重新启动时消失。

        • NGINX 常见的错误是504 网关超时。确保您的超时配置足够。您需要调整它们。

        • 请注意,非常长的域名似乎在 NGINX 中会出现问题。出于测试目的,请查看是否使用 IP 地址有效。如果有效,而域名无效,则您就知道了问题所在。

        摘要

        在本章中,我们通过将应用程序最终部署到云上,巩固了我们对 React、Node 和 GraphQL 的 Web 开发知识。学习如何将应用程序部署到 AWS 云上非常有价值,因为它目前是最受欢迎和广泛使用的云服务。此外,使用 NGINX 是正确的选择,因为 NGINX 在 Node 社区中非常高效和受欢迎。

        非常感谢您加入我的旅程。作为开发人员,总是有新的东西可以学习和尝试。但是,通过了解一些最重要和关键的 Web 技术,您已经迈出了一大步。现在,您拥有了创建真正的、全栈、尖端 Web 应用所需的所有工具。再次恭喜!

        祝您继续成功。

        posted @ 2024-05-23 16:00  绝不原创的飞龙  阅读(36)  评论(0编辑  收藏  举报