React-和-TypeScript-学习指南-全-

React 和 TypeScript 学习指南(全)

原文:zh.annas-archive.org/md5/5da49be498b161721792aaa3c885dee9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React 是由 Meta 构建的,旨在为其代码库提供更多结构,并使其能够更好地扩展。React 在 Facebook 上表现得如此出色,以至于他们最终将其开源。如今,React 是构建应用前端的主导技术;它允许我们构建小型、隔离且高度可重用的组件,这些组件可以组合在一起以创建复杂的用户界面。

TypeScript 是由 Microsoft 构建的,旨在帮助开发者更轻松地开发基于 JavaScript 的大型程序。它是 JavaScript 的超集,为它带来了丰富的类型系统。这个类型系统帮助开发者早期捕捉到错误,并允许创建工具来稳健地导航和重构代码。

本书将教会你如何使用这两种技术来创建大型、复杂的用户界面,这些界面易于维护。

这本书面向谁

如果你是一名希望使用 React 和 TypeScript 创建大型和复杂前端开发的开发者,这本书适合你。本书不假设你之前有任何 React 或 TypeScript 的知识——然而,对 JavaScript、HTML 和 CSS 的基本了解将有助于你掌握所涵盖的概念。

本书涵盖的内容

第一章介绍 React,涵盖了构建 React 组件的基本原理。这包括使用 JSX 定义组件输出,使用 props 使组件可配置,以及使用状态使组件交互。

第二章介绍 TypeScript,全面讲述了 TypeScript 及其类型系统的基本原理。这包括使用内置类型以及创建新类型。

第三章设置 React 和 TypeScript,解释了如何创建用于 React 和 TypeScript 开发的工程。然后章节继续介绍如何创建使用 TypeScript 来使 props 和 states 类型安全的 React 组件。

第四章使用 React Hooks,详细介绍了常见的 React Hooks 及其典型用例。章节还涵盖了如何使用 TypeScript 使 Hooks 类型安全。

第五章React 前端样式化方法,介绍了如何使用几种不同的方法来样式化 React 组件。每种方法的优点也得到了探讨。

第六章使用 React Router 进行路由,介绍了一个流行的库,它为多页应用提供了客户端路由。它涵盖了如何声明页面的路径以及如何在这些页面之间创建链接。它还涵盖了如何为高度动态的页面实现页面参数。

第七章使用表单,探讨了如何使用几种不同的方法来实现表单,包括使用一个流行的库。每种方法的优点也包括在内。

第八章状态管理,介绍了如何在不同的组件之间共享状态。探讨了多种方法及其优点。

第九章与 RESTful API 交互,展示了 React 组件如何与 REST API 交互。章节逐步介绍了一种使用核心 React 的方法,然后是使用一个流行库的替代方法。

第十章与 GraphQL API 交互,展示了 React 组件如何与 GraphQL API 交互。章节详细介绍了如何使用两个不同的流行库来实现这一点。

第十一章可重用组件,引入了几个使 React 组件高度可重用但仍然类型安全的模式。

第十二章使用 Jest 和 React Testing Library 进行单元测试,首先深入探讨了如何使用 Jest 测试函数。然后章节转向如何借助 React Testing Library 测试 React 组件。

为了充分利用本书

为了充分利用本书,您需要了解 JavaScript 的基础知识,包括以下内容:

  • 理解一些原始的 JavaScript 类型,例如stringnumberbooleannullundefined

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

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

  • 理解如何使用ifelse关键字创建条件语句

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

  • 理解基本的 HTML 元素,如divulah1

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

理解基本的 CSS 也有帮助,包括以下内容:

  • 如何调整元素大小并包括边距和填充

  • 如何定位元素

  • 如何为元素着色

为了跟随本书的内容,您需要在您的计算机上安装以下技术:

本书涵盖的软件/硬件
React 18.0 或更高版本
TypeScript 4.7 或更高版本

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于避免与代码复制和粘贴相关的任何潜在错误

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/5CvU5

约定使用

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这里,null被传递,因为没有属性。”

代码块设置如下:

<div className=”title”>
  <span>Oh no!</span>
</div>

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

React.createElement(
  'span',
  null,
  title ? title : 'Something important'
);

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

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要提示

看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告此错误。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上遇到我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

读完Learn React with TypeScript (Second Edition)后,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?

不要担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您将获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接

二维码

https://packt.link/free-ebook/9781804614204

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的邮箱

第一部分:简介

本部分将帮助您开始学习 React 和 TypeScript,分别了解这两种技术的 fundamentals。然后我们将开始一起使用这些技术,以便我们能够创建强大的类型安全组件。我们还将详细了解 React 的常用 hooks 以及它们在应用程序中的使用情况。

本部分包括以下章节:

  • 第一章介绍 React

  • 第二章介绍 TypeScript

  • 第三章设置 React 和 TypeScript

  • 第四章使用 React Hooks

第一章:介绍 React

Facebook 已经成为一款非常流行的应用。随着其知名度的增长,对新增功能的需求也在增加。React 是 Facebook 为帮助更多人参与代码库并更快地交付功能而提供的解决方案。React 在 Facebook 的工作非常出色,以至于 Meta 最终将其开源。如今,React 是一个成熟的库,用于构建基于组件的前端,它非常受欢迎,拥有庞大的社区和生态系统。

TypeScript 也是一个由另一家大型公司,微软,维护的流行、成熟的库。它允许用户向他们的 JavaScript 代码添加丰富的类型系统,帮助他们提高生产力,尤其是在大型代码库中。

本书将教会你如何使用这两个出色的库来构建易于维护的健壮前端。本书的前两章将分别介绍 React 和 TypeScript。然后,你将学习如何将 React 和 TypeScript 结合起来,使用强类型来构建健壮的组件。本书涵盖了构建网络前端所需的关键主题,例如样式、表单和数据获取。

在本章中,我们将介绍 React 并了解它带来的好处。然后,我们将构建一个简单的 React 组件,学习 JSX 语法和组件属性。之后,我们将学习如何使用组件状态和事件使组件交互。在这个过程中,我们还将学习如何在 JavaScript 模块中组织代码。

在本章结束时,你将能够创建简单的 React 组件,并准备好学习如何使用 TypeScript 强类型化它们。

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

  • 理解 React 的好处

  • 理解 JSX

  • 创建一个组件

  • 理解导入和导出

  • 使用属性

  • 使用状态

  • 使用事件

技术要求

在本章中,我们使用以下工具:

  • 浏览器:一个现代浏览器,如 Google Chrome。

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

  • CodeSandbox:我们将使用这个在线工具来构建一个 React 组件。它可以在 codesandbox.io/ 找到。

本章中所有的代码片段都可以在 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter1/ 在线找到。

理解 React 的好处

在我们开始创建第一个 React 组件之前,在本节中,我们将了解 React 是什么以及探索其一些好处。

React 非常受欢迎。我们之前提到 Meta 使用 React 为 Facebook 开发,但许多其他知名公司也在使用它,例如 Netflix、Uber 和 Airbnb。React 的流行导致了一个围绕它的大生态系统,其中包括优秀的工具、流行的库和许多经验丰富的开发者。

React 流行的一个原因是它很简单。这是因为它专注于做好一件事 – 提供一个强大的机制来构建 UI 组件。组件是 UI 的组成部分,可以组合在一起来创建前端。此外,组件可以重用,因此可以在不同的屏幕上甚至在其他应用程序中使用。

React 的窄焦点意味着它可以集成到现有的应用程序中,即使它使用不同的框架。这是因为它不需要接管整个应用程序来运行;它乐意作为应用程序前端的一部分运行。

React 组件通过使用 虚拟 DOM文档对象模型)来高效地显示。你可能熟悉真实的 DOM – 它提供了网页的结构。然而,对真实 DOM 的更改可能会带来高昂的成本,导致交互式应用程序的性能问题。React 通过使用真实 DOM 的内存表示形式,即虚拟 DOM,来解决这个性能问题。在 React 更改真实 DOM 之前,它会生成一个新的虚拟 DOM,并将其与当前的虚拟 DOM 进行比较,以计算对真实 DOM 所需的最小更改量。然后,真实 DOM 使用这些最小更改进行更新。

Meta 使用 React 为 Facebook 开发是一个重大优势,因为它确保了其最高质量 – React 导致 Facebook 出问题对 Meta 来说可不是什么好事!这也意味着在确保新版本的 React 容易采用,从而有助于降低应用程序的维护成本方面投入了大量的思考和关注。

React 的简单性意味着它容易且快速学习。有许多优秀的资源,例如这本书。还有一系列工具,使构建 React 应用程序变得非常容易 – 其中一个工具叫做 Create React App,我们将在 第三章 中学习,设置 ReactTypeScript

现在我们开始理解 React 了,让我们在下一节深入探讨,了解 React 组件是如何定义显示内容的。

理解 JSX

JSX 是我们在 React 组件中用来定义组件应显示内容的语法。JSX 代表 JavaScript XML,这开始让我们对它有了些了解。我们将从本节开始学习 JSX,并在在线沙盒中编写一些 JSX 代码。

以下代码片段是一个带有高亮 JSX 的 React 组件:

function App() {
  return (
    <div className="App">
      <Alert type="information" heading="Success">
        Everything is really good!
      </Alert>
    </div>
  );
}

你可以看到 JSX 看起来有点像 HTML。然而,它并不是 HTML,因为 HTML 的 div 元素不包含 className 属性,也没有名为 Alert 的元素。JSX 还直接嵌入在 JavaScript 函数中,这有点奇怪,因为 script 元素通常用于在 HTML 中放置 JavaScript。

JSX 是一种 JavaScript 语法扩展。这意味着它不能直接在浏览器中执行 – 首先需要将其转换为 JavaScript。一个可以将 JSX 转换为 JavaScript 的流行工具叫做 Babel。

执行以下步骤来在 Babel 操场中编写你的第一个 JSX:

  1. 打开浏览器,转到 babeljs.io/repl,并在左侧面板中输入以下 JSX:

    <span>Oh no!</span>
    

以下内容出现在右侧面板中,这是我们的 JSX 编译后的结果:

React.createElement("span", null, "Oh no!");

我们可以看到它编译成了一个 React.createElement 函数调用,该调用有三个参数:

  • 元素类型可以是 HTML 元素名称(例如 span),React 组件类型或 React 片段类型。

  • 包含要应用于元素的属性的对象。在这里,null 被传递,因为没有属性。

  • 元素的内容。请注意,在 React 中,元素的内容通常被称为 children

注意

右侧面板的顶部可能还包含一个 "use strict" 声明,用于指定 JavaScript 将在 严格模式 下运行。严格模式是 JavaScript 引擎在遇到有问题的代码时抛出错误,而不是忽略它。有关 JavaScript 中严格模式的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode

你也可能在右侧面板中看到 /*#__PURE__*/ 注释。这些注释有助于打包器(如 webpack)在打包过程中删除冗余代码。我们将在 第三章 设置 React 和 TypeScript 中学习关于 webpack 的内容。

  1. 让我们通过将 div 元素放在 span 元素周围来扩展我们的示例,如下代码片段所示:

    <div className="title">
    
      <span>Oh no!</span>
    
    </div>
    

这现在编译成了两个对 React.createElement 的函数调用,其中 span 被传递为 div 的子元素:

React.createElement("div", {
  className: "title"
}, React.createElement("span", null, "Oh no!"));

我们还可以看到一个 className 属性,通过 div 元素传递了 "title" 值。

注意

我们已经看到,React 使用 className 属性而不是 class 来引用 CSS 类。这是因为 class 是 JavaScript 中的一个关键字,使用它会导致错误。

  1. 现在我们来做一些真正有趣的事情。让我们在 JSX 中嵌入一些 JavaScript。所以,进行以下高亮更改:

    const title = "Oh no!";
    
    <div className="title">
    
      <span>{title}</span>
    
    </div>
    

我们声明了一个 title JavaScript 变量,将其赋值为 "Oh no!",并将其嵌入到 span 元素中。

注意,title 变量被放置在元素内部的括号中。任何 JavaScript 代码都可以通过括号包围的方式嵌入到 JSX 中。

我们现在的代码编译成了以下内容:

const title = "Oh no!";
React.createElement("div", {
  className: "title"
}, React.createElement("span", null, title));
  1. 为了进一步说明 JavaScript 在 JSX 中的使用,让我们在 span 元素内部使用一个 JavaScript 三元表达式。添加以下三元表达式:

    const title = "Oh no!";
    
    <div className="title">
    
      <span>{title ? title : "Something important"}</span>
    
    </div>
    

三元表达式是 JavaScript 中的一个内联条件语句。表达式从条件开始,后跟?,然后是条件为真时返回的内容,接着是:,最后是条件为假时返回的内容。有关三元表达式的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_Operator

我们看到嵌套调用React.createElement使用三元表达式作为span的子元素:

React.createElement(
  "span",
  null,
  title ? title : "Something important"
);

这完成了我们在 Babel playground 中对 JSX 的探索。

总结来说,JSX 可以被视为 HTML 和 JavaScript 的混合,用于指定 React 组件的输出。JSX 需要使用像 Babel 这样的工具将其转换为 JavaScript。有关 JSX 的更多信息,请参阅以下链接:reactjs.org/docs/introducing-jsx.html

现在我们对 JSX 有了更深入的了解,我们将在下一节创建我们的第一个 React 组件。

创建组件

在本节中,我们将使用一个名为 CodeSandbox 的在线工具来创建一个 React 项目。在创建一个基本的 React 组件之前,我们将花时间了解 React 应用的入口点和组件在项目中的结构。

创建 CodeSandbox 项目

CodeSandbox 的伟大之处在于我们可以在网页浏览器中点击一下按钮就创建一个 React 项目,然后专注于如何创建 React 组件。请注意,我们将在第三章“设置 React 和 TypeScript”中学习如何在本地计算机上的代码编辑器中创建 React 项目。我们本章的重点是学习 React 基础知识。

现在,让我们执行以下步骤在 CodeSandbox 中创建一个 React 组件:

  1. 在浏览器中转到codesandbox.io/并点击页面右侧的Create Sandbox按钮。

注意

如果你想,可以创建一个 CodeSandbox 账户,但你也可以作为一个匿名用户创建一个 React 项目。

  1. 出现了一个项目模板列表。点击React模板(不要选择React TypeScript模板,因为我们本章将专注于 React)。

几秒钟后,将创建一个 React 项目:

图 1.1 – CodeSandbox 中的 React 项目

图 1.1 – CodeSandbox 中的 React 项目

CodeSandbox 编辑器中有三个主要面板:

  • 文件面板:这通常在左侧,包含项目中的所有文件。

  • 代码编辑器面板:这通常是中间面板,包含代码。这是我们编写 React 组件代码的地方。在文件面板中点击一个文件,它将在代码编辑器面板中打开。

  • 浏览器面板:这显示正在运行的应用的预览,通常在右侧。

现在我们已经创建了一个 React 项目,我们将花一些时间来了解应用的入口点。

理解 React 入口点

这个 React 应用的入口点在 index.js 文件中。通过在 文件 面板中单击它来打开此文件并检查其内容:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

这段代码中有很多内容。以下是每行代码的解释(如果你在本书的这一部分还没有完全理解,不要担心,你很快就会明白):

  • 第一条语句从 React 中导入了一个 StrictMode 组件。这意味着在文件中的代码将使用来自 react 库的 StrictMode 组件。我们将在下一节详细讲解导入语句。

  • 第二条语句从 React 中导入了一个 createRoot 函数。

  • 第三条导入语句从我们项目的 App.js 文件中导入了一个 App 组件。

  • 然后将一个 rootElement 变量赋值给一个具有 id"root" 的 DOM 元素。

  • React 的 createRoot 函数接收一个 DOM 元素并返回一个变量,该变量可以用来显示 React 组件树。然后将 rootElement 变量传递给 createRoot,并将结果赋值给 root 变量。

  • root 变量上调用 render 函数,传递包含嵌套 App 组件的 StrictMode 组件的 JSX。render 函数在页面上显示 React 组件。这个过程通常被称为 渲染

注意

StrictMode 组件将检查其内部的内容以查找潜在的问题,并在浏览器控制台中报告它们。这通常被称为 React 的严格模式。React 中的严格模式与 JavaScript 中的严格模式不同,但它们消除坏代码的目的相同。

总结来说,index.js 中的代码在具有 id"root" 的 DOM 元素中以 React 的严格模式渲染了 App 组件。

接下来,我们将花一些时间来了解 React 组件树以及 index.js 中引用的 App 组件。

理解 React 组件树

一个 React 应用由组件和 DOM 元素的树状结构组成。树的根组件是树顶部的组件。在我们的 CodeSandbox 项目中,根组件是 StrictMode 组件。

React 组件可以嵌套在另一个 React 组件内部。在 CodeSandbox 项目中,App 组件嵌套在 StrictMode 组件内部。这是一种强大的组件组合方式,因为任何组件都可以放在 StrictMode 内部——它不一定是 App

React 组件可以在它们的 JSX 中引用一个或多个其他组件,甚至 DOM 元素。打开 App.js 文件并观察它引用了 DOM 元素 divh1h2

<div className="App">
  <h1>Hello CodeSandbox</h1>
  <h2>Start editing to see some magic happen!</h2>
</div>

CodeSandbox 项目的组件树构建如下:

StrictMode
└── App
    └── div
         └── h1
           └── h2

总结来说,一个 React 应用由 React 组件和 DOM 元素的树状结构组成。

接下来,是时候创建一个 React 组件了。

创建一个基本的 alert 组件

现在,我们将创建一个显示警告的组件,我们将其简单地称为Alert。它将包括一个图标、一个标题和一条消息。

重要提示

React 组件名称必须以大写字母开头。如果组件名称以小写字母开头,它将被视为 DOM 元素,并且无法正确渲染。有关更多信息,请参阅 React 文档中的以下链接:reactjs.org/docs/jsx-in-depth.html#user-defined-components-must-be-capitalized

执行以下步骤以在 CodeSandbox 项目中创建组件:

  1. src文件夹中,并在出现的菜单中选择创建文件

  2. 光标放置在一个新文件中,准备您输入组件文件名。将文件名输入为Alert.js并按Enter键。

注意

组件文件的文件名对 React 或 React 转译器来说并不重要。通常的做法是将文件名与组件同名,无论是 Pascal 大小写还是 snake 大小写。然而,文件扩展名必须是.js.jsx,以便 React 转译器能够识别这些为 React 组件。

  1. Alert.js文件将在代码编辑器面板中自动打开。将以下代码输入到该文件中:

    function Alert() {
    
      return (
    
        <div>
    
          <div>
    
            <span role="img" aria-label="Warning">⚠</span>
    
            <span>Oh no!</span>
    
          </div>
    
          <div>Something isn't quite right ...</div>
    
        </div>
    
      );
    
    }
    

请记住,代码片段可在网上找到以供复制。上一个代码片段的链接可在github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter1/Section3-Creating-a-component/Alert.js找到。

该组件渲染以下项目:

  • 一个警告图标(请注意,这是一个警告表情符号)。

  • 一个标题,哦不!

  • 一条消息,有些地方不太对…

注意

rolearia-label属性已添加到包含警告图标的span元素中,以帮助屏幕阅读器理解这是一个具有警告标题的图像。

有关img角色的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/img_role

有关aria-label属性的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label

或者,可以使用箭头函数语法实现 React 组件。以下代码片段是Alert组件的箭头函数语法版本:

const Alert = () => {
  return (
    <div>
      <div>
        <span role="img" aria-label="Warning">
          ⚠
        </span>
        <span>Oh no!</span>
      </div>
      <div>Something isn't quite right ...</div>
    </div>
  );
};

注意

在 React 函数组件的上下文中,箭头函数和普通函数之间没有显著的区别。所以,选择哪一个取决于个人喜好。本书通常使用常规函数语法,因为它需要输入更少的字符,然而,如果你愿意,你可以在以下链接中找到有关 JavaScript 箭头函数的更多信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions

恭喜你,你已经创建了你的第一个 React 组件。让我们快速回顾本节的关键点:

  • 在 React 应用中的入口点通常是 index.js

  • React 的 createRoot 函数允许 React 组件树在 DOM 元素内渲染。

  • 一个 React 组件是一个以大写字母开头的 JavaScript 函数。该函数使用 JSX 语法返回应显示的内容。

你可能已经注意到 alert 组件没有出现在 importexport 语句中。

理解导入和导出

importexport 语句允许 JavaScript 被结构化为模块。

本节将首先介绍为什么 JavaScript 模块很重要,然后如何使用 importexport 语句定义和使用它们。然后我们将利用这些知识将 alert 组件添加到 CodeSandbox 项目的 React 组件树中。

理解模块的重要性

默认情况下,JavaScript 代码在所谓的全局作用域中执行。这意味着一个文件中的代码会自动在另一个文件中可用。不幸的是,这意味着我们实现的函数可能会覆盖其他文件中具有相同名称的函数。你可以想象这种结构很快就会变得具有挑战性和风险,难以维护。

幸运的是,JavaScript 有一个模块功能。模块的函数和变量是隔离的,因此不同模块中具有相同名称的函数不会冲突。这是一种更安全的代码结构方式,并且在构建 React 应用时是一种常见的做法。

接下来,我们将学习如何定义模块。

使用导出语句

模块是一个至少包含一个 export 语句的文件。export 语句引用了可供其他模块使用的成员。可以将此视为使成员公开可用。成员可以是文件中的函数、类或变量。未包含在 export 语句中的成员是私有的,且在模块外部不可用。

以下代码语句是一个带有其 export 语句高亮的模块示例。这被称为命名导出语句,因为公开成员被明确命名:

function myFunc1() {
  ...
}
function myFunc2() {
  ...
}
function myFunc3() {
  ...
}
export { myFunc1, myFunc3 };

在示例中,myFunc1myFunc3 函数是公开的,而 myFunc2 是私有的。

或者,可以在公共函数之前添加 export 关键字:

export function myFunc1() {
  ...
}
function myFunc2() {
  ...
}
export function myFunc3() {
  ...
}

本书将使用 export 关键字方法,因为这样可以立即清楚地知道哪个函数是公开的。在文件底部有一个单独的 export 语句,你必须继续滚动到文件底部才能找出一个函数是否是公开的。

export 语句定义在模块的底部:

export default myFunc1;

default 关键字表示导出是一个默认 export 语句。

第二种变体是在成员前添加了 exportdefault 关键字:

export default function myFunc1() {
  ...
}

本书通常使用命名导出而不是默认导出。

接下来,我们将学习关于 import 语句的内容。

使用 import 语句

使用 import 语句允许使用模块的公共成员。与 export 语句一样,有 import 语句。默认 import 语句只能用于引用默认 export 语句。

这里是一个默认 import 语句的例子:

import myFunc1 from './myModule';

myModule.js 文件中导入了默认导出成员,并命名为 myFunc1

注意

导入的默认成员的名称不一定需要与默认导出成员的名称匹配,但这样做是一种常见的做法。

这里是一个命名 import 语句的例子:

import { myFunc1, myFunc3 } from './myModule';

在这里,从 myModule.js 文件中导入了名为 myFunc1myFunc3 的命名导出成员。

注意

与默认导入不同,导入成员的名称必须与导出成员的名称匹配。

现在我们已经了解了如何将 JavaScript 代码结构化为模块,我们将使用这些知识将 alert 组件添加到 CodeSandbox 项目的 React 组件树中。

将 Alert 添加到 App 组件中

回到我们的 CodeSandbox 项目中的 Alert 组件,我们将在 App 组件中引用 Alert。为此,执行以下步骤:

  1. 首先,我们需要导出 Alert 组件。打开 Alert.js 并在 Alert 函数之前添加 export 关键字:

    export function Alert() {
    
      ...
    
    }
    

注意

将每个 React 组件放在单独的文件中,并因此有一个单独的模块,这是一种常见的做法。这可以防止文件变得过大,并有助于代码库的可读性。

  1. 现在我们可以将 Alert 导入到 App.js 文件中。打开 App.js 并在文件顶部添加高亮的 import 语句:

    import { Alert } from './Alert';
    
    import "./styles.css";
    
    export default function App() {
    
      ...
    
    }
    
  2. 我们现在可以在 App 组件的 JSX 中引用 Alert。在 div 元素内部添加高亮行,替换其现有内容:

    export default function App() {
    
      return (
    
        <div className="App">
    
          <Alert />
    
        </div>
    
      );
    
    }
    

该组件将在 浏览器 面板中显示以下内容:

图 1.2 – 浏览器面板中的 alert 组件

图 1.2 – 浏览器面板中的 alert 组件

很好!如果你注意到 alert 组件的样式并不美观,不要担心——我们将在 第四章 React 前端样式方法 中学习如何对其进行样式化。

这里是对本节中几个关键点的回顾:

  • React 应用程序使用 JavaScript 模块结构化,以帮助代码库可维护。

  • 通常,一个 React 组件在其自己的模块中结构化,因此在使用之前需要导出和导入到另一个 React 组件中。

接下来,我们将学习如何使警告组件更加灵活。

使用属性

目前,警告组件相当不灵活。例如,警告消费者不能更改标题或消息。目前,标题或消息需要在 Alert 本身内进行更改。属性 解决了这个问题,我们将在本节中学习它们。

注意

Props 是 属性 的缩写。React 社区通常将它们称为 props,因此我们将在本书中这样做。

理解属性

props 是一个可选参数,它被传递到一个 React 组件中。该参数是一个包含我们选择的属性的对象。以下代码片段显示了 ContactDetails 组件中的 props 参数:

function ContactDetails(props) {
  console.log(props.name);
  console.log(props.email);
  ...
}

在前面的代码片段中,props 参数包含 nameemail 属性。

注意

参数不必命名为 props,但这是常见的做法。

属性作为属性在 JSX 中传递给组件。属性名称必须与组件中定义的名称匹配。以下是一个将属性传递给前面的 ContactDetails 组件的示例:

<ContactDetails name="Fred" email="fred@somewhere.com" />

因此,属性使组件输出更加灵活。组件的消费者可以将适当的属性传递到组件中,以获得所需输出。

接下来,我们将向我们所工作的警告组件添加一些属性。

向警告组件添加属性

在 CodeSandbox 项目中,按照以下步骤向警告组件添加属性以使其更加灵活:

  1. 打开 alert.js 并向函数添加一个 props 参数:

    export function Alert(props) {
    
      ...
    
    }
    
  2. 我们将为警告定义以下属性:

    • type: 这将是 "information""warning",并确定警告中的图标。

    • heading: 这将确定警告的标题。

    • children: 这将确定警告的内容。children 属性实际上是一个用于组件主要内容的特殊属性。

更新警告组件的 JSX 以使用属性如下:

export function Alert(props) {
  return (
    <div>
      <div>
        <span
          role="img"
          aria-label={
            props.type === "warning"
              ? "Warning"
              : "Information"
          }
        >
          {props.type === "warning" ? "⚠" : "ℹ"}
        </span>
        <span>{props.heading}</span>
      </div>
      <div>{props.children}</div>
    </div>
  );
}

注意到 App 组件还没有向 Alert 传递任何属性:

图 1.3 – 只显示信息图标的警告组件

图 1.3 – 只显示信息图标的警告组件

  1. 打开 App.js 并更新 JSX 中的 Alert 组件,如下传递属性:

    export default function App() {
    
      return (
    
        <div className="App">
    
          <Alert type="information" heading="Success">
    
            Everything is really good!
    
          </Alert>
    
        </div>
    
      );
    
    }
    

注意到 Alert 组件不再自动关闭,因此可以将 Everything is really good! 传递到其内容中。内容是通过 children 属性传递的。

浏览器 面板现在显示配置的警告组件:

图 1.4 – 浏览器面板中配置的警告组件

图 1.4 – 浏览器面板中配置的警告组件

  1. 我们可以通过解构 props 参数来稍微清理一下警告组件的代码。

注意

解构是 JavaScript 的一个特性,允许从对象中提取属性。更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

再次打开Alert.js,解构function参数,并如下使用解包的 props:

export function Alert({ type, heading, children }) {
  return (
    <div>
      <div>
        <span
          role="img"
          aria-label={
            type === "warning" ? "Warning" :               "Information"
          }
        >
          {type === "warning" ? "⚠" : "ℹ"}
        </span>
        <span>{heading}</span>
      </div>
      <div>{children}</div>
    </div>
  );
}

这更简洁,因为我们直接使用解包的 props,而不是通过props参数引用它们。

  1. 我们希望type属性默认为"information"。如下定义此默认值:

    export function Alert({
    
      type = "information",
    
      heading,
    
      children
    
    }) {
    
      ...
    
    }
    

至此,警报组件的 props 实现已完成。以下是对 props 的快速回顾:

  • Props 允许通过消费 JSX 来配置组件,并作为 JSX 属性传递。

  • Props 作为对象参数在组件定义中接收,然后可以在其 JSX 中使用。

接下来,我们将继续改进警报组件,允许用户关闭它。

使用状态

组件状态是一个特殊的变量,包含有关组件当前情况的信息。例如,组件可能处于加载状态或错误状态。

在本节中,我们将学习状态,并在 CodeSandbox 项目中使用它来构建我们的警报组件。我们将使用状态来允许用户关闭警报。

理解状态

没有预定义的状态列表;我们为特定组件定义适当的状态。有些组件甚至不需要任何状态;例如,在我们 CodeSandbox 项目中的AppAlert组件到目前为止还没有需要状态的要求。

然而,状态是使组件交互的关键部分。当用户与组件交互时,组件的输出可能需要改变。例如,点击一个组件可能需要使组件中的某个元素不可见。组件状态的改变会导致组件刷新,这通常被称为重新渲染。因此,用户点击组件可能导致状态改变,从而使组件中的某个元素变得不可见。

状态是通过 React 的useState函数定义的。useState函数是 React 的钩子之一。React 钩子是在 React 的 16.8 版本中引入的,为函数组件提供了强大的功能,如状态。关于 React 钩子有一个专门的章节,即第四章使用 React Hooks

useState的语法如下:

const [state, setState] = useState(initialState);

这里是关键点:

  • 初始状态值传递给useState。如果没有传递值,它将初始化为undefined

  • useState返回一个包含当前状态值和更新状态值函数的元组。在先前的代码片段中,该元组被解构。

  • 在先前的代码片段中,状态变量名为state,但我们可以选择任何有意义的名称。

  • 我们还可以选择状态设置函数的名称,但通常的做法是使用与状态变量相同的名称,并在其前面加上set

  • 可以通过定义多个useState实例来定义多个状态。例如,以下是加载和错误状态的定义:

    const [loading, setLoading] = useState(true);
    
    const [error, setError] = useState();
    

接下来,我们将在警报组件中实现状态以确定其是否可见。

在警报组件中实现可见状态

我们将首先在警报组件中实现一个功能,允许用户关闭它。该功能的关键部分是控制警报的可见性,我们将使用visible状态来实现。此状态将是truefalse,并且最初将其设置为true

按照以下步骤在Alert中实现visible状态:

  1. 在 CodeSandbox 项目中打开Alert.js

  2. 在文件顶部添加以下import语句以从 React 导入useState钩子:

    import { useState } from 'react';
    
  3. 在组件定义中如下定义visible状态:

    export function Alert(...) {
    
      const [visible, setVisible] = useState(true);
    
      return (
    
        ...
    
      );
    
    }
    
  4. 在状态声明之后,添加一个条件,如果visible状态为false,则返回null。这意味着将不会渲染任何内容:

    export function Alert(...) {
    
      const [visible, setVisible] = useState(true);
    
      if (!visible) {
    
        return null;
    
      }
    
      return (
    
        ...
    
      );
    
    }
    

visible状态为true时,组件将进行渲染。尝试将初始状态值更改为false,你将在浏览器面板中看到它消失。

目前,警报组件正在使用visible状态的值,如果它是false,则不渲染任何内容。然而,组件尚未更新visible状态——也就是说,setVisible目前未使用。我们将在实现关闭按钮后更新visible状态,我们将在下一部分实现。

向警报中添加关闭按钮

我们将在警报组件中添加一个关闭按钮,允许用户关闭它。我们将使其可配置,以便警报消费者可以选择是否渲染关闭按钮。

执行以下步骤:

  1. 首先打开Alert.js并添加一个closable属性:

    export function Alert({
    
      type = "information",
    
      heading,
    
      children,
    
      closable
    
    }) {
    
      ...
    
    }
    

警报组件的消费者将使用closable属性来指定是否显示关闭按钮。

  1. 按照以下方式在标题和内容之间添加一个关闭按钮:

    export function Alert(...) {
    
      ...
    
      return (
    
        <div>
    
          <div>
    
            ...
    
            <span>{heading}</span>
    
          </div>
    
          <button aria-label="Close">
    
            <span role="img" aria-label="Close">❌</span>
    
          </button>
    
          <div>{children}</div>
    
        </div>
    
      );
    
    }
    

注意,包含关闭图标的span元素被赋予了"img"角色和"Close"标签,以帮助屏幕阅读器。同样,按钮也被赋予了"Close"标签以帮助屏幕阅读器。

关闭按钮在警报组件中的显示方式如下:

图 1.5 – 警报组件中的关闭按钮

图 1.5 – 警报组件中的关闭按钮

  1. 目前,关闭按钮总是渲染,而不仅仅是当closable属性为true时。我们可以使用 JavaScript 逻辑短路表达式(由&&字符表示)有条件地渲染close按钮。为此,进行以下突出显示的更改:

    import { useState } from 'react';
    
    export function Alert(...) {
    
      ...
    
      return (
    
        <div>
    
          <div>
    
            ...
    
            <span>{heading}</span>
    
          </div>
    
          {closable && (
    
            <button aria-label="Close">
    
              <span role="img" aria-label="Close">
    
                ❌
    
              </span>
    
            </button>
    
          )}
    
          <div>{children}</div>
    
        </div>
    
      );
    
    }
    

如果closable真值,则按钮将被渲染。

注意

有关逻辑AND短路表达式的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_AND

有关 JavaScript 的假值,请参阅以下链接:developer.mozilla.org/en-US/docs/Glossary/Falsy,有关真值,请参阅以下链接:developer.mozilla.org/en-US/docs/Glossary/Truthy

  1. 打开App.js并将closable属性传递给Alert

    export default function App() {
    
      return (
    
        <div className="App">
    
          <Alert type="information" heading="Success"         closable>
    
            Everything is really good!
    
          </Alert>
    
        </div>
    
      );
    
    }
    

注意,closable属性上没有明确定义值。我们可以按照以下方式传递值:

closable={true}

然而,没有必要在布尔属性上传递值。如果一个元素上存在布尔属性,其值将自动为true

当指定了closable属性时,关闭按钮在警告组件中显示,就像在图 1**.5中之前那样。但是当没有指定closable属性时,关闭按钮不会显示:

图 1.6 – 当未指定 closable 时,关闭按钮不在警告组件中

图 1.6 – 当未指定 closable 时,关闭按钮不在警告组件中

优秀!

对我们迄今为止关于 React 状态的了解进行快速回顾:

  • 使用 React 的useState钩子定义状态

  • 状态的初始值可以通过useState钩子传递

  • useState返回一个状态变量,可以用来有条件地渲染元素

  • useState还返回一个函数,可以用来更新状态的值

你可能已经注意到,关闭按钮实际上并没有关闭警告。在下一节中,我们将通过学习 React 中的事件来纠正这一点。

使用事件

事件是允许组件具有交互性的另一个关键部分。在本节中,我们将了解 React 事件是什么以及如何在 DOM 元素上使用事件。我们还将学习如何创建我们自己的 React 事件。

随着我们学习事件,我们将继续扩展警告组件的功能。我们将先完成关闭按钮的实现,然后再创建一个当警告被关闭时的事件。

理解事件

浏览器事件发生在用户与 DOM 元素交互时。例如,点击按钮会从该按钮引发一个click事件。

当事件被引发时,可以执行逻辑。例如,当关闭按钮被点击时,警告可以关闭。可以注册一个名为事件处理器(有时称为事件监听器)的函数,用于包含在元素事件中,当该事件发生时执行逻辑。

注意

有关浏览器事件的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events

React 中的事件与浏览器原生事件非常相似。事实上,React 事件是浏览器原生事件的一个包装器。

React 中的事件处理程序通常使用属性在 JSX 中注册到元素上。以下代码片段在button元素上注册了一个名为handleClickclick事件处理程序:

<button onClick={handleClick}>...</button>

接下来,我们将回到我们的警告组件,并在关闭按钮上实现一个click处理程序来关闭警告。

在警告中实现关闭按钮点击处理程序

目前,我们的警告组件包含一个关闭按钮,但点击时没有任何反应。警告还包含一个visible状态,它决定了警告是否显示。因此,为了完成关闭按钮的实现,我们需要在点击时添加一个事件处理程序来将visible状态设置为false。执行以下步骤来完成此操作:

  1. 打开Alert.js并在关闭按钮上注册一个如下所示的click处理程序:

    <button aria-label="Close" onClick={handleCloseClick}>
    

我们已经在关闭按钮上注册了一个名为handleCloseClickclick处理程序。

  1. 然后,我们需要在组件中实现handleCloseClick函数。从创建一个位于return语句之上的空函数开始:

    export function Alert(...) {
    
      const [visible, setVisible] = useState(true);
    
      if (!visible) {
    
        return null;
    
      }
    
      function handleCloseClick() {}
    
      return (
    
        ...
    
      );
    
    }
    

这可能看起来有点奇怪,因为我们已经将handleCloseClick函数放在了另一个函数Alert内部。处理程序需要放在Alert函数内部;否则,警告组件将无法访问它。

如果喜欢,可以使用箭头函数语法来编写事件处理程序。处理程序的箭头函数版本如下:

export function Alert(...) {
  const [visible, setVisible] = useState(true);
  if (!visible) {
    return null;
  }
  const handleCloseClick = () => {}
  return (
    ...
  );
}

事件处理程序也可以直接在 JSX 元素上添加,如下所示:

<button aria-label="Close" onClick={() => {}}>

在警告组件中,我们将坚持使用命名的handleCloseClick事件处理程序函数。

  1. 现在我们可以使用visible状态设置函数在事件处理程序中将visible状态设置为false

    function handleCloseClick() {
    
      setVisible(false);
    
    }
    

如果你点击浏览器面板中的关闭按钮,警告就会消失。太棒了!

刷新图标可以被点击,使组件在浏览器面板中重新出现:

图 1.7 – 浏览器面板刷新选项

图 1.7 – 浏览器面板刷新选项

接下来,我们将扩展关闭按钮,以便在警告关闭时触发一个事件。

实现警告关闭事件

现在,我们将在警告组件中创建一个自定义事件。当警告关闭时,将触发此事件,以便消费者可以在发生这种情况时执行逻辑。

组件中的自定义事件是通过实现一个属性来实现的。这个属性是一个在触发事件时被调用的函数。

要实现一个关闭警告事件,请按照以下步骤操作:

  1. 首先打开Alert.js并为事件添加一个属性:

    export function Alert({
    
      type = "information",
    
      heading,
    
      children,
    
      closable,
    
      onClose
    
    }) {}
    

我们将属性命名为onClose

注意

通常,事件属性的名称以on开头。

  1. handleCloseClick事件处理程序中,在将visible状态设置为false之后触发关闭事件:

    function handleCloseClick() {
    
      setVisible(false);
    
      if (onClose) {
    
        onClose();
    
      }
    
    }
    

注意,我们只有在onClose被定义并且由消费者作为属性传递时才调用它。这意味着我们并没有强迫消费者处理这个事件。

  1. 我们现在可以在 App 组件中处理 alert 被关闭的情况。打开 App.js 并在 JSX 中的 Alert 上添加以下事件处理器:

    <Alert
    
      type="information"
    
      heading="Success"
    
      closable
    
      onClose={() => console.log("closed")}
    
    >
    
      Everything is really good!
    
    </Alert>;
    

我们这次使用了内联事件处理器。

浏览器 面板中,如果你点击关闭按钮并查看控制台,你会看到输出了 closed

图 1.8 – 浏览器面板关闭控制台输出

图 1.8 – 浏览器面板关闭控制台输出

这就完成了关闭事件和本章 alert 的实现。

这是关于 React 事件我们所学到的:

  • 事件,连同状态,使组件具有交互性

  • 事件处理器是在 JSX 元素上注册的函数

  • 通过实现一个函数属性并调用它来引发事件,可以创建一个自定义事件

本章中我们创建的组件是一个函数组件。你还可以使用类来创建组件。例如,alert 组件的类组件版本在 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter1/Class-component/Alert.js。然而,由于以下原因,函数组件在 React 社区中占主导地位:

  • 通常,它们需要更少的代码来实现

  • 组件内的逻辑可以更容易地重用

  • 实现方式非常不同

由于这些原因,我们将专注于本书中的函数组件。

接下来,我们将总结本章所学的内容。

摘要

我们现在明白 React 是一个流行的库,用于创建基于组件的前端。在本章中,我们使用 React 创建了一个 alert 组件。

组件输出使用 HTML 和 JavaScript 的混合体 JSX 声明。JSX 需要被转换为 JavaScript 才能在浏览器中执行。

可以将属性作为 JSX 属性传递给组件。这允许组件的消费者控制其输出和行为。组件接收属性作为一个对象参数。JSX 属性名称形成对象参数属性名称。我们在本章的 alert 组件中实现了一系列属性。

事件可以被处理,以便在用户与组件交互时执行逻辑。我们在 alert 组件中创建了一个关闭按钮点击事件的处理器。

可以使用 useState 钩子来定义状态,以重新渲染组件并更新其输出。状态通常在事件处理器中更新。我们为 alert 是否可见创建了状态。

可以通过实现一个函数属性来创建自定义事件。这允许组件的消费者在用户与其交互时执行逻辑。我们在 alert 组件上实现了一个关闭事件。

在下一章,我们将介绍 TypeScript。

问题

回答以下问题以巩固你在本章中学到的内容:

  1. 以下组件定义有什么问题?

    export function important() {
    
      return <div>This is really important!</div>;
    
    }
    
  2. 带有属性的组件如下定义:

    export function Name({ name }) {
    
      return <div>name</div>;
    
    }
    

尽管属性值没有输出。问题是什么?

  1. 组件属性如下传递给组件:

    <ContactDetails name="Fred" email="fred@somewhere.com" />
    

然后将组件定义为以下内容:

export function ContactDetails({ firstName, email }) {
  return (
    <div>
      <div>{firstName}</div>
      <div>{email}</div>
    </div>
  );
}

尽管没有输出名字Fred。问题是什么?

  1. 以下 JSX 中处理click事件的方式有什么问题?

    <button click={() => console.log("clicked")}>
    
      Click me
    
    </button>;
    
  2. 这里定义的loading状态的初始值是什么?

    const [loading, setLoading] = useState(true);
    
  3. 以下组件中设置状态的方式有什么问题?

    export function Agree() {
    
      const [agree, setAgree] = useState();
    
      return (
    
        <button onClick={() => agree = true}>
    
          Click to agree
    
        </button>
    
      );
    
    }
    
  4. 以下组件实现了一个可选的Agree事件。这个实现有什么问题?

    export function Agree({ onAgree }) {
    
      function handleClick() {
    
        onAgree();
    
      }
    
      return (
    
        <button onClick={handleClick}>
    
          Click to agree
    
        </button>
    
      );
    
    }
    

答案

这里是关于你在本章中学到的内容的问答:

  1. 组件定义的问题在于其名称是小写的。React 函数必须以大写字母开头命名:

    export function Important() {
    
      ...
    
    }
    
  2. 问题在于div元素内部的name变量没有被大括号括起来。所以,将输出单词name而不是name属性的值。以下是组件的修正版本:

    export function Name({ name }) {
    
      return <div>{name}</div>;
    
    }
    
  3. 问题在于传递了一个name属性而不是firstName。以下是修正后的 JSX:

    <ContactDetails firstName="Fred" email="fred@somewhere.com" />
    
  4. 问题在于传递了一个click属性而不是onClick。以下是修正后的 JSX:

    <button onClick={() => console.log("clicked")}>
    
      Click me
    
    </button>;
    
  5. loading状态初始值是true

  6. 状态没有使用状态设置函数进行更新。以下是设置状态的修正版本:

    export function Agree() {
    
      const [agree, setAgree] = useState();
    
      return (
    
        <button onClick={() => setAgree(true)}>
    
          Click to agree
    
        </button>
    
      );
    
    }
    
  7. 问题在于如果onAgree没有被传递,点击按钮将导致错误,因为它将是undefined。以下是组件的修正版本:

    export function Agree({ onAgree }) {
    
      function handleClick() {
    
        if (onAgree) {
    
          onAgree();
    
        }
    
      }
    
      return (
    
        <button onClick={handleClick}>
    
          Click to agree
    
        </button>
    
      );
    
    }
    

第二章:介绍 TypeScript

在本章中,我们将首先了解 TypeScript 是什么,以及它是如何在 JavaScript 之上提供更丰富的类型系统的。我们将学习 TypeScript 中的基本类型,如数字和字符串,然后学习如何使用不同的 TypeScript 功能创建自己的类型来表示对象和数组。最后,我们将通过理解 TypeScript 编译器及其在 React 应用程序中的关键选项来结束本章。

到本章结束时,你将准备好学习如何使用 TypeScript 来构建带有 React 的前端。

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

  • 理解 TypeScript 的好处

  • 理解 JavaScript 类型

  • 使用基本的 TypeScript 类型

  • 创建 TypeScript 类型

  • 使用 TypeScript 编译器

技术要求

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

本章中的所有代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter2

理解 TypeScript 的好处

在本节中,我们将首先了解 TypeScript 是什么,它与 JavaScript 的关系,以及 TypeScript 如何使团队更高效。

理解 TypeScript

TypeScript 首次于 2012 年发布,并且仍在开发中,每隔几个月就会发布新版本。但 TypeScript 是什么,它有哪些好处?

TypeScript 通常被称为 JavaScript 的超集或扩展,因为 JavaScript 中的任何功能在 TypeScript 中都是可用的。与 JavaScript 不同,TypeScript 不能直接在浏览器中执行 - 它必须首先转换为 JavaScript。

注意

值得注意的是,有一个提案正在考虑中,该提案将允许 TypeScript 在不进行转换的情况下直接在浏览器中执行。有关更多信息,请参阅以下链接:github.com/tc39/proposal-type-annotations

TypeScript 为 JavaScript 添加了一个丰富的类型系统。它通常与 Angular、Vue 和 React 等前端框架一起使用。TypeScript 还可用于使用 Node.js 构建后端。这展示了 TypeScript 类型系统的灵活性。

当 JavaScript 代码库增长时,它可能变得难以阅读和维护。TypeScript 的类型系统解决了这个问题。TypeScript 使用类型系统允许代码编辑器在开发者编写有问题的代码时捕获类型错误。代码编辑器还使用类型系统提供生产力功能,如强大的代码导航和代码重构。

接下来,我们将通过一个示例来了解 TypeScript 如何捕获 JavaScript 无法捕获的错误。

提前捕获类型错误

类型信息帮助 TypeScript 编译器捕获类型错误。在 Visual Studio Code 等代码编辑器中,类型错误在开发者犯下类型错误后立即用红色下划线标出。执行以下步骤以体验 TypeScript 捕获类型错误的示例:

  1. 在您选择的文件夹中打开 Visual Studio Code。

  2. 通过在 EXPLORER 面板中选择 新建文件 选项创建一个名为 calculateTotalPrice.js 的新文件。

图 2.1 – 在 Visual Studio Code 中创建新文件

图 2.1 – 在 Visual Studio Code 中创建新文件

  1. 将以下代码输入到文件中:

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

记住,代码片段可在网上找到以供复制。上一个代码片段的链接可在 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter2/Section1-Understanding-TypeScript/calculateTotalPrice.js 找到。

代码中可能存在一个难以发现的错误,并且 Visual Studio Code 不会突出显示该错误。

  1. 现在创建一个文件的副本,但使用 .ts 扩展名而不是 .js。可以通过在 EXPLORER 面板中右键单击文件并选择 复制 选项来复制文件。然后再次右键单击 EXPLORER 面板并选择 粘贴 选项以创建复制的文件。

注意

.ts 文件扩展名表示 TypeScript 文件。这意味着 TypeScript 编译器将对这个文件执行类型检查。

  1. calculateTotalPrice.ts 文件中,从函数名称的末尾移除 JS 并对代码进行以下突出显示的更新:

    function calculateTotalPrice(
    
      product: { name: string; unitPrice: number },
    
      quantity: number,
    
      discount: number
    
    ) {
    
      const priceWithoutDiscount = product.price * quantity;
    
      const discountAmount = priceWithoutDiscount * discount;
    
      return priceWithoutDiscount - discountAmount;
    
    }
    

在这里,我们添加了 TypeScript function 参数。我们将在下一节中详细介绍类型注解。

关键点是类型错误现在用红色波浪线突出显示:

图 2.2 – 高亮显示的类型错误

图 2.2 – 高亮显示的类型错误

错误在于函数引用了产品对象中不存在的price属性。应该引用的属性是unitPrice

在开发过程中早期捕捉这些问题可以提高团队的生产率,并且是质量保证需要捕捉的更少的一件事。情况可能会更糟——错误可能会进入实时应用程序,给用户带来不良体验。

请保持这些文件在 Visual Studio Code 中打开,因为我们将在下一个示例中运行 TypeScript 如何提高开发体验的示例。

使用 IntelliSense 提高开发体验和生产力

IntelliSense是代码编辑器中的一个功能,它提供了关于代码元素的有用信息,并允许快速完成代码。例如,IntelliSense 可以提供对象中可用的属性列表。

执行以下步骤以体验 TypeScript 与 IntelliSense 相比 JavaScript 如何工作得更好,以及这对生产力的积极影响。作为这项练习的一部分,我们将修复上一节中的价格错误:

  1. 打开calculateTotalPrice.js文件,在第 2 行,即product.price被引用的地方,移除price。然后,将光标放在点(.)之后,点击Ctrl + 空格键。这会打开 Visual Studio Code 的 IntelliSense:

图 2.3 – JavaScript 文件中的 IntelliSense

图 2.3 – JavaScript 文件中的 IntelliSense

Visual Studio Code 只能猜测潜在的属性名称,因此它列出了它在文件中看到的变量名称和函数名称。不幸的是,在这种情况下,IntelliSense 无法提供帮助,因为正确的属性名称unitPrice并未列出。

  1. 现在打开calculateTotalPrice.ts文件,从product.price中移除price,然后按Ctrl + 空格键再次打开 IntelliSense:

图 2.4 – TypeScript 文件中的 IntelliSense

图 2.4 – TypeScript 文件中的 IntelliSense

这次,Visual Studio Code 列出了正确的属性。

  1. 从 IntelliSense 中选择unitPrice以解决类型错误。

IntelliSense 只是 TypeScript 提供的一个工具。它还可以提供强大的重构功能,例如重命名 React 组件,并帮助进行准确的代码导航,例如跳转到函数定义。

为了回顾我们在本节中学到的内容:

  • TypeScript 的类型检查功能有助于在开发过程中早期捕捉问题

  • TypeScript 使代码编辑器能够提供诸如 IntelliSense 之类的生产力功能

  • 这些优势在处理大型代码库时提供了显著的好处

接下来,我们将学习 JavaScript 中的类型系统。这将进一步强调在大型代码库中使用 TypeScript 的必要性。

理解 JavaScript 类型

在理解 TypeScript 中的类型系统之前,让我们简要地探索 JavaScript 中的类型系统。为此,打开 CodeSandbox,访问codesandbox.io/,并按照以下步骤操作:

  1. 通过选择Vanilla选项创建一个新的纯 JavaScript 项目。

  2. 打开index.js,删除其内容,并用以下代码替换:

    let firstName = "Fred"
    
    console.log("firstName", firstName, typeof firstName);
    
    let score = 9
    
    console.log("score", score, typeof score);
    
    let date = new Date(2022, 10, 1);
    
    console.log("date", date, typeof date);
    

代码将三个变量赋值为不同的值。代码还将变量值及其 JavaScript 类型输出到控制台。

这是控制台输出:

图 2.5 – 一些 JavaScript 类型

图 2.5 – 一些 JavaScript 类型

firstName是字符串,score是数字,这并不奇怪。然而,date是一个对象而不是更具体的日期类型,这有点令人惊讶。

  1. 让我们在现有代码之后添加几行代码:

    score = "ten"
    
    console.log("score", score, typeof score);
    

再次,控制台输出有点令人惊讶:

图 2.6 – 变量类型变化

图 2.6 – 变量类型变化

score变量已从number类型更改为string类型!这是因为 JavaScript 是松散类型的。

一个关键点是 JavaScript 只有一组最小的类型,如stringnumberboolean。值得注意的是,所有的 JavaScript 类型都在 TypeScript 中可用,因为 TypeScript 是 JavaScript 的超集。

此外,JavaScript 允许变量改变其类型——这意味着如果变量被更改为完全不同的类型,JavaScript 引擎不会抛出错误。这种松散的类型使得代码编辑器无法捕获类型错误。

注意

关于 JavaScript 类型的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures

现在我们已经了解了 JavaScript 类型系统的局限性,我们将学习 TypeScript 的类型系统,从基本类型开始。

使用基本 TypeScript 类型

在本节中,我们将首先了解 TypeScript 类型如何声明以及它们如何从赋值中推断出来。然后我们将学习 TypeScript 中常用的基本类型,这些类型在 JavaScript 中不可用,并了解它们的有用用例。

使用类型注解

TypeScript 类型注解允许变量以特定类型声明。这允许 TypeScript 编译器检查代码是否遵循这些类型。简而言之,类型注解允许 TypeScript 在代码使用错误类型的情况下比在 JavaScript 中更早地捕获错误。

打开 TypeScript Playground,访问www.typescriptlang.org/play,并按照以下步骤进行操作以探索类型注解:

  1. 删除左侧面板中的任何现有代码,并输入以下变量声明:

    let unitPrice: number;
    

类型注解位于变量声明之后。它以冒号开头,后跟我们要分配给变量的类型。在这种情况下,unitPrice将被指定为number类型。请记住,number是 JavaScript 中的一个类型,这意味着它也适用于 TypeScript。

转译后的 JavaScript 如下所示:

let unitPrice;

然而,请注意类型注解已经消失。这是因为 JavaScript 中没有类型注解。

注意

您还可能在转译后的 JavaScript 顶部看到"use strict";。这意味着 JavaScript 将在 JavaScript 严格模式下执行,这将捕获更多的编码错误。有关 JavaScript 严格模式的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode

  1. 在程序中添加第二行:

    unitPrice = "Table";
    

注意,在这一行下方的unitPrice处出现了一条红色横线。如果您将鼠标悬停在下划线的unitPrice上,会描述一个类型错误:

图 2.7 – 捕获到的类型错误

图 2.7 – 捕获到的类型错误

  1. 您还可以使用与变量注解相同的语法为函数参数和函数的返回值添加类型注解。例如,在 TypeScript Playground 中输入以下函数:

    function getTotal(
    
      unitPrice: number,
    
      quantity: number,
    
      discount: number
    
    ): number {
    
      const priceWithoutDiscount = unitPrice * quantity;
    
      const discountAmount = priceWithoutDiscount * discount;
    
      return priceWithoutDiscount - discountAmount;
    
    }
    

我们已经声明了unitPricequantitydiscount参数,它们都是number类型。函数的返回类型注解位于函数括号之后,在先前的例子中这也是一个number类型。

注意

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

  1. 在代码中添加另一行以调用getTotal函数,并使用错误的quantity类型。将getTotal函数的调用结果赋值给一个具有错误类型的变量:

    let total: string = getTotal(500, "one", 0.1);
    

两个错误都会立即被检测并突出显示:

图 2.8 – 捕获到的两个类型错误

图 2.8 – 捕获到的两个类型错误

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

接下来,我们将学习 TypeScript 在类型检查代码时不总是需要类型注解。

使用类型推断

类型注解非常有价值,但它们需要编写额外的代码。这些额外的代码需要花费时间来编写。幸运的是,TypeScript 强大的类型推断系统意味着类型注解并不总是需要指定。当变量被赋予一个值时,TypeScript 会推断该变量的类型。

在 TypeScript Playground 中执行以下步骤以探索类型推断:

  1. 首先,删除任何之前的代码,然后添加以下行:

    let flag = false;
    
  2. 悬停在flag变量上。会出现一个工具提示,显示flag被推断出的类型:

图 2.9 – 悬停在变量上显示其类型

图 2.9 – 悬停在变量上显示其类型

  1. 在此行下方添加另一行,错误地将flag设置为无效值:

    flag = "table";
    

类型错误会立即被捕获,就像我们使用类型注解为变量分配类型时一样。

类型推断是 TypeScript 的一个优秀特性,它可以防止大量类型注解带来的代码膨胀。因此,使用类型推断并仅在推断不可行时回退到使用类型注解是一种常见的做法。

接下来,我们将查看 TypeScript 中的Date类型。

使用 Date 类型

我们已经知道 JavaScript 中不存在Date类型,但幸运的是,TypeScript 中存在Date类型。TypeScript 的Date类型是对 JavaScript Date对象的表示。

注意

有关 JavaScript Date对象的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

要探索 TypeScript 的Date类型,请在 TypeScript Playground 中执行以下步骤:

  1. 首先,删除任何之前的代码,然后添加以下行:

    let today: Date;
    
    today = new Date();
    

声明了一个名为today的变量,它被分配了Date类型并设置为今天的日期。

  1. 将这两行重构为以下使用类型推断而不是类型注解的单行:

    let today = new Date();
    
  2. 检查today是否已被分配为Date类型,通过悬停在它上面并检查工具提示来完成:

图 2.10 – 确认 today 已推断出 Date 类型

图 2.10 – 确认 today 已推断出 Date 类型

  1. 现在,通过在新的行上添加today.来检查 IntelliSense 是否正常工作:

图 2.11 – IntelliSense 在日期上工作得很好

图 2.11 – IntelliSense 在日期上工作得很好

  1. 删除此行并添加一条略微不同的代码行:

    today.addMonths(2);
    

Date对象中不存在addMonths函数,因此会引发类型错误:

图 2.12 – 在日期上捕获到的类型错误

图 2.12 – 在日期上捕获到的类型错误

总结来说,Date类型具有我们期望的所有功能——推断、IntelliSense 和类型检查——这对于处理日期来说非常有用。

接下来,我们将了解 TypeScript 类型系统中的一个逃生口。

使用 any 类型

如果我们声明一个没有类型注解和值的变量,TypeScript 会推断出什么类型?让我们通过在 TypeScript Playground 中输入以下代码来找出答案:

let flag;

现在,将鼠标悬停在flag上:

图 2.13 – 被赋予 any 类型的变量

图 2.13 – 被赋予 any 类型的变量

因此,TypeScript 给没有类型注解且没有立即赋值的变量赋予any类型。这是一种选择不执行特定变量的类型检查的方法,通常用于动态内容或第三方库中的值。然而,TypeScript 日益强大的类型系统意味着我们今天需要更少地使用any

相反,有一个更好的选择:unknown类型。

使用unknown类型

当我们不确定类型但想以强类型方式与之交互时,我们可以使用unknown类型。执行以下步骤以探索这如何是any类型的更好替代方案:

  1. 在 TypeScript Playground 中,删除任何之前的代码,并输入以下内容:

    fetch("https://swapi.dev/api/people/1")
    
      .then((response) => response.json())
    
      .then((data) => {
    
        console.log("firstName", data.firstName);
    
      });
    

代码从网络 API 中获取一个《星球大战》角色。没有抛出类型错误,所以代码看起来是正常的。

  1. 现在点击运行选项来执行代码:

图 2.14 – firstName 属性具有未定义的值

图 2.14 – firstName 属性具有未定义的值

firstName属性似乎不在获取的数据中,因为它在输出到控制台时是undefined

为什么在引用firstName的第四行没有抛出类型错误?嗯,data的类型是any,这意味着不会对其执行类型检查。你可以悬停在data上以确认它已被赋予any类型。

  1. data添加unknown类型注解:

     fetch("https://swapi.dev/api/people/1")
    
      .then((response) => response.json())
    
      .then((data: unknown) => {
    
        console.log("firstName", data.firstName);
    
      });
    

当在firstName处引用时,现在会抛出一个类型错误:

图 2.15 – 在未知数据参数上发生类型错误

图 2.15 – 在未知数据参数上发生类型错误

unknown类型是any类型的对立面,因为它在其类型中不包含任何内容。一个不包含任何内容的类型可能看起来没有用。然而,如果进行了检查以允许 TypeScript 扩展它,变量的类型可以被扩展。

  1. 在我们给 TypeScript 提供信息以扩展data之前,将其引用的属性从firstName更改为name

    fetch("https://swapi.dev/api/people/1")
    
      .then((response) => response.json())
    
      .then((data: unknown) => {
    
        console.log("name", data.name);
    
      });
    

name是一个有效的属性,但仍然发生类型错误。这是因为data仍然是unknown

  1. 现在将代码中的高亮部分更改以扩展data类型:

    fetch("https://swapi.dev/api/people/1")
    
      .then((response) => response.json())
    
      .then((data: unknown) => {
    
        if (isCharacter(data)) {
    
          console.log("name", data.name);
    
        }
    
      });
    
    function isCharacter(
    
      character: any
    
    ): character is { name: string } {
    
      return "name" in character;
    
    }
    

可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter2/Section2-Basic-types/Using-the-unknown-type/code.ts复制代码片段。

if语句使用一个名为isCharacter的函数来验证对象中是否包含name属性。在这个例子中,这个调用的结果是true,所以逻辑将流入if分支。

注意isCharacter的返回类型,它是:

character is { name: string }

如果函数返回true,则这是一个character{ name: string }的类型。在这个例子中,类型谓词是true,所以character被扩展为一个具有name字符串属性的对象。

  1. 在引用data变量的每一行上悬停。data最初具有unknown类型,其中它被赋予了一个类型注解。然后,在if分支内部,它被扩展到{name: string}

图 2.16 – 分配给数据的扩展类型

图 2.16 – 分配给数据的扩展类型

注意到类型错误也已经消失了。太好了!

  1. 接下来,运行代码。你将在控制台看到Luke Skywalker输出。

总结来说,unknown类型是对于不确定数据类型的优秀选择。然而,你不能与unknown变量交互——变量必须在任何交互之前被扩展到其他类型。

接下来,我们将学习一个用于函数不返回值的类型。

使用 void 类型

void类型用于表示函数的返回类型,在这种情况下,函数不返回任何值。

例如,在 TypeScript Playground 中输入以下函数:

function logText(text: string) {
  console.log(text);
}

悬停在函数名上可以确认函数的返回类型被赋予了一个void类型。

图 2.17 – 返回类型已确认为准 void

图 2.17 – 返回类型已确认为准 void

你可能认为你可以使用undefined作为前面示例的返回类型:

function logText(text: string): undefined {
  console.log(text);
}

然而,这引发了一个类型错误,因为undefined类型的返回类型意味着函数预期会返回一个值(类型为undefined)。示例函数没有返回任何值,所以返回类型是void

总结来说,void是一个特殊类型,用于函数的返回类型,在这种情况下,函数没有返回语句。

接下来,我们将学习never类型。

使用 never 类型

never类型表示永远不会发生的事情,通常用于指定不可达的代码区域。让我们在 TypeScript Playground 中探索一个例子:

  1. 删除任何现有代码并输入以下代码:

    function foreverTask(taskName: string): never {
    
      while (true) {
    
        console.log(`Doing ${taskName} over and over again       ...`);
    
      }
    
    }
    

函数调用了一个无限循环,这意味着函数永远不会退出。因此,我们给函数赋予了一个never类型的返回类型注解,因为我们不期望函数会退出。这与void不同,因为void意味着它将会退出,但没有返回值。

注意

在前面的例子中,我们使用 JavaScript 模板字符串来构建输出到控制台的字符串。模板字符串由反引号(` `)包围,并可以包含以美元符号($${expression})为前缀的花括号中的 JavaScript 表达式。当需要将静态文本与变量合并时,模板字符串非常出色。有关模板字符串的更多信息,请参阅此链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

  1. foreverTask函数更改为跳出循环:

    function foreverTask(taskName: string): never {
    
      while (true) {
    
        console.log(`Doing ${taskName} over and over again       ...`);
    
        break;
    
      }
    
    }
    

TypeScript 正确地抱怨:

图 2.18 – never 返回类型上的类型错误

图 2.18 – never 返回类型上的类型错误

  1. 删除 break 语句并删除 never 返回类型注解:

    function foreverTask(taskName: string) {
    
      while (true) {
    
        console.log(`Doing ${taskName} over and over again ...`);
    
      }
    
    }
    
  2. 将鼠标悬停在 foreverTask 函数名称上。我们可以看到 TypeScript 推断的返回类型为 void

图 2.19 – 返回类型推断为 void

图 2.19 – 返回类型推断为 void

因此,TypeScript 在这种情况下无法推断出 never 类型。相反,它推断出返回类型为 void,这意味着函数将不带任何值退出,但这在本例中并不适用。这是一个提醒,始终要检查推断的类型,并在适当的地方使用类型注解。

总结来说,never 类型用于代码永远不会到达的地方。

接下来,让我们来介绍数组。

使用数组

数组是 TypeScript 从 JavaScript 继承的结构。我们像往常一样向数组添加类型注解,但在末尾使用方括号 [] 来表示这是一个数组类型。

让我们在 TypeScript Playground 中探索一个示例:

  1. 删除任何现有代码,并输入以下内容:

    const numbers: number[] = [];
    

或者,可以使用 Array 泛型类型语法:

const numbers: Array<number> = [];

我们将在 第十一章 可重用组件 中学习 TypeScript 中的泛型。

  1. 通过使用数组的 push 函数向数组中添加 1

    numbers.push(1);
    
  2. 现在向数组中添加一个字符串:

    numbers.push("two");
    

正如我们所预期的那样,会抛出一个类型错误:

图 2.20 – 向数字数组添加字符串类型时的类型错误

图 2.20 – 向数字数组添加字符串类型时的类型错误

  1. 现在将所有代码替换为以下内容:

    const numbers = [1, 2, 3];
    
  2. 将鼠标悬停在 numbers 上以验证 TypeScript 已经推断出它的类型为 number[]

图 2.21 – 数组类型推断

图 2.21 – 数组类型推断

极好 – 我们可以看到 TypeScript 的类型推断在数组上是如何工作的!

数组是用于结构化数据最常用的类型之一。在前面的示例中,我们只使用了一个具有 number 类型元素的数组,但可以使用任何类型作为元素,包括具有自己属性的对象。

在本节中,我们回顾了所有基本类型:

  • TypeScript 向 JavaScript 类型添加了许多有用的类型,例如 Date,并且能够表示数组。

  • TypeScript 可以从其分配的值推断出变量的类型。当类型推断无法给出所需类型时,可以使用类型注解。

  • 具有类型 any 的变量不会进行类型检查,因此应避免使用此类型。

  • unknown 类型是 any 的强类型替代品,但 unknown 变量必须进行类型提升才能进行交互。

  • void 是一个不返回值的函数的返回类型。

  • never 类型可以用来标记代码中无法到达的区域。

  • 可以在数组项目类型之后使用方括号来定义数组类型。

在下一节中,我们将学习如何创建自己的类型。

创建 TypeScript 类型

上一节展示了 TypeScript 拥有一套非常出色的标准类型。在本节中,我们将学习如何创建我们自己的类型。我们将从学习创建对象类型的三个不同方法开始。然后,我们将学习关于强类型 JavaScript 类的内容。最后,我们将学习两种创建用于存储一系列值变量的类型的方法。

使用对象类型

对象在 JavaScript 程序中非常常见,因此学习如何在 TypeScript 中表示它们非常重要。实际上,我们已经在本章前面使用对象类型为calculateTotalPrice函数中的product参数创建了一个对象类型。以下是product参数类型注解的提醒:

function calculateTotalPrice(
  product: { name: string; unitPrice: number },
  ...
) {
  ...
}

TypeScript 中的对象类型表示得有点像 JavaScript 对象字面量。然而,与属性值不同,属性类型被指定。对象定义中的属性可以用分号或逗号分隔,但使用分号是常见做法。

清除 TypeScript Playground 中的任何现有代码,并按照以下示例来探索对象类型:

  1. 将以下变量赋值给一个对象:

    let table = {name: "Table", unitPrice: 450};
    

如果将鼠标悬停在table变量上,你会看到它被推断为以下类型:

{
  name: string;
  unitPrice: number;
}

因此,类型推断在对象中工作得很好。

  1. 现在,在下一行,尝试将discount属性设置为10

    table.discount = 10;
    

尽管类型中不存在discount属性,但只有nameunitPrice属性存在。因此,发生类型错误。

  1. 假设我们想要表示一个包含nameunitPrice属性的product对象,但希望unitPrice是可选的。删除现有代码,并用以下代码替换:

    const table: { name: string; unitPrice: number } = {
    
      name: "Table",
    
    };
    
  2. 这会引发类型错误,因为unitPrice在类型注解中是一个必需的属性。我们可以使用以下?符号来使其可选而不是必需:

    const table: { name: string; unitPrice?: number } = {
    
      name: "Table",
    
    };
    

类型错误消失了。

注意

在函数中可以使用?符号表示可选参数。例如,myFunction(requiredParam: string, optionalParam: string)

现在,让我们学习一种简化对象类型定义的方法。

创建类型别名

我们在上一个示例中使用的类型注解相当长,对于更复杂的对象结构会更长。此外,必须为不同的变量写入相同的对象结构会有些令人沮丧:

const table: { name: string; unitPrice?: number } = ...;
const chair: { name: string; unitPrice?: number } = ...;

类型别名解决了这些问题。正如其名所示,类型别名指的是另一个类型,其语法如下:

type YourTypeAliasName = AnExistingType;

打开 TypeScript Playground 并跟随示例来探索类型别名:

  1. 首先,为我们在上一个示例中使用的商品对象结构创建一个类型别名:

    type Product = { name: string; unitPrice?: number };
    
  2. 现在,将两个变量分配给这个Product类型:

    let table: Product = { name: "Table" };
    
    let chair: Product = { name: "Chair", unitPrice: 40 };
    

这样就干净多了!

  1. 类型别名可以使用&符号扩展另一个对象。通过添加以下类型别名创建一个折扣产品的第二个类型:

    type DiscountedProduct = Product & { discount: number };
    

DiscountedProduct表示一个包含nameunitPrice(可选)和discount属性的对象。

注意

使用&符号扩展另一个类型的类型被称为交集类型

  1. 按照以下方式添加以下变量,使用DiscountedProduct类型:

    let chairOnSale: DiscountedProduct = {
    
      name: "Chair on Sale",
    
      unitPrice: 30,
    
      discount: 5,
    
    };
    
  2. 类型别名也可以用来表示函数。添加以下类型别名来表示一个函数:

    type Purchase = (quantity: number) => void;
    

前面的类型表示一个包含number参数的函数,并且不返回任何内容。

  1. 使用Purchase类型在Product类型中创建purchase函数属性,如下所示:

    type Purchase = (quantity: number) => void;
    
    type Product = {
    
      name: string;
    
      unitPrice?: number;
    
      purchase: Purchase;
    
    };
    

因为需要purchase函数属性,tablechairchairOnSale变量声明将引发类型错误。

  1. 按照以下方式向table变量声明中添加purchase函数属性:

    let table: Product = {
    
      name: "Table",
    
      purchase: (quantity) =>
    
        console.log(`Purchased ${quantity} tables`),
    
    };
    
    table.purchase(4);
    

table变量声明上的类型错误已解决。

  1. 可以以类似chairchairOnSale变量声明的方式添加purchase属性来解决这个问题。然而,在这个探索中忽略这些类型错误,继续到下一步。

  2. 点击运行选项来运行购买四张桌子的代码。“已购买 4 张桌子”输出到控制台。

总结来说,类型别名允许将现有类型组合在一起,并提高类型的可读性和可重用性。我们将在本书中广泛使用类型别名。

接下来,我们将探索创建类型的另一种方法。保持 TypeScript Playground 打开,代码保持不变——我们将在下一节中使用它。

创建接口

正如我们在上一个示例中使用类型别名创建的那样,可以使用 TypeScript 的interface关键字创建对象类型,后跟其名称,然后是括号中组成interface的部分:

interface Product {
  ...
}

前往包含类型别名探索中代码的 TypeScript Playground,并跟随操作来探索接口:

  1. 首先,将Product类型别名替换为以下Product接口:

    interface Product {
    
      name: string;
    
      unitPrice?: number;
    
    }
    

table变量赋值出现类型错误,因为purchase属性尚未存在——我们将在第 4 步中添加它。然而,chair变量赋值编译时没有错误。

  1. 接口可以使用extends关键字扩展另一个接口。将DiscountedProduct类型别名替换为以下接口:

    interface DiscountedProduct extends Product {
    
      discount: number;
    
    }
    

注意到chairOnSale变量赋值编译时没有错误。

  1. 接口也可以用来表示函数。添加以下接口来表示一个函数,替换类型别名版本:

    interface Purchase {(quantity: number): void}
    

创建函数的接口语法不如使用类型别名直观。

  1. 按照以下方式将Purchase接口添加到Product接口中:

    interface Product {
    
      name: string;
    
      unitPrice?: number;
    
      purchase: Purchase;
    
    }
    

table变量声明上的类型错误已解决,但现在在chairchairOnSale变量声明上引发了类型错误。

  1. 点击运行选项来运行购买四张桌子的代码。“已购买 4 张桌子”输出到控制台。

在前面的步骤中,我们使用接口和使用类型别名执行了相同的任务。因此,显而易见的问题是,我应该何时使用类型别名而不是接口,反之亦然? 类型别名和接口在创建对象类型方面的功能非常相似——所以简单的答案是,这取决于对对象类型的偏好。然而,类型别名可以创建接口无法创建的类型,例如联合类型,我们将在本章后面介绍。

注意

有关类型别名和接口之间差异的更多信息,请参阅以下链接:www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces

本书其余部分使用类型别名而不是接口来定义类型。

接下来,我们将学习如何使用 TypeScript 与类一起使用。

创建类

是标准的 JavaScript 功能,它作为创建对象的模板。在类中定义的属性和方法将自动包含在从该类创建的对象中。

打开 TypeScript Playground,删除任何现有代码,并按照以下步骤执行以探索 TypeScript 中的类:

  1. 添加以下代码以创建一个表示产品并具有名称和单价属性的类的示例:

    class Product {
    
      name;
    
      unitPrice;
    
    }
    

如果你悬停在 nameunitPrice 属性上,你会看到它们具有 any 类型。正如我们所知,这意味着不会对它们进行类型检查。

  1. 将以下类型注解添加到属性中:

    class Product {
    
      name: string;
    
      unitPrice: number;
    
    }
    

不幸的是,TypeScript 抛出了以下错误:

图 2.22 – 类属性上的类型错误

图 2.22 – 类属性上的类型错误

错误是因为当创建类的实例时,这些属性值将是 undefined,这不在 stringnumber 类型中。

  1. 一种解决方案是使属性可选,以便它们可以接受 undefined 作为值。通过在类型注解的开始处添加 ? 符号来尝试此解决方案:

    class Product {
    
      name?: string;
    
      unitPrice?: number;
    
    }
    
  2. 如果我们不希望值最初是 undefined,我们可以像这样分配初始值:

    class Product {
    
      name = "";
    
      unitPrice = 0;
    
    }
    

如果你现在悬停在属性上,你会看到 name 已推断为 string 类型,而 unitPrice 已推断为 number 类型。

  1. 向类属性添加类型的另一种方法是在构造函数中。移除分配给属性的值,并将构造函数添加到类中,如下所示:

    class Product {
    
      name;
    
      unitPrice;
    
      constructor(name: string, unitPrice: number) {
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
      }
    
    }
    

如果你悬停在属性上,你会看到已推断出正确的类型。

  1. 实际上,如果构造函数参数被标记为 public,则不需要定义属性。

    class Product {
    
      constructor(public name: string, public unitPrice:     number) {
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
      }
    
    }
    

TypeScript 会自动为标记为 public 的构造函数参数创建属性。

  1. 可以像我们之前为函数所做的那样,将类型注解添加到方法参数和返回值中:

    class Product {
    
      constructor(public name: string, public unitPrice:     number) {
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
      }
    
      getDiscountedPrice(discount: number): number {
    
        return this.unitPrice - discount;
    
      }
    
    }
    
  2. 现在创建类的实例并将它的折扣价格输出到控制台:

    const table = new Product("Table", 45);
    
    console.log(table.getDiscountedPrice(5));
    

如果运行代码,40会被输出到控制台。

总结一下,类属性可以在构造函数中或通过分配默认值来指定类型。类方法可以像常规 JavaScript 函数一样强类型化。

注意

更多关于类的信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes

接下来,我们将学习如何创建一个表示一系列值的类型。

创建枚举

enum关键字,后面跟着我们想要给它的名字,然后是其可能的值,用大括号括起来。

让我们在 TypeScript Playground 中探索一个例子:

  1. 首先,创建包含LowMediumHigh值的Level枚举:

    enum Level {
    
      Low,
    
      Medium,
    
      High
    
    }
    
  2. 现在,创建一个level变量,并将其赋值为Level枚举中的LowHigh值。也将level值输出到控制台:

    let level = Level.Low;
    
    console.log(level);
    
    level = Level.High
    
    console.log(level);
    

注意,当你引用枚举时,你会得到智能感知。

  1. 点击运行选项来执行代码并观察枚举值:

图 2.23 – 枚举值的输出

图 2.23 – 枚举值的输出

默认情况下,枚举是零基数字(这意味着第一个枚举值是 0,下一个是 1,再下一个是 2,依此类推)。在先前的例子中,Level.Low0Level.Medium1Level.High2

  1. 而不是使用默认值,我们可以在等于(=)符号之后显式地为每个枚举项定义自定义值。显式地将值设置为13之间:

    enum Level {
    
      Low = 1,
    
      Medium = 2,
    
      High = 3
    
    }
    

你可以重新运行代码来验证这一点。

  1. 现在,让我们做一些有趣的事情。将level赋值为大于 3 的数字:

    level = 10;
    

注意,这里没有发生类型错误。这有点令人惊讶——基于数字的枚举并不像我们希望的那样类型安全。

  1. 而不是使用数字枚举值,让我们尝试使用字符串。将所有当前代码替换为以下内容:

    enum Level {
    
      Low = "L",
    
      Medium = "M",
    
      High = "H"
    
    }
    
    let level = Level.Low;
    
    console.log(level);
    
    level = Level.High
    
    console.log(level);
    

如果运行此代码,我们会看到预期的LH输出到控制台。

  1. 添加另一行代码,将level赋值为以下字符串:

    level = "VH";
    
    level = "M"
    

我们立即在这些赋值上看到类型错误被抛出:

图 2.24 – 确认字符串枚举是类型安全的

图 2.24 – 确认字符串枚举是类型安全的

总结一下,枚举是一种用用户友好的名称表示一系列值的方法。默认情况下,它们是零基数字,并不像我们希望的那样类型安全。然而,我们可以将枚举基于字符串,这更类型安全。

接下来,我们将学习 TypeScript 中的联合类型。

创建联合类型

联合类型是多个其他类型的数学并集,用于创建一个新类型。与枚举类似,联合类型可以表示一系列值。如前所述,可以使用类型别名来创建联合类型。

联合类型的一个例子如下:

type Level = "H" | "M" | "L";

这个 Level 类型与我们之前创建的 Level 类型的枚举版本类似。区别在于,联合类型只包含值("H""M""L")而不是名称("High""Medium""Large")和值。

清除 TypeScript Playground 中的任何现有代码,让我们来玩一玩联合类型:

  1. 首先创建一个表示 "red""green""blue" 的类型:

    type RGB = "red" | "green" | "blue";
    

注意,这个类型是字符串的联合,但联合类型可以由任何类型组成——甚至可以是混合类型!

  1. 创建一个具有 RGB 类型的变量并分配一个有效值:

    let color: RGB = "red";
    
  2. 现在尝试分配一个类型外的值:

    color = "yellow";
    

如预期,发生类型错误:

图 2.25 – 联合类型上的类型错误

图 2.25 – 联合类型上的类型错误

当一个类型只能持有特定的一组字符串时,如前例所示,由字符串组成的联合类型非常出色。

这里是对我们关于创建类型的了解的回顾:

  • 对象和函数可以使用类型别名或接口来表示。它们具有非常相似的功能,但类型别名语法在表示函数时更为直观。

  • ? 符号可以指定对象属性或函数参数是可选的。

  • 可以将类型注解添加到类属性、构造函数和方法参数中,以使它们具有类型安全性。

  • 与基于字符串的联合类型类似,基于字符串的枚举非常适合一组特定的字符串。如果字符串具有意义,那么字符串联合类型是最简单的方法。如果字符串没有意义,则可以使用字符串枚举来使它们可读。

现在我们已经涵盖了类型,接下来,我们将学习 TypeScript 编译器。

使用 TypeScript 编译器

在本节中,我们将学习如何使用 TypeScript 编译器进行代码类型检查并将其转换为 JavaScript。首先,我们将使用 Visual Studio Code 创建一个包含我们在上一节中编写的代码的简单 TypeScript 项目。然后,我们将使用 Visual Studio Code 内部的终端与 TypeScript 编译器进行交互。

在您选择的空白文件夹中打开 Visual Studio Code,并执行以下步骤:

  1. 在包含以下内容的 package.json 中:

    {
    
      "name": "tsc-play",
    
      "dependencies": {
    
        "typescript": "⁴.6.4"
    
      },
    
      "scripts": {
    
        "build": "tsc src/product.ts"
    
      }
    
    }
    

该文件定义了一个项目名称为 tsc-play,并将 TypeScript 设置为唯一的依赖项。该文件还定义了一个名为 build 的 npm 脚本,它将调用 TypeScript 编译器(tsc),并将 src 文件夹中的 product.ts 文件传递给它。不要担心 product.ts 文件不存在——我们将在 步骤 3 中创建它。

  1. 现在,通过从 终端 菜单中选择 New Terminal 来打开 Visual Studio Code 终端,然后输入以下命令:

    npm install
    

这将安装 package.jsondependencies 部分中列出的所有库。因此,这将安装 TypeScript。

  1. 创建一个名为 src 的文件夹,然后在其中创建一个名为 product.ts 的文件。

  2. 打开 product.ts 并添加以下内容:

    class Product {
    
      constructor(public name: string, public unitPrice:     number) {
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
      }
    
      getDiscountedPrice(discount: number): number {
    
        return this.unitPrice - discount;
    
      }
    
    }
    
    const table = new Product("Table", 45);
    
    console.log(table.getDiscountedPrice(5));
    

这段代码可以在使用类的部分中找到。您可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter2/Section4-Using-the-compiler/src/product.ts复制此代码。

  1. 在终端中输入以下命令:

    npm run build
    

这将运行我们在第一步中定义的 npm build 脚本。

命令完成后,注意在src文件夹中product.ts旁边会出现一个product.js文件。

  1. 打开转换后的product.js文件并阅读内容。它看起来如下所示:

    var Product = /** @class */ (function () {
    
      function Product(name, unitPrice) {
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
        this.name = name;
    
        this.unitPrice = unitPrice;
    
      }
    
      Product.prototype.getDiscountedPrice = function     (discount) {
    
        return this.unitPrice - discount;
    
      };
    
      return Product;
    
    })();
    
    var table = new Product("Table", 45);
    
    console.log(table.getDiscountedPrice(5));
    

注意到类型注解已被删除,因为它们不是有效的 JavaScript。同时注意,它已被转换为 JavaScript,能够在非常旧的浏览器中运行。

TypeScript 编译器使用的默认配置并不理想。例如,我们可能希望将转换后的 JavaScript 放在一个完全独立的文件夹中,并且可能希望针对更新的浏览器。

  1. 可以使用名为tsconfig.json的文件来配置 TypeScript 编译器。在项目的根目录中添加一个tsconfig.json文件,包含以下代码:

    {
    
      "compilerOptions": {
    
        "outDir": "build",
    
        "target": "esnext",
    
        "module": "esnext",
    
        "lib": ["DOM", "esnext"],
    
        "strict": true,
    
        "jsx": "react",
    
        "moduleResolution": "node",
    
        "noEmitOnError": true
    
      },
    
      "include": ["src/**/*"],
    
      "exclude": ["node_modules", "build"]
    
    }
    

您可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter2/Section4-Using-the-compiler/tsconfig.json复制此代码。

下面是compilerOptions字段中每个设置的说明:

  • outDir:这是放置转换后的 JavaScript 的文件夹。

  • target:这是我们想要转换到的 JavaScript 版本。esnext目标意味着下一个版本。

  • Module:这是代码中使用的模块类型。esnext模块意味着标准 JavaScript 模块。

  • Lib:在类型检查过程中包含的标准库类型。DOM提供浏览器 DOM API 类型,而esnext是 JavaScript 下一个版本 API 的类型。

  • Strict:当设置为true时,表示最严格的类型检查级别。

  • Jsx:当设置为React时,允许编译器转换 React 的 JSX。

  • moduleResolution:这是查找依赖项的方式。我们希望 TypeScript 在node_modules文件夹中查找,因此我们选择了node

  • noEmitOnError:当设置为true时,表示如果发现类型错误,则不会发生转换。

include字段指定要编译的 TypeScript 文件,而exclude字段指定要排除的文件。

注意

关于 TypeScript 编译器选项的更多信息,请参阅以下链接:www.typescriptlang.org/tsconfig

  1. TypeScript 编译器配置现在指定了src文件夹中的所有文件都要进行编译。因此,从package.json中的build脚本中删除文件路径:

    {
    
      ...,
    
      "scripts": {
    
        "build": "tsc"
    
      }
    
    }
    
  2. 删除src文件夹中之前的转换后的product.js文件。

  3. 在终端中重新运行 build 命令:

    npm run build
    

这次转换的文件被放置在 build 文件夹中。您还会注意到,现在转换的 JavaScript 使用了现代浏览器支持的类。

  1. 我们将要尝试的最后一件事是类型错误。打开 product.ts 并更新构造函数以引用错误的属性名:

    class Product {
    
      constructor(public name: string, public unitPrice:     number) {
    
        this.name = name;
    
        this.price = unitPrice;
    
      }
    
      ...
    
    }
    
  2. 删除 build 文件夹以移除之前转换的 JavaScript 文件。

  3. 在终端中重新运行 build 命令:

    npm run build
    

类型错误在终端中报告。注意,JavaScript 文件没有被转换。

总结来说,TypeScript 有一个名为 tsc 的编译器,我们可以使用它来执行类型检查和转换,作为持续集成过程的一部分。编译器非常灵活,可以使用名为 tsconfig.json 的文件进行配置。值得注意的是,Babel 通常用于转换 TypeScript(以及 React),让 TypeScript 专注于类型检查。

接下来,我们将回顾本章所学的内容。

摘要

TypeScript 通过丰富的类型系统补充了 JavaScript,在本章中,我们通过使用 TypeScript 的类型检查来早期捕获错误。

我们还了解到,JavaScript 类型,如 numberstring,可以在 TypeScript 中使用,以及仅存在于 TypeScript 中的类型,如 Dateunknown

我们探讨了联合类型,并了解到这些类型非常适合表示一组特定的字符串。我们现在明白,如果字符串值不是非常有意义,字符串枚举是字符串联合类型的替代方案。

可以使用类型别名创建新类型。我们了解到类型别名可以基于对象、函数,甚至是联合类型。我们现在知道,类型注解中的 ? 符号使对象属性或函数参数成为可选的。

我们还了解了很多关于 TypeScript 编译器及其如何在不同用例中良好工作的信息,因为它非常可配置。当我们开始在下一章中使用 TypeScript 与 React 一起工作时,这将是重要的。在那里,我们将在学习如何为 React 和 TypeScript 项目设置不同的方式之前,学习如何为 React props 和 state 设置强类型。

问题

回答以下问题以检查您对 TypeScript 的了解:

  1. 在以下代码中,flag 变量的推断类型会是什么?

    let flag = false;
    
  2. 以下函数的返回类型是什么?

    function log(message: string) {
    
      return console.log(message);
    
    }
    
  3. 日期数组的类型注解是什么?

  4. 在以下代码中会发生类型错误吗?

    type Point = {x: number; y: number; z?: number};
    
    const point: Point = { x: 24, y: 65 };
    
  5. 使用类型别名创建一个只能持有介于 1 和 3 之间(包括 1 和 3)的整数值的数字。

  6. 当发现类型错误时,可以使用哪个 TypeScript 编译器选项来防止转换过程?

  7. 以下代码会引发类型错误,因为 lastSale 不能接受 null 值:

    type Product = {
    
      name: string;
    
      lastSale: Date;
    
    }
    
    const table: Product = {name: "Table", lastSale: null}
    

如何更改 Product 类型以允许 lastSale 接受 null 值?

答案

  1. flag 变量会被推断为 boolean 类型。

  2. 函数中的返回类型是 void

  3. 日期数组可以表示为Date[]Array<Date>

  4. point变量上不会引发类型错误。因为它可选,所以不需要包含z属性。

  5. 可以创建一个用于数字 1-3 的类型,如下所示:

    type OneToThree = 1 | 2 | 3;
    
  6. 当发现类型错误时,可以使用noEmitOnError编译器选项(设置为true)来防止编译过程。

  7. 联合类型可用于lastSale属性,以便它接受null值:

    type Product = {
    
      name: string;
    
      lastSale: Date | null;
    
    }
    
    const table: Product = {name: "Table", lastSale: null}
    

第三章:设置 React 和 TypeScript

在本章中,我们将学习如何同时使用 React 和 TypeScript。我们将首先通过使用 webpack 这个工具的步骤来创建一个 React 和 TypeScript 项目。然后我们将创建另一个项目,但这次使用 Create React App 这个工具来展示如何加快创建 React 和 TypeScript 项目的流程。

然后,本章将介绍如何使用 TypeScript 使 React 的 props 和 states 类型安全,扩展第一章节中构建的 alert 组件。最后,我们将学习如何使用 React 的 DevTools 调试你的应用。

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

  • 使用 webpack 创建项目

  • 使用 Create React App 创建项目

  • 创建 React 和 TypeScript 组件

技术要求

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

  • Node.jsnpm:React 和 TypeScript 依赖于这些。你可以从nodejs.org/en/download/安装它们。

  • Visual Studio Code:我们将使用这个编辑器来编写代码和执行终端命令。你可以从code.visualstudio.com/安装它。

本章中所有的代码片段都可以在网上找到,链接为github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter3

使用 webpack 创建项目

设置 React 和 TypeScript 项目是棘手的,因为 JSX 和 TypeScript 代码都需要被转换成 JavaScript。在本节中,我们将逐步介绍如何使用一个名为 webpack 的工具来设置 React 和 TypeScript 项目。

介绍 webpack

Webpack 是一个将 JavaScript 源代码文件打包在一起的工具。它还可以打包 CSS 和图片。它可以在扫描文件时运行其他工具,如 Babel,将 React 转换成 JavaScript,以及将 TypeScript 类型检查器。它是一个成熟且在 React 社区中极其流行的工具,许多 React 项目都依赖于它。

Webpack 非常灵活,但不幸的是,它需要大量的配置。当我们使用 webpack 创建我们的项目时,我们将见证这一点。

重要的是要理解 webpack 不是一个项目创建工具。例如,它不会安装 React 或 TypeScript - 我们必须单独做这件事。相反,一旦安装和配置,webpack 会将 React 和 TypeScript 等工具结合起来。因此,我们不会在本节中早期使用 webpack。

创建文件夹结构

我们将首先为项目创建一个简单的文件夹结构。这个结构将把项目的配置文件与源代码分开。执行以下步骤来完成这个任务:

  1. 打开你想要项目所在的文件夹中的 Visual Studio Code。

  2. src。通过在src上右键单击可以创建一个文件夹,其中src是源代码的简称。

因此,src 文件夹将包含应用的所有源代码。项目配置文件将被放置在项目的根目录中。

接下来,我们将定义关于项目的关键信息。

创建 package.json

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

在项目的根目录中创建一个 package.json 文件,内容如下:

{
  "name": "my-app",
  "description": "My React and TypeScript app",
  "version": "0.0.1"
}

这个文件目前包含最少的信息。然而,它最终将包含其他详细信息,例如 React 和 TypeScript 作为应用的依赖项。

注意

更多信息可以在以下链接的 package.json 中找到:docs.npmjs.com/cli/v8/configuring-npm/package-json

接下来,我们将添加将托管 React 应用的网页。

添加网页

一个 HTML 页面将托管应用。在 src 文件夹中,创建一个名为 index.html 的文件,内容如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>My app</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

这个代码片段可以从以下链接复制并粘贴:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter3/Section1-Creating-a-project-with-Webpack/src/index.html

React 应用将被注入到具有 "root" 属性值的 div 元素中。我们将在后面的章节中介绍如何注入 React 应用,即添加 React

添加 TypeScript

接下来,我们将安装 TypeScript 到项目中。为此,执行以下步骤:

  1. 首先,通过打开 终端 菜单并点击 新建终端 来打开 Visual Studio Code 终端。

  2. 我们从上一章知道,如果不指定任何选项使用 npm install,则会安装 package.json 内列出的依赖项。install 命令有选项用于安装尚未在 package.json 中列出的特定包。在终端中执行以下命令来安装 typescript

    npm install --save-dev typescript
    

我们还包含了一个 --save-dev 选项来指定 typescript 应该作为一个仅开发依赖项安装。这是因为 TypeScript 只在开发期间需要,而不在运行时需要。

  1. 命令完成后,打开 package.json。您将看到 typescript 现在在 devDependencies 部分中被列为一个开发依赖项:

    {
    
      "name": "my-app",
    
      "description": "My React and TypeScript app",
    
      "version": "0.0.1",
    
      "devDependencies": {
    
        "typescript": "⁴.6.4"
    
      }
    
    }
    

注意,前面代码片段中 typescript 的版本(4.6.4)可能和您的示例不同。这是因为 npm install 会安装依赖项的最新版本,除非在命令中指定了版本。

  1. 接下来,我们将创建一个 TypeScript 配置文件。请注意,我们不会配置 TypeScript 进行任何转换编译 – 我们将使用 Babel 来做,这将在后面介绍。因此,TypeScript 配置将专注于类型检查。

要完成这个任务,请在root文件夹中创建一个名为tsconfig.json的文件,并将以下内容输入到其中:

{
  "compilerOptions": {
    "noEmit": true,
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "jsx": "react",
    "forceConsistentCasingInFileNames": true,
    "strict": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

此代码片段可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter3/Section1-Creating-a-project-with-Webpack/tsconfig.json复制和粘贴。

这里是对上一章未解释的每个设置的说明:

  • noEmit设置为true可以阻止 TypeScript 编译器进行任何转换。

  • allowSyntheticDefaultImportsesModuleInterop设置为true允许 React 以默认导入的方式导入,如下所示:

    import React from 'react'
    
    • 如果不将这些设置设置为true,React 将必须像这样导入:
    import * as React from 'react'
    
  • forceConsistentCasingInFileNames设置为true可以启用类型检查过程,检查导入语句中引用的文件名的首字母大小写是否一致。

注意

关于 TypeScript 编译器选项的更多信息,请参阅以下链接:www.typescriptlang.org/docs/handbook/compiler-options.html

添加 React

接下来,我们将安装 React 及其 TypeScript 类型到项目中。然后我们将添加一个 React 根组件。为此,执行以下步骤:

  1. 在终端中执行以下命令以安装 React:

    npm install react react-dom
    

React 有两个库:

  • 核心库被称为react,它被用于所有版本的 React。

  • 特定的 React 变体,用于构建 Web 应用的变体,称为react-dom。另一个变体的例子是用于构建移动应用的 React Native 变体。

  1. React 不包含 TypeScript 类型——相反,它们在单独的 npm 包中。现在让我们安装这些包:

    npm install --save-dev @types/react @types/react-dom
    
  2. 根组件将位于src文件夹中的名为index.tsx的文件中。创建此文件并包含以下内容:

    import React, { StrictMode } from 'react';
    
    import { createRoot } from 'react-dom/client';
    
    const root = createRoot(
    
      document.getElementById('root') as HTMLElement
    
    );
    
    function App() {
    
      return <h1>My React and TypeScript App!</h1>;
    
    }
    
    root.render(
    
      <StrictMode>
    
        <App />
    
      </StrictMode>
    
    );
    

此代码片段可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter3/Section1-Creating-a-project-with-Webpack/src/index.tsx复制和粘贴。

该文件的结构与第一章中警报组件项目中的index.js文件类似。它将 React 应用注入到具有id'root'的 DOM 元素中。应用很简单——它显示一个名为My React and TypeScript App!的标题。

注意,index文件的扩展名是.tsx而不是.js。这允许 Babel 和 TypeScript 在转换和类型检查过程中检测包含 JSX 的 TypeScript 文件。对于不包含任何 JSX 的 TypeScript 代码,可以使用.ts扩展名。

此外,请注意createRoot调用中的as HTMLElement

const root = createRoot(
  document.getElementById('root') as HTMLElement
);

这被称为HTMLElement | null,因为document.getElementById可能找不到元素并返回null。然而,我们确信元素会被找到,因为我们已经在index.html文件中指定了它,所以使用类型断言将类型缩小到HTMLElement是安全的。

React 现在已安装到项目中,项目还包含一个简单的 React 应用。

添加 Babel

如前所述,Babel 将在这个项目中将 React 和 TypeScript 代码转换为 JavaScript。执行以下步骤以安装和配置 Babel:

  1. 首先,在 Visual Studio Code 的终端中使用以下命令安装核心 Babel 库:

    npm install --save-dev @babel/core
    

Babel 作为开发依赖项安装,因为它仅在开发期间需要将代码转换为 JavaScript,而不是在应用运行时。

此命令的简短版本如下:

npm i -D @babel/core

i代表install-D代表--save-dev

  1. 接下来,安装一个名为@babel/preset-env的 Babel 插件,它允许使用最新的 JavaScript 功能:

    npm i -D @babel/preset-env
    
  2. 现在,安装一个名为@babel/preset-react的 Babel 插件,它可以将 React 代码转换为 JavaScript:

    npm i -D @babel/preset-react
    
  3. 类似地,安装一个名为@babel/preset-typescript的 Babel 插件,它可以将 TypeScript 代码转换为 JavaScript:

    npm i -D @babel/preset-typescript
    
  4. 最后两个要安装的插件允许在 JavaScript 中使用 async 和 await 功能:

    npm i -D @babel/plugin-transform-runtime @babel/runtime
    
  5. Babel 可以在一个名为.babelrc.json的文件中进行配置。在项目的根目录下创建此文件,并包含以下内容:

    {
    
      "presets": [
    
        "@babel/preset-env",
    
        "@babel/preset-react",
    
        "@babel/preset-typescript"
    
      ],
    
      "plugins": [
    
        [
    
          "@babel/plugin-transform-runtime",
    
          {
    
            "regenerator": true
    
          }
    
        ]
    
      ]
    
    }
    

此代码片段可以在github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter3/Section1-Creating-a-project-with-Webpack/.babelrc.json找到。

上述配置告诉 Babel 使用已安装的插件。Babel 现在已安装并配置。

注意

更多关于 Babel 的信息,请参阅以下链接:babeljs.io/

接下来,我们将使用 webpack 将所有内容粘合在一起。

添加 webpack

Webpack 是一个流行的工具,主要用于将 JavaScript 源代码文件捆绑在一起。它在扫描文件时可以运行其他工具,如 Babel。因此,我们将使用 webpack 扫描所有源文件并将它们转换为 JavaScript。Webpack 处理过程的输出将是一个在index.html中引用的单个 JavaScript 包。

安装 webpack

执行以下步骤以安装 webpack 及其相关库:

  1. 首先,在 Visual Studio Code 的终端中使用以下命令安装 webpack:

    npm i -D webpack webpack-cli
    

这将安装核心 webpack 库以及其命令行界面。

Webpack 在webpack包中包含 TypeScript 类型,因此我们不需要单独安装它们。

  1. 接下来,运行以下命令以安装 webpack 的开发服务器:

    npm i -D webpack-dev-server
    

在开发过程中,Webpack 的开发服务器用于托管 Web 应用程序,并在代码更改时自动更新。

  1. 需要一个 Webpack 插件来允许 Babel 将 React 和 TypeScript 代码转换为 JavaScript。这个插件被称为 babel-loader。使用以下命令安装它:

    npm i -D babel-loader
    
  2. Webpack 可以创建一个 index.html 文件来托管 React 应用程序。我们希望 Webpack 使用 src 文件夹中的 index.html 文件作为模板,并将 React 应用的包添加到其中。一个名为 html-webpack-plugin 的插件可以完成这项工作。使用以下命令安装此插件:

    npm i -D html-webpack-plugin
    

Webpack 及其相关库现在已安装。

配置 Webpack

接下来,我们将配置 Webpack 以完成我们所需的一切。可以创建开发和生产的不同配置,因为它们的要求略有不同。然而,在本章中,我们将专注于开发配置。执行以下步骤以配置 Webpack:

  1. 首先,安装一个名为 ts-node 的库,它允许在 TypeScript 文件中定义配置:

    npm i -D ts-node
    
  2. 现在,我们可以添加开发配置文件。在项目根目录下创建一个名为 webpack.dev.config.ts 的文件。这个文件中的代码很长,可以从以下链接复制粘贴:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter3/Section1-Creating-a-project-with-Webpack/webpack.dev.config.ts。在接下来的步骤中,我们将解释这个文件的不同部分。

  3. 配置文件以各种导入语句和一个配置对象类型开始:

    import path from 'path';
    
    import HtmlWebpackPlugin from 'html-webpack-plugin';
    
    import {
    
      Configuration as WebpackConfig,
    
      HotModuleReplacementPlugin,
    
    } from 'webpack';
    
    import {
    
      Configuration as WebpackDevServerConfig
    
    } from 'webpack-dev-server';
    
    type Configuration = WebpackConfig & {
    
      devServer?: WebpackDevServerConfig;
    
    }
    

让我们回顾以下最重要的几点:

  • path 节点库将告诉 Webpack 将包放置在哪里。

  • HtmlWebpackPlugin 将被用来创建 index.html

  • Webpack 的配置 TypeScript 类型来自 webpackwebpack-dev-server 包。因此,我们使用交集类型将它们结合起来,创建了一个名为 Configuration 的类型。

  1. 然后配置对象定义如下:

    const config: Configuration = {
    
      mode: 'development',
    
      output: {
    
        publicPath: '/',
    
      },
    
      entry: './src/index.tsx',
    
      ...
    
    };
    
    export default config;
    

让我们回顾以下最重要的几点:

  • mode 属性告诉 Webpack 配置是用于开发的,这意味着 React 开发工具将包含在包中。

  • output.publicPath 属性是应用中的根路径,这对于在开发服务器中正确实现深度链接非常重要。

  • entry 属性告诉 Webpack React 应用程序的入口点在哪里,在我们的项目中是 index.tsx

  • Webpack 期望配置对象是一个默认导出,因此我们将 config 对象作为默认导出。

  1. 以下代码突出了进一步的配置:

    const config: Configuration = {
    
      ...,
    
      module: {
    
        rules: [
    
          {
    
            test: /\.(ts|js)x?$/i,
    
            exclude: /node_modules/,
    
            use: {
    
              loader: 'babel-loader',
    
              options: {
    
                presets: ['@babel/preset-env', '@babel/              preset-react', '@babel/preset-typescript'],
    
              },
    
            },
    
          },
    
        ],
    
      },
    
      resolve: {
    
        extensions: ['.tsx', '.ts', '.js'],
    
      }
    
    };
    

module属性通知 webpack 如何处理不同的模块。我们需要告诉 webpack 对于具有.js.ts.tsx扩展名的文件使用babel-loader

resolve.extensions属性告诉 webpack 在模块解析期间查找 TypeScript 文件和 JavaScript 文件。

  1. 接下来,定义了几个插件:

    const config: Configuration = {
    
      ...,
    
      plugins: [
    
        new HtmlWebpackPlugin({
    
          template: 'src/index.html',
    
        }),
    
        new HotModuleReplacementPlugin(),
    
      ]
    
    };
    

如前所述,HtmlWebpackPlugin创建 HTML 文件。它已被配置为使用src文件夹中的index.html作为模板。

HotModuleReplacementPlugin允许在应用运行时更新模块,而无需完全重新加载。

  1. 最后,以下属性完成了配置:

    const config: Configuration = {
    
      ...,
    
      devtool: 'inline-source-map',
    
      devServer: {
    
        static: path.join(__dirname, 'dist'),
    
        historyApiFallback: true,
    
        port: 4000,
    
        open: true,
    
        hot: true,
    
      }
    
    };
    

devtool属性告诉 webpack 使用完整的内联源映射,这允许在转译之前调试原始源代码。

devServer属性配置 webpack 开发服务器。它配置了 Web 服务器根目录为dist文件夹,并在端口4000上提供文件。现在,historyApiFallback对于深度链接是必需的,并且我们还指定了在服务器启动后打开浏览器。

开发配置现在已经完成。但在我们尝试以开发模式运行应用之前,我们需要创建一个 npm 脚本来运行 webpack。

  1. 首先,打开package.json并添加一个包含start脚本的scripts部分:

    {
    
      ...,
    
      "scripts": {
    
        "start": "webpack serve --config webpack.dev.config.      ts"
    
      }
    
    }
    
  2. 现在,我们可以在终端运行以下命令以开发模式运行应用:

    npm run start
    

npm run命令在package.jsonscripts部分执行一个脚本。start脚本通常用于以开发模式运行程序。它如此常见,以至于 npm 不需要run部分就能识别。因此,命令可以缩短为以下形式:

npm start

几秒钟后,应用在默认浏览器中打开:

图 3.1 – 开发模式下在浏览器中运行的应用

图 3.1 – 开发模式下在浏览器中运行的应用

  1. 保持应用运行并打开index.tsx文件。将h1元素的内容更改为略有不同:

    function App() {
    
      return <h1>My Super React and TypeScript App!</h1>;
    
    }
    

当文件被保存时,请注意正在运行的应用会自动刷新:

图 3.2 – 应用自动刷新

图 3.2 – 应用自动刷新

这就完成了使用 webpack 设置 React 和 TypeScript 项目的配置。以下是设置的关键点回顾:

  • 需要一个 HTML 文件来托管 React 应用

  • Webpack 在 Babel 的帮助下将应用的 React 和 TypeScript 代码转译成 JavaScript,然后在 HTML 文件中引用它

  • Webpack 有一个开发服务器,它会自动在我们编写代码时刷新应用

注意

更多关于 webpack 的信息可在以下链接找到:webpack.js.org/

设置 React 和 TypeScript 应用程序需要做大量的工作,而且它只能完成我们将要构建的实际应用程序的一小部分。例如,不能使用 CSS,并且设置不支持单元测试。幸运的是,有一个更简单的方法来创建 React 和 TypeScript 项目,我们将在下一节中学习。不过,在本节中学到的内容非常重要,因为我们即将使用的工具在底层也使用了 webpack。

使用 Create React App 创建项目

Create React App 是创建 React 项目的流行工具。它基于 webpack,因此上一节的知识将帮助您了解 Create React App 的工作原理。在本节中,我们将使用 Create React App 创建一个 React 和 TypeScript 项目。

使用 Create React App

与上一节的设置不同,Create React App 会生成一个包含我们可能需要的所有常用工具的 React 和 TypeScript 项目,包括 CSS 和单元测试支持。

要使用 Create React App,请在您选择的空白文件夹中打开 Visual Studio Code 并运行以下命令:

npx create-react-app myapp --template typescript

npx 允许 npm 包临时安装和运行。这是运行项目脚手架工具(如 Create React App)的常用方法。

create-react-app 是用于创建项目的 Create React App 工具的包。我们已传递了应用程序名称 myapp。我们还指定了应使用 typescript 模板来创建项目。

创建项目需要花费大约一分钟的时间,但这比手动使用 webpack 创建要快得多!

当命令完成创建项目后,请在 Visual Studio Code 中重新打开位于 myapp 文件夹中的项目。请注意,如果您将应用程序命名为其他名称,您的目录可能会有所不同。在应用程序名称文件夹中非常重要;否则,依赖项可能会安装在不正确的位置。

接下来,我们将了解 linting 是什么,并将其扩展程序添加到 Visual Studio Code 中。

将 linting 添加到 Visual Studio Code

Linting 是检查代码中潜在问题的过程。在代码编写过程中,使用 linting 工具来捕捉问题是一种常见的做法。ESLint 是一个流行的工具,可以 lint React 和 TypeScript 代码。幸运的是,Create React App 已经在我们的项目中安装并配置了 ESLint。

例如 Visual Studio Code 这样的编辑器可以与 ESLint 集成,以突出显示潜在问题。按照以下步骤将 ESLint 扩展程序安装到 Visual Studio Code 中:

  1. 在 Visual Studio Code 中打开 扩展 区域。在 Windows 的 文件 菜单中的 首选项 菜单,或者在 Mac 的 代码 菜单中的 首选项 菜单中都可以找到 扩展 选项。

  2. 在左侧将出现一个扩展列表,并且可以在扩展列表上方的搜索框中搜索特定扩展。在扩展列表搜索框中输入 eslint

图 3.3 – Visual Studio Code ESLint 扩展

图 3.3 – Visual Studio Code ESLint 扩展

应该出现在列表顶部的由微软开发的扩展 ESLint。

  1. 点击 安装 按钮安装扩展。

  2. 现在,我们需要确保 ESLint 扩展已配置为检查 React 和 TypeScript。因此,在 Visual Studio Code 中打开 设置 区域。在 Windows 的 文件 菜单中的 首选项 菜单或 Mac 上的 代码 菜单中的 首选项 菜单中找到 设置 选项。

  3. 在设置搜索框中,输入 eslint: probe 并选择 工作区 选项卡:

图 3.4 – Visual Studio Code ESLint 探针设置

图 3.4 – Visual Studio Code ESLint 探针设置

此设置定义了 ESLint 检查代码时使用的语言。

  1. 确保列表中包含 typescripttypescriptreact。如果没有,请使用 添加 项目 按钮添加它们。

Visual Studio Code 的 ESLint 扩展现在已在项目中安装和配置。

注意

有关 ESLint 的更多信息,请参阅以下链接:eslint.org/

接下来,我们将向项目中添加自动代码格式化。

添加代码格式化

下一个我们将自动设置的工具有助于格式化代码。自动代码格式化确保代码格式一致,这有助于提高可读性。拥有格式一致的代码也有助于开发者在代码审查中看到重要的更改——而不是格式上的差异。

Prettier 是一个流行的工具,能够格式化 React 和 TypeScript 代码。不幸的是,Create React App 不会为我们安装和配置它。请按照以下步骤在项目中安装和配置 Prettier:

  1. 使用以下命令在 Visual Studio Code 的终端中安装 Prettier:

    npm i -D prettier
    

Prettier 作为开发依赖项安装,因为它仅在开发时间使用,而不在运行时使用。

  1. Prettier 与 ESLint 有重叠的样式规则,因此安装以下两个库以允许 Prettier 从 ESLint 负责样式规则:

    npm i -D eslint-config-prettier eslint-plugin-prettier
    

eslint-config-prettier 禁用冲突的 ESLint 规则,而 eslint-plugin-prettier 是一个使用 Prettier 格式化代码的 ESLint 规则。

  1. 需要更新 ESLint 配置以允许 Prettier 管理样式规则。Create React App 允许在 package.json 中的 eslintConfig 部分覆盖 ESLint 配置。如下所示,将 Prettier 规则添加到 package.json 中的 eslintConfig 部分:

    {
    
      ...,
    
      "eslintConfig": {
    
        "extends": [
    
          "react-app",
    
          "react-app/jest",
    
          "plugin:prettier/recommended"
    
        ]
    
      },
    
      ...
    
    }
    
  2. 可以在名为 .prettierrc.json 的文件中配置 Prettier。在根目录中创建此文件,并包含以下内容:

    {
    
      "printWidth": 100,
    
      "singleQuote": true,
    
      "semi": true,
    
      "tabWidth": 2,
    
      "trailingComma": "all",
    
      "endOfLine": "auto"
    
    }
    

我们指定了以下内容:

  • 行在 100 个字符处换行

  • 字符串限定符是单引号

  • 分号放置在语句的末尾

  • 缩进级别为两个空格

  • 在多行数组和对象的末尾添加尾随逗号

  • 保持现有的行结束符

注意

更多有关配置选项的信息可以在以下链接中找到:prettier.io/docs/en/options.html

Prettier 现已安装并配置到项目中。

Visual Studio Code 可以与 Prettier 集成,在保存源文件时自动格式化代码。因此,让我们将 Prettier 扩展安装到 Visual Studio Code 中:

  1. 在扩展列表的搜索框中打开 prettier。一个名为 Prettier – 代码格式化器 的扩展应该出现在列表的顶部:

图 3.5 – Visual Studio Code Prettier 扩展

图 3.5 – Visual Studio Code Prettier 扩展

  1. 点击 安装 按钮来安装扩展。

  2. 接下来,在 Visual Studio Code 中打开 设置 区域。选择 工作区 选项卡并确保 保存时格式化 选项被勾选:

图 3.6 – Visual Studio Code “保存时格式化”设置

图 3.6 – Visual Studio Code “保存时格式化”设置

此设置告诉 Visual Studio Code 在保存文件时自动格式化代码。

  1. 需要设置另一个设置。这是 Visual Studio Code 应用于格式化代码的默认格式化器。点击 工作区 选项卡并确保 默认格式化器 设置为 Prettier - 代码格式化器

图 3.7 – 将默认格式化器设置为 Prettier - 代码格式化器

图 3.7 – 将默认格式化器设置为 Prettier - 代码格式化器

Visual Studio Code 的 Prettier 扩展现在已安装并配置到项目中。接下来,我们将以开发模式运行应用程序。

以开发模式启动应用程序

执行以下步骤以以开发模式启动应用程序:

  1. Create React App 已经创建了一个名为 start 的 npm 脚本,该脚本以开发模式运行应用程序。在终端中运行此脚本如下:

    npm start
    

几秒钟后,应用程序将出现在默认浏览器中:

图 3.8 – 以开发模式运行的 React 应用程序

图 3.8 – 以开发模式运行的 React 应用程序

如果您的应用程序报告了 Prettier 格式化问题,请打开相关的文件并保存。这将正确格式化文件并解决错误。

  1. 打开 App.tsx 并将 学习 React 链接更改为 学习 React 和 TypeScript

    <a
    
      className="App-link"
    
      href="https://reactjs.org"
    
      target="_blank"
    
      rel="noopener noreferrer"
    
    >
    
      Learn React and TypeScript
    
    </a>;
    

保存文件后,运行中的应用程序将自动刷新以更新链接文本:

图 3.9 – 更新的 React 应用程序

图 3.9 – 更新的 React 应用程序

  1. App.tsx 中的代码进行实验。添加一个已使用的变量并在 JSX 元素中传递一个无效的属性:

    function App() {
    
      const unused = 'something';
    
      return (
    
        <div className="App" invalidProp="something">
    
          ...
    
        </div>
    
      );
    
    }
    

如预期,问题被捕获并在 Visual Studio Code 中报告。问题也在浏览器中报告。

图 3.10 – Visual Studio Code 捕获的问题

图 3.10 – Visual Studio Code 捕获的问题

  1. 在继续之前,删除无效代码并停止应用程序运行。停止应用程序的快捷键是 Ctrl + C

我们已经看到了 Create React App 如何提供高效的开发体验。接下来,我们将生成生产构建。

生成生产构建

按照以下步骤生成可以部署到生产环境的应用程序构建:

  1. Create React App 已经创建了一个名为 build 的 npm 脚本,用于生成部署到生产所需的所有工件。在终端中按照以下方式运行此脚本:

    npm run build
    

几秒钟后,部署工件将放置在 build 文件夹中。

  1. 打开 build 文件夹 – 它包含许多文件。根文件是 index.html,它引用了其他 JavaScript、CSS 和图像文件。所有文件都经过优化,用于生产,已移除空白并压缩 JavaScript。

这完成了生产构建和用 Create React App 设置的 React 和 TypeScript 项目的设置。以下是设置的关键点回顾:

  • npx 工具可以执行 Create React App 库,指定 typescript 模板以创建一个 React 和 TypeScript 项目。

  • Create React App 设置了许多有用的项目功能,例如代码检查、CSS 支持、SVG 支持。

  • Create React App 还设置了 npm 脚本来在开发模式下运行应用程序并生成生产构建。

  • Create React App 没有设置的一个功能是自动代码格式化。然而,Prettier 可以手动安装和配置以提供此功能。

请保持此项目安全,因为我们将在下一节继续使用它。

接下来,我们将学习如何创建一个使用 TypeScript 进行类型检查的 React 组件。

创建 React 和 TypeScript 组件

第一章**,介绍 React 中,我们使用 React 创建了一个警告组件。在本节中,我们将使用 TypeScript 使组件具有强类型,并体验其带来的好处。我们首先为警告组件的属性添加一个类型,然后尝试为其状态定义一个类型。完成警告组件后,我们将使用 React 的 DevTools 检查该组件。

添加属性类型

我们将继续使用上一节中用 Create React App 创建的 React 和 TypeScript 项目。按照以下步骤添加具有强类型的警告组件:

  1. 如果项目尚未打开,请打开 Visual Studio Code 中的项目。确保您在应用程序名称文件夹中打开项目,以便 package.json 位于根目录。

  2. src 文件夹中创建一个名为 Alert.tsx 的新文件。粘贴警告组件的 JavaScript 版本,该版本可在 GitHub 上找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter1/Section7-Using-events/Alert.js

注意,某些属性上报告了类型错误,因为它们只能推断为具有 any 类型。

  1. 在组件上方添加以下类型。这将成为组件属性的类型:

    type Props = {
    
      type?: string;
    
      heading: string;
    
      children: React.ReactNode;
    
      closable?: boolean;
    
      onClose?: () => void;
    
    };
    

headingchildren 属性是必需的,但其余属性是可选的。

children 属性被赋予了一个特殊类型,称为 React.ReactNode。这允许它接受 JSX 元素以及字符串。

类型名称可以是任何名称,但通常将其称为 Props

记住从 第二章* “创建接口”部分,在 介绍 TypeScript 中,接口语法可以用作创建类型的替代方案,而不是类型别名。Props 类型的接口版本如下:

interface Props {
  type?: string;
  heading: string;
  children: React.ReactNode;
  closable?: boolean;
  onClose?: () => void;
}

如上章所述,选择类型别名或接口作为组件属性的类型主要取决于个人喜好。

  1. 现在,在解构参数之后将 Props 类型分配给警报组件:

    export function Alert({
    
      type = "information",
    
      heading,
    
      children,
    
      closable,
    
      onClose,
    
    }: Props) {
    
      ...
    
    }
    

警报属性现在具有强类型。

  1. 打开 App.tsx 并将标题元素替换为警报组件。在使用 JSX 之前,不要忘记导入警报组件。不要向 Alert 传递任何属性以测试类型检查:

    import React from 'react';
    
    import './App.css';
    
    import { Alert } from './Alert';
    
    function App() {
    
      return (
    
        <div className="App">
    
          <Alert />
    
        </div>
    
      );
    
    }
    
    export default App;
    

如预期的那样,在 Alert 上引发了类型错误:

图 3.11 – 警报组件上的类型错误

图 3.11 – 警报组件上的类型错误

  1. Alert 传递一个 header 属性并给它一些内容:

    <Alert heading="Success">Everything is really good!</Alert>
    

类型错误将消失。

  1. 如果尚未运行,请以开发模式启动应用程序(npm start)。之后,应用程序组件将按预期出现在页面上。

接下来,我们将学习如何显式地为 React 组件状态赋予类型。

添加状态类型

按照以下步骤在警报组件中实验 visible 状态类型:

  1. 打开 Alert.tsx 并将鼠标悬停在 visible 状态变量上以确定其推断类型。它已被推断为 boolean 类型,因为它被初始化为 true 值。boolean 类型正是我们想要的类型。

  2. 作为实验,移除传递给 useState 的初始值 true。然后,再次将鼠标悬停在 visible 状态变量上。它已被推断为 undefined 类型,因为没有传递默认值到 useState。这显然不是我们想要的类型。

  3. 有时,useState 类型没有被推断为我们想要的类型,就像在上一个步骤中那样。在这些情况下,可以使用 useState 显式地定义状态类型。通过添加以下泛型参数显式地为 visible 状态赋予 boolean 类型:

    const [visible, setVisible] = useState<boolean>();
    

注意

泛型参数就像一个常规函数参数,但为函数定义了一个类型。泛型参数在函数名称之后使用尖括号指定。

  1. useState 声明恢复到其原始状态,将其初始化为 true 并不指定显式类型:

    const [visible, setVisible] = useState(true);
    
  2. 通过按 Ctrl + C 停止应用程序运行。

总结来说,始终检查从 useState 推断的状态类型,如果推断的类型不是所需的类型,则使用其泛型参数显式定义类型。

接下来,我们将学习如何使用 React 浏览器开发工具。

使用 React DevTools

React DevTools 是适用于 Chrome 和 Firefox 的浏览器扩展。这些工具允许检查和调试 React 应用程序。扩展程序的链接如下:

要安装扩展,点击 添加到 Chrome添加到 Firefox 按钮。您需要重新打开浏览器才能使用这些工具。

执行以下步骤以探索工具:

  1. 在 Visual Studio Code 中,通过在终端中运行 npm start 以开发模式启动应用程序。几秒钟后,应用程序将在浏览器中显示。

  2. 通过按 F12 打开浏览器开发工具。React DevTools 添加了两个面板,分别称为 组件分析器

  3. 首先,我们将探索 组件 面板,因此选择此面板:

图 3.12 – React DevTools 组件面板

图 3.12 – React DevTools 组件面板

React 组件树出现在左侧。选择一个 React 组件将显示右侧的当前属性和状态值。

  1. 注意,状态没有命名——它有一个通用的名称,状态。点击 hooks 部分右侧的魔杖图标。

状态的名称现在出现在括号中:

图 3.13 – 点击魔杖后的状态变量名

图 3.13 – 点击魔杖后的状态变量名

  1. 在 Visual Studio Code 中,打开 App.tsx 并将 closable 属性传递给 Alert

    <Alert heading="Success" closable>
    
      Everything is really good!
    
    </Alert>
    

应用程序刷新,关闭按钮出现。

  1. 点击关闭按钮,注意在 DevTools 中 visible 状态变为 false

组件 面板在调试大型组件树时非常有用,可以快速了解属性和状态值。

  1. 刷新浏览器,以便警报再次出现。在 React DevTools 的 组件 面板中,通过点击齿轮图标打开设置。在 常规 部分中,如果尚未勾选,请勾选 组件渲染时高亮更新 选项。

图 3.14 – 重新渲染高亮选项

图 3.14 – 重新渲染高亮选项

此选项将在组件重新渲染时高亮显示。

  1. 在尝试重新渲染高亮之前,打开 Alert.tsx 并将其更新为渲染 visible 状态为 false

    if (!visible) {
    
      return <div>Gone!</div>;
    
    }
    

在此更改之前,重新渲染高亮没有元素可以高亮。

  1. 现在,点击浏览器中警报的关闭按钮。重新渲染的警报组件将以绿色边框突出显示:

图 3.15 – 重新渲染高亮

图 3.15 – 重新渲染高亮

这完成了我们对 组件 面板的探索。按 F5 刷新浏览器,以便在继续之前警报组件再次出现。

  1. 现在,我们将探索 Profiler 面板,因此请选择此面板。此工具允许对交互进行性能分析,这对于跟踪性能问题很有用。

  2. 点击 开始分析 选项,即蓝色圆形图标。

  3. 点击警报中的关闭按钮。

  4. 点击 停止分析 选项,即红色圆形图标。将出现所有组件重新渲染的时间线:

图 3.16 – React DevTools 组件

图 3.16 – React DevTools 组件

这表明在点击关闭按钮时,Alert 被重新渲染,耗时 0.7 毫秒。

该工具有助于快速识别特定用户交互中的慢速组件。

  1. 这完成了我们对 React DevTools 的探索。撤销我们对 Alert 组件所做的更改,以便它在不可见时再次渲染 null

    if (!visible) {
    
      return null;
    
    }
    

这完成了本节的内容。以下是一个回顾:

  • 可以向组件 props 添加类型以使它们具有类型安全性

  • 状态可以从其初始值推断出来,但可以使用 useState 上的泛型参数显式定义

  • React DevTools 可以安装在浏览器中,以检查运行中的应用程序中的组件树并帮助追踪性能问题

这就结束了本章的内容。

摘要

我们以使用 webpack 和 Babel 创建 React 和 TypeScript 项目开始本章。涉及了许多步骤,而我们只设置了我们实际项目所需的一小部分。例如,我们尚未设置生成用于生产的优化包的能力。

我们继续使用 Create React App 创建 React 和 TypeScript 项目。我们看到了这种方法是如何以更快、更彻底的方式创建项目。然后,我们使用了 ESLint 进行代码检查和 Prettier 进行自动代码格式化。通过这次练习,我们现在理解了 TypeScript、ESLint 和 Prettier 如何一起使用来创建高质量的 React 和 TypeScript 项目环境。

在最后一节中,我们学习了如何使用强类型属性和状态创建 React 组件。我们体验了这种方式如何帮助我们快速发现问题。我们还使用了 React 的 DevTools,它允许检查 React 组件树并分析性能。

在下一章中,我们将学习 React 的常见 hooks。

问题

以下问题将检查你在本章中学到的内容:

  1. 在以下没有类型注解的组件中,name prop 将具有什么类型?

    export function Name({ name }) {
    
      return <span>{name}</span>;
    
    }
    
  2. 在下面的 useState 语句中,firstName 状态将具有什么类型?

    const [firstName, setFirstName] = useState("");
    
  3. ContactDetails 组件的 props 具有以下类型:

    type Props = {
    
      firstName?: string;
    
      email: string;
    
    };
    
    export function ContactDetails({ firstName, email }: Props) {
    
      ...
    
    }
    

前一个组件在另一个组件的 JSX 中如下引用:

<ContactDetails email="fred@somewhere.com" />

是否会引发类型错误?

  1. status 状态变量可以存储 "Good""Bad" 的值,初始值为 "Good"。它在以下代码中定义:

    const [status, setStatus] = useState("Good");
    

status 分配的类型是什么?如何将其类型缩小到只有 "Good""Bad"

  1. FruitList 组件接收一个水果名称数组并在列表中显示它们。它在另一个组件的 JSX 中如下引用:

    <FruitList fruits={["Banana", "Apple", "Strawberry"]} />;
    

你会为 FruitList 组件定义什么类型?

  1. 一个可以存储 null 或电子邮件地址的 email 状态变量,初始值为 null。你将如何使用 useState 钩子定义此状态?

  2. 以下组件允许用户同意:

    export function Agree({ onAgree }: Props) {
    
      return (
    
        <button onClick={() => onAgree()}>
    
          Click to agree
    
        </button>
    
      );
    
    }
    

你会定义什么作为 Props 的类型定义?

答案

这里是前一个部分问题的答案。

  1. name 属性将具有 any 类型。

  2. firstName 状态将被赋予 string 类型,因为 string 将从初始值 "" 推断出来。

  3. 即使没有传递 firstName,也不会引发类型错误,因为它被定义为可选的。

  4. status 的推断类型是 string。可以使用泛型类型参数显式地为状态定义类型,如下所示:

    const [status, setStatus] = useState<'Good' | 'Bad'>('Good');
    
  5. FruitList 组件的类型可能如下所示:

    type Props = {
    
      fruits: string[];
    
    }
    

或者,它可以使用以下方式定义:

interface Props {
  fruits: string[];
}
  1. email 状态可以定义如下:

    const [email, setEmail] = useState<string | null>(null);
    

需要显式地定义类型;否则,null 的初始值将使 email 的类型变为 null

  1. Props 的类型可能如下所示:

    type Props = {
    
      onAgree: () => void;
    
    };
    

或者,它可以使用以下方式定义:

interface Props {
  onAgree: () => void;
}

第四章:使用 React 钩子

在本章中,我们将学习 React 的常见钩子以及如何使用 TypeScript 使用它们。我们将在一个允许用户调整某人的得分的 React 组件中实现所有这些钩子的知识。我们将从探索 effect 钩子开始,并开始理解它在哪些用例中是有用的。然后,我们将深入研究两个状态钩子 useStateuseReducer,了解何时最好使用每个钩子。之后,我们将介绍 ref 钩子以及它与状态钩子的区别,然后是 memo 和回调钩子,看看它们如何帮助性能。

因此,我们将涵盖以下主题:

  • 使用 Effect 钩子

  • 使用状态钩子

  • 使用 ref 钩子

  • 使用 Memo 钩子

  • 使用回调钩子

技术要求

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

本章中所有代码片段都可以在网上找到,地址为 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter4

使用 Effect 钩子

在本节中,我们将学习 effect 钩子及其用途。然后,我们将创建一个新的 React 项目和一个使用 effect 钩子的组件。

理解 effect 钩子参数

effect 钩子用于组件副作用。组件副作用是在组件作用域之外执行的操作,例如网络服务请求。effect 钩子是通过 React 的 useEffect 函数定义的。useEffect 包含两个参数:

  • 执行效果的函数;至少,这个函数在组件渲染时运行

  • 一个可选的依赖数组,当它改变时会导致效果函数重新运行

这里是一个组件中 useEffect 钩子的示例:

function SomeComponent() {
  function someEffect() {
    console.log("Some effect");
  }
  useEffect(someEffect);
  return ...
}

之前的效果钩子传递了一个名为 someEffect 的效果函数。没有传递效果依赖项,所以效果函数在每次组件渲染时都会执行。

通常,匿名箭头函数用于效果函数。以下是一个使用匿名效果函数的相同示例:

function SomeComponent() {
  useEffect(() => {
    console.log("Some effect");
  });
  return ...
}

如您所见,这个版本的代码稍微短一些,并且可能更容易阅读。

这里是另一个效果的示例:

function SomeOtherComponent({ search }) {
  useEffect(() => {
    console.log("An effect dependent on a search prop",       search);
  }, [search]);
  Return ...;
}

这次效果依赖于一个 search 属性。因此,search 属性在 effect 钩子的第二个参数中的数组中定义。每当 search 的值改变时,效果函数都会运行。

钩子的规则

所有钩子都必须遵守一些规则,包括 useEffect

  • Hook 只能在函数组件的顶层被调用。所以,Hook 不能在循环或嵌套函数(如事件处理器)中被调用。

  • Hook 不能有条件地被调用。

  • Hook 只能在函数组件中使用,而不能在类组件中使用。

以下示例违反了规则:

export function AnotherComponent() {
  function handleClick() {
    useEffect(() => {
      console.log("Some effect");
    });
  }
  return <button onClick={handleClick}>Cause effect</button>;
}

这是一种违规,因为 useEffect 是在处理函数中而不是在顶层被调用的。修正后的版本如下:

export function AnotherComponent() {
  const [clicked, setClicked] = useState(false);
  useEffect(() => {
    if (clicked) {
      console.log("Some effect");
    }
  }, [clicked]);
  function handleClick() {
    setClicked(true);
  }
  return <button onClick={handleClick}>Cause effect</button>;
}

useEffect 已经提升到顶层,并且现在依赖于在处理函数中设置的 clicked 状态。

以下是一个违反 Hooks 规则的另一个示例:

function YetAnotherComponent({ someProp }) {
  if (!someProp) {
    return null;
  }
  useEffect(() => {
    console.log("Some effect");
  });
  return ...
}

违规是因为 useEffect 是有条件地被调用的。如果 someProp 是假值,组件会返回 null,并且 useEffect 从不会被调用。所以,条件是只有当 someProp 是真值时,useEffect 才会被调用。

修正后的版本如下:

function YetAnotherComponent({someProp}) {
  useEffect(() => {
    if (someProp) {
      console.log("Some effect");
    }
  });
  if (!someProp) {
    return null
  }
  return ...
}

useEffect 已经提升到条件之上。条件也被放入效果函数中,以便其逻辑仅在 someProp 是真值时执行。

效果清理

效果可以返回一个函数,在组件卸载时执行清理逻辑。清理逻辑确保没有留下可能导致内存泄漏的东西。让我们考虑以下示例:

function ExampleComponent({onClickAnywhere}) {
  useEffect(() => {
    function handleClick() {
      onClickAnywhere();
    }
    document.addEventListener("click", handleClick);
  });
  return ...
}

前面的效果函数将事件处理器附加到 document 元素上。尽管事件处理器不会被移除,所以随着效果的重新运行,多个事件处理器将附加到 document 元素上。这个问题通过返回一个 cleanup 函数来解决,如下所示:

function ExampleComponent({ onClickAnywhere }) {
  useEffect(() => {
    function handleClick() {
      onClickAnywhere();
    }
    document.addEventListener("click", listener);
    return function cleanup() {
      document.removeEventListener("click", listener);
    };
  });
  return ...;
}

通常,匿名箭头函数被用于清理函数:

function ExampleComponent({ onClickAnywhere }) {
  useEffect(() => {
    function handleClick() {
      onClickAnywhere();
    }
    document.addEventListener("click", listener);
    return () => {
      document.removeEventListener("click", listener);
    };
  });
  return ...;

匿名箭头函数比上一个例子中的命名函数要短一些。

接下来,我们将探讨效果 Hook 的一个常见用例。

创建项目

让我们从在 Visual Studio Code 中使用 Create React App 创建一个新项目开始。我们已经在 第三章**,设置 React 和 TypeScript 中学习了如何这样做 – 步骤如下:

  1. 在你选择的空白文件夹中打开 Visual Studio Code 并运行以下命令:

    npx create-react-app app --template typescript
    

Create React App 需要一两分钟来创建项目。在后续命令中,应用被命名为 app,但你可以随意更改这个名字。

  1. 在刚刚创建的 app 文件夹(或你给它起的名字)中重新打开 Visual Studio Code。

  2. 安装 Prettier 及其库,以便它能够与 ESLint 一起工作。在终端中运行以下命令来完成此操作:

    npm i -D prettier eslint-config-prettier eslint-plugin-prettier
    
  3. 启用 Visual Studio Code 在文件保存时自动格式化代码。为此,在项目根目录中创建一个 .vscode 文件夹,并创建一个包含以下内容的 settings.json 文件:

    {
    
      "editor.formatOnSave": true,
    
      "editor.defaultFormatter": "esbenp.prettier-vscode"
    
    }
    
  4. 更新 ESLint 配置以允许 Prettier 管理样式规则。为此,将以下高亮行添加到 package.json 中的 eslintConfig 部分:

    {
    
      ...,
    
      "eslintConfig": {
    
        "extends": [
    
          "react-app",
    
          "react-app/jest",
    
          "plugin:prettier/recommended"
    
        ]
    
      },
    
      ...
    
    }
    
  5. 在一个名为 .prettierrc.json 的文件中添加以下 Prettier 配置:

    {
    
      "printWidth": 100,
    
      "singleQuote": true,
    
      "semi": true,
    
      "tabWidth": 2,
    
      "trailingComma": "all",
    
      "endOfLine": "auto"
    
    }
    
  6. src 文件夹中删除以下文件,因为这些文件在这个项目中不是必需的:

    • App.test.tsx

    • Logo.svg

  7. 打开 index.tsx 并保存文件,无需进行任何更改。这将消除任何格式问题。

  8. 打开 App.tsx 并将内容替换为以下内容:

    import React from 'react';
    
    import './App.css';
    
    function App() {
    
      return <div className="App"></div>;
    
    }
    
    export default App;
    
  9. 通过在终端中运行 npm start 启动开发模式下的应用程序。目前应用程序包含一个空白页面。在我们探索 React 组件中的不同钩子时,请保持应用程序运行。

这就是创建的项目。接下来,我们将使用效果钩子。

使用效果钩子获取数据

效果钩子的一个常见用途是获取数据。执行以下步骤以实现一个获取人名的效果:

  1. 创建一个将模拟数据请求的函数。为此,在 src 文件夹中创建一个名为 getPerson.ts 的文件,然后向该文件添加以下内容:

    type Person = {
    
      name: string,
    
    };
    
    export function getPerson(): Promise<Person> {
    
      return new Promise((resolve) =>
    
        setTimeout(() => resolve({ name: "Bob" }), 1000)
    
      );
    
    }
    

函数在经过一秒钟后异步返回一个对象,{ name: "Bob" }

注意返回类型的类型注解,Promise<Person>Promise 类型代表 JavaScript 的 Promise,它最终会被完成。Promise 类型有一个泛型参数,用于指定在承诺中解析的项目类型,在这个例子中是 Person。有关 JavaScript 承诺的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

  1. 接下来,我们将创建一个最终将显示人和分数的 React 组件。在 src 文件夹中创建一个名为 PersonScore.tsx 的文件,然后向该文件添加以下内容:

    import { useEffect } from 'react';
    
    import { getPerson } from './getPerson';
    
    export function PersonScore() {
    
      return null;
    
    }
    

已经从 React 导入了 useEffect 钩子以及我们刚刚创建的 getPerson 函数。目前,组件仅返回 null

  1. 在返回语句上方添加以下效果:

    export function PersonScore() {
    
      useEffect(() => {
    
        getPerson().then((person) => console.log(person));
    
      }, []);
    
      return null;
    
    }
    

效果调用 getPerson 函数并将返回的人输出到控制台。由于在第二个参数中指定了一个空数组作为效果依赖项,因此效果仅在组件最初渲染后执行。

  1. 打开 App.tsx 并在 div 元素内渲染 PersonScore 组件:

    import React from 'react';
    
    import './App.css';
    
    import { PersonScore } from './PersonScore';
    
    function App() {
    
      return (
    
        <div className="App">
    
          <PersonScore />
    
        </div>
    
      );
    
    }
    
    export default App;
    
  2. 在浏览器中转到正在运行的应用程序,并查看控制台中出现的 person 对象,这验证了获取 person 数据的效果已正确运行:

图 4.1 – 效果输出

图 4.1 – 效果输出

你可能还会注意到,effect 函数已经执行了两次而不是一次。这种行为是故意的,并且仅在开发模式下的 React Strict Mode 中发生。这最终将允许未来的 React 功能在移除 UI 的部分时保留状态。有关此行为的更多信息,请参阅 React 团队的这篇博客文章:reactjs.org/blog/2022/03/29/react-v18.html#new-strict-mode-behaviors

  1. 接下来,我们将重构调用 effect 函数的方式,以揭示一个有趣的问题。打开 PersonScore.tsx 并将 useEffect 调用改为使用 async/await 语法:

    useEffect(async () => {
    
      const person = await getPerson();
    
      console.log(person);
    
    }, []);
    

注意

async/await 语法是编写异步代码的另一种方式。许多开发者更喜欢它,因为它读起来像同步代码。有关 async/await 的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Promises#async_and_await

前面的代码可以说是更易读,但 React 会抛出一个错误。查看浏览器的控制台,你会看到以下错误:

图 4.2 – Effect 异步错误

图 4.2 – Effect 异步错误

这个错误信息非常有用——useEffect Hook 不允许将标记为 async 的函数传递给它。

  1. 接下来,更新代码并使用错误消息中建议的方法:

    useEffect(() => {
    
      async function getThePerson() {
    
        const person = await getPerson();
    
        console.log(person);
    
      }
    
      getThePerson();
    
    }, []);
    

effect 函数中定义了一个嵌套的异步函数,并立即调用;这工作得很好。

  1. 与初始版本相比,这种 effect 实现可以说是更不易读。因此,在继续到下一节之前,请切换回那个版本。代码可以从以下链接复制:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter4/Section1-Using-the-effect-hook/src/PersonScore.tsx

这就完成了我们对 effect Hook 的探索——以下是总结:

  • effect Hook 用于在组件渲染时或某些属性或状态发生变化时执行组件副作用。

  • effect Hook 的一个常见用例是获取数据。另一个用例是当需要手动注册 DOM 事件时。

  • 所需的任何 effect 清理工作都可以在 effect 函数返回的函数中完成。

接下来,我们将了解 React 中的两个状态 Hook。在我们移动到下一节之前,请保持应用程序运行。

使用状态 Hook

我们已经在之前的章节中学习了 useState Hook,但在这里我们将再次探讨它,并将其与另一个我们尚未涉及的 state Hook 进行比较,即 useReducer。我们将扩展上一节中创建的 PersonScore 组件,以探索这些状态 Hook。

使用 useState

作为提醒,useState Hook 允许在变量中定义状态。useState的语法如下:

const [state, setState] = useState(initialState);

我们将增强上一节中创建的PersonScore组件,以便在state中存储人的姓名。我们还将为分数添加state,该分数可以通过组件中的某些按钮进行增加、减少和重置。我们还将向组件中添加loading状态,当true时将显示加载指示器。

执行以下步骤:

  1. 打开PersonScore.tsx并将useState添加到 React 导入语句中:

    import { useEffect, useState } from 'react';
    
  2. 在组件函数顶部,在useEffect调用之上添加以下namescoreloading状态定义:

    export function PersonScore() {
    
      const [name, setName] = useState<string | undefined>();
    
      const [score, setScore] = useState(0);
    
      const [loading, setLoading] = useState(true);
    
      useEffect( ... );
    
      return null;
    
    }
    

score状态初始化为0loading初始化为true

  1. 将效果函数更改为在获取到个人信息后设置loadingname状态值。这应该替换现有的console.log语句:

    useEffect(() => {
    
      getPerson().then((person) => {
    
        setLoading(false);
    
        setName(person.name);
    
      });
    
    }, []);
    

在获取到个人信息后,loading设置为falsename设置为人的姓名。

  1. 接下来,在useEffect调用和返回语句之间添加以下if语句:

    useEffect( ... );
    
    if (loading) {
    
      return <div>Loading ...</div>;
    
    }
    
    return ...
    

loading状态为true时,这将显示加载指示器。

  1. 将组件的返回语句从输出空内容更改为输出以下内容:

    if (loading) {
    
      return <div>Loading ...</div>;
    
    }
    
    return (
    
      <div>
    
        <h3>
    
          {name}, {score}
    
        </h3>
    
        <button>Add</button>
    
        <button>Subtract</button>
    
        <button>Reset</button>
    
      </div>
    
    );
    

人的姓名和分数在一个带有添加减去重置按钮的标题中显示(不用担心输出未样式化 – 我们将在下一章学习如何样式化组件):

图 4.3 – 获取数据后的 PersonScore 组件

图 4.3 – 获取数据后的 PersonScore 组件

  1. 更新添加按钮,以便在点击时增加分数:

    <button onClick={() => setScore(score + 1)}>Add</button>
    

按钮点击事件调用分数状态设置器以增加状态。

根据它们的前一个值更新状态值的方法是另一种方法。该方法使用状态设置器中的参数来提供前一个状态值,因此我们的示例可以如下所示:

setScore(previousScore => previousScore + 1)

这可能有点难以阅读,所以我们将继续使用我们的初始方法。

  1. 按照以下方式为其他按钮添加分数状态设置器:

    <button onClick={() => setScore(score - 1)}>Subtract</button>
    
    <button onClick={() => setScore(0)}>Reset</button>
    
  2. 在运行的应用程序中,点击不同的按钮。它们应该像您预期的那样改变分数。

图 4.4 – 点击按钮后的 PersonScore 组件

图 4.4 – 点击按钮后的 PersonScore 组件

  1. 在完成这个练习之前,让我们花点时间来了解状态值实际上是在何时设置的。更新效果函数以在设置后输出状态值:

    useEffect(() => {
    
      getPerson().then((person) => {
    
        setLoading(false);
    
        setName(person.name);
    
        console.log("State values", loading, name);
    
      });
    
    }, []);
    

我们可能期望控制台输出为false"Bob"?然而,控制台输出的是trueundefined。这是因为更新状态值不是立即的 – 相反,它们被批处理并在下一次渲染之前更新。因此,只有在下一次渲染时,loading才会变为falsename才会变为"Bob"

我们不再需要在这个步骤中添加的 console.log 语句,所以在继续之前将其删除。

接下来,我们将学习一个用于使用状态的替代 React Hook。

理解 useReducer

useReducer 是管理状态的一种替代方法。它使用一个 reducer 函数来处理状态变化,该函数接收当前状态值并返回新的状态值。

这是一个 useReducer 调用的示例:

const [state, dispatch] = useReducer(reducer, initialState);

因此,useReducer 接收一个 reducer 函数和初始状态值作为参数。然后它返回一个包含当前状态值和一个用于 dispatch 状态变化的函数的元组。

dispatch 函数接收一个描述更改的参数。这个对象被称为 dispatch 调用,如下所示:

dispatch({ type: 'add', amount: 2 });

动作没有定义的结构,但通常包含一个属性,例如 type,用于指定更改的类型。动作中的其他属性可能根据更改的类型而变化。以下是一个 dispatch 调用的另一个示例:

dispatch({ type: 'loaded' });

这次,动作只需要类型来更改必要的状态。

将我们的注意力转向 reducer 函数,它具有当前状态值和动作的参数。以下是一个 reducer 的示例代码片段:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { ...state, total: state.total + action.amount };
    case ...
      ...
    default:
      return state;
  }
}

reducer 函数通常包含一个基于动作类型的 switch 语句。每个 switch 分支对状态进行必要的更改并返回更新后的状态。在状态变化期间会创建一个新的状态对象——当前状态永远不会被修改。修改状态会导致组件不重新渲染。

注意

"add" branch the state variable (...state). The spread syntax copies all the properties from the object after the three dots. In the preceding code snippet, all the properties are copied from the state variable into the new state object returned. The total property value will then be overwritten by state.total + action.amount because this is defined after the spread operation in the new object creation. For more information on the spread syntax, see the following link: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax.

useReducer 的类型可以在其泛型参数中显式定义,如下所示:

const [state, dispatch] = useReducer<Reducer<State, Action>>(
  reducer,
  initialState
);

Reducer 是一个标准的 React 类型,它具有泛型参数,用于定义状态和动作的类型。

因此,useReducer 比起 useState 更复杂,因为状态变化需要通过我们必须实现的 reducer 函数进行。这对于具有相关属性或状态变化依赖于先前状态值的复杂状态对象来说是有益的。

接下来,我们将使用 useReducer 实现状态。

使用 useReducer

我们将重构我们一直在工作的 PersonScore 组件,使用 useReducer 而不是 useState。为此,执行以下步骤。使用的代码片段可以从 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter4/Section2-Using-state-hooks/2-Using-useReducer/src/PersonScore.tsx 复制。

  1. 打开 PersonScore.tsx 并从 React 中导入 useReducer 而不是 useState

    import { useEffect, useReducer } from 'react';
    
  2. 我们将状态放在一个单独的对象中,所以定义一个类型在导入语句下方:

    type State = {
    
      name: string | undefined;
    
      score: number;
    
      loading: boolean;
    
    };
    
  3. 接下来,让我们也定义所有动作对象类型:

    type Action =
    
      | {
    
          type: 'initialize';
    
          name: string;
    
        }
    
      | {
    
          type: 'increment';
    
        }
    
      | {
    
          type: 'decrement';
    
        }
    
      | {
    
          type: 'reset';
    
        };
    

这些动作对象代表了状态可以改变的所有方式。动作对象类型通过联合类型组合,允许动作是这些中的任何一个。

  1. 现在,在类型定义下方定义以下减法函数:

    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case 'initialize':
    
          return { name: action.name, score: 0, loading: false };
    
        case 'increment':
    
          return { ...state, score: state.score + 1 };
    
        case 'decrement':
    
          return { ...state, score: state.score - 1 };
    
        case 'reset':
    
          return { ...state, score: 0 };
    
        default:
    
          return state;
    
      }
    
    }
    

减法函数包含一个switch语句,为每种类型的操作执行相应的状态变更。

注意当引用stateaction参数时的良好 IntelliSense:

图 4.5 – 减法函数内的 IntelliSense

图 4.5 – 减法函数内的 IntelliSense

  1. PersonScore组件内部,将useState调用替换为以下useReducer调用:

    const [{ name, score, loading }, dispatch] = useReducer(
    
      reducer,
    
      {
    
        name: undefined,
    
        score: 0,
    
        loading: true,
    
      }
    
    );
    

状态已使用undefined名称、分数0loading设置为true初始化。

当前状态值已解构为namescoreloading变量。如果你悬停在解构的状态变量上,你会看到它们的类型已经被正确推断。

  1. 现在我们需要修改组件中更新状态的那些地方。从效果函数开始,在返回人员信息后分发初始化操作:

    useEffect(() => {
    
      getPerson().then(({ name }) =>
    
        dispatch({ type: 'initialize', name })
    
      );
    
    }, []);
    
  2. 最后,在按钮点击处理程序中分发相关操作:

    <button onClick={() => dispatch({ type: 'increment' })}>
    
      Add
    
    </button>
    
    <button onClick={() => dispatch({ type: 'decrement' })}>
    
      Subtract
    
    </button>
    
    <button onClick={() => dispatch({ type: 'reset' })}>
    
      Reset
    
    </button>
    
  3. 如果你尝试点击运行中的应用程序中的按钮,它们将正确更新。

这就完成了我们对useReducer钩子的探索。它比useState更适合复杂的状态管理情况,例如,当状态是一个具有相关属性和状态变更依赖于先前状态值的复杂对象时。当状态基于独立于任何其他状态的原始值时,useState钩子更为合适。

在接下来的几节中,我们将继续扩展PersonScore组件。接下来,我们将学习如何使用ref钩子将焦点移动到添加按钮。

使用ref钩子

在本节中,我们将了解ref钩子和它的用途。然后,我们将通过增强我们一直在工作的PersonScore组件来演示ref钩子的一个常见用例。

理解ref钩子

ref钩子被称作useRef,它返回一个变量,其值在组件的生命周期内保持持久。这意味着当组件重新渲染时,变量不会丢失其值。

ref钩子返回的值通常被称为ref。ref 可以更改而不会导致重新渲染。

这是useRef的语法:

const ref = useRef(initialValue);

可以选择性地将初始值传递给useRefref的类型可以通过useRef的泛型参数显式定义:

const ref = useRef<Ref>(initialValue);

当没有传递初始值或初始值为null时,泛型参数是有用的。这是因为 TypeScript 无法正确推断类型。

通过其current属性访问ref的值:

console.log("Current ref value", ref.current);

可以通过其current属性更新ref的值:

ref.current = newValue;

useRef钩子的一种常见用法是强制访问 HTML 元素。HTML 元素在 JSX 中有一个ref属性,可以被分配给 ref。以下是一个示例:

function MyComponent() {
  const inputRef = useRef<HTMLInputElement>(null);
  function doSomething() {
    console.log(
      "All the properties and methods of the input",
      inputRef.current
    );
  }
  return <input ref={inputRef} type="text" />;
}

这里使用的 ref 被称为inputRef,初始值为null。因此,它被明确地赋予了一个类型HTMLInputElement,这是输入元素的常规类型。然后,这个 ref 被分配到 JSX 中输入元素的ref属性上。然后,所有输入的属性和方法都可通过 ref 的current属性访问。

接下来,我们将在PersonScore组件中使用useRef钩子。

使用 ref 钩子

我们将增强我们一直在工作的PersonScore组件,使用useRef将焦点移动到添加按钮。为此,执行以下步骤。所有使用的代码片段都可在以下链接中找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter4/Section3-Using-the-ref-hook/src/PersonScore.tsx

  1. 打开PersonScore.tsx文件,并从 React 中导入useRef

    import { useEffect, useReducer, useRef } from 'react';
    
  2. useReducer语句创建一个 ref:

    const [ ... ] = useReducer( ... );
    
    const addButtonRef = useRef<HTMLButtonElement>(null);
    
    useEffect( ... )
    

这个 ref 被命名为addButtonRef,初始值为null。它被赋予了标准的HTMLButtonElement类型。

注意

所有标准 HTML 元素都有对应 React 的 TypeScript 类型。右键点击HTMLButtonElement类型并选择转到定义,以发现所有这些类型。React TypeScript 类型将打开,包含所有 HTML 元素类型。

  1. 将 ref 分配给添加按钮 JSX 元素的ref属性:

    <button
    
      ref={addButtonRef}
    
      onClick={() => dispatch({ type: 'increment' })}
    
    >
    
      Add
    
    </button>
    
  2. 现在我们有了focus方法的引用,可以在获取到人员信息后将其移动到焦点上。让我们在获取人员的现有效果下方添加另一个效果来完成此操作:

    useEffect(() => {
    
      getPerson().then(({ name }) =>
    
        dispatch({ type: 'initialize', name })
    
      );
    
    }, []);
    
    useEffect(() => {
    
      if (!loading) {
    
        addButtonRef.current?.focus();
    
      }
    
    }, [loading]);
    
    if (loading) {
    
      return <div>Loading ...</div>;
    
    }
    

loading状态为true时,效果会被执行,这将在获取到人员信息之后发生。

注意在 ref 的current属性后面的?符号。这是在不需要检查current是否为null的情况下调用的focus方法。有关可选链的更多信息,请访问以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining

我们可以在现有的效果中将焦点移动到添加按钮上,如下所示:

useEffect(() => {
  getPerson().then(({ name }) => {
    dispatch({ type: 'initialize', name });
    addButtonRef.current?.focus();
  });
}, []);

然而,这种做法将获取数据、设置状态和设置按钮焦点等关注点混合在一起。这种关注点的混合会使组件难以理解和修改。

  1. 如果你刷新包含运行中的应用程序的浏览器,你将在添加按钮上看到一个焦点指示器:

图 4.6 – 焦点的添加按钮

图 4.6 – 焦点的添加按钮

如果你按下Enter键,你会看到添加按钮被点击并且分数增加。这证明了添加按钮是聚焦的。

这完成了增强和我们对 ref Hook 的探索。

回顾一下,useRef Hook 创建了一个可变的值,并且在变化时不会引起重新渲染。它通常用于在 React 中以命令式方式访问 HTML 元素。

接下来,我们将学习 memo Hook。

使用 memo Hook

在本节中,我们将学习 memo Hook 及其用途。然后,我们将通过我们在PersonScore组件中一直在工作的例子进行演示。

理解 memo Hook

memo Hook 创建了一个记忆值,对于有计算成本高昂的计算的值来说是有益的。该 Hook 被称为useMemo,其语法如下:

const memoizedValue = useMemo(() => expensiveCalculation(), []);

将返回记忆值的函数作为第一个参数传递给useMemo。这个第一个参数应该执行昂贵的计算。

传递给useMemo的第二个参数是一个依赖项数组。所以,如果expensiveCalculation函数有依赖项ab,调用将如下所示:

const memoizedValue = useMemo(
  () => expensiveCalculation(a, b),
  [a, b]
);

当任何依赖项发生变化时,第一个参数中的函数会再次执行以返回一个新值进行记忆。在之前的例子中,每当ab发生变化时,就会创建一个新的memoizedValue版本。

记忆值的类型是推断出来的,但可以在useMemo上的泛型参数中显式定义。以下是一个显式定义记忆值应该具有number类型的示例:

 const memoizedValue = useMemo<number>(
  () => expensiveCalculation(),
  []
);

接下来,我们将实验useMemo

使用 memo Hook

我们将使用我们在PersonScore组件中一直在工作的组件来与useMemo Hook 进行交互。为此,执行以下步骤。使用的代码片段可在github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter4/Section4-Using-the-memo-hook找到:

  1. 打开PersonScore.tsx并从 React 中导入useMemo

    import {
    
      useEffect,
    
      useReducer,
    
      useRef,
    
      useMemo
    
    } from 'react';
    
  2. 在导入语句下方添加以下昂贵的函数:

    function sillyExpensiveFunction() {
    
      console.log("Executing silly function");
    
      let sum = 0;
    
      for (let i = 0; i < 10000; i++) {
    
        sum += i;
    
      }
    
      return sum;
    
    }
    

该函数计算从010000之间的所有数字,并且执行需要一段时间。

  1. PersonScore组件的效果下方添加对函数的调用:

    useEffect( ... );
    
    const expensiveCalculation = sillyExpensiveFunction();
    
    if (loading) {
    
      return <div>Loading ...</div>;
    
    }
    
  2. 将函数调用的结果添加到namescore下方的 JSX 中:

    <h3>
    
      {name}, {score}
    
    </h3>
    
    <p>{expensiveCalculation}</p>
    
    <button ... >
    
      Add
    
    </button>
    
  3. 刷新包含应用的浏览器并点击按钮。如果你查看控制台,你会看到在按钮点击后组件重新渲染时,昂贵的函数每次都会执行。

图 4.7 – 执行多次的昂贵函数

图 4.7 – 执行多次的昂贵函数

记住,在开发模式下,双渲染会发生,React 的 Strict Mode 也是如此。所以,一旦点击按钮,你会在控制台中看到执行愚蠢函数两次。

每次组件重新渲染时执行昂贵的函数可能会导致性能问题。

  1. 将对 sillyExpensiveFunction 的调用重写如下:

    const expensiveCalculation = useMemo(
    
      () => sillyExpensiveFunction(),
    
      []
    
    );
    

useMemo Hook 用于记忆化函数调用的值。

  1. 刷新包含运行中的应用程序的浏览器并点击按钮。如果你查看控制台,你会看到当按钮被点击时,昂贵的函数并没有被执行,因为使用了记忆化的值。

图 4.8 – 记忆化的昂贵函数调用

图 4.8 – 记忆化的昂贵函数调用

这就完成了我们对 useMemo Hook 的探索。本节的要点是,useMemo Hook 通过记忆化函数的结果并在函数重新执行时使用记忆化的值来帮助提高函数调用的性能。

接下来,我们将探讨另一个可以帮助性能的 Hook。

使用回调 Hook

在本节中,我们将了解回调 Hook 以及它的用途。然后,我们将使用这个 Hook 在我们一直在工作的 PersonScore 组件中。

理解回调 Hook

回调 Hook 记忆化一个函数,以便它在每次渲染时不会被重新创建。这个 Hook 被称为 useCallback,其语法如下:

const memoizedCallback = useCallback(() => someFunction(), []);

将执行要记忆化函数的函数传递给 useCallback 作为第一个参数。传递给 useCallback 的第二个参数是一个依赖项数组。因此,如果 someFunction 函数有依赖项 ab,调用将如下所示:

const memoizedCallback = useCallback(
  () => someFunction(a, b),
  [a, b]
);

当任何依赖项发生变化时,第一个参数中的函数会再次执行,以返回一个新的函数进行记忆化。在先前的例子中,每当 ab 发生变化时,就会创建一个新的 memoizedCallback 版本。

记忆化函数的类型是推断出来的,但可以在 useCallback 上的泛型参数中显式定义。以下是一个显式定义记忆化函数没有参数并返回 void 的例子:

 const memoizedValue = useCallback<() => void>(
  () => someFunction (),
  []
);

useCallback 的一个常见用例是防止子组件不必要的重新渲染。在尝试 useCallback 之前,我们将花时间了解组件何时会重新渲染。

理解组件何时重新渲染

我们已经了解到,当组件的状态发生变化时,组件会重新渲染。考虑以下组件:

export function SomeComponent() {
  const [someState, setSomeState] = useState('something');
  return (
    <div>
      <ChildComponent />
      <AnotherChildComponent something={someState} />
      <button
        onClick={() => setSomeState('Something else')}
      ></button>
    </div>
  );
}

someState 发生变化时,SomeComponent 将会重新渲染——例如,当按钮被点击时。此外,当 someState 发生变化时,ChildComponentAnotherChildComponent 也会重新渲染。这是因为当其父组件重新渲染时,组件也会重新渲染。

这种重新渲染的行为可能会引起性能问题 – 尤其是在大型组件树的上层组件渲染时。然而,这很少会引起性能问题。这是因为只有当虚拟 DOM 发生变化时,DOM 才会更新,而更新 DOM 是这个过程中的慢速部分。在先前的例子中,如果 ChildComponent 的定义如下,那么在 SomeComponent 重新渲染时,ChildComponent 的 DOM 不会更新:

export function ChildComponent() {
  return <span>A child component</span>;
}

在重新渲染期间,ChildComponent 的 DOM 不会更新,因为虚拟 DOM 将保持不变。

虽然这种重新渲染的行为通常不会引起性能问题,但如果一个计算密集型的组件频繁重新渲染,或者一个具有缓慢副作用组件频繁重新渲染,它可能会引起性能问题。例如,我们希望避免在具有数据获取副作用的组件中不必要的重新渲染。

React 中有一个名为 memo 的函数,可以用来防止不必要的重新渲染。memo 函数可以按照以下方式应用于 ChildComponent 以防止不必要的重新渲染:

export const ChildComponent = memo(() => {
  return <span>A child component</span>;
});

memo 函数包装了组件,并针对一组给定的属性对结果进行记忆化。如果属性相同,则在重新渲染期间使用记忆化的函数。请注意,前面的代码片段使用了箭头函数语法,以便组件可以作为命名导出。

总结来说,React 的 memo 函数可以防止缓慢组件的不必要重新渲染。

接下来,我们将使用 memo 函数和 useCallback 钩子来防止不必要的重新渲染。

使用回调钩子

我们现在将通过提取 Reset 来重构 PersonScore 组件。这将导致 Reset 组件的不必要重新渲染,我们将使用 React 的 memo 函数和 useCallback 钩子来解决这个问题。

要这样做,请执行以下步骤。使用的代码片段可在 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter4/Section5-Using-the-callback-hook 找到:

  1. src 文件夹中创建一个新的文件,名为 Reset.tsx,用于重置按钮,内容如下:

    type Props = {
    
      onClick: () => void,
    
    };
    
    export function Reset({ onClick }: Props) {
    
      console.log("render Reset");
    
      return <button onClick={onClick}>Reset</button>;
    
    }
    

该组件接受一个点击处理程序并显示重置按钮。该组件还会将 render Reset 输出到控制台,以便我们可以清楚地看到组件何时重新渲染。

  1. 打开 PersonScore.tsx 并导入 Reset 组件:

    import { Reset } from './Reset';
    
  2. 按照以下方式替换现有的重置按钮为新 Reset 组件:

    <div>
    
      ...
    
      <button onClick={() => dispatch({ type: 'decrement' })}>
    
        Subtract
    
      </button>
    
      <Reset onClick={() => dispatch({ type: 'reset' })} />
    
    </div>;
    
  3. 前往浏览器中运行的 app,打开 React 的 DevTools。确保在 Components 面板的设置中勾选了 Highlight updates when components render. 选项:

图 4.9 – 重新渲染高亮选项

图 4.9 – 重新渲染高亮选项

  1. 在浏览器中,Reset 不必要地重新渲染。你还会看到重新渲染高亮显示在 Reset 按钮周围。

图 4.10 – 重置组件不必要的重新渲染

图 4.10 – 重置组件不必要的重新渲染

  1. 使用浏览器的 DevTools 检查 DOM。为此,右键单击 h3 元素内容被更新 – 由于没有其他元素因更新而高亮显示。

图 4.11 – 重新渲染后 h3 元素被更新

图 4.11 – 重新渲染后 h3 元素被更新

即使 Reset 不必要地重新渲染,也不会导致 DOM 更新。此外,Reset 的计算成本不高,也不包含任何副作用。因此,不必要的渲染实际上并不是性能问题。然而,我们将使用这个例子来学习如何使用 React 的 memo 函数,以及 useCallback 钩子可以防止不必要的渲染。

  1. 我们现在将添加 React 的 memo 函数来尝试防止不必要的重新渲染。打开 React.tsx 并在文件顶部添加一个 React 导入语句以导入 memo

    import { memo } from 'react';
    
  2. 现在,按照以下方式将 memo 包裹在 Reset 组件周围:

    export const Reset = memo(({ onClick }: Props) => {
    
      console.log("render Reset");
    
      return <button onClick={onClick}>Reset</button>;
    
    });
    
  3. 此外,在 Reset 组件定义下方添加以下行,以便在 React 的 DevTools 中具有有意义的名称:

    Reset.displayName = 'Reset';
    
  4. 在浏览器中,点击 Reset 仍然是不必要地重新渲染。

  5. 我们将使用 React 的 DevTools 来开始理解为什么当其结果被记忆化时,Reset 仍然不必要地重新渲染。打开 Profiler 面板并点击齿轮图标以打开设置。转到 Profiler 设置部分并确保 Record why each component rendered while profiling. 被勾选:

图 4.12 – 确保 Record why each component rendered while profiling. 选项被勾选

图 4.12 – 确保 Record why each component rendered while profiling. 选项被勾选

  1. 点击蓝色圆圈图标开始分析,然后点击应用中的 Add 按钮。点击红色圆圈图标停止分析。

  2. 在出现的火焰图中,点击 Reset 组件的重新渲染:

图 4.13 – 重置重新渲染的信息

图 4.13 – 重置重新渲染的信息

因此,不必要的 Reset 渲染发生是因为 onClick 属性发生了变化。onClick 处理程序包含相同的代码,但在每次渲染时都会创建一个新的函数实例。这意味着 onClick 在每次渲染时将具有不同的引用。变化的 onClick 属性引用意味着从 Reset 中记忆的结果没有被使用,而是发生了重新渲染。

  1. 我们可以使用 useCallback 钩子来记忆 onClick 处理程序并防止重新渲染。打开 PersonScore.tsx 并首先将处理程序重构为命名函数:

    function handleReset() {
    
      dispatch({ type: 'reset' });
    
    }
    
    if (loading) {
    
      return <div>Loading ...</div>;
    
    }
    
    return (
    
      <div>
    
        ...
    
        <Reset onClick={handleReset} />
    
      </div>
    
    );
    
  2. 现在,将 useCallback 添加到 React 导入语句中:

    import {
    
      useEffect,
    
      useReducer,
    
      useRef,
    
      useMemo,
    
      useCallback
    
    } from 'react';
    
  3. 最后,将 useCallback 包裹在我们刚刚创建的点击处理程序周围:

    const handleReset = useCallback(
    
      () => dispatch({ type: 'reset' }),
    
      []
    
    );
    
  4. 现在,如果你点击 重置,它就不再会不必要地重新渲染。

这就完成了我们对 useCallback Hooks 的探索。

这里是对本节所学内容的快速回顾:

  • 当其父组件重新渲染时,组件会重新渲染。

  • React 的 memo 函数可以用来防止对子组件的不必要重新渲染。

  • useCallback 可以用来缓存函数。这可以用来为传递给子组件的函数属性创建一个稳定的引用,以防止不必要的重新渲染。

  • React 的 memo 函数和 useCallback 应该明智地使用——在使用它们之前确保它们有助于性能,因为它们会增加代码的复杂性。

接下来,我们将总结本章内容。

概述

在本章中,我们了解到所有 React Hooks 都必须在函数组件的最高级别调用,并且不能有条件地调用。

useEffect Hook 可以用来在组件渲染时执行组件副作用。我们学习了如何使用 useEffect 来获取数据,这是一个常见的用例。

useReduceruseState 用于使用状态的另一种选择,我们在 PersonScore 示例组件中体验了这两种方法。useState 对于原始状态值非常出色。useReducer 对于复杂对象状态值非常好,尤其是在状态变化依赖于先前状态值时。

useRef Hook 创建一个可变的值,在改变时不会引起重新渲染。我们使用 useRef 在渲染后设置 HTML 元素的焦点,这是一个常见的用例。

useMemouseCallback Hooks 可以用来分别缓存值和函数,并且可以用于性能优化。我们用于这些 Hooks 的示例有点牵强,使用 useCallback 并没有提高性能,所以请记住,使用这些 Hooks 确实可以提高性能。

到目前为止,在这本书中,我们创建的组件都是未加样式的。在下一章,我们将学习几种为 React 组件添加样式的途径。

问题

回答以下问题以检查你对 React Hooks 的了解:

  1. 以下组件将渲染一些文本 5 秒。但这是有问题的——问题是什么?

    export function TextVanish({ text }: Props) {
    
      if (!text) {
    
        return null;
    
      }
    
      const [textToRender, setTextToRender] = useState(text);
    
      useEffect(() => {
    
        setTimeout(() => setTextToRender(""), 5000);
    
      }, []);
    
      return <span>{textToRender}</span>;
    
    }
    
  2. 以下代码是从一个 React 组件中摘取的,该组件获取一些数据并将其存储在状态中。尽管如此,这段代码有几个问题——你能找出任何问题吗?

    const [data, setData] = useState([]);
    
    useEffect(async () => {
    
      const data = await getData();
    
      setData(data);
    
    });
    
  3. 当按钮被点击时,以下组件在生产模式下会重新渲染多少次?此外,点击一次后按钮的内容会是什么?

    export function Counter() {
    
      const [count, setCount] = useState(0);
    
      return (
    
        <button
    
          onClick={() => {
    
            setCount(count + 1);
    
            setCount(count + 1);
    
            setCount(count + 1);
    
          }}
    
        >
    
          {count}
    
        </button>
    
      );
    
    }
    
  4. 当按钮被点击时,以下组件在生产模式下会重新渲染多少次?此外,点击一次后按钮的内容会是什么?

    export function CounterRef() {
    
      const count = useRef(0);
    
      return (
    
        <button
    
          onClick={() => {
    
            count.current = count.current + 1;
    
          }}
    
        >
    
          {count.current}
    
        </button>
    
      );
    
    }
    
  5. 考虑以下 reducer 函数:

    type State = { steps: number };
    
    type Action =
    
      | { type: 'forward'; steps: number }
    
      | { type: 'backwards'; steps: number };
    
    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case 'forward':
    
          return { ...state, steps: state.steps + action.steps };
    
        case 'backwards':
    
          return { ...state, steps: state.steps - action.        steps };
    
        default:
    
          return state;
    
      }
    
    }
    

"backwards" 分支中,action 参数的类型会被缩小到什么?

  1. 考虑以下 Counter 组件:

    export function Counter() {
    
      const [count, setCount] = useState(0);
    
      const memoCount = useMemo(() => count, []);
    
      return (
    
        <div>
    
          <button onClick={() => setCount(count + 1)}>
    
            {memoCount}
    
          </button>
    
        </div>
    
      );
    
    }
    

点击一次后按钮的内容会是什么?

  1. 考虑以下Counter组件:

    export function Counter() {
    
      const [count, setCount] = useState(0);
    
      const handleClick = useCallback(() => {
    
        setCount(count + 1);
    
      }, []);
    
      return (
    
        <div>
    
          <button onClick={handleClick}>{count}</button>
    
        </div>
    
      );
    
    }
    

点击两次后按钮的内容会是什么?

答案

  1. 组件的问题在于useStateuseEffect都是条件性地调用的(当text属性定义时),React 不允许其钩子被条件性地调用。在if语句之前放置钩子可以解决这个问题:

    export function TextVanish({ text }: Props) {
    
      const [textToRender, setTextToRender] = useState(text);
    
      useEffect(() => {
    
        setTimeout(() => setTextToRender(""), 5000);
    
      }, []);
    
      if (!text) {
    
        return null;
    
      }
    
      return <span>{textToRender}</span>;
    
    }
    
  2. 代码的主要问题是效果函数不能使用async关键字标记为异步。一个解决方案是恢复到较旧的 promise 语法:

    const [data, setData] = useState([]);
    
    useEffect(() => {
    
      getData().then((theData) => setData(theData));
    
    });
    

另一个主要问题是调用useEffect时没有定义依赖项。这意味着效果函数将在每次渲染时执行。效果函数设置了一些状态,这导致重新渲染。因此,组件将不断重新渲染,效果函数将无限期地执行。将空数组传递到useEffect的第二个参数将解决问题:

useEffect(() => {
  getData().then((theData) => setData(theData));
}, []);

另一个问题是在data状态中将具有any[]类型,这并不理想。在这种情况下,可能最好显式定义状态类型如下:

const [data, setData] = useState<Data[]>([]);

最后一个问题是在组件卸载之后可能设置数据状态,这可能导致内存泄漏。一个解决方案是在组件卸载时设置一个标志,并在标志设置时不设置状态:

useEffect(() => {
  let cancel = false;
  getData().then((theData) => {
    if (!cancel) {
      setData(theData);
    }
  });
  return () => {
    cancel = true;
  };
}, []);
  1. 在生产模式下,按钮只会渲染一次,因为状态更改是分批处理的。

状态直到下一次渲染才会改变,所以点击一次按钮将导致count被设置为1,这意味着按钮内容将是1

  1. 当按钮被点击时,按钮不会重新渲染,因为对 ref 的更改不会导致重新渲染。

当按钮被点击时,counter引用将增加。然而,因为不会发生重新渲染,按钮内容仍然是0

  1. 'backwards'分支中,TypeScript 将action参数的类型缩小为{ type: 'backwards'; steps: number }

  2. 按钮的内容始终是0,因为初始计数0被记忆化且从未更新。

  3. 点击一次后按钮内容将是1,在随后的点击中保持为1。所以,点击两次后,它将是1

关键在于handleClick函数仅在组件最初渲染时创建,因为useCallback记忆化了它。所以,在记忆化的函数中,count状态始终是0。这意味着count状态将始终更新为1,这将出现在按钮内容中。

第二部分:应用基础

这一部分涵盖了构建 React 核心之外的应用程序的基本主题。这些主题包括样式、客户端路由和表单。每个主题都涉及你可以采取的不同方法,以及每种方法的优点。我们还将介绍几个常用的第三方库,这些库通常用于应用程序的这些部分。

这一部分包括以下章节:

  • 第五章, React 前端样式化方法

  • 第六章, 使用 React Router 进行路由

  • 第七章, 使用表单

第五章:风格化 React 前端的方法

在本章中,我们将使用四种不同的方法来对我们在前几章中工作的警报组件进行样式设计。首先,我们将使用纯 CSS 并了解这种方法的不利之处。然后,我们将转向使用 CSS 模块,这将解决纯 CSS 的主要问题。接下来,我们将使用一个名为 Emotion 的 CSS-in-JS 库和一个名为 Tailwind CSS 的库,并了解这些库各自的优点。

我们还将学习如何在 React 应用中使用 SVG 并在警报组件的信息和警告图标中使用它们。

我们将涵盖以下主题:

  • 使用纯 CSS

  • 使用 CSS 模块

  • 使用 CSS-in-JS

  • 使用 Tailwind CSS

  • 使用 SVG

技术要求

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

本章中使用的所有代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter5

使用纯 CSS

我们将从这个部分开始,通过设置一个包含警报组件的 React 和 TypeScript 项目来启动,该组件来自 第三章**,设置 React 和 TypeScript。接下来,我们将添加来自 第三章 的警报组件,并使用纯 CSS 对其进行样式设计。最后,我们将探讨纯 CSS 的一些挑战,并了解我们如何减轻这些问题。

创建项目

我们将使用的是我们在 第三章 结尾时完成的项目。您可以在以下位置找到它:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter3/Section2-Creating-a-project-with-Create-React-App/myapp。要本地复制此项目,请执行以下步骤:

  1. 在您选择的文件夹中打开 Visual Studio Code。

  2. 在终端中运行以下命令以克隆本书的 GitHub 仓库:

    git clone https://github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition.git
    
  3. Learn-React-with-TypeScript-2nd-Edition\Chapter3\Section2-Creating-a-project-with-Create-React-App\myapp 子文件夹中重新打开 Visual Studio Code。这包含在 第三章 结尾时的项目状态。

  4. 运行以下命令以安装所有依赖项:

    npm i
    

项目现在已设置好。接下来,我们将花一些时间了解如何在 React 组件中使用纯 CSS。

理解如何引用 CSS

Create React App 已经在项目中启用了纯 CSS 的使用。实际上,如果你查看 App.tsx 文件,它已经使用了纯 CSS:

...
import './App.css';
...
function App() {
  return (
    <div className="App">
      ...
    </div>
  );
}
...

App.css 文件中导入 CSS 样式,并在外部的 div 元素上引用 App CSS 类。

React 使用 className 属性而不是 class,因为 class 是 JavaScript 中的一个保留字。className 属性在转译过程中被转换为 class 属性。

CSS 导入语句是 webpack 的一个特性。当 webpack 处理所有文件时,它将包含所有导入的 CSS 到包中。

执行以下步骤以探索项目生成的 CSS 包:

  1. 首先,打开并查看 App.css 文件。正如我们之前所看到的,App.cssApp.tsx 文件中被使用。然而,它包含了一些不再使用的 CSS 类,例如 App-headerApp-logo。在我们添加警报组件时,这些类在 App 组件中被引用,然后我们移除了它们。保留这些多余的 CSS 类。

  2. 打开 index.tsx 文件,你会注意到导入了 index.css。然而,在这个文件中没有引用任何 CSS 类。如果你打开 index.css,你会注意到它只包含针对元素名称的 CSS 规则,而没有 CSS 类。

  3. 在终端中运行以下命令以生成生产构建:

    npm run build
    

几秒钟后,构建工件将出现在项目根目录下的 build 文件夹中。

  1. build 文件夹中打开 index.html 并注意所有空白都被移除了,因为它已经针对生产进行了优化。接下来,找到引用 CSS 文件的 link 元素,并记下路径 – 它将类似于 /static/css/main.073c9b0a.css

图 5.1 – index.html 中的链接元素

图 5.1 – index.html 中的链接元素

  1. 打开引用的 CSS 文件。所有空白都被移除了,因为它已经针对生产进行了优化。注意它包含来自 index.cssApp.css 的所有 CSS,包括多余的 App-headerApp-logo CSS 类。

图 5.2 – 包含多余的 App-header CSS 类的打包 CSS 文件

图 5.2 – 包含多余的 App-header CSS 类的打包 CSS 文件

这里关键点是 webpack 不会移除任何多余的 CSS – 它将包含所有已导入的 CSS 文件中的所有内容。

接下来,我们将使用纯 CSS 来样式化警报组件。

在警报组件中使用纯 CSS

现在我们已经了解了如何在 React 中使用纯 CSS,让我们来样式化警报组件。执行以下步骤:

  1. src 文件夹中添加一个名为 Alert.css 的 CSS 文件。这个文件可以在 GitHub 上找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter5/Section1-Using-plain-CSS/app/src/Alert.css 以便复制。

  2. 我们将逐步添加 CSS 类,并理解每个类中的样式。首先,在 Alert.css 中添加一个 container 类:

    .container {
    
      display: inline-flex;
    
      flex-direction: column;
    
      text-align: left;
    
      padding: 10px 15px;
    
      border-radius: 4px;
    
      border: 1px solid transparent;
    
    }
    

这将在外部的 div 元素上使用。样式使用内联 flexbox,项目垂直流动并左对齐。我们还添加了一个漂亮的圆角边框以及在边框和子元素之间的少量填充。

  1. container 中添加以下额外的类,这些类可以在其中使用:

    .container.warning {
    
      color: #e7650f;
    
      background-color: #f3e8da;
    
    }
    
    .container.information {
    
      color: #118da0;
    
      background-color: #dcf1f3;
    
    }
    

我们将使用这些类为不同类型的 alert 添加适当的颜色。

  1. 为头部容器元素添加以下类:

    .header {
    
      display: flex;
    
      align-items: center;
    
      margin-bottom: 5px;
    
    }
    

这将应用于包含图标、标题和关闭按钮的元素。它使用一个水平流动的 flexbox,子元素垂直居中。它还在 alert 消息之前添加了一个小的间隙。

  1. 现在为图标添加以下类,使其宽度为 30 像素:

    .header-icon {
    
      width: 30px;
    
    }
    
  2. 接下来,添加以下类以应用于标题,使其加粗:

    .header-text {
    
      font-weight: bold;
    
    }
    
  3. 添加以下类以应用于关闭按钮:

    .close-button {
    
      border: none;
    
      background: transparent;
    
      margin-left: auto;
    
      cursor: pointer;
    
    }
    

这移除了边框和背景。它还将按钮对齐到标题的右侧,并给它一个指针鼠标光标。

  1. 为内容元素添加以下类:

    .content {
    
      margin-left: 30px;
    
      color: #000;
    
    }
    

这添加了一个左外边距,使消息水平与标题对齐,并将文本颜色设置为黑色。

这完成了所有的 CSS 类定义。

  1. 打开 Alert.tsx 并为刚刚创建的 CSS 文件添加一个导入语句:

    import './Alert.css';
    
  2. 现在我们将在 alert 组件的元素中引用我们刚刚创建的 CSS 类。在 alert JSX 中添加以下高亮的 CSS 类名引用来完成此操作:

    <div className={`container ${type}`}>
    
      <div className="header">
    
        <span
    
          ...
    
          className="header-icon"
    
        >
    
          {type === "warning" ? "⚠" : "ℹ"}
    
        </span>
    
        <span className="header-text">{heading}</span>
    
      </div>
    
      {closable && (
    
        <button
    
          ...
    
          className="close-button"
    
        >
    
          ...
    
        </button>
    
      )}
    
      <div className="content">{children}</div>
    
    </div>
    

现在 alert 组件中的元素正在通过导入的 CSS 文件中的 CSS 类进行样式化。

  1. 将关闭按钮移动到位于头部容器内部,在 header 元素下方:

    <div className={`container ${type}`}>
    
      <div className="header">
    
        ...
    
        <span className="header-text">{heading}</span>
    
        {closable && (
    
          <button
    
            aria-label="Close"
    
            onClick={handleCloseClick}
    
            className="close-button"
    
          >
    
            <span role="img" aria-label="Close">
    
            </span>
    
          </button>
    
        )}
    
      </div>
    
      <div className="content">{children}</div>
    
    </div>;
    
  2. 通过在终端中运行 npm start 启动应用在开发模式下。

几秒钟后,改进的 alert 组件将在浏览器中显示:

图 5.3 – 使用纯 CSS 样式的 alert 组件

图 5.3 – 使用纯 CSS 样式的 alert 组件

这完成了 alert 组件的样式,但让我们继续,以便我们可以观察到纯 CSS 的一个缺点。

经历 CSS 冲突

我们现在将看到一个 CSS 与不同组件冲突的例子。保持应用在开发模式下运行,然后按照以下步骤操作:

  1. 打开 App.tsx 并将 div 元素上引用的 CSS 类从 App 更改为 container

    <div className="container">
    
      <Alert ...>
    
        ...
    
      </Alert>
    
    </div>
    
  2. 打开 App.css 并将 App CSS 类重命名为 container,并为其添加 20px 的填充:

    .container {
    
      text-align: center;
    
      padding: 20px;
    
    }
    
  3. 现在,查看正在运行的应用程序,并注意警报不再在页面上水平居中。使用浏览器 DevTools 检查元素。如果你检查 App 组件中的 div 元素,你会看到来自警报组件中 container CSS 类的样式也应用到了它上面,以及我们刚刚添加的 container CSS 类。因此,text-align CSS 属性是 left 而不是 center

图 5.4 – CSS 类冲突

图 5.4 – CSS 类冲突

  1. 在继续之前,通过按 Ctrl + C 停止运行的应用程序。

这里关键点是,纯 CSS 类的作用域是整个应用程序,而不仅仅是导入的文件。这意味着如果 CSS 类有相同的名称,它们可能会发生冲突,正如我们刚才所经历的。

解决 CSS 冲突的一个方法是仔细命名,在 App 组件中使用 container 可以命名为 App__container,而在 Alert 组件中的 container 可以命名为 Alert__container。然而,这需要开发团队所有成员的自律。

注意

BEM 代表 Block(块)、Element(元素)、Modifier(修饰符),是 CSS 类名的一个流行命名约定。更多信息可以在以下链接中找到:css-tricks.com/bem-101/

这里是对本节内容的快速回顾:

  • Create React App 配置 webpack 以处理 CSS,以便 CSS 文件可以导入到 React 组件文件中

  • 导入的 CSS 文件中的所有样式都应用于应用程序 – 没有作用域或删除冗余样式

接下来,我们将学习一种不会在组件间出现 CSS 冲突的样式方法。

使用 CSS 模块

在本节中,我们将学习一种称为 CSS 模块 的 React 应用程序样式方法。我们将首先了解 CSS 模块,然后我们将使用它们在我们的警报组件中。

理解 CSS 模块

CSS modules 是一个开源库,可在 GitHub 上找到 github.com/css-modules/css-modules,它可以添加到 webpack 处理中,以方便 CSS 类名的自动作用域。

CSS 模块是一个 CSS 文件,就像在上一节中一样;然而,文件名有一个 .module.css 扩展名而不是 .css。这个特殊的扩展名允许 webpack 区分 CSS 模块文件和纯 CSS 文件,以便可以对其进行不同的处理。

CSS 模块文件可以按照以下方式导入到 React 组件文件中:

import styles from './styles.module.css';

这与导入纯 CSS 的语法类似,但定义了一个变量来保存 CSS 类名映射信息。在前面的代码片段中,CSS 类名信息被导入到一个名为 styles 的变量中,但变量名可以是任何我们选择的。

CSS 类名映射信息变量是一个包含与 CSS 类名对应的属性名的对象。每个类名属性包含一个用于 React 组件的作用域类名值。以下是将导入到名为 MyComponent 的组件中的映射对象的一个示例:

{
  container: "MyComponent_container__M7tzC",
  error: "MyComponent_error__vj8Oj"
}

作用域 CSS 类名以组件文件名开头,然后是原始 CSS 类名,接着是一个随机字符串。这种命名结构防止类名冲突。

CSS 模块中的样式在组件的 className 属性中如下引用:

<span className={styles.error}>A bad error</span>

元素上的 CSS 类名将解析为作用域类名。在上面的代码片段中,styles.error 将解析为 MyComponent_error__ vj8Oj。因此,运行中的应用中的样式将是作用域样式名称,而不是原始类名。

使用 Create React App 创建的项目已经安装并配置了 CSS 模块和 webpack。这意味着我们不需要安装 CSS 模块就可以在我们的项目中开始使用它们。

接下来,我们将在我们工作的警报组件中使用 CSS 模块。

在警报组件中使用 CSS 模块

现在我们已经理解了 CSS 模块,让我们在警报组件中使用它们。执行以下步骤:

  1. 首先将 Alert.css 重命名为 Alert.module.css;现在这个文件可以作为 CSS 模块使用。

  2. 打开 Alert.module.css 并将 CSS 类名更改为驼峰式而不是中划线式。这将使我们能够更容易地在组件中引用作用域 CSS 类名 – 例如,styles.headerText 而不是 styles["header-text"]。更改如下:

    .headerIcon {
    
      ...
    
    }
    
    .headerText {
    
      ...
    
    }
    
    .closeButton {
    
      ...
    
    }
    
  3. 现在,打开 Alert.tsx 并将 CSS 导入语句更改为如下导入 CSS 模块:

    import styles from './Alert.module.css';
    
  4. 在 JSX 中,更改类名引用以使用 CSS 模块的 scoped 名称:

    <div className={`${styles.container} ${styles[type]}`}>
    
      <div className={styles.header}>
    
        <span
    
          ...
    
          className={styles.headerIcon}
    
        >
    
          {type === "warning" ? "⚠" : "ℹ"}
    
        </span>
    
        {heading && (
    
          <span className={styles.headerText}>{heading}</        span>
    
        )}
    
        {closable && (
    
          <button
    
            ...
    
            className={styles.closeButton}
    
          >
    
            ...
    
          </button>
    
        )}
    
      </div>
    
      <div className={styles.content}>{children}</div>
    
    </div>
    
  5. 通过在终端中运行 npm start 来启动应用。

几秒钟后,样式化的警报将出现。这次警报将水平居中,这是样式不再冲突的标志。

  1. 使用浏览器的 DevTools 检查 DOM 中的元素。你会看到警报组件现在正在使用作用域 CSS 类名。这意味着警报容器样式不再与应用容器样式冲突。

图 5.5 – CSS 模块作用域的类名

图 5.5 – CSS 模块作用域的类名

  1. 在继续之前,通过按 Ctrl + C 停止运行中的应用。

  2. 为了完善我们对 CSS 模块的理解,让我们看看生产构建中的 CSS 会发生什么。然而,在我们这样做之前,让我们在 Alert.module.css 的底部添加一个冗余的 CSS 类:

    ...
    
    .content {
    
      margin-left: 30px;
    
      color: #000;
    
    }
    
    .redundant {
    
      color: red;
    
    }
    
  3. 现在,通过在终端中执行 npm run build 来创建生产构建。

几秒钟后,在 build 文件夹中创建构建工件。

  1. 打开捆绑的 CSS 文件,你会注意到以下要点:

    • 它包含来自 index.cssApp.css 和我们刚刚创建的 CSS 模块的所有 CSS。

    • CSS 模块中的类名具有作用域。这将确保生产环境中的样式不会冲突,就像开发模式中那样。

    • 它包含来自 CSS 模块的冗余 CSS 类名。

图 5.6 – 包含在 CSS 包中的冗余 CSS 类

图 5.6 – 包含在 CSS 包中的冗余 CSS 类

这样就完成了将警报组件重构为使用 CSS 模块的过程。

注意

更多关于 CSS 模块的信息,请访问 GitHub 仓库 github.com/css-modules/css-modules

这里是对我们关于 CSS 模块所学内容的回顾:

  • CSS 模块允许 CSS 类名自动作用域到 React 组件上。这防止了不同 React 组件的样式冲突。

  • CSS 模块不是浏览器标准功能;相反,它是一个开源库,可以添加到 webpack 流程中。

  • 在使用 Create React App 创建的项目中,CSS 模块是预安装和预配置的。

  • 与纯 CSS 类似,冗余的 CSS 类不会从生产 CSS 包中删除。

接下来,我们将学习另一种为 React 应用程序添加样式的途径。

使用 CSS-in-JS

在本节中,我们首先理解 CSS-in-JS 及其优点。然后,我们将重构我们使用的警报组件以实现 CSS-in-JS,并观察它与 CSS 模块的不同之处。

理解 CSS-in-JS

CSS-in-JS 不是浏览器功能,甚至不是一个特定的库 – 而是一种库类型。CSS-in-JS 库的流行例子有 styled-componentsEmotion。styled-components 和 Emotion 之间没有显著差异 – 它们都非常流行,并且具有相似的 API。我们将在本章中使用 Emotion。

情感生成的是具有作用域的样式,类似于 CSS 模块。然而,你是在 JavaScript 中而不是在 CSS 文件中编写 CSS,因此得名 CSS-in-JS。实际上,你可以直接在 JSX 元素上编写 CSS,如下所示:

<span
  css={css`
    font-weight: 700;
    font-size: 14;
  `}
>
  {text}
</span>

每个 CSS-in-JS 库的语法略有不同 – 以下示例是来自 Emotion 样式的代码片段。

将样式直接放在组件上允许开发者完全理解组件,而无需访问另一个文件。这显然会增加文件大小,可能会使代码更难阅读。然而,可以通过将子组件识别并从文件中提取出来来减轻大文件大小。或者,可以将样式从组件文件中提取到一个导入的 JavaScript 函数中。

CSS-in-JS 的一个巨大好处是你可以将逻辑混合到样式之中,这对于高度交互的应用程序非常有用。以下示例包含一个依赖于 important 属性的 font-weight 条件和依赖于 mobile 属性的 font-size 条件:

<span
  css={css`
    font-weight: ${important ? 700 : 400};
    font-size: ${mobile ? 15 : 14};
  `}
>
  {text}
</span>

使用 JavaScript 字符串插值来定义条件语句。

相当于纯 CSS 的示例可能类似于以下示例,为不同的条件创建单独的 CSS 类:

<span
  className={`${important ? "text-important" : ""} ${
    mobile ? "text-important" : ""
  }`}
>
  {text}
</span>

如果一个元素的样式高度条件化,那么 CSS-in-JS 可能更容易阅读,当然也更容易编写。

接下来,我们将在我们工作的警报组件中使用 Emotion。

在警报组件中使用情感

现在我们已经了解了 CSS-in-JS,让我们在警报组件中使用 Emotion。为此,执行以下步骤。所有使用的代码片段都可以在 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter5/Section3-Using-CSS-in-JS/app/src/Alert.tsx 找到:

  1. Create React App 没有安装和设置 Emotion,因此我们首先需要安装 Emotion。在终端中运行以下命令:

    npm i @emotion/react
    

这将需要几秒钟的时间来安装。

  1. 打开 Alert.tsx 并删除 CSS 模块导入。

  2. 在文件顶部添加对 Emotion 的 css 属性的导入,并添加一个特殊注释:

    /** @jsxImportSource @emotion/react */
    
    import { css } from '@emotion/react';
    
    import { useState } from 'react';
    

这个特殊注释将 JSX 元素转换为使用 Emotion 的 jsx 函数进行转换,而不是使用 React 的 createElement 函数。Emotion 的 jsx 函数为包含 Emotion 的 css 属性的元素添加样式。

  1. 在 JSX 中,我们需要将所有的 className 属性替换为等效的 Emotion css 属性。样式基本上与我们之前创建的 CSS 文件中定义的相同,所以解释不会重复。

我们将一次样式化一个元素,从外部的 div 元素开始:

<div
  css={css`
    display: inline-flex;
    flex-direction: column;
    text-align: left;
    padding: 10px 15px;
    border-radius: 4px;
    border: 1px solid transparent;
    color: ${type === "warning" ? "#e7650f" : "#118da0"};
    background-color: ${type === "warning"
      ? "#f3e8da"
      : "#dcf1f3"};
  `}
>
  ...
</div>

在这个代码片段中有几个重要的点需要解释:

  • css 属性通常不在 JSX 元素上有效。文件顶部的特殊注释 (/** @jsxImportSource @emotion/react */) 允许这样做。

  • 在这种情况下,css 属性被设置为 css。有关标签模板字面量的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

  • 标签模板字面量在运行时将样式转换为 CSS 类。我们将在 步骤 14 中验证这一点。

  • 使用字符串插值来实现颜色的条件样式。记住,我们不得不使用纯 CSS 或 CSS 模块定义三个 CSS 类。这个 CSS-in-JS 版本可能更易于阅读,当然也更简洁。

  1. 接下来,设置头部容器的样式:

    <div
    
      css={css`
    
        display: flex;
    
        align-items: center;
    
        margin-bottom: 5px;
    
      `}
    
    >
    
      <span role="img" ... > ... </span>
    
      <span ...>{heading}</span>
    
      {closable && ...}
    
    </div>
    
  2. 接下来,按照以下方式设置图标样式:

    <span
    
      role="img"
    
      aria-label={type === "warning" ? "Warning" :     "Information"}
    
      css={css`
    
        width: 30px;
    
      `}
    
    >
    
      {type === "warning" ? "⚠" : "ℹ"}
    
    </span>
    
  3. 然后,按照以下方式设置标题样式:

    <span
    
      css={css`
    
        font-weight: bold;
    
      `}
    
    >
    
      {heading}
    
    </span>
    
  4. 现在,按照以下方式设置关闭按钮样式:

    {closable && (
    
      <button
    
        ...
    
        css={css`
    
          border: none;
    
          background: transparent;
    
          margin-left: auto;
    
          cursor: pointer;
    
        `}
    
      >
    
        ...
    
      </button>
    
    )}
    
  5. 最后,按照以下方式设置消息容器样式:

    <div
    
      css={css`
    
        margin-left: 30px;
    
        color: #000;
    
      `}
    
    >
    
      {children}
    
    </div>
    
  6. 在终端中运行 npm start 来启动应用程序。警报组件将像之前一样出现。

  7. 使用浏览器的开发者工具检查 DOM 中的元素。警报组件使用范围 CSS 类名,类似于 CSS 模块:

图 5.7 – 情感的范围类名

图 5.7 – Emotion 的作用域类名

  1. 在继续之前,通过按Ctrl + C停止运行中的应用程序。

  2. 为了完善我们对 Emotion 的理解,让我们看看生产构建中的 CSS 会发生什么。首先,通过在终端中执行npm run build来创建生产构建。

几秒钟后,构建工件将在build文件夹中创建。

  1. 打开build/static/css文件夹中的捆绑 CSS 文件。注意,Emotion 样式不在其中。这是因为 Emotion 通过 JavaScript 在运行时生成样式,而不是在构建时。如果你这么想,样式不能在构建时生成,因为它们可能依赖于仅在运行时才知道值的 JavaScript 变量。

这完成了对警报组件的重构,以使用 CSS-in-JS。

注意

更多关于 emotion 的信息,请访问他们的网站emotion.sh/docs/introduction

这是关于 Emotion 和 CSS-in-JS 我们所学的总结:

  • CSS-in-JS 库的样式是在 JavaScript 中定义的,而不是在 CSS 文件中。

  • Emotion 的样式可以直接在 JSX 元素上使用css属性定义。

  • 一个巨大的好处是可以在样式上直接添加条件逻辑,这有助于我们更快地设置交互式组件的样式。

  • 由于 Emotion 的样式依赖于 JavaScript 变量,它们在运行时而不是在构建时应用,这允许优雅地定义条件样式逻辑,但也意味着会有轻微的性能损失,因为样式是在运行时创建和应用的。

接下来,我们将了解另一种不同的方法来设置 React 前端。

使用 Tailwind CSS

在本节中,我们将首先理解 Tailwind CSS 及其优势。然后,我们将重构我们一直在使用的警报组件,以使用 Tailwind,并观察它与我们所尝试的其他方法有何不同。

理解 Tailwind CSS

Tailwind 是一组预构建的 CSS 类,可用于设置应用程序的样式。它被称为实用优先 CSS 框架,因为预构建的类可以被视为灵活的实用工具。

以下是一个 CSS 类的示例:bg-white,它将元素的背景设置为白色 – bgbackground的缩写。另一个例子是bg-orange-500,它将背景颜色设置为橙色的 500 号色调。Tailwind 包含一个很棒的颜色调色板,可以进行自定义。

可以组合使用实用类来设置元素的样式。以下示例展示了如何在 JSX 中设置按钮元素的样式:

<button className="border-none rounded-md bg-emerald-700 text-white cursor-pointer">
  ...
</button>

下面是对前面示例中使用的类的解释:

  • border-none移除元素的边框。

  • rounded-md使元素的边框圆角。md代表medium。也可以使用lg(大型)或甚至full,以获得更圆的边框。

  • bg-emerald-700将元素的背景颜色设置为翡翠色的 700 号色调。

  • text-white将元素的文本颜色设置为白色。

  • cursor-pointer将元素的指针设置为指针。

实用类是低级别的,专注于样式化非常具体的东西。这使得类具有灵活性,允许它们高度可重用。

Tailwind 可以通过在类名前加上 hover: 来指定当元素处于悬停状态时应应用该类。以下示例在悬停时将按钮背景设置为更深的翡翠色:

<button className="md border-none rounded-md bg-emerald-700 text-white cursor-pointer hover:bg-emerald-800">
  ...
</button>

因此,Tailwind 的一个关键点是,我们不会为每个想要样式的元素编写新的 CSS 类 - 相反,我们使用大量经过深思熟虑的现有类。这种方法的优点是它有助于使应用程序看起来既美观又一致。

注意

关于 Tailwind 的更多信息,请参阅以下链接的网站:tailwindcss.com/。Tailwind 网站是搜索和理解所有可用实用类的一个关键资源。

接下来,我们将安装和配置 Tailwind,用于包含我们一直在工作的警报组件的项目。

安装和配置 Tailwind CSS

现在我们已经了解了 Tailwind,让我们在警报组件项目中安装和配置它。为此,执行以下步骤:

  1. 在 Visual Studio 项目中,首先通过在终端运行以下命令来安装 Tailwind:

    npm i -D tailwindcss
    

Tailwind 库作为开发依赖项安装,因为它在运行时不是必需的。

  1. Tailwind 通过使用名为 PostCSS 的库集成到 Create React App 项目中。PostCSS 是一个使用 JavaScript 转换 CSS 的工具,Tailwind 作为插件在其中运行。通过在终端运行以下命令来安装 PostCSS:

    npm i -D postcss
    
  2. Tailwind 还推荐另一个名为 Autoprefixer 的 PostCSS,它为 CSS 添加供应商前缀。通过在终端运行以下命令来安装它:

    npm i -D autoprefixer
    
  3. 接下来,在终端运行以下命令以生成 Tailwind 和 PostCSS 的配置文件:

    npx tailwindcss init -p
    

几秒钟后,将创建两个配置文件。Tailwind 配置文件名为 tailwind.config.js,PostCSS 配置文件名为 postcss.config.js

  1. 打开 tailwind.config.js 并指定 React 组件的路径如下:

    module.exports = {
    
      content: [
    
        './src/**/*.{js,jsx,ts,tsx}'
    
      ],
    
      theme: {
    
        extend: {},
    
      },
    
      plugins: [],
    
    }
    
  2. 现在,打开 src 文件夹中的 index.css 并在文件顶部添加以下三行:

    @tailwind base;
    
    @tailwind components;
    
    @tailwind utilities;
    

这些被称为 指令,将在构建过程中生成 Tailwind 所需的 CSS。

Tailwind 已安装并准备好使用。

接下来,我们将使用 Tailwind 为我们一直在工作的警报组件进行样式设计。

使用 Tailwind CSS

现在,让我们使用 Tailwind 为警报组件进行样式设计。我们将删除 emotion 的 css JSX 属性,并在 JSX 的 className 属性中使用 Tailwind 实用类名。为此,执行以下步骤:

  1. 打开 Alert.tsx 并从删除文件顶部的特殊 emotion 注释和 css 导入语句开始。

  2. 将最外层 div 元素的 css 属性替换为 className 属性,如下所示:

    <div
    
      className={`inline-flex flex-col text-left px-4 py-3     rounded-md border-1 border-transparent`}
    
    >
    
      ...
    
    </div>
    

这里是刚刚使用的工具类的解释:

  • inline-flexflex-col 创建一个垂直流动的内联弹性盒子

  • text-left 将项目对齐到左侧

  • px-4 添加了 4 个间距单位的左右填充

  • py-3 添加了顶部和底部 3 个间距单位的填充

  • 我们之前遇到过 rounded-md —— 这会使 div 元素的角落变得圆滑

  • border-1border-transparent 添加了一个透明的 1 像素边框

注意

间距单位在 Tailwind 中定义,是一个比例刻度。一个间距单位等于 0.25rem,大约等于 4px

  1. 仍然在最外层的 div 元素上,使用字符串插值添加以下条件样式:

    <div
    
      className={`inline-flex flex-col text-left px-4 py-3 rounded-md border-1 border-transparent ${
    
        type === 'warning' ? 'text-amber-900' : 'text-      teal-900'
    
      } ${type === 'warning' ? 'bg-amber-50' : 'bg-teal-    50'}`}
    
    >
    
      ...
    
    </div>
    

文本颜色设置为 900 琥珀色调用于警告警报,900 蓝绿色调用于信息警报。背景颜色设置为 50 琥珀色调用于警告警报,50 蓝绿色调用于信息警报。

  1. 接下来,将标题容器的 css 属性替换为 className 属性,如下所示:

    <div className="flex items-center mb-1">
    
      <span role="img" ... > ... </span>
    
      <span ... >{heading}</span>
    
      {closable && ...}
    
    </div>
    

这里是刚刚使用的工具类的解释:

  • flexitems-center 创建了一个水平流动的弹性盒子,其中项目垂直居中

  • mb-1 在元素的底部添加了 1 个间距单位边距

  1. 将图标上的 css 属性替换为 className 属性,如下所示:

    <span role="img" ... className="w-7">
    
      {type === 'warning' ? '⚠' : 'ℹ'}
    
    </span>
    

w-7 将元素宽度设置为 7 个间距单位。

  1. 将标题上的 css 属性替换为 className 属性,如下所示:

    <span className="font-bold">{heading}</span>
    

font-bold 将元素的字体重量设置为粗体。

  1. 将关闭按钮上的 css 属性替换为 className 属性,如下所示:

    {closable && (
    
      <button
    
        ...
    
        className="border-none bg-transparent ml-auto cursor-      pointer"
    
      >
    
        ...
    
      </button>
    
    )}
    

在这里,border-none 移除了元素边框,bg-transparent 使元素背景透明。ml-auto 将左边距设置为自动,使元素右对齐。cursor-pointer 将鼠标光标设置为指针。

  1. 最后,将消息容器的 css 属性替换为 className 属性,如下所示:

    <div className="ml-7 text-black">
    
      {children}
    
    </div>
    

ml-7 将元素左边缘设置为 7 个间距单位,text-black 将文本颜色设置为黑色。

  1. 通过在终端中运行 npm start 来运行应用程序。几秒钟后,应用程序将在浏览器中显示。

注意,由于 Tailwind 的默认颜色方案和一致的间距,警报组件看起来更美观。

  1. 使用浏览器的 DevTools 检查 DOM 中的元素。注意使用的 Tailwind 工具类,并注意间距单位使用 CSS rem 单位。

一个需要注意的关键点是,没有发生 CSS 类名作用域。不需要任何作用域,因为类是通用的和可重用的,而不是特定于任何元素。

图 5.8 – 使用 Tailwind 定制的警报

图 5.8 – 使用 Tailwind 定制的警报

  1. 在继续之前,通过按 Ctrl + C 停止运行应用程序。

  2. 为了结束我们对 Tailwind 的理解,让我们看看生产构建中的 CSS 会发生什么。首先,通过在终端中执行 npm run build 来创建一个生产构建。

几秒钟后,构建工件将在 build 文件夹中创建。

  1. build/static/css 文件夹打开打包的 CSS 文件。注意文件开头的基 Tailwind 样式。你还会看到我们使用的所有 Tailwind 类都包含在这个文件中。

图 5.9 – 打包的 CSS 文件中的 Tailwind CSS 类

图 5.9 – 打包的 CSS 文件中的 Tailwind CSS 类

注意

一个重要的观点是 Tailwind 不会添加所有它的 CSS 类——那样会产生一个巨大的 CSS 文件!相反,它只添加在应用中使用的 CSS 类。

这就完成了将警报组件重构为使用 Tailwind 的过程。

这里是对我们关于 Tailwind 学习的回顾:

  • Tailwind 是一组经过深思熟虑的可重用 CSS 类集合,可以应用于 React 元素。

  • Tailwind 有一个不错的默认调色板和 4 像素的间距刻度,这两者都可以自定义。

  • Tailwind 是一个 PostCSS 插件,在构建时执行。

  • 与 Emotion 不同,Tailwind 不会产生运行时性能惩罚,因为样式不是在运行时创建和应用的。

  • 只有在 React 元素上使用的类才包含在 CSS 构建包中。

接下来,我们将使警报组件中的图标看起来更美观。

使用 SVG

在本节中,我们将学习如何在 React 中使用 SVG 文件以及如何将它们用于警报组件的图标。

理解如何在 React 中使用 SVG

SVG 代表 可缩放矢量图形,它由基于数学公式的点、线、曲线和形状组成,而不是特定的像素。这使得它们在缩放时不会扭曲。图标的品质对于正确呈现非常重要——如果它们被扭曲,会使整个应用感觉不专业。在现代网络开发中,使用 SVG 为图标是很常见的。

Create React App 在创建项目时配置 webpack 使用 SVG 文件。实际上,logo.svg 在模板 App 组件中被引用,如下所示:

import logo from './logo.svg';
...
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        ...
      </header>
    </div>
  );
}
export default App;

在前面的示例中,logo 被导入为 SVG 文件的路径,然后用于 img 元素的 src 属性以显示 SVG。

另一种引用 SVG 的方法是将其作为组件引用,如下所示:

import { ReactComponent as Logo } from './logo.svg';
function SomeComponent() {
  return (
    <div>
      <Logo />
    </div>
  );
}

SVG React 组件在名为 ReactComponent 的命名导入中可用。在前面的示例中,SVG 组件被别名 Logo,然后在 JSX 中使用。

接下来,我们将学习如何在警报组件中使用 SVG。

将 SVG 添加到警报组件

执行以下步骤以将警报组件中的表情符号图标替换为 SVG:

  1. 首先,在 src 文件夹中创建三个名为 cross.svginfo.svgwarning.svg 的文件。然后,从 GitHub 仓库 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter5/Section5-Using-SVGs/app/src 复制并粘贴这些文件的内容。

  2. 打开 Alert.tsx 并添加以下导入语句以将 SVG 作为 React 组件导入:

    import { ReactComponent as CrossIcon } from './cross.svg';
    
    import { ReactComponent as InfoIcon } from './info.svg';
    
    import { ReactComponent as WarningIcon } from './warning.svg';
    

我们已经为 SVG 组件赋予了适当的别名。

  1. 更新包含表情符号图标的 span 元素,以使用以下 SVG 图标组件:

    <span
    
      role="img"
    
      aria-label={type === 'warning' ? 'Warning' :     'Information'}
    
      className="inline-block w-7"
    
    >
    
      {type === 'warning ' ? (
    
        <WarningIcon className="fill-amber-900 w-5 h-5" />
    
      ) : (
    
        <InfoIcon className="fill-teal-900 w-5 h-5" />
    
      )}
    
    </span>;
    

我们已经使用 Tailwind 适当地调整了图标的大小和颜色。

  1. 接下来,更新表情符号关闭图标为以下 SVG 关闭图标:

    <button
    
      aria-label="Close"
    
      onClick={handleCloseClick}
    
      className="border-none bg-transparent ml-auto cursor-    pointer"
    
    >
    
      <CrossIcon />
    
    </button>
    
  2. 通过在终端中运行 npm start 来运行应用程序。几秒钟后,应用程序将以包含改进的警报组件的浏览器形式出现:

图 5.10 – 带有 SVG 图标的警报

图 5.10 – 带有 SVG 图标的警报

这就完成了警报组件——它现在看起来好多了。

下面是关于在 React 应用中使用 SVG 的快速回顾:

  • Webpack 需要配置以打包 SVG 文件,Create React App 为我们做了这个配置。

  • SVG 文件的默认导入是 SVG 的路径,然后可以在 img 元素中使用。

  • 可以使用名为 ReactComponent 的命名导入来在 JSX 中将 SVG 引用为 React 组件。

接下来,我们将总结本章所学的内容。

摘要

在本章中,我们学习了四种样式化方法。

首先,我们了解到纯 CSS 可以用来样式化 React 应用,但所有导入的 CSS 文件中的样式都会被打包,无论是否使用了某个样式。此外,样式并不是作用域到特定的组件——我们观察到 container CSS 类名与 AppAlert 组件冲突。

接下来,我们学习了关于 CSS 模块的内容,它允许我们以作用域到组件的方式导入纯 CSS 文件。我们了解到 CSS 模块是一个开源库,在用 Create React App 创建的项目中预先安装和配置。我们看到这解决了 CSS 冲突问题,但没有移除冗余样式。

然后,我们讨论了 CSS-in-JS 库,它允许在 React 组件上直接定义样式。我们使用 emotion 的 css 属性来样式化警报组件,而不需要外部 CSS 文件。这种方法的优点是,条件样式逻辑可以更快地实现。我们了解到 emotion 的样式作用域类似于 CSS 模块,但作用域是在运行时而不是在构建时发生的。我们还了解到,这种方法的微小性能成本是因为样式是在运行时创建的。

我们探讨的第四种样式方法是使用 Tailwind CSS。我们了解到 Tailwind 提供了一组可重用的 CSS 类,可以应用于 React 元素,包括一个漂亮的默认调色板和 4 px 的间距刻度,这两者都可以自定义。我们还了解到,只有使用的 Tailwind 类会被包含在生产构建中。

最后,我们了解到 Create React App 配置了 webpack 以启用 SVG 文件的使用。SVG 可以作为 img 元素中的路径引用,或者作为名为 import 的 React 组件使用。

在下一章中,我们将探讨使用名为 React Router 的流行库在 React 应用中实现多页面的方法。

问题

回答以下问题以检查您对 React 样式的了解:

  1. 为什么以下使用纯 CSS 可能会有问题?

    <div className="wrapper"></div>
    
  2. 我们有一个使用 CSS 模块样式化的组件,如下所示:

    import styles from './styles1.module.css';
    
    function ComponentOne() {
    
      return <div className={styles.wrapper}></div>;
    
    }
    

我们还有一个使用 CSS 模块样式化的组件,如下所示:

import styles from './styles2.module.css';
function ComponentTwo() {
  return <div className={styles.wrapper}></div>;
}

由于它们都使用wrapper类名,这些div元素的样式是否会冲突?

  1. 我们有一个使用 CSS 模块样式化的组件,如下所示:

    import styles from './styles3.module.css';
    
    function ComponentThree() {
    
      return <div className={styles.wrapper}>
    
    </div>
    
    }
    

styles3.module.css中的样式如下:

.wrap {
  display: flex;
  align-items: center;
  background: #e7650f;
}

当应用运行时,样式没有被应用。问题是什么?

  1. 我们正在定义一个具有kind属性的可用按钮组件,该属性可以是"square""rounded"。圆形按钮应该有 4px 的边框半径,而方形按钮应该没有边框半径。我们如何使用 Emotion 的css属性定义这种条件样式?

  2. 我们正在使用 Tailwind 对按钮元素进行样式化。它目前被样式化为以下这样:

    <button className="bg-blue-500 text-white font-bold py-2 px-4 rounded">
    
      Button
    
    </button>
    

我们如何通过将按钮背景设置为用户悬停时的 700 度蓝色来增强样式?

  1. 如下引用一个 logo SVG:

    import Logo from './logo.svg';
    
    function LogoComponent() {
    
      return <Logo />;
    
    }
    

然而,logo 没有被渲染。问题是什么?

  1. 我们正在使用 Tailwind 对具有color属性以确定其颜色的按钮元素进行样式化,该属性如下所示:

    <button className={`bg-${color}-500 text-white font-bold py-2 px-4 rounded`}>
    
      Button
    
    </button>
    

然而,按钮颜色不起作用。问题是什么?

答案

  1. 包装器 CSS 类可能会与其他类冲突。为了降低这种风险,可以将类名手动范围限制到组件中:

    <div className="card-wrapper"></div>
    
  2. CSS 不会冲突,因为 CSS 模块会将类名范围限制在每个组件中。

  3. 组件中引用了错误的类名 - 它应该是wrap而不是wrapper

    import styles from './styles3.module.css';
    
    function ComponentThree() {
    
      return <div className={styles.wrap}>
    
    </div>
    
    }
    
  4. 按钮上的css属性可以是以下这样:

    <button
    
      css={css`
    
        border-radius: ${kind === "rounded" ? "4px" : "0px"};
    
      `}
    
    >
    
      ...
    
    </button>
    
  5. 样式可以调整如下以包括悬停样式:

    <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
    
      ...
    
    </button
    
  6. Logo将持有 SVG 的路径而不是组件。导入语句可以调整如下以导入一个 logo 组件:

    import { ReactComponent as Logo } from './logo.svg';
    
    function LogoComponent() {
    
      return <Logo />;
    
    }
    
  7. bg-${color}-500类名有问题,因为这只能在运行时解决,因为存在color变量。使用的 Tailwind 类在构建时确定并添加到包中,这意味着相关的背景颜色类不会打包。这意味着背景颜色样式不会应用到按钮上。

第六章:使用 React Router 进行路由

在本章中,我们将构建一个简单的应用程序,实现以下页面:

  • 欢迎用户的首页

  • 列出所有产品的产品列表页面

  • 一个提供特定产品详细信息的产品页面

  • 专为特权用户设计的管理员页面

这一切都将使用名为 React Router 的库来管理。

通过这种方式,我们将学习如何从产品列表到产品页面实现静态链接,并在产品页面上实现产品 ID 的路由参数。我们还将了解在应用程序的搜索功能中关于表单导航和查询参数的内容。

最后,本章将介绍如何懒加载页面代码以提高性能。

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

  • 介绍 React Router

  • 声明路由

  • 创建导航

  • 使用嵌套路由

  • 使用路由参数

  • 创建错误页面

  • 使用索引路由

  • 使用搜索参数

  • 编程导航

  • 使用表单导航

  • 实现懒加载

技术要求

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

本章中所有的代码片段都可以在网上找到,地址为 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter6

介绍 React Router

在本节中,我们在了解 React Router 是什么以及如何安装它之前,首先创建一个新的 React 项目用于应用程序。

创建项目

我们将使用 Visual Studio Code 在本地开发应用程序,这需要一个基于 Create React App 的新项目设置。我们已经多次介绍过这一点,所以在本章中我们将不介绍步骤——相反,请参阅 第三章设置 React 和 TypeScript。创建一个具有您选择的名称的应用程序项目。

我们将使用 Tailwind CSS 来设计应用程序。我们已经在 第五章前端设计方法 中介绍了如何安装和配置 Tailwind,因此您创建了 React 和 TypeScript 项目后,请安装并配置 Tailwind。

理解 React Router

如其名所示,React Router 是 React 应用程序的路由库。路由器负责选择在应用程序中显示的内容。例如,当请求 /products/6 路径时,React Router 负责确定要渲染哪些组件。对于包含多个页面的任何应用程序,路由器都是必不可少的,并且 React Router 已经是许多年 React 的流行路由库。

安装 React Router

React Router 包含在一个名为 react-router-dom 的包中。使用以下终端命令在项目中安装它:

npm i react-router-dom

TypeScript 类型包含在 react-router-dom 中,因此不需要单独安装。

接下来,我们将在应用中创建一个页面并声明一个显示该页面的路由。

声明路由

我们将从这个部分开始创建一个列出应用产品的页面组件。然后我们将学习如何使用 React Router 的 createBrowserRouter 函数创建路由器并声明路由。

创建产品列表页面

产品列表页面组件将包含应用中所有 React 工具的列表。按照以下步骤创建:

  1. 我们将首先创建页面的数据源。首先,在 src 文件夹中创建一个名为 data 的文件夹,然后在 data 文件夹中创建一个名为 products.ts 的文件。

  2. 将以下内容添加到 products.ts 中(您可以从 GitHub 仓库 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter6/src/data/products.ts 复制并粘贴):

    export type Product = {
    
      id: number,
    
      name: string,
    
      description: string,
    
      price: number,
    
    };
    
    export const products: Product[] = [
    
      {
    
        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 implement       robust forms',
    
        id: 3,
    
        name: 'React Hook Form',
    
        price: 9,
    
      },
    
      {
    
        description: 'A library that helps you interact with       a REST API',
    
        id: 4,
    
        name: 'React Apollo',
    
        price: 10,
    
      },
    
      {
    
        description: 'A library that provides utility CSS       classes',
    
        id: 5,
    
        name: 'Tailwind CSS',
    
        price: 7,
    
      },
    
    ];
    

这是一个包含应用中所有 React 工具的 JavaScript 数组列表。

注意

通常,这类数据位于某个服务器上,但这超出了本章的范围。我们将在 第九章与 RESTful API 交互 中详细介绍如何与服务器数据交互,包括如何使用 React Router 高效地完成此操作。

  1. 现在我们将创建产品列表页面组件。首先,在 src 文件夹中创建一个名为 pages 的文件夹,用于存放所有页面组件。接下来,在 pages 文件夹中创建一个名为 ProductsPage.tsx 的文件,用于产品列表页面组件。

  2. 将以下 import 语句添加到 ProductsPage.tsx 中以导入我们刚刚创建的产品:

    import { products } from '../data/products';
    
  3. 接下来,开始创建 ProductsPage 组件,输出页面的标题:

    export function ProductsPage() {
    
      return (
    
        <div className="text-center p-5">
    
          <h2 className="text-xl font-bold text-slate-600">
    
            Here are some great tools for React
    
          </h2>
    
        </div>
    
      );
    
    }
    

这使用 Tailwind 类使标题变大、加粗、灰色并水平居中。

  1. 接下来,在 JSX 中添加产品列表:

    <div className="text-center p-5 text-xl">
    
      <h2 className="text-base text-slate-600">
    
        Here are some great tools for React
    
      </h2>
    
      <ul className="list-none m-0 p-0">
    
        {products.map((product) => (
    
          <li key={product.id} className="p-1 text-base text-        slate-800">
    
            {product.name}
    
          </li>
    
        ))}
    
      </ul>
    
    </div>
    

Tailwind 类从无序列表元素中移除了项目符号、边距和填充,并将列表项设置为灰色。

注意,我们使用产品数组 map 函数遍历每个产品并返回一个 li 元素。使用 Array.map 是 JSX 循环逻辑的常见做法。

注意列表项元素上的 key 属性。React 需要在循环中的元素上使用此属性以有效地更新相应的 DOM 元素。key 属性的值必须在数组中是唯一的且稳定的,所以我们使用了产品 ID。

目前这完成了产品页面的创建。这个页面在应用中还没有显示,因为它不是其组件树的一部分——我们需要使用 React Router 声明它,我们将在下一步中这样做。

理解 React Router 的路由器

React Router 中的路由是一个跟踪浏览器 URL 并执行导航的组件。React Router 中有几个路由器可用,推荐用于 Web 应用程序的是名为 createBrowserRouter 的函数,它创建一个浏览器路由器。

createBrowserRouter 需要一个包含应用程序中所有 路由 的参数。一个路由包含一个路径和当应用程序的浏览器地址匹配该路径时要渲染的组件。以下代码片段创建了一个具有两个路由的路由器:

const router = createBrowserRouter([
  {
    path: 'some-page',
    element: <SomePage />,
  },
  {
    path: 'another-page',
    element: <AnotherPage />,
  }
]);

当路径是 /some-page 时,将渲染 SomePage 组件。当路径是 /another-page 时,将渲染 AnotherPage 组件。

createBrowserRouter 返回的路由器被传递给一个 RouterProvider 组件,并放置在 React 组件树的较高位置,如下所示:

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

现在我们开始理解 React Router 的路由器,我们将在我们的项目中使用它。

声明产品路由

我们将在应用中使用 createBrowserRouterRouterProvider 声明产品列表页面。执行以下步骤:

  1. 我们将创建自己的组件来包含所有的路由定义。在 src 文件夹中创建一个名为 Routes.tsx 的文件,包含以下 import 语句:

    import {
    
      createBrowserRouter,
    
      RouterProvider,
    
    } from 'react-router-dom';
    
    import { ProductsPage } from './pages/ProductsPage';
    

我们已从 React Router 中导入了 createBrowserRouterRouterProvider。我们还导入了 ProductsPage,我们将在下一个 products 路由中渲染它。

  1. import 语句下方添加以下组件,以定义具有 products 路由的路由器:

    const router = createBrowserRouter([
    
      {
    
        path: 'products',
    
        element: <ProductsPage />,
    
      },
    
    ]);
    

因此,当路径是 /products 时,将渲染 ProductsPage 组件。

  1. 仍然在 Routes.tsx 中,在路由器下创建一个名为 Routes 的组件,如下所示:

    export function Routes() {
    
      return <RouterProvider router={router} />;
    
    }
    

此组件将 RouterProvider 包装起来,并将路由传递给它。

  1. 打开 index.tsx 文件,在其他的 import 语句下方添加我们刚刚创建的 Routes 组件的 import 语句:

    import { Routes } from './Routes';
    
  2. Routes 而不是 App 作为顶级组件渲染,如下所示:

    root.render(
    
      <React.StrictMode>
    
        <Routes />
    
      </React.StrictMode>
    
    );
    

这使得我们定义的 products 路由成为组件树的一部分。这意味着当路径是 /products 时,产品列表页面将在应用中渲染。

  1. 删除对 App 组件的 import 语句,因为目前不需要它。

  2. 使用 npm start 运行应用。

出现一个错误屏幕,解释说当前路由未找到:

图 6.1 – React Router 的标准错误页面

图 6.1 – React Router 的标准错误页面

错误页面来自 React Router。正如错误消息所建议的,我们可以提供自己的错误屏幕,我们将在本章的后面做到这一点。

  1. 将浏览器 URL 更改为 http://localhost:3000/products

你将看到产品列表页面组件按以下方式渲染:

图 6.2 – 产品列表页面

图 6.2 – 产品列表页面

这确认了 products 路由运行良好。在我们回顾并进入下一节之前,保持应用运行。

在本节中,我们回顾一下我们学到了什么:

  • 在 Web 应用程序中,React Router 使用 createBrowserRouter 定义路由。

  • 每个路由都有一个路径和一个组件,当浏览器的 URL 与该路径匹配时,将渲染该组件。

  • createBrowserRouter 返回的路由器被传递给一个 RouterProvider 组件,该组件应放置在组件树的高层。

关于 createBrowserRouter 的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/routers/create-browser-router。关于 RouterProvider 的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/routers/router-provider

接下来,我们将学习关于可以执行导航的 React Router 组件。

创建导航

React Router 附带名为 LinkNavLink 的组件,它们提供导航。在本节中,我们将在应用程序顶部创建一个包含 React Router 的 Link 组件的导航栏。然后我们将用 NavLink 组件替换 Link,并了解两个组件之间的区别。

执行以下步骤以创建包含 React Router 的 Link 组件的应用程序头部:

  1. 首先,在 src 文件夹中创建一个名为 Header.tsx 的应用程序头部文件,包含以下 import 语句:

    import { Link } from 'react-router-dom';
    
    import logo from './logo.svg';
    

我们已从 React Router 中导入了 Link 组件。

我们还导入了 React 标志,因为我们将在应用程序头部包含导航选项。

  1. 创建 Header 组件如下所示:

    export function Header() {
    
      return (
    
        <header className="text-center text-slate-50       bg-slate-900 h-40 p-5">
    
          <img
    
            src={logo}
    
            alt="Logo"
    
            className="inline-block h-20"
    
          />
    
          <h1 className="text-2xl">React Tools</h1>
    
          <nav></nav>
    
        </header>
    
      );
    
    }
    

该组件包含一个包含 React 标志、应用程序标题和一个空 nav 元素的 header 元素。我们使用了 Tailwind 类来使头部灰色,并将标志和标题水平居中。

  1. 现在,在 nav 元素内部创建一个链接:

    <nav>
    
      <Link
    
        to="products"
    
        className="text-white no-underline p-1"
    
      >
    
        Products
    
      </Link>
    
    </nav>
    

Link 组件有一个 to 属性,它定义了要导航到的路径。要显示的文本可以在 Link 内容中指定。

  1. 打开 Routes.tsx 并为刚刚创建的 Header 组件添加一个导入语句:

    import { Header } from './Header';
    
  2. router 定义中,添加一个渲染 Header 组件的根路径,如下所示:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <Header />,
    
      },
    
      {
    
        path: 'products',
    
        element: <ProductsPage />,
    
      },
    
    ]);
    

我们刚刚所做的不太理想,因为 Header 组件需要在所有路由上显示,而不仅仅是根路由。然而,它将允许我们探索 React Router 的 Link 组件。我们将在 使用嵌套 路由 部分中整理这个问题。

  1. 在运行中的应用程序中,将浏览器地址更改为应用程序的根目录。新的应用程序头部出现,包含 产品 链接:

图 6.3 – 应用程序头部

图 6.3 – 应用程序头部

  1. 现在,使用浏览器的 DevTools 检查应用程序头部元素:

图 6.4 – 头部组件检查

图 6.4 – 头部组件检查

我们可以看到 Link 组件被渲染为一个 HTML 锚元素。

  1. 在 DevTools 中选择 网络 选项卡并清除任何显示的现有请求。点击应用头部中的 产品 链接。浏览器将导航到产品列表页面。

注意,没有为产品列表页面发起网络请求。这是因为 React Router 使用客户端导航覆盖了锚元素的默认行为:

图 6.5 – 客户端导航

图 6.5 – 客户端导航

最后,请注意,在产品列表页面上应用头部消失了,这不是我们想要的效果。我们将在 使用嵌套 路由 部分解决这个问题。

在我们进入下一个部分之前,保持应用运行。

导航工作得很好,但如果在产品列表页面活动时,产品链接有不同的样式会更好。我们将在下一个改进中实现这一点。

React Router 的 NavLink 类似于 Link 元素,但允许它在活动时以不同的方式样式化。这对于导航栏来说非常方便。

执行以下步骤以在应用头部将 Link 替换为 NavLink

  1. 打开 Header.tsx 并将 Link 引用更改为 NavLink

    import { NavLink } from 'react-router-dom';
    
    ...
    
    export function Header() {
    
      return (
    
        <header ...>
    
          ...
    
          <nav>
    
            <NavLink
    
              to="products"
    
              className="..."
    
            >
    
              Products
    
            </NavLink>
    
          </nav>
    
        </header>
    
      );
    
    }
    

目前,应用头部看起来和表现完全相同。

  1. NavLink 组件上的 className 属性接受一个函数,可以根据页面是否处于活动状态有条件地样式化它。将 className 属性更新为以下内容:

    <NavLink
    
      to="products"
    
      className={({ isActive }) =>
    
        `text-white no-underline p-1 pb-0.5 border-solid        border-b-2 ${
    
          isActive ? "border-white" : "border-transparent"
    
        }`
    
      }
    
    >
    
      Products
    
    </NavLink>
    

该函数接受一个参数 isActive,用于定义链接的页面是否处于活动状态。如果链接处于活动状态,我们已为其添加了底部边框。

我们目前还看不到这个更改的影响,因为 产品 链接还没有出现在产品列表页面上。我们将在下一个部分解决这个问题。

这样就完成了应用头部以及我们对 NavLink 组件的探索。

总结一下,NavLink 在我们想要突出显示活动链接时非常适合用于主应用导航,而 Link 则非常适合我们应用中的其他所有链接。

有关 Link 组件的更多信息,请参阅以下链接:reactrouter.com/en/main/components/link。有关 NavLink 组件的更多信息,请参阅以下链接:reactrouter.com/en/main/components/nav-link

接下来,我们将学习嵌套路由。

使用嵌套路由

在本节中,我们将介绍 嵌套路由 以及它们有用的场景,然后在我们的应用中使用嵌套路由。嵌套路由还将解决我们在前几节中遇到的应用头部消失的问题。

理解嵌套路由

嵌套路由允许路由的一部分渲染组件。例如,以下模拟通常使用嵌套路由实现:

图 6.6 – 嵌套路由的使用案例

图 6.6 – 嵌套路由的使用案例

模拟显示有关客户的信息。路径确定活动标签页 – 在模拟中,/customers/1234/history

一个Customer组件可以渲染这个屏幕的壳体,包括客户的姓名、图片和标签页标题。渲染标签页内容的组件可以与Customer组件解耦,并与路径耦合。

这个特性被称为嵌套路由,因为Route组件嵌套在彼此内部。以下是模拟路由的示例:

const router = createBrowserRouter([
  {
    path: 'customer/:id',
    element: <Customer />,
    children: [
      {
        path: 'profile',
        element: <CustomerProfile />,
      },
      {
        path: 'history',
        element: <CustomerHistory />,
      },
      {
        path: 'tasks',
        element: <CustomerTasks />,
      },
    ],
  },
]);

这种定义路由的嵌套方法使得它们易于阅读和理解,正如您在前面的代码片段中所看到的。

嵌套路由的一个关键部分是子组件在父组件中的渲染位置。在前面的代码片段中,CustomerProfile组件将在Customer组件中渲染在哪里?解决方案是 React Router 的Outlet组件。以下是从模拟中Customer组件的Outlet组件示例:

export function Customer() {
  ...
  return (
    <div>
      <Name ... />
      <Picture ... />
      <nav>
        <NavLink to="profile" ... >Profile</NavLink>
        <NavLink to="history" ... >History</NavLink>
        <NavLink to="tasks" ... >Tasks</NavLink>
      </nav>
      <Outlet />
    </div>
  );
}

因此,在这个例子中,CustomerProfile组件将在Customer组件的导航选项之后渲染。请注意,Customer组件与嵌套内容解耦。这意味着可以在不更改Customer组件的情况下向客户页面添加新标签页。这是嵌套路由的另一个好处。

接下来,我们将在我们的应用中使用嵌套路由。

在应用中使用嵌套路由

在我们的应用中,我们将使用App组件作为应用的壳体,它渲染根路径。然后我们将产品列表页面嵌套在这个组件中:

  1. 打开App.tsx,将所有现有内容替换为以下内容:

    import { Outlet } from 'react-router-dom';
    
    import { Header } from './Header';
    
    export default function App() {
    
      return (
    
        <>
    
          <Header />
    
          <Outlet />
    
        </>
    
      );
    
    }
    

该组件渲染应用头部,并在其下方渲染嵌套内容。

注意

空的 JSX 元素<></>React 片段。React 片段不会添加到 DOM 中,并且作为 React 组件只能返回单个元素的解决方案,因此它们是在 React 组件中返回多个元素的一种方式,同时保持 React 的愉悦。

  1. 打开Routes.tsx,导入我们刚刚修改的App组件,并移除对Header组件的import

    import {
    
      createBrowserRouter,
    
      RouterProvider,
    
    } from 'react-router-dom';
    
    import { ProductsPage } from './pages/ProductsPage5';
    
    import App from './App';
    
  2. 更新router定义如下:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <App />,
    
        children: [
    
          {
    
            path: 'products',
    
            element: <ProductsPage />,
    
          }
    
        ]
    
      }
    
    ]);
    

产品列表页面现在嵌套在App组件内部。

  1. 如果您回到运行中的应用,您将看到应用头部现在出现在产品列表页面上。您还会看到下划线的产品链接,因为它是一个活动链接:

图 6.7 – 产品列表页面的应用头部

图 6.7 – 产品列表页面的应用头部

总结一下,嵌套路由允许为不同的路径段渲染组件。Outlet组件用于在父组件内渲染嵌套内容。

更多关于Outlet组件的信息,请参阅以下链接:reactrouter.com/en/main/components/outlet

接下来,我们将学习路由参数。

使用路由参数

在本节中,我们将了解路由参数及其在应用中使用路由参数之前如何有用。

理解路由参数

路由参数是路径中的一个可变段。变量段的值对组件可用,以便它们可以条件性地渲染某些内容。

在以下路径中,1234 是一个客户的 ID:/customers/1234/

这可以如下定义为一个路由参数:

{ path: '/customer/:id', element: <Customer /> }

一个冒号 (:) 后跟一个名称定义了一个路由参数。选择一个有意义的参数名称取决于我们,所以路径中的 :id 段是前面路由中的路由参数定义。

可以在路径中使用多个路由参数,如下所示:

{
  path: '/customer/:customerId/tasks/:taskId',
  element: <CustomerTask />,
}

路由参数名称显然必须在路径中是唯一的。

路由参数通过 React Router 的 useParams 钩子对组件可用。以下代码片段是一个示例,说明了如何获取 customerIdtaskId 路由参数的值:

const params = useParams<Params>();
console.log('Customer id', params.customerId);
console.log('Task id', params.taskId);

从代码片段中我们可以看到,useParams 有一个泛型参数,它定义了参数的类型。前面代码片段的 type 定义如下:

type Params = {
  customerId: string;
  taskId: string;
};

需要注意的是,路由参数的值始终是字符串,因为它们是从路径中提取的,而路径是字符串。

现在我们已经了解了路由参数,我们将在我们的应用中使用一个路由参数。

在应用中使用路由参数

我们将在我们的应用中添加一个产品页面来显示每个产品的描述和价格。页面的路径将包含一个用于产品 ID 的路由参数。执行以下步骤以实现产品页面:

  1. 我们将首先创建产品页面。在 src/pages 文件夹中,创建一个名为 ProductPage.tsx 的文件,并包含以下 import 语句:

    import { useParams } from 'react-router-dom';
    
    import { products } from '../data/products';
    

我们已从 React Router 中导入了 useParams 钩子,这将允许我们获取 id 路由参数的值——即产品的 ID。我们还导入了 products 数组。

  1. 按照以下方式开始创建 ProductPage 组件:

    type Params = {
    
      id: string;
    
    };
    
    export function ProductPage() {
    
      const params = useParams<Params>();
    
      const id =
    
        params.id === undefined ? undefined :       parseInt(params.id);
    
    }
    

我们使用 useParams 钩子获取 id 路由参数,如果它有值,则将其转换为整数。

  1. 现在,添加一个变量,将其分配给具有路由参数中 ID 的产品:

    export function ProductPage() {
    
      const params = useParams<Params>();
    
      const id =
    
        params.id === undefined ? undefined :       parseInt(params.id);
    
      const product = products.find(
    
        (product) => product.id === id
    
      );
    
    }
    
  2. 在 JSX 中从 product 变量返回产品信息:

    export function ProductPage() {
    
      ...
    
      return (
    
        <div className="text-center p-5 text-xl">
    
          {product === undefined ? (
    
            <h1 className="text-xl text-slate-900">
    
              Unknown product
    
            </h1>
    
          ) : (
    
            <>
    
              <h1 className="text-xl text-slate-900">
    
                {product.name}
    
              </h1>
    
              <p className="text-base text-slate-800">
    
                {product.description}
    
              </p>
    
              <p className="text-base text-slate-800">
    
                {new Intl.NumberFormat('en-US', {
    
                  currency: 'USD',
    
                  style: 'currency',
    
                }).format(product.price)}
    
              </p>
    
            </>
    
          )}
    
        </div>
    
      );
    
    }
    

如果找不到产品,将返回 Unknown product。如果找到产品,将返回其名称、描述和价格。我们使用 JavaScript 的 Intl.NumberFormat 函数来格式化价格。

这样就完成了产品页面的创建。

  1. 下一个任务是添加产品页面的路由。打开 Routes.tsx 并为产品页面添加一个 import 语句:

    import { ProductPage } from './pages/ProductPage';
    
  2. 为产品页面添加以下突出显示的路由:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <App />,
    
        children: [
    
          {
    
            path: 'products',
    
            element: <ProductsPage />,
    
          },
    
          {
    
            path: 'products/:id',
    
            element: <ProductPage />,
    
          }
    
        ]
    
      }
    
    ]);
    

因此,/products/2 路径应该返回一个 React Redux 的产品页面。

  1. 在运行的应用中,将浏览器 URL 更改为 localhost:3000/products/2。React Redux 产品应该显示出来:

图 6.8 – 产品页面

图 6.8 – 产品页面

  1. 本节的最后一个任务是将在产品列表页面上的产品列表转换为打开相关产品页面的链接。打开 ProductsPage.tsx 并从 React Router 中导入 Link 组件:

    import { Link } from 'react-router-dom';
    
  2. 在 JSX 中的产品名称周围添加一个 Link 组件:

    <ul className="list-none m-0 p-0">
    
      {products.map((product) => (
    
        <li key={product.id}>
    
          <Link
    
            to={`${product.id}`}
    
            className="p-1 text-base text-slate-800           hover:underline"
    
          >
    
            {product.name}
    
          </Link>
    
        </li>
    
      ))}
    
    </ul>
    

链接路径相对于组件的路径。鉴于组件路径是 /products,我们将链接路径设置为产品 ID,它应该与 product 路由匹配。

  1. 返回正在运行的应用程序并转到产品列表页面。将鼠标悬停在产品上,你现在会看到它们是链接:

图 6.9 – 产品列表链接

图 6.9 – 产品列表链接

  1. 点击其中一个产品,将显示相关产品页面。

这完成了关于路由参数的这一部分。以下是一个快速回顾:

  • 路由参数是在路径中定义的可变段,使用冒号后跟参数名称表示

  • 可以使用 React Router 的 useParams 钩子访问路由参数

关于 useParams 钩子的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/hooks/use-params

记得我们在声明路由部分遇到的 React Router 的错误页面吗?接下来,我们将学习如何自定义该错误页面。

创建错误页面

在本节中,我们将了解 React Router 中的错误页面是如何工作的,然后再在我们的应用程序中实现一个。

理解错误页面

目前,当发生错误时,会显示一个 React Router 内置的错误页面。我们可以通过在运行的应用程序中输入一个无效路径来检查这一点:

图 6.10 – 标准的 React Router 错误页面

图 6.10 – 标准的 React Router 错误页面

由于在路由器中找不到匹配的路由,因此引发了一个错误。错误页面上的 404 未找到 消息证实了这一点。

这个标准的错误页面并不理想,因为信息是针对开发者而不是真实用户。此外,应用头部没有显示,因此用户无法轻松导航到确实存在的页面。

正如错误消息所暗示的,可以在路由上使用 errorElement 属性来覆盖标准错误页面。以下是一个为客户的路由定义的自定义错误页面的示例;如果此路由上发生任何错误,将渲染 CustomersErrorPage 组件:

const router = createBrowserRouter([
  ...,
  {
    path: 'customers',
    element: <CustomersPage />,
    errorElement: <CustomersErrorPage />
  },
  ...
]);

现在我们已经开始了解 React Router 中的错误页面,我们将在我们的应用程序中实现一个。

添加错误页面

执行以下步骤以在应用程序中创建一个错误页面:

  1. 首先,在 src/pages 文件夹中创建一个名为 ErrorPage.tsx 的新页面,内容如下:

    import { Header } from '../Header';
    
    export function ErrorPage() {
    
      return (
    
        <>
    
          <Header />
    
          <div className="text-center p-5 text-xl">
    
            <h1 className="text-xl text-slate-900">
    
              Sorry, an error has occurred
    
            </h1>
    
          </div>
    
        </>
    
      );
    
    }
    

该组件简单地返回应用头部,并在下面显示一个 抱歉,发生了错误 的消息。

  1. 打开 Routes.tsx 并为错误页面添加一个 import 语句:

    import { ErrorPage } from './pages/ErrorPage';
    
  2. 按如下方式在根路由上指定错误页面:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <App />,
    
        errorElement: <ErrorPage />,
    
        children: ...
    
      },
    
    ]);
    

在根路由上指定错误页面意味着如果有任何路由有错误,它将会显示。

  1. 切换回运行中的应用,并将浏览器 URL 更改为 localhost:3000/invalid。将显示错误页面:

图 6.11 – 错误页面

图 6.11 – 错误页面

  1. 这是一个好的开始,但我们可以通过提供用户更多从 React Router 的 useRouteError 钩子中获取的信息来改进它。再次打开 ErrorPage.tsx 并添加 useRouteErrorimport 语句:

    import { useRouteError } from 'react-router-dom';
    
  2. 在组件的 return 语句之前使用 useRouteError 将错误分配给 error 变量:

    export function ErrorPage() {
    
      const error = useRouteError();
    
      return ...
    
    }
    
  3. error 变量是 unknown 类型 – 你可以通过悬停在其上验证这一点。我们可以使用类型谓词函数来允许 TypeScript 将其缩小到我们可以处理的内容。在组件下方添加以下类型谓词函数:

    function isError(error: any): error is { statusText: string } {
    
      return "statusText" in error;
    
    }
    

该函数检查错误对象是否有 statusText 属性,如果有,则给它赋予具有此属性的类型。

  1. 我们现在可以使用这个函数来渲染 statusText 属性中的信息:

    return (
    
      <>
    
        <Header />
    
        <div className="text-center p-5 text-xl">
    
          <h1 className="text-xl text-slate-900">
    
            Sorry, an error has occurred
    
          </h1>
    
          {isError(error) && (
    
            <p className="text-base text-slate-700">
    
              {error.statusText}
    
            </p>
    
          )}
    
        </div>
    
      </>
    
    );
    
  2. 在运行中的应用中,错误信息以无效路径的形式显示在错误页面上:

图 6.12 – 包含错误信息的错误页面

图 6.12 – 包含错误信息的错误页面

这就完成了关于错误页面的本节内容。关键点是使用路由上的 errorElement 属性来捕获和显示错误。可以通过 useRouteError 钩子获取特定的错误信息。

更多关于 errorElement 的信息,请参阅以下链接:reactrouter.com/en/main/route/error-element。更多关于 useRouteError 钩子的信息,请参阅以下链接:reactrouter.com/en/main/hooks/use-route-error

接下来,我们将学习关于索引路由的内容。

使用索引路由

目前,应用的根路径除了标题外不显示任何内容。在本节中,我们将学习索引路由,以便在根路径上显示一个友好的欢迎信息。

理解索引路由

一个 index 布尔属性,如下例所示:

{
  path: "/",
  element: <App />,
  children: [
    {
      index: true,
      element: <HomePage />,
    },
    ...,
  ]
}

接下来,我们将使用索引路由在我们的应用中添加一个首页。

在应用中使用索引路由

执行以下步骤,在我们的应用中使用索引路由添加一个首页:

  1. src/pages 文件夹中创建一个名为 HomePage.tsx 的新文件,内容如下:

    export function HomePage() {
    
      return (
    
        <div className="text-center p-5 text-xl">
    
          <h1 className="text-xl text-slate-900">Welcome to         React Tools!</h1>
    
        </div>
    
      );
    
    }
    

页面显示一个欢迎信息。

  1. 打开 Routes.tsx 并导入我们刚刚创建的首页:

    import { HomePage } from './pages/HomePage';
    
  2. 按如下方式将首页作为根路径的索引页面添加:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <App />,
    
        errorElement: <ErrorPage />,
    
        children: [
    
          {
    
            index: true,
    
            element: <HomePage />,
    
          },
    
          ...
    
        ]
    
      }
    
    ]);
    
  3. 我们将在标题中添加到标志和应用程序标题的链接,以便跳转到首页。打开 Header.tsx 并从 React Router 导入 Link 组件:

    import { NavLink, Link } from 'react-router-dom';
    
  4. 按如下方式将根页面的链接包裹在标志和标题周围:

    <header ...>
    
      <Link to="">
    
        <img src={logo} ... />
    
      </Link>
    
      <Link to="">
    
        <h1 ...>React Tools</h1>
    
      </Link>
    
      <nav>
    
        ...
    
      </nav>
    
    </header>
    
  5. 在运行的应用程序中,点击应用程序标题将转到根页面,您将看到显示的欢迎信息:

图 6.13 – 欢迎页面

图 6.13 – 欢迎页面

这完成了关于索引路由本节的介绍。

回顾一下,索引路由是一个默认子路由,它使用一个 index 布尔属性定义。

更多关于索引路由的信息,请参阅以下链接:reactrouter.com/en/main/route/route#index

接下来,我们将学习搜索参数。

使用搜索参数

在本节中,我们将学习 React Router 中的搜索参数,并使用它们在应用程序中实现搜索功能。

理解搜索参数

? 字符和 & 字符分隔。搜索参数有时被称为 typewhen,它们是搜索参数:https://somewhere.com/?type=sometype&when=recent

React Router 有一个钩子,它返回用于获取和设置搜索参数的函数,称为 useSearchParams

const [searchParams, setSearchParams] = useSearchParams();

searchParams 是一个 JavaScript 的 URLSearchParams 对象。在 URLSearchParams 上有一个 get 方法,可以用来获取搜索参数的值。以下示例获取了一个名为 type 的搜索参数的值:

const type = searchParams.get('type');

setSearchParams 是一个用于设置搜索参数值的函数。函数参数是一个对象,如下例所示:

setSearchParams({ type: 'sometype', when: 'recent' });

接下来,我们将向我们的应用程序添加搜索功能。

向应用程序添加搜索功能

我们将在应用程序的页眉中添加一个搜索框。提交搜索将用户带到产品列表页面,并列出符合搜索条件的产品集合。执行以下步骤:

  1. 打开 Header.tsx 文件,并将 useSearchParams 添加到 React Router 的导入中。同时,添加一个从 React 导入 FormEvent 类型的 import 语句:

    import { FormEvent } from 'react';
    
    import {
    
      NavLink,
    
      Link,
    
      useSearchParams
    
    } from 'react-router-dom';
    
  2. 使用 useSearchParams 钩子解构函数以在 return 语句之前获取和设置搜索参数:

    export function Header() {
    
      const [searchParams, setSearchParams] = useSearchParams();
    
      return ...
    
    }
    
  3. 在标志上方添加以下搜索表单:

    <header ...>
    
      <form
    
        className="relative text-right"
    
        onSubmit={handleSearchSubmit}
    
      >
    
        <input
    
          type="search"
    
          name="search"
    
          placeholder="Search"
    
          defaultValue={searchParams.get('search') ?? ''}
    
          className="absolute right-0 top-0 rounded py-2 px-3         text-gray-700"
    
        />
    
      </form>
    
      <Link to="">
    
        <img src={logo} ... />
    
      </Link>
    
      ...
    
    </header>
    

表单包含一个搜索框,其默认值是 search 参数的值。searchParams.get 如果参数不存在,则返回 null,因此在这种情况下使用 ?? 将搜索框的默认值设置为空字符串。

注意

?? 运算符如果左操作数是 nullundefined,则返回右操作数;否则,返回左操作数。更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing

表单提交会调用一个 handleSearchSubmit 函数,我们将在下一步实现它。

  1. return 语句上方添加一个 handleSearchSubmit 函数,如下所示:

    export function Header() {
    
      const [searchParams, setSearchParams] =     useSearchParams();
    
      function handleSearchSubmit(e:     FormEvent<HTMLFormElement>) {
    
        e.preventDefault();
    
        const formData = new FormData(e.currentTarget);
    
        const search = formData.get('search') as string;
    
        setSearchParams({ search });
    
      }
    
      return ...
    
    }
    

提交处理程序参数使用 FormEvent 类型进行类型化。FormEvent 是一个泛型类型,它接受元素的类型,对于表单提交处理程序,这个类型是 HTMLFormElement

我们在提交处理器的参数上使用 preventDefault 方法来防止表单被提交到服务器,因为我们在这个函数中处理所有逻辑。

我们使用 JavaScript FormData 接口获取搜索字段的值。然后,我们使用类型断言将搜索字段值的类型设置为字符串。

提交处理器的最后一行代码设置了搜索参数的值。这将更新浏览器的 URL 以包含此搜索参数。

注意

我们将在 第七章 与表单一起工作 中学习更多关于 React 中表单的知识。

  1. 现在,我们需要根据搜索参数的值过滤产品列表。打开 ProductsPage.tsx 并将 useSearchParams 添加到 import 语句中:

    import { Link, useSearchParams } from 'react-router-dom';
    
  2. ProductsPage 组件的顶部,按照以下方式从 useSearchParams 中解构 searchParams

    export function ProductsPage() {
    
      const [searchParams] = useSearchParams();
    
      return ...
    
    }
    
  3. return 语句之前添加以下函数以通过搜索值过滤产品列表:

    const [searchParams] = useSearchParams();
    
    function getFilteredProducts() {
    
      const search = searchParams.get('search');
    
      if (search === null || search === "") {
    
        return products;
    
      } else {
    
        return products.filter(
    
          (product) =>
    
            product.name
    
              .toLowerCase()
    
              .indexOf(search.toLowerCase()) > -1
    
        );
    
      }
    
    }
    
    return ...
    

函数首先获取 search 参数的值。如果没有搜索参数或值为空字符串,则返回完整的产品列表。否则,使用数组的 filter 函数过滤产品列表,检查搜索值是否包含在产品名称中,不考虑大小写。

  1. 在 JSX 中使用我们刚刚创建的函数来输出过滤后的产品。将 products 的引用替换为对 getFilteredProducts 的调用,如下所示:

    <ul className="list-none m-0 p-0">
    
      {getFilteredProducts().map((product) => (
    
        <li
    
          key={product.id}
    
          className="p-1 text-base text-slate-800"
    
        >
    
          <Link
    
            to={`${product.id}`}
    
            className="p-1 text-base text-slate-800           hover:underline"
    
          >
    
            {product.name}
    
          </Link>
    
        </li>
    
      ))}
    
    </ul>
    
  2. 在运行中的应用程序中,当在主页上时,在搜索框中输入一些搜索条件,然后按 Enter 键提交搜索。

搜索参数已添加到浏览器中的 URL。然而,它并没有导航到产品列表页面。不用担心这个问题,因为我们在下一节中会解决这个问题:

图 6.14 – 添加到 URL 中的搜索参数

图 6.14 – 添加到 URL 中的搜索参数

本节的关键点是,React Router 的 useSearchParams 钩子允许你设置和获取 URL 搜索参数。这些参数也以 JavaScript URLSearchParams 对象的结构进行组织。

关于 useSearchParams 钩子的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/hooks/use-search-params。有关 URLSearchParams 的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/API/URLSearchParams

接下来,我们将探索另一个允许程序化导航的 React Router 钩子。

程序化导航

React Router 的 LinkNavLink 组件允许声明式导航。然而,有时我们必须强制导航 – 实际上,这对于我们应用程序中的搜索功能导航到产品列表页面非常有用。在本节中,我们将学习如何使用 React Router 进行编程式导航,并使用它来完成应用程序的搜索功能。执行以下步骤:

  1. 打开 Header.tsx 并从 React Router 中导入 useNavigate 钩子:

    import {
    
      NavLink,
    
      Link,
    
      useSearchParams,
    
      useNavigate
    
    } from 'react-router-dom';
    

useNavigate 钩子返回一个我们可以用来执行编程式导航的函数。

  1. 在调用 useSearchParams 钩子之后调用 useNavigate。将结果分配给名为 navigate 的变量:

    export function Header() {
    
      const [searchParams, setSearchParams] =     useSearchParams();
    
      const navigate = useNavigate();
    
      ...
    
    }
    

navigate 变量是一个可以用于导航的函数。它接受一个参数,用于指定要导航到的路径。

  1. handleSearchSubmit 中,将 setSearchParams 调用替换为 navigate 调用,以便使用相关搜索参数跳转到产品列表页面:

    function handleSearchSubmit(e: FormEvent<HTMLFormElement>) {
    
      e.preventDefault();
    
      const formData = new FormData(e.currentTarget);
    
      const search = formData.get('search') as string;
    
      navigate(`/products/?search=${search}`);
    
    }
    
  2. 我们不再需要 setSearchParams,因为搜索参数的设置已包含在导航路径中,因此从 useSearchParams 调用中删除此部分:

    const [searchParams] = useSearchParams();
    
  3. 在运行的应用程序中,在搜索框中输入一些搜索条件,然后按 Enter 键提交搜索。

搜索参数用于跳转到产品列表页面。当产品列表页面出现时,将显示正确筛选的产品:

图 6.15 – 带筛选产品的产品列表页面

图 6.15 – 带筛选产品的产品列表页面

因此,编程式导航是通过使用 useNavigate 钩子实现的。这返回一个函数,可以导航到传递给它的路径。

关于 useNavigate 钩子的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/hooks/use-navigate

接下来,我们将重构搜索表单的导航,以使用 React Router 的 Form 组件。

使用表单导航

在本节中,我们将使用 React Router 的 Form 组件在提交搜索条件时导航到产品列表页面。Form 是 HTML form 元素的包装器,它处理客户端的表单提交。这将取代 useNavigate 的使用并简化代码。执行以下步骤:

  1. Header.tsx 中,首先从 React Router 的 import 中删除 useNavigate,并用 Form 组件替换它:

    import {
    
      NavLink,
    
      Link,
    
      useSearchParams,
    
      Form
    
    } from 'react-router-dom';
    
  2. 在 JSX 中,将 form 元素替换为 React Router 的 Form 组件:

    <Form
    
      className="relative text-right"
    
      onSubmit={handleSearchSubmit}
    
    >
    
      <input ... />
    
    </Form>
    
  3. 在 JSX 中的 Form 元素中,删除 onSubmit 处理程序。用以下 action 属性替换,以便将表单发送到 products 路由:

    <Form
    
      className="relative text-right"
    
      action="/products"
    
    >
    
      ...
    
    </Form>
    

React Router 的表单提交模仿了原生 form 元素提交到服务器路径的方式。然而,React Router 将表单提交到客户端路由。此外,Form 默认模仿 HTTP GET 请求,因此 URL 将自动添加 search 参数。

  1. 剩余的任务是删除以下代码:

    • 删除 React 导入语句,因为FormEvent现在是多余的

    • 删除对useNavigate的调用,因为现在不再需要它了

    • 删除handleSearchSubmit函数,因为现在不再需要它了

  2. 在运行中的应用中,在搜索框中输入一些搜索条件,然后按Enter键提交搜索。这将像之前一样工作。

这大大简化了代码!

我们将在第七章第九章中学习更多关于 React Router 的Form组件。本节的关键要点是Form包装了 HTML 的form元素,并在客户端处理表单提交。

关于Form的更多信息,请参阅 React Router 文档中的以下链接:reactrouter.com/en/main/components/form

接下来,我们将学习一种可以应用于应用中大型页面的性能优化类型。

实现懒加载

目前,我们应用中的所有 JavaScript 代码都是在应用首次加载时一起加载的。这在大型应用中可能会出现问题。在本节中,我们将学习如何在组件的路由变为活动状态时才加载其 JavaScript 代码。这种模式通常被称为懒加载。在我们的应用中,我们将创建一个懒加载的管理员页面。

理解 React 懒加载

默认情况下,所有 React 组件都会被打包在一起,并在应用首次加载时一起加载。这对大型应用来说效率不高——尤其是当用户不使用很多组件时。懒加载 React 组件解决了这个问题,因为懒加载组件不包括在初始加载的包中;相反,它们的 JavaScript 代码在渲染时才会被获取和加载。

懒加载 React 组件主要有两个步骤。首先,组件必须按照以下方式动态导入:

const LazyPage = lazy(() => import('./LazyPage'));

在代码块中,lazy是 React 中的一个函数,它使得导入的组件可以懒加载。请注意,懒加载的页面必须是默认导出——懒加载不适用于命名导出。

Webpack 可以将LazyPage的 JavaScript 代码分割成单独的包。请注意,这个单独的包将包括LazyPage的任何子组件。

第二步是在 React 的Suspense组件内部按照以下方式渲染懒加载组件:

<Route
  path="lazy"
  element={
    <Suspense fallback={<div>Loading…</div>}>
      <LazyPage />
    </Suspense>
  }
/>

Suspense组件的fallback属性可以被设置为在懒加载页面正在获取时渲染的元素。

接下来,我们将在我们的应用中创建一个懒加载管理员页面。

将懒加载管理员页面添加到应用中

执行以下步骤以将懒加载管理员页面添加到我们的应用中:

  1. src/pages文件夹中创建一个名为AdminPage.tsx的文件,并包含以下内容:

    export default function AdminPage() {
    
      return (
    
        <div classNa"e="text-center p-5 text"xl">
    
          <h1 classNa"e="text-xl text-slate-"00">Admin         Panel</h1>
    
          <p classNa"e="text-base text-slate-"00">
    
            You shou'dn't come here often becaus' I'm lazy
    
          </p>
    
        </div>
    
      );
    
    }
    

该页面非常小,因此它不是懒加载的一个很好的用例。然而,它的简单性将使我们能够专注于如何实现懒加载。

  1. 打开Routes.tsx并从 React 中导入lazySuspense

    import { lazy, Suspense } fr'm 're'ct';
    
  2. 按如下方式导入管理员页面(这一点很重要,即它必须出现在所有其他import语句之后):

    const AdminPage = lazy(() => impo't('./pages/AdminP'ge'));
    
  3. 按如下方式添加admin路由:

    const router = createBrowserRouter([
    
      {
    
        pat':''/',
    
        element: <App />,
    
        errorElement: <ErrorPage />,
    
        children: [
    
          ...,
    
          {
    
            pat': 'ad'in',
    
            element: (
    
              <Suspense
    
                fallback={
    
                  <div classNa"e="text-center p-5 text-xl                 text-slate-"00">
    
                    Loading...
    
                  </div>
    
                }
    
              >
    
                <AdminPage />
    
              </Suspense>
    
            )
    
          }
    
        ]
    
      }
    
    ]);
    

管理员页面的路径是/admin。当管理员页面的 JavaScript 被获取时,将渲染一个加载指示器。

  1. 打开Header.tsx并在Products链接之后添加管理员页面的链接,如下所示:

    <nav>
    
      <NavLink ... >
    
        Products
    
      </NavLink>
    
      <NavLink
    
        "o="ad"in"
    
        className={({ isActive }) =>
    
          `text-white no-underline p-1 pb-0.5 border-solid          border-b-2 ${
    
            isActive"? "border-wh"te"": "border-transpar"nt"
    
          }`
    
        }
    
      >
    
        Admin
    
      </NavLink>
    
    </nav>
    
  2. 在跑步应用中,打开浏览器开发者工具并转到网络标签,清除任何现有请求。通过从 限制菜单中选择慢 3G来降低连接速度:

图 6.16 – 设置慢速连接

图 6.16 – 设置慢速连接

  1. 现在,点击页眉中的管理员链接。加载指示器出现,因为管理员页面的 JavaScript 正在下载:

图 6.17 – 加载指示器

图 6.17 – 加载指示器

在管理员页面下载完成后,它将在浏览器中渲染。如果你查看 DevTools 中的网络标签,你会看到管理员页面包正在懒加载的确认信息:

图 6.18 – 管理员页面下载

图 6.18 – 管理员页面下载

这完成了关于懒加载 React 组件的部分。总之,通过动态导入组件文件并在Suspense组件内渲染组件来实现 React 组件的懒加载。

关于懒加载 React 组件的更多信息,请参阅 React 文档中的以下链接:reactjs.org/docs/code-splitting.html

这也完成了本章的内容。接下来,我们将回顾我们关于 React Router 所学的知识。

摘要

React Router 为我们提供了一套全面的组件和钩子,用于管理我们应用中页面之间的导航。我们使用createBrowserRouter来定义我们所有 Web 应用的路线。一个路由包含一个路径和一个组件,当路径与浏览器 URL 匹配时,将渲染该组件。我们使用errorElement属性为路由渲染一个自定义错误页面。

我们使用嵌套路由允许App组件渲染应用外壳和其中的页面组件。我们在App组件内部使用 React Router 的Outlet组件来渲染页面内容。我们还使用根路由上的索引路由来渲染欢迎信息。

我们使用 React Router 的NavLink组件来渲染导航链接,当它们的路由处于活动状态时会被突出显示。Link组件非常适合具有静态样式要求的其他链接 – 我们将其用于产品列表上的产品链接。我们使用 React Router 的Form组件在提交搜索表单时导航到产品列表页面。

路由参数和查询参数允许将参数传递到组件中,以便它们可以渲染动态内容。useParams提供了访问路由参数的权限,而useSearchParams提供了访问查询参数的权限。

React 组件可以懒加载以提高启动性能。这是通过动态导入组件文件并在 Suspense 组件内部渲染组件来实现的。

在下一章中,我们将学习所有关于 React 中的表单知识。

问题

让我们通过以下问题来测试我们对 React Router 的知识:

  1. 我们在应用中声明了以下路由:

    const router = createBrowserRouter([
    
      {
    
        path: "/",
    
        element: <App />,
    
        errorElement: <ErrorPage />,
    
        children: [
    
          { path: "customers", element: <CustomersPage /> }
    
        ]
    
      }
    
    ]);
    

当路径是 /customers 时,哪个组件将会渲染?

当路径是 /products 时,哪个组件将会渲染?

  1. 在一个可以处理 /customers/37 路径的路由中,路径会是什么?37 是一个客户 ID,可能会变化。

  2. 一个 settings 页面的路由如下定义:

    {
    
      path: "/settings",
    
      element: <SettingsPage />,
    
      children: [
    
        { path: "general", element: <GeneralSettingsTab /> },
    
        { path: "dangerous", element: <DangerousSettingsTab       /> }
    
      ]
    
    }
    

设置页面有 /settings/general/settings/dangerous,分别。然而,当请求这些路径时,设置页面没有显示任何标签内容——那么,我们在 SettingsPage 组件中可能遗漏了什么?

  1. 我们正在实现一个应用中的导航栏。当点击导航项时,应用应该导航到相关页面。我们应该使用哪个 React Router 组件来渲染导航项?Link 还是 NavLink

  2. 路由如下定义:

    { path: "/user/:userId", element: <UserPage /> }
    

UserPage 组件内部,以下代码用于从浏览器 URL 获取用户 id 信息:

const params = useParams<{id: string}>();
const id = params.id;

然而,id 总是 undefined。问题是什么?

  1. 以下 URL 包含一个在 customers 页面上的搜索参数示例:

/``customers/?search=cool company

然而,以下实现中出现了错误:

function getFilteredCustomers() {
  const criteria = useSearchParams.get('search');
  if (criteria === null || criteria === "") {
    return customers;
  } else {
    return customers.filter(
      (customer) =>
        customer.name.toLowerCase().indexOf(criteria.          toLowerCase()) > -1
    );
  }
}

问题是什么?

  1. 一个 React 组件如下所示进行懒加载:

    const SpecialPage = lazy(() => import('./pages/SpecialPage'));
    
    const router = createBrowserRouter([
    
      ...,
    
      {
    
        path: '/special',
    
        element: <SpecialPage />,
    
      },
    
      ...
    
    ]);
    

然而,React 抛出了一个错误。问题是什么?

答案

  1. 当路径是 /customers 时,CustomersPage 将会渲染。

当路径是 /products 时,ErrorPage 将会渲染。

  1. 路径可以是 path="customers/:customerId"

  2. 很可能是因为 Outlet 组件没有被添加到 SettingsPage 中。

  3. 这两个都可以工作,但 NavLink 更好,因为它允许在活动状态下对项目进行样式化。

  4. 引用的路由参数应该是 userId

    const params = useParams<{userId: string}>();
    
    const id = params.userId;
    
  5. 钩子必须在函数组件的最顶层调用。此外,useSearchParams 钩子没有直接的 get 方法。以下是修正后的代码:

    const [searchParams] = useSearchParams();
    
    function getFilteredCustomers() {
    
      const criteria = searchParams.get('search');
    
      ...
    
    }
    
  6. 懒加载的组件必须嵌套在 Suspense 组件内部,如下所示:

    {
    
      path: '/special',
    
      element: (
    
        <Suspense fallback={<Loading />}>
    
          <SpecialPage />
    
        </Suspense>
    
      )
    
    }
    

第七章:使用表单

表单在应用程序中非常常见,因此能够高效地实现它们至关重要。在某些应用程序中,表单可能很大且复杂,使它们表现良好是一项挑战。

在本章中,我们将学习如何使用不同的方法在 React 中构建表单。我们将制作的示例表单是您在公司网站上经常看到的联系表单。它将包含一些字段和一些验证逻辑。

构建表单的第一种方法是将字段值存储在状态中。我们将看到这种方法如何使代码膨胀并影响性能。接下来,我们将采用浏览器原生表单功能的方法,减少所需代码量并提高性能。然后我们将使用 React Router 的Form组件,这在第六章中简要介绍过,使用 React Router 进行路由。最后一种方法将是使用一个名为React Hook Form的流行库。我们将体验 React Hook Form 如何帮助我们实现表单验证和提交逻辑,同时保持出色的性能。

我们将涵盖以下主题:

  • 使用受控字段

  • 使用非受控字段

  • 使用 React Router Form

  • 使用原生验证

  • 使用 React Hook Form

技术要求

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

本章中的所有代码片段都可以在github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter7上找到。

使用受控字段

在本节中,我们将构建我们联系表单的第一个版本。它将包含用户姓名、电子邮件地址、联系原因以及用户可能希望添加的任何附加注释字段。

此方法将涉及使用受控字段,即字段值存储在状态中。我们将使用这种方法来实现表单 - 然而,在这样做的时候,我们将注意所需代码量及其对性能的负面影响;这将帮助您了解为什么其他方法更好。

要开始,首先,我们需要创建一个 React 和 TypeScript 项目,就像之前的章节一样。

创建项目

我们将使用 Visual Studio Code 和一个新的 Create React App 项目设置来开发表单。我们之前已经多次介绍过这一点,所以本章中不会介绍步骤 - 相反,请参阅第三章设置 React 和 TypeScript。创建一个名为您选择的联系表单的项目。

我们将使用 Tailwind CSS 来设置表单样式。我们之前在第五章,“前端样式化方法”中介绍了如何在 Create React App 中安装和配置 Tailwind,所以在你创建了 React 和 TypeScript 项目之后,安装并配置 Tailwind。

我们将使用 Tailwind 插件来帮助我们设置表单样式——它为字段元素提供了开箱即用的样式。按照以下步骤安装和配置此插件:

  1. 在终端中运行以下命令来安装此插件:

    npm i -D @tailwindcss/forms
    
  2. 打开 tailwind.config.js 来配置插件。将高亮代码添加到该文件中,以告诉 Tailwind 使用我们刚刚安装的表单插件:

    module.exports = {
    
      content: ['./src/**/*.{js,jsx,ts,tsx}'],
    
      theme: {
    
        extend: {},
    
      },
    
      plugins: [require('@tailwindcss/forms')],
    
    };
    

这完成了项目设置。接下来,我们将创建表单的第一个版本。

创建联系表单

现在,按照以下步骤创建联系表单的第一个版本:

  1. src 文件夹中创建一个名为 ContactPage.tsx 的文件,并包含以下 import 语句:

    import { useState, FormEvent } from 'react';
    

我们已经从 React 中导入了 useState 钩子和 FormEvent 类型,我们最终将在实现中使用它们。

  1. 在导入语句下添加以下 type 别名。此类型将代表所有字段值:

    type Contact = {
    
      name: string;
    
      email: string;
    
      reason: string;
    
      notes: string;
    
    };
    
  2. 添加以下 function 组件:

    export function ContactPage() {
    
      return (
    
        <div className="flex flex-col py-10 max-w-md       mx-auto">
    
          <h2 className="text-3xl font-bold underline         mb-3">Contact Us</h2>
    
          <p className="mb-3">
    
            If you enter your details we'll get back to you           as soon as we
    
            can.
    
          </p>
    
          <form></form>
    
        </div>
    
      );
    
    }
    

这将在页面上水平居中显示一个标题和一些说明。

  1. form 元素内部添加以下字段:

    <form ...>
    
      <div>
    
        <label htmlFor="name">Your name</label>
    
        <input type="text" id="name" />
    
      </div>
    
      <div>
    
        <label htmlFor="email">Your email address</label>
    
        <input type="email" id="email" />
    
      </div>
    
      <div>
    
        <label htmlFor="reason">Reason you need to contact       us</label>
    
        <select id="reason">
    
          <option value=""></option>
    
          <option value="Support">Support</option>
    
          <option value="Feedback">Feedback</option>
    
          <option value="Other">Other</option>
    
        </select>
    
      </div>
    
      <div>
    
        <label htmlFor="notes">Additional notes</label>
    
        <textarea id="notes" />
    
      </div>
    
    </form>
    

我们添加了用户姓名、电子邮件地址、联系原因和附加注释的字段。每个字段标签都与它的编辑器相关联,通过将 htmlFor 属性设置为编辑器的 id 值来实现。这有助于辅助技术,如屏幕阅读器,在字段获得焦点时读出标签。

  1. 按以下方式在 form 元素的底部添加一个 submit 按钮:

    <form ...>
    
      ...
    
      <div>
    
        <button
    
          type="submit"
    
          className="mt-2 h-10 px-6 font-semibold bg-black         text-white"
    
        >
    
          Submit
    
        </button>
    
      </div>
    
    </form>
    
  2. 字段容器都将具有相同的样式,因此创建一个变量来存储样式,并将其分配给所有字段容器,如下所示:

    const fieldStyle = "flex flex-col mb-2";
    
    return (
    
      <div ...>
    
        ...
    
        <form ...>
    
          <div className={fieldStyle}>...</div>
    
          <div className={fieldStyle}>...</div>
    
          <div className={fieldStyle}>...</div>
    
          <div className={fieldStyle}>...</div>
    
          <div>
    
            <button
    
              type="submit"
    
              className="mt-2 h-10 px-6 font-semibold             bg-black text-white"
    
            >
    
              Submit
    
            </button>
    
          </div>
    
        </form>
    
      </div>
    
    );
    

现在字段已经使用垂直流动的 flexbox 和每个字段下方的小边距进行了很好的样式化。

  1. 按以下方式添加用于存储字段值的 state

    export function ContactPage() {
    
      const [contact, setContact] = useState<Contact>({
    
        name: "",
    
        email: "",
    
        reason: "",
    
        notes: "",
    
      });
    
      const fieldStyle = ...;
    
      ...
    
    }
    

我们已经给状态赋予了之前创建的 Contact 类型,并将字段值初始化为空字符串。

  1. 按以下方式将状态绑定到 name 字段编辑器:

    <div className={fieldStyle}>
    
      <label htmlFor="name">Your name</label>
    
      <input
    
        type="text"
    
        id="name"
    
        value={contact.name}
    
        onChange={(e) =>
    
          setContact({ ...contact, name: e.target.value })
    
        }
    
      />
    
    </div>
    

value 设置为状态的当前值。当用户填写输入元素时,会触发 onChange,我们使用它来更新状态值。为了构建新的状态对象,我们克隆当前状态,并用 onChange 参数中的新值覆盖其 name 属性。

  1. 按照相同的方法将状态绑定到其他字段编辑器,如下所示:

    <div className={fieldStyle}>
    
      ...
    
      <input
    
        type="email"
    
        id="email"
    
        value={contact.email}
    
        onChange={(e) =>
    
          setContact({ ...contact, email: e.target.value })
    
        }
    
      />
    
    </div>
    
    <div className={fieldStyle}>
    
      ...
    
      <select
    
        id="reason"
    
        value={contact.reason}
    
        onChange={(e) =>
    
          setContact({ ...contact, reason: e.target.value })
    
        }
    
      >
    
        ...
    
      </select>
    
    </div>
    
    <div className={fieldStyle}>
    
      ...
    
      <textarea
    
        id="notes"
    
        value={contact.notes}
    
        onChange={(e) =>
    
          setContact({ ...contact, notes: e.target.value })
    
        }
    
      />
    
    </div>
    
  2. 按以下方式向 form 元素添加提交处理程序:

    function handleSubmit(e: FormEvent<HTMLFormElement>) {
    
      e.preventDefault();
    
      console.log('Submitted details:', contact);
    
    }
    
    const fieldStyle = ...;
    
    return (
    
      <div>
    
        ...
    
        <form onSubmit={handleSubmit}>
    
          ...
    
        </form>
    
      </div>
    
    );
    

提交处理程序参数使用 React 的 FormEvent 类型。提交处理程序函数通过在处理程序参数上使用 preventDefault 方法来阻止表单被发送到服务器。而不是将表单发送到服务器,我们将 contact 状态输出到控制台。

  1. 最后一步是在 App 组件中渲染 ContactPage。打开 App.tsx 并将其内容替换为以下内容:

    import { ContactPage } from './ContactPage';
    
    function App() {
    
      return <ContactPage />;
    
    }
    
    export default App;
    

App 组件简单地渲染我们刚刚创建的 ContactPage 页面组件。App 组件也保持为默认导出,这样 index.tsx 就不会被破坏。

这样就完成了表单的第一轮迭代。我们现在将使用表单并发现一个潜在的性能问题。执行以下步骤:

  1. 通过在终端中执行 npm start 来以开发模式运行应用。表单显示如下:

图 7.1 – 联系表单

图 7.1 – 联系表单

  1. 我们将使用 React DevTools 突出显示组件重新渲染,这将突出显示一个潜在的性能问题。

打开浏览器 DevTools 并选择组件面板。如果没有组件面板,请确保浏览器中已安装 React DevTools(参见第三章了解如何安装 React DevTools)。

点击设置齿轮图标以查看 React DevTools 设置,并勾选在组件渲染时突出显示更新选项。此选项将在页面中重新渲染的组件周围显示蓝色绿色轮廓。

  1. 填写表单并注意每次在字段中输入字符时,表单周围都会出现蓝色绿色轮廓:

图 7.2 – 每次按键时的突出显示重新渲染

图 7.2 – 每次按键时的突出显示重新渲染

因此,每次在字段中输入字符时,整个表单都会重新渲染。这很有道理,因为字段变化时会发生状态变化,状态变化会导致重新渲染。在这个小型表单中这不是一个大问题,但在大型表单中可能会成为一个重大的性能问题。

  1. 完成表单中的所有字段并点击提交按钮。字段值将输出到控制台。

这样就完成了表单的第一轮迭代。保持应用运行,我们反思实现并进入下一节。

本节的关键要点是,使用状态来控制字段值可能会导致性能问题。必须将状态绑定到每个字段也感觉有点重复。

接下来,我们将实现一个更高效、更简洁的表单版本。

使用非受控字段

非受控字段与受控字段相反 – 它是字段值不由状态控制的地方。相反,使用原生浏览器功能来获取字段值。在本节中,我们将重构联系表单以使用非受控字段,并查看其优势。

执行以下步骤:

  1. 打开 ContactPage.tsx 并首先从 React 导入中移除 useState,因为现在不再需要它。

  2. 然后,在 component 函数的顶部,移除对 useState 的调用(这次表单迭代将不使用任何状态)。

  3. 从字段编辑器中移除 valueonChange 属性,因为我们不再使用状态来控制字段值。

  4. 现在,在所有字段编辑器上添加name属性,如下所示:

    <form onSubmit={handleSubmit}>
    
      <div className={fieldStyle}>
    
        <label htmlFor="name">Your name</label>
    
        <input type="text" id="name" name="name" />
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="email">Your email address</label>
    
        <input type="email" id="email" name="email" />
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="reason">
    
          Reason you need to contact us
    
        </label>
    
        <select id="reason" name="reason">
    
          ...
    
        </select>
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="notes">Additional notes</label>
    
        <textarea id="notes" name="notes" />
    
      </div>
    
      ...
    
    </form>;
    

name属性很重要,因为它将允许我们在表单提交处理程序中轻松提取字段值,这是我们接下来要做的。

  1. 在提交处理程序中添加以下代码以在它们输出到控制台之前提取字段值:

    function handleSubmit(e: FormEvent<HTMLFormElement>) {
    
      e.preventDefault();
    
      const formData = new FormData(e.currentTarget);
    
      const contact = {
    
        name: formData.get('name'),
    
        email: formData.get('email'),
    
        reason: formData.get('reason'),
    
        notes: formData.get('notes'),
    
      } as Contact;
    
      console.log('Submitted details:', contact);
    
    }
    

FormData是一个接口,允许访问表单中的值,并在其构造函数参数中接受一个表单元素。它包含一个get方法,该方法返回作为参数传递的名称的字段值。有关FormData的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/API/FormData

这样就完成了表单的重构。总结一下,非受控字段没有存储在状态中的值。相反,字段值是通过使用FormData获得的,这依赖于字段编辑器具有name属性。

注意与受控字段实现相比,实现中的代码更少。现在我们将尝试表单并检查表单是否在每次按键时重新渲染。执行以下步骤:

  1. 在运行的应用程序中,确保 DevTools 仍然打开,并且在组件渲染时突出显示更新选项仍然勾选。

  2. 填写表格后,你会发现重新渲染的轮廓从未出现。这很有道理,因为没有状态了,所以不会因为状态变化而重新渲染。

  3. 完成表单中的所有字段并点击提交按钮。字段值将像之前一样输出到控制台。

图 7.3 – 完成的表单,提交数据在控制台中

图 7.3 – 完成的表单,提交数据在控制台中

  1. 通过按Ctrl + C停止应用程序的运行。

因此,这种实现更短、性能更好,是简单表单的优秀方法。实现中的关键点是包括字段编辑器的name属性,并使用FormData接口提取表单值。

当前的实现虽然很简单 - 例如,没有提交成功消息。在下一节中,我们将使用 React Router 并添加提交消息。

使用 React Router Form

第六章中,我们开始学习 React Router 的Form组件。我们了解到Form是 HTML form元素的包装器,用于处理表单提交。现在我们将更详细地介绍Form,并使用它在我们联系表单上提供漂亮的提交成功消息。

执行以下步骤:

  1. 首先,在终端中执行以下命令来安装 React Router:

    npm i react-router-dom
    
  2. 现在,让我们创建一个ThankYouPage组件,它将通知用户他们的提交已成功。为此,在src文件夹中创建一个名为ThankYouPage.tsx的文件,内容如下:

    import { useParams } from 'react-router-dom';
    
    export function ThankYouPage() {
    
      const { name } = useParams<{ name: string }>();
    
      return (
    
        <div className="flex flex-col py-10 max-w-md       mx-auto">
    
          <div
    
            role="alert"
    
            className="bg-green-100 py-5 px-6 text-base text-          green-700 "
    
          >
    
            Thanks {name}, we will be in touch shortly
    
          </div>
    
        </div>
    
      );
    
    }
    

该组件使用一个路由参数来表示包含在感谢信息中的人名。

  1. 接下来,打开 App.tsx 并从 React Router 中添加以下导入:

    import {
    
      createBrowserRouter,
    
      RouterProvider,
    
      Navigate
    
    } from 'react-router-dom';
    

我们之前没有遇到过 React Router 的 Navigate 组件——它是一个执行导航的组件。我们将在 步骤 5 中使用它,在路由定义中,从根路径重定向到联系页面。

  1. contactPageAction 添加到 ContactPageimport 语句中,并导入 ThankYouPage 组件:

    import {
    
      ContactPage,
    
      contactPageAction
    
    } from './ContactPage';
    
    import { ThankYouPage } from './ThankYouPage';
    

注意,contactPageAction 还不存在,因此将发生编译错误。我们将在 步骤 9 中解决这个问题。

  1. 仍然在 App.tsx 中,设置渲染联系和感谢页面的路由:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <Navigate to="contact" />,
    
      },
    
      {
    
        path: '/contact',
    
        element: <ContactPage />,
    
        action: contactPageAction,
    
      },
    
      {
    
        path: '/thank-you/:name',
    
        element: <ThankYouPage />,
    
      },
    
    ]);
    

contact 路由上有一个我们尚未涉及的 action 属性——它处理表单提交。我们将其设置为 contactPageAction,我们将在 步骤 9 中创建它。

  1. App.tsx 中的最后一个任务是更改 App 组件以返回带有路由定义的 RouterProvider

    function App() {
    
      return <RouterProvider router={router} />;
    
    }
    
  2. 现在,打开 ContactPage.tsx 并从 React Router 中添加以下导入:

    import {
    
      Form,
    
      ActionFunctionArgs,
    
      redirect,
    
    } from 'react-router-dom';
    
  3. 在 JSX 中,将 form 元素改为 React Router 的 Form 组件,并移除 onSubmit 属性:

    <Form method="post">
    
      ...
    
    </Form>
    

我们已将表单的方法设置为 "post",因为表单将修改数据。默认表单方法是 "get"

  1. 现在,将 handleSubmit 函数移出组件,到文件的底部。将函数重命名为 contactPageAction,允许其导出,并使其异步:

    export async function contactPageAction(
    
      e: FormEvent<HTMLFormElement>
    
    ) {
    
      e.preventDefault();
    
      const formData = new FormData(e.currentTarget);
    
      const contact = {
    
        name: formData.get('name'),
    
        email: formData.get('email'),
    
        reason: formData.get('reason'),
    
        notes: formData.get('notes'),
    
      } as Contact;
    
      console.log('Submitted details:', contact);
    
    }
    

这现在将是一个处理部分表单提交的 React Router 动作。

  1. contactPageAction 上的参数更改为以下内容:

    export async function contactPageAction({
    
      request,
    
    }: ActionFunctionArgs)
    

当 React Router 调用此函数时,会传入一个 request 对象。

  1. contactPageAction 中移除 e.preventDefault() 语句,因为 React Router 会为我们处理这个。

  2. formData 赋值改为从 React Router 的 request 对象中获取数据:

    const formData = await request.formData();
    
  3. contactPageAction 函数的最后更改是在提交结束时重定向到感谢页面:

    export async function contactPageAction({
    
      request,
    
    }: ActionFunctionArgs) {
    
      ...
    
      return redirect(
    
        `/thank-you/${formData.get('name')}`
    
      );
    
    }
    
  4. 移除 FormEvent 导入,因为现在这是多余的。

  5. 通过在终端中执行 npm start 来以开发模式运行应用。

  6. 应用将自动重定向到 Contact 页面。完成表单并提交。

应用将重定向到感谢页面:

图 7.4 – 感谢页面

图 7.4 – 感谢页面

这就完成了关于 React Router 表单功能的本节内容。在我们回顾并进入下一节之前,保持应用运行。

React Router 的 Form 组件的关键点如下:

  • React Router 的 Form 组件是 HTML form 元素的包装器

  • 表单默认提交到当前路由,但可以使用 path 属性提交到不同的路径

  • 我们可以在提交过程中使用在提交的路由上定义的动作函数来编写逻辑

有关 React Router 的表单组件更多信息,请参阅以下链接:reactrouter.com/en/components/form

接下来,我们将实现表单验证。

使用原生验证

在本节中,我们将添加姓名、电子邮件和原因字段的必填验证,并确保电子邮件匹配特定模式。我们将使用标准 HTML 表单验证来实现这些规则。

执行以下步骤:

  1. ContactPage.tsx 中,将 required 属性添加到姓名、电子邮件和原因字段编辑器,为这些字段添加 HTML 表单必填验证:

    <Form method="post">
    
      <div className={fieldStyle}>
    
        ...
    
        <input type="text" id="name" name="name" required />
    
      </div>
    
      <div className={fieldStyle}>
    
        ...
    
        <input type="email" id="email" name="email" required />
    
      </div>
    
      <div className={fieldStyle}>
    
        ...
    
        <select id="reason" name="reason" required >...</      select>
    
      </div>
    
      ...
    
    </Form>
    
  2. email 字段编辑器上添加以下模式匹配验证:

    <input
    
      type="email"
    
      id="email"
    
      name="email"
    
      required
    
      pattern="\S+@\S+\.\S+"
    
    />
    

此模式将确保输入为电子邮件格式。

  1. 在运行的应用程序中,不填写任何字段,提交表单。验证启动,表单提交未完成。相反,姓名字段被聚焦,并在其下方出现错误信息:

图 7.5 – 姓名字段的 HTML 表单验证信息

图 7.5 – 姓名字段的 HTML 表单验证信息

注意,错误信息在不同浏览器中的样式略有不同 – 上述截图来自 Firefox。

  1. 正确填写姓名字段以确保其有效。然后,继续尝试电子邮件字段验证。例如,尝试输入没有 @ 符号的电子邮件地址;你会发现电子邮件字段需要填写一个格式正确的电子邮件地址。

图 7.6 – 电子邮件字段的 HTML 表单验证信息

图 7.6 – 电子邮件字段的 HTML 表单验证信息

  1. 正确填写电子邮件字段以确保其有效。然后,继续尝试原因字段验证。尝试选择空白原因,你会发现验证错误发生。

  2. 正确填写所有字段并提交表单。你会发现感谢信息如之前一样出现。

  3. 通过按 CTRL + C 停止应用程序运行。

标准 HTML 表单验证实现的简单性是令人愉快的。然而,如果我们想自定义验证用户体验,我们需要编写 JavaScript 来使用约束验证 API。有关此 API 和更多关于 HTML 表单验证的信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation

在下一节中,我们将使用流行的表单库来改进验证用户体验。在 React 中使用它比使用约束验证 API 更容易一些。

使用 React Hook Form

在本节中,我们将了解 React Hook Form 并使用它来改进我们的联系表单的验证用户体验。

理解 React Hook Form

如其名所示,React Hook Form 是一个用于构建表单的 React 库。它非常灵活,可以用于简单的表单,如我们的联系表单,也可以用于具有复杂验证和提交逻辑的大型表单。它也非常高效,经过优化,不会引起不必要的重新渲染。它还非常受欢迎,拥有数万个 GitHub 星标,并且自 2019 年首次发布以来一直在稳步成熟。

React Hooks Form 的关键部分是useForm钩子,它返回有用的函数和状态。以下代码片段显示了如何调用useForm钩子:

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting, isSubmitSuccessful }
} = useForm<FormData>();

useForm有一个泛型类型参数,用于字段值的类型。在先前的示例中,字段值的类型是FormData

理解register函数

useForm返回的键函数是一个register函数,它接受一个唯一的字段名称,并以对象结构返回以下内容:

  • 当字段编辑器的值发生变化时触发的onChange处理程序

  • 当字段编辑器失去焦点时触发的onBlur处理程序

  • 字段编辑器元素的引用

  • 字段名称

register函数返回的这些项目被分散到字段编辑器元素上,以便 React Hook Form 能够高效地跟踪其值。以下代码片段允许 React Hook Form 跟踪名称字段编辑器:

<input {...register('name')} />

register的结果被分散到input元素上之后,它将包含refnameonChangeonBlur属性:

<input
  ref={someVariableInRHF}
  name="name"
  onChange={someHandlerInRHF}
  onBlur={anotherHandlerInRHF}
/>

refonChangeonBlur属性将引用 React Hook Form 中跟踪input元素值的代码。

指定验证

字段验证在register字段的选项参数中定义为以下内容:

<input {...register('name', {required: true})} />

在前面的示例中,指定了所需的验证。相关的错误信息可以定义为替代true标志的选项,如下所示:

<input
  {...register('name', { required: 'You must enter a name' })}
/>

可以应用一系列不同的验证规则。请参阅 React Hook Form 文档中的此页面,以获取所有可用规则列表:react-hook-form.com/get-started#applyvalidation

获取验证错误

useForm返回一个名为errors的状态,其中包含表单验证错误。errors状态是一个包含无效字段错误信息的对象。例如,如果name字段无效,因为违反了required规则,则errors对象可能如下所示:

{
  name: {
    message: 'You must enter your email address',
    type: 'required'
  }
}

在有效状态下的字段不存在于errors对象中,因此字段验证错误信息可以按如下方式条件性地渲染:

{errors.name && <div>{errors.name.message}</div>}

在前面的代码片段中,如果名称字段有效,errors.name将是undefined,因此错误信息不会渲染。如果名称字段无效,errors.name将包含错误,因此错误信息会渲染。

处理提交

useForm 钩子还返回一个名为 handleSubmit 的处理程序,可以用于表单提交。handleSubmit 接收一个函数,当 React Hook Form 成功验证表单时,它会调用此函数。以下是一个使用 handleSubmit 的示例:

function onSubmit(data: FormData) {
  console.log('Submitted data:', data);
}
return (
  <form onSubmit={handleSubmit(onSubmit)}>
  </form>
);

在前面的示例中,onSubmit 只在表单成功验证时在提交时被调用,而不是在表单无效时。

isSubmitting 状态可以在表单提交时禁用元素。以下示例在表单提交时禁用了 submit 按钮:

<button type="submit" disabled={isSubmitting}>Submit</button>

isSubmitSuccessful 可以用来有条件地渲染成功的提交消息:

if (isSubmitSuccessful) {
  return <div>The form was successfully submitted</div>;
}

React Hook Form 中还有许多其他功能,但这些都是常用的基本函数和状态。有关更多信息,请参阅 react-hook-form.com/ 的 React Hook Form 文档。

现在我们已经了解了 React Hook Form 的基础知识,我们将重构我们的联系表单以使用它。

使用 React Hook Form

我们将重构我们一直在工作的联系表单,以使用 React Hook Form。表单将包含相同的功能,但实现将使用 React Hook Form。在代码更改后,我们将使用 React 的 DevTools 检查表单重新渲染的频率。

我们将移除对 React Router 的 Form 组件的使用 - 它目前处理表单提交。React Hook Form 也能够处理提交,我们需要它来完全控制提交过程,以确保表单在这个过程中有效。

执行以下步骤:

  1. 让我们从安装 React Hook Form 开始。在终端中运行以下命令:

    npm i react-hook-form
    

此包中包含 TypeScript 类型,因此无需单独安装。

  1. 打开 ContactPage.tsx 并添加从 React Hook Form 导入 useFormimport 语句:

    import { useForm } from 'react-hook-form';
    
  2. 从 React Router 的 import 语句中移除 FormredirectActionFunctionArgs,并用 useNavigate 替换它们:

    import { useNavigate } from 'react-router-dom';
    

我们将使用 useNavigate 在表单提交结束时导航到感谢页面。

  1. ContactPage 组件的顶部添加以下 useForm 调用,并解构 registerhandleSubmit 函数:

    export function ContactPage() {
    
      const { register, handleSubmit } = useForm<Contact>();
    
      ...
    
    }
    
  2. 在调用 useForm 之后添加以下 useNavigate 调用,以获取我们可以用来执行导航的函数:

    export function ContactPage() {
    
      const { register, handleSubmit } = useForm<Contact>();
    
      const navigate = useNavigate();
    
    }
    
  3. 在 JSX 中,用原生的 form 元素替换 React Router 的 Form 元素的使用。从 form 元素上移除 method 属性,并用以下 onSubmit 属性替换它:

    <form onSubmit={handleSubmit(onSubmit)}>
    
      ...
    
    </form>
    

我们将在下一步实现 onSubmit 函数。

  1. 在调用 useNavigate 之后添加以下 onSubmit 函数。React Hook Form 在确保表单有效后,会使用表单数据调用此函数:

    const navigate = useNavigate();
    
    function onSubmit(contact: Contact) {
    
      console.log('Submitted details:', contact);
    
      navigate(`/thank-you/${contact.name}`);
    
    }
    

该函数将表单数据输出到控制台,然后导航到感谢页面。

  1. 从文件底部移除 contactPageAction 函数,因为现在不再需要它。

  2. 将字段编辑器的 name 属性替换为对 register 的调用。将字段名称传递给 register 并如下展开 register 的结果:

    <form onSubmit={handleSubmit(onSubmit)}>
    
      <div className={fieldStyle}>
    
        <label htmlFor="name">Your name</label>
    
        <input ... {...register('name')} />
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="email">Your email address</label>
    
        <input ... {...register('email')} />
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="reason">Reason you need to contact       us</label>
    
        <select ... {...register('reason')}>
    
          ...
    
        </select>
    
      </div>
    
      <div className={fieldStyle}>
    
        <label htmlFor="notes">Additional notes</label>
    
        <textarea ... {...register('notes')} />
    
      </div>
    
      ...
    
    </Form>
    

React Hook Form 现在将能够跟踪这些字段。

  1. 打开 App.tsx 并从 ContactPageimport 语句中移除 contactPageAction,并从 /contact 路由中移除它:

    import { ContactPage } from './ContactPage';
    
    ...
    
    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: <Navigate to="contact" />,
    
      },
    
      {
    
        path: '/contact',
    
        element: <ContactPage />
    
      },
    
      {
    
        path: '/thank-you/:name',
    
        element: <ThankYouPage />,
    
      }
    
    ]);
    
  2. 通过在终端中执行 npm start 来以开发模式运行应用程序。

  3. 打开 React DevTools 并确保当组件渲染时高亮显示更新选项仍然勾选,这样我们就可以观察表单何时重新渲染。

  4. 使用有效值填写表单。当输入字段值时,不会出现重新渲染轮廓,因为它们还没有使用状态来引起重新渲染。这是 React Hook Form 高效跟踪字段值的确认。

  5. 现在,点击提交按钮。在表单成功提交后,字段值将输出到控制台,并显示感谢页面,就像之前一样。

我们不得不做更多的工作来设置 React Hook Form 中的表单,以便它可以跟踪字段。这使 React Hook Form 能够验证字段,我们将在下一节中实现这一点。

添加验证

我们现在将移除标准 HTML 表单验证的使用,并使用 React Hook Form 的验证。使用 React Hook Form 的验证使我们能够更容易地提供出色的验证用户体验。

执行以下步骤:

  1. 打开 ContactPage.tsx 并将 FieldError 类型添加到 React Hook Form 的 import 语句中:

    import { useForm, FieldError } from 'react-hook-form';
    
  2. 如下从 useForm 中解构 errors 状态:

    const {
    
      register,
    
      handleSubmit,
    
      formState: { errors }
    
    } = useForm<Contact>();
    

如果有验证错误,errors 将包含验证错误。

  1. noValidate 属性添加到表单元素中,以防止任何原生 HTML 验证:

    <form noValidate onSubmit={handleSubmit(onSubmit)}>
    
  2. 通过移除所有字段编辑器的验证 requiredpattern 属性来移除所有字段的原生 HTML 验证规则。

  3. 为名称、电子邮件和原因字段添加所需的验证规则到 register 函数中,如下所示:

    <div className={fieldStyle}>
    
      <label htmlFor="name">Your name</label>
    
      <input
    
        type="text"
    
        id="name"
    
        {...register('name', {
    
          required: 'You must enter your name'
    
        })}
    
      />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="email">Your email address</label>
    
      <input
    
        type="email"
    
        id="email"
    
        {...register('email', {
    
          required: 'You must enter your email address'
    
        })}
    
      />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="reason">Reason you need to contact us</label>
    
      <select
    
        id="reason"
    
        {...register('reason', {
    
          required: 'You must enter the reason for contacting         us'
    
        })}
    
      >
    
        ...
    
      </select>
    
    </div>
    

我们已经使用验证规则指定了验证错误消息。

  1. 为电子邮件字段添加一个额外的规则,以确保它匹配特定的模式:

    <input
    
      type="email"
    
      id="email"
    
      {...register('email', {
    
        required: 'You must enter your email address',
    
        pattern: {
    
          value: /\S+@\S+\.\S+/,
    
          message: 'Entered value does not match email         format',
    
        }
    
      })}
    
    />
    
  2. 如果字段无效,我们现在将为其设置样式。每个字段都将使用相同的样式逻辑,因此如下定义样式函数:

    function getEditorStyle(fieldError: FieldError | undefined) {
    
      return fieldError ? 'border-red-500' : '';
    
    }
    
    return (
    
      <div>
    
        ...
    
      </div>
    
    );
    

字段错误被传递到 getEditorStyle 函数中。如果存在错误,该函数返回一个 Tailwind CSS 类,用于使用红色边框样式化元素。

  1. 使用 getEditorStyle 函数在无效时样式化名称、电子邮件和原因字段:

    <div className={fieldStyle}>
    
      <label htmlFor="name">Your name</label>
    
      <input ... className={getEditorStyle(errors.name)} />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="email">Your email address</label>
    
      <input ... className={getEditorStyle(errors.email)} />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="reason">Reason you need to contact us</label>
    
      <select ... className={getEditorStyle(errors.reason)} >
    
        ...
    
      </select>
    
    </div>
    

React Hook Form 的 errors 状态包含一个属性,用于包含具有验证错误的字段。例如,如果名称字段值无效,errors 将包含一个名为 name 的属性。

  1. 现在,让我们在字段无效时在每个字段下显示验证错误。每个字段的错误结构和样式相同,但消息不同,因此我们将创建一个可重用的验证错误组件。在src文件夹中创建一个名为ValidationError.tsx的文件,内容如下:

    import { FieldError } from 'react-hook-form';
    
    type Props = {
    
      fieldError: FieldError | undefined;
    
    };
    
    export function ValidationError({ fieldError }: Props) {
    
      if (!fieldError) {
    
        return null;
    
      }
    
      return (
    
        <div role="alert" className="text-red-500 text-xs       mt-1">
    
          {fieldError.message}
    
        </div>
    
      );
    
    }
    

组件有一个fieldError属性,用于从 React Hook Form 接收字段错误。如果没有字段错误,则不会渲染任何内容。如果有错误,它将以红色文本在div元素内渲染。role="alert"属性允许屏幕阅读器读取验证错误。

  1. 返回到ContactPage.tsx并导入ValidationError组件:

    import { ValidationError } from './ValidationError';
    
  2. 按照以下方式在每个字段编辑器下添加ValidationError实例:

    <div className={fieldStyle}>
    
      <label htmlFor="name">Your name</label>
    
      <input ... />
    
      <ValidationError fieldError={errors.name} />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="email">Your email address</label>
    
      <input ... />
    
      <ValidationError fieldError={errors.email} />
    
    </div>
    
    <div className={fieldStyle}>
    
      <label htmlFor="reason">Reason you need to contact us</label>
    
      <select ... >...</select>
    
      <ValidationError fieldError={errors.reason} />
    
    </div>
    

这完成了表单验证的实现。我们现在将按照以下步骤测试我们的增强表单:

  1. 在运行的应用程序中,确保 DevTools 中的组件渲染时高亮更新选项仍然勾选,这样我们就可以观察表单何时重新渲染。

  2. 在没有填写表格的情况下点击提交按钮。

图 7.7 – 表单提交时高亮显示的重新渲染和验证错误

图 7.7 – 表单提交时高亮显示的重新渲染和验证错误

  1. 填写表格并点击errors状态。这是必要的重新渲染,因为我们需要页面更新以显示验证错误信息。

另一个你可能注意到的问题是,没有输出到控制台,因为我们的onSubmit函数只有在表单有效时才会被调用。

  1. 正确填写表格并提交。现在字段值将输出到控制台,并显示感谢页面。

表单现在工作得很好。

你可能注意到的一个问题是验证实际发生的时间——它发生在表单提交时。我们将验证改为每次字段编辑器失去焦点时发生。执行以下步骤:

  1. ContactPage.tsx中,向useForm调用添加以下参数:

    const {
    
      register,
    
      handleSubmit,
    
      formState: { errors },
    
    } = useForm<Contact>({
    
      mode: "onBlur",
    
      reValidateMode: "onBlur"
    
    });
    

mode选项现在告诉 React Hook Form 在字段编辑器失去焦点时最初进行验证。reValidationMode选项现在告诉 React Hook Form 在字段编辑器失去焦点时随后进行验证。

  1. 在运行的应用程序中,访问表单中的字段而不填写它们,以查看验证过程。

这完成了表单的构建,以及 React Hook Form 这一章节。以下是 React Hook Form 关键部分的总结:

  • 当表单填写时,React Hook Form 不会引起不必要的重新渲染。

  • React Hook Form 的register函数需要在字段编辑器上展开。此函数允许高效地跟踪字段值,并允许指定验证规则。

  • React Hook Form 的提交处理程序自动防止服务器提交,并确保在调用我们的提交逻辑之前表单是有效的。

接下来,我们将总结本章内容。

摘要

在本章的开始,我们了解到表单中的字段值可以通过状态来控制。然而,这会导致表单进行大量的不必要的重新渲染。然后我们意识到,不使用状态来控制字段值,而是使用FormData接口来检索字段值,这样更高效,并且需要更少的代码。

我们使用了 React Router 的Form组件,它是对原生form元素的包装。它将数据提交到客户端路由而不是服务器。然而,它不涵盖验证——我们尝试使用原生的 HTML 验证来实现这一点,这很简单,但使用原生 HTML 验证来提供良好的用户体验是棘手的。

我们介绍了一个流行的表单库 React Hook Form,以提供更好的验证用户体验。它包含一个useForm钩子,该钩子返回有用的函数和状态。该库不会导致不必要的渲染,因此它非常高效。

我们了解到 React Hook Form 的register函数需要在每个字段编辑器上展开。这样它就可以高效地跟踪字段值,而不会引起不必要的渲染。我们还了解到 React Hook Form 包含几个常见的验证规则,包括必填字段和与特定模式匹配的字段值。字段验证规则在register函数中指定,并且可以使用适当的验证消息指定。useForm返回一个errors状态变量,可以用来有条件地渲染验证错误消息。

我们探讨了 React Hook Form 提供的提交处理程序。我们了解到它阻止了服务器端提交并确保表单是有效的。这个提交处理程序有一个用于调用有效表单数据的函数的参数。

在下一章中,我们将详细讨论状态管理。

问题

回答以下问题以检查你对 React 中表单的了解:

  1. 当在姓名字段中输入Bob时,以下表单在初始渲染后将会渲染多少次?

    function ControlledForm () {
    
      const [name, setName] = useState('');
    
      return (
    
        <form
    
          onSubmit={(e) => {
    
            e.preventDefault();
    
            console.log(name);
    
          }}
    
        >
    
          <input
    
            placeholder="Enter your name"
    
            value={name}
    
            onChange={(e) => setName(e.target.value)}
    
          />
    
        </form>
    
      );
    
    }
    
  2. 当在姓名字段中输入Bob时,以下表单在初始渲染后将会渲染多少次?

    function UnControlledForm() {
    
      return (
    
        <form
    
          onSubmit={(e) => {
    
            e.preventDefault();
    
            console.log(
    
              new FormData(e.currentTarget).get('name')
    
            );
    
          }}
    
        >
    
          <input placeholder="Enter your name" name="name" />
    
        </form>
    
      );
    
    }
    
  3. 以下表单包含一个未受控的搜索字段。当将搜索条件输入其中并提交时,控制台中出现的是null而不是搜索条件。为什么会这样?

    function SearchForm() {
    
      return (
    
        <form
    
          onSubmit={(e) => {
    
            e.preventDefault();
    
            console.log(
    
              new FormData(e.currentTarget).get('search')
    
            );
    
          }}
    
        >
    
          <input type="search" placeholder="Search ..." />
    
        </form>
    
      );
    
    }
    
  4. 以下组件是一个使用 React Hook Form 实现的搜索表单。当将搜索条件输入其中并提交时,控制台中出现的是一个空对象,而不是包含搜索条件的对象。为什么会这样?

    function SearchReactHookForm() {
    
      const { handleSubmit } = useForm();
    
      return (
    
        <form
    
          onSubmit={handleSubmit((search) => console.        log(search))}
    
        >
    
          <input type="search" placeholder="Search ..." />
    
        </form>
    
      );
    
    }
    
  5. 以下组件是另一个使用 React Hook Form 实现的搜索表单。表单确实可以正常工作,但在onSubmit参数上抛出了一个类型错误。如何解决这个类型错误?

    function SearchReactHookForm() {
    
      const { handleSubmit, register } = useForm();
    
      async function onSubmit(search) {
    
        console.log(search.criteria);
    
        // send to web server to perform the search
    
      }
    
      return (
    
        <form onSubmit={handleSubmit(onSubmit)}>
    
          <input
    
            type="search"
    
            placeholder="Search ..."
    
            {...register('criteria')}
    
          />
    
        </form>
    
      );
    
    }
    
  6. 继续从上一个问题中的搜索表单,当在 Web 服务器上执行搜索时,我们如何禁用input元素?

  7. 在上一问题中继续使用搜索表单,如果条件为空,我们如何防止搜索执行?

答案

  1. Bob被输入到名称字段时,表单将渲染三次。这是因为每次值的改变都会导致重新渲染,因为值绑定到了状态上。

  2. Bob被输入到名称字段时,表单将不会重新渲染,因为它的值没有绑定到状态上。

  3. FormData接口需要input元素上的name属性,否则它将无法找到它,并返回null

    <input type="search" placeholder="Search ..." name="search" />
    
  4. 为了让 React Hook Form 跟踪字段,register函数需要像以下这样在input元素上展开:

    function SearchReactHookForm() {
    
      const { handleSubmit, register } = useForm();
    
      return (
    
        <form
    
          onSubmit={handleSubmit((search) => console.        log(search))}
    
        >
    
          <input
    
            type="search"
    
            placeholder="Search ..."
    
            {...register('criteria')}
    
          />
    
        </form>
    
      );
    
    }
    
  5. 可以在调用useFormonSubmit搜索参数时定义并指定字段值的类型:

    type Search = {
    
      criteria: string;
    
    };
    
    function SearchReactHookForm() {
    
      const { handleSubmit, register } = useForm<Search>();
    
      async function onSubmit(search: Search) {
    
        ...
    
      }
    
      return ...
    
    }
    
  6. 可以使用 React Hook Form 的isSubmitting状态在 Web 服务器上执行搜索时禁用input元素:

    function SearchReactHookForm() {
    
      const {
    
        handleSubmit,
    
        register,
    
        formState: { isSubmitting },
    
      } = useForm<Search>();
    
      ...
    
      return (
    
        <form onSubmit={handleSubmit(onSubmit)}>
    
          <input
    
            type="search"
    
            placeholder="Search ..."
    
            {...register('criteria')}
    
            disabled={isSubmitting}
    
          />
    
        </form>
    
      );
    
    }
    
  7. 可以将所需的验证添加到搜索表单中,以防止在条件为空时执行搜索:

    <input
    
      type="search"
    
      placeholder="Search ..."
    
      {...register('criteria', { required: true })}
    
      disabled={isSubmitting}
    
    />
    

第三部分:数据

本部分涵盖了与 REST 和 GraphQL API 交互的不同方法以及每种方法的优点。我们将学习如何高效地管理来自这些 API 的数据,包括使用几个流行的第三方库。

本部分包括以下章节:

  • 第八章状态管理

  • 第九章与 RESTful API 交互

  • 第十章与 GraphQL API 交互

第八章:状态管理

在本章中,我们将了解 共享状态,这是由多个不同组件使用的状态。我们将探讨管理共享状态的三个方法,并讨论每种方法的优缺点。

为了实现这一点,我们将构建一个简单的应用程序,其中包含一个显示用户名的头部,主内容也会引用用户名。用户名将存储在需要由多个组件访问的状态中。

我们将从最简单的状态解决方案开始。这是使用 React 的一个状态钩子来存储状态,并通过属性将其传递给其他组件。这种方法通常被称为 属性钻取

我们将要了解的第二种方法是 React 中的一个特性,称为 上下文。我们将学习如何创建一个包含状态的上下文,并允许其他组件访问它。

我们将要介绍的最后一个方法是流行的库 Redux。在重构应用程序以使用 Redux 之前,我们将花时间了解 Redux 是什么以及其概念。

因此,我们将涵盖以下主题:

  • 创建项目

  • 使用属性钻取

  • 使用 React 上下文

  • 使用 Redux

技术要求

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

本章中所有的代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter8

创建项目

我们将使用 Visual Studio Code 和一个新的基于 Create React App 的项目设置来开发我们的表单。我们之前已经多次介绍过这一点,所以本章中不会介绍步骤——相反,请参阅 第三章设置 React 和 TypeScript

我们将使用 Tailwind CSS 来设计表单样式。我们之前也介绍了如何在 Create React App 中安装和配置 Tailwind,请参阅 第五章前端设计方法。因此,在创建 React 和 TypeScript 项目之后,安装并配置 Tailwind。

我们还将使用 @tailwindcss/forms 插件来设计表单样式。因此,也要安装这个插件——有关如何操作的更多信息,请参阅 第七章与表单一起工作

我们将要构建的应用程序将包含一个头部和其下的某些内容。以下是我们将创建的组件结构:

图 8.1 – 应用组件结构

图 8.1 – 应用组件结构

该头部将包含一个登录按钮,用于验证和授权用户以获取其姓名和权限。一旦验证通过,用户的姓名将在应用头部显示,并在内容中欢迎用户。如果用户具有管理员权限,将显示重要内容。

因此,执行以下步骤以创建应用中所需的初始文件版本,而不进行任何语句管理(一些代码片段可能较长 - 不要忘记您可以从github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter8/prop-drilling复制它们):

  1. 我们将首先创建一个包含验证用户功能的文件。在src文件夹中创建一个名为api的文件夹。然后,在api文件夹中创建一个名为authenticate.ts的文件,并添加以下内容:

    export type User = {
    
      id: string;
    
      name: string;
    
    };
    
    export function authenticate(): Promise<User | undefined> {
    
      return new Promise((resolve) =>
    
        setTimeout(() => resolve({ id: "1", name: "Bob" }),       1000)
    
      );
    
    }
    

该函数模拟了名为 Bob 的用户成功验证。

  1. 接下来,我们将创建一个包含授权用户功能的文件。因此,在api文件夹中创建一个名为authorize.ts的文件,并添加以下内容:

    export function authorize(id: string): Promise<string[]> {
    
      return new Promise((resolve) =>
    
        setTimeout(() => resolve(["admin"]), 1000)
    
      );
    
    }
    

该函数模拟了用户被授权具有管理员权限。

  1. 接下来,我们将创建一个用于应用头部的组件。在src文件夹中创建一个名为Header.tsx的文件,并添加以下内容:

    import { User } from './api/authenticate';
    
    type Props = {
    
      user: undefined | User;
    
      onSignInClick: () => void;
    
      loading: boolean;
    
    };
    

组件有一个用于用户的属性,如果用户尚未验证,则该属性为undefined。组件还有一个名为onSignInClick的属性,用于loading,它确定当用户验证或授权时应用是否处于加载状态。

  1. 将以下组件实现添加到Header.tsx中:

    export function Header({
    
      user,
    
      onSignInClick,
    
      loading,
    
    }: Props) {
    
      return (
    
        <header className="flex justify-between items-center       border-b-2 border-gray-100 py-6">
    
          {user ? (
    
            <span className="ml-auto font-bold">
    
              {user.name} has signed in
    
            </span>
    
          ) : (
    
            <button
    
              onClick={onSignInClick}
    
              className="whitespace-nowrap inline-flex items-            center justify-center ml-auto px-4 py-2 w-36             border border-transparent rounded-md             shadow-sm text-base font-medium text-white             bg-indigo-600 hover:bg-indigo-700"
    
              disabled={loading}
    
            >
    
              {loading ? '...' : 'Sign in'}
    
            </button>
    
          )}
    
        </header>
    
      );
    
    }
    

如果用户已验证,组件会通知用户他们已登录。如果用户未经验证,组件会显示一个登录按钮。

  1. 接下来,我们将实现一个用于主应用内容的组件。在src文件夹中创建一个名为Main.tsx的文件,并添加以下内容:

    import { User } from './api/authenticate';
    
    import { Content } from './Content';
    
    type Props = {
    
      user: undefined | User;
    
      permissions: undefined | string[];
    
    };
    

组件有一个用于用户及其权限的属性。我们已导入一个名为Content的组件,我们将在第 7 步中创建它。

  1. 现在,将以下组件实现添加到Main.tsx中:

    export function Main({ user, permissions }: Props) {
    
      return (
    
        <main className="py-8">
    
          <h1 className="text-3xl text-center font-bold         underline">Welcome</h1>
    
          <p className="mt-8 text-xl text-center">
    
            {user ? `Hello ${user.name}!` : "Please sign in"}
    
          </p>
    
          <Content permissions={permissions} />
    
        </main>
    
      );
    
    }
    

组件指示用户登录,如果他们未经验证或显示传递用户权限的Content组件。

  1. src文件夹中创建的最后一个文件被命名为Content.tsx。将以下内容添加到该文件中:

    type Props = {
    
      permissions: undefined | string[];
    
    };
    
    export function Content({ permissions }: Props) {
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return permissions.includes('admin') ? (
    
        <p className="mt-4 text-l text-center">
    
          Some important stuff that only an admin can do
    
        </p>
    
      ) : (
    
        <p className="mt-4 text-l text-center">
    
          Insufficient permissions
    
        </p>
    
      );
    
    }
    

如果用户未授权,组件不显示任何内容。如果用户具有管理员权限,则显示一些重要内容。否则,它会通知用户他们缺少权限。

这样就完成了项目设置。应用将编译并运行,但不会显示我们创建的任何组件,因为我们还没有在App组件中引用它们。我们将在分享用户和权限信息到多个组件时进行此操作。

使用钻探法

在这种第一种状态管理方法中,我们将userpermissionsloading状态存储在App组件中。然后,App组件将使用 props 将此状态传递给HeaderMain组件。

因此,这种方法使用了我们已知的 React 特性。这种方法被称为属性钻取,因为状态是通过 props 向下传递给组件树的。

执行以下步骤来重构App组件,以存储userpermissionsloading状态,并将此状态传递给HeaderMain组件:

  1. 打开App.tsx,首先删除所有现有代码,并添加以下导入语句:

    import { useReducer } from 'react';
    
    import { Header } from './Header';
    
    import { Main } from './Main';
    
    import { authenticate, User } from './api/authenticate';
    
    import { authorize } from './api/authorize';
    

我们从 React 中导入了useReducer来存储状态。我们还导入了HeaderMain组件,以便我们可以使用状态值来渲染它们。最后,我们导入了authenticateauthorize函数,因为我们将在该组件中创建登录处理程序。

  1. 在导入语句之后,添加一个状态类型并创建一个初始状态值的变量:

    type State = {
    
      user: undefined | User,
    
      permissions: undefined | string[],
    
      loading: boolean,
    
    };
    
    const initialState: State = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 接下来,为可以更新状态的不同的动作创建一个类型:

    type Action =
    
      | {
    
          type: "authenticate",
    
        }
    
      | {
    
          type: "authenticated",
    
          user: User | undefined,
    
        }
    
      | {
    
          type: "authorize",
    
        }
    
      | {
    
          type: "authorized",
    
          permissions: string[],
    
        };
    

"authenticate"动作将启动认证过程,当完成时发生"authenticated"。同样,"authorize"动作将启动授权过程,当完成时发生"authorized"

  1. 接下来,添加一个更新状态的reducer函数:

    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case "authenticate":
    
          return { ...state, loading: true };
    
        case "authenticated":
    
          return { ...state, loading: false, user: action.        user };
    
        case "authorize":
    
          return { ...state, loading: true };
    
        case "authorized":
    
          return {
    
            ...state,
    
            loading: false,
    
            permissions: action.permissions,
    
          };
    
        default:
    
          return state;
    
      }
    
    }
    

该函数接受现有状态和动作作为参数。该函数使用动作类型的 switch 语句在每个分支中创建状态的新版本。

  1. 现在,让我们按照以下方式定义App组件:

    function App() {
    
      const [{ user, permissions, loading }, dispatch] =
    
        useReducer(reducer, initialState);
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <Header
    
            user={user}
    
            onSignInClick={handleSignInClick}
    
            loading={loading}
    
          />
    
          <Main user={user} permissions={permissions} />
    
        </div>
    
      );
    
    }
    
    export default App;
    

该组件使用我们之前定义的reducer函数和initialState变量来useReducer。我们从useReducer中解构了userpermissionsloading状态值。在 JSX 中,我们渲染了HeaderMain组件,并将适当的状态值作为 props 传递。

  1. JSX 中的Header元素引用了一个名为handleSignInClick的处理程序,需要实现。在返回语句上方创建此处理程序,如下所示:

    async function handleSignInClick() {
    
      dispatch({ type: "authenticate" });
    
      const authenticatedUser = await authenticate();
    
      dispatch({
    
        type: "authenticated",
    
        user: authenticatedUser,
    
      });
    
      if (authenticatedUser !== undefined) {
    
        dispatch({ type: "authorize" });
    
        const authorizedPermissions = await authorize(
    
          authenticatedUser.id
    
        );
    
        dispatch({
    
          type: "authorized",
    
          permissions: authorizedPermissions,
    
        });
    
      }
    
    }
    

登录处理程序在过程中验证和授权用户,并分派必要的动作。

  1. 通过在终端中运行npm start来以开发模式运行应用程序。应用程序如图所示:

图 8.2 – 登录前的应用

图 8.2 – 登录前的应用

  1. 点击登录按钮。然后发生认证和授权过程,几秒钟后,出现以下屏幕:

图 8.3 – 登录后的应用

图 8.3 – 登录后的应用

这就完成了属性钻取方法。

这种方法的优点是简单,并且使用了我们已熟悉的 React 特性。这种方法的缺点是它强制所有在提供状态和访问状态的组件之间的组件都必须有一个该状态的 prop。因此,一些不需要访问状态的组件被迫访问它。例如,Main 组件 – permissions 状态被迫通过它传递到 Content 组件。

本节的关键点是,使用 props 在几个相邻组件之间共享状态是可以的,但不是在组件树中相隔甚远的许多组件之间共享的最佳选择。

接下来,保持应用运行,我们将探讨一个更适合在许多组件之间共享状态的解决方案。

使用 React 上下文

在本节中,我们将学习 React 中称为 上下文 的一个特性。然后,我们将从上一节重构应用以使用 React 上下文。

理解 React 上下文

React 上下文是一个对象,组件可以访问它。该对象可以包含状态值,因此它提供了一种在组件之间共享状态的机制。

使用 createContext 函数创建上下文,如下所示:

const SomeContext = createContext<ContextType>(defaultValue);

必须将上下文的默认值传递给 createContext。它还有一个泛型类型参数,用于表示由 createContext 创建的对象的类型。

上下文还包含一个 Provider 组件,需要将其放置在组件树中需要访问上下文对象的组件之上。可以创建一个包装器组件来存储共享状态,并将其传递给上下文 Provider 组件,如下所示:

export function SomeProvider({ children }: Props) {
  const [someState, setSomeState] = useState(initialState);
  return (
    <SomeContext.Provider value={{ someState }}>
      {children}
    </SomeContext.Provider>
  );
}

在前面的例子中使用了 useState 来处理状态,但也可以使用 useReducer

提供者包装器组件可以适当地放置在组件树中,位于需要共享状态的组件之上:

function App() {
  return (
    <SomeProvider>
      <Header />
      <Main />
    </SomeProvider>
  );
}

React 还包含一个 useContext 钩子,可以用来使上下文值可以作为钩子被消费,如下所示:

const { someState } = useContext(SomeContext);

必须将上下文传递给 useContext,并可以从上下文对象的结果中解构属性。

因此,想要访问共享状态的组件可以使用 useContext 如下访问:

export function SomeComponent() {
  const { someState } = useContext(SomeContext);
  return <div>I have access to {someState}</div>;
}

关于 React 上下文的更多信息,请参阅以下链接:reactjs.org/docs/context.html

现在我们已经了解了 React 上下文,我们将在上一节创建的应用中使用它。

使用 React 上下文

我们将从上一节开始重构应用,以使用 React 上下文。我们首先创建一个包含上下文和提供者包装器的文件。然后,在提供者包装器中使用 useReducer 来存储状态。我们还将创建一个 useContext 的包装器,以便更容易地消费它。

因此,要完成此操作,请执行以下步骤:

  1. 首先在 src 文件夹中创建一个名为 AppContext.tsx 的文件。这将包含上下文、提供者包装器和 useContext 包装器。

  2. 将以下导入语句添加到 AppContext.tsx 中:

    import {
    
      createContext,
    
      useContext,
    
      useReducer,
    
      ReactNode,
    
    } from 'react';
    
    import { User } from './api/authenticate';
    

我们已经从 React 导入了我们需要的所有函数,包括我们将需要的用于提供者包装器 children 属性的 ReactNode 类型。我们还导入了 User 类型,这是我们需要的用户状态类型。

  1. 我们需要添加一个状态类型和一个初始状态值的变量。我们已经在 App.tsx 中有了这些,所以以下行可以从 App.tsx 移动到 AppContext.tsx

    type State = {
    
      user: undefined | User,
    
      permissions: undefined | string[],
    
      loading: boolean,
    
    };
    
    const initialState = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 类似地,Action 类型以及 reducer 函数可以从 App.tsx 移动到 AppContext.tsx。以下是移动的代码行:

    type Action =
    
      | {
    
          type: "authenticate",
    
        }
    
      | {
    
          type: "authenticated",
    
          user: User | undefined,
    
        }
    
      | {
    
          type: "authorize",
    
        }
    
      | {
    
          type: "authorized",
    
          permissions: string[],
    
        };
    
    function reducer(state: State, action: Action): State {
    
      switch (action.type) {
    
        case "authenticate":
    
          return { ...state, loading: true };
    
        case "authenticated":
    
          return { ...state, loading: false, user: action.        user };
    
        case "authorize":
    
          return { ...state, loading: true };
    
        case "authorized":
    
          return { ...state, loading: false, permissions:         action.permissions };
    
        default:
    
          return state;
    
      }
    
    }
    

注意,在移动此函数后,App.tsx 文件将引发编译错误。我们将在下一组指令中解决这个问题。

  1. 接下来,我们将在 AppContext.tsx 中创建一个上下文类型:

    type AppContextType = State & {
    
      dispatch: React.Dispatch<Action>,
    
    };
    

上下文将包含状态值和一个用于分发操作的 dispatch 函数。

  1. 现在,我们可以创建上下文,如下所示:

    const AppContext = createContext<AppContextType>({
    
      ...initialState,
    
      dispatch: () => {},
    
    });
    

我们将上下文命名为 AppContext。我们使用 initialState 变量和虚拟的 dispatch 函数作为默认上下文值。

  1. 接下来,我们可以实现提供者包装器,如下所示:

    type Props = {
    
      children: ReactNode;
    
    };
    
    export function AppProvider({ children }: Props) {
    
      const [{ user, permissions, loading }, dispatch] =
    
        useReducer(reducer, initialState);
    
      return (
    
        <AppContext.Provider
    
          value={{
    
            user,
    
            permissions,
    
            loading,
    
            dispatch,
    
          }}
    
        >
    
          {children}
    
        </AppContext.Provider>
    
      );
    
    }
    

我们将组件命名为 AppProvider,它返回上下文的 Provider 组件,包含状态值和 dispatch 函数。

  1. AppContext.tsx 中最后要做的就是创建一个 useContext 的包装器,如下所示:

    export const useAppContext = () => useContext(AppContext);
    

这就完成了我们在 AppContext.tsx 中需要做的所有工作。

因此,AppContext.tsx 导出 AppProvider 组件,可以放置在组件树中的 HeaderMain 之上,以便它们可以访问用户和权限信息。AppContext.tsx 还导出 useAppContext,以便 HeaderMainContent 组件可以使用它来获取访问用户和权限信息。

现在,执行以下步骤以对 AppHeaderMainContent 组件进行必要的更改,以便从 AppContext 访问用户和权限信息:

  1. 我们将从 Header.tsx 开始。首先导入 authenticateauthorizeuseAppContext 函数。同时,移除 User 类型以及 Header 组件的属性:

    import { authenticate } from './api/authenticate';
    
    import { authorize } from './api/authorize';
    
    import { useAppContext } from './AppContext';
    
    export function Header() {
    
      return ...
    
    }
    
  2. Header 将现在处理登录过程,而不是 App。因此,将 App.tsx 中的 handleSignInClick 处理器移动到 Header.tsx,并将其放置在返回语句之上,如下所示:

    export function Header() {
    
      async function handleSignInClick() {
    
        dispatch({ type: 'authenticate' });
    
        const authenticatedUser = await authenticate();
    
        dispatch({
    
          type: 'authenticated',
    
          user: authenticatedUser,
    
        });
    
        if (authenticatedUser !== undefined) {
    
          dispatch({ type: 'authorize' });
    
          const authorizedPermissions = await authorize(
    
            authenticatedUser.id
    
          );
    
          dispatch({
    
            type: 'authorized',
    
            permissions: authorizedPermissions,
    
          });
    
        }
    
      }
    
      return ...
    
    }
    
  3. 更新登录点击处理器以引用我们刚刚添加的函数:

    <button
    
      onClick={handleSignInClick}
    
      className=...
    
      disabled={loading}
    
    >
    
      {loading ? '...' : 'Sign in'}
    
    </button>
    
  4. Header.tsx 中最后要做的就是从上下文中获取 userloadingdispatch。在组件顶部添加以下对 useAppContext 的调用:

    export function Header() {
    
      const { user, loading, dispatch } = useAppContext();
    
      ...
    
    }
    
  5. 让我们转到 Main.tsx。移除对 User 类型的导入语句,并添加对 useAppContext 的导入语句:

    import { Content } from './Content';
    
    import { useAppContext } from './AppContext';
    
  6. 移除 Main 组件的属性,并从 useAppContext 获取 user

    export function Main() {
    
      const { user } = useAppContext();
    
      return ...
    
    }
    
  7. Main 中的 JSX 中,移除 Content 元素的 permissions 属性:

    <Content />
    
  8. 现在,打开 Content.tsx 并添加一个 useAppContext 的导入语句:

    import { useAppContext } from './AppContext';
    
  9. 移除 Content 组件的属性,并从 useAppContext 获取 permissions

    export function Content() {
    
      const { permissions } = useAppContext();
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return ...
    
    }
    
  10. 最后,我们将修改 App.tsx。移除除了 HeaderMain 之外的所有导入语句,并添加一个 AppProvider 的导入语句:

    import { Header } from './Header';
    
    import { Main } from './Main';
    
    import { AppProvider } from './AppContext';
    
  11. 仍然在 App.tsx 中,移除对 useReducer 的调用,并移除传递给 HeaderMain 的所有属性:

    function App() {
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <Header />
    
          <Main />
    
        </div>
    
      );
    
    }
    
  12. AppProvider 包裹在 HeaderMain 旁边,以便它们可以访问上下文:

    function App() {
    
      return (
    
        <div className="max-w-7xl mx-auto px-4">
    
          <AppProvider>
    
            <Header />
    
            <Main />
    
          </AppProvider>
    
        </div>
    
      );
    
    }
    

现在编译错误将被解决,运行中的应用将看起来和表现如前。

  1. 通过按 Ctrl + C 停止应用运行。

这完成了将应用重构为使用 React 上下文而不是属性钻取的过程。

与属性钻取相比,React 上下文需要编写更多的代码。然而,它允许组件使用钩子而不是通过属性在组件之间传递来访问共享状态。这是一个优雅的共享状态解决方案,尤其是在许多组件共享状态时。

接下来,我们将了解一个流行的第三方库,它可以用来共享状态。

使用 Redux

在本节中,我们将在使用 Redux 之前了解 Redux,并将其用于重构我们一直在工作的应用。

理解 Redux

Redux 是一个成熟的州管理库,它最初于 2015 年发布。它在 React 上下文之前发布,并成为共享状态管理的一种流行方法。

创建存储

在 Redux 中,状态存在于一个称为 useReducer 的集中式不可变对象中,存储中的状态通过分发 reducer 函数来更新,该函数创建状态的新版本。

在过去,需要大量的代码来设置 Redux 存储,并在 React 组件中消耗它。今天,一个名为 Redux Toolkit 的伴侣库减少了使用 Redux 所需的代码。可以使用 Redux Toolkit 的 configureStore 函数创建 Redux 存储,如下所示:

export const store = configureStore({
  reducer: {
    someFeature: someFeatureReducer,
    anotherFeature: anotherFeatureReducer
  },
});

configureStore 函数接收存储的还原器。应用中的每个功能都可以有自己的状态区域和还原器来改变状态。不同的状态区域通常被称为 someFeatureanotherFeature

Redux Toolkit 有一个用于创建切片的函数,称为 createSlice

export const someSlice = createSlice({
  name: "someFeature",
  initialState,
  reducers: {
    someAction: (state) => {
      state.someValue = "something";
    },
    anotherAction: (state) => {
      state.someOtherValue = "something else";
    },
  },
});

createSlice 函数接收一个包含切片名称、初始状态以及处理不同动作和更新状态的函数的对象参数。

createSlice 创建的切片包含一个包装动作处理器的 reducer 函数。当创建存储时,可以在 configureStorereducer 属性中引用此 reducer 函数:

export const store = configureStore({
  reducer: {
    someFeature: someSlice.reducer,
    ...
  },
});

在前面的代码片段中,someSlice 的还原器已被添加到存储中。

向 React 组件提供存储

Redux 存储通过其 Provider 组件在组件树中定义。Provider 组件上的 value 需要指定 Redux 存储(来自 configureStore)。Provider 组件必须放置在需要访问存储的组件之上:

<Provider store={store}>
  <SomeComponent />
  <AnotherComponent />
</Provider>

在前面的例子中,SomeComponentAnotherComponent 可以访问存储。

从组件中访问存储

组件可以使用 React Redux 的 useSelector 钩子从 Redux 存储访问状态。一个选择存储中相关状态的功能被传递到 useSelector

const someValue = useSelector(
  (state: RootState) => state.someFeature.someValue
);

在前面的例子中,someValue是从存储中的someFeature切片中选择的。

从组件向存储分发动作

React Redux 还有一个名为 useDispatch 的钩子,它返回一个 dispatch 函数,可以用来分发动作。动作是从使用 createSlice 创建的切片中创建的函数:

const dispatch = useDispatch();
return (
  <button onClick={() => dispatch(someSlice.actions.someAction())}>
    Some button
  </button>
);

在前面的例子中,当按钮被点击时,someSlice 中的 someAction 被分发。

更多关于 Redux 的信息,请参阅以下链接:redux.js.org/。有关 Redux Toolkit 的更多信息,请参阅以下链接:redux-toolkit.js.org/

现在我们已经了解了 Redux,我们将在上一节创建的应用中使用它。

安装 Redux

首先,我们必须将 Redux 和 Redux Toolkit 安装到我们的项目中。在终端中运行以下命令:

npm i @reduxjs/toolkit react-redux

这将安装我们需要的所有 Redux 组件,包括其 TypeScript 类型。

使用 Redux

现在,我们可以重构应用程序以使用 Redux 而不是 React 上下文。首先,我们将创建一个用于用户信息的 Redux slice,然后再创建一个包含此切片的 Redux 存储。然后,我们将继续将存储添加到 React 组件树中,并在 HeaderMainContent 组件中消费它。

创建 Redux Slice

我们将首先创建一个用于用户状态的 Redux slice。执行以下步骤:

  1. src文件夹中创建一个名为store的文件夹,然后在其中创建一个名为userSlice.ts的文件。

  2. 将以下导入语句添加到 userSlice.ts

    import { createSlice } from '@reduxjs/toolkit';
    
    import type { PayloadAction } from '@reduxjs/toolkit';
    
    import { User } from '../api/authenticate';
    

我们最终将使用 createSlice 创建 Redux slice。PayloadAction 是一个我们可以用于动作对象的类型。在定义状态类型时,我们需要 User 类型。

  1. 将以下 State 类型及其初始状态值从 AppContext.tsx 复制到 userSlice.ts

    type State = {
    
      user: undefined | User;
    
      permissions: undefined | string[];
    
      loading: boolean;
    
    };
    
    const initialState: State = {
    
      user: undefined,
    
      permissions: undefined,
    
      loading: false,
    
    };
    
  2. 接下来,按照以下方式在 userSlice.ts 中开始创建切片:

    export const userSlice = createSlice({
    
      name: 'user',
    
      initialState,
    
      reducers: {
    
      }
    
    });
    

我们已将切片命名为 user 并传递了初始状态值。我们导出切片,以便以后可以用来创建 Redux 存储。

  1. 现在,在 reducers 对象内部定义以下动作处理程序:

    reducers: {
    
      authenticateAction: (state) => {
    
        state.loading = true;
    
      },
    
      authenticatedAction: (
    
        state,
    
        action: PayloadAction<User | undefined>
    
      ) => {
    
        state.user = action.payload;
    
        state.loading = false;
    
      },
    
      authorizeAction: (state) => {
    
        state.loading = true;
    
      },
    
      authorizedAction: (
    
        state,
    
        action: PayloadAction<string[]>
    
      ) => {
    
        state.permissions = action.payload;
    
        state.loading = false;
    
      }
    
    }
    

每个动作处理程序都会更新所需的状态。PayloadAction 用于动作参数的类型。PayloadAction 是一个带有动作有效负载类型的泛型类型。

  1. 最后,从切片中导出动作处理程序和reducer函数:

    export const {
    
      authenticateAction,
    
      authenticatedAction,
    
      authorizeAction,
    
      authorizedAction,
    
    } = userSlice.actions;
    
    export default userSlice.reducer;
    

reducer函数使用了默认导出,因此消费者可以按需命名它。

这样就完成了 Redux 切片的实现。

创建 Redux 存储

接下来,让我们创建 Redux 存储。执行以下步骤:

  1. store文件夹中创建一个名为store.ts的文件,包含以下导入语句:

    import { configureStore } from '@reduxjs/toolkit';
    
    import userReducer from './userSlice';
    
  2. 接下来,使用configureStore函数创建存储,引用我们之前创建的 reducer:

    export const store = configureStore({
    
      reducer: { user: userReducer }
    
    });
    

我们导出store变量,以便我们可以在以后在 React Redux 的Provider组件中使用它。

  1. store.ts中最后要做的就是在 Redux 的全状态对象中导出类型,我们最终会在消费 Redux 存储的组件中的useSelector钩子中需要这个类型:

    export type RootState = ReturnType<typeof store.getState>;
    

ReturnType是 TypeScript 的一个标准实用工具类型,它返回传递给它的函数类型的返回类型。Redux 存储中的getState函数返回完整的状态对象。因此,我们使用ReturnType来推断完整状态对象的类型,而不是显式地定义它。

这样就完成了 Redux 存储的实现。

将 Redux 存储添加到组件树中

接下来,我们将使用 React Redux 的Provider组件在组件树中的适当位置添加存储。遵循以下步骤:

  1. 打开App.tsx并移除AppContext导入语句。同时移除AppContext.tsx文件,因为现在不再需要它。

  2. 从 React Redux 导入Provider组件和我们创建的 Redux 存储的导入语句:

    import { Provider } from 'react-redux';
    
    import { store } from './store/store';
    
  3. 在 JSX 中将AppProvider替换为Provider,如下所示:

    <div className="max-w-7xl mx-auto px-4">
    
      <Provider store={store}>
    
        <Header />
    
        <Main />
    
      </Provider>
    
    </div>
    

我们将导入的 Redux 存储传递给Provider

现在 Redux 存储对HeaderMainContent组件都是可访问的。

在组件中消费 Redux 存储

我们现在将 Redux 存储集成到HeaderMainContent组件中。这将替换之前的 React 上下文消费代码。遵循以下步骤:

  1. 首先打开Header.tsx并移除AppContext导入语句。

  2. Header.tsx中添加以下导入语句:

    import { useSelector, useDispatch } from 'react-redux';
    
    import type { RootState } from './store/store';
    
    import {
    
      authenticateAction,
    
      authenticatedAction,
    
      authorizeAction,
    
      authorizedAction,
    
    } from './store/userSlice';
    

我们将引用 Redux 中的状态以及分发动作,因此我们导入了useSelectoruseDispatchRootState类型是我们最终将传递给useSelector的函数中所需的。我们还导入了我们创建的切片中的所有动作,因为我们将在修订后的登录处理程序中需要它们。

  1. Header组件内部,将useAppContext调用替换为useSelector调用以获取所需的状态:

    export function Header() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      const loading = useSelector(
    
        (state: RootState) => state.user.loading
    
      );
    
      async function handleSignInClick() {
    
        ...
    
      }
    
      return ...
    
    }
    
  2. 同时,调用useDispatch来获取dispatch函数:

    export function Header() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      const loading = useSelector(
    
        (state: RootState) => state.user.loading
    
      );
    
      const dispatch = useDispatch();
    
      async function handleSignInClick() {
    
        ...
    
      }
    
      return ...
    
    }
    
  3. Header.tsx中最后要做的就是在handleSignInClick中修改以引用 Redux 切片中的动作函数:

    async function handleSignInClick() {
    
      dispatch(authenticateAction());
    
      const authenticatedUser = await authenticate();
    
      dispatch(authenticatedAction(authenticatedUser));
    
      if (authenticatedUser !== undefined) {
    
        dispatch(authorizeAction());
    
        const authorizedPermissions = await authorize(
    
          authenticatedUser.id
    
        );
    
        dispatch(authorizedAction(authorizedPermissions));
    
      }
    
    }
    
  4. 现在,打开Main.tsx并将AppContext导入语句替换为useSelectorRootState类型的导入语句:

    import { useSelector } from 'react-redux';
    
    import { RootState } from './store/store';
    
  5. 将对useAppContext的调用替换为对useSelector的调用以获取user状态值:

    export function Main() {
    
      const user = useSelector(
    
        (state: RootState) => state.user.user
    
      );
    
      return ...
    
    }
    
  6. 接下来,打开Content.tsx,并将AppContext导入语句替换为对useSelectorRootState类型的导入语句:

    import { useSelector } from 'react-redux';
    
    import { RootState } from './store/store';
    
  7. 将对useAppContext的调用替换为对useSelector的调用,以获取permissions状态值:

    export function Content() {
    
      const permissions = useSelector(
    
        (state: RootState) => state.user.permissions
    
      );
    
      if (permissions === undefined) {
    
        return null;
    
      }
    
      return ...
    
    }
    
  8. 通过在终端中运行npm start来运行应用程序。应用程序的外观和行为将与之前一样。

这就完成了将应用程序重构为使用 Redux 而不是 React 上下文的过程。

这里是使用 Redux 的关键点的回顾:

  • 状态存储在中央存储中

  • 状态通过分发由 reducer 处理的动作来更新

  • 需要适当地在组件树中放置Provider组件,以便组件可以访问 Redux 存储

  • 组件可以使用useSelector钩子选择状态,并使用useDispatch钩子分发动作

正如您所经历的,即使使用 Redux Toolkit,在使用 Redux 管理状态时也需要许多步骤。对于简单的状态管理需求来说,这有点过度,但在有大量共享应用程序级状态时却非常出色。

摘要

在本章中,我们构建了一个包含需要共享状态的组件的小型单页应用程序。我们首先使用现有的知识,并使用属性在组件之间传递状态。我们了解到这种方法的一个问题是,不需要访问状态的组件被迫访问它,如果其子组件需要访问它的话。

我们继续学习 React 上下文,并将应用程序重构为使用它。我们了解到 React 上下文可以使用useStateuseReducer存储状态。然后,可以通过上下文的Provider组件将状态提供给树中的组件。然后,组件通过useContext钩子访问上下文状态。我们发现这比通过属性传递状态要好得多,尤其是当许多组件需要访问状态时。

接下来,我们学习了 Redux,它与 React 上下文类似。一个区别是,只能有一个包含状态的 Redux 存储,但可以有多个 React 上下文。我们了解到需要将Provider组件添加到组件树中,以便组件可以访问 Redux 存储。组件使用useSelector钩子选择状态,并使用useDispatch钩子分发动作。然后,reducer 处理动作并相应地更新状态。

在下一章中,我们将学习如何在 React 中与 REST API 一起工作。

问题

回答以下问题以检查您在本章中学到的内容:

  1. 我们定义了一个上下文,如下所示,以保存应用程序的主题状态:

    type Theme = {
    
      name: string;
    
      color: 'dark' | 'light';
    
    };
    
    type ThemeContextType = Theme & {
    
      changeTheme: (
    
        name: string,
    
        color: 'dark' | 'light'
    
      ) => void;
    
    };
    
    const ThemeContext = createContext<ThemeContextType>();
    

尽管代码可以编译,但问题是什么?

  1. 问题 1 的上下文中有一个名为ThemeProvider的提供者包装器,它被添加到组件树中,如下所示:

    <ThemeProvider>
    
      <Header />
    
      <Main />
    
    </ThemeProvider>
    
    <Footer />
    

当在Footer组件中使用useContext解构时,主题状态是undefined。问题是什么?

  1. 在应用程序中是否可以有两个 React 上下文?

  2. 在应用程序中是否可以有两个 Redux 存储?

  3. 以下代码分发了一个动作来更改主题:

    function handleChangeTheme({ name, color }: Theme) {
    
      useDispatch(changeThemeAction(name, color));
    
    }
    

这段代码存在问题。问题是什么?

  1. 在一个 React 组件中,是否可以使用 useState 以及来自 Redux 存储的状态来仅使用本组件所需的状态?

  2. 在本章中,当我们实现 Redux 切片时,动作处理程序似乎直接更新了状态,如下例所示:

    authorizedAction: (
    
      state,
    
      action: PayloadAction<string[]>
    
    ) => {
    
      state.permissions = action.payload;
    
      state.loading = false;
    
    }
    

为什么我们可以修改状态?我以为 React 中的状态必须是不可变的?

答案

  1. 在使用 TypeScript 时,createContext 必须传递一个默认值。以下是修正后的代码:

    const ThemeContext = createContext<ThemeContextType>({
    
      name: 'standard',
    
      color: 'light',
    
      changeTheme: (name: string, color: 'dark' | 'light') => {},
    
    });
    
  2. Footer 必须按照以下方式放置在 ThemeProvider 内:

    <ThemeProvider>
    
      <Header />
    
      <Main />
    
      <Footer />
    
    </ThemeProvider>
    
  3. 是的,在应用中 React 上下文的数量没有限制。

  4. 不,一个应用中只能添加一个 Redux 存储。

  5. useDispatch 不能直接用来分发动作——它返回一个函数,可以用来分发动作:

    const dispatch = useDispatch();
    
    function handleChangeTheme({ name, color }: Theme) {
    
      dispatch(changeThemeAction(name, color));
    
    }
    
  6. 是的,使用 useStateuseReducer 定义的本地状态可以与来自 Redux 存储的共享状态一起使用。

  7. Redux Toolkit 使用一个名为 state 对象的库,而不对其进行修改。有关 immer 的更多信息,请参阅以下链接:github.com/immerjs/immer

第九章:与 RESTful API 交互

在本章中,我们将构建一个页面,该页面列出从 REST API 获取的博客文章,以及一个表单,用于将博客文章提交到 REST API。通过这种方式,我们将了解从 React 组件与 REST API 交互的各种方法。

第一种方法将是使用 React 的 useEffect 钩子和浏览器的 fetch 函数。作为此过程的一部分,我们将学习如何使用类型断言函数来为来自 REST API 的数据提供强类型。然后,我们将使用 React Router 的数据加载功能并体验其优势。之后,我们将转向使用一个流行的库,即 React Query,并体验其优势,最后我们将结合使用 React Query 和 React Router 以获得这两个库的最佳效果。

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

  • 设置环境

  • 使用 effect 钩子与 fetch 结合使用

  • 使用 fetch 发送数据

  • 使用 React Router

  • 使用 React Query

  • 使用 React Router 和 React Query 结合

技术要求

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

本章中所有的代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter9.

创建项目

在本节中,我们将首先创建我们将要构建的应用程序项目。然后,我们将为应用程序创建一个 REST API 以供其消费。

设置项目

我们将使用 Visual Studio Code 开发应用程序,并需要一个基于 Create React App 的新项目设置。我们之前已经多次介绍过这一点,因此在本章中我们将不介绍这些步骤——相反,请参阅 第三章设置 React 和 TypeScript

我们将使用 Tailwind CSS 来设计应用程序的样式。我们之前在 第五章前端样式方法 中介绍了如何安装和配置 Tailwind。因此,在您创建了 React 和 TypeScript 项目之后,请安装并配置 Tailwind。

我们将使用 React Router 来加载数据,因此请参阅第六章使用 React Router 进行路由,了解如何进行此操作。

我们将使用 @tailwindcss/forms 插件来设计表单。请参阅 第七章与表单一起工作,以回顾如何实现这些。

理解组件结构

应用程序将是一个单页应用程序,其中包含一个位于现有文章列表上方的添加新文章的表单。应用程序将分为以下组件:

图 9.1 – 应用组件结构

图 9.1 – 应用组件结构

这里是这些组件的描述:

  • PostsPage 将通过引用 NewPostFormPostsLists 组件来渲染整个页面。它还将与 REST API 交互。

  • NewPostForm 将渲染一个表单,允许用户输入新的博客文章。这将使用 ValidationError 组件来渲染验证错误消息。ValidationError 组件将与在 第七章 中创建的相同。

  • PostsList 将渲染博客文章列表。

好的,现在我们知道了组件结构,让我们创建 REST API。

创建 REST API

我们将使用一个名为 JSON Server 的工具创建 REST API,它允许快速创建 REST API。通过运行以下命令安装 JSON Server:

npm i -D json-server

然后,我们在 JSON 文件中定义 API 后面的数据。在项目的根目录中创建一个名为 db.json 的文件,包含以下内容:

{
  "posts": [
    {
      "title": "Getting started with fetch",
      "description": "How to interact with backend APIs using         fetch",
      "id": 1
    },
    {
      "title": "Getting started with useEffect",
      "description": "How to use React's useEffect hook for         interacting with backend APIs",
      "id": 2
    }
  ]
}

前面的 JSON 表示 API 后面的数据最初将包含两篇博客文章(此代码片段可以从 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter9/useEffect-fetch/db.json 复制)。

现在我们需要定义一个 npm 脚本来启动 JSON 服务器并处理请求。打开 package.json 并添加一个名为 server 的脚本,如下所示:

{
  ...,
  "scripts": {
    ...,
    "server": "json-server --watch db.json --port 3001 --delay       2000"
  },
  ...
}

该脚本启动 JSON 服务器并监视我们刚刚创建的 JSON 文件。我们指定 API 在端口 3001 上运行,以免与在端口 3000 上运行的 app 冲突。我们还通过添加 2 秒的延迟来减缓 API 响应,这将帮助我们看到数据何时从 React 应用程序中获取。

在终端中,通过运行我们刚刚创建的脚本启动 API,如下所示:

npm run server

几秒钟后,API 启动。为了检查 API 是否正常工作,打开浏览器并输入以下地址:http://localhost:3001/posts。博客文章数据应如下显示在浏览器中:

图 9.2 – 博客文章 REST API

图 9.2 – 博客文章 REST API

更多关于 JSON Server 的信息,请参阅以下链接:github.com/typicode/json-server

现在项目已经设置好了 REST API,保持 API 运行,接下来,我们将学习如何使用 useEffect 与 REST API 交互。

使用 effect 钩子与 fetch 一起使用

在本节中,我们将创建一个页面,列出我们从刚刚创建的 REST API 返回的博客文章。我们将使用浏览器的 fetch 函数和 React 的 useEffect 钩子与 REST API 交互。

使用 fetch 获取博客文章

我们将首先创建一个函数,使用浏览器的 fetch 函数从 REST API 获取博客文章;我们将 API URL 存储在一个环境变量中。为此,执行以下步骤:

  1. 将使用相同的 URL 来获取以及保存新的博客文章到 REST API。我们将把这个 URL 存储在一个环境变量中。因此,在项目的根目录中创建一个名为 .env 的文件,包含以下变量:

    REACT_APP_API_URL = http://localhost:3001/posts/
    

这个环境变量在构建时注入到代码中,可以通过 process.env.REACT_APP_API_URL 被代码访问。Create React App 项目的环境变量必须以 React_APP_ 为前缀。有关环境变量的更多信息,请参阅以下链接:create-react-app.dev/docs/adding-custom-environment-variables/

  1. 现在,在 src 文件夹中创建一个名为 posts 的文件夹,用于存放所有博客文章功能的文件。

  2. posts 文件夹中创建一个名为 getPosts.ts 的文件。在这个文件中,添加以下获取博客文章的函数:

    export async function getPosts() {
    
      const response = await fetch(
    
        process.env.REACT_APP_API_URL!
    
      );
    
      const body = await response.json()
    
      return body;
    
    }
    

fetch 函数有一个用于 REST API URL 的参数。我们使用了 REACT_APP_API_URL 环境变量来指定这个 URL。环境变量值可以是 undefined,但我们知道这不是情况,所以我们在其后添加了一个 !)。

注意

非空断言操作符是 TypeScript 中的一个特殊操作符。它用于通知 TypeScript 编译器它前面的表达式不能是 nullundefined

fetch 返回一个 Response 对象,我们调用它的 json 方法来获取以 JSON 格式的响应体。json 方法是异步的,因此我们需要 await 它。

关于 fetch 的更多信息,请参阅以下链接:developer.mozilla.org/en-US/docs/Web/API/Fetch_API

这完成了 getPosts 的初始版本。然而,getPosts 的返回值类型目前是 any,这意味着不会对其进行类型检查。我们将在下一步改进这一点。

强类型响应数据

第二章 介绍 TypeScript 中,我们学习了如何使用 unknown 类型和使用类型谓词来对未知数据进行强类型化。我们将使用 unknown 类型与一个名为 getPosts 的 TypeScript 特性一起使用。执行以下步骤:

  1. 向 JSON 响应添加类型断言,以便 body 变量具有 unknown 类型:

    export async function getPosts() {
    
      const response = await fetch(postsUrl);
    
      const body = (await response.json()) as unknown;
    
      return body;
    
    }
    
  2. 接下来,在 getPosts 下方添加以下类型断言函数:

    export function assertIsPosts(
    
      postsData: unknown
    
        ): asserts postsData is PostData[] {
    
    }
    

注意返回类型注解:asserts postsData is PostData[]。如果没有错误发生,这意味着 postsData 参数是 PostData[] 类型。

不要担心 PostData 被引用时的编译错误 - 我们将在第 8 步创建 PostData 类型。

  1. 让我们继续实现 assertIsPosts。它将对 postsData 参数进行一系列检查,如果检查失败,它将抛出异常。通过检查 postsData 是否为数组来开始实现:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      if (!Array.isArray(postsData)) {
    
        throw new Error("posts isn't an array");
    
      }
    
      if (postsData.length === 0) {
    
        return;
    
      }
    
    }
    
  2. 现在,让我们检查数组项是否具有 id 属性:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      ...
    
      postsData.forEach((post) => {
    
        if (!('id' in post)) {
    
          throw new Error("post doesn't contain id");
    
        }
    
        if (typeof post.id !== 'number') {
    
          throw new Error('id is not a number');
    
        }
    
      });
    
    }
    

我们使用数组的 forEach 方法遍历所有文章。在循环内部,我们使用 in 操作符检查 id 属性是否存在。我们还使用 typeof 操作符检查 id 值是否为 number 类型。

  1. 我们可以对 titledescription 属性执行类似的检查:

    export function assertIsPosts(
    
      postsData: unknown
    
    ): asserts postsData is PostData[] {
    
      …
    
      postsData.forEach((post) => {
    
        ...
    
        if '!('ti'le' in post)) {
    
          throw new Err"r("post do'sn't contain ti"le");
    
        }
    
        if (typeof post.title !'= 'str'ng') {
    
          throw new Err'r('title is not a str'ng');
    
        }
    
        if '!('descript'on' in post)) {
    
          throw new Err"r("post do'sn't contain         descript"on");
    
        }
    
        if (typeof post.description !'= 'str'ng') {
    
          throw new Err'r('description is not a str'ng');
    
        }
    
      });
    
    }
    

这完成了类型断言函数的实现。

  1. 返回到 getPosts,添加对 assert 函数的调用:

    export async function getPosts() {
    
      const response = await fetch(postsUrl);
    
      const body = (await response.json()) as unknown;
    
      assertIsPosts(body);
    
      return body;
    
    }
    

在成功调用 assertIsPosts 之后,body 变量现在将是 PostData[] 类型。您可以在返回语句中悬停在 body 变量上以确认这一点。

  1. 最终步骤是添加 PostData 类型。在 getPosts.ts 的顶部添加以下导入语句以导入 PostData

    import { PostData } from './types';
    

由于 types 文件尚不存在,文件仍将存在编译错误 - 我们将在下一步中完成此操作。

  1. posts 文件夹中添加一个名为 types.ts 的文件,并包含以下 PostData 类型的定义:

    export type PostData = {
    
      id: number;
    
      title: string;
    
      description: string;
    
    };
    

这种类型表示来自 REST API 的博客文章。

现在,我们有一个强类型函数,可以从 REST API 获取博客文章。接下来,我们将创建一个 React 组件来列出博客文章。

创建博客文章列表组件

我们将创建一个 React 组件,它接受博客文章数据并将其以列表形式渲染。执行以下步骤:

  1. posts 文件夹中创建一个名为 PostsList.tsx 的文件,并包含以下导入语句:

    import { PostData } from './types';
    
  2. 接下来,开始实现组件,如下所示:

    type Props = {
    
      posts: PostData[];
    
    };
    
    export function PostsList({ posts }: Props) {
    
    }
    

组件有一个名为 posts 的属性,它将包含博客文章。

  1. 现在,按照以下方式在无序列表中渲染博客文章:

    export function PostsList({ posts }: Props) {
    
      return (
    
        <ul className="list-none">
    
          {posts.map((post) => (
    
            <li key={post.id} className="border-b py-4">
    
              <h3 className="text-slate-900 font-bold">
    
                {post.title}
    
              </h3>
    
              <p className=" text-slate-900 ">{post.description}</p>
    
            </li>
    
          ))}
    
        </ul>
    
      );
    
    }
    

Tailwind CSS 类在具有粗体标题的博客文章之间添加灰色线条。

这完成了 PostsList 组件。接下来,我们将创建一个引用 PostsList 组件的页面组件。

创建博客文章页面组件

我们将创建一个博客文章页面组件,该组件使用 getPosts 函数获取博客文章数据,并使用我们刚刚创建的 PostsList 组件进行渲染。执行以下步骤:

  1. posts 文件夹的组件中创建一个名为 PostsPage.tsx 的文件,并包含以下导入语句:

    import { useEffect, useState } from 'react';
    
    import { getPosts } from './getPosts';
    
    import { PostData } from './types';
    
    import { PostsList } from './PostsList';
    

我们已导入 getPosts 函数、PostList 组件以及我们在上一节中创建的 PostData 类型。我们还导入了来自 React 的 useStateuseEffect 钩子。我们将使用 React 状态来存储博客文章,并使用 useEffect 在页面组件挂载时调用 getPosts

  1. 通过定义博客文章的状态以及它们是否正在被获取来开始实现页面组件。

    export function PostsPage() {
    
      const [isLoading, setIsLoading] = useState(true);
    
      const [posts, setPosts] = useState<PostData[]>([]);
    
    }
    
  2. 接下来,使用 useEffect 钩子调用 getPosts 函数,如下所示:

    export function PostsPage() {
    
      …
    
      useEffect(() => {
    
        let cancel = false;
    
        getPosts().then((data) => {
    
          if (!cancel) {
    
            setPosts(data);
    
            setIsLoading(false);
    
          }
    
        });
    
        return () => {
    
          cancel = true;
    
        };
    
      }, []);
    
    }
    

我们在调用 getPosts 时使用较旧的 promise 语法,因为较新的 async/await 语法不能直接在 useEffect 中使用。

如果在 getPosts 调用仍在进行时卸载 PostsPage 组件,设置 dataisLoading 状态变量将导致错误。因此,我们使用了一个 cancel 标志来确保在设置 dataisLoading 状态变量时组件仍然挂载。

我们还指定了一个空数组作为效果依赖项,以便效果仅在组件挂载时运行。

  1. useEffect 调用之后,在数据获取期间添加一个加载指示器:

    export function PostsPage() {
    
      ...
    
      useEffect(...);
    
      if (isLoading) {
    
        return (
    
          <div className="w-96 mx-auto mt-6">
    
            Loading ...
    
          </div>
    
        );
    
      }
    
    }
    

Tailwind CSS 类将加载指示器水平放置在页面中心,并在其上方留有一点边距。

  1. 最后,在条件加载指示器之后渲染页面标题和帖子列表:

    export function PostsPage() {
    
      ...
    
      if (isLoading) {
    
        return (
    
          <div className="w-96 mx-auto mt-6">
    
            Loading ...
    
          </div>
    
        );
    
      }
    
      return (
    
        <div className="w-96 mx-auto mt-6">
    
          <h2 className="text-xl text-slate-900 font-bold">Posts</h2>
    
          <PostsList posts={posts} />
    
        </div>
    
      );
    
    }
    

Tailwind CSS 类将列表放置在页面中心,并在其上方留有一点边距。一个大的 帖子 标题也以深灰色渲染在列表上方。

  1. 现在,打开 App.tsx 并将其内容替换为以下内容,以便渲染我们刚刚创建的页面组件:

    import { PostsPage } from './posts/PostsPage';
    
    function App() {
    
      return <PostsPage />;
    
    }
    
    export default App;
    
  2. 通过在新的终端中运行 npm start 来运行应用程序,该终端与运行 REST API 的终端分开。在数据被获取时,加载指示器将短暂出现:

图 9.3 – 加载指示器

图 9.3 – 加载指示器

博客帖子列表将如下显示:

图 9.4 – 博客帖子列表

图 9.4 – 博客帖子列表

这就完成了 PostsPage 组件的这个版本。

在本节中,我们学习了如何使用 fetchuseEffect 在 REST API 中与 HTTP GET 请求交互的关键点:

  • fetch 将执行实际的 HTTP 请求,该请求将 REST API 的 URL 作为参数

  • 可以使用类型断言函数来为响应数据添加强类型

  • useEffect 可以在包含状态数据的组件挂载时触发 fetch 调用

  • 可以在 useEffect 内部使用一个标志来检查在设置数据状态之前组件是否在 HTTP 请求期间被卸载

在保持应用程序和 REST API 运行的情况下,在下一节中,我们将学习如何使用 fetch 将数据发布到 REST API。

使用 fetch 发送数据

在本节中,我们将创建一个表单,该表单将新的博客帖子提交到我们的 REST API。我们将创建一个使用 fetch 向 REST API 发送数据的函数。该函数将在表单的提交处理程序中被调用。

使用 fetch 创建新的博客帖子

我们将首先创建一个函数,该函数将新的博客帖子发送到 REST API。这将使用浏览器的 fetch 函数,但这次使用 HTTP POST 请求。执行以下步骤:

  1. 我们将首先在posts文件夹中的 types.ts 文件中打开,并添加以下两个类型:

    export type NewPostData = {
    
      title: string;
    
      description: string;
    
    };
    
    export type SavedPostData = {
    
      id: number;
    
    };
    

第一个类型代表一个新的博客帖子,第二个类型代表当博客帖子成功保存时从 API 获取的数据。

  1. posts 文件夹中创建一个名为 savePost.ts 的新文件,并添加以下导入语句:

    import { NewPostData, SavedPostData } from './types';
    

我们还导入了我们刚刚创建的类型。

  1. 开始实现savePost函数如下:

    export async function savePost(
    
      newPostData: NewPostData
    
    ) {
    
      const response = await fetch(
    
        process.env.REACT_APP_API_URL!,
    
        {
    
          method: 'POST',
    
          body: JSON.stringify(newPostData),
    
          headers: {
    
            'Content-Type': 'application/json',
    
          },
    
        }
    
      );
    
    }
    

savePost函数有一个参数newPostData,包含新博客帖子的标题和描述,并使用fetch将其发送到 REST API。在fetch调用中已指定第二个参数,指定应使用 HTTP POST请求,并将新博客帖子数据包含在请求体中。请求体还声明为 JSON 格式。

  1. 接下来,将响应体强类型化如下:

    export async function savePost(newPostData: NewPostData) {
    
      const response = await fetch( ... );
    
      const body = (await response.json()) as unknown;
    
      assertIsSavedPost(body);
    
    }
    

我们将响应体设置为具有unknown类型,然后使用类型断言函数给它一个特定的类型。这将引发编译错误,直到我们在第 6 步中实现assertIsSavedPost

  1. 通过合并响应中的博客帖子 ID 与函数提供的博客帖子标题和描述来完成savePost的实现:

    export async function savePost(newPostData: NewPostData) {
    
      ...
    
      return { ...newPostData, ...body };
    
    }
    

因此,该函数返回的对象将是一个带有 REST API ID 的新博客帖子。

  1. 最后一步是实现类型断言函数:

    function assertIsSavedPost(
    
      post: any
    
    ): asserts post is SavedPostData {
    
      if (!('id' in post)) {
    
        throw new Error("post doesn't contain id");
    
      }
    
      if (typeof post.id !== 'number') {
    
        throw new Error('id is not a number');
    
      }
    
    }
    

该函数检查响应数据是否包含一个数字id属性,如果包含,则断言数据是SavedPostData类型。

这样就完成了savePost函数的实现。接下来,我们将添加一个表单组件,允许用户输入新的博客帖子。

创建博客帖子表单组件

我们将创建一个包含表单的组件,该表单用于捕获新的博客帖子。当表单提交时,它将调用我们刚刚创建的savePost函数。

我们将使用 React Hook Form 实现表单,以及一个ValidationError组件。我们在第七章中详细介绍了 React Hook Form 和ValidationError组件,因此实现步骤不会详细说明。

执行以下步骤:

  1. 我们将首先创建一个ValidationError组件,该组件将渲染表单验证错误。在posts文件夹中创建一个名为ValidationError.tsx的文件,内容如下:

    import { FieldError } from 'react-hook-form';
    
    type Props = {
    
      fieldError: FieldError | undefined,
    
    };
    
    export function ValidationError({ fieldError }: Props) {
    
      if (!fieldError) {
    
        return null;
    
      }
    
      return (
    
        <div role="alert" className="text-red-500 text-xs       mt-1">
    
          {fieldError.message}
    
        </div>
    
      );
    
    }
    
  2. posts文件夹中创建一个名为NewPostForm.tsx的新文件。这个文件将包含一个用于捕获新博客帖子标题和描述的表单。将该文件中的以下导入语句添加到文件中:

    import { FieldError, useForm } from 'react-hook-form';
    
    import { ValidationError } from './ValidationError';
    
    import { NewPostData } from './types';
    
  3. 开始实现表单组件如下:

    type Props = {
    
      onSave: (newPost: NewPostData) => void;
    
    };
    
    export function NewPostForm({ onSave }: Props) {
    
    }
    

该组件有一个用于保存新博客帖子的 prop,以便可以在表单组件之外处理与 REST API 的交互。

  1. 现在,从 React Hook Form 的useForm钩子中解构registerhandleSubmit函数以及有用的状态变量:

    type Props = {
    
      onSave: (newPost: NewPostData) => void;
    
    };
    
    export function NewPostForm({ onSave }: Props) {
    
      const {
    
        register,
    
        handleSubmit,
    
        formState: { errors, isSubmitting, isSubmitSuccessful },
    
      } = useForm<NewPostData>();
    
    }
    

我们将新帖数据的类型传递给useForm钩子,以便它知道要捕获的数据的形状。

  1. 为字段容器样式创建一个变量,为编辑器样式创建一个函数:

    export function NewPostForm({ onSave }: Props) {
    
      ...
    
      const fieldStyle = 'flex flex-col mb-2';
    
      function getEditorStyle(
    
        fieldError: FieldError | undefined
    
      ) {
    
        return fieldError ? 'border-red-500' : '';
    
      }
    
    }
    
  2. form元素中按如下方式渲染titledescription字段:

    export function NewPostForm({ onSave }: Props) {
    
      ...
    
      return (
    
        <form
    
          noValidate
    
          className="border-b py-4"
    
          onSubmit={handleSubmit(onSave)}
    
        >
    
          <div className={fieldStyle}>
    
            <label htmlFor="title">Title</label>
    
            <input
    
              type="text"
    
              id="title"
    
              {...register('title', {
    
                required: 'You must enter a title',
    
              })}
    
              className={getEditorStyle(errors.title)}
    
            />
    
            <ValidationError fieldError={errors.title} />
    
          </div>
    
          <div className={fieldStyle}>
    
            <label htmlFor="description">Description</label>
    
            <textarea
    
              id="description"
    
              {...register('description', {
    
                required: 'You must enter the description',
    
              })}
    
              className={getEditorStyle(errors.description)}
    
            />
    
            <ValidationError fieldError={errors.description}           />
    
          </div>
    
        </form>
    
      );
    
    }
    
  3. 最后,渲染一个保存按钮和成功消息:

    <form
    
      noValidate
    
      className="border-b py-4"
    
      onSubmit={handleSubmit(onSave)}
    
    >
    
      <div className={fieldStyle}> ... </div>
    
      <div className={fieldStyle}> ... </div>
    
      <div className={fieldStyle}>
    
        <button
    
          type="submit"
    
          disabled={isSubmitting}
    
          className="mt-2 h-10 px-6 font-semibold bg-black         text-white"
    
        >
    
          Save
    
       </button>
    
        {isSubmitSuccessful && (
    
          <div
    
            role="alert"
    
            className="text-green-500 text-xs mt-1"
    
          >
    
            The post was successfully saved
    
         </div>
    
        )}
    
      </div>
    
    </form>
    

这样就完成了NewPostForm组件的实现。

  1. 现在打开 PostPage.tsx 文件并导入我们之前创建的 NewPostForm 组件和 savePost 函数。同时,导入 NewPostData 类型:

    import { useEffect, useState } from 'react';
    
    import { getPosts } from './getPosts';
    
    import { PostData, NewPostData } from './types';
    
    import { PostsList } from './PostsList';
    
    import { savePost } from './savePost';
    
    import { NewPostForm } from './NewPostForm';
    
  2. PostPage JSX 中,将 NewPostForm 表单添加到 PostsList 之上:

    <div className="w-96 mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-    bold">Posts</h2>
    
      <NewPostForm onSave={handleSave} />
    
      <PostsList posts={posts} />
    
    </div>;
    
  3. 在获取博客文章的效果下方添加保存处理函数:

    useEffect(() => {
    
      ...
    
    }, []);
    
    async function handleSave(newPostData: NewPostData) {
    
      const newPost = await savePost(newPostData);
    
      setPosts([newPost, ...posts]);
    
    }
    
  4. 处理函数调用 savePost 并传入表单中的数据。文章保存后,它将被添加到 posts 数组的开头。

  5. 在运行的应用程序中,新的博客文章表单将出现在博客文章列表上方,如下所示:

图 9.5 – 新博客文章表单位于文章列表上方

图 9.5 – 新博客文章表单位于文章列表上方

  1. 用一篇新的博客文章填写表单并按下 保存 按钮。几秒钟后,新文章应该出现在列表的顶部。

图 9.6 – 新博客文章添加到文章列表中

图 9.6 – 新博客文章添加到文章列表中

这样就完成了表单的实现及其与博客文章页面的集成。

在本节关于使用 fetch 发送数据的几个关键点如下:

  • fetch 函数的第二个参数允许指定 HTTP 方法。在本节中,我们使用此参数进行 HTTP POST 请求。

  • fetch 函数的第二个参数还允许提供请求体。

再次保持应用程序和 REST API 运行,在下一节中,我们将使用 React Router 的数据获取功能来简化我们的数据获取代码。

使用 React Router

在本节中,我们将了解 React Router 如何与数据获取过程集成。我们将使用这些知识来简化我们应用程序中获取博客文章的代码。

理解 React Router 数据加载

React Router 的数据加载与 React Router 表单类似,我们在 第七章 中学习过。我们不是定义一个处理表单提交的动作,而是定义一个 some-page 路由:

const router = createBrowserRouter([
  ...,
  {
    path: '/some-page',
    element: <SomePage />,
    loader: async () => {
      const response = fetch('https://somewhere');
      return await response.json();
    }
  },
  ...
]);

React Router 在渲染路由上定义的组件之前调用加载器以获取数据。然后,数据通过 useLoaderData 钩子可用在组件中:

export function SomePage() {
  const data = useLoaderData();
  ...
}

这种方法效率很高,因为路由组件只渲染一次,因为数据在第一次渲染时就已经可用。

更多关于 React Router 加载器的信息,请参阅以下链接:reactrouter.com/en/main/route/loader。更多关于 useLoaderData 钩子的信息,请参阅以下链接:reactrouter.com/en/main/hooks/use-loader-data

现在我们开始理解 React Router 中的数据加载,我们将在我们的应用程序中使用它。

使用 React Router 进行数据加载

执行以下步骤以在我们的应用程序中使用 React Router 数据加载器:

  1. 打开 App.tsx 文件并添加以下导入语句:

    import {
    
      createBrowserRouter,
    
      RouterProvider
    
    } from 'react-router-dom';
    
  2. 此外,导入 getPosts 函数:

    import { getPosts } from './posts/getPosts';
    

getPosts 将是加载函数。

  1. App 组件上方添加以下路由定义:

    const router = createBrowserRouter([
    
      {
    
        path: "/",
    
        element: <PostsPage />,
    
        loader: getPosts
    
      }
    
    ]);
    
  2. App 组件中,将 PostsPage 替换为 RouterProvider

    function App() {
    
      return <RouterProvider router={router} />;
    
    }
    
  3. 打开 PostsPage.tsx 并移除 React 导入语句,因为在这个组件中不再需要它。

  4. 此外,将 assertIsPosts 添加到 getPosts 导入语句中,并移除 getPosts

    import { assertIsPosts } from './getPosts';
    

我们最终需要 assertIsPosts 来类型化数据。

  1. 仍然在 PostsPage.tsx 中,添加以下导入语句,以便使用 React Router 中的一个钩子,该钩子允许我们访问加载器数据:

    import { useLoaderData } from 'react-router-dom';
    
  2. PostsPage 组件内部,移除 isLoadingposts 状态定义。这些将不再需要,因为我们将从 React Router 获取数据,而无需等待。

  3. 移除当前获取数据的 useEffect 调用。

  4. 移除 handleSave 函数的第二行,该行设置 posts 状态。handleSave 现在应如下所示:

    async function handleSave(newPostData: NewPostData) {
    
      await savePost(newPostData);
    
    }
    
  5. 同时移除加载指示器。

  6. 现在,在 PostsPage 组件的顶部,调用 useLoaderData 并将结果分配给 posts 变量:

    export function PostsPage() {
    
      const posts = useLoaderData();
    
      …
    
    }
    
  7. 很不幸,postsunknown 类型,因此在传递给 PostsLists 组件时存在类型错误。使用 assertsIsPosts 函数将数据类型化为 PostData[]

    const posts = useLoaderData();
    
    assertIsPosts(posts);
    

类型错误现在已解决。

注意,从 types 导入语句中导入的 PostData 未使用。保持其完整性,因为我们将在下一节再次使用它。

  1. 运行的应用程序应该看起来和表现与之前相似。你可能注意到的一点是,当使用表单添加新的博客文章时,它不会出现在列表中——你必须手动刷新页面才能看到它。当我们在本章后面使用 React Query 时,这将被解决。

注意我们刚刚移除了多少代码——这表明代码现在变得更加简单。使用 React Router 加载数据的另一个好处是,在数据获取后,PostsPage 不会重新渲染——数据是在 PostsPage 渲染之前获取的。

接下来,我们将改进数据获取过程的用户体验。

延迟 React Router 数据获取

如果数据获取过程缓慢,React Router 渲染组件之前会有明显的延迟。幸运的是,我们可以通过使用 React Router 的 defer 函数和 Await 组件,以及 React 的 Suspense 组件来解决这个问题。执行以下步骤将它们添加到我们的应用程序中:

  1. 首先打开 App.tsx 并将 defer 函数添加到 React Router 导入语句中:

    import {
    
      createBrowserRouter,
    
      RouterProvider,
    
      defer
    
    } from 'react-router-dom';
    
  2. 在路由定义中按照以下方式更新 loader 函数:

    const router = createBrowserRouter([
    
      {
    
        path: "/",
    
        element: ...,
    
        loader: async () => defer({ posts: getPosts() })
    
      }
    
    ]);
    

React Router 的 defer 函数接收一个包含承诺数据的对象。对象中的属性名是数据的唯一键,在我们的例子中是 posts。值是获取数据的函数,在我们的例子中是 getPosts

注意,我们没有等待 getPosts,因为我们希望加载器完成,并且 PostsPage 立即渲染。

  1. 打开 PostsPage.tsx 并为 React 的 Suspense 组件添加一个导入语句:

    import { Suspense } from 'react';
    
  2. Await 组件添加到 React Router 的导入语句中:

    import { useLoaderData, Await } from 'react-router-dom';
    
  3. 在组件中,将 useLoaderData 的调用更新为将结果分配给 data 变量而不是 posts

    const data = useLoaderData();
    

加载器数据的形式现在略有不同——它将是一个包含 posts 属性的对象,其中包含博客文章。博客文章也不会立即出现,就像之前那样——data.posts 属性将包含博客文章的承诺。

  1. 此外,删除对 assertIsPosts 的调用——我们将在 步骤 9 中使用它。

  2. data 变量是 unknown 类型,因此请在组件下方添加一个类型断言函数,以便可以将其强类型化:

    type Data = {
    
      posts: PostData[];
    
    };
    
    export function assertIsData(
    
      data: unknown
    
    ): asserts data is Data {
    
      if (typeof data !== 'object') {
    
        throw new Error("Data isn't an object");
    
      }
    
      if (data === null) {
    
        throw new Error('Data is null');
    
      }
    
      if (!('posts' in data)) {
    
        throw new Error("data doesn't contain posts");
    
      }
    
    }
    

类型断言函数检查 data 参数是否是一个包含 posts 属性的对象。

  1. 我们现在可以使用断言函数来为组件中的 data 变量指定类型:

    const data = useLoaderData();
    
    assertIsData(data);
    
  2. 在 JSX 中,将 SuspenseAwait 包裹在 PostsList 旁边,如下所示:

    <Suspense fallback={<div>Fetching...</div>}>
    
      <Await resolve={data.posts}>
    
        {(posts) => {
    
          assertIsPosts(posts);
    
          return <PostsList posts={posts} />;
    
        }}
    
      </Await>
    
    </Suspense>
    

SuspenseAwait 一起工作,仅在数据已被获取时渲染 PostsLists。我们使用 Suspense 来渲染 assertIsPosts 以确保 posts 被正确类型化。

  1. 在运行的应用程序中,你现在将注意到当页面加载时会出现 Fetching… 消息:

图 9.7 – 数据获取期间的数据获取消息

图 9.7 – 数据获取期间的数据获取消息

  1. 通过在运行应用程序的终端中按 Ctrl + C 来停止应用程序的运行,但保持 API 运行。

这个解决方案的伟大之处在于,由于使用了 SuspenseAwait,当 PostsPage 被渲染时,仍然不会发生重新渲染。

我们现在将快速回顾一下我们使用 React Router 的数据获取功能所学到的东西:

  • React Router 的 loader 允许我们高效地将获取的数据加载到路由组件中

  • React Router 的 defer 允许路由组件在数据获取时不会被阻止渲染组件

  • React Router 的 useLoaderData 钩子允许组件访问路由的加载数据

  • React 的 Suspense 和 React Router 的 Await 允许组件在数据仍在获取时进行渲染

有关 React Router 中延迟数据的更多信息,请参阅以下链接:reactrouter.com/en/main/guides/deferred

在下一节中,我们将使用另一个流行的库来管理服务器数据,以进一步提高用户体验。

使用 React Query

React Query 是一个用于与 REST API 交互的流行库。它所做的关键事情是管理围绕 REST API 调用的状态。它所做的另一件事是 React Router 不做的是维护获取数据的缓存,这提高了应用程序的感知性能。

在本节中,我们将重构应用程序以使用 React Query 而不是 React Router 的加载器功能。然后,我们将再次重构应用程序以同时使用 React Query 和 React Router 的加载器,以获得这两个世界的最佳效果。

安装 React Query

我们的第一项任务是安装 React Query,我们可以在终端中运行以下命令来完成:

npm i @tanstack/react-query

此库包括 TypeScript 类型,因此不需要安装任何额外的包。

添加 React Query 提供者

React Query 需要在需要访问其管理的数据的组件树上的一个提供者组件。最终,React Query 将在我们的应用程序中保存博客文章数据。执行以下步骤以将 React Query 提供者组件添加到 App 组件中:

  1. 打开 App.tsx 并添加以下导入语句:

    import {
    
      QueryClient,
    
      QueryClientProvider,
    
    } from '@tanstack/react-query';
    

QueryClient 提供对数据的访问。QueryClientProvider 是我们需要放置在组件树中的提供者组件。

  1. 按照以下方式将 QueryClientProvider 包裹在 RouterProvider 之外:

    const queryClient = new QueryClient();
    
    const router = createBrowserRouter( ... );
    
    function App() {
    
      return (
    
        <QueryClientProvider client={queryClient}>
    
          <RouterProvider router={router} />
    
        </QueryClientProvider>
    
      );
    
    }
    

QueryClientProvider 需要一个 QueryClient 实例来传递给它,因此我们在 App 组件外部创建此实例。我们将 queryClient 变量放置在路由定义之上,因为我们最终会在路由定义中使用它。

PostsPage 组件现在可以访问 React Query。接下来,我们将在 PostsPage 中使用 React Query。

使用 React Query 获取数据

React Query 将获取数据的请求称为 useQuery 钩子以执行此操作。我们将在 PostsPage 组件中使用 React Query 的 useQuery 钩子来调用 getPosts 函数并存储它返回的数据。这将暂时替代 React Router 的加载器功能。执行以下步骤:

  1. 从 React Query 中导入 useQuery

    import { useQuery } from '@tanstack/react-query';
    
  2. getPosts 添加到 getPosts 导入语句中:

    import { assertIsPosts, getPosts } from './getPosts';
    

我们最终将使用 getPosts 来获取数据并将其存储在 React Query 中。

  1. PostPage 组件中,注释掉 data 变量:

    // const data = useLoaderData();
    
    // assertIsData(data);
    

我们将这些行注释掉而不是删除,因为我们将在下一节中使用 React Router 和 React Query 一起时再次使用它们。

  1. 现在,按照以下方式添加对 useQuery 的调用:

    export function PostsPage() {
    
      const {
    
        isLoading,
    
        isFetching,
    
        data: posts,
    
      } = useQuery(['postsData'], getPosts);
    
      // const data = useLoaderData();
    
      // assertIsData(data);
    
      ...
    
    }
    

传递给 useQuery 的第一个参数是数据的唯一键。这是因为 React Query 可以存储许多数据集,并使用键来标识每个数据集。在这种情况下,键是一个包含数据名称的数组。然而,键数组可以包括我们想要获取的特定记录的 ID 或如果我们只想获取一页记录的页码。

传递给 useQuery 的第二个参数是获取函数,即我们现有的 getPosts 函数。

我们已经解构了以下状态变量:

  • isLoading – 组件是否正在首次加载。

  • isFetching – 获取函数是否正在被调用。当 React Query 认为数据已过时,它将重新获取数据。我们将在稍后与应用程序一起玩耍时体验重新获取数据。

  • data – 已获取的数据。我们将此 posts 变量别名为 posts 以匹配之前的 posts 状态值。保持相同的名称可以最小化对组件其余部分的更改。

注意

useQuery 中可以解构出其他有用的状态变量。一个例子是 isError,它表示 fetch 函数是否出错。有关更多信息,请参阅以下链接:tanstack.com/query/v4/docs/reference/useQuery

  1. 在返回语句上方添加一个加载指示器:

    if (isLoading || posts === undefined) {
    
      return (
    
        <div className="w-96 mx-auto mt-6">
    
          Loading ...
    
        </div>
    
      );
    
    }
    
    return ...
    

检查 posts 状态是否为 undefined 表示 TypeScript 编译器知道在 JSX 中引用 posts 时它不是 undefined

  1. 在 JSX 中,注释掉 Suspense 及其子元素:

    return (
    
      <div className="w-96 mx-auto mt-6">
    
        <h2 className="text-xl text-slate-900 font-      bold">Posts</h2>
    
        <NewPostForm onSave={mutate} />
    
        {/* <Suspense fallback={<div>Fetching ...</div>}>
    
            <Await resolve={data.posts}           errorElement={<p>Error!</p>}>
    
              {(posts) => {
    
                assertIsPosts(posts);
    
                return <PostsList posts={posts} />;
    
              }}
    
            </Await>
    
          </Suspense> */}
    
      </div>
    
    );
    

我们将此代码块注释掉而不是删除它,因为我们将在下一节中使用 React Router 和 React Query 一起使用时恢复它。

  1. 当数据正在获取时,显示一个获取指示器,并在数据获取后渲染博客文章:

    <div className="w-96 mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-    bold">Posts</h2>
    
      <NewPostForm onSave={handleSave} />
    
      {isFetching ? (
    
        <div>Fetching ...</div>
    
      ) : (
    
        <PostsList posts={posts} />
    
      )}
    
      ...
    
    </div>
    
  2. 通过在终端中运行 npm start 来运行应用。博客文章页面将显示与之前相同。一个技术差异是 PostsPage 在数据获取后会被重新渲染。

  3. 离开浏览器窗口并将焦点设置到不同的窗口,例如您的代码编辑器。现在,将焦点重新设置到浏览器窗口,注意获取指示器会短暂出现:

图 9.8 – 数据重新获取时的获取指示器

图 9.8 – 数据重新获取时的获取指示器

这是因为 React Query 默认假设当浏览器恢复焦点时数据已过时。有关此行为的更多信息,请参阅 React Query 文档中的以下链接:tanstack.com/query/v4/docs/guides/window-focus-refetching

  1. React Query 的一个伟大特性是它维护数据缓存。这允许我们在获取新鲜数据的同时渲染带有缓存数据的组件。为了体验这一点,在 PostsPage JSX 中,移除 PostsList 渲染时的 isFetching 条件:

    <PostsList posts={posts} />
    

因此,即使数据已过时,PostsList 也会渲染。

  1. 在运行的应用中,按 F5 刷新页面。然后,离开浏览器窗口并将焦点设置到不同的窗口。将焦点重新设置到浏览器窗口并注意没有获取指示器出现,博客文章列表保持完整。

  2. 重复前面的步骤,但这次,观察浏览器 DevTools 中的 网络 选项卡。注意当应用重新聚焦时,会发起第二个网络请求:

图 9.9 – 两个博客文章的 API 请求

图 9.9 – 两个博客文章的 API 请求

因此,React Query 无缝地允许组件渲染旧数据,并在数据被获取后用新数据重新渲染。

接下来,我们将继续重构帖子页面,以便在将新的博客文章发送到 API 时使用 React Query。

使用 React Query 更新数据

React Query 可以使用名为useMutation钩子的功能来更新数据。在PostsPage.tsx中执行以下步骤,将保存新博客文章的保存更改为使用 React Query 突变:

  1. 按照以下方式更新 React Query 的导入:

    import {
    
      useQuery,
    
      useMutation,
    
      useQueryClient,
    
    } from '@tanstack/react-query';
    

useMutation钩子允许我们执行一个突变。useQueryClient钩子将使我们能够获取组件正在使用的queryClient实例,并访问和更新缓存的数据。

  1. 在调用useQuery之后添加对useMutation的调用,如下所示:

    const {
    
      isLoading,
    
      data: posts,
    
      isFetching,
    
    } = useQuery(['postsData'], getPosts);
    
    const { mutate } = useMutation(savePost);
    

我们将执行 REST API HTTP POST请求的函数传递给useMutation。我们从useMutation的返回值中解构出mutate函数,我们将在第 4 步中使用它来触发突变。

注意

还可以从useMutation解构出其他有用的状态变量。一个例子是isError,它指示fetch函数是否出错。有关更多信息,请参阅以下链接:tanstack.com/query/v4/docs/reference/useMutation

  1. 当突变成功完成后,我们希望更新posts缓存以包含新的博客文章。进行以下突出显示的更改以实现此目的:

    const queryClient = useQueryClient();
    
    const { mutate } = useMutation(savePost, {
    
      onSuccess: (savedPost) => {
    
        queryClient.setQueryData<PostData[]>(
    
          ['postsData'],
    
          (oldPosts) => {
    
            if (oldPosts === undefined) {
    
              return [savedPost];
    
            } else {
    
              return [savedPost, ...oldPosts];
    
            }
    
          }
    
        );
    
      },
    
    });
    

useMutation的第二个参数允许配置突变。onSuccess配置选项是一个在突变成功完成后被调用的函数。

useQueryClient返回组件正在使用的查询客户端。这个查询客户端有一个名为setQueryData的方法,它允许更新缓存数据。setQueryData有缓存数据的键和要缓存的新数据副本的参数。

  1. 我们可以通过在NewPostForm JSX 元素的onSave属性上调用解构的mutate函数来在新文章保存时触发突变:

    <NewPostForm onSave={mutate} />
    
  2. 现在,我们可以移除handleSave函数,因为现在它是多余的。

  3. 导入的NewPostData类型也可以移除。现在,此类型的导入语句应如下所示:

    import { PostData } from './types';
    
  4. 在运行的应用程序中,如果您输入并保存一篇新的博客文章,它将像之前的实现一样出现在列表中:

图 9.10 – 新博客文章添加到帖子列表

图 9.10 – 新博客文章添加到帖子列表

这样就完成了将保存新博客文章重构为使用 React Query 突变的过程。这也完成了关于 React Query 的这一部分 – 这里是对关键点的回顾:

  • React Query 是一个流行的库,它通过缓存管理来自后端 API 的数据,有助于提高性能

  • React Query 实际上并不执行 HTTP 请求 – 可以使用浏览器的fetch函数来完成此操作

  • React Query 的QueryClientProvider组件需要放置在组件树的高端,在需要后端数据的地方之上

  • React Query 的useQuery钩子允许数据在状态中被检索和缓存

  • React Query 的useMutation钩子允许更新数据

想了解更多关于 React Query 的信息,请访问库的文档网站:tanstack.com/query

接下来,我们将学习如何将 React Query 集成到 React Router 的数据获取能力中。

使用 React Router 与 React Query

到目前为止,我们已经体验到了 React Router 和 React Query 数据获取的好处。React Router 减少了重新渲染的次数,而 React Query 提供了数据的客户端缓存。在本节中,我们将将这些库结合到我们的应用程序中,以便它具有这两个好处。

执行以下步骤:

  1. 首先,打开App.tsx并将路由定义上的 loader 函数更改为以下内容:

    const router = createBrowserRouter([
    
      {
    
        path: '/',
    
        element: ...,
    
        loader: async () => {
    
          const existingData = queryClient.getQueryData([
    
            'postsData',
    
          ]);
    
          if (existingData) {
    
            return defer({ posts: existingData });
    
          }
    
          return defer({
    
            posts: queryClient.fetchQuery(
    
              ['postsData'],
    
              getPosts
    
            )
    
          });
    
        }
    
      }
    
    ])
    

在 loader 内部,我们使用查询客户端上的 React Query 的getQueryData函数从其缓存中获取现有数据。如果有缓存数据,则返回;否则,数据将被检索、延迟并添加到缓存中。

  1. 打开PostsPage.tsx并移除 React Query 的useQuery的使用,因为现在 React Router 的 loader 管理数据加载过程。

  2. getPosts导入语句中移除getPosts函数,因为现在这个函数在 React Router 的 loader 中使用了。

  3. 此外,移除加载指示器,因为我们将在第 6 步中恢复使用 React Suspense。

  4. 数据将再次使用 React Router 的useLoaderData钩子检索,因此取消注释这两行代码:

    export function PostsPage() {
    
      const queryClient = useQueryClient();
    
      const { mutate } = useMutation( ... );
    
      const data = useLoaderData();
    
      assertIsData(data);
    
      return ...
    
    }
    
  5. 此外,恢复在 JSX 中使用SuspenseAwait。JSX 现在应该是这样的:

    <div className="w-96 max-w-xl mx-auto mt-6">
    
      <h2 className="text-xl text-slate-900 font-bold">
    
        Posts
    
      </h2>
    
      <NewPostForm onSave={mutate} />
    
      <Suspense fallback={<div>Fetching ...</div>}>
    
        <Await resolve={data.posts}>
    
          {(posts) => {
    
            assertIsPosts(posts);
    
            return <PostsList posts={posts} />;
    
          }}
    
        </Await>
    
      </Suspense>
    
    </div>
    
  6. 运行中的应用程序将像以前一样显示博客文章,但首次加载应用程序时,PostsPage将不再发生第二次渲染。然而,在通过表单添加新的博客文章后,它不会出现在列表中。我们将在下一步中解决这个问题。

  7. 在保存新的博客文章后,我们需要使路由组件重新渲染以获取最新数据。我们可以通过使路由导航到我们当前所在的页面来实现,如下所示:

    import {
    
      useLoaderData,
    
      Await,
    
      useNavigate
    
    } from 'react-router-dom';
    
    ...
    
    export function PostsPage() {
    
      const navigate = useNavigate();
    
      const queryClient = useQueryClient();
    
      const { mutate } = useMutation(savePost, {
    
        onSuccess: (savedPost) => {
    
          queryClient.setQueryData<PostData[]>(
    
            ['postsData'],
    
            (oldPosts) => {
    
              if (oldPosts === undefined) {
    
                return [savedPost];
    
              } else {
    
                return [savedPost, ...oldPosts];
    
              }
    
            }
    
          );
    
          navigate('/');
    
        },
    
      });
    
      ...
    
    }
    

在博客文章保存并添加到缓存后执行导航。这意味着路由的 loader 将执行并从缓存中填充其数据。然后PostsPage将使用useLoaderData返回的最新数据渲染。

这完成了应用程序的最终修订和本节关于使用 React Router 与 React Query 的内容。通过集成这两个库,我们获得了每个库的关键好处:

  • React Router 的数据加载器防止在页面加载数据时发生不必要的重新渲染

  • React Query 的缓存防止了对 REST API 的不必要调用

这两个库的集成方式是在 React Router 的 loader 中获取和设置数据,在 React Query 缓存中。

摘要

在本章中,我们使用了浏览器的fetch函数来发起 HTTP GETPOST请求。请求的 URL 是fetch函数的第一个参数。fetch函数的第二个参数允许指定请求选项,例如 HTTP 方法和正文。

可以使用类型断言函数来为 HTTP 请求响应体中的数据强类型。该函数接收具有unknown类型的数据。然后,该函数执行检查以验证数据的类型,如果数据无效,则抛出错误。如果没有错误发生,则在函数的类型断言签名中指定数据断言的类型。

React 的useEffect钩子可以用来在组件挂载时执行从后端 API 获取数据并存储到状态的调用。可以在useEffect内部使用一个标志来确保在设置数据状态之前,组件在 HTTP 请求后仍然挂载。

React Query 和 React Router 替换了数据获取过程中的useEffectuseState的使用,并简化了我们的代码。React Router 的 loader 函数允许数据被获取并传递到组件路由中,从而消除了不必要的重新渲染。React Query 包含一个可以在组件中使用的缓存,可以在获取最新数据的同时乐观地渲染数据。React Query 还包含一个useMutation钩子,用于启用数据的更新。

在下一章中,我们将介绍如何与 GraphQL API 交互。

问题

回答以下问题以检查你在本章中学到了什么:

  1. 以下效果尝试从 REST API 获取数据并将其存储在状态中:

    useEffect(async () => {
    
      const response = await fetch('https://some-rest-api/');
    
      const data = await response.json();
    
      setData(data);
    
    }, []);
    

这种实现有什么问题?

  1. 以下获取函数返回一个包含首字母的数组:

    export async function getFirstNames() {
    
      const response = await fetch('https://some-    firstnames/');
    
      const body = await response.json();
    
      return body;
    
    }
    

然而,该函数的返回类型是any。那么,我们如何改进实现,使其返回类型为string[]

  1. fetch函数参数中,应该指定什么method选项才能使其发起 HTTP PUT请求?

    fetch(url, {
    
      method: ???,
    
      body: JSON.stringify(data),
    
    });
    
  2. 当使用fetch向受保护的资源发起 HTTP 请求时,如何在 HTTP Authorization头中指定 bearer 令牌?

  3. 一个组件使用 React Query 的useQuery来获取数据,但组件出现以下错误:

未捕获错误:未设置 QueryClient,请使用 QueryClientProvider 设置一个

你认为问题是什么?

  1. 可以从 React Query 的useMutation中解构哪个状态变量来确定 HTTP 请求是否返回了错误?

答案

  1. 实现有两个问题:

    • useEffect不支持顶层async/await

    • 如果在 HTTP 请求期间组件卸载,则在设置data状态时将发生错误

这里是一个解决了这些问题的实现:

useEffect(() => {
  let cancel = false;
  fetch('https://some-rest-api/')
    .then((response) => data.json())
    .then((data) => {
      if (!cancel) {
        setData(data);
      }
    });
  return () => {
    cancel = true;
  };
}, []);
  1. 可以在响应体对象上使用assert函数如下:

    export async function getFirstNames() {
    
      const response = await fetch('https://some-    firstnames/');
    
      const body = await response.json();
    
      assertIsFirstNames(body);
    
      return body;
    
    }
    
    function assertIsFirstNames(
    
      firstNames: unknown
    
    ): asserts firstNames is string[] {
    
      if (!Array.isArray(firstNames)) {
    
        throw new Error('firstNames isn't an array');
    
      }
    
      if (firstNames.length === 0) {
    
        return;
    
      }
    
      firstNames.forEach((firstName) => {
    
        if (typeof firstName !== 'string') {
    
          throw new Error('firstName is not a string');
    
        }
    
      });
    
    }
    
  2. 方法选项应该是'PUT'

    fetch(url, {
    
      method: 'PUT',
    
      body: JSON.stringify(data),
    
    });
    
  3. 当使用fetch向受保护的资源发起 HTTP 请求时,可以使用headers.Authorization选项来指定 bearer 令牌:

    fetch(url, {
    
      headers: {
    
        Authorization: 'Bearer some-bearer-token',
    
        'Content-Type': 'application/json',
    
      },
    
    });
    
  4. 问题在于 React Query 的 QueryClientProvider 没有放置在 useQuery 所使用的组件之上,即在组件树中。

  5. 可以从 React Query 的 useMutation 中解构出 isError 状态变量,以确定 HTTP 请求是否返回了错误。或者,可以检查 status 状态变量是否为 'error' 值。

第十章:与 GraphQL API 交互

GraphQL API 是具有与它们交互的特殊语言的 Web API。这些 API 是 React 前端中非常流行的 REST API 的替代品。

在本章中,我们将首先了解特殊的 GraphQL 语言,在 GitHub GraphQL API 上执行一些基本查询。然后我们将构建一个 React 应用程序,允许用户搜索 GitHub 仓库并为其加星,体验 GraphQL 相对于 REST 的优势。

应用程序将使用带有 React Query 的浏览器 fetch 函数与 GitHub GraphQL API 交互。然后我们将重构应用程序的实现,使用一个称为 Apollo 客户端 的专用 GraphQL 客户端。

我们将涵盖以下主题:

  • 理解 GraphQL 语法

  • 准备工作

  • 使用 React Query 和 fetch

  • 使用 Apollo 客户端

技术要求

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

本章中所有代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter10

理解 GraphQL 语法

与 React Query 类似,GraphQL 将获取数据的请求称为 查询。在以下子节中,我们将学习如何编写一个基本的 GraphQL 查询,从几个字段返回数据。这些字段将具有原始值,因此结果将是扁平的。然后我们将学习如何编写一个更高级的查询,包含基于对象的字段值,这些值有自己的属性。最后,我们将学习如何使用参数使查询更具可重用性。

返回扁平数据

执行以下步骤以使用 GitHub GraphQL API 探索器获取您的 GitHub 用户账户信息:

  1. 在浏览器中打开以下 URL 以打开 GitHub GraphQL API 探索器:docs.github.com/en/graphql/overview/explorer

  2. 如果您尚未登录,请使用 Sign in with GitHub 按钮登录。将出现一个 GraphQL API 探索器页面,如下所示:

图 10.1 – GitHub GraphQL API 探索器

图 10.1 – GitHub GraphQL API 探索器

  1. 在 GraphQL API 探索器的右上角面板中,输入以下查询:

    query {
    
      viewer {
    
        name
    
      }
    
    }
    

查询以query关键字开头,以指定操作是一个用于获取数据(而不是更新数据)的查询。值得注意的是,query关键字是可选的,因为操作默认为查询。

在操作之后,通过指定所需的对象和字段来指定要返回的数据。在我们的例子中,我们指定返回viewer对象中的name字段。

  1. 点击执行查询按钮,这是一个包含黑色三角形的圆形按钮。

查询结果如下显示在查询的右侧:

图 10.2 – GitHub GraphQL

图 10.2 – GitHub GraphQL

我们请求的数据以 JSON 对象的形式返回。name字段值应该是你存储在 GitHub 账户中的名字。

  1. 在查询结果的右侧是文档浏览器。如果尚未展开,请展开此面板:

图 10.3 – 文档浏览器

图 10.3 – 文档浏览器

  1. 点击viewer,这是我们刚刚查询的对象。对象类型出现在对象名称的右侧,对象描述出现在下方。

注意

与许多语言一样,GraphQL 的字段有类型 – 有内置类型如StringIntBoolean,以及创建自定义类型的能力。有关更多信息,请参阅以下链接:graphql.org/learn/schema/#type-language

  1. 滚动到文档浏览器中的viewer对象(它应该在底部):

图 10.4 – 文档浏览器中的 viewer 对象

图 10.4 – 文档浏览器中的 viewer 对象

  1. 点击viewer对象旁边的User类型,在User类型中列出:

图 10.5 – User 类型中的字段

图 10.5 – User 类型中的字段

  1. 让我们在查询中添加avatarUrl字段,因为这个字段是我们可用的附加字段:

    query {
    
      viewer {
    
        name
    
        avatarUrl
    
      }
    
    }
    

我们只需在viewer对象内部添加avatarUrl字段,并在name字段和avatarUrl字段之间添加一个换行符。

  1. 如果你执行查询,avatarUrl字段将被添加到 JSON 结果中。这应该是一个指向你照片的路径:

图 10.6 – 包含 avatarUrl 的更新查询结果

图 10.6 – 包含 avatarUrl 的更新查询结果

这就完成了我们的第一个 graphQL 查询。

  1. 我们已经看到了 GraphQL 如何灵活,能够指定我们希望在响应中返回哪些字段。接下来,我们将创建另一个返回层次结构的查询。

返回层次化数据

现在,我们将通过返回基于对象的字段而不是只有原始值字段来创建一个更复杂的查询。这意味着结果将具有层次结构而不是扁平的。我们将查询 GitHub 仓库,返回其名称、描述和星级数。所以,执行以下步骤:

  1. 首先,将以下查询输入到 GitHub GraphQL API 探索器的查询面板中:

    query {
    
      repository (owner:"facebook", name:"react") {
    
        id
    
        name
    
        description
    
      }
    
    }
    

查询请求 repository 对象中的 idnamedescription 字段。在指定 repository 对象之后,指定了两个参数,用于仓库的 ownername

  1. 现在,让我们请求仓库的星标数量。为此,在 stargazers 对象中添加 totalCount 字段,如下所示:

    query {
    
      repository (owner:"facebook", name:"react") {
    
        id
    
        name
    
        description
    
        stargazers {
    
          totalCount
    
        }
    
      }
    
    }
    
  2. 如果你执行查询,结果将类似于以下截图:

图 10.7 – 查询特定仓库

图 10.7 – 查询特定仓库

这就完成了我们的第二个 GraphQL 查询。

因此,GraphQL 允许我们为不同的数据片段发出单个网络请求,只返回我们需要的字段。使用 REST API 做类似的事情可能需要多个请求,并且我们会得到比需要返回的更多数据。在这些类型的查询中,GraphQL 在 REST 上表现得更加出色。

接下来,我们将学习如何允许查询参数值的变化。

指定查询参数

我们刚刚做出的查询已经包含了仓库名称和所有者的参数。然而,owner 参数被硬编码为 "facebook" 的值,而 name 参数被设置为 "react"

您可能已经注意到了查询面板下的 查询变量 面板。这允许指定查询参数值。然后查询参数引用变量名称而不是硬编码的值。

执行以下步骤以调整仓库查询,以便查询参数可以变化:

  1. 查询 变量 面板中添加以下查询变量:

    {
    
      "owner": "facebook",
    
      "name": "react"
    
    }
    

如您所见,变量使用 JSON 语法指定。我们为仓库所有者命名变量为 owner,为仓库名称命名变量为 name

  1. 更新查询以如下引用查询变量:

    query ($owner: String!, $name: String!) {
    
      repository (owner:$owner, name:$name) {
    
        ...
    
      }
    
    }
    

查询参数在 query 关键字之后用括号指定。参数名称必须以美元符号($)为前缀。每个参数的类型在冒号(:)之后指定——在我们的例子中,两个参数都是 String。类型后面的感叹号(!)表示它是一个必需的查询参数。然后可以在查询中引用这些参数,在我们的例子中,这是请求仓库对象的地方。

  1. 如果我们执行查询,JSON 结果将与具有硬编码的仓库所有者和名称标准的查询相同。

  2. 现在,更改变量值以针对不同的仓库重新运行查询。JSON 结果将包含相同的字段,但包含请求的仓库的值。以下是对 TypeScript 仓库的查询和结果:

图 10.8 – TypeScript 仓库的参数查询

图 10.8 – TypeScript 仓库的参数查询

  1. 现在,我们已经熟悉了从 GraphQL 服务器读取数据。接下来,我们将学习如何请求更改 GraphQL 数据。

GraphQL 变更

在 GraphQL 中对数据进行更改被称为变更。星标仓库是对底层数据的更改,因此我们可以将其视为变更的一个示例。

执行以下步骤以创建一个星标 GitHub 仓库的变更:

  1. 为了星标一个仓库,我们需要仓库 ID。因此,将最后查询结果的仓库 ID 复制到您的剪贴板。以下 TypeScript 仓库的 ID:

    "MDEwOlJlcG9zaXRvcnkyMDkyOTAyNQ=="
    
  2. 将查询变量替换为我们想要星标的仓库 ID 的变量:

    {
    
      "repoId": "MDEwOlJlcG9zaXRvcnkyMDkyOTAyNQ=="
    
    }
    
  3. 将查询面板中的内容替换为以下变更:

    mutation ($repoId: ID!) {
    
      addStar(input: { starrableId: $repoId }) {
    
        starrable {
    
          stargazers {
    
            totalCount
    
          }
    
        }
    
      }
    
    }
    

让我们分解这段代码:

  • 我们在mutation之前加上mutation关键字。

  • 我们在括号中的mutation关键字之后放置要传递给mutation的参数。在我们的例子中,我们有一个用于我们想要星标的仓库 ID 的单个参数。

  • addStar是我们调用的mutation函数,它有一个名为input的参数,我们需要传递。

  • input实际上是一个包含一个名为starrableId的字段的对象,我们需要包含这个字段。这个值是我们想要星标的仓库 ID,所以我们将其设置为我们的$repoId仓库 ID 变量。

  • mutation参数之后,我们指定响应中我们想要返回的内容。在我们的例子中,我们想要返回仓库上的星标数量。

  1. 如果我们执行mutation,星标将被添加到仓库中,并返回新的总星标数量:

图 10.9 – 将仓库设置为星标

图 10.9 – 将仓库设置为星标

这就完成了本节关于熟悉 GraphQL 语法的介绍。为了回顾,以下是一些关键点:

  • GraphQL 查询获取数据,而变更则更改数据。这些操作分别使用querymutation关键字指定。

  • 响应中所需的数据可以在查询/变更中指定,这有助于后端交互更高效。

  • 查询参数变量可以被指定,以允许查询/变更可重用。

接下来,我们将设置一个 React 项目,该项目最终将与 GitHub GraphQL API 交互。

设置项目

在本节中,我们将首先创建我们将要构建的应用程序的项目。我们将构建一个 React 应用程序,允许用户搜索 GitHub 仓库并为其星标。它将使用 GitHub GraphQL API,因此我们将为这个生成一个个人访问令牌PAT),并将其存储在环境变量中。

创建项目

我们将使用 Visual Studio Code 和一个基于 Create React App 的新项目设置来开发应用程序。我们之前已经多次介绍过这一点,所以在本章中我们将不会介绍这些步骤——相反,请参阅第三章设置 React 和 TypeScript

我们将使用 Tailwind CSS 来设置应用程序的样式。我们之前在第五章前端样式方法中介绍了如何安装和配置 Tailwind 在 Create React App 中。因此,在创建 React 和 TypeScript 项目后,安装并配置 Tailwind。

我们将使用 React Hook Form 来实现创建博客文章的表单,并使用@tailwindcss/forms插件来设置表单样式。因此,安装@tailwindcss/forms插件和 React Hook Form(如果您忘记如何做,请参阅第七章与表单一起工作)。

现在项目已经设置好了,接下来,我们将获取访问 GitHub GraphQL API 的权限。

为 GitHub GraphQL API 创建个人访问令牌(PAT)

GitHub GraphQL API 受个人访问令牌(PAT)保护,它是一串字符,是保护 Web API 的常用机制。按照以下步骤生成 PAT:

  1. 在浏览器中,访问 GitHub:github.com/

  2. 如果您尚未登录,请登录您的 GitHub 账户。如果您还没有 GitHub 账户,可以通过注册 账户按钮创建一个。

  3. 现在,打开您的头像下的菜单并点击设置

  4. 接下来,访问左侧栏底部的开发者设置选项。

  5. 前往左侧栏上的个人访问令牌页面。

  6. 点击生成新令牌按钮开始创建 PAT。点击按钮后,您可能需要输入密码。

  7. 在生成令牌之前,您将被要求指定作用域。输入令牌描述,勾选仓库和用户作用域,然后点击生成 令牌按钮。

然后在页面上生成并显示令牌。请复制此令牌,因为我们将在下一节构建我们的应用程序时需要它。

创建环境变量

在编写与 GitHub GraphQL API 交互的代码之前,我们将为 API URL 和 PAT 创建环境变量:

  1. 让我们从创建一个环境文件开始,用于存储 GitHub GraphQL API 的 URL。在项目的根目录中创建一个名为.env的文件,包含以下变量:

    REACT_APP_GITHUB_URL = https://api.github.com/graphql
    

此环境变量在构建时注入到代码中,可以通过process.env.REACT_APP_GITHUB_URL由代码访问。Create React App 项目中的环境变量必须以React_APP_为前缀。

关于环境变量的更多信息,请参阅以下链接:create-react-app.dev/docs/adding-custom-environment-variables/

  1. 现在,我们将为 GitHub PAT 令牌创建第二个环境变量。但是,我们不想将此文件提交到源代码控制,所以将其放置在项目根目录下的.env.local文件中:

    REACT_APP_GITHUB_PAT = your-token
    

.env.local.gitignore 文件中,因此此文件不会提交到源代码控制,从而降低了您的 PAT 被盗的风险。在上面的代码片段中将 your-token 替换为您的 PAT 令牌。

这样就完成了环境变量的创建。

在下一节中,我们将开始构建一个将与 GitHub GraphQL API 交互的应用程序。

使用 React Query 和 fetch

在本节中,我们将构建一个包含表单的应用程序,允许用户搜索和星标 GitHub 仓库。该应用程序还将包含包含我们从 GitHub 的名字的头部。我们将使用带有 React Query 的浏览器 fetch 函数与 GitHub GraphQL API 交互。让我们开始吧。

创建头部

我们将创建包含我们的 GitHub 名字的头部。我们将创建一个 Header 组件,其中包含此内容,它将从 App 组件中引用。Header 组件将使用 React Query 执行一个函数,该函数调用 GitHub GraphQL API 获取我们的 GitHub 名字。

创建一个获取查看者信息的函数

执行以下步骤以创建一个函数,该函数向 GitHub GraphQL API 发送请求以获取有关已登录查看者的详细信息:

  1. 我们将首先创建一个用于 API 调用的文件夹。在 src 文件夹中创建一个名为 api 的文件夹。

  2. 现在,我们将创建一个函数将使用的类型。在 src/api 文件夹中创建一个名为 types.ts 的文件,内容如下:

    export type ViewerData = {
    
      name: string;
    
      avatarUrl: string;
    
    };
    

此类型表示已登录的查看者。

  1. api 文件夹中创建一个名为 getViewer.ts 的文件,该文件将包含我们需要实现的函数。为刚刚创建的类型添加一个导入语句:

    import { ViewerData } from './types';
    
  2. 在导入语句下,添加一个常量,并将其分配给下面的 GraphQL 查询:

    export const GET_VIEWER_QUERY = `
    
      query {
    
        viewer {
    
          name
    
          avatarUrl
    
        }
    
      }
    
    `;
    

这是我们之前在章节中用来获取当前查看者姓名和头像 URL 的相同查询。

  1. 在此文件中添加以下类型,它表示 GraphQL API 调用的响应:

    type GetViewerResponse = {
    
      data: {
    
        viewer: ViewerData;
    
      };
    
    };
    
  2. 按照以下方式开始实现函数:

    export async function getViewer() {
    
      const response = await fetch(
    
        process.env.REACT_APP_GITHUB_URL!
    
      );
    
    }
    

我们使用 fetch 函数向 GraphQL API 发送请求。我们使用了 REACT_APP_GITHUB_URL 环境变量来指定 GraphQL API URL。环境变量值可以是 undefined,但我们知道这不是情况,所以我们在此之后添加了一个非空断言 (!)。

  1. 按照以下方式在请求体中指定 GraphQL 查询:

    export async function getViewer() {
    
      const response = await fetch(
    
        process.env.REACT_APP_GITHUB_URL!,
    
        {
    
          body: JSON.stringify({
    
            query: GET_VIEWER_QUERY
    
          }),
    
          headers: {
    
            'Content-Type': 'application/json'
    
          }
    
        }
    
      );
    
    }
    

GraphQL 查询在请求体中以对象结构指定,包含一个 query 属性,该属性包含 GraphQL 查询字符串,在我们的例子中是 GET_VIEWER_QUERY。我们还指定请求以 JSON 格式使用 Content-Type HTTP 头。

  1. 对于 GraphQL API 请求必须使用 HTTP POST 方法。因此,让我们在请求中指定这一点:

    export async function getViewer() {
    
      const response = await fetch(
    
        process.env.REACT_APP_GITHUB_URL!,
    
        {
    
          method: 'POST',
    
          body: ...,
    
          headers: ...,
    
        }
    
      );
    
    }
    
  2. PAT 保护 GitHub GraphQL API,因此让我们将其添加到请求中:

    export async function getViewer() {
    
      const response = await fetch(
    
        process.env.REACT_APP_GITHUB_URL!,
    
        {
    
          ...,
    
          headers: {
    
            'Content-Type': 'application/json',
    
            Authorization: `bearer ${process.env.REACT_APP_          GITHUB_PAT}`
    
          },
    
        }
    
      );
    
    }
    
  3. 函数的最后几个步骤是获取 JSON 响应体,并适当地对其进行类型化,然后再返回:

    export async function getViewer() {
    
      const response = await fetch(
    
        ...
    
      );
    
      const body = (await response.json()) as unknown;
    
      assertIsGetViewerResponse(body);
    
      return body.data;
    
    }
    
  4. 我们使用名为assertIsGetViewerResponse的类型断言函数来缩小body的类型。该函数的实现较长,与我们在第九章,“与 RESTful API 交互”中实现的模式相同,因此我们在此步骤中不列出它,但请参阅github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter10/Using-React-Query/src/api/getViewer.ts以了解该函数的实现。

一个不同之处在于,该函数的参数类型为any而不是unknown。这是由于 TypeScript 的一个已知问题,即当unknown类型是对象时,无法缩小其类型。有关更多信息,请参阅以下链接:github.com/microsoft/TypeScript/issues/25720。在这种情况下使用any类型是可以的——assertIsGetViewerResponse函数将完美地工作。

这样就完成了获取已登录 GitHub 观看者详细信息的函数实现。

接下来,我们将创建页眉组件。

创建页眉组件

我们将创建一个应用程序页眉组件,该组件将调用我们刚刚实现的getViewer函数并显示观看者的姓名和头像:

  1. 我们将使用 React Query 调用getViewer并管理它返回的数据。因此,让我们通过在终端中运行以下命令来安装此包:

    npm i @tanstack/react-query
    
  2. src文件夹中创建一个名为Header.tsx的组件文件。

  3. Header.tsx中添加以下导入语句,以导入 React Query 的useQuery钩子和我们的getViewer函数:

    import { useQuery } from '@tanstack/react-query';
    
    import { getViewer } from './api/getViewer';
    
  4. 按照以下步骤开始实现Header组件:

    export function Header() {
    
      const { isLoading, data } = useQuery(['viewer'],     getViewer);
    
    }
    

我们使用useQuery钩子调用getViewergetViewer返回的数据将位于解构的data变量中。我们还解构了一个isLoading变量,以便在下一步实现加载指示器。

  1. 添加如下加载指示器:

    export function Header() {
    
      const { isLoading, data } = useQuery(['viewer'],     getViewer);
    
      if (isLoading || data === undefined) {
    
        return <div>...</div>;
    
      }
    
    }
    
  2. 使用以下 JSX 完成组件实现:

    export function Header() {
    
      ...
    
      return (
    
        <header className="flex flex-col items-center text-      slate-50 bg-slate-900 h-40 p-5">
    
          <img
    
            src={data.viewer.avatarUrl}
    
            alt="Viewer"
    
            className="rounded-full w-16 h-16"
    
          />
    
          <div>{data.viewer.name}</div>
    
          <h1 className="text-xl font-bold">GitHub Search</        h1>
    
        </header>
    
      );
    
    }
    

渲染一个背景为非常深灰色的header元素。该header元素包含观看者的头像、姓名和GitHub 搜索的标题,所有内容水平居中。

这样就完成了页眉的实现。接下来,我们将将其添加到组件树中。

将 Header 组件添加到应用程序中

执行以下步骤将Header组件添加到App组件中:

  1. 打开App.tsx并删除所有现有内容。

  2. 添加导入语句,用于 React Query 的提供者组件和客户端以及我们的Header组件:

    import {
    
      QueryClient,
    
      QueryClientProvider,
    
    } from '@tanstack/react-query';
    
    import { Header } from './Header';
    
  3. 通过将 React Query 的提供者组件包裹在Header组件周围来实现组件:

    const queryClient = new QueryClient();
    
    function App() {
    
      return (
    
        <QueryClientProvider client={queryClient}>
    
          <Header />
    
        </QueryClientProvider>
    
      );
    
    }
    
    export default App;
    
  4. 现在,让我们在终端中运行npm start来尝试应用程序。应该会显示包含你的头像和姓名的页眉:

图 10.10 – 包含查看者头像和名称的头部

图 10.10 – 包含查看者头像和名称的头部

这完成了应用头部。接下来,我们将开始实现应用的主要部分。

创建仓库页面

应用程序的主要部分将是一个允许用户搜索 GitHub 仓库并为其加星的页面。页面组件将被命名为 RepoPage,并将引用其他三个组件,如下所示:

图 10.11 – 仓库页面组件结构

图 10.11 – 仓库页面组件结构

这里是对组件的解释:

  • 允许用户输入搜索条件的表单将包含在 SearchRepoForm 组件中

  • FoundRepo 组件将在搜索后渲染匹配的仓库

  • StarRepoButton 组件将渲染用户可以点击以加星仓库的按钮

  • RepoPage 组件将使用 React Query 来管理对 GitHub GraphQL API 的调用并存储返回的数据

接下来,我们将通过实现一个用于执行仓库搜索的函数来开始构建仓库页面。

创建搜索函数

我们将首先实现一个调用 GitHub GraphQL API 以查找仓库的函数。执行以下步骤:

  1. 我们将首先创建几个函数将使用的类型。打开 src/api/types.ts 并添加以下类型:

    export type SearchCriteria = {
    
      org: string,
    
      repo: string,
    
    };
    
    export type RepoData = {
    
      repository: {
    
        id: string,
    
        name: string,
    
        description: string,
    
        viewerHasStarred: boolean,
    
        stargazers: {
    
          totalCount: number,
    
        },
    
      },
    
    };
    

SearchCriteria 类型表示我们在 GraphQL 查询参数中需要的信息,以找到 GitHub 仓库。RepoData 类型表示从仓库搜索返回的数据。

  1. src/api 文件夹中创建一个名为 getRepo.ts 的函数文件。

  2. 打开 getRepo.ts 并首先导入刚刚创建的类型:

    import { RepoData, SearchCriteria } from './types';
    
  3. 为以下 GraphQL 查询添加一个常量:

    export const GET_REPO = `
    
      query GetRepo($org: String!, $repo: String!) {
    
        repository(owner: $org, name: $repo) {
    
          id
    
          name
    
          description
    
          viewerHasStarred
    
          stargazers {
    
            totalCount
    
          }
    
        }
    
      }
    
    `;
    

这是我们在本章 GitHub GraphQL API 探索器中创建的相同查询。

  1. 在常量下方添加以下类型:

    type GetRepoResponse = {
    
      data: RepoData;
    
    };
    

GetRepoResponse 类型表示从 GraphQL 查询返回的数据 – 它引用我们在 步骤 1 中创建的 RepoData 类型。

  1. 按以下方式实现函数:

    export async function getRepo(searchCriteria: SearchCriteria) {
    
      const response = await fetch(process.env.REACT_APP_    GITHUB_URL!, {
    
        method: 'POST',
    
        body: JSON.stringify({
    
          query: GET_REPO,
    
          variables: {
    
            org: searchCriteria.org,
    
            repo: searchCriteria.repo,
    
          },
    
        }),
    
        headers: {
    
          'Content-Type': 'application/json',
    
          Authorization: `bearer ${process.env.REACT_APP_        GITHUB_PAT}`,
    
        },
    
      });
    
      const body = (await response.json()) as unknown;
    
      assertIsGetRepoResponse(body);
    
      return body.data;
    
    }
    

这与我们在之前创建的获取查看者信息的函数遵循相同的模式。一个区别是我们已经指定了 GraphQL 查询 orgrepo 参数,它们被设置为 searchCriteria 函数参数中的属性。

  1. assertIsGetRepoResponse 类型断言函数遵循与之前类型断言函数相同的模式。实现较为冗长,因此在此未列出。您可以在以下位置找到实现:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter10/Using-React-Query/src/api/getRepo.ts

这完成了查找 GitHub 仓库的函数实现。

接下来,我们将创建仓库搜索表单组件。

创建搜索表单组件

我们将实现一个表单组件,允许用户搜索仓库。表单将包含组织和仓库名称字段。组件在表单提交时不会调用 GitHub GraphQL API;相反,它将提交的搜索条件传递回页面组件以执行此操作。

我们将使用 React Hook Form 进行实现,它应该已经安装。实现模式与之前我们实现的表单非常相似,因此这里没有详细列出执行此实现的步骤。SearchRepoForm 组件的实现可以从书的 GitHub 仓库中复制如下:

  1. src 文件夹中创建一个名为 repoPage 的新文件夹,然后在此文件夹中创建一个名为 SearchRepoForm.tsx 的新文件。

  2. 打开 SearchRepoForm.tsx 并将其内容从 github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/blob/main/Chapter10/Using-React-Query/src/repoPage/SearchRepoForm.tsx 复制到其中。

SearchRepoForm 组件的实现现在已经在我们的项目中就绪。

接下来,我们将实现一个渲染找到的仓库的组件。

创建 FoundRepo 组件

FoundRepo 组件将显示仓库名称、描述和星级数。执行以下步骤来实现此组件:

  1. src/repoPage 文件夹中创建一个名为 FoundRepo.tsx 的文件。

  2. 通过添加以下组件属性类型来开始实现:

    type Props = {
    
      name: string;
    
      description: string;
    
      stars: number;
    
    };
    

因此,仓库名称、描述和星级数将被传递到组件中。

  1. 添加以下组件实现:

    export function FoundRepo({ name, description, stars }: Props) {
    
      return (
    
        <div className="py-4">
    
          <div className="flex flex-row items-center justify-        between mb-2">
    
            <h2 className="text-xl font-bold">{name}</h2>
    
            <div className="px-4 py-2 rounded-xl text-          gray-800 bg-gray-200 font-semibold text-sm flex           align-center w-max">
    
              {stars} Stars
    
            </div>
    
          </div>
    
          <p>{description}</p>
    
        </div>
    
      );
    
    }
    

仓库名称被渲染为粗体标题。星级数在仓库名称右侧的灰色圆角背景中渲染。描述在名称下方渲染。

这样就完成了找到的仓库组件的实现。

接下来,我们将实现调用 GitHub GraphQL API 来标记仓库的函数。

创建标记仓库的函数

我们将使用本章前面用于标记 GitHub 仓库的相同 GraphQL 查询。函数中使用的模式将与之前我们创建的 getViewer 函数相似。执行以下步骤:

  1. src/api 文件夹中创建一个名为 starRepo.ts 的文件,包含以下 GraphQL 查询:

    export const STAR_REPO = `
    
      mutation ($repoId: ID!) {
    
        addStar(input: { starrableId: $repoId }) {
    
          starrable {
    
            stargazers {
    
              totalCount
    
            }
    
          }
    
        }
    
      }
    
    `;
    

这是我们在 GitHub GraphQL API 探索器中之前创建的相同查询。

  1. 添加以下函数实现:

    export async function starRepo(repoId: string) {
    
      const response = await fetch(process.env.REACT_APP_    GITHUB_URL!, {
    
        method: 'POST',
    
        body: JSON.stringify({
    
          query: STAR_REPO,
    
          variables: {
    
            repoId,
    
          },
    
        }),
    
        headers: {
    
          'Content-Type': 'application/json',
    
          Authorization: `bearer ${process.env.REACT_APP_        GITHUB_PAT}`,
    
        },
    
      });
    
      await response.json();
    
    }
    

这与其他调用 GitHub GraphQL API 的函数遵循相同的模式。

这样就完成了调用 GitHub GraphQL API 来标记仓库的功能。

接下来,我们将实现星号按钮组件。

创建星号按钮

星号按钮是一个被样式化为黑色文字的普通按钮。

src/repoPage 文件夹中创建一个名为 StarRepoButton.tsx 的文件,并将以下实现添加到其中:

type Props = {
  onClick: () => void;
};
export function StarRepoButton({ onClick }: Props) {
  return (
    <button
      type="button"
      className="mt-2 h-10 px-6 font-semibold bg-black text-        white"
      onClick={onClick}
    >
      Star
    </button>
  );
}

这样就完成了星号按钮的实现。

接下来,我们将创建应用的主页组件。

创建仓库页面

仓库页面组件将引用我们刚刚创建的 SearchRepoFormFoundRepoStarRepoButton 组件。此组件还将调用我们使用 React Query 创建的 getRepostarRepo 函数。为此,执行以下步骤:

  1. src/repoPage 文件夹中创建一个名为 RepoPage.tsx 的文件,并添加以下导入语句:

    import { useState } from 'react';
    
    import {
    
      useQuery,
    
      useMutation,
    
      useQueryClient,
    
    } from '@tanstack/react-query';
    
    import { getRepo } from '../api/getRepo';
    
    import { starRepo } from '../api/starRepo';
    
    import { RepoData, SearchCriteria } from '../api/types';
    
    import { SearchRepoForm } from './SearchRepoForm';
    
    import { FoundRepo } from './FoundRepo';
    
    import { StarRepoButton } from './StarRepoButton';
    

我们已经导入了我们之前创建的组件和数据函数,以及 React Query 的钩子和客户端。我们还导入了 React 的状态钩子,因为我们需要在 React Query 之外存储一些状态。

  1. 按照以下方式开始组件实现:

    export function RepoPage() {
    
      const [searchCriteria, setSearchCriteria] = useState<
    
        SearchCriteria | undefined
    
      >();
    
    }
    

我们将搜索标准存储在状态中,以便在下一步中将它输入到 useQuery 中。我们将在搜索仓库表单提交的 第 6 步 中设置此状态。

  1. 接下来,调用 useQuery 钩子以获取给定搜索标准对应的仓库,如下所示:

    export function RepoPage() {
    
      const [searchCriteria, setSearchCriteria] = ...
    
      const { data } = useQuery(
    
        ['repo', searchCriteria],
    
        () => getRepo(searchCriteria as SearchCriteria),
    
        {
    
          enabled: searchCriteria !== undefined,
    
        }
    
      );
    
    }
    

我们不希望在组件挂载时执行查询,因此我们使用 enabled 选项仅在 searchCriteria 设置时运行查询,这将在搜索仓库表单提交时发生。

我们在查询键中使用搜索标准,并将其传递给 getRepo 函数。我们对 getRepo 参数使用类型断言以从其中移除 undefined。这是安全的,因为我们知道在调用 getRepo 时由于 enabled 选项表达式,它不能是 undefined

  1. 按照以下方式定义星号变异:

    export function RepoPage() {
    
      const [searchCriteria, setSearchCriteria] = ...
    
      const { data } = useQuery(...);
    
      const queryClient = useQueryClient();
    
      const { mutate } = useMutation(starRepo, {
    
        onSuccess: () => {
    
          queryClient.setQueryData<RepoData>(
    
            ['repo', searchCriteria],
    
            (repo) => {
    
              if (repo === undefined) {
    
                return undefined;
    
              }
    
              return {
    
                ...repo,
    
                viewerHasStarred: true,
    
              };
    
            }
    
          );
    
        }
    
      });
    
    }
    

变异调用我们之前创建的 getRepo 函数。我们使用变异的 onSuccess 选项来更新 React Query 的缓存仓库数据,并将 viewerHasStarred 属性设置为 true

  1. 从组件返回以下 JSX:

    export function RepoPage() {
    
      ...
    
      return (
    
        <main className="max-w-xs ml-auto mr-auto">
    
          <SearchRepoForm onSearch={handleSearch} />
    
          {data && (
    
            <>
    
              <FoundRepo
    
                name={data.repository.name}
    
                description={data.repository.description}
    
                stars={data.repository.stargazers.totalCount}
    
              />
    
              {!data.repository.viewerHasStarred && (
    
                <StarRepoButton onClick={handleStarClick} />
    
              )}
    
            </>
    
          )}
    
        </main>
    
      );
    
    }
    

组件被包裹在一个 main 元素中,它使内容居中。仓库搜索表单放置在 main 元素内部。如果找到了仓库,则会渲染找到的仓库(如果找到了仓库)以及星号按钮(如果仓库尚未被收藏)。

我们将在以下步骤中实现 handleSearchhandleStarClick 处理器。

  1. 按照以下方式创建 handleSearch 处理器:

    export function RepoPage() {
    
      ...
    
      function handleSearch(search: SearchCriteria) {
    
        setSearchCriteria(search);
    
      }
    
      return ...
    
    }
    

处理器设置 searchCriteria 状态,这会触发重新渲染以及 useQuery 钩子调用 getRepo 并传入搜索标准。

  1. 按照以下方式创建 handleStarClick 处理器:

    export function RepoPage() {
    
      ...
    
      function handleStarClick() {
    
        if (data) {
    
          mutate(data.repository.id);
    
        }
    
      }
    
      return ...
    
    }
    

处理器调用变异并传入找到的仓库的 ID,这将调用 starRepo 函数。

这样就完成了仓库页面组件的实现。

  1. 打开 App.tsx 并在应用标题下添加我们刚刚创建的 RepoPage 组件:

    ...
    
    import { RepoPage } from './repoPage/RepoPage';
    
    ...
    
    function App() {
    
      return (
    
        <QueryClientProvider client={queryClient}>
    
          <Header />
    
          <RepoPage />
    
        </QueryClientProvider>
    
      );
    
    }
    
  2. 现在,让我们在终端中运行 npm start 来尝试这个应用。应该会显示仓库搜索表单,如下所示:

图 10.12 – 仓库搜索表单

图 10.12 – 仓库搜索表单

  1. 输入一个你还没有收藏的 GitHub 组织和仓库,然后按 Search。找到的仓库将显示一个 Star 按钮:

图 10.13 – 带有 Star 按钮的找到的仓库

图 10.13 – 带有 Star 按钮的找到的仓库

  1. 点击 Star 按钮来收藏仓库。然后 Star 按钮将消失。

  2. 在继续之前,通过在终端中按 Ctrl + C 停止应用运行。

这完成了应用的第一次迭代。以下是使用 fetch 和 React Query 与 GraphQL API 交互的关键点的总结:

  • fetch 函数可以通过将查询或突变放在请求体中并使用 HTTP POST 方法来调用 GraphQL API。

  • React Query 可以执行包含 fetch 的函数并管理响应数据。

  • useQueryuseMutation 上的 enabled 选项可以在用户与应用交互时执行包含 fetch 的函数。我们使用了这个功能在提交仓库搜索表单时执行查询。

在下一节中,我们将重构代码以使用专门的 GraphQL 客户端。

使用 Apollo Client

在本节中,我们将了解 Apollo Client 并在我们构建的应用中使用它,以替换 React Query 和 fetch 的使用。

理解 Apollo Client

Apollo Client 是一个用于与 GraphQL 服务器交互的客户端库。它拥有名为 useQueryuseMutation 的查询和突变钩子,类似于 React Query。Apollo Client 还像 React Query 一样在客户端缓存中存储数据,并需要一个放置在需要 GraphQL 数据的组件之上的提供者组件。

Apollo Client 做的一件事是,它直接与 GraphQL API 交互,而不是要求一个函数来做这件事,这是 React Query 所不具备的。

安装 Apollo Client

我们的第一项任务是安装 Apollo Client,我们可以在终端中运行以下命令来完成:

npm i @apollo/client graphql

这个库包含了 TypeScript 类型,因此不需要安装额外的包。

重构 App 组件

我们将要重构的第一个组件是 App 组件。执行以下步骤:

  1. 打开 App.tsx 并将 React Query 的导入替换为以下 Apollo Client 的导入语句:

    import {
    
      ApolloClient,
    
      InMemoryCache,
    
      ApolloProvider,
    
    } from '@apollo/client';
    
  2. 更新 queryClient 变量的赋值如下:

    const queryClient = new ApolloClient({
    
      uri: process.env.REACT_APP_GITHUB_URL!,
    
      cache: new InMemoryCache(),
    
      headers: {
    
        Authorization: `bearer ${process.env.REACT_APP_GITHUB_PAT}`,
    
      }
    
    });
    

我们现在正在使用 Apollo Client。我们已经指定了 API 的 URL 和 PAT,因为 Apollo Client 将直接调用 API。

  1. 最后一步是将 JSX 中的 QueryClientProvider 替换为 ApolloProvider

    <ApolloProvider client={queryClient}>
    
      <Header />
    
      <RepoPage />
    
    </ApolloProvider>
    

App 组件现在正在使用 Apollo Client。

接下来,我们将重构 Header 组件。

重构 Header 组件

现在,我们将重构 Header 组件以使用 Apollo Client。执行以下步骤:

  1. 打开getViewer.ts文件。由于 Apollo Client 不需要这些,可以删除getViewerassertIsGetViewerResponse函数以及GetViewerResponse类型。还可以删除ViewerData的导入语句。

  2. 将以下导入语句添加到getViewer.tsx文件中:

    import { gql } from '@apollo/client';
    

gql是一个函数,我们将在下一步中使用它来包装 GraphQL 查询字符串常量。

  1. gql函数添加到 GraphQL 查询字符串常量之前,如下所示:

    export const GET_VIEWER_QUERY = gql`
    
      query {
    
        viewer {
    
          name
    
          avatarUrl
    
        }
    
      }
    
    `;
    

因此,GET_VIEWER_QUERY现在被分配给一个标签模板字面量,而不是一个普通字符串。我们在第五章中介绍了标签模板字面量,当时我们使用了 Emotion 的css属性。gql函数将查询字符串转换为 Apollo Client 可以使用的查询对象。

  1. 打开Header.tsx文件并更新useQuery的导入语句,使其来自 Apollo Client。同时导入从getViewer.ts导出的常量。我们不再需要导入getViewer

    import { useQuery } from '@apollo/client';
    
    import { GET_VIEWER_QUERY } from './api/getViewer';
    
  2. 现在更新useQuery钩子如下:

    const { loading: isLoading, data } = useQuery(
    
      GET_VIEWER_QUERY
    
    );
    

Apollo Client 的useQuery钩子接受一个查询定义对象的参数,并返回类似于 React Query 的有用状态变量。我们将loading状态变量别名为isLoading,以便加载指示器的渲染保持不变。

关于 Apollo Client 查询的更多信息,请参阅以下链接:www.apollographql.com/docs/react/data/queries/.

这样就完成了Header组件。

接下来,我们将重构仓库页面。

重构仓库页面

重构仓库页面将是一个类似的过程。执行以下步骤:

  1. 打开getRepo.ts文件,删除getRepoassertIsGetResponse函数以及GetRepoReponse类型。同时删除导入的RepoDataSearchCriteria类型。

  2. 导入gql函数并将其添加到查询字符串之前:

    import { gql } from '@apollo/client';
    
    export const GET_REPO = gql`
    
      query ...
    
    `;
    
  3. 打开starRepo.ts文件并删除starRepo函数。

  4. 导入gql函数并将其添加到查询字符串之前:

    import { gql } from '@apollo/client';
    
    export const STAR_REPO = gql`
    
      mutation ...
    
    `;
    
  5. 打开RepoPage.tsx文件并将 React Query 的导入语句替换为 Apollo Client 的导入语句。同时导入我们在前两个步骤中更改的 GraphQL 查询常量:

    import {
    
      useLazyQuery,
    
      useMutation,
    
      useApolloClient,
    
    } from '@apollo/client';
    
    import { GET_REPO } from '../api/getRepo';
    
    import { STAR_REPO } from '../api/starRepo';
    

我们将使用useLazyQuery钩子而不是useQuery,因为我们希望在表单提交时触发查询,而不是在组件挂载时。

  1. 将对useQuery的调用替换为以下对useLazyQuery的调用:

    const [getRepo, { data }] = useLazyQuery(GET_REPO);
    

useLazyQuery返回一个元组,其中第一个元素是一个可以调用来触发查询的函数。我们称这个触发函数为getRepo。第二个元组元素是一个包含有用状态变量的对象,例如 API 响应中的数据,我们已对其进行解构。

有关 useLazyQuery 的更多信息,请参阅以下链接:www.apollographql.com/docs/react/data/queries/#manual-execution-with-uselazyquery

  1. 接下来,将 queryClient 变量赋值和 useMutation 调用替换为以下内容:

    const queryClient = useApolloClient();
    
    const [starRepo] = useMutation(STAR_REPO, {
    
      onCompleted: () => {
    
        queryClient.cache.writeQuery({
    
          query: GET_REPO,
    
          data: {
    
            repository: {
    
              ...data.repository,
    
              viewerHasStarred: true,
    
            },
    
          },
    
          variables: searchCriteria,
    
        });
    
      },
    
    });
    

Apollo Client 的 useMutation 钩子的第一个参数是突变定义对象,在我们的例子中是 STAR_REPO。第二个参数包含突变选项。我们指定了 onCompleted 选项,这是一个在突变完成后调用的函数。我们使用此选项来更新数据缓存,以指示查看者现在已将仓库加星标。

有关 Apollo Client 突变的更多信息,请参阅以下链接:www.apollographql.com/docs/react/data/mutations

  1. 更新 handleSearch 函数以调用 useLazyQuery 触发函数:

    function handleSearch(search: SearchCriteria) {
    
      getRepo({
    
        variables: { ...search },
    
      });
    
      setSearchCriteria(search);
    
    }
    
  2. 更新 handleStarClick 函数以调用 useMutation 触发函数:

    async function handleStarClick() {
    
      if (data) {
    
        starRepo({ variables: { repoId: data.repository.id } });
    
      }
    
    }
    

这完成了仓库页面的重构。

  1. 现在,在终端中运行 npm start 来尝试应用程序。尝试搜索一个仓库并给它加星标——它应该像之前一样工作。

这完成了应用程序的第二轮迭代以及我们对 Apollo Client 的使用。以下是使用 Apollo Client 的关键点:

  • Apollo Client 是一个用于与 GraphQL API 交互的专用库

  • 与 React Query 不同,Apollo Client 直接与 GraphQL API 交互,因此不需要使用 fetch 的单独函数

  • Apollo Client 的 ApolloProvider 组件需要放置在组件树中,位于需要后端数据的地方之上

  • Apollo Client 的 useQuery 钩子允许在状态中获取和缓存数据

  • Apollo Client 的 useMutation 钩子允许更新数据

接下来,我们将总结本章内容。

摘要

在本章中,我们首先学习了查询和突变的 GraphQL 语法。GraphQL 的一个伟大功能是能够请求和接收所需的仅有的对象和字段。这真的可以帮助我们应用程序的性能。

我们使用了 React Query 和 fetch 来与 GraphQL API 进行交互。这与与 REST API 交互非常相似,但 HTTP 方法需要是 POST,并且查询或突变需要放在请求体中。我们在 React Query 中了解到的一个新功能是,可以使用 enabled 选项在用户通过交互与应用程序进行交互时触发查询。

我们重构了应用程序以使用 Apollo Client,这是一个专门的 GraphQL 客户端。它与 React Query 非常相似,因为它有 useQueryuseMutation 钩子以及一个提供者组件。与 React Query 相比的一个优点是 Apollo Client 直接与 GraphQL API 交互,这意味着我们编写的代码更少。

在下一章中,我们将介绍有助于我们构建可重用组件的模式。

问题

回答以下问题以检查你在本章中学到了什么:

  1. 以下是一个尝试使用 GraphQL 查询获取 GitHub 观众的姓名和电子邮件地址的示例:

    viewer: {
    
      name,
    
      email
    
    }
    

查询出错 - 问题是什么?

  1. 取消 GitHub 仓库星标的突变是什么?该突变应包含一个用于仓库 ID 的参数。

  2. 以下 fetch 的使用是尝试调用 GraphQL API:

    const response = await fetch(process.env.REACT_APP_API_URL!, {
    
      body: JSON.stringify({
    
        query: GET_DATA_QUERY,
    
      }),
    
    });
    

这不起作用 - 问题是什么?

  1. 当使用 Apollo Client 时,受保护的 GraphQL API 中的授权访问令牌在哪里指定?

  2. 一个组件使用 Apollo Client 的 useQuery 钩子从 GraphQL API 获取数据,但组件出现以下错误:

无法在上下文或作为选项传递中找到“client”。将根组件包裹在 中,或者通过选项传递 ApolloClient 实例 via options

你认为问题是什么?

  1. 以下尝试使用 Apollo Client 的 useQuery 钩子从 GraphQL API 获取数据:

    const { loading, data } = useQuery(`query {
    
      contact {
    
        name
    
        email
    
      }
    
    }
    
    `);
    

调用出错,你认为问题是什么?

  1. 可以从 Apollo Client 的 useMutation 钩子中解构哪个状态变量来确定请求是否返回了错误?

答案

  1. 查询语法不正确 - 语法类似于 JSON,但没有冒号和逗号。此外,可以省略 query 关键字,但最佳实践是包含它。以下是修正后的查询:

    query {
    
      viewer {
    
        name
    
        email
    
      }
    
    }
    
  2. 以下突变将取消 GitHub 仓库的星标:

    mutation ($repoId: ID!) {
    
      removeStar(input: { starrableId: $repoId }) {
    
        starrable {
    
          stargazers {
    
            totalCount
    
          }
    
        }
    
      }
    
    }
    
  3. 请求缺少 HTTP POST 方法:

    const response = await fetch(process.env.REACT_APP_API_URL!, {
    
      method: 'POST',
    
      body: JSON.stringify({
    
        query: GET_DATA_QUERY,
    
      }),
    
    });
    
  4. 当创建 Apollo Client 时,授权访问令牌被指定,并将其传递给提供者组件:

    const queryClient = new ApolloClient({
    
      ...,
    
      headers: {
    
        Authorization: `bearer ${process.env.REACT_APP_ACCESS_TOKEN}`,
    
      },
    
    });
    
    function App() {
    
      return (
    
        <ApolloProvider
    
          client={queryClient}
    
        >
    
          ...
    
        </ApolloProvider>
    
      );
    
    }
    
  5. 问题在于 Apollo Client 的 ApolloProvider 组件没有被放置在组件树中使用 useQuery 的组件之上。

  6. gql 函数必须应用于查询字符串,以将其转换为 Apollo Client 所期望的对象格式:

    const { loading, data } = useQuery(gql`
    
      query {
    
        viewer {
    
          name
    
          email
    
        }
    
      }
    
    `);
    
  7. 可以从 React Query 的 useMutation 钩子中解构 error 状态变量来确定 HTTP 请求是否返回了错误。

第四部分:高级 React

在本部分中,我们将学习多种不同的模式,使我们能够复用大量的 React 和 TypeScript 代码。我们还将介绍如何在 React 组件上实施自动化测试,这使我们能够快速发布应用程序的新功能。

本部分包括以下章节:

  • 第十一章可复用组件

  • 第十二章使用 Jest 和 React Testing Library 进行单元测试

第十一章:可重用组件

在本章中,我们将构建一个清单组件,并使用各种模式使其高度可重用,同时仍然具有强类型。

我们将首先使用 TypeScript 泛型来为传递给组件的数据提供强类型。然后,我们将使用属性展开模式使组件 API 灵活,并允许组件消费者使用渲染属性模式自定义渲染组件的部分。之后,我们将学习如何创建自定义钩子,并使用它来提取勾选项的逻辑,以及如何使组件内的状态可控制以改变组件的行为。

我们将涵盖以下主题:

  • 创建项目

  • 使用泛型属性

  • 使用属性展开

  • 使用渲染属性

  • 添加勾选功能

  • 创建自定义钩子

  • 允许内部状态受控

技术要求

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

本章中的所有代码片段都可以在以下网址找到:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter11

创建项目

在本节中,我们将为我们将要构建的应用程序及其文件夹结构创建项目。文件夹结构将非常简单,因为它包含一个带有我们将构建的清单组件的单页。

我们将使用与之前章节相同的方式,使用 Visual Studio Code 开发应用程序,因此请打开 Visual Studio Code 并执行以下步骤:

  1. 使用 Create React App 创建项目。如果您记不起这些步骤,请参阅第三章设置 React 和 TypeScript

  2. 我们将使用 Tailwind CSS 来设计应用程序,因此请将此安装到项目中并配置它。如果您记不起这些步骤,请参阅第五章前端设计方法

这就完成了项目设置。

使用泛型属性

在本节中,我们将花一些时间了解如何创建我们自己的泛型类型,并了解 TypeScript 中的keyof功能,这对于泛型类型非常有用。我们将使用这些知识来构建具有泛型属性的第一版清单组件。

理解泛型

我们在这本书中使用了泛型。例如,useState钩子有一个可选的泛型参数,用于状态变量的类型:

const [visible, setVisible] = useState<boolean>()

函数中的泛型参数允许该函数使用不同的类型进行重用,并且具有强类型。以下函数返回数组中的第一个元素,如果数组为空,则返回null。然而,该函数仅适用于string数组:

function first(array: Array<string>): string | null {
  return array.length === 0 ? null : array[0];
}

泛型使我们能够使这个函数适用于任何类型的数组。

泛型函数

尽管我们在这本书中使用了泛型函数,但我们还没有创建自己的泛型函数。泛型类型参数在函数括号之前的尖括号中定义:

function someFunc<T1, T2, ...>(...) {
 ...
}

泛型类型的名称可以是任何你喜欢的,但应该是有意义的,以便于理解。

这里是之前看到的函数的泛型版本。现在,它可以与包含任何类型元素的数组一起工作:

function first<Item>(array: Array<Item>): Item | null {
  return array.length === 0 ? null : array[0];
}

函数有一个名为Item的单个泛型参数,它在array函数参数的类型以及函数的返回类型中使用。

泛型类型

自定义类型也可以是泛型的。对于type别名,其泛型参数在类型名称之后的尖括号中定义:

type TypeName<T1, T2, …> = {
 ...
}

例如,React 组件的属性可以是泛型的。以下是一个泛型属性类型的示例:

type Props<Item> = {
  items: Item[];
  ...
};

Props类型有一个名为Item的单个泛型参数,它在items属性的类型中使用。

泛型 React 组件

泛型属性可以被集成到一个泛型函数中,以生成一个泛型 React 组件。以下是一个泛型List组件的示例:

type Props<Item> = {
  items: Item[];
};
export function List<Item>({ items }: Props<Item>) {
  ...
}

List组件中的items属性现在可以是任何类型,这使得组件更加灵活和可重用。

现在我们已经了解了如何创建具有泛型属性的组件,我们将创建检查列表组件的第一迭代。

创建一个基本的列表组件

我们现在开始创建我们的可重用组件。在这个迭代中,它将是一个包含从数据数组中获取的一些主要和次要文本的基本列表。

执行以下步骤:

  1. 首先,在src文件夹中创建一个名为Checklist的组件文件夹。然后,在这个文件夹中创建一个名为Checklist.tsx的文件。

  2. 打开Checklist.tsx并添加以下Props类型:

    type Props<Data> = {
    
      data: Data[];
    
      id: keyof Data;
    
      primary: keyof Data;
    
      secondary: keyof Data;
    
    };
    

下面是对每个属性的说明:

  • data属性是驱动列表中项的数据

  • id属性是每个数据项中唯一标识该项的属性名

  • primary属性是每个数据项中包含要渲染在各个项中的主要文本的属性名

  • secondary属性是每个数据项中包含要渲染在各个项中的补充文本的属性名

这是我们第一次在类型注解中遇到keyof运算符。它查询其后的指定类型以获取属性名,并从它们构造一个联合类型,因此idprimarysecondary的类型将是从每个数据项的所有属性名组成的联合类型。

  1. 接下来,开始按照以下方式实现组件函数:

    export function Checklist<Data>({
    
      data,
    
      id,
    
      primary,
    
      secondary,
    
    }: Props<Data>) {
    
      return (
    
        <ul className="bg-gray-300 rounded p-10">
    
          {data.map((item) => {
    
          })}
    
        </ul>
    
      );
    
    }
    

组件渲染一个灰色、无序列表元素,具有圆角。我们还遍历数据项,我们最终将在其中渲染每个项。

  1. 我们将首先在 data.map 函数内部实现该功能。该函数检查唯一标识符(idValue)是否为字符串或数字,如果不是,则不会渲染任何内容。该函数还检查主文本属性(primaryText)是否为字符串,如果不是,同样不会渲染任何内容:

    {data.map((item) => {
    
      const idValue = item[id] as unknown;
    
      if (
    
        typeof idValue !== 'string' &&
    
        typeof idValue !== 'number'
    
      ) {
    
        return null;
    
      }
    
      const primaryText = item[primary] as unknown;
    
      if (typeof primaryText !== 'string') {
    
        return null;
    
      }
    
      const secondaryText = item[secondary] as unknown;
    
    }
    
  2. 通过以下方式完成实现,将列表项渲染如下:

    {data.map((item) => {
    
      ...
    
      return (
    
        <li
    
          key={idValue}
    
          className="bg-white p-6 shadow rounded mb-4         last:mb-0"
    
        >
    
          <div className="text-xl text-gray-800 pb-1">
    
            {primaryText}
    
          </div>
    
          {typeof secondaryText === 'string' && (
    
            <div className="text-sm text-gray-500">
    
              {secondaryText}
    
            </div>
    
          )}
    
        </li>
    
      );
    
    })}
    

列表项以白色背景和圆角渲染。主文本以大号灰色文本渲染,次要文本则渲染得小得多。

  1. Checklist 文件夹中创建一个名为 index.ts 的新文件,并将 Checklist 组件导出到其中:

    export * from './Checklist';
    

此文件将简化 Checklist 组件的 import 语句。

  1. 在看到组件的实际效果之前,最后的步骤是将它添加到应用程序的组件树中。打开 App.tsx 并将其内容替换为以下内容:

    import { Checklist } from './Checklist';
    
    function App() {
    
      return (
    
        <div className="p-10">
    
          <Checklist
    
            data={[
    
              { id: 1, name: 'Lucy', role: 'Manager' },
    
              { id: 2, name: 'Bob', role: 'Developer' },
    
            ]}
    
            id="id"
    
            primary="name"
    
            secondary="role"
    
          />
    
        </div>
    
      );
    
    }
    
    export default App;
    

我们引用 Checklist 组件并将其传递一些数据。注意 idprimarysecondary 属性的类型安全性 – 我们被迫使用有效的属性名与数据项一起输入。

  1. 通过在终端中输入 npm start 来运行应用程序。清单组件将如图所示出现:

图 11.1 – 我们的基本清单组件

图 11.1 – 我们的基本清单组件

目前,该组件渲染一个基本列表 – 我们将在本章后面添加已检查的功能。

这完成了关于泛型属性的部分。

回顾一下,以下是一些关键点:

  • TypeScript 泛型允许可重用代码具有强类型。

  • 函数可以有泛型参数,这些参数在实现中被引用。

  • 类型也可以有泛型参数,这些参数在实现中被引用。

  • 通过向泛型函数组件中传递泛型属性类型,可以使 React 组件成为泛型。组件实现将基于泛型属性。

接下来,我们将了解一个允许属性类型从 HTML 元素继承属性的模式的用法。

使用属性展开

在本节中,我们将了解一个名为 ul 元素的模式。这将允许组件的消费者指定属性,例如清单的高度和宽度。

因此,执行以下步骤:

  1. 打开 Checklist.tsx 并从 React 中导入以下类型:

    import { ComponentPropsWithoutRef } from 'react';
    

此类型允许我们引用 HTML 元素(如 ul)的属性。它是一个泛型类型,它将 HTML 元素名称作为泛型参数。

  1. ul 元素的属性添加到组件属性类型中,如下所示:

    type Props<Data> = {
    
      data: Data[];
    
      id: keyof Data;
    
      primary: keyof Data;
    
      secondary: keyof Data;
    
    } & ComponentPropsWithoutRef<'ul'>;
    
  2. 添加一个 ulProps 来收集 ul 元素的所有属性到一个单一的 ulProps 变量中:

    export function Checklist<Data>({
    
      data,
    
      id,
    
      primary,
    
      secondary,
    
      ...ulProps
    
    }: Props<Data>) {
    
      ...
    
    }
    

这是我们在这本书中第一次使用剩余参数。它们将传递到函数中的多个参数收集到一个数组中,所以任何未称为 dataidprimarysecondary 的属性都将收集到 ulProps 数组中。有关剩余参数的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters

  1. 现在,我们可以使用扩展运算符将 ulProps 传播到 ul 元素上:

    export function Checklist<Data>({
    
      data,
    
      id,
    
      primary,
    
      secondary,
    
      ...ulProps
    
    }: Props<Data>) {
    
      return (
    
        <ul
    
          className="bg-gray-300 rounded p-10"
    
          {...ulProps}
    
        >...</ul>
    
      );
    
    }
    
  2. 我们可以使用 Checklist 的新功能来指定列表的高度和宽度。打开 App.tsx 并添加以下 style 属性,以及更多数据项:

    <Checklist
    
      data={[
    
        { id: 1, name: 'Lucy', role: 'Manager' },
    
        { id: 2, name: 'Bob', role: 'Developer' },
    
        { id: 3, name: 'Bill', role: 'Developer' },
    
        { id: 4, name: 'Tara', role: 'Developer' },
    
        { id: 5, name: 'Sara', role: 'UX' },
    
        { id: 6, name: 'Derik', role: 'QA' }
    
      ]}
    
      id="id"
    
      primary="name"
    
      secondary="role"
    
      style={{
    
        width: '300px',
    
        maxHeight: '380px',
    
        overflowY: 'auto'
    
      }}
    
    />
    
  3. 如果应用程序没有运行,请在终端中输入 npm start 来运行它。清单组件以我们预期的尺寸出现:

图 11.2 – 尺寸化的清单组件

图 11.2 – 尺寸化的清单组件

由于我们传递到组件中的样式,组件现在具有固定的高度,并带有垂直滚动条。

这就完成了我们对属性传播模式的运用。以下是对关键点的回顾:

  • 我们将属性类型与 ComponentPropsWithoutRef 交集,以添加我们想要传播到 HTML 元素上的属性

  • 我们在组件属性中使用剩余参数来收集所有 HTML 元素属性到一个数组中

  • 然后,我们可以在 JSX 中的 HTML 元素上扩展剩余参数

接下来,我们将学习一种允许消费者渲染组件部分的模式。

使用渲染属性

在本节中,我们将学习关于 渲染属性 模式的知识,并使用它来允许组件的消费者在清单组件内渲染项目。

理解渲染属性模式

使组件高度可重用的方法之一是允许消费者在其内部渲染内部元素。button 元素上的 children 属性就是这样一个例子,因为它允许我们指定我们喜欢的任何按钮内容:

<button>We can specify any content here</button>

渲染属性模式允许我们使用除 children 之外的属性来提供这种能力。当 children 属性已经被用于其他目的时,这非常有用,如下面的例子所示:

<Modal heading={<h3>Enter Details</h3>}>
  Some content
</Modal>

在这里,headingModal 组件中的一个渲染属性。

当允许消费者渲染与传递到组件中的数据相关的元素时,渲染属性非常有用,因为渲染属性可以是一个函数:

<List
  data={[...]}
  renderItem={(item) => <div>{item.text}</div>}
/>

之前的例子有一个名为 renderItem 的渲染属性,它在 List 组件中渲染每个列表项。数据项传递给它,以便它可以在列表项中包含其属性。这与我们接下来将为我们的清单组件实现的内容类似。

添加 renderItem 属性

我们将在清单中添加一个名为 renderItem 的属性,允许消费者控制列表项的渲染。执行以下步骤:

  1. 打开 Checklist.tsx 并将 ReactNode 类型添加到 React import 语句中:

    import { ComponentPropsWithoutRef, ReactNode } from 'react';
    

ReactNode代表 React 可以渲染的元素。因此,我们将使用ReactNode作为我们渲染属性的返回类型。

  1. Props类型添加一个名为renderItem的渲染属性:

    type Props<Data> = {
    
      data: Data[];
    
      id: keyof Data;
    
      primary: keyof Data;
    
      secondary: keyof Data;
    
      renderItem?: (item: Data) => ReactNode;
    
    } & React.ComponentPropsWithoutRef<'ul'>;
    

该属性是一个函数,它接受数据项并返回需要渲染的内容。我们将其设置为可选,因为我们将为列表项提供默认实现,同时也允许消费者覆盖它。

  1. renderItem添加到组件函数参数中:

    export function Checklist<Data>({
    
      data,
    
      id,
    
      primary,
    
      secondary,
    
      renderItem,
    
      ...ulProps
    
    }: Props<Data>) {
    
      ...
    
    }
    
  2. 在 JSX 中,在映射函数顶部添加一个if语句来检查是否指定了renderItem属性。如果指定了renderItem,则使用数据项调用它,并从映射函数中返回其结果:

    <ul ...>
    
      {data.map((item) => {
    
        if (renderItem) {
    
          return renderItem(item);
    
        }
    
        const idValue = item[id] as unknown;
    
        ...
    
      })}
    
    </ul>
    

因此,如果指定了renderItem,它将被调用以获取作为列表项渲染的元素。如果没有指定renderItem,它将像之前一样渲染列表项。

  1. 为了尝试新属性,打开App.tsx并添加以下renderItem属性:

    <Checklist
    
      ...
    
      renderItem={(item) => (
    
        <li key={item.id} className="bg-white p-4       border-b-2">
    
          <div className="text-xl text-slate-800 pb-1">
    
            {item.name}
    
          </div>
    
          <div className="text-slate-500">{item.role}</div>
    
        </li>
    
      )}
    
    />
    

列表项现在以扁平、白色的形式渲染,它们之间有边框。

  1. 如果应用没有运行,请在终端中输入npm start来运行它。带有覆盖列表项的清单组件将出现:

图 11.3 – 覆盖的列表项

图 11.3 – 覆盖的列表项

  1. 在继续下一节之前,请从App.tsx中的Checklist元素中移除renderItem的使用。然后应该出现列表项的默认渲染。

这就完成了关于渲染属性模式的这一节。为了回顾,以下是一些关键点:

  • 渲染属性模式允许组件消费者覆盖组件的部分渲染。

  • 渲染属性可以是元素或返回元素的函数。

  • 渲染属性的一个常见用例是数据驱动的列表,其中可以覆盖列表项的渲染。

接下来,我们将向清单组件添加检查功能。

添加检查功能

目前,我们的清单组件不包含检查项的能力,因此我们现在将复选框添加到项目列表中,使用户能够检查它们。我们将使用 React 状态跟踪已检查的项。

因此,执行以下步骤将此功能添加到我们的组件中:

  1. 打开Checklist.tsx并在 React import语句中添加useState

    import {
    
      ComponentPropsWithoutRef,
    
      ReactNode,
    
      useState
    
    } from 'react';
    

我们将使用状态来存储已检查项的 ID。

  1. 在组件实现顶部,添加已选中项的 ID 状态:

    const [checkedIds, setCheckedIds] = useState<IdValue[]>([]);
    

我们引用了一个尚未定义的IdValue类型 – 我们将在完成组件实现步骤 6 后定义它。

  1. 按如下方式将复选框添加到项目列表中:

    <li
    
      key={idValue}
    
      className="bg-white p-6 shadow rounded mb-4 last:mb-0"
    
    >
    
      <label className="flex items-center">
    
        <input
    
          type="checkbox"
    
          checked={checkedIds.includes(idValue)}
    
          onChange={handleCheckChange(idValue)}
    
        />
    
        <div className="ml-2">
    
          <div className="text-xl text-gray-800 pb-1">
    
            {primaryText}
    
          </div>
    
          {typeof secondaryText === 'string' && (
    
            <div className="text-sm text-gray-500">
    
              {secondaryText}
    
            </div>
    
          )}
    
        </div>
    
      </label>
    
    </li>
    

checkedIds状态通过检查列表项的 ID 是否包含在其中,为复选框的checked属性提供动力。

我们将在下一步实现引用的handleCheckChange函数。注意,引用调用函数时传递了已检查的列表项的 ID。

  1. 按照以下方式在组件中开始实现 handleCheckChange 函数:

    const [checkedIds, setCheckedIds] = useState<IdValue[]>([]);
    
    const handleCheckChange = (checkedId: IdValue) => () => {};
    
    return ...
    

这是一个返回处理函数的函数。这种复杂性是因为基本的已选处理函数没有传入列表项的 ID。这种方法被称为柯里化,更多关于它的信息可以在以下链接中找到:javascript.info/currying-partials

  1. 按照以下方式完成处理函数的实现:

    const handleCheckChange = (checkedId: IdValue) => () => {
    
      const isChecked = checkedIds.includes(checkedId);
    
      let newCheckedIds = isChecked
    
        ? checkedIds.filter(
    
            (itemCheckedid) => itemCheckedid !== checkedId
    
          )
    
        : checkedIds.concat(checkedId);
    
      setCheckedIds(newCheckedIds);
    
    };
    

实现更新列表项的 ID 到 checkedIds 状态,如果列表项已被选中,如果未选中则移除它。

  1. 接下来,让我们定义 IdValue 类型。在 Checklist 文件夹中创建一个名为 types.ts 的新文件,其中包含 IdValue 的定义:

    export type IdValue = string | number;
    

在这里,列表项的 ID 可以是 stringnumber 类型的值。

  1. 返回到 Checklist.tsx 并导入 IdValue

    import { IdValue } from './types';
    

现在应该已经解决了编译错误。

  1. 如果应用没有运行,请在终端中输入 npm start 来运行它。检查清单组件将带有每个列表项的复选框显示出来:

图 11.4 – 列表项的复选框

图 11.4 – 列表项的复选框

检查清单组件现在包括复选框。然而,有一个机会可以使已选逻辑可重用——我们将在下一节中介绍这一点。

创建自定义钩子

在本节中,我们将了解自定义 React 钩子。然后,我们将使用这些知识从检查清单组件中提取已选逻辑到一个可重用的自定义钩子。

理解自定义钩子

除了 useState 等标准钩子之外,React 允许我们创建自己的自定义钩子。自定义钩子允许逻辑在多个组件之间隔离和重用。

自定义钩子是通过一个以单词 use 开头的函数定义的。这种命名约定有助于 ESLint 检查自定义钩子使用中存在的问题。以下是一个提供切换逻辑的自定义钩子:

export function useToggle() {
  const [toggleValue, setToggleValue] = useState(false);
  function toggle() {
    setToggleValue(!toggleValue);
  }
  return {toggleValue, toggle};
};

自定义钩子包含当前切换值的状态,该值可以是 truefalse。它还包括一个名为 toggle 的函数,该函数切换当前值。当前切换值和 toggle 函数以对象结构从自定义钩子返回。

注意,不需要返回对象结构。如果自定义钩子只返回一个项目,则可以直接返回该项目。如果自定义钩子返回两个项目(如前例所示),则可以返回一个元组(如 useState 所做的那样)。对于两个以上的项目,对象结构更好,因为对象键可以清楚地说明每个项目是什么。

自定义钩子的另一个特性是它使用了其他标准 React 钩子。例如,useToggle 自定义钩子使用了 useState。如果自定义钩子没有调用 React 钩子或另一个自定义钩子,它就只是一个普通函数,而不是自定义钩子。

这个自定义钩子可以在组件的实现中如下使用:

const { toggleValue, toggle } = useToggle();
return (
  <div className="App">
    <button onClick={toggle}>{toggleValue ? 'ON' : 'OFF'}</button>
  </div>
);

从自定义钩子的返回值中解构出切换值(toggleValue)和toggle函数。切换值用于渲染文本truefalsetoggle函数也被分配给按钮的点击处理函数。

自定义钩子也可以接受参数。在这个例子中,我们在useToggle钩子中添加了一个默认值:

type Params = {
  defaultToggleValue?: boolean;
};
export function useToggle({ defaultToggleValue }: Params) {
  const [toggleValue, setToggleValue] = useState(
    defaultToggleValue
  );
  ...
}

在前面的示例中,参数以对象结构形式存在。当有多个参数时,对象结构很方便,并且添加新参数时不会出问题。

参数以对象的形式传递到自定义钩子中。以下是一个使用useToggle的示例,其初始值为true

const { toggleValue, toggle } = useToggle({
  defaultToggleValue: true
});

现在我们已经了解了如何创建和使用自定义钩子,我们将在我们的清单组件中将其付诸实践。

将勾选逻辑提取到自定义钩子中

我们将把勾选项的逻辑提取到一个自定义钩子中。这将允许未来的组件使用这个逻辑,并使代码更加整洁。

自定义钩子将被命名为useChecked,并将包含勾选列表项 ID 的状态。钩子还将包括一个可以附加到复选框的处理函数,以更新勾选列表项 ID 的状态。

要做到这一点,请执行以下步骤:

  1. Checklist文件夹中,创建一个名为useChecked.ts的自定义钩子文件。

  2. 打开useChecked.ts并添加以下import语句:

    import { useState } from 'react';
    
    import { IdValue } from './types';
    

该钩子将使用通过IdValue类型化的 React 状态。

  1. 开始实现自定义钩子的函数,通过初始化状态:

    export function useChecked() {
    
      const [checkedIds, setCheckedIds] =     useState<IdValue[]>([]);
    
    }
    

该钩子没有任何参数。useState的调用与当前Checklist组件中的调用完全相同——这可以复制粘贴到自定义钩子中。

  1. 向自定义钩子添加一个勾选处理函数。这可以从Checklist组件的实现中复制:

    export function useChecked() {
    
      const [checkedIds, setCheckedIds] =     useState<IdValue[]>([]);
    
      const handleCheckChange = (checkedId: IdValue) => () => {
    
        const isChecked = checkedIds.includes(checkedId);
    
        let newCheckedIds = isChecked
    
          ? checkedIds.filter(
    
              (itemCheckedid) => itemCheckedid !== checkedId
    
            )
    
          : checkedIds.concat(checkedId);
    
        setCheckedIds(newCheckedIds);
    
      };
    
    }
    
  2. 自定义钩子实现中的最后一个任务是返回勾选 ID 和处理函数:

    export function useChecked() {
    
      ...
    
      return { handleCheckChange, checkedIds };
    
    }
    
  3. 接下来,打开Checklist.tsx并移除状态定义和handleCheckChange处理函数。同时,从import语句中移除useStateIdValue,因为它们是多余的。

  4. 仍然在Checklist.tsx中,导入我们刚刚创建的useChecked钩子:

    import { useChecked } from './useChecked';
    
  5. 调用useChecked并解构勾选 ID 和处理函数:

    export function Checklist<Data>({ ... }: Props<Data>) {
    
      const { checkedIds, handleCheckChange } = useChecked();
    
      return ...
    
    }
    
  6. 如果应用没有运行,请在终端中输入npm start来运行它。清单组件将出现并像我们做出这些更改之前一样表现。

这样就完成了自定义钩子的实现和使用。总结一下,以下是一些关键点:

  • 自定义钩子使代码更加整洁,并且由于它们隔离了逻辑,因此是可重用的。

  • 自定义钩子必须以use开头。

  • 自定义钩子必须使用标准的 React 钩子或另一个自定义钩子。

  • 自定义钩子只是一个返回组件可以使用的有用东西的函数。当返回许多项目时,使用对象结构是理想的,因为对象键清楚地说明了每个项目是什么。

  • 自定义钩子可以有参数。使用对象结构作为参数对于许多项目来说很理想,并且当添加新参数时不会破坏任何东西。

接下来,我们将介绍一个模式,允许组件的消费者使用状态来控制其部分行为。

允许控制内部状态

在本节中,我们将学习如何允许组件的消费者控制其内部状态。我们将在清单组件中使用此模式,以便用户可以只检查单个项目。

理解如何控制内部状态

允许组件的消费者控制状态可以使组件的行为根据状态进行调整。让我们通过一个例子来了解,这个例子使用了我们在上一节学习自定义钩子时提到的useToggle自定义钩子。

需要额外的两个属性来允许控制内部状态——一个用于当前状态值,另一个用于变化处理器。这些额外的属性在useToggle中是toggleValueonToggleValueChange

type Params = {
  defaultToggleValue?: boolean;
  toggleValue?: boolean;
  onToggleValueChange?: (toggleValue: boolean) => void;
};
export function useToggle({
  defaultToggleValue,
  toggleValue,
  onToggleValueChange,
}: Params) {
  ...
}

这些属性被标记为可选,因为此模式并不强制组件的消费者控制状态——这是一个他们可以选择加入的功能。

注意

组件的消费者永远不会同时指定defaultToggleValuetoggleValuedefaultToggleValue仅在消费者不想使用状态来控制toggleValue时使用。当消费者想要使用状态来控制toggleValue时,他们可以设置他们状态的初始值。

现在toggleValue属性与toggleValue状态冲突,因为它们有相同的名称,因此需要将状态重命名:

const [resolvedToggleValue, setResolvedToggleValue] =
  useState(defaultToggleValue);
function toggle() {
  setResolvedToggleValue(!resolvedToggleValue);
}
return { resolvedToggleValue, toggle };

内部状态的默认值现在需要考虑可能存在控制状态的属性:

const [resolvedToggleValue, setResolvedToggleValue] =
  useState(defaultToggleValue || toggleValue);

当状态改变时,如果已定义变化处理器,则会被调用:

function toggle() {
  if (onToggleValueChange) {
    onToggleValueChange(!resolvedToggleValue);
  } else {
    setResolvedToggleValue(!resolvedToggleValue);
  }
}

再次强调,即使消费者没有控制状态,我们也需要更新内部状态。

实现此模式时的最后一步是在受控状态更新时更新内部状态。我们可以使用useEffect来完成此操作:

useEffect(() => {
  const isControlled = toggleValue !== undefined;
  if (isControlled) {
    setResolvedToggleValue(toggleValue);
  }
}, [toggleValue]);

当状态属性改变时,会触发效果。我们检查状态属性是否正在被控制;如果是,则使用其值更新内部状态。

下面是一个在useToggle中控制toggleValue的例子:

const [toggleValue, setToggleValue] = useState(false);
const onCount = useRef(0);
const { resolvedToggleValue, toggle } = useToggle({
  toggleValue,
  onToggleValueChange: (value) => {
    if (onCount.current >= 3) {
      setToggleValue(false);
    } else {
      setToggleValue(value);
      if (value) {
        onCount.current++;
      }
    }
  },
});

此示例将切换值存储在其自己的状态中,并将其传递给useToggleonToggleValueChange通过更新状态值来处理。仅允许状态值设置为true最多三次的逻辑。

因此,这个用例已经覆盖了切换的默认行为,使其只能设置为true最多三次。

现在我们已经了解了如何允许内部状态受控,我们将在清单组件中使用它。

允许checkedIds受控

目前,我们的清单组件允许选择多个项目。如果我们允许checkedIds状态由消费者控制,他们可以更改清单组件,以便他们只能选择单个项目。

因此,执行以下步骤:

  1. 我们将从useChecked.ts开始。向 React import语句添加useEffect

    import { useState, useEffect } from 'react';
    
  2. 为受控的选中 ID 和更改处理程序添加新参数:

    type Params = {
    
      checkedIds?: IdValue[];
    
      onCheckedIdsChange?: (checkedIds: IdValue[]) => void;
    
    };
    
    export function useChecked({
    
      checkedIds,
    
      onCheckedIdsChange,
    
    }: Params) {
    
      ...
    
    }
    
  3. 更新内部状态名称为resolvedCheckedIds,并在定义的情况下将其默认为传入的checkedIds参数:

    export function useChecked({
    
      checkedIds,
    
      onCheckedIdsChange,
    
    }: Params) {
    
      const [resolvedCheckedIds, setResolvedCheckedIds] =
    
        useState<IdValue[]>(checkedIds || []);
    
      const handleCheckChange = (checkedId: IdValue) => () => {
    
        const isChecked = resolvedCheckedIds.      includes(checkedId);
    
        let newCheckedIds = isChecked
    
          ? resolvedCheckedIds.filter(
    
              (itemCheckedid) => itemCheckedid !== checkedId
    
            )
    
          : resolvedCheckedIds.concat(checkedId);
    
        setResolvedCheckedIds(newCheckedIds);
    
      };
    
      return { handleCheckChange, resolvedCheckedIds };
    
    }
    
  4. 更新handleCheckChange处理程序以在定义的情况下调用传入的更改处理程序:

    const handleCheckChange = (checkedId: IdValue) => () => {
    
      const isChecked = resolvedCheckedIds.    includes(checkedId);
    
      let newCheckedIds = isChecked
    
        ? resolvedCheckedIds.filter(
    
            (itemCheckedid) => itemCheckedid !== checkedId
    
          )
    
        : resolvedCheckedIds.concat(checkedId);
    
      if (onCheckedIdsChange) {
    
        onCheckedIdsChange(newCheckedIds);
    
      } else {
    
        setResolvedCheckedIds(newCheckedIds);
    
      }
    
    };
    
  5. useCheck.ts中的最后一个任务是同步受控的选中 ID 与内部状态。添加以下useEffect钩子以实现此目的:

    useEffect(() => {
    
      const isControlled = checkedIds !== undefined;
    
      if (isControlled) {
    
        setResolvedCheckedIds(checkedIds);
    
      }
    
    }, [checkedIds]);
    
  6. 现在,打开Checklist.tsx并导入IdValue类型:

    import { IdValue } from './types';
    
  7. 为受控的选中 ID 和更改处理程序添加新 props:

    type Props<Data> = {
    
      data: Data[];
    
      id: keyof Data;
    
      primary: keyof Data;
    
      secondary: keyof Data;
    
      renderItem?: (item: Data) => ReactNode;
    
      checkedIds?: IdValue[];
    
      onCheckedIdsChange?: (checkedIds: IdValue[]) => void;
    
    } & ComponentPropsWithoutRef<'ul'>;
    
    export function Checklist<Data>({
    
      data,
    
      id,
    
      primary,
    
      secondary,
    
      renderItem,
    
      checkedIds,
    
      onCheckedIdsChange,
    
      ...ulProps
    
    }: Props<Data>) {}
    
  8. 将这些 props 传递给useChecked并将解构的checkedIds变量重命名为resolvedCheckedIds

    const { resolvedCheckedIds, handleCheckChange } = useChecked({
    
      checkedIds,
    
      onCheckedIdsChange,
    
    });
    
    return (
    
      <ul className="bg-gray-300 rounded p-10" {...ulProps}>
    
        {data.map((item) => {
    
          ...
    
          return (
    
            <li ... >
    
              <label className="flex items-center">
    
                <input
    
                  type="checkbox"
    
                  checked={resolvedCheckedIds.                includes(idValue)}
    
                  onChange={handleCheckChange(idValue)}
    
                />
    
                ...
    
              </label>
    
            </li>
    
          );
    
        })}
    
      </ul>
    
    );
    
  9. 在“清单”文件夹中打开index.ts文件。导出IdValue类型,因为组件的消费者现在可以传入checkedIds,这是一个该类型的数组:

    export type { IdValue } from './types';
    

export语句之后的type关键字是 TypeScript 在导出已从引用文件中导出的命名类型时必需的。

  1. 现在,打开App.tsx并从 React 导入useState,以及IdValue类型:

    import { useState } from 'react';
    
    import {
    
      Checklist,
    
      IdValue
    
    } from './Checklist';
    
  2. App组件中定义状态以用于单个选中 ID:

    function App() {
    
      const [checkedId, setCheckedId] = useState<IdValue |     null>(
    
        null
    
      );
    
      ...
    
    }
    

当没有选中项时,状态是null。这不能设置为undefined,因为Checklist会认为checkedIds是未受控的。

  1. 创建一个当项目被选中时的处理程序:

    function handleCheckedIdsChange(newCheckedIds: IdValue[]) {
    
      const newCheckedIdArr = newCheckedIds.filter(
    
        (id) => id !== checkedId
    
      );
    
      if (newCheckedIdArr.length === 1) {
    
        setCheckedId(newCheckedIdArr[0]);
    
      } else {
    
        setCheckedId(null);
    
      }
    
    }
    

处理程序将选中 ID 存储在状态中,或者在选中项被取消选中时将状态设置为null

  1. 按如下方式将选中 ID 和更改处理程序传递给Checklist元素:

    <Checklist
    
      ...
    
      checkedIds={checkedId === null ? [] : [checkedId]}
    
      onCheckedIdsChange={handleCheckedIdsChange}
    
    />;
    
  2. 让我们试一试。如果应用没有运行,请在终端中输入npm start来运行它。你会发现只能选中单个列表项。

这样就完成了关于允许内部状态受控的部分。以下是一个总结:

  • 这种模式很有用,因为它改变了组件的行为

  • 组件必须公开一个 prop 来控制状态值,另一个用于其更改处理程序

  • 内部,组件仍然管理状态,并使用useEffect与消费者同步

  • 如果状态是受控的,则调用消费者的更改处理程序在内部更改处理程序中

概述

在本章中,我们创建了一个可重用的清单组件,并在过程中使用了许多有用的模式。

我们首先学习了如何实现泛型属性,这允许组件使用不同的数据类型,但仍然保持强类型。我们使用它来允许将不同的数据传递到清单组件中,而不牺牲类型安全。

我们学习了如何允许组件的消费者将属性传播到内部元素。一个常见的用例是将属性传播到内部容器元素,以允许消费者调整其大小,这正是我们在清单组件中所做的。

渲染属性模式是开发可重用组件时最有用的模式之一。我们了解到它允许消费者负责渲染组件的部分。我们使用这个模式来覆盖我们的清单组件中列表项的渲染。

自定义钩子隔离逻辑,对于在组件之间共享逻辑并保持组件内的代码整洁非常有用。自定义钩子必须直接或间接地调用标准 React 钩子。我们将我们的清单组件中的选中逻辑提取到一个自定义钩子中。

我们最后学习的是允许组件的内部状态被控制。这个强大的模式允许组件的消费者调整其行为。我们使用这个模式来只允许在我们的清单组件中检查单个列表项。

在下一章中,我们将学习如何为 React 组件编写自动化测试。

问题

回答以下问题以检查你在本章中学到的内容:

  1. 以下组件的片段渲染了选项,可以选择其中一个:

    type Props<TOption> = {
    
      options: TOption[];
    
      value: string;
    
      label: string;
    
    };
    
    export function Select({
    
      options,
    
      value,
    
      label,
    
    }: Props<TOption>) {
    
      return ...
    
    }
    

尽管在组件的属性参数上抛出了以下 TypeScript 错误:找不到名称‘TOption’。问题是什么?

  1. 在问题 1 中的组件的 valuelabel 属性应该只设置为 options 值中的属性名。我们可以给 valuelabel 什么类型,以便 TypeScript 在类型检查中包含它们?

  2. 在前一个问题中的 Select 组件中添加了一个名为 option 的属性,如下所示:

    type Props<TOption> = {
    
      ...,
    
      option: ReactNode;
    
    };
    
    export function Select<TOption>({
    
      ...,
    
      option
    
    }: Props<TOption>) {
    
      return (
    
        <div>
    
          <input />
    
          {options.map((option) => {
    
            if (option) {
    
              return option;
    
            }
    
            return ...
    
          })}
    
        </div>
    
      );
    
    }
    

option 应该允许组件的消费者覆盖选项的渲染。你能发现实现中的缺陷吗?

  1. 以下是一个渲染 label 元素和 input 元素的 Field 组件:

    type Props = {
    
      label: string;
    
    } & ComponentPropsWithoutRef<'input'>;
    
    export function Field({ ...inputProps, label }: Props) {
    
      return (
    
        <>
    
          <label>{label}</label>
    
          <input {...inputProps} />
    
        </>
    
      );
    
    }
    

虽然存在一个问题——你能发现它吗?

  1. 消费者如何指定要传播到前一个问题中的 Field 组件中的 label 元素的属性?注意我们仍然希望消费者将属性传播到 input 元素。

  2. 在前一个问题中的 Field 组件中添加了一个自定义钩子。这个自定义钩子被命名为 useValid,它验证字段是否已填充:

    export function useValid() {
    
      function validate(value: string) {
    
        return (
    
          value !== undefined && value !== null && value !==         ''
    
        );
    
      }
    
      return validate;
    
    }
    
    export function Field({ ... }: Props) {
    
      const [valid, setValid] = useState(true);
    
      const validate = useValid();
    
      return (
    
        <>
    
          <label {...labelProps}>{label}</label>
    
          <input
    
            {...inputProps}
    
            onBlur={(e) => {
    
              setValid(validate(e.target.value));
    
            }}
    
          />
    
          {!valid && <span>Please enter something</span>}
    
        </>
    
      );
    
    }
    

实现有什么问题?

  1. 函数组件可以有多少个渲染属性?

答案

  1. 泛型类型必须在组件函数以及属性中定义:

    export function Select<TOption>({
    
      options,
    
      value,
    
      label,
    
    }: Props<TOption>) {
    
      return ...
    
    }
    
  2. keyof运算符可以用来确保valuelabeloptions中的键:

    type Props<TOption> = {
    
      options: TOption[];
    
      value: keyof TOption;
    
      label: keyof TOption;
    
    };
    
  3. 消费者可能需要选项的数据,因此属性应该是一个包含数据作为参数的函数:

    type Props<TOption> = {
    
      ...,
    
      renderOption: (option: TOption) => ReactNode;
    
    };
    
    export function Select<TOption>({
    
      options,
    
      value,
    
      label,
    
      renderOption,
    
    }: Props<TOption>) {
    
      return (
    
        <div>
    
          <input />
    
          {options.map((option) => {
    
            if (renderOption) {
    
              return renderOption(option);
    
            }
    
            return ...
    
        </div>
    
      );
    
    }
    
  4. 存在语法错误,因为剩余参数是第一个参数。剩余参数必须是最后一个:

    export function Field({ label, ...inputProps }: Props) {
    
      ...
    
    }
    
  5. 可以使用ComponentPropsWithoutRef类型添加labelProps属性。然后可以将这些属性展开到label元素上:

    type Props = {
    
      label: string;
    
      labelProps: ComponentPropsWithoutRef<'label'>;
    
    } & ComponentPropsWithoutRef<'input'>;
    
    export function Field({
    
      label,
    
      labelProps,
    
      ...inputProps
    
    }: Props) {
    
      return (
    
        <>
    
          <label {...labelProps}>{label}</label>
    
          <input {...inputProps} />
    
        </>
    
      );
    
    }
    
  6. useValid没有调用标准的 React 钩子。更好的实现是将状态也提取到自定义钩子中:

    export function useValid() {
    
      const [valid, setValid] = useState(true);
    
      function validate(value: string) {
    
        setValid(
    
          value !== undefined && value !== null && value !== ''
    
        );
    
      }
    
      return { valid, validate };
    
    }
    
    export function Field({ ... }: Props) {
    
      const { valid, validate } = useValid();
    
      return (
    
        <>
    
          <label {...labelProps}>{label}</label>
    
          <input
    
            {...inputProps}
    
            onBlur={(e) => {
    
              validate(e.target.value);
    
            }}
    
          />
    
          {!valid && <span>Please enter something</span>}
    
        </>
    
      );
    
    }
    
  7. 组件可以拥有的渲染属性数量没有限制。

第十二章:使用 Jest 和 React Testing Library 进行单元测试

在本章中,我们将学习如何使用 Jest 和 React Testing Library,这两个流行的自动化测试工具可以在 React 应用程序中一起使用。我们将对我们在第十一章“可复用组件”中创建的清单组件创建测试。

我们将首先关注 Jest,并使用它来测试简单的函数,了解 Jest 的常见 匹配器函数用于编写期望,以及如何执行测试以检查它们是否通过。

然后,我们将继续学习使用 React Testing Library 进行组件测试。我们将了解不同的查询类型和变体以及它们如何帮助我们创建健壮的测试。

之后,我们将学习使用 React Testing Library 伴侣包模拟用户交互的最准确方法。我们将使用它来为清单组件中正在检查的项目创建测试。

在本章结束时,我们将学习如何确定哪些代码被测试覆盖,更重要的是,哪些代码未被覆盖。我们使用 Jest 的代码覆盖率工具来完成此操作,并理解它给出的所有不同覆盖率统计信息。

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

  • 测试纯函数

  • 测试组件

  • 模拟用户交互

  • 获取代码覆盖率

技术要求

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

我们将从上一章结束的代码的修改版本开始。修改后的代码包含提取到纯函数中的逻辑,这将非常适合用于我们编写的第一个测试。此代码可在网上找到,地址为github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter12/start

执行以下步骤将此下载到您的本地计算机:

  1. 在浏览器中访问download-directory.github.io/

  2. 在网页上的文本框中,输入以下 URL:github.com/PacktPublishing/Learn-React-with-TypeScript-2nd-Edition/tree/main/Chapter12/start

  3. 按下 Enter 键。现在将下载包含 start 文件的 ZIP 文件。

  4. 将 ZIP 文件解压到您选择的文件夹中,并在 Visual Studio Code 中打开该文件夹。

  5. 在 Visual Studio Code 的终端中,执行以下命令来安装所有依赖项:

    npm i
    

您现在可以开始为清单组件编写测试了。

测试纯函数

在本节中,我们将首先了解 Jest 测试的基本组成部分。然后,我们将通过在清单组件中的纯函数上实现测试来将理论知识付诸实践。

纯函数对于给定的一组参数值具有一致的输出值。这些函数只依赖于函数参数,不依赖于函数外部的内容,并且也不改变传递给它们的任何参数值。因此,纯函数非常适合学习如何编写测试,因为它们没有需要处理的复杂副作用。

在本节中,我们还将介绍如何测试异常,这对于测试类型断言函数很有用。最后,在本节的最后,我们将学习如何在测试套件中运行测试。

理解 Jest 测试

Jest 在 Create React App 项目中预先安装并配置为在具有特定扩展名的文件中查找测试。这些文件扩展名是 .test.ts 用于纯函数的测试和 .test.tsx 用于组件的测试。或者,可以使用 .spec.* 文件扩展名。

测试是通过 Jest 的 test 函数定义的:

test('your test name', () => {
  // your test implementation
});

test 函数有两个参数,用于测试名称和实现。通常,测试实现是一个匿名函数。测试实现可以通过在匿名函数前放置 async 关键字来异步执行:

test('your test name', async () => {
  // your test implementation
});

测试实现将包括调用带有待测试参数的函数并检查结果是否符合我们的期望:

test('your test name', async () => {
  const someResult = yourFunction('someArgument');
  expect(someResult).toBe('something');
});

Jest 的 expect 函数用于定义我们的期望。函数调用的结果传递给 expect,并返回一个包含我们可以用来定义特定期望的方法的对象。这些方法被称为 匹配器。如果期望失败,Jest 将使测试失败。

前面的测试使用了 toBe 匹配器。toBe 匹配器检查原始值是否相等,前面的测试使用它来检查 someResults 变量是否等于 "something"。其他常见的匹配器如下:

  • toStrictEqual 用于检查对象或数组中的值。它递归地检查对象或数组中的每个属性。以下是一个示例:

    expect(someResult).toStrictEqual({
    
      field1: 'something',
    
      field2: 'something else'
    
    });
    
  • not 用于检查匹配器的相反面。以下是一个示例:

    expect(someResult).not.toBe('something');
    
  • toMatch 用于检查字符串与 正则表达式regexes)的匹配。以下是一个示例:

    expect(someResult).toMatch(/error/);
    
  • toContain 用于检查元素是否在数组中。以下是一个示例:

    expect(someResult).toContain(99);
    

所有标准匹配器的完整列表可以在 Jest 文档的 jestjs.io/docs/expect 找到。

现在我们已经了解了 Jest 测试的基础知识,我们将创建我们的第一个 Jest 测试。

测试 isChecked

我们将要测试的第一个函数是 isChecked。这个函数有两个参数:

  • checkedIds:这是一个当前被选中的 ID 数组

  • idValue:这是用于确定是否被选中的 ID

我们将为列表项被选中时编写一个测试,以及当它没有被选中时编写另一个测试:

  1. src/Checklist文件夹中创建一个名为isChecked.test.ts的文件,该文件将包含测试。

注意

将测试文件放置在要测试的源文件旁边是最佳实践。这允许开发者快速导航到函数的测试。

  1. 打开isChecked.test.ts并导入isChecked函数:

    import { isChecked } from './isChecked';
    
  2. 开始创建第一个测试如下:

    test('', () => {
    
    });
    

Jest 将test函数放在全局作用域中,因此不需要导入它。

  1. 添加以下测试名称:

    test('should return true when in checkedIds', () => {
    
    );
    

为测试名称制定命名约定是良好的实践,以便它们保持一致且易于理解。在这里,我们使用了以下命名结构:

当{输入/ 状态条件}时,应该{预期的输出/行为}

  1. 现在,让我们开始实现测试内部的逻辑。测试的第一步是用我们想要测试的参数调用被测试的函数:

    test('should return true when in checkedIds', () => {
    
      const result = isChecked([1, 2, 3], 2);
    
    });
    
  2. 测试的第二步(也是最后一步)是检查结果是否是我们预期的,对于这个测试来说是true

    test('should return true when in checkedIds', () => {
    
      const result = isChecked([1, 2, 3], 2);
    
      expect(result).toBe(true);
    
    });
    

由于结果是原始值(一个布尔值),我们使用toBe匹配器来验证结果。

  1. 添加第二个测试以覆盖 ID 不在已检查 ID 中的情况:

    test('should return false when not in checkedIds', () => {
    
      const result = isChecked([1, 2, 3], 4);
    
      expect(result).toBe(false);
    
    });
    

这完成了对isChecked函数的测试。接下来,我们将学习如何测试抛出的异常。测试之后,我们将检查我们的测试是否正常工作。

测试异常

我们将要测试的是assertValueCanBeRendered类型断言函数。这与我们上次测试的函数略有不同,因为我们想测试是否抛出了异常,而不是返回值。

Jest 有一个toThrow匹配器,可以用来检查是否抛出了异常。为了捕获异常,被测试的函数必须在期望内部执行,如下所示:

test('some test', () => {
  expect(() => {
    someAssertionFunction(someValue);
  }).toThrow('some error message');
});

我们将使用这种方法为assertValueCanBeRendered类型断言函数添加三个测试。执行以下步骤:

  1. src/Checklist文件夹中创建一个名为assertValueCanBeRendered.test.ts的文件用于测试,并导入assertValueCanBeRendered类型断言函数:

    import { assertValueCanBeRendered } from './assertValueCanBeRendered';
    
  2. 我们将添加的第一个测试是检查当值不是字符串或数字时是否会抛出异常:

    test('should raise exception when not a string or number', () => {
    
      expect(() => {
    
        assertValueCanBeRendered(
    
          true
    
        );
    
      }).toThrow(
    
        'value is not a string or a number'
    
      );
    
    });
    

我们传递一个true布尔值,这应该会导致错误。

  1. 接下来,我们将测试当值是一个字符串时是否不会抛出异常:

    test('should not raise exception when string', () => {
    
      expect(() => {
    
        assertValueCanBeRendered(
    
          'something'
    
        );
    
      }).not.toThrow();
    
    });
    

我们使用not匹配器与toThrow一起检查没有抛出异常。

  1. 最后的测试将检查当值是一个数字时不会抛出异常:

    test('should not raise exception when number', () => {
    
      expect(() => {
    
        assertValueCanBeRendered(
    
          99
    
        );
    
      }).not.toThrow();
    
    });
    

这完成了对assertValueCanBeRendered类型断言函数的测试。

现在我们已经实现了一些测试,接下来我们将学习如何运行它们。

运行测试

Create React App 有一个名为test的 npm 脚本,用于运行测试。测试运行后,当源代码或测试代码更改时,监视器将重新运行测试。

执行以下步骤以运行所有测试并实验测试监视器选项:

  1. 打开终端并执行以下命令:

    npm run test
    

test是一个非常常见的 npm 脚本,因此可以省略run关键字。此外,test可以缩短为t。因此,上一个命令的简短版本如下:

npm t

测试将会运行,以下摘要将在终端中显示:

图 12.1 – 第一次测试运行

图 12.1 – 第一次测试运行

注意,终端中没有像命令执行完毕后通常出现的命令提示符。这是因为命令还没有完全完成,因为测试监视器正在运行——这被称为监视模式。命令将不会完成,直到使用c键退出监视模式。在监视模式下留下终端,继续下一步。

  1. 目前所有测试都通过了。现在,我们将故意使一个测试失败,以便我们可以看到 Jest 提供的信息。因此,打开assertValueCanBeRendered.ts并按如下方式更改第一个测试的预期错误信息:

    test('should raise exception when not a string or number', () => {
    
      expect(() => {
    
        assertValueCanBeRendered(true);
    
      }).toThrow('value is not a string or a numberX');
    
    });
    

一旦测试文件被保存,测试将重新运行,失败的测试将如下报告:

图 12.2 – 失败的测试

图 12.2 – 失败的测试

Jest 提供了关于失败的有价值的信息,帮助我们快速解决测试失败。它告诉我们以下内容:

  • 哪个测试失败了

  • 预期结果与实际结果的比较

  • 失败发生的代码行

通过将测试回滚以检查正确的错误信息来解决测试失败。现在测试应该如下所示:

test('should raise exception when not a string or number', () => {
  expect(() => {
    assertValueCanBeRendered(true);
  }).toThrow('value is not a string or a number');
});
  1. 我们现在将开始探索测试监视器上的一些选项。在终端中按w键,其中测试监视器仍在运行。测试监视器选项将如下列出:

图 12.3 – 测试监视器选项

图 12.3 – 测试监视器选项

  1. 我们可以通过使用p监视选项来过滤 Jest 执行的测试文件。按p键,当提示输入模式时输入isChecked。模式可以是任何正则表达式。Jest 将搜索与正则表达式模式匹配的测试文件并执行它们。因此,Jest 在我们的测试套件中运行了isChecked.test.ts的测试:

图 12.4 – Jest 运行检查匹配模式的测试文件

图 12.4 – Jest 运行检查匹配模式的测试文件

  1. 要清除文件名过滤器,请按c键。

  2. 我们还可以使用t监视选项通过测试名称过滤 Jest 执行的测试。按t键,当提示输入测试名称时输入should return false when not in checkedIds。Jest 将搜索与正则表达式模式匹配的测试名称并执行它们。因此,Jest 在我们的测试套件中运行了should return false when not in checkedIds测试:

图 12.5 – Jest 运行匹配模式的测试名称

图 12.5 – Jest 运行匹配模式的测试名称

  1. c键清除测试名称过滤器,然后按q键退出测试监视器。

这就完成了我们对运行 Jest 测试的探索,以及本节对测试纯函数的讨论。以下是对关键点的快速回顾:

  • 测试使用 Jest 的 test 函数定义。

  • 测试中的期望值使用 Jest 的 expect 函数结合一个或多个匹配器来定义。

  • expect 函数的参数可以是一个执行被测试函数的函数。这对于使用 toThrow 匹配器测试异常非常有用。

  • Jest 的测试运行器有一套全面的选项用于运行测试。测试监视器在大型代码库中特别有用,因为它默认只运行受更改影响的测试。

接下来,我们将学习如何测试 React 组件。

测试组件

测试组件非常重要,因为这正是用户与之交互的部分。在组件上拥有自动化测试可以让我们对应用程序的正确运行充满信心,并在我们更改代码时帮助我们防止回归。

在本节中,我们将学习如何使用 Jest 和 React 测试库来测试组件。然后,我们将对上一章中开发的清单组件创建一些测试。

理解 React 测试库

React 测试库是测试 React 组件的流行伴侣库。它提供了渲染组件并选择内部元素的功能。然后,可以使用另一个名为 jest-dom 的伴侣库提供的特殊匹配器来检查这些内部元素。

基本组件测试

下面是一个组件测试的例子:

test('should render heading when content specified', () => {
  render(<Heading>Some heading</Heading>);
  const heading = screen.getByText('Some heading');
  expect(heading).toBeInTheDocument();
});

让我们解释一下这个测试:

  • React 测试库的 render 函数渲染我们想要测试的组件。我们传入所有适当的属性和内容,以便组件处于检查所需的所需状态。在这个测试中,我们在内容中指定了一些文本。

  • 下一行选择组件的内部元素。React 测试库的 screen 对象上有许多方法可以用来选择元素。这些方法被称为 getByText,通过匹配指定的文本内容来选择元素。在这个测试中,一个具有 Some heading 文本内容的元素将被选择并分配给 heading 变量。

  • 测试中的最后一行是期望。toBeInTheDocument 匹配器是来自 jest-dom 的一个特殊匹配器,用于检查期望中的元素是否在 DOM 中。

理解查询

React 测试库的查询是一个选择渲染组件内部 DOM 元素的方法。有许多不同的查询以不同的方式找到元素:

  • ByRole: 通过元素的角色进行查询。

注意

DOM 元素有一个role属性,允许辅助技术(如屏幕阅读器)理解它们是什么。许多 DOM 元素都有这个属性预设——例如,button元素自动具有'button'的角色。有关角色的更多信息,请参阅developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles

  • ByLabelText: 通过关联的标签查询元素。有关元素如何与标签关联的不同方式的更多信息,请参阅 React Testing Library 文档中的此页面:testing-library.com/docs/queries/bylabeltext

  • ByPlaceholderText: 通过占位符文本查询元素。

  • ByText: 通过元素的文本内容查询元素。

  • ByDisplayValue: 通过inputtextareaselect元素的值查询元素。

  • ByAltText: 通过alt属性查询img元素。

  • ByTitle: 通过title属性查询元素。

  • ByTestId: 通过测试 ID(data-testid属性)查询元素。

还有不同类型的查询,它们在找到的元素上的行为略有不同。每个查询类型在查询方法名称上都有一个特定的前缀:

  • getBy: 如果找不到单个元素,则抛出错误。这对于同步获取单个元素是理想的。

  • getAllBy: 如果至少找不到一个元素,则抛出错误。这对于同步获取多个元素是理想的。

  • findBy: 如果找不到单个元素,则抛出错误。对于元素的检查会重复一定的时间(默认为 1 秒)。因此,这对于异步获取可能不在 DOM 中立即存在的单个元素是理想的。

  • findAllBy: 如果在指定时间内(默认为 1 秒)至少找不到一个元素,则抛出错误。这对于异步获取可能不在 DOM 中立即存在的多个元素是理想的。

  • queryBy: 如果找不到元素,则返回null。这对于检查元素是否存在是理想的。

  • queryAllBy: 这与queryBy相同,但返回一个元素数组。这对于检查多个元素是否不存在是理想的。

因此,在先前的测试中使用的getByText查询通过指定的文本内容查找元素,如果找不到元素则引发错误。

有关查询的更多信息,请参阅 React Testing Library 文档中的以下页面:testing-library.com/docs/queries/about/

注意,这些查询中没有任何一个引用实现细节,例如元素名称、ID 或 CSS 类。如果由于代码重构导致这些实现细节发生变化,测试不应该中断,这正是我们想要的。

现在我们已经了解了 React Testing Library,我们将使用它来编写我们的第一个组件测试。

实现清单组件测试

我们将要编写的第一个组件测试是检查列表项是否正确渲染。第二个组件测试将检查在自定义渲染时列表项是否正确渲染。

在 Create React App 项目中预安装了 React Testing Library 和jest-dom,这意味着我们可以直接编写测试。执行以下步骤:

  1. src/Checklist文件夹中创建一个名为Checklist.test.tsx的新文件,并添加以下导入语句:

    import { render, screen } from '@testing-library/react';
    
    import { Checklist } from './Checklist';
    
  2. 按照以下步骤开始创建测试:

    test('should render correct list items when data specified', () => {
    
    });
    
  3. 在测试中,使用一些数据渲染Checklist

    test('should render correct list items when data specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
    });
    

我们渲染了一个单独的列表项,它应该有主要文本Lucy和次要文本Manager

  1. 让我们检查Lucy是否已经渲染:

    test('should render correct list items when data specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      expect(screen.getByText('Lucy')).toBeInTheDocument();
    
    });
    

我们使用getByText查询选择了元素,并将其直接输入到预期中。我们使用toBeInTheDocument匹配器来检查找到的元素是否在 DOM 中。

  1. 通过添加一个类似的预期来检查Manager来完成测试:

    test('should render correct list items when data specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      expect(screen.getByText('Lucy')).toBeInTheDocument();
    
      expect(screen.getByText('Manager')).    toBeInTheDocument();
    
    });
    

这样就完成了我们的第一个组件测试。

  1. 我们将一次性添加第二个测试,如下所示:

    test('should render correct list items when renderItem specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
          renderItem={(item) => (
    
            <li key={item.id}>
    
              {item.name}-{item.role}
    
            </li>
    
          )}
    
        />
    
      );
    
      expect(
    
        screen.getByText('Lucy-Manager')
    
      ).toBeInTheDocument();
    
    });
    

我们使用与之前测试相同的数据渲染了一个单独的列表项。然而,这个测试自定义渲染了列表项,在名称和角色之间有一个连字符。我们使用相同的getByText查询来检查是否在 DOM 中找到了具有正确文本的列表项。

  1. 如果测试没有自动运行,请在终端中运行npm test来运行它们。使用p选项运行这两个新测试——它们都应该通过:

图 12.6 – 组件测试通过

图 12.6 – 组件测试通过

这样就完成了我们的前两个组件测试。看看 React Testing Library 如何使这变得如此简单!

使用测试 ID

我们将要实现的下一个测试是检查当指定时列表项是否被选中。这个测试会稍微复杂一些,需要复选框上的测试 ID。执行以下步骤:

  1. 首先,打开Checklist.tsx并注意input元素上的以下测试 ID:

    <input
    
      ...
    
      data-testid={`Checklist__input__${idValue.toString()}`}
    
    />
    

使用data-testid属性将测试 ID 添加到元素中。我们可以连接列表项 ID,以便测试 ID 对每个列表项都是唯一的。

  1. 现在,回到Checklist.test.tsx文件并开始编写测试:

    test('should render correct checked items when specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
          checkedIds={[1]}
    
        />
    
      );
    
    });
    

我们已经使用与之前测试相同的数据渲染了清单。然而,我们已指定使用checkedIds属性来检查列表项。

  1. 现在,让我们看看测试的预期:

    test('should render correct checked items when specified', () => {
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
          checkedIds={[1]}
    
        />
    
      );
    
      expect(
    
        screen.getByTestId('Checklist__input__1')
    
      ).toBeChecked();
    
    });
    

我们使用getByTestId查询通过测试 ID 选择复选框。然后我们使用toBeChecked匹配器来验证复选框是否被选中。toBeChecked是来自jest-dom包的另一个特殊匹配器。

这个新的测试应该通过,这样我们在Checklist上就有三个通过测试:

图 12.7 – 所有三个组件测试通过

图 12.7 – 所有三个组件测试通过

  1. 按下q键停止测试运行器。

这样就完成了本节关于组件测试的内容。这里是一个快速回顾:

  • React Testing Library 包含许多用于选择 DOM 元素的有用查询。不同的查询类型会找到单个或多个元素,如果找不到元素,则不会报错。甚至还有一个用于重复搜索异步渲染的元素的查询类型。

  • jest-dom包含许多用于检查 DOM 元素的有用匹配器。一个常见的匹配器是toBeInTheDocument,它验证一个元素是否在 DOM 中。然而,jest-dom还包含许多其他有用的匹配器,例如toBeChecked用于检查元素是否被勾选。

接下来,我们将学习如何在测试中模拟用户交互。

模拟用户交互

到目前为止,我们的测试只是简单地使用各种属性设置了清单组件。用户可以通过勾选和取消勾选项目与清单组件进行交互。在本节中,我们将首先学习如何在测试中模拟用户交互。然后,我们将利用这些知识来测试点击列表项时是否勾选,以及是否触发了onCheckedIdsChange

理解fireEventuser-event

React Testing Library 有一个fireEvent函数可以在 DOM 元素上触发事件。以下示例在保存按钮上触发了一个click事件:

render(<button>Save</button>);
fireEvent.click(screen.getByText('Save'));

这是可以的,但如果逻辑是使用mousedown事件而不是click事件实现的呢?那么测试将需要如下所示:

render(<button>Save</button>);
fireEvent.mouseDown(screen.getByText('Save'));

幸运的是,在测试中执行用户交互有一个替代方法。这个替代方法是使用user-event包,这是一个 React Testing Library 的配套包,它模拟用户交互而不是特定的事件。使用user-event的相同测试看起来如下:

const user = userEvent.setup();
render(<button>Save</button>);
await user.click(screen.getByText('Save'));

测试将涵盖使用click事件或mousedown事件实现的逻辑。因此,它与实现细节的耦合度较低,这是好的。因此,我们将使用user-event包来编写我们清单组件的交互式测试。

user-event包可以模拟除了点击之外的其他交互。有关更多信息,请参阅以下链接的文档:testing-library.com/docs/user-event/intro

实现检查项目的清单测试

现在,我们将为清单组件编写两个交互式测试。第一个测试将检查点击时项目是否被勾选。第二个测试将检查点击项目时是否调用了onCheckedIdsChange。执行以下步骤:

  1. Create React App 预先安装了user-event包,但它可能是在版本 14 之前的版本,这有一个不同的 API。打开package.json,然后找到@testing-library/user-event依赖项并检查版本。如果版本不是 14 或更高,那么在终端中运行以下命令来更新它:

    npm i @testing-library/user-event@latest
    
  2. 我们将在与其它组件测试相同的测试文件中添加交互式测试。因此,打开Checklist.test.tsx并添加对user-event的导入语句:

    import userEvent from '@testing-library/user-event';
    
  3. 第一个测试将测试点击时项是否被选中。开始实现如下:

    test('should check items when clicked', async () => {
    
    });
    

我们将测试标记为异步,因为user-event中的模拟用户交互是异步的。

  1. 接下来,初始化用户模拟如下:

    test('should check items when clicked', async () => {
    
      const user = userEvent.setup();
    
    });
    
  2. 我们现在可以渲染一个列表项,就像我们在之前的测试中所做的那样。我们还将获取渲染的列表项中的复选框引用,并检查它是否未被选中:

    test('should check items when clicked', async () => {
    
      const user = userEvent.setup();
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      const lucyCheckbox = screen.getByTestId(
    
        'Checklist__input__1'
    
      );
    
      expect(lucyCheckbox).not.toBeChecked();
    
    });
    
  3. 现在,让我们转向用户交互。通过在user对象上调用click方法来模拟用户点击列表项;需要将要点击的复选框传递给click参数:

    test('should check items when clicked', async () => {
    
      const user = userEvent.setup();
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      const lucyCheckbox = screen.getByTestId(
    
        'Checklist__input__1'
    
      );
    
      expect(lucyCheckbox).not.toBeChecked();
    
      await user.click(lucyCheckbox);
    
    });
    
  4. 测试的最后一步是检查复选框现在是否被选中:

    test('should check items when clicked', async () => {
    
      const user = userEvent.setup();
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      const lucyCheckbox = screen.getByTestId(
    
        'Checklist__input__1'
    
      );
    
      expect(lucyCheckbox).not.toBeChecked();
    
      await user.click(lucyCheckbox);
    
      expect(lucyCheckbox).toBeChecked();
    
    });
    
  5. 下一个测试将测试当列表项被点击时,分配给onCheckedIdsChange属性的函数是否被调用。以下是测试:

    test('should call onCheckedIdsChange when clicked', async () => {
    
      const user = userEvent.setup();
    
      let calledWith: IdValue[] | undefined = undefined;
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
          onCheckedIdsChange={(checkedIds) =>
    
            (calledWith = checkedIds)
    
          }
    
        />
    
      );
    
      await user.click(screen.getByTestId('Checklist__input__1'));
    
      expect(calledWith).toStrictEqual([1]);
    
    });
    

我们将calledWith变量设置为onCheckedIdsChange参数的值。在列表项被点击后,我们使用toStrictEqual匹配器检查calledWith变量的值。toStrictEqual匹配器是一个标准的 Jest 匹配器,非常适合检查数组和对象。

  1. 第二个测试引用了IdValue类型,因此添加一个导入语句:

    import { IdValue } from './types';
    
  2. 通过在终端中运行npm test来运行测试。按p键以运行Checklist.test.tsx文件中的所有测试。我们现在应该有五个通过组件测试:

图 12.8 – 五个通过组件测试

图 12.8 – 五个通过组件测试

  1. 通过按q键停止测试运行器。

这样就完成了点击项的测试以及模拟用户交互本节的测试。我们了解到 React Testing Library 的fireAction函数引发了一个特定的事件,将测试与实现细节耦合。更好的方法是使用user-event包来模拟用户交互,在这个过程中可能会引发多个事件。

接下来,我们将学习如何快速确定任何未由测试覆盖的代码。

获取代码覆盖率

代码覆盖率是我们用来指代我们的应用程序代码中有多少被单元测试覆盖。当我们编写单元测试时,我们将对哪些代码被覆盖以及哪些代码未被覆盖有一个相当的了解,但随着应用程序的增长和时间的推移,我们将失去这种跟踪。

在本节中,我们将学习如何使用 Jest 的代码覆盖率选项,这样我们就不必在脑海中记住哪些代码被覆盖了。我们将使用代码覆盖率选项来确定清单组件的代码覆盖率,并理解报告中的所有不同统计数据。我们将使用代码覆盖率报告来找到清单组件中的某些未覆盖代码。然后我们将扩展清单组件的测试以实现完整的代码覆盖率。

运行代码覆盖率

为了获取代码覆盖率,我们使用带有--coverage选项的test命令。我们还包含一个--watchAll=false选项,告诉 Jest 不要在监视模式下运行。因此,在终端中运行以下命令以确定我们的应用程序的代码覆盖率:

npm run test -- --coverage --watchAll=false

由于代码覆盖率计算,测试运行会花费更长的时间。当测试完成后,终端会输出一个包含测试结果的代码覆盖率报告:

图 12.9 – 终端代码覆盖率报告

图 12.9 – 终端代码覆盖率报告

接下来,我们将花一些时间来理解这份代码覆盖率报告。

理解代码覆盖率报告

覆盖率报告列出了每个文件的覆盖率,并汇总了项目中所有文件的覆盖率。因此,整个应用程序的代码覆盖率在 57.44%到 62.5%之间,具体取决于我们采用哪个统计数据。

这里是对所有统计列的解释:

  • % 语句:这是语句覆盖率,指的是在测试执行过程中执行了多少源代码语句

  • % 分支:这是分支覆盖率,指的是在测试执行过程中有多少条件逻辑的分支被执行

  • % 函数:这是函数覆盖率,指的是在测试执行过程中调用了多少函数

  • % 行:这是行覆盖率,指的是在测试执行过程中执行了多少行源代码

报告最右侧的列非常有用。它给出了测试未覆盖的源代码行。例如,清单组件中的getNewCheckedIds.ts文件的第 9 行和第 10 行未被覆盖。

还有一种以 HTML 格式生成的报告版本。每次运行带有--coverage选项的测试时,此文件都会自动生成。因此,由于我们刚刚运行了带有--coverage选项的测试,此报告已经生成。执行以下步骤以探索 HTML 报告:

  1. 报告位于coverage\lcov-report文件夹中的index.html文件中。双击文件,使其在浏览器中打开:

图 12.10 – HTML 覆盖率报告

图 12.10 – HTML 覆盖率报告

报告包含与终端报告相同的数据,但这个报告是交互式的。

  1. 在报告的第二行点击src/Checklist链接。现在页面显示了清单组件中文件的覆盖率:

图 12.11 – 清单组件文件覆盖率报告

图 12.11 – 清单组件文件覆盖率报告

  1. 点击getNewCheckedIds.ts链接以深入查看该文件的覆盖率:

图 12.12 – getNewCheckedIds.ts 覆盖率报告

图 12.12 – getNewCheckedIds.ts 覆盖率报告

我们可以看到,未覆盖的行 9 和 10 在getNewCheckedIds.ts文件中非常清晰地突出显示。

因此,HTML 覆盖率报告在大型代码库中非常有用,因为它从高级覆盖率开始,并允许你深入查看特定文件夹和文件的覆盖率。在报告中查看文件时,我们可以快速确定未覆盖的代码位置,因为它被清晰地突出显示。

接下来,我们将更新我们的测试,以便getNewCheckedIds.ts中的第 9 行和第 10 行被覆盖。

实现检查清单组件的全面覆盖率

目前未被测试检查的逻辑是当列表项被点击但已检查时的逻辑。我们将扩展'should check items when clicked'测试以覆盖此逻辑。执行以下步骤:

  1. 打开Checklist.test.tsx并将'should check items when clicked'测试重命名为以下内容:

    test('should check and uncheck items when clicked', async () => {
    
      ...
    
    });
    
  2. 在测试末尾添加以下突出显示的行以再次点击复选框并检查它是否未选中:

    test('should check and uncheck items when clicked', async () => {
    
      const user = userEvent.setup();
    
      render(
    
        <Checklist
    
          data={[{ id: 1, name: 'Lucy', role: 'Manager' }]}
    
          id="id"
    
          primary="name"
    
          secondary="role"
    
        />
    
      );
    
      const lucyCheckbox = screen.getByTestId(
    
        'Checklist__input__1'
    
      );
    
      expect(lucyCheckbox).not.toBeChecked();
    
      await user.click(lucyCheckbox);
    
      expect(lucyCheckbox).toBeChecked();
    
      await user.click(lucyCheckbox);
    
      expect(lucyCheckbox).not.toBeChecked();
    
    });
    
  3. 在终端中,使用覆盖率重新运行测试:

    npm run test -- --coverage --watchAll=false
    

所有测试仍然通过,检查清单组件的覆盖率现在在所有统计指标上报告为 100%:

图 12.13 – 检查清单组件的 100%覆盖率

图 12.13 – 检查清单组件的 100%覆盖率

检查清单组件现在得到了很好的覆盖。然而,index.tstypes.ts出现在报告中且覆盖率为零,这有点令人烦恼。我们将在下一部分解决这个问题。

忽略覆盖率报告中的文件

我们将从覆盖率报告中移除index.tstypes.ts,因为它们不包含任何逻辑,并产生不必要的噪音。执行以下步骤:

  1. 打开package.json文件。我们可以在package.json文件中的jest字段中配置 Jest,并且有一个coveragePathIgnorePatterns配置选项用于从覆盖率报告中移除文件。将以下 Jest 配置添加到package.json以忽略types.tsindex.ts文件:

    {
    
      ...,
    
      "jest": {
    
        "coveragePathIgnorePatterns": [
    
          "types.ts",
    
          "index.ts"
    
        ]
    
      }
    
    }
    
  2. 在终端中,使用覆盖率重新运行测试:

    npm run test -- --coverage --watchAll=false
    

types.tsindex.ts文件已从覆盖率报告中移除:

图 12.14 – 从覆盖率报告中移除了 types.ts 和 index.ts 文件

图 12.14 – 从覆盖率报告中移除了 types.ts 和 index.ts 文件

这部分关于代码覆盖率的讨论到此结束。以下是一个简要的回顾:

  • --coverage选项在测试运行后输出代码覆盖率报告。

  • 除了终端中的覆盖率报告外,还生成一个交互式的 HTML 代码覆盖率报告。在大型测试套件中,这有助于深入分析未覆盖的代码。

  • 两种报告格式都突出了未覆盖的代码,为我们提供了改进测试套件的有价值信息。

摘要

在本章中,我们使用 Jest 和 React Testing Library 在检查清单组件上创建了测试。此外,我们还学习了 Jest 核心包中的常见匹配器和在名为jest-dom的配套包中用于组件测试的有用匹配器。

我们使用了 Jest 的测试运行器,并使用选项来运行某些测试。这对于大型代码库来说特别有用。

我们了解了 React Testing Library 中可用的广泛查询,用于以不同方式选择元素。我们在检查清单测试中广泛使用了getByText查询。我们还为列表项复选框创建了一个测试 ID,以便可以使用getByTestId查询来唯一选择它们。

我们了解到 user-event 包是模拟与实现解耦的用户交互的绝佳方式。我们使用它来模拟用户点击列表项复选框。

我们学习了如何生成代码覆盖率报告,并理解了报告中的所有统计数据。报告包括了关于未覆盖代码的信息,我们使用这些信息在清单组件上实现了 100% 的覆盖率。

因此,我们已经到达了这本书的结尾。你现在对 React 和 TypeScript 都很熟悉,并在 React 核心之外的区域,如样式、客户端路由、表单和 Web API,拥有丰富的知识。你将能够开发跨不同页面甚至不同应用程序的可重用组件。除此之外,你现在将能够编写一个健壮的测试套件,这样你就可以自信地发布新功能。

总结来说,这本书的知识将使你能够高效地使用 React 和 TypeScript 构建大型和复杂的应用程序的前端。我希望你阅读这本书的乐趣和我写作这本书的乐趣一样多!

问题

回答以下问题以检查你在本章中学到的内容:

  1. 我们为 HomePage 组件编写了一些测试,并将它们放在名为 HomePage.tests.tsx 的文件中。然而,当执行 npm test 命令时,测试并没有运行——甚至当按下 a 键来运行所有测试时也是如此。你认为可能的问题是什么?

  2. 为什么以下预期没有通过?如何解决这个问题?

    expect({ name: 'Bob' }).toBe({ name: 'Bob' });
    
  3. 哪个匹配器可以用来检查一个变量不是 null

  4. 这里有一个检查保存按钮是否被禁用的预期:

    expect(
    
      screen.getByText('Save').hasAttribute('disabled')
    
    ).toBe(true);
    

预期结果如预期通过,但是否有不同的匹配器可以用来简化这个操作?

  1. 为我们在这章中使用的 getNewCheckedIds 函数编写一个测试。测试应该检查如果 ID 已经在已检查的 ID 数组中,则从数组中移除该 ID。

  2. 我们有一个包含 findBy 查询类型的 form 元素,以便查询在数据被获取之前重试:

    expect(screen.findByText('Save')).toBeInTheDocument();
    

然而,预期并没有工作——你能找到问题所在吗?

  1. 以下预期尝试检查一个保存按钮不在 DOM 中:

    expect(screen.getByText('Save')).toBe(null);
    

然而,这并没有按预期工作。相反,由于找不到保存按钮,引发了一个错误。如何解决这个问题?

答案

  1. 问题在于文件扩展名是 tests.tsx 而不是 test.tsx

  2. toBe 匹配器应该仅用于检查原始值,如数字和字符串——这是一个对象。应该使用 toStrictEqual 匹配器来检查对象,因为它检查所有属性的值而不是对象引用:

    expect({ name: 'Bob' }).toStrictEqual({ name: 'Bob' });
    
  3. nottoBeNull 匹配器可以组合起来检查一个变量不是 null

    expect(something).not.toBeNull();
    
  4. toBeDisabled 匹配器可以从 jest-dom 中使用:

    expect(screen.getByText('Save')).toBeDisabled();
    
  5. 这里是一个测试示例:

    test('should remove id when already in checked ids', () => {
    
      const result = getNewCheckedIds([1, 2, 3], 2);
    
      expect(result).toStrictEqual([1, 3]);
    
    });
    
  6. findBy 查询类型需要等待,因为它异步执行:

    expect(await screen.findByText('Save')).toBeInTheDocument();
    
  7. queryBy 查询类型可以被使用,因为它在找不到元素时不会抛出异常。此外,可以使用 nottoBeInTheDocument 匹配器来检查元素不在 DOM 中:

    expect(screen.queryByText('Save')).not.toBeInTheDocument();
    
posted @ 2025-09-07 09:21  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报