React-和-TypeScript3-学习手册-全-

React 和 TypeScript3 学习手册(全)

原文:zh.annas-archive.org/md5/9ec979022a994e15697a4059ac32f487

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React 是由 Facebook 开发的,旨在为其代码库提供更多结构,并使其能够更好地扩展。React 对 Facebook 非常有效,以至于他们最终将其开源。如今,React 是构建前端最流行的 JavaScript 库之一。它允许我们构建小型、隔离的、高度可重用的组件,这些组件可以组合在一起,以创建复杂的前端。

TypeScript 是由微软开发的,旨在帮助开发人员更轻松地构建大型前端应用程序。它是 JavaScript 的超集,为其带来了丰富的类型系统。这种类型系统帮助开发人员及早发现错误,并允许创建工具来稳健地导航和重构代码。

这本书将教会你如何有效地使用这两种技术来创建易于维护的大型复杂前端。

这本书适合谁

这本书主要面向希望使用 React 和 TypeScript 创建大型 Web 应用程序的 Web 开发人员。假定具有 JavaScript 和 HTML 的基本理解。

这本书涵盖了什么

第一章,“TypeScript 基础”,介绍了 TypeScript 类型系统,涵盖了基本类型。它继续介绍了如何配置非常灵活的 TypeScript 编译器。还介绍了代码检查和代码格式化,以及它们的配置。

第二章,“TypeScript 3 有什么新功能”,介绍了 TypeScript 3 版本中引入的重要新功能。元组在本章中占据重要地位,以及与之密切相关的剩余和扩展语法,以及我们如何可以使用这些构造与强类型。在移动到改进设置 React 组件中默认属性值之前,还介绍了有效设置多个相关 TypeScript 项目的方法。

第三章,“使用 React 和 TypeScript 入门”,从如何创建同时使用这两种技术的项目开始。该章介绍了如何以基于类和功能的方式构建强类型 React 组件。管理状态和挂接到生命周期事件也是本章的关键主题。

第四章,使用 React Router 进行路由,介绍了一个可以帮助我们高效创建具有多个页面的应用程序的库。它涵盖了如何创建页面链接,并声明应该呈现的组件。逐步介绍了如何实现路由参数、查询参数和嵌套路由。该章节还涵盖了如何根据需要从路由加载组件,以优化大量大页面的应用程序性能。

第五章,高级类型,专注于 TypeScript 类型。这一次,介绍了更高级但仍然非常有用的类型,如泛型类型、联合类型、重载签名以及 keyof 和查找类型。

第六章,组件模式,介绍了构建 React 组件的一些常见模式,同时仍保持强类型。首先介绍了容器组件,然后是复合组件。流行的渲染道具模式和高阶组件也在本章中介绍。

第七章,使用表单,介绍了如何使用 React 和 TypeScript 高效实现表单。逐步构建了一个通用的表单组件,包括验证和提交。

第八章,React Redux,介绍了这个流行库如何帮助管理应用程序中的状态。使用动作和减速器构建了一个强类型的 Redux 存储。该章节最后讨论了如何使用新的 React 函数在组件中实现 Redux 风格的结构,而无需 Redux。

第九章,与 RESTful API 交互,首先详细介绍了异步代码。然后,该章节继续介绍了如何使用原生 JavaScript 函数以及流行的开源库与 RESTful API 进行交互。

第十章,与 GraphQL API 交互,首先介绍了读取和写入数据的语法。该章节涵盖了如何使用 HTTP 库与 GraphQL 服务器进行交互,然后转向使用一个流行的专门构建的库。

第十一章,使用 Jest 进行单元测试,介绍了如何测试纯函数和 React 组件。还介绍了一个流行的开源库,用于在组件内部重构时使测试更加灵活。还介绍了 Jest 的一些出色功能,如快照测试、模拟和代码覆盖率。

答案,包含本书各章节练习的答案。

要充分利用本书

您需要了解 JavaScript 的基础知识,包括以下内容:

  • 了解一些原始 JavaScript 类型,如字符串、数字、布尔值、null 和 undefined

  • 如何创建变量并引用它们,包括数组和对象

  • 如何创建函数并调用它们的理解

  • 如何使用 if 和 else 关键字创建条件语句的理解

您需要了解 HTML 的基础知识,包括以下内容:

  • 了解基本的 HTML 标签,如 div、ul、p、a、h1 和 h2,以及如何将它们组合在一起创建网页

  • 如何引用 CSS 类来为 HTML 元素设置样式的理解

了解基本的 CSS 也有帮助,但不是必需的:

  • 如何设置元素的大小并包括边距和填充

  • 如何定位元素

  • 如何给元素上色

您需要在计算机上安装以下技术:

  • Google Chrome:可以在www.google.com/chrome/上安装。

  • Node.js 和 npm:本书中都会用到它们。您可以在nodejs.org/en/download/上安装它们。如果您已经安装了它们,请确保 Node.js 至少是 8.2 版本,npm 至少是 5.2 版本。

  • TypeScript:可以通过在终端中输入以下命令来安装它:

npm install -g typescript
  • Visual Studio Code:您需要它来编写 React 和 TypeScript 代码。可以从code.visualstudio.com/安装。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 请登录或在www.packt.com注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • Linux 系统使用 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-React-with-TypeScript-3。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789610253_ColorImages.pdf

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“让我们在项目的根目录下创建一个名为tsconfig.json的新文件。”

代码块设置如下:

import * as React from "react";

const App: React.SFC = () => {
  return <h1>My React App!</h1>;
};

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

interface IProps {
 title: string;
  content: string;
  cancelCaption?: string;
 okCaption?: string;
}

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

cd my-components
npm install tslint tslint-react tslint-config-prettier --save-dev

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。以下是一个例子:“我们需要点击“安装”选项来安装扩展。”

警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。

第一章:TypeScript 基础知识

Facebook 已经成为一个非常受欢迎的应用程序。随着其受欢迎程度的增加,对新功能的需求也在增加。React 是 Facebook 的答案,可以帮助更多的人在代码库上工作并更快地交付功能。React 对 Facebook 来说效果非常好,他们最终将其开源。如今,React 是一个成熟的用于构建基于组件的前端的库,非常受欢迎,并拥有庞大的社区和生态系统。

TypeScript 也是一个受欢迎的成熟库,由一家大公司——微软维护。它允许用户向他们的 JavaScript 代码添加强类型,帮助他们在大型代码库中更加高效。

本书将教您如何使用这两个令人惊叹的库来构建易于维护的强大前端。本书的前几章将专注于 TypeScript。然后,您将开始学习 React 以及如何使用具有强类型的 TypeScript 组件来构建强大的前端。

在本章中,我们将介绍 TypeScript 与 JavaScript 的关系以及它带来的好处。因此,基本的 JavaScript 理解是必需的。我们还将介绍您在编写面向浏览器的代码时通常会使用的 TypeScript 基础知识。

您将了解到在构建前端时使用 TypeScript 的必要性,以及 TypeScript 真正擅长的项目类型。您还将了解如何将您的 TypeScript 代码转译为 JavaScript,以便在浏览器中运行。最后但同样重要的是,您将学习如何对您的 TypeScript 代码执行额外的检查,使其更易读和易维护。

在本章结束时,您将准备好开始学习如何使用 TypeScript 构建具有 React 的前端。

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

  • 理解 TypeScript 的好处

  • 理解基本类型

  • 创建接口、类型别名和类

  • 将代码结构化为模块

  • 配置编译

  • TypeScript 代码检查

  • 代码格式化

技术要求

在本章中,我们将使用以下技术:

  • TypeScript playground:这是一个网站,网址为www.typescriptlang.org/play/,允许您在不安装 TypeScript 的情况下玩耍并了解其特性。

  • Node.jsnpm:TypeScript 和 React 依赖于这些。您可以在nodejs.org/en/download/安装它们。如果您已经安装了这些,请确保 Node.js 至少是 8.2 版本,npm至少是 5.2 版本。

  • TypeScript:可以通过npm安装这个。在终端中输入以下命令:

npm install -g typescript

本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/01-TypeScriptBasics

理解 TypeScript 的好处

当 JavaScript 代码库增长时,阅读和维护可能变得困难。TypeScript 是 JavaScript 的扩展,添加了静态类型。TypeScript 编译器读取包含类型信息的 TypeScript 代码,并生成干净、可读的 JavaScript,其中包含转换和删除的类型信息。编译后的代码可以在我们喜爱的浏览器和 Node.js 中运行。

TypeScript 相对于 JavaScript 提供了几个优点:

  • 编码错误可以在开发过程中更早地被捕捉到

  • 静态类型允许构建工具,提高开发者的体验和生产力

  • 尚未在所有浏览器中实现的 JavaScript 功能实际上可以在针对这些浏览器的应用程序中使用

我们将在接下来的章节中详细介绍这些要点。

早期捕捉编码错误

类型信息帮助 TypeScript 编译器在用户遇到错误之前捕捉到错误和拼写错误。在诸如 Visual Studio Code 之类的代码编辑器中,用户一旦出错,错误就会立即用红色下划线标出。例如,创建一个名为utils.js的文件,并粘贴以下代码,用于计算订单行上的总价:

function calculateTotalPrice(product, quantity, discount) {
  var priceWithoutDiscount = product.price * quantity;
  var discountAmount = priceWithoutDiscount * discount;
  return priceWithoutDiscount - discountAmount;
}

代码中存在一个错误,可能很难发现。如果我们在 Visual Studio Code 中打开文件,不会有错误被突出显示。如果我们将文件扩展名更改为.ts,Visual Studio Code 会立即用红色下划线标出需要我们注意的代码部分:

大多数错误都是 TypeScript 要求一些类型信息。因此,让我们为我们的代码添加一些类型:

interface IProduct {
 name: string;
 unitPrice: number;
}

function calculateTotalPrice(product: IProduct, quantity: number, discount: number): number {
  var priceWithoutDiscount: number = product.price * quantity;
  var discountAmount: number = priceWithoutDiscount * discount;
  return priceWithoutDiscount - discountAmount;
}

如果你不理解我们刚刚添加的内容,不要担心;我们将在下一节中介绍类型。关键点是,我们现在有一个突出显示的错误,实际上就是 bug:

bug 是我们的函数引用了产品对象中不存在的price属性。我们应该引用的属性是unitPrice

更好的开发者体验和生产力

让我们通过将price重命名为unitPrice来修复上一节中的错误。请注意,Visual Studio Code 给我们提供了智能感知列表unitPrice作为一个选项,因为它查看了我们的类型定义:

在这里,TypeScript 和 Visual Studio Code 使用类型为我们提供更好的编写体验。除了智能感知外,我们还提供了代码导航功能,以及在多个文件中安全重命名函数和变量。这些功能提高了我们的生产力,特别是当代码库很大并且有一个团队在上面工作时。

使用未来的 JavaScript 功能

TypeScript 还有另一个重要的好处需要理解。TypeScript 允许我们使用一些 JavaScript 中尚未被所有浏览器采用的功能,但仍然可以针对这些浏览器。TypeScript 通过将这些功能的使用转译为目标浏览器支持的 JavaScript 代码来实现这一点。

例如,让我们看一下 ES7 中的幂运算符(**),它在 IE 中不受支持。让我们创建一个名为future.ts的文件,并输入以下代码:

var threeSquared: number = 3 ** 2;
console.log(threeSquared);

当我们在浏览器中运行程序时,它应该将9放入控制台。在这之前,让我们运行代码对 TypeScript 编译器进行转译成 JavaScript。在与future.ts相同的目录中的终端中运行以下命令:

tsc future

这应该生成一个名为future.js的文件,内容如下:

var threeSquared = Math.pow(3, 2);
console.log(threeSquared);

因此,TypeScript 将幂运算符转换为对Math.pow函数的调用,这在 IE 中是支持的。为了确认这一点,将生成的 JavaScript 代码粘贴到 IE 的控制台中,输出应该是9

这个例子故意简单,但可能并不那么有用。Async/awaitspread操作符、rest参数和箭头函数是远比 IE 不支持但 TypeScript 允许使用的更有用的功能。如果你不知道上一句中的功能是什么,不要担心,因为我们需要时会介绍它们。

理解基本类型

我们在上一节中提到了类型。在本节中,我们将介绍在 TypeScript 中常用的基本类型,以便开始理解我们应该在每种类型中使用哪些情况。我们将大量使用在线 TypeScript 播放器,所以确保准备好。

基本类型

在理解我们如何在 TypeScript 中声明带有类型的变量和函数之前,让我们简要地看一下基本类型,这些是最基本的类型。基本类型是没有属性的简单值。TypeScript 与 JavaScript 共享以下基本类型:

  • string:表示 Unicode 字符序列

  • number:表示整数和浮点数

  • 布尔值:表示逻辑真或假

  • undefined:表示尚未初始化的值

  • null:表示没有值

类型注释

JavaScript 变量的类型是在运行时确定的。JavaScript 变量的类型也可以在运行时更改。例如,一个保存数字的变量可以后来被替换为字符串。通常,这是不希望的行为,可能导致我们应用程序中的错误。

TypeScript 注释允许我们在编写代码时声明具体类型的变量。这允许 TypeScript 编译器在代码执行运行时检查代码是否遵守这些类型。简而言之,类型注释允许 TypeScript 在我们编写代码时更早地捕捉到代码使用错误类型的错误。

TypeScript 注释允许我们使用:Type语法声明带有类型的变量。

  1. 让我们浏览到 TypeScript 播放器,并将以下变量声明输入到左侧窗格中:
let unitPrice: number;
  1. 转译后的 JavaScript 将显示在右侧,如下所示:
var unitPrice;

let已被转换为var。这是因为播放器使用的编译器设置为针对广泛的浏览器,其中一些不支持let。另外,请注意类型注释已经消失。这是因为 JavaScript 中不存在类型注释。

  1. 让我们在我们的程序中添加第二行:
unitPrice = "Table";

注意到unitPrice下面出现了一条红线,如果你将鼠标悬停在上面,你会得到正确的信息,即存在类型错误:

  1. 您还可以使用相同的:Type语法为返回值的函数参数添加类型注释。让我们在 playground 中输入以下函数:
function getTotal(unitPrice: number, quantity: number, discount: number): number {
  const priceWithoutDiscount = unitPrice * quantity;
  const discountAmount = priceWithoutDiscount * discount;
  return priceWithoutDiscount - discountAmount;
}

我们已经声明了unitPricequantitydiscount参数,都是数字。return类型注释在函数的括号后面,前面的例子中也是一个数字。

我们在不同的例子中使用了constlet来声明变量。let允许变量在声明后改变值,而const变量则不能改变。在前面的函数中,priceWithoutDiscountdiscountAmount在初始赋值后不会改变值,所以我们使用了const

  1. 让我们用一个不正确的类型调用我们的函数,并将结果赋给一个不正确的类型的变量:
let total: string = getTotal(500, "one", 0.1);

我们发现one被红色下划线标出,突出显示存在类型错误:

  1. 如果我们将one更正为1total应该被红色下划线标出,突出显示存在类型问题:

TypeScript 编译器使用类型注释来检查分配给变量和函数参数的值是否对其类型有效。

这种强类型检查是 JavaScript 中所没有的,它在大型代码库中非常有用,因为它帮助我们立即检测到类型错误。

类型推断

我们已经看到类型注释是非常有价值的,但它们涉及大量额外的输入。幸运的是,TypeScript 强大的类型推断系统意味着我们不必一直提供注释。当我们立即设置变量值时,我们可以使用类型推断。

让我们看一个例子:

  1. 让我们在 TypeScript playground 中添加以下变量赋值:
let flag = false;
  1. 如果我们将鼠标悬停在flag变量上,我们可以看到 TypeScript 已经推断出类型为boolean

  1. 如果我们在下面再添加一行,错误地将flag设置为Table,我们会得到一个类型错误:

因此,当我们声明一个变量并立即设置其类型时,我们可以使用类型推断来节省一些按键。

任意

如果我们声明一个没有类型注释和没有值的变量会怎么样?TypeScript 会推断出什么类型?让我们在 TypeScript playground 中输入以下代码并找出:

let flag;

如果我们将鼠标悬停在flag上,我们会看到它被赋予了any类型:

因此,TypeScript 编译器给了一个没有类型注释和没有立即赋值的变量,any类型。any类型是特定于 TypeScript 的;它在 JavaScript 中不存在。这是一种选择不对特定变量进行类型检查的方式。它通常用于动态内容或来自第三方库的值。然而,TypeScript 日益强大的类型系统意味着我们这些天需要更少地使用any

Void

void是另一种在 JavaScript 中不存在的类型。它通常用于表示一个不返回任何东西的函数。

让我们来看一个例子:

  1. 让我们在 TypeScript playground 中输入以下函数:
function logText(text: string): void {
  console.log(text);
}

该函数只是简单地将一些文本记录到控制台中,并且不返回任何东西。因此,我们将返回类型标记为void

  1. 如果我们删除返回类型注释并悬停在函数名logText上,我们会看到 TypeScript 已经推断出类型为void

这在编写不返回任何东西的函数时为我们节省了一些按键。

Never

never类型表示永远不会发生的事情,通常用于指定代码中不可达的区域。同样,这在 JavaScript 中不存在。

现在是一个例子的时间:

  1. 在 TypeScript playground 中输入以下代码:
function foreverTask(taskName: string): never {
  while (true) {
    console.log(`Doing ${taskName} over and over again ...`);
  }
}

该函数调用一个无限循环并且永远不会返回,因此我们给它一个never类型的类型注释。这与void不同,因为 void 表示它将返回,但没有值。

在前面的例子中,我们使用了 JavaScript 模板文字来构造要记录到控制台的字符串。模板文字由反引号括起来。

function foreverTask(taskName: string): never {

while (true) {

console.log(一遍又一遍地做${taskName}...);

break;

}

}


The TypeScript compiler quite rightly complains:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/5d906286-d584-45cd-9574-abe194697c9f.png)

3.  Let's now remove the `break` statement and the `never` type annotation. If we hover over the `foreverTask` function name with our mouse, we see that TypeScript has inferred the type to be `void`, which is not what we want in this example:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/fad95219-2e7b-4f52-868c-f8c7d4e0f348.png)

The `never` type is useful in places where the code never returns. However, we will probably need to explicitly define the `never` type annotation because the TypeScript compiler isn't smart enough yet to infer that.

# Enumerations

Enumerations allow us to declare a meaningful set of friendly names that a variable can be set to. We use the `enum` keyword, followed by the name we want to give to it, followed by the possible values in curly braces.

Here's an example:

1.  Let's declare an `enum` for order statuses in the TypeScript playground:

枚举 OrderStatus {

已支付,

已发货,

已完成,

取消

}


2.  If we look at the transpiled JavaScript, we see that it looks very different:

var OrderStatus;

(function (OrderStatus) {

OrderStatus[OrderStatus["Paid"] = 1] = "Paid";

OrderStatus[OrderStatus["Shipped"] = 2] = "Shipped";

OrderStatus[OrderStatus["Completed"] = 3] = "Completed";

OrderStatus[OrderStatus["Cancelled"] = 4] = "Cancelled";

})(OrderStatus || (OrderStatus = {}));


This is because enumerations don't exist in JavaScript, so the TypeScript compiler is transpiling the code into something that does exist.

3.  Let's declare a `status` variable, setting the value to the `shipped` status:

让状态 = OrderStatus.Shipped;


Notice how we get nice IntelliSense when typing the value:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/c0622af4-14b0-458e-9000-e13bab5ece8c.png)

4.  By default, the numerical values start from `0` and increment. However, the starting value can be explicitly declared in the `enum`, as in the following example, where we set `Paid` to `1`:

枚举订单状态 {

已付款 = 1,

已发货,

完成,

取消

}


5.  Let's set our `status` variable to the shipped status and log this to the console:

让状态 = OrderStatus.Shipped;

console.log(status);


If we run the program, we should see 2 output in the console:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/769c4a7b-e932-4c2d-aa61-9c212d990806.png)

6.  In addition, all the values can be explicitly declared, as in the following example:

枚举订单状态 {

已付款 = 1,

已发货 = 2,

完成 = 3,

取消 = 0

}


Enumerations are great for data such as a status that is stored as a specific set of integers but actually has some business meaning. They make our code more readable and less prone to error.

# Objects

The object type is shared with JavaScript and represents a non-primitive type. Objects can contain typed properties to hold bits of information.

Let's work through an example:

1.  Let's enter the following code into the TypeScript playground, which creates an object with several properties of information:

const customer = {

名称:"灯具有限公司",

营业额:2000134,

活跃:true

};


If we hover over `name`, `turnover`, and `active`, we'll see that TypeScript has smartly inferred the types to be `string`, `number`, and `boolean` respectively.

2.  If we hover over the `customer` variable name, we see something interesting:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/c6e0e7db-5c1c-49ac-896f-f39a28d79730.png)

3.  Rather than the type being `object`, it is a specific type with `name`, `turnover`, and `active` properties. On the next line, let's set the `turnover` property to some other value:

客户营业额 = 500000;


As we type the turnover property, IntelliSense provides the properties that are available on the object:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/3bce2651-60e6-4992-9238-86fe107cf2f5.png)

We used `const` to declare the `customer` variable and then was able to change one of its property values later in the program. Shouldn't this have thrown an error? Well, the customer variable reference hasn't changed — just some properties within it. So, this is fine with the TypeScript compiler.

4.  This line of code is perfectly fine, so we don't get any complaints from the compiler. If we set the `turnover` to a value that has an incorrect type, we'll be warned as we would expect:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/1f089aaa-2e42-4ec7-b030-d16f55f1c4b7.png)

5.  Now let's set a property on `customer` that doesn't exist yet:

客户利润 = 10000;


We'll see that TypeScript complains:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/d006bad6-416a-493b-981d-871147cb7938.png)

This makes sense if we think about it. We've declared `customer` with `name`, `turnover`, and `active` properties, so setting a `profit` property should cause an error. If we wanted a `profit` property, we should have declared it in the original declaration.

In summary, the `object` type is flexible because we get to define any properties we require, but TypeScript will narrow down the type to prevent us incorrectly typing a property name.

# Arrays

Arrays are structures that TypeScript inherits from JavaScript. We add type annotations to arrays as usual, but with square brackets at the end to denote that this is an array type.

Let's take a look at an example:

1.  Let's declare the following array of numbers in the TypeScript playground:

const numbers: number[] = [];


Here, we have initialized the array as empty.

2.  We can add an item to the array by using the array's `push` function. Let's add the number `1` to our array:

numbers.push(1);


We used `const` to declare the `numbers` variable and was able to change its array elements later in the program. The array reference hasn't changed – just the elements within it. So, this is fine with the TypeScript compiler.

3.  If we add an element with an incorrect type, the TypeScript compiler will complain, as we would expect:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/e45056fd-2fe2-4cfe-8524-f39b2ae21fdd.png)

4.  We can use type inference to save a few keystrokes if we declare an array with some initial values. As an example, if we type in the following declaration and hover over the `numbers` variable, we'll see the type has been inferred as `number[]`.

const numbers = [1, 3, 5];


5.  We can access an element in an array by using the element number in square brackets. Element numbers start at `0`.

Let's take an example:

1.  Let's log out the number of elements under the numbers variable declaration, as follows:

console.log(numbers[0]);

console.log(numbers[1]);

console.log(numbers[2]);


2.  Let's now click the Run option on the right-hand side of the TypeScript playground to run our program. A new browser tab should open with a blank page. If we press *F12* to open the Developer tools and go to the console section, we'll see 1, 3, and 5 output to the console.

3.  There are several ways to iterate through elements in an array. One option is to use a `for` loop, as follows:

对于(让 i 在数字中){

console.log(numbers[i]);

}


If we run the program, we'll see 1, 3, and 5 output to the console again.

4.  Arrays also have a useful function for iterating through their elements, called `forEach`. We can use this function as follows:

numbers.forEach(function (num) {

console.log(num);

});


5.  `forEach` calls a nested function for each array element, passing in the array element. If we hover over the `num` variable, we'll see it has been correctly inferred as a `number`. We could have put a type annotation here, but we have saved ourselves a few keystrokes:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/c63c876d-8896-4227-867d-f292fd967726.png)

Arrays are one of the most common types we'll use to structure our data. In the preceding examples, we've only used an array with elements having a number type, but any type can be used for elements, including objects, which in turn have their own properties.

# Creating interfaces, types aliases, and classes

In the *Understanding basic types* section, we introduced ourselves to objects, which are types that can have their own properties. Interfaces, type aliases, and classes are ways that we can define an object structure before we start using it.

Following here is the `customer` object we worked with, where we declared the `customer` variable with an initial object value:

const customer = {

名称:"灯具有限公司",

营业额:2000134,

活跃:true

};


1.  Let's try to declare the customer variable and set its value on a subsequent line:

让客户:对象;

客户 = {

名称:"灯具有限公司",

营业额:2000134,

活跃:true

};


2.  So far, so good. However, let's see what happens when we try to change the customers `turnover` value:

客户营业额 = 2000200;


3.  The lack of IntelliSense when we type `turnover` isn't what we are used to. When we've finished typing the line, we get a compiler error:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/7e69d3b3-6dbf-4e19-874b-146b8a5d60e8.png)

The TypeScript compiler doesn't know about the properties in the `customer` object and so thinks there's a problem.

So, we need another way of defining an object structure with the ability to set property values later in the program. That's where interfaces, type aliases, and classes come in; they let us define the structure of an object by letting us define our own types.

# Interfaces

An interface is a contract that defines a type with a collection of property and method definitions without any implementation. Interfaces don't exist in JavaScript, so they are purely used by the TypeScript compiler to enforce the contract by type checking.

We create an interface with the `interface` keyword, followed by its name, followed by the bits that make up the `interface` in curly braces:

产品接口 {

...

}


# Properties

Properties are one of the elements that can be part of an interface. Properties can hold values associated with an object. So, when we define a property in an interface, we are saying that objects that implement the interface must have the property we have defined.

Let's start to play with an interface in the TypeScript playground:

1.  Enter the following interface:

产品接口 {

名称:字符串;

单价:数字;

}


2.  The preceding example creates a `Product` interface with `name` and `unitPrice` properties. Let's go on to use this interface by using it as the type for a `table` variable:

const table: 产品 = {

名称:"桌子",

单价:500

}


3.  Let's try to set a property that doesn't exist in the interface:

const chair: 产品 = {

产品名称:"桌子",

价格:70

}


As expected, we get a type error:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/21c3b0c0-bab7-46c6-b4dd-157ad1354b35.png)

4.  Properties on an interface can reference another interface because an interface is just a type. The following example shows an `OrderDetail` interface making use of a `Product` interface:

产品接口 {

名称:字符串;

单价:数字;

}

订单详情接口 {

产品:产品;

数量:数字;

}

const table: 产品 = {

名称:"桌子",

单价:500

}

const tableOrder: 订单详情 = {

产品:桌子,

数量:1

};


This gives us the flexibility to create complex object structures, which is critical when writing large, complex apps.

# Method signatures

Interfaces can contain method signatures as well. These won't contain the implementation of the method; they define the contracts for when interfaces are used in an implementation.

Let's look at an example:

1.  Let's add a method to the `OrderDetail` interface we just created. Our method is called `getTotal` and it has a `discount` parameter of type `number` and returns a `number`:

订单详情接口 {

产品:产品;

数量:数字;

获取总数(折扣:数字):数字;

}


Notice that the `getTotal` method on the interface doesn't specify anything about how the total is calculated – it just specifies the method signature that should be used.

2.  Having adjusted our `OrderDetail` interface, our `tableOrder` object, which implemented this interface, will now be giving a compilation error. So, let's resolve the error by implementing `getTotal`:

const tableOrder: 订单详情 = {

产品:桌子,

数量:1,

获取总数(折扣:数字):数字 {

const priceWithoutDiscount = this.product.unitPrice *

这个数量;

const discountAmount = priceWithoutDiscount * discount;

返回 priceWithoutDiscount - discountAmount;

}

};


Notice that the implemented method has the same signature as in the `OrderDetail` interface.

The method implementation uses the `this` keyword to get access to properties on the object. If we simply referenced `product.unitPrice` and `quantity` without `this`, we would get a compilation error, because TypeScript would assume these variables are local within the method.

3.  Let's tweak the method signature to discover what we can and can't do. We'll start by changing the parameter name:

获取总数(折扣百分比:数字):数字 {

const priceWithoutDiscount = this.product.unitPrice *

这个数量;

const discountAmount = priceWithoutDiscount *

折扣百分比;

返回 priceWithoutDiscount - discountAmount;

}


4.  We'll see that we don't get a compilation error. Let's change the method name now:

总数(折扣百分比:数字):数字 {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discountPercentage;

返回 priceWithoutDiscount - discountAmount;

}


5.  This does cause an error because a `total` method doesn't exist on the `OrderDetail` interface:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/d383f245-e341-45d1-b6bb-33d726f27e64.png)

6.  We could try changing the return type:

const tableOrder: 订单详情 = {

产品:桌子,

数量:1,

获取总数(折扣百分比:数字):字符串 {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discountPercentage;

返回(priceWithoutDiscount - discountAmount)。toString();

}

};


This actually doesn't produce a compilation error in the TypeScript playground, but it should do!

7.  So, let's use Visual Studio Code for this example. After we've opened Visual Studio Code in a folder of our choice, let's create a file called `interfaces.ts` and paste in the interface definitions for the `Product` and `OrderDetail` interfaces, along with the `table` variable declaration.
8.  We can then enter the preceding implementation of the `OrderDetail` interface. As expected, we get a compilation error:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/0b00ed1a-ea34-4688-b4c7-30386fb6a7d5.png)

9.  Changing the parameter type also results in a compilation error:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/290762cb-1585-4daa-aca8-46972adbec6b.png)

The errors provided by TypeScript are fantastic—they are very specific about where the problem is, allowing us to quickly correct our mistakes.

10.  So, when implementing a method from an interface, the parameter names aren't important, but the other parts of the signature are. In fact, we don't even need to declare the parameter names in the interface:

订单详情接口 {

...

获取总数(数字):数字;

}


However, omitting the parameter names arguably makes the interface harder to understand—how do we know exactly what the parameter is for?

# Optional properties and parameters

We might want to make a property optional because not every situation where the interface is implemented requires it. Let's take the following steps in our `OrderDetail` interface:

1.  Let's create an optional property for the date it was added. We specify an optional value by putting a `?` at the end of the property name but before the type annotation:

订单详情接口 {

产品:产品;

数量:数字;

dateAdded?:日期,

getTotal(折扣:数字):数字;

}


We'll see that our implementation of this interface, `tableOrder`, isn't broken. We can choose to add `dateAdded` to `tableOrder` but it isn't required.

2.  We might also want to make a method parameter optional. We do this in a similar way by putting a `?` after the parameter name. In our example, let's make `discount` optional in the `OrderDetail` interface:

接口 OrderDetail {

产品:产品;

数量:数字;

dateAdded?:日期,

getTotal(折扣?:数字):数字;

}


3.  We can change the method implementation signature as well:

getTotal(折扣?:数字):数字{

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount *(折扣|| 0);

返回无折扣价格 - 折扣金额;

}


We've also dealt with the case when a discount isn't passed into the method by using `(discount || 0)` in the `discountAmount` variable assignment.

`x || y` is shorthand for *if x is* truthy *then use x, otherwise, use y*. The following values are falsy values: `false`, `0`, `""`, `null`, `undefined`, and `NaN`. All other values are truthy.

4.  With our optional parameter in place, we can call `getTotal` without passing a value for the discount parameter:

tableOrder.getTotal()


The preceding line doesn't upset the TypeScript compiler.

# Readonly properties

We can stop a property from being changed after it has initially been set by using the `readonly` keyword before the property name.

1.  Let's give this a try on our `Product` interface by making the `name` property `readonly`:

接口产品{

只读名称:字符串;

单价:数字;

}


2.  Let's also make sure we have an instance of the `Product` interface in place:

const table:产品= {

名称:"桌子",

单价:500

};


3.  Let's change the `name` property `table` now on the next line:

table.name = "更好的桌子";


As expected, we get a compilation error:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/ef683144-77d7-4cac-b646-244090f50f9e.png)

`readonly` properties are a simple way of freezing their values after being initially set. A common use case is when you want to code in a functional way and prevent unexpected mutations to a property.

# Extending interfaces

Interfaces can *extend* other interfaces so that they inherit all the properties and methods from its parent. We do this using the `extends` keyword after the new interface name and before the interface name that is being extended.

Let's look at the following example:

1.  We create a new interface, taking `Product` as a base, and add information about discount codes:

接口产品{

名称:字符串;

单价:数字;

}

接口折扣码 {

代码:字符串;

百分比:数字;

}

带折扣码的产品接口扩展产品 {

折扣码:DiscountCode[];

}


2.  We can create an instance of the interface in the usual way, filling in properties from the base interface as well as the child interface:

const table:带折扣码的产品= {

名称:"桌子",

单价:500,

折扣码:[

{代码:"SUMMER10",百分比:0.1},

{代码:"BFRI",百分比:0.2}

]

};


Interfaces allow us to create complex but flexible structured types for our TypeScript program to use. They are a really important feature that we can use to create a robust, strongly-typed TypeScript program.

# Type aliases

In simple terms, a type alias creates a new name for a type. To define a type alias, we use the `type` keyword, followed by the alias name, followed by the type that we want to alias.

We'll explore this with the following example:

1.  Let's create a type alias for the `getTotal` method in the `OrderDetail` interface we have been working with. Let's try this in the TypeScript playground:

类型获取总数=(折扣:数字)=> 数字;

接口 OrderDetail {

产品:产品;

数量:数字;

getTotal:GetTotal;

}


Nothing changes with objects that implement this interface – it is purely a way we can structure our code. It arguably makes the code a little more readable.

2.  Type aliases can also define the shape of an object. We could use a type alias for our `Product` and `OrderDetail` types that we previously defined with an interface:

类型产品= {

名称:字符串;

单价:数字;

};

类型 OrderDetail = {

产品:产品;

数量:数字;

getTotal:(折扣:数字)=> 数字;

};


3.  We use these types in exactly the same way as we used our interface-based types:

const table:产品= {

名称:"桌子",

单价:500

};

const orderDetail:OrderDetail = {

产品:桌子,

数量:1,

getTotal(折扣:数字):数字{

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount *折扣;

返回无折扣价格 - 折扣金额;

}

};


So, type aliases seem very similar to interfaces. What is the difference between a type alias and an interface? The main difference is that type aliases can't be extended or implemented from like you can with interfaces. So, for a simple structure that doesn't require inheritance, should we use an interface or should we use a type alias? There isn't strong reasoning to prefer either approach. However, we should be consistent with whichever approach we choose to improve the readability of our code.

# Classes

Classes feature in many programming languages, including JavaScript. They let us shape objects with type annotations in a similar way to interfaces and type aliases. However, classes have many more features than interfaces and type aliases, which we'll explore in the following sections.

# Basic classes

Classes have lots of features. So, in this section we'll look at the basic features of a class. We use the `class` keyword followed by the class name, followed by the definition of the class.

Let's look at this in more depth with the following example:

1.  We could use a class to define the `Product` type we previously defined as an interface and as a type alias:

类产品{

名称:字符串;

单价:数字;

}


2.  We create an instance of our `Product` class by using the `new` keyword followed by the class name and parentheses. We then go on to interact with the class, setting property values or calling methods:

const table = new Product();

table.name = "桌子";

table.unitPrice = 500;


Notice that when we use this approach we don't need a type annotation for the table variable because the type can be inferred.

Classes have many more features than type aliases and interfaces though. One of these features is the ability to define the implementation of methods in a class.

Let's explore this with an example:

1.  Let's change the `OrderDetail` type we have been working within previous sections to a class. We can define the implementation of the `getTotal` method in this class:

类 OrderDetail {

产品:产品;

数量:数字;

getTotal(折扣:数字):数字{

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount *折扣;

返回无折扣价格 - 折扣金额;

}

}


2.  We can create an instance of `OrderDetail`, specifying a `product` and `quantity`, and then calling the `getTotal` method with a `discount` to get the total price:

const table = new Product();

table.name = "桌子";

table.unitPrice = 500;

const orderDetail = new OrderDetail();

orderDetail.product = table;

orderDetail.quantity = 2;

总数= orderDetail.getTotal(0.1);

console.log(总数);


If we run this and look at the console, we should see an output of `900`.

# Implementing interfaces

We can use classes and interfaces together by defining the contract in an interface and then implementing the class as per the interface. We specify that a class is implementing a particular interface using the `implements` keyword.

As an example, we can define an interface for the order detail and then a class that implements this interface:

接口 IOrderDetail {

产品:产品;

数量:数字;

getTotal(折扣:数字):数字;

}

类 OrderDetail 实现 IOrderDetail {

产品:产品;

数量:数字;

getTotal(折扣:数字):数字{

const priceWithoutDiscount = this.product.unitPrice *

这个数量;

const discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

}

}


In the preceding example, we've prefixed the interface with `I` so that readers of the code can quickly see when we are referencing interfaces.

Why would we use this approach? It seems like more code than we need to write. So, what's the benefit? This approach allows us to have multiple implementations of an interface, which can be useful in certain situations.

# Constructors

Constructors are functions that perform the initialization of new instances of a class. In order to implement a constructor, we implement a function called `constructor`. It's common to set property values in the constructor to simplify consumption of the class.

Let's look at the following example:

1.  Let's create a constructor in the `OrderDetail` class that allows us to set the `product` and `quantity`:

class OrderDetail implements IOrderDetail {

product: Product;

quantity: number;

constructor(product: Product, quantity: number) {

this.product = product;

this.quantity = quantity;

}

getTotal(discount: number): number {

...

}

}


2.  If we create an instance of the class, we are forced to pass in the `product` and `quantity`:

const orderDetail = new OrderDetail(table, 2);


3.  This is nice because we've reduced three lines of code to one line. However, we can make our class even nicer to work with by making the default `quantity` parameter `1` if nothing is passed in:

constructor(product: Product, quantity: number = 1) {

this.product = product;

this.quantity = quantity;

}


4.  We now don't have to pass in a `quantity` if it is `1`:

const orderDetail = new OrderDetail(table);


5.  We can save ourselves a few keystrokes and let the TypeScript compiler implement the `product` and `quantity` properties by using the `public` keyword before the parameters in the constructor:

class OrderDetail implements IOrderDetail {

constructor(public product: Product, public quantity: number = 1) {

this.product = product;

this.quantity = quantity;

}

getTotal(discount: number): number {

...

}

}


# Extending classes

Classes can extend other classes. This is the same concept as interfaces extending other interfaces, which we covered in the *Extending interfaces* section. This is a way for class properties and methods to be shared with child classes.

As with interfaces, we use the `extends` keyword followed by the class we are extending. Let's look at an example:

1.  Let's create a `ProductWithDiscountCodes` from our `Product` class:

class Product {

name: string;

unitPrice: number;

}

interface DiscountCode {

code: string;

percentage: number;

}

class ProductWithDiscountCodes extends Product {

discountCodes: DiscountCode[];

}


2.  We can then consume the `ProductWithDiscountCodes` class as follows, leveraging properties from the base class as well as the child class:

const table = new ProductWithDiscountCodes();

table.name = "Table";

table.unitPrice = 500;

table.discountCodes = [

{ code: "SUMMER10", percentage: 0.1 },

{ code: "BFRI", percentage: 0.2 }

];


3.  If the parent class has a constructor, then the child class will need to pass the constructor parameters using a function called `super`:

class Product {

constructor(public name: string, public unitPrice: number) {

}

}

interface DiscountCode {

code: string;

percentage: number;

}

class ProductWithDiscountCodes extends Product {

constructor(public name: string, public unitPrice: number) {

super(name, unitPrice);

}

discountCodes: DiscountCode[];

}


# Abstract classes

Abstract classes are a special type of class that can only be inherited from and not instantiated. They are declared with the `abstract` keyword, as in the following example:

1.  We can define a base `Product` class as follows:

abstract class Product {

name: string;

unitPrice: number;

}


2.  If we try to create an instance of this, the compiler will complain, as we would expect:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/d5892d27-9ae6-48f1-bced-7dfac45ab000.png)

3.  We can create a more specific usable class for food products by extending `Product`:

class Food extends Product {

constructor(public bestBefore: Date) {

super();

}

}


4.  Here, we are adding a `bestBefore` date in our `Food` class. We can then create an instance of `Food`, passing in the `bestBefore` date:

const bread = new Food(new Date(2019, 6, 1));


Abstract classes can have `abstract` methods that child classes must implement. Abstract methods are declared with the `abstract` keyword in front of them, as in the following example:

1.  Let's add an `abstract` method to our base `Product` class:

abstract class Product {

name: string;

unitPrice: number;

abstract delete(): void;

}


2.  After we add the `abstract` method, the compiler immediately complains about our `Food` class because it doesn't implement the `delete` method:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/a9d5b1d7-7508-4386-8418-38d4725ebeb0.png)

3.  So, let's fix this and implement the `delete` method:

class Food extends Product {

deleted: boolean;

constructor(public bestBefore: Date) {

super();

}

delete() {

this.deleted = false;

}

}


# Access modifiers

So far, all our class properties and methods have automatically had the `public` access modifier. This means they are available to interact with class instances and child classes. We can explicitly set the `public` keyword on our class properties and methods immediately before the property or method name:

class OrderDetail {

public product: Product;

public quantity: number;

public getTotal(discount: number): number {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

}

}


As you might have guessed, there is another access modifier, called `private`**,** which allows the member to only be available to interact with inside the class and not on class instances or child classes.

Let's look at an example:

1.  Let's add a `delete` method in our `OrderDetail` class, which sets a private `deleted` property:

class OrderDetail {

public product: Product;

public quantity: number;

private deleted: boolean;

public delete(): void {

this.deleted = true;

}

...

}


2.  Let's create an instance of `OrderDetail` and try to access the `deleted` property:

const orderDetail = new OrderDetail();

orderDetail.deleted = true;


As expected, the compiler complains:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/1d66fcc2-2ce4-422a-8407-cc3ad618b557.png)

There is a third access modifier, `protected`, which allows the member to be available to interact with inside the class and on child classes, but not on class instances.

# Property setters and getters

Our classes so far have had simple property declarations. However, for more complex scenarios, we can implement a property with a `getter` and a `setter`. When implementing `getters` and `setters`, generally, you'll need a private property to hold the property value:

*   `getter` is a function with the property name and the `get` keyword at the beginning and no parameters. Generally, this will return the value of the associated private property.
*   `setter` is a function with the same name with the `set` keyword at the beginning and a single parameter for the value. This will set the value of the associated private property.
*   The `private` property is commonly named the same as the `getter` and `setter` with an underscore in front.

Let's take a look at an example:

1.  Let's create `getters` and `setters` for the `unitPrice` property in our `Product` class. The `setter` ensures the value is not less than `0`. The `getter` ensures `null` or `undefined` is never returned:

class Product {

name: string;

private _unitPrice: number;

get unitPrice(): number {

return this._unitPrice || 0;

}

set unitPrice(value: number) {

如果(值 < 0){

值 = 0;

}

this._unitPrice = 值;

}

}


2.  Let's consume the `Product` class and try this out:

const 表 = 新产品();

表.名称 = "桌子";

console.log(表.单价);

表.单价 = -10;

console.log(表.单价);


If we run this, we should see two 0's in the console.

# Static

Static properties and methods are held in the class itself and not in class instances. They can be declared using the `static` keyword before the property or method name.

Let's look at the following example:

1.  Let's make the `getTotal` method static on the `OrderDetail` class we have been using:

类订单明细{

产品:产品;

数量:数字;

静态获取总计(折扣:数字):数字{

const 无折扣价格 = this.product.unitPrice * this.quantity;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}

}


2.  We get compilation errors where we try to reference the properties on the class. This is because the `static` method isn't in the class instance and therefore can't access these properties:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/bdc9105e-9736-4573-8891-8d40a6c467e4.png)

3.  To make the `static` method work, we can move its dependencies on the class instance to parameters in the function:

静态获取总计(单价:数字,数量:数字,折扣:数字):数字{

const 无折扣价格 = 单价 * 数量;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}


4.  We can now call the static method on the class type itself, passing in all the parameter values:

const 总计 = 订单明细.获取总计(500,2,0.1);

console.log(总计);


If we run the preceding program, we should get an output of `900` in the console.

# Structuring code into modules

By default, TypeScript generated JavaScript code that executes in what is called the global scope. This means code from one file is automatically available in another file. This in turn means that the functions we implement can overwrite functions in other files if the names are the same, which can cause our applications to break.

Let's look at an example in Visual Studio Code:

1.  Let's create a file called `product.ts` and enter the following interface for a product:

接口产品{

名称:字符串;

单价:数字;

}


2.  Let's create another file, called `orderDetail.ts`, with the following content:

类订单明细{

产品:产品;

数量:数字;

获取总计(折扣:数字):数字{

const 无折扣价格 = this.product.unitPrice * this.quantity;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}

}


The compiler doesn't give us any complaints. In particular, the reference to the `Product` interface in the `OrderDetail` class is able to be resolved, even though it's in a different file. This is because both `Product` and `OrderDetail` are in the global scope.

Operating in the global scope is problematic because item names can conflict across different files, and as our code base grows, this is harder to avoid. Modules resolve this issue and help us write well organized and reusable code.

# Module formats

Modules feature in JavaScript as part of ES6, which is great. However, lots of code exists in other popular module formats that came before this standardization. TypeScript allows us to write our code using ES6 modules, which can then transpile into another module format if specified.

Here is a brief description of the different module formats that TypeScript can transpile to:

*   **Asynchronous Module Definition** (**AMD**): This is commonly used in code targeted for the browser and uses a `define` function to define modules.
*   **CommonJS**: This format is used in Node.js programs. It uses `module.exports` to define modules and `require` to define dependencies.
*   **Universal Module Definition** (**UMD**): This can be used in both browser apps and Node.js programs.
*   **ES6**: This is the native JavaScript module format and uses the `export` keyword to define modules and `import` to define dependencies.

In the following sections (and, in fact, this whole book), we'll write our code using ES6 modules.

# Exporting

Exporting code from a module allows it to be used by other modules. In order to export from a module, we use the `export` keyword. We can specify that an item is exported using `export` directly before its definition. Exports can be applied to interfaces, type aliases, classes, functions, constants, and so on.

Let's start to adjust our example code from the previous section to operate in modules rather than the global scope:

1.  Firstly, let's export the `Product` interface:

导出接口产品{

名称:字符串;

单价:数字;

}


2.  After we make this change, the compiler will complain about the reference to the `Product` interface in the `OrderDetail` class:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/9f796565-f7ba-49c9-8306-7fdf77d57e23.png)

This is because `Product` is no longer in the global scope but `OrderDetail` still is. We'll resolve this in the next section, but let's look at alternative ways we can export the `Product` interface first.

3.  We can use an `export` statement beneath the item declarations. We use the `export` keyword followed by a comma-delimited list of item names to export in curly braces:

接口产品{

名称:字符串;

单价:数字;

}

导出{产品}


4.  With this approach, we can also rename exported items using the `as` keyword:

接口产品{

名称:字符串;

单价:数字;

}

导出{产品作为库存}


# Importing

Importing allows us to import items from an exported module. We do this using an `import` statement that includes the item names to import in curly braces and the file path to get the items from (excluding the `ts` extension). We can only import items that are exported in the other module file.

1.  Let's resolve the issue with our `OrderDetail` class by importing the `Product` interface:

导入{产品}从“产品”;

类订单明细{

产品:产品;

数量:数字;

获取总计(折扣:数字):数字{

const 无折扣价格 = this.product.unitPrice * this.quantity;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}

}


2.  We can rename imported items using the `as` keyword in an `import` statement. We then reference the item in our code using the new name:

导入{产品作为库存}从“产品”;

类订单明细{

产品:库存;

数量:数字;

获取总计(折扣:数字):数字{

const 无折扣价格 = this.product.unitPrice * this.quantity;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}

}


# Default exports

We can specify a single item that can be exported by default using the `default` keyword:

默认导出接口{

名称:字符串;

单价:数字;

}


Notice that we don't need to name the interface. We can then import a default exported item using an `import` statement without the curly braces with a name of our choice:

导入产品从“产品”;


# Configuring compilation

We need to compile our TypeScript code before it can be executed in a browser. We do this by running the TypeScript compiler, `tsc`, on the files we want to compile. TypeScript is very popular and is used in many different situations:

*   It is often introduced into large existing JavaScript code bases
*   It comes by default in an Angular project
*   It is often used to add strong types to a React project
*   It can even be used in Node.js projects

All these situations involve slightly different requirements for the TypeScript compiler. So, the compiler gives us lots of different options to hopefully meet the requirements of our particular situation.

1.  Let's give this a try by opening Visual Studio Code in a new folder and creating a new file, called `orderDetail.ts`, with the following content:

导出接口产品{

名称:字符串;

单价:数字;

}

导出类订单明细{

产品:产品;

数量:数字;

获取总计(折扣:数字):数字{

const 无折扣价格 = this.product.unitPrice * this.quantity;

const 折扣金额 = 无折扣价格 * 折扣;

返回无折扣价格 - 折扣金额;

}

}


2.  We can open a Terminal in Visual Studio Code by going to the View menu and choosing Terminal. Let's enter the following command in the Terminal:

tsc 订单明细


3.  Hopefully, no errors should be output from the compiler and it should generate a file called `orderDetail.js`, containing the following transpiled JavaScript:

"use strict";

exports.__esModule = true;

var 订单明细 =(function(){

函数订单明细(){

}

OrderDetail.prototype.getTotal = function (discount) {

var priceWithoutDiscount = this.product.unitPrice * this.quantity;

var discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

};

return OrderDetail;

}());

exports.OrderDetail = OrderDetail;


We'll continue to use `orderDetail.ts` in the following sections as we explore how the compiler can be configured.

# Common options

As mentioned earlier, there are lots of configuration options for the TypeScript compiler. All the configuration options can be found at [`www.typescriptlang.org/docs/handbook/compiler-options.html`](https://www.typescriptlang.org/docs/handbook/compiler-options.html). The following sections detail some of the more common options that are used.

# --target

This determines the ECMAScript version the transpiled code will be generated in.

The default is `ES3`, which will ensure the code works in a wide range of browsers and their different versions. However, this compilation target will generate the most amount of code because the compiler will generate polyfill code for features that aren't supported in `ES3`.

The `ESNext` option is the other extreme, which compiles to the latest supported proposed ES features. This will generate the least amount of code, but will only work on browsers that have implemented the features we have used.

As an example, let's compile `orderDetail.ts` targeting `ES6` browsers. Enter the following in the terminal:

tsc orderDetail --target es6


Our transpiled JavaScript will be very different from the last compilation and much closer to our source TypeScript because classes are supported in `es6`:

export class OrderDetail {

getTotal(discount) {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

}

}


# --outDir

By default, the transpiled JavaScript files are created in the same directory as the TypeScript files. `--outDir` can be used to place these files in a different directory.

Let's give this a try and output the transpiled `orderDetail.js` to a folder called `dist`. Let's enter the following in the terminal:

tsc orderDetail --outDir dist


A `dist` folder will be created containing the generated `orderDetail.js` file.

# --module

This specifies the module format that the generated JavaScript should use. The default is the **CommonJS** module format if ES3 or ES5 are targeted. `ES6` and `ESNext` are common options today when creating a new project.

# --allowJS

This option tells the TypeScript compiler to process JavaScript files as well as TypeScript files. This is useful if we've written some of our code in JavaScript and used features that haven't been implemented yet in all browsers. In this situation, we can use the TypeScript compiler to transpile our JavaScript into something that will work with a wider range of browsers.

# --watch

This option makes the TypeScript compiler run indefinitely. Whenever a source file is changed, the compiling process is triggered automatically to generate the new version. This is a useful option to switch on during our developments:

1.  Let's give this a try by entering the following in a terminal:

tsc orderDetail --watch


2.  The compiler should run and, when completed, give the message `Watching for file changes`. Let's change the `getTotal` method in the `OrderDetail` class to handle situations when `discount` is undefined:

getTotal(discount: number): number {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * (discount || 0);

return priceWithoutDiscount - discountAmount;

}


3.  When we save `orderDetail.ts`, the compiler will say `File change detected. Starting incremental compilation...` and carry out the compilation.

To exit the watch mode, we can kill the terminal by clicking the bin icon in the Terminal.

# --noImplicitAny

This forces us to explicitly specify the `any` type where we want to use it. This forces us to think about our use of `any` and whether we really need it.

Let's explore this with an example:

1.  Let's add a `doSomething` method to our `OrderDetail` class that has a parameter called `input` with no type annotation:

export class OrderDetail {

...

doSomething(input) {

input.something();

return input.result;

}

}


2.  Let's do a compilation with the `--noImplicitAny` flag in the Terminal:

tsc orderDetail --noImplicitAny


The compiler outputs the following error message because we haven't explicitly said what type the `input` parameter is:

orderDetail.ts(14,15): 错误 TS7006: 参数 'input' 隐含地具有 'any' 类型。


3.  We can fix this by adding a type annotation with `any` or, better still, something more specific:

doSomething(input: {something: () => void, result: string}) {

input.something();

return input.result;

}


If we do a compilation with `--noImplicitAny` again, the compiler is happy.

# --noImplicitReturns

This ensures we return a value in all branches of a function if the return type isn't `void`.

Let's see this in action with an example:

1.  In our `OrderDetail` class, let's say we have the following implementation for our `getTotal` method:

getTotal(discount: number): number {

if (discount) {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

} else {

// 我们忘记了这个分支!

}

}


2.  We've forgotten to implement the branch of code that deals with the case where there is no discount. If we compile the code without the `--noImplicitReturns` flag, it compiles fine:

tsc orderDetail


3.  However, let's see what happens if we compile the code with the `--noImplicitReturns` flag:

tsc orderDetail --noImplicitReturns


We get the following error, as expected:

orderDetail.ts(9,31): 错误 TS7030: 不是所有的代码路径都返回一个值。


# --sourceMap

When this is set, `*.map` files are generated during the transpilation process. This will allow us to debug the TypeScript version of the program (rather than the transpiled JavaScript). So, this is generally switched on during development.

# --moduleResolution

This tells the TypeScript compiler how to resolve modules. This can be set to `classic` or `node`. If we are using ES6 modules, this defaults to `classic`, which means the TypeScript compiler struggles to find third-party packages such as Axios. So, we can explicitly set this to `node` to tell the compiler to look for modules in `"node_modules"`.

# tsconfig.json

As we have seen, there are lots of different switches that we can apply to the compilation process, and repeatedly specifying these on the command line is a little clunky. Luckily, we can specify these options in a file called `tsconfig.json`. The compiler options we have looked at in previous sections are defined in a `compilerOptions` field without the `"--"` prefix.

Let's take a look at an example:

1.  Let's create a `tsconfig.json` file with the following content:

{

"compilerOptions": {

"target": "esnext",

"outDir": "dist",

"module": "es6",

"moduleResolution": "node",

"sourceMap": true,

"noImplicitReturns": true,

"noImplicitAny": true

}

}


2.  Let's run a compile without specifying the source file and any flags:

tsc


The compilation will run fine, with the transpiled JavaScript being output to the `dist` folder along with a source map file.

# Specifying files for compilation

There are several ways to tell the TypeScript compiler which files to process. The simplest method is to explicitly list the files in the `files` field:

{

"compilerOptions": {

...

},

"files": ["product.ts", "orderDetail.ts"]

}


However, that approach is difficult to maintain as our code base grows. A more maintainable approach is to define file patterns for what to include and exclude with the `include` and `exclude` fields.

The following example looks at the use of these fields:

1.  Let's add the following `include` fields, which tell the compiler to compile TypeScript files found in the `src` folder and its subfolders:

{

"compilerOptions": {

...

},

"include": ["src/**/*"]

}


2.  At the moment, our source files aren't in a folder called `src`, but let's run a compile anyway:

tsc


3.  As expected, we get `No inputs were found in the config file...` from the compiler.

Let's create an `src` folder and move `orderDetail.ts` into this folder. If we do a compile again, it will successfully find the files and do a compilation.

So, we have lots of options for adapting the TypeScript compiler to our particular situation. Some options, such as `--noImplicitAny`, force us to write good TypeScript code. We can take the checks on our code to the next level by introducing linting into our project, which we'll look at in the next section.

# TypeScript linting

As we have seen, the compiler does lots of useful checks against our TypeScript code to help us write error-free code. We can take this a step further and lint the code to help us make our code even more readable and maintainable. TSLint is a linter that is very popular in TypeScript projects, and we will explore it in this section.

The home page for TSLint is at [`palantir.github.io/tslint/`](https://palantir.github.io/tslint/).

We'll install TSLint in the next section.

# Installing TSLint

We'll install TSLint in this section, along with a Visual Studio Code extension that will highlight linting problems right in the code:

1.  Let's install TSLint globally via `npm`, as follows:

npm install -g tslint


2.  Now, we can open Visual Studio Code and go to the extensions area (*Ctrl + Shift + X*) and type `tslint` in the search box at the top-left. The extension is called TSLint and was published by egamma:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/6ff55d56-134d-4c7d-b580-ac11aea7f5a3.png)

3.  We need to click the Install option to install the extension.
4.  After it has been installed, we'll need to reload Visual Studio Code for the extension to become enabled.

Now that this extension is installed, along with TSLint globally, linting errors will be highlighted right in our code, as we'll see in the following sections.

# Configuring rules

The rules that `tslint` uses when checking our code are configurable in a file called `tslint.json`. In order to explore some of the rules, we first need a TypeScript file:

1.  So, let's create a file called `orderDetail.ts` with the following content in Visual Studio Code:

export interface Product {

name: string;

unitPrice: number;

}

export class OrderDetail {

product: Product;

quantity: number;

getTotal(discount: number): number {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discount;

返回 priceWithoutDiscount - discountAmount;

}

}


2.  Let's now create a `tslint.json` file. We define the rules we want to implement in a `rules` field. Let's add the following rule:

{

"rules": {

"member-access": true

}

}


3.  A full list of the rules can be found at: [`palantir.github.io/tslint/rules/`](https://palantir.github.io/tslint/rules/). The `member-access` rule forces us to explicitly declare the access modifier for classes. We haven't explicitly defined the property and method access modifiers in the `OrderDetail` class because they are `public` by default. So, with our linting rule in place, Visual Studio Code will highlight the lack of access modifiers to us:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/630fab81-811a-428b-a180-b858f7caa663.png)

4.  As we put a `public` access modifier in front of the properties and method, the warnings go away:

export class OrderDetail {

public product: Product;

public quantity: number;

public getTotal(discount: number): number {

const priceWithoutDiscount = this.product.unitPrice * this.quantity;

const discountAmount = priceWithoutDiscount * discount;

return priceWithoutDiscount - discountAmount;

}

}


The `member-access` rule forces us to write more code – how can this be a good thing? The rule is useful if you're reading the code and don't know TypeScript well enough to understand that class members without access modifiers are public. So, it's great if our team consists of developers who don't know TypeScript that well yet, but not necessarily for an experienced team of TypeScript developers.

Lots of the `tslint` rules are like `member-access` – in some teams, they will work well and in others, they don't really add value. This is why rules are configurable!

# Built-in rules

`tslint` has a handy collection of built-in rulesets that can be used. We can use these by specifying the ruleset name in the `extends` field. We can use multiple rulesets by putting all their names in the array:

1.  Let's adopt the opinionated set of rules that `tslint` ships with, called `"tslint:recommended"`. So, in our `tslint.json` file, let's remove the `rules` field and add an `extends` field, as follows:

{

“extends”: [“tslint:recommended”]

}


We immediately get lint errors when `tslint.json` is saved. The error is complaining about the lack of an `I` prefix on our `Product` interface. The logic behind the rule is that, while reading code, if a type starts with an `I`, we immediately know that it is an interface.

2.  Let's pretend that this rule isn't valuable to us. We can override this rule from `"tslint:recommended"` in the `"rules"` field. The rule is called `"interface-name"`. So, let's override this to `false`:

{

“extends”: [“tslint:recommended”],

“rules”: {

“interface-name”: false

}

}


When `tslint.json` is saved, the linting errors immediately go away.

# Excluding files

We can exclude files from the linting process. This is useful for excluding third-party code. We do this by specifying an array of files in an `exclude` field in the `linterOptions` field:

{

“extends”: [“tslint:recommended”],

“linterOptions”: {

“exclude”: [“node_modules/**/*.ts”]

}

}


The preceding configuration excludes third-party `node` packages from the linting process.

Now that we've added TSLint to our tool belt, we are going to add another tool that will automatically format our code for us. This will help our code adhere to some of the code formattings TSLint rules.

# Code formatting

In this section, we are going to install another extension in Visual Studio Code, called Prettier, which will automatically format our code. As well as putting a stop to all the ongoing debates over styles, it will help us adhere to some of the TSLint rules:

1.  Let's open Visual Studio Code, go to the Extensions area, and type prettier in the search box. The extension is called Prettier - Code formatter and was published by Esben Petersen:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/bcb3fc98-b4c0-4e54-ad03-d19831c3d52b.png)

2.  We need to click the Install option to install the extension.
3.  After it has been installed, we'll need to reload Visual Studio Code for the extension to become enabled.
4.  The last step is to make sure the Format on Save option is ticked in User Settings. Press *Ctrl* +*,* (comma) to open the settings screen and type Format On Save in the search box to find the setting. If the setting isn't ticked, then tick it:

![](https://gitee.com/OpenDocCN/freelearn-react-zh/raw/master/docs/lrn-react-ts3/img/869f1ce0-1f9b-46d3-b2ed-cda84e18a00d.png)

Now that this extension is installed, when we save our TypeScript code, it will automatically be formatted nicely for us.

# Summary

At the start of this chapter, there was a section on why we would use TypeScript to build a frontend. We now have first-hand experience of TypeScript catching errors early and giving us productivity features such as IntelliSense. We learned that TypeScript is just an extension of JavaScript. So, we get to use all of the features in JavaScript plus additional stuff from TypeScript. One of these additional things is type annotations, which help the compiler spot errors and light up features such as code navigation in our code editor.

We haven't covered everything about types yet, but we have enough knowledge to build fairly complex TypeScript programs now. Classes, in particular, allow us to model complex real-world objects nicely. We learned about modules and how they keep us out of that dangerous global scope. Modules allow us to structure code nicely and make it reusable. We can even use these if we need to support IE, because of that magical TypeScript compiler.

We learned a fair bit about the TypeScript compiler and how it can work well in different use cases because it is very configurable. This is going to be important for when we start to use TypeScript with React later in the book.

`TSLint` and `Prettier` were the icings on the cake. It's down to us and our team to debate and decide the TSLint rules we should go with. The benefit of both these tools is that they force consistency across our code base, which makes it more readable.

Now that we understand the basics of TypeScript, we'll dive into the new features that have been added in TypeScript 3.

# Questions

Here are some questions to test what you have learned in this first chapter. The answers can be found in the appendix.

Good luck!

1.  What are the 5 primitive types?
2.  What would the inferred type be for the `flag` variable be in the following code?

const flag = false;


3.  What's the difference between an interface and a type alias?
4.  What is wrong with the following code? How could this be resolved?

class Product {

constructor(public name: string, public unitPrice: number) {}

}

let table = new Product();

table.name = "Table";

table.unitPrice = 700;


1.  如果我们希望我们的 TypeScript 程序支持 IE11,编译器的 `--target` 选项应该是什么?

1.  是否可能让 TypeScript 编译器转译 ES6 `.js` 文件?如果可以,如何做?

1.  我们如何防止 `console.log()` 语句进入我们的代码?

# 进一步阅读

[`www.typescriptlang.org`](http://www.typescriptlang.org) 在 TypeScript 上有很好的文档。值得查看这个网站的以下页面来巩固你的知识,或者将它们用作快速参考指南:

+   **基本类型**: [`www.typescriptlang.org/docs/handbook/basic-types.html`](https://www.typescriptlang.org/docs/handbook/basic-types.html)

+   **接口**: [`www.typescriptlang.org/docs/handbook/interfaces.html`](https://www.typescriptlang.org/docs/handbook/interfaces.html)

+   **类**: [`www.typescriptlang.org/docs/handbook/classes.html`](https://www.typescriptlang.org/docs/handbook/classes.html)

+   **模块**: [`www.typescriptlang.org/docs/handbook/modules.html`](https://www.typescriptlang.org/docs/handbook/modules.html)

+   **编译器选项**: [`www.typescriptlang.org/docs/handbook/compiler-options.html`](https://www.typescriptlang.org/docs/handbook/compiler-options.html)

完整的 `tslint` 规则列表可以在 [`palantir.github.io/tslint/rules/`](https://palantir.github.io/tslint/rules/) 找到。


# 第二章:TypeScript 3 中的新功能

在其六年的存在中,TypeScript 一直在不断前进并成熟。TypeScript 3 对 React 开发人员来说是一个重要的发布吗?在 TypeScript 3 中我们必须添加到我们的工具包中的新功能是什么?这些问题将在本章中得到解答,首先是`tuple`类型以及它如何可以成功地与 React 社区中非常流行的**rest**和**spread** JavaScript 语法一起使用。然后,我们将介绍新的`unknown`类型以及它如何作为`any`类型的替代。此外,我们将使用 TypeScript 中的新项目引用将 TypeScript 项目分解为较小的项目。最后,我们将介绍在 TypeScript 3 中改进的强类型 React 组件中定义默认属性的方法。

在本章结束时,我们将准备好开始学习如何使用 TypeScript 3 来构建具有 React 的前端。在本章中,我们将涵盖以下主题:

+   元组

+   未知类型

+   项目引用

+   默认 JSX 属性

# 技术要求

在本章中,我们将使用与第一章中相同的技术,*TypeScript Basics*:

+   TypeScript playground:这是一个网站,位于[`www.typescriptlang.org/play/`](https://www.typescriptlang.org/play/),允许我们在不安装它的情况下玩转和理解 TypeScript 中的功能。

+   Node.js 和`npm`:TypeScript 和 React 依赖于这些。您可以从[`nodejs.org/en/download/`](https://nodejs.org/en/download/)安装它们。如果您已经安装了这些,请确保`npm`至少是 5.2 版本。

+   TypeScript:这可以通过`npm`安装,输入以下命令在终端中:

```jsx
npm install -g typescript
  • 在本章中使用 TypeScript 3 非常重要。您可以使用以下命令在终端中检查您的 TypeScript 版本:
tsc -v

如果您需要升级到最新版本,可以运行以下命令:

npm install -g typescript@latest
  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码。这可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint(由 egamma 提供)和 Prettier(由 Estben Petersen 提供)扩展程序。

本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/02-WhatsNewInTS3找到。

元组

元组在 TypeScript 3 中有了一些增强,因此它们可以与流行的restspread JavaScript 语法一起使用。在我们进入具体的增强之前,我们将介绍元组是什么,以及restspread语法是什么。元组类似于数组,但元素的数量是固定的。这是一种简单的方式来结构化数据并使用一些类型安全性。

让我们来玩玩元组:

  1. 在 TypeScript playground 中,让我们输入以下元组变量的示例:
let product: [string, number];

我们已经将product变量初始化为具有两个元素的元组类型。第一个元素是一个字符串,第二个是一个数字。

  1. 我们可以在下一行的product变量中存储产品名称和单位价格,如下所示:
product = ["Table", 500];
  1. 让我们尝试以另一种方式存储产品名称和单位价格:
product = [500, "Table"];

毫不奇怪,我们会得到一个编译错误。如果我们悬停在500上,编译器会正确地抱怨它期望一个字符串。如果我们悬停在"Table"上,编译器会抱怨它期望一个数字:

所以,我们确实获得了类型安全性,但元组并没有告诉我们元素中应该是什么。因此,它们适用于小型结构或元素明显的结构。

  1. 以下示例可以说是相当可读的:
let flag: [string, boolean];
flag = ["Active", false]

let last3Scores: [string, number, number, number]
last3Scores = ["Billy", 60, 70, 75];

let point: [number, number, number];
point = [100, 200, 100];
  1. 然而,以下示例不太可读:
let customer: [string, number, number];
customer = ["Tables Ltd", 500100, 10500];

那最后两个数字到底代表什么?

  1. 我们可以像数组一样使用元组中的元素索引来访问元组中的项目。因此,在 TypeScript playground 中让我们访问product变量中的产品名称和单位价格:
let product: [string, number];
product = ["Table", 500];
console.log(product[0]);
console.log(product[1]);

如果我们运行程序,将会在控制台输出"Table"和 500。

  1. 我们可以像使用for循环或数组forEach函数一样迭代元组中的元素:
let product: [string, number];
product = ["Table", 500];

for (let element in product) {
 console.log(product[element]); 
}

product.forEach(function(element) {
 console.log(element); 
});

运行程序,将会在控制台两次输出Table500。请注意,我们不需要为element变量添加类型注释,因为 TypeScript 编译器聪明地推断出了这一点。

所以,这就是元组类型,但是 TypeScript 3 中有什么新东西呢?这些增强主要是受 JavaScript 的restspread语法的流行驱动的,所以让我们在下一节简要介绍一下这个。

JavaScript 的 rest 和 spread 语法

在 JavaScript 中,rest参数收集多个参数并将它们压缩成一个单一参数。它被称为rest,因为它将剩余的参数收集到一个参数中。

rest参数与表述性状态转移协议REST)无关。

这种语法是在 ES6 中引入的,允许我们很好地实现具有不确定数量参数的函数。

我们使用三个点在参数名之前定义一个rest参数。

让我们通过一个快速示例:

  1. 让我们创建一个logScores函数,它接受一个scores rest参数,只是将参数输出到控制台:
function logScores(...scores) {
  console.log(scores);
}

这是纯 JavaScript - 当我们查看 TypeScript 3 中的新功能时,我们将为rest参数引入类型。

  1. 我们可以这样调用logScores
logScores(50, 85, 75);

如果我们运行这个,我们将得到一个包含我们传入的三个元素的数组输出到控制台。因此,我们的scores参数已将所有参数收集到一个数组中。

spread语法与rest参数相反。它允许可迭代的对象,如array,扩展为函数参数。

让我们看一个例子:

  1. 让我们重新定义我们的logScore函数,具有特定的参数:
function logScore(score1, score2, score3) {
  console.log(score1, score2, score3);
}

请注意,这仍然是纯 JavaScript - 还没有类型!

  1. 让我们定义一个scores数组:
const scores = [75, 65, 80];
  1. 最后,让我们使用spread语法将我们的scores变量传递给我们的logScore函数:
logScore(...scores);

如果您正在使用 TypeScript playground,您将收到编译错误,期望 3 个参数,但得到 0 个或更多。尽管如此,程序仍然运行,因为这是完全有效的 JavaScript。如果我们运行它,75, 65, 80将被输出到控制台。

在接下来的章节中,我们将看到 TypeScript 3 中的新功能如何帮助我们更好地帮助编译器理解我们在使用restspread时要做的事情。这将允许我们解决前面示例中看到的编译错误。

开放式元组

在 TypeScript 3 之前,元组必须具有固定数量的元素。TypeScript 3 为rest元素提供了更多的灵活性。rest元素类似于上一节中描述的rest参数,但它们与元组元素类型一起工作。rest元素允许我们定义一个开放式元组。

时间来通过一个例子:

  1. 在 TypeScript playground 中,让我们创建一个元组,第一个元素是字符串,后续元素是数字:
type Scores = [string, ...number[]];
  1. 我们应该能够使用这种结构来存储某人的名字和无限数量的分数。让我们为Billy和三个分数试一试:
const billyScores: Scores = ["Billy", 60, 70, 75];
  1. 让我们继续尝试Sally和四个分数:
const sallyScores: Scores = ["Sally", 60, 70, 75, 70];

这两个变量都可以正常编译,因为我们已经将数字定义为开放式的。

元组函数参数

TypeScript 3 中的元组function参数允许我们创建强类型的rest参数。

举个例子:

  1. 当我们第一次看到rest参数时,我们创建了一个纯 JavaScript 版本的logScores,它在scores变量中收集了无限数量的参数:
function logScores(...scores) {
  console.log(scores);
}
  1. 在 TypeScript 3 中,我们现在可以使用元组rest参数来使这个例子具有强类型。让我们在 TypeScript playground 中试一试:
function logScores(...scores: [...number[]]) {
  console.log(scores);
}
  1. 让我们用一些分数调用我们的函数:
logScores(50, 85, 75);

我们不会得到编译错误,如果我们运行程序,控制台会输出包含50, 85, 75的数组。

我们可以创建一个增强版本的函数,它使用开放元组部分的Scores类型。

  1. function将接受名字以及无限数量的分数:
type Scores = [string, ...number[]];

function logNameAndScores(...scores: Scores) {
  console.log(scores);
}
  1. 让我们尝试用Sally的一些分数调用我们的函数:
logNameAndScores("Sally", 60, 70, 75, 70);

如果我们运行程序,Sally和她的分数数组将被输出到控制台。

扩展表达式

TypeScript 3 允许我们使用带有扩展表达式的元组。

让我们看一个例子:

  1. 让我们回到我们之前使用spread语法的有问题的纯 JavaScript 示例:
function logScore(score1, score2, score3) {
  console.log(score1 + ", " + score2 + ", " + score3);
}

const scores = [75, 65, 80];

logScore(...scores);

TypeScript 编译器引发了错误预期 3 个参数,但得到了 0 个或更多个

  1. 让我们在 TypeScript 3 中使用增强元组来解决这个问题。我们将首先为function参数添加类型:
function logScore(score1: number, score2: number, score3: number) {
  console.log(score1, score2, score3);
}

还没有什么新东西,我们仍然得到编译错误。

  1. 让我们将scores变量改为固定元组:
 const scores: [number, number, number] = [75, 65, 80];

就是这样 - 编译错误消失了!我们所需要做的就是告诉编译器scores中有多少项,它就可以成功地传递到logScore函数中。

因此,在 TypeScript 3 中,我们可以扩展固定元组。那么开放元组呢?让我们试试看:

const scoresUnlimited: [...number[]] = [75, 65, 80];

logScore(...scoresUnlimited);

不幸的是,编译器还不够聪明,不允许我们这样做。我们得到了编译错误预期 3 个参数,但得到了 0 个或更多个

空元组

在 TypeScript 3 中,我们现在可以定义一个空元组类型。让我们在 TypeScript playground 中玩一下这个:

  1. 让我们为一个空元组创建以下类型别名:
 type Empty = [];
  1. 让我们声明一个这种类型的变量,并将其赋值为空数组:
  const empty: Empty = [];
  1. 现在,让我们尝试声明一个这种类型的变量,并将其赋值为一个非空数组:
  const notEmpty: Empty = ["Billy"];

正如预期的那样,我们得到了一个编译错误:

然而,空元组类型有什么用呢?单独来看,它可能并不那么有用,但它可以作为联合类型的一部分使用,我们将在本书的后面详细介绍。现在,举个快速的例子,我们可以创建一个类型,表示最多三个分数,其中零个分数也是可以接受的:

type Scores = [] | [number] | [number, number] | [number, number, number]

const benScores: Scores = [];
const samScores: Scores = [55];
const bobScores: Scores = [95, 75];
const jayneScores: Scores = [65, 50, 70];
const sarahScores: Scores = [95, 50, 75, 75];

所有分数都是有效的,除了 Sarah 的,因为Scores类型不允许四个分数。

可选元组元素

TypeScript 3 中的最终元组增强功能是具有可选元素的能力。可选元素使用元素类型的末尾加上?来指定。

让我们看看另一个使用我们分数主题的例子:

  1. 让我们创建一个类型,表示一到三个分数:
   type Scores = [number, number?, number?];
  1. 因此,我们应该能够创建变量来保存一到三个分数:
const samScores: Scores = [55];
const bobScores: Scores = [95, 75];
const jayneScores: Scores = [65, 50, 70];

正如预期的那样,这个编译得很好。

  1. 四个元素呢?让我们试试:
 const sarahScores: Scores = [95, 50, 75, 75];

正如我们所期望的那样,我们得到了一个编译错误:

  1. 如果我们尝试没有元素,我们再次得到了一个编译错误:
  const benScores: Scores = [];

在元组中定义可选元素时,它们被限制在元组的末尾。让我们尝试在可选元素之后定义一个必需元素:

 type ProblematicScores = [number?, number?, number];

正如预期的那样,我们得到了一个编译错误:

可选元素在函数rest参数中也起作用。让我们试试这个:

  1. 让我们在我们之前的部分中使用的scores类型在我们的logScores函数中使用:
type Scores = [number, number?, number?];

function logScores(...scores: Scores) {
  console.log(scores);
}
  1. 如果我们尝试传递两个分数,代码将编译得很好,因为最后一个参数是可选的:
logScores(45, 80);
  1. 正如预期的那样,如果我们传递了四个分数,我们会收到Expected 1-3 arguments, but got 4
logScores(45, 70, 80, 65);

当我们有可选参数时,很可能我们的函数实现需要知道哪些参数已经传递。我们可以使用元组的length属性来做到这一点:

  1. 让我们创建一个增强版本的分数记录器,称为logScoresEnhanced,如果我们记录了所有3个分数,它会感谢我们:
type Scores = [number, number?, number?];

function logScoresEnhanced(...scores: Scores) {
  if (scores.length === 3) {
    console.log(scores, "Thank you for logging all 3 scores");
  } else {
    console.log(scores);
  }
}
  1. 现在,让我们用不同的参数调用这个函数:
logScoresEnhanced(60, 70, 75); 
logScoresEnhanced(45, 80); 
logScoresEnhanced(95); 

如果我们运行程序,只有在第一次调用时传递了所有三个分数时才会得到感谢。

在 TypeScript 3 中对元组的所有增强允许我们以强类型的方式使用restspread语法。在本书的后面,当我们使用 React 组件时,我们将利用这个特性。

未知类型

unknown是在 TypeScript 3 中添加的新类型。在 TypeScript 3 之前,当我们不确定第三方库中对象的所有属性和方法时,可能会使用any类型。然而,当我们用any类型声明变量时,TypeScript 编译器不会对其进行任何类型检查。在这些情况下,可以使用unknown类型使我们的代码更加类型安全。这是因为unknown类型是经过类型检查的。因此,unknown通常可以作为any的替代。

在 TypeScript playground 中,让我们通过一个使用any的函数和一个使用unknown的改进版本的例子来进行演示:

  1. 首先,让我们创建一个以any类型参数的logScores函数。它会将参数的namescores属性记录到控制台中:
function logScores(scores: any) {
  console.log(scores.firstName); 
  console.log(scores.scores); 
}
  1. 让我们使用以下函数调用:
logScores({
  name: "Billy",
  scores: [60, 70, 75]
});

如果我们运行程序,控制台中会先显示undefined,然后是[60, 70, 75]。我们传入了一个正确的对象参数,但是我们的函数将firstName而不是name记录到了控制台中。程序编译得很好,并且在运行时没有产生错误,但是没有得到我们想要的结果。这都是因为我们告诉编译器不要使用any类型进行任何类型检查。

  1. 让我们开始创建一个更好的版本,使用unknown类型:
function logScoresBetter(scores: unknown) {
  console.log(scores.firstName);
  console.log(scores.scores);
}

我们立即在引用scores中的属性时得到编译器警告:

因此,编译器现在正在检查我们的scores变量,这很好,甚至在firstName属性上警告我们。然而,scores属性也会产生一个复杂的错误,但是是有效的。那么,我们如何告诉编译器呢?我们需要在我们的代码中明确进行一些类型检查。我们将在接下来的几节中介绍几种方法。

使用类型断言进行类型检查

我们可以在函数中使用另一个具有类型断言返回类型的函数来执行类型检查。让我们探索一下,并最终创建一个新版本的logScores函数:

  1. 首先,我们将定义一个名为scoresCheck的新函数来进行必要的类型检查:
const scoresCheck = (
  scores: any
): scores is { name: string; scores: number[] } => {
  return "name" in scores && "scores" in scores;
};

这个函数接受一个带有类型谓词的scores参数,scores is { name: string; scores: number[] },确保它包含正确类型的namescores属性。该函数只是返回scores参数是否包含namescores属性。

  1. 让我们在我们的logScores函数中使用这个函数:
function logScores(scores: unknown) {
  if (scoresCheck(scores)) {
    console.log(scores.firstName);
    console.log(scores.scores);
  }
}

我们立即得到了我们想要的编译错误:

类型断言,scores is { name: string, scores: number[] },允许 TypeScript 编译器在if块中缩小类型,以便将属性记录到控制台。这导致scores.scores编译正常,但scores.firstName出现错误,这正是我们想要的。

类型谓词是关键部分。没有它,TypeScript 编译器仍然会在有效的scores.scores引用上抛出错误。尝试删除类型谓词,自己看看。

请注意,我们可以使用类型别名使谓词更易读:

type Scores = { name: string; scores: number[] }

const scoresCheck = (
  scores: any
): scores is Scores => {
  return "name" in scores && "scores" in scores;
};

以这种方式使用类型谓词称为类型保护。我们将在本书后面介绍其他实现类型保护的方法。

使用类型断言进行类型缩小

在使用unknown时进行类型检查的另一种方法是使用类型断言。类型断言让我们使用as关键字告诉编译器类型是什么。

让我们创建另一个版本的logScores函数作为示例:

  1. 首先,让我们为我们想要函数参数的结构创建一个类型别名:
type Scores = { 
  name: string; 
  scores: number[] 
};
  1. 在我们的logScores函数中,我们现在可以使用as关键字告诉编译器期望的类型:
function logScores(scores: unknown) {
  console.log((scores as Scores).firstName);
  console.log((scores as Scores).scores);
}

这足以让编译器准确定位问题:

unknown类型允许我们减少对any类型的使用,并创建更强类型和健壮的 TypeScript 程序。但是,当引用unknown类型时,我们需要编写更多的代码。我们需要编写额外的代码来检查unknown变量的类型,以便 TypeScript 编译器可以确保我们正在访问其中的有效成员。

项目引用

TypeScript 3 允许 TypeScript 项目依赖于其他 TypeScript 项目,通过允许tsconfig.json引用其他tsconfig.json文件。

这样可以更容易地将我们的代码拆分成较小的项目。我们的前端代码可能是 TypeScript,除了我们的后端也是 TypeScript。有了 TypeScript 3,我们可以有一个前端 TypeScript 项目,一个后端 TypeScript 项目,以及一个共享的 TypeScript 项目,其中包含在前端和后端都使用的代码。将我们的代码拆分成较小的项目也可以让我们构建更快,因为它们可以逐步工作。

设置示例

为了探索这一点,我们将通过一个在 Visual Studio Code 中引用另一个项目的 TypeScript 项目的示例来进行工作:

  1. 首先,让我们创建一个名为Shared的新文件夹。这将是一个用于共享代码的项目,可能会在许多其他项目中使用。

  2. 在我们的Shared文件夹中,让我们创建以下tsconfig.json作为起点:

{
  "compilerOptions": {
    "target": "es5",
    "outDir": "dist",
    "module": "es6",
    "sourceMap": true,
    "noImplicitReturns": true,
    "noImplicitAny": true,
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}
  1. 让我们创建一个包含名为utils.ts的 TypeScript 文件的src文件夹,并包含以下函数randomString
export function randomString() {
  return Math.floor((1 + Math.random()) * 0x10000).toString(16);
}

这是一个创建随机字符的函数,正如其名称所示。我们将在另一个项目中使用这个函数。

  1. 现在让我们开始创建我们的第二个项目,所以回到我们解决方案的根目录,创建一个名为ProjectA的文件夹。

  2. ProjectA中,让我们创建以下tsconfig.json作为起点:

{
  "compilerOptions": {
    "target": "es5",
    "outDir": "dist",
    "module": "es6",
    "sourceMap": true,
    "noImplicitReturns": true,
    "noImplicitAny": true
  },
  "include": ["src/**/*"]
}
  1. 让我们还在ProjectA中创建一个名为person.ts的 TypeScript 文件的src文件夹,并包含以下代码:
import { randomString } from "../../Shared/dist/utils";

class Person {
  id: string;
  name: string;
  constructor() {
    this.id = randomString();
  }
}

该代码定义了一个关于人的简单信息类。人的唯一标识符在构造函数中使用来自我们Shared项目的randomString函数设置为随机字符串。

  1. 让我们打开终端,转到我们的Shared文件夹,并编译我们的Shared项目:
cd Shared
tsc

Shared项目编译得很好。

  1. 现在让我们尝试编译ProjectA
cd ..
cd ProjectA
tsc

我们得到了一个编译错误:

error TS7016: Could not find a declaration file for module '../../Shared/dist/utils'. '.../Shared/dist/utils.js' implicitly has an 'any' type.

因此,我们创建了两个依赖项目,但它们还不彼此理解,这就是我们出错的原因。我们将在接下来的部分中解决这个问题,使用 TypeScript 3 的新功能来处理多个项目。

引用项目

设置 TypeScript 3 的多项目功能的第一步是使用tsconfig.json中的新字段references引用项目。这个字段是一个指定要引用的项目的对象数组。

在我们的工作示例中,让ProjectA开始理解Shared项目:

  1. 让我们更改ProjectA中的tsconfig.json以引用Shared项目:
{
  "compilerOptions": {
    ...
  },
  "references": [
 { "path": "../shared" }
 ]
}

如果我们希望依赖项目生成的 JavaScript 代码包含在当前项目的同一文件中,我们可以在依赖项上将prepend设置为true

"references": [
  { "path": "../shared", "prepend": true }
]

不过,在我们的示例中,我们不会使用prepend

  1. 如果我们再次编译ProjectA,会出现不同的错误:
error TS6306: Referenced project '.../shared' must have setting "composite": true

错误提示了问题出在哪里。我们将在下一节中通过缺少的composite设置来解决这个问题。

编译器选项的添加

仅仅引用另一个项目对于 TypeScript 编译器来说并不足以正确处理多个项目。我们需要在依赖项目中添加一些额外的编译器选项。

compilerOptions字段有一个新字段叫做composite,如果我们使用多个项目,必须将其设置为true。这样可以确保启用某些选项,以便可以对依赖于它的任何项目进行引用和增量构建。

compositetrue时,declaration也必须设置为true,强制生成相应的.d.ts文件,其中包含项目的类型。这使得 TypeScript 只在类型更改时构建依赖项目,而不是一直重新构建所有依赖项目。

让我们对我们的工作示例进行以下更改:

  1. 让我们打开Shared项目中的tsconfig.json并进行以下更改:
{
  "compilerOptions": {
    "composite": true,
 "declaration": true,
    ...
  },
}
  1. 在终端中,让我们进入Shared项目目录并编译我们的Shared项目:
cd ..
cd Shared
tsc

项目编译正常。现在让我们尝试在终端中再次编译ProjectA

cd ..
cd ProjectA
tsc

这次,ProjectA编译得很好。

因此,我们成功地使用了 TypeScript 3 的多项目功能将两个项目联系在一起。在下一节中,我们将进一步改进我们项目的设置。

跨项目 Go to Definition

为了使 Visual Studio Code 中的 Go to Definition 功能跨项目工作,我们需要在tsconfig.json中设置declarationMap设置。

让我们继续进行我们的多项目示例:

  1. 让我们在ProjectA中打开person.ts,右键单击randomString引用,然后选择 Go to Definition:

我们被带到了声明文件而不是源文件:

  1. 我们可以通过在Shared项目的tsconfig.json中设置declarationMap来解决这个问题:
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    ...
  },
}

如果我们编译Shared项目并再次尝试“转到定义”功能,我们会被带到源文件,这正是我们想要的。

因此,通过在依赖项目中将declarationMap设置为true,以及compositedeclaration,我们可以获得对多个 TypeScript 项目的出色支持。

构建模式

TypeScript 3 编译器包括使用--build标志执行智能增量构建的能力。让我们在我们的示例多项目解决方案中试一试:

  1. 首先,让我们进入解决方案的根目录,打开终端,然后输入以下内容:
tsc --build ProjectA --verbose

--verbose标志告诉编译器告诉我们它正在做什么的细节。消息确认它已经选择了Shared项目以及ProjectA

Projects in this build: 
  * Shared/tsconfig.json
  * ProjectA/tsconfig.json

然后编译器会检查每个项目是否是最新的。如果项目是最新的,我们会得到以下内容:

Project 'Shared/tsconfig.json' is up to date because newest input 'Shared/src/utils.ts' is older than oldest output 'Shared/dist/utils.js'
  1. 让我们在Shared项目的utils.ts文件中进行更改,添加一个空格,然后删除它,然后保存文件。

  2. 让我们再次构建ProjectA

tsc --build ProjectA --verbose

正如预期的那样,我们会收到一条消息,指示Shared项目已过时,并将被重新构建:

Project 'Shared/tsconfig.json' is out of date because oldest
output 'Shared/dist/utils.js' is older than newest input 'Shared/src/utils.ts

Building project '.../Shared/tsconfig.json'
  1. 如果我们想要强制重新构建,即使项目是最新的,我们可以使用--force标志。让我们试一试:
tsc --build ProjectA --force --verbose

当我们这样做时,编译器仍然会检查项目是否是最新的(并告诉我们),然后继续构建每个项目。

因此,除了出色的多项目支持外,我们还可以使用--build标志加快解决方案的构建速度。随着时间的推移,随着解决方案的增长,这变得越来越有价值。如果我们想要强制重新构建一个项目,我们可以使用--force标志以及--build

默认的 JSX 属性

TypeScript 3 还改进了我们如何使用--strictNullChecks在 React 组件上设置默认属性。在 TypeScript 3 之前,我们必须将具有默认值的属性设置为可选的,并在引用它们时执行null检查。在本书中我们还没有介绍 React,所以在这一点上我们只会简要提及。

让我们通过一个示例来感受一下改进:

  1. 以下是一个在 TypeScript 2.9 中具有一些默认属性的 React 组件。该组件名为SplitText,它接受一些文本,将其拆分,并在列表中呈现已拆分的部分:
interface IProps {
  text: string;
  delimiter?: string;
}

class SplitText extends Component<IProps> {
  static defaultProps = {
    delimiter: ","
  };
  render() {
    const bits = this.props.text.split(this.props.delimiter!); 
    return (
      <ul>
        {bits.map((bit: string) => (
          <li key={bit}>{bit}</li>
        ))}
      </ul>
    );
  }
}

const App = () => (
  <div>
    <SplitText text="Fred,Jane,Bob" />
  </div>
);

export default App;

该组件有一个delimiter属性,默认值为,。在 TypeScript 2.9 中,我们需要将delimiter设为可选属性,否则如果在调用组件中没有指定它,就会得到编译器错误(即使有默认值)。

还要注意,在bits变量声明中引用delimiter后面需要加上!。这是告诉编译器这绝对不会是未定义的。

  1. 这是调用SplitText的组件:
const App = () => (
  <div>
    <SplitText text="Fred,Jane,Bob" />
  </div>
);

渲染时的效果如下:

  1. 现在,让我们来看一下 TypeScript 3 中的组件:
interface IProps {
  text: string;
  delimiter: string;
}

class SplitText extends React.Component<IProps> {
  static defaultProps = {
    delimiter: ","
  };
  render() {
    const bits = this.props.text.split(this.props.delimiter);
    return (
      <ul>
        {bits.map((bit: string) => (
          <li key={bit}>{bit}</li>
        ))}
      </ul>
    );
  }
}

请注意,我们不需要将delimiter属性设为可选。还要注意,我们不需要告诉编译器this.props.delimiter不会是未定义的。

总之,在 TypeScript 3 中,我们不必费力使默认属性在代码中正常工作!

这是我们第一次接触 React。如果此时代码示例并不太有意义,不要担心。我们将在第三章中开始学习有关 React 组件的知识,使用 React 和 TypeScript 入门

总结

在构建 React 应用程序时,使用restspread语法非常常见。我们已经看到,TypeScript 3 通过元组的增强,允许我们以强类型的方式使用restspread

我们还看到了如何使用unknown类型来减少对any类型的使用。unknown类型需要我们编写更多的代码,但也允许我们创建一个更强类型、更易维护的代码库。

TypeScript 一直让处理大型代码库变得更容易。引入项目引用后,我们现在可以更轻松地将解决方案分割成更小的项目。这种方法使得大型解决方案更易维护和灵活,并且使用新的--build标志可以获得更快的构建时间。

我们简要介绍了在 React 组件中使用defaultprops的改进。在接下来的章节中,我们将经常使用这个功能来学习如何构建强类型的 React 组件。

现在,我们开始逐渐熟悉 TypeScript,下一章中我们将开始学习 React。我们将首先学习如何创建 React 和 TypeScript 项目,然后再学习如何创建 React 和 TypeScript 组件。

问题

为了巩固我们对 TypeScript 3 的学习,试着回答以下问题:

  1. 我们有以下绘制点的函数:
function drawPoint(x: number, y: number, z: number) {
  ...
}

我们还有以下point变量:

const point: [number, number, number] = [100, 200, 300];

我们如何以简洁的方式调用drawPoint函数?

  1. 我们需要创建drawPoint函数的另一个版本,可以通过传递xyz点值作为参数来调用它:
drawPoint(1, 2, 3);

drawPoint的实现内部,我们从元组类型[number, number, number]中绘制点。我们如何定义具有所需元组的方法参数?

  1. 在你的drawPoint实现中,你如何使点中的z是可选的?

  2. 我们有一个名为getData的函数,它调用 web API 来获取一些数据。不同的 API 资源数量仍在增长,所以我们选择使用any作为返回类型:

function getData(resource: string): any {
  const data = ... // call the web API
  if (resource === "person") {
    data.fullName = `${data.firstName} ${data.surname}`;
  }
  return data;
}

我们如何通过利用unknown类型使getData更加类型安全?

  1. 我们可以使用哪个build标志来确定哪些项目已过时并需要重新构建而无需重新构建?

进一步阅读

以下链接是关于 TypeScript 3.0 更多信息的好资源:

第三章:开始学习 React 和 TypeScript

React 是一个帮助我们构建应用程序前端的 JavaScript 库。它允许我们使用强大且可重用的组件来构建应用程序的结构。它帮助我们以结构化的方式管理组件使用的数据和它们的状态。它使用一种叫做虚拟 DOM 的东西来高效地渲染我们的前端。

TypeScript 可以与 React 完美配合,使我们能够为我们的 React 组件添加静态类型。这些类型帮助我们的代码编辑器在编写 React 组件时发现问题,并为我们提供安全重构的工具。

在本章中,我们将介绍创建 React 和 TypeScript 项目的两种不同方式。我们将创建我们的第一个 React 组件,这将是一个确认对话框。我们将首先涵盖 JSX 和强类型 props。我们将讨论如何处理对话框的按钮点击事件。

然后,我们将学习声明和与强类型状态交互,这将用于隐藏和显示对话框。我们将讨论组件的生命周期方法,并涉及在 React 17 中已删除的方法。

最后,我们将学习函数组件以及它们的使用场景。

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

  • 创建 React 和 TypeScript 项目

  • 创建类组件

  • 处理类组件事件

  • 类组件状态

  • 类组件的生命周期方法

  • 创建函数组件

技术要求

本章中我们使用以下技术:

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。可以从以下链接安装它们:nodejs.org/en/download/。如果您已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要由 egamma 开发的 TSLint 扩展和由 Estben Petersen 开发的 Prettier 扩展。

  • Babel Repl:我们将简要使用这个在线工具来探索 JSX。它可以在babeljs.io/repl找到。

本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/03-GettingStartedWithReactAndTypeScript

创建 React 和 TypeScript 项目

有几种方法可以创建一个 React 和 TypeScript 项目。我们将首先使用一个名为create-react-app的流行工具快速创建一个项目。

然后,我们将以更加手动的方式创建一个项目,帮助我们理解所有不同的部分。

使用 create-react-app

create-react-app是一个命令行工具,我们可以使用它快速创建一个带有许多有用部分的 React 和 TypeScript 应用程序。

在您选择的空文件夹中打开 Visual Studio Code。让我们使用这个工具创建一个应用程序:

  1. 我们使用create-react-app npm包来创建一个 React 和 TypeScript 项目,输入以下命令:
npx create-react-app my-react-ts-app --typescript

npx工具会临时安装create-react-app npm包,并使用它来创建我们的项目。

我们选择将项目命名为my-react-ts-app。我们还指定了--typescript,这是告诉工具使用 TypeScript 设置项目的部分。

工具将花费一分钟左右的时间来创建您的项目。

请注意,我们使用的 React 版本至少需要是16.7.0-alpha.0版本。我们可以在package.json文件中检查这一点。如果package.json中的 React 版本小于16.7.0-alpha.0,那么我们可以使用以下命令安装此版本:

npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
  1. 项目创建完成后,将 TSLint 作为开发依赖项添加到项目中,并添加一些与 React 和 Prettier 兼容的规则:
cd my-react-ts-app
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在添加一个包含一些规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-
   config-prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}

在这里,我们将一般推荐的规则与 React 和 Prettier 的特定规则合并在一起。我们已经启用了debuggerconsole语句的使用,这在开发应用程序时会偶尔派上用场。

我们还抑制了关于import语句和对象字面量键排序的规则,以使我们从本书中复制代码的生活更轻松。

  1. 现在我们可以通过输入以下命令来启动开发服务器中的应用程序:
npm start

几秒钟后,一个浏览器窗口打开,我们的应用程序正在运行:

我们的 React 代码在src文件夹中。

  1. 在我们的应用程序仍在运行时,打开App.tsx文件。您将立即看到render方法上的一个 linting 错误,因为我们没有指定修饰符:

因此,让我们通过添加public作为修饰符来修复这个问题:

class App extends Component {
  public render() {
    return ( ... );
  }
}
  1. 当我们仍然在App.tsx中时,让我们将锚点标签更改为以下内容:
<a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">
 Learn React and TypeScript
</a>
  1. 保存文件,然后返回浏览器中的应用程序。应用程序已自动更改,显示新内容。不错!

create-react-app已经为我们的项目配置了很多很棒的东西。如果我们只是想快速开始学习 React,并跳过 React 和 TypeScript 代码如何打包以便从 web 服务器提供的部分,这很棒。

在下一节中,我们将手动执行一些create-react-app自动为我们执行的操作。这将开始让我们了解当 React 和 TypeScript 应用程序打包时需要发生什么。

手动创建项目

在本节中,我们将逐步手动创建一个 React 和 TypeScript 项目。我们将从创建我们的文件夹结构开始。

创建我们的文件夹结构

我们需要一个文件夹结构,可以在项目的配置文件、源代码和要分发到我们的 web 服务器的文件之间提供良好的分离。

我们所有的配置文件都将放在我们项目的根目录中:

  1. 在您选择的空文件夹中打开 Visual Studio Code,并创建一个名为src的文件夹。这将保存我们的源代码。

  2. 让我们还创建一个名为dist的文件夹。这将保存要分发到我们的 web 服务器的文件。

创建 package.json

package.json文件定义了我们的项目名称、描述、构建命令、依赖的npm模块等等。

打开一个终端窗口,并运行以下命令:

npm init

这将提示您输入有关项目的各种信息,然后创建一个包含该信息的package.json文件。

添加 TypeScript

我们在第一章中全局安装了 TypeScript,TypeScript 基础。在本节中,我们将在我们的项目中本地安装它。在本地拥有 TypeScript 可以简化构建过程。

我们可以通过在终端中运行以下命令来在我们的项目中安装 TypeScript:

npm install typescript --save-dev

--save-dev命令将 TypeScript 依赖标记为仅用于开发目的。

创建 tsconfig.json

如第一章中所述,TypeScript 基础tsconfig.json指定了我们的 TypeScript 代码如何编译和转译。

让我们在项目的根目录中创建一个名为tsconfig.json的新文件,并输入以下内容:

{ 
  "compilerOptions": { 
    "target": "es5", 
    "module": "es6", 
    "moduleResolution": "node", 
    "lib": ["es6", "dom"],
    "sourceMap": true, 
    "jsx": "react", 
    "strict": true, 
    "noImplicitReturns": true,
    "rootDir": "src",
    "outDir": "dist",
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

添加 TSLint

第一章TypeScript Linting中,我们介绍了 TSLint。按照以下步骤将其添加到您的项目中:

  1. 通过在终端中输入以下命令来安装 TSLint:
 npm install tslint --save-dev
  1. 在我们的项目的根目录下添加一个基本的tslint.json文件,并输入以下内容:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "linterOptions": {
    "exclude": ["node_modules/**/*.ts"]
  }
}

添加带有类型的 React

让我们通过在终端中运行以下命令来将 React 库添加到我们的项目中:

npm install react react-dom

我们还希望为 React 添加 TypeScript 类型。因此,将这些作为开发依赖项添加到我们的项目中,如下所示:

npm install @types/react @types/react-dom --save-dev

创建一个根网页

我们需要一个 HTML 页面来托管我们的 React 应用程序。在我们的dist文件夹中创建一个名为index.html的文件,并输入以下内容:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"/>
</head>
<body>
  <div id="root"></div>
  <script src="bundle.js"></script>
</body>
</html>

来自我们的 React 应用程序的 HTML 将被注入到具有id ="root"div中。所有应用程序的 JavaScript 代码最终将捆绑在一起,形成一个名为bundle.js的文件,位于dist文件夹中。

当然,目前这两者都不存在-我们将在以后的部分中进行这样做。

创建一个简单的 React 组件

让我们创建一个非常简单的 React 组件。在您的src文件夹中创建一个名为index.tsx的文件,并输入以下内容:

import * as React from "react";

const App: React.SFC = () => {
  return <h1>My React App!</h1>;
};

我们的组件简单地在h1标签中返回My React App!

tsx扩展名将 TypeScript React 组件与普通 JavaScript React 组件区分开来,后者具有jsx扩展名。React.SFC是我们可以用于没有任何内部状态的 React 组件的 TypeScript 类型。我们将在本书的后面学到更多关于这些组件的知识,并且我们将在本章的后面看到状态。

下一步是将我们的 React 组件注入到index.html中。我们可以通过使用ReactDOM.render函数来实现。ReactDOM.render将我们的组件作为第一个参数,要注入的 HTML 元素作为下一个元素。

让我们将突出显示的行添加到index.tsx中:

import * as React from "react";
import * as ReactDOM from "react-dom";

const App: React.SFC = () => {
  return <h1>My React App!</h1>;
};

ReactDOM.render(<App />, document.getElementById("root") as HTMLElement);

现在我们已经有了一个小型的应用程序,我们需要打包它。我们将在下一节中介绍这个。

添加 webpack

Webpack 是一个流行的工具,我们可以使用它将所有 JavaScript 代码捆绑成bundle.js文件,这是我们的index.html所期望的。

  1. 通过在终端中输入以下命令,将 webpack 及其命令行界面安装到我们的项目中作为开发依赖项:
npm install webpack webpack-cli --save-dev
  1. Webpack 还有一个方便的 Web 服务器,我们可以在开发过程中使用。因此,让我们也通过终端安装它:
npm install webpack webpack-dev-server --save-dev
  1. 在我们开始配置 webpack 之前,还有一个最后的任务要完成。这就是安装一个名为ts-loader的 webpack 插件,它将帮助加载我们的 TypeScript 代码。按照以下方式安装:
npm install ts-loader --save-dev
  1. 现在我们在项目中有了所有这些 webpack 的东西,是时候配置它了。在项目根目录中创建一个名为webpack.config.js的文件,并输入以下内容:
const path = require("path");

module.exports = {
  entry: "./src/index.tsx",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"]
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js"
  },
  devServer: {
    contentBase: path.join(__dirname, "dist"),
    compress: true,
    port: 9000
  }
};

这里有很多事情要做,让我们来分解一下:

  • module.exports是我们的 webpack 配置对象。

  • entry字段告诉 webpack 从哪里开始查找要捆绑的模块。在我们的项目中,这是index.tsx

  • module字段告诉 webpack 如何处理不同的模块。我们的项目告诉 webpack 使用ts-loader来处理带有tstsx扩展名的文件。

  • resolve字段告诉 webpack 如何解析模块。在我们的项目中,我们需要处理tsx.ts文件,以及标准的.js文件。

  • output字段告诉 webpack 在哪里捆绑我们的代码。在我们的项目中,这是dist文件夹中名为bundle.js的文件。

  • devServer字段配置 webpack 开发服务器。我们告诉它 web 服务器的根目录是dist文件夹,并在9000 端口上提供文件。

项目文件夹和文件

现在我们应该有以下文件夹,并且其中有以下文件:

├─ dist/
  ├─ bundle.js
  ├─ index.html
├─ node_modules/
├─ src/
  ├─ index.tsx 
├─ package.json
├─ tsconfig.json
├─ tslint.json
├─ webpack.config.js

现在我们几乎可以运行我们的应用程序了,只剩下一件事要做,我们将在下一节讨论。

创建启动和构建脚本

我们将利用npm脚本来启动我们的应用程序以开发模式运行,并构建我们应用程序的生产版本:

  1. 让我们打开package.json - 现在里面应该有相当多的内容。我们需要找到scripts部分,其中可能只有一个名为test的脚本。添加startbuild的突出显示脚本:
{
  ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
    "start": "webpack-dev-server --env development",
 "build": "webpack --env production"
  },
  ..
}
  1. 运行以下命令,生成我们应用程序的生产版本:
npm run build

现在,webpack 将开始并执行其操作。如果我们查看dist文件夹,最终会出现一个名为bundle.js的文件。此文件包含所有 JavaScript 压缩代码,包括来自 React 库和我们简单的 React 组件的代码。

  1. 现在,输入以下命令:
npm start

webpack 开发服务器将启动。

  1. 如果我们浏览到http://localhost:9000/,我们会看到我们的 Web 应用程序:

  1. 在我们的应用程序仍在运行时,在index.tsx中,让我们将我们的App组件更改为以下内容:
const App: React.SFC = () => {
  return <h1>My React and TypeScript App!</h1>;
};
  1. 如果我们保存index.tsx并转到浏览器,我们会看到我们的应用程序会自动更新新内容:

我们将保留我们手动配置的项目。它不像create-react-app项目那样功能强大,但我们已经开始了解 React 和 TypeScript 项目是如何打包的。

创建一个类组件

到目前为止,我们已经创建了一些非常简单的组件。在本节中,我们将构建一个稍微复杂一些的组件,并开始更熟悉组件的一些不同部分。

一起,我们将开始构建一个名为Confirm的组件,该组件将允许用户继续进行操作或停止。

当我们完成时,我们的组件将看起来像以下截图:

我们将使用create-react-app快速启动一个项目,如下所示:

  1. 在您选择的文件夹中打开 Visual Studio Code,并在终端中输入以下内容:
npx create-react-app my-components --typescript

这次我们选择将项目命名为my-components

  1. 让我们将 TSLint 与一些规则作为开发依赖项添加到我们的项目中:
cd my-components
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在添加一个包含以下规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}
  1. 通过在App.tsx中为render方法添加缺少的访问修饰符来修复 linting 错误:
class App extends Component {
  public render() {
    return ( ... );
  }
}
  1. 现在我们可以启动开发服务器并启动我们的应用程序:
npm start
  1. 接下来,我们将缩小并修复应用程序标题,以便为我们的确认组件留出足够的空间。更改app.css中的突出显示的行:
.App-logo {
  animation: App-logo-spin infinite 20s linear;
  height: 80px;
}

.App-header {
  background-color: #282c34;
  height: 200px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  color: white;
} 

应用程序标题现在应该会短一点。

在接下来的几节中,我们将继续开发我们的组件,同时保持应用程序运行。

创建一个基本的类组件

让我们看一下为我们创建的App.tsx。这是一个类组件的示例。现在我们将创建我们自己的类组件。按照以下步骤进行:

  1. src文件夹中创建一个名为Confirm.tsx的文件,并输入以下内容:
import * as React from "react";

class Confirm extends React.Component {

}

export default Confirm;

我们在第一章中学习了有关类的所有内容,TypeScript Basics**. 在这里,我们正在创建一个扩展自 React 标准Component类的类。请注意,我们已经在文件顶部导入了 React,并且我们还在文件底部使用默认导出导出了我们的类组件。

  1. 现在让我们开始实现我们的Confirm类组件,通过创建一个render方法:
class Confirm extends React.Component {
  public render() {
 return (
 );
 }
}

render方法确定组件需要显示什么。我们使用 JSX 定义需要显示的内容。简单来说,JSX 是 HTML 和 JavaScript 的混合。我们将在下一节中更详细地探讨它。

  1. 暂时,让我们在我们的render方法中输入以下内容:
public render() {
  return (
    <div className="confirm-wrapper confirm-visible">
 <div className="confirm-container">
 <div className="confirm-title-container">
 <span>This is where our title should go</span>
 </div>
 <div className="confirm-content-container">
 <p>This is where our content should go</p>
 </div>
 <div className="confirm-buttons-container">
 <button className="confirm-cancel">Cancel</button>
 <button className="confirm-ok">Okay</button>
 </div>
 </div>
 </div>
  );
}

目前,我们的render方法看起来更像 HTML 而不是 JavaScript,除了那个有趣的className属性——那不应该是class吗?

我们将在下一节中更详细地介绍这一点和 JSX,但在此之前,让我们在App组件中使用我们的Confirm组件。

  1. App.tsx中,我们需要导入我们的Confirm组件类,如下所示:
import Confirm from "./Confirm";
  1. 我们的Confirm组件可以在 JSX 中被引用为<Confirm />。因此,让我们将其添加到App.tsx中的 JSX 中:
<div className="App">
  <header className="App-header">
    ...
  </header>
  <Confirm />
</div>

如果我们查看我们应用程序运行的浏览器页面,现在应该看起来像下面这样:

  1. 我们将使用 CSS 使我们的组件看起来更像一个对话框。让我们创建一个名为Confirm.css的文件,并将以下内容输入其中:
.confirm-wrapper {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  background-color: gray;
  opacity: 0;
  visibility: hidden;
  transform: scale(1.1);
  transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;
  z-index: 1;
}
.confirm-visible {
  opacity: 1;
  visibility: visible;
  transform: scale(1);
  transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;
}
.confirm-container {
  background-color: #fff;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 0.2em;
  min-width: 300px;
}
.confirm-title-container {
  font-size: 1.3em;
  padding: 10px;
  border-top-left-radius: 0.2em;
  border-top-right-radius: 0.2em;
}
.confirm-content-container {
  padding: 0px 10px 15px 10px;
}
.confirm-buttons-container {
  padding: 5px 15px 10px 15px;
  text-align: right;
}
.confirm-buttons-container button {
  margin-left: 10px;
  min-width: 80px;
  line-height: 20px;
  border-style: solid;
  border-radius: 0.2em;
  padding: 3px 6px;
  cursor: pointer;
}
.confirm-cancel {
  background-color: #fff;
  border-color: #848e97;
}
.confirm-cancel:hover {
  border-color: #6c757d;
}
.confirm-ok {
  background-color: #848e97;
  border-color: #848e97;
  color: #fff;
}
.confirm-ok:hover {
  background-color: #6c757d;
  border-color: #6c757d;
}
  1. 现在让我们在Confirm.tsx中导入我们的 CSS:
import  "./Confirm.css";

我们浏览器页面中的组件现在应该看起来像下面这样:

因此,React 类组件有一个称为render的特殊方法,在其中我们定义了组件在 JSX 中显示的内容。

在下一节中,我们将暂时离开我们的确认组件,同时学习更多关于 JSX 的知识。

JSX

如前一节所述,JSX 看起来有点像 HTML。我们可以在我们的 JavaScript(或 TypeScript)代码中使用 JSX,就像我们在上一节的render函数中所做的那样。不过,JSX 并不是有效的 JavaScript——我们需要一个预处理步骤将其转换为 JavaScript。

我们将使用在线的 Babel REPL 来玩转 JSX:

  1. 打开浏览器,转到babeljs.io/repl,并在左侧窗格中输入以下 JSX:
<span>This is where our title should go</span>

右侧窗格中显示的是我们的 JSX 编译后的内容:

React.createElement(
  "span",
  null,
  "This is where our title should go"
);

我们可以看到它编译为对React.createElement的调用,它有三个参数:

  • 元素类型,可以是 HTML 标签名称字符串(例如"span"),React 组件类型或 React 片段类型

  • 包含要应用于元素的 props 的对象

  • 元素的子元素

  1. 让我们通过在我们的span周围放置div标签来扩展我们的示例:
<div className="confirm-title-container">
  <span>This is where our title should go</span>
</div>

现在,它编译为两个对React.createElement的调用,其中span作为子元素传递给div

React.createElement(
  "div",
  { className: "confirm-title-container" },
  React.createElement(
    "span",
    null,
    "This is where our title should go"
  )
);
  1. 现在开始有点意义了,但到目前为止,我们的 JSX 只包含 HTML。现在让我们通过声明一个 props 对象文字来添加一些 JavaScript。我们还可以使用大括号在span中引用title属性:
const props = {
 title: "React and TypeScript"
};
<div className="confirm-title-container">
  <span>{props.title}</span>
</div>

现在更有趣了。它编译成了这样:

var props = {
 title: "React and TypeScript"
};
React.createElement(
 "div",
 { className: "confirm-title-container" },
 React.createElement(
 "span",
 null,
 props.title
 )
);

关键点是我们可以通过使用大括号将 JavaScript 注入 HTML 中。

  1. 为了进一步说明这一点,让我们清空props对象文字,并在span中使用 JavaScript 三元表达式:
const props = {};
<div className="confirm-title-container">
  <span>{props.title ? props.title : "React and TypeScript"}</span>
</div>

我们看到嵌套调用React.createElement使用我们的三元表达式作为span的子元素:

React.createElement(
  "span",
  null,
  props.title ? props.title : "React and TypeScript"
)

那么,为什么我们使用className属性而不是class?嗯,我们现在明白了 JSX 编译成 JavaScript,而class是 JavaScript 中的关键字,在 JSX 中有一个class属性会产生冲突。因此,React 使用className来代替 CSS 类引用。

现在我们对 JSX 有了更多的了解,让我们回到我们的Confirm组件。

组件 props

目前,我们的Confirm组件的标题和内容文本是硬编码的。让我们将这些更改为引用组件接受的属性(props)。

  1. 首先,我们需要为我们的 props 定义一个 TypeScript 类型。我们将在Confirm.tsx中的Confirm类之前使用接口:
interface IProps {
  title: string;
  content: string;
}
  1. 然后,在类定义中引用React.Component之后,我们可以在尖括号中引用IProps类型:
class Confirm extends React.Component<IProps>

React.Component是所谓的通用类。通用类允许在类内使用的类型被传递进来。在我们的情况下,我们已经传入了我们的IProps接口。如果这一点目前还不太明白,不要担心,我们将在本书的后面介绍通用类。

  1. 我们可以使用this.props.propName在我们的类中访问 props。在我们的 JSX 中,我们现在可以引用 props 而不是硬编码标题和内容:
...
<div className="confirm-title-container">
  <span>{this.props.title}</span>
</div>
<div className="confirm-content-container">
  <p>{this.props.content}</p>
</div>
...

请注意,当我们在App.tsx中引用我们的Confirm组件时,现在会有一个 TypeScript 编译错误。这是因为我们的组件现在期望titlecontent属性,如下所示:

  1. 让我们添加titlecontent属性:
<Confirm 
 title="React and TypeScript" 
  content="Are you sure you want to learn React and TypeScript?" 
/>

编译错误现在消失了,如果我们查看浏览器,我们的组件呈现方式与我们实现 props 之前完全相同。

可选的 props

接口 props 可以是可选的,正如我们在第一章中发现的那样,TypeScript 基础。因此,我们也可以使用这种机制向 React 组件添加可选的 props。

让我们添加一些可选的 props,以允许确认组件上的按钮标题可配置:

  1. 在我们的接口中添加一些额外的可选 props,称为cancelCaptionokCaption
interface IProps {
  title: string;
  content: string;
  cancelCaption?: string;
 okCaption?: string;
}

我们在类型注释之前放一个?来表示该 prop 是可选的。还要注意,在我们引用ConfirmApp.tsx中,我们没有得到编译错误,因为我们不需要在Confirm上输入这些属性。

  1. 现在让我们在 JSX 中引用这些 props,替换硬编码的标题:
<div className="confirm-buttons-container">
  <button className="confirm-cancel">
 {this.props.cancelCaption}
 </button>
 <button className="confirm-ok">
 {this.props.okCaption}
 </button>
</div>

如果我们现在查看浏览器,我们在我们正在运行的应用程序中没有按钮标题:

这是因为当我们在App.tsx中引用Confirm时,我们没有提供这些值。

在下一节中,我们将通过为cancelCaptionokCaption添加一些默认值来解决这个问题。

默认属性值

当组件初始化时,可以向组件 props 添加默认值。这可以使用一个名为defaultProps的静态对象文字来实现。

让我们在我们的Confirm组件中利用这个特性,如下所示:

  1. 在我们的类中为cancelCaptionokCaption创建一些默认值:
class Confirm extends React.Component<IProps> {
  public static defaultProps = {
 cancelCaption: "Cancel",
 okCaption: "Okay"
 };
  public render() { ... }
}

如果我们再次查看我们正在运行的应用程序,我们再次有了按钮标题。

  1. 让我们通过覆盖默认值,并在App.tsx中提供这些标题的特定属性来完成本节:
<Confirm
  title="React and TypeScript"
  content="Are you sure you want to learn React and TypeScript?"
  cancelCaption="No way"
 okCaption="Yes please!"
/>

我们正在运行的应用程序现在应该看起来像下面这样:

具有默认值的可选 props 可以使组件更容易消耗,因为最常见的配置可以自动设置,而无需指定任何内容。

因此,我们的Confirm组件现在很灵活,但按钮还没有做任何事情。我们将在接下来的几节中解决这个问题。

处理类组件事件

事件存在于许多编程语言中。它们允许我们指定逻辑,根据用户与我们的应用程序的交互方式执行。

基本事件处理程序

所有原生的 JavaScript 事件都可以在 JSX 中处理。JSX 允许我们使用调用函数的 props 来做到这一点。prop 名称是通过在原生事件名称前加上on并使用驼峰命名法来派生的。因此,在 JSX 中,click事件的 prop 名称是onClick

我们可以在node_modules/@types/react文件夹中的index.d.ts文件中看到所有可用事件及其相应类型的列表。

我们需要能够控制我们的Confirm组件中按钮的行为。按照以下步骤进行:

  1. 第一步是处理这些按钮的click事件。让我们在确定按钮上设置onClick属性,如下所示:
<button className="confirm-ok" onClick={this.handleOkClick}>...</button>

因此,我们告诉按钮在被点击时调用Confirm类中的handleOkClick方法。

  1. 接下来,让我们创建handleOkClick方法:
private handleOkClick() {
  console.log("Ok clicked");
}

切换到运行我们应用的浏览器,并点击“是的,请!”按钮。如果我们查看控制台,应该会看到“Ok clicked”显示:

正如我们所看到的,使用我们已经熟悉的机制处理事件非常简单。不过,我们的handleOkClick方法实际上存在问题。我们将在下一节详细讨论并解决这个问题。

这个问题

我们的事件处理程序遭受 JavaScript 经典的this问题。我们目前在事件处理程序中没有引用this,这就是为什么问题还没有浮出水面的原因。

让我们暴露handleOkClick方法的问题,以便更好地理解发生了什么:

  1. 将组件的 props 记录到控制台:
private handleOkClick() {
  console.log("Ok clicked", this.props);
}

现在,再次尝试点击“是的,请!”按钮。将出现以下错误:

问题在于this在事件处理程序中不引用我们的类,而是undefined

一个解决方案是将handleOkClick方法更改为箭头函数。

箭头函数表达式比函数表达式的语法更短。它们也不会创建自己的this,所以它们是解决this问题的一个很好的解决方案。

  1. 让我们将我们的方法转换为箭头函数,如下所示:
private handleOkClick = () => {
 console.log("Ok clicked", this.props);
};

现在再次尝试点击“是的,请!”按钮。我们应该看到成功将 props 输出到控制台。

  1. 继续,现在让我们在取消按钮上实现一个点击处理程序。首先,我们需要在Cancel按钮的onClick属性上引用一个处理程序:
<button className="confirm-cancel" onClick={this.handleCancelClick}>...</button>
  1. 现在我们将创建handleCancelClick箭头函数:
private handleCancelClick = () => {
  console.log("Cancel clicked", this.props);
};

总之,为了避免this问题,我们可以使用箭头函数来实现事件处理程序。

接下来,我们希望组件的使用者能够在按钮被点击时执行一些逻辑。我们将在下一节中介绍如何做到这一点。

函数 props

在上一节中,我们看到了如何为具有我们的onClick事件处理程序的函数设置 props。在本节中,我们将实现自己的函数 props,以便我们组件的消费者在按下确定和取消按钮时可以执行一些逻辑。

  1. 让我们在我们的接口上实现这两个额外的 props。该函数不接受任何参数,也不返回任何内容。因此,类型为() => void,如下所示:
interface IProps {
 title: string;
 content: string;
 cancelCaption?: string;
 okCaption?: string;
 onOkClick: () => void;
 onCancelClick: () => void;
}

这些 props 是必需的,所以当我们引用Confirm组件时,我们立即在App.tsx中得到编译错误。我们稍后会修复这个问题。

  1. 接下来,让我们在按钮被点击时调用我们的函数 props,而不是将消息记录到控制台中:
private handleCancelClick = () => {
  this.props.onCancelClick();
};

private handleOkClick = () => {
  this.props.onOkClick();
};
  1. 现在我们将通过在App.tsx中实现箭头函数处理程序来修复编译错误。首先让我们创建箭头函数处理程序:
private handleCancelConfirmClick = () => {
 console.log("Cancel clicked");
};

private handleOkConfirmClick = () => {
 console.log("Ok clicked");
};
  1. 现在我们可以引用这些函数,我们在其中使用Confirm组件:
<Confirm
  ...
  onCancelClick={this.handleCancelConfirmClick}
 onOkClick={this.handleOkConfirmClick}
/>

如果我们回到我们的应用程序,我们会看到编译错误已经解决。如果我们单击确定和取消按钮,我们会得到预期的消息输出到控制台:

目前,我们的按钮除了将消息记录到控制台外什么都不做。我们希望确认对话框在单击任一按钮时关闭。我们将在下一节中实现这一点。

类组件状态

状态是一个确定组件行为和渲染方式的对象。我们需要在我们的应用程序中引入状态,以便管理我们的确认对话框是打开还是关闭。

这种特定的状态将存在并由App组件中进行管理,并作为一个 prop 传递给Confirm组件。

  1. 首先,在Confirm组件中创建一个open属性,将其添加到其 props 接口中:
interface IProps {
  open: boolean;
  title: string;
  content: string;
  cancelCaption?: string;
  okCaption?: string;
  onOkClick: () => void;
  onCancelClick: () => void;
}
  1. 我们将在render函数中使用open属性来确定对话框是否可见。confirm-visible CSS 类使对话框可见。

因此,在最外层的div中,让我们在className属性中使用 JavaScript 三元表达式,只有在open属性为true时才包含confirm-visible,如下所示:

public render() {
  return (
    <div
      className={
 this.props.open
 ? "confirm-wrapper confirm-visible"
 : "confirm-wrapper"
 }
    >
      ...
    </div>
  );
}

我们现在在App.tsx中有一个编译错误,因为我们在使用Confirm时没有指定open属性。

  1. 在开始在App.tsx中创建和管理状态之前,让我们在Confirm中的open属性中简单地传递false
<Confirm
  open={false}
  title="React and TypeScript"
  content="Are you sure you want to learn React and TypeScript?"
  cancelCaption="No way"
  okCaption="Yes please!"
  onCancelClick={this.handleCancelConfirmClick}
  onOkClick={this.handleOkConfirmClick}
/>

如果我们现在在浏览器中查看应用程序,编译错误将消失,我们的确认对话框将关闭。

定义状态类型

让我们在App.tsx中创建一个状态,并正确地管理确认对话框是打开还是关闭:

  1. 首先,让我们在App类上面创建一个状态的接口:
interface IState {
 confirmOpen: boolean;
}
  1. 然后我们告诉App组件关于状态类型,我们可以使用React.Component的第二个泛型参数来做到这一点:
class App extends React.Component<{}, IState>

我们使用{}作为 props 类型,因为这个组件没有 props。

初始化状态

现在我们已经指定了我们的组件有一个状态,我们需要初始化它。我们在类构造函数中初始化组件状态。

  1. 让我们创建一个构造函数,并将confirmOpen状态初始化为true
constructor(props: {}) {
 super(props);
 this.state = {
  confirmOpen: true,
 };
}

我们调用super因为我们的类扩展了React.Component

状态保存在组件类的私有属性中。在构造函数中,我们可以将状态设置为我们所需的对象字面量,我们的情况下confirmOpen设置为true

  1. 当我们引用Confirm组件时,我们可以使用这个状态:
<Confirm
  open={this.state.confirmOpen}
  ...
/>

如果我们在运行的应用中查看,确认对话框应该再次打开。

因此,私有状态属性给了我们访问组件状态的权限,并且我们可以在类构造函数中初始化它。

改变状态

当确认对话框按钮被点击时,我们希望关闭对话框。因此,我们希望在按钮被点击时将confirmOpen的状态改为 false。

我们已经有了箭头函数处理按钮点击事件,也许我们可以在那里改变状态:

  1. 让我们尝试在handleOkConfirmClick中做到这一点,替换console.log
private handleOkConfirmClick = () => {
  this.state.confirmOpen = false;
};

我们得到了一个编译错误,如下所示:

错误消息表明状态是只读的!为什么会这样,我们如何改变状态?

我们需要在组件类中使用一个叫做setState的方法来改变状态。这有助于确保我们稳健和高效地管理状态。setState接受一个参数,这个参数是一个包含我们想要改变的状态的对象字面量。

  1. 让我们改变我们的代码来使用setState
private handleOkConfirmClick = () => {
  this.setState({ confirmOpen: false });
};

编译错误消失了,如果我们在运行的应用中点击 Yes please!,确认对话框现在将关闭。我们成功地改变了状态。

  1. 改变handleCancelConfirmClick的实现以关闭对话框:
private handleCancelConfirmClick = () => {
  this.setState({ confirmOpen: false });
};

在我们关闭确认对话框之后,我们没有办法再次打开它。

  1. 那么,让我们在App.tsx中添加一个名为 Confirm 的按钮:
<button onClick={this.handleConfirmClick}>Confirm</button>
<Confirm ... />
  1. 我们需要创建刚刚引用的处理程序:
private handleConfirmClick = () => {
  this.setState({ confirmOpen: true });
};

现在,我们可以点击确认按钮,在确认对话框关闭后重新打开它。

  1. 让我们在App.tsx中的 Confirm 按钮上方添加一段文本,根据确认对话框是取消还是确认而变化。我们将定义一个额外的状态来驱动这段文本:
interface IState {
  confirmOpen: boolean;
  confirmMessage: string;
}
  1. 现在,让我们在构造函数中初始化消息:
constructor(props: {}) {
  super(props);
  this.state = {
    confirmMessage: "Please hit the confirm button",
    confirmOpen: true,
  };
}
  1. 当确认对话框被确认或取消时,状态现在已经改变:
private handleOkConfirmClick = () => {
  this.setState({
    confirmMessage: "Cool, carry on reading!",
    confirmOpen: false
  });
};

private handleCancelConfirmClick = () => {
  this.setState({
    confirmMessage: "Take a break, I'm sure you will later ...",
    confirmOpen: false
  });
};
  1. 最后,我们可以在确认按钮上方呈现消息:
<p>{this.state.confirmMessage}</p>
<button onClick={this.handleConfirmClick}>Confirm</button>
<Confirm ... />

如果我们现在运行应用程序,我们会看到应用程序中的消息根据我们确认或取消确认对话框而改变。

虽然我们可以在构造函数中直接设置状态属性,但在类组件的其他地方却不能。相反,状态应该通过在组件类中调用setState方法来改变。

类组件生命周期方法

类组件中的生命周期方法允许我们在过程中的特定点运行代码。以下是组件过程的高级图表,显示了不同方法何时被调用:

现代 React 生命周期方法的图表,来自 React 文档

componentDidMount

componentDidMount在组件被插入 DOM 时被调用。以下是该方法的一些常见用例:

  • 调用 web 服务以获取一些数据

  • 添加事件监听器

  • 初始化计时器

  • 初始化第三方库

我们将改变我们一直在构建的应用程序,为用户提供 10 秒的时间来确认他们是否想要学习 React 和 TypeScript。为了做到这一点,我们需要利用componentDidMount方法:

  1. 让我们首先在App.tsx中使确认对话框在应用程序加载时关闭:
constructor(props: {}) {
  super(props);
  this.state = {
    confirmMessage: "Please hit the confirm button",
    confirmOpen: false
  };
}
  1. 我们将从10开始倒计时到0,然后在达到0时隐藏确认按钮。让我们在App.tsx中添加和初始化这两个状态:
interface IState {
  confirmOpen: boolean;
  confirmMessage: string;
 confirmVisible: boolean; countDown: number;
}

constructor(props: {}) {
  super(props);
  this.state = {
    confirmMessage: "Please hit the confirm button",
    confirmOpen: false,
    confirmVisible: true,
 countDown: 10
  };
}
  1. 我们将在App类中使用timer10倒数到1。让我们在构造函数上方创建一个名为timer的私有属性:
private timer: number = 0;
  1. 现在,让我们使用componentDidMount方法来初始化我们的timer
public componentDidMount() {
  this.timer = window.setInterval(() => this.handleTimerTick(), 1000);
}
  1. 计时器将每秒调用一个名为handleTimerTick的方法。实现该方法如下:
private handleTimerTick() {
  this.setState(
    {
      confirmMessage: `Please hit the confirm button ${
        this.state.countDown
      } secs to go`,
      countDown: this.state.countDown - 1
    }
  );
}

我们也在减少计数器,更新用户在此方法中显示的消息。但是,我们需要在这里做更多的工作:我们需要停止计时器,隐藏确认按钮,并告诉用户他们来得太迟了!

  1. 我们的自然本能可能是写出这样的东西:
private handleTimerTick() {
  this.setState(
    {
      confirmMessage: `Please hit the confirm button ${
        this.state.countDown
      } secs to go`,
      countDown: this.state.countDown - 1
    }
  );
  if (this.state.countDown <= 0) {
 clearInterval(this.timer);
 this.setState({
 confirmMessage: "Too late to confirm!",
 confirmVisible: false
 });
 }
}

然而,这是不正确的,因为状态是异步更新的,所以在setState调用中更新后,this.state.countDown可能还没有更新下一行。

  1. 相反,我们需要将此代码移动到setState的回调中:
private handleTimerTick() {
  this.setState(
    {
      confirmMessage: `Please hit the confirm button ${
        this.state.countDown
      } secs to go`,
      countDown: this.state.countDown - 1
    },
 () => {
 if (this.state.countDown <= 0) {
 clearInterval(this.timer);
 this.setState({
 confirmMessage: "Too late to confirm!",
 confirmVisible: false
 });
 }
 }
  );
}
  1. 如果点击确认、确定或取消按钮,我们也要停止计时器:
private handleConfirmClick = () => {
  this.setState({ confirmOpen: true });
  clearInterval(this.timer);
};

private handleCancelConfirmClick = () => {
  this.setState(...);
  clearInterval(this.timer);
};

private handleOkConfirmClick = () => {
  this.setState(...;
  clearInterval(this.timer);
};
  1. 我们的最后一项工作是在确认按钮周围加上条件,只有在confirmVisible状态为true时才显示它:
<p>{this.state.confirmMessage}</p>
{this.state.confirmVisible && (
 <button onClick={this.handleConfirmClick}>Confirm</button>
)}
<Confirm ... />

x && y允许我们用单个分支简洁地表达条件。基本上,如果左操作数为假,那么&&的右操作数不会被评估和渲染。

现在,是时候试一试了。当应用程序首次运行时,我们会看到倒计时:

如果我们在十秒内不确认,就太迟了:

componentWillUnmount

componentWillUnmount在组件从 DOM 中移除之前被调用。以下是此方法的一些常见用例:

  • 移除事件监听器

  • 取消活动网络请求

  • 移除计时器

我们将在我们的应用程序中使用componentWillUnmount来确保我们的timer被停止和移除。让我们在componentDidMount方法之后的App类中添加以下内容:

public componentWillUnmount() {
  clearInterval(this.timer);
}

从 props 获取派生状态

getDerivedStateFromProps在每次组件渲染时被调用。它可以用于在某些 props 更改时更改状态。这是组件类中的静态方法,返回更改后的状态,如果状态没有更改则返回 null。

让我们在我们的应用程序中查看这个生命周期方法。在App类的顶部添加以下内容:

public static getDerivedStateFromProps(props: {}, state: IState) {
  console.log("getDerivedStateFromProps", props, state);
  return null;
}

如果我们在应用程序运行时查看控制台,我们会发现我们的方法在倒计时递减时被调用:

在更新之前获取快照和更新后获取快照

getSnapshotBeforeUpdate在更新 DOM 之前调用。从getSnapshotBeforeUpdate返回的值会传递给componentDidUpdate

componentDidUpdate在 DOM 更新后立即被调用。在渲染期间调整窗口大小是getSnapshotBeforeUpdate有用的示例。

让我们来看看我们应用中的这些生命周期方法:

  1. 让我们在App类的顶部附近,在timer变量声明下面添加以下内容:
private renderCount = 0;
  1. 现在,让我们添加生命周期方法:
public getSnapshotBeforeUpdate(prevProps: {}, prevState: IState) {
  this.renderCount += 1;
  console.log("getSnapshotBeforeUpdate", prevProps, prevState, {
    renderCount: this.renderCount
  });
  return this.renderCount;
}

public componentDidUpdate(prevProps: {}, prevState: IState, snapshot: number) {
  console.log("componentDidUpdate", prevProps, prevState, 
  snapshot, {
    renderCount: this.renderCount
  });
}

查看正在运行的应用程序:

我们看到方法按照我们期望的顺序被调用,并且componentDidUpdate成功地从getSnapshotBeforeUpdate中获取了渲染计数。

shouldComponentUpdate

shouldComponentUpdate在渲染发生之前被调用。它返回一个布尔值,用于确定是否应该进行渲染。它可以用于优化性能,防止不必要的渲染周期。

  1. 让我们通过添加以下方法来查看我们应用中的这个生命周期方法:
public shouldComponentUpdate(nextProps: {}, nextState: IState) {
  console.log("shouldComponentUpdate", nextProps, nextState);
  return true;
}

如果我们查看正在运行的应用程序,我们会发现shouldComponentUpdate发生在getDerivedStateFromPropsgetSnapshotBeforeUpdate之间,正如我们所期望的那样。

  1. 现在让我们通过返回false来阻止渲染:
public shouldComponentUpdate(nextProps: {}, nextState: IState) {
  console.log("shouldComponentUpdate", nextProps, nextState);
  return false;
}

我们看到getSnapshotBeforeUpdatecomponentDidUpdate没有被调用,因为在初始渲染之后没有发生渲染:

  1. 在进入下一节之前,让我们通过将此标志设置回true来允许我们的组件再次渲染:
public shouldComponentUpdate(nextProps: {}, nextState: IState) {
  console.log("shouldComponentUpdate", nextProps, nextState);
  return true;
}

shouldComponentUpdate可以通过阻止不必要的渲染来提高性能,但应谨慎使用。它可能引入难以解决的错误。此外,我们需要添加的额外代码来检查是否应该进行渲染实际上可能会减慢应用程序的速度。

已弃用的生命周期方法

在 React 17 中,有一些生命周期方法已被弃用并更名。我们不再需要使用这些方法——getDerivedStateFromPropsgetSnapshotBeforeUpdate基本上取代了它们。然而,以下是这些方法的简要描述,以防您在现有代码库中遇到它们:

  • componentWillMount(现在是UNSAFE_componentWillMount):在初始渲染期间,在组件被添加到 DOM 之前调用

  • componentWillReceiveProps(现在是UNSAFE_componentWillReceiveProps):在组件属性更改时调用

  • componentWillUpdate(现在是UNSAFE_componentWillUpdate):在组件更新之前调用

创建一个函数组件

顾名思义,函数组件是使用 JavaScript 函数实现的。这些组件有时被称为功能无状态组件,这可能有点令人困惑,因为它们在 React 的较新版本中可以包含状态。

创建一个基本的函数组件

让我们重构我们的Confirm组件为一个函数组件,以学习如何实现这些:

  1. 打开Confirm.tsx并用以下内容替换类签名:
const Confirm: React.SFC<IProps> = (props) => {
...
}

我们使用箭头函数来定义一个函数组件,将 props 类型作为泛型参数传递进去。

我们将在本书的后面学习泛型函数,所以如果现在还不太明白,不用担心。

我们使用无状态功能组件SFCReact.SFC来表示这些类型的组件。

我们的组件现在出现了几个编译错误。我们将在接下来的步骤中解决这些问题。

  1. 函数组件没有渲染方法。相反,函数本身返回应该被渲染的 JSX。所以,让我们通过删除render签名来改变我们函数的这部分,并保留返回语句:
return (
  <div
    className={
      this.props.open
        ? "confirm-wrapper confirm-visible"
        : "confirm-wrapper"
    }
  >
      ...
  </div>
);
  1. 我们需要将事件处理程序更改为箭头函数常量,并直接访问 props,而不是通过this。我们还应该将这些处理程序移到return语句之上:
const handleCancelClick = () => {
 props.onCancelClick();
};

const handleOkClick = () => {
 props.onOkClick();
}; 

return ( ... )
  1. 然后我们直接引用 props 和事件处理程序,而不是通过this在我们的 JSX 中:
<div
  className={
    props.open
      ? "confirm-wrapper confirm-visible"
      : "confirm-wrapper"
  }
>
  <div className="confirm-container">
    <div className="confirm-title-container">
      <span>{props.title}</span>
    </div>
    <div className="confirm-content-container">
      <p>{props.content}</p>
    </div>
    <div className="confirm-buttons-container">
      <button className="confirm-cancel" onClick=
       {handleCancelClick}>
        {props.cancelCaption}
      </button>
      <button className="confirm-ok" onClick={handleOkClick}>
        {props.okCaption}
      </button>
    </div>
  </div>
</div>
  1. 我们还有一个关于静态defaultProps变量的问题。我们将这个移出我们的函数,并将其放在函数下面的对象文字中,如下所示:
Confirm.defaultProps = {
  cancelCaption: "Cancel",
  okCaption: "Okay"
}

如果我们查看正在运行的应用程序,所有的编译错误应该都已经解决了,应用程序应该像以前一样工作。

以下代码是一个函数组件的模板。我们的Confirm组件现在应该有类似于这样的结构:

import * as React from "react";

const ComponentName: React.SFC<IProps> = props => {
 const handler = () => {
 ...
 };

 return (
 <div>Our JSX</div>
 );
};
ComponentName.defaultProps = {
 ...
};

export default ComponentName;

因此,函数组件是创建组件的另一种方式。在下一节中,我们将看看如何向函数组件添加状态。

有状态的函数组件

我们已经提到函数组件可以有状态。在本节中,我们将向我们的函数Confirm组件添加状态,以强制用户在关闭之前点击取消按钮两次,如下所示:

  1. 我们将首先通过添加下面突出显示的行来定义和初始化取消按钮被点击的次数状态:
const Confirm: React.SFC<IProps> = props => {

  const [cancelClickCount, setCancelClickCount] = 
  React.useState(0); 
  const handleOkClick = () => {
    props.onOkClick();
  };
  ...
}

这行代码看起来有点奇怪,让我们来分解一下:

  • React.useState是一个 React 函数,它让我们创建状态,将默认值作为参数传递。在我们的例子中,我们将其传递一个默认值为 0。

  • useState函数返回一个包含两个元素的数组:

  • 第一个数组元素包含状态的当前值

  • 第二个数组元素包含将状态设置为不同值的函数。

  • 我们解构数组并将第一个数组元素(状态值)存储在cancelClickCount中,将第二个数组元素(设置状态的函数)存储在setCancelClickCount中。

  • 函数的其余部分现在可以通过cancelClickCount变量访问取消点击计数。该函数还能够通过setCancelClickCount变量增加取消点击计数。

  1. 让我们重构handleCancelClick箭头函数以增加cancelClickCount,并且只在计数达到2时调用onCancelClick属性:
const handleCancelClick = () => {
  const newCount = cancelClickCount + 1;
 setCancelClickCount(newCount);
 if (newCount >= 2) {
 props.onCancelClick();
 }
};

现在,设置状态的函数将新状态作为其参数。

  1. 接下来,我们将更改取消按钮的标题,在第一次点击后显示"真的吗?":
<button className="confirm-cancel" onClick={handleCancelClick}>
  {cancelClickCount === 0 ? props.cancelCaption : "Really?"}
</button>

因此,我们通过在定义状态时解构的变量在 JSX 中访问状态值。

如果我们在运行的应用程序中尝试这样做,我们应该会发现取消按钮文本在第一次点击后更改为"真的吗?",并且确认对话框在第二次点击后关闭。

在我们理解了定义状态所需的代码之后,访问和设置状态就变得非常简单和优雅。

让我们继续到下一部分,并了解如何钩入函数组件的生命周期事件。

函数组件生命周期钩子

我们可以在函数组件的生命周期的某些时刻调用代码。让我们从Confirm组件开始探索这一点,从组件首次渲染开始,如下所示:

  1. 让我们在定义状态的下面添加高亮显示的代码行:
const [cancelClickCount, setCancelClickCount] = React.useState(0);

React.useEffect(() => {
 console.log("Confirm first rendering");
}, []);
  • 我们使用 React 的useEffect函数来钩入组件的生命周期。

  • 该函数接受一个箭头函数,当组件首次渲染时执行。

  • 该函数接受第二个参数,确定何时调用我们的箭头函数。该参数是一个值的数组,当更改时,将调用箭头函数。在我们的例子中,我们传入一个空数组,因此我们的箭头函数在第一次渲染后永远不会被调用。

  • 如果我们现在尝试运行应用程序并打开控制台,我们会看到确认第一次渲染只出现一次。

  1. 现在让我们将useEffect的第二个参数移除:
React.useEffect(() => {
  console.log("Confirm rendering");
});

如果我们查看运行中的应用程序和控制台,我们会看到每次Confirm被渲染时都会出现确认渲染

  1. 让我们再次将其更改为以下内容:
React.useEffect(
  () => {
    console.log("open changed");
  },
  [props.open]
);

如果我们查看运行中的应用程序和控制台,我们会看到每次Confirm组件的open属性值发生变化时都会出现open changed

  1. 那么如何在组件卸载时进行挂钩呢?让我们尝试以下内容:
React.useEffect(() => {
  console.log("Confirm first rendering");
  return () => {
 console.log("Confirm unmounted");
 };
}, []);

因此,我们的箭头函数可以返回一个在组件卸载时执行的函数。

  1. 我们的Confirm组件目前不会卸载,因此在App.tsx中,让我们在倒计时达到0时不再渲染它:
{this.state.countDown > 0 && (
  <Confirm
    ...
  />
)}

如果我们查看运行中的应用程序和控制台,当倒计时达到0时,我们会看到确认卸载出现。

因此,当函数组件首次渲染、当它们的 props 改变以及当它们被卸载时,我们可以执行逻辑。

在下一节中,我们将看一种可以用来优化函数组件渲染周期的方法。

这部分关于 hooks 的内容是基于 React v16.6.0 编写的。当新版本发布时,我们将分享更新的代码。

优化函数组件渲染

我们的Confirm组件实际上被渲染得比它需要的更多。在本节中,我们将对此进行优化,以便它仅在其 props 发生变化时才进行渲染:

  1. 首先,让我们在函数组件的顶部添加一行,这样我们就可以看到它何时被渲染:
console.log("Confirm rendering");

如果我们查看运行中的应用程序和控制台,我们会看到每次App组件倒计时时都会发生渲染。倒计时是在App组件状态中的,状态的改变意味着组件将被重新渲染,以及任何子组件。这就是为什么,没有任何优化的情况下,我们的Confirm组件在每次倒计时时都会被渲染。

  1. 接下来是优化。实际上非常简单:
const ConfirmMemo = React.memo(Confirm);
export default ConfirmMemo;

因此,我们用来自 React 的memo函数包装我们的组件。然后导出这个包装函数。memo函数只在组件的 props 发生变化时才会渲染组件。

如果我们查看运行中的应用程序和控制台,我们会看到我们的组件不再在每次倒计时时被渲染。

因此,考虑到这是多么简单,我们是否应该用memo包装所有的函数组件呢?不!当memo确定组件是否发生变化时,会有性能成本。如果组件实际上没有进行任何不必要的渲染,使用memo会导致组件变慢。

memo应该谨慎使用,只用于需要多次渲染的组件。

鉴于类组件和函数组件的特性相似,我们应该使用哪种类型?实际上并没有直接的答案。如果我们的团队习惯于面向对象的代码,也许基于类的组件会更容易学习。如果我们的团队习惯于更多的函数式编程技术,那么基于函数的组件可能会使他们更加高效。

这两种方法都是创建 React 组件的好方法——选择权在你手中!

总结

在本章中,我们学习了几种不同的方法来创建 React 和 TypeScript 项目。更加手动的方法教会了我们有多少个组成部分。在本书中,我们将经常使用create-react-app来快速创建我们的应用程序。

我们学习了所有的 React 类组件都有一个返回 JSX 的render方法,告诉 React 要显示什么。JSX 非常灵活和强大,因为 JavaScript 表达式可以与 HTML 混合在一起。

我们学习了如何使用 props 配置组件,以及如何向 props 添加 TypeScript 类型,以帮助在使用组件时防止错误。

接下来,我们学习了组件如何管理渲染的内容以及它们如何使用状态。与 props 一样,在 React 和 TypeScript 应用程序中,状态是强类型的。我们在构造函数中初始化状态,并通过setState方法进行更改。

我们还学习了事件处理程序,它们允许我们对用户与我们的组件的交互做出反应。JSX 为我们提供了方便的onEventName属性来处理事件。

接下来,我们了解了可以实现的各种生命周期方法,以在过程的各个阶段执行逻辑。最常用的生命周期方法是componentDidMount,它在组件刚刚添加到 DOM 时发生。

最后,我们学习了函数组件,这是一种实现组件的替代方法。在 React 的最新版本中,我们可以在其中使用状态,访问常见的生命周期钩子,甚至优化渲染周期。

在第三章中,开始使用 React 和 TypeScript,我们将学习如何高效地构建具有多个页面的 React 和 TypeScript 应用程序。

问题

根据我们刚刚学到的知识,回答以下问题:

  1. 在开发过程中,允许调试器语句和向控制台输出日志的 TSLint 设置是什么?

  2. 在 JSX 中,我们如何在类组件中显示一个带有来自名为buttonLabel的 prop 的标签的按钮?

  3. 我们如何使buttonLabel prop 成为可选的,并默认为 Do It?

  4. 在 JSX 中,我们如何只在doItVisible状态为true时显示这个按钮?(假设我们已经声明了一个包含doItVisible的状态类型,并且它已经在构造函数中初始化。)

  5. 我们如何为这个按钮创建一个点击处理程序?

  6. 我们声明了一个包含doItDisabled的状态类型。它也已经在构造函数中初始化。我们如何在点击后将这个状态设置为禁用 Do it 按钮?

  7. 如果按钮在禁用状态下被点击,点击处理程序是否仍然被执行?

  8. 在类组件中,我们将使用哪个生命周期方法来向我们的 React 组件中的非 React 网络组件添加事件处理程序?

  9. 我们将使用哪个生命周期方法来移除这个事件处理程序?

  10. 我们有一个名为Counter的函数组件。它需要包含一个名为count的状态片段,以及一个用于更新它的函数setCount。我们如何定义这个状态并将初始计数默认为 10?

  11. 在前面的Counter组件中,我们有一个需要将count减少 1 的decrement函数。

const decrement = () => {
  // TODO - reduce count by 1
};

这如何实现?

进一步阅读

第四章:使用 React Router 进行路由

如果我们的应用程序有多个页面,我们需要管理不同页面之间的导航。React Router 是一个很棒的库,可以帮助我们做到这一点!

在本章中,我们将构建一个网上商店,我们可以在其中购买一些用于 React 的工具。我们的简单商店将有多个页面,我们将使用 React Router 来管理这些页面。当我们完成时,商店将如下截图所示:

在本章中,我们将学习以下主题:

  • 使用路由类型安装 React Router

  • 声明路由

  • 创建导航

  • 路由参数

  • 处理未找到的路由

  • 实现页面重定向

  • 查询参数

  • 路由提示

  • 嵌套路由

  • 动画过渡

  • 延迟加载路由

技术要求

在本章中,我们将使用以下技术:

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。我们可以从nodejs.org/en/download/安装这些。如果已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint(由 egamma 提供)和 Prettier(由 Estben Petersen 提供)扩展。

本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/04-ReactRouter上找到。

使用路由安装 React Router

React Router及其类型在npm中,因此我们可以从那里安装它们。

在安装 React Router 之前,我们需要创建我们的 React 商店项目。让我们通过选择一个空文件夹并打开 Visual Studio Code 来做好准备。要做到这一点,请按照以下步骤进行:

  1. 现在让我们打开一个终端并输入以下命令来创建一个新的 React 和 TypeScript 项目:
npx create-react-app reactshop --typescript

请注意,我们使用的 React 版本至少需要是16.7.0-alpha.0。我们可以在package.json文件中检查这一点。如果package.json中的 React 版本小于16.7.0-alpha.0,那么我们可以使用以下命令安装此版本:

npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
  1. 项目创建后,让我们将 TSLint 作为开发依赖项添加到我们的项目中,并添加一些与 React 和 Prettier 兼容的规则:
cd reactshop
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在让我们添加一个包含一些规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}
  1. 现在,让我们输入以下命令将 React Router 安装到我们的项目中:
npm install react-router-dom
  1. 让我们还安装 React Router 的 TypeScript 类型,并将它们保存为开发依赖项:
npm install @types/react-router-dom --save-dev

在进入下一节之前,我们将删除一些我们不需要的create-react-app创建的文件:

  1. 首先,让我们删除App组件。因此,让我们删除App.cssApp.test.tsxApp.tsx文件。让我们还在index.tsx中删除对"./App"的导入引用。

  2. 让我们还通过删除serviceWorker.ts文件并在index.tsx中删除对它的引用来删除服务工作者。

  3. index.tsx中,让我们将根组件从<App/>更改为<div/>。我们的index.tsx文件现在应该包含以下内容:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import './index.css';

ReactDOM.render(
  <div />,
  document.getElementById('root') as HTMLElement
);

声明路由

我们使用BrowserRouterRoute组件在应用程序中声明页面。BrowserRouter是顶层组件,它查找其下方的Route组件以确定所有不同的页面路径。

我们将在本节的后面使用BrowserRouterRoute声明一些页面,但在此之前,我们需要创建我们的前两个页面。这第一个页面将包含我们在商店中要出售的 React 工具列表。我们使用以下步骤来创建我们的页面:

  1. 因此,让我们首先通过创建一个ProductsData.ts文件并包含以下内容来为我们的工具列表创建数据:
export interface IProduct {
  id: number;
  name: string;
  description: string;
  price: number;
}

export const products: IProduct[] = [
  {
    description:
      "A collection of navigational components that compose  
       declaratively with your app",
    id: 1,
    name: "React Router",
    price: 8
  },
  {
    description: "A library that helps manage state across your app",
    id: 2,
    name: "React Redux",
    price: 12
  },
  {
    description: "A library that helps you interact with a GraphQL backend",
    id: 3,
    name: "React Apollo",
    price: 12
  }
];
  1. 让我们创建另一个名为ProductsPage.tsx的文件,其中包含以下内容来导入 React 以及我们的数据:
import * as React from "react";
import { IProduct, products } from "./ProductsData";
  1. 我们将在组件状态中引用数据,因此让我们为此创建一个接口:
interface IState {
  products: IProduct[];
}
  1. 让我们继续创建名为ProductsPage的类组件,将状态初始化为空数组:
class ProductsPage extends React.Component<{}, IState> {
  public constructor(props: {}) {
    super(props);
    this.state = {
      products: []
    };
  }
}

export default ProductsPage;
  1. 现在让我们实现componentDidMount生命周期方法,并从ProductData.ts将数据设置为products数组:
public componentDidMount() {
  this.setState({ products });
}
  1. 继续实现render方法,让我们欢迎我们的用户并在列表中列出产品:
public render() {
  return (
    <div className="page-container">
      <p>
        Welcome to React Shop where you can get all your tools for ReactJS!
      </p>
      <ul className="product-list">
        {this.state.products.map(product => (
          <li key={product.id} className="product-list-item">
           {product.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

我们已经在products数组中使用了map函数来迭代元素并为每个产品生成一个列表项标签li。我们需要为每个li赋予一个唯一的key属性,以帮助 React 管理列表项的任何更改,而在我们的情况下是产品的id

  1. 我们已经引用了一些 CSS 类,因此让我们将它们添加到index.css中:
.page-container {
 text-align: center;
 padding: 20px;
 font-size: large;
}

.product-list {
 list-style: none;
 margin: 0;
 padding: 0;
}

.product-list-item {
 padding: 5px;
}
  1. 现在让我们实现我们的第二个页面,即管理面板。因此,让我们创建一个名为AdminPage.tsx的文件,并在其中添加以下功能组件:
import * as React from "react";

const AdminPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Admin Panel</h1>
      <p>You should only be here if you have logged in</p>
    </div>
  );
};

export default AdminPage;
  1. 现在我们的商店中有两个页面,我们可以为它们声明两个路由。让我们创建一个名为Routes.tsx的文件,其中包含以下内容,以从 React Router 中导入ReactBrowserRouterRoute组件,以及我们的两个页面:
import * as React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";

import AdminPage from "./AdminPage";
import ProductsPage from "./ProductsPage";

我们已经在导入语句中将BrowserRouter重命名为Router,以节省一些按键次数。

  1. 接下来,让我们实现一个包含我们两个路由的功能组件:
const Routes: React.SFC = () => {
  return (
    <Router>
      <div>
        <Route path="/products" component={ProductsPage} />
        <Route path="/admin" component={AdminPage} />
      </div>
    </Router>
  );
};

export default Routes;

在渲染过程中,如果Route组件中的path与当前路径匹配,那么该组件将被渲染,如果不匹配,则将渲染null。在我们的例子中,如果路径是"/products",则将渲染ProductPage,如果路径是"/admin",则将渲染AdminPage

  1. 以下是将我们的Routes作为根组件在index.tsx中渲染的最后一步:
import * as React from "react";
import * as ReactDOM from "react-dom";
import "./index.css";
import Routes from "./Routes";

ReactDOM.render(<Routes />, document.getElementById("root") as HTMLElement);
  1. 现在我们应该能够运行我们的应用程序了:
npm start

应用可能会从根页面开始,因为该路径没有指向任何内容,所以页面会是空白的。

  1. 如果我们将路径更改为"/products",我们的产品列表应该呈现如下:

  1. 如果我们将路径更改为"/admin",我们的管理面板应该呈现如下:

现在我们已经成功创建了一些路由,我们真的需要一个导航组件来使我们的页面更加可发现。我们将在下一节中做到这一点。

创建导航

React Router 提供了一些很好的组件来提供导航。我们将使用这些组件来实现应用程序标题中的导航选项。

使用 Link 组件

我们将使用 React Router 中的Link组件来创建我们的导航选项,具体步骤如下:

  1. 让我们从创建一个名为Header.tsx的新文件开始,其中包含以下导入:
import * as React from "react";
import { Link } from "react-router-dom";

import logo from "./logo.svg";
  1. 让我们在Header功能组件中使用Link组件创建两个链接:
const Header: React.SFC = () => {
  return (
    <header className="header">
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        <Link to="/products" className="header-
         link">Products</Link>
        <Link to="/admin" className="header-link">Admin</Link>
      </nav>
    </header>
  );
};

export default Header;

Link组件允许我们定义链接导航到的路径以及要显示的文本。

  1. 我们已经引用了一些 CSS 类,所以让我们把它们添加到index.css中:
.header {
  text-align: center;
  background-color: #222;
  height: 160px;
  padding: 20px;
  color: white;
}

.header-logo {
  animation: header-logo-spin infinite 20s linear;
  height: 80px;
}

@keyframes header-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.header-title {
  font-size: 1.5em;
}

.header-link {
  color: #fff;
  text-decoration: none;
  padding: 5px;
}
  1. 现在我们的Header组件就位了,让我们在Routes.tsximport它:
import Header from "./Header";
  1. 然后我们可以在 JSX 中使用它如下:
<Router>
  <div>
    <Header />
    <Route path="/products" component={ProductsPage} />
    <Route path="/admin" component={AdminPage} />
  </div>
</Router>
  1. 如果我们检查正在运行的应用程序,它应该看起来像以下截图,有一个漂亮的标题和两个导航选项,可以转到我们的产品和管理页面:

  1. 尝试点击导航选项-它们有效!如果我们使用浏览器开发者工具检查产品和管理元素,我们会看到 React Router 已将它们呈现为锚标签:

如果我们在点击导航选项时查看开发者工具中的网络选项卡,我们会看到没有网络请求正在被发出来为我们的 React 应用程序提供页面。这表明 React Router 正在处理我们的导航。

使用 NavLink 组件

React Router 还提供了另一个用于链接页面的组件,称为NavLink。实际上,这更适合我们的需求。以下步骤解释了我们如何重构我们的Header组件以使用NavLink

  1. 所以,让我们在我们的Header组件中用NavLink替换Link并进行一些改进:
import * as React from "react";
import { NavLink } from "react-router-dom";

import logo from "./logo.svg";

const Header: React.SFC = () => {
  return (
    <header className="header">
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        <NavLink to="/products" className="header-
         link">Products</NavLink>
        <NavLink to="/admin" className="header-
         link">Admin</NavLink>
      </nav>
    </header>
  );
};

export default Header;

此时,我们的应用程序看起来和行为都完全一样。

  1. NavLink公开了一个activeClassName属性,我们可以用它来设置活动链接的样式。所以,让我们使用它:
<NavLink to="/products" className="header-link" activeClassName="header-link-active">
  Products
</NavLink>
<NavLink to="/admin" className="header-link" activeClassName="header-link-active">
  Admin
</NavLink>
  1. 让我们将header-link-active的 CSS 添加到我们的index.css中:
.header-link-active {
  border-bottom: #ebebeb solid 2px;
}
  1. 如果我们现在切换到正在运行的应用程序,活动链接将被下划线划掉:

因此,NavLink非常适合主应用程序导航,我们希望突出显示活动链接,而Link非常适合应用程序中的所有其他链接。

路由参数

路由参数是路径的可变部分,在目标组件中可以使用它们来有条件地渲染某些内容。

我们需要向我们的商店添加另一个页面,以显示每个产品的描述和价格,以及将其添加到购物篮的选项。我们希望能够使用"/products/{id}"路径导航到此页面,其中id是产品的 ID。例如,到达 React Redux 的路径将是"products/2"。因此,路径的id部分是一个路由参数。我们可以通过以下步骤来完成所有这些:

  1. 让我们在两个现有路由之间的Routes.tsx中添加此路由。路由的id部分将是一个路由参数,我们在其前面用冒号定义它:
<Route path="/products" component={ProductsPage} />
<Route path="/products/:id" component={ProductPage} />
<Route path="/admin" component={AdminPage} />
  1. 当然,ProductPage组件还不存在,所以,让我们首先创建一个名为ProductPage.tsx的新文件,其中包含以下导入:
import * as React from "react";
import { RouteComponentProps } from "react-router-dom";
import { IProduct, products } from "./ProductsData";
  1. 关键部分在于我们将使用RouteComponentProps类型来访问路径中的id参数。让我们使用RouteComponentProps通用类型来定义我们的ProductPage组件的 props 类型别名,并传入一个具有id属性的类型:
type Props = RouteComponentProps<{id: string}>;

如果您不理解type表达式中的尖括号,不要担心。这表示一个通用类型,我们将在第五章中探讨高级类型

理想情况下,我们应该将id属性指定为数字,以匹配产品数据中的类型。但是,RouteComponentProps只允许我们拥有类型为字符串或未定义的路由参数。

  1. ProductPage组件将具有状态来保存正在呈现的产品以及它是否已添加到购物篮中,因此让我们为我们的状态定义一个接口:
interface IState {
  product?: IProduct;
  added: boolean;
}
  1. 产品最初将是undefined,这就是为什么它被定义为可选的。让我们创建我们的ProductPage类并初始化状态,以便产品不在购物篮中:
class ProductPage extends React.Component<Props, IState> {
  public constructor(props: Props) {
    super(props);
    this.state = {
      added: false
    };
  }
}

export default ProductPage;
  1. 当组件加载到 DOM 中时,我们需要使用Route参数中的id属性从产品数据中找到我们的产品。RouteComponentProps给我们一个包含params对象的match对象,其中包含我们的id路由参数。所以,让我们实现这个:
public componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    const product = products.filter(p => p.id === id)[0];

    this.setState({ product });
  }
}

请记住,id路由参数是一个字符串,这就是为什么我们在将其与filter数组中的产品数据进行比较之前,将其转换为数字使用parseInt

  1. 现在我们已经在组件状态中有了我们的产品,让我们继续进行render函数:
public render() {
  const product = this.state.product;
  return (
    <div className="page-container">
      {product ? (
        <React.Fragment>
          <h1>{product.name}</h1>
          <p>{product.description}</p>
          <p className="product-price">
            {new Intl.NumberFormat("en-US", {
              currency: "USD",
              style: "currency"
            }).format(product.price)}
          </p>
          {!this.state.added && (
            <button onClick={this.handleAddClick}>Add to 
              basket</button>
          )}
        </React.Fragment>
      ) : (
        <p>Product not found!</p>
      )}
    </div>
  );
}

在这个 JSX 中有一些有趣的地方:

  • 在函数内的第一行,我们将product变量设置为产品状态,以节省一些按键,因为我们在 JSX 中引用产品很多。

  • div内的三元运算符在有产品时呈现产品。否则,它会通知用户找不到产品。

  • 我们在三元运算符的真部分中使用React.Fragment,因为三元运算符的每个部分只能有一个父级,React.Fragment是一种实现这一点的机制,而不需要渲染像div这样的不是真正需要的标记。

  • 我们使用Intl.NumberFormat将产品价格格式化为带有货币符号的货币。

  1. 当单击“添加到购物篮”按钮时,我们还将调用handleAddClick方法。我们还没有实现这一点,所以现在让我们这样做,并将added状态设置为true
private handleAddClick = () => {
  this.setState({ added: true });
};
  1. 现在我们已经实现了ProductPage组件,让我们回到Routes.tsx并导入它:
import ProductPage from "./ProductPage";
  1. 让我们打开我们的运行中的应用,输入"/products/2"作为路径:

不太符合我们的要求!ProductsPageProductPage都被渲染了,因为"/products/2"同时匹配"/products""/products/:id"

  1. 为了解决这个问题,我们可以告诉"/products"路由只在有精确匹配时才进行渲染:
<Route exact={true} path="/products" component={ProductsPage} />
  1. 在我们进行这些更改并保存Routes.tsx之后,我们的产品页面看起来好多了:

  1. 我们不打算让用户输入特定的路径来访问产品!因此,我们将更改ProductsPage,使用Link组件为每个产品链接到ProductPage。首先,让我们从 React Router 中导入LinkProductsPage中:
import { Link } from "react-router-dom";
  1. 现在,我们不再在每个列表项中渲染产品名称,而是要渲染一个Link组件,用于跳转到我们的产品页面:
public render() {
  return (
    <div className="page-container">
      <p>
        Welcome to React Shop where you can get all your tools 
         for ReactJS!
      </p>
      <ul className="product-list">
        {this.state.products.map(product => (
          <li key={product.id} className="product-list-item">
            <Link to={`/products/${product.id}`}>{product.name}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
  1. 在我们查看运行中的应用之前,让我们在index.css中添加以下 CSS 类:
.product-list-item a {
  text-decoration: none;
}

现在,如果我们在应用中的产品列表中点击一个列表项,它会带我们到相关的产品页面。

处理未找到的路由

如果用户输入了我们应用中不存在的路径会怎么样?例如,如果我们尝试导航到"/tools",我们在标题下面什么都看不到。这是有道理的,因为 React Router 没有找到匹配的路由,所以什么都没有渲染。然而,如果用户导航到无效的路径,我们希望通知他们该路径不存在。以下步骤可以实现这一点:

  1. 因此,让我们创建一个名为NotFoundPage.tsx的新文件,其中包含以下组件:
import * as React from "react";

const NotFoundPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Sorry, this page cannot be found</h1>
    </div>
  );
};

export default NotFoundPage;
  1. 让我们在Routes.tsx中导入这个:
import NotFoundPage from "./NotFoundPage";
  1. 然后让我们在其他路由中添加一个Route组件:
<Router>
  <div>
    <Header />
    <Route exact={true} path="/products" component={ProductsPage} 
       />
    <Route path="/products/:id" component={ProductPage} />
    <Route path="/admin" component={AdminPage} />
    <Route component={NotFoundPage} />
  </div>
</Router>

然而,这将对每个路径进行渲染:

当没有找到其他路由时,我们如何只渲染NotFoundPage?答案是在 React Router 中用Switch组件包裹路由。

  1. 首先在Routes.tsx中导入Switch
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
  1. 现在让我们将Route组件包裹在Switch组件中:
<Switch>
  <Route exact={true} path="/products" component={ProductsPage} />
  <Route path="/products/:id" component={ProductPage} />
  <Route path="/admin" component={AdminPage} />
  <Route component={NotFoundPage} />
</Switch>

Switch组件只渲染第一个匹配的Route组件。如果我们查看运行中的应用,我们会发现我们的问题已经解决了。如果输入一个不存在的路径,我们会得到一个友好的未找到消息:

实现页面重定向

React Router 有一个名为Redirect的组件,我们可以用它来重定向到页面。我们在接下来的几节中使用这个组件来改进我们的商店。

简单重定向

如果我们访问/路由路径,我们会注意到我们得到了“抱歉,找不到此页面”的消息。让我们把它改成在路径为/时重定向到"/products"

  1. 首先,我们需要在Routes.tsx中导入Redirect组件:
import { BrowserRouter as Router, Redirect,Route, Switch } from "react-router-dom";
  1. 现在我们可以使用Redirect组件在路径为/时重定向到"/products"
<Switch>
 <Redirect exact={true} from="/" to="/products" />
  <Route exact={true} path="/products" component={ProductsPage} 
   />
  <Route path="/products/:id" component={ProductPage} />
  <Route path="/admin" component={AdminPage} />
  <Route component={NotFoundPage} />
</Switch>
  1. 我们在Redirect上使用了exact属性,以便它只匹配/而不匹配"/products/1""/admin"。如果我们尝试在运行的应用程序中输入/作为路径,它将立即重定向到"/products"

条件重定向

我们可以使用Redirect组件来保护未经授权的用户访问页面。在我们的商店中,我们可以使用这个来确保只有已登录的用户可以访问我们的 Admin 页面。我们通过以下步骤来实现这一点:

  1. 让我们首先在Routes.tsx中的 Admin 页面路由之后添加一个到LoginPage的路由:
<Route path="/login" component={LoginPage} />
  1. 当然,LoginPage目前不存在,所以让我们创建一个名为LoginPage.tsx的文件并输入以下内容:
import * as React from "react";

const LoginPage: React.SFC = () => {
  return (
    <div className="page-container">
      <h1>Login</h1>
      <p>You need to login ...</p>
    </div>
  );
};

export default LoginPage;
  1. 然后我们可以回到Routes.tsx并导入LoginPage
import LoginPage from "./LoginPage";
  1. 如果我们去运行的应用程序并导航到"/login",我们会看到我们的登录页面:

我们不打算完全实现我们的登录页面;我们已经实现的页面足以演示条件重定向。

  1. 在我们在Routes.tsx中实现"admin"路径的条件重定向之前,我们需要在Routes.tsx中添加一个关于用户是否已登录的状态:
const Routes: React.SFC = () => {
  const [loggedIn, setLoggedIn] = React.useState(false);
  return (
    <Router>
      ...
    </Router>
   );
};

因此,我们使用了useState钩子来添加一个名为loggedIn的状态变量和一个名为setLoggedIn的函数。

  1. 最后一步是在"/admin"路径的Route组件内添加以下内容:
<Route path="/admin">
 {loggedIn ? <AdminPage /> : <Redirect to="/login" 
 />}
</Route>

如果用户已登录,我们有条件地渲染AdminPage,否则,我们重定向到"/login"路径。如果我们现在在运行的应用程序中点击admin链接,我们将被重定向到登录页面。

  1. 如果我们将loggedIn状态更改为 true,我们就能再次访问我们的 Admin 页面:
const [loggedIn, setLoggedIn] = React.useState(true);

查询参数

查询参数是 URL 的一部分,允许将附加参数传递到路径中。例如,"/products?search=redux"有一个名为search的查询参数,值为redux

让我们实现这个例子,让商店的用户可以搜索产品:

  1. 让我们首先在ProductsPage.tsx中的状态中添加一个名为search的变量,它将保存搜索条件:
interface IState {
  products: IProduct[];
  search: string;
}
  1. 鉴于我们需要访问 URL,我们需要在ProductsPage中使用RouteComponentProps作为props类型。让我们首先导入这个:
import { RouteComponentProps } from "react-router-dom";
  1. 然后我们可以将其用作props类型:
class ProductsPage extends React.Component<RouteComponentProps, IState> {
  1. 我们可以在constructor中将search状态初始化为空字符串:
public constructor(props: RouteComponentProps) {
  super(props);
  this.state = {
    products: [],
    search: ""
  };
}
  1. 然后我们需要在componentDidMount中将search状态设置为搜索查询参数。React Router 通过location.searchprops参数中给我们访问所有查询参数。然后我们需要解析该字符串以获取我们的搜索查询字符串参数。我们可以使用URLSearchParamsJavaScript 函数来做到这一点。我们将使用静态的getDerivedStateFromProps生命周期方法来做到这一点,当组件加载时以及其props参数发生变化时会调用该方法:
public static getDerivedStateFromProps(
  props: RouteComponentProps,
  state: IState
) {
  const searchParams = new URLSearchParams(props.location.search);
  const search = searchParams.get("search") || "";
  return {
    products: state.products,
    search
  };
}
  1. 不幸的是,URLSearchParams在所有浏览器中尚未实现,因此我们可以使用一个名为url-search-params-polyfill的 polyfill。让我们安装这个:
npm install url-search-params-polyfill
  1. 让我们将其导入到ProductPages.tsx中:
import "url-search-params-polyfill";
  1. 然后我们可以在render方法中使用search状态,通过在返回的列表项周围包装一个if语句,只有在产品名称中包含search的值时才返回结果:
<ul className="product-list">
  {this.state.products.map(product => {
    if (
 !this.state.search ||
 (this.state.search &&
 product.name
 .toLowerCase()
 .indexOf(this.state.search.toLowerCase()) > -1)
 ) {
      return (
        <li key={product.id} className="product-list-item">
          <Link to={`/products/${product.id}`}>{product.name}
           </Link>
        </li>
      );
    } else {
 return null;
 }
  })}
</ul>
  1. 如果我们在运行的应用程序中输入"/products?search=redux"作为路径,我们将看到我们的产品列表仅包含 React Redux:

  1. 我们将通过在应用程序标题中添加一个搜索输入来完成实现此功能,该输入将设置搜索查询参数。让我们首先在Header.tsx中的Header组件中创建一些状态来存储搜索值:
 const [search, setSearch] = React.useState("");
  1. 我们还需要通过 React Router 和URLSearchParams访问查询字符串,所以让我们导入RouteComponentPropswithRouterURLSearchParams polyfill:
import { NavLink, RouteComponentProps, withRouter} from "react-router-dom";
import "url-search-params-polyfill";
  1. 让我们向Header组件添加一个props参数:
const Header: React.SFC<RouteComponentProps> = props => { ... }
  1. 现在我们可以从路径查询字符串中获取搜索值,并在组件首次渲染时将search状态设置为该值:
const [search, setSearch] = React.useState("");
React.useEffect(() => {
  const searchParams = new URLSearchParams(props.location.search);
 setSearch(searchParams.get("search") || "");
}, []);
  1. 现在让我们在render方法中添加一个search输入,让用户输入他们的搜索条件:
public render() {
  return (
    <header className="header">
      <div className="search-container">
 <input
 type="search"
 placeholder="search"
 value={search}
 onChange={handleSearchChange}
 onKeyDown={handleSearchKeydown}
 />
 </div>
      <img src={logo} className="header-logo" alt="logo" />
      <h1 className="header-title">React Shop</h1>
      <nav>
        ...
      </nav>
    </header>
  );
}
  1. 让我们将刚刚引用的search-container CSS 类添加到index.css中:
.search-container {
  text-align: right;
  margin-bottom: -25px;
}
  1. 回到Header.tsx,让我们添加handleSearchChange方法,该方法在render方法中被引用,并将保持我们的search状态与输入的值保持同步:
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setSearch(e.currentTarget.value);
};
  1. 现在我们可以实现handleSearchKeydown方法,该方法在render方法中被引用。当按下Enter键时,这需要将search状态值添加到路径查询字符串中。我们可以利用RouteComponentProps给我们的history属性中的push方法:
const handleSearchKeydown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === "Enter") {
    props.history.push(`/products?search=${search}`);
  }
};
  1. 我们需要导出使用withRouter高阶组件包装的Header组件,以便引用this.props.history能够工作。所以,让我们这样做并调整我们的export表达式:
export default withRouter(Header);
  1. 让我们在运行的应用程序中尝试一下。如果我们在搜索输入中输入redux并按下Enter键,应用程序应该导航到产品页面并将产品过滤为 React Redux:

路由提示

有时,我们可能希望要求用户确认他们是否要离开页面。如果用户在页面上进行数据输入并在保存数据之前按导航链接转到另一页,这将很有用。React Router 中的Prompt组件允许我们执行此操作,如下所述:

  1. 在我们的应用程序中,如果用户尚未将产品添加到其购物篮中,我们将提示用户确认是否要离开产品页面。首先,在ProductPage.tsx中,让我们从 React Router 中导入Prompt组件:
import { Prompt, RouteComponentProps } from "react-router-dom";
  1. Prompt组件在满足某些条件时在导航期间调用确认对话框。我们可以在我们的 JSX 中使用Prompt组件如下:
<div className="page-container">
  <Prompt when={!this.state.added} message={this.navAwayMessage}
  />
  ...
</div>

when属性允许我们指定对话框何时出现的表达式。在我们的情况下,这是当产品尚未添加到购物篮时。

message属性允许我们指定一个返回要在对话框中显示的消息的函数。

  1. 在我们的情况下,我们调用一个navAwayMessage方法,接下来我们将实现:
private navAwayMessage = () =>
    "Are you sure you leave without buying this product?";
  1. 让我们尝试一下,通过导航到 React Router 产品,然后在不点击添加到购物篮按钮的情况下离开:

我们被要求确认是否要离开。

嵌套路由

嵌套路由是指 URL 超过一个级别,并且呈现多个组件。在本节中,我们将实现一些嵌套路由在我们的管理页面中。我们完成的管理页面将如下截图所示:

前面截图中的 URL 有 3 个级别,会显示如下内容:

  • 包含用户和产品链接的顶级菜单。

  • 包含所有用户的菜单。在我们的示例中只有 Fred、Bob 和 Jane。

  • 所选用户的信息。

  1. 让我们开始打开AdminPage.tsx并从react-router-dom中为以下内容添加import语句:
import { NavLink, Route, RouteComponentProps } from "react-router-dom";
  • 我们将使用NavLink组件来呈现菜单。

  • Route组件将用于渲染嵌套路由

  • RouteComponentProps类型将用于从 URL 获取用户的id

  1. 我们将用无序列表替换p标签,其中包含菜单选项 Users 和 Products:
<div className="page-container">
  <h1>Admin Panel</h1>
  <ul className="admin-sections>
 <li key="users">
 <NavLink to={`/admin/users`} activeClassName="admin-link-
        active">
 Users
 </NavLink>
 </li>
 <li key="products">
 <NavLink to={`/admin/products`} activeClassName="admin-link-
       active">
 Products
 </NavLink>
 </li>
 </ul>
</div>

我们使用NavLink组件导航到两个选项的嵌套路由。

  1. 让我们在index.css中添加我们刚刚引用的 CSS 类:
.admin-sections {
  list-style: none;
  margin: 0px 0px 20px 0px;
  padding: 0;
}

.admin-sections li {
  display: inline-block;
  margin-right: 10px;
}

.admin-sections li a {
  color: #222;
  text-decoration: none;
}

.admin-link-active {
  border-bottom: #6f6e6e solid 2px;
}
  1. 回到AdminPage.tsx,让我们在我们刚刚添加的菜单下面添加两个Route组件。这些将处理我们在菜单中引用的/admin/users/admin/products路径:
<div className="page-container">
  <h1>Admin Panel</h1>
  <ul className="admin-sections">
    ...
  </ul>
 <Route path="/admin/users" component={AdminUsers} />
 <Route path="/admin/products" component={AdminProducts} />
</div>
  1. 我们刚刚引用了尚不存在的AdminUsersAdminProducts组件。让我们首先在AdminPage.tsx中的AdminPage组件下面输入以下内容来实现AdminProducts组件:
const AdminProducts: React.SFC = () => {
  return <div>Some options to administer products</div>;
};

因此,此组件只在屏幕上呈现一些文本。

  1. 现在让我们继续处理AdminUsers组件,这更加复杂。我们将从在AdminPage.tsx中的AdminProducts组件下面定义用户接口以及一些用户数据开始:
interface IUser {
  id: number;
  name: string;
  isAdmin: boolean;
}
const adminUsersData: IUser[] = [
  { id: 1, name: "Fred", isAdmin: true },
  { id: 2, name: "Bob", isAdmin: false },
  { id: 3, name: "Jane", isAdmin: true }
];

所以,在我们的示例中有 3 个用户。

  1. 让我们开始在AdminPage.tsx中实现AdminUsers组件:
const AdminUsers: React.SFC = () => {
  return (
    <div>
      <ul className="admin-sections">
        {adminUsersData.map(user => (
          <li>
            <NavLink
              to={`/admin/users/${user.id}`}
              activeClassName="admin-link-active"
            >
              {user.name}
            </NavLink>
          </li>
        ))}
      </ul>
    </div>
  );
};

该组件呈现一个包含每个用户名称的链接。该链接是到一个嵌套路径,最终将显示有关用户的详细信息。

  1. 因此,我们需要定义另一个路由,调用一个组件来渲染有关用户的详细信息。我们可以通过使用另一个Route组件来实现这一点:
<div>
  <ul className="admin-sections">
    ...
  </ul>
 <Route path="/admin/users/:id" component={AdminUser} />
</div>
  1. 我们刚刚定义的路径路由到一个我们还没有定义的AdminUser组件。所以,让我们从AdminUsers组件下面开始:
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  return null;
};

我们使用RouteComponentProps从 URL 路径中获取id并在 props 中使其可用。

  1. 现在,我们可以使用路径中的id来从我们的adminUsersData数组中获取用户:
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  let user: IUser;
 if (props.match.params.id) {
 const id: number = parseInt(props.match.params.id, 10);
 user = adminUsersData.filter(u => u.id === id)[0];
 } else {
 return null;
 }
  return null;
};
  1. 现在我们有了user对象,我们可以呈现其中的信息。
const AdminUser: React.SFC<RouteComponentProps<{ id: string }>> = props => {
  let user: IUser;
  if (props.match.params.id) {
    const id: number = parseInt(props.match.params.id, 10);
    user = adminUsersData.filter(u => u.id === id)[0];
  } else {
    return null;
  }
  return (
 <div>
 <div>
 <b>Id: </b>
 <span>{user.id.toString()}</span>
 </div>
 <div>
 <b>Is Admin: </b>
 <span>{user.isAdmin.toString()}</span>
 </div>
 </div>
 );
};
  1. 如果我们转到运行的应用程序,转到管理页面并单击产品菜单项,它将如下所示:

  1. 如果我们单击用户菜单项,我们将看到我们可以单击以获取有关用户的更多信息的 3 个用户。这将看起来像本节中的第一个截图。

因此,为了实现嵌套路由,我们使用NavLinkLink组件创建必要的链接,并使用Route组件将这些链接路由到要使用Route组件呈现内容的组件。在本节之前,我们已经了解了这些组件,所以我们只需要学习如何在嵌套路由的上下文中使用它们。

动画过渡

在本节中,当用户导航到不同页面时,我们将添加一些动画。我们使用react-transition-group npm包中的TransitionGroupCSSTransition组件来实现这一点,如下所示:

  1. 因此,让我们首先安装此包及其 TypeScript 类型:
npm install react-transition-group
npm install @types/react-transition-group --save-dev

TransitionGroup跟踪其所有子元素并计算子元素何时进入或退出。CSSTransitionTransitionGroup获取子元素是离开还是退出,并根据该状态对子元素应用 CSS 类。因此,TransitionGroupCSSTransition可以包装我们的路由并调用我们可以创建的 CSS 类,以实现页面的进出动画。

  1. 因此,让我们将这些组件导入Routes.tsx
import { CSSTransition, TransitionGroup } from "react-transition-group";
  1. 我们还需要从 React Router 中导入RouteComponentProps
import { Redirect, Route, RouteComponentProps, Switch} from "react-router-dom";
  1. 让我们将RouteComponentProps用作Route组件的 props 类型:
const Routes: React.SFC<RouteComponentProps> = props => {
  ...
}
  1. 让我们将CSSTransitionTransitionGroup组件添加到Switch组件周围的 JSX 中:
<TransitionGroup>
  <CSSTransition
    key={props.location.key}
    timeout={500}
    classNames="animate"
    >
    <Switch>
      ...
    </Switch>
  </CSSTransition>
</TransitionGroup>

TransitionGroup要求子元素具有唯一的key,以确定何时退出和进入。因此,我们已经指定了CSSTransition上的key属性为RouteComponentPropslocation.key属性。我们已经指定了过渡将在半秒内运行的timeout属性。我们还指定了将使用animate前缀调用的 CSS 类,通过classNames属性。

  1. 因此,让我们在index.css中添加这些 CSS 类:
.animate-enter {
  opacity: 0;
  z-index: 1;
}
.animate-enter-active {
  opacity: 1;
  transition: opacity 450ms ease-in;
}
.animate-exit {
  display: none;
}

CSSTransition将在其键更改时调用这些 CSS 类。这些 CSS 类最初隐藏了正在过渡的元素,并逐渐缓解了元素的不透明度,以便显示出来。

  1. 如果我们转到index.tsx,我们会得到一个编译错误,因为它期望我们传递来自路由器的history等 props 给Routes组件:

不幸的是,我们无法使用withRouter高阶组件,因为这将位于Router组件之外。为了解决这个问题,我们可以添加一个名为RoutesWrap的新组件,它不接受任何 props,并包装我们现有的Routes组件。Router将移动到RoutesWrap,并包含一个始终渲染我们的Routes组件的Route组件。

  1. 因此,让我们将RoutesWrap组件添加到Routes.tsx中,并导出RoutesWrap而不是Routes
const RoutesWrap: React.SFC = () => {
 return (
 <Router>
 <Route component={Routes} />
 </Router>
 );
};

class Routes extends React.Component<RouteComponentProps, IState> { 
  ...
}

export default RoutesWrap;

编译错误消失了,这太棒了。

  1. 现在让我们从我们的Routes组件中删除Router,将div标签作为其根:
public render() {
  return (
    <div>
      <Header />
      <TransitionGroup>
        ...
      </TransitionGroup>
    </div>
  );
}

如果我们转到运行的应用程序并导航到不同的页面,您将看到一个很好的淡入淡出动画,当页面进入视图时。

延迟加载路由

目前,当应用程序首次加载时,将加载我们应用程序的所有 JavaScript。这包括用户不经常使用的管理页面。如果AdminPage组件在应用程序加载时不加载,而是按需加载,那将是很好的。这正是我们将在本节中要做的。这称为“延迟加载”组件。以下步骤允许我们按需加载内容:

  1. 首先,我们将从 React 中导入Suspense组件,稍后我们将使用它:
import { Suspense } from "react";
  1. 现在,我们将以不同的方式导入AdminPage组件:
const AdminPage = React.lazy(() => import("./AdminPage"));

我们使用一个名为lazy的 React 函数,它接受一个返回动态导入的函数,然后将其分配给我们的AdminPage组件变量。

  1. 在我们这样做之后,我们可能会遇到一个 linting 错误:在 ES5/ES3 中进行动态导入调用需要'Promise'构造函数。确保您有'Promise'构造函数的声明,或在--lib选项中包含'ES2015'。因此,在tsconfig.json中,让我们添加lib编译器选项:
"compilerOptions": { 
  "lib": ["es6", "dom"],
  ...
}
  1. 接下来的部分是在AdminPage组件周围包装Suspense组件:
<Route path="/admin">
  {loggedIn ? (
    <Suspense fallback={<div className="page-container">Loading...</div>}>
      <AdminPage />
    </Suspense>
  ) : (
    <Redirect to="/login" />
  )}
</Route>

Suspense组件显示一个包含 Loading...的div标签,同时加载AdminPage

  1. 让我们在运行的应用程序中尝试这个。让我们打开浏览器开发者工具,转到网络选项卡。在我们的应用程序中,让我们转到产品页面并刷新浏览器。然后清除开发者工具中网络选项卡中的内容。如果我们然后转到应用程序中的管理页面并查看网络选项卡中的内容,我们将看到动态加载AdminPage组件的 JavaScript

  1. AdminPage组件加载非常快,所以我们从来没有真正看到 Loading ... div标签。所以,让我们在浏览器开发者工具中减慢连接速度:

  1. 如果我们然后刷新浏览器,再次转到管理页面,我们将看到 Loading ...:

在这个例子中,AdminPage组件并不是很大,所以这种方法并没有真正对性能产生积极影响。然而,按需加载更大的组件确实可以帮助提高性能,特别是在慢速连接上。

总结

React Router 为我们提供了一套全面的组件,用于管理应用程序中页面之间的导航。我们了解到顶层组件是Router,它在其下寻找Route组件,我们在其中定义了应该为特定路径呈现哪些组件。

Link组件允许我们链接到应用程序中的不同页面。我们了解到NavLink组件类似于Link,但它包括根据是否为活动路径来设置样式的能力。因此,NavLink非常适合应用程序中的主导航元素,而Link非常适合出现在页面上的其他链接。

RouteComponentProps是一种类型,它使我们能够访问路由参数和查询参数。我们发现 React Router 不会为我们解析查询参数,但可以使用原生 JavaScript URLSearchParams接口来为我们做这个。

Redirect组件在特定条件下重定向到路径。我们发现这非常适合保护只有特权用户可以访问的页面。

Prompt组件允许我们在特定条件下要求用户确认他们是否要离开页面。我们在产品页面上使用它来再次确认用户是否要购买产品。这个组件的另一个常见用例是在输入的数据没有保存时,确认离开数据输入页面的导航。

我们了解到嵌套路由如何为用户提供进入应用程序特定部分的深链接。我们只需使用LinkNavLinkRoute组件来定义相关链接以处理这些链接。

我们使用react-transition-group npm包中的TransitionGroupCSSTransition组件改进了我们的应用体验。我们将这些组件包裹在定义应用路径的Route组件周围,并添加了 CSS 类来实现我们希望页面退出和进入视图时的动画效果。

我们了解到,React 的lazy函数以及其Suspense组件可以用于按需加载用户很少使用的大型组件。这有助于提高应用程序的启动时间性能。

问题

让我们通过以下问题来测试我们对 React Router 的了解:

  1. 我们有以下显示客户列表的Route组件:
<Route path="/customers" component={CustomersPage} />

当页面是"/customers"时,CustomersPage组件会渲染吗?

  1. 当页面是"/customers/24322"时,CustomersPage组件会渲染吗?

  2. 我们只希望在路径为"/customers"时,CustomersPage组件才会渲染。我们如何更改Route上的属性来实现这一点?

  3. 什么样的Route组件可以处理"/customers/24322"路径?它应该将"24322"放在名为customerId的路由参数中。

  4. 我们如何捕获不存在的路径,以便通知用户?

  5. 我们如何在CustomersPage中实现search查询参数?因此,"/customers/?search=Cool Company"将显示名称为"Cool Company"的客户。

  6. 过了一会儿,我们决定将"customer"路径更改为"clients"。我们如何实现这一点,以便用户仍然可以使用现有的"customer"路径,但路径会自动重定向到新的"client"路径?

进一步阅读

第五章:高级类型

我们已经学习了相当多的 TypeScript 类型系统知识。在本章中,我们将继续这个旅程,这次深入一些更高级的类型和概念,这将帮助我们在本书后面创建可重用的强类型 React 组件。

我们将学习如何将现有类型组合成联合类型。我们将在第八章React Redux中发现,这些类型对于创建强类型的 React Redux 代码至关重要。

我们在第二章中简要介绍了类型守卫,TypeScript 3 有什么新特性,当时我们学习了unknown类型。在本章中,我们将更详细地了解这些内容。

泛型是 TypeScript 的一个特性,许多库使用它允许消费者使用其库创建强类型应用程序。React 本身在类组件中使用它,允许我们在组件中创建强类型的 props 和 states。我们将在本章中详细介绍泛型。

重载签名是一个很好的功能,允许我们的单个函数接受不同组合的参数。我们将在本章中学习如何使用这些内容。

查找和映射类型允许我们从现有类型动态创建新类型。我们将在本章末尾详细了解这些内容。

在本章中,我们将学习以下主题:

  • 联合类型

  • 类型守卫

  • 泛型

  • 重载签名

  • 查找和映射类型

技术要求

在本章中,我们将使用以下技术:

  • TypeScript playground:这是一个网站,网址为www.typescriptlang.org/play,允许我们在不安装 TypeScript 的情况下进行实验和了解其特性。在本章中,我们将大部分时间使用这个网站。

  • Visual Studio Code:我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/网站安装。我们还需要在 Visual Studio Code 中安装TSLint(由 egamma 提供)和Prettier(由 Esben Petersen 提供)扩展。

本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/05-AdvancedTypes.

联合类型

顾名思义,联合类型是我们可以组合在一起形成新类型的类型。联合类型通常与字符串文字类型一起使用,我们将在第一部分中介绍。联合类型可以用于一种称为辨识联合的模式,我们可以在创建通用和可重用的 React 组件时使用它。

字符串文字类型

字符串文字类型的变量只能被赋予字符串文字类型中指定的确切字符串值。

在 TypeScript playground 中,让我们通过一个例子来看一下:

  1. 让我们创建一个名为Control的字符串文字类型,它只能设置为"Textbox"字符串:
type Control = "Textbox";
  1. 现在让我们创建一个名为notes的变量,使用我们的Control类型,并将其设置为"Textbox"
let notes: Control;
notes = "Textbox";

正如我们所期望的,TypeScript 编译器对此非常满意。

  1. 现在让我们将变量设置为不同的值:
notes = "DropDown";

我们得到了编译错误,类型"DropDown"不能赋值给类型"Textbox"

  1. 与 TypeScript 中的所有其他类型一样,nullundefined也是有效的值:
notes = null;
notes = undefined;

字符串文字类型本身并不那么有用。然而,当它们用于联合类型时,它们变得非常有用,我们将在下一部分中看到。

字符串文字联合类型

字符串文字联合类型是指我们将多个字符串文字类型组合在一起。

让我们从上一个例子继续,并通过这个例子来看一下。

  1. 让我们增强我们的Control类型,使其成为字符串文字的联合类型:
type Control = "Textbox" | "DropDown"

我们使用|在联合类型中组合类型。

  1. 现在将我们的notes变量设置为"Textbox""DropDown"现在是完全有效的:
let notes: Control;
notes = "Textbox";
notes = "DropDown";
  1. 让我们扩展我们的Control类型,以包含更多的字符串文字:
type Control = "Textbox" | "DropDown" | "DatePicker" | "NumberSlider";
  1. 现在我们可以将我们的notes变量设置为这些值中的任何一个:
notes = "DatePicker";
notes = "NumberSlider";

如果我们仔细想一想,这真的很有用。我们本来可以将notes变量声明为string,但是用包含的特定字符串文字来声明它可以包含的内容,这样就可以使它成为超级类型安全。

辨识联合模式

辨识联合模式允许我们处理不同联合类型的逻辑。

让我们通过一个例子来看一下:

  1. 让我们首先创建三个接口来表示文本框、日期选择器和数字滑块:
interface ITextbox {
  control: "Textbox";
  value: string;
  multiline: boolean;
}

interface IDatePicker {
  control: "DatePicker";
  value: Date;
}

interface INumberSlider {
  control: "NumberSlider";
  value: number;
}

它们都有一个名为control的属性,这将是模式中的辨识者。

  1. 让我们继续将这些接口组合成一个名为Field的联合类型:
type Field = ITextbox | IDatePicker | INumberSlider;

因此,我们可以从任何类型创建联合类型,而不仅仅是字符串文字。在这种情况下,我们已经从三个接口创建了一个联合类型。

  1. 现在让我们创建一个函数来初始化Field类型中的值:
function intializeValue(field: Field) {
  switch (field.control) {
    case "Textbox":
      field.value = "";
      break;
    case "DatePicker":
      field.value = new Date();
      break;
    case "NumberSlider":
      field.value = 0;
      break;
    default:
      const shouldNotReach: never = field;
  }
}

我们需要设置的值取决于辨别属性control。因此,我们使用了switch语句来根据这个属性进行分支。

switch语句中的default分支是让事情变得有趣的地方。这个分支永远不应该被执行,所以我们在那个分支中放置了一个带有never类型的语句。在接下来的步骤之后,我们将看到这样做的价值。

  1. 假设时间已经过去,我们对复选框字段有了新的要求。让我们为此实现一个接口:
interface ICheckbox {
  control: "Checkbox";
  value: boolean;
}
  1. 让我们也将这个加入到联合Field类型中:
type Field = ITextbox | IDatePicker | INumberSlider | ICheckbox;

我们会立即看到我们的initializeValue函数在never声明上抛出编译错误:

这非常有价值,因为never语句确保我们不会忘记为新的复选框要求添加代码分支。

  1. 所以,让我们去实现这个额外的分支,针对"Checkbox"字段:
function intializeValue(field: Field) {
  switch (field.control) {
    case "Textbox":
      field.value = "";
      break;
    case "DatePicker":
      field.value = new Date();
      break;
    case "NumberSlider":
      field.value = 0;
      break;
    case "Checkbox":
 field.value = false;
 break;
    default:
      const shouldNotReach: never = field;
  }
}

因此,联合类型允许我们将任何类型组合在一起形成另一个类型。这使我们能够创建更严格的类型,特别是在处理字符串时。辨别联合模式允许我们为联合中的不同类型有逻辑分支,而never类型帮助我们捕捉添加新类型到联合类型时需要发生的所有变化。

类型守卫

类型守卫允许我们在代码的条件分支中缩小对象的特定类型。当我们需要实现处理联合类型中特定类型的代码分支时,它们非常有用。

在上一节中,当我们实现intializeValue函数时,我们已经使用了类型守卫。在辨别属性control上的switch语句允许我们在联合中的每种类型上设置值。

我们可以实现类型守卫的其他方法。以下部分介绍了不同的方法。

使用typeof关键字

typeof关键字是 JavaScript 中返回表示类型的字符串的关键字。因此,我们可以在条件中使用它来缩小类型。

让我们通过一个例子来说明:

  1. 我们有一个可以是字符串或字符串数组的联合类型:
type StringOrStringArray = string | string[];
  1. 我们需要实现一个名为first的函数,它接受一个StringOrStringArray类型的参数并返回一个string
function first(stringOrArray: StringOrStringArray): string {

}
  1. 如果stringOrArray是一个string,那么函数需要返回第一个字符;否则,它应该返回第一个数组元素:
function first(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else {
    return stringOrArray[0];
  }
}

在第一个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string

在第二个分支中,如果我们悬停在stringOrArray上,我们会看到类型已成功缩小为string[]

  1. 为了检查我们的函数是否有效,我们可以添加以下内容:
console.log(first("The"));
console.log(first(["The", "cat"]));

如果我们运行程序,TThe将被输出到控制台。

typeof关键字只能与 JavaScript 类型一起使用。为了说明这一点,让我们创建一个增强版本的函数:

  1. 我们将我们的函数称为firstEnhanced。我们希望第二个分支专门处理string[]类型,并将第三个分支标记为永远不会到达的地方。让我们试试看:
function firstEnhanced(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else if (typeof stringOrArray === "string[]") { 
    return stringOrArray[0];
  } else {
    const shouldNotReach: never = stringOrArray;
  }
}

TypeScript 编译器对第二个分支不满意:

消息给了我们一些线索。JavaScript 的typeof关键字适用于 JavaScript 类型,这些类型是stringnumberbooleansymbolundefinedobjectfunction;因此错误消息中结合了这些类型的联合类型。因此,我们的第二个分支中的typeof实际上会返回"object"

  1. 让我们正确地实现这个:
function firstEnhanced(stringOrArray: StringOrStringArray): string {
  if (typeof stringOrArray === "string") {
    return stringOrArray.substr(0, 1);
  } else if (typeof stringOrArray === "object") { 
    return stringOrArray[0];
  } else {
    const shouldNotReach: never = stringOrArray;
  }
}

TypeScript 编译器现在又高兴了。

因此,typeof非常适合根据 JavaScript 类型进行分支,但不太适合于 TypeScript 特定类型。让我们在接下来的部分中找出如何弥合这一差距。

使用 instanceof 关键字

instanceof关键字是另一个 JavaScript 关键字。它检查对象是否具有特定的构造函数。通常用于确定对象是否是类的实例。

让我们通过一个例子来看一下:

  1. 我们有两个表示PersonCompany的类:
class Person {
  id: number;
  firstName: string;
  surname: string;
}

class Company {
  id: number;
  name: string;
}
  1. 我们还有一个结合这两个类的联合类型:
type PersonOrCompany = Person | Company;
  1. 现在我们需要编写一个函数,该函数接受PersonCompany并将它们的名称输出到控制台:
function logName(personOrCompany: PersonOrCompany) {
  if (personOrCompany instanceof Person) {
    console.log(`${personOrCompany.firstName} ${personOrCompany.surname}`);
  } else {
    console.log(personOrCompany.name);
  }
}

在使用instanceof时,我们在它之前有要检查的变量,之后是构造函数名称(类名)。

在第一个分支中,如果我们悬停在personOrCompany上,我们会得到Person类型:

在第二个分支中,如果我们悬停在personOrCompany上,我们会得到Company类型:

因此,instanceof在处理类时非常适用于缩小类型。然而,我们使用许多不是 JavaScript 类型或基于类的 TypeScript 类型。那么,在这些情况下我们该怎么办呢?让我们在接下来的部分中找出答案。

使用in关键字

in关键字是另一个 JavaScript 关键字,可用于检查属性是否在对象中。

让我们使用in关键字来实现上一节的示例:

  1. 这次,我们使用接口而不是PersonCompany结构的类:
interface IPerson {
  id: number;
  firstName: string;
  surname: string;
}

interface ICompany {
  id: number;
  name: string;
}
  1. 我们再次从PersonCompany结构创建一个联合类型:
type PersonOrCompany = IPerson | ICompany;
  1. 最后,让我们使用in关键字来实现我们的函数:
function logName(personOrCompany: PersonOrCompany) {
 if ("firstName" in personOrCompany) {
  console.log(`${personOrCompany.firstName} ${personOrCompany.surname}`);
 } else {
  console.log(personOrCompany.name);
 }
}

in关键字之前,我们用双引号将属性名称放在一起,然后是要检查的对象。

如果我们在第一个分支上悬停在personOrCompany上,我们会得到IPerson类型。如果我们在第二个分支上悬停在personOrCompany上,我们会得到ICompany类型。

因此,in关键字非常灵活。它可以与任何对象一起使用,通过检查属性是否存在来缩小其类型。

在下一节中,我们将介绍最后一个类型保护。

使用用户定义的类型保护

在无法使用其他类型保护的情况下,我们可以创建自己的类型保护。我们可以通过创建一个返回类型为类型断言的函数来实现这一点。在本书之前,我们实际上在讨论unknown类型时使用了用户定义的类型保护。

让我们使用我们自己的类型保护函数来实现上两节的示例:

  1. 我们有相同的接口和联合类型:
interface IPerson {
  id: number;
  firstName: string;
  surname: string;
}

interface ICompany {
  id: number;
  name: string;
}

type PersonOrCompany = IPerson | ICompany;
  1. 因此,让我们实现返回对象是否为IPerson类型的类型保护函数:
function isPerson(personOrCompany: PersonOrCompany): personOrCompany is IPerson {
  return "firstName" in personOrCompany;
}

类型断言personOrCompanyIPerson有助于 TypeScript 编译器缩小类型。要确认这一点,在第一个分支上悬停在personOrCompany上应该给出IPerson类型。然后,如果我们在第二个分支上悬停在personOrCompany上,我们应该得到ICompany类型。

创建用户定义的类型保护比其他方法更费力,但它为我们提供了处理其他方法无法解决的情况的灵活性。

泛型

泛型可以应用于函数或整个类。这是一种允许消费者使用自己的类型与泛型函数或类一起使用的机制。接下来的部分将介绍这两种情况的示例。

泛型函数

让我们通过一个通用函数的示例来进行讲解。我们将创建一个包装函数,用于调用fetch JavaScript 函数从 web 服务获取数据:

  1. 让我们从创建function签名开始:
function getData<T>(url: string): Promise<T> {

}

我们在函数名后的尖括号中放置一个T来表示它是一个通用函数。实际上我们可以使用任何字母,但T是常用的。然后我们在类型是通用的地方使用T。在我们的示例中,通用部分是返回类型,所以我们返回Promise<T>

如果我们想要使用箭头函数,这将是:

const getData = <T>(url: string): Promise<T> => {

};
  1. 现在让我们实现我们的函数:
function getData<T>(url: string): Promise<T> {
  return fetch(url).then(response => {
    if (!response.ok) {
      throw new Error(response.statusText);
    }
    return response.json();
  });
}
  1. 最后,让我们消费这个函数:
interface IPerson {
  id: number;
  name: string;
}

getData<IPerson>("/people/1").then(person => console.log(person));

我们在函数名后的尖括号中传递我们想要在函数中使用的类型。在我们的例子中,它是IPerson

如果我们在then回调中悬停在person上,我们会看到person被正确地类型化为IPerson

因此,顾名思义,通用函数是与通用类型一起工作的函数。先前示例的另一种实现方式是将any作为返回类型,但那不是类型安全的。

通用类

我们可以使整个类成为通用的。让我们深入了解一个将数据存储在列表中的通用类的示例:

  1. 首先让我们定义我们的类,不包含任何内容:
class List<T> {

}

我们通过在类名后面加上<T>来标记类为通用的。

  1. 在类内部,让我们为列表中的数据创建一个private属性:
private data: T[] = [];

我们使用T来引用通用类型。在我们的示例中,我们的data属性是一个根据类声明的任何类型的数组。

  1. 现在让我们添加一个public方法来获取列表中的所有数据:
public getList(): T[] {
  return this.data;
}

我们使用T[]来引用通用数组作为返回类型。

  1. 让我们实现一个向列表中添加项目的方法:
public add(item: T) {
  this.data.push(item);
}

我们使用通用类型T来引用传入的数据项。该实现简单地使用数组的push方法将项目添加到我们的private数组中。

  1. 让我们也实现一个从列表中移除项目的方法:
public remove(item: T) {
  this.data = this.data.filter((dataItem: T) => {
    return !this.equals(item, dataItem);
  });
}
private equals(obj1: T, obj2: T) {
  return Object.keys(obj1).every(key => {
    return obj1[key] === obj2[key];
  });
}

我们再次使用通用类型T来引用传入的数据项。该实现使用数组的filter方法来过滤私有数组中的项目。过滤谓词使用一个检查两个对象是否相等的private方法。

  1. 因此,现在我们已经实现了我们的通用列表类,让我们创建一个类型和一些数据,以便消费它:
interface IPerson {
  id: number;
  name: string;
}
const billy: IPerson = { id: 1, name: "Billy" };
  1. 现在让我们创建一个通用类的实例:
const people = new List<IPerson>();

我们在类名后面使用尖括号中的类型来与类交互。

  1. 现在我们可以通过添加和删除billy来与类交互:
people.add(billy);
people.remove(billy);
  1. 让我们尝试在我们的列表实例中使用不同的类型:
people.add({name: "Sally"});

我们得到了编译错误,正如我们所预期的那样:

  1. 让我们将列表实例中的所有项目保存到一个变量中:
const items = people.getList();

如果我们悬停在items变量上,我们会看到类型已经被正确推断为IPerson[]

因此,泛型类允许我们使用不同类型的类,但仍然保持强类型。

我们在本书的早些时候使用了泛型类,我们用它来实现了带有 props 和 state 的 React 类组件:

interface IProps { ... }
interface IState { ... }
class App extends React.Component<IProps, IState> {
  ...
}

在这里,React.Component类有两个用于 props 和 state 的泛型参数。

因此,泛型在这本书中是一个非常重要的概念,我们将大量使用它来创建强类型的 React 组件。

重载签名

重载签名允许使用不同的签名调用函数。这个特性可以很好地用于简化库向消费者提供的一组函数。如果一个库包含condenseString公共函数和condenseArray,那么将它们简化为只包含一个公共condense函数会很好,不是吗?我们将在本节中做到这一点:

  1. 我们有一个从字符串中移除空格的函数:
function condenseString(string: string): string {
  return string.split(" ").join("");
}
  1. 我们有另一个从数组项中移除空格的函数:
function condenseArray(array: string[]): string[] {
  return array.map(item => item.split(" ").join(""));
}
  1. 现在我们想将这两个函数合并为一个单一的函数。我们可以使用联合类型来实现:
function condense(stringOrArray: string | string[]): string | string[] {
  return typeof stringOrArray === "string"
    ? stringOrArray.split(" ").join("")
    : stringOrArray.map(item => item.split(" ").join(""));
}
  1. 让我们使用我们的统一函数:
const condensedText = condense("the cat sat on the mat");

当我们输入函数参数时,智能感知提醒我们需要输入一个字符串或字符串数组:

如果我们悬停在condensedText变量上,我们会看到推断类型是联合类型:

  1. 现在是时候添加两个签名重载来改进我们函数的使用了:
function condense(string: string): string;
function condense(array: string[]): string[];
function condense(stringOrArray: string | string[]): string | string[] { ... }

我们在主函数签名之前添加了函数重载签名。我们为处理字符串时添加了一个重载,为处理字符串数组时添加了第二个重载。

  1. 让我们使用我们的重载函数:
const moreCondensedText = condense("The cat sat on the mat");

现在,当我们输入参数时,我们得到了改进的智能感知。我们还可以使用上下箭头来滚动两个不同的签名:

如果我们悬停在moreCondensedText变量上,我们会看到我们获得了更好的类型推断:

因此,重载签名可以改善开发人员使用我们函数的体验。它们可以提供改进的智能感知和类型推断。

查找和映射类型

keyof是 TypeScript 中的一个关键字,它创建了对象中所有属性的联合类型。创建的类型称为查找类型。这允许我们根据现有类型的属性动态创建类型。这是一个有用的功能,我们可以用它来针对不同的数据创建通用但强类型的代码。

让我们通过一个例子来说明:

  1. 我们有以下IPerson接口:
interface IPerson {
  id: number;
  name: string;
}
  1. 让我们在这个接口上使用keyof创建一个查找类型:
type PersonProps = keyof IPerson;

如果我们悬停在PersonProps类型上,我们会看到创建了一个包含"id""name"的联合类型:

  1. 让我们向IPerson添加一个新属性:
interface IPerson {
  id: number;
  name: string;
  age: number
}

如果我们再次悬停在PersonProps类型上,我们会看到该类型已自动扩展以包含"age"

因此,PersonProps类型是一个查找类型,因为它查找它需要包含的文字。

现在让我们用查找类型创建一些有用的东西:

  1. 我们将创建一个Field类,其中包含字段名称、标签和默认值:
class Field {
  name: string;
  label: string;
  defaultValue: any;
}
  1. 这只是一个开始,但我们可以通过使我们的类通用来使name更加强类型化:
class Field<T, K extends keyof T> {
  name: K;
  label: string;
  defaultValue: any;
}

我们在类上创建了两个通用参数。第一个是包含字段的对象类型,第二个是对象内的属性名称。

  1. 如果我们创建类的实例,可能会更有意义。让我们使用上一个示例中的IPerson,并将"id"作为字段名称传递进去:
const idField: Field<IPerson, "id"> = new Field();
  1. 让我们尝试引用在IPerson中不存在的属性:
const addressField: Field<IPerson, "address"> = new Field();

我们得到了编译错误,正如我们所期望的那样:

捕捉这样的问题是查找类型的好处,而不是使用string类型。

  1. 现在让我们把注意力转向Field类中的defaultValue属性。目前这不是类型安全的。例如,我们可以将idField设置为一个字符串:
idField.defaultValue = "2";
  1. 让我们解决这个问题,使defaultValue具有类型安全性:
class Field<T, K extends keyof T> {
  name: K;
  label: string;
  defaultValue: T[K];
}

我们使用T[K]查找类型。对于idField,这将解析为IPersonid属性的类型,即number

现在设置idField.defaultValue的代码行会引发编译错误,正如我们所期望的那样:

  1. 让我们将"2"更改为2
idField.defaultValue = 2;

编译错误消失了。

因此,在创建可变数据类型的通用组件时,查找类型可能会很有用。

现在让我们转到映射类型。同样,这些让我们可以从现有类型的属性中创建新类型。但是,映射类型允许我们通过从现有属性中映射它们来明确定义新类型中的属性。

让我们通过一个示例来看一下:

  1. 首先,让我们创建一个类型,我们将在下一步中进行映射:
interface IPerson {
  id: number;
  name: string;
}
  1. 现在让我们创建一个新版本的interface,其中所有属性都是使用映射类型readonly的:
type ReadonlyPerson = { readonly [P in keyof IPerson]: IPerson[P] };

创建映射的重要部分是[P in keyof IPerson]。这会遍历IPerson中的所有属性,并将每个属性分配给P以创建类型。因此,在上一个示例中生成的类型如下:

type ReadonlyPerson = { 
  readonly id: number
  readonly name: string 
};
  1. 让我们尝试一下,看看我们的类型是否真的是readonly
let billy: ReadonlyPerson = {
  id: 1,
  name: "Billy"
};
billy.name = "Sally";

正如我们所期望的,当我们尝试将readonly属性设置为新值时,会引发编译错误:

所以我们的映射类型起作用了!这种映射类型的更通用版本实际上是 TypeScript 中的标准类型,即Readonly<T>

  1. 现在让我们使用标准的readonly类型:
let sally: Readonly<IPerson> = {
  id: 1,
  name: "sally"
};
  1. 让我们尝试更改我们的readonly中的值:
Sally.name = "Billy";

引发编译错误,正如我们所期望的那样:

如果我们在 Visual Studio Code 中使用“转到定义”选项来查看Readonly类型,我们会得到以下结果:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

这与我们的ReadonlyPerson类型非常相似,但是IPerson已被替换为通用类型T

让我们尝试创建我们自己的通用映射类型:

  1. 我们将创建一个映射类型,使现有类型的所有属性都是string类型:
type Stringify<T> = { [P in keyof T]: string };
  1. 让我们尝试使用我们的映射类型:
let tim: Stringify<IPerson> = {
 id: "1",
 name: "Tim"
};
  1. 让我们尝试将id设置为一个数字:
tim.id = 1

预期的编译错误被引发:

因此,在需要基于现有类型创建新类型的情况下,映射类型非常有用。除了Readonly<T>之外,在 TypeScript 中还有许多标准映射类型,例如Partial<T>,它创建一个映射类型,使所有属性都是可选的。

总结

在本章中,我们学习了 TypeScript 中一些更高级的类型,从联合类型开始。联合类型非常有用,允许我们通过将现有类型联合在一起来创建新类型。我们发现,将字符串字面量联合在一起可以创建比普通string更具体和类型安全的类型。

我们探讨了各种实现类型守卫的方式。类型守卫在逻辑分支中帮助编译器缩小联合类型的范围时非常有用。它们在使用unknown类型时,在逻辑分支中告诉编译器类型是什么也非常有用。

泛型,顾名思义,允许我们创建通用类型。在详细讨论了这个主题之后,React 组件中的 props 和 state 的类型安全现在更加有意义了。我们将在本书的其余部分大量使用通用类和函数。

我们了解到重载签名允许我们拥有具有不同参数和返回类型的函数。现在我们可以有效地使用这个特性来简化我们在库中公开的公共函数。

我们学习了如何可以使用查找和映射类型从现有类型属性动态创建新类型。我们现在知道,有许多有用的标准 TypeScript 映射类型,如Readonly<T>Partial<T>

学习所有这些特性是对下一章的很好准备,我们将深入探讨在使用 React 组件时的一些常见模式。

问题

让我们来试试一些关于高级类型的问题:

  1. 我们有一个代表课程结果的interface,如下:
interface ICourseMark {
  courseName: string;
  grade: string;
}

我们可以像这样使用这个interface

const geography: ICourseMark = {
  courseName: "Geography",
  grade: "B"
} 

成绩只能是 A、B、C 或 D。我们如何创建这个接口中grade属性的更强类型版本?

  1. 我们有以下函数,用于验证数字和字符串是否有值:
function isNumberPopulated(field: number): boolean {
  return field !== null && field !== undefined;
}

function isStringPopulated(field: string): boolean {
  return field !== null && field !== undefined && field !== "";
}

我们如何将这些组合成一个名为isPopulated的单一函数,带有签名重载?

  1. 我们如何可以使用泛型实现一个更灵活的isPopulated函数?

  2. 我们有一个代表阶段的type别名:

type Stages = {
  pending: 'Pending',
  started: 'Started',
  completed: 'Completed',
};
  1. 我们如何可以编程地将这个转换成'Pending' | 'Started' | 'Completed'联合类型?

  2. 我们有以下联合类型:

type Grade = 'gold' | 'silver' | 'bronze';

我们如何可以编程地创建以下类型:

type GradeMap = {
  gold: string;
  silver: string;
  bronze: string
};

进一步阅读

TypeScript 文档中有一个关于高级类型的很棒的部分,值得一看:

www.typescriptlang.org/docs/handbook/advanced-types.html

第六章:组件模式

在本章中,我们将继续构建之前的 React 商店。我们将构建一个可重用的选项卡组件,以及一个可重用的加载指示器组件,两者都将在商店的产品页面上使用。本章将首先将产品页面分割为容器和展示组件,然后再处理选项卡组件,利用复合组件和渲染属性模式。然后,我们将继续实现一个使用高阶组件模式的加载指示器组件。

在这一章中,我们将学习以下主题:

  • 容器和展示组件

  • 复合组件

  • 渲染属性模式

  • 高阶组件

技术要求

在本章中,我们将使用以下技术:

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。我们可以从nodejs.org/en/download/安装这些。如果我们已经安装了这些,确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint(由 egamma 提供)和 Prettier(由 Estben Petersen 提供)扩展。

  • React 商店:我们将从我们在查看 React Router 的章节中开始的项目开始。这可以在 GitHub 上找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/04-ReactRouter

本章中的所有代码片段都可以在以下网址找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/06-ComponentPatterns

容器和展示组件

将页面分割为容器和展示组件可以使展示组件更容易重用。容器组件负责事物的运作,从 Web API 获取数据并管理状态。展示组件负责外观。展示组件通过属性接收数据,同时具有属性事件处理程序,以便其容器可以管理用户交互。

我们将在我们的 React 商店中使用这种模式,将产品页面分成容器和展示组件。ProductPage组件将是容器,我们将引入一个名为Product的新组件,它将是展示组件:

  1. 让我们首先在 Visual Studio Code 中打开我们的商店项目,并在终端中输入以下命令来启动应用程序:
npm start
  1. 如果我们导航到一个产品,让我们回顾一下产品页面是什么样子的:

  1. 让我们创建一个名为Product.tsx的新文件,其中包含我们的展示组件,内容如下:
import * as React from "react";

const Product: React.SFC<{}> = props => {
  return <React.Fragment>TODO</React.Fragment>;
};

export default Product;

我们的展示组件是一个函数组件。

  1. 展示组件通过 props 接收数据,也通过 props 委托事件处理。因此,让我们为产品数据项、是否已添加到购物篮以及添加到购物篮的处理程序创建 props:
import * as React from "react";
import { IProduct } from "./ProductsData";

interface IProps {
 product: IProduct;
 inBasket: boolean;
 onAddToBasket: () => void;
}
const Product: React.SFC<IProps> = props => {
  return <React.Fragment>TODO</React.Fragment>;
};

export default Product;
  1. 如果我们查看ProductsPage.tsx,我们将复制React.Fragment部分中有产品时的 JSX。然后我们将其粘贴到Product组件的 return 语句中:
const Product: React.SFC<IProps> = props => {
  return (
    <React.Fragment>
 <h1>{product.name}</h1>
 <p>{product.description}</p>
 <p className="product-price">
 {new Intl.NumberFormat("en-US", {
 currency: "USD",
 style: "currency"
 }).format(product.price)}
 </p>
 {!this.state.added && (
 <button onClick={this.handleAddClick}>Add to basket</button>
 )}
 </React.Fragment>
  );
};

我们现在有一些参考问题需要解决。

  1. 让我们在 return 语句之前定义一个产品变量,以解决 JSX 中产品引用的问题:
const product = props.product;
return ( 
  ...
)
  1. 现在通过inBasket prop 传递产品是否在购物篮中。因此,让我们改变添加到购物篮按钮周围的条件以使用这个 prop:
{!props.inBasket && (
  <button onClick={this.handleAddClick}>Add to basket</button>
)}
  1. 我们需要解决的最后一个参考问题是点击“添加到购物篮”按钮的处理程序。让我们首先创建一个简单调用onAddToBasket prop 的处理程序:
const product = props.product;

const handleAddClick = () => {
 props.onAddToBasket();
};

return (
  ...
);
  1. 我们可以在 JSX 中删除对此处理程序的引用this
{!props.inBasket && (
  <button onClick={handleAddClick}>Add to basket</button>
)}

这就是我们目前的Product展示组件完成了。因此,让我们在ProductPage组件中引用我们的Product组件。

  1. 首先,让我们将我们的Product组件导入到ProductPage.tsx中:
import Product from "./Product";
  1. 现在,让我们用我们的Product组件替换在 JSX 中复制的部分:
return (
 <div className="page-container">
   <Prompt when={!this.state.added} message={this.navAwayMessage} />
   {product ? (
     <Product
 product={product}
 inBasket={this.state.added}
 onAddToBasket={this.handleAddClick}
 />
   ) : (<p>Product not found!</p>)}
 </div>
);

我们将产品、产品是否已添加到购物篮以及添加到购物篮的处理程序一起作为 props 传递给Product组件。

如果我们再次查看商店并转到产品页面,它看起来完全一样。

因此,我们刚刚实现了我们的第一个容器和展示组件。容器组件非常适合作为页面中的顶层组件,从 Web API 获取数据,并管理页面内的所有状态。展示组件只关注需要呈现在屏幕上的内容。这种模式的好处是展示组件可以更容易地在应用程序的其他地方使用。例如,我们的Product组件可以相当容易地在商店中创建的其他页面上使用。这种模式的另一个好处是,展示组件通常更容易进行单元测试。在我们的示例中,我们的Product组件是一个纯函数,因此对其进行单元测试只是检查不同输入的输出是否正确,因为没有副作用。我们将在本书的后面详细介绍单元测试。

在下一节中,我们将继续增强我们的产品页面,通过向其添加评论并添加选项卡来将产品描述与评论分开。

复合组件

复合组件是一组共同工作的组件。我们将使用这种模式在产品页面上创建一个可重用的选项卡组件,以分隔产品描述和评论。

为产品添加评论

在创建我们的Tabs复合组件之前,让我们在产品页面上添加评论:

  1. 首先,我们需要在ProductsData.ts中为评论数据结构添加一个接口:
export interface IReview {
  comment: string;
  reviewer: string;
}
  1. 我们现在可以将评论添加到我们的产品接口中:
export interface IProduct {
  ...
  reviews: IReview[];
}
  1. 我们现在可以将评论添加到我们的产品数据数组中:
const products: IProduct[] = [
  {
    id: 1,
    ...
    reviews: [
 {
 comment: "Excellent! This does everything I want",
 reviewer: "Billy"
 },
 { comment: "The best router I've ever worked with", reviewer: 
      "Sally" }
 ]
  },
  {
    id: 2,
    ..
    reviews: [
 {
 comment: "I've found this really useful in a large app I'm 
        working on",
 reviewer: "Billy"
 },
 {
 comment: "A bit confusing at first but simple when you get   
        used to it",
 reviewer: "Sally"
 }
 ]
  },
  {
    id: 3,
    ..
    reviews: [
 {
 comment: "I'll never work with a REST API again!",
 reviewer: "Billy"
 },
 {
 comment: "It makes working with GraphQL backends a breeze",
 reviewer: "Sally"
 }
 ]
  }
];

因此,我们为每个产品添加了一个reviews属性,它是一个评论数组。每个评论都是一个包含commentreviewer属性的对象,由IReview接口定义。

  1. 有了我们的数据,让我们在描述之后将评论添加到我们的Product组件中:
<p>{product.description}</p>
<div>
 <ul className="product-reviews">
 {product.reviews.map(review => (
 <li key={review.reviewer} className="product-reviews-item">
 <i>"{review.comment}"</i> - {review.reviewer}
 </li>
 ))}
 </ul>
</div>
<p className="product-price">
  ...
</p>

因此,我们正在使用map函数在reviews数组上显示commentreviewer

  1. 我们引用了一些新的 CSS 类,因此让我们将它们添加到index.css中:
.product-reviews {
  list-style: none;
  padding: 0px;
}
.product-reviews .product-reviews-item {
  display: block;
  padding: 8px 0px;
}

如果我们查看正在运行的应用程序并转到产品,我们现在将看到评论:

现在我们已经添加了评论,我们可以在下一节中处理我们的Tabs组件。

创建一个基本的选项卡组件

我们的工作现在是使用我们将要构建的选项卡组件将描述与评论分开。我们将首先创建一个简单的选项卡组件,然后在下一节将其重构为复合组件模式。

是时候开始我们的选项卡组件了:

  1. 首先,让我们创建一个名为Tabs.tsx的文件,用以下内容作为骨架类组件:
import * as React from "react";

interface IProps {}
interface IState {}
class Tabs extends React.Component<IProps, IState> {
  public constructor(props: IProps) {
    super(props);
    this.state = {};
  }
  public render() {
    return;
  }
}

export default Tabs;

我们选择创建基于类的组件,因为我们的组件将需要跟踪活动的选项卡标题的状态。

  1. 因此,让我们通过添加一个属性来完成我们状态的接口,该属性将给出活动的标题名称:
interface IState {
  activeHeading: string;
}
  1. 我们的组件将接受选项卡标题并将它们显示为属性。因此,让我们完成这个接口:
interface IProps {
  headings: string[];
}

因此,我们的组件可以接受一个headings属性中的标题名称数组。

  1. 让我们现在在构造函数中为activeHeading状态创建初始值:
public constructor(props: IProps) {
  super(props);
  this.state = {
    activeHeading:
 this.props.headings && this.props.headings.length > 0
 ? this.props.headings[0]
 : ""
  };
}

因此,活动标题最初将设置为headings数组中的第一个元素。三元运算符确保我们的组件在消费者未传递任何选项卡时不会产生错误。

  1. 现在转到渲染方法,让我们通过映射headings属性在列表中渲染我们的选项卡:
    public render() {
      return (
        <ul className="tabs">
          {this.props.headings.map(heading => (
            <li className={heading === this.state.activeHeading ? 
            "active" : ""}
            >
              {heading}
            </li>
          ))}
        </ul>
      );
    }

我们引用了一些 CSS 类,包括active,它是基于三元运算符设置的,取决于正在呈现的是否是活动选项卡标题。

  1. 现在让我们将这些 CSS 类添加到index.css中:
.tabs {
  list-style: none;
  padding: 0;
}
.tabs li {
  display: inline-block;
  padding: 5px;
  margin: 0px 5px 0px 5px;
  cursor: pointer;
}
.tabs li:focus {
  outline: none;
}
.tabs li.active {
  border-bottom: #222 solid 2px;
}

在我们可以看到我们的选项卡组件是什么样子之前,我们需要使用它。

  1. 因此,让我们首先导入Tabs组件,然后将其添加到Product组件中。
import Tabs from "./Tabs";
  1. 现在我们可以在产品名称和描述之间添加Tabs组件:
<h1>{product.name}</h1>
<Tabs headings={["Description", "Reviews"]} />
<p>{product.description}</p>

我们将向Tabs组件传递我们要显示的两个选项卡标题,即描述和评论。

让我们看看这是什么样子:

这是一个良好的开始。第一个选项卡下划线是active CSS 样式,正如我们所希望的那样。但是,如果我们点击评论选项卡,什么也不会发生。

  1. 因此,让我们在Tabs.tsx中引用点击处理程序来处理每个选项卡:
<li
  onClick={this.handleTabClick}
  className={heading === this.state.activeHeading ? "active" : ""}
>
  {heading}
</li>
  1. 现在让我们也实现点击处理程序:
private handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
  const li = e.target as HTMLLIElement;
  const heading: string = li.textContent ? li.textContent : "";
  this.setState({ activeHeading: heading });
};

我们首先从litextContent中提取标题。然后将activeHeading状态设置为此标题。这将导致 React 重新渲染组件,显示所点击的选项卡为活动状态。

请注意,我们使用as关键字帮助 TypeScript 编译器将li变量声明为HTMLLIElement。如果不这样做,编译器将不允许我们访问其中的textContent属性。

如果我们再次转到运行的应用程序,现在我们可以单击选项卡并看到活动状态的变化。

目前,我们的选项卡组件只是渲染一些可以单击的选项卡。它还没有与任何内容相关联。直到下一节关于渲染属性模式的部分,我们才会将标题与内容关联起来。但是,现在是时候探索复合组件模式,并在下一节中稍微增强我们的选项卡标题。

利用复合组件模式

目前,我们的选项卡标题只能是字符串。如果我们希望允许组件的使用者在标题中定义更丰富的内容怎么办?例如,使用者可能希望在选项卡标题前放置图标或使标题加粗。因此,使用的 JSX 可能如下所示:

<Tabs>
  <Tabs.Tab name="Description" initialActive={true}>
    <b>Description</b>
  </Tabs.Tab>
  <Tabs.Tab name="Reviews">
     Reviews
  </Tabs.Tab>
</Tabs>

在上一个示例中,TabsTabs.Tab是复合组件:

  • Tabs是渲染其中的Tabs.Tab组件的组件。它还管理活动选项卡的状态。

  • Tabs.Tab渲染单个标题。它以唯一的选项卡名称作为属性,允许管理活动选项卡。它还接受一个名为initialActiveboolean属性,该属性在首次加载时设置该选项卡为活动状态。渲染的标题是Tabs.Tab标记内的内容。因此,第一个选项卡将以粗体呈现描述。

因此,让我们将我们的基本选项卡组件重构为一个复合组件,可以类似于上一个示例中使用:

  1. 我们的Tabs组件不再接受任何属性,因此,让我们删除IProps接口。我们可以删除构造函数,因为我们不再需要从属性初始化状态。我们还将状态属性的名称从activeHeading更改为activeName
interface IState {
  activeName: string;
}
class Tabs extends React.Component<{}, IState> {
  public render() {
    ...
  }
  ...
}
  1. 首先,我们将在Tabs中工作Tab组件。因此,让我们为其属性创建一个接口:
interface ITabProps {
  name: string;
  initialActive?: boolean;
}
  • name属性是选项卡的唯一名称。稍后将使用它来帮助我们管理活动选项卡。

  • initialActive属性指定组件首次加载时选项卡是否处于活动状态。

  1. 现在让我们在我们的Tabs类组件中添加以下Tab函数组件:
class Tabs extends React.Component<IProps, IState> {

  public static Tab: React.SFC<ITabProps> = props => <li>TODO - render the nodes child nodes</li>;

  public render() {...}

  ...
}

这是将渲染每个标签的组件的开始。Tab组件被定义为Tabs组件的静态属性。这意味着Tab存在于实际的Tabs类中,而不是它的实例中。因此,我们必须记住我们无法访问任何Tabs实例成员(例如this)。但是,现在我们可以在 JSX 中使用Tabs.Tab来引用Tab,这是我们的要求之一。

目前,Tab只是渲染带有提醒的li,提醒我们需要以某种方式渲染组件的子节点。请记住,我们希望消费Tabs组件的标记如下:

<Tabs.Tab name="Description" initialActive={true}>
  <b>Description</b>
/Tabs.Tab>
  1. 因此,我们的渲染函数需要以某种方式在我们的li标签内渲染<b> Description </b>。我们该如何做呢?答案是通过一个叫做children的特殊属性:
public static Tab: React.SFC<ITabProps> = props => <li>{props.children}</li>;

React 组件属性可以是任何类型,包括 React 节点。children属性是 React 给组件的一个特殊属性,其中包含组件的子节点。我们通过在 JSX 中引用children属性来渲染组件的子节点。

我们的Tab组件还没有完成,但我们暂时会保持这样。现在我们需要继续进行Tabs组件。

  1. Tabs类中的render方法现在只是简单地渲染其子节点。让我们用以下内容替换这个方法:
public render() {
  return (
    <ul className="tabs">{this.props.children}</ul>
  );
}

我们再次使用神奇的children属性来渲染Tabs中的子节点。

我们在复合TabsTab组件中取得了进展,但是我们的项目不再编译,因为我们有一个标签点击处理程序handleTabClick,它不再被引用。当点击标签标题时,我们需要以某种方式从Tab组件中引用它,但请记住Tab无法访问Tabs的成员。那么,我们该如何做呢?我们将在下一节中找到这个问题的答案。

使用 React 上下文共享状态

React 上下文允许在组件之间共享状态。它与复合组件非常配合。我们将在TabsTab组件中使用它来共享状态:

  1. 我们的第一个任务是在文件顶部创建一个用于Tabs.tsx中使用的上下文接口,就在导入语句的下面:
interface ITabsContext {
  activeName?: string;
  handleTabClick?: (name: string) => void;
}

因此,我们的上下文将包含活动标签名称以及标签点击处理程序的引用。这些是需要在组件之间共享的两个状态。

  1. 接下来,让我们在ITabsContext接口下创建上下文:
const TabsContext = React.createContext<ITabsContext>({});

我们在 React 中使用createContext函数创建了我们的上下文,这是一个通用函数,用于创建一个通用类型的上下文,在我们的情况下是ITabsContext

我们需要将默认上下文值作为参数值传递给createContext,但在我们的情况下这是没有意义的,所以我们只是传递一个空的{}对象,以使 TypeScript 编译器满意。这就是为什么ITabsContext中的两个属性都是可选的。

  1. 现在是时候在我们的复合组件中使用这个上下文了。我们需要做的第一件事是在Tabsrender方法中定义上下文提供程序:
public render() {
  return (
    <TabsContext.Provider
 value={{
 activeName: this.state ? this.state.activeName : "",
 handleTabClick: this.handleTabClick
 }}
 >
      <ul className="tabs">{this.props.children}</ul>
    </TabsContext.Provider>
  );
}

这里有一些事情要处理,所以让我们来分解一下:

  • 我们之前声明的上下文常量TabsContext在 JSX 中可以作为<TabsContext />组件使用。

  • 上下文提供程序用值填充上下文。鉴于Tabs管理状态和事件处理,将提供程序引用到那里是有意义的。

  • 我们使用<TabsContext.Provider />引用提供程序。

  • 提供程序接受一个名为value的属性作为上下文值。我们将其设置为一个包含活动选项卡名称和选项卡点击事件处理程序的对象。

  1. 我们需要稍微调整选项卡点击处理程序,因为点击不再直接在Tabs中处理。因此,我们只需要将活动选项卡名称作为参数传入,然后在方法中设置活动选项卡名称状态:
private handleTabClick = (name: string) => {
  this.setState({ activeName: name });
};
  1. 现在我们已经向上下文提供了一些数据,是时候在Tab组件中使用它了:
 public static Tab: React.SFC<ITabProps> = props => (
  <TabsContext.Consumer>
 {(context: ITabsContext) => {
 const activeName = context.activeName
 ? context.activeName
 : props.initialActive
 ? props.name
 : "";
 const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => 
      {
 if (context.handleTabClick) {
 context.handleTabClick(props.name);
 }
 };
      return (
        <li
          onClick={handleTabClick}
 className={props.name === activeName ? "active" : ""}
        >
          {props.children}
        </li>
      );
    }}
  </TabsContext.Consumer>
);

这看起来又有点令人生畏,所以让我们来分解一下:

  • 我们可以通过上下文组件内的Consumer组件来消费上下文。所以,在我们的情况下是<TabsContext.Consumer />

  • Consumer的子代需要是一个具有上下文值参数并返回一些 JSX 的函数。Consumer然后将渲染我们返回的 JSX。

如果这仍然有点令人困惑,不要担心。当我们讨论子代属性和渲染属性时,我们将在以后更详细地介绍这种模式。

  • 这个上下文函数为我们提供了渲染选项卡所需的一切。我们可以从context参数中访问状态,还可以访问Tab组件的props对象。

  • 函数的第一行通过使用上下文中的内容来确定活动选项卡名称。如果上下文中的活动选项卡是空字符串,我们将使用当前选项卡名称,如果已经定义为初始活动选项卡。

  • 函数的第二行创建了一个标签点击处理程序,如果已经指定了上下文标签点击处理程序,则调用它。

  • 返回语句与以前一样,但我们已经能够添加标签点击处理程序的引用和类名。

所以,这就是我们的标签复合组件。React 上下文的语法一开始可能看起来有点奇怪,但当你习惯了它之后,它真的很简单和优雅。

在我们尝试之前,我们需要在我们的Product组件中使用我们的复合组件。让我们用以下突出显示的 JSX 替换我们之前对Tabs组件的使用:

 <React.Fragment>
  <h1>{product.name}</h1>

  <Tabs>
 <Tabs.Tab name="Description" initialActive={true}>
 <b>Description</b>
 </Tabs.Tab>
 <Tabs.Tab name="Reviews">Reviews</Tabs.Tab>
 </Tabs>

  <p>{product.description}</p>
  ...
</React.Fragment>

这正是我们在开始构建复合标签组件时想要实现的 JSX。如果我们转到运行的应用程序并浏览到产品页面,我们的标签组件将完美地工作,描述标签会以粗体显示:

因此,复合组件非常适合相互依赖的组件。<Tabs.Tab />的语法真的强调了Tab需要与Tabs一起使用。

React 上下文与复合组件非常配合,允许复合中的组件轻松共享状态。状态甚至可以包括诸如事件处理程序之类的函数。

允许消费者指定要在组件的各个部分中呈现的内容,为消费者提供了极大的灵活性。将此自定义内容指定为组件的子级是直观且自然的。在接下来的部分中,我们将继续使用这种方法来完成我们的标签组件。

渲染道具模式

在上一节中,我们使用了渲染道具模式的一种形式,其中我们利用了children道具。我们用它来允许Tab组件的消费者为标签标题呈现自定义内容。这很好,但是如果我们想允许消费者在组件的不同部分呈现自定义内容怎么办?在我们的Tabs组件中,我们还没有允许消费者呈现标签的内容。我们确实希望消费者能够为此指定自定义内容,但是既然我们已经使用了children道具来表示标题,那么现在该怎么做呢?

答案很简单,但一开始并不明显。答案是,因为 props 可以是任何东西,它们可以是一个呈现内容的函数 - 就像特殊的childrenprop 一样。这些类型的 prop 被称为渲染 prop。我们可以拥有尽可能多的渲染 prop,从而灵活地允许消费者呈现组件的多个部分。

在上一节中,当我们使用 React 上下文时,实际上使用了渲染 prop。我们消费上下文的方式是通过渲染 prop。

接下来,我们将利用渲染 prop 模式完成我们的Tabs组件。

使用渲染 prop 完成 Tabs

我们将通过使用渲染 prop 模式来完成我们的 Tabs 组件。在我们实现第一个渲染 prop 之前,让我们考虑一下当Tabs组件完成后,我们希望消费者如何消费它。以下的 JSX 是我们理想情况下从Product组件中消费Tabs组件的方式:

<Tabs>
  <Tabs.Tab
    name="Description"
    initialActive={true}
    heading={() => <b>Description</b>}
  >
    <p>{product.description}</p>
  </Tabs.Tab>

  <Tabs.Tab 
    name="Reviews"
    heading={() => "Reviews"} 
  >
    <ul className="product-reviews">
      {product.reviews.map(review => (
        <li key={review.reviewer}>
          <i>"{review.comment}"</i> - {review.reviewer}
        </li>
      ))}
    </ul>
  </Tabs.Tab>
</Tabs>

让我们来看看这些关键部分的步骤:

  • 我们仍然在使用复合组件。渲染 prop 与这些组件完全兼容。

  • 每个选项卡的标题不再在Tab组件的子元素中定义。相反,我们使用一个heading渲染 prop,在那里我们仍然可以呈现简单的字符串或更丰富的内容。

  • 然后指定选项卡内容为Tab组件的子元素。

使用渲染 prop 来设置选项卡标题

因此,让我们改变选项卡标题的实现,使用渲染 prop:

  1. Tabs.tsx中,让我们首先在选项卡 props 接口中添加一个新的属性用于标题:
interface ITabProps {
  name: string;
  initialActive?: boolean;
  heading: () => string | JSX.Element;
}

这个属性是一个没有参数的函数,返回一个string或一些 JSX。这就是我们的渲染 prop 的定义。

  1. 更改实现非常简单。我们只需在Tab组件的返回语句中用新的渲染 prop 函数替换对childrenprop 函数的调用:
return (
  <li
    onClick={handleTabClick}
    className={props.name === activeName ? "active" : ""}
  >
    {props.heading()}
  </li>
);
  1. 让我们将Product.tsxTabs的使用切换为以下内容:
<Tabs>
  <Tabs.Tab
    name="Description"
    initialActive={true}
    heading={() => <b>Description</b>}
  />
  <Tabs.Tab name="Reviews" heading={() => "Reviews"} />
</Tabs>

我们可能会收到一个 TSLint 警告:由于其渲染性能影响,JSX 属性中禁止使用 lambda。知道 lambda 可能会有问题是有用的,这样我们在遇到性能问题时可以记住这一点。然而,我们将在tslint.json中关闭此规则,指定"jsx-no-lambda"false

{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    ...
    "jsx-no-lambda": false
  },
  ...
}

如果我们想要非常关注性能,我们可以引用组件内的方法,而不是使用 lambda 函数。

在保存了新的 TSLint 设置之后,编译器的投诉希望会消失。请注意,我们可能需要杀死终端并再次启动应用程序以消除编译器的投诉。

如果我们尝试在我们的应用程序中使用产品页面,它将表现得和以前一样。

因此,实现渲染属性模式非常简单。使用此模式最耗时的事情是理解它可以做什么以及它是如何工作的。一旦我们掌握了它,它就是一个可以为我们组件的消费者提供渲染灵活性的优秀模式。

在我们的Tab组件完成之前,我们还有最后一个部分要完成。

使用“children”属性来呈现选项卡内容。

现在我们的Tab组件已经接近完成了。最后的任务是允许消费者呈现选项卡内容。我们将使用children属性来实现这一点:

  1. 首先,在Tabs.tsx中,让我们将上下文接口中的handleTabClick属性更改为包括要呈现的内容:
interface ITabsContext {
  activeName: string;
  handleTabClick?: (name: string, content: React.ReactNode) => void;
}
  1. 我们还将在状态接口中保存活动内容以及活动选项卡名称。因此,让我们将其添加到Tabs的状态接口中:
interface IState {
  activeName: string;
  activeContent: React.ReactNode;
}
  1. 现在让我们在Tabs中更改选项卡点击处理程序,以设置活动内容的状态以及活动选项卡名称:
private handleTabClick = (name: string, content: React.ReactNode) => {
  this.setState({ activeName: name, activeContent: content });
};
  1. Tab组件中,让我们通过传递children属性来调用选项卡点击处理程序,以获取选项卡内容的附加参数:
const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
  if (context.handleTabClick) {
    context.handleTabClick(props.name, props.children);
  }
};
  1. 现在让我们在Tabs``render方法中呈现我们状态中的活动内容,就在我们呈现选项卡标题的下面:
<TabsContext.Provider ...
>
  <ul className="tabs">{this.props.children}</ul>
  <div>{this.state && this.state.activeContent}</div>
</TabsContext.Provider>
  1. 让我们改变在Product组件中使用Tabs组件的方式:
<h1>{product.name}</h1>

<Tabs>
 <Tabs.Tab
 name="Description"
 initialActive={true}
 heading={() => <b>Description</b>}
 >
 <p>{product.description}</p>
 </Tabs.Tab>

 <Tabs.Tab name="Reviews" heading={() => "Reviews"}>
 <ul className="product-reviews">
 {product.reviews.map(review => (
 <li key={review.reviewer}>
 <i>"{review.comment}"</i> - {review.reviewer}
 </li>
 ))}
 </ul>
 </Tabs.Tab>
</Tabs>

<p className="product-price">
...
</p>

现在选项卡内容已经嵌套在每个Tab组件中,正如我们所希望的那样。

让我们试一试。如果我们转到产品页面,我们会注意到一个问题:

在页面首次加载时未呈现内容。如果我们单击“Reviews”选项卡或“Description”选项卡,然后内容就会加载。

  1. 问题在于当选项卡初始加载时,我们没有任何代码来呈现内容。因此,让我们通过在Tab组件中添加高亮显示的行来解决这个问题:
public static Tab: React.SFC<ITabProps> = props => (
 <TabsContext.Consumer>
 {(context: ITabsContext) => {
  if (!context.activeName && props.initialActive) {
 if (context.handleTabClick) {
 context.handleTabClick(props.name, props.children);
 return null;
 }
 }
 const activeName = context.activeName
 ? context.activeName
 : props.initialActive
 ? props.name
 : "";
 ...
 }}
 </TabsContext.Consumer>
);

高亮显示的行在上下文中没有活动选项卡并且选项卡被标记为初始活动时,调用选项卡点击处理程序。在这种情况下,我们返回 null,因为调用选项卡点击将设置活动选项卡的状态,这将导致另一个渲染周期。

我们的选项卡组件现在应该已经完成了。让我们通过转到产品页面来检查:

内容呈现如我们所期望的那样。 如果我们点击评论选项卡,这也会很好地呈现:

因此,渲染道具和子道具模式非常适合允许消费者渲染自定义内容。 语法一开始可能看起来有点棘手,但当你理解它时,它就变得非常合理和优雅。

在下一节中,我们将看一下本章中的最终模式。

高阶组件

高阶组件HOC)是一个将组件作为参数并返回增强版本的函数组件。 这可能不太明晰,因此我们将在本节中通过一个示例来说明。 我们的示例创建了一个名为withLoader的 HOC,可以应用于任何组件,以在组件忙碌时添加加载旋转器。 我们将在我们的 React 商店(我们在上一节中工作过的)中使用它在产品页面上获取数据时。 完成后将如下所示:

添加异步数据获取

目前,我们商店中的数据获取是瞬时的,因为所有数据都是本地的。 因此,在着手处理withLoader组件之前,让我们重构数据获取函数,包括延迟和异步。 这将更好地模拟使用 Web API 获取数据的真实数据获取函数:

  1. ProductData.ts中,让我们添加以下箭头函数,用于获取产品:
export const getProduct = async (id: number): Promise<IProduct | null> => {
  await wait(1000);
  const foundProducts = products.filter(customer => customer.id === id);
  return foundProducts.length === 0 ? null : foundProducts[0];
};

该函数接受产品 ID 并使用products数组中的filter函数找到产品,然后返回它。

该函数以async关键字为前缀,因为它是异步的。

  1. 该函数还使用await关键字异步调用名为wait的函数。 因此,让我们创建wait函数:
const wait = (ms: number): Promise<void> => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

该函数使用标准的 JavaScript setTimeout函数等待我们在函数参数中指定的毫秒数。 该函数返回一个在setTimeout完成时解析的Promise

如果此刻asyncawait关键字以及承诺不太明晰,不要担心。 我们将在本书的后面详细讨论这些。

因此,我们现在有一个异步获取产品的函数,至少需要 1 秒。让我们将其插入到我们的产品页面中。ProductPage组件是一个负责获取数据的容器组件,所以让我们在这里插入它。

  1. 首先,让我们将getProduct函数导入到ProductPage中:
import { getProduct, IProduct } from "./ProductsData";
  1. 让我们向ProductPage的状态添加一个名为loading的属性,以指示数据是否正在加载:
interface IState {
  product?: IProduct;
  added: boolean;
  loading: boolean;
}
  1. 让我们在构造函数中也将这个状态初始化为true
public constructor(props: Props) {
  super(props);
  this.state = {
    added: false,
    loading: true
  };
}
  1. 现在,我们可以在ProductPage组件加载时使用getProduct函数:
public async componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    const product = await getProduct(id);
    if (product !== null) {
      this.setState({ product, loading: false });
    }
  }
}

我们使用await关键字异步调用getProduct。为了做到这一点,我们需要使用async关键字将componentDidMount生命周期方法标记为异步。在获取产品后,我们将其设置在状态中,并将loading标志重置为false

  1. 如果我们的商店没有运行,让我们运行这个:
npm start

如果我们转到产品页面,我们会发现产品加载大约需要 1 秒。您可能会注意到在产品加载时显示“产品未找到!”。这是因为产品在初始渲染时未设置。我们暂时忽略这个问题,因为我们的withLoaderHOC 将解决这个问题。

因此,现在我们正在异步获取数据,大约需要 1 秒,我们准备实现我们的withLoaderHOC 并在产品页面上使用它。我们将在下一节中完成这个操作。

实现withLoader HOC

我们将创建一个名为withLoader的加载器组件,可以与任何组件一起使用,以指示组件正在忙于执行某些操作:

  1. 让我们首先创建一个名为withLoader.tsx的新文件,内容如下:
import * as React from "react";

interface IProps {
  loading: boolean;
}

const withLoader = <P extends object>(
  Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
  // TODO - return a loading spinner if loading is true otherwise return the component passed in 

export default withLoader;

这里有一些事情正在发生,让我们来分解一下:

  • withLoader是一个接受类型为P的组件的函数。

  • withLoader调用一个函数组件。

  • 函数组件的属性被定义为P & IProps,这是一个交集类型。

交集类型将多种类型合并为一个。因此,XYZXYZ的所有属性和方法合并到一个新类型中。

  • 因此,SFC 的属性包括从传入的组件中获取的所有属性,以及我们定义的loading布尔属性。

  • 使用剩余参数,将 props 解构为一个loading变量和一个包含所有其他属性的props变量。

  1. 因此,我们剩下要做的工作是,如果loadingtrue,则返回我们的加载旋转器,否则我们只需要返回传入的组件。我们可以使用下面代码中窗口中突出显示的三元表达式来实现这一点:
const withLoader = <P extends object>(
  Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
  loading ? (
 <div className="loader-overlay">
 <div className="loader-circle-wrap">
 <div className="loader-circle" />
 </div>
 </div>
 ) : (
 <Component {...props} />
 );

传入的组件在第二个三元分支中返回。我们使用扩展语法将props变量中的属性扩展到组件中。

加载旋转器在第一个三元分支中返回。

  1. 加载旋转器引用了一些 CSS 类,所以让我们把它们添加到index.css中:
.loader-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: Black;
  opacity: 0.3;
  z-index: 10004;
}
.loader-circle-wrap {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  height: 100px;
  width: 100px;
  margin: auto;
}
.loader-circle {
  border: 4px solid #ffffff;
  border-top: 4px solid #899091;
  border-radius: 50%;
  width: 100px;
  height: 100px;
  animation: loader-circle-spin 0.7s linear infinite;
}

loader-overlay类在整个页面上创建一个黑色的透明覆盖层。loader-circle-wrap类在覆盖层的中心创建一个100px100px的正方形。loader-circle类创建旋转的圆圈。

我们的withLoader HOC 现在已经完成。

供参考,下面的代码块显示了基于类的withLoader版本:

const withLoader = <P extends object>(Component: React.ComponentType<P>) =>
  class WithLoader extends React.Component<P & IProps> {
    public render() {
      const { loading, ...props } = this.props as IProps;
      return loading ? (
        <div className="loader-overlay">
          <div className="loader-circle-wrap">
            <div className="loader-circle" />
          </div>
        </div>
      ) : (
        <Component {...props} />
      );
    }
  };

我们将坚持使用 SFC 版本,因为它不包含任何状态,也不需要访问任何生命周期方法。

在下一节中,我们将在商店应用程序中的产品页面中使用我们的withLoader组件。

使用 withLoader HOC

使用 HOC 非常简单。我们只需将 HOC 包装在我们想增强的组件周围。这样做的最简单的地方是在导出语句中。

让我们将在上一节中创建的withLoader HOC 添加到我们的产品页面中:

  1. 因此,我们将使用withLoader来包装Product组件。首先,让我们将withLoader导入到Product.tsx中:
import withLoader from "./withLoader";
  1. 现在我们可以在导出语句中将withLoader包装在Product周围:
export default withLoader(Product);

现在,在ProductPage组件中,我们得到了一个编译错误,因为它期望向Product传递一个 loading 属性。

  1. 因此,让我们在引用Product时,从加载状态中传递 loading 属性:
<Product
  loading={this.state.loading}
  product={product}
  inBasket={this.state.added}
  onAddToBasket={this.handleAddClick}
/>
  1. ProductPage.tsx中,我们应该修改渲染Product组件的条件。现在,如果产品仍在加载,我们希望渲染Product。然后将渲染加载旋转器:
{product || this.state.loading ? (
  <Product
    loading={this.state.loading}
    product={product}
    inBasket={this.state.added}
    onAddToBasket={this.handleAddClick}
  />
) : (
  <p>Product not found!</p>
)}

然而,这会导致另一个编译错误,因为Product组件内的product属性不希望是undefined。然而,在加载产品时它将是undefined

  1. 因此,让我们在IProps中将这个属性设为可选的,用于Product组件:
interface IProps {
  product?: IProduct;
  inBasket: boolean;
  onAddToBasket: () => void;
}

这样,在Product组件中引用product属性时,JSX 中会出现进一步的编译错误,因为在加载数据时它现在将是undefined

  1. 一个简单的解决方法是,如果我们没有产品,就渲染nullwithLoader高阶组件在这种情况下会渲染一个加载旋转器。所以,我们只是让 TypeScript 编译器在这里很高兴:
const handleAddClick = () => {
  props.onAddToBasket();
};
if (!product) {
 return null;
}
return (
  <React.Fragment>
    ...
  </React.Fragment>
);

现在 TypeScript 编译器很高兴,如果我们去商店的产品页面,它将在渲染产品之前显示我们的加载旋转器:

因此,高阶组件非常适合增强组件,其中增强是可以应用于许多组件的东西。我们的加载旋转器是高阶组件的一个常见用例。另一个非常常见的 HOC 模式的用法是在使用 React Router 时。在本书的前面,我们使用了 React Router 的withRouter高阶组件来访问路径的参数。

总结

在本章中,我们学习了容器组件以及它们如何用于管理状态以及展示组件需要做什么。然后展示组件可以专注于它们需要看起来的样子。这使得展示组件可以更容易地在多个地方重复使用并进行单元测试。

我们学到了复合组件是相互依赖的组件。在父类上将复合子项声明为静态成员,可以清楚地告诉消费者这些组件应该一起使用。React 上下文是复合组件共享状态的便捷方式。

我们学到了特殊的 children 属性,可以用来访问和渲染组件的子项。然后我们学到,我们可以创建自己的渲染属性,为消费者提供对组件的自定义渲染部分的极大灵活性。

在上一节中,我们学习了高阶组件以及它们如何用于实现对组件的常见增强。在本书的前面,我们已经使用了 React Router 高阶组件来获取路径参数。

在下一章中,我们将学习如何在 React 中创建表单。在下一章的末尾,我们将使用本章学到的一些模式来以通用的方式处理表单。

问题

让我们用一些问题来测试一下我们对组件模式的学习成果:

  1. React 给我们提供了什么特殊属性来访问组件的子项?

  2. 有多少组件可以通过 React 上下文共享状态?

  3. 在使用 React 上下文时,它使用什么模式来允许我们使用上下文渲染我们的内容?

  4. 一个组件中有多少个渲染 props?

  5. 一个组件中有多少个 children props?

  6. 我们只在产品页面上使用了withLoader。我们在ProductData.ts中使用以下函数来获取所有产品:

export const getProducts = async (): Promise<IProduct[]> => {
  await wait(1000);
  return products;
};

你能用这个来通过使用withLoader高阶组件在产品页面上实现一个加载器吗?

  1. 是否可以使用 children props 模式来创建一个加载器?消费的 JSX 可能如下所示:
<Loader loading={this.state.loading}>
  <div>
    The content for my component ...
  </div>
</Loader>

如果可以的话,试着去实现它。

进一步阅读

第七章:处理表单

表单在我们构建的应用程序中非常常见。在本章中,我们将学习如何在 React 和 TypeScript 中使用受控组件构建表单。作为学习练习,我们将为我们在其他章节中一直在开发的 React 商店构建一个联系我们表单。

我们很快会发现,在创建表单时涉及大量样板代码,因此我们将研究构建通用表单组件以减少样板代码。客户端验证对我们构建的表单的用户体验至关重要,因此我们还将深入讨论这个主题。

最后,表单提交是一个关键考虑因素。我们将介绍如何处理提交错误,以及成功情况。

在本章中,我们将讨论以下主题:

  • 使用受控组件创建表单

  • 使用通用组件减少样板代码

  • 验证表单

  • 表单提交

技术要求

我们将在本章中使用以下技术:

  • Node.jsnpm:TypeScript 和 React 依赖于这些。可以从以下链接安装它们:nodejs.org/en/download/。如果您已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写 React 和 TypeScript

代码,可以从code.visualstudio.com/安装。我们还需要 TSLint 扩展(由 egamma 提供)和 Prettier 扩展(由 Estben Petersen 提供)。

为了从上一章节恢复代码,可以在github.com/carlrip/LearnReact17WithTypeScript上下载LearnReact17WithTypeScript存储库。然后可以在 Visual Studio Code 中打开相关文件夹,然后在终端中输入npm install来进行恢复。本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/07-WorkingWithForms上找到。

使用受控组件创建表单

表单是大多数应用程序的常见部分。在 React 中,创建表单的标准方式是使用所谓的受控组件。受控组件的值与 React 中的状态同步。当我们实现了我们的第一个受控组件时,这将更有意义。

我们将扩展我们一直在构建的 React 商店,以包括一个联系我们表单。这将使用受控组件来实现。

添加联系我们页面

在我们开始处理表单之前,我们需要一个页面来承载表单。该页面将是一个容器组件,我们的表单将是一个展示组件。我们还需要创建一个导航选项,可以带我们到我们的新页面。

在开始实现我们的表单之前,我们将写下以下代码:

  1. 如果还没有,打开在 Visual Studio Code 中的 React 商店项目。在src文件夹中创建一个名为ContactUsPage.tsx的新文件,其中包含以下代码:
import * as React from "react";

class ContactUsPage extends React.Component {
  public render() {
    return (
      <div className="page-container">
        <h1>Contact Us</h1>
        <p>
         If you enter your details we'll get back to you as soon as  
         we can.
        </p>
      </div>
    );
  }
}

export default ContactUsPage;

这个组件最终将包含状态,因此我们创建了一个基于类的组件。目前,它只是简单地呈现一个带有一些说明的标题。最终,它将引用我们的表单。

  1. 现在让我们将这个页面添加到可用的路由中。打开Routes.tsx,并导入我们的页面:
import ContactUsPage from "./ContactUsPage";
  1. Routes组件的render方法中,我们现在可以在admin路由的上方添加一个新路由到我们的页面:
<Switch>
  <Redirect exact={true} from="/" to="/products" />
  <Route path="/products/:id" component={ProductPage} />
  <Route exact={true} path="/products" component={ProductsPage} />
  <Route path="/contactus" component={ContactUsPage} />
  <Route path="/admin">
    ...
  </Route>
  <Route path="/login" component={LoginPage} />
  <Route component={NotFoundPage} />
</Switch>
  1. 现在打开Header.tsx,其中包含所有的导航选项。让我们在管理员链接的上方添加一个NavLink到我们的新页面:
<nav>
  <NavLink to="/products" className="header-link" activeClassName="header-link-active">
    Products
  </NavLink>
  <NavLink to="/contactus" className="header-link" activeClassName="header-link-active">
 Contact Us
 </NavLink>
  <NavLink to="/admin" className="header-link" activeClassName="header-link-active">
    Admin
  </NavLink>
</nav>
  1. 通过在终端中输入以下内容,在开发服务器中运行项目:
npm start

你应该看到一个新的导航选项,可以带我们到我们的新页面:

现在我们有了新页面,我们准备在表单中实现我们的第一个受控输入。我们将在下一节中完成这个任务。

创建受控输入

在这一部分,我们将开始创建包含我们第一个受控输入的表单:

  1. src文件夹中创建一个名为ContactUs.tsx的新文件,其中包含以下代码:
import * as React from "react";

const ContactUs: React.SFC = () => {
  return (
    <form className="form" noValidate={true}>
      <div className="form-group">
        <label htmlFor="name">Your name</label>
        <input type="text" id="name" />
      </div>
    </form>
  );
};

export default ContactUs;

这是一个函数组件,用于呈现一个包含用户姓名标签和输入框的表单。

  1. 我们引用了一些 CSS 类,所以让我们把它们添加到index.css的底部:
.form {
  width: 300px;
  margin: 0px auto 0px auto;
}

.form-group {
  display: flex;
  flex-direction: column;
  margin-bottom: 20px;
}

.form-group label {
  align-self: flex-start;
  font-size: 16px;
  margin-bottom: 3px;
}

.form-group input, select, textarea {
  font-family: Arial;
  font-size: 16px;
  padding: 5px;
  border: lightgray solid 1px;
  border-radius: 5px;
}

form-group类将包装表单中的每个字段,显示标签在输入框上方,并具有良好的间距。

  1. 现在让我们从我们的页面引用我们的表单。转到ContactUsPage.tsx并导入我们的组件:
import ContactUs from "./ContactUs";
  1. 然后我们可以在div容器底部的render方法中引用我们的组件:
<div className="page-container">
  <h1>Contact Us</h1>
  <p>If you enter your details we'll get back to you as soon as we can.</p>
  <ContactUs />
</div>

如果我们查看正在运行的应用程序并转到联系我们页面,我们将看到我们的名字字段被呈现:

我们可以将我们的名字输入到这个字段中,但目前什么也不会发生。我们希望输入的名字存储在ContactUsPage容器组件的状态中。这是因为ContactUsPage最终将管理表单提交。

  1. 让我们为ContactUsPage添加一个状态类型:
interface IState {
 name: string;
 email: string;
 reason: string;
 notes: string;
}

class ContactUsPage extends React.Component<{}, IState> { ... }

除了人的名字,我们还将捕获他们的电子邮件地址,联系商店的原因以及任何其他附加说明。

  1. 让我们还在构造函数中初始化状态:
public constructor(props: {}) {
  super(props);
  this.state = {
    email: "",
    name: "",
    notes: "",
    reason: ""
  };
}
  1. 现在,我们需要将ContactUsPage中的名字值传递到ContactUs组件中。这将允许我们在输入框中显示该值。我们可以通过首先在ContactUs组件中创建 props 来实现这一点:
interface IProps {
 name: string;
 email: string;
 reason: string;
 notes: string;
}

const ContactUs: React.SFC<IProps> = props => { ... }

我们已为我们最终要捕获的所有数据创建了 props。

  1. 现在,我们可以将名字输入值绑定到name属性:
<div className="form-group">
  <label htmlFor="name">Your name</label>
  <input type="text" id="name" value={props.name} />
</div>
  1. 现在,我们可以从ContactUsPage的状态中传递这些:
<ContactUs 
  name={this.state.name} 
 email={this.state.email} 
 reason={this.state.reason} 
 notes={this.state.notes} 
/>

让我们去运行的应用程序并转到我们的联系我们页面。尝试在名字输入框中输入一些内容。

似乎什么都没有发生……有什么东西阻止我们输入值。

我们刚刚将输入值设置为一些 React 状态,因此 React 现在控制着输入的值。这就是为什么我们似乎不再能够输入的原因。

我们正在创建我们的第一个受控输入。但是,如果用户无法输入任何内容,受控输入就没有多大用处。那么,我们如何使输入框再次可编辑呢?

答案是我们需要监听输入值的更改,并相应地更新状态。然后 React 将从状态中呈现新的输入值。

  1. 让我们通过onChange属性监听输入的更改:
<input type="text" id="name" value={props.name} onChange={handleNameChange} />
  1. 让我们也创建我们刚刚引用的处理程序:
const ContactUs: React.SFC<IProps> = props => {
  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
 props.onNameChange(e.currentTarget.value);
 };
  return ( ... );
};

请注意,我们已经使用了通用的React.ChangeEvent命令,其类型为我们正在处理的元素(HTMLInputElement)。

事件参数中的currentTarget属性为我们提供了事件处理程序所附加到的元素的引用。其中的value属性为我们提供了输入的最新值。

  1. 处理程序引用了一个我们尚未定义的onNameChange函数属性。因此,让我们将其添加到我们的接口中,以及其他字段的类似属性:
interface IProps {
  name: string;
  onNameChange: (name: string) => void;
  email: string;
  onEmailChange: (email: string) => void;
  reason: string;
  onReasonChange: (reason: string) => void;
  notes: string;
  onNotesChange: (notes: string) => void;
}
  1. 现在我们可以将这些 props 从ContactUsPage传递到ContactUs
<ContactUs
  name={this.state.name}
  onNameChange={this.handleNameChange}
  email={this.state.email}
  onEmailChange={this.handleEmailChange}
  reason={this.state.reason}
  onReasonChange={this.handleReasonChange}
  notes={this.state.notes}
  onNotesChange={this.handleNotesChange}
/>
  1. 让我们在ContactUsPage中创建我们刚刚引用的更改处理程序,设置相关状态:
private handleNameChange = (name: string) => {
  this.setState({ name });
};
private handleEmailChange = (email: string) => {
  this.setState({ email });
};
private handleReasonChange = (reason: string) => {
  this.setState({ reason });
};
private handleNotesChange = (notes: string) => {
  this.setState({ notes });
};

如果我们现在去运行应用程序中的联系我们页面,并输入一些内容到姓名中,这次输入会按预期的方式行为。

  1. 让我们在ContactUsrender方法中添加电子邮件、原因和备注字段:
<form className="form" noValidate={true} onSubmit={handleSubmit}>
  <div className="form-group">
    <label htmlFor="name">Your name</label>
    <input type="text" id="name" value={props.name} onChange={handleNameChange} />
  </div>

  <div className="form-group">
 <label htmlFor="email">Your email address</label>
 <input type="email" id="email" value={props.email} onChange={handleEmailChange} />
 </div>

 <div className="form-group">
 <label htmlFor="reason">Reason you need to contact us</label>
 <select id="reason" value={props.reason} onChange={handleReasonChange}>
 <option value="Marketing">Marketing</option>
 <option value="Support">Support</option>
 <option value="Feedback">Feedback</option>
 <option value="Jobs">Jobs</option>
 <option value="Other">Other</option>
 </select>
 </div>

 <div className="form-group">
 <label htmlFor="notes">Additional notes</label>
 <textarea id="notes" value={props.notes} onChange={handleNotesChange} />
 </div>
</form>

对于每个字段,我们在div容器中呈现一个label和适当的编辑器,使用form-group类来很好地间隔我们的字段。

所有编辑器都引用处理更改值的处理程序。所有编辑器还从适当的ContactUs属性中设置其值。因此,所有字段编辑器都是受控组件。

让我们更仔细地看一下select编辑器。我们使用value属性在select标签中设置值。然而,这在原生的select标签中并不存在。通常情况下,我们必须在select标签中的相关option标签中包含一个selected属性:

<select id="reason">
  <option value="Marketing">Marketing</option>
  <option value="Support" **selected**>Support</option>
  <option value="Feedback">Feedback</option>
  <option value="Jobs">Jobs</option>
  <option value="Other">Other</option>
</select>

React 在select标签中添加了value属性,并在幕后管理option标签上的selected属性。这使我们能够一致地在我们的代码中管理inputtextareaselected

  1. 现在让我们为这些字段创建更改处理程序,调用我们之前创建的函数 props:
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  props.onEmailChange(e.currentTarget.value);
};
const handleReasonChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  props.onReasonChange(e.currentTarget.value);
};
const handleNotesChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  props.onNotesChange(e.currentTarget.value);
};

这完成了我们基本的联系我们表单,使用各种受控表单元素。我们还没有实现任何验证或提交表单。我们将在本章后面处理这些问题。

我们已经注意到为每个字段获取字段更改到状态的类似代码。在下一节中,我们将开始开发一个通用表单组件,并切换到使用它来处理我们的联系我们表单。

使用通用组件减少样板代码

通用表单组件将有助于减少实现表单所需的代码量。在本节中,我们将对我们在上一节中为ContactUs组件所做的事情进行重构。

让我们考虑如何理想地使用通用组件来生成ContactUs组件的新版本。它可能是以下 JSX 的样子:

<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
  <Form.Field name="name" label="Your name" />
  <Form.Field name="email" label="Your email address" type="Email" />
  <Form.Field name="reason" label="Reason you need to contact us" type="Select" options={["Marketing", "Support", "Feedback", "Jobs", "Other"]} />
  <Form.Field name="notes" label="Additional notes" type="TextArea" />
</Form>

在这个例子中,有两个通用的复合组件:FormField。以下是一些关键点:

  • Form组件是复合组件的容器,管理状态和交互。

  • 我们在Form组件的defaultValues属性中传递字段的默认值。

  • Field组件为每个字段渲染标签和编辑器。

  • 每个字段都有一个name属性,它将确定状态中存储字段值的属性名称。

  • 每个字段都有一个label属性,用于指定每个字段标签中显示的文本。

  • 使用type属性指定特定的字段编辑器。默认编辑器是基于文本的input

  • 如果编辑器类型是Select,那么我们可以使用options属性指定出现在其中的选项。

渲染新的ContactUs组件的 JSX 比原始版本要短得多,而且可能更容易阅读。状态管理和事件处理程序被隐藏在Form组件中并封装起来。

创建一个基本的表单组件

是时候开始处理我们的通用Form组件了:

  1. 让我们首先在src文件夹中创建一个名为Form.tsx的新文件,其中包含以下内容:
import * as React from "react";

interface IFormProps {}

interface IState {}

export class Form extends React.Component<IFormProps, IState> {
  constructor(props: IFormProps) {}
  public render() {}
}

Form是一个基于类的组件,因为它需要管理状态。我们将 props 接口命名为IFormProps,因为以后我们将需要一个字段 props 的接口。

  1. 让我们在IFormProps接口中添加一个defaultValues属性。这将保存表单中每个字段的默认值:
export interface IValues {
 [key: string]: any;
}

interface IFormProps {
  defaultValues: IValues;
}

我们使用了一个名为IValues的额外接口来表示默认值类型。这是一个可索引的键/值类型,具有string类型的键和any类型的值。键将是字段名称,值将是字段值。

因此,defaultValues属性的值可以是这样的:

{ name: "", email: "", reason: "Support", notes: "" }
  1. 现在让我们继续处理Form中的状态。我们将在状态属性values中存储字段值:
interface IState {
  values: IValues;
}

请注意,这与defaultValues属性的类型相同,即IValues

  1. 现在我们将在构造函数中使用默认值初始化状态:
constructor(props: IFormProps) {
  super(props);
  this.state = {
    values: props.defaultValues
  };
}
  1. 在本节中,我们要做的最后一件事是开始实现Form组件中的render方法:
public render() {
 return (
 <form className="form" noValidate={true}>
 {this.props.children}
 </form>
 );
}

我们在form标签中渲染子组件,使用了我们在上一章中使用的神奇的children属性。

这很好地引出了Field组件,我们将在下一节中实现它。

添加一个基本的 Field 组件

Field组件需要渲染标签和编辑器。它将位于Form组件内部的静态属性Field中。消费者可以使用Form.Field来引用此组件:

  1. 让我们首先在Form.tsx中为字段 props 创建一个接口,就在IFormProps上面:
interface IFieldProps {
  name: string;
  label: string;
  type?: "Text" | "Email" | "Select" | "TextArea";
  options?: string[];
}
  • name属性是字段的名称。

  • label属性是要在字段标签中显示的文本。

  • type属性是要显示的编辑器类型。我们已经为此属性使用了联合类型,包含了我们将要支持的可用类型。请注意,我们已将其定义为可选属性,因此稍后需要为此定义一个默认值。

  • options属性仅适用于Select编辑器类型,也是可选的。这定义了要在下拉列表中显示的选项列表,是一个string数组。

  1. 现在,让我们在Form中为Field组件添加一个骨架静态Field属性:
public static Field: React.SFC<IFieldProps> = props => {
  return ();
};
  1. 在我们忘记之前,让我们为字段type属性添加默认值。我们将其定义如下,在Form类的外部和下方:
Form.Field.defaultProps = {
  type: "Text"
};

因此,默认的type将是基于文本的输入。

  1. 现在,让我们尝试渲染字段:
public static Field: React.SFC<IFieldProps> = props => {
  const { name, label, type, options } = props;
  return (
    <div className="form-group">
 <label htmlFor={name}>{label}</label>
 <input type={type.toLowerCase()} id={name} />
 </div>
  );
}
  • 我们首先从 props 对象中解构namelabeltypeoptions

  • 该字段被包裹在一个div容器中,使用form-group类在index.css中已经实现的方式在垂直方向上间隔字段。

  • 然后,在div容器内部渲染labellabelhtmlFor属性引用inputid

这是一个很好的开始,但并非所有不同的字段编辑器都是输入。实际上,这只适用于TextEmail类型。

  1. 因此,让我们稍微调整一下,并在输入周围包裹一个条件表达式:
<label htmlFor={name}>{label}</label>
{(type === "Text" || type === "Email") && (
  <input type={type.toLowerCase()} id={name} />
)}
  1. 接下来,让我们通过添加高亮的 JSX 来处理TextArea类型:
{(type === "Text" || type === "Email") ... }

{type === "TextArea" && (
 <textarea id={name} />
)}
  1. 现在,我们可以渲染我们将要支持的最终编辑器,如下所示:
{type === "TextArea" ... } {type === "Select" && (
  <select>
    {options &&
      options.map(option => (
        <option key={option} value={option}>
          {option}
        </option>
      ))}
  </select>
)} 

我们渲染一个select标签,其中包含使用options数组属性中的map函数指定的选项。请注意,我们为每个选项分配一个唯一的key属性,以便在检测到选项的任何更改时保持 React 的正常运行。

现在,我们已经有了基本的FormField组件,这很棒。但是,实现仍然相当无用,因为我们尚未在状态中管理字段值。让我们在下一节中解决这个问题。

与 React 上下文共享状态

字段值的状态存储在Form组件中。但是,这些值是通过Field组件渲染和更改的。Field组件无法访问Form中的状态,因为状态存在于Form实例中,而Field没有。

这与我们在上一章中实现的复合Tabs组件非常相似。我们使用 React 上下文在Tabs复合组件之间共享状态。

在本节中,我们将使用相同的方法来处理Forms组件。

  1. 让我们首先在Form.tsx中为表单上下文创建一个接口:
interface IFormContext {
  values: IValues;
}

上下文只包含与我们状态中的IValues相同类型的值。

  1. 现在让我们在IFormContext下方使用React.createContext创建上下文组件:
const FormContext = React.createContext<IFormContext>({
  values: {}
});

通过将初始上下文值设置为空文字值,我们使 TypeScript 编译器满意。

  1. Formrender方法中,创建包含状态中的值的上下文值:
public render() {
  const context: IFormContext = {
 values: this.state.values
 };
  return ( ... )
}
  1. render方法的 JSX 中的form标签周围包装上下文提供程序:
<FormContext.Provider value={context}>
  <form ... >
    ...
  </form>
</FormContext.Provider>
  1. 现在我们可以在Field SFC 中使用上下文:
<FormContext.Consumer>
 {context => (
    <div className="form-group">
    </div>
 )}
</FormContext.Consumer>
  1. 既然我们可以访问上下文了,让我们在所有三个编辑器中从中呈现值:
<div className="form-group">
  <label htmlFor={name}>{label}</label>
  {(type === "Text" || type === "Email") && (
    <input type={type.toLowerCase()} id={name} value={context.values[name]} />
  )}

  {type === "TextArea" && (
    <textarea id={name} value={context.values[name]} />
  )}

  {type === "Select" && (
    <select value={context.values[name]}>
    ...
    </select>
  )}
</div>

TypeScript 编译器现在对我们的FormField组件满意了。因此,我们可以开始对新的ContactUs实现进行工作。

然而,用户现在还不能输入任何内容到我们的表单中,因为我们还没有处理更改并将新值传递给状态。现在我们需要实现更改处理程序。

  1. 让我们首先在Form类中创建一个setValue方法:
private setValue = (fieldName: string, value: any) => {
  const newValues = { ...this.state.values, [fieldName]: value };
  this.setState({ values: newValues });
};

这个方法的关键点如下:

  • 该方法接受字段名称和新值作为参数。

  • 使用一个名为newValues的新对象创建values对象的新状态,该对象展开了状态中的旧值,然后添加了新的字段名称和值。

  • 然后在状态中设置新值。

  1. 然后我们在表单上下文中创建对该方法的引用,以便Field组件可以访问它。让我们首先将其添加到表单上下文接口中:
interface IFormContext {
  values: IValues;
  setValue?: (fieldName: string, value: any) => void;
}

我们将属性设置为可选,以便在创建表单上下文组件时使 TypeScript 编译器满意。

  1. 然后我们可以在创建上下文值时在Form中创建对setValue方法的引用:
const context: IFormContext = {
  setValue: this.setValue,
  values: this.state.values
};
  1. 现在我们可以从Field组件中访问并调用这个方法。在Field中,在我们解构props对象之后,让我们创建一个更改处理程序来调用setValue方法:
const { name, label, type, options } = props;

const handleChange = (
 e:
 | React.ChangeEvent<HTMLInputElement>
 | React.ChangeEvent<HTMLTextAreaElement>
 | React.ChangeEvent<HTMLSelectElement>,
 context: IFormContext
) => {
 if (context.setValue) {
 context.setValue(props.name, e.currentTarget.value);
 }
};

让我们来看看这个方法的关键点:

  • TypeScript 更改事件类型为ChangeEvent<T>,其中T是正在处理的元素的类型。

  • 处理程序的第一个参数e是 React 的 change 事件处理程序参数。我们将所有不同的编辑器的 change 处理程序类型联合起来,这样我们就可以在一个函数中处理所有的变化。

  • 处理程序的第二个参数是表单上下文。

  • 我们需要一个条件语句来检查setValue方法是否不是undefined,以使 TypeScript 编译器满意。

  • 然后我们可以使用字段名称和新值调用setValue方法。

  1. 然后我们可以在input标签中引用这个 change handler,如下所示:
<input 
  type={type.toLowerCase()} 
  id={name} 
  value={context.values[name]}
  onChange={e => handleChange(e, context)} 
/>

请注意,我们使用了一个 lambda 函数,这样我们就可以将上下文值传递给handleChange

  1. 我们也可以在textarea标签中这样做:
<textarea 
  id={name} 
  value={context.values[name]} 
  onChange={e => handleChange(e, context)} 
/>
  1. 我们也可以在select标签中这样做:
<select 
 value={context.values[name]}
 onChange={e => handleChange(e, context)} 
>
 ...
</select>

因此,我们的FormField组件现在很好地协同工作,渲染字段并管理它们的值。在下一节中,我们将通过实现一个新的ContactUs组件来尝试我们的通用组件。

实现我们的新 ContactUs 组件

在本节中,我们将使用我们的FormField组件实现一个新的ContactUs组件:

  1. 让我们首先从ContactUs.tsx中删除 props 接口。

  2. ContactUs SFC 中的内容将与原始版本非常不同。让我们首先删除内容,使其看起来如下:

const ContactUs: React.SFC = () => {
  return ();
};
  1. 让我们将我们的Form组件导入到ContactUs.tsx中:
import { Form } from "./Form";
  1. 现在我们可以引用Form组件,传递一些默认值:
return (
  <Form
 defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
 >
 </Form>
);
  1. 让我们添加name字段:
<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
  <Form.Field name="name" label="Your name" />
</Form>

请注意,我们没有传递type属性,因为这将默认为基于文本的输入,这正是我们需要的。

  1. 现在让我们添加emailreasonnotes字段:
<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
>
  <Form.Field name="name" label="Your name" />
  <Form.Field name="email" label="Your email address" type="Email" />
 <Form.Field
 name="reason"
 label="Reason you need to contact us"
 type="Select"
 options={["Marketing", "Support", "Feedback", "Jobs", "Other"]}
 />
 <Form.Field name="notes" label="Additional notes" type="TextArea" />
</Form>
  1. ContactUsPage现在会简单得多。它不会包含任何状态,因为现在状态是在Form组件中管理的。我们也不需要向ContactUs组件传递任何 props:
class ContactUsPage extends React.Component<{}, {}> {
  public render() {
    return (
      <div className="page-container">
        <h1>Contact Us</h1>
        <p>
          If you enter your details we'll get back to you as soon as we can.
        </p>
        <ContactUs />
      </div>
    );
  }
}

如果我们转到运行中的应用程序并转到联系我们页面,它会按照要求呈现并接受我们输入的值。

我们的通用表单组件正在良好地进展,并且我们已经使用它来实现了ContactUs组件,正如我们所希望的那样。在下一节中,我们将通过添加验证进一步改进我们的通用组件。

验证表单

在表单中包含验证可以提高用户体验,让他们立即得到关于输入信息是否有效的反馈。在本节中,我们将为我们的Form组件添加验证,然后在我们的ContactUs组件中使用它。

我们将在ContactUs组件中实现的验证规则是这些:

  • 名称和电子邮件字段应填写

  • 名称字段应至少为两个字符

当字段编辑器失去焦点时,我们将执行验证规则。

在下一节中,我们将向Form组件添加一个属性,允许消费者指定验证规则。

向表单添加验证规则属性

让我们考虑如何指定验证规则给表单。我们需要能够为一个字段指定一个或多个规则。一些规则可能有参数,比如最小长度。如果我们能够像下面的示例那样指定规则就好了:

<Form
  ...
  validationRules={{
 email: { validator: required },
 name: [{ validator: required }, { validator: minLength, arg: 3 }]
 }}
>
  ...
</Form>

让我们尝试在Form组件上实现validationRules属性:

  1. 首先在Form.tsx中为Validator函数定义一个类型:
export type Validator = (
  fieldName: string,
  values: IValues,
  args?: any
) => string;

Validator函数将接受字段名称、整个表单的值和特定于函数的可选参数。将返回包含验证错误消息的字符串。如果字段有效,则返回空字符串。

  1. 让我们使用此类型创建一个Validator函数,以检查Validator类型下名为required的字段是否已填写:
export const required: Validator = (
  fieldName: string,
  values: IValues,
  args?: any
): string =>
  values[fieldName] === undefined ||
  values[fieldName] === null ||
  values[fieldName] === ""
    ? "This must be populated"
    : "";

我们导出该函数,以便稍后在我们的ContactUs实现中使用。该函数检查字段值是否为undefinednull或空字符串,如果是,则返回必须填写此字段的验证错误消息。

如果字段值不是undefinednull或空字符串,则返回空字符串以指示该值有效。

  1. 同样,让我们为检查字段输入是否超过最小长度创建一个Validator函数:
export const minLength: Validator = (
  fieldName: string,
  values: IValues,
  length: number
): string =>
  values[fieldName] && values[fieldName].length < length
    ? `This must be at least ${length} characters`
    : "";

该函数检查字段值的长度是否小于长度参数,如果是,则返回验证错误消息。否则,返回空字符串以指示该值有效。

  1. 现在,让我们通过一个属性向Form组件传递验证规则的能力:
interface IValidation {
 validator: Validator;
 arg?: any;
}

interface IValidationProp {
 [key: string]: IValidation | IValidation[];
}

interface IFormProps {
  defaultValues: IValues;
  validationRules: IValidationProp;
}
  • validationRules属性是一个可索引的键/值类型,其中键是字段名称,值是一个或多个IValidation类型的验证规则。

  • 验证规则包含Validator类型的验证函数和传递到验证函数的参数。

  1. 有了新的validationRules属性,让我们将其添加到ContactUs组件中。首先导入验证函数:
import { Form, minLength, required } from "./Form";
  1. 现在,让我们将验证规则添加到ContactUs组件的 JSX 中:
<Form
  defaultValues={{ name: "", email: "", reason: "Support", notes: "" }}
  validationRules={{
 email: { validator: required },
 name: [{ validator: required }, { validator: minLength, arg: 2 }]
 }}
>
  ...
</Form>

现在,如果名称和电子邮件已填写,并且名称至少为两个字符长,我们的表单就是有效的。

这就是validationRulesprop 的完成。在下一节中,我们将跟踪验证错误消息,以准备在页面上呈现它们。

跟踪验证错误消息

当用户完成表单并字段变为有效或无效时,我们需要在状态中跟踪验证错误消息。稍后,我们将能够将错误消息呈现到屏幕上。

Form组件负责管理所有表单状态,因此我们将错误消息状态添加到其中,如下所示:

  1. 让我们将验证错误消息状态添加到表单状态接口中:
interface IErrors {
 [key: string]: string[];
}

interface IState {
  values: IValues;
  errors: IErrors;
}

errors状态是可索引的键/值类型,其中键是字段名称,值是验证错误消息的数组。

  1. 让我们在构造函数中初始化errors状态:
constructor(props: IFormProps) {
  super(props);
  const errors: IErrors = {};
 Object.keys(props.defaultValues).forEach(fieldName => {
 errors[fieldName] = [];
 });
  this.state = {
    errors,
    values: props.defaultValues
  };
}

defaultValuesprop 包含其键中的所有字段名称。我们遍历defaultValues键,将适当的errors键设置为空数组。因此,当Form组件初始化时,没有任何字段包含任何验证错误消息,这正是我们想要的。

  1. Field组件最终将呈现验证错误消息,因此我们需要将这些添加到表单上下文中。让我们从将这些添加到表单上下文接口开始:
interface IFormContext {
 errors: IErrors;  values: IValues;
  setValue?: (fieldName: string, value: any) => void;
}
  1. 让我们在创建上下文时将errors空文字作为默认值添加。这是为了让 TypeScript 编译器满意:
const FormContext = React.createContext<IFormContext>({
  errors: {},
  values: {}
});
  1. 现在,我们可以在上下文值中包含错误:
public render() {
  const context: IFormContext = {
    errors: this.state.errors,
    setValue: this.setValue,
    values: this.state.values
  };
  return (
    ...
  );
}

现在,验证错误在表单状态中,也在表单上下文中,以便Field组件可以访问。在下一节中,我们将创建一个方法来调用验证规则。

调用验证规则

到目前为止,我们可以定义验证规则,并且有状态来跟踪验证错误消息,但是还没有调用规则。这就是我们将在本节中实现的内容:

  1. 我们需要在Form组件中创建一个方法,该方法将验证字段,调用指定的验证器函数。让我们创建一个名为validate的方法,该方法接受字段名称和其值。该方法将返回一个验证错误消息数组:
private validate = (
  fieldName: string,
  value: any
): string[] => {

};
  1. 让我们获取字段的验证规则并初始化一个errors数组。当验证器被执行时,我们将在errors数组中收集所有的错误。在所有验证器被执行后,我们还将返回errors数组:
private validate = ( 
  fieldName: string,
  value: any
): string[] => {
  const rules = this.props.validationRules[fieldName];
 const errors: string[] = [];

  // TODO - execute all the validators

  return errors;
}
  1. 规则可以是一个IValidation数组,也可以是一个单独的IValidation。让我们检查一下,如果只有一个验证规则,就调用validator函数:
const errors: string[] = [];
if (Array.isArray(rules)) {
 // TODO - execute all the validators in the array of rules
} else {
  if (rules) {
    const error = rules.validator(fieldName, this.state.values, rules.arg);
    if (error) {
      errors.push(error);
    }
  }
}
return errors;
  1. 现在让我们处理有多个验证规则时的代码分支。我们可以在规则数组上使用forEach函数来遍历规则并执行validator函数:
if (Array.isArray(rules)) {
  rules.forEach(rule => {
 const error = rule.validator(
 fieldName,
 this.state.values,
 rule.arg
 );
 if (error) {
 errors.push(error);
 }
 });
} else {
  ...
}
return errors;
  1. 我们需要在validate方法中实现的最后一部分代码是设置新的errors表单状态:
if (Array.isArray(rules)) {
 ...
} else {
 ...
}
const newErrors = { ...this.state.errors, [fieldName]: errors };
this.setState({ errors: newErrors });
return errors;

我们将旧的错误状态扩展到一个新对象中,然后为字段添加新的错误。

  1. Field组件需要调用这个validate方法。我们将在表单上下文中添加对这个方法的引用。让我们先将它添加到IFormContext接口中:
interface IFormContext {
  values: IValues;
  errors: IErrors;
  setValue?: (fieldName: string, value: any) => void;
  validate?: (fieldName: string, value: any) => void;
}
  1. 现在我们可以在Formrender方法中将其添加到上下文值中:
public render() {
  const context: IFormContext = {
    errors: this.state.errors,
    setValue: this.setValue,
    validate: this.validate,
    values: this.state.values
  };
  return (
    ...
  );
}

我们的表单验证进展顺利,现在我们有一个可以调用的方法来调用字段的所有规则。然而,这个方法还没有被从任何地方调用,因为用户填写表单。我们将在下一节中做这件事。

从字段触发验证规则执行

当用户填写表单时,我们希望在字段失去焦点时触发验证规则。我们将在本节中实现这一点:

  1. 让我们创建一个函数,来处理三种不同编辑器的blur事件:
const handleChange = (
  ...
};

const handleBlur = (
 e:
 | React.FocusEvent<HTMLInputElement>
 | React.FocusEvent<HTMLTextAreaElement>
 | React.FocusEvent<HTMLSelectElement>,
 context: IFormContext
) => {
 if (context.validate) {
 context.validate(props.name, e.currentTarget.value);
 }
};

return ( ... )
  • TypeScript 的模糊事件类型是FocusEvent<T>,其中T是正在处理的元素的类型。

  • 处理程序的第一个参数e是 React 模糊事件处理程序参数。我们将所有不同的处理程序类型联合起来,这样我们就可以在一个函数中处理所有的模糊事件。

  • 处理程序的第二个参数是表单上下文。

  • 我们需要一个条件语句来检查validate方法是否不是undefined,以使 TypeScript 编译器满意。

  • 然后我们可以使用字段名称和需要验证的新值调用validate方法。

  1. 现在我们可以在文本和电子邮件编辑器的Field JSX 中引用这个处理程序:
{(type === "Text" || type === "Email") && (
  <input
    type={type.toLowerCase()}
    id={name}
    value={context.values[name]}
    onChange={e => handleChange(e, context)}
    onBlur={e => handleBlur(e, context)}
  />
)}

我们将onBlur属性设置为调用我们的handleBlur函数的 lambda 表达式,同时传入模糊参数和上下文值。

  1. 现在让我们在另外两个编辑器中引用这个处理程序:
{type === "TextArea" && (
  <textarea
    id={name}
    value={context.values[name]}
    onChange={e => handleChange(e, context)}
    onBlur={e => handleBlur(e, context)}
  />
)}
{type === "Select" && (
  <select
    value={context.values[name]}
    onChange={e => handleChange(e, context)}
    onBlur={e => handleBlur(e, context)}
  >
    ...
  </select>
)}

我们的字段现在在失去焦点时执行验证规则。在我们尝试给我们的联系我们页面一个尝试之前,还有一项任务要做,我们将在下一节中完成。

渲染验证错误消息

在这一节中,我们将在Field组件中渲染验证错误消息:

  1. 让我们在form-groupdiv容器底部显示所有错误,使用我们已经实现的form-error CSS 类的span
<div className="form-group">
  <label htmlFor={name}>{label}</label>
  {(type === "Text" || type === "Email") && (
    ...
  )}
  {type === "TextArea" && (
    ...
  )}
  {type === "Select" && (
    ...
  )}
  {context.errors[name] &&
 context.errors[name].length > 0 &&
 context.errors[name].map(error => (
 <span key={error} className="form-error">
 {error}
 </span>
 ))}
</div>

因此,我们首先检查字段名称是否有错误,然后在errors数组中使用map函数为每个错误渲染一个span

  1. 我们已经引用了一个 CSS form-error类,所以让我们把它添加到index.css中:
.form-error {
  font-size: 13px;
  color: red;
  margin: 3px auto 0px 0px;
}

现在是时候尝试联系我们页面了。如果我们的应用程序没有启动,请使用npm start启动它,然后转到联系我们页面。如果我们通过名称和电子邮件字段进行切换,将触发必填验证规则,并显示错误消息:

这正是我们想要的。如果我们回到名称字段,尝试在切换之前只输入一个字符,那么最小长度验证错误会触发,正如我们所期望的那样:

我们的通用表单组件现在几乎完成了。我们的最后任务是提交表单,我们将在下一节中完成。

表单提交

提交表单是表单实现的最后一部分。Form组件的消费者将处理实际的提交,这可能会导致调用 Web API。我们的Form组件在表单提交时将简单地调用消费者代码中的一个函数。

在表单中添加一个提交按钮

在这一节中,我们将向我们的Form组件添加一个提交按钮:

  1. 让我们在Form JSX 中添加一个提交按钮,放在form-group中的div容器中:
<FormContext.Provider value={context}>
  <form className="form" noValidate={true}>
    {this.props.children}
    <div className="form-group">
 <button type="submit">Submit</button>
 </div>
  </form>
</FormContext.Provider>
  1. 使用以下 CSS 样式为按钮添加样式在index.css中:
.form-group button {
  font-size: 16px;
  padding: 8px 5px;
  width: 80px;
  border: black solid 1px;
  border-radius: 5px;
  background-color: black;
  color: white;
}
.form-group button:disabled {
  border: gray solid 1px;
  background-color: gray;
  cursor: not-allowed;
}

我们现在在表单上有一个黑色的提交按钮,当禁用时是灰色的。

添加一个 onSubmit 表单 prop

在我们的Form组件中,我们需要一个新的 prop,允许消费者指定要调用的submit函数。我们将在这一节中完成这个任务:

  1. 让我们首先在Form props 接口中创建一个名为onSubmit的新 prop 函数:
export interface ISubmitResult {
 success: boolean;
 errors?: IErrors;
}

interface IFormProps {
  defaultValues: IValues;
  validationRules: IValidationProp;
  onSubmit: (values: IValues) => Promise<ISubmitResult>;
}

该函数将接受字段值,并异步返回提交是否成功,以及在服务器上发生的任何验证错误。

  1. 我们将跟踪表单是否正在提交或者在 Form 状态中成功提交的情况。
interface IState {
  values: IValues;
  errors: IErrors;
  submitting: boolean;
 submitted: boolean;
}
  1. 让我们在构造函数中初始化这些状态值:
constructor(props: IFormProps) {
  ...
  this.state = {
    errors,
    submitted: false,
 submitting: false,
    values: props.defaultValues
  };
}
  1. 如果表单正在提交或已成功提交,我们现在可以禁用提交按钮:
<button
  type="submit"
  disabled={this.state.submitting || this.state.submitted}
>
  Submit
</button>
  1. 让我们在 form 标签中引用一个提交处理程序:
<form className="form" noValidate={true} onSubmit={this.handleSubmit}>
  ...
</form>
  1. 现在我们可以开始实现我们刚刚引用的提交处理程序:
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

};

我们在提交事件参数中调用 preventDefault 来阻止浏览器自动发布表单。

  1. 在开始表单提交过程之前,我们需要确保所有字段都是有效的。让我们引用并创建一个执行此操作的 validateForm 函数:
private validateForm(): boolean {
 const errors: IErrors = {};
 let haveError: boolean = false;
 Object.keys(this.props.defaultValues).map(fieldName => {
 errors[fieldName] = this.validate(
 fieldName,
 this.state.values[fieldName]
 );
 if (errors[fieldName].length > 0) {
 haveError = true;
 }
 });
 this.setState({ errors });
 return !haveError;
}

private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  if (this.validateForm()) {

 }
};

validateForm 函数遍历字段,调用已经实现的 validate 函数。状态会更新为最新的验证错误,并返回字段中是否有任何错误。

  1. 让我们现在实现剩下的提交处理程序:
private handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  if (this.validateForm()) {
    this.setState({ submitting: true });
 const result = await this.props.onSubmit(this.state.values);
 this.setState({
 errors: result.errors || {},
 submitted: result.success,
 submitting: false
 });
  }
};

如果表单有效,我们首先将 submitting 状态设置为 true。然后我们异步调用 onSubmit prop 函数。当 onSubmit prop 函数完成时,我们将函数中的任何验证错误与提交是否成功一起设置在状态中。我们还在状态中设置提交过程已经完成的事实。

现在,我们的 Form 组件有一个 onSubmit 函数 prop。在下一节中,我们将在我们的联系我们页面中使用它。

使用 onSubmit 表单 prop

在这一节中,我们将在 ContactUs 组件中使用 onSubmit 表单 prop。ContactUs 组件不会管理提交,它只会委托给 ContactUsPage 组件来处理提交:

  1. 让我们首先导入 ISubmitResultIValues,并在 ContactUs 组件中为 onSubmit 函数创建一个 props 接口:
import { Form, ISubmitResult, IValues, minLength, required } from "./Form";

interface IProps {
 onSubmit: (values: IValues) => Promise<ISubmitResult>;
} const ContactUs: React.SFC<IProps> = props => { ... }
  1. 创建一个 handleSubmit 函数来调用 onSubmit prop:
const ContactUs: React.SFC<IProps> = props => {
  const handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
 const result = await props.onSubmit(values);
 return result;
 };
  return ( ... );
};

onSubmit prop 是异步的,所以我们需要在我们的函数前加上 async,并在 onSubmit 调用前加上 await

  1. 在 JSX 中将此提交处理程序绑定到表单的 onSubmit prop 中:
return (
  <Form ... onSubmit={handleSubmit}>
    ...
  </Form>
);
  1. 现在让我们转到 ContactUsPage 组件。让我们首先创建提交处理程序:
private handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
  await wait(1000); // simulate asynchronous web API call
  return {
    errors: {
      email: ["Some is wrong with this"]
    },
    success: false
  };
};

在实践中,这可能会调用一个 web API。在我们的例子中,我们异步等待一秒钟,并返回一个带有 email 字段的验证错误。

  1. 让我们创建刚刚引用的 wait 函数:
const wait = (ms: number): Promise<void> => {
 return new Promise(resolve => setTimeout(resolve, ms));
};
  1. 现在让我们将handleSubmit方法与ContactUsonSubmit属性连接起来:
<ContactUs onSubmit={this.handleSubmit} />
  1. 我们已经引用了IValuesISubmitResult,所以让我们导入它们:
import { ISubmitResult, IValues } from "./Form";

如果我们转到正在运行的应用程序中的联系我们页面,填写表单并单击提交按钮,我们会收到有关电子邮件字段存在问题的通知,这是我们所期望的:

  1. 让我们将ContactUsPage中的提交处理程序更改为返回成功的结果:
private handleSubmit = async (values: IValues): Promise<ISubmitResult> => {
  await wait(1000); // simulate asynchronous web API call
 return {
 success: true
 };
};

现在,如果我们再次转到正在运行的应用程序中的联系我们页面,填写表单并单击提交按钮,提交将顺利进行,并且提交按钮将被禁用:

因此,我们的联系我们页面已经完成,还有我们的通用FormField组件。

总结

在本章中,我们讨论了受控组件,这是 React 处理表单数据输入的推荐方法。通过受控组件,我们让 React 通过组件状态控制输入值。

我们研究了构建通用的FormField组件,这些组件包含状态和更改处理程序,因此我们不需要为应用程序中每个表单中的每个字段实现单独的状态和更改处理程序。

然后,我们创建了一些标准验证函数,并在通用Form组件中添加了添加验证规则的能力,并在Field组件中自动呈现验证错误。

最后,我们添加了在使用通用Form组件时处理表单提交的能力。我们的联系我们页面已更改为使用通用的FormField组件。

我们的通用组件只处理非常简单的表单。毫不奇怪,已经有相当多的成熟表单库在外面。一个受欢迎的选择是 Formik,它在某些方面类似于我们刚刚构建的内容,但功能更加强大。

如果您正在构建包含大量表单的应用程序,构建一个通用表单或使用 Formik 等已建立的库来加快开发过程是非常值得的。

问题

通过尝试以下实现来检查关于 React 和 TypeScript 中表单的所有信息是否已经掌握:

  1. 扩展我们的通用Field组件,使用原生数字输入包括一个数字编辑器。

  2. 在联系我们表单上实现一个紧急性字段,以指示回复的紧急程度。该字段应为数字。

  3. 在通用的Form组件中实现一个新的验证器函数,用于验证一个数字是否落在另外两个数字之间。

  4. 在紧急字段上实施验证规则,以确保输入是 1 到 10 之间的数字。

  5. 我们的验证在用户点击字段而不输入任何内容时触发。当字段失去焦点时如何触发验证,但只有在字段已经被更改时?

进一步阅读

以下链接是关于 React 中表单的进一步信息的良好来源:

第八章:React Redux

到目前为止,在本书中,我们已经在 React 组件内部管理了状态。当状态需要在不同组件之间共享时,我们还使用了 React 上下文。这种方法对许多应用程序都很有效。React Redux 帮助我们强大地处理复杂的状态场景。当用户交互导致状态发生多个变化时,它会发挥作用,也许其中一些是有条件的,特别是当交互导致 web 服务调用时。当应用程序中存在大量共享状态时,它也非常有用。

在本章中,我们将继续构建我们的 React 商店,添加 React Redux 来帮助我们管理状态交互。最终,我们将在商店的页眉中添加一个购物篮摘要组件,通知用户他们的购物篮中有多少件商品。Redux 将帮助我们在商品添加到购物篮时更新此组件。

在本章的最后一节中,我们将探讨一种类似于 Redux 的方法,用于在组件内部管理复杂状态。这是在 Redux 存储中管理状态和仅在组件内部使用setStateuseState之间的中间地带。

在本章中,我们将学习以下主题:

  • 原则和关键概念

  • 安装 Redux

  • 创建 reducers

  • 创建动作

  • 创建存储

  • 将我们的 React 应用连接到存储

  • 使用 useReducer 管理状态

技术要求

在本章中,我们将使用以下技术:

为了从上一章恢复代码,可以下载github.com/carlrip/LearnReact17WithTypeScript上的LearnReact17WithTypeScript存储库。然后可以在 Visual Studio Code 中打开相关文件夹,然后在终端中输入npm install进行恢复。本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/08-ReactRedux%EF%BB%BF上找到。

原则和关键概念

在本节中,我们将首先介绍 Redux 中的三个原则,然后深入探讨核心概念。

原则

让我们来看看 Redux 的三个原则:

  • 唯一数据源:这意味着整个应用程序状态存储在一个对象中。在真实的应用程序中,该对象可能包含复杂的嵌套对象树。

  • 状态是只读的:这意味着状态不能直接更改。这有点像说我们不能直接更改组件内的状态。在 Redux 中,更改状态的唯一方法是分派所谓的动作。

  • 更改是通过纯函数进行的:负责更改状态的函数称为 reducers。

在接下来的章节中,我们将更深入地了解动作和 reducers,以及管理它们的东西,即所谓的store

关键概念

应用程序的整个状态存储在所谓的store中。状态存储在一个 JavaScript 对象中,如下所示:

{
  products: [{ id: 1, name: "Table", ...}, {...}, ...],
  productsLoading: false,
  currentProduct: { id: 2, xname: "Chair", ... },
  basket: [{ product: { id: 2, xname: "Chair" }, quantity: 1 }],
};

在这个例子中,单个对象包含以下内容:

  • 产品数组

  • 产品是否正在从 Web API 中获取

  • 用户正在查看的当前产品

  • 用户购物篮中的物品

状态不包含任何函数、设置器或任何获取器。它是一个简单的 JavaScript 对象。存储还协调 Redux 中的所有移动部分。这包括通过 reducers 推送动作来更新状态。

因此,要更新存储中的状态,首先需要分派一个action。动作是另一个简单的 JavaScript 对象,如下所示:

{
  type: "PRODUCTS/LOADING"
}

type属性确定需要执行的操作类型。这是操作的一个重要且必需的部分。如果操作对象中没有type,reducer 将不知道如何更改状态。在前面的示例中,操作除了type属性之外没有包含任何其他内容。这是因为 reducer 不需要其他信息来为此类型的操作更改状态。

以下示例是另一个操作:

{
  type: "PRODUCTS/GETSINGLE",
  product: { id: 1, name: "Table", ...}
}

这次,在操作中包含了一个product属性的额外信息。reducer 需要这些额外信息来为此类型的操作更改状态。

因此,reducer 是实际更改状态的纯函数。

纯函数对于给定的一组参数总是返回相同的结果。因此,这些函数不依赖于函数范围之外的任何状态,而这些状态没有传递到函数中。纯函数也不会改变函数范围之外的任何状态。

以下是 reducer 的一个示例:

export const productsReducer = (state = initialProductState, action) => {
  switch (action.type) {
    case "PRODUCTS/LOADING": {
      return {
        ...state,
        productsLoading: true
      };
    }
    case "PRODUCTS/GETSINGLE": {
      return {
        ...state,
        currentProduct: action.product,
        productsLoading: false
      };
    }
    default:
  }
  return state || initialProductState;
};

以下是关于 reducer 的一些内容:

  • reducer 接受当前状态和正在执行的操作这两个参数。

  • 当首次调用 reducer 时,状态参数默认为初始状态对象

  • 在操作类型上使用 switch 语句,并为其每个分支创建一个新的状态对象

  • 为了创建新状态,我们将当前状态扩展到一个新对象中,然后用已更改的属性覆盖它

  • 新状态是从 reducer 返回的

您会注意到我们刚刚看到的操作和 reducer 没有 TypeScript 类型。显然,在我们在接下来的章节中实现这些时,我们将包含必要的类型。

因此,现在我们已经开始了解 Redux 是什么,是时候在我们的 React 商店中实践这一点了。

安装 Redux

在我们可以使用 Redux 之前,我们需要安装它以及 TypeScript 类型。我们还将安装一个名为redux-thunk的额外库,这是为了实现异步操作而需要的:

  1. 如果我们还没有的话,让我们从上一章结束的地方在 Visual Studio Code 中打开我们的 React 商店项目。因此,让我们在终端中通过npm安装核心 Redux 库:
npm install redux

请注意,核心 Redux 库中包含了 TypeScript 类型。因此,不需要额外安装这些类型。

  1. 让我们现在安装 Redux 的 React 特定部分。这些部分允许我们将 React 组件连接到 Redux 存储。让我们通过npm安装这些部分:
npm install react-redux
  1. 让我们也安装react-redux的 TypeScript 类型:
npm install --save-dev @types/react-redux
  1. 让我们也安装redux-thunk
npm install redux-thunk
  1. 最后,我们可以安装redux-thunk的 TypeScript 类型:
npm install --save-dev @types/redux-thunk

现在所有 Redux 部分都已安装,我们可以在下一节中将 Redux 添加到我们一直在开发的 React 商店中。

创建操作

我们将扩展我们在之前章节中构建的 React 商店,并添加 Redux 来管理Products页面上的状态。在本节中,我们将创建操作来开始将产品添加到页面的过程。将有一个操作来获取产品。将有另一个操作来改变一些新的加载状态,最终我们将把它与我们项目中已经拥有的withLoading HOC 联系起来。

在我们开始编写 Redux 操作之前,让我们在ProductsData.ts中创建一个虚拟 API 来获取产品:

export const getProducts = async (): Promise<IProduct[]> => {
  await wait(1000);
  return products;
};

因此,该函数在返回产品之前会异步等待一秒钟。

我们需要通过创建一些类型来开始实现我们的操作。我们将在下一步完成这个步骤。

创建状态和操作类型

现在是时候开始使用 Redux 来增强我们的 React 商店了。我们将首先为 Redux 存储创建一些状态和操作类型:

  1. 让我们在src文件夹中创建一个名为ProductsTypes.ts的新文件,并在顶部添加以下导入语句:
import { IProduct } from "./ProductsData";
  1. 让我们为我们将要实现的两种不同操作类型创建一个枚举:
export enum ProductsActionTypes {
  GETALL = "PRODUCTS/GETALL",
  LOADING = "PRODUCTS/LOADING"
}

Redux 不规定操作类型字符串的格式。因此,操作类型字符串的格式是我们的选择。但是,我们需要确保这些字符串在存储中的操作类型中是唯一的。因此,我们在字符串中包含了两个信息:

  • 操作涉及的存储区域。在我们的情况下,这是PRODUCTS

  • 该区域内的特定操作。在我们的情况下,我们有GETALL用于获取所有产品,LOADING用于指示产品正在被获取。

我们可以选择PRODUCTS-GETALLGet All Products。我们只需要确保字符串是唯一的。我们使用枚举来在实现操作和减速器时给我们良好的 IntelliSense。

  1. 现在我们可以为这两个操作创建接口:
export interface IProductsGetAllAction {
  type: ProductsActionTypes.GETALL,
  products: IProduct[]
}

export interface IProductsLoadingAction {
  type: ProductsActionTypes.LOADING
}

IProductsGetAllAction接口用于在需要获取产品时分派的动作。IProductsLoadingAction接口用于导致减速器改变加载状态的动作。

  1. 让我们将动作类型与联合类型结合在一起:
export type ProductsActions =
  | IProductsGetAllAction
  | IProductsLoadingAction

这将是传递给减速器的动作参数的类型。

  1. 最后,让我们在存储中为这个状态区域创建一个接口:
export interface IProductsState {
  readonly products: IProduct[];
  readonly productsLoading: boolean;
}

因此,我们的状态将包含一个产品数组,以及产品是否正在加载。

请注意,属性前缀带有readonly关键字。这将帮助我们避免直接更改状态。

现在我们已经为动作和状态准备好了类型,我们可以在下一节中创建一些动作。

创建动作

在这一节中,我们将创建两个动作,用于获取产品并指示产品正在加载。

  1. 让我们从创建一个带有以下导入语句的ProductsActions.ts文件开始:
import { ActionCreator, AnyAction, Dispatch } from "redux";

这些是我们在实现动作时将要使用的 Redux 中的一些类型。

  1. 我们的动作之一将是异步的。因此,让我们从redux-thunk中导入一个类型,以便在实现此动作时准备好:
import { ThunkAction } from "redux-thunk";
  1. 让我们添加另一个导入语句,以便我们可以使用我们的虚假 API:
import { getProducts as getProductsFromAPI } from "./ProductsData";

我们将 API 函数重命名为getProductsFromAPI,以避免名称冲突,因为我们将稍后创建一个名为getProducts的动作。

  1. 让我们还导入上一节中创建的类型:
import { IProductsGetAllAction, IProductsLoadingAction, IProductsState, ProductsActionTypes } from "./ProductsTypes";
  1. 我们现在要创建一个称为动作创建者的东西。动作创建者就像它的名字一样:它是一个创建并返回动作的函数!让我们为创建产品加载动作创建一个动作创建者:
const loading: ActionCreator<IProductsLoadingAction> = () => {
  return {
    type: ProductsActionTypes.LOADING
  }
};
  • 我们使用包含适当动作接口的泛型ActionCreator类型来定义函数签名

  • 该函数简单地返回所需的动作对象

我们可以使用隐式返回语句更简洁地编写这个函数,如下所示:

const loading: ActionCreator<IProductsLoadingAction> = () => ({
  type: ProductsActionTypes.LOADING
});

在实现动作创建者时,我们将使用这种更短的语法。

  1. 让我们继续实现获取产品的动作创建者。这更复杂,所以让我们从函数签名开始:
export const getProducts: ActionCreator<ThunkAction<Promise<AnyAction>, IProductsState, null, IProductsGetAllAction>> = () => {};

我们再次使用泛型ActionCreator类型,但这次它包含的不仅仅是最终返回的动作接口。这是因为这个特定的动作是异步的。

我们在ActionCreator中使用ThunkAction进行异步操作,这是一个具有四个参数的泛型类型:

  • 第一个参数是返回类型,理想情况下应该是Promise<IProductsGetAllAction>。但是,TypeScript 编译器很难解析这一点,因此我们选择了稍微宽松一些的Promise<AnyAction>类型。

  • 第二个参数是动作所关注的状态接口。

  • 第三个参数是传递给动作创建者的参数类型,在我们的情况下是null,因为没有参数。

  • 最后一个参数是动作的类型。

我们导出此动作创建者,因为最终将从ProductsPage组件中调用它。

  1. 异步动作需要返回一个最终会分派我们的动作的异步函数:
export const getProducts: ActionCreator<ThunkAction<Promise<AnyAction>, IProductsState, null, IProductsGetAllAction>> = () => {
  return async (dispatch: Dispatch) => {

 };
};

因此,该函数的第一件事是返回另一个函数,使用async关键字标记为异步。内部函数将调度程序从存储中作为参数。

  1. 让我们实现内部函数:
return async (dispatch: Dispatch) => {
  dispatch(loading());
 const products = await getProductsFromAPI();
 return dispatch({
 products,
 type: ProductsActionTypes.GETALL
 });
};
  • 我们首先要做的是分派另一个动作,以便加载状态最终由 reducer 相应地更改

  • 下一步是从虚拟 API 异步获取产品

  • 最后一步是分派所需的动作

现在我们已经创建了一些动作,我们将在下一节中创建一个 reducer。

创建 reducer

Reducer 是一个负责为给定动作创建新状态的函数。因此,该函数接受当前状态的动作,并返回新状态。在本节中,我们将为产品创建两个动作的 reducer。

  1. 让我们从创建一个名为ProductsReducer.ts的文件开始,其中包含以下导入语句:
import { Reducer } from "redux";
import { IProductsState, ProductsActions, ProductsActionTypes } from "./ProductsTypes";

我们从 Redux 中导入Reducer类型以及我们之前创建的动作和状态的类型。

  1. 接下来,我们需要定义初始状态是什么:
const initialProductState: IProductsState = {
  products: [],
  productsLoading: false
};

因此,我们将产品设置为空数组,并将产品加载状态设置为false

  1. 我们现在可以开始创建 reducer 函数:
export const productsReducer: Reducer<IProductsState, ProductsActions> = (
  state = initialProductState,
  action
) => {
 switch (action.type) {
  // TODO - change the state
 }
 return state;
};
  • 我们使用 Redux 的Reducer泛型类型对函数进行了类型化,传入了我们的状态和动作类型。这为我们提供了很好的类型安全性。

  • 该函数根据 Redux 所需的状态和动作参数。

  • 状态默认为我们在上一步中设置的初始状态对象。

  • 在函数的最后,如果动作类型在 switch 语句中没有被识别,我们将返回默认状态。

  1. 让我们继续实现我们的 reducer:
switch (action.type) {
  case ProductsActionTypes.LOADING: {
 return {
 ...state,
 productsLoading: true
 };
 }
 case ProductsActionTypes.GETALL: {
 return {
 ...state,
 products: action.products,
 productsLoading: false
 };
 }
}

我们为每个 action 实现了一个 switch 分支。两个分支都遵循相同的模式,通过返回一个新的状态对象,其中包含旧状态并合并了适当的属性。

这是我们的第一个 reducer 完成。在下一节中,我们将创建我们的 store。

创建一个 store

在本节中,我们将创建一个 store,用于保存我们的状态并管理动作和 reducer:

  1. 让我们首先创建一个名为Store.tsx的新文件,并使用以下导入语句从 Redux 中获取我们需要的部分:
import { applyMiddleware, combineReducers, createStore, Store } from "redux";
  • createStore是一个我们最终将用来创建我们的 store 的函数

  • 我们需要applyMiddleware函数,因为我们需要使用 Redux Thunk 中间件来管理我们的异步动作

  • combineReducers函数是一个我们可以用来合并我们的 reducers 的函数

  • Store是一个我们可以用于 store 的 TypeScript 类型

  1. 让我们导入redux-thunk
import thunk from "redux-thunk";
  1. 最后,让我们导入我们的 reducer 和状态类型:
import { productsReducer } from "./ProductsReducer";
import { IProductsState } from "./ProductsTypes";
  1. store 的一个关键部分是状态。因此,让我们为此定义一个接口:
export interface IApplicationState {
  products: IProductsState;
}

此时,接口只包含了我们的产品状态。

  1. 现在让我们把我们的 reducer 放到 Redux 的combineReducer函数中:
const rootReducer = combineReducers<IApplicationState>({
  products: productsReducer
});
  1. 有了状态和根 reducer 定义,我们可以创建我们的 store。实际上,我们要创建一个创建 store 的函数:
export default function configureStore(): Store<IApplicationState> {
  const store = createStore(rootReducer, undefined, applyMiddleware(thunk));
  return store;
}
  • 创建我们的 store 的函数被称为configureStore,并返回具有特定 store 状态的通用Store类型。

  • 该函数使用 Redux 的createStore函数来创建并返回 store。我们传入我们的 reducer 以及 Redux Thunk 中间件。我们将undefined作为初始状态,因为我们的 reducer 会处理初始状态。

我们已经在我们的 store 上取得了很好的进展。在下一节中,我们将开始连接我们的 React 商店到我们的 store。

将我们的 React 应用连接到 store。

在本节中,我们将连接Products页面到我们的 store。第一步是添加 React Redux 的Provider组件,我们将在下一节中完成。

添加 store Provider 组件

Provider组件可以将 store 传递给其下的任何级别的组件。因此,在本节中,我们将在组件层次结构的顶部添加Provider,以便所有我们的组件都可以访问它:

  1. 让我们打开我们现有的index.tsx并从 React Redux 中导入Provider组件:
import { Provider} from "react-redux";
  1. 让我们还从 React Redux 中导入Store类型:
import { Store } from "redux";
  1. 我们需要从我们的商店中导入以下内容:
import configureStore from "./Store";
import { IApplicationState } from "./Store";
  1. 然后我们将在导入语句之后创建一个小的函数组件:
interface IProps {
  store: Store<IApplicationState>;
}
const Root: React.SFC<IProps> = props => {
  return ();
};

这个Root组件将成为我们的新根元素。它将我们的商店作为一个 prop。

  1. 因此,我们需要在我们的新根组件中包含旧的根元素Routes
const Root: React.SFC<IProps> = props => {
  return (
    <Routes />
  );
};
  1. 这个组件还需要添加另一件事,那就是来自 React Redux 的Provider组件:
return (
  <Provider store={props.store}>
    <Routes />
  </Provider>
);

我们已经将Provider放在了组件树的顶部,并将我们的商店传递给它。

  1. 完成我们的新根组件后,让我们更改我们的根渲染函数:
const store = configureStore();
ReactDOM.render(<Root store={store} />, document.getElementById(
  "root"
) as HTMLElement);

我们首先使用我们的configureStore函数创建商店,然后将其传递给我们的Root组件。

因此,这是将我们的组件连接到商店的第一步。在下一节中,我们将完成对ProductPage组件的连接。

将组件连接到商店

我们即将看到我们增强的商店在行动中。在本节中,我们将连接我们的商店到几个组件。

将 ProductsPage 连接到商店

我们要连接到商店的第一个组件将是ProductsPage组件。

让我们打开ProductsPage.tsx并开始重构它:

  1. 首先,让我们从 React Redux 中导入connect函数:
import { connect } from "react-redux";

我们将在本节末尾使用connect函数将ProductsPage组件连接到商店。

  1. 让我们从我们的商店中导入存储状态类型和getProducts动作创建者:
import { IApplicationState } from "./Store";
import { getProducts } from "./ProductsActions";
  1. ProductPage组件现在不会包含任何状态,因为这将保存在 Redux 存储中。因此,让我们首先删除状态接口、静态getDerivedStateFromProps方法,以及构造函数。ProductsPage组件现在应该具有以下形状:
class ProductsPage extends React.Component<RouteComponentProps> {
  public async componentDidMount() { ... }
  public render() { ... }
}
  1. 现在,数据将通过 props 从商店中获取。因此,让我们重构我们的 props 接口:
interface IProps extends RouteComponentProps {
 getProducts: typeof getProducts;
 loading: boolean;
 products: IProduct[];
}

class ProductsPage extends React.Component<IProps> { ... }

因此,我们将从商店传递以下数据到我们的组件:

  • getProducts动作创建者

  • 一个名为loading的标志,指示产品是否正在被获取

  • 产品数组

  1. 因此,让我们调整componentDidMount生命周期方法,以调用getProducts动作创建者来开始获取产品的过程:
public componentDidMount() {
  this.props.getProducts();
}
  1. 我们不再直接引用ProductsData.ts中的products数组。因此,让我们从输入语句中删除它,使其如下所示:
import { IProduct } from "./ProductsData";
  1. 我们仍然看不到我们以前使用的search状态。现在我们将在render方法开始时获取它,而不是在状态中存储它:
public render() {
  const searchParams = new URLSearchParams(this.props.location.search);
 const search = searchParams.get("search") || "";
  return ( ... );
}
  1. 让我们留在render方法中,替换旧的state引用:
<ul className="product-list">
  {this.props.products.map(product => {
    if (!search || (search && product.name.toLowerCase().indexOf(search.toLowerCase()) > -1)
    ) { ... }
  })}
</ul>
  1. 在类下面,但在导出语句之前,让我们创建一个函数,将来自存储的状态映射到组件属性:
const mapStateToProps = (store: IApplicationState) => {
  return {
    loading: store.products.productsLoading,
    products: store.products.products
  };
};

因此,我们正在获取产品是否正在加载以及从存储中获取这些产品并将它们传递给我们的 props。

  1. 我们还需要映射到另一个 prop,那就是getProducts函数 prop。让我们创建另一个函数,将这个操作从存储映射到组件中的函数 prop:
const mapDispatchToProps = (dispatch: any) => {
  return {
    getProducts: () => dispatch(getProducts())
  };
};
  1. 在文件底部还有一项工作要做。这是在导出之前,将 React Redux 的connect HOC 包装在我们的ProductsPage组件周围:
export default connect(
 mapStateToProps,
 mapDispatchToProps
)(ProductsPage);

connect HOC 将组件连接到我们的存储,这是由组件树中更高级别的Provider组件提供给我们的。connect HOC 还调用映射函数,将存储中的状态和操作创建者映射到组件属性中。

  1. 现在终于是时候尝试我们增强的页面了。让我们通过终端启动开发服务器和应用程序:
npm start

我们应该发现页面的行为与以前完全相同。唯一的区别是现在状态是在我们的 Redux 存储中管理的。

在下一节中,我们将通过添加我们项目中已经拥有的加载旋转器来增强我们的产品页面。

将 ProductsPage 连接到加载存储状态。

在本节中,我们将向产品页面添加一个加载旋转器。在此之前,我们将把产品列表提取到自己的组件中。然后我们可以将withLoader HOC 添加到提取的组件中:

  1. 让我们为提取的组件创建一个名为ProductsList.tsx的新文件,并导入以下内容:
import * as React from "react";
import { Link } from "react-router-dom";
import { IProduct } from "./ProductsData";
import withLoader from "./withLoader";
  1. 该组件将接受产品数组和搜索字符串的 props:
interface IProps {
  products?: IProduct[];
  search: string;
}
  1. 我们将称该组件为ProductList,它将是一个 SFC。让我们开始创建组件:
const ProductsList: React.SFC<IProps> = props => {
  const search = props.search;
  return ();
};
  1. 现在我们可以将ProductsPage组件 JSX 中的ul标签移动到我们新的ProductList组件的返回语句中:
return (
  <ul className="product-list">
    {props.products &&
      props.products.map(product => {
        if (
          !search ||
          (search &&
            product.name.toLowerCase().indexOf(search.toLowerCase()) 
            > -1)
        ) {
          return (
            <li key={product.id} className="product-list-item">
              <Link to={`/products/${product.id}`}>{product.name}
              </Link>
            </li>
          );
        } else {
          return null;
        }
      })}
  </ul>
);

请注意,在移动 JSX 后,我们会删除对this的引用。

  1. 完成ProductList组件后,让我们将其导出并使用我们的withLoaderHOC 包装:
export default withLoader(ProductsList);
  1. 让我们更改ProductPage.tsx中的返回语句以引用提取的组件:
return (
  <div className="page-container">
    <p>
      Welcome to React Shop where you can get all your tools for ReactJS!
    </p>
    <ProductsList
 search={search}
 products={this.props.products}
 loading={this.props.loading}
 />
  </div>
);
  1. 我们不要忘记引入已引用的ProductsList组件:
import ProductsList from "./ProductsList";
  1. 最后,我们可以在ProductsPage.tsx中删除导入的Link组件,因为它不再被引用。

如果我们转到正在运行的应用程序并浏览到产品页面,我们现在应该看到产品加载时的加载旋转器:

因此,我们的产品页面现在已经很好地连接到了 Redux 存储。在下一节中,我们将把产品页面连接到存储。

将产品状态和操作添加到存储

ProductPage组件连接到我们的存储首先需要在我们的存储中进行一些工作。我们需要额外的状态来存储当前产品,以及它是否已添加到购物篮中。我们还需要额外的操作和减速器代码来获取产品并将其添加到购物篮中:

  1. 首先,在ProductsTypes.ts中为当前产品添加额外的状态:
export interface IProductsState {
  readonly currentProduct: IProduct | null;
  ...
}
  1. 当我们在ProductTypes.ts中时,让我们添加获取产品的操作类型:
export enum ProductsActionTypes {
  GETALL = "PRODUCTS/GETALL",
  GETSINGLE = "PRODUCTS/GETSINGLE",
  LOADING = "PRODUCTS/LOADING"
}
  1. 让我们还为获取产品添加操作类型:
export interface IProductsGetSingleAction {
  type: ProductsActionTypes.GETSINGLE;
  product: IProduct;
}
  1. 然后,我们可以将此操作类型添加到我们的联合操作类型中:
export type ProductsActions = IProductsGetAllAction| IProductsGetSingleAction | IProductsLoadingAction;
  1. 让我们继续在ProductsActions.ts中创建新的操作创建者。首先,让我们导入我们的虚假 API 以获取产品:
import { getProduct as getProductFromAPI, getProducts as getProductsFromAPI} from "./ProductsData";
  1. 然后我们可以导入我们需要实现的操作创建者的类型:
import { IProductsGetAllAction, IProductsGetSingleAction, IProductsLoadingAction, IProductsState, ProductsActionTypes } from "./productsTypes";
  1. 让我们实现获取产品的操作创建者:
export const getProduct: ActionCreator<ThunkAction<Promise<any>, IProductsState, null, IProductsGetSingleAction>> = (id: number) => {
  return async (dispatch: Dispatch) => {
    dispatch(loading());
    const product = await getProductFromAPI(id);
    dispatch({
      product,
      type: ProductsActionTypes.GETSINGLE
    });
  };
};

这与getProducts操作创建者非常相似。结构上唯一的区别是操作创建者接受产品 ID 的参数。

  1. 现在转到ProductsReducer.ts中的减速器。首先在初始状态中将当前产品设置为 null:
const initialProductState: IProductsState = {
  currentProduct: null,
  ...
};
  1. productReducer函数中,让我们为我们的新操作类型在 switch 语句中添加一个分支:
switch (action.type) {
  ...
  case ProductsActionTypes.GETSINGLE: {
 return {
 ...state,
 currentProduct: action.product,
 productsLoading: false
 };
 }
}

我们将旧状态扩展到一个新对象中,覆盖当前项目,并将加载状态设置为false

因此,这是产品页面在 Redux 存储中需要的一些状态管理。但是,我们还没有在我们的存储中管理购物篮。我们将在下一节中完成这一点。

将购物篮状态和操作添加到存储中

在这一部分,我们将为我们的购物篮添加状态管理。我们将在我们的存储中创建一个新的部分。

  1. 首先,让我们创建一个名为BasketTypes.ts的类型的新文件,内容如下:
import { IProduct } from "./ProductsData";

export enum BasketActionTypes {
  ADD = "BASKET/ADD"
}

export interface IBasketState {
  readonly products: IProduct[];
}

export interface IBasketAdd {
  type: BasketActionTypes.ADD;
  product: IProduct;
}

export type BasketActions = IBasketAdd;
  • 我们的购物篮中只有一个状态,那就是购物篮中产品的数组。

  • 同样也只有一个动作。这是将产品添加到购物篮中。

  1. 让我们创建一个名为BasketActions.ts的文件,内容如下:
import { BasketActionTypes, IBasketAdd } from "./BasketTypes";
import { IProduct } from "./ProductsData";

export const addToBasket = (product: IProduct): IBasketAdd => ({
  product,
  type: BasketActionTypes.ADD
});

这是用于添加到购物篮的动作创建者。该函数接受一个产品,并在具有适当动作类型的动作中返回它。

  1. 现在到了减速器。让我们创建一个名为BasketReducer.ts的文件,其中包含以下导入语句:
import { Reducer } from "redux";
import { BasketActions, BasketActionTypes, IBasketState } from "./BasketTypes";
  1. 让我们为初始购物篮状态创建一个对象:
const initialBasketState: IBasketState = {
  products: []
};
  1. 现在让我们创建减速器:
export const basketReducer: Reducer<IBasketState, BasketActions> = (state = initialBasketState, action) => {
  switch (action.type) {
    case BasketActionTypes.ADD: {
      return {
        ...state,
        products: state.products.concat(action.product)
      };
    }
  }
  return state || initialBasketState;
};

这遵循与productsReducer相同的模式。

一个有趣的地方要注意的是,我们如何优雅地将product添加到products数组中,而不会改变原始数组。我们使用 JavaScript 的concat函数,它通过将原始数组与传入的参数合并来创建一个新数组。这是在减速器中使用的一个很好的函数,其中状态变化涉及向数组添加项目。

  1. 现在让我们打开Store.ts并导入购物篮的新减速器和状态:
import { basketReducer } from "./BasketReducer";
import { IBasketState } from "./BasketTypes";
  1. 让我们将购物篮状态添加到存储中:
export interface IApplicationState {
 basket: IBasketState;
  products: IProductsState;
}
  1. 现在我们有两个减速器。因此,让我们将购物篮减速器添加到combineReducers函数调用中:
export const rootReducer = combineReducers<IApplicationState>({
  basket: basketReducer,
  products: productsReducer
});

现在我们已经调整了我们的存储,我们可以将我们的ProductPage组件连接到它。

将 ProductPage 连接到存储

在这一部分,我们将把ProductPage组件连接到我们的存储中:

  1. 首先将以下内容导入到ProductPage.tsx中:
import { connect } from "react-redux";
import { addToBasket } from "./BasketActions";
import { getProduct } from "./ProductsActions";
import { IApplicationState } from "./Store";
  1. 现在我们要引用存储的getProduct,而不是来自ProductsData.ts的产品。因此,让我们从此导入中删除它,使其看起来像以下内容:
import { IProduct } from "./ProductsData";
  1. 接下来,让我们将状态移入属性:
interface IProps extends RouteComponentProps<{ id: string }> {
  addToBasket: typeof addToBasket;
  getProduct: typeof getProduct;
  loading: boolean;
  product?: IProduct;
  added: boolean;
}

class ProductPage extends React.Component<IProps> { ... }

因此,在此移动之后,应该删除IState接口和Props类型。

  1. 我们可以移除构造函数,因为我们现在不需要初始化任何状态。这一切都在存储中完成。

  2. 让我们将componentDidMount生命周期方法更改为调用获取产品的动作创建者:

public componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    this.props.getProduct(id);
  }
}

请注意,我们还移除了async关键字,因为该方法不再是异步的。

  1. 继续进行render函数,让我们将对状态的引用替换为对属性的引用:
public render() {
  const product = this.props.product;
  return (
    <div className="page-container">
      <Prompt when={!this.props.added} message={this.navAwayMessage}
      />
      {product || this.props.loading ? (
        <Product
          loading={this.props.loading}
          product={product}
          inBasket={this.props.added}
          onAddToBasket={this.handleAddClick}
        />
      ) : (
        <p>Product not found!</p>
      )}
    </div>
  );
}
  1. 现在让我们来看点击处理程序,并重构它以调用添加到购物篮的动作创建者:
private handleAddClick = () => {
  if (this.props.product) {
    this.props.addToBasket(this.props.product);
  }
};
  1. 现在进行连接过程的最后几个步骤。让我们实现将存储中的动作创建者映射到组件属性的函数:
const mapDispatchToProps = (dispatch: any) => {
  return {
    addToBasket: (product: IProduct) => dispatch(addToBasket(product)),
    getProduct: (id: number) => dispatch(getProduct(id))
  };
};
  1. 将状态映射到组件 prop 有点复杂。让我们从简单的映射开始:
const mapStateToProps = (store: IApplicationState) => {
  return {
    basketProducts: store.basket.products,
    loading: store.products.productsLoading,
    product: store.products.currentProduct || undefined
  };
};

请注意,我们将 null 的currentProduct映射到undefined

  1. 我们需要映射的剩余 prop 是added。我们需要检查商店中的当前产品是否在购物篮状态中,以设置这个boolean值。我们可以使用产品数组中的some函数来实现这一点:
const mapStateToProps = (store: IApplicationState) => {
  return {
    added: store.basket.products.some(p => store.products.currentProduct ? p.id === store.products.currentProduct.id : false),
    ...
  };
};
  1. 最后一步是使用 React Redux 中的connect HOC 将ProductPage组件连接到商店:
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(ProductPage);

现在我们可以进入运行的应用程序,访问产品页面,并将其添加到购物篮中。点击“添加到购物篮”按钮后,该按钮应该消失。如果我们浏览到另一个产品,然后回到我们已经添加到购物篮中的产品,那么“添加到购物篮”按钮就不应该出现。

所以,现在我们的产品和产品页面都连接到了 Redux 商店。在下一节中,我们将创建一个购物篮摘要组件并将其连接到商店。

创建并连接 BasketSummary 到商店

在本节中,我们将创建一个名为BasketSummary的新组件。这将显示购物篮中的物品数量,并位于我们商店的右上角。以下截图显示了购物篮摘要将在屏幕右上角的样子:

  1. 让我们创建一个名为BasketSummary.tsx的文件,内容如下:
import * as React from "react";

interface IProps {
  count: number;
}

const BasketSummary: React.SFC<IProps> = props => {
  return <div className="basket-summary">{props.count}</div>;
};

export default BasketSummary;

这是一个简单的组件,它以一个 prop 的形式接收购物篮中产品的数量,并在一个带有basket-summary CSS 类的div中显示这个值。

  1. 让我们在index.css中添加我们刚刚引用的 CSS 类:
.basket-summary {
  display: inline-block;
  margin-left: 10px;
  padding: 5px 10px;
  border: white solid 2px;
}
  1. 我们将把我们的购物篮摘要添加到我们的页眉组件中。所以,让我们把它导入到Header.tsx中:
import BasketSummary from "./BasketSummary";
  1. 让我们也从 React Redux 中导入connect函数:
import { connect } from "react-redux";
  1. 让我们也导入我们商店的状态类型:
import { IApplicationState } from "./Store";
  1. Header组件添加一个购物篮中产品数量的 prop:
interface IProps extends RouteComponentProps {
 basketCount: number;
}

class Header extends React.Component<IProps, IState> { 
   public constructor(props: IProps) { ... }
   ...
}

我们将在这个组件中保持搜索状态为本地。

  1. 现在让我们将BasketSummary组件添加到Header组件的 JSX 中:
<header className="header">
  <div className="search-container">
    <input ... />
    <BasketSummary count={this.props.basketCount} />
  </div>
  ...
</header>
  1. 下一步是将商店购物篮中的产品数量映射到basketCount prop:
const mapStateToProps = (store: IApplicationState) => {
  return {
    basketCount: store.basket.products.length
  };
};
  1. 最后,我们可以将Header组件连接到商店:
export default connect(mapStateToProps)(withRouter(Header));

现在Header组件正在使用BasketSummary组件,并且也连接到商店,我们应该能够在运行的应用程序中添加产品到购物篮并看到购物篮摘要增加。

这样,这一部分关于将组件连接到商店的内容就完成了。我们已经将一些不同的组件连接到了商店,所以希望这个过程现在很清晰。

在下一节中,我们将探讨一种类似 Redux 的方法来管理组件内的状态。

使用 useReducer 管理状态

Redux 非常适合管理应用程序中的复杂状态。但是,如果我们要管理的状态只存在于单个组件中,那么它可能会有点重。显然,我们可以使用setState(对于类组件)或useState(对于函数组件)来管理这些情况。但是,如果状态很复杂怎么办?可能会有很多状态片段,状态交互可能涉及很多步骤,其中一些是异步的。在本节中,我们将探讨使用 React 中的useReducer函数来管理这些情况的方法。我们的示例将是人为的和简单的,但它将让我们了解这种方法。

我们将在我们的 React 商店的产品页面上添加一个喜欢按钮。用户可以多次喜欢一个产品。Product组件将跟踪喜欢的数量以及最后一次喜欢的日期和时间:

  1. 我们将首先打开Product.tsx并在Product组件之前创建一个接口,用于我们的状态,包含喜欢的数量和最后一次喜欢的日期:
interface ILikeState {
  likes: number;
  lastLike: Date | null;
}
  1. 我们将创建一个变量来保存初始状态,也在Product之外:
const initialLikeState: ILikeState = {
  likes: 0,
  lastLike: null
};
  1. 现在让我们为这个动作创建一个类型:
enum LikeActionTypes {
  LIKE = "LIKE"
}

interface ILikeAction {
  type: LikeActionTypes.LIKE;
  now: Date;
}
  1. 我们还将创建一个包含所有动作类型的联合类型。在我们的示例中,我们只有一个动作类型,但让我们这样做以了解一个可扩展的方法:
type LikeActions = ILikeAction;
  1. Product组件内部,让我们在 React 中调用useReducer函数来获取我们的状态和dispatch函数:
const [state, dispatch]: [
    ILikeState,
    (action: ILikeAction) => void
  ] = React.useReducer(reducer, initialLikeState);

让我们来分解一下:

  • 我们传递给useReducer一个名为reducer的函数(我们还没有创建)。

  • 我们还将我们的初始状态传递给useReducer

  • useReducer返回一个包含两个元素的数组。第一个元素是当前状态,第二个是一个dispatch函数来调用一个动作。

  1. 让我们重构这一行并解构状态,以便我们可以直接引用状态的片段:
const [{ likes, lastLike }, dispatch]: [
    ILikeState,
    (action: ILikeAction) => void
  ] = React.useReducer(reducer, initialLikeState);
  1. Product组件的 JSX 底部,让我们添加 JSX 来渲染我们有多少个喜欢和一个按钮来添加喜欢:
{!props.inBasket && (
  <button onClick={handleAddClick}>Add to basket</button>
)}
<div className="like-container">
 {likes > 0 && (
 <div>{`I like this x ${likes}, last at ${lastLike}`}</div>
 )}
 <button onClick={handleLikeClick}>
 {likes > 0 ? "Like again" : "Like"}
 </button>
</div>
  1. 让我们将刚刚引用的like-container CSS 类添加到index.css中:
.like-container {
  margin-top: 20px;
}

.like-container button {
  margin-top: 5px;
}
  1. 让我们也在 Like 按钮上实现点击处理程序:
const handleLikeClick = () => {
  dispatch({ type: LikeActionTypes.LIKE, now: new Date() });
};
  1. 我们的最后任务是在Product组件之外实现 reducer 函数,在LikeActions类型的下面:
const reducer = (state: ILikeState = initialLikeState, action: LikeActions) => {
 switch (action.type) {
 case LikeActionTypes.LIKE:
 return { ...state, likes: state.likes + 1, lastLike: action.now };
 }
 return state;
};

如果我们尝试这样做,我们将在导航到产品页面后最初看到一个 Like 按钮。如果我们点击它,按钮文本会变成 Like,上面会出现一段文字,指示有多少个赞和上次点赞的时间。

这个实现感觉非常类似于在 Redux 存储中实现操作和 reducers,但这都是在一个组件内部。对于我们刚刚经历过的例子来说,这有点过度,但在我们需要管理更多状态片段的情况下可能会很有用。

总结

我们在本章开始时介绍了 Redux,学习了其原则和关键概念。我们了解到状态存储在一个单一对象中,并在分发 action 时通过称为 reducers 的纯函数进行更改。

我们在我们的 React 商店中创建了自己的 store 来将理论付诸实践。以下是我们在实现中学到的一些关键点:

  • 为 action 类型创建枚举在引用它们时给我们提供了很好的智能感知。

  • 使用接口定义 actions 可以提供很好的类型安全性,并允许我们创建一个 reducer 可以用于处理的 actions 的联合类型。

  • 在状态接口中使用只读属性可以帮助我们避免直接改变状态。

  • 同步 action 创建者只是简单地返回所需的 action 对象。

  • 异步 action 创建者返回一个最终返回 action 对象的函数。

  • Reducer 包含了它处理的每种 action 类型的逻辑分支,通过将旧状态扩展到一个新对象中,然后用更改后的属性覆盖它来创建新状态。

  • Redux 的createStore函数创建了实际的 store。我们将所有的 reducer 合并在一起,还有 Redux Thunk 中间件来管理异步操作。

然后我们将一些组件连接到了 store。以下是这个过程中的关键点:

  • 来自 React Redux 的Provider组件需要位于所有想要使用 store 的组件之上。它接收一个包含 store 的 prop。

  • 然后,来自 React Redux 的connect高阶组件将单独的组件连接到 store。它接收两个参数,可以用于将状态和 action 创建者映射到组件 props。

在我们的 React 应用程序中实现 Redux 时,有很多要理解的细节。因为 Redux 强制我们将逻辑分解成易于理解和维护的单独部分,所以在状态管理复杂的情况下,Redux 会发挥作用。

我们学到,我们可以利用 React 的useReducer函数在单个组件中使用类似 Redux 的方法。当状态复杂且仅存在于单个组件中时,可以使用这种方法。

Redux 动作经常要做的一个任务是与 REST API 交互。在下一章中,我们将学习如何在基于类和基于函数的组件中与 REST API 交互。我们还将了解一个我们用来调用 REST API 的本地函数,以及一个流行的开源库。

问题

在结束本章之前,让我们用一些问题来测试我们的知识:

  1. action 对象中的type属性是必需的吗?这个属性需要被称为 type 吗?我们可以称其为其他名称吗?

  2. action 对象可以包含多少个属性?

  3. 什么是 action creator?

  4. 为什么我们在 React 商店应用程序中的 Redux 存储中需要 Redux Thunk?

  5. 除了 Redux Thunk,我们还能用其他东西吗?

  6. 在我们刚刚实现的basketReducer中,为什么我们不直接使用push函数将项目添加到购物篮状态中?也就是说,高亮显示的行有什么问题?

export const basketReducer: Reducer<IBasketState, BasketActions> = (
  state = initialBasketState,
  action
) => {
  switch (action.type) {
    case BasketActionTypes.ADD: {
      state.products.push(action.product);
    }
  }
  return state || initialBasketState;
};

进一步阅读

以下链接是关于 React Redux 的更多信息的好资源:

第九章:与 RESTful API 交互

与 RESTful API 交互是构建应用程序时我们需要做的非常常见的任务,它总是导致我们必须编写异步代码。因此,在本章的开始,我们将详细了解一般的异步代码。

有许多库可以帮助我们与 REST API 交互。在本章中,我们将看看原生浏览器函数和一个流行的开源库来与 REST API 交互。我们将发现开源库相对于原生函数的额外功能。我们还将看看如何在 React 类和基于函数的组件中与 REST API 交互。

在本章中,我们将学习以下主题:

  • 编写异步代码

  • 使用 fetch

  • 使用 axios 与类组件

  • 使用 axios 与函数组件

技术要求

在本章中,我们使用以下技术:

  • TypeScript playground:这是一个网站,位于www.typescriptlang.org/play/,允许我们在不安装任何东西的情况下玩耍异步代码。

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。我们可以从nodejs.org/en/download/安装这些。如果我们已经安装了这些,请确保npm至少是 5.2 版本。

  • TypeScript:可以通过终端中的以下命令使用npm安装:

npm install -g typescript
  • Visual Studio Code。我们需要一个编辑器来编写我们的 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint (by egamma) 和 Prettier (by Estben Petersen) 扩展。

  • jsonplaceholder.typicode.com:我们将使用这个在线服务来帮助我们学习如何与 RESTful API 交互。

本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/09-RestfulAPIs.上找到

编写异步代码

TypeScript 代码默认是同步执行的,每行代码都会依次执行。然而,TypeScript 代码也可以是异步的,这意味着事情可以独立于我们的代码发生。调用 REST API 就是异步代码的一个例子,因为 API 请求是在我们的 TypeScript 代码之外处理的。因此,与 REST API 交互会迫使我们编写异步代码。

在本节中,我们将花时间了解在编写异步代码时可以采取的方法,然后再使用它们与 RESTful API 进行交互。我们将在下一节开始时看一下回调函数。

回调函数

回调是我们将作为参数传递给异步函数的函数,在异步函数完成时调用。在下一节中,我们将通过一个使用回调的异步代码示例进行说明。

回调执行

让我们在 TypeScript 播放器中通过一个使用回调的异步代码示例来进行说明。让我们输入以下代码:

let firstName: string;
setTimeout(() => {
  firstName = "Fred";
  console.log("firstName in callback", firstName);
}, 1000);
console.log("firstName after setTimeout", firstName); 

该代码调用了 JavaScript 的setTimeout函数,这是一个异步函数。它以回调作为第一个参数,并以执行应等待的毫秒数作为第二个参数。

我们使用箭头函数作为回调函数,在其中将firstName变量设置为"Fred"并将其输出到控制台。我们还在调用setTimeout后立即在控制台中记录firstName

那么,哪个console.log语句会首先执行呢?如果我们运行代码并查看控制台,我们会看到最后一行首先执行:

关键点在于,在调用setTimeout之后,执行会继续到下一行代码。执行不会等待回调被调用。这可能会使包含回调的代码比同步代码更难阅读,特别是当我们在回调中嵌套回调时。许多开发人员称之为回调地狱

那么,我们如何处理异步回调代码中的错误?我们将在下一节中找出答案。

处理回调错误

在本节中,我们将探讨在使用回调代码时如何处理错误:

  1. 让我们从在 TypeScript 播放器中输入以下代码开始:
try {
 setTimeout(() => {
 throw new Error("Something went wrong");
 }, 1000);
} catch (ex) {
 console.log("An error has occurred", ex); 
}

我们再次使用setTimeout来尝试回调。这次,在回调函数内抛出一个错误。我们希望使用try / catch来捕获回调外部的错误,围绕setTimeout函数。

如果我们运行代码,我们会发现我们没有捕获错误:

  1. 我们必须在回调函数内处理错误。因此,让我们将我们的示例调整为以下内容:
interface IResult {
  success: boolean;
  error?: any;
}
let result: IResult = { success: true };
setTimeout(() => {
  try {
    throw new Error("Something went wrong");
  } catch (ex) {
    result.success = false;
    result.error = ex;
  }
}, 1000);
console.log(result);

这次,try / catch在回调函数内。我们使用一个变量result来确定回调是否成功执行,以及任何错误。IResult接口为我们提供了对结果变量的良好类型安全性。

如果我们运行这段代码,我们将看到我们成功处理了错误:

因此,处理错误以及读取基于回调的代码是一个挑战。幸运的是,有替代方法来处理这些挑战,我们将在接下来的部分中介绍。

承诺

promise 是一个 JavaScript 对象,它代表异步操作的最终完成(或失败)及其结果值。接下来,我们将看一个消耗基于 promise 的函数的示例,然后创建我们自己的基于 promise 的函数。

消耗基于 promise 的函数

让我们快速看一下一些暴露了基于 promise 的 API 的代码:

fetch("https://jsonplaceholder.typicode.com/posts")
  .then(response => response.json()) 
  .then(data => console.log(data))
  .catch(json => console.log("error", json));
  • 这个函数是用于与 RESTful API 交互的本机 JavaScript fetch函数

  • 该函数接受一个用于请求的 URL。

  • 它有一个then方法来处理响应和读取响应主体

  • 它有一个catch方法来处理任何错误

代码执行流程与我们阅读的方式相同。我们还不必在then方法中做任何额外的工作来处理错误。因此,这比使用基于回调的异步代码要好得多。

在下一节中,我们将创建我们自己的基于 promise 的函数。

创建一个基于 promise 的函数

在本节中,我们将创建一个wait函数,以异步等待传递的毫秒数:

  1. 让我们在 TypeScript playground 中输入以下内容:
const wait = (ms: number) => {
  return new Promise((resolve, reject) => {
    if (ms > 1000) {
      reject("Too long");
    }
    setTimeout(() => {
      resolve("Sucessfully waited");
    }, ms);
  });
};
  • 该函数开始通过返回一个Promise对象,该对象将需要异步执行的函数作为其构造函数参数

  • promise函数接受一个resolve参数,这是一个在函数执行完成时调用的函数

  • promise 函数还接受一个reject参数,这是一个在函数出错时调用的函数

  • 在内部,我们使用带有回调的setTimeout来进行实际的等待

  1. 让我们消费我们基于 promise 的wait函数:
wait(500)
 .then(result => console.log("then >", result))
 .catch(error => console.log("catch >", error));

该函数只是在等待 500 毫秒后将结果或错误输出到控制台。

因此,让我们尝试运行它:

正如我们所看到的,控制台中的输出表明then方法被执行了。

  1. 如果我们用大于 1000 的参数调用wait函数,catch方法应该被调用。让我们试一试:
wait(1500)
 .then(result => console.log("then >", result))
 .catch(error => console.log("catch >", error));

如预期的那样,catch方法被执行:

因此,promise 给了我们一种很好的编写异步代码的方式。然而,在本书的早期我们已经使用了另一种方法。我们将在下一节中介绍这种方法。

异步和等待

asyncawait是两个 JavaScript 关键字,我们可以使用它们使异步代码的阅读几乎与同步代码相同:

  1. 让我们看一个例子,消费我们在上一节中创建的wait函数,将以下代码输入到 TypeScript playground 中,放在wait函数声明之后:
const someWork = async () => {
  try {
    const result = await wait(500); 
    console.log(result);
  } catch (ex) {
    console.log(ex);
  }
};

someWork();
  • 我们创建了一个名为someWork的箭头函数,并用async关键字标记为异步。

  • 然后我们调用带有await关键字前缀的wait。这会暂停下一行的执行,直到wait完成。

  • try / catch将捕获任何错误。

因此,代码非常类似于您在同步方式下编写的方式。

如果我们运行这个例子,我们会得到确认,try分支中的console.log语句等待wait函数完全完成后才执行:

  1. 让我们将等待时间改为1500毫秒:
const result = await wait(1500); 

如果我们运行这个,我们会看到一个错误被引发并捕获:

因此,asyncawait使我们的代码易于阅读。在 TypeScript 中使用这些的一个好处是,代码可以被转译以在旧版浏览器中运行。例如,我们可以使用asyncawait编码,同时支持 IE。

现在我们对编写异步代码有了很好的理解,我们将在接下来的章节中将其付诸实践,当我们与 RESTful API 交互时。

使用 fetch

fetch函数是一个原生的 JavaScript 函数,我们可以用它来与 RESTful API 交互。在本节中,我们将通过fetch进行一些常见的 RESTful API 交互,从获取数据开始。在本节中,我们将与出色的JSONPlaceholder REST API 进行交互。

使用 fetch 获取数据

在本节中,我们将使用fetchJSONPlaceholder REST API 获取一些帖子,从基本的GET请求开始。

基本的 GET 请求

让我们打开 TypeScript playground 并输入以下内容:

fetch("https://jsonplaceholder.typicode.com/posts")
  .then(response => response.json())
  .then(data => console.log(data));

以下是一些关键点:

  • fetch函数中的第一个参数是请求的 URL

  • fetch是一个基于承诺的函数

  • 第一个then方法处理响应

  • 第二个then方法处理当响应体已解析为 JSON 时

如果我们运行代码,应该会在控制台输出一个帖子数组:

获取响应状态

我们经常需要检查请求的状态。我们可以这样做:

fetch("https://jsonplaceholder.typicode.com/posts").then(response => {
  console.log(response.status, response.ok); 
});
  • 响应的status属性给出了响应的 HTTP 状态码

  • 响应的ok属性是一个boolean,返回 HTTP 状态码是否在 200 范围内

如果我们运行先前的代码,我们会在控制台得到 200 和 true 的输出。

让我们尝试一个帖子不存在的示例请求:

fetch("https://jsonplaceholder.typicode.com/posts/1001").then(response => {
  console.log(response.status, response.ok); 
});

如果我们运行上述代码,我们会在控制台得到 404 和 false 的输出。

处理错误

使用基于承诺的函数,我们在catch方法中处理错误:

fetch("https://jsonplaceholder.typicode.com/posts")
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(json => console.log("error", json));

然而,catch方法不会捕获不在 200 范围内的响应。在先前的示例中,我们得到了响应状态码为 404。因此,HTTP 错误状态码可以在第一个then方法中处理,而不是catch方法。

那么,catch方法是用来做什么的?答案是捕获网络错误。

这就是使用fetch获取数据的方法。在下一节中,我们将介绍发布数据。

使用 fetch 创建数据

在本节中,我们将使用fetch来使用JSONPlaceholder REST API 创建一些数据。

基本的 POST 请求

通过 REST API 创建数据通常涉及使用 HTTP POST方法,并将要创建的数据放在请求体中。

让我们打开 TypeScript playground 并输入以下内容:

fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  body: JSON.stringify({
    title: "Interesting post",
    body: "This is an interesting post about ...",
    userId: 1
  })
})
  .then(response => {
    console.log(response.status); 
    return response.json();
  })
  .then(data => console.log(data));

fetch调用与获取数据的方式基本相同。关键区别在于第二个参数,它是一个包含请求的方法和主体的选项对象。还要注意主体需要是一个string

如果我们运行上述代码,我们将在控制台中得到 201 和包含生成的帖子 ID 的对象。

请求 HTTP 标头

我们经常需要在请求中包含 HTTP 标头。我们可以在options对象中的headers属性中指定这些内容:


fetch("https://jsonplaceholder.typicode.com/posts", {
 method: "POST",
 headers: {
 "Content-Type": "application/json",
 Authorization: "bearer some-bearer-token"
 },
  body: JSON.stringify({
    title: "Interesting post",
    body: "This is an interesting post about ...",
    userId: 1
  })
})
  .then(response => {
    console.log(response.status); 
    return response.json();
  })
  .then(data => console.log(data));

请求标头可以用于任何 HTTP 方法,而不仅仅是 HTTP POST。例如,我们可以用于GET请求如下:

fetch("https://jsonplaceholder.typicode.com/posts/1", {
  headers: {
 "Content-Type": "application/json",
 Authorization: "bearer some-bearer-token"
 }
}).then(...);

因此,这就是如何使用fetch向 REST API 发布数据。在下一节中,我们将看看如何更改数据。

使用 fetch 更改数据

在本节中,我们将使用fetch通过 REST API 更改一些数据。

基本的 PUT 请求

通过PUT请求通常更改数据。让我们打开 TypeScript 播放器并输入以下内容:

fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    title: "Corrected post",
    body: "This is corrected post about ...",
    userId: 1
  })
})
  .then(response => {
    console.log(response.status);
    return response.json();
  })
  .then(data => console.log(data)); 

因此,进行 HTTP PUTfetch调用的结构与POST请求非常相似。唯一的区别是我们在选项对象中指定method属性为PUT

如果我们运行上述代码,我们将得到 200 和更新的POST对象输出到控制台。

基本的 PATCH 请求

一些 REST API 提供PATCH请求,允许我们提交对资源部分的更改。让我们打开 TypeScript 播放器并输入以下内容:

fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "PATCH",
  headers: {
    "Content-type": "application/json"
  },
  body: JSON.stringify({
    title: "Corrected post"
  })
})
 .then(response => {
    console.log(response.status); 
    return response.json();
  })
  .then(data => console.log(data));

因此,我们正在使用PATCH HTTP 方法提交对帖子标题的更改。如果我们运行上述代码,我们将得到 200 和更新的帖子对象输出到控制台。

因此,这就是如何使用fetch进行PUTPATCH。在下一节中,我们将删除一些数据。

使用 fetch 删除数据

通常,我们通过 REST API 上的DELETE HTTP 方法删除数据。在 TypeScript 播放器中输入以下内容:

fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "DELETE"
}).then(response => {
  console.log(response.status); 
});

因此,我们正在请求使用DELETE方法删除帖子。

如果我们运行上述代码,我们将在控制台中得到 200 的输出。

因此,我们已经学会了如何使用原生的fetch函数与 RESTful API 进行交互。在下一节中,我们将看看如何使用流行的开源库执行相同操作,并了解其相对于fetch的优势。

使用 axios 与类组件

axios是一个流行的开源 JavaScript HTTP 客户端。我们将构建一个小型的 React 应用程序,从JSONPlaceholder REST API 中创建、读取、更新和删除帖子。在此过程中,我们将发现axios相对于fetch的一些优点。在下一节中,我们的第一个任务是安装axios

安装 axios

在我们安装axios之前,我们将快速创建我们的小型 React 应用程序:

  1. 在我们选择的文件夹中,让我们打开 Visual Studio Code 和它的终端,并输入以下命令来创建一个新的 React 和 TypeScript 项目:
npx create-react-app crud-api --typescript

请注意,我们使用的 React 版本至少需要是16.7.0-alpha.0版本。我们可以在package.json文件中检查这一点。如果package.json中的 React 版本旧于16.7.0-alpha.0,那么我们可以使用以下命令安装这个版本:

npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
  1. 项目创建后,让我们将 TSLint 作为开发依赖项添加到我们的项目中,以及一些与 React 和 Prettier 配合良好的规则:
cd crud-api
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在让我们添加一个包含一些规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "jsx-no-lambda": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}
  1. 如果我们打开App.tsx,会有一个 linting 错误。所以,让我们通过在render方法上添加public修饰符来解决这个问题:
class App extends Component {
  public render() {
    return ( ... );
  }
}
  1. 现在我们可以使用 NPM 安装axios
npm install axios

请注意,axios中包含 TypeScript 类型,因此我们不需要安装它们。

  1. 在继续开发之前,让我们先运行我们的应用程序:
npm start

应用程序将在浏览器中启动并运行。在下一节中,我们将使用 axios 从 JSONPlaceholder 获取帖子。

使用 axios 获取数据

在本节中,我们将在App组件中呈现来自JSONPlaceholder的帖子。

基本的 GET 请求

我们将从axios开始,使用基本的 GET 请求获取帖子,然后在无序列表中呈现它们:

  1. 让我们打开App.tsx并为axios添加一个导入语句:
import axios from "axios";
  1. 让我们还为从 JSONPlaceholder 获取的帖子创建一个接口:
interface IPost {
  userId: number;
  id?: number;
  title: string;
  body: string;
}
  1. 我们将把帖子存储在状态中,所以让我们为此添加一个接口:
interface IState {
 posts: IPost[];
}
class App extends React.Component<{}, IState> { ... }
  1. 然后在构造函数中将帖子状态初始化为空数组:
class App extends React.Component<{}, IState> {
  public constructor(props: {}) {
 super(props);
 this.state = {
 posts: []
 };
 }
}
  1. 从 REST API 获取数据时,通常会在componentDidMount生命周期方法中进行。所以,让我们使用axios来获取我们的帖子:
public componentDidMount() {
  axios
    .get<IPost[]>("https://jsonplaceholder.typicode.com/posts")
    .then(response => {
      this.setState({ posts: response.data });
    });
}
  • 我们使用axios中的get函数来获取数据,这是一个类似于fetch的基于 Promise 的函数

  • 这是一个通用函数,它接受响应主体类型作为参数

  • 我们将我们请求的 URL 作为参数传递给get函数

  • 然后我们可以在then方法中处理响应

  • 我们通过响应对象中的data属性获得对响应主体的访问权限,该对象是根据通用参数进行了类型化。

因此,这比fetch更好的两种方式:

  • 我们可以轻松输入响应

  • 有一步(而不是两步)来获取响应主体

  1. 既然我们已经在组件状态中有了帖子,让我们在render方法中呈现帖子。让我们还删除header标签:
public render() {
  return (
    <div className="App">
      <ul className="posts">
 {this.state.posts.map(post => (
 <li key={post.id}>
 <h3>{post.title}</h3>
 <p>{post.body}</p>
 </li>
 ))}
 </ul>
    </div>
  );
}

我们使用posts数组的map函数来显示帖子的无序列表。

  1. 我们引用了一个posts CSS 类,因此让我们将其添加到index.css中:
.posts {
  list-style: none;
  margin: 0px auto;
  width: 800px;
  text-align: left;
}

如果我们查看正在运行的应用程序,它现在将如下所示:

因此,使用axios进行基本的GET请求非常简单。我们需要在类组件中使用componentDidMount生命周期方法,以便进行 REST API 调用,该调用将从响应中呈现数据。

但是我们如何处理错误呢?我们将在下一节中介绍这一点。

处理错误

  1. 让我们调整我们的请求中的 URL:
.get<IPost[]>("https://jsonplaceholder.typicode.com/postsX")

如果我们查看正在运行的应用程序,帖子将不再被呈现。

  1. 我们希望处理这种情况并给用户一些反馈。我们可以使用catch方法来做到这一点:
axios
  .get<IPost[]>("https://jsonplaceholder.typicode.com/postsX")
  .then( ... )
  .catch(ex => {
 const error =
 ex.response.status === 404
 ? "Resource not found"
 : "An unexpected error has occurred";
 this.setState({ error });
 });

因此,与fetch不同,HTTP 状态错误代码可以在catch方法中处理。catch中的错误对象参数包含一个包含有关响应的信息的response属性,包括 HTTP 状态代码。

  1. 我们在catch方法中引用了一个名为error的状态片段。我们将在下一步中使用它来呈现错误消息。但是,我们首先需要将此状态添加到我们的接口并进行初始化:
interface IState {
 posts: IPost[];
  error: string;
}
class App extends React.Component<{}, IState> {
  public constructor(props: {}) {
    super(props);
    this.state = {
      posts: [],
      error: "" 
    };
  }
}
  1. 然后,如果包含值,让我们呈现错误:
<ul className="posts">
  ...
</ul>
{this.state.error && <p className="error">{this.state.error}</p>}
  1. 让我们现在将刚刚引用的error CSS 类添加到index.css中:
.error {
  color: red;
}

如果我们现在查看正在运行的应用程序,我们将看到红色的资源未找到。

  1. 现在让我们将 URL 更改为有效的 URL,以便我们可以继续查看如何在下一节中包含 HTTP 标头:
.get<IPost[]>("https://jsonplaceholder.typicode.com/posts")

因此,使用axios处理 HTTP 错误与使用fetch不同。我们在fetch的第一个then方法中处理它们,而我们在axioscatch方法中处理它们。

请求 HTTP 标头

为了在请求中包含 HTTP 标头,我们需要向get函数添加第二个参数,该参数可以包含各种选项,包括 HTTP 标头。

让我们在我们的请求中添加一个内容类型的 HTTP 标头:

.get<IPost[]>("https://jsonplaceholder.typicode.com/posts", {
  headers: {
 "Content-Type": "application/json"
 }
})

因此,我们在一个名为headers的属性中的对象中定义了 HTTP 标头。

如果我们查看正在运行的应用程序,它将完全相同。JSONPlaceholder REST API 不需要内容类型,但我们与之交互的其他 REST API 可能需要。

在下一节中,我们将看看在fetch函数中很难实现的一些东西,即在请求上指定超时的能力。

超时

在一定时间后超时请求可以改善我们应用的用户体验:

  1. 让我们给我们的请求添加一个超时:
.get<IPost[]>("https://jsonplaceholder.typicode.com/posts", {
  headers: {
    "Content-Type": "application/json"
  },
  timeout: 1
})

因此,向axios请求添加超时非常简单。我们只需在选项对象中添加一个timeout属性,并设置适当的毫秒数。我们已经指定了 1 毫秒,这样我们就可以希望看到请求超时。

  1. 现在让我们在catch方法中处理超时:
.catch(ex => {
  const error =
    ex.code === "ECONNABORTED"
 ? "A timeout has occurred"
      : ex.response.status === 404
        ? "Resource not found"
        : "An unexpected error has occurred";
  this.setState({ error });
});

因此,我们在捕获的错误对象中检查code属性,以确定是否发生了超时。

如果我们查看正在运行的应用程序,我们应该得到确认,即已发生超时,并显示为红色的超时已发生。

  1. 现在让我们将超时时间更改为更合理的值,这样我们就可以继续看看如何在下一节中允许用户取消请求:
.get<IPost[]>("https://jsonplaceholder.typicode.com/posts", {
  ...
  timeout: 5000
})

取消请求

允许用户取消请求可以改善我们应用的用户体验。在本节中,我们将借助axios来实现这一点:

  1. 首先,我们将从axios中导入CancelTokenSource类型:
import axios, { CancelTokenSource } from "axios";
  1. 让我们在状态中添加一个取消令牌和一个加载标志:
interface IState {
 posts: IPost[];
 error: string;
 cancelTokenSource?: CancelTokenSource;
 loading: boolean;
}
  1. 让我们在构造函数中初始化加载状态:
this.state = {
  posts: [],
  error: "",
  loading: true
};

我们已将取消令牌定义为可选的,因此我们不需要在构造函数中初始化它。

  1. 接下来,我们将生成取消令牌源并将其添加到状态中,就在我们进行GET请求之前:
public componentDidMount() {
  const cancelToken = axios.CancelToken;
 const cancelTokenSource = cancelToken.source();
 this.setState({ cancelTokenSource });
  axios
    .get<IPost[]>(...)
    .then(...)
    .catch(...);
}
  1. 然后我们可以在 GET 请求中使用令牌:
.get<IPost[]>("https://jsonplaceholder.typicode.com/posts", {
  cancelToken: cancelTokenSource.token,
  ...
})
  1. 我们可以按照以下方式在catch方法中处理取消。让我们还将loading状态设置为false
.catch(ex => {
  const error = axios.isCancel(ex)
 ? "Request cancelled"
    : ex.code === "ECONNABORTED"
      ? "A timeout has occurred"
      : ex.response.status === 404
        ? "Resource not found"
        : "An unexpected error has occurred";
  this.setState({ error, loading: false });
});

因此,我们使用axios中的isCancel函数来检查请求是否已被取消。

  1. 当我们在componentDidMount方法中时,让我们在then方法中将loading状态设置为false
.then(response => {
  this.setState({ posts: response.data, loading: false });
})
  1. render方法中,让我们添加一个取消按钮,允许用户取消请求:
{this.state.loading && (
 <button onClick={this.handleCancelClick}>Cancel</button>
)}
<ul className="posts">...</ul>
  1. 让我们实现刚刚引用的取消按钮处理程序:
private handleCancelClick = () => {
  if (this.state.cancelTokenSource) {
    this.state.cancelTokenSource.cancel("User cancelled operation");
  }
};

为了取消请求,在取消令牌源上调用取消方法。

所以,用户现在可以通过点击取消按钮来取消请求。

  1. 现在,这将很难测试,因为我们正在使用的 REST API 非常快!因此,为了看到一个被取消的请求,让我们在componentDidMount方法中在请求发送后立即取消它:
axios
  .get<IPost[]>( ... )
  .then(response => { ... })
  .catch(ex => { ... });

cancelTokenSource.cancel("User cancelled operation");

如果我们查看正在运行的应用程序,我们应该看到请求被取消的验证,显示为红色的“请求已取消”。

因此,axios使得通过添加取消请求的能力来改善我们应用的用户体验变得非常容易。

在我们继续下一节之前,我们将使用axios来创建数据,让我们删除刚刚添加的行,以便在请求后立即取消它。

使用 axios 创建数据

现在让我们继续创建数据。我们将允许用户输入帖子标题和正文并保存:

  1. 让我们首先为标题和正文创建一个新的状态:
interface IState {
  ...
  editPost: IPost;
}
  1. 让我们也初始化这个新状态:
public constructor(props: {}) {
  super(props);
  this.state = {
    ...,
    editPost: {
      body: "",
      title: "",
 userId: 1
 }
  };
}
  1. 我们将创建一个inputtextarea来从用户那里获取帖子的标题和正文:
<div className="App">
  <div className="post-edit">
 <input
 type="text"
 placeholder="Enter title"
 value={this.state.editPost.title}
 onChange={this.handleTitleChange}
 />
 <textarea
 placeholder="Enter body"
 value={this.state.editPost.body}
 onChange={this.handleBodyChange}
 />
 <button onClick={this.handleSaveClick}>Save</button>
 </div>
  {this.state.loading && (
    <button onClick={this.handleCancelClick}>Cancel</button>
  )}
  ...
</div>
  1. 让我们实现刚刚引用的更改处理程序来更新状态:
private handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  this.setState({
    editPost: { ...this.state.editPost, title: e.currentTarget.value }
  });
};

private handleBodyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  this.setState({
    editPost: { ...this.state.editPost, body: e.currentTarget.value }
  });
};
  1. 我们可以在index.css中添加一些 CSS 来使这一切看起来合理:
.post-edit {
  display: flex;
  flex-direction: column;
  width: 300px;
  margin: 0px auto;
}
.post-edit input {
  font-family: inherit;
  width: 100%;
  margin-bottom: 5px;
}

.post-edit textarea {
  font-family: inherit;
  width: 100%;
  margin-bottom: 5px;
}

.post-edit button {
  font-family: inherit;
  width: 100px;
}
  1. 我们还可以开始处理保存点击处理程序,并使用axios将新帖子POST到 REST API:
private handleSaveClick = () => {
  axios
    .post<IPost>(
      "https://jsonplaceholder.typicode.com/posts",
      {
        body: this.state.editPost.body,
        title: this.state.editPost.title,
        userId: this.state.editPost.userId
      },
      {
        headers: {
          "Content-Type": "application/json"
        }
      }
    )
};
  1. 我们可以使用then方法处理响应:
.then(response => {
  this.setState({
    posts: this.state.posts.concat(response.data)
  });
});

因此,我们将新的帖子与现有帖子连接起来,为状态创建一个新的帖子数组。

post函数调用的结构与get非常相似。实际上,我们可以像对get一样添加错误处理、超时和取消请求的能力。

如果我们在运行的应用程序中添加一个新帖子并单击“保存”按钮,我们会看到它添加到帖子列表的底部。

接下来,我们将允许用户更新帖子。

使用 axios 更新数据

现在让我们继续更新数据。我们将允许用户点击现有帖子中的“更新”按钮来更改和保存它:

  1. 让我们首先在帖子列表中的每个列表项中创建一个“更新”按钮:
<li key={post.id}>
  <h3>{post.title}</h3>
  <p>{post.body}</p>
  <button onClick={() => this.handleUpdateClick(post)}>
 Update
 </button>
</li>
  1. 我们现在可以实现“更新”按钮的点击处理程序,该处理程序将在组件状态中设置正在编辑的帖子:
private handleUpdateClick = (post: IPost) => {
  this.setState({
    editPost: post
  });
};
  1. 在我们现有的保存点击处理程序中,我们现在需要为现有的POST请求和我们需要实现的PUT请求编写两个代码分支:
private handleSaveClick = () => {
  if (this.state.editPost.id) {
    // TODO - make a PUT request
  } else {
    axios
      .post<IPost>( ... )
      .then( ... );
  }
};
  1. 现在让我们实现PUT请求:
if (this.state.editPost.id) {
  axios
 .put<IPost>(
 `https://jsonplaceholder.typicode.com/posts/${
 this.state.editPost.id
 }`,
 this.state.editPost,
 {
 headers: {
 "Content-Type": "application/json"
 }
 }
 )
 .then(() => {
 this.setState({
 editPost: {
 body: "",
 title: "",
 userId: 1
 },
 posts: this.state.posts
 .filter(post => post.id !== this.state.editPost.id)
 .concat(this.state.editPost)
 });
 });
} else {
  ...
}

因此,我们过滤并连接更新的帖子,为状态创建一个新的帖子数组。

put函数调用的结构与getpost非常相似。同样,我们可以添加错误处理、超时和取消请求的能力,就像我们为get做的那样。

在运行的应用程序中,如果我们点击帖子中的“更新”按钮,更改标题和正文,然后点击“保存”按钮,我们会看到它从原来的位置移除,并以新的标题和正文添加到帖子列表的底部。

如果我们想要PATCH一个帖子,我们可以使用patch axios方法。这与put的结构相同,但是我们可以只传递需要更新的值,而不是传递整个被更改的对象。

在下一节中,我们将允许用户删除帖子。

使用 axios 删除数据

现在让我们继续删除数据。我们将允许用户点击现有帖子中的“删除”按钮来删除它:

  1. 让我们首先在帖子的每个列表项中创建一个“删除”按钮:
<li key={post.id}>
  <h3>{post.title}</h3>
  <p>{post.body}</p>
  <button onClick={() => this.handleUpdateClick(post)}>
    Update
  </button>
  <button onClick={() => this.handleDeleteClick(post)}>
 Delete
 </button>
</li>
  1. 现在我们可以创建删除按钮的点击处理程序:
private handleDeleteClick = (post: IPost) => {
  axios
    .delete(`https://jsonplaceholder.typicode.com/posts/${post.id}`)
    .then(() => {
      this.setState({
        posts: this.state.posts.filter(p => p.id !== post.id)
      });
    });
};

因此,我们使用axiosdelete方法来发出 HTTP 的DELETE请求,其结构与其他方法相同。

如果我们进入运行的应用程序,我们应该在每个帖子中看到一个删除按钮。如果我们点击其中一个按钮,我们会在短暂的延迟后看到它从列表中移除。

因此,这就结束了关于使用类组件的axios的部分。我们已经看到,axios函数比fetch更清晰,而且具有诸如有类型的响应、超时和请求取消等功能,使其成为许多开发人员的首选。在下一节中,我们将重构刚刚实现的App组件为函数组件。

在函数组件中使用 axios

在本节中,我们将在函数组件中使用axios实现 REST API 调用。我们将重构上一节中构建的App组件:

  1. 首先,我们将声明一个名为defaultPosts的常量,它将保存稍后将使用的默认帖子状态。我们将在IPost接口之后添加这个常量,并将其设置为空数组:
const defaultPosts: IPost[] = [];
  1. 我们将删除IState接口,因为状态现在将被构造为各个状态片段。

  2. 我们还将删除之前的App类组件。

  3. 接下来,让我们在defaultPosts常量下开始App函数组件:

const App: React.SFC = () => {}
  1. 现在我们可以为帖子、错误、取消令牌、加载标志和正在编辑的帖子创建状态:
const App: React.SFC = () => {
  const [posts, setPosts]: [IPost[], (posts: IPost[]) => void] = React.useState(defaultPosts);

  const [error, setError]: [string, (error: string) => void] = React.useState("");

  const cancelToken = axios.CancelToken;
  const [cancelTokenSource, setCancelTokenSource]: [CancelTokenSource,(cancelSourceToken: CancelTokenSource) => void] = React.useState(cancelToken.source());

  const [loading, setLoading]: [boolean, (loading: boolean) => void] = React.useState(false);

  const [editPost, setEditPost]: [IPost, (post: IPost) => void] = React.useState({
    body: "",
    title: "",
    userId: 1
  });
}

因此,我们使用useState函数来定义和初始化所有这些状态片段。

  1. 当组件首次挂载时,我们希望进行 REST API 调用以获取帖子。在状态定义的行之后,我们可以使用useEffect函数,将空数组作为第二个参数进行这样的操作:
React.useEffect(() => {
  // TODO - get posts
}, []);
  1. 让我们在箭头函数中调用 REST API 以获取帖子:
React.useEffect(() => {
  axios
 .get<IPost[]>("https://jsonplaceholder.typicode.com/posts", {
 cancelToken: cancelTokenSource.token,
 headers: {
 "Content-Type": "application/json"
 },
 timeout: 5000
 });
}, []);
  1. 让我们处理响应并设置帖子状态,同时将加载状态设置为false
React.useEffect(() => {
  axios
    .get<IPost[]>(...)
    .then(response => {
 setPosts(response.data);      setLoading(false);
 });
}, []);
  1. 让我们也处理任何错误,将错误状态与加载状态设置为false
React.useEffect(() => {
  axios
    .get<IPost[]>(...)
    .then(...)
    .catch(ex => {
 const err = axios.isCancel(ex)
 ? "Request cancelled"
 : ex.code === "ECONNABORTED"
 ? "A timeout has occurred"
 : ex.response.status === 404
 ? "Resource not found"
 : "An unexpected error has occurred";
 setError(err);
      setLoading(false);
 });
}, []);
  1. 现在我们可以继续处理事件处理程序了。这些与类组件实现非常相似,只是用const替换了private访问修饰符,以及用特定的状态变量和状态设置函数替换了this.statethis.setState。我们将从取消按钮的点击处理程序开始:
const handleCancelClick = () => {
  if (cancelTokenSource) {
    cancelTokenSource.cancel("User cancelled operation");
  }
};
  1. 接下来,我们可以为标题和正文输入添加更改处理程序:
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEditPost({ ...editPost, title: e.currentTarget.value });
};

const handleBodyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  setEditPost({ ...editPost, body: e.currentTarget.value });
};
  1. 接下来是保存按钮的点击处理程序:
const handleSaveClick = () => {
  if (editPost.id) {
    axios
      .put<IPost>(
        `https://jsonplaceholder.typicode.com/posts/${editPost.id}`,
        editPost,
        {
          headers: {
            "Content-Type": "application/json"
          }
        }
      )
      .then(() => {
        setEditPost({
          body: "",
          title: "",
          userId: 1
        });
        setPosts(
          posts.filter(post => post.id !== editPost.id).concat(editPost)
        );
      });
  } else {
    axios
      .post<IPost>(
        "https://jsonplaceholder.typicode.com/posts",
        {
          body: editPost.body,
          title: editPost.title,
          userId: editPost.userId
        },
        {
          headers: {
            "Content-Type": "application/json"
          }
        }
      )
      .then(response => {
        setPosts(posts.concat(response.data));
      });
  }
};
  1. 接下来让我们来处理更新按钮:
const handleUpdateClick = (post: IPost) => {
  setEditPost(post);
};
  1. 最后一个处理程序是用于删除按钮:
const handleDeleteClick = (post: IPost) => {
  axios
    .delete(`https://jsonplaceholder.typicode.com/posts/${post.id}`)
    .then(() => {
      setPosts(posts.filter(p => p.id !== post.id));
    });
};
  1. 我们的最后任务是实现返回语句。同样,这与类组件的render方法非常相似,只是去掉了对this的引用:
return (
  <div className="App">
    <div className="post-edit">
      <input
        type="text"
        placeholder="Enter title"
        value={editPost.title}
        onChange={handleTitleChange}
      />
      <textarea
        placeholder="Enter body"
        value={editPost.body}
        onChange={handleBodyChange}
      />
      <button onClick={handleSaveClick}>Save</button>
    </div>
    {loading && <button onClick={handleCancelClick}>Cancel</button>}
    <ul className="posts">
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
          <button onClick={() => handleUpdateClick(post)}>Update</button>
          <button onClick={() => handleDeleteClick(post)}>Delete</button>
        </li>
      ))}
    </ul>
    {error && <p className="error">{error}</p>}
  </div>
);

就是这样!我们与 REST API 交互的函数组件已经完成。如果我们尝试这样做,它应该与以前的行为完全一样。

在与 REST API 交互方面的主要区别在于,我们使用useEffect函数来进行 REST API 调用以获取需要呈现的数据。当组件已挂载时,我们仍然会这样做,就像在基于类的组件中一样。这只是一种不同的方式来利用组件的生命周期事件。

总结

基于回调的异步代码可能很难阅读和维护。谁花了几个小时来追踪回调式异步代码中错误的根本原因?或者只是花了几个小时来理解一段回调式异步代码试图做什么?幸运的是,现在我们有了编写异步代码的替代方法。

基于 Promise 的函数比基于回调的异步代码有了很大的改进,因为代码更易读,错误处理也更容易。asyncawait关键字可以说比基于 Promise 的函数代码更容易阅读异步代码,因为它非常接近同步等效代码的样子。

现代浏览器有一个名为fetch的很好的函数,用于与 REST API 进行交互。这是一个基于 Promise 的函数,允许我们轻松地发出请求并很好地管理响应。

axiosfetch的一种流行替代品。该 API 可以说更清晰,并且允许我们更好地处理 HTTP 错误代码。使用axios也可以非常简单地处理超时和取消请求。axios也非常友好于 TypeScript,因为类型已经内置到库中。在使用过axiosfetch之后,你更喜欢哪一个?

我们可以在类组件和函数组件中与 REST API 进行交互。当调用 REST API 以获取数据以在第一个组件渲染中显示时,我们需要等到组件挂载后。在类组件中,我们使用componentDidMount生命周期方法来实现这一点。在函数组件中,我们使用useEffect函数,将空数组作为第二个参数传递。在两种类型的组件中都有与 REST API 交互的经验后,你会在下一个 React 和 TypeScript 项目中使用哪种组件类型?

REST API 并不是我们可能需要交互的唯一类型的 API。GraphQL 是一种流行的替代 API 服务器。我们将在下一章学习如何与 GraphQL 服务器交互。

问题

让我们回答以下问题,以帮助我们对刚学到的知识有更深刻的理解:

  1. 如果我们在浏览器中运行以下代码,控制台会输出什么?
try {
 setInterval(() => {
  throw new Error("Oops");
 }, 1000);
} catch (ex) {
  console.log("Sorry, there is a problem", ex); 
}
  1. 假设帖子9999不存在,如果我们在浏览器中运行以下代码,控制台会输出什么?
fetch("https://jsonplaceholder.typicode.com/posts/9999")
  .then(response => {
    console.log("HTTP status code", response.status);
    return response.json();
  })
  .then(data => console.log("Response body", data))
  .catch (error => console.log("Error", error));
  1. 如果我们用axios做类似的练习,当运行以下代码时,控制台会输出什么?
axios
  .get("https://jsonplaceholder.typicode.com/posts/9999")
  .then(response => {
    console.log("HTTP status code", response.status);
  })
  .catch(error => {
    console.log("Error", error.response.status);
  });
  1. 使用原生的fetch而不是axios有什么好处?

  2. 我们如何在以下axios请求中添加一个 Bearer 令牌?

axios.get("https://jsonplaceholder.typicode.com/posts/1")
  1. 我们正在使用以下axiosPUT请求来更新帖子标题?
axios.put("https://jsonplaceholder.typicode.com/posts/1", {
  title: "corrected title", 
  body: "some stuff"
});
  1. 尽管身体没有改变,但我们只是想要更新标题。我们如何将这个转换为PATCH请求,以使这个 REST 调用更有效?

  2. 我们已经实现了一个函数组件来显示一个帖子。它使用以下代码从 REST API 获取帖子?

React.useEffect(() => {
  axios
    .get(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .then(...)
    .catch(...);
});

上述代码有什么问题?

进一步阅读

以下链接是本章涵盖的主题的进一步信息的好资源:

第十章:与 GraphQL API 交互

GraphQL 是由 Facebook 维护的用于读取和写入数据的开源 Web API 语言。它允许客户端指定返回的数据,并在单个请求中请求多个数据区域。这种效率和灵活性使其成为 REST API 的一个引人注目的替代方案。GraphQL 还支持读取和写入数据。

在本章中,我们将开始尝试针对 GitHub 进行一些 GraphQL 查询,以熟悉使用GitHub GraphQL API资源管理器的语法。我们将探讨如何读取和写入 GraphQL 数据,以及如何精确指定我们希望在响应中返回的数据方式。

然后,我们将在 React 和 TypeScript 应用程序中使用 GitHub GraphQL 服务器,构建一个小应用程序,该应用程序搜索 GitHub 存储库并返回有关其的一些信息。我们将使用上一章关于axios的知识与 GitHub GraphQL 服务器进行交互。然后我们将转而使用 Apollo,这是一个使与 GraphQL 服务器交互变得轻而易举的客户端库。

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

  • GraphQL 查询和变异语法

  • 使用 axios 作为 GraphQL 客户端

  • 使用 Apollo GraphQL 客户端

  • 在 Apollo 中使用缓存数据

技术要求

在本章中,我们使用以下技术:

  • Node.js 和 npm:TypeScript 和 React 依赖于这些。我们可以从nodejs.org/en/download/安装它们。如果我们已经安装了这些,请确保npm至少是 5.2 版本。

  • Visual Studio Code:我们需要一个编辑器来编写 React 和 TypeScript 代码,可以从code.visualstudio.com/安装。我们还需要在 Visual Studio Code 中安装 TSLint (by egamma) 和 Prettier (by Estben Petersen) 扩展。

  • GitHub:我们需要一个 GitHub 账户。如果我们还没有账户,可以在以下链接注册:github.com/join

  • GitHub GraphQL API Explorer:我们将使用此工具来玩转 GraphQL 查询和变异的语法。该工具位于developer.github.com/v4/explorer/

本章中的所有代码片段都可以在github.com/carlrip/LearnReact17WithTypeScript/tree/master/10-GraphAPIs上找到。

GraphQL 查询和变异语法

在本节中,我们将使用 GitHub GraphQL API 资源浏览器开始熟悉与 GraphQL 服务器交互的语法,从下一节开始阅读数据。

阅读 GraphQL 数据

为了读取 GraphQL 数据,我们进行所谓的查询。在本节中,我们将首先介绍基本的 GraphQL 语法,然后讨论如何在查询结果中包含嵌套对象,以及如何通过允许传递参数来创建可重用的查询。

基本查询

在本节中,我们将使用 GitHub GraphQL API 资源浏览器来获取有关我们的 GitHub 用户帐户的信息:

  1. 让我们在浏览器中打开以下 URL 以打开工具:

developer.github.com/v4/explorer/

如果我们还没有登录 GitHub 帐户,我们将需要登录。

  1. 在左上角的面板中,让我们输入以下内容,然后点击执行查询按钮:
query { 
  viewer { 
    name
  }
}

这是我们的第一个 GraphQL 查询。以下是一些关键点:

  • 我们使用query关键字作为查询的前缀。这实际上是可选的。

  • viewer是我们想要获取的对象的名称。

  • name是我们想要返回的viewer中的一个字段。

查询结果将显示在右侧:

我们请求的数据以 JSON 对象的形式返回。JSON 包含一个包含name字段的viewer对象的data对象。name的值应该是我们的名字,因为这是存储在我们的 GitHub 帐户中的名字。

  1. 在结果窗格的右侧有一个文档链接。如果我们点击这个链接,会出现一个文档资源浏览器:

如果我们点击查询链接,将显示可以查询的所有对象,包括viewer,这是我们刚刚查询的对象。如果我们点击进入这个对象,我们将看到viewer中可用的所有字段。

  1. 让我们将avatarUrl添加到我们的查询中,因为这是我们可以使用的另一个字段:
query { 
  viewer { 
    name
    avatarUrl
  }
}

因此,我们只需在nameavatarUrl字段之间加上一个换行符,将avatarUrl字段添加到viewer对象中。如果我们执行查询,我们将看到avatarUrl添加到 JSON 结果中。这应该是我们的图像的路径。

因此,我们已经看到了 GraphQL 的灵活性,可以精确指定我们希望在响应中返回哪些字段。在下一节中,我们将进一步指定我们希望返回的嵌套对象。

返回嵌套数据

让我们在本节中进行更复杂的查询。我们将搜索 GitHub 存储库,返回有关它的信息,包括它拥有的星星数量以及最近提出的问题作为嵌套数组:

  1. 让我们开始输入以下查询并执行它:
query { 
  repository (owner:"facebook", name:"react") {
    name
    description
  }
}

这次,我们要求repository对象,但传递了ownername存储库的两个参数。我们要求返回存储库的namedescription

我们看到返回了我们请求的存储库和字段:

  1. 现在让我们请求存储库的星星数量。为此,我们要求stargazers嵌套对象中的totalCount字段:
query { 
  repository (owner:"facebook", name:"react") {
    name
    description
    stargazers {
 totalCount
 }
  }
}

如果我们执行查询,我们会看到返回的结果:

  1. 现在让我们给stargazers中的totalCount添加一个别名:
stargazers {
  stars:totalCount
}

如果我们执行查询,我们会看到星星数量返回到我们指定的别名:

{
  "data": {
    "repository": {
      "name": "react",
      "description": "A declarative, efficient, and flexible JavaScript library for building user interfaces.",
      "stargazers": {
        "stars": 114998
      }
    }
  }
}
  1. 让我们继续请求存储库中的最后5个问题:
{ 
  repository (owner:"facebook", name:"react") {
    name
    description
    stargazers {
      stars:totalCount
    }
    issues(last: 5) {
 edges {
 node {
 id
 title
 url
 publishedAt
 }
 }
 }
  }
}

我们通过将5传递到最后一个参数来请求issues对象。然后,我们请求包含我们感兴趣的问题字段的edges对象中的node对象。

那么,edgesnode对象是什么?为什么我们不能直接请求我们想要的字段?嗯,这种结构是为了方便基于游标的分页。

如果我们执行查询,我们会得到结果中包含的最后5个问题。

因此,GraphQL 允许我们为不同的数据部分进行单个网络请求,只返回我们需要的字段。使用 GitHub REST API 进行类似的操作可能需要多个请求,并且我们会得到比我们需要的更多的数据。在这些类型的查询中,GraphQL 比 REST 更出色。

查询参数

我们刚刚进行的查询是硬编码的,用于获取特定存储库的数据。在本节中,我们将在查询中定义变量,这些变量基本上允许将参数传递给它:

  1. 我们可以在query关键字后的括号中添加查询变量,用逗号分隔。每个参数都通过在分号后声明其类型来定义其名称。这类似于在 TypeScript 函数中使用类型注释定义参数。变量名需要以$为前缀。类型后面的!表示这是必需的。因此,在我们的情况下,为了执行查询,这两个变量都是必需的。然后可以在查询中引用这些变量,在我们的例子中,这是我们请求存储库对象的地方:
query ($org: String!, $repo: String!) { 
  repository (owner:$org, name:$repo) {
    ...
  }
}
  1. 在执行查询之前,我们需要指定变量值。我们在左下角的查询变量窗格中以 JSON 对象的形式进行此操作:
{
  "org": "facebook",
  "repo": "react"
}
  1. 如果我们执行查询,我们将得到我们请求的存储库的结果:

我们现在已经开始习惯从 GraphQL 服务器中读取数据。但是我们如何创建新的数据项或更新数据呢?我们将在下一节中找到答案。

编写 GraphQL 数据

现在让我们把注意力转向写入 GraphQL 服务器。我们可以通过所谓的 mutations 来实现这一点。在本节中,我们将创建一个mutation来向存储库添加 GitHub 星标:

  1. 为了收藏一个存储库,我们需要存储库的id。因此,让我们将这个添加到我们一直在工作的查询中:
query ($org: String!, $repo: String!) { 
  repository (owner:$org, name:$repo) {
    id
    ...
  }
}
  1. 让我们复制结果中返回的id。React 存储库的id如下所示:
MDEwOlJlcG9zaXRvcnkxMDI3MDI1MA==
  1. 现在我们可以写我们的第一个mutation
mutation ($repoId: ID!) {
  addStar(input: { starrableId: $repoId }) {
    starrable {
      stargazers {
        totalCount
      }
    }
  }
}

以下是关于这个mutation的一些关键点:

  • 我们用mutation关键字作为前缀来定义一个 mutation。

  • 我们将要传递给mutation的参数放在mutation关键字后面的括号中。在我们的例子中,我们为要收藏的存储库id设置了一个参数。

  • addStar是我们正在调用的mutation函数,它有一个名为input的参数,我们需要传递给它。

  • input实际上是一个对象,其中包含一个名为starrableId的字段,我们需要包含它。其值是我们要收藏的存储库id,因此我们将其设置为我们的存储库id变量$repoId

  • mutation参数之后,我们可以指定我们希望在响应中返回什么。在我们的例子中,我们希望返回存储库上的星星数量。

  1. 我们可以在查询变量窗格中指定存储库id的参数值:
{
  "repoId": "MDEwOlJlcG9zaXRvcnkxMDI3MDI1MA=="
}
  1. 如果我们执行mutation,星星将被添加到存储库中,并且新的总星星数量将被返回:

现在我们对 GraphQL 查询和变异都有了很好的掌握。在下一节中,我们将开始从 React 和 TypeScript 应用程序与 GraphQL 服务器进行交互。

使用 axios 作为 GraphQL 客户端

与 GraphQL 服务器的交互是通过 HTTP 完成的。我们在第九章中学到,与 Restful API 交互axios是一个很好的 HTTP 客户端。因此,在本章中,我们将介绍如何使用axios与 GraphQL 服务器进行交互。

为了帮助我们学习,我们将创建一个 React 和 TypeScript 应用程序来返回有关我们 GitHub 帐户的信息。因此,我们的第一个任务是获取一个令牌,以便我们可以访问查询 GitHub GraphQL 服务器并搭建一个 React 和 TypeScript 应用程序。

生成 GitHub 个人访问令牌

GitHub GraphQL 服务器需要一个令牌才能与其进行交互。所以,让我们去生成一个个人访问令牌:

  1. 让我们登录到我们的 GitHub 帐户,并通过打开头像下的菜单并选择设置来进入我们的设置页面。

  2. 在左侧菜单中,我们需要选择开发者设置选项。这将带我们到开发者设置页面。

  3. 然后我们可以在左侧菜单中选择个人访问令牌选项。

  4. 然后我们将看到一个生成新令牌的按钮,我们可以点击它来生成我们的令牌。点击按钮后,我们可能会被提示输入密码。

  5. 在生成令牌之前,我们被要求指定范围。让我们输入一个令牌描述,选中 repo 和 user,然后点击生成令牌按钮。

  6. 然后生成的令牌将显示在页面上供我们复制并在我们的 React 应用程序中使用。

既然我们有了我们的令牌,让我们在下一节中搭建一个 React 和 TypeScript 应用程序。

创建我们的应用程序

我们将按照通常的步骤来搭建一个 React 和 TypeScript 应用程序:

  1. 让我们在我们选择的文件夹中打开 Visual Studio Code 并打开终端。让我们输入以下命令来创建一个新的 React 和 TypeScript 项目:
npx create-react-app repo-search --typescript

请注意,我们使用的 React 版本至少需要是16.7.0-alpha.0版本。我们可以在package.json文件中检查这一点。如果package.json中的 React 版本小于16.7.0-alpha.0,那么我们可以使用以下命令安装这个版本:

npm install react@16.7.0-alpha.0
npm install react-dom@16.7.0-alpha.0
  1. 项目创建后,让我们将 TSLint 作为开发依赖项添加到我们的项目中,并添加一些适用于 React 和 Prettier 的规则:
cd repo-search
npm install tslint tslint-react tslint-config-prettier --save-dev
  1. 现在让我们添加一个包含一些规则的tslint.json文件:
{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-
   prettier"],
  "rules": {
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "jsx-no-lambda": false,
    "no-debugger": false,
    "no-console": false,
  },
  "linterOptions": {
    "exclude": [
      "config/**/*.js",
      "node_modules/**/*.ts",
      "coverage/lcov-report/*.js"
    ]
  }
}
  1. 如果打开App.tsx,会出现一个 linting 错误。所以,让我们通过在render方法上添加public作为修饰符来解决这个问题:
class App extends Component {
  public render() {
    return ( ... );
  }
}
  1. 现在我们可以使用npm安装axios
npm install axios
  1. 在继续开发之前,让我们先启动我们的应用程序:
npm start
  1. 在我们使用axios进行第一个 GraphQL 查询之前,让我们在src目录中创建一个名为Header.tsx的新文件,其中包含以下import
import React from "react";
import axios from "axios";

这个组件最终将包含我们从 GitHub 获取的姓名和头像。

  1. 暂时让我们的Header组件返回空值:
export const Header: React.SFC = () => {
  return null;
}
  1. 现在让我们回到App.tsx,并导入我们刚刚创建的Header组件:
import { Header } from "./Header";
  1. 现在我们可以调整App.tsx中的 JSX,包括我们的Header组件:
<div className="App">
  <header className="App-header">
    <Header />
  </header>
</div>
  1. 作为本节的最后一个任务,让我们在App.css中更改App-Header的 CSS 类,以便标题不那么高:
.App-header {
  background-color: #282c34;
  min-height: 200px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  color: white;
}

查询 GraphQL 服务器

现在我们已经有了我们的 React 和 TypeScript 项目,让我们使用axios进行 GraphQL 查询:

  1. Header.tsx中,我们将首先为 GraphQL 查询响应和其中的 viewer 数据创建两个接口:
interface IViewer {
  name: string;
  avatarUrl: string;
}

interface IQueryResult {
  data: {
    viewer: IViewer;
  };
}
  1. 让我们在Header组件中创建一些状态变量用于viewer
const [viewer, setViewer]: [
  IViewer,
  (viewer: IViewer) => void
] = React.useState({name: "", avatarUrl: ""});
  1. 现在是时候进行 GraphQL 查询了。我们将在组件刚刚挂载时进行这个操作。我们可以使用useEffect函数来实现这一点:
React.useEffect(() => {
  // TODO - make a GraphQL query 
}, []);

我们将一个空数组作为第二个参数传递,这样查询只会在组件挂载时执行,而不是在每次渲染时执行。

  1. 然后让我们使用axios进行 GraphQL 查询:
React.useEffect(() => {
  axios
 .post<IQueryResult>(
 "https://api.github.com/graphql",
 {
 query: `query { 
 viewer { 
 name
 avatarUrl
 }
 }`
 }
 )
}, []);

请注意,尽管我们正在读取数据,但我们正在进行 HTTP POST。GraphQL 要求我们使用 HTTP POST,因为查询的细节在请求体中。

我们还在使用之前使用的接口IQueryResult来处理响应数据。

  1. 如前所述,我们需要在 HTTP 授权标头中传递我们的令牌。所以,让我们这样做:
axios
  .post<IQueryResult>(
    "https://api.github.com/graphql",
    {
      query: `query { 
        viewer { 
          name
          avatarUrl
        }
      }`
    },
    {
 headers: {
 Authorization: "bearer our-bearer-token"
 }
 }
  )

显然,我们需要用我们之前从 GitHub 获取的真实令牌来替换。

  1. 我们还没有处理响应,所以让我们设置viewer状态变量:
axios
  .post<IQueryResult>(
    ...
  )
  .then(response => {
 setViewer(response.data.data.viewer);
 });
  1. 现在我们已经从 GraphQL 查询中获取了数据,让我们渲染我们的头像和姓名以及我们的应用程序标题:
return (
  <div>
 <img src={viewer.avatarUrl} className="avatar" />
 <div className="viewer">{viewer.name}</div>
 <h1>GitHub Search</h1>
 </div>
);
  1. 让我们将刚刚引用的头像 CSS 类添加到App.css中:
.avatar {
  width: 60px;
  border-radius: 50%;
}

如果我们查看正在运行的应用程序,应该在应用程序标题中看到我们的头像和姓名:

因此,我们刚刚使用了一个 HTTP 库与 GraphQL 服务器进行交互。所有 GraphQL 请求都是使用 HTTP POST 方法进行的,即使是用于读取数据的请求也是如此。所有 GraphQL 请求也都是发送到同一个端点。我们想要从中获取数据的资源不在 URL 中,而是在请求体中。因此,虽然我们可以使用 HTTP 库,比如axios,来查询 GraphQL 服务器,但感觉有点奇怪。

在下一节中,我们将看一下一个 GraphQL 客户端,它将帮助我们以更自然的方式查询 GraphQL 服务器。

使用 Apollo GraphQL 客户端

Apollo 客户端是一个用于与 GraphQL 服务器交互的客户端库。它比使用通用 HTTP 库如axios有许多优点,比如能够在我们的 JSX 中以声明方式读写数据,并且开箱即用地启用缓存。

在本节中,我们将重构上一节中使用axios构建的内容,以使用 Apollo,并且稍微扩展我们的应用程序以包括 GitHub 仓库搜索。

安装 Apollo 客户端

我们的第一项工作是将 Apollo 安装到我们的项目中。

  1. 要将 Apollo 客户端添加到我们的项目中,让我们通过npm安装以下包:
npm install apollo-boost react-apollo graphql
  • apollo-boost包含了我们设置 Apollo 客户端所需的一切

  • react-apollo包含了我们将用来与 GraphQL 服务器交互的 React 组件

  • graphql是一个核心包,我们将用它来解析 GraphQL 查询

  1. 我们还将为graphql安装一些 TypeScript 类型:
npm install @types/graphql --save-dev
  1. 我们需要确保 TypeScript 在编译我们的代码时包含es2015esNext库。因此,让我们在tsconfig.json中添加以下lib字段:
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["es2015", "dom", "esnext"],
    ...
  },
  ...
}

现在我们已经准备好开始使用 Apollo 与 GitHub GraphQL 服务器进行交互了。

从 axios 迁移到 Apollo

现在我们已经安装了所有 Apollo 的部分,让我们将我们的axios代码迁移到 Apollo。

添加 Apollo 提供程序

我们将从App.tsx开始,在那里我们将定义我们的 Apollo 客户端并提供App组件层次结构下的所有组件:

  1. App.tsx中,让我们导入apollo-boost,以及从react-apollo导入ApolloProvider组件:
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
  1. App类组件的上方,让我们创建我们的ApolloClient
const client = new ApolloClient({
  uri: "https://api.github.com/graphql",
  headers: {
    authorization: `Bearer our-bearer-token`
  }
});

显然,我们需要用我们之前从 GitHub 获取的真实令牌来替换它。

  1. 最后一步是使用ApolloProvider组件将我们创建的ApolloClient提供给应用中的所有其他组件。我们通过将ApolloProvider作为根组件,并将ApolloClient对象传递给它来实现这一点:
public render() {
  return (
    <ApolloProvider client={client}>
      <div className="App">
        <header className="App-header">
          <Header />
        </header>
      </div>
    </ApolloProvider>
  );
}

现在ApolloClient已经设置好了,我们可以开始与 GraphQL 服务器进行交互了。

使用查询组件查询 GraphQL

我们现在要使用Query组件来获取我们的 GitHub 姓名和头像,替换axios代码:

  1. 让我们首先删除axios导入语句,而不是有以下导入:
import gql from "graphql-tag";
import { Query } from "react-apollo";
  1. 我们的IViewer接口将保持不变,但我们需要稍微调整我们的IQueryResult接口:
interface IQueryResult {
  viewer: IViewer;
}
  1. 我们接下来要定义我们的 GraphQL 查询:
const GET_VIEWER = gql`
  {
    viewer {
      name
      avatarUrl
    }
  }
`;

所以,我们将查询设置为GET_VIEWER变量,并在模板文字中定义了我们的查询。然而,在模板文字之前的gql函数有点奇怪。模板文字不应该在括号中吗?实际上,这被称为标记模板文字,其中来自核心 GraphQL 库的gql函数解析其旁边的模板文字。我们最终得到了一个 Apollo 可以使用和执行的GET-VIEWER中的查询。

  1. 我们现在要开始定义我们的查询。我们可以直接在 JSX 中使用react-apollo中的Query组件定义我们的查询。然而,为了增加一些类型安全性,我们将创建一个名为GetViewerQuery的新组件,该组件继承自Query并将结果类型定义为泛型参数:
class GetViewerQuery extends Query<IQueryResult> {}
  1. 我们不再需要任何状态,所以我们可以删除viewersetViewer变量。

  2. 我们还可以删除使用useEffect函数调用axios查询的部分,因为我们现在要在 JSX 中进行查询。

  3. 所以,让我们使用我们的GetViewerQuery组件来调用我们的查询:

return (
  <GetViewerQuery query={GET_VIEWER}>
    {({ data }) => {
      if (!data || !data.viewer) {
        return null;
      }
      return (
        <div>
          <img src={data.viewer.avatarUrl} className="avatar" />
          <div className="viewer">{data.viewer.name}</div>
          <h1>GitHub Search</h1>
        </div>
      );
    }}
  </GetViewerQuery>
);
  • 我们将我们之前创建的查询作为query属性传递给GetViewerQuery组件。

  • 查询结果在GetViewerQuery的 children 函数中返回。

  • children 函数参数包含一个包含data属性中数据的对象。我们将这些数据解构到一个data变量中。

  • 如果没有任何数据,我们会提前退出并返回null

  • 如果我们有数据,然后返回我们的头像和姓名的 JSX,引用data属性。

如果我们查看我们正在运行的应用程序,它应该与axios版本完全相同。如果显示错误,我们可能需要再次npm start应用程序。

  1. 我们可以从 children 函数参数中获取其他信息。一个有用的信息是数据是否正在加载。让我们使用这个来显示一个加载消息:
return (
  <GetViewerQuery query={GET_VIEWER}>
    {({ data, loading }) => {
      if (loading) {
 return <div className="viewer">Loading ...</div>;
 }
      ...
    }}
  </GetViewerQuery>
);
  1. 我们可以从 children 函数参数中获取的另一个有用的信息是有关发生的错误的信息。让我们使用这个来显示错误消息,如果有的话:
return (
  <GetViewerQuery query={GET_VIEWER}>
    {({ data, loading, error }) => {
      if (error) {
 return <div className="viewer">{error.toString()}</div>;
 }
      ...
    }}
  </GetViewerQuery>
);

这个 Apollo 实现真的很优雅。Query组件如何在组件生命周期的正确时刻进行网络请求,并允许我们向其余的组件树提供数据,真是聪明。

在下一节中,我们将继续使用 Apollo 来增强我们的应用程序。

添加一个仓库搜索组件

在这一部分,我们将添加一个组件来搜索 GitHub 仓库并返回一些关于它的信息:

  1. 让我们首先创建一个名为RepoSearch.tsx的新文件,其中包含以下导入:
import * as React from "react";
import gql from "graphql-tag";
import { ApolloClient } from "apollo-boost";
  1. 我们将以ApolloClient作为 prop 传入。因此,让我们为此添加一个接口:
interface IProps {
  client: ApolloClient<any>;
}
  1. 接下来,我们将搭建我们的组件:
const RepoSearch: React.SFC<IProps> = props => {
  return null;
}

export default RepoSearch;
  1. 现在让我们在App.tsx中引用这个,首先导入它:
import RepoSearch from "./RepoSearch";
  1. 现在我们可以将其添加到应用程序标题下,传入ApolloClient
<ApolloProvider client={client}>
  <div className="App">
    <header className="App-header">
      <Header />
    </header>
    <RepoSearch client={client} />
  </div>
</ApolloProvider>

我们的仓库search组件现在已经很好地设置好了。在下一节中,我们可以实现一个搜索表单。

实现搜索表单

让我们实现一个搜索表单,允许用户提供组织名称和仓库名称:

  1. 回到RepoSearch.tsx,让我们开始定义搜索字段的状态,从接口开始:
interface ISearch {
  orgName: string;
  repoName: string;
}
  1. 现在我们可以创建一个变量来保存我们的search状态,以及一个设置它的函数:
const RepoSearch: React.SFC<IProps> = props => {
  const [search, setSearch]: [
 ISearch,
 (search: ISearch) => void
 ] = React.useState({
 orgName: "",
 repoName: ""
 });

  return null;
}
  1. 让我们在 JSX 中定义search表单:
return (
  <div className="repo-search">
    <form onSubmit={handleSearch}>
      <label>Organization</label>
      <input
        type="text"
        onChange={handleOrgNameChange}
        value={search.orgName}
      />
      <label>Repository</label>
      <input
        type="text"
        onChange={handleRepoNameChange}
        value={search.repoName}
      />
      <button type="submit">Search</button>
    </form>
  </div>
);

我们引用了一些尚未实现的部分。因此,我们将逐一实现这些。

  1. 让我们添加在App.css中引用的repo-search类。我们还将为标签和输入以及搜索按钮添加样式:
.repo-search {
  margin: 30px auto;
  width: 300px;
  font-family: Arial;
  font-size: 16px;
  text-align: left;
}

.repo-search label {
  display: block;
  margin-bottom: 3px;
  font-size: 14px;
}

.repo-search input {
  display: block;
  margin-bottom: 10px;
  font-size: 16px;
  color: #676666;
  width: 100%;
}

.repo-search button {
  display: block;
  margin-bottom: 20px;
  font-size: 16px;
}
  1. 接下来,让我们实现简单更新search状态的输入更改处理程序:
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setSearch({ ...search, orgName: e.currentTarget.value });
};

const handleRepoNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setSearch({ ...search, repoName: e.currentTarget.value });
};
  1. 我们需要实现的最后一部分是search处理程序:
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

  // TODO - make GraphQL query
};

我们在事件参数上调用preventDefault来阻止发生完整的后退。

所以,搜索表单已经开始了。我们将在下一节中实现 GraphQL 查询。

实现搜索查询

我们现在到了需要进行 GraphQL 查询来实际搜索的地步:

  1. 让我们首先为我们期望从查询中获取的仓库数据创建一个接口:
interface IRepo {
  id: string;
  name: string;
  description: string;
  viewerHasStarred: boolean;
  stargazers: {
    totalCount: number;
  };
  issues: {
    edges: [
      {
        node: {
          id: string;
          title: string;
          url: string;
        };
      }
    ];
  };
}

这是我们在之前的部分中从 GitHub GraphQL Explorer 中得到的结构。

  1. 我们将需要为这个状态设置一个默认值。所以,让我们定义这个:
const defaultRepo: IRepo = {
  id: "",
  name: "",
  description: "",
  viewerHasStarred: false,
  stargazers: {
    totalCount: 0
  },
  issues: {
    edges: [
      {
        node: {
          id: "",
          title: "",
          url: ""
        }
      }
    ]
  }
};
  1. 我们还可以为整个查询结果定义一个接口:
interface IQueryResult {
  repository: IRepo;
}
  1. 现在我们可以使用标记模板字面量来创建查询本身:
const GET_REPO = gql`
  query GetRepo($orgName: String!, $repoName: String!) {
    repository(owner: $orgName, name: $repoName) {
      id
      name
      description
      viewerHasStarred
      stargazers {
        totalCount
      }
      issues(last: 5) {
        edges {
          node {
            id
            title
            url
            publishedAt
          }
        }
      }
    }
  }
`;

这是我们在之前的部分中在 GitHub GraphQL Explorer 中进行的查询。与以前的查询不同,这个查询有一些参数,我们需要在稍后执行查询时包含这些参数。

  1. 我们需要将从查询中获取的数据存储在状态中。所以,让我们创建一个名为repo的状态变量,以及一个设置它的函数:
const [repo, setRepo]: [
    IRepo,
    (repo: IRepo) => void
  ] = React.useState(defaultRepo);
  1. 我们还将在状态中存储search的任何问题:
const [searchError, setSearchError]: [
  string,
  (searchError: string) => void
] = React.useState("");
  1. 让我们更新handleSearch箭头函数,在进行search之前清除任何搜索错误状态:
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

  setSearchError("");
};
  1. 让我们继续使用作为属性传递的ApolloClient来进行查询:
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();

  setSearchError("");

  props.client
 .query<IQueryResult>({
 query: GET_REPO
 });
};
  1. 这里还有更多的工作要做。首先,我们需要从我们在search状态中拥有的值中传递query参数,用于组织名称和仓库名称:
.query<IQueryResult>({
  query: GET_REPO,
  variables: { orgName: search.orgName, repoName: search.repoName }
})
  1. 现在是时候在then方法中处理响应并将repo状态设置为响应中的数据了:
props.client
  .query<IQueryResult>( ... )
  .then(response => {
 setRepo(response.data.repository);
 });
  1. 我们还将在catch方法中处理任何错误,并更新searchError状态:
props.client
  .query<IQueryResult>(...)
  .then(...)
  .catch(error => {
 setSearchError(error.message);
 });

如果我们在运行的应用中尝试进行search,查询将会正常进行,但我们还没有显示结果。让我们在下一部分中做这件事。

渲染搜索结果

让我们渲染从仓库查询中获取的数据:

  1. 如果我们有搜索结果,让我们在search表单下渲染仓库名称及其星数以及描述:
return (
  <div className="repo-search">
    <form ...>
      ...
    </form>
    {repo.id && (
 <div className="repo-item">
 <h4>
 {repo.name}
 {repo.stargazers ? ` ${repo.stargazers.totalCount}
           stars` : ""}
 </h4>
 <p>{repo.description}</p>
 </div>
 )}
  </div>
);
  1. 我们还将渲染最后的5个仓库问题:
...
<p>{repo.description}</p>
<div>
 Last 5 issues:
 {repo.issues && repo.issues.edges ? (
 <ul>
 {repo.issues.edges.map(item => (
 <li key={item.node.id}>{item.node.title}</li>
 ))}
 </ul>
 ) : null}
</div>
  1. 如果出现问题,让我们渲染在状态中捕获的错误消息:
{repo.id && (
  ...
)}
{searchError && <div>{searchError}</div>}
  1. 让我们在App.css中为搜索结果中的仓库标题添加一些 CSS:
.repo-search h4 {
  text-align: center;
}

如果我们搜索一个仓库,现在应该看到有关仓库的信息被渲染出来:

我们现在已经可以使用 Apollo 舒适地查询 GraphQL 服务器了。在下一部分,我们将处理变异。

使用 Apollo 实现变异

让我们允许用户在我们的应用中为 GitHub 仓库加星。这将涉及通过 Apollo 发送一个mutation

  1. 首先,让我们从react-apollo中导入Mutation组件:
import { Mutation } from "react-apollo";
  1. 现在让我们创建mutation。这是我们之前在 GitHub GraphQL Explorer 中执行的相同查询:
const STAR_REPO = gql`
  mutation($repoId: ID!) {
    addStar(input: { starrableId: $repoId }) {
      starrable {
        stargazers {
          totalCount
        }
      }
    }
  }
`;
  1. 在 JSX 中,在我们渲染描述的地方,让我们放置Mutation组件:
<p>{repo.description}</p>
<div>
 {!repo.viewerHasStarred && (
 <Mutation
 mutation={STAR_REPO}
 variables={{ repoId: repo.id }}
 >
 {() => (
 // render Star button that invokes the mutation when 
           clicked
 )}
 </Mutation>
 )}
</div> <div>
  Last 5 issues:
  ...
</div>
  • 只有在viewer还没有给存储库添加星标时,我们才渲染mutation

  • Mutation组件接受我们刚刚定义的 mutation 以及变量,这在我们的情况下是存储库的id

  1. Mutation组件有一个 children 函数,它给了我们访问addStar函数的权限。因此,让我们渲染一个 Star!按钮,当点击时调用addStar
<Mutation
    ...
  >
    {(addStar) => (
      <div>
 <button onClick={() => addStar()}>
 Star!
 </button>
 </div>
    )}
  </Mutation>
)}
  1. Mutation组件还告诉我们mutation正在执行,通过 children 函数的第二个参数中的loading属性。让我们使用这个来禁用按钮,并通知用户星标正在被添加:
<Mutation
  ...
>
  {(addStar, { loading }) => (
    <div>
      <button disabled={loading} onClick={() => addStar()}>
        {loading ? "Adding ..." : "Star!"}
      </button>
    </div>
  )}
</Mutation>
  1. Mutation组件还告诉我们是否有错误。因此,让我们使用这个并在发生错误时渲染错误:
<Mutation
  ...
>
  {(addStar, { loading, error }) => (
    <div>
      <button ...>
        ...
      </button>
      {error && <div>{error.toString()}</div>}
    </div>
  )}
</Mutation>

如果我们尝试给存储库添加星标,星标应该会成功添加。我们可以去 GitHub 存储库的github.com验证这一点。

现在我们已经实现了查询和mutation,我们真正掌握了 Apollo。不过,有一件事情有点奇怪,也许我们已经注意到了。在我们给存储库添加星标后,应用程序中星标的数量没有更新。即使我们再次搜索存储库,星标的数量仍然是我们开始之前的数量。但是,如果我们刷新浏览器并再次搜索存储库,我们会得到正确的星标数量。那么,这是怎么回事呢?我们将在下一节中找出答案。

在 Apollo 中使用缓存数据

我们在上一节结束时留下了一个谜。为什么我们在开始搜索后没有得到存储库search的最新星标数量?答案是 Apollo 在初始search后缓存了存储库数据。当执行相同的查询时,它会从缓存中获取结果,而不是从 GraphQL 服务器获取数据。

让我们再次确认一下:

  1. 让我们打开应用程序并在网络选项卡上打开开发者工具,并清除之前的请求:

  1. 让我们进行一次搜索。我们会看到向 GitHub GraphQL 服务器发出了几个请求:

  1. 在开发者工具中,网络选项卡,让我们清除请求,然后在我们的应用程序中再次点击搜索按钮。我们会看到没有网络请求被发出,但数据被渲染出来。所以,数据一定是来自本地缓存。

所以,我们使用apollo-boost配置的ApolloClient会自动将查询缓存到内存中。在下一节中,我们将学习如何清除缓存,以便我们的应用程序在仓库被加星后显示正确的星星数量。

使用refetchQueries清除缓存

mutation发生后,我们需要一种清除缓存查询结果的方法。一种方法是在Mutation组件上使用refetchQueries属性:

  1. 让我们试一试。refetchQueries属性接受一个包含应该从缓存中移除的具有相应变量值的查询对象数组:
<Mutation
  mutation={STAR_REPO}
  variables={{ repoId: repo.id }}
  refetchQueries={[
 {
 query: GET_REPO,
 variables: {
 orgName: search.orgName,
 repoName: search.repoName
 }
 }
 ]}
>
  ...
</Mutation>
  1. 如果我们现在给一个仓库加星标,星星的数量不会立即更新。然而,如果按下搜索按钮,星星就会更新。

所以,缓存已经清除,但是体验仍然不理想。理想情况下,我们希望在点击“Star!”按钮后立即更新星星的数量。

如果我们仔细思考刚才做的事情,我们正在试图绕过缓存。然而,缓存的存在是为了帮助我们的应用程序表现良好。

所以,这种方法并不理想。用户体验仍然不理想,我们刚刚使我们的应用程序性能下降了。一定有更好的方法!我们将在下一节中探索另一种方法。

在 Mutation 后更新缓存

让我们再次仔细思考一下问题:

  • 我们在缓存中有关于仓库的一些信息,包括它拥有的星星数量。

  • 当我们给仓库加星标时,我们希望看到星星的数量增加了一个。

  • 如果我们可以在缓存中将星星的数量增加一个,那会怎么样?这应该能解决问题。

所以,让我们尝试一下,在mutation完成后更新缓存:

  1. 首先,让我们移除上一节中实现的refetchQueries属性。

  2. Mutation组件上有一个update属性,我们可以利用它来更新缓存。所以,让我们开始实现这个功能:

<Mutation
  mutation={STAR_REPO}
  update={cache => {
 // Get the cached data 
 // update the cached data
 // update our state 
 }}
>
  ...
</Mutation>
  1. 所以,我们需要实现一个箭头函数,更新可用作参数的缓存:
<Mutation
  ...
  update={cache => {
 const data: { repository: IRepo } | null = cache.readQuery({
 query: GET_REPO,
 variables: {
 orgName: search.orgName,
 repoName: search.repoName
 }
 });
 if (data === null) {
 return;
 }
 }}
>
  ...
</Mutation>

所以,缓存有一个readQuery函数,我们可以使用它来获取缓存的数据。如果在缓存中找不到数据,那么我们可以退出函数而不做其他事情。

  1. 因此,现在我们从缓存中获取了数据,我们可以增加星星的数量。为此,我们创建一个新对象,并将缓存存储库的属性扩展到其中,并用增加的星星数量和查看者已经为存储库加星的事实覆盖它:
update={cache => {
  ...
  if (data === null) {
    return;
  }
  const newData = {
 ...data.repository,    viewerHasStarred: true,
 stargazers: {
 ...data.repository.stargazers,
 totalCount: data.repository.stargazers.totalCount + 1
 }
 };
}}
  1. 然后,我们可以使用其writeQuery函数更新缓存。我们传入带有变量值的查询和要存储在缓存中的新数据:
update={cache => {
  ...
  const newData = {
    ...
  };
 cache.writeQuery({
 query: GET_REPO,
 variables: {
 orgName: search.orgName,
 repoName: search.repoName
 },
 data: { repository: newData }
 });
}}
  1. 还有一件事要做,那就是更新repo状态,以便星星的数量立即在屏幕上更新:
update={cache => {
  ...
  cache.writeQuery(...);
  setRepo(newData);
}}

就是这样。如果我们再次尝试在应用程序中为存储库加星,我们应该会看到星星的数量立即增加。

缓存是 Apollo 提供的伟大功能之一。Mutation组件上的update属性为我们提供了一种精确更新缓存的方式。Mutation组件上的refetchQueries属性是一种更粗暴且效率低下的强制更新缓存的方式。

总结

GraphQL 比 REST 更出色,因为它允许我们以更少的努力有效地获取所需的数据。GitHub GraphQL Explorer 是一个很好的工具,可以让我们熟悉语法。我们可以向 GraphQL 服务器发出两种主要类型的请求:

  • 我们可以执行query来读取数据

  • 我们可以执行mutation来写入数据

查询允许我们指定响应中需要的对象和字段。我们可以使用别名来重命名它们。我们可以通过定义变量来参数化查询。我们可以给变量类型,并在末尾使用!来指定每个变量是否是必需的。本章中我们没有涵盖的查询功能还有条件包含字段和强大的分页功能。总之,这是一种非常强大的查询语言!

变异与查询有一些相同的特性,比如能够向它们传递参数。我们可以控制响应中包含的数据,这真是太棒了。

GraphQL 通过 HTTP 运行,使用 HTTP POST请求到单个 URL。HTTP 正文包含查询或mutation信息。我们可以使用 HTTP 客户端与 GraphQL 服务器交互,但使用专门与 GraphQL 服务器交互的 Apollo 等库可能会更有效率。

React Apollo 是一组与核心 Apollo 库配合使用的 React 组件。它为我们提供了很好的QueryMutationReact 组件,用于在我们的 JSX 中包含查询和变更,使我们的代码更易于阅读。在我们使用这些组件之前,我们需要设置我们的ApolloClient对象,包括 GraphQL 服务器的 URL 和任何凭据。我们还需要在我们的组件树的顶部包含一个ApolloProvider组件,高于所有需要 GraphQL 数据的组件。

当我们使用apollo-boost搭建项目时,缓存默认开启。Mutation组件给了我们updaterefetchQueries属性来管理缓存更新。

总的来说,GraphQL 是与后端交互的一种非常高效的方式,它与 React 和 TypeScript 应用程序非常配合。

因此,到目前为止,我们在这本书中学到了许多关于 React 和 TypeScript 的不同方面。一个我们尚未涉及的重要主题是如何对我们构建的应用进行健壮的测试。我们将在下一章中介绍这个主题。

问题

让我们尝试一些问题,来测试我们刚刚学到的知识:

  1. 在 GitHub GraphQL Explorer 中,创建一个查询,返回 React 项目中最后五个未解决的问题。在响应中返回问题标题和 URL。

  2. 增强最后一个查询,并使返回的问题数量成为一个参数,并将其默认设置为五。

  3. 在 GitHub GraphQL Explorer 中创建一个mutation来取消对一个已标星的存储库的标星。mutation应该以一个必需的存储库id作为参数。

  4. GraphQL 查询的哪一部分放在 HTTP 请求中?

  5. GraphQL mutation的哪一部分放在 HTTP 请求中?

  6. 如何使react-apolloQuery组件的响应类型安全?

  7. 使用react-boost搭建项目时,默认情况下是否开启缓存?

  8. 我们可以在Mutation组件上使用哪个属性来更新本地缓存?

进一步阅读

以下链接是关于 GraphQL、React 和 Apollo 的进一步信息的好资源:

第十一章:使用 Jest 进行单元测试

构建一个强大的单元测试套件,捕捉真正的错误并在重构代码时不会误报阳性,是我们作为软件开发人员所做的最艰巨的任务之一。Jest 是一个很好的测试工具,可以帮助我们应对这一挑战,我们将在本章中了解到。

也许应用程序中最容易进行单元测试的部分是纯函数,因为没有副作用需要处理。我们将重新访问我们在第七章中构建的验证函数,使用表单,并对其进行一些单元测试,以便学习如何对纯函数进行单元测试。

在构建应用程序时,单元测试组件是我们将进行的最常见类型的单元测试。我们将详细了解它,并利用一个库来帮助我们实施测试,在重构代码时不会不必要地中断。

我们将学习什么是快照测试,以及如何利用它来更快地实现我们的测试。快照可以用于测试纯函数以及组件,因此它们是我们非常有用的工具。

模拟是一个具有挑战性的话题,因为如果我们模拟得太多,我们实际上并没有测试我们的应用程序。然而,有一些依赖关系是有意义的,比如 REST API。我们将重新访问我们在第九章中构建的应用程序,与 Restful API 交互,以便对其实施一些单元测试并学习有关模拟的知识。

在为我们的应用程序实现一套单元测试时,了解我们已经测试过哪些部分以及哪些部分尚未测试是很有用的。我们将学习如何使用代码覆盖工具来帮助我们快速识别需要更多单元测试的应用程序区域。

本章将涵盖以下主题:

  • 测试纯函数

  • 测试组件

  • 使用 Jest 快照测试

  • 模拟依赖关系

  • 获取代码覆盖率

技术要求

我们在本章中使用以下技术:

为了从之前的章节中恢复代码,可以下载LearnReact17WithTypeScript存储库,网址为github.com/carlrip/LearnReact17WithTypeScript。然后可以在 Visual Studio Code 中打开相关文件夹,并在终端中输入npm install来进行恢复。本章中的所有代码片段都可以在以下链接找到:github.com/carlrip/LearnReact17WithTypeScript/tree/master/11-UnitTesting

测试纯函数

我们将在本节中开始我们的单元测试之旅,通过对纯函数实现一个单元测试。

纯函数对于给定的参数值集合具有一致的输出值。纯函数仅依赖于函数参数,不依赖于函数外部的任何东西。这些函数也不会改变传递给它们的任何参数值。

这些函数仅依赖于它们的参数值,这使得它们很容易进行单元测试。

我们将在我们构建的 React 商店中的Form组件中创建的required验证函数上实现一个单元测试。如果还没有,请在 Visual Studio Code 中打开这个项目。

我们将使用 Jest 作为我们的单元测试框架,这在测试 React 应用中非常流行。幸运的是,create-react-app工具在创建项目时已经为我们安装和配置了 Jest。因此,Jest 已经准备好在我们的 React 商店项目中使用。

创建一个基本的纯函数测试

让我们在项目中创建我们的第一个单元测试,来测试Form.tsx中的required函数:

  1. 首先在src文件夹中创建一个名为Form.test.tsx的文件。我们将使用这个文件来编写我们的测试代码,以测试Form.tsx中的代码。

test.tsx扩展名很重要,因为 Jest 在查找要执行的测试时会自动查找具有此扩展名的文件。请注意,如果我们的测试不包含任何 JSX,我们可以使用test.ts扩展名。

  1. 让我们导入我们想要测试的函数,以及我们需要用于参数值的 TypeScript 类型:
import { required, IValues } from "./Form";
  1. 让我们开始使用 Jest 的test函数创建我们的测试:
test("When required is called with empty title, 'This must be populated' should be returned", () => {
  // TODO: implement the test
});

test函数接受两个参数:

  • 第一个参数是告诉我们测试是否通过的消息,将显示在测试输出中

  • 第二个参数是包含我们的测试的箭头函数

  1. 我们将继续调用required函数,并使用包含空title属性的values参数:
test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
  const values: IValues = {
 title: ""
 };
 const result = required("title", values);
  // TODO: check the result is correct
});
  1. 我们在这个测试中的下一个任务是检查required函数的结果是否符合我们的期望。我们可以使用 Jest 的expect函数来做到这一点:
test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
  const values: IValues = {
    title: ""
  };
  const result = required("title", values);
  expect(result).toBe("This must be populated");
});

我们将要检查的变量传递给expect函数。然后我们在其后链接一个toBe匹配函数,它检查expect函数的结果是否与toBe函数提供的参数相同。

toBe是我们可以用来检查变量值的许多 Jest 匹配函数之一。完整的函数列表可以在jestjs.io/docs/en/expect找到。

  1. 现在我们的测试完成了,我们可以在终端中输入以下内容来运行测试:
npm test

这将启动 Jest 测试运行程序的观察模式,这意味着它将持续运行,在更改源文件时执行测试。

Jest 最终会找到我们的测试文件,执行我们的测试,并将结果输出到终端,如下所示:

  1. 让我们更改测试中的预期结果,使测试失败:
expect(result).toBe("This must be populatedX");

当我们保存测试文件时,Jest 会自动执行测试,并将失败输出到终端,如下所示:

Jest 为我们提供了有关失败的宝贵信息。它告诉我们:

  • 哪个测试失败了

  • 预期结果与实际结果的比较

  • 我们测试代码中发生失败的那一行

这些信息帮助我们快速解决测试失败。

  1. 在继续之前,让我们纠正我们的测试代码:
expect(result).toBe("This must be populated");

当我们保存更改时,测试现在应该通过。

了解 Jest 观察选项

在 Jest 执行我们的测试后,它会提供以下选项:

> Press f to run only failed tests.
> Press o to only run tests related to changed files.
> Press p to filter by a filename regex pattern.
> Press t to filter by a test name regex pattern.
> Press q to quit watch mode.
> Press Enter to trigger a test run.

这些选项让我们指定应该执行哪些测试,这对于测试数量增加时非常有用。让我们探索一些这些选项:

  1. 如果我们按下F,Jest 将只执行失败的测试。在我们的代码中,我们得到确认我们没有失败的测试:

  1. 让我们按下F键退出此选项,并返回到所有可用的选项。

  2. 现在,让我们按下P。这允许我们测试特定文件或与正则表达式模式匹配的文件集合。当提示输入文件名模式时,让我们输入form

我们在Form.test.tsx中的测试将会被执行。

  1. 我们将保留文件名过滤器并按T。这将允许我们通过测试名称添加额外的过滤器。让我们输入required

我们对required函数的测试将会被执行。

  1. 要清除过滤器,我们可以按C

如果我们收到错误信息——watch 不支持没有 git/hg,请使用--watchAll,这是因为我们的项目不在 Git 存储库中。我们可以通过在终端中输入git init命令来解决这个问题。

我们已经很好地掌握了可用于执行测试的选项。

为单元测试结果添加结构

随着我们实施更多的单元测试,将单元测试结果添加一些结构是很有用的,这样我们就可以更容易地阅读它们。有一个名为describe的 Jest 函数,我们可以用它来将某些测试的结果分组在一起。如果一个函数的所有测试都被分组在一起,可能会更容易阅读测试结果。

让我们这样做,并使用 Jest 中的describe函数重构我们之前创建的单元测试:

describe("required", () => {
  test("When required called with title being an empty string, an error should be 'This must be populated'", () => {
    const values: IValues = {
      title: ""
    };
    const result = required("title", values);
    expect(result).toBe("This must be populated");
  });
});

describe 函数接受两个参数:

  • 第一个参数是测试组的标题。我们已经为此使用了我们正在测试的函数名称。

  • 第二个参数是包含要执行的测试的箭头函数。我们已经将我们的原始测试放在这里。

当我们保存我们的测试文件时,测试将自动运行,并且我们改进的输出将显示在终端上,测试结果显示在required标题下:

我们开始熟悉 Jest,已经实现并执行了一个单元测试。在下一节中,我们将继续进行更复杂的单元测试组件的主题。

测试组件

组件的单元测试是具有挑战性的,因为组件具有依赖项,如浏览器的 DOM 和 React 库。在我们进行必要的检查之前,我们如何在测试代码中渲染组件?在编写用户交互的代码时,如何触发 DOM 事件,比如点击按钮?

我们将在本节中回答这些问题,通过对我们在 React 商店中创建的ContactUs组件进行一些测试来实现。

创建一个基本组件测试

我们将首先创建一个单元测试,以验证在不填写字段的情况下提交“联系我们”表单会在页面上显示错误:

  1. 我们将对ContactUs组件进行单元测试。我们将首先在src文件夹中创建一个名为ContactUs.test.tsx的文件。

  2. 我们将使用ReactDOM来渲染ContactUs组件的测试实例。让我们导入ReactReactDOM

import React from "react";
import ReactDOM from "react-dom";
  1. 我们将模拟表单提交事件,因此让我们从 React 测试工具中导入Simulate函数:
import { Simulate } from "react-dom/test-utils";
  1. 现在让我们导入需要测试的组件:
import ContactUs from "./ContactUs";
  1. 我们还需要从Form.tsx中导入提交结果接口:
import { ISubmitResult } from "./Form";
  1. 让我们开始使用 Jest 的test函数创建我们的测试,并将结果输出到ContactUs组。
describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    // TODO - implement the test
  });
});
  1. 我们测试实现中的第一个任务是在 DOM 中创建我们的 React 组件:
test("When submit without filling in fields should display errors", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };

 const container = document.createElement("div");
 ReactDOM.render(<ContactUs onSubmit={handleSubmit} />, container);

 // TODO - submit the form and check errors are shown

 ReactDOM.unmountComponentAtNode(container);
});

首先,我们创建一个容器div标签,然后将我们的ContactUs组件渲染到其中。我们还为onSubmit属性创建了一个处理程序,它返回成功。测试中的最后一行通过移除测试中创建的 DOM 元素来进行清理。

  1. 接下来,我们需要获取对表单的引用,然后提交它:
ReactDOM.render(<ContactUs onSubmit={handleSubmit} />, container);

const form = container.querySelector("form");
expect(form).not.toBeNull();
Simulate.submit(form!);

// TODO - check errors are shown

ReactDOM.unmountComponentAtNode(container);

以下是一步一步的描述:

  • 我们使用querySelector函数,传入form标签来获取对form标签的引用。

  • 然后我们通过使用 Jest 的expect函数和nottoBeNull函数链式调用来检查表单是否不是null

  • 使用 React 测试工具中的Simulate函数来模拟submit事件。我们在form变量后面使用!来告诉 TypeScript 编译器它不是null

  1. 我们的最终任务是检查验证错误是否显示:
Simulate.submit(form!);

const errorSpans = container.querySelectorAll(".form-error");
expect(errorSpans.length).toBe(2);

ReactDOM.unmountComponentAtNode(container);

让我们一步一步来看:

  • 我们在容器 DOM 节点上使用querySelectorAll函数,传入一个 CSS 选择器来查找应该包含错误的span标签

  • 然后我们使用 Jest 的expect函数来验证页面上显示了两个错误

  1. 当测试运行时,它应该成功通过,给我们两个通过的测试:

在这个测试中,Jest 在一个虚拟 DOM 中渲染组件。表单submit事件也是通过标准的 React 测试工具中的simulate函数模拟的。因此,为了方便交互式组件测试,需要进行大量的模拟。

还要注意的是,我们在测试代码中引用了内部实现细节。我们引用了一个form标签,以及一个form-errorCSS 类。如果我们以后将此 CSS 类名称更改为contactus-form-error,我们的测试将会失败,而我们的应用可能并没有问题。

这被称为false positive,并且可以使具有这些测试的代码库非常耗时。

使用 react-testing-library 改进我们的测试

react-testing-library 是一组工具,帮助我们为 React 组件编写可维护的测试。它主要帮助我们从测试代码中删除实现细节。

我们将使用这个库来从我们的测试代码中删除 CSS 类引用,以及与 React 事件系统的紧耦合。

安装 react-testing-library

让我们首先通过终端安装react-testing-library作为开发依赖:

npm install --save-dev react-testing-library

几秒钟后,这将被添加到我们的项目中。

从我们的测试中删除 CSS 类引用

我们将通过删除对form-errorCSS 类的依赖来改进我们的测试。相反,我们将通过错误文本获取错误的引用,这是用户在屏幕上看到的,而不是实现细节:

  1. 我们将从react-testing-library导入一个render函数,现在我们将使用它来渲染我们的组件。我们还将导入一个cleanup函数,在测试结束时我们将使用它来从 DOM 中移除测试组件:
import { render, cleanup} from "react-testing-library";
  1. 我们可以使用我们刚刚导入的render函数来渲染我们的组件,而不是使用ReactDOM.render,如下所示:
test("When submit without filling in fields should display errors", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
    return {
      success: true
    };
  };
  const { container, getAllByText } = render(
 <ContactUs onSubmit={handleSubmit} />
 );

  const form = container.querySelector("form");
  ...
});

我们将容器 DOM 节点存储在container变量中,还有一个getallByText函数,我们将使用它来获取显示的错误的引用。

  1. 现在让我们使用getAllByText函数来获取页面上显示的错误:
Simulate.submit(form!);

const errorSpans = getAllByText("This must be populated");
expect(errorSpans.length).toBe(2);
  1. 我们要做的最后一项更改是在测试结束时使用我们刚刚导入的cleanup函数清理我们的 DOM,而不是ReactDOM.unmountComponentAtNode。我们还将在 Jest 的afterEach函数中执行此操作。我们完成的测试现在应该如下所示:
afterEach(cleanup);

describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    const handleSubmit = async (): Promise<ISubmitResult> => {
      return {
        success: true
      };
    };
    const { container, getAllByText } = render(
      <ContactUs onSubmit={handleSubmit} />
    );

    const form = container.querySelector("form");
    expect(form).not.toBeNull();
    Simulate.submit(form!);

    const errorSpans = getAllByText("This must be populated");
    expect(errorSpans.length).toBe(2);
  });
});

当测试运行时,它应该仍然正常执行,并且测试应该通过。

使用fireEvent进行用户交互

我们现在将转而依赖于本机事件系统,而不是 React 的事件系统,后者位于其之上。这使我们更接近测试用户在使用我们的应用时发生的情况,并增加了我们对测试的信心:

  1. 让我们首先通过从react-testing-library导入语句中添加fireEvent函数:
import { render, cleanup, fireEvent } from "react-testing-library";
  1. 我们将在对render函数的调用中解构变量时添加getByText函数:
const { getAllByText, getByText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

我们还可以删除解构的container变量,因为它将不再需要。

  1. 然后,我们可以使用此函数获取对提交按钮的引用。之后,我们可以使用我们导入的fireEvent函数来点击按钮:
const { getAllByText, getByText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

const submitButton = getByText("Submit");
fireEvent.click(submitButton);

const errorSpans = getAllByText("This must be populated");
expect(errorSpans.length).toBe(2);

之前引用form标签的代码现在已经被移除。

当测试运行时,它仍然通过。

因此,我们的测试引用用户看到的项目,而不是实现细节,并且不太可能出现意外中断。

为有效的表单提交创建第二个测试

现在我们已经掌握了如何编写健壮测试的要领,让我们添加第二个测试,检查当表单填写不正确时是否不显示验证错误:

  1. 我们将从我们的ContactUs组中创建一个新的测试:
describe("ContactUs", () => {
  test("When submit without filling in fields should display errors", () => {
    ...
  });

  test("When submit after filling in fields should submit okay", () => {
 // TODO - render component, fill in fields, submit the form and check there are no errors
 });
});
  1. 我们将以与第一个测试相同的方式渲染组件,但是解构稍有不同的变量:
test("When submit after filling in fields should submit okay", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };
 const { container, getByText, getByLabelText } = render(
 <ContactUs onSubmit={handleSubmit} />
 );
});

现在:

  • 我们将需要container对象来检查是否显示了任何错误

  • 我们将使用getByText函数来定位提交按钮

  • 我们将使用getByLabelText函数来获取对我们输入的引用

  1. 我们现在可以使用getByLabelText函数获取对名称输入的引用。之后,我们进行一些检查,以验证名称输入确实存在:
const { container, getByText, getByLabelText } = render(
  <ContactUs onSubmit={handleSubmit} />
);

const nameField: HTMLInputElement = getByLabelText(
 "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
  1. 然后,我们需要模拟用户填写此输入。我们通过调用本机的change事件来实现这一点,传入所需的事件参数,其中包括我们的输入值:
const nameField: HTMLInputElement = getByLabelText(
  "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
fireEvent.change(nameField, {
 target: { value: "Carl" }
});

我们已经模拟了用户将名称字段设置为Carl

在调用getByLabelText后,我们使用类型断言来通知 TypeScript 编译器返回的元素是HTMLInputElement类型,这样我们就不会得到编译错误。

  1. 然后我们可以按照相同的模式填写电子邮件字段:
const nameField: HTMLInputElement = getByLabelText(
  "Your name"
) as HTMLInputElement;
expect(nameField).not.toBeNull();
fireEvent.change(nameField, {
  target: { value: "Carl" }
});

const emailField = getByLabelText("Your email address") as HTMLInputElement;
expect(emailField).not.toBeNull();
fireEvent.change(emailField, {
 target: { value: "carl.rippon@testmail.com" }
});

在这里,我们模拟用户将电子邮件字段设置为carl.rippon@testmail.com

  1. 然后,我们可以通过点击提交按钮来提交表单,就像我们第一次测试时一样:
fireEvent.change(emailField, {
  target: { value: "carl.rippon@testmail.com" }
});

const submitButton = getByText("Submit");
fireEvent.click(submitButton); 
  1. 我们的最后任务是验证屏幕上没有显示错误。不幸的是,我们不能像上次测试中使用getAllByText函数,因为这个函数期望至少找到一个元素,而在我们的情况下,我们期望没有元素。因此,在进行此检查之前,我们将在错误周围添加一个包装的div标签。让我们去Form.tsx并做这个:
{context.errors[name] && context.errors[name].length > 0 && (
 <div data-testid="formErrors">
    {context.errors[name].map(error => (
      <span key={error} className="form-error">
        {error}
      </span>
    ))}
  </div>
)}

我们给div标签添加了一个data-testid属性,我们将在我们的测试中使用它。

  1. 让我们回到我们的测试。我们现在可以使用data-testid属性定位围绕错误的div标签。然后我们可以验证这个div标签是null,因为没有显示错误:
fireEvent.click(submitButton); 

const errorsDiv = container.querySelector("[data-testid='formErrors']");
expect(errorsDiv).toBeNull();

当测试在我们的测试套件中运行时,我们会发现现在有三个通过的测试。

不过,引用data-testid属性是一个实现细节,对吗?用户看不到或关心data-testid属性,这似乎与我们之前说的相矛盾。

这有点是一个实现细节,但它是专门为我们的测试而设计的。因此,实现重构不太可能意外地破坏我们的测试。

在下一节中,我们将添加另一个测试,这次使用 Jest 快照测试。

使用 Jest 快照测试

快照测试是 Jest 将渲染组件的所有元素和属性与先前渲染组件的快照进行比较的测试。如果没有差异,那么测试通过。

我们将添加一个测试来验证ContactUs组件是否正常渲染,通过使用 Jest 快照测试来检查 DOM 节点:

  1. 我们将在ContactUs测试组中创建一个标题为“渲染正常”的测试,以与以前相同的方式渲染组件:
describe("ContactUs", () => {
  ...
  test("Renders okay", () => {
 const handleSubmit = async (): Promise<ISubmitResult> => {
 return {
 success: true
 };
 };
 const { container } = render(<ContactUs onSubmit={handleSubmit} />);

 // TODO - do the snapshot test
 });
});
  1. 现在我们可以添加一行来执行快照测试:
test("Renders okay", () => {
  const handleSubmit = async (): Promise<ISubmitResult> => {
    return {
      success: true
    };
  };
  const { container } = render(<ContactUs onSubmit={handleSubmit} />);

  expect(container).toMatchSnapshot();
});

进行快照测试非常简单。我们将要比较的 DOM 节点传递给 Jest 的expect函数,然后在其后链接toMatchSnapshot函数。

当测试运行时,我们将在终端中得到快照已被写入的确认,如下所示:

  1. 如果我们查看我们的src文件夹,我们会看到现在包含一个__snapshots__文件夹。如果我们查看这个文件夹,我们会看到一个名为ContactUs.test.tsx.snap的文件。打开文件,我们会看到以下内容:
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ContactUs Renders okay 1`] = `
<div>
  <form
    class="form"
    novalidate=""
  >
    <div
      class="form-group"
    >
      <label
        for="name"
      >
        Your name
      </label>
      <input
        id="name"
        type="text"
        value=""
      />
    </div>
    ...
  </form>
</div>
`;

这个片段中有一些内容被剥离了,但我们明白:我们从传入toMatchSnapshot函数的container元素中得到了每个 DOM 节点的副本,包括它们的属性。

不过,这个测试与我们的实现紧密耦合。因此,对 DOM 结构或属性的任何更改都将破坏我们的测试。

  1. 举个例子,在Form.tsx中的Form组件中添加一个div标签:
<form ...>
  <div>{this.props.children}</div>
  ...
</form>

当测试运行时,我们将看到确认我们的测试已经失败。Jest 在终端中很好地显示了差异:

  1. 我们很高兴这是一个有效的改变,所以我们可以按U让 Jest 更新快照:

那么,快照测试是好事还是坏事呢?它们是不稳定的,因为它们与组件的实现紧密耦合。但是它们非常容易创建,当它们出现问题时,Jest 会很好地突出显示问题区域,并允许我们有效地纠正测试快照。它们非常值得一试,看看你的团队是否从中获得价值。

在本章中,我们已经学到了很多关于单元测试 React 和 TypeScript 应用程序的知识。接下来,我们将学习如何模拟依赖关系。

模拟依赖

模拟组件的依赖关系可以使组件更容易测试。但是,如果我们模拟了太多东西,测试是否真的在验证组件在我们的真实应用程序中是否能正常工作呢?

确定要模拟的内容是编写单元测试时最困难的任务之一。有一些东西是有意义模拟的,比如 REST API。REST API 是前端和后端之间的一个相当固定的契约。模拟 REST API 也可以让我们的测试运行得又快又顺利。

在这一部分,我们最终将学习如何模拟使用axios进行的 REST API 调用。不过首先,我们将学习 Jest 的函数模拟功能。

在 Jest 中使用模拟函数

我们将对验证提交“联系我们”表单而未填写字段会导致页面显示错误的测试进行另一个改进。我们将添加一个额外的检查,以确保提交处理程序不会被执行:

  1. 让我们回到我们编写的第一个组件测试:ContactUs.test.tsx。我们手动创建了一个handleSubmit函数,我们在ContactUs组件的实例中引用了它。让我们将其更改为 Jest 模拟函数:
const handleSubmit = jest.fn();

我们的测试将像以前一样正确运行,但这次是 Jest 为我们模拟函数。

  1. 现在 Jest 正在模拟提交处理程序,我们可以在测试结束时检查它是否被调用。我们使用nottoBeCalled Jest 匹配函数来做到这一点:
const errorSpans = container.querySelectorAll(".form-error");
expect(errorSpans.length).toBe(2);

expect(handleSubmit).not.toBeCalled();

这真的很好,因为我们不仅简化了我们的提交处理程序函数,而且还很容易地添加了一个检查来验证它是否被调用。

让我们继续实施的第二个测试,验证Contact Us表单是否被正确提交:

  1. 我们将再次更改handleSubmit变量以引用 Jest 模拟函数:
const handleSubmit = jest.fn();
  1. 让我们验证提交处理程序是否被调用。我们使用toBeCalledTimes Jest 函数传入我们期望函数被调用的次数,这在我们的情况下是1
const errorsDiv = container.querySelector("[data-testid='formErrors']");
expect(errorsDiv).toBeNull();

expect(handleSubmit).toBeCalledTimes(1);

当测试执行时,它仍应该通过。

  1. 还有一个有用的检查我们可以做。我们知道提交处理程序正在被调用,但它是否有正确的参数?我们可以使用toBeCalledWith Jest 函数来检查这一点:
expect(handleSubmit).toBeCalledTimes(1);
expect(handleSubmit).toBeCalledWith({
 name: "Carl",
 email: "carl.rippon@testmail.com",
 reason: "Support",
 notes: ""
});

同样,当测试执行时,它仍应该通过。

因此,通过让 Jest 模拟我们的提交处理程序,我们很快为我们的测试添加了一些有价值的额外检查。

使用axios-mock-adapter模拟 Axios

我们将转移到我们在第九章中创建的项目,与 Restful API 交互。我们将添加一个测试,验证帖子是否正确呈现在页面上。我们将模拟 JSONPlaceholder REST API,这样我们就可以控制返回的数据,使我们的测试可以顺利快速地执行:

  1. 首先,我们需要安装axios-mock-adapter包作为开发依赖:
npm install axios-mock-adapter --save-dev
  1. 我们还将安装react-testing-library
npm install react-testing-library --save-dev
  1. 项目已经有一个测试文件App.test.tsx,其中包括对App组件的基本测试。我们将删除测试,但保留导入,因为我们需要这些。

  2. 此外,我们将从 react-testing-library 导入一些函数,axios和一个MockAdapter类,我们将使用它来模拟 REST API 调用:

import { render, cleanup, waitForElement } from "react-testing-library";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
  1. 让我们在每个测试后添加通常的清理行:
afterEach(cleanup);
  1. 我们将使用适当的描述创建我们的测试,并将其放在App组下:
describe("App", () => {
  test("When page loads, posts are rendered", async () => {

    // TODO - render the app component with a mock API and check that the posts in the rendered list are as expected
      });
});

请注意,arrow函数标有async关键字。这是因为我们最终会在测试中进行异步调用。

  1. 我们在测试中的第一项工作是使用MockAdapter类模拟 REST API 调用:
test("When page loads, posts are rendered", async () => {
    const mock = new MockAdapter(axios);
 mock.onGet("https://jsonplaceholder.typicode.com/posts").reply(200, [
 {
 userId: 1,
 id: 1,
 title: "title test 1",
 body: "body test 1"
 },
 {
 userId: 1,
 id: 2,
 title: "title test 2",
 body: "body test 2"
 }
 ]);
});

我们使用onGet方法来定义调用获取帖子的 URL 时所需的响应 HTTP 状态码和主体。因此,对 REST API 的调用应该返回包含我们的测试数据的两个帖子。

  1. 我们需要检查帖子是否正确渲染。为了做到这一点,我们将在App.tsx中的无序帖子列表中添加data-testid属性。我们只在有数据时才会渲染这个。
{this.state.posts.length > 0 && (
  <ul className="posts" data-testid="posts">
    ...
  </ul>
)}
  1. 在我们的测试中,我们现在可以渲染组件并解构getByTestId函数:
mock.onGet("https://jsonplaceholder.typicode.com/posts").reply(...);
const { getByTestId } = render(<App />);
  1. 我们需要检查渲染的帖子是否正确,但这很棘手,因为这些是异步渲染的。我们需要在进行检查之前等待帖子列表被添加到 DOM 中。我们可以使用 react-testing-library 中的waitForElement函数来实现这一点:
const { getByTestId } = render(<App />);
const postsList: any = await waitForElement(() => getByTestId("posts"));

waitForElement函数接受一个箭头函数作为参数,然后返回我们正在等待的元素。我们使用getByTestId函数获取帖子列表,它使用data-testid属性找到它。

  1. 然后,我们可以使用快照测试来检查帖子列表中的内容是否正确:
const postsList: any = await waitForElement(() => getByTestId("posts"));
expect(postsList).toMatchSnapshot();
  1. 在我们的测试可以成功执行之前,我们需要在tsconfig.json中进行更改,以便 TypeScript 编译器知道我们正在使用asyncawait
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "es2015"],
    ...
  },
  "include": ["src"]
}

当测试执行时,将创建快照。如果我们检查快照,它将包含两个包含我们告诉 REST API 返回的数据的列表项。

我们已经了解了 Jest 和 react-testing-library 中一些很棒的功能,这些功能帮助我们编写可维护的纯函数和 React 组件的测试。

然而,我们如何知道我们的应用程序的哪些部分由单元测试覆盖了,更重要的是,哪些部分没有覆盖?我们将在下一节中找出答案。

获取代码覆盖率

代码覆盖率是指我们的应用代码有多少被单元测试覆盖。当我们编写单元测试时,我们会对覆盖了哪些代码和哪些代码没有覆盖有一个大致的了解,但随着应用的增长和时间的推移,我们会失去对此的追踪。

Jest 带有一个很棒的代码覆盖工具,所以我们不必记住哪些代码被覆盖了。在本节中,我们将使用这个工具来发现我们在上一节中工作的项目中的代码覆盖情况,我们在那里模拟了axios

  1. 我们的第一个任务是添加一个npm脚本,该脚本将在打开覆盖跟踪工具时运行测试。让我们添加一个名为test-coverage的新脚本,其中包括在执行react-scripts时使用--coverage选项:
"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build",
  "test": "react-scripts test",
  "test-coverage": "react-scripts test --coverage",
  "eject": "react-scripts eject"
},
  1. 然后我们可以在终端中运行这个命令:
npm run test-coverage

几秒钟后,Jest 将在终端上呈现每个文件的高级覆盖统计信息:

  1. 如果我们查看项目文件结构,我们会看到一个coverage文件夹已经添加了一个lcov-report文件夹。lcov-report文件夹中有一个index.html文件,其中包含了每个文件的覆盖率的更详细信息。让我们打开它并看一看:

我们看到了与终端中呈现的相同信息。

这四列统计数据的含义是什么?

  • Statements列显示了代码中执行了多少个语句

  • Branches列显示了代码中条件语句中执行了多少分支

  • Function列显示了代码中调用了多少个函数

  • Line列显示了代码中执行了多少行。通常,这将与Statements数字相同。但是,如果将多个语句放在一行上,它可能会有所不同。例如,以下内容被计为一行,但包含两个语句:

let name = "Carl"; console.log(name);
  1. 我们可以深入到每个文件中找出哪些具体的代码没有被覆盖。让我们点击App.tsx链接:

左侧带有绿色背景的1x表示这些代码行已被我们的测试执行了一次。红色高亮显示的代码是我们的测试未覆盖到的代码。

因此,获取覆盖率统计信息和确定我们可能想要实现的其他测试是相当容易的。这是非常值得使用的,可以让我们确信我们的应用程序经过了充分的测试。

总结

在本章中,我们学会了如何使用 Jest 测试用 TypeScript 编写的纯函数。我们只需使用我们想要测试的参数执行函数,并使用 Jest 的expect函数与 Jest 的匹配器函数之一,比如toBe,来验证结果。

我们看了如何与 Jest 的测试运行器交互,以及如何应用过滤器,以便只执行我们关注的测试。我们了解到测试 React 和 TypeScript 组件比测试纯函数更复杂,但 Jest 和 react-testing-library 为我们提供了很大的帮助。

我们还学会了如何使用render函数渲染组件,以及如何使用各种函数与检查元素进行交互,比如来自 react-testing-library 的getByTextgetLabelByText

我们学会了如何使用 react-testing-library 中的waitForElement函数轻松测试异步交互。我们现在明白了在测试中不引用实现细节的好处,这将帮助我们构建更健壮的测试。

我们还讨论了 Jest 的巧妙快照测试工具。我们看到这些测试经常会出问题,但也知道它们非常容易创建和更改的原因。

模拟和监视函数的能力是另一个我们现在了解的很棒的 Jest 功能。检查组件事件处理程序的函数是否以正确的参数被调用,确实可以为我们的测试增加价值。

我们讨论了axios-mock-adapter库,我们可以用它来模拟axios REST API 请求。这使我们能够轻松测试与 RESTful API 交互的容器组件。

我们现在知道如何快速确定我们需要实现的额外测试,以确保我们的应用程序经过了充分的测试。我们创建了一个npm脚本命令来实现这一点,使用react-scripts--coverage选项。

总的来说,我们现在具有知识和工具,可以使用 Jest 为我们的应用程序稳健地创建单元测试。

Jasmine 和 Mocha 是两个流行的替代测试框架,与 Jest 相比的一个巨大优势是它被create-react-app配置为开箱即用。如果我们想使用它们,我们将不得不手动配置 Jasmine 和 Mocha。然而,如果您的团队已经熟悉其中任何一个工具,而不是学习另一个测试框架,那么 Jasmine 和 Mocha 也值得考虑。

Enzyme 是另一个与 Jest 一起用于测试 React 应用程序的流行库。它支持浅渲染,这是一种仅渲染组件中顶层元素而不是子组件的方法。这是值得探索的,但请记住,我们模拟得越多,我们离真相就越远,我们对应用程序是否经过充分测试的信心就越少。

问题

  1. 假设我们正在实施一个 Jest 测试,并且我们有一个名为result的变量,我们想要检查它不是null。我们如何使用 Jest 匹配器函数来实现这一点?

  2. 假设我们有一个名为person的变量,类型为IPerson

interface IPerson {
  id: number;
  name: string;
}

我们想要检查person变量是否为{ id: 1, name: "bob" }。我们如何使用 Jest 匹配器函数来实现这一点?

  1. 在上一个问题中,我们是否可以使用 Jest 快照测试来进行我们的检查?如果可以,如何实现?

  2. 我们实现了一个名为CheckList的组件,它从数组中呈现文本列表。每个列表项都有复选框,以便用户可以选择列表项。该组件有一个名为onItemSelect的函数属性,当用户通过选中复选框选择项目时会调用该函数。我们正在实施一个测试来验证onItemSelect属性是否有效。以下代码行在测试中呈现组件:

const { container } = render(<SimpleList data={["Apple", "Banana", "Strawberry"]} onItemSelect={handleListItemSelect} />);

如何使用 Jest 模拟函数来处理handleListItemSelect并检查它是否被调用?

  1. 在上一个问题中的SimpleList的实现中,onItemSelect函数接受一个名为item的参数,该参数是用户选择的string值。在我们的测试中,假设我们已经模拟了用户选择Banana。我们如何检查onItemSelect函数是否被调用,并且参数为Banana

  2. 在上述两个问题中的SimpleList的实现中,文本使用一个标签显示,该标签使用for属性与复选框相关联。我们如何使用 react-testing-library 中的函数来首先定位Banana复选框,然后检查它?

  3. 在本章中,我们发现从 JSONPlaceholder REST API 渲染帖子的代码覆盖率很低。其中一个未覆盖的领域是在从 REST API 获取帖子时,在componentDidMount函数中处理 HTTP 错误代码。创建一个测试来覆盖代码的这一部分。

进一步阅读

以下资源对于查找有关单元测试 React 和 TypeScript 应用程序的更多信息很有用:

第十二章:答案

第一章:TypeScript 基础知识

  1. 有哪五种原始类型?
  • string:表示 Unicode 字符序列

  • number:表示整数和浮点数

  • boolean:表示逻辑真或假

  • undefined:表示尚未初始化的值

  • null:表示没有值

  1. 在以下代码中,flag变量的推断类型将是什么?
 const flag = false;

flag将被推断为boolean类型。

  1. 接口和类型别名之间有什么区别?

主要区别在于类型别名不能被扩展或实现,就像你可以使用接口一样。

  1. 以下是代码中的问题?
class Product {
  constructor(public name: string, public unitPrice: number) {}
}

let table = new Product();
table.name = "Table";
table.unitPrice = 700;

构造函数需要传递nameunitPrice。以下是解决问题的两种方法。

在构造函数中传递值:

let table = new Product("Table", 700);

使参数可选:

class Product {
  constructor(public name?: string, public unitPrice?: number) {}
}
  1. 如果我们希望我们的 TypeScript 程序支持 IE11,--target编译器选项应该是什么?

这应该是es5,因为 IE11 只支持 ES5 功能。

  1. 是否可能让 TypeScript 编译器转译 ES6 JavaScript 文件?如果可以,怎么做?

是的!我们可以使用--allowJS设置来让编译器转译 JavaScript 文件。

  1. 我们如何防止console.log()语句进入我们的代码?

我们可以使用 tslint 和"no-console"规则来强制执行。这将是tslint.json中的规则:

{
 "rules": {
 "no-console": true
 }
}

第二章:TypeScript 3 中的新功能

  1. 我们有一个绘制点的函数:
function drawPoint(x: number, y: number, z: number) {
  ...
}

我们还有以下point变量:

const point: [number, number, number] = [100, 200, 300];

我们如何以简洁的方式调用drawPoint函数?

drawPoint(...point);
  1. 我们需要创建drawPoint函数的另一个版本,可以通过传递 x、y 和 z 点值来调用:
drawPoint(1, 2, 3);

drawPoint的实现内部,我们从元组数据类型[number, number, number]中绘制点。如何定义具有所需元组的方法参数?

function drawPoint(...point: [number, number, number]) {
  ...
}
  1. 在你的drawPoint实现中,如何使z点可选?
function drawPoint(...point: [number, number, number?]) {
  ...
}
  1. 我们有一个名为getData的函数,调用 Web API 获取一些数据。不同的 API 资源数量仍在增长,所以我们选择使用any作为返回类型:
function getData(resource: string): any {
  const data = ... // call the web API
  if (resource === "person") {
    data.fullName = `${data.firstName} ${data.surname}`;
  }
  return data;
}

我们如何通过利用unknown类型使getData更加类型安全?

class Person {
 firstName: string;
 surname: string;
 fullName: string;
}

function getData(resource: string): unknown {
  const data = {};
  if (data instanceof Person) {
    data.fullName = `${data.firstName} ${data.surname}`;
  }
  return data;
}
  1. 我们可以使用哪个build标志来确定哪些项目已过时并需要重建,而无需进行重建?
tsc --build ... --dry --verbose 

第三章:使用 React 和 TypeScript 入门

  1. 在开发过程中,允许调试器语句和记录到控制台的TSLint设置是什么?
"rules": {
  "no-debugger": false,
  "no-console": false,
},
  1. 在 JSX 中,如何在类组件中显示一个名为buttonLabel的 prop 的按钮标签?
<button>{this.props.buttonLabel}</button>
  1. 我们如何使buttonLabel属性可选并默认为 Do It?

在接口的类型注释之前使用?

interface IProps {
  buttonLabel?: string
}

在类组件的顶部实现一个静态defaultProps对象:

public static defaultProps = {
  buttonLabel: "Do it"
};
  1. 在 JSX 中,如果名为doItVisible的状态为 true,我们如何只显示前面的按钮?假设我们已经声明了一个包含doItVisible的状态类型,并且它已经在构造函数中初始化了。
{this.state.doItVisible && <button>{this.props.buttonLabel}</button>}
  1. 我们如何为这个按钮创建一个点击处理程序?
<button onClick={this.handleDoItClick}>{this.props.buttonLabel}</button>
private handleDoItClick = () => {
  // TODO: some stuff!
};
  1. 我们声明了一个包含doItDisabled的状态类型。它也在构造函数中初始化了。我们如何在点击后将此状态设置为禁用“Do it”按钮?
private handleDoItClick = () => {
  this.setState({doItDisabled: true})
};
<button disabled={this.state.doItDisabled}>{this.props.buttonLabel}</button>
  1. 如果按钮被禁用后被点击,点击处理程序仍然会被调用吗?

  1. 在类组件中,哪个生命周期方法会被用来给我们的 React 组件中的非 React web 组件添加事件处理程序?

componentDidMount

  1. 然后我们会使用哪个生命周期方法来移除这个事件处理程序?

componentWillUnmount

  1. 我们有一个名为Counter的函数组件。它需要包含一个名为count的状态和一个名为setCount的更新函数。我们如何定义这个状态并将初始计数默认为 10?
const count, setCount = React.useState(10);
  1. 在前面的Counter组件中,我们有一个decrement函数,需要将count减 1:
const decrement = () => {
  // TODO - reduce count by 1
};

这可以如何实现?

const decrement = () => {
  setCount(count - 1);
};

第四章:使用 React Router 进行路由

  1. 我们有以下显示客户列表的Route
<Route path="/customers" component={CustomersPage} />

当页面是"/customers"时,CustomersPage组件会被渲染吗?

是的

  1. 当页面是"/customers/24322"时,CustomersPage组件会被渲染吗?

是的

  1. 我们只希望当路径是"/customers"时才渲染CustomersPage组件。我们如何改变Route上的属性来实现这一点?

我们可以使用exact属性:

<Route exact={true} path="/customers" component={CustomersPage} />
  1. 什么样的Route可以处理路径"/customers/24322"?它应该将"24322"放在名为customerId的路由参数中:
<Route exact={true} path="/customers/:customerId" component={CustomerPage} />
  1. 然后我们可以将RouteComponentProps作为CustomerPage的 props 类型,并通过props.match.params.customerId访问customerId

我们如何捕获不存在的路径,以便通知用户?

确保所有的Route组件都包裹在一个Switch组件中。然后我们可以在 Switch 中添加一个渲染给用户一个未找到消息的组件作为最后一个Route

<Switch>
  <Route path="/customers/:customerId" component={CustomerPage} />
  <Route exact={true} path="/customers" component={CustomersPage} />
  <Route component={NotFoundPage} />
</Switch>
  1. 我们如何在CustomersPage中实现一个search查询参数?因此,"/customers/?search=Cool Company"将显示名称为 Cool Company 的客户。

首先,我们需要在我们的类中将 props 类型设置为RouteComponentProps

import { RouteComponentProps } from "react-router-dom";

class CustomersPage extends React.Component<RouteComponentProps, IState> { ... }

我们可以使用URLSearchParams来获取search查询参数,并在componentDidMount生命周期方法中进行搜索:

public componentDidMount() {
  const searchParams = new URLSearchParams(props.location.search);
  const search = searchParams.get("search") || "";

  const products = await ... // make web service call to do search

  this.setState({ products });
}
  1. 过了一会儿,我们决定将"customer"路径更改为"clients"。我们如何实现这一点,以便用户仍然可以使用现有的"customer"路径,但路径会自动重定向到新的"client"路径。

我们可以使用Redirect组件将旧路径重定向到新路径:

<Switch>
 <Route path="/clients/:customerId" component={CustomerPage} />
 <Route exact={true} path="/clients" component={CustomersPage} />

 <Redirect from="/customers/:customerId" to="/clients/:customerId" />
 <Redirect exact={true} from="/customers" to="/clients" />

 <Route component={NotFoundPage} />
</Switch>

第五章:高级类型

  1. 我们有一个表示课程结果的接口如下:
interface ICourseMark {
  courseName: string;
  grade: string;
}

我们可以如下使用这个接口:

const geography: ICourseMark = {
  courseName: "Geography",
  grade: "B"
} 

成绩只能是 A、B、C 或 D。我们如何在这个接口的grade属性中创建一个更强类型的版本?

我们可以使用联合类型:

interface ICourseMark {
  courseName: string;
  grade: "A" | "B" | "C" | "D";
}
  1. 我们有以下函数,用于验证数字和字符串是否具有值:
function isNumberPopulated(field: number): boolean {
  return field !== null && field !== undefined;
}

function isStringPopulated(field: string): boolean {
  return field !== null && field !== undefined && field !== "";
}

我们如何将这些组合成一个名为isPopulated的单一函数,带有签名重载?

我们可以在主函数中使用重载签名,然后使用联合类型来处理field的不同分支逻辑:

function isPopulated(field: number): boolean 
function isPopulated(field: string): boolean
function isPopulated(field: number | string): boolean {
 if (typeof field === "number") {
 return field !== null && field !== undefined;
 } else {
 return field !== null && field !== undefined && field !== "";
 }
}
  1. 我们如何使用泛型实现一个更灵活的isPopulated函数版本?

我们可以使用带有typeof类型保护的通用函数来处理字符串的特殊代码分支:

function isPopulated<T>(field: T): boolean {
  if (typeof field === "string") {
    return field !== null && field !== undefined && field !== "";
  } else {
    return field !== null && field !== undefined;
  }
} 
  1. 我们有一个名为 stages 的类型别名:
type Stages = {
  pending: 'Pending',
  started: 'Started',
  completed: 'Completed',
};

我们如何以编程方式将其转换为联合类型'Pending' | 'Started' | 'Completed'

我们可以使用keyof关键字:

type StageUnion = keyof Stages
  1. 我们有以下联合类型:
type Grade = 'gold' | 'silver' | 'bronze';

我们如何以编程方式创建以下类型?

type GradeMap = {
  gold: string;
  silver: string;
  bronze: string
};

我们可以按以下方式映射类型:

type GradeMap = { [P in Grade]: string }

第六章:组件模式

  1. React 给我们提供了什么特殊属性来访问组件的子元素?

一个名为children的属性

  1. 有多少个组件可以共享 React 上下文的状态?

在组件层次结构中提供者组件下,我们可以有多少个组件?

  1. 在消费 React 上下文时,它使用什么模式允许我们使用上下文渲染我们的内容?

渲染 props 模式

  1. 一个组件中可以有多少个渲染 props?

尽可能多

  1. 一个组件中有多少个子元素 props?

1

  1. 我们只在产品页面上使用了withLoader。我们在ProductData.ts中有以下函数来获取所有产品:
export const getProducts = async (): Promise<IProduct[]> => {
  await wait(1000);
  return products;
};

您能使用这个来通过使用withLoaderHOC 在产品页面上实现一个加载器旋转器吗?

首先,我们将ProductPage分成一个容器和一个展示组件。 展示组件将渲染产品列表,并将其导出为包裹在withLoaderHOC 中的组件:

import * as React from "react";
import { Link } from "react-router-dom";
import { IProduct } from "./ProductsData";
import withLoader from "./withLoader";

interface IProps {
  products: IProduct[];
  search: string;
}
const ProductList: React.SFC<IProps> = props => {
  const { products, search } = props;
  return (
    <ul className="product-list">
      {products.map(product => {
        if (
          !search ||
          (search &&
            product.name.toLowerCase().indexOf(search.toLowerCase()) > -1)
        ) {
          return (
            <li key={product.id} className="product-list-item">
              <Link to={`/products/${product.id}`}>{product.name}</Link>
            </li>
          );
        } else {
          return null;
        }
      })}
    </ul>
  );
};

export default withLoader(ProductList);

然后我们可以在ProductPage中使用它,如其render方法中所示:

public render() {
 return (
 <div className="page-container">
 <p>
 Welcome to React Shop where you can get all your tools for ReactJS!
 </p>
 <ProductList
 loading={this.state.loading}
 products={this.state.products}
 search={this.state.search}
 />
 </div>
 );
}
  1. 是否可能使用 children props 模式创建一个加载器旋转器? 因此,消费 JSX 将类似于这样:
<Loader loading={this.state.loading}>
  <div>
    The content for my component ...
  </div>
</Loader>

如果可以的话,试着实现一下?

是的

import * as React from "react";

interface IProps {
  loading: boolean;
}

const Loader: React.SFC<IProps> = props =>
  props.loading ? (
    <div className="loader-overlay">
      <div className="loader-circle-wrap">
        <div className="loader-circle" />
      </div>
    </div>
  ) : props.children ? (
    <React.Fragment>{props.children}</React.Fragment>
  ) : null;

export default Loader;

第七章:处理表单

  1. 扩展我们的通用Field组件,使用原生数字输入包括一个数字编辑器。
  • 首先,在type属性中添加"Number"
interface IFieldProps {
  ...
  type?: "Text" | "Email" | "Select" | "TextArea" | "Number";
}
  • 在渲染input时包括"Number"类型:
{(type === "Text" || type === "Email" || type === "Number") && (
  <input
    type={type.toLowerCase()}
    id={name}
    value={context.values[name]}
    onChange={e => handleChange(e, context)}
    onBlur={e => handleBlur(e, context)}
  />
)}
  1. 在“联系我们”表单上实现一个紧急性字段,以指示回复的紧急程度。 该字段应为数字。

在 notes 字段之后立即添加以下字段:

<Form.Field name="urgency" label="How urgent is a response?" type="Number" />
  1. 在通用Form组件中实现一个新的验证函数,验证一个数字是否在另外两个数字之间。

Form.tsx中添加以下函数:

export const between: Validator = (
  fieldName: string,
  values: IValues,
  bounds: { lower: number; upper: number }
): string =>
  values[fieldName] &&
  (values[fieldName] < bounds.lower || values[fieldName] > bounds.upper)
    ? `This must be between ${bounds.lower} and ${bounds.upper}`
    : "";
  1. 实现一个紧急性字段的验证规则,以确保它在 1 到 10 之间。
  • 首先在ContactUs.tsx中导入between验证器:
import { between, Form, ISubmitResult, IValues, minLength, required } from "./Form";
  • ContactUs.tsx中的validationRules属性中添加紧急性规则:
validationRules={{
  email: { validator: required },
  name: [{ validator: required }, { validator: minLength, arg: 3 }],
  urgency: [{ validator: between, arg: { lower: 1, upper: 10 } }]
}}
  1. 当用户在不输入任何内容的情况下点击并离开字段时,我们的验证会触发。 我们如何才能触发验证,当字段失去焦点但只有在它被更改时? 尝试实现一下这个。
  • 我们需要跟踪表单状态中的字段是否被触摸:
interface ITouched {
 [key: string]: boolean;
}

interface IState {
  touched: ITouched;
  ...
}
  • 我们在构造函数中将每个字段的touched值初始化为false
constructor(props: IFormProps) {
  super(props);
  const errors = {};
  const touched = {};
  Object.keys(props.defaultValues).forEach(fieldName => {
    errors[fieldName] = [];
    touched[fieldName] = false;
  });
  this.state = {
    errors,
    submitted: false,
    submitting: false,
    touched,
    values: props.defaultValues
  };
}
  • setValue方法中,我们更新正在更新的字段的touched值为true
private setValue = (fieldName: string, value: any) => {
  const newValues = { ...this.state.values, [fieldName]: 
   value };
  const newTouched = { ...this.state.touched, [fieldName]:  
   true };
  this.setState({ values: newValues, touched: newTouched });
};
  • 在 validate 方法的顶部,我们检查字段是否被触摸,如果没有,我们返回一个空数组来指示字段是有效的:
private validate = (fieldName: string, value: any): string[] => {
  if (!this.state.touched[fieldName]) {
    return [];
  }
  ...
};

第八章:React Redux

  1. 操作对象中的type属性是必需的吗? 这个属性需要被称为type吗? 我们可以称它为其他名称吗?

操作对象中type属性是必需的,并且必须称为type

  1. 操作对象可以包含多少属性?

我们可以有很多!它需要至少包括一个type属性。然后可以包括我们需要的其他属性,以便减少状态的改变,但这通常被合并在一个额外的属性中。因此,通常一个动作会有一个或两个属性。

  1. 什么是动作创建者?

动作创建者是返回动作对象的函数。组件调用这些函数以改变存储中的状态。

  1. 为什么我们在 React 商店应用程序的 Redux 存储中需要 Redux Thunk?

默认情况下,Redux 存储无法管理异步动作创建者。需要向 Redux 存储添加中间件以便促进异步动作创建者。Redux Thunk 是我们添加的中间件来实现这一点。

  1. 除了 Redux Thunk 之外,我们还能用其他东西吗?

是的!我们可以创建自己的中间件。还有其他成熟的库,比如 Redux Saga,我们也可以使用。

  1. 在我们刚刚实现的basketReducer中,为什么我们不直接使用push函数将项目添加到篮子状态中?高亮显示的行有什么问题?
export const basketReducer: Reducer<IBasketState, BasketActions> = (
  state = initialBasketState,
  action
) => {
  switch (action.type) {
    case BasketActionTypes.ADD: {
      state.products.push(action.product);
    }
  }
  return state || initialBasketState;
};

这直接改变了产品的状态,并使函数变得不纯。这是因为我们改变了状态参数,它存在于函数范围之外。在这种情况下违反了这个规则,导致当单击“添加到篮子”按钮时,篮子摘要在渲染页面上不会增加。

第九章:与 RESTful API 交互

  1. 如果我们在浏览器中运行以下代码,控制台会输出什么?
try {
 setInterval(() => {
  throw new Error("Oops");
 }, 1000);
} catch (ex) {
  console.log("Sorry, there is a problem", ex); 
}

我们会收到一条消息,说发生了一个未捕获的错误(Oops)。console.log语句将不会被执行。

  1. 假设帖子 9999 不存在,如果我们在浏览器中运行以下代码,控制台会输出什么:
fetch("https://jsonplaceholder.typicode.com/posts/9999")
  .then(response => {
    console.log("HTTP status code", response.status);
    return response.json();
  })
  .then(data => console.log("Response body", data))
  .catch (error => console.log("Error", error));

关键是,使用fetch函数时,HTTP 错误不会在catch方法中处理。

  1. 如果我们使用axios进行类似的练习,运行以下代码时控制台会输出什么?
axios
  .get("https://jsonplaceholder.typicode.com/posts/9999")
  .then(response => {
    console.log("HTTP status code", response.status);
  })
  .catch(error => {
    console.log("Error", error.response.status);
  });

关键是,HTTP 错误确实在catch方法中使用axios处理。

  1. 使用本机fetch而不是axios的好处是什么?

如果我们的目标是现代浏览器(而不是 IE),并且只需要简单的 REST API 交互,那么fetch可能比axios更受欢迎,因为我们的代码不依赖于第三方代码。它也可能运行得更快,因为执行的非本地代码更少。

  1. 如何在以下axios请求中添加一个 Bearer 令牌?
axios.get("https://jsonplaceholder.typicode.com/posts/1")
  1. 第二个参数是一个对象文字,其中有一个header属性,可以包含请求的 HTTP 标头:
axios
  .get("https://jsonplaceholder.typicode.com/posts/1", {
    headers: {
      "Authorization": `Bearer ${token}`
    }
  });
  1. 我们正在使用以下axiosPUT请求来更新帖子标题:
axios.put("https://jsonplaceholder.typicode.com/posts/1", {
  title: "corrected title", 
  body: "some stuff"
});

尽管主体没有改变 - 我们只是想要更新标题。我们如何将其更改为PATCH请求,以使此 REST 调用更有效?

axios.patch("https://jsonplaceholder.typicode.com/posts/1", {
  title: "corrected title"
});
  1. 我们已经实现了一个函数组件来显示一个帖子。它使用以下代码从 REST API 获取帖子:
React.useEffect(() => {
  axios
    .get(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .then(...)
    .catch(...);
});

上述代码有什么问题?

useEffect函数的第二个参数丢失,这意味着每次组件呈现时都会调用 REST API。应该提供一个空数组作为第二个参数,以便 REST API 仅在第一次呈现时被调用:

React.useEffect(() => {
  axios
    .get(`https://jsonplaceholder.typicode.com/posts/${id}`)
    .then(...)
    .catch(...);
}, []);

第十章:与 GraphQL API 交互

  1. 在 GitHub GraphQL Explorer 中,创建一个查询,返回 React 项目中最后五个未解决的问题。在响应中返回问题标题和 URL:
query { 
  repository (owner:"facebook", name:"react") {
    issues(last: 5, states:[OPEN]) {
      edges {
        node {
          title
          url
        }
      }
    }
  }
}
  1. 增强最后一个查询,使返回的问题数量成为一个参数,并将其默认值设置为 5:
query ($lastCount: Int = 5) { 
  repository (owner:"facebook", name:"react") {
    issues(last: $lastCount, states: [OPEN]) {
      edges {
        node {
          title
          url
        }
      }
    }
  }
}
  1. 在 GitHub GraphQL Explorer 中创建一个突变,取消对一个星标存储库的标记。该突变应该将存储库 ID 作为参数:
mutation ($repoId: ID!) {
  removeStar(input: { starrableId: $repoId }) {
    starrable {
      stargazers {
        totalCount
      }
    }
  }
}
  1. GraphQL 查询放在 HTTP 请求的哪一部分?

HTTP 主体

  1. GraphQL 突变放在 HTTP 请求的哪一部分?

HTTP 主体

  1. 我们如何使来自react-apolloQuery组件的响应类型安全?

创建另一个组件,扩展Query,将结果的类型作为泛型参数传入:

class MyQuery extends Query<IResult> {}

然后我们可以在 JSX 中使用MyQuery组件。

  1. 使用react-boost脚手架项目时,默认情况下是否启用缓存?

开启

  1. 我们可以在Mutation组件上使用哪个属性来更新本地缓存?

update属性。

第十一章:使用 Jest 进行单元测试

  1. 假设我们正在实现一个 Jest 测试,并且有一个名为result的变量,我们想检查它不是null。我们如何使用 Jest 匹配器函数来实现这一点?
expect(result).not.toBeNull()
  1. 假设我们有一个名为person的变量,类型为IPerson
interface IPerson {
  id: number;
  name: string;
}

我们想要检查person变量是否为{ id: 1, name: "bob" }。我们如何使用 Jest 匹配器函数来实现这一点?

expect(person).not.toBeEqual({ id: 1, name: "bob" });
  1. 在上一题中,我们是否可以通过 Jest 快照测试来进行我们的检查?如果可以,如何进行?

是的:

expect(person).toMatchSnapshot();
  1. 我们实现了一个名为CheckList的组件,它从数组中以列表的形式呈现文本。每个列表项都有一个复选框,以便用户可以选择列表项。该组件有一个名为onItemSelect的函数 prop,当用户通过选中复选框选择项目时会调用该函数。我们正在实施一个测试来验证onItemSelectprop 是否起作用。以下代码行在测试中呈现组件:
const { container } = render(<SimpleList data={["Apple", "Banana", "Strawberry"]} onItemSelect={handleListItemSelect} />);

我们如何使用 Jest 模拟函数来检查handleListItemSelect是否被调用?

const handleListItemSelect = jest.fn();
const { container } = render(<SimpleList data={["Apple", "Banana", "Strawberry"]} onItemSelect={handleListItemSelect} />);

// TODO - select the list item

expect(handleListItemSelect).toBeCalledTimes(1);
  1. 在上一题中的SimpleList的实现中,onItemSelect函数接受一个名为item的参数,这是用户选择的string值。在我们的测试中,假设我们已经模拟用户选择了"Banana"。我们如何检查onItemSelect函数是否被调用,并且参数为"Banana"
expect(handleListItemSelect).toBeCalledWith("Banana");
  1. 在上两题中的SimpleList的实现中,文本是使用与复选框绑定的标签显示的,使用了for属性。我们如何使用 React Testing Library 中的函数来首先定位"Banana"复选框,然后对其进行检查?
const checkbox = getByLabelText("Banana") as HTMLInputElement;
fireEvent.change(checkbox, {
  target: { checked: true }
});
  1. 在本章中,我们发现从 JSONPlaceholder REST API 渲染帖子的代码覆盖率很低。其中一个未覆盖的领域是在从 REST API 获取帖子时,在componentDidMount函数中处理 HTTP 错误代码。创建一个测试来覆盖这部分代码:
test("When the post GET request errors when the page is loaded, an error is shown", async () => {
  const mock = new MockAdapter(axios);
  mock.onGet("https://jsonplaceholder.typicode.com/posts").reply(404);

  const { getByTestId } = render(<App />);

  const error: any = await waitForElement(() => getByTestId("error"));

  expect(error).toMatchSnapshot();
});

需要向App组件代码添加一个测试 ID:

{this.state.error && <p className="error" data-testid="error">{this.state.error}</p>}
posted @ 2024-05-16 14:50  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报