File failed to load: /extensions/MathZoom.js

MERN-技术栈高级教程-全-

MERN 技术栈高级教程(全)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

一、简介

Web 应用开发已经今非昔比,甚至几年前也是如此。今天,有这么多的选择,门外汉往往不知道什么对他们有好处。有很多选择;不仅仅是广泛的(所使用的各种层或技术),还包括用于帮助开发的工具。这本书声称 MERN 堆栈对于开发一个完整的 web 应用是非常棒的,并带领读者完成所有必要的工作。

在这一章中,我将对 MERN 堆栈所包含的技术进行概述。在这一章中,我不会详细介绍细节或例子,相反,我将只介绍高层次的概念。我将关注这些概念如何影响对 MERN 是否是您下一个 web 应用项目的好选择的评估。

MERN 是什么?

任何 web 应用都是使用多种技术构建的。这些技术的组合被称为“堆栈”,因 LAMP stack 而流行,LAMP stack 是 Linux、Apache、MySQL、PHP 的缩写,这些都是开源软件。随着 web 开发的成熟和交互性的出现,单页面应用变得越来越流行。SPA 是一种 web 应用范例,它避免了从服务器获取整个 web 页面的内容来显示新内容。相反,它使用对服务器的轻量级调用来获取一些数据或片段,并更改网页。与完全重新加载页面的旧方法相比,结果看起来相当漂亮。这带来了前端框架的兴起,因为很多工作都是在前端完成的。几乎在同一时间,虽然完全不相关,NoSQL 数据库也开始流行起来。

MEAN (MongoDB,Express,AngularJS,Node.js)栈是早期的开源栈之一,集中体现了向 SPAs 和 NoSQL 的转变。基于模型视图控制器(MVC)设计模式的前端框架 AngularJS 锚定了这个堆栈。MongoDB 是一个非常流行的 NoSQL 数据库,用于持久数据存储。Node.js 是一个服务器端 JavaScript 运行时环境,Express 是一个构建在 Node.js 之上的 web 服务器,它们构成了中间层,即 web 服务器。几年前,这种堆栈可以说是任何新的 web 应用最流行的堆栈。

不完全竞争,但 React 是脸书创造的替代前端技术,越来越受欢迎,并提供了 AngularJS 的替代方案。因此,它将 MEAN 中的“A”替换为“R ”,得到 MERN 堆栈。我说“不完全是”,因为 React 不是一个成熟的 MVC 框架。它是一个用于构建用户界面的 JavaScript 库,所以在某种意义上,它是 MVC 的视图部分。

尽管我们选择了一些定义技术来定义一个堆栈,但这些不足以构建一个完整的 web 应用。需要其他工具来帮助开发过程,并且需要许多库来补充 React。这本书是关于基于 MERN 堆栈和所有这些相关的工具和库来构建一个完整的 web 应用。

谁应该读这本书

除了 MERN 堆栈之外,有任何 web 应用堆栈经验的开发人员和架构师会发现这本书对于了解这种现代堆栈很有用。要求事先了解 web 应用如何工作。还需要 JavaScript 知识。还假设读者了解 HTML 和 CSS 的基础知识。如果你也熟悉版本控制工具 git,那会有很大帮助;您可以通过克隆保存了本书中描述的所有源代码的 git 存储库,并通过检查一个分支来运行每个步骤,来试验代码。

书中的代码使用了 JavaScript (ES2015+)的最新特性,并且假设你对这些特性如类、胖箭头函数、const关键字等非常熟悉。每当我第一次使用这些现代 JavaScript 特性时,我会用注释指出来,这样您就知道这是一个新特性。如果你不熟悉某个特定的特性,你可以在遇到它的时候仔细阅读。

如果你已经决定你的新应用将使用 MERN 堆栈,那么这本书是一个完美的推动者,让你快速起步。即使你没有,读这本书也会让你对 MERN 感到兴奋,并让你有足够的知识为未来的项目做出选择。你将学到的最重要的东西是把多种技术放在一起,构建一个完整的、功能性的 web 应用,你可以被称为 MERN 的全栈开发者或架构师。

这本书的结构

虽然这本书的重点是让您学习如何构建一个完整的 web 应用,但这本书的大部分内容都围绕 React 展开。这只是因为,就像大多数现代水疗中心一样,前端代码构成了主体。在这种情况下,React 用于前端。

这本书的基调是教程式的,是为边做边学而设计的。在本书的过程中,我们将构建一个 web 应用。我使用“我们”这个术语,因为您将需要编写代码,就像我向您展示将作为大量代码清单的一部分编写的代码一样。除非你和我一起自己写代码,并解决练习,否则你不会得到这本书的全部好处。我鼓励你而不是复制粘贴;相反,请键入代码。我发现这在学习过程中非常有价值。非常小的细微差别(例如,引号的类型)可能会造成很大的差异。当你输入代码时,你会比仅仅阅读代码时更能意识到这一点。

有时,你可能会遇到你输入的东西不工作的情况。在这种情况下,您可能希望复制粘贴以确保代码是正确的,并克服您可能犯下的任何打字错误。在这种情况下,不要从书的电子版本中复制粘贴而不是,因为排版可能不符合实际代码。我在 https://github.com/vasansr/pro-mern-stack-2 创建了一个 GitHub 库,供你比较,在不可避免的情况下,可以复制粘贴。

我还在每一个可以单独测试的更改后添加了一个检查点(实际上是一个 git 分支),这样您就可以在线查看两个检查点之间的确切差异。存储库的主页(自述文件)中列出了检查点和到 diffs 的链接。您可能会发现这比查看整个源代码,甚至是本书正文中的清单更有用,因为 GitHub 的差异远比我在印刷品中展示的更有表现力。

我采用了一种更实际、更能解决问题的方法,而不是每一节都涵盖一个主题或技术。到本书结束时,我们将已经开发出一个成熟的工作应用,但是我们将从一个 Hello World 示例开始。就像在一个真实的项目中一样,随着我们的进展,我们将为应用添加更多的功能。当我们这样做时,我们会遇到需要额外的概念或知识才能继续的任务。对于其中的每一个,我将介绍可以使用的概念或技术,我将详细讨论这一点。

因此,您可能不会发现每一章或每一节都专门针对一个主题或技术。一些章节可能集中在一项技术上,而其他章节可能针对我们希望在应用中实现的一系列目标。随着我们的进步,我们将在技术和工具之间切换。

我已经尽可能地包括练习,这使你要么思考,要么在互联网上查找各种资源。这是为了让您知道在哪里可以获得本书中没有涉及的其他信息,通常是非常高级的主题或 API。

我选择了一个问题跟踪应用作为我们将一起构建的应用。这是大多数开发人员都能理解的,同时具有任何企业应用都会有的许多属性和要求,通常称为“CRUD”应用(CRUD 代表数据库记录的创建、读取、更新和删除)。

约定

书中使用的许多约定非常明显,所以我不会一一解释。我将只讨论一些关于如何构建各部分以及如何显示代码更改的约定,因为这不是很明显。

每章有多个部分,每个部分都致力于一组代码更改,这些代码更改会产生一个可以运行和测试的工作应用。一个部分可以有多个清单,但是它们中的每一个都不能单独测试。每个部分在 GitHub 存储库中都会有一个相应的条目,在那里您可以看到该部分结束时应用的完整源代码,以及前一部分和当前部分之间的差异。您会发现 difference 视图对于识别该部分中所做的更改非常有用。

所有代码更改将出现在该部分的列表中,但请不要依赖它们的准确性。可以在 GitHub 资源库中找到可靠且有效的代码,这些代码甚至可能在最后一刻发生了变化,无法及时印刷出版。所有列表都有一个列表标题,其中包括被更改或创建的文件的名称。

您可以使用 GitHub 资源库来报告印刷书籍中的问题。但是在你这样做之前,一定要检查现有的问题列表,看看是否有其他人报告了同样的问题。我会监控这些问题并发布解决方案,如果有必要,还会在 GitHub 库中更正代码。

如果一个列表包含一个文件、一个类、一个函数或一个完整的对象,那么它就是一个完整的列表。一个完整的列表也可以包含两个或更多的类、函数或对象,但不能包含多个文件。在这种情况下,如果实体不连续,我将使用省略号来表示未更改的代码块。

清单 1-1 是一个完整清单的例子,是整个文件的内容。

const express = require('express');

const app = express();
app.use(express.static('static'));

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 1-1.server.js: Express Server

另一方面,部分列表不会列出完整的文件、函数或对象。它将以省略号开始和结束,中间可能会有省略号以跳过未更改的代码块。添加的新代码将以粗体突出显示,未更改的代码将以正常字体显示。清单 1-2 是部分清单的一个例子,有一些小的增加。

...
  "scripts": {
    "compile": "babel src --presets react --out-dir static",
    "watch": "babel src --presets react --out-dir static --watch",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 1-2.package.json: Adding Scripts for Transformation

删除的代码将使用删除线显示,如清单 1-3 所示。

...
  "devDependencies": {
    "babel-polyfill": "⁶.13.0",
    ...
  }
...

Listing 1-3.package.json: Changes for Removing Polyfill

代码块在常规文本中用于提取代码中的变化以供讨论,通常是清单中代码的重复。这些不是清单,通常只有一两行。下面是一个例子,从清单中提取一行,突出显示一个单词:

...
const contentNode = ...
...

所有需要在控制台上执行的命令都是以$开头的代码块形式。这里有一个例子:

$ npm install express

书中使用的所有命令也可以在 GitHub 库的一个名为commands.md的文件中找到。这是为了在图书出版后纠正图书中的错误,同时也是一个更可靠的复制粘贴来源。同样,我们鼓励您不要复制粘贴这些命令,但是如果您因为发现某些东西不起作用而被迫这样做,那么请从 GitHub 库复制粘贴,而不是从书中的文本复制粘贴。

在本书后面的章节中,代码将被拆分到两个项目或目录中。为了区分应该在哪个目录下发出命令,命令块将以cd开始。例如,要在名为api的目录中执行一个命令,将使用以下命令:

$ cd api
$ npm install dotenv@6

所有需要在 MongoDB shell 中执行的命令都是以>开头的代码块形式。例如:

> show collections

这些命令也收集在一个文件中,在 GitHub 存储库中称为mongoCommands.md

你需要什么

您将需要一台能够运行您的服务器并执行其他任务(如编译)的计算机。您还需要一个浏览器来测试您的应用。我推荐一台基于 Linux 的计算机,比如 Ubuntu,或者一台 Mac 作为你的开发服务器,但是稍加改动,你也可以使用 Windows PC。

直接在 Windows 上运行 Node.js 也可以,但是本书中的代码示例假设是基于 Linux 的 PC 或 Mac。如果您选择直接在 Windows PC 上运行,您可能需要进行适当的更改,特别是在 shell 中运行命令时,使用副本而不是使用软链接,在极少数情况下,还要处理路径分隔符中的\/

一种选择是尝试使用 vagger(https://www.vagrantup.com/)运行一个 Ubuntu 服务器虚拟机(VM)。这很有帮助,因为您最终将在基于 Linux 的服务器上部署您的代码,并且最好从一开始就习惯这种环境。但是你可能会发现编辑文件很难,因为在 Ubuntu 服务器中,你只有一个控制台。Ubuntu 桌面虚拟机可能更适合你,但是它需要更多的内存。

此外,为了保持本书的简洁,我没有包括软件包的安装说明,它们对于不同的操作系统是不同的。您需要遵循软件包提供商网站上的安装说明。在许多情况下,我没有包括网站的直接链接,我请你去看看。这是由于几个原因。首先是让你自己学习如何搜索这些。第二,由于在写这本书的时候 MERN 堆栈正在经历的快速变化,我提供的任何链接可能已经转移到另一个位置。

MERN 组件

我将简要介绍构成 MERN 堆栈的主要组件,以及我们将用来构建 web 应用的其他一些库和工具。我将只谈一些突出的特点,而把细节留给更适合的其他章节。

React

React 锚定 MERN 堆栈。在某种意义上,这是 MERN 堆栈的定义组件。

React 是一个由脸书维护的开源 JavaScript 库,可用于创建以 HTML 呈现的视图。与 AngularJS 不同,React 不是一个框架。这是一个图书馆。因此,它本身并没有规定一个框架模式,比如 MVC 模式。您使用 React 来呈现视图(MVC 中的 V ),但是如何将应用的其余部分连接在一起完全取决于您。

不仅仅是脸书本身,还有许多其他公司在生产中使用 React,如 Airbnb、Atlassian、Bitbucket、Disqus、Walmart 等。GitHub 知识库上的 120,000 颗星星表明了它的受欢迎程度。

我将讨论 React 的几个突出特点。

为什么脸书发明了 React

脸书的人构建了 React 供自己使用,后来他们将其开源。现在,他们为什么要建立一个新的图书馆,而外面有成吨的图书馆呢?

React 并不是诞生于我们都看到的脸书应用,而是诞生于脸书的广告组织。最初,他们使用典型的客户端 MVC 模型,该模型具有所有常规的双向数据绑定和模板。视图会监听模型的变化,并通过更新自己来响应这些变化。

很快,随着应用变得越来越复杂,这变得非常棘手。将会发生的情况是,一个更改将导致一个更新,这将导致另一个更新(因为由于那个更新而发生了一些更改),这将导致另一个更新,以此类推。这种级联更新变得难以维护,因为根据更新的根本原因,更新视图的代码会有细微的差别。

然后他们想,当在视图中描述模型的所有代码都已经存在时,为什么我们还需要处理所有这些呢?我们不是通过添加越来越小的代码片段来管理转换来复制代码吗?为什么我们不能使用模板(也就是视图)本身来管理状态变化?

从那时起,他们开始考虑构建一些声明性的东西,而不是 ?? 命令性的东西。

宣言的

React 视图是声明性的。这实际上意味着,作为程序员,您不必担心管理视图状态或数据变化的影响。换句话说,您不必担心视图状态的变化导致的 DOM 转换或突变。声明性使得视图一致、可预测、更容易维护、更容易理解。处理过渡是别人的问题。

这是如何工作的?让我们比较一下 React 和传统方法(比如使用 jQuery)的工作原理。

给定数据,React 组件声明视图的外观。当数据发生变化时,如果您习惯了 jQuery 的工作方式,通常会进行一些 DOM 操作。例如,如果在一个表中插入了一个新行,您可以创建 DOM 元素并使用 jQuery 插入它。但不是在 React。你什么都不做!React 库计算出新视图的外观并呈现出来。

这样不会太慢吗?它不会导致每次数据更改时刷新整个屏幕吗?React 使用其虚拟 DOM 技术来处理这个问题。您声明视图的外观,React 用它构建一个虚拟表示,一个内存中的数据结构。我将在第二章中讨论更多,但是现在,只要把虚拟 DOM 看作一种中间表示,介于 HTML 和实际 DOM 之间。

当事情发生变化时,React 会基于新的事实(状态)构建一个新的虚拟 DOM,并将其与旧的(事情发生变化之前的)虚拟 DOM 进行比较。React 然后计算旧的和更改后的虚拟 DOM 之间的差异,然后将这些更改应用到实际的 DOM。

与用 jQuery 方式执行的手动更新相比,这只会增加很少的开销,因为计算虚拟 DOM 中差异的算法已经得到了最大程度的优化。因此,我们可以两全其美:不必担心实现转换,也不必担心最小变化的性能。

基于组件的

React 的基本构建块是一个维护自身状态并呈现自身的组件。

在 React 中,您所做的只是构建组件。然后,将组件放在一起,组成另一个组件来描述一个完整的视图或页面。组件封装了数据和视图的状态,或者它是如何呈现的。这使得整个应用的编写和推理变得更加容易,因为它被分割成多个组件,并且一次只关注一件事情。

组件通过以只读属性的形式将状态信息共享给它们的子组件,并通过回调它们的父组件来相互通信。我将在后面的章节中更深入地探讨这个概念,但是它的要点是 React 中的组件是非常内聚的,但是彼此之间的耦合是最小的。

没有模板

许多 web 应用框架依赖模板来自动创建重复的 HTML 或 DOM 元素。这些框架中的模板语言是开发人员必须学习和练习的。不是在 React。

React 使用一种全功能编程语言来构造重复的或有条件的 DOM 元素。这种语言正是 JavaScript。例如,当你想构造一个表格时,你可以用 JavaScript 写一个for(...)循环或者使用Arraymap()函数。

有一种中间语言来表示虚拟 DOM,那就是 JSX(JavaScript XML 的缩写),它非常像 HTML。这允许您用熟悉的语言创建嵌套的 DOM 元素,而不是使用 JavaScript 函数手工构造它们。注意,JSX 不是一种编程语言;它是一种像 HTML 一样的表示性标记。它也非常类似于 HTML,所以你不必学太多。稍后会详细介绍。

事实上,您不必使用 JSX——如果您愿意,您可以编写纯 JavaScript 来创建虚拟 DOM。但是如果你习惯于 HTML,用 JSX 会更简单。不过不用担心;这真的不是一门你需要学习的新语言。

同形的

React 也可以在服务器上运行。这就是同构的意思:相同的代码可以在服务器和浏览器上运行。这允许您在需要时在服务器上创建页面,例如,出于 SEO 目的。稍后我会在第十二章中更详细地讨论这是如何工作的,这一章是关于服务器端渲染的。但是为了能够在服务器上运行 React 代码,我们确实需要能够运行 JavaScript 的东西,这就是我介绍 Node.js 的地方。

Node.js

简单来说,Node.js 就是浏览器之外的 JavaScript。Node.js 的创建者只是采用了 Chrome 的 V8 JavaScript 引擎,并将其作为 JavaScript 运行时独立运行。如果您熟悉运行 Java 程序的 Java 运行时,您可以很容易地联想到 JavaScript 运行时:Node.js 运行时运行 JavaScript 程序。

虽然你可能会发现有人认为 Node.js 不适合生产使用,声称它是为浏览器设计的,但也有许多行业领导者选择了 Node.js。网飞、优步和 LinkedIn 是在生产中使用 Node.js 的几家公司,这应该可以作为一个健壮和可扩展的环境来运行任何应用的后端。

Node.js 模块

在浏览器中,您可以加载多个 JavaScript 文件,但是您需要一个 HTML 页面来完成所有这些工作。不能从一个 JavaScript 文件引用另一个 JavaScript 文件。但是对于 Node.js,没有 HTML 页面来启动这一切。在没有封装的 HTML 页面的情况下,Node.js 使用自己的基于 CommonJS 的模块系统将多个 JavaScript 文件放在一起。

模块就像图书馆。您可以通过使用require关键字(在浏览器的 JavaScript 中找不到)来包含另一个 JavaScript 文件的功能(假设它是按照模块的规范编写的)。因此,为了更好地组织,您可以将代码分成文件或模块,并使用require加载它们。我将在后面的章节中讨论确切的语法;在这一点上,需要注意的是,与浏览器上的 JavaScript 相比,使用 Node.js 有一种更简洁的模块化代码的方法。

Node.js 附带了许多编译成二进制文件的核心模块。这些模块提供对诸如文件系统、网络、输入/输出等操作系统元素的访问。它们还提供了一些大多数程序通常需要的实用函数。

除了您自己的文件和核心模块之外,您还可以找到大量第三方开源库,以便于安装。这就把我们带到了 npm。

Node.js 和 npm

npm 是 Node.js 的默认包管理器。您可以使用 npm 安装第三方库(包)并管理它们之间的依赖关系。npm 注册中心( www.npmjs.com )是人们出于共享目的发布的所有模块的公共存储库。

尽管 npm 最初是作为 Node.js 模块的存储库,但它很快转变为一个包管理器,用于交付其他基于 JavaScript 的模块,特别是那些可以在浏览器中使用的模块。jQuery 是目前最流行的客户端 JavaScript 库,它是一个 npm 模块。事实上,尽管 React 很大程度上是客户端代码,可以作为脚本文件直接包含在 HTML 中,但还是建议通过 npm 安装 React。但是一旦它作为一个包被安装,我们需要一些东西把所有的代码放在一起,这些代码可以包含在 HTML 中,这样浏览器就可以访问这些代码。为此,有一些构建工具,如 Browserify 或 Webpack,可以将您自己的模块以及第三方库放在一个包中,该包可以包含在 HTML 中。

在撰写本书时,npm 在模块或包存储库中名列前茅,拥有超过 450,000 个包(见图 1-1 )。Maven,两年前曾经是最大的,现在只有不到一半的数量。这表明 npm 不仅是最大的,而且是增长最快的存储库。人们常说 Node.js 的成功很大程度上归功于 npm 和围绕它涌现的模块生态系统。

img/426054_2_En_1_Chapter/426054_2_En_1_Fig1_HTML.jpg

图 1-1

各种语言的模块数量(来源: www.modulecounts.com )

npm 不仅易于创建和使用模块;它还有一个独特的冲突解决技术,允许一个模块的多个冲突版本并存,以满足依赖性。因此,在大多数情况下,npm 只是工作。

Node.js 是事件驱动的

Node.js 有一个异步的、事件驱动的、非阻塞的输入/输出(I/O)模型,而不是使用线程来实现多任务。

大多数其他语言都依赖线程来同时处理事情。但事实上,当单个处理器运行您的代码时,并不存在并发这种情况。线程给人一种同时性的感觉,让其他代码段运行,而一个代码段等待(阻塞)某个事件完成。通常,这些是 I/O 事件,例如从文件中读取或通过网络进行通信。例如,在一行中,您调用打开一个文件,在下一行中,您已经准备好了文件句柄。真正发生的是,当文件被打开时,你的代码被阻塞(什么也不做)。如果有另一个线程正在运行,操作系统或语言将会切换出这段代码,并在阻塞期间开始运行其他代码。

另一方面,Node.js 没有线程。它依靠回调来让你知道一个挂起的任务已经完成。因此,如果您编写一行代码来打开一个文件,您可以为它提供一个回调函数来接收结果—文件句柄。在下一行,您继续做其他不需要文件句柄的事情。如果你习惯了异步 Ajax 调用,你会立刻明白我的意思。由于 JavaScript 的底层语言结构,如闭包,事件驱动编程对 Node.js 来说是很自然的。

Node.js 使用一个事件循环来实现多任务。这只是一个需要处理的事件队列,以及对这些事件运行的回调。在前面的例子中,准备读取的文件将是一个事件,它将触发您在打开它时提供的回调。如果你不完全明白这一点,也不用担心。本书其余部分的例子应该会让你对它真正的工作原理感到舒服。

一方面,基于事件的方法使 Node.js 应用变得更快,并让程序员能够愉快地忘记用于同步多线程事件的信号量和锁。另一方面,编写本质上异步的代码需要一些学习和实践。

表达

Node.js 只是一个可以运行 JavaScript 的运行时环境。直接在 Node.js 上手工编写一个成熟的 web 服务器并不容易,也没有必要。Express 是一个简化编写服务器代码任务的框架。

Express 框架允许您定义路由,即当符合特定模式的 HTTP 请求到达时该做什么的规范。匹配规范是基于正则表达式(regex)的,非常灵活,就像大多数其他 web 应用框架一样。“做什么”部分只是一个函数,它被提供给解析后的 HTTP 请求。

Express 为您解析请求 URL、头和参数。在响应方面,正如预期的那样,它具有 web 应用所需的所有功能。这包括确定响应代码、设置 cookies、发送自定义标头等。此外,您可以编写 Express 中间件,即可以插入到任何请求/响应处理路径中的定制代码片段,以实现常见的功能,如日志记录、身份验证等。

Express 没有内置的模板引擎,但是它支持你选择的任何模板引擎,比如 pug、mustache 等。但是,对于 SPA,您不需要使用服务器端模板引擎。这是因为所有动态内容的生成都是在客户端完成的,而 web 服务器只通过 API 调用提供静态文件和数据。

总之,Express 是一个针对 Node.js 的 web 服务器框架。就您可以使用它实现的功能而言,它与许多其他 web 服务器框架没有太大的不同。

MongoDB

MongoDB 是 MERN 堆栈中使用的数据库。这是一个面向 NoSQL 文档的数据库,具有灵活的模式和基于 JSON 的查询语言。不仅许多现代公司(包括脸书和谷歌)在生产中使用 MongoDB,一些历史悠久的公司如 SAP 和苏格兰皇家银行也采用了 MongoDB。

我将在这里讨论 MongoDB 存在(和不存在)的几个问题。

NoSQL

NoSQL 代表“非关系型”,不管这个首字母缩略词扩展成什么。它本质上是而不是一个传统的数据库,在那里你有列和行的表,它们之间有严格的关系。我发现 NoSQL 数据库有两个区别于传统数据库的特征。

首先是它们通过将负载分布在多台服务器上进行水平扩展的能力。他们这样做是牺牲了传统数据库的一个重要方面:强一致性。也就是说,副本之间的数据不一定在很短的时间内保持一致。更多信息,请阅读“上限定理”( https://en.wikipedia.org/wiki/CAP_theorem )。但实际上,很少有应用需要 web 规模,NoSQL 数据库的这一方面也很少发挥作用。

第二,也是我认为更重要的一点,NoSQL 数据库不一定是关系数据库。你不必用表格的行和列来考虑你的对象。应用(对象)和磁盘(表格中的行)中的表示之间的差异有时被称为阻抗不匹配。这是一个从电气工程借来的术语,大概意思是,我们说的不是同一种语言。由于阻抗不匹配,我们必须使用一个层来转换或映射对象和关系。这些层被称为对象关系映射(ORM)层。

相反,在 MongoDB 中,您可以像在应用代码中一样看待持久化数据,即作为对象或文档。这有助于您避开 ORM 层,像在应用的内存中一样自然地考虑持久数据。

面向文档

与数据以关系或表的形式存储的关系数据库相比,MongoDB 是一个面向文档的数据库。存储单位(相当于一行)是一个文档,或者一个对象,多个文档存储在集合(相当于一张表)。集合中的每个文档都有一个惟一的标识符,使用这个标识符可以访问它。标识符被自动索引。

想象一下发票的存储结构,包括客户姓名、地址等。以及发票中的项目(行)列表。如果您必须将它存储在一个关系数据库中,您将使用两个表,比如说,invoiceinvoice_lines,其中的行或项目通过一个外键关系引用发票。在 MongoDB 中并非如此。您可以将整个发票存储为单个文档,获取它,并在原子操作中更新它。这不仅适用于发票中的行项目。文档可以是任何深度嵌套的对象。

现代关系数据库已经开始通过允许数组字段和 JSON 字段来支持一级嵌套,但它与真正的文档数据库并不相同。MongoDB 能够对深度嵌套的字段进行索引,这是关系数据库所不能做到的。

不利的一面是数据以非规范化的方式存储。这意味着数据有时会重复,需要更多的存储空间。此外,像重命名主(目录)条目名称这样的事情将意味着遍历数据库并更新所有重复数据。但话说回来,如今存储变得相对便宜,重命名主条目是罕见的操作。

无模式

在 MongoDB 数据库中存储对象并不一定要遵循规定的模式。集合中的所有文档不需要有相同的字段集。

这意味着,尤其是在开发的早期阶段,您不需要在模式中添加/重命名列。您可以在应用代码中快速添加字段,而不必担心数据库迁移脚本。乍一看,这似乎是一件好事,但实际上它所做的只是将数据完整性的责任从数据库转移到您的应用代码上。我发现在更大的团队和更稳定的产品中,最好有一个严格或半严格的模式。使用像 mongoose 这样的对象文档映射库(本书没有涉及)可以缓解这个问题。

基于 JavaScript

MongoDB 的语言是 JavaScript。

对于关系数据库,我们有一种叫做 SQL 的查询语言。对于 MongoDB,查询语言是基于 JSON 的。通过在 JSON 对象中指定操作,可以创建、搜索、修改和删除文档。查询语言不像英语(你不用SELECT或说WHERE),因此更容易以编程方式构建。

数据也以 JSON 格式交换。事实上,为了有效地利用空间,数据被原生存储在一个叫做 BSON 的 JSON 变体中(其中 B 代表二进制)。当您从集合中检索文档时,它作为 JSON 对象返回。

MongoDB 附带了一个构建在 Node.js 等 JavaScript 运行时之上的 shell,这意味着您拥有了一个强大且熟悉的脚本语言(JavaScript)来通过命令行与数据库进行交互。您还可以用 JavaScript 编写代码片段,这些代码片段可以保存并在服务器上运行(相当于存储过程)。

工具和库

如果不使用工具来帮助您,很难构建任何 web 应用。下面简单介绍一下除了 MERN 堆栈组件之外的其他工具,我们将使用它们来开发本书中的示例应用。

React 路由

React 只为我们提供了视图呈现功能,并有助于管理单个组件中的交互。当涉及到在组件的不同视图之间转换并保持浏览器 URL 与视图的当前状态同步时,我们需要更多的东西。

这种管理 URL 和历史的能力被称为路由。它类似于 Express 所做的服务器端路由:解析一个 URL,并根据其组成部分,将一段代码与该 URL 相关联。React-Router 不仅可以做到这一点,还可以管理浏览器的Back按钮功能,这样我们就可以在看似页面的内容之间进行转换,而无需从服务器加载整个页面。我们可以自己构建这个,但是 React-Router 是一个非常易用的库,它为我们管理这个。

ReactBootstrap

Bootstrap 是最流行的 CSS 框架,已经被改编为 React,该项目被称为 React-Bootstrap。这个库不仅为我们提供了大部分的 Bootstrap 功能,而且这个库提供的组件和部件也为我们提供了大量关于如何设计自己的部件和组件的信息。

还有其他为 React 构建的组件/CSS 库(如 Material-UI、MUI、Elemental UI 等。)和单个组件(如 react-select、react-treeview 和 react-date-picker)。所有这些都是很好的选择,取决于你想要达到的目标。但是我发现 React-Bootstrap 是最全面的单个库,并且熟悉 Bootstrap(我想大多数人已经知道了)。

网页包

当涉及到模块化代码时,这个工具是必不可少的。还有其他竞争工具,如 Bower 和 Browserify,它们也服务于模块化和捆绑所有客户端代码的目的,但是我发现 Webpack 更容易使用,并且不需要其他工具(如 gulp 或 grunt)来管理构建过程。

我们将使用 Webpack,不仅将客户端代码模块化并构建成一个包以交付给浏览器,还将“编译”一些代码。我们需要编译步骤来从用 JSX 编写的 React 代码生成纯 JavaScript。

其他图书馆

很多时候,我们会觉得需要一个库来解决我们所有人都会面临的一个看似常见的问题。在本书中,我们将使用 body-parser(以 JSON 或表单数据的形式解析 POST 数据)和 ESLint(用于确保我们的代码遵循约定)等库,所有这些都在服务器端,还有一些类似 react-select 的库在客户端。

其他流行的图书馆

尽管我们不会将这些库作为本书的一部分使用,但是一些非常受欢迎的 MERN 堆栈的补充,并且一起使用的有:

  • 这是一个状态管理库,也结合了 Flux 编程模式。它通常用在大型项目中,即使对于单个屏幕,管理状态也变得复杂。

  • mongose:如果你熟悉对象关系映射层,你可能会发现 mongose 有些类似。这个库在 MongoDB 数据库层上增加了一个抽象层次,让开发人员可以像这样查看对象。在处理 MongoDB 数据库时,该库还提供了其他有用的便利。

  • Jest :这是一个测试库,可以用来轻松测试 React 应用。

版本

尽管 MERN 堆栈目前是一个健壮的、可用于生产的堆栈,但它的每个组件都在快速改进。在写这本书的时候,我已经使用了所有可用工具和库的最新版本。

但是毫无疑问,当你读到这本书的时候,很多东西都已经改变了,最新的版本将和我写这本书的时候用的不一样。因此,在安装任何软件包的说明中,我都包括了软件包的主要版本。

注意

为了避免软件包变化带来的意外,请安装与书中提到的软件包相同的主要版本,而不是最新版本的软件包。

较小的版本变化应该是向后兼容的。例如,如果这本书使用了软件包的 4.1.0 版本,并且在安装时您获得了最新的 as 4.2.0,那么代码应该可以工作而无需任何更改。但是有很小的可能性,软件包的维护者在一个小版本升级中错误地引入了一个不兼容的变化。因此,如果您发现尽管从 GitHub 库复制粘贴了代码,但还是有问题,作为最后的手段,切换到书中使用的精确的版本,可以在库的根目录下的package.json文件中找到。

表 1-1 列出了作为本书一部分的重要工具和库的主要版本。

表 1-1

各种工具和库的版本

|

成分

|

主要版本

|

评论

|
| --- | --- | --- |
| Node.js | Ten | 这是一个 LTS(长期支持)版本 |
| 表达 | four | - |
| MongoDB | Three point six | 社区版 |
| React | Sixteen | - |
| React 路由 | four | - |
| ReactBootstrap | Zero point three two | 现在还没有 1.0 版本,1.0 可能会有问题 |
| Bootstrap 程序 | three | 这与 React Bootstrap 程序 0(以及释放时的 1)兼容 |
| 网页包 | four | - |
| 斯洛文尼亚语 | five | - |
| 巴比伦式的城市 | seven | - |

请注意,JavaScript 规范本身有许多版本,对各种版本和特性的支持因浏览器和 Node.js 而异。在本书中,我们将使用用于编译 JavaScript 的工具所支持的所有新特性,以达到最低的公分母:ES5。这组功能包括 ES2015 (ECMAScript 2015)、ES2016 和 ES2017 中的 JavaScript 功能。这些统称为 ES2015+。

为什么是 MERN?

现在,您对 MERN 堆栈及其组成有了一个大致的了解。但是它真的远远优于其他栈吗,比如说 LAMP,MEAN 等等。?无论如何,这些栈中的任何一个对于大多数现代 web 应用来说都足够好了。总而言之,熟悉是软件生产率的关键,所以我不建议一个 MERN 初学者盲目地在 MERN 上开始他们的新项目,尤其是如果他们有一个积极的截止日期。我建议他们选择他们已经熟悉的堆栈。

但是 MERN 确实有它特殊的地方。它非常适合前端内置大量交互性的 web 应用。回过头来再读一遍“为什么脸书建造 React”这一节,它会给你一些启发。你也许可以用其他的栈来达到同样的效果,但是你会发现用 MERN 来做是最方便的。所以,如果你有选择的余地,并且有时间熟悉一下,你会发现 MERN 是个不错的选择。我将谈谈我喜欢 MERN 的几件事,这些可能有助于你做出决定。

JavaScript 无处不在

我最喜欢 MERN 的一点是,那里到处都使用单一的语言。我们对客户端代码和服务器端代码都使用 JavaScript。即使你有数据库脚本(在 MongoDB 中),你也是用 JavaScript 写的。所以,你唯一需要了解和熟悉的语言是 JavaScript。

后端的所有其他基于 MongoDB 和 Node.js 的栈都是如此,尤其是 MEAN 栈。但是让 MERN 技术栈脱颖而出的是,你甚至不需要知道一种生成页面的模板语言。在 React 方式中,以编程方式生成 HTML(实际上是 DOM 元素)的方式是使用 JavaScript。因此,您不仅避免了学习一门新语言,还获得了 JavaScript 的全部功能。这与模板语言不同,模板语言有其自身的局限性。当然,你需要了解 HTML 和 CSS,但是它们不是编程语言,你无法避免学习 HTML 和 CSS(不仅仅是标记,还有范例和结构)。

除了在编写客户端和服务器端代码时不必切换上下文的明显优势之外,跨层使用单一语言还可以让您在这些层之间共享代码。我能想到执行业务逻辑、进行验证等功能。可以分享的东西。它们需要在客户端运行,以便更好地响应用户输入,从而获得更好的用户体验。它们还需要在服务器端运行,以保护数据模型。

JSON 无处不在

当使用 MERN 堆栈时,对象表示在任何地方都是 JSON (JavaScript 对象表示法)——在数据库中,在应用服务器上,在客户机上,甚至在网络上。

我发现这通常在转换方面为我节省了很多麻烦。没有对象关系映射(ORM),不必强制将对象模型放入行和列,没有特殊的序列化和反序列化代码。像 mongoose 这样的对象文档映射器(ODM)可能有助于实施一个模式,并使事情变得更加简单,但是底线是您可以节省大量的数据转换代码。

此外,它只是让我从本地对象的角度来考虑,甚至在使用 shell 直接检查数据库时,也将它们视为自然的自我。

Node.js 性能

由于其事件驱动的架构和非阻塞 I/O,Node.js 被认为是非常快速和有弹性的 web 服务器。

虽然需要一点时间来适应,但我毫不怀疑当您的应用开始扩展并接收大量流量时,这将在削减成本和节省花费在解决服务器 CPU 和 I/O 问题上的时间方面发挥重要作用。

国家预防机制生态系统

我已经讨论了大量可供每个人免费使用的 npm 包。您面临的大多数问题都已经有了一个 npm 包作为解决方案。即使不完全符合你的需求,你也可以叉出来,自己做 npm 包。

npm 是在其他优秀的包管理人员的基础上发展起来的,因此它包含了许多最佳实践。我发现 npm 是迄今为止我用过的最容易使用和最快的包管理器。部分原因是由于 JavaScript 代码的紧凑性,大多数 npm 包都很小。

同形的

SPAs 曾经有过 SEO 不友好的问题,因为搜索引擎不会调用 Ajax 来获取数据或运行 JavaScript 代码来呈现页面。人们不得不使用变通方法,比如在服务器上运行 PhantomJS 来伪生成 HTML 页面,或者使用 Prerender.io 服务来为我们做同样的事情。这增加了复杂性。

有了 MERN 堆栈,在服务器之外提供完整的页面是很自然的事情,不需要事后才想到的工具。这是可能的,因为 React 运行在 JavaScript 上,这在客户端或服务器上都是一样的。当基于 React 的代码在浏览器上运行时,它从服务器获取数据,并在浏览器中构造页面(DOM)。这是呈现 UI 的 SPA 方式。如果我们想在服务器上为搜索引擎机器人生成相同的页面,可以使用相同的基于 React 的代码从 API 服务器获取数据,构建页面(这次是 HTML)并将其返回给客户端。这被称为服务器端渲染 (SSR)。

图 1-2 比较了这两种操作模式。使这成为可能的事实是,在服务器和浏览器中使用相同的语言来运行 UI 构造代码:JavaScript。这就是术语同构的含义:相同的代码可以在浏览器或服务器上运行。我们将在第十二章中深入讨论 SSR 及其工作原理。在这一点上,足以理解的是,相同的代码可以在浏览器和客户端上运行,以实现两种不同的操作模式。

img/426054_2_En_1_Chapter/426054_2_En_1_Fig2_HTML.jpg

图 1-2

SPA 做事方式和使用 React 的服务器端渲染的比较

事实上,React Native 将它推向了另一个极端:它甚至可以生成移动应用的 UI。我没有在本书中介绍 React Native,但是这个事实应该会让您对 React 是如何构造的以及它将来能为您做什么有所了解。

不是框架!

没有多少人喜欢或欣赏这一点,但我真的很喜欢 React 是一个库,而不是一个框架。

一个框架是固执己见的;它有一套做事的方法。这个框架要求你填写它认为我们所有人都想完成的事情。另一方面,库为您提供了构建应用的工具。从短期来看,一个框架帮助很大,因为它去掉了大部分标准的东西。但是随着时间的推移,框架的变幻莫测,它对我们想要完成的事情的假设,以及学习曲线会让你希望你能对正在发生的事情有所控制,特别是当你有一些特殊的需求时。

有了函数库,有经验的架构师可以完全自由地设计自己的应用,从函数库的功能中挑选,并构建自己的框架,以满足应用的独特需求和变化。因此,对于有经验的架构师或非常独特的应用需求,库更好,尽管框架可以让您快速入门。

摘要

这本书让你体验使用 MERN 堆栈开发一个应用需要什么,是什么样子。

在这一章中,我们讨论了使 MERN 成为任何 web 应用的令人信服的选择的原因,其中包括单一编程语言在整个堆栈中的优势、NoSQL 数据库的特性以及 React 的同构。我希望这些理由能够说服您尝试 MERN 堆栈,如果不采用它的话。

这本书鼓励你去做,去思考,去实验,而不仅仅是阅读。因此,请记住以下提示,以便您能从本书中获得最大收益:

  • 避免从书中或 GitHub 库中复制粘贴代码。相反,你可以自己输入代码。只有当你陷入困境,发现事情并不像预期的那样工作时,才求助于复制粘贴。

  • 使用 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )查看代码列表和变更;因为 GitHub 显示差异的方式,所以更方便。

  • 不要依赖书中代码列表的准确性,而是依赖 GitHub 库中的代码。如果你不得不复制粘贴,那么从 GitHub 库开始,而不是从书上。

  • 使用本书中使用的相同版本的包和工具,而不是最新版本。最新版本和本书中的版本可能会有差异,这可能会导致问题的出现。

  • 不要跳过练习:这些练习旨在让你思考并了解到哪里去寻找更多的资源。

最后,我希望您对了解 MERN 堆栈感到非常兴奋。因此,我们将在下一章直接进入代码,创建最基本的应用:Hello World 应用。

二、你好世界

按照惯例,我们将从 Hello World 应用开始,这是一个最简单的应用,使用了大部分 MERN 组件。任何 Hello World 的主要目的都是展示我们正在使用的技术或堆栈的基本特征,以及启动和运行它所需的工具。

在这个 Hello World 应用中,我们将使用 React 呈现一个简单的页面,并使用 Node.js 和 Express 从 web 服务器提供该页面。这将让你学习这些技术的基本原理。这也将让您对 nvm、npm 和 JSX 变换有一些基本的了解——一些我们将会经常用到的工具。

无服务器 Hello World

为了快速起步,让我们在一个 HTML 文件中编写一段简单的代码,使用 React 在浏览器上显示一个简单的页面。没有安装,下载,或服务器!你所需要的是一个现代的浏览器,可以运行我们编写的代码。

让我们开始创建这个 HTML 文件,并将其命名为index.html。您可以使用您最喜欢的编辑器,将这个文件保存在文件系统的任何地方。让我们从基本的 HTML 标签开始,比如<html><head><body>。然后,让我们包括 React 库。

毫不奇怪,React 库是一个 JavaScript 文件,我们可以使用<script>标签将它包含在 HTML 文件中。它由两部分组成:第一部分是 React 核心模块,负责处理 React 组件及其状态操作等。第二个是 ReactDOM 模块,它处理将 React 组件转换成浏览器可以理解的 DOM。这两个库可以在 unpkg 中找到,un pkg 是一个内容交付网络(CDN ),它使得所有开源 JavaScript 库都可以在线使用。让我们使用来自以下 URL 的库的开发(相对于生产)版本:

这两个脚本可以包含在<head>部分,使用如下的<script>标签:

...
  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
...

接下来,在主体中,让我们创建一个<div>,它将最终保存我们将创建的任何 React 元素。这可以是一个空的<div>,但是它需要一个 ID,比如说content,来识别和获取 JavaScript 代码中的句柄。

...
  <div id="content"></div>
...

要创建 React 元素,需要调用 React 模块的createElement()函数。这非常类似于 JavaScript document.createElement()函数,但是有一个额外的特性,允许嵌套元素。该函数最多接受三个参数,其原型如下:

React.createElement(type, [props], [...children])

类型可以是任何 HTML 标签,比如字符串'div',或者 React 组件(我们将在下一章开始创建)。props是包含 HTML 属性或自定义组件属性的对象。最后一个参数是零个或多个子元素,也是使用createElement()函数本身创建的。

对于 Hello World 应用,让我们创建一个非常简单的嵌套元素——一个带有 title 属性的<div>(只是为了展示属性是如何工作的),它包含一个带有“Hello World!”字样的标题下面是用于创建我们的第一个 React 元素的 JavaScript 代码片段,该元素将放在主体的<script>标记中:

...
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );
...

注意

我们在本书中使用了 es 2015+JavaScript 特性,在这个片段中,我们使用了const关键字。这应该可以在所有现代浏览器中正常工作。如果你使用的是旧版浏览器,比如 Internet Explorer 10,你需要将const改为var。在本章的最后,我们将讨论如何支持旧的浏览器,但在此之前,请使用一种现代浏览器进行测试。

React 元素(React.createElement()调用的结果)是一个 JavaScript 对象,表示屏幕上显示的内容。因为它可以是其他元素的嵌套集合,并且可以描述整个屏幕上的一切,所以它也被称为虚拟 DOM 。请注意,这还不是真正的 DOM,它在浏览器的内存中,这就是它被称为虚拟 DOM 的原因。它作为一组嵌套很深的 React 元素驻留在 JavaScript 引擎的内存中,这些元素也是 JavaScript 对象。React 元素不仅包含需要创建哪些 DOM 元素的细节,还包含一些有助于优化的关于树的附加信息。

这些 React 元素中的每一个都需要被转移到真实的 DOM 中,以便在屏幕上构建用户界面。为此,需要对应于每个 React 元素进行一系列的document.createElement()调用。当调用ReactDOM.render()函数时,ReactDOM 会这样做。该函数将需要呈现的元素和需要放置的 DOM 元素作为参数。

我们已经使用React.createElement()构建了需要呈现的元素。至于包含元素,我们在主体中创建了一个<div>,它是新元素需要放置的目标。我们可以通过调用document.getElementByID()来获得父进程的句柄,就像我们使用普通的 JavaScript 一样。让我们这样做,并呈现 Hello World React 元素:

...
    ReactDOM.render(element, document.getElementById('content'));
...

让我们把这些都放在index.html里。该文件的内容如清单 2-1 所示。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script>
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );

    ReactDOM.render(element, document.getElementById('content'));
  </script>
</body>

</html>

Listing 2-1index.html: Server-less Hello World

您可以通过在浏览器中打开该文件来测试它。加载 React 库可能需要几秒钟的时间,但是很快你就会看到浏览器显示标题,如图 2-1 所示。您还应该能够将鼠标悬停在文本上或外部 div 边界内文本右侧的任何地方,并且应该能够看到工具提示“外部 div”弹出。

img/426054_2_En_2_Chapter/426054_2_En_2_Fig1_HTML.jpg

图 2-1

用 React 写的 Hello World

练习:无服务器 Hello World

  1. 尝试向h1元素添加一个类(您还需要在<head>部分的<style>部分中定义该类,以测试它是否工作)。提示:在 stackoverflow.com 搜索“如何在 jsx 中指定类”。你能解释这个吗?

  2. 检查开发人员控制台中的element变量。你看到了什么?如果你给这棵树起个名字,你会给它起什么名字?

本章末尾有答案。

小艾

我们在上一节中创建的简单元素很容易使用React.createElement()调用来编写。但是想象一下,编写一个深度嵌套的元素和组件层次结构:它会变得非常复杂。此外,当使用函数调用时,实际的 DOM 不容易可视化,因为如果它是普通的 HTML,它是可以可视化的。

为了解决这个问题,React 有一种叫做 JSX 的标记语言,它代表了 JavaScript XML ??。JSX 看起来非常像 HTML,但也有一些不同之处。因此,代替React.createElement()调用,JSX 可以用来构建一个元素或元素层次结构,使它看起来非常像 HTML。对于我们创建的简单的 Hello World 元素,事实上,HTML 和 JSX 之间没有区别。所以,让我们把它写成 HTML 并把它赋给元素,替换掉React.CreateElement()调用:

...
    const element = (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );
...

注意,尽管它惊人地接近 HTML 语法,但它是而不是 HTML。还要注意,标记没有用引号括起来,所以它也不是一个可以用作innerHTML的字符串。它是 JSX,可以和 JavaScript 自由混合。

现在,考虑到与 HTML 相比的所有差异和复杂性,你为什么需要学习 JSX 呢?它增加了什么价值?为什么不直接编写 JavaScript 本身呢?我在导言一章中谈到的一件事是,MERN 自始至终只有一种语言;这不是与那相反吗?

随着我们进一步探索 React,你很快就会发现 HTML 和 JSX 之间的差异并不是翻天覆地的,它们非常符合逻辑。只要你理解并内化了其中的逻辑,你就不需要记很多东西,也不需要查资料。尽管直接编写 JavaScript 来创建虚拟 DOM 元素确实是一种选择,但我发现这非常繁琐,并且不能帮助我可视化 DOM。

此外,由于您可能已经知道基本的 HTML 语法,编写 JSX 可能会更好。当你阅读 JSX 时,很容易理解屏幕会是什么样子,因为它与 HTML 非常相似。因此,在本书的其余部分,我们使用 JSX。

但是浏览器的 JavaScript 引擎不理解 JSX。它必须被转换成常规的基于 JavaScript 的React.createElement()调用。为此,需要一个编译器。做这件事的编译器(事实上还可以做更多)是 Babel。理想情况下,我们应该预编译代码并将其注入到浏览器中,但出于原型设计的目的,Babel 提供了一个可以在浏览器中使用的独立编译器。像往常一样,这是一个 JavaScript 文件,可以在 unpkg 上获得。让我们将这个脚本包含在index.html<head>部分中,如下所示:

...
  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
...

但是编译器也需要被告知哪些脚本必须被转换。它在所有脚本中查找属性type= " text/babel",并转换和运行任何具有该属性的脚本。因此,让我们将这个属性添加到主脚本中,让 Babel 完成它的工作。下面是实现这一点的代码片段:

...
  <script type="text/babel">
...

清单 2-2 显示了使用 JSX 的一整套更改。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script type="text/babel">
    const element = React.createElement('div', {title: 'Outer div'},
      React.createElement('h1', null, 'Hello World!')
    );
    const element = (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );

    ReactDOM.render(element, document.getElementById('contents'));
  </script>
</body>

</html>

Listing 2-2index.html: Changes for Using JSX

注意

虽然我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有出现在书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

当您测试这组更改时,您会发现页面的外观没有什么不同,但是由于 Babel 进行了编译,它可能会稍微慢一点。但别担心。我们将很快切换到在构建时而不是运行时编译 JSX,以消除性能影响。请注意,该代码还不能在较旧的浏览器上运行;您可能会在脚本babel.min.js中得到错误。

文件index.html可以在 GitHub 存储库中的目录public下找到;这是文件最终的位置。

练习:JSX

  1. 从脚本中删除type=``text/babel。当你加载index.html时会发生什么?你能解释一下为什么吗?放回type=``text/babel但是去掉巴别塔 JavaScript 库。现在会发生什么?

  2. 我们用的是缩小版的巴别塔,但不是 React 和 ReactDOM。你能猜到原因吗?切换到生产缩小版本,并在 React 中引入运行时错误(查看 unpkg.com 网站,了解这些库的生产版本的名称)。例如,在内容 Node 的 ID 中引入一个错别字,这样就没有地方安装组件了。会发生什么?

本章末尾有答案。

项目设置

无服务器设置允许您熟悉 React,而无需任何安装或启动服务器。但是您可能已经注意到了,这对开发和生产都没有好处。在开发过程中,需要额外的时间从内容交付网络或 CDN 加载脚本。如果您使用浏览器开发人员控制台的 Network 选项卡查看每个脚本的大小,您会发现 babel 编译器(即使是缩小版)非常大。在生产中,尤其是在大型项目中,JSX 到 JavaScript 的运行时编译会降低页面加载速度并影响用户体验。

所以,让我们稍微组织一下,从 HTTP 服务器提供所有文件。当然,我们将使用 MERN 堆栈的一些其他组件来实现这一点。但是在我们做所有这些之前,让我们设置我们的项目和文件夹,我们将在其中保存文件和安装库。

我们将在 shell 中输入的命令已经被收集在 GitHub 库根目录下的一个名为commands.md的文件中。

注意

如果您在键入命令时发现某些东西不能按预期工作,请在 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )中交叉检查这些命令。这是因为错别字可能是在书的制作过程中引入的,或者最后一刻的更正可能错过了这本书。另一方面,GitHub 库反映了最新的和经过测试的代码和命令。

非易失性存储器

首先,让我们安装 nvm。这代表 Node Version Manager,该工具使 Node.js 的多个版本之间的安装和切换变得容易。Node.js 可以在没有 nvm 的情况下直接安装,但我发现,当我不得不开始一个新项目,并且希望在那个时间点使用 Node.js 的最新和最棒的版本时,一开始安装 nvm 使我的生活变得更容易。与此同时,我不想为我的其他大型项目切换到最新版本,因为害怕破坏这些项目中的东西。

要安装 nvm,如果您使用的是 Mac OS 或任何基于 Linux 的发行版,请遵循 nvm 的 GitHub 页面上的说明。这可以在 https://github.com/creationix/nvm 找到。Windows 用户可以关注 nvm for Windows(在你喜欢的搜索引擎中搜索)或者直接安装 Node.js,不需要 nvm。一般来说,我建议 Windows 用户安装一个 Linux 虚拟机(VM),最好使用 vagger,并在 VM 内完成所有的服务器端编码。这通常效果最好,尤其是因为代码最终几乎总是部署在 Linux 服务器上,拥有相同的开发环境效果最好。

关于 nvm 的一件棘手的事情是知道它如何初始化你的路径。这在不同的操作系统上有不同的工作方式,所以一定要仔细阅读其中的细微差别。本质上,它向您的 shell 的初始化脚本添加了几行,以便您下次打开 shell 时,您的路径被初始化并执行 nvm 的初始化脚本。这让 nvm 知道所安装的 Node.js 的不同版本,以及默认可执行文件的路径。

因此,最好在安装 nvm 后立即启动一个新的 shell,而不是继续安装它。一旦你为你的 nvm 找到了正确的道路,事情就会进展顺利。

你可以选择直接安装 Node.js,不安装 nvm,这也很好。但是本章的其余部分假设您已经安装了 nvm。

Node.js

现在我们已经安装了 nvm,让我们使用 nvm 安装 Node.js。有许多版本的 Node.js 可用(请查看网站, https://nodejs.org ),但出于本书的目的,我们将选择最新的长期支持(LTS),它恰好是 10:

$ nvm install 10

LTS 版本肯定会比其他版本获得更长时间的支持。这意味着,尽管不能指望功能升级,但可以指望向后兼容的安全和性能修复。此外,新的次要版本可以安装,而不必担心破坏现有的代码。

现在我们已经安装了 Node.js,让我们将该版本作为未来的默认版本。

$ nvm alias default 10

否则,下次进入 shell 时,node 将不在路径中,或者我们选择以前安装的默认版本。您可以通过在新的 shell 或终端中键入以下内容来确认默认安装的 node 版本:

$ node --version

此外,一定要确保任何外壳也显示最新版本。(注意,Windows 版本的 nvm 不支持alias命令。每次打开一个新的 shell 时,您可能都必须执行nvm use 10。)

通过 nvm 安装 Node.js 也会安装软件包管理器 npm。如果您直接安装 Node.js,请确保您也安装了兼容版本的 npm。您可以通过记下随 Node.js 一起安装的 npm 版本来确认这一点:

$ npm --version

它应该显示版本 6 的一些内容。npm 可能会提示您有新版本的 npm,并要求您安装该版本。在任何情况下,让我们安装我们希望在本书中使用的 npm 版本,如下指定版本:

$ npm install –g npm@6

请确保您不会错过–g标志。它告诉 npm 全局安装自己,也就是说,对所有项目都可用。要再次检查,再次运行npm --version

*### 项目

在我们用 npm 安装任何第三方包之前,初始化项目是个好主意。有了 npm,甚至一个应用也被认为是一个包。包定义了应用的各种属性。一个重要的属性是应用所依赖的其他包的列表。随着时间的推移,这种情况将会改变,因为随着应用的发展,我们发现需要使用库。

首先,我们至少需要一个占位符来保存和初始化这些东西。让我们创建一个名为pro-mern-stack-2的目录来托管应用。让我们从这个目录中初始化项目,如下所示:

$ npm init

这个命令问你的大多数问题应该很容易回答。默认设置也很好。从现在开始,对于所有 shell 命令,尤其是 npm 命令(我将在下面描述),您应该位于项目目录中。这将确保所有的更改和安装都本地化到项目目录中。

新公共管理

要使用 npm 安装任何东西,要使用的命令是npm install <package>。首先,因为我们需要一个 HTTP 服务器,所以让我们使用 npm 安装 Express。安装 Express 非常简单:

$ npm install express

一旦完成,你会注意到它说安装了许多软件包。这是因为它还会安装 Express 依赖的所有其他软件包。现在,让我们卸载并重新安装一个特定的版本。在本书中,我们使用版本 4,所以让我们在安装时指定该版本。

$ npm uninstall express
$ npm install express@4

注意

安装软件包时,只指定主要版本(在本例中为 4)就足够了。这意味着你可以安装一个次要版本,这个版本与你写这本书时使用的版本不同。在极少数情况下,这会导致问题,请在 GitHub 存储库中的package.json中查找包的具体版本。然后,在安装软件包时指定确切名称,例如npm install express@4.16.4

npm 是非常强大的,它的选择是巨大的。目前,我们只关心软件包的安装和一些其他有用的东西。项目目录下安装文件的位置是 npm 的制作者有意识的选择。这具有以下效果:

  1. 所有安装都位于项目目录的本地。这意味着不同的项目可以使用不同版本的任何已安装的软件包。乍一看,这似乎是不必要的,感觉像是大量的重复。但是,当您启动多个 Node.js 项目,并且不想处理一个不需要的包升级时,您将真正欣赏 npm 的这一特性。此外,您会注意到整个 Express 包(包括所有依赖项)只有 1.8MB。由于包非常小,所以磁盘使用量过大根本不是问题。

  2. 包的依赖项也在包内被隔离。因此,可以安装依赖于一个公共包的不同版本的两个包,并且它们都有自己的副本,因此可以完美地工作。

  3. 安装软件包不需要管理员(超级用户)权限。

当然,有一个全局安装包的选项,有时这样做很有用。一个用例是将命令行实用程序打包为 npm 包。在这种情况下,不管工作目录如何,让命令行可用是非常有用的。在这种情况下,npm install 的–g选项可用于全局安装包,并使其在任何地方都可用。

如果您已经通过 nvm 安装了 Node.js,全局安装将使用您自己的主目录,并使该包可用于您主目录中的所有项目。全局安装软件包不需要超级用户或管理员权限。另一方面,如果您直接安装了 Node.js,使其对您计算机上的所有用户可用,您将需要超级用户或管理员权限。

此时,最好再次查看 GitHub 存储库( https://github.com/vasansr/pro-mern-stack-2 ),尤其是查看与上一步的不同之处。在本节中,我们只添加了新文件,所以您将看到的唯一区别是新文件。

练习:项目设置

  1. package.json是什么时候创作的?如果猜不出来,就考察内容;这应该给你一个提示。还是想不通?回去重新做你的步骤。从创建项目目录开始,在每个步骤中查看目录内容。

  2. 卸载 Express,但使用选项--no-save。现在,只需输入npm install。会发生什么?这次手动添加另一个依赖项,比如 MongoDB 到package.json。使用版本作为“最新”。现在,输入npm install。会发生什么?

  3. 安装任何新软件包时使用--save-dev。你觉得package.json有什么不同?你认为会有什么不同?

  4. 您认为软件包文件安装在哪里?键入npm ls --depth=0检查所有当前安装的软件包。清理所有不需要的包。

试着安装和卸载 npm。这通常是有用的。从文档中了解关于 npm 版本语法的更多信息: https://docs.npmjs.com/files/package.json#dependencies

本章末尾有答案。

注意

虽然签入package.json.lock文件是一个很好的做法,这样安装的确切版本可以在团队成员之间共享,但是我已经将它从存储库中排除了,以保持差异的简洁和可读性。当您使用 MERN 堆栈启动一个团队项目时,您应该在您的 Git 存储库中签入这个文件。

表达

如果您还记得上一章的介绍,Express 是在 Node.js 环境中运行 HTTP 服务器的最佳方式。首先,我们将使用 Express 只服务静态文件。这是为了让我们习惯于 Express 所做的事情,而不用进入大量的服务器端编码。我们将通过 Express 提供我们在上一节中创建的index.html文件。

在上一步中,我们已经安装了 Express,但是为了确保它在那里,让我们执行 npm 命令来再次安装它。如果软件包已经安装,这个命令什么也不做,所以如果有疑问,我们可以再次运行它。

$ npm install express@4

要开始使用 Express,让我们导入模块并使用模块导出的顶级函数,以便实例化一个应用。这可以使用以下代码来完成:

...
const express = require('express');
...

require是 Node.js 特有的 JavaScript 关键字,用于导入其他模块。这个关键字不是浏览器端 JavaScript 的一部分,因为没有包含其他 JavaScript 文件的概念。所有需要的脚本都直接包含在 HTML 文件中。ES2015 规范想出了一种使用import关键字来包含其他文件的方法,但在规范出来之前,Node.js 不得不使用require发明自己的方法。它也被称为包含其他模块的常见方式。

在前一行中,我们加载了名为express的模块,并在名为express的常量中保存了该模块导出的顶层对象。Node.js 允许的东西是一个函数,一个对象,或者任何适合变量的东西。模块输出的类型和形式实际上取决于模块,模块的文档会告诉你如何使用它。

在 Express 的情况下,该模块导出一个可用于实例化应用的函数。我们只是把这个函数赋给了变量express

注意

我们使用 ES2015 const关键字来定义变量express。这使得变量在第一次声明后不可赋值。对于可能被赋予新值的变量,可以用关键字let代替const

Express 应用是监听特定 IP 地址和端口的 web 服务器。可以创建多个应用来监听不同的端口,但是我们不会这样做,因为我们只需要一台服务器。让我们通过调用express()函数来实例化这个唯一的应用:

...
const app = express();
...

现在我们已经有了应用的句柄,让我们来设置它。Express 是一个框架,它自己完成最少的工作;相反,它让名为中间件的功能来完成大部分工作。中间件是一个接受 HTTP 请求和响应对象的函数,加上链中的下一个中间件函数。该函数可以查看和修改请求和响应对象,响应请求,或者通过调用下一个中间件函数来决定继续使用中间件链。

此时,我们需要查看请求并根据请求 URL 的路径返回文件内容的东西。内置的express.static函数生成一个中间件函数来完成这个任务。它通过尝试将请求 URL 与生成器函数的参数指定的目录下的文件进行匹配来响应请求。如果文件存在,它返回文件的内容作为响应,如果不存在,它链接到下一个中间件函数。我们可以这样创建中间件:

...
const fileServerMiddleware = express.static('public');
...

static()函数的参数是中间件应该查找文件的目录,相对于应用运行的位置。对于我们将作为本书的一部分构建的应用,我们将把所有静态文件存储在项目根目录下的public目录中。让我们在项目根目录下创建这个新目录public,并将我们在上一节中创建的index.html移动到这个新目录中。

现在,为了让应用使用静态中间件,我们需要在应用上安装。Express 应用中的中间件可以使用应用的use()方法来挂载。该方法的第一个参数是要匹配的任何 HTTP 请求的基本 URL。第二个论点是中间件功能本身。因此,要使用静态中间件,我们可以这样做:

...
app.use('/', fileServerMiddleware);
...

第一个参数是可选的,如果没有指定,默认为'/',所以我们也可以跳过它。

最后,既然应用已经设置好了,我们需要启动服务器,让它为 HTTP 请求提供服务。应用的listen()方法启动服务器并永远等待请求。它将端口号作为第一个参数。让我们使用端口 3000,一个任意的端口。我们不会使用端口 80,通常的 HTTP 端口,因为要监听该端口,我们需要有管理(超级用户)权限。

listen()方法还接受另一个参数,这是一个可选的回调函数,当服务器成功启动时可以调用它。让我们提供一个匿名函数,它只打印服务器已经启动的消息,如下所示:

...
app.listen(3000, function () {
  console.log('App started on port 3000');
});
...

让我们将所有这些放在项目根目录下的一个名为server.js的文件中。清单 2-3 显示了最终的服务器代码,其中use()调用与中间件的创建合并在一行中,并跳过了可选的第一个参数,即挂载点。

const express = require('express');

const app = express();

app.use(express.static('public'));

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 2-3server.js: Express Server

现在,我们准备启动 web 服务器并为index.html提供服务。如果你在 GitHub 库中寻找代码,你会在一个名为server的目录下找到server.js。但是此时,该文件需要位于项目目录的根目录下。

要启动服务器,在项目的根目录下使用 Node.js 运行时运行它,如下所示:

$ node server.js

您应该会看到一条消息,说明应用已经在端口 3000 上启动。现在,打开你的浏览器,在地址栏输入http://localhost:3000/index.html。您应该会看到我们在上一节中创建的 Hello World 页面。如果你看到一个 404 错误信息,可能你还没有将index.html移动到public目录中。

静态中间件函数服务于来自public目录的index.html文件的内容,因为它匹配请求 URL。但它也足够聪明,可以将请求翻译成/(网站的根目录),并通过在目录中查找index.html来做出响应。这类似于 Apache 等其他静态 web 服务器的做法。因此,只需输入http://localhost:3000/就足以进入 Hello World 页面。

要启动服务器,我们必须向 Node.js 提供入口点的名称(server.js),这可能不容易记住或者告诉项目的其他用户。如果我们的项目中有许多文件,那么人们如何知道哪个文件是启动服务器的呢?幸运的是,所有 Node.js 项目中都使用了一个约定:npm 脚本用于执行常见任务。以下命令行是启动服务器的另一种方法:

$ npm start

执行这个命令时,npm 会查找文件server.js并使用 Node.js 运行它。因此,让我们停止服务器(在命令 shell 中使用 Ctrl+C)并使用npm start重新启动服务器。您应该会看到相同的消息,说明服务器已经启动。

但是如果我们有一个不同的服务器起点呢?事实上,我们希望所有与服务器相关的文件都放在一个名为server的目录中。因此,让我们创建该目录并将server.js移动到该目录中。

现在,如果您运行npm start,它将失败并出现错误。那是因为 npm 在根目录中寻找server.js,没有找到。为了让 npm 知道服务器的入口点是子目录server中的server.js,需要在package.jsonscripts部分添加一个条目。清单 2-4 展示了这些变化。

...
  "main": "index.js",
  "scripts": {
    "start": "node server/server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 2-4package.json: Changes for Start Script

因此,如果服务器起始点不是根目录中的server.js,那么完整的命令行必须在package.jsonscripts部分指定。

注意在package.json中还有一个名为main的字段。当我们初始化这个文件时,这个字段的值被自动设置为index.js。该字段是而不是用于指示服务器的起点。相反,如果这个包是一个模块(相对于一个应用),那么当这个项目在其他项目中使用require()作为一个模块导入时,index.js将会是要加载的文件。由于这个项目不是可以导入到其他项目中的模块,所以这个字段我们没有任何兴趣,源代码中也没有index.js

现在,我们可以使用npm start看到熟悉的应用启动消息,并最后一次测试它。在这个时候,最好检查一下 GitHub 库,看看这一部分的不同之处。特别是,看一下现有文件package.json中的更改,以熟悉如何在书中显示文件中的更改,以及如何在 GitHub 中将相同的更改视为差异。

练习:快速

  1. index.html文件的名称改为其他名称,比如说hello.html。这对应用有什么影响?

  2. 如果您希望所有静态文件都可以通过一个带前缀的 URL 来访问,例如/public,您会做什么改变?提示:在 https://expressjs.com/en/starter/static-files.html 看一下静态文件的 Express 文档。

本章末尾有答案。

单独的脚本文件

在前面的所有章节中,JSX 到 JavaScript 的转换发生在运行时。这是低效的,也是不必要的。相反,让我们将转换转移到开发中的构建阶段,这样我们就可以部署一个随时可用的应用发行版。

作为第一步,我们需要将 JSX 和 JavaScript 从一体化软件index.html中分离出来,并将其称为外部脚本。这样,我们可以将 HTML 保持为纯 HTML,并将所有需要编译的脚本保存在一个单独的文件中。让我们调用这个外部脚本App.jsx并将它放在public目录中,这样就可以从浏览器中引用它为/App.jsx。当然,新脚本文件的内容不会包含<script>标签。并且,在index.html中,让我们将内联脚本替换为对外部源的引用,如下所示:

...
  <script type="text/babel" src="/App.jsx"></script>
...

注意,仍然需要脚本类型text/babel,因为 JSX 编译是在浏览器中使用巴别塔库进行的。新修改的文件列在清单 2-5 和 2-6 中。

<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
</head>

<body>
  <div id="contents"></div>

  <script type="text/babel" src="/App.jsx"></script>
</body>

</html>

Listing 2-5index.html: Separate HTML and JSX

const element = (
  <div title="Outer div">
    <h1>Hello World!</h1>
  </div>
);

ReactDOM.render(element, document.getElementById('contents'));

Listing 2-6App.jsx: JSX Part Separated Out from the HTML

此时,应用应该会像以前一样继续工作。如果您将浏览器指向http://localhost:3000,您应该会看到同样的 Hello World 消息。但是我们只分离了文件;我们没有把变形移到建造时间。JSX 继续通过在浏览器中执行的巴别塔库脚本进行转换。在下一节中,我们将把转换移到构建时间。

JSX 变换

现在让我们创建一个新目录来保存所有的 JSX 文件,这些文件将被转换成普通的 JavaScript 并保存到public文件夹中。让我们称这个目录为src,并将App.jsx移动到这个目录中。

对于转换,我们需要安装一些巴别塔工具。我们需要核心的 Babel 库和命令行界面(CLI)来完成转换。让我们使用以下命令来安装它们:

$ npm install --save-dev @babel/core@7 @babel/cli@7

为了确保 Babel 编译器作为命令行可执行文件可用,让我们尝试在命令行上执行命令babel,并使用--version选项检查安装的版本。由于这不是一个全球安装,巴别塔将不会出现在路径中。我们必须从它的安装位置专门调用它,如下所示:

$ node_modules/.bin/babel --version

这应该会给出与此类似的输出,但是次要版本可能会有所不同,例如,7.2.5 而不是 7.2.3:

7.2.3 (@babel/core 7.2.2)

我们可以使用 npm 的--global(或–g)选项在全球范围内安装@babel/cli。这样,我们就可以访问任何目录中的命令,而不必在路径前面加上前缀。但是正如前面所讨论的,将所有安装保持在项目的本地是一个好的做法。这是为了我们不必处理跨项目的包的版本差异。此外,npm 的最新版本给了我们一个方便的命令叫做npx,它可以解析任何可执行文件的正确的本地路径。该命令仅在 npm 版本 6 及更高版本中可用。让我们使用这个命令来检查 Babel 版本:

$ npx babel --version

接下来,要将 JSX 语法转换成常规 JavaScript,我们需要一个预置(Babel 使用的一种插件)。这是因为 Babel 能够进行许多其他变换(我们将在下一节中介绍),并且许多不属于 Babel 核心库的预设作为不同的包提供。JSX 变换预置就是这样一个叫做preset-react的预置,所以让我们安装它。

$ npm install --save-dev @babel/preset-react@7

现在我们准备将App.jsx转换成纯 JavaScript。babel命令行接受一个输入目录,其中包含适用的源文件和预置,并接受输出目录作为选项。对于 Hello World 应用,源文件在src目录中,所以我们希望转换的输出在public目录中,并且我们希望应用 JSX 转换预置@babel/react。下面是实现这一点的命令行:

$ npx babel src --presets @babel/react --out-dir public

如果您查看输出目录public,您会看到那里有一个名为App.js的新文件。如果您在编辑器中打开该文件,您可以看到 JSX 元素已经被转换为React.createElement()调用。注意,Babel 编译自动为输出文件使用了扩展名.js,这表明它是纯 JavaScript。

现在,我们需要改变index.html中的引用来反映新的扩展,并删除脚本类型规范,因为它是纯 JavaScript。此外,我们不再需要在index.html中加载运行时转换器,因此我们可以摆脱babel-core脚本库规范。这些变化如清单 2-7 所示。

...
  <script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
...
  <body>
    <div id="contents"></div
    <script src="/App.jsx" type="text/babel"></script>
    script src="/App.js"></script>
  </body>
...

Listing 2-7index.html: Change in Script Name and Type

如果您测试这一组更改,您应该看到事情像以前一样工作。为了更好地衡量,你可以使用浏览器的开发者控制台来确保获取的是App.js,而不是App.jsx。开发人员控制台可以在大多数浏览器上找到;您可能需要查看您的浏览器文档,以获得访问它的说明。

练习:JSX 变换

  1. 检查转换输出App.js的内容。你会在public目录中找到它。你看到了什么?

  2. 为什么我们在安装babel-cli的时候用了--save-dev?提示:在 https://docs.npmjs.com/cli/install 阅读用于安装 CLI 命令的 npm 文档。

本章末尾有答案。

旧浏览器支持

我之前提到过,JavaScript 代码将在所有支持 ES2015 的现代浏览器中工作。但是,如果我们需要支持旧的浏览器,例如,Internet Explorer,该怎么办呢?老版本的浏览器不支持箭头功能和Array.from()方法。事实上,在 IE 11 或更早版本中运行此时的代码应该会抛出一个控制台错误消息,说明Object.assign不是一个函数。

让我们对 JavaScript 进行一些更改,并使用这些高级 ES2015 功能。然后,让我们做一些改变,以便在旧的浏览器中也支持所有这些特性。要使用 ES2015 功能,我们不显示包含 Hello World 的消息,而是创建一个大陆数组,并构建一条包含每个大陆的消息。

...
const continents = ['Africa','America','Asia','Australia','Europe'];
...

现在,让我们使用Array.from()方法构造一个新的数组,每个大洲的名称前面有一个 Hello,末尾有一个感叹号。为此,我们将使用数组的map()方法,接受一个箭头函数。我们将使用字符串插值,而不是连接字符串。Array.from()、箭头功能和字符串插值都是 ES2015 的功能。使用新的映射数组,让我们构造消息,它只是加入新数组。下面是代码片段:

...
const helloContinents = Array.from(continents, c => `Hello ${c}!`);
const message = helloContinents.join(' ');
...

现在,让我们在 heading 元素中使用构造的message变量,而不是硬编码的 Hello World 消息。与使用反勾号的 ES2015 字符串插值类似,JSX 让我们通过将 JavaScript 表达式括在花括号中来使用它。这些将被表达式的值替换。这不仅适用于 HTML 文本 Node,也适用于属性。例如,元素的类名可以是 JavaScript 变量。让我们使用这个特性来设置要在标题中显示的消息。

...
    <h1>{message}</h1>
...

修改后的App.jsx的完整源代码如清单 2-8 所示。

const continents = ['Africa','America','Asia','Australia','Europe'];
const helloContinents = Array.from(continents, c => `Hello ${c}!`);
const message = helloContinents.join(' ');

const element = (
  <div title="Outer div">
    <h1>{message}</h1>
  </div>
);

ReactDOM.render(element, document.getElementById('contents'));

Listing 2-8App.jsx: Changes to Show the World with ES2015 Features

如果您使用 Babel 转换它,重启服务器并指向它的浏览器。你会发现它可以在大多数现代浏览器上运行。但是,如果您查看转换后的文件App.js,您会发现 JavaScript 本身并没有改变,只有 JSX 被替换为React.createElement()调用。这在既不识别箭头函数语法也不识别Array.from()方法的旧浏览器上肯定会失败。

巴贝尔再次前来救援。我谈到了 Babel 能够进行的其他转换,这包括将较新的 JavaScript 特性转换成较旧的 JavaScript,即 ES5。就像 JSX 变换的react预置一样,每个特性都有一个插件。比如有个插件叫plugin-transform-arrow-functions。我们可以安装这个插件,并在 React 预置之外使用它,如下所示:

$ npm install --no-save @babel/plugin-transform-arrow-functions@7

我们使用了--no-save安装选项,因为这是一个临时安装,我们不希望package.json因为临时安装而改变。让我们使用这个插件并像这样转换源文件:

$ npx babel src --presets @babel/react
--plugins=@babel/plugin-transform-arrow-functions --out-dir public

现在,如果您检查转换的输出,App.js,您将看到箭头函数已经被常规函数所取代。

这很好,但是如何知道哪些插件必须被使用呢?找出哪些浏览器支持什么语法以及我们必须为每种浏览器选择什么转换将是一件很乏味的事情。幸运的是,Babel 通过一个名为preset-env的预置来自动解决这个问题。这个预设让我们指定需要支持的目标浏览器,并自动应用支持这些浏览器所需的所有转换和插件。

所以,让我们卸载transform-arrow-function预置,安装包含所有其他插件的env预置。

$ npm uninstall @babel/plugin-transform-arrow-functions@7
$ npm install --save-dev @babel/preset-env@7

我们不使用命令行(如果使用的话,会很长),而是指定需要在配置文件中使用的预置。Babel 在一个名为.babelrc的文件中寻找这个。事实上,在不同的目录中可以有一个.babelrc文件,该目录中文件的设置可以在每个目录中单独指定。因为我们在名为src的目录中有所有的客户端代码,所以让我们在那个目录中创建这个文件。

.babelrc文件是一个 JSON 文件,它可以包含预置和插件。预设被指定为一个数组。我们可以将这两个预设指定为数组中的字符串,如下所示:

...
{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

预置preset-env需要进一步配置以指定目标浏览器及其版本。这可以通过使用第一个元素作为预设名称后跟其选项的数组来实现。对于preset-env,我们将使用的选项称为targets,它是一个对象,以键作为浏览器名称,以值作为其目标版本:

  ["@babel/preset-env", {
    "targets": {
      "safari": "10",
       ...
     }
  }]

让我们包括对 IE 版本 11 和其他流行浏览器稍旧版本的支持。完整的配置文件如清单 2-9 所示。

{
  "presets": [
    ["@babel/preset-env", {
      "targets": {
        "ie": "11",
        "edge": "15",
        "safari": "10",
        "firefox": "50",
        "chrome": "49"
      }
    }],
    "@babel/preset-react"
  ]
}

Listing 2-9src/.babelrc: Presets Configured for JSX and ES5 Transform

现在,babel命令可以在命令行上不指定任何预置的情况下运行:

$ npx babel src --out-dir public

如果您运行这个命令,然后检查生成的App.js,您会发现箭头函数已经被一个常规函数所取代,字符串插值也已经被一个字符串连接所取代。如果您从配置文件中取出行ie: "11"并重新运行转换,您会发现这些转换不再存在于输出文件中,因为我们的目标浏览器已经本地支持这些特性。

但是,即使进行了这些转换,如果您在 Internet Explorer 版本 11 上测试,代码仍然无法工作。这是因为不仅仅是转变;有一些内置的东西,比如Array.find(),是浏览器中没有的。请注意,再多的编译或转换也不能像Array.find()实现那样添加一堆代码。我们真的需要这些实现作为函数库在运行时可用。

所有这些功能实现都被称为 polyfills ,以补充旧浏览器中缺失的实现。Babel 转换只能处理语法变化,但是需要这些 polyfills 来添加这些新函数的实现。Babel 也提供了这些聚合填充,只需将它们包含在 HTML 文件中就可以使用这些功能。巴别塔多填充可以在 unpkg 中找到,所以让我们把它包含在index.html中。清单 2-10 显示了index.html的变化,包括多孔填料。

...
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
</head>
...

Listing 2-10index.html: Changes for Including Babel Polyfill

现在,代码也可以在 Internet Explorer 上运行。图 2-2 显示了新的 Hello World 屏幕应该是什么样子。

img/426054_2_En_2_Chapter/426054_2_En_2_Fig2_HTML.jpg

图 2-2

新的 Hello World 屏幕

练习:旧版浏览器支持

  1. 通过使用<br />代替空格连接各个消息,尝试将消息格式化为每行一条。你能做到吗?为什么不呢?

本章末尾有答案。

使自动化

除了能够使用npm start启动项目,npm 还能够定义其他定制命令。当该命令有许多命令行参数,并且在 shell 上输入这些参数变得很繁琐时,这一点尤其有用。(我不是说过 npm 很厉害吗?这是它做的事情之一,即使这不是真正的软件包管理器功能。)这些定制命令可以在package.json的脚本部分指定。然后可以从控制台使用npm run <script>运行这些程序。

让我们添加一个名为compile的脚本,它的命令行是 Babel 命令行来完成所有的转换。我们不需要前缀npx,因为 npm 会自动计算出命令的位置,这些命令是任何本地安装包的一部分。我们需要在package.json做的附加工作是:

...
    "compile": "babel src --out-dir public",
...

转换现在可以这样运行:

$ npm run compile

在这之后,如果您再次运行npm start来启动服务器,您可以看到App.jsx的任何变化都反映在应用中。

注意

避免使用也是 npm 第一级命令的 npm 子命令名,如buildrebuild,因为如果在 npm 命令中省略了run,会导致无声错误。

当我们处理客户端代码并频繁更改源文件时,我们必须为每次更改手动重新编译它。如果有人能为我们检测到这些变化,并将源代码重新编译成 JavaScript,那不是很好吗?嗯,Babel 通过--watch选项支持开箱即用。为了使用它,让我们在 Babel 命令行中添加另一个名为watch的脚本,并增加这个选项:

...
    "watch": "babel src --out-dir public --watch --verbose"
...

它本质上是与 compile 相同的命令,但是有两个额外的命令行选项,--watch--verbose。第一个选项指示 Babel 监视源文件中的变化,第二个选项使它每当变化导致重新编译时就在控制台中打印出一行。这只是为了保证无论何时做出更改,编译都已经发生,只要您在运行该命令的控制台上保持警惕。

通过使用名为nodemon的包装器命令,可以对服务器代码的更改进行类似的重启。每当一组文件发生更改时,该命令都会使用指定的命令重新启动 Node.js。你也可能通过搜索互联网发现forever是另一个可以用来实现同样目标的包。通常,forever用于在崩溃时重启服务器,而不是监视文件的变化。最佳实践是在开发期间使用nodemon(真正需要观察变化的地方)和在生产中使用forever(崩溃时需要重启)。那么,现在让我们安装nodemon:

$ npm install nodemon@1

现在,让我们使用nodemon来启动服务器,而不是package.jsonstart的脚本规范中的 Node.js。命令nodemon还需要一个选项来指示使用-w选项来监视哪个文件或目录的变化。因为所有的服务器文件都将放在名为server的目录中,所以当该目录中的任何文件发生变化时,我们可以使用-w servernodemon重启 Node.js。因此,package.json中启动脚本的新命令现在将是:

...
    "start": "nodemon -w server server/server.js"
...

清单 2-11 显示了在package.json中添加或更改的最后一组脚本。

...
  "scripts": {
    "start": "node server/server.js",
    "start": "nodemon -w server server/server.js",
    "compile": "babel src --out-dir public",
    "watch": "babel src --out-dir public --watch --verbose",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 2-11Package.json: Adding Scripts for Transformation

如果您现在使用npm run watch运行新命令,您会注意到它执行了一次转换,但是没有返回到 shell。它实际上是在一个永久的循环中等待,观察源文件的变化。所以,要运行服务器,需要另一个终端,在那里可以执行npm start

如果你对App.jsx做了一个小的改变并保存文件,你会看到public目录中的App.js被重新生成。而且,当您刷新浏览器时,您可以看到这些更改,而不必手动重新编译。您还可以对server.js进行任何更改,并看到服务器启动,控制台上会显示一条消息,提示服务器正在重启。

摘要

在本章中,您学习了如何构建 React 应用的基础知识。我们从运行时编译的用 React JSX 编写的一段简单代码开始,然后我们将编译和文件提供给服务器。

我们用 nvm 安装 Node.js 您看到了 npm 不仅可以用来安装 Node.js 包,还可以用来在传统的或容易发现的脚本中保存命令行指令。然后,我们使用 Babel 来 transpile ,也就是说,将语言的一种规范转换或编译成另一种规范,以支持更老的浏览器。Babel 还帮助我们将 JSX 转化为纯 JavaScript。

您还对 Node.js with Express 的功能有所了解。我们没有使用 MongoDB,即 MERN 堆栈中的 M,但是我希望您能够很好地了解堆栈的其他组件。

到目前为止,您应该已经熟悉了这本书的 GitHub 库是如何组织的,以及书中使用的约定。对于每个部分,都有一组可测试的代码,您可以将自己的代码与它们进行比较。重要的是,每一步之间的差异对于理解每一步中发生的确切变化是有价值的。请再次注意,GitHub 资源库中的代码是值得依赖的,其中的最新更改无法在印刷书籍中体现出来。如果你发现你已经一字不差地遵循了这本书,但是事情并不像预期的那样工作,请参考 GitHub 库,看看印刷的书的错误是否已经在那里被纠正。

在接下来的两章中,我们将更深入地研究 React,然后在后面的章节中讨论 API、MongoDB 和 Express。

练习答案

练习:无服务器 Hello World

  1. 要在React.createElement()中指定一个类,我们需要用{ className: <name>}代替{ class: <name>}。这是因为class是 JavaScript 中的保留字,我们不能把它作为对象中的字段名。

  2. element变量包含一个嵌套的元素树,它反映了 DOM 应该包含的内容。我称之为虚拟世界,这也是人们通常所说的。

练习:JSX

  1. 删除脚本类型将导致浏览器将其视为常规 JavaScript,我们将在控制台上看到语法错误,因为 JSX 不是有效的 JavaScript。相反,移除 Babel 编译器将导致该脚本被忽略,因为浏览器不识别类型为text/babel的脚本,它将忽略该脚本。在这两种情况下,应用都不会工作。

  2. 缩小版的 React 隐藏或缩短了运行时错误。非精简版本给出了完整的错误和有用的警告。

练习:项目设置

  1. package.json是在我们使用npm init创建项目时创建的。事实上,我们在运行npm init时对提示的所有 React 都记录在了package.json中。

  2. 当使用--no-save时,npm 保持文件package.json不变。由此可见,package.json早就保留了明示的从属关系。不带任何选项或参数运行npm install会安装package.json中列出的所有依赖项。因此,您可以手动将依赖项添加到package.json中,只需使用npm install

  3. --save-dev选项在devDependencies而不是dependencies添加包。开发依赖列表将不会被安装到产品中,这由被设置为字符串production的环境变量NODE_ENV来指示。

  4. 包文件安装在项目下的目录node_modules下。npm ls以树状方式列出所有已安装的软件包。--depth=0将树深度限制在顶层包。删除整个node_modules目录是确保您开始清理的一种方式。

练习:快速

  1. 静态文件中间件并没有像对待index.html那样特别对待hello.html,所以你将不得不像这样使用文件名来访问应用:http://localhost:3000/hello.html

  2. 为了通过不同的挂载点访问静态文件,在中间件生成的帮助函数中指定前缀作为第一个参数。比如app.use('/public', express.static('/public'))

练习:JSX 变换

  1. App.js 现在包含纯 JavaScript,所有 JSX 元素都转换成了React.createElement()调用。当转换发生在浏览器中时,我们之前看不到这种转换。

  2. 当我们部署代码时,我们将只部署应用的预构建版本。也就是说,我们将在构建服务器或我们的开发环境上转换 JSX,并将结果 JavaScript 推到我们的生产服务器上。因此,在生产服务器上,我们将不需要构建应用所需的工具。因此,我们使用了--save-dev,这样,在生产服务器上,就不需要安装这个包了。

练习:旧浏览器支持

  1. React 故意这样做,以避免跨站点脚本漏洞。插入 HTML 标记并不容易,尽管有一种使用元素的dangerouslySetInnerHTML属性的方法。正确的做法是组成一个组件数组。我们将在后面的章节中探讨如何做到这一点。*

三、React 组件

在 Hello World 示例中,我们使用纯 JSX 创建了一个非常基本的 React 本地组件。然而,在现实世界中,您想要做的远不止一个简单的单线 JSX 所能做的。这就是 React 组件的用武之地。React 组件可以使用其他组件和基本 HTML 元素组成;它们可以响应用户输入、改变状态、与其他组件交互等等。

但是,在进入所有细节之前,让我首先描述一下我们将作为本书的一部分构建的应用。在本章以及后续章节中的每一步,我们将逐一介绍需要执行的应用或任务的特性,并解决它们。我喜欢这种方法,因为当我将它们立即投入使用时,我学到了最好的东西。这种方法不仅让你欣赏和内化概念,因为你把它们用上了,而且把更有用和实用的概念带到了最前面。

我想出的这个应用是大多数开发人员都能理解的。

问题跟踪器

我相信你们大多数人都熟悉 GitHub 问题或吉拉。这些应用帮助您创建一堆问题或错误,将它们分配给人们,并跟踪它们的状态。这些本质上是管理一系列对象或实体的 CRUD 应用(创建、读取、更新和删除数据库中的记录)。CRUD 模式非常有用,因为几乎所有的企业应用都是在不同的实体或对象上围绕 CRUD 模式构建的。

在问题跟踪器的情况下,我们将只处理单个对象或记录,因为这足以描述模式。一旦您掌握了如何在 MERN 实现 CRUD 模式的基本原理,您就能够复制该模式并创建一个真实的应用。

以下是问题跟踪应用的需求列表,这是 GitHub 问题或吉拉的简化或低调版本:

  • 用户应该能够查看问题列表,并能够通过各种参数过滤列表。

  • 用户应该能够通过提供问题字段的初始值来添加新问题。

  • 用户应该能够通过更改问题的字段值来编辑和更新问题。

  • 用户应该能够删除问题。

问题应具有以下属性:

  • 总结问题的标题(自由格式的长文本)

  • 向其分配问题的所有者(自由格式短文本)

  • 状态指示器(可能的状态值列表)

  • 创建日期(自动分配的日期)

  • 解决问题所需的努力(天数,一个数字)

  • 预计完成日期或到期日期(日期,可选)

请注意,我包含了不同类型的字段(列表、日期、数字、文本),以确保您了解如何处理不同的数据类型。我们将从简单开始,一次构建一个特性,并在过程中了解 MERN 堆栈。

在本章中,我们将创建 React 类并实例化组件。我们还将通过组合较小的组件来创建较大的组件。最后,我们将在这些组件之间传递数据,并根据数据动态创建组件。就功能而言,本章的目标是展示问题跟踪器的主页:问题列表。我们将对用于显示页面的数据进行硬编码,并将从服务器检索数据的工作留到下一章。

React 类

在这一节中,我们的目标是将单行 JSX 转换成一个从 React 类实例化的简单 React 组件,以便我们稍后可以使用第一个类 React 组件的全部功能。

React 类用于创建真正的组件(与模板化的 HTML 相反,在模板化的 HTML 中,我们基于变量创建 Hello World 消息,这是我们在上一章中创建的)。这些类可以在其他组件中重用,处理事件等等。首先,让我们用一个简单的类代替 Hello World 示例,该类构成了问题跟踪器应用的起点。

React 类是通过扩展React.Component创建的,所有定制类都必须从这个基类派生。在类定义中,至少需要一个render()方法。当 React 需要在 UI 中显示组件时,它调用这个方法。

还有其他一些对 React 有特殊意义的方法可以实现,称为生命周期方法。这些提供了组件形成和其他事件的不同阶段的挂钩。我们将在后面的章节中讨论其他的生命周期函数。但是render()必须存在的一个,否则组件将没有屏幕存在。render()函数应该返回一个元素(可以是一个本地 HTML 元素,比如<div>,也可以是另一个 React 组件的实例)。

让我们将 Hello World 示例从一个简单的元素改为使用一个名为HelloWorld的 React 类,它是从React.Component扩展而来的:

...
class HelloWorld extends React.Component {
  ...
}
...

注意

我们使用 ES2015 class关键字和extends关键字来定义一个 JavaScript 类。React 建议使用 ES2015 类。如果您不熟悉 JavaScript 类,请阅读并了解从 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes 开始的类。

现在,在这个类中,需要一个render()方法,它应该返回一个元素。我们将使用与消息相同的 JSX <div>作为返回的元素。

...
  render() {
    return (
      <div title="Outer div">
        <h1>{message}</h1>
      </div>
    );
...

让我们也将所有用于消息构造的代码移到render()函数中,这样它仍然封装在需要的范围内,而不是污染全局名称空间。

...
  render() {
    const continents = ['Africa','America','Asia','Australia','Europe'];
    const helloContinents = Array.from(continents, c => `Hello ${c}!`);
    const message = helloContinents.join(' ');

    return (
      ...
    );
...

本质上,JSX 元素现在是从名为 Hello World 的组件类的render()方法返回的。Hello World 元素的 JSX 表示形式周围的括号不是必需的,但这是一种惯例,通常用于使代码更具可读性,尤其是当 JSX 跨越多行时。

正如使用形式为<div></div>的 JSX 创建一个div元素的实例一样,HelloWorld类的实例也可以这样创建:

...
const element = <HelloWorld />;
...

现在,这个元素可以用来代替<div>元素,在名为contents的 Node 中进行渲染,就像以前一样。这里值得注意的是,divh1是内置的 React 组件或元素,可以直接实例化。而HelloWorld是我们定义并随后实例化的东西。并且在HelloWorld内,我们使用了 React 内置的div组件。清单 3-1 中显示了新的变更后的App.jsx

将来,我可能会互换使用组件和组件类,就像有时我们倾向于使用类和对象一样。但是现在应该很明显,HelloWorlddiv实际上是 React 组件类,而<HelloWorld /><div />是组件类的有形组件或实例。不用说,只有一个HelloWorld类,但是基于这个类可以实例化许多HelloWorld组件。

class HelloWorld extends React.Component {
  render() {
    const continents = ['Africa','America','Asia','Australia','Europe'];
    const helloContinents = Array.from(continents, c => `Hello ${c}!`);
    const message = helloContinents.join(' ');

    return (
      <div title="Outer div">
        <h1>{message}</h1>
      </div>
    );
  }
}

const element = <HelloWorld />;

ReactDOM.render(element, document.getElementById('contents'));

Listing 3-1App.jsx: A Simple React Class and Instance

到现在为止,您应该在一个控制台中运行npm run watch,并且在一个单独的控制台中使用npm start启动服务器。因此,对App.jsx的任何修改都应该被自动编译。因此,如果你刷新你的浏览器,你应该看到所有大洲的问候,就像以前一样。

练习:React 类

  1. render函数中,不是返回一个<div>,而是尝试返回两个前后放置的<div>元素。会发生什么?为什么,解决方案是什么?确保你看着控制台运行npm run watch

  2. 通过将字符串'contents'更改为'main'或其他不能识别 HTML 中元素的字符串,为 React 库创建一个运行时错误。从哪里可以看出错误?像未定义的变量引用这样的 JavaScript 运行时错误怎么办?

本章末尾有答案。

构成组件

在上一节中,您看到了如何通过将内置的 React 组件(相当于 HTML 元素)放在一起来构建组件。也可以构建一个使用其他用户定义组件的组件,这就是我们将在本节中探讨的内容。

组件组合是 React 最强大的特性之一。这样,UI 可以被分割成更小的独立部分,这样每个部分都可以独立地编码和推理,从而更容易构建和理解复杂的 UI。使用组件而不是以整体的方式构建 UI 也鼓励重用。我们将在后面的章节中看到我们构建的组件是如何被轻松重用的,即使我们在构建组件的时候没有想到重用。

组件接受输入(称为属性),其输出是组件的呈现 UI。在本节中,我们将不使用输入,而是将细粒度的组件放在一起构建一个更大的 UI。在编写组件时,需要记住以下几点:

  • 当细粒度组件之间可能存在逻辑分离时,应该将较大的组件拆分为细粒度组件。在本节中,我们将创建逻辑上分离的组件。

  • 当有重用的机会时,可以构建从不同调用者接受不同输入的组件。当我们在第十章中为用户输入构建专门的部件时,我们将创建可重用的组件。

  • React 的哲学更喜欢组件组合,而不是继承。例如,现有组件的专门化可以通过将属性传递给通用组件而不是从它继承来完成。你可以在 https://reactjs.org/docs/composition-vs-inheritance.html 了解更多信息。

  • 一般来说,记住将组件之间的耦合保持在最低限度(耦合是指一个组件需要知道另一个组件的细节,包括它们之间传递的参数或属性)。

让我们设计应用的主页来显示问题列表,并能够过滤问题和创建新问题。因此,它将包含三个部分:一个用于选择显示哪些问题的过滤器、问题列表,以及最后一个用于添加问题的条目表单。我们现在关注的是组成组件,所以我们将只为这三个部分使用占位符。用户界面的结构和层次如图 3-1 所示。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig1_HTML.jpg

图 3-1

问题列表页面的结构

让我们定义三个占位符类——IssueFilterIssueTableIssueAdd——每个类中的<div>内只有一个占位符文本。IssueFilter组件将如下所示:

...
class IssueFilter extends React.Component {
  render() {
    return (
      <div>This is a placeholder for the issue filter.</div>
    );
  }
}
...

另外两个类——IssueTableIssueAdd——将是相似的,各有不同的占位符消息:

...
class IssueTable extends React.Component {
    ...
      <div>This is a placeholder for a table of issues.</div>
...
class IssueAdd extends React.Component {
    ...
      <div>This is a placeholder for a form to add an issue.</div>
...

为了将这些放在一起,让我们删除 Hello World 类,并添加一个名为IssueList的类。

...
class IssueList extends React.Component {
}
...

现在让我们添加一个render()方法。在这个方法中,让我们添加每个新占位符类的一个实例,用一条<hr>或水平线分隔。正如您在前面部分的练习中看到的,由于render()的返回必须是一个单一元素,所以这些元素必须包含在<div>或 React Fragment组件中。组件Fragment就像一个封闭的<div>,但是它对 DOM 没有影响。

让我们在IssueListrender()方法中使用这样一个Fragment组件:

...
  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable />
        <hr />
        <IssueAdd />
      </React.Fragment>
    );
  }
...

最后,让我们实例化IssueList类,而不是实例化一个HelloWorld类,我们将把它放在contents div 下。

...

const element = <HelloWorld />;

const element = <IssueList />;

...

理想情况下,每个组件都应该作为一个独立的文件来编写。但是目前,我们只有占位符,所以为了简洁起见,我们将所有的类保存在同一个文件中。此外,您还没有学会如何将多个类文件放在一起。在后面的阶段,当类扩展到它们的实际内容,并且我们也有方法从另一个类构建或引用一个类时,我们将把它们分离出来。

清单 3-2 显示了带有所有组件类的App.jsx文件的新内容。

class IssueFilter extends React.Component {
  render() {
    return (
      <div>This is a placeholder for the issue filter.</div>
    );
  }
}

class IssueTable extends React.Component {
  render() {
    return (
      <div>This is a placeholder for a table of issues.</div>
    );
  }
}

class IssueAdd extends React.Component {
  render() {
    return (
      <div>This is a placeholder for a form to add an issue.</div>
    );
  }
}

class IssueList extends React.Component {
  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable />
        <hr />
        <IssueAdd />
      </React.Fragment>
    );
  }
}

const element = <IssueList />;

ReactDOM.render(element, document.getElementById('contents'));

Listing 3-2App.jsx: Composing Components

这段代码的效果将是一个无趣的页面,如图 3-2 所示。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig2_HTML.jpg

图 3-2

通过组合组件跟踪问题

练习:组成组件

  1. 在开发人员控制台中检查 DOM。您看到任何对应于React.Fragment组件的 HTML 元素了吗?与使用一个<div>元素来封装各种元素相比,您认为这有什么用?

本章末尾有答案。

使用属性传递数据

组成没有任何变量的组件并不那么有趣。应该可以将不同的输入数据从父组件传递到子组件,并在不同的实例上进行不同的呈现。在问题跟踪器应用中,可以用不同输入实例化的一个这样的组件是显示单个问题的表行。根据不同的输入(问题),该行可以显示不同的数据。新的 UI 结构如图 3-3 所示。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig3_HTML.jpg

图 3-3

带有问题行的问题列表 UI 层次结构

因此,让我们创建一个名为IssueRow的组件,然后在IssueTable中多次使用它,传入不同的数据来显示不同的问题,就像这样:

...
class IssueTable extends React.Component {
  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>ID</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          <IssueRow /> {/* somehow pass Issue 1 data to this */}
          <IssueRow /> {/* somehow pass Issue 2 data to this */}
        </tbody>
      </table>
    );
  }
}
...

注意

JSX 本身不支持注释。为了添加注释,必须添加一个具有 JavaScript 样式注释的 JavaScript 片段。因此,表单{/* ... */}可用于在 JSX 内放置评论。像<!-- ... -->这样使用 HTML 风格的注释是行不通的。

事实上,在任何 JSX 代码片段中切换到 JavaScript 世界的方法就是使用花括号。在前一章中,我们用它来显示 Hello World 消息,这是一个使用语法{message}名为message的 JavaScript 变量。

将数据传递给子组件的最简单方法是在实例化组件时使用属性。我们在上一章中使用了title属性,但这是一个最终影响 DOM 元素的属性。任何自定义属性也可以以类似的方式从IssueTable传递:

...
    <IssueRow issue_title="Title of the first issue" />
...

我们使用名称issue_title而不是简单的title来避免这个自定义属性和 HTML title属性之间的混淆。现在,在孩子的render()方法中,属性的值可以通过一个叫做props的特殊对象变量来访问,这个变量可以通过this访问器获得。例如,issue_title的值是如何在IssueRow组件的单元格中显示的:

...
    <td>{this.props.issue_title}</td>
...

在本例中,我们传递了一个简单的字符串。其他数据类型甚至 JavaScript 对象都可以这样传递。通过使用花括号({})而不是引号,可以传递任何 JavaScript 表达式,因为花括号切换到 JavaScript 世界。

因此,让我们将问题的标题(作为字符串)、ID(作为数字)和行样式(作为对象)从IssueTable传递到IssueRow。在IssueRow类中,我们将使用这些传入的属性来显示 ID 和标题,并通过this.props访问这些属性来设置行的样式。

清单 3-3 中显示了完整的IssueRow类的代码。

class IssueRow extends React.Component {
  render() {
    const style = this.props.rowStyle;
    return (
      <tr>
        <td style={style}>{this.props.issue_id}</td>
        <td style={style}>{this.props.issue_title}</td>
      </tr>
    );
  }
}

Listing 3-3App.jsx: IssueRow Component, Accessing Passed-in Properties

我们为表格单元格使用了属性style,就像我们在常规 HTML 中使用它一样。但是请注意,这并不是真正的 HTML 属性。相反,它是一个被传递给内置 React 组件<td>属性。只是将td组件中的style属性解释并设置为 HTML style属性。大多数情况下,像style一样,属性的名称与 HTML 属性相同,但对于少数引起与 JavaScript 保留字冲突的属性,命名要求不同。因此,在 JSX,class HTML 属性需要是className。此外,HTML 属性中的连字符需要替换为骆驼大小写的名称,例如,max-length在 JSX 变成了maxLength

在位于 https://reactjs.org/docs/dom-elements.html 的 React 文档中可以找到 DOM 元素的完整列表以及如何指定这些元素的属性。

现在我们有一个IssueRow组件接收属性,让我们从父组件IssueTable传递它们。ID 和标题都很简单,但是我们需要传递的样式在 React 和 JSX 中有特殊的规范约定。

React 不需要 CSS 类型的字符串,而是需要将其指定为具有特定约定的对象,该约定包含一系列 JavaScript 键值对。这些键与 CSS 样式名相同,除了它们不是破折号(如border-collapse),而是骆驼大小写(如borderCollapse)。这些值是 CSS 样式值,就像在 CSS 中一样。指定像素值也有一种特殊的简写方式;你可以只用一个数字(比如 4)来代替字符串"4px"

让我们给这些行一个像素的银色边框和一些填充,比如说四个像素。封装该规范的样式对象如下:

...
    const rowStyle = {border: "1px solid silver", padding: 4};
...

这可以在实例化时使用rowStyle={rowStyle}传递给IssueRow组件。这个和其他变量可以传递给IssueRow,同时像这样实例化它:

...
<IssueRow rowStyle={rowStyle} issue_id={1}
  issue_title="Error in console when clicking Add" />
...

注意,我们没有对问题 ID 使用类似字符串的引号,因为它是一个数字,也没有对rowStyle使用类似字符串的引号,因为它是一个对象。我们使用花括号,这使得它成为一个 JavaScript 表达式。

现在,让我们构造IssueTable组件,它本质上是一个<table>,有一个标题行和两列(ID 和 title),以及两个硬编码的IssueRow组件。让我们也为表格指定一个内联样式来指示折叠的边框,并使用相同的rowStyle变量来指定标题行样式,使其看起来一致。

清单 3-4 显示了修改后的IssueTable组件类。

class IssueTable extends React.Component {
  render() {
    const rowStyle = {border: "1px solid silver", padding: 4};
    return (
      <table style={{borderCollapse: "collapse"}}>
        <thead>
          <tr>
            <th style={rowStyle}>ID</th>
            <th style={rowStyle}>Title</th>
          </tr>
        </thead>
        <tbody>
          <IssueRow rowStyle={rowStyle} issue_id={1}
            issue_title="Error in console when clicking Add" />
          <IssueRow rowStyle={rowStyle} issue_id={2}
            issue_title="Missing bottom border on panel" />
        </tbody>
      </table>
    );
  }
}

Listing 3-4App.jsx: IssueTable Passing Data to IssueRow

图 3-4 显示了代码中这些变化的影响。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig4_HTML.jpg

图 3-4

将数据传递给子组件

练习:使用属性传递数据

  1. 尝试为表格添加一个属性border=1,就像我们在普通 HTML 中做的那样。会发生什么?为什么呢?提示:阅读 React API 参考的“DOM Elements”一节中标题为“所有支持的 HTML 属性”的部分。

  2. 为什么表格的内嵌样式中有一个双花括号?提示:与另一种风格相比,我们声明了一个变量并使用它,而不是内联指定它。

  3. 花括号是在 JSX 标记中间转义成 JavaScript 的一种方式。将这与 PHP 等其他模板语言中的类似技术进行比较。

本章末尾有答案。

使用子 Node 传递数据

还有另一种方法将数据传递给其他组件,即使用组件的类似 HTML 的 Node 的内容。在子组件中,可以使用名为this.props.children的特殊字段this.props来访问它。

就像在常规 HTML 中一样,React 组件可以嵌套。在 Hello World 示例中,我们在一个<div>中嵌套了一个<h1>元素。当组件被转换为 HTML 元素时,元素以相同的顺序嵌套。React 组件可以像<div>一样工作,接受嵌套元素。在这种情况下,JSX 表达式将需要包含开始和结束标记,并在其中嵌套元素。

但是,当父 React 组件渲染时,子组件不会自动位于其下,因为父 React 组件的结构需要确定子组件将出现的确切位置。因此,React 让父组件使用this.props.children访问子元素,并让父组件决定它需要显示在哪里。当需要将其他组件包装在父组件中时,这非常有用。例如,添加边框和填充的包装器<div>可以这样定义:

...
class BorderWrap extends React.Component {
  render() {
    const borderedStyle = {border: "1px solid silver", padding: 6};
    return (
      <div style={borderedStyle}>
        {this.props.children}
      </div>
    );
  }
}
...

然后,在呈现过程中,任何组件都可以用填充的边框包装,如下所示:

...
    <BorderWrap>
      <ExampleComponent />
    </BorderWrap>
...

因此,可以使用这种技术将其作为< IssueRow >的子内容嵌入,而不是将问题标题作为属性传递给IssueRow,如下所示:

...
  <IssueRow issue_id={1}>Error in console when clicking Add</IssueRow>
...

现在,在IssueRowrender()方法中,它将需要被称为this.props.children,而不是被称为this.props.issue_title,就像这样:

...
   <td style={borderedStyle}>{this.props.children}</td>
...

让我们修改应用,使用这种将数据从IssueTable传递到IssueRow的方法。让我们也传入一个嵌套的标题元素作为子元素,它是一个<div>,包含一段强调的文本。这一变化如清单 3-5 所示。

...
class IssueRow extends React.Component {
...
    return (
      <tr>
        <td style={style}>{this.props.issue_id}</td>
        <td style={style}>{this.props.issue_title}</td>
        <td style={style}>{this.props.children}</td>
      </tr>
    );
...
}
...
...
class IssueTable extends React.Component {
...
        <tbody>
          <IssueRow rowStyle={rowStyle} issue_id={1}
            issue_title="Error in console when clicking Add" />
          <IssueRow rowStyle={rowStyle} issue_id={2}
            issue_title="Missing bottom border on panel" />
          <IssueRow rowStyle={rowStyle} issue_id={1}>
            Error in console when clicking Add
          </IssueRow>
          <IssueRow rowStyle={rowStyle} issue_id={2}>
            <div>Missing <b>bottom</b> border on panel</div>
          </IssueRow>
        </tbody>
...

Listing 3-5App.jsx: Using Children Instead of Props

这些变化对输出的影响很小,只在第二期的标题中看到一点点格式。如图 3-5 所示。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig5_HTML.jpg

图 3-5

将数据传递给子组件

练习:使用子 Node 传递数据

  1. 什么时候以propschildren的形式传递数据比较合适?提示:想想我们想要传递的是什么。

本章末尾有答案。

动态构图

在这一节中,我们将用一系列问题中以编程方式生成的组件集替换我们的硬编码组件集IssueRow。在后面的章节中,我们将通过从数据库获取问题列表来变得更加复杂,但是现在,我们将使用一个简单的内存 JavaScript 数组来存储问题列表。

让我们将问题的范围从仅仅一个 ID 和一个标题扩展到尽可能多的领域。清单 3-6 展示了这个内存数组,它在文件App.jsx的开头被全局声明。它只有两个问题。字段due在第一条记录中未定义,以确保我们处理这是一个可选字段的事实。

const issues = [
  {
    id: 1, status: New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2018-08-16'), due: new Date('2018-08-30'),
    title: 'Missing bottom border on panel',
  },
];

Listing 3-6App.jsx

: In-Memory Array of Issues

您可以添加更多的示例问题,但两个问题足以演示动态合成。现在,让我们修改IssueTable类来使用这个问题数组,而不是硬编码的列表。在IssueTable class' render()方法中,让我们遍历问题数组,并从中生成一个IssueRows数组。

为此,Arraymap()方法很方便,因为我们可以将一个问题对象映射到一个IssueRow实例。此外,让我们传递issue对象本身,而不是将每个字段作为属性传递,因为有许多字段作为对象的一部分。这是一种在表体中就地实现的方法:

...
    <tbody>
      {issues.map(issue => <IssueRow rowStyle={rowStyle} issue={issue}/>)}
    </tbody>
...

如果你想使用一个for循环而不是map()方法,你不能在 JSX 中这样做,因为 JSX 并不是真正的模板语言。它只允许花括号内的 JavaScript 表达式。我们必须在render()方法中创建一个变量,并在 JSX 中使用它。出于可读性考虑,我们还是这样为问题行集创建变量:

...
  const issueRows = issues.map(issue => <IssueRow rowStyle={rowStyle} issue={issue}/>);
...

现在,我们可以将IssueTable中的两个硬编码问题组件替换为<tbody>元素中的这个变量,如下所示:

...
    <tbody>
      {issueRows}
    </tbody>
...

在其他框架和模板语言中,使用模板创建多个元素需要在模板语言中使用特殊的for循环结构(例如 AngularJS 中的ng-repeat)。但是在 React 中,常规 JavaScript 可以用于所有编程结构。这不仅为您提供了 JavaScript 操纵模板的全部能力,还减少了您需要学习和记忆的结构数量。

IssueTable类中的标题行现在需要为每个问题字段提供一列,所以让我们也这样做。但是现在,为每个单元格指定样式变得很乏味,所以让我们为表格创建一个类,将其命名为table-bordered,并使用 CSS 来为表格和每个表格单元格设置样式。这种风格需要成为index.html的一部分,清单 3-7 显示了对该文件的修改。

...
  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
  <style>
    table.bordered-table th, td {border: 1px solid silver; padding: 4px;}
    table.bordered-table {border-collapse: collapse;}
  </style>
</head>
...

Listing 3-7index.html: Styles for Table Borders

现在,我们可以从所有的表格单元格和表格标题中删除rowStyle。需要做的最后一件事是用一个名为key的属性来标识IssueRow的每个实例。这个键的值可以是任何值,但是它必须唯一地标识一行。React 需要这个key,这样它就可以在情况发生变化时优化差异的计算,例如,当插入一个新行时。我们可以使用问题的 ID 作为键,因为它唯一地标识了行。

清单 3-8 显示了最终的IssueTable类,它包含一组动态生成的IssueRow组件和修改后的头部。

class IssueTable extends React.Component {
  render() {
    const issueRows = issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );

    return (
      <table className="bordered-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>Status</th>
            <th>Owner</th>
            <th>Created</th>
            <th>Effort</th>
            <th>Due Date</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          {issueRows}
        </tbody>
      </table>
    );
  }
}

Listing 3-8App.jsx: IssueTable Class with IssueRows Dynamically Generated and Modified Header

IssueRow的变化相当简单。必须删除内联样式,还需要添加几列,每个添加的字段一列。因为 React 不会在要显示的对象上自动调用toString(),所以日期必须显式地转换为字符串。toString()方法会产生一个很长的字符串,所以让我们用toDateString()来代替。由于字段due是可选的,我们需要在调用字段toDateString()之前检查它是否存在。一种简单的方法是在如下表达式中使用三元运算符? - ::

...
  issue.due ? issue.due.toDateString() : ''
...

三元运算符非常方便,因为它是一个 JavaScript 表达式,可以直接用来代替显示字符串。否则,要使用if-then-else语句,代码必须在 JSX 部分之外,在render()方法实现的开始。新的IssueRow类如清单 3-9 所示。

class IssueRow extends React.Component {
  render() {
    const issue = this.props.issue;
    return (
      <tr>
        <td>{issue.id}</td>
        <td>{issue.status}</td>
        <td>{issue.owner}</td>
        <td>{issue.created.toDateString()}</td>
        <td>{issue.effort}</td>
        <td>{issue.due ? issue.due.toDateString() : ''}</td>
        <td>{issue.title}</td>
      </tr>
    );
  }
}

Listing 3-9App.jsx: New IssueRow Class Using Issue Object Property

经过这些更改后,屏幕应该如图 3-6 所示。

img/426054_2_En_3_Chapter/426054_2_En_3_Fig6_HTML.jpg

图 3-6

发出从数组以编程方式构造的行

练习:动态构图

  1. 我们使用问题的id字段作为键值。还有什么其他的钥匙可以用?你会选择哪一个?

  2. 在上一节中,我们将问题的每个字段作为单独的属性传递给了IssueRow。在本节中,我们传递了整个问题对象。为什么呢?

  3. 不要使用局部变量issueRows,尝试直接在<tbody>中使用映射表达式。有用吗?它告诉我们什么?

本章末尾有答案。

摘要

在本章中,我们创建了问题跟踪器主页的一个准系统版本。我们开始使用 React 类,而不是简单的元素,其中一些只是占位符,用来描述我们尚未开发的组件。我们通过编写细粒度的单个组件并将它们放在一起(组合)到一个封闭组件中来实现这一点。我们还将参数或数据从封装组件传递到其子组件,从而重用组件类并使用不同的数据对其进行不同的呈现,动态地使用map()来基于输入数据的数组生成组件。

这些组件除了根据输入数据呈现自己之外,没有做太多事情。在下一章,我们将看到用户交互如何影响数据和改变组件的外观。

练习答案

练习:React 类

  1. 编译将失败,并出现错误“相邻的 JSX 元素必须用封闭标记括起来”。render()方法只能有一个返回值,因此,它只能返回一个元素。将两个<div>放在另一个< div >中是一种解决方案,或者如错误消息所示,使用一个Fragment组件是另一种解决方案,我们将在后面的章节中讨论。

  2. 如果是 React 错误,React 会在浏览器的 JavaScript 控制台中打印错误。控制台中也会显示常规的 JavaScript 错误,但显示的代码不是原代码;就是编译好的代码。我们将在后面的章节中学习如何使用原始源代码进行调试。

练习:组成组件

  1. 不,没有封闭元素。由IssueList返回的所有元素都直接出现在contents div 下。在这种情况下,我们可以很容易地使用一个<div>来包含元素。

    But imagine a situation where a list of table-rows needs to be returned, like this:

    ...
      <tr> {/* contents of row 1 */} </tr>
      <tr> {/* contents of row 2 */} </tr>
    ...
    
    

    然后,调用组件将这些行放在一个<tbody>元素下。添加一个<div>来包含这些行会导致无效的 DOM 树,因为<tbody>中不能有<div>。在这种情况下,碎片是唯一的选择。

练习:使用属性传递数据

  1. 将不显示边框。React 解释每个元素属性的方式与 HTML 解析器不同。边框属性不是受支持的属性之一。React 完全忽略了 border 属性。

  2. 外面的大括号表示属性值是一个 JavaScript 表达式。内部大括号指定一个对象,它是属性的值。

  3. React 的花括号和 PHP 的<?php ... ?>类似,略有区别。标签内的内容是成熟的程序,而在 JSX,你只能有 JavaScript 表达式。所有像for循环这样的编程结构都是在 JSX 之外用普通 JavaScript 编写的。

练习:使用子 Node 传递数据

  1. 对于传递任何类型的数据都很灵活和有用。另一方面,children只能是一个元素,也可以深度嵌套。因此,如果您有简单的数据,就将其作为props传递。如果您要传递一个组件,如果它嵌套很深并且自然地出现在子组件中,您可以使用children。组件也可以作为props来传递,通常是当您想要传递多个组件或者组件不是父组件的自然子内容时。

练习:动态构图

  1. 属性的另一个选择是数组索引,因为它也是惟一的。如果键是一个像 UUID 这样的大值,您可能会认为使用数组索引更有效,但实际上并非如此。React 使用键识别该行。如果它找到了相同的键,它就假定这是同一行。如果该行没有更改,它不会重新呈现该行。

    因此,如果插入一行,如果行的键是对象的 ID,React 将更有效地移动现有的行,而不是重新呈现整个表。如果使用数组索引,它会认为插入行之后的每一行都已更改,并重新呈现每一行。

  2. 传递整个对象显然更简洁。只有当被传递的属性数量是对象的全部属性的一个小的子集时,我才会选择传递单个属性。

  3. 它是有效的,尽管事实上我们在表达式中有 JSX。花括号内的任何内容都被解析为 JavaScript 表达式。但是因为我们在 JavaScript 表达式上使用了 JSX 变换,所以这些片段也将经过变换。这是可能的嵌套更深,并使用另一套花括号内嵌套的 JSX,等等。

四、React 状态

直到现在,我们只看到了静态组件,也就是没有变化的组件。为了制作响应用户输入和其他事件的组件,React 在组件中使用了一个名为 state 的数据结构。在这一章中,我们将探讨如何使用 React 状态,以及如何操作它来改变组件的外观和它在屏幕上显示的内容。

状态本质上保存着数据,一些可以改变的东西,而不是你之前看到的不可变的props形式的属性。这个状态需要在构建视图的render()方法中使用。只有状态的改变才能改变观点。当数据或状态发生变化时,React 会自动重新呈现视图,以显示新更改的数据。

对于本章,目标是添加一个按钮,并在单击该按钮时向初始问题列表添加一行。我们将添加这个按钮来代替IssueAdd组件中的占位符文本。通过这样做,您将了解组件的状态,如何操作它,如何处理事件,以及如何在组件之间进行通信。

我们将从追加一行开始,无需用户交互。我们将使用计时器而不是按钮来完成这项工作,这样我们就可以专注于状态和修改,而不用处理像用户输入这样的事情。在本章的结尾,我们将用一个实际的按钮和一个用户输入的表单来代替计时器。

初态

组件的状态是在组件的类中一个名为this.state的变量中捕获的,该变量应该是一个由一个或多个键-值对组成的对象,其中每个键是一个状态变量名,值是该变量的当前值。React 并不指定什么需要进入状态,但是在状态中存储任何影响渲染视图并且可以由于任何事件而改变的内容是很有用的。这些通常是由于用户交互而生成的事件。

对于IssueTable组件,正在显示的问题列表肯定就是这样一段数据,它既影响呈现的视图,也可以在添加、编辑或删除问题时改变。因此,一系列问题是一个理想的状态变量。

其他事情,比如窗口的大小,也可以改变,但是这不会影响 DOM。即使显示发生了变化(例如,由于窗口变窄,一行可能会换行),浏览器也会根据相同的 DOM 直接处理这种变化。所以,我们不需要在组件的状态中捕获它。可能会有影响 DOM 的情况;例如,如果窗口的高度决定了我们显示多少个问题,我们可以将窗口的高度存储在一个状态变量中,并限制正在构建的IssueRow组件的数量。在这些情况下,窗口的高度或导出值(例如,正在显示的问题数量)也可以存储在状态中。

不改变的东西,比如表格的边框样式,也不需要进入状态。这是因为用户交互或其他事件不会影响边框的样式。

现在,让我们使用一个问题数组作为组件的唯一状态,并使用该数组构建问题表。因此,在IssueTablerender()方法中,让我们将创建IssueRows集合的循环改为使用名为issues的状态变量,而不是像这样的全局数组:

...
  const issueRows = this.state.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
...

至于初始状态,让我们使用一组硬编码的问题,并将其设置为初始状态。我们已经有一系列全球性的问题;让我们将这个数组重命名为initialIssues,只是为了明确它只是一个初始集合。

...
const initialIssues = [
  ...
];
...

设置初始状态需要在组件的构造函数中完成。这可以通过简单地将变量this.state分配给一组状态变量及其值来实现。让我们使用变量initialIssues来初始化状态变量issues的值,如下所示:

...
    this.state = { issues: initialIssues };
...

注意,我们只使用了一个名为issues的状态变量。我们可以有其他状态变量,例如,如果我们在多个页面中显示问题列表,并且我们还希望将当前显示的页码作为另一个状态变量,我们可以通过向像page: 0这样的对象添加另一个键来实现。

清单 4-1 中显示了使用状态来呈现视图IssueTable的所有更改。

...

const issues = [

const initialIssues = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
  },
...
class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: initialIssues };
  }

  render() {
    const issueRows = issues.map(issue =>
    const issueRows = this.state.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );
...

Listing 4-1App.jsx: Initializing and Using State

运行和测试这段代码应该不会在应用中显示任何变化;您仍然会看到一个包含两行问题的表,就像以前一样。

练习:初始状态

  1. 如果您需要根据问题的状态以不同的背景颜色显示每一行,您会怎么做?您是否有一个对应于每个问题的颜色列表也存储在州中?为什么或为什么不?

本章末尾有答案。

异步状态初始化

尽管我们在构造函数中设置了初始状态,但是常规的 SPA 组件不太可能静态地拥有初始状态。这些通常是从服务器上获取的。在问题跟踪器应用的情况下,甚至要显示的初始问题列表也必须通过 API 调用来获取。

状态只能在构造函数中赋值。之后,可以修改状态,但是只能通过调用React.Componentthis.setState()方法。该方法接受一个参数,该参数是一个包含所有已更改的状态变量及其值的对象。我们拥有的唯一状态变量是一个叫做issues的变量,它可以在对this.setState()的调用中设置为任何问题列表,如下所示:

...
  this.setState({ issues: newIssues });
...

如果有额外的状态变量,只设置一个变量(issues)将导致与现有状态合并。例如,如果我们将当前页面存储为另一个状态变量,状态变量issues的新值将被合并到状态中,保持当前页面的值不变。

因为在构造组件的时候,我们没有初始数据,所以我们必须在构造函数中给状态变量issues分配一个空数组。

...
  constructor() {
    this.state = { issues: [] };
...

我们现在还不会从服务器获取数据,但是为了探究状态初始化的变化,让我们模拟这样一个调用。全局问题数组和对服务器的调用之间的关键区别在于,后者需要异步调用。让我们向IssueTable类添加一个方法,该方法异步返回一组问题。最终,我们将用一个对服务器的 API 调用来代替它,但是目前,我们将使用一个setTimeout()调用来使它异步。在对setTimeout()调用的回调中(最终将是一个 Ajax 调用),让我们用初始问题的静态数组调用this.setState(),如下所示:

...
  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }
...

500 毫秒的超时值有些随意:期望一个真正的 API 调用在这段时间内获取初始问题列表是合理的。

现在,在IssueTable的构造函数内调用loadData()是非常诱人的。它甚至看起来可以工作,但事实是构造函数只构造了组件(也就是说,在内存中完成对象的所有初始化),并不呈现 UI。当组件需要显示在屏幕上时,渲染会在稍后进行。如果this.setState()在组件准备好呈现之前被调用,事情就会出错。在简单的页面中,您可能看不到这种情况,但是如果初始页面很复杂并且需要时间来呈现,并且如果 Ajax 调用在呈现完成之前返回,您将会得到一个错误。

React 提供了许多被称为生命周期方法的其他方法来迎合这种情况和其他需要根据阶段或组件状态的变化来做一些事情的情况。除了构造函数和render()方法之外,组件的以下生命周期方法可能会引起人们的兴趣:

  • componentDidMount():一旦组件的表示被转换并插入到 DOM 中,就调用这个方法。在这个方法中可以调用一个setState()

  • componentDidUpdate():这个方法在更新发生后立即被调用,但在初始渲染时不会被调用。this.setState()可以在这个方法内调用。还向该方法提供先前的属性和先前的状态作为参数,以便该函数有机会在采取动作之前检查先前的属性和状态与当前的属性和状态之间的差异。

  • componentWillUnmount() :这个方法对于清除比如取消定时器和挂起的网络请求很有用。

  • shouldComponentUpdate():此方法可用于优化和防止重新渲染,以防道具或状态发生变化,但实际上并不影响输出或视图。这种方法很少使用,因为当状态和属性设计良好时,很少会出现状态或属性改变但视图不需要更新的情况。

在这种情况下,启动数据加载的最佳位置是componentDidMount()方法。在这个时间点上,DOM 保证已经准备好了,并且可以调用setState()来重新呈现组件。componentDidUpdate()也是一个选项,但是因为初始渲染时可能不会调用它,所以我们不要使用它。让我们在IssueTable中添加componentDidMount()方法,并在该方法中加载数据:

...
  componentDidMount() {
    this.loadData();
  }
...

清单 4-2 中显示了IssueTable类的一整套变化。

...
class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: initialIssues };
    this.state = { issues: [] };
  }

  componentDidMount() {
    this.loadData();
  }

  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }
...

Listing 4-2App.jsx, IssueTable: Loading State Asynchronously

如果你刷新浏览器(假设你仍然在两个不同的控制台上运行npm run watchnpm start),你会发现问题列表会像以前一样显示。但是,你也会看到在页面加载后的一瞬间,表格是空的,如图 4-1 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig1_HTML.jpg

图 4-1

显示几分之一秒的空表

它很快就被填满了,但仍然有闪烁。当我们在后面的章节中探索服务器端的渲染时,我们将摆脱这种笨拙的闪烁。目前,让我们忍受这个小的 UI 不愉快。

更新状态

在前面的小节中,您看到了如何设置初始状态,在构造函数中使用直接赋值,以及使用this.setState()在其他生命周期方法中设置值。在这一节中,让我们对状态做一个小的改变,而不是为它设置一个全新的值。让我们添加一个新的问题,从而改变,不是整个状态,而只是它的一部分。

首先,让我们在IssueTable中添加一个方法来添加一个新问题。这可以接受一个 issue 对象作为参数,我们将为它分配一个新的 ID 并设置创建日期。新的 ID 可以从数组的现有长度中计算出来。

...
  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
  }
...

注意状态变量不能直接设置,也不能直接变异。也就是说,不允许将this.state.issues设置为新值或修改其元素。组件中的变量this.state应该总是被视为不可变的。例如,不应执行以下操作:

...
    this.state.issues.push(issue);    // incorrect!
...

原因是 React 不会自动识别这种状态变化,因为它是一个普通的 JavaScript 变量。让 React 知道事情发生了变化,并导致 rerender 的唯一方法是调用this.setState()。此外,this.setState()可能导致直接对状态变量所做的改变被覆盖。因此,也不应该执行以下操作:

...
    issues = this.state.issues;
    issues.push(issue);         // same as this.state.issues.push()!
    this.setState({ issues: issues });
...

这看似可行,但在这个组件及其派生组件的一些生命周期方法中会产生意想不到的后果。特别是在那些比较新旧属性的方法中,旧状态和新状态之间的差异不会被检测到。

setState()调用中需要的是一系列新的问题,比如状态变量的副本。如果任何现有的数组元素(比如某个问题本身)正在发生变化,那么不仅需要数组的副本,还需要正在变化的对象的副本。有称为不变性助手、的库,比如immutable.js ( http://facebook.github.io/immutable-js/ ),可以用来构造新的状态对象。当对象的属性被修改时,库会创建一个最佳副本。

但我们只会追加一个问题,而不会改变现有的问题。制作数组的浅层副本相当简单,目前这就足够了。因此,我们不会使用这个库——我们不需要编写太多额外的代码来处理它。如果在您的应用中,您发现由于状态中对象的深度嵌套,您必须制作大量副本,您可以考虑使用immutable.js

制作数组副本的简单方法是使用slice()方法。所以让我们像这样创建一个issues数组的副本:

...
    issues = this.state.issues.slice();
...

在本章的后面,我们将创建一个用户界面来添加新问题。但是现在,与其处理 UI 和事件处理的复杂性,不如让我们添加一个计时器,当计时器到期时,一个硬编码的样本问题将被追加到问题列表中。让我们首先在全局initialIssues之后全局声明这个硬编码的样本发布对象:

...
const sampleIssue = {
  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',
};
...

让我们在IssueTable的构造函数中,在两秒钟的定时器到期后,在对createIssue()的调用中使用这个对象:

...
    setTimeout(() => {
      this.createIssue(sampleIssue);
    }, 2000);
...

这应该会在页面加载后自动将示例问题添加到问题列表中。清单 4-3 显示了最后一组更改——使用计时器将一个样本问题添加到问题列表中。

...
const initialIssues = [
  ...
];

const sampleIssue = {

  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',

};

...

class IssueTable extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
    setTimeout(() => {
      this.createIssue(sampleIssue);
    }, 2000);
  }

  ...

  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
  }
}
...

Listing 4-3App.jsx: Appending an Issue on a Timer

在运行这组更改并刷新浏览器时,您会看到有两行问题要开始处理。两秒钟后,添加第三行,其中包含新生成的 ID 和样本问题的内容。三排表截图如图 4-2 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig2_HTML.jpg

图 4-2

将行追加到初始问题集

注意,我们没有在IssueRow组件上显式调用setState()。React 会自动将任何依赖于父组件状态的更改传播到子组件。此外,我们不必编写任何代码来将行插入 DOM。React 计算了对虚拟 DOM 的更改,并插入了一个新行。

此时,可以直观地描绘出组件的层次结构和数据流,如图 4-3 所示。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig3_HTML.jpg

图 4-3

设置状态并将数据作为道具传递

练习:更新状态

  1. 在第一个定时器之后设置另一个定时器,比如说三秒钟,根据sampleIssue添加另一个问题。当添加第二个新问题时,您是否注意到有些地方出错了?提示:看第一期新发行的 ID。你认为这是为什么?怎么能纠正呢?

  2. IssueRowrender()方法中添加一个console.log。你预计render()会被叫多少次?您看到多少控制台日志?(确保您撤消了在之前的练习中所做的更改!)

本章末尾有答案。

提升状态

在我们添加用户界面元素来创建新的问题之前,让我们将创建的开始移动到它真正属于的地方:在IssueAdd组件中。这将允许我们一步一步地处理变更,因为将添加新问题的计时器从IssueTable组件移动到IssueAdd组件并不像第一次出现时那么简单。

如果你真的试图移动它,你会立即意识到createIssue()方法也必须移动,或者我们需要在IssueAdd中有一个变体,它可以与IssueTable通信并调用继续保留在那里的createIssue()方法。但是在 React 中,兄弟姐妹之间没有直接的交流方式。只有父母才能把信息传递给孩子;横向交流似乎很难,如果不是不可能的话。

解决这个问题的方法是让公共父包含状态和所有处理这个状态的方法。通过将状态提升到级别IssueList,信息可以向下传播到IssueAdd以及IssueTable

让我们从将状态转移到IssueList和装载初始状态的方法开始。IssueTable的构造函数既有状态初始化又有定时器,其中只有状态初始化需要移动(定时器会移动到IssueAdd):

...
class IssueList extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
  }
...

其他处理状态的方法有componentDidMount()loadData()createIssue()。让我们把这些也移到IssueList类:

...
class IssueList extends React.Component {
  ...
  componentDidMount() {
    ...
  }
  loadData() {
    ...
  }
  createIssue(issue) {
    ...
  }
  ...
}

现在,IssueTable没有状态来构造IssueRow组件。但是你已经看到了数据是如何以props的形式从父母传递给孩子的。让我们使用这个策略,通过 props 将一系列问题从IssueList内的状态传递给IssueTable:

...
        <IssueTable issues={this.state.issues} />
...

并且,在IssueTable中,我们需要从 props 中获取相同的数据,而不是引用状态变量issues:

...
    const issueRows = this.state.issues.map(issue =>
    const issueRows = this.props.issues.map(issue =>
...

至于IssueAdd,我们需要将计时器移入这个类的构造函数中,并从这个组件中触发一个新问题的添加。但是我们这里没有可用的createIssue()方法。幸运的是,由于父组件可以将信息传递给子组件,我们将把方法本身作为道具的一部分从IssueList传递给IssueAdd,这样就可以从IssueAdd调用它。下面是IssueListIssueAdd组件的实例化变化:

...
        <IssueAdd createIssue={this.createIssue} />
...

这让我们使用this.props.createIssue()作为计时器回调的一部分,从IssueAdd调用createIssue()。因此,让我们在IssueAdd中创建一个构造函数,并对设置的计时器进行一点小小的修改,以使用通过道具传入的createIssue回调,如下所示:

...
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
...

在我们可以说我们已经完成了这一组更改之前,我们还需要处理另外一件事情。与此同时,我们一直使用 arrow 函数语法来设置计时器。在 ES2015 中,箭头功能具有将上下文(this的值)设置为词法范围的效果。这意味着回调中的this将引用词法范围内的this,也就是说,在匿名函数之外,代码存在的地方。

只要被调用的函数与计时器回调在同一个类中,这就可以工作。在loadData()方法中,它仍然有效,因为this指的是计时器被触发的IssueList组件,因此,this.state指的是IssueList本身的状态。

但是,当从IssueAdd内的定时器调用createIssue时,this将引用IssueAdd组件。但是我们真正想要的是createIssue总是用this指代IssueList组件。否则,this.state.issues将未定义。

实现这一点的方法是,在传递之前将方法绑定到IssueList组件。我们可以在像这样实例化IssueAdd时进行这样的更改:

...
        <IssueAdd createIssue={this.createIssue.bind(this)} />
...

但是,如果我们需要再次引用同一个方法并将其传递给其他子组件,我们就必须重复这段代码。此外,永远不会出现我们需要方法不被绑定的情况,所以最好用其自身的绑定版本替换createIssue的定义。推荐的方法是在实现此方法的类的构造函数中。

所以,与其在IssueAdd的实例化过程中绑定,不如在IssueList的构造函数中绑定。

...
    this.createIssue = this.createIssue.bind(this);
...

在做了所有这些更改之后,这些类的新版本如下面的清单所示。清单 4-4 显示了新的IssueTable类;清单 4-5 展示了新的IssueAdd类;清单 4-6 显示了新的IssueList类。

class IssueTable extends React.Component {
  render() {
    const issueRows = this.props.issues.map(issue =>
      <IssueRow key={issue.id} issue={issue} />
    );

    return (
      <table className="bordered-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>Status</th>
            <th>Owner</th>
            <th>Created</th>
            <th>Effort</th>
            <th>Due Date</th>
            <th>Title</th>
          </tr>
        </thead>
        <tbody>
          {issueRows}
        </tbody>
      </table>
    );
  }
}

Listing 4-4App.jsx: New IssueTable Class

class IssueAdd extends React.Component {
  constructor() {
    super();
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
  }
  render() {
    return (
      <div>This is a placeholder for a form to add an issue.</div>
    );
  }
}

Listing 4-5App.jsx, IssueAdd: New IssueAdd Class

class IssueList extends React.Component {
  constructor() {
    super();
    this.state = { issues: [] };
    this.createIssue = this.createIssue.bind(this);
  }

  componentDidMount() {
    this.loadData();
  }

  loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
  }

  createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
  }

  render() {
    return (
      <React.Fragment>
        <h1>Issue Tracker</h1>
        <IssueFilter />
        <hr />
        <IssueTable issues={this.state.issues} />
        <hr />
        <IssueAdd createIssue={this.createIssue} />
      </React.Fragment>
    );
  }
}

Listing 4-6App.jsx, IssueList: New IssueList Class

这些更改的效果在用户界面中看不到。应用将像以前一样运行。在刷新浏览器时,您将看到一个空的表格,很快就会填充两个问题,两秒钟后,将添加另一个问题。

但是这为我们做了很好的准备,我们可以用一个按钮代替IssueAdd中的计时器,用户可以点击这个按钮来添加新的问题。

练习:提升状态

  1. 移除方法createIssue()的绑定。您在控制台中看到什么错误?它告诉你什么?

本章末尾有答案。

事件处理

现在让我们通过点击一个按钮来交互式地添加一个问题,而不是使用计时器来添加。我们将创建一个包含两个文本输入的表单,并使用用户在其中输入的值来添加一个新问题。添加按钮将触发添加。

让我们首先创建一个表单,用IssueAddrender()方法中的两个文本输入代替占位符div

...
      <div>This is a placeholder for a form to add an issue.</div>
      <form>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button>Add</button>
      </form>
...

此时,我们可以从构造函数中移除产生问题的计时器。

...
  constructor() {
    super();
    setTimeout(() => {
      this.props.createIssue(sampleIssue);
    }, 2000);
  }
...

如果您运行代码,您将看到一个表单代替了IssueAdd中的占位符。图 4-4 中显示了这个屏幕截图。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig4_HTML.jpg

图 4-4

IssueAdd 将占位符替换为表单

此时,单击 Add 将提交表单并再次获取相同的屏幕。这不是我们想要的。首先,我们希望它使用 owner 和 title 字段中的值来调用createIssue()。其次,我们希望阻止表单被提交,因为我们将自己处理该事件。

为了处理像onclickonsubmit这样的事件,我们需要提供给元素的属性简单来说就是onClickonSubmit。与普通的 HTML 和 JavaScript 一样,这些属性将函数作为值。我们将创建一个名为handleSubmit()的类方法,在点击添加按钮时接收表单中的提交事件。在这个方法中,我们需要一个表单的句柄,所以像在常规 HTML 中一样,让我们给表单一个名字,比如说,issueAdd,然后可以在 JavaScript 中使用document.forms.issueAdd来引用它。

所以,让我们像这样用一个名字和一个onSubmit处理程序重写表单声明。

...
            <form name="issueAdd" onSubmit={this.handleSubmit}>
...

现在,我们可以在IssueAdd中实现方法handleSubmit()。该方法接收触发提交的事件作为参数。为了防止点击添加按钮时表单被提交,我们需要在事件上调用preventDefault()函数。然后,通过documents.forms.issueAdd使用表单句柄,我们可以获得文本输入字段的值。利用这些,我们将通过调用createIssue()创建一个新的问题。在调用了createIssue()之后,让我们通过清除文本输入字段来为下一组输入准备好表单。

...
  handleSubmit(e) {
    e.preventDefault();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
    }
    this.props.createIssue(issue);
    form.owner.value = ""; form.title.value = "";
  }
...

注意

在这一点上,我们使用传统的方式获取用户输入,使用命名输入并使用 DOM 元素的value属性获取它们的值。React 有另一种处理用户输入的方式,通过控制的组件,输入的值被绑定到一个状态变量。我们将在后面的章节中探讨这一点。

因为handleSubmit将从一个事件中被调用,所以上下文或者说this将被设置为生成事件的对象,通常是window对象。正如您在上一节中看到的,要让这个方法通过this访问对象变量,我们需要在构造函数中将它绑定到this:

...
  constructor() {
    super();
    this.handleSubmit = this.handleSubmit.bind(this);
  }
...

清单 4-7 显示了经过这些修改后的IssueAdd类的新的完整代码。

class IssueAdd extends React.Component {
  constructor() {
    super();
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleSubmit(e) {
    e.preventDefault();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
    }
    this.props.createIssue(issue);
    form.owner.value = ""; form.title.value = "";
  }

  render() {
    return (
      <form name="issueAdd" onSubmit={this.handleSubmit}>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button>Add</button>
      </form>
    );
  }
}

Listing 4-7App.jsx, IssueList: New IssueAdd Class

不再需要全局对象sampleIssue,所以我们可以去掉它。这一变化如清单 4-8 所示。

...

const sampleIssue = {

  status: 'New', owner: 'Pieta',
  title: 'Completion date should be optional',

};

...

Listing 4-8App.jsx, Removal of sampleIssue

现在,您可以通过在 owner 和 title 字段中输入一些值并单击 Add 来测试这些更改。您可以添加任意多的行。如果您添加两个问题,您将得到如图 4-5 所示的屏幕。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig5_HTML.jpg

图 4-5

使用 IssueAdd 表单添加新问题

最后,我们已经能够从IssueAdd组件本身封装和发起一个新问题的创建。为了做到这一点,我们将状态“提升”到最不常见的祖先,这样所有的子 Node 都可以通过传入的属性或通过可以修改状态的回调直接访问它。图 4-6 中描述了这种新的用户界面层次数据和功能流程。与图 4-3 中状态保持的情况相比。

img/426054_2_En_4_Chapter/426054_2_En_4_Fig6_HTML.jpg

图 4-6

提升状态后的组件层次结构和数据流

练习:事件处理

  1. 刷新浏览器;你会看到增加的问题都不见了。一个人如何保持变化?

  2. 移除e.preventDefault()。单击“添加”按钮,为“所有者”和“职位”添加一些值。会发生什么?你在地址栏里看到了什么?你能解释这个吗?

  3. 使用开发人员控制台检查该表,并在<tbody>元素上添加一个断点作为“在子树修改时中断”。现在,添加一个新问题。子树被修改了多少次?与“更新状态”中的练习#2 相比,在练习# 2 中,您跟踪了一个IssueRow中的render()调用的数量。

本章末尾有答案。

无状态组件

我们有三个起作用的 React 组件(IssueAddIssueRowIssueTable),它们被分层组合成IssueList(另一个组件IssueFilter,仍然是一个占位符)。但是这些功能组件类之间存在差异。

有很多方法,一个状态,状态的初始化,以及修改状态的函数。相比较而言,IssueAdd有一定的交互性,但没有状态 1 。但是,如果你注意到,IssueRowIssueTable除了一个render()方法之外什么都没有。出于性能和代码清晰的原因,建议将这些组件写成函数而不是类:一个接受props并基于它进行渲染的函数。就好像组件的视图是其props的纯函数,并且是无状态的。render()函数本身可以是组件。

如果一个组件不依赖于 props,可以写成一个简单的函数,函数的名字就是组件名。例如,考虑我们在第 2 (React 组件)一章开头写的 Hello World 类:

...
class HelloWorld extends React.Component {
  render() {
    return (
      <div title="Outer div">
        <h1>Hello World!</h1>
      </div>
    );
  }
}
...

这可以重写为一个像这样的纯函数:

...
function HelloWorld() {
  return (
    <div title="Outer div">
      <h1>Hello World!</h1>
    </div>
  );
}
...

如果渲染只依赖于道具(通常情况下,确实是这样),函数可以用一个参数作为道具来编写,可以在函数的 JSX 体中访问这些参数。假设 Hello World 组件接收一条消息作为道具的一部分。该组件可以重写如下:

...
function HelloWorld(props) {
  return (
    <div title="Outer div">
      <h1>{props.message}</h1>
    </div>
  );
}
...

当呈现的输出可以表示为 JavaScript 表达式时,可以使用使用箭头函数的更简洁的形式,也就是说,除了 return 语句之外没有其他语句的函数:

...
const HelloWorld = (props) => (
  <div title="Outer div">
    <h1>{props.message}</h1>
  </div>
);
...

这个HelloWorld组件可以这样实例化:

...
  <HelloWorld message="Hello World" />
...

既然IssueRowIssueTable是无状态组件,那我们就把它们改成纯函数吧。新部件分别如清单 4-9 和清单 4-10 所示。

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      <td>{issue.id}</td>
      <td>{issue.status}</td>
      <td>{issue.owner}</td>
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ''}</td>
      <td>{issue.title}</td>
    </tr>
  );
}

Listing 4-9App.jsx, IssueRow as a Stateless Component

function IssueTable(props) {
  const issueRows = props.issues.map(issue =>
    <IssueRow key={issue.id} issue={issue} />
  );

  return (
    <table className="bordered-table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Status</th>
          <th>Owner</th>
          <th>Created</th>
          <th>Effort</th>
          <th>Due Date</th>
          <th>Title</th>
        </tr>
      </thead>
      <tbody>
        {issueRows}
      </tbody>
    </table>
  );
}

Listing 4-10App.jsx, IssueTable as a Stateless Component

设计组件

大多数初学者会对 state 和props有点困惑,什么时候使用哪个,应该选择什么粒度的组件,以及如何完成这一切。本节专门讨论一些原则和最佳实践。

国家对道具

state 和props都保存模型信息,但它们是不同的。props是不可变的,而 state 不是。通常,状态变量作为props传递给子组件,因为子组件不会维护或修改它们。它们接收一个只读副本,并仅用它来渲染组件的视图。如果子 Node 中的任何事件影响了父 Node 的状态,子 Node 将调用父 Node 中定义的方法。对这个方法的访问应该已经通过props作为回调传递了。

由于组件层次结构中任何地方的事件,能够改变的任何东西都被认为是状态的一部分。避免将计算值保存在状态中;相反,只需在需要时计算它们,通常在render()方法中。

不要将props复制到状态中,因为props是不可变的。如果你觉得有必要这么做,考虑修改这些props的原始状态。一个例外是当props被用作状态的初始值,并且该状态在初始化后确实与原始状态脱节。

您可以使用表 4-1 作为差异的快速参考。

表 4-1

国家对道具

|

属性

|

状态

|

道具

|
| --- | --- | --- |
| 易变性 | 可以使用this.setState()进行更改 | 无法改变 |
| 所有权 | 属于组件 | 属于祖先,则该组件将获得一个只读副本 |
| 信息 | 模型数据 | 模型数据 |
| 影响 | 组件的渲染 | 组件的渲染 |

组件层次结构

将应用分成组件和子组件。通常,这将反映数据模型本身。例如,在问题跟踪器中,问题数组由IssueTable组件表示,每个问题由IssueRow组件表示。

就像拆分函数和对象一样决定粒度。该组件应该是自包含的,具有到父组件的最少的逻辑接口。如果你发现它做了太多的事情,就像在函数中一样,它可能应该被拆分成多个组件,这样它就遵循了单一责任原则(即每个组件应该只负责一件事情)。如果您向一个组件传递了太多的props,这表明要么该组件需要被拆分,要么它不需要存在:父组件本身可以完成这项工作。

沟通

组件之间的通信取决于方向。父母通过props与孩子沟通;当状态改变时,props自动改变。孩子们通过回电与父母交流。

兄弟姐妹和表兄弟姐妹之间无法交流,所以如果有需要,信息必须向上传递,然后再向下传递。这被称为提升状态。这就是我们在处理添加新问题时所做的。IssueAdd组件必须在IssueTable中插入一行。这是通过保持国家在最少的共同祖先,IssueList。添加由IssueAdd发起,一个新的数组元素通过回调添加到IssueList的状态中。通过将issues数组从IssueList向下传递为props,可以在IssueTable中看到结果。

如果父母有必要知道孩子的状态,你可能做错了。虽然 React 确实提供了一种使用refs的方法,但是如果你严格遵循单向数据流,你应该不会觉得有必要:状态作为道具流入子 Node,事件引起状态变化,状态变化作为道具流回。

无状态组件

在设计良好的应用中,大多数组件都是其属性的无状态函数。所有的状态都将在层次结构顶部的几个组件中被捕获,所有后代的道具都是从这些组件中派生出来的。

我们对IssueList就是这么做的,在那里我们保持状态。我们将所有派生组件转换为无状态组件,只依赖于层次结构中传递的属性来呈现它们自己。我们将状态保留在IssueList中,因为这是依赖于该状态的所有后代中最不常见的组件。有时候,你可能会发现,没有逻辑上的共同祖先。在这种情况下,您可能不得不发明一个新的组件来保存状态,即使该组件在视觉上什么也没有。

摘要

在本章中,您学习了如何在用户交互或其他事件中使用状态并对其进行更改。更有趣的方面是状态值如何像props一样沿着组件层次结构向下传播。您还了解了用户交互:单击按钮添加新问题,以及这如何导致状态发生变化,进而,子组件中的道具如何发生变化,导致它们也重新呈现。此外,您还了解了孩子如何通过回调与父母交流。

我们使用模拟异步调用和浏览器本地数据来实现这一切。在下一章,我们将从服务器获取数据,而不是使用本地数据。当添加一个问题时,我们会将数据发送到服务器进行保存。

练习答案

练习:初始状态

  1. 您可以将每一行的背景色存储为状态的一部分,但是,这些值必须在某个时间点进行计算。什么时候是做这件事的好时机?就在设置状态之前?设置初始状态的时候呢?

因为这是一个从派生的值,所以在render()方法中计算这些值并随时使用它们比将它们保存在状态中更好也更有效。

练习:更新状态

  1. 当第二个计时器触发并添加另一个问题时,您会发现它的 ID 为 4,但是第三行的 ID 也变为 4。此外,在控制台中,您将看到一个错误,大意是找到了具有相同键的两个孩子。

    发生这种情况是因为我们使用与第一个相同的对象来创建第二个问题,并且将 ID 设置为 4 会将其设置在唯一的对象:sampleIssue中。为了避免这种情况,您必须在使用它创建新问题之前创建对象的副本,比如说,使用Object.assign()

  2. 初始化时,每行呈现一次(两次呈现,每行一次)。插入新行后,每一行都被渲染一次(三次渲染,每行一次)。虽然调用了 render,但这并不意味着 DOM 被更新了。每次渲染时只重新创建虚拟 DOM。真正的 DOM 更新只发生在有差异的地方。

练习:提升状态

  1. 在移除bind()调用时,在评估this.state.issues时,您会看到一个错误,指出 undefined 不是对象。这应该告诉您this.state是未定义的,并 Bootstrap 您思考this是否是这个调用序列中正确的this

    将来,如果您看到类似的错误,它应该会触发一个想法,可能是某个地方的bind()调用丢失了。

练习:事件处理

  1. 为了持久保存更改,我们可以将问题保存在浏览器的本地存储中,或者保存在服务器中。修改全局initialIssues变量将不起作用,因为当页面刷新时,这个变量将被重新创建。

  2. 页面被刷新,就好像对/提出了新的请求。在地址栏里可以看到类似?owner=&title=的拥有者和头衔的 URL 查询参数。这是因为表单的默认动作是一个带有表单值的 GET HTTP 请求,您在 URL 栏中看到的只是这个调用的结果。(URL 参数中的值为空,因为它们在handleSubmit()中被赋给了空字符串)。

  3. 您将看到<tbody>下的子树只被修改了一次。在修改的细节中,您可以看到添加了一个孩子,但是没有修改任何其他现有的孩子。如果您将它与render()方法调用的数量进行比较,您会发现,尽管每一行都调用了render(),但是只有新的一行被添加到 DOM 中。

五、Express 和 GraphQL

现在,您已经学习了如何使用 React 创建组件和构建可工作的用户界面,在本章中,我们将花一些时间为数据集成后端服务器。

到目前为止,Express 和 Node.js 服务器提供的唯一资源是以index.html形式的静态内容。在本章中,除了静态 HTML 文件之外,我们将开始使用来自 Express 和 Node.js 服务器的 API 来获取和存储数据。这将取代浏览器内存中的硬编码问题数组。我们将对前端和后端代码进行更改,因为我们将实现和使用 API。

我们不会将数据保存在磁盘上;相反,我们将只使用服务器内存中的模拟数据库。我们将把真正的持久性留到下一章。

表达

我在 Hello World 一章中简要地提到了 Express 以及如何使用 Express 来服务静态文件。但是 Express 可以做的不仅仅是服务静态文件。Express 是一个最小但灵活的 web 应用框架。从某种意义上说,Express 本身做得很少。它依赖于被称为中间件的其他模块来提供大多数应用需要的功能。

选择途径

第一个概念是路由。Express 的核心是路由,它从本质上接受一个客户机请求,将它与任何存在的路由进行匹配,并执行与该路由相关联的处理程序功能。处理函数应该生成适当的响应。

路由规范由 HTTP 方法(GET、POST 等)组成。)、匹配请求 URI 的路径规范以及路由处理程序。处理程序在请求对象和响应对象中传递。可以检查请求对象以获得请求的各种细节,可以使用响应对象的方法将响应发送给客户端。所有这些可能看起来有点令人不知所措,所以让我们从一个简单的例子开始,并探索细节。

我们已经有了一个使用express()函数创建的 Express 应用。我们还安装了一个处理静态文件的中间件。中间件功能处理与路径规范匹配的任何请求,而不考虑 HTTP 方法。相反,路由可以用特定的 HTTP 方法匹配请求。因此,为了匹配 GET HTTP 方法,必须使用app.get()而不是app.use()。此外,处理函数(路由函数采用的第二个参数)可以将响应设置为发送回调用者,如下所示:

...
app.get('/hello', (req, res) => {
  res.send('Hello World!');
});
...

请求匹配

当收到一个请求时,Express 做的第一件事就是将请求与其中一个路由匹配。请求方法与路线的方法相匹配。在前面的例子中,路由的方法是get(),所以任何使用 GET 方法的 HTTP 请求都将匹配它。此外,请求 URL 与路径规范匹配,路径规范是路由中的第一个参数,即/ hello。当一个 HTTP 请求符合这个规范时,就会调用处理函数。在前面的例子中,我们只是用一条文本消息来响应。

路线的方法和路径不必是特定的。如果您想匹配所有的 HTTP 方法,您可以编写app.all()。如果需要匹配多个路径,可以传入一个路径数组,甚至像'/*.do'这样的正则表达式也可以匹配任何以扩展名.do结尾的请求。很少使用正则表达式,但是经常使用路由参数,所以我将对此进行更详细的讨论。

路线参数

路由参数是路径规范中与 URL 的一部分匹配的命名段。如果出现匹配,URL 中该部分的值将作为请求对象中的变量提供。

它以下列形式使用:

app.get('/customers/:customerId', ...)

URL /customers/1234将匹配路由规范,/customers/4567也是如此。在这两种情况下,客户 ID 将被捕获并作为请求的一部分提供给处理函数req.params,参数的名称作为关键字。因此,对于这些 URL 中的每一个,req.params.customerId将分别具有值12344567

注意

查询字符串不是路径规范的一部分,因此不能对查询字符串的不同参数或值使用不同的处理程序。

路线查找

可以设置多个路由来匹配不同的 URL 和模式。路由不会尝试寻找最佳匹配;相反,它会尝试按照安装顺序匹配所有路由。使用第一个匹配项。因此,如果两个路由可能匹配一个请求,它将使用第一个定义的路由。因此,必须按照优先级顺序定义路线。

因此,如果您添加模式而不是非常具体的路径,您应该注意在具体路径之后添加更通用的模式,以防请求同时匹配两者。例如,如果您想要匹配/api/下的所有内容,也就是说,像/api/*这样的模式,您应该只在处理路径的所有更具体的路由之后添加这个路由,例如/api/issues

处理函数

一旦匹配了路由,就调用处理函数,在前面的例子中,它是提供给路由设置函数的匿名函数。传递给处理程序的参数是请求对象和响应对象。处理函数不应该返回任何值。但是它可以检查请求对象,并根据请求参数发送响应作为响应对象的一部分。

让我们简单看一下请求和响应对象的重要属性和方法。

请求对象

使用请求对象的属性和方法可以检查请求的任何方面。下面列出了一些重要且有用的属性和方法:

  • req.params:这是一个包含映射到命名路由参数的属性的对象,正如您在使用:customerId的例子中看到的。属性的键将是路由参数的名称(在本例中是customerId),值将是作为 HTTP 请求的一部分发送的实际字符串。

  • req.query:保存解析后的查询字符串。它是一个以键作为查询字符串参数,以值作为查询字符串值的对象。多个同名的键被转换为数组,带有方括号符号的键导致嵌套对象(例如,order[status]=closed可以作为req.query.order.status访问)。

  • req.header, req.get(header):get方法可以访问请求中的任何头部。header 属性是一个对象,所有标题都存储为键值对。一些头被特殊处理(如 Accept ),并在请求对象中有专门的方法。这是因为依赖于这些标题的常见任务可以轻松处理。

  • req.path:这包含了 URL 的路径部分,也就是 everything up any?开始查询字符串。通常,路径是路由规范的一部分,但是如果路径是可以匹配不同 URL 的模式,则可以使用此属性来获取请求中接收到的实际路径。

  • req.url, req.originalURL:这些属性包含完整的 URL,包括查询字符串。注意,如果您有任何修改请求 URL 的中间件,originalURL将保存修改前收到的 URL。

  • req.body:包含请求体,对 POST、PUT 和 PATCH 请求有效。注意,主体是不可用的(req.body将是未定义的),除非安装了一个中间件来读取和选择性地解释或解析主体。

还有许多其他的方法和属性;完整的列表请参考 http://expressjs.com/en/api.html#req 的 Express 的请求文档以及 Node.js 的请求对象at https://nodejs.org/api/http.html#http_class_http_incomingmessage ,Express 请求是从该请求扩展而来的。

响应对象

response 对象用于构造和发送响应。请注意,如果没有发送响应,客户端将一直等待。

  • res.send(body):你已经简单看过了res.send()方法,它用一个字符串来响应。这个方法也可以接受一个缓冲区(在这种情况下,内容类型被设置为application/octet-stream,而不是字符串情况下的text/html)。如果主体是一个对象或数组,它会自动转换为具有适当内容类型的 JSON 字符串。

  • res.status(code):设置响应状态码。如果未设置,则默认为200 OK。一种常见的发送错误的方式是将status()send()方法组合在一个单独的调用中,就像res.status(403).send("Access Denied")一样。

  • res.json(object):这和res.send()一样,除了这个方法强制转换传入 JSON 的参数,而res.send()可能会不同地对待一些参数,比如null。它还使代码可读和明确,表明您确实在发送一个 JSON。

  • res.sendFile(path):以path的文件内容响应。使用文件的扩展名猜测响应的内容类型。

响应对象中还有许多其他方法和属性;在 http://expressjs.com/en/api.html#res 可以查看 Express 文档中的完整列表,在 https://nodejs.org/api/http.html#http_class_http_serverresponse 可以查看 HTTP 模块中 Node.js 的 Response 对象。但是对于最常见的使用,前面的方法应该足够了。

中间件

Express 是一个 web 框架,它本身的功能很少。Express 应用本质上是一系列中间件函数调用。其实路由本身无非就是一个中间件功能。区别在于,中间件通常对请求和/或需要为所有或大多数请求完成的事情进行一般处理,但不一定是发送响应的链中的最后一个。另一方面,路由旨在用于特定的路径+方法组合,并被期望发出响应。

中间件功能是那些可以访问请求对象(req)、响应对象(res)以及应用的请求-响应周期中的下一个中间件功能的功能。下一个中间件功能通常用一个名为next的变量来表示。我不会详细讨论如何编写自己的中间件函数,因为我们不会在应用中编写新的中间件。但是我们肯定会使用一些中间件,所以理解任何中间件如何在高层次上工作是很方便的。

在 Hello World 示例中,我们已经使用了一个名为express.static的中间件来服务静态文件。这是作为 Express 的一部分唯一可用的内置中间件(除了路由)。但是 Express 团队还支持其他非常有用的中间件,我们将在本章中使用 body-parser,尽管是间接使用。第三方中间件可通过 npm 获得。

中间件可以在应用级别(适用于所有请求)或特定的路径级别(适用于特定的请求路径模式)。在应用级别使用中间件的方法是简单地向应用提供功能,就像这样:

app.use(middlewareFunction);

在使用static中间件的情况下,我们通过调用express.static()方法构建了一个中间件功能。这不仅返回了一个中间件函数,还配置它使用名为public的目录来查找静态文件。

为了将相同的中间件仅用于匹配某个 URL 路径的请求,比如说,/public,调用app.use()方法时必须使用两个参数,第一个参数是路径,如下所示:

app.use('/public', express.static('public'));

这将使在路径/public上安装静态中间件,所有静态文件都必须用前缀/public访问,例如/public/index.html

应用接口

REST(表述性状态转移的缩写)是应用编程接口(API)的架构模式。还有其他更老的模式,比如 SOAP 和 XMLRPC,但是最近,REST 模式越来越流行。

由于 Issue Tracker 应用中的 API 仅供内部使用,我们可以使用任何 API 模式,甚至可以发明自己的模式。但是我们不要这样做,因为使用现有的模式会迫使您更好地思考和组织 API 和模式,并鼓励一些好的实践。

虽然我们不会使用 REST 模式,但我会简单地讨论一下,因为由于它的简单性和少量的构造,它是更受欢迎的选择之一。它会让你体会到我最终选择使用 GraphQL 的区别和逻辑。

基于资源

API 是基于资源的(而不是基于动作的)。因此,像getSomethingsaveSomething这样的 API 名称在 REST APIs 中并不常见。事实上,没有传统意义上的 API 名称,因为 API 是由资源和动作组合而成的。实际上只有资源名叫做端点

基于统一资源标识符(URI,也称为端点)来访问资源。资源是名词(不是动词)。通常每个资源使用两个 URIs:一个用于集合(如/customers),一个用于单个对象(如/customers/1234),其中1234唯一地标识一个客户。

资源也可以形成层次结构。例如,客户的订单集合由/customers/1234/orders标识,该客户的订单由/customers/1234/orders/43标识。

作为操作的 HTTP 方法

要访问和操作资源,可以使用 HTTP 方法。资源是名词,而 HTTP 方法是操作它们的动词。它们映射到资源上的 CRUD(创建、读取、更新、删除)操作。表 5-1 显示了 CRUD 操作到 HTTP 方法和资源的常用映射。

表 5-1

HTTP 方法的 CRUD 映射

|

操作

|

方法

|

资源

|

例子

|

备注

|
| --- | --- | --- | --- | --- |
| 阅读列表 | 得到 | 募捐 | GET /customers | 列出对象(附加查询字符串可用于过滤和排序) |
| 阅读 | 得到 | 目标 | GET /customers/1234 | 返回单个对象(查询字符串可用于指定哪些字段) |
| 创造 | 邮政 | 募捐 | POST /customers | 使用主体中指定的值创建对象 |
| 更新 | 放 | 目标 | PUT /customers/1234 | 用正文中指定的对象替换该对象 |
| 更新 | 修补 | 目标 | PATCH /customers/1234 | 按照主体中的指定,修改对象的某些属性 |
| 删除 | 删除 | 目标 | DELETE /customers/1234 | 删除对象 |

其他一些操作,比如 DELETE 和 PUT in 集合,也可以用来一次性删除和修改整个集合,但是这种用法并不常见。HEAD 和 OPTIONS 也是有效的动词,它们给出关于资源的信息,而不是实际的数据。它们主要用于向外公开并由许多不同客户端使用的 API。

尽管 HTTP 方法和操作映射被很好地映射和指定,REST 本身并没有为以下内容制定规则:

  • 对对象列表进行过滤、排序和分页。查询字符串通常以特定于实现的方式来指定这些内容。

  • 指定在读取操作中返回哪些字段。

  • 如果有嵌入对象,指定在读取操作中要扩展哪些对象。

  • 指定要在修补操作中修改的字段。

  • 对象的表示。在读取和写入操作中,您可以自由地使用 JSON、XML 或任何其他对象表示。

鉴于不同的 API 集使用不同的方式来处理这些问题,大多数 REST API 实现更像 REST- 而不是严格的 REST。这影响了普遍采用,因此,缺少工具来帮助完成实现基于 REST 的 API 所需的许多常见工作。

GraphQL(图形 SQL)

尽管 REST 范式在使 API 可预测方面非常有用,但是前面讨论的缺点使得当不同的客户端访问同一组 API 时很难使用它。例如,一个对象在移动应用中的显示方式和在桌面浏览器中的显示方式可能会有很大不同,因此,更细粒度的控制以及不同资源的聚合可能会更好。

GraphQL 正是为了解决这些问题而开发的。因此,GraphQL 是一个更加精细的规范,具有以下显著特征。

字段规格

与 REST APIss 不同,在 REST API 中,您很难控制服务器将什么作为对象的一部分返回,而在 GraphQL 中,必须指定需要返回的对象的属性。在 REST API 中,不指定对象的字段会返回整个对象。相反,在 GraphQL 查询中,不请求任何内容是无效的。

这使得客户端可以控制通过网络传输的数据量,从而提高效率,特别是对于移动应用等轻型前端。此外,添加新功能(字段或新 API)不需要您引入新版本的 API 集。给定一个查询,由于返回数据的形状是由其决定的,所以不管 API 如何变化,结果都是一样的。

不利的一面是 GraphQL 查询语言有一点学习曲线,任何 API 调用都必须使用它。幸运的是,这种语言的规范非常简单,很容易掌握。

基于图形

REST APIs 是基于资源的,而 GraphQL 是基于图形的。这意味着对象之间的关系在 GraphQL APIs 中被自然地处理。

在问题跟踪器应用中,您可以认为问题和用户有关系:问题分配给用户,用户有一个或多个分配给他们的问题。当查询用户的属性时,GraphQL 可以很自然地查询与分配给他们的所有问题相关的一些属性。

单端点

GraphQL API 服务器有一个端点,而 REST 中每个资源只有一个端点。被访问的资源或字段的名称作为查询本身的一部分提供。

这使得使用单个查询来查询客户端所需的所有数据成为可能。由于查询基于图形的性质,所有相关对象都可以作为一个对象的查询的一部分来检索。不仅如此,甚至不相关的对象也可以在对 API 服务器的一次调用中被查询。这消除了对“聚合”服务的需求,聚合服务的工作是将多个 API 结果放在一个包中。

强类型

GraphQL 是一种强类型查询语言。所有字段和参数都有一个类型,可以根据该类型来验证查询和结果,并给出描述性的错误消息。除了类型之外,还可以指定哪些字段和参数是必需的,哪些是可选的。所有这些都是使用 GraphQL 模式语言完成的。

强类型系统的优点是它可以防止错误。考虑到 API 是由不同的团队编写和使用的,因此必然会有沟通上的差距,这是一件很棒的事情。

GraphQL 的类型系统有自己的语言来指定您希望在 API 中支持的类型的细节。它支持整数和字符串等基本标量类型、由这些基本数据类型组成的对象以及自定义标量类型和枚举。

反省

可以向 GraphQL 服务器查询它所支持的类型。这为工具和客户端软件创建了一个强大的平台来构建这些信息。这包括静态类型语言中的代码生成工具和资源管理器,使开发人员能够快速测试和学习 API 集,而无需重新整理代码库或与 cURL 争论。

我们将使用一个这样的工具,叫做 Apollo Playground,来测试我们的 API,然后将它们集成到应用的 UI 中。

图书馆

单独解析和处理类型系统语言(也称为 GraphQL 模式语言)以及查询语言是很困难的。幸运的是,大多数语言中都有用于此目的的工具和库。

对于后端的 JavaScript,有一个名为 GraphQL.js 的 GraphQL 参考实现。为了将它与 Express 联系起来,并使 HTTP 请求成为 API 调用的传输机制,有一个名为express-graphql的包。

但是这些都是非常基本的工具,缺乏一些高级支持,比如模块化模式和定制标量类型的无缝处理。包graphql-tools和相关的apollo-server构建在 GraphQL.js 之上,以添加这些高级特性。在本章中,我们将使用问题跟踪器应用的高级包。

我将只介绍应用所需的 GraphQL 特性。对于您在自己的特定应用中可能需要的高级功能,请参考位于 https://graphql.org 的 GraphQL 的完整文档和位于 https://www.apollographql.com/docs/graphql-tools/ 的工具。

关于 API

让我们从一个简单的 API 开始,它返回一个字符串,叫做 About。在这一节中,我们将实现这个 API 以及另一个 API,它允许我们更改这个 API 返回的字符串。这将让您学习使用 GraphQL 进行简单读取和写入的基础知识。

在我们开始为它编写代码之前,我们需要用于graphql-toolsapollo-server的 npm 包,以及它们所依赖的基础包graphql。包graphql-toolsapollo-server-express的依赖项,所以我们不必明确指定,而graphql是对等依赖项,需要单独安装。以下是安装它们的命令:

$ npm install graphql@0 apollo-server-express@2

现在,让我们定义我们需要支持的 API 的模式。GraphQL 模式语言要求我们使用关键字type定义每种类型,后跟类型的名称,再加上花括号中的规范。例如,要定义一个包含用户名字符串的User类型,这是模式语言中的规范:

...
type User {
  name: String
}
...

对于 About API,我们不需要任何特殊的类型,只要基本的数据类型String就足够好了。但是 GraphQL 模式有两个特殊的类型,它们是类型系统的入口点,称为QueryMutation。所有其他 API 或字段都是在这两种类型下分层定义的,它们就像 API 的入口点。查询字段应该返回现有状态,而突变字段应该改变应用数据中的某些内容。

模式必须至少有Query类型。查询和变异类型之间的区别是概念上的:在一个查询或变异中没有什么是您在另一个查询或变异中不能做的。但是一个微妙的区别是,查询字段是并行执行的,而变异字段是串行执行的。所以,最好按照它们的本意来使用它们:在Query下实现读操作,在Mutation下实现修改系统的东西。

GraphQL 类型系统支持以下基本数据类型:

  • Int:有符号 32 位整数。

  • Float:有符号双精度浮点值。

  • String:UTF 8 字符序列。

  • Boolean : truefalse

  • ID:表示唯一的标识符,序列化为字符串。使用一个ID代替一个字符串表明它不适合人类阅读。

除了指定类型之外,模式语言还提供了一个指示值是可选的还是强制的条款。默认情况下,所有值都是可选的(也就是说,它们可以是 null),那些需要值的值是通过在类型后添加一个感叹号(!)来定义的。

在 About API 中,我们只需要在Query下有一个名为about的字段,这个字段是一个字符串,也是一个强制字段。注意,模式定义是 JavaScript 中的一个字符串。我们将使用模板字符串格式,这样我们可以在模式中平滑地添加新行。因此,可以查询的about字段的模式定义如下:

...
const typeDefs = `
  type Query {
    about: String!
  }
`;
...

我们将在初始化服务器时使用变量typeDefs,但在此之前,让我们定义另一个字段,让我们更改消息并将其称为setAboutMessage。但是这需要为我们将接收的新消息输入一个值。这种输入值的指定就像函数调用一样:使用括号。因此,为了表明这个字段需要一个名为message的强制字符串输入,我们需要编写:

...
  setAboutMessage(message: String!)
...

请注意,所有参数都必须命名。GraphQL 模式语言中没有位置参数。此外,所有字段都必须有一个类型,并且没有 void 或其他类型指示该字段不返回任何内容。为了克服这一点,我们可以使用任何数据类型,并使其可选,这样调用者就不需要一个值。

让我们使用字符串数据类型作为setAboutMessage字段的返回值,并将其添加到模式中的Mutation类型下。让我们将包含模式的变量命名为typeDefs,并在server.js中将它定义为一个字符串:

...
const typeDefs = `
  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String
  }
`;
...

注意,我不再调用这些 API,而是调用类似于setAboutMessage字段的东西。这是因为所有的 GraphQL 都只有字段,访问字段会有副作用,比如设置一些值。

下一步是拥有在访问这些字段时可以调用的处理程序或函数。这样的函数被称为解析器,因为它们将查询解析为具有实值的字段。虽然模式定义是用特殊的模式语言完成的,但是解析器的实现依赖于我们使用的编程语言。例如,如果您要用 Python 定义 About API 集,那么模式字符串将与 JavaScript 中的相同。但是处理程序看起来与我们用 JavaScript 编写的完全不同。

在 Apollo 服务器和graphql-tools中,解析器被指定为遵循模式结构的嵌套对象。在每个叶级别,需要使用与字段同名的函数来解析字段。因此,在最顶层,我们将在解析器中有两个名为QueryMutation的属性。让我们开始定义它:

...
const resolvers = {
  Query: {
  },
  Mutation: {
  },
};
...

Query对象中,我们需要一个用于about的属性,这是一个返回 About 消息的函数。让我们首先将消息定义为文件顶部的一个变量。因为我们将在setAboutMessage字段中改变消息的值,所以我们需要使用let关键字而不是const

...
let aboutMessage = "Issue Tracker API v1.0";
...

现在,函数需要做的就是返回这个变量。一个简单的不带参数的箭头函数应该可以达到这个目的:

...
  Query: {
    about: () => aboutMessage,
  },
...

因为我们需要接收输入参数,所以setAboutMessage函数没有这么简单。所有解析器函数都有四个参数,如下所示:

fieldName(obj, args, context, info)

参数描述如下:

  • obj:包含父字段解析器返回结果的对象。该参数启用了 GraphQL 查询的嵌套性质。

  • args:一个对象,其参数被传递到查询中的字段中。例如,如果用setAboutMessage(message: "New Message")调用字段,args对象将是:{ "message": "New Message" }

  • context:这是一个由特定查询中的所有解析器共享的对象,用于包含每个请求的状态,包括身份验证信息、数据加载器实例以及解析查询时应该考虑的任何其他内容。

  • info:这个参数应该只在高级情况下使用,但是它包含了关于查询执行状态的信息。

返回值应该是架构中指定的类型。在字段setAboutMessage的情况下,由于返回值是可选的,所以它可以选择不返回任何内容。但是,返回某个值来指示该字段的成功执行是一个很好的实践,所以让我们只返回message输入值。在这种情况下,我们也不会使用父对象(Query)的任何属性,所以我们可以忽略第一个参数obj,只使用args中的属性。因此,setAboutMessage的函数定义如下:

...
function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}
...

注意

我们使用 ES2015 析构赋值特性来访问第二个参数args中的message属性。这相当于将参数命名为args,并将属性访问为args.message,而不是简单的message

现在,我们可以将该函数指定为顶级字段MutationsetAboutMessage的解析器,如下所示:

...
  Mutation: {
    setAboutMessage,
  },
...

注意

我们使用 ES2015 对象属性简写来指定setAboutMessage属性的值。当属性名和赋给它的变量名相同时,变量名可以跳过。因此,{ setAboutMessage: setAboutMessage }可以简单地写成{ setAboutMessage }

既然我们已经定义了模式以及相应的解析器,我们就可以初始化 GraphQL 服务器了。方法是构造一个在apollo-server-express包中定义的ApolloServer对象。构造函数接受一个至少有两个属性的对象——typeDefsresolvers——并返回一个 GraphQL 服务器对象。下面是实现这一点的代码:

...
const { ApolloServer } = require('apollo-server-express');
...
const server = new ApolloServer({
  typeDefs,
  resolvers,
});
...

最后,我们需要在 Express 中安装 Apollo 服务器作为中间件。我们需要一个路径(单个端点)来安装中间件。但是,阿波罗服务器不是一个单一的中间件;事实上,有一组中间件功能以不同的方式处理不同的 HTTP 方法。ApolloServer对象为我们提供了一个方便的方法来完成所有这些工作,这个方法叫做applyMiddleware。它接受一个配置对象作为它配置服务器的参数,其中两个重要的属性是apppath。因此,要在 Express 应用中安装中间件,让我们添加以下代码:

...
server.applyMiddleware({ app, path: '/graphql' });
...

将所有这些放在一起,我们应该有一个工作的 API 服务器。清单 5-1 显示了server.js的新内容,其中包含了所有的代码片段。

const express = require('express');
const { ApolloServer } = require('apollo-server-express');

let aboutMessage = "Issue Tracker API v1.0";

const typeDefs = `
  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String
  }
`;

const resolvers = {
  Query: {
    about: () => aboutMessage,
  },
  Mutation: {
    setAboutMessage,
  },
};

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const app = express();

app.use(express.static('public'));

server.applyMiddleware({ app, path: '/graphql' });

app.listen(3000, function () {
  console.log('App started on port 3000');
});

Listing 5-1server.js: Implementing the About API Set

注意

虽然我们不遗余力地确保所有代码清单的准确性,但可能会有打字错误、格式错误(如引号类型),甚至是在出版前没有出现在书中的更正。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

正如我前面指出的,GraphQL 模式和自省允许开发人员开发能够探索 API 的工具。默认情况下,名为 Playground 的工具是 Apollo 服务器的一部分,只需浏览 API 端点即可访问。因此,如果你在浏览器的地址栏中输入http://localhost:3000/graphql,你将会找到游乐场的用户界面。

游乐场的默认主题是黑色。使用设置功能(右上角的齿轮图标),我把它改成了浅色主题,同时把字体大小减小到了 12。如果您也进行这些更改,您可能会看到如图 5-1 所示的用户界面。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig1_HTML.jpg

图 5-1

图 QL 操场

在我们测试 API 之前,最好使用 UI 右侧的绿色 schema 按钮来探索模式。这样,您会发现模式中描述了aboutsetAboutMessage字段。要进行查询,您可以在左侧窗口中键入查询,并在单击 Play 按钮后在右侧看到结果,如 UI 中所述。

必须使用查询语言来编写查询。该语言类似于 JSON,但不是 JSON。查询需要遵循模式的相同层次结构,并且与之相似。但是我们不指定字段的类型,只指定它们的名称。对于输入字段,我们指定名称和值,用冒号(:)分隔。因此,要访问about字段,必须使用顶级的query,它只包含我们需要检索的字段,即about。以下是完整的查询:

query {
  about
}

请注意,Playground 中有一个自动完成功能,在您键入时可能会派上用场。操场还使用红色下划线显示查询中的错误。这些特性使用模式来了解可用的字段、参数及其类型。Playground 从服务器查询模式,因此每当模式改变时,如果您依赖自动完成,您需要刷新浏览器以便从服务器检索改变的模式。

因为默认情况下所有的查询都是类型Query(与Mutation相反),我们可以跳过关键字query,只输入{ about }。但是为了清楚起见,让我们始终包含query关键字。单击播放按钮,您会在右侧的结果窗口中看到以下输出:

{
  "data": {
    "about": "Issue Tracker API v1.0"
  }
}

与遵循查询语言语法的查询不同,输出是一个常规的 JSON 对象。它还反映了查询的结构,以“data”作为结果中的根对象。

现在为了测试setAboutMessage字段,您可以用一个突变来替换查询,或者更好的方法是,您可以在 UI 中使用+符号打开一个新的选项卡,然后像这样输入突变查询:

mutation {
  setAboutMessage(message: "Hello World!")
}

运行此查询应该会返回与结果相同的消息,如下所示:

{
  "data": {
    "setAboutMessage": "Hello World!"
  }
}

现在,运行最初的about查询(在第一个选项卡中)应该会返回新消息,"Hello World!"以证明新消息已经在服务器中成功设置。为了确保操场没有变魔术,让我们在命令行中使用 cURL 对about字段进行查询。

一个快速的方法是使用操场上的 COPY CURL 按钮复制命令,并将其粘贴到命令 shell 中。(在 Windows 系统上,shell 不接受单引号,因此您必须手动编辑单引号并将其更改为双引号,然后使用反斜杠对查询中的双引号进行转义。)该命令及其输出如下所示:

$ curl 'http://localhost:3000/graphql' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: http://localhost:3000' --data-binary '{"query":"query {\n  about\n}\n"}' –compressed
{"data":{"about":"Hello World!"}}

注意,cURL 查询是作为 JSON 发送的,实际的查询编码为属性query的字符串值。通过在浏览器中检查开发人员控制台的 Network 选项卡,您可以看到在使用 Playground 时会发生类似的事情。JSON 至少包含一个名为query的属性(如curl命令所示),以及可选的operationNamevariables属性。JSON 对象看起来像这样:

{
  "operationName":null,
  "variables":{},
  "query": "{\n  about\n}\n"
}

此外,如果您查看标题(或者理解curl命令),您还会发现对于setAboutMessage变异和about查询,使用的 HTTP 方法是相同的:POST。使用 POST 方法从服务器获取值可能会让人感到有些不安,所以如果您更喜欢 GET,可以使用它。GET URL 的查询字符串可以包含如下查询:

$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'

注意,这不是一个 JSON 对象,就像 POST 操作一样。该查询作为一个普通的 URL 编码字符串发送。我们必须避开花括号,因为它们对 cURL 有特殊的意义,所以在浏览器的常规 Ajax 调用中,您不需要这样做。如果您执行此命令,您应该会看到与之前的 POST 命令相同的结果:

$ curl 'http://localhost:3000/graphql?query=query+\{+about+\}'
{"data":{"about":"Hello World!"}}

练习:关于 API

  1. 对 cURL 在浏览器和命令行中使用相同的 URL。例如,键入curl http://localhost:3000/graphql,这与我们在浏览器中调用操场时使用的 URL 相同。或者,复制粘贴我们用于对about字段进行 GET 请求的curl命令。你看到了什么?你能解释一下区别吗?提示:比较请求头。

  2. 对于只读 API 调用,使用 GET 与 POST 的优缺点是什么?

本章末尾有答案。

GraphQL 架构文件

在上一节中,我们在 JavaScript 文件中指定了 GraphQL 模式。如果模式变得更大,将模式分离成自己的文件会很有用。这将有助于保持 JavaScript 源文件更小,ide 可能能够格式化这些文件并启用语法着色。

因此,让我们将模式定义移动到它自己的文件中,而不是源文件中的字符串。移动内容本身很简单;让我们创建一个名为schema.graphql的文件,并将字符串typeDefs的内容移入其中。新文件schema.graphql显示在清单 5-2 中。

type Query {
  about: String!
}

type Mutation {
  setAboutMessage(message: String!): String
}

Listing 5-2schema.graphql: New File for GraphQL Schema

现在,要使用这个变量代替字符串变量,这个文件的内容必须读入一个字符串。让我们使用fs模块和readFileSync函数来读取文件。然后,在创建阿波罗服务器时,我们可以使用readFileSync返回的字符串作为属性typeDefs的值。server.js文件中的变化如清单 5-3 所示。

const fs = require('fs');

const express = require('express');
...

const typeDefs = `

  type Query {
    about: String!
  }
  type Mutation {
    setAboutMessage(message: String!): String!
  }

`;

...

const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
  resolvers,
});
...

Listing 5-3server.js: Changes for Using the GraphQL Schema File

还有一件事需要更改:默认情况下,在检测到文件更改时重启服务器的nodemon工具只查找扩展名为.js的文件的更改。为了让它监视其他扩展的变化,我们需要添加一个-e选项,指定它需要监视的所有扩展。因为我们添加了一个扩展名为.graphql的文件,所以让我们将jsgraphql指定为该选项的两个扩展名。

清单 5-4 中显示了对package.json的更改。

...
  "scripts": {
    "start": "nodemon -w server -e js,graphql server/server.js",
    "compile": "babel src --out-dir public",
...

Listing 5-4package.json: Changes to nodemon to Watch GraphQL Files

如果您现在使用npm start重启服务器,您将能够使用 Playground 测试 API,并确保它们像以前一样运行。

列表 API

现在您已经学习了 GraphQL 的基础知识,让我们利用这些知识在构建问题跟踪器应用方面取得一些进展。我们要做的下一件事是实现一个 API 来获取问题列表。我们将使用 Playground 测试它,在下一节中,我们将更改前端以集成这个新的 API。

让我们从修改模式开始,定义一个名为Issue的自定义类型。它应该包含我们到目前为止一直在使用的 issue 对象的所有字段。但是由于 GraphQL 中没有标量类型来表示日期,所以我们暂时使用 string 类型。我们将在本章后面实现自定义标量类型。因此,该类型将有整数和字符串,其中一些是可选的。下面是新类型的部分模式代码:

...
type Issue {
  id: Int!
  ...
  due: String
}
...

现在,让我们在Query下添加一个新字段来返回问题列表。指定另一种类型的列表的 GraphQL 方法是用方括号将它括起来。我们可以使用[Issue]作为字段的类型,我们称之为issueList。但是我们需要说的是,不仅返回值是强制的,列表中的每个元素也不能为空。因此,我们必须在Issue和数组类型后面加上感叹号,就像在[Issue!]!中一样。

让我们使用注释将顶级的QueryMutation定义与定制类型分开。在模式中添加注释的方法是在行首使用#字符。所有这些变化都列在清单 5-5 中。

type Issue {

  id: Int!
  title: String!
  status: String!
  owner: String
  effort: Int
  created: String!
  due: String

}

##### Top level declarations

type Query {
  about: String!
  issueList: [Issue!]!
}

type Mutation {
  setAboutMessage(message: String!): String
}

Listing 5-5schema.graphql: Changes to Include Field issueList and New Issue Type

在服务器代码中,我们需要在新字段的Query下添加一个解析器,它指向一个函数。我们还会有一系列问题(我们在前端代码中的问题的副本),这些问题是数据库的替身。我们可以立即从解析器返回这个数组。该函数可以像对about字段那样就地使用,但是知道我们将扩展该函数来做不仅仅是返回一个硬编码的数组,让我们为它创建一个名为issueList的单独函数。

清单 5-6 显示了server.js中的这组变化。

...
let aboutMessage = "Issue Tracker API v1.0";

const issuesDB = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },

];

const resolvers = {
  Query: {
    about: () => aboutMessage,
    issueList,
  },
  Mutation: {
    setAboutMessage,
  },
};

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
}

function issueList() {

  return issuesDB;

}

...

Listing 5-6server.js: Changes for issueList Query Field

为了在操场上测试这一点,您需要运行一个查询来指定带有子字段的issueList字段。但是首先,需要刷新浏览器,以便 Playground 拥有最新的模式,并且在您键入查询时不会显示错误。

数组本身不需要在查询中展开。这是隐式的(由于模式规范),issueList返回一个数组,因此字段的子字段在数组中自动展开。

下面是一个这样的查询,您可以运行它来测试issueList字段:

query {
  issueList {
    id
    title
    created
  }
}

该查询将产生如下输出:

{
  "data": {
    "issueList": [
      {
        "id": 1,
        "title": "Error in console when clicking Add",
        "created": "Tue Jan 15 2019 05:30:00 GMT+0530 (India Standard Time)"
      },
      {
        "id": 2,
        "title": "Missing bottom border on panel",
        "created": "Wed Jan 16 2019 05:30:00 GMT+0530 (India Standard Time)"
      }
    ]
  }
}

如果在查询中添加更多的子字段,也将返回它们的值。如果您查看日期字段,您会看到它们已经使用Date JavaScript 对象的toString()方法从Date对象转换为字符串。

练习:列表 API

  1. 试着不为issueList字段指定子字段,比如query { issueList },就像我们为about字段所做的那样,然后单击 Play 按钮。你观察到的结果是什么?尝试使用查询{ issueList { } }指定一个空字段列表,并播放请求。你现在看到了什么?你能解释一下区别吗?

  2. issueList下的查询中添加一个无效的子字段(比如,test)。当您单击播放按钮时,会出现什么错误?特别是,Playground 会将请求发送到服务器吗?在开发人员控制台打开的情况下,在操场上尝试一下。

  3. 一个包括问题列表和about字段的聚合查询会是什么样子?

本章末尾有答案。

列表 API 集成

现在我们已经有了列表 API,让我们把它集成到 UI 中。在这一节中,我们将把 I ssueList React 组件中的loadData()方法的实现替换为从服务器获取数据的东西。

为了使用 API,我们需要进行异步 API 调用,或者 Ajax 调用。流行的库 jQuery 是使用$.ajax()函数的一种简单方法,但是仅仅为了这个目的而包含整个 jQuery 库似乎有些矫枉过正。幸运的是,有许多库提供这种功能。更好的是,现代浏览器通过 Fetch API 本地支持 Ajax 调用。对于 Internet Explorer 等较老的浏览器,可以从whatwg-fetch获得 Fetch API 的 polyfill。让我们直接从 CDN 中使用这个 polyfill,并将它包含在index.html中。为此,我们将使用之前使用的相同 CDN,unpkg.com。这些变化如清单 5-7 所示

...
  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
  <script src="https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js"></script>
  <style>
...

Listing 5-7index.html: Changes for Including whatwg-fetch Polyfill

注意

只有 Internet Explorer 和其他浏览器的旧版本才需要 polyfill。所有最新版本的流行浏览器——如 Chrome、Firefox、Safari、Edge 和 Opera——都原生支持fetch()

接下来,在loadData()方法中,我们需要构造一个 GraphQL 查询。这是一个简单的字符串,类似于我们在操场上用来测试issueList GraphQL 字段的字符串。但是我们必须确保我们查询的是问题的所有子字段,因此下面的查询可以获取所有问题和所有子字段:

...
    const query = `query {
      issueList {
        id title status owner
        created effort due
      }
    }`;
...

我们将这个查询字符串作为 JSON 中的query属性值发送,作为fetch请求主体的一部分。我们将使用的方法是 POST,我们将添加一个头,表明内容类型是 JSON。下面是完整的fetch请求:

...
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
...

注意

我们使用了await关键字来处理异步调用。这是 ES2017 规范的一部分,受除 Internet Explorer 之外的所有浏览器的最新版本支持。它是由旧浏览器的 Babel transforms 自动处理的。另外,await只能在标有async的函数中使用。我们将不得不在loadData()函数中添加async关键字。如果不熟悉async/await构造,可以在 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function 了解一下。

一旦响应到达,我们就可以通过使用response.json()方法将 JSON 数据转换成 JavaScript 对象。最后,我们需要调用一个setState()来为名为issues的状态变量提供问题列表,如下所示:

...
    const result = await response.json();
    this.setState({ issues: result.data.issueList });
...

我们还需要为loadData()的函数定义添加关键字async,因为我们已经在这个函数中使用了await s。

此时,您将能够在浏览器中刷新问题跟踪器应用,但会看到一个错误。这是因为我们使用了一个字符串而不是Date对象,并且在IssueRow组件的render()方法中使用toDateString()将日期转换为字符串的调用抛出了一个错误。让我们删除转换,按原样使用字符串:

...
      <td>{issue.created}</td>
      ...
      <td>{issue.due}</td>
...

我们现在也可以删除全局变量initialIssues,因为我们不再需要它在loadData()中。清单 5-8 显示了App.jsx中的一整套变更。

const initialIssues = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2018-08-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2018-08-16'), due: new Date('2018-08-30'),
    title: 'Missing bottom border on panel',
  },

];

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      ...
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ''}</td>
      ...
   );
}
...

  async loadData() {
    setTimeout(() => {
      this.setState({ issues: initialIssues });
    }, 500);
    const query = `query {
      issueList {
        id title status owner
        created effort due
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    const result = await response.json();
    this.setState({ issues: result.data.issueList });
  }

Listing 5-8App.jsx: Changes for Integrating the List API

这就完成了集成 List API 所需的更改。现在,如果您通过刷新浏览器来测试应用,您会发现一个类似于图 5-2 所示的屏幕截图。你会注意到日期又长又难看,但除此之外,屏幕看起来和前一章结束时一样。添加操作将不起作用,因为它在添加新问题时使用Date对象而不是字符串。我们将在下一节讨论这两个问题。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig2_HTML.jpg

图 5-2

列表 API 集成后

自定义标量类型

将日期存储为字符串在大多数情况下似乎可行,但并非总是如此。首先,对日期进行排序和过滤变得更加困难,因为每次都必须将字符串转换成Date类型。此外,无论服务器在哪里,日期都应该以用户的时区和地区显示。不同的用户可能基于他们在哪里而不同地看到相同的日期,甚至看到“2 天前”等形式的日期。

为了实现这一切,我们需要将日期存储为 JavaScript 的原生Date对象。理想情况下,应该在仅向用户显示时将其转换为特定于地区的字符串。但不幸的是,JSON 没有Date类型,因此,在 API 调用中使用 JSON 传输数据也必须将日期与字符串相互转换。

在 JSON 中传输Date对象的推荐字符串格式是 ISO 8601 格式。它简明扼要,广为接受。这也是 JavaScript 的DatetoJSON()方法使用的相同格式。在这种格式中,诸如 2019 年 1 月 26 日下午 2:30 UTC 这样的日期将被写成2019-01-26T14:30:00.000Z。使用DatetoJSON()toISOString()方法将日期转换成这个字符串,以及使用new Date(dateString)将它转换回日期,都是简单明了的。

尽管 GraphQL 本身不支持日期,但它支持自定义标量类型,这可用于创建自定义标量类型日期。为了能够使用自定义标量类型,必须完成以下工作:

  1. 在模式中使用scalar关键字而不是type关键字定义标量的类型。

  2. 为所有标量类型添加一个顶级解析器,它通过类方法处理序列化(在输出时)和解析(在输入时)。

之后,新类型可以像任何本地标量类型一样使用,比如StringInt。让我们称这个新的标量类型为GraphQLDate。标量类型必须在模式中使用关键字scalar定义,后跟自定义类型的名称。让我们把它放在文件的开头:

...
scalar GraphQLDate
...

现在,我们可以用created替换String类型关联,用GraphQLDate替换due字段。清单 5-9 显示了标量定义的变化和日期字段的新数据类型。

scalar GraphQLDate

type Issue {
  id: Int!
  ...
  created: StringGraphQLDate!
  due: StringGraphQLDate
}
...

Listing 5-9schema.graphql: Changes in Schema for Scalar Date

标量类型解析器需要是包graphql-tools中定义的类GraphQLScalarType的对象。我们先在server.js导入这个类:

...
const { GraphQLScalarType } = require('graphql');
...

GraphQLScalarType的构造函数接受一个具有各种属性的对象。我们可以通过调用类型上的new()来创建这个解析器,如下所示:

...
const GraphQLDate = new GraphQLScalarType({ ... });
...

初始化器的两个属性——namedescription——在自省中使用,所以让我们将它们设置为适当的值:

...
  name: 'GraphQLDate',
  description: 'A Date() type in GraphQL as a scalar',
...

将调用类方法serialize()将日期值转换为字符串。此方法将值作为参数,并期望返回一个字符串。因此,我们所要做的就是对值调用toISOString()并返回它。下面是serialize()的方法:

...
  serialize(value) {
    return value.toISOString();
  },
...

需要另外两个方法parseValue()parseLiteral()来将字符串解析回日期。让我们把这种解析留到稍后阶段,当它确实需要接受输入值时,因为这些是可选的方法。

最后,我们需要将这个解析器设置为与QueryMutation(在顶层)相同的级别,作为标量类型GraphQLDate的值。清单 5-10 显示了server.js中的整套变化。

...
const { ApolloServer } = require('apollo-server-express');

const { GraphQLScalarType } = require('graphql');

...

const GraphQLDate = new GraphQLScalarType({

  name: 'GraphQLDate',
  description: 'A Date() type in GraphQL as a scalar',
  serialize(value) {
    return value.toISOString();
  },

});

const resolvers = {
  Query: {
    ...
  },
  Mutation: {
    ...
  },
  GraphQLDate,
};
...

Listing 5-10server.js: Changes for Adding a Resolver for GraphQLDate

此时,如果您切换到操场并刷新浏览器(由于模式更改),然后测试 List API。您将看到日期作为 ISO 字符串的等价物返回,而不是以前使用的特定于地区的长字符串。这里有一个在操场上测试的查询:

query {
  issueList {
    title
    created
    due
  }
}

以下是该查询的结果:

{
  "data": {
    "issueList": [
      {
        "title": "Error in console when clicking Add",
        "created": "2019-01-15T00:00:00.000Z",
        "due": null
      },
      {
        "title": "Missing bottom border on panel",
        "created": "2019-01-16T00:00:00.000Z",
        "due": "2019-02-01T00:00:00.000Z"
      }
    ]
  }
}

现在,在App.jsx中,我们可以将字符串转换为原生的Date类型。实现这一点的一种方法是在从服务器获取问题后遍历这些问题,并用它们的日期等价物替换字段duecreated。更好的方法是将一个 reviver 函数传递给 JSON parse()函数。reviver 函数是一个被调用来解析所有值的函数,JSON 解析器给它一个机会来修改默认解析器要做的事情。

因此,让我们创建这样一个函数,它在输入中寻找类似日期的模式,并将所有这样的值转换为日期。我们将使用一个正则表达式来检测这种模式,并使用new Date()进行简单的转换。下面是 reviver 的代码:

...
const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {
  if (dateRegex.test(value)) return new Date(value);
  return value;
}
...

转换函数response.json()不能让我们指定一个 reviver,所以我们必须使用response.text()获取正文的文本,并通过传入 reviver 使用JSON.parse()自己解析它,就像这样:

...
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
...

现在,我们可以恢复我们的更改,将日期显示为之前的状态:使用toDateString()IssueRow中呈现日期。包括这一变化,在App.jsx中使用Date标量类型的一整套变化如清单 5-11 所示。

const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {

  if (dateRegex.test(value)) return new Date(value);
  return value;

}

function IssueRow(props) {
  const issue = props.issue;
  return (
    <tr>
      ...
      <td>{issue.created.toDateString()}</td>
      <td>{issue.effort}</td>
      <td>{issue.due ? issue.due.toDateString() : ' '}</td>
...
   );
}
...
class IssueList extends React.Component {
  async loadData() {
    ...
    const result = await response.json();
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
    this.setState({ issues: result.data.issueList });
  }
  ...
}
...

Listing 5-11App.jsx: Changes for Receiving ISO Formatted Dates

经过这一系列的修改,应用应该像以前一样出现在上一章的末尾。日期的格式看起来会很好。即使添加一个问题也应该可以,但是在刷新浏览器时,添加的问题将会消失。这是因为我们没有将问题保存在服务器中,我们所做的只是在浏览器中更改了问题列表的本地状态,这将在刷新时重置为初始问题集。

练习:自定义标量类型

  1. server.js中,删除将类型GraphQLDate关联到解析器对象的解析器。发出调用issueList的 API 请求。输出有区别吗?你认为如何解释这种差异或缺乏差异?

  2. 你如何确定标量类型解析器确实被使用了?

本章末尾有答案。

创建 API

在本节中,我们将实现一个 API,用于在服务器中创建一个新问题,该问题将被附加到服务器内存中的问题列表中。

为此,我们必须首先在模式中的Mutation下定义一个名为issueAdd的字段。这个字段应该接受参数,就像setAboutMessage字段一样。但是这一次,我们需要多个参数,每个参数对应于要添加的问题的一个属性。或者,我们可以将一个新类型定义为一个对象,该对象具有我们输入所需的字段。这不能与Issue类型相同,因为它有一些必填字段(idcreated)不是输入的一部分。这些值仅由服务器设置。此外,GraphQL 在输入类型方面需要不同的规范。我们必须使用input关键字,而不是使用type关键字。

让我们首先在模式中定义这个名为IssueInputs的新输入类型:

...
input IssueInputs {
  # ... fields of Issue
}
...

我们讨论了如何在模式中添加注释。但是这些注释不是类型或子字段的正式描述。对于显示在 schema explorer 中的真实文档,需要在字段上方添加一个字符串。当向开发人员展示模式时,这些描述将作为有用的提示出现。因此,让我们为IssueInputs以及属性status添加一个描述,假设如果不提供,它将默认为值'New':

...
"Toned down Issue, used as inputs, without server generated values."
input IssueInputs {
  ...
  "Optional, if not supplied, will be set to 'New'"
  status: String
  ...
}
...

现在,我们可以使用类型IssueInputs作为Mutation下新的issueAdd字段的参数类型。该字段的返回值可以是任何值。返回在服务器上生成的值,通常是新对象的 ID,这是一种很好的做法。在这种情况下,因为 ID 和创建日期都是在服务器上设置的,所以让我们返回创建的整个问题对象。

清单 5-12 显示了对模式的一整套更改。

...

"Toned down Issue, used as inputs, without server generated values."

input IssueInputs {

  title: String!
  "Optional, if not supplied, will be set to 'New'"
  status: String
  owner: String
  effort: Int
  due: GraphQLDate

}

##### Top level declarations
...
type Mutation {
  setAboutMessage(message: String!): String
  issueAdd(issue: IssueInputs!): Issue!
}

Listing 5-12schema.graphql: Changes for New Type IssueInputs and New Field issueAdd

接下来,我们需要一个用于issueAdd的解析器,它接受一个IssueInput类型并在内存数据库中创建一个新问题。就像我们对setAboutMessage所做的一样,我们可以忽略第一个参数,使用一个析构赋值来访问问题对象,即输入:

...
function issueAdd(_, { issue }) {
  ...
}

在函数中,让我们像在浏览器中一样设置 ID 和创建日期:

...
  issue.created = new Date();
  issue.id = issuesDB.length + 1;
...

此外,如果没有提供状态(因为我们没有将其声明为必需的子字段),我们也将状态默认为值'New':

...
  if (issue.status == undefined) issue.status = 'New';
...

最后,我们可以将问题附加到全局变量issuesDB中,并按原样返回问题对象:

...
  issuesDB.push(issue);
  return issue;
...

该函数现在可以设置为MutationissueAdd字段的解析器:

...
  Mutation: {
    setAboutMessage,
    issueAdd,
  },
...

我们推迟了实现自定义标量类型GraphQLDate的解析器,因为那时我们不需要它。但是现在,因为类型IssueInputs有一个GraphQLDate类型,我们必须实现解析器来接收日期值。在GraphQLDate解析器中需要实现两种方法:parseValueparseLiteral

方法parseLiteral在正常情况下被调用,其中字段在查询中被就地指定。解析器用一个参数ast调用这个方法,这个参数包含一个kind属性和一个value属性。kind属性表示解析器找到的令牌的类型,可以是浮点、整数或字符串。对于GraphQLDate,我们唯一需要支持的令牌类型是字符串。我们可以使用graphql/language中的Kind包中定义的常量来检查这一点。如果令牌的类型是 string,我们将解析该值并返回一个日期。否则,我们就返回undefined。下面是parseLiteral的实现:

...
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
  },
...

返回值undefined向 GraphQL 库表明该类型不能被转换,它将被视为一个错误。

如果输入作为变量提供,将调用方法parseValue。我将在本章后面的部分讨论查询输入中的变量,但是现在,把它看作 JavaScript 对象形式的输入,一个预先解析的 JSON 值。该方法的参数将直接是值,没有类型规范,所以我们需要做的就是用它构造一个日期,并像这样返回它:

...
  parseValue(value) {
    return new Date(value);
  },
...

清单 5-13 中显示了对server.js的一整套更改。

...
const { GraphQLScalarType } = require('graphql');

const { Kind } = require('graphql/language');

...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
    return new Date(value);
  },
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
  },
});
...

const resolvers = {
  ...
  Mutation: {
    setAboutMessage,
    issueAdd,
  },
  GraphQLDate,
};
...

function issueAdd(_, { issue }) {

  issue.created = new Date();
  issue.id = issuesDB.length + 1;
  if (issue.status == undefined) issue.status = 'New';
  issuesDB.push(issue);
  return issue;

}

...

Listing 5-13server.js: Changes for the Create API

现在,我们准备使用操场测试 Create API。如果您在操场上浏览模式(可能需要刷新浏览器)并深入到IssueInputsstatus字段,您会发现我们在模式中提供的描述。其截图如图 5-3 所示。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig3_HTML.jpg

图 5-3

显示问题输入和状态描述的模式

要测试新问题的添加,您可以在 Playground 中使用以下查询:

mutation {
  issueAdd(issue:{
    title: "Completion date should be optional",
    owner: "Pieta",
    due: "2018-12-13",
  }) {
    id
    due
    created
    status
  }
}

运行这个查询应该会在操场的结果窗口中给出以下结果:

{
  "data": {
    "issueAdd": {
      "id": 4,
      "due": "2018-12-13T00:00:00.000Z",
      "created": "2018-10-03T14:48:10.551Z",
      "status": "New"
    }
  }
}

这表明已经正确解析和转换了到期日期。状态字段也被默认为'New'。您还可以通过在操场上运行对issueList的查询并检查结果来确认问题已经被创建。

练习:创建 API

  1. 我们使用了一个input复杂类型来为issueAdd提供值。与单独通过每个字段相比,像issueAdd(title: String!, owner: String ...)。每种方法的优缺点是什么?

  2. 尝试为该字段传递一个有效的整数,如due: 2018,而不是有效的日期字符串。你认为在parseLiteralast.kind的值会是多少?在parseLiteral中添加console.log信息并确认。你认为ast.kind还有哪些值是可能的?

  3. 传递一个字符串,但传递一个无效的日期,比如为due字段传递"abcdef"。会发生什么?如何解决这个问题?

  4. 有没有另一种方法来指定status字段的默认值?提示:在 http://graphql.github.io/learn/schema/#arguments 阅读 GraphQL 模式文档中的传递参数。

本章末尾有答案。

创建 API 集成

让我们开始集成 Create API,在 UI 中对新问题的默认设置做一点小小的改变。让我们取消将状态设置为'New'的操作,并将截止日期设置为当前日期的 10 天后。这种改变可以在App.jsx中的IssueAdd组件的handleSubmit()方法中完成,就像这样:

...
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
    }
...

在进行 API 调用之前,我们需要一个填充了字段值的查询。让我们使用一个模板字符串在IssueListcreateIssue()方法中生成这样一个查询。我们可以使用传入的问题对象的标题和所有者属性,但是对于日期字段due,我们必须按照 ISO 格式将其显式转换为字符串,因为这是我们决定用于传递日期的格式。

在返回路径上,我们将不需要任何新问题的值,但是因为子字段不能为空,所以让我们只指定id字段。因此,让我们按如下方式形成查询字符串:

...
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;
...

现在,让我们使用这个查询来异步执行fetch,就像我们对问题列表调用所做的那样:

...
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
...

我们可以使用返回的完整问题对象,并像以前一样将其添加到状态数组中,但是更简单的方法是(尽管性能较差)在将新问题发送到服务器后调用loadData()来刷新问题列表。它也更准确,以防出现错误而无法添加问题,或者其他用户同时添加了更多问题。

...
    this.loadData();
...

清单 5-14 显示了集成 Create API 的一整套更改。

...
class IssueAdd extends React.Component {
  ...
  handleSubmit(e) {
    ...
    const issue = {
      owner: form.owner.value, title: form.title.value, status: 'New',
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
    }
    ...
  }
  ...
}
...
  async createIssue(issue) {
    issue.id = this.state.issues.length + 1;
    issue.created = new Date();
    const newIssueList = this.state.issues.slice();
    newIssueList.push(issue);
    this.setState({ issues: newIssueList });
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    this.loadData();
   }

Listing 5-14App.jsx: Changes for Integrating the Create API

在通过使用 UI 添加新问题来测试这组更改时,您会发现截止日期被设置为从当前日期起 10 天后。此外,如果刷新浏览器,您会发现添加的问题仍然存在,因为新问题现在已保存在服务器上。

练习:创建 API 集成

  1. 添加标题中带有引号的新问题,例如,Unable to create issue with status "New"。会发生什么?检查控制台以及浏览器的开发人员控制台中的请求和响应。你认为如何解决这个问题?

本章末尾有答案。

查询变量

对于这两个变异调用,我们都在查询字符串中指定了字段的参数。当在操场上测试一个 API 时,就像我们对setAboutMessage所做的那样,这非常有效。但是在大多数应用中,参数是动态的,基于用户输入。这正是issueAdd所发生的,我们必须使用字符串模板来构造查询字符串。

这不是一个好主意,首先是因为将模板转换成实际字符串的开销很小。一个更重要的原因是需要对引号和花括号等特殊字符进行转义。这很容易出错,也很容易被忽略。由于我们没有进行任何转义,如果您在此时通过添加一个在标题中有双引号的问题来测试问题跟踪器应用,您会发现它不能正常工作。

GraphQL 有一流的方法从查询中提取动态值,并将其作为单独的字典传递。这些值被称为变量。这种传递动态值的方式非常类似于 SQL 查询中的预处理语句。

要使用变量,我们必须先命名操作。这是通过在querymutation字段说明后指定一个名称来实现的。例如,要给一个setAboutMessage突变命名,必须完成以下工作:

mutation setNewMessage { setAboutMessage(message: "New About Message") }

接下来,必须用变量名替换输入值。变量名以$字符开始。让我们调用变量$message,并用这个变量替换字符串“New About Message”。最后,为了接受变量,我们需要将它声明为操作名的参数。因此,新的查询将是:

mutation setNewMessage($message: String!) { setAboutMessage(message: $message) }

现在,为了提供变量值,我们需要在一个 JSON 对象中发送它,这个 JSON 对象是与查询字符串分开的。在游乐场中,右下角有一个名为查询变量的选项卡。点击这个按钮将会打开请求窗口,并允许您在下半部分输入查询变量。我们需要将变量作为一个 JSON 对象发送,将变量名(不带$)作为属性,变量值作为属性值。

*操场截图如图 5-4 ,消息值为"Hello World!"

img/426054_2_En_5_Chapter/426054_2_En_5_Fig4_HTML.jpg

图 5-4

带有查询变量的操场

如果您在开发人员控制台中检查请求数据,您会发现请求 JSON 有三个属性— operationNamevariablesquery。虽然到目前为止我们只使用了query,但是为了利用变量,我们不得不同时使用另外两个。

注意

GraphQL 规范允许多个操作出现在同一个查询字符串中。但是在一次调用中只能执行其中的一个。operationName的值指定需要执行那些操作中的哪一个。

我们现在准备在查询中用常规字符串替换模板字符串,使用操作名和变量规范格式。新的查询字符串将如下所示:

...
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;
...

然后,在构造fetch()请求的主体时,除了query属性之外,我们还要指定variables属性,它将包含一个变量:issue。清单 5-15 显示了App.jsx中的一整套变化,包括这一点。

...
  async createIssue(issue) {
    const query = `mutation {
      issueAdd(issue:{
        title: "${issue.title}",
        owner: "${issue.owner}",
        due: "${issue.due.toISOString()}",
      }) {
        id
      }
    }`;
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables: { issue } })
    });
    this.loadData();
  }
...

Listing 5-15App.jsx: Changes for Using Query Variables

在 Issue Tracker 应用中测试这些变化时,您会发现添加新问题的工作方式和以前一样。此外,您应该能够在新增加的问题的标题中使用双引号,而不会导致任何错误。

练习:查询变量

  1. 在自定义标量类型GraphQLDate中,现在我们在使用变量,你觉得会调用哪一种解析方法?会是parseLiteral还是parseValue?在这两个函数中添加一个临时的console.log语句,确认你的答案。

本章末尾有答案。

输入验证

到目前为止,我们已经忽略了验证。但是所有的应用都需要一些典型的验证,不仅是为了防止来自 UI 的无效输入,也是为了防止来自直接 API 调用的无效输入。在本节中,我们将添加一些对大多数应用来说很典型的验证。

一种常见的验证是限制允许值的集合,可以在下拉列表中显示。问题跟踪器应用中的status字段就是这样一个字段。实现这种验证的一种方法是在issueAdd解析器中添加对允许值数组的检查。但是 GraphQL 模式本身通过枚举类型或枚举为我们提供了一种自动的方式。模式中的一个enum定义如下:

...
enum Color {
  Red
  Green
  Blue
}
...

请注意,虽然该定义可以翻译成其他语言中的实际枚举类型,但由于 JavaScript 没有枚举类型,因此在客户端和服务器端都将它们作为字符串处理。让我们为名为StatusType的状态添加这个枚举类型:

...
enum StatusType {
  New
  Assigned
  Fixed
  Closed
}
...

现在,我们可以用Issue类型中的StatusType替换String类型:

...
type Issue {
  ...
  status: StatusType!
  ...
}

同样可以在IssueInput类型中完成。但是 GraphQL 模式的一个显著特性是,它允许我们在输入没有给定参数值的情况下提供默认值。这可以通过在类型说明后添加一个=符号和默认值来实现,比如owner: String = "Self"。在status的情况下,缺省值是一个 enum,所以可以不用引号来指定它,如下所示:

...
  status: StatusType = New
...

现在,我们可以移除server.jsissueAdd解析器内issue.status'New'的默认值。清单 5-16 显示了对schema.graphql文件的所有更改。

scalar GraphQLDate

enum StatusType {
  New
  Assigned
  Fixed
  Closed
}

type Issue {
  ...
  status: StringStatusType!
  ...
}
...
input IssueInputs {
  ...
  status: StringStatusType = New
  owner: String
  effort: Int
  due: GraphQLDate
}
...

Listing 5-16schema.graphql: Changes for Using Enums and Default Values

至于编程验证,我们必须在server.js中保存新问题之前进行。我们将在一个名为validateIssue()的独立函数中实现这一点。让我们首先创建一个数组来保存验证失败的错误消息。当我们发现多个验证失败时,数组中的每个验证失败消息都有一个字符串。

...
function validateIssue(_, { issue }) {
  const errors = [];
...

接下来,让我们为该期的标题添加一个最小长度。如果检查失败,我们将把一条消息推入到errors数组中。

...
  if (issue.title.length < 3) {
    errors.push('Field "title" must be at least 3 characters long.')
  }
...

让我们添加一个有条件的强制验证,当状态设置为Assigned时,检查所有者是否是必需的。UI 在这个阶段无法设置 status 字段,因此为了测试这一点,我们将使用 Playground。

...
  if (issue.status == 'Assigned' && !issue.owner) {
    errors.push('Field "owner" is required when status is "Assigned"');
  }
...

我们可以添加更多的验证,但是对于演示编程验证来说,这已经足够了。在检查结束时,如果我们发现 errors 数组不为空,我们将抛出一个错误。Apollo 服务器建议使用UserInputError类来生成用户错误。让我们用它来构造一个要抛出的错误:

...
  if (errors.length > 0) {
    throw new UserInputError('Invalid input(s)', { errors });
  }
...

现在,让我们再添加一个我们之前没有做的验证:在解析值的过程中捕获无效的日期字符串。当日期字符串无效时,new Date()构造函数不会抛出任何错误。相反,它创建一个 date 对象,但该对象包含一个无效的日期。检测输入错误的一种方法是检查构造的日期对象是否是有效值。在构建日期后,可以使用检查isNaN(date)来完成。让我们在parseValueparseLiteral实施这项检查:

...
  parseValue(value) {
    const dateValue = new Date(value);
    return isNaN(dateValue) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    if (ast.kind == Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
    }
  },
...

注意,返回undefined被库视为错误。如果提供的文字不是字符串,函数将不返回任何内容,这与返回undefined相同。

最后,您会发现,尽管所有的错误都被发送到客户机并显示给用户,但是没有办法在服务器上捕获这些错误以供以后分析。此外,如果能监控服务器的控制台,甚至在开发过程中就能看到这些错误,那就太好了。Apollo 服务器有一个名为formatError的配置选项,可以用来更改将错误发送回调用者的方式。我们也可以使用此选项在控制台上打印出错误:

...
  formatError: error => {
    console.log(error);
    return error;
  }
...

在清单 5-17 中显示了server.js中添加GraphQLDate类型的编程验证和适当验证的所有变化。

...
const { ApolloServer, UserInputError } = require('apollo-server-express');
...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
    return new Date(value);
    const dateValue = new Date(value);
    return isNaN(dateValue) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    return (ast.kind == Kind.STRING) ? new Date(ast.value) : undefined;
    if (ast.kind == Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
    }
  },
});
...

function validateIssue(_, { issue }) {
  const errors = [];
  if (issue.title.length < 3) {
    errors.push('Field "title" must be at least 3 characters long.')
  }
  if (issue.status == 'Assigned' && !issue.owner) {
    errors.push('Field "owner" is required when status is "Assigned"');
  }
  if (errors.length > 0) {
    throw new UserInputError('Invalid input(s)', { errors });
  }

}

function issueAdd(_, { issue }) {
  issueValidate(issue);
  issue.created = new Date();
  issue.id = issuesDB.length + 1;
  if (issue.status == undefined) issue.status = 'New';
  issuesDB.push(issue);
  return issue;
}
...

const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
  resolvers,
  formatError: error => {
    console.log(error);
    return error;
  },
});

Listing 5-17server.js: Programmatic Validations and Date Validations

使用应用测试这些更改将会很困难,需要临时更改代码,所以您可以使用 Playground 来测试验证。注意,由于status现在是一个枚举,该值应该作为一个文字提供,也就是说,在操场上没有引号。对issueAdd的有效调用如下所示:

mutation {
  issueAdd(issue:{
    title: "Completion date should be optional",
    status: New,
  }) {
    id
    status
  }
}

运行这段代码时,操场结果应该显示添加了以下新问题:

{
  "data": {
    "issueAdd": {
      "id": 5,
      "status": "New"
    }
  }
}

如果您将状态更改为像Unknown这样的无效枚举,您应该会得到如下错误:

{
  "error": {
    "errors": [
      {
        "message": "Expected type StatusType, found Unknown.",
...

如果您使用字符串"New"来代替,它应该会显示如下有用的错误消息:

{
  "error": {
    "errors": [
      {
        "message": "Expected type StatusType, found \"New\"; Did you mean the enum value New?",
...

最后,如果您完全删除状态,您会发现它确实将值默认为New,如结果窗口所示。

为了测试编程验证,您可以尝试创建一个两个检查都失败的问题。以下查询应该有所帮助:

mutation {
  issueAdd(issue:{
    title: "Co",
    status: Assigned,
  }) {
    id
    status
  }
}

运行此查询时,将返回以下错误,其中两条消息都列在 exception 部分下。

{
  "data": null,
  "errors": [
    {
      "message": "Invalid input(s)",
      ...
      "extensions": {
        "code": "BAD_USER_INPUT",
        "exception": {
          "errors": [
            "Field \"title\" must be at least 3 characters long.",
            "Field \"owner\" is required when status is \"Assigned\""
          ],
...

为了测试日期验证,您需要使用文字和查询变量进行测试。对于文字测试,可以使用以下查询:

mutation {
  issueAdd(issue:{
    title: "Completion data should be optional",
    due: "not-a-date"
  }) {
    id
  }
}

将返回以下错误:

{
  "error": {
    "errors": [
      {
        "message": "Expected type GraphQLDate, found \"not-a-date\".",
        ...
        "extensions": {
          "code": "GRAPHQL_VALIDATION_FAILED",

至于基于查询变量的测试,下面是可以使用的查询:

mutation issueAddOperation($issue: IssueInputs!) {
  issueAdd(issue: $issue) {
    id
    status
    due
  }
}

这是查询变量:

{"issue":{"title":"test", "due":"not-a-date"}}

运行此命令时,您应该会在结果窗口中看到以下错误:

{
  "error": {
    "errors": [
      {
        "message": "Variable \"$issue\" got invalid value {\"title\":\"test\",\"due\":\"not-a-date\"}; Expected type GraphQLDate at value.due.",
        ...
        "extensions": {
          "code": "INTERNAL_SERVER_ERROR",

显示错误

在本节中,我们将修改用户界面,以便向用户显示任何错误消息。我们将处理由于网络连接问题以及无效用户输入导致的传输错误。对于用户来说,通常不应该出现服务器错误和其他错误(这些很可能是 bug),如果出现了,让我们只显示收到的代码和消息。

这是创建一个公共实用函数来处理所有 API 调用和报告错误的好机会。我们可以用这个公共函数替换实际处理程序中的fetch调用,并将任何错误作为 API 调用的一部分显示给用户。我们称这个函数为graphQLFetch。这将是一个异步函数,因为我们将使用await调用fetch()。让我们让函数将query和变量作为两个参数:

...
async function graphQLFetch(query, variables = {}) {
...

注意

我们使用 ES2015 默认函数参数将{}分配给参数variables,以防调用者没有传入它。点击 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters 了解更多此功能。

所有的传输错误都将从对fetch()的调用中抛出,所以让我们将对fetch()的调用和随后对主体的检索包装起来,并在try-catch块中解析它。让我们使用catch块中的alert来显示错误:

...
  try {
    const response = await fetch('/graphql', {
      ...
    });
  ...
  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
  }
...

fetch操作与最初在issueAdd中执行的操作相同。一旦fetch完成,我们将在result.errors中寻找错误。

...
    if (result.errors) {
      const error = result.errors[0];
...

错误代码可以在error.extensions.code中找到。让我们使用这段代码,以不同的方式处理我们预期的每种类型的错误。对于BAD_USER_INPUT,我们需要将所有的验证错误结合在一起,并显示给用户:

...
      if (error.extensions.code == 'BAD_USER_INPUT') {
        const details = error.extensions.exception.errors.join('\n ');
        alert(`${error.message}:\n ${details}`);
...

对于所有其他错误代码,我们将显示收到的代码和消息。

...
      } else {
        alert(`${error.extensions.code}: ${error.message}`);
      }
...

最后,在这个新的效用函数中,让我们返回result.data。调用者可以检查是否有数据返回,如果有,就使用它。IssueList中的方法loadData()是第一个调用者。构建完查询后,所有获取数据的代码都可以替换为使用查询对graphQLFetch的简单调用。因为它是一个异步函数,我们可以使用await关键字,并将结果直接接收到一个名为data的变量中。如果数据为非空,我们可以用它来设置状态,如下所示:

...
  async loadData() {
    ...
    const data = await graphQLFetch(query);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }
...

让我们对同一个类中的createIssue方法进行类似的更改。在这里,我们还需要传递第二个参数 variables,它是一个包含变量issues的对象。在返回路径上,如果数据有效,我们知道操作成功了,因此我们可以调用this.loadData()。除了知道操作成功之外,我们不使用数据的返回值。

...
    const data = await graphQLFetch(query, { issue });
    if (data) {
      this.loadData();
    }
...

清单 5-18 中显示了App.jsx中显示错误的一整套更改。

...

async function graphQLFetch(query, variables = {}) {

  try {
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables })
    });
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);

    if (result.errors) {
      const error = result.errors[0];
      if (error.extensions.code == 'BAD_USER_INPUT') {
        const details = error.extensions.exception.errors.join('\n ');
        alert(`${error.message}:\n ${details}`);
      } else {
        alert(`${error.extensions.code}: ${error.message}`);
      }
    }
    return result.data;

  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
  }

}

...
class IssueList extends React.Component {
  ...

  async loadData() {
    const query = `query {
      ..
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query })
    });
    const body = await response.text();
    const result = JSON.parse(body, jsonDateReviver);
    this.setState({ issues: result.data.issueList });
    const data = await graphQLFetch(query);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }

  async createIssue(issue) {
    const query = `mutation issueAdd($issue: IssueInputs!) {

      issueAdd(issue: $issue) {
        id
      }
    }`;

    const response = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json'},
      body: JSON.stringify({ query, variables: { issue } })
    });
    this.loadData();
    const data = await graphQLFetch(query, { issue });
    if (data) {
      this.loadData();
    }
  }

Listing 5-18App.jsx: Changes for Displaying Errors

要测试传输错误,您可以在刷新浏览器后停止服务器,然后尝试添加新问题。如果这样做,您将会发现如图 5-5 中的屏幕截图所示的错误消息。

img/426054_2_En_5_Chapter/426054_2_En_5_Fig5_HTML.jpg

图 5-5

传输错误消息

至于其他消息,可以通过在用户输入中键入一个小标题来测试标题的长度。其他验证只能通过临时更改代码来测试,例如,将status设置为所需的值,将due字段设置为无效的日期字符串等。在IssueAdd组件的handleSubmit()方法中。

摘要

在本章中,我们比较了两个 API 标准:REST 和 GraphQL。尽管 REST 被广泛使用,但考虑到它的特性和易于实现,我们选择了 GraphQL,因为有工具和库可以帮助我们构建 API。

GraphQL 是一个非常结构化的 API 标准,并且非常广泛。我只介绍了 GraphQL 的基础知识,其中只包括问题跟踪应用在这个阶段所需的特性。我鼓励你在 https://graphql.org/ 阅读更多关于 GraphQL 的文章。还有一些高级特性,比如指令和片段,这些特性有助于重用代码来构建查询。这些在大型项目中可能非常方便,但我不会在本书中涉及这些,因为它们对于问题跟踪器应用来说并不是真正必需的。

在本章中,您已经看到了如何使用 GraphQL 构建 CRUD 的 C 和 R 部分。您还看到了一些验证是如何容易实现的,以及 GraphQL 的强类型系统如何帮助避免错误并使 API 自文档化。我们将在后面的章节中处理 CRUD 的 U 和 D 部分,当我们构建这些特性时。

同时,看看如何持久化数据将是一个好主意。我们将一系列问题从浏览器内存转移到服务器内存。在下一章,我们将进一步把它转移到一个真正的数据库,MongoDB。

练习答案

练习:关于 API

  1. 浏览器中的相同 URL 和 cURL 命令行会导致不同的结果。在浏览器中,返回操场 UI,而在命令行中,执行 API。Apollo 服务器通过查看Accept头来进行区分。如果它找到了"text/html"(这是浏览器发送的),它返回操场 UI。您可以通过在 cURL 命令行中添加--header "Accept: text/html"并执行它来检查这一点。

  2. 浏览器可以缓存 GET 请求,并从缓存本身返回响应。不同的浏览器行为不同,很难预测正确的行为。通常,您会希望 API 结果永远不被缓存,而是总是从服务器获取。在这种情况下,使用 POST 是安全的,因为浏览器不会缓存 POST 的结果。

    但是如果您真的希望浏览器尽可能缓存某些 API 响应,因为结果很大并且不会改变(例如,图像),GET 是唯一的选择。或者,您可以使用 POST,但自己处理缓存(例如,通过使用本地存储),而不是让浏览器来处理。

练习:列表 API

  1. 在第一种情况下,查询具有有效的语法,但是不符合模式。游乐场发送了请求,服务器对此响应了一个错误。

    在第二种情况下,Playground 没有将查询发送到服务器(您可以在控制台日志中看到使用开发人员控制台的错误),因为它发现查询没有有效的语法:花括号中应该有一个子字段名称。

    在这两种情况下,Playground 都在查询中将错误显示为红色下划线。将光标悬停在红色下划线上将显示实际的错误消息,而不管它是语法错误还是模式错误。

  2. 添加无效的子字段不会使查询在语法上无效。请求被发送到服务器,服务器验证查询并返回一个错误,指出子字段无效。

  3. 查询可以像query { about issueList { id title created } }一样。在结果中,您可以看到aboutissueList都作为data对象的属性返回。

练习:自定义标量类型

  1. 无论是否使用标量类型的解析器,输出都是相同的。将GraphQLDate定义为标量类型的模式使得Date对象的默认解析器使用toJSON()而不是toString()

  2. 可以在 serialize 函数中添加一条console.log()消息。或者,如果您临时更改转换以使用Date.toString()而不是Date.toISOString(),您可以看到转换正在以不同的方式进行。

练习:创建 API

  1. 就详细程度而言,两种方法是相同的,所有的公共属性都必须在IssueIssueInputs或参数列表之间重复。如果属性列表发生变化,例如,如果我们添加一个名为severity的新字段,那么必须在两个地方进行更改:在Issue类型中,在IssueInputs类型中,参数列表指向issueAdd

    定义输入类型的一个优点是可以重用相同的类型。例如,如果创建和更新操作可以接受相同的输入类型,这就很方便了。

  2. 传递一个整数会将ast.kind设置为Kind.INT(?? 被设置为字符串'IntValue',如控制台日志所示)。其他可能的值有Kind.FLOATKind.BOOLEANKind.ENUM

  3. 传递一个有效的字符串但传递一个无效的日期不会在创建问题的过程中抛出任何错误,但是问题将与一个无效的Date对象一起保存,这是new Date()使用无效的日期字符串的结果。当问题被返回时,将会看到这样的效果;会有错误显示date对象不能被转换成字符串。我们将在本章后面添加验证。

  4. 可以在模式中通过在类型规范后添加一个=符号和缺省值来指定缺省值,比如status: String = "New"。我们将在本章的后面切换到这种方法。

练习:创建 API 集成

  1. 问题没有产生,控制台将出现一个错误,指示请求不正确。您会发现请求的格式不正确,因为引号结束了字符串,这是标题的值,GraphQL 查询解析器无法识别其后的所有内容。

    解决这个问题的一种方法是在字符串值中查找引号,并使用反斜杠()字符对它们进行转义。但是正如您将在下一节中看到的,有一种更好的方法可以做到这一点。

练习:查询变量

  1. 由于这些值不是作为查询字符串中的文字传递的,现在将调用的是parseValue。*

六、MongoDB

在本章中,我将讨论 MongoDB、数据库层和 MERN 堆栈中的 M。到目前为止,我们在用作数据库的 Express 服务器内存中存在一系列问题。我们将用真正的持久性来代替它,并从 MongoDB 数据库中读取和写入问题列表。

为了实现这一点,我们需要在云上安装或使用 MongoDB,习惯它的 shell 命令,安装 Node.js 驱动程序以从 Node.js 访问它,最后修改服务器代码以替换 API 调用,从而从 MongoDB 数据库而不是内存中的问题数组中读取和写入。

MongoDB 基础知识

这是一个介绍性的部分,在这里我们不会修改应用。我们将在这一节中讨论这些核心概念:MongoDB、文档和集合。然后,我们将设置 MongoDB,并通过使用 mongo shell 读写数据库的示例来探索这些概念。

文档

MongoDB 是一个文档数据库,这意味着记录的等价物是一个文档,或者一个对象。在关系数据库中,您按照行和列来组织数据,而在文档数据库中,整个对象可以写成一个文档。

对于简单的对象,这似乎与关系数据库没有什么不同。但是假设你有嵌套对象(称为嵌入文档)和数组的对象。现在,当使用关系数据库时,通常需要多个表。例如,在一个关系数据库中,一个发票对象可以存储在一个invoice表(用于存储发票细节,如客户地址和交货细节)和一个invoice_items表(用于存储货物中每一项的细节)的组合中。在 MongoDB 中,整个 Invoice 对象将被存储为一个文档。这是因为一个文档可以以嵌套的方式包含数组和其他对象,所包含的对象不必分离到其他文档中。

文档是由字段和值对组成的数据结构。字段的值可能包括对象、数组和对象数组等等,嵌套的深度与您希望的一样。MongoDB 文档类似于 JSON 对象,所以很容易把它们看作 JavaScript 对象。与 JSON 对象相比,MongoDB 文档不仅支持原始数据类型——布尔、数字和字符串——还支持其他常见的数据类型,如日期、时间戳、正则表达式和二进制数据。

发票对象可能如下所示:

{
  "invoiceNumber" : 1234,
  "invoiceDate" : ISODate("2018-10-12T05:17:15.737Z"),
  "billingAddress" : {
    "name" : "Acme Inc.",
    "line1" : "106 High Street",
    "city" : "New York City",
    "zip" : "110001-1234"
  },
  "items" : [
    {
      "description" : "Compact Flourescent Lamp",
      "quantity" : 4,
      "price" : 12.48
    },
    {
      "description" : "Whiteboard",
      "quantity" : 1,
      "price" : 5.44
    }
  ]
}

在这个文档中,有数字、字符串和日期数据类型。此外,还有一个嵌套对象(billingAddress)和一个对象数组(items)。

收集

集合就像关系数据库中的表:它是一组文档。就像在关系数据库中一样,集合可以有主键和索引。但是与关系数据库相比,还是有一些不同之处。

MongoDB 中规定了一个主键,它有一个保留的字段名_id。即使在创建文档时没有提供_id字段,MongoDB 也会创建这个字段,并为每个文档自动生成一个惟一的键。通常情况下,自动生成的 ID 可以按原样使用,因为即使当多个客户端同时向数据库写入数据时,它也很方便,并且可以保证生成惟一的键。MongoDB 使用一种称为ObjectId的特殊数据类型作为主键。

_id字段被自动索引。除此之外,还可以在其他字段上创建索引,这包括嵌入文档中的字段和数组字段。索引用于有效地访问集合中的文档子集。

与关系数据库不同,MongoDB 不要求您为集合定义模式。惟一的要求是集合中的所有文档必须有一个惟一的_id,但是实际的文档可能有完全不同的字段。但是实际上,集合中的所有文档都有相同的字段。尽管在应用的初始阶段,灵活的模式对于模式更改可能非常方便,但是如果没有在应用代码中添加某种模式检查,这可能会导致问题。

从 3.6 版本开始,MongoDB 已经支持模式的概念,尽管它是可选的。你可以在 https://docs.mongodb.com/manual/core/schema-validation/index.html 阅读所有关于 MongoDB 模式的内容。模式可以强制允许和必需的字段及其数据类型,就像 GraphQL 一样。但是它也可以验证其他东西,比如字符串长度和整数的最小和最大值。

但是由于模式冲突而产生的错误并没有给出足够的细节来说明从 3.6 版本开始哪些验证检查失败了。在 MongoDB 的未来版本中,这一点可能会有所改进,到那时就值得考虑添加全面的模式检查了。对于问题跟踪器应用,我们将不使用 MongoDB 的模式验证特性,相反,我们将在后端代码中实现所有必要的验证。

数据库

数据库是许多集合的逻辑分组。因为没有像 SQL 数据库中那样的外键,所以数据库的概念只是一个逻辑分区名称空间。大多数数据库操作从单个集合中读取或写入,但是$lookup(聚合管道中的一个阶段)相当于 SQL 数据库中的一个连接。这个阶段可以合并同一个数据库中的文档。

此外,备份和其他管理任务是作为一个单元在数据库上进行的。一个数据库连接被限制为只能访问一个数据库,因此要访问多个数据库,需要多个连接。因此,将应用的所有集合保存在一个数据库中是很有用的,尽管一个数据库服务器可以托管多个数据库。

查询语言

与关系数据库中通用的类似英语的 SQL 不同,MongoDB 查询语言由方法组成,以实现各种操作。读写操作的主要方法是 CRUD 方法。其他方法包括聚合、文本搜索和地理空间查询。

所有方法都对集合进行操作,并将参数作为指定操作细节的 JavaScript 对象。每种方法都有自己的规范。例如,要插入一个文档,唯一需要的参数就是文档本身。对于查询,参数是查询过滤器和要返回的字段列表(也称为投影)。

查询过滤器是一个 JavaScript 对象,由零个或多个属性组成,其中属性名是要匹配的字段的名称,属性值由另一个带有运算符和值的对象组成。例如,要匹配字段invoiceNumber大于 1,000 的所有文档,可以使用以下查询过滤器:

{ "invoiceNumber": { $gt: 1000 } }

因为没有用于查询或更新的“语言”,所以可以非常容易地以编程方式构造查询过滤器。

与关系数据库不同,MongoDB 鼓励非规范化,也就是说,将文档的相关部分存储为嵌入式子文档,而不是作为关系数据库中的独立集合(表)。以人(姓名、性别等)为例。)及其联系信息(主要地址、次要地址等)。).在关系数据库中,这需要为人员和联系人创建单独的表,然后在需要所有信息时将这两个表连接起来。另一方面,在 MongoDB 中,它可以存储为同一个人文档中的联系人列表。这是因为集合的连接对于 MongoDB 中的大多数方法来说并不自然:最方便的find()方法一次只能操作一个集合。

装置

在您尝试在您的计算机上安装 MongoDB 之前,您可能想要尝试一个能够让您访问 MongoDB 的托管服务。有许多服务,但以下是受欢迎的,并有一个免费版本,您可以使用一个小的测试或沙盒应用。对于我们将作为本书的一部分构建的问题跟踪器应用来说,这些都非常好。

  • MongoDB Atlas ( https://www.mongodb.com/cloud/atlas ):我简称这个为 Atlas。一个小数据库(共享 RAM,512 MB 存储)是免费的。

  • mLab(之前为 MongoLab) ( https://mlab.com/ ): mLab 已经宣布被 MongoDB Inc .收购,最终可能会并入 Atlas 本身。沙盒环境是免费的,限制为 500 MB 的存储空间。

  • Compose ( https://www.compose.com ):在许多其他服务中,Compose 提供 MongoDB 作为服务。有 30 天的试用期,但是没有永久免费的沙盒选项。

在这三个中,我发现 Atlas 是最方便的,因为主机的位置有许多选择。当连接到数据库时,它让我选择一个离我的位置最近的数据库,这样可以最小化延迟。mLab 不提供群集—可以单独创建数据库。Compose 不是永久免费的,您可能需要 30 多天来完成这本书。

任何托管选项的缺点是,除了访问数据库时的小的额外延迟之外,您需要互联网连接。这意味着您可能无法在无法访问互联网的地方测试您的代码,例如在飞机上。相比之下,在您的计算机上安装 MongoDB 可能会更好,但安装比注册一个基于云的选项需要更多的工作。

即使使用云选项之一,您也需要下载并安装 mongo shell 才能远程访问数据库。每项服务都附带了关于这一步骤的说明。注册这些服务时,请选择 MongoDB 3.6 或更高版本。按照服务提供商给出的说明,通过使用 mongo shell 连接到集群或数据库来测试注册。

如果您选择在您的计算机上安装 MongoDB(它可以很容易地安装在 OS X、Windows 和大多数基于 Linux 的发行版上),请查找安装说明,每个操作系统的安装说明都是不同的。您可以按照 mongodb 网站( https://docs.mongodb.com/manual/installation/ 或在您的搜索引擎中搜索“MongoDB 安装”)上的说明来安装 MongoDB。

选择 MongoDB 版本 3.6 或更高版本,最好是最新的,因为一些示例使用了仅在版本 3.6 中引入的特性。大多数本地安装选项都允许您将服务器、shell 和工具安装在一起。检查情况是否如此;如果没有,您可能需要单独安装它们。

在本地安装之后,确保您已经启动了 MongoDB 服务器(守护进程或服务的名称是 mongod ),如果安装过程尚未启动它的话。通过运行 mongo shell 来测试安装,如下所示:

$ mongo

在 Windows 系统上,您可能需要在命令后面追加.exe。根据您的安装方法,该命令可能需要路径。如果 shell 成功启动,它还将连接到本地 MongoDB 服务器实例。如果您在本地安装了 MongoDB 4 . 0 . 2 版,您应该会看到控制台上打印的 MongoDB 版本、它所连接的数据库(默认为 test)以及一个命令提示符,如下所示:

MongoDB shell version v4.0.2
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 4.0.2
>

您看到的消息可能与此略有不同,尤其是如果您安装了不同版本的 MongoDB。但是您确实需要看到提示>,在那里您可以键入更多的命令。相反,如果您看到一条错误消息,请重新执行安装和服务器启动过程。

蒙哥贝壳

mongo shell 是一个交互式 JavaScript shell,非常像 Node.js shell。在交互式 shell 中,除了 JavaScript 的全部功能之外,还有一些非 JavaScript 的便利。在这一节中,我们将讨论通过 shell 可能实现的基本操作,这些操作是最常用的。关于 shell 所有功能的完整参考,您可以在 https://docs.mongodb.com/manual/mongo/ 查看 mongo shell 文档。

我们将在 mongo shell 中输入的命令已经收集在一个名为mongo_commands.txt的文件中。这些命令已经过测试,可以在 Atlas 或本地安装中正常工作,但是您可能会发现其他选项中有所变化。例如,mLab 只允许您连接到数据库(而不是集群),因此它不允许在 mLab 中的数据库之间切换。

注意

如果您在键入命令时发现某些东西不能按预期工作,请在 GitHub 资源库( https://github.com/vasansr/pro-mern-stack-2 )中交叉检查这些命令。这是因为错别字可能是在书的制作过程中引入的,或者最后一刻的更正可能错过了这本书。另一方面,GitHub 库反映了最新的和经过测试的代码和命令。

要使用 MongoDB,您需要连接到数据库。让我们从查找哪些数据库可用开始。显示当前数据库的命令是:

> show databases

这将列出数据库及其占用的存储。例如,在 MongoDB 的全新本地安装中,您将看到以下内容:

admin         0.000GB
config        0.000GB
local         0.000GB

这些是 MongoDB 用于内部记账等的系统数据库。我们不会使用这些来创建我们的集合,所以我们最好改变当前的数据库。要识别当前数据库,命令是:

> db

mongo shell 连接到的默认数据库称为test,这就是您可能看到的该命令的输出。现在让我们看看这个数据库中存在哪些集合。

> show collections

您会发现这个数据库中没有任何集合,因为它是全新安装的。此外,您还会发现,当我们列出可用数据库时,数据库test并未列出。这是因为数据库和集合实际上只在对它们的第一次写操作时创建。

让我们切换到名为issuetracker的数据库,而不是使用默认数据库:

> use issuetracker

这将导致确认新数据库是issuetracker的输出:

switched to db issuetracker

让我们确认该数据库中也没有集合:

> show collections

该命令不应返回任何内容。现在,让我们创建一个新集合。这是通过在集合中创建一个文档来实现的。集合作为全局对象db的属性被引用,与集合同名。名为employees的集合可以简称为db.employees。让我们使用insertOne()方法在employees集合中插入一个新文档。该方法接受要插入的文档作为参数:

> db.employees.insertOne({ name: { first: 'John', last: 'Doe' }, age: 44 })

该命令的结果将显示操作的结果和创建的新文档的 ID,如下所示:

{
     "acknowledged" : true,
     "insertedId" : ObjectId("5bbc487a69d13abf04edf857")
}

除了insertOne()方法之外,任何集合上都有许多方法可用。输入“db.employees.”后按两下 Tab 键可以看到可用方法的列表(按 Tab 键前需要输入末尾的句点)。您可能会发现如下所示的输出:

db.employees.addIdIfNeeded(              db.employees.getWriteConcern(
db.employees.aggregate(                  db.employees.group(
db.employees.bulkWrite(                  db.employees.groupcmd(
db.employees.constructor                 db.employees.hasOwnProperty
db.employees.convertToCapped(            db.employees.hashAllDocs(
db.employees.convertToSingleObject(      db.employees.help(
db.employees.copyTo(                     db.employees.initializeOrderedBulkOp(
db.employees.count(                      db.employees.initializeUnorderedBulkOp(
db.employees.createIndex(                db.employees.insert(
db.employees.createIndexes(              db.employees.insertMany(
db.employees.dataSize(                   db.employees.insertOne(
...

这就是 mongo shell 的自动完成功能。请注意,您可以让 mongo shell 自动完成任何方法的名称,方法是在输入方法的开头几个字符后按 Tab 字符。

现在让我们检查文档是否已经在集合中创建。为此,我们可以对集合使用find()方法。没有任何参数,该方法只列出集合中的所有文档:

> db.employees.find()

这将显示我们刚刚创建的文档,但是它的格式并不“漂亮”。它将全部打印在一行中,并且可能不方便地换到下一行。为了获得更清晰的输出,我们可以对find()方法的结果使用pretty()方法:

> db.employees.find().pretty()

这应该会显示更清晰的输出,如下所示:

{
     "_id" : ObjectId("5bbc487a69d13abf04edf857"),
     "name" : {
            "first" : "John",
            "last" : "Doe"
     },
     "age" : 44
}

在这个时间点,如果您执行show collectionsshow databases,您会发现employees集合和issuetracker数据库确实已经被创建,并在它们各自命令的输出中列出。让我们在同一个集合中插入另一个文档,并尝试处理集合中的多个文档:

> db.employees.insertOne({ name: { first: 'Jane', last: 'Doe' }, age: 54 })

现在,既然我们已经在 shell 中拥有了 JavaScript 的全部功能,让我们尝试使用它来体验一下。让我们将结果收集到一个 JavaScript 数组变量中,而不是在屏幕上打印结果。find()方法的结果是一个可以迭代的游标。在cursor对象中,还有除了pretty()之外的方法,其中一个就是toArray()。该方法从查询中读取所有文档,并将它们放在一个数组中。所以,让我们使用这个方法,并将其结果赋给一个数组变量。

> let result = db.employees.find().toArray()

现在,变量result应该是一个包含两个元素的数组,每个元素都是一个employee文档。让我们使用 JavaScript 数组方法forEach()遍历它们并打印每个雇员的名字:

> result.forEach((e) => print('First Name:', e.name.first))

这将产生如下输出:

First Name: John
First Name: Jane

在 Node.js 中,console.log方法可用于在控制台上打印对象。另一方面,mongo shell 提供了用于相同目的的print()方法,但是它只打印字符串。在打印之前,需要使用实用函数tojson()将对象转换成字符串。还有另外一种方法,叫做printjson(),把对象打印成 JSON。让我们用它来检查嵌套文档name的内容,而不仅仅是名字:

> result.forEach((e) => printjson(e.name))

现在,您应该看到name对象被扩展成名和姓,如下所示:

{ "first" : "John", "last" : "Doe" }
{ "first" : "Jane", "last" : "Doe" }

除了提供一种访问数据库和集合的方法的机制之外,shell 本身做得很少。它是 JavaScript 引擎,它构成了 shell 的基础,并赋予了 shell 很大的灵活性和强大的功能。

在下一节中,我们将讨论集合的更多方法,比如您刚刚了解到的insertOne()。许多编程语言都可以通过驱动程序访问这些方法。mongo shell 只是另一个可以访问这些方法的工具。您会发现其他编程语言中可用的方法和参数与 mongo shell 中的非常相似。

练习:MongoDB 基础知识

  1. 使用 shell,显示一个在cursor对象上可用的方法列表。提示:在 https://docs.mongodb.com/manual/tutorial/access-mongo-shell-help/ 查找 mongo shell 文档以获得 mongo Shell 帮助。

本章末尾有答案。

MongoDB CRUD 操作

因为 mongo shell 是最容易尝试的,所以让我们使用 shell 本身来探索 MongoDB 中可用的 CRUD 操作。我们将继续使用我们在上一节中创建的issuetracker数据库。但是让我们清空数据库,这样我们可以重新开始。collection 对象提供了一个方便的方法来擦除自身,称为drop():

> db.employees.drop()

这应该会产生如下输出:

true

这不同于删除集合中的所有文档,因为它还会删除集合中的所有索引。

创造

在上一节中,您简要地看到了如何插入文档,作为其中的一部分,您发现了 MongoDB 如何自动创建主键,这是一种称为ObjectID的特殊数据类型。现在让我们使用自己的 ID,而不是让 MongoDB 自动生成一个 ID。

> db.employees.insertOne({
  _id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 44
})

这将导致以下输出:

{ "acknowledged" : true, "insertedId" : 1 }

请注意,insertedId的值反映了我们为_id提供的值。这意味着,我们能够提供自己的价值,而不是一种ObjectID类型的价值。让我们尝试创建一个新的相同的文档(您可以使用向上箭头键在 mongo shell 中重复前面的命令)。它将失败,并出现以下错误:

WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: issuetracker.employees index: _id_ dup key: { : 1.0 }",
     "op" : {
            "_id" : 1,
            "name" : {
                  "first" : "John",
                  "last" : "Doe"
            },
            "age" : 44
     }
})

这表明_id字段仍然是主键,并且应该是惟一的,不管它是自动生成的还是在文档中提供的。现在,让我们添加另一个文档,但是使用一个新字段作为名称的一部分,比如中间名:

> db.employees.insertOne({
  name: {first: 'John', middle: 'H', last: 'Doe'},
  age: 22
})

这很好,使用find(),您可以看到集合中存在两个文档,但是它们不一定是相同的模式。这就是灵活模式的优势:只要发现需要存储的新数据元素,就可以增强模式,而不必显式修改模式。

在这种情况下,任何缺少name下的middle字段的员工文档都意味着该员工没有中间名。另一方面,如果添加了一个在缺失时没有隐含意义的字段,则必须在代码中处理它的缺失。或者必须运行一个迁移脚本,将该字段的值默认为某个值。

你还会发现两个文档的_id字段的格式不一样,甚至数据类型也不一样。对于第一个文档,数据类型是整数。对于第二个,它是类型ObjectID(这就是为什么它被显示为ObjectID(...)。因此,不仅仅是同一集合中两个文档之间的字段存在差异,甚至同一字段的数据类型也可能不同。

在大多数情况下,将主键的创建留给 MongoDB 就很好了,因为您不必担心保持它的唯一性:MongoDB 会自动做到这一点。但是,这个标识符不是人类可读的。在 Issue Tracker 应用中,我们希望标识符是一个数字,以便于记忆和谈论。但是我们不使用_id字段来存储人类可读的标识符,而是使用一个名为id的新字段,让 MongoDB 自动生成_id

所以,让我们放弃这个集合,开始用一个名为id的新字段创建新文档。

> db.employees.drop()

> db.employees.insertOne({
  id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 48
})

> db.employees.insertOne({
  id: 2,
  name: { first: 'Jane', last: 'Doe'} ,
  age: 16
})

该集合有一个方法,可以一次接收多个文档。这种方法叫做insertMany()。让我们用它在一个命令中再创建几个文档:

> db.employees.insertMany([
  { id: 3, name: { first: 'Alice', last: 'A' }, age: 32 },
  { id: 4, name: { first: 'Bob', last: 'B' }, age: 64 },
])

对这个问题的回答会显示多个insertedIds被创建,而不是为insertOne()方法创建一个insertedId,如下所示:

{
     "acknowledged" : true,
     "insertedIds" : [
            ObjectId("5bc6d80005fb87b8f2f5cf6f"),
            ObjectId("5bc6d80005fb87b8f2f5cf70")
     ]
}

阅读

现在集合中有多个文档,让我们看看如何检索文档的子集,而不是完整的列表。find()方法接受另外两个参数。第一个是应用于列表的过滤器,第二个是投影,即指定要检索的字段。

过滤器规范是一个对象,其中属性名是要过滤的字段,值是它需要匹配的值。让我们获取一个员工的文档,由等于 1 的id标识。因为我们知道给定的 ID 只能有一个雇员,所以让我们使用findOne()而不是find()。方法findOne()是方法find()的变体,它返回单个对象而不是光标。

> db.employees.findOne({ id: 1 })

这将返回我们创建的第一个员工文档,输出如下所示:

{
     "_id" : ObjectId("5bc6d7e505fb87b8f2f5cf6d"),
     "id" : 1,
     "name" : {
            "first" : "John",
            "last" : "Doe"
     },
     "age" : 48
}

注意,我们在这里没有使用pretty(),但是输出被美化了。这是因为findOne()返回单个对象,mongo shell 默认美化对象。

过滤器实际上是{ id: { $eq: 1 } },的简写,其中$eq是表示字段id的值必须等于到 1 的运算符。一般意义上,过滤器中单个元素的格式是fieldname: { operator: value }。也可以使用其他运算符进行比较,如$gt表示大于等。让我们尝试使用$gte(大于或等于)操作符来获取 30 岁或以上的员工列表:

> db.employees.find({ age: { $gte: 30 } })

该命令应该返回三个文档,因为我们插入了那些年龄超过 30 岁的人。如果指定了多个字段,那么它们都必须匹配,这与用一个操作符将它们组合起来是一样的:

> db.employees.find({ age: { $gte: 30 }, 'name.last': 'Doe'  })

现在返回的文档数量应该减少到只有一个,因为只有一个文档符合这两个标准,姓氏等于'Doe',年龄大于 30。注意,我们使用了点符号来指定嵌套文档中嵌入的字段。这也让我们在字段名两边加上引号,因为它是一个普通的 JavaScript 对象属性。

要匹配同一个字段的多个值,例如,匹配大于 30 的年龄和小于 60 的年龄,不能使用相同的策略。这是因为过滤器是一个普通的 JavaScript 对象,一个文档中不能存在两个同名的属性。因此,像{ age: { $gte: 30 }, age: { $lte: 60 } }这样的过滤器将不起作用(JavaScript 不会抛出错误,而是只为属性age选择一个值)。必须使用一个显式的$and操作符,它接受一个指定多个字段值标准的对象数组。您可以在 https://docs.mongodb.com/manual/reference/operator/query/ 的 MongoDB 参考手册的操作符部分阅读到关于$and操作符和更多操作符的所有内容。

当在一个字段上进行筛选是常见的情况时,在该字段上创建索引通常是一个好主意。集合上的createIndex()方法就是为了这个目的。它接受一个指定构成索引的字段的参数(多个字段将构成一个复合索引)。让我们在年龄字段上创建一个索引:

> db.employees.createIndex({ age: 1 })

有了这个索引,任何使用包含字段age的过滤器的查询都会快得多,因为 MongoDB 将使用这个索引,而不是扫描集合中的所有文档。但这并不是一个唯一的指数,因为许多人可能年龄相同。

年龄字段可能不是常用的过滤器,但是根据标识符获取文档将会非常频繁。MongoDB 自动在_id字段上创建一个索引,但是我们使用了自己的标识符id,这个字段更有可能用于获取单个雇员。所以让我们创建一个关于这个领域的索引。此外,它必须是唯一的,因为它标识了雇员:没有两个雇员应该有相同的id值。createIndex()的第二个参数是一个包含索引各种属性的对象,其中一个属性指定索引是否惟一。让我们用它来创建一个关于id的唯一索引:

> db.employees.createIndex({ id: 1 }, { unique: true })

现在,当提供了一个带有id的过滤器时,不仅find()方法的性能会好得多,而且 MongoDB 还会阻止创建带有重复id的文档。让我们通过为第一个员工重新运行insert命令来尝试一下:

> db.employees.insertOne({
  id: 1,
  name: { first: 'John', last: 'Doe' },
  age: 48
})

现在,您应该会在 mongo shell 中看到这样的错误(对于您来说,ObjectID会有所不同):

WriteError({
     "index" : 0,
     "code" : 11000,
     "errmsg" : "E11000 duplicate key error collection: issuetracker.employees index: id_1 dup key: { : 1.0 }",
     "op" : {
            "_id" : ObjectId("5bc04b8569334c5ff5bb7e8c"),

            "id" : 1
               ...
     }
})

规划

与此同时,我们检索到了与过滤器匹配的整个文档。在上一节中,当我们必须只打印文档字段的子集时,我们使用了一个forEach()循环。但是这意味着即使我们只需要打印文档的一部分,也要从服务器获取整个文档。当文档很大时,这会占用大量网络带宽。为了将获取限制在某些字段,find()方法采用了第二个参数,称为投影。投影指定在结果中包括或排除哪些字段。

该规范的格式是一个对象,其中一个或多个字段名称作为关键字,值为 0 或 1,以指示排除或包含。但是 0 和 1 不能组合。您可以从零开始并包含使用 1 的字段,或者从所有内容开始并排除使用 0 的字段。_id字段是一个例外;除非您指定 0,否则它将始终包含在内。以下将获取所有雇员,但仅获取他们的名字和年龄:

> db.employees.find({}, { 'name.first': 1, age: 1 })

请注意,我们指定了一个空的过滤器,即必须获取所有文档。因为投影是第二个参数,所以必须这样做。前面的请求应该会打印出这样的内容:

{ "_id" : ObjectId("5bbc...797855"), "name" : { "first" : "John" }, "age" : 48 }
{ "_id" : ObjectId("5bbc...797856"), "name" : { "first" : "Jane" }, "age" : 16 }
{ "_id" : ObjectId("5bbc...797857"), "name" : { "first" : "Alice" }, "age" : 32 }
{ "_id" : ObjectId("5bbc...797858"), "name" : { "first" : "Bob" }, "age" : 64 }

即使我们只指定了名字和年龄,字段_id也会自动包含在内。要禁止包含该字段,需要将其显式排除,如下所示:

> db.employees.find({}, { _id: 0, 'name.first': 1, age: 1 })

现在,输出将排除 ID,如下所示:

{ "name" : { "first" : "John" }, "age" : 48 }
{ "name" : { "first" : "Jane" }, "age" : 16 }
{ "name" : { "first" : "Alice" }, "age" : 32 }
{ "name" : { "first" : "Bob" }, "age" : 64 }

更新

有两种方法可以用来修改单据,分别是updateOne()updateMany()。这两种方法的参数是相同的,除了updateOne()在找到并更新第一个匹配的文档后停止。第一个参数是一个查询过滤器,与find()使用的过滤器相同。第二个参数是更新规范,如果只需要更改对象的某些字段。

使用updateOne()时,主键或任何唯一标识符是过滤器中通常使用的,因为过滤器只能匹配一个文档。更新规范是一个具有一系列$set属性的对象,这些属性的值指示另一个对象,该对象指定字段及其新值。让我们更新由id 2 标识的员工的年龄:

> db.employees.updateOne({ id: 2 }, { $set: {age: 23 } })

这应该会产生以下输出:

{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

matchedCount返回了符合过滤器的文档数量。如果过滤器匹配了不止一个,那么就会返回这个数字。但是因为该方法应该只修改一个文档,所以修改的计数应该总是 1,除非修改无效。如果再次运行该命令,您将发现modifiedCount将为 0,因为 ID 为 2 的雇员的年龄已经是 23 岁。

要一次性修改多个文档,必须使用updateMany()方法。格式与updateOne()方法相同,但效果是所有匹配的文档都将被修改。让我们使用updateMany()方法为所有员工添加一个organization字段:

> db.employees.updateMany({}, { $set: { organization: 'MyCompany' } })

注意,即使字段organization在文档中不存在,新值MyCompany也会应用于所有文档。如果你执行命令find()在投影中单独显示公司,这个事实将被证实。

还有一种用替换的方法叫做replaceOne()。如果完整的已修改文档可用,则无需指定要修改的字段,只需用新文档替换现有文档即可。这里有一个例子:

> db.employees.replaceOne({ id: 4 }, {
  id: 4,
  name : { first : "Bobby" },
  age : 66
});

该命令将用新文档替换 ID 为 4 的现有文档。事实上,organizationname.last字段没有被指定,这将导致这些字段在被替换的文档中不存在,而不是使用updateOne()改变。得到被替换的对象应该证明:**

> db.employees.find({ id: 4 })

这应该会产生如下所示的文档:

{ "_id" : ObjectId("5c38ae3da7dc439456c0281b"), "id" : 4, "name" : { "first" : "Bobby" }, "age" : 66 }

您可以看到它不再有字段name.lastorganization,因为在提供给命令replaceOne()的文档中没有指定这些字段。它只是用提供的文档替换文档,除了字段ObjectId。作为主键,该字段不能通过updateOne()replaceOne()改变。

删除

delete操作接受一个过滤器并从集合中删除文档。滤波器格式相同,变量deleteOne()deleteMany()都可用,就像在update操作中一样。

让我们删除最后一个文档,ID 为 4:

> db.employees.deleteOne({ id: 4 })

这将产生以下输出,确认删除仅影响一个文档:

{ "acknowledged" : true, "deletedCount" : 1 }

让我们通过查看集合的大小来交叉检查删除。集合上的count()方法告诉我们它包含多少个文档。现在执行它应该会返回值 3,因为我们最初插入了四个文档。

> db.employees.count()

总计

find()方法用于返回集合中的所有文档或文档子集。很多时候,我们需要的不是文档列表,而是摘要或集合,例如,符合某个标准的文档的数量。

count()方法当然可以带一个过滤器。但是其他的聚合函数呢,比如 sum?这就是aggregate()发挥作用的地方。与支持 SQL 的关系数据库相比,aggregate()方法执行 GROUP BY 子句的功能。但是它还可以执行其他功能,比如连接,甚至展开(基于数组展开文档)等等。

您可以在位于 https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline/ 的 MongoDB 文档中查找aggregate()函数支持的高级特性,但是现在,让我们看看它提供的真正的聚合和分组构造。

aggregate()方法在管道中工作。管道中的每个阶段从前一阶段的结果中获取输入,并根据其规范进行操作,以产生一组新的修改后的文档。当然,管道的初始输入是整个集合。管道规范采用对象数组的形式,每个元素都是一个对象,具有一个标识管道阶段类型的属性和指定管道效果的值。

例如,通过使用$match(滤波器)和$project(投影),可以使用aggregate()来复制find()方法。要执行实际的聚合,需要使用$group阶段。stage 的规范包括由属性_id标识的分组关键字和作为关键字的其他字段,其值是聚合规范和需要执行聚合的字段。_id可以为空,以对整个集合进行分组。

让我们通过获取整个集合中所有雇员的总年龄来尝试一下。管道数组中只有一个元素,一个具有单一属性$group的对象。在值中,_id将被设置为null,因为我们不想按任何字段分组。我们需要(使用聚合函数$sum)将字段age求和成一个新的字段total_age,如下所示:

> db.employees.aggregate([
  { $group: { _id: null, total_age: { $sum: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : null, "total_age" : 103 }

相同的函数$sum可用于通过简单地对值 1 求和来获得记录的计数:

> db.employees.aggregate([
  { $group: { _id: null, count: { $sum: 1 } } }
])

要按字段对聚合进行分组,我们需要指定字段的名称(以$为前缀)作为_id的值。让我们使用organization字段,但是在此之前,让我们插入一个组织不同于其余文档的新文档(它们都被设置为MyCompany):

> db.employees.insertOne({
  id: 4,
  name: { first: 'Bob', last: 'B' },
  age: 64,
  organization: 'OtherCompany'
})

现在,下面是使用 sum 跨不同组织聚合年龄的命令:

> db.employees.aggregate([
  { $group: { _id: '$organization', total_age: { $sum: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : "OtherCompany", "total_age" : 64 }
{ "_id" : "MyCompany", "total_age" : 103 }

让我们也尝试另一个聚合函数,比方说 average,使用$avg:

> db.employees.aggregate([
  { $group: { _id: '$organization', average_age: { $avg: '$age' } } }
])

这应该会产生如下输出:

{ "_id" : "OtherCompany", "average_age" : 64 }
{ "_id" : "MyCompany", "average_age" : 34.333333333333336 }

还有其他聚合函数,包括最小值和最大值。关于完整的设置,请参考 https://docs.mongodb.com/manual/reference/operator/aggregation/group/#accumulator-operator 的文档。

练习:MongoDB Crud 操作

  1. 编写一个简单的语句来检索所有有中间名的雇员。提示:在 https://docs.mongodb.com/manual/reference/operator/query/ 查找查询操作符的 MongoDB 文档。

  2. 过滤器规范是 JSON 吗?提示:考虑字段名称周围的日期对象和引号。

  3. 假设某个员工的中间名设置错误,您需要删除它。为此写一个语句。提示:在 https://docs.mongodb.com/manual/reference/operator/update/ 查找更新操作符的 MongoDB 文档。

  4. 在索引创建过程中,1 表示什么?还允许哪些有效值?提示:在 https://docs.mongodb.com/manual/indexes/ 查找 MongoDB 索引文档。

本章末尾有答案。

MongoDB Node.js 驱动程序

这是 Node.js 驱动程序,允许您连接 MongoDB 服务器并与之交互。它提供的方法与您在 mongo shell 中看到的非常相似,但又不完全相同。我们可以使用一个名为 Mongoose 的对象文档映射器来代替低级别的 MongoDB 驱动程序,它具有更高的抽象级别和更方便的方法。但是学习低级别的 MongoDB 驱动程序可能会让您更好地处理 MongoDB 本身的实际工作,所以我选择为问题跟踪器应用使用低级别的驱动程序。

首先,让我们安装驱动程序:

$ npm install mongodb@3

让我们也启动一个新的 Node.js 程序,尝试一下驱动程序方法的不同使用方式。在下一节中,我们将使用此次试用的一些代码来将驱动程序集成到问题跟踪器应用中。让我们将这个示例 Node.js 程序称为trymongo.js,并将其放在一个名为scripts的新目录中,以区别于应用中的其他文件。

首先要做的是连接到数据库服务器。这可以通过首先从驱动程序导入对象MongoClient,然后使用标识要连接的数据库的 URL 从它创建一个新的客户机对象,最后对它调用connect方法来完成,如下所示:

...
const { MongoClient } = require('mongodb');

const client = new MongoClient(url);
client.connect();
...

URL 应该以mongodb://开头,后跟要连接的服务器的主机名或 IP 地址。可以使用:作为分隔符添加一个可选端口,但是如果 MongoDB 服务器运行在默认端口 27017 上,就不需要这个端口。将连接参数分离到一个配置文件中而不是保存在一个签入的文件中是一个好习惯,但是我们将在下一章中这样做。现在,让我们对此进行硬编码。如果您使用了某个云提供商,可以从相应的连接说明中获得 URL。对于本地安装,URL 将是mongodb://localhost/issuetracker。请注意,MongoDB Node.js 驱动程序接受数据库名称作为 URL 本身的一部分,最好以这种方式指定它,即使云提供商可能不会明确显示这一点。

让我们将本地安装 URL 添加到trymongo.js和云提供商 URL 的注释版本。

...
const url = 'mongodb://localhost/issuetracker';

// Atlas URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';
...

此外,客户机构造函数接受另一个参数,为客户机提供更多设置,其中之一是是否使用新的样式解析器。让我们更改构造函数来传递它,以避免在最新的 Node.js 驱动程序(3.1 版)中出现警告。

...
const client = new MongoClient(url, { useNewUrlParser: true });
...

connect()方法是一个异步方法,需要一个回调来接收连接结果。回调接受两个参数:错误和结果。结果是客户端对象本身。在回调中,可以通过调用client对象的db方法来获得到数据库的连接(相对于到服务器的连接)。因此,回调和到数据库的连接可以写成这样:

...
client.connect(function(err, client) {
  const db = client.db();
...

到数据库的连接db,类似于我们在 mongo shell 中使用的db变量。特别是,我们可以用它来获得集合及其方法的句柄。让我们使用 mongo shell 来处理上一节中使用的名为employees的集合。

...
  const collection = db.collection('employees');
...

有了这个集合,我们可以做我们在上一节中用 mongo shell 的等价物db.employees所做的事情。这些方法也非常相似,只是它们都是异步的。这意味着这些方法接受常规参数,但也接受操作完成时调用的回调函数。回调函数中的约定是将错误作为第一个参数传递,将操作结果作为第二个参数传递。在前面的连接方法中,您已经看到了这种回调模式。

让我们插入一个文档并回读它,看看这些方法在 Node.js 驱动程序中是如何工作的。可以使用insertOne方法编写插入,传入一个雇员文档和一个回调。在回调中,让我们打印新创建的_id。就像在 mongo shell insertOne命令中一样,创建的 ID 作为结果对象的一部分返回,位于名为insertedId的属性中。

...
  const employee = { id: 1, name: 'A. Callback', age: 23 };
  collection.insertOne(employee, function(err, result) {
    console.log('Result of insert:\n', result.insertedId);
...

注意,访问集合和insert操作只能在连接操作的回调中调用,因为只有这样我们才知道连接成功了。还需要一些错误处理,但是让我们稍后再处理这个问题。

现在,在insert操作的回调中,让我们使用结果的 ID 读回插入的文档。我们可以使用我们提供的 ID(id)或者自动生成的 MongoDB ID ( _id)。让我们使用_id来确保我们能够使用结果值。

...
    collection.find({ _id: result.insertedId})
      .toArray(function(err, docs) {
        console.log('Result of find:\n', docs);
      }
...

现在我们已经完成了文档的插入和回读,我们可以关闭到服务器的连接了。如果我们不这样做,Node.js 程序就不会退出,因为连接对象正在等待被使用,并且正在监听一个套接字。

...
      client.close();
...

让我们将所有这些放在一个名为testWithCallbacks()的函数中。我们很快也将使用一种不同的方法来使用 Node.js 驱动程序。同样,按照惯例,让我们给这个函数传递一个回调函数,一旦所有操作完成,我们将从testWithCallbacks()函数调用这个函数。然后,如果有任何错误,可以将它们传递给回调函数。

让我们首先声明这个函数:

...
function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  ...
}
...

并且在作为每个操作结果的每个回调中,在出现错误时,我们需要执行以下操作:

  • 关闭与服务器的连接

  • 打电话回电

  • 从调用中返回,以便不再执行任何操作

当所有操作完成时,我们也需要这样做。错误处理的模式如下:

...
    if (err) {
      client.close();
      callback(err);
      return;
    }
...

让我们从主部分引入一个对testWithCallbacks()函数的调用,为它提供一个回调函数来接收任何错误,如果有错误就打印出来。

...
testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
});
...

引入所有错误处理和回调后,trymongo.js文件中的最终代码如清单 6-1 所示。

注意

虽然我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有出现在书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

const { MongoClient } = require('mongodb');

const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  client.connect(function(err, client) {
    if (err) {
      callback(err);
      return;
    }
    console.log('Connected to MongoDB');

    const db = client.db();
    const collection = db.collection('employees');

    const employee = { id: 1, name: 'A. Callback', age: 23 };
    collection.insertOne(employee, function(err, result) {
      if (err) {
        client.close();
        callback(err);
        return;
      }
      console.log('Result of insert:\n', result.insertedId);
      collection.find({ _id: result.insertedId})

        .toArray(function(err, docs) {
        if (err) {
          client.close();
          callback(err);
          return;
        }
        console.log('Result of find:\n', docs);
        client.close();
        callback(err);
      });
    });
  });
}

testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
});

Listing 6-1trymongo.js: Using Node.js driver, Using the Callbacks Paradigm

在测试之前,让我们清理一下集合。我们可以打开另一个命令 shell,在其中运行 mongo shell,并执行db.employees.remove({})。但是 mongo shell 有一种命令行方式,可以使用--eval命令行选项执行简单的命令。让我们这样做,并传递要连接的数据库名称;否则,该命令将在默认数据库test上执行。对于本地安装,该命令如下所示:

$ mongo issuetracker --eval "db.employees.remove({})"

如果您使用来自某个主机提供商的远程服务器,而不是数据库名称,请使用主机提供商建议的包含数据库名称的连接字符串。例如,Atlas 命令可能如下所示(用您自己的主机名、用户名和密码替换):

$ mongo "mongodb+srv://cluster0-xxxxx.mongodb.net/issuetracker" --username atlasUser --password atlasPassword --eval "db.employees.remove({})"

现在,我们准备测试我们刚刚创建的试用程序。它可以这样执行:

$ node scripts/trymongo.js

这将产生如下输出(您将看到不同的ObjectID,否则输出应该是相同的):

--- testWithCallbacks ---
Connected to MongoDB
Result of insert:
 5bbef955580a2c313d4052f6
Result of find:
 [ { _id: 5bbef955580a2c313d4052f6,
    id: 1,
    name: 'A. Callback',
    age: 23 } ]

正如你可能感觉到的,回调范式有点笨拙。但好处是它可以在旧版本的 JavaScript)中工作,因此也可以在旧版本的 Node.js 中工作。ES2015 开始支持承诺,Node.js MongoDB 驱动程序也支持承诺,这是对回调的改进。但在 ES2017 和 7.6 版的 Node.js 中,出现了对 async/await 范式的完全支持,这是使用驱动程序的推荐和最方便的方式。

让我们在使用 async/await 范例的trymongo.js中实现另一个名为testWithAsync()的函数。所有带有回调的异步调用现在都可以被对同一方法的调用所取代,但不提供回调。在方法调用之前使用await将模拟一个同步调用,由等待调用完成并返回结果。例如,我们可以不将回调传递给connect()方法,而是像这样等待它完成:

...
    await client.connect();
...

在下一行中,我们可以在操作完成后做任何需要做的事情,在本例中,连接到数据库:

...
    await client.connect();
    const db = client.db();
...

相同的模式可以用于其他异步调用,但有一点不同:调用的结果(最初是回调的第二个参数)可以直接赋给一个变量,比如函数调用的返回值。所以,insertOne()的结果可以这样捕捉:

...
    const result = await collection.insertOne(employee);
...

错误将被抛出并被捕获。我们可以将所有操作放在一个单独的try块中,并在一个地方(catch块)捕获任何错误,而不是在每次调用之后。函数不需要接受回调,因为如果调用者需要等待结果,可以在调用这个函数之前添加一个await,并抛出错误。

在每个操作connect()insertOne(),find()之前使用await的新功能如清单 6-2 所示。

async function testWithAsync() {
  console.log('\n--- testWithAsync ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  try {
    await client.connect();
    console.log('Connected to MongoDB');
    const db = client.db();
    const collection = db.collection('employees');

    const employee = { id: 2, name: 'B. Async', age: 16 };
    const result = await collection.insertOne(employee);
    console.log('Result of insert:\n', result.insertedId);

    const docs = await collection.find({ _id: result.insertedId })
      .toArray();
    console.log('Result of find:\n', docs);
  } catch(err) {
    console.log(err);
  } finally {
    client.close();
  }
}

Listing 6-2trymongo.js, testWithAsync Function

最后,让我们修改程序的主要部分,在处理来自testWithCallbacks()的返回值的回调中调用testWithAsync():

...
testWithCallbacks(function(err) {
  if (err) {
    console.log(err);
  }
  testWithAsync();
});
...

如果您如前所述使用remove()清除集合并测试这些更改,您将会看到这个结果(您看到的ObjectIDs将与这里显示的不同):

--- testWithCallbacks ---
Connected to MongoDB
Result of insert:
 5bbf25dcf50e97340be0f01f
Result of find:
 [ { _id: 5bbf25dcf50e97340be0f01f,
    id: 1,
    name: 'A. Callback',
    age: 23 } ]

--- testWithAsync ---
Connected to MongoDB
Result of insert:
 5bbf25dcf50e97340be0f020
Result of find:
 [ { _id: 5bbf25dcf50e97340be0f020,
    id: 2,
    name: 'B. Async',
    age: 16 } ]

测试错误是否被捕获和显示的一个好方法是再次运行程序。会有错误,因为我们在字段id上有一个唯一的索引,所以 MongoDB 将抛出一个重复的键冲突。如果在创建索引后删除了集合,可以运行createIndex()命令来恢复这个索引。

正如您所看到的,async/await 范式在代码方面要小得多,也清晰易读得多。事实上,虽然我们在这个函数中捕获了错误,但是我们并不需要这样做。我们也可以让打电话的人来处理。

考虑到 async/await 范例的好处,让我们在与数据库交互时在 Issue Tracker 应用中使用它。

模式初始化

mongo shell 不仅是一个交互式 shell,而且还是一个脚本环境。利用这一点,可以编写脚本来执行各种任务,例如模式初始化和迁移。因为 mongo shell 实际上是构建在 JavaScript 引擎之上的,所以 JavaScript 的强大功能在脚本中是可用的,就像在 shell 本身中一样。

交互式和非交互式工作模式之间的一个区别是,非交互式 shell 不支持非 JavaScript 快捷方式,例如use <db>show collections命令。该脚本必须是遵循正确语法的常规 JavaScript 程序。

让我们在script目录中创建一个名为init.mongo.js的模式初始化脚本。因为 MongoDB 不强制模式,所以实际上不存在像在关系数据库中创建表那样的模式初始化。唯一真正有用的是创建索引,这是一次性任务。同时,让我们用一些样本文档来初始化数据库,以便于测试。我们将使用我们用来测试 mongo shell 的同一个数据库issuetracker,来存储所有与问题跟踪器应用相关的集合。

让我们复制来自server.js的问题数组,并使用同一个数组在名为issues的集合上使用insertMany()来初始化集合。但是在此之前,让我们通过在同一个集合上调用一个带有空过滤器的remove()(它将匹配所有文档)来清除现有的问题。然后,让我们在有用的字段上创建一些索引,我们将使用这些索引来搜索集合。

清单 6-3 显示了初始化脚本init.mongo.js的完整内容。文件开头的注释说明了如何为不同类型的数据库运行这个脚本——local、Atlas 和 mLab。

/*
 * Run using the mongo shell. For remote databases, ensure that the
 * connection string is supplied in the command line. For example:
 * localhost:
 *   mongo issuetracker scripts/init.mongo.js
 * Atlas:
 *   mongo mongodb+srv://user:pwd@xxx.mongodb.net/issuetracker
     scripts/init.mongo.js
 * MLab:
 *   mongo mongodb://user:pwd@xxx.mlab.com:33533/issuetracker
     scripts/init.mongo.js
 */

db.issues.remove({});

const issuesDB = [
  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },
];

db.issues.insertMany(issuesDB);
const count = db.issues.count();
print('Inserted', count, 'issues');

db.issues.createIndex({ id: 1 }, { unique: true });
db.issues.createIndex({ status: 1 });
db.issues.createIndex({ owner: 1 });
db.issues.createIndex({ created: 1 });

Listing 6-3init.mongo.js: Schema Initialization

如果您像这样使用 MongoDB 的本地安装,您应该能够使用 mongo shell 运行这个脚本,在命令行中将文件名作为参数:

$ mongo issuetracker scripts/init.mongo.js

对于使用 MongoDB 的其他方法,在脚本的顶部有作为注释的说明。实际上,必须在命令行中指定整个连接字符串,包括用于连接托管服务的用户名和密码。在连接字符串之后,您可以键入脚本的名称scripts/init.mongo.js

您可以在任何希望将数据库重置为原始状态的时候运行该命令。您应该会看到一个输出,表明插入了两个问题,以及 MongoDB 版本和 shell 版本。请注意,在索引已经存在的情况下创建索引没有任何效果,因此多次创建索引是安全的。

练习:模式初始化

  1. 使用 Node.js 脚本和 MongoDB 驱动程序可以完成相同的模式初始化。使用 mongo shell 和 Node.js MongoDB 驱动程序这两种方法各有什么优缺点?

  2. 还有其他可能有用的索引吗?提示:如果我们在应用中需要一个搜索栏呢?在 https://docs.mongodb.com/manual/indexes/#index-types 阅读 MongoDB 索引类型。

本章末尾有答案。

从 MongoDB 读取

在上一节中,您看到了如何使用 Node.js 驱动程序来执行基本的 CRUD 任务。有了这些知识,现在让我们将 List API 改为从 MongoDB 数据库中读取,而不是从服务器中的内存数组中读取。因为我们已经用相同的初始问题集初始化了数据库,所以在测试时,您应该在 UI 中看到相同的问题集。

在我们为驱动程序做的试验中,我们在一系列操作中使用了到数据库的连接,并关闭了它。相反,在应用中,我们将维护连接,以便我们可以在许多操作中重用它,这些操作将从 API 调用中触发。因此,我们需要将与数据库的连接存储在一个全局变量中。除了import语句和其他全局变量声明之外,我们还可以调用全局数据库连接变量db:

...
const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname
// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

let db;
...

接下来,让我们编写一个连接数据库的函数,它初始化这个全局变量。这是我们在trymongo.js中所做的一个小变化。我们不要在这个函数中捕捉任何错误,而是让调用者来处理它们。

...
async function connectToDb() {
  const client = new MongoClient(url, { useNewUrlParser: true });
  await client.connect();
  console.log('Connected to MongoDB at', url);
  db = client.db();
}
...

现在,我们必须更改服务器的设置,首先连接到数据库,然后启动 Express 应用。由于connectToDb()是一个异步函数,我们可以使用await等待它完成,然后调用app.listen()。但是由于await不能用在程序的主要部分,我们必须将它放在一个async函数中,并立即执行该函数。

...
(async function () {
  await connectToDb();
  app.listen(3000, function () {
    console.log('App started on port 3000');
  });
})();
...

但是我们也必须处理错误。因此,让我们将这个匿名函数的内容包含在一个try块中,并在控制台的catch块中打印任何错误:

...
(async function () {
  try {
    ...
  } catch (err) {
    console.log('ERROR:', err);
  }
})();
...

现在我们已经连接到在名为db的全局变量中设置的数据库,我们可以在列表 API 解析器issueList()中使用它,通过调用issues集合上的find()方法来检索问题列表。我们需要从这个函数返回一系列问题,所以让我们像这样对find()的结果使用toArray()函数:

...
  const issues = await db.collection('issues').find({}).toArray();
...

清单 6-4 中显示了对server.js的更改。

...
const { Kind } = require('graphql/language');

const { MongoClient } = require('mongodb');

const url = 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

let db;

let aboutMessage = "Issue Tracker API v1.0";
...

async function issueList() {
  return issuesDB;
  const issues = await db.collection('issues').find({}).toArray();
  return issues;
}
...

async function connectToDb() {

  const client = new MongoClient(url, { useNewUrlParser: true });
  await client.connect();
  console.log('Connected to MongoDB at', url);
  db = client.db();

}

const server = new ApolloServer({
...

(async function () {

  try {
    await connectToDb();
    app.listen(3000, function () {
      console.log('App started on port 3000');
    });
  } catch (err) {
    console.log('ERROR:', err);
  }

})();

Listing 6-4server.js: Changes for Reading the Issue List from MongoDB

注意

我们不需要做任何特殊的事情,因为解析器issueList()现在是一个异步函数,它不会立即返回值。graphql-tools库自动处理这个问题。解析器可以立即返回值或返回承诺(这是异步函数立即返回的内容)。两者都是解析程序可接受的返回值。

由于来自数据库的问题现在除了包含id字段之外还包含一个_id,让我们将它包含在Issue类型的 GraphQL 模式中。否则,调用 API 的客户端将无法访问该字段。让我们使用ID作为它的 GraphQL 数据类型,并使它成为强制的。这一变化如清单 6-5 所示。

...
type Issue {
  _id: ID!
  id: Int!
  ...
}
...

Listing 6-5schema.graphql: Changes to add _id as a Field in Issue

现在,假设服务器仍在运行(或者您已经重新启动了服务器和编译),如果您刷新浏览器,您会发现两个初始问题集列在一个表中,和以前一样。UI 本身不会显示任何变化,但是为了让自己相信数据确实来自数据库,您可以使用 mongo shell 和集合上的updateMany()方法修改集合中的文档。例如,如果您将所有文档的工作量更新为 100 并刷新浏览器,您应该会看到表中所有行的工作量都显示为 100。

练习:从 MongoDB 中读取

  1. 我们将连接保存在一个全局变量中。当连接丢失时会发生什么?停止 MongoDB 服务器,然后再次启动它,看看会发生什么。连接还能用吗?

  2. 关闭 MongoDB 服务器,等待一分钟或更长时间,然后再次启动服务器。现在,刷新浏览器。会发生什么?你能解释这个吗?如果您希望即使在数据库服务器关闭的情况下,连接也能工作更长时间,该怎么办?提示:在 http://mongodb.github.io/node-mongodb-native/3.1/reference/connecting/connection-settings/ 查找连接设置参数。

  3. 我们使用toArray()将问题列表转换成一个数组。如果列表太大,比如说一百万个文档,该怎么办?你会怎么处理这件事?提示:在 http://mongodb.github.io/node-mongodb-native/3.1/api/Cursor.html 查找 MongoDB Node.js 驱动的Cursor的文档。注意,find()方法返回一个Cursor

写入 MongoDB

为了完全替换服务器上的内存数据库,我们还需要更改 Create API 以使用 MongoDB 数据库。正如您在 MongoDB CRUD 操作一节中看到的,创建新文档的方法是对集合使用insertOne()方法。

我们使用内存数组的大小来生成新文档的id字段。我们可以做同样的事情,使用集合的count()方法来获取下一个 ID。但是当有多个用户使用这个应用时,在我们调用count()方法和调用insertOne()方法之间有一个小的机会创建一个新文档。我们真正需要的是一种可靠的方法来生成一个不会重复的数字序列,就像流行的关系数据库中的序列一样。

MongoDB 没有直接提供这样的方法。但是它支持原子更新操作,可以返回更新的结果。这种方法叫做findOneAndUpdate()。使用这种方法,我们可以更新一个计数器并返回更新后的值,但是我们可以使用增加当前值的$inc操作符,而不是使用$set操作符。

让我们首先创建一个包含计数器的集合,该计数器保存最新生成的问题 ID 的值。为了使它更通用,让我们假设我们可能有其他这样的计数器,并使用一个集合,该集合的 ID 设置为计数器的名称,一个名为current的值字段保存计数器的当前值。将来,我们可以在同一个集合中添加更多的计数器,并且这些将转化为每个计数器一个文档。

首先,让我们修改模式初始化脚本,以包含一个名为counters的集合,并用一个针对问题计数器的文档填充它。因为有些插入会产生一些样本问题,所以我们需要将计数器的值初始化为插入文档的计数。变化在init.mongo.js中,清单 6-6 显示了这个文件。

...
print('Inserted', count, 'issues');

db.counters.remove({ _id: 'issues' });

db.counters.insert({ _id: 'issues', current: count });

...

Listing 6-6init.mongo.js: Initialize Counters for Issues

让我们再次运行模式初始化脚本,使更改生效:

$ mongo issuetracker scripts/init.mongo.js

现在,对增加字段currentfindOneAndUpdate()的调用保证返回序列中下一个唯一值。让我们在server.js中创建一个函数来做这件事,但是以一种通用的方式。我们将让它获取计数器的 ID 并返回下一个序列。在这个函数中,我们要做的就是调用findOneAndUpdate()。它使用提供的 ID 标识要使用的计数器,递增名为current的字段,并返回新值。默认情况下,findOneAndUpdate()方法的结果返回原始文档。要使它返回新的、修改过的文档,选项returnOriginal必须设置为false

方法findOneAndUpdate()的参数是(a)过滤器或匹配,我们使用了 _ id,然后是(b)更新操作,我们使用了值为 1 的$inc操作符,最后是(c)操作的选项。下面是完成必要工作的代码:

...
async function getNextSequence(name) {
  const result = await db.collection('counters').findOneAndUpdate(
    { _id: name },
    { $inc: { current: 1 } },
    { returnOriginal: false },
  );
  return result.value.current;
}
...

注意

返回当前值或新值的选项在 Node.js 驱动程序和 mongo shell 中的调用是不同的。在 mongo shell 中,该选项被称为returnNewDocument,默认为false。在 Node.js 驱动中,该选项被称为returnOriginal,默认为true。在这两种情况下,默认行为都是返回原始文档,因此必须指定选项来返回新文档。

现在,我们可以使用这个函数生成一个新的 ID 字段,并在解析器issueAdd()中的提供的issue对象中设置它。然后,我们可以使用insertOne()写入名为issues的集合,然后使用findOne()读回新创建的问题。

...
  issue.id = await getNextSequence('issues');

  const result = await db.collection('issues').insertOne(issue);
  const savedIssue = await db.collection('issues')
    .findOne({ _id: result.insertedId });
  return savedIssue;
...

最后,我们可以消除服务器中的内存问题。包括这一变化,清单 6-7 中显示了server.js中的全部变化。

...

const issuesDB = [

  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    ...
  },
  ...
  },

];

...

async function getNextSequence(name) {

  const result = await db.collection('counters').findOneAndUpdate(
    { _id: name },
    { $inc: { current: 1 } },
    { returnOriginal: false },
  );
  return result.value.current;

}

async function issueAdd(_, { issue }) {
  const errors = [];
  ...
  issue.created = new Date();

  issue.id = issuesDB.length + 1;
  issue.id = await getNextSequence('issues');

  issuesDB.push(issue);
  const result = await db.collection('issues').insertOne(issue);

  return issue;
  const savedIssue = await db.collection('issues')
    .findOne({ _id: result.insertedId });
  return savedIssue;
...

Listing 6-7server.js: Changes for Create API to Use the Database

测试这组更改将显示可以添加新的问题,即使重新启动 Node.js 服务器或数据库服务器,新添加的问题仍然存在。作为交叉检查,您可以使用 mongo shell 在 UI 每次更改后查看集合的内容。

练习:写入 MongoDB

  1. 我们是否可以将_id添加到传入的对象中并返回,而不是对插入的对象执行find()操作?

本章末尾有答案。

摘要

在本章中,您了解了 MongoDB 中数据库实例的安装和其他访问方法。您看到了如何使用 mongo shell 和 Node.js 驱动程序来访问 MongoDB 中的基本操作:CRUD 操作。然后,我们修改了问题跟踪器应用,使用其中的一些方法来读写 MongoDB 数据库,从而使问题列表持久化。

我只讲述了 MongoDB 的基础知识,只讲述了对构建 Issue Tracker 应用有用的功能和特性,这是一个相当简单的 CRUD 应用。实际上,数据库、Node.js 驱动程序和 mongo shell 的功能非常强大,复杂的应用可能需要 MongoDB 的更多功能。我鼓励您看一看 MongoDB 文档( https://docs.mongodb.com/manual/ )和 Node.js 驱动程序文档( http://mongodb.github.io/node-mongodb-native/ ),以熟悉数据库和 Node.js 驱动程序还能做什么。

既然我们已经使用了 MERN 堆栈的基本要素,并且有了一个可以工作的应用,那么让我们暂时停止实现特性,而是稍微组织一下。在应用变得更大、更笨拙之前,让我们将代码模块化,并使用工具来提高我们的生产率。

我们将在下一章通过使用 Webpack 来实现这一点,web pack 是可用于模块化前端和后端代码的最佳工具之一。

练习答案

练习:MongoDB 基础知识

  1. 根据“访问 mongo shell 帮助”下的 mongo shell 文档,您可以发现在许多对象上都有一个名为help()的方法,包括cursor对象。获得帮助的方法是使用db.collection.find().help()

    但由于这也是一个类似 Node.js 的 JavaScript shell,按 Tab 将自动完成,双 Tab 将显示可能完成的列表。因此,如果您将光标指定给一个变量,并在键入变量名和其后的点之后按 Tab 键两次,shell 将列出可能的完成,这是光标上可用方法的列表。

练习:MongoDB CRUD 操作

  1. 这可以使用如下的$exists操作符来完成:

    > db.employees.find({ "name.middle": { $exists: true } })

  2. 过滤器规范不是 JSON 文档,因为它不是字符串。它是一个常规的 JavaScript 对象,这就是为什么您可以跳过属性名两边的引号。与 JSON 字符串不同,您还可以将真正的Date对象作为字段值。

  3. The $unset operator in an update can be used to unset a field (which is actually different from setting it to null). Here is an example:

    > db.employees.update(({_id: ObjectId("57b1caea3475bb1784747ccb")},
    {"name.middle": {$unset: null}})
    
    

    尽管我们提供了null作为$unset的值,但是这个值被忽略了。它可以是任何东西。

  4. 1 表示遍历索引的升序排序。-1用于表示降序排序。这只对复合索引有用,因为一个字段的简单索引可以用来双向遍历集合。

练习:模式初始化

  1. 使用 Node.js 驱动程序的优点是有一种跨应用和脚本的方式,这种熟悉有助于防止错误。但是运行这个程序需要一个合适的 Node.js 环境,包括安装的 npm 模块,而 mongo shell 脚本可以从任何地方运行,只要机器安装了 mongo shell。

  2. 搜索栏在搜索问题时非常有用。在这种情况下,标题字段上的文本索引(基于单词的索引)会很有用。我们将在书的结尾实现一个文本索引。

练习:从 MongoDB 中读取

  1. 连接对象实际上是一个连接池。它会自动确定最佳做法:重用现有的 TCP 连接,在连接断开时重新建立新的连接,等等。建议使用全局变量(至少重用连接对象)。

  2. 如果数据库在短时间内(不到 30 秒)不可用,当数据库再次可用时,驱动程序会重试并重新连接。如果数据库长时间不可用,读取将引发错误。还原数据库时,驱动程序也无法重新建立连接。在这种情况下,需要重启应用服务器。

    使用连接设置reconnectTriesreconnectInterval可以更改默认的 30 秒间隔。

  3. 一种选择是对结果使用limit()来限制返回值的最大记录数。例如,find().limit(100)返回前 100 个文档。如果要对 UI 中的输出进行分页,也可以使用skip()方法来指定列表的起始位置。

    另一方面,如果您认为客户端可以处理大型列表,但是您不希望在服务器上花费那么多内存,那么您可以使用hasNext()next()一次处理一个文档,并将结果返回给客户端。

练习:写入 MongoDB

  1. 添加_id并返回传入的对象本来是可行的,只要您确实知道写操作是成功的,并且对象被原样写入数据库。在大多数情况下,这是正确的,但是从数据库中获得结果是一个好的实践,因为这是最终的真理。**

七、架构和 ESLint

在本章和下一章中,我们将暂停添加特性。相反,当应用变得越来越大时,我们将会变得更有组织性。

在这一章中,我们将再次审视该架构,并使其更加灵活,以便它能够满足具有大量流量的大型应用的需求。我们将使用一个名为 dotenv 的包来帮助我们在不同的环境中运行相同的代码,为每个环境使用不同的配置,比如开发和生产。

最后,我们将添加检查,以验证我们编写的代码遵循标准和良好的实践,并在测试周期的早期捕获可能的错误。为此,我们将使用 ESLint。

UI 服务器

到目前为止,我们并没有太关注应用的架构,并且唯一的服务器处理两个功能。Express 服务器不仅提供静态内容,还提供 API 调用。该架构如图 7-1 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig1_HTML.jpg

图 7-1

单一服务器架构

所有的请求都在同一个物理服务器上,其中有一个也是唯一的 Express 应用。然后,根据请求将请求路由到两个不同的中间件。目录中的任何请求匹配文件都由名为static的中间件进行匹配。这个中间件使用磁盘来读取文件并提供文件内容。其他匹配/graphql路径的请求由 Apollo 服务器的中间件处理。这个中间件使用解析器从 MongoDB 数据库获取数据。

这对于小型应用非常有用,但是随着应用的增长,会出现以下一种或多种情况:

  • API 有其他的消费者,不仅仅是基于浏览器的 UI。例如,API 可能会暴露给第三方或移动应用。

  • 这两部分有不同的扩容要求。通常,随着 API 的消费者越来越多,您可能需要多个 API 服务器和一个负载平衡器。然而,由于大多数静态内容能够并且将会被缓存在浏览器中,所以为静态资产提供许多服务器可能是大材小用。

此外,两种功能都在同一个服务器上完成,都在同一个 Node.js 和 Express 流程中,这使得诊断和调试性能问题变得更加困难。一个更好的选择是将这两个功能分成两个服务器:一个提供静态内容,另一个只托管 API。

在后面的章节中,我将介绍服务器呈现,其中完整的页面将从服务器生成,而不是在浏览器上构建。这有助于搜索引擎正确地索引页面,因为搜索引擎机器人不一定运行 JavaScript。当我们实现服务器渲染时,如果所有的 API 代码和 UI 代码都是分开的,将会有所帮助。

图 7-2 描绘了 UI 和 API 服务器分离的新一代架构。它还展示了当我们实现服务器端渲染时,它最终将适用于何处。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig2_HTML.jpg

图 7-2

独立的 UI 服务器架构

在图 7-2 的图中,可以看到有两个服务器:UI 服务器和 API 服务器。这些可以是物理上不同的计算机,但是出于开发的目的,我们将在同一台计算机上运行它们,但是在不同的端口上运行。这些将使用两个不同的 Node.js 进程运行,每个进程都有自己的 Express 实例。

API 服务器现在将只负责处理API 请求,因此,它将只响应路径中匹配/graphql的 URL。因此,Apollo 服务器中间件及其对 MongoDB 数据库的请求将是 API 服务器中唯一的中间件。

UI 服务器部分现在将只包含静态中间件。在未来,当我们引入服务器渲染时,该服务器将通过调用 API 服务器的 API 来获取必要的数据,从而负责生成 HTML 页面。目前,我们将只使用 UI 服务器来提供所有静态内容,包括index.html和包含所有 React 代码的 JavaScript 包。

浏览器将负责根据请求的类型使用适当的服务器:所有的 API 调用将被定向到 API 服务器,而静态文件将被提交到 UI 服务器。

为了实现这一点,我们要做的第一件事是创建一个新的目录结构,将 UI 和 API 代码清晰地分开。

注意

理想情况下,UI 和 API 代码应该属于两个不同的存储库,因为它们之间没有共享。但是为了方便阅读这本书和参考 GitHub 库中的 Git diffs(https://github.com/vasansr/pro-mern-stack-2),我把代码放在一起,但是放在最顶层的不同目录中。

让我们重命名server目录api,而不是创建一个新的目录。

$ mv server api

注意

显示的命令(也可以在 GitHub 存储库(commands.md文件中的 https://github.com/vasansr/pro-mern-stack-2 )是为了在 MacOS 或基于 Linux 的发行版中的 bash shell 中执行。如果您使用的是 Windows PC,则必须使用 Windows 的等效命令。

因为我们拥有的所有脚本都只适用于 API 服务器,所以让我们将scripts目录也移到新目录api下。

$ mv scripts api

对于 UI 代码,让我们在项目根目录下创建一个名为ui的新目录,并将 UI 相关的目录publicsrc移到这个目录下。

$ mkdir ui
$ mv public ui
$ mv src ui

但是仅仅移动目录是不够的;我们需要在这些目录uiapi中各有一个package.json文件,既用于保存 npm 依赖关系,也用于创建运行服务器的便捷脚本。有了新的package.json文件并安装了所有的依赖项后,新的目录结构将如图 7-3 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig3_HTML.jpg

图 7-3

用于 UI 服务器分离的新目录结构

现在让我们在两个新目录中创建两个新的package.json文件。为了方便起见,您还可以从根项目目录中复制这个文件并进行修改。

在 API 对应的文件中,让我们在名称(例如,pro-mern-stack-2-api)和描述(例如,"Pro MERN Stack (2nd Edition) API")中使用 API 这个词。至于脚本,我们将只有一个脚本来启动服务器。由于文件的位置已经从server更改为当前目录,我们可以在这个脚本中删除nodemon-w选项。

...
    "start": "nodemon -e js,graphql server.js",
...

至于依赖项,我们没有任何devDependencies,但是有运行服务器所需的所有常规依赖项。完整的package.json文件如清单 7-1 所示。

{
  "name": "pro-mern-stack-2-api",
  "version": "1.0.0",
  "description": "Pro MERN Stack (2nd Edition) API",
  "main": "index.js",
  "scripts": {
    "start": "nodemon -e js,graphql server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
  },
  "author": "vasan.promern@gmail.com",
  "license": "ISC",
  "homepage": "https://github.com/vasansr/pro-mern-stack-2",
  "dependencies": {
    "apollo-server-express": "².3.1",
    "express": "⁴.16.4",
    "graphql": "⁰.13.2",
    "mongodb": "³.1.10",
    "nodemon": "¹.18.9"
  }
}

Listing 7-1api/package.json: New File

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

现在,让我们根据api目录中的新package.json文件安装所有的 npm 依赖项。

$ cd api
$ npm install

因为我们将在这个新的api目录中运行服务器,所以我们需要从当前目录加载schema.graphql。因此,让我们修改server.js中的代码,从正在加载的schema.graphql的路径中删除/server/前缀。

...
const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...

我们还可以删除static中间件的加载,并在控制台消息中将新服务器称为 API 服务器,而不是应用服务器。清单 7-2 中显示了api/server.js的全套变更。

...
const server = new ApolloServer({
  typeDefs: fs.readFileSync('./server/schema.graphql', 'utf-8'),
...
const app = express();

app.use(express.static('public'));

server.applyMiddleware({ app, path: '/graphql' });
...
    app.listen(3000, function () {
      console.log('AppAPI server started on port 3000');
    });
...

Listing 7-2api/server.js: Changes for New Location of schema.graphql

此时,您应该能够使用npm start运行 API 服务器。此外,如果您使用 GraphQL Playground 测试 API,您应该会发现 API 像以前一样工作。

UI 服务器的变化有点复杂。我们需要一个新的既有服务器又有转换 npm 包的package.json,比如 Babel。让我们在 UI 目录中创建新的package.json。你可以通过从项目根目录复制或者运行npm init来完成。然后,在依赖项部分,让我们添加 Express 和 nodemon:

...
  "dependencies": {
    "express": "⁴.16.4",
    "nodemon": "¹.18.9"
  },
...

至于devDependencies,让我们从根目录下的package.json开始保留原设置。

...
  "devDependencies": {
    "@babel/cli": "⁷.2.3",
    "@babel/core": "⁷.2.2",
    "@babel/preset-env": "⁷.2.3",
    "@babel/preset-react": "⁷.0.0"
  }
...

让我们安装 UI 服务器所需的所有依赖项。

$ cd ui
$ npm install

现在,让我们创建一个 Express 服务器来服务目录ui中名为uiserver.js的静态文件。这与我们为 Hello World 创建的服务器非常相似。我们所需要的是带有static中间件的 Express 应用。文件内容如清单 7-3 所示。

const express = require('express');

const app = express();

app.use(express.static('public'));

app.listen(8000, function () {
  console.log('UI started on port 8000');
});

Listing 7-3ui/uiserver.js: New Server for Static Content

要运行这个服务器,让我们在package.json中创建一个启动它的脚本。这是您在其他服务器启动脚本中看到的常见的nodemon命令。这一次,我们将只关注uiserver.js文件,因为我们还有其他与服务器本身无关的文件。

...
  "scripts": {
    "start": "nodemon -w uiserver.js uiserver.js",
  },
...

此外,为了生成转换后的 JavaScript 文件,让我们添加compilewatch脚本,就像在原始的package.json文件中一样。该文件的完整内容,包括compilewatch脚本,如清单 7-4 所示。

{
  "name": "pro-mern-stack-2-ui",
  "version": "1.0.0",
  "description": "Pro MERN Stack (2nd Edition) - UI",
  "main": "index.js",
  "scripts": {
    "start": "nodemon -w uiserver.js uiserver.js",
    "compile": "babel src --out-dir public",
    "watch": "babel src --out-dir public --watch --verbose"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vasansr/pro-mern-stack-2.git"
  },
  "author": "vasan.promern@gmail.com",
  "license": "ISC",
  "homepage": "https://github.com/vasansr/pro-mern-stack-2",
  "dependencies": {
    "express": "⁴.16.3",
    "nodemon": "¹.18.4"
  },
  "devDependencies": {
    "@babel/cli": "⁷.0.0",
    "@babel/core": "⁷.0.0",
    "@babel/preset-env": "⁷.0.0",
    "@babel/preset-react": "⁷.0.0"
  }
}

Listing 7-4ui/package.json: New File for the UI Server

现在,您可以通过在每个对应的目录中使用npm start运行 UI 和 API 服务器来测试应用。至于转换,您可以在ui目录中运行npm run compilenpm run watch。但是 API 调用将会失败,因为端点/graphql在 UI 服务器中没有处理程序。因此,我们需要更改 UI 来调用 API 服务器,而不是对 UI 服务器进行 API 调用。这可以在App.jsx文件中完成,如清单 7-5 所示。

...
async function graphQLFetch(query, variables = {}) {
  try {
    const response = await fetch('http://localhost:3000/graphql', {
      method: 'POST',
...

Listing 7-5ui/src/App.jsx: Point to a Different API Server

现在,如果您测试应用,您会发现它像以前一样工作。我们也可以清理根目录。文件package.json和目录node_modules不再需要,可以删除。完成此操作的 Linux 和 MacOS 命令如下:

$ rm package.json
$ rm -rf node_modules

练习:UI 服务器

  1. 打开一个新的浏览器标签,输入http://localhost:3000。你看到了什么,为什么?我们需要对此做些什么吗?有哪些选择?提示:以类似的方式浏览到 GitHub 的 API 端点主机,在 https://api.github.com

本章末尾有答案。

多重环境

我们推迟了移除硬编码的东西,比如端口号和 MongoDB URL。既然目录结构已经最终确定,现在可能是删除所有硬编码并将它们作为更容易更改的变量的好时机。

通常,有三种部署环境:开发、试运行和生产。每一个的服务器端口和 MongoDB URL 会有很大的不同。例如,API 服务器和 UI 服务器的端口都是 80。我们使用了两个不同的端口,因为两个服务器都运行在同一台主机上,并且两个进程不能在同一个端口上侦听。此外,我们使用 8000 这样的端口,因为使用端口 80 需要管理特权(超级用户权限)。

与其根据可能的部署目标(如开发、登台和生产)预先确定端口和 MongoDB URL,不如让变量保持灵活性,以便它们可以在运行时设置为任何值。提供这些的典型方式是通过环境变量,特别是对于远程目标和生产服务器。但是在开发过程中,最好能够在一些配置文件中包含这些内容,这样开发人员就不需要每次都记住设置这些内容。

让我们使用一个名为 dotenv 的包来帮助我们实现这一点。这个包可以将存储在文件中的变量转换成环境变量。因此,在代码中,我们只处理环境变量,但是环境变量可以通过真实的环境变量或配置文件来提供。

dotenv 包寻找一个名为.env的文件,它可以包含像在 shell 中定义的变量。例如,我们可以在该文件中包含以下行:

...
DB_URL=mongodb://localhost/issuetracker
...

在代码中,我们要做的就是使用process.env.DB_URL查找环境变量DB_URL,并使用其中的值。这个值可以被程序启动前定义的实际环境变量覆盖,所以没有必要有这个文件。事实上,大多数生产部署只从环境变量中获取值。

现在让我们安装软件包,首先在 API 服务器中:

$ cd api
$ npm install dotenv@6

要使用这个包,我们需要做的就是require它并立即调用它的config()

...
require('dotenv').config();
...

现在,我们可以通过使用process.env属性来使用任何环境变量。让我们首先在server.js中为 MongoDB URL 这样做。我们已经有了一个变量url,我们可以将它从process.env设置为DB_URL,如果没有定义的话,就将它默认为原来的本地主机值:

...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
...

同样,对于服务器端口,我们使用一个名为API_SERVER_PORT的环境变量,并在server.js中使用一个名为port的变量,如下所示:

...
const port = process.env.API_SERVER_PORT || 3000;
...

现在我们可以使用可变端口来启动服务器。

...
    app.listen(3000port, function () {
      console.log('API server started on port 3000');
      console.log(`API server started on port ${port}`);
...

请注意引号样式从单引号到反勾号的变化,因为我们使用了字符串插值。清单 7-6 显示了对api/server.js文件的一整套修改。

...
const fs = require('fs');

require('dotenv').config();

const express = require('express');
...
const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

...

const port = process.env.API_SERVER_PORT || 3000;

(async function () {
  try {
    ...
    app.listen(3000port, function () {
      console.log('API server started on port 3000');
      console.log(`API server started on port ${port}`);
    });
   ...
...

Listing 7-6api/server.js: Changes to Use Environment Variables

让我们在api目录中创建一个名为.env的文件。我在 GitHub 资源库中包含了一个名为sample.env的文件,你可以从中复制并修改以适应你的环境,尤其是DB_URL。该文件的内容如清单 7-7 所示。

## DB
# Local
DB_URL=mongodb://localhost/issuetracker

# Atlas - replace UUU: user, PPP: password, XXX: hostname
# DB_URL=mongodb+srv://UUU:PPP@XXX.mongodb.net/issuetracker?retryWrites=true

# mLab - replace UUU: user, PPP: password, XXX: hostname, YYY: port
# DB_URL=mongodb://UUU:PPP@XXX.mlab.com:YYY/issuetracker

## Server Port
API_SERVER_PORT=3000

Listing 7-7api/sample.env: Sample .env File

建议不要将.env文件签入任何存储库。每个开发人员和部署环境都必须根据自己的需要,在环境或该文件中专门设置变量。这是为了使对此文件的更改保留在开发人员的计算机中,而其他人的更改不会覆盖开发人员的设置。

更改nodemon命令行也是一个好主意,这样它可以监视对该文件的更改。由于当前命令行不包含 watch 规范(因为它默认为".",即当前目录),所以让我们也包含它。清单 7-8 显示了package.json中对这个脚本的修改。

...
  "scripts": {
    "start": "nodemon -e js,graphql -w . -w .env server.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
...

Listing 7-8api/package.json: nodemon to Watch for .env

现在,如果您在文件.env中将API_SERVER_PORT指定为4000并重启 API 服务器(因为 nodemon 需要知道新的观察文件),您应该会看到它现在使用端口 4000。您可以撤销这一更改,改为定义一个环境变量(不要忘记在 bash shell 中使用export来使该变量对子进程可用),并查看更改是否已经完成。注意,实际的环境变量优先于(或覆盖)在.env文件中定义的相同变量。

让我们也对api/scripts/trymongo.js做一组类似的更改,以使用环境变量DB_URL。这些变化如清单 7-9 所示。还有一些更改是在连接后打印出 URL,以交叉检查环境变量是否被使用。

require('dotenv').config();
const { MongoClient } = require('mongodb');

const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';

// Atlas URL  - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb+srv://UUU:PPP@cluster0-XXX.mongodb.net/issuetracker?retryWrites=true';

// mLab URL - replace UUU with user, PPP with password, XXX with hostname

// const url = 'mongodb://UUU:PPP@XXX.mlab.com:33533/issuetracker';

...
  client.connect(function(err, client) {
    ...
    console.log('Connected to MongoDB');
    console.log('Connected to MongoDB URL', url);
...
    await client.connect();
    console.log('Connected to MongoDB');
    console.log('Connected to MongoDB URL', url);

Listing 7-9api/scripts/trymongo.js: Read DB_URI from the Environment Using dotenv

现在,您可以像以前一样使用命令行和 Node.js 来测试脚本,您将看到不同环境变量的效果,包括在 shell 和.env文件中。

我们需要对 UI 服务器进行类似的更改。在这种情况下,我们需要使用的变量是:

  • UI 服务器端口

  • 要调用的 API 端点

UI 服务器端口更改类似于 API 服务器端口更改。让我们先把那件事做完。至于 API 服务器,我们来安装 dotenv 包。

$ cd ui
$ npm install dotenv@6

然后,在ui/uiserver.js文件中,让我们要求并配置 dotenv:

...
require('dotenv').config();
...

让我们也将硬编码的端口改为使用环境变量。

...

const port = process.env.UI_SERVER_PORT || 8000;

app.listen(8000 port, function () {
  console.log('UI started on port 8000');
  console.log(`UI started on port ${port}`);
});
...

与这些变化不同,API 端点必须以 JavaScript 代码的形式到达浏览器。它不是可以从环境变量中读取的东西,因为它不会传输到浏览器。

一种方法是在构建和绑定过程中,用变量值替换代码中的预定义字符串。我将在下一节描述这个方法。尽管对许多人来说这是一个有效的首选,但我还是选择将配置设为运行时变量,而不是编译时变量。这是因为在真正的 UI 服务器上,设置服务器端口和 API 端点的方式是统一的。

为此,让生成一个 JavaScript 文件,并将其注入到index.html中。这个 JavaScript 文件将包含一个带有环境内容的全局变量。让我们称这个新的脚本文件为env.js,并将其包含在index.html中。这是本节中对index.html的唯一更改,如清单 7-10 所示。

...
  <div id="contents"></div>

  <script src="/env.js"></script>
  <script src="/App.js"></script>
...

Listing 7-10ui/public/index.html: Include the Script /env.js

现在,在 UI 服务器中,让我们生成这个脚本的内容。这应该会导致设置一个名为ENV的全局变量,其中一个或多个属性被设置为环境变量,如下所示:

...
window.ENV = {
  UI_API_ENDPOINT: "http://localhost:3000"
}
...

当 JavaScript 被执行时,它将初始化对象的全局变量ENV。当任何其他地方需要该变量时,可以从全局变量中引用它。现在,在 UI 服务器代码中,让我们首先为 API 端点初始化一个变量,如果找不到,就使用默认值。然后,我们将构造一个对象,只将这一个变量作为属性。

...
const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT || 'http://localhost:3000';
const env = { UI_API_ENDPOINT };
...

现在,我们可以在服务器中创建一个路由来响应对env.js的 GET 调用。在该路由的处理程序中,让我们使用env对象根据需要构造字符串,并将其作为响应发送:

...
app.get('/env.js', function(req, res) {
  res.send(`window.ENV = ${JSON.stringify(env)}`)
})
...

清单 7-11 中显示了对ui/uiserver.js的完整更改。

require('dotenv').config();

const express = require('express');

const app = express();

app.use(express.static('public'));

const UI_API_ENDPOINT = process.env. UI_API_ENDPOINT || 'http://localhost:3000/graphql';

const env = { UI_API_ENDPOINT };

app.get('/env.js', function(req, res) {

  res.send(`window.ENV = ${JSON.stringify(env)}`)

})

const port = process.env.UI_SERVER_PORT || 8000;

app.listen(8000port, function () {
  console.log('UI started on port 8000');
  console.log(`UI started on port ${port}`);
});

Listing 7-11ui/uiserver.js: Changes for Environment Variable Usage

就像 API 服务器一样,让我们创建一个.env文件来保存两个变量,一个用于服务器的端口,另一个用于 UI 需要访问的 API 端点。您可以使用sample.env文件的副本,其内容如清单 7-12 所示。

UI_SERVER_PORT=8000
UI_API_ENDPOINT=http://localhost:3000/graphql

Listing 7-12ui/sample.env: Sample .env File for the UI Server

最后,在App.jsx中,API 端点是硬编码的,让我们用来自全局ENV变量的属性替换硬编码。这一变化如清单 7-13 所示。

...
async function graphQLFetch(query, variables = {}) {
  try {
    const response = await fetch('http://localhost:3000/graphql', {
    const response = await fetch(window.ENV.UI_API_ENDPOINT, {
    ...
  ...
}
...

Listing 7-13ui/src/App.jsx: Replace Hard-Coding of API Endpoint

让我们也让 nodemon 监视.env文件中的变化。由于我们在 UI 服务器中指定了要监视的单个文件,这要求我们使用-w命令行选项添加另一个要监视的文件。对ui/package.json的更改如清单 7-14 所示。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
...

Listing 7-14ui/package.json: nodemon to Watch for Changes in .env

现在,如果您使用默认端口和端点测试应用,应用应该像以前一样工作。如果您一直在控制台中运行npm run watch,对App.jsx的更改将会被自动重新编译。

您还可以通过实际的环境变量和对.env文件(如果有)的更改来确保对任何变量的更改生效。如果您通过一个环境变量来改变一个变量,那么如果您使用的是 bash shell,一定要记住导出它。此外,必须重新启动服务器,因为 nodemon 不会监视对任何环境变量的更改。

练习:多种环境

  1. 在浏览器中,手动键入http://localhost:8000/env.js。你看到了什么?将环境变量UI_API_ENDPOINT设置到不同的位置,并重启 UI 服务器。检查env.js的内容。

本章末尾有答案。

基于代理的体系结构

如果你在测试时在开发者控制台打开了网络标签,你会注意到有两个/graphql调用,而不是一个。第一次调用的 HTTP 方法是OPTIONS。原因是 API 调用是针对不同于应用来源(http://localhost:8000)的主机(http://localhost:3000)。由于同源策略,这样的请求通常会被浏览器阻止,除非服务器特别允许。

同源策略的存在是为了防止恶意网站获得对应用的未授权访问。您可以在 https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy 阅读该政策的详细内容。但其要点是,由于由一个来源设置的 cookie 会自动附加到对该来源的任何请求,因此恶意网站可能会从浏览器调用该来源,并且浏览器会附加该 cookie。

假设您登录了一家银行的网站。在另一个浏览器选项卡中,您正在浏览一些运行恶意 JavaScript 的新闻网站,可能是通过网站上的广告。如果这个恶意的 JavaScript 对银行的网站进行 Ajax 调用,并将 cookies 作为请求的一部分发送出去,那么这个恶意的 JavaScript 最终会冒充您,甚至可能将资金转移到黑客的帐户上!

因此,浏览器通过要求这样的请求被明确允许来防止这种情况。可以允许的请求类型由同源策略以及由服务器控制的参数控制,服务器确定是否可以允许请求。这种机制被称为跨源资源共享或简称 CORS。默认情况下,Apollo GraphQL 服务器允许跨源的未经验证的请求。对OPTIONS请求的响应中的以下标题表明了这一点:

Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Origin: *

让我们禁用 Apollo 服务器的默认行为(当然,使用一个环境变量)并检查 API 服务器的新行为。让我们调用这个环境变量ENABLE_CORS并将api/.env文件设置为false(默认为true,当前行为)。

...
## Enable CORS (default: true)
ENABLE_CORS=false
...

现在,在 API 的server.js中,让我们寻找这个环境变量,并根据这个变量将一个名为cors的选项设置为truefalse。对api/server.js的更改如清单 7-15 所示。

...
const app = express();

const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';

console.log('CORS setting:', enableCors);

server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
...

Listing 7-15api/server.js: Option for Enabling CORS

如果您测试应用,您会发现OPTION请求失败,HTTP 响应为 405。现在,应用不会受到恶意的跨站点攻击。但是这也意味着我们需要一些其他的机制来进行 API 调用。

我将更详细地讨论 CORS,以及为什么在应用的当前阶段启用 CORS 是安全的,因为所有的资源都是公开的,无需认证。但为了安全起见,我们也来看看替代方案。在这一节中,我们将改变 UI,甚至向 UI 服务器发出 API 请求,我们将安装一个代理,这样任何对/graphql的请求都会被路由到 API 服务器。这种新架构如图 7-4 所示。

img/426054_2_En_7_Chapter/426054_2_En_7_Fig4_HTML.jpg

图 7-4

基于代理的体系结构

使用http-proxy-middleware包可以很容易地实现这样的代理。让我们安装这个包:

$ cd ui
$ npm install http-proxy-middleware@0

现在,代理可以用作软件包提供的中间件,安装在路径/graphql上,使用app.use()。创建中间件只需要一个选项:代理的目标,这是请求必须被代理的主机的基本 URL。让我们定义另一个名为API_PROXY_TARGET的环境变量,并使用它的值作为目标。如果这个变量是未定义的,我们可以跳过安装代理,而不是默认它。

清单 7-16 中显示了ui/uiserver.js的变更。

...
require('dotenv').config();
const express = require('express');

const proxy = require('http-proxy-middleware');

...

const apiProxyTarget = process.env.API_PROXY_TARGET;

if (apiProxyTarget) {

  app.use('/graphql', proxy({ target: apiProxyTarget }));

}

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||
...

Listing 7-16ui/uiserver.js: Changes to Install Proxy

现在让我们更改在ui/.env中指定 API 端点的环境变量,将其设置为/graphql,这意味着/graphql在与原点相同的主机上。进一步,让我们定义代理的目标,变量API_PROXY_TARGEThttp://localhost:3000

...
UI_API_ENDPOINT=http://localhost:3000/graphql

API_PROXY_TARGET=http://localhost:3000

...

现在,如果您测试应用并查看浏览器的开发人员控制台中的 Network 选项卡,您会发现对于每个 API 调用,只有一个请求发送到 UI 服务器(端口 8000 ),并成功执行。

您可以使用本节中描述的代理方法,或者让 UI 直接调用 API 服务器并在 API 服务器中启用 CORS。这两个选项都很好,您的实际选择取决于各种因素,例如您的部署环境和应用的安全需求。

出于阅读本书的目的,让我们将本节中对.env文件所做的更改还原,以便使用直接 API 调用机制。您可以将 API 和 UI 目录中的sample.env文件从 GitHub 库复制到您自己的.env文件中,这反映了 API 的直接工作方式。

斯洛文尼亚语

一个棉绒(?? 棉绒的东西)检查可能是错误的可疑代码。它还可以检查您的代码是否符合您希望整个团队遵循的约定和标准,以使代码具有可预测的可读性。

虽然对于什么是好的标准有多种观点和争论(例如,制表符和空格),但是对于是否首先需要一个标准却没有争论。对于一个团队或者一个项目,采用一个标准远比采用正确的标准重要。

ESLint ( https://eslint.org )是一个非常灵活的 linter,可以让你定义你想要遵循的规则。但我们需要一些东西作为起点,最吸引我的规则是 Airbnb 的规则。其吸引力的部分原因是它的受欢迎程度:如果更多的人采用它,它就变得越标准化,所以更多的人最终会跟随它,成为一个良性循环。

Airbnb ESLint 配置有两个部分:基本配置适用于普通 JavaScript,常规配置也包括 JSX 和 React 的规则。在本节中,我们将只对后端代码使用 ESLint,这意味着我们只需要安装基本配置,以及基本配置所需的 ESLint 和其他依赖项:

$ cd api
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-config-airbnb-base@13

ESLint 在.eslintrc文件中寻找一组规则,这是一个 JSON 规范。这些不是规则的定义,而是需要启用或禁用哪些规则的规范。规则集也可以被继承,这就是我们在配置中使用extends属性所要做的。使用一个.eslintrc文件使规则应用于该目录中的所有文件。对于单个文件中的覆盖,可以在该文件的注释中指定规则,甚至可以只在一行中指定。

配置文件中的规则在属性rules下指定,该属性是一个包含一个或多个规则的对象,由规则名标识,值是错误级别。错误等级为offwarningerror。例如,要指定规则quotes(检查字符串的单引号和双引号)应该显示警告,这就是规则需要被指定的方式:

...
  rules: {
    "quotes": "warning”
  }
...

许多规则都有选项,例如,规则quotes有一个选项,用于选择要执行的报价类型是单个还是两个。指定这些选项时,值需要是一个数组,第一个元素作为错误级别,第二个(或更多,取决于规则)是选项。下面是 quotes 规则如何选择一个选项来检查双引号:

...
    "quotes": ["warning", "double"]
...

先说一个基础配置,只继承了 Airbnb 的基础配置,没有任何规则。让我们使用env属性来具体说明代码将在哪里运行。因为所有的后端代码都只能在 Node.js 上运行(并且只能在 Node.js 上运行),所以这个属性对于值为truenode只有一个条目。下面是.eslintrc文件在这个阶段的样子:

{
  "extends": "airbnb-base",
  "env": {
    "node": "true"
  }
}

现在,让我们在整个api目录上运行 ESLint。执行此操作的命令行如下:

$ cd api
$ npx eslint .

或者,您可以在编辑器中安装一个插件,在编辑器中显示 lint 错误。流行的代码编辑器 Atom 和 Sublime 都有插件来处理这个问题;按照各自网站上的说明安装插件。然后,我们将查看每种类型的错误或警告,并处理它。

对于大多数错误,我们只是要更改代码以符合建议的标准。但在少数情况下,我们会对 Airbnb 规则进行例外处理。这可能是针对整个项目,或者在某些情况下,针对特定文件或文件中的某一行。

让我们看看每种类型的错误并修复它们。请注意,我只是在讨论 ESLint 在我们到目前为止编写的代码中可能会发现的错误。当我们写更多的代码时,我们会修复所有的 lint 错误,所以强烈推荐一个编辑器插件来报告我们输入时的错误。

文体问题

JavaScript 在语法上非常灵活,所以有很多方法可以编写相同的代码。linter 规则会报告一些错误,以便您在整个项目中使用一致的样式。

  • 缩进:始终期望一致的缩进;这不需要辩解。让我们解决所有的违规问题。

  • 关键字间距:关键字之间的空格(ifcatch等)。)并建议使用左括号。让我们更改代码,无论哪里报告了这一点。

  • 缺少分号:关于到处都有分号还是哪儿都没有分号更好,有很多争论。两者都可以工作,除了少数情况下缺少分号会导致行为改变。如果您遵循无分号标准,您必须记住那些特殊情况。还是用 Airbnb 默认的吧,就是要求处处分号。

  • 字符串必须使用单引号 : JavaScript 允许单引号和双引号。为了标准化,最好始终使用一种风格。让我们使用 Airbnb 默认的单引号。

  • 新行上的对象属性:一个对象的所有属性必须在一行中,或者每个属性在新行中。这只是使它更可预测,尤其是当一个新的属性必须被插入的时候。对于是将新属性附加到现有行中的一行还是新行中,没有疑问。

  • objects中 before }之后的空格:这只是为了可读性;让我们在 linter 报告错误的地方更改它。

  • Arrow 函数风格:linter 建议要么在单个参数和函数体之间使用括号,要么在参数和返回表达式之间不使用括号(即不是函数体)。让我们进行建议的修改。

最佳实践

这些规则与更好的做事方式有关,通常有助于避免错误。

  • 函数必须命名为:省略函数名会使调试更加困难,因为堆栈跟踪无法识别函数。但这仅适用于常规函数,不适用于箭头样式的函数,因为箭头样式的函数应该是回调的小段。

  • 一致返回:函数应该总是返回值或者从不返回值,不管条件如何。这提醒开发人员添加返回值或明确返回值,以防他们忘记返回条件之外的值。

  • 变量必须在使用之前定义:虽然 JavaScript 提升了定义,使得它们在整个文件中都可用,但是在使用之前定义它们是个好习惯。否则,当从上到下阅读代码时,它会变得混乱。

  • 控制台:特别是在浏览器中,这些通常是遗留下来的调试信息,因此不适合在客户端显示。但是这些在 Node.js 应用中是没问题的。因此,让我们在 API 代码中关闭这条规则。

  • 返回作业:虽然很简洁,但是返回和作业放在一起可能会让读者感到困惑。还是回避一下吧。

可能的错误

考虑您可能遇到的这些错误:

  • 重新声明变量:当一个变量在更高的范围内遮蔽(覆盖)另一个变量时,很难阅读和理解原编码者的意图。也不可能在更高的范围内访问变量,所以最好给变量取不同的名字。

  • 未声明的变量:最好避免内部作用域中的变量与外部作用域中的同名。这是令人困惑的,它隐藏了对外部作用域变量的访问,以防需要访问它。但是在 mongo 脚本中,我们确实有真正全局的变量:dbprint。让我们在注释中将它们声明为全局变量,这样 ESLint 就知道这些不是错误:

    ...
    /* global db print */
    ...
    
    
  • 更喜欢箭头回调:当使用匿名函数时(比如当传递一个回调给另一个函数时),最好使用箭头函数风格。这具有将变量this设置为当前上下文的额外效果,这在大多数情况下是可取的,并且语法也更简洁。如果函数很大,最好把它分成一个命名的常规函数。

  • 三重等于:三重等于的使用确保了在比较之前值不会被强制。在大多数情况下,这就是我们想要的,它避免了由于强制值而导致的错误。

  • 函数参数的赋值:改变传入的参数可能会导致调用者没有注意到变化,从而导致意外的行为。让我们避免更改函数参数的值,而是制作参数的副本。

  • 受限全局函数 : iNaN被认为是受限全局函数,因为它将非数字强制转换为数字。推荐使用函数Number.isNaN(),但是它只对数字有效,所以在用Number.isNaN()检查之前,让我们对日期对象做一个getTime()。另外,print()是一个受限的全局变量,但是它在 mongo 脚本中的使用是有效的,所以让我们只对 mongo 脚本关闭这个规则,如下所示:

    ...
    /* eslint no-restricted-globals: "off" */
    ...
    
    
  • Wrap 立即调用函数表达式(life):立即调用的函数表达式是一个单独的单元。用括号把它括起来,不仅使它更清楚,而且使它成为一个表达式而不是一个声明。

API 目录下最后一个.eslintrc文件的内容如清单 7-17 所示。

{
  "extends": "airbnb-base",
  "env": {
    "node": "true"
  },
  rules: {
    "no-console": "off"
  }
}

Listing 7-17api/.eslintrc: Settings for ESLint in the API Directory

对 API 目录下 JavaScript 文件的修改如清单 7-18 到 7-20 所示。

/*
 ...
*/

/* global db print */

/* eslint no-restricted-globals: "off" */

db.issues.remove({});

const issuesDB = 
  {
    id: 1, status: 'New', owner: 'Ravan', effort: 5,
    created: new Date('2019-01-15'), due: undefined,
    id: 1,
    status: 'New',
    owner: 'Ravan',
    effort: 5,
    created: new Date('2019-01-15'),
    due: undefined,
    title: 'Error in console when clicking Add',
  },
  {
    id: 2, status: 'Assigned', owner: 'Eddie', effort: 14,
    created: new Date('2019-01-16'), due: new Date('2019-02-01'),
    id: 2,
    status: 'Assigned',
    owner: 'Eddie',
    effort: 14,
    created: new Date('2019-01-16'),
    due: new Date('2019-02-01'),
    title: 'Missing bottom border on panel',
  },
...

Listing 7-18api/scripts/init.mongo.js: Fixes for ESLint Errors

function testWithCallbacks(callback) {
  console.log('\n--- testWithCallbacks ---');
  const client = new MongoClient(url, { useNewUrlParser: true });
  client.connect(function(err, client) {
  client.connect((connErr) => {
    if (err connErr) {
      callback(errconnErr);
      return;
    }
    console.log('Connected to MongoDB URL', url);
...
    const employee = { id: 1, name: 'A. Callback', age: 23 };
    collection.insertOne(employee, function(err, result) {
    collection.insertOne(employee, (insertErr, result) => {
      if (err insertErr) {
        client.close();
        callback(err insertErr);
         return;
      }
      console.log('Result of insert:\n', result.insertedId);
      collection.find({ _id: result.insertedId})
      collection.find({ _id: result.insertedId })
        .toArray(function(err, docs) {
        .toArray((findErr, docs) => {
        if (err) {
          client.close();
          callback(err);
          return;
        }
          if (findErr) {
            client.close();
            callback(findErr);
            return;
          }
        console.log('Result of find:\n', docs);
        client.close();
        callback(err);
     });
          console.log('Result of find:\n', docs);
          client.close();
          callback();
        });
...

async function testWithAsync() {

  ...
  } catch(err) {
  } catch (err) {
  ...
}

testWithCallbacks(function(err) {

testWithCallbacks((err) => {

  ...
}

Listing 7-19api/scripts/trymongo.js: Fixes for ESLint Errors

let db;

let aboutMessage = "Issue Tracker API v1.0";

let aboutMessage = 'Issue Tracker API v1.0';

...

const GraphQLDate = new GraphQLScalarType({
  ...
  parseValue(value) {
     ...
    return isNaN(dateValue) ? undefined : dateValue;
    return Number.isNaN(dateValue.getTime()) ? undefined : dateValue;
  },
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      const value = new Date(ast.value);
      return isNaN(value) ? undefined : value;
      return Number.isNaN(value.getTime()) ? undefined : value;
    }
    return undefined;
  },
});
...

const resolvers = {

  ...

};

...

function setAboutMessage(_, { message }) {
  return aboutMessage = message;
  aboutMessage = message;
  return aboutMessage;
}
...

async function issueAdd(_, { issue }) {

  ...
    errors.push('Field "title" must be at least 3 characters long.')
    errors.push('Field "title" must be at least 3 characters long.');
  ...
  if (issue.status == 'Assigned' && !issue.owner) {
  if (issue.status === 'Assigned' && !issue.owner) {
  ...
  const newIssue = Object.assign({}, issue);
  issue newIssue.created = new Date();
  issue newIssue.id = await getNextSequence('issues');

  const result = await db.collection('issues').insertOne(issue newIssue);
  ...
}
...

const resolvers = {

  ...

};

...

const server = new ApolloServer({
  ...
  formatError: error => {
  formatError: (error) => {
  ...
});
...

const enableCors = (process.env.ENABLE_CORS || 'true') == 'true';

const enableCors = (process.env.ENABLE_CORS || 'true') === 'true';

...

(async function start() {
  ...
    app.listen(port, function () => {

      ...
  ...

})();

}());

Listing 7-20api/server.js: Fixes for ESLint Errors

最后,让我们添加一个 npm 脚本,它将 lint all 目录中的所有文件。命令行类似于我们之前使用的 lint 整个目录。对此的更改显示在package.json中的清单 [7-21 中。

...
  "scripts": {
    "start": "nodemon -e js,graphql -w . -w .env server.js",
    "lint": "eslint .",
    "test": "echo \"Error: no test specified\" && exit 1"

  },
...

Listing 7-21api/package.json: New Script for lint

前端的 ESLint

在本节中,我们将把 ESLint 检查添加到 UI 目录中。这一次,我们不仅要安装airbnb-base包,还要安装完整的 Airbnb 配置,包括 React 插件。

$ cd ui
$ npm install --save-dev eslint@5 eslint-plugin-import@2
$ npm install --save-dev eslint-plugin-jsx-a11y@6 eslint-plugin-react@7
$ npm install --save-dev eslint-config-airbnb@17

接下来,让我们通过扩展airbnb-base从服务器代码的.eslintrc开始。由于这是 Node.js 代码,我们也将环境设置为node,将规则no-console设置为off,就像在 API 配置中一样。清单 7-22 显示了.eslintrc的全部内容。

{
  "extends": "airbnb-base",
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}

Listing 7-22ui/.eslintrc: New ESLint Configuration for UI Server Code

要运行 linter,我们可以使用当前目录(.)作为命令行参数来执行命令。但是,在当前目录上执行将导致 ESLint 也在子目录中运行,这包括在public目录下编译的文件。编译后的文件会有很多 lint 错误,因为它不是源代码。因此,让我们通过使用 ESLint 的--ignore-pattern命令行选项来排除public目录,从而将其从 ESLint 范围中排除。

$ npx eslint . --ignore-pattern public

另一种忽略文件模式的方法是将它们作为行添加到名为.eslintignore的文本文件中。当有许多模式要被忽略时,这是很有用的。因为我们只需要忽略一个目录,所以我们将使用命令行选项。

在开发的这个阶段,uiserver.js文件将抛出与 API server.js类似的错误。文件。这些是样式问题,包括缺少分号、对箭头功能的偏好以及长行的换行符样式。修正这些错误后对server.js的更改如清单 7-23 所示。

...

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT ||

  'http://localhost:3000/graphql';

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT

  || 'http://localhost:3000/graphql';
...

app.get('/env.js', function(req, res) {

app.get('/env.js', (req, res) => {

...

app.listen(port, function () {

app.listen(port, () => {

...

Listing 7-23ui/uiserver.js: Fixes for ESLint Errors

现在,让我们从简单配置src目录下的 React 代码开始。.eslintrc文件将不得不扩展airbnb而不是airbnb-base。在环境中,我们可以指定对 browser 的支持,而不是 Node.js。起始的.eslintrc文件将如下所示:

...
{
  "extends": "airbnb",
  "env": {
    "browser": true
  }
}
...

现在,我们可以运行 ESLint 来检查 React 代码。在 ESLint 的早期调用中,没有检查App.jsx中的 React 代码,因为默认情况下 ESLint 不匹配扩展名为jsx的文件。为了包含这个扩展,ESLint 需要在命令行选项中包含完整的扩展列表--ext.

$ npx eslint . --ext js,jsx --ignore-pattern public

该命令引发的错误包括一些我们在前面部分已经讨论过的问题。这些是:

  • 新行上的对象属性

  • 缺少分号

  • 字符串必须使用单引号

  • 一致回报

  • 对象定义中花括号周围的间距

  • 对象中{之前和之后}的空格

  • 三倍相等

让我们讨论一下 ESLint 显示的其他问题。

文体问题

  • 隐式箭头换行符:这是一个风格问题,为了保持换行符的一致性。建议将从箭头函数返回的表达式与箭头放在同一行。如果表达式很长,无法放在一行中,可以从同一行开始用括号括起来。让我们来做这个改变。

  • 中缀操作符必须有空格:为了可读性,操作符周围需要空格。让我们按照建议做些改变。

最佳实践

  • “React”必须在范围内:当 ESLint 检测到 JSX 时,它期望 React 被定义。在这个阶段,我们包括来自 CDN 的 React。很快,我们将通过使用 npm 安装这些模块来使用它们。在那之前,让我们禁用这些检查。我们将内联执行这些操作,保持.eslintrc内容没有这种临时的变通办法。让我们在App.jsx文件中添加以下注释:

  • 无状态函数:组件IssueFilter目前只是一个占位符。当我们向它添加功能时,它将成为一个有状态的组件。在此之前,让我们禁用 ESLint 检查,但只针对这个组件。

...
/* eslint "react/react-in-jsx-scope": "off" */
/* globals React ReactDOM */
/* eslint "react/jsx-no-undef": "off" */
...

  • 更喜欢析构,尤其是属性赋值(props assignment):这种从对象中赋值变量的新方式不仅更简洁、可读性更好,还可以为那些被创建的属性保存临时引用。让我们按照建议更改代码。

  • 每个文件一个组件:每个文件只声明一个组件提高了组件的可读性和可重用性。目前,我们还没有讨论如何为 React 代码创建多个文件。我们将在下一章做那件事;在此之前,让我们禁用对文件的检查。

...
// eslint-disable-next-line react/prefer-stateless-function
class IssueFilter extends React.Component {
...

  • 无警告:这条规则的初衷是清除未运行的调试消息。我们将在文档中把警告消息转换成风格优美的消息。在那之前,让我们禁用这种检查,但是只在我们显示错误消息的文件中。

  • 缺少尾随逗号:在多行数组或对象中的最后一项需要逗号,这在插入新项时非常方便。此外,当在比如说 GitHub 中查看两个版本之间的差异时,在最后一行添加逗号的事实表明这一行发生了变化,而实际上并没有。

...
/* eslint "react/no-multi-comp": "off" */
...

可能的错误

  • Props validation :检查传递给组件的属性的类型是一个很好的实践,既可以让组件的用户清楚地知道,又可以避免输入中的错误。虽然我会简单地讨论这个问题,但我不会在 React 代码中添加 props 验证,纯粹是为了避免代码清单中的干扰。让我们为问题跟踪器应用全局关闭此规则,但我鼓励您在自己的应用中保持启用此规则。

  • 按钮类型:虽然一个按钮的默认类型是submit,但是最好确保明确声明,以防这不是预期的行为,开发者遗漏了添加一个类型。让我们按照建议,将submit添加到按钮的类型中。

  • 函数参数重新赋值:给一个函数参数赋值会使原参数不可访问,导致混淆行为。让我们使用一个新的变量,而不是重用函数参数。

...
  "rules": {
    "react/prop-types": "off",
  }
...

在对.eslintrc文件进行这些更改后,该文件的最终内容如清单 7-24 所示。

{
  "extends": "airbnb",
  "env": {
    "browser": true
  },
  rules: {
    "react/prop-types": "off"
  }
}

Listing 7-24ui/src/.eslintrc: New ESLint Configuration for UI Code

清单 7-25 整合了对uiserver.js的所有修改,以解决 ESLint 错误。

...

/* eslint "react/react-in-jsx-scope": "off" */

/* globals React ReactDOM */

/* eslint "react/jsx-no-undef": "off" */

/* eslint "no-alert": "off" */

...

// eslint-disable-next-line react/prefer-stateless-function

class IssueFilter extends React.Component {
...

function IssueRow(props{ issue }) {
  const issue = props.issue;
...

function IssueTable(props{ issue }) {
  const issueRows = props.issues.map(issue =>
  const issueRows = issues.map(issue => (
     <IssueRow key={issue.id} issue={issue} />
  ));
...

    const issue = {
      owner: form.owner.value, title: form.title.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000*60*60*24*10),
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    }
    this.props.createIssue(issue);
    const { createIssue } = this.props;
    createIssue(issue);
    form.owner.value = ""; form.title.value = "";
    form.owner.value = ''; form.title.value = '';
...

        <button type="submit">Add</button>

...

async function graphQLFetch(query, variables = {}) {
...

      headers: { 'Content-Type': 'application/json'},
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables })
      body: JSON.stringify({ query, variables }),
...

      if (error.extensions.code == 'BAD_USER_INPUT') {
      if (error.extensions.code === 'BAD_USER_INPUT') {
...

  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
    return null;
  }
}

class IssueList extends React.Component {
  ...
  render() {
    const { issues } = this.state;
    return (
      ...
        <IssueTable issues={this.state.issues} />
      ...
    )
...

Listing 7-25ui/src/App.jsx: Fixes for ESLint Errors

最后,为了方便起见,我们给 UI 目录中的package.json添加一个脚本,对所有相关文件执行 lint。命令行与我们之前用来检查整个目录的命令行相同。这显示在清单 7-26 中。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "lint": "eslint . --ext js,jsx --ignore-pattern public",
    ...
  },
...

Listing 7-26ui/package.json: Command for Running ESLint on the UI Directory

现在,命令npm run lint将检查当前设置,以及将被添加到 UI 目录下的任何其他文件。在这些代码更改之后,该命令应该不会返回任何错误或警告。

React 型态

在像 Java 这样的强类型语言中,参数的类型总是预先确定的,并作为函数声明的一部分来指定。这确保了调用者知道列表和参数类型,并确保传入的参数根据规范进行验证。

类似地,从一个组件传递到另一个组件的属性也可以根据规范进行验证。该规范以类中名为propTypes的静态对象的形式提供,属性的名称作为键,验证器作为值。验证器是由PropTypes导出的众多常量之一,例如PropTypes.string。当属性为必填项时,可以在数据类型后添加.isRequired。对象PropTypes作为一个名为 prop-types 的模块可用,它可以包含在 CDN 的index.html中,就像我们对 React 本身所做的那样。这一变化如清单 7-27 所示。

...
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/prop-types@15/prop-types.js"></script>
...

Listing 7-27ui/public/index.html: Changes to Include PropTypes Library

IssueTableIssueRow组件分别需要一个对象和一个对象数组作为属性。虽然PropTypes支持数组和对象等数据类型,但 ESLint 认为这些太模糊了。相反,必须描述对象的实际形状,这意味着必须指定对象的每个字段及其数据类型,以避免 ESLint 警告。

让我们添加一个更简单的检查来确保IssueAdd被传递给一个createIssue函数。我们需要定义一个IssueAdd.propTypes对象,用createIssue作为键,用PropTypes.func.isRequired作为它的类型。此外,由于PropTypes是一个全局对象(由于包含在 CDN 的脚本中),它必须声明为全局对象以避免 ESLint 错误。清单 7-28 中显示了对App.jsx的这些更改。

...
/* globals React ReactDOM PropTypes */
...
class IssueAdd extends React.Component {
  ...
}

IssueAdd.propTypes = {

  createIssue: PropTypes.func.isRequired,

};

Listing 7-28ui/src/App.jsx: Adding PropType Validation for IssueAdd Component

在运行时,仅在开发模式下检查属性验证,当任何验证失败时,控制台中会显示一条警告。如果您在构造IssueAdd组件时移除了createIssue属性的传递,您将在开发人员控制台中发现以下错误:

Warning: Failed prop type: The prop `createIssue` is marked as required in `IssueAdd`, but its value is `undefined`.
    in IssueAdd (created by IssueList)
    in IssueList

尽管为所有组件添加基于PropTypes的验证是一个好主意,但出于本书的目的,我将跳过这一步。唯一的原因是它使代码更加冗长,可能会分散读者对主要变化的注意力。

摘要

虽然在本章中我们没有给应用添加任何特性,但是我们通过分离 UI 和 API 服务器做了一个大的架构改变。我们讨论了 CORS 的含义,并编写了一个使用代理来处理它的选项。

然后,您看到了如何使应用可配置用于不同的部署环境,如试运行和生产环境。我们还通过添加对遵循编码标准、最佳实践和验证的检查来净化代码。

在下一章中,我们将继续通过模块化代码(即,将单个大文件分割成更小的、可重用的部分)和添加对调试的支持以及开发过程中有用的其他工具来提高开发人员的生产力。

练习答案

练习:UI 服务器

  1. 您应该会在浏览器中看到类似于Cannot GET /的消息。这是 Express 服务器返回的消息,因为不存在/的路由。这本身不是问题,因为 API 的唯一消费者是 web UI,并且在我们的控制之下。另一方面,如果 API 被公开给其他 API,比如 GitHub 的 API,那么返回一条有用的消息来指明真正的 API 端点在哪里,确实会更好。

    另一个选择是将 API 托管在根(/)上,而不是在/graphql上。但是将/graphql作为端点名称可以清楚地表明它是一个 GraphQL API。

练习:多种环境

  1. env.js的内容将显示一个带有UI_API_ENDPOINT属性的对象的 JavaScript 赋值window.ENV。在对环境进行更改后重新启动 UI 服务器将导致内容反映新值。

八、模块化和网络包

在前一章中,我们开始通过改变架构和增加对编码标准和最佳实践的检查来进行组织。在这一章中,我们将进一步把代码分成多个文件,并添加工具来简化开发过程。我们将使用 Webpack 来帮助我们将前端代码分割成基于组件的文件,将代码增量地注入浏览器,并在前端代码发生变化时自动刷新浏览器。

你们中的一些人可能会发现这一章不值得花时间,因为它没有在应用的真正特性上取得任何进展,并且/或者因为它没有讨论组成堆栈的任何技术。如果您不太关心所有这些,而是依赖于其他人给你一个预定义目录结构的模板,以及 Webpack 等构建工具的配置,那么这是一个完全有效的想法。这可以让您只关注 MERN 堆栈,而不必处理所有的工具。在这种情况下,您有以下选择:

  • 从本书的 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )下载本章末尾的代码,并以此作为你项目的起点。

  • 使用初学者工具包create-react-app ( https://github.com/facebook/create-react-app )启动您的新 React 应用,并为您的应用添加代码。但是请注意,create-react-app只处理 MERN 堆栈的 React 部分;您必须自己处理 API 和 MongoDB。

  • 使用mern.io ( http://mern.io )创建整个应用的目录结构,其中包括整个 MERN 堆栈。

但是,如果您是一名架构师,或者只是为您的团队设置项目,那么理解工具如何帮助开发人员提高工作效率以及您如何能够更好地控制整个构建和部署过程是非常重要的。在这种情况下,我鼓励你而不是跳过这一章,即使你使用了这些搭建工具中的一个,这样你就可以了解在引擎盖下到底发生了什么。

后端模块

api/server.js如何在 Node.js 文件中包含模块中,您已经看到了所有这些。安装完模块后,我们使用内置函数require()来包含它。JavaScript 中有各种各样的模块化标准,其中 Node.js 实现了 CommonJS 标准的一个微小变化。在这个系统中,本质上有两个关键元素与模块系统交互:requireexports

元素是一个可以用来从另一个模块导入符号的函数。传递给require()的参数是模块的 ID。在 Node 的实现中,ID 是模块的名称。对于使用 npm 安装的软件包,这与软件包的名称相同,并且与安装软件包文件的node_modules目录中的子目录相同。对于同一应用中的模块,ID 是需要导入的文件的路径。

比如从与api/server.js同目录的一个名为other.js的文件中导入符号,需要传递给require()的 ID 就是这个文件的路径,也就是'./other.js',像这样:

const other = require('./other.js');

现在,由other.js导出的将在other变量中可用。这是由我们谈到的另一个因素控制的:exports。一个文件或模块导出的主符号必须设置在该文件内一个名为module.exports的全局变量中,这个变量将由对require()的函数调用返回。如果有多个符号,它们都可以被设置为一个对象中的属性,我们可以通过解引用对象或使用析构赋值来访问它们。

首先,让我们将函数GraphQLDate()从主server.js文件中分离出来,并为此创建一个名为graphql_date.js的新文件。除了整个函数本身,我们还需要新文件中的以下内容:

  1. require()从其他包中导入GraphQLScalarTypeKind的语句。

  2. 将变量module.exports设置到函数中,以便导入文件后可以使用。

该文件的内容如清单 8-1 所示,其中api/server.js中对原始文件的更改以粗体突出显示。

const { GraphQLScalarType } = require('graphql');

const { Kind } = require('graphql/language');

const GraphQLDate = new GraphQLScalarType({
 ...
});

module.exports = GraphQLDate;

Listing 8-1api/graphql_date.js: Function GraphQLDate() in a New File

现在,在文件api/server.js中,我们可以像这样导入符号GraphQLDate:

...
const GraphQLDate = require('graphql_date.js');
...

如您所见,分配给module.exports的是调用require()返回的值。现在,GraphQLDate这个变量可以像以前一样无缝地用在解析器中。但是我们还不会在server.js中做这个改变,因为我们会对这个文件做更多的改变。

我们可以分离出来的下一组函数是与 about 消息相关的函数。尽管我们为解析器about使用了一个匿名函数,现在让我们创建一个命名函数,以便它可以从不同的文件中导出。让我们创建一个新文件,它导出 API 目录中的两个函数getMessage()setMessage()``about.js。这个文件的内容非常简单,如清单 8-2 所示。但是我们不像在graphql_date.js中那样只导出一个函数,而是将setMessagegetMessage作为一个对象的两个属性导入。

let aboutMessage = 'Issue Tracker API v1.0';

function setMessage(_, { message }) {
  ...
}

function getMessage() {

  return aboutMessage;

}

module.exports = { getMessage, setMessage };

Listing 8-2api/about.js: Separated About Message Functionality to New File

现在,我们可以从这个文件中导入about对象,并在需要在解析器中使用它们时取消引用about.getMessageabout.setMessage,如下所示:

...
const about = require('about.js');
...
const resolvers = {
  Query: {
    about: about.getMessage,
    ...
  },
  Mutation: {
    setAboutMessage: about.setMessage,
    ...
  },
  ...
};

这个变化可能在server.js中,但是我们将把所有这些都分离到一个处理 Apollo 服务器、模式和解析器的文件中。让我们现在创建该文件,并将其命名为api/api_handler.js。让我们将resolvers对象的构造和 Apollo 服务器的创建移到这个文件中。至于实际的解析器实现,我们将从另外三个文件中导入它们— graphql_date.jsabout.jsissue.js

至于从这个文件的导出,让我们导出一个函数,它将做applyMiddleware()作为server.js的一部分所做的事情。我们可以调用这个函数installHandler(),只需在这个函数中调用applyMiddleware()

清单 8-3 中显示了这个新文件的全部内容,与server.js中的原始代码相比有所变化。

const fs = require('fs');
require('dotenv').config();
const { ApolloServer, UserInputError } = require('apollo-server-express');

const GraphQLDate = require('./graphql_date.js');

const about = require('./about.js');

const issue = require('./issue.js');

const resolvers = {
  Query: {
    about: about.getMessage,
    issueList: issue.list,
  },
  Mutation: {
    setAboutMessage: about.setMessage,
    issueAdd: issue.add,
  },
  GraphQLDate,
};

const server = new ApolloServer({
  ...
});

function installHandler(app) {

  const enableCors = (process.env.ENABLE_CORS || 'true') === 'true';
  console.log('CORS setting:', enableCors);
  server.applyMiddleware({ app, path: '/graphql', cors: enableCors });

}

module.exports = { installHandler };

Listing 8-3api/api_handler.js: New File to Separate the Apollo Server Construction

我们还没有创建issue.js,这是导入与问题相关的解决方案所需要的。但在此之前,让我们将数据库连接的创建和一个将连接处理程序放入一个新文件的函数分开。issue.js文件将需要这个数据库连接,等等。

让我们调用包含所有数据库相关代码db.js的文件,并将其放在 API 目录中。让我们将函数connectToDb()getNextSequence()以及存储连接结果的全局变量db移到这个文件中。让我们按原样导出这两个函数。至于全局连接变量,让我们通过一个叫做getDb()的 getter 函数来公开它。全局变量url现在也可以移入函数connectDb()本身。

该文件的内容如清单 8-4 所示,其中server.js中对原始文件的更改以粗体突出显示。

require('dotenv').config();
const { MongoClient } = require('mongodb');

let db;

async function connectToDb() {
  const url = process.env.DB_URL || 'mongodb://localhost/issuetracker';
  ...
}

async function getNextSequence(name) {
  ...
}

function getDb() {

  return db;

}

module.exports = { connectToDb, getNextSequence, getDb };

Listing 8-4api/db.js: Database Related Functions Separated Out

现在,我们准备分离与问题对象相关的功能。让我们在 API 目录下创建一个名为issue.js的文件,并移动与该文件相关的问题。此外,我们必须从db.js导入函数getDb()getNextSequence()。去使用它们。然后,我们不得不使用getDb()的返回值,而不是直接使用全局变量db。至于导出,我们可以导出函数issueListissueAdd,但是现在它们在模块内,它们的名字可以简化为仅仅listadd。这个新文件的内容如清单 8-5 所示。

const { UserInputError } = require('apollo-server-express');

const { getDb, getNextSequence } = require('./db.js');

async function issueListlist() {
  const db = getDb();
  const issues = await db.collection('issues').find({}).toArray();
  return issues;
}

function issueValidatevalidate(issue) {
  const errors = [];
  ...

}

async function issueAddadd(_, { issue }) {
  const db = getDb();
  validate(issue);
  ...
  return savedIssue;
}

module.exports = { list, add };

Listing 8-5api/issue.js: Separated Issue Functions

最后,我们可以修改文件api/server.js来使用所有这些。在所有的代码都转移到单独的文件之后,剩下的只是应用的实例化,应用 Apollo 服务器中间件,然后启动服务器。清单 8-6 中列出了整个文件的内容。删除的代码没有明确显示。新代码以粗体突出显示。

require('dotenv').config();
const express = require('express');

const { connectToDb } = require('./db.js');

const { installHandler } = require('./api_handler.js');

const app = express();

installHandler(app);

const port = process.env.API_SERVER_PORT || 3000;

(async function () {
  try {
    await connectToDb();
    app.listen(port, function () {
      console.log(`API server started on port ${port}`);
    });
  } catch (err) {
    console.log('ERROR:', err);
  }
}());

Listing 8-6api/server.js: Changes After Moving Out Code To Other Files

现在,应用已经准备好进行测试了。您可以通过 Playground 以及使用 Issue Tracker 应用 UI 来确保事情像 API 服务器代码模块化之前一样工作。

前端模块和 Webpack

在这一节中,我们将处理前端,或者 UI 代码,它们都在一个叫做App.jsx的大文件中。传统上,使用分割客户端 JavaScript 代码的方法是使用多个文件,并使用主 HTML 文件中的<script>标签或index.html将它们全部(或任何需要的)包含在内。这并不理想,因为依赖关系管理是由开发人员通过维护 HTML 文件中文件的特定顺序来完成的。此外,当文件数量变大时,这变得难以管理。

Webpack 和 Browserify 等工具提供了替代方案。使用这些工具,可以使用与 Node.js 中使用的require()等价的语句来定义依赖关系。然后,这些工具不仅会自动确定应用自身的依赖模块,还会自动确定第三方库的依赖关系。然后,他们将这些单独的文件放入一个或几个纯 JavaScript 包中,这些包中包含 HTML 文件所需的所有代码。

唯一的缺点是这需要一个构建步骤。但是,应用已经有一个构建步骤,将 JSX 和 ES2015 转换成普通的 JavaScript。让构建步骤也创建一个基于多个文件的包并没有太大的变化。Webpack 和 Browserify 都是很好的工具,可以用来实现目标。但是我选择了 Webpack,因为它可以更简单地完成我们想要做的事情,它包括第三方库和我们自己的模块的独立包。它有一个单一的管道来转换、捆绑和观察变化,并尽可能快地生成新的包。

如果您选择 Browserify,您将需要其他任务运行程序(如 gulp 或 grunt)来自动观察和添加多个转换。这是因为 Browserify 只做一件事:bundle。为了将 bundle 和 transform(使用 Babel)结合起来并观察文件的变化,您需要将它们放在一起,gulp 就是这样一个工具。相比之下,Webpack(在加载器的帮助下,我们将很快探索)不仅可以捆绑,还可以做更多的事情,例如转换和监视文件的更改。你不需要额外的任务运行者来使用 Webpack。

请注意,Webpack 还可以处理其他静态资产,如 CSS 文件。它甚至可以拆分包,以便它们可以异步加载。我们将不练习 Webpack 的这些方面;相反,我们将关注能够模块化客户端代码的目标,目前主要是 JavaScript。

为了习惯 Webpack 真正做什么,让我们从命令行使用 Webpack,就像我们使用 Babel 命令行对 JSX 变换所做的那样。让我们首先安装 Webpack,它作为一个包和一个命令行界面来运行它。

$ cd ui
$ npm install --save-dev webpack@4 webpack-cli@3

我们使用选项--save-dev,因为生产中的 UI 服务器不需要 Webpack。只有在构建过程中,我们才需要 Webpack,以及我们将在本章剩余部分安装的所有其他工具。为了确保我们可以使用命令行运行 Webpack,让我们检查安装的版本:

$ npx webpack --version

这应该会打印出类似 4.23.1 的版本。现在,让我们“打包”这个App.js文件并创建一个名为app.bundle.js的包。这可以简单地通过在App.js文件上运行 Webpack 并指定输出选项app.bundle.js来完成,两者都在public目录下。

$ npx webpack public/App.js --output public/app.bundle.js

这将产生如下所示的输出:

Hash: c5a639b898efcc81d3f8
Version: webpack 4.23.1
Time: 473ms
Built at: 10/25/2018 9:52:25 PM
        Asset      Size  Chunks             Chunk Names
app.bundle.js  6.65 KiB       0  [emitted]  main
Entrypoint main = app.bundle.js
[0] ./public/App.js 10.9 KiB {0} [built]

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/concepts/mode/

为了消除警告消息,让我们在命令行中提供开发模式:

$ npx webpack public/App.js --output public/app.bundle.js --mode development

这两种模式的区别在于 Webpack 自动做的各种事情,比如删除模块名、缩小等等。在构建用于生产部署的包时,拥有所有这些优化是好的,但是这些可能会妨碍调试和高效的开发过程。

生成的文件app.bundle.js没什么意思,与App.js本身也没什么不同。还要注意,我们没有对 React 文件App.jsx运行它,因为 Webpack 本身不能处理 JSX。在这种情况下,它所做的只是缩小App.js。我们这样做只是为了确保我们已经正确安装了 Webpack,并且能够运行它并创建输出。为了让 Webpack 找出依赖关系并把多个文件放在一起,让我们把单个文件App.jsx分成两个,取出函数graphQLFetch并把它放在一个单独的文件中。

我们可以像在后端代码中一样,使用require方式导入其他文件。但是您会注意到,互联网上的大多数前端代码示例都使用 ES2015 风格的模块,并使用了import关键字。这是一种更新、可读性更强的导入方式。甚至 Node.js 也支持import语句,但是从 Node.js 的版本 10 开始,它还处于试验阶段。如果没有,它也可以用于后端代码。使用import会强制使用 Webpack。因此,让我们仅将 ES2015 风格的import用于前端代码。

要导入另一个文件,需要使用关键字import,然后是要导入的元素或变量(这可能是分配给require()结果的变量),接着是关键字from,然后是文件或模块的标识符。例如,要从文件graphQLfetch.js中导入graphQLFetch,需要做的是:

...
import graphQLFetch from './graphQLFetch.js';
...

使用新的 ES2015 风格导出函数非常简单,只需在要导出的任何内容的定义前加上关键字export即可。此外,如果正在导出单个函数,可以在export之后添加关键字default,并且它可以是import语句的直接结果(或顶级导出)。所以,让我们用从App.jsx复制过来的相同函数的内容创建一个新文件ui/src/graphQLFetch.js。我们还需要实现jsonDateReviver和函数。这个文件的内容如清单 8-7 所示,其中export default被添加到了函数的定义中。

const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {
  if (dateRegex.test(value)) return new Date(value);
  return value;
}

export default async function graphQLFetch(query, variables = {}) {
  ...
}

Listing 8-7ui/src/graphQLFetch.js: New File with Exported Function graphQLFetch

现在,让我们从ui/src/App.jsx中删除相同的一组行,并用一个import语句替换它们。这一变化如清单 8-8 所示。

...
/* eslint "react/no-multi-comp": "off" */

/* eslint "no-alert": "off" */

import graphQLFetch from './graphQLFetch.js';

const dateRegex = new RegExp('^\\d\\d\\d\\d-\\d\\d-\\d\\d');

function jsonDateReviver(key, value) {

  if (dateRegex.test(value)) return new Date(value);
  return value;

}

class IssueFilter extends React.Component {
  ...
}

async function graphQLFetch(query, variables = {}) {

  ...

}

...

Listing 8-8ui/src/App.jsx: Replace graphQLFetch with an Import

此时,ESLint 将显示一个错误,大意是import语句中的扩展名(.js是意外的,因为该扩展名可以被自动检测到。但是事实证明,import语句只能检测.js文件扩展名,我们很快也会导入.jsx文件。此外,在后端代码中,我们使用了require()语句中的扩展。让我们对这个 ESLint 规则做个例外,总是在import语句中包含扩展,当然,通过 npm 安装的包除外。清单 8-9 显示了对ui/src目录中的.eslintrc文件的更改。

...
  "rules": {
    "import/extensions": [ "error", "always", { "ignorePackages": true } ],
    "react/prop-types": "off"
  }
...

Listing 8-9ui/src/.eslintrc: Exception for Including Extensions in Application Modules

如果你正在运行npm run watch,你会发现App.jsgraphQLFetch.js都是在public目录中经过巴别塔转换后创建的。如果没有,可以运行ui目录下的npm run compile。现在,让我们再次运行 Webpack 命令,看看会发生什么。

$ npx webpack public/App.js --output public/app.bundle.js --mode development

这应该会产生如下输出:

Hash: 4207ff5d100f44fbf80e
Version: webpack 4.23.1
Time: 112ms
Built at: 10/25/2018 10:21:06 PM
        Asset      Size  Chunks             Chunk Names
app.bundle.js  16.5 KiB    main  [emitted]  main
Entrypoint main = app.bundle.js
[./public/App.js] 9.07 KiB {main} [built]
[./public/graphQLFetch.js] 2.8 KiB {main} [built]

正如您在输出中看到的,打包过程包括了App.jsgraphQLFetch.js。Webpack 已经自动计算出由于import语句App.js依赖于graphQLFetch.js,并且已经将它包含在包中。现在,我们需要用app.bundle.js替换index.html中的App.js,因为新的包包含了所有需要的代码。这一变化如清单 8-10 所示。

...
  <script src="/env.js"></script>
  <script src="/App.js/app.bundle.js"></script>
</body>
...

Listing 8-10ui/public/index.html: Replace App.js with app.bundle.js

如果您现在测试应用,您应该会发现它和以前一样工作。为了更好地测量,您还可以在浏览器中的开发人员控制台的 Network 选项卡中检查从服务器获取的确实是app.bundle.js

所以,现在你知道了如何在前端代码中使用多个文件,为了方便和模块化,我们可以创建更多类似于graphQLFetch.js的文件。但是这个过程并不简单,因为我们必须首先手动转换文件,然后使用 Webpack 将它们放在一个包中。任何手动步骤都容易出错:人们很容易忘记转换,最终会捆绑转换后文件的旧版本。

转换和捆绑

好消息是,Webpack 能够将这两个步骤结合起来,消除了对中间文件的需要。但它自己无法做到这一点;它需要一些叫做装载机的帮手。除了纯 JavaScript 之外的所有转换和文件类型都需要 Webpack 中的加载器。这些是分开的包裹。为了能够运行巴别塔转换,我们需要巴别塔加载器。

让我们现在安装它。

$ cd ui
$ npm install --save-dev babel-loader@8

在 Webpack 的命令行中使用这个加载器有点麻烦。为了使配置和选项更容易,可以向 Webpack 提供配置文件。它寻找的默认文件叫做webpack.config.js。Webpack 使用 Node.js require()将该文件作为一个模块加载,因此我们可以将该文件视为一个常规的 JavaScript,其中包含一个module.exports变量,该变量导出指定转换和绑定过程的属性。让我们开始在ui目录下构建这个文件,其中有一个属性:mode。让我们将它默认为 development,就像我们之前在命令行中所做的那样。

...
module.exports = {
  mode: development,
}
...

entry属性指定了一个文件,该文件是可以确定所有依赖关系的起点。在问题跟踪器应用中,该文件位于src目录下的App.jsx。接下来再加上这个。

...
  entry: './src/App.jsx',
...

output属性需要是具有filenamepath两个属性的对象。该路径必须是绝对路径。推荐使用path模块和path.resolve函数来构建绝对路径。

...

const path = require('path');

module.exports = {
  ...
  output: {
    filename: 'app.bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
...

加载器是在属性module下指定的,它包含一系列作为数组的规则。每个规则至少有一个test,它是一个匹配文件的正则表达式,还有一个use,它指定在查找匹配时使用的加载器。我们将使用两者都匹配的正则表达式。jsx.js文件和 Babel 加载器,当文件匹配这个正则表达式时运行转换,如下所示:

...
      {
        test: /\.jsx?$/,
        use: 'babel-loader',
      },
...

完整的文件ui/webpack.config.js如清单 8-11 所示。

const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/App.jsx',
  output: {
    filename: 'app.bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: 'babel-loader',
      },
    ],
  },
};

Listing 8-11ui/webpack.config.js: Webpack Configuration

注意,我们不需要为 Babel loader 提供任何进一步的选项,因为 Webpack 所做的只是使用现有的 Babel transformer。这使用了来自src目录中.babelrc的现有配置。

此时,您可以快速运行不带任何参数的 Webpack 命令行,并看到文件app.bundle.js已创建,而没有创建任何中间文件。您可能需要删除public目录中的中间文件App.jsgraphQLFetch.js来确保这一点。执行此操作的命令行如下:

$ npx webpack

这可能需要一点时间。此外,就像 Babel 的--watch选项一样,Webpack 也附带了一个--watch选项,它增量地构建包,只转换已更改的文件。让我们试试这个:

$ npx webpack --watch

该命令不会退出。现在,如果您更改其中一个文件,比如说graphQLFetch.js,您将在控制台上看到以下输出:

Hash: 3fc38bc043fafe268e06
Version: webpack 4.23.1
Time: 53ms
Built at: 10/25/2018 11:09:49 PM
        Asset      Size  Chunks             Chunk Names
app.bundle.js  16.6 KiB    main  [emitted]  main
Entrypoint main = app.bundle.js
[./src/graphQLFetch.js] 2.71 KiB {main} [built]
    + 1 hidden module

注意输出中的最后一行:+1 hidden module。这实际上意味着当只有graphQLFetch.js被改变时App.jsx没有被改变。这是为compilewatch修改 npm 脚本的好时机,使用 Webpack 命令代替 Babel 命令。

Webpack 有两种模式,生产和开发,它们改变了在转换过程中添加的优化类型。让我们假设在开发过程中,我们将始终使用观察脚本,为了构建一个用于部署的包,我们将使用生产模式。命令行参数覆盖配置文件中指定的内容,因此我们可以在 npm 脚本中相应地设置模式。

清单 8-12 中显示了这样做所需的更改。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "compile": "babel src --out-dir public",
    "compile": "webpack --mode production",
    "watch": "babel src --out-dir public --watch --verbose"
    "watch": "webpack --watch"
  },
...

Listing 8-12ui/package.json: Changes to npm Scripts to Use Webpack Instead of Babel

现在,我们准备将App.jsx文件拆分成许多文件。建议将每个 React 组件放在自己的文件中,尤其是如果组件是有状态的。无状态组件可以在方便的时候与其他组件组合在一起。

所以,让我们把组件IssueListApp.jsx分开。然后,让我们将层次结构中的第一级组件——IssueFilterIssueTableIssueAdd——分离到它们自己的文件中。在每个项目中,我们将导出主要组件。App.jsx会导入IssueList.jsx,?? 又会导入其他三个组件。IssueList.jsx也需要导入graphQLFetch.js,因为它调用 Ajax。

让我们也将 ESLint 异常移动或复制到适当的新文件中。所有文件将有一个声明 React 为全局的异常;IssueFilter对于无状态组件也有例外。

清单 8-13 中描述了新文件IssueList.jsx

/* globals React */

/* eslint "react/jsx-no-undef": "off" */

import IssueFilter from './IssueFilter.jsx';

import IssueTable from './IssueTable.jsx';

import IssueAdd from './IssueAdd.jsx';

import graphQLFetch from './graphQLFetch.js';

export default class IssueList extends React.Component {
  ...
}

Listing 8-13ui/src/IssueList.jsx: New File for the IssueList Component

新的IssueTable.jsx文件如清单 8-14 所示。注意,这包含两个无状态组件,其中只有IssueTable被导出。

/* globals React */

function IssueRow({ issue }) {
  ...
}

export default function IssueTable({ issues }) {
  ...
}

Listing 8-14ui/src/IssueTable.jsx: New File for the IssueTable Component

新的IssueAdd.jsx文件如清单 8-15 所示。

/* globals React PropTypes */

export default class IssueAdd extends React.Component {
  ...
}

Listing 8-15ui/src/IssueAdd.jsx: New File for the IssueAdd Component

新的IssueFilter.jsx文件如清单 8-16 所示。

/* globals React */

/* eslint "react/prefer-stateless-function": "off" */

export default class IssueFilter extends React.Component {
  ...
}

Listing 8-16ui/src/IssueFilter.jsx: New File for the IssueFilter Component

最后,主类App.jsx将只有很少的代码,只有一个IssueList组件的实例化并将其安装在内容<div>中,以及必要的注释行来声明 React 和 ReactDOM 作为 ESLint 的全局变量。清单 8-17 中完整显示了该文件(为简洁起见,删除的行未显示)。

/* globals React ReactDOM  */

import IssueList from './IssueList.jsx';

const element = <IssueList />;

ReactDOM.render(element, document.getElementById('contents'));

Listing 8-17ui/src/App.jsx: Main File with Most Code Moved Out

如果你从ui目录运行npm run watch,你会发现所有的文件都被转换并捆绑到app.bundle.js中。如果您现在测试应用,它应该像以前一样工作。

练习:变换和捆绑

  1. 运行npm run watch时,保存任何仅改变间距的 JSX 文件。Webpack 会重建包吗?为什么不呢?

  2. 是否有必要将组件的安装(在App.jsx中)和组件本身(IssueList)分开到不同的文件中?提示:想想我们将来还需要哪些页面。

  3. 如果在导出一个类时没有使用关键字default,比如说IssueList,会发生什么?提示:在 https://developer.mozilla.org/en-US/docs/web/javascript/reference/statements/export#Using_the_default_export 的 JavaScript export语句上查找 Mozilla Developer Network (MDN)文档。

本章末尾有答案。

库捆绑包

到目前为止,为了简单起见,我们将第三方库作为 JavaScript 直接包含在 CDN 中。虽然这在大多数情况下都很有效,但我们必须依赖 CDN 服务来支持我们的应用。此外,需要包含许多库,这些库之间也有依赖关系。

在本节中,我们将使用 Webpack 创建一个包含这些库的包。如果你还记得,我讨论过 npm 不仅用于服务器端库,也用于客户端库。更重要的是,Webpack 理解这一点,可以处理通过 npm 安装的客户端库。

让我们首先使用 npm 来安装我们一直使用到现在的客户端库。这与index.html中的<script>列表相同。

$ cd ui
$ npm install react@16 react-dom@16
$ npm install prop-types@15
$ npm install whatwg-fetch@3
$ npm install babel-polyfill@6

接下来,为了使用这些已安装的库,让我们在所有需要它们的客户端文件中导入它们,就像我们在拆分App.jsx后导入应用的文件一样。所有带有 React 组件的文件都需要导入 React。App.jsx 还需要导入 ReactDOM。polyfills— babel-polyfillwhatwg-fetch—可以导入到任何地方,因为它们将被安装在全局名称空间中。让我们在App.jsx里做这个,切入点。清单 8-18 到 8-22 中显示了这一点以及其他组件的变化。

/* globals React ReactDOM  */

import 'babel-polyfill';

import 'whatwg-fetch';

import React from 'react';

import ReactDOM from 'react-dom';

import IssueList from './IssueList.jsx';
...

Listing 8-18App.jsx: Changes for Importing Third-Party Libraries

-/* globals React */

-/* eslint "react/jsx-no-undef": "off" */

import React from 'react';

import IssueFilter from './IssueFilter.jsx';
...

Listing 8-19IssueList.jsx: Changes for Importing Third-Party Libraries

/* globals React */

/* eslint "react/prefer-stateless-function": "off" */

import React from 'react';

export default class IssueFilter extends React.Component {

...

Listing 8-20IssueFilter.jsx: Changes for Importing Third-Party Libraries

-/* globals React */

import React from 'react';

function IssueRow(props) {
...

Listing 8-21IssueTable.jsx: Changes for Importing Third-Party Libraries

/* globals React PropTypes */

import React from 'react';

import PropTypes from 'prop-types';

export default class IssueAdd extends React.Component {
...

Listing 8-22IssueAdd.jsx: Changes for Importing Third-Party Libraries

如果您已经运行了npm run watch,您会注意到在它的输出中,隐藏模块的数量已经从几个增加到几百个,并且app.bundle.js的大小已经从几千字节增加到 1MB 以上。Webpack 捆绑的新输出现在看起来像这样:

Hash: 2c6bf561fa9aba4dd3b1
Version: webpack 4.23.1
Time: 2184ms
Built at: 10/26/2018 11:51:01 AM
        Asset      Size  Chunks             Chunk Names
app.bundle.js  1.16 MiB    main  [emitted]  main
Entrypoint main = app.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 492 bytes {main} [built]
    + 344 hidden modules

这个包包含了所有的库,这是一个小问题。库不会经常改变,但是应用代码会改变,尤其是在开发和测试期间。即使应用代码经历了很小的变化,整个包也会被重新构建,因此,客户机必须从服务器获取(现在很大的)包。我们没有利用这样一个事实,即当脚本没有被修改时,浏览器可以缓存脚本。这不仅影响开发过程,而且即使在生产中,用户也不会有最佳的体验。

一个更好的选择是有两个包,一个用于应用代码,另一个用于所有的库。事实证明,我们可以在 Webpack 中使用一种叫做splitChunks的优化来轻松做到这一点。为了使用这种优化并自动命名它创建的不同包,我们需要在文件名中指定一个变量。让我们使用一个命名的入口点和包的名称作为 UI 的 Webpack 配置中的文件名变量,如下所示:

...
  entry: './src/App.jsx',
  entry: { app: './src/App.jsx' },
  output: {
    filename: 'app.bundle.js',
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
...

接下来,让我们通过从转换中排除库来节省一些时间:它们已经在所提供的发行版中被转换了。为此,我们需要排除 Babel loader 中node_modules下的所有文件。

...
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: 'babel-loader',
...

最后,让我们启用优化splitChunks。这个插件做了我们想要的开箱即用,也就是说,它将node_modules下的所有东西都分离到一个不同的包中。我们需要做的就是说我们需要all作为属性chunks的值。此外,为了给包起一个方便的名字,让我们在配置中给它起一个名字,就像这样:

...
    splitChunks: {
      name: 'vendor',
      chunks: 'all',
    },
...

清单 8-23 中显示了对ui目录下webpack.config.js的一整套更改。

module.exports = {
  mode: 'development',
  entry: './src/App.jsx',
  entry: { app: './src/App.jsx' },
  output: {
    filename: 'app.bundle.js',
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
    ],
  },
  optimization: {
    splitChunks: {
      name: 'vendor',
      chunks: 'all',
    },
  },
};

Listing 8-23ui/webpack.config.js: Changes for Separate Vendor Bundle

现在,如果您重新启动npm run watch,它应该会输出两个包——app.bundle.jsvendor.bundle.js——如该命令的示例输出所示:

Hash: 0d92c8636ffc24747d70
Version: webpack 4.23.1
Time: 1664ms
Built at: 10/26/2018 2:32:34 PM
           Asset      Size  Chunks             Chunk Names
   app.bundle.js  29.7 KiB     app  [emitted]  app
vendor.bundle.js  1.24 MiB  vendor  [emitted]  vendor
Entrypoint app = vendor.bundle.js app.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {vendor} [built]
[./src/App.jsx] 307 bytes {app} [built]
[./src/IssueAdd.jsx] 3.45 KiB {app} [built]
[./src/IssueFilter.jsx] 2.67 KiB {app} [built]
[./src/IssueList.jsx] 6.02 KiB {app} [built]
[./src/IssueTable.jsx] 1.16 KiB {app} [built]
[./src/graphQLFetch.js] 2.71 KiB {app} [built]
    + 338 hidden modules

既然捆绑包中包含了所有的第三方库,我们可以从 CDN 中删除这些库的加载。相反,我们可以包含新的脚本vendor.bundle.js。变化都在index.html中,如清单 8-24 所示。

...
<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>

  <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/prop-types@15/prop-types.js"></script>

  <script src="https://unpkg.com/@babel/polyfill@7/dist/polyfill.min.js"></script>
  <script src="https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js"></script>
  <style>
    ...
  </style>
</head>

<body>
  ...
  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>
...

Listing 8-24ui/public/index.html: Removal of Libraries Included Directly from CDN

如果您现在测试应用(当然是在启动 API 和 UI 服务器之后),您会发现应用和以前一样工作。此外,快速查看开发人员控制台中的 Network 选项卡将会发现,不再从 CDN 中获取库;相反,新脚本vendor.bundle.js是从 UI 服务器获取的。

如果您对任何一个 JSX 文件做一个小的更改并刷新浏览器,您会发现获取vendor.bundle.js会返回一个“304 Not Modified”响应,但是应用的包app.bundle.js确实被获取了。考虑到vendor.bundle.js文件的大小,这将大大节省时间和带宽。

热模块更换

Webpack 的监视模式适用于客户端代码,但是这种方法有一个潜在的缺陷。在刷新浏览器以查看更改的效果之前,您必须留意运行命令npm run watch的控制台,以确保绑定完成。如果你太快地按下刷新键,你将会得到客户端代码的前一个版本,挠头想为什么你的修改不起作用,然后花时间调试。

此外,目前,我们需要一个额外的控制台来运行 UI 目录中的npm run watch以检测更改并重新编译文件。为了解决这些问题,Webpack 有一个强大的功能,叫做热模块替换(HMR)。这在应用运行时改变了浏览器中的模块,完全消除了刷新的需要。此外,如果有任何应用状态,也将被保留,例如,如果您正在某个文本框中键入内容,由于没有页面刷新,该状态将被保留。最重要的是,它通过只更新更改的内容来节省时间,并且它消除了切换窗口和按刷新按钮的需要。

使用 Webpack 实现 HMR 有两种方法。第一个涉及一个名为webpack-dev-server的新服务器,它可以从命令行安装和运行。它读取webpack.config.js的内容,并启动一个服务于编译文件的服务器。这是没有专用 UI 服务器的应用的首选方法。但是既然我们已经有了一个 UI 服务器,最好稍微修改一下来做webpack-dev-server会做的事情:编译,观察变化,实现 HMR。

HMR 有两个可以安装在 Express 应用中的中间件包,称为webpack-dev-middlewarewebpack-hot-middleware。让我们安装这些软件包:

$ cd ui
$ npm install --save-dev webpack-dev-middleware@3
$ npm install --save-dev webpack-hot-middleware@2

我们将在用于 UI 的 Express 服务器中使用这些模块,但是只有在显式启用时,因为我们不想在生产中这样做。我们必须导入这些模块,并将它们作为中间件安装在 Express 应用中。但是这些模块需要特殊的配置,不同于webpack.config.js中的默认设置。这些是:

  • 他们需要额外的入口点(除了App.jsx),以便 Webpack 可以将这个额外功能所需的客户端代码构建到包中。

  • 需要安装一个插件来生成增量更新,而不是整个软件包。

与其为此创建新的配置文件,不如让我们在启用 HMR 时动态修改配置。由于配置本身是一个 Node.js 模块,这很容易做到。但是我们确实需要在配置中做一个改变,一个不影响原始配置的改变。需要将入口点更改为数组,以便可以轻松地推送新的入口点。这一变化如清单 8-25 所示。

...
  entry: { app: './src/App.jsx' },
  entry: { app: ['./src/App.jsx'] },
...

Listing 8-25ui/webpack.config.js: Change Entry to an Array

现在,让我们为 Express 服务器添加一个选项来启用 HMR。让我们使用一个名为ENABLE_HMR的环境变量,默认为true,只要它不是生产部署。这给了开发者一个机会,如果他们更喜欢webpack --watch的做事方式,就可以关掉它。

...
const enableHMR = (process.env.ENABLE_HMR || 'true') === 'true';
if (enableHMR && (process.env.NODE_ENV !== 'production')) {
  console.log('Adding dev middleware, enabling HMR');
  ...
}
...

要启用 HMR,我们要做的第一件事是导入 Webpack 的模块和我们刚刚安装的两个新模块。我们还必须让 ESLint 知道,我们有一个特殊的情况,我们正在有条件地安装开发依赖项,因此可以禁用一些检查。

...
  /* eslint "global-require": "off" */
  /* eslint "import/no-extraneous-dependencies": "off" */
  const webpack = require('webpack');
  const devMiddleware = require('webpack-dev-middleware');
  const hotMiddleware = require('webpack-hot-middleware');
...

接下来,让我们导入配置文件。这也只是一个require()调用,因为配置只是一个 Node.js 模块:

...
  const config = require('./webpack.config.js');
...

config中,让我们为 Webpack 添加一个新的入口点,它将为 UI 代码的更改安装一个监听器,并在它们更改时获取新的模块。

...
  config.entry.app.push('webpack-hot-middleware/client');
...

然后,让我们为 HMR 启用插件,可以使用webpack.HotModuleReplacementPlugin()实例化它。

...
  config.plugins = config.plugins || [];
  config.plugins.push(new webpack.HotModuleReplacementPlugin());
...

最后,让我们从这个配置创建一个 Webpack 编译器,并创建dev中间件(它使用配置进行代码的实际编译并发送包)和hot中间件(它逐渐将新模块发送到浏览器)。

...
  const compiler = webpack(config);
  app.use(devMiddleware(compiler));
  app.use(hotMiddleware(compiler));
...

注意,devhot中间件必须在静态中间件之前安装。否则,如果包存在于public目录中(因为npm run compile已经执行了一段时间),那么static模块将会找到它们并发送它们作为响应,甚至在devhot中间件有机会之前。

清单 8-26 中显示了对uiserver.js文件的更改。

...
const app = express();

const enableHMR = (process.env.ENABLE_HMR || 'true') === 'true';

if (enableHMR && (process.env.NODE_ENV !== 'production')) {

  console.log('Adding dev middleware, enabling HMR');
  /* eslint "global-require": "off" */
  /* eslint "import/no-extraneous-dependencies": "off" */
  const webpack = require('webpack');
  const devMiddleware = require('webpack-dev-middleware');
  const hotMiddleware = require('webpack-hot-middleware');

  const config = require('./webpack.config.js');
  config.entry.app.push('webpack-hot-middleware/client');
  config.plugins = config.plugins || [];
  config.plugins.push(new webpack.HotModuleReplacementPlugin());

  const compiler = webpack(config);
  app.use(devMiddleware(compiler));
  app.use(hotMiddleware(compiler));

}

app.use(express.static('public'));
...

Listing 8-26ui/uiserver.js: Changes for Hot Module Replacement Middleware

此时,以下是启动 UI 服务器的不同方式:

  • npm run compile + npm run start:在生产模式下(变量NODE_ENV定义为生产),服务器的启动需要npm run compile已经运行,并且app.bundle.jsvendor.bundle.js已经生成并且在public目录下。

  • npm run start:在开发模式下(NODE_ENV未定义或设置为开发),这将启动默认启用 HMR 的服务器。对源文件的任何更改都会在浏览器中立即被 hot 替换。

  • npm run watch + npm run startENABLE_HMR=false:在开发或生产模式下,这些需要在两个控制台中运行。watch命令寻找变化并重新生成 JavaScript 包,start命令运行服务器。如果没有ENABLE_HMR,包将从public目录中提供,由watch命令生成。

让我们将这些作为注释添加到 UI 中的package.json中的脚本之前。由于 JSON 文件不能像 JavaScript 那样有注释,我们将只使用前缀为#的属性来实现这一点。对ui/package.json的更改如清单 8-27 所示。

...
  "scripts": {
    "#start": "UI server. HMR is enabled in dev mode.",
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "#lint": "Runs ESLint on all relevant files",
    "lint": "eslint . --ext js,jsx --ignore-pattern public",
    "#compile": "Generates JS bundles for production. Use with start.",
    "compile": "webpack --mode production",
    "#watch": "Compile, and recompile on any changes.",
    "watch": "webpack --watch"
  },
...

Listing 8-27ui/package.json: Comments to Define Each Script

现在,如果您在 UI 中运行npm start以及在 API 服务器中运行npm start,您将能够测试应用。如果你正在运行npm run watch,你现在可以停止它。应用应该像以前一样工作。您还会在浏览器的开发人员控制台中看到以下内容,向您保证 HMR 确实已被激活:

[HMR] connected

但是当一个文件改变时,比如说IssueFilter.jsx,你会在浏览器的控制台上看到一个警告:

[HMR] bundle rebuilding
HMR] bundle rebuilt in 102ms
[HMR] Checking for updates on the server...
Ignored an update to unaccepted module ./src/IssueFilter.jsx -> ./src/IssueList.jsx -> ./src/App.jsx -> 0
[HMR] The following modules couldn't be hot updated: (Full reload needed)
This is usually because the modules which have changed (and their parents) do not know how to hot reload themselves. See https://webpack.js.org/concepts/hot-module-replacement/ for more details.
[HMR]  - ./src/IssueFilter.jsx

这意味着虽然模块被重建并在浏览器中接收,但它不能被接受。为了接受对一个模块的更改,它的父模块需要使用HotModuleReplacementPluginaccept()方法来接受它。插件的接口通过module.hot属性公开。让我们无条件地接受模块层次结构顶层的所有更改,App.jsx。对此的更改如清单 8-28 所示。

...
ReactDOM.render(element, document.getElementById('contents'));

if (module.hot) {

  module.hot.accept();

}

...

Listing 8-28ui/src/App.jsx: Changes to Accept HMR

现在,如果你改变了比如说IssueFilter.jsx的内容,你会在开发人员控制台中看到,不仅仅是这个模块,所有包含链中包含这个及以上的模块都会被更新:IssueList.jsx,然后是App.jsx。这样做的一个效果是App.jsx模块被 HMR 插件再次加载(相当于import被执行)。这具有运行该文件内容中的整个代码的效果,包括以下内容:

...
const element = <IssueList />;
ReactDOM.render(element, document.getElementById('contents'));
..

因此,IssueList组件被再次构造和呈现,几乎所有的东西都被刷新。这可能会丢失本地状态。例如,如果您在IssueAdd组件的所有者和标题文本框中输入了一些内容,那么当您更改IssueFilter.jsx时,这些文本将会丢失。

为了避免这种情况,我们应该理想地在每个模块中寻找变化,并再次安装组件,但是保留本地状态。React 没有使这成为可能的方法,即使有,在每个组件中这样做也是很乏味的。为了解决这些问题,创建了react-hot-loader包。在编译时,它用代理替换组件的方法,然后调用真正的方法,如render()。然后,当一个组件的代码被更改时,它会自动引用新的方法,而不必重新挂载该组件。

这在应用中证明是有用的,在这些应用中,本地状态在刷新之间的保存确实很重要。但是对于问题跟踪器应用,让我们不要实现react-hot-loader,相反,让我们满足于当一些代码改变时重新加载整个组件层次结构。在任何情况下,它都不会花费太多时间,并且节省了安装和使用react-hot-loader的复杂性。

练习:热模块更换

  1. 当一个模块的代码被改变时,你如何判断浏览器没有被完全刷新?使用浏览器开发工具的网络部分,观察发生了什么。

本章末尾有答案。

排除故障

编译文件的不愉快之处在于原始源代码会丢失,如果您必须在调试器中设置断点,这几乎是不可能的,因为新代码几乎不像原始代码。创建一个包含所有源文件的包会使情况变得更糟,因为您甚至不知道从哪里开始。

幸运的是,Webpack 解决了这个问题,它能够给你源代码图,也就是你输入源代码时包含原始源代码的东西。源映射还将转换后的代码中的行号连接到原始代码。浏览器的开发工具理解源映射并将两者关联起来,这样原始源代码中的断点就变成了转换后的代码中的断点。

Webpack 配置可以指定哪种类型的源映射可以与编译后的包一起创建。一个名为devtool的配置参数完成了这项工作。可以生成的源地图的种类各不相同,但是最精确的(也是最慢的)是由值source-map生成的。对于这个应用,因为 UI 代码足够小,所以速度并不慢,所以让我们用它作为devtool的值。对 UI 目录中webpack.config.js的修改如清单 8-29 所示。

...
  optimization: {
    ...
  },
  devtool: 'source-map'
};
...

Listing 8-29ui/webpack.config.js: Enable Source Map

如果您使用的是支持 HMR 的 UI 服务器,您应该会在运行 UI 服务器的控制台中看到以下输出:

webpack built dc6a1e03ee249e546ffb in 2964ms
⌈wdm⌋: Hash: dc6a1e03ee249e546ffb
Version: webpack 4.23.1
Time: 2964ms
Built at: 10/27/2018 12:08:12 AM
               Asset      Size  Chunks             Chunk Names
       app.bundle.js  54.2 KiB     app  [emitted]  app
   app.bundle.js.map  41.9 KiB     app  [emitted]  app
    vendor.bundle.js  1.26 MiB  vendor  [emitted]  vendor
vendor.bundle.js.map   1.3 MiB  vendor  [emitted]  vendor
Entrypoint app = vendor.bundle.js vendor.bundle.js.map app.bundle.js app.bundle.js.map
[0] multi ./src/App.jsx webpack-hot-middleware/client 40 bytes {app} [built]
[./node_modules/ansi-html/index.js] 4.16 KiB {vendor} [built]
[./node_modules/babel-polyfill/lib/index.js] 833 bytes {vendor} [built]
...

如你所见,除了包包,还有附带的地图,扩展名为.map。现在,当您查看浏览器的开发人员控制台时,您将能够看到原始源代码,并能够在其中放置断点。Chrome 浏览器中的一个例子如图 8-1 所示。

img/426054_2_En_8_Chapter/426054_2_En_8_Fig1_HTML.jpg

图 8-1

使用源代码映射在原始源代码中设置断点

您会发现其他浏览器中的源代码大致相似,但不完全相同。你可能要四处看看才能找到它们。例如在 Safari 中,可以在 Sources-> app . bundle . js-> " "-> src 下看到源代码。

如果您使用 Chrome 或 Firefox 浏览器,您还会在控制台中看到一条消息,要求您安装 React 开发工具插件。你可以在 https://reactjs.org/blog/2015/09/02/new-react-developer-tools.html 找到这些浏览器的安装说明。这个附加组件提供了以类似 DOM inspector 的分层方式查看 React 组件的能力。例如,在 Chrome 浏览器中,你会在开发者工具中找到一个 React 标签。图 8-2 显示了这个附加组件的截图。

img/426054_2_En_8_Chapter/426054_2_En_8_Fig2_HTML.jpg

图 8-2

Chrome 浏览器中的 React 开发者工具

注意

在撰写本书时,React 开发人员工具与 React 版本 16.6.0 存在兼容性问题。如果你确实面临一个问题(控制台中会出现类似Uncaught TypeError: Cannot read property 'displayName' of null的错误),你可能不得不将 React 的版本降级到 16.5.2。

定义插件:构建配置

您可能不习惯我们在前端注入环境变量的机制:像env.js这样生成的脚本。首先,这比生成一个已经在需要替换的地方替换了这个变量的包效率低。另一个原因是全局变量通常不被接受,因为它会与其他脚本或包中的全局变量冲突。

幸运的是,有一个选择。我们不会使用这种机制来注入环境变量,但是我已经在这里讨论过了,所以如果方便的话,它会给你一个尝试和采用的选项。

为了在构建时替换变量,Webpack 的DefinePlugin插件派上了用场。作为webpack.config.js的一部分,下面的内容可以添加到中,定义一个预定义的字符串,其值如下:

...
  plugins: [
    new webpack.DefinePlugin({
      __UI_API_ENDPOINT__: "'http://localhost:3000/graphql'",
    })
  ],
...

现在,在App.jsx的代码中,可以像这样使用__UI_API_ENDPOINT__字符串,而不是硬编码这个值(注意没有引号;它由变量本身提供):

...
    const response = await fetch(__UI_API_ENDPOINT__, {
...

当 Webpack 转换并创建一个包时,该变量将在源代码中被替换,结果如下:

...
    const response = await fetch('http://localhost:3000/graphql', {
...

webpack.config.js中,您可以通过使用dotenv和一个环境变量来确定变量的值,而不是在那里硬编码:

...
require('dotenv').config();
...
    new webpack.DefinePlugin({
      __UI_API_ENDPOINT__: `'${process.env.UI_API_ENDPOINT}'`,
    })
...

虽然这种方法工作得很好,但是它的缺点是必须为不同的环境创建不同的包或构建。这也意味着一旦部署,例如,对服务器配置的更改,如果不进行另一次构建,就无法完成。出于这些原因,我选择坚持通过env.js为问题跟踪器应用注入运行时环境。

生产优化

尽管 Webpack 完成了所有必要的工作,比如当模式被指定为生产时缩小 JavaScript 的输出,但是有两件事情需要开发人员特别注意。

首先要关心的是捆绑大小。本章最后,第三方库并不多,厂商捆绑的大小在生产模式下在 200KB 左右。这个一点都不大。但是随着我们添加更多的特性,我们将使用更多的库,包的大小也必然会增加。随着我们在接下来的几章中的进展,您将很快发现,当编译用于生产时,Webpack 开始显示一个警告,提示vendor.bundle.js的包大小太大,这会影响性能。此外,还会有一个警告,即入口点app所需的所有资产的组合大小太大。

解决这些问题的方法取决于应用的类型。对于用户经常使用的应用,如问题跟踪器应用,包的大小不是很重要,因为它将被用户的浏览器缓存。除了第一次之外,包不会被获取,除非它们已经被改变。由于我们已经将应用捆绑包与库分开,我们或多或少地确保了作为供应商捆绑包一部分的大部分 JavaScript 代码不会改变,因此不需要频繁获取。因此,可以忽略 Webpack 警告。

但是对于有很多不经常使用的用户的应用,他们中的大多数是第一次访问 web 应用,或者在很长时间之后,浏览器缓存将没有任何作用。为了优化此类应用的页面加载时间,重要的是不仅要将包分成更小的部分,而且要使用一种称为延迟加载的策略仅在需要时加载包。拆分和加载代码以提高性能的实际步骤取决于应用的使用方式。例如,推迟预先加载 React 库是没有意义的,因为如果不这样做,任何页面的内容都不会显示。但是在后面的章节中,你会发现这是不正确的,当页面是使用服务器渲染和 React 构建的时候,它们确实可以被延迟加载。

对于问题跟踪器应用,我们假设它是一个经常使用的应用,因此浏览器缓存对我们来说非常有用。如果您的项目需求不同,您会发现关于代码拆分( https://webpack.js.org/guides/code-splitting/ )和惰性加载( https://webpack.js.org/guides/lazy-loading/ )的 Webpack 文档很有用。

另一个需要关注的是浏览器缓存,尤其是当你不想让它缓存 JavaScript 包的时候。当应用代码发生更改,并且用户浏览器缓存中的版本错误时,就会发生这种情况。大多数现代浏览器都很好地处理了这一点,通过检查服务器包是否已经改变。但是旧的浏览器,尤其是 Internet Explorer,会主动缓存脚本文件。唯一的解决方法是,如果脚本文件的内容已经更改,就更改它的名称。

这在 Webpack 中通过使用内容散列作为包名的一部分来解决,如位于 https://webpack.js.org/guides/caching/ 的 Webpack 文档中的缓存指南所述。注意,由于脚本名称已经生成,您还需要生成index.html本身来包含生成的脚本名称。这也是由一个名为 HTMLWebpackPlugin 的插件实现的。

我们不会在问题跟踪器应用中使用它,但是您可以在 Webpack ( https://webpack.js.org/guides/output-management/ )的输出管理指南和从 https://webpack.js.org/plugins/html-webpack-plugin/ 开始的 HTMLWebpackPlugin 本身的文档中了解更多关于如何做的信息。

摘要

延续前一章中编码卫生的精神,我们在本章中模块化了代码。由于 JavaScript 最初并不是为模块化而设计的,所以我们需要 Webpack 工具来将一些小的 JavaScript 文件和 React 组件放在一起并生成一些包。

我们消除了运行时库(如 React 和 polyfills)对 CDN 的依赖。同样,Webpack 帮助解决了依赖性,并为它们创建了包。您还看到了 Webpack 的 HMR 如何通过有效地替换浏览器中的模块来帮助我们提高生产率。然后,您了解了有助于调试的源映射。

在下一章,我们将回到添加特性上来。我们将探索客户端路由的一个重要概念,它将允许我们显示不同的组件或页面,并以无缝的方式在它们之间导航,即使应用实际上将继续是单页面应用(SPA)。

练习答案

练习:变换和捆绑

  1. 不,如果您保存的文件只有额外的空间,Webpack 不会重建。这是因为预处理或加载阶段产生了一个规范化的 JavaScript,它与原始的 JavaScript 没有什么不同。仅当规范化脚本不同时,才会触发重新绑定。

  2. 到目前为止,我们只有一个页面可以显示,即问题列表。接下来,我们将呈现其他页面,例如,编辑问题的页面,列出所有用户的页面,显示个人资料信息的页面,等等。然后,App.jsx文件需要根据用户交互挂载不同的组件。因此,将应用与可能加载的每个顶级组件分开很方便。

  3. 不使用default关键字会导致将类导出为导出对象的属性(而不是它本身)。这相当于在定义了可导出元素之后执行此操作:

    export { IssueList };
    
    

    In the import statement, you would have to do this:

    import { IssueList } from './IssueList.jsx';
    
    

注意 LHS 周围的析构赋值。这允许从单个文件中导出多个元素,您希望从导入中导出的每个元素用逗号分隔。当只导出一个元素时,最简单的方法是使用default关键字。

练习:热模块更换

  1. 浏览器控制台中有许多日志告诉您 HMR 正在被调用。此外,如果您查看网络请求,您会发现对于浏览器刷新,请求是针对所有资产的。看看这些资产的规模。通常,当客户端代码改变时,vendor.bundle.js不会被再次获取(它会返回 304 响应),但是app.bundle.js会被重新加载。

    但是当 HMR 成功的时候,你会看到所有的资产都没有被取走;相反,传输的是比app.bundle.js小得多的增量文件。*

九、React 路由

既然我们已经组织了项目并添加了开发工具以提高生产力,那么让我们回到添加更多特性到问题跟踪器上来。

在这一章中,我们将探索路由的概念,或者处理我们可能需要显示的多个页面。即使在单页面应用(SPA)中,实际上应用中也有多个逻辑页面(或视图)。只是页面加载只在第一次从服务器进行。之后,通过操作或更改 DOM 而不是从服务器获取整个页面来显示其他视图。

要在应用的不同视图之间导航,需要 routing 。路由将页面的状态链接到浏览器中的 URL。这不仅是一种根据 URL 推断页面中显示内容的简单方法,它还具有以下非常有用的属性:

  • 用户可以使用浏览器的前进/后退按钮在应用的已访问页面(实际上是视图)之间导航。

  • 个人网页可以加入书签,以后再访问。

  • 视图链接可以与其他人共享。假设您想请某人帮助您解决某个问题,并且您想向他们发送显示该问题的链接。对于收件人来说,通过电子邮件向他们发送链接比让他们浏览用户界面要容易和方便得多。

在水疗真正成熟之前,这是相当困难的,有时甚至是不可能的。SPAs 只有一个页面,也就是说只有一个 URL。所有的导航都必须是交互式的:用户必须通过预定义的步骤浏览应用。例如,无法将特定问题的链接发送给某人。相反,他们必须被告知按照 SPA 上的一系列步骤来解决问题。但是现代水疗会优雅地处理这个问题。

在本章中,我们将探索如何使用 React Router 来简化在视图之间设置导航的任务。我们将从应用的另一个视图开始,在这个视图中,用户可以查看和编辑单个问题。然后,我们将在视图之间创建链接,以便用户可以在它们之间导航。在我们创建的超链接上,我们将添加可以传递到不同视图的参数,例如,需要显示的问题的 ID,到显示单个问题的视图。最后,我们将看到如何嵌套组件和路由。

为了影响路由,任何页面都需要连接到浏览器能够识别并指示“这是用户正在查看的页面”的东西一般来说,对于水疗中心,有两种方式来建立这种联系:

  • 基于散列的 : 这使用 URL 的锚部分(跟随#的所有内容)。这个方法很自然,因为#部分可以被解释为页面中的一个位置,并且在一个 SPA 中只有一个页面。这个位置决定了显示页面的哪个部分。在#之前的部分永远不会从组成整个应用的唯一页面(index.html)改变。这很容易理解,并且对大多数应用都很有效。事实上,在不使用路由库的情况下,我们自己实现基于散列的路由是非常简单的。但是我们不会自己做,我们会用 React 路由来做。

  • 浏览器历史:这使用了新的 HTML5 API,让 JavaScript 处理页面转换,同时防止浏览器在 URL 改变时重新加载页面。即使有 React Router 的帮助,实现起来也有点复杂(因为它迫使您考虑当服务器收到对不同 URL 的请求时会发生什么)。但是,当我们想从服务器本身呈现一个完整的页面时,这非常方便,尤其是让搜索引擎爬虫获取页面内容并对其进行索引。

我们将从基于散列的技术开始,因为它容易理解,然后切换到浏览器历史技术,因为我们将在后面的章节中实现服务器端呈现。

简单路由

在本节中,我们将创建两个视图,一个用于我们一直在处理的问题列表,另一个(占位符)用于报告节。我们还将确保主页,即/,重定向到问题列表。首先,让我们安装将帮助我们完成这一切的包:React Router。

$ cd ui
$ npm install react-router-dom@4

让我们也为报告视图创建一个占位符组件。我们将把它和其他组件一起保存在ui/src目录中。我们称这个组件的文件为IssueReport.jsx,其全部内容在清单 9-1 中列出。

import React from 'react';

export default function IssueReport() {
  return (
    <div>
      <h2>This is a placeholder for the Issue Report</h2>
    </div>
  );
}

Listing 9-1ui/src/IssueReport.jsx: New File for Report Placeholder

现在,让我们将应用的主页分成两个部分:一个标题部分包含一个导航栏,其中包含指向不同视图的超链接;一个内容部分,它将根据所选的超链接在两个视图之间切换。无论显示何种视图,导航栏都将保持不变。将来,我们可能会在内容部分看到其他视图。让我们为内容创建一个组件,并把它放在目录ui/src下名为Contents.jsx的文件中。该组件将负责视图之间的切换。

为了基于被点击的超链接实现不同组件之间的路由或切换,React Router 提供了一个名为Route的组件。它将路由需要匹配的路径和当路径与浏览器中的 URL 匹配时需要显示的组件作为属性。让我们使用路径/issues来显示问题列表,使用/report来显示报告视图。以下代码片段将实现这一点:

...
      <Route path="/issues" component={IssueList} />
      <Route path="/report" component={IssueReport} />
...

为了将主页重定向到/issues,我们可以进一步添加一个从/重定向到/issuesRedirect组件,如下所示:

...
      <Redirect from="/" to="/issues" />
...

最后,让我们添加一条当没有匹配的路由时显示的消息。注意,当属性path没有为Route组件指定时,这意味着它匹配任何路径。

...
const NotFound = () => <h1>Page Not Found</h1>;
...
      <Route component={NotFound} />
...

这四个路由需要封装在一个包装器组件中,它可以只是一个<div>。但是为了表明只需要显示这些组件中的一个,它们应该被包含在一个<Switch>组件中,以便只呈现第一个匹配的组件。在这种情况下,我们确实需要switch,因为最后一条路线将匹配任何路径。

还要注意,该匹配是一个前缀为的匹配。例如,路径/将不仅匹配/,还匹配/issues/report。所以路线的顺序也很重要。Redirect必须在/issues/report之后出现,并且总括路线必须在最后出现。或者,可以将exact属性添加到任何路由中,以表明它需要完全匹配。

请注意,匹配在两个方面不同于快速路由。首先,在 Express 中,默认情况下匹配是精确的,必须添加一个*来匹配后面的任何内容。其次,在 Express 中,路由匹配停止进一步的处理(除非是中间件,它可以为请求-响应过程增值并继续),而在 React Router 中,明确需要一个<Switch>来使它在第一次匹配时停止。否则,所有路径匹配的组件都会被渲染。

让我们对Redirect使用一个精确匹配,并让全包路线成为最后一个。在添加了必要的import语句和<Switch>包装器之后,Contents.jsx的最终内容如清单 9-2 所示。

import React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';

import IssueList from './IssueList.jsx';
import IssueReport from './IssueReport.jsx';

const NotFound = () => <h1>Page Not Found</h1>;

export default function Contents() {
  return (
    <Switch>
      <Redirect exact from="/" to="/issues" />
      <Route path="/issues" component={IssueList} />
      <Route path="/report" component={IssueReport} />
      <Route component={NotFound} />
    </Switch>
  );
}

Listing 9-2ui/src/Contents.jsx: New File for the Contents Section

接下来,让我们创建显示导航栏和内容组件的页面。一个像这样一个接一个地显示NavBarContents组件的无状态组件将完成必要的工作。(需要一个<div>来包含这两个元素,因为组件的 render 方法只能返回一个元素) :

...
    <div>
      <NavBar />
      <Contents />
    </div>
...

至于导航栏,我们需要一系列超链接。因为我们将使用HashRouter,所有的页面都将有主 URL 作为/,后面是一个以#符号开始的页面内锚,并且有路线的实际路径。例如,为了匹配Route规范中指定的/issues路径,URL 将是/#/issues,其中第一个/是 SPA 的唯一页面,#是锚点的分隔符,/issues是路由的路径。

因此,到问题列表的链接将采用/#/issues的形式,如下所示:

...
      <a href="/#/issues">Issue List</a>
...

让我们有三个超链接,一个是主页,一个是问题列表,一个是报告。要用竖线(|)字符分隔它们,我们需要使用如下 JavaScript 表达式:

...
      {' | '}
...

这是因为空白被 JSX 变换去除了,否则我们不能自然地添加周围有空白的条。让我们将导航栏创建为一个带有三个超链接的无状态组件,并将它与同样无状态的Page组件放在一个名为Page.jsx的新文件中。这个新文件的内容如清单 9-3 所示。

import React from 'react';

import Contents from './Contents.jsx';

function NavBar() {
  return (
    <nav>
      <a href="/">Home</a>
      {' | '}
      <a href="/#/issues">Issue List</a>
      {' | '}
      <a href="/#/report">Report</a>
    </nav>
  );
}

export default function Page() {
  return (
    <div>
      <NavBar />
      <Contents />
    </div>
  );
}

Listing 9-3ui/src/Page.jsx: New File for Composite Page

最后,在App.jsx中,我们需要将这个页面而不是原始的IssueList组件呈现到 DOM 中。此外,页面本身需要包装在路由周围,因为所有路由功能都必须在路由中才能工作。我们将使用react-router-dom包中的HashRouter组件。清单 9-4 显示了对App.jsx的这些更改。

...
import ReactDOM from 'react-dom';

import { HashRouter as Router } from 'react-router-dom';

import IssueList from './IssueList.jsx';

import Page from './Page.jsx';

const element = <IssueList />;

const element = (
  <Router>
    <Page />
  </Router>
);
...

Listing 9-4ui/src/App.jsx: Changes to Mount Page Instead of IssueList

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

现在,如果您通过导航到localhost:8000在浏览器中测试应用,您会发现浏览器的 URL 会自动更改为http://localhost:8000/#/issues。这是因为Redirect路线。在屏幕上,您会看到一个导航栏和其下方的常见问题列表。截图如图 9-1 所示。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig1_HTML.jpg

图 9-1

导航栏和常见问题列表

现在,您应该能够通过单击导航栏中的超链接在三个视图之间切换。点击主页将重定向至问题列表,点击报告将显示类似报告视图的占位符,如图 9-2 所示。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig2_HTML.jpg

图 9-2

问题报告占位符

如果键入任何其他文本而不是报告或问题,您也应该看到“未找到页面”消息。重要的是,您应该能够使用前进和后退按钮在导航历史中导航。浏览器的刷新也应该显示当前页面。

练习:简单路由

  1. Redirect的属性列表中删除exact。会发生什么?你能解释这种行为吗?没有exact属性你能达到要求吗?(记住在此练习后恢复更改。)

  2. 用一个<div>替换<Switch>。现在发生了什么?你能解释一下这个吗?(在此练习之后,还原代码以恢复原始行为。)

  3. 查看问题列表时,如果您在 URL 后面追加一些额外的文本,例如/ #/issues000,您预计会发生什么情况?尝试一下来确认你的答案。现在,尝试同样的方法,但是在额外的文本前加一个/符号,例如/ #/issues/000。现在你期待什么,你看到了什么?也试着在路线中加入exact。关于匹配算法,它告诉了你什么?

本章末尾有答案。

路线参数

正如您刚才看到的(也就是说,如果您已经完成了上一节中的练习),URL 的路径和路由的路径不需要完全匹配。URL 中匹配部分后面的内容是路径的动态部分,它可以作为路由组件中的一个变量来访问。这是向组件提供参数的一种方式。另一种方法是使用 URL 的查询字符串,我们将在下一节探讨这一点。

让我们使用这个工具来显示一个允许我们编辑问题的页面。现在,我们将创建一个占位符,就像我们对报告所做的那样。我们将这个文件称为IssueEdit.jsx。稍后,我们将进行 Ajax 调用,获取问题的详细信息,并以表单的形式显示出来,供用户进行更改和保存。为了确保我们收到的是正确的问题 ID,让我们将其显示在占位符中。

在 route path 中指定参数类似于在 Express 中,使用字符:后跟将接收值的属性的名称。我们姑且称编辑一个问题的路径的基,/edit。然后,路径规范/edit/:id将匹配一个 URL 路径,如/edit/1/edit/2等。对路线的更改以及组件的导入如清单 9-5 所示,在Contents.jsx文件中。

...
import IssueReport from './IssueReport.jsx';

import IssueEdit from './IssueEdit.jsx';

...
      <Route path="/issues" component={IssueList} />
      <Route path="/edit/:id" component={IssueEdit} />
...

Listing 9-5ui/src/Contents.jsx: Changes for IssueEdit Route

通过props,所有路由组件都被提供一个名为match的对象,该对象包含匹配操作的结果。其中包含一个名为params的字段,用于保存路由参数变量。因此,要访问包含id的 URL 路径的尾部,可以使用match.params.id。让我们使用它并在IssueEdit.jsx中创建占位符编辑组件。该文件的内容如清单 9-6 所示。

import React from 'react';

export default function IssueEdit({ match }) {
  const { id } = match.params;
  return (
    <h2>{`This is a placeholder for editing issue ${id}`}</h2>
  );
}

Listing 9-6ui/src/IssueEdit.jsx: New File for Placeholder IssueEdit Component

现在,你可以输入/ #/edit/1等等。在浏览器的 URL 栏中进行测试,但是为了方便起见,我们在问题列表的每一行中创建一个超链接。为此,我们将创建一个名为 Action 的新列,并用一个指向/edit/<issue id>的超链接填充它。这些变化出现在IssueTable.jsx,如清单 9-7 所示。

...
function IssueRow({ issue }) {
...
      <td>{issue.title}</td>
      <td><a href={`/#/edit/${issue.id}`}>Edit</a></td>
    </tr>
...
}
...

export default function IssueTable({ issues }) {
...
          <th>Title</th>
          <th>Action</th>
        </tr>
...
}
...

Listing 9-7ui/src/IssueTable.jsx

现在,如果您测试应用并转到问题列表页面,您将在表格右侧看到一个额外的列,其中有一个名为 Edit 的链接。单击此链接应该会显示用于编辑问题的占位符页面,以及您单击的问题的 ID。要返回问题列表页面,您可以使用浏览器的后退按钮,或者单击导航栏中的问题列表超链接。占位符编辑页面的截图如图 9-3 所示。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig3_HTML.jpg

图 9-3

编辑页面占位符

查询参数

像我们在上一节中看到的那样,添加变量(如正在编辑的问题的 ID)作为路由参数是非常简单和自然的。但是会有这样的情况,变量很多,而且不一定有一定的顺序。

让我们以问题列表为例。到目前为止,我们一直在显示数据库中的所有问题。这显然不是一个好主意。理想情况下,我们将有许多方法来过滤要显示的问题。例如,我们希望根据状态、受托人等进行筛选。,能够在数据库中搜索包含特定文本的问题,并具有用于对列表进行排序和分页的附加参数。URL 的查询字符串部分是处理这些问题的理想方式。

我们现在不会实现所有可能的过滤器、排序和分页。但是为了理解查询参数是如何工作的,让我们基于 status 字段实现一个简单的过滤器,以便用户可以只列出具有特定状态的问题。让我们首先更改 List API 来接受这个过滤器。让我们从更改 GraphQL 模式开始。这是一个简单的变化;我们所需要做的就是将一个名为status的参数添加到issueList查询中,类型为StatusType。这一变化如清单 9-8 所示。

...
type Query {
  about: String!
  issueList(status: StatusType): [Issue!]!
}
...

Listing 9-8api/schema.graphql: Addition of a Filter to issueList API

让我们在文件issue.js的 API 实现中,在函数list()中接受这个新的参数。这个函数现在将接受一个名为status的参数,类似于add()函数。由于参数是可选的,我们将有条件地添加一个状态过滤器,并将其传递给集合的find()方法。这些变化如清单 9-9 所示,都是issue.js的一部分。

...
async function list(_, { status }) {
  const db = getDb();
  const filter = {};
  if (status) filter.status = status;
  const issues = await db.collection('issues').find(filter).toArray();
  return issues;
}
...

Listing 9-9api/issue.js: Handle Filtering on Issue Status

在这一点上,使用操场运行一个快速测试是很好的。您可以使用以下查询测试对状态为New的问题的问题列表的过滤:

{
  issueList(status: New) {
    id status title
  }
}

您应该得到一个只包含新问题的响应。此外,您可以确保原始查询(没有任何过滤器)也能正常工作,返回数据库中的所有问题。

现在,让我们用三个超链接替换筛选器的占位符:所有问题、新问题和已分配问题。让我们使用一个名为status的查询字符串变量,其值指示要过滤的状态,并添加超链接,就像我们在导航栏中所做的那样。带有超链接而不是占位符的新组件如清单 9-10 所示,作为IssueFilter.js文件的全部内容。

/* eslint "react/prefer-stateless-function": "off" */

import React from 'react';

export default class IssueFilter extends React.Component {
  render() {
    return (
      <div>This is a placeholder for the issue filter.</div>
      <div>
        <a href="/#/issues">All Issues</a>
        {' | '}
        <a href="/#/issues?status=New">New Issues</a>
        {' | '}
        <a href="/#/issues?status=Assigned">Assigned Issues</a>
      </div>
    );
  }
}

Listing 9-10ui/src/IssueFilter.js: New Component with Filter Links

查询字符串需要由作为loadData()函数一部分的IssueList组件来处理。就像match属性一样,React 路由也提供一个名为location的对象作为 props 的一部分,该对象包括路径(在字段pathname中)和查询字符串(在字段search中)。React 路由不解析查询字符串,而是让应用决定如何解析这个字段。让我们遵循查询字符串的常规解释和解析,这可以通过 JavaScript API URLSearchParams()轻松完成,就像这样,在loadData()方法中:

...
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
...

API URLSearchParams()可能需要一个针对旧浏览器的 polyfill,尤其是 Internet Explorer,如 https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams 的 MDN 文档中所述。既然我们也承诺支持 IE,让我们安装 polyfill。

$ cd ui
$ npm install url-search-params@1

要包含 polyfill,我们必须在IssueList.jsx中将其导入。

...
import URLSearchParams from 'url-search-params';
...

解析完查询字符串后,我们将使用URLSearchParamsget()方法访问status参数,就像params.get('status')一样。让我们创建一个变量,作为查询变量提供给 GraphQL。如果参数不存在,params.get()方法返回null,但是在这种情况下我们想跳过设置变量。因此,在将状态添加到变量之前,我们将添加一个检查来查看状态是否已定义。

...
    const vars = {};
    if (params.get('status')) vars.status = params.get('status');
...

让我们将简单的 GraphQL 查询修改为一个带有变量的命名操作:

...
    const query = `query issueList($status: StatusType) {
      issueList (status: $status) {
        id title status owner
        created effort due
      }
    }`;
...

现在,我们可以修改对graphQLFetch()的调用,以包含具有状态过滤器参数的查询变量:

...
    const data = await graphQLFetch(query, vars);
...

此时,如果您尝试应用并通过单击每个过滤器超链接来应用过滤器,您会发现问题列表并没有改变。但是在 URL 中使用现有过滤器刷新浏览器时,它会显示正确的过滤问题列表。您还会发现,导航到另一个路径,例如报告页面或编辑页面,并使用 back 按钮会使过滤器生效。这表明在初始渲染时调用了loadData(),但是查询字符串的变化不会导致调用loadData()

我在前面简单地谈到了组件生命周期方法。这些是 React 对组件所做的各种更改的挂钩。我们使用生命周期方法componentDidMount()来挂钩组件的初始就绪状态。类似地,我们需要挂钩一个方法,告诉我们查询字符串已经更改,这样我们就可以重新加载列表。这个图表很好地描述了一整套生命周期方法: http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

从图中可以清楚地看出,生命周期方法componentDidUpdate()有利于在某些属性发生变化时采取行动。发生这种情况时会自动调用一个render(),但这还不够。我们还需要通过调用loadData()来刷新状态。

让我们实现生命周期挂钩componentDidUpdate(),并在必要时通过比较IssueList中的新旧查询字符串来重新加载数据。这个方法是通过了前面的道具。当前道具可以使用this.props来获得。我们可以通过比较先前和当前属性的location.search属性来检测过滤器中的变化,并在发生变化时重新加载数据:

...
  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.loadData();
    }
  }
...

清单 9-11 显示了对IssueList组件的完整更改,包括最近的更改。

...
import React from 'react';

import URLSearchParams from 'url-search-params';

...

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.loadData();
    }
  }
...

  async loadData() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    const vars = {};
    if (params.get('status')) vars.status = params.get('status');

    const query = `query {
      issueList {
    const query = `query issueList($status: StatusType) {
      issueList (status: $status) {
        id title status owner
        created effort due
      }
    }`;

    const data = await graphQLFetch(query, vars);
    if (data) {
    ...
  }
...

Listing 9-11ui/src/IssueList.jsx: Changes for Handling a Query String Based Filter

现在,如果您在不同的过滤器超链接之间导航,您会发现问题列表会根据所选的过滤器进行刷新。

练习:查询参数

  1. 通过在 URL 栏中更改问题 ID 或为其中一个问题创建书签,在两个不同问题的编辑页面之间导航。正确显示了两个不同的页面。与问题列表页面相比,这里需要一个componentDidUpdate()方法。为什么会这样?提示:思考属性的更改如何影响问题编辑页面中的问题列表。

  2. componentDidUpdate()中,检查新旧属性是否相同。这需要吗?一个原因当然是,它避免了不必要的loadData()。但是为什么不在属性改变时重新加载数据呢?自己试试看。(记得在练习后恢复更改。)

  3. IssueAddrender()方法中添加一个断点,在过滤器之间切换。你会发现当过滤器改变时,这个组件被再次渲染。对性能有什么影响?这怎么优化?提示:阅读关于在 https://reactjs.org/docs/react-component.html React 的文档中的“组件生命周期”部分。

本章末尾有答案。

链接

到目前为止,我们一直使用href来创建超链接。尽管这是可行的,但 React Router 提供了一种更好、更方便的方式来通过Link组件创建链接。这个组件很像一个href,但是它有以下不同之处:

  • Link中的路径总是绝对的;它不支持相对路径。

  • 查询字符串和散列可以作为单独的属性提供给Link

  • LinkNavLink的一个变体能够判断当前 URL 是否与链接匹配,并向链接添加一个类以将其显示为活动的。

  • 一个Link在不同种类的路由之间工作是相同的,也就是说,指定路由的不同方式(使用#字符,或使用路径原样)对程序员是隐藏的。

让我们利用链接组件的这些属性,将所有的href更改为Link,这个组件有一个属性to,它可以是一个字符串(对于简单的目标)或一个对象(对于带有查询字符串的目标,等等)。).让我们从IssueRow组件的变化开始,这里的目标是一个简单的字符串。我们需要将组件的名称从<a>更改为<Link>,并将属性href更改为to,同时保持与目标相同的字符串。清单 9-12 中显示了IssueTable.jsx的变化。

...
import React from 'react';

import { Link } from 'react-router-dom';

function IssueRow({ issue }) {
...
      <td>{issue.title}</td>
      <td><a href={`/#/edit/${issue.id}`}>Edit</a></td>
      <td><Link to={`/edit/${issue.id}`}>Edit</Link></td>
...
}
...

Listing 9-12ui/src/IssueTable.jsx: Changes to IssueRow to Use Link

接下来我们可以使用IssueFilter,这里有查询字符串。这一次,让我们提供一个分别包含路径和查询字符串的对象,而不是一个字符串。这些的对象属性分别是pathnamesearch。因此,对于新问题的链接,to属性将包含路径名为/issues,查询字符串为?status=New

请注意,React Router 不会对查询字符串做出假设,正如您在上一节中看到的那样。本着同样的精神,它也要求将前缀?作为查询字符串的一部分。清单 9-13 中显示了对IssueFilter的这些更改。

...
import React from 'react';

import { Link } from 'react-router-dom';

export default class IssueFilter extends React.Component {
...
        <a href="/#/issues">All Issues</a>
        <Link to="/issues">All Issues</Link>
        ...
        <a href="/#/issues?status=New">New Issues</a>
        <Link to={{ pathname: '/issues', search: '?status=New' }}>
          New Issues
        </Link>
        ...
        <a href="/#/issues?status=Assigned">Assigned Issues</a>
        <Link to={{ pathname: '/issues', search: '?status=Assigned' }}>
          Assigned Issues
        </Link>
...
}
...

Listing 9-13ui/src/IssueFilter.jsx: Change to Convert hrefs to Links

至于Page.jsx中的导航栏,让我们用一个NavLink来代替,这将允许我们突出显示当前活动的导航链接。注意,NavLink将高亮显示任何部分匹配 URL 路径的路径,这些路径基于由/分隔的段。当导航路径的整个层次结构都可以突出显示时,或者当导航链接在页面中有更多变化时,这很有用。对于我们目前在应用中的导航栏来说,Home链接,它的目标只是/,将匹配浏览器 URL 中的任何路径。为了避免总是高亮显示,NavLink有一个exact属性,就像Route组件的属性一样,强制进行精确匹配而不是前缀匹配。让我们只对Home链接使用该属性,并像对IssueRow组件那样简单地转换其他属性。这些变化如清单 9-14 所示。

...
import React from 'react';

import { NavLink } from 'react-router-dom';

...

function NavBar() {
...
      <a href="/">Home</a>
      <NavLink exact to="/">Home</NavLink>
      ...
      <a href="/#/issues">Issue List</a>
      <NavLink to="/issues">Issue List</NavLink>
      ...
      <a href="/#/report">Report</a>
      <NavLink to="/report">Report</NavLink>
...
}
...

Listing 9-14ui/src/Page.jsx: Change to Replace hrefs with NavLinks

NavLink只在链接匹配 URL 时添加一个名为active的类。为了改变活动链接的外观,我们需要为这个类定义一个样式。让我们为样式规范中的活动链接使用浅蓝色背景。清单 9-15 显示了index.html的这一变化。

...
  <style>
    ...
    a.active {background-color: #D8D8F5;}
  </style>
...

Listing 9-15ui/public/index.html: Style for Active NavLinks

现在,当您测试应用时,您不仅应该看到它像以前一样工作,还应该看到基于当前显示的页面突出显示的导航链接之一:问题列表页面或报告页面。然而,没有任何相应导航链接的编辑页面不会导致任何链接被突出显示。查看问题列表时的应用截图如图 9-4 所示。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig4_HTML.jpg

图 9-4

查看列表时突出显示的问题列表链接

如果您两次单击同一个链接,您可能还会在开发人员工具控制台中看到一条警告,称“哈希历史不能推送相同的路径...”此消息仅在开发模式下可见,并且仅在我们以编程方式推入与之前相同的路由路径时才会显示。您可以放心地忽略此警告。在任何情况下,我们将很快过渡到浏览器历史路由,在那里将不会看到这个警告。

练习:链接

  1. 你可能已经注意到我们没有使用NavLink s 来过滤链接。试着把这些也改成NavLinks。在过滤器之间导航时,您观察到了什么?你能解释这个吗?(记得在完成实验后恢复更改。)

  2. 假设您使用的是第三方 CSS 库,使用该库突出显示链接的方式是添加current类而不是active类。你会怎么做?提示:在 https://reacttraining.com/react-router/web/api/NavLink 查阅NavLink的文档。

本章末尾有答案。

程序导航

当变量值是动态的并且可能有许多无法预先确定的组合时,通常使用查询字符串。它们通常也是 HTML 表单的结果。一个表单需要动态地构造查询字符串,这与我们到目前为止在Link中使用的预先确定的字符串相反。

在后面的章节中,我们将创建一个更正式的表单,而不仅仅是将状态作为过滤器,但是在这一节中,我们将添加一个简单的下拉列表,并根据下拉列表的值设置查询字符串。我们可以通过传递一个来自IssueList的回调来直接重新加载列表,该回调接受新的过滤器作为参数。但是,URL 将不会反映页面的当前状态,这不是一个好主意,因为如果用户刷新浏览器,过滤器将被清除。建议保持数据流单向:当下拉列表值改变时,它会改变 URL 的查询字符串,进而应用过滤器。即使我们从中间开始也是一样的:直接改变 URL 的查询字符串,它将应用过滤器。

让我们首先创建这个简单的下拉列表,并用它替换IssueFilter中的链接。

...
      <div>
        Status:
        {' '}
        <select>
          <option value="">(All)</option
          <option value="New">New</option>
          ...
        </select>
      </div>
...

注意

编译器在元素边界处去除 JSX 中的所有空白,因此标签Status:后的空格将不起作用。在标签后添加空格的一种方法是使用 HTML 不间断空格。另一种插入元素的方法是将它作为 JavaScript 文本添加,这就是我们在本例中使用的方法。

接下来,让我们在 dropdown 值改变时捕获事件,并且可以预见的是,在onChange中捕获这个事件的属性。让我们添加这个属性,并将其设置为一个名为onChangeStatus的类方法。

...
        <select onChange={this.onChangeStatus}>
...

在方法onChangeStatus的实现中,我们可以通过value属性,使用事件的目标(它将是下拉列表本身的句柄)获取下拉列表中所选项目的值:

...
  onChangeStatus(e) {
    const status = e.target.value;
}
...

就像 React Router 给IssueList组件增加的location属性一样,它还增加了一些更多的属性,其中一个是history.使用 this,location,query string 等。可以设置浏览器的 URL。但是,与IssueList不同,由于IssueFilter不直接是任何路由的一部分,React 路由不能自动使这些可用。为此,我们必须将这些附加属性显式地注入到IssueFilter组件中。这可以使用 React Router 提供的名为withRouter()的包装函数来完成。这个函数接受一个组件类作为参数,并返回一个新的组件类,它的historylocationmatch作为props的一部分。因此,我们不导出组件,而是像这样导出包装好的组件:

...
export default class IssueFilter extends React.Component {
  ...
}
...

export default withRouter(IssueFilter);

...

现在,在onChangeStatus()中,我们将可以访问this.props.history,它可以用于根据更改后的过滤器推送新位置。但是要访问处理程序中的this,我们必须确保处理程序被绑定到构造函数中的this

...
  constructor() {
    super();
    this.onChangeStatus = this.onChangeStatus.bind(this);
  }
...

现在,在处理程序中,我们可以使用historypush()方法来推送新位置。这个方法接受一个对象,就像我们用于Link指定位置的对象一样,即一个pathname和一个search。让我们也处理一下空状态选项,我们将不会对其进行搜索。

...
  onChangeStatus(e) {
    ...
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : '',
    });
  }
...

清单 9-16 中显示了IssueFilter.jsx的完整源代码。删除的代码没有显示出来,因为几乎所有以前的代码都被删除了。

import React from 'react';
import { withRouter } from 'react-router-dom';

class IssueFilter extends React.Component {
  constructor() {
    super();
    this.onChangeStatus = this.onChangeStatus.bind(this);
  }

  onChangeStatus(e) {
    const status = e.target.value;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : '',
    });
  }

  render() {
    return (
      <div>
        Status:
        {' '}
        <select onChange={this.onChangeStatus}>
          <option value="">(All)</option>
          <option value="New">New</option>
          <option value="Assigned">Assigned</option>
          <option value="Fixed">Fixed</option>
          <option value="Closed">Closed</option>
        </select>
      </div>
    );
  }
}

export default withRouter(IssueFilter);

Listing 9-16ui/src/IssueFilter.jsx: New Implementation of IssueFilter

如果您现在测试应用,您会发现当在下拉列表中选择不同的项目时,问题列表会发生变化。要查看它是否适用于除“新”和“已分配”之外的状态,您必须直接在 MongoDB 中或通过 Playground 添加更多关于其他状态的问题。图 9-5 中显示了应用的屏幕截图,其中问题列表已根据新问题进行了过滤。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig5_HTML.jpg

图 9-5

使用下拉列表过滤的问题列表

练习:程序化导航

  1. 我们用的是historypush()方法。还有什么方法可以使用,效果会有什么不同?提示:在 https://reacttraining.com/react-router/web/api/history 查阅history的文档。试试看。(记得在练习后恢复更改。)

  2. 过滤问题列表,例如,新建。现在,保持开发人员控制台打开,并在浏览器中单击刷新。下拉菜单是否反映了过滤器的状态?再次在下拉列表中选择新建。你看到了什么?这是什么意思?

  3. IssueList组件可以访问history对象。因此,不要在IssueFilter上使用withRouter,你可以将history对象从IssueList传递到IssueFilter,或者传递一个回调到IssueFilter,设置一个新的过滤器并从子组件调用它。比较这些选择。与使用withRouter的原始方法相比,有哪些优点和缺点?

本章末尾有答案。

嵌套路由

在显示对象列表的同时显示一个对象的细节的常见模式是使用 header-detail UI 模式。这与一些电子邮件客户端相同,特别是 Outlook 和 Gmail,它们可以纵向或横向拆分使用。对象列表显示了关于它们的简要信息(每封电子邮件的发件人和主题),选择其中一个对象后,所选对象(邮件本身)的更多详细信息将显示在详细信息区域。

问题跟踪器没有多大用处,除非它能够存储每个问题的详细描述和不同用户的评论。因此,与电子邮件客户端类似,让我们为问题添加一个描述字段,它可能是很长的文本,不适合显示在问题列表中。让我们也这样做,以便在选择一个问题时,页面的下半部分显示该问题的描述。

这需要嵌套路由,其中路径的开始部分描述了页面的一个部分,并且基于该页面内的交互,路径的后面部分描述了变化,或者对页面中额外显示的内容的进一步定义。在 Issue Tracker 应用的情况下,除了问题列表之外,我们将让/issues显示问题列表(没有详细信息),让/issues/1显示详细信息部分,其中包含对 ID 为 1 的问题的描述。

React Router 通过其动态路由理念使这一点变得容易。在组件层次结构中的任何一点,都可以添加一个Route组件,如果 URL 与 route 的路径匹配,就会呈现这个组件。在 Issue Tracker 应用中,我们可以定义这样一个Route,其实际组件是问题细节,在IssueList中,就在IssueAdd部分之后。路径可以是/issues/<id>的形式,类似于IssueEdit组件的路径匹配,如下所示:

...
        <IssueAdd createIssue={this.createIssue} />
        <hr />
        <Route path="/issues/:id" component={IssueDetail} />
...

因此,与快速路由不同,React 路由的路由不需要全部预先声明;它们可以放置在任何级别,并在渲染过程中进行评估。

但是在我们做这个改变之前,让我们修改模式来添加一个描述字段。我们将在类型Issue和类型IssueInputs中这样做。我们还需要一个新的 API,它可以检索给定 ID 的单个问题。这个 API 是组件IssueDetail用来获取描述的,而IssueTable不会获取描述。让我们简单地称这个 API 为issue,它接受一个整数作为参数来指定要获取的问题的 ID。清单 9-17 中列出了schema.graphql的变更。

...
type Issue {
  ...
  description: String
}
...

input IssueInputs {
  ...
  description: String
}
...

type Query {
  ...
  issue(id: Int!): Issue!
}
...

Listing 9-17api/schema.graphql: Changes for a New Field in Issue and a New Get API

接下来,让我们实现 API 来获得一个问题。这相当简单:我们需要做的就是使用id参数创建一个 MongoDB 过滤器,并使用这个过滤器在issues集合上调用findOne()。让我们调用这个函数get()并将它和其他从issue.js导出的函数一起导出。这组更改如清单 9-18 所示。

...

async function get(_, { id }) {

  const db = getDb();
  const issue = await db.collection('issues').findOne({ id });
  return issue;

}

async function list(_, { status }) {
  ...
}
...

module.exports = { list, add, get };
...

Listing 9-18api/issue.js: Implementation of New Function get() to Fetch a Single Issue

最后,我们需要在提供给 Apollo 服务器的解析器中绑定新函数。清单 9-19 中显示了对api_handler.js的更改。

const resolvers = {
  Query: {
    ...
    issue: issue.get,
  },
...

Listing 9-19api/api_handler.js

此时,您可以使用 Playground 测试新的 API。您可以创建一个带有描述字段的新问题,使用issue查询获取它,并查看描述是否被返回。为了方便起见,我们还可以修改模式初始化器脚本,为初始问题集添加一个描述字段。清单 9-20 中显示了对init.mongo.js的更改。

...
const issuesDB = [
  {
    ...
    description: 'Steps to recreate the problem:'
      + '\n1\. Refresh the browser.'
      + '\n2\. Select "New" in the filter'
      + '\n3\. Refresh the browser again. Note the warning in the console:'
      + '\n   Warning: Hash history cannot PUSH the same path; a new entry'
      + '\n   will not be added to the history stack'
      + '\n4\. Click on Add.'
      + '\n5\. There is an error in console, and add doesn\'t work.',
  },
  {
    ...
    description: 'There needs to be a border in the bottom in the panel'
      + ' that appears when clicking on Add',
  },
];
...

Listing 9-20api/scripts/init.mongo.js: Addition of Description to Sample Issues

您可以使用通常的命令运行这个脚本来初始化数据库,以便描述与您的测试和本章中的屏幕截图相匹配:

$ mongo issuetracker api/scripts/init.mongo.js

如果运行该脚本,您可能必须从主页链接开始,因为它可能已经删除了一些您手动创建的问题。否则,如果 UI 引用这些问题,您可能会得到一个 GraphQL 错误,大意是Query.issue不能为 null。

现在,我们可以实现IssueDetail组件了。作为该组件的一部分,我们将执行以下操作:

  1. 我们将维护状态,其中将包含一个问题对象。

  2. 像在IssueEdit组件中一样,将从match.params.id中检索发布对象的 ID。

  3. 问题对象将通过使用fetch() API 的issue GraphQL 查询以一种叫做loadData()的方法提取,并设置为状态。

  4. 方法loadData()将在组件安装后(第一次)或 ID 改变时(在componentDidUpdate()中)被调用。

  5. render()方法中,我们将使用<pre>标签显示描述,以便在显示中保持换行。

在一个名为IssueDetail.jsx的新文件中,组件的完整代码如清单 9-21 所示。

import React from 'react';

import graphQLFetch from './graphQLFetch.js';

export default class IssueDetail extends React.Component {
  constructor() {
    super();
    this.state = { issue: {} };
  }

  componentDidMount() {
    this.loadData();
  }

  componentDidUpdate(prevProps) {
    const { match: { params: { id: prevId } } } = prevProps;
    const { match: { params: { id } } } = this.props;
    if (prevId !== id) {
      this.loadData();
    }
  }

  async loadData() {
    const { match: { params: { id } } } = this.props;
    const query = `query issue($id: Int!) {
      issue (id: $id) {
        id description
      }
    }`;

    const data = await graphQLFetch(query, { id });
    if (data) {
      this.setState({ issue: data.issue });
    } else {
      this.setState({ issue: {} });

    }
  }

  render() {
    const { issue: { description } } = this.state;
    return (
      <div>
        <h3>Description</h3>
        <pre>{description}</pre>
      </div>
    );
  }
}

Listing 9-21ui/src/IssueDetail.jsx: New Component to Show the Description of an Issue

为了将IssueDetail组件集成到IssueList组件中,我们需要添加一条路由,如本节开头所讨论的。但是,不要硬编码/issues,让我们使用父组件中匹配的路径,使用this.props.match.path。这样,即使父路径由于任何原因发生更改,更改也会被隔离到一个位置。

这一变化以及必要的导入如清单 9-22 所示。

...
import URLSearchParams from 'url-search-params';

import { Route } from 'react-router-dom';

...

import IssueAdd from './IssueAdd.jsx';

import IssueDetail from './IssueDetail.jsx';

...

  render() {
    const { issues } = this.state;
    const { match } = this.props;
    ...
        <IssueAdd createIssue={this.createIssue} />
        <hr />
        <Route path={`${match.path}/:id`} component={IssueDetail} />
    ...
  }
...

Listing 9-22ui/src/IssueList.jsx: Changes for Including IssueDetail in a Route

要选择一个问题,让我们在问题列表中的“编辑”链接旁边创建另一个链接。这一次,让我们使用一个NavLink来突出显示所选的问题。理想情况下,我们应该能够通过单击行中的任何位置来进行选择,并且在选择时应该高亮显示整行。但是让我们留到后面的章节,在那里我们将有更好的工具来实现这个效果。NavLink将指向/issues/<id>,其中<id>是所选行中问题的 ID。

此外,为了不丢失 URL 的查询字符串部分,我们必须将当前查询字符串作为搜索属性添加到链接的目标中。但是,要访问当前的查询字符串,我们需要访问当前的位置,由于IssueRow没有显示为Route的一部分,我们必须通过用withRouter包装组件来注入位置。

IssueTable.jsx文件的修改如清单 9-23 所示。

...
import React from 'react';
import { Link, NavLink, withRouter } from 'react-router-dom';
...

function IssueRow({ issue }) {

const IssueRow = withRouter(({ issue, location: { search } }) => {

  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  ...
      <td>{issue.title}</td>
      <td><Link to={`/edit/${issue.id}`}>Edit</Link></td>
      <td>
        <Link to={`/edit/${issue.id}`}>Edit</Link>
        {' | '}
        <NavLink to={selectLocation}>Select</NavLink>
      </td>
    </tr>
  ...

}

});

Listing 9-23ui/src/IssueTable.jsx: Addition of a Link to Select an Issue for Display in the Details Section

如果您现在测试这个应用,您会在每个问题的编辑链接旁边找到一个选择链接。单击此链接应该会更改 URL,以便将问题的 ID 附加到主路径,但在查询字符串(如果有)之前。您应该在有过滤器和没有过滤器的情况下进行尝试,以确保它在两种情况下都有效,并且刷新会继续显示所选问题的描述。

选中 ID 1 问题的页面截图如图 9-6 所示。

img/426054_2_En_9_Chapter/426054_2_En_9_Fig6_HTML.jpg

图 9-6

选定的问题和描述

练习:嵌套布线

  1. 在呈现IssueList时,我们可以不使用Route,而是将问题列表的路由路径定义为/issues/:id,然后将传递 ID 的IssueDetail显示为 props 的一部分。比较获得相同结果的两种方法。有哪些利弊?

本章末尾有答案。

浏览器历史路由

在本章的开始,我们讨论了两种路由——基于散列的和基于浏览器历史的。如果我们自己来做的话,基于散列的路由很容易理解和实现:只需在转换时改变 URL 的锚部分就足够了。此外,服务器必须只返回对/的请求的index.html,而不返回其他的。

但是使用基于散列的路由的缺点是当服务器需要响应不同的 URL 路径时。想象一下在浏览器上点击刷新。当使用基于散列的路由时,浏览器从服务器向/发出请求,而不管#或实际路由路径之后是什么。如果我们必须让服务器以不同的方式处理这种刷新,更好的策略是对不同的路由使用不同的 URL 基础(也就是说,没有#和它后面的内容)。

当我们需要支持对搜索引擎爬虫的响应时,这种需求(从服务器本身对不同的路由做出不同的响应)就出现了。这是因为,对于爬虫找到的每个链接,如果 URL 不同,就会产生一个新的请求。如果跟在#后面的是不同的,爬虫会认为它只是页面中的一个锚点,并且不管路径是什么,只对/发出请求。

为了使我们的应用搜索引擎友好,使用基于浏览器历史的路由是必要的。但这还不是全部,服务器还必须响应整个页面。相反,对于浏览器请求,页面将在浏览器上构建。我们还不会生成要显示的页面,因为实现它相当复杂,它应该有自己的一章。现在,我们将切换到基于浏览器历史的路由,但是假设页面是通过只操纵 DOM 来构造的。

切换到使用这种新路由就像改变import语句并使用BrowserRouter而不是HashRouter一样简单。该组件通过使用 HTML5 历史 API ( pushStatereplaceStatepopState)来保持 UI 与 URL 同步,从而实现路由。

这一变化显示在清单 9-24 的App.jsx中。

...
import ReactDOM from 'react-dom';
import { HashRouter BrowserRouter as Router } from 'react-router-dom';
...

Listing 9-24ui/src/App.jsx: Changes for Using Browser History Based Router

要测试这个变化,就得从原点位置开始,也就是http://localhost:8000。该应用将似乎工作,所有的链接将 Bootstrap 您到正确的页面和视图。此外,您会发现这些 URL 将没有一个#,而对于问题列表页面来说,它们只是简单的 URL,如http://localhost:8000/issues

但是任何视图的刷新都将失败。例如,在“问题列表”页面中,如果刷新浏览器,您将在屏幕上看到以下消息:

Cannot GET /issues

这是因为浏览器中的 URL 当前指向/issues并且浏览器向服务器请求/issues,这不是由 UI 服务器处理的。为了解决这个问题,我们需要对 UI 服务器进行更改,它会为任何未被处理的URL 返回index.html。这可以通过在路径*的所有其他路由之后安装一个快速路由来实现,该路由读取index.html的内容并将其返回。

response对象有一个方便的方法叫做sendFile()。但是出于安全原因,必须指定文件的完整绝对路径——它不接受相对路径。让我们使用内置 Node.js 模块path中的path.resolve()将相对路径转换为绝对路径。对uiserver.js的更改如清单 9-25 所示。

...
require('dotenv').config();

const path = require('path');

...

app.get('/env.js', (req, res) => {
  ...
});

app.get('*', (req, res) => {

  res.sendFile(path.resolve('public/index.html'));

});

...

Listing 9-25ui/uiserver.js: Respond with index.html for All Requests

如果您在做出这一更改后测试应用,您会发现任何页面上的刷新都可以像以前一样工作。测试公共目录中的其他文件是否得到了正确的服务也是一个好主意,特别是,app.bundle.jsvendor.bundle.js

但是在正常的开发模式下,HMR 会提供这些包,而不是让 UI 服务器从公共目录中获取它们。因此,您需要禁用 HMR(通过将环境变量ENABLE_HMR设置为false),使用npm run compile手动编译包,然后启动 UI 服务器。然后,在刷新应用时,您应该看到这些文件被正确地从服务器中检索出来。完成测试后,不要忘记将更改恢复到 HMR。

仍有一项影响 HMR 运作的变革有待完成。Webpack 在output下有一个名为publicPath的配置选项。当使用按需加载或加载图像、文件等外部资源时,这是一个重要的选项。但是到目前为止我们还没有使用它们,没有将它们设置为任何值也不会影响应用的功能。该值默认为空字符串,这意味着与当前页面的位置相同。

原来,当模块发生变化并被 HMR 重新编译时,Webpack 使用publicPath的值来获取模块的更新信息。因此,如果您在某个位置(如/edit/1/issues/1)更改源文件,您会发现 HMR 调用失败。如果你查看开发者工具的网络选项卡,你会发现这些请求返回的是index.html的内容,而不是模块更新信息。

当浏览器指向/issues/issues/1时,您可以通过查看源文件改变时发生的情况来比较这两个请求和响应。在第一种情况下,您将看到对像/f3f397176a7b9c3237cf.hot-update.json这样的资源的请求,它成功了。而在第二种情况下,就会像/edit/f3f397176a7b9c3237cf.hot-update.json一样,失败。这是因为 Webpack 正在向当前位置发出请求相对于。这个请求不能被热的中间件匹配,所以它失败到 catch-all Express route,它返回index.html的内容。

我们在使用基于散列的路由时没有遇到这个问题,因为页面的位置总是/,路由受到 URL 的锚部分的影响。正确的请求应该没有前缀/edit。为了实现这一点,我们必须改变webpack.config.js来设置publicPath配置。对此的更改如清单 9-26 所示。

...
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'public'),
    publicPath: '/',
  },
...

Listing 9-26ui/webpack.config.js: Changes to Add publicPath

经过这次修改后,你会发现 HMR 在应用的任何页面上都能正常工作。

练习:浏览器历史路由

  1. 如果我们在应用中使用href s 而不是Link s 作为超链接,那么过渡到使用BrowserRouter会不会同样简单?还需要做哪些改变?

  2. 现在让我们根据效果来比较使用hrefLink的情况。在Link之外增加一个href,用于在问题表中导航编辑。点击这两个链接,比较发生了什么。(提示:使用开发人员工具的“网络”选项卡来检查网络流量。)

    现在,用HashHistory做同样的对比(注意:你得在href里用/#/ edit/edit不行。)现在,有区别吗?试着解释你所看到的。(记得在练习后还原实验变化。)

本章末尾有答案

摘要

在本章中,您学习了如何实现客户端路由,即根据菜单或导航栏中的链接显示不同的页面。React 路由库对此有所帮助。

您还了解了如何将浏览器中的 URL 与页面中显示的内容连接起来,以及如何使用参数和查询字符串来调整页面内容。正确实现路由是让用户在点击超链接和使用浏览器中的后退/前进按钮时有一种自然感觉的关键。此外,将浏览器中的 URL 连接到页面上的内容不仅有助于我们以有组织的方式思考不同的页面或视图,还可以帮助用户将链接添加到书签中并使用浏览器的刷新按钮。

在下一章,我们将探索如何处理企业应用中一个非常常见的事件:表单,React 方式。当我们这样做时,我们还将通过实现对问题的更新和删除操作来完成对问题对象的 CRUD。

练习答案

练习:简单路由

  1. 如果从Redirect组件中删除了exact属性,您将看到内容部分是空白的,不管单击的是什么超链接。这是因为所有的URL 现在都匹配第一条路由。因为没有为路线定义组件,所以页面是空白的。此外,您还会在控制台中看到一个错误,提示您试图重定向到相同的路由。这是因为即使在导航中,相同的路线(??)也是匹配的。

    通过对路由重新排序,您几乎可以实现所需的行为:重定向可以放在两条路由之后、全部捕获之前。现在,/issues/report路径将与前两条路径匹配,并在那里停止。如果两者都不匹配,那么任何其他路由将匹配/,并将重定向到/issues。这与之前的行为并不完全相同,因为它将总是重定向到/issues,而不是显示未找到的页面。

  2. 如果您将<Switch>替换为<div>,您会发现除了问题列表或报告占位符之外,始终会显示“未找到页面”消息。这是因为匹配不会在第一次匹配时停止,而是向显示所有匹配路线的组件。NotFound组件的路径(空)匹配任何路径,因此总是显示。

  3. URL / #/issues000显示未找到的页面,而/ #/issues/000显示没有exact属性的问题列表,否则显示未找到的页面。这表明非精确路由匹配路径的完整段,每段由/分隔。这不是简单的前缀匹配。

练习:查询参数

  1. 当一个组件的属性改变时,React 会自动调用一个render()。当属性的改变只影响渲染时,我们不需要做任何进一步的工作。

    问题列表中的不同之处在于属性的变化导致了状态的变化。这种变化必须在某个地方被触发,我们选择了生命周期方法componentDidUpdate()来做这件事。最终,即使在问题编辑页面中,当我们在对服务器的异步调用中加载问题细节时,我们也必须实现componentDidUpdate()方法。

  2. 如果不检查新旧属性是否相同,就会导致无限循环。这是因为一个新的状态也被认为是对组件的更新,因此再次调用componentDidUpdate(),这个循环将无休止地继续下去。

  3. 父组件中的任何更改都会触发子组件中的渲染,因为假设父组件的状态也会影响子组件。通常,这不是一个性能问题,因为重新计算的只是虚拟 DOM。由于新旧虚拟 DOM 将是相同的,所以实际的 DOM 将不会被更新。

    对虚拟 DOM 的更新并不昂贵,因为它们只不过是内存中的数据结构。但是,在极少数情况下,特别是当组件层次非常深并且受影响的组件数量非常大时,更新虚拟 DOM 的行为可能需要一些时间。这可以通过挂钩生命周期方法shouldComponentUpdate()并确定渲染是否有保证来优化。

练习:链接

  1. 如果你使用NavLinks,你会发现所有的链接总是高亮显示。那是因为Link只匹配 URL 和链接的路径,并不认为查询字符串是路径的一部分。因为所有链接的路径都是/issues,所以它们总是匹配的。

    与路径参数相比,查询参数不是一个有限集,因此,不鼓励用于导航链接。如果过滤器是导航链接,我们应该像对待主导航栏一样使用路由参数。

  2. NavLink组件的activeClassName属性决定了当链接活动时添加的类。您可以将该属性设置为current值,以获得想要的效果。

练习:程序化导航

  1. 可以使用history.replace()方法,它替换当前的 URL,这样历史记录就没有旧的位置。另一方面,router.push()确保用户可以使用 back 按钮返回到之前的视图。

    当两条路线没有真正不同时,可以使用替换。它类似于 HTTP 重定向,其中请求的内容是相同的,但是在不同的位置可用。在这种情况下,记住第一个位置作为浏览器历史的一部分是没有用的。

  2. 刷新时,下拉菜单重置为默认值All。但是列表是根据下拉列表的前一个选择进行过滤的,这反映在 URL 中作为查询字符串的一部分。我们将在下一章讨论表单时同步下拉列表值和查询字符串。

    如果下拉列表值更改为选择原始状态,开发人员控制台会显示一条警告:

Hash history cannot PUSH the same path; a new entry will not be added to the history stack.

由于路径相同,哈希历史拒绝推送路径,因此组件不会更新。

  1. 包装函数withRouter有点难以理解。其他选项很容易理解,甚至看起来更简单。但是想象一个更加嵌套的层次结构,其中IssueFilterIssueList中不止一层。在这种情况下,history对象必须通过所有中间组件,增加所有这些组件之间的耦合。

    IssueFilter直接操作 URL 减少了耦合,让每个组件处理一个单独的职责。对于IssueFilter,它是一个设置 URL 的任务,对于IssueList,它是一个使用来自 URL 的查询字符串的任务,不管它是如何设置的。

练习:嵌套布线

  1. 这两种方法之间的差别并不大,也可能是一致性的问题。无论如何,Route所做的就是匹配 URL,如果匹配就显示一个组件。因为匹配是作为IssueList的一部分发生的,所以嵌套路由并没有增加多少,至少在这种情况下是这样。因此,显示包装在if条件中的IssueDetail组件(在存在 ID 的情况下)就可以了。

    另一个考虑因素是子组件在层次结构中的嵌套深度。在IssueDetail的情况下,它只有一层深度,从IssueListIssueDetail的 ID 传递非常简单。如果嵌套路由的组件嵌套很深,那么 ID 必须通过多个其他组件传递,所以对于IssueDetail来说,通过路由本身从 URL 获取这个参数可能更容易。

练习:浏览器历史路由

  1. 如果我们没有使用Link s,我们将不得不改变所有的href s 来删除#/前缀。这是使用Link s 与普通href s 相比的一个优势

  2. 当使用BrowserHistory时,href使浏览器导航到另一个 URL,从而向服务器发起请求,获取页面,然后呈现它。相比较而言,Link不会对服务器产生新的请求;它只是在浏览器中更改 URL,并通过替换需要为新路由显示的组件,以编程方式处理这一更改。

    当使用HashHistory时,这两种方法没有明显的不同,因为基本 URL 总是相同的(/)。即使点击href,浏览器也不会向服务器发出新的请求,因为基本 URL 不会改变。*

十、React 表单

用户输入是任何 web 应用的重要组成部分,问题跟踪器应用也不例外。我们创建了一个表单和用户输入来创建一个新问题。但是它是非常初级的,并且它没有演示在 React 中应该如何处理表单。

在这一章,我们将开始接受更多的用户输入。我们将把硬编码的过滤器转换成更灵活的用户输入,然后用一个表单填充编辑页面。最后,我们将添加从问题列表页面删除问题的功能,尽管这不一定是一个表单。

为了能够做到这一切,我们还必须修改后端 API 来支持这些功能。我们将修改 List API 以获得更多的过滤器选项,并且我们将创建新的更新和删除 API。因此,我们将完成所有 CRUD 操作的实现。

受控组件

声明式编程面临的挑战之一是表单输入中的用户交互,尤其是当表单输入包含用于显示模型初始值的值时。如果像传统的 HTML 代码一样,一个文本的值<input>被设置为一个字符串,这意味着这个值总是那个字符串,因为它是这样声明的。另一方面,如果允许用户编辑来更改该值,任何重新渲染都会破坏用户更改。

我们在使用IssueAdd组件和表单时没有这个问题,因为表单没有任何初始值,也就是说,它只执行接收用户输入的功能。因此,组件的内部状态可能不受其父组件IssueAdd的控制。每当需要输入值时,例如当用户点击Add按钮时,可以通过使用传统的 HTML 函数查看其值来确定。

为了能够在输入中显示一个值,它必须由父 Node 通过状态变量或 props 变量控制。这可以通过将输入值设置为 state 或 props 变量来实现。因此,输入将直接反映该值,呈现表单的 React 组件也将控制表单中随后的用户输入会发生什么。其值由 React 以这种方式控制的输入表单元素被称为受控组件

如果您还记得,在上一章中,我们推迟了下拉列表中当前活动过滤器的显示。我们现在开始吧。让我们将下拉列表的值设置为状态过滤器的值。为此,我们将使用URLSearchParams并在IssueFilter组件的render()期间提取其当前值。清单 10-1 中显示了对此的更改。

...
import React from 'react';

import URLSearchParams from 'url-search-params';

...
  render() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    return (
      ...
       <select value={params.get('status') || ''} onChange={this.onChangeStatus}>
...

Listing 10-1.ui/src/IssueFilter.jsx: Status Filter as a Controlled Component

此时,如果您测试应用,您会发现,与以前不同,刷新显示的是过滤器的当前值,而不是默认值All

表单中的受控组件

状态过滤器现在是一个简单的受控组件,但这并不适用于所有情况。现在让我们添加更多的过滤器(我们很快就会添加)。我们将需要一个过滤器的表单,我们将让用户进行更改,然后使用应用按钮应用它们。让我们从添加一个带有应用处理程序的应用按钮开始。

...
        <select value={params.get('status') || ''} onChange={this.onChangeStatus}>
          ...
        </select>
        {' '}
        <button type="button" onClick={this.applyFilter}>Apply</button>
...

现在,在onChangeStatus方法中,我们需要删除将新过滤器推入历史的代码,因为这将是applyFilter方法的一部分:

...
  onChangeStatus(e) {
    const status = e.target.value;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : '',
    });
  }
...

此时(您可以忽略 ESLint 错误,因为我们将很快填充该方法所需的代码),如果您测试应用,您会发现您不能更改下拉列表的值!这是因为 select 的值仍然是原始值。在该值更改之前,下拉列表无法显示新的状态。

许多其他框架(例如 Angular)提供的解决方案是开箱即用的双向绑定。组件不仅绑定到状态中的值,反之亦然。任何用户输入也会自动改变状态变量。

但是在 React 中,单向数据流很重要,它不支持双向绑定作为库的一部分。为了让用户的更改流回表单输入组件,必须在输入中设置新值。为了获得新的值,必须捕获onChange()事件,它将事件作为参数,作为事件的一部分,我们可以获得用户选择的新值。

这也意味着,我们需要的不是 URL 参数,而是输入值的存储,可以更改它以反映下拉列表中的新值。州是储存这种价值的理想场所。当用户更改值时,可以使用onChange()事件处理程序中的setState()用新值更新状态变量,以便它作为输入值反映回来。

让我们首先创建这个状态变量,并在构造函数中将其初始化为 URL 值。

...
  constructor({ location: { search } }) {
    super();
    const params = new URLSearchParams(search);
    this.state = {
      status: params.get('status') || “,
    };

    this.onChangeStatus = this.onChangeStatus.bind(this);
  }
...

然后,让我们使用这个状态变量作为在render()期间下拉输入的值。

...
  render() {
    const { status } = this.state;
    ...
        <select value={status} onChange={this.onChangeStatus}>
...

作为处理onChange的一部分,我们可以将状态变量设置为新值,作为事件参数的一部分提供给处理程序,如event.target.value

...
  onChangeStatus(e) {
    this.setState({ status: e.target.value });
  }
...

现在,您会发现您可以更改下拉列表的值。更重要的是,值总是作为状态的一部分可用,所以要访问当前值,我们需要做的就是访问this.state.status。让我们在applyFilter中这样做,并使用历史来推送新的状态过滤器(我们从下拉菜单的onChange处理程序中移除了它),然后在构造函数中将这个新方法绑定到this

...
  constructor({ location: { search } }) {
    ...
    this.onChangeStatus = this.onChangeStatus.bind(this);
    this.applyFilter = this.applyFilter.bind(this);
  }
...

  applyFilter() {
    const { status } = this.state;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : “,
    });
  }
...

此时,您会发现 Apply 按钮的工作原理是更改 URL,从而应用一个新的过滤器。但是还有一个小问题。当应用一个过滤器并通过Link改变过滤器时,例如通过点击导航栏中的问题列表,新的过滤器不会被反映。这是因为当链接被点击时,只有组件的属性被改变。不会再次构造该组件,也不会修改其状态。

为了让新的过滤器反映链接被点击的时间,我们需要挂钩到一个生命周期方法,告诉我们一个属性已经改变,并再次显示过滤器。我们将使用与之前相同的方法来寻找属性的变化:componentDidUpdate。而且,显示过滤器只需要根据搜索参数在状态中设置新值,就像在构造函数中一样。

...
  constructor() {
    ...
  }

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.showOriginalFilter();
    }
  }

  onChangeStatus(e) {
    ...
  }

  showOriginalFilter() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    this.setState({
      status: params.get('status') || “,
    });
  }
...

最后,让我们也指出用户已经选择了一个新的过滤器,但是还没有应用新的过滤器的状态之间的区别。同时,让我们给用户一个重置过滤器的选项,以便用户可以看到显示的列表正在使用的原始过滤器。我们可以通过添加一个Reset按钮来做到这一点,当有任何更改时,该按钮将被启用,单击Reset按钮,将显示原始的过滤器。为此,我们需要引入一个名为changed的状态变量,我们将基于这个变量禁用按钮。

...
  render() {
    const { status, changed } = this.state;
    return (
      ...
        <button type="button" onClick={this.applyFilter}>Apply</button>
        {' '}
        <button
          type="button"
          onClick={this.showOriginalFilter}
          disabled={!changed}
        >
          Reset
        </button>
...

状态变量需要在onChange内设置为true,在构造函数中设置为false,当原始过滤器通过重置再次显示时。此外,由于方法showOriginalFilter现在是从一个事件中调用的,我们必须将它绑定到this

...
  constructor({ location: { search } }) {
    ...
    this.state = {
      status: params.get('status') || “,
      changed: false,
    };
    ...
    this.showOriginalFilter = this.showOriginalFilter.bind(this);
  }
  ...

  onChangeStatus(e) {
    this.setState({ status: e.target.value, changed: true });
  }
  ...

  showOriginalFilter() {
    ...
    this.setState({
      status: params.get('status') || “,
      changed: false,
    });
  }

所有这些变化都显示在清单 10-2 中。

...

  constructor({ location: { search } }) {
    super();
    const params = new URLSearchParams(search);
    this.state = {
      status: params.get('status') || “,
      changed: false

,
    };

    this.onChangeStatus = this.onChangeStatus.bind(this);
    this.applyFilter = this.applyFilter.bind(this);
    this.showOriginalFilter = this.showOriginalFilter.bind(this);
  }
...

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.showOriginalFilter();
    }
  }

  onChangeStatus(e) {
    const status = e.target.value;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : “,
    });
    this.setState({ status: e.target.value, changed: true });
  }

  showOriginalFilter() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    this.setState({
      status: params.get('status') || “,
      changed: false,
    });
  }

  applyFilter() {
    const { status } = this.state;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : “,
    });
  }

  render() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    const { status, changed } = this.state;
    return (
        ...
        <select value={params.get('status') || “} onChange={this.onChangeStatus}>
        <select value={status} onChange={this.onChangeStatus}>
          ...
        </select>
        {' '}
        <button type="button" onClick={this.applyFilter}>Apply</button>
        {' '}
        <button
          type="button"
          onClick={this.showOriginalFilter}
          disabled={!changed}
        >
          Reset
        </button>
      </div>
    );
...

Listing 10-2.ui/src/IssueFilter.jsx: Changes for Using Controlled Components in Forms

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,总是依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试的和最新的源代码,尤其是当某些东西不能按预期工作时。

通过这些更改,当您测试应用时,您会发现只有在单击 Apply 按钮时才会应用过滤器(而不是在下拉列表中选择新值)。此外,刷新浏览器将保留正在显示的过滤器(如果有),并且当对过滤器进行任何更改时,重置按钮将被启用。您可以通过点按“重设”按钮恢复到原始过滤器。图 10-1 显示了新更改的过滤表单的截图。

img/426054_2_En_10_Chapter/426054_2_En_10_Fig1_HTML.jpg

图 10-1。

新的过滤器表单

更多过滤器

现在我们有了过滤器的表单,我们可以添加更多的方法来过滤问题列表。实际应用中一个有用的过滤器是 Assignee 字段上的过滤器。但是从学习表单的角度来看,这并不有趣,因为它是一个文本字段,非常简单——我们必须添加一个文本输入,在它的onChange中,我们必须更新一个状态变量,并在过滤器中使用它。

一个更有趣的过滤字段是非文本字段,这不是那么简单。因此,让我们在“工作”字段上添加一个过滤器,因为这是一个数字。为此,我们需要两个字段,一个最小值和一个最大值来过滤,这两个值都是可选的。首先,让我们更改 API 来实现这个过滤器,并使用 Playground 测试它。

让我们首先改变模式,向issueList API 添加两个参数,都是整数,称为effortMineffortMaxschema.graphql的变更如清单 10-3 所示。

type Query {
  about: String!
  issueList(status: StatusType): [Issue!]!
  issueList(
    status: StatusType
    effortMin: Int
    effortMax: Int
  ): [Issue!]!
  issue(id: Int!): Issue!
}

Listing 10-3.api/schema.graphql: Changes for More Filter Options

处理新值不像处理状态那样简单,因为我们必须检查大于和小于,而不是相等比较。MongoDB 过滤器的effort属性只有在两个选项都存在的情况下才能被创建,然后$gte$lte选项必须被设置,如果没有定义的话。issue.js的变更如清单 10-4 所示。

async function list(_, { status, effortMin, effortMax }) {
  ...
  if (status) filter.status = status;

  if (effortMin !== undefined || effortMax !== undefined) {
    filter.effort = {};
    if (effortMin !== undefined) filter.effort.$gte = effortMin;
    if (effortMax !== undefined) filter.effort.$lte = effortMax;
  }
  ...
}

Listing 10-4.api/issue.js: Changes for Filter on Effort

为了测试issueList API 中的新过滤器,您可以使用带有命名查询的 Playground,如下所示:

query issueList(
  $status: StatusType
  $effortMin: Int
  $effortMax: Int
) {
  issueList(
    status: $status
    effortMin: $effortMin
    effortMax: $effortMax
  ) {
    id
    title
    status
    owner
    effort
  }
}

您可以在底部的查询变量部分为effortMineffortMax给出不同的值来测试它。

练习:更多过滤器

  1. 使用问题跟踪器应用中的添加表单添加一些问题。现在,如果你在操场上运行一个以effortMin为 0 的查询,你会发现添加的文档并没有返回。对于effortMin的任何值都是如此。为什么?

  2. 如果您希望返回所有未定义工作的文档,而不管查询是什么,您将如何修改过滤器?提示:在 https://docs.mongodb.com/manual/reference/operator/query/or 查找 MongoDB $or查询操作符。

本章末尾有答案。

打字输入

在本节中,我们将更改 UI,为工作过滤器添加两个输入。由于这两个输入只需要接受数字,我们将为用户的击键添加一个过滤器,以便只接受数字。

但在此之前,让我们修改IssueList,以便在加载数据时使用新的过滤器值。更改包括从 URL 的搜索参数中获取两个额外的过滤器参数,并在修改后的 GraphQL 查询中使用它们来获取问题列表。因为来自 URL 的值是字符串,所以必须使用parseInt()将它们转换成整数值。对此的更改如清单 10-5 所示。

...
  async loadData() {
    ...
    if (params.get('status')) vars.status = params.get('status');

    const effortMin = parseInt(params.get('effortMin'), 10);
    if (!Number.isNaN(effortMin)) vars.effortMin = effortMin;
    const effortMax = parseInt(params.get('effortMax'), 10);
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList($status: StatusType) {
    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int

    ) {
      issueList (status: $status) {
      issueList(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
      ...
      }
    }`;
...

Listing 10-5.ui/src/IssueList.jsx: Using Effort Filters in Issue List

此时,您可以通过在 URL 中键入过滤器参数来测试这些更改的效果。下一步是为IssueFilter组件中的新过滤字段的输入添加两个状态变量。就这么办吧。

...
  constructor({ location: { search } }) {
    ...
    this.state = {
      status: params.get('status') || “,
      effortMin: params.get('effortMin') || “,
      effortMax: params.get('effortMax') || “,
      changed: false,
    };
    ...
  }
...

同时我们也在showOriginalFilter里加上这些,也是类似的改动。(这些变化微不足道,为了简洁起见,这里没有突出显示。如果需要,参考清单 10-6 。)注意,我们在状态中使用字符串来表示这些值,它们实际上是数字。它给我们带来的便利是,当操作或读取 URL 参数时,我们不需要在数字和字符串之间进行转换。

现在,让我们在过滤器表单中为这些值添加输入字段。我们将在IssueFilter组件的状态下拉列表后添加两个文本类型的<input>字段。我们将使用状态中相应变量的值。我们还将为这两个onChange方法设置onChange处理程序。

...
  render() {
    const { status, changed } = this.state;
    const { effortMin, effortMax } = this.state;
    return (
      ...
        <select value={status} onChange={this.onChangeStatus}>
          ...
        </select>
        {' '}
        Effort between:
        {' '}
        <input
          size={5}
          value={effortMin}
          onChange={this.onChangeEffortMin}
        />
        {' - '}
        <input
          size={5}
          value={effortMax}
          onChange={this.onChangeEffortMax}
        />
        ...
...

到目前为止,这些变化与我们对 status 下拉菜单所做的非常相似。但是这是一个数字输入,我们必须验证这个值确实是一个数字。相反,让我们防止用户输入非数字字符。通常,我们会在onChange处理程序中将状态变量设置为event.target.value。相反,我们将测试结果文本是否可以转换为数字,如果可以,我们将放弃更改,而不是设置状态变量。这里是用于effortMin字段的onChange处理程序,它通过使用正则表达式来匹配只包含数字字符的输入(\d)来实现这一点。

...
  onChangeEffortMin(e) {
    const effortString = e.target.value;
    if (effortString.match(/^\d*$/)) {
      this.setState({ effortMin: e.target.value, changed: true });
    }
  }
...

让我们为onChangeEffortMax添加一个类似的处理程序,并将这些方法绑定到构造函数中的this。(参考清单 10-6 了解这一简单变化。)

最后,我们可以使用applyFilter中的状态变量来设置历史中的新位置。因为有更多的变量,所以让我们使用URLSearchParams来构造查询字符串,而不是使用普通的字符串模板。

...
  applyFilter() {
    const { status, effortMin, effortMax } = this.state;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : “,
    });

    const params = new URLSearchParams();
    if (status) params.set('status', status);
    if (effortMin) params.set('effortMin', effortMin);
    if (effortMax) params.set('effortMax', effortMax);

    const search = params.toString() ? `?${params.toString()}` : “;
    history.push({ pathname: '/issues', search });
  }
...

在清单 10-6 中显示了在IssueFilter组件中添加这两个过滤字段的一整套更改。

...
  constructor({ location: { search } }) {
    ...
      status: params.get('status') || “,
      effortMin: params.get('effortMin') || “,
      effortMax: params.get('effortMax') || “,
    ...
    this.onChangeStatus = this.onChangeStatus.bind(this);
    this.onChangeEffortMin = this.onChangeEffortMin.bind(this);
    this.onChangeEffortMax = this.onChangeEffortMax.bind(this);
  }
...

  onChangeStatus(e) {
    ...
  }

  onChangeEffortMin(e) {
    const effortString = e.target.value;
    if (effortString.match(/^\d*$/)) {
      this.setState({ effortMin: e.target.value, changed: true });
    }
  }

  onChangeEffortMax(e) {
    const effortString = e.target.value

;
    if (effortString.match(/^\d*$/)) {
      this.setState({ effortMax: e.target.value, changed: true });
    }
  }
...

  showOriginalFilter() {
    ...
      status: params.get('status') || “,
      effortMin: params.get('effortMin') || “,
      effortMax: params.get('effortMax') || “,
    ...
  }

  applyFilter() {
    const { status, effortMin, effortMax } = this.state;
    const { history } = this.props;
    history.push({
      pathname: '/issues',
      search: status ? `?status=${status}` : “,
    });

    const params = new URLSearchParams();
    if (status) params.set('status', status);
    if (effortMin) params.set('effortMin', effortMin);
    if (effortMax) params.set('effortMax', effortMax);

    const search = params.toString() ? `?${params.toString()}` : “;
    history.push({ pathname: '/issues', search });
  }

  render() {
    const { status, changed } = this.state;
    const { effortMin, effortMax } = this.state

;
    ...
        </select>
        {' '}
        Effort between:
        {' '}
        <input
          size={5}
          value={effortMin}
          onChange={this.onChangeEffortMin}
        />
        {' - '}
        <input
          size={5}
          value={effortMax}
          onChange={this.onChangeEffortMax}
        />
    ...
  }
...

Listing 10-6.ui/src/IssueFilter.jsx: Changes for Adding Effort Filters

有了这组更改,您将能够使用各种过滤器组合来测试应用。URL 栏中也应该可以看到过滤器值。要清除过滤器,您可以单击导航栏中的问题列表链接。图 10-2 中显示了应用了最大努力 10 点的新滤镜的截图。

img/426054_2_En_10_Chapter/426054_2_En_10_Fig2_HTML.jpg

图 10-2。

显示工作过滤器的问题列表

练习:打字输入

  1. 假设我们不将effortMineffortMax的值转换成整数,也就是说,我们使用 GraphQL 查询变量中的字符串值。你预计会发生什么?试试看,确认你的答案。

  2. 尝试使用<input type="number">而不是默认的文本类型。在不同的浏览器上测试,比如 Chrome、Safari 和 Firefox。键入可能有效的数字字符,如。(点)和 e .你观察到了什么?为什么呢?提示:在变更处理程序中添加一些console.log语句,并观察日志。

本章末尾有答案。

编辑表单

我们一直有一个编辑页面的占位符。现在您已经了解了组件,尤其是受控组件,让我们试着为IssueEdit.jsx中的编辑页面创建一个完整的表单,在用户可以更改的输入字段中显示问题的所有字段。我们还会有一个提交按钮,但是我们还不会处理表单的提交。在我们实现了一个 API 来更新一个现有的问题之后,我们将把这个问题留给下面的部分。

让我们从定义这个组件的状态开始。至少,我们需要存储每个输入的当前值,它对应于正在编辑的问题的字段。在构造函数中,让我们为问题定义一个空对象。(为了简洁起见,我省略了显而易见的新代码,比如 imports。请参考下面的清单,了解完整的更改。下面的代码片段是用来解释的。)

...
export default class IssueEdit extends React.Component {
  constructor() {
    super();
    this.state = {
      issue: {},
    };
...

我们将用从服务器获取的问题替换空问题。让我们在一个名为loadData()的方法中使用issue API 来异步加载数据。对此的 GraphQL 查询很简单;它接受 ID 作为参数(来自 props ),并指定需要返回所有可能的字段。

...
  async loadData() {
    const query = `query issue($id: Int!) {
      issue(id: $id) {
        id title status owner
        effort created due description
      }
    }`;

    const { match: { params: { id } } } = this.props;
    const data = await graphQLFetch(query, { id });
}
...

但是因为所有输入字段的内容都是字符串,所以状态字段也需要是字符串。我们不能直接使用 API 调用的结果。因此,在加载数据后,我们必须将问题字段的自然数据类型转换为字符串。此外,我们需要为所有可选字段添加一个null检查,并使用空字符串作为值。如果 API 失败(由空的data表示),我们将在状态中使用一个空的问题。

  ...
    if (data) {
      const { issue } = data;
      issue.due = issue.due ? issue.due.toDateString() : “;
      issue.effort = issue.effort != null ? issue.effort.toString() : “;
      issue.owner = issue.owner != null ? issue.owner : “;
      issue.description = issue.description != null ? issue.description : “;
      this.setState({ issue });
    } else {
      this.setState({ issue: {});
    }
  }
...

现在,我们可以编写render()方法了。在这种方法中,每个字段的值都可以设置为状态中的相应值。例如,owner输入字段看起来像这样:

...
  const { issue: { owner } } = this.state;
  <input value={owner} />
...

但是在组件已经被构造并且loadData()已经返回数据的期间,我们将发布对象作为一个空对象。如果给定 ID 的问题不存在,也是如此。为了处理这两种情况,让我们检查问题对象中是否存在一个id字段,并避免呈现表单。如果 props 中的id字段无效,我们将显示一条错误消息,指出该 id 不存在任何问题。如果没有,我们将假设页面正在完成loadData(),并在render()方法中返回 null。

...
    const { issue: { id } } = this.state;
    const { match: { params: { id: propsId } } } = this.props;
    if (id == null) {
      if (propsId != null) {
        return <h3>{`Issue with ID ${propsId} not found.`}</h3>;
      }
      return null;
    }
...

如果这两个条件不匹配,我们可以呈现表单。请注意,我们使用的是二倍等于而不是三倍等于,它匹配任何看起来像 null 的东西,包括未定义的。我们将使用一个有两列的表,在第一列显示字段名的标签,输入(或 ID 和创建日期的只读标签)作为值。除了所有字段之外,该表单还需要一个提交按钮和一个提交处理程序,我们将命名为handleSubmit()

此外,为了测试在加载新的问题对象时表单是否正常工作,而无需转到另一个页面或手动更改 URL,让我们添加上一个和下一个问题对象的链接。因为这只是一个测试,当下一个或上一个 id 不是有效 ID 时,我们不会禁用链接;我们将让页面显示一个错误。这些链接可以添加到表单的末尾。

...
    return (
      <form onSubmit={this.handleSubmit}>
        <h3>{`Editing issue: ${id}`}</h3>
        <table>
          <tbody>
            <tr>
              <td>Created:</td>
              <td>{created.toDateString()}</td>
            </tr>

            ...

            <tr>
              <td />
              <td><button type="submit">Submit</button></td>
            </tr>
          </tbody>
        </table>
        <Link to={`/edit/${id - 1}`}>Prev</Link>
        {' | '}
        <Link to={`/edit/${id + 1}`}>Next</Link>
      </form>
    );
...

与过滤器表单一样,我们还需要一个针对每个输入的onChange事件处理程序。但是由于这个表单中的字段数量太多,这可能会变得单调乏味和重复。相反,让我们利用事件的目标有一个名为name的属性,它将反映表单中输入字段的名称。让我们使用问题对象中的字段名称作为输入的名称。然后,让我们为所有的输入使用一个通用的onChange事件处理程序。例如,owner输入行现在看起来像这样:

...
            <tr>
              <td>Owner:</td>
              <td>
                <input
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                />
              </td>
            </tr>
...

对于其余的输入字段,请参考清单 10-7 。

并且,在onChange()方法中,我们将从事件的目标中获取字段的名称,用它来设置issue对象中的属性值(需要一个来自当前状态的副本),并设置新的状态。注意,不建议在到达新状态时直接使用this.state,因为当其他setState调用已经发出但尚未生效时,它可能无法准确反映真实的当前值。推荐的方法是给setState方法提供一个回调,该方法接受前一个状态并返回一个新状态。下面是考虑到这些问题的onChange()事件处理程序。

...
  onChange(event) {
    const { name, value } = event.target;
    this.setState(prevState => ({
      issue: { ...prevState.issue, [name]: value },
    }));
  }
...

注意

我们使用 ES2015+ spread操作符...传播发布对象的值,就好像它们都是单独提到的,像{ id: prevState.issue.id, title: prevState.issue.title }等。与Object.assign()相比,这是一种更简单的复制对象的方式。然后用name作为变量名的的属性来覆盖被扩展的属性。

最后,我们需要生命周期方法componentDidMount()componentDidUpdate()来加载数据。同样,在handleSubmit()方法中,我们现在只在控制台上显示问题的内容。清单 10-7 显示了IssueEdit文件的完整列表,包括这些额外的方法和一些输入字段的修饰属性。为了简洁起见,没有显示删除的代码。

import React from 'react';
import { Link } from 'react-router-dom';

import graphQLFetch from './graphQLFetch.js';

export default class IssueEdit extends React.Component {
  constructor() {
    super();
    this.state = {
      issue: {},
    };
    this.onChange = this.onChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  componentDidMount() {
    this.loadData();
  }

  componentDidUpdate(prevProps) {
    const { match: { params: { id: prevId } } } = prevProps;
    const { match: { params: { id } } } = this.props;
    if (id !== prevId) {
      this.loadData();
    }
  }

  onChange(event) {
    const { name, value } = event.target;
    this.setState(prevState => ({
      issue: { ...prevState.issue, [name]: value },
    }));
  }

  handleSubmit(e) {
    e.preventDefault();
    const { issue } = this.state;
    console.log(issue); // eslint-disable-line no-console
  }

  async loadData() {
    const query = `query issue($id: Int!) {
      issue(id: $id) {
        id title status owner

        effort created due description
      }
    }`;

    const { match: { params: { id } } } = this.props;
    const data = await graphQLFetch(query, { id });
    if (data) {
      const { issue } = data;
      issue.due = issue.due ? issue.due.toDateString() : “;
      issue.effort = issue.effort != null ? issue.effort.toString() : “;
      issue.owner = issue.owner != null ? issue.owner : “;
      issue.description = issue.description != null ? issue.description : “;
      this.setState({ issue });
    } else {
      this.setState({ issue: {} });
    }
  }

  render() {
    const { issue: { id } } = this.state;
    const { match: { params: { id: propsId } } } = this.props;
    if (id == null) {
      if (propsId != null) {
        return <h3>{`Issue with ID ${propsId} not found.`}</h3>;
      }
      return null;
    }

    const { issue: { title, status } } = this.state;
    const { issue: { owner, effort, description } } = this.state;
    const { issue: { created, due } } = this.state;

    return (
      <form onSubmit={this.handleSubmit}>
        <h3>{`Editing issue: ${id}`}</h3>
        <table>
          <tbody>
            <tr>
              <td>Created:</td>
              <td>{created.toDateString()}</td>
            </tr>
            <tr>
              <td>Status:</td>

              <td>
                <select name="status" value={status} onChange={this.onChange}>
                  <option value="New">New</option>
                  <option value="Assigned">Assigned</option>
                  <option value="Fixed">Fixed</option>
                  <option value="Closed">Closed</option>
                </select>
              </td>
            </tr>
            <tr>
              <td>Owner:</td>
              <td>
                <input
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                />
              </td>
            </tr>
            <tr>
              <td>Effort:</td>
              <td>
                <input
                  name="effort"
                  value={effort}
                  onChange={this.onChange}
                />
              </td>
            </tr>
            <tr>
              <td>Due:</td>
              <td>
                <input
                  name="due"
                  value={due}
                  onChange={this.onChange}
                />
              </td>

            </tr>
            <tr>
              <td>Title:</td>
              <td>
                <input
                  size={50}
                  name="title"
                  value={title}
                  onChange={this.onChange}
                />
              </td>
            </tr>
            <tr>
              <td>Description:</td>
              <td>
                <textarea
                  rows={8}
                  cols={50}
                  name="description"
                  value={description}
                  onChange={this.onChange}
                />
              </td>
            </tr>
            <tr>
              <td />
              <td><button type="submit">Submit</button></td>
            </tr>
          </tbody>
        </table>
        <Link to={`/edit/${id - 1}`}>Prev</Link>
        {' | '}
        <Link to={`/edit/${id + 1}`}>Next</Link>

      </form>
    );
  }
}

Listing 10-7.ui/src/IssueEdit.jsx: New Contents for Showing an Edit Form

现在可以通过单击问题列表页面中任何问题的编辑链接来测试编辑页面。您可以看到字段值反映了数据库中保存的内容。单击 Submit 将在控制台上显示编辑后的值,但这些值将是字符串而不是自然数据类型。编辑页面截图如图 10-3 所示。

img/426054_2_En_10_Chapter/426054_2_En_10_Fig3_HTML.jpg

图 10-3。

编辑页面

练习:编辑页面

  1. 如果我们不将字符串字段中的空值转换为空字符串,会发生什么?通过删除检查 null 并为 description 字段分配一个空字符串的那一行,自己尝试一下。对缺少描述字段而不是值(甚至是空字符串)的问题执行此操作。

本章末尾有答案。

专用输入组件

虽然我们通过将所有的onChange()处理程序合并到一个处理程序中节省了一些重复的代码,但是很明显这种方法还有改进的余地。

  • 在处理非字符串数据类型时,当需要验证值时(例如,检查完成日期是否在今天之前),必须将其转换为自然数据类型。在将修改后的问题发送到服务器之前,需要进行相同的转换。

  • 如果有多个相同类型(数字或日期)的输入,则需要重复每个输入的转换。

  • 输入允许用户输入任何内容,并且不会拒绝无效的数字或日期。我们已经发现 HTML5 输入类型没有多大帮助,而且由于onChange处理程序是一个通用的处理程序,您不能为不同的输入类型添加掩码。

理想情况下,我们希望表单的状态以自然数据类型(数字、日期等)存储字段。).我们还希望共享所有的数据类型转换例程。解决所有这些问题的一个好办法是为非字符串输入制作可重用的 UI 组件,这些组件在其onChange处理程序中发出自然数据类型。我们可以很好地使用一些很棒的包,比如提供这些 UI 组件的 react-numeric-input 和 react-datepicker。但是为了理解如何构建这些 UI 组件,让我们创建我们自己的极简组件。

我们将首先为数字创建一个简单的 UI 组件,进行简单的验证和转换。然后,我们将为日期创建一个更复杂的 UI 组件,它能做更多的事情,比如让用户知道值是否以及何时无效。

在所有这些组件中,我们将采用分离状态的方法——在这种情况下,只要用户没有编辑该组件,该组件就是受控的,它唯一的功能就是显示当前值。当用户开始编辑时,我们将使它成为不受控制的组件。在这种状态下,父对象中的值将不会更新,并且两个值(当前值和已编辑值)将会分离。一旦用户完成编辑,如果值有效,这两个值将同步返回。

从另一个角度来看,专门化的组件是不受控制的,但是实际的<input>元素是受控制的。也就是说,在专门化的组件中,我们将有一个状态变量来控制输入元素中的值。这种方法有助于我们处理暂时无效的值,在许多情况下,从一个有效值转换到另一个有效值时需要用到这些值。对于简单的数字,这种需求可能不是那么明显。但是当你不得不处理像小数和日期这样的数据类型时,就会出现用户还没打完,中间值无效的情况。如果用户在输入被判断为有效或无效之前完成输入,这对提高可用性有很大帮助。

数字输入

我们将创建的第一个专用输入组件是用于数字输入的。我们将在编辑页面中使用它来代替普通的<input>元素。让我们称这个组件为NumInput,并在ui/src/目录下使用一个名为NumInput.jsx的新文件。

让我们首先定义接收字符串并转换为数字的转换函数,反之亦然。作为其中的一部分,我们将使用一个空字符串来对应数字的空值。

...
function format(num) {
  return num != null ? num.toString() : “;
}

function unformat(str) {
  const val = parseInt(str, 10);
  return Number.isNaN(val) ? null : val;
}
...

unformat()函数中,如果字符串不代表数字,我们返回 null。因为我们将检查用户击键中的有效字符,所以只有当字符串为空时才会出现非数字输入,所以这已经足够好了。

接下来,在组件的构造函数中,在将作为 props 传入的值转换为字符串之后,让我们设置一个状态变量(我们将使用它作为<input>元素的值)。

...
  constructor(props) {
    ...
    this.state = { value: format(props.value) };
  }
...

在输入的onChange()中,我们将检查包含有效数字的输入,如果是,则设置状态,就像我们在过滤器表单中所做的那样。

...
  onChange(e) {
    if (e.target.value.match(/^\d*$/)) {
      this.setState({ value: e.target.value });
    }
  }
...

为了使更改在父对象中生效,我们必须调用父对象的onChange()。我们不会将此作为组件的onChange()方法的一部分;相反,当输入失去焦点时,我们将调用父 Node 的onChange()。input 元素的onBlur()属性可以用来处理焦点丢失。在调用父 Node 的onChange()时,我们将自然数据类型中的值作为第二个参数传递。这样,如果需要的话,我们可以让父 Node 处理onChange()的原始事件(第一个参数)。

...
  onBlur(e) {
    const { onChange } = this.props;
    const { value } = this.state;
    onChange(e, unformat(value));
  }
...

render()方法中,我们将只呈现一个值被设置为状态变量的<input>元素以及组件类的onChange()onBlur()处理程序。这些方法必须绑定到构造函数中的this,因为它们是事件处理程序。此外,我们将复制父元素可能想要作为实际的<input>元素的props的一部分提供的所有其他属性(例如,size属性)。让我们使用 spread 操作符无缝地做到这一点,使用语法{...this.props}

清单 10-8 中这个新文件的完整清单显示了render()功能以及onBlur()onChange()的绑定。

import React from 'react';

function format(num) {
  return num != null ? num.toString() : “;
}

function unformat(str) {
  const val = parseInt(str, 10);
  return Number.isNaN(val) ? null : val;
}

export default class NumInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: format(props.value) };
    this.onBlur = this.onBlur.bind(this);
    this.onChange = this.onChange.bind(this);
  }

  onChange(e) {
    if (e.target.value.match(/^\d*$/)) {
      this.setState({ value: e.target.value });
    }
  }

  onBlur(e) {
    const { onChange } = this.props;
    const { value } = this.state;
    onChange(e, unformat(value));
  }

  render() {
    const { value } = this.state;
    return (
      <input
        type="text"
        {...this.props}
        value={value}
        onBlur={this.onBlur}
        onChange={this.onChange}

      />
    );
  }
}

Listing 10-8.ui/src/NumInput.jsx: New Specialized Input Component for Numbers

现在,让我们在问题编辑页面中使用这个新的 UI 组件。第一个变化是在IssueEdit组件中将<input>替换为<NumInput>

...
              <td>Effort:</td>
              <td>
                <input NumInput
...

然后,让我们更改onChange()处理程序,以包含组件可能作为第二个参数发送给我们的自然数据类型中的值。但是我们也有常规的 HTML 组件,它们不会提供第二个参数作为自然值。因此,需要检查是否提供了该值。如果没有提供,我们可以使用事件本身的字符串值。

...
  onChange(event, naturalValue) {
    const { name, value: textValue } = event.target;
    const value = naturalValue === undefined ? textValue : naturalValue;
    ...
  }
...

经过这些更改后,如果您测试应用,只要您从问题列表导航到任何编辑页面,它似乎都可以正常工作。但是,如果您使用“下一个/上一个”按钮,您会发现“工作”字段中的值不会改变,相反,它会保留原始问题的工作值。如果显示的第一个问题没有结果,它将保持空白。

这是因为在构造组件时,我们只将 props 中的值复制到NumInput组件的状态。此后,当使用下一个/上一个按钮时,组件的属性会改变,但状态保持不变,因为旧的组件被重新使用。我们有以下选择来解决这个问题:

  • 我们可以挂钩生命周期方法componentWillReceiveProps()(不推荐)或getDerivedStateFromProps()来重新初始化状态。但是这些方法也可以在道具没有变化,但是父对象由于某种原因被重新渲染时调用。我们可以检查 props 值的变化,但是当下一个/上一个问题具有相同的努力值时怎么办?

  • 我们可以使用生命周期方法componentDidUpdate()来替换状态。但是正如 ESLint 错误所建议的那样,在这个方法中同步设置状态并不是一个好主意。

  • 我们可以捕获onFocus()事件来设置编辑状态。否则,我们可以显示转换成字符串的 props 值。但是,即使这样,在输入有焦点的情况下,当显示的问题被另一个问题替换时,这种方法也不起作用。(如果正在显示的问题作为计时器的一部分被更改,如在幻灯片中,则会发生这种情况。)

  • 当一个新的问题被加载时,我们可以重新绘制页面。例如,这可以通过在loadData()正在进行时引入“正在加载”状态并呈现消息或 null 而不是表单来实现。当问题对象改变时,这将强制重新构建组件。但是,当加载新的问题时,这会导致闪烁,因为整个表单会暂时消失。

这些选项中的任何一个都可以在一些假设或一些变通办法下工作。但是让我们使用推荐的方法来处理这种情况。本质上需要的是一种用新的初始属性构造组件的方法。最好的方法是给组件分配一个key属性,当加载一个新的问题时,这个属性会改变。React 使用此属性来指示如果键不同,则不能重用组件对象;必须建造一个新的。

因为问题的 ID 是惟一的,所以我们也可以将它用作输入组件的键。让我们开始吧。此外,我们现在可以为 effort 字段删除空字符串的替换,因为空字符串将由NumInput组件无缝处理。随着这些变化,对IssueEdit组件的最终修改如清单 10-9 所示。

...
import graphQLFetch from './graphQLFetch.js';

import NumInput from './NumInput.jsx';

...

  onChange(event, naturalValue) {
    const { name, value: textValue } = event.target;
    const value = naturalValue === undefined ? textValue : naturalValue;
    ...
  }
...

  async loadData() {
    ... 

    if (data) {
      issue.due = issue.due ? issue.due.toDateString() : '';
      issue.effort = issue.effort != null ? issue.effort.toString() : '';
    ...
  }
...

  render() {
    ...
              <td>Effort:</td>
              <td>
                <input NumInput
                  name="effort"
                  value={effort}
                  onChange={this.onChange}
                  key={id}
                />
    ...
  }
...

Listing 10-9.ui/src/IssueEdit.jsx: Changes for Using NumInput

现在,如果您测试该应用,您应该能够编辑“工作”字段,并看到当您单击“下一个/上一个”时,该值根据问题对象而变化。另外,当您单击 Submit 时,您应该能够看到 issue 对象中的工作值确实是一个数字(该值周围没有引号)。

日期输入

在数字输入专用组件中,我们不必担心用户输入的有效性,因为我们防止了用户输入任何无效值。对于日期输入,我们不能这样做,因为有效性不能完全由日期中允许的字符来决定。例如,尽管允许使用所有数字,但像 999999 这样的数字不是有效的日期。

本质上,对于一个日期,只有当用户输入完日期时,才能确定其有效性。从输入元素失去焦点可以被用作编辑已经完成的信号。因此,在onBlur()处理程序中,我们必须检查用户输入的日期的有效性,然后通知父 Node 新值有效性的变化(如果有的话),以及新值是否有效。为了通知父 Node 新的有效性,让我们使用一个新的可选回调函数onValidityChange()。让我们把焦点状态和有效性保存在新的状态变量中,叫做focusedvalid。下面是新的onBlur()方法,包括所有这些:

...
  onBlur(e) {
    const { value, valid: oldValid } = this.state;
    const { onValidityChange, onChange } = this.props;
    const dateValue = unformat(value);
    const valid = value === “ || dateValue != null;
    if (valid !== oldValid && onValidityChange) {
      onValidityChange(e, valid);
    }
    this.setState({ focused: false, valid });
    if (valid) onChange(e, dateValue);
  }
...

注意,我们允许一个空字符串作为有效日期,以及任何其他可以使用unformat()方法转换成日期对象的字符串,我们将只使用Date(string)构造函数。

让我们也将日期的显示格式和可编辑格式分开。为了便于显示,可以根据方法toDateString()将日期转换成的语言环境来显示日期字符串。但是在编辑时,让我们强迫用户输入明确的YYYY-MM-DD格式。因此,我们将有两个功能,一个用于显示,另一个用于编辑,而不是像在NumInput中那样只有一个format()功能。

...
function displayFormat(date) {
  return (date != null) ? date.toDateString() : “;
}

function editFormat(date) {
  return (date != null) ? date.toISOString().substr(0, 10) : “;
}

function unformat(str) {
  const val = new Date(str);
  return Number.isNaN(val.getTime()) ? null : val;
}
...

onChange()方法中,我们将检查有效字符,它们只是数字和破折号(-)字符。所有其他字符将被禁止。我们将在这次检查中使用的正则表达式是/^[\d-]*$/

render()方法中,如果用户输入的值是无效的,或者用户正在编辑它,让我们按原样显示它。否则,让我们显示从日期的原始值转换的显示格式或可编辑格式。

...
    const displayValue = (focused || !valid) ? value
      : displayFormat(origValue);
    render() {
     <input
        ...
        value={displayValue}
      ...
    }
...

让我们也使用 CSS 类来表示无效值,但是只有当输入不处于焦点状态时,也就是说,用户没有在编辑它。

...
    const className = (!valid && !focused) ? 'invalid' : null;
...

清单 10-10 中显示了DateInput组件的代码,以及所有这些附加内容、元素的外观属性和完整的构造函数。

import React from 'react';

function displayFormat(date) {
  return (date != null) ? date.toDateString() : “;
}

function editFormat(date) {
  return (date != null) ? date.toISOString().substr(0, 10) : “;
}

function unformat(str) {
  const val = new Date(str);
  return Number.isNaN(val.getTime()) ? null : val;
}

export default class DateInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: editFormat(props.value),
      focused: false,
      valid: true

,
    };
    this.onFocus = this.onFocus.bind(this);
    this.onBlur = this.onBlur.bind(this);
    this.onChange = this.onChange.bind(this);
  }

  onFocus() {
    this.setState({ focused: true });
  }

  onBlur(e) {
    const { value, valid: oldValid } = this.state;
    const { onValidityChange, onChange } = this.props;
    const dateValue = unformat(value);
    const valid = value === “ || dateValue != null;
    if (valid !== oldValid && onValidityChange) {
      onValidityChange(e, valid);
    }
    this.setState({ focused: false, valid });
    if (valid) onChange(e, dateValue);
  }

  onChange(e) {
    if (e.target.value.match(/^[\d-]*$/)) {
      this.setState({ value: e.target.value });
    }
  }

  render() {
    const { valid, focused, value } = this.state;
    const { value: origValue, name } = this.props;
    const className = (!valid && !focused) ? 'invalid' : null;
    const displayValue = (focused || !valid) ? value
      : displayFormat(origValue);
    return (
      <input
        type="text"
        size={20}
        name={name}
        className={className}
        value={displayValue}
        placeholder={focused ? 'yyyy-mm-dd' : null}
        onFocus={this.onFocus}
        onBlur={this.onBlur}
        onChange={this.onChange}
      />
    );
  }
}

Listing 10-10.ui/src/DateInput.jsx: New File for the DateInput Component

为了在IssueEdit中使用这个新组件,让我们将due字段改为一个DateInput组件。此外,我们必须添加一个新的方法,用于在一个名为invalidFields的状态变量中存储每个输入的有效性状态。让我们使用一个对象,对于每个设置为值true的无效字段,它都有一个条目。如果字段有效,我们将删除该属性,这样就可以方便地检查任何无效字段的存在。

...
  onValidityChange(event, valid) {
    const { name } = event.target;
    this.setState((prevState) => {
      const invalidFields = { ...prevState.invalidFields, [name]: !valid };
      if (valid) delete invalidFields[name];
      return { invalidFields };
    });
  }
...

在构造函数和方法loadData()中,我们必须将状态变量invalidFields设置为一个空对象,以便为正在加载的任何新问题初始化它。

render()方法中,我们现在可以添加一个新变量来计算显示任何无效字段存在的消息。只有当存在任何无效字段时,我们才会初始化这条消息,这可以通过查看invalidFields状态变量的长度来计算。让我们也使用一个名为error的类来强调错误消息。

...
    const { invalidFields } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0) {
      validationMessage = (
        <div className="error">
          Please correct invalid fields before submitting.
        </div>
      );
    }
...

如果在编辑页面中有任何无效的输入,我们可以在表单中使用这个元素来显示消息。

...
        </table>
        {validationMessage}
        <Link to={`/edit/${id - 1}`}>Prev</Link>
...

清单 10-11 中显示了这些变化,以及一些更加修饰性的变化,比如将方法绑定到IssueEdit组件中的this

...
import NumInput from './NumInput.jsx';

import DateInput from './DateInput.jsx';

...

  constructor() {
    ...
    this.state = {
      ...
      invalidFields: {},
    };
    ...
    this.onValidityChange = this.onValidityChange.bind(this);
  }
...

  onValidityChange(event, valid) {
    const { name } = event.target;
    this.setState((prevState) => {
      const invalidFields = { ...prevState.invalidFields, [name]: !valid };
      if (valid) delete invalidFields[name];
      return { invalidFields };
    });
  }
...

  async loadData() {
    ...
    if (data) {
      ...
      issue.due = issue.due ? issue.due.toDateString() : “;
      ...
      this.setState({ issue, invalidFields: {} });
    } else {
      this.setState({ issue: {}, invalidFields: {} })

;
    }
  }
...

  render() {
    ...
    if (id == null) {
      ...
    }

    const { invalidFields } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0) {
      validationMessage = (
        <div className="error">
          Please correct invalid fields before submitting.
        </div>
      );
    }
    ...
               <input DateInput
                  name="due"
                  value={due}
                  onChange={this.onChange}
                  onValidityChange={this.onValidityChange}
                  key={id}
                />
    ...
        </table>
        {validationMessage}
        <Link to={`/edit/${id - 1}`}>Prev</Link>
    ...
  }
...

Listing 10-11.ui/src/IssueEdit: Changes to Use the New DateInput Component

我们需要对样式表进行一些修改,以突出显示错误消息(比如用红色字体),并以不同的方式显示有错误的输入(比如用红色边框代替普通边框)。清单 10-12 中显示了对index.html的这些更改。

...
  <style>
    ...
    input.invalid {border-color: red;}
    div.error {color: red;}
  </style>
...

Listing 10-12.ui/public/index.html: Style Changes for Error Messages and Error Inputs

样式更改需要刷新浏览器,因为 HMR 不处理对index.html的更改。一旦完成,您就可以测试新的日期输入字段了。当您输入一个有效值并单击 Submit 时,您将看到实际的日期对象被存储并显示在控制台中。对于所有无效的值,您应该看到一个红色边框的输入以及一个红色的错误消息。对于这些无效输入,您将看到用户在单击 Submit 时输入的原始值或任何以前的有效值。

文本输入

文本输入可能看起来没有必要,因为不需要进行验证或转换。但是让组件处理输入字段的空值会非常方便。否则,对于每个可选的文本字段,我们将需要处理 null 检查,并在加载数据时使用空字符串。

因此,非常类似于NumInput组件,让我们创建一个TextInput组件,有一些不同。format()unformat()的存在只是为了与空值进行相互转换。在onChange()方法中,我们没有有效用户输入的掩码:任何输入都是允许的。最后,为了处理 HTML 元素名称的变化(我们可以让textareainput,两者都处理文本数据),我们不要在组件中硬编码元素标签,而是让我们把它作为可选的tag属性传入,我们可以默认为input。为了能够做到这一点,我们将不得不退回到React.createElement()方法,而不是使用 JSX,因为标签名是一个变量。

清单 10-13 中显示了TextInput组件的完整源代码。

import React from 'react';

function format(text) {
  return text != null ? text : “;
}

function unformat(text) {
  return text.trim().length === 0 ? null : text;
}

export default class TextInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: format(props.value) };
    this.onBlur = this.onBlur.bind(this);
    this.onChange = this.onChange.bind(this);
  }

  onChange(e) {
    this.setState({ value: e.target.value });
  }

  onBlur(e) {
    const { onChange } = this.props;
    const { value } = this.state;
    onChange(e, unformat(value));
  }

  render() {
    const { value } = this.state;
    const { tag = 'input', ...props } = this.props;
    return React.createElement(tag, {
      ...props,
      value,
      onBlur: this.onBlur,
      onChange: this.onChange

,
    });
  }
}

Listing 10-13.ui/src/TextInput.jsx: New Text Input Component

IssueEdit组件中,我们可以将所有文本输入元素替换为TextInput,将描述元素替换为TextInput,并将标签的属性设置为textarea。我们将不得不使用key属性来确保在从编辑一个问题切换到另一个问题时组件被重构。最后,我们可以删除所有 null 到空字符串的转换,并在IssueEdit组件的状态下加载问题。

清单 10-14 显示了对IssueEdit的最后一组更改。

...
import DateInput from './DateInput.jsx';

import TextInput from './TextInput.jsx';

...

  async loadData() {
    ...
    if (data) {
      const { issue } = data;
      issue.owner = issue.owner != null ? issue.owner : “;
      issue.description = issue.description != null ? issue.description : “;
      this.setState({ issue, invalidFields: {} });
    } else {
      this.setState({ issue: {}, invalidFields: {} });
    }
    this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
  }
...

  render() {
            ...
              <td>Owner:</td>
              <td>
                <input TextInput
                  name="owner"
                  ...
                  key={id}
              </td>
            ...
              <td>Title:</td>
              <td>
                <input TextInput
                  name="title"
                  ...
                  key={id}
              </td>
            ...
              <td>Description:</td>
              <td>
                <textarea TextInput
                  tag="textarea"
                  ...
                  key={id}
              </td>
            ...
  }
...

Listing 10-14.ui/src/IssueEdit.jsx: Changes for Using TextInput Component

现在,您可以测试所有的文本输入,并查看当单击 Submit 时,用户输入的空字符串是否被转换为null值,反之亦然:数据库中的空值应该在 UI 中显示为空字符串。

更新 API

现在我们已经有了编辑页面的用户界面,让我们准备将编辑过的问题保存到数据库中。当然,我们需要一个更新 API,这就是我们将在本节中实现的。可以通过两种不同的方式进行更新:

  • 更新文档中的一个或多个字段:这可以使用 MongoDB update命令并使用$set操作符来设置字段的新值。

  • 用新值替换整个文档:这类似于创建一个新问题,为文档中的字段提供所有值(已更改的和未更改的)。MongoDB replace命令可用于用新文档替换文档。

在问题跟踪器的情况下,idcreated字段是特殊的,因为它们仅在问题被创建时被初始化,并且在那之后从不被修改。替换方法必然意味着原始对象被读取并与 API 输入提供的新值合并,否则idcreated字段将获得新的值,就像在create API 中一样。相同的输入数据类型IssueInputs也可用于替换操作。

如果我们使用 update 方法,其中只提供了一些字段,我们必须维护这个可以在 GraphQL 模式中更新的字段列表。这种数据类型与IssueInputs数据类型非常相似,只是所有字段都是可选的。缺点是输入字段列表的改变需要改变IssueInputs和这个新的数据类型。

但是更新方法提供了一些灵活性。它允许用户界面非常容易地更新单个字段。在接下来的部分中,我们将添加直接从问题列表中关闭问题的功能,您可以看到这种方法如何很好地支持这两种用例:从编辑页面替换问题以及从问题列表页面更改单个字段。

在其他情况下,支持替换操作可能会更好,但是created字段的出现使得只支持更新操作更有吸引力。在这种情况下,替换可以被视为对所有可修改字段的更新。

所以让我们实现一个更新问题的 API,就像 MongoDB update()命令一样,使用$set操作符。让我们首先更改模式以反映这个新的 API:我们首先需要一个新的输入数据类型,它包含所有可以更改的可能字段,并且所有字段都是可选的。姑且称之为IssueUpdateInputs。然后,我们需要一个新的突变切入点,姑且称之为updateIssue。这将返回新修改的问题。对schema.graphql的更改如清单 10-15 所示。

...
type IssueInputs {
  ...
}

"""Inputs for issueUpdate: all are optional. Whichever is specified will

be set to the given value, undefined fields will remain unmodified."""

input IssueUpdateInputs {

  title: String
  status: StatusType
  owner: String
  effort: Int
  due: GraphQLDate
  description: String

}

...

type Mutation {
  ...
  issueUpdate(id: Int!, changes: IssueUpdateInputs!): Issue!
}

然后,让我们将 API 连接到它在api_handler.js中的解析器。清单 10-16 显示了这一变化。

...
const resolvers = {
  ...
  Mutation: {
    ...
    issueUpdate: issue.update,
  },
...

Listing 10-16.api/api_handler.js: New API Endpoint and Resolver for updateIssue

Listing 10-15.api/schema.graphql: Update API and Its Input Data Type

现在,我们可以在名为update()的函数中实现issue.js中的实际解析器。在这个函数中,我们需要根据新的输入来验证问题。最简单的方法是从数据库中获取完整的对象,合并提供给 API 的更改,并运行我们用于添加问题的验证。让我们也只在影响有效性的字段发生变化时运行验证:标题、状态或所有者。一旦验证成功,我们就可以使用updateOne() MongoDB 函数和$set操作来保存更改。

最后,我们需要导出update()函数以及module.exports中的其他导出函数。所有这些变化如清单 10-17 所示。

async function update(_, { id, changes }) {

  const db = getDb();
  if (changes.title || changes.status || changes.owner) {
    const issue = await db.collection('issues').findOne({ id });
    Object.assign(issue, changes);
    validate(issue);
  }
  await db.collection('issues').updateOne({ id }, { $set: changes });
  const savedIssue = await db.collection('issues').findOne({ id });
  return savedIssue;

}

module.exports = { list, add, get };
  list,
  add,
  get,
  update,
};
...

Listing 10-17.api/issue.js: Resolver for the Update API

现在,你可以用操场来测试这些变化。为此,您可以使用以下命名查询:

mutation issueUpdate($id: Int!, $changes: IssueUpdateInputs!) {
  issueUpdate(id: $id, changes: $changes) {
    id title status owner
    effort created due description

  }
}

要更改 ID 为 2 的问题的状态和所有者,您可以使用如下查询变量:

{ "id": 2, "changes": { "status": "Assigned", "owner":"Eddie" } }

您还可以测试无效的更改,例如少于三个字符的标题,或者当“状态”设置为“已分配”时为空所有者,并确保更改被错误地拒绝。

更新问题

现在我们有了一个功能更新 API,我们可以编写handleSubmit()方法来调用 API 以保存用户所做的更改。

我们可以像上一节中使用的操场测试一样使用命名查询。至于名为changes的查询变量,我们需要从issue对象中剥离不能更改的字段并复制它。不能更改的字段是idcreated。可以这样做:

...
    const { id, created, ...changes } = issue;
...

注意

我们使用 ES2015+ rest操作符...在对idcreated变量进行析构赋值后,将发布对象值的剩余部分收集到changes变量中。

使用 GraphQL API 使用命名查询保存对象后,让我们用返回的问题值替换当前显示的问题。这仅仅需要一个带有返回问题的setState()呼叫,如果有的话。让我们也显示一条警告消息来表明操作成功,因为 UI 中没有其他可见的变化。

...
    const data = await graphQLFetch(query, { changes, id });
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
    }
...

此外,如果表单中有无效字段,我们什么也不做就返回。为此,我们可以应用与显示无效字段消息相同的检查。清单 10-18 显示了从编辑问题页面更新问题的完整更改。

...
  async handleSubmit(e) {
    e.preventDefault();
    const { issue, invalidFields } = this.state;
    console.log(issue); // eslint-disable-line no-console
    if (Object.keys(invalidFields).length !== 0) return;

    const query = `mutation issueUpdate(
      $id: Int!
      $changes: IssueUpdateInputs!
    ) {
      issueUpdate(
        id: $id
        changes: $changes
      ) {
        id title status owner
        effort created due description
      }
    }`;

    const { id, created, ...changes } = issue;
    const data = await graphQLFetch(query, { changes, id });
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
    }
  }
...

Listing 10-18.ui/src/IssueEdit.jsx: Changes for Saving Edits to the Database

现在,您可以测试应用,将对问题的任何更改保存到数据库中。在“编辑”和“问题列表”页面中应该可以看到这些更改。

更新字段

现在,让我们使用相同的 API 一次性更新单个字段,而不是整个问题对象。假设我们需要一种从问题列表页面本身快速关闭问题的方法(即,将其状态设置为Closed)。

为了实现这一点,我们需要在每一行都有一个按钮来启动操作。让我们更改IssueTable组件,将这个按钮添加为 Actions 列的一部分。点击这个按钮时,我们需要启动一个关闭动作,这个动作可以是一个在 props 中作为回调传递的函数。回调需要从IssueList经由IssueTable传递到IssueRow。此外,为了识别要关闭哪个问题的,我们还必须接收表中问题的索引,作为 props 中的另一个值。索引可以在IssueTable组件本身中计算,同时遍历问题列表。

清单 10-19 中显示了对IssueRowIssueTable组件的更改。

...
const IssueRow = withRouter(({ issue, location: { search } }) => {
  issue,
  location: { search },
  closeIssue,
  index,

}) => {

  ...
        <NavLink to={selectLocation}>Select</NavLink>
        {' | '}
        <button type="button" onClick={() => { closeIssue(index); }}>
          Close
        </button>
  ...
});

export default function IssueTable({ issues, closeIssue }) {
  const issueRows = issues.map((issue, index) => (
    <IssueRow key={issue.id} issue={issue} />
      key={issue.id}
      issue={issue}
      closeIssue={closeIssue}
      index={index}
    />
  ));
  ...
}
...

Listing 10-19.ui/src/IssueTable.jsx: Changes for Adding a Close Button

现在,让我们在IssueList组件中实现closeIssue()方法。让我们有一个名为closeIssue的命名查询,它接受一个问题 ID 作为查询变量。在查询的实现中,我们将调用类似于常规的update调用的issueUpdate API,但是将更改硬编码为将状态设置为 closed。

...
    const query = `mutation issueClose($id: Int!) {
      issueUpdate(id: $id, changes: { status: Closed }) {
        ...
      }
    }`;
...

执行查询后,如果执行成功,我们用返回值中的问题替换相同索引处的问题。因为状态是不可变的,所以我们必须复制一份issue状态变量。此外,因为我们使用现有状态的剩余部分进行复制,所以我们必须做推荐的事情,对接受前一个状态的this.setState()使用回调。如果执行不成功,我们将重新加载全部数据。

...
    if (data) {
      this.setState((prevState) => {
        const newList = [...prevState.issues];
        newList[index] = data.issueUpdate;
        return { issues: newList };
      });
    } else {
      this.loadData();
    }
...

IssueList组件的其他更改是在道具中将closeIssue()方法作为回调传递给IssueTable,并将closeIssue()方法绑定到this。清单 10-20 显示了完整的变更集,包括IssueList组件中的变更。

...
  constructor() {
    ...
    this.closeIssue = this.closeIssue.bind(this);
  }
...
  async createIssue(issue) {
    ...
  }

  async closeIssue(index) {
    const query = `mutation issueClose($id: Int!) {
      issueUpdate(id: $id, changes: { status: Closed }) {
        id title status owner
        effort created due description
      }
    }`;
    const { issues } = this.state;
    const data = await graphQLFetch(query, { id: issues[index].id });
    if (data) {
      this.setState((prevState) => {
        const newList = [...prevState.issues];
        newList[index] = data.issueUpdate;
        return { issues: newList };
      });
    } else {
      this.loadData();
    }
  }
...

  render() {
    ...
        <IssueTable issues={issues} closeIssue={this.closeIssue} />
    ...
  }
...

Listing 10-20.ui/src/IssueList.jsx: Changes for Handling Click of Close Button

可以通过单击问题列表中任何一行的关闭按钮来测试这组更改。该行中的问题状态应更改为Closed

练习:更新字段

  1. 我们可以从IssueRow本身调用更新 API 吗?这样做的后果是什么?

本章末尾有答案。

删除 API

为了完成 CRUD 操作集,让我们实现最后一个操作,Delete。让我们首先实现一个删除 API。首先,我们将修改模式以包含 Delete API,它只接受要删除的字段的 ID。我们将返回一个布尔值来表示成功删除。这一变化如清单 10-21 所示。

...
type Mutation {
  ...
  issueDelete(id: Int!): Boolean!
}
...

Listing 10-21.api/schema.graphql: Changes for Adding a Delete API

接下来,我们将在 API 处理程序的issue.js中将 API 连接到它的解析器。我们将简单地调用issue.js中的函数delete。这一变化如清单 10-22 所示。

...
const resolvers = {
  ...
  Mutation: {
    ...
    issueDelete: issue.delete,
  },
  ...
};
...

Listing 10-22.api/api_handler.js: Changes for Adding a Delete API

现在,让我们实现删除 API 的解析器。不只是删除给定 ID 的记录,而是让我们做一个在计算机中删除文件时通常会发生的事情:它被移到垃圾箱。这是为了让我们有机会在以后的时间点恢复它。让我们使用一个名为deleted_issues的新集合来存储所有删除的问题。我们可能会决定定期清除该表,因此我们还可以添加一个deleted字段来保存删除的日期和时间,这很方便(例如,清除 30 天前删除的所有问题)。

为此,我们将基于给定的 ID 从issues集合中检索问题,添加deleted字段,将其保存到deleted_issues,然后从issues集合中删除它。注意,我们不能将函数命名为delete,因为delete是 JavaScript 中的保留关键字。因此,我们将函数命名为remove(),但是我们将使用名称delete导出它。清单 10-23 显示了实现解析器的更改。

...
async function update(_, { id, changes }) {
  ...
}

async function remove(_, { id }) {

  const db = getDb();
  const issue = await db.collection('issues').findOne({ id });
  if (!issue) return false;
  issue.deleted = new Date();

  let result = await db.collection('deleted_issues').insertOne(issue);
  if (result.insertedId) {
    result = await db.collection('issues').removeOne({ id });
    return result.deletedCount === 1;
  }
  return false;

}

module.exports = {
  list,
  add,
  get,
  update,
  delete: remove,
};
... 

Listing 10-23.api/issue.js: Addition of Resolver for the Delete API

最后,让我们也作为初始化脚本的一部分初始化这个集合。这包括两件事:首先清理集合,然后在 ID 字段上创建一个索引以便于检索。对此的更改如清单 10-24 所示。

...
db.issues.remove({});

db.deleted_issues.remove({});

...

db.issues.createIndex({ created: 1 });

db.deleted_issues.createIndex({ id: 1 }, { unique: true });

...

Listing 10-24.api/scripts/init.mongo.js: Initialization of deleted_issues Collection

现在,您可以使用 Playground 测试 Delete API。您可以使用如下的变异来删除 ID 为 4 的问题:

mutation {
  issueDelete(id: 4)
}

如果 ID 为 4 的问题存在,它将被删除,API 将返回true。否则,API 将返回false。您可以使用 MongoDB shell 检查集合deleted_issues的内容,以验证该问题已经在该集合中备份。

删除问题

删除问题的 UI 更改与我们使用 Close 按钮更新字段的更改非常相似。

让我们首先添加按钮,并通过IssueTableIssueRows传递必要的回调。让我们使用名称deleteIssue进行回调,我们将很快在IssueList中实现。就像closeIssue回调一样,我们需要删除该问题的索引。我们已经为此目的传入了索引,所以我们将在这里使用同样的方法。

IssueTableIssueRows的更改如清单 10-25 所示。

...
const IssueRow = withRouter(({
  ...
  deleteIssue,
  index,
}) => {
  ...
        <button type="button" onClick={() => { closeIssue(index); }}>
          Close
        </button>
        {' | '}
        <button type="button" onClick={() => { deleteIssue(index); }}>
          Delete
        </button>
  ...
});

export default function IssueTable({ issues, closeIssue, deleteIssue }) {
  const issueRows = issues.map((issue, index) => (
    <IssueRow
      ...
      deleteIssue={deleteIssue}

      index={index}
    />
...

Listing 10-25.ui/src/IssueTable.jsx: Changes for Delete Button and Handling It

下一组变化是在IssueList组件中。同样,更改与我们对关闭按钮所做的非常相似:一个deleteIssue()方法获取要删除的问题的索引,在查询变量中使用这个 id 调用删除 API,如果 API 成功,issues状态变量中删除该问题。如果没有,它将重新加载数据。此外,存在用户正在删除所选问题的可能性。在这种情况下,让我们恢复到一个未选中的视图,即导航回/issues(即没有 ID 后缀)。

其他变化是将新方法绑定到this并将该方法作为回调传递给IssueTable组件。

这些变化如清单 10-26 所示。

...
  constructor() {
    ...
    this.deleteIssue = this.deleteIssue.bind(this);
  }
...

  async closeIssue(index) {
    ...
  }

  async deleteIssue(index) {
    const query = `mutation issueDelete($id: Int!) {
      issueDelete(id: $id)
    }`;
    const { issues } = this.state;
    const { location: { pathname, search }, history } = this.props;
    const { id } = issues[index];
    const data = await graphQLFetch(query, { id });
    if (data && data.issueDelete) {
      this.setState((prevState) => {
        const newList = [...prevState.issues];
        if (pathname === `/issues/${id}`) {
          history.push({ pathname: '/issues', search });
        }
        newList.splice(index, 1);
        return { issues: newList };
      });
    } else {
      this.loadData();
    }
  }

  render() {
    ...
        <IssueTable issues={issues} closeIssue={this.closeIssue} />
          issues={issues}
          closeIssue={this.closeIssue}
          deleteIssue={this.deleteIssue}
        />
  }
...

Listing 10-26.ui/src/IssueList.jsx: Changes for Implementing Delete Functionality

现在,如果您测试这个应用,您会在 Action 列中发现一个附加的 Delete 按钮。如果您单击“删除”,您应该会发现该问题已从列表中删除。在这一点上,我们不要求确认删除,因为最终我们将添加一个撤销按钮,恢复已删除的问题。这样,如果用户误点击了删除,他们可以撤销他们的操作。带有删除按钮的问题列表页面将类似于图 10-4 所示的屏幕截图。

img/426054_2_En_10_Chapter/426054_2_En_10_Fig4_HTML.jpg

图 10-4。

带有删除按钮的问题列表

摘要

我们使用编辑页面浏览表单,并查看受控和非受控表单组件之间的区别。我们还添加了新的 API 来满足新表单的需求,并通过添加删除操作完成了 CRUD 范例。重要的是,我们创建了专门的输入组件,可以处理大多数应用中预期的不同数据类型。

当我们做这一切的时候,你一定有一个想法:我们能不能让这一切,尤其是编辑页面,在浏览器中看起来更好?这正是我们将在下一章着手做的事情。我们将使用一个流行的 CSS 库来为 UI 添加一些修饰。

练习答案

练习:更多过滤器

  1. MongoDB 在数据类型方面非常严格。这也意味着,对于没有值的字段,它不能确定类型,因此,如果该字段有一个筛选条件,匹配就不会发生。如果字段上有 any 过滤器,则任何具有空值的字段都将被忽略且不会返回。

  2. 如果我们确实需要返回缺少工作字段的文档,我们必须创建一个包含原始过滤器的条件,以及一个工作未定义的条件。$or操作符接受一组过滤器,并根据任何过滤条件匹配文档。

    为了匹配未定义工作字段的文档,我们必须使用{$exists: false}作为字段的标准。下面是 mongo shell 中的一个例子:

    > db.issues.find({$or: [
      {effort: {$lte: 10}},
      {effort: {$exists: false}}
    ]});
    
    

练习:打字输入

  1. 即使发送的查询带有针对effortMin和/或effortMax的字符串,服务器似乎也会接受它并自动将其转换为整数。在将 effort 字段发送到服务器之前,该查询会将它们转换为整数。

    尽管这看起来很方便,而且不在 UI 中添加转换也很诱人,但出于几个原因,不建议这样做。首先,graphql-js 库的这种行为将来可能会改变,如果发生这种情况,可能会破坏我们的实现。以整数形式提供整数值更安全。

    其次,如果解析的值不是数字,UI 会忽略任何非数字值。因此,应用就像没有提供过滤器一样工作。另一方面,如果该值没有被解析,文本输入将被发送到服务器,从而导致错误(这可以通过直接在浏览器的 URL 中键入这些非数字值来测试)。

  2. 如果您将输入的类型设置为数字,您会发现(a)它在不同的浏览器上表现不同,(b)屏蔽在某些浏览器上不起作用,以及(c)当它允许无效字符时,您在onChange中看不到它们。这是因为根据 HTML 规范,当指定了类型并且输入不符合规范时,输入的value应该返回一个空字符串。如何处理无效值也取决于浏览器;例如,一些浏览器可能会显示输入无效的事实,而其他浏览器可能会阻止无效的输入。

    当使用 React 时,最好不要使用输入字段的 type 属性,最好自己处理验证或屏蔽(或者使用为您做这件事的包)。这使得跨浏览器的行为是可预测的,并允许您对如何处理无效输入做出明智的决定,特别是为了获得有效值而暂时无效的输入。

练习:编辑页面

  1. 如果受控组件的值设置为 null,则 React 会在控制台上显示一条警告:

警告:

'textarea'上的'value'属性不应为空。考虑使用空字符串来清除组件,或者使用'undefined'来清除不受控制的组件。

警告是因为空值是一个信号,表示组件不受控制。受控组件必须具有非空值。

练习:更新字段

  1. 虽然启动 API 可以从IssueRow组件本身完成,但发出成功信号和更新问题列表只能在IssueList组件内完成,因为状态驻留在那里。此外,这将导致IssueRow组件不再是一个纯粹的函数。它还需要一个组件内 close 动作的处理程序,这使得有必要将其定义为一个类。

    因为状态驻留在IssueList组件中,所以最好也让同一个组件操纵状态。

十一、React Bootstrap

像 Bootstrap ( https://getbootstrap.com )和 Foundation ( https://foundation.zurb.com/ )这样的 CSS 框架已经改变了人们构建网站的方式。这些工具使得 web 应用看起来更专业,响应更快(也就是说,让它更好地适应移动屏幕)。当然,缺点是这些现有的框架可能无法为您提供细粒度的可定制性,并且您的应用看起来会像许多其他应用一样。但是,即使您有能力创建自己的端到端定制风格,我也建议您从这些框架开始。这是因为从这些框架使用的模式中可以学到很多东西。

因为我们使用 React 作为 UI 库,所以我们需要选择一些适合 React 并能很好地使用 React 的东西。我评估了 React + Foundation、Material UI 和 React-Bootstrap,因为根据谷歌搜索,它们似乎最受欢迎。

React + Foundation 在功能上似乎与 React-Bootstrap 没有太大区别,但 Bootstrap 本身更受欢迎。Material UI 有一个有趣的 CSS-in-JS 和 inline-style 样式方法,非常适合 React 的哲学,即将组件所需的一切与组件本身隔离开来。但是这个框架不太受欢迎,似乎是一项正在进行的工作。而且,也许内联式方法过于偏离常规。

React-Bootstrap 是一个安全的选择,它建立在非常流行的 Bootstrap 之上,符合我们的需要(除了缺少日期选择器)。因此,我为这本书选择了 React-Bootstrap。在这一章中,我们将看看如何使用 React-Bootstrap 让应用看起来更专业。我不会讨论如何定制主题和其他高级主题,但是你将学到足够的东西来理解 React-Bootstrap 是怎么回事,所以如果需要的话,你可以很容易地进一步学习。

Bootstrap 安装

在这一节中,我们将安装 React-Bootstrap,并通过在 UI 中做一个可见的小更改来确认它是否工作。让我们首先安装 React-Bootstrap:

$ cd ui
$ npm install react-bootstrap@0

React-Bootstrap 包含一个 React 组件库,本身没有 CSS 样式或主题。要使用这些组件,需要在应用中包含 Bootstrap 样式表。包含样式表的版本或机制由我们决定,但是我们需要使用的版本是版本 3。React-Bootstrap 尚不支持最新版本的 Bootstrap 程序(版本 4)。因此,让我们包括 bootstrap 样式表的版本 3。最简单的方法是从 CDN 直接将其包含在index.html中,这是 React-Bootstrap“入门”页面( https://react-bootstrap.github.io )所推荐的。

但是因为我们在本地安装了其他第三方 JavaScript 依赖项,所以让我们对 Bootstrap 也做同样的事情。让我们使用 npm 安装 bootstrap,这样它的发行版文件就可以使用,并且可以直接从服务器提供服务,就像index.html和其他静态文件一样。

$ npm install bootstrap@3

下一步是在应用中包含 Bootstrap 样式表。一种方法是使用 Webpack 的样式和 CSS 加载器。这可以通过使用一个import(或require)语句来包含 CSS 文件来实现,就像其他 React 或 JavaScript 模块一样。然后,Webpack 将构建依赖关系树,并在创建的包中包含所有已经导入的样式。这是通过在包含所有样式的 JavaScript 包中创建一个字符串来实现的。当应用被加载时,字符串作为一个<style>Node 被放入 DOM。

为了让它工作,我们需要为 Webpack 安装 CSS 和样式加载器。然后,我们需要在 Webpack 配置中添加模式匹配,根据文件扩展名触发这些加载器。我们还需要样式表可能包含的图标和字体的加载器。最后,我们需要一个单独的import语句来导入bootstrap.css,可能只是在App.jsx中。

我发现对于我们的需求来说,所有这些都是多余的。Webpack 的 CSS 和样式加载器的目的是能够模块化样式表,就像我们模块化 React 代码一样。如果每个组件都有自己的一组样式,并被分离到它们自己的 CSS 文件中,这种方法会非常有效。但事实是 Bootstrap 是作为一个整体样式表提供的。即使只使用了一个组件,也必须包含整个 CSS。那么为什么不直接包含整个样式表呢?这就是我们要做的。

我们将在public目录下保留一个到 Bootstrap 程序发行版的符号链接,并像其他静态文件(如index.html)一样包含 CSS。在 Mac 或基于 Linux 的计算机上实现这一点的命令是:

$ ln -s ../node_modules/bootstrap/dist public/bootstrap

在 Windows PC 上,命令行工具mklink可以用来做同样的事情,用/J选项为目录创建一个连接

> mklink /J public\bootstrap node_modules\bootstrap\dist

或者,您可以将 Bootstrap 库下的整个dist目录复制到公共目录中。

如果您现在浏览这个新目录,您会发现三个子目录:cssfontsjs。不会使用js目录,因为这是 React-Bootstrap 所替换的。在index.html中,我们将添加一个到主 Bootstrap 样式表的链接,它位于我们链接或复制的目录的css子目录下。我们将不包括可选的主题文件,只包括主要的简化 Bootstrap 样式,如下所示:

...
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
...

此时,如果您测试应用,您应该会看到一个不同外观的应用,因为有了新的 Bootstrap 样式表。例如,默认字体应该是无衬线字体(Helvetica 或 Arial ),而不是衬线字体(Times New Roman)。

现在让我们在移动设备上测试一下,看看自举响应设计的效果。做到这一点的一个方法是实际使用移动电话并连接到运行在你的桌面上的服务器。但是这不会起作用,除非你把环境变量UI_API_ENDPOINT改成你的计算机的 IP 地址。或者,您必须使用代理配置,以便所有请求都通过 UI 服务器路由。

更简单的方法是使用 web 浏览器的移动仿真模式来查看它的外观。我发现只有 Chrome 准确地反映了真实移动设备中发生的事情。Safari(使用开发菜单下的响应模式)和 Firefox(在开发工具中)只模拟屏幕大小的变化。图 11-1 显示了你在真实移动设备或 Chrome 移动模拟器上看到的内容。请注意,某些设备可能看不到轮廓或设备框架。此外,需要在开发人员工具设置中打开显示设备框架选项才能看到设备框架。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig1_HTML.jpg

图 11-1

移动模拟器中的应用

正如你所见,屏幕看起来真的压扁或缩小。你可以通过挤压(在移动模拟器中使用 Shift-drag)来改变扩容比例,但是我们真的希望它默认使用较小的屏幕宽度。移动浏览器不采用设备宽度的原因大致是这样的:它假设页面不是为移动屏幕设计的,所以它选择一个适合桌面的任意宽度,使用它,然后缩小页面以适应屏幕。

我们需要让应用知道如何处理小屏幕的移动浏览器,这样它就不会做额外的工作来尝试将桌面网站放入移动屏幕。方法是在主页面中添加一个名为viewport的 meta 标签,其内容指定初始宽度等于设备的宽度,初始扩容为 100%。清单 11-1 显示了这一变化以及index.html中包含的样式表。

...
<head>
  ...
  <title>Pro MERN Stack</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" >
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
...

Listing 11-1ui/public/index.html: Changes for Bootstrap and Mobile Device Compatibility

通过这一改变,屏幕应该看起来更好,屏幕填充整个设备宽度,如图 11-2 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig2_HTML.jpg

图 11-2

视口设置后的移动模拟器

我们测试了 Bootstrap 样式表的生效,并在移动浏览器中进行了检查。现在让我们测试一下 React-Bootstrap 的组件是否可用。为此,我们使用一个简单的 React-Bootstrap 组件。可用组件列表可在 https://react-bootstrap.github.io/components/alerts/ 的 React-Bootstrap 文档中找到。让我们选择<Label>组件,这是一个简单的组件,以突出显示的方式显示文本。让我们在问题列表的标题中使用它,并确保它按预期呈现。对此的更改在IssueList组件中进行,如清单 11-2 所示。

...
import { Route } from 'react-router-dom';

import { Label } from 'react-bootstrap';

...

  render() {
    ...
    return (
      <React.Fragment>
        <h1><Label>Issue Tracker</Label></h1>
    ...
  }
...

Listing 11-2ui/src/IssueList.jsx: Change the App Title to Use React-Bootstrap’s Label Component

现在,如果您测试应用,您会发现问题列表页面中的标题显示为深色背景和白色前景。其截图如图 11-3 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig3_HTML.jpg

图 11-3

带有 Bootstrap 标签的问题列表页面

小跟班

为了熟悉 React-Bootstrap 使用组件的方式,让我们从一个简单的组件开始:Button。让我们用 Bootstrap 按钮替换问题过滤器中的应用和重置按钮。

可以使用<Button>组件创建一个简单的基于文本的按钮。除了常规的<button>组件支持的所有属性之外,<Button>组件使用bsStyle属性来使按钮看起来与众不同。除了默认显示白色背景的按钮,允许的样式有primarysuccessinfowarningdangerlink。让我们使用primary样式的应用按钮和默认的重置按钮。因此,对于复位按钮,唯一的变化是标签名称从button变为Button。应用按钮的变化是标签名称和添加了bsStyle="primary"

这些变化如清单 11-3 所示。

...
import { withRouter } from 'react-router-dom';

import { Button } from 'react-bootstrap';

...

        <button type="button" onClick={this.applyFilter}>Apply</button>
        <Button bsStyle="primary" type="button" onClick={this.applyFilter}>
          Apply
        </Button>
        {' '}
        <button
        <Button
          ...
        >
          Reset
        </button>
        </Button>
...

Listing 11-3ui/src/IssueFilter.jsx: Replace Buttons with Bootstrap Buttons

下一步,让我们在问题表中使用图标而不是文本来关闭和删除问题。我们将需要使用 React-Bootstrap 的Glyphicon组件,而不是<Button>元素中的文本。该组件可识别的图标列表可从位于 https://getbootstrap.com/docs/3.3/components/ 的 Bootstrap 网站获得。让我们在IssueRow组件中用remove图标表示关闭动作,用trash图标表示删除动作,来代替常规按钮。让我们使用一个更小的按钮,通过使用bsSize属性使表格看起来更紧凑。关闭动作的代码如下,它保留了最初的onClick()事件。

...
        <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
          <Glyphicon glyph="remove" />
        </Button>
...

可以对删除操作的按钮进行类似的更改。但是由于图标的预期动作并不太明显,所以最好在鼠标悬停在按钮上时显示一个工具提示。HTML 属性title可以用于这个目的,但是让我们使用 Bootstrap 的风格化的Tooltip组件。但是使用它并不像设置一个title属性那么简单。Tooltip组件必须在鼠标经过和鼠标离开时显示或隐藏。在常规的 Bootstrap 中(也就是没有 React),jQuery 将用于注入这些处理程序,但是在 React 中,我们需要一个适合组件层次结构的更干净的机制。

方法是使用包装按钮的OverlayTrigger组件,并将Tooltip组件作为属性。Tooltip本身很简单:元素的子元素是要显示的内容。此外,由于工具提示的默认位置是在按钮的右边,如果按钮靠近屏幕边缘,这可能会变得模糊,让我们将位置更改为按钮上方。为此,我们可以将placement属性指定为top

...
  const closeTooltip = (
    <Tooltip id="close-tooltip" placement="top">Close Issue</Tooltip>
  );
...

需要使用id属性来使组件可访问。现在,这个变量可以用作OverlayTrigger组件中的一个属性,我们将为工具提示效果包装这个按钮。此外,让我们自定义工具提示显示的延迟时间,因为默认的延迟时间非常短且具有干扰性。这可以通过使用OverlayTriggerdelayShow属性来完成。

...
        <OverlayTrigger delayShow={1000} overlay={closeTooltip}>
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
            <Glyphicon glyph="remove" />
          </Button>
        </OverlayTrigger>
...

用于删除动作的类似组件集,以及import语句等。完成对IssueTable.jsx文件的修改,如清单 11-4 所示。

...
import { Link, NavLink, withRouter } from 'react-router-dom';

import {

  Button, Glyphicon, Tooltip, OverlayTrigger,

} from 'react-bootstrap';

const IssueRow = withRouter(({
  ...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const closeTooltip = (
    <Tooltip id="close-tooltip" placement="top">Close Issue</Tooltip>
  );
  const deleteTooltip = (
    <Tooltip id="delete-tooltip" placement="top">Delete Issue</Tooltip>
  );
  return (
    ...
        <button type="button" onClick={() => { closeIssue(index); }}>
          Close
        </button>
        <OverlayTrigger delayShow={1000} overlay={closeTooltip}>
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
            <Glyphicon glyph="remove" />
          </Button>
        </OverlayTrigger>
        {' | '}
        {' '}
        <OverlayTrigger delayShow={1000} overlay={deleteTooltip}>
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
            <Glyphicon glyph="trash" />
          </Button>
        </OverlayTrigger>
      </td>
   ...
});
...

Listing 11-4ui/src/IssueTable.jsx: Changes for Buttons with Icons and Tooltip

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,一定要依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试和最新的源代码,尤其是如果有些东西不能像预期的那样工作。

我们将把编辑和选择链接转换成图标,以便以后使用。这是因为,作为链接,它们需要很好地与 React Router 一起工作,并无缝地处理<Link><NavLink>元素。如果您使用当前的更改测试应用,您应该会发现问题列表页面看起来类似于图 11-4 中的屏幕截图。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig4_HTML.jpg

图 11-4

带有 Bootstrap 按钮和关闭按钮上的悬停工具提示的问题列表

导航栏

在这一节中,我们将设计页眉中的导航链接,并添加一个在所有页面上都可见的页脚。

对于导航栏,我们从应用标题开始。我们将把它从问题列表页面移到它应该在的导航栏。主页、问题列表和报告链接可以显示为标题右侧的样式化导航链接。在右侧,我们还有一个用于创建新问题的操作项(在后面的部分中,我们将把页面内添加表单移动到一个模式中)和一个用于将来可能添加的任何其他操作的扩展下拉菜单。目前,扩展菜单只有一个动作,叫做 About(暂时不做任何事情)。

创建导航栏的起始组件是Navbar。让我们首先解决导航栏的布局和实现它所需的组件。每一项都是一个NavItem。这些项目可以在一个Nav中组合在一起。因此,我们需要两个Nav元素,一个用于导航栏的左侧,另一个用于右侧。使用pullRight属性可以将右侧Nav向右对齐。

...
  <Navbar>
    <Nav>
      <NavItem>Home</NavItem>
      <NavItem>Issue List</NavItem>
      <NavItem>Report</NavItem>
    </Nav>
    <Nav pullRight>
      ...
    </Nav>
  </Navbar>
...

至于应用标题,让我们按照文档的建议使用Navbar.HeaderNavbar.Brand。这应该出现在所有的Nav元素之前。

...
  <Navbar>
    <Navbar.Header>
      <Navbar.Brand>Issue Tracker</Navbar.Brand>
    </Navbar.Header>
...

在右侧,我们首先需要一个用于创建问题操作的基于图标的项目。我们将使用NavItem中的Glyphicon组件来实现这一点。此外,为了给图标添加一个工具提示,让我们像在问题列表页面中使用动作按钮一样使用OverlayTrigger

...
      <Nav pullRight>
        <NavItem>
          <OverlayTrigger ...
            <Glyphicon glyph="plus" />
          </OverlayTrigger
        </NavItem>
...

至于扩展菜单,我们需要一个下拉菜单。React-Bootstrap 组件NavDropdown可用于创建下拉菜单,每个菜单项都是一个MenuItem组件。下拉列表需要一个标题,通常是文本。但是由于菜单是通用的,我们将只使用一组垂直方向的三个点来表示扩展菜单是可用的。通常,当使用文本时,插入符号将指示下拉列表。但是因为我们使用了一个已经表示下拉列表的图标,我们将通过指定noCaret作为属性来移除插入符号。

...
        <NavDropdown
          id="user-dropdown"
          title={<Glyphicon glyph="option-vertical" />}
          noCaret
        >
          <MenuItem>About</MenuItem>
        </NavDropdown>
...

现在,让我们添加导航项应该执行的操作。NavItem组件可以接受一个href作为属性,或者一个onClick事件处理程序。现在,我们有以下选择:

  • NavItem上使用一个href属性:这有一个问题,React 路由的<Link>将不被使用,点击href会导致浏览器完全刷新。

  • 使用 React Router 的<Link>而不是NavItem:这将搞乱样式,不能正确对齐,因为<Link>使用锚标签(<a>),并且没有办法改变组件类。

这两种选择都会带来问题。打破这个僵局的推荐方法是使用react-router-bootstrap包,它提供了一个名为LinkContainer的包装器,充当 React 路由的NavLink,同时让它的孩子拥有自己的渲染。我们可以将一个没有hrefNavItem作为子 Node 放在LinkContainer上,让父 NodeLinkContainer处理到路由的路径。

让我们安装软件包来使用LinkContainer:

$ cd ui
$ npm install react-router-bootstrap@0

现在,我们可以用一个LinkContainer(在导入包之后)将所有左侧的导航项与它需要指向的路线的路径包装在一起。组件LinkContainer支持 React 路由组件NavLink的所有属性。

...
        <LinkContainer to="/issues">
          <NavItem>Issue List</NavItem>
        </LinkContainer>
...

让我们添加一个简单的页脚,显示这本书的 GitHub 资源库的链接。包括这一变化,Page.jsx文件的全部内容如清单 11-5 所示。为了简洁起见,我排除了被删除的代码行,这些被删除的代码行只是原始的NavLink和相应的import语句。

import React from 'react';
import {
  Navbar, Nav, NavItem, NavDropdown,
  MenuItem, Glyphicon, Tooltip, OverlayTrigger,
} from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';

import Contents from './Contents.jsx';

function NavBar() {
  return (
    <Navbar>
      <Navbar.Header>
        <Navbar.Brand>Issue Tracker</Navbar.Brand>
      </Navbar.Header>
      <Nav>
        <LinkContainer exact to="/">
          <NavItem>Home</NavItem>
        </LinkContainer>
        <LinkContainer to="/issues">
          <NavItem>Issue List</NavItem>
        </LinkContainer>
        <LinkContainer to="/report">
          <NavItem>Report</NavItem>
        </LinkContainer>
      </Nav>
      <Nav pullRight>
        <NavItem>
          <OverlayTrigger
            placement="left"
            delayShow={1000}
            overlay={<Tooltip id="create-issue">Create Issue</Tooltip>}
          >
            <Glyphicon glyph="plus" />
          </OverlayTrigger>
        </NavItem>
        <NavDropdown
          id="user-dropdown"
          title={<Glyphicon glyph="option-vertical" />}
          noCaret
        >
          <MenuItem>About</MenuItem>
        </NavDropdown>
      </Nav>
    </Navbar>
  );
}

function Footer() {
  return (
    <small>
      <p className="text-center">
        Full source code available at this
        {' '}
        <a href="https://github.com/vasansr/pro-mern-stack-2">
          GitHub repository
        </a>
      </p>
    </small>
  );
}

export default function Page() {
  return (
    <div>
      <NavBar />
      <Contents />
      <Footer />
    </div>
  );
}

Listing 11-5ui/src/Page.jsx: New NavBar-Based Header and Trivial Footer

既然应用标题是导航栏的一部分,让我们将它从问题列表页面中删除。对此的更改如清单 11-6 所示。

...
import { Route } from 'react-router-dom';

import { Label } from 'react-bootstrap';

...

      <React.Fragment>
        <h1><Label>Issue Tracker</Label></h1>
...

Listing 11-6ui/src/IssueList.jsx: Removal of Application Title

现在,如果您测试应用,您应该会看到如图 11-5 中截图所示的问题列表页面。您还应该能够使用导航栏在问题列表页面和报告页面之间切换,并且导航栏也应该在编辑屏幕中可见。导航栏右侧的项目仍然是没有效果的虚拟项目。我们将在后面的章节中实现这些。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig5_HTML.jpg

图 11-5

带有导航栏的问题列表页面

练习:导航栏

  1. React-Bootstrap 的Navbar at https://react-bootstrap.github.io/components/navbar/ 文档列出了对一个名为fixedTop的属性的支持。这是为了在垂直滚动时保持NavBar在顶部。试试这个。您可能需要转到编辑页面和/或降低屏幕高度,以显示垂直滚动条。你发现问题了吗?你会怎么解决?提示:在 https://getbootstrap.com/docs/3.3/components/#navbar-fixed-top 参考 Bootstrap 的 Navbar 文档。(请记住在练习后恢复这些实验变化。)

本章末尾有答案。

嵌板

到目前为止,我们一直使用水平标尺(<hr>元素)来划分页面中的部分。例如,在问题列表页面中,我们使用它来将过滤器从问题表中分离出来。此外,在包含 Bootstrap 之后,您会注意到左边空白消失了,导致问题列表页面的所有内容出现在左侧窗口边缘的右侧。

Bootstrap 的Panel组件是使用边框和可选标题分别显示部分的好方法。让我们用它来装饰问题列表页面中的过滤器部分。我们将通过在render()方法中的IssueFilter实例周围添加一个面板来对IssueList组件进行更改。

Panel组件由可选标题(Panel.Heading)和面板主体(Panel.Body)组成。面板主体是我们放置<IssueFilter />实例的地方。

...
        <Panel>
          ...
          <Panel.Body>
            <IssueFilter />
          </Panel.Body>
        </Panel>
...

至于标题,让我们为它添加一个带有文本Filter的标题。不使用纯文本,而是将它包装在一个用于面板标题的Panel.Title组件中,使其突出。

...
          <Panel.Heading>
            <Panel.Title>Filter</Panel.Title>
          </Panel.Heading>
...

现在,为了节省空间,让我们折叠面板。当用户想要应用过滤器时,他们可以点击标题并显示和操作过滤器。方法是将collapsible属性添加到Panel.Body中,并通过设置面板标题的toggle属性来控制折叠行为。

...
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
...

清单 11-7 显示了围绕问题过滤器实现可折叠面板的完整更改。点击标题Filter,可折叠/展开面板。

...
import { Route } from 'react-router-dom';

import { Panel } from 'react-bootstrap';

...

  render() {
    ...
    return (
      <React.Fragment>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter />
          </Panel.Body>
        </Panel>
        <IssueTable
        ...
      </React.Fragment>
    );
  }
...

Listing 11-7ui/src/IssueList.jsx: Changes for Adding a Panel Around the Issue Filter

即使问题过滤器周围有一个面板,在测试应用时,你会发现没有左边距。Bootstrap 的网格系统是增加余量的系统。虽然我们还不会使用网格,但是我们需要用一个<Grid>组件来包装页面主体,以增加边距。让我们在Page.jsx中的Contents组件实例周围添加网格组件,而不是对每个页面都这样做。

Bootstrap 中有两种网格容器:一种是填充整个页面的流动容器,另一种是固定容器(默认),其大小是固定的,但可以适应屏幕大小。让我们使用一个使用fluid属性的流体网格来匹配导航条,因为让问题列表充满屏幕以获得更好的可读性会很好。

这一变化如清单 11-8 所示。

...
import {
  ...
  Grid,
} from 'react-bootstrap';
...

export default function Page() {
      <Grid fluid>
        <Contents />
      </Grid>
  );
}
...

Listing 11-8ui/src/Page.jsx: Wrapping Page Contents with a Grid to Add Margins

当您尝试这一组更改时,您会发现面板标题不太容易点击。首先,光标不会变成可以点击的东西。另外,你唯一能点击的地方是在文本上。出于可用性的考虑,我们真正想要的是光标指示它是可点击的,并且让整个标题区域都是可点击的。

如果您使用 Safari 或 Chrome 的检查器检查 DOM,您可以看到有一个由 React-Bootstrap 在标题可折叠时添加的<a>元素。不幸的是,我们没有办法配置面板,要么不添加<a>(让您自己为标题指定一个可点击的 Node),要么告诉它填充水平空间。我们可以做到这一点的唯一方法是使用一种样式,使<a>成为填充空间和设置光标的块元素。

让我们在index.html中加入这种风格。清单 11-9 显示了这一变化。

...
  <style>
    ...
    .panel-title a {display: block; width: 100%; cursor: pointer;}
  </style>
...

Listing 11-9public/index.html: Style for Making Entire Panel Heading Clickable

现在,在测试中,你会发现页面的内容和窗口的边缘之间有边距。您必须刷新浏览器,因为样式已更新的index.html不会被 HMR 自动更新。您还会发现有一个可点击的面板标题,点击它可以打开过滤器表单。其截图如图 11-6 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig6_HTML.jpg

图 11-6

可折叠面板中的问题过滤器

练习:面板

  1. 假设我们希望面板在浏览器刷新时以展开状态显示。你将如何实现这一目标?更有趣的是,如果除了默认的“所有状态”和“所有工作”之外的任何过滤器有效,我们希望面板被展开。你将如何实现这一目标?提示:在 https://react-bootstrap.github.io/components/panel/#panels-props-accordion 的 React-Bootstrap 文档中查找Panel组件的属性。

本章末尾有答案。

桌子

除了样式化之外,Bootstrap 还可以为表格添加一些细节。在这一节中,我们将把普通表转换成 Bootstrap 表,Bootstrap 表看起来更好,扩展到适合屏幕,并在悬停时突出显示一行。此外,我们将使整行可点击以选择问题,以便其描述显示在详细信息部分。这将替换Action列中的Select链接。我们将使用react-router-bootstrap中的LinkContainer组件来实现这一点。我们还将把编辑链接转换成一个按钮,我们不能和其他两个动作按钮一起做,因为那时我们还没有发现LinkContainer组件。

在本节中,我们将使用IssueTable.jsx文件中的新组件LinkContainerTable,所以让我们先导入它们:

...

import { LinkContainer } from 'react-router-bootstrap';

import {
  Button, Glyphicon, Tooltip, OverlayTrigger, Table,
} from 'react-bootstrap';
...

将表转换为 Bootstrap 表非常简单,只需用 React-Bootstrap 的<Table>组件标签替换<table>标签。在我们这样做的同时,让我们也添加一些属性,这些属性记录在 React-Bootstrap 的 https://react-bootstrap.github.io/components/table/ 的表格页面中。

  • striped:高亮显示具有不同背景的交替行。这可能会干扰所选行的显示,所以我们不要使用它。

  • bordered:在行和单元格周围添加边框。让我们使用这个。

  • condensed:默认大小文字周围空白太多,我们用精简模式吧。

  • hover:高亮光标下的行。让我们使用这个。

  • responsive:在较小的屏幕上,使表格水平滚动,而不是减少列的宽度。我们也用这个吧。

IssueTable组件的render()函数中的新表格组件现在看起来像这样:

...
    <table className="bordered-table">
    <Table bordered condensed hover responsive>
      ...
    </table>
    </Table>
...

接下来,要用整行替换Select链接,让我们用一个LinkContainer包装整行,并让它导航到与使用to属性的Select链接相同的位置。注意,如果路由匹配链接路径,LinkContainer还会自动将active类添加到类似NavLink的包装元素中。Bootstrap 自动用灰色背景突出显示这些行。因此,我们不返回IssueRow组件的render()方法中的<tr>元素,而是将它赋给一个常量。然后,让我们返回包装在一个LinkContainer周围的相同元素。

...
  return (
  const tableRow = (
    <tr>
      ...
        <NavLink to={selectLocation}>Select</NavLink>
        {' | '}
      ...
    </tr>
  );

  return (
    <LinkContainer to={selectLocation}>
      {tableRow}
    </LinkContainer>
  );
...

现在,让我们将编辑链接转换成一个带有图标的按钮。让我们使用ButtonGlyphicon组件,并像关闭和删除按钮一样使用TooltipOverlayTrigger,除了onClick()事件处理程序:

...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const editTooltip = (
    <Tooltip id="close-tooltip" placement="top">Edit Issue</Tooltip>
  );
...
        <Link to={`/edit/${issue.id}`}>Edit</Link>
        {' | '}
        <OverlayTrigger delayShow={1000} overlay={editTooltip}>
          <Button bsSize="xsmall">
            <Glyphicon glyph="edit" />
          </Button>
        </OverlayTrigger>
...

现在,让我们用一个LinkContainer来包装按钮,而不是onClick()事件处理程序;to属性是从原Link组件的to属性复制而来的:

...
        <LinkContainer to={`/edit/${issue.id}`}>
          <OverlayTrigger delayShow={1000} overlay={editTooltip}>
            ...
          </OverlayTrigger>
        </LinkContainer>
...

在这个时间点上,更改似乎会起作用,但是单击关闭或删除按钮也会产生选择行的副作用。这是因为我们现在在行上有了一个onClick()处理程序(由LinkContainer组件安装),当单击这些按钮时就会调用它。为了防止事件从按钮传播到包含的行,我们需要在处理程序中调用e.preventDefault()。让我们将事件处理程序分成显式函数(相对于onClick属性中的匿名函数)并使用函数名。

对于“删除”按钮,更改如下:

...
  function onDelete(e) {
    e.preventDefault();
    deleteIssue(index);
  }

  const tableRow = (
  ...
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
          <Button bsSize="xsmall" onClick={onDelete}>
  );
...

我们需要对 Close 按钮进行类似的更改(为了简洁起见,没有显示)。清单 11-10 显示了对IssueTable.jsx文件的完整更改。

...
import { Link, NavLink, withRouter } from 'react-router-dom';

import { LinkContainer } from 'react-router-bootstrap';

import {
  Button, Glyphicon, Tooltip, OverlayTrigger, Table,
} from 'react-bootstrap';

const IssueRow = withRouter(({
  ...
  const selectLocation = { pathname: `/issues/${issue.id}`, search };
  const editTooltip = (
    <Tooltip id="close-tooltip" placement="top">Edit Issue</Tooltip>
  );
  ...
  const deleteTooltip = (
    ...
  );

  function onClose(e) {
    e.preventDefault();
    closeIssue(index);
  }

  function onDelete(e) {
    e.preventDefault();
    deleteIssue(index);
  }

  return (
  const tableRow = (
    ...
        <Link to={`/edit/${issue.id}`}>Edit</Link>

        {' | '}
        <LinkContainer to={`/edit/${issue.id}`}>
          <OverlayTrigger delayShow={1000} overlay={editTooltip}>
            <Button bsSize="xsmall">
              <Glyphicon glyph="edit" />
            </Button>
          </OverlayTrigger>
        </LinkContainer>
        {' '}
        <NavLink to={selectLocation}>Select</NavLink>
        {' | '}
    ...
          <Button bsSize="xsmall" onClick={() => { closeIssue(index); }}>
          <Button bsSize="xsmall" onClick={onClose}>
    ...
          <Button bsSize="xsmall" onClick={() => { deleteIssue(index); }}>
          <Button bsSize="xsmall" onClick={onDelete}>
    ...
  );

  return (
    <LinkContainer to={selectLocation}>
      {tableRow}
    </LinkContainer>
  );
});
...

export default function IssueTable({ issues, closeIssue, deleteIssue }) {
  ...
  return (
    <table>
    <Table bordered condensed hover responsive>

      ...
    </table>
    </Table>
    ...
  );
}
...

Listing 11-10ui/src/IssueTable.jsx: Changes for Using Bootstrap Table, Clickable Rows, and Edit Button

我们还可以通过将光标变为指针来表明表格行是可点击的。此外,我们现在可以删除用于为原始表格设置边框的样式,以及显示活动时高亮显示的NavLink。清单 11-11 显示了样式的这些变化。

...
  <style>
    table.bordered-table th, td {border: 1px solid silver; padding: 4px;}
    table.bordered-table {border-collapse: collapse;}
    a.active {background-color: #D8D8F5;}
    table.table-hover tr {cursor: pointer;}
...

Listing 11-11ui/public/index.html: Removal of Old Styles and New Style for Table Rows

如果您现在测试应用,您会发现表格看起来有所不同。它现在水平填充屏幕,光标下的行高亮显示。您可以单击该行进行选择,而不是使用旧的选择链接。点击关闭或删除按钮应该而不是选择问题。使用移动仿真器的快速测试也应该确认该表是可水平滚动的。新问题列表页面截图如图 11-7 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig7_HTML.jpg

图 11-7

带有 Bootstrap 表的问题列表页面

形式

可以使用 Bootstrap 以多种方式设计表单的样式和布局。但是在开始布局表单之前,让我们首先使用库提供的基本组件,用 React-Bootstrap 的等价物和标签来替换简单的<input><select>选项。让我们在问题过滤器表单中完成此操作。

在本节中,我们将使用许多新的 React-Bootstrap 组件。我们先在IssueFilter.jsx中导入。

...
import {
  ButtonToolbar, Button, FormGroup, FormControl, ControlLabel, InputGroup,
} from 'react-bootstrap';
...

使用 React-Bootstrap,使用一个FormControl实例化公共输入类型。默认情况下,它使用常规的<input>类型来呈现实际的元素。componentClass属性可以用来将这个默认值更改为任何其他元素类型,例如select。表单控件的其余属性,如valueonChange,与常规的<input><select>元素相同。

可以使用ControlLabel组件将标签与表单控件相关联。该组件的唯一子组件是标签文本。为了将标签和控件放在一起,它们需要放在一个FormGroup下。例如,状态筛选器的下拉列表及其标签可以改写如下:

...
        <FormGroup>
          <ControlLabel>Status:</ControlLabel>
          <FormControl
            componentClass="select"
            value={status}
            onChange={this.onChangeStatus}
          >
            <option value="">(All)</option>
            ...
          </FormControl>
        </FormGroup>
...

Effort输入不是那么简单,因为它们由两个输入组成。我们可以用一个InputGroup来包围两个FormControls,但是它本身会导致两个输入一个在另一个下面。InputGroup.Addon组件可用于显示相邻的输入,以及显示两个输入之间的破折号。

...
        <FormGroup>
          <ControlLabel>Effort between:</ControlLabel>
          <InputGroup>
            <FormControl value={effortMin} onChange={this.onChangeEffortMin} />
            <InputGroup.Addon>-</InputGroup.Addon>
            <FormControl value={effortMax} onChange={this.onChangeEffortMax} />
          </InputGroup>
        </FormGroup>
...

我们在两个按钮之间使用了空格字符。更好的方法是使用ButtonToolbar组件。

...
        <ButtonToolbar>
          <Button ...>
            Apply
          </Button>
          {' '}
          <Button
            ...
          >
            Reset
          </Button>
        </ButtonToolbar>
...

清单 11-12 中显示了对IssueFilter组件的完整更改。

...

import { Button } from 'react-bootstrap';

import {

  ButtonToolbar, Button, FormGroup, FormControl, ControlLabel, InputGroup,

} from 'react-bootstrap';

...

  render() {
    ...
    return (
      <div>
        Status:
        {' '}
        <select value={status} onChange={this.onChangeStatus}>
          <option value="">(All)</option>
          ...
        </select>
        <FormGroup>
          <ControlLabel>Status:</ControlLabel>
          <FormControl
            componentClass="select"
            value={status}
            onChange={this.onChangeStatus}
          >
            <option value="">(All)</option>

            ...
          </FormControl>
        </FormGroup>
        {' '}
        Effort between:
        {' '}
        <input
          size={5}
          value={effortMin}
          onChange={this.onChangeEffortMin}
        />
        {' - '}
        <input
          size={5}
          value={effortMax}
          onChange={this.onChangeEffortMax}
        />
        <FormGroup>
          <ControlLabel>Effort between:</ControlLabel>
          <InputGroup>
            <FormControl value={effortMin} onChange={this.onChangeEffortMin} />
            <InputGroup.Addon>-</InputGroup.Addon>
            <FormControl value={effortMax} onChange={this.onChangeEffortMax} />
          </InputGroup>
        </FormGroup>
        {' '}
        <ButtonToolbar>
          <Button ...>
            Apply
          </Button>
          {' '}
          <Button ...>
            Reset
          </Button>
        </ButtonToolbar>

      </div>
    );
  }
...

Listing 11-12ui/src/IssueFilter.jsx: Replace Inputs with Bootstrap Form Controls

在使用 Bootstrap 表单控件的这些更改之后,应用的功能应该没有变化。过滤器现在看起来不同了,如图 11-8 中的截图所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig8_HTML.jpg

图 11-8

使用 Bootstrap 表单控件的问题过滤器

网格系统

表单在问题过滤器中的默认布局不是很好,因为它在垂直方向上占用了很多空间,并且输入控件不必要的宽。但是这对于窄屏幕或页面中的窄部分可能很有效。

处理这个问题的一个更好的方法是使用 Bootstrap 的网格系统,让每个字段(包括标签)浮动,也就是说,占据其先例旁边的空间,或者如果屏幕宽度不允许的话,占据其先例下面的空间。问题过滤器是这种行为的一个很好的用例,因为我们希望看到它水平放置,但是在较小的屏幕上,一个在另一个下面。

网格系统是这样工作的:水平空间被分成最多 12 列。一个单元格(使用组件Col)可以在不同的屏幕宽度上占据一列或多列以及不同数量的列。如果一行中的列间距超过 12 个单元格,则单元格会换行(Row组件)。如果需要强制单元格流动中断,则需要新的一行。对于表单来说,使用网格系统的最好方式是有一个单独的行,并指定每个表单控件(一个单元格)在不同的屏幕宽度上占据多少列。例如:

...
<Grid fluid>
  <Row>
    <Col xs={4}>...</Col>
    <Col xs={6}>...</Col>
    <Col xs={3}>...</Col>
  </Row>
</Grid>
...

这个网格有一行三个单元格,分别占据四、六、三列。xs属性表示“极小”的屏幕宽度,因此,这些单元格宽度仅适用于移动设备。其他屏幕尺寸的宽度分配可以使用smmdlg来指定,它们分别代表小、中和大屏幕。如果未指定,将使用小于此尺寸的适用于屏幕尺寸的值。因此,仅使用xs将意味着所有屏幕尺寸使用相同的单元格宽度。

然后,Bootstrap 负责将它们进行布局,并决定如何根据不同的屏幕宽度来调整单元格。在过滤器中,我们有三个宽度大致相等的单元格:状态输入(带标签)、工作输入(带标签)和按钮(在一起)。即使在非常小的屏幕上,我们也不想将输入或按钮分成多行,所以我们将把它们分别视为一个单元格。

让我们从最小的屏幕尺寸开始:移动设备。让我们使用每个单元格一半的屏幕宽度。这意味着我们将状态和努力放在一行,按钮放在下一行。这可以通过指定xs={6}来实现,即总共可用的 12 列的一半。您可能想知道为什么三个各有六列的单元格总共有 18 列,却能容纳一行 12 列。但事实是,网格系统将最后六列换行到另一行(注意,不是行)。

最好用段落和线条来对比流体网格系统。行就像段落,而不是线条。一个段落(行)可以包含多行。随着段落宽度(屏幕宽度)的减少,它将需要更多的行。只有当你要断两组句子(单元格组)的时候,你才真正需要另一段(行)。大多数人花了一些时间来欣赏流体网格系统的这一方面,因为许多流行的示例在固定网格而不是流体网格中显示行和列,因此将屏幕布局为多行。

接下来,让我们考虑一个稍微大一点的屏幕:平板电脑,在横向模式下。这个大小的属性是sm。让我们用一行中的所有三个单元格来填充屏幕宽度。我们必须为每个单元格使用四列的宽度,因此为这些单元格指定sm={4}。如果我们有更多的单元格,那么这也将包装成多行,但因为我们正好有三个,这将适合屏幕上的一行。

在像桌面这样的大屏幕上,我们可以让每个单元格继续占据四列,这不需要更多的属性规范。但是我认为如果表单控件伸展得太多,看起来会很笨拙,所以让我们使用md={3}lg={2}来减少单元格的宽度。这将导致较大屏幕上的尾随列未被占用。

Bootstrap 的网格系统通常以一个<Grid>开始,但是我们的整个内容已经被一个网格包装了,所以我们不需要另一个。我们可以直接添加一个<Row>,在其中我们可以添加<Col> s,它将保存每个FormGroupsButtonToolbar。我们先导入这两个组件。

...
import {
  ...
  Row, Col,
} from 'react-bootstrap';
...

现在,我们可以添加一个Row来替换其中的<div>和三个Col,这将包装表单组和按钮工具栏。

...
      <Row>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <ButtonToolbar>
          ...
          </ButtonToolbar>
        </Col>
      </Row>
...

但这将显示对齐中的一个问题。由于前两个单元格的高度包括标签,因此它大于按钮工具栏的高度。内容垂直居中对齐,按钮将出现在下拉列表和输入字段的上方。为了正确设置,我们还需要为按钮工具栏添加一个FormGroup,并使用&nbsp;添加一个空白标签。

...
          <FormGroup>
            <ControlLabel>&nbsp;</ControlLabel>
            <ButtonToolbar>
            ...
            </ButtonToolbar>
          </FormGroup>
...

发布过滤器的最终更改如清单 11-13 所示。(请注意,对缩进的更改不会突出显示。)

...
import {
  ...
  Row, Col,
} from 'react-bootstrap';
...

  render() {
    ...
    return (
      <div>
      <Row>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>Status:</ControlLabel>
            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>Effort between:</ControlLabel>

            ...
          </FormGroup>
        </Col>
        <Col xs={6} sm={4} md={3} lg={2}>
          <FormGroup>
            <ControlLabel>&nbsp;</ControlLabel>
            <ButtonToolbar>
            ...
            </ButtonToolbar>
          </FormGroup>
        </Col>
      </Row>
      </div>
    );
  }

Listing 11-13ui/src/IssueFilter.jsx: Using the Grid System for Issue Filter

如果您测试这些更改,除了表单的布局之外,您应该不会发现任何行为上的差异。很小和小屏幕尺寸的截图分别如图 11-9 和图 11-10 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig10_HTML.jpg

图 11-10

在小屏幕上用网格系统过滤问题

img/426054_2_En_11_Chapter/426054_2_En_11_Fig9_HTML.jpg

图 11-9

在一个很小的屏幕上用网格系统过滤问题

练习:网格系统

  1. 假设单元格较大,您需要单元格(a)在非常小的屏幕上,一个在另一个下面出现,(b)在小屏幕上,每行最多有两个单元格,(c)在中等大小的屏幕上,一起适合宽度,以及(d)在大屏幕上,一起占据三分之二的屏幕宽度。在这种情况下,列的宽度规格是多少?

  2. 虽然对于移动设备来说很棒,但是在桌面浏览器上输入控件看起来有点过大。怎么做才能让它们看起来小一点?提示:在 https://react-bootstrap.github.io/components/forms/ 查找表单的 React-Bootstrap 文档,并查找控制大小的属性。你可能会找到多个选项,所以选择一个你认为最好的。

本章末尾有答案。

嵌入式表单

有时我们希望表单控件彼此相邻,包括标签。这对于具有两个或三个输入的小型表单来说是非常理想的,这些输入可以全部放在一行中并且紧密相关。这种风格将适合问题添加形式。让我们也用标签替换占位符,使它们更明显,这意味着我们将不得不使用FormGroup s 和ControlLabel s,就像我们对过滤器表单所做的那样。

对于基于网格的表单,我们不必将控件或组包含在<Form>中,因为组的默认行为是垂直布局(一个在另一个下面,包括标签)。对于内嵌表单,我们需要一个带有inline属性的<Form>来包装表单控件。这也很方便,因为我们需要设置表单的其他属性:name 和 submit handler。

与基于网格的表单不同,内嵌表单不需要列和行。FormGroup元素可以一个接一个地放置。此外,按钮周围不需要一个FormGroup,如果没有给按钮一个ControlLabel,也没有对齐含义。至于元素之间的间距,我们需要使用{' '}在标签和控件之间以及表单组之间手动添加间距。

这些是将 Issue Add 表单转换为 Bootstrap inline 表单的唯一更改。这些如清单 11-14 所示。

...
import PropTypes from 'prop-types';

import {

  Form, FormControl, FormGroup, ControlLabel, Button,

} from 'react-bootstrap';

...

  render() {
    return (
      <form Form inline name="issueAdd" onSubmit={this.handleSubmit}>
        <input type="text" name="owner" placeholder="Owner" />
        <input type="text" name="title" placeholder="Title" />
        <button type="submit">Add</button>
        <FormGroup>
          <ControlLabel>Owner:</ControlLabel>
          {' '}
          <FormControl type="text" name="owner" />

        </FormGroup>
        {' '}
        <FormGroup>
          <ControlLabel>Title:</ControlLabel>
          {' '}
          <FormControl type="text" name="title" />
        </FormGroup>
        {' '}
        <Button bsStyle="primary" type="submit">Add</Button>
      </form>
      </Form>
    );
  }
...

Listing 11-14ui/src/IssueAdd.jsx: Changes for Conversion to an Inline Form

这可能是从问题列表页面中删除横向规则的好时机,因为表格和表单本身有明显的分离。页脚确实需要与页面的其余部分分开,所以让我们在页脚中添加一条水平线。这些变化显示在清单 11-15 和清单 11-16 中。

...
function Footer() {
  return (
    <small>
      <hr />
      <p className="text-center">
...

Listing 11-16ui/src/Page.jsx: Addition of Horizontal Rule Above the Footer

Listing 11-15ui/src/IssueList.jsx: Removal of Horizontal Rules

...
        <IssueTable
          ...
        >
        <hr />
        <IssueAdd createIssue={this.createIssue} />
        <hr />
...

现在测试应用时,您会发现这些变化只是视觉上的。务必确保功能没有改变。新屏幕的截图如图 11-11 所示。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig11_HTML.jpg

图 11-11

将问题添加表单作为内嵌表单

练习:内嵌表单

  1. 尝试在非常小的屏幕上查看新的IssueAdd表单。你看到了什么?关于 Bootstrap 和表单,它告诉了你什么?

  2. 假设您没有为控件更改使用标签。还需要一个FormGroup吗?尝试一下,尤其是在非常小的屏幕上。

  3. 这两个控件的宽度是相同的,似乎我们对此没有控制。属性似乎只影响高度。如果要显示更广的标题输入,可以怎么做?

本章末尾有答案。

水平形式

我们将探讨的下一种表单是水平表单,其中标签出现在输入的左侧,但是每个字段一个接一个地出现。通常,输入填充父容器直到右边缘,给它一个对齐的外观。让我们将问题编辑页面更改为使用水平表单,因为该表单有许多字段,这种表单将适合它。当我们这样做时,让我们也使用 Bootstrap 提供的验证状态来突出无效输入,而不是我们自己的基本方法来显示日期输入中的验证错误。

让我们首先导入我们将在IssueEdit.jsx中使用的新组件。

...
import { LinkContainer } from 'react-router-bootstrap';
import {
  Col, Panel, Form, FormGroup, FormControl, ControlLabel,
  ButtonToolbar, Button,
} from 'react-bootstrap';
...

为了布局一个水平的表单,我们需要horizontal属性,因此,我们需要用 Bootstrap 的<Form>替换一个普通的<form>,并设置这个属性。让我们也移动显示正在编辑的问题的 ID 的<h3>,并将整个表单包含在一个面板中,原始<h3>的内容形成面板的标题。让我们继续在表单后面的面板中显示验证消息。这一切都在IssueEdit组件的render()方法中。

...
    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>{`Editing issue: ${id}`}</Panel.Title>
        </Panel.Heading>
        <Panel.Body>
          <Form horizontal onSubmit={this.handleSubmit}>

           ...
          </Form>
          {validationMessage}
        </Panel.Body>
      </Panel>
    )
...

在表单中,我们可以为每个可编辑字段设置通常的FormGroup。在其中,我们将拥有控件标签和实际控件。但这还不是全部。我们还需要指定标签和输入将占用多少宽度。为此,我们需要将<ControlLabel><FormControl>包含在<Col> s 中,并指定列宽。因为我们希望它在大多数情况下填满屏幕,所以我们不会对不同的屏幕尺寸使用不同的宽度,只对小屏幕宽度使用一个规范,使用sm属性将标签和输入按一定比例分开。对于更大的屏幕宽度,网格系统将使用相同的比例。至于屏幕宽度很小,会导致它折叠成单列。让我们在两列之间选择 3-9 的分割。

例如,所有者字段可能是这样的:

...
            <FormGroup>
              <Col sm={3}>
                <ControlLabel>Owner</ControlLabel>
              </Col>
              <Col sm={9}>
                <FormControl name="owner" ... />
              </Col>
            </FormGroup>
...

用一个<Col>包围<FormControl>效果很好,但是对于一个<ControlLabel>,这并没有达到标签右对齐的预期效果。Bootstrap 程序文档中建议的方法是将<Col>componentClass设置为ControlLabel。这样做的效果是用一个ControlLabel和一个Col的组合类来呈现一个元素,而不是用一个<div>中的标签。

回想一下,我们也可以为FormInput指定自己的组件类。这让我们可以在需要的地方使用定制组件NumInputDateInputTextInput来代替常规的<input>。因此Owner字段的最终代码如下:

...
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Owner</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
...

所有其他控件可以类似地编写,对于Status下拉列表的componentClassselect,对于所有者、标题和描述字段的TextInput,对于工作的NumInput,以及对于到期的DateInput。原始控件使用的所有其他属性都可以保留。有关这些其他控件的变更,请参考清单 11-17;为了简洁起见,我没有在这里提出这些变化。

对于创建的日期字段,React-Bootstrap 以组件FormControl.Static的形式提供了一个静态控件。让我们利用这一点。

...
                <FormControl.Static>
                  {created.toDateString()}
                </FormControl.Static>
...

对于 Submit 按钮,我们可以使用primary样式将其转换为 Bootstrap Button组件。但是通常的预期是在任何提交操作之后需要有一个取消操作。因为这不是一个对话框,所以让我们使用一个返回到问题列表的链接来代替取消操作。这可以通过围绕新的后退按钮的LinkContainer来实现,它被设计成一个链接。

...
                <ButtonToolbar>
                  <Button bsStyle="primary" type="submit">Submit</Button>
                  <LinkContainer to="/issues">
                    <Button bsStyle="link">Back</Button>
                  </LinkContainer>
                </ButtonToolbar>
...

现在,由于按钮不需要标签,我们可以像以前一样使用相同的技巧,在第一个单元格中提供一个空标签。但是更好的方法是,现在我们在网格中有了列,指定一个到列开始位置的偏移量。

...
            <FormGroup>
              <Col smOffset={3} sm={6}>
                <ButtonToolbar>
                ...
                </ButtonToolbar>
              </Col>
            </FormGroup>
...

我们可以保留下一个和上一个链接,但是让我们使用一个面板页脚来放置它们,就在面板主体的末尾。

...
        </Panel.Body>
        <Panel.Footer>
          <Link to={`/edit/${id - 1}`}>Prev</Link>
          {' | '}
          <Link to={`/edit/${id + 1}`}>Next</Link>
        </Panel.Footer>
...

Bootstrap 的表单控件支持以独特的方式显示无效的输入字段。为了实现这一点,validationState属性可以在任何FormGroup中使用。该属性的值为error会使标签和控件显示为红色,并在表单控件中显示一个表示相同内容的红色十字图标。

我们只有日期输入到期字段,它在此表单中可以有一个无效的状态。让我们使用状态变量invalidFields并在该对象中查找具有该字段名称的属性,以确定有效性。

...
            <FormGroup validationState={
              invalidFields.due ? 'error' : null
            }
            >
              <Col componentClass={ControlLabel} sm={3}>Due</Col>
...

清单 11-17 中显示了对IssueEdit.jsx文件的完整更改。为了可读性,显示了整个render()方法,而不是删除和添加的行,分别以删除线和粗体显示。唯一的其他变化是在进口,这是显示使用常规公约。

...
import { Link } from 'react-router-dom';
import { LinkContainer } from 'react-router-bootstrap';

import {

  Col, Panel, Form, FormGroup, FormControl, ControlLabel,
  ButtonToolbar, Button,

} from 'react-bootstrap';

...

    return (
      <Panel>
        <Panel.Heading>
          <Panel.Title>{`Editing issue: ${id}`}</Panel.Title>
        </Panel.Heading>
        <Panel.Body>
          <Form horizontal onSubmit={this.handleSubmit}>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Created</Col>
              <Col sm={9}>
                <FormControl.Static>
                  {created.toDateString()}
                </FormControl.Static>
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Status</Col>
              <Col sm={9}>
                <FormControl
                  componentClass="select"
                  name="status"
                  value={status}
                  onChange={this.onChange}

                >
                  <option value="New">New</option>
                  <option value="Assigned">Assigned</option>
                  <option value="Fixed">Fixed</option>
                  <option value="Closed">Closed</option>
                </FormControl>
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Owner</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  name="owner"
                  value={owner}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Effort</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={NumInput}
                  name="effort"
                  value={effort}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup validationState={
              invalidFields.due ? 'error' : null
            }
            >
              <Col componentClass={ControlLabel} sm={3}>Due</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={DateInput}
                  onValidityChange={this.onValidityChange}
                  name="due"
                  value={due}
                  onChange={this.onChange}

                  key={id}
                />
                <FormControl.Feedback />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Title</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  size={50}
                  name="title"
                  value={title}
                  onChange={this.onChange}
                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col componentClass={ControlLabel} sm={3}>Description</Col>
              <Col sm={9}>
                <FormControl
                  componentClass={TextInput}
                  tag="textarea"
                  rows={4}
                  cols={50}
                  name="description"
                  value={description}
                  onChange={this.onChange}

                  key={id}
                />
              </Col>
            </FormGroup>
            <FormGroup>
              <Col smOffset={3} sm={6}>
                <ButtonToolbar>
                  <Button bsStyle="primary" type="submit">Submit</Button>
                  <LinkContainer to="/issues">
                    <Button bsStyle="link">Back</Button>
                  </LinkContainer>
                </ButtonToolbar>
              </Col>
            </FormGroup>
          </Form>
          {validationMessage}
        </Panel.Body>
        <Panel.Footer>
          <Link to={`/edit/${id - 1}`}>Prev</Link>
          {' | '}
          <Link to={`/edit/${id + 1}`}>Next</Link>
        </Panel.Footer>
      </Panel>
    );
  }
...

Listing 11-17ui/src/IssueEdit.jsx: render() Method Rewritten to Use a Bootstrap Horizontal Form

此时,如果您测试应用,您会发现日期字段没有填满屏幕的宽度。它看起来也与其他输入有很大不同。原因是我们根据验证状态将DateInput中的输入类设置为“null或“invalid”。Bootstrap 通常会为输入设置一个类,我们的设置,尤其是 null,会覆盖它。

我们在DateInput类中需要的是保留 Bootstrap 已经设置给<input>的类。一种选择是用this.props.className代替className。但是除了className之外,可能还有其他的属性被穿过。所以更安全的做法是使用其余的属性,并将它们传递给<input>元素。此外,我们不需要将类设置为invalid,因为 Bootstrap 的validationState会替换它。

清单 11-18 中显示了DateInput的变更。

...
  render() {
    const { valid, focused, value } = this.state;
    const { value: origValue, name } = this.props;
    const className = (!valid && !focused) ? 'invalid' : null;
    const { value: origValue, onValidityChange, ...props } = this.props;
    const displayValue = (focused || !valid) ? value
      : displayFormat(origValue);
    return (
      <input
        type="text"
        size={20}
        name={name}
        className={className}
        {...props}
        value={displayValue}
        placeholder={focused ? 'yyyy-mm-dd' : null}
        onFocus={this.onFocus}
    ...
 }
...

Listing 11-18ui/src/DateInput.jsx: Pass Through Class and Other Properties from Parent

更改之后,当您测试表单时,它将看起来像它应该的样子。该表单的屏幕截图如图 11-12 所示,其中包括验证消息和到期字段的错误指示,该字段显示红色边框和红色 x。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig12_HTML.jpg

图 11-12

作为水平 Bootstrap 表单的问题编辑页面

练习:水平形式

  1. 添加一个验证,使用 Bootstrap 的validationState检查标题是否为三个字符或更多,就像我们对 Due 字段所做的那样。这在效果上与 Due 字段的验证有何不同?视觉上有什么不同?为什么呢?

本章末尾有答案。

验证警报

Bootstrap 通过Alert组件提供了风格优美的警报。转换为 Bootstrap 式警报的第一个候选项是问题编辑页面中的验证消息。这可能看起来像页面的其余部分对齐和样式。也可能更微妙。因为表单域本身显示有问题,所以在用户单击 Submit 之前不需要显示错误消息。我们还会让用户在看到消息后将其删除。

React-Bootstraps 的<Alert>组件很好地解决了这个问题。它有不同风格的消息,如dangerwarning,它也有能力显示关闭图标。让我们将这个组件包含在IssueEdit组件的导入列表中。

...
import {
  ...
  ButtonToolbar, Button, Alert,
} from 'react-bootstrap';
...

然后,让我们使用这个组件来构造验证消息。

...
      validationMessage = (
        <div className="error">
        <Alert bsStyle="danger">
          Please correct invalid fields before submitting.
        </Alert>
        </div>
...

但是它的可见性需要由父组件来处理:消息应该基于IssueEdit中的状态变量有条件地显示。让我们添加这个状态变量。

...
  constructor() {
    ...
    this.state = {
      ...
      showingValidation: false,
    };
...

让我们使用这个新的状态变量来控制validationMessage的内容,同时初始化这个变量。

...
    const { invalidFields, showingValidation } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0 && showingValidation) {
      validationMessage = (
        ...
      );
    }
...

关闭图标是<Alert>的一部分,为了让该图标使消息消失,我们必须传入一个修改可见性状态的处理程序。Alert组件接受一个名为onDismiss的回调来实现这一点。当用户单击关闭图标时,调用这个回调。现在让我们定义两个方法来切换验证消息可见性的状态,并将它们绑定到构造函数中的this(参见清单 11-19 中的bind语句)。

...
  showValidation() {
    this.setState({ showingValidation: true });
  }

  dismissValidation() {
    this.setState({ showingValidation: false });
  }
...

第二个方法可以作为onDismiss属性传递给Alert组件。

...
        <Alert bsStyle="danger" onDismiss={this.dismissValidation}>
...

现在,验证消息的可见性总是假的。正如我们已经决定的,让我们在用户点击提交时开始显示消息。这可以通过在提交处理程序中无条件调用showValidation()来完成。如果没有错误,则抑制验证消息。

...
  async handleSubmit(e) {
    e.preventDefault();
    this.showValidation();
    ...
  }
...

让我们也将验证消息从表单外部移动到一个新表单组中的 Submit 按钮,就在包含 Submit 按钮的FormGroup之后。我们将使用与按钮工具栏相同的策略来指定开始验证消息列的偏移量。

...
            <FormGroup>
              <Col smOffset={3} sm={9}>{validationMessage}</Col>
            </FormGroup>
          </Form>
          {validationMessage}
...

清单 11-19 中显示了对IssueEdit组件的完整更改。

...
import {
  ...
  ButtonToolbar, Button, Alert,
} from 'react-bootstrap';
...

  constructor() {
    ...
    this.state = {
      ...
      showingValidation: false,
    };
  }
...

  async handleSubmit(e) {
    e.preventDefault();
    this.showValidation();
    ...
  }
...

  async loadData() {
    ...
  }

  showValidation() {
    this.setState({ showingValidation: true });

  }

  dismissValidation() {
    this.setState({ showingValidation: false });
  }
...

  render() {
    ...
    const { invalidFields, showingValidation } = this.state;
    let validationMessage;
    if (Object.keys(invalidFields).length !== 0 && showingValidation) {
      validationMessage = (
        <div className="error">
        <Alert bsStyle="danger" onDismiss={this.dismissValidation}>
          Please correct invalid fields before submitting.
        </Alert>
        </div>
      );
    }
    ...
            <FormGroup>
              <Col smOffset={3} sm={9}>{validationMessage}</Col>
            </FormGroup>
          </Form>
          {validationMessage}

  }
...

Listing 11-19ui/src/IssueEdit.jsx: Showing Validation Using Bootstrap Alert Component

此时,我们可以去掉一些用红色字体显示这些错误的样式。对样式表的修改如清单 11-20 所示。

...
  <style>
    ...
    input.invalid {border-color: red;}
    div.error {color: red;}
    ....
  </style>
...

Listing 11-20ui/public/index.html: Removal of Old Styles

现在,如果您测试应用,您会发现 Due 字段中的错误被阻止提交。只有在您单击 Submit 后,才会显示程式化的错误消息,并且可以使用警告消息右上角的红色 X 来消除该错误消息。图 11-13 显示了一个问题编辑页面的屏幕截图,其中 Due 字段有一个错误。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig13_HTML.jpg

图 11-13

使用危险风格的 Bootstrap 警告的验证消息

烤面包

现在让我们看看结果消息和信息性警报,也就是操作成功和失败的报告。这些消息是为了不引人注目,所以让它们在几秒钟后自动消失,而不是让用户关闭它们。我们还将让消息覆盖页面,并像 Android 操作系统中的吐司消息一样过渡进出。

因为许多页面都需要显示这样的消息,所以让我们为此创建一个新的可重用的定制组件。让我们以 Android 操作系统的 Toast 消息命名这个新组件Toast。我们将在警报组件本身上建模接口:可见性将由父组件控制,父组件传递一个onDismiss属性,可以调用该属性来消除它。除了关闭图标的点击调用这个onDismiss回调之外,还会有一个定时器在到期时调用onDismiss()。它显示的消息可以被指定为组件的子组件。

下面是这个组件的一个例子,它可以放在父组件的 DOM 层次结构中的任何位置,因为它绝对位于父组件的布局之外。

...
        <Toast
          showing={this.state.showingToast}
          onDismiss={this.dismissToast}
          bsStyle="success"
        >
...

让我们开始在ui/src目录下名为Toast.jsx的新文件中实现Toast组件。我们将从 React 的基本导入和我们将使用的 React-Bootstrap 组件开始:AlertCollapse以及类本身的定义。

...
import React from 'react';
import { Alert, Collapse } from 'react-bootstrap';

export default class Toast extends React.Component {
}
...

render()方法中,我们将首先添加一个具有所需属性的警报,所有这些属性都是作为道具从父 Node 传入的。

...
  render() {
    const {
      showing, bsStyle, onDismiss, children,
    } = this.props;
    return (
          <Alert bsStyle={bsStyle} onDismiss={onDismiss}>
            {children}
          </Alert>
    );
  }
...

让我们将警告消息放在靠近窗口左下角的位置,覆盖任何其他 UI 元素。为此,我们可以使用样式position: fixed将警告包含在绝对定位的<div>中。

...
        <div style={{ position: 'fixed', bottom: 20, left: 20 }}>
          <Alert ... />
        </div>
...

为了显示和隐藏警报,我们将使用 React-Bootstrap 的Collapse组件。这个组件接受一个名为in的属性,这个属性决定了它的子元素是淡出in还是淡出。当设置为true时,子元素显示(淡入),当设置为false时,子元素隐藏(淡出)。为此,我们可以直接使用传入的属性showing

...
      <Collapse in={showing}>
        <div ... />
      </Collapse>
...

现在,让我们设置五秒钟后自动解散。因为我们期望 Toast 被构造为showing被设置为false,所以每当 Toast 被显示时,我们可以期待一个componentDidUpdate()调用。因此,在这个生命周期方法中,让我们添加一个计时器,并在它到期时调用onDismiss

...
componentDidUpdate() {
    const { showing, onDismiss } = this.props;
    if (showing) {
      setTimeout(onDismiss, 5000);
    }
  }
...

但是,即使用户离开了页面,计时器也可能触发,所以在组件被卸载时关闭计时器是个好主意。因此,让我们将计时器保存在一个名为dismissTimer的对象变量中,并在组件被卸载时清除这个计时器。在设置新的定时器之前,让我们也清除同一个定时器。

...
  componentDidUpdate() {
    ...
    if (showing) {
      clearTimeout(this.dismissTimer);
      this.dismissTimer = setTimeout(onDismiss, 5000);
    }
  }

  componentWillUnmount() {
    clearTimeout(this.dismissTimer);
  }
...

清单 11-21 中显示了Toast.jsx的完整源代码。

import React from 'react';
import { Alert, Collapse } from 'react-bootstrap';

export default class Toast extends React.Component {
  componentDidUpdate() {
    const { showing, onDismiss } = this.props;
    if (showing) {
      clearTimeout(this.dismissTimer);
      this.dismissTimer = setTimeout(onDismiss, 5000);
    }
  }

  componentWillUnmount() {
    clearTimeout(this.dismissTimer);
  }

  render() {
    const {
      showing, bsStyle, onDismiss, children,
    } = this.props;
    return (
      <Collapse in={showing}>
        <div style={{ position: 'fixed', bottom: 20, left: 20 }}>
          <Alert bsStyle={bsStyle} onDismiss={onDismiss}>
            {children}
          </Alert>
        </div>
      </Collapse>
    );
  }
}

Listing 11-21ui/src/Toast.jsx: New Component to Show a Toast Message

要使用Toast组件,我们必须对所有需要显示成功或错误消息的组件进行修改。但是在graphQLFetch.js中还有数据获取功能,显示任何错误的警告。因为这不是一个组件,所以让调用组件传入一个回调来显示错误。我们还将使这个回调成为可选的,以便调用者可以选择隐藏错误的显示,并通过查看返回值来处理它。我们也可以删除由于alert()函数调用的存在而禁用 ESLint 错误的注释,因为我们将不再有这些。graphQLFetch.js的变更如清单 11-22 所示。

...

/* eslint "no-alert": "off" */

...

export default async function
graphQLFetch(query, variables= {}, showError = null) {

  ...
    if (result.errors) {
        ...
        alert(`${error.message}:\n ${details}`);
        if (showError) showError(`${error.message}:\n ${details}`);
      } else if (showError) {
        alert(`${error.extensions.code}: ${error.message}`);
        showError(`${error.extensions.code}: ${error.message}`);
      }
    }
    ...
  } catch (e) {
    alert(`Error in sending data to server: ${e.message}`);
    if (showError) showError(`Error in sending data to server: ${e.message}`);
    ...
  }
...

Listing 11-22ui/src/graphQLFetch.js: Changes for Replacing Alerts with a Callback

接下来,让我们更改组件IssueDetailIssueEditIssueList来使用 Toast。这些组件的变化非常相似。让我们从导入组件和对状态的更改开始。我们需要一个可见性变量,一个消息变量,一个 Toast 类型变量(错误、成功、警告等)。).

...
import Toast from './Toast.jsx';
...
  constructor() {
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'success',
    };
    ...
  }
...

然后,让我们创建三个方便的函数:一个显示成功消息,一个显示错误消息,一个消除祝酒词。所有这些都是用 Toast 变量的新值来设置状态。

...
  showSuccess(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'success',
    });
  }

  showError(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'danger',
    });
  }

  dismissToast() {
    this.setState({ toastVisible: false });
  }
...

(这些必须绑定到this,我在这里没有明确显示它的代码。参见清单 11-23 。)

方法dismissToast()将被传递给Toast组件。我们可以用showSuccess()方法替换任何警报,例如在handleSubmit:IssueEdit组件中

...
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
      this.showSuccess('Updated issue successfully');
    }
...

至于错误,我们可以将方法this.showError传递给graphQLFetch()的函数调用:

...
    const data = await graphQLFetch(query, { ... }, this.showError);
...

组件可以在任何地方呈现,但是让我们选择在 JSX 的最末端呈现,就在最后的结束标记之前。在IssueEdit的情况下,它将正好在面板的结束标记之前。

...
  render() {
    ...
    const { toastVisible, toastMessage, toastType } = this.state;

    return (
      <Panel>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </Panel>
    );
...

下面的清单显示了对每个组件的实际更改,每个组件都有微小的变化。在IssueList组件中,除了前面的更改之外,让我们添加一条成功删除问题的成功消息。在IssueDetail组件中,不需要成功消息,因此不包括方法及其绑定。

清单 11-23 、清单 11-24 和清单 11-25 中显示了这些组件的变化。

...
import graphQLFetch from './graphQLFetch.js';

import Toast from './Toast.jsx';

...

  constructor() {
    ...
    this.state = { issues: [] }
      issues: [],
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'info',
    };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);

  }
...

  async loadData() {
    ...
    const data = await graphQLFetch(query, vars, this.showError);
    ...
  }

  async createIssue(issue) {
    ...
    const data = await graphQLFetch(query, { issue }, this.showError);
    ...
  }

  async closeIssue(index) {
    ...
    const data = await graphQLFetch(query, { id: issues[index].id },
      this.showError);
    ...
  }

  async deleteIssue(index) {
    ...
    const data = await graphQLFetch(query, { id }, this.showError);
    ...
    if (data && data.issueDelete) {
      ...
      this.showSuccess(`Deleted issue ${id} successfully.`);
    }
    ...
  }

  showSuccess(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'success',
    });
  }

  showError(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'danger',

    });
  }

  dismissToast() {
    this.setState({ toastVisible: false });
  }

  render() {
    const { issues } = this.state;
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
    return (
      <React.Fragment>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </React.Fragment>
    );
  }
...

Listing 11-25ui/src/IssueList.jsx: Changes for Including Toast Message

...
import graphQLFetch from './graphQLFetch.js';

import Toast from './Toast.jsx';

...

  constructor() {
    this.state = { issue: {} };
      issue: {},
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'info',
    };

    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...
  componentDidUpdate(prevProps) {
    ...
  }

  showError(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'danger',
    });
  }

  dismissToast() {
    this.setState({ toastVisible: false });
  }

  async loadData() {
    ...
    const data = await graphQLFetch(query, { id }, this.showError);
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    return (
      <div>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}

          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </div>
    );
  }
...

Listing 11-24ui/src/IssueDetail.jsx: Changes for Including Toast Component

...
import TextInput from './TextInput.jsx';

import Toast from './Toast.jsx';

...

  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ' ',
      toastType: 'success',
    };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
  ...

  async handleSubmit(e) {
    ...
    const data = await graphQLFetch(query, { changes, id }, this.showError);
    if (data) {
      this.setState({ issue: data.issueUpdate });
      alert('Updated issue successfully'); // eslint-disable-line no-alert
      this.showSuccess('Updated issue successfully');
    }
  }

  async loadData() {
    ...
    const query = `query issue($id: Int!) {

      ...
    }`;

    const data = await graphQLFetch(query, { id }, this.showError);
    ...
  }
...

  dismissValidation() {
    ...
  }

  showSuccess(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'success',
    });
  }

  showError(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'danger',
    });
  }

  dismissToast() {
    this.setState({ toastVisible: false });

  }
...

  render() {
    ...
    const { toastVisible, toastMessage, toastType } = this.state;

    return (
      <Panel>
        ...
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </Panel>
    );
  }
...

Listing 11-23ui/src/IssueEdit.jsx: Changes for Using Toast Component

现在,我们已经去掉了 UI 代码中的所有警告,并用 Toast 消息替换它们。您可以通过创建或更新具有无效值的问题(如标题少于三个字符)来测试错误消息。您可以通过在编辑页面中保存问题并删除问题来测试成功提示消息。图 11-14 显示了保存更改后编辑页面中的一个成功 Toast 消息的截图。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig14_HTML.jpg

图 11-14

编辑页面中的 Toast 消息

模型

在本节中,我们将用一个模式对话框替换页面内组件IssueAdd,该对话框通过单击标题中的创建问题导航项来启动。这样,用户可以从应用的任何位置创建问题,而不仅仅是从问题列表页面。此外,当提交新问题时,我们将在问题编辑页面中显示新创建的问题,因为无论对话框是从哪里启动的,都可以这样做。

除了模态对话框,创建问题也可以是一个单独的页面。但是,当必需字段的数量很少时,模态工作得更好;用户可以快速创建问题,然后根据需要填写更多信息。

当一个模态被呈现时,它被呈现在保存页面的 DOM 的 main <div>之外。因此,就代码放置而言,它可以放置在组件层次结构中的任何位置。为了启动或关闭该模式,“创建问题”导航项目是控制组件。因此,让我们创建一个自包含的组件:它显示导航项,启动对话框并控制其可见性,创建问题,并在成功创建后转到问题编辑页面。让我们把这个新组件叫做IssueAddNavItem,并把它放在一个叫做IssueAddNavItem.jsx的文件中。

让我们首先将用于创建问题的NavItem从导航栏移动到这个新组件,并添加一个onClick()处理程序,通过调用方法showModal()来显示模态对话框,稍后我们将定义这个方法。

...
  render() {
    return (
      <React.Fragment>
        <NavItem onClick={this.showModal}>
          ...
        </NavItem>
      </React.Fragment>
    );
  }
...

接下来,因为模态组件可以放在任何地方,所以让我们把它添加在NavItem之后。模态对话框定义的根是Modal组件。这个组件需要两个重要的属性:showing,它控制模式对话框的可见性,以及一个onHide()处理程序,当用户单击十字图标关闭对话框时,它将被调用。我们将为showing定义一个状态变量来控制可见性。

...
    const { showing } = this.state;
    return (
      ...
        <NavItem onClick={this.showModal}>
          ...
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal}>

        </Modal>
    );
...

showModal()hideModal()方法只需要适当地设置状态变量。

...
  showModal() {
    this.setState({ showing: true });
  }

  hideModal() {
    this.setState({ showing: false });
  }
...

Modal组件中,让我们使用标题(Modal.Header)来显示模态的标题。属性可以用来显示一个十字图标,点击它可以取消对话框。

...
        <Modal keyboard show={showing} onHide={this.hideModal}>
          <Modal.Header closeButton>
            <Modal.Title>Create Issue</Modal.Title>
          </Modal.Header>
        </Modal>
...

然后,在主体中,让我们添加一个带有两个字段 Title 和 Owner 的垂直表单(默认)。这将是两个FormGroups,就像最初的 Add inline 表单一样,但是它们之间没有任何间隔。

...
          <Modal.Body>
            <Form name="issueAdd">
              <FormGroup>
                <ControlLabel>Title</ControlLabel>
                <FormControl name="title" autoFocus />
              </FormGroup>
              <FormGroup>
                <ControlLabel>Owner</ControlLabel>
                <FormControl name="owner" />
              </FormGroup>
            </Form>
          </Modal.Body>
...

我们将使用模态的页脚(Modal.Footer)来显示一个按钮工具栏,其中 Submit 和 Cancel 按钮分别作为主按钮和链接按钮。我们让提交按钮 click 调用方法handleSubmit(),取消按钮隐藏模态对话框。

...
          <Modal.Footer>
            <ButtonToolbar>
              <Button
                type="button"
                bsStyle="primary"
                onClick={this.handleSubmit}
              >
                Submit
              </Button>
              <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>
            </ButtonToolbar>
          </Modal.Footer>
...

handleSubmit()方法中,我们需要结合读取表单值(从文件IssueAdd.jsx中)和通过调用 Create API(从IssueList.jsx中)提交值这两个功能。当用户点击提交时,我们还需要关闭模式对话框。如果成功,我们将通过将编辑页面的链接推送到历史记录来显示问题编辑页面。

...
  async handleSubmit(e) {
    e.preventDefault();
    this.hideModal();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    };
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const data = await graphQLFetch(query, { issue }, this.showError);
    if (data) {
      const { history } = this.props;
      history.push(`/edit/${data.issueAdd.id}`);
    }
  }
...

为了处理graphQLFetch调用中的错误,我们需要显示一条 Toast 消息。因此,我们将像前面几节一样定义 Toast 状态变量和一个showError()方法。新文件IssueAddNavItem.jsx的内容,包括 Toast 消息的添加,如清单 11-26 所示。

import React from 'react';
import { withRouter } from 'react-router-dom';
import {
  NavItem, Glyphicon, Modal, Form, FormGroup, FormControl, ControlLabel,
  Button, ButtonToolbar, Tooltip, OverlayTrigger,
} from 'react-bootstrap';

import graphQLFetch from './graphQLFetch.js';
import Toast from './Toast.jsx';

class IssueAddNavItem extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      showing: false,
      toastVisible: false,
      toastMessage: '',
      toastType: 'success',
    };
    this.showModal = this.showModal.bind(this);
    this.hideModal = this.hideModal.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }

  showModal() {
    this.setState({ showing: true });
  }

  hideModal() {
    this.setState({ showing: false });

  }

  showError(message) {
    this.setState({
      toastVisible: true, toastMessage: message, toastType: 'danger',
    });
  }

  dismissToast() {
    this.setState({ toastVisible: false });
  }

  async handleSubmit(e) {
    e.preventDefault();
    this.hideModal();
    const form = document.forms.issueAdd;
    const issue = {
      owner: form.owner.value,
      title: form.title.value,
      due: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 10),
    };
    const query = `mutation issueAdd($issue: IssueInputs!) {
      issueAdd(issue: $issue) {
        id
      }
    }`;

    const data = await graphQLFetch(query, { issue }, this.showError);
    if (data) {
      const { history } = this.props;
      history.push(`/edit/${data.issueAdd.id}`);
    }
  }

  render() {
    const { showing } = this.state;
    const { toastVisible, toastMessage, toastType } = this.state;
    return (
      <React.Fragment>
        <NavItem onClick={this.showModal}>
          <OverlayTrigger
            placement="left"
            delayShow={1000}

            overlay={<Tooltip id="create-issue">Create Issue</Tooltip>}
          >
            <Glyphicon glyph="plus" />
          </OverlayTrigger>
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal}>
          <Modal.Header closeButton>
            <Modal.Title>Create Issue</Modal.Title>
          </Modal.Header>
          <Modal.Body>
            <Form name="issueAdd">
              <FormGroup>
                <ControlLabel>Title</ControlLabel>
                <FormControl name="title" autoFocus />
              </FormGroup>
              <FormGroup>
                <ControlLabel>Owner</ControlLabel>
                <FormControl name="owner" />
              </FormGroup>
            </Form>
          </Modal.Body>
          <Modal.Footer>
            <ButtonToolbar>
              <Button
                type="button"
                bsStyle="primary"
                onClick={this.handleSubmit}
              >
                Submit
              </Button>
              <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>

            </ButtonToolbar>
          </Modal.Footer>
        </Modal>
        <Toast
          showing={toastVisible}
          onDismiss={this.dismissToast}
          bsStyle={toastType}
        >
          {toastMessage}
        </Toast>
      </React.Fragment>
    );
  }
}

export default withRouter(IssueAddNavItem);

Listing 11-26ui/src/IssueAddNavItem.jsx: New File for Adding an Issue

为了使用这个新组件,我们需要用这个新组件的实例替换Page.jsx中的NavItem。对Page.jsx的更改如清单 11-27 所示。

...
import {
  ...
  MenuItem, Glyphicon, Tooltip, OverlayTrigger,
  Grid,
}

import IssueAddNavItem from './IssueAddNavItem.jsx';

...
function NavBar() {
  ...
      <Nav pullRight>
        <NavItem>
          ...
        </NavItem>
        <IssueAddNavItem />
        ...
      </Nav>
  ...
}
...

Listing 11-27ui/src/Page.jsx

IssueList组件中,我们可以删除IssueAddcreateIssue函数的渲染,因为问题是直接从模态中创建的。IssueList.jsx的变更如清单 11-28 所示。

...
import IssueTable from './IssueTable.jsx';

import IssueAdd from './IssueAdd.jsx';

...
  constructor() {
    ...
    this.createIssue = this.createIssue.bind(this);
    ...
  }
...

  async createIssue(issue) {
    ...
  }

  render() {
    ...
        <IssueAdd createIssue={this.createIssue} />

    ...
  }
...

Listing 11-28ui/src/IssueList.jsx: Changes to Remove IssueAdd and createIssue

完成这些更改后,如果您点击导航栏右侧的+图标,您应该会看到如图 11-15 所示的模态对话框。您应该能够使用此对话框创建新问题,如果成功,您应该会看到新创建问题的编辑页面。

img/426054_2_En_11_Chapter/426054_2_En_11_Fig15_HTML.jpg

图 11-15

“创建问题模式”对话框

摘要

在 MERN 堆栈中向应用添加样式和主题与任何其他堆栈没有什么不同,因为重要的部分是 CSS 以及各种浏览器如何处理样式。它们不会因选择的堆栈而有所不同。这个领域的先驱 Bootstrap 实现了浏览器独立性和开箱即用的响应行为。React-Bootstrap 取代了处理 Bootstrap 元素的独立 JavaScript 代码,使自包含组件成为可能。

我们可以使用 Material-UI 或任何其他框架来实现所需要的东西,但是从本章中可以看出,如果需要的话,如何设计自己风格的可重用 UI 组件。如果你看一下位于 https://react-bootstrap.github.io/getting-started/introduction 的 React-Bootstrap 和位于 http://getbootstrap.com/docs/3.3/ 的 Bootstrap 本身的文档,以了解这两个库提供的组件种类,那也是很好的。

此时,除了一些高级特性之外,应用可能看起来是完整的。但是,如果应用需要搜索引擎机器人能够自然地索引页面,它将需要能够直接从服务器提供完全构建的页面,因为它们会出现在浏览器中。这是因为搜索引擎通常不会在它们抓取的页面中运行 JavaScript,对于 SPA 来说,这是在浏览器中构建页面的关键。

在下一章中,您将学习如何在服务器上构建 HTML 并响应客户端,更重要的是,如何在客户端和 UI 服务器上使用相同的代码库来实现这一点。

练习答案

练习:导航栏

  1. 仅仅使用fixedTop属性将导致导航栏覆盖内容的顶部。要解决这个问题,您需要按照 Bootstrap 文档中的建议给body标签添加一个填充。

    通常,除了 React-Bootstrap 文档之外,您还需要参考 Bootstrap 文档,因为 React-Bootstrap 基于 Bootstrap。当您这样做时,请记住选择 3.3 版文档,而不是最新的 Bootstrap 4 版文档。

练习:面板

  1. 属性控制面板的初始状态是展开还是关闭。将该属性设置为true(或仅指定该属性)将具有在浏览器刷新时显示过滤器打开的效果。为了使该行为依赖于过滤器的存在,您可以检查 location 的搜索属性是否为空字符串,以计算该属性的值,如下面的代码片段所示:

    ...
        const hasFilter = location.search !== ' ';
        return (
          <React.Fragment>
            <Panel defaultExpanded={hasFilter}>
          ...
    ...
    
    

练习:网格系统

  1. 如果单元格更大,规格xs={12} sm={6} md={4} lg={3}将会发挥最佳作用。在非常小和小的屏幕上,你会看到多行,在中等和大的屏幕上,单元格将适合一行。

  2. 为了让控件看起来更小,你可以在FormGroup s 上使用bsSize="small"属性。它也可以在FormControl s 上使用,但是在FormGroup上使用它也会影响标签。但是这不适用于按钮——我们必须为每个按钮指定属性。

练习:内嵌表单

  1. 在一个非常小的屏幕上,控件看起来像一个默认的窗体,标签和控件一个在另一个下面。React-Bootstrap 表单组件具有进行这种转换的媒体查询,就像网格系统中的列一样。

  2. 不,一个表单组并不真的需要显示一个接一个的内嵌表单控件。但是如果没有一个窗体组,在一个非常小的屏幕上,控件之间靠得太近,视觉上没有吸引力。因此,即使不使用标签,最好用一个FormGroup将控件括起来。

  3. 要指定精确的宽度,必须使用内嵌样式,比如style={{ width: 300 }}。如果没有宽度规格,控制项会在非常小的萤幕上填满萤幕的宽度。对于宽度,它采用所有屏幕尺寸上指定的宽度。实际上,如果我们设置了宽度,最好在所有控件上设置尺寸,而不是在某些控件上。

练习:水平形式

  1. The way to add a validation indication is using the following property in the FormGroup corresponding to the Title field:

    ...
                <FormGroup validationState={title.length < 3 ? 'error' : null}>
    ...
    
    

    尽管这确实显示了错误(在控件周围使用红色边框,标签为红色字体),但并没有阻止提交。这是因为我们没有让TextInput处理长度错误并通知IssueEdit组件,以便它可以更新invalidFields

    从视觉上看,您会发现在 Title 字段输入无效的情况下,红色的 X 是不存在的。原因与我们对该部分进行更改之前对DateInput的原因相同:我们没有通过 Bootstrap 可能添加到组件中的道具,如class

十二、服务器渲染

在这一章中,我们将探索 React 的另一个基石,除了能够直接渲染到 DOM 之外,还能够在服务器上生成 HTML。这使得能够创建同构的应用,也就是说,应用在服务器和客户机上使用相同的代码库来完成任务:渲染到 DOM 或创建 HTML。

服务器呈现(也称为服务器端呈现或简称为 SSR)与 SPA 的特征相反:不是通过 API 获取数据并在浏览器上构建 DOM,而是在服务器上构建整个 HTML 并发送到浏览器。当应用需要被搜索引擎索引时,就需要它了。搜索引擎机器人通常从根 URL ( /)开始,然后遍历结果 HTML 中存在的所有超链接。它们不执行 JavaScript 来通过 Ajax 调用获取数据,也不查看更改后的 DOM。因此,要让应用的页面被搜索引擎正确地索引,服务器需要响应与 Ajax 调用componentDidMount()方法和随后重新呈现页面后相同的 HTML。

例如,如果向/issues发出请求,那么返回的 HTML 应该预先填充了表中的问题列表。同样的道理也适用于所有其他可以加入书签或者有超链接指向它们的页面。但是这违背了 SPAs 的目的,当用户在应用中导航时,SPAs 提供了更好的用户体验。因此,我们的工作方式如下:

  • 第一次在应用中打开任何页面时(例如,通过在浏览器中键入 URL,或者甚至在已经在应用的任何页面中时在浏览器上选择刷新),整个页面将被构造并从服务器返回。我们称之为服务器渲染

  • 一旦任何页面被加载并且用户导航到另一个页面,我们将使它像 SPA 一样工作。也就是只做 API,直接在浏览器上修改 DOM。我们称之为浏览器渲染

请注意,这适用于任何页面,而不仅仅是主页。例如,用户可以在浏览器中键入某期杂志编辑页面的 URL,该页面将在服务器上构建并返回。

由于实现这一切的步骤并不简单,我们将创建一个新的简单页面——关于页面——用于掌握所需的技术。我们将从一个静态页面开始,然后通过使用 API 获取它所呈现的数据来增加复杂性。一旦我们完善了在服务器上呈现 About 页面的技术,我们将把所需的更改扩展到所有其他页面。

注意:不是所有的应用都需要服务器渲染。如果一个应用不需要被搜索引擎索引,就可以避免服务器渲染带来的复杂性。通常,如果页面没有公共访问权限,它们不需要搜索引擎索引。单单性能优势不足以证明服务器渲染的复杂性。

新目录结构

到目前为止,我们并没有过多关注 UI 的目录结构。这是因为src目录下的所有代码都应该被编译成一个包并提供给浏览器。这种情况不会再发生了。我们需要的是三组文件:

  • 所有的共享文件,本质上就是所有的 React 组件。

  • 用于使用 Express 运行 UI 服务器的一组文件。这将为服务器渲染导入共享的 React 组件。

  • 浏览器包的起点,包含所有共享的 React 组件,可以发送到浏览器执行。

所以,让我们把当前的源代码分成三个目录,每个目录对应一组文件。让我们将共享的 React 组件保存在src目录中。至于特定于浏览器和特定于服务器的文件,让我们分别创建两个名为browserserver的目录。然后,让我们将特定于浏览器的文件App.jsx移到它的目录中,将特定于服务器的文件uiserver.js移到server目录中。

$ cd ui
$ mkdir browser
$ mkdir server
$ mv src/App.jsx browser
$ mv uiserver.js server

这种变化还要求林挺、编译和捆绑配置发生变化,以反映新的目录结构。让我们从林挺的变化开始。让我们有四个.eslintrc文件,一个在根目录(ui),一个在子目录中——srcbrowserserver目录——这样每个文件都继承了根目录下的文件。在ui目录的.eslintrc处,我们需要做的就是指定要继承的规则集,也就是airbnb。对此的更改如清单 12-1 所示。

...
{
  "extends": "airbnb-base",
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}
...

Listing 12-1ui/.eslintrc: Changes to Keep Only the Base

接下来,在共享的src文件夹中,让我们为环境添加node: true,以表明这些文件集将在 Node.js 和浏览器中运行。我们还将删除extends规范,因为它将从父目录的.eslintrc继承而来。对此的更改如清单 12-2 所示。

...
{
  "extends": "airbnb",
  "env": {
    "browser": true,
    "node": true
   },
   rules: {
  ...
}
...

Listing 12-2ui/src/.eslintrc: Changes to Add Node

现在,让我们在browser目录下创建一个新的.eslintrc,它与src目录下原来的.eslintrc相同,没有 Node.js 环境。这个新文件如清单 12-3 所示。

{
  "env": {
    "browser": true
  },
  rules: {
    "import/extensions": [ 'error', 'always', { ignorePackages: true } ],
    "react/prop-types": "off"
  }
}

Listing 12-3ui/browser/.eslintrc: New ESLint Configuration for Browser Source Files

至于服务器.eslintrc,它是原始服务器在ui目录中的副本,只指定环境(仅 Node.js)并允许控制台消息。如清单 12-4 所示。

{
  "env": {
    "node": true
  },
  "rules": {
    "no-console": "off"
  }
}

Listing 12-4ui/server/.eslintrc: New ESLint Configuration for Server Source Files

下一步,让我们在browser目录中添加一个.babelrc,它是共享的src目录中的一个副本。

$ cd ui
$ cp src/.babelrc browser

我们还需要在App.jsxuiserver.js中更改导入/必需文件的位置。这些显示在清单 12-5 和 12-6 中。

...
import Page from '../src/Page.jsx';
...

Listing 12-5ui/browser/App.jsx: New Location of Page.jsx

...

const config = require('./webpack.config.js');

const config = require('../webpack.config.js');

...

Listing 12-6ui/server/uiserver.js: New Location of Webpack Config File

最后,我们需要在package.json中为 UI 服务器启动文件的新位置更改入口点,在webpack.config.js中为App.jsx的位置更改入口点。这些显示在清单 12-7 和 12-8 中。

...
  "scripts": {
    "start": "nodemon -w uiserver.js -w .env uiserver.js",
    "start": "nodemon -w server -w .env server/uiserver.js",
    ...
  }
...

Listing 12-7ui/package.json: Changes for Entry Point of uiserver.js

...
module.exports = {
  ...
  entry: { app: ['./src/App.jsx'] },
  entry: { app: ['./browser/App.jsx'] },
  ...
}

Listing 12-8ui/webpack.config.js

: Changes for Entry Point of the Bundle

经过这些更改后,应用应该像以前一样工作。您应该在启用 HMR 和禁用 HMR 的情况下进行测试,并在启动服务器之前使用npm run compile手动编译浏览器包。

基本服务器渲染

我们使用ReactDOM.render()方法将一个 React 元素呈现到 DOM 中。用于在服务器端创建 HTML 的对应方法是ReactDOMServer.renderToString()。虽然这个方法本身很简单,但是为了使用它我们需要做的改变却不简单。所以,让我们用一个简单的About组件来熟悉一下基本原理。然后,在后面的部分中,我们将对应用中的其他组件使用相同的技术。

清单 12-9 中显示了非常基本的About组件的代码。

import React from 'react';

export default function About() {
  return (
    <div className="text-center">
      <h3>Issue Tracker version 0.9</h3>
      <h4>
        API version 1.0
      </h4>
    </div>
  );
}

Listing 12-9ui/src/About.jsx: New About Component

让我们在应用中包含新的组件,以便在单击扩展菜单项 About 时显示它。这需要的第一个改变是在路由组中,因此/about加载了About组件。清单 12-10 中显示了对Contents.jsx的更改。

...
import IssueEdit from './IssueEdit.jsx';

import About from './About.jsx';

...
    <Switch>
      ...
      <Route path="/report" component={IssueReport} />
      <Route path="/about" component={About} />
      ...
    </Switch>
...

Listing 12-10ui/src/Contents.jsx: Include About Component in the Application

我们还需要更改菜单项,使其链接到/about,而不是虚拟的。清单 12-11 中显示了对Page.jsx的更改。

...
function NavBar() {
  return (
    ...
          <LinkContainer to="/about">
            <MenuItem>About</MenuItem>
          </LinkContainer>
    ...
  );
}
...

Listing 12-11ui/src/Page.jsx: Include About in the Navigation Bar

现在,如果您将浏览器指向http://localhost:8000/并通过点击 About extended 菜单项导航到新页面,您应该会看到 About 页面,就像应用中的任何其他页面一样。该页面截图如图 12-1 所示。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig1_HTML.jpg

图 12-1

“关于”页面

但是在这种情况下,组件是由浏览器呈现的,就像所有其他组件到目前为止被呈现的一样。图 12-2 显示了导致使用浏览器渲染显示“关于”页面的事件序列图。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig2_HTML.jpg

图 12-2

浏览器渲染序列图

下面是对浏览器渲染过程中发生的事情的解释。解释和图表用于“关于”页面,但顺序对于任何其他页面都是相同的:

  1. 用户在主页上键入 URL 或刷新浏览器。

  2. UI 服务器返回index.html,它引用了 JavaScript app.bundle.js。它也被获取,并且包含 react 组件,包括About组件。现在,页面被认为是加载的。(问题列表组件也将被挂载,但目前这并不重要。)

  3. 用户单击“关于”页面的链接。

  4. React 挂载并呈现About组件,其代码是 JavaScript 包的一部分。此时,组件中的所有静态文本都可以看到。

  5. 一旦初始挂载完成,就会调用componentDidMount(),这将触发对 API 服务器的调用,以获取组件的数据。我们还没有实现这一点,但是通过考虑我们已经实现的其他页面,例如问题列表页面,您应该能够理解这一点。

  6. 使用 API 调用成功获取数据后,组件将使用数据重新呈现。

接下来,让我们在服务器上呈现About组件。由于服务器尚未使用 JSX 编译,我们需要手动将其编译为纯 JavaScript,以便服务器可以包含它:

$ cd ui
$ npx babel src/About.jsx --out-dir server

这将在server目录中产生一个名为About.js的文件。现在,在服务器上,导入About.js后,我们可以使用下面的代码片段呈现组件的字符串表示:

...
  const markup = ReactDOMServer.renderToString(<About />);
...

但是这将只为About组件产生标记。我们仍然需要 HTML 的其余部分,如<head>部分,以及插入到contents <div>中的组件。所以,让我们用现有的index.html制作一个模板,它可以接受<div>的内容并返回完整的 HTML。

pug 等强大的模板语言可以用于此,但我们的要求非常简单,因此我们将只使用 ES2015 模板字符串功能。让我们将这个函数放在server目录下一个名为template.js的文件中。模板字符串与index.html的内容相同,只是删除了<script>标签。该文件的全部内容如清单 12-12 所示。突出显示的变化是与index.html的不同。

function template(body) {

  return `<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <title>Pro MERN Stack</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" >
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <style>
    table.table-hover tr {cursor: pointer;}
    .panel-title a {display: block; width: 100%; cursor: pointer;}
  </style>
</head>

<body>
  <!-- Page generated from template. -->
  <div id="contents">${body}</div>

  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>

</html>

`;

}

module.exports = template;

Listing 12-12ui/server/template.js: Template for Server Rendered HTML

我们最终会添加脚本,但目前,最好在没有这种复杂性的情况下测试更改。至于导入About组件并将其呈现为 string,让我们在服务器目录的一个新文件中,在一个名为render()的函数中完成这项工作。该函数将像任何快速路由处理程序一样接收常规请求和响应。然后,它将发送模板作为响应,用从ReactDOMServer.renderToString()创建的标记替换主体。

由于server目录中的代码仍然是纯 JavaScript(还没有编译步骤),所以我们不使用 JSX 来实例化About组件。相反,让我们用React.createElement:

...
  const body = ReactDOMServer.renderToString(
    React.createElement(About),
  );
...

srcserver目录中的代码之间还有一个小的不兼容性。这些使用不同的方式来包含其他文件和模块。您可能还记得第 8 “模块化和 Webpack”,React 代码使用了import/export范式,而不是像在服务器代码中那样使用require/module.exports方式包含模块。幸运的是,这两者是兼容的,有一个小的变化。Babel 编译器将使用export default关键字导出的任何变量也放在module.exports中,但是使用属性default。因此,我们必须在使用require()导入About组件后添加default:

...
const About = require('./About.js').default;
...

这个新文件的完整代码如清单 12-13 所示。

const React = require('react');
const ReactDOMServer = require('react-dom/server');

const About = require('./About.js').default;
const template = require('./template.js');

function render(req, res) {
  const body = ReactDOMServer.renderToString(
    React.createElement(About),
  );
  res.send(template(body));
}

module.exports = render;

Listing 12-13ui/server/render.js: New File for Rendering About

现在,在uiserver.js中,我们可以将这个函数设置为带有/about路径的路由的处理程序。让我们在返回index.html的总括路线之前添加这条路线。这个变化,连同包含render.jsimport声明,显示在清单 12-14 中。

...
const proxy = require('http-proxy-middleware');

const render = require('./render.js');

...

app.get('/about', render);

app.get('*', (req, res) => {
  ...
});
...

Listing 12-14ui/server/uiserver.js: New Route for /About to Return Server-Rendered About

此时,About.js需要手工编译。使用npx babel命令,然后重启服务器。如果您将浏览器指向http://localhost:8000/about,您应该会看到没有任何导航栏装饰的About组件。这是因为我们用About组件替换了占位符${body},而没有布线的Page组件。图 12-3 显示了服务器端呈现的这个普通 About 页面的截图。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig3_HTML.jpg

图 12-3

服务器呈现了关于页面

虽然这与浏览器呈现的版本有一点不同(我们将在后面的部分中解决),但它足以证明相同的组件可以在服务器和浏览器上呈现。图 12-4 中的序列图详细解释了服务器渲染过程中发生的事情,其中包括一个从 API 服务器获取数据的步骤,这个步骤我们还没有实现。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig4_HTML.jpg

图 12-4

服务器渲染序列图

以下是导致服务器渲染的一系列步骤的说明:

  1. 用户键入“关于”页面的 URL(或在“关于”页面上选择浏览器中的刷新)。

  2. 浏览器向 UI 服务器发送对/about的请求。

  3. UI 服务器使用 GraphQL API 调用从 API 服务器获取页面所需的数据。我们将在本章的后面部分实现这一点。

  4. 在 UI 服务器上,用About组件及其数据调用ReactDOM.renderToString()

  5. 服务器返回一个 HTML,其中包含关于页面的标记。

  6. 浏览器将 HTML 转换为 DOM,并且“关于”页面在浏览器中可见。

练习:基本服务器渲染

  1. 假设服务器中呈现的组件的字符串表示非常大。从内存中的模板创建一个字符串会占用大量内存,并且速度很慢。在这种情况下你有什么选择?提示:在 https://reactjs.org/docs/react-dom-server.html 查阅ReactDOMServer的文档,看看还有哪些方法可用。

本章末尾有答案。

服务器的 Webpack

此时,我们只有一个简单的About组件。我们将需要它通过调用about API 来获取数据,并在服务器上呈现数据。我们将在接下来的章节中完成所有这些,但在此之前,让我们先解决不得不从About.jsx手动编译About.js的不便。很快,我们将不得不编译src目录下的所有文件以包含在服务器端,手动编译将变得不可行。

此外,您还看到了import/export范式和require/module.exports范式虽然兼容,但混合使用时并不方便。人们需要记住在使用import/export范例的文件的每个require()之后添加.default

事实证明,Webpack 也可以用于服务器,它可以动态编译 JSX。这也将让我们在 UI 服务器代码库中一致地使用import/export范例。Webpack 的工作方式与前端代码非常相似,但有一点不同。许多服务器端 Node 包(如 Express)与 Webpack 不兼容。它们动态地导入其他包,使得 Webpack 很难遵循依赖链。因此,我们必须从捆绑包中排除第三方库,并依赖于在 UI 服务器的文件系统中存在的node_modules

我们要做的第一件事是在webpack.config.js文件中为服务器配置添加一个新的部分。Webpack 允许导出一系列配置。当遇到一个数组时,Webpack 执行这个数组中的所有配置。新的webpack.config.js文件的轮廓是这样的:

...
const browserConfig = {
  mode: 'development',
  entry: { app: ['./browser/App.jsx'] },
  ...
}

const serverConfig = {
  mode: 'development',
  entry: { server: ['./server/uiserver.js'] },
  ...
}

module.exports = [browserConfig, serverConfig];
...

browserConfig变量包含原始配置内容。在服务器和浏览器之间使用共享文件的一个问题是,我们不能使用相同的巴别塔配置。为 Node.js 编译时,目标只是 Node 的最新版本,而为浏览器编译时,它需要一个浏览器版本列表。因此,让我们去掉srcbrowser目录中的.babelrc,转而通过 Webpack 配置 Babel 选项。这样,我们可以根据目标告诉 Babel 使用什么选项:浏览器还是服务器。

$ cd ui
$ rm src/.babelrc browser/.babelrc

现在,在webpack.config.jsbrowserConfig部分,我们可以这样指定这些选项:

...
        use: 'babel-loader',
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: {
                  ie: '11',
                  ...
                },
              }],
              '@babel/preset-react',
            ],
         },
       },
...

对于服务器配置,我们需要一个输出规范。让我们将这个包编译到一个名为dist(distribution 的简称)的新目录中,并将这个包称为server.js

...
const serverConfig = {
  ...
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
...

至于服务器的 Babel 配置,让我们将所有的jsjsx文件编译到 target Node.js 版本 10,并包含 React 预置。

...
const serverConfig = {
  ...
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: { node: '10' },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
...

这种配置不会起作用,因为它不排除node_modules中的模块。我们可以使用与浏览器配置相同的策略,但是在服务器上这样做的推荐方式是使用webpack-node-externals模块,这样效果更好。让我们安装这个包。

$ cd ui
$ npm install --save-dev webpack-node-externals@1

现在,在 Webpack 配置中,我们可以导入此包并在配置的服务器部分使用它,如下所示:

...
const path = require('path');

const nodeExternals = require('webpack-node-externals');

...
const serverConfig = {
  ...
  target: 'node',
  externals: [nodeExternals()],
  output: {
...

webpack.config.js的最终内容如清单 12-15 所示。这包括一个我没有明确提到的服务器配置的源映射规范。此外,为了简洁起见,我没有显示删除的代码:列出了文件的全部内容。

const path = require('path');
const nodeExternals = require('webpack-node-externals');

const browserConfig = {
  mode: 'development',
  entry: { app: ['./browser/App.jsx'] },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'public'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {

                targets: {
                  ie: '11',
                  edge: '15',
                  safari: '10',
                  firefox: '50',
                  chrome: '49',
                },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
  optimization: {
    splitChunks: {
      name: 'vendor',
      chunks: 'all',
    },
  },
  devtool: 'source-map',

};

const serverConfig = {
  mode: 'development',
  entry: { server: ['./server/uiserver.js'] },
  target: 'node',
  externals: [nodeExternals()],
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                targets: { node: '10' },
              }],
              '@babel/preset-react',
            ],
          },
        },
      },
    ],
  },
  devtool: 'source-map',
};

module.exports = [browserConfig, serverConfig];

Listing 12-15ui/webpack.config.js: Full Listing of File for Including Server Configuration

现在,我们准备将所有的require()语句转换成import语句。但是在我们这样做之前,因为我们偏离了在导入中不指定扩展名的规范,所以我们必须为此禁用 ESLint 设置。对服务器端.eslintrc的这一更改如清单 12-16 所示。

...
  "rules": {
    "no-console": "off",
    "import/extensions": "off"
  }
...

Listing 12-16ui/server/.eslintrc: Disable Import Extensions Rule

让我们首先将template.js改为使用导入/导出范例。清单 12-17 中显示了对此的更改。

...
export default function template(body) {
  ...
}
...

module.exports = template;

...

Listing 12-17ui/server/template.js: Use Import/Export

至于render.js,让我们把所有的require()语句都改成import语句。此外,现在我们可以在服务器端处理 JSX 作为绑定过程的一部分,让我们用 JSX 替换React.createElement(),并更改文件的扩展名以反映这一事实。

$ cd ui
$ mv server/render.js server/render.jsx

render.jsx文件的新内容如清单 12-18 所示。

import React from 'react';
import ReactDOMServer from 'react-dom/server';

import About from '../src/About.jsx';
import template from './template.js';

function render(req, res) {
  const body = ReactDOMServer.renderToString(<About />);
  res.send(template(body));
}

export default render;

Listing 12-18ui/server/render.jsx: New File for Rendering, Using JSX

在主服务器文件uiserver.js中,除了导入更改的require()语句,我们还需要更改加载初始配置的 HMR 初始化例程。既然配置导出了一个数组,我们将使用该数组中的第一个配置,而不是原样使用该配置。

...
  const config = require('../webpack.config.js')[0];
...

因为我们现在正在执行一个包,所以当在服务器上遇到任何错误时,堆栈跟踪中显示的行号就是这个包的行号。当我们遇到错误时,这一点也不方便。source-map-support模块解决了这个问题。在前端,source-map support模块也方便了添加断点。在后端,它所做的只是让错误消息更易读。

让我们安装source-map-support包:

$ cd ui
$ npm install source-map-support@0

我们现在可以在主服务器文件uiserver.js中安装这种支持,如下所示:

...
import SourceMapSupport from 'source-map-support';
...
SourceMapSupport.install();
...

uiserver.js的最终更改如清单 12-19 所示。

...

require('dotenv').config();

const path = require('path');

const express = require('express');

const proxy = require('http-proxy-middleware');

const render = require('./render.js');

import dotenv from 'dotenv';

import path from 'path';

import express from 'express';

import proxy from 'http-proxy-middleware';

import SourceMapSupport from 'source-map-support';

import render from './render.jsx';

const app = express();

SourceMapSupport.install();

dotenv.config();

...

  const config = require('../webpack.config.js')[0];
...

Listing 12-19ui/server/uiserver.js: Changes for Using Import, Source Maps, and Webpack Config Array Element

让我们更改package.json中的脚本部分,使用包来启动服务器,而不是文件uiserver.js。让我们也修改 ESLint 命令来反映新的目录结构。这些变化如清单 12-20 所示。

...
  "scripts": {
    ...
    "start": "nodemon -w server -w .env server/uiserver.js",
    "start": "nodemon -w dist -w .env dist/server.js",
    ...
    "lint": "eslint . --ext js,jsx --ignore-pattern public",
    "lint": "eslint server src browser --ext js,jsx",
    ...
  }
...

Listing 12-20ui/package.json: Changes for Scripts

手动生成的About.js文件已经不需要了,我们来清理一下。

$ cd ui
$ rm server/About.js

可以使用以下手动编译命令构建服务器包:

$ cd ui
$ npx webpack

现在,您可以使用npm start启动应用并检查它。应用的行为应该没有变化。您可以直接或者通过加载/issues并从那里导航来尝试 About 页面。这两个头像将继续不同,因为我们还没有返回一个带有导航栏的 HTML,等等。从服务器渲染时。

服务器的 HMR

尽管对服务器使用 Webpack 确实简化了编译过程,但您会发现在开发过程中,每次更改都需要重启服务器。您可以通过运行npm start来使用nodemon包装器,但是即使在那里您也会发现前端 HMR 并不工作。这是因为,在重启时,会重新安装 HMR 中间件,但是浏览器会尝试连接到原来的 HMR,但它已经不存在了。

所有这些问题的解决方案是使用 HMR 自动重新加载模块,即使是在后端。由于我们使用 Webpack 来捆绑服务器,这应该是可行的。但事实是,Express 已经存储了对任何现有模块的引用,当接受 HMR 更改时,需要告诉它替换这些模块。虽然这是可以做到的,但是设置起来相当复杂。

因此,让我们采取简单的方法:我们将只基于共享文件夹重新加载对模块的更改。至于对uiserver.js本身的更改,我们预计这种情况很少发生,所以让我们在这个文件更改时手动重启服务器,并对它包含的其余代码使用 HMR。

让我们首先创建一个新的 Webpack 配置,为服务器启用 HMR。这个配置应该不同于用于创建产品包的配置。对于浏览器,我们动态添加了 HMR 作为 UI 服务器的一部分(通过加载配置并在服务器代码中修改它)。但是由于在服务器代码的情况下我们没有服务器来服务这个包,所以我们必须创建一个单独的配置文件。与其复制整个配置文件并对其进行更改,不如让它基于原始配置并合并HMR 所需的更改。一个名为webpack-merge的包对此很有用。

$ cd ui
$ npm install --save-dev webpack-merge@4

让我们使用它将服务器配置上的 HMR 更改合并到一个名为webpack.serverHMR.js的新文件中。在这个文件中,让我们首先从主配置文件加载基本配置。请注意,服务器配置是数组中的第二个元素。

...
const serverConfig = require('./webpack.config.js')[1];
...

然后,让我们将serverConfig与新的变更合并:我们将添加一个新的入口点来轮询变更,我们将把 HMR 插件添加到这个配置中。完整的新文件如清单 12-21 所示。

/*
  eslint-disable import/no-extraneous-dependencies
*/
const webpack = require('webpack');
const merge = require('webpack-merge');
const serverConfig = require('./webpack.config.js')[1];

module.exports = merge(serverConfig, {
  entry: { server: ['./node_modules/webpack/hot/poll?1000'] },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ],
});

Listing 12-21ui/webpack.serverHMR.js: Merged Configuration for Server HMR

现在,如果您使用这个文件作为配置并使用watch选项来运行 Webpack,那么可以根据更改来重新构建服务器包。此外,它将让正在运行的服务器监听更改,并加载已更改的模块。使用此配置运行 Webpack 的命令如下:

$ cd ui
$ npx webpack -w --config webpack.serverHMR.js

但是,HMR 不会工作,因为服务器(还)不接受更改。如前所述,我们只接受对render.jsx的更改。因此,在uiserver.js中,我们可以在文件末尾添加以下内容:

...
if (module.hot) {
  module.hot.accept('./render.jsx');
}
...

但是,这样做的效果仅仅是加载已更改的模块并替换该文件中的变量render来引用新更改的模块。前往/about的快速路线仍然有一个旧的render功能的句柄。理想情况下,我们应该告诉 Express route 有一个新的render功能,可能是这样的:

...
if (module.hot) {
  module.hot.accept('./render.jsx', () => {
    app.get('/about', render);
  });
}

不幸的是,这会导致安装另一条路由,而不是替换现有的路由。在 Express 中也没有办法让卸载一条路由。为了解决这个问题,我们创建一个函数包装器并在其中显式调用render(),而不是将对函数的引用传递给快速路由处理程序。这样,被调用的render函数总是最新的。这一变化,连同模块接受变化,如清单 12-22 所示。

...
app.get('/env.js', (req, res) => {
  ...
});

app.get('/about', render);

app.get('/about', (req, res, next) => {

  render(req, res, next);

});

...

app.listen(port, () => {
  ...
});

if (module.hot) {

  module.hot.accept('./render.jsx');

}

...

Listing 12-22ui/server/uiserver.js: Changes for HMR

最后,让我们更改package.json的脚本部分,添加启动 UI 服务器的方便脚本。我们现在可以修改start脚本来删除 nodemon(因为 HMR 会自动加载模块)。然后,让我们用一个在观察模式下运行webpack.serverHMR.js配置的watch-server-hmr脚本来替换watch脚本。因为在开发模式下启动 UI 服务器需要这个脚本和启动脚本,所以让我们添加一个名为dev-all的脚本,一个接一个地完成这两项工作。

在 npm 脚本中,可以使用&操作符组合多个命令。这些命令是同时启动的。为了在运行npm start命令之前保护正在构建的server.js包,最好在运行npm start命令之前有一个sleep命令。等待的时间长短取决于计算机的速度和编译服务器文件所需的时间。首先,您可以使用五秒钟的睡眠计时器,并根据您的需要进行定制。

清单 12-23 显示了对package.json脚本的更改,但是脚本dev-all只在 MacOS 和 Linux 上工作。

...
  "scripts": {
    ...
    "start": "nodemon -w dist -w .env dist/server.js",
    "start": "node dist/server.js",
    ...
    "#watch": "Compile, and recompile on any changes.",
    "watch": "webpack --watch"
    "#watch-server-hmr": "Recompile server HMR bundle on changes.",
    "watch-server-hmr": "webpack -w --config webpack.serverHMR.js",
    "#dev-all": "Dev mode: watch for server changes and start UI server",
    "dev-all": "rm dist/* && npm run watch-server-hmr & sleep 5 && npm start"
  },
...

Listing 12-23ui/package.json: Changes to Scripts for HMR

在 Windows PC 上,您可能需要使用等效命令创建自己的批处理文件,或者在不同的命令窗口中执行npm watch-server-hmrnpm start

现在,您可以停止所有其他 UI 服务器命令,并使用单个npm run dev-all命令重新启动它。应用应该像以前一样工作,但大多数更改应该会自动反映出来,而不必重新启动该命令。

服务器路由

从服务器呈现 About 页面的方式不同于从/issues导航到该页面的方式。在第一种情况下,它在没有导航栏的情况下显示,在第二种情况下,它在有导航栏的情况下显示。

发生这种情况的原因如下。在浏览器上,App.jsxPage组件挂载到contents div 上。但是,在服务器上,About组件通过填充在模板中直接呈现在contents div 中。

在服务器上,用一个Router包围页面,或者使用SwitchNavLink s,都会抛出错误。这是因为Router实际上是为 DOM 准备的,在 DOM 中,单击一个路由的链接,浏览器的历史被操纵,不同的组件基于路由规则被加载。

在服务器上,React Router 建议我们使用StaticRouter来代替BrowserRouter。此外,BrowserRouter查看浏览器的 URL,而StaticRouter必须被提供 URL。基于此,路由将选择适当的组件进行渲染。StaticRouter获取一个名为location的属性,这是一个静态 URL,其余的渲染将需要它。它还需要一个名为context的属性,现在它的用途还不明显,所以我们只为它提供一个空对象。

然后让我们修改render.js来呈现Page组件,而不是About组件,但是被StaticRouter所包围。对此的更改如清单 12-24 所示。

...

import { StaticRouter } from 'react-router-dom';

import About from '../src/About.jsx';

import Page from '../src/Page.jsx';

...

function render(req, res) {
  const body = ReactDOMServer.renderToString(<About />);
  const element = (
    <StaticRouter location={req.url} context={{}}>
      <Page />
    </StaticRouter>
  );
  const body = ReactDOMServer.renderToString(element);
  res.send(template(body));
}
...

Listing 12-24ui/server/render.jsx: Changes to Render Page Instead of About Directly

现在,如果您测试应用,您将发现服务器呈现和浏览器呈现对于 About 页面是相同的:导航栏将在两种情况下出现。要测试服务器呈现,您需要在“关于”页面上按浏览器上的“刷新”。至于浏览器呈现,您需要在另一个页面中刷新浏览器,比如问题列表页面,然后使用扩展菜单导航到“关于”页面。请参考图 12-1 中的截图,回顾一下它的外观。

请注意,此时,除了 About 页面,其他页面仅在浏览器上呈现,即使在刷新时也是如此。一旦我们完善了关于页面服务器的渲染,我们将很快解决这个问题。

练习:服务器路由

  1. 在“关于”页面上按浏览器上的“刷新”,使用服务器呈现显示页面。尝试通过单击导航栏中的“创建问题”菜单项(+图标)来创建新问题。会发生什么?你能解释这个吗?提示:(a)尝试在IssueAddNavItemshowModal()方法中放置一个断点,然后(b)使用浏览器的开发工具检查+图标。检查附加到它的事件侦听器。点击主页菜单后尝试这些,并注意不同之处。

  2. 在“关于”页面上按浏览器上的“刷新”,使用服务器呈现显示页面。使用开发人员工具检查网络调用,然后导航到任何其他页面,例如问题列表页面。从报告页面而不是“关于”页面开始,执行相同的操作。你看到了什么不同,为什么?

本章末尾有答案。

水合物

尽管该页面看起来像它现在应该的样子,但仍然存在问题。如果您尝试了上一节末尾的练习,您会发现呈现的是纯 HTML 标记,没有任何 JavaScript 代码或事件处理程序。因此,在页面中不可能有用户交互。

为了附加事件处理程序,我们必须包含源代码,并让 React 控制呈现的页面。方法是加载 React 并让它呈现页面,就像在使用ReactDOM.render()进行浏览器呈现时一样。因为我们没有在模板中包含 JavaScript 包,所以它没有被调用,因此 React 没有获得页面的控制权。因此,让我们将脚本添加到服务页面,就像在index.html中一样,看看会发生什么。对模板的更改如清单 12-25 所示。

...
<body>
  <!-- Page generated from template. -->
  <div id="contents">${body}</div>

  <script src="/env.js"></script>
  <script src="/vendor.bundle.js"></script>
  <script src="/app.bundle.js"></script>
</body>
...

Listing 12-25ui/server/template.js: Include Browser Bundles

现在,如果您通过刷新 About 页面来测试应用,您会发现+按钮起作用了!这意味着已经附加了事件处理程序。但是您还会在控制台上看到这样的警告:

警告:render():调用ReactDOM.render()合并服务器呈现的标记将在 React v17 中停止工作。如果您希望 React 附加到服务器 HTML,请用ReactDOM.hydrate()替换ReactDOM.render()调用。

因此,React 区分了呈现 DOM 以替换 DOM 元素和将事件处理程序附加到服务器呈现的 DOM。让我们按照警告的建议将render()改为hydrate()。清单 12-26 中显示了对App.jsx的更改。

...
ReactDOM.render hydrate(element, document.getElementById('contents'));
...

Listing 12-26ui/browser/App.jsx: Change Render to Hydrate

在测试这一更改时,您会发现警告已经消失,并且所有事件处理程序都已安装。您不仅可以在单击+按钮时看到效果,还可以在导航到导航栏中的其他选项卡时看到效果。以前,这些会导致浏览器刷新,而现在这些导航会直接将适当的组件加载到 DOM 中,React Router 发挥了它的魔力。

这一步完成了事件的服务器呈现序列,为了完成,服务器呈现的序列图需要这最后一步。新的顺序图如图 12-5 所示。

img/426054_2_En_12_Chapter/426054_2_En_12_Fig5_HTML.jpg

图 12-5

使用水合物更新的服务器渲染序列图

对图表的更改概括在以下步骤中:

  1. 服务器返回应用和 React 以及其他库源代码包的脚本标签,而不是普通的About组件。

  2. “关于”页面是可查看的,但不是交互式的。这里的执行并没有真正的改变,只是图中明确声明了页面不是交互式的。

  3. 浏览器获取 JavaScript 包并执行它们。作为其中的一部分,ReactDOM.hydrate()以被路由的页面作为根元素执行。

  4. ReactDOM. hydrate()导致事件处理程序被附加到所有组件,现在页面是可见的交互的。

来自 API 的数据

我们在About组件中使用了硬编码的消息。实际上,这个字符串应该来自 API 服务器。具体来说,about API 的结果应该显示在 API 的硬编码版本字符串的位置。

如果我们遵循与从 API 加载数据的其他组件相同的模式,我们将在生命周期方法componentDidMount()中实现数据获取并设置组件的状态。但是在这种情况下,当组件在服务器上呈现时,我们真的需要 API 的返回值可用。

这意味着我们也需要能够从服务器通过graphQLFetch()向 API 服务器发出请求。在此期间,这个函数假设它是从浏览器调用的。这需要改变。首先,我们需要用既可以在浏览器上使用又可以在 Node.js 上使用的东西来替换whatwg-fetch模块。我们将使用名为isomorphic-fetch的包来实现这一点。所以让我们更换包装。

$ cd ui
$ npm uninstall whatwg-fetch
$ npm install isomorphic-fetch@2

现在,我们可以用isomorphic-fetch代替import whatwg-fetch。但是导入目前在App.jsx内,这是浏览器特有的。我们把它从那里去掉,在真正需要的地方加上isomorphic-fetch:graphQLFetch.js。对App.jsx的更改如清单 12-27 所示。

...

import 'whatwg-fetch';

...

Listing 12-27ui/browser/App.jsx: Removal of whatwg-fetch Import

graphQLFetch.js中,我们目前在window.ENV.UI_API_ENDPOINT中有 API 端点规范。这在服务器上不起作用,因为没有名为window的变量。我们需要使用process.env变量。但是我们没有任何东西来表明该函数是在浏览器中还是在 Node.js 中被调用。Webpack 的插件DefinePlugin可以用来定义运行时可用的全局变量。我们在第八章“模块化和 Webpack”的末尾已经简要讨论过这个插件,但是没有使用它。现在让我们使用这个插件来定义一个名为__isBrowser__的变量,它在浏览器包中被设置为true,但在服务器包中被设置为false。清单 12-28 显示了在webpack.config.js中定义该变量的变化。

...

const webpack = require('webpack');

...
const browserConfig = {
  ...
  plugins: [
    new webpack.DefinePlugin({
      __isBrowser__: 'true',
    }),
  ],
  devtool: 'source-map',
};

const serverConfig = {
  ...
  plugins: [
    new webpack.DefinePlugin({
      __isBrowser__: 'false',
    }),
  ],
  devtool: 'source-map',
};

Listing 12-28ui/webpack.config.js: DefinePlugin and Setting __isBrowser__

uiserver.js文件中,让我们设置process.env中的变量,如果它还没有设置的话。这是为了让其他模块可以获得这个配置变量,而不必担心默认值。此外,在代理操作模式中,浏览器和服务器 API 端点需要不同。浏览器需要为 API 使用 UI 服务器,API 服务器将被代理到 API 服务器,而服务器需要直接调用 API 服务器。让我们为 UI 服务器的 API 端点引入一个新的环境变量,称为UI_SERVER_API_ENDPOINT。如果没有指定,我们可以将其默认为与UI_API_ENDPOINT相同的端点。对此的更改如清单 12-29 所示。

...
if (apiProxyTarget) {
  ...
}

const UI_API_ENDPOINT = process.env.UI_API_ENDPOINT

  || 'http://localhost:3000/graphql';

const env = { UI_API_ENDPOINT };

if (!process.env.UI_API_ENDPOINT) {

  process.env.UI_API_ENDPOINT = 'http://localhost:3000/graphql';

}

if (!process.env.UI_SERVER_API_ENDPOINT) {

  process.env.UI_API_ENDPOINT = process.env.UI_API_ENDPOINT;

}

app.get('/env.js', (req, res) => {
  const env = { UI_API_ENDPOINT: process.env.UI_API_ENDPOINT };
  res.send(`window.ENV = ${JSON.stringify(env)}`);
});
...

Listing 12-29ui/server/uiserver.js: Set process.env Variable If Not Set

您可以将新的环境变量添加到您的.env文件中,但是因为我们正在使用非代理操作模式,所以您可以将其注释掉。清单 12-30 中的sample.env也有同样的变化。

...
UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

...

Listing 12-30ui/sample.env: Addition of Environment Variable for API Endpoint for Use by the UI Server

现在,我们可以更改graphQLFetch.js来从process.envwindow.ENV获得正确的 API 端点,这取决于它是运行在 Node.js 上还是浏览器上。对该文件的更改如清单 12-31 所示。

...
import fetch from 'isomorphic-fetch';
...

export default async function
graphQLFetch(query, variables = {}, showError = null) {
  const apiEndpoint = (__isBrowser__) // eslint-disable-line no-undef
    ? window.ENV.UI_API_ENDPOINT
    : process.env.UI_SERVER_API_ENDPOINT;
  try {
    const response = await fetch(window.ENV.UI_API_ENDPOINT apiEndpoint, {
      ...
  }
  ...
}
...

Listing 12-31ui/src/graphQLFetch.js: Using Isomorphic-Fetch and Conditional Configuration

现在,我们可以从服务器调用graphQLFetch()。在调用renderToString()之前,我们可以调用 About API 来获取数据,如下所示:

...
async function render(req, res) {
  const resultData = await graphQLFetch('query{about}');
...

但是,我们如何在呈现的同时将这些信息传递给About组件呢?一种方法是将它作为道具传递给Page组件,后者又将它传递给Contents组件,最后传递给About。但是这是一个障碍,并导致组件之间的过度耦合——PageContents都不需要知道只与About相关的数据。

这个问题的解决方案是使用一个全局存储所有需要渲染的组件层次结构所需的数据。让我们在一个名为store.js的文件中将这个存储创建为共享目录中的一个模块。这个存储的实现很简单:只是一个导出的空对象。该模块的用户可以通过导入该模块来分配全局可用的键值。新文件的内容如清单 12-32 所示。

const store = {};

export default store;

Listing 12-32ui/src/store.js: New Global Generic Storage Module (Complete Source)

现在,API 调用的结果可以保存在这个存储中。清单 12-33 中显示了对render.jsx的更改,以及对graphQLFetch()的调用以获取初始数据。

import template from './template.js';

import graphQLFetch from '../src/graphQLFetch.js';

import store from '../src/store.js';

async function render(req, res) {
  const initialData = await graphQLFetch('query{about}');
  store.initialData = initialData;
  ...
}
...

Listing 12-33ui/src/render.jsx: Changes for Saving the Data from an API Call

随着数据在全局存储中可用,我们现在可以更改About组件来从全局存储中读取它,以显示真实的 API 版本。让我们也通过检查存储是否存在来保护这一点;当在浏览器中构造相同的组件时,这可能很有用。清单 12-34 显示了对About.jsx的更改。

...
import React from 'react';

import store from './store.js';

export default function About() {
  ...
      <h4>
        API version 1.0
        {store.initialData ? store.initialData.about : 'unknown'}
      </h4>
  ...
}
...

Listing 12-34ui/src/About.jsx: Use Version Obtained from the API Call Via a Global Store

如果您对此进行测试,您会惊讶地发现 About 页面将 API 版本显示为“unknown ”,而不是从 API 获取的值。查看一下页面的源代码(使用开发工具检查页面源代码),您会发现 HTML 确实有来自服务器的 API 版本字符串。那么,为什么它没有出现在屏幕上呢?

如果您查看开发人员控制台,您会看到如下错误消息:

警告:文本内容不匹配。服务器:“问题跟踪 API v1.0 版”客户端:“未知”

这应该给你一个关于潜在问题的提示。我们将在下一节讨论这个问题。

同步初始数据

上一节中的错误消息说ReactDOM.hydrate()生成的 DOM 和服务器呈现的 DOM 之间存在差异。在服务器上,我们使用 API 调用的结果来设置版本,但是当 React 试图在浏览器上使用hydrate()附加事件处理程序时,它在存储中没有找到任何值,因此出现了错误。以下是 React 文档中的一段引文:

React 期望在服务器和客户端之间呈现的内容是相同的。它可以修补文本内容中的差异,但是您应该将不匹配视为错误并修复它们。在开发模式下,React 会在水合过程中发出不匹配警告。在不匹配的情况下,不能保证属性差异“得到修补”。出于性能原因,这一点很重要,因为在大多数应用中,不匹配的情况很少发生,因此验证所有标记会非常昂贵。

仔细想想,很有道理。当水合(或者将事件处理程序附加到 DOM)时,如果由hydrate()调用生成的树与已经存在的、由服务器呈现的树不匹配,事情就会变得不明确。请注意,hydrate()只是render()的一个变种——它真正创建了一个带有事件处理程序的虚拟 DOM,可以与实际的 DOM 同步。

需要的是使浏览器呈现与服务器呈现相同。但是这要求使用相同的数据在服务器和浏览器上呈现组件。浏览器渲染期间的 API 调用(例如,在生命周期方法componentDidMount()中)不会剪切它,因为它是异步的。我们需要组件第一次在?? 渲染时的数据。

推荐的方法是将 API 调用产生的相同初始数据以脚本的形式传递给浏览器,并使用它来初始化全局存储。这样,当组件被呈现时,它将具有与在服务器上呈现的组件相同的数据。

要做的第一件事是改变模板,使其接受一个额外的参数(初始数据)并将其设置在一个<script>部分的全局变量中。因为我们必须将任何对象转换成有效 JavaScript 的字符串表示,所以让我们使用JSON.stringify()将数据转换成字符串。让我们称这个全局变量为__INITIAL_DATA__。双下划线表示这是一个特殊的全局变量,因为有其他模块,它不太可能与任何其他全局变量冲突。对template.js的更改如清单 12-35 所示。

...
export default function template(body, data) {
  ...
  <div id="contents">${body}</div>
  <script>window.__INITIAL_DATA__ = ${JSON.stringify(data)}</script>
  ...
}
...

Listing 12-35ui/server/template.js: Include Initial Data as a Script

在服务器上呈现时,我们现在可以通过这个模板将初始数据传递给浏览器,这些数据与在服务器上呈现页面时使用的数据相同。清单 12-36 显示了render.jsx中对此的更改。

...
async function render(req, res) {
 ...
  const body = ReactDOMServer.renderToString(element);
  res.send(template(body, initialData));
}
...

Listing 12-36ui/server/render.jsx: Changes for Sending Initial Data to the Browser

在浏览器中,我们需要先将这个值设置为全局存储,这样当组件被渲染时,它就可以访问全局存储中的初始数据。我们可以在浏览器渲染开始的地方进行全局存储初始化,在App.jsx中。这一变化如清单 12-37 所示。

...
import Page from '../src/Page.jsx';

import store from '../src/store.js';

// eslint-disable-next-line no-underscore-dangle

store.initialData = window.__INITIAL_DATA__;

...

Listing 12-37ui/browser/App.jsx: Use Initial Data to Initialize the Store

现在,当组件在浏览器中呈现时,它在存储区中的初始数据与在服务器上呈现时相同。如果您在 About 页面中通过刷新浏览器来测试应用,您将发现 React 错误消息不再显示。这表明浏览器呈现的结果与服务器呈现的结果相匹配,从而允许 React 附加事件处理程序而没有任何不匹配。

其他页面仍然会显示错误,例如,/issues URL 会在控制台中抛出以下错误:

警告:期望服务器 HTML 在<div>中包含匹配的<div>

原始的index.html将被返回给 URL /issues,它的主体中只有一个空的<div>,因为它不像 About 页面那样由服务器呈现。当 React 在调用hydrate()期间在浏览器中呈现 DOM 时,呈现的是实际的页面。因此,服务器呈现的内容和浏览器呈现的内容不匹配,因此会出现错误。当我们以一种通用的方式同步所有页面的服务器和浏览器数据时,我们将在本章的后面部分解决这个问题。

公共数据提取器

此时,尽管使用指向/about的 URL 刷新浏览器效果很好,但是您会发现从任何其他页面(比如说/issues)开始导航到 About 页面时,不会显示来自 API 服务器的 API 版本。这是因为我们从来没有在About组件中添加一个数据提取器,该数据提取器可以用来填充它的消息,以处理它只安装在浏览器上的情况。

因此,就像其他组件一样,让我们在About组件中添加一个componentDidMount()方法。现在需要将它从无状态函数转换成常规组件。让我们使用一个状态变量来存储和显示 API 版本。我们称这个变量为apiAbout。让我们从全局存储中初始化构造函数中的这个变量,如果它有初始数据的话。

...
  constructor(props) {
    super(props);
    const apiAbout = store.initialData ? store.initialData.about : null;
    this.state = { apiAbout };
  }
...

如果初始数据丢失,这将把状态变量设置为null,当/issues页面被加载并且用户导航到 About 页面时就会出现这种情况。我们可以利用这个事实在componentDidMount()内部发起一个 API 调用。但是因为我们在render.jsx中进行了相同的 API 调用,所以让我们使用一个公共函数来获取可以由About组件和render.jsx文件共享的数据。最好的地方是在About组件本身,作为一个静态函数。

...
  static async fetchData() {
    const data = await graphQLFetch('query {about}');
    return data;
  }
...

现在,在componentDidMount()中,如果状态变量apiAbout还没有被构造函数初始化,那么可以在状态中获取和设置数据。

...
  async componentDidMount() {
    const { apiAbout } = this.state;
    if (apiAbout == null) {
      const data = await About.fetchData();
      this.setState({ apiAbout: data.about });
    }
  }
...

最后,在render()方法中,我们可以使用状态变量而不是来自存储的变量。清单 12-38 显示了所有这些更改后的About.jsx的完整源代码。

import React from 'react';
import store from './store.js';
import graphQLFetch from './graphQLFetch.js';

export default class About extends React.Component {
  static async fetchData() {
    const data = await graphQLFetch('query {about}');
    return data;
  }

  constructor(props) {
    super(props);
    const apiAbout = store.initialData ? store.initialData.about : null;
    this.state = { apiAbout };
  }

  async componentDidMount() {
    const { apiAbout } = this.state;
    if (apiAbout == null) {
      const data = await About.fetchData();
      this.setState({ apiAbout: data.about });
    }
  }

  render() {
    const { apiAbout } = this.state;
    return (
      <div className="text-center">
        <h3>Issue Tracker version 0.9</h3>
        <h4>
          {apiAbout}
        </h4>
      </div>
    );
  }
}

Listing 12-38ui/src/About.jsx: Replaced Contents of About.jsx for Loading Data

现在,render.jsx中的 GraphQL 查询可以替换为对About.fetchData()的调用。这一变化如清单 12-39 所示。

...

import graphQLFetch from '../src/graphQLFetch.js';

import About from '../src/About.jsx';

...

async function render(req, res) {
  const resultData = await graphQLFetch('query{about}');
  const resultData = About.fetchData();
...

Listing 12-39ui/server/render.jsx: Use Common Data Fetcher from About.jsx

更改之后,您可以测试应用,特别是加载主页或/issues,然后导航到 About 页面。您应该看到显示的是正确的 API 版本。您还可以通过检查开发人员工具的 Network 选项卡来确认正在调用 API。

生成的路线

在这一节中,我们将修复 React 为其余页面显示的不匹配错误。我们还将建立一个框架,以一种通用的方式处理获取数据,这样我们就可以删除对render.jsx中的About.fetchData()的调用,并让它获取适合于将在页面中实际呈现的组件的数据。

首先,我们不返回index.html,而是使用所有页面的模板返回服务器呈现的 HTML。对此的改变是在处理路径/about的快速路由中。让我们用一个*来替换它,以指示任何路径都应该返回模板化的 HTML,而不是来自公共目录的文件index.html。这一变化如清单 12-40 所示。

...

import path from 'path';

...

app.get('/about', (req, res, next) => {

app.get('*', (req, res, next) => {

  render(req, res, next);
});

app.get('*', (req, res) => {

  res.sendFile(path.resolve('public/index.html'));

});

Listing 12-40ui/server/uiserver.js: Return Templated HTML for Any Path

由于不再需要index.html,我们可以删除这个文件。

$ cd ui
$ rm public/index.html

这一更改需要重启服务器,因为 HMR 本身不处理对uiserver.js的更改。在测试应用时,您会发现所有页面的不匹配<div>的 React 错误不再出现。如果你检查页面源码,你会发现服务器返回一个完整的页面,带有导航条等。,但是没有数据。

例如,当您刷新页面/issues时,您将看到表格标题存在,但是表格本身并没有填充问题。它与浏览器渲染相匹配,因为即使在浏览器中,初始渲染也是从一组空问题开始的。只有在componentDidMount()期间,问题列表才会从 API 中取出并填充到表格中。我们将在接下来的章节中解决这个问题。现在,让我们确保我们有能力根据匹配的路由来确定需要获取什么数据。

我们需要解决的主要问题是,通过 API 调用所需的数据需要在服务器上启动渲染之前可用。做到这一点的唯一方法是为可用的路由列表保留一个共同的真实来源。然后,我们可以将请求的 URL 与每个路由进行匹配,并找出哪个组件(以及哪个fetchData()方法)将匹配。相同的真实来源也应该负责在渲染期间生成实际的<Route>组件。

让我们将这个可路由页面的列表保存在一个名为routes.js的新文件中的 JavaScript 数组中。这可以是一个简单的数组,其中包含路由的路径以及在路由与 URL 匹配时需要呈现的组件。这个新文件如清单 12-41 所示。

import IssueList from './IssueList.jsx';
import IssueReport from './IssueReport.jsx';
import IssueEdit from './IssueEdit.jsx';
import About from './About.jsx';
import NotFound from './NotFound.jsx';

const routes = [
  { path: '/issues', component: IssueList },
  { path: '/edit/:id', component: IssueEdit },
  { path: '/report', component: IssueReport },
  { path: '/about', component: About },
  { path: '*', component: NotFound },
];

export default routes;

Listing 12-41ui/src/routes.js: New File to Store Route Metadata

我们已经将NotFound作为一个组件导入并使用,但是它被定义为Contents.jsx的一部分,这是行不通的。让我们把它分离出来并为它创建一个新文件,如清单 12-42 所示。

import React from 'react';

function NotFound() {
  return <h1>Page Not Found</h1>;
}

export default NotFound;

Listing 12-42ui/src/NotFound.jsx: New File for the Page Not Found Component

我们现在可以修改Contents.jsx来从这个路由元数据数组生成<Route>组件。让我们映射数组并为每个组件返回一个<Route>,其属性与数组中每个对象的属性相同。React 还需要数组中每个元素的惟一键,这可以是 route 的路径,因为它必须是惟一的。对该文件的更改如清单 12-43 所示。

...

import IssueList from './IssueList.jsx';

import IssueReport from './IssueReport.jsx';

import IssueEdit from './IssueEdit.jsx';

import About from './About.jsx';

const NotFound = () => <h1>Page Not Found</h1>;

import routes from './routes.js';

export default function Contents() {
  return (
    <Switch>
      <Redirect exact from="/" to="/issues" />
      <Route path="/issues" component={IssueList} />
      <Route path="/edit/:id" component={IssueEdit} />
      <Route path="/report" component={IssueReport} />
      <Route path="/about" component={About} />
      <Route component={NotFound} />
      {routes.map(attrs => <Route {...attrs} key={attrs.path} />)}
    </Switch>
  );
}
...

Listing 12-43ui/src/Contents.jsx: Changes to Generate Routes from routes.js Array

在服务器和浏览器上进行渲染时,将根据 URL 选择一条路线进行渲染。在浏览器上,BrowserRouter的历史对象将提供匹配的 URL,在服务器上,我们已经通过StaticRouterlocation属性提供了它,我们用它来包装页面。

我们仍然需要用更通用的东西替换对About.fetchData()的调用。要做到这一点,我们需要确定哪些组件将匹配通过render.jsx中的请求对象传入的当前 URL。React Router 公开了一个名为matchPath()的函数,正是为了这个目的:它匹配一个给定的 JavaScript 对象,这是一个路由规范,如routes.js中的数组所示:

...
  const match = matchPath(urlPath, routeObject)
...

routeObject对象应该包含属性pathexactstrict,就像定义一个<Route>组件一样。如果路线与提供的urlPath匹配,它返回一个match对象。因此,我们可以迭代来自routes.js的路由数组,并找到匹配的路由。

...
import routes from '../src/routes.js';
import { StaticRouter, matchPath } from 'react-router-dom';
...
  const activeRoute = routes.find(
    route => matchPath(req.path, route),
  );
...

如果有匹配,我们可以查看匹配的 route 对象的component属性,看看组件中是否定义了静态函数来获取数据。如果有,我们可以调用那个函数来获取初始数据。

...
  let initialData;
  if (activeRoute && activeRoute.component.fetchData) {
    initialData = await activeRoute.component.fetchData();
  }
...

这个初始数据现在可以代替对About.fetchData()的硬编码调用。清单 12-44 中显示了这一变更以及对render.jsx的变更。

...
import { StaticRouter, matchPath } from 'react-router-dom';
...

import About from '../src/About.jsx';

import store from '../src/store.js';

import routes from '../src/routes.js';

...

async function render(req, res) {
  const initialData = About.fetchData();
  const activeRoute = routes.find(
    route => matchPath(req.path, route),
  );

  let initialData;
  if (activeRoute && activeRoute.component.fetchData) {
    initialData = await activeRoute.component.fetchData();
  }
  ...
}

Listing 12-44ui/server/render.jsx: Changes for Fetching the Initial Data Depending on the Matched Route

有了这些改变,我们已经设法摆脱了数据的硬编码,这些数据需要根据将为匹配路线呈现的组件来获取。但是我们还没有在许多组件中实现数据获取。例如,在/issues刷新浏览器将继续呈现一个空表,该表稍后会填充从浏览器中的 API 调用获取的问题列表。这不是我们所需要的:对/issues的请求应该产生一个页面,其中充满了与过滤器匹配的问题列表。但是与我们为About组件所做的有细微差别:这些 API 调用根据参数的不同而不同。当我们开始在每个现有组件中实现数据提取器时,我们将探索如何传递这些参数以用于呈现。

带参数的数据提取器

在这一节中,我们将让IssueEdit组件从服务器呈现它需要预先填充的数据。

首先,让我们将数据获取器分成一个静态方法,就像我们在About组件中所做的那样。该方法依赖问题的 ID 来获取数据。拥有该信息的最普通的实体是组件在浏览器上呈现时自动访问的match对象。

在服务器上呈现时,matchPath()调用的结果给了我们相同的信息。所以,让我们改变fetchData()函数的原型,使其包含match。此外,由于在浏览器和服务器上显示错误的方法不同,我们也将显示错误的函数showError作为参数。然后,让我们将 GraphQL 查询从loadData()函数移到这个函数,并使用从match对象获得的问题 ID 来执行它。

...
  static async fetchData(match, showError) {
    const query = `...`;

    const { params: { id } } = match;
    const result = await graphQLFetch(query, { id }, showError);
    return result;
  }
...

作为构造函数的一部分,我们可以检查是否有任何初始数据,并使用它来初始化状态变量issue。此外,不使用空的issue对象,让我们将状态变量设置为null,以表明它不是从服务器预加载的。既然我们有多个组件在查看初始数据,那么在浏览器上呈现的组件的构造函数可能会混淆初始数据。因此,让我们在使用完数据后,将其从存储中删除。

...
  constructor() {
    super();
    const issue = store.initialData ? store.initialData.issue : null;
    delete store.initialData;
    this.state = {
      issue: {},
...

componentDidMount()方法中,我们现在可以寻找状态变量的存在。如果不是null,说明是从服务器渲染的。如果是null,这意味着用户从服务器加载的不同页面导航到浏览器中的这个组件。在这种情况下,我们可以使用fetchData()加载数据。

...
  componentDidMount() {
    const { issue } = this.state;
    if (issue == null) this.loadData();
  }
...

loadData()方法中,我们将把原来对graphQLfetch()的调用替换为对fetchData()的调用。

...
  async loadData() {
    const { match } = this.props;
    const data = await IssueEdit.fetchData(match, this.showError);
    this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
  }
...

既然我们将issue对象的值设置为null来表示一个预初始化状态,如果发布状态变量是null,那么让我们在render()方法中返回null

...
  render() {
    const { issue } = this.state;
    if (issue == null) return null;

    const { issue: { id } } = this.state;
    ...
  }
...

清单 12-45 中显示了对IssueEdit组件的完整更改。

...
import Toast from './Toast.jsx';

import store from './store.js';

...

export default class IssueEdit extends React.Component {
  static async fetchData(match, showError) {
    const query = `query issue($id: Int!) {
      issue(id: $id) {
        id title status owner
        effort created due description
      }
    }`;

    const { params: { id } } = match;
    const result = await graphQLFetch(query, { id }, showError);
    return result;
  }

  constructor() {
    super();
    const issue = store.initialData ? store.initialData.issue : null;
    delete store.initialData;
    this.state = {
      issue: {},
      issue,
      invalidFields: {},
      ...
    }
    ...
  }
  ...

  componentDidMount() {
    const { issue } = this.state;
    if (issue == null) this.loadData();

  }
  ...

  async loadData() {
    const query = `query issue($id: Int!) {
      ...
    }`;

    const { match: { params: { id } } } = this.props;
    const data = await graphQLFetch(query, { id }, this.showError);
    const { match } = this.props;
    const data = await IssueEdit.fetchData(match, this.showError);
    this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
  }
  ...

  render() {
    const { issue } = this.state;
    if (issue == null) return null;
    ...
  }
  ...
}
...

Listing 12-45ui/src/IssueEdit.jsx: Changes to Use a Common Data Fetcher

因为我们还有创建初始数据的About组件,所以让我们在使用它之后删除这个数据,就像我们在IssueEdit中所做的那样。对此的更改如清单 12-46 所示。

...
  constructor(props) {
    ...
    const apiAbout = store.initialData ? store.initialData.about : null;
    delete store.initialData;
    ...
   }
...

Listing 12-46ui/src/About.jsx: Add Deletion of initialData After Consumption

下一步是在服务器渲染时在render.jsx中传递match参数。我们可以在find()例程中保存matchPath()的结果,但是我选择在匹配后重新评估这个值。对render.jsx的更改如清单 12-47 所示。

...
  if (activeRoute && activeRoute.component.fetchData) {
    const match = matchPath(req.path, activeRoute);
    initialData = await activeRoute.component.fetchData(match);
  }
...

Listing 12-47ui/server/render.jsx: Changes to Include the Match Object in a Call to fetchData()

此时,如果您导航到任何问题的编辑页面并刷新浏览器,您将在开发人员控制台中发现如下错误:

Uncaught TypeError: created.toDateString is not a function

这是因为我们使用JSON.stringify()写出了issue对象的内容,它将日期转换为字符串。当我们通过graphQLFetch进行 API 调用时,我们使用了 JSON date reviver 函数将字符串转换为 date 对象,但是初始化数据的脚本没有使用JSON.parse()。该脚本按原样执行。你可以在浏览器中使用 View Page Source 查看源文件,你会发现键created被设置为一个字符串。

解决方案是序列化初始化数据的内容,使其成为正确的 JavaScript(创建的属性将被赋予new Date(...))。为此,我们现在可以安装一个名为serialize-javascript的包:

$ cd ui
$ npm install serialize-javascript@1

现在,让我们用serialize函数替换template.js中的JSON.stringify()。对此的更改如清单 12-48 所示。

...

import serialize from 'serialize-javascript';

...

  <script>window.__INITIAL_DATA__ = ${JSON.stringify serialize(data)}</script>
...

Listing 12-48ui/server/template.js: Use Serialize Instead of Stringify

如果您在更改后测试应用,您会发现错误消息消失了,问题编辑页面正确地显示了问题,包括日期字段。此外,如果您导航到 About 页面,您应该会看到它从 API 服务器加载 API 版本(开发人员工具的 Network 选项卡会显示这一点)。这证明了一个页面的初始数据不会影响另一个页面的呈现。

练习:带参数的数据提取器

  1. 有没有办法对初始数据也使用 JSON 日期回顾策略?反过来怎么样:有没有办法在 API 调用中使用serialize()而不是JSON.parse()?我们应该这样做吗?

本章末尾有答案

带搜索的数据提取器

在这一节中,我们将在IssueList组件中实现数据获取器。在IssueEdit组件中,我们处理了数据获取器需要匹配路线的参数这一事实。在IssueList中,我们将处理这样一个事实,即 URL 的搜索(查询)字符串部分是获取正确问题集所必需的。

让我们将查询字符串(React Router 调用这个search)以及match对象传递给fetchData()。在服务器中,我们不能直接访问这个值,所以我们必须搜索?字符,并在请求的 URL 上使用子串操作来获得这个值。清单 12-49 显示了render.jsx对此的更改。

...
  if (activeRoute && activeRoute.component.fetchData) {
    const match = matchPath(req.path, activeRoute);
    const index = req.url.indexOf('?');
    const search = index !== -1 ? req.url.substr(index) : null;
    initialData = await activeRoute.component.fetchData(match, search);
  }
...

Listing 12-49ui/server/render.jsx: Include Search String in fetchData() Calls

由于这一变化,我们还必须修改IssueEdit以在它的静态fetchData()方法中包含这个新参数。对此的更改如清单 12-50 所示。

...
export default class IssueEdit extends React.Component {
  static async fetchData(match, search, showError) {
    ...
  }
  ...
  async loadData() {
    ...
    const data = await IssueEdit.fetchData(match, null, this.showError);
    ...
  }
  ...
}
...

Listing 12-50ui/src/IssueEdit: Changes for Change in fetchData() Prototype

现在,让我们在IssueList组件中创建数据获取器。我们将把所需的代码从loadData()方法移到这个新的静态方法中。以下变化与loadData()方法中的原始代码相比。有关该方法的完整列表,请参考列表 12-51 。

...
  static async fetchData(match, search, showError) {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    ...

    const query = `query issueList(
      ...
    }`;

    const data = await graphQLFetch(query, vars, this.showError);
    return data;
  }
...

loadData()方法现在将使用这个数据提取器,而不是直接进行查询。已经移动到fetchData()的代码没有显示为明确删除;这是这个方法的完整代码。

...
  async loadData() {
    const { location: { search } } = this.props;
    const data = await IssueList.fetchData(null, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList });
    }
  }
...

在构造函数中,我们将使用存储和初始数据来设置初始问题集,并在用完之后将其删除。

...
  constructor() {
    super();
    const issues = store.initialData ? store.initialData.issueList : null;
    delete store.initialData;
    this.state = {
      issues: [],
      issues,
...

当组件被安装时,如果状态变量有一组有效的问题,即它不是null,我们可以避免加载数据。

...
  componentDidMount() {
    const { issues } = this.state;
    if (issues == null) this.loadData();
  }
...

最后,和在IssueEdit组件中一样,如果状态变量issues被设置为null,让我们跳过渲染。清单 12-51 中显示了对该组件的一整套更改,包括最后的更改。

...
import Toast from './Toast.jsx';

import store from './store.js';

...

export default class IssueList extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = {};
    if (params.get('status')) vars.status = params.get('status');

    const effortMin = parseInt(params.get('effortMin'), 10);
    if (!Number.isNaN(effortMin)) vars.effortMin = effortMin;
    const effortMax = parseInt(params.get('effortMax'), 10);
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueList(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        id title status owner
        created effort due
      }
    }`;

    const data = await graphQLFetch(query, vars, showError);
    return data;
  }

  constructor() {

    super();
    const issues = store.initialData ? store.initialData.issueList : null;
    delete store.initialData;
    this.state = {
      issues: [],
      issues,
      ...
    };
    ...
  }
...

  componentDidMount() {
    const { issues } = this.state;
    if (issues == null) this.loadData();
  }
...

  async loadData() {
    const { location: { search } } = this.props;
    const params = new URLSearchParams(search);
    ...
    const data = await graphQLFetch(query, vars, this.showError);
    const data = await IssueList.fetchData(null, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList });

    }
  }
...

  render() {
    const { issues } = this.state;
    if (issues == null) return null;
    ...
  }
...
}

Listing 12-51ui/src/IssueList.jsx: Changes for Data Fetcher Using Search

如果您现在测试应用,尤其是问题列表页面,您应该会发现问题列表的刷新显示了表中填写的问题。这可以通过检查页面的源代码来确认:您应该发现该表是预填充的。此外,如果您查看开发人员工具的网络选项卡,您应该不会看到任何 API 调用来获取刷新时的问题列表,而 API 调用将在从任何其他页面导航时进行。还要使用不同的过滤器值进行测试,以确保正确使用搜索字符串。

嵌套组件

我们还有一个组件需要处理:IssueDetail。此时,无论是单击列表中的某个问题行,还是使用包含问题 ID(如/issues/1)的 URL 刷新浏览器,组件似乎都可以工作。但是你会发现细节是在挂载组件之后获取的,而不是作为服务器渲染 HTML 的一部分。如前所述,这并不好。我们真的需要细节部分也在服务器上呈现。

虽然 React Router 的动态路由在通过 UI 中的链接导航时工作得很好,但在服务器渲染时却很不方便。我们不容易处理嵌套路由。一种选择是在routes.js中添加路由嵌套,并将嵌套的 route 对象传递给包含组件,以便它可以基于此在适当的位置创建一个<Route>组件。

另一种选择是我们在第九章的“嵌套路由”练习中讨论过的。在这个替代方案中,IssueList的路由规范包括一个可选的问题 ID,这个组件也处理细节部分的加载。这具有以下优点:

  • 路由规范仍然很简单,只有平面结构的顶层页面,没有任何层次结构。

  • 在问题列表加载了所选问题的情况下,它为我们提供了将两个 API 调用合并为一个的机会。

让我们选择这个选项,并修改组件IssueList来呈现它所包含的细节。这将导致IssueDetail组件被大大简化,使其成为一个无状态组件,只呈现细节。新的IssueDetail组件的完整代码如清单 12-52 所示。

import React from 'react';

export default function IssueDetail({ issue }) {
  if (issue) {
    return (
      <div>
        <h3>Description</h3>
        <pre>{issue.description}</pre>
      </div>
    );
  }
  return null;
}

Listing 12-52ui/src/IssueDetail.jsx: Replaced Contents with a Stateless Component

让我们也修改路由来指定 ID 参数,这是可选的。指定参数是可选的方法是给它附加一个?。清单 12-53 显示了routes.js的变更。

...
const routes = [
  { path: '/issues/:id?', component: IssueList },
  ...
];
...

Listing 12-53ui/src/routes.js: Modification of /issues Route to Include an Optional Parameter

现在,在IssueList组件中,我们将在props.match中找到所选问题的 ID。同样,在fetchData()中,我们将使用 match 对象来查找并使用所选问题的 ID。如果存在一个选定的 ID,我们将在一个graph QL 调用中获取它的详细信息和问题列表。GraphQL 允许在一个query中添加多个命名查询,所以我们将利用这一点。但是因为对问题细节的第二次调用是可选的,所以我们必须有条件地执行它。我们可以使用 GraphQL 的@include指令来实现这一点。我们将传递一个额外的变量hasSelection,如果这个变量的值是true,我们将包含第二个查询。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { hasSelection: false, selectedId: 0 };
    ...

    const { params: { id } } = match;
    const idInt = parseInt(id, 10);
    if (!Number.isNaN(idInt)) {
      vars.hasSelection = true;
      vars.selectedId = idInt;
    }

    const query = `query issueList(
      ...
      $hasSelection: Boolean!
      $selectedId: Int!
    ) {
      issueList(
        ...
      }
      issue(id: $selectedId) @include (if : $hasSelection) {
        id description
      }
    }`;

    const data = await graphQLFetch(query, vars, showError);
    return data;
  }
...

现在,当hasSelection被设置为true : issueListissue时,返回的data对象将有两个属性。同样的情况也会出现在store.initialData中,所以让我们使用额外的issue对象来设置构造函数中的初始状态。

...
  constructor() {
    ...
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    delete store.initialData;
    this.state = {
      issues,
      selectedIssue,
      ...
    };
...

我们需要对loadData()进行类似的更改:将match传递给fetchData(),然后使用结果来设置状态变量selectedIssueissues

...
  async loadData() {
    const { location: { search }, match } = this.props;
    const data = await IssueList.fetchData(nullmatch, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList, selectedIssue: data.issue });
    }
  }
...

现在我们可以在render()函数中使用新的状态变量selectedIssue来显示所需位置的细节,而不是一个<Route>

...
  render() {
    ...
    const { match } = this.props;
    const { selectedIssue } = this.state;
    return (
      ...
        <Route path={`${match.path}/:id`} component={IssueDetail} />
        <IssueDetail issue={selectedIssue} />
      ...
    );
  }
...

此时,刷新问题列表和更改过滤器将起作用,但选择新的问题行将不会反映详细信息部分中的更改。这是因为componentDidUpdate()只检查搜索中的变化并重新加载数据。我们还需要检查要重新加载的所选问题的 ID 是否有变化。

...
  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const {
      location: { search: prevSearch },
      match: { params: { id: prevId } },
    } = prevProps;
    const { location: { search }, match: { params: { id } } } = this.props;
    if (prevSearch !== search || prevId !== id) {
      this.loadData();
    }
  }
...

清单 12-54 中显示了对IssueList组件的一整套更改。

...
import URLSearchParams from 'url-search-params';

import { Route } from 'react-router-dom';

...

  static async fetchData(match, search, showError) {
    ...
    const vars = { hasSelection: false, selectedId: 0 };
    ...
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const { params: { id } } = match;
    const idInt = parseInt(id, 10);
    if (!Number.isNaN(idInt)) {
      vars.hasSelection = true;
      vars.selectedId = idInt;
    }

    const query = `query issueList(
      ...
      $hasSelection: Boolean!
      $selectedId: Int!
    ) {
      issueList(
        ...
      }
      issue(id: $selectedId) @include (if : $hasSelection) {
        id description
      }
    }`;
    ...
  }
...

  constructor() {
    ...
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    delete store.initialData;
    this.state = {
      issues,
      selectedIssue,
      ...
    };
...

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const {
      location: { search: prevSearch },
      match: { params: { id: prevId } },

    } = prevProps;
    const { location: { search }, match: { params: { id } } } = this.props;
    if (prevSearch !== search || prevId !== id) {
      this.loadData();
    }
  }
...

async loadData() {
    const { location: { search }, match } = this.props;
    const data = await IssueList.fetchData(nullmatch, search, this.showError);
    if (data) {
      this.setState({ issues: data.issueList, selectedIssue: data.issue });
    }
  }
...

render() {
    ...
    const { match } = this.props;
    const { selectedIssue } = this.state;
    return (
      ...
        <Route path={`${match.path}/:id`} component={IssueDetail} />
        <IssueDetail issue={selectedIssue} />

      ...
    );
  }
...

Listing 12-54ui/src/IssueList.jsx: Pull Up IssueDetail Into IssueList

如果您现在测试问题列表页面,尤其是在选择任何一个问题并刷新浏览器的情况下,您会发现所选问题的详细信息与问题列表一起加载。如果您通过单击另一行来更改所选问题,您将在 Developer Tools 的 Network 选项卡中看到,单个 GraphQL 调用正在获取问题列表以及所选问题的详细信息。

练习:嵌套构件

  1. 当更改所选问题时,尽管这只是一个 GraphQL 调用,但整个问题列表都将被获取。这不是必需的,并且会增加网络流量。您将如何为此进行优化?

本章末尾有答案

重新寄送

我们还有最后一件事要处理:对主页的请求,也就是/,从服务器返回一个包含一个空页面的 HTML。这似乎是可行的,因为在浏览器上渲染之后,React Router 会在浏览器历史中重定向到/issues 。我们真正需要的是服务器本身用 301 重定向来响应,这样浏览器就可以从服务器获取/issues。通过这种方式,搜索引擎机器人也将从对/的请求中获得与对/issues的请求相同的内容。

React 路由的StaticRouter通过在传递给它的任何上下文中设置一个名为url的变量来处理这个问题。我们一直在向StaticRouter传递一个空的、未命名的上下文。相反,让我们向它传递一个命名的对象,尽管它是空的。渲染成 string 后,如果在这个对象中设置了url属性,就意味着路由匹配到了一个重定向到这个 URL。我们需要做的就是在响应中发送一个重定向,而不是模板化的响应。

清单 12-55 显示了对render.jsx进行的处理重定向的更改。

...
  store.initialData = initialData;
  const context = {};
  const element = (
    <StaticRouter location={req.url} context={{}context}>
      <Page />
    </StaticRouter>
  );
  const body = ReactDOMServer.renderToString(element);

  if (context.url) {
    res.redirect(301, context.url);
  } else {
    res.send(template(body, initialData));
  }
...

Listing 12-55ui/server/render.jsx: Handle Redirects on the Server

现在,如果您在浏览器中输入http://localhost:8000/,您应该看到问题列表页面加载时没有任何闪烁。在 Network 选项卡中,您会发现第一个请求导致了到/issues的重定向,然后按照常规路径在服务器上呈现问题列表。

摘要

这一章可能有点沉重,因为我们使用了复杂的结构和模式来实现服务器渲染。希望使用 About 页面可以降低复杂性,并帮助您理解服务器渲染的基本概念。

现在很明显,React 本身不是一个框架,并不决定完成应用的每个附加部分。React Router 在前端路由方面帮了我们一点忙,但是有些并不适合服务器渲染。我们必须发明自己的生成路由模式和数据获取模式,作为每个路由组件中的静态方法,来处理与服务器呈现的页面相关的数据。

当我们进入下一章时,我们不会只关注一个单一的特性或概念。相反,我们将实现许多应用共有的特性。在此过程中,我们将了解 MERN 堆栈如何满足这些高级功能的需求。

练习答案

练习:基本服务器渲染

  1. 可以考虑用ReactDOMServerrenderToNodeStream()代替renderToString()。此方法返回一个可以通过管道传输到快速响应流的流。我们不需要模板,而是需要前体和后体字符串,我们可以分别在通过管道传输 Node 流之前和之后将它们写入响应。

练习:服务器路由

  1. 当使用服务器呈现来呈现 About 页面时,您会发现单击+菜单项没有任何作用。菜单项没有附加事件处理程序,此外,您会发现没有可以放置断点的代码。原因是模板不包括包含组件和 React 库代码的 JavaScript 包。

  2. 在浏览器呈现的导航栏中,单击链接不会从服务器加载页面。只进行 XHR 调用来获取数据,并且在浏览器上构造 DOM。在服务器呈现的导航栏中,点击一个链接从服务器加载页面,就像普通的href会做的那样。原因与前面的练习相同:在服务器呈现的页面中,没有附加事件处理程序来捕获 click 事件并在浏览器中对 DOM 进行更改。在服务器呈现的页面中,链接的行为就像纯href链接一样:它们让浏览器加载一个新页面。

练习:带参数的数据提取器

  1. 你可以使用JSON.parse(),但是它需要一个字符串作为参数。因为初始数据的字符串表示本身有许多双引号,所以需要对它们进行转义,或者可以用单引号将它们括起来。一些人使用的另一种策略是使用隐藏的textareadiv来存储字符串,并从 DOM 中读取它,然后在该字符串上调用JSON.parse()。我发现序列化是一个更简洁、更清晰的选择。

    相反,API 的调用者需要在结果数据上使用一个eval()而不是JSON.parse()。这是非常危险的,因为如果数据中包含任何新的函数,它将允许安装新的函数。如果 API 服务器由于某种原因遭到破坏,这可能会导致恶意代码被注入浏览器。此外,这种策略假设调用者使用 JavaScript,这可能不是一个有效的假设。

练习:嵌套构件

  1. 优化正在获取的数据的一个好策略是编写另一个方法,通过不同的 GraphQL 查询单独获取选定的问题。如果搜索没有改变,但是 id 参数改变了,那么这个方法,比如说loadSelectedIssue(),可以从componentDidUpdate()调用。

十三、高级功能

在这一章中,我们将看看许多应用共有的特性。这些特性跨越了 MERN 堆栈所包含的不同技术(前端、后端、数据库),并要求我们对所有这些技术进行整合以使其发挥作用。

我们将首先重构 UI 代码,以便在显示 Toast 消息的许多组件之间重用公共代码。我们将使用一种常见的模式来 React,将组件中大多数重复的代码移动到新文件中。然后,我们将实现到目前为止一直是占位符的报告页面。这就需要我们使用 MongoDB 的聚合函数。然后,我们将在问题列表页面中实现分页,以处理大型列表。这将锻炼 MongoDB 的另一个特性:find()的跳过和偏移选项。

然后,我们将在删除问题时实现一个撤销操作来恢复它们。最后,我们将显示一个搜索栏,用户可以在其中键入关键字并查找与关键字匹配的问题。

吐司的高阶分量

在显示和管理 Toast 消息的主视图中,有相当一部分代码是重复的。这包括以下内容:

  • 用于 Toast 的状态变量:显示状态、消息和消息类型

  • 方法showError()showSuccess()dismissToast()

  • Toast组件在render()功能中的位置

在许多其他语言中,这些问题可以通过从实现了这些方法和变量的基类继承实际视图来解决。但是,React 的作者建议在组件间重用代码时,组合优先于继承。因此,让我们创建一个新的组件来包装每个主视图,以添加 Toast 功能。我们姑且称这个类为ToastWrapper。因此,我们将使用ToastWrapper和任何视图组件,比如说IssueList,用组合一个包装组件。这是视图的父视图需要使用的,而不是普通视图组件。下面是ToastWrapperrender()方法的框架:

...
    render() {
      return (
        <React.Fragment>
          <IssueList />
          <Toast />
        </React.Fragment>
      );
    }
...

现在,我们可以将与 Toast 相关的所有状态变量移到ToastWrapper组件和dismissToast方法中。在 render 中,我们可以使用状态变量来控制 Toast 的显示,将代码移出IssueList

...
    constructor(props) {
      super(props);
      this.state = {
        toastVisible: false, toastMessage: “, toastType: 'success',
      };
      this.dismissToast = this.dismissToast.bind(this);
    }

    dismissToast() {
      this.setState({ toastVisible: false });
    }

    render() {
      const { toastType, toastVisible, toastMessage } = this.state;
      return (
        <React.Fragment>
          <IssueList />
          <Toast
            bsStyle={toastType}
            showing={toastVisible}
            onDismiss={this.dismissToast}
          >
            {toastMessage}
          </Toast>
        </React.Fragment>
      );
    }
...

在原始组件IssueList中,我们需要一种方法来显示错误,并且如果需要的话,消除它。让我们在ToastWrapper中创建showErrorshowSuccess方法,并将它们作为道具传递给IssueList。此外,让我们包括父母可能想要传递给IssueList的任何其他道具。

...
    showSuccess(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'success' });
    }

    showError(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'danger' });
    }
...
    render() {
      ...
          <IssueList
            showError={this.showError}
            showSuccess={this.showSuccess}
            dismissToast={this.dismissToast}
            {...this.props}
          />
...

我们仍然需要参数化视图,而不是硬编码IssueList。为每个需要显示 Toast 消息的视图创建这个类会违背重用代码的目的。一种方法是将原始组件作为父组件中包装的子组件进行传递,如下所示:

...
  <ToastWrapper>
    <IssueList .../>
  </ToastWrapper>
...

并且,在ToastWrapper类中,我们可以使用props.children来代替硬编码IssueList。对于其他视图,IssueEditIssueAddNavItem,我们将需要类似的包装组件。这在某些情况下可以工作,但是如果你看看这些组件在哪里被使用,你会注意到我们需要提供一个组件,而不是一个实例。下面是来自routes.js的片段。

...
  { path: '/issues/:id?', component: IssueList },
...

我们真正需要的是从现有的组件类IssueListIssueEditIssueAddNavItem中创建一个新的组件类。让我们创建一个名为withToast的函数来做这件事,就像 React 路由的withRouter函数一样。它将接受原始组件作为参数,并返回一个使用ToastWrapper并包装原始组件的类。

...

export default function withToast(OriginalComponent) {

  return class ToastWrapper extends React.Component {
    ...
    render() {
          <OriginalComponent
            ...
            {...this.props}
          />
  };
}
...

现在,无论哪里提到IssueList,我们都可以简单地用withToast(IssueList)来代替。这种从现有组件类创建新组件类并向其注入额外功能的模式被称为高阶组件 (HOC)。清单 13-1 显示了新 HOC 的完整代码。

import React from 'react';
import Toast from './Toast.jsx';

export default function withToast(OriginalComponent) {
  return class ToastWrapper extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        toastVisible: false, toastMessage: ", toastType: 'success',
      };
      this.showSuccess = this.showSuccess.bind(this);
      this.showError = this.showError.bind(this);
      this.dismissToast = this.dismissToast.bind(this);
    }

    showSuccess(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'success' });
    }

    showError(message) {
      this.setState({ toastVisible: true, toastMessage: message, toastType: 'danger' });
    }

    dismissToast() {
      this.setState({ toastVisible: false });
    }

    render() {
      const { toastType, toastVisible, toastMessage } = this.state;
      return (
        <React.Fragment>
          <OriginalComponent
            showError={this.showError}
            showSuccess={this.showSuccess}
            dismissToast={this.dismissToast}
            {...this.props}
          />
          <Toast
            bsStyle={toastType}
            showing={toastVisible}
            onDismiss={this.dismissToast}
          >
            {toastMessage}
          </Toast>
        </React.Fragment>
      );
    }
  };
}

Listing 13-1ui/src/withToast.jsx: New HOC for Adding Toast Functionality to a Component

现在,要使用这个新功能,可以在routes.js中完成以下操作:

...
  { path: '/issues/:id?', component: withToast(IssueList) },
...

但是这增加了两个模块之间的耦合:routes.js现在需要知道哪些组件需要 Toast 功能。相反,就像我们对withRouter包装所做的那样,让我们将这个包装器封装在组件本身(如IssueList)中,并导出修改后的组件类,例如:

...
export default class IssueList extends React.Component {
 ...
}

export default withToast(IssueList);

...

但是这有一个副作用:它会隐藏静态方法fetchData(),这个方法是从这个组件外部调用的。我们还必须将组件的静态方法的引用复制到包装的组件中,使其可见。

...
const IssueListWithToast = withToast(IssueList);
IssueListWithToast.fetchData = IssueList.fetchData;

export default IssueListWithToast;
...

为了让IssueList组件使用withToast,需要做的其他更改是删除状态变量、Toast 相关的方法定义,以及用通过 props 接收的方法替换 Toast 函数的类方法。清单 13-2 中显示了IssueList组件的一整套变更。

...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

export default class IssueList extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  async loadData() {
    const { location: { search }, match, showError } = this.props;
    const data = await IssueList.fetchData(match, search, this.showError);
    ...
  }
...

  async closeIssue(index) {
    ...
    const { showError } = this.props;
    const data = await graphQLFetch(query, { id: issues[index].id },
      this.showError);
    ...
  }
...

  async deleteIssue(index) {
    ...
    const { showSuccess, showError } = this.props;
    ...
    const data = await graphQLFetch(query, { id }, this.showError);
    if (data && data.issueDelete) {
      ...
      this.showSuccess(`Deleted issue ${id} successfully.`);
    }
    ...
  }
...

  showSuccess(message) {
    ...
  }

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

const IssueListWithToast = withToast(IssueList);

IssueListWithToast.fetchData = IssueList.fetchData;

export default IssueListWithToast;

...

Listing 13-2ui/src/IssueList.jsx: Changes for Using the withToast HOC

清单 13-3 中显示了对组件IssueEdit的一组类似更改。

...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

export default class IssueEdit extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showSuccess = this.showSuccess.bind(this);
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  async handleSubmit(e) {
    ...
    const { showSuccess, showError } = this.props;
    const data = await graphQLFetch(query, { changes, id }, this.showError);
    if (data) {
      ...
      this.showSuccess('Updated issue successfully');
    }
  }

  async loadData() {
    const { match, showError } = this.props;
    const data = await IssueEdit.fetchData(match, null, this.showError);
    ...
  }

...

  showSuccess(message) {
    ...
  }

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

const IssueEditWithToast = withToast(IssueEdit);

IssueEditWithToast.fetchData = IssueEdit.fetchData;

export default IssueEditWithToast;

...

Listing 13-3ui/src/IssueEdit.jsx: Changes for Using the withToast HOC

组件IssueAddNavItem的变化类似,只是略有不同。这个组件没有fetchData()方法,所以我们不需要将该方法复制到包装的组件中。此外,组件已经用withRouter()包装了,所以除此之外我们还需要添加withToast()包装器。变更如清单 13-4 所示。

...
...

import Toast from './Toast.jsx';

import withToast from './withToast.jsx';

...

class IssueAddNavItem extends React.Component {
  ...
  constructor() {
    ...
    this.state = {
      ...
      toastVisible: false,
      toastMessage: ",
      toastType: 'info',
     };
    ...
    this.showError = this.showError.bind(this);
    this.dismissToast = this.dismissToast.bind(this);
  }
...

  showError(message) {
    ...
  }

  dismissToast() {
    ...
  }
...

  async handleSubmit(e) {
    ...
    const { showError } = this.props;
    const data = await graphQLFetch(query, { issue }, this.showError);
    ...
  }
...

  render() {
    ...
    const { toastVisible, toastType, toastMessage } = this.state;
    ...
        <Toast
          ...
        >
          {toastMessage}
        </Toast>
    ...
  }
...

export default withToast(withRouter(IssueAddNavItem));
...

Listing 13-4ui/src/IssueAddNavItem.jsx: Changes for Using the withToast HOC

有了这些变化,应用应该继续像以前一样工作。您可以通过测试这些组件显示的每个错误或成功消息来测试它。

注意

尽管我们不遗余力地确保所有代码清单的准确性,但在本书付印之前,可能会有一些错别字甚至更正没有被收入书中。所以,一定要依赖 GitHub 库( https://github.com/vasansr/pro-mern-stack-2 )作为所有代码清单的经过测试和最新的源代码,尤其是如果有些东西不能像预期的那样工作。

MongoDB 聚合

到目前为止,我们已经在导航栏中为报告留下了占位符。为了准备在接下来的两个小节中实现这个页面,让我们探索一下 MongoDB 在获取集合的汇总数据方面提供了什么,也就是说,聚合

首先,让我们在数据库中创建许多问题,以便摘要看起来有意义。清单 13-5 显示了一个简单的 MongoDB shell 脚本,用于生成一组随机分布在日期、所有者和状态之间的问题。

/* global db print */
/* eslint no-restricted-globals: "off" */

const owners = ['Ravan', 'Eddie', 'Pieta', 'Parvati', 'Victor'];
const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];

const initialCount = db.issues.count();

for (let i = 0; i < 100; i += 1) {
  const randomCreatedDate = (new Date())
    - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24;
  const created = new Date(randomCreatedDate);
  const randomDueDate = (new Date())
    - Math.floor(Math.random() * 60) * 1000 * 60 * 60 * 24;
  const due = new Date(randomDueDate);

  const owner = owners[Math.floor(Math.random() * 5)];
  const status = statuses[Math.floor(Math.random() * 4)];
  const effort = Math.ceil(Math.random() * 20);
  const title = 'Lorem ipsum dolor sit amet, ${i}';
  const id = initialCount + i + 1;

  const issue = {
    id, title, created, due, owner, status, effort,
  };

  db.issues.insertOne(issue);
}

const count = db.issues.count();
db.counters.update({ _id: 'issues' }, { $set: { current: count } });

print('New issue count:', count);

Listing 13-5api/scripts/generate_data.mongo.js: Mongo Shell Script to Generate Some Data

让我们运行这个脚本一次,用 100 个新问题填充数据库。如果您在本地主机上使用 mongo,执行此操作的命令是:

$ cd api
$ mongo issuetracker scripts/generate_data.mongo.js

MongoDB 提供了集合方法aggregate()来使用管道汇总和执行集合上的各种其他读取任务。管道是在返回结果集之前对集合进行的一系列转换。事实上,对没有任何参数的aggregate()的默认调用与对find()的调用是相同的,也就是说,它返回集合中的整个文档列表,没有任何操作。

MongoDB 聚合管道由多个阶段组成。每个阶段都会在文档通过管道时对其进行转换。例如,一个match阶段将像一个过滤器一样过滤来自前一阶段的文档列表。为了模拟带有滤波器的find(),可以使用流水线中的单个match级。要转换文档,可以使用一个project阶段。与find()中的投影不同,它甚至可以使用表达式向文档添加新的计算字段。

每个阶段不必产生前一阶段的一对一映射。group阶段就是这样一个阶段,它产生一个摘要,而不是复制每个文档。unwind阶段的作用正好相反:它为每个数组元素将数组字段扩展到一个文档中。同一个阶段可以出现多次—例如,您可以从一个match开始,然后是一个group,然后是另一个match,以便在分组后过滤掉一些文档。

有关所有可用阶段的完整列表,请参考位于 https://docs.mongodb.com/manual/reference/operator/aggregation/ 的 MongoDB 管道阶段文档。我将只深入讨论在 Issue Tracker 应用中实现报告页面所需的两个阶段match(基于过滤器)和group(总结计数)。

aggregate()方法采用单个参数,即管道阶段规范的数组。每个阶段规范都是一个对象,带有一个指示阶段类型的键和保存阶段参数的值。正如您在find()方法中指定的那样,一个match阶段接收过滤器。因此,以下命令(在 MongoDB shell 中发出)将返回所有未解决的问题:

> db.issues.aggregate([ { $match: { status: 'New' } } ])

小组赛稍微复杂一点。它由一组需要创建的字段以及如何创建它们的规范组成。在对象规范中,键是字段的名称,值指定如何构造字段的值。这些值通常基于文档中的现有字段(实际上是前一阶段的结果),要引用这些字段,需要使用一个$前缀,如果没有这个前缀,它将被视为文字。

_id字段是强制性的,具有特殊的含义:它是结果分组所依据的值。通常,您使用一个现有的字段规范,将根据该字段的值进行分组。对于输出中的其余字段,您可以指定一个聚合函数来构造它们的值。

例如,如果您需要所有者分组的所有问题的总和和平均工作量,您可以使用下面的aggregate()命令(它在管道中有一个阶段):

> db.issues.aggregate([
  { $group: {
    _id: '$owner',
    total_effort: { $sum: '$effort' },
    average_effort: {$avg: '$effort' },
  } }
])

该命令将产生如下输出:

{ "_id" : "Victor", "total_effort" : 232, "average_effort" : 10.08695652173913 }
{ "_id" : "Pieta", "total_effort" : 292, "average_effort" : 12.166666666666666 }
{ "_id" : "Parvati", "total_effort" : 212, "average_effort" : 11.157894736842104 }
{ "_id" : "Eddie", "total_effort" : 143, "average_effort" : 8.9375 }
{ "_id" : "Ravan", "total_effort" : 213, "average_effort" : 10.65 }

如果整个集合需要分组为单个值,即不需要分组字段;您可以为_id使用一个文字值,通常为 null。此外,没有特殊的计数函数——您只需要将数字 1 相加,就可以得到所有匹配文档的计数。因此,使用aggregate()方法计算集合中文档数量的另一种方法是

> db.issues.aggregate([ { $group: { _id: null, count: { $sum: 1 } } }])

这将产生如下输出:

{ "_id" : null, "count" : 102 }

为了在对输出进行分组之前使用过滤器,可以使用一个match作为数组中的第一个元素,后面是group阶段。因此,要统计状态为New的问题的数量,可以使用下面的 aggregate 命令:

> db.issues.aggregate([
  { $match: { status: 'New' } },
  { $group: { _id: null, count: { $sum: 1 } } },
])

与集合中的所有问题相比,这将导致更低的计数,如下所示:

{ "_id" : null, "count" : 31 }

您可以通过发出一个count命令来验证计数是否正确。

> db.issues.count({ status: 'New' })

对于报告页面,让我们创建一个数据透视表(或交叉选项卡)输出,显示分配给不同所有者的问题数量,并进一步按状态分组。为此,我们需要两个分组字段,所有者和状态。通过将_id字段设置为对象而不是字符串,可以指定多个分组字段。该对象需要包含输出的名称和每个字段的字段标识符。然后,在输出中,将返回一个对象,而不是一个字符串作为_id的值,每个返回的行都有两个字段的不同组合。

因此,要获得按所有者和状态分组的问题计数,可以使用以下命令:

> db.issues.aggregate([
  { $group: {
    _id: { owner: '$owner',status: '$status' },
    count: { $sum: 1 },
  } }
])

这将产生一个文档数组,每个所有者状态组合对应一个文档,这是键或_id

{ "_id" : { "owner" : "Eddie", "status" : "Closed" }, "count" : 2 }
{ "_id" : { "owner" : "Parvati", "status" : "Closed" }, "count" : 2 }
{ "_id" : { "owner" : "Victor", "status" : "Closed" }, "count" : 6 }
{ "_id" : { "owner" : "Victor", "status" : "Assigned" }, "count" : 6 }
{ "_id" : { "owner" : "Parvati", "status" : "Fixed" }, "count" : 6 }
...

要在对结果进行分组之前添加过滤器,可以使用match阶段。例如,要获得工作量大于 4 的问题的计数,命令应该是:

> db.issues.aggregate([
  { $match: { effort: { $gte: 4 } } },
  { $group: {
    _id: { owner: '$owner',status: '$status' },
    count: { $sum: 1 },
  } }
])

这是查询的最终结构,我们将在下一节中使用它来实现有助于构建报告页面的 API。

发货计数 API

作为 Issue Counts API 实现的一部分,我们需要对 MongoDB 进行聚合查询,如前一节所述。按原样返回从 MongoDB 接收到的数据会非常方便,但是让我们尝试让调用者更容易使用它。在返回的数组中,每个所有者有一个元素,而不是每个所有者-状态组合有一个元素,每个状态的计数各有一个属性。在 GraphQL 模式中,让我们将其定义为一种新类型。

...
type IssueCounts {
  owner: String!
  New: Int
  Assigned: Int
  Fixed: Int
  Closed: Int
}
...

查询本身将接受一个过滤器规范作为输入(如在issueList查询中),并返回一组IssueCounts对象。清单 13-6 中显示了schema.graphql的适用变更。

...
type Issue {
  ...
}

type IssueCounts {

  owner: String!
  New: Int
  Assigned: Int
  Fixed: Int
  Closed: Int

}

...
type Query {
  ...
  issue(id: Int!): Issue!
  issueCounts(
    status: StatusType
    effortMin: Int
    effortMax: Int
  ): [IssueCounts!]!
}

...

Listing 13-6api/schema.graphql: Changes for Issue Counts API

我们将把 API 或解析器的实现和其他与这个对象相关的解析器放在issue.js中。我们将调用新函数counts(),它将接受一个过滤器规范,与list函数相同。让我们从list函数中复制过滤器构造部分,并在查询中使用它,这只不过是我们在上一节结束时最终确定的内容。然后,我们将处理数据库返回的每个文档,并更新一个名为stats的对象。我们将使用作为所有者的键来定位对象(如果没有找到,我们将创建一个),然后将状态键的值设置为计数。

...
  const stats = {};
  results.forEach((result) => {
    // eslint-disable-next-line no-underscore-dangle
    const { owner, status: statusKey } = result._id;
    if (!stats[owner]) stats[owner] = { owner };
    stats[owner][statusKey] = result.count;
  });
...

但是我们需要返回一个数组给调用者。我们可以通过简单地调用Object.values(stats)来做到这一点。最后,我们需要添加新函数作为另一个导出值,以及其他导出值。清单 13-7 中显示了对issue.js的全部更改。

...
async function remove(_, { id }) {
  ...
}

async function counts(_, { status, effortMin, effortMax }) {

  const db = getDb();
  const filter = {};

  if (status) filter.status = status;

  if (effortMin !== undefined || effortMax !== undefined) {
    filter.effort = {};
    if (effortMin !== undefined) filter.effort.$gte = effortMin;
    if (effortMax !== undefined) filter.effort.$lte = effortMax;
  }

  const results = await db.collection('issues').aggregate([
    { $match: filter },
    {
      $group: {
        _id: { owner: '$owner', status: '$status' },
        count: { $sum: 1 },
      },
    },
  ]).toArray();

  const stats = {};
  results.forEach((result) => {
    // eslint-disable-next-line no-underscore-dangle
    const { owner, status: statusKey } = result._id;
    if (!stats[owner]) stats[owner] = { owner };
    stats[owner][statusKey] = result.count;
  });
  return Object.values(stats);

}

module.exports = {
  list,
  add,
  get,
  update,
  delete: remove,
  counts,
};
...

Listing 13-7api/issue.js: Changes for Issue Counts API

现在,为了将新函数绑定到 GraphQL 模式,让我们对为端点issueCounts指定解析器的api_handler.js进行更改。清单 13-8 中显示了适当的变更。

...
const resolvers = {
  Query: {
    ...
    issueCounts: issue.counts,
  },
  ...
};
...

Listing 13-8api/api_handler.js: Changes for Issue Counts API

您现在可以使用操场测试 API。一个简单的带有最小工作量过滤器的查询如下所示:

query {
  issueCounts(effortMin: 4) {
    owner New Assigned Fixed Closed
  }
}

运行该查询后,您应该会看到如下结果:

{
  "data": {
    "issueCounts": [
      {
        "owner": "Eddie",
        "New": 4,
        "Assigned": 2,
        "Fixed": 4,
        "Closed": 1
      },
      {
        "owner": "Parvati",
        "New": 4,
        "Assigned": 3,
        "Fixed": 6,
        "Closed": 2
      },
      ...
    ]
  }
}

练习:发料数量 API

  1. 除了将stats对象转换成一个数组,我们还能原样返回stats对象吗?模式会是什么样子?提示:在 https://github.com/apollographql/apollo/issues/5 查找 Apollo GraphQL 问题。

本章末尾有答案。

报告页面

现在我们有了一个工作的 API,让我们为报告页面构建 UI。我们将使用一种通常称为交叉表或数据透视表的格式:一个表的一个轴标有状态,另一个轴标有所有者。

由于所有者的数量可能很多,而状态的数量有限,因此让我们在水平轴(表格行标题)上排列状态,并使用每个所有者一行来显示分配给该所有者的问题数。通过这种方式,我们可以轻松处理大量的所有者。让我们用从React.component继承的常规组件替换IssueReport中的无状态组件占位符,并从render()方法开始。我们将使用一个可折叠的面板并将IssueFilter组件放在这里,就像我们对问题列表所做的那样。接下来,让我们展示一个带有报告的表格,API 返回的数据中的每个值都占一行。

但是我们不能原样使用IssueFilter组件。这是因为它的 Apply 按钮被硬编码为导航到路线/issues,过滤器被设置为搜索字符串。让我们首先解决这个问题,将基本 URL 作为道具传递给这个组件。在问题列表中,这可以作为/issues传递,在报告页面中,这可以作为/report传递。在清单 13-9 中显示了IssueFilter组件的变化。

...
  applyFilter() {
    ...
    const { history, urlBase } = this.props;
    ...
    history.push({ pathname: '/issues' urlBase, search });
  }
...

Listing 13-9ui/src/IssueFilter.jsx: Changes for Customizable Base URL

让我们也为传入新属性而对IssueList进行修改。这一变化如清单 13-10 所示。

...
  render() {
    ...
          <Panel.Body collapsible>
            <IssueFilter urlBase="/issues" />
          </Panel.Body>
    ...
  }
}
...

Listing 13-10ui/src/IssueList.jsx: Changes to Customizable Base URL in IssueFilter

现在,让我们从IssueReport组件中的 render()方法开始,使用一个可折叠的过滤器和一个表格。我们将很快填充headerColumnsstatRows变量。

...
    return (
      <>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter urlBase="/report" />
          </Panel.Body>
        </Panel>
        <Table bordered condensed hover responsive>
          <thead>
            <tr>
              <th />
              {headerColumns}
            </tr>
          </thead>
          <tbody>
            {statRows}
          </tbody>
        </Table>
      </>
    );
...

注意

语法<><React.Fragment>的 JSX 快捷方式。

在为每个状态生成标题列时,让我们创建一个可以迭代的所有状态的数组,而不是单独指定每个状态。使用这个,我们将为标题中的每个状态生成一个<th>

...
const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];
...

  render() {
    const headerColumns = (
      statuses.map(status => (
        <th key={status}>{status}</th>
      ))
    );
    ...
  }
...

至于行本身,我们需要通过调用 API 来迭代接收到的数据。让我们将这些数据存储在一个名为stats的状态变量中。如果这个变量没有初始化(当异步 API 调用还没有返回时就会出现这种情况),我们返回一个空白页。现在可以生成这样的行:

...
  render() {
    const { stats } = this.state;
    if (stats == null) return null;
    ...

    const statRows = stats.map(counts => (
      <tr key={counts.owner}>
        <td>{counts.owner}</td>
        {statuses.map(status => (
          <td key={status}>{counts[status]}</td>
        ))}
      </tr>
    ));
    ...
  }

...

现在让我们实现数据获取静态方法fetchData()。从过滤器创建查询变量的初始部分可以从IssueList.jsx复制过来,除了hasSelectionselectedID变量。GraphQL 的查询是我们在操场上测试时使用的,但是使用过滤器参数作为变量。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { };
    ...
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueCounts(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        owner New Assigned Fixed Closed
      }
    }`;
    const data = await graphQLFetch(query, vars, showError);
    return data;
  }
...

对于该组件的其余实现,让我们遵循与其他主视图中相同的模式。我们需要添加以下内容:

  • 从存储区获取初始数据并在消费后将其删除的构造函数

  • 一种componentDidMount()方法,用于在数据尚未加载的情况下加载数据

  • 一个componentDidUpdate()方法,用于检查搜索字符串是否已经更改,如果已经更改,则重新加载数据

  • 这两个生命周期方法可以调用的一个loadData()方法来加载它并设置状态

  • 一个 Toast 包装器,它需要被导出,而不是原来的类

页面的最终完整代码如清单 13-11 所示。

import React from 'react';
import { Panel, Table } from 'react-bootstrap';

import IssueFilter from './IssueFilter.jsx';
import withToast from './withToast.jsx';
import graphQLFetch from './graphQLFetch.js';
import store from './store.js';

const statuses = ['New', 'Assigned', 'Fixed', 'Closed'];

class IssueReport extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    const vars = { };
    if (params.get('status')) vars.status = params.get('status');

    const effortMin = parseInt(params.get('effortMin'), 10);
    if (!Number.isNaN(effortMin)) vars.effortMin = effortMin;
    const effortMax = parseInt(params.get('effortMax'), 10);
    if (!Number.isNaN(effortMax)) vars.effortMax = effortMax;

    const query = `query issueList(
      $status: StatusType
      $effortMin: Int
      $effortMax: Int
    ) {
      issueCounts(
        status: $status
        effortMin: $effortMin
        effortMax: $effortMax
      ) {
        owner New Assigned Fixed Closed
      }
    }`;
    const data = await graphQLFetch(query, vars, showError);
    return data;
  }

  constructor(props) {
    super(props);
    const stats = store.initialData ? store.initialData.issueCounts : null;
    delete store.initialData;
    this.state = { stats };
  }

  componentDidMount() {
    const { stats } = this.state;
    if (stats == null) this.loadData();
  }

  componentDidUpdate(prevProps) {
    const { location: { search: prevSearch } } = prevProps;
    const { location: { search } } = this.props;
    if (prevSearch !== search) {
      this.loadData();
    }
  }

  async loadData() {
    const { location: { search }, match, showError } = this.props;
    const data = await IssueReport.fetchData(match, search, showError);
    if (data) {
      this.setState({ stats: data.issueCounts });
    }
  }

  render() {
    const { stats } = this.state;
    if (stats == null) return null;

    const headerColumns = (
      statuses.map(status => (
        <th key={status}>{status}</th>
      ))
    );

    const statRows = stats.map(counts => (
      <tr key={counts.owner}>
        <td>{counts.owner}</td>
        {statuses.map(status => (
          <td key={status}>{counts[status]}</td>
        ))}
      </tr>
    ));

    return (
      <>
        <Panel>
          <Panel.Heading>
            <Panel.Title toggle>Filter</Panel.Title>
          </Panel.Heading>
          <Panel.Body collapsible>
            <IssueFilter urlBase="/report" />
          </Panel.Body>
        </Panel>
        <Table bordered condensed hover responsive>
          <thead>
            <tr>
              <th />
              {headerColumns}
            </tr>
          </thead>
          <tbody>
            {statRows}
          </tbody>
        </Table>
      </>
    );
  }
}

const IssueReportWithToast = withToast(IssueReport);
IssueReportWithToast.fetchData = IssueReport.fetchData;

export default IssueReportWithToast;

Listing 13-11ui/src/IssueReport.jsx: New Report Page

如果您现在测试报告页面,您应该会看到类似于图 13-1 中的页面。您可以使用可折叠的滤镜面板来更改滤镜并查看其效果。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig1_HTML.jpg

图 13-1

报告页面

练习:报告页面

  1. 假设您需要行总计。你将如何着手实现这一点?试试看。

  2. 列合计怎么样?如何实现这些目标?

本章末尾有答案。

带分页的列表 API

您可能已经注意到问题列表页面已经变得很难使用,因为它显示了数据库中的所有问题。在这一节和下一节中,我们将实现分页,以便向用户显示一组有限的问题,并可以导航到其他页面。让我们为下一节保留 UI,并在这一节修改 List API 以支持分页。

为了显示分页栏,我们还需要列表的总页数。因此,让我们首先修改模式,在问题列表之外添加页面计数。我们不直接返回问题列表,而是需要返回一个包含列表和页数的对象。然后,除了过滤器规范之外,我们还需要指定要获取哪个页面。

清单 13-12 显示了模式中的变化。

...
type IssueCounts {
  ...
}

type IssueListWithPages {

  issues: [Issue!]!
  pages: Int

}

...

type Query {
  ...
  issueList(
    ...
    page: Int = 1
  ): [Issue!]! IssueListWithPages
  ...
}
...

Listing 13-12api/schema.graphql: Addition of Page Count to List API

注意

在实际项目中更改 GraphQL API 并不是一个好的做法,因为这会破坏 UI 应用。推荐的做法是创建一个新的 API,例如,为此创建一个名为issueListWithPages的查询,尤其是在应用已经投入生产的情况下。但是我正在修改现有的 API,以便最终的代码简洁。

在 API 实现中,我们必须使用新的参数page跳到给定的页面,并限制返回的对象数量。MongoDB 游标方法skip()可以用来获取从一个偏移量开始的文档列表。此外,limit()光标方法可用于将输出限制到某个数字。我们将使用PAGE_SIZE常量来表示一页中的文档数。

...
const PAGE_SIZE = 10;
...
  const issues = await db.collection('issues').find(filter).
    .skip(PAGE_SIZE * (page - 1))
    .limit(PAGE_SIZE)
    .toArray();
...

每当我们在列表中使用偏移量时,我们还需要确保列表在多次查询时处于相同的顺序。如果没有明确的排序顺序,MongoDB 不能保证输出的任何顺序。两个查询之间文档的顺序可能不同(尽管看起来总是插入的顺序)。为了保证一定的顺序,我们需要包含一个排序规范。由于 ID 是排序的自然关键字(因为它匹配插入顺序),并且它是一个索引字段(也就是说,请求列表按这个顺序排序没有损失),所以让我们将它用作排序关键字。

...
  const issues = await db.collection('issues').find(filter).
    .sort({ id: 1 })
    ...
...

现在,我们还需要页面数,这需要与该过滤器匹配的文档数。MongoDB 让我们查询游标本身匹配的文档数,而不是通过另一个查询来获得计数。所以,与其把find()返回的光标转换成数组,不如先保留光标并查询它的计数,再转换成数组返回。

...
  const cursor = db.collection('issues').find(filter)
    ....

  const totalCount = await cursor.count(false);
  const issues = cursor.toArray();
  const pages = Math.ceil(totalCount / PAGE_SIZE);
...

注意,count()方法是异步的,但是它最终会评估光标的内容。因此,可以同步调用游标toArray()上的下一个调用。count()函数的参数接受一个布尔值,该值决定返回的计数是否考虑了skip()limit()的影响。使用参数的值作为false给出了匹配过滤器的对象总数。这是我们需要的计数。

现在,我们可以返回问题列表以及返回值中的页数。清单 13-13 显示了所有的变化,包括 API 实现中的变化。

...

const PAGE_SIZE = 10;

async function list(_, {
  status, effortMin, effortMax, page,
}) {
  ...
  const issues = await db.collection('issues').find(filter).toArray();
  const cursor = db.collection('issues').find(filter)
    .sort({ id: 1 })
    .skip(PAGE_SIZE * (page - 1))
    .limit(PAGE_SIZE);

  const totalCount = await cursor.count(false);
  const issues = cursor.toArray();
  const pages = Math.ceil(totalCount / PAGE_SIZE);
  return issues;
  return { issues, pages };
}
...

Listing 13-13api/issue.js: Add Pagination Support to List API

由于模式中的返回值已经改变,我们还需要改变调用者,即IssueList组件来适应这种改变。我们不需要直接使用数据中的值issueList,而是需要将它用作issueList.issues。我们还不会实现分页;此更改只是为了通过显示前 10 个问题来确保问题列表页面继续工作。

清单 13-14 显示了对该组件的更改。

...
  static async fetchData(match, search, showError) {
    ...
    const query = `query issueList(
      ...
      issueList(
        ...
      ) {
        issues {
          id title status owner
          created effort due
        }
      }
      ...
  }
...

  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    ...
  }
...

  async loadData() {
    ...
    if (data) {
      this.setState({
        issues: data.issueList.issues,
        ...
      });
    }
  }
...

Listing 13-14ui/src/IssueList.jsx: Changes to Account for API Change

API 的变化可以在操场上测试。您可以使用以下查询来测试页面参数:

query {
  issueList(page: 4) {
     issues { id title }
     pages
  }
}

这将返回 10 期和总页数。如果您有 102 期(最初的两期来自init.mongo.js,另外 100 期来自generate_data.mongo.js),则页数应该返回为 11,如下所示:

{
  "data": {
    "issueList": {
      "issues": [
        {
          "id": 31,
          "title": "Lorem ipsum dolor sit amet, 28"
        },
        ...
        {
          "id": 40,
          "title": "Lorem ipsum dolor sit amet, 37"
        }

      ],
      "pages": 11
    }
  }
}

此外,您可以测试问题列表页面,以确保 API 更改没有破坏任何东西,只是您现在应该只看到第一页(即 10 个问题),而不是所有 100 个左右的问题。

UI 页面

现在让我们使用我们在上一节中编写的新 API 来显示一个页面栏。

React-Bootstrap 支持分页表示,但这是一个纯粹的表示组件。为了计算要显示的页面,特别是如果有页面的页面(让我们称之为部分),要完成的计算不是任何开箱即用组件的一部分。根据 https://react-bootstrap.github.io/components/pagination/#pagination 的文档,以前的版本支持这样一个组件,它现在作为@react-bootstrap/pagination在一个单独的存储库中。但是我没有选择这个库,原因如下:

  • 国家预防机制网页说它没有得到积极维护。

  • 产生的页面项目是按钮而不是链接,这使得搜索引擎机器人很难索引它们。我们更喜欢的是能够与 React 路由配合使用的<Link>或同等产品。

因此,让我们创建自己的极简分页栏,以五页为单位显示页面。要进入下一个或上一个部分,让我们使用该栏两端的><指示器。这所需的数学很简单,足以证明可以做什么。

首先,让我们修改IssueList组件中的数据获取器,以包括查询中的总页数,并将其保存在状态中。

...
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    ...

    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;
    vars.page = page;

    const query = `query issueList(
      ...
      $page: Int
    ) {
      issueList(
        ...
        page: $page
      ) {
        issues {
          ...
        }
        pages
      }
      ...
    }`;
  }
...

现在,data.issueList.issues将有一个问题列表,data.issueList.pages将有这个问题的总页数。让我们使用所有这些来设置状态变量,这些变量将方便地呈现分页栏。我们需要在构造函数和初始化或修改状态的loadData()方法中这样做。

...
  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    const initialData = store.initialData || { issueList: {} };
    const {
      issueList: { issues, pages }, issue: selectedIssue,
    } = initialData;
    ...
    this.state = {
      ...
      pages,
    };
    ...
  }
...
  async loadData() {
    ...
    if (data) {
      this.setState({
        ...
        pages: data.issueList.pages,
      });
    }
  }
...

现在,在render()函数中,我们可以开始布局分页栏。但是,除了使用一个LinkContainer来创建实际的链接之外,bar 中的每个链接还需要对当前活动的过滤器进行编码。为了缓解这个问题,让我们在这个文件中创建一个名为PageLink的新组件。这将根据传递的搜索params、要链接到的页码和当前页面,用链接包装作为显示对象传递的任何内容,以确定该链接是否需要突出显示为活动的。我们将使用页码 0 来表示不可用的页面。

...
function PageLink({
  params, page, activePage, children,
}) {
  params.set('page', page);
  if (page === 0) return React.cloneElement(children, { disabled: true });
  return (
    <LinkContainer
      isActive={() => page === activePage}
      to={{ search: `?${params.toString()}` }}
    >
      {children}
    </LinkContainer>
  );
}
...

现在,在render()函数中,我们可以生成一系列页面链接。我们将使用的实际链接组件是 React-Bootstrap 的Pagination.Item组件。我不会解释下面使用的数学细节,但总体逻辑是页面被分成每个SECTION_SIZE页面的部分,我们需要做的就是显示该部分中的页面。<>指示器将移动到上一节和下一节,这只是该节起始页之前和之后的SECTION_SIZE页。如果 previous 和 next 不可用,小于第一页或大于最后一页,我们将指定 0 作为要导航到的页面,这样它将被禁用。

...
    const params = new URLSearchParams(search);
    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;

    const startPage = Math.floor((page - 1) / SECTION_SIZE) * SECTION_SIZE + 1;
    const endPage = startPage + SECTION_SIZE - 1;
    const prevSection = startPage === 1 ? 0 : startPage - SECTION_SIZE;
    const nextSection = endPage >= pages ? 0 : startPage + SECTION_SIZE;

    const items = [];
    for (let i = startPage; i <= Math.min(endPage, pages); i += 1) {
      params.set('page', i);
      items.push((
        <PageLink key={i} params={params} activePage={page} page={i}>
          <Pagination.Item>{i}</Pagination.Item>
        </PageLink>
      ));
    }
...

最后,让我们在返回的 JSX 中显示这组项目,作为分页栏的一部分。为了启动这个工具条,我们使用了Pagination组件,并将每个可点击的链接显示为这个组件中的一个Pagination.Item组件,由一个PageLink包装。清单 13-15 显示了完整的更改,包括在IssueList组件中显示分页的最后一个更改。

...

import { Panel, Pagination } from 'react-bootstrap';

import { LinkContainer } from 'react-router-bootstrap';

...

const SECTION_SIZE = 5;

function PageLink({

  params, page, activePage, children,

}) {

  params.set('page', page);
  if (page === 0) return React.cloneElement(children, { disabled: true });
  return (
    <LinkContainer
      isActive={() => page === activePage}
      to={{ search: `?${params.toString()}` }}
    >
      {children}
    </LinkContainer>
  );

}

class IssueList extends React.Component {
  static async fetchData(match, search, showError) {
    const params = new URLSearchParams(search);
    ...

    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;
    vars.page = page;

    const query = `query issueList(
      ...
      $page: Int
    ) {
      issueList(
        ...
        page: $page
      ) {
        issues {
          ...
        }
        pages
      }
      ...
    }`;
  }

  constructor() {
    super();
    const issues = store.initialData
      ? store.initialData.issueList.issues : null;
    const selectedIssue = store.initialData
      ? store.initialData.issue
      : null;
    const initialData = store.initialData || { issueList: {} };
    const {
      issueList: { issues, pages }, issue: selectedIssue,
    } = initialData;
    ...
    this.state = {
      ...
      pages,
    };
    ...
  }
...
  async loadData() {
    ...
    if (data) {
      this.setState({
        ...
        pages: data.issueList.pages,
      });
    }
  }
...

  render() {
    ...

    const { selectedIssue, pages } = this.state;
    const { location: { search } } = this.props;

    const params = new URLSearchParams(search);
    let page = parseInt(params.get('page'), 10);
    if (Number.isNaN(page)) page = 1;

    const startPage = Math.floor((page - 1) / SECTION_SIZE) * SECTION_SIZE + 1;
    const endPage = startPage + SECTION_SIZE - 1;
    const prevSection = startPage === 1 ? 0 : startPage - SECTION_SIZE;
    const nextSection = endPage >= pages ? 0 : startPage + SECTION_SIZE;

    const items = [];
    for (let i = startPage; i <= Math.min(endPage, pages); i += 1) {
      params.set('page', i);
      items.push((
        <PageLink key={i} params={params} activePage={page} page={i}>
          <Pagination.Item>{i}</Pagination.Item>
        </PageLink>
      ));
    }

    return (
      <React.Fragment>
        ...
        <Pagination>
          <PageLink params={params} page={prevSection}>
            <Pagination.Item>{'<'}</Pagination.Item>
          </PageLink>
          {items}
          <PageLink params={params} page={nextSection}>
            <Pagination.Item>{'>'}</Pagination.Item>
          </PageLink>
        </Pagination>
      </React.Fragment>
    );
  }

...

Listing 13-15ui/src/IssueList.jsx: Changes for Pagination UI

如果您现在测试应用,您会发现主页重定向到完整的问题列表,该列表有 11 页,分为三个部分,每个部分 5 页。<>链接将分别将您带到上一个和下一个部分,如果由于达到部分边界而导致操作不可用,它们将显示为禁用。带有分页栏的问题列表页面截图如图 13-2 所示。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig2_HTML.jpg

图 13-2

第 8 页上带有分页栏的问题列表屏幕

但是还有一个小问题:分页,尤其是活动按钮,有一个 z 索引为 3 的样式。这是由 Bootstrap 设置的。这本身没问题,但是当显示 Toast 消息时,分页按钮隐藏了消息。为了克服这个问题,让我们将 Toast 消息的 z-index 设置得更高一些,这样它总是出现在顶部。清单 13-16 中显示了Toast.jsx的变更。

...
      <Collapse in={showing}>
        <div style={{
          position: 'fixed', bottom: 20, left: 20, zIndex: 10,
        }}
        >
...

Listing 13-16ui/src/Toast.jsx: Set the Toast’s Z-Index So It Always Shows on Top

可以基于当前页面创建更直观、更有创意的分页,这也让用户可以转到页面的末尾或开头。但是这应该已经为您提供了如何在 MERN 堆栈中实现分页的基本构件。

分页性能

使用同一个游标来获取计数的方法对于小数据集来说是可以的,但是不能用于大数据集。知道页数的分页的问题是,它需要过滤集中的文档总数。

事实是,在任何数据库中,计算匹配的数量都是很昂贵的。唯一的方法是应用过滤器并访问每个文档来检查它是否与过滤器匹配。当然,除非您对每个可能的过滤器组合都有索引,这意味着要么限制您希望允许用户使用的过滤器类型,要么花费巨大的存储容量来索引所有组合。

我发现,实际上,当结果可能非常大时,显示精确的页数或匹配记录数没有多大用处。如果它确实有数百页长,那么用户很可能不想直接跳到第 97 页,甚至最后一页。在这种情况下,建议只显示上一个和下一个链接,而不是查询每个请求的总数。React-Bootstrap 的分页组件将很好地适用于这种方法。

如果用户(或搜索引擎机器人)不太可能超出最初的几个页面,这种方法肯定会奏效。但是碰巧的是,即使是一个skip()操作也必须遍历所有被跳过的文档,才能到达被显示页面的开头。例如,如果有一百万个文档,并且用户(或搜索引擎机器人)将一直遍历到最后一页,这将意味着最后一页将在返回对应于最后一页的列表之前检索所有一百万个文档。

使用这些大型集合的理想策略是在 API 的返回中返回一个值,以索引字段值的形式指示下一页的开始位置。在问题跟踪器应用的情况下,ID 字段非常适合。使用这种策略,你不会使用skip()操作;相反,您使用 ID 作为过滤器,使用$gte操作符。由于 ID 字段被索引,数据库就不必跳过这么多文档来得到这个 ID;它将直接到达文档并从那里遍历以获取一页文档。在这些情况下,禁用“上一页”和“下一页”按钮变得很重要,超出了本书的范围。

练习:分页用户界面

  1. 当前活动页面必须从search字符串计算,一次在fetchData()方法中,另一次在render()方法中。在这种情况下,这可能是一个简单的操作,但在这可能需要大量代码和/或计算量很大的情况下,您是否应该考虑在状态中保存这个值?有哪些利弊?提示:在 https://reactjs.org/docs/thinking-in-react.html#step-3-identify-the-minimal-but-complete-representation-of-ui-state 查阅“React 中思考”页面。

本章末尾有答案。

撤消删除 API

我们要实现的下一个特性是删除操作的撤销操作。破坏性操作(如删除)的旧惯例是要求用户确认。但是如果我们不要求确认,可用性就会增强,因为用户很少会对“你确定吗?”回答“不”问题。如果用户错误地删除了问题,提供撤销功能会更好。

在下一节中,我们将实现撤销功能的用户界面来删除问题。首先,在本节中,我们将实现为此所需的 API,一个恢复已删除问题的 API。我们需要做的第一个改变是在模式中。为此,我们将添加一个新的突变,并将其命名为issueRestore。清单 13-17 显示了对模式的更改。

...
type Mutation {
  ...
  issueRestore(id: Int!): Boolean!
}
...

Listing 13-17api/schema.graphql: Changes to the Restore API

接下来,API 的实际实现有点类似于 Delete API 本身。不同之处在于,restore API 不是从issues集合转移到deleted_issues集合,而是以相反的方向转移一个问题:从deleted_issues集合转移到issues集合。让我们从remove()函数中复制代码,并交换这两个集合名。

清单 13-18 中列出了增加restore功能的变更。

...
async function remove(_, { id }) {
  ...
}

async function restore(_, { id }) {

  const db = getDb();
  const issue = await db.collection('deleted_issues').findOne({ id });
  if (!issue) return false;
  issue.deleted = new Date();

  let result = await db.collection('issues').insertOne(issue);
  if (result.insertedId) {
    result = await db.collection('deleted_issues').removeOne({ id });
    return result.deletedCount === 1;
  }
  return false;

}

...

module.exports = {
  ...
  restore,
  counts,
};
...

Listing 13-18api/issue.js: New Restore API Implementation

最后,我们必须将解析器绑定到 API 处理程序中的 API 端点。这一变化如清单 13-19 所示。

...
const resolvers = {
  ...
  Mutation: {
    ...
    issueRestore: issue.restore,
  },
  ...
};
...

Listing 13-19api/api_handler.js: Changes to Restore API

您可以使用操场测试新的 API。您可以使用“问题列表”页面中的“删除”按钮删除问题。然后,在 Playground 中,您可以执行以下变化,用您刚刚删除的问题的 ID 替换 ID:

mutation {
  issueRestore(id: 6)
}

它应该会返回一个成功值,如果您刷新浏览器,您应该会看到已删除的问题已被恢复。

撤消删除用户界面

启动撤消删除操作的最佳位置是显示问题已被删除的 Toast 消息中。在指示删除操作成功的 Toast 消息中,让我们包含一个按钮,单击它可以启动撤销。这需要在IssueList组件中完成。

然后,当点击按钮时,我们需要调用 Restore API。最好的方法是使用IssueList类中的一个方法来恢复一个问题,这个方法接受要恢复的问题的 ID。撤销按钮现在可以将其onClick属性设置为这个方法。对此的更改如清单 13-20 所示。

...
import { Panel, Pagination, Button } from 'react-bootstrap';
...

async deleteIssue(index) {
    ...
      showSuccess('Deleted issue ${id} successfully.');
      const undoMessage = (
        <span>
          {`Deleted issue ${id} successfully.`}
          <Button bsStyle="link" onClick={() => this.restoreIssue(id)}>
            UNDO
          </Button>
        </span>
      );
      showSuccess(undoMessage);
    ...
  }

  async restoreIssue(id) {
    const query = `mutation issueRestore($id: Int!) {
      issueRestore(id: $id)
    }`;
    const { showSuccess, showError } = this.props;
    const data = await graphQLFetch(query, { id }, showError);
    if (data) {
      showSuccess(`Issue ${id} restored successfully.`);
      this.loadData();
    }
  }
...

Listing 13-20ui/src/IssueList.jsx: Changes for Including an Undo Button

现在,当您单击问题列表中任何一行上的删除按钮时,Toast 消息将包含一个撤销按钮(实际上是一个链接)。点击该按钮将会恢复已删除的问题。图 13-3 显示了问题列表页面的屏幕截图,其中包含带有撤销链接的 Toast 消息。您应该能够单击撤销链接,并看到已删除的问题又回到了问题列表中。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig3_HTML.jpg

图 13-3

带有撤消链接的删除成功提示

文本索引 API

大多数应用中的搜索栏让你只需输入一些单词就能找到文档。我们不会像搜索过滤器那样实现它,而是作为一个自动完成功能来实现,它会找到所有与输入的单词匹配的问题,并让用户选择其中一个来直接查看。我们将在导航栏中添加这个搜索,因为用户应该能够跳转到一个特定的问题,无论他们正在查看哪个页面。

假设问题的数量很大,如果我们对所有问题都应用像正则表达式这样的过滤标准,效果不会很好。这是因为要应用正则表达式,MongoDB 必须扫描所有文档并应用正则表达式来查看它是否与搜索词匹配。

另一方面,MongoDB 的文本索引可以让您快速找到包含特定术语的所有文档。文本索引收集所有文档中的所有术语(单词),并创建一个查找表,给定一个术语(单词),返回包含该术语(单词)的所有文档。您可以通过以下 MongoDB shell 命令使用标题中的所有单词创建这样一个索引:

> db.issues.createIndex({ title: "text" })

现在,如果您查找标题中任何术语的问题,它将返回匹配的文档。使用文本索引的语法如下:

> db.issues.find({ $text: {$search: "click" } })

这将返回一个标题中包含单词click的文档。但这可能还不够,我们可能还需要在描述中搜索相同术语的能力。因此,让我们通过在索引中包含描述文本来重新创建索引。要删除创建的索引,让我们首先确定当前存在哪些索引。

> db.issues.getIndexes()

您应该找到一个属性设置为title_text的索引。我们需要删除该索引,并重新创建包含描述字段的索引。

> db.issues.dropIndex('title_text')
> db.issues.createIndex({ title: "text", description: "text" })

如果您现在执行相同的find()查询来查找包含单词click的文档,您应该会发现它返回两个问题,第一个问题是因为标题中有术语“clicking”。第二个也将被返回,因为描述中有“点击”一词。请注意,这不是模式搜索,例如,搜索术语“clic”不会匹配任何文档,即使它与文档中的文本部分匹配。此外,你会发现常见的词(称为停用词),如“在”、“当”等。没有索引,搜索这些将导致没有匹配。

让我们将这个索引保存在init.mongo.js中,这样下次数据库初始化时,就会创建这个索引。变更如清单 13-21 所示。

...
db.issues.createIndex({ created: 1 });

db.issues.createIndex({ title: 'text', description: 'text' });

...

Listing 13-21api/scripts/init.mongo.js: Addition of Text index

下一个变化是在 GraphQL 模式中。让我们为搜索字符串再添加一个过滤器选项。清单 13-22 中显示了这些变化。

...
type Query {
  ...
  issueList(
    ...
    search: String
    page: Int = 1
  ): IssueListWithPages
  ...
}
...

Listing 13-22api/schema.graphql: Addition of Search in Filter

现在,让我们更改issue.js中的list解析器,使用新的参数来搜索文档。所有需要做的就是添加搜索字符串作为一个额外的过滤器,如果它存在的话。变更如清单 13-23 所示。

...
async function list(_, {
  status, effortMin, effortMax, search, page,
}) {
  ...
  if (search) filter.$text = { $search: search };

  const cursor = db.collection('issues').find(filter)
  ...
}

...

Listing 13-23api/issue.js: Changes to Add a Search Filter

为了测试这些变化,您可以使用操场和我们在 mongo shell 中使用的术语。以下是一个查询示例:

query {
  issueList(search: "click") {
    issues { id title description }
    pages
  }
}

这个查询应该在结果中返回两个文档,与在 mongo shell 中执行时相同。在下一节中,我们将添加 UI 来使用这个 API,并使用搜索栏搜索文档。请注意,尽管 API 允许将其他过滤器值与搜索查询相结合,但 UI 将只使用其中的一个。

搜索栏

让我们使用一个流行的控件,而不是自己实现搜索组件。我选择了 React Select ( https://react-select.com/home ),因为它非常适合这个目的:在用户键入一个单词后,要求异步获取结果并在下拉列表中显示它们,可以选择其中一个。这个组件的Async变体让我们很容易实现这个效果。

让我们首先安装包含组件的包。

$ cd ui
$ npm install react-select@2

让我们在 UI source 目录中创建一个新的组件,它将显示一个 React Select,并使用 List API 中的新搜索过滤器实现获取文档所需的方法。React Select 需要两个回调来显示选项:loadOptionsfilterOptions。第一种是异步方法,需要返回一组选项。每个选项都是一个具有属性labelvalue的对象,label是用户看到的内容,value是唯一的标识符。让我们选择问题 ID 作为value,对于label,让我们将 ID 和问题标题结合起来。

让我们首先实现loadOptions()方法,并使用graphQLFetch()函数获取与搜索词匹配的问题列表。让我们将 API 限制为只对长度超过两个字母的单词触发。

...
  async loadOptions(term) {
    if (term.length < 3) return [];
    const query = `query issueList($search: String) {
      issueList(search: $search) {
        issues {id title}
      }
    }`;

    const data = await graphQLFetch(query, { search: term });
    return data.issueList.issues.map(issue => ({
      label: `#${issue.id}: ${issue.title}`, value: issue.id,
    }));
  }
...

下一个回调函数filterOption将为每个返回的选项调用,以确定是否在下拉列表中显示该选项。这在其他情况下很有用,但是由于使用loadOptions()检索的选项已经是匹配的了,我们可以在回调中返回true。如果我们不提供这个函数,React Select 将应用它自己的匹配逻辑,这不是我们想要的。因此,在render()方法中,我们可以用这两个回调函数返回 React Select,如下所示:

...
import SelectAsync from 'react-select/lib/Async';
...
  render() {
    return (
      <SelectAsync
        loadOptions={this.loadOptions}
        filterOption={() => true}
      />
    );
  }
...

下一步是当用户选择显示的问题之一时采取行动。React Select 提供了一个onChange属性,这是一个当用户选择一个项目时调用的回调函数,所选项目的值作为参数。当发生这种情况时,让我们显示该问题的编辑页面,因为该页面完全显示了一个问题。为了能够做到这一点,我们需要 React 路由的历史可用,所以我们最终需要使用withRouter来导出这个组件。

...
  onChangeSelection({ value }) {
    const { history } = this.props;
    history.push(`/edit/${value}`);
  }
...
  render() {
    return (
      <SelectAsync
        ...
        onChange={this.onChangeSelection}
      />
    );
  }
...

我们将向 React Select 控件添加一些更有用的属性:

  • 如果在同一个页面上使用了多个 React 选择,那么instanceId对于 React Select 来说是很有用的,可以用来识别控件。我们把这个设为search-select。如果没有这个 ID,您会发现 React Select 会自动生成这些 ID,并且会在控制台中显示一个错误,指出服务器呈现的 ID 和客户端呈现的 ID 不匹配。

  • 我们不需要可以用来下拉预加载选项列表的下拉指示器(控件右侧的向下箭头)。因为没有预装选项,所以不需要这样做。对此没有直接的选项,相反,React Select 允许使用components属性定制 React Select 中的每个组件。我们将把DropdownIndicator组件设置为 null,表示不需要显示任何内容。

  • React Select 组件设计用于选择,并在选择后显示选择。我们真的不需要显示选中的项目,所以我们只需将value属性设置为空字符串来实现这一点。

最后,如果在调用 API 时出现错误,最好显示一个错误。所以,让我们用withToast包装组件,并将showError函数提供给 GraphQL fetch函数。通过所有这些改变,新组件的完整代码如清单 13-24 所示。

import React from 'react';
import SelectAsync from 'react-select/lib/Async'; // eslint-disable-line
import { withRouter } from 'react-router-dom';

import graphQLFetch from './graphQLFetch.js';
import withToast from './withToast.jsx';

class Search extends React.Component {
  constructor(props) {
    super(props);

    this.onChangeSelection = this.onChangeSelection.bind(this);
    this.loadOptions = this.loadOptions.bind(this);
  }

  onChangeSelection({ value }) {
    const { history } = this.props;
    history.push('/edit/${value}');
  }

  async loadOptions(term) {
    if (term.length < 3) return [];
    const query = `query issueList($search: String) {
      issueList(search: $search) {
        issues {id title}
      }
    }`;

    const { showError } = this.props;
    const data = await graphQLFetch(query, { search: term }, showError);
    return data.issueList.issues.map(issue => ({
      label: `#${issue.id}: ${issue.title}`, value: issue.id,
    }));
  }

  render() {
    return (
      <SelectAsync
        instanceId="search-select"
        value=""
        loadOptions={this.loadOptions}
        filterOption={() => true}
        onChange={this.onChangeSelection}
        components={{ DropdownIndicator: null }}
      />
    );
  }
}

export default withRouter(withToast(Search));

Listing 13-24ui/src/Search.jsx: New File and Component for the Search Bar

为了将它集成到导航栏中,我们可以在Page.jsx中的两个<Nav>之间添加组件。

...

import Search from './Search.jsx';

...
      <Nav>
        ...
      </Nav>
      <Search />
      <Nav pullRight>
        ...
      </Nav>
...

虽然在功能上这是可行的,但是你会发现标题中搜索栏的对齐方式不正确;搜索控件占据标题的整个宽度,并将右侧Nav推到下一行。为了避免这种情况,我们需要用一个<div>或者限制宽度的东西来包装搜索组件。让我们使用 React-Bootstrap 的Col组件,而不是固定的宽度,它将根据屏幕大小灵活地改变其宽度。此外,正如 React-Bootstrap 的 Navbars 文档中的 https://react-bootstrap.github.io/components/navbar/?no-cache=1#navbars-form 所建议的,为了让控件在Nav中正确对齐,我们需要将它包含在一个Navbar.Form组件中。

清单 13-25 显示了导航栏中的一整套更改。

...
import {
  ...
  Grid, Col,
} from 'react-bootstrap';
...
import IssueAddNavItem from './IssueAddNavItem.jsx';

import Search from './Search.jsx';

...

function NavBar() {
      ...
      <Nav>
        ...
      </Nav>
      <Col sm={5}>
        <Navbar.Form>
          <Search />
        </Navbar.Form>
      </Col>
      <Nav pullRight>
        ...
      </Nav>
}
...

Listing 13-25ui/src/Page.jsx: Changes to Include the Search Control in the Navigation Bar

此时,应用和搜索控件都将工作。你应该能够在条款中输入,并看到一个可以选择的匹配问题下拉列表。例如,如果您键入单词“点击”,两个问题将显示在下拉列表中。选择其中任何一个,页面应该切换到编辑选择的问题。带有下拉菜单的搜索控件截图如图 13-4 所示。

img/426054_2_En_13_Chapter/426054_2_En_13_Fig4_HTML.jpg

图 13-4

问题列表页面中的搜索控件

摘要

在这一章中,我们探讨了各种技术和概念,您可以用它们来实现使应用更加可用的通用特性。

您首先了解了可重用代码的常见 React 模式:高阶组件(hoc)。然后,您看到了如何使用 MongoDB 的聚合框架来汇总甚至扩展从集合中获取的数据。您了解了如何实现通用特性,如何使用第三方组件撤销删除和添加分页,以及如何使用搜索控件在 MongoDB 中基于文本索引查找问题。

在下一章中,我们将讨论如何为问题跟踪器应用实现认证和授权。我们将使用 Google Sign-in 让用户登录到问题跟踪应用。虽然大多数应用将继续对每个人可用,包括那些没有登录的人,但我们将使它只有登录的用户才能对数据进行任何修改。

练习答案

练习:发料数量 API

  1. GraphQL 模式不允许变量模式,也不允许包含未在模式本身中命名的字段的对象。如果我们原样返回对象stats,这将相当于每个对象都有一个未预定义的键(所有者字段的值)。我们无法定义这样一个模式。GraphQL 就是要能够指定哪些键对调用者来说是重要的,而使用一个可变的键将使这变得不可能。

练习:报告页面

  1. 添加行总计是对render()方法的一个小改动。在变量statRows的定义中,你可以在<td> s 的statuses.map生成集之后再加一个<td>,在这个<td>中,你可以这样加那一行的所有计数:

    ...
       <td>{statuses.reduce((total, status) => total + counts[status], 0)}</td>
    ...
    
    
  2. 列总计没有那么简单,您必须减少整个stats数组,并在 reducer 函数中返回一个对象,其中包含每个状态的总计。

练习:分页用户界面

  1. 正如文档所建议的,将计算值存储在状态中并不是一个好主意。取而代之的是,推荐在需要的时候从原始的事实来源计算它们。在计算变得昂贵的情况下,可以使用诸如memorize这样的实用程序来缓存计算出的值。

十四、认证

大多数应用需要识别和验证用户。我们将集成一个社交登录,而不是创建一个自定义的注册和验证机制。我们将只实现一个(谷歌网站登录)。这将为其他集成提供一个很好的例子,因为它使用了 OAuth2 机制,大多数其他认证集成也使用这种机制。

我们将使用户无需登录即可查看所有信息,但为了进行任何更改,他们必须登录。我们将使用一个模态对话框,让用户从应用的任何地方登录 Google。一旦登录,应用将让用户停留在相同的页面,以便他们可以在登录后执行编辑功能。

在所有这些中,我们不会忽略服务器渲染。我们将确保整个页面可以在 UI 服务器上呈现,即使它们是经过身份验证的页面。

登录用户界面

让我们首先为登录用户构建必要的用户界面。虽然我们不会在这一节中做任何认证,但是我们将确保 UI 方面的所有基础工作都准备就绪,以便在后面的部分中添加它。

在导航栏的右侧,有一个标签为“登录”的项目。单击这个按钮,让我们显示一个模态对话框,让用户使用一个标记为“登录”的按钮进行登录。对于问题跟踪应用,我们只有一个登录按钮,但这种方法允许你添加多个登录选项,如脸书、GitHub 等。成功登录后,让我们显示用户名而不是登录菜单项,并显示一个下拉菜单让用户退出。

为了实现这一切,让我们创建一个名为SignInNavItem的类似于IssueAddNavItem的新组件,它可以放在导航栏中。该组件的完整代码如清单 14-1 所示,我将在这里讨论几个重要的片段。

状态变量和一些显示模态的方法与组件IssueAddNavItem非常相似:showModalhideModal方法使用一个名为showing的变量来控制模态的可见状态。此外,让我们用一个名为user的状态变量对象来保存登录状态(signedIn)和用户名(givenName)。如果状态变量表明用户已经登录,那么render()方法只返回一个下拉菜单和一个菜单项来注销用户。

...
    if (user.signedIn) {
      return (
        <NavDropdown title={user.givenName} id="user">
          <MenuItem onClick={this.signOut}>Sign out</MenuItem>
        </NavDropdown>
      );
...

如果用户没有登录,render()方法返回用于登录的菜单项以及显示登录按钮的模态对话框。

...
        <NavItem onClick={this.showModal}>
          Sign in
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal} bsSize="sm">
          ...
          <Modal.Body>
            <Button block bsStyle="primary" onClick={this.signIn}>
              Sign In
            </Button>
          </Modal.Body>
          ...
        </Modal>
...

单击模式中的 Sign In 按钮,我们要做的就是将用户名设置为“User1”,将登录状态设置为true。注销时,我们将撤销此操作。对于这些,我们在组件中有处理程序signInsignOut。最后,我们需要添加一个bind(this)。清单 14-1 显示了这个组件的最终源代码。

import React from 'react';
import {
  NavItem, Modal, Button, NavDropdown, MenuItem,
} from 'react-bootstrap';

export default class SigninNavItem extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      showing: false,
      user: { signedIn: false, givenName: '' },
    };
    this.showModal = this.showModal.bind(this);
    this.hideModal = this.hideModal.bind(this);
    this.signOut = this.signOut.bind(this);
    this.signIn = this.signIn.bind(this);
  }

  signIn() {
    this.hideModal();
    this.setState({ user: { signedIn: true, givenName: 'User1' } });
  }

  signOut() {
    this.setState({ user: { signedIn: false, givenName: '' } });
  }

  showModal() {
    this.setState({ showing: true });

  }

  hideModal() {
    this.setState({ showing: false });
  }

  render() {
    const { user } = this.state;
    if (user.signedIn) {
      return (
        <NavDropdown title={user.givenName} id="user">
          <MenuItem onClick={this.signOut}>Sign out</MenuItem>
        </NavDropdown>
      );
    }

    const { showing } = this.state;
    return (
      <>
        <NavItem onClick={this.showModal}>
          Sign in
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal} bsSize="sm">
          <Modal.Header closeButton>
            <Modal.Title>Sign in</Modal.Title>
          </Modal.Header>
          <Modal.Body>
            <Button block bsStyle="primary" onClick={this.signIn}>
              Sign In
            </Button>
          </Modal.Body>
          <Modal.Footer>
            <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>

          </Modal.Footer>
        </Modal>
      </>
    );
  }
}

Listing 14-1ui/src/SignInNavItem.jsx: New Component for Signing In

让我们更改导航栏,在IssueAddNavItem组件之后插入这个新的导航项目。清单 14-2 中显示了对Page.jsx的更改。

...

import SignInNavItem from './SignInNavItem.jsx';

...
      <Nav pullRight>
        <IssueAddNavItem />
        <SignInNavItem />
        ...
      </Nav>
...

Listing  14-2ui/src/Page.jsx: Inclusion of the Sign In Menu in the Navigation Bar

有了这些改变,你会发现点击登录会显示一个带有单个按钮的模态对话框,如图 14-1 中的截图所示。单击该按钮会用标题为“用户 1”的下拉菜单替换菜单项。在单击“注销”时,UI 应该返回到初始状态,并显示菜单项“登录”。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig1_HTML.jpg

图 14-1

登录模式对话框

Google 登录

现在我们已经准备好了大部分的 UI,让我们用一个使用 Google 登录的按钮来代替登录按钮。登录后,我们将使用 Google 检索并显示用户名,而不是硬编码的“User1”。

https://developers.google.com/identity/sign-in/web/sign-in 的“指南”部分列出了与 Google Sign-In 集成的各种选项。作为准备措施,我们需要一个控制台项目和一个客户机 ID 来标识应用。按照指南中的说明创建您自己的项目和客户 ID。至于源 URI,使用http://localhost:8000,这是到目前为止问题跟踪器应用所在的位置。完成后,将客户端 ID 保存在 UI 服务器目录下的.env文件中;这将需要在初始化谷歌图书馆的用户界面代码。清单 14-3 中显示了sample.env中的一个示例条目。你必须使用你自己的客户 ID 来代替YOUR_CLIENT_ID

注意

指南中的按钮会在 API 控制台中自动创建一个名为 My Project 的项目。如果您想要更好地控制将要使用的名称,可以在 API 控制台的 https://console.cloud.google.com/apis/credentials 创建一个 OAuth2 客户端 ID。

...
# ENABLE_HMR=true

GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com

...

Listing 14-3ui/src/sample.env: Configuration for the Google Client ID

在指南中列出的推荐集成方法中,库本身呈现按钮并处理其启用和登录状态。不幸的是,这并不能很好地与 React 一起工作,因为 Google 库需要一个按钮的句柄,并且需要它是永久的。如果您试图在 React-Bootstrap 模式中使用该按钮,Google library 会抛出错误。这是因为,在关闭模式时,按钮被销毁,当模式再次打开时,按钮被重新创建。图书馆显然不喜欢这样。因此,我们必须按照名为“自定义登录按钮”的指南来显示按钮。

让我们从包括谷歌图书馆开始。我们将在template.js中做这件事,以及 UI 需要的所有其他脚本。我们将使用的库是允许我们定制登录按钮的库,它在“构建定制图形”下的代码清单中指定。清单 14-4 中显示了对此的更改。

...
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <script src="https://apis.google.com/js/api:client.js"></script>

  <style>
...

Listing 14-4ui/server/template.js: Changes for Including Google Library

初始化库时,我们需要使用 Google 客户端 ID。为了能够在 UI 代码中访问它,我们需要像传递配置变量UI_API_ENDPOINT一样传递它,使用对/env.js的请求。让我们称这个新的配置变量为GOOGLE_CLIENT_ID。清单 14-5 显示了对 UI 服务器的更改,允许 UI 代码访问这个新变量。

...
app.get('/env.js', (req, res) => {
  const env = {
    UI_API_ENDPOINT: process.env.UI_API_ENDPOINT,
    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
  };
  res.send(`window.ENV = ${JSON.stringify(env)}`);
});
...

Listing 14-5ui/server/uiserver.js: Changes to Send Google Client ID to the UI

现在我们已经准备好使用这个库,并在SignInNavItem内实现 Google 登录。这个组件的完整变更可以在清单 14-6 中找到,我将在下面讨论其中的一些片段。

让我们从初始化库开始。这可以在组件SignInNavItemcomponentDidMount内完成。这个组件将只被调用一次,因为它在标题中并且总是可见的。这方面的代码摘自本指南的“构建自定义图形”一节。我们将在成功初始化库时设置一个状态变量disabled(初始化为true)。只有在成功初始化之后,我们才会使用这个状态来启用登录按钮。

...
  componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
  }
...

在模态对话框中,让我们用一个遵循 Google 品牌指南的按钮来替换纯文本按钮(这在集成指南中有描述)。这对于生产中的任何应用都是必须的。不过,为了测试,你可以只使用纯文本按钮。我把一个公开图片的 URL(https://developers.google.com/identiimg/btn_google_signin_light_normal_web.png)缩短为 https://goo.gl/4yjp6B 并使用了它

...
              <img src="https://goo.gl/4yjp6B" alt="Sign In" />
...

如果缺少客户机 ID(如果部署的.env或环境没有这个变量,这是可能的),让我们在单击 Sign In 菜单项时显示一条错误消息。否则,让我们继续显示模态对话框。为了能够使用 Toast 消息显示错误,我们需要在导出组件之前使用withToast来包装它,我们将很快添加这一功能。

...
  showModal() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    const { showError } = this.props;
    if (!clientId) {
      showError('Missing environment variable GOOGLE_CLIENT_ID');
      return;
    }
    this.setState({ showing: true });
  }
...

最后,在signIn处理程序中,让我们调用auth2.signin()方法。本指南中没有描述这种方法,但是您可以在“参考”一节中找到描述。成功登录后,我们将从登录结果获得的配置文件中设置用户名。另外,由于内部的await调用,signIn处理程序现在需要成为一个async函数。

...
      const auth2 = window.gapi.auth2.getAuthInstance();
      const googleUser = await auth2.signIn();
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
...

清单 14-6 显示了完整的变更集,包括错误处理和与 Toast 相关的变更。

...

import withToast from './withToast.jsx';

export default class SigninNavItem extends React.Component {
  constructor(props) {
    ...
    this.state = {
      showing: false,
      disabled: true,
      ...
    };
    ...
  }
...

  componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
  }

  async signIn() {
    this.hideModal();
    this.setState({ user: { signedIn: true, givenName: 'User1' } });
    const { showError } = this.props;
    try {
      const auth2 = window.gapi.auth2.getAuthInstance();
      const googleUser = await auth2.signIn();
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
    } catch (error) {
      showError(`Error authenticating with Google: ${error.error}`);
    }
  }
...

  showModal() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    const { showError } = this.props;
    if (!clientId) {
      showError('Missing environment variable GOOGLE_CLIENT_ID');
      return;
    }
    this.setState({ showing: true });

  }
...

  render() {
    ...
    const { showing, disabled } = this.state;
    ...
            <Button
              block
              disabled={disabled}
              bsStyle="primary"
              onClick={this.signIn}
            >
              <img src="https://goo.gl/4yjp6B" alt="Sign In" />
            </Button>
    ...
  }
...

export default withToast(SigninNavItem);

...

Listing 14-6ui/src/SignInNavItem.jsx: Changes for Google Sign-In

由于对.env文件的更改,UI 服务器需要使用npm run dev-all重新启动。一旦你这样做并点击登录菜单项,你会在模态对话框中找到谷歌按钮。点击它会弹出一个新窗口,由谷歌控制。这将允许您登录您自己的任何 Google 帐户。图 14-2 显示了带有 Google 登录按钮的模态对话框截图。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig2_HTML.jpg

图 14-2

使用 Google 按钮的登录模式对话框

一旦你登录了 Google,你会发现这个菜单项会被你的名字所取代,点击它,会出现一个注销菜单项,让你注销。其截图如图 14-3 所示。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig3_HTML.jpg

图 14-3

登录后的应用

练习:Google 登录

  1. 假设我们想在登录后显示用户的个人资料图片。你认为这能做到吗?怎么做?提示:在谷歌开发者网站 https://developers.google.com/identity/sign-in/web/people 查找获取个人资料信息的指南。

本章末尾有答案。

验证 Google 令牌

仅仅向谷歌认证是不够的;我们需要做一些认证工作。在本节中,我们将确保凭据在后端得到验证。我们还将从后端获取用户名,以验证我们只使用经过验证的身份验证信息。稍后,我们将为登录用户建立一个会话,并在浏览器刷新过程中保持它的持久性。

作为一种安全措施,需要在后端验证令牌。这是因为后端不能信任 UI 已经完成了身份验证,因为它是公共的,并且还可以响应任何 HTTP 请求,而不仅仅是来自问题跟踪器 UI 的请求。在 https://developers.google.com/identity/sign-in/web/backend-auth 的指南“向后端服务器认证”中描述了实现这一点的技术。本质上,客户端身份验证库在成功的身份验证时返回一个令牌,这可以在后端使用 Google 的 Node.js 身份验证库进行验证。

让我们在 API 服务器中创建一个名为auth.js的新文件来保存所有与认证相关的函数。另外,我们不要使用 GraphQL 来实现登录 API。一个原因是在 GraphQL 解析器中设置和访问 cookie 并不简单,我们将在后面的章节中使用 cookie 来维护会话。另一个原因是为了保持实现的灵活性,如果需要,可以使用第三方库,比如 Passport,它直接连接到 Express 而不是 GraphQL。

因此,在auth.js中,我们将实现一系列端点作为快速路由。我们将导出这些路线,稍后我们会将它们装载到主 Express 应用中。这个文件的完整代码如清单 14-7 所示,我将讨论其中的一些片段。

因为我们需要访问 POST 请求的主体,所以我们必须安装一个解析器来允许我们这样做并在路由中使用它。此外,我们需要按照谷歌登录指南中的建议安装谷歌认证库。

$ cd api
$ npm install body-parser@1
$ npm install google-auth-library@2

auth.js中,我们需要做的第一件事是构建一个要导出的路由。让我们也在其中安装body-parser中间件。我们将在端点中只接受 JSON 文档。为此,可以使用bodyParser.json(),通过req.body访问 JSON 文档。

...
const Router = require('express');
const bodyParser = require('body-parser');

const routes = new Router();

routes.use(bodyParser.json());
...

module.exports = { routes };
...

在本节中,我们将只实现一个路由,'/signin'。在这个路由实现中,我们将从请求体中检索提供的 Google 令牌,并使用 Google 身份验证库对其进行验证。

...
const { OAuth2Client } = require('google-auth-library');
...

routes.post('/signin', async (req, res) => {
  const googleToken = req.body.google_token;
  ...

  const client = new OAuth2Client();
  let payload;
  try {
    const ticket = await client.verifyIdToken({ idToken: googleToken });
    payload = ticket.getPayload();
  } catch (error) {
    res.status(403).send('Invalid credentials');
  }

  ...
});
...

一旦我们获得了基于经过验证的令牌的有效载荷,我们就可以从有效载荷中提取各种字段,比如姓名和电子邮件。让我们提取这些内容,并用一个包含这些内容的 JSON 对象以及一个指示登录成功的布尔值来响应。清单 14-7 中显示了auth.js的完整代码,包括响应。

const Router = require('express');
const bodyParser = require('body-parser');
const { OAuth2Client } = require('google-auth-library');

const routes = new Router();

routes.use(bodyParser.json());

routes.post('/signin', async (req, res) => {
  const googleToken = req.body.google_token;
  if (!googleToken) {
    res.status(400).send({ code: 400, message: 'Missing Token' });
    return;
  }

  const client = new OAuth2Client();
  let payload;
  try {
    const ticket = await client.verifyIdToken({ idToken: googleToken });
    payload = ticket.getPayload();
  } catch (error) {
    res.status(403).send('Invalid credentials');
  }

  const { given_name: givenName, name, email } = payload;
  const credentials = {
    signedIn: true, givenName, name, email,
  };
  res.json(credentials);
});

module.exports = { routes };

Listing 14-7api/auth.js: New File for Auth-Related Code and Routes

现在,要在主应用中使用这组新路线,我们需要在主应用中挂载这些路线。让我们在路径/auth处这样做,以将名称空间与/graphql分开。因此,要访问signin端点,要使用的完整路径将是/auth/signin。这个变化是在server.js中进行的,如清单 14-8 所示。

...

const auth = require('./auth.js');

const app = express();

app.use('/auth', auth.routes);

installHandler(app);
...

Listing 14-8api/server.js: Changes for Mounting Auth Routes

此时,可以测试 API,但是这样做不太方便。您必须在变量googleUserSignInNavItem中初始化之后添加一个断点。成功登录后,执行将在此断点处停止。现在,在 JavaScript 控制台中,您可以执行以下代码来提取令牌:

> googleUser.getAuthResponse().id_token;

这将打印出一个很长的令牌,你可以复制。如果您正在使用 bash,那么您可以使用这个令牌通过粘贴来初始化一个名为token的环境变量,例如:

$ token="eyJhbGciOiJSUzI1NiI...."

现在,您可以通过在 MacOS 和 Linux 中执行下面的curl命令来测试新的signin API:

$ curl http://localhost:3000/auth/signin -X POST \
  --data "{ \"google_token\": \"$token\" }" \
  -H "Content-Type: application/json"

在输出中,您应该可以看到概要文件的详细信息,例如:

{"signedIn":true,"givenName":"Vasan","name":"Vasan Subramanian","email":"vasan.XXXXX@gmail.com"}

现在,由于我们有了一个新的端点前缀/auth,我们将需要一个新的配置变量,以便 UI 可以向它发送请求。这需要从服务器传递到 UI,就像 API 端点配置变量UI_API_ENDPOINT一样。让我们称新的配置变量为UI_AUTH_ENDPOINT。另外,在代理配置的情况下,除了/graphql端点前缀之外,我们还需要代理这个新的端点前缀。这两个添加在uiserver.js中,如清单 14-9 所示。

...
if (apiProxyTarget) {
  app.use('/graphql', proxy({ target: apiProxyTarget }));
  app.use('/auth', proxy({ target: apiProxyTarget }));
}
...

if (!process.env.UI_AUTH_ENDPOINT) {

  process.env.UI_AUTH_ENDPOINT = 'http://localhost:3000/auth';

}

app.get('/env.js', (req, res) => {
  ...
    UI_API_ENDPOINT: process.env.UI_API_ENDPOINT,
    UI_AUTH_ENDPOINT: process.env.UI_AUTH_ENDPOINT,
    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
  ...
});
...

Listing 14-9ui/server/uiserver.js: Changes for New /Auth Endpoint Prefix

此更改将需要重启和刷新浏览器(因为对uiserver.js的更改不由 HMR 处理)。现在我们准备将 Google 令牌发送给新的 API。令牌本身可以通过调用googleUser.getAuthResponse().id_token获得,正如我们在手动提取令牌时看到的。然后,我们需要将这个令牌传递给signin API,收集其结果 JSON,并从那里使用givenName字段来设置状态变量givenName

这些变化都在组件SignInNavItem中,如清单 14-10 所示。

...
  async signIn() {
    ...
    let googleToken;
    try {
      ...
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
      googleToken = googleUser.getAuthResponse().id_token;
    } catch (error) {
    ...

    try {
      const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
      const response = await fetch(`${apiEndpoint}/signin`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ google_token: googleToken }),
      });
      const body = await response.text();
      const result = JSON.parse(body);
      const { signedIn, givenName } = result;

      this.setState({ user: { signedIn, givenName } });
    } catch (error) {
      showError(`Error signing into the app: ${error}`);
    }
  }
...

Listing 14-10ui/src/SignInNavItem: UI Changes for Verifying Google Token at the Back-End

现在,如果您尝试登录,假设您没有在代理模式下运行,您将得到以下错误。

Access to fetch at 'http://localhost:3000/auth/signin' from origin 'http://localhost:8000' has been blocked by CORS policy:

正如我们在第七章中所讨论的,所有的 GraphQL APIs 都成功执行了,因为 Apollo Server 支持 CORS。但是,这仅适用于端点前缀/graphql。对于新的端点前缀/auth,我们需要单独处理它。但这还不是全部。由于我们将在下一节设置一个 cookie,因此我们需要一个更复杂的配置来完成这项工作。

与其现在做所有这些,不如让我们切换到代理操作模式,因为在这种模式下工作更简单。完成所有与身份验证和授权相关的更改后,我们将切换回非代理模式,并在后面的小节中正确配置 CORS。

要切换到代理模式,您必须在您的.env文件中进行更改(或者手动设置环境变量)。修改后的sample.env文件如清单 14-11 所示,可以用来复制粘贴行。因为我们将在代理和非代理模式之间来回切换,所以最好将两种配置都放在手边,但是注释掉。

...
UI_SERVER_PORT=8000

UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

# API_PROXY_TARGET=http://localhost:3000

# ENABLE_HMR=true
GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com

# Regular config

# UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Proxy Config

UI_API_ENDPOINT=http://localhost:8000/graphql

UI_AUTH_ENDPOINT=http://localhost:8000/auth

API_PROXY_TARGET=http://localhost:3000

UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

...

Listing 14-11ui/sample.env: Switching to Proxy Mode

这种改变也需要重启 UI 服务器。重启后,如果您测试登录过程,您会发现对/auth/signin的 API 调用现在成功了(使用开发人员控制台中的 Network 选项卡来验证这一点),您会发现您的名字(基于您用来登录的 Google 用户)反映在导航栏中,如前一节所述。但不同的是,名字现在是验证过的,也可以在后端使用。

JSON Web 令牌

尽管我们从后端验证了令牌并使用了名称,但是我们没有持久化信息。这意味着在浏览器刷新时,关于登录的信息会消失,迫使用户重新登录。此外,对其他 API 的任何调用都不会携带任何身份验证信息,从而阻止 API 应用任何授权限制。

保存身份验证信息的一种方法是在后端创建一个由 cookie 标识的会话。这可以通过使用中间件express-session轻松完成,它在请求中添加了一个名为req.session的属性。在此会话中,可以设置和检索变量,例如用户的 ID 和电子邮件。中间件在内存中维护会话变量和 cookie 之间的映射,该映射也由中间件自动发送到浏览器。

但是使用这样的内存会话被认为是不可伸缩的,原因有几个:

  • 如果服务器实例不是单个的(出于可伸缩性或高可用性的原因),会话信息将不会在实例之间共享,需要用户分别登录到所有实例。

  • 会话信息是不透明编码的,不能在不同的服务之间共享,尤其是那些用不同语言编写或使用不同技术的服务。

  • 服务器重新启动将会丢失登录。

JSON Web 令牌(JWT)通过对需要存储在令牌中的所有会话信息进行编码来解决这个问题。这与我们通过谷歌认证后收到的谷歌令牌非常相似。令牌字符串本身包含所有信息,即用户名、电子邮件 ID 等。但是信息是加密的,所以它不能被窥探或模仿。

为什么不用谷歌令牌本身呢?为什么我们需要自己生成一个?原因是,如果您需要引入其他形式的身份验证,最好有一个单一的令牌,统一地向 Issue Tracker 应用标识用户。此外,创建我们自己的令牌允许我们添加更多的变量,例如,可以将角色标识符添加到会话信息中,并且可以快速检索该标识符以应用授权规则。

在这一节中,我们将建立一个即使在服务器重新启动后仍然存在的会话。我们将使用 JWT 生成一个令牌,并将其发送回浏览器。在 UI 进行的每个 API 调用中,都需要包含这个令牌,以标识登录的用户。

有多种方法可以保存令牌并在 API 调用时将其发送回后端:

  • UI 可以将它保存在内存,并在每个 API 调用请求中附加一个令牌作为头。用于此目的的通用标题是Authorization:标题。只要用户将应用用作 SPA,并且不刷新浏览器,这将非常有效。但是在浏览器刷新时,由于页面被重新加载,所有的 JavaScript 内存将被重新初始化,使得令牌不可用。

  • 令牌可以保存在浏览器的本地存储或会话存储中,而不是内存中,这样它就被持久化了。但是,如果应用中存在跨站点脚本(XSS)漏洞,这可能是不安全的。你可以在 OWASP 网站上了解更多信息: https://www.owasp.org/index.php/Cross-site_Scripting_(XSS) 。从本质上讲,XSS 漏洞是通过在生成页面时忘记对 HTML 特殊字符进行转义而产生的,这使得恶意用户能够将代码注入到应用中。因为它没有被转义,所以代码可以被执行而不是显示,从而允许以编程方式访问本地存储数据。

    大多数现代的 UI 框架,包括 React,都通过让程序员很难不编码就生成 HTML 来避免 XSS。但是,我们已经包括了一些第三方 UI 库,如 React-Bootstrap 和 React-Select,我们不能确定这些库在抵御 XSS 攻击方面做得有多好。

  • 令牌可以作为 cookie、发送,为了避免 XSS,我们可以通过设置 cookie 上的HttpOnly标志来防止 cookie 被编程读取。缺点是可以保存在 cookie 中的信息量被限制在 4KB。此外,浏览器对跨站点 cookies 有许多限制。由于问题跟踪器 UI 和 API 服务器是不同的,如果这两个服务器在不同的域下,几乎不可能使 cookies 工作。

    您也可能在互联网上读到 cookies 会使您的应用面临跨站点请求伪造(XSRF ),并且您需要为每个请求提供一个 XSRF 令牌来避免这种情况。但是这只适用于传统的 HTML 表单。

这些选择的总结如表 14-1 所示。

表 14-1

JWT 储存方法的比较

|

存储方式

|

优点

|

cons

|
| --- | --- | --- |
| 内存中 | 安全;没有大小限制 | 会话不是持久的;必须以编程方式管理在所有请求中包含令牌 |
| 局部存储器 | 没有大小限制 | 可能容易受到 XSS 的攻击;存储和令牌包含必须以编程方式管理 |
| 饼干 | 易于实施 | 数据的大小限制;跨域限制;容易受到 HTML 表单中 XSRF 的攻击 |

如果存储在 JWT 中的信息足够小,并且 UI 和 API 服务器属于同一个域,那么使用 cookies 来存储 JWT 似乎是最好的选择。此外,我们需要存储非常少的信息(只有姓名和电子邮件 ID,可能还有将来的角色),并且大多数应用部署的 UI 和 API 是主域的子域。此外,由于 Issue Tracker 应用没有传统的 HTML 表单,并且 GraphQL API 不接受除了application/json以外的任何内容作为 POST 请求中的内容类型,因此它不容易受到 XSRF 的攻击。但是它允许使用 GET 方法进行 API 调用,这很容易受到 XSRF 的攻击。我们需要禁用这种访问方法,因为 UI 不使用它。

让我们从在signin API 中生成 JWT 开始。jsonwebtoken包有一个方便的方法来做到这一点,所以让我们安装它。此外,因为我们将使用 cookie,所以让我们也安装一个 cookie 解析器。

$ cd api
$ npm install jsonwebtoken@8
$ npm install cookie-parser@1

我们不仅需要在auth.js中设置和检索 cookiess,还需要在 GraphQL 解析器中设置和检索 cookie(将来,当我们实现授权时)。因此,让我们为所有路由全局安装 cookie 解析器。清单 14-12 中显示了对server.js的更改。

...
const express = require('express');

const cookieParser = require('cookie-parser');

...

app.use(cookieParser());

app.use('/auth', auth.routes);
...

Listing 14-12api/server.js: Include Cookie Parser in All Routes

我们将对auth.js进行多处修改,这些都显示在清单 14-13 中。首先要做的是在signin API 中生成一个 JWT,并将其设置为 cookie。jsonwebtoken包提供了一个名为sign的简单函数,它接收一个 JavaScript 对象,并使用一个密钥对其进行加密。然后我们将设置一个名为jwt的 cookie,其值作为签名令牌。

...

const jwt = require('jsonwebtoken');

...
routes.post('/signin', async (req, res) => {
  ...
  const token = jwt.sign(credentials, JWT_SECRET);
  res.cookie('jwt', token, { httpOnly: true });

  res.json(credentials);
}
...

接下来,让我们创建一个新的 API 来获取当前的登录状态。这将完成验证 JWT 和提取用户名等工作。让我们将这个 API 端点称为路由集/auth下的/user。在这个过程中,我们将从 cookie 中检索 JWT 并调用jwt.verify(),这与sign相反:检索凭证。我们还将凭证的检索分离到一个单独的函数中,因为将来当我们实现授权时,我们需要对每个请求都这样做。

...

function getUser(req) {

  const token = req.cookies.jwt;
  if (!token) return { signedIn: false };

  try {
    const credentials = jwt.verify(token, JWT_SECRET);
    return credentials;
  } catch (error) {
    return { signedIn: false };
  }

}

...

routes.post('/user', (req, res) => {

  res.send(getUser(req));

});

...

至于JWT_SECRET变量,我们在环境中需要一个配置变量。为此,我们使用一个名为JWT_SECRET的环境变量。您应该生成自己的随机字符串作为该变量的值,尤其是在生产环境中部署应用时。

在没有这个变量的情况下,让我们只使用一个默认值,但只是在开发模式下。在生产中,如果密钥丢失,我们将禁用身份验证。包括此次变更在内,对auth.js的完整变更如清单 14-13 所示。

...
const { OAuth2Client } = require('google-auth-library');

const jwt = require('jsonwebtoken');

let { JWT_SECRET } = process.env;

if (!JWT_SECRET) {

  if (process.env.NODE_ENV !== 'production') {
    JWT_SECRET = 'tempjwtsecretfordevonly';
    console.log('Missing env var JWT_SECRET. Using unsafe dev secret');
  } else {
    console.log('Missing env var JWT_SECRET. Authentication disabled');
  }

}

const routes = new Router();

routes.use(bodyParser.json());

function getUser(req) {

  const token = req.cookies.jwt;
  if (!token) return { signedIn: false };

  try {
    const credentials = jwt.verify(token, JWT_SECRET);

    return credentials;
  } catch (error) {
    return { signedIn: false };
  }

}

routes.post('/signin', async (req, res) => {
  if (!JWT_SECRET) {
    res.status(500).send('Missing JWT_SECRET. Refusing to authenticate');
  }
  ...

  const credentials = {
    ...
  };

  const token = jwt.sign(credentials, JWT_SECRET);
  res.cookie('jwt', token, { httpOnly: true });

  res.json(credentials);
});

routes.post('/user', (req, res) => {

  res.send(getUser(req));

});

...

Listing 14-13api/auth.js: Changes for JWT Generation and Verification

为了确保在浏览器刷新时我们继续保持登录状态,让我们使用/auth/user端点 API 获取认证信息。到目前为止,唯一使用用户信息的组件是SignInNavItem组件。因此,让我们将这些数据加载到组件的componentDidMount()方法中,并在其状态中设置用户信息。

我们将使用在componentDidMount()中调用的loadData()函数的常见模式。在这个函数中,我们将调用/auth/user API,检索用户信息,并设置状态。注意,我们必须使用fetch() API,而不是 GraphQL API,因为还没有 GraphQL API 来获取当前的用户信息。对此的更改如清单 14-14 所示。

...
  async componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
    await this.loadData();
  }

  async loadData() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
    });
    const body = await response.text();
    const result = JSON.parse(body);
    const { signedIn, givenName } = result;
    this.setState({ user: { signedIn, givenName } });
  }
...

Listing 14-14ui/src/SignInNavItem.jsx: Changes to Use Persist and Use Authentication Info from the Back-End

现在,您可以测试应用,您会发现登录信息在浏览器刷新后仍然存在。检查 JWT 是否也在每个 GraphQL 请求上发送也是一个好主意。为此,您可以导航到不同的页面,并使用开发人员控制台来检查网络流量。您应该看到名为jwt的 cookie 在每次请求时都被发送到服务器。同样在浏览器刷新时,您应该看到一个请求发送到/auth/user,导航栏中的菜单项从“登录”变为用户的名字。

如果您单击“注销”,菜单项将变回“登录”,但是在浏览器刷新时,您会发现用户名又回到了菜单项上。这是因为 cookie 仍然是活动的,这表明处于登录状态。

注销

注销需要两件事:浏览器中的 JWT cookie 需要被清除,Google 认证需要被忘记。

因为我们已经在 cookie 中设置了HttpOnly标志,所以不能从前端代码以编程方式访问它。要清除它,我们必须依靠服务器。为此,让我们在/auth下实现另一个 API 来注销,这实质上只是清除 cookie。清单 14-15 中显示了对auth.js的更改。

...

routes.post('/signout', async (req, res) => {

  res.clearCookie('jwt');
  res.json({ status: 'ok' });

});

routes.post('/user', (req, res) => {
  ...
});
...

Listing 14-15api/auth.js: Sign-Out API

让我们从 UI 调用这个 API,并用对它的调用替换组件SignInNavItem中的普通signOut()函数。此外,让我们也调用谷歌认证 API 的signOut()函数,它是authInstance的一部分。清单 14-16 显示了SignInNavItem组件的变更。

...
  async signOut() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const { showError } = this.props;
    try {
      await fetch(`${apiEndpoint}/signout`, {
        method: 'POST',
      });
      const auth2 = window.gapi.auth2.getAuthInstance();
      await auth2.signOut();
      this.setState({ user: { signedIn: false, givenName: '' } });
    } catch (error) {
      showError(`Error signing out: ${error}`);
    }
  }
...

Listing 14-16ui/src/SignInNavItem.jsx: Changes for Signing Out from the Back-End and Google

经过这一系列更改后,即使在浏览器刷新时,您也会发现注销状态并没有改变。要确认这一点,您可以检查网络流量,以确保没有根据任何请求发送 cookie。确认这一点的另一种方法是查看浏览器中的 cookie 数据,并确保网站localhost在注销后没有jwt cookie。

批准

现在,我们已经确定了一个正在访问问题跟踪器应用的用户,让我们使用这些信息。典型的企业应用会有角色和属于角色的用户。角色将指定允许用户进行哪些操作。我们不需要实现所有这些,我们只需要实现一个简单的授权规则,它足以演示如何实现这一点。

我们将实现的规则是这样的:如果一个用户登录了,这个用户被允许进行修改。未经验证的用户只能阅读问题。因此,我们将防止未授权用户访问mutation下的任何 API。我们还需要更改 UI 来禁用不可用的操作,但是让我们在下一节讨论这个问题。在这一节中,我们将只确保后端 API 是安全的,并防止未经授权的修改。当试图进行未经授权的操作时,API 将报告错误。

Apollo Server 提供了一种机制,通过这种机制可以将上下文传递给所有解析器。到目前为止,我们只在任何解析器中使用前两个参数,例如:

...
async function add(_, { issue }) {
...

事实上,GraphQL 库传递了第三个参数,即上下文,可以根据应用的需求进行定制。

...
async function add(_, { issue }, context) {
...

可以将上下文设置为可以从请求中导出的任何值。我们可以将用户信息设置为上下文,并让每个解析器检查凭证是否足以满足请求。给定我们的简单规则,这可能看起来像:

...
async function add(_, { issue }, user) {
  if (!user || !user.signedIn) {
    throw new AuthenticationError('You must be signed in');
  }
  ...
}
...

让我们首先创建保存用户信息的上下文,并作为第三个参数传递给每个解析器。这样做的地方是在 Apollo 服务器初始化期间。除了typedef和解析器,我们还需要指定一个函数,它接受一个对象,将req作为一个属性,并返回将提供给所有解析器的上下文。

...
const server = new ApolloServer({
  typeDefs: 
...
  resolvers,
  context: getContext,
});
...

由于我们已经有了一个函数,它返回给定请求对象的用户凭证,作为auth.js的一部分,使用它来实现getContext()将会非常简单。经过这样的修改,对api_handler.js的最终修改如清单 14-17 所示。

...
const issue = require('./issue.js');

const auth = require('./auth.js');

...

function getContext({ req }) {

  const user = auth.getUser(req);
  return { user };

}

const server = new ApolloServer({
  typeDefs: ...
  resolvers,
  context: getContext,
  ...
});
...

Listing 14-17api/api_hander.js: Set the User as Context in All Resolver Calls

现在,与其在每个解析器函数中包含上下文并在所有函数中检查有效用户,不如让我们尝试重用这些代码。我们需要的是接收一个现有的解析器,并返回一个在执行解析器之前进行检查的函数。让我们在auth.js中创建这样一个函数并导出它。让我们也导出getUser,因为在getContext()内的api_handler中需要它。这些变化如清单 14-18 所示。

...
const jwt = require('jsonwebtoken');

const { AuthenticationError } = require('apollo-server-express');

...

function mustBeSignedIn(resolver) {

  return (root, args, { user }) => {
    if (!user || !user.signedIn) {
      throw new AuthenticationError('You must be signed in');
    }
    return resolver(root, args, { user });
  };

}

module.exports = { routes, getUser, mustBeSignedIn };
...

Listing 14-18api/auth.js: Common Function for Simplistic Authorization Check

现在,为了防止未经身份验证的用户调用受保护的 API,让我们用一个mustBeSignedIn包装的函数替换它们的导出。保护setAboutMessage的变化如清单 14-19 所示。

...

const { mustBeSignedIn } = require('./auth.js');

let aboutMessage = 'Issue Tracker API v1.0';
...

module.exports = { getMessage, setMessage: mustBeSignedIn(setMessage) };
...

Listing 14-19api/about.js: Prevent Unauthenticated Access to setAboutMessage

清单 14-20 中显示了所有与问题相关的 API 的一组类似的更改。

...
const { getDb, getNextSequence } = require('./db.js');

const { mustBeSignedIn } = require('./auth.js');

...

module.exports = {
  list,
  add: mustBeSignedIn(add),
  get,
  update: mustBeSignedIn(update),
  delete: mustBeSignedIn(remove),
  restore: mustBeSignedIn(restore),
  counts,
};
...

Listing 14-20api/issue.js: Prevent Unauthenticated Access to Issue Methods

现在,如果您尝试从调用这些 API 的 UI 中访问任何功能,您应该会发现它们会失败,并出现一个错误,"UNAUTHENTICATED: You must be signed in."例如,单击+按钮创建一个问题,然后在模式对话框中单击 Submit,将会导致出现一个错误消息。

练习:授权

  1. 如果我们需要防止未经认证的用户访问任何 ?? 功能,也就是说,整个应用需要有受保护的访问,那么 GraphQL APIs 应该如何改变呢?忽略允许用户登录所需的 UI 更改。只关注 API。提示:在 https://www.apollographql.com/docs/apollo-server/features/authentication.html 查找阿波罗认证文档。

本章末尾有答案。

支持授权的用户界面

阻止用户执行未授权的操作是很好的,但是在 UI 上阻止对这些操作的访问比只在后端检查它们更好。在这一节和下一节中,我们将让 UI 知道登录状态。

我们将使用两种机制来做到这一点。在本节中,我们将通过常规方式禁用导航栏中的“创建问题”按钮。我们将把状态提升到一个共同的祖先,并让状态向下流动,作为可以在子 Node 中使用的道具。在下一节中,我们将使用一种不同的技术,这种技术更适合于将道具传递给层次结构中非常深的组件。

让我们选择Page组件作为用户登录状态所在的层次结构中的组件。为此,我们必须将组件转换成常规组件,也就是说,不是无状态组件。然后,我们将状态变量userSignInNavItem移到Page。与其使用不同的方法来登录和退出,不如使用一个叫做onUserChange的方法,因为这样更容易将其作为道具传递下去。我们将把这个方法和user变量向下传递到导航栏(稍后,再向下传递)。

我们还需要通过调用 API /auth/user来加载componentDidMount()中的状态。这段代码可以从SignInNavItem复制过来。清单 14-21 中显示了对Page组件的更改。

...
export default function class Page () extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: { signedIn: false } };

    this.onUserChange = this.onUserChange.bind(this);
  }

  async componentDidMount() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
    });
    const body = await response.text();
    const result = JSON.parse(body);
    const { signedIn, givenName } = result;
    this.setState({ user: { signedIn, givenName } });
  }

  onUserChange(user) {
    this.setState({ user });
  }

  render() {
    const { user } = this.state;
    return (
      <div>
        <NavBar user={user} onUserChange={this.onUserChange} />
        <Grid fluid>
          <Contents />
        </Grid>
        <Footer />
      </div>
    );
  }

}

...

Listing 14-21ui/src/Page.jsx: Lift User State Up to Page

既然导航栏已经为登录用户提供了一个适当的变量,让我们将它传递给需要它们的NavItems:创建问题和登录菜单项。此外,登录菜单项需要在登录状态发生变化时调用onUserChange属性。所以让我们也通过它。这些变化如清单 14-22 所示。

...
function NavBar({ user, onUserChange }) {
  ...
      <Nav pullRight>
        <IssueAddNavItem user={user} />
        <SignInNavItem user={user} onUserChange={onUserChange} />
        ...
      </Nav>
  ...
}
...

Listing 14-22ui/src/Page.jsx: Passing Through User Properties to Navigation Items

显示处于禁用状态的 Create Issue 导航项很简单:我们将检查signedIn标志,如果是false,则禁用NavItem。清单 14-23 中显示了对IssueAddInNavItem组件的更改。

...
  render() {
    const { showing } = this.state;
    const { user: { signedIn } } = this.props;
    return (
      <React.Fragment>
        <NavItem disabled={!signedIn} onClick={this.showModal}>
      ...
    );
  }
...

Listing 14-23ui/src/IssueAddNavItem.jsx: Disable the Item When Not Signed In

SignInNavItem组件中,我们将为用户移除状态变量,并使用传入的新道具。此外,我们不再需要在组件挂载上加载数据,因为这是与Page组件中的状态一起完成的。这些变化如清单 14-24 所示。

...
  constructor(props) {
    this.state = {
      ...
      user: { signedIn: false, givenName: '' },
    };
  }
...

  async componentDidMount() {
    ...
    await this.loadData();
  }

  async loadData() {
    ...
  }

  async signIn() {
    ...
      this.setState({ user: { signedIn, givenName } });
      const { onUserChange } = this.props;
      onUserChange({ signedIn, givenName });
    ...
  }

  async signOut() {
    ...
      this.setState({ user: { signedIn: false, givenName: '' } });
      const { onUserChange } = this.props;
      onUserChange({ signedIn: false, givenName: ' ' });
    ...
  }
...

  render() {
    const { user } = this.state props;
    ...
  }
...

Listing 14-24ui/src/SignInNavItem.jsx: Moving User State Out

现在,在测试中,你会发现SignInNavItem保留了它在登录时显示用户名,在未登录时显示登录标签的行为。此外,当用户未登录时,“创建问题”按钮会被禁用。

尽管这看起来像是做了很多工作却收效甚微,但是当兄弟组件之间共享公共属性时,这就变得很有必要了。注意,我们可以将状态移到导航栏组件NavBar,但是因为我们需要将用户状态传递给不在NavBar下的其他组件,所以将它移到Page组件会更方便。

React 上下文

在本节中,我们将让其他组件知道身份验证状态。我们将禁用问题表中的关闭和删除按钮,然后禁用编辑页面中的提交按钮。

但是,以与导航菜单项相同的方式这样做不仅会使它变得乏味(通过层次结构中的许多组件传递用户属性),而且还会带来挑战。在Contents组件中,我们从一个数组中生成路线,不清楚如何将道具传递给将要呈现的组件。

因此,在这一节中,我将介绍 React Context API,它可用于跨组件层次结构传递属性,而不会让中间组件知道它。React 上下文旨在共享被认为是全局的数据。经过身份验证的用户确实属于全局类别,因此为此使用上下文不失为一个好主意。

要开始使用上下文,我们首先需要使用React.createContext()方法创建一个上下文。这需要一个参数,即上下文的缺省值,它将被传递给所有需要它的组件。因为我们需要传递一个用户对象,所以让我们将它用作上下文变量,并将其默认值设置为用户未登录的初始状态。我们还需要跨组件共享上下文,包括设置值和使用值的位置。因此,让我们为此创建一个名为UserContext的独立 JavaScript 模块。这个新模块如清单 14-25 所示。

import React from 'react';

const UserContext = React.createContext({
  signedIn: false,
});

export default UserContext;

Listing 14-25ui/src/UserContext.js: A React Context for Storing User State

创建的上下文在其下公开了一个名为Provider的组件,该组件需要包装在任何需要该上下文的组件层次结构中。该组件接受一个名为value的属性,该属性需要被设置为上下文将在所有派生组件中设置的值。例如,请看下面的代码:

<UserContext.Provider value={{ givenName: 'User1' }}>
  <IssueList />
</UserContext>

这将使UserContextthis.context的形式提供给IssueList组件,以及IssueList的所有后代。但是为了明确需要消费的是UserContext,我们必须将上下文类型指定为任何希望消费上下文的后代中的组件的静态变量。因此,要在IssueTable组件中使用用户上下文,我们需要做以下事情:

class IssueTable extends React.Component {
  render() {
    const user = this.context;
    ...
  }
}

IssueTable.contextType = UserContext;

注意,由于IssueTableIssueList的子 Node,它将接收用户上下文,而不必通过IssueList组件显式传递。此外,我们不必将提供者中的上下文值设置为静态值,如图所示。它可以被设置为状态变量的值,这样当状态改变时,就可以用上下文中的新值重新呈现所有子组件。

因此,让我们将用户状态设置为提供者中的值,并将提供者包装在Page中的Contents组件周围。对此的更改如清单 14-26 所示。

...
import Search from './Search.jsx';

import UserContext from './UserContext.js';

...

  render() {
    const { user } = this.state;
    return (
      ...
          <UserContext.Provider value={user}>
            <Contents />
          </UserContext.Provider>
      ...
    );
  }

Listing 14-26ui/src/Page.jsx: Providing the User Context

现在,Contents的所有后代都可以访问用户上下文。我们先在IssueEdit组件里消费一下。如前所述,我们需要定义静态变量contextType,并将其设置为对象UserContext。然后,用户将作为this.context可用,我们将使用它来获取signedIn属性,并基于此禁用提交按钮。这些变化如清单 14-27 所示。

...

import UserContext from './UserContext.js';

class IssueEdit extends React.Component {
  render() {
    ...
    const user = this.context;

    return (
      ...
                  <Button
                    disabled={!user.signedIn}
                    bsStyle="primary"
                    type="submit"
                  >
      ...
    );
  }
}

IssueEdit.contextType = UserContext;

...

Listing 14-27ui/src/IssueEdit.jsx: Changes to Disable the Submit Button Based on User Context

这一更改可以单独测试—在您登录之前,您应该能够看到编辑页面中的提交按钮被禁用。

下一个需要修改以包含用户上下文的组件是IssueRow组件。但是不幸的是,无状态组件没有一个this变量,因此,无法通过this获得上下文。React 的早期版本将上下文作为附加参数传递给功能组件,这可以使用遗留上下文 API 来完成。但是不建议这样做,因为旧的 API 已经过时了。

另一种选择是使用最近的非无状态组件的父组件的上下文,并将变量作为 props 向下传递。但是这违背了创建上下文的初衷。因此,让我们将IssueRow组件转换成常规的(也就是说,而不是无状态的)组件,并使用上下文。

还有另一个复杂性:我们使用withRouter包装组件。这导致被包装的组件从内部组件(被包装的组件)继承静态变量contextType,从而导致开发人员控制台出错。这是因为被包装的组件恰好是一个无状态组件。

为了防止它抛出这个错误,我们需要删除包装组件中的contextType静态变量,将它单独留在内部组件中。在撰写本书时,React Router 中就存在这个问题,但在您阅读本书并试用它时,这个问题可能已经解决了。更多信息请参见 https://stackoverflow.com/questions/53240058/use-hoist-non-react-statics-with-withrouter 本期详细内容。

要做到这一切,我们需要为内部组件创建一个不同的名称,以便可以独立地访问它。姑且称之为IssueRowPlain,和之前一样,用路由包装的组件为IssueRow。清单 14-28 显示了转换为常规组件以及消费上下文的变化。为了简洁起见,没有显示缩进的变化。

...
import UserContext from './UserContext.js';

const IssueRow = withRouter(({

  issue, location: { search }, closeIssue, deleteIssue, index,

}) => {

// eslint-disable-next-line react/prefer-stateless-function

class IssueRowPlain extends React.Component {

  render() {
    const {
      issue, location: { search }, closeIssue, deleteIssue, index,
    } = this.props;
    const user = this.context;
    const disabled = !user.signedIn;

    const selectLocation = { pathname: `/issues/${issue.id}`, search };
    ...
            <Button disabled={disabled} bsSize="xsmall" onClick={onClose}>
          ...
            <Button disabled={disabled} bsSize="xsmall" onClick={onDelete}>
    ...

  }
}

IssueRowPlain.contextType = UserContext;

const IssueRow = withRouter(IssueRowPlain);

delete IssueRow.contextType;

...

Listing 14-28ui/src/IssueTable.jsx: IssueRow Converted to Regular Component for Consuming User Context

通过这一更改,您应该能够看到关闭和删除按钮在默认情况下是禁用的,直到您登录。如果您愿意,您可以恢复对导航项目的更改,以使用上下文,而不是通过NavBar组件将用户作为道具传递。

练习:对上下文做出 React

  1. 我们避免将道具传递给由<Route> s 构造的组件。在user上下文的情况下,我们可以这样做,因为用户信息确实可以被认为是一个全局变量。如果您需要将某些东西传递给被路由的组件,但它不是一个全局变量,该怎么办?换句话说,如何将属性传递给路由组件?提示:查一下这篇博文: https://tylermcginnis.com/react-router-pass-props-to-components/

本章末尾有答案。

有证书的 CORS

当我们添加对 Google token 的验证时,我们不得不切换到代理操作模式,因为 CORS 阻止了向/auth/signin发送请求。在这一节中,我们将看到如何通过放松 CORS 选项让应用在非代理模式下工作,同时保持安全性。

请求被阻止的原因是,当应用的来源(起始页面/index.html的来源)与任何 XHR 调用的目标不同时,浏览器会认为它不安全而阻止它。在问题跟踪器应用中,起始页是从http://localhost:8000获取的,但是 API 调用是对http://localhost:3000进行的。在这一点上,阅读第 7 “架构和 ESLint”中标题为“基于代理的架构”的部分会对你有所帮助,作为对 CORS 的一个总结。

让我们首先切换回非代理模式。如果您使用基于sample.env.env,您可以做出如清单 14-29 所示的更改。常规配置中的行被取消注释,代理配置中的行被注释掉以影响此更改。

...
# Regular config
# UI_API_ENDPOINT=http://localhost:3000/graphql
# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Proxy Config
# UI_API_ENDPOINT=http://localhost:8000/graphql
# UI_AUTH_ENDPOINT=http://localhost:8000/auth
# API_PROXY_TARGET=http://localhost:3000
# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql
...

Listing 14-29ui/sample.env: Switch Back to Non-Proxy Mode

需要重新启动服务器才能读入新的环境变量。此时,如果您尝试登录,您会发现登录失败,并在开发人员控制台中显示以下错误消息:

Access to fetch at 'http://localhost:3000/auth/user' from origin 'http://localhost:8000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Apollo 服务器的默认配置启用了 CORS 并允许对/graphql的请求。但是由于在/auth上没有做,所以被屏蔽了。让我们尝试启用 CORS 的/auth路线。cors包让我们很容易做到这一点。我们需要做的就是在路由中安装一个中间件,它将处理在对 API 服务器的飞行前请求中设置必要的报头。让我们首先在api目录下安装包。

$ cd api
$ npm install cors@2

然后,我们需要导入这个包并在auth.js中添加一个中间件。

...

const cors = require('cors');

...
routes.use(bodyParser.json());

routes.use(cors());

...

现在,登录似乎成功了,您将能够看到 Sign In 菜单项发生了变化,以反映给定的名称。因此,添加默认中间件确实有效,浏览器也向/auth routes 发送 API 请求。但是有一个警告。如果刷新浏览器,你会发现认证信息已经消失了!

如果您检查开发人员工具的 Network 选项卡,您会发现jwt cookie 没有在进一步的请求中被发送到服务器,因此对/auth/user的请求会将signedIn标志返回为false。但是你可以看到 cookie 确实是在对/auth/signin的回应中设置的。

因此,默认的 CORS 配置似乎允许请求,但是不允许为跨源请求发送 cookies。这是一个安全的默认设置,因为任何公共资源都应该是可访问的;只有经过身份验证的具有任何用户凭据的请求才必须被阻止。

要让凭证也传递到跨来源,必须执行以下操作:

  1. 所有 XHR 调用(即使用 API fetch()的调用)都必须包含头credentials: 'include'。否则,cookies 将从这些调用中删除。

  2. Including credentials using this method is forbidden if cross-origin requests from a wildcard origin are allowed by the server. If you noticed in the request headers, there is a header Access-Control-Allow-Origin: *. This is the default CORS configuration, and for good reason, including credentials from any origin should be disallowed. We’ll need to change this to allow requests with origin only from the UI server. Thus, the CORS middleware should include an origin as a configuration option, for example:

    ...
    routes.use(cors({ origin: 'http://localhost:8000’ }));
    ...
    
    

    如果没有这样做,您会在开发人员控制台中发现如下有用消息:

  3. 如果浏览器被指示发送凭证,这是不够的;服务器也必须明确地允许它。这由另一个名为credentials的 CORS 配置选项完成,该选项必须设置为true

Access to fetch at 'http://localhost:3000/auth/user' from origin 'http://localhost:8000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

让我们首先对auth.js进行更改,以设置这些 CORS 配置选项。同样,与其硬编码原点,不如使用一个名为UI_SERVER_ORIGIN的环境变量。您可以将它保存在您的.env文件中,类似于清单 14-30 中所示的对sample.env的更改。

...
# ENABLE_CORS=false

UI_SERVER_ORIGIN=http://localhost:8000

...

Listing 14-30api/sample.env: Option for UI Server’s Origin

auth.js中,让我们使用这个环境变量并设置 CORS 选项来包含来源和凭证。清单 14-31 中显示了对auth.js的最后一组更改。

...

const cors = require('cors');

...
routes.use(bodyParser.json());

const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000';

routes.use(cors({ origin, credentials: true }));

...

Listing 14-31api/auth.js: Changes to Include CORS with Correct Configuration Options

我们还需要在所有对/auth的 API 调用中包含凭证。先在SignInNavItem内做这个吧。如清单 14-32 所示。

...
  async signIn() {
    ...
      const response = await fetch(`${apiEndpoint}/signin`, {
        method: 'POST',
        credentials: 'include',
        ...
      });
    ...
  }

  async signOut() {
    ...
      await fetch(`${apiEndpoint}/signout`, {
        method: 'POST',
        credentials: 'include',
      });
    ...
  }
...

Listing 14-32ui/src/SignInNavItem.jsx: Include Credentials in Fetch Calls

然后,在Page.jsx中,当我们获取用户凭证时,我们必须包含这些凭证。这一变化如清单 14-33 所示。

...
  async componentDidMount() {
    ...
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
      credentials: 'include',
    });
...

Listing 14-33ui/src/Page.jsx: Include Credentials in Fetch Calls

现在,在测试中,您会发现浏览器刷新确实会保持登录状态。但是所有的 GraphQL API 调用都使用原始的 CORS 配置,它还没有凭证。这将导致 API 拒绝来自应用的任何修改。例如,即使您已登录,创建问题也会失败,提示您需要登录。

为了让 GraphQL API 调用允许这些,我们还需要在 Apollo 服务器中设置 CORS 配置选项。除了truefalse值,创建 Apollo 服务器时的 CORS 选项还可以接受 CORS 配置本身。这是我们将用来设置 CORS 配置。

除了只允许已配置的源,我们还将限制只允许 POST 的方法。这是因为 Apollo 服务器默认允许 GET 请求,正如“JSON Web 令牌”一节中所讨论的,这可能是 XSRF 漏洞的一个原因。应用本身不使用 POST 之外的任何方法,因此添加此限制是安全的。清单 14-34 显示了 GraphQL API 中 CORS 配置的变化。

...
function installHandler(app) {
  ...
  console.log('CORS setting:', enableCors);
  let cors;
  if (enableCors) {
    const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000';
    const methods = 'POST';
    cors = { origin, methods, credentials: true };
  } else {
    cors = 'false';
  }
  server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
}
...

Listing 14-34api/api_handler.js: CORS Configuration for Apollo Server

因为所有的 GraphQL API 调用都是通过graphQLFetch函数路由的,所以我们唯一需要添加credentials: 'include'头的地方是在graphQLFetch.js中。这一变化如清单 14-35 所示。

...
    const response = await fetch(apiEndpoint, {
      method: 'POST',
      credentials: 'include',
      ...
    });
...

Listing 14-35ui/src/graphQLFetch.js: Include Credentials in Fetch Calls

通过所有这些更改,您会发现所有 GraphQL 调用的凭证也被发送到服务器。在这一点上,应用应该像上一节结束时在代理操作模式下那样工作。您还应该查看开发人员工具中的 Network 选项卡,并验证 cookie 是否被设置在身份验证响应上,并被发送到 API 服务器用于/auth/graphql调用。

使用凭据的服务器呈现

到目前为止,我们还没有处理包含认证用户信息的页面的服务器呈现。这样做的效果是,在浏览器刷新时,页面被加载,就好像用户没有登录一样。菜单项上写着“签到”。然后,在异步获取用户凭证并更新用户状态后,菜单会更改为登录用户的给定名称。

实际上,至少对于搜索引擎机器人来说,这是完全没问题的。这是因为搜索引擎不可能以用户身份登录并抓取网站页面。机器人抓取的只是公开可用的页面集。

但是,如果您不喜欢从未登录状态转换到已登录状态的(稍微)笨拙的闪烁,或者,如果您希望无论用户是否登录,所有页面的行为都保持一致,那么您可能希望考虑在包含凭证的服务器上呈现页面。

使用凭证的服务器呈现本质上有三个挑战,这不同于我们用于路由视图的服务器呈现的常规模式。

  • 为用户凭证获取的初始数据将被发送到/auth端点,而不是/graphql端点。服务器渲染依赖于这样一个事实,即所有的数据获取调用都要经过graphQLFetch(),在那里我们根据调用是来自浏览器还是 UI 服务器来做出关键的决定。

  • 当获取用户数据时,UI 服务器获取数据的 API 调用必须包含 cookie。当从 UI 调用时,浏览器会自动添加这个 cookie。但是在 UI 服务器中,我们需要手动包含 cookie。否则,呼叫将表现为用户未登录。

  • 在视图中使用fetchData()函数渲染之前,除了提取的任何其他数据之外,提取的初始数据还需要。此外,这个数据不是由作为路由一部分的任何视图获取的:它是在一个高得多的级别上获取的,在Page组件中。

为了解决第一个挑战,让我们为经过身份验证的用户凭证引入一个新的 GraphQL API。我们使用了/auth组路由,因为 GraphQL 解析器不能设置 cookies,因为它们不能访问 Express response 对象。但是获取登录用户的凭证只需要访问 Express request 对象,正如我们在前面的授权部分看到的,这应该是可行的。

要开始实现这个 API,让我们改变模式。这一变化如清单 14-36 所示。

...
type IssueListWithPages {
  ...
}

type User {

  signedIn: Boolean!
  givenName: String
  name: String
  email: String

}

...

type Query {
  about: String!
  user: User!
  ...
}
...

Listing 14-36api/schema.graphql: User Credentials API

至于解析器,让我们引入一个解析器函数作为auth.js的一部分,它只返回上下文。您还记得,我们将所有解析器的 GraphQL 上下文都设置为 user 对象,所以这就是我们所需要的返回值。对auth.js的更改如清单 14-37 所示。

...

function resolveUser(_, args, { user }) {

  return user;

}

module.exports = {
  routes, getUser, mustBeSignedIn, resolveUser,
};
...

Listing 14-37api/auth.js: GraphQL Resolver for Returning User Credentials

让我们将这个新的解析器绑定到 API 处理程序中。这一变化如清单 14-38 所示。列表

...
onst resolvers = {
  Query: {
    about: about.getMessage,
    user: auth.resolveUser,
    ...
  },
  ...
};

...

14-38api/api_handler.js: Add Resolver for User API

要测试 API,您可以使用操场。但是现在我们有了受保护的 API,默认情况下 Playground 不会为这些 API 工作。您需要确保在 API 查询中发送 cookies。操场上有一个设置允许这样做,叫做request.credentials.,默认值是"omit",你需要把它改成"include"。然后,所有请求都将包含 cookies。因此,在登录到应用后,下面的查询应该返回登录用户的给定名称。

query { user { givenName } }

下一个挑战是让用户凭证通过 UI 服务器传递给 API 调用。您还记得,当浏览器向路由的 URL(比如到 UI 服务器的/issues/about)发出请求时,会返回一个服务器呈现的页面。然后,UI 服务器对 API 服务器进行 API 调用,并使用数据预填充页面。因此,UI 服务器在第一步中接收到的 cookie 需要在对 API 服务器的调用中进行复制。

本质上,作为呈现的一部分,从服务器发出的任何对graphQLFetch()的调用都需要包含在发起请求时接收到的 JWT cookie。我们可以做到这一点的方法是让所有的fetchData()静态函数接收一个可选参数cookie,当任何函数调用graphQLFetch()时都可以传递这个参数。

现在让我们将graphQLFetch.js改为能够在 API 请求中包含 cookie。这一变化如清单 14-39 所示。

...
export default async function
graphQLFetch(query, variables = {}, showError = null, cookie = null) {
  const apiEndpoint = (__isBrowser__) // eslint-disable-line no-undef
    ? window.ENV.UI_API_ENDPOINT
    : process.env.UI_API_ENDPOINT;
  try {
    const headers = { 'Content-Type': 'application/json' };
    if (cookie) headers.Cookie = cookie;
    const response = await fetch(apiEndpoint, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
...

Listing 14-39ui/src/graphQLFetch.js: Changes to Pass Through Cookies

由于这个函数既在服务器上也在浏览器上使用,我们必须确保对于来自服务器渲染例程的调用,即来自render.jsx的调用,cookie 参数只传递给。对于来自浏览器的调用,浏览器将自动包含 cookie。

使用这个,让我们改变Page组件来加载数据,作为静态fetchData()函数的一部分,就像我们对其他路由组件所做的那样。我们也将用新的 GraphQL API 替换对/auth/user的调用。这是新的静态函数,cookie 是可选的。我们需要为服务器端请求传入 cookie,因此该函数需要将 cookie 作为参数。

...
  static async fetchData(cookie) {
    const query = `query { user {
      signedIn givenName
    }}`;
    const data = await graphQLFetch(query, null, null, cookie);
    return data;
  }
...

在构造函数中,让我们检查包含用户凭证值的全局存储,就像我们对路由组件所做的那样,如果它存在,就使用它。我们将通过在状态变量中存储一个null值来表示它不存在,这个值componentDidMount()可以检查并加载数据。我们将需要使用不同的变量,而不是使用store.initialData,因为用户数据是加上渲染路由组件所需的其他数据。为此,让我们使用一个名为userData的新变量。

...
  constructor(props) {
    ...
    const user = store.userData ? store.userData.user : null;
    delete store.userData;
    this.state = { user };
    ...
  }
...

注意,由于Page组件总是被渲染,所以不可能出现服务器不将用户数据作为渲染的一部分的情况。但是为了保持一致,并允许通过导航将该组件安装到服务器上,让我们确保数据被加载到componentDidMount()中,就像我们对视图所做的那样。这个调用不需要包含任何 cookies,因为浏览器会自动附加它们。

...
  async componentDidMount() {
    const { user } = this.state;
    if (user == null) {
      const data = await Page.fetchData();
      this.setState({ user: data.user });
    }
  }
...

最后,在render()方法中,让我们检查用户状态变量是否为 null 并返回null,以保持它与其他视图一致,即使这种情况不会发生。清单 14-40 中显示了该变更以及对Page.jsx的其他变更。

...
import UserContext from './UserContext.js';

import graphQLFetch from './graphQLFetch.js';

import store from './store.js';

...

export default class Page extends React.Component {
  static async fetchData(cookie) {
    const query = `query { user {
      signedIn givenName
    }}`;
    const data = await graphQLFetch(query, null, null, cookie);
    return data;
  }

  constructor(props) {
    super(props);
    const user = store.userData ? store.userData.user : null;
    delete store.userData;
    this.state = { user: { signedIn: false } };

    ...
  }

  async componentDidMount() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    ...
    this.setState({ user: { signedIn, givenName } });
    const { user } = this.state;
    if (user == null) {
      const data = await Page.fetchData();
      this.setState({ user: data.user });
    }
  }
...

  render() {
    const { user } = this.state;
    if (user == null) return null;
    ...
  }
...

Listing 14-40ui/src/Page.jsx: Changes for fetchData Separation, Using graphQLFetch and Global Store

我们真的不需要改变所有其他的fetchData()调用,比如IssueList.fetchData来包含 cookie。这是因为 cookie 的存在对这些公开可用的 API 的结果没有影响。可能在某些情况下需要这样做,例如,如果返回的问题列表取决于当前登录的用户。在这种情况下,相关的fetchData()函数也需要修改,以便能够通过任何 cookies。

为了解决获取用户数据以及路由视图所需的其他数据的下一个挑战,让我们决定所有的全局数据获取(比如用户凭证)都必须在服务器上呈现时进行硬编码。我们不会像对布线元件那样通用和灵活。因此,当在服务器上呈现时,让我们显式地调用Page.fetchData()来获取全局数据并将其包含在存储中。

我们需要做的第一个改变是在模板中,我们将为用户数据创建一个新变量,就像我们为初始数据所做的那样。我们还将为初始数据重命名参数,使其更加明确。这显示在清单 14-41 中。

...
export default function template(body, data initialData, userData) {
 ...
  <script>
    window.__INITIAL_DATA__ = ${serialize(data initialData)}
    window.__USER_DATA__ = ${serialize(userData)}
  </script>
  ...
}
...

Listing 14-41ui/server/template.js: Include User Data

并且,在浏览器中,为了将这些数据传输到商店,我们需要更改App.jsx。变更如清单 14-42 所示。

...
import store from '../src/store.js';

// eslint-disable-next-line no-underscore-dangle

/* eslint-disable no-underscore-dangle */

store.initialData = window.__INITIAL_DATA__;

store.userData = window.__USER_DATA__;

...

Listing 14-42ui/browser/App.jsx: Transfer User Data to Store

接下来,在服务器呈现调用过程中,让我们获取用户数据,并将其提供给模板,以构建浏览器中可用的用户数据变量。在这样做的时候,我们必须将 cookie 从请求头传递到fetchData()调用。服务器渲染的变化如清单 14-43 所示。

...
  if (activeRoute && activeRoute.component.fetchData) {
    ...
    initialData = await activeRoute.component
    .fetchData(match, search, req.headers.cookie);
  }

  const userData = await Page.fetchData(req.headers.cookie);

  store.initialData = initialData;
  store.userData = userData;
...

    res.send(template(body, initialData, userData));
...

Listing 14-43ui/server/render.jsx

注意,我们将 cookie 包含在所有 fetchData()请求的中,包括活动路由组件的fetchData()调用。如前所述,这不是必需的,因为这些调用不会受到凭证存在的影响。然而,为了保持一致性,并让将来可能使用凭证的视图的修改以不同的方式显示内容,让我们保持这一点。

现在,如果您测试应用,您会发现菜单项的闪烁不再存在。虽然这不是一个很好的用例,可以用引入的所有复杂性来解决,但它可以作为一种模式,用于在服务器呈现时包含真正的全局数据。

在前两节中,我介绍了 cookies 的一个重要方面,当从浏览器直接访问 API 时,它就会发挥作用。因为我们使用localhost作为访问应用的域,所以它可以无缝地工作。

需要注意的是,当涉及到跨站点请求时,cookies 和 CORS 的工作方式略有不同。CORS 甚至会将端口差异视为不同的来源,而服务器设置的 cookie 与绑定在一起,浏览器对同一域的所有请求都将包含 cookie。例如,由localhost:3000设置的 cookie 也将被发送到localhost:8000。cookie 策略忽略端口的差异。

如果您在开发人员工具中检查网络流量,您会发现 JWT cookie 被传递给对localhost:8000(UI 服务器)的请求,即使它是由端口 3000 上的 API 服务器通过signin API 设置的。这使得我们能够在服务器呈现期间通过 UI 服务器将凭证传递给 API 服务器。

为了测试这是否也适用于真实的域,您需要创建一个域和两个子域,它们都指向本地主机。这可以通过编辑hosts文件并添加以下行来完成:

...
127.0.0.1 api.promernstack.com ui.promernstack.com
...

(在 MacOS 和 Linux 上,这个文件可以在/etc/hosts找到,在 Windows PC 上可以在c:\Windows\System32\Drivers\etc\hosts找到。)

现在,您可以设置环境变量并配置您的 UI 服务器,以便 API 端点基于api.promernstack.com:3000,然后使用ui.promernstack.com:8000访问应用。清单 14-44 显示了ui目录中sample.env的变化。

...
# Regular config
# UI_API_ENDPOINT=http://localhost:3000/graphql
# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Regular config with domains

UI_API_ENDPOINT=http://api.promernstack.com:3000/graphql

UI_AUTH_ENDPOINT=http://api.promernstack.com:3000/auth

...

Listing 14-44ui/sample.env: Changes for Using Domain Names

您还需要在 Google 开发者控制台的允许来源列表中添加 UI 服务器的 URL ui.promernstack.com来测试这一点;否则,您将无法使用 Google 登录。此外,API 服务器需要将 CORS 原点设置为 UI 的新 URL:ui.promernstack.com:8000

...
UI_SERVER_ORIGIN=http://ui.promernstack.com:8000
...

在此之后,您会发现认证和授权仍然不起作用。特别是,登录可以工作,但是浏览器刷新会丢失凭证。

那是因为 cookie 域的默认域设置是 URL 的主机部分,也就是api.promernstack.com。cookie 将而不是作为对ui.promernstack.com的调用中的请求的一部分发送(如您在开发工具网络选项卡中所见)。为了让两个应用共享 cookie,我们需要将 cookie 的域设置为基本域名,也就是说,不包含子域apiui。让我们在响应/signinres.cookie()调用中这样做。让我们从名为COOKIE_DOMAIN的环境变量中获取域。这显示在清单 14-45 中。

...
res.cookie('jwt', token, { httpOnly: true, domain: process.env.COOKIE_DOMAIN });
...

Listing 14-45api/auth.js: Changes for Setting Cookie Domain

至于设置变量本身,你应该在你的.env文件中完成。清单 14-46 中显示了一个示例更改,以及我们之前对UI_SERVER_ORIGIN所做的更改。

...

UI_SERVER_ORIGIN=http://localhost:8000

UI_SERVER_ORIGIN=http://ui.promernstack.com:8000

COOKIE_DOMAIN=promernstack.com

...

Listing 14-46api/sample.env: New Variable for COOKIE_DOMAIN and Setting UI_SERVER_ORIGIN

现在(在重启服务器之后,因为环境变量已经改变了),您会发现身份验证工作正常,因为 API 调用中设置的 cookies 被用于对 UI 服务器的请求。

但是,如果您需要不同的域,例如,如果您需要在 API 处于api.promernstack.com:3000时使用localhost:8000访问应用,您会发现凭证是而不是发送到 API 服务器的。这是因为域是不同的(localhostpromernstack.com)。要求两台服务器位于两个不同的域(而不是子域)的情况非常少见。但是如果真的需要,最好的选择是使用代理操作模式,在这种模式下,浏览器只能看到 API 和 UI 的一个域。

摘要

有许多方法来验证用户,不同的应用有不同的需求。对于问题跟踪器应用,我们使用 Google 来验证用户。注册和认证用户需要不同的方法,比如使用用户 ID,但是一旦通过认证,你在本章中学到的其他概念仍然适用。

您看到了如何使用 JWT 以无状态、安全的方式持久保存会话信息。然后,您看到了授权如何与 GraphQL APIs 一起工作,以及如何扩展它以根据应用的需求执行不同的授权检查。您还看到了当浏览器直接访问 API 时,浏览器上的 CORS 和 cookie 处理限制是如何发挥作用的。

所有这些都是在您自己的计算机上运行应用时完成的。为了使应用对其他用户可用,它需要托管在外部服务器上。在下一章中,我们将看看如何在云上使用平台即服务(PaaS)来实现这一点,Heroku。

练习答案

练习:Google 登录

  1. 是的,个人资料图片是可用的,可以通过调用基本个人资料的getImageUrl()函数获得。

练习:授权

  1. As the section titled “Schema Authorization” explains, a simple way to achieve this is to throw an exception in the getContext() function itself, if the user is not signed in.

    ...
    function getContext({ req }) {
      const user = auth.getUser(req);
      if (!user || !user.signedIn) throw new AuthenticationError('you must be logged in');
      return { user };
    },
    ...
    
    

    这样,调用甚至不会到达任何解析器函数。

练习:对上下文做出 React

  1. 正如博文中所建议的,您将需要使用Route组件的render属性,而不是component属性。属性接受一个以props作为参数的函数。使用提供的道具,您可以通过传入这些道具以及您需要传入的任何其他附加道具来构造一个组件。因此,如果您必须将user作为道具传递,这就是Context.jsx中的代码看起来的样子。

    ...
          {routes.map(attrs => (
            <Route path={attrs.path} key={attrs.path}
              render={(props) => <attrs.component {...props} user={user} />}
            />
          ))}
    ...
    
    

十五、部署

有很多方法可以在云上部署问题跟踪器。你可以选择自己动手,比如在 Amazon AWS、Google Cloud 和 Microsoft Azure 上运行你自己的基于 Linux 的实例,并在这些实例上运行 Node.js 和 MongoDB,就像你在本书中在本地计算机上所做的一样。

但是我发现平台即服务(PaaS)选项更容易部署和维护。因此,在这一章中,我选择了最受欢迎的 PaaS 之一,Heroku。在为您的生产应用做出选择之前,您可以尝试一个免费层。在这一章中,我将指导你在 Heroku 上部署问题跟踪器应用。

Git 仓库

在 Heroku 上部署应用最简单的方法是使用 Git 存储库。到目前为止,我们还没有讨论过如何在团队中控制和共享问题跟踪器源代码以进行协作。您已经在本地计算机上创建了所有文件,并且很可能还没有使用存储库。现在是这么做的好时机,因为我们无论如何都需要在 Heroku 上部署。您可以使用 CVS、SVN、Git 或任何其他现代源代码控制系统,但是由于 Heroku 需要 Git,我们也将使用相同的系统来满足问题跟踪器应用的协作需求。

我们将要创建的存储库不应该与本书附带的存储库混淆,在 https://github.com/vasansr/pro-mern-stack-2 。这本书的存储库已经以一种方便的方式进行了组织,以便跟踪书中的代码更改,但它对于现实生活中的应用来说并不理想。因此,不要克隆或派生这个存储库;相反,从我们到目前为止编写的代码开始。

你可以使用 GitHub 或 BitBucket 或任何云 Git 服务,甚至你自己的托管 Git 服务器。我假设 GitHub 用于下面的说明。其他服务的说明即使不完全相同,也是相似的。

让我们从在 GitHub 中创建这些存储库开始。在 https://github.com 登录 GitHub(如果您没有帐户,请创建一个),使用用户界面,探索如何创建存储库。我们有两个可部署的应用:API 服务器和 UI 服务器。我们将需要两个存储库,每个存储库一个。让我们称他们为tracker-apitracker-ui。您还需要 Git 命令行实用程序:git。我不会详细介绍如何安装git或者设置它来访问 GitHub。做这些有不同的选择,你会在互联网上找到很多资源,GitHub 网站本身也会帮助你。

从 Git 命令行访问 GitHub 存储库有两种选择:使用 SSH 或使用 HTTPS。在下面的说明中,我将假设您已经使用 SSH 设置了 Git 进行访问。如果您喜欢 HTTPS,那么您必须在下面的命令中相应地更改存储库的远程 URL。

创建存储库之后,让我们用当前的代码库初始化它们。首先,让我们来处理 API 代码库。首先,我们需要在 API 目录中初始化 Git。以下命令将完成这项工作:

$ cd api
$ git init

接下来,我们需要添加所有由 Git 管理的文件。您可以手动添加每个文件。但是使用名为.gitignore的文件更容易指定要排除哪些文件,所以我们就这么做吧。API 目录下该文件的内容如清单 15-1 所示。列表

node_modules
.env

15-1api/.gitignore: List of Files to Exclude from Git Management

现在,要添加 API 目录中的所有文件,不包括清单 15-1 中显示的文件,让我们执行以下操作:

$ git add .
$ git commit -m "First commit"

在这一章中,会有很多情况需要用到你的 GitHub 用户名。定义一个环境变量会很方便,这样命令就可以从书或书的 GitHub 库中复制粘贴,而无需修改。为此,让我们使用一个名为GITHUB_USER的环境变量。此时,您需要将这个变量设置为您的 GitHub 用户名。

下面的命令假设一台 Linux 计算机或一台 Mac 计算机,使用$GITHUB_USER访问环境变量。在 Windows PC 上,您需要使用%GITHUB_USER%来代替,或者用您的 GitHub 用户名替换变量名。这同样适用于我们将在本章中使用的其他变量。

现在,让我们将代码推送到 GitHub:

$ git remote add origin git@github.com:$GITHUB_USER/tracker-api.git
$ git push -u origin master

在 Git 中添加一个 remote 会在本地存储库和远程存储库之间建立一个链接,在本例中,远程存储库位于 GitHub 上。成功之后,您应该能够在 GitHub 网站中看到源代码。检查并确保所有代码,包括脚本子目录,都已创建。

UI 目录也需要类似的步骤。但是要排除的文件是不同的。除了目录node_modules和文件.env之外,我们还有通过编译生成的文件,这些文件不需要签入 Git 存储库。这些是整个dist目录和public目录中的.js.js.map文件。让我们包含这些并创建一个.gitignore文件,其内容在清单 15-2 中列出。列表

dist
node_modules
.env
public/*.js
public/*.js.map

15-2ui/.gitignore: Files to Exclude from the UI Directory

现在,为了初始化、添加文件并将 UI 目录推送到 GitHub,让我们按照创建存储库后出现的 GitHub 提示符中的描述执行以下命令:

$ cd ui
$ git init
$ git add .
$ git commit -m "First commit"
$ git remote add origin git@github.com:$GITHUB_USER/tracker-ui.git
$ git push -u origin master

此时,您可以在 GitHub 上在线浏览 UI 存储库,以确保文件确实已经被推送到 GitHub。

MongoDB

在我们在 Heroku 上部署服务器之前,让我们首先确保在云上有一个 MongoDB 数据库。请务必重新阅读第 6“MongoDB”中题为“安装”的章节。如果您已经在云上使用了 MongoDB 数据库,那么就没什么可做的了。如果没有,选择该章中描述的云选项之一,并按照说明在云上创建一个数据库。

将连接 URL 以及用户 ID 和密码放在手边会很方便,所以让我们为此设置一个名为DB_URL的环境变量,并在下面的命令中使用它。如果您刚刚从本地数据库转移到云上的新数据库,您还需要初始化数据库。为此,请使用以下命令:

$ cd api
$ mongo $DB_URL scripts/init.mongo.js
$ mongo $DB_URL scripts/generate_data.mongo.js

赫罗库

首先要做的是创建一个 Heroku 账户,如果你还没有的话。这可以从 https://heroku.com 开始。一旦你有了登录账号,你就可以从 https://devcenter.heroku.com/articles/heroku-cli#download-and-install 安装 Heroku CLI。我们将为部署执行的大多数命令也可以从 Heroku web 用户界面获得。但是使用 CLI,更容易按照本书中的说明执行命令。

安装后使用 CLI 的第一件事是登录。

$ heroku login

这应该会响应以下提示,这将打开浏览器以获取登录信息。

heroku: Press any key to open up the browser to login or q to exit:

如果您已经通过 web 用户界面登录 Heroku,登录应该是自动的。否则,它可能会提示您输入用户 ID 和密码。登录后,您应该会在控制台中看到以下内容。

Logging in... done
Logged in as YOUR_MAIL_ID

在接下来的小节中,我们将在 Heroku 上创建和部署 API 应用,然后是 UI 应用。

API 应用

部署 API 应用需要对应用进行一些更改,这是 Heroku 所期望的。

首先,应用可以监听的端口由 Heroku 动态分配。原因是每个应用都部署在一个容器中,而不是一个专用的主机中。因此,Heroku 为应用分配一个端口,并为该应用向该端口发送流量。但是,在互联网上,同一个端口反映为 HTTP (80)或 HTTPS (443)端口。Heroku 有一个防火墙可以做到这一点。Heroku 然后设置一个环境变量,让应用知道容器将从哪个端口接收流量。这个环境变量简称为PORT。这是应用必须监听的端口。

所以,让我们把我们一直使用的环境变量API_SERVER_PORT改成PORTserver.js的变化如清单 15-3 所示。列表

...
const port = process.env.API_SERVER_PORT || 3000;
...

15-3api/server.js: Change in Environment Variable Name for Server Port

如果你一直在使用一个.env,在那个文件中也必须做一个类似的改变,但是这只是为了在你的本地计算机上测试。它不会影响 Heroku 部署,因为在 Heroku 中没有使用.env设置环境变量。清单 15-4 显示了示例.env文件中的相同变化。列表

...
## Server Port
API_SERVER_PORT=3000
...

15-4api/sample.env: Change in Variable Name for Server Port

在生产环境中运行时,Apollo server 默认禁用 Playground。但是有操场是好的,因为数据是公开的,让我们启用它。这需要在创建 Apollo 服务器时修改代码来设置选项,如清单 15-5 所示。列表

...
const server = new ApolloServer({
  ...
  playground: true,
  introspection: true,
});
...

15-5api/api_handler.js: Enable GraphQL Playground in Production

Heroku 部署应用并确定环境、语言等。使用自动检测算法。存储库中存在的package.json足以让 Heroku 检测到这是一个 Node.js 环境。但是由于有不同版本的 Node.js 引擎可供 Heroku 使用,我们需要告诉 Heroku 我们的应用需要在哪个版本上运行。此外,由于我们将在运行之前安装软件包,我们还需要指定我们想要使用的 npm 版本。方法是在package.json中设置engines属性。这一变化如清单 15-6 所示。列表

...
  "engines": {
    "node": "10.x",
    "npm": "6.x"
  },
  "scripts": {
...

15-6api/package.json: Engine Specification for Heroku

为了使这些改变永久化,让我们提交它们。但是提交只会影响本地存储库。将这些更改也推送到 GitHub remote 是一个好主意,这样您团队中的其他人也可以获得这些更改。

$ cd api
$ git commit -am "Changes for Heroku"
$ git push origin master

现在,我们准备在 Heroku 上部署应用。正如 Heroku 文档中所建议的,我们首先需要创建并初始化应用。为此需要使用 Heroku CLI 命令create。应用的名称需要是通用的,所以让我们使用您的 GitHub 用户名作为应用名称的一部分,尽量减少所选名称不可用的可能性。

$ heroku create tracker-api-$GITHUB_USER
Creating tracker-api-GITHUB_USER... done
https://tracker-api-GITHUB_USER.herokuapp.com/ | https://git.heroku.com/tracker-api- GITHUB_USER.git

相反,如果它显示一个应用名称已被使用的错误,您将不得不为该应用尝试不同的名称。当它成功时,创建将在 Heroku 上添加一个 Git 远程存储库,这个存储库在本地将被称为heroku,推送到这个存储库将具有部署应用的效果。

但是在此之前,让我们设置 API 服务器需要的环境变量。我们需要数据库的网址和 JWT 的秘密。至于 cookie 域,我们先设置为 API 服务器和 UI 服务器的域的共有部分,也就是herokuapp.com

$ heroku config:set \
  DB_URL=$DB_URL \
  JWT_SECRET=YOUR_SPECIAL_SECRET \
  COOKIE_DOMAIN=herokuapp.com

这个命令假设一台 Linux 或 Mac 计算机。当使用 Windows PC 时,你将不得不对变量使用%DB_URL%语法,并在一行中键入整个命令,而不是使用多行并在结尾使用\字符。

注意,我们不需要设置PORT变量,因为它是在启动应用时由 Heroku 设置的,这是我们可以使用的唯一端口。现在,我们可以通过对 Heroku remote 进行简单的 Git 推送操作,将应用部署到云上。

$ git push heroku master

这应该会在控制台上显示大致如下的输出:

remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
...

remote: -----> Installing binaries
remote:        engines.node (package.json):  10.x
remote:        engines.npm (package.json):   6.x
remote:
...

remote:
remote: -----> Building dependencies
remote:        Installing node modules (package.json + package-lock)
...

remote: -----> Pruning devDependencies
remote:        removed 126 packages and audited 2780 packages in 6.746s
remote:        found 0 vulnerabilities
...

remote: -----> Build succeeded!
remote:        Released v4
remote:        https://tracker-api-$GITHUB_USER.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.

你应该仔细阅读这些信息,确保没有任何差错或意外。在这一点上特别感兴趣的是消息:

remote:        Installing node modules (package.json + package-lock)

这意味着 Heroku 在从 Git 存储库复制文件之后,也在目标目录上运行了npm install。由于 Git 存储库中也有package.jsonpackage-lock.json文件,它安装的版本也会自动匹配您在开发过程中使用的版本。

注意

Heroku 也安装了在devDependencies中列出的包,但是后来删除了这些包,这可以从消息Pruning devDependencies中看到。

如果一切正常,您应该会看到最后一行,表示部署已经验证并完成。现在,您可以通过使用操场来测试 API。这也将确保 API 服务器可以连接到云上的 MongoDB 服务器。要访问游乐场,请浏览到https://tracker-api-$GITHUB_USER.herokuapp.com/graphql(用您的 GitHub 用户 ID 替换$GITHUB_USER)。

您也可以只在控制台中键入heroku open,浏览器选项卡或窗口应该会打开,显示以前的 URL,但没有/graphql路径,导致出现“无法获取/”的消息。一旦窗口打开进入游乐场,你将需要追加/graphql

如果事情似乎不工作,要排除故障,您可以通过执行以下命令行来查看服务器的控制台输出:

$ heroku logs

API 服务器的正常成功启动应该如下所示:

2018-12-30T12:20:34.841550+00:00 app[web.1]: > pro-mern-stack-2-api@1.0.0 start /app
2018-12-30T12:20:34.841552+00:00 app[web.1]: > nodemon -e js,graphql -w . -w .env server.js
2018-12-30T12:20:35.498072+00:00 app[web.1]: [nodemon] 1.18.9
2018-12-30T12:20:35.500474+00:00 app[web.1]: [nodemon] to restart at any time, enter `rs`
2018-12-30T12:20:35.501650+00:00 app[web.1]: [nodemon] watching: *.* .env
2018-12-30T12:20:35.502464+00:00 app[web.1]: [nodemon] starting `node server.js`
2018-12-30T12:20:37.028765+00:00 app[web.1]: CORS setting: true
2018-12-30T12:20:38.639869+00:00 heroku[web.1]: State changed from starting to up
2018-12-30T12:20:38.512917+00:00 app[web.1]: Connected to MongoDB at mongodb+srv://UUU:PPP@XXX.mongodb.net/issuetracker?retryWrites=true
2018-12-30T12:20:38.523184+00:00 app[web.1]: API server started on port 46837

用户界面应用

UI 服务器需要 UI 服务器中类似的一组步骤。首先,让我们更改设置监听端口的变量的名称。清单 15-7 和 15-8 中显示了所需的更改。列表

...
const port = process.env.UI_SERVER_PORT || 8000;
...

15-7ui/server/uiserver.js: Change in Name of PORT Environment Variable

...
UI_SERVER_PORT=8000
...

Listing 15-8ui/sample.env: Change in Name of PORT Environment Variable

在 API 应用中,源代码足以让服务器启动并运行服务器。UI 应用是不同的,因为它需要在启动服务器所需的文件准备好之前进行编译。还需要将 Bootstrap 的静态 CSS 和 JavaScript 文件链接或复制到public目录。

有两个 npm 脚本可以实现这一点。脚本postinstall是在npm install结束后立即运行的脚本。这是一个特定于 Node.js 的脚本,它将由 npm 自动运行。因此,当开发人员在本地运行npm install以及 Heroku 在部署后运行npm install时,它都会生效。另一个剧本是heroku-postbuild,是针对 Heroku 的。也就是说,这个脚本只在 Heroku 部署上运行,而不是当开发人员在本地计算机上运行npm install时。

对于开发人员来说,安装后运行编译是浪费时间,因为他们通常会通过 Webpack 使用 HMR。此外,将 Bootstrap 文件链接到 public 将意味着我们必须假设它是一台 Mac 或 Linux 计算机。所以,让我们只做脚本heroku-postbuild中的这两个步骤。此外,我们需要对package.json进行更改,以指定 Node.js 和 npm 版本,就像我们对 API 服务器所做的那样。清单 15-9 显示了对package.json的所有更改。列表

...
  "main": "index.js",
  "engines": {
    "node": "10.x",
    "npm": "6.x"
  },
...
    "dev-all": "rm dist/* && npm run watch-server-hmr & sleep 5 && npm start",
    "heroku-postbuild": "npm run compile && ln -fs ../node_modules/bootstrap/dist
    public/bootstrap"
...

15-9ui/package.json: Changes for Specifying engine, postinstall, and post-build

为了使更改永久化,让我们将它们提交到本地 Git 存储库,并将它们推送到 GitHub remote。

$ cd ui
$ git commit -am "Changes for Heroku"
$ git push origin master

现在,我们准备部署 UI 服务器,但是首先我们需要在 Heroku 上创建应用。让我们称这个应用为tracker-ui-GITHUB_USER并创建它。

$ heroku create tracker-ui-$GITHUB_USER

在我们通过将服务器推到 Heroku remote 来启动它之前,我们需要配置 UI 服务器需要的 API 和认证端点。我们已经在 shell 中设置了GITHUB_USER环境变量,因此使用它,让我们设置这些配置变量。对于 Google 认证,我们还设置了另一个变量,叫做GOOGLE_CLIENT_ID。将这个变量设置为从 Google 开发人员控制台获得的 Google 客户端 ID。

$ heroku config:set \
  UI_API_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/graphql \
  UI_AUTH_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/auth \
  GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID

部署服务器与我们部署 API 服务器是一样的。我们需要把储存库推到 Heroku 遥控器上。

$ git push heroku master

在部署日志中,您应该会看到与 API 服务器部署非常相似的消息。但重要的是,您还应该看到这样的编译步骤:

...
remote: -----> Building dependencies
remote:        Installing node modules (package.json + package-lock)
...

remote:        Running heroku-postbuild
remote:
remote:        > pro-mern-stack-2-ui@1.0.0 heroku-postbuild ↲ /tmp/build_605a3a265a979f27ab6e5296a8297eb9
remote:        > npm run compile && ln -fs ../node_modules/bootstrap/dist public/bootstrap
remote:
remote:
remote:        > pro-mern-stack-2-ui@1.0.0 compile ↲ /tmp/build_605a3a265a979f27ab6e5296a8297eb9
remote:        > webpack --mode production
remote:
remote:        Hash: 0288037a5cd24d5397fc7b520cbaa24cafcace5c
...

remote: -----> Caching build
remote:        - node_modules
remote:
remote: -----> Pruning devDependencies
remote:        removed 454 packages and audited 3507 packages in 8.764s
remote:        found 0 vulnerabilities
remote:
...

remote: -----> Launching...
remote:        Released v1
remote:        https://tracker-ui-$GITHUB_USER.herokuapp.com/ deployed to Heroku
remote:
remote:        Verifying deploy... done.

一旦应用启动并运行,您应该能够查看主要问题页面。但是在导航到任何其他页面时,您应该会看到一个 CORS 错误。这是因为 API 服务器没有被设置为UI_SERVER_ORIGIN,它将被默认为http://localhost:8000。现在我们知道了 UI 服务器的 URL,让我们在 API 服务器中设置它。

$ cd api
$ heroku config:set \
  UI_SERVER_ORIGIN=https://tracker-ui-$GITHUB_USER.herokuapp.com

现在,如果您浏览到应用 URL(或者,只需在ui目录中键入heroku open,您应该会看到问题跟踪器 UI,其中加载了初始问题集。您还应该能够成功导航到其他页面。这些其他页面也应该在该页面上刷新浏览器时工作。

但是你会发现这只对未经认证的用户有效。由于正在使用新域以及 CORS 和 cookie 的考虑,此时登录将不起作用。但是在我们解决这个问题之前,让我们看看代理模式是否有效。

代理模式

代理模式应该看起来正常工作,因为没有 CORS 或 cookie 的考虑。让我们通过设置相应的环境变量来设置代理模式。

$ cd ui
$ heroku config:set \
  UI_API_ENDPOINT=https://tracker-ui-$GITHUB_USER.herokuapp.com/graphql \
  UI_AUTH_ENDPOINT=https://tracker-ui-$GITHUB_USER.herokuapp.com/auth \
  UI_SERVER_API_ENDPOINT=https://tracker-api-$GITHUB_USER.herokuapp.com/graphql \
  API_PROXY_TARGET=https://tracker-api-$GITHUB_USER.herokuapp.com

现在,如果您尝试应用,第一个页面加载将成功,但是在导航到其他视图时,您将看到浏览器呈现的请求将超时,并且在开发人员工具的 Network 选项卡中,您将发现 API 调用失败。如果您尝试使用代理 URL https://tracker-ui-$GITHUB_USER.herokuapp.com/graphql的 Playground,您将会看到一个错误页面,同样是因为对 API 服务器的代理请求失败。发生这种情况是因为 Heroku 路由 HTTP 请求方式。

事实上,许多 web 应用在 Heroku 上共享相同的资源。不仅仅是计算资源,它们还共享 IP 地址。因此,有必要为每个请求指定该请求应该到达哪个应用,以便它可以被路由到适当的应用。这是使用 HTTP 请求中的Host头来完成的。这项技术被称为基于名字的虚拟主机,它也受到流行的 Web 服务器如 Apache 和 nginx 的支持。

浏览器会自动将这个头设置为 URL 的主机名部分,因此从浏览器到 API 服务器或 UI 服务器的任何请求都会被 Heroku 路由到正确的应用。但是当代理请求时,http-proxy-middleware不会自动这样做。默认情况下,它使用从浏览器接收到的原始Host头,并将其复制到对 API 服务器的请求中。

因此,当浏览器发起请求说https://tracker-ui-$GITHUB_USER.herokuapp.com/graphql时,会发生以下一系列事件:

  1. 浏览器解析出tracker-ui-$GITHUB_USER.herokuapp.com的 IP 地址,这是所有 Heroku 应用共享的众多常用 IP 地址之一。

  2. 浏览器将Host头设置为tracker-ui-$GITHUB_USER.herokuapp.com

  3. Heroku 查看Host头并将请求路由到 UI 服务器。

  4. 代理中间件拦截该请求(因为目标是/graphql),并尝试将其转发给tracker-api-$GITHUB_USER.herokuapp.com

  5. 代理中间件为 API 服务器解析主机,这也会产生一个公共的 IP 地址。

  6. 代理中间件向 IP 地址转发相同的请求,即Host头为tracker-ui-$GITHUB_USER.herokuapp.com

  7. Heroku 接收请求,查看Host头,并将其路由到 UI 服务器!这将导致无限循环,直到请求超时。

我们在本地计算机上尝试代理模式的原因是没有基于虚拟主机的路由。所有对http://localhost:3000的请求都直接登陆到 API 服务器,这不会导致任何问题。

让它在 Heroku 中正确工作的补救方法是改变http-proxy-middleware的行为。我们需要它,以便将Host头设置为与目标 URL 相同,而不是原始头。代理中间件选项中的标志changeOrigin控制这种行为,我们需要做的就是将它设置为true。清单 15-10 显示了此次修复对uiserver.js的更改。列表

...
if (apiProxyTarget) {
  app.use('/graphql', proxy({ target: apiProxyTarget, changeOrigin: true }));
  app.use('/auth', proxy({ target: apiProxyTarget, changeOrigin: true }));
}
...

15-10ui/server/uiserver.js: Change Origin When Proxying Requests

现在,如果您提交这些更改,并使用git push heroku master将它们推送到 Heroku,您会发现应用工作得非常好。您应该确认身份验证有效,并且在浏览器刷新时保持有效。

非代理模式

即使两个应用有相同的域(heroku.com),非代理操作模式也不起作用。浏览器不会在这两个应用之间共享 cookie,因为heroku.com列在公共后缀列表中。这个列表正是针对这样的情况,你可以在 https://publicsuffix.org/learn/ 了解更多。

虽然一开始看起来很不方便,但你现在可能已经意识到,如果你的两个应用可以共享同一个 cookie,那么它也会在 Heroku 上的所有其他应用之间共享,甚至是那些属于其他 Heroku 用户的应用。当然,这一点也不安全。因此,Heroku 通过将域名herokuapp.com添加到公共后缀列表中,确保了该域名与顶级域名处于同等地位。这导致浏览器拒绝域为herokuapp.comset-cookie报头。

在 UI 和 API 服务器之间共享 cookies 的唯一方法是使用自定义域。然后,该域上的子域可以用作 UI 和 API 服务器,从而支持共享 cookies,使非代理操作模式能够工作。因此,您必须为自己创建一个自定义域,以便在 Heroku 上尝试非代理操作模式。

有很多方法可以注册自己的域名,包括免费的。选择一个并创建一个域。完成之后,创建一个名为CUSTOM_DOMAIN的环境变量,并将其设置为该域。例如,如果您已经将myfreedomain.tk注册为您的自定义域名,则将CUSTOM_DOMAIN设置为myfreedomain.tk,包括顶级域名.tk。在下面的命令中,我们将使用ui.$CUSTOM_DOMAINapi.$CUSTOM_DOMAIN作为 UI 和 API 应用的子域。

使用自定义域的一个缺点是 Heroku 默认情况下不支持 SSL,或者免费。您需要升级到 Heroku 上的付费帐户,才能为您的自定义域启用 SSL。因此,在本章的剩余部分,我们将使用基于http://而不是https://的 URL。

首先,您必须授权新的基于域的 URL 作为 Google Developer 项目中授权的 JavaScript 源。原点会是http://ui.$CUSTOM_DOMAIN。这还需要您在 Google developer console 的 OAuth 同意屏幕选项卡中添加一个授权域作为$CUSTOM_DOMAIN。注意,添加 JavaScript 源后,需要一段时间才能生效。如果你计划在将来使用 SSL,在 origin now 本身中添加https://版本可能是个好主意。

接下来,我们需要将这些域添加到 Heroku,以便它能够识别这些域,并将发往这些域的 HTTP 流量定向到我们创建的 Heroku 应用。让我们首先在 UI 应用中这样做。

$ cd ui
$ heroku domains:add ui.$CUSTOM_DOMAIN

这将显示如下所示的输出:

Adding ui.$CUSTOM_DOMAIN to tracker-ui-$GITHUB_USER ... done
 ▸    Configure your app's DNS provider to point to the DNS Target
 ▸    sheltered-tor-u2t67pge87ki9sbr6iqw1h.herokudns.com.
 ▸    For help, see https://devcenter.heroku.com/articles/custom-domains

按照控制台输出的指示,您需要配置 DNS,以便对ui.$CUSTOM_DOMAIN的请求到达 Heroku 托管的 UI 应用。这需要在您的域提供商中使用他们的 UI 来完成。您将需要创建一个 CNAME 记录,这将为一个域创建一个别名。本质上,您需要将自定义域映射为真实域的别名,例如 Heroku 自动为应用分配的sheltered-tor-u2t67pge87ki9sbr6iqw1h.herokudns.com

然后,我们需要将 API 应用的域添加到 Heroku:

$ cd api
$ heroku domains:add api.$CUSTOM_DOMAIN

然后,您需要在域提供商的记录中设置 DNS 别名映射,就像您对 UI 服务器所做的那样。如果您使用 GoDaddy 托管您的域,您应该会在域管理器中看到类似图 15-1 的屏幕。

img/426054_2_En_15_Chapter/426054_2_En_15_Fig1_HTML.jpg

图 15-1

为 ui 和 api 创建记录后域管理器的屏幕截图

接下来,让我们将 UI 应用的 API 和auth端点设置为新的基于定制域的 API 服务器的 URL,同时我们切换到非代理操作模式。

$ cd ui
$ heroku config:set \
  UI_API_ENDPOINT=http://api.$CUSTOM_DOMAIN/graphql \
  UI_SERVER_API_ENDPOINT=http://api.$CUSTOM_DOMAIN/graphql \
  UI_AUTH_ENDPOINT=http://api.$CUSTOM_DOMAIN/auth
$ heroku config:unset \
  API_PROXY_TARGET

最后,为了让 CORS 正常工作,并在 UI 和 API 应用之间共享 cookies,我们需要如下配置 API 服务器:

$ cd api
$ heroku config:set \
  UI_SERVER_ORIGIN=http://ui.$CUSTOM_DOMAIN \
  COOKIE_DOMAIN=$CUSTOM_DOMAIN

服务器现在会自动重启,如果您测试应用,它应该像在代理模式下一样无缝地工作。

摘要

尽管我们让问题跟踪器应用在您的本地计算机上工作,但将其部署到云,尤其是部署到平台即服务,会带来一些挑战。

在代理模式下,当代理请求时,我们必须正确设置http-proxy-middleware来改变Host头。在非代理模式中,您了解到使用默认应用域不起作用,我们必须使用自定义域。我们还必须对代码进行更改,但是这些更改也与本地开发的应用兼容。

我们提到了 Git 和版本控制,并且我们为存储库使用了两个遥控器:一个用于 GitHub 上的团队协作,另一个用于 Heroku。这只是管理版本和发布的一种方式。如果你在一个团队中工作,想要协作,你可以在 GitHub remote 中这样做,当发布的时候,你可以把它推到 Heroku remote 中。我没有深入研究管理发布的其他选项,因为这些都是您的项目特有的,也不是真正的 MERN 主题。

当我们接近这本书的结尾时,让我们在下一章讨论 MERN 堆栈还能做什么。我们将停止改变应用,只看其他相关的技术和库,可能对你的 MERN 项目有用。

十六、展望未来

我希望到现在为止,我已经成功地在你的脑海中植入了 MERN 堆栈的基本原理。更重要的是,我希望我已经让你能够更上一层楼,因为这绝不是问题跟踪器应用或你心目中的任何其他项目的终结。如果你真的试着回答了每一章的练习,你现在应该知道在哪里可以得到更多的信息。

可以添加更多的功能,可以使用更多的技术来帮助您向前发展。在这一章中,如果你决定在实际项目中使用 MERN,我会提到一些你可能会考虑的技术。但是这仅仅是对可能性的简单介绍;到目前为止,我们不会向我们创建的应用添加任何代码。

请注意,这些新事物不一定适合您的应用。当您遇到困难或者希望随着应用的增长自动处理一些重复的代码时,您需要仔细评估它们。本书的前几章应该已经给了你足够的信心,让你可以使用 MERN 堆栈,并手动或自己解决所有问题。但是在很多情况下,其他人面临类似的问题,并创建了库来解决它们。我建议你寻找一个现有的解决方案,但是要等到你清楚你想要解决的是什么。

猫鼬

大多数使用关系数据库的技术栈可以用对象关系映射(ORM)库来补充。这些增加了一个抽象层,让开发人员看到对象本身,而不是带有行和列的表。

对于 MongoDB,乍一看,似乎没有必要将关系映射到数据库在内存中存储到对象的方式,因为对象会自然地映射到 MongoDB 文档,而无需中间层的转换。但是对象文档映射(ODM)层还可以提供其他功能。mongose(https://mongoosejs.com)是 MongoDB 的一个流行的 ODM 库,它提供了以下内容:

  • 模式定义:这很有用,因为 MongoDB 不像 SQL 数据库那样强制执行模式。对于 SQL 数据库,数据库会自动捕获模式错误,而我们忽略了问题跟踪器应用的模式验证。使用 Mongoose,您可以定义模式,并根据模式自动验证新文档。从 3.6 版本开始,MongoDB 本身支持模式,但是与 Mongoose 相比,这种支持似乎很原始。

  • 验证:在必需的检查和数据类型检查方面,Mongoose 拥有比 GraphQL 更多的内置验证器。这些包括字符串长度和数字的最小和最大检查。此外,您可以添加自定义验证器,如电子邮件 ID 验证器,并跨对象类型重用它们。

  • 同构:有一个浏览器组件允许在浏览器中使用模式验证。这可以在用户提交表单之前在 UI 中显示错误,从而提高可用性。

  • 模型:虽然我们封装了issue.js中与问题相关的所有代码,但是我们并没有将函数附加到问题对象上。使用 Mongoose,您可以编写真正面向对象的模型,可以在对象中封装数据和方法。模型也让开发人员更直观地编写代码,例如,使用Object.save()而不是db.collection.insertOne()

对于较小的项目,如问题跟踪器,可能不需要 Mongoose。如果需要,您可以轻松地提取和共享issue.js中的验证,以重用代码。但是对于多人在一个团队中工作的大型项目,使用 Mongoose 肯定会避免开发过程中的错误,并作为对象模式的文档,这对团队的新人尤其有帮助。

流量

如果你读过 React,很可能你也听说过 Redux 和/或 Flux 模式。由于它的受欢迎程度,很容易就开始使用它。但在找到解决方案之前,让我们先看看有哪些改进的机会。

当我们添加用户登录时,我们发现我们必须将用户信息和登录操作沿着组件层次结构向上传输,然后再向下传输到使用它的组件。对于介于两者之间、不需要知道信息的组件来说,这似乎有点浪费(以及不必要的耦合增加)。例如,组件NavBar本身对用户名或登录状态没有什么用处。它所做的只是将知识向下传递给SignInNavItem

我们通过对真正的全局状态变量使用 React 上下文解决了这个问题。此外,为了初始化,我们还创建了一个全局store对象。如果您确实遇到了需要在许多组件之间共享状态的情况,然而,状态并不是真正的全局的,您将感觉到需要向store对象中添加越来越多的内容。但是任何全球性的东西都需要一些限制或契约来避免不受控制的变化所造成的混乱。这就是 Flux 架构模式试图定义的。

Flux 支持单向数据流,因此状态的所有更改都通过调度程序来传递,调度程序控制更改的顺序,从而避免了因相互依赖而导致的无限循环。尽管这种模式是由开发 React 的同一批人(也就是脸书)发明的,但这种模式并不仅限于在 React 中使用。以下是我从脸书的 React 博客中引用的对通量的一个非常简洁而完整的描述:

当用户与 React 视图交互时,视图通过调度器发送一个动作(通常表示为带有一些字段的 JavaScript 对象),通知保存应用数据和业务逻辑的各个存储。当存储改变状态时,它们会通知视图某些内容已经更新。这与 React 的声明性模型配合得特别好,该模型允许存储发送更新,而无需指定如何在状态之间转换视图。

本质上,模式由正式定义的动作组成,例如,由用户发起的创建问题。该动作被分派到一个商店。这会影响商店的状态。典型地,一个被称为缩减器的函数被用来描述一个动作对状态的影响:新的状态是当前状态和动作的函数。

Redux 和 Mobx 是 React 的作者推荐的两种流行的选择,可用于全局状态管理,它们在很大程度上遵循了 Flux 模式的概念。这些框架或通量模式的效果是,您将不得不编写大量样板代码,也就是说,看起来非常像其他代码的代码,看起来没有必要。对于每个用户操作,您必须正式定义一个操作、一个调度程序和一个缩减器,在给定当前状态的情况下,返回新状态的内容。

让我们以删除问题的动作为例。您必须定义一组正式的动作,包括像DELETE_ISSUE这样的常量。然后,您必须定义一个 reducer,它是一个接受各种动作及其参数并返回一个新状态的函数(每个不同的动作有一个switch-case)。然后,您必须创建一个 dispatcher,它将动作和参数转换成实际的动作,比如向服务器发送请求。

如果应用中的状态机非常复杂,比如说,如果删除请求可以从 UI 中的几个不同位置发起(甚至可以从 UI 外部发起,就像其他用户的操作一样),并且除了删除表中的一行之外,还有许多其他含义,那么所有这些都是值得的。没有多少应用面临这种复杂性。

我可以满怀信心地向你保证,总有一天你的应用会在一个页面(想象一下你的脸书页面)中变得足够大,你会知道你需要 Redux 或 Mobx,以及为什么。在此之前,更明智的做法可能是在继续学习这些新模式的同时,只使用基础知识和基本原理来完成工作。

创建 React 应用

我们做了很多工作来使用 Webpack 和 Babel 建立 React 编译和开发环境。如果您对设置这一切不感兴趣,或者不想更好地控制优化和定制,您可能想考虑应用初始化器。

一个这样的应用初始化器是 Create React App。这可以帮助您快速入门,并为一个纯 react 应用设置所有必要的工具,如 Webpack 和 Babel。这可以满足问题跟踪器 UI 服务器的几乎所有要求。以下命令可能已经创建了初始版本:

$ npx create-react-app tracker-ui

注意,我们不需要安装任何 Node.js 模块。命令行npx临时安装了运行命令行create-react-app所需的任何东西并运行它。现在,在目录tracker-ui中,您会发现一个src目录,其中有两个文件:一个 JavaScript 文件和一个 CSS 文件。这只是一个开端;您可以在此添加更多文件来为您的项目编写代码。现在,要启动应用,您可以使用熟悉的start脚本:

$ npm start

这不仅会编译和提供捆绑包,还会自动打开一个浏览器标签并将其指向应用的 URL,http://localhost:3000/.您可以在 https://facebook.github.io/create-react-app/docs/getting-started 查看 Create React App 的用户指南。在您的项目中使用该工具之前,您必须记住以下几点:

  • 这就创建了一个 React 应用,也就是说,它没有服务器端组件。这意味着没有创建 Express 服务器,因此您不能做问题跟踪器 UI 服务器所做的事情:代理请求和在服务器上呈现。

  • 这只能用于 UI 服务器。API 服务器将需要保持其在问题跟踪器应用中的状态。

  • 如果您确实需要代理服务器和服务器渲染,您可以从创建 React 应用开始,并通过执行npm run eject和安装 Express 等自定义配置来“弹出”生成的应用。弹出已创建的应用具有使所有配置可见并允许自定义的效果,但它会阻止您轻松升级到 Create React 应用的新版本。

  • Create React App 使用 Webpack 处理所有资产,包括 CSS。由于问题跟踪器依赖于 React-Bootstrap,而我们没有模块化的 CSS,这并不十分理想,尽管可以通过包含 Bootstrap 的 CSS 文件来实现。例如,参见源文件index.js,如何包含index.css

因此,如果您的需求是一个纯粹的 React 应用,或者如果您愿意为 UI 服务器进行配置更改以包含 Express,那么您可以使用 Create React App 作为起点。或者,你可以在 https://github.com/facebook/create-react-app#popular-alternatives 查看一些基于你的项目类型的流行备选方案。

mern.io

如果您希望在 MERN 堆栈应用中遵循的大多数流行实践上有一个良好的开端,那么从一个已经用所有样板代码以及一组示例对象精心制作的项目开始是很方便的,您可以调整或复制这些示例对象来快速完成您的工作。

http://mern.io 可以找到一个专为 MERN 技术栈打造的脚手架工具。这个项目包括一个漂亮的工具,可以用来创建一个 MERN 应用的框架。这个包本身叫做mern-cli,这是一个命令行实用程序,它创建了一个基于 MERN 堆栈的应用。创建新的 MERN 应用(仅在 Linux 或 MacOS 上)的命令如下:

$ npm install -g mern-cli
$ mern init mernApp

如果您的 Node.js 版本是 10,您可能会得到一些编译器警告,现在可以安全地忽略它们。您会发现在目录mernApp下有一个完整的功能应用。为了快速查看它是否真的工作,您需要做的就是导航到目录,安装所有需要的包(使用npm install,然后运行npm start:

$ cd mernApp
$ npm install
$ npm start

将会有更多的警告,因为该项目使用了旧版本的 npm 包,现在已经发现有一些漏洞。如果您忽略这些警告,将浏览器指向http://localhost:8000,您将看到一个可以创建和删除博客帖子的功能应用。我们在问题跟踪器应用中所做的工作与mernApp的区别如下:

  • 应用mernApp不使用 Bootstrap 程序或 ReactBootstrap 程序。它有自己的样式表来设计它的内容。但是包含 Bootstrap 并不困难,它可以按照我们在 React-Bootstrap 章节中所做的步骤来完成(第十一章)。

  • mernApp使用 Mongoose 和 Redux,这两种技术我们在前面的章节中讨论过,但没有在问题跟踪器应用中使用。

  • mernApp中没有认证或会话处理。

  • 代码被组织成模块,这些模块是代码的内聚部分,它们一起工作以公开重要的功能。默认创建的唯一模块是Post模块,您可以根据需要创建更多模块。

  • 服务器是一个单独的服务器,不像问题跟踪器那样,我们有单独的 API 和 UI 服务器。

  • 使用基于 REST 的 API,而不是 GraphQL。

尽管有这些差异,这个项目还是显示出了希望。但是它有点过时,不能在 Windows 上工作,在创建应用的过程中抛出了许多警告,并且它没有得到积极的维护。但是 3.0 版本正在开发中。也许当新版本发布时,它会成为基于 MERN 的应用的应用初始化器。

护照

当你需要集成更多的认证提供者,比如脸书或 Twitter,如果你遵循我们在第十四章“认证”中采用的方法,你必须为每个认证选项编写不同的代码分支。

Node.js 包 Passport ( http://www.passportjs.org/ )通过创建一个可以插入多个认证策略的单一框架来解决这个问题。Passport 本身只规定了应用开发人员的框架和接口。每个策略(例如,Google 策略)都是作为一个单独的包来实现的。

请注意,Passport 是一个后端专用包。来自 UI 的所有身份验证请求都需要通过后端传递。这与我们在问题跟踪器中作为 Google 登录的一部分实现的不同,在问题跟踪器中,我们使用 Google 的客户端库直接向 Google 的身份验证引擎发起身份验证请求。一旦认证成功,我们将 Google 认证令牌从 UI 传递到后端进行验证。

相比之下,Passport 使用 Google 支持的 Open ID Connect 协议作为替代( https://developers.google.com/identity/protocols/OpenIDConnect )。在这种方法中,UI 调用应用的后端进行身份验证,而不是 Google 的身份验证引擎。然后用户被重定向到谷歌账户页面,而不是弹出窗口。然后,使用一组到应用后端的回调 URL,需要处理身份验证的成功和失败。

Open ID 方法适合服务器呈现的应用和用户需要从一开始就登录的应用。对于像问题跟踪器这样的 SPA,这将导致应用页面的几次刷新。相比之下,问题跟踪器应用中使用的直接方法不会导致浏览器刷新,而是在页面中更新身份验证信息。但是与 Passport 在处理多个身份验证提供者时提供的所有实现的便利性相比,这只是一个小麻烦。

那都是乡亲们!

我希望对你来说,这是一次穿过 MERN 堆栈水域的有趣航行,就像我一样。通过思考编程模型、范例和 MERN 堆栈让我大开眼界的新思维,我学到了很多。

我确保我们研究了 MERN 堆栈中每个部分的具体细节和附带的工具,而不是使用像 Passport 或 Create React App 这样的框架来简化工作。我希望你喜欢弄脏自己的手,以及随之而来的学习,尽管完成这项工作更难。

但这还远远没有结束。几个月后,我有理由相信情况会有所不同。谁知道呢?浏览器本身可能会适应或结合虚拟 DOM 技术,使得 React 变得多余!或者,你会看到一个新的框架(不是一个库),锚点作为 MVC 产品中的视图部分。或者,我们使用的新版本库可能会提出一种全新的做事方式。

关键是继续寻找这些新的发展,同时非常、非常深入地分析为什么它们对你的应用和团队有用或没用。

为展望更美好的未来干杯。

posted @   绝不原创的飞龙  阅读(94)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示