现代-JavaScript-Web-开发秘籍-全-

现代 JavaScript Web 开发秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 20 多年前起源以来,JavaScript 已经从一种旨在通过为网页添加一些交互性来增强网页的基本语言发展成为一种完整的语言,已被用于开发非常复杂的现代网站,具有高度交互行为和快速响应时间,成功挑战了经典的桌面应用程序。JavaScript 不仅成为 Web 开发的工具,还在服务器开发中占据一席之地,开始取代更传统的语言和框架,如 PHP、Java 和.NET,因为开发人员在使用 Node 时也可以利用他们的 JavaScript 知识。最后,移动应用程序和桌面程序开发这两个之前专门用于特定语言的领域,也成为 JavaScript 广泛工具的一部分。

鉴于 JavaScript 的广泛应用范围,在本书中,我们将首先提供有关最新版本 JavaScript 的新功能的见解,这些功能可以应用于各个领域,并且还涵盖了几种现代工具,这些工具将帮助您进行开发。然后,我们将开始使用语言进行特定领域的开发,首先是基于服务的服务器开发,然后创建一个将使用这些服务的网页,接着创建相同网页的移动原生版本,最后制作一个桌面可执行程序——我们的每一个产品都基于 JavaScript 和我们的一套工具。

这本书适合谁

本书适用于希望探索最新 JavaScript 特性、框架和工具以构建完整 Web 应用程序(包括服务器端和客户端代码)的开发人员。需要具备基本的 JavaScript 工作知识,并且您将了解到最新版本,截至 2018 年 6 月,以跟上语言中的最新发展和功能。

本书涵盖的内容

本书涵盖了多个主题,分为五个部分。在第一部分中

在前两章中,我们将概述 JavaScript 工具和特性:

  • 第一章《使用 JavaScript 开发工具》是我们将学习和安装几种将帮助我们开发的工具,例如用于开发的 Visual Studio Code,用于包管理的 npm,用于版本控制的 Git,用于源代码格式化的 Prettier,用于代码质量检查的 ESLint,以及用于数据类型检查的 Flow 等。

  • 第二章《使用现代 JavaScript 特性》中,我们将看到如何在代码中使用类型,并深入了解 JavaScript 的最新添加内容,涉及字符串、作用域、函数、异步调用、面向类的编程、模块,甚至一点函数式编程FP)。

在第二部分的接下来的三章中,我们将继续使用 Node 开发服务器端代码,最终编写一个完整的 RESTful 服务器:

  • 第三章《使用 Node 进行开发》涵盖了 Node 的基础知识,并学习如何使用 JavaScript 进行服务器开发。我们将涵盖诸如流处理、访问数据库、执行外部进程等主题。

  • 第四章《使用 Node 实现 RESTful 服务》中,我们将看到如何使用 Express 开发服务器,提供静态文件,并处理跨域资源共享CORS)权限和认证规则,最终构建一个 RESTful 服务集成所有内容。

  • 第五章《测试和调试您的服务器》将教您如何使用更多工具来调试代码并为其编写单元测试,包括 Winston、Morgan、Jest、Postman 和 Swagger。

在服务器上工作之后,我们将转向浏览器,这构成了本书的第三部分。我们将在接下来的五章中致力于使用React开发 Web 应用程序,并使用我们刚刚开发的服务器作为后端,因此我们将全面开发:

  • 第六章,使用 React 进行开发,我们将了解 React 框架,设置它以使用开发工具,然后我们将创建一个单页应用程序SPA),并在接下来的章节中进行扩展。

  • 第七章,增强您的应用程序,涉及使用 SASS 和StyledComponents为您的应用程序设计样式,使其具有适应性和响应性,并涵盖可访问性和国际化问题。

  • 第八章,扩展您的应用程序,我们将看到如何使用 Redux 处理状态,这是一个对于大规模网站必不可少的强大工具,我们还将包括诸如路由、授权和代码拆分以提高性能等主题。

  • 第九章,调试您的应用程序,我们将涵盖诸如日志记录和使用浏览器和独立工具进行增强调试等主题。

  • 第十章,测试您的应用程序,我们将使用 Jest 为我们的代码编写单元测试,并了解如何使用 Storybook 来简化开发和测试。

恰好React的一个变体React Native可以用于开发移动

应用程序,这将是我们接下来的两章的主题组成部分。

对于本书的第四部分:

  • 第十一章,使用 React Native 创建移动应用程序,我们将看到如何安装和使用 React Native 来构建我们网页的移动版本,它将适用于不同尺寸的设备,横向或纵向模式,并利用本地功能。

  • 第十二章,测试和调试您的移动应用程序,我们将介绍如何使用一些我们已经见过的工具,如 Jest 和 Storybook,以及一些专门用于移动开发的新工具来调试和测试我们的代码。

最后,在第五部分,本书的最后一章中,我们将同时使用我们的服务器和客户端

知识,以使用Electron开发本地桌面应用程序:

  • 第十三章,使用 Electron 创建桌面应用程序,我们将看到我们可以使用 Electron 与我们已经见过的工具 React 和 Node 一起制作、调试和测试本地桌面应用程序,您可以将其分发给用户,他们可以像安装其他桌面程序一样在自己的计算机上安装它们。

为了充分利用本书

本书假定您已经具有 JavaScript 的基本知识,并从那里开始。解释了语言的现代特性,以便我们可以以最佳方式开发代码。所有代码都遵循语言的最佳实践。对于 Web 和移动应用程序,还需要 HTML 和 CSS 的知识。

本书中的所有代码都可以在 Windows、macOS 和 Linux 机器上运行,因此您不应该在使用任何计算机时遇到任何问题。对终端/命令行工具的一些经验将会很有帮助,但大部分工作将通过图形界面完成。

下载示例代码文件

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

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

  1. 请登录或注册www.packtpub.com

  2. 选择 SUPPORT 选项卡。

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

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

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

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-JavaScript-Web-Development-Cookbook。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

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

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“请记住,您并不总是需要util.promisify()。”

代码块设置如下:

// Source file: src/roundmath.js

/* @flow */
"use strict";

// *continues..*

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

// Source file: src/flowcomments.js

let someFlag /*: boolean */;
let greatTotal /*: number */;
let firstName /*: string */;

function toString(x /*: number */) /*: string */ {
    return String(x);
}

let traffic /*: "red" | "amber" | "green" */;

// *continues...*

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

> npm install moment --save
> npm run addTypes

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。以下是一个例子:“VSC 通过其命令面板提供对命令的完全访问...如下截图所示。”

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

章节

在本书中,您会经常看到几个标题(准备就绪如何做...它是如何工作的...还有更多...参见)。

要清晰地说明如何完成一个配方,请使用以下各节:

准备就绪

本节告诉您该配方中可以期待什么,并描述了如何设置任何软件或配方所需的任何初步设置。

如何做...

本节包含了遵循该配方所需的步骤。

它是如何工作的...

本节通常包括对前一节中发生的事情的详细解释。

还有更多...

本节包含了有关该配方的其他信息,以使您对该配方更加了解。

还有更多...

本节包含了有关该配方的其他信息,以使您对该配方更加了解。

参见

本节提供了有用的链接,以获取有关该配方的其他有用信息。

第一章:使用 JavaScript 开发工具

我们将在这里看到的配方如下:

  • 安装 Visual Studio Code 进行开发

  • 扩展 Visual Studio Code

  • 添加 Fira Code 字体以获得更好的编辑效果

  • 添加 npm 进行包管理

  • 使用 Git 进行版本控制

  • 使用 Prettier 格式化您的源代码

  • 使用 JSDoc 为您的代码编写文档

  • 使用 ESLint 添加代码质量检查

  • 添加 Flow 进行数据类型检查

介绍

JavaScript 已经不再是一个简单的用于向网页添加小效果或行为的工具,现在它已经成为世界上使用最广泛的语言之一,应用于各种开发。鉴于当今包、库和框架的复杂性和多样性,您不会开始工作而没有一整套工具,在本章中,我们将致力于建立一个良好的开发环境,以便您可以以最有效的方式工作。

让我们首先设置一些对所有 JS 开发都有用的工具。有人说“差的工匠怪工具”,所以让我们通过做出一些好选择来避免甚至一点点这样的情况!

安装 Visual Studio Code 进行开发

我们需要的第一个工具是一个集成开发环境IDE),或者至少是一个强大的代码编辑器。有些人可能会使用简单的编辑器,可能类似于viNotepad,但从长远来看,手工做所有事情所浪费的时间并不值得。有许多选择,例如(按字母顺序)Atom、Eclipse、IntelliJ IDEA、Microsoft Visual Studio、NetBeans、Sublime Text、WebStorm 和 Visual Studio Code。就我个人而言,我选择了后者,当然您也可以完全使用其他任何一个。

“集成开发环境”这个术语并没有一个很明确定义。一个集成开发环境通常集成了许多工具,为开发人员提供更无缝的体验。专为开发工作而设计的编辑器通过插件或扩展提供了一些类似的功能。虽然这当然可以近似于使用集成开发环境的便利性,但可能会出现一些问题,比如更难的安装或配置,或者界面可能更难理解,但最终,您可能会获得几乎相同的功能集。

Visual Studio CodeVSC)基本上是一个源代码编辑器,由微软于 2015 年开发。尽管名字相似,但它与微软更强大的集成开发环境 Visual Studio 无关。该编辑器是免费且开源的,最新版本是(目前)1.29.1,日期为 2018 年 11 月,但新版本每月发布一次。它可以用于 JS 开发,也可以用于其他语言,因此如果你想在 PHP 中进行服务器端编码,你也可以完全使用 VSC。然而,从我们的角度来看,VSC 为基本上所有前端语言(JS、TypeScript、JSON、HTML、CSS、LESS、SASS)提供了智能感知功能,这是一个很好的卖点。有关更多信息,请参阅code.visualstudio.com/docs/editor/intellisense

一个很好的地方是 VSC 是用 JS 编写的,基于 Node,并使用Electron框架打包为桌面应用程序。(我们将在第十三章中看到这些主题,使用 Electron 创建桌面应用程序。)这自动让您可以在 Linux、macOS 和 Windows 中使用 VSC,这是一个很大的优势,如果您在一个团队中工作,而不是每个人都有相同的开发环境偏好。

一个普遍存在的误解是 VSC 是基于 Atom 编辑器的。尽管 VSC 共享相同的编辑器组件(Monaco),但 VSC 本身与 Atom 不同。这种误解的根源可能是Electron在 2013 年创建时最初被称为Atom Shell;名称在 2015 年更改为Electron

过去,我曾广泛使用 Eclipse,Microsoft Visual Studio 和 NetBeans。然而,现在我只使用 VSC。为什么我更喜欢它?我的原因(你的情况可能不同!)包括以下几点:

  • 适用于多个操作系统:我个人一直在 Mac 和 Linux 上使用它,有时也在 Windows 上使用

  • 积极开发和维护:定期提供更新(包括错误修复)

  • 非常好的性能:VSC 感觉非常快速

  • 智能感知支持:对所有 JS 需求开箱即用

  • 通过插件可用的扩展:这些将集成到你的工作流中,添加新功能

  • 集成调试:正如我们将在第五章中看到的,测试和调试你的服务器

  • 集成源代码管理:通过 Git(参见稍后的使用 Git 进行版本控制部分)

  • 集成终端:你可以在不离开 VSC 的情况下运行命令或启动进程

另一方面,也存在一些缺点;主要有以下两点:

  • 插件的界面、配置和设计通常各不相同,因此你将不得不处理频繁的不一致性。

  • VSC 对项目或创建例如与Node后端服务器通信的React前端应用程序所需的工具之间的链接没有任何了解。VSC 最多只能识别文件夹,但你如何组织它们,以及在哪里放置你的代码片段,完全取决于你。

如何做...

如何安装 VSC?每个操作系统的说明都不同,而且随着时间的推移可能会有所不同,所以我们只会指导你在code.visualstudio.com/download下载适合你系统的包,并按照code.visualstudio.com/docs/setup/setup-overview上的正确平台特定说明进行操作。对于 Linux 发行版,除了自己下载和安装一些软件包之外,可能还有其他方法。例如,对于 OpenSUSE,存在一个存储库,允许你通过 OpenSUSE 自身安装和更新 VSC;请查看en.opensuse.org/Visual_Studio_Code获取相关说明,或者查看code.visualstudio.com/docs/setup/linux获取更多特定于发行版的说明。

如果你想尝试最新功能,并尽早看到新功能,还有一个Insiders 版本。你可以安装正常的 VSC 稳定版本和 Insiders 版本,并使用你喜欢的那个。不过要注意,你可能会遇到意外的错误,但你可以通过让 VSC 开发团队知道来帮助他们摆脱这些错误!

它是如何工作的...

安装完成后,打开 VSC 并尝试其设置,以开始按照你喜欢的方式配置事物,参见下图。左下角的齿轮菜单提供了访问多个相关项目的选项,例如键盘快捷键、颜色方案和图标集。如果你以前使用过 VSC,你将可以访问更多最近的文件和文件夹:

VSC 中的欢迎屏幕,以及左下角的设置齿轮

配置 VSC 有点不同寻常,但也许是可以预料的,因为它起源于 JS。基本上,如下截图所示,你会得到一个分屏,左边以 JSON 格式显示所有可用的配置项(超过四百个!),你可以通过在右边写入新值来更改它们的值。如果你将鼠标悬停在任何设置上,你将看到可能的值,并且你可以通过点击来选择新的值:

配置 VSC 是通过编辑一个 JSON 文件来完成的

您想选择一个不同的编辑器进行工作,或者至少查看一下目前有哪些可用的?您可以查看www.slant.co/topics/1686/~javascript-ides-or-editors ,其中列出了许多候选项,每个候选项都有其优缺点。截至目前(2018 年 10 月),该页面显示了 41 个选项,其中 Visual Studio Code 位列榜首。

VSC 的一个额外优势与更新有关。它会定期检查是否有新版本可用,并允许您下载并安装它。(另一方面,如果您使用 Linux 并通过存储库安装 VSC,它可能会自动更新,而无需您确认。)之后,您将获得一个信息屏幕,显示上个月的更改;如下面的截图所示:

每月更新后,您将收到 VSC 的新功能通知

VSC 的配置超出了我们刚提到的内容;请查看以下部分,以了解更多扩展其功能和使其更适合您使用的方法。

扩展 Visual Studio Code

VSC 包括许多开箱即用的功能,您可以使用这些功能开始工作,而且没有问题。通过扩展,您可以为语言、调试、代码质量和许多其他功能添加支持。还提供了配置视觉方面的功能,您可以更改 VSC 的主题、快捷键和一般偏好设置。但是,您可能希望为 VSC 添加更多功能,这就是扩展(插件)的用武之地。

您甚至可以为 VSC 开发自己的扩展,尽管我们在本书中不会涉及这个主题。如果您感兴趣,请查看code.visualstudio.com/docs/extensions/overview。扩展可以用 JS 或 TypeScript 编写(请参阅为数据类型检查添加 Flow部分),当然您也可以使用 VSC 本身来开发它们!

如何做…

扩展是可选的可安装附加功能,提供特定的新功能。安装新扩展非常简单。您可以通过转到主菜单中的 View | Extensions(您也可以找到其键盘快捷键)或单击 VSC 左侧活动栏底部的 Extensions 图标来查看所有可用扩展的菜单。

首先,您将获得当前安装的扩展列表,如下面的截图所示:

已安装的扩展列表

如果您愿意,您可以禁用任何特定的扩展:在屏幕左侧点击它,然后在右侧点击禁用。您还可以完全卸载任何扩展,而且您很可能会经常这样做;找出扩展是否适合您的唯一方法就是通过实验!看看下面的截图:

VSC 市场是搜索新扩展的好地方

查找新的扩展也很容易。您可以去 VSC 市场,如前面的截图所示,也可以直接在 VSC 内部搜索,通过在搜索框中输入,如下面的截图所示。我建议注意安装总数(越高越好)和从 1 到 5 星的评分(同样,越高越好)。我们将使用多个扩展;例如,在本章中使用 Prettier 格式化源代码使用 JSDoc 记录代码部分;以后还会有更多:

您还可以通过在 VSC 内部键入一些关键字来搜索新的扩展

扩展会自动更新,你不必做太多事情。我建议定期查看你的扩展列表,并可能再次搜索新的扩展;有些情况下,新版本会废弃旧版本,但用新名称,所以更新可能不起作用。最后,准备好进行实验,找出哪些扩展适合你!

添加 Fira Code 字体以获得更好的编辑

如果你想尝试一个可能很快就会引发(热烈?激烈?)讨论的话题,大声说出最适合编程的字体是哪一种,然后等着看吧!我不想引发任何争论,但我肯定可以推荐一种可以让你的 JS 代码看起来更好,更易读的字体。

Slant 上的一篇文章,网址为www.slant.co/topics/67/~best-programming-fonts,列出了 100 多种编程字体;你甚至想过有这么多可用吗?

更好的字体的关键在于连字的概念。在印刷术中,当两个或更多个字母连接在一起成为一个字符时,就会出现连字。好吧,正确的技术术语应该是字形,但我们不要把它弄得更复杂!

你可能不知道的一些连字是这样的:和符号(&)最初是拉丁字母Et的连字,拼写成拉丁文的et,意思是and。同样,德语的ß字符是两个相邻的s字母的连字,而西班牙语的Ñ最初是一对N字符,一个写在另一个上面。

在 JS 中,有许多符号是以两个或更多字符编写的,只是因为没有其他方式可用。例如,大于或等于符号被键入为>=,这看起来不如数学符号好看,是吧?其他组合包括<=(小于或等于),=>(用于箭头函数,在第二章中我们将遇到使用现代 JavaScript 功能),二进制移位运算符<<>>,相等运算符=====(以及相应的!=!==),等等。

不要混淆连字字距。两者都涉及显示相邻的字符,但前者是指连接字符并用新字符替换它们,而后者是指减少字符之间的距离。如果你把一个f放在一个i旁边,字距会使它们更接近而不重叠(就像你可以减少AV之间的间距一样,因为字母的形状),而连字会用fi替换两个字符,实际上连接了两个字母。

操作方法如下:

虽然有许多等宽字体(意思是所有字符的宽度都相同,这有助于屏幕对齐和缩进),但提供连字的字体并不多。在我的情况下,经过许多实验后,我可以推荐使用 Fira Code,可以在github.com/tonsky/FiraCode上线获取。这种字体不仅为 JS 提供了许多连字,还为其他编程语言提供了连字。看看下面的插图,了解所有的可能性:

所有可用的连字,如图所示

https://raw.githubusercontent.com/tonsky/FiraCode/master/showcases/all_ligatures.png

下载最新版本(截至 2018 年 12 月为 1.206)并按照你的操作系统的标准程序进行安装。之后,你将不得不更改一对 VSC 设置,就像本章前面所示;只需添加以下行,并保存你的配置:

"editor.fontFamily": "'Fira Code', 'Droid Sans Mono', 'Courier New'",
"editor.fontLigatures": true,
.
.
.

第一行定义了你想使用的字体(在 CSS 样式中,我还提供了备选方案,以防我将我的设置带到另一台没有Fira Code的机器上),第二行告诉 VSC 启用屏幕连字。

工作原理如下:

在上一节中进行更改后,当您打开 VSC 时,您将能够看到以下截图中的代码:

一个示例清单,显示了几个连字号;请参见第 60 行(=>),第 63 行(===和||),或第 71 行(<=)

请注意,当您输入代码时,您无需做任何事情。如果您想要一个箭头连字号,您将需要像平常一样输入两个字符=>;它们在屏幕上的显示方式只是字体渲染的结果。同样,如果您想搜索箭头,请搜索=>,因为这将保存到磁盘上。

现在我们已经将 VSC 配置为我们喜欢的样子,让我们开始更多的软件包来帮助管理源代码和其他功能。

添加 npm 进行软件包管理

无论是在前端还是后端工作时,您肯定会想要使用已有的库和框架,这就产生了一个有趣的问题:如何处理这些软件包的需求,更多的软件包,它们自己甚至需要更多的软件包,依此类推。在第三章中,使用 Node 进行开发,我们将使用Node,但我们需要超前一步,并现在安装npmNode的软件包管理器)以便能够设置几个其他工具。

npm也是一个庞大的软件仓库的名称,位于www.npmjs.com/,大约有 60 万个软件包,您可以在以下截图中观察到,它以每天超过 500 个软件包的速度增长,根据www.modulecounts.com/等统计数据,这是一个跟踪几个知名代码仓库的地方:

根据 www.modulecounts.com/的数据,npm 仓库的增长似乎是指数级的。

可以肯定地说,现代 JS 应用程序可能不需要至少一个,更可能需要几个来自npm的软件包,因此添加一个软件包管理器将是强制性的;让我们看看其中的一些。

如何做…

要获取npm,您必须首先安装Node,这将对第三章以及接下来的章节有所帮助。我们不会从网上复制详细信息(请参见docs.npmjs.com/getting-started/installing-node),但我们可以总结如下:

  1. 通过下载并进行手动安装(对于 Windows 来说是最常见的方式)或通过添加适当的仓库,然后使用 Linux 软件包管理器来安装Node(这是我在我的 OpenSuse 机器上的做法)。请注意,选择长期支持LTS)版本,其主要版本号为偶数(例如 8.x.x),除非您足够冒险,使用最新的开发版本,并且不介意出现停止工作等风险!

  2. 验证Node是否正确安装。在命令行中,输入node -v并获取当前版本;在我的机器上,是 v9.7.1,但在您尝试时这肯定会改变,是的,我感到很有冒险精神,没有使用 LTS 版本!

  3. 使用npm -v命令检查npm是否是最新版本。如果不是(请参考以下代码片段),您将需要更新它:

> npm -v 
5.5.1 

 ────────────────────── 
   │                                    │
   │  Update available 5.5.1 → 5.7.1    │
   │     Run npm i -g npm to update     │
   │                                    │
    ──────────────────────

如果您正在使用一个软件包管理器(这意味着您可以自动获取软件的更新,而无需逐个查找每个软件包),您可能还对安装nvm感兴趣,尽管这是可选的;有关更多信息,请参见github.com/creationix/nvm

它是如何工作的…

我们将在本文的几个地方再次使用npm。您将不得不使用它来安装多个软件包(其中一些出现在本章中,例如JSDocPrettier),然后我们将看到如何配置应用程序,以便所有所需的软件包都可用且保持最新。

您可以在docs.npmjs.com/找到所有npm功能的完整文档。

使用 npm 创建项目

如果您选择任何空目录并只安装一个软件包,您将收到一些与缺少文件相关的警告,并且还会发现一些新元素:

~ > md sample
~ > cd sample
~/sample > npm install lodash 
npm WARN saveError ENOENT: no such file or directory, open '/home/fkereki/sample/package.json' 
npm notice created a lockfile as package-lock.json. You should commit this file. 
npm WARN enoent ENOENT: no such file or directory, open '/home/fkereki/sample/package.json' 
npm WARN sample No description 
npm WARN sample No repository field. 
npm WARN sample No README data 
npm WARN sample No license field. 

+ lodash@4.17.11
added 1 package from 2 contributors and audited 1 package in 1.945s 
found 0 vulnerabilities

~/sample> dir 
total 4 
drwxr-xr-x 3 fkereki users  20 Mar 15 11:39 node_modules 
-rw-r--r-- 1 fkereki users 313 Mar 15 11:39 package-lock.json

这里发生了什么?让我们一步一步地解释结果,然后添加缺少的部分。当您安装模块时,它们(以及它们的所有依赖项,以及它们依赖的依赖项等)默认放在node_modules目录中。这是一个很好的措施,因为将放入该目录的所有代码实际上都不是您编写的,并且最终将由npm在您的直接控制下进行更新。我们可以通过快速转到新创建的目录并检查其内容来快速验证:

~/sample> cd node_modules
~/sample/node_modules> dir 
total 36 
drwxr-xr-x 3 fkereki users 20480 Mar 15 11:39 lodash

但是,您如何控制要安装哪些软件包(及其版本)?这就是缺少的package.json文件的目的,它让您指定要安装的软件包,以及我们稍后在本书中会遇到的其他一些东西。您可以手动创建此文件,但最好使用npm init并回答几个问题。这将创建所需的文件,最终描述项目的所有依赖关系,以及我们稍后将看到的其他功能(例如构建或部署过程):

~/sample> npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help json` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (sample) simpleproject
version: (1.0.0) 
description: A simple project to show package.json creation
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: Federico Kereki
license: (ISC) 
About to write to /home/fkereki/sample/package.json:

{
 "name": "simpleproject",
 "version": "1.0.0",
 "description": "A simple project to show package.json creation",
 "main": "index.js",
 "scripts": {
 "test": "echo \"Error: no test specified\" && exit 1"
 },
 "author": "Federico Kereki",
 "license": "ISC"
}
Is this ok? (yes)

让我们快速浏览每个字段,但请记住这些只是基本字段;您可以在docs.npmjs.com/files/package.json找到更完整的官方描述。由于我们跳过了一些答案,生成的项目文件中并非所有字段都存在,但您可以稍后添加所有内容:

  • name:要分配给项目的任何名称;默认情况下为目录的名称。

  • version:项目的语义版本号。每当创建新版本时,您都会更新此数字。有关语义版本控制的更多信息,请参阅semver.org/

  • description:项目的简单描述,由npm搜索命令使用。

  • main:程序的主要入口点的名称。通常使用index.js

  • test command:要执行代码的单元测试,您将运行的命令(脚本)。我们稍后在本书中也会看到这一点。

  • git repository:如果您要使用源代码控制,您将在此处提供详细信息。我们将在本章后面的使用 Git 进行版本控制部分中介绍这一点。

  • scripts:这是一个包含可以使用npm run运行的脚本命令的对象;例如,您可以编写脚本来构建项目,部署它,检查它是否符合代码质量规则等。

  • author:创建项目的人。

  • license:要分配给项目的任何许可证;这是为了让其他人知道他们可以如何使用您的软件包(权限,限制),如果您允许的话。您可以在spdx.org/licenses/找到(相当长的!)可能的许可证列表,并在选择时要小心;涉及法律方面的问题!

但是,软件包在哪里?让我们在下一节中看看。

为不同目的安装软件包

有两种安装npm软件包的方法:全局或本地:

  • 如果你计划从命令行使用这个包,就全局安装它;例如,npm install prettier -g会安装prettier命令,这样你就可以在任何地方使用它。(我们将在使用 Prettier 格式化你的源代码部分看到更多关于prettier的内容。)你可能需要以管理员身份运行命令,或者使用sudo

  • 否则,如果你只需要这个包用于你的项目,就在本地安装它。

本地安装包也可以通过多种方式完成:

  • 如果你需要这个包用于你自己的项目,那么你可以使用npm install lodash --save将其安装为生产包

  • 或者,如果你需要这个包来构建你的项目,但不作为最终生成的代码的一部分,可以使用npm install eslint --save-dev将其安装为开发包

有许多命令和选项的简写版本,比如i代表install,或者-D代表--save-dev,但我更喜欢把所有东西都写出来。如果你想了解更多,请尝试npm --help

运行这两个后续命令后,如果你检查package.json,你会注意到添加了一些行:

~/sample> cat package.json  
{ 
  "name": "simpleproject", 
  "version": "1.0.0", 
  "description": "A simple project to show package.json creation", 
  "main": "index.js", 
  "scripts": { 
    "test": "echo \"Error: no test specified\" && exit 1" 
  }, 
  "author": "Federico Kereki", 
  "license": "ISC", 
  "dependencies": { 
 "lodash": "⁴.17.5" 
 }, 
 "devDependencies": { 
 "prettier": "¹.11.1" 
 } 
}

dependenciesdevDependencies条目指的是你需要的生产和开发包。如果你正在编写软件,并且决定需要一个新的包,有两种方法可以做到这一点:

  • package.json中添加一个条目,然后运行npm install来获取它

  • 或者,使用npm install,要么使用--save要么使用--save-devpackage.json将被npm更新

要删除一个依赖,使用npm uninstall。你必须包括--save--save-dev,以便同时从package.json中删除引用。

如果你需要特定版本,你将需要了解语义版本控制。版本规则可能变得复杂,我们只会看到主要的规则;查看docs.npmjs.com/files/package.json#dependenciesgithub.com/npm/node-semver#versions获取完整的描述:

4.5.6 版本为 4.5.6,没有其他版本
⁴.0.0 最新兼容版本 4.x.x
⁴.2.0 最新兼容版本 4.2.x
>5.6.7 大于 5.6.7 的版本
~8.7.6 大约等于 8.7.6 的版本;应该是 8.7.x

还有更多...

维护你的包并更新它们是一项重要的任务,如果你是开发团队的一部分,可能有人甚至在不同的地区或国家,那么每个人都应该始终使用相同的配置,这变得非常重要。如果项目非常动态(意味着包将经常被添加、删除或更新),npm可能会变得有点慢,也可能会产生一致性或安全问题;为了解决这种情况,Facebook 在 2016 年发布了一个新的包管理器yarn。(参见yarnpkg.com/en/。)

如果你想看到这些变化的原因,请参阅关于yarn的原始博客文章code.facebook.com/posts/1840075619545360

一个关键特性是你可以无缝地用yarn替换npm,并开始使用后者,因为它具有相同的功能集(除了一些细微差异),同时以更快、更可靠和更安全的方式工作。例如,yarn可以并行管理下载,甚至可以使用缓存包,因此甚至可以在没有连接到互联网的情况下进行一些更新!

安装非常简单,有点讽刺。使用npmnpm install -g yarn,从那一刻起,你就可以直接使用yarn,忘记npm。查看yarnpkg.com/en/docs/install获取有关安装过程的更完整文档。

有关比较npmyarn命令的更多细节,请查看yarnpkg.com/lang/en/docs/migrating-from-npm/shift.infinite.red/npm-vs-yarn-cheat-sheet-8755b092e5cc

使用 Git 进行版本控制

在现代软件开发中,毋庸置疑,您将需要一些 SCM(软件配置管理)软件来跟踪代码中的所有更改。今天,最常用的工具是Git,我们也将使用它。Git是由 Linus Torvalds 于 2005 年创建的(他还创建了 Linux!)用于 Linux 内核的开发;考虑到其源代码超过 2500 万行,这并不是一项小任务!

Linux 不是唯一一个使用Git控制的主要操作系统;2017 年 2 月,微软本身决定将 Microsoft Windows 的开发迁移到Git,并开发了定制功能以增强远程工作。

我们不会深入探讨Git的工作原理,要使用哪些命令等等,因为这将是一本书的材料!我们将专注于如何在 VSC 中使用Git。这相当简单,因为 VSC 不仅是为了访问Git而编写的,而且还有一些扩展可以使工作更加轻松,因此您不必记住大量的命令和选项;看看下面的插图:

Git 有很多命令,但您可以很好地应对其中的一些选择性命令。

这个 XKCD 漫画可以在 https://xkcd.com/1597/上找到。

如何做…

就我个人而言,我有一个 GitHub 账户,并决定将其用于本书的代码。这不仅是一种能够快速与读者分享所有代码的方式,而且(非常重要!)也是一种确保我不会意外丢失工作的方式,我很有能力做到这一点!请访问github.com/fkereki/modernjs获取所有代码。我假设您有一个适当的Git服务器,并且能够初始化项目,将其连接到服务器等等。此外,VSC 需要在您的计算机上预先安装Git;如果您还没有安装,请查看git-scm.com/book/en/v2/Getting-Started-Installing-Git开始安装。

VSC 通过其命令面板提供了对命令的完全访问...如下截图所示。您可以在那里搜索命令,点击后,VSC 将逐个询问所有可能的参数,这样您就不必靠记忆来完成它们:

您可以通过 VSC 的命令面板输入 Git 命令,并且如果需要,会要求您输入所需的参数

提交代码是相当频繁的,所以您可以直接点击源代码控制图标(在左侧的第三个)并输入您想要的提交消息。在那个屏幕上,您还可以恢复本地更改等等;鼠标悬停以获取所有可能的功能。

还有更多…

有一个Git扩展我会推荐给 VSC:寻找GitLens(也称为Git Supercharged)并安装它。这个扩展提供了对几乎所有Git信息的访问。

看一下以下的截图:

GitLens 的使用

除了其他功能,GitLens还提供了以下功能:

  • 一个镜头,显示最近的提交和作者信息

  • 一个资源管理器,用于浏览存储库和文件历史

  • 一个责备注释,显示谁对一行进行了最后的更改,就像 git blame 一样

  • 搜索提交的能力以不同的方式,并且更多

有关更详细的信息,请参阅gitlens.amod.io/。特别注意定制github.com/eamodio/vscode-gitlens/#configuration,因为大多数功能都可以调整以更好地适应您的工作风格。您可以通过标准设置页面(查找所有名称以GitLens开头的所有配置项),或者通过打开命令面板并查找 GitLens:打开设置,这将打开一个特殊的设置屏幕,如下面的屏幕截图所示:

Gitlens 还提供了一个特殊的屏幕设置功能,允许您配置工具的几乎每个方面

既然我们已经建立了一个开发环境,并选择并安装了一组最少的工具,让我们进一步添加一些可选的,但强烈推荐的额外软件包,这些软件包将有助于生成更好的代码。

使用 Prettier 格式化您的源代码

如果您在一个有几个其他开发人员的项目中工作,迟早会出现关于代码格式应该如何格式化的争论,而且这些争论可能会持续很长时间!确定源代码的单一标准确实是必要的,但如果格式取决于每个人,那么你肯定会得到比团队成员更多的“标准”!看看下面的插图。在团队中不希望出现额外的摩擦或激怒,而且风格争论可能会持续很长时间:

你不能拥有多个标准。

这个 XKCD 漫画可以在 https://xkcd.com/927/上在线获取。

现代 JS 项目的问题更加严重,因为它们不仅包括 JS 源代码,还可能包括TypeScriptFlow(稍后请参阅添加 Flow 进行数据类型检查部分),JSX(请参阅第六章,使用 React 开发),JSON,HTML,CSS 或 SCSS,甚至更多。

在尝试了许多源代码格式化程序之后,我最终决定将Prettier用于所有目的。Prettier是一个有主见的代码格式化程序,支持我之前列出的所有语言,根据一套规则重新格式化源代码,从而确保所有代码符合预期的样式。

如果您想阅读Prettier的原始描述,请参阅jlongster.com/A-Prettier-Formatter,作者在博客文章中描述了该项目的基本原理,并介绍了实现和选项的一些细节。

这意味着什么,它是有主见的吗?许多(或大多数)代码格式化程序提供了一个非常大的配置选项集,您可以调整这些选项以使代码看起来符合您的期望。另一方面,Prettier有自己的一套规则,几乎没有配置的余地,因此可以缩短所有争论。此外,您可以使其与 VSC 无缝配合,这意味着每当您保存代码时,它都会被重新格式化。

让我们看一些有主见的例子。使用箭头函数(我们将在第二章的定义函数部分中更详细地介绍它们,使用现代 JavaScript 功能),如果函数有一个参数,将其括在括号中是可选的:

const plus1= (x)=> 1+x

然而,Prettier决定在这种情况下不包括括号。另外,请注意它添加了一些空格以增加清晰度,以及(可选的)缺少分号:

const plus1 = x => 1 + x;

同样,如果您使用承诺(我们将在第二章的紧凑执行异步调用部分中看到它们,使用 JavaScript 现代功能),您可能会写出以下内容:

fetch('http://some.url').then((response) => {
    return response.json();
  }).then((myJson) => {
    console.log(myJson);
  }).catch(e => { /* something wrong */ });

然而,它将被重新格式化为更常见的以下代码:

fetch("http://some.url")
    .then(response => {
        return response.json();
    })
    .then(myJson => {
        console.log(myJson);
    })
    .catch(e => {
        /* something wrong */
    });

注意每个.then(...)都被推到了单独的一行,这是 JS 最常见的风格。Prettier应用的格式规则源自通常的实践,这里不可能列出所有的规则。但真正重要的是,通过使用这个工具,你可以确信你的整个团队将以相同的方式工作。

如果你的团队对某些规则抱怨,提醒他们有句话说“有一种正确的方式,一种错误的方式,还有军队的方式!”采用Prettier后,不再有关于风格的讨论,和平最终会降临。

如何做…

安装Prettier非常简单:你只需要添加 VSC 扩展,你可以通过搜索Prettier Code Formatter找到;作为检查,最新版本(截至 2018 年 12 月)是 1.16.0,作者是 Esben Petersen。插件本身可以在 VSC 市场上找到,网址为marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode。你也可以全局安装它(就像我们在本章前面看到的为不同目的安装包一节中那样),以便能够在脚本中或通过npmyarn命令行使用它。参见prettier.io/docs/en/install.html,我建议你这样做。

在 VSC 偏好设置中有一个改变你会想要做。转到文件|首选项|设置,并在用户配置中添加以下行,这样每个文件在保存时都会自动格式化:

"editor.formatOnSave": true,
.
.
.

如果你更愿意只将Prettier应用于 JS,那么你应该使用这个:

"[javascript]": {
    "editor.formatOnSave": true
},
.
.
.

正如我们所说,Prettier对代码的外观有很强的意见,并且只有少数几个选项可以更改。可用的选项可以在package.json中设置(这样整个团队更容易共享),在"prettier"键中。一些可能性(也就是你可能想要修改的)如下:

选项 默认值 含义
arrowParens false 对于只有一个参数的箭头函数,是否将其括在括号中。
bracketSpacing true 在对象的开括号之后和闭括号之前包括一个空格。
jsxBracketSameLine false 如果为true,多行 JSX 元素的结束>将添加在最后一行的末尾;如果为false,它将在单独的一行上。
printWidth 80 最大行大小。
semi true 在每一行的末尾添加分号,即使不需要。
singleQuote false 对字符串使用单引号。
tabWidth 2 缩进大小。
trailingComma none 指定是否在可能的情况下添加尾随逗号。选项有none(从不添加这样的逗号),es5(在 ES5 允许的地方添加,如数组或对象),或all(即使在函数参数中也添加)。
useTabs false 使用制表符进行缩进。

就我个人而言,我只使用tabWidth:4printWidth:75,但后者只是为了这本书,而不是为了其他工作。我的package.json包括以下内容;我将其放在dependencies键之前,但你也可以将其放在其他位置:

"prettier": {
    "tabWidth": 4,
    "printWidth": 75
},
.
.
.

你也可以独立于 VSC 使用Prettier,在这种情况下,配置选项应该放在一个.prettierrc文件中。参见prettier.io/docs/en/cli.htmlprettier.io/docs/en/configuration.html了解更多信息。

最后,如果出于某种原因你想要避免Prettier代码格式化,你可以这样做:

  • 通过将其路径和名称添加到项目根目录下的.prettierignore文本文件中,可以避免给定文件的所有格式。

  • 通过在其前面加上// prettier-ignore comment来避免重新格式化单个句子

对于后一种选项,记住根据源代码语言使用适当的注释样式。例如,在 HTML 文件中,你会使用<!-- prettier-ignore -->,而在 CSS 中,应该是/* prettier-ignore */,对于 JSX,是{/* prettier-ignore */}

它是如何工作的…

有两种使用Prettier的方法。第一种是配置 VSC 在保存代码时自动格式化代码;按照我们之前安装 VSC 时看到的说明,将编辑器的“保存时格式化”选项更改为 true,就可以了。当然,你也可以通过右键单击并选择“格式化文档”选项来随时格式化代码。

你也可以在网上使用Prettier。转到prettier.io/playground/,将你的代码粘贴到左侧面板,你将立即在右侧面板中获得一个格式化的版本。看下面的截图,以了解代码重新格式化的示例:

在线的 Prettier 可以用来尝试配置参数,或进行快速的代码重新格式化会话

如果你想尝试一下少量可用的选项,点击左下角的“显示选项”,你就可以根据我们在前一节中看到的内容配置 Prettier,看下面的截图:

如果你想动态地尝试(少量可用的)Prettier 设置,你可以在在线游乐场中进行

在为本书准备代码时,我将右边距设置为 75,因为这样才能适合打印页面。我还将缩进设置为 4 个字符,因为我觉得这样更清晰。除此之外,我将一切都保持默认设置;这样处理起来就少了一些样式参数!

使用 JSDoc 记录你的代码

一个良好的可维护性规则是代码应该被记录。JSDoc(或JSDoc3;名称反映了当前版本,3.6.0)是一个 API 文档生成器,可以为你的代码生成完整的 HTML 网站文档。你只需要在你的源代码中添加注释(以特定格式),JSDoc就会扫描代码来提取它们并生成文档。让我们先看看这些注释应该如何编写,然后再转向一个工具,让 VSC 更容易地完成这项工作。

JSDoc的官方网页在usejsdoc.org/,源代码可以在github.com/jsdoc3/jsdoc找到。

如何做…

JSDoc的主要思想是记录你的 API,包括函数、类、方法等。JSDoc注释应该在被记录的代码之前。注释以/**开头,以*/结尾;双星号将它们与普通注释区分开来。

不要过度使用星号,因为如果写三个或更多,那么注释也会被忽略;JSDoc期望两个星号,不多不少。

以下代码块显示了可能的最简单的示例,说明了如何通过提供函数的目标和参数描述来记录函数:

/**
 * Solves the Hanoi Towers puzzle, for any number of disks.
 *
 * @param {number} disks - How many disks to move
 * @param {string} from - The starting pole's name
 * @param {string} to - The destination pole's name
 * @param {string} extra - The other pole's name
 */
const hanoi = (disks, from, to, extra) => {
    if (disks === 1) {
        console.log(`Move disk 1 from post ${from} to post ${to}`);
    } else {
        hanoi(disks - 1, from, extra, to);
        console.log(`Move disk ${disks} from post ${from} to post ${to}`);
        hanoi(disks - 1, extra, to, from);
    }
};

@param符号是一个块标签,引入了一个代码项,这里是函数的参数。常见标签的(部分)列表如下:

@author 开发者的名字。
@class 定义一个类。
@constructor 将函数标记为构造函数。
@copyright, @license 法律细节。
@deprecated 将函数或方法标记为已弃用。
@exports 导出的模块成员。
@function, @callback 定义一个函数,更具体地说,是用作回调的函数。
@param 期望的参数。数据类型可以在大括号内添加。
@property or @prop 对象的属性。
@return or @returns 函数或方法的返回值。
@throws or @exception 方法抛出的异常。
@version 库的版本。

还有更多的标签,比如@private,用于标识成员为私有,但由于 JS 实际上并没有提供这个功能,我跳过了它。其他标签更具体,你可能不会使用它们,比如@generator@mixin。如果你想看到可能的块(以及一些内联)标签的完整列表,请查看usejsdoc.org/index.html

坦白说:我们在这本书中不会经常使用JsDoc,但只是因为所有需要的解释都将在文本本身中给出。对于正常的工作,我总是会使用它,但在这本书中它主要是多余的。

它是如何工作的...

编写这种注释可能很快变得乏味,但你可以使用Document This VSC 扩展来自动生成所需的模板,然后再进行补充。你可以在marketplace.visualstudio.com/items?itemName=joelday.docthis找到这个扩展,但通过 VSC 本身安装它会更简单:搜索Document This,它会很快出现。

在包含了这个扩展之后,如果你在代码上右键单击,将会出现一个新的命令,它将自动生成(大部分为空)的注释,供你完成。

至于生成自动文档,查看usejsdoc.org/about-commandline.html;我们不会深入讨论这个,因为它非常简单。你可以配置JSDoc,还可以更改它用于生成页面的模板;有关这些主题,请参阅usejsdoc.org/about-configuring-jsdoc.htmlusejsdoc.org/about-configuring-default-template.html。请参阅以下截图:

JSDoc 输出的一个简单示例

当然,文档化单个函数不会是你的用例!但对于我们的目的来说,这已经足够了;对于正常使用,你将得到一个带有链接到每个类、函数等的索引,充分记录你的代码。

你已经设置好了你的工作环境,并且能够在最新版本的 JS 中编写有文档记录的、缩进良好的代码,但这仍然不能防止可能发生的错误,所以现在让我们深入了解如何增强你的代码。

使用 ESLint 添加代码质量检查

JS 是一种非常强大的语言,但也有很大的滥用潜力。例如,大多数人会同意,如果a==b为真,并且b==c也为真,那么a==c也应该为真,但由于 JS 对==运算符应用的数据类型转换规则,你会得到以下结果:

""==0   // true
0=="0"  // true
""=="0" // false!?

接下来是另一个例子;这个非常简单的函数返回什么?

function mystery() {
    return
    { 
        something: true 
    }
}

如果你回答一个对象,你会被一个缺少分号咬到。这段代码实际上被 JS 解释为以下内容:

function mystery() {
    return ;
    {
        something: true;
    }
}

注意return后面的分号。这个函数返回undefined,而something被解释为一个表达式的标签,恰好是true;糟糕!这种情况很常见,即使你知道自己在做什么,至少得到一个关于代码可能存在问题的警告也有助于排除错误,这就是ESLint产生的警告类型。

前面显示的陷阱只是 JS 对不知情的开发人员之一。搜索JavaScript 陷阱,你会得到几个可能错误的列表。

如何做到...

Linters是一类编程工具,它们分析你的源代码,并提出关于低质量用法或构造的警告和错误,甚至可能暗示着错误。我们将使用 ESLint,由 Nicholas Zakas 于 2013 年创建;该工具的当前版本是 5.10.0,截至 2018 年 12 月。

1978 年,贝尔实验室的 Stephen Johnson 编写了第一个lint程序,他还在 Unix,另一个编译器编译器yacc)和便携式 C 编译器上工作,这使得更容易为不同的计算机架构输出代码。

^(ESLint基于可插拔规则,可以根据您的特定偏好启用或禁用,或进行配置。(您甚至可以开发自己的规则,但这超出了本书的范围。)还有一些规则包,可以让您避免必须单独配置数十个不同的规则。)

安装 ESLint 非常简单,只需要执行以下操作:

 npm install eslint eslint-config-recommended --save-dev

然后,您将不得不将 ESLint 选项添加到package.json配置文件中;让我们开始吧。首先,我们将添加一个脚本,将 ESLint 应用于我们的完整源目录(此时只有一个文件!)使用npm run eslint

"scripts": {
    "build": "babel src -d out",
 "eslint": "eslint src",
    "test": "echo \"Error: no test specified\" && exit 1"
}

我们还必须为 ESLint 本身指定一些配置。我们将为此添加一个全新的部分:

"eslintConfig": {
    "parserOptions": {
        "ecmaVersion": 2017,
        "sourceType": "module"
    },
    "env": {
        "browser": true,
        "node": true
    },
    "extends": "eslint:recommended",
    "rules": {}
} 

让我们逐个项目地进行:

  • parserOptions允许您指定要处理的 JS 版本(我选择 2017 年,用于 ES8),以及是否要使用模块(我提前指出这一点,我们将在第二章的模块化代码组织部分中看到)使用现代 JavaScript 功能)。

  • env允许您指定要使用的环境,这实际上意味着假定存在一些全局变量。在这种情况下,我表示我将同时使用浏览器和Node的代码,但还有很多其他可能的环境;请查看eslint.org/docs/user-guide/configuring中的指定环境部分。稍后,我们将添加一些更多的环境,例如用于单元测试。

  • extends允许您选择一组预定义的规则,稍后您将能够修改以适应您的口味。我选择推荐的设置;您可以在github.com/kunalgolani/eslint-config上了解更多信息。可用的规则集仅在ESlint主要版本更改时才会更改,因此它们是相当稳定的。此外,recommended设置代表通常达成一致的规则列表,因此在开始进行特定更改之前,请尝试按原样进行。完整的规则集可在eslint.org/docs/rules/上找到,推荐的规则可以在github.com/eslint/eslint/blob/master/conf/eslint-recommended.js上找到。

  • rules允许您更改一些规则以更好地适应您的风格。我们很快就会看到这样做的充分理由。

如果(仅当)您计划使用ESLint尚不支持的一些Babel功能,您应该从www.npmjs.com/package/babel-eslint安装并使用babel-eslint包。这还需要向.eslintrc.json文件添加一行以更改ESLint使用的解析器。但是,请记住,您几乎不太可能需要进行此更改!

工作原理...

如果我们按原样使用npm run eslint,我们将得到以下结果:

> npm run eslint
> simpleproject@1.0.0 eslint /home/fkereki/sample
> eslint src

/home/fkereki/sample/src/eight_queens.js
 32:1 error Unexpected console statement no-console
> X 1 problem (1 error, 0 warnings)

标准规则不允许使用console.log(...),因为您可能不希望将它们包含在您的应用程序中;这是eslint.org/docs/rules/no-console中的no-console规则。我们可以在全局或本地基础上启用或禁用规则。如果我们批准此console.log(...),那么我们必须在本地禁用no-console规则。我们将在问题行之前向源代码添加注释来完成这一点:

// eslint-disable-next-line no-console console.log(`Solutions found: ${solutions}`);

如果你使用了// eslint-disable no-console,你会禁用整个源文件的no-console规则;没有进一步的规定的// eslint-disable会禁用文件的所有规则。之后,如果你使用npm run eslint,你将不会得到错误。

现在,让我们设置一个全局规则。有些人不喜欢solutions++这一行,因为不是每个人都对++运算符感到舒适;对此有一个no-plusplus规则,位于eslint.org/docs/rules/no-plusplus,但默认情况下它不在推荐的设置中启用,所以我们将在package.jsonrules部分中全局启用它:

"rules": {
 "no-plusplus": "error"
}

之后,如果你运行ESLint,你会得到一个新的错误,应该修复代码的开发者:

/home/fkereki/sample/src/eight_queens.js 
  13:9  error  Unary operator '++' used  no-plusplus

规则的可能配置是"off"(如果你想要禁用它),"warn"(如果你想要得到一个警告,但接受它),和"error"(拒绝文件)。一些规则接受额外的配置,但那些是特定的;你需要阅读规则文档以了解可能的更改。参见eslint.org/docs/rules/no-empty以了解no-empty规则的具体示例,该规则不允许空代码块,但有一个额外选项允许它们在catch语句中。

决定启用或禁用哪些规则通常发生在项目开始时,可以预期随着时间的推移会发生一些新的规则更改。无论你选择什么,理想情况下你应该只使用"off""error";如果开发人员习惯于警告,最终他们会不再关注它们,这可能是不好的!熟悉所有规则列表,参见eslint.org/docs/rules/.

最后,所有项目都将使用一个out/目录用于输出文件,然后你可以进行分发。如果你想查看其中的一些文件,你不需要ESLint在生成的代码中抗议可能的错误。为了避免这种情况,你可以在package.json文件中添加一个小节:

 "eslintIgnore": ["**/out/*.js"],

还有更多...

当然,所有这些检查都是非常好的,但是如果你不得不停止工作,保存一切,并且每次想要检查代码中的问题时都要运行一个单独的工具,那将很快变得难以忍受。然而,使用 VSC,你可以添加一个插件以实时与 ESLint 进行交互。转到扩展视图并搜索 ESLint;你应该找到并安装一个扩展,目前版本为 1.7.2(2018 年 3 月),由 Dirk Baeumer 编写。

安装了这个扩展之后,错误将以波浪红色下划线的形式显示在屏幕上,如果你将鼠标悬停在上面,你会得到一个关于失败规则的解释。看一个例子:

ESLint 插件在实时显示代码问题

ESLint的配置项非常少;我只使用了一个"eslint.alwaysShowStatus": true,所以状态栏将显示ESLint是否已启用。

一个你可以考虑的有趣的包是 web DeepScan工具,网址为deepscan.io/home/DeepScan被宣传为超越 Lint,因为它还可以检测与隐式类型转换、空检查、不可达代码等有关的运行时问题。目前,DeepScan被认为处于测试阶段,尚无付费计划。你可以在开源项目中免费使用它;例如,你可以在 GitHub 项目中自动使用它。

为数据类型检查添加 Flow

让我们通过考虑一个将 JS 转换为(一种新的)语言的工具来结束本章。JS 的特点之一是无类型;例如,变量可以保存任何类型的值,函数可以返回任何类型的值,没有办法声明变量应该存储哪种类型的值,或者函数应该返回哪种类型的值。在本节中,我们将添加由 Facebook 开发的工具Flow,它允许进行数据类型控制。

Angular 开发人员不使用Flow,而是选择TypeScript。(好吧,不仅仅是 Angular 开发人员;您几乎可以在任何地方使用TypeScript!)这个 JS 的版本是由微软开发的,也包括数据类型,风格与Flow非常相似。TypeScript有自己的转换器,您不需要BabelFlow,因此配置会简单一些。您将使用TSLint而不是ESLint,但您不需要放弃 ESLint 的规则:安装tslint-eslint-rules;(请参阅github.com/buzinas/tslint-eslint-rules),您将获得两全其美。

我们将在第二章的添加类型部分中全面介绍如何使用Flow,但让我先给您一个预览;然后,我们将安装所有所需的包,然后我们将进一步了解更多细节。假设您编写了一个非常复杂的函数来添加两个数字:

function addTwoNumbers(x, y) {
    return x + y;
}

console.log(addTwoNumbers(22, 9)); // 31, fine

然而,由于 JS 不会检查类型并且具有一些转换规则,以下两行也可以工作:

console.log(addTwoNumbers("F", "K")); // FK - oops..*.*
console.log(addTwoNumbers([], {}));   // [object Object]! more oops...

原则上,您可以向函数添加大量数据类型检查代码来验证typeof(x)==="number",但这可能会变得很繁琐。(当然,对于某些情况,这是唯一的解决方案。)然而,许多错误可以在运行代码之前被检测到,就像这里发生的情况一样。

如果您修改函数以包含数据类型声明,Flow将能够检测到两个错误的使用,并且您将能够在运行代码之前解决这种情况:

function addTwoNumbers(x: number, y: number) {
    return x + y;
}

基本上就是这样!当然,关于可用的数据类型、定义新数据类型、使用接口等细节有很多,但我们将在下一章中介绍。目前,让我们安装它,并承诺我们很快会了解更多关于它的用法。

操作步骤如下…

安装Flow取决于您是否正在使用Babel(例如客户端浏览器代码的情况)或不使用(例如服务器端代码的情况)。我们将在第三章中看到如何处理Node;在这里,我们只考虑 Babel。

首先,执行以下命令来获取所需的 Flow 包,包括 Babel 和 ESLint 包:

npm install flow-bin babel-preset-flow eslint-plugin-flowtype --save-dev

然后,在package.json中添加"flow"预设为 Babel:

"babel": {
    "presets": ["env", "flow"] 
},

还要在package.json中的ESLint配置中添加一些行:

"eslintConfig": {
    "parserOptions": {
        "ecmaVersion": 2017,
        "sourceType": "module"
    },
    "env": {
        "browser": true,
        "node": true
    },
 "parser": "babel-eslint",
 "extends": ["eslint:recommended", "plugin:flowtype/recommended"],
 "plugins": ["flowtype"],
    "rules": {
        .
        .
        .
    }
},

package.json中添加一个"flow"脚本:

"scripts": {
    "build": "babel src -d out",
    "flow": "flow",
    .
    .
    .
},

最后,执行npm run flow init来初始化Flow,只需一次,以创建一个包含Flow进程将使用的信息的.flowconfig文件。(有关此文件的更多信息,请参见flow.org/en/docs/config/。)

.flowconfig文件实际上并不符合其他配置文件的风格,应该是一个 JSON 文件,可能是package.json的一部分。然而,这仍然是一个未决事项;您可以查看github.com/facebook/flow/issues/153来监控进展,但目前,您将不得不处理.flowconfig

工作原理…

通过您刚刚编写的配置,您已经准备好了!每当开始工作时,只需执行npm run flow,就可以运行一个后台进程,逐步检查您的代码,并让您了解可能的数据类型问题。但是,如果您使用 VSC,甚至可以跳过此步骤;请参见下一节。

配置 Flow 的 linting

尽管ESLint已经很好地帮助我们避免 JS 的不良编码实践,但它在数据类型方面做得不多,但Flow可以帮助我们在这方面。

您可以应用一组规则,并且可以通过我们在上一节中提到的.flowconfig文件进行配置:

[lints]
all=warn
unsafe-getters-setters=off

第一行all=warn是一个全局设置,定义了所有规则的标准设置;可能的值为offwarnerror。之后,您可以为单个规则指定设置;例如,在前面的代码中,我决定忽略有关不安全的 getter 或 setter 的警告。一些规则如下:

  • sketchy-null,每当您测试可能为 false 的变量的值(例如零),但也为 null 或未定义时,例如在if (x) { ... }的上下文中。此警告旨在提醒您变量可能具有您未考虑的值。

  • sketchy-null-boolsketchy-null-numbersketchy-null-stringsketchy-null-mixedsketchy-null的更细粒度版本,并且仅适用于指定的数据类型。

  • unclear-type警告使用anyObjectFunction作为数据类型注释。

  • untyped-importuntyped-type-import警告您不要从未输入类型的文件中导入。

  • unsafe-getters-setters建议不要使用 getter 或 setter,因为它们会产生副作用。

阅读完整的当前Flow linting 规则集,网址为flow.org/en/docs/linting/rule-reference/,您还将在其中找到每个规则的示例。

您还应将include_warnings设置为true,以便能够在 VSC 中获得警告:

[options]
include_warnings=true

无论您在.fontconfig中包含哪些设置,都将全局应用于整个项目,但您也可以按文件或甚至按代码行进行更改,与 ESLint 类似。您可以通过使用flowlint-next-line注释和列出要更改的规则来禁用一行的警告:

// flowlint-next-line sketchy-null-bool:off
if (x) {
    // ...
} 

还有另一个注释flowlint,适用于整个文件。查看flow.org/en/docs/linting/flowlint-comments/以获取更多可能性。

在 VSC 中使用 Flow

与以前一样,我们希望在 VSC 中直接查看Flow问题。有一个简单的解决方案:只需转到扩展,搜索Flow Language Support,并安装该软件包;就是这样!

您还必须更改 VSC 的两个设置:

  • 添加"flow.useNPMPackagedFlow": true,这将消除在开始时执行npm run flow的需要;扩展将自行执行此操作

  • 添加"javascript.validate.enable": false以避免 Flow 的语法与 JS 之间的冲突

之后,您将能够在屏幕上看到Flow错误;请参阅以下屏幕截图以了解示例:

VSC Flow 扩展允许您实时捕获数据类型错误;但是,错误消息并不总是非常清晰

第二章:使用现代 JavaScript 特性

本章我们将涵盖的食谱如下:

  • 添加类型

  • 处理字符串

  • 增强您的代码

  • 定义函数

  • 函数式编程

  • 紧凑地进行异步调用

  • 处理对象和类

  • 在模块中组织代码

  • 确定功能的可用性

介绍

在上一章中,我们使用了许多工具来设置我们的工作环境,这些工具将贯穿本书的整个过程。在本章中,我们将为本书的其余部分做好准备,并考虑一些有趣和强大的现代 JavaScript 特性,可以帮助您更有效地编写更好的代码。

我们将考虑一些新的语言特性,这些特性将会很方便,但肯定不是所有!JS 确实已经发展成为一门大语言,有一些特性您可能永远不会需要。从一开始,我们也将更认真地使用Flow,旨在放弃使用无类型 JS,以更安全地开发代码。

重要的是要强调 JS 已经在这些年里发展了,并且没有一个单一的标准版本。最近的一个版本(正式上)被称为 ECMAScript 2018,通常缩写为 ES2018。语言的当前版本列表如下:

  • ECMAScript 1,1997 年 6 月

  • ECMAScript 2,1998 年 6 月,基本上与上一个版本相同

  • ECMAScript 3,1999 年 12 月,添加了几个新功能

  • ECMAScript 5,2009 年 12 月(从未有过 ECMAScript 4;那个版本被放弃了),也称为 JS5

  • ECMAScript 5.1,2011 年 6 月

  • ECMAScript 6(ES2015 或 ES6),2015 年 6 月

  • ECMAScript 7(ES2016),2016 年 6 月

  • ECMAScript 8(ES2017),2017 年 6 月

  • ECMAScript 9(ES2018),2018 年 6 月

ECMA 最初是欧洲计算机制造商协会的首字母缩写,但现在这个名字被认为是一个独立的名字。您可以访问它的网站www.ecma-international.org/,并在www.ecma-international.org/publications/standards/Ecma-262.htm上查看标准语言规范。

每当我们在本文中提到 JS 而没有进一步的规定时,我们指的是最新版本(即 ES2018)。没有浏览器完全实现这个版本,在本书的后面,我们将通过使用Babel来解决这个问题,这是一个工具,可以将现代特性转换为等效的、但更老的和兼容的代码,因此即使您以最新的方式编程,使用较旧的浏览器的用户仍然能够运行您的代码。我们将使用的工具将自行安装Babel,因此我们不需要这样做,但如果您感兴趣,可以在babeljs.io/上阅读更多信息。

所有与 JS 相关的很好的来源是Mozilla 开发者网络MDN),它已经有十多年的各种网络文档。请访问他们的网站developer.mozilla.org/bm/docs/Web/JavaScript;我们将经常参考它。您还可以阅读es6-features.org/,了解 ES6 功能的丰富示例。

添加类型

在上一章中,我们安装了Flow,以便我们可以为 JS 添加数据类型检查,但我们并没有真正涉及其语法或规则。让我们现在进入这个话题,然后再进入 JS 特定的功能。

入门

Flow will ignore any files that lack this comment, so even if you were adding the tool to an already existing project, you could do it gradually, adding files one at a time:
/* @flow */

从 Flow 的控制开始,您只需指定您期望任何变量的数据类型,Flow将检查它是否始终被正确使用。幸运的是,Flow也能够通过值确定数据类型;例如,如果您将一个字符串赋给一个变量,它将假定这个变量是用来包含字符串的。从flow.org/en/docs/usage/中调整一个例子,您可以写如下内容:

/* @flow */

function foo(x: ?number): string {
    if (x) {
        return x;
    } else {
        return "some string";
    }
}

console.log(foo("x"));

:?number:string注释指定x是一个可选的数值参数,并且foo应该返回一个字符串。您能看到代码的其余部分有两个问题吗?如果您使用npm run flow,您将得到一个报告,显示问题所在。首先,您不能return x,因为变量和预期返回值之间的数据类型不匹配:

Error ------------------------------------------------------------------------------------- src/types_examples.js:5:16

Cannot return x because number [1] is incompatible with string [2].

        2│
 [1][2] 3│ function foo(x /* :?number */) /* :string */ {
        4│     if (x) {
        5│         return x;
        6│     } else {
        7│         return 'some string';
        8│     }

其次,您正在尝试调用一个函数,但传递了错误类型的参数:


Error------------------------------------------------------------------------------------- src/types_examples.js:12:17

Cannot call foo with 'x' bound to x because string [1] is incompatible with number [2].

  [2] 3│ function foo(x /* :?number */) /* :string */ {
 :
 9│ }
 10│
 11│ // eslint-disable-next-line no-console
 [1] 12│ console.log(foo('x'));
 13│

前面的所有代码(除了类型声明)都是有效的 JS 代码,因此它将被接受;Flow会告诉您问题所在,以便您可以解决它们。现在,让我们更详细地了解一下,看看这个工具给我们提供了所有的可能性。

如果您想忽略Flow对任何行的警告,请在其前面加上注释,如// @FlowFixMe,并在后面说明为什么要跳过该情况。有关更多信息,请参阅flow.org/en/docs/config/options/#toc-suppress-comment-regex

如何做...

有许多种方法来定义类型,以便您可以处理简单和复杂的情况而不会出现问题。让我们从更简单的基本类型开始,然后再转向更具体的情况。

Flow 中的基本类型

可以在flow.org/en/docs/types/找到可能的数据类型定义——我们不会在这里全部复制,而是通过一些示例向您展示主要的数据类型。请查看完整的文档,因为有许多可能性,您应该了解:

:boolean 布尔值。
:number 数值。
:string 字符串。
:null 空值。您不仅会声明某个变量应该始终为 null;而是,您将与高级联合类型一起使用这些,我们将在下一节中看到。
:void 空(未定义)值。
:mixed 任何类型,但仍会进行一致性检查。例如,如果在某一点Flow知道变量是布尔值,那么将其用作字符串将被标记为错误。
:any 任何类型,Flow不会对其进行任何检查。这相当于在任何类型的任何上禁用类型检查。
function foo(x: ?boolean) 一个带有可选boolean参数的函数。这与声明参数可以是booleannullundefined是一样的。
function bar() :string 返回字符串结果的函数。
{ property ?: number } 可选的对象属性;如果存在,它可以是数字或未定义,但不能是null
: Array&lt;number> : number[] 两种不同风格的数字数组。如果您想处理固定长度的数组,元组可能适用;请访问flow.org/en/docs/types/tuples/了解更多信息。

我们将在本章后面的为箭头函数定义类型中找出如何为这些定义分配或定义类型。

我们可以在以下代码中看到一些定义的示例。我禁用了 ESLint 关于未使用变量的规则,以避免明显的问题:

// Source file: src/types_basic.js

/* @flow */
/* eslint-disable no-unused-vars */

let someFlag: boolean;
let greatTotal: number;
let firstName: string;

function toString(x: number): string {
    return String(x);
}

function addTwo(x: number | string, y: number | string) {
    return x + y;
}

function showValue(z: mixed): void {
    // not returning anything
    console.log("Showing... ", z);
}

let numbersList: Array&lt;number>;
numbersList = [22, 9, 60]; // OK
numbersList[1] = "SEP"; // error; cannot assign a string to a number

let anotherList: number[] = [12, 4, 56];

// *continues...*

addTwo()的定义存在一个隐藏的问题:您确定xy始终是相同类型吗?实际上,x可以是一个数字,y可以是一个字符串,Flow不会抱怨。我们没有简单的方法来测试这一点,需要运行时检查typeof x === typeof y

当您定义一个对象时,应为其所有属性和方法提供数据类型。对象定义被认为是sealed,这意味着您不能更改对象类型。如果您不能或不想这样做,请从一个空对象开始,然后Flow将允许您随意添加属性:

// *...continued*

let sealedObject: { name: string, age?: number } = { name: "" };

sealedObject.name = "Ivan Horvat"; // OK

sealedObject.id = 229; // error: key isn't defined in the data type 
sealedObject = { age: 57 }; // error: mandatory "name" field is missing

let unsealedObject = {};
unsealedObject.id = 229; // OK

如果一个函数期望一个带有一些属性的对象,并且它接收到一个带有这些属性以及一些额外属性的对象,Flow不会报错。如果你不想要这样,可以使用exact objects;参见flow.org/en/docs/types/objects/#toc-exact-object-types。然而,这也会导致问题,比如禁用 spread 操作符;参见github.com/facebook/flow/issues/2405进行(长达两年的)讨论。

现在,让我们转向更复杂的定义,你可能最终会使用它们,因为它们更符合通常的业务需求和程序规范。

联合类型

上一节的基本定义可能足够用于大量的代码,但是当你开始处理更复杂的问题时,你将需要一些更高级的Flow特性,并且你可能希望单独定义类型,以便在其他地方重用它们。因此,在本节和接下来的几节中,我们将看一些更高级的类型。

在 JS 中,一个变量可能在不同的时间具有不同的数据类型是很常见的。对于这种情况,你可以使用union types

// Source file: src/types_advanced.js

let flag: number | boolean;
flag = true; // OK
flag = 1; // also OK
flag = "1"; // error: wrong type

let traffic: "red" | "amber" | "green"; // traffic is implicitly string
traffic = "yellow"; // error: not allowed

type numberOrString = number | string;
function addTwo(x: numberOrString, y: numberOrString) {
    return x + y;
}

// *continues...*

在某些情况下,你可能有对象,这些对象根据某些内部值具有不同的属性,你也可以使用disjoint unions;参见flow.org/en/docs/types/unions/

类类型

Flow支持类,并且大部分是自动的。每当你定义一个类,它就成为一个独立的类型,所以你不需要做其他任何事情;你可以在其他地方直接使用它。(我们将在不久的将来在使用对象和类部分中更多地了解类。)你可以像为对象和函数一样为属性和方法分配类型。再次以Person类为例,以下代码展示了如何在Flow中定义它:

// Source file: src/types_advanced.js

class Person {
 // *class fields need Flow annotations*
 first: string;
 last: string;

    constructor(first: string, last: string) {
        this.first = first;
        this.last = last;
    }

    initials(): string {
        return `${this.first[0]}${this.last[0]}`;
    }

    fullName(): string {
        return `${this.first} ${this.last}`;
    }

    get lastFirst(): string {
        return `${this.last}, ${this.first}`;
    }

    set lastFirst(lf: string) {
        // *very unsafe; no checks!*
        const parts = lf.split(",");
        this.last = parts[0];
        this.first = parts[1];
    }
}

let pp = new Person("Jan", "Jansen"); // *OK*
let qq = new Person(1, 2); // ***error: wrong types for the constructor***
let rr: Person; // *OK, "Person" type is understood and can be used* 

然而,你可能会遇到一个问题。如果你有不同的类,即使它们的形状完全相同,Flow也不会认为它们是等价的。例如,即使AnimalPet是等价的,也不允许将Pet赋值给Animal(反之亦然):

// Source file: src/types_advanced.js

class Animal {
 name: string;
 species: string;
 age: number;
}

class Pet {
 name: string;
 species: string;
 age: number;
}

let tom: Animal;
tom = new Pet(); // error: *Pet and Animal are distinct types*

在这种特殊情况下,如果你说Pet扩展Animal,那么你可以将Pet赋值给Animal,但反过来不行。一个更一般的解决方案将涉及创建一个interface并在多个地方使用它:

// Source file: src/types_advanced.js interface AnimalInt {
 name: string;
 species: string;
 age: number;
}

class Animal2 implements AnimalInt {
 name: string;
 species: string;
 age: number;
}

class Pet2 implements AnimalInt {
 name: string;
 species: string;
 age: number;
}

let tom2: AnimalInt; // *not Animal2 nor Pet2*
tom2 = new Pet2(); // *OK now*

请注意,包括三个字段的interface定义并不意味着在定义Animal2Pet2时免除你声明这些字段;事实上,如果你忘记了其中一些字段,Flow会指出错误,因为这三个字段都没有标记为可选的。

类型别名

当你的类型变得更加复杂,或者当你想要在多个地方重用相同的定义时,你可以创建一个类型别名:

// Source file: src/types_advanced.js

type simpleFlag = number | boolean;

type complexObject = {
 id: string,
 name: string,
 indicator: simpleFlag,
 listOfValues: Array&lt;number>
};

在这种方式中定义类型之后,你可以在任何地方使用它们,甚至在定义新类型时也可以,就像我们在complexObject中定义字段为之前定义的simpleFlag类型一样:

// Source file: src/types_advanced.js

let myFlag: simpleFlag;

let something: complexObject = {
 id: "B2209",
 name: "Anna Malli",
 indicator: 1,
 listOfValues: [12, 4, 56]
};

类型别名甚至可以是泛型的,我们将在下一节中看到。你还可以从一个模块中导出类型,并在任何地方导入它们进行使用;我们将在使用库部分讨论这个问题。

泛型类型

在函数式编程中,通常会使用identity函数,它的定义如下:

// Source file: src/types_advanced.jsconst identity = x => x;

在组合逻辑中,这对应于 I combinator

你如何为这个函数编写类型定义?如果参数是一个数字,它将返回一个数字;如果是一个字符串,它将返回一个字符串,依此类推。写出所有可能的情况将是一件苦差事,也不太符合不要重复自己DRY)。Flow提供了一个解决方案,使用泛型类型*:

// Source file: src/types_advanced.js

const identity = &lt;T>(x: T): T => x;

在这种情况下,T代表通用类型。函数的参数和函数本身的结果都被定义为T类型,因此Flow将知道参数的类型是什么,结果类型也将是相同的。对于更常见的定义函数的方式,将使用类似的语法:

// Source file: src/types_advanced.jsfunction identity2&lt;T>(x: T): T {
    return x;
}

Flow还会检查您是否意外地限制了通用类型。在以下情况下,您将始终返回一个数字,而T实际上可能是任何其他不同的类型:

// Source file: src/types_advanced.jsfunction identity3&lt;T>(x: T): T {
    return 229; // *wrong; this is always a number, not generic*
*}*

您不必限制自己只使用单个通用类型;以下荒谬的例子显示了两种类型的情况:

// Source file: src/types_advanced.js

function makeObject&lt;T1, T2>(x: T1, y: T2) {
    return { first: x, second: y };
}

还可以使用带有通用类型的参数化类型,稍后可以对其进行指定。在以下示例中,对于pair的类型定义允许您进一步创建新类型,每种类型将始终生成相同类型的值对:

// Source file: src/types_advanced.js

type pair&lt;T> = [T, T];

type pairOfNumbers = pair&lt;number>;
type pairOfStrings = pair&lt;string>;

let pn: pairOfNumbers = [22, 9];

let ps: pairOfStrings = ["F", "K"];

您还可以使用通用类型的更多方式;请查看flow.org/en/docs/types/generics/,了解可用可能性的完整描述。

不透明类型用于更安全的编码

Flow(以及 TypeScript)中,结构上相同的类型被认为是兼容的,可以使用其中一个来代替另一个。让我们考虑一个例子。在乌拉圭,有一张带有 DNI 代码的国民身份证:这是一个由七位数字、一个破折号和一个检查位组成的字符串。您可以有一个应用程序,让您更新人们的数据:

// Source file: src/opaque_types.js

type dniType = string;
type nameType = string;

function updateClient(id: number, dni: dniType, name: nameType) {
    /*
        *Talk to some server*
 *Update the DNI and name for the client with given id*
    */
}

会发生什么?如果您没有定义更好的类型,那么您可能会执行诸如updateClient(229, "Kari Nordmann", "1234567-8")这样的调用;您能发现交换的值吗?由于dniTypenameType都只是底层字符串,即使它们暗示完全不同的概念,Flow也不会抱怨。Flow确保类型的正确使用,但由于它不处理语义,因此您的代码仍然可能显然错误。

不透明类型是不同的,因为它们从外部隐藏了内部实现细节,并且具有更严格的兼容性规则。您可以有一个名为opaque_types.js的文件,其中包含以下定义:

// Source file: src/opaque_types.js opaque type dniType = string;
type nameType = string; // *not opaque!*

然后,在另一个源文件中,我们可以尝试以下操作:

// Source file: src/opaque_usage.js

import type { dniType, nameType } from "./opaque_types";
import { stringToDni } from "./opaque_types";

let newDni = "1234567-8"; // *supposedly a DNI*
let newName = "Kari Nordmann";

updateClient(229, newName, newDni); // *doesn't work; 2nd argument should be a DNI*
updateClient(229, newDni, newName); // *doesn't work either; same reason*

我们如何解决这个问题?即使更改newDni的定义也无济于事:

let newDni: dniType = "1234567-8"; // *a string cannot be assigned to DNI*

即使在进行此更改后,Flow仍然会抱怨字符串不是 DNI。当我们使用不透明类型时,如果我们想要进行类型转换,我们必须自己提供。在我们的情况下,我们应该在我们的类型定义文件中添加这样的函数:

// Source file: src/opaque_types.js

const stringToDni = (st: string): dniType => {
    /*
    *    do validations on st*
 *if OK, return a dniType*
 *if wrong, throw an error*
    */
    return (st: dniType);
};

export { stringToDni };

现在,我们可以开始工作了!让我们看看代码:

// Source file: src/opaque_usage.js

updateClient(229, stringToDni(newDni), newName); // *OK!*

这仍然不是最佳的。我们知道所有 DNI 值都是字符串,所以我们应该能够将它们用作字符串,对吧?事实并非如此:

// Source file: src/opaque_usage.js

function showText(st: string) {
    console.log(`Important message: ${st}`);
}

let anotherDni: dniType = stringToDni("9876543-2");
showText(anotherDni); // error!

anotherDni变量是dniType,但是由于不透明类型不包含有关真实类型的信息,因此尝试将其用作string会失败。当然,您可以编写一个dniToString()函数,但这似乎有些过度——在一个潜在包含数十种数据类型的系统中,这种方式很快就会失控!我们有一个备选方案:我们可以添加一个子类型约束,这将允许将不透明类型用作不同的类型:

// Source file: src/opaque_types.js

opaque type dniType : string = string;

这意味着dniType可以用作string,但反之则不行。使用不透明类型将增加代码的安全性,因为将捕获更多的错误,但通过这些约束也可以获得一定程度的灵活性,这将使您的生活更轻松。

使用库

今天,您创建的任何项目都很可能依赖于第三方库,而且很可能这些库并不是用Flow编写的。默认情况下,Flow将忽略这些库,并且不会进行任何类型检查。这意味着您在使用库时可能会犯任何数据类型错误,这些错误将不会被识别,您将不得不通过测试和调试来处理它们——这是一个倒退到更糟糕时代的现象!

为了解决这个问题,Flow让您使用库定义libdefs)(请参阅flow.org/en/docs/libdefs/)来描述库的数据类型、接口或类,与库本身分开,就像 C++和其他语言中的头文件一样。Libdefs 是.js文件,但它们放在项目根目录下的flow-typed目录中。

您可以通过编辑.flowconfig配置文件来更改此目录,但我们不会干涉它。如果您有兴趣进行这样的更改,请参阅flow.org/en/docs/config/上的[libs]文档。

存在一个库定义的存储库flow-typed,在其中您可以找到许多流行库的已经制作好的文件;有关更多信息,请参阅github.com/flowtype/flow-typed。但您不需要直接处理它,因为有一个工具可以为您完成这项工作,尽管有时它会把责任推给您!

这些天对Flow的主要反对意见,以及 TypeScript 的一个观点,是在数据类型描述方面,对于后者来说,支持的库列表要大得多。有一些项目试图使Flow与 TypeScript 的描述一起工作,但到目前为止,这仍然是悬而未决的,尽管已经显示了一些良好的结果。

首先,安装新工具:

npm install flow-typed --save-dev

然后,在package.json中添加一个脚本来简化工作:

scripts: {
    .
    .
    .
    addTypes: "flow-typed install",
    .
    .
    .

使用npm run addTypes将扫描您的项目并尝试添加所有可能的 libdefs。如果它找不到库的适当定义(很抱歉,这并不罕见),它将在所有地方使用any创建一个基本定义。例如,我将moment库添加到项目中:

> npm install moment --save
> npm run addTypes

之后,flow-typed目录被添加到项目根目录。在其中,出现了许多文件,包括moment_v2.3.x.js,其中包含了moment库的类型定义。对于没有 libdef 的库,也创建了文件,但您可以忽略它们。

如果您需要一个 libdef,但它不存在,您可能可以自己创建它。(并且,请将您的工作贡献给flow-typed项目!)我添加了npm install fetch --save,但当我尝试获取 libdef 时,它找不到。因此,我可以继续在没有定义的情况下工作(标准情况!),或者我可以尝试创建适当的文件;没有一个真正是最佳情况。

我建议将flow-typed目录添加到.gitignore中,以便这些文件不会上传到 Git。因为每次从存储库中拉取时都要执行npm install是标准做法,现在您还必须使用npm run addTypes——或者更好的是,创建一个将执行这两个命令的脚本!

处理字符串

自从第一个版本以来,字符串一直是 JS 的一个特性,但现在有一些更多的功能可用。

如何做...

在接下来的章节中,我们将看到许多函数,我们将在本书的其余部分中使用这些函数,例如插值(从几个部分构建字符串)或标记字符串(我们将在第七章的为内联样式创建 StyledComponents部分中使用它们来为组件设置样式),这只是两个例子。

在模板字符串中插值

每个人都曾经使用常见的运算符来构建字符串,就像下面的代码片段一样:

let name = lastName + "," + firstName;
let clientUrl = basicUrl + "/clients/" + clientId + "/";

JavaScript 现在已经添加了模板文字,提供了一种简单的方法来包含变量文本并生成多行字符串。字符串插值非常简单,前面的代码可以重写如下:

let name = `${lastName}, ${firstName}`;
let clientUrl = `${basicUrl}/clients/${clientId}/`;

模板文字以前被称为模板字符串,但当前的 JS 规范不再使用该表达式。有关更多信息,请访问developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

模板文字由反引号字符(...)界定。您可以在任何需要替换值或表达式的地方使用${...}

let confirm = `Special handling: ${flagHandle ? "YES" : "NO"}`;

当插值时,当然很容易过度并开始推送太多逻辑。出于这个原因,我建议避免以下代码:

let list = ["London", "Paris", "Amsterdam", "Berlin", "Prague"];
let sched = `Visiting ${list.length > 0 ? list.join(", ") : "no cities"}`;
// Visiting London, Paris, Amsterdam, Berlin, Prague

如果list为空,将生成"访问没有城市"。如果将逻辑推出模板,将会更清晰;即使生成的代码有点大,也会在清晰度上获得优势:

let list = ["London", "Paris", "Amsterdam", "Berlin", "Prague"];
let destinations = list.length > 0 ? list.join(", ") : "no cities";
let sched = `Visiting ${destinations}`;

我们将在以后的模板中避免在React中包含逻辑,(从第六章的使用 React 开发到第十章的测试您的应用程序),并看看如何渲染组件。

标记模板

标记模板是模板的更高级形式,我们一直在看。基本上,这是另一种调用函数的方式,但语法类似于模板字符串。让我们看一个例子,然后解释一下:

// Source file: src/tagged_templates.js function showAge(strings, name, year) {
    const currYear = new Date().getFullYear();
    const yearsAgo = currYear - year;
    return (
        strings[0] + name + strings[1] + year + `, ${yearsAgo} years ago`
    );
}

const who = "Prince Valiant";
const when = 1937;
const output1 = showAge`The ${who} character was created in ${when}.`;
console.log(output1);
// *The Prince Valiant character was created in 1937, 81 years ago*

const model = "Suzuki";
const yearBought = 2009;
const output2 = showAge`My ${model} car was bought in ${yearBought}`;
console.log(output2);
// *My Suzuki car was bought in 2009, 9 years ago*

showAge()函数被以下方式调用:

  • 一个字符串数组,对应于模板的每个常量部分,因此在第一个案例中,strings[0]Thestrings[2].

  • 每个表达式都包含一个参数;在我们的例子中,有两个

该函数可以进行任何计算并返回任何类型的值——可能不是字符串!在我们的例子中,该函数生成原始字符串的增强版本,添加了多少年前发生的事情——例如,漫画角色被创建或购买汽车。

我们将在第七章的为内联样式创建 StyledComponents部分中使用标记模板,增强您的应用程序;我们将使用的 styled-component 库完全依赖于此功能,以实现更可读的代码。

编写多行字符串

新模板文字的另一个特性是它们可以跨越多行。在 JS 的早期版本中,如果要生成多行文本,您必须在输出字符串中插入换行字符("\n"),如下所示:

let threeLines = "These are\nthree lines\nof text";
console.log(threeLines);
// *These are*
// *three lines*
// *of text*

使用模板字符串,您可以按照所需的方式编写该行:

let threeLines = `These are
three lines
of text`;

但是,我建议不要这样做。即使代码可能看起来更易读,但当它缩进时,结果看起来很丑陋,因为续行必须从第一列开始——您明白为什么吗?看看以下代码——续行被推到了左边,打破了缩进代码的视觉连续性:

if (someCondition) {
    .
    .
    .
    if (anotherCondition) {
        .
        .
        .
        var threeLines = `These are
three lines
of text`;
    }
}

您可以使用反斜杠来转义不应成为模板的一部分的字符:

let notEscaped1 = `this is \$\{not\} interpolation\\nright? `;
// *"this is ${not} interpolation\nright? "*

您可能想了解String.raw(请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/raw)作为避免模板化的替代方法。您可以完全避免模板化,因为一项非正式调查显示,几乎没有开发人员知道它,而且它并不是一个很大的优势。

重复字符串

让我们以几个新的与字符串相关的函数结束。大多数都很容易理解,因此解释大多数都会很简短。有关所有可用字符串函数的完整列表,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String

您可以使用.repeat(...)方法迭代任何字符串:

"Hello!".repeat(3); // Hello!Hello!Hello!

填充字符串

您可以通过使用.padStart(...).padEnd(...)在原始文本的左侧或右侧添加重复的字符串来将字符串填充到给定长度:

"Hello".padStart(12);       // "       Hello"
"Hello".padStart(12,"XYZ"); // "XYZXYZXHello"
"Hello".padStart(3);        // "Hello"; no effect here

"Hello".padEnd(12);         // "Hello       "
"Hello".padEnd(12,"XYZ");   // "HelloXYZXYZX"
"Hello".padEnd(4);          // "Hello"; no effect here either

在可能的用途中,您可以在数字左侧用零填充。我们必须将数字转换为字符串,因为填充方法仅适用于字符串:

let padded = String(229.6).padStart(12, "0"); // "*0000000229.6*"

使用padStartpadEnd而不是padLeftpadRight的原因与从左到右和从右到左的语言有关。人们认为 start 和 end 不会产生歧义,而 left 和 right 会产生歧义。例如,在希伯来语中,字符串的开始在右侧打印,结束在左侧。

在字符串中搜索

有新的功能可以确定字符串是否以给定字符串开头,结尾或包含。这可以让你摆脱使用indexOf(...)和与长度相关的计算:

"Hello, there!".startsWith("He"); // true
"Hello, there!".endsWith("!");    // true
"Hello, there!".includes("her");  // true

这些方法中的每一个都有一个位置作为可选的第二个参数,指定在哪里进行搜索;有关更多信息,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWithdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith,和developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes

修剪字符串

您可以通过使用.trim(...).trimStart(...).trimEnd(...)在两端或一端修剪字符串:

"   Hello, there!  ".trim();      //    "*Hello, there!*"
"   Hello, there!  ".trimStart(); //    "*Hello, there!*  "
"   Hello, there!  ".trimEnd();   // "   *Hello, there!*"

最初,.trimStart().trimLeft(),而.trimEnd().trimRight(),但名称已更改,原因与.padStart().padEnd()相同。

遍历字符串

字符串现在是可迭代对象(例如数组),这意味着您可以使用for...of逐个字符地对它们进行迭代:

for (let ch of "PACKT") {
    console.log(ch);
}

扩展运算符(在本章的扩展和连接值部分深入了解)也可以工作,因此将字符串转换为单个字符的数组:

let letters = [..."PACKT"];
// ["P", "A", "C", "K", "T"]

增强您的代码

现在,让我们来看看 JS 的几个有用的新功能,这些功能与基本需求和特性有关。这不会是详尽无遗的,毕竟 JS 很大!但是,我们将涉及您可能会使用的最有趣的功能。

如何做...

本节中的功能没有共同的主题,除了它们将帮助您编写更短,更简洁的代码,并帮助您避免可能的常见错误。

在严格模式下工作

让我们从一个您可能不需要的更改开始!JS 对某些错误有些漫不经心,而不是警告或崩溃,它只是悄悄地忽略它们。2015 年,新增了一个严格模式,改变了 JS 引擎的行为,开始报告这些错误。要启用新模式,您必须在任何其他内容之前包含一行简单的字符串:

"use strict";

包含此字符串将强制执行代码的严格模式。捕获了哪些错误?简要列表包括以下内容:

  • 您不能意外创建全局变量。如果您在函数中拼错了变量的名称,JS 会创建一个新的全局变量并继续进行;在严格模式下,会产生一个错误。

  • 您不能使用eval()来创建变量。

  • 您不能有重复名称的函数参数,例如function doIt(a, b, a, c)

  • 您不能删除不可写对象属性;例如,您不能删除someObject.prototype

  • 您不能写入某些变量;例如,您不能执行undefined=22NaN=9

  • with语句是被禁止的。

  • 一些单词(例如interfaceprivate)被保留为 JS 未来版本的关键字。

上面的列表并不完整,还有一些更改和限制。有关完整详情,请阅读developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode

你应该使用这个吗?对于你的主脚本,"use strict"是可选的,但对于模块和类,它是隐含的。因此,大多数代码将始终在严格模式下运行,所以你真的会习惯包含那个字符串。也就是说,如果你使用Babel,转译器已经为你提供了所需的字符串。另一方面,Node 的模块将需要它,我们将在下一章中看到。

变量作用域

作用域的概念与可见性的概念相关联:作用域是定义元素(如变量或函数)可以被引用或使用的上下文。经典上,JS 只提供了两种类型的作用域:全局作用域(可在任何地方访问)和函数作用域(只能在函数内部访问)。由于作用域从 JS 开始就存在,让我们只记住一些规则,不做太多阐述:

  • 作用域是按层次排列的,作用域可以访问作用域中的所有内容,但反之则不行。

  • 如果你在内部作用域重新定义了某个东西,那么对作用域的访问将被禁用。引用将始终指向子定义,你无法访问外部包围作用域中同名的元素。

JS5 引入了一种新类型的作用域,称为作用域,它让你以更谨慎的方式工作。这允许你为单个块创建变量,而这些变量在块之外甚至在定义它们的函数或方法的其余部分中都不存在。有了这个概念,除了使用var之外,还添加了两种定义变量的新方法:letconst

新的声明不受提升的影响,所以如果你不习惯在使用之前在代码顶部声明所有变量,可能会遇到问题。由于通常的做法是在函数开始时进行所有声明,这不太可能影响你。更多细节请参阅developer.mozilla.org/en-US/docs/Glossary/Hoisting

第一个选项let允许你声明一个变量,该变量将被限制在使用它的块或语句中。第二个选项const添加了这样一个规定,即变量不应该改变值,而应该是常量;如果你尝试给常量赋新值,将会产生错误。以下简单示例展示了新的行为:

使用const来表示常量值需要很少的解释,但是let呢?原因可以追溯到BASIC编程语言的起源。在那种语言中,你可以使用类似37 LET X1 = (B1*A4 - B2*A2) / D的代码为变量赋值;这行代码来自达特茅斯学院 1964 年 10 月的BASIC手册复印件。更多信息请参阅www.bitsavers.org/pdf/dartmouth/BASIC_Oct64.pdf

// Source file: src/let_const.js

{ 
    let w = 0;
}
console.log(w); // *error: w is not defined!*

let x = 1;
{
    let x = 99;
}
console.log(x); // *still 1*;

let y = 2;
for (let y = 999; 1 > 2; y++) {
    /* *nothing!* */
}
console.log(y); // *still 2*;

const z = 3;
z = 9999; // *error!*

使用let也解决了一个经典问题。以下代码会做什么?在这里:

// Source file: src/let_const.js

// *Countdown to zero?*
var delay = 0;
for (var i = 10; i >= 0; i--) {
    delay += 1000;
    setTimeout(() => {
        console.log(i + (i > 0 ? "..." : "!"));
    }, delay);
}

如果你期望一个倒计时从十到零(10... 9... 8...一直到2... 1... 0!)并且每秒递减一次,你会感到惊讶,因为这段代码会输出-1!十一次!这个问题与闭包有关;当循环结束时,i变量已经是-1,所以当等待(超时)函数运行时,i就有了那个值。这可以通过几种方式解决,但是使用let而不是var是最简单的解决方案;每个闭包将捕获循环变量的不同副本,倒计时将是正确的:

// Source file: src/let_const.js

var delay = 0;
for (let i = 10; i >= 0; i--) { // *minimal fix!*
    delay += 1000;
    setTimeout(() => {
        console.log(i + (i > 0 ? "..." : "!"));
    }, delay);
}

有关块和let/const的更多信息,请查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/blockdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/constdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let在 MDN 上。

扩展和连接值

一个新的运算符,...,允许你将数组、字符串或对象展开为独立的值。这比解释更难,所以让我们看一些基本的例子:

// Source file: src/spread_and_rest.js

let values = [22, 9, 60, 12, 4, 56];

const maxOfValues = Math.max(...values); // 60
const minOfValues = Math.min(...values); // 4

你还可以用它来复制数组或连接它们:

// Source file: src/spread_and_rest.js

let arr1 = [1, 1, 2, 3];
let arr2 = [13, 21, 34];

let copyOfArr1 = [...arr1]; // a copy of arr1 is created

let fibArray = [0, ...arr1, 5, 8, ...arr2]; // first 10 Fibonacci numbers

如果你将扩展运算符应用到一个字符串,效果就是将它分隔成单独的字符,就像你使用了.split()一样;例如,console.log(..."JS")显示["J", "S"],所以这种情况并不特别有趣。

你还可以用它来克隆或修改对象;事实上,这是我们稍后要遇到的用法,主要是在第八章,扩展你的应用中,当我们使用Redux时:

// Source file: src/spread_and_rest.js

let person = { name: "Juan", age: 24 };

let copyOfPerson = { ...person }; // same data as in the person object

let expandedPerson = { ...person, sister: "María" };
// {name: "Juan", age: 24, sister: "María"}

这对于编写具有未定义数量参数的函数也很有用,避免了arguments伪数组的旧式用法。在这里,它不是将一个元素拆分成多个,而是将几个不同的元素合并成一个数组。然而,请注意,这种用法仅适用于函数的最后一个参数;像function many(a, ...several, b, c)这样的东西是不允许的:

// Source file: src/spread_and_rest.js

function average(...nums: Array&lt;number>): number {
    let sum = 0;
    for (let i = 0; i &lt; nums.length; i++) {
        sum += nums[i];
    }
    return sum / nums.length;
};

console.log(average(22, 9, 60, 12, 4, 56)); // 27.166667

如果你想知道为什么我称arguments为伪数组,原因是因为它看起来有点像一个数组,但只提供.length属性;更多信息请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments。无论如何,由于扩展运算符,你不会与它打交道。

解构数组和对象

JS 现在提供的另一个强大的构造是解构赋值。这也比解释更难,所以让我们直接看一些例子!最简单的情况可以让你将数组拆分为变量:

let [a, b, c] = [22, 9, 60]; // *a=22, b=9, c=60*

更有趣的是,你可以交换或调整变量!继续前面的例子,我们会得到以下结果:

[a, b] = [b, a];       // *a and b are swapped! a=9, b=22*
[c, b, a] = [b, a, c]; // *and now a=60, b=9, c=22*

你还可以为缺失的变量分配默认值,忽略你不关心的值,甚至应用rest运算符:

// *default values*
let [d, e = 1, f = 2, g] = [12, 4]; // *d=12, e=4, f=2, g=undefined*

// *ignoring values*
let [h, , i] = [13, 21, 34];       // *h=13, i=34*

// *using with rest*
let [j, k, ...l] = [2, 3, 5, 8];   // *j=2, k=3, l=[5,8]*

这也可以应用于对象,让你选择属性,甚至重命名它们,就像下面代码中的 flag 和 name 一样。默认情况下分配值也是可能的:

let obj = { p: 1, q: true, r: "FK" };

let { p, r } = obj;             // p=1, r="FK"
let { q: flag, r: name } = obj; // Renaming: flag=true, name="FK"
let { q, t = "India" } = obj;   // q=true; t="India"

其中一个有趣的用法是允许函数一次返回多个值。如果你想返回两个值,你可以返回一个数组或一个对象,并使用解构将返回的值分开成一个句子:

function minAndMax1(...nums) {
    return [Math.min(...nums), Math.max(...nums)];
}

let [small1, big1] = minAndMax1(22, 9, 60, 12, 4, 56);

或者,你可以使用一个对象和箭头函数来增加变化;注意我们使用的额外括号,因为我们正在返回一个对象。顺便说一下,我们也重命名了属性:

const minAndMax2 = (...nums) => ({
    min: Math.min(...nums),
    max: Math.max(...nums)
});

let { min: small2, max: big2 } = minAndMax2(22, 9, 60, 12, 4, 56);

如果你访问以下链接,你可以在 MDN 上找到许多关于扩展和解构的例子:

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment

进行幂运算

最后,让我们介绍一个新添加的运算符**,表示幂运算:

let a = 2 ** 3; // *8* 

这只是现有的Math.pow()函数的快捷方式:

let b = Math.pow(2, 3); // also 8

还存在一个指数赋值运算符,类似于+=-=等等:

let c = 4;
c **= 3; // *4 cubed: 64*

这是一个你可能不经常使用的运算符,除非你处理利息计算和金融公式。最后提醒一下:就像数学中的指数运算符从右到左分组一样,所以2 ** 3 ** 4计算为2 ** (3 ** 4);小心!

定义函数

JS 并不是一个函数式编程语言,但它包含几乎所有一个完整的函数式语言所提供的东西。在我们的情况下,我们不会深入探讨这种编程范式,但让我们看看一些将简化你工作的重要特性。

如何做...

JS 一直包括函数,可以以许多方式定义,但现在又有一种函数定义样式,将提供几个优势;继续阅读。

编写箭头函数

在阅读前面的段落后,你是否尝试计算 JS 中有多少种方式可以定义函数?实际上有比你想象的更多,至少包括以下几种:

  • 一个命名函数声明function one(...) {...}

  • 一个匿名函数表达式var two = function(...) {...}

  • 一个命名函数表达式var three = function someName(...) {...}

  • 立即调用的表达式var four = (function() { ...; return function(...) {...}; })()

  • 函数构造函数var five = new Function(...)

  • 新样式,箭头函数var six = (...) => {...}

你可能已经习惯了前面的三种方式,而后面的两种可能不太常见。然而,我们现在关心的是最后一种样式,称为箭头函数。箭头函数的工作方式与其他方式定义的函数基本相同,但有三个关键区别:

  • 箭头函数没有arguments对象

  • 箭头函数可能会隐式返回一个值,即使没有提供return语句

  • 箭头函数不绑定this的值

实际上,还有一些更多的区别,包括不能将箭头函数用作构造函数,它们没有原型属性,也不能用作生成器。有关更多信息,请参见developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions

第一个区别可以简单地通过使用展开运算符来处理,就像我们在本章前面看到的那样。因此,让我们专注于最后两项,这两项更有趣。

返回值

箭头函数可以有一段代码,其中包含一些返回语句,或者它可能只是一个表达式。前一种情况最类似于定义函数的标准方式;例如,我们可以编写一个函数来添加三个数字,如下所示,使用两种样式。我们应该在定义中添加数据类型,但我们很快就会解决这个问题:

function addThree1 (x, y, z) {
    const s = x + y + z;
    return s;
}

const addThree2 = (x, y, z) => {
    const s = x + y + z;
    return s;
};

如果你可以通过返回一个表达式来做到这一点,那么你可以写一个等效的版本;只需在箭头后面立即写出你想要返回的内容:

const addThree3 = (x, y, z) => x + y + z;

有一个特殊情况:如果你要返回一个对象,那么你必须将它放在括号中,否则 JS 会将其与一段代码块混淆。对于Redux(我们将在第八章的使用 Redux 管理状态部分中看到),你可能想编写一个返回actionaction creator,即一个带有type属性和可能更多属性的对象:

const simpleAction = (t, d) => {
    type: t;
    data: d;
};

console.log(simpleAction("ADD_KEY", 229)); // *undefined*

这里发生了什么?JS 将大括号解释为一个块,然后typedata被视为标签(如果您不记得,请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/label!),因此整个对象实际上是一个不返回任何内容的块,JS 返回一个undefined结果。将对象放在括号中将按预期工作:

const simpleAction = (t, d) => ({
    type: t;
    data: d;
});

// *this works as expected*

在箭头函数中处理 this

一个众所周知的 JS 问题是如何处理this,因为它的值并不总是你期望的!现代 JS 通过箭头函数解决了这个问题,与普通函数不同,箭头函数继承了正确的this值。一个众所周知的例子如下:您期望以下代码在几秒钟后显示JAVASCRIPT,但实际上会显示undefined(不要在意您可以以更简单的方式编写show();我想强调的是一个一般性问题而不是一个特定的解决方案):

// Source file: src/arrow_functions.js

function Show(value: mixed): void {
    this.saved = value;
 setTimeout(function() {
 console.log(this.saved);
 }, 1000);
}

let w = new Show("Doesn't work..."); // *instead, "undefined" is shown*

解决这个问题有三种方法:

  • 使用.bind()来正确绑定超时函数到this的正确值

  • 使用闭包和定义一个本地变量(通常称为that)来存储和保存this的原始值

  • 使用箭头函数,无需额外工作即可工作

我们可以在以下代码中看到这三种解决方案:

// Source file: src/arrow_functions.js

function Show1(value: mixed): void {
    this.saved = value;
    setTimeout(
 function() {
 console.log(this.saved);
 }.bind(this),
        1000
    );
}

function Show2(value: mixed): void {
    this.saved = value;
    const that = this;
    setTimeout(function() {
        console.log(that.saved);
    }, 2000);
}

function Show3(value: mixed): void {
    this.saved = value;
    setTimeout(() => {
 console.log(this.saved);
 }, 3000);
}

let x = new Show1("This");
let y = new Show2("always");
let z = new Show3("works");

我们将在第六章的使用 React 开发中看到在 React 中定义组件的.bind()思想,我们将处理与this相关的问题。

为箭头函数定义类型

最后,让我们看看如何为箭头函数定义类型。我们可以在Flow 中的基本类型部分中看到的toString()函数的另外几个实现:

// Source file: src/types_basic.js

const toString2 = (x: number): string => {
    return x + "";
};

type numberToString = number => string;
const toString3: numberToString = (x: number) => String(x);

定义默认参数值

函数的一个有趣的新特性是定义缺失参数的默认值的可能性。我们可以编写一个函数来计算n次方根,默认情况下会计算平方根:

// Source file: src/default_arguments.js

function root(a: number, n: number = 2): number {
 return a ** (1 / n);
}

// Or, equivalently:
// const root = (a: number, n: number = 2): number => a ** (1 / n);

console.log(root(125, 3));       // *5*
console.log(root(4));            // *2*
console.log(root(9, undefined)); // *3*

如第三个示例所示,传递undefined等同于省略该值。这意味着您可以为任何参数提供默认值:例如someFunction(undefined, 22, undefined)的调用将使用第一个和第三个参数的默认值,第二个参数为 22。

默认值也可以用于方法和构造函数。在以下Counter类中,如果未提供数字,inc()方法将使计数器递增1。此外,当构造计数器时,如果您没有提供初始值,将使用零:

// Source file: src/default_arguments.js

class Counter {
    count: number; // *required by Flow*

    constructor(i: number = 0) {
        this.count = 0;
    }

    inc(n: number = 1) {
        this.count += n;
    }
}

const cnt = new Counter();
cnt.inc(3);
cnt.inc();
cnt.inc();

console.log(cnt.count); // 5

最后一个细节,您可以使用先前参数的值来计算后面参数的默认值。一个简单的无意义的例子显示了这一点;我会跳过类型声明,因为它们在这里并不重要:

// Source file: src/default_arguments.js

function nonsense(a = 2, b = a + 1, c = a * b, d = 9) {
    console.log(a, b, c, d);
}

nonsense(1, 2, 3, 4);                 // *1 2 3 4*
nonsense();                           // *2 3 6 9*
nonsense(undefined, 4, undefined, 6); // *2 4 8 6*

使用默认值是简化函数使用的一种非常实用的方式,特别是在具有许多参数的复杂 API 的情况下,但允许用户省略任何合理值。

函数式编程

函数式编程通常比命令式更具声明性,具有更高级的函数,可以以更简单,直接的方式完成完整的处理。在这里,让我们看看您应该真正采用的几种函数式编程技术。

如何做...

函数式编程一直存在于 JS 中,但语言的最新版本已经添加了其他语言的众所周知的特性,您可以使用这些特性来缩短代码,使其更容易理解。

将数组减少为值

一个简单的问题:您有多少次循环遍历数组,例如,添加它的数字?很可能有很多次!这种操作——逐个遍历数组元素执行一些计算以得出最终结果——是我们将以函数方式实现的第一个操作,使用.reduce()

名称.reduce()基本上告诉我们它的作用:将完整的数组减少为一个单一的值。在其他语言中,这个操作被称为fold

最常见的例子,大多数文本和文章都展示了,是对数组的所有元素求和,而且由于我传统,让我们就这样做吧!您必须为您的计算提供一个初始值(在本例中,因为我们想要一个总和,所以它将是零),以及一个在访问每个数组元素时将更新计算值的函数:

// Source file: src/map_filter_reduce.js

const someArray: Array&lt;number> = [22, 9, 60, 12, 4, 56];

const totalSum = someArray.reduce(
    (acc: number, val: number) => acc + val,
    0
); // *163*

它是如何工作的?在内部,.reduce()首先采用您的初始值(在本例中为零),然后调用减少函数,给它累积总数(acc)和数组的第一个元素(val)。函数必须更新累积总数:在这种情况下,它将计算0 + 22,所以下一个总数将是*22*。之后,.reduce()会再次调用该函数,传递 22(更新后的总数)和9(第二个数组元素),31将成为新的累积总数。这将系统地进行整个数组,直到计算出最终值(163)。请注意,循环控制的所有方面都是自动的,因此您不可能在某个地方出错,而且代码相当声明性:您几乎可以将其阅读为"通过对所有元素求和,从零开始,将someArray减少为一个值"。

.reduce()还有一些更多的可能性:查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce获取更多信息。您还可以使用.reduceRight(),它基本上以相同的方式工作,但是从数组的末尾开始并向后进行;请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/ReduceRight获取更多信息。

当然,您不仅限于处理数字数组;您可以处理任何数据类型,并且最终结果也可以是任何类型。例如,您可以使用.reduce()将一个名字数组转换为 HTML 项目符号列表,如下所示:

// Source file: src/map_filter_reduce.js

const names = ["Juan", "María", "Sylvia", "Federico"];

const bulletedList =
    "&lt;ul>" +
    names.reduce((acc, val) => `${acc}&lt;li>${val}&lt;/li>`, "") +
    "&lt;/ul>";

// *&lt;ul>&lt;li>Juan&lt;/li>&lt;li>María&lt;/li>&lt;li>Sylvia&lt;/li>&lt;li>Federico&lt;/li>&lt;/ul>*

稍加练习,可以肯定地说,您可能能够将数组上的任何类型的计算转换为一个.reduce()调用,从而获得更短、更清晰的代码。

映射数组

第二种非常常见的操作是遍历数组,并通过对每个元素进行某种处理来生成一个新数组。幸运的是,我们也有一种函数式的方法来实现这个功能,即使用.map()。这个函数的工作方式很简单:给定一个数组和一个函数,它将该函数应用于数组的每个元素,并生成一个包含每次调用结果的新数组。

假设我们调用了一个 Web 服务,并得到了一个包含人员数据的数组。我们只想要他们的年龄,以便我们能够进行其他处理;比如,计算使用该服务的人员的平均年龄。我们可以简单地处理这个问题:

// Source file: src/map_filter_reduce.js

type person = { name: string, sex: string, age: number };

const family: Array&lt;person> = [
    { name: "Huey", sex: "M", age: 7 },
    { name: "Dewey", sex: "M", age: 8 },
    { name: "Louie", sex: "M", age: 9 },
    { name: "Daisy", sex: "F", age: 25 },
    { name: "Donald", sex: "M", age: 30 },
    { name: "Della", sex: "F", age: 30 }
];

const ages = family.map(x => x.age);
//  [*7, 8, 9, 25, 30, 30*]

使用.map()就像.reduce()一样,是处理数组的一种更短、更安全的方式。事实上,大多数情况下,这两种操作是连续使用的,中间可能混合一些.filter()操作来选择应该或不应该被处理的内容;让我们现在来看看这个。

.map()操作还有一些额外的特性;请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map获取完整的描述。此外,如果您真的想影响原始数组,而不是生成一个新数组,请查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach中的.forEach()方法。

过滤数组

我们正在考虑的第三个操作是.filter(),它将扫描整个数组并生成一个新数组,但只包含满足某些条件的元素,这些条件是由您通过函数给出的。根据我们的例子,我们可以通过编写以下内容来从服务结果中选择只有男性:

// Source file: src/map_filter_reduce.js

const males = family.filter(x => x.sex === "M");
// *an array with Huey, Dewey, Louie, and Donald records*

有了这三个操作,就可以轻松地进行调用序列并生成少量代码的结果。例如,我们可以找出家庭中年龄最大的男性吗?是的,只需几行代码就可以:

// Source file: src/map_filter_reduce.js

const eldestMaleAge = family
    .filter(x => x.sex === "M")
    .map(x => x.age)
    .reduce((acc, val) => Math.max(acc, val), 0); // *30*

这种链式操作的风格非常常见:在这种情况下,我们首先选择男性,然后选择他们的年龄,然后将数组减少到一个单一值,即最大值:简洁!

从函数生成函数

让我们通过查看一个典型的函数式编程工具来完成本节的功能方面:高阶函数HOFs):生成函数作为结果的函数!在后面的章节中,我们将实际上遇到更多 HOF 的用法;在这里,让我们解决一个简单的例子。

以下示例摘自我之前为 Packt 撰写的书籍《精通 JavaScript 函数式编程》。第二章,函数式思维-第一个例子,第六章,生成函数-高阶函数将特别涉及到 HOFs。更多信息请参见www.packtpub.com/web-development/mastering-javascript-functional-programming

假设您开发了一个电子商务网站。用户选择产品,将它们添加到购物车中,最后点击“BILL ME”按钮,以便对其信用卡进行扣款。但是,如果用户点击两次或更多次,他/她将被多次而不是一次计费。您的应用程序可能在其 HTML 中有以下内容:

&lt;button id="billBtn" onclick="billUser(sales, data)">Bill me&lt;/button>

在您的脚本中的某个地方,会有以下类似的代码。我没有包含数据类型声明,因为它们与我们的代码无关;我们实际上并不知道或关心billUser()的参数是什么:

function billUser(sales, data) {
    window.alert("Billing the user...");
    // *actually bill the user*
}

现在,为了避免重复点击按钮,您可以采取什么措施?有几种不太好的解决方案,例如以下:

  • 什么都不做,只是警告用户,并希望他们注意!

  • 使用全局标志来表示用户点击一次的事实。

  • 在用户点击后,从按钮中删除onclick处理程序。

  • onclick处理程序更改为其他不会向用户收费的内容。

然而,所有这些解决方案都有些不足,依赖于全局对象,需要您干预计费功能,与用户视图紧密相关等。由于要求某些函数仅执行一次并不是一个奇特的要求,让我们指定以下内容:

  • 原始函数应保持不变并执行其功能-仅此而已

  • 我们希望有一个新函数,它将调用原始函数,但只调用一次

  • 我们希望有一个通用的解决方案,这样我们就可以在不同的情况下应用它

我们将编写一个名为once()的函数,它将以一个函数作为参数并生成一个新函数,但是只会执行一次。逻辑并不复杂,但请仔细研究:

// Source file: src/functional_code.js

const once = fn => {
 let done = false;
 return (...args) => {
 if (!done) {
 done = true;
 fn(...args);
 }
    };
};

我们新函数的一些分析如下:

  • 定义显示once()将一个通用函数(fn())作为参数

  • return语句表明once()返回另一个函数

  • 我们使用展开运算符来处理具有任意数量参数的函数

  • 我们使用闭包来处理done变量,它会记住fn()是否被调用

为了清晰起见,我省略了类型定义,但在本书提供的源代码中,提供了完整的定义。您能自己解决吗?提示:once()函数的输出应与其输入的类型相同。

有了这个新函数,你可以将按钮编码如下。当用户点击按钮时,将调用带有(sales, data)作为参数的函数不是billUser(),而是将once()应用于billUser()的结果——这将导致产生一个只调用billUser()一次的新函数:

&lt;button id="billButton" onclick="once(billUser)(sales, data)">
Bill me
&lt;/button>;

这就是高阶函数的概念:一个接收函数作为参数并产生一个新函数作为结果的函数。通常,我们可能希望进行三种可能的转换:

  • 包装函数:我们这样做是为了保持它们的原始功能,但添加一些新功能;例如,我们可以添加日志记录或计时,以便原始函数仍然执行其功能,但记录其参数或生成时间信息。

  • 修改函数:我们这样做是为了使它们在某些关键点上与原始版本不同;这就是我们对once()所做的事情,它会生成一个仅运行一次的新版本的函数

  • 其他更改:这些更改包括将函数转换为 promise(我们将在Node中看到这一点,在第三章的使用 promise 代替错误优先回调部分,使用 Node 进行开发)等等

紧凑地进行异步调用

当 Ajax 开始出现时,它通常与回调一起使用,这些回调本身可能有自己的回调,内部还有更多的回调,最终导致了“回调地狱”的术语的产生。作为摆脱这种不切实际的编程风格的一种方式,出现了另外两种处理服务和异步调用的方式:promises 和async/await——尽管事实上,后者也使用 promises!

入门

让我们通过一个简单的例子来看看这两种风格。这本书是在三个不同的城市写的:印度的普纳、英格兰的伦敦和乌拉圭的蒙得维的亚,所以让我们做一些与这些城市相关的工作。我们将编写代码来获取这些城市的天气信息:

  • 仅对蒙得维的亚

  • 先是伦敦,然后是普纳,这样第二个调用将在第一个完成后才开始

  • 对三个城市进行并行处理,以便三个请求将同时进行处理,通过重叠来节省时间

我们不会深入讨论诸如使用这个或那个 API、获取私钥等细节,我们将通过访问免费的天气频道页面来进行伪装。我们将使用以下定义来进行所有的编码,我们将在Node中使用axios模块来完成,现在不要担心细节:

// Source file: src/get_service_with_promises.js

const axios = require("axios");

const BASE_URL = "https://weather.com/en-IN/weather/today/l/";

// *latitude and longitude data for our three cities*
const MONTEVIDEO_UY = "-34.90,-56.16";
const LONDON_EN = "51.51,-0.13";
const PUNE_IN = "18.52,73.86";

const getWeather = coords => axios.get(`${BASE_URL}${coords}`);

BASE_URL常量提供基本的网址,您必须将所需城市的坐标(纬度、经度)附加到其中。单独使用时,我们将得到一个类似于以下截图的页面:

我们将使用 Ajax 来获取城市的天气信息

在现实生活中,我们不会获取网页,而是获取 API,然后处理返回的结果。在我们的情况下,由于我们实际上并不关心数据,而是关心我们将用来进行调用的方法,我们将满足于显示一些无聊的信息,比如发送回多少字节。我同意,这完全没有用,但对于我们的例子来说已经足够了!

在本书中的几个地方我们将使用axios,所以您可能希望阅读它的文档,可以在github.com/axios/axios找到。

如何做...

使用函数作为回调是处理异步调用的最经典方式,但这有几个缺点,比如代码难以阅读以及在处理一些不太常见的情况时出现系列困难。在这里,我们将看看两种替代的工作方式。

使用 promises 进行 Ajax 调用

我们可以进行网络服务调用的第一种方法是使用 promises,它们是(直到更现代的async/await语句出现,我们将在下一节中看到)最受欢迎的方法。promises 在一段时间内就已经可用(最早是在 2011 年通过 jQuery 的 deferred 对象,之后是通过BlueBirdQ等库),但在最近的 JS 版本中,它们变成了原生的。由于 promises 实际上不能被认为是新东西,让我们看一些例子,以便我们可以继续使用更现代的工作方式——不,我们甚至不会考虑比 promises 更早的工作方式,而是直接使用回调!

原生 promises 意味着不再需要库吗?这是一个棘手的问题!JS promises 相当基础,大多数库都添加了几种可以简化编码的方法。(请参阅bluebirdjs.com/docs/api-reference.htmlgithub.com/kriskowal/q/wiki/API-Reference了解BluebirdQ的这些功能。)因此,虽然您可能可以完全使用原生 promises,但在某些情况下,您可能希望继续使用库。

获取 Montevideo 的天气数据很简单,如果我们使用之前定义的getWeather()函数:

// Source file: src/get_service_with_promises.js

function getMontevideo() {
    getWeather(MONTEVIDEO_UY)
        .then(result => {
            console.log("Montevideo, with promises");
            console.log(`Montevideo: ${result.data.length} bytes`);
        })
        .catch(error => console.log(error.message));
}

getWeather()函数实际上返回一个 promise;它的.then()方法对应于成功情况,.catch()对应于任何错误情况。

连续获取两个城市的数据也很简单。我们不希望在第一个请求成功之前开始第二个请求,这导致以下方案:

// Source file: src/get_service_with_promises.js

function getLondonAndPuneInSeries() {
 getWeather(LONDON_EN)
 .then(londonData => {
 getWeather(PUNE_IN)
 .then(puneData => {
                    console.log("London and Pune, in series");
                    console.log(`London: ${londonData.data.length} b`);
                    console.log(`Pune: ${puneData.data.length} b`);
                })
                .catch(error => {
                    console.log("Error getting Pune...", error.message);
                });
        })
        .catch(error => {
            console.log("Error getting London...", error.message);
        });
}

这不是编写这样一系列调用的唯一方法,但由于我们实际上不会直接使用 promises,让我们跳过其他方法。

最后,为了并行调用和优化时间,Promise.all()方法将用于构建一个新的 promise,由三个单独的 promise 组成。如果所有调用都成功,那么更大的 promise 也会成功;如果其中任何一个调用失败,那么失败也将是全局结果:

有关Promise.all()的更多信息,请查看developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all。如果您更愿意构建一个 promise,当任何一个(而不是所有)涉及的 promises 成功时,您应该使用Promise.race();请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race

// Source file: src/get_service_with_promises.js

function getCitiesInParallel() {
    const montevideoGet = getWeather(MONTEVIDEO_UY);
    const londonGet = getWeather(LONDON_EN);
    const puneGet = getWeather(PUNE_IN);

    Promise.all([montevideoGet, londonGet, puneGet])
 .then(([montevideoData, londonData, puneData]) => {
            console.log("All three cities in parallel, with promises");
            console.log(`Montevideo: ${montevideoData.data.length} b`);
            console.log(`London: ${londonData.data.length} b`);
            console.log(`Pune: ${puneData.data.length} b`);
        })
        .catch(error => {
            console.log(error.message);
        });
}

请注意我们如何使用解构赋值来获取每个城市的数据。调用这些函数的结果可能如下;我为了清晰起见添加了一些间距:

Montevideo, with promises
Montevideo: 353277 bytes

London and Pune, in series
London: 356537 b
Pune: 351679 b

All three cities in parallel, with promises
Montevideo: 351294 b
London: 356516 b
Pune: 351679 b

使用 promises 组织网络调用是一种简单直接的方法,但可能嵌套的.then()方法的使用可能变得难以理解,因此我们真的应该看看其他方法。我们将在下一节中做到这一点。

使用 async/await 进行 Ajax 调用

第二种方法async/await更现代,但实际上也是使用 promises,但简化了工作。有一些重要的定义我们应该考虑:

  • async函数将包含一些await表达式,取决于 promises

  • await表达式暂停async函数的执行,直到 promise 解决。

  • promise 解决后,将恢复处理,返回值

  • 如果发生错误,可以使用try ... catch捕获

  • await只能在 async 函数中使用

这如何影响我们的编码?让我们回顾一下我们的三个例子。获取单个城市的信息很简单:

// Source file: src/get_service_with_async_await.js async function getMontevideo() {
    try {
        const montevideoData = await getWeather(MONTEVIDEO_UY);
        console.log("Montevideo, with async/await");
        console.log(`Montevideo: ${montevideoData.data.length} bytes`);
    } catch (error) {
        console.log(error.message);
    }
}

我们仍然使用承诺(通过axios通过getWeather()调用返回的承诺),但现在代码看起来更加熟悉:您等待结果出现,然后处理它们——它几乎看起来就像是同步调用一样!

按顺序获取伦敦和普纳的数据也非常直接:您等待第一个城市的数据,然后等待第二个城市的数据,然后进行最终处理;还有什么比这更简单的呢?让我们看看代码:

// Source file: src/get_service_with_async_await.js async function getLondonAndPuneInSeries() {
    try {
        const londonData = await getWeather(LONDON_EN);
 const puneData = await getWeather(PUNE_IN);
        console.log("London and Pune, in series");
        console.log(`London: ${londonData.data.length} b`);
        console.log(`Pune: ${puneData.data.length} b`);
    } catch (error) {
        console.log(error.message);
    }
}

最后,同时获取所有数据还取决于我们在上一节中看到的Promise.all()方法:

// Source file: src/get_service_with_async_await.js

async function getCitiesInParallel() {
    try {
        const montevideoGet = getWeather(MONTEVIDEO_UY);
        const londonGet = getWeather(LONDON_EN);
        const puneGet = getWeather(PUNE_IN);

 const [montevideoData, londonData, puneData] = await Promise.all([
 montevideoGet,
 londonGet,
 puneGet
 ]);

        console.log("All three cities in parallel, with async/await");
        console.log(`Montevideo: ${montevideoData.data.length} b`);
        console.log(`London: ${londonData.data.length} b`);
        console.log(`Pune: ${puneData.data.length} b`);
    } catch (error) {
        console.log(error.message);
    }
}

并行调用代码与纯承诺版本非常相似:这里唯一的区别是您await结果,而不是使用.then()

我们已经看到了处理异步服务调用的两种方法。这两种方法都被广泛使用,但在本文中,我们倾向于使用async/await,因为生成的代码似乎更清晰,附带的额外负担更少。

使用对象和类

如果您想开始一场热烈的讨论,请问一群 Web 开发人员:JavaScript 是面向对象的语言,还是仅仅是基于对象的语言?,然后迅速撤退!这种讨论,虽然可能有些深奥,但已经进行了多年,可能还会继续一段时间。支持基于对象观点的常见论点与 JS 没有包括类和继承,而是基于原型。这个论点现在已经无效,因为 JS 的最新版本提供了两个新关键字,classextends,它们的行为方式与其他官方面向对象语言中的对应物基本相同。但是,请记住,新类只是现有基于原型的继承的语法糖;没有真正引入新的范例或模型。

JS 可以进行继承,但更难。要了解如何以老式方式实现这一点,请查看developer.mozilla.org/en-US/docs/Learn/JavaScript/Objects/Inheritance,您会同意使用classextends要比手动分配原型和构造函数好得多!

如何做到...

如果您已经使用过其他常见的编程语言,如 Java、C++和 Python,那么类和对象的概念对您来说应该已经很清楚;我们将假设是这种情况,并看看这些概念如何应用于现代 JS。

定义类

让我们从基础知识开始,看看现代 JS 中如何定义类。之后,我们将转向其他有趣的特性,但您可能不经常使用。要定义一个类,我们只需编写类似以下内容的内容:

// Source file: src/class_persons.js

class Person {
    constructor(first, last) {
        this.first = first;
        this.last = last;
    }

    initials() {
        return `${this.first[0]}${this.last[0]}`;
    }

    fullName() {
        return `${this.first} ${this.last}`;
    }
}

let pp = new Person("Erika", "Mustermann");
console.log(pp); // *Person {first: "Erika", last: "Mustermann"}*
console.log(pp.initials()); // *"EM"*
console.log(pp.fullName()); // *"Erika Mustermann"*

新的语法比在旧版本的 JS 中使用函数作为构造函数要清晰得多。我们编写了一个.constructor()方法,它将初始化新对象,并且我们定义了两个方法,.initials().fullName(),它们将对Person类的所有实例可用。

我们遵循使用大写字母开头的类名和使用小写字母开头的变量、函数、方法等的通常约定。

扩展类

我们还可以扩展先前存在的类。要引用原始构造函数,请使用super(),要引用父类的方法,请使用super.method();请参阅此处的.fullName()的重新定义:

// Source file: src/class_persons.js

class Developer extends Person {
    constructor(first, last, language) {
        super(first, last);
        this.language = language;
    }

    fullName() {
 // *redefines the original method*
        return `${super.fullName()}, ${this.language} dev`;
    }
}

let dd = new Developer("John", "Doe", "JS");
console.log(dd); // *Developer {first: "John", last: "Doe", language: "JS"}*
console.log(dd.initials()); // *"JD"*
console.log(dd.fullName()); // *"John Doe, JS dev"*

您不仅限于扩展自己的类;您也可以扩展 JS 类:

// Source file: src/class_persons.js

class ExtDate extends Date {
    fullDate() {
        const months = [
            "JAN",
            "FEB",
            "MAR",
            "APR",
            "MAY",
            "JUN",
            "JUL",
            "AUG",
            "SEP",
            "OCT",
            "NOV",
            "DEC"
        ];

        return (
            months[this.getMonth()] + 
            " " +
            String(this.getDate()).padStart(2, "0") +
            " " +
            this.getFullYear()
        );
    }
}

console.log(new ExtDate().fullDate()); // *"MAY 01 2018"*

如果您不需要特殊的构造函数,可以省略它;默认情况下将调用父类的构造函数。

实现接口

JS 不允许多重继承,也不提供实现接口的方法。但是,您可以通过使用mixins构建自己的伪接口,使用高阶函数(正如我们之前在从函数生成函数部分看到的那样),但参数是一个类,并向其添加方法(但不是属性)。即使您实际上不使用它,让我们看一个简短的例子,因为它提供了另一个以函数方式工作的例子。

阅读developer.mozilla.org/en-US/docs/Glossary/Mixin以获取定义。作为替代方案,您可以使用 TypeScript;有关后者的更多信息,请参阅www.typescriptlang.org/docs/handbook/interfaces.html

让我们再次从之前获取的Person类开始。让我们想象一些接口:一个可以提供一个具有生成自身的 JSON 版本的方法的对象,另一个可以告诉您对象具有多少属性。(好吧,这些示例都不太有用,但请忍耐;我们将使用的方法才是重要的。)我们将定义两个接收类作为参数并返回其扩展版本的函数:

// Source file: src/class_persons.js

const toJsonMixin = base =>
    class extends base {
        toJson() {
            return JSON.stringify(this);
        }
    };

const countKeysMixin = base =>
    class extends base {
        countKeys() {
            return Object.keys(this).length;
        }
    };

现在,我们可以通过使用这两个 mixins 创建一个新的PersonWithMixins类(不是一个很好的名称,对吧?),甚至可以提供不同的实现,就像.toJson()方法一样。一个非常重要的细节是要扩展的类实际上是函数调用的结果;看一下:

// Source file: src/class_persons.js

class PersonWithTwoMixins extends toJsonMixin(countKeysMixin(Person)) {
    toJson() { 
        // *redefine the method, just for the sake of it*
        return "NEW TOJSON " + super.toJson();
    }
}

let p2m = new PersonWithTwoMixins("Jane", "Roe");
console.log(p2m);
console.log(p2m.toJson());    // *NEW TOJSON {"first":"Jane","last":"Roe"}*
console.log(p2m.countKeys()); // *2*

以这种方式向对象添加方法可以解决无法实现接口的问题。这很重要,因为它展示了 JS 如何让您以高级方式工作,似乎超出了语言本身提供的范围,这样当您尝试解决问题时,您不会感到语言在阻碍您。

使用Flow,我们将使用通常的 Java 风格的 implements 和接口声明,但它们只用于类型检查;有关更多详细信息,请参阅实现接口部分。

静态方法

通常,您可能有一些与类相关但不属于特定对象实例的实用函数。在这种情况下,您可以将这些函数定义为静态方法,并且它们将以一种简单的方式可用。例如,我们可以创建一个.getMonthName()方法,它将返回给定月份的名称:

// Source file: src/class_persons.js

class ExtDate extends Date {
    static getMonthName(m) {
        const months = [
            "JAN",
            "FEB",
            .
            .
            .
            "DEC"
        ];
        return months[m];
    }
    fullDate2() {
        return (
            ExtDate.getMonthName(this.getMonth()) +
            " " +
            String(this.getDate()).padStart(2, "0") +
            " " +
            this.getFullYear()
        );
    }
}

console.log(new ExtDate().fullDate2()); // *"MAY 01 2018"*
console.log(ExtDate.getMonthName(8));  // *"SEP"*

静态方法必须通过给出类名来访问;因为它们不对应对象,所以不能与 this 或对象本身一起使用。

使用 getter 和 setter

JS 现在允许您定义动态属性,而不是对象中存储的值,而是在现场计算的值。例如,对于先前的Person类,我们可以有一个用于lastFirstgetter,如下所示:

// Source file: src/class_persons.js

class Person {
    constructor(first, last) {
        this.first = first;
        this.last = last;
    }

    // initials() method snipped out...

    fullName() {
        return `${this.first} ${this.last}`;
    }

 get lastFirst() {
 return `${this.last}, ${this.first}`;
 }

    // *see below...*
}

使用此定义,您可以访问.lastFirst属性,就好像它实际上是对象的属性一样;不需要括号:

pp = new Person("Jean", "Dupont");
console.log(pp.fullName()); // *"Jean Dupont"*
console.log(pp.lastFirst); // *"Dupont, Jean"*

您可以使用setter来补充 getter,并且它将执行任何您希望执行的操作。例如,我们可能希望让用户为.lastFirst分配一个值,然后相应地更改.first.last

有点鲁莽地工作(没有对参数进行检查!),我们可以将以下定义添加到我们的Person类中:

// Source file: src/class_persons.js

class Person {
    // ...*continued from above*

 set lastFirst(lf) {
 *// very unsafe; no checks!*
 const parts = lf.split(",");
 this.last = parts[0];
 this.first = parts[1];
 }
}

pp.lastFirst = "Svensson, Sven";
console.log(pp); // *Person **{first: " Sven", last: "Svensson"}***

当然,拥有属性并拥有相同属性的 getter 或 setter 是不允许的。此外,getter 函数不能有参数,setter 函数必须恰好有一个。

您可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/getdeveloper.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set找到有关 getter 和 setter 的更多信息。

前面的部分并没有穷尽 JS 关于类和对象的所有可能性(远远不够!),但我选择了最可能的一些来让它更清晰。

模块化代码

随着今天的 JS 应用程序变得越来越复杂,处理命名空间和依赖关系变得越来越难。解决这个问题的关键是模块的概念,它允许你将解决方案分成独立的部分,利用封装来避免不同模块之间的冲突。在本节中,我们将看看如何以这种方式工作。然而,我们将从以前的 JS 模式开始,这种模式可能以自己的方式变得有用。

Node,我们将从下一章开始使用它,也有模块,但方式不同,所以我们暂时不讨论它的模块。

如何做...

当处理数百甚至数千行代码时,组织代码是一种基本需求,因此在 JS 最终定义标准之前,设计了许多处理问题的方式。首先,我们将看看更经典的iffy方式(我们很快就会知道这意味着什么),然后转向更现代的解决方案,但请注意,阅读其他人的代码时可能会遇到所有这些风格!

以 IIFE 方式进行模块化

在模块广泛可用之前,有一种相当常见的模式,基本上提供了今天模块提供的相同功能。首先,让我们介绍一小段代码片段,然后检查它的属性:

// Source file: src/iife_counter.js

/* @flow */

/*
   * In the following code, the only thing that needs*
 *an explicit type declaration for Flow, is "name".*
 *Flow can work out on its own the rest of the types.*
*/

const myCounter = ((name: string) => {
    let count = 0;

    const inc = () => ++count;

    const get = () => count; // private

    const toString = () => `${name}: ${get()}`;

 return {
 inc,
 toString
 }; 
})("Clicks");

console.log(myCounter); // *an object, with methods **inc** and **toString***

myCounter.inc(); // *1*
myCounter.inc(); // *2*
myCounter.inc(); // *3*

myCounter.toString(); // *"Clicks: 3"*

定义一个函数并立即调用它称为 IIFE,发音为iffy,代表Immediately Invoked Function Expression

IIFE 也被称为Self-Executing Anonymous Functions,这听起来不如iffy好听!

我们定义了一个函数(以name => ...开头的函数),但我们立即调用它(之后跟着("Clicks"))。因此,myCounter被分配的不是一个函数,而是它的返回值,也就是一个对象。让我们分析一下这个对象的内容。由于函数的作用域规则,你在内部定义的任何东西都不会从外部可见。在我们的特定情况下,这意味着countget()inc()toString()都不可访问。然而,由于我们的 IIFE 返回了一个包含后两个函数的对象,这两个函数(仅限这两个函数)可以从外部使用:这就是揭示模块模式

一个问题:"Clicks"值存储在哪里,为什么从调用到调用count的值不会丢失?这两个问题的答案都与一个众所周知的 JS 特性有关,闭包,这个特性从语言开始就存在。更多信息请参阅developer.mozilla.org/en-US/docs/Web/JavaScript/Closures

如果你迄今为止一直在跟进,那么以下内容对你来说应该是清楚的:

  • 除非你自愿揭示它们,否则在模块中定义的任何变量或函数都不会从外部可见或可访问

  • 无论你在模块中决定使用什么名称,都不会与外部名称冲突,因为正常的词法作用域规则

  • 捕获的变量(在我们的例子中是name)会持续存在,以便模块可以存储信息并在以后使用

总的来说,我们必须同意 IIFE 是穷人的模块,它们的使用非常普遍。浏览一下网络;你肯定会找到它的例子。然而,ES6 引入了一种更通用(更清晰、更易理解)的定义模块的方式,这就是我们将要使用的:让我们下面来谈谈这个。

以现代方式重新做我们的 IIFE 模块

模块中的关键概念是你将有单独的文件,每个文件代表一个模块。有两个互补的概念:导入和导出。模块将从其他模块导入它们需要的功能,这些功能必须已经导出,以便它们可用。

首先,让我们看看前一节中计数器模块的等价物,然后评论我们可以使用的额外功能:

// Source file: src/module_counter.1.js

/* @flow */

let name: string = "";
let count: number = 0;

let get = () => count;
let inc = () => ++count;
let toString = () => `${name}: ${get()}`;

/*
 *Since we cannot initialize anything otherwise,*
 *a common pattern is to provide a "init()" function*
 *to do all necessary initializations.*
*/
const init = (n: string) => {
 name = n;
};

export default { inc, toString, init }; // *everything else is private*

我们如何使用这个模块?让我们先搁置一些内部方面的解释,先回答这个问题。

要在我们应用程序的其他文件中使用这个模块,我们将编写如下内容,使用一个新的源文件来导入我们的模块导出的函数:

// Source file: src/module_counter_usage.js
 import myCounter from "module_counter";
/*
   * Initialize the counter appropriately*
*/ myCounter.init("Clicks");

/*
   * The rest would work as before*
*/
myCounter.inc(); // 1
myCounter.inc(); // 2
myCounter.inc(); // 3
myCounter.toString(); // "Clicks: 3"

好的,所以使用这个模块来提供一个计数器并没有那么不同。与 IIFE 版本的主要区别在于,这里我们无法进行初始化。提供这一功能的常见模式是导出一个init()函数,该函数将执行所需的操作。使用模块的人必须首先调用init()来正确设置事物。

无需立即调用init()函数,就像 IIFE 版本那样,你可以推迟到必要时再调用。此外,init()函数可以被多次调用以重置模块。这些可能性提供了额外的功能。

添加初始化检查

如果你愿意,你可以通过使.init()函数更强大,使模块在未经初始化时崩溃。

// Source file: module_counter.2.js

/* @flow */

let name = "";
let count = 0;

let get = () => count;

let throwNotInit = () => {
 throw new Error("Not initialized");
};
let inc = throwNotInit;
let toString = throwNotInit;

/*
 *Since we cannot initialize anything otherwise,*
 *a common pattern is to provide a "init()" function*
 *to do all necessary initializations. In this case,*
 *"inc()" and "toString()" will just throw an error* 
 *if the module wasn't initialized.*
*/
const init = (n: string) => {
    name = n;
 inc = () => ++count;
 toString = () => `${name}: ${get()}`;
};

export default { inc, toString, init }; // *everything else is private*

通过这种方式,我们可以确保正确使用我们的模块。请注意,将新函数赋值以替换旧函数的想法是函数式编程风格的典型特征;函数是一等对象,可以传递、返回或存储。

使用更多的导入/导出可能性

在前一节中,我们通过使用所谓的默认导出来从我们的模块中导出了一个单一项:每个模块一个。还有另一种导出方式,命名导出,每个模块可以有多个。你甚至可以在同一个模块中混合它们,但通常最好不要混在一起。例如,假设你需要一个模块来进行一些距离和重量转换。你的模块可能如下所示:

// Source file: src/module_conversions.js

/* @flow */

type conversion = number => number;

const SPEED_OF_LIGHT_IN_VACUUM_IN_MPS = 186282;
const KILOMETERS_PER_MILE = 1.60934;
const GRAMS_PER_POUND = 453.592;
const GRAMS_PER_OUNCE = 28.3495;

const milesToKm: conversion = m => m * KILOMETERS_PER_MILE;
const kmToMiles: conversion = k => k / KILOMETERS_PER_MILE;

const poundsToKg: conversion = p => p * (GRAMS_PER_POUND / 1000);
const kgToPounds: conversion = k => k / (GRAMS_PER_POUND / 1000);

const ouncesToGrams: conversion = o => o * GRAMS_PER_OUNCE;
const gramsToOunces: conversion = g => g / GRAMS_PER_OUNCE;

/*
 *It's usually preferred to include all "export"*
 *statements together, at the end of the file.*
 *You need not have a SINGLE export, however.*
*/
export { milesToKm, kmToMiles };
export { poundsToKg, kgToPounds, gramsToOunces, ouncesToGrams };
export { SPEED_OF_LIGHT_IN_VACUUM_IN_MPS };

你可以有任意多的定义,并且你可以导出其中的任何一个;在我们的例子中,我们导出了六个函数和一个常量。你不需要将所有内容打包到一个单独的export中;你可以有多个,就像我们已经向你展示的那样。通常,导出通常会被分组放在模块的末尾,以帮助读者快速找到模块导出的所有内容,但有时你可能会在整个代码中找到它们;我们不会这样做。你也可以在定义的同一行导出某些内容,就像export const LENGTH_OF_YEAR_IN_DAYS = 365.2422,但出于一致性的考虑,我们也不会使用这种风格。

当导入具有命名导出的模块时,你只需要说明你想要导出的内容。你可以从不同的模块中导入;你只需要多个import语句。通常的做法是在源文件的开头将它们分组。你也可以重命名一个import,就像下面的代码中的poundsToKg,我们将使用p_to_kg。实际上,如果你从两个不同的模块中导入了同名的导出,你会这样做;在我们的特定例子中,这并没有太多意义:

// Source file: src/module_conversion_usage.js

/* @flow */

import {
 milesToKm,
 ouncesToGrams,
 poundsToKg as p_to_kg
} from "./module_conversions.js";
console.log(`A miss is as good as ${milesToKm(1)} kilometers.`);

console.log(
    `${ouncesToGrams(1)} grams of protection `,
    `are worth ${p_to_kg(1) * 1000} grams of cure.`
);

到目前为止,我们已经看到了如何导出 JS 元素——在我们的例子中是函数和常量,但你也可以导出类、对象、数组等等。在下一节中,我们将回到 Flow,并看看类型也可以被导出和导入。

使用模块的 Flow 类型

导出数据类型(包括泛型、接口等)与普通导出非常相似,只是你必须包含type这个词。如果你想在原始模块中的其他地方使用转换类型,你可以添加以下内容:

export type { conversion };

相应地,无论你想在哪里导入该类型,你都可以添加类似于这样的内容:

import type { conversion } from "./module_conversions.js";

然而,一个重要的细节是:你不能在处理标准 JS 元素的同一句中导出或导入数据类型:exportexport type是不同的、独立的语句,importimport type也是如此。

确定功能的可用性

为了完成本章,让我介绍两个网络工具,可以帮助你了解你可以安全使用哪些功能,以及哪些功能将需要转译器(比如我们在本章开头提到的Babel)。

如何做到这一点...

你的第一个资源将是kangax.github.io/compat-table/,它提供非常全面和完整的表格,逐个功能地显示了 JS 引擎在各处的支持情况。根据你的具体需求,你可能完全可以不需要转译,但在采取这样的措施之前,你应该谨慎考虑!

以下截图显示了 Kangax 的工作:

Kangax 网站可以让你确定浏览器、Node 版本等提供了哪些功能或者没有提供哪些功能

第二个你应该知道的网络工具是Can I use...,网址是caniuse.com/。在这个网站上,你可以搜索任何功能(无论是 JS、HTML 还是 CSS),你将看到哪些浏览器版本支持它或者不支持它。一个需要注意的评论是:这个网站只提供桌面和移动浏览器的信息;例如你无法看到一个功能在Node中是否被支持。以下截图显示了Can I use...的工作:

Can I Use... 网站可以让你找出哪些浏览器支持(或不支持)某个功能

第三章:使用 Node 进行开发

本章中我们将看到的示例有:

  • 检查 Node 的设置

  • 使用模块

  • 直接使用 Flow 与 Node

  • 使用 Flow 与 Node 通过预处理

  • 使用 Nodemon 运行您的 Node 代码

  • 使用承诺代替错误回调

  • 使用流处理请求

  • 使用流压缩文件

  • 使用数据库

  • 使用 exec()执行外部进程

  • 使用 spawn()运行命令,并与其通信

  • 使用 fork()来运行 Node 命令

介绍

我们在第一章的安装 Node 和 npm部分安装了Node,但那只是为了设置npmNode不仅可以用作 Web 服务器,这是最常见的用法,还可以用来编写 shell 命令行或者桌面应用程序,正如我们将在第十三章中看到的那样,使用 Electron 创建桌面应用程序。然而,所有这些环境都有一些共同的配置和开发实践,这将是本章的目标。在本章中,我们将开始Node的开发。

检查 Node 的设置

首先,让我们通过创建一个非常基本的服务器来验证Node是否正常工作;我们将在下一章第四章中详细介绍使用 Node 实现 RESTful 服务,但现在我们只是想确保一切正常。在其他章节中,我们将更认真地使用Node,但这里的目标是验证它是否正常工作。

如何做…

稍微超前一点,让我们设置一个非常基本的服务器,通过发送'Server alive!'字符串来回答所有请求。为此,我们需要遵循三个步骤:

  1. 使用require()导入Nodehttp模块——我们将在下一节中更多地了解模块;暂时假设require()等同于import

  2. 然后,使用createServer()方法来设置我们的服务器。

  3. 之后,提供一个函数,通过发送text/plain固定的答案来回答所有请求。

以下代码代表了最基本的服务器,将让我们知道一切是否都正常工作。我将文件命名为miniserver.js。粗体字的那一行完成了所有的工作,我们将在下一节中详细讨论:

// Source file: src/miniserver.js

/* @flow */
"use strict";

const http = require("http");

http
 .createServer((req, res) => {
 res.writeHead(200, { "Content-Type": "text/plain" });
 res.end("Server alive!");
 })
 .listen(8080, "localhost");

console.log("Mini server ready at http://localhost:8080/");

工作原理…

我们写了一个服务器;现在,让我们看看它是如何运行的。通过这个非常简单的项目,我们可以直接运行服务器代码。在本章的后面部分,我们将看到使用 Flow 类型将需要一些额外的预处理;然而,我们现在可以跳过这部分。我们可以使用以下命令行启动我们的服务器:

> node src/miniserver.js
Mini server ready at http://localhost:8080/

要验证一切是否正常工作,只需打开浏览器,转到http://localhost:8080。以下截图显示了(诚然不是很令人印象深刻的)结果:

我们的极简服务器正在运行,表明我们的 Node 正常工作

既然我们知道一切都正常,让我们开始一些基本技术,这些技术将在书中的其他地方使用。

为什么我们将服务器运行在端口8080而不是80?要访问低于1024的端口,您需要管理员(root)权限。然而,这将使您的服务器非常不安全;如果黑客设法进入服务器,他将在您的机器上拥有最高权限!因此,标准做法是以普通权限在端口1024以上(例如8080)运行Node,并设置反向代理以将流量发送到端口80(HTTP)或443(HTTPS)。

使用模块

在第二章的在模块中组织代码部分,我们看到了现代 JS 如何使用模块。然而,对于Node,我们有一点挫折:它不是用现代 JS 的方式处理模块的——除非您愿意使用实验性功能!

为什么Node不能使用现代的 JS 模块?原因可以追溯到新模块出现之前的几年,那时还不存在importexport语句,Node实现了 CommonJS 模块格式。(我们将在下一节中更多地了解这些模块。)显然,为了与Node一起使用的库也是使用了那种格式开发的,现在有无数个遵循这些指南的模块。

然而,自从新的模块标准出现以来,开始施加压力使用新的语法——但这带来了一些问题,不仅仅是调整语言;你可以让两种截然不同的模块风格共存吗?(因为,没有人能够神奇地将所有使用 CommonJS 的现有代码转换成新的格式,对吧?)还有一些其他的区别。ES 模块是用于异步方式使用的,而 CommonJS 模块是同步的;在大多数情况下,这并不会造成差异,但也有一些必须考虑的情况。

目前得出的解决方案还不能被认为是最终的。目前(自 8.5 版本以来),你可以通过使用--experimental-modules命令行标志来启用 ES 模块。如果你用它来调用 node,它将识别 ES 模块,如果它们的扩展名是.mjs而不是普通的.js。希望到 10 版本时,这将不再需要,但这并不能保证,而且到那时也有一定的风险,一些细节可能会发生变化!

这个解决方案使用了新的.mjs文件扩展名来识别新式模块,因为这个缘故它被戏称为迈克尔·杰克逊解决方案,因为这三个单词的首字母。

所以,如果我在一两年后写这本书,我可能会告诉你,只需继续,开始使用.mjs文件扩展名,并使用新的模块风格。

有关此功能的当前信息,请参阅nodejs.org/api/esm.html

然而,此时,这并不被认为是一个完全安全的步骤——这个功能在这个时间点上显然被标记为实验性——所以让我们继续使用当前(旧的)标准,并学习如何使用老式模块。让我们创建一个数学模块,你可能想用它来进行金融编码,这样我们就可以看到一个从头开始构建的Node风格模块。

如何做...

对于Node模块,导出和导入元素的方式有两个重要的变化。任何文件都可以是一个模块,就像 ES 模块一样。简而言之,为了从一个模块中导入东西,你将不得不使用一个require()函数,而模块本身将使用一个exports对象来指定它将导出什么。

JS 数学运算符(加法、减法等)不会进行四舍五入,所以让我们编写一个roundmath.js模块,它将执行算术运算,但是将结果四舍五入到分,用于想象中的与业务相关的应用程序。首先,我们从启用Flow和设置严格模式的两行常见代码开始:

// Source file: src/roundmath.js

/* @flow */
"use strict";

// *continues...*

不要忘记在所有模块的代码之前添加"use strict"行,就像我们在上一章的在严格模式下工作部分中提到的那样。JS 模块在定义上是严格的,但这并不适用于Node模块,它们是严格的。

然后,让我们定义我们的函数。为了多样化,我们将有一些内部(不导出)函数,以及一些将被导出的函数:

// ...*continued*

// These won't be exported:

const roundToCents = (x: number): number => Math.round(x * 100) / 100;
const changeSign = (x: number): number => -x;

// The following will be exported:

const addR = (x: number, y: number): number => roundToCents(x + y);

const subR = (x: number, y: number): number => addR(x, changeSign(y));

const multR = (x: number, y: number): number => roundToCents(x * y);

const divR = (x: number, y: number): number => {
    if (y === 0) {
        throw new Error("Divisor must be nonzero");
    } else {
        return roundToCents(x / y);
    }
};

// *continues*...

最后,按照通常的惯例,所有的输出将在底部放在一起,这样就很容易看到模块导出的所有内容。与现代的export语句不同,你可以将想要导出的内容赋值给一个exports对象。如果你想要保持变量或函数私有,你只需要跳过赋值;在我们的例子中,我们只导出了我们编写的六个函数中的四个。

// ...*continued* exports.addR = addR;
exports.subR = subR;
exports.multR = multR;
exports.divR = divR;

它是如何工作的...

我们如何使用这个模块,它是如何工作的?如果我们想要从其他模块导入一些函数,我们会写如下内容;看看我们如何使用我们设计的一些操作:

// Source file: src/doroundmath.js

/* @flow */
"use strict";

const RM = require("./roundmath.js");

console.log(RM.addR(12.348, 4.221)); // 16.57
console.log(RM.changeSign(0.07)); // error; RM.changeSign is not a function

前两行是通常的。然后,我们require()我们需要的任何模块;在这种情况下,只有一个。此外,按照惯例,所有这些要求都被分组在一起,从头开始,以便更容易理解模块的需求,而不必浏览整个代码。在我们的例子中,RM被分配了exports对象,所以你可以引用RM.addR()RM.subR()等等,这清楚地告诉读者你正在使用RM模块的东西。

如果你想写得更少一些,你可以利用解构语句(我们在上一章的解构数组和对象部分遇到过)直接将所需的方法分配给单独的变量:

/* @flow */
"use strict";

const { multR, divR } = require("./roundmath.js");

console.log(multR(22.9, 12.4)); // 283.96
console.log(divR(22, 7)); // 3.14

最好习惯于只导入你需要的模块。在其他情况下(我们将在后面的章节中看到),我们可以使用工具来删除你实际上没有使用的任何模块,如果你require()所有东西,那是不可能的。

直接使用 Flow 与 Node

由于我们使用Flow,而Node实际上并不知道数据类型,如果我们尝试执行我们的数据类型代码,显然会出现问题。对此有两种解决方案:一种不太优雅,但可以加快开发速度,另一种更强大,但需要额外的工作。让我们在这里考虑第一种更简单的解决方案,把第二种留给下一节。

如何做到这一点...

碰巧Flow提供了两种指定类型的方式:到目前为止我们一直在使用的方式,带有额外的类型标记,以及另一种更冗长的方式,通过注释。当然,JS 并不了解类型定义,所以第一种风格不会起作用,除非我们做额外的工作(正如我们将看到的),但使用注释是完全安全的。

要用注释定义类型,所有Flow特定的定义都必须用以/*:开头的注释括起来,以通常的*/结束,对于简单的基本类型,或者用/*::*/来定义其他类型。我们可以回顾一些我们在第二章中看到的例子。简单的情况如下:

// Source file: src/flowcomments.js

let someFlag /*: boolean */;
let greatTotal /*: number */;
let firstName /*: string */;

function toString(x /*: number */) /*: string */ {
    return String(x);
}

let traffic /*: "red" | "amber" | "green" */;

// *continues...*

更复杂的定义,包括可选参数、类型和不透明类型、类属性等等,需要更长的注释:

// ...*continued* /*::
type pair<T> = [T, T];
type pairOfNumbers = pair<number>;
type pairOfStrings = pair<string>;

type simpleFlag = number | boolean;

type complexObject = {
    id: string,
    name: string,
    indicator: simpleFlag,
    listOfValues: Array<number>
};
*/

class Person {
 /*::
 first: string;
 last: string;
 */

    constructor(first /*: string */, last /*: string */) {
        this.first = first;
        this.last = last;
    }

    // ...several methods, snipped out
}

// *continues...*

你也可以导出和导入数据类型:

// *...continued* /*::
import type { dniType, nameType } from "./opaque_types";
*/

/*::
export type { pairOfNumbers, pairOfStrings };
*/

它是如何工作的...

为什么以及这是如何工作的?Flow能够识别/*::  ...  */*/**:  ...  */注释,因此可以很好地完成它的工作。由于Flow代码都隐藏在注释中,从 JS 引擎的角度来看,Flow部分甚至不存在,因此这种工作方式的明显优势是你可以直接执行你的代码。

为什么你不喜欢这个?显而易见的批评是,代码看起来,委婉地说,很丑陋。如果你习惯于,比如说,TypeScript,不得不把所有与类型相关的东西都用注释包起来可能会变得很繁琐,代码也会变得更难阅读。此外,有可能会出现你会输错注释的风险(忘记其中的一个冒号是可能的),然后 Flow 将会忽略你的定义,可能会让 bug 通过。

有没有其他选择?是的,有,但这将需要一些额外的处理,同时给我们使用标准Flow符号的好处;让我们现在转向这个。

通过预处理使用 Flow 与 Node

使用注释有点过于冗长。如果您更愿意使用直接的类型注释和额外的语句,您将不得不进行一些预处理,以在尝试运行您的Node代码之前摆脱Flow的装饰。这样做的好处是,所需的处理可以非常高效,并且在开发时几乎不会被注意到;让我们深入研究一下,看看我们如何保留Flow定义,同时不破坏我们的Node代码。

如何做…

我们想要使用更简短、更简洁的Flow风格,但Node无法执行带有这些附加内容的代码。我们的难题的解决方案很简单:在尝试运行之前,只需删除与Flow相关的所有内容!有一个叫做flow-remove-types的包可以做到这一点。首先,像往常一样,您需要安装所需的包:

npm install flow-remove-types --save-dev

接着,您将需要通过添加一个新的脚本来启用它。我们是在src/目录中编写我们的代码,所以让我们将Flow清理后的输出发送到out/目录。在那个目录中,我们将得到我们将在服务器中使用的代码版本:

"scripts": {
 "build": "flow-remove-types src/ -d out/",
    "addTypes": "flow-typed install",
    "update": "npm install && flow-typed install",
    "flow": "flow",
    .
    .
    .
},

最后,我们还应该告诉Git忽略out/目录。我们已经忽略了node_modulesflow-typed目录,所以让我们再添加一个:

**/node_modules 
**/flow-typed 
**/out

我们指定**/out而不是只有out/,因为我们在许多项目之间共享一个Git存储库,为了这本书。如果像更常见的那样,您为每个项目都有一个单独的存储库,那么您只需指定out

它是如何工作的…

从您开始使用flow-remove-types起,会有什么变化?首先,显而易见的是,您不能只是用简单的node src/somefilename.js来运行您的项目;首先您需要通过npm run build来剥离Flow。这个命令的效果将是在out/中创建一个src/中的所有内容的副本,但没有类型声明。然后,您将能够通过node out/somefilename.js来运行项目——文件名不会改变。

flow-remove-types包清理您的文件时,它会用空格替换所有类型声明,因此转换后的输出文件的行数完全相同,每个函数的起始行也完全相同,不需要源映射,输出可读性不变。下面的代码显示了在使用模块部分中的我们模块的一部分在处理后的样子:

/* @flow */
"use strict";

// These won't be exported:

const roundToCents = (x: number): number => Math.round(x * 100) / 100;

const changeSign = (x: number): number => -x;

// The following will be exported:

const addR = (x: number, y: number): number => roundToCents(x + y);

const subR = (x: number, y: number): number => addR(x, changeSign(y));

const multR = (x: number, y: number): number => roundToCents(x * y);

const divR = (x: number, y: number): number => {
    if (y === 0) {
        throw new Error("Divisor must be nonzero");
    } else {
        return roundToCents(x / y);
    }
};

如果您更喜欢较小的输出(毕竟,阅读带有所有这些空格的代码可能有点令人厌烦),您可以生成一个源映射并删除所有空格,方法是通过向构建脚本添加一些参数,或者通过添加一个不同的脚本,如下面的代码片段所示:

"scripts": {
    "build": "flow-remove-types src/ -d out/",
    "buildWithMaps": "flow-remove-types src/ -d out/ --pretty --sourcemaps",
    .
    .
    .
},

VSC 中包含的Node调试器完全支持源映射,因此生成更简洁的代码不会成为问题。我们将在第五章中更多地了解这一点,测试和调试您的服务器

现在我们有了一种方法来继续使用NodeFlow,但是运行我们的代码变得稍微复杂了一点;让我们看看我们是否可以解决这个问题!

使用 Nodemon 运行您的 Node 代码

到目前为止,我们所做的工作是,每次进行更改后,运行我们更新的Node代码都需要执行以下操作:

  1. 停止当前版本的代码,如果它仍在运行。

  2. 重新运行构建过程以更新out目录。

  3. 运行新版本的代码。

对于每一个小的更改,做所有这些事情可能会很快变得无聊和令人厌烦。但是,有一个解决方案:我们可以安装一个监视程序,它将监视我们的文件变化,并自动执行这里提到的所有操作,从而使我们摆脱重复的琐事。让我们看看如何设置一个工具来监视变化,并自动执行所有这些步骤。

如何做…

我们将要安装和配置nodemon,它将为我们处理一切,根据需要运行更新的代码。首先,显然,我们必须安装提到的包。您可以使用npm install nodemon -g全局安装,但我宁愿在本地安装:

npm install nodemon --save-dev

然后,我们需要添加一对脚本:

  • npm start将构建应用程序并运行我们的主文件

  • npm run nodemon将开始监视

"scripts": {
    "build": "flow-remove-types src/ -d out/",
    "buildWithMaps": "flow-remove-types src/ -d out/ --pretty --
     sourcemaps",
 "start": "npm run build && node out/doroundmath.js",
 "nodemon": "nodemon --watch src --delay 1 --exec npm start",
    .
    .
    .  
},

现在,我们已经准备好监视我们的应用程序进行更改,并根据需要重新启动它!

它是如何工作的...

对我们最感兴趣的命令是第二个。当您运行它时,nodemon将开始监视,这意味着它将监视您选择的任何目录(在这种情况下是out),每当它检测到某个文件更改时,它将等待一秒钟(例如,以确保所有文件都已保存),然后重新运行应用程序。我是如何做到这一点的?

最初,我启动了nodemon。当您运行npm run nodemon时,项目将被构建然后运行,nodemon将继续等待任何更改;请参阅以下截图:

当您启动 nodemon 时,它会构建项目,运行它,并继续观察是否有任何需要重新启动的更改

之后,我只是添加了一个简单的console.log()行,所以文件会被更改;以下截图是结果,显示了重建和重新启动的代码,以及额外的输出行:

在观察文件发生任何更改后,nodemon 将重新启动项目。在这种情况下,我刚刚添加了一行日志,用于更改的添加文本。

就是这样。应用程序将自动重新构建和重新启动,而无需我们每次手动重新运行npm start;这是一个很大的帮助!

阅读更多关于nodemon的信息nodemon.io/github.com/remy/nodemon.

使用承诺而不是错误优先的回调

现在,让我们开始考虑在编写服务时会派上用场的几种技术。

Node作为一个单线程运行,所以如果每次它需要调用一个服务,或者读取一个文件,或者访问一个数据库,或者进行任何其他 I/O 相关的操作,它都必须等待它完成,那么处理请求将需要很长时间,阻塞其他请求的处理,并且服务器的性能会非常差。相反,所有这些操作都是异步进行的,您必须提供一个回调,当操作完成时将调用该回调;与此同时,Node将可用于处理其他客户端的请求。

有许多函数的同步版本,但它们只能应用于桌面工作,而绝不能用于 Web 服务器。

Node建立了一个标准,即所有回调都应该接收两个参数:一个错误和一个结果。如果操作以某种方式失败,错误参数将描述原因。否则,如果操作成功,错误将为 null 或 undefined(但无论如何都是假值),结果将具有结果值。

这意味着通常的Node代码充满了回调,如果回调本身需要另一个操作,那就意味着更多的回调,这些回调本身可能有更多的回调,导致所谓的回调地狱。我们不想以这种方式工作,我们希望能够选择现代的承诺,幸运的是,有一种简单的方法可以做到这一点。让我们看看如何通过避免回调来简化我们的代码。

如何做…

让我们首先看一下常见的错误优先回调是如何工作的。fs(文件系统)模块提供了一个readFile()方法,可以读取一个文件,并且可以生成它的文本或错误。我的showFileLength1()函数尝试读取一个文件,并列出其长度。与回调一样,我们必须提供一个函数,该函数将接收两个值:可能的错误和可能的结果。

此函数必须检查第一个参数是否为 null。如果不为 null,则意味着存在问题,操作不成功。另一方面,如果第一个参数为 null,则第二个参数具有文件读取操作的结果。以下代码突出了与Node回调一起使用的通常编程模式;粗体字的行是关键行:

// Source file: src/promisify.js

/* @flow */
"use strict";

const fs = require("fs");

const FILE_TO_READ = "/home/fkereki/MODERNJS/chapter03/src/promisify.js"; // its own source!

function showFileLength1(fileName: string): void {
    fs.readFile(fileName, "utf8", (err, text) => {
 if (err) {
 throw err;
 } else {
 console.log(`1\. Reading, old style: ${text.length} bytes`);
 }
    });
}
showFileLength1(FILE_TO_READ);

// *continues...*

这种编码风格是众所周知的,但实际上并不适合基于 promise 甚至更好的async/await的现代开发。因此,自Node的 8 版本以来,已经有一种方法可以自动将错误优先的回调函数转换为 promise:util.promisify()。如果将该方法应用于任何旧式函数,它将变成一个 promise,然后您可以以更简单的方式进行处理。

它的工作原理…

util模块是Node的标准模块,您只需执行以下操作即可使用它:

const util = require("util");

util.promisify()方法实际上是另一个高阶函数的示例,正如我们在第二章的使用 JavaScript 现代特性部分中看到的那样。

使用util.promisify(),我们可以使fs.readFile()返回一个 promise,然后使用.then().catch()方法进行处理:

// ...*continued*

function showFileLength2(fileName: string): void {
    fs.readFile = util.promisify(fs.readFile); 
    fs
        .readFile(fileName, "utf8")
        .then((text: string) => {
            console.log(`2\. Reading with promises: ${text.length} bytes`);
        })
        .catch((err: mixed) => {
            throw err;
        });
}
showFileLength2(FILE_TO_READ);

// *continues...*

您还可以编写const { promisify } = require("util"),然后它将变成fs.readFile = promisify(fs.readFile)

这还允许我们使用asyncawait;我将使用箭头async函数,只是为了多样性:

// ...*continued*

const showFileLength3 = async (fileName: string) => {
    fs.readFile = util.promisify(fs.readFile);

    try {
        const text: string = await fs.readFile(fileName, "utf8");
        console.log(`3\. Reading with async/await: ${text.length} bytes`);
    } catch (err) {
        throw err;
    }
};
showFileLength3(FILE_TO_READ);

还有更多…

请记住,您并不总是需要util.promisify()。这有两个原因:

无论如何,追求标准用法都会有所帮助,因此我们将在本书的其余部分采用基于 promise 的风格。

使用流来处理请求

如果您必须处理大量数据集,很明显会引起问题。您的服务器可能无法提供所有所需的内存,或者即使这不会成为问题,所需的处理时间也会超过标准等待时间,导致超时 - 再加上您的服务器会关闭其他请求,因为它将致力于处理您的长时间处理请求。

Node提供了一种使用流来处理数据集合的方法,能够在数据流动时处理数据,并将其传输以将功能组合成更小步骤的方式,非常类似于 Linux 和 Unix 的管道。让我们看一个基本示例,如果您有兴趣进行低级Node请求处理,可以使用该示例。(正如我们将在下一章中看到的那样,我们将使用更高级的库来完成这项工作。)当请求到来时,其主体可以作为流访问,从而使您的服务器能够处理任何大小的请求。

将发送给客户端的响应也是一个流;我们将在下一节使用流压缩文件中看到一个示例。

流可以有四种类型:

  • 可读:可以(显然!)读取的地方。您可以使用此选项来处理文件,或者如下例所示,获取网络请求的数据。

  • 可写:可以写入数据的地方。

  • 双工:可读写,例如网络套接字。

  • 转换:可以转换读取和写入的双工流;我们将看到一个用于压缩文件的示例。

如何做…

让我们编写一些简单的代码来处理请求,并显示所请求的内容。我们的请求处理的主要代码将如下所示:

// Source file: src/process_request.js

const http = require("http");

http
    .createServer((req, res) => {
        // *For PUT/POST methods, wait until the*
        // *complete request body has been read.*

        if (req.method === "POST" || req.method === "PUT") {
            let body = "";

 req.on("data", data => {
 body += data;
 });

 req.on("end", () => processRequest(req, res, body));

        } else {
            return processRequest(req, res, "");
        }
    })
    .listen(8080, "localhost");

// *continues...*

processRequest()函数将非常简单,仅限于显示其参数。如果您需要更好地理解如何处理请求,这种代码可能会有所帮助,正如我们将在下一章中看到的那样。我们将从 URL 和请求体中获取参数:

// ...*continued*

const url = require("url");
const querystring = require("querystring");

function processRequest(req, res, body) {
 /*
 *Get parameters, both from the URL and the request body*
 */
 const urlObj = url.parse(req.url, true);
 const urlParams = urlObj.query;
 const bodyParams = querystring.parse(body);

 console.log("URL OBJECT", urlObj);
 console.log("URL PARAMETERS", urlParams);
 console.log("BODY PARAMETERS", bodyParams);

 /*
 * Here you would analyze the URL to decide what is required*
 *Then you would do whatever is needed to fulfill the request*
 *Finally, when everything was ready, results would be sent*
 *In our case, we just send a FINISHED message*
 */

 res.writeHead(200, "OK");
 res.end(`FINISHED WITH THE ${req.method} REQUEST`);
}

下面我们将看到的代码输出将是请求url对象(req.url),它的参数以及请求体中的参数。

工作原理…

让我们运行刚刚编写的简单服务器,看看它是如何工作的。我们可以使用以下两行构建并运行它:

> npm run build
> node out/process_request.js

服务器运行后,我们可以使用curl进行测试——我们将在第五章的从命令行测试简单服务部分中回到这一点,并且我们将看到我们的FINISHED...消息:

> curl "http://127.0.0.1:8080/some/path/in/the/server?alpha=22&beta=9" 
FINISHED WITH THE GET REQUEST

URL 周围的引号是必需的,因为&字符本身对于 shell 行命令具有特殊含义。

服务器控制台将显示以下输出,但我们现在关心的是 URL 参数,它与curl调用中提供的内容匹配:

URL OBJECT Url {
 protocol: null,
 slashes: null,
 auth: null,
 host: null,
 port: null,
 hostname: null,
 hash: null,
 search: '?alpha=22&beta=9',
 query: { alpha: '22', beta: '9' },
 pathname: '/some/path/in/the/server',
 path: '/some/path/in/the/server?alpha=22&beta=9',
 href: '/some/path/in/the/server?alpha=22&beta=9' }
URL PARAMETERS { alpha: '22', beta: '9' }
BODY PARAMETERS {}

这很容易,但如果服务请求是POST,我们将监听事件来构建请求的body。请参考以下:

  • 'data'在有更多数据需要处理时触发。在我们的情况下,每次事件中我们都会向body字符串添加内容,以便构建请求体

  • 当没有更多数据时,将触发'end'。在这里,我们使用它来识别我们已经获得了完整的请求体,并且现在准备继续处理它。

  • 'close'(当流关闭时)和'error'事件在这里不适用,但也适用于流处理。

如果我们执行curl -X "POST" --data "gamma=60" --data "delta=FK" "http://127.0.0.1:8080/other/path/"进行POST,传递一对 body 参数,控制台输出将会改变:

URL OBJECT Url {
 protocol: null,
 slashes: null,
 auth: null,
 host: null,
 port: null,
 hostname: null,
 hash: null,
 search: null,
 query: {},
 pathname: '/other/path/',
 path: '/other/path/',
 href: '/other/path/' }
URL PARAMETERS {}
BODY PARAMETERS { gamma: '60', delta: 'FK' }

nodejs.org/api/stream.html上阅读更多关于流的信息(大量信息!)。

使用流压缩文件

我们可以看到更多使用流的例子,包括多种类型,比如我们想要压缩文件的情况。在这个示例中,我们将使用可读流从源中读取,使用可写流放置压缩后的结果。

如何做…

代码非常简单,而且也很短。我们只需要require所需的模块,为我们将要读取的文件创建一个输入流,为我们将要创建的文件创建一个输出流,并将第一个流导入到第二个流中;没有比这更简单的了:

// Source file: src/zip_files.js

const zlib = require("zlib");

const fs = require("fs");

const inputStream = fs.createReadStream(
 "/home/fkereki/Documents/CHURCHES - Digital Taxonomy.pdf"
);

const gzipStream = zlib.createGzip();

const outputStream = fs.createWriteStream(
 "/home/fkereki/Documents/CHURCHES.gz"
);

inputStream.pipe(gzipStream).pipe(outputStream);

工作原理…

我们使用fs模块生成两个流:一个可读流,用于读取给定文件(这里是一个固定的文件,但读取任何其他文件也很简单),一个可写流,用于存放压缩后的输出。我们将通过gzip模块将输入流导入,该模块将在将输入传递到输出之前对其进行压缩。

我们也可以轻松地创建一个服务器,将压缩文件发送给客户端进行下载。以下是所需的代码;关键区别在于压缩流现在转到response流。我们还必须提供一些头信息,以便客户端知道正在发送一个压缩文件:

// Source file: src/zip_send.js

const zlib = require("zlib");
const fs = require("fs");

const http = require("http");

http
    .createServer(function(request, response) {
        // Tell the client, this is a zip file.
 response.writeHead(200, {
 "Content-Type": "application/zip",
 "Content-disposition": "attachment; filename=churches.gz"
 });

        const inputStream = fs.createReadStream(
            "/home/fkereki/Documents/CHURCHES - Digital Taxonomy.pdf"
        );

        const gzipStream = zlib.createGzip();

        inputStream.pipe(gzipStream).pipe(response);
    })
    .listen(8080, "localhost");

如果您npm run build然后node out/zip_send.js,打开127.0.0.1:8080将会得到以下截图中显示的内容;您将获得要下载的压缩文件:

流也用于压缩并发送文件到浏览器

nodejs.org/api/fs.html上阅读更多关于fs的信息,并在nodejs.org/api/zlib.html上阅读更多关于zlib的信息。

使用数据库

现在让我们看看如何访问数据库,比如MySQLPostgreSQLMSSQLOracle或其他。 (我们将在第四章中需要这个,使用 Node 实现 RESTful 服务,当我们开始构建一组服务时。) 经常需要访问数据库,所以这就是我们要做的。我选择了一些地理数据(包括国家,它们的地区以及这些地区的城市),之后我们将添加一些其他内容,以便处理更复杂的例子。

准备工作

获取国家列表很容易:我使用了来自github.com/datasets/country-codes的数据,我将其修剪为只有国家的两个字符代码(如 ISO 3166-1 标准中的那样)和名称。对于地区和城市,我使用了 GeoNames 的数据,来自download.geonames.org/export/dump/;特别是我使用了admin1CodesASCII.txt,我将其改编为regions.csv,以及cities15000.zip,我编辑成了cities.csv

如果你想以更清晰的格式查看 CSV 文件,可以查看 VSC 的 EXCEL VIEWER 扩展。

关于这三个表,你需要知道的是:

  • 国家由两个字母的代码标识(比如UY代表乌拉圭,IN代表印度),并且有一个名称

  • 地区属于一个国家,由国家代码加上一个字符串标识;此外,它们有一个名称

  • 城市由数字代码标识,有一个名称,纬度和经度,人口,并且位于一个国家的一个地区

这就足够开始了;稍后,我们将添加一些更多的表,以便进行更多的实验。我使用了MariaDBMySQL的开源分支;参见mariadb.com/)和MySQL WorkBench(参见www.mysql.com/products/workbench/)来创建表并导入数据,因为这比手工操作更简单!我还创建了一个fkereki用户,密码为modernJS!!,以便访问这些表。

如果你想使用不同的数据库,比如 PostgreSQL 或 Oracle,下面的代码会非常相似,所以不用太担心处理特定的数据库。如果你使用 ORM,你会看到一些访问数据的与数据库无关的方法,这可能会帮助你真正需要处理不同的数据库产品时。

如何做…

为了访问MariaDB数据库,我们将从github.com/mscdex/node-mariasql安装mariasql包,然后将其.query()方法 promisify,以便更轻松地工作。安装完成后使用npm install mariasql --save,不久之后(你会看到一些对象代码被构建),包将被安装。按照接下来提到的步骤进行。

另一个可能性是使用来自github.com/steelbrain/mariasql-promisemariasql-promise,其所有方法都已经返回了 promises。然而,使用这个库获取连接对象并存储以供以后使用更加困难,这就是为什么我选择了原始的库;毕竟,我们只需要修改.query()来返回一个 promise。

获取连接

首先,让我们定义一些以后会用到的常量;除了Flow和严格使用行之外,我们只需要MariaDB库,promisify()函数,并定义四个常量来访问数据库:

// Source file: src/dbaccess.js

/* @flow */
"use strict";

const mariaSQL = require("mariasql");
const { promisify } = require("util");

const DB_HOST = "127.0.0.1";
const DB_USER = "fkereki";
const DB_PASS = "modernJS!!";
const DB_SCHEMA = "world";

// *continues...*

现在,让我们获取一个数据库连接。我们只需创建一个新对象,并promisify它的.query()方法。dbConn变量将作为参数传递给每个需要访问数据库的函数:

// ...*continued*

function getDbConnection(host, user, password, db) {
    const dbConn = new mariaSQL({ host, user, password, db });
    dbConn.query = promisify(dbConn.query);
    return dbConn;
}

const dbConn = getDbConnection(DB_HOST, DB_USER, DB_PASS, DB_SCHEMA); // *continues*...

执行一些查询

测试连接是否正常工作的一个简单方法是执行一个返回常量值的微不足道的查询;这里真正重要的是,函数应该在不抛出任何异常的情况下工作。我们使用await来获取.query()方法的结果,这是一个包含所有找到的行的数组;在这种情况下,数组显然只有一行:

// ...*continued*

async function tryDbAccess(dbConn) {
    try {
        const rows = await dbConn.query("SELECT 1960 AS someYear");
        console.log(`Year was ${rows[0].someYear}`);
    } catch (e) {
        console.log("Unexpected error", e);
    }
}

// *continues*...

让我们试试其他的东西:比如找出有更多城市的十个国家?我们可以使用.forEach()以一个相当不太吸引人的格式列出结果:

// ...*continued*

async function get10CountriesWithMoreCities(dbConn) {
    try {
        const myQuery = `SELECT 
            CI.countryCode, 
            CO.countryName, 
            COUNT(*) as countCities
        FROM cities CI JOIN countries CO 
        ON CI.countryCode=CO.countryCode
        GROUP BY 1 
        ORDER BY 3 DESC 
        LIMIT 10`;

 const rows = await dbConn.query(myQuery);
 rows.forEach(r =>
 console.log(r.countryCode, r.countryName, r.countCities)
 );
    } catch (e) {
        console.log("Unexpected error", e);
    }
}

// *continues...*

更新数据库

最后,让我们做一些更新。我们将首先添加一个新的(虚构的!)国家;然后我们将检查它是否存在;我们将更新它并检查更改,然后我们将删除它,最后我们将验证它是否已经消失:

// ...*continued*

async function addSeekAndDeleteCountry(dbConn) {
    try {
        const code = "42";
        const name = "DOUGLASADAMSLAND";

        /*
            1\. Add the new country via a prepared insert statement
        */
 const prepInsert = dbConn.prepare(
 "INSERT INTO countries (countryCode, countryName) VALUES (:code, :name)"
 );
 const preppedInsert = prepInsert({ code, name });
        await dbConn.query(preppedInsert);

        /*
            2\. Seek the recently added country, return an array of objects
        */
 const getAdams = `SELECT * FROM countries WHERE countryCode="${code}"`;
        const adams = await dbConn.query(getAdams);
        console.log(
            adams.length,
            adams[0].countryCode,
            adams[0].countryName
        );

        /*
            3\. Update the country, but using placeholders
        */
        await dbConn.query(
            `UPDATE countries SET countryName=? WHERE countryCode=?`,
            ["NEW NAME", code]
        );

        /*
           4\. Check the new data, but returning an array of arrays instead
        */
        const adams2 = await dbConn.query(
            `SELECT * FROM countries WHERE countryCode=?`,
            [code],
            { useArray: true }
        );
        console.log(adams2.length, adams2[0][0], adams2[0][1]);

        /*
            5\. Drop the new country
        */
 await dbConn.query(`DELETE FROM countries WHERE countryCode="42"`);

        /*
            6\. Verify that the country is no more
        */
        const adams3 = await dbConn.query(getAdams);
        console.log(adams3.length);
    } catch (e) {
        console.log("Unexpected error", e);
    }
}

// *continues...*

将所有内容整合在一起

现在,我们所要做的就是调用这三个函数,以获得一个完整的工作示例:

// ...*continued*

tryDbAccess(dbConn);
get10CountriesWithMoreCities(dbConn);
addSeekAndDeleteCountry(dbConn);

最后,我添加了一个脚本来自动运行所有测试,通过执行npm run start-db

"scripts": {
    "build": "flow-remove-types src/ -d out/",
    "buildWithMaps": "flow-remove-types src/ -d out/ --pretty --sourcemaps",
    "start": "npm run build && node out/doroundmath.js",
    "start-db": "npm run build && node out/dbaccess.js",
    .
    .
    .
},

让我们分析一下代码的工作原理,并注意一些有趣的地方。

它是如何工作的...

运行tryDbAccess()并不难理解:常量查询发送到服务器,返回一个包含单行的数组。我们的代码的输出将如下所示:

Year was 1960

第二个查询变得更有趣。除了实际编写 SQL 查询的细节(这超出了本书的目标)之外,有趣的是返回的数组,每个数组中都有一个包含所选字段的对象:

IN India 1301
BR Brazil 1203
RU Russian Federation 1090
DE Germany 1061
CN China 810
FR France 633
ES Spain 616
JP Japan 605
IT Italy 575
MX Mexico 556

现在,让我们来看最后一个例子。我们看到了几种创建将要执行的语句的方式。

INSERT使用了一个准备好的语句。准备好安全查询的一个好方法(意思是,它们不会涉及到 SQL 注入攻击)是使用准备好的字符串。.prepare()方法很有趣:给定一个字符串,它返回一个函数,当用实际参数调用时,它本身将返回要在查询中使用的字符串。当然,您也可以手动构建函数,就像我在其他示例中所做的那样——但是这时你必须确保生成的查询是安全的!

.escape()方法可以帮助构建一个安全的查询字符串,如果你不想使用.prepare()。更多信息请参见github.com/mscdex/node-mariasql

接下来的SELECT使用了手工创建的字符串(这里没有什么太原创的东西),但UPDATE显示了另一种风格:使用?符号作为占位符。在这种情况下,您还必须提供一个值数组,用于替换占位符;数组中的值的顺序必须与预期的参数匹配,这是非常重要的。

接下来,第二个SELECT也使用了占位符,但添加了一个小调整:传递一个带有useArray:true选项的对象,函数执行速度会稍快一些,因为它不会为每一行创建对象,而是直接返回数组。然而,这也存在一个问题,因为现在你必须记住数组的每个位置代表什么意思。

代码的结果如预期的那样:首先是一行,显示实际创建了一个国家,带有我们传递的值;然后是相同的记录,但名称已更改,最后是一个零,表明该国家不再存在:

1 '42' 'DOUGLASADAMSLAND'
1 '42' 'NEW NAME'
0

还有更多...

在这一部分,我们已经通过直接连接,使用表和游标对数据库执行各种操作的几个示例进行了讨论。您还可以考虑使用对象关系映射ORM)库,以处理对象:最为人熟知的可能是Sequelize(在docs.sequelizejs.com/),但还有一些其他包(如TinyORMObjection.jsCaminteJS,只是提到一些仍在开发中的包,没有被放弃)。

使用 exec()执行外部进程

如果你正在使用Node实现一些服务,可能会有一些情况需要进行一些繁重的处理,正如我们之前提到的,这是一个不行,因为你会阻塞所有用户。如果你需要做这种工作,Node可以让你将工作卸载到外部进程中,从而使自己空闲并可继续工作。外部进程将以异步方式独立工作,完成后,你将能够处理其结果。有几种方法可以做到这一点;让我们来看看。

运行一个单独命令的第一种选择是child_process.exec()方法。这将生成一个 shell,并在其中执行给定的命令。生成的任何输出将被缓冲,当命令执行完成时,将调用一个回调函数,其中包含生成的输出或错误。

让我们看一个通过访问文件系统调用外部进程的例子。

如何做到这一点...

一个例子,获取给定路径上所有 JS 文件的目录列表,可能如下所示。(是的,当然你可以和应该使用fs.readDir()来做到这一点,但我们想展示如何使用子进程来做到这一点。)

正如本章前面的使用 Promise 代替错误优先回调部分所示,我们将promisify()调用,以简化编码:

// Source file: src/process_exec.js

const child_process = require("child_process");
const { promisify } = require("util");
child_process.exec = promisify(child_process.exec);

async function getDirectoryJs(path: ?string) {
    try {
 const cmd = "ls -ld -1 *.js";
        const stdout = await child_process.exec(cmd, { cwd: path });
        console.log("OUT", path || "");
        console.log(stdout);
    } catch (e) {
        console.log("ERR", e.stderr);
    }
}

它是如何工作的...

当我们调用.exec()方法时,将创建一个单独的 shell,并在其中运行命令。如果调用成功,将返回输出;否则,将抛出一个具有.stderr属性的对象作为异常。可能的一对运行如下:

getDirectoryJs("/home/fkereki/MODERNJS/chapter03/flow-typed/npm");
*OUT /home/fkereki/MODERNJS/chapter03/flow-typed/npm*
*-rw-r--r-- 1 fkereki users 4791 Apr 9 12:52 axios_v0.18.x.js*
*-rw-r--r-- 1 fkereki users 3006 Mar 28 14:51 babel-cli_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 3904 Apr 9 12:52 babel-eslint_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 2760 Apr 9 12:52 babel-preset-env_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 888 Apr 9 12:52 babel-preset-flow_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 518 Apr 9 12:52 eslint-config-recommended_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 14995 Apr 9 12:52 eslint-plugin-flowtype_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 73344 Apr 9 12:52 eslint_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 1889 Mar 28 14:51 fetch_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 188 Apr 9 12:52 flow-bin_v0.x.x.js*
*-rw-r--r-- 1 fkereki users 13290 Apr 9 12:52 flow-coverage-report_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 1091 Apr 9 12:52 flow-remove-types_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 5763 Apr 9 12:52 flow-typed_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 1009 Apr 9 12:52 mariasql_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 0 Mar 28 14:51 moment_v2.3.x.js*
*-rw-r--r-- 1 fkereki users 5880 Apr 9 12:52 nodemon_vx.x.x.js*
*-rw-r--r-- 1 fkereki users 4786 Apr 9 12:52 prettier_v1.x.x.js*

getDirectoryJs("/boot");
*ERR ls: cannot access '*.js': No such file or directory*

.exec()的第二个参数提供了一个可能选项的对象。在我们的情况下,我们正在指定命令的当前工作目录(cwd)。另一个有趣的选项可以让你处理产生大量输出的命令。默认情况下,最大缓冲输出量为 200K;如果你需要更多,你将不得不添加一个对象,其中maxBuffer选项设置为更大的值;查看nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback了解更多关于这些和其他选项的信息。

你可以执行的命令的复杂性没有限制,但也存在风险。请记住被黑客攻击的可能性:如果你正在根据用户提供的某些输入构建你的命令,你可能会成为命令注入攻击的受害者。想象一下,如果你想构建类似于ls ${path}的东西,而用户提供的path"/; rm -rf *",会发生什么?

还有更多...

使用.exec()对于短命令和少量输出非常好。如果你实际上不需要 shell,你甚至可以使用.execFile()更好,它直接运行所需的命令,而不是首先创建一个 shell,然后在其中运行命令。有关更多信息,请参阅nodejs.org/api/child_process.html#child_process_child_process_execfile_file_args_options_callback

使用 spawn()运行命令,并与其通信

使用.exec()很简单,但你只能处理小型输出,并且也无法得到部分答案:让我们更多地了解这一点。想象一下,你正在准备一个大文件要发送给客户端。如果你要用.exec()读取该文件,你将无法在读取完整个文件之前开始将文件内容发送给客户端。然而,如果文件太大,这不仅意味着延迟,还可能导致崩溃。使用.spawn()给你一个有趣的补充:使用流进行双向通信,与生成的进程。

如何做到这一点...

使用.spawn()在一般情况下类似于.exec()。现在让我们使用一个单独的进程来读取一个目录并将其结果发送回来。我们将使用流传递要处理的路径,并且我们也将通过流获得找到的文件列表。

首先,让我们有主要的代码,它将生成一个进程:

// Source file: src/process_spawn.js

const path = require("path");
const { spawn } = require("child_process");

const child = spawn("node", [path.resolve("out/process_spawn_dir.js")]);

child.stdin.write("/home/fkereki");

child.stdout.on("data", data => {
    console.log(String(data));
});

child.stdout.on("end", () => {
    child.kill();
});

最后,我们需要子进程,它将如下所示:

// Source file: src/process_spawn.js

const fs = require("fs");

process.stdin.resume();

process.stdin.on("data", path => {
    // Received a path to process
    fs
        .readdirSync(path)
        .sort((a, b) => a.localeCompare(b, [], { sensitivity: "base" }))
        .filter(file => !file.startsWith("."))
        .forEach(file => process.stdout.write(file + "\n"));

 process.stdout.end();
});

它是如何工作的...

生成的进程展示了另一种事件的情况。进程保持在那里等待,每当进程通过stdin输入接收到任何数据时,就会触发"data"事件,就像通过child.stdin.write("/home/fkereki")行所做的那样。然后,进程使用fs.readdirSync()读取目录,这是一个同步调用,在正常的Node代码中不应该使用,但在子进程中是安全的,因为它不会阻塞任何东西。调用的结果被排序,过滤以避免隐藏文件,然后将行写入stdout

与子进程类似,父进程监听来自子进程stdout的事件。每当数据到达时(触发"data"事件),它会简单地使用console.log()记录。当子进程发出信号表示不会再有数据到来时,通过执行process.stdout.end(),将触发"end"事件,父进程会识别它,并可以做任何想做的事情。

这种生成进程的方式允许父进程和子进程之间进行双向通信,可以用于许多不同的形式。

使用 fork()来运行 Node 命令

Child_process.fork()方法是.spawn()的一个特殊情况,它专门只生成新的Node进程。生成的子进程内置了一个通信通道,使得在父进程和子进程之间传递消息变得更简单:你只需使用.send()方法发送消息,并在另一侧监听"message"事件。让我们看看如何分叉出第二个进程,并与第一个进程进行通信。

如何做...

由于上一节的代码使用.spawn()来启动一个新的Node实例并运行一些代码,很明显我们可以很快简单地调整它,改用.fork()。此外,我们不必使用stdinstdout进行通信,而是选择使用消息传递。

首先,让我们从父代码开始。它将变成以下内容;关键的差异是使用.fork()而不是.spawn(),以及发送文件路径给子进程的方式:

// Source file: src/process_fork.js

const path = require("path");
const { fork } = require("child_process");

const child = fork(path.resolve("out/process_fork_dir.js"));

child.send({ path: "/home/fkereki" });

child.on("message", data => {
    console.log(String(data));
});

然后,子代码也会显示一些小的变化,接收消息的方式以及向父进程发送数据的方式:

// Source file: src/process_fork_dir.js

const fs = require("fs");

process.on("message", obj => {
    // Received a path to process
    fs
        .readdirSync(obj.path)
        .sort((a, b) => a.localeCompare(b, [], { sensitivity: "base" }))
        .filter(file => !file.startsWith("."))
        .forEach(file => process.send && process.send(file));
});

它是如何工作的...

使用.fork()意味着子进程是一个Node进程,所以我们不需要明确地提到它,就像我们在上一节中所做的那样,我们只需要传递要执行的 JS 文件的名称。

第二个差异,正如我们所提到的,是我们不再使用stdinstdout进行通信,而是可以.send()一个消息(从父到子或反之亦然),并且我们监听"message"事件而不是"data"事件。

如果你分析代码中突出显示的差异,你会意识到这些差异实际上是非常小的,对于需要运行一个单独的Node进程的特殊情况(但并不罕见),.fork()更合适,可能也更简单一些。

第四章:使用 Node 实现 RESTful 服务

我们将涵盖以下配方:

  • 使用 Express 开发服务器

  • 添加中间件

  • 获取请求参数

  • 提供静态文件

  • 添加路由

  • 实现安全连接

  • 使用 Helmet 添加安全保障

  • 实现 CORS

  • 使用 JWT 添加身份验证

  • 将所有内容绑在一起-构建 REST 服务器

介绍

在上一章中,我们看了一系列重要的基本 Node 技术。在本章中,我们将使用它们来建立一个基本的Express服务器,并在此基础上进行扩展,直到我们能够生成一个适用于面向服务的架构SOA)设置的 RESTful 服务器。

使用 Express 开发服务器

虽然你可以使用普通的Node并做任何事情,但今天Express无疑是最常用的Node框架,它允许您通过提供大量基本功能来轻松开发服务器。首先,让我们安装它并检查它是否工作,然后继续构建服务等。

在这个配方中,我们将首先进行Express的基本安装,以便我们可以在后面的章节中进行更高级的工作。

您可以在expressjs.com/了解更多关于Express的信息。

如何做...

让我们安装Express并确保它可以工作。安装基本上很简单,因为它只是另一个npm包,所以你只需要一个简单的命令:

npm install express --save

您可以在npm命令中添加一个--verbose可选参数,以获得更详细的输出并能够看到发生的事情。

接下来,让我们重新做一下上一章的基本测试服务器,但使用Express。是的,对于这样一个简单的功能来说,这实在是过度了,但我们只是想检查我们是否以正确的方式设置了一切!参考以下代码:

// Source file: src/hello_world.js

/* @flow */
"use strict";

const express = require("express");

const app = express();

app.get("/", (req, res) => res.send("Server alive, with Express!"));
app.listen(8080, () =>
    console.log(
        "Mini server (with Express) ready at http://localhost:8080/!"
    )
);

它是如何工作的...

运行此服务器与我们非常基本的Node服务器几乎相同:

> npm run build
> node out/hello_world.js
Mini server (with Express) ready at http://localhost:8080/!

我们可以进行与之前相同的测试,并注意以下内容:

  • 访问/地址会返回一个服务器活着的消息

  • 其他路径会产生 404(未找到)错误:

> curl 127.0.0.1:8080
Server alive, with Express!

尝试访问其他路径(或/,但不是使用GET)将返回一个404错误和一个 HTML 错误屏幕:

基本的 Express 配置显示了 404(未找到)错误的错误屏幕

关键的一行是app.get("/", (req, res) => ...)调用。基本上,在创建了应用程序对象(app)之后,您可以指定一个路由(在本例中为/),一个 HTTP 方法(例如.get().post().put().delete()),以及更多内容。

转到expressjs.com/en/4x/api.html#app.METHOD了解更多可用方法。

您还可以使用.all()作为每种可能方法的捕获,以及当用户点击特定路径时将被调用的函数。在我们的情况下,无论请求(req)是什么,响应(res)都是恒定的,但显然您希望为实际服务做更多事情!

毋庸置疑,您肯定会有多个路由,可能不仅处理GET方法。您肯定可以添加许多更多的路由和方法,我们将在接下来的章节中介绍更高级的路由。

另一条有趣的线是app.listen(),它指定要监听的端口,以及在服务器启动时将执行的函数;在我们的情况下,它只是一个日志消息。

现在我们已经成功运行了服务器,让我们实现一些其他常见的服务器功能。

添加中间件

Express的所有功能都基于一个关键概念:中间件。如果你使用纯粹的 Node,你必须编写一个单一的大型请求处理程序来处理服务器可能收到的所有请求。通过使用中间件,Express让你将这个过程分解成更小的部分,以一种更加功能化、管道式的方式。如果你需要检查安全性、记录请求、处理路由等等,所有这些都将由适当放置的中间件函数来完成。

首先,让我们了解ExpressNode的区别,看看我们如何添加一些基本的自定义中间件,然后再应用常见需求的通常函数。更多信息请参考以下图表:

标准处理,在没有 Express 中间件处理的情况下 - 你的代码必须处理所有的处理

在标准处理中(参见前面的图表),Node从互联网客户端获取请求,将它们传递给你的代码进行处理,获取生成的响应,并将其传递给原始客户端。你的代码必须处理所有的东西,基本上相当于一个非常大的函数,处理安全性、加密、路由、错误等等。如果你将Express加入其中,这个过程会有所改变:

当 Express 被添加时,它通过将请求传递给中间件堆栈来处理响应

在这种情况下,你设置了一系列函数,Express会按顺序调用它们,每个函数都处理整个过程的一个特定方面,简化了整体逻辑。此外,你不必直接处理常见问题(比如,CORS 或压缩等),因为有很多Express包已经提供了这样的功能;你只需要将它们添加到适当的位置。

为了更好地了解这是如何工作的,在这个示例中,让我们开发一个非常基本的请求日志记录器(我们将在第五章的使用 Morgan 添加 HTTP 日志记录部分中深入学习),以及一个错误报告器。

准备就绪

如果你想添加一些中间件,你必须将它放在你定义的所有函数中的正确顺序中。例如,如果你想要记录一些东西,你可能希望在任何处理之前就这样做,所以你会在堆栈的顶部或者非常靠近顶部添加这个定义。

中间件函数接收三个参数:传入的 HTTP 请求(我们一直称之为req),传出的 HTTP 响应(res),以及当你希望继续处理下一个中间件时必须调用的函数(next())。当你的中间件被调用时,它必须要么发送一个响应(使用res.send()res.sendFile()res.end()),要么调用next(),以便堆栈中的下一个函数有机会产生答案。

错误函数有些不同,它们在我们刚刚列出的三个参数中添加了一个错误(err)参数;有四个参数的函数在Express眼中标志着它是一个错误处理器。如果一切正常,Express会跳过错误中间件,但如果发生错误,Express将跳过每个函数,直到它找到第一个可用的错误函数。

让我们跳到最后,查看我们完整的中间件示例,如下所示;我们将在下一节中解释它是如何工作的:

// Source file: src/middleware.js

/* @flow */
"use strict";
const express = require("express");
const app = express();

app.use((req, res, next) => {
    console.log("Logger... ", new Date(), req.method, req.path);
    next();
});

app.use((req, res, next) => {
    if (req.method !== "DELETE") {
        res.send("Server alive, with Express!");
    } else {
        next(new Error("DELETEs are not accepted!"));
    }
});

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () =>
    console.log(
        "Mini server (with Express) ready at http://localhost:8080/!"
    )
);

如何做...

让我们从我们的日志记录器开始。我们希望它适用于每个路径,这样我们就可以省略路径。另一种方法是编写app.use("*", ...),它的意思完全相同;我们也将使用它作为示例。您的逻辑可以做任何事情,由于我们想要记录请求,我们可以列出当前时间戳、request方法和请求的路径。之后——这是最重要的事情——由于我们还没有处理请求,调用next()是强制的,否则请求将陷入处理的困境,永远不会向客户端发送任何内容:

app.use((req, res, next) => {
    console.log("Logger... ", new Date(), req.method, req.path);
    next();
});

由于我们希望有一些错误,让我们定义DELETE方法不被接受,因此将调用next(),但传递一个错误对象;其他请求将只得到一个简单的文本答复。然后,我们的主要请求处理代码可能如下所示:

app.use((req, res, next) => {
 if (req.method === "DELETE") {
 next(new Error("DELETEs are not accepted!"));
    } else {
        res.send("Server alive, with Express!");
    }
});

最后,我们的错误处理代码将记录错误,并返回500状态:

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

您会注意到需要禁用no-unused-varsESLint 规则。仅通过函数签名识别错误并不是一个很好的做法,如果您将错误处理程序设置在堆栈的末尾,以便没有其他函数可以调用,那么下一个参数将是未使用的,并且会导致错误。有一些关于在即将推出的Express版本中解决这种情况的讨论,但目前这一点并不重要。

我们刚刚展示的错误代码,尽管很基本,但实际上可以在您编写的几乎每个 Node 服务器中使用。我们将在我们的示例中使用它。

它是如何工作的...

我们已经设置好了一切;现在,让我们看看我们的代码是如何工作的:

> npm run build
> node out/middleware.js

我们可以使用一些 curl 请求来测试这个;让我们使用GETPOSTDELETE

> curl "http://127.0.0.1:8080/some/path/to/get?value=9" 
Server alive, with Express!
> curl -X POST "http://127.0.0.1:8080/a/post/to/a/path" 
Server alive, with Express!
> curl -X DELETE "http://127.0.0.1:8080/try/to/delete?key=22" 
INTERNAL SERVER ERROR

记录的输出将如下所示:

Logger... 2018-05-08T00:22:20.192Z GET /some/path/to/get
Logger... 2018-05-08T00:22:44.282Z POST /a/post/to/a/path
Logger... 2018-05-08T00:23:01.888Z DELETE /try/to/delete
Error.... DELETEs are not accepted!

现在,我们知道如何编写自己的中间件,但恰好Express提供了许多现成的函数。让我们试试它们,看看我们如何在几个常见需求中使用它们。

获取请求参数

让我们解决一个基本问题:如何获取请求参数?在我们之前的示例中,在上一章的使用流处理请求部分中,我们手动处理了请求流以获取主体,并使用解析函数提取参数。但是,Express已经提供了一些中间件,您可以在堆栈中的任何其他函数之前使用,这些函数需要来自主体或 URL 本身的参数。因此,在本教程中,让我们看看如何访问请求参数,这是一个非常基本的需求。

如何做...

让我们看看如何访问参数。首先,您必须要求body-parser模块,并要求您想要的选项;我们将在下一节中详细介绍:

// Source file: src/get_parameters.js

const bodyParser = require("body-parser");
app.use(bodyParser.urlencoded({ extended: false }));

由于您希望在任何处理之前解析参数,因此app.use()行将位于堆栈的顶部。

现在,更详细地了解一下,body-parser模块提供了四个解析器:

  • URL 编码的 body 解析器,就像我们在这里使用的一样,阅读有关使用extended true 或 false 的差异。查看github.com/expressjs/body-parser获取更多信息。

  • JSON body 解析器,如bodyParser.json(),用于处理Content-Typeapplication/json的请求。

  • 原始 body 解析器,如bodyParser.raw(),默认情况下用于处理application/octet-stream内容,尽管可以通过提供type选项进行更改。

  • 文本 body 解析器,如bodyParser.text(),用于处理text/plain内容。

后三个解析器可能提供额外的选项;查看文档以获取更多信息。但是,请注意,如果您必须处理多部分主体,那么您不能依赖于body-parser;请参阅github.com/expressjs/body-parser获取一些替代方案,并查看适合您的内容。

它是如何工作的...

我们只需要添加几行代码,一切就设置好了。我们可以通过更改前一节中的记录器,或者编写如下代码来看到我们的代码工作:

// Source file: src/get_parameters.js

app.use("*", (req, res) => {
    console.log(req.query, req.body);
    res.send("Server alive, with Express!");
});

URL 参数会被Express自动分离为req.queryreq.body将被bodyParser解析。我们可以尝试一些服务调用,GETPOST,以涵盖所有情况:

> curl "http://127.0.0.1:8080/birthdays?day=22&month=9&year=1960" 
> curl -X POST --data "name=FK" "http://127.0.0.1:8080/persons" 

输出将如下所示:

> node out/get_parameters.js
Mini server (with Express) ready at http://localhost:8080/!
{ day: '22', month: '9', year: '1960' } {}
{} { name: 'FK' }

在第一种情况(GET)中,我们可以看到req.query是一个带有三个查询参数的对象,而在第二种情况(POST)中,没有查询参数,但req.body提供了我们提供的单个参数(name)。

这应该让您相信 Express 的设计的优点,基于中间件堆栈,但让我们通过一些更多的例子来看一下,比如处理静态文件、路由、安全性等等。

提供静态文件

我们计划创建一组 REST 服务,但您的服务器很可能还需要提供一些静态文件,比如图片、PDF 等等。原则上,您可以通过手动设置特定路由来处理每个静态资源,然后编写一个函数来读取所需的文件并将其内容流式传输到客户端;我们在前一章的使用流处理请求部分做了类似的事情。

然而,这是一个如此常见和重复的任务,Express提供了一个更简单的解决方案;让我们看看如何简单地提供静态文件。

更好的解决方案是在您的堆栈中添加另一个服务器,比如 nginx,并让它处理静态文件。标准服务器更擅长处理这种类型的简单请求,并且会让您的 Node 代码空闲下来处理更复杂、更苛刻的任务。

如何做...

假设我们想为一个应用程序提供一些国旗图标。我做了以下操作:

  1. 我在与输出文件所在的/out目录同级别创建了一个/flags目录,并在其中创建了一些子目录:/flags/america/north/flags/america/south/flags/europe

  2. 我在这些目录中放了一些来自GoSquared的免费国旗图标,取自www.gosquared.com/resources/flag-icons/。为了多样化,这些国旗可以在/static路径下访问,实际上并不存在。

  3. 我写了以下代码;这是之前的基本服务器,只是添加了一些代码(用粗体字显示)来处理静态文件:

// Source file: src/serve_statics.js

/* @flow */
"use strict";

const express = require("express");
const path = require("path");
const app = express();

app.get("/", (req, res) => res.send("Server alive, with Express!"));

app.use(
 "/static",
 express.static(path.join(__dirname, "../flags"), {
 immutable: true,
 maxAge: "30 days"
 })
);

app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () =>
    console.log(
        "Mini Express static server ready at http://localhost:8080/!"
    )
);

如果您想阅读更多关于提供静态文件的信息,请查看 Node 的文档expressjs.com/en/starter/static-files.html

在这种情况下,app.use()得到了一个特殊的函数express.static(),它负责发送给定路径中的文件,并带有一些缓存标头;让我们深入了解一下:

  • app.use()的第一个参数是用户将选择的路径的基础;请注意,它不需要存在于实际目录中,就像我们之前看到的其他示例一样。顺便说一句,如果我们想接受所有 HTTP 方法,我们可以写app.use()

  • express.static()的第一个参数指定了文件所在的路径。我使用path.join()函数来找到实际路径:/flags/out在同一级别。

  • express.static()的第二个参数允许您添加选项;在我们的例子中,我发送了一些缓存标头,以便浏览器知道该文件可以安全地缓存 30 天。

maxAge参数的格式可以是ms包(github.com/zeit/ms)理解的格式,它能够将日期和时间字符串转换为相应的毫秒数,这是 JS 的标准。

它是如何工作的...

每当用户指定以/static开头的路径时,它会被转换成等效的以/flags开头的路径,如果找到文件,它将被发送回来,并包括缓存头。查看以下截图,以了解此示例:

一个静态的标志图标,从一个不存在的路径/static 提供,映射到一个实际的路径

还有更多...

如果您想出于某种特定原因发送静态文件,而不使用前一节中显示的方法,您可以使用路由和res.sendFile()方法,如下面的代码所示:

// Source file: src/serve_statics_alt.js

/* @flow */
"use strict";

const express = require("express");
const app = express();
const path = require("path");

const flagsPath = path.join(__dirname, "../flags");

app.get("/uruguay", (req, res) =>
 res.sendFile(`${flagsPath}/america/south/UY.png`)
);

app.get("/england", (req, res) =>
 res.sendFile(`${flagsPath}/europe/GB.png`)
);

app.get("/license", (req, res) =>
 res.sendFile(`${flagsPath}/license.txt`)
);

app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () =>
    console.log(
        "Mini Express static server ready at http://localhost:8080/!"
    )
);

如果您访问127.0.0.1:8080/uruguay,您将得到我的祖国的国旗,而127.0.0.1:8080/license将检索我选择的图标集的 MIT 许可证;请参见以下截图:

测试发送文本文件的不同路由

当然,如果您有很多静态文件需要提供,您不会使用这种方法,但如果您只有一些文件,那么这种替代解决方案非常有效。

您可能已经注意到,我没有为缓存添加头信息,但这当然是可以做的。在expressjs.com/en/api.html#res.sendFile上阅读更多关于res.sendFile()的信息,特别是immutableheaders选项。

添加路由

无论您构建什么样的服务器(我们计划构建一个 RESTful 服务器,或者其他任何类型的服务器),您都必须处理路由,NodeExpress提供了简单的方法来做到这一点。

回到我们在上一章与数据库一起工作中的数据库,以 RESTful 的方式,我们应该提供以下路由,允许给定的方法:

  • /countriesGET获取所有国家的列表,POST创建一个新的国家)

  • /countries/someCountryIdGET访问一个国家,PUT更新一个国家,DELETE删除一个国家)

  • /regionsGET获取所有国家的所有地区,POST创建一个新的地区)

  • /regions/someCountryIdGET获取给定国家的所有地区)

  • /regions/someCountryId/someRegionIdGET访问一个地区,PUT更新一个地区,DELETE删除一个地区)

  • /citiesGET获取所有城市 - 但由于结果集的大小,我们实际上不会想要允许这样做!—再加上POST创建一个新的城市)

  • /cities/someCityIdGET访问一个城市,PUT更新一个城市,DELETE删除一个城市)

您还可以允许额外的参数,例如允许对结果集进行分页,或者启用一些过滤,但我们现在关心的是设置路由。您可以在主文件中设置所有必要的路由,就像我们在迄今为止的简短示例中所做的那样,但是随着您开始添加更多的路由,需要一些组织来避免最终得到一个数千行长的主文件。

如何做到...

由于Express的帮助,我们不需要太多的代码,只需要两行新代码来启用我们的路由;查看以下代码:

// Source file: src/routing.js

/* @flow */
"use strict";

const express = require("express");
const app = express();

const myRouter = require("./router_home.js");
app.use("/", myRouter);

// *eslint-disable-next-line no-unused-vars*
app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () =>
    console.log("Routing ready at http://localhost:8080")
);

router_home.js模块可以具有路由分支的第一级,如下面的代码所示:

// Source file: src/router_home.js

/* @flow */
"use strict";

const express = require("express");
const routerHome = express.Router();

const routerCountries = require("./router_countries.js");
const routerRegions = require("./router_regions.js");
const routerCities = require("./router_cities.js");

routerHome.use("/countries", routerCountries);
routerHome.use("/regions", routerRegions);
routerHome.use("/cities", routerCities);

module.exports = routerHome;

而且,再往下一级,我们将有三个指定下一级的文件。例如,国家的路由将如下所示。您会注意到一个奇怪的额外路由/URUGUAY,我只是为了向您展示我们可以有比 RESTful 服务器需要的更多路由!

// Source file: src/router_countries.js

/* @flow */
"use strict";

const express = require("express");
const routerCountries = express.Router();

routerCountries.get("/", (req, res) => {
    res.send(`All countries... path=${req.originalUrl}`);
});

routerCountries.get("/URUGUAY", (req, res) => {
 res.send(`GET UY (Uruguay)... path=${req.originalUrl}`);
});

routerCountries.get("/:country", (req, res) => {
    res.send(`GET Single country... ${req.params.country}`);
});

module.exports = routerCountries;

地区路由文件将如下所示,并且我们将跳过城市路由,因为它与国家路由非常相似:

// Source file: src/router_regions.src

/* @flow */
"use strict";

const express = require("express");
const routerRegions = express.Router();

routerRegions.get("/", (req, res) => {
    res.send(`Region GET ALL... `);
});

routerRegions.get("/:country", (req, res) => {
    res.send(`Region GET ALL FOR Country=${req.params.country}`);
});

routerRegions.get("/:country/:id", (req, res) => {
    res.send(`Region GET ${req.params.country}/${req.params.id}`);
});

routerRegions.delete("/:country/:id", (req, res) => {
    res.send(`Region DELETE... ${req.params.country}/${req.params.id}`);
});

routerRegions.post("/", (req, res) => {
    res.send(`Region POST... `);
});

routerRegions.put("/:country/:id", (req, res) => {
    res.send(`Region PUT... ${req.params.country}/${req.params.id}`);
});

module.exports = routerRegions;

您可以在expressjs.com/en/starter/basic-routing.htmlexpressjs.com/en/guide/routing.html上阅读更多关于Express路由的信息。

它是如何工作的...

当然,我们所谓的RESTful 服务器,至少目前来说,是一个彻头彻尾的笑话,因为它只返回固定的答案,什么都不做,但关键部分基本上都在这里。首先,让我们分析它的结构。当你写app.use(somePath, aRouter)时,这意味着所有以给定路径开头的路由都将由提供的路由接管,该路由将负责从给定路径开始的路由。首先,我们写一个从/开始的基本路由,然后按路径(/countries/regions/cities)拆分路由,为每个路径编写一个路由。这些后续的路由将深入到路径中,直到所有的路由都被映射出来。

要搞清楚:当服务器收到一个请求,比如/regions/uy,请求首先由我们的主路由(在routing.js)处理,然后传递给主页路由(router_home.js),再传递给最终路由(router_regions.js),最终在那里处理请求。

现在,让我们来看看路由本身。这里有两种路由:常量路由,比如/countries,和变量路由,比如/regions/uy/4,其中包括一些变化的项目,比如在这种情况下的uy4。当你写一个路由,比如/regions/:country/:id,Express 会提取出变化的部分(这里是:country:id),并将它们作为req.params对象的属性(req.params.countryreq.params.id)提供,这样你就可以在逻辑中使用它们。

你也可以使用正则表达式来定义路径,但要记住这个笑话:一个程序员有一个问题;一个程序员决定使用正则表达式来解决它;一个程序员现在有两个问题。

因此,如果我们在前面的路径上实现一些请求,我们将看到功能路由;我们缺少的只是实际的 RESTful 代码来产生一些有用的结果,但我们将在本章后面讨论到这一点:

> curl "http://127.0.0.1:8080/regions" 
Region GET ALL..

> curl "http://127.0.0.1:8080/regions/uy" 
Region GET ALL FOR Country=uy

> curl -X POST "http://127.0.0.1:8080/regions" 
Region POST... 

> curl -X PUT "http://127.0.0.1:8080/regions/uy/4" 
Region PUT... uy/4

当然,尝试一些不允许的方法会产生错误;尝试对/regions进行DELETE请求,你就会明白我的意思。我们现在知道如何进行任何类型的路由,但我们仍然必须能够接收 JSON 对象,如果需要的话允许 CORS,以及其他一些考虑,所以让我们继续努力,首先通过 HTTPS 启用安全连接。

实施安全连接

通过 HTTPS 发送数据而不是 HTTP 是一个很好的安全实践,如果你的服务器必须通过网络发送敏感的、安全的数据,那么这实际上是强制性的。通过与客户端浏览器建立加密连接,可以避免许多种类型的攻击,所以让我们看看如何使用NodeExpress实现安全连接。

在这个示例中,我们将介绍如何启用 HTTPS,以使我们的服务器更加安全。

如何做到这一点...

我们想要启用 HTTPS 连接,所以我们需要做一些工作来安装我们需要的一切。

在这个安装过程中的第一步将是获得一个适当验证你拥有的网站的证书。购买它超出了本书的范围,所以让我们通过生成我们自己的自签名证书来解决这个问题——当然,这并不真正安全,但会让我们完成所有必要的配置!

假设我们想设置我们的www.modernjsbook.com网站。在 Linux 中工作,你可以通过执行以下命令并回答一些问题来创建必要的证书文件:

openssl req -newkey rsa:4096 -nodes -keyout modernjsbook.key -out modernjsbook.csr openssl x509 -signkey modernjsbook.key -in modernjsbook.csr -req -days 366 -out modernjsbook.crt

做完这些之后,你将得到三个文件:一个证书签名请求CSR),一个 KEY(私钥),和一个自签名证书(CRT)文件,如下;在现实生活中,一个证书颁发机构CA)将是实际的签发者:

> dir
-rw-r--r-- 1 fkereki users 1801 May 14 22:32 modernjsbook.crt
-rw-r--r-- 1 fkereki users 1651 May 14 22:31 modernjsbook.csr
-rw------- 1 fkereki users 3272 May 14 22:31 modernjsbook.key

现在,当您设置服务器时,您必须读取这些文件(这些文件应该存放在一个安全的只读目录中,以增加安全性),并将它们的内容作为选项传递。我们将使用fs模块来做到这一点,就像之前的例子一样,由于只有在服务器加载时才会读取文件,因此可以使用fs.readFileSync()。看一下以下代码:

// Source file: src/https_server.js

/* @flow */
"use strict";

const express = require("express");
const app = express();
const https = require("https");

const fs = require("fs");
const path = require("path");

const keysPath = path.join(__dirname, "../../certificates");

const ca = fs.readFileSync(`${keysPath}/modernjsbook.csr`);
const cert = fs.readFileSync(`${keysPath}/modernjsbook.crt`);
const key = fs.readFileSync(`${keysPath}/modernjsbook.key`);

https.createServer({ ca, cert, key }, app).listen(8443);

为什么使用端口8443?原因与安全有关,我们在上一章的检查 Node 设置部分看到了原因;这与我们使用端口8080而不是端口80的原因相同。

它是如何工作的...

运行上述代码就足以使您的服务器获得加密连接。(当然,如果您使用自签名证书,最终用户将收到有关实际安全性缺失的警告,但您会获得有效的证书,不是吗?)我们可以在以下截图中看到这一结果——请记住,使用真正的证书,用户将不会收到有关您不安全网站的警报!

安装证书并使用 HTTPS 而不是 HTTP 会生成一个安全的服务器。

当然,由于我们自己制作了证书,Google Chrome 并不真的喜欢这个网站!

我们还可以通过运行第二个服务器来强制 HTTP 用户使用 HTTPS,这次使用 HTTP,并将所有流量重定向到我们的第一个安全服务器:

// Source file: src/http_server.js

/* @flow */
"use strict";

const express = require("express");
const app = express();
const http = require("http");

http.createServer(app).listen(8080);

app.use((req, res, next) => {
    if (req.secure) {
        next();
    } else {
 res.redirect(
 `https://${req.headers.host.replace(/8080/, "8443")}${req.url}`
 );
    }
});

Node服务器只能监听一个端口,因此您需要将此服务器作为单独的实例运行。现在,如果您尝试使用 HTTP 访问服务器,将会自动重定向,这是一个很好的做法!

添加安全连接很简单;让我们继续处理更多安全方面的工作。

使用 Helmet 添加安全保障

Express是一个非常好的工具,可以构建您的 RESTful 服务器,或者提供任何其他类型的服务。然而,除非您采取一些额外的预防措施,Express 并不适用所有的安全最佳实践,这可能会使您的服务器失败。无论如何,还没有丧失一切,因为有一些软件包可以帮助您进行这些实践,Helmet(在helmetjs.github.io/)是其中最好的之一。

不要认为Helmet——或者其他类似的软件包——是一种可以解决您可能出现的当前和未来安全问题的魔法解药!将其视为朝着正确方向迈出的一步,但您必须时刻关注可能的威胁和安全漏洞,并不要相信任何单一软件包可以管理一切。

如何做...

鉴于它与Express兼容,Helmet也是一个中间件。它的安装和设置相当容易,幸运的是。使用npm来处理第一部分:

npm install helmet --save

Helmet发挥作用只是在中间件堆栈的顶部添加它而已:

const helmet = require("helmet");
app.use(helmet());

你已经准备好了!默认情况下,Helmet启用以下安全措施列表,所有这些措施都意味着向请求的响应中添加、更改或删除特定的标头。有关特定标头或选项的更多文档,请查看helmetjs.github.io/docs/

模块 效果
dnsPrefetchControl X-DNS-Prefetch-Control标头设置为禁用浏览器预取(在用户甚至点击链接之前完成的请求),以防止用户的隐私问题,他们可能看起来正在访问实际上并没有访问的页面(helmetjs.github.io/docs/dns-prefetch-control)。
frameguard X-Frame-Options标头设置为防止页面显示在 iframe 中,从而避免一些可能导致您不知不觉点击隐藏链接的点击劫持攻击(helmetjs.github.io/docs/frameguard/)。
hidePoweredBy  移除X-Powered-By标头(如果存在),以便潜在的攻击者不会知道服务器的技术,使得针对漏洞的攻击和利用变得更加困难 (helmetjs.github.io/docs/hide-powered-by)
hsts  设置Strict-Transport-Security标头,以便浏览器继续使用 HTTPS 而不是切换到不安全的 HTTP。 (helmetjs.github.io/docs/hsts/)
ieNoOpen  设置X-Download-Options标头,以防止旧版本的 Internet Explorer 在您的页面中下载不受信任的 HTML (helmetjs.github.io/docs/ienoopen)。
noSniff 设置X-Content-Type-Options标头,以防止浏览器尝试嗅探(猜测)下载文件的 MIME 类型,以禁用一些攻击 (helmetjs.github.io/docs/dont-sniff-mimetype)。
xssFilter 设置X-XSS-Protection标头,以禁用某些形式的跨站脚本XSS)攻击,您可能会在点击链接时无意中在页面上运行 JS 代码 (helmetjs.github.io/docs/xss-filter)。

您还可以选择启用一些额外的选项,如果它们符合您的要求。有关如何执行此操作的说明,请查看 Helmet 的文档helmetjs.github.io/docs/:该软件包现在已更新到 3.12.0 版本,经常更新,简单的npm install可能不足以启用新功能。请查看以下表格:

模块 效果
contentSecurityPolicy 允许您配置Content-Security-Policy标头,以指定允许在您的页面上的内容和它们可以从哪里下载 (helmetjs.github.io/docs/xss-filter)。
expectCt 允许您设置Expect-CT标头,要求证书透明度CT),以检测可能无效的证书或授权机构 (helmetjs.github.io/docs/expect-ct/)。
hpkp 允许您配置Public-Key-Pins标头,以防止一些可能的中间人攻击,通过检测可能受损的证书 (helmetjs.github.io/docs/hpkp/)。
noCache 设置几个标头,以防止用户使用旧的缓存文件版本,尽管有更新版本可用,这些旧版本可能存在漏洞或错误 (helmetjs.github.io/docs/nocache/)。
referrerPolicy 允许您设置Referrer-Policy标头,以便浏览器隐藏请求来源的信息,避免一些可能的隐私问题 (helmetjs.github.io/docs/referrer-policy)。

它是如何工作的...

关于使用Helmet没有更多要说的了。将其添加到中间件堆栈中,并配置要启用或禁用的内容,可能会根据文档中的详细说明提供一些选项,Helmet将简单地验证任何响应中包含的标头是否遵循我们在前一节中列出的安全注意事项。

让我们快速检查一下。如果您运行我们的hello_world.js服务器,那么localhost:8080/的响应将包括以下标头:

Connection: keep-alive
Content-Length: 27
Content-Type: text/html; charset=utf-8
Date: Wed, 16 May 2018 01:57:10 GMT
ETag: W/"1b-bpQ4Q2jOe/d4pXTjItXGP42U4V0"
X-Powered-By: Express

相同的结果,但是运行helmet_world.js,本质上是相同的代码,但添加了Helmet,将显示更多标头,如下面的粗体文本所示:

Connection: keep-alive
Content-Length: 27
Content-Type: text/html; charset=utf-8
Date: Wed, 16 May 2018 01:58:50 GMT
ETag: W/"1b-bpQ4Q2jOe/d4pXTjItXGP42U4V0"
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block

如果您逐个启用一些可选功能,您将获得更多标头,但是差异是明显的:我们成功地添加了一些安全控制,几乎没有编码!

与所有安全措施一样,有必要遵循 Helmet 的功能,以便您可能添加或删除一些新的中间件选项,并保护您的服务器免受新的威胁。

实施 CORS

每当浏览器从服务器请求某些资源时,都会应用一些验证规则。对于这些交互中的许多情况,这些交互只是请求信息,而不会尝试对服务器进行任何形式的更改,因此没有限制,请求总是被允许,如以下情况:

  • CSS 样式通过<link rel="stylesheet">标签进行请求

  • 图像通过<img>标签进行请求

  • JS 代码通过<script>标签进行请求

  • 媒体通过<audio><media>标签进行请求

对于其他类型的请求,同源策略单一源策略SOP)限制了只能发送到相同源的请求(意思是协议,如http://,主机名,如modernjsbook.com,和端口,如:8080),拒绝任何不匹配一个或多个源 URL 元素的其他请求。例如,所有 Ajax 请求都将被拒绝。

但是,如果您愿意接受来自一些或所有服务器的请求,您可以应用跨源资源共享CORS)来启用此类请求。基本上,CORS 定义了一种交互样式,让服务器决定是否允许跨源请求;而不是阻止每个请求(如 SOP 所暗示的那样)或允许所有请求(这是一个巨大的安全漏洞!),可以应用规则来决定是这样还是那样。

如果您想阅读 CORS 的当前规范,请参阅fetch.spec.whatwg.org/上的 Fetch Living Standard 文档,特别是第 3.2 节。关于 CORS 的一篇好文章可以在developer.mozilla.org/en-US/docs/Web/HTTP/CORS找到。

如何做...

让我们开始启用 CORS。基本上,这只是处理一些请求的问题,通过检查它们的头部数据并向浏览器发送一些其他头部数据,以便它知道可以期望什么。这种类型的过程非常容易通过应用中间件来解决,而且已经存在一个用于此目的的包(cors),可以通过以下代码轻松安装:

 npm install cors --save

您可以为所有路由或仅为少数路由启用 CORS。第一种方式只需要两行代码(在下面的代码中用粗体标出),告诉Express在顶部应用中间件,适用于所有请求:

// Source file: src/cors_server.js

/* @flow */
"use strict";

const express = require("express");
const app = express();

const cors = require("cors");
app.use(cors());

app.get("/", (req, res) => res.send("Server alive, with CORS!"));

app.listen(8080, () =>
    console.log("CORS server ready at http://localhost:8080/!")
);

您还可以专门为任何给定的路由启用它。从本章的前面选择一个示例,您可以指定尝试获取城市的 CORS;更改将是最小的:

routerCities.get("/:id", cors(), (req, res) => {
    res.send(`GET City... ${req.params.id}`);
});

最后,一些请求需要进行预检,这意味着浏览器在发送实际请求之前,将发送一个OPTIONS请求来验证原始请求是否可以被接受。要启用此功能,您应该为任何路由启用 CORS,如以下示例中所示,或者在中间件堆栈的开头使用单个app.options('*', cors())行进行通用设置:

routerCities.options("/:id", cors()); 
routerCities.delete("/:id", (req, res) => {
    res.send(`DELETE City... ${req.params.id}`);
});

它是如何工作的...

验证 CORS 是否已启用的最简单方法是使用curl或类似工具模拟来自不同来源的调用。(在接下来的章节中,当我们进行一些测试时,我们将看到更多这方面的内容。)我们可以通过编写一个小的网页来简化这个过程,该网页将进行跨域GET,添加一个虚拟标头以强制 CORS,并检查网络流量。我们的页面非常简单——完全没有花哨的东西!

// Source file: src/cors_request.html

<html>
<head></head>
<body>
    <script type="text/javascript">
        const req = new XMLHttpRequest();
        req.open('GET', 'http://www.corsserver.com:8080/', true);
        req.onreadystatechange = () => {
            if (req.readyState === 4) {
                if (req.status >= 200 && req.status < 400) {
                    console.log(req.responseText)
                } else {
                    console.warn("Problems!")
                }
            }
        };
        req.setRequestHeader("dummy", "value");
        req.send();
    </script>
</body>
</html>

我们将在www.corsserver.com:8080上运行我们的 CORS 服务器(实际上我正在我的机器上黑客攻击/etc/hosts文件,以便服务器实际上在我的机器上),我们将使用 Web Server for Chrome 来加载和运行我们的页面。查看以下截图以查看执行此操作的结果:

执行简单的跨域 GET 显示我们的服务器收到了一个 OPTIONS 请求,然后是 GET 请求

使用 CORS 比其他替代方案更安全,包括老牌的 JSONP(一种跨域获取信息的方法),因此将其添加到服务器应该是强制性的。然而,正如我们所见,只需使用一点 Express 中间件就可以轻松实现。

使用 JWT 添加身份验证

对于任何基于服务器的应用程序,必须解决的一个挑战是身份验证,因此我们的 RESTful 服务器需要一个解决方案。在传统的网页中,可能会使用会话和 cookie,但是如果您使用 API,则无法保证请求来自浏览器;事实上,它们很可能来自另一个服务器。再加上 HTTP 是无状态的,RESTful 服务也应该是如此,我们需要另一种机制,JSON Web TokensJWT)是一个经常使用的解决方案。

JWT 有时被读作JOT;请参阅 RFC 的第一部分www.rfc-editor.org/info/rfc7519

JWT 的想法是,客户端将首先与服务器交换有效凭据(如用户名和密码),然后得到一个令牌,之后将允许他们访问服务器的资源。令牌是使用密码学方法创建的,比通常的密码要长得多,更加晦涩。但是,令牌足够小,可以作为请求体参数或 HTTP 头发送。

将令牌作为查询参数放在 URL 中是一种糟糕的安全实践!而且,考虑到令牌实际上不是请求的一部分,将其放在请求体中也不太合适,因此选择一个头部;推荐的是Authorization: Bearer

获得令牌后,必须在每个 API 调用中提供它,并且服务器在继续之前将对其进行检查。令牌可能包含有关用户的所有信息,以便服务器无需再次查询数据库来重新验证请求。在这方面,令牌就像您在受限建筑物的前台获得的安全通行证;您必须向安全人员证明您的身份,但之后您只需出示通行证(将被识别和接受)就可以在建筑物内移动,而无需一遍又一遍地进行整个身份验证过程。

查看jwt.io/,可以使用在线工具处理 JWT,还有关于令牌的大量信息。

我们不会深入讨论 JWT 的创建、格式等细节;如果您感兴趣,可以阅读文档,因为我们将使用库来处理所有这些细节。(我们可能只需记住令牌包括一个有效载荷,其中包含一些与客户端或令牌本身相关的声明,比如过期或发布日期,并且如果需要可能包含更多信息——但不包括秘密数据,因为令牌可以被读取。)

在这个示例中,让我们创建一个基本服务器,首先能够向有效用户发放 JWT,其次检查特定路由的 JWT 是否存在。

如何操作...

让我们看看如何添加身份验证。为了使用 JWT,我们将使用来自github.com/auth0/node-jsonwebtokenjsonwebtoken。使用以下命令安装它:

npm install jsonwebtoken --save

我们的 JWT 代码示例将比以前的示例更大,并且应该分成多个文件。但是,为了使其更清晰,我避免了这样做。首先,我们需要做一些声明,关键行用粗体标出:

// Source file: src/jwt_server.js

/* @flow */
"use strict";

const express = require("express");
const app = express();
const jwt = require("jsonwebtoken");
const bodyParser = require("body-parser");

const validateUser = require("./validate_user.js");

const SECRET_JWT_KEY = "modernJSbook";

app.use(bodyParser.urlencoded({ extended: false }));

几乎所有内容都是标准的,除了validateUser()函数和SECRET_JWT_KEY字符串。后者将用于签署令牌,并且绝对不应该出现在代码本身中!(如果有人能够入侵源代码,你的秘密将会泄露;相反,将密钥设置为环境变量,并从那里获取值。)

至于函数,检查用户是否存在以及他们的密码是否正确是很简单的,可以通过许多方式实现,比如访问数据库、活动目录、服务等。在这里,我们只使用硬编码版本,只接受一个用户。然后,validate_user.js源代码非常简单:

// Source file: src/validate_user.js

/* @flow */
"use strict";

/*
 *In real life, validateUser could check a database,*
 *look into an Active Directory, call another service,*
 *etc. -- but for this demo, let's keep it quite*
 *simple and only accept a single, hardcoded user.*
*/

const validateUser = (
    userName: string,
    password: string,
    callback: (?string, ?string) => void) => {
    if (!userName || !password) {
        callback("Missing user/password", null);
    } else if (userName === "fkereki" && password === "modernjsbook") {
        callback(null, "fkereki"); // OK, send userName back
    } else {
        callback("Not valid user", null);
    }
};

module.exports = validateUser;

让我们回到我们的服务器。在初始定义之后,我们可以放置不需要令牌的路由。让我们有一个/public路由,还有一个/gettoken路由用于获取稍后的 JWT。在后者中,我们将查看POST是否在其主体中包含userpassword值,并且如果它们是有效用户,通过我们在前面的代码中展示的validateUser()函数。任何问题都意味着将发送401状态,而如果用户正确,将创建一个在一小时后过期的令牌:

// Source file: src/jwt_server.js

app.get("/public", (req, res) => {
    res.send("the /public endpoint needs no token!");
});

app.post("/gettoken", (req, res) => {
    validateUser(req.body.user, req.body.password, (idErr, userid) => {
 if (idErr !== null) {
 res.status(401).send(idErr);
 } else {
 jwt.sign(
 { userid },
 SECRET_JWT_KEY,
 { algorithm: "HS256", expiresIn: "1h" },
 (err, token) => res.status(200).send(token)
 );
 }
    });
});

既然不受保护的路由已经处理完毕,让我们添加一些中间件来验证令牌是否存在。根据 JWT RFC 的预期,我们希望包括一个Authorization: Bearer somejwttoken标头,并且必须被接受。如果没有这样的标头,或者它的格式不正确,将发送 401 状态。如果令牌存在,但已过期或有任何其他问题,将发送 403 状态。最后,如果没有问题,userid字段将从有效载荷中提取,并附加到请求对象,以便将来的代码能够使用它:

// Source file: src/jwt_server.js

app.use((req, res, next) => {
    // First check for the Authorization header
    const authHeader = req.headers.authorization;
 if (!authHeader || !authHeader.startsWith("Bearer ")) {
        return res.status(401).send("No token specified");
    }

    // Now validate the token itself
    const token = authHeader.split(" ")[1];
    jwt.verify(token, SECRET_JWT_KEY, (err, decoded) => {
        if (err) {
            // Token bad formed, or expired, or other problem
            return res.status(403).send("Token expired or not valid");
        } else {
            // Token OK; get the user id from it
            req.userid = decoded.userid;
            // Keep processing the request
            next();
        }
    });
});

现在,让我们添加一些受保护的路由(实际上,只有一个,/private,仅供本例使用),然后进行错误检查并设置整个服务器:

// Source file: src/jwt_server.js app.get("/private", (req, res) => {
 res.send("the /private endpoint needs JWT, but it was provided: OK!");
});

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () =>
    console.log("Mini JWT server ready, at http://localhost:8080/!")
);

我们完成了!让我们看看这一切是如何结合在一起的。

它是如何工作的...

我们可以先测试/public/private路由,不需要任何令牌。前者不会引起任何问题,但后者将被我们的令牌测试代码捕捉并拒绝:

> curl "http://localhost:8080/public" 
the /public endpoint needs no token!

> curl "http://localhost:8080/private" 
No token specified

现在,让我们尝试获取一个令牌。查看以下代码:

> curl http://localhost:8080/gettoken -X POST -d "user=fkereki&password=modernjsbook" 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI2ODM5MDEwLCJleHAiOjE1MjY4NDI2MTB9.cTwpL-x7kszn7C9OUXhHlkTGhb8Aa7oOGwNf_nhALCs

另一种测试方法是转到jwt.io/并创建一个 JWT,在有效载荷中包括userid:"fkereki",并使用modernJSbook作为密钥。不过,你需要自己计算到期日期(exp)。

jwt.io上检查令牌显示以下有效载荷:

{
  "userid": "fkereki",
  "iat": 1526839010,
  "exp": 1526842610
}

iat属性显示 JWT 是在 2018 年 5 月 20 日下午 2:00 左右发布的,exp属性显示令牌设置为一个小时(3,600 秒)后过期。如果我们现在重复对/private的 curl 请求,但添加适当的标头,它将被接受。但是,如果你等待(至少一个小时!),结果将不同;JWT 检查中间件将检测到过期的令牌,并产生 403 错误:

> curl "http://localhost:8080/private" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI2ODM5MDEwLCJleHAiOjE1MjY4NDI2MTB9.cTwpL-x7kszn7C9OUXhHlkTGhb8Aa7oOGwNf_nhALCs"
the /private endpoint needs JWT, but it was provided: OK!

有了这段代码,我们现在有了一种在我们的 RESTful 服务器中添加身份验证的方法。如果你愿意,你可以进一步添加特定的授权规则,以便一些用户可以访问一些功能,而其他用户将受到限制。现在,让我们尝试将一切整合在一起,并构建一个小型的 REST 服务。

将所有内容绑在一起-构建一个 REST 服务器

在这个配方中,让我们至少写一个完整的 RESTful 服务器的部分,用于我们在上一章的“使用数据库”部分开始使用的世界数据库,根据我们在本章前面的“添加路由”部分看到的路由方案。我们将专注于处理地区,但仅仅是为了简洁起见;国家和城市在编码方面非常相似,完整的代码已经提供在本书中。

我们的 REST 服务将发送 JSON 答案并需要令牌进行授权。我们将启用 CORS,以便我们可以从不同的网页访问它们。我们将处理的路由如下:

  • GET /regions将提供所有国家的所有地区

  • GET /regions/:country将返回给定国家的所有地区

  • GET /regions/:country/:region将返回一个单独的地区

  • DELETE /regions/:country/:region将允许我们删除给定的地区

  • POST /regions/:country将允许我们创建一个新的地区

  • PUT /regions/:country/:region将允许我们创建或更新给定的地区

处理国家和城市是非常相似的,只有一两个例外:

  • 由于结果集的大小,我们不会接受GET /cities请求来提供世界上所有的城市;只有GET /cities/:city将被允许。另一种选择是接受请求,但发送回405状态码,方法不允许

  • 由于国家代码不能随意分配,我们不允许POST /countries。相反,PUT /countries/:country将需要添加一个新的国家,以及更新现有的国家。

每种类型的请求将产生适当的 HTTP 状态代码;我们将在以下部分看到这一点。此外,GET请求将发送 JSON 结果,POST请求将发送新创建实体的位置;稍后会详细介绍。

如何做到...

让我们看看如何编写我们的服务器。我们将从一些基本代码开始,跳过我们之前看到的部分,比如 CORS 和 JWT 处理:

// Source file: src/restful_server.js

/* @flow */
"use strict";
const express = require("express");
const app = express();
const bodyParser = require("body-parser");
const dbConn = require("./restful_db.js");
app.get("/", (req, res) => res.send("Secure server!"));

/*
    Add here the logic for CORS
*/

/*
    Add here the logic for providing a JWT at /gettoken
    and the logic for validating a JWT, as shown earlier
*/

处理路由是相当标准的。由于路由简单且少,我们可以将它们放在同一个源文件中;否则,我们将为不同的路由集设置单独的文件。路由的处理程序肯定会放在另一个文件("restful_regions.js")中,以免混淆主服务器代码。请注意,如果存在,国家和地区代码是 URL 的一部分;每当需要地区的名称时,它会放在主体参数中:

// Source file: src/restful_server.js

const {
    getRegion,
    deleteRegion,
    postRegion,
    putRegion
} = require("./restful_regions.js");

app.get("/regions", (req, res) => getRegion(res, dbConn));

app.get("/regions/:country", (req, res) =>
    getRegion(res, dbConn, req.params.country)
);

app.get("/regions/:country/:region", (req, res) =>
    getRegion(res, dbConn, req.params.country, req.params.region)
);

app.delete("/regions/:country/:region", (req, res) =>
    deleteRegion(res, dbConn, req.params.country, req.params.region)
);

app.post("/regions/:country", (req, res) =>
    postRegion(res, dbConn, req.params.country, req.body.name)
);

app.put("/regions/:country/:region", (req, res) =>
    putRegion(
        res,
        dbConn,
        req.params.country,
        req.params.region,
        req.body.name
    )
);

最后,让我们看一些我们已经看到的代码,以完成服务器、错误处理和设置服务器本身:

// Source file: src/restful_server.js

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
    console.error("Error....", err.message);
    res.status(500).send("INTERNAL SERVER ERROR");
});

/*
   Add here the logic for HTTPS, finishing with: 

    https.createServer({ ca, cert, key }, app);
*/

app.listen(8080, () =>
    console.log("Routing ready at http://localhost:8080")
);

让我们继续看看它是如何工作的。我们将在下一节展示处理路由的代码。

它是如何工作的...

由于我们有四种请求,让我们适当地分割我们对服务器代码的研究。

处理 GET 请求

正如我们之前看到的,有三种可能的路由来处理:

  • /regions获取所有国家的所有地区

  • /regions/UY获取给定国家的所有地区-在这种情况下是乌拉圭(UY

  • /regions/UY/11获取一个国家的特定地区-这里是乌拉圭的地区 11

我们可以通过改变我们将要执行的SQL SELECT来以类似的方式处理所有三种情况。然而,处理结果将需要一个特殊情况,正如我们将在下面的代码中注意到的:

// Source file: src/restful_regions.js

const getRegion = async (
    res: any,
    dbConn: any,
    country: ?string,
    region: ?string
) => {
    try {
        res.set("Connection", "close");

        let sqlQuery = "";
        let regions;
        if (country == null) {
            sqlQuery = `
                SELECT rr.* 
                FROM regions rr 
                JOIN countries cc 
                ON cc.countryCode=rr.countryCode
                ORDER BY cc.countryCode, rr.regionCode
            `;
            regions = await dbConn.query(sqlQuery);
        } else if (region == null) {
            sqlQuery = `
                SELECT 1
                FROM countries
                WHERE countryCode=?
            `;

            const countries = await dbConn.query(sqlQuery, [country]);
            if (countries.length === 0) {
                res.status(404).send("Country not found");
                return;
            }

            sqlQuery = `
                SELECT rr.* 
                FROM regions rr 
                JOIN countries cc 
                ON cc.countryCode=rr.countryCode
                WHERE rr.countryCode=?
                ORDER BY rr.regionCode
            `;
            regions = await dbConn.query(sqlQuery, [country]);
        } else {
            sqlQuery = `
                SELECT rr.* 
                FROM regions rr 
                JOIN countries cc 
                ON cc.countryCode=rr.countryCode
                WHERE rr.countryCode=? 
                AND rr.regionCode=?
            `;
            regions = await dbConn.query(sqlQuery, [country, region]);
        }

        if (regions.length > 0 || region === null) {
            res.status(200)
                .set("Content-Type", "application/json")
                .send(JSON.stringify(regions));
        } else {
            res.status(404).send("Not found");
        }
    } catch (e) {
        res.status(500).send("Server error");
    }
};

在前面的代码中我们提到的特殊情况是请求类似于/regions/XYZZY,并提供了错误的国家代码。在这种情况下,我们可以发送404,而不是发送一个空集(这可能意味着该国家存在,因为它似乎没有任何地区),所以第二个if语句(提供了国家,但地区不存在)在继续之前进行了特殊检查。

我们可以通过几个示例看到这段代码的工作。不带进一步参数的/regions提供了一个相当大的输出(22 MB),因此可能需要添加参数以进行过滤或分页:

我从服务器中删除了 HTTPS、CORS,主要是 JWT 代码,以使示例更容易跟踪。这样做意味着我没有收到额外的标头,并且避免了在每次调用中提供 JWT。是的,我有点作弊,但随书提供的源代码包括所有内容,所以不用担心!

> curl localhost:8080/regions/
[{"countryCode":"AD", "regionCode":"2", "regionName":"Canillo"}, {"countryCode":"AD", "regionCode":"3", "regionName":"Encamp"}, {"countryCode":"AD", "regionCode":"4", "regionName":"La Massana"},
.
.
.
{"countryCode":"ZW", "regionCode":"7", "regionName":"Matabeleland South"}, {"countryCode":"ZW", "regionCode":"8", "regionName":"Masvingo"}, {"countryCode":"ZW", "regionCode":"9", "regionName":"Bulawayo"}]

请求特定国家(例如/regions/UY)会产生一个与之前收到的答案非常相似的答案,但只包括国家UY(乌拉圭)的地区,对单个地区的请求会得到一个单一的对象:

> curl localhost:8080/regions/uy/10 
[{"countryCode":"UY","regionCode":"10","regionName":"Montevideo"}]

最后,我们可以尝试一个错误;查看以下截图并注意 404 状态:

向我们的 RESTful 服务器请求不存在国家的地区会产生 404 错误

处理删除操作

删除一个地区很简单,只是必须事先检查地区是否有任何城市。我们可以通过实现级联删除来解决这个问题,这样当你删除一个地区时,它的所有城市也会被删除,或者我们可能会禁止删除。在我们的情况下,我选择了后者,但可以争论说前者也是有效的,并且不需要非常复杂的逻辑:

为什么我们要自己检查城市,而不是让数据库服务器使用外键来做呢?原因很简单:我想展示一些代码,它超出了一个单一的 SQL 语句。同样的论点也适用于级联删除,你可以用手工编写的 SQL 语句来实现,或者在数据库中设置特殊规则。并且,让我声明,对于实际的应用程序,让数据库来做这项工作实际上是更可取的!

// Source file: src/restful_regions.js

const deleteRegion = async (
    res: any,
    dbConn: any,
    country: string,
    region: string
) => {
    try {
        res.set("Connection", "close");

        const sqlCities = `
            SELECT 1 FROM cities 
            WHERE countryCode=? 
            AND regionCode=? 
            LIMIT 1 
        `;
        const cities = await dbConn.query(sqlCities, [country, region]);
        if (cities.length > 0) {
            res.status(405).send("Cannot delete a region with cities");
            return;
        }

        const deleteRegion = `
                DELETE FROM regions 
                WHERE countryCode=? 
                AND regionCode=?
            `;

        const result = await dbConn.query(deleteRegion, [country, region]);
        if (result.info.affectedRows > 0) {
            res.status(204).send();
        } else {
            res.status(404).send("Region not found");
        }
    } catch (e) {
        res.status(500).send("Server error");
    }
};

我们可以以类似的方式进行测试。删除没有城市的地区是有效的,而试图对有城市的地区或不存在的地区进行删除是无效的:

> curl localhost:8080/regions/uy/23 -X DELETE --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8080 (#0) 
> DELETE /regions/uy/23 HTTP/1.1 
*.* .
.
< HTTP/1.1 204 No Content

> curl localhost:8080/regions/uy/10 -X DELETE --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> DELETE /regions/uy/10 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.59.0
> Accept: */*
> 
< HTTP/1.1 405 Method Not Allowed
*.* .
.
Cannot delete a region with cities

> curl localhost:8080/regions/uy/99 --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /regions/uy/99 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.59.0
> Accept: */*
> 
< HTTP/1.1 404 Not Found
*.* .
.
Not found

查看可能返回的不同状态代码:

  • 如果一个地区被删除没有问题,那么返回 204,并且在这种情况下,不会发送文本响应

  • 如果请求的地区不存在,则返回 404

  • 如果请求无法被接受(因为地区有城市),则返回 405

当然,你可以改变服务的工作方式,例如,如果提供了某个参数,可以提供级联删除操作,就像http://some.server/regions/uy/23?cascade=true。此外,对于一些服务,这个操作可能会在没有要求的情况下发生;用户可能有一组偏好,每当一个用户被删除时,你也应该删除他们的偏好。这将取决于服务的期望语义。

处理 PUT 请求

PUT请求意味着要更新一个现有资源。在我们的情况下,一个先决条件是指定的地区必须存在;否则,404错误是合适的。如果地区存在,那么我们可以更新它并发送204状态。如果MySQL检测到地区没有发生任何变化,它会让你知道UPDATE没有改变任何东西;你可以发送204(就像我选择的那样)或者409错误,但无论如何,你可以确定地区有你想要的数据。我们还需要进行一些参数检查;在这种情况下,只是为了确保给出了一个名称,但数据验证逻辑可能会更加复杂:

// Source file: src/restful_regions.js

const putRegion = async (
    res: any,
    dbConn: any,
    country: string,
    region: string,
    name: string
) => {
    res.set("Connection", "close");

    if (!name) {
        res.status(400).send("Missing name");
        return;
    }

    try {
        const sqlUpdateRegion = `
            UPDATE regions
            SET regionName=?
            WHERE countryCode=? 
            AND regionCode=? 
        `;

        const result = await dbConn.query(sqlUpdateRegion, [
            name,
            country,
            region
        ]);

        if (result.info.affectedRows > 0) {
            res.status(204).send();
        } else {
            res.status(409).send("Region not updated");
        }
    } catch (e) {
        res.status(500).send("Server error");
    }
};

这很容易测试,因为只有两种情况(地区存在或不存在),再加上一个健全性检查,以防名称丢失。让我们首先添加缺失的波浪符号到一个地区;就像以前一样,由于 204 状态代码,不会收到任何内容:

> curl localhost:8080/regions/uy/16 -X PUT -d "name=San Jose" --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8080 (#0) 
> PUT /regions/uy/16 HTTP/1.1 
.
.
.
< HTTP/1.1 204 No Content

这两种错误情况(不存在的地区,缺少名称)很快就会被处理。前一种情况是由MySQL检测到的,而后一种情况是由最初的if语句捕获的:

> curl localhost:8080/regions/uy/xyzzy -X PUT -d "name=Colossal Cave" --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8080 (#0) 
> PUT /regions/uy/xyzzy HTTP/1.1 
*.* .
.
< HTTP/1.1 409 Conflict 
*.* .
.
Region not updated

> curl localhost:8080/regions/uy/10 -X PUT --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8080 (#0) 
> PUT /regions/uy/10 HTTP/1.1 
*.* .
.
< HTTP/1.1 400 Bad Request 
*.* .
.
Missing name

处理PUT请求只是最简单的情况;让我们通过仔细研究最复杂的请求,即POST,来完成对服务器的研究。

处理 POST 请求

最后,处理POST请求会更加复杂,因为您应该说明要向哪个集合(在这种情况下是一个国家)添加新资源,并且逻辑应该完成所有工作,包括分配一个 ID。这意味着我们的代码会更长,因为我们将添加查找未使用的区域代码的需求。还会有另一个区别:当资源被创建时,新资源的 URI 应该在Location头中返回,因此这将是另一个额外的要求。

最后,再次,我们将进行一些数据验证,就像PUT请求一样:

// Source file: src/restful_regions.js

const postRegion = async (
    res: any,
    dbConn: any,
    country: string,
    name: string
) => {
    res.set("Connection", "close");

    if (!name) {
        res.status(400).send("Missing name");
        return;
    }

    try {
        const sqlCountry = `
            SELECT 1 
            FROM countries
            WHERE countryCode=? 
        `;
        const countries = await dbConn.query(sqlCountry, [country]);
        if (countries.length === 0) {
            res.status(403).send("Country must exist");
            return;
        }

        const sqlGetId = `
            SELECT MAX(CAST(regionCode AS INTEGER)) AS maxr 
            FROM regions
            WHERE countryCode=? 
        `;
        const regions = await dbConn.query(sqlGetId, [country]);
        const newId =
            regions.length === 0 ? 1 : 1 + Number(regions[0].maxr);

        const sqlAddRegion = `
            INSERT INTO regions SET 
            countryCode=?,
            regionCode=?,
            regionName=?
        `;

        const result = await dbConn.query(sqlAddRegion, [
            country,
            newId,
            name
        ]);
        if (result.info.affectedRows > 0) {
            res.status(201)
                .header("Location", `/regions/${country}/${newId}`)
                .send("Region created");
        } else {
            res.status(409).send("Region not created");
        }
    } catch (e) {
        res.status(500).send("Server error");
    }
};

这是需要最多查询的逻辑。我们必须(1)检查国家是否存在,(2)确定该国家的最大区域 ID,然后(3)插入新区域并向用户返回 201 状态。我们可以像对PUT做的那样测试这个,所以让我们看一个简单的案例:

> curl localhost:8080/regions/ar -X POST -d "name=NEW REGION" --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8080 (#0) 
> POST /regions/ar HTTP/1.1 
*.
.
.*
< HTTP/1.1 201 Created 
< X-Powered-By: Express 
< Location: /regions/ar/25 
.
.
.
Region created

> curl localhost:8080/regions/ar/25 
[{"countryCode":"ar","regionCode":"25","regionName":"NEW REGION"}]

阿根廷有 24 个省,编号从 1 到 24 在regions表中,因此如果我们添加一个新的,它应该是#25,并且答案中的Location头证明了这一点。(我们只返回路由,没有服务器和端口,但我们可以很容易地添加这些数据。)进行GET确认POST成功。

还有更多...

我们已经使用Express建立了一个简单的 RESTful 服务器,但还有更多内容——足够写一本书!让我们通过快速浏览一些您可能想要考虑用于自己项目的想法和工具来结束本章。

接受 JSON 数据

在我们的示例中,我们使用了POST参数,但也可以接收、解析和处理 JSON 输入。(这可以使调用 REST 服务变得更容易,因为在前端很可能您可以轻松地生成一个带有所需请求参数的对象。)使用express.json()作为中间件,请求体将包括来自 JSON 参数的数据。

请访问expressjs.com/en/4x/api.html#express.json了解更多信息。

添加 PATCH 方法进行部分更新

PUT 方法使您更新完整的实体,但有时您只想影响一些字段,在这种情况下,您可以允许PATCH方法。PATCH类似于PUT,但允许您仅更新一些属性。添加对此方法的支持并不复杂,非常类似于PUT逻辑,因此您可以通过相对较少的额外编码提供更强大的服务器。

您可以在developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH了解更多关于 PATCH 的信息,如果您关心其规范,可以在datatracker.ietf.org/doc/rfc5789/找到。

使用 Restify 而不是 Express

虽然Express是一个非常受欢迎和广泛使用的软件包,可以用来构建任何类型的服务器,但如果您特别想要一个仅 REST 服务器而没有其他功能,您可以考虑使用其他软件包,比如Restify。这种改变的优势与软件包的定位有关,它提供了与Express类似的功能,但需要更少的代码来实现 RESTful 部署。Restify的一些知名用户是npm和 Netflix,但名单还要长得多。

您可以在restify.com/了解更多关于Restify的信息。

允许过滤、排序和分页

由于 REST 基本上是一种风格,而不是服务的规范,因此有一些方面没有规定,您可以在实现上有一些自由。三个常见的要求是过滤(这样您就不会得到所有实体,而只会得到满足某些条件的实体)、排序(使实体按某种顺序包含)和分页(因为一次显示数百或数千个实体是不切实际的)。当然,这三个要求彼此交互;如果您进行排序和过滤,那么分页应该适用于排序后的过滤数据。

所有这些要求都可以通过添加一些查询参数(或可能的标头)来处理,但您需要花一些时间来了解对您来说最佳的方式:

  • 过滤可以使用格式进行指定,例如filter=price[lt]220,这将指定给定属性(price)必须小于(lt)某个值(200)。构建涉及逻辑运算符(如andornot)以及可选括号的更复杂表达式也可以完成,但这会导致服务器进行更复杂的解析和解释。

  • 排序可以通过参数进行指定,例如sortby=price,name,首先按price排序,然后按name排序。您可以添加其他选项以允许升序或降序排序。

  • 分页可以通过使用limitoffset参数来完成,其解释与 SQL SELECT语句中使用的相同(有关更多信息,请参见dev.mysql.com/doc/refman/8.0/en/select.html),或者通过指定页面大小和页面编号。

将这些选项添加到您的 REST 服务器的处理中将使其更加强大,并使客户端能够发送更具体、优化的请求。您可能还需要另一个扩展功能;能够选择额外的相关实体,因此请继续阅读。

使用 GraphQL 而不是 REST

REST 服务是标准且易于使用的,但在您不仅需要单个实体,还需要一些相关实体时可能会带来一些开销;例如,您如何获取一个国家及其所有地区?根据我们当前的设计,您必须分别调用并自行连接结果,或者自行扩展您的路由。例如,您可以对/regions/uy?include=cities执行此操作,以便服务器将在UY的每个地区中添加一个包含其城市的数组。虽然这种解决方案可能适用于我们正在使用的这个小例子,但对于更大、更复杂的数据库,其中表之间以多种方式相关联,这可能很容易失控。

然而,还有另一种选择。GraphQL是由 Facebook 开发的数据查询语言,它允许您在客户端定义所需数据的结构;服务器将根据需要生成相应的数据。GraphQL允许您通过跟踪引用来构建复杂结构,并将其与最小延迟一起发送,从而在单个请求中获取许多相关资源。您还可以使用工具来帮助您定义数据模式和执行在线查询。

让我们来看一个非常简短的例子,取自 GraphQL 官方网站的文档,网址为graphql.org/learn/queries/。假设有一个星球大战电影数据库,您可以编写以下查询,想要获取一些电影中的英雄,以及每个英雄的姓名、出现在其中的电影列表和所有朋友的姓名:

{
    leftComparison: hero(episode: EMPIRE) {
        ...comparisonFields
    }
    rightComparison: hero(episode: JEDI) {
        ...comparisonFields
    }
}

fragment comparisonFields on Character {
    name
    appearsIn
    friends {
        name
    }
}

这个查询的结果如下。请注意对象结构如何遵循您在查询中的规范,并且重复字段或外键访问都由GraphQL服务器解决,并在单个请求中完成。

{
    data: {
        leftComparison: {
            name: "Luke Skywalker",
            appearsIn: ["NEWHOPE", "EMPIRE", "JEDI"],
            friends: [
                {
                    name: "Han Solo"
                },
                {
                    name: "Leia Organa"
                },
                {
                    name: "C-3PO"
                },
                {
                    name: "R2-D2"
                }
            ]
        },
        rightComparison: {
            name: "R2-D2",
            appearsIn: ["NEWHOPE", "EMPIRE", "JEDI"],
            friends: [
                {
                    name: "Luke Skywalker"
                },
                {
                    name: "Han Solo"
                },
                {
                    name: "Leia Organa"
                }
            ]
        }
    }
}

虽然这超出了本章的范围(毕竟我们想要一个符合 REST 原则的服务器),但GraphQL是一个非常有效的选择,适用于需要处理复杂的、链接的结构的应用程序,否则这些结构需要太多的处理和通信时间。

要了解有关GraphQL的更多信息,请访问官方网站graphql.org/

实施基于微服务的架构

现在,通过遵循本章的结构,您可以开发一个服务器,从而在 SOA 中工作可能会演变为一个微服务组织,其中服务器不再是一个可以提供多个服务的单片代码,而是作为一组不同的迷你服务器组织,它们之间松散耦合,通过轻量级协议连接,每个服务器都有单一的责任。服务可以由不同的团队创建,甚至可以使用不同的语言或库,只需依赖于遵循给定接口,以便其他服务可以根据需要自由地与它们交互。

这种基于独立较小组件的结构极大地有助于可伸缩性、模块化,甚至开发和测试。如果需要进行更改,其影响将更小,而诸如持续交付和部署等策略也变得可行。开发微服务本身很容易完成,而进行此操作所需的技术主要是本章中所见的技术。只需将一个微服务器的请求添加到另一个微服务器以收集所有所需的信息即可。

关于基于微服务架构的好处,一些很好的起点是 Martin Fowler 在martinfowler.com/microservices/上的文章,以及 Chris Richardson 在microservices.io/patterns/microservices.html上的文章。

第五章:测试和调试你的服务器

在本章中,我们将看到以下的方法:

  • 使用 Winston 添加日志记录

    • 使用 Morgan 添加 HTTP 日志记录
  • 为不同环境配置你的服务器

    • 对你的代码进行单元测试
    • 测量你的测试覆盖率
    • 调试你的代码
  • 从命令行测试简单服务

    • 使用 Postman 测试更复杂的调用序列
  • 使用 Swagger 记录和测试你的 REST API

介绍

在之前的章节中,我们安装了Node并创建了一个 RESTful 服务器。一切准备好了吗?通常情况下,事情不会那么顺利——bug 会悄悄地爬进来,你将不得不找出如何修复你的代码。在本章中,我们将进入实际细节,比如测试和调试你的服务器。

所以,在本章之后,你的Node RESTful 服务器将准备好部署和正式生产工作。让我们开始必要的任务。

- 使用 Winston 添加日志记录

让我们从一个简单的基本需求开始:日志记录。建立稳固、正确的日志记录可以帮助你快速找到问题,而不完整或其他缺乏的日志记录可能会让你寻找几个小时,而问题可能只是一个简单的琐事。任何应用程序的基本规则是确保设置适当的登录,这样你就可以确信任何出现的情况至少会被识别和记录以供将来分析。

你可能的第一个想法是只使用控制台系列函数,比如console.log()console.warn()console.info()等。(有关完整参考,请查看developer.mozilla.org/en-US/docs/Web/API/console。)虽然这些对于快速调试很好,但对于应用级别的日志记录来说并不够。你应该能够选择你想要的日志类型(全部?仅错误?)来决定你在不同环境下看到的日志(例如,在开发中你可能想看到某些类型的日志,但在生产中不需要),甚至可以启用或禁用日志。最后,我们希望对提供的信息有一些控制:

    • 时间戳,了解每个日志写入的时间
    • 文本格式化,使日志可以被人类理解,但也可以被应用程序解析
  • 级别设置,通常在一个范围内从error(最严重)到warninginformativeverbose,最后是debuggingsilly(是的,真的!)

  • 选择目的地,比如stdoutstderr,文件系统等

npm列表中,你会发现许多可以进行日志记录的模块:有些是通用工具,而其他一些则更具体。在我们的情况下,我们将使用Winston进行通用的应用级别日志记录,并且我们将转向另一个工具Morgan,它专门用于 HTTP 流量日志记录,我们将在下一节中看到。

如何做到...

  • 我们想要安装Winston,所以第一步将是应用这个历史悠久的方法:
 npm install winston --save

目前,版本 3.0 还处于测试版阶段,但当你拿到这本书的时候,它几乎肯定已经不再是测试版,而是准备投入生产了。(顺便说一句,我通过使用略微修改的命令安装了测试版:npm install winston@next --save;否则,我会得到一个 2.x.x 版本。)

关于Winston的(彻底的!)文档,请查看它自己的 GitHub 页面github.com/winstonjs/winston。不过要小心网络上的文章,因为在版本 3 中有一些重要的更改,所以大多数代码在没有更新的情况下是无法工作的。

我们想要看一个关于Winston使用的简单例子。这个包有许多配置参数,所以让我们尝试建立一个基本的、合理的配置,这样你就可以自己扩展了:

// Source file: winston_server.js

/* @flow */
"use strict";

const express = require("express");
const winston = require("winston");

const app = express();

const logger = winston.createLogger({
    transports: [
        new winston.transports.Console({
            level: "info",
            format: winston.format.combine(
                winston.format.colorize({ all: true }),
                winston.format.label({ label: "serv" }),
                winston.format.timestamp(),
                winston.format.printf(
                    msg =>
                       `${msg.timestamp} [${msg.label}] ${msg.level} ${
                            msg.message
                        }`
                )
            )
        }),
        new winston.transports.File({
            filename: "serv_error.txt.log",
            level: "warn",
            format: winston.format.combine(
                winston.format.timestamp(),
                winston.format.printf(
                    msg =>
                        `${msg.timestamp} [serv] ${msg.level} ${
                            msg.message
                        }`
                )
            )
        }),
        new winston.transports.File({
            filename: "serv_error.json.log",
            level: "warn"
        })
    ]
});

// *continues...*

Winston可以同时处理多个传输,而传输意味着您记录的任何内容的存储设备。单个记录器可以有多个传输,但配置不同:例如,您可能希望在控制台上显示所有日志,但只在警告和错误时写入文件,还有更多可能性,包括写入数据库或将数据发送到某个 URL。格式也可能不同(控制台的文本行,文件可能是 JSON 格式?),因此您在配置消息的输出位置方面有很大的灵活性。

在我们的情况下,我们创建了三个传输:

  • 控制台输出,用于所有标记为"info"及以上的消息,使用带有时间戳的着色输出(我们马上就会看到),发出带有时间戳、标签("serv")的输出,以帮助区分服务器的消息和可能出现在控制台上的其他应用程序的消息,错误级别和消息

  • 一个文件输出,用于所有标记为"warn"及以上的消息,以文本格式

  • 另一个文件输出,用于相同的消息,但以 JSON 格式

我们将在本章的后面部分,为不同环境配置服务器,中看到如何调整日志记录(和其他功能),这样您就可以更加灵活地进行日志记录和其他功能。

创建了记录器并定义了传输后,我们只需在需要的地方使用它。我将从一个非常基本的服务器开始,这样我们就可以专注于使日志记录工作:我们只处理两个路由——/,它将发送一条消息,和/xyzzy,它将模拟一些程序故障,而不是发送一个“什么也没发生”的消息。

一开始,我们可以手动记录每个请求——尽管我们将在之后看到,使用Morgan会得到更好的输出。以下代码就是这样做的:

// ...*continued*

app.use((req, res, next) => {
    logger.info(`${req.method} request for ${req.originalUrl}`);
    next();
});

// *continues...*

然后,对于每个路由,我们可以添加一些infodebug消息,因为我们可能需要:

// ...*continued*

app.get("/", (req, res) => {
    logger.info("Doing some processing...");
 logger.debug("Some fake step 1; starting");
 logger.debug("Some fake step 2; working");
 logger.debug("Some fake step 3; finished!");
    res.send("Winston server!");
});

app.get("/xyzzy", (req, res) => {
    logger.info("Adventurer says 'XYZZY'");
    res.say_xyzzy(); // this will fail
    res.send("Nothing happens.");
});

// *continues...*

处理错误路由可能会产生一个warn消息,在其他未经计划的情况下,会直接产生一个error。对于前者,我只列出了所需的路由,对于后者,列出了错误消息和回溯堆栈,以帮助未来调试:

// ...*continued*

app.use((req, res) => {
    logger.warn(`UNKNOWN ROUTE ${req.originalUrl}`);
    res.status(404).send("NOT FOUND");
});

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
    logger.error(`GENERAL ERROR ${err.message}\n${err.stack}`);
    res.status(500).send("INTERNAL SERVER ERROR");
});

app.listen(8080, () => {
    logger.info("Ready at http://localhost:8080");
});

我们准备好了!让我们试一试。

它是如何工作的...

构建项目后,我运行了Winston日志记录代码来捕获所有生成的日志。我用curl模拟了一系列调用进行了尝试;我们将在本章的后面部分中看到如何在更复杂的任务中执行此操作:

> curl localhost:8080/ 
Winston server!
> curl localhost:8080/ 
Winston server!
> curl localhost:8080/invented 
NOT FOUND
> curl localhost:8080/ 
Winston server!
> curl localhost:8080/xyzzy 
INTERNAL SERVER ERROR
> curl localhost:8080/ 
Winston server!
> curl localhost:8080/ 
Winston server!

控制台上的输出可以在下面的截图中看到。正常行是绿色的(是的,在黑白书中很难看到,对此很抱歉!),警告是黄色的,错误是红色的。对于不存在的/invented路径的请求以警告结束,而对于/xyzzy的请求产生了一个错误,因为我们尝试调用一个不存在的函数:

温斯顿的控制台输出一些虚拟请求

不同的日志文件记录了什么?根据我们的规范,只有警告和错误消息被存储。文本文件基本上与控制台输出相同,这是有道理的,因为我们为这两个传输选择的格式规范完全相同:

2018-05-28T00:29:06.651Z [serv] warn UNKNOWN ROUTE /invented
2018-05-28T00:29:11.214Z [serv] error GENERAL ERROR res.say_xyzzy is not a function
TypeError: res.say_xyzzy is not a function
 at app.get (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:60:9)
 at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)
 at next (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/route.js:137:13)
 at Route.dispatch (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/route.js:112:3)
 at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)
 at /home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:281:22
 at Function.process_params (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:335:12)
 at next (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:275:10)
 at app.use (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:47:5)
 at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)

另一方面,JSON 文件有点简化:每行包括一个带有messagelevel属性的对象,因为我们没有指定应该添加什么。但是,您可以更改这一点:阅读温斯顿的文档github.com/winstonjs/winston/blob/master/README.md,您将有很多可用的可能性:

{"message":"UNKNOWN ROUTE /invented","level":"warn"}
{"message":"GENERAL ERROR res.say_xyzzy is not a function\nTypeError: res.say_xyzzy is not a function\n at app.get (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:60:9)\n at Layer.handle [as handle_request] *...part of the text snipped out...*
(/home/fkereki/MODERNJS/chapter05/out/winston_server.js:47:5)\n at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)","level":"error"}

因此,我们有一种灵活的方式来记录几乎我们想要的任何内容,但是我们的 HTTP 日志记录特别简略,这是包括Morgan的一个很好的理由,正如我们将看到的。

还有更多...

你可能还对其他软件包感兴趣,比如Bunyangithub.com/trentm/node-bunyan)或Pinogithub.com/pinojs/pino);后者据说是性能最佳的日志软件包,但不要听我的,自己试试看!最后,如果你在开发npm软件包,那么debuggithub.com/visionmedia/debug)可能是你的首选软件包——它基本上是console方法的包装器,非常简单,也适用于 Web 应用和Node

使用 Morgan 添加 HTTP 日志

在上一节中,当我们包含了一些进行Winston记录的中间件时,我们成功提供了一个非常基本的 HTTP 记录功能:

app.use((req, res, next) => {
    logger.info(`${req.method} request for ${req.originalUrl}`);
    next();
});

虽然这样做可以工作,但我们可能还需要更多的信息,比如响应的 HTTP 状态码,所需的处理时间等,所以让我们将Morgan加入进来,因为该软件包专门用于请求记录。

你可以在github.com/expressjs/morgan了解更多关于Morgan的信息。

在这个示例中,我们将Morgan添加到我们的软件堆栈中,以便为所有已处理的请求获得更好的日志。

如何做...

让我们从常规方法开始安装Morgan

 npm install morgan --save

现在我们必须在服务器中包含它,并且我们还需要fs软件包来将Morgan的日志写入文件。请注意,我是在我们之前的服务器上添加的,所以Winston的部分将保持不变,与我们在上一节中看到的一样:

// Source file: src/morgan_server.js

/* @flow */
"use strict";

const express = require("express");
const winston = require("winston");
const morgan = require("morgan");
const fs = require("fs");

const app = express();

// *continues...*

我们想要对文件进行一些一般性的记录,以及所有错误(HTTP 状态码为 400 及更高)输出到控制台,所以我们必须将morgan添加两次到我们的中间件堆栈中。morgan的第一个参数定义了日志消息的形式:你必须提供一个函数来生成将被记录的消息,或者一个包含morgan将在运行时替换的标记的字符串。在下面的代码片段中,我使用了两种风格,只是为了多样化:文件输出使用函数,控制台使用字符串:

// ...*continued*

const morganStream = fs.createWriteStream("serv_http_errors.log", {
    flags: "a"
});

app.use(
    morgan(
        (tokens, req, res) =>
 `${new Date().toISOString()} [http] ` +
 `${tokens.method(req, res)} ${tokens.url(req, res)}`,
        {
            immediate: true,
            stream: morganStream
        }
    )
);

app.use(
    morgan(
        `:date[iso] [http] ` +
 `:method :url (:status) :res[content-length] - :response-time ms`,
        {
            skip: (req, res) => res.statusCode < 400
        }
    )
);

// *continues...*

morgan的第二个选项允许你添加一些选项,例如以下内容:

  • immediate,意味着请求将在进来时立即记录(immediate:true)或在处理后记录(immediate:false)。前者的优势在于你可以确保所有请求都会被记录,即使发生严重崩溃,但后者提供了更多信息。

  • skip(),一个函数,让你决定是否记录给定的请求。在我们的情况下,我们将使用它来记录得到 400 或更高状态的请求。

  • stream,输出应写入的流。

在指定输出格式时,你可以访问多个数据片段,称为 Morgan 术语中的标记,例如以下内容,但请查阅完整列表的文档:

:date[format] 当前日期和时间(以 UTC 时间)以多种格式显示
:http-version 请求的 HTTP 版本
:method 请求的 HTTP 方法
:remote-addr 请求的远程地址
:req[header] 请求的给定标头,如果标头不存在则为“-”
:res[header] 响应的给定标头,如果标头不存在则为“-”
:response-time 处理时间,以毫秒为单位
:status 响应的 HTTP 状态
:url 请求的 URL

你可以看到我在设置Morgan的输出时使用了几个这些标记。现在,让我们看看它是如何工作的。

它是如何工作的...

让我们试一试,使用我们为winston使用的相同示例。由于我们将控制台输出设置为仅显示警告和错误,我们将只看到添加的一对行。显示[http]而不是[serv]有助于在控制台输出的其余部分中找到它们。

.
.
.
2018-05-28T19:27:19.232Z [http] GET /invented (404) 9 - 0.886 ms
.
.
.
2018-05-28T19:27:23.771Z [http] GET /xyzzy (500) 21 - 0.925 ms
.
.
.

(完整的)HTTP 日志被记录到一个文件中,只是所有请求的列表:

2018-05-28T19:27:16.871Z [http] GET /
2018-05-28T19:27:17.827Z [http] GET /
2018-05-28T19:27:19.231Z [http] GET /invented
2018-05-28T19:27:20.677Z [http] GET /
2018-05-28T19:27:23.770Z [http] GET /xyzzy
2018-05-28T19:27:25.296Z [http] GET /

请注意,我们选择立即记录日志,这意味着所有请求——即使可能导致一切崩溃的请求——都会被记录,但请求本身的结果则不可用。如果您希望获取该信息,但仅针对导致某些错误的请求,您可以添加第三个morgan目标,共享相同的文件流,但仅用于错误,如下面的代码片段所示:

app.use(
    morgan(
        `:date[iso] [http] ` +
            `:method :url (:status) :res[content-length] - :response-time ms`,
        {
            skip: (req, res) => res.statusCode < 400,
            stream: morganStream
        }
    )
);

使用这个方法,日志将包括更多数据,但仅适用于您选择的请求:

2018-05-28T19:36:54.968Z [http] GET /
2018-05-28T19:36:55.453Z [http] GET /
2018-05-28T19:36:56.011Z [http] GET /
2018-05-28T19:36:58.149Z [http] GET /invented
2018-05-28T19:36:58.151Z [http] GET /invented (404) 9 - 1.230 ms
2018-05-28T19:36:59.528Z [http] GET /
2018-05-28T19:37:00.033Z [http] GET /
2018-05-28T19:37:01.886Z [http] GET /xyzzy
2018-05-28T19:37:01.888Z [http] GET /xyzzy (500) 21 - 1.115 ms
2018-05-28T19:37:03.060Z [http] GET /
2018-05-28T19:37:03.445Z [http] GET /
2018-05-28T19:37:03.903Z [http] GET /

还有更多...

如果您愿意,可以将Morgan的输出发送到Winston中,以获得单个公共日志流,如下所示:

// Source file: src/morgan_in_winston_server.js

app.use(
    morgan(
        `:method :url (:status) :res[content-length] - :response-time ms`,
        {
 stream: {
 write: message => logger.info(message.trim())
 }
        }
    )
);

一些输出可能如下;我突出显示了morgan行:

2018-05-28T20:03:59.931Z [serv] info Ready at http://localhost:8080
2018-05-28T20:04:02.140Z [serv] info Doing some processing...
2018-05-28T20:04:02.146Z [serv] info GET / (200) 15 - 3.642 ms
2018-05-28T20:04:02.727Z [serv] info Doing some processing...
2018-05-28T20:04:02.728Z [serv] info GET / (200) 15 - 0.581 ms
2018-05-28T20:04:04.479Z [serv] warn UNKNOWN ROUTE /invented
2018-05-28T20:04:04.480Z [serv] info GET /invented (404) 9 - 1.170 ms
2018-05-28T20:04:05.842Z [serv] info Doing some processing...
2018-05-28T20:04:05.843Z [serv] info GET / (200) 15 - 0.490 ms
2018-05-28T20:04:07.640Z [serv] info Adventurer says 'XYZZY'

我所做的更改的一些细节如下:

  • 添加.trim()可以去除可能存在的额外换行符

  • 由于所有消息都通过winston发送,因此您在输出中看不到[http]的区分文本

  • 如果您希望发送警告状态码为 400 或以上的警告,您将需要编写一个更复杂的函数,该函数将扫描消息文本并决定是否使用logger.info()或其他方法

为不同环境配置服务器

无论您开发什么,可以肯定的是,您至少会使用两个环境,开发生产,并且代码的设置不会相同。例如,您不会使用相同的配置来访问数据库、记录错误或连接到分析服务等等:在开发环境中运行时,您将需要一定的设置,而在生产环境中可能会有很多变化。

您可以在代码中设置所有内容,但是在明文中保存用户、密码、IP 等敏感数据,并将其保存在可能被黑客攻击的源代码存储库中,这并不是一个安全的做法。您应该专门处理开发配置,并将实际部署留给另一个团队,他们将安全地处理该配置。

Node 允许您访问环境变量并将其用于配置,以便您可以将该设置移出代码之外。在本章中,让我们看看如何处理所有这些,这也将在本章后期间接下来的测试间接帮助我们。

如何做...

当您开发软件时,显然会在与生产不同的环境中工作;事实上,您可能有多个环境,如开发测试预生产生产等。我们也会这样做;让我们首先回顾一些我们在本书中已经看到的配置。

在第三章的使用 Node 进行开发部分的获取连接部分中,当我们创建服务时,我们定义了四个常量来访问数据库,如下所示:

const DB_HOST = "127.0.0.1";
const DB_USER = "fkereki";
const DB_PASS = "modernJS!!";
const DB_SCHEMA = "world";

在上一章的使用 JWT 添加身份验证部分中,我们有一个用于签名的密钥:

const SECRET_JWT_KEY = "modernJSbook";

最后,在这一章中,我们决定应该做哪些级别的日志记录。然而,我们对这些级别进行了硬编码,没有可能在生产中进行不同的设置:

const logger = winston.createLogger({
    transports: [
        new winston.transports.Console({
            level: "info",
            format: winston.format.combine(
                winston.format.colorize({ all: true }),
                .
                .
                .

我们还写了以下内容,并进行了一些硬编码:

const morganStream = fs.createWriteStream("serv_http_errors.log", {
    flags: "a"
});

app.use(
    morgan(
        `:date[iso] [http] ` +
            `:method :url (:status) :res[content-length] - :response-time ms`,
        {
            skip: (req, res) => res.statusCode < 400

值得指出的是,开发和生产之间的更改不仅限于列出或不列出;您还可以更改日志格式、日志应写入的文件等等。

动态更改配置的关键是使用环境变量,这些变量通过process.env对象提供。您环境中的每个配置变量都将显示为该对象的属性。如果我们编写并运行一个仅包含单个console.log(process.env)行的程序(或者在命令行中执行node -e "console.log(process.env)"),您将获得类似以下的输出:

> node show_env.js
{ GS_LIB: '/home/fkereki/.fonts',
 KDE_FULL_SESSION: 'true',
 PILOTPORT: 'usb:',
 HOSTTYPE: 'x86_64',
 VSCODE_NLS_CONFIG: '{"locale":"en-us","availableLanguages":{}}',
 XAUTHLOCALHOSTNAME: 'linux',
 XKEYSYMDB: '/usr/X11R6/lib/X11/XKeysymDB',
 LANG: 'en_US.UTF-8',
 WINDOWMANAGER: '/usr/bin/startkde',
 LESS: '-M -I -R',
 DISPLAY: ':0',
 JAVA_ROOT: '/usr/lib64/jvm/jre',
 HOSTNAME: 'linux',
 .
 .
 .
 . *many, many lines snipped out*
 .
 .
 .
 PATH: '/home/fkereki/bin:/usr/local/bin:/usr/bin:/bin:/usr/lib/mit/sbin',
 JAVA_BINDIR: '/usr/lib64/jvm/jre/bin',
 KDE_SESSION_UID: '1000',
 KDE_SESSION_VERSION: '5',
 SDL_AUDIODRIVER: 'pulse',
 HISTSIZE: '1000',
 SESSION_MANAGER: 'local/linux:@/tmp/.ICE-unix/2202,unix/linux:/tmp/.ICE-unix/2202',
 CPU: 'x86_64',
 CVS_RSH: 'ssh',
 LESSOPEN: 'lessopen.sh %s',
 GTK_IM_MODULE: 'ibus',
 NODE_VERSION: '9' }

nodejs.org/api/process.html#process_process_env了解更多关于process.env及其内容的信息。

有两种利用这一点的方法。我们可以使用一个环境变量来检查我们是在开发中、生产中还是其他任何情况下,并根据此设置一些属性,或者我们可以直接从环境中获取这些属性的值。这两种解决方案中的任何一种都将帮助您将代码与环境解耦;让我们看看这在实践中是如何工作的。

它是如何工作的...

让我们从确定环境开始。标准是在运行Node服务器之前设置一个名为NODE_ENV的环境变量,其中包含环境的名称。如何做取决于您的实际机器,但在 Linux 中,类似以下内容,而在 Windows 中则需要SET命令:

> export NODE_ENV=production 
> echo $NODE_ENV 
Production

在您的代码中,如果您正在开发中,则可以将isDev变量设置为 true(否则为 false),只需两行代码。如果未指定环境,则第一行将默认为"development",这很可能是最安全的选择:

// Source file: show_env.js

const dev = process.env.NODE_ENV || "development";
const isDev = dev === "development";
level attribute gets its value, depending on the environment:
const logger = winston.createLogger({
    transports: [
        new winston.transports.Console({
            level: isDev ? "info" : "warn",
            format: winston.format.combine(
                winston.format.colorize({ all: true }),
                .
                .
                .

更改日志文件也很简单,与前面的代码类似:

let loggingFile;
if (isDev) { 
    loggingFile = "serv_http_errors.log";
} else {
    loggingFile = "/var/log/http_server.txt";
}

const morganStream = fs.createWriteStream(loggingFile, {
    flags: "a"
});

这种风格有效,但仍然存在一些问题:

  • 环境的任何更改都需要更改(硬编码的)服务器

  • 路径、令牌、密码等都驻留在源代码中,处于非常可见的状态

因此,我们可以通过直接从环境中直接获取内部变量的值来做得更好:

const DB_HOST = process.env.DB_HOST;
const DB_USER = process.env.DB_USER;
const DB_PASS = process.env.DB_PASS;
const DB_SCHEMA = process.env.DB_SCHEMA;
const SECRET_JWT_KEY = process.env.SECRET_JWT_KEY;

或者,对于日志记录,我们可以使用以下内容:

const logger = winston.createLogger({
    transports: [
        new winston.transports.Console({
            level: process.env.WINSTON_LEVEL,
            format: winston.format.combine(
                winston.format.colorize({ all: true }),
                .
                .
                .

还有更多...

如果您想简化在开发中的工作,但同时也希望在将代码推送到生产或其他环境时为其他人提供便利,您可能需要研究一下dotenv,这是一个npm包,可以让您在文本文件中使用环境变量。使用npm install dotenv --save安装该包,然后在项目的根目录创建一个具有.env扩展名的文件,其中包含所需的变量值:

DB_HOST=127.0.0.1
DB_USER=fkereki
DB_PASS=modernJS!!
DB_SCHEMA=world
SECRET_JWT_KEY=modernJSbook

然后,在您的代码中,您只需要添加一行代码,它将加载并合并.env文件中的所有定义到process.env中。当然,如果您只想在开发中使用此功能(正如dotenv的创建者最初打算的那样),您可以先检查isDev变量,就像我们之前看到的那样:

if (isDev) {
    dotenv.load();
}

环境文件不应上传到源代码控制,因此在您的.gitignore文件中添加一行**/*.env是有意义的。但是,您可以上传一个示例文件(比如config.env.example),但不包含环境变量的实际值;这将帮助新开发人员获取必要的文件,同时保护安全性。

您可以在github.com/motdotla/dotenv了解更多关于dotenv的信息。

单元测试您的代码

确保质量并保护自己免受回归错误(在修改某些内容并重新引入先前已纠正的错误时发生的错误)的最佳实践之一是确保您的代码经过单元测试。有三种类型的测试:

  • 单元测试,适用于每个组件,各自独立

  • 集成测试,适用于组件共同工作

  • 端到端E2E测试,适用于整个系统

单元测试很好——不仅因为它有助于尝试您的代码,而且因为如果做得好,就像测试驱动设计TDD)一样,您基本上首先设置测试,然后再编写代码——因为它将有助于生成更高质量的代码,这肯定会对系统中的所有错误产生影响。(甚至在任何测试工作开始之前发现错误也是节省金钱的;您发现并修复错误的越早,成本就越低。)因此,让我们专注于如何在 Node 工作中使用单元测试。

当然,众所周知测试可以证明错误的存在,但不能证明它们的不存在,所以无论您做多少测试,都会有一些错误漏掉!当发生这种情况时,TDD 将使您首先创建一些新的单元测试来定位错误,然后才开始实际修复它;至少,特定的错误不会再次出现,因为它将被检测到。

有很多用于单元测试的工具和框架,在本书中我们将使用Jest,这是一个现代的令人愉快的 JavaScript 测试工具,它是由 Facebook 开发的。我们还有额外的优势,可以将它用于 React 或 React Native。安装非常简单,只需要npm install jest --save-dev。做完这些之后,我们就可以编写我们的测试了;让我们看看如何做。

您可以在官方网页上阅读更多关于Jest的信息,网址是facebook.github.io/jest/

在这个配方中,我们将看看如何为Node编写单元测试,并为未来的章节获得有效的经验。

如何做...

编写单元测试可能更简单或更困难,这取决于您如何设计代码。如果您以清晰、无副作用的方式工作,那么编写功能测试将会非常简单。如果您开始添加诸如回调或承诺、数据库或文件系统等复杂性,那么您将需要更多的工作,因为您将不得不模拟其中一些元素;毕竟,您不想在生产数据库上运行测试,对吧?

在接下来的章节中,我们将看看如何编写单元测试,并学习如何处理一些特定的概念,如模拟间谍

进行功能测试

首先,让我们看一个简单的、基本的功能测试集,为此,让我们回到我们在第三章的使用模块部分编写的四舍五入库。当您测试一个模块时,您只测试导出的函数,看它们是否按照规格执行。然后,有趣的部分是测试以下内容:

const addR = (x: number, y: number): number => roundToCents(x + y);

const subR = (x: number, y: number): number => addR(x, changeSign(y));

const multR = (x: number, y: number): number => roundToCents(x * y);

const divR = (x: number, y: number): number => {
    if (y === 0) {
        throw new Error("Divisor must be nonzero");
    } else {
        return roundToCents(x / y);
    }
};

这四个函数在功能上是完全的,因为它们的计算结果仅取决于它们的输入参数,并且它们绝对没有副作用。编写测试需要(1)定义测试组,和(2)在每个组中包含一个或多个测试。在这里,为每个函数编写一个组是有意义的,所以让我们看看代码可能是怎样的;我们可以从addR()函数开始,然后写出类似这样的东西:

// Source file: src/roundmath.test.js

/* @flow */
"use strict";

const rm = require("./roundmath");

describe("addR", () => {
    it("should add first and round later", () => {
        expect(rm.addR(1.505, 2.505)).toBe(4.01);
    });

    it("should handle negatives", () => {
        expect(rm.addR(3.15, -2.149)).toBe(1.0);
    });
});

// *continues...*

最常见的风格是将单元测试文件命名为与被测试文件相同的方式,只是在文件扩展名之前添加"test""spec"。在我们的情况下,对于roundmath.js,我们将单元测试文件命名为roundmath.test.js。至于放置位置,Jest能够找到您的测试,无论您将它们放在何处,所以通常的做法是将这个新文件放在原始文件旁边,以便找到它。

每个describe()调用定义了一个组,其中的每个it()调用定义了一个特定的测试。如果测试失败,Jest 将报告它,给出组和测试的描述,如“addR 应该先添加,然后再四舍五入”。测试包括(1)设置事物,如果需要的话;(2)通过调用函数实际运行测试;和(3)检查函数是否按我们的预期执行。

我们编写的第一个测试验证了当添加数字时,应该先进行加法,然后再进行四舍五入;先进行四舍五入,然后再进行加法是不正确的。我们通过调用addR(1.505, 2.505)来测试这一点,我们期望结果是4.01;如果函数先进行了四舍五入,结果将会是4.02。每个测试都应该能够验证函数的至少一个属性;我们的第二个测试检查addR()是否能够处理负数。

您编写关于代码的假设的风格旨在易于阅读:期望某某是某个值。诸如toBe()toThrow()(见我们的下一个示例)的方法称为匹配器;有关更多信息,请参阅facebook.github.io/jest/docs/en/expect.html上的相当长的列表。

当然,对于复杂的代码,可能只有几个测试是不够的,通常会有更多的测试,但是作为一个例子,这些就足够了。请注意,我们应该为所有函数编写测试;例如,divR()可以使用类似这样的东西。虽然第一个测试非常直接(类似于addR()的测试),但在第二个测试中,我们验证调用divR()时,如果除数为零,应该抛出异常:

// ...*continued*

describe("divR", () => {
    it("should divide first, then round", () => {
        expect(rm.divR(22.96, 0.001)).toBe(22960);
    });

    it("should not divide by zero", () =>
        expect(() => rm.divR(22, 0)).toThrow());
});

如果您错过了一些函数或它们的一部分,稍后在本章中,我们将看看如何检测到;现在不要担心。此时,我们将继续编写测试,然后运行完整的测试套件。

使用间谍

我们编写的功能测试非常好,但在某些情况下不适用,比如当您使用回调时。让我们转向另一段我们编写的代码:我们用于 JWT 的用户验证例程。基本上,这个函数接收一个用户名、一个密码和一个错误回调,用于表示用户名是否真的有这个密码。我们编写了非常基本的验证代码(只接受一个用户!),但这里并不重要;我们想看看如何处理回调。我们现在关心的重要部分在以下代码片段中突出显示:

const validateUser = (
    userName: string,
    password: string,
    callback: (?string, ?string) => void) => {
    if (!userName || !password) {
        callback("Missing user/password", null);
    } else if (userName === "fkereki" && password === "modernjsbook") {
        callback(null, "fkereki"); // OK, send userName back
    } else {
        callback("Not valid user", null);
 }
};

测试这将需要实际传递一个回调,然后尝试看它是如何被调用的;这是可以做到的,但细节会很混乱。或者,我们可以有一个间谍——一个虚拟函数,稍后我们可以询问它是否被调用,以及它是如何被调用的,以及更多:

// Source file: validate_user.test.js

/* @flow */
"use strict";

const validateUser = require("./validate_user");

describe("validateUser", () => {
    let cb;
    beforeEach(() => {
        cb = jest.fn();
    });

    it("should reject a call with empty user", () => {
        validateUser("", "somepass", cb);
        expect(cb).toHaveBeenCalled();
        expect(cb).toHaveBeenCalledWith("Missing user/password", null);
    });

    it("should reject a wrong password", () => {
        validateUser("fkereki", "wrongpassword", cb);
        expect(cb).toHaveBeenCalledWith("Not valid user", null);
    });

    it("should accept a correct password", () => {
        validateUser("fkereki", "modernjsbook", cb);
        expect(cb).toHaveBeenCalledWith(null, "fkereki");
    });
});

我们可以通过调用jest.fn()来创建这样的间谍。由于我们将为每个测试编写一个新的间谍,我们可以利用beforeEach()函数,Jest 将在运行每个单独的测试之前自动调用它;这将节省一些额外的编写。实际上有四个函数可以使用,如下所示:

  • beforeAll()将在开始测试之前只调用一次;例如,您可以在这里设置一个测试数据库并填充它的某些数据

  • beforeEach()将在每个测试之前调用,就像我们在示例中创建间谍时所做的那样。

  • afterEach()将在每个测试后调用,以清理

  • afterAll()将在运行所有测试后调用;例如,您可以销毁仅用于测试目的创建的测试数据库

这三个测试都是类似的;我们将选择第一个。我们调用验证例程,但传递一个空参数。根据验证规范,这应该创建一个错误。通过这样做,我们可以测试回调是否真的被调用,并且它是通过传递一个错误作为第一个参数,以及作为第二个参数传递了什么来调用的。

(当然,第一个测试使用.toHaveBeenCalled()匹配器是不需要的,因为第二个测试测试了它是否使用特定值调用,但我们只是想展示一个新的匹配器对。)

如果我们只关心看一个给定的函数是否被调用,使用间谍是非常实用的,但是如果被测试的函数实际上需要从我们的间谍那里返回一些值会发生什么?我们也可以解决这个问题;让我们进入一个更复杂的例子。

使用模拟

让我们最后使用一个更复杂的例子来工作——一个处理与地区相关的 REST 代码的部分,它需要一个数据库并使用承诺,还有其他几个复杂情况。让我们以DELETE方法处理程序为例:

const deleteRegion = async (
    res: any,
    dbConn: any,
    country: string,
    region: string
) => {
    try {
        const sqlCities = `
            SELECT 1 FROM cities 
            WHERE countryCode="${country}" 
            AND regionCode="${region}" 
            LIMIT 1 
        `;

        const cities = await dbConn.query(sqlCities);

        if (cities.length > 0) {
            res.status(405).send("Cannot delete a region with cities");
        } else {
            const deleteRegion = `
                DELETE FROM regions 
                WHERE countryCode="${country}" 
                AND regionCode="${region}"
            `;

            const result = await dbConn.query(deleteRegion);

            if (result.info.affectedRows > 0) {
                res.status(204).send();
            } else {
                res.status(404).send("Region not found");
            }
        }
    } catch (e) {
        res.status(500).send("Server error");
    }
};

通过将数据库连接(dbConn)作为参数传递给函数,我们做对了一些事情。这意味着我们可以模拟它——也就是提供一个替代版本,它将按照我们的意愿行事,但实际上不使用任何数据库。同样,处理我们的请求将需要模拟一个响应对象(res),我们将要检查其状态码;我们可以手工编码,但使用node-mocks-http包更简单,所以只需使用npm install node-mocks-http --save进行安装。查看其文档github.com/howardabrams/node-mocks-http,获取更多信息——它可以做更多!

我们知道DELETE方法应该(1)确认要删除的区域不能有城市,(2)如果是真的,那么就删除该区域。我们如何测试第一个检查是否有效?让我们为deleteRegion()提供一个模拟,它会说我们想要删除的区域实际上有一些城市:

// Source file: src/restful_regions.test.js

/* @flow */
"use strict";

const { deleteRegion } = require("./restful_regions");
const mockRes = require("node-mocks-http");

describe("deleteRegion", () => {
    let mDb;
    let mRes;
    beforeEach(() => {
        mDb = { query: jest.fn() };
        mRes = new mockRes.createResponse();
    });

    it("should not delete a region with cities", async () => {
        mDb.query.mockReturnValueOnce(Promise.resolve([1]));
        await deleteRegion(mRes, mDb, "FK", "22");
        expect(mRes.statusCode).toBe(405);
    });

// *continues*...

我们可以编写一个完整的模拟数据库,分析传入的查询,然后提供一些预期的答案,但在这种情况下,对代码如何检查城市有一些了解是很有帮助的。我们可以创建一个带有查询属性(mDb.query)的模拟数据库对象,并设置当第一次调用mDb.query()时,它将返回一个解析为包含单个 1 的数组的 promise——因为这就是实际的 SQL 语句在检查实际包含一些城市的区域时会产生的结果。我们还将创建一个模拟响应对象(mRes),它将得到例程的答复。

还剩下什么要做?你只需要调用deleteRegion()函数并传入所有参数,await其结果,并验证响应状态码是否为 405,然后你就完成了!

其他测试类似,但我们必须模拟两个 SQL 访问,而不是一个:

// ...*continued*

    it("should delete a region without cities", async () => {
        mDb.query
 .mockReturnValueOnce(Promise.resolve([]))
 .mockReturnValueOnce(
 Promise.resolve({
 info: { affectedRows: 1 }
 })
 );
        await deleteRegion(mRes, mDb, "ST", "12");
        expect(mRes.statusCode).toBe(204);
    });

    it("should produce a 404 for non-existing region", async () => {
        mDb.query
            .mockReturnValueOnce(Promise.resolve([]))
            .mockReturnValueOnce(
                Promise.resolve({
                    info: { affectedRows: 0 }
                })
            );
        await deleteRegion(mRes, mDb, "IP", "24");
        expect(mRes.statusCode).toBe(404);
    });
});

有趣的是,我们可以设置一个模拟函数,每次调用时产生不同的答案,根据我们的需要。因此,为了测试deleteRegion()是否能正确删除没有城市的区域,我们的模拟 DB 对象必须执行以下操作:

  • 首先,返回一个空数组,显示要删除的区域没有城市

  • 其次,返回一个带有affectedRows:1的对象,显示(假设的)DELETE SQL 命令成功了

设置完这些后,其余的代码就像我们的第一个案例一样;等待函数并检查状态码。

它是如何工作的...

为了运行测试,我们需要编辑package.json中的一个脚本。更改"test"脚本,到目前为止只有一个错误消息,所以它将读取如下内容:

 "test": "jest out/"

"test"脚本可以通过输入npm test来运行。在我们的案例中,由于我们的输出代码进入out/目录,我们告诉 Jest 检查该目录,并运行它可以找到的所有测试(默认情况下是*.test.js文件)。你可以修改 Jest 的配置以适应更特定的情况,但总的来说,它在零配置的情况下工作得很好。输出简洁实用,如下截图所示:

npm test 命令的结果简洁明了

在我们的案例中,匹配我们所做的,它显示我们运行了三套测试,总共包括 10 个测试,它们都通过了。如果一个或多个测试产生了错误的结果,我们将得到另一种结果,有很多红色。我故意修改了一个测试,以便它失败,以下输出是结果:

修改一个测试以使其失败,并运行 Jest,会产生一个包括未达预期、失败测试等的列表

在上述屏幕截图中,我们可以看到一个测试失败,在restful_regions.test.js文件中,显示期望获得 204 结果,但实际收到了 404 错误。该文件标有红色的FAIL消息;其他两个文件标有绿色的PASS。在我们的情况下,这是因为我们故意编写了一个失败的测试,但在现实生活中,如果测试之前一切正常,现在失败了,这意味着有人搞乱了代码并意外引入了错误。(公平地说,还存在测试当时不完全正确,而被测试的函数实际上是正确的可能性!)无论如何,获得红色结果意味着代码不能被视为准备就绪,需要更多的工作。

还有更多....

如果您需要模拟一些无法(或不愿意)将其作为参数注入到函数中的包,可以向 Jest 提供完整的模拟版本。假设您想要模拟"fs"包:您将首先在node_modules的同一级别创建一个__mocks__目录,然后在那里编写和放置您的手动模拟代码,最后您将在测试文件的开头指定jest.mock("fs"),以便Jest将使用您的模块而不是标准模块。

所有这些都可能变成一项琐事,所以最好尝试将所有模块作为参数提供给您的函数(就像我们在删除区域时使用dbConn一样),以便可以使用标准模拟。但是,如果您无法这样做,请查看facebook.github.io/jest/docs/en/manual-mocks.html获取更多信息。

测量您的测试覆盖率

好吧,您已经编写了很多测试,但实际上您测试了多少代码?这种测试质量(广度)的度量称为覆盖率,并且很容易确定;在本教程中,让我们找出如何做到这一点。幸运的是,鉴于我们所做的所有工作,这将是一个非常简单的教程。

如何做...

要使Jest生成覆盖率报告,显示哪些部分的代码被您的测试覆盖(或未覆盖),您只需在package.json文件中的相应脚本中添加一对参数即可:

 "test": "jest out/ --coverage --no-cache"

在上一行代码中,第一个参数--coverage告诉Jest收集所有必要的信息,第二个参数--no-cache确保所有信息都是最新的;在某些情况下,当省略此参数时,可能会产生不完全正确的结果。这如何影响测试?让我们看看!

它是如何工作的...

使用覆盖率运行Jest的关键区别是控制台中添加了不同的报告,并且还构建了一个 HTML 页面。首先,让我们检查前者:查看以下屏幕截图-再次,我承认在黑白中看到颜色真的很难!

在运行 Jest 时包括覆盖率选项会产生对测试的更详细的分析

对于每个文件,您将获得以下信息:

  • %Stmts:由于您的测试至少执行了一次的语句百分比。理想情况下,每个语句都应该至少执行一次;否则,任何未执行的语句都可能是任何内容,您都不会意识到。

  • %Branch:采取的分支百分比。这与%Stmts的推理类似-如果有一些分支(例如,else)从未被采取,这意味着您的代码中存在一些可能执行任何操作的路径。

  • %Funcs:文件中调用的函数百分比。

  • %Lines:覆盖的行数百分比。请注意,一行可能有几个语句,因此%Lines始终大于或等于%Stmts

  • 未覆盖的行号:这不是行数(几十亿!?),而是从未执行的特定行的数字。

在我们的案例中,我们发现validate_user.js中的所有函数都经过了测试,但是roundmath.js中的一半函数被忽略了(我们测试了addR()divR(),但忘记了subR()multR(),所以是正确的),而restful_regions.js中只测试了一个函数(DELETE处理程序)。获得更好的覆盖率意味着更多的工作,而且从经济角度来看,追求 100%可能并不总是明智的(80%-90%是常见的),但 25%或 50%绝对太低了,因此需要更多的工作。

更有趣的部分是,您可以通过查看项目的coverage/lcov_report/目录并在浏览器中打开index.html来深入分析测试运行情况,如下面的屏幕截图所示:

Web 覆盖率报告的主页显示的数据基本上与控制台运行相同

首先,您可以看到不同颜色的文件:通常,红色表示不太好的结果,绿色是最好的结果。有趣的部分是,如果您点击文件,您将获得详细的分析,包括每一行,是否执行或未执行等:

您可以看到哪些行被执行,哪些被忽略,以及为什么没有达到 100%

在我们的案例中,即使我们认为我们已经覆盖了deleteRegion()中的所有情况,屏幕显示我们错过了一个可能的情况:SQL 服务器无法回答。当然,是否为此包含特定测试是您必须做出的决定:至少我们可以看到所有最重要的代码都被覆盖了,但不要忘记同一文件中未经测试的其他函数!

调试您的代码

在某个时候,您将不得不调试您的代码。您可能只需使用一点日志记录就足够了(使用控制台对象,正如我们在使用 Winston 添加日志记录部分中所看到的那样),但使用更强大的调试器会很有帮助。在这个教程中,让我们看看如何使用断点、变量检查等进行实时调试,这样您就不会仅仅通过查看控制台日志来推断出问题所在。

如何做...

有两种调试方法;让我们在这里看看这两种方法。

如果您只想留在您的 IDE 中,Visual Studio Code 允许您直接开始调试会话。只需单击要运行的代码(提醒:选择 out/目录中的代码,并不要忘记使用npm run build),然后在菜单中选择 Debug | Start Debugging。窗口将如下所示:

您可以在 Visual Studio Code 中直接开始调试会话

或者,如果您宁愿继续使用 Chrome 的您最喜欢的开发人员工具,那么您可以使用另一种方法。首先,在 Chrome 中,搜索N.I.M.Node.js V8 Inspector Manager,可以在chrome.google.com/webstore/detail/nodejs-v8-inspector-manag/gnhhdgbaldcilmgcpfddgdbkhjohddkj找到,并将其添加到您的浏览器中。

这样做后,通过转到about:inspect打开N.I.M.控制台,您将获得如下屏幕截图所示的内容:

N.I.M.扩展允许您使用 Chrome 的开发人员工具调试 Node 会话

现在,您只需转到 VSC 或 shell 会话,并运行您的代码。在执行此操作之前,添加--inspect选项,如node --inspect out/restful_server.js。您将收到以下输出:

要将 Node 连接到 Chrome 的开发人员工具,您必须使用额外的--inspect选项运行您的代码

之后,将打开一个窗口,您将完全访问 Chrome 的调试器控制台,如下面的屏幕截图所示:

如果您在 Chrome 的调试器中检查 URL,您会看到类似chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=...的东西,后面跟着一个 URL 和一个(长)十六进制数。这些值在运行Node时使用--inspect列出,在以"Debugger listening on ws..."开头的行中。

如果 N.I.M.已启用,您的 Node 会话将连接到它,并且您将能够从 Chrome 内部调试您的代码

最后,在任何情况下,您都准备好开始一个严肃的调试会话;让我们看看您能做些什么。

如果您想了解代码检查的工作原理,请阅读以下文章:nodejs.org/en/docs/guides/debugging-getting-started/。这也为您提供了在其他 IDE 中进行调试的提示。

它是如何工作的...

在前面的屏幕截图中,无论是 VSC 还是 Chrome,我都打开了out/restful_regions.js文件,并在进行SELECT以获取一些地区的地方设置了一个断点。对/regions/uy的请求导致运行在这一点暂停。然后您可以执行以下操作:

  • 检查所有变量,包括块、局部和全局变量——这包括修改它们的值的可能性,如果您想的话

  • 添加一些变量或表达式以进行监视;每当执行暂停时,您将看到它们的值

  • 查看调用堆栈

  • 设置一些特定的断点

关于程序执行,您可以执行以下操作:

  • 在任何断点处停止执行

  • 重新开始执行

  • 逐步执行您的代码,选择下钻以分析函数调用

如果您使用 Chrome,您将能够获得一些额外的选项,比如内存使用分析或代码执行分析,但显然这些特定于 Web 的选项不会有任何好处。然而,通过使用检查选项来调试您的代码是一个非常好的帮助,所以要习惯它;您会非常感激它!

从命令行测试简单服务

每当您创建服务时,您都需要一种测试它们的方法。到目前为止,我们已经看到了一些使用curl进行测试的例子。因此,在这个教程中,让我们深入一点,看看一些您可能会发现有用的选项。或者,您可以选择另一个工具,比如wget。对于我们的目的,这两个选项都差不多,允许我们做我们需要的 RESTful 服务测试:它们是可脚本化的,它们可以下载东西,它们也可以发送请求来发布数据,所以您使用的将主要是个人偏好的问题。

如果您想了解更多关于curl的信息,请访问其网站curl.haxx.se/,或者查看源代码github.com/curl/curl。您可能还对Everything Curl感兴趣,这是一本详细介绍这个工具的书籍,可以在www.gitbook.com/download/pdf/book/bagder/everything-curl免费获取——但是,请注意它有 300 多页长!

准备工作

如何安装curl将取决于您的操作系统,但它几乎适用于您可能使用的每个平台;只需查看curl.haxx.se/download.html上的所有下载。该命令有数十种可能的选项,但对于我们的意图,我们将查看以下表格。请注意,大多数选项都有两个版本:一个短的,单个字符的版本,和一个更长的版本,旨在更清晰地理解:

-K filename``--config filename 允许您指定一个包含选项的文件的名称,以便您的命令更短。在给定的文件中,每个选项将在不同的行中。
-d key=value``--data key=value 允许您在请求的正文中发送数据。如果您多次使用此选项,curl将使用&作为分隔符,作为标准。
--data-binary someData 类似于--data,但用于发送二进制数据。最常见的是后面跟着@filename,意思是将发送命名文件的内容。
-D filename``--dump-header filename 将接收到的数据的标头转储到文件中。
-H "header:value"``--header "header:value" 允许您设置并发送请求的某些标头。您可以多次使用此选项来设置多个标头。
-i``--include 在输出中包含接收数据的标头。
-o filename``--output filename 将接收到的数据存储在给定的文件中。
-s``--silent 最小化控制台输出。
-v``--verbose 最大化控制台输出。
-X method``--request method 指定将使用的 HTTP 方法,例如GETPOSTPUT等。

最后,如果您需要帮助,请使用curl --helpcurl --manual,您将得到该实用程序及其选项的完整描述。现在让我们看看如何使用curl来测试我们的服务。

如何做到...

让我们为上一章中创建的 RESTful 服务器进行一整套测试,包括 JWT 在内的所有选项都已启用,您会记得,我们为了简化代码而删除了 JWT!让我们按照以下步骤进行:

首先,我们可以验证服务器是否正在运行;/路由不需要令牌。请记住我们使用的是8443,实际上是 HTTPS:请求将发送到该端口:

> curl localhost:8443/
Ready

现在,如果我们尝试访问某个区域,我们将被拒绝,因为缺少授权的 JWT:

> curl localhost:8443/regions/uy/10 
No token specified

  • 如果该行以*开头,则是curl本身的一些信息

  • 如果该行以>开头,则是请求中发送的标头

  • 如果该行以<开头,则是接收到的标头

在下面的清单中,我突出显示了传入的数据:

> curl localhost:8443/regions/uy/10 --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8443 (#0)
> GET /regions/uy/10 HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.59.0
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
< X-Powered-By: Express
< Access-Control-Allow-Origin: *
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 18
< ETag: W/"12-s2+Ia/H9PDrgc59/6Z0mcWLfxuw"
< Date: Sun, 03 Jun 2018 21:00:40 GMT
< 
* Closing connection 0
No token specified

我们可以通过使用/gettoken路由并提供userpassword值来获取令牌。让我们将接收到的令牌存储在一个文件中,以简化未来的测试。

> curl localhost:8443/gettoken -d "user=fkereki" -d "password=modernjsbook" -o token.txt 
 % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current 
 Dload  Upload   Total   Spent    Left  Speed 
100   187  100   153  100    34   149k  34000 --:--:-- --:--:-- --:--:--  182k 

> cat token.txt 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs

现在我们可以尝试一个简单的GET。我们可以在标头中剪切和粘贴令牌,或者在 Linux 系统中至少使用一些 shell 功能,并利用反引号选项将令牌文件的内容包含在请求中:

> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs" 
[{"countryCode":"UY","regionCode":"10","regionName":"Montevideo"}]

> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`" 
[{"countryCode":"UY","regionCode":"10","regionName":"Montevideo"}]

我们所剩下的就是尝试其他路由和方法。让我们将蒙得维的亚的名称更改为 MVD,实际上这是其国际机场的 IATA 代码;我们首先进行PUT(应该产生一个 204 状态码),然后进行GET以验证更新:

> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`" -X PUT -d "name=MVD" --verbose 
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8443 (#0) 
> PUT /regions/uy/10 HTTP/1.1 
> Host: localhost:8443 
> User-Agent: curl/7.59.0 
> Accept: */* 
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs 
> Content-Length: 8 
> Content-Type: application/x-www-form-urlencoded 
> 
* upload completely sent off: 8 out of 8 bytes 
< HTTP/1.1 204 No Content 
< X-Powered-By: Express 
< Access-Control-Allow-Origin: * 
< Connection: close 
< Date: Sun, 03 Jun 2018 21:09:01 GMT 
< 
* Closing connection 0

> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`"
[{"countryCode":"UY","regionCode":"10","regionName":"MVD"}]

在一个实验中,我创建了一个新的区域,编号为 20。让我们删除它,并验证它是否已经消失,然后再进行另一个GET。第一个请求应该得到一个 204 状态,第二个请求应该得到一个 404,因为该区域将不再存在:

> curl localhost:8443/regions/uy/20 -H "Authorization: Bearer `cat token.txt`" -X DELETE --verbose  
*   Trying 127.0.0.1... 
* TCP_NODELAY set 
* Connected to localhost (127.0.0.1) port 8443 (#0) 
> DELETE /regions/uy/20 HTTP/1.1 
> Host: localhost:8443 
> User-Agent: curl/7.59.0 
> Accept: */* 
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs 
> 
< HTTP/1.1 204 No Content 
< X-Powered-By: Express 
< Access-Control-Allow-Origin: * 
< Connection: close 
< Date: Sun, 03 Jun 2018 21:12:06 GMT 
< 
* Closing connection 0 

> curl localhost:8443/regions/uy/20 -H "Authorization: Bearer `cat token.txt`" -X DELETE --verbose  
.
. *several lines snipped out*
.
< HTTP/1.1 404 Not Found 
.
. *more snipped lines*
.
Region not found

最后,让我们发明一个新的区域来验证POST也起作用;应该返回 201 状态,以及新的 ID(在我们删除了之前发明的第 20 个乌拉圭区域后,应该是 20):

> curl localhost:8443/regions/uy -H "Authorization: Bearer `cat token.txt`" -X POST -d "name=Fictitious" --verbose 
.
. *lines snipped out*
.
< HTTP/1.1 201 Created 
< X-Powered-By: Express 
< Access-Control-Allow-Origin: * 
< Connection: close 
< Location: /regions/uy/20 
.
. *snipped lines*
.
Region created

> curl localhost:8443/regions/uy -H "Authorization: Bearer `cat token.txt`" 
[{"countryCode":"UY","regionCode":"1","regionName":"Artigas"},{"countryCode":"UY","regionCode":"10","regionName":"MVD"},
.
. *snipped out lines*
.
{"countryCode":"uy","regionCode":"20","regionName":"Fictitious"},
.
. *more snipped out lines*
.
{"countryCode":"UY","regionCode":"9","regionName":"Maldonado"}]

因此,通过使用curl和一些控制台工作,我们可以开始测试任何类型的服务。然而,在某些时候,您可能需要处理更复杂的服务调用序列,并且手动完成所有这些工作可能会变得繁琐。通过谨慎的脚本编写,您可以简化工作,但让我们考虑另一个工具 Postman,它更适合这种工作。

使用 Postman 测试更复杂的调用序列

手动测试服务,甚至使用精心制作的 shell 脚本也不是很容易。此外,如果您需要进行某种复杂的测试,使用脚本可能会变得太难。Postman可以用来为服务编写测试,将它们组织成完整的测试套件,并记录您的 RESTful API 的工作方式。您还可以用它来模拟服务或作为开发的帮助,但我们不会在这里讨论这些。在这个配方中,我们将专注于测试方面。

准备就绪

www.getpostman.com/下载Postman,并根据每个平台的说明进行安装。记得查看其文档,了解更多我们这里不会看到的功能。

如何做...

Postman 允许您创建可以存储在集合中的请求。在每个请求之前和之后,您可以执行 JavaScript 代码,无论是为即将到来的请求设置,处理结果响应,还是为测试序列中未来的请求存储一些信息。让我们看看以下部分。

进行基本请求

首先,我们将从一个简单的测试开始,以获取 JWT,然后将其存储,以便我们可以在即将进行的测试中使用。打开Postman应用程序,点击 New 创建一个请求。给它一个名称和描述,然后选择或创建一个集合或文件夹来保存它。不要太担心实际的放置位置;您可以移动请求,编辑它们,等等。

然后,为了获取令牌,我们需要一个POST,所以设置方法。选择 BODY 选项卡,选择x-www-form-urlencoded选项,并添加两个值,userpassword,这些值将随请求一起发送。(对于其他情况,您可能会发送原始数据,如 XML 或 JSON,或二进制数据,如文件。)查看以下截图:

创建一个 POST 请求来获取 JWT

现在,如果您点击发送进行测试,请求将发送到服务器,答复将出现在屏幕底部:

我们的请求测试显示一切正常运行

添加一些检查

然而,这还不够。我们不仅想检查/gettoken端点是否工作,我们还想测试令牌是否正确,并且如果正确,存储它以便后续请求可以使用。我们将创建一个环境(点击右上角的齿轮图标)并添加一个token条目,以便我们可以存储和检索从服务器获取的值:

创建环境是您可以在请求之间共享数据的一种方式

最后,让我们为令牌编写一些测试,并将其值存储在环境中。编写测试本身与我们已经做过的有些类似,但您需要查看文档,了解可用的对象和方法。至于测试本身,它们使用Chai(参见www.chaijs.com/),在编写您的期望方面类似于Jest,但并非完全相同:

pm.test("Response is long enough", () => 
    pm.expect(pm.response.text()).to.have.lengthOf.above(40)); 

pm.test("Response has three parts", () => 
    pm.expect(pm.response.text().split(".")).to.have.lengthOf(3));

pm.environment.set("token", pm.response.text()); // for later scripts

首先,我们将测试答复至少应该有 40 个字节长;令牌没有特定的大小限制,但 40 个字符偏低。然后,第二个测试将检查令牌由三部分组成,由句点分隔。最后,我们将把响应本身存储在环境中,以备将来使用。如果您检查 TESTS 选项卡,您会看到我们的两个测试都通过了,如下截图所示:

我们创建的两个测试都成功了

链接请求

如果您检查环境,您会看到令牌已经存储。现在让我们写一个第二个测试,一个GET,将使用令牌。我通过对/regions/uy进行请求,但我在标头中添加了一行,使用Authorization键和Bearer {{token}}值,以便以前存储的令牌值将替换标头中的值。我还添加了一些测试,以确保(1)我得到了成功的 JSON 答复,以及(2)答复是至少 19 个地区的数组。(是的,我知道我的国家乌拉圭确实有 19 个地区,但有时,出于测试目的,我可能会添加一些新的!)这些测试显示了一些我们以前没有见过的功能:

pm.test("Answer should be JSON", () => {
    pm.response.to.be.success;
    pm.response.to.have.jsonBody(); 
});

pm.test("Answer should have at least 19 regions", () => {
    const regions = JSON.parse(pm.response.text());
    pm.expect(regions).to.have.lengthOf.at.least(19);
});

通过这种方式,您可以创建完整的请求序列;确保获取 JWT 放在列表中较早的位置。在一个集合中,您还可以有许多文件夹,每个文件夹都有一个不同的步骤序列。(您也可以通过程序更改序列,但我们不会在这里讨论这个问题;请查看www.getpostman.com/docs/v6/postman/scripts/branching_and_looping获取更多信息。)

我创建了两个文件夹来测试一些GET和一个DELETE,但是,当然,您应该编写更多的测试来验证每种方法,以及尽可能多的不同序列。让我们看看如何使它们运行。

工作原理...

一旦您将请求组织到文件夹中,您可以通过单击左侧边栏上的文件夹来运行任何给定的序列。如果一切正常,所有测试都将获得绿色标记;红色标记表示存在问题:

运行一个集合会运行其中的每个测试。绿色块表示成功;红色块表示错误。

有了这个,您已经有了一个很好的工具来记录您的 API(确保每个测试和字段都有解释),并确保它保持工作状态,超越单元测试进入完整的端到端(E2E)测试。

根据您的Postman帐户,您还可以设置定期监视您的 API;请查看www.getpostman.com/docs/v6/postman/monitors/intro_monitors获取更多信息。

还有更多...

通过使用newman包(使用npm install newman --save-dev进行安装),您可以从命令行运行您的Postman测试,这也可以让您将其包含在持续集成工作流程中。首先,从Postman中导出您的集合(我无聊地称之为postman_collection.json),然后在您的package.json文件中添加一个名为"newman":"newman run postman_collection.json"的新脚本。然后使用npm run newman将产生类似于以下代码片段中所示的输出。您还可以测试所有测试是否都运行良好,或者是否存在问题:

> npm run newman

> simpleproject@1.0.0 newman /home/fkereki/MODERNJS/chapter05
> newman run postman_collection.json

newman

Restful server testing for regions

❏ Test Delete
↳ Get JWT
  POST localhost:8443/gettoken [200 OK, 386B, 14ms]
  ✓ Response is long enough
  ✓ Response has three parts

↳ Delete non-existing region
  DELETE localhost:8443/regions/zz/99 [404 Not Found, 255B, 4ms]
  ✓ Status code is 404 baby!!

❏ Test Get
↳ Get JWT
  POST localhost:8443/gettoken [200 OK, 386B, 2ms]
  ✓ Response is long enough
  ✓ Response has three parts

↳ Get /regions/uy
  GET localhost:8443/regions/uy [200 OK, 1.46KB, 2ms]
  ✓ Answer should be JSON
  ✓ Answer should have at least 19 regions

↳ Get /regions/uy/10
  GET localhost:8443/regions/uy/11 [200 OK, 303B, 2ms]
  ✓ Answer has a single region
  ✓ Country code is UY
  ✓ Region code is 11
  ✓ Region name is Paysandu
  ✓ Answer is valid, JSON

使用 Swagger 文档和测试您的 REST API

现在让我们更专注于使用一个众所周知的工具进行文档编写和测试:Swagger。这是一个旨在帮助您设计、建模和测试 API 的工具。关键思想是,最终您将拥有一个在线交互式文档,其中将详细描述所有 API 调用、参数类型和限制、必需和可选值等,甚至让您可以即时尝试调用,以更好地理解 API 的使用方式。

如何做...

设置Swagger的第一步,也是最难的一步是准备完整 API 的规范。这意味着要用YAML Ain't Markup Language(YAML)编写,可能很难搞定。但是,您可以使用 Web 编辑器,可以在自己的服务器上运行(转到swagger.io/tools/swagger-editor/进行必要的下载)或在线运行editor.swagger.io。然而,在写完之后,设置一切将变得非常容易,只需要三行代码!

YAML 是一个递归缩写,代表YAML Ain't Markup Language。如果您想了解更多信息,请访问yaml.org/

编写我们的规范

我们无法在这里介绍编写 API 规范的全部规则,也无法在我们的示例中包含所有功能。此外,任何 API 的完整描述可能长达数百行,这是另一个问题。因此,让我们先了解一些基本定义,以及一些服务,以了解需要做些什么。首先,我们需要一些关于我们服务器的基本数据:

swagger: "2.0"
info:
  description: "This is a RESTful API to access countries, regions, and cities."
  version: "1.0.0"
  title: "World Data API"

host: "127.0.0.1:8443"
schemes:
- "http"

然后我们必须描述标签(考虑部分),我们的文档将被分成。我们使用令牌(用于安全性),加上国家、地区和城市,因此这些似乎是所需的定义:

tags:
- name: "token"
  description: "Get a JWT for authorization"
- name: "countries"
  description: "Access the world countries"
- name: "regions"
  description: "Access the regions of countries"
- name: "cities"
  description: "Access the world cities"

让我们看看/gettoken路由。我们定义了一个 POST 请求,它获取编码的参数,并返回纯文本。需要两个字符串参数,userpassword。如果一切正常,API 可能会返回 200 状态,否则返回 401:

paths:
 /gettoken:
    post:
      tags:
      - "token"
      summary: "Get a token to authorize future requests"
      consumes: 
        - "application/x-www-form-urlencoded"
      produces:
        - text/plain
      parameters:
        - in: formData
          name: user
          required: true
          type: string
        - in: formData
          name: password
          required: true
          type: string
      responses:
        200:
          description: A valid token to use for other requests
        401:
          description: "Wrong user/password"

获取一个国家的地区将得到类似的规范:

/regions:
  get:
    tags:
    - "regions"
    summary: "Get all regions of all countries"
    produces:
      - application/json
    parameters:
      - in: header
        name: "Authorization"
        required: true
        type: string
        description: Authorization Token
    responses:
      200:
        description: "OK"
      401:
        description: "No token provided"

启用 Swagger

要启用Swagger文档,我们需要swagger-ui-express包,并且还需要加载 YAML 规范的 JSON 版本,因此您需要几行代码。首先,使用通常的npm install swagger-ui-express --save安装包,然后将以下行添加到您的服务器:

const swaggerUi = require("swagger-ui-express");
const swaggerDocument = require("../swagger.json");

在服务器上,我们还必须添加一行以启用新路由,在其他app.use()语句之后。我们正在为我们的 RESTful API 添加Swagger,并且没有令牌:您可能更喜欢设置一个不同的服务器,只提供对 API 的访问,并可能还启用授权,但这两个更改都很容易实现。所以,让我们在这里选择更简单的版本:

app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));

您已经准备好了!重新构建项目并启动服务器后,新路由将可用,为您的服务器提供在线文档。

它是如何工作的...

如果您启动服务器,访问/api-docs路由将提供访问主Swagger屏幕,如下所示:

Swagger 生成一个主页面,可以访问您定义的每个路由

交互很容易:选择一个区域,点击一个给定的请求,您将获得所有路由和操作的列表。例如,让我们看看如何获取乌拉圭的地区。首先,我们必须获取一个令牌,所以我们想要打开令牌区域,并输入必要的用户和密码,如下面的截图所示:

发出请求只是填写字段并执行查询的问题

当进程运行时,您将得到答案,如下面的截图所示:

成功的请求返回了安全令牌

您可以在顶部看到等效的curl请求,与本章前面所做的内容相匹配,在从命令行测试简单服务部分。现在,复制该令牌并粘贴到/regions/uy端点意味着我们已经准备好进行查询了:

获得令牌后,我们可以设置查询以获取一个国家的所有地区。

剩下要做的就是执行该查询,我们将得到所需的结果,如下面的截图所示:

可以进行一系列调用,Swagger 让您轻松尝试不同的端点

我们可以指出什么?首先,显然,Swagger是一个非常好的工具,就文档而言。您可以为方法、参数、结果添加描述,甚至包括示例值和结果。这意味着需要使用您的 API 的开发人员将有一个非常好的学习如何使用它的方式。就实际使用您的 API 而言,SwaggercurlPostman更简单,但它不能链操作,这将由您自己完成。您应该真的考虑使用这个工具开始您的开发,并且只有在您把一切都记录下来后才继续实际编码;试一试吧!

第六章:使用 React 进行开发

在本章中,我们将涵盖以下内容:

  • 开始使用 React

  • 重新安装您的工具

  • 定义组件

  • 处理状态

  • 组合组件

  • 处理生命周期事件

  • 使用 Storybook 简化组件开发

介绍

在最后三章中,我们正在使用Node开发后端,现在我们将转向前端,构建一个 Web 应用程序:特别是一种现代风格的单页应用程序SPA),用户已经习惯了。

开始使用 React

假设您想构建一个 Web 应用程序。您会如何做?除非您一直躲在某个地方,否则您可能已经意识到,有许多框架可以帮助您构建和组织您的网页。但是,您可能会想知道,如果您已经了解 HTML、CSS 和 JS,为什么要使用框架,而不是继续使用纯 JS,可能还有一些库,如jQueryLodash?毕竟,框架会施加一些规则和工作方式,您可能会认为这些规则令人讨厌或麻烦。

当然,您还必须学习如何使用框架,而且直到您熟练掌握它之前,您可能不会从中受益。因此,对于为什么?这个问题有几种可能的答案,甚至包括当然,不使用任何框架!,这对于一个非常小的、简单的项目可能也是可以的:

  • 框架为您提供了一个经过充分测试的、稳固的方式来组织您的项目

  • 框架通常更适合大型应用程序

  • 框架让您在更高级的抽象层次上工作(例如,创建和使用自己的组件),并处理让一切正常运行的琐碎方面

  • 培养新开发人员通常更简单:如果他们了解框架,他们已经知道应该放置在哪里以及它们如何相互作用

当然,正如我之前提到的,所有这些优势并不适用于小型项目,只有少数开发人员。

然而,还有一个更重要的原因。框架帮助您解决保持状态(数据)和视图同步的困难任务。对于大型应用程序,应用程序中一个角落发生的更改或事件可能会对应用程序的其他地方产生影响。试图将所有更改正确传播到代码中并不是一件简单的事情。

大多数框架会自动生成视图,从数据中获取,每当状态发生变化时,它们会以最佳方式更新屏幕。例如,假设您在某处有一个 doodads 列表。然后,您调用一个网络服务,获取更新后的列表——大多数 doodads 匹配,但有一些被添加了,有一些缺失了。当然,您可以从零开始重新创建列表,但那看起来不太好,如果您决定每次发生变化时重新生成整个屏幕,性能会受到影响。通常情况下,框架会计算当前列表和新列表之间的差异,并相应地更新 HTML 代码,添加或删除 DOM 元素,以使列表再次正确。手动完成所有这些工作,并将其扩展到整个应用程序,将是一件有点太多的事情!

有几个知名的框架,如Angular(由 Google)、VueEmberBackboneKnockout等等。(有时候你会觉得每天都有一个新的框架诞生!)我们将在本书中使用React(由 Facebook)。

承认一点:React更正确地称为而不是框架,因为它不包含您开箱即用开发应用程序所需的一切。然而,所有必要的包都在那里,所以这不会妨碍我们。顺便说一句,这种批评也适用于VueKnockoutBackbone

React还可以用于使用React-Native创建移动应用程序,我们将在本书的第十一章中看到,使用 React Native 创建移动应用程序

一篇有趣的文章,JavaScript 框架的终极指南,在javascriptreport.com/the-ultimate-guide-to-javascript-frameworks/列出了 50 多个框架!看一看,看看每个框架有什么优缺点。

在这个步骤中,我们将安装必要的包并构建我们自己的非常基本的第一个 Web 应用程序。

如何做...

让我们继续创建我们的基本应用程序。如果你不得不纯手工设置一个项目,你会发现自己不得不处理许多不同的工具,比如Babel用于转译,ESLint用于代码检查,Jest用于测试,或者Webpack用于将你的整个应用程序打包在一起,而不是不得不将数十个或数百个单独的文件发送到网络上。然而,如今,有一个更简单的工具,create-react-app,可以处理这个繁琐的工作,并让你迅速开始React开发。其关键卖点是零配置,这意味着已经选择了一些合理的好选择,用于开发和生产构建,并且你可以直接开始编写代码,而不必关心无数的配置细节。

对于内行的人来说,create-react-app被称为 CRA,这就是我们将要使用的名称。顺便说一句,CRA 并不是创建项目的唯一可能方式;例如,react-boilerplate(在github.com/react-boilerplate/react-boilerplate)提供了一个替代方案,但所选择的一套包和工具更适合有经验的React开发人员。

要创建基本结构(我们稍后会解释),我们将使用npx来运行应用程序创建工具,如下面的代码所示。由于我们在第六章,让我们(想象!)将我们的项目命名为chapter06

> npx create-react-app chapter06 Creating a new React app in /home/fkereki/JS_BOOK/modernjs/chapter06.

Installing packages. This might take a couple minutes.
Installing react-scripts...

*...many lines describing installed packages, snipped out...*

Success! Created chapter06 at /home/fkereki/JS_BOOK/modernjs/chapter06
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd chapter06
  npm start

Happy hacking!

如果你好奇,npx类似于npm,但它执行一个二进制命令,该命令要么在你的node_modules目录中找到,要么在一个中央缓存中找到,甚至安装它可能需要运行的任何包。有关更多信息,请访问其 GitHub 页面github.com/zkat/npx,或者更好的是,阅读 npx 的创建者的一篇文章,Introducing npx: an npm package runner medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b

它是如何工作的...

运行脚本将创建一个基本的项目结构,包括以下内容:

  • 一个package.json文件,以及一个相应的node_modules目录。

  • 一个README.md文件,基本上是你可以在github.com/wmonk/create-react-app-typescript/blob/master/packages/react-scripts/template/README.md找到的内容的副本。特别注意它,因为它充满了提示、建议和解决你可能遇到的常见问题。

  • 一个public/目录,其中包含应用程序的index.html基本 HTML 代码,以及一个favicon.ico图标文件和一个描述你的应用程序的manifest.json文件。(如果你想了解更多关于后者的信息,请查看developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json。)

  • 一个src/目录,其中包含你的应用程序的index.js基本代码,带有 CSS 样式的index.css,以及显示一些欢迎文本和一些基本说明的App组件。你所有的 JS 和 CSS 文件都应该放在src/或其子目录中,否则它们将不会被包含在构建中。

基本上,你会想要编辑index.*App.*文件,并通过扩展其结构来提供更多的组件、样式等来扩展项目。(注意:不要更改index.*文件的名称,否则你的项目将无法运行!)在开始编写代码之前,就像前面的运行所示,在创建的项目目录中,你应该尝试npm start

通过这样做,你将能够看到新的应用程序,就像下面的截图所示:

创建的应用程序,准备开始编码

如果你愿意,你可以在App.js中进行任何小的更改,保存它,然后注意浏览器页面的立即变化。关于你可以在编码中使用的 JS 功能,项目已经设置为接受大多数现代选项,从 ES6(完整)、ES7(如指数运算符,你可能永远不会使用!),甚至更新的(最有趣的是asyncawait),再加上一些Stage 3提案;查看github.com/facebook/create-react-app/blob/master/packages/react-scripts/template/README.md#supported-language-features-and-polyfills获取更新的列表。值得注意的是,Flow 是被认可的,还有 JSX,我们将在后面的部分中使用。

还有更多...

可能会发生这样的情况,有时你需要做一些额外的配置,CRA 没有考虑到,或者你无法以其他方式添加它。在这种情况下,你可以使用npm run eject命令将所有配置、脚本和依赖项直接移动到你的项目中,这样你就可以按照自己的方式开始调整事情。当然,这将比使用零配置设置更困难,但至少你不会被锁定,没有出路。

如果你好奇想知道所有这些东西都藏在哪里,可能想研究一下所有设置是如何进行的,答案是“在node_modules/create_react_app”目录中;弹出项目会将这个目录中的东西复制到你的项目中。

重新安装你的工具

我们一直在使用ESLint进行代码质量检查,Prettier进行格式化,Flow进行数据类型检查。在这个教程中,我们将重新使用这些包,并且将测试(Jest等)留到第十章,测试你的应用程序。对于我们的两个工具来说,这将是非常简单的,但对于第三个工具来说会稍微复杂一些。

如何做...

通过完全手动安装,让所有东西一起工作将会非常困难,但 CRA 已经包含了我们需要的几乎所有东西,所以你只需要添加一些配置细节。

重新安装 Flow 和 Prettier

让我们从Flow开始。这很简单:我只是像对Node做的一样,添加了相同的包、脚本、.flowconfig文件等。(如果需要,查看第一章,使用 JavaScript 开发工具中的添加 Flow 进行数据类型检查部分获取更多信息。)

接下来,让我们处理Prettier。这也很简单:我不得不从package.json中删除以下行,并将它们放在一个单独的.prettierrc文件中:

{
    "tabWidth": 4,
    "printWidth": 75
}

Flow已经知道关于React和 CRA 的一切,所以在这方面你不需要任何东西。然而,要使用PropTypes(我们很快就会讲到),你需要适当的 flow-typed 包,这很容易安装:

npm run addTypes prop-types@15

重新安装 ESLint

最后,我们的第三个工具需要更多的工作。对于ESLint,我们也不能使用package.json,我们需要一个.eslintrc文件。但是,即使你提取了那部分,你会发现配置并没有关注你的设置,这是因为 CRA 有自己一套ESLint规则,你无法更改!除非当然,你决定弹出项目并开始自己进行配置,但你会尽量避免这样做。有一个包,react-app-rewired,它允许你在不弹出的情况下更改内部配置。首先安装一些必需的包:

npm install react-app-rewired react-app-rewire-eslint --save-dev

至于规则本身,你会想要有以下内容:

npm install eslint-plugin-flowtype eslint-config-recommended eslint-plugin-react --save-dev

现在你需要在package.json中更改一些脚本:

"scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test --env=jsdom",
    "eject": "react-app-rewired eject",
    .
    .
    .

最后,在项目的根目录下创建一个config-overrides.js文件,与package.json文件处于同一级别。/* global module */的注释是为了避免在ESLint开始工作后出现一个错误,报告module未定义:

const rewireEslint = require("react-app-rewire-eslint");
function overrideEslintOptions(options) {
    // *do stuff with the eslint options...*
    return options;
}

/* global module */
module.exports = function override(config, env) {
    config = rewireEslint(config, env, overrideEslintOptions);
    return config;
};

你已经准备好了!你的.eslintrc文件应该如下所示,其中包括一些添加和更改:

{
    "parser": "babel-eslint",
    "parserOptions": {
        "ecmaVersion": 2017,
        "sourceType": "module"
    },
    "env": {
        "node": true,
        "browser": true,
        "es6": true,
        "jest": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:flowtype/recommended",
        "plugin:react/recommended"
    ],
    "plugins": ["babel", "flowtype", "react"],
    "rules": {
        "no-console": "off",
        "no-var": "error",
        "prefer-const": "error",
        "flowtype/no-types-missing-file-annotation": 0
    }
}

如果你想知道为什么我留下了Node这一行,那是因为Storybook(我们将在本章末看到)使用了一个module变量,否则它将被标记为未定义。

它是如何工作的...

在这种情况下,没有太多需要解释的。项目的正常配置已经包括了我们需要的所有工具,所以我们只是在进行一些配置,而不是按照标准进行操作。

至于ESLint,当你现在使用npm start时,ESLint配置将被重新配置以适应你的配置,而不是 CRA 的配置。这意味着你所有的标准设置和检查将继续运行,你将为 React 应用相同的质量检查运行,除了显然的 React 特定的检查。

你可以在github.com/timarney/react-app-rewired了解更多关于react-app-rewired的信息。

定义组件

使用React的关键思想是,一切 - 我的意思是,一切 - 都是一个组件。你整个的网络应用将是一个组件,它本身由其他组件组成,这些组件本身又有更小的组件,依此类推。组件生成 HTML,显示在屏幕上。HTML 的数据来自外部分配的props(属性)和内部维护的state。每当 props 或 state 发生变化时,React 会负责刷新 HTML,以便视图(用户所见的内容)始终保持最新。

让我们来看一个例子。想象一下,你想创建一个屏幕,让用户查询世界各地区域的数据。你该如何设计?查看以下屏幕截图以获取详细信息:

每当用户选择一个国家,我们将显示几张关于其地区的信息卡片。

注意:我在 http://www.wireframes.com 上创建了这个草图,但不要因为我糟糕的素描能力而责怪这个工具!

你的整个视图将是一个组件,但显然这对编码或测试并不会有所帮助。一个很好的设计原则是每个组件应负责单一职责,如果它需要做更多的事情,就将其分解为更小的组件。在我们的例子中,我们将有以下内容:

  • 整个表格是一个RegionsInformationTable

  • 顶部的部分可以是CountryFilterBar,带有一个国家的下拉菜单

  • 在底部我们有一个ResultsDataTable,显示了一系列ExpandableCard组件,每个组件都有一个标题,一个切换开关,以及更多组件的空间。我们本可以为这种情况设计一个特定的卡片,但是拥有一个通用的卡片,其组件可以是我们想要的任何东西,更加强大。

第一条规则涉及事件,例如单击元素,输入数据等。它们应该一直传递到某个组件能够完全处理它们为止:事件向上流动。例如,当用户单击按钮时,该组件不应该(也不能)完全处理它,至少因为它无法访问表格。因此,事件将通过回调传递(通过回调)直到某个组件能够处理它。您可能有选择:例如,CountryFilterBar组件可以处理调用服务并获取数据,然后将结果传递给RegionsInformationTable,以便它可以将其传递给ResultsDataTable组件,后者将生成必要的ExpandableCard元素。另一种选择是将CountryFilterBar的值传递给RegionsInformationTable,后者将自行进行搜索,或者将其传递得更高,以便某个组件进行搜索并将数据作为 props 推送到我们的大表格。

前面的解释帮助我们做出第二个决定。您应该分析组件层次结构,并决定数据(props 或 state)应该放在哪里。一个关键规则是:如果两个(或更多)组件共享数据(或者一个组件产生其他组件需要的数据),它应该属于更高级的组件,它将根据需要向下传递:数据向下流动。在我们的情况下,当我们决定区域数据将由CountryFilterBar拥有,然后传递给RegionResults表时,我们已经应用了该规则;每个ExpandableCard只能使用它接收到的 props。

即使我们还不知道如何处理 Web 服务请求以获取必要的数据(或者例如初始化国家下拉菜单),我们可以构建组件的静态版本并查看其工作原理。

最好从 Web 设计的这些静态方面开始,然后再处理动态方面,例如对事件的反应或获取数据。让我们开始编写代码。

如何做...

我们需要创建几个组件,这将使我们能够找出如何在其他组件中包含组件,如何传递属性,如何定义它们等。让我们逐个组件地进行。

创建应用程序

要启动一个React应用程序,我们只需要一个基本的 HTML 页面,CRA 已经在public/index.html中提供了一个。简化到基础部分(查看完整版本的书源代码),它大致如下,关键部分是<div>,其中将放置所有React生成的 HTML 代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        .
        .
        .
        <title>React App</title>
    </head>
    <body>
 <div id="root"></div>
    </body>
</html>

我们应用程序的入口将是index.js,它(我们在这里省略了一些无关紧要的代码行)归结为以下代码:

/* @flow */

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

const root = document.getElementById("root");
if (root) {
    ReactDOM.render(<App />, root);
}

为什么我们需要定义一个root变量和if?关键是Flow检查:document.getElementById(...)调用可能会产生一个 Web 节点,也可能为空,并且Flow提醒我们在承诺工作之前检查空值。

现在我们有了基本的脚手架,让我们开始编写一些实际的React组件!

创建基本的 App 组件

让我们从App.js文件开始;我们将呈现一个简单的RegionsInformationTable。我们正在扩展一个名为PureComponentReact类;我们稍后会解释这意味着什么。您自己的组件名称应以大写字母开头,以区别于应该以小写字母开头的 HTML 名称。每个组件都应该有一个.render()方法,用于生成所需的 HTML;还有更多方法可以用于此,我们将会看到:

/* @flow */

import React from "react";
import { RegionsInformationTable } from "./components/regionsInformationTable";

class App extends React.PureComponent<{}> {
 render() {
 return <RegionsInformationTable />;
 }
}

export default App;

在定义组件时必须指定的唯一方法是.render()。组件还有许多其他方法,包括几种生命周期方法,我们将在处理生命周期事件部分中看到,但它们都是可选的。

您可能会问自己:为什么要费事创建一个什么都不做,只是生成一个<RegionsInformationTable>组件的<App>组件?为什么不直接使用后者?我们将在接下来的部分中解释原因;我们希望<App>组件能做更多的事情,比如定义路由、管理存储等。因此,即使在这个特定的小例子中,这是多余的-这是我们想要保留的一种模式。

您还需要注意我们写了React.PureComponent<{}>,这是为了让Flow知道我们的组件既不需要属性也不需要状态。在后面的部分中,我们将看到更多需要更好类型定义的示例。

创建RegionsInformationTable组件

我们可以立即看到RegionsInformationTable组件是如何渲染的:它只依赖于我们决定创建的另外两个组件。请注意,我们返回 HTML 代码,就好像它是一个有效的 JS 值:这就是 JSX,它提供了一种非常简单的方式来交织 JS 代码和 HTML 代码。我们将有一个国家列表(大大减少!)据说来自于一个 Web 服务,以及一个地区列表(也减少了,带有虚假数据),它将在用户选择国家后来自不同的服务。这些数据是组件的状态;每当这些列表中的任何一个发生变化时,React 都会重新渲染组件及其包含的所有内容。我们将在处理状态部分进一步讨论这一点:

// Source file: src/components/regionsInformationTable/index.js

/* @flow */

import React from "react";

import { CountryFilterBar } from "../countryFilterBar";
import { ResultsDataTable } from "../resultsDataTable.2";

export class RegionsInformationTable extends React.PureComponent<
    {},
    {
        countries: Array<{
            code: string,
            name: string
        }>,
        regions: Array<{
            id: string,
            name: string,
            cities: number,
            pop: number
        }>
    }
> {
    state = {
        countries: [
            { code: "AR", name: "Argentine" },
            { code: "BR", name: "Brazil" },
            { code: "PY", name: "Paraguay" },
            { code: "UY", name: "Uruguay" }
        ],

        regions: []
    };

    update = (country: string) => {
        console.log(`Country ... ${country}`);

        this.setState(() => ({
            regions: [
                {
                    id: "UY/5",
                    name: "Durazno",
                    cities: 8,
                    pop: 60000
                },
                {
                    id: "UY/7",
                    name: "Florida",
                    cities: 20,
                    pop: 67000
                },
                {
                    id: "UY/9",
                    name: "Maldonado",
                    cities: 17,
                    pop: 165000
                },
                {
                    id: "UY/10",
                    name: "Montevideo",
                    cities: 1,
                    pop: 1320000
                },
                {
                    id: "UY/11",
                    name: "Paysandu",
                    cities: 16,
                    pop: 114000
                }
            ]
        }));
    }

    render() {
        return (
            <div>
 <CountryFilterBar
 list={this.state.countries}
 onSelect={this.update}
 />
 <ResultsDataTable results={this.state.regions} />
            </div>
        );
    }
}

这个组件不接收任何属性,但使用状态,因此为了Flow的缘故,我们不得不写React.PureComponent<{},{countries:..., regions:...}>,为状态元素提供数据类型。您还可以在单独的文件中定义这些数据类型(有关此内容的更多信息,请参见flow.org/en/docs/types/modules/),但我们就此打住。

关于国家列表呢?CountryFilterBar应该显示一些国家,所以父组件将作为属性提供列表;让我们看看它将如何接收和使用该列表。我们还将提供一个回调函数onSelect,子组件将使用它来在用户选择国家时通知您。最后,我们将把(虚假的,硬编码的)地区列表传递给ResultsDataTable

值得注意的是:属性是使用name=...语法传递的,与 HTML 元素的标准用法相同;您的React元素与常见的标准 HTML 元素的用法相同。这里唯一的区别是您使用大括号,以模板方式包含任何表达式。

顺便说一句,注意我们的地区列表起初是空的;结果表将不得不处理这一点。当用户选择一个国家时,.update()方法将运行,并使用.setState()方法加载一些地区,我们将在下一节中看到。在本书的后面,我们还将看到如何使用 Web 服务获取这些数据,但目前,固定的结果将不得不使用。

创建CountryFilterBar组件

我们需要的下一个组件更复杂:它接收一对属性,并且首先提供了这些属性的PropTypes定义:

// Source file: src/components/countryFilterBar.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

export class CountryFilterBar extends React.PureComponent<{
    list?: Array<{ code: string, name: string }>,
    onSelect: string => void
}> {
    static propTypes = {
        list: PropTypes.arrayOf(PropTypes.object),
        onSelect: PropTypes.func.isRequired
    };

    static defaultProps = {
        list: []
    };
// *continued...*

这是我们第一个接收属性的组件。我们将为Flow提供一个定义,很简单:组件将接收list,一个对象数组,和onSelect,一个带有单个字符串参数的函数,不返回任何内容。

React还允许您为参数定义运行时检查。我们定义了一个propTypes类属性,其中包含我们的组件将接收的每个实际属性的元素,以及另一个defaultProps属性,用于在未提供实际值时提供默认值。如果需要定义数据类型(例如,onSelect是一个函数),以及它们是必需的还是可选的(在这种情况下都是必需的)。在开发中(而不是在生产中),每当您将属性传递给对象时,它们将根据其定义进行检查,如果存在某种不匹配,将产生警告;这是一种很好的调试技术。

为什么要同时使用FlowPropTypes,如果它们似乎都是做同样的工作?基本上,Flow是一个静态检查器,而PropTypes是一个动态的运行时检查器。如果您在整个应用程序中都使用Flow,理论上,您可以不使用PropTypes - 但由于这个包在测试中会捕捉到您忽略的任何内容,它是代码的额外“安全网”。我同意写两套数据类型确实很麻烦。

这些有效的类型如下:

  • any,如果任何类型都可以接受 - 这不是一个好的做法

  • array

  • arrayOf(someType),指定数组元素的值

  • bool,用于布尔值

  • element,用于 React 元素

  • func,用于函数

  • instanceOf(SomeClass),用于必须是给定类的实例的对象

  • node,对于任何可以呈现为 HTML 的东西,比如数字或字符串

  • number

  • object

  • objectOf(SomeType),指定具有给定类型的属性值的对象

  • oneOf([...值数组...]),验证属性是否限制在某些值上

  • oneOfType([...类型数组...]),指定一个属性将是类型列表中的一个

  • shape({...具有类型的对象...}),完全定义一个对象,包括键和值类型

  • string

  • symbol

您甚至可以进一步定义,例如,用于类型验证的特定函数。有关PropTypes的所有可能性的完整解释,请阅读reactjs.org/docs/typechecking-with-proptypes.html

现在,我们如何为过滤器生成 HTML 呢?我们需要几个<option>元素,并且我们可以将.map()应用于this.props.list(通过this.props访问属性),如下所示。还要注意我们如何使用onChange回调来在选择不同的国家时通知父组件:

// *...continues*

    onSelect(e) {
        this.props.onSelect(e.target.value);
    }

    render() {
        return (
            <div>
                Country:&nbsp;
                <select onChange={this.onSelect}>
                    <option value="">Select a country:</option>
                    {this.props.list.map(x => (
 <option key={x.code} value={x.code}>
 {x.name}
 </option>
 ))}
                </select>
            </div>
        );
    }
}

输入属性(this.props)应被视为只读,永远不要修改。另一方面,组件的状态(this.state)是可读写的,可以被修改,尽管不是直接修改,而是通过this.setState(),我们将看到。

key=属性需要特别解释。每当您定义一个列表(例如<option><li>)并且 React 需要重新呈现它时,key属性用于识别已经可用的元素并避免重新生成它们,而是重复使用它们。请记住,CountryFilterBar组件将随着时间的推移以不同的国家列表呈现,因此 React 将通过避免创建已经存在的列表元素来优化其性能。

创建 ResultsDataTable 组件

构建结果表格很容易,需要的工作与我们在国家选择器中所做的类似。我们只需要检查特殊情况,即当我们没有任何地区要显示时:

// Source file: src/components/resultsDataTable.1/index.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

import { ExpandableCard } from "../expandableCard.1";
import "../general.css";

export class ResultsDataTable extends React.PureComponent<{
    results: Array<{
        id: string,
        name: string,
        cities: number,
        pop: number
    }>
}> {
    static propTypes = {
        results: PropTypes.arrayOf(PropTypes.object).isRequired
    };

    render() {
        if (this.props.results.length === 0) {
            return <div className="bordered">No regions.</div>;
        } else {
            return (
                <div className="bordered">
                    {this.props.results.map(x => (
 <ExpandableCard
 key={x.id}
 name={x.name}
 cities={x.cities}
 population={x.pop}
 />
 ))}
                </div>
            );
        }
    }
}

一个附带的评论:React允许我们将 props 定义为可选的(意味着在定义PropTypes时没有包含isRequired),并提供默认值。在这种情况下,如果结果可能被提供,您将编写以下代码,使用defaultProps来提供必要的默认值:

    static propTypes = {
        results: PropTypes.arrayOf(PropTypes.object)
    };

 static defaultProps = {
 results: []
 }

FlowPropTypes方面,定义与之前的非常相似。有趣的部分是使用.map()来处理所有接收到的对象,为每个创建一个ExpandableCard;这是 React 中非常常见的模式。因此,我们现在需要完成我们的应用程序的是提供一个可展开的卡片,所以让我们开始吧。

创建可展开卡组件

首先,让我们忘记扩展卡片 - 即使这使得组件的名称不准确!在这里,我们只是制作一个显示几个字符串的组件。在组合组件部分,我们将看到一些实现我们最初目标的有趣方法:

// Source file: src/components/expandableCard.1/index.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

import "../general.css";

export class ExpandableCard extends React.PureComponent<{
    name: string,
    cities: number,
    population: number
}> {
    static propTypes = {
        name: PropTypes.string.isRequired,
        cities: PropTypes.number.isRequired,
        population: PropTypes.number.isRequired
    };

    render() {
        return (
            <div className="bordered">
                NAME:{this.props.name}
                <br />
                CITIES:{this.props.cities}
                <br />
                POPULATION:{this.props.population}
            </div>
        );
    }
}

一切准备就绪;让我们看看它是如何以及为什么运作的!

它是如何工作的...

当你用npm start启动应用程序时,你会得到我们的基本屏幕,显示带有国家的下拉框,以及没有卡片,如下面的截图所示:

我们的基本应用程序,显示固定的、不变的卡片

然后,假设你选择了一个国家;会发生什么?让我们一步一步地跟踪一下:

  1. CountryFilterBar中,onChange事件将触发并执行一个回调(this.props.onSelect()),并提供所选国家的代码。

  2. RegionsInformationTable中,提供给CountryFilterBar的回调是this.update(),因此该方法将被执行。

  3. 更新方法将记录国家(仅供参考)并使用this.setState (见下一节)在RegionsInformationTable状态中加载一些区域。

  4. 状态的改变将导致React重新渲染组件。

  5. CountryFilterBar不需要重新渲染,因为它的 props 和状态都没有改变。

  6. 另一方面,ResultsDataTable将重新渲染,因为它的 props 将改变,接收一个新的区域列表。

因此,说了这么多之后,新的视图将如下所示:

React 处理所有必要的重新渲染后,更新的视图

这基本上是你的应用程序将如何工作的:事件被捕获和处理,状态被改变,props 被传递,React负责重新渲染需要重新渲染的部分。

还有更多...

让我们回到CountryFilterBar组件。我们使用了最近的 JS 方式来定义它,但在许多文章和书籍中,你可能会发现一个你应该了解的旧风格,这样你就可以更好地理解这个变体:

// Source file: src/components/countryFilterBar.old.style.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import "../general.css";

export class CountryFilterBar extends React.PureComponent<{
    list: Array<{ code: string, name: string }>,
    onSelect: string => void
}> {
 constructor(props) {
 super(props);
 this.onSelect = this.onSelect.bind(this);
 }

 onSelect(e: { target: HTMLOptionElement }) {
 this.props.onSelect(e.target.value);
 }

    render() {
        return (
            <div className="bordered">
                Country:&nbsp;
                <select onChange={this.onSelect}>
                    <option value="">Select a country:</option>
                    {this.props.list.map(x => (
                        <option key={x.code} value={x.code}>
                            {x.name}
                        </option>
                    ))}
                </select>
            </div>
        );
    }
}

CountryFilterBar.propTypes = {
 list: PropTypes.arrayOf(PropTypes.object).isRequired,
 onSelect: PropTypes.func.isRequired
};

CountryFilterBar.defaultProps = {
 list: []
};

我们可以总结如下的差异:

  • propTypesdefaultProps的值是通过直接修改类来分别定义的

  • 我们在构造函数中绑定了this.onSelect,所以当调用这个方法时,this的值将是window对象,而不是我们需要的。

使用现代 JS 功能,这是不需要的,但要注意,在旧的 JS 代码中,你可能会发现这些模式。

处理状态

在前一节中,我们看到了state用于区域的用法;让我们深入了解一下。状态的概念与 props 非常相似,但有关键的区别:props 是从外部分配的,是只读的,而状态是私下处理的,是可读写的。如果一个组件需要保留一些信息,它可以用来渲染自己,那么使用状态就是解决方案。

如何做...

通过使用类字段来定义状态,这是 JS 的一个相当新的功能,通过Babel启用,因为它还没有完全正式化。(请参阅github.com/tc39/proposal-class-fields查看提案,该提案处于第 3 阶段,意味着它离正式采纳只有一步之遥。)在旧的 JS 版本中,你必须在类构造函数中创建this.state,但这种语法更清晰。让我们记住代码是什么样子的,然后放弃 Flow 定义。

首先,让我们修改RegionsInformationTable组件:

export class RegionsInformationTable extends React.PureComponent<...> {
 state = {
        countries: [
            { code: "AR", name: "Argentine" },
            { code: "BR", name: "Brazil" },
            { code: "PY", name: "Paraguay" },
            { code: "UY", name: "Uruguay" }
        ],

        regions: []
 };

其次,让我们看看当国家改变时会发生什么。对象的渲染可以取决于它的 props(如我们所说,它不能改变)和它的状态(它可以改变),但在更新状态时有一个重要的限制。你不能简单地给组件的状态赋一个新值,因为React不会检测到它,然后不会进行任何渲染。相反,你必须使用.setState()方法。这个方法可以以不同的方式调用,但functional.setState()是最安全的方法。通过这种方式,你必须传递一个函数,该函数将接收状态和 props,并返回需要更新的状态的任何部分。在我们之前的代码中,我们将写下以下内容:

update(country: string) {
        .
        .
        .
        this.setState((state, props) => ({ regions: [ 
                .
                .
                .
            ]}));

如果你检查一下,你会发现我们在实际代码中没有包括stateprops参数,但这是为了满足 ESLint 关于函数中没有未使用的参数的规则。

它是如何工作的...

为什么我们需要传递一个函数?理解这一点有一个关键:状态更新是异步的。每当你调用.setState()时,React将更新组件的状态并启动协调过程以更新 UI 视图。但是如果有多个.setState()调用会发生什么呢?问题就在这里。

React允许排队许多这样的调用一起进行单个更新,以实现更好的性能,这具有重要的影响:在执行.setState()之前,状态可能已经发生了变化!(即使这样,如果进行批处理,更新将按照调用它们的顺序进行。)因此,你提供一个函数,React将使用适当更新的state参数调用它。不要做任何依赖于this.state的事情,因为它可能是错误的;始终使用state参数进行操作。

无论如何,你应该知道有一个快捷方式。如果(仅当)你的更新不以任何方式依赖于状态或 props 值,你可以使用另一种调用而不需要一个函数。例如,我们的更新可以简单地写成如下形式,this.state.regions将被改变,而其余状态将保持不变;关键是regions属性的新值不以任何方式依赖于状态或 props:

this.setState({ regions: [ ...]});

为什么这样会起作用?因为在这种情况下,即使状态在之前已经改变,你的更新仍然是相同的。但要小心,只有当你的更新完全独立于状态和 props 时,才使用这种语法;否则,使用我们首先展示的函数式方法。

一旦你意识到状态更新是函数,你就可以将逻辑从组件中移出,进行独立的编码和测试,这将与我们在第八章中使用Redux进行的操作非常相似,扩展你的应用程序。你会写this.setState(someFunction),然后someFunction()会被单独定义;你的代码将变得更加声明式。

还有更多...

通过我们在这里所做的一切,你可能会意识到你拥有处理任何应用程序规模的状态所需的一切-你是对的!你可以在App组件中设置整个应用程序的一般状态(记得我们提到过App将有更多的责任?),并且你将能够进行以下操作:

  • 通过使用 props 将其传递给组件

  • 根据组件发送的事件进行更新

这是一个完全有效的解决方案,App.state可以包含整个页面的各种数据。例如,App可以处理调用 Web 服务以获取给定国家的地区,将结果存储在其状态中,并将其传递给我们的组件以便它们进行渲染。在我们的硬编码版本中,RegionsInformationTable拥有国家列表(它是从哪里获取的?)并处理地区的请求(通过返回硬编码数据)。实际上,正如我们将在本书后面看到的那样,从服务器获取这种信息将以不同的方式处理,并且在更高的级别:RegionsInformationTable将处理渲染表格,并将数据收集留给解决方案的另一部分。

即使你将 Web 服务处理传递给App,随着应用程序规模的增长,由于你可能需要跟踪的数据字段数量,这种解决方案可能变得难以控制。在第八章中,我们将为此找到更好的可扩展解决方案,通过添加一个特定的包来更有序、结构化地处理状态更新。

组合组件

让我们回到ExpandableCard,之前我们没有完全完成。我们当然可以做一个特定于地区的卡片,但似乎可扩展或压缩的卡片的一般概念足够有用,以至于我们可能更喜欢一个更一般的解决方案。React允许我们通过组合来做到这一点,我们将在本节中看到。

如何做...

我们想要创建的组件可以包含任何类型的内容。(顺便说一句,相同的想法也适用于通用对话框、标题部分或侧边栏。)React 允许您传递一个特殊的 children 属性(this.props.children),以便您可以将子元素传递给原始组件。

首先,让我们看看我们的ResultsDataTable代码会如何改变。首先,render()方法将需要更改:

render() {
    if (this.props.results.length === 0) {
        return <div className="bordered">No regions.</div>;
    } else {
        return (
            <div className="bordered">
                {this.props.results.map(x => (
 <ExpandableCard key={x.id} title={x.name}>
 <div>CITIES:{x.cities}</div>
 <div>POPULATION:{x.pop}</div>
 </ExpandableCard>
                ))}
            </div>
        );
    }
}

其次,让我们定义我们正在使用的组件。我们正在插入一个带有键和标题的ExpandableCard组件,并在其中包含一对<div>元素,其中包含城市和人口的数据。这些内容将作为this.prop.children可用,我们稍后会看到。我们还添加了一个titleprop 和一个内部状态open,当您通过.toggle()方法展开或压缩卡片时,它将被切换。首先,让我们看看 props、state 和类型:

// Source file: src/comopnents/expandableCard.2/index.js

/* @flow */

import * as React from "react";
import PropTypes from "prop-types";

import "../general.css";
import "./expandableCard.css";

export class ExpandableCard extends React.PureComponent<
    {
        children: React.ChildrenArray<React.ChildrenArray<React.Node>>,
        title: string
    },
    { open: boolean }
> {
    static propTypes = {
        children: PropTypes.arrayOf(PropTypes.element).isRequired,
        title: PropTypes.string.isRequired
    };

    state = {
        open: false
    };

// *continues...*

对于ReactFlow预定义了许多数据类型。(您可以在github.com/facebook/flow/blob/master/website/en/docs/react/types.md上了解更多信息。)

您可能需要的一些更常见的类型如下,但是请阅读上述网页以获取完整的列表:

数据类型 解释
React.ChildrenArray<T> 一个子元素数组,类型为<T>,就像前面的代码中所示的那样。
React.Element<typeof Component> 特定类型的节点:例如,React.Element<"div">是一个渲染<div>的元素。
React.Key 用作键的 prop 的类型:基本上是数字或字符串。
React.Node 可以呈现的任何节点,包括 React 元素、数字、字符串、布尔值、未定义、null 或这些类型的数组。

最后,让我们来到组件的功能部分。让我们看看当组件的状态显示应该展开时,如何显示组件的子元素。还有一个有趣的地方是看看点击卡片如何调用.toggle()方法来改变组件的state.open值:

// *continued*...

    toggle = () => {
        this.setState(state => ({ open: !state.open }));
    }

    render() {
        if (this.state.open) {
            return (
                <div className="bordered">
                    {this.props.title}
                    <div
                        className="toggle"
                        onClick={this.toggle}
                    >
                        △
                    </div>
 <div>{this.props.children}</div>
                </div>
            );
        } else {
            return (
                <div className="bordered">
                    {this.props.title}
                    <div
                        className="toggle"
                        onClick={this.toggle}
                    >
                        ▽
                    </div>
                </div>
            );
        }
    }
}

我们完成了!让我们看看这一切是如何结合在一起的。

它是如何工作的...

当此对象首次呈现时,this.state.open为 false,因此.render()方法将只产生卡片的标题,以及一个指向下方的三角形,表明可以通过点击展开卡片。当用户点击三角形时,将调用this.setState(),并传递一个函数,该函数将获取this.state.open的当前值,并切换它。React将决定对象是否需要重新呈现(因为状态的改变),这一次,由于this.state.open将为 true,将呈现卡片的扩展完整版本。特别是,三角形将指向上方,因此用户将了解如果他们在那里点击,卡片将被压缩。查看以下截图,进行试运行,显示一些展开和压缩的卡片:

我们应用程序的运行;一些卡片已展开并显示其子元素

扩展卡的内容将是什么?这就是this.props.children发挥作用的地方。任何作为 props 提供的元素都将在这里呈现。通过这种方式,你可以重用你的ExpandableCard来呈现任何类型的内容。主要特征(标题,展开/收缩卡的三角形)将始终存在,但由于使用了组合,你可以拥有任何你需要的可展开卡的版本。

处理生命周期事件

组件不仅有一个.render()方法 - 它们还可以实现许多更多的生命周期事件,可以帮助你在特定情况下。在这一节中,让我们介绍所有可用的方法,并提供关于何时使用它们的想法。

要获取所有可用方法的完整描述,请访问reactjs.org/docs/react-component.html - 但要特别注意一些已弃用的、遗留的方法,应该避免使用,并且阅读每个方法的条件和参数。

如何做...

让我们按顺序来看一下组件的生命周期,从组件被创建并放入 DOM 中开始,到它的生命周期中可能被更新的时候,直到组件从 DOM 中被移除的时刻。我们只会介绍主要的方法,即使这样,你可能也不会用到所有的方法:

  • constructor(): 这个方法在组件被挂载之前被调用,用于基本设置和初始化。这个方法用于各种初始化。唯一的关键规则是在做任何其他事情之前,你应该始终先调用super(props),这样this.props就会被创建并且可以访问。

  • componentDidMount(): 这个方法在组件被挂载后被调用。

  • shouldComponentUpdate(nextProps, nextState): 这个方法被 React 用来决定一个组件是否需要重新渲染。

  • render(): 这个(强制性的)方法产生 HTML 元素,理想情况下只基于this.propsthis.state。如果函数返回一个booleannull值,将不会呈现任何内容。这个方法应该是纯的,不尝试修改组件的状态(这可能导致恶性循环)或使用除了状态和 props 之外的任何东西。

  • forceUpdate(): 这个方法不是真正的生命周期方法,你可以在任何时候调用它来强制重新渲染。

  • componentDidUpdate(previousProps, previousState): 这个方法在组件更新后被调用。

  • componentWillUnmount(): 这个方法在组件将要被卸载之前被调用。

它是如何工作的...

我们已经介绍了上一节中的方法。现在让我们来看一些关于让不太明显的方法工作的想法:

**方法 ** 解释
componentDidMount() 这是开始从网络服务获取数据的常规位置。一个常见的技巧是有一个状态属性,比如this.state.loading,当你请求数据时将其初始化为 true,并在数据到来后重置为 false。然后可以让.render()方法产生不同的输出,可能是加载图标,直到数据到来,然后是真实的数据。
shouldComponentUpdate(...) 这个方法作为性能优化,允许 React 跳过不必要的更新。对于React.PureComponent,这是通过比较当前状态和下一个状态,以及当前 props 和下一个 props 来实现的。对于普通的React.Components,这个方法总是返回true,强制重新渲染。如果你的组件是基于任何额外的东西(比如除了状态和 props 之外的其他东西)进行渲染,你应该使用Component而不是PureComponent
componentDidUpdate(...) 您可以使用此方法执行一些动画,或从 Web 服务获取数据,但在后一种情况下,您可能希望将当前状态和 props 与先前的值进行比较,因为如果没有更改,则可能不需要请求,或者可能已经完成了。
componentWillUnmount() 这是通常执行一些清理任务的地方,比如禁用定时器或删除侦听器。

使用 Storybook 简化组件开发

在开发组件时,有一个基本而重要的问题:如何尝试它们?当然,您可以在任何页面的任何地方包含它们,但是每当您想要查看它们的工作方式时,您必须按照应用程序的完整路径,以便您可以实际看到组件。

Storybook是一个 UI 开发环境,可以让您在应用程序之外独立地可视化您的组件,甚至以交互方式对它们进行更改,直到您完全正确为止!

如何做...

首先,安装Storybook本身;我们将使用这个版本的React,但这个工具也可以与AngularVue一起使用:

npm install @storybook/react --save-dev

然后在package.json中添加一些脚本:一个将启动Storybook(稍后我们将看到),另一个将构建一个独立的应用程序,您可以使用它来展示您的组件:

"scripts": { 
 "storybook": "start-storybook -p 9001 -c .storybook",    "build-storybook": "build-storybook -c .storybook -o out_sb",
    .
    .
    .

现在让我们为ExpandableCard编写一个简单的故事。在该组件所在的同一目录中(最终版本,实际上允许展开和压缩,而不是没有该行为的第一个版本),创建一个ExpandableCard.story.js文件。您想展示关于您的组件的内容是什么?您可以显示以下内容:

  • 一个可展开的卡片,里面有几行,就像我们之前使用的那样

  • 另一张卡片,有很多行,展示卡片如何拉伸

  • 一个包含其他卡片的卡片,每个卡片都有一些最小的内容

代码风格与我们在第五章中为Node编写的测试非常相似,测试和调试您的服务器。我假设您可以弄清楚每个测试的作用:

// Source file: src/components/expandableCard.2/expandableCard.story.js

import React from "react";
import { storiesOf } from "@storybook/react";

import { ExpandableCard } from "./";

storiesOf("Expandable Card", module)
    .add("with normal contents", () => (
        <ExpandableCard key={229} title={"Normal"}>
            <div>CITIES: 12</div>
            <div>POPULATION: 41956</div>
        </ExpandableCard>
    ))

    .add("with many lines of content", () => (
        <ExpandableCard key={229} title={"Long contents"}>
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
            Many, many lines<br />
        </ExpandableCard>
    ))

    .add("with expandable cards inside", () => (
        <ExpandableCard key={229} title={"Out card"}>
            <ExpandableCard key={1} title={"First internal"}>
                A single 1
            </ExpandableCard>
            <ExpandableCard key={2} title={"Second internal"}>
                Some twos
            </ExpandableCard>
            <ExpandableCard key={3} title={"Third internal"}>
                Three threes: 333
            </ExpandableCard>
        </ExpandableCard>
    ));

为了不只有一个故事,让我们为CountryFilterBar组件写一个简短的故事;它将在相同的目录中,命名为countryFilterBar.story.js。是的,我知道这是一个非常简单的组件,但这只是为了我们的例子!

// Source file: src/components/countryFilterBar/countryFilterBar.story.js

import React from "react";
import { storiesOf } from "@storybook/react";

import { CountryFilterBar } from "./";

const countries = [
    { code: "AR", name: "Argentine" },
    { code: "BR", name: "Brazil" },
    { code: "PY", name: "Paraguay" },
    { code: "UY", name: "Uruguay" }
];

storiesOf("Country Filter Bar", module).add("with some countries", () => (
    <CountryFilterBar list={countries} onSelect={() => null} />
));

最后,我们需要一个启动器。在项目的根目录下创建一个.storybook目录,并在其中创建一个config.js文件,如下所示:

import { configure } from "@storybook/react";

configure(() => {
    const req = require.context("../src", true, /\.story\.js$/);
    req.keys().forEach(filename => req(filename));
}, module);

configure(loadStories, module);

是的,这有点神秘,但基本上是要扫描/src目录,并选择所有文件名以.story.js结尾的文件。现在我们准备好看看这一切是如何结合在一起的。

它是如何工作的...

我们只为一些组件编写了故事,但这对我们的目的足够了。要启动Storybook服务器,您必须运行我们在本节早些时候创建的脚本之一:

npm run storybook

经过一些工作,你会得到以下屏幕:

Storybook,显示所有可用的故事。您可以与组件交互,单击它们,甚至测试源代码中的更改。

您可以在左侧边栏中选择任何组件(甚至使用过滤文本框),并获取它的各个故事。单击一个故事将在右侧显示相应的组件。您可以使用组件并查看其外观和性能...如果您不满意,您可以动态更改其源代码,并立即查看结果!

最后,让我们构建一个独立的展示应用程序:

$ npm run build-storybook

> chapter06@0.1.0 build-storybook /home/fkereki/JS_BOOK/modernjs/chapter06
> build-storybook -s public -o out_sb

info @storybook/react v3.4.8
info
info => Loading custom addons config.
info => Using default webpack setup based on "Create React App".
info => Copying static files from: public
info Building storybook ...
info Building storybook completed.

/out_sb目录中,我们将拥有一个完整的独立版本的展示。要查看它的工作原理,我们可以使用 Chrome 浏览器的 Web 服务器应用程序(在 Chrome 扩展中搜索),并选择输出目录:

Chrome 的 Web 服务器应用程序足以让我们看到独立的 Storybook 会是什么样子

如果您打开屏幕上显示的 Web 服务器 URL,您将得到与之前完全相同的输出-但现在您可以将out_sb目录复制到其他位置,并将其用作展示工具,独立于开发人员。

还有更多...

您可以通过插件扩展Storybook,从而增强您的展示。在众多可用的插件中,我们将安装其中三个,并快速查看它们的用法:

  • addon-actions允许您查看事件处理程序接收的数据,以查看例如当用户单击组件时会发生什么

  • addon-notes允许您向组件添加注释,以解释其工作原理或提供有关其用法的见解

  • addon-knobs允许您动态调整组件的属性以查看其变化

您可以在storybook.js.org/addons/introduction/上阅读有关插件的更多信息,并查看可用插件的画廊storybook.js.org/addons/addon-gallery/

由于插件非常简单,让我们看一个使用了所有前述插件的示例。首先,我们需要在.storybook目录中创建一个addons.js文件,每个要使用的插件都需要一行:

import "@storybook/addon-actions/register";
import "@storybook/addon-knobs/register";
import "@storybook/addon-notes/register";

现在让我们修改我们的故事,使CountryFilterBar将显示其在选择事件上发送的值,并且还将显示一些描述该组件的注释,以便ExpandableCard可以让您调整其接收到的属性:

// Source file: src/components/expandableCard.2/expandableCardWithAddon.story.js

import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { withNotes } from "@storybook/addon-notes";

import { CountryFilterBar } from "./";
import markDownText from "./countryFilterBar.md";

const countries = [
    { code: "AR", name: "Argentine" },
    { code: "BR", name: "Brazil" },
    { code: "PY", name: "Paraguay" },
    { code: "UY", name: "Uruguay" }
];

storiesOf("Country Filter Bar (with addons)", module).add(
    "with some countries - with actions and notes",
    withNotes(markDownText)(() => (
        <CountryFilterBar
            list={countries}
            onSelect={action("change:country")}
        />
    ))
);

对于动作,我提供了一个action(...)函数,它将在另一个选项卡“ACTION LOGGER”中显示其结果,如下所示:

每当您选择一个国家时,执行的回调及其参数将显示在 ACTIONS 选项卡中。

我点击了我的国家乌拉圭,我可以看到正在发送“UY”。

我还添加了一个withNotes(...)调用,提供了我创建的一个 markdown 文件中的文本。此内容将显示在 NOTES 选项卡中,如下截图所示:

您可以为每个组件提供良好的文档(不像我的!)

最后,我们可以添加一些“旋钮”,让用户动态修改参数。让他们修改卡片的标题和其中显示的数字:

import React from "react";
import { storiesOf } from "@storybook/react";

import { withKnobs, text, number } from "@storybook/addon-knobs";

import { ExpandableCard } from "./";

storiesOf("Expandable Card (with knobs)", module)
 .addDecorator(withKnobs)
    .add("with normal contents", () => (
        <ExpandableCard key={229} title={text("Card title", "XYZZY")}>
            <div>CITIES: {number("Number of cities", 12)}</div>
            <div>POPULATION: {number("Population", 54321)}</div>
        </ExpandableCard>
    ));

当用户看到这个故事时,KNOBS 面板会让他们在屏幕上立即更新一些值。

向故事添加旋钮可以让用户尝试不同的设置。您在旋钮面板中输入的值会自动反映在组件中。

我们仅使用文本和数字,但您还可以为布尔值、颜色、日期、给定范围内的数字、对象、字符串数组和列表中的选项提供旋钮。

第七章:增强您的应用程序

在本章中,我们将继续前进,考虑一些可以使应用程序更好的工具。我们将看到的示例包括以下内容:

  • 添加 SASS 进行分离样式

  • 为内联样式创建 StyledComponents

  • 使您的应用程序对屏幕尺寸做出响应

  • 使您的应用程序适应不同的屏幕尺寸

  • 创建具有国际化和本地化的全局应用程序

  • 为可访问性设置

介绍

在上一章中,我们开始使用React开发应用程序,并了解了如何使用它的基础知识,如何创建应用程序以及如何开发组件。

我们还将利用在上一章中使用的Storybook,这样我们可以单独演示每个工具,而不必浪费时间专注于其他任何事情。

添加 SASS 进行分离样式

我们应该添加的第一件事就是处理应用程序的样式。如果你愿意,你无需学习任何新知识,也无需安装任何额外的东西,因为你可以使用老式的 CSS——就像我们之前做的那样!我们在上一章中使用了一些 CSS(查找src/components/general.css文件),但我们甚至不需要去那里。当我们创建项目时,会创建一个App.js文件,其中包含以下代码:

import React, { Component } from "react";
import logo from "./logo.svg";
import "./App.css";

class App extends Component {
    render() {
        return (
            <div className="App">
                <header className="App-header">
                    <img src={logo} className="App-logo"
                       alt="logo" />
                    <h1 className="App-title">Welcome to 
                       React</h1>
                </header>
                <p className="App-intro">
                    To get started, edit <code>src/App.js</code>
                    and save to reload.
                </p>
            </div>
        );
    }
}

export default App;

通过包含import "./App.css"行,你可以获取在App.css文件中定义的样式,并且可以在代码中随处使用它们。

使用import来处理样式并不是 JS 的事情,而是由Webpack引起的,create-react-app使用它来为你的应用程序生成输出代码。

因此,如果你只想使用 CSS,你只需要做一点点,就可以了!然而,有许多工具可以帮助你处理样式,添加非常有用的功能,在本节中我们将考虑如何使用SASS,这是最著名的 CSS 扩展语言之一。

如果你想完全学习SASS,我建议浏览sass-lang.com/,特别是查看 LEARNING SASS 和 DOCUMENTATION 区域,分别位于sass-lang.com/guidesass-lang.com/documentation/file.SASS_REFERENCE.html

如何做…

SASS是一个预处理器,可以处理.scssSassy CSS)文件,并生成标准的 CSS 文件,供浏览器使用。预处理步骤是使用 CSS 中尚不可用(至少目前还不可用)的功能的关键,例如变量、嵌套结构、继承、混合等。你可以安装并使用SASS作为一个独立的工具,但这并不是很吸引人;相反,我们将目标放在将其包含在项目中,以便所有需要的预处理都将自动完成。让我们看看如何做到这一点。

SASS有两种可能的语法:一种是较旧的,缩进的语法,简称为缩进语法,另一种是较新的 SCSS。虽然前者更为简洁,但后者的优势在于它是 CSS 的扩展,这意味着任何有效的 CSS 文件在 SCSS 中也是有效的,具有完全相同的含义。如果你正在从 CSS 迁移到SASS,这将是一个很好的帮助,所以我们在文本中只使用 SCSS。

首先,我们需要安装一个工具。create-react-app的开发人员不想包含固定的 CSS 预处理器,所以你可以添加任何你想要的。有几种SASS工具,但推荐使用以下工具:

 npm install node-sass-chokidar --save-dev

其次,我们还需要在.flowconfig文件中添加一行额外的内容,以便正确识别.scss文件。更改后的部分将如下所示:


[options]
include_warnings=true
module.file_ext=.scss .
.
.

最后,我们需要修改一些脚本。SASS预处理将与 npm start 并行运行,为此我们需要一个可以让你并行运行多个命令的包:

npm install npm-run-all --save-dev

现在更改后的脚本将如下所示:

"build-scss": "node-sass-chokidar src/ -o src/",
"watch-scss": "npm run build-scss && node-sass-chokidar src/ -o src/ --watch --recursive",
"start-js": "react-app-rewired start",
"build-js": "react-app-rewired build",
"storybook-js": "start-storybook -p 9001 -c .storybook",
"start": "npm-run-all -p watch-scss start-js",
"build": "npm-run-all build-scss build-js",
"storybook": "npm-run-all -p watch-scss storybook-js",
.
.
.

让我们看看我们的新的和更新的流程做了什么:

  • build-scsssrc/中的.scss文件转换为.css文件;我们将使用后者。

  • watch-scss对 SASS 文件进行初始转换,然后以watch模式运行,每当有新的或更改的文件需要处理时就会运行。

  • start-jsbuild-jsstorybook-js是我们的旧startbuildstorybook进程,我们将不会直接使用它们。

  • start现在同时运行watch-scssstart-js(因为有-p选项)

  • build现在运行build-scss,然后是build-js,所以在构建应用程序之前所有的 SCSS 都已经被转换了。

  • storybook现在同时运行watch-scssstorybook-js,也是并行的。

你准备好了!从现在开始,.scss文件将被正确处理,并转换为.css文件;现在让我们看看如何让这对我们起作用。

它是如何工作的...

让我们创建和设计一个基本组件,一个有颜色的按钮,尽可能利用尽可能多的SASS功能。这将是一个极端的例子,因为你不太可能有这样一个复杂的方式来创建简单的代码,但我们想在这里突出SASS

首先是按钮本身的代码,我们将其称为SassButton。它有三个 props:normal(如果为 true,将显示normal颜色;如果为 false,将显示alert颜色);buttonText,按钮上将显示的文本;以及onSelect,一个点击的回调。我在下面的代码片段中突出显示了与 CSS 相关的行。

// Source file: /src/components/sassButton/sassButton.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import "./styles.css";

export class SassButton extends React.PureComponent<{
    normal: boolean,
    buttonText: string,
    onSelect: void => void
}> {
    static propTypes = {
        normal: PropTypes.bool.isRequired,
        buttonText: PropTypes.string.isRequired,
        onSelect: PropTypes.func.isRequired
    };

    render() {
        return (
            <div
                className={
                    this.props.normal ? "normalButton" : "alertButton"
                }
                onClick={this.props.onSelect}
            >
                <span>{this.props.buttonText}</span>
            </div>
        );
    }
}

即使使用SASS.scss文件,你将导入预处理后的.css输出文件,而不是.scss原始文件。小心不要错误地导入.scss文件。

我们假设 CSS 类.normalButton.alertButton存在;现在让我们开始创建它们。首先,让我们定义一个partial的 SCSS 文件,_constants.scss,它将定义一些颜色变量。部分文件的文件名总是以下划线开头,并且它们不会被转换为 CSS;相反,它们被认为是从其他 SCSS 文件中导入的@import

$normalColor: green;
$normalText: yellow;

$alertColor: red;
$alertText: white;

变量的名称以美元符号开头,是设置标准定义的一种非常好的方式,比如用于字体或颜色。如果我决定要将我的正常颜色改为蓝色,我只需要在一个地方进行更改,然后它就会在所有地方被替换。请注意,我可以在许多地方使用$normalColor,用于背景、文本等,所有这些都将在单个编辑中更新。

现在让我们定义一些mixins,可以用来包含 CSS 代码,甚至可以使用参数。我们的darkenBackground() mixin 将生成代码来设置background-color的值,并将:hover属性更改为使用它的任何元素的颜色变暗 25%。请注意:hover中的&,它代表父元素,还有darken()函数,这只是 SASS 提供的许多函数之一,用于处理颜色、大小等等。

查看sass-lang.com/documentation/file.SASS_REFERENCE.html#operations了解更多信息:

@mixin darkenBackground($color) {
    background-color: $color;
    &:hover {
        background-color: darken($color, 25%);
        transition: all 0.5s ease;
    }
}

@mixin coloredBoldText($color) {
    color: $color;
    font-weight: bold;
}

最后,我们可以在styles.scss文件中构建我们的样式。首先,我们导入我们的 partials:

@import "_constants.scss";
@import "_mixins.scss";

然后,为了展示其他SASS功能,让我们定义一个基本的占位符类,%baseButton,它将被扩展。初始的%字符(类似于类或 ID 的初始字符)表示这段代码不是直接使用的:

%baseButton {
    display: inline-block;
    text-decoration: none;
    padding: 5px 10px;
    border-radius: 3px;
}

现在让我们扩展这个基本类来创建我们的按钮:我们将使用@extend来实现,还有@include来将我们的 mixins 的输出添加到结果代码中。我们还包括了一些/* ... */的注释,但你也可以使用//来进行单行注释:

/*
    A simple button for normal situations
*/
.normalButton {
    @extend %baseButton;
    @include darkenBackground($normalColor);

    span {
        @include coloredBoldText($normalText);
    }
}

/*
    An alert button for warnings or errors
*/
.alertButton {
 @extend %baseButton;
    @include darkenBackground($alertColor);

    span {
        @include coloredBoldText($alertText);
    }
}

如果你想知道最终产生了什么,生成的styles.css文件如下:仔细查看,你会看到翻译后的 mixins 和常量,以及如何定义扩展样式,如何使用:hover等等。

.normalButton, .alertButton {
  display: inline-block;
  text-decoration: none;
  padding: 5px 10px;
  border-radius: 3px; }

.normalButton {
  background-color: green; }
  .normalButton:hover {
    background-color: #000100;
    transition: all 0.5s ease; }
  .normalButton span {
    color: yellow;
    font-weight: bold; }

.alertButton {
  background-color: red; }
  .alertButton:hover {
    background-color: maroon;
    transition: all 0.5s ease; }
  .alertButton span {
    color: white;
    font-weight: bold; }

现在我们只需要编写一个故事,然后在Storybook中查看我们的按钮:

// Source file: /src/components/sassButton/sassButton.story.js

import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";

import { SassButton } from "./";

storiesOf("SASS buttons", module)
    .add("normal style", () => (
 <SassButton
 normal
 buttonText={"A normal SASSy button!"}
 onSelect={action("click:normal")}
 />
    ))
    .add("alert style", () => (
 <SassButton
 normal={false}
 buttonText={"An alert SASSy button!"}
 onSelect={action("click:alert")}
 />
    ));

当属性应为truefalse时,只需包含其名称即可使其为 true。请看第一个故事中我们可以只写normal而不是normal={true};两者是等价的。

我们可以在以下截图中看到普通按钮:

我们在 Storybook 中展示的普通按钮

警报按钮,带有悬停光标,如下截图所示:

我们的警报按钮,带有悬停颜色

因此,我们已经看到了一个常见的解决方案:使用SASS创建 CSS。在下一节中,让我们以更原始的方式工作,通过在 JS 代码中直接使用 CSS 代码,而不是将其与 JS 代码分开!

为内联样式创建 StyledComponents

CSS-in-JS 有时是一个有争议的话题。在React之前,几乎是强制性的,您必须有一组 JS、HTML 和 CSS 分开的文件。当React引入 JSX 时,这是对这组的一种打击,因为我们开始在 JS 代码中放置 HTML。CSS-in-JS 是这个想法的自然延伸,因为现在我们也希望在同一个 JS 文件中包含样式。

对此的第一反应是:这不就是回到了内联样式吗?这是一个合理的问题,但内联样式并不足够强大。虽然您可以通过内联样式管理大量样式,但事实是,有一些功能无法以这种方式访问:关键帧动画、媒体查询、伪选择器等等。

选择 CSS-in-JS 的想法是通过使用 JS 编写样式,然后将这些样式注入 DOM 中的<style>标签中,因此您可以完全利用 CSS 来编写代码。此外,这也与基于组件的方法非常一致,例如 React 的方法,因为您设法将您需要的一切都封装在一起,而不是依赖全局样式文件并且必须处理 CSS 的单一命名空间。

有许多推广这种样式的包,其中,我们将选择styled-components,这是最受推崇的 CSS-in-JS 样式包之一。它的理念很有趣:不是向组件添加样式,而是创建包含这些样式并在各处使用它们的组件。让我们开始看看如何将这个软件包添加到我们的代码中,然后继续使用它。

关于 CSS-in-JS 的原始讨论,由 Christopher vjeux Chedeau 主讲,他阐述了在 JS 中处理样式的原因,请参阅speakerdeck.com/vjeux/react-css-in-js

如何做…

安装styled-components非常简单 - 请注意,这不是开发依赖,因为您实际上将在生产代码中使用该软件包,而不是作为单独的预处理步骤或类似的东西:

npm install styled-components --save

我们将使用标记模板文字(我们之前在第二章的使用现代 JavaScript 功能中看到过),因此您可能希望刷新一下书中的那部分。

使用Flow不会有问题,因为它对styled-components的支持很好,所以我们不需要做任何特别的事情。最后,对于 VSC,您可能希望使用vscode-styled-components扩展来添加语法高亮显示。

阅读styled-components的完整文档,网址为www.styled-components.com/docs

它是如何工作的…

让我们尝试通过使用我们的新工具来重新创建我们用SASS构建的按钮。我们不会尝试模仿SASS代码,但我们将尝试应用一些相同的概念,比如在单独的文件中定义常量,使函数作为 mixin 工作,并扩展类,就像我们之前做的那样。我们有一个问题,因为styled-components不像SASS那样提供颜色函数,所以我们将添加一个新库来处理这个问题,color

这个包为您提供了许多方法来创建和操作颜色,所以您最好看一下它的文档,网址是github.com/qix-/color

npm install color --save

现在,我们已经准备好了。首先,我们将在constants.js文件中定义一些基本的颜色常量,可以在任何地方使用:

export const NORMAL_COLOR = "green";
export const NORMAL_TEXT = "yellow";

export const ALERT_COLOR = "red";
export const ALERT_TEXT = "white";

还有一种通过主题来共享全局样式数据的替代方法;如果您感兴趣,请查看www.styled-components.com/docs/advanced#theming

现在我们将直接开始定义我们的组件,因为所有的样式也将在那里。首先,我们需要一些导入:

// Source file: /src/components/styledButton/styledButton.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import styled from "styled-components";
import Color from "color";

import {
    NORMAL_TEXT,
    NORMAL_COLOR,
    ALERT_TEXT,
    ALERT_COLOR
} from "./constants";

// *continues...*

有了这个,我们可以开始主要的代码了。我们将有一个makeSpan()函数,它将作为一个 mixin;我们很快就会用到它,并且我们会看到props的含义:

// ...*continued*

const makeSpan = props => `
    span {
        color: ${props.normal ? NORMAL_TEXT : ALERT_TEXT};
        font-weight: bold;
    }
`;

// *continues...*

然后,我们将定义一个BasicStyledDiv组件,带有一些基本样式,作为我们按钮的基类。(记住,我们正在以这种不必要的复杂方式工作,只是为了突出一些您可能想在真正需要的问题中使用的功能!)这个组件将大致相当于我们在上一节中使用SASS声明的%baseButton

// ...*continued*

const BasicStyledDiv = styled.div`
    display: inline-block;
    text-decoration: none;
    padding: 5px 10px;
    border-radius: 3px;
`;

// *continues...*

之后,我们可以通过扩展之前的组件来创建一个StyledDiv组件。由于styled-component让我们可以使用函数和表达式,我们不必像在构建.normalButton.alertButton时那样创建两种不同的样式,就像我们在SASS中所做的那样。另外,请注意,我们可以在这里使用&,表示对类的引用,就像在SASS中一样:

// ...*continued*

const StyledDiv = BasicStyledDiv.extend`
    background-color: ${props =>
        props.normal ? NORMAL_COLOR : ALERT_COLOR};
    &:hover {
        background-color: ${props =>
            Color(props.normal ? NORMAL_COLOR : ALERT_COLOR)
                .darken(0.25)
                .string()};
        transition: all 0.5s ease;
    }
    ${props => makeSpan(props)};
`;

// *continues...*

我们看到的这个props参数是什么?在创建样式时,组件的 props 将传递给我们的代码,因此我们可以调整我们的样式。在这种情况下,如果组件的this.props.normal值为true,将使用NORMAL_COLOR;否则,将应用ALERT_COLOR。这大大简化了我们的代码,因为我们不必以固定的方式创建样式;我们可以使它们调整到我们想要的任何东西。

在所有这些之后,我们的按钮本身的代码非常简单:

// ...*continued*

export class StyledButton extends React.PureComponent<{
    normal: boolean,
    buttonText: string,
    onSelect: void => void
}> {
    static propTypes = {
        normal: PropTypes.bool.isRequired,
        buttonText: PropTypes.string.isRequired,
        onSelect: PropTypes.func.isRequired
    };

    render() {
        return (
 <StyledDiv
 normal={this.props.normal}
 onClick={this.props.onSelect}
 >
                <span>{this.props.buttonText}</span>
 </StyledDiv>
        );
    }
}

// *continues...*

编写一个故事来检查这实际上是微不足道的,因为我们只需要复制我们为SASS样式按钮编写的上一个故事,并用StyledButton替换SassButton;不需要其他任何东西。(好吧,我还为了清晰起见改变了一些字符串,但这些修改微不足道。)如果我们启动Storybook,我们可以快速验证我们的新按钮与之前的按钮以相同的方式工作;请参见以下截图作为证据:

使用styled-components与 SASS 一样成功,而且更“JavaScripty”

如果您想获得一些具体的提示,并学习一些新技巧,请查看github.com/styled-components/styled-components/blob/master/docs/tips-and-tricks.md

使您的应用程序对屏幕尺寸做出响应

创建 Web 应用程序意味着您不能假设任何给定的显示尺寸。实际上,用户可能会更改浏览器窗口的大小,您的应用程序应该以某种方式对此做出响应,重新排列屏幕上显示的内容,以更好地适应当前的屏幕尺寸。如果您的 Web 应用程序能够进行这种重新组织,就可以说它是响应式的。如今,鉴于具有浏览器的设备范围极其广泛(从小型手机到非常大的平板显示屏),进行响应式设计确实是必不可少的,因此在本节中我们将看到如何处理这个问题。我假设您已经了解 CSS 概念,比如网格和列设计;如果不了解,请阅读相关内容。

为了消除一个常见的,相当明显的问题,如果你了解当前 CSS 的趋势,你可能会问为什么我们不使用 Flexbox 或 CSS Grids,这两者都可以轻松实现响应式设计。答案在于可用性:如果你查看诸如www.caniuse.com/这样的地方,你会发现这些功能最近才可用,因此用户可能还没有访问权限。总之,注意以下内容:

  • Internet Explorer 对这两个功能有部分支持,但有许多错误

  • Edge 自 2018 年 4 月的版本 17 开始支持它们

  • FireFox 自 2018 年 5 月的版本 60 开始支持它们

  • Safari 自 2018 年 3 月的 11.1 版本开始支持它们

  • Chrome 自 2016 年 3 月的版本 49 开始支持 FlexBox,但自 2018 年 4 月的版本 66 开始支持 CSS Grid

正如你所看到的,如果你想使用这些功能,截至今天(2018 年 12 月),只有少数用户可能可以访问它们,而对于绝大多数用户来说,显示可能会混乱。因此,即使这意味着使用比需要更大的库,我们将选择当前标准,正如我们将在下一节中看到的。

如何做…

Bootstrap是最受欢迎的用于网站和 Web 应用程序设计的前端库之一,自 2011 年 8 月就已经可用;它已经有大约七年的历史了。自第 2 版以来就包括了响应式设计处理。移动优先设计(所以你应该首先让你的设计在较小的设备上工作,然后才担心为较大的屏幕添加处理)是在第 3 版中包括的,而SASS支持出现在第 4 版中。除了响应式设计支持,Bootstrap还提供其他功能,如组件、排版和更多实用工具,所以你可能不要错过查看整个文档getbootstrap.com/docs/4.1/getting-started/introduction/

Bootstrap目前是 GitHub 的第二大关注项目,紧随 FreeCodeCamp 的第一名。如果你想知道,React实际上几乎与另一个框架Vue和 EBook 的免费编程书籍套装的第三名并列。你可以在github.com/search?o=desc&q=stars%3A%3E1&s=stars&type=Repositories上自行查看结果。

要安装Bootstrap,我们只需要常规的npm命令:

npm install bootstrap --save

你可以通过下载预构建的图像(包括 CSS 和 JS)来保存你的工作;查看getbootstrap.com/docs/4.1/getting-started/download/获取这些选项。另外,还有一个Reactreact-bootstrap.github.io/react-bootstrap,它目前只支持Bootstrap第 3 版,但承诺即将全面支持Bootstrap第 4 版。你可能还想看看另一个可能的选择,reactstrap,在reactstrap.github.io/

Bootstrap提供了许多功能,包括:

在任何情况下,我们不会专门处理前面的列表,因为这基本上只是样式的问题,而我们已经做过了。相反,我们将专注于定位元素,改变它们的大小,甚至根据当前屏幕大小隐藏或显示它们;让我们现在继续进行。

它是如何工作的...

Bootstrap使用基于 12 列的网格系统,具有基于媒体查询的多个设备尺寸的断点:

  • xs:非常小,例如纵向手机,宽度小于 576 像素

  • sm:小,如横向手机,最大到 768 像素

  • md:中等,如平板电脑,最大到 992 像素

  • lg:大,如台式机,最大到 1200 像素

  • xl:超大,超过 1200 像素

这些限制并非是硬编码的,可以更改。其他常见的值是 1024 和 1440,而不是 992 和 1200。另一个可能性是考虑高清设备(1920x1080)和 4K 设备,分辨率为 2560x1600。

无论何时放置元素,您都可以指定它们的宽度以列为单位,并且根据可用的行空间进行排列,如果需要,可以移动到新的行。您还可以根据屏幕尺寸允许元素的不同大小和顺序,并根据可用空间甚至隐藏或显示组件(完全或部分)。

调整元素大小

通过使用col-xx-yy类(例如col-sm-3col-md-5),您可以根据当前屏幕宽度决定元素的大小。以下代码示例显示了这一点,并请注意我避免了使用单独的样式表,只是为了简化:

// Source file: /src/App.1.js

/* @flow */

import React, { Component } from "react";

class App extends Component<{}> {
    render() {
        const cl = "border border-dark p-2 bg-warning ";

        return (
            <div className="container mw-100">
                <div className="row border">
 <div className={cl + "col-sm-2 col-md-6"}>2/6</div>
                    <div className={cl + "col-sm-4"}>4</div>
                    <div className={cl + "col-sm-1"}>1</div>
                    <div className={cl + "col-sm-1"}>1</div>
                    <div className={cl + "col-sm-1"}>1</div>
 <div className={cl + "col-sm-1 col-md-5"}>1/5</div>
                    <div className={cl + "col-sm-2 "}>2</div>
 <div className={cl + "col-sm-7 col-md-3"}>7/3</div>
                    <div className={cl + "col-sm-4 "}>4</div>
 <div className={cl + "col-sm-1 col-md-3"}>1/3</div>
                </div>
            </div>
        );
    }
}

export default App;

我们可以看到渲染如何随着屏幕尺寸的变化而改变;请参见以下图片:

相同的元素,在不同的屏幕宽度下呈现

在最小的屏幕尺寸下,所有元素在垂直方向上都以相同的大小呈现;这在逻辑上适合非常小的设备。随着窗口大小的增加,7/3 元素现在占据了 7 列,而 2/6、1/5 和 1/3 元素变窄了。当我们进一步增加窗口宽度时,请注意 7/3 元素仅占据了三列,而其他 3 个元素变宽了。

当然,您几乎不太可能会设计出这种奇怪的设计,具有如此多不同的宽度和如此奇特的调整规则,但这里的重点是,通过使用Bootstrap网格,元素可以在大小上变化,并且可以优雅地流动到不同的行,而无需做任何特殊处理。

重新排序元素

在前面的例子中,我们看到了组件如何调整大小并跨行流动。然而,还有其他要求:例如,您可能希望组件在给定屏幕尺寸下出现在不同位置。幸运的是,Bootstrap也允许这样做。让我们有一个元素会在其他元素中改变位置:

// Source file: /src/App.2.js

/* @flow */

import React, { Component } from "react";

class App extends Component<{}> {
    render() {
        const cl = "border border-dark p-2 bg-warning ";
        const ch = "border border-dark p-2 bg-dark text-white ";

        return (
            <div className="container mw-100">
                <div className="row border">
                    <div className={cl + "col-sm-2 col-md-6"}>2/6</div>
                    <div className={cl + "col-sm-4"}>4</div>
                    <div className={cl + "col-sm-1"}>1</div>
 <div
 className={
 ch + "col-sm-1 order-sm-first order-md-
                            last"
 }
 >
 1
 </div>
                    <div className={cl + "col-sm-1 col-md-5"}>1/5</div>
                    <div className={cl + "col-sm-3 "}>3</div>
                </div>
            </div>
        );
    }
}

export default App;

对于小设备,我们的特殊组件应该是第一个,对于中等设备,它应该移动到最后。对于非常小的设备(我们没有提供任何特殊规则),它应该出现在正常位置。请参见以下图片:

组件还可以改变它们的相对位置。

这满足了常见的第二组要求,让您可以随意变化组件在屏幕上出现的顺序。我们只有一个更多的情况,我们将在下一节中看到。

隐藏或显示元素

我们的最终设计规则是,某些组件(或其中的部分)在给定的屏幕尺寸下可能不应该显示。例如,如果您正在提供有关电影的信息,在大屏幕上,您可以包括一场戏的静态画面,以及主要演员的照片,以及电影标题和完整描述,但在小屏幕上,您可能只需要电影标题和基本信息。让我们用一对组件展示这种要求:一个将完全隐藏,而另一个将只隐藏部分内容:

// Source file: /src/App.3.js

/* @flow */

import React, { Component } from "react";

class App extends Component<{}> {
    render() {
        const cl = "border border-dark p-2 bg-warning ";
        const ch = "border border-dark p-2 bg-dark text-white ";

        return (
            <div className="container mw-100">
                <div className="row border">
                    <div className={cl + "col-sm-2 col-md-6"}>2/6</div>
 <div className={ch + "d-none d-sm-block col-sm-4"}>
 0/4
 </div>
                    <div className={cl + "col-sm-2"}>2</div>
                    <div className={cl + "col-sm-2"}>2</div>
                    <div className={cl + "col-sm-1 col-md-5"}>1/5</div>
                    <div className={cl + "col-sm-3 "}>3</div>
 <div className={ch + "col-sm-7 "}>
 <div>TOP</div>
 <div className="d-none d-sm-block">(MIDDLE)
                        </div>
 <div>BOTTOM</div>
 </div>
                    <div className={cl + "col-sm-4 "}>4</div>
                </div>
            </div>
        );
    }
}

export default App;

要查看此操作,请查看以下图片:

一个组件在小屏幕上完全消失,而其他组件显示不同的内容

0/4 组件设置为仅在小屏幕及更大屏幕上显示,因此在左侧截图中它就消失了。另一个组件在较小的屏幕上显示两行,但在较大的屏幕上显示更多内容(好吧,是三行而不是两行)。

使您的应用程序适应以提高可用性

通过使用我们在前一节中看到的网格和所有样式,在许多情况下,您不需要额外的东西来构建响应式网站。然而,在某些情况下,移动组件,调整它们的大小,甚至隐藏部分或全部内容是不够的。例如,您可能确实希望在小屏幕和大屏幕上显示完全不同的组件 - 比如,手机上有三个选项卡的屏幕,一次只显示一个选项卡,但在台式机上有三列显示,同时显示所有内容。变化可能会更加激烈:您可能会决定某些功能在移动设备上不可用,而只包含在大屏幕上。因此,您不是在进行响应式设计,而是在进行自适应设计,这意味着屏幕的实际设计和功能将发生变化,然后我们需要能够处理代码中的内部变化。

如何做…

如果您想自己进行自适应设计,您当然可以设置监听屏幕尺寸或方向变化,然后生成一些组件或其他内容。虽然这种方法没有错,但通过安装react-responsive,可以使其变得更简单,该软件包会处理所有这些事情 - 您只需指定在满足某些条件时将渲染一些组件,每当满足条件时,这些组件将被渲染。在任何尺寸或方向变化时,该软件包将处理所需的任何重新渲染。

安装需要使用常规的npm命令:

npm install react-responsive --save

该软件包中的关键组件称为<MediaQuery>,它让您可以使用媒体查询或使用属性来处理,更像是React;我更喜欢后者,但如果您对更像 CSS 的选项感兴趣,请查看文档。现在让我们看看它是如何使用的。

github.com/contra/react-responsive上阅读有关react-responsive的更多信息。它的功能比我在本文中展示的要多得多。

它是如何工作的…

基本上,您只需要在渲染时生成一个或多个<MediaQuery>组件来检测任何尺寸变化,那些满足要求的组件将被实际渲染,而其余的将不会出现在页面上。

让我们写一个非常基本的例子,其中包含大量的媒体查询,以查看您将要使用的编码风格。以下是在 react-responsive GitHub 页面上给出的一个例子;我们将尝试检测当前设备和窗口的一些方面:

// Source file: /src/App.4.js

/* @flow */

import React, { Component } from "react";
import MediaQuery from "react-responsive";

const XS = 576; // phone
const SM = 768; // tablet
const MD = 992; // desktop
const LG = 1200; // large desktop

class App extends Component<{}> {
    render() {
        return (
            <div>
                <MediaQuery minDeviceWidth={MD + 1}>
                    <div>Device: desktop or laptop</div>

                    <MediaQuery maxWidth={XS}>
                        <div>Current Size: small phone </div>
                    </MediaQuery>

                    <MediaQuery minWidth={XS + 1} maxWidth={SM}>
                        <div>Current Size: normal phone</div>
                    </MediaQuery>

                    <MediaQuery minWidth={SM + 1} maxWidth={MD}>
                        <div>Current Size: tablet</div>
                    </MediaQuery>

                    <MediaQuery minWidth={MD + 1} maxWidth={LG}>
                        <div>Current Size: normal desktop</div>
                    </MediaQuery>

                    <MediaQuery minWidth={LG + 1}>
                        <div>Current Size: large desktop</div>
                    </MediaQuery>
                </MediaQuery>

                <MediaQuery maxDeviceWidth={MD}>
                    <div>Device: tablet or phone</div>
                    <MediaQuery orientation="portrait">
                        <div>Orientation: portrait</div>
                    </MediaQuery>
                    <MediaQuery orientation="landscape">
                        <div>Orientation: landscape</div>
                    </MediaQuery>
                </MediaQuery>
            </div>
        );
    }
}

export default App;

我定义了四个大小常量(XSSMMDLG)以匹配Bootstrap使用的值,但您当然可以使用其他大小。

您还可以修改Bootstrap中的值,以便它可以与不同的断点一起使用:请参阅getbootstrap.com/docs/4.1/layout/grid/#grid-tiers了解更多信息。

每当我们的App组件被渲染时,媒体查询都会被执行,根据它们的结果,组件将会或不会被渲染。在我们的情况下,我们只是生成一些带有文本的<div>实例,但显然您可以实际上生成任何其他类型的组件。

我们可以在 Chrome 中运行这个应用程序,并在调整窗口大小时查看它如何生成不同的内容:参见下图:

我们的组件会自动对任何屏幕尺寸的变化做出反应,并生成不同的组件,即使我们的示例缺乏多样性!

或者,您可以在工具栏中使用设备切换功能,然后还可以查看您的应用程序在手机或平板电脑上的外观; 请查看以下屏幕截图以了解示例:

Chrome 的开发者工具包括设备切换功能,可以模拟多种设备,包括手机和平板电脑

使用Bootstrap进行简单调整,使用react-responsive进行更复杂的工作,可以确保您的应用程序适合在任何设备上运行。 现在让我们转向另一种情况:在不同的国家或地区运行!

制作具有国际化和本地化的全球应用

随着全球化水平的不断提高,您编写的任何网站可能需要使用两种或更多种语言。 在加拿大,英语和法语是强制性的; 在瑞士,可能需要四种语言; 甚至在(据说是单一语言的)美国,西班牙语版本的网站也可能会添加到英语版本中。 当然,仅仅翻译是不够的:日期和货币金额也需要根据国家的不同格式进行不同的格式化,因此我们也必须处理这一点。

现在,一些定义:能够使软件适应不同语言称为国际化,通常缩写为i18n——18 代表了国际化一词中的初始i和最终n之间的 18 个字母。然后,配置系统以适应特定区域的具体过程称为本地化,缩写为l10n,原因与i18n类似。最后,如果您真的喜欢这些数字字母缩写,国际化和本地化的组合也被称为全球化,缩写为g11n

这对定义是基于 W3C 的一份文件,网址为www.w3.org/International/questions/qa-i18n。 在那里,他们定义了“国际化是设计和开发[...],使得易于适应不同文化,地区或语言的目标受众”,“本地化是指根据特定目标市场(区域设置)的语言,文化和其他要求进行调整”。

幸运的是,在React中处理这些方面很简单,只需要提前进行一些规划,我们将在本教程中看到。

如何做…

处理所有 i18n 问题的一个很好的包是i18next。 我们可以使用以下命令安装它,以及一个用于检测浏览器语言的包:

npm install i18next i18next-browser-languagedetector --save

您还需要决定一个备用语言(可能是"en",表示英语),并为应用程序中使用的所有字符串提供翻译。 为了体验一下,对于一个虚构的数据输入表单(在一个非常小的应用程序中;通常情况下,您可能会有数百种翻译!),您可以为英语准备以下translations.en.json文件:

{
    "details": "Details",
    "number": "How many things?",
    "color": "Thing Color",
    "send it before": "Send the thing before",
    "please enter details": "Please, enter details for your thing:",
    "summary": "Your only thing will be there before {{date, 
     AS_DATE}}",
    "summary_plural":
        "Your {{count}} things will be there before {{date, AS_DATE}}",
    "colors": {
        "none": "None",
        "steel": "Steel",
        "sand": "Sand"
    }
}

如果您决定还提供西班牙语("es")翻译,您将添加另一个文件translations.es.json。 (注意:您可以以任何您希望的方式命名文件,不必遵循我的示例。)这个新的 JSON 文件具有完全相同的键,但是用西班牙语翻译:

{
    "details": "Detalles",
    "number": "¿Cuántas cosas?",
    "color": "Color de la cosa",
    "send it before": "Enviar antes de",
    "please enter details": "Por favor, ingrese detalles para su 
     cosa:",
    "summary": "Su única cosa llegará antes de la fecha {{date, 
     AS_DATE}}",
    "summary_plural":
        "Sus {{count}} cosas llegarán antes del {{date, AS_DATE}}",
    "colors": {
        "none": "Ninguno",
        "steel": "Acero",
        "sand": "Arena"
    }
}

其思想是,每当您想要显示一些文本时,您将通过其键(例如"details""number")引用它,最终提供额外的参数(如"summary"),然后翻译包将选择正确的字符串进行显示; 让我们通过完成一个示例来看看它是如何工作的。

i18next包还可以处理复数和特定格式规则。 您首先必须初始化它,如下所示; 我们正在创建一个i18n文件:

// Source file: /src/components/i18nform/i18n.js

import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";

import EN_TEXTS from "./translations.en.json";
import ES_TEXTS from "./translations.es.json";

i18n.use(LanguageDetector).init({
    resources: {
        en: { translations: EN_TEXTS },
        es: { translations: ES_TEXTS }
    },
    fallbackLng: "en",
    ns: ["translations"],
    defaultNS: "translations",
    debug: true,
    interpolation: {
        escapeValue: false,
        format: function(value, format, lang = i18n.language) {
            if (format === "AS_DATE") {
                try {
                    const dd = new Date(value);
                    return new Intl.DateTimeFormat(lang).format(
                        new Date(
                            dd.getTime() + dd.getTimezoneOffset() * 
                            60000
                        )
                    );
                } catch (e) {
                    return "???";
                }
            } else {
                return value;
            }
        }
    }
});

const t = i18n.t.bind(i18n); // to allow using t(...) instead of i18n.t(...)

export { i18n, t };

关于代码的一些细节应该注意:

  • use(...)方法告诉i18next使用浏览器语言检测器包。

  • resources属性中,您必须为每种语言提供一组翻译,我们从我们的 JSON 文件中导入。

  • fallbackLng指定英语("en")将是默认语言。

  • nsdefaultNS定义了翻译的命名空间,通常只是translations,就像我们在resources属性中使用的那样。

  • debug是一个很好的工具,因为它将在控制台记录任何您想要翻译但在资源中未定义的键。

  • interpolation.escapeValue为您提供了转义所有值的选项:您可以使用它来显示未选中的用户输入值,但我们在这里不需要它。

  • interpolation.format允许您定义一个特殊的格式化函数,该函数应该为给定值以特定格式在特定语言下产生您想要的任何输出。在我们的案例中,我们将其与summarysummary_plural键一起使用,以便以适当的样式格式化日期:英语为月/日/年,西班牙语为日/月/年。您还可以使用此函数将数字格式化为货币,例如。

您可以在www.i18next.com/上查看i18next的完整文档。

它是如何工作的...

想象我们正在定义一个输入表单,让您订购一些东西,选择它们的颜色,并决定交付的最终日期。我们的<I18nForm>组件可以编码如下-请注意,我们只关注输入表单,不关注实际执行用户数据!同样,不要关注糟糕的 UI 设计;再次强调,我们关心的是翻译,所以我希望尽可能少的额外 JSX 代码:

// Source file: /src/components/i18nform/i18nform.js

/* @flow */

import React from "react";

import "./styles.css";

import { i18n, t } from "./i18n";

export class I18nForm extends React.PureComponent<
    {},
    {
        delivery: String,
        howMany: Number,
        thingColor: String
    }
> {
    state = {
        delivery: "2018-09-22",
        howMany: 1,
        thingColor: "NC"
    };

    constructor(props) {
        super(props);
        this.rerender = () => this.forceUpdate();
    }

    componentDidMount() {
        i18n.on("languageChanged", this.rerender);
    }

    componentWillUnmount() {
        i18n.off("languageChanged", this.rerender);
    }

    render() {
        return (
            <div>
                <div>
                    <h2>{t("details")}</h2>
                    <button onClick={() => i18n.changeLanguage("es")}>
                        ES
                    </button>
                    <button onClick={() => i18n.changeLanguage("en")}>
                        EN
                    </button>
                </div>
                <br />
                <div>{t("please enter details")}</div>
                <br />
                <div>
                    {t("send it before")}:
                    <input
                        type="date"
                        value={this.state.delivery}
                        onChange={e =>
                            this.setState({ delivery: e.target.value })
                        }
                    />
                </div>
                <div>
                    {t("number")}:
                    <input
                        type="number"
                        min="1"
                        value={this.state.howMany}
                        onChange={e =>
                            this.setState({
                                howMany: Number(e.target.value)
                            })
                        }
                    />
                </div>
                <div>
                    {t("color")}:
                    <select
                        onChange={e =>
                            this.setState({ thingColor: e.target.value })
                        }
                    >
                        <option value="NC">{t("colors.none")}</option>
                        <option value="ST">{t("colors.steel")}</option>
                        <option value="SD">{t("colors.sand")}</option>
                    </select>
                </div>
                <br />
                <div>
                    {t("summary", {
 count: this.state.howMany,
 date: this.state.delivery
 })}
                </div>
            </div>
        );
    }
}

应该注意代码的一些细节:

  • 通过对象传递插值的额外参数(就像"summary"键一样)是通过对象完成的,使用所需的参数

  • 如果要为单数和复数版本定义不同的行,您必须像我们在这里做的那样定义两个键:单数的summary和复数的summary_plural,然后i18next将根据count参数的值决定使用哪个

我们如何处理动态语言更改?我们提供了两个按钮来调用i18n.changeLanguage(...),但是我们如何重新渲染组件?至少有三种方法可以做到这一点:

  • 您可以监听"languageChanged"事件并强制更新,这就是我们在这里所做的。(我们使用.on(...)来设置我们的组件进行监听,使用.off(...)在卸载时停止它。)

  • 另一个解决方案是将当前选择的语言包含在应用程序状态中(我们将在下一章中进行讨论),并且您可以通过 props 将其提供给组件,因此React将在语言更改时重新渲染所有内容。

  • 最后,您可以使用react-i18next框架包在github.com/i18next/react-i18next上提供更无缝的集成。

我们可以用一个非常简单的故事来测试我们的翻译:

// Source file: /src/components/i18nform/i18nform.story.js

/* @flow */

import React from "react";
import { storiesOf } from "@storybook/react";

import { I18nForm } from "./";

storiesOf("i18n form", module).add("standard", () => <I18nForm />);

当故事加载时,它看起来如下屏幕截图所示:

我们的输入表单,带有初始值,显示为英文

如果更改数量,底部的文本将相应更新;请参阅以下屏幕截图:

如果我们订购多件物品,将使用复数翻译。

而且,如果将语言更改为西班牙语,所有文本将自动翻译;请查看以下屏幕截图:

通过监听语言更改事件,我们可以强制组件重新渲染自身,并显示新选择语言的翻译

一个小细节:每当您使用日期<input>元素时,日期将根据您计算机的区域设置进行格式化,因此对于西班牙语,显示的值不会改变。但是,元素的值始终相同,采用 ISO 格式:在我们的案例中是2018-09-22。您可以通过使用特殊手工制作的组件来解决这个问题,但我们不会这样做,因为我们关心的是展示翻译是如何工作的。

正如我们所看到的,为国际使用准备应用程序并不是一个非常困难的问题。即使您一开始没有计划这样做,以这种方式工作也是值得的;在现有代码中进行翻译可能更难。

为辅助功能设置(a11y)

在设计网页时,“可访问性”一词指的是提供支持,以便包括残疾人在内的所有人都可以使用您的页面。因此,必须考虑许多需求,例如:

  • 视力限制,从视力不佳,色觉问题,一直到完全失明

  • 听力限制,需要为听力受损的用户提供一些备用方法

  • 移动限制,可能意味着难以或无法使用手部或控制鼠标

  • 认知限制,可能会使屏幕上显示的信息变得复杂

有许多工具可以帮助残疾用户,例如屏幕缩放,语音识别,屏幕阅读器,盲文终端,闭路字幕等,但即使这些工具也需要一些额外的信息才能正常工作。Web 内容可访问性指南WCAG)是由万维网联盟W3C)的Web 可访问性倡议WAI)发布的一套指南。当前版本 2.1,可在线获取,网址为www.w3.org/TR/WCAG21/,基于四个原则,即 POUR 作为首字母缩写:

  • 可感知性**:信息和用户界面组件必须以用户可以感知的方式呈现给用户

  • 可操作性:用户界面组件和导航必须是可操作的

  • 可理解性:用户界面的信息和操作必须是可理解的

  • 健壮性:内容必须足够健壮,以便可以被各种用户代理解释,包括辅助技术

这些原则,引用自引用页面,包括使用颜色的指南,使用键盘工作,为屏幕阅读器提供信息,具有足够的对比度,显示错误等;足够和建议的技术,可以帮助遵循指南;以及成功标准,意味着可用于符合性测试的可测试条件。后者的标准也用于定义三个符合级别:A,最低级别;AA,中等级别,包括所有AAA的成功标准,以及AAA,最难达到的级别,满足所有现有的标准,但也承认对于一些网站来说可能无法实现。

确保您的 Web 应用程序遵循所有指南并应用所有技术并不容易,因此我们将看看如何向React添加一些工具,以使您的任务变得更加容易。

如何做到…

为了检查我们的辅助功能工作,我们将安装一些软件包,因此让我们按照提到的步骤进行:一个用于编写代码时进行静态检查的软件包,使用ESLint,另一个用于运行应用程序时进行动态检查。如果你问自己为什么要使用两个工具而不是一个?,答案是静态工具无法检查一切:例如,如果您将变量的值分配给标题,那么该值在运行时不会为空吗?另一方面,由于您的所有代码都经过了检查,您有机会检测一些在正常测试期间可能被忽略的事情,因此通过使用两个工具,您并不是在做冗余工作,而是增加了发现辅助功能问题的几率。

安装ESLint模块非常简单。首先,我们将使用npm添加该软件包:

npm install eslint-plugin-jsx-a11y --save-dev

然后,我们将不得不稍微修改我们的.eslintrc文件,添加新的插件,并指定我们要强制执行的规则。

{
    .
    .
    .
    "extends": [
        "eslint:recommended",
        "plugin:flowtype/recommended",
        "plugin:react/recommended",
 "plugin:jsx-a11y/recommended"
    ],
    "plugins": ["babel", "flowtype", "react", "jsx-a11y"],
    .
    .
    .
}

如果您不想使用所有规则(就像我们在这里所做的那样),您可以在文件的"rules"部分中指定您关心的规则:请参阅github.com/evcohen/eslint-plugin-jsx-a11y获取有关此内容的详细信息,并检查github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules上提供的所有规则的完整集合。

我们想要的第二个添加是react-a11y,这是一个在 React 渲染函数内部修改的软件包,因此可以在运行时检测到可访问性问题。安装很简单:

npm install react-a11y --save

然后,在应用程序启动时,您将不得不初始化a11y模块,以及您想要检查的规则。规则的格式与ESLint使用的相同。检查github.com/reactjs/react-a11y/tree/master/docs/rules获取完整列表,因为可能会添加新规则。(您还必须查看该列表,以了解哪些规则(如果有的话)具有特殊选项。)默认情况下,所有规则都是“关闭”的,因此您必须明确将它们打开为“警告”或“错误”。截至 2018 年 12 月,完整的配置如下:

import React from "react";
import ReactDOM from "react-dom";
import a11y from "react-a11y";

a11y(React, ReactDOM, {
    rules: {
        "avoid-positive-tabindex": "warn",
        "button-role-space": "warn",
        "hidden-uses-tabindex": "warn",
        "img-uses-alt": "warn",
        "label-uses-for": "warn",
        "mouse-events-map-to-key-events": "warn",
        "no-access-key": "warn",
        "no-hash-ref": "warn",
        "no-unsupported-elements-use-aria": "warn",
        "onclick-uses-role": "warn",
        "onclick-uses-tabindex": "warn",
        "redundant-alt": ["warn", ["picture", "image", "photo", "foto", 
        "bild"]],
        "tabindex-uses-button": "warn",
        "use-onblur-not-onchange": "warn",
        "valid-aria-role": "warn"
    }
});

// a11y.restoreAll() *would undo all changes*

您可能不希望在生产中启用a11y,以避免不必要的减速。

我们已经准备好了;现在让我们看看所有这些是如何结合在一起的。

它是如何工作的…

首先,让我们看看通过ESLint检测到的错误会发生什么,然后我们将转移到运行时问题。

解决静态问题

我们糟糕的a11y编码的第一个“受害者”是我们的 SASS 按钮;请参阅以下截图:

我们的 SASS 按钮至少有两个与可访问性相关的问题

一个a11y规则是你应该能够只使用键盘来使用应用程序,所以我们需要能够通过tabIndex来切换到按钮(这需要使用tabIndex),并提供一个键盘监听器(onKeyPressonKeyDown)。此外,我们的元素的角色(作为按钮工作)必须被指定。更正后的 JSX 代码如下:

<div
    className={
        this.props.normal ? "normalButton" : "alertButton"
    }
    onClick={this.props.onSelect}
 onKeyPress={this.keyDownAsClick}
 tabIndex="0"
 role="button"
>
    <span>{this.props.buttonText}</span>
</div>

新方法.keyDownAsClick()将检查用户是否按下空格键(ASCII 码 32)或ENTER键(ASCII 码 13),如果是,则调用与onClick处理程序相同的逻辑:

keyDownAsClick = (e: { keyCode: number }) => {
    if (e.keyCode === 32 || e.keyCode === 13) {
        this.props.onSelect();
    }
}

我们的输入表格也有一个问题,尽管更简单。请参阅以下截图:

我们的物品订购表格只有一个小的 a11y 问题

问题及其解决方案很明显:建议用onBlur替换onChange,这对用户没有任何影响。鉴于所需更改很小,我们不会显示编辑后的代码,只需编辑文件以替换该方法。

我们可以尝试向我们的表单添加图像,只是为了获得另一个不同的警告。尝试向表单添加 Packt 标志,如下所示:

<img
    src="img/packt-logo.svg"
    style={{ width: "50px", height: "25px" }}
/>

在这种情况下,我们将收到有关需要alt属性的警告(向img标签添加alt="Packt logo"即可描述图像);请查看以下截图:

另一个 a11y 规则要求图像具有描述它们的 alt 属性

最后,让我们看一个我们的工具失败的案例!我们使用styled-components创建的按钮基本上与我们的SASS按钮有相同的问题,但没有报告任何问题;为什么?原因很简单:如果您检查代码(请参阅本章前面的为单独的样式添加 SASS部分),我们没有使用<div><button>实例或任何其他可识别的 HTML 标签,而是使用了<StyledDiv><StyledButton>,我们的a11y eslint插件无法理解。到目前为止,唯一的解决方法是手动将我们的样式组件更改回其原始标记,解决可能出现的任何问题,然后再回到样式版本,这显然不是一个很好的解决方案!

解决运行时问题

如果我们现在尝试在Storybook中使用我们的固定组件,react-a11y不会对它们发表任何意见,但它会报告styled-components的一些问题,这是我们之前无法解决的;请参见以下截图:

react-a11y 运行时测试显示了我们组件中的一些问题

当然,鉴于我们构建的组件与之前的SASS组件相匹配,不足为奇的是,解决辅助功能问题的解决方案是相同的:添加onKeyDowntabIndexrole和一个键处理方法。已更正代码的相关部分如下:

keyDownAsClick = (e: { keyCode: number }) => {
 if (e.keyCode === 32 || e.keyCode === 13) {
 this.props.onSelect();
 }
};

render() {
    return (
        <StyledDiv
            normal={this.props.normal}
            onClick={this.props.onSelect}
 onKeyDown={this.keyDownAsClick}
 tabIndex="0"
 role="button"
        >
            <span>{this.props.buttonText}</span>
        </StyledDiv>
    );
}

当然,我们只是看到了可能出现的所有问题及其解决方案的冰山一角,但真正重要的是您有一些工具来帮助您开发启用a11y的应用程序,正如我们所展示的那样。

还有更多

我们可以做些什么来确保一个完全符合a11y应用程序?不幸的是,您无法仅凭一些工具来管理它。例如,我们选择的工具中没有一个指出我们应该为输入字段添加名称,正如 ARIA 规则所指出的那样(有关更多信息,请参见w3c.github.io/using-aria/#fifth)。此外,有些条件无法在代码中进行测试。例如,指南指出,错误或强制字段不应仅通过颜色(由于色盲)进行突出显示,而应该有一些外部文本或标记;您如何以自动化方式测试这一点?请查看以下截图,这是从govuk-elements.herokuapp.com/errors/example-form-validation-multiple-questions中获取的示例,增强了错误的可见性:

来自英国政府网站的示例输入表单,显示了良好的 a11y 实践,用于错误

没有经过审计,不可能获得 A、AA 或 AAA 级别,但您可以添加更多工具来帮助解决这个问题:

  • W3C Web Accessibility Initiative 在www.w3.org/WAI/ER/tools/提供了一个庞大的工具列表(截至今天为止有 113 个!)

  • A11Y 项目提供了一个社区努力,简化网络辅助功能,展示了一些有用的技术,网址为a11yproject.com/

  • MDN 对 ARIA 进行了全面的概述,这是 W3C 专门为屏幕阅读器提供额外信息的规范,通过使用 HTML 属性,网址为developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques

  • W3C 还提供了许多关于使用 ARIA 的建议,包括代码示例,网址为w3c.github.io/using-aria/

  • 有几种辅助功能检查器,适用于所有主要浏览器,可以在页面运行时诊断页面,所以只需搜索它们;有些作为浏览器扩展,而其他一些是用于添加到您的网站的代码,以便检测和报告可能的问题

即使没有单个工具或一组工具可以确保a11y合规性,您也可以构建自己的一套良好的起始工具;多做一些实验!

第八章:扩展您的应用程序

在本章中,我们将专注于更大、更复杂的应用程序,添加以下示例:

  • 使用 Redux 管理状态

  • 使用 redux-thunk 执行异步操作

  • 使用 react-router 添加路由

  • 在路由中添加授权

  • 性能的代码拆分

介绍

在前两章中,我们看到了如何使用React开发 Web 应用程序,并努力使它们国际化可用,对每个人都可访问,并且样式良好。在本章中,我们将添加一些更多的功能,这些功能是大多数应用程序的典型功能。

使用 Redux 管理状态

构建应用程序有什么困难?显然,您可以使用纯粹的 JS 做任何事情,但当您尝试保持应用程序的 UI 和状态同步时,事情开始变得复杂。您调用服务,获取数据。数据必须在多个位置反映,HTML 元素必须更改、添加或删除等等——这就是复杂性所在。

到目前为止,我们只在组件中处理状态,您完全可以继续这样做:您顶层组件的状态将包括您需要的一切,您可以通过将一切需要的内容作为 props 传递给下面的组件来管理。当然,随着应用程序的增长,这种方法将不太可行。什么时候会出现临界点?Redux是一个管理状态的工具,但其开发人员建议只有在满足以下条件时才使用他们的软件包,我引用自redux.js.org/#before-proceeding-further

  • “您有合理数量的数据随时间变化。”

  • “您需要一个状态的单一真相来源。”

  • “您会发现将所有状态保留在顶层组件中已不再足够。”

当然,这些规则并不是非常明确,并且允许主观性,因此没有明确的点需要使用Redux。然而,对于大多数现代大型应用程序来说,可以肯定地说Redux可能会派上用场,因此让我们在本章的其余部分假设如此。

在本教程中,我们将安装Redux,并开始看看如何在React中使用它。

准备工作

在任何其他操作之前,我们必须安装一些软件包:redux,状态管理软件包本身,以及用于在React中使用Reduxreact-redux绑定。(您可以在其他框架或库中使用Redux,但本书不涵盖此内容。)安装很简单,只需使用npm,就像我们以前做过的那样:

npm install redux react-redux --save

我们必须学习几个概念才能使用Redux

  • 存储: 应用程序状态的唯一位置(“单一真相来源”)。您在应用程序开始时全局创建存储,然后将组件连接到它。连接的组件在状态更改时将重新呈现,并且它们渲染所需的一切都应该来自存储。存储只能通过操作进行更新。

  • 操作: 您的组件dispatch的对象,其中包含您希望的任何新数据。操作始终具有type属性以区分不同类型,并且没有限制的任何其他数据。操作通常由操作创建者创建以简化编码,并在被调度后由减速器处理。

  • 减速器: 纯函数(意味着没有副作用!)根据操作接收的数据改变应用程序的状态。状态永远不会被修改;而是必须产生一个新状态,其中包含必要的任何更改。减速器根据旧状态和操作中接收的数据产生新状态。

这在以下图表中显示:

Redux 中的数据流是严格单向的,始终遵循循环模式

使用这种流程循环有助于保持状态和视图同步-因为后者是根据前者产生的,并且对状态的所有更新立即导致视图更新。我们已经安装了必要的工具,并且知道我们必须做什么;现在,让我们开始一个实际的例子。

您可能希望查看eslint-plugin-redux,它为您提供了如何充分利用Redux的一些规则。在github.com/DianaSuvorova/eslint-plugin-react-redux上查看它,如果您感兴趣,将其一些或全部规则添加到您的ESLint配置中;默认情况下,它们都是禁用的。

在这个示例中,让我们做一个简单的示例,展示前一节中大部分概念。在网上阅读了多篇文章和教程之后,我认为有必要提供涉及计数器的某种示例,并且让我们不要打破传统,在这里也做一下!我们想要一个可以通过点击按钮修改的计数器,并且我们还想知道我们点击了多少次。

如何做...

在我们开始编写代码之前,让我们公开:我们将为本可以轻松解决的问题编写太多行代码而不需要 Redux - 我们不会有合理数量的数据随时间变化,而只有几个计数,我们肯定不会发现将所有状态保留在顶级组件中不够好,但由于我们想要一个简单的初始示例,我们仍然会使用Redux

定义动作

首先,我们需要一些动作。我们想要增加和减少计数器,我们还想将其重置为零。前两个要求可以通过单个动作实现(减少只是通过负数增加),所以我们需要两个动作,每个都由一个常量标识:

// Source file: src/counterApp/counter.actions.js

/* @flow */

export const COUNTER_INCREMENT = "counter:increment";
export const COUNTER_RESET = "counter:reset";

export type CounterAction = {
    type: string,
    value?: number
};

export const reset = () =>
    ({
        type: COUNTER_RESET
    }: CounterAction);

export const increment = (inc: number) =>
    ({
        type: COUNTER_INCREMENT,
        value: inc
    }: CounterAction);

export const decrement = (dec: number) =>
    ({
        type: COUNTER_INCREMENT,
        value: -dec
    }: CounterAction);

// *returning increment(-dec) would have worked as well*

实际上,我们应该说increment()decrement()reset()是动作创建者;这些函数返回的实际动作是值。

编写 reducer

然后,在定义我们的动作之后,我们需要一个 reducer 来处理它们。当然,这也意味着我们必须定义我们状态的形状和其初始值:

// Source file: src/counterApp/counter.reducer.js

/* @flow */

import { COUNTER_INCREMENT, COUNTER_RESET } from "./counter.actions";

import type { CounterAction } from "./counter.actions.js";

export const reducer = (
 state = {
 // *initial state*
 count: 0,
 clicks: 0
 },
    action: CounterAction
) => {
    switch (action.type) {
        case COUNTER_INCREMENT:
            return {
                count: state.count + action.value,
                clicks: state.clicks + 1
            };

        case COUNTER_RESET:
            return { count: 0, clicks: state.clicks + 1 };

        default:
            return state;
    }
};

我们的 reducer 基本上是一个 switch 语句;当找到正确的类型时,将返回一个新的状态。这种模式非常重要,也是Redux的关键。我们不仅仅更新状态,而是每次生成一个新的状态对象。我们需要一个默认情况,因为动作会传递给所有 reducer(在我们的情况下不是这样,因为我们只有一个),所以可能会有一个 reducer 会忽略一个动作。

在我们的示例中,我们有一个单一的 reducer 和一组单一的动作,因此可以说它们都可以放在同一个文件中,但这在大多数应用程序中不太可能。此外,如果您的状态增长过大,请查看combineReducers(),并且您将能够以更有组织的方式工作,使用多个 reducer 和将 store 分成逻辑片段。

定义 store

然后,在所有先前的定义之后,我们可以定义我们的 store:

// Source file: src/counterApp/store.js

/* @flow */

import { createStore } from "redux";

import { reducer } from "./counter.reducer.js";

export const store = createStore(reducer);

顺便说一句,还可以通过将其作为第二个参数传递给createStore()来定义状态的初始值。

构建我们的组件

最后,完全定义了我们的 store 以及将要分派的动作和将处理它们的 reducer,我们可以通过定义我们的组件来快速完成。我们的Counter组件将具有文本、计数器值和一些按钮。请注意,我们将count(计数器值)作为 prop 接收,并且我们还有一个dispatch()函数作为另一个 prop:

// Source file: src/counterApp/counter.component.js

/* @flow */

import React from "react";
import { PropTypes } from "prop-types";

import {
    increment,
    decrement,
    reset,
    CounterAction
} from "./counter.actions.js";

export class Counter extends React.PureComponent<{
    count: number,
    dispatch: CounterAction => any
}> {
    static propTypes = {
        count: PropTypes.number.isRequired,
        dispatch: PropTypes.func.isRequired
    };

    onAdd1 = () => this.props.dispatch(increment(1));
    onSub2 = () => this.props.dispatch(decrement(2));
    onReset = () => this.props.dispatch(reset());

    render() {
        return (
            <div>
                Value: {this.props.count}
                <br />
                <button onClick={this.onAdd1}>Add 1</button>
                <button onClick={this.onSub2}>Subtract 2</button>
                <button onClick={this.onReset}>Reset</button>
            </div>
        );
    }
}

每个按钮都会分派一个动作,这个动作是由我们之前看到的动作创建者创建的。

我们需要第二个组件。ClicksDisplay组件更简单!我们将clicks的总数作为 prop 接收,并简单地显示它:

// Source file: src/counterApp/clicksDisplay.component.js

/* @flow */

import React from "react";
import { PropTypes } from "prop-types";

export class ClicksDisplay extends React.PureComponent<{
    clicks: number
}> {
    static propTypes = {
        clicks: PropTypes.number.isRequired
    };

    render() {
        return <div>Clicks so far: {this.props.clicks}</div>;
    }
}

连接组件到 store

一个很好的设计规则是分离关注点,它说您不应该直接连接组件到存储,而是创建一个新组件,一个连接的组件,它将从存储中获取所需的任何内容,并将其传递给原始组件。这个规则将简化我们所有的测试:我们的基本组件仍然会通过 props 接收所有内容,我们不必对存储或类似的东西进行任何模拟来测试它们。

Dan Abramov 的一篇关于定义组件的好文章是Presentational and Container Components,网址是medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0。关于这一点的更多信息可以在Container Components中找到,网址是medium.com/@learnreact/container-components-c0e67432e005

因此,根据这个规则,对于我们想要连接的每个组件,我们将添加一个新的连接版本。在我们的情况下,计数的连接版本将如下,因此组件的count属性将接收state.count的值:

// Source file: src/counterApp/counter.connected.js

/* @flow */

import { connect } from "react-redux";

import { Counter } from "./counter.component";

const getProps = state => ({ count: state.count });

export const ConnectedCounter = connect(getProps)(Counter);

同样,用于显示点击总数的组件将以类似的方式连接:

// Source file: src/counterApp/clicksDisplay.connected.js

/* @flow */

import { connect } from "react-redux";

import { ClicksDisplay } from "./clicksDisplay.component";

const getProps = state => ({
 clicks: state.clicks
});

export const ConnectedClicksDisplay = connect(getProps)(ClicksDisplay);

我们将把这些连接的组件放在我们的主要代码中,它们将从存储中获取值,并将其传递给我们的原始组件,这些组件将完全不变。

定义主页

我们的最后一段代码是基于create-react-app生成的标准App.js文件;index.js导入了App类:

// Source file: src/App.counter.js

/* @flow */

import React, { Component, Fragment } from "react";
import { Provider } from "react-redux";

import { store } from "./counterApp/store";
import { ConnectedCounter, ConnectedClicksDisplay } from "./counterApp";

class App extends Component<{}> {
    render() {
        return (
 <Provider store={store}>
                <Fragment>
                    <ConnectedCounter />
                    <hr />
                    <ConnectedClicksDisplay />
                </Fragment>
 </Provider>
        );
    }
}

这里的关键部分是<Provider>组件。这是React最新的Context功能的一部分(有关更多信息,请参见reactjs.org/docs/context.html),它为任何以下组件提供对store对象的访问;connect()函数(我们在上一节中使用过)使用它为这些组件提供 props,并订阅它们的更改。顺便说一句,我们在这里使用Fragment,只是因为Provider期望一个单一元素。除此之外,<div>也可以工作。

将所有内容放在一起,让我们看看这是如何工作的!

它是如何工作的...

当我们启动应用程序时,当前状态计数为零,点击次数也是如此,因此屏幕看起来如下:

我们的计数器应用程序处于初始状态。

在点击以下按钮后,值和点击次数会更新,并且视图会自动反映这些变化;请参见以下截图。一定要理解一切是如何发生的:

  • 每当您点击按钮时,都会触发一个动作。

  • 当 reducer 处理动作时,它会创建一个新的状态。

  • React看到状态改变时,它会重新绘制您的应用程序。看一下以下截图:

每次点击后,计数值和点击次数都会自动更新,并且视图会重新呈现

因此,我们已经看到,我们可以使用Redux来保持全局状态,并在需要时重新呈现视图,而我们不需要额外的工作。现在,让我们考虑一个常见的问题:当我们进行异步更改时,例如,当我们进行 Ajax 调用时,我们该如何处理?

另请参阅

Redux并不是您可以与React一起使用的唯一状态管理包。最受欢迎的是MobX,它添加了响应式编程概念,例如可观察对象和数组;请查看github.com/mobxjs/mobx。它的基本范式与Redux大不相同,在许多方面更简单,并更类似于电子表格;但在使用之前,请准备好改变您的思维方式!

使用 redux-thunk 进行异步操作

我们如何执行异步动作,比如调用 web 服务?这种调用需要一些不同的处理:如果我们仍在等待 Ajax 调用的结果,那么您不能只是分派一个动作。Reduxthunk中间件允许您编写一个返回函数而不是动作的动作创建者;该函数可以访问存储内容和分派函数本身,并且可以执行异步调用,分派其他函数等。

似乎thunk这个词的起源来自一个非常晚的编程会话,在那里,在经过多个小时的工作后,找到了一个之前考虑过的问题的解决方案,thunk成为了它的名字,作为think的派生词,你可以理解为什么!

这听起来有点神秘,所以让我们深入了解它是如何工作的,通过在第六章中构建的国家/地区组件的变体,使用 React 进行开发,只是这一次我们将使用实际的 API 调用——对于这些调用,我们已经有了我们的Node服务器,我们在第四章中创建了它,使用 Node 实现 RESTful 服务

如何做…

让我们修改我们的地区应用程序,以便它将连接到后端服务。

首先,要使用redux-thunk,我们必须安装该软件包:

npm install redux-thunk --save

然后,我们必须修改存储以使用新的中间件。(我们将在本章后面和下一章中看到更多的中间件。)这个改变非常小,如下面的代码所示:

// Source file: src/regionsApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";

import { reducer } from "./worlds.reducer.js";

export const store = createStore(reducer, applyMiddleware(thunk));

定义动作

每当您尝试从服务获取数据时,一个常见的模式如下:

  • 在您发出请求时触发一个动作;这个动作可能设置一些标志,然后由某个组件使用,以显示“正在加载…”文本或旋转图标,以显示正在进行某些操作,用户应该等待

  • 如果服务请求成功,触发一个表示成功的动作,重置正在加载…标志,并提供必须添加到存储中的新数据

  • 如果服务请求失败,重置正在加载…标志,但以某种方式表示错误

我们应用程序需要的动作首先与获取国家下拉列表的国家列表有关,其次是获取给定国家的地区列表。动作如下;首先是与国家相关的动作:

// Source file: src/regionsApp/world.actions.js

/* @flow */

// Countries actions

export const COUNTRIES_REQUEST = "countries:request";
export const COUNTRIES_SUCCESS = "countries:success";
export const COUNTRIES_FAILURE = "countries:failure";

export type CountriesAction = {
    type: string,
    country?: string,
    listOfCountries?: [object]
};

export const countriesRequest = () =>
    ({
        type: COUNTRIES_REQUEST
    }: CountriesActions);

export const countriesSuccess = (listOfCountries: []) =>
    ({
        type: COUNTRIES_SUCCESS,
        listOfCountries
    }: CountriesActions);

export const countriesFailure = () =>
    ({
        type: COUNTRIES_FAILURE
    }: CountriesActions);

// *continues...*

对于地区,我们有一个类似的设置:

// *...continued*

// *Regions actions*

export const REGIONS_REQUEST = "regions:request";
export const REGIONS_SUCCESS = "regions:success";
export const REGIONS_FAILURE = "regions:failure";

export type RegionsAction = {
    type: string,
    listOfRegions?: [object]
};

export const regionsRequest = (country: string) =>
    ({
        type: REGIONS_REQUEST,
        country
    }: RegionsActions);

export const regionsSuccess = (listOfRegions: [{}]) =>
    ({
        type: REGIONS_SUCCESS,
        listOfRegions
    }: RegionsActions);

export const regionsFailure = () =>
    ({
        type: REGIONS_FAILURE
    }: RegionsActions);

注意动作常量的风格——我们使用"countries""regions"作为一种命名空间(如"countries:success""regions:success"),以避免可能的名称重复。

编写 reducer

我们有动作;现在,我们需要一个 reducer。它的代码也不复杂:

// Source file: src/regionsApp/world.reducer.js

/* @flow */

import {
    COUNTRIES_REQUEST,
    COUNTRIES_SUCCESS,
    COUNTRIES_FAILURE,
    REGIONS_REQUEST,
    REGIONS_SUCCESS,
    REGIONS_FAILURE
} from "./world.actions";

import type { CountriesAction, RegionsAction } from "./world.actions";

// import type { CounterAction } from "./world.actions.js";

export const reducer = (
    state: object = {
        // initial state
        loadingCountries: false,
        currentCountry: "",
        countries: [],
        loadingRegions: false,
        regions: []
    },
    action: CountriesAction | RegionsAction
) => {
    switch (action.type) {
        case COUNTRIES_REQUEST:
            return {
                ...state,
                loadingCountries: true,
                countries: []
            };

        case COUNTRIES_SUCCESS:
            return {
                ...state,
                loadingCountries: false,
                countries: action.listOfCountries
            };

        case COUNTRIES_FAILURE:
            return {
                ...state,
                loadingCountries: false,
                countries: []
            };

        case REGIONS_REQUEST:
            return {
                ...state,
                loadingRegions: true,
                currentCountry: action.country,
                regions: []
            };

        case REGIONS_SUCCESS:
            return {
                ...state,
                loadingRegions: false,
                regions: action.listOfRegions
            };

        case REGIONS_FAILURE:
            return {
                ...state,
                loadingRegions: false,
                regions: []
            };

        default:
            return state;
    }
};

唯一需要注意的是以下代码风格,以一种您可能以前没有见过的方式使用扩展运算符:

    return {
 ...state,
        loadingCountries: true,
        currentCountry: "",
        countries: []
    };

当返回新状态时,我们必须小心,以免丢失旧状态的一部分,因此以...state开头的对象是一种非常常见的编码模式。

为了避免意外更改状态,一个很好的解决方案是使用immutable-js(在github.com/facebook/immutable-js/)或seamless-immutable(在github.com/rtfeldman/seamless-immutable)等软件包处理状态,因为这样你就无法修改状态对象;你被迫产生一个新的状态对象,避免许多难以发现的错误。

修改国家下拉列表

我们之前有一个国家下拉列表,接收了一个国家列表。让我们重写它,以便如果没有提供这样的列表,它将使用一个函数调用 thunk,并从我们的服务器获取国家:

// Source file: src/regionsApp/countrySelect.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

import "../general.css";

export class CountrySelect extends React.PureComponent<{
    dispatch: ({}) => any
}> {
    static propTypes = {
 loading: PropTypes.bool.isRequired,
        list: PropTypes.arrayOf(PropTypes.object).isRequired,
        onSelect: PropTypes.func.isRequired,
 getCountries: PropTypes.func.isRequired
    };

    componentDidMount() {
 if (this.props.list.length === 0) {
 this.props.getCountries();
 }
    }

    onSelect = (e: { target: HTMLOptionElement }) =>
        this.props.onSelect(e.target.value);

    render() {
        if (this.props.loading) {
            return <div className="bordered">Loading countries...</div>;
        } else {
            const sortedCountries = [...this.props.list].sort(
                (a, b) => (a.countryName < b.countryName ? -1 : 1)
            );

            return (
                <div className="bordered">
                    Country:&nbsp;
                    <select
                        onChange={this.onSelect}
                        onBlur={this.onSelect}
                    >
                        <option value="">Select a country:</option>
                        {sortedCountries.map(x => (
                            <option
                                key={x.countryCode}
                                value={x.countryCode}
                            >
                                {x.countryName}
                            </option>
                        ))}
                    </select>
                </div>
            );
        }
    }
}

.componentDidMount()方法中,如果没有可用的列表,我们调用一个函数(很快我们会看到),来获取该列表,并将其放入存储中。将使用loading属性,因此在等待国家到达时,将显示加载国家...文本,而不是空的<select>组件。

首先,我们的getCountries()函数的签名有点奇怪(一个返回异步函数的函数,带有dispatch参数),但这是redux-thunk所需的。逻辑更有趣:

// Source file: src/regionsApp/countrySelect.connected.js

/* @flow */

import { connect } from "react-redux";

import { CountrySelect } from "./countrySelect.component";
import { getCountries, getRegions } from "./world.actions";

const getProps = state => ({
 list: state.countries,
 loading: state.loadingCountries
});

const getDispatch = dispatch => ({
 getCountries: () => dispatch(getCountries()),
 onSelect: c => dispatch(getRegions(c))
});

export const ConnectedCountrySelect = connect(
    getProps,
    getDispatch
)(CountrySelect);

这个组件的连接版本不像以前那样简短,因为我们将不得不将 props 连接到存储,并且还要连接要派发的动作;我在以下片段的代码中突出显示了这些部分:

由于大部分新行为将发生在国家下拉组件中,我们可以用一个非常简单的表格来处理:

// Source file: src/regionsApp/regionsTable.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

import "../general.css";

export class RegionsTable extends React.PureComponent<{
    list: Array<{
        regionCode: string,
        regionName: string
    }>
}> {
    static propTypes = {
        list: PropTypes.arrayOf(PropTypes.object).isRequired
    };

    static defaultProps = {
        list: []
    };

    render() {
        if (this.props.list.length === 0) {
            return <div className="bordered">No regions.</div>;
        } else {
            const ordered = [...this.props.list].sort(
                (a, b) => (a.regionName < b.regionName ? -1 : 1)
            );

            return (
                <div className="bordered">
                    {ordered.map(x => (
                        <div key={x.countryCode + "-" + x.regionCode}>
                            {x.regionName}
                        </div>
                    ))}
                </div>
            );
        }
    }
}

我们还按字母顺序对区域进行排序,并创建一个简单的<div>列表,每个列表中都有一个区域的名称。连接的组件可以访问区域列表和加载标志,以便在从服务器获取区域列表时显示一些内容:

// Source file: src/regionsApp/regionsTable.connected.js

/* @flow */

import { connect } from "react-redux";

import { RegionsTable } from "./regionsTable.component";

const getProps = state => ({
 list: state.regions,
 loading: state.loadingRegions
});

export const ConnectedRegionsTable = connect(getProps)(RegionsTable);

设置主应用程序

我们已经拥有所有必要的组件,所以现在我们可以制作我们的应用程序。(不,我没有忘记承诺的功能!)我们的主要代码将如下所示:

// Source file: src/App.regions.js

/* @flow */

import React, { Component, Fragment } from "react";
import { Provider } from "react-redux";

import {
    ConnectedCountrySelect,
    ConnectedRegionsTable
} from "./regionsApp";

import { store } from "./regionsApp/store";

class App extends Component<{}> {
    render() {
        return (
            <Provider store={store}>
                <Fragment>
                    <ConnectedCountrySelect />
                    <ConnectedRegionsTable />
                </Fragment>
            </Provider>
        );
    }
}

export default App;

使用 thunks

我们需要两个函数:一个用于获取国家列表,另一个用于获取当前选择国家的区域。让我们从前者开始,并记住这段代码将添加到我们之前看到的动作文件中:

如果我们选择一个国家,服务将被调用,并且结果将被显示;请参阅以下截图。这个逻辑也很有趣:

// Source file: src/regionsApp/world.actions.js

import axios from "axios";

export const getCountries = () => async dispatch => {
    try {
        dispatch(countriesRequest());
        const result = await axios.get(`http://fk-server:8080/countries`);
        dispatch(countriesSuccess(result.data));
    } catch (e) {
        dispatch(countriesFailure());
    }
};

当用户选择一个国家时,下拉列表使用一个 thunk 来获取其区域。

  • 首先,我们派发countriesRequest()动作创建者的结果,这样应用程序的状态将显示我们正在等待一些结果。

  • 然后,我们使用axios()包,就像我们之前在 Node 工作中使用的那样,调用我们的服务器并获取国家列表。

  • 修改区域表

  • 您还会注意到,我对国家进行了排序,因为服务按国家代码顺序发送它们。

当国家列表返回时,将派发一个countriesSuccess()动作,并附上收到的国家列表。

要处理区域,我们将有类似的代码:

// Source file: src/regionsApp/world.actions.js

export const getRegions = (country: string) => async dispatch => {
    if (country) {
        try {
            dispatch(regionsRequest());
            const result = await axios.get(
                `http://fk-server:8080/regions/${country}`
            );
            dispatch(regionsSuccess(result.data));
        } catch (e) {
            dispatch(regionsFailure());
        }
    } else {
        dispatch(regionsFailure());
    }
};

现在,事情开始变得有趣起来。我们为国家下拉列表提供了两个函数,这两个函数都将使用 thunks 来连接到服务器。让我们来看看它们!

它是如何工作的...

当我们npm start我们的应用程序时,我们看到一个非常简单的设计;请参阅以下截图。让我们了解我们是如何到达这里的:

  1. 显示了主页面。

  2. 当国家下拉列表接收到一个空的国家列表时,使用 thunk 获取所有国家。

  3. 如果调用成功,我们会派发一个countriesSuccess()动作,并传递我们收到的国家列表。

  4. 减速器更新了存储,将loadingCountries标志设置为 true

  5. 页面被重绘,而不是下拉列表,显示了"加载国家..."文本。

  6. 派发了一个getCountries()动作。

  7. 当区域表绘制时没有任何区域,会显示一些"没有区域"文本。

  8. 页面被重绘,现在国家下拉列表有一个国家列表要显示,如下截图所示:

代码与以前相似,所以我们不需要做太多分析。

我们的初始屏幕

如您所见,我们的代码能够派发许多动作,但要等到合适的时机才能这样做。

  1. 如果调用失败,我们会派发一个countriesFailure()动作,以显示失败。

  2. 派发了一个regionsRequest()动作。

  3. 减速器更新了存储,包括所有国家,并将loadingCountries重置为 false。

  4. 当地区返回时,将分派一个regionsSuccess()动作,

  5. 在减速器创建了一个新状态后,页面被重绘,显示了地区列表。参考以下截图:

调用我们的 restful 服务器的结果

然而,你可能会想,"加载国家..."的文本在哪里?问题(如果你愿意这样说!)是服务响应太快了,所以消息一闪而过就消失了。如果我们在getCountries()函数中作弊并添加一些延迟,我们可以看到它更长一点。在调用axios()之前包含以下行以延迟执行五秒:

await new Promise(resolve => setTimeout(resolve, 5000));

现在,你将有时间看到缺失的状态,如下截图所示:

添加一些延迟让我们在等待国家列表时看到显示的内容

所以,现在我们可以看到我们的状态处理是正确的,并且一切都显示得如我们所希望的那样!

还有更多...

当你编写你的动作创建者时,它实际上不仅传递了dispatch(),还传递了getState()函数。这个函数可以用来访问当前的状态值。我们没有使用这个,但是,例如,你可以用它来进行缓存或其他类似的想法。我们的getRegions()函数可以如下所示,以检测你是否再次请求相同国家的地区:

// Source file: src/regionsApp/world.actions.js

export const getRegions2 = (country: string) => async (
 dispatch,
 getState
) => {
 if (country === getState().currentCountry) {
 console.log("Hey! You are getting the same country as before!");
 }

    if (country) {
        .
        .
        . *everything as earlier*
        .
        .
    }
};

在我们的情况下,我们除了记录一条消息之外,没有做任何其他事情,但是你可以使用接收到的参数加上当前状态的内容来进行一些更复杂的逻辑。

使用 react-router 添加路由

当你使用React(就像其他前端框架一样,比如AngularVue,只是举几个例子)时,通常会开发单页应用程序SPAs),当你访问它们的不同部分时,它们永远不会进行完整的页面重新加载;相反,新内容会被切换到视图中,但仍停留在原始页面上。即使这种导航体验现代而流畅,但仍然期望一些更传统的路由方面:后退前进按钮应该根据你的浏览历史移动你,你还应该能够将应用程序的特定部分添加到书签中,以便以后能够快速返回到它。

通常情况下,使用React处理路由有很多种方式,但react-router目前是迄今为止最常用的库,可能是因为它真的符合React的范式:路由只是你渲染并且按预期工作的组件!让我们从构建一个简单的应用程序开始,以展示路由是如何工作的,然后在下一节中,我们将通过要求在允许访问某些路由之前进行身份验证来增加一些复杂性。

入门

react-router库实际上是处理React应用程序中的路由的标准。安装它需要一个微妙的区别:不是直接获取那个包,而是必须选择一个不同的包,react-router-dom,它本身会负责获取react-router

npm install react-router-dom --save

我们可以轻松地构建一个应用程序,其中包含多个链接,一个路由器将负责渲染正确的视图,甚至为错误的链接提供一个 404 页面。当然,我们将专注于路由方面,因此在其他方面,我们的应用程序将更像是一个骨架,而不是一个实际可用的网页——不要开始对它非常简单的样式着迷!

如何做...

在这个教程中,我们将创建一个基本的应用程序,但有几个路由;让我们看看如何做到这一点。

首先,我们需要导入一些包并创建一些组件,这些组件将代表我们应用程序中的不同页面。对于后者,因为我们不打算包含任何实际的逻辑或内容,我们将使用非常简单的功能组件,它们只渲染一个H1标题...我告诉过你我们的应用程序会非常简单!

// Source file: src/App.routing.js

/* @flow */

import React, { Component } from "react";
import { Provider } from "react-redux";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";

import { store } from "./routingApp/store";

const Home = () => <h1>Home Sweet Home</h1>;
const Help = () => <h1>Help! SOS!</h1>;
const Alpha = () => <h1>Alpha</h1>;
const Bravo = () => <h1>Bravo</h1>;
const Charlie = () => <h1>Charlie</h1>;
const Zulu = () => <h1>Zulu</h1>;
const Error404 = () => <h1>404 Error!</h1>;

// *continued...*

现在,为了继续,我们必须规划我们的应用程序。我们将有一个带有<nav>栏的<header>,其中我们将包括到我们应用程序各部分的链接。在下面,我们将有一个常见区域,其中将呈现正确的组件。我们的<App>组件可能如下所示-虽然在现实生活中,您可能会在单独的文件中定义所有路由;我在这里放置了所有内容以保持简洁:

// *...continued*

class App extends Component<{}> {
    render() {
        return (
            <Provider store={store}>
 <BrowserRouter>
                    <div>
                        <header>
                            <nav>
                                <Link to="/">Home</Link>&nbsp;
                                <Link to="/about/routing">
                                    About Routing
                                </Link>&nbsp;
                                <Link to="/alpha">Alpha...</Link>&nbsp;
                                <Link to="/bravo">Bravo...</Link>&nbsp;
                                <Link to="/charlie">Charlie...
                                </Link>&nbsp;
                                <Link to="/wrong">...Wrong...
                                </Link>&nbsp;
                                <Link to="/zulu">Zulu</Link>&nbsp;
                                <Link to="/help">Help</Link>&nbsp;
                            </nav>
                        </header>

                        <Switch>
                            <Route path="/" component={Home} />
                            <Route path="/help" component={Help} />
 <Route
 path="/about/:something"
 render={props => (
 <div>
 <h1>About...</h1>
 {props.match.params.something}
 </div>
 )}
 />
                            <Route path="/alpha" component={Alpha} />
                            <Route path="/bravo" component={Bravo} />
                            <Route path="/charlie" component={Charlie} 
                            />
                            <Route path="/zulu" component={Zulu} />
                            <Route component={Error404} />
                        </Switch>
                    </div>
                </BrowserRouter>
            </Provider>
        );
    }
}

export default App;

我已经突出显示了代码的几个部分;让我们看看为什么:

  • <BrowserRouter>是基于 HTML5“History”API 的组件,负责保持视图与 URL 同步;后者的更改将反映在新视图中。

  • <Link ...>是您必须使用的组件,而不是通常的<a ...> HTML 标签,to=指向所需的路由。

  • <Switch>是一个组件,它呈现第一个匹配当前位置的子<Route><Redirect>组件(我们很快将使用<Redirect>)。

  • <Route ...>定义了在路径匹配时必须呈现的组件。请注意,您可能需要精确指定以避免错误的巧合;否则,访问"/alpha"将匹配第一个路由"/",并显示错误的组件。您可以通过使用component=或提供render()函数来指定要呈现的内容;当您需要显示多个组件或获取一些参数时,后者非常有用。特别是,我们在"/about/:something"中使用了这个;当匹配到这个路由时,类似于Express(查看第四章中的添加路由部分,在使用 Node 实现 RESTful 服务中),将提供一个新的 prop,其属性与 URL 的冒号开头部分相符。您可以通过指定path=来省略这一点,然后您将有一个通配符,这对于 404 错误非常有用,就像我们在这里做的那样。

所以,我们有了代码;让我们看看它的运行情况。

它是如何工作的...

如果您npm start应用程序,然后导航到它,您将得到主页,如下截图所示:

我们的路由应用程序,显示了基本“/”路由的组件

如果选择任何有效路由(即,至少现在不要选择错误的路由!),将激活匹配的路由,并显示相应的组件,如下截图所示:

选择有效路由会得到相应的组件

最后,如果选择了错误的路由,将显示默认组件,如下所示:

我们的中的最后一个路由是未定义路由的通配符

还有更多...

有一件事我们还没有用过:直接导航到给定路由或返回到上一个位置等。每当匹配到一个<Route>时,呈现的组件都会获得一些特殊的 props,您可以使用:

因此,我们现在能够处理路由;让我们继续处理需要授权的路由。

在路由中添加授权

我们之前的路由示例工作得很好,但在一些应用程序中,您可能需要授权,以便只有已登录的用户才能访问您网站的部分。 (如果您使用的是我们在第四章中开发的 API,使用 Node 实现 RESTful 服务,则还需要用户进行标识,该 API 需要JSON Web TokenJWT)。因此,让我们看看我们需要做哪些额外工作,以便在页面上既有受限制的路由又有不受限制的路由。

如何做…

让我们通过保护一些路由并要求先前成功登录来为我们的应用程序添加授权。

我们可以找到一个非常符合React风格的解决方案。我们将有一些任何人都可以无限制访问的未受保护路由,以及需要登录的受保护路由。我们将需要两个组件。

创建一个登录组件

首先,让我们创建一个<Login>组件,我们将调用我们的 RESTful 服务器,向其传递用户名和密码,(如果值正确)然后获取 JWT:

// Source file: src/routingApp/login.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import { Redirect } from "react-router-dom";

export class Login extends React.PureComponent<{
    logging: boolean
}> {
    static propTypes = {
        onLogin: PropTypes.func.isRequired,
        logging: PropTypes.bool.isRequired,
        token: PropTypes.string.isRequired,
        location: PropTypes.object
    };

    state = {
        userName: "",
        password: ""
    };

    onUserNameBlur = e => this.setState({ userName: e.target.value });

    onPasswordBlur = e => this.setState({ password: e.target.value });

    onLoginClick = () =>
        this.props.onLogin(this.state.userName, this.state.password);

    render() {
        if (
            this.state.userName &&
            this.state.password &&
            this.props.token
        ) {
            return (
                <Redirect to={this.props.location.state.from.pathname} />
            );
        } else {
            return (
                <div>
                    <h1>Login Form</h1>
                    <div>
                        User:<input
                            type="text"
                            onBlur={this.onUserNameBlur}
                        />
                    </div>
                    <div>
                        Password:
                        <input
                            type="password"
                            onBlur={this.onPasswordBlur}
                        />
                    </div>
                    <button
                        onClick={this.onLoginClick}
                        disabled={this.props.logging}
                    >
                        Login
                    </button>
                </div>
            );
        }
    }
}

定义操作和减速器

在深入细节之前,让我们看看我们将拥有的减速器和操作。前者非常简单,因为基本上我们关心的只是有一个token和一个logging标志:

// Source file: src/routingApp/login.reducer.js

/* @flow */

import {
    LOGIN_REQUEST,
    LOGIN_SUCCESS,
    LOGIN_FAILURE
} from "./login.actions";

export const reducer = (
    state: object = {
        // initial state
        logging: false,
        token: ""
    },
    action
) => {
    switch (action.type) {
        case LOGIN_REQUEST:
            return {
                ...state,
                logging: true,
                token: ""
            };

        case LOGIN_SUCCESS:
            return {
                ...state,
                logging: false,
                token: action.token
            };

        case LOGIN_FAILURE:
            return {
                ...state,
                logging: false
            };

        default:
            return state;
    }
};

我们将有一些操作创建者,这将帮助我们了解其余部分。其中一个重要的是attemptLogin(),它尝试连接到服务器,如果成功则存储令牌,标记用户已登录:

// Source file: src/routingApp/login.actions.js

/* @flow */

import { loginService } from "./serviceApi";

export const LOGIN_REQUEST = "login:request";
export const LOGIN_SUCCESS = "login:success";
export const LOGIN_FAILURE = "login:failure";

export const loginRequest = () => ({
    type: LOGIN_REQUEST
});

export const loginSuccess = (token: string) => ({
    type: LOGIN_SUCCESS,
    token
});

export const loginFailure = () => ({
    type: LOGIN_FAILURE
});

// Complex actions:

export const attemptLogin = (
    user: string,
    password: string
) => async dispatch => {
    try {
        dispatch(loginRequest());
        *// the next line delays execution for 5 seconds:*
 *// await new Promise(resolve => setTimeout(resolve, 5000));*
        const result = await loginService(user, password);
        dispatch(loginSuccess(result.data));
    } catch (e) {
        dispatch(loginFailure());
    }
};

我们将把写一个<LogOut>组件留给您作为练习,它将提供一个按钮,当点击时将调用一个动作来删除当前令牌。

创建一个保护路由的组件

要保护一个路由,让我们创建一个新组件,它将检查用户是否已登录。在第一种情况下,路由将显示,而不需要进一步操作。但是,在第二种情况下,不会显示原始路由组件,而是会产生<Redirect>,将用户重定向到登录页面:

// Source file: src/routingApp/authRoute.component.js

/* @flow */

import React from "react";
import { Route, Redirect } from "react-router-dom";
import PropTypes from "prop-types";

export class Auth extends React.Component<{
    loginRoute: string,
    token: string,
    location: object
}> {
    static propTypes = {
        loginRoute: PropTypes.string.isRequired,
        token: PropTypes.string.isRequired,
        location: PropTypes.object
    };

    render() {
        const myProps = { ...this.props };
        if (!myProps.token) {
            delete myProps.component;
            myProps.render = () => (
                <Redirect
                    to={{
                        pathname: this.props.loginRoute,
                        state: { from: this.props.location }
                    }}
                />
            );
        }
        return <Route {...myProps} />;
    }
}

我们将把这个组件连接到存储,以便它可以访问当前令牌和登录页面的路径:

// Source file: src/routingApp/authRoute.connected.js

/* @flow */

import { connect } from "react-redux";

import { Auth } from "./authRoute.component";
export const AuthRoute = connect(state => ({
    token: state.token,
    loginRoute: "/login"
}))(Auth);

现在,我们拥有了一切我们需要的东西;让我们让它工作起来!

它是如何工作的…

要使用我们的新组件,我们将在本章前面的原始路由中做一些更改。让我们保护一些路由。只需要将Route更改为AuthRoute

// Source file: src/App.routing.auth.js

<AuthRoute path="/alpha" component={Alpha} />
<AuthRoute path="/bravo" component={Bravo} />
<AuthRoute path="/charlie" component={Charlie} />
<AuthRoute path="/zulu" component={Zulu} />
<AuthRoute component={Error404} />

所有更改后的路由都需要先前的登录——如果用户输入错误的路由,我们甚至不会告诉他们 404 错误;我们将强制他们首先登录,如果他们不这样做,他们甚至无法知道路由是否存在。

现在,如果我们打开应用程序并尝试访问普通的未受保护路由,一切都会像以前一样工作。但是,如果您尝试访问一些受保护的路由,比如"/charlie",您将被重定向到登录页面,如下截图所示:

尝试访问受保护的路由将重定向到登录界面

登录后,<Login>组件将产生自己的<Redirect>,将用户发送回最初请求的页面。请参见以下截图:

成功登录后,您将再次被重定向到您最初请求的页面;现在的 URL 指向我们想要访问的页面

因此,现在您有了一种处理各种路由的方法,而且也非常符合React的方式!

还有更多…

在通常的 Web 开发中,您使用 cookie 或可能使用本地存储来存储访问信息,但在React应用程序中,将令牌(或您使用的任何内容)存储在状态中就足够了。如果需要为 API 调用提供令牌,请记住,操作定义如下:

const anActionCreator = 
    (...*parameters*...) => 
        (dispatch, getState) => 
            { ...*your action*... }

因此,您可以通过getState()函数访问令牌,并根据需要将其传回服务器;回到getRegions2()代码,我们看到了如何执行异步操作,以查看使用此函数的示例。

性能的代码拆分

随着应用程序规模的增长,加载速度将逐渐变慢,这将让用户感到不满意。(而且,请记住,并非每个人都可以访问高速连接,尤其是在移动设备上!)此外,如果用户只需要其中的一小部分,用户不应该下载整个代码:例如,如果用户想浏览产品,为什么还要下载注册视图?

解决这个空间和速度问题的方法是代码拆分,这意味着您的应用程序将被分解成较小的块,只有在需要时才会加载。幸运的是,有很好的工具可以做到这一点,而且不需要对现有代码进行太多更改,因此这是一个全面的胜利。

准备工作

当您导入一个模块时,它是一个静态的东西,所需模块的代码被包含在通用源代码包中。但是,您可以使用动态import()调用在运行时加载代码。您可以自己处理这个问题,但已经有一个简单的软件包可以导入,react-loadable,它将处理大多数情况。让我们以通常的方式安装它:

npm install react-loadable --save

我们将使用此软件包的一些功能,因此您应该查看github.com/jamiebuilds/react-loadable以获取有关增强动态代码加载功能的更多想法。

截至 2018 年 12 月,import()处于第 3 阶段,这意味着它是一个候选项,只期望进行少量(如果有的话)更改,并且已经在通往第 4 阶段的道路上,这意味着它将被包含在正式的 ECMAScript 标准中。但是,与其他 JS 扩展一样,您已经可以在代码中使用它们,并且它受到BabelWebpack的支持。您可以在tc39.github.io/proposal-dynamic-import/上阅读更多关于import()的信息。

如何做…

让我们修改我们的路由应用程序,即使它很小!尝试进行代码拆分。

首先,让我们看看我们的主要代码将是什么样子:

// Source file: src/App.splitting.js

/* @flow */

/* eslint-disable */

import React, { Component } from "react";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";

import {
 AsyncAlpha,
 AsyncBravo,
 AsyncCharlie,
 AsyncZulu,
 AsyncHelp
} from "./splittingApp";

const Home = () => <h1>Home Sweet Home</h1>;
const Error404 = () => <h1>404 Error!</h1>;

class App extends Component<{}> {
    render() {
        return (
            <BrowserRouter>
                <div>
                    <header>
                        <nav>
                            <Link to="/">Home</Link>&nbsp;
                            <Link to="/alpha">Alpha...</Link>&nbsp;
                            <Link to="/bravo">Bravo...</Link>&nbsp;
                            <Link to="/charlie">Charlie...</Link>&nbsp;
                            <Link to="/wrong">...Wrong...</Link>&nbsp;
                            <Link to="/zulu">Zulu</Link>&nbsp;
                            <Link to="/help">Help</Link>&nbsp;
                        </nav>
                    </header>

                    <Switch>
                        <Route exact path="/" component={Home} />
                        <Route path="/help" component={AsyncHelp} />
                        <Route path="/alpha" component={AsyncAlpha} />
                        <Route path="/bravo" component={AsyncBravo} />
                        <Route path="/charlie" component={AsyncCharlie} 
                        />
                        <Route path="/zulu" component={AsyncZulu} />
                        <Route component={Error404} />
                    </Switch>
                </div>
            </BrowserRouter>
        );
    }
}

export default App;

我们已经将AlphaBravo和其他组件分开,以便我们可以动态加载它们。查看其中一个的代码就足够了:

// Source file: src/splittingApp/alpha.component.js

/* @flow */

import React from "react";

const Alpha = () => <h1>Alpha</h1>;

export default Alpha;

但是AsyncAlphaAsyncBravo等呢?这些组件是它们的正常对应版本的动态加载版本,我们可以使用react-loadable获取:

// Source file: src/splittingApp/alpha.loadable.js

/* @flow */

import Loadable from "react-loadable";

import { LoadingStatus } from "./loadingStatus.component";

export const AsyncAlpha = Loadable({
 loader: () => import("./alpha.component"),
 loading: LoadingStatus
});

AsyncAlpha组件可以动态加载,而在加载时,其内容将由LoadingStatus组件提供;您可以使其变得很花哨,但我选择了一个非常简单的东西:

// Source file: src/splittingApp/loadingStatus.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

export class LoadingStatus extends React.Component<{
    isLoading: boolean,
    error: boolean
}> {
    static propTypes = {
        isLoading: PropTypes.bool,
        error: PropTypes.bool
    };

    render() {
 if (this.props.isLoading) {
 return <div>Loading...</div>;
 } else if (this.props.error) {
 return <div>ERROR: the component could not be loaded.</div>;
 } else {
 return null;
 }
    }
}

因此,现在我们知道如何动态加载任何组件,让我们看看它是如何工作的。

能够动态加载组件,而不是像通常的 Web 应用程序一样加载整个路由,这是一个很大的优势。例如,您的应用程序可能在选项卡中有一个大型、重型组件,但除非用户实际转到该选项卡,否则为什么要加载它?延迟加载还可以帮助更快地显示页面;您可以尝试首先显示顶部的组件,并使用动态导入来加载底部的组件。

工作原理…

我们将使用 Web 开发人员工具来查看网络传输。当我们启动应用程序时,我们会得到主页和一些传输,包括bundle.js,主要源代码块。随着应用程序规模的增大,这个文件的大小将大幅增加。请参阅以下截图:

页面的初始加载显示只有 bundle.js 被发送到了网络上

如果我们点击一个链接,相应的分割代码块将被传输。访问几个链接后,您会得到类似以下的内容:

当您转到不同的链接时,块将被加载,但只有在需要时。

即使我们的示例非常小,您也可以看到您可以轻松地将应用程序分成几个较小的块。我们无法给出规则来建议何时应该开始应用这种技术,但是,正如我们所见,将任何组件更改为异步加载的等效组件所需的工作很少,因此,即使您的应用程序已经编写完成,您也可以开始使用这种技术。

还有更多...

Loadable() 创建的组件包括一个.preload()方法,您可以在实际需要组件之前使用它来启动导入过程。我们可以快速测试一下。例如,让我们设置一下,如果用户将鼠标移动到 Alpha 链接上,组件将被预加载:

<Link to="/alpha">
    <span onMouseOver={() => AsyncAlpha.preload()}>Alpha...</span>
</Link>

我们可以快速验证一下这个功能。当您加载代码的更新版本时,如果您将鼠标悬停在 Alpha 链接上,您会看到一部分代码被下载 - 尽管屏幕上没有任何变化,如下面的截图所示:

预加载在后台工作,让您可以提前下载组件:一个块(1.chunk.js)已经被加载,尽管它还没有显示在屏幕上

给它一些时间,并注意当您实际点击 Alpha 链接时,组件将立即显示,无需进一步下载。预加载还有更多用途:例如,您可以在初始页面加载后使用setTimeout(),或者您可以进行预测性下载,试图根据用户的操作来预测他们接下来会想要什么。

第九章:调试您的应用程序

我们将在这里看到的食谱是:

  • 以风格记录

  • 使用 React 开发者工具进行调试

  • 使用独立工具进行调试

  • 使用 redux-logger 记录 Redux

  • 使用 Redux 开发者工具调试 Redux

  • 连接路由进行调试

介绍

在之前的章节中,我们看到了如何开发基本的React应用程序,如何增强它以获得更好的用户体验,以及如何扩展它,使其更适用于复杂和大型应用程序范围。然而,所有这些开发肯定需要测试和调试,因此在本章中,我们将涉及调试食谱,在接下来的章节中,我们将涵盖测试。

以风格记录

记录仍然是一个非常好的工具,但您不能仅依赖于使用console.log()console.error()等工具。即使它们可以在短暂的调试运行中完成工作,但如果您计划更严肃地包括日志记录并希望在生产中禁用它,您将不得不追踪每个日志调用,或者猴子补丁控制台对象,以便.log().error()不起作用,这甚至更糟!

回到第五章的使用 Winston 添加日志记录部分,测试和调试您的服务器,我们使用Winston进行日志记录(还使用了Morgan,但那是特定于 HTTP 日志记录,所以不算),该库具有启用我们轻松启动或停止日志记录的功能。没有适用于浏览器的Winston版本,但我们可以退回到debug,这是一个旧标准(我们在刚才提到的章节末尾的还有更多...部分中提到的),它也可以在网络上使用。

您可以在github.com/visionmedia/debug找到调试的完整文档。请注意,如果愿意,您也可以在Node中使用它,尽管我们认为我们之前的选择更好。

准备就绪

您可以像在Node中使用它一样安装debug

npm install debug --save

您还必须决定如何命名空间您的日志,因为使用调试可以轻松选择显示哪些消息(如果有的话)和哪些不显示。一些可能的想法是为应用程序中的每个服务使用名称,例如MYAPP:SERVICE:LOGINMYAPP:SERVICE:COUNTRIESMYAPP_SERVICE:PDF_INVOICE等,或者为每个表单使用名称,例如MYAPP_FORM:NEW_USERMYAPP:FORM:DISPLAY_CARTMYAPP:FORM:PAY_WITH_CARD等,或者为特定组件使用名称,例如MYAPP:COMPONENT:PERSONAL_DATAMYAPP:COMPONENT_CART等;您可以根据需要为操作,减速器等列出清单。

有一种方法可以在之后选择显示哪些日志,方法是在LocalStorage中存储一个值(我们将在此处介绍),这样您就可以设置:

  • MYAPP:*显示来自我的应用程序的所有日志

  • MYAPP:SERVICE:*显示所有与服务相关的日志

  • MYAPP:FORM:MYAPP:COMPONENT:*显示与某些表单或组件相关的日志,但省略其他日志

  • MYAPP:SERVICE:COUNTRIESMYAPP:FORM:NEW_USERMYAPP:FORM:PAY_WITH_CARD来显示与这三个项目相关的日志

您还可以使用"-"前缀字符串来排除它。 MYAPP:ACTIONS:*,-MYAPP:ACTIONS:LOADING将启用所有操作,但不包括LOADING

您可能会想:为什么在每个地方都包含固定文本MYAPP:?关键在于,您可能使用的许多库实际上也使用调试进行日志记录。如果您要说显示所有内容(*)而不是MYAPP:*,则会在控制台中获得所有这些库的每条消息,这不是您预期的!

您可以自由决定日志的命名,但建立一个结构良好的列表将使您能够稍后选择要显示的日志,这意味着您不必开始乱弄代码以启用或禁用任何给定的消息集。

如何做到这一点...

让我们至少在某种程度上复制我们在Winston中所拥有的内容,这样如果您进行全栈工作,无论是客户端还是服务器端,都会更容易。我们希望有一个带有.warn().info()等方法的记录器对象,它将以适当的颜色显示给定的消息。此外,我们不希望在生产中显示日志。这将导致我们的代码如下:

// Source file: src/logging/index.js

/* @flow */

import debug from "debug";

constWHAT_TO_LOG = "myapp:SERVICE:*"; // change this to suit your needs
const MIN_LEVEL_TO_LOG = "info"; // error, warn, info, verbose, or debug

const log = {
 error() {},
    warn() {},
    info() {},
    verbose() {},
    debug() {}
};

const logMessage = (
    color: string,
    topic: string,
    message: any = "--",
    ...rest: any
) => {
    const logger = debug(topic);
    logger.color = color;
    logger(message, ...rest);
};

if (process.env.NODE_ENV === "development") {
    localStorage.setItem("debug", WHAT_TO_LOG);

 /* *eslint-disable no-fallthrough* */
    switch (MIN_LEVEL_TO_LOG) {
        case "debug":
            log.debug = (topic: string, ...args: any) =>
                logMessage("gray", topic, ...args);

        case "verbose":
            log.verbose = (topic: string, ...args: any) =>
                logMessage("green", topic, ...args);

        case "info":
            log.info = (topic: string, ...args: any) =>
                logMessage("blue", topic, ...args);

        case "warn":
            log.warn = (topic: string, ...args: any) =>
                logMessage("brown", topic, ...args);

        case "error":
        default:
            log.error = (topic: string, ...args: any) =>
                logMessage("red", topic, ...args);
    }
}

export { log };

一些重要的细节:

  • WHAT_TO_LOG常量允许您选择应显示哪些消息。

  • MIN_LEVEL_TO_LOG常量定义了将被记录的最低级别。

  • 日志对象具有每个严重级别的方法,就像 Winston 一样。

  • 最后,如果我们不处于开发模式,将返回一个无效的log对象;所有对日志方法的调用都将产生完全没有任何输出。

请注意,我们在switch语句中使用了 fallthrough(其中没有break语句!)来正确构建log对象。这并不常见,而且我们不得不在 ESLint 中关闭它!

我们已经有了我们需要的代码;让我们看一个使用它的例子。

它是如何工作的…

鉴于日志记录并不是一个复杂的概念,而且我们已经在服务器上看到了它,让我们来看一个非常简短的例子。我们可以更改我们应用程序的index.js文件,以包含一些示例日志:

// Source file: src/index.js

.
.
.

import { log } from "./logging";

log.error("myapp:SERVICE:LOGIN", `Attempt`, { user: "FK", pass: "who?" });

log.error("myapp:FORM:INITIAL", "Doing render");

log.info(
    "myapp:SERVICE:ERROR_STORE",
    "Reporting problem",
    "Something wrong",
    404
);

log.warn("myapp:SERVICE:LOGIN");

log.debug("myapp:SERVICE:INFO", "This won't be logged... low level");

log.info("myapp:SERVICE:GETDATE", "Success", {
    day: 22,
    month: 9,
    year: 60
});

log.verbose("myapp:SERVICE:LOGIN", "Successful login");

运行我们的应用程序将在控制台中产生以下输出;请参阅下一个截图。您应该验证只有正确的消息被记录:info级别及以上,并且只有它们匹配myapp:SERVICE:*

使用调试可以在控制台中产生清晰、易懂的输出

请注意,根据我们的规范,只显示了与myapp:SERVICE相关的消息。

使用 React Developer Tools 进行调试

当我们使用Node(在第五章中,测试和调试您的服务器)时,我们看到了如何进行基本调试,但现在我们将专注于一个React-特定的工具,React Developer ToolsRDT),这些工具专门用于与组件和 props 一起使用。在这个教程中,让我们看看如何安装和使用这个工具包。

准备工作

RDT 是 Chrome 或 Firefox 的扩展,可以让您在标准 Web 开发工具中检查组件。我们将在这里使用 Chrome 版本,但是 Firefox 的使用方式类似。您可以通过访问Chrome Web Storechrome.google.com/webstore/category/extensions)并搜索 RDT 来安装该扩展;您想要的扩展是由 Facebook 编写的。单击“添加到 Chrome”按钮,当您打开 Chrome 开发者工具时,您将找到一个新的选项卡,React。

如果您不使用 Chrome 或 Firefox,或者如果您必须测试将显示在 iframe 中的React应用程序,您将希望查看工具的独立版本;我们将在使用独立工具进行调试部分中介绍它们,就在这一部分之后。

如何做…

让我们看看如何在上一章中的使用 Redux 管理状态部分中开发的计数器应用程序中使用 RDT。该应用程序很简单,所以我们可以很容易地看到如何使用该工具,但当然您也可以将其应用于非常复杂、充满组件的页面。启动应用程序,打开 Web 开发工具,选择 React 选项卡,如果展开每个组件,您将看到类似以下截图的内容:

Web 开发工具中的 React 选项卡让您访问应用程序的整个组件层次结构

顺便说一下,您可以将该工具与任何使用React开发的应用程序一起使用。当工具的小图标变色时,表示可以使用,如果单击它,您将获得有关您是运行开发(红色图标)还是生产(绿色图标)的信息;此截图显示了我们的具体情况:

我们的新工具将检测并与任何 React 开发的应用程序一起工作

它是如何工作的…

我们已经安装了我们的调试工具,并将其应用到了我们的应用程序;现在让我们看看它是如何工作的,以及我们可以用它做些什么。

如果您通过点击选择任何特定组件,您可以看到它生成的组件和 HTML 元素。您还可以通过在屏幕上直接选择组件(点击 Memory 标签左侧的最左边的图标),然后点击 React 标签来以更传统的方式选择组件;您点击的元素将被选中。您还可以使用搜索功能查找特定组件;这在大型应用程序中将非常有用,可以避免手动滚动大量 HTML。

每个组件旁边的三角形可能有两种不同的颜色,这取决于它是实际的React组件(例如我们的情况下的<Counter><ClicksDisplay>)还是与存储连接的Redux。HTML 元素没有任何三角形。

在第三个面板中,您可以看到当前的 props。如果您编辑一个(例如尝试将count prop 设置为不同的值),您将立即在左侧看到更改。此外,如果您点击一个按钮,您将看到 prop 值如何更改;在您的应用程序上尝试一下三个按钮。

如果您想与任何组件进行交互,您可能会注意到当前选择的组件旁边有== $r。这意味着有一个特殊的 JS 变量,它指向我们的情况下所选择的组件,<Counter>。如果您打开 Console 标签,可以通过输入$r.props来检查其 props,或者尝试调用各种可用的方法,例如$r.onAdd1(),如下一个截图所示:

$r变量让您可以使用(和实验)当前选择的组件

有趣的是,在我们的应用程序中,当我们编写它时,.onAdd1()方法实际上会分派一个动作,我们可以在截图中看到:一个带有type:"counter:increment"value:1的对象,就像我们编写的一样;请参阅上一章中的定义动作部分进行检查。

如果您选择<Provider>组件,您可以检查应用程序的当前状态。首先您需要选择它(以便$r指向它),然后在 Console 标签中,您需要输入$r.store.getState()来获得如下一个截图中的结果:

通过选择组件,您可以检查应用程序的状态

实际上,如果您愿意,甚至可以触发动作;通过输入类似$r.store.dispatch({type:"counter:increment", value:11}),您可以完全控制应用程序状态。

使用独立工具进行调试

如果您正在使用其他浏览器,如 Safari 或 Internet Explorer,或者由于某些原因无法使用 Chrome 或 Firefox,那么有一个独立版本的工具,您可以在github.com/facebook/react-devtools/tree/master/packages/react-devtools找到。不过,需要警告的是,对于 Web 开发,您将无法获得完整的功能,因此最好还是使用支持的浏览器!

准备就绪

我们想要使用独立工具;让我们看看如何设置它。首先,显然,我们需要安装该软件包。您可以全局安装,但我更喜欢在项目本身内部进行本地工作:

npm install react-devtools --save-dev

为了能够运行新命令,您可以使用npx(正如我们在书中看到的那样),但更容易的方法是在package.json中定义一个新的脚本。添加类似以下内容,您就可以使用npm run devtools打开独立应用程序:

"scripts": {
    .
    .
    .
    "devtools": "react-devtools"
}

现在你已经设置好了;让我们看看如何使用这个工具。

如果您感兴趣,这个独立应用程序本身是用 JS 编写的,并使用Electron转换为桌面应用程序,我们将在本书的第十三章中看到使用 Electron 创建桌面应用程序

如何做到这一点…

我们已经得到了独立工具;让我们看看如何使用它。为了以独立方式使用 RDT,您需要在 HTML 代码的顶部添加一行。

<!DOCTYPE html>
<html lang="en">

<head>
 <script src="img/192.168.1.200:8097"></script>
  .
  .
  .

然后正常启动应用程序,等它运行起来后,启动独立应用程序。您将看到类似下一个截图的东西。请注意,我们看到了两个单独的窗口:一个带有 RDT,另一个带有应用程序(为了多样性)在 Opera 中;我也可以使用 Safari 或 IE 或任何其他浏览器:

独立的 RDT 让您即使在非 Chrome 或 Firefox 浏览器中运行 React 应用程序也可以进行检查

现在您真的可以开始了;让我们通过查看我们可以(和不能)做什么来完成本节。

有关如何配置独立应用程序的更多详细信息,特别是如果您需要使用不同的端口,请查看官方文档github.com/facebook/react-devtools/tree/master/packages/react-devtools。对于复杂的情况,您可能需要使用不同的软件包react-devtools-core,在github.com/facebook/react-devtools/tree/master/packages/react-devtools-core

它是如何工作的…

这个版本的开发工具让您可以与应用程序交互并查看组件和属性,但是您将受到通过控制台与它们交互的限制,我们将看到。

首先,通过检查在 Opera 窗口中单击按钮是否会自动在 RDT 中看到更改,就可以开始。在一些“添加 1”点击后查看下一个截图以查看结果:

您在 React 应用程序中所做的任何操作都将显示在开发工具中。在这个示例中,我点击了六次“添加 1”,更新后的组件树显示了新值

大多数功能的工作方式与 Chrome 相同。您可以按名称搜索组件,如果右键单击组件,将获得多个选项,包括显示组件名称的所有出现(与搜索一样)或复制其属性;请参阅以下截图:

RDT 让您获取有关任何组件的完整信息

但是,请注意,您将无法获得完整的值。例如,在前面的示例中,复制的属性如下代码片段所示;我得到了一个字符串描述,而不是一个函数:

{
  "count": 6,
  "dispatch": "[function dispatch]"
}

另一个限制是您将无法使用$r直接访问对象;这超出了工具的能力。但是,如果您在调试时没有解决方案,至少您将能够看到应用程序的内部工作,这并不是可以随意忽视的!

使用 redux-logger 记录 Redux

调试的一个基本工具是使用日志记录器。虽然 JS 已经有足够的日志记录功能可用(我们已经在第五章的使用 Winston 添加日志记录部分中提到了window.console函数),但是您需要一些帮助来记录Redux的操作,这是一个关键要求。当然,您可以在分派任何操作之前添加代码,但那将变得太冗长。相反,我们将考虑添加一些中间件,以记录所有操作;即使我们将在接下来的使用 Redux 开发者工具调试 Redux部分中看到更好的工具,这种日志也将非常有用。在这个示例中,让我们看看如何添加redux-logger

我们已经使用了 thunks 的中间件,但是如果您想编写自己的中间件,您可以在redux.js.org/advanced/middleware找到几个示例(包括日志函数)。

准备工作

像往常一样,我们的第一步是获取新工具。安装简单明了,与大部分文本中看到的情况相同:

npm install redux-logger --save

这将安装新的包,但您必须手动将其添加到您的存储创建代码中;单独使用该包不会产生任何效果。

如果您想了解更多关于redux-logger的功能和能力,请查看github.com/evgenyrodionov/redux-logger

如何做…

设置redux-logger需要首先使用createLogger()函数创建一个记录器,该函数允许您选择许多选项来自定义记录的输出,然后将生成的记录器作为Redux的中间件包含。

在众多可用选项中,这些是最有趣的:

  • colors : 如果您希望更改输出的外观。

  • diff: : 一个布尔标志,用于决定是否要显示旧状态和新状态之间的差异;还有一个diffPredicate(getState, action)函数,你可以用它来决定是否显示差异。

  • duration : 一个布尔标志,用于打印处理操作所花费的时间;这主要在异步操作中会很有趣

  • predicate(getState, action) : 可以检查动作和当前状态,并返回 true 或 false 来定义是否应该记录动作;这对于限制日志记录到一些动作类型非常有用。

  • titleFormatter()stateTransformer()actionTransformer()和其他几个格式化函数。

有关完整的选项集,请查看github.com/evgenyrodionov/redux-logger

设置我们的计数器应用程序

我们将看到如何在最简单的情况下使用此记录器与我们的计数器应用程序,然后与区域浏览器一起使用,它将添加 thunks 到混合中。您必须使用applyMiddleware()函数(我们在执行异步操作:redux-thunk部分中已经看到了,当我们开始使用redux-thunk时,在第八章中)将记录器添加到流程中:

// Source file: src/counterApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import { createLogger } from "redux-logger";

import { reducer } from "./counter.reducer.js";

const logger = createLogger({ diff: true, duration: true });
export const store = createStore(reducer, applyMiddleware(logger));
.
.
.

当然,您可能只想在开发中启用这个功能,因此前面片段的最后一行应该是以下内容:

export const store =
    process.env.NODE_ENV === "development"
        ? createStore(reducer, applyMiddleware(logger))
        : createStore(reducer);
.
.
.

这将设置记录器以访问每个分派的动作,并记录它,包括状态之间的差异和处理时间。我们很快就会看到这是如何工作的,但首先让我们看一下我们的第二个应用程序,它已经有一些中间件。

设置我们的区域应用程序

当您想要应用两个或更多个中间件时,您必须指定它们将被应用的顺序。在我们的情况下,记住 thunk 可以是一个对象(fine to list)或一个函数(最终会被调用以产生一个对象),我们必须将我们的记录器放在所有可能的中间件的最后:

// Source file: src/regionsApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";

import { reducer } from "./worlds.reducer.js";

const logger = createLogger({ duration: true });

export const store = createStore(reducer, applyMiddleware(thunk, logger));
.
.
.

我决定跳过列出差异,因为我们将得到一些有点长的列表(例如 200 多个国家),因此输出将变得太大。现在让我们看看这个日志是如何在实践中工作的。

它是如何工作的…

我们将两个应用程序都设置为记录所有操作,没有过滤;我们只需要npm start,日志输出将出现在 Web 开发者工具控制台中。

记录计数器应用程序

计数器应用程序非常简单:整个状态只有两个数据(当前计数器值和到目前为止的点击次数),因此很容易跟踪测试运行期间发生的情况;请参见下一个屏幕截图:

计数器应用程序的一个示例运行,但使用 redux-logger 记录所有操作

你可以轻松地跟踪测试运行,并且你将能够看到我们点击每个按钮时分派了哪个操作以及存储的连续值——如果在减速器的逻辑中有任何问题,你可能会发现它们很容易检测到,因为屏幕上显示了所有信息。

记录地区应用程序

我们的第二个应用程序更有趣,因为我们正在进行实际的异步请求,要处理的数据量更大,而屏幕显示虽然仍然有点简单,但至少比计数器显示更复杂。当我们启动应用程序时,下拉菜单使用了一个操作来请求整个国家列表,正如你在这个截图中所看到的:

下拉组件分派了一个操作来获取国家(countries:request),并且证明成功(countries:success),返回了一个包含 249 个国家的列表

国家加载完毕后,我决定选择法国(对 2018 年 FIFA 足球世界杯冠军的一个小小的致敬!),然后一些新的操作被触发,如下一张截图所示:

选择国家的结果:多个操作被分派并调用了 API

为了显示更小,我压缩了前两个操作,然后扩展了最后一个操作,显示了从我们自己的服务器收到的答案。你可以检查所有地区是否正确显示,尽管按名称排序,因为我们已经按名称对列表进行了排序。

有了这个记录器,你已经有了一个很好的工具来查看React+Redux应用程序中发生的事情——但我们将添加另一个工具,以更好地工作。

使用 Redux 开发者工具调试 Redux

如果你正在使用React+Redux工作,最好的工具之一就是Redux开发者工具(或 DevTools),它提供了一个控制台,让你查看操作和状态,甚至提供了一个“时光机”模式,让你可以来回穿梭,这样你就可以仔细检查一切是否如预期那样。在这个教程中,让我们看看如何使用这个非常强大的工具来帮助调试我们的代码。

如果你想看看 Dan Abramov 在 2015 年 React Europe 的演示,请查看他在www.youtube.com/watch?v=xsSnOQynTHs的演讲。

准备就绪

安装所需的redux-devtools-extension很容易,但要小心!不要混淆redux-devtools-extension包,位于github.com/zalmoxisus/redux-devtools-extension,与redux-devtools,一个类似但不同的包,位于github.com/reduxjs/redux-devtools。后者更像是一个“自制”包,需要大量配置,尽管它可以让你为Redux创建一个完全定制的监视器,如果你愿意的话。对我们来说,这就是我们需要的:

npm install redux-devtools-extension --save-dev

你还需要安装一个 Chrome 扩展程序Redux Devtools,它与我们刚刚安装的包一起工作。这个扩展将在 Web 开发者工具中添加一个新选项,我们将看到。

如何做…

composeWithDevTools() added function will take care of the necessary connections to make everything work:
// Source file: src/regionsApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "redux-devtools-extension";

import { reducer } from "./worlds.reducer.js";

const logger = createLogger({ duration: true });

export const store = createStore(
    reducer,
    composeWithDevTools(applyMiddleware(thunk, logger))
);

如果你运行代码,它将像以前一样工作,但让我们看看添加的调试功能是如何工作的。

它是如何工作的…

让我们启动我们的地区应用程序,然后打开 Web 开发者工具并选择 Redux 选项卡。你将得到类似下面截图的东西:

加载应用程序会显示初始状态以及一些操作:请求国家和该请求的成功

这里有很多功能。下面的滑块(你必须点击底部栏上的时钟图标才能看到)可能是最有趣的,因为它可以让你来回穿梭;尝试滑动它,你会看到应用程序的变化。

例如,你可以轻松地看到当国家请求操作被分发时屏幕是什么样子的,但数据返回之前;请参见下一个截图。你会记得为了检查这个,我们不得不添加一个人为的时间延迟;现在,你可以随意检查情况,而无需添加任何特殊代码。

通过滑块,你可以看到应用程序在任何以前的时刻是什么样子的

如果你在顶部的下拉列表中选择检查员选项,你可以检查操作和状态。例如,在下一个截图中,你可以检查当从服务器检索到国家列表及其所有数据时分发的操作。你会注意到这种信息与Redux日志记录器包生成的信息非常相似,但你可以以更动态的方式处理它。

检查员功能让你查看操作(如此处)和状态,所以你可以检查发生的一切

让我们再进一步;再次选择法国,我们将看到这些地区进来后状态发生了什么变化。Diff 标签只显示状态中的差异:在我们的情况下,loadingRegions的值被重置为 false(当请求地区操作被分发时,它被设置为 true),地区列表得到了它的值(法国的所有地区)。请参见下一个截图。

Diff 标签让你快速看到状态变化的属性,进行更快、更简单的分析

我们还没有浏览所有的功能,所以继续点击各处,找到其他可用的功能。例如,底部栏左侧的按钮可以让你打开一个单独的调试窗口,这样你的屏幕就不会那么拥挤了;另一个按钮可以让你创建和分发任何操作,所以继续,尝试一切!

你真的应该尝试使用这个工具,以清晰地了解你可以通过它实现什么,特别是尝试时光机功能。你会欣赏到这种结果之所以可能,是因为React以状态的方式创建视图,但最终你会注意到缺少了什么;让我们找出是什么,以及如何修复它?

连接路由进行调试

我们错过了什么?我们在本章的前几节中尝试的简单应用程序没有包括路由——但如果包括了呢?问题现在显而易见:每当用户导航到新的路由时,状态中没有任何内容来跟踪这种变化,所以时光机功能实际上不会起作用。为了解决这个问题,我们需要让路由信息与存储同步,这样就能恢复我们的调试功能;让我们看看如何做到这一点。

准备工作

在之前的react-router版本中,一个react-router-redux包负责链接路由和状态,但该包最近已被弃用,由connected-react-router取而代之,我们将安装它。我提到这一点是因为网络上仍然有许多文章显示了前一个包的用法;要小心:

npm install --save connected-react-router

这是解决方案的一半;让这个包工作将(再一次!)需要对存储和应用程序的结构进行更改;让我们看看。

如何做…

我们想修改我们的代码,使 Redux 时光机功能能够工作。让我们再次使用我们在第八章中看到的使用 react-router 添加路由部分中的基本路由应用程序;我们有路由,还有一个分发一些操作的登录表单,所以我们将能够(在非常小的范围内,同意!)看到在正常应用程序中找到的各种东西。

将有两个地方发生变化:首先,我们将不得不将我们的存储与与路由器相关的history对象连接起来,其次,我们将不得不在我们的主代码中添加一个组件。存储更改如下-请注意,我们还在这里添加了与本章其余部分匹配的其他调试工具:

// Source file: src/routingApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "redux-devtools-extension";
import { connectRouter, routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history";

import { reducer } from "./login.reducer";

const logger = createLogger({ duration: true });

export const history = createBrowserHistory();

export const store = createStore(
 connectRouter(history)(reducer),
    composeWithDevTools(
        applyMiddleware(routerMiddleware(history), thunk, logger)
    )
);

代码看起来有点晦涩,但基本上:

  • 我们创建一个history对象,我们需要导出它,因为我们以后会用到它

  • 我们用connectRouter()包装我们原来的reducer,以生成一个新的reducer,它将意识到路由器状态

  • 我们添加了routerMiddleware(history)以允许像push()这样的路由方法

然后我们将不得不在我们的主 JSX 中添加一个<ConnectedRouter>组件;这将需要我们之前创建的history对象:

// Source file: src/App.routing.auth.js

import React, { Component } from "react";
import { Provider } from "react-redux";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
import { ConnectedRouter } from "connected-react-router";

import {
    ConnectedLogin,
    AuthRoute
} from "./routingApp";
import { history, store } from "./routingApp/store";

const Home = () => <h1>Home Sweet Home</h1>;
const Help = () => <h1>Help! SOS!</h1>;
.
.
.

class App extends Component<{}> {
    render() {
        return (
            <Provider store={store}>
                <BrowserRouter>
 <ConnectedRouter history={history}>
                        <div>
                            <header>
                                <nav>
                                    <Link to="/">Home</Link>&nbsp;
                                    <Link to="/login">Log 
                                     in</Link>&nbsp;
                                    .
                                    .
                                    .
                                </nav>
                            </header>

                            <Switch>
                              <Route exact path="/" component={Home} />
                              <Route path="/help" component={Help} />
                                .
                                .
                                .
                            </Switch>
                        </div>
 </ConnectedRouter>
                </BrowserRouter>
            </Provider>
        );
    }
}

export default App;

现在一切都设置好了;让我们看看这是如何工作的。

要了解更多关于connected-react-router的信息,请查看其 GitHub 页面github.com/supasate/connected-react-router;特别是,您可能会对页面底部列出的许多文章中的各种提示和建议感兴趣。

它是如何工作的…

现在让我们启动我们的应用程序,并不要忘记从第四章运行我们的服务器,使用 Node 实现 RESTful 服务,就像我们以前做的那样。打开Redux DevTools,我们看到一个新的动作@@INIT,现在状态包括一个新的路由器属性;请参阅以下截图:

将路由连接到存储后,会出现一些新的动作和状态属性

如果我们点击 Alpha…,我们会看到有两个动作被分派:第一个尝试访问/alpha,第二个是我们重定向到/login页面,如下截图所示:

尝试访问受保护的路由会将我们重定向到登录页面

输入用户名和密码后,我们看到我们的 login:request 和 login:success 动作-就像我们启用Redux开发者工具以来看到的那样-然后是另一个动作,对应于重定向到/alpha页面,如下截图所示:

我们自己的动作与路由器动作交织在一起

但是,现在时间机器功能也对路由启用了;例如,如果您将滑块移回到开头,您将再次看到主页,并且您可以来回移动,视图将适当地反映您之前看到的一切;请查看下一个截图:

连接了路由器到状态后,现在我们可以使用滑块返回并每次看到正确的页面

现在我们有了一套很好的调试工具;让我们继续进行自动测试,就像我们之前在Node中做的那样。

第十章:测试您的应用程序

在本章中,我们将涵盖以下配方:

  • 使用 Jest 和 Enzyme 测试组件

  • 测试 reducers 和映射

  • 测试 actions 和 thunks

  • 使用快照测试更改

  • 测量测试覆盖率

介绍

在上一章中,我们处理了调试。现在让我们添加一些单元测试配方,以满足我们开发所需的一切。正如我们以前所见,良好的单元测试不仅有助于开发,还可以作为预防工具,避免回归错误。

使用 Jest 和 Enzyme 测试组件

回到第五章,测试和调试您的服务器,我们对Node代码进行了单元测试,并且我们使用了Jest。正如我们所说的,这个包的一个优点是我们也可以将其与React(或React Native一起使用,我们将在第十一章中查看使用 React Native 创建移动应用程序),所以我们之前在本书中学到的一切仍然有效;如果你愿意,快速浏览一下,这样我们就不必在这里重复了。

我们应该测试什么?显然,我们必须为我们的组件编写单元测试,但由于我们一直在使用Redux,我们还需要为 reducers、actions 和 thunks 编写测试;我们将在本节和接下来的节中涵盖所有这些主题。其中一些测试将非常简单,而其他一些则需要更多的工作。那么,让我们开始吧!

准备就绪

对于Node,我们必须自己安装Jest,但create-react-app已经为我们做了这件事,所以这是一件少了的事情需要担心。(如果你自己创建了React应用程序,通过编写自己的配置文件,那么你应该看一下jestjs.io/docs/en/getting-started来了解如何继续。)然而,我们还将使用Enzyme,这是一个可以简化对组件生成的 HTML 进行断言或操作的包,这与jQuery非常相似。

如果你想了解更多关于这些功能,或者如果你有一段时间没有使用jQuery了(就像我自己一样!),请阅读有关cheerio的信息,这是Enzyme使用的包,网址是github.com/cheeriojs/cheerio。关于Enzyme本身,包括其配置,你可以访问其 GitHub 网站github.com/airbnb/enzyme

由于我们使用的是React的 16 版本,安装该包的当前方式如下;需要enzyme-adapter-react-16附加包来将EnzymeReact链接起来:

npm install enzyme enzyme-adapter-react-16 --save-dev

另一个好处是,我们不需要进行任何特殊配置,因为create-react-app也会负责设置一切。然而,如果你决定需要一些特殊的东西,react-app-rewired会帮助你:在github.com/timarney/react-app-rewired上查看更多信息。

我们拥有一切所需的东西;让我们开始测试吧!

如何做到这一点...

我们应该测试哪些组件?我们已经使用过连接和未连接的组件,但我们将在这里专注于后者。为什么?连接的组件从mapStateToProps()mapDispatchToProps()函数中获取它们的 props 和 dispatch 逻辑;我们可以相信这是这样的,因此我们实际上不需要测试它。如果你愿意,你可以设置一个存储并验证这两个函数是否起作用,但这些测试很容易编写,我不建议你真的需要它们。相反,我们将专注于组件的未连接版本并对其进行全面测试。我们将在这里设置所有的测试,然后我们将看看如何运行它们,以及期望的输出是什么。

测试没有事件的组件

我们想要测试一个组件,所以让我们选择一个合适的组件。对于我们的第一个单元测试,让我们使用<RegionsTable>组件,它没有处理任何事件;它只是一个显示组件。测试通常与组件同名,但将扩展名从.js改为.test.js——或者.spec.js,但我更喜欢.test.js。随便选,只要保持一致。

首先,让我们从考虑我们应该测试什么开始。我们组件的规范说明它的工作方式取决于它接收到的国家列表是空的还是非空的。在第一种情况下,我们可以测试生成的 HTML 文本是否包含No regions,在第二种情况下,我们应该验证提供的所有地区是否出现在输出中。当然,你可以想出更详细、更具体的情况,但尽量不要让你的测试太脆弱,意思是实现的细微变化会导致测试失败。我描述的测试可能并不涵盖所有情况,但几乎可以肯定,即使你以不同的方式实现组件,测试仍然应该成功。

开始实际测试时,它们都会以类似的方式开始:我们需要导入必要的库,以及要测试的组件,并设置Enzyme及其适配器。在下面的代码中,我将突出显示相关的行:

// Source file: src/regionsApp/regionsTable.test.js

/* @flow */

import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";

import { RegionsTable } from "./regionsTable.component";

Enzyme.configure({ adapter: new Adapter() });

// *continued...*

就像我们之前做的那样,我们将使用describe()it()来设置不同的测试用例。要检查空地区列表的情况,我们只需要使用几行代码:

// ...*continues*

describe("RegionsTable", () => {
    it("renders correctly an empty list", () => {
        const wrapper = Enzyme.render(<RegionsTable list={[]} />);
 expect(wrapper.text()).toContain("No regions.");
    });

// *continued*...

我们使用Enzyme.render()来为我们的组件生成 DOM,使用.text()方法生成其文本版本。通过后者,我们只需要验证所需的文本是否出现,因此整个测试非常简短。

我们还有第二个用例,其中我们提供了一个非空的地区列表。代码类似,但显然更长;让我们先看看代码,然后再解释它:

// *...continues*

    it("renders correctly a list", () => {
        const wrapper = Enzyme.render(
            <RegionsTable
                list={[
                    {
                        countryCode: "UY",
                        regionCode: "10",
                        regionName: "Montevideo"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "9",
                        regionName: "Maldonado"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "5",
                        regionName: "Cerro Largo"
                    }
                ]}
            />
        );
 expect(wrapper.text()).toContain("Montevideo");
 expect(wrapper.text()).toContain("Maldonado");
 expect(wrapper.text()).toContain("Cerro Largo");
    });
});

逻辑非常相似:渲染组件,生成文本,检查正确的内容是否存在。正如我们所说,你也可以验证每个地区是否在<li>元素内,以及它们是否有键等;然而,要记住我们关于脆弱测试的写法,并避免过度规定测试,以便只有一个可能的、特定的组件实现才能通过它们!

测试带有事件的组件

现在我们想要测试一个带有事件的组件。为此,<CountrySelect>组件会很方便,因为它可以处理一些事件,并且会相应地调用一些回调函数。

首先,让我们看一下初始设置,包括我们将用于不同测试的国家列表:

// Source file: src/regionsApp/countrySelect.test.js

/* @flow */

import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";

import { CountrySelect } from "./countrySelect.component";

Enzyme.configure({ adapter: new Adapter() });

const threeCountries = [
    {
        countryCode: "UY",
        countryName: "Uruguay"
    },
    {
        countryCode: "AR",
        countryName: "Argentina"
    },
    {
        countryCode: "BR",
        countryName: "Brazil"
    }
];

// *continued...*

现在,我们将为哪些情况编写单元测试?让我们从没有给出国家列表的情况开始:根据我们的要求,在这种情况下,组件将不得不使用一个属性,比如getCountries(),来获取必要的数据。我们将再次使用spy(我们在第五章的使用 spy部分中看到它们)来模拟和测试必要的行为:

// ...*continues*

describe("CountrySelect", () => {
    it("renders correctly when loading, with no countries", () => {
 const mockGetCountries = jest.fn();
 const mockOnSelect = jest.fn();

        const wrapper = Enzyme.mount(
            <CountrySelect
                loading={true}
                onSelect={mockOnSelect}
                getCountries={mockGetCountries}
                list={[]}
            />
        );
        expect(wrapper.text()).toContain("Loading countries");

 expect(mockGetCountries).toHaveBeenCalledTimes(1);
 expect(mockOnSelect).not.toHaveBeenCalled();
    });

// *continued...*

我们创建了两个 spy:一个用于onSelect事件处理程序,一个用于获取国家列表。测试组件输出是否包含"Loading countries"文本很简单;让我们专注于 spy。我们期望组件应该调用获取国家列表的函数(但只调用一次!),并且事件处理程序不应该被调用:最后两个检查就解决了这个问题。

现在,如果提供了一个国家列表,会发生什么?我们可以编写类似的测试,只是验证一个不同之处,即组件没有调用函数来获取(已经给出的)国家;我已经突出显示了相关代码:

// ...*continues*

    it("renders correctly a countries dropdown", () => {
 const mockGetCountries = jest.fn();
 const mockOnSelect = jest.fn();

        const wrapper = Enzyme.mount(
            <CountrySelect
                loading={false}
                onSelect={mockOnSelect}
                getCountries={mockGetCountries}
                list={threeCountries}
            />
        );

        expect(wrapper.text()).toContain("Uruguay");
        expect(wrapper.text()).toContain("Argentina");
        expect(wrapper.text()).toContain("Brazil");

 expect(mockGetCountries).not.toHaveBeenCalled();
 expect(mockOnSelect).not.toHaveBeenCalled();
    });

// *continued...*

鉴于我们已经编写的测试,这部分代码应该很容易理解:我们之前已经看到类似的测试,所以这里没有新的东西需要解释。

让我们来到最终、更有趣的情况:我们如何模拟用户选择了某些东西?为此,我们将不得不检测<CountrySelect>组件中的<select>元素,为此我决定提供一个 name 属性:我在组件原始的render()方法中改变了一行,并将其从<select onChange={this.onSelect}>改为<select onChange={this.onSelect} name="selectCountry**"**>,这样我就有了一种方法来获取元素。当然,你可能会反对以任何方式改变原始组件代码,你也可以非常正确地指出,这使得测试比以前更加脆弱;如果组件以不同的方式重新编码,而不使用<select>元素,测试将自动失败,你是对的。这是一个关于测试到何种程度以及需要什么额外负担的判断。

为了完成我们的测试套件,我们要验证正确的事件处理程序是否被调用:

// ...*continues*

    it("correctly calls onSelect", () => {
        const mockGetCountries = jest.fn();
 const mockOnSelect = jest.fn();

        const wrapper = Enzyme.mount(
            <CountrySelect
                loading={false}
 onSelect={mockOnSelect}
                getCountries={mockGetCountries}
                list={threeCountries}
            />
        );

 wrapper
 .find("[name='selectCountry']")
 .at(0)
 .simulate("change", { target: { value: "UY" } });

        expect(mockGetCountries).not.toHaveBeenCalled();
 expect(mockOnSelect).toHaveBeenCalledTimes(1);
 expect(mockOnSelect).toHaveBeenCalledWith("UY");
    });
});

我们必须使用一些 DOM 遍历来找到所需的元素,然后使用.simulate()来触发事件。由于实际上并没有真正触发任何事件,我们必须提供它可能包含的值,这在我们的情况下是.target.value。然后我们可以通过验证事件处理程序是否以正确的值("UY")被调用一次来完成我们的测试。

我们已经编写了组件测试;让我们看看它们是如何工作的。

它是如何工作的...

运行测试很简单:您只需要使用npm test,就像我们为Node做的那样:

Jest 的输出与我们在 Node 中看到的样式相同;快照总数将在后面解释

Jest被设置为自动监视更改,因此如果您修改任何文件,测试将再次进行 - q命令将停止监视模式,您将不得不使用a来运行所有测试,或者pt来过滤一些要运行的测试。

我们现在已经看到了如何测试组件。然而,还需要一些额外的工作,因为在我们的示例中,我们还没有处理任何与Redux相关的事项,比如分发操作或 thunks;让我们转向其他类型的测试。

测试 reducers 和映射

在测试完组件之后,我们现在转向一个更简单的测试集:首先是 reducers;然后是mapStateToProps()mapDispatchToProps()等映射。为什么这些测试更容易编写?因为在所有这些情况下,我们都在处理纯函数,没有副作用,它们的输出仅基于它们的输入。我们在本书早期处理了这些类型的函数,当时我们为 Node 进行了测试,所以现在我们将用一个简短的部分来完成。我们唯一需要特别注意的是验证没有函数(例如 reducer)试图修改状态,但除此之外,测试都很简单。在这个配方中,让我们看看我们为 reducers 和映射需要哪些不同类型的测试。

如何做...

我们将不得不测试 reducers 和映射,所以让我们首先考虑如何测试 reducer。有两个关键的事情需要验证:首先,给定一个输入状态,它产生一个正确的输出状态,其次,reducer 不修改原始状态。第一个条件是非常明显的,但第二个条件很容易被忽视 - 修改当前状态的 reducer 可能会产生难以发现的错误。

让我们看看我们如何测试我们的国家和地区应用程序的 reducer。首先,由于所有测试都是类似的,我们只会看到其中的一些,针对所有可能的操作中的两个 - 但当然,你想测试所有的操作,对吧?我们还将包括另一个测试,以验证对于未知操作,reducer 只返回初始状态,以任何方式都不改变:

// Source file: src/regionsApp/world.reducer.test.js

/* @flow */

import { reducer } from "./world.reducer.js";
import { countriesRequest, regionsSuccess } from "./world.actions.js";

describe("The countries and regions reducer", () => {
    it("should process countryRequest actions", () => {
        const initialState = {
            loadingCountries: false,
            currentCountry: "whatever",
            countries: [{}, {}, {}],
            loadingRegions: false,
            regions: [{}, {}]
        };

        const initialJSON = JSON.stringify(initialState);

        expect(reducer(initialState, countriesRequest())).toEqual({
            loadingCountries: true,
            currentCountry: "whatever",
            countries: [],
            loadingRegions: false,
            regions: [{}, {}]
        });

        expect(JSON.stringify(initialState)).toBe(initialJSON);
    });

    it("should process regionsSuccess actions", () => {
        const initialState = {
            loadingCountries: false,
            currentCountry: "whatever",
            countries: [{}, {}, {}],
            loadingRegions: true,
            regions: []
        };

        const initialJSON = JSON.stringify(initialState);

        expect(
            reducer(
                initialState,
                regionsSuccess([
                    { something: 1 },
                    { something: 2 },
                    { something: 3 }
                ])
            )
        ).toEqual({
            loadingCountries: false,
            currentCountry: "whatever",
            countries: [{}, {}, {}],
            loadingRegions: false,
            regions: [{ something: 1 }, { something: 2 }, { something: 3 }]
        });

        expect(JSON.stringify(initialState)).toBe(initialJSON);
    });

    it("should return the initial state for unknown actions", () => {
        const initialState = {
            loadingCountries: false,
            currentCountry: "whatever",
            countries: [{}, {}, {}],
            loadingRegions: true,
            regions: []
        };
        const initialJSON = JSON.stringify(initialState);

        expect(
            JSON.stringify(reducer(initialState, { actionType: "other" }))
        ).toBe(initialJSON);
        expect(JSON.stringify(initialState)).toBe(initialJSON);
    });
});

您是否想知道Enzyme,以及为什么我们跳过它?我们只在渲染组件时才需要它,所以对于测试 reducer 或操作(正如我们很快将要做的那样),根本不需要它。

reducer 的每个测试都是相同的,并遵循以下步骤:

  1. 定义initialState并使用JSON.stringify()保存其原始字符串表示。

  2. 调用 reducer 并使用.toEqual()(一个Jest方法,它在对象之间进行深度、递归的相等比较)来验证新状态是否完全匹配您期望的状态。

  3. 检查initialState的 JSON 表示是否仍然与原始值匹配。

我为国家和地区使用了虚拟值,但如果您想更加小心,您可以指定完整、正确的值,而不是像{ something:2 }"whatever"这样的值;这取决于您。

您可能想看看redux-testkitgithub.com/wix/redux-testkit;这个包可以帮助您编写 reducer 测试,自动检查状态是否已被修改。

编写这些测试后,很明显为映射函数编写测试是相同的。例如,当我们设置<ConnectedRegionsTable>组件时,我们编写了一个getProps()函数:

const getProps = state => ({
    list: state.regions,
    loading: state.loadingRegions
});

我们必须导出该函数(当时我们没有这样做,因为它不会在其他地方使用),然后可以执行测试,如下所示:

// Source file: src/regionsApp/regionsTable.connected.test.js

/* @flow */

import { getProps } from "./regionsTable.connected.js";

describe("getProps for RegionsTable", () => {
    it("should extract regions and loading", () => {
        const initialState = {
            loadingCountries: false,
            currentCountry: "whatever",
            countries: [{ other: 1 }, { other: 2 }, { other: 3 }],
            loadingRegions: false,
            regions: [{ something: 1 }, { something: 2 }]
        };
        const initialJSON = JSON.stringify(initialState);

        expect(getProps(initialState)).toEqual({
            list: [{ something: 1 }, { something: 2 }],
            loading: false
        });
        expect(JSON.stringify(initialState)).toBe(initialJSON);
    });
});

这是如何工作的?让我们看看运行这些测试时会发生什么。

它是如何工作的...

使用npm test将产生一个很好的全部绿色输出,这意味着所有测试都已通过,就像前一节一样;不需要再次看到。在每个单独的测试中,我们应用了之前描述的技术:设置状态,保存其字符串版本,应用 reducer 或 mapper 函数,检查它是否与您希望它产生的匹配,并检查原始状态是否仍然与保存的版本匹配。

想象一下,有人意外地修改了我们测试的getProps()函数,以便它返回地区而不是返回国家列表,如下所示:

通过使用.toEqual()方法检测到映射(或 reducer)函数的任何意外更改,

进行产生和预期值的深度比较

因此,这些简单的测试可以帮助您防止意外更改-包括预期值的添加、删除或修改。这是一个很好的安全网!

测试操作和 thunks

为了完成我们的测试目标,我们必须看看如何测试操作和 thunks。测试前者在我们迄今为止所做的一切之后真的非常琐碎,因为只需要调用一个操作创建者并检查生成的操作上的字段,但是测试 thunks,这肯定会涉及异步服务调用,并且肯定会分发几个-好吧,这很有趣!

我们将跳过更简单的操作测试(尽管我们将测试它们,正如您将看到的那样),并直接开始编写我们的 thunks 的单元测试。

准备工作

我们在这里需要的一个好工具是redux-mock-store,这是一个小包,让我们可以使用一个假存储,模仿其所有功能,并提供一些调用,比如.getActions(),以检查分发了哪些操作,以什么顺序,带有哪些数据等等。安装很简单,像往常一样:

npm install redux-mock-store --save-dev

您可能想知道我们将如何管理模拟 API 服务调用。根据您的架构,如果您的 thunks 直接使用axios()fetch()之类的东西来联系服务,那么您肯定需要相应的模拟包。但是,由于我们将这些 API 调用分离到单独的包中,我们可以通过模拟整个调用来很好地完成,以便不会进行任何 AJAX 调用;我们很快就会做到这一点。

请查看redux-mock-store的完整文档,网址是 github.com/dmitry-zaets/redux-mock-store

如何做...

我们想要测试动作。让我们看看如何执行这些测试。

由于我们一直在大量使用我们的国家和地区示例,让我们通过测试(至少一部分)其动作和 thunk 来结束:getCountries()是一个很好的例子,而且与getRegions()非常相似。在这里,记住特定的代码将是很有帮助的,让我们来看一下:

export const getCountries = () => async dispatch => {
 try {
 dispatch(countriesRequest());
 const result = await getCountriesAPI();
 dispatch(countriesSuccess(result.data));
 } catch (e) {
 dispatch(countriesFailure());
 }
};

首先,它分发一个动作来标记正在进行的请求。然后,它等待网络服务调用的结果;这将需要模拟!最后,如果调用成功,将分发一个包括接收到的国家列表的动作。在失败的调用上,将分发一个不同的动作,但显示失败。

现在让我们考虑一下-我们如何处理 API 调用?world.actions.js源代码直接从一个模块中导入getCountriesAPI(),但是Jest专门为此提供了一个功能:我们可以模拟一个完整的模块,为我们想要的任何函数提供模拟或间谍,如下所示:

// Source file: src/regionsApp/world.actions.test.js

/* @flow */

import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";

import {
    getCountries,
    COUNTRIES_REQUEST,
    COUNTRIES_SUCCESS,
    COUNTRIES_FAILURE
} from "./world.actions.js";

import { getCountriesAPI } from "./serviceApi";

let mockPromise;
jest.mock("./serviceApi", () => {
 return {
 getCountriesAPI: jest.fn().mockImplementation(() => mockPromise)
 };

// *continues...*

每当getCountries()函数调用getCountriesAPI()时,我们的模拟模块将被使用,并且将返回一个承诺(mockPromise);我们需要适当地决定这个承诺应该是什么,并且根据我们想要测试失败或成功来做出选择。

现在我们有了拦截 API 调用并使其产生我们想要的任何结果的方法,我们可以继续编写实际的测试。

让我们先处理快乐路径,在这种情况下,国家的 API 调用是成功的,没有问题。测试可以以以下方式编写:

// ...*continued*

describe("getCountries", () => {
    it("on API success", async () => {
 const fakeCountries = {
 data: [{ code: "UY" }, { code: "AR" }, { code: "BR" }]
 };
 mockPromise = Promise.resolve(fakeCountries);

        const store = configureMockStore([thunk])({});

        await store.dispatch(getCountries());

        const dispatchedActions = store.getActions();

 expect(getCountriesAPI).toHaveBeenCalledWith();
 expect(dispatchedActions.length).toBe(2);
 expect(dispatchedActions[0].type).toBe(COUNTRIES_REQUEST);
 expect(dispatchedActions[1].type).toBe(COUNTRIES_SUCCESS);
 expect(dispatchedActions[1].listOfCountries).toEqual(
 fakeCountries.data
 );
    });

// *continues...*

这段代码的结构是怎样的?

  1. 首先,我们定义了一些数据(fakeCountries),这些数据将由我们的mockPromise返回。

  2. 然后,根据redux-mock-store的文档,我们创建了一个模拟商店;在我们的情况下,我们只使用了thunk中间件,但您可以添加更多。实际上,在我们的原始代码中,我们在thunk后面跟着logger,但这对我们的测试不相关。

  3. 之后,我们store.dispatch()getCountries() thunk 并等待其结果。

  4. 一切都完成后,我们使用store.getActions()来获取实际分发的动作列表。

  5. 我们测试我们的getCountriesAPI()函数是否被调用;如果没有被调用,我们将陷入严重麻烦!

  6. 最后,我们测试了所有分发的动作,检查它们的type和其他属性。实际上,这是对动作创建者本身的间接测试!

既然我们已经看过一个成功的案例,让我们假设 API 调用以某种方式失败了。为了模拟这一点,我们所要做的就是为getCountriesAPI()调用定义一个不同的承诺来返回:

// ...*continued*

    it("on API failure", async () => {
 mockPromise = Promise.reject(new Error("failure!"));

        const store = configureMockStore([thunk])({});

        await store.dispatch(getCountries());

        const dispatchedActions = store.getActions();

        expect(getCountriesAPI).toHaveBeenCalledWith();
        expect(dispatchedActions.length).toBe(2);
        expect(dispatchedActions[0].type).toBe(COUNTRIES_REQUEST);
        expect(dispatchedActions[1].type).toBe(COUNTRIES_FAILURE);
    });
});

// *continues...*

在这种情况下有什么不同?我们的mockPromise现在设置为失败,因此第二个分发的动作的测试会有所不同:在这种情况下,我们只会得到一个失败,而不是成功和国家列表-但是测试的其余部分基本相同。

最后,让我们完成一个额外的案例。当我们编写 thunk 时,我们发现我们可以通过getState()函数访问当前状态,并根据其内容采取不同的行动。我们本来可以编写我们的getCountries()函数,以避免在已经获得国家列表时进行 API 调用,以进行小优化;关键部分将如下所示:

// ...*continued*

export const getCountries = () => async (dispatch, getState) => {
 if (getState().countries.length) {
 // no need to do anything!
 } else {
        try {
            dispatch(countriesRequest());
            const result = await getCountriesAPI();
            dispatch(countriesSuccess(result.data));
        } catch (e) {
            dispatch(countriesFailure());
        }
    }
};

// *continues*...

我们如何测试这种情况?不同之处在于我们如何设置商店,以及实际分发了哪些动作:

// ...*continued*

describe("optimized getCountries", () => {
    it("doesn't do unneeded calls", async () => {
        const store = configureMockStore([thunk])({
 countries: [{ land: 1 }, { land: 2 }]
        });

 jest.resetAllMocks();

        await store.dispatch(getCountries());

        expect(getCountriesAPI).not.toHaveBeenCalled();
 expect(store.getActions().length).toBe(0);
    });
});

当我们设置存储时,我们可以提供初始值,就像在这种情况下,我们假设一些国家(虚假数据!)已经被加载。一个特殊的要求:我们必须使用jest.resetAllMocks(),否则我们将无法检查getCountriesAPI()是否被调用 - 因为它调用了,但是由之前的测试调用的。然后,在分派 thunk 之后,我们只需检查 API 是否未被调用,并且未分派任何操作:一切正常!

它是如何工作的...

运行这些测试并不复杂,只需要npm test。我们可以看到我们的两个测试的结果(原始和优化后的getCountries()函数),通过的结果表明一切都如预期那样。当您运行单个测试时,输出会更详细,显示每个单独的测试:

对于操作和 thunk 的测试需要更多的设置,但以相同的方式运行。这次我们运行单个测试,因此获得了更详细的输出。

使用快照测试进行更改

到目前为止,我们一直在看组件、事件和操作的自动测试,因此让我们通过考虑一个测试工具来结束本章,这个测试工具并不真正是 TDD 的一部分,而是对事后不希望或不期望的更改的一种保障:快照。(在 TDD 中,测试将在编写组件代码之前编写,但您会看到这在这里是不可能的。)快照测试的工作方式如下:您渲染一个 UI 组件,捕获生成了什么 HTML,然后将其与先前存储的参考捕获进行比较。如果两个捕获不匹配,要么有人做了意外的更改,要么更改实际上是预期的。如果是这种情况,您将不得不验证新的捕获是否正确,然后放弃旧的捕获。

如何做...

我们可以为所有组件使用快照测试,但对于那些在其属性方面变化的组件来说,这更有趣,因此可以预期不同的行为。我们将使用不同的渲染方式:而不是生成 HTML 元素,我们将使用生成文本输出的渲染器,这样可以轻松存储和比较。

首先,最简单的情况是具有标准固定输出的组件。我们有一些例子:对于我们的<ClicksDisplay>组件,测试将写成如下形式:

// Source file: src/counterApp/clicksDisplay.test.js

import React from "react";
import TestRenderer from "react-test-renderer";

import { ClicksDisplay } from "./";

describe("clicksDisplay", () => {
    it("renders correctly", () => {
 const tree = TestRenderer
 .create(<ClicksDisplay clicks={22} />)
 .toJSON();
 expect(tree).toMatchSnapshot();
    });
});

基本上,我们导入特殊的TestRenderer渲染器函数,使用它为我们的组件生成输出,然后将其与存储的快照进行比较;我们很快就会看到这是什么样子。测试基本上总是相同的:对于我们的<Counter>组件,测试代码将是完全类似的:

// Source file: src/counterApp/counter.test.js

import React from "react";
import TestRenderer from "react-test-renderer";

import { Counter } from "./counter.component";

describe("clicksDisplay", () => {
    it("renders correctly", () => {
        const tree = TestRenderer
            .create(<Counter count={9} dispatch={() => null} />)
            .toJSON();
        expect(tree).toMatchSnapshot();
    });
});

差异很小;只需提供正确的预期属性,没有其他。让我们继续进行更有趣的案例。

如果您必须使用无法预先确定的属性值来渲染对象(这不太可能),您将不得不使用特殊的属性匹配器;您可以在jestjs.io/docs/en/snapshot-testing#property-matchers了解更多信息。

当您有组件的输出取决于其属性时,快照测试变得更有趣,因为它们可以让您验证不同的结果是否如预期那样产生。对于我们的国家和地区代码,我们有这样的情况:例如,<RegionsTable>组件预期显示区域列表(如果提供了),或者显示"没有区域"文本(如果没有可用的)。我们应该编写这些测试。让我们继续:

// Source file: src/regionsApp/regionsTable.snapshot.test.js

import React from "react";
import TestRenderer from "react-test-renderer";

import { RegionsTable } from "./regionsTable.component";

describe("RegionsTable", () => {
 it("renders correctly an empty list", () => {
        const tree = TestRenderer.create(<RegionsTable list={[]} />).toJSON();
        expect(tree).toMatchSnapshot();
    });

 it("renders correctly a list", () => {
        const tree = TestRenderer
            .create(
                <RegionsTable
                    list={[
                        {
                            countryCode: "UY",
                            regionCode: "10",
                            regionName: "Montevideo"
                        },
                        .
                        .
                        .
                    ]}
                />
            )
            .toJSON();
        expect(tree).toMatchSnapshot();
    });
});

我们有两种不同的情况,就像我们之前描述的那样:一个快照将匹配没有区域的情况,另一个将匹配如果提供了一些区域的预期情况。对于<CountrySelect>组件,代码将类似:

// Source file: src/regionsApp/countrySelect.snapshot.test.js

import React from "react";
import TestRenderer from "react-test-renderer";

import { CountrySelect } from "./countrySelect.component";

describe("CountrySelect", () => {
 it("renders correctly when loading, with no countries", () => {
        const tree = TestRenderer
            .create(
                <CountrySelect
                    loading={true}
                    onSelect={() => null}
                    getCountries={() => null}
                    list={[]}
                />
            )
            .toJSON();
        expect(tree).toMatchSnapshot();
    });

 it("renders correctly a countries dropdown", () => {
        const tree = TestRenderer
            .create(
                <CountrySelect
                    loading={false}
                    onSelect={() => null}
                    getCountries={() => null}
                    list={[
                        {
                            countryCode: "UY",
                            countryName: "Uruguay"
                        },
                        .
                        .
                        .
                    ]}
                />
            )
            .toJSON();
        expect(tree).toMatchSnapshot();
    });
});

因此,测试具有多个可能输出的组件并不难,只需要编写多个快照测试;一个简单的解决方案。

最后,为了简化测试,当您的组件本身有更多的组件时,使用浅渲染有助于集中在主要的高级方面,并将内部组件的渲染细节留给其他测试。我们可以像这样快速创建一个虚构的<CountryAndRegions>组件,显示我们国家的下拉菜单和地区表:

// Source file: src/regionsApp/countryAndRegions.test.js

import React from "react";
import ShallowRenderer from "react-test-renderer/shallow";

import { CountrySelect } from "./countrySelect.component";
import { RegionsTable } from "./regionsTable.component";

class CountryAndRegions extends React.Component {
    render() {
        return (
            <div>
                <div>
                    Select:
                    <CountrySelect
                        loading={true}
                        onSelect={() => null}
                        getCountries={() => null}
                        list={[]}
                    />
                </div>
                <div>
                    Display: <RegionsTable list={[]} />
                </div>
            </div>
        );
    }
}

describe("App for Regions and Countries", () => {
    it("renders correctly", () => {
        const tree = new ShallowRenderer().render(<CountryAndRegions />);
        expect(tree).toMatchSnapshot();
    });
});

请注意,使用ShallowRenderer的方式与其他渲染器不同:您必须创建一个新对象,调用其.render()方法,而不再使用.toJSON()。我们将很快看一下这个新测试与以前的测试有何不同。

它是如何工作的...

运行快照与运行其他测试没有什么不同:您运行Jest测试脚本,所有测试一起运行。

运行测试

如果您像之前一样运行npm test,您现在会得到类似以下清单的输出:

 PASS src/regionsApp/countryAndRegions.test.js
 PASS src/counterApp/counter.test.js
 PASS src/regionsApp/countrySelect.test.js
 PASS src/regionsApp/regionsTable.test.js
 PASS src/counterApp/clicksDisplay.test.js

Test Suites: 5 passed, 5 total
Tests:       7 passed, 7 total
Snapshots:   7 passed, 7 total
Time:        0.743s, estimated 1s
Ran all test suites related to changed files.

Watch Usage
 › Press a to run all tests.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

唯一可见的区别是您会得到特定数量的快照(在这种情况下为七个),但还有更多。

生成的快照文件

如果您检查源代码目录,您会发现一些新的__snapshots__目录,其中包含一些.snap文件。例如,在/regionsApp目录中,您会发现这个:

> dir
-rw-r--r-- 1 fkereki users 956 Aug 10 20:48 countryAndRegions.test.js
-rw-r--r-- 1 fkereki users 1578 Jul 28 13:02 countrySelect.component.js
-rw-r--r-- 1 fkereki users 498 Jul 25 23:16 countrySelect.connected.js
-rw-r--r-- 1 fkereki users 1301 Aug 10 20:31 countrySelect.test.js
-rw-r--r-- 1 fkereki users 212 Jul 22 21:07 index.js
-rw-r--r-- 1 fkereki users 985 Aug 9 23:45 regionsTable.component.js
-rw-r--r-- 1 fkereki users 274 Jul 22 21:17 regionsTable.connected.js
-rw-r--r-- 1 fkereki users 1142 Aug 10 20:32 regionsTable.test.js
-rw-r--r-- 1 fkereki users 228 Jul 25 23:16 serviceApi.js
drwxr-xr-x 1 fkereki users 162 Aug 10 20:44 __snapshots__
-rw-r--r-- 1 fkereki users 614 Aug 3 22:22 store.js
-rw-r--r-- 1 fkereki users 2679 Aug 3 21:33 world.actions.js

对于每个包含快照的.test.js文件,您会找到一个相应的.snap文件:

> dir __snapshots__/
-rw-r--r-- 1 fkereki users 361 Aug 10 20:44 countryAndRegions.test.js.snap
-rw-r--r-- 1 fkereki users 625 Aug 10 20:32 countrySelect.test.js.snap
-rw-r--r-- 1 fkereki users 352 Aug 10 20:01 regionsTable.test.js.snap

这些文件的内容显示了运行时生成的快照。例如,countrySelect.test.js.snap文件包括以下代码:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CountrySelect renders correctly a countries dropdown 1`] = `
<div
  className="bordered"
>
  Country: 
  <select
    onChange={[Function]}
  >
    <option
      value=""
    >
      Select a country:
    </option>
    <option
      value="AR"
    >
      Argentina
    </option>
    <option
      value="BR"
    >
      Brazil
    </option>
    <option
      value="UY"
    >
      Uruguay
    </option>
  </select>
</div>
`;

exports[`CountrySelect renders correctly when loading, with no countries 1`] = `
<div
  className="bordered"
>
  Loading countries...
</div>
`;

您可以看到我们两种情况的输出:一个是完整的国家列表,另一个是在加载国家时,等待服务响应到达时的情况。

我们还可以在countryAndRegions.test.js.snap文件中看到一个浅层测试:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App for Regions and Countries renders correctly 1`] = `
<div>
  <div>
    Select:
    <CountrySelect
      getCountries={[Function]}
      list={Array []}
      loading={true}
      onSelect={[Function]}
    />
  </div>
  <div>
    Display: 
    <RegionsTable
      list={Array []}
    />
  </div>
</div>
`;

在这种情况下,请注意<CountrySelect><RegionsTable>组件没有展开;这意味着您只在这里测试高级快照,这是期望的。

重新生成快照

如果组件发生了变化会发生什么?仅仅为了这个目的,我对一个组件进行了一个非常小的更改。运行测试后,我收到了一个 FAIL 消息,附带了一个比较,这是由通常的diff命令生成的:

 FAIL src/regionsApp/countryAndRegions.test.js
  ● App for Regions and Countries › renders correctly

    expect(value).toMatchSnapshot()

 Received value does not match stored snapshot 1.

    - Snapshot
    + Received

    @@ -7,11 +7,11 @@
           loading={true}
           onSelect={[Function]}
         />
       </div>
       <div>
 - Display: 
 + Displays: 
         <RegionsTable
           list={Array []}
         />
       </div>
     </div>

      at Object.it (src/regionsApp/countryAndRegions.test.js:31:22)
          at new Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:46:16)

那么,您应该怎么做呢?您应该首先验证更改是否正确,如果是这样,您可以删除.snap文件(这样它将在下次重新生成),或者您可以按u键,如测试摘要中所示:

Snapshot Summary
 › 1 snapshot test failed in 1 test suite. Inspect your code changes or press `u` to update them.

小心!如果您只是重新生成快照而没有验证输出是否正确,那么测试将毫无意义;这是一个非常糟糕的结果!

测量测试覆盖率

我们已经在第五章的测量测试覆盖率部分看到了如何为Jest测试获取覆盖率,因此在这个示例中,我们将简要介绍一些我们将对测试进行的小改动。

如何做...

我们想要衡量我们的测试有多彻底,所以让我们看看必要的步骤。在使用Node时,我们直接调用了jest命令。然而,在这里,由于应用是由create-react-app构建的,我们将不得不以稍有不同的方式工作。我们将不得不向package.json添加一个新的脚本,以便用额外的参数调用我们的测试:

"scripts": {
    .
    .
    .
    "test": "react-app-rewired test --env=jsdom",
 "coverage": "react-app-rewired test --env=jsdom --coverage --no-cache",
    .
    .
    .
}

--coverage选项将生成一个覆盖率报告,并生成一个/coverage目录,与Node一样,--no-cache选项将强制 Jest 重新生成所有结果,而不是依赖于先前可能不再有效的缓存值。

我们的.gitignore文件包括一行内容为/coverage,因此生成的文件不会被推送到 Git 服务器。

它是如何工作的...

如果你运行npm run coverage,你将得到文本输出和 HTML 输出。前者看起来像下面截图中显示的内容;你必须接受现实中,行是绿色、黄色或红色的,取决于覆盖程度。

在我们的情况下,我们得到了很多红色,因为我们只写了一些测试,而不是进行完整的测试套件;你可以自己完成它,作为读者的练习!

有色 ASCII 输出显示了我们所有源代码文件的覆盖评估;绿色表示良好的覆盖,黄色表示中等覆盖,

红色表示结果不佳。由于我们只写了一些测试,我们得到了很多红色!

如果你在浏览器中打开/coverage/lcov-report/index.html文件,你会得到与Node章节中相同类型的结果,如下所示:

HTML 输出允许您浏览项目的目录和文件。如果您点击特定文件,甚至可以看到哪些行和函数被执行,哪些被测试跳过。

如果你愿意,甚至可以使用coverageThreshold配置对象来指定必须达到的覆盖水平,以便测试被认为是足够的;有关更多信息,请参阅jestjs.io/docs/en/configuration.html#coveragethreshold-object

我们现在已经完成了与ReactRedux的工作,我们已经看过了如何构建 Web 应用程序,并且使用了之前开发的Node服务器后端。让我们继续进行其他类型的开发,首先是移动应用程序,同样也是用 JS!

第十一章:使用 React Native 创建移动应用程序

在本章中,我们将看看以下食谱:

  • 设置事情

  • 添加开发工具

  • 使用本机组件

  • 适应设备和方向

  • 样式和布局您的组件

  • 添加特定于平台的代码

  • 路由和导航

介绍

在过去的几章中,我们向您展示了如何使用React构建 Web 应用程序,在本章中,我们将使用一个紧密相关的React Native来开发可以在 Android 和 iOS(苹果)手机上运行的本机应用程序。

设置事情

对于移动应用程序的开发,有几种可能的方法:

  • 使用本机语言,例如 Java 或 Kotlin 用于 Android,或 Objective C 或 Swift 用于 iOS,使用每个平台的本机开发工具。这可以确保您的应用程序最适合不同的手机,但需要多个开发团队,每个团队都有特定平台的经验。

  • 使用纯网站,用户可以通过手机浏览器访问。这是最简单的解决方案,但应用程序会有一些限制,比如无法访问大多数手机功能,因为它们无法在 HTML 中使用。此外,使用无线连接运行,信号强度可能会有所不同,有时可能会很困难。您可以使用任何框架进行开发,比如React

  • 开发混合应用程序,这是一个网页,捆绑了一个浏览器,包括一组扩展,以便您可以使用手机的内部功能。对于用户来说,这是一个独立的应用程序,即使没有网络连接也可以运行,并且可以使用大多数手机功能。这些应用程序通常使用 Apache Cordova 或其衍生产品 PhoneGap。

还有第四种风格,由 Facebook 开发的React Native,沿用了现有的ReactReact Native(从现在开始,我们将缩写为RN)不是将组件呈现到浏览器的 DOM,而是调用本机 API 来创建通过您的 JS 代码处理的内部组件。通常的 HTML 元素和 RN 的组件之间存在一些差异,但并不难克服。使用这个工具,您实际上正在构建一个外观和行为与任何其他本机应用程序完全相同的本机应用程序,只是您使用了一种语言 JS,用于 Android 和 iOS 开发。

在这个示例中,我们将设置一个 RN 应用程序,以便我们可以开始尝试开发手机应用程序。

如何做...

有三种设置 RN 应用程序的方法:完全手动设置,这是您不想做的;其次,使用react-native-cli命令行界面进行打包;或者最后,使用一个与我们已经用于React非常相似的包,create-react-native-app(从现在开始,我们将称其为CRAN)。这两个包之间的一个关键区别是,对于后者,您无法包含自定义的本地模块,如果需要这样做,您将不得不弹出项目,这也需要设置其他几个工具。

您可以在facebook.github.io/react-native/docs/getting-started.html了解更多关于后两种方法的信息,如果您想为弹出做好准备,可以访问github.com/react-community/create-react-native-app/blob/master/EJECTING.md

我们首先要获取一个命令行实用程序,其中包括许多其他包:

npm install create-react-native-app -g

之后,我们可以使用只有三个命令的简单项目创建和运行一个简单的项目:

create-react-native-app yourprojectname
cd yourprojectname
npm start

您已经准备好了!让我们看看它是如何工作的——是的,我们还有一些配置要做,但检查一下事情是否进行得很顺利是件好事。

它是如何工作的...

运行应用程序时,它会在您的机器上的端口1900019001启动服务器,您将使用Expo应用程序连接到该服务器,您可以在expo.io/learn找到该应用程序,适用于 Android 或 iOS。按照屏幕上的说明进行安装:

启动应用程序时获得的初始屏幕

当您第一次打开Expo应用程序时,它将看起来像以下截图。请注意,手机和您的机器必须在同一本地网络中,并且您的机器还必须允许连接到端口1900019001;您可能需要修改防火墙才能使其正常工作:

在加载 Expo 应用程序时,您需要扫描 QR 码以连接到服务器

使用扫描 QR 码选项后,将进行一些同步,很快您将看到您的基本代码运行正常:

成功——您的代码已经运行起来了!

此外,如果您修改App.js源代码,更改将立即反映在您的设备上,这意味着一切正常!为了确保这一点,摇动手机以启用调试菜单,并确保启用了实时重新加载和热重新加载。您还需要远程 JS 调试以备后用。您的手机应该如下所示:

这些设置可以重新加载和调试

还有更多...

通过使用Expo客户端,CRAN 可以让您为 iOS 开发,即使您没有苹果电脑。(如果您有 Windows 或 Linux 机器,则无法为苹果系统开发;您必须拥有 MacBook 或类似设备;这是苹果的限制。)此外,在实际设备上工作在某些方面更好,因为您可以实际看到最终用户将看到的内容——毫无疑问。

但是,您可能有几个原因希望以不同方式工作,也许是在计算机上使用模拟真实设备的模拟器。首先,您可能很难获得十几个最受欢迎的设备,以便在每个设备上测试您的应用程序。其次,在自己的机器上工作更加方便,您可以轻松进行调试,截图,复制和粘贴等。因此,您可以安装 Xcode 或 Android SDK 以使自己能够使用模拟机器进行工作。

我们不会在这里详细介绍,因为根据您的开发操作系统和目标操作系统有很多组合;相反,让我们指向文档facebook.github.io/react-native/docs/getting-started.html,在那里您应该点击使用本机代码构建项目,并查看与模拟器一起工作所需的内容。安装完毕后,您将需要Expo客户端(与您的实际设备一样),然后您将能够在自己的机器上运行代码。

例如,看一下以下截图中模拟 Nexus 5 的 Android 模拟器:

在您的屏幕上直接运行的模拟 Nexus 5 Android

使用此模拟器,您将具有与实际设备完全相同的功能。例如,您还可以获得调试菜单,尽管打开它的方式会有所不同;例如,在我的 Linux 机器上,我需要按Ctrl + M

所有在手机上可用的功能在模拟设备上也同样可用

使用Android 虚拟设备AVD)管理器,您可以为手机和平板电脑创建许多不同的模拟器;使用 Xcode,您也可以获得类似的功能,尽管这仅适用于 macOS 计算机。

添加开发工具

现在,让我们更好地配置一下。与之前的章节一样,我们希望使用 ESLint 进行代码检查,Prettier进行格式化,Flow进行数据类型检查。CRAN 负责包含BabelJest,所以我们不需要为这两个做任何事情。

如何做...

与在React中需要添加特殊的rewiring包才能使用特定配置的情况相反,在 RN 中,我们只需要添加一些包和配置文件,就可以准备好了。

添加 ESLint

对于 ESLint,我们需要相当多的包。我们在React中使用了大部分,但还有一个特殊的添加,eslint-plugin-react-native,它添加了一些 RN 特定的规则:

npm install --save-dev \
 eslint eslint-config-recommended eslint-plugin-babel \
 eslint-plugin-flowtype eslint-plugin-react eslint-plugin-react-native

如果你想了解eslint-plugin-react-native添加的(实际上很少的)额外规则,请查看其 GitHub 页面github.com/Intellicode/eslint-plugin-react-native。其中大部分与样式有关,还有一个是用于特定平台代码的,但我们稍后会讨论这个。

我们需要一个单独的.eslintrc文件,就像我们在React中所做的一样。适当的内容包括以下内容,我已经突出显示了 RN 特定的添加内容:

{
    "parser": "babel-eslint",
    "parserOptions": {
        "ecmaVersion": 2017,
        "sourceType": "module",
        "ecmaFeatures": {
            "jsx": true
        }
    },
    "env": {
        "node": true,
        "browser": true,
        "es6": true,
        "jest": true,
 "react-native/react-native": true
    },
    "extends": [
        "eslint:recommended",
        "plugin:flowtype/recommended",
        "plugin:react/recommended",
 "plugin:react-native/all"
    ],
    "plugins": ["babel", "flowtype", "react", "react-native"],
    "rules": {
        "no-console": "off",
        "no-var": "error",
        "prefer-const": "error",
        "flowtype/no-types-missing-file-annotation": 0
    }
}

添加 Flow

完成后,ESLint已经设置好识别我们的代码,但我们还需要配置Flow

npm install --save-dev flow flow-bin flow-coverage-report flow-typed

我们需要在package.jsonscripts部分添加几行:

"scripts": {
    "start": "react-native-scripts start",
    .
    .
    .
 "flow": "flow",
 "addTypes": "flow-typed install"
},

然后,我们需要初始化Flow的工作目录:

npm run flow init

最后,我们可以使用与之前 React 相同的.flowconfig文件:

[ignore]
.*/node_modules/.*

[include]

[libs]

[lints]
all=warn
untyped-type-import=off
unsafe-getters-setters=off

[options]
include_warnings=true

[strict]

现在我们已经准备好使用Flow,所以我们可以继续以我们习惯的方式工作——我们只需要添加Prettier来格式化我们的代码,然后我们就可以开始了!

添加 Prettier

重新安装Prettier并没有太多的事情,我们只需要一个npm命令,再加上我们一直在使用的.prettierrc文件。对于前者,只需使用以下命令:

npm install --save-dev prettier

对于配置,我们可以使用这个.prettierrc文件的内容:

{
    "tabWidth": 4,
    "printWidth": 75
}

现在,我们准备好了!我们可以检查它是否工作;让我们来做吧。

它是如何工作的...

让我们检查一切是否正常。我们将首先查看 CRAN 创建的App.js文件,我们可以立即验证工具是否正常工作——因为检测到了一个问题!看一下以下截图:

我们可以验证 ESLint 集成是否正常工作,因为它会突出显示一个问题

失败的规则是来自eslint-plugin-react-native的新规则:no-color-literals,因为我们在样式中使用了常量,这可能在将来会成为一个维护的头疼。我们可以通过添加一个变量来解决这个问题,并且我们将使用类型声明来确保Flow也在运行。新的代码应该如下所示——我已经突出显示了所需的更改:

// Source file: App.original.fixed.js /* @flow */

import React from "react";
import { StyleSheet, Text, View } from "react-native";

export default class App extends React.Component<> {
    render() {
        return (
            <View style={styles.container}>
                <Text>Open up App.js to start working on your app!</Text>
                <Text>Changes you make will automatically reload.</Text>
                <Text>Shake your phone to open the developer menu.</Text>
            </View>
        );
    }
}

const white: string = "#fff";

const styles = StyleSheet.create({
    container: {
        flex: 1,
        backgroundColor: white,
        alignItems: "center",
        justifyContent: "center"
    }
});

因此,现在我们已经恢复了所有的工具,我们可以开始实际的代码了!

使用原生组件

使用 RN 的工作方式非常类似于使用React——有组件、状态、属性、生命周期事件等等,但有一个关键区别:你自己的组件不是基于 HTML,而是基于特定的 RN 组件。例如,你不会使用<div>元素,而是使用<View>元素,然后 RN 将其映射到 iOS 的UIView或 Android 的Android.View。视图可以嵌套在视图中,就像<div>标签一样。视图支持布局和样式,它们响应触摸事件等等,因此它们基本上等同于<div>标签,除了移动环境的行为和特定性。

还有更多的不同之处:组件的属性也与 HTML 的不同,你需要查看文档(在facebook.github.io/react-native/docs/components-and-apis)来了解每个特定组件的所有可能性。

您不仅限于使用 RN 提供的组件。您可以通过使用其他人开发的本机组件来扩展您的项目;一个一流的来源是令人敬畏的 React Native 列表,网址为www.awesome-react-native.com/。请注意,您可能需要弹出您的项目才能这样做,因此请查看github.com/react-community/create-react-native-app/blob/master/EJECTING.md获取更多信息。

准备就绪

让我们首先浏览一下您可能想要使用的 RN 组件和 API 的列表,然后我们将转移到一些实际的代码:

RN 组件 替代... 目的
ActivityIndicator 动画 GIF 用于显示循环加载指示器的组件
Button button 处理触摸(点击)的组件
DatePickerAndroid TimePickerAndroid input type="date" input type="time" 显示弹出窗口的 API,您可以在其中输入日期和时间;适用于 Android

| DatePickerIOS | input type="date" input type="datetime-local"

input type="time" | 用户可以输入日期和时间的组件;适用于 iOS |

FlatList - 仅呈现可见元素的列表组件;用于提高性能
Image img 用于显示图像的组件
Picker select 从列表中选择值的组件
Picker.Item option 用于定义列表的值的组件
ProgressBarAndroid - 用于显示活动的组件;仅适用于 Android
ProgressViewIOS - 用于显示活动的组件;仅适用于 iOS
ScrollView - 可包含多个组件和视图的滚动容器
SectionList - 类似于FlatList,但允许分段列表
Slider input type="number" 从一系列值中选择值的组件
StatusBar - 管理应用程序状态栏的组件
StyleSheet CSS 为您的应用程序应用样式
Switch input type="checkbox" 用于接受布尔值的组件
Text - 用于显示文本的组件
TextInput input type="text" 用键盘输入文本的组件
TouchableHighlight TouchableOpacity - 使视图响应触摸的包装器
View div 应用程序的基本结构特征
VirtualizedList - FlatList的更灵活版本
WebView iframe 用于呈现网络内容的组件

还有许多您可能感兴趣的 API;其中一些如下:

API 描述
Alert 显示具有给定标题和文本的警报对话框
Animated 简化创建动画
AsyncStorage LocalStorage的替代方案
Clipboard 提供获取和设置剪贴板内容的访问权限
Dimensions 提供设备尺寸和方向变化的访问权限
Geolocation 提供地理位置访问权限;仅适用于已弹出的项目
Keyboard 允许控制键盘事件
Modal 显示在视图上方的内容
PixelRatio 提供设备像素密度的访问
Vibration 允许控制设备振动

为了尽可能少出问题,您可能更喜欢避开特定平台的组件和 API,并使用通用的兼容组件。但是,如果您决定使用一些特定于 Android 或 iOS 的元素,请查看facebook.github.io/react-native/docs/platform-specific-code了解如何操作的详细信息;这并不复杂。但是请记住,这将变得更难以维护,并且可能会改变一些交互或屏幕设计。

现在,让我们重新访问我们在第六章中为React编写的示例,使用 React 开发,国家和地区页面,这也将让我们使用Redux和异步调用,就像第八章中那样,扩展你的应用程序。由于我们使用了PropTypes,我们将需要该包。使用以下命令安装它:

npm install prop-types --save

然后,我们将不得不重新安装一些包,从Redux和相关的开始。实际上,CRAN 已经包括了reduxreact-redux,所以我们不需要这些,但redux-thunk没有包括在内。如果你以不同的方式创建了项目,而没有使用 CRAN,你将需要手动安装这三个包。在这两种情况下,以下命令都可以使用,因为npm不会安装已经安装的包:

npm install react react-redux redux-thunk --save

我们还将在本书中早些时候使用axios进行异步调用:

npm install axios --save

默认情况下,RN 提供了fetch而不是axios。然而,RN 包括了XMLHttpRequestAPI,这使我们可以毫无问题地安装axios。有关网络处理的更多信息,请查看facebook.github.io/react-native/docs/network

我们的最后一步将是运行我们在第四章中编写的服务器代码,使用 Node 实现 RESTful 服务,这样我们的应用程序将能够进行异步调用。转到该章节的目录,然后输入以下命令:

node out/restful_server.js.

现在,我们准备好了!现在让我们看看如何修改我们的代码,使其适用于 RN。

如何做...

由于 RN 使用自己的组件,你的 HTML 经验将没有多少用处。在这里,我们将看到一些变化,但为了充分利用 RN 的所有可能性,你将需要自己学习它的组件。让我们从<RegionsTable>组件开始,它相当简单。我们在第六章的使用 React 开发部分看到了它的原始代码;在这里,让我们专注于差异,这些差异都限制在render()方法中。之前,我们使用<div>标签并在其中显示文本;在这里,使用 RN,我们需要使用<View><Text>元素:

// Source file: src/regionsApp/regionsTable.component.js

.
.
.

render() {
    if (this.props.list.length === 0) {
        return (
 <View>
 <Text>No regions.</Text>
 </View>
        );
    } else {
        const ordered = [...this.props.list].sort(
            (a, b) => (a.regionName < b.regionName ? -1 : 1)
        );

        return (
 <View>
                {ordered.map(x => (
 <View key={x.countryCode + "-" + x.regionCode}>
 <Text>{x.regionName}</Text>
 </View>
                ))}
 </View>
        );
    }
}

请注意,在组件的其余部分没有变化,你所有的React知识仍然有效;你只需要调整你的渲染方法的输出。

接下来,我们将更改<CountrySelect>组件以使用<Picker>,这有点类似,但我们需要一些额外的修改。让我们看看我们的组件,突出显示需要进行更改的部分:

// Source file: src/regionsApp/countrySelect.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import { View, Text, Picker } from "react-native";

export class CountrySelect extends React.PureComponent<{
    dispatch: ({}) => any
}> {
    static propTypes = {
        loading: PropTypes.bool.isRequired,
 currentCountry: PropTypes.string.isRequired,
        list: PropTypes.arrayOf(PropTypes.object).isRequired,
        onSelect: PropTypes.func.isRequired,
        getCountries: PropTypes.func.isRequired
    };

    componentDidMount() {
        if (this.props.list.length === 0) {
            this.props.getCountries();
        }
    }

 onSelect = value => this.props.onSelect(value);

    render() {
        if (this.props.loading) {
            return (
 <View>
 <Text>Loading countries...</Text>
 </View>
            );
        } else {
            const sortedCountries = [...this.props.list].sort(
                (a, b) => (a.countryName < b.countryName ? -1 : 1)
            );

            return (
 <View>
 <Text>Country:</Text>
 <Picker
 onValueChange={this.onSelect}
 prompt="Country"
 selectedValue={this.props.currentCountry}
 >
 <Picker.Item
 key={"00"}
 label={"Select a country:"}
 value={""}
 />
 {sortedCountries.map(x => (
 <Picker.Item
 key={x.countryCode}
 label={x.countryName}
 value={x.countryCode}
 />
 ))}
 </Picker>
 </View>
            );
        }
    }
}

很多变化!让我们按照它们发生的顺序来看:

  • 一个意外的变化:如果你想让<Picker>组件显示其当前值,你必须设置它的selectedValue属性;否则,即使用户选择了一个国家,变化也不会在屏幕上显示出来。我们将不得不提供一个额外的属性currentCountry,我们将从存储中获取它,这样我们就可以将它用作我们列表的selectedValue

  • 当用户选择一个值时触发的事件也是不同的;事件处理程序将直接调用选择的值,而不是使用event.target.value来处理事件。

  • 我们必须用<Picker>替换<select>元素,并提供一个prompt文本属性,当扩展列表显示在屏幕上时将使用它。

  • 我们必须使用<Item>元素来表示单个选项,注意要显示的label现在是一个属性。

让我们不要忘记连接国家列表到存储时的更改;我们只需要在getProps()函数中添加一个额外的属性:

// Source file: src/regionsApp/countrySelect.connected.js

const getProps = state => ({
    list: state.countries,
 currentCountry: state.currentCountry,
    loading: state.loadingCountries
});

现在,我们需要做的就是看一下主应用是如何设置的。我们的App.js代码将非常简单:

// Source file: App.js

/* @flow */

import React from "react";
import { Provider } from "react-redux";

import { store } from "./src/regionsApp/store";
import { Main } from "./src/regionsApp/main";

export default class App extends React.PureComponent<> {
    render() {
        return (
 <Provider store={store}>
 <Main />
 </Provider>
        );
    }
}

这很简单。其余的设置将在main.js文件中进行,其中有一些有趣的细节:

// Source file: src/regionsApp/main.js

/* @flow */

import React from "react";
import { View, StatusBar } from "react-native";

import {
    ConnectedCountrySelect,
    ConnectedRegionsTable
} from ".";

export class Main extends React.PureComponent<> {
    render() {
        return (
 <View>
 <StatusBar hidden />
                <ConnectedCountrySelect />
                <ConnectedRegionsTable />
 </View>
        );
    }
}

除了在以前使用<div>的地方使用<View>(这是一个你应该已经习惯的变化)之外,还有一个额外的细节:我们不希望显示状态栏,因此我们使用<StatusBar>元素,并确保隐藏它。

好了,就是这样!在编写 RN 代码时,起初你需要努力记住哪些元素相当于你以前熟悉的 HTML 元素,哪些属性或事件发生了变化,但除此之外,你以前的所有知识仍然有效。最后,让我们看看我们的应用程序运行。

它是如何工作的...

为了多样化,我决定使用模拟设备,而不是像本章前面那样使用我的手机。在使用npm start启动应用程序后,我启动了我的设备,很快就得到了以下结果:

我们的应用程序刚刚加载,等待用户选择国家

如果用户触摸<Picker>元素,将显示一个弹出窗口,列出从我们的 Node 服务器接收到的国家,如下面的屏幕截图所示:

在触摸国家列表时,将显示一个弹出窗口,以便用户选择所需的国家。

当用户实际点击一个国家时,将触发onValueChange事件,并在调用服务器后显示区域列表,如下所示:

选择一个国家后,它的区域列表将显示出来,就像我们之前的 HTML React 版本一样

一切都很顺利,并且正在使用原生组件;太棒了!顺便说一句,如果你对我们描述的selectedValue问题不太确定,只需省略该属性,当用户选择一个国家时,你将得到一个糟糕的结果:

有一些差异,比如需要存在selectedValue属性,否则当前选择的值

不会更新-即使选择了巴西,选择器也不会显示它

在这里,我们通过一个编写 RN 代码的示例,正如我们所看到的,它与简单的React代码并没有太大不同,除了我们不能使用 HTML 之外,我们必须依赖不同的元素。

我们已经看到了两种运行我们代码的方式:使用我们的移动设备上的Expo客户端,以及在我们的计算机上使用模拟器。要尝试 RN,你可能想看看一些在线游乐场,比如 Snack,snack.expo.io/以及Repl.it,在repl.it/languages/react_native。在这两种环境中,你可以创建文件,编辑代码,并在线查看你的实验结果。

还有更多...

在让你的应用程序运行后的最后一步是创建一个独立的软件包,最好可以通过苹果和谷歌应用商店进行分发。如果你手动创建了你的应用程序,那么这个过程可能会变得有点复杂,你甚至需要一台真正的 macOS 电脑,因为否则你将无法为 iOS 构建:你将不得不阅读如何使用Xcode或 Android 开发者工具来制作应用程序,这可能有点复杂。相反,使用 CRAN 应用程序,这个过程可以简化,因为Expo提供了一个应用程序构建功能,这样你就不必自己构建。查看docs.expo.io/versions/latest/guides/building-standalone-apps.html获取具体的说明。

无论你决定如何进行构建过程,都要查看一些建议,以确保你的应用程序将被批准并受到良好的接待。docs.expo.io/versions/latest/guides/app-stores.html

适应设备和方向

当我们在第七章的增强您的应用程序中开发了一个响应式和自适应的网页时,我们必须处理窗口大小可能随时改变的可能性,我们的页面内容必须正确地重新定位自己。对于移动设备,屏幕尺寸不会改变,但仍然有可能旋转(从纵向模式到横向模式,反之亦然),因此您仍然必须处理至少一个变化。当然,如果您希望使您的应用程序在所有设备上看起来很好,那么您可能需要考虑屏幕尺寸,以决定如何容纳您的内容。

在这个示例中,我们将介绍一种简单的技术,使您的应用程序能够识别不同的设备类型。这种技术可以很容易地升级,以覆盖特定的屏幕尺寸。

我们稍后将更多地关注样式;目前,我们将专注于让应用程序识别设备类型和方向,然后在下一节中,我们将提供具体的样式示例。

如何做...

如果我们希望我们的应用程序适应,我们必须能够在我们的代码中回答几个问题:

  • 我们如何知道设备是平板还是手机?

  • 我们如何了解它是纵向模式还是横向模式?

  • 我们如何编写一个组件,根据设备类型的不同进行不同的渲染?

  • 我们如何使一个组件在屏幕方向改变时自动重绘?

现在让我们来讨论所有这些问题。让我们首先看看我们如何了解设备类型和方向。RN 包括一个 API,Dimensions,它提供了渲染应用程序所需的屏幕尺寸等数据。那么,我们如何了解设备类型和方向呢?第二个问题更容易:因为没有正方形设备(至少目前没有!),只需查看两个尺寸中哪个更大-如果高度更大,则设备处于纵向模式,否则设备处于横向模式。

然而,第一个问题更难。在屏幕尺寸方面,没有严格的规定来界定手机的结束和平板的开始,但是如果我们查看设备信息并计算形态因子(最长边与最短边的比率),一个简单的规则就出现了:如果计算出的比率为 1.6 或以下,则更可能是平板电脑,而更高的比率则表明是手机。

如果您需要更具体的数据,请查看iosres.com/获取有关 iOS 设备的信息,或查看material.io/tools/devicesscreensiz.es获取更多设备的信息,特别是用于 Android 的设备,其屏幕尺寸种类更多。

使用以下代码,我们基本上返回了Dimensions提供的所有信息,以及一些属性(.isTablet.isPortrait)以简化编码:

// Source file: src/adaptiveApp/device.js

/* @flow */

import { Dimensions } from "react-native";

export type deviceDataType = {
    isTablet: boolean,
    isPortrait: boolean,
    height: number,
    width: number,
    scale: number,
    fontScale: number
};

export const getDeviceData = (): deviceDataType => {
    const { height, width, scale, fontScale } = Dimensions.get("screen");

    return {
 isTablet: Math.max(height, width) / Math.min(height, width) <= 1.6,
 isPortrait: height > width,
        height,
        width,
        scale,
        fontScale
    };
};

使用上述代码,我们拥有了绘制适合所有设备、尺寸和两种可能方向的视图所需的一切,但我们如何使用这些数据呢?现在让我们来看看这一点,并使我们的应用程序在所有情况下都能适当调整。

有关Dimensions API 的更多信息,请阅读facebook.github.io/react-native/docs/dimensions

我们可以直接在组件中使用getDeviceData()提供的信息,但这会带来一些问题:

  • 因为它们在函数中有一个隐藏的依赖,所以组件将不像以前那样功能强大

  • 因此,测试组件将变得更加困难,因为我们必须模拟该函数

  • 最重要的是,当方向改变时,设置组件自动重新渲染将不会那么容易

解决这一切的方法很简单:让我们将设备数据放入存储中,然后相关组件(需要改变渲染方式的组件)可以连接到数据。我们可以创建一个简单的组件来实现这一点:

// Source file: src/adaptiveApp/deviceHandler.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import { View } from "react-native";

class DeviceHandler extends React.PureComponent<{
    setDevice: () => any
}> {
    static propTypes = {
        setDevice: PropTypes.func.isRequired
    };

    onLayoutHandler = () => this.props.setDevice();

    render() {
 return <View hidden onLayout={this.onLayoutHandler} />;
    }
}

export { DeviceHandler };

该组件不会显示在屏幕上,因此我们可以将其添加到我们的主视图中的任何位置。连接组件是另一个必要的步骤;当 onLayout 事件触发时(意味着设备的方向已经改变),我们将不得不调度一个动作:

// Source file: src/adaptiveApp/deviceHandler.connected.js

/* @flow */

import { connect } from "react-redux";

import { DeviceHandler } from "./deviceHandler.component";
import { setDevice } from "./actions";

const getDispatch = dispatch => ({
 setDevice: () => dispatch(setDevice())
});

export const ConnectedDeviceHandler = connect(
    null,
    getDispatch
)(DeviceHandler);

当然,我们需要定义动作和减速器,以及存储。让我们看看如何做到这一点——我们将从动作开始。除了我们假设的应用程序需要的其他动作之外,我们至少需要以下内容:

// Source file: src/adaptiveApp/actions.js

/* @flow */

import { getDeviceData } from "./device";

import type { deviceDataType } from "./device"

export const DEVICE_DATA = "device:data";

export type deviceDataAction = {
    type: string,
    deviceData: deviceDataType
};

export const setDevice = (deviceData?: object) =>
 ({
 type: DEVICE_DATA,
 deviceData: deviceData || getDeviceData()
 }: deviceDataAction); /* *A real app would have many more actions!*
*/

我们正在导出一个 thunk,其中将包含 deviceData。请注意,通过允许它作为参数提供(或者使用默认值,由 getDeviceData() 创建),我们将简化测试;如果我们想模拟横向平板电脑,我们只需提供一个适当的 deviceData 对象。

最后,减速器将如下所示(显然,对于真实的应用程序,将会有更多的动作!):

// Source file: src/adaptiveApp/reducer.js

/* @flow */

import { getDeviceData } from "./device";

import { DEVICE_DATA } from "./actions";

import type { deviceAction } from "./actions";

export const reducer = (
    state: object = {
        // initial state: more app data, plus:
 deviceData: getDeviceData()
    },
    action: deviceAction
) => {
    switch (action.type) {
 case DEVICE_DATA:
 return {
 ...state,
 deviceData: action.deviceData
 };

        /*
  *          In a real app, here there would*
 *be plenty more "case"s*
        */

        default:
            return state;
    }
};

现在,我们在存储中有了设备信息,我们可以研究如何编写自适应、响应式的组件。

我们可以通过使用一个非常基本的组件来看如何编写自适应和响应式组件,该组件只是显示它是手机还是平板电脑,以及它当前的方向。拥有所有 deviceData 对象的访问权限意味着我们可以做出任何决定:显示什么、显示多少元素、使它们的大小如何等等。我们将使这个示例简短,但应该清楚如何扩展它:

// Source file: src/adaptiveApp/adaptiveView.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import { View, Text, StyleSheet } from "react-native";

import type { deviceDataType } from "./device";

const textStyle = StyleSheet.create({
    bigText: {
        fontWeight: "bold",
        fontSize: 24
    }
});

export class AdaptiveView extends React.PureComponent<{
    deviceData: deviceDataType
}> {
 static propTypes = {
 deviceData: PropTypes.object.isRequired
 };

 renderHandset() {
        return (
            <View>
                <Text style={textStyle.bigText}>
                    I believe I am a HANDSET currently in
                    {this.props.deviceData.isPortrait
                        ? " PORTRAIT "
                        : " LANDSCAPE "}
                    orientation
                </Text>
            </View>
        );
    }

 renderTablet() {
        return (
            <View>
                <Text style={textStyle.bigText}>
                    I think I am a
                    {this.props.deviceData.isPortrait
                        ? " PORTRAIT "
                        : " LANDSCAPE "}
                    TABLET
                </Text>
            </View>
        );
    }

 render() {
 return this.props.deviceData.isTablet
 ? this.renderTablet()
 : this.renderHandset();
 }
}

不要担心 textStyle 的定义——很快我们将介绍它的工作原理,但现在我认为接受它定义了粗体、较大的文本应该很容易。

给定 this.props.deviceData,我们可以使用 .isTablet 属性来决定调用哪个方法(.renderTablet().renderHandset())。在这些方法中,我们可以使用 .isPortrait 来决定使用什么布局:竖屏或横屏。最后——虽然我们在示例中没有显示这一点——我们可以使用 .width.height 来显示更多或更少的组件,或计算组件的大小等等。我们只需要将组件连接到存储,如下所示,就可以了:

// Source file: src/adaptiveApp/adaptiveView.connected.js

/* @flow */

import { connect } from "react-redux";

import { AdaptiveView } from "./adaptiveView.component";

const getProps = state => ({
 deviceData: state.deviceData
});

export const ConnectedAdaptiveView = connect(getProps)(AdaptiveView);

现在我们已经拥有了一切需要的东西,让我们看看它是如何工作的!

工作原理...

我们已经准备了一个(隐藏的)组件,通过调度一个动作来响应方向的变化以更新存储,我们知道如何编写一个将使用设备信息的组件。我们的主页面可能如下所示:

// Source file: src/adaptiveApp/main.js

/* @flow */

import React from "react";
import { View, StatusBar } from "react-native";

import { ConnectedAdaptiveView } from "./adaptiveView.connected";
import { ConnectedDeviceHandler } from "./deviceHandler.connected";

export class Main extends React.PureComponent<> {
    render() {
        return (
            <View>
                <StatusBar hidden />
 <ConnectedDeviceHandler />
 <ConnectedAdaptiveView />
            </View>
        );
    }
}

如果我在(模拟的)Nexus 5 设备上以竖屏模式运行应用程序,我们会看到类似以下的内容:

我们的设备被识别为一个手机,目前是竖屏(垂直)方向

旋转设备会产生不同的视图:

当方向改变时,存储会更新,应用程序会适当地重新渲染自己

在我们的设计中,组件从不自己使用 Dimension API——因为它们从存储中获取设备信息,所以可以在功能上测试不同设备和方向下的组件行为,而无需模拟任何东西。

还有更多...

ESLint react/require-render-return rule to make .render() not to return anything:
import React from "react";
import PropTypes from "prop-types";

// eslint-disable-next-line react/require-render-return
class SomethingBase extends React.PureComponent<{
    deviceData: deviceDataType
}> {
    static propTypes = {
        deviceData: PropTypes.object.isRequired
    };

    render() {
 throw new Error("MUST IMPLEMENT ABSTRACT render() METHOD");
 }
}

export { SomethingBase };

为了继续,编写单独的 something.handset.jssomething.tablet.js 文件,这些文件扩展 SomethingBase 来定义 SomethingHandsetSomethingTablet 组件。最后,设置 something.component.js 文件,用于检查设备是手机还是平板,并返回 <SomethingHandset> 组件或 <SomethingTablet> 组件:

import { SomethingTablet } from "./something.tablet";
import { SomethingHandset } from "./something.handset";
import { getDeviceData } from "./device";

export const Something = getDeviceData().isTablet ? SomethingTablet : SomethingHandset;

使用这种样式,您可以在代码中使用和连接 <Something> 组件,而在内部,它们实际上是当前设备类型的适当版本。

在计算机科学术语中,这被称为工厂设计模式,您可以在不实际指定其类的情况下创建对象。

样式和布局组件

将 CSS 样式应用到您的应用程序并不困难,但是与 HTML 相比,您将不得不放弃并重新学习一些概念,这些概念在 RN 中与 HTML 中的概念完全不同:

  • 在网页中,CSS 样式是全局的,适用于所有标签;在 RN 中,样式是在组件之间局部完成的;没有全局样式。此外,您不需要选择器,因为样式直接与组件相关联。

  • 没有样式的继承:在 HTML 中,子元素默认继承其父元素的一些样式,但在 RN 中,如果您希望发生这种情况,您将不得不为子元素提供特定的所需样式。但是,如果您希望,您可以export样式并在其他地方import它们。

  • RN 样式完全是动态的:您可以使用所有 JS 函数来计算您希望应用的任何值。您甚至可以动态更改样式,因此应用程序的背景颜色可以在白天变得更浅,随着时间的推移逐渐变暗。您不需要像 SASS 或 LESS 那样的东西;您可以进行数学计算并使用常量,因为这是纯 JS。

还有一些其他细微的差异:

  • RN 使用驼峰命名风格(例如fontFamily)而不是 CSS 的kebab-case风格(例如font-family);这很容易适应。此外,并非所有通常的 CSS 属性都可能存在(这取决于特定组件),有些可能受到其可能值的限制。

  • RN 只有两种可能的测量单位:百分比或密度无关像素DP)。DP 不是来自 Web 的经典屏幕像素;相反,它们适用于每种设备,独立于其像素密度或每英寸像素ppi),从而确保所有屏幕具有统一的外观。

  • 布局使用 flex 完成,因此定位元素更简单。您可能没有网页可用的所有选项集,但您获得的对于任何类型的布局来说绝对足够。

关于 RN 中的样式有很多内容可供阅读(首先,请参阅facebook.github.io/react-native/docs/style进行介绍,以及facebook.github.io/react-native/docs/height-and-widthfacebook.github.io/react-native/docs/flexbox进行元素的大小和定位),因此,在这里,我们将通过为我们的国家和地区应用程序设置一些具体的示例来查看一些内容。

如何做...

让我们尝试稍微增强我们的应用程序。并且,为了完成我们之前看到的关于自适应和响应式显示的内容,我们将为纵向和横向方向提供不同的布局。我们不需要媒体查询或基于列的布局;我们将使用简单的样式。

让我们从为<Main>组件创建样式开始。我们将使用我们之前开发的<DeviceHandler>;两个组件都将连接到存储。我不想为平板电脑和手机制作特定版本,但我想在纵向和横向方向上显示不同的布局。对于前者,我基本上使用了我之前开发的内容,但对于后者,我决定将屏幕一分为二,在左侧显示国家选择器,右侧显示地区列表。哦,您可能会注意到我选择使用内联样式,即使这不是首选选项;由于组件通常很短,您可以在 JSX 代码中直接放置样式而不会失去清晰度。这取决于您是否喜欢:

// Source file: src/regionsStyledApp/main.component.js

/* @flow */

import React from "react";
import { View, StatusBar } from "react-native";

import {
    ConnectedCountrySelect,
    ConnectedRegionsTable,
    ConnectedDeviceHandler
} from ".";
import type { deviceDataType } from "./device";

 export class Main extends React.PureComponent<{
    deviceData: deviceDataType
}> {
    render() {
 **if (this.props.deviceData.isPortrait) {** .
            . *// portrait view*
            .
 **} else {**            .
            . *// landscape view*
            .
        }
    }
}

**当设备处于纵向方向时,我创建了一个占据整个屏幕的<View>flex:1),并使用flexDirection:"column"垂直设置其组件,尽管这实际上是默认值,所以我可以省略这一步。我没有为<CountrySelect>组件指定大小,但我设置了<RegionsTable>以占据所有可能的(剩余的)空间。详细代码如下:

// Source file: src/regionsStyledApp/main.component.js

            return (
 <View style={{ flex: 1 }}>
                    <StatusBar hidden />
                    <ConnectedDeviceHandler />
 <View style={{ flex: 1, flexDirection: "column" }}>
                        <View>
                            <ConnectedCountrySelect />
                        </View>
 <View style={{ flex: 1 }}>
                            <ConnectedRegionsTable />
                        </View>
                    </View>
                </View>
            );

对于横向方向,需要进行一些更改。我将主视图的内容方向设置为水平(flexDirection:"row"),并在其中添加了两个大小相同的视图。对于第一个国家列表,我将其内容设置为垂直并居中,因为我认为这样看起来更好,而不是出现在顶部。对于占据屏幕右侧的地区列表,我没有做任何特别的事情。

// Source file: src/regionsStyledApp/main.component.js

            return (
 <View style={{ flex: 1 }}>
                    <StatusBar hidden />
                    <ConnectedDeviceHandler />
 <View style={{ flex: 1, flexDirection: "row" }}>
                        <View
 style={{
 flex: 1,
 flexDirection: "column",
 justifyContent: "center"
 }}
                        >
                            <ConnectedCountrySelect />
                        </View>
 <View style={{ flex: 1 }}>
                            <ConnectedRegionsTable />
                        </View>
                    </View>
                </View>
            );

如果要使组件占据更大的空间,增加其 flex 值;flex意味着组件将根据可用空间灵活地扩展或收缩,这些空间按照它们的 flex 值的直接比例共享。如果我想要国家列表占据屏幕的三分之一,将其他两分之一留给地区列表,我会为其设置flex:1,并为地区列表设置flex:2。当然,您也可以直接设置高度和宽度(无论是 DIP 值还是百分比),就像您在 CSS 中所做的那样。

除了"center"之外,如果您想要在视图中分配子组件,还有其他几个选项:

  • "flex-start"将它们放在一起,放在父视图的开始位置;在这里,是顶部,因为是垂直对齐的

  • "flex-end"的行为类似,但将子组件放置在父视图的末尾(这里是底部)

  • "space-between"在子组件之间均匀分割额外的空间

  • "space-around"也均匀分割额外的空间,但包括父视图开头和结尾的空间

  • "space-evenly"在子组件和分隔空间之间均匀分割所有空间

设置主要的 flex 方向后,您可以使用alignItems来指定子组件沿着次要的 flex 方向对齐的方式(如果flexDirection"row",那么次要方向将是"column",反之亦然)。可能的值是"flex-start""center""flex-end",意思与刚才给出的类似,或者您可以使用"stretch",它将占据所有可能的空间。

如果您想尝试这些选项,请访问facebook.github.io/react-native/docs/flexbox并修改代码示例。您将立即看到您的更改的效果,这是理解每个选项的效果和影响的最简单方法。

现在,让我们来设置地区表的样式。为此,我需要进行一些更改,首先是需要使用<ScrollView>而不是普通的<View>,因为列表可能太长而无法适应屏幕。另外,为了展示一些样式和常量,我决定使用单独的样式文件。我首先创建了一个styleConstants.js文件,其中定义了一个颜色常量和一个简单的全尺寸样式:

// Source file: src/regionsStyledApp/styleConstants.js

/* @flow */

import { StyleSheet } from "react-native";

export const styles = StyleSheet.create({
    fullSize: {
        flex: 1
    }
});

export const lowColor = "lightgray";

这里有趣的地方,不是(假定相当简陋的)fullSize样式,而是您可以导出样式,或者定义将在其他地方使用的简单 JS 常量。在地区列表中,我导入了样式和颜色:

// Source file: src/regionsStyledApp/regionsTable.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";
import { View, ScrollView, Text, StyleSheet } from "react-native";

import type { deviceDataType } from "./device";

import { lowColor, fullSizeStyle } from "./styleConstants";

const ownStyle = StyleSheet.create({
 grayish: {
 backgroundColor: lowColor
 }
});

export class RegionsTable extends React.PureComponent<{
    deviceData: deviceDataType,
    list: Array<{
        regionCode: string,
        regionName: string
    }>
}> {
    static propTypes = {
        deviceData: PropTypes.object.isRequired,
        list: PropTypes.arrayOf(PropTypes.object).isRequired
    };

    static defaultProps = {
        list: []
    };

    render() {
        if (this.props.list.length === 0) {
            return (
 <View style={ownStyle.fullSize}>
                    <Text>No regions.</Text>
                </View>
            );
        } else {
            const ordered = [...this.props.list].sort(
                (a, b) => (a.regionName < b.regionName ? -1 : 1)
            );

            return (
                <ScrollView style={[fullSizeStyle, ownStyle.grayish]}>
                    {ordered.map(x => (
                        <View key={`${x.countryCode}-${x.regionCode}`}>
                            <Text>{x.regionName}</Text>
                        </View>
                    ))}
                </ScrollView>
            );
        }
    }
}

在上述代码块中有一些有趣的细节:

  • 正如我之前所说,我使用了<ScrollView>组件,以便用户可以浏览超出可用空间的列表。<FlatList>组件也是一种可能,尽管对于这里相对较短和简单的列表来说,它不会有太大的区别。

  • 我使用导入的颜色创建了一个本地样式grayish,稍后我会用到。

  • 我直接将导入的fullSize样式应用到了区域的<ScrollView>上。

  • 我给第二个<ScrollView>应用了多个样式;如果你提供一个样式数组,它们会按照出现的顺序应用。在这种情况下,我得到了一个全尺寸的灰色区域。请注意,只有在存在一些区域时颜色才会被应用;否则颜色不会改变。

请注意,样式可以动态创建,这可以产生有趣的效果。举个例子,基于 RN 文档中的一个例子,你可以根据 prop 改变标题的样式。在下面的代码中,标题的样式会根据this.props.isActive的值而改变:

<View>
    <Text
        style={[
            styles.title,
 this.props.isActive
 ? styles.activeTitle
 : styles.inactiveTitle
        ]}
    >
        {this.props.mainTitle}
    </Text>
</View>

你可以产生更有趣的结果;记住你可以充分利用 JS 的全部功能,并且样式表可以动态创建,所以你实际上有无限的可能性。

它是如何工作的...

我启动了模拟器,尝试了一下代码。在纵向方向时,视图如下截图所示;请注意我向下滚动了,应用程序正确处理了它:

我们的样式化应用程序,显示颜色、样式和可滚动视图

如果你改变设备的方向,我们的设备处理逻辑会捕获事件,并且应用程序会以不同的方式呈现。在这里,我们可以看到分屏,左边是居中的元素,右边是可滚动的视图,有灰色的背景:

横向视图得到了不同的布局,这要归功于新的样式规则

我们已经看到了——这只是 RN 提供的许多样式特性的简介,你可以获得与 HTML 和 CSS 相同类型的结果,尽管在这里你确实在使用不同的元素和样式。应用 JS 的全部功能来定义样式的可能性让你不再需要使用诸如 SASS 之类的工具,因为它所带来的所有额外功能已经通过 JS 本身可用。让我们看一个更进一步的样式示例,这次是针对文本的,因为我们考虑如何编写专门针对特定平台的代码。

添加特定于平台的代码

使用通用组件对大多数开发来说已经足够了,但你可能想利用一些特定于平台的功能,RN 提供了一种方法来实现这一点。显然,如果你开始沿着这个趋势发展,你可能会面临更大的工作量,并且更难维护你的代码,但如果明智地进行,它可以为你的应用增添一些额外的亮点

在这个示例中,我们将看看如何调整你的应用,使其更适合在任何平台上运行。

如何做...

识别你的平台最简单的方法是使用Platform模块,其中包括一个属性Platform.OS,告诉你当前是在 Android 还是 iOS 上运行。让我们来看一个简单的例子。假设你想在你的应用中使用一些等宽字体。恰好在不同平台上,相关字体系列的名称不同:在 Android 上是"monospace",而在苹果设备上是"AmericanTypewriter"(等等)。通过检查Platform.OS,我们可以适当地设置样式表的.fontFamily属性,如下面的截图所示:

使用Platform.OS是检测设备平台的最简单方法

如果你想要不同地选择几个属性,你可能想使用Platform.select()

const headings = Platform.select({
    android: { title: "An Android App", subtitle: "directly from Google" },
    ios: { title: "A iOS APP", subtitle: "directly from Apple" }
});

在这种情况下,headings.titleheadings.subtitle将获得适合当前平台的值,无论是 Android 还是 iOS。显然,你可以使用Platform.OS来管理这个,但这种样式可能更简洁。

有关 Android 和 iOS 设备上可用字体系列的更多信息,您可以查看github.com/react-native-training/react-native-fonts上的列表。但是,请注意,列表可能会随着版本的变化而改变。

它是如何工作的...

为了多样化,我决定在 Snack(在本章前面提到过的snack.expo.io/)中尝试平台检测,因为这比在两台实际设备上运行代码要快得多,也更简单。

我打开了页面,在提供的示例应用程序中,我只是添加了我之前展示的.fontFamily更改,并测试了两个平台的结果:

Snack 模拟器显示了我的应用程序的不同外观,Android(左)和 iOS(右)具有不同的字体

正如我们所看到的,平台差异的问题可以很容易地解决,您的应用程序的最终用户将获得更符合其对颜色、字体、组件、API 等方面期望的东西。

还有更多...

我们在这个示例中看到的变化范围相当小。如果您想要一些更大的差异,比如,例如,使用DatePickerIOS组件在 iOS 上获取日期,但在 Android 上使用DatePickerAndroid API,那么还有另一个功能您应该考虑。

假设您自己的组件名为AppropriateDatePicker。如果您分别创建名为appropriateDatePicker.component.ios.jsappropriateDatePicker.component.android.js的两个文件,那么当您使用import { AppropriateDatePicker } from "AppropriateDatePicker"导入您的组件时,.ios.js版本将用于苹果设备,.android.js版本将用于安卓设备:简单!

有关Platform模块和特定于平台的选项的完整描述,请阅读facebook.github.io/react-native/docs/platform-specific-code

路由和导航

使用React路由器,您只需使用<Link>组件从一个页面导航到另一个页面,或者使用方法以编程方式打开不同的页面。在 RN 中,有一种不同的工作方式,react-navigation包实际上是事实上的标准。在这里,您定义一个导航器(有几种可供选择),并为其提供应该处理的屏幕(视图),然后忘记它!导航器将自行处理一切,显示和隐藏屏幕,添加选项卡或滑动抽屉,或者其他任何需要的功能,您不必做任何额外的工作!

在这个示例中,我们将重新访问本书前面页面的一个示例,并展示路由的不同写法,以突出风格上的差异。

导航比我们在这里看到的更多。查看reactnavigation.org/docs/en/api-reference.html上的 API 文档以获取更多信息,如果您在 Google 上搜索,请注意,因为react-navigation包已经发展,许多网站引用了当前已弃用的旧方法。

如何做到...

在本书的React部分,我们构建了一个完整的路由解决方案,包括公共和受保护的路由,使用登录视图输入用户的用户名和密码。在移动应用程序中,由于用户受到更多限制,我们可以在开始时启用登录,并在之后启用正常导航。所有与用户名、密码和令牌相关的工作基本上与以前相同,所以现在让我们只关注在 RN 中不同的导航,并忘记常见的细节。

首先,让我们有一些视图——一个带有一些居中文本的空屏幕就可以了:

// Source file: src/routingApp/screens.js

/* @flow */

import React, { Component } from "react";
import {
    Button,
    Image,
    StyleSheet,
    Text,
    TouchableOpacity,
    View
} from "react-native";

const myStyles = StyleSheet.create({
    fullSize: {
        flex: 1
    },
    fullCenteredView: {
        flex: 1,
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center"
    },
    bigText: {
        fontSize: 24,
        fontWeight: "bold"
    },
    hamburger: {
        width: 22,
        height: 22,
        alignSelf: "flex-end"
    }
});

// *continues...*

然后,为了简化创建所有所需的视图,让我们有一个makeSimpleView()函数,它将生成一个组件。我们将在右上角包括一个汉堡图标,它将打开和关闭导航抽屉;稍后我们会详细了解。我们将使用这个函数来创建大多数视图,并添加一个SomeJumps额外视图,其中包含三个按钮,允许您直接导航到另一个视图:

// ...*continued*

const makeSimpleView = text =>
    class extends Component<{ navigation: object }> {
        displayName = `View:${text}`;

        render() {
            return (
                <View style={myStyles.fullSize}>
 <TouchableOpacity
 onPress={this.props.navigation.toggleDrawer}
 >
 <Image
 source={require("./hamburger.png")}
 style={myStyles.hamburger}
 />
 </TouchableOpacity>
                    <View style={myStyles.fullCenteredView}>
                        <Text style={myStyles.bigText}>{text}</Text>
                    </View>
                </View>
            );
        }
    };

export const Home = makeSimpleView("Home");
export const Alpha = makeSimpleView("Alpha");
export const Bravo = makeSimpleView("Bravo");
export const Charlie = makeSimpleView("Charlie");
export const Zulu = makeSimpleView("Zulu");
export const Help = makeSimpleView("Help!");

export const SomeJumps = (props: object) => (
    <View style={myStyles.fullSize}>
 <Button
 onPress={() => props.navigation.navigate("Alpha")}
 title="Go to Alpha"
 />
 <Button
 onPress={() => props.navigation.navigate("Bravo")}
 title="Leap to Bravo"
 />
 <Button
 onPress={() => props.navigation.navigate("Charlie")}
 title="Jump to Charlie"
 />
    </View>
);

在这里,为了简单起见,鉴于我们没有使用 props 或 state,并且视图足够简单,我使用了SomeJumps组件的函数定义,而不是使用类,就像大多数其他示例一样。如果您想重新访问这个概念,请查看reactjs.org/docs/components-and-props.html

navigation属性来自哪里?我们将在下一节中看到更多,但这里可以给出一些解释。每当您创建一个导航器,您都会为其提供一组视图来处理。所有这些视图都将获得一个额外的属性navigation,它具有一组您可以使用的方法,例如切换抽屉的可见性,导航到给定屏幕等。在reactnavigation.org/docs/en/navigation-prop.html上阅读有关此对象的信息。

现在,让我们创建抽屉本身。这将处理侧边栏菜单并显示所需的任何视图。createDrawerNavigator()函数获取一个包含将要处理的屏幕的对象,以及一组选项;在这里,我们只指定了抽屉本身的颜色和宽度(还有很多可能性,详细信息请参阅reactnavigation.org/docs/en/drawer-navigator.html):

// Source file: src/routingApp/drawer.js

/* @flow */

import { createDrawerNavigator } from "react-navigation";

import {
    Home,
    Alpha,
    Bravo,
    Charlie,
    Zulu,
    Help,
    SomeJumps
} from "./screens";

export const MyDrawer = createDrawerNavigator(
    {
        Home: { screen: Home },
        Alpha: { screen: Alpha },
        Bravo: { screen: Bravo },
        Charlie: { screen: Charlie },
        Zulu: { screen: Zulu },
        ["Get Help"]: { screen: Help },
        ["Some jumps"]: { screen: SomeJumps }
    },
    {
 drawerBackgroundColor: "lightcyan",
 drawerWidth: 140
    }
);

createDrawerNavigation()的结果本身是一个组件,它将负责显示所选的任何视图,显示和隐藏抽屉菜单等。我们只需要创建主应用程序本身。

接下来,让我们创建可导航的应用程序,因为我们现在有一组视图和一个抽屉导航器来处理它们。我们应用程序的主视图非常简单-查看它的.render()方法,你会同意的:

// Source file: App.routing.js

/* @flow */

import React from "react";
import { StatusBar } from "react-native";

import { MyDrawer } from "./src/routingApp/drawer";

class App extends React.Component {
    render() {
        return (
            <React.Fragment>
 <StatusBar hidden />
 <MyDrawer />
            </React.Fragment>
        );
    }
}

export default App;

有趣的一点是:由于导航器是组件。如果您愿意,您可以在另一个导航器中包含一个导航器!例如,您可以创建一个TabNavigator,并将其包含在抽屉导航器中:当选择相应选项时,您将在屏幕上获得一个选项卡视图,现在由选项卡导航器管理。如果您愿意,您可以以任何希望的方式组合导航器,从而允许非常复杂的导航结构。

它是如何工作的...

当您打开应用程序时,将显示初始路由。您可以提供多个选项,例如initialRouteName来指定应该显示的第一个视图,order来重新排列抽屉项,甚至自定义contentComponent如果您想自己绘制抽屉的内容;总而言之,有很多灵活性。您的第一个屏幕应该看起来像下面的样子:

我们的抽屉导航器显示初始屏幕

通常打开抽屉的方式是从左边滑动(尽管也可以设置抽屉从右边滑动)。我们还提供了汉堡图标来切换抽屉的打开和关闭。打开抽屉应该看起来像下面的截图:

打开的抽屉显示菜单,当前屏幕突出显示,其余屏幕变暗。

单击任何菜单项将隐藏当前视图,并显示所选视图。例如,我们可以选择Some jumps屏幕,如下所示:

选择选项后,抽屉菜单会自动关闭,并显示所选屏幕

在这个特定的屏幕中,我们展示了三个按钮,它们都使用props.navigation.navigate()方法来显示不同的屏幕。这表明你的导航不仅限于使用抽屉,而且你也可以以任何你想要的方式直接浏览。

还有更多……

React章节中我们没有提到Redux,你可能已经注意到了。虽然使用它是可能的,但react-navigation的作者们倾向于启用它,在reactnavigation.org/docs/en/redux-integration.html上你可以读到以下内容:

“警告:在 2018 年秋季发布的 React Navigation 的下一个主要版本中,我们将不再提供任何关于如何与 Redux 集成的信息,它可能会停止工作。在 React Navigation 问题跟踪器上发布的与 Redux 相关的问题将立即关闭。Redux 集成可能会继续工作,但在制定库的任何设计决策时,它将不会被测试或考虑。”

这个警告表明,把空间用于一个可能会突然停止工作的集成并不是一个好主意。如果你想集成Redux,请阅读我之前提到的页面,但在更新导航包时要小心,以防止某些功能停止工作。你已经被警告了!**

第十二章:测试和调试您的移动应用程序

在本章中,我们将研究以下配方:

  • 使用 Jest 编写单元测试

  • 添加快照测试

  • 测量测试覆盖率

  • 使用 Storybook 预览组件

  • 使用 react-native-debugger 调试您的应用程序

  • 使用 Reactotron 进行替代方式的调试

介绍

在上一章中,我们看到了如何开发React Native(RN)移动应用程序,以及我们如何与NodeReact一样,让我们通过查看测试和调试我们的应用程序来完成移动应用程序的开发过程。

使用 Jest 编写单元测试

进行 RN 的单元测试不会太让人惊讶,因为我们将能够重用之前学到的大部分知识(例如,使用Jest也与快照一起使用,或者如何测试Redux),除了一些必须注意的小细节,我们将会看到。

在这个配方中,我们将看看如何为 RN 设置单元测试,沿用我们已经为NodeReact做的工作。

准备工作

无论您是使用 CRAN(就像我们一样)还是使用react-native init创建移动应用程序,对Jest的支持都是内置的;否则,您将不得不自己安装它,就像我们在第五章的单元测试您的代码部分中看到的那样,测试和调试您的服务器。根据您创建项目的方式,在package.json中的Jest配置会有所不同;我们不必做任何事情,但是请参阅jestjs.io/docs/en/tutorial-react-native.html#setup以获取替代方案。我们将不得不添加一些我们之前使用过的包,但仅此而已:

npm install enzyme enzyme-adapter-react-16 react-test-renderer redux-mock-store --save

完成后,我们可以像以前一样编写测试。让我们看一个例子。

如何做...

在本书的早些时候,我们为国家和地区应用程序编写了一些测试,因为我们已经在 RN 中重写了它,为什么不也重写测试呢?这将使我们能够验证为 RN 编写单元测试与为普通的React编写单元测试并没有太大的不同。我们已经为<RegionsTable>组件编写了测试;让我们在这里检查一下:

// Source file: src/regionsStyledApp/regionsTable.test.js

/* @flow */

import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";

import { RegionsTable } from "./regionsTable.component";

Enzyme.configure({ adapter: new Adapter() });

const fakeDeviceData = {
 isTablet: false,
 isPortrait: true,
 height: 1000,
 width: 720,
 scale: 1,
 fontScale: 1
};

describe("RegionsTable", () => {
    it("renders correctly an empty list", () => {
 const wrapper = Enzyme.shallow(
 <RegionsTable deviceData={fakeDeviceData} list={[]} />
 );
 expect(wrapper.contains("No regions."));
    });

    it("renders correctly a list", () => {
 const wrapper = Enzyme.shallow(
            <RegionsTable
 deviceData={fakeDeviceData}
                list={[
                    {
                        countryCode: "UY",
                        regionCode: "10",
                        regionName: "Montevideo"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "9",
                        regionName: "Maldonado"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "5",
                        regionName: "Cerro Largo"
                    }
                ]}
            />
        );

 expect(wrapper.contains("Montevideo"));
 expect(wrapper.contains("Maldonado"));
 expect(wrapper.contains("Cerro Largo"));
    });
});

差异真的很小,大部分都是相同的代码:

  • 我们不得不添加fakeDeviceData,但那只是因为我们的 RN 组件需要它

  • 我们将Enzyme.render()更改为Enzyme.shallow()

  • 我们改变了使用wrapper对象来直接检查包含的文本的方式,使用wrapper.contains()

有关所有可用包装器方法的完整(而且很长!)列表,请查看github.com/airbnb/enzyme/blob/master/docs/api/shallow.md

我们还可以看一下<CountrySelect>的测试,其中涉及模拟事件。我们可以跳过与React版本几乎相同的测试;让我们专注于我们原始测试中的最后一个:

// Source file: src/regionsStyledApp/countrySelect.test.js

/* @flow */
import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";

import { CountrySelect } from "./countrySelect.component";

Enzyme.configure({ adapter: new Adapter() });

const threeCountries = [
    {
        countryCode: "UY",
        countryName: "Uruguay"
    },
    {
        countryCode: "AR",
        countryName: "Argentina"
    },
    {
        countryCode: "BR",
        countryName: "Brazil"
    }
];

const fakeDeviceData = {
    isTablet: false,
    isPortrait: true,
    height: 1000,
    width: 720,
    scale: 1,
    fontScale: 1
}

describe("CountrySelect", () => {
    // 
    // *some tests omitted*
    //

    it("correctly calls onSelect", () => {
        const mockGetCountries = jest.fn();
        const mockOnSelect = jest.fn();

        const wrapper = Enzyme.shallow(
            <CountrySelect
                deviceData={fakeDeviceData}
                loading={false}
                currentCountry={""}
                onSelect={mockOnSelect}
                getCountries={mockGetCountries}
                list={threeCountries}
            />
        );

 wrapper.find("Picker").simulate("ValueChange", "UY");

 expect(mockGetCountries).not.toHaveBeenCalled();
 expect(mockOnSelect).toHaveBeenCalledTimes(1);
 expect(mockOnSelect).toHaveBeenCalledWith("UY");
    });
});

我们为React和 RN 编写测试的关键区别在于我们.find()要点击的元素的方式(RN 使用Picker组件,而不是一组option元素),以及我们模拟的事件("ValueChange"而不是"change")。除此之外,代码与之前的代码相同。

对于原生模块,您可能需要使用模拟来模拟预期的行为。我们的代码中没有使用这样的模块,但是如果您需要其中任何一个,可以使用我们在第五章的测试和调试您的服务器中看到的相同的模拟样式,以及对React本身的模拟样式在第十章的测试您的应用程序中。

在 RN 组件测试中已经讨论了一些差异,因为在测试操作或减速器时代码没有差异。这些使用相同的功能单元测试风格,不涉及任何特定的 RN 功能,所以我们没有更多可说的了。在下一节中,我们将查看我们的测试运行。

它是如何工作的...

运行测试与以前一样,只需一个命令:

npm test

输出如下截图所示——请注意,我们还运行了一些从React章节复制过来的测试,没有任何更改,它们也表现得很完美:

我们所有组件的测试都通过了

因此,除了需要使用浅渲染,并可能需要以不同的方式访问元素或模拟事件,为 RN 编写单元测试基本上与为React编写单元测试相同,这是个好消息。然而,我们忘了一件事——快照测试怎么样?让我们继续。

添加快照测试

使用 RN 进行快照测试是一个惊喜,因为你不需要改变之前的工作方式。让我们看几个例子,你就会相信。

如何做...

我们已经在第十章的使用快照测试更改部分中看到了快照测试。恰好,相同的代码在 RN 应用中也能完美运行,而不需要任何特定的更改,除了那些取决于代码变化的部分。让我们考虑以下示例。我们之前开发的<RegionsTable>组件在 RN 中有一个额外的 prop:deviceData。因此,我们可以复制原始快照测试代码,然后只需添加新的 prop,如下所示:

// Source file: src/regionsStyledApp/regionsTable.snapshot.test.js

/* @flow */

import React from "react";
import TestRenderer from "react-test-renderer";

import { RegionsTable } from "./regionsTable.component";

const fakeDeviceData = {
 isTablet: false,
 isPortrait: true,
 height: 1000,
 width: 720,
 scale: 1,
 fontScale: 1
};

describe("RegionsTable", () => {
    it("renders correctly an empty list", () => {
        const tree = TestRenderer.create(
            <RegionsTable deviceData={fakeDeviceData} list={[]} />
        ).toJSON();
        expect(tree).toMatchSnapshot();
    });

    it("renders correctly a list", () => {
        const tree = TestRenderer.create(
            <RegionsTable
                deviceData={fakeDeviceData}
                list={[
                    {
                        countryCode: "UY",
                        regionCode: "10",
                        regionName: "Montevideo"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "9",
                        regionName: "Maldonado"
                    },
                    {
                        countryCode: "UY",
                        regionCode: "5",
                        regionName: "Cerro Largo"
                    }
                ]}
            />
        ).toJSON();
        expect(tree).toMatchSnapshot();
    });
});

如果你愿意比较版本,你会发现唯一改变的部分是我用粗体标出的部分,它们与不同的组件有关,而不是与任何 RN 特定的东西有关。如果你为<CountrySelect>组件编写快照测试,你会发现完全相同的结果:唯一必要的更改与其新的 props(deviceDatacurrentCountry)有关,但没有其他困难。

为了多样化,让我们为我们的<Main>组件添加快照测试。这里有两个有趣的细节:

  • 由于我们的组件在纵向或横向模式下呈现不同,我们应该有两个测试;和

  • 由于该组件包含连接的组件,我们不要忘记添加<Provider>组件,否则连接将无法建立。

代码如下;特别要注意不同的设备数据和<Provider>的包含:

// Source file: src/regionsStyledApp/main.snapshot.test.js

/* @flow */

import React from "react";
import { Provider } from "react-redux";
import TestRenderer from "react-test-renderer";

import { Main } from "./main.component";
import { store } from "./store";

const fakeDeviceData = {
    isTablet: false,
    isPortrait: true,
    height: 1000,
    width: 720,
    scale: 1,
    fontScale: 1
};

describe("Main component", () => {
    it("renders in portrait mode", () => {
        const tree = TestRenderer.create(
 <Provider store={store}>
                <Main
                    deviceData={{ ...fakeDeviceData, isPortrait: true }}
                />
 </Provider>
        ).toJSON();
        expect(tree).toMatchSnapshot();
    });

    it("renders in landscape mode", () => {
        const tree = TestRenderer.create(
 <Provider store={store}>
                <Main
                    deviceData={{ ...fakeDeviceData, isPortrait: false }}
                />
 </Provider>
        ).toJSON();
        expect(tree).toMatchSnapshot();
    });
});

它是如何工作的...

由于我们所有快照测试的文件名都以.snapshot.js结尾,我们可以用一个命令运行所有快照测试:

npm test snapshot

第一次运行测试时,与以前一样,将创建快照:

与 React 一样,第一次运行将为组件创建快照

如果我们检查__snapshots__目录,我们会发现其中有三个生成的.snap文件。它们的格式与我们之前开发的React示例相同。让我们看一下之前展示的<RegionsTable>的一个:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`RegionsTable renders correctly a list 1`] = `
<RCTScrollView
  style={
    Array [
      undefined,
      Object {
        "backgroundColor": "lightgray",
      },
    ]
  }
>
  <View>
    <View>
      <Text
        accessible={true}
        allowFontScaling={true}
        ellipsizeMode="tail"
      >
        Cerro Largo
      </Text>
    </View>
    <View>
      <Text
        accessible={true}
        allowFontScaling={true}
        ellipsizeMode="tail"
      >
        Maldonado
      </Text>
    </View>
    <View>
      <Text
        accessible={true}
        allowFontScaling={true}
        ellipsizeMode="tail"
      >
        Montevideo
      </Text>
    </View>
  </View>
</RCTScrollView>
`;

exports[`RegionsTable renders correctly an empty list 1`] = `
<View
  style={undefined}
>
  <Text
    accessible={true}
    allowFontScaling={true}
    ellipsizeMode="tail"
  >
    No regions.
  </Text>
</View>
`;

如果将来再次运行测试,而且没有任何更改,那么结果将是三个 PASS 绿色消息:

我们的快照测试都成功了

一切都很顺利,所以我们可以断言编写快照测试不会给 RN 测试增加任何复杂性,并且可以毫无困难地进行。

测量测试覆盖率

就像我们在第五章的测试和调试您的服务器和第十章的测试您的应用程序中为NodeReact做的那样,我们希望对我们的测试覆盖率进行测量,以了解我们的工作有多彻底,并能够检测到需要更多工作的代码片段。幸运的是,我们将能够使用之前使用的相同工具来管理,因此这个步骤将很容易实现。

如何做...

CRAN 提供的应用程序设置包括我们之前看到的Jest,而Jest为我们提供了所需的覆盖选项。首先,我们需要添加一个简单的脚本,以便用一些额外的参数运行我们的测试套件:

"scripts": {
    .
    .
    .
    "test": "jest",
 "coverage": "jest --coverage --no-cache",
},

就这些了,我们没有其他事情要做;让我们看看它是如何工作的!

它是如何工作的...

运行测试很简单;我们只需要使用新的脚本:

npm run coverage

整个套件将以与本章前几节相同的方式运行,但最后将生成一个文本摘要。与之前一样,颜色将被使用:绿色表示覆盖良好(在测试方面),黄色表示中等覆盖率,红色表示覆盖率低或没有覆盖:

使用启用覆盖选项的 Jest 生成了与我们在 Node 和 React 中看到的相同类型的结果

我们还可以检查生成的 HTML 文件,这些文件可以在/coverage/lcov-report中找到。在那里打开index.html文件,你将得到一个交互式版本的报告,就像下面的截图一样:

生成的 HTML 报告是交互式的,可以让你看到你在测试中错过了什么

例如,如果你想知道为什么deviceHandler.component.js文件得分如此之低(不要紧,你没有为它编写测试;所有的代码都应该被覆盖,如果可能的话),你可以点击它并查看原因。在我们的情况下,onLayoutHandler代码(逻辑上)从未被调用,因此降低了该文件的覆盖率:

点击文件将显示哪些行被执行,哪些行(红色背景)被忽略

要查看如何禁用未覆盖的报告行,或者对于你不想考虑的情况,可以查看github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md

使用 Storybook 预览组件

Storybook,我们在第六章的Simplifying component development with Storybook部分中介绍的React工具,也可以用来帮助开发组件,因此在这个教程中,让我们看看如何使用它来简化我们的工作。

准备工作

安装Storybook很简单,与之前的操作类似;react-native-storybook-loader包将允许我们将*.story.js文件放在任何我们想要的地方,并且无论如何都能找到它们。第二个命令将需要一些时间,安装许多包;请注意!此外,将在你的目录根目录下创建一个storybook目录。使用以下命令安装Storybook

npm install @storybook/cli react-native-storybook-loader --save-dev
npx storybook init

storybook/Stories目录可以安全地删除,因为我们将把我们的故事和被演示的组件放在其他地方,就像我们在本书的前面部分所做的那样。

在使用 CRNA 创建的 RN 应用程序中运行Storybook需要额外的步骤:提供一个适当的App.js文件。实现这一点的最简单方法是使用一行文件:

export default from './storybook';

然而,这是一个问题——你将如何运行你的应用程序?当然,你可以有两个不同的App.storybook.jsApp.standard.js文件,并将其中一个复制到App.js,但如果手动完成,这很快就会变得无聊。当然,你可以使用一些npm脚本。以下命令适用于 Linux 或 macOS 设备,使用cp命令来复制文件,但对于 Windows 设备需要进行小的更改:

"scripts": {
 "start": "cp App.standard.js App.js && react-native-scripts start",
    .
    .
    .
 "storybook": "cp App.storybook.js App.js && rnstl && storybook start -p 7007"
},

我们还需要在package.json中添加一些加载器的配置。以下内容使加载器在./src目录中查找*.story.js文件,并生成一个带有找到的故事的storyLoader.js文件:

"config": {
    "react-native-storybook-loader": {
        "searchDir": [
            "./src"
        ],
        "pattern": "**/*.story.js",
        "outputFile": "./storybook/storyLoader.js"
    }
},

最后,我们将不得不修改storybook/index.js,如下所示:

import { getStorybookUI, configure } from "@storybook/react-native";

import { loadStories } from "./storyLoader";

configure(loadStories, module);
const StorybookUI = getStorybookUI({ port: 7007, onDeviceUI: true });

export default StorybookUI;

我们现在已经设置好了,让我们写一些故事!

查看github.com/storybooks/storybook/tree/master/app/react-native了解 RN 的Storybook的更多文档,以及github.com/elderfo/react-native-storybook-loader了解我们正在使用的加载程序的详细信息。

如何做...

让我们写一些故事。我们可以从<RegionsTable>组件开始,这很简单:它不包括任何操作,只显示数据。我们可以写两种情况:当提供空的地区列表时,以及当提供非空列表时。我们不必过多考虑所需的假数据,因为我们可以重用我们为单元测试编写的内容!考虑以下代码:

// Source file: src/regionsStyledApp/regionsTable.story.js

/* @flow */

import React from "react";
import { storiesOf } from "@storybook/react-native";

import { Centered } from "../../storybook/centered";
import { RegionsTable } from "./regionsTable.component";

const fakeDeviceData = {
    isTablet: false,
    isPortrait: true,
    height: 1000,
    width: 720,
    scale: 1,
    fontScale: 1
};

storiesOf("RegionsTable", module)
    .addDecorator(getStory => <Centered>{getStory()}</Centered>)
    .add("with no regions", () => (
        <RegionsTable deviceData={fakeDeviceData} list={[]} />
    ))
    .add("with some regions", () => (
        <RegionsTable
            deviceData={fakeDeviceData}
            list={[
                {
                    countryCode: "UY",
                    regionCode: "10",
                    regionName: "Montevideo"
                },
                {
                    countryCode: "UY",
                    regionCode: "9",
                    regionName: "Maldonado"
                },
                {
                    countryCode: "UY",
                    regionCode: "5",
                    regionName: "Cerro Largo"
                }
            ]}
        />
    ));

添加一个修饰器来使显示的组件居中只是为了清晰起见:必要的<Centered>代码很简单,并且需要一点我们在上一章中看到的样式:

// Source file: storybook/centered.js

/* @flow */

import React from "react";
import { View, StyleSheet } from "react-native";
import PropTypes from "prop-types";

const centerColor = "white";
const styles = StyleSheet.create({
 centered: {
 flex: 1,
 backgroundColor: centerColor,
 alignItems: "center",
 justifyContent: "center"
 }
});

export class Centered extends React.Component<{ children: node }> {
    static propTypes = {
        children: PropTypes.node.isRequired
    };

    render() {
        return <View style={styles.centered}>{this.props.children}</View>;
    }
}

现在,为<CountrySelect>设置故事更有趣,因为我们有操作。我们将为组件提供两个操作:当用户点击它以选择一个国家时,以及用于获取国家列表的getCountries()回调的另一个操作:

// Source file: src/regionsStyledApp/countrySelect.story.js

/* @flow */

import React from "react";
import { storiesOf } from "@storybook/react-native";
import { action } from "@storybook/addon-actions";

import { Centered } from "../../storybook/centered";
import { CountrySelect } from "./countrySelect.component";

const fakeDeviceData = {
    isTablet: false,
    isPortrait: true,
    height: 1000,
    width: 720,
    scale: 1,
    fontScale: 1
};

storiesOf("CountrySelect", module)
    .addDecorator(getStory => <Centered>{getStory()}</Centered>)
    .add("with no countries yet", () => (
        <CountrySelect
            deviceData={fakeDeviceData}
            loading={true}
            currentCountry={""}
            onSelect={action("click:country")}
            getCountries={action("call:getCountries")}
            list={[]}
        />
    ))
    .add("with three countries", () => (
        <CountrySelect
            deviceData={fakeDeviceData}
            currentCountry={""}
            loading={false}
            onSelect={action("click:country")}
            getCountries={action("call:getCountries")}
            list={[
                {
                    countryCode: "UY",
                    countryName: "Uruguay"
                },
                {
                    countryCode: "AR",
                    countryName: "Argentina"
                },
                {
                    countryCode: "BR",
                    countryName: "Brazil"
                }
            ]}
        />
    ));

我们现在已经准备好了;让我们看看这是如何工作的。

它是如何工作的...

要查看Storybook应用程序,我们需要使用我们在前一节中编辑的脚本。首先运行storybook脚本(最好在单独的控制台中执行此操作),然后运行应用程序本身,如下所示:

// *at one terminal*
npm run storybook

// *and at another terminal*
npm start

第一个命令产生了一些输出,让我们确认我们的脚本是否有效,并且找到了所有的故事。以下代码略作编辑以便更清晰:

> npm run storybook

> chapter12b@0.1.0 storybook /home/fkereki/JS_BOOK/modernjs/chapter12
> cp App.storybook.js App.js && rnstl && storybook start -p 7007

Generating Dynamic Storybook File List

Output file: /home/fkereki/JS_BOOK/modernjs/chapter12/storybook/storyLoader.js
Patterns: ["/home/fkereki/JS_BOOK/modernjs/chapter12/src/**/*.story.js"]
Located 2 files matching pattern '/home/fkereki/JS_BOOK/modernjs/chapter12/src/**/*.story.js' 
Compiled story loader for 2 files:
 /home/fkereki/JS_BOOK/modernjs/chapter12/src/regionsStyledApp/countrySelect.story.js
 /home/fkereki/JS_BOOK/modernjs/chapter12/src/regionsStyledApp/regionsTable.story.js
=> Loading custom .babelrc from project directory.
=> Loading custom addons config.
=> Using default webpack setup based on "Create React App".
Scanning 1424 folders for symlinks in /home/fkereki/JS_BOOK/modernjs/chapter12/node_modules (18ms)

RN Storybook started on => http://localhost:7007/

Scanning folders for symlinks in /home/fkereki/JS_BOOK/modernjs/chapter12/node_modules (27ms)

+----------------------------------------------------------------------+
|                                                                      |
| Running Metro Bundler on port 8081\.                                  |
|                                                                      |
| Keep Metro running while developing on any JS projects. Feel free to |
| close this tab and run your own Metro instance if you prefer.        |
|                                                                      |
| https://github.com/facebook/react-native                             |
|                                                                      |
+----------------------------------------------------------------------+

Looking for JS files in
 /home/fkereki/JS_BOOK/modernjs/chapter12/storybook
 /home/fkereki/JS_BOOK/modernjs/chapter12
 /home/fkereki/JS_BOOK/modernjs/chapter12 

Metro Bundler ready.

webpack built bab22529b80fbd1ce576 in 2918ms
Loading dependency graph, done.

我们可以打开浏览器,得到一个与我们为 Web 应用程序和React获得的视图非常相似的视图:

您可以在侧边栏中选择故事,应用程序将显示它们

如果您在菜单中选择一个故事,应用程序将显示它,如下所示:

应用程序会在浏览器中显示您选择的故事

您还可以通过按压前面截图左上角的汉堡菜单来选择在应用程序本身中显示哪个故事。结果选择菜单显示如下:

该应用程序还允许您选择要显示的故事

最后,您可以在浏览器中看到操作。让我们想象一下,您打开了包含三个国家的国家列表的故事:

国家选择器让您与操作进行交互

如果您点击巴西,浏览器将显示已触发的操作。首先,我们可以看到当getCountries()回调被调用时,会出现 call:getCountries,然后当您点击一个选项时会出现 click:country。

与 Web 应用程序一样,您可以与故事互动,并查看调用了哪些操作以及使用了哪些参数

因此,我们已经看到,添加故事实际上与 Web 应用程序相同,并且您还可以获得额外的工具来帮助开发-您应该考虑这一点。

使用 react-native-debugger 调试您的应用程序

调试 RN 应用程序比处理 Web 应用程序更难,因为您想要做的一切都是远程完成的;您不能在移动设备上运行功能齐全的调试器。有几种工具可以帮助您解决这个问题,在本节中,我们将考虑一个“万能”工具react-native-debugger,它包括一个强大的三合一实用程序,其中大多数(如果不是全部)您的需求应该得到满足。

您需要进行彻底调试的基本工具(我们之前已经遇到过)如下:

当然,您可以单独安装它们,并与三者一起使用,但将它们全部放在一起无疑更简单,因此我们将遵循这个方法。所以,让我们开始调试我们的代码吧!

您可以在facebook.github.io/react-native/docs/debugging了解 RN 调试的基础知识,并在github.com/jhen0409/react-native-debugger学习react-native-debugger

入门

我们需要安装几个软件包才能让一切正常工作。首先,只需从github.com/jhen0409/react-native-debugger/releases的发布页面获取react-native-debugger可执行文件。安装只需解压下载的文件;执行只需在解压后的目录中运行可执行文件。

我们需要安装一些软件包,以便将我们的应用程序连接到react-native-debugger,可以通过模拟器或实际设备上运行以下命令来获取这些软件包。让我们使用以下命令安装这些软件包:

npm install react-devtools remote-redux-devtools --save-dev

我们现在已经准备好了一切。让我们看一下如何将工具(主要是 Redux 调试器)集成到我们的应用程序中的一些细节,然后我们就可以开始调试了。

如何做...

让我们看看如何设置我们的应用程序,以便我们可以使用我们的调试工具。首先,我们需要在存储创建代码中进行简单更改,添加几行,如下所示:

// Source file: src/regionsStyledApp/store.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { composeWithDevTools } from "redux-devtools-extension";

import { reducer } from "./world.reducer";

export const store = createStore(
    reducer,
 composeWithDevTools(applyMiddleware(thunk))
);

仅仅是为了让我们能够实际获得一些调试消息,我在整个代码中添加了各种console.log()console.error()调用。为了保持一致,我想使用debug(来自www.npmjs.com/package/debug),就像我们在本书中之前所做的那样,但它不起作用,因为它需要LocalStorage,而在 RN 中,您将使用不同的 APIAsyncStorage。只是举个例子,我们将查看world.actions.js的一些日志输出。我没有打扰记录成功的 API 调用的输出,因为我们将通过react-native-debugger获得,我们将看到:

// Source file: src/regionsStyledApp/world.actions.js

.
.
.

export const getCountries = () => async dispatch => {
 console.log("getCountries: called");
    try {
        dispatch(countriesRequest());
        const result = await getCountriesAPI();
        dispatch(countriesSuccess(result.data));
    } catch (e) {
 console.error("getCountries: failure!");
        dispatch(countriesFailure());
    }
};

export const getRegions = (country: string) => async dispatch => {
 console.log("getRegions: called with ", country);
    if (country) {
        try {
            dispatch(regionsRequest(country));
            const result = await getRegionsAPI(country);
            dispatch(regionsSuccess(result.data));
        } catch (e) {
 console.error("getRegions: failure with API!");
            dispatch(regionsFailure());
        }
    } else {
 console.error("getRegions: failure, no country!");
        dispatch(regionsFailure());
    }
};

我们已经准备就绪,让我们试一试。

它是如何工作的...

首先,使用以下命令运行您的应用程序:

npm start

在您的设备上(无论是真实设备还是模拟设备),通过摇动(在实际设备上)或在 macOS 上使用 command + m 或在 Windows 或 Linux 上使用 Ctrl + M 来访问开发者菜单。至少,您希望启用远程 JS 调试:

使用设备的开发者菜单启用远程 JS 调试

现在,通过点击下载的可执行文件打开react-native-debugger应用程序。如果没有任何反应,即使重新加载应用程序后仍然没有反应,那么问题肯定是由于设置了不同的端口:在菜单中,选择 Debugger,然后 New Window,选择端口 19001,一切都应该正常。当您启动应用程序时,它应该看起来像以下截图。请注意屏幕右侧的所有日志,左上角的第一个Redux操作,左下角的 React 工具(如果您不喜欢其中的某些工具,右键单击屏幕以隐藏其中的任何一个):

成功连接后,您将看到 react-native-debugger 中的三个工具同时运行

如果您检查网络选项卡,您会发现应用程序的 API 调用默认情况下不会显示。有一个简单的解决方法:右键单击react-native-debugger,选择启用网络检查,然后右键单击 Chrome 开发人员工具并选择 Log XMLHttpRequests,所有调用都将显示:

API 调用默认情况下不会显示,但可以通过右键单击 react-native-debugger 屏幕启用

您还可以检查AsyncStorage-请参阅以下屏幕截图。我选择隐藏ReactRedux DevTools,就像我之前提到的那样,只是为了清晰。由于我们的应用实际上并没有使用AsyncStorage,我稍微捏造了一下:请注意,您可以对任何模块使用require()函数,然后直接使用它:

使用 RN 调试器检查 AsyncStorage

还能说什么呢?实际上并不多,因为这些工具基本上与我们在 Web 上使用React时看到的工具相同。这里有趣的细节是,您可以一次获得所有这些工具,而不必处理许多单独的窗口。让我们通过考虑一个可能更喜欢的备用工具来结束这一章节。

使用 Reactotron 以另一种方式进行调试

虽然react-native-debugger可能适用于您大部分的需求,但还有另一个软件包,虽然与许多功能相符,但也添加了一些新功能,或者至少对旧功能进行了调整:Reactotron。这个工具也可以与纯React一起使用,但我选择在这里与 RN 一起显示它,因为您更有可能需要它。毕竟,Web 的React工具易于使用,而无需任何不必要的复杂性,而 RN 调试,正如我们所见,稍微有些挑战。据说 Reactotron 比react-native-debugger更有效,但我不会证明这一点:去试试看,并且要知道结果可能有所不同YMMV)。让我们通过演示这种替代调试方式来结束这一章节。

准备工作

我们需要一对包:基本的 Reactotron 包,以及reactotron-redux来帮助处理 Redux。使用以下命令安装它们:

npm install reactotron-react-native reactotron-redux --save-dev

Reactotron 可以与redux-sagas一起工作,而不是redux-thunk,甚至可以与 MobX 一起工作,而不是 Redux。在github.com/infinitered/reactotron上了解更多信息。

您还需要一个连接到您的应用程序的本机可执行工具。转到github.com/infinitered/reactotron/releases的发布页面,并获取与您的环境匹配的软件包:在我特定的情况下,我只下载并解压了Reactotron-linux-x64.zip文件。对于 macOS 用户,还有另一种可能性:查看github.com/infinitered/reactotron/blob/master/docs/installing.md

安装所有这些后,我们准备好准备我们的应用程序;现在让我们这样做!

如何做...

事实上,您可以同时使用 Reactotron 和react-native-debugger,但为了避免混淆,让我们有一个单独的App.reactotron.js文件和一些其他更改。我们必须遵循一些简单的步骤。首先,让我们通过向package.json添加一个新的脚本来启用使用 Reactotron 运行我们的应用程序:

    "scripts": {
        "start": "cp App.standard.js App.js && react-native-scripts start",
 "start-reactotron": "cp App.reactotron.js App.js && react-native-scripts start",
        .
        .
        .

其次,让我们配置连接和插件。我们将创建一个reactotronConfig.js文件来与Reactotron建立连接:

// Source file: reactotronConfig.js

/* @flow */

import Reactotron from "reactotron-react-native";
import { reactotronRedux } from "reactotron-redux";

const reactotron = Reactotron.configure({
    port: 9090,
    host: "192.168.1.200"
})
    .useReactNative({
        networking: {
            ignoreUrls: /\/logs$/
        }
    })
    .use(
        reactotronRedux({
            isActionImportant: action => action.type.includes("success")
        })
    )
    .connect();

Reactotron.log("A knick-knack is a thing that sits on top of a whatnot");
Reactotron.warn("If you must make a noise, make it quietly");
Reactotron.error("Another nice mess you've gotten me into.");

export default reactotron;

以下是上一个代码片段中一些值和选项的一些细节:

  • 192.168.1.200是我的机器的 IP,9090是建议使用的端口。

  • 网络调试的ignoreUrls选项可以消除 Expo 发出的一些调用,但不会消除我们自己的代码,使会话更清晰。

  • isActionImportant函数允许您突出显示一些操作,以便它们更加显眼。在我们的情况下,我选择了countries:successregions:success操作,这两个操作的类型都包含"success",但当然,您也可以选择任何其他操作。

Reactotron还包括日志记录功能,因此我添加了三个(无用的!)调用,只是为了看看它们在我们的调试中是如何显示的。我不想展示我们添加的所有日志,但您可能希望使用以下命令,以便所有日志都会发送到Reactotron

console.log = Reactotron.log;
console.warn = Reactotron.warn;
console.error = Reactotron.error;

现在,我们必须调整我们的存储,以便它可以与reactotron-redux插件一起使用。我选择复制store.js,并将其命名为store.reactotron.js,并进行以下必要的更改:

// Source file: src/regionsStyledApp/store.reactotron.js

/* @flow */

import { AsyncStorage } from "react-native";
import { applyMiddleware } from "redux";
import thunk from "redux-thunk";
import reactotron from "../../reactotronConfig";

import { reducer } from "./world.reducer";

export const store = reactotron.createStore(
    reducer,
    applyMiddleware(thunk)
);

// *continues*...

为了多样化,并且能够看到Reactotron如何处理AsyncStorage,我添加了一些(完全无用的!)行来设置一些项目:

// ...*continued*

(async () => {
    try {
        await AsyncStorage.setItem("First", "Federico");
        await AsyncStorage.setItem("Last", "Kereki");
        await AsyncStorage.setItem("Date", "Sept.22nd");
        await AsyncStorage.getItem("Last");
    } catch (e) {
    }
})();

接下来,让我们对App.js文件进行一些更改。这些更改很小:只需包含配置文件,并使用我刚刚调整的存储:

// Source file: App.reactotron.js

/* @flow */

import React from "react";
import { Provider } from "react-redux";

import "./reactotronConfig";
 import { store } from "./src/regionsStyledApp/store.reactotron";
import { ConnectedMain } from "./src/regionsStyledApp/main.connected";

export default class App extends React.PureComponent<> {
    render() {
        return (
            <Provider store={store}>
                <ConnectedMain />
            </Provider>
        );
    }
}

现在,我们准备好了;让我们看看它的运行情况!

有关Reactotron的完整文档,请查看开发者的网页github.com/infinitered/reactotronReactotron还包括更多插件,可以在使用ReduxStorybook时帮助您进行慢函数的基准测试,或记录消息,因此您可能会在那里找到许多有趣的东西。

它是如何工作的...

要使用Reactotron,只需启动它(双击应该就可以了),您将看到以下截图中显示的初始屏幕。该工具将等待您的应用连接;有时,可能需要多次尝试才能开始初始连接,但之后,事情应该会顺利进行。

Reactotron 的初始屏幕显示它正在等待连接

启动应用程序后,您将看到它已经建立了连接。Reactotron显示了一些详细信息:例如,设备正在运行 Android 8.1.0 版本,我们还可以看到设备的大小和比例。请参阅以下截图:

连接成功后,您可以查看有关设备的详细信息

应用程序启动时,我们会得到类似以下截图的东西。请注意突出显示的操作(countries:success),ASYNC STORAGE 日志,以及我们添加的来自老电影的三行(对于电影爱好者来说,这是一个有趣的时间:谁说了这三句话?):

当我们的应用程序开始运行时,我们会在 Reactotron 窗口中得到所有这些调试文本。

我们还可以查看Redux存储的状态——请参阅以下截图。我检查了deviceData和一个国家:

您可以检查 Redux 存储以查看其中放入了什么

最后,我在应用程序中选择了奥地利。我们可以检查已发出的 API 调用,以及随后分派的操作;请参阅以下截图:

在我们的应用程序中选择奥地利的结果:我们可以检查 API 调用和 Redux 操作。在这里,我们看到了

奥地利的九个地区,以及莫扎特故乡萨尔茨堡的详细信息

Reactotron有一些不同的功能,正如我们所说的,对于某些目的,它可能比react-native-debugger更适合您,因此它是您调试工具库中值得包含的内容。

第十三章:使用 Electron 创建桌面应用程序

我们将查看以下配方:

  • 使用 React 设置 Electron

  • 向您的应用程序添加 Node 功能

  • 构建更窗口化的体验

  • 测试和调试您的应用程序

  • 制作一个可分发的软件包

介绍

在之前的章节中,我们使用Node来设置服务器,并使用React创建网页。在本章中,我们将把两者结合起来,添加另一个名为Electron的工具,并看看如何使用 JS 编写与任何本机可执行应用程序完全相同的桌面应用程序。

使用 React 设置 Electron

Electron是由 GitHub 创建的开源框架,它允许您开发桌面可执行文件,将 Node 和 Chrome 集成在一起,提供完整的 GUI 体验。 Electron已用于几个知名项目,包括开发人员工具,如 Visual Studio Code,Atom 和 Light Table。基本上,您可以使用 HTML,CSS 和 JS(或使用React,就像我们将要做的那样)来定义 UI,但您还可以使用Node中的所有软件包和功能,因此您不会受到沙箱化体验的限制,可以超越您只能使用浏览器做的事情。

您可能还想了解渐进式 Web 应用程序PWA),这些是可以像本机应用程序一样“安装”在您的计算机上的 Web 应用程序。这些应用程序像其他应用程序一样启动,并在常见的应用程序窗口中运行,而不像浏览器那样显示标签或 URL 栏。 PWA 可能(尚未?)无法访问完整的桌面功能,但对于许多情况来说可能已经足够了。在developers.google.com/web/progressive-web-apps/上阅读有关 PWA 的更多信息。

如何做...

现在,在这个配方中,让我们首先安装Electron,然后在后续的配方中,我们将看到如何将我们的一个React应用程序转换为桌面程序。

我从第八章的存储库副本开始,扩展您的应用程序,以获取国家和地区应用程序,这与我们用于 RN 示例的相同。恰好您可以完全使用 CRA 构建的应用程序与Electron完美地配合,甚至无需弹出它,这就是我们将在这里做的。首先,我们需要安装基本的Electron软件包,因此在我们编写React应用程序的同一目录中,我们将执行以下命令:

npm install electron --save-dev

然后,我们需要一个启动 JS 文件。从github.com/electron/electron-quick-startmain.js文件中获取一些提示,我们将创建以下electron-start.js文件:

// Source file: electron-start.js

/* @flow */

const { app, BrowserWindow } = require("electron");

let mainWindow;

const createWindow = () => {
    mainWindow = new BrowserWindow({
        height: 768,
        width: 1024
    });
    mainWindow.loadURL("http://localhost:3000");
    mainWindow.on("closed", () => {
        mainWindow = null;
    });
};

app.on("ready", createWindow);

app.on("activate", () => mainWindow === null && createWindow());

app.on(
    "window-all-closed",
    () => process.platform !== "darwin" && app.quit()
);

以下是关于前面代码片段的一些要点:

  • 此代码在Node中运行,因此我们使用require()而不是import

  • mainWindow变量将指向浏览器实例,我们的代码将在其中运行

  • 我们将首先运行我们的 React 应用程序,因此 Electron 将能够从localhost:3000加载代码

在我们的代码中,我们还必须处理以下事件:

  • Electron完成初始化并可以开始创建窗口时,将调用"ready"

  • "closed"表示您的窗口已关闭;您的应用程序可能有多个窗口打开,因此在这一点上,您应该删除已关闭的窗口。

  • "window-all-closed"意味着您的整个应用程序已关闭。在 Windows 和 Linux 中,这意味着退出,但对于 macOS,通常不会退出应用程序,因为苹果通常的规则。

  • 当您的应用程序重新激活时,将调用"activate",因此如果窗口已被删除(如在 Windows 或 Linux 中),您必须重新创建它。

Electron可以发出的完整事件列表在github.com/electron/electron/blob/master/docs/api/app.md中;查看一下。

我们已经有了我们的React应用程序,所以我们只需要一种调用Electron的方法。将以下脚本添加到package.json中,你就准备好了:

 "scripts": {
 "electron": "electron .",
        .
        .
        .

我们已经准备好了;让我们看看它是如何一起运作的。

它是如何工作的...

要以开发模式运行Electron应用程序(稍后我们将创建一个可执行文件),我们必须执行以下操作:

  1. 从第四章运行我们的restful_server_cors服务器代码,使用 Node 实现 RESTful 服务

  2. 启动React应用程序,需要服务器正在运行。

  3. 等待加载完成,然后再进行下一步。

  4. 启动Electron

因此,基本上,您将需要运行以下两个命令,但是您需要在单独的终端中执行这些命令,并且在启动Electron之前还需要等待React应用程序在浏览器中显示:

// *in the directory for our restful server:* node out/restful_server_cors.js // *in the React app directory:* npm start

// *and after the React app is running, in other terminal:*
npm run electron

启动Electron后,屏幕迅速出现,我们再次发现我们的国家和地区应用程序,现在独立于浏览器运行。请参阅以下屏幕截图-请注意,我将窗口从其 1024×768 大小调整为:

我们的应用程序作为一个独立的可执行文件运行

应用程序像往常一样工作;例如,我选择了一个国家,加拿大,并正确地得到了它的地区列表:

应用程序像以前一样工作;选择一个国家,然后调用我们的 RESTful 服务器将获取其地区

我们完成了!您可以看到一切都是相互关联的,就像以前一样,如果您对React源代码进行任何更改,它们将立即反映在Electron应用程序中。

到目前为止,我们已经看到我们可以将网页制作成可执行文件;现在让我们看看如何使其更加强大。

向您的应用程序添加 Node 功能

在上一个教程中,我们看到只需进行一些小的配置更改,我们就可以将我们的网页变成一个应用程序。但是,您仍然受到限制,因为您仍然只能使用沙盒浏览器窗口中可用的功能。您不必这样想,因为您可以使用让您超越网络限制的功能来添加基本所有Node功能。让我们在本教程中看看如何做到这一点。

如何做...

我们想要为我们的应用程序添加一些典型桌面应用程序的功能。让我们看看如何做到这一点。向您的应用程序添加Node功能的关键是使用Electron中的remote模块。借助它,您的浏览器代码可以调用主进程的方法,从而获得额外的功能。

有关远程模块的更多信息,请参见github.com/electron/electron/blob/master/docs/api/remote.md。还有一些额外的信息可能会在electronjs.org/docs/api/remote中派上用场。

假设我们想要添加将国家地区列表保存到文件的可能性。我们需要访问fs模块以便能够写入文件,并且我们还需要打开对话框来选择要写入的文件。在我们的serviceApi.js文件中,我们将添加以下功能:

// Source file: src/regionsApp/serviceApi.js

/* @flow */

const electron = window.require("electron").remote;

.
.
.

const fs = electron.require("fs");

export const writeFile = fs.writeFile.bind(fs);

export const showSaveDialog = electron.dialog.showSaveDialog;

添加了这个之后,我们现在可以从我们的主代码中写文件和显示对话框。要使用此功能,我们可以在我们的world.actions.js文件中添加一个新的操作:

// Source file: src/regionsApp/world.actions.js

/* @flow */

import {
    getCountriesAPI,
    getRegionsAPI,
 showSaveDialog,
 writeFile
} from "./serviceApi";

.
.
.

export const saveRegionsToDisk = () => async (
    dispatch: ({}) => any,
    getState: () => { regions: [] }
) => {
    showSaveDialog((filename: string = "") => {
        if (filename) {
            writeFile(filename, JSON.stringify(getState().regions), e =>
                e && window.console.log(`ERROR SAVING ${filename}`, e);
            );
        }
    });
};

当调度saveRegionsToDisk()操作时,它将显示一个对话框,提示用户选择要写入的文件,然后将当前的地区集合(从getState().regions中获取)以 JSON 格式写入所选文件。我们只需向我们的<RegionsTable>组件添加适当的按钮,以便能够调度必要的操作:

// Source file: src/regionsApp/regionsTableWithSave.component.js

/* @flow */

import React from "react";
import PropTypes from "prop-types";

import "../general.css";

export class RegionsTable extends React.PureComponent<{
    loading: boolean,
    list: Array<{
        countryCode: string,
        regionCode: string,
        regionName: string
    }>,
 saveRegions: () => void
}> {
    static propTypes = {
        loading: PropTypes.bool.isRequired,
        list: PropTypes.arrayOf(PropTypes.object).isRequired,
 saveRegions: PropTypes.func.isRequired
    };

    static defaultProps = {
        list: []
    };

    render() {
        if (this.props.list.length === 0) {
            return <div className="bordered">No regions.</div>;
        } else {
            const ordered = [...this.props.list].sort(
                (a, b) => (a.regionName < b.regionName ? -1 : 1)
            );

            return (
                <div className="bordered">
                    {ordered.map(x => (
                        <div key={x.countryCode + "-" + x.regionCode}>
                            {x.regionName}
                        </div>
                    ))}
 <div>
 <button onClick={() => this.props.saveRegions()}>
 Save regions to disk
 </button>
 </div>
                </div>
            );
        }
    }
}

我们快要完成了!当我们将此组件连接到存储时,我们只需添加新的操作,如下所示:

// Source file: src/regionsApp/regionsTableWithSave.connected.js

/* @flow */

import { connect } from "react-redux";

import { RegionsTable } from "./regionsTableWithSave.component";

import { saveRegionsToDisk } from "./world.actions";

const getProps = state => ({
    list: state.regions,
    loading: state.loadingRegions
});

const getDispatch = (dispatch: any) => ({
 saveRegions: () => dispatch(saveRegionsToDisk())
});

export const ConnectedRegionsTable = connect(
    getProps,
 getDispatch
)(RegionsTable);

现在,一切准备就绪-让我们看看它是如何工作的。

它是如何工作的...

我们添加的代码显示了我们如何访问Node包(在我们的情况下是fs)和一些额外的功能,比如显示一个保存到磁盘的对话框。(后一个功能与您的应用程序的本机外观更相关,我们将在即将到来的构建更窗口化的体验部分中看到更多相关内容。)当我们运行更新后的应用程序并选择一个国家时,我们将看到我们新添加的按钮,就像以下截图中的那样:

现在,在区域列表后面有一个“保存区域到磁盘”按钮

单击按钮将弹出对话框,允许您选择数据的目标:

单击按钮会弹出一个保存屏幕,指定要将结果保存到哪个文件

如果单击“保存”,区域列表将以 JSON 格式编写,就像我们在writeRegionsToDisk()函数中指定的那样:

[{"countryCode":"CA","regionCode":"1","regionName":"Alberta"},
{"countryCode":"CA","regionCode":"10","regionName":"Quebec"},
{"countryCode":"CA","regionCode":"11","regionName":"Saskatchewan"},
{"countryCode":"CA","regionCode":"12","regionName":"Yukon"},
{"countryCode":"CA","regionCode":"13","regionName":"Northwest Territories"},
{"countryCode":"CA","regionCode":"14","regionName":"Nunavut"},
{"countryCode":"CA","regionCode":"2","regionName":"British Columbia"},
{"countryCode":"CA","regionCode":"3","regionName":"Manitoba"},
{"countryCode":"CA","regionCode":"4","regionName":"New Brunswick"},
{"countryCode":"CA","regionCode":"5","regionName":"Newfoundland and Labrador"},
{"countryCode":"CA","regionCode":"7","regionName":"Nova Scotia"},
{"countryCode":"CA","regionCode":"8","regionName":"Ontario"},
{"countryCode":"CA","regionCode":"9","regionName":"Prince Edward Island"}]

最后要注意的细节是,您的应用程序现在无法在浏览器中运行,您将不得不习惯看到以下截图中的内容,即使您的代码在Electron中运行良好:

如果使用 Node 或 Electron 的功能,您的代码将不再在浏览器中运行,尽管它在 Electron 中的表现良好

就是这样!毫不费力地,我们能够超越普通浏览器应用的限制。您可以看到在Electron应用程序中几乎没有限制。

构建更窗口化的体验

在上一个示例中,我们添加了使用Node提供的任何和所有功能的可能性。在这个示例中,让我们专注于使我们的应用程序更像窗口,具有图标、菜单等。我们希望用户真的相信他们正在使用一个本地应用程序,具有他们习惯的所有功能。以下是来自electronjs.org/docs/api的有趣主题列表的一些亮点,但还有许多其他可用选项:

clipboard 使用系统剪贴板进行复制和粘贴操作
dialog 显示用于消息、警报、打开和保存文件等的本机系统对话框
globalShortcut 检测键盘快捷键
MenuMenuItem 创建带有菜单和子菜单的菜单栏
Notification 添加桌面通知
powerMonitorpowerSaveBlocker 监控电源状态变化,并禁用进入睡眠模式
screen 获取有关屏幕、显示器等的信息
Tray 向系统托盘添加图标和上下文菜单

让我们添加一些这些功能,以便我们可以获得一个外观更好、更与桌面集成的应用程序。

如何做...

任何体面的应用程序可能至少应该有一个图标和一个菜单,可能还有一些键盘快捷键,所以让我们现在添加这些功能,并且仅仅是为了这个缘故,让我们也为区域写入磁盘时添加一些通知。连同我们已经使用的保存对话框,这意味着我们的应用程序将包括几个本机窗口功能。让我们实施以下步骤,并了解如何添加这些额外功能。

首先,让我们添加一个图标。显示图标是最简单的事情,因为在创建BrowserWindow()对象时只需要一个额外的选项。我不太擅长图形视觉设计,所以我只是从 Icon-Icons 网站上下载了 Alphabet, letter, r Icon Free 文件,网址是icon-icons.com/icon/alphabet-letter-r/62595。实现图标如下:

mainWindow = new BrowserWindow({
    height: 768,
    width: 1024,
 icon: "./src/regionsApp/r_icon.png"
});

您还可以为系统托盘选择图标,尽管在该上下文中无法使用我们的区域应用程序,但您可能仍然希望了解一下。

在构建时,还有另一种方法可以向应用程序添加图标,即在package.json"build"条目中添加额外的配置项。

接下来,我们将添加的第二个功能是一个菜单,还有一些全局快捷键。在我们的App.regions.js文件中,我们需要添加几行来访问Menu模块,并定义我们自己的菜单:

// Source file: src/App.regions.js

.
.
.

import { getRegions } from "./regionsApp/world.actions";

.
.
.

const electron = window.require("electron").remote;
const { Menu } = electron;

const template = [
    {
        label: "Countries",
        submenu: [
            {
                label: "Uruguay",
                accelerator: "Alt+CommandOrControl+U",
                click: () => store.dispatch(getRegions("UY"))
            },
            {
                label: "Hungary",
                accelerator: "Alt+CommandOrControl+H",
                click: () => store.dispatch(getRegions("HU"))
            }
        ]
    },
    {
        label: "Bye!",
        role: "quit"
    }
];

const mainMenu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(mainMenu);

使用模板是创建菜单的一种简单方法,但您也可以手动执行,逐个添加项目。我决定有一个国家菜单,有两个选项,可以显示乌拉圭(我出生的地方)和匈牙利(我父亲的父亲来自的地方)的地区。click属性会分派适当的操作。我还使用accelerator属性来定义全局快捷键。请参阅github.com/electron/electron/blob/master/docs/api/accelerator.md以获取可以使用的可能键组合的列表,包括以下内容:

  • 命令键,如Command(或Cmd),Control(或Ctrl),或两者(CommandOrControlCmdOrCtrl

  • 备用键,如AltAltGrOption

  • 常用键,如ShiftEscape(或Esc),TabBackspaceInsertDelete

  • 功能键,如F1F24

  • 光标键,包括HomeEndPageUpPageDown

  • 媒体键,如MediaPlayPauseMediaStopMediaNextTrackMediaPreviousTrackVolumeUpVolumeDownVolumeMute

我还希望能够退出应用程序(不要紧,Electron创建的窗口已经有一个×图标来关闭它!)-这是一个预定义的角色,您不需要做任何特殊的事情。可以在electronjs.org/docs/api/menu-item#roles找到完整的角色列表。有了这些角色,您可以做很多事情,包括一些特定的 macOS 功能,以及以下内容:

  • 使用剪贴板(剪切复制粘贴粘贴并匹配样式

  • 处理窗口(最小化关闭退出重新加载强制重新加载

  • 缩放(放大缩小重置缩放

最后,也只是为了这个缘故,让我们为文件写入时添加一个通知触发器。Electron有一个Notification模块,但我选择使用node-notifier,这个模块非常简单易用。首先,我们将以通常的方式添加这个包:

npm install node-notifier --save

serviceApi.js中,我们将不得不导出新的函数,这样我们就可以从其他地方导入,我们很快就会看到:

const electron = window.require("electron").remote;

.
.
.

export const notifier = electron.require("node-notifier");

最后,让我们在我们的world.actions.js文件中使用它:


import {
 notifier,
    .
    .
    .
} from "./serviceApi";

有了所有的设置,实际发送通知非常简单,需要的代码很少:

// Source file: src/regionsApp/world.actions.js

.
.
.

export const saveRegionsToDisk = () => async (
    dispatch: ({}) => any,
    getState: () => { regions: [] }
) => {
    showSaveDialog((filename: string = "") => {
        if (filename) {
            writeFile(filename, JSON.stringify(getState().regions), e => {
 if (e) {
 window.console.log(`ERROR SAVING ${filename}`, e);
 } else {
 notifier.notify({
 title: "Regions app",
 message: `Regions saved to ${filename}`
 });
 }
            });
        }
    });
};

我们准备好了!让我们看看我们更窗口化的应用现在是什么样子。

工作原理...

首先,我们可以轻松检查图标是否出现。请参阅以下屏幕截图,并将其与本章的第一个屏幕截图进行比较:

我们的应用现在有了自己的图标,可能不是太独特或原创,但总比没有好

现在,让我们看看菜单。它有我们的选项,包括快捷键:

我们的应用现在也有一个菜单,就像任何值得尊敬的应用程序一样

然后,如果我们选择一个选项(比如乌拉圭),无论是用鼠标还是全局快捷键,屏幕都会正确加载预期的区域:

菜单项按预期工作;我们可以使用乌拉圭选项来查看我的国家的 19 个部门

最后,让我们看看通知是否按预期工作。如果我们点击“保存区域到磁盘”按钮并选择一个文件,我们将看到一个通知,如下面的屏幕截图所示:

现在保存文件会显示通知;在这种情况下,是为了 Linux 与 KDE

我们已经看到如何扩展我们的浏览器页面以包括Node功能和窗口本地函数。现在,让我们回到更基本的要求,学习如何测试和调试我们的代码。

测试和调试您的应用程序

现在,我们来到了一个常见的要求:测试和调试您的应用程序。我必须告诉您的第一件事是,关于测试方面没有什么新闻!我们为测试浏览器和Node代码所看到的所有技术仍然适用,因为您的Electron应用程序本质上只是一个浏览器应用程序(尽管可能具有一些额外功能),您将以与之前相同的方式模拟它,因此在这里没有新东西需要学习。

然而,就调试而言,由于您的代码不是在浏览器中运行,因此将会有一些新的要求。与React Native类似,我们将不得不使用一些工具,以便能够在代码运行时查看我们的代码。让我们在本节中看看如何处理所有这些。

如何做...

我们想要安装和配置所有必要的调试工具。让我们在本节中完成这些。调试的关键工具将是electron-devtools-installer,您可以从github.com/MarshallOfSound/electron-devtools-installer获取。我们将使用一个简单的命令安装它,以及之前使用过的Redux Devtools扩展:

npm install electron-devtools-installer redux-devtools-extension --save-dev

要使用Redux Devtools,我们将首先修复存储,就像我们之前做的那样;这里没有什么新东西:

// Source file: src/regionsApp/store.with.redux.devtools.js

/* @flow */

import { createStore, applyMiddleware } from "redux";
import { composeWithDevTools } from "redux-devtools-extension";
import thunk from "redux-thunk";

import { reducer } from "./world.reducer";

export const store = createStore(
    reducer,
 composeWithDevTools(applyMiddleware(thunk))
);

对于工具本身,我们还需要稍微调整我们的起始代码:

// Source file: electron-start.with.debugging.js

/* @flow */

const { app, BrowserWindow } = require("electron");
const {
 default: installExtension,
 REACT_DEVELOPER_TOOLS,
 REDUX_DEVTOOLS
} = require("electron-devtools-installer");

let mainWindow;

const createWindow = () => {
    mainWindow = new BrowserWindow({
        height: 768,
        width: 1024
    });
    mainWindow.loadURL("http://localhost:3000");

 mainWindow.webContents.openDevTools();

 installExtension(REACT_DEVELOPER_TOOLS)
 .then(name => console.log(`Added Extension: ${name}`))
 .catch(err => console.log("An error occurred: ", err));

 installExtension(REDUX_DEVTOOLS)
 .then(name => console.log(`Added Extension: ${name}`))
 .catch(err => console.log("An error occurred: ", err));

    mainWindow.on("closed", () => {
        mainWindow = null;
    });
};

app.on("ready", createWindow);

app.on("activate", () => mainWindow === null && createWindow());

app.on(
    "window-all-closed",
    () => process.platform !== "darwin" && app.quit()
);

好消息是,您可以从代码中添加所有工具,无需特殊安装或其他程序。进行这些简单的更改后,您就完成了;现在,让我们看看它的工作原理!

工作原理...

如果您启动修改后的代码,您将看到Electron窗口现在包括经典的 Chrome 工具,包括ReactRedux。请参阅以下屏幕截图:

electron-devtools-installer 包让您通过简单的程序添加所需的所有工具

除了控制台,您还可以使用React Devtools来检查组件:

React Devtools 可用于检查组件及其属性

同样,Redux DevTools让您检查操作和存储。请参阅以下屏幕截图:

您还安装了 Redux 开发者工具,可以让您检查与 Redux 相关的所有内容

正如您所看到的,我们已经习惯的所有工具都有了,只有一个例外——网络调用呢?让我们现在来看看。

还有更多...

您可能已经注意到,网络选项卡不显示应用程序发出的 API 调用。在 RN 中,我们解决了这个问题,因为我们使用的工具包括检查所有网络流量的功能,但在这里不会发生这种情况。因此,我们将不得不做一些额外的工作,而不是一个简单的自动化解决方案。如果您使用axios进行所有 API 调用,您可以简单地修改其原始方法以生成日志:

// Source file: src/regionsApp/serviceApi.js

.
.
.

axios.originalGet = axios.get;
axios.get = (uri, options, ...args) =>
    axios.originalGet(uri, options, ...args).then(response => {
        console.log(`GET ${uri}`, {
            request: { uri, options, ...args },
            response
        });
        return response;
    });

所示的更改将导致每个成功的GET都记录您需要的所有内容,就像以下屏幕截图中所示:

我们修改后的axios.get()方法产生了令人满意的日志

当然,这只是所需更改的冰山一角。您将不得不为失败的调用添加代码(因此,在.catch()中添加一些日志),您还将希望对其他方法(.post().delete()等)进行此类更改,但必要的代码很简单,所以我将把它作为读者的练习留下!

制作可分发的软件包

现在我们有了一个完整的应用程序,剩下的就是将其打包,以便您可以将其作为可执行文件交付给 Windows、Linux 或 macOS 用户。让我们通过本节来看看如何做到这一点。

如何做...

有许多打包应用程序的方法,但我们将使用一个名为electron-builder的工具,如果您能正确配置它,将使这一切变得更加容易!

您可以在www.electron.build/上阅读有关electron-builder、其功能和配置的更多信息。

让我们看看必要的步骤。首先,我们将不得不开始定义构建配置,我们的初始步骤将是,像往常一样,安装工具:

npm install electron-builder --save-dev

要访问添加的工具,我们需要一个新的脚本,我们将在package.json中添加:

"scripts": {
 "dist": "electron-builder",
    .
    .
    .
}

我们还需要向package.json添加一些更多的细节,这些细节对于构建过程和生成的应用程序是必需的。特别是,需要更改homepage,因为 CRA 创建的index.html文件使用绝对路径,这些路径将无法与Electron后来一起使用:

"name": "chapter13",
"version": "0.1.0",
"description": "Regions app for chapter 13",
"homepage": "./",
"license": "free",
"author": "Federico Kereki",

最后,将需要一些特定的构建配置。您不能在 Linux 或 Windows 机器上构建 macOS,因此我将不包括该配置。我们必须指定文件的位置,要使用的压缩方法等等:

"build": {
    "appId": "com.electron.chapter13",
    "compression": "normal",
    "asar": true,
    "extends": null,
    "files": [
        "electron-start.js",
        "build/**/*",
        "node_modules/**/*",
        "src/regionsApp/r_icon.png"
    ],
    "linux": {
        "target": "zip"
    },
    "win": {
        "target": "portable"
    }
}

www.electron.build/multi-platform-build上阅读有关为不同平台构建的更多信息。有关所有配置选项的更多信息,请参阅www.electron.build/configuration/configuration#configuration

我们已经完成了所需的配置,但代码本身也需要做一些更改,我们将不得不调整代码以构建包。当打包的应用程序运行时,将不会有 webpack 服务器运行;代码将从构建的React包中获取。此外,您不希望包含调试工具。因此,起始代码将需要以下更改:

// Source file: electron-start.for.builder.js

/* @flow */

const { app, BrowserWindow } = require("electron");
const path = require("path");
const url = require("url");

let mainWindow;

const createWindow = () => {
    mainWindow = new BrowserWindow({
        height: 768,
        width: 1024,
 icon: path.join(__dirname, "./build/r_icon.png")
    });
    mainWindow.loadURL(
 url.format({
 pathname: path.join(__dirname, "./build/index.html"),
 protocol: "file",
 slashes: true
 })
    );
    mainWindow.on("closed", () => {
        mainWindow = null;
    });
};

app.on("ready", createWindow);

app.on("activate", () => mainWindow === null && createWindow());

app.on(
    "window-all-closed",
    () => process.platform !== "darwin" && app.quit()
);

主要是,我们正在从build/目录中获取图标和代码。npm run build命令将负责生成该目录,因此我们可以继续创建我们的可执行应用程序。

它是如何工作的...

完成此设置后,构建应用程序基本上是微不足道的。只需执行以下操作,所有可分发文件将在dist/目录中找到:

npm run electron-builder

您可能希望在.gitignore文件中添加一行,以便不提交分发目录。我在我的文件中包含了**/dist行,与之前的**/node_modules**/dist现有行相对应。

现在我们有了 Linux 应用程序,我们可以通过解压.zip文件并单击chapter13可执行文件来运行它。(名称来自package.json中的"name"属性,我们之前修改过。)结果应该像下面的截图所示:

Linux 可执行文件作为本机应用程序运行,显示与我们之前看到的相同的屏幕。

我还想尝试一下 Windows 的EXE文件。由于我没有 Windows 机器,我通过从developer.microsoft.com/en-us/microsoft-edge/tools/vms/下载免费的VirtualBox虚拟机来实现,它们只能使用 90 天,但我只需要几分钟。

下载虚拟机,将其设置在VirtualBox中,并最终运行它后,产生的结果与 Linux 的结果相同,如下面的截图所示:

我们的本机 Windows 应用程序在 Windows 机器上同样运行

因此,我们已经成功开发了一个React应用程序,增强了NodeElectron功能,并最终为不同的操作系统打包了它。有了这个,我们就完成了!

第十四章:其他您可能喜欢的书籍

如果您喜欢这本书,您可能会对 Packt 的其他书感兴趣:

构建企业级 JavaScript 应用程序

丹尼尔·李

ISBN:9781788477321

  • 在整本书中实践测试驱动开发(TDD)

  • 使用黄瓜、Mocha 和 Selenium 编写端到端、集成、单元和 UI 测试

  • 使用 Express 和 Elasticsearch 构建无状态 API

  • 使用 OpenAPI 和 Swagger 记录您的 API

  • 使用 React、Redux 和 Webpack 构建和捆绑前端应用程序

  • 使用 Docker 容器化服务

  • 使用 Kubernetes 部署可扩展的微服务

使用 JavaScript 学习区块链编程

埃里克·特劳布

ISBN:9781789618822

  • 深入了解区块链和环境设置

  • 从头开始创建您自己的去中心化区块链网络

  • 构建和测试创建去中心化网络所需的各种端点

  • 了解工作证明和用于保护数据的哈希算法

  • 挖掘新的区块,创建新的交易,并将交易存储在区块中

  • 探索共识算法并将其用于同步区块链网络

留下评论-让其他读者知道您的想法

请通过在购买书籍的网站上留下评论与其他人分享您对这本书的想法。如果您从亚马逊购买了这本书,请在该书的亚马逊页面上留下诚实的评论。这对其他潜在读者来说非常重要,他们可以看到并使用您的公正意见来做出购买决定,我们可以了解我们的客户对我们的产品的看法,我们的作者可以看到您与 Packt 合作创建的标题的反馈。这只需要您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 都是有价值的。谢谢!

posted @ 2024-05-23 16:02  绝不原创的飞龙  阅读(18)  评论(0编辑  收藏  举报