React-和-GraphQL-全栈-Web-开发第二版-全-
React 和 GraphQL 全栈 Web 开发第二版(全)
原文:
zh.annas-archive.org/md5/218d260d064933ae511d6d90a02baf1c译者:飞龙
前言
在过去几年中,越来越多的 Web 开发者依赖 JavaScript 来构建他们的前端和后端。本书涵盖了 Apollo、Express.js、Node.js 和 React 的一些主要技术。我们将介绍如何设置 React 和 Apollo,以便在用 Node.js 和 Sequelize 构建的后端上运行 GraphQL 请求。在此基础上,我们还将介绍对所编写的组件或函数进行测试,并使用 CircleCI 在 AWS ECS 上自动化部署。到本书结束时,您将了解如何结合最新的前端和后端技术。
这本书面向的对象
这本书是为熟悉 React 和 GraphQL 的 Web 开发者编写的,他们希望提高自己的技能,并使用 React、Apollo、Node.js 和 SQL 等行业标准构建全栈应用程序,同时学习使用 GraphQL 解决复杂问题。
这本书涵盖的内容
第一章, 准备开发环境,通过介绍一些核心概念、完整流程以及准备一个可工作的 React 设置来解释应用程序的架构。我们将看到 React、Apollo Client 和 Express.js 是如何协同工作的,并介绍在 React 中工作时的一些良好实践。此外,我们还将向您展示如何使用 React Developer Tools 调试前端。
第二章, 使用 Express.js 设置 GraphQL,教您如何通过安装 Express.js 和 Apollo 通过 NPM 配置您的后端。Express.js 将用于 Web 服务器,它处理并将所有 GraphQL 请求传递给 Apollo。
第三章, 连接到数据库,讨论了 GraphQL 在数据修改和查询方面提供的机会。例如,我们将使用传统的 SQL 构建一个完整的应用程序。为了简化数据库代码,我们将使用 Sequelize,它允许我们使用普通的 JavaScript 对象查询我们的 SQL 服务器,并允许我们使用 MySQL、MSSQL、PostgresSQL 或仅仅是 SQLite 文件。我们将在 Apollo 和 Sequelize 中为用户和帖子构建模型和模式。
第四章, 将 Apollo 集成到 React 中,您将学习如何将 Apollo 集成到 React 中,并构建前端组件以发送 GraphQL 请求。这一章将解释 Apollo 特定的配置。
第五章, 可重用 React 组件和 React Hooks,在基本概念和获取及展示数据的流程清晰的情况下,将更深入地探讨编写更复杂的 React 组件以及在这些组件间共享数据。
第六章, 使用 Apollo 和 React 进行身份验证,将解释在 Web 和 GraphQL 中验证用户的常见方式。您将通过使用最佳实践来构建完整的身份验证工作流程。
第七章, 处理图像上传,是您将在 Apollo 之上构建一个工作认证和授权系统的点。继续前进,为了超越具有 JSON 响应的正常请求,就像 GraphQL 那样,我们现在将通过 Apollo 上传图像并将它们保存在单独的对象存储中,例如 AWS S3。
第八章, React 中的路由, 是您将实现一些进一步功能以构建面向最终用户的完整应用程序的地方,例如个人资料页面。我们将通过安装 React Router v5 来完成这项工作。
第九章, 实现服务器端渲染,涵盖了服务器端渲染。对于许多应用程序来说,这是必需的。这对于 SEO 很重要,但也可以对您的最终用户产生积极影响。本章将专注于将您当前的应用程序迁移到服务器端渲染设置。
第十章, 实时订阅,探讨了我们的应用程序是如何成为 WebSocket 和 Apollo 订阅的绝佳用例。我们日常使用的许多应用程序都有一个自我更新的通知栏。本章将专注于如何使用名为订阅的实验性 GraphQL 和 Apollo 功能构建此功能。
第十一章, 为 React 和 Node.js 编写测试,探讨了真实的生产就绪应用程序总是有一个自动化的测试环境。我们将使用 Mocha,一个 JavaScript 单元测试框架,以及 Enzyme,一个 React 测试工具,以确保我们应用程序的质量。本章将专注于测试 GraphQL 后端以及如何使用 Enzyme 正确测试 React 应用程序。
第十二章, 使用 CircleCI 和 AWS 进行持续部署,研究了部署。部署应用程序意味着不再需要通过 FTP 手动上传文件。如今,您可以在没有完整服务器运行的情况下在云中几乎运行您的应用程序。为了便于部署我们的应用程序,我们将使用 Docker。在部署我们的应用程序之前,我们将快速介绍一个基本的持续部署设置,这将让您轻松地部署所有新代码。本章将解释如何使用 Git、Docker、AWS 和 CircleCI 来部署您的应用程序。
要充分利用本书
要开始阅读本书并编写功能代码,您需要满足一些要求。关于操作系统,您几乎可以在所有操作系统上运行完整代码和其他依赖项。本书的主要依赖项将在本书中逐一解释。
本书涵盖的软件/硬件:
-
Node.js 14+
-
React 17+
-
Sequelize 6+
-
MySQL 5 或 8
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图和图表的彩色图像 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801077880_ColorImages.pdf
static.packt-cdn.com/downloads/9781801077880_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“Apollo Client 的最新版本附带useQuery钩子。”
代码块设置如下:
if (loading) return 'Loading...';
if (error) return 'Error! ${error.message}';
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
mkdir src/client/apollo touch src/client/apollo/index.js
任何命令行输入或输出都应如下编写:
mkdir src/client/components
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“在顶部栏中,您将找到Prettify按钮,它可以整理您的查询,使其更易于阅读。”
小贴士或重要提示
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《使用 GraphQL 和 React 第二版进行全栈 Web 开发》,我们非常期待听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。

https://packt.link/r/1801077886
您的评论对我们和科技社区都非常重要,并将帮助我们确保我们提供高质量的内容。
第一部分:构建栈
每次旅程都是从第一步开始的。我们的第一步将是查看如何使用 Node.js、React、MySQL 和 GraphQL 来完成基本设置。了解如何自己构建这样的设置以及不同技术如何协同工作,对于理解本书后面更高级的主题非常重要。
在本节中,包含以下章节:
-
第一章, 准备你的开发环境
-
第二章, 使用 Express.js 设置 GraphQL
-
第三章, 连接到数据库
第一章:准备你的开发环境
在本书中,我们将构建一个简化版的 Facebook,称为Graphbook。我们将允许用户注册和登录,以阅读和撰写帖子并与朋友聊天,类似于我们在常见的社交网络上所能做的。
在开发应用程序时,做好充分的准备始终是一个要求。然而,在我们开始之前,我们需要将我们的栈组合起来。在本章中,我们将探讨我们的技术是否与我们的开发过程很好地配合,在开始之前我们需要什么,以及哪些工具可以帮助我们在构建软件时。
本章通过介绍核心概念、完整流程和准备一个可工作的 React 设置,解释了我们应用程序的架构。
本章涵盖了以下主题:
-
架构和技术
-
仔细思考如何构建栈的架构
-
构建 React 和 GraphQL 栈
-
安装和配置 Node.js
-
使用 webpack、Babel 和其他要求设置 React 开发环境
-
使用 Chrome DevTools 和 React Developer Tools 调试 React 应用程序
技术要求
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter01。
理解应用程序架构
自从 2015 年首次发布以来,GraphQL 已经成为了标准 SOAP 和 REST API 的新替代品。GraphQL 是一个规范,就像 SOAP 和 REST 一样,你可以遵循它来构建你的应用程序和数据流。它之所以创新,是因为它允许你查询实体的特定字段,例如用户和帖子。这种功能使其非常适合同时针对多个平台。移动应用可能不需要在桌面计算机浏览器中显示的所有数据。你发送的查询由一个类似于 JSON 的对象组成,该对象定义了你的平台需要哪些信息。例如,一个针对post的查询可能看起来像这样:
post {
id
text
user {
user_id
name
}
}
GraphQL 根据你的查询对象中指定的正确实体和数据解决问题。GraphQL 中的每个字段都代表一个解析到值的函数。这些函数被称为解析函数。返回值可以是相应的数据库值,例如用户的姓名,也可以是一个日期,该日期在返回之前由你的服务器格式化。
GraphQL 完全与数据库无关,可以在任何编程语言中实现。为了跳过实现 GraphQL 库的步骤,我们将使用 Apollo,这是一个 Node.js 生态系统的 GraphQL 服务器。多亏了 Apollo 背后的团队,这使得它非常模块化。Apollo 与许多常见的 Node.js 框架一起工作,例如 Hapi、Koa 和 Express.js。
我们将使用 Express.js 作为我们的基础,因为它在 Node.js 和 GraphQL 社区中被广泛使用。GraphQL 可以与多个数据库系统和分布式系统一起使用,为所有服务提供一个简单的 API。它允许开发者统一现有系统并处理客户端应用程序的数据获取。
如何将你的数据库、外部系统和其他服务组合成一个服务器后端取决于你。在这本书中,我们将使用 Sequelize 通过 MySQL 服务器作为我们的数据存储。SQL 是最知名且最常用的数据库查询语言,而通过 Sequelize,我们有一个现代客户端库,用于我们的 Node.js 服务器连接到我们的 SQL 服务器。
HTTP 是访问 GraphQL API 的标准协议。它也适用于 Apollo 服务器。然而,GraphQL 并不固定于一种网络协议。我们之前提到的所有内容都是后端的重要部分。
当我们到达我们的Graphbook应用程序的前端时,我们将主要使用 React。React 是由 Facebook 发布的一个 JavaScript UI 框架,它引入了许多现在常用于在网络上以及原生环境中构建界面的技术。
使用 React 带来了一系列显著的优势。在构建 React 应用程序时,你总是将代码拆分成许多组件,以提高它们的效率和可重用性。当然,你可以在不使用 React 的情况下这样做,但 React 使这变得非常容易。此外,React 教你如何以响应式的方式更新应用程序状态以及 UI。你永远不会分别更新 UI 和数据。
React 通过使用虚拟 DOM,将虚拟 DOM 和实际 DOM 进行比较并相应地更新,从而使得重新渲染非常高效。只有当虚拟 DOM 和实际 DOM 之间存在差异时,React 才会应用这些更改。这种逻辑阻止了浏览器重新计算布局、层叠样式表(CSS)以及其他影响应用程序整体性能的计算。
在整本书中,我们将使用 Apollo 客户端库。它自然地与 React 和我们的 Apollo 服务器集成。
如果我们将所有这些放在一起,结果就是由 Node.js、Express.js、Apollo、SQL、Sequelize 和 React 组成的主堆栈。
基本设置
使应用程序工作的基本设置是逻辑请求流程,如下所示:

图 1.1 – 逻辑请求流程
这里是如何工作的逻辑请求流程:
-
客户端请求我们的网站。
-
Express.js 服务器处理这些请求并服务一个静态 HTML 文件。
-
客户端根据这个 HTML 文件下载所有必要的文件,这些文件还包括一个捆绑的 JavaScript 文件。
-
这个捆绑的 JavaScript 文件是我们的 React 应用程序。在执行完这个文件中的所有 JavaScript 代码后,所有必要的 Ajax 别名 GraphQL 请求都会发送到我们的 Apollo 服务器。
-
Express.js 接收请求并将它们传递给我们的 Apollo 端点。
-
Apollo 从所有可用的系统中查询所有请求的数据,例如我们的 SQL 服务器或第三方服务,合并数据,并以 JSON 格式发送回来。
-
React 可以将 JSON 数据渲染为 HTML。
这个工作流程是使应用程序工作的基本设置。在某些情况下,为我们的客户端提供服务器端渲染是有意义的。服务器需要在返回 HTML 给客户端之前自己渲染并发送所有的 XMLHttpRequests。如果服务器在初始加载时发送请求,用户将节省一次或多次往返。我们将在本书的后面部分关注这个主题,但这就是应用程序架构的精髓。考虑到这一点,让我们动手设置我们的开发环境。
安装和配置 Node.js
准备我们的项目的第一步是安装 Node.js。有两种方法可以做到这一点:
-
一种选项是安装 Node 版本管理器(NVM)。使用 NVM 的好处是您可以在几乎所有的 UNIX 基础系统(如 Linux 和 macOS)上轻松地并行运行多个 Node.js 版本,它为您处理安装过程。在这本书中,我们不需要在不同版本的 Node.js 之间切换的选项。
-
另一个选项是如果您使用 Linux,可以通过您发行版的包管理器安装 Node.js。官方的 PKG 文件适用于 Mac,而 MSI 文件适用于 Windows。我们将在这本书中使用常规的 Linux 包管理器,因为它是最简单的方法。
注意
您可以在以下链接找到 Node.js 的 下载 部分:
nodejs.org/en/download/.
我们将在这里使用第二个选项。它涵盖了常规的服务器配置,并且易于理解。我会尽量简短,并跳过所有其他选项,例如针对 Windows 的 Chocolatey 和针对 Mac 的 Brew,这些选项非常特定于那些操作系统。
我假设您使用基于 Debian 的系统,以便于使用这本书。它具有正常的 APT 包管理器和用于轻松安装 Node.js 和 MySQL 的仓库。如果您不是使用基于 Debian 的系统,您可以在 nodejs.org/en/download/package-manager/ 查找安装 Node.js 的匹配命令。
我们的项目将是新的,这样我们就可以使用 Node.js 14,这是当前的 LTS 版本:
-
首先,让我们通过运行以下命令添加我们的包管理器的正确仓库:
curl -fsSL https://deb.nodesource.com/setup_14.x | sudo bash - -
接下来,我们必须使用以下命令安装 Node.js 和用于原生模块的构建工具:
apt-get install -y nodejs build-essential -
最后,让我们打开一个终端并验证安装是否成功:
node --version注意
通过包管理器安装 Node.js 将自动安装 npm。
太好了!你现在可以运行 Node.js 的服务器端 JavaScript,并使用 npm 为你的项目安装 Node.js 模块。
我们项目所依赖的所有依赖项都可在npmjs.com找到,并可以使用 npm 或 Yarn 进行安装。我们将依赖 npm,因为它比 Yarn 更广泛地被使用。所以,让我们继续,并开始使用 npm 来设置我们的项目和其依赖项。
设置 React
我们项目的开发环境已经准备好了。在本节中,我们将安装和配置 React,这是本章的主要内容。让我们首先为我们的项目创建一个新的目录:
mkdir ~/graphbook
cd ~/graphbook
我们的项目将使用 Node.js 和许多 npm 包。我们将创建一个package.json文件来安装和管理我们项目的所有依赖项。它存储有关项目的信息,如版本号、名称、依赖项等。
只需运行npm init来创建一个空的package.json文件:
npm init
npm 会询问一些问题,例如询问包名,实际上就是项目名。输入Graphbook以在生成的package.json文件中插入你的应用程序名称。
我更喜欢从版本号 0.0.1 开始,因为 npm 提供的默认版本号 1.0.0 对我来说代表的是第一个稳定版本。然而,关于你在这里使用哪个版本,这是你的选择。
你可以通过按Enter键来跳过所有其他问题,以保存 npm 的默认值。其中大部分都不相关,因为它们只是提供信息,如描述或仓库链接。我们将在本书的工作过程中填写其他字段,如脚本。你可以在下面的屏幕截图中看到一个命令行的示例:
![Figure 1.2 – npm 项目设置
![Figure 1.2 – npm 项目设置
Figure 1.2 – npm 项目设置
这本书的第一个也是最重要的依赖项是 React。使用 npm 将 React 添加到我们的项目中:
npm install --save react react-dom
这个命令从npmjs.com安装了两个 npm 包到我们的项目文件夹下的node_modules。
由于我们提供了--save选项并添加了这些包的最新版本号,npm 自动编辑了我们的package.json文件。
你可能想知道为什么我们安装了两个包,尽管我们只需要 React。react包只提供 React 特定的方法。所有 React Hooks,如componentDidMount、useState,甚至 React 的组件类,都来自这个包。你需要这个包来编写任何 React 应用程序。
在大多数情况下,你甚至不会注意到你已经使用了react-dom。这个包提供了将浏览器实际 DOM 连接到你的 React 应用程序的所有功能。通常,你使用ReactDOM.render在 HTML 的特定位置渲染你的应用程序,并且只在你代码中渲染一次。我们将在本书的后面部分介绍如何渲染 React。
还有一个名为ReactDOM.findDOMNode的函数,它为你提供了对 DOMNode 的直接访问,但我强烈建议不要使用这个函数,因为 DOMNodes 上的任何更改在 React 本身中都是不可用的。我从未需要使用这个函数,所以如果可能的话,尽量避免使用它。现在,我们的 npm 项目已经设置好,两个主要依赖项也已经安装,我们需要准备一个环境来打包我们将要编写的所有 JavaScript 文件。我们将在下一节中关注这一点。
准备和配置 webpack
当我们的浏览器访问我们的应用程序时,它会请求一个index.html文件。它指定了运行我们的应用程序所需的所有文件。我们需要创建这个index.html文件,并将其作为我们应用程序的入口点:
-
为我们的
index.html文件创建一个单独的目录:mkdir public cd public touch index.html -
然后,将以下内容保存到
index.html中:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Graphbook</title> </head> <body> <div id="root"></div> </body> </html>
如你所见,这里没有加载任何 JavaScript。只有一个带有root ID 的div标签。这个div标签是ReactDOM将我们的应用程序渲染到的 DOMNode。
那么,我们如何使用这个index.html文件来启动 React 呢?
为了实现这一点,我们需要使用一个网络应用程序打包器,它将准备和打包我们所有的应用程序资源。所有必需的 JavaScript 文件和node_modules都被打包和压缩;SASS 和 SCSS 预处理器被转换为 CSS,并且也被合并和压缩。
有几个应用程序打包器包可用,包括 webpack、Parcel 和 Gulp。对于我们的用例,我们将使用 webpack。它是最常见的模块打包器,并且有一个庞大的社区。为了打包我们的 JavaScript 代码,我们需要安装 webpack 及其所有依赖项,如下所示:
npm install --save-dev @babel/core babel-loader @babel/preset-env @babel/preset-react clean-webpack-plugin css-loader file-loader html-webpack-plugin style-loader url-loader webpack webpack-cli webpack-dev-server
此命令将所有开发工具添加到package.json文件中的devDependencies。我们需要这些工具来打包我们的应用程序。它们仅在开发环境中安装,并在生产中跳过。
如果你还不知道,设置 webpack 可能有点麻烦。许多选项可能会相互干扰,并在你打包应用程序时导致问题。现在,让我们在项目的根目录中创建一个webpack.client.config.js文件。
输入以下代码:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const buildDirectory = 'dist';
const outputDirectory = buildDirectory + '/client';
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
path: path.join(__dirname, outputDirectory),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
devServer: {
port: 3000,
open: true
},
plugins: [
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [path.join(__dirname,
buildDirectory)]
}),
new HtmlWebpackPlugin({
template: './public/index.html'
})
]
};
webpack 配置文件只是一个普通的 JavaScript 文件,你可以在这里 require node_modules和自定义 JavaScript 文件。这和 Node.js 内部的任何地方都一样。让我们快速浏览一下这个配置的所有主要属性。理解这些将使未来的自定义 webpack 配置变得容易得多。所有重要点都在这里解释:
-
HtmlWebpackPlugin:这会自动生成一个包含所有 webpack 打包的 HTML 文件。我们传递之前创建的index.html作为模板。 -
CleanWebpackPlugin:这会清空所有提供的目录以清理旧的构建文件。cleanOnceBeforeBuildPatterns属性指定了一个在构建过程开始之前被清理的文件夹数组。 -
entry字段告诉 webpack 我们应用程序的起点在哪里。这个文件需要我们创建。 -
output对象指定了我们的包是如何命名的以及它应该保存的位置。对我们来说,这是dist/client/bundle.js。 -
在
module.rules中,我们将我们的文件扩展名与正确的加载器匹配。所有 JavaScript 文件(除了位于node_modules中的文件)都由babel-loader转译,以便我们可以在代码中使用 ES6 特性。我们的 CSS 由style-loader和css-loader处理。还有许多其他用于 JavaScript、CSS 和其他文件扩展名的加载器可用。 -
webpack 的
devServer功能使我们能够直接运行 React 代码。这包括在浏览器中无需重新运行构建或刷新浏览器标签页的情况下热重载代码。注意
如果您需要 webpack 配置的更详细概述,请查看官方文档:
webpack.js.org/concepts/。
考虑到这一点,让我们继续前进。我们在 webpack 配置中缺少 src/client/index.js 文件,所以让我们创建它,如下所示:
mkdir -p src/client
cd src/client
touch index.js
您可以暂时留空这个文件。它可以在没有内容的情况下由 webpack 打包。我们将在本章的后面更改它。
为了启动我们的开发 webpack 服务器,我们将在 package.json 中添加一个命令,我们可以使用 npm 来运行。
将以下行添加到 package.json 中的 scripts 对象:
"client": "webpack serve --devtool inline-source-map --hot --config webpack.client.config.js"
现在,在您的控制台中执行 npm run client 并观察新浏览器窗口的打开。我们正在使用新创建的配置文件运行 webpack serve。
当然,浏览器仍然是空的,但如果你用 Chrome DevTools 检查 HTML,你会看到我们已经有了一个 bundle.js 文件,并且我们的 index.html 文件被用作模板。
有了这些,我们已经学会了如何将空的 index.js 文件包含在包中并服务于浏览器。接下来,我们将在模板 index.html 文件中渲染第一个 React 组件。
渲染第一个 React 组件
对于 React 来说,有许多最佳实践。其背后的核心哲学是在可能的情况下将我们的代码拆分为单独的组件。我们将在 第五章,可重用 React 组件和 React Hooks 中更详细地介绍这种方法。
我们的 index.js 文件是前端代码的主要起点,这就是它应该保持的样子。不要在这个文件中包含任何业务逻辑。相反,尽量保持它尽可能干净和精简。
index.js 文件应包含以下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App/>, document.getElementById('root'));
ECMAScript 2015 的发布引入了 import 功能。我们可以使用它来加载我们的 npm 包——react 和 react-dom——以及我们的第一个自定义 React 组件,我们必须现在编写它。
当然,我们需要涵盖传统的 Hello World 程序。
在您的 index.js 文件旁边创建 App.js 文件,并确保它包含以下内容:
import React from 'react';
const App = () => {
return (
<div>Hello World!</div>
)
}
export default App
在这里,我们导出了一个名为 App 的单个无状态函数,然后由 index.js 文件导入。正如我们之前解释的,我们现在正在积极地在 index.js 文件中使用 ReactDOM.render。
ReactDOM.render 的第一个参数是我们想要渲染的组件或函数,这是显示 DOMNode 的导出函数,它应该在那里渲染。我们通过一个简单的 document.getElementById JavaScript 接收 DOMNode。
当我们创建 index.html 文件时,我们定义了我们的根元素。保存 App.js 文件后,webpack 将尝试重新构建一切。然而,它不应该能够做到这一点。webpack 在打包我们的 index.js 文件时将遇到问题,因为我们使用 ReactDOM.render 方法中的 <App /> 标签语法,它没有被转换为正常的 JavaScript 函数。
我们配置了 webpack 来加载 Babel 以处理我们的 JavaScript 文件,但没有告诉 Babel 要转换什么以及不要转换什么。
让我们在根目录中创建一个 .babelrc 文件,其中包含以下内容:
{
"presets": ["@babel/env","@babel/react"]
}
注意
你可能需要重新启动服务器,因为当文件被修改时,.babelrc 文件不会被重新加载。几分钟后,你应该能在浏览器中看到标准的 Hello World! 消息。
在这里,我们告诉 Babel 使用 @babel/preset-env 和 @babel/preset-react,这些预设与 webpack 一起安装。这些预设允许 Babel 转换特定的语法,例如 JSX。我们可以使用这些预设来创建所有浏览器都能理解的正常 JavaScript,并且 webpack 可以打包。
从 React 状态渲染数组
Hello World! 是每本好编程书的必备,但当我们使用 React 时,我们追求的并不是这个。
一个像 Facebook 或 Graphbook 这样的社交网络,我们现在正在编写,需要一个新闻源和发布新闻的输入。让我们来实现这个功能。
由于这是本书的第一章,我们将在 App.js 中完成这个任务。
由于我们还没有设置 GraphQL API,我们应该在这里使用一些假数据。我们可以在以后用真实数据替换它。
在导出为 default 的 App 函数上方定义一个新变量,如下所示:
const initialPosts = [
{
id: 2,
text: 'Lorem ipsum',
user: {
avatar: '/uploads/avatar1.png',
username: 'Test User'
}
},
{
id: 1,
text: 'Lorem ipsum',
user: {
avatar: '/uploads/avatar2.png',
username: 'Test User 2'
}
}
];
我们将使用 React 渲染这两个假帖子。为了准备这个,将 App.js 文件的第一个行更改为以下内容:
import React, { useState } from 'react';
这确保了 React 的 useState 函数被导入并且可以被我们的无状态函数访问。
将你的 App 函数当前内容替换为以下代码:
const [posts, setPosts] = useState(initialPosts);
return (
<div className="container">
<div className="feed">
{ initialPosts.map((post, i) =>
<div key={post.id} className="post">
<div className="header">
<img src={post.user.avatar} />
<h2>{post.user.username}</h2>
</div>
<p className="content">
{post.text}
</p>
</div>
)}
</div>
</div>
)
在这里,我们通过 React 的 useState 函数在函数内部初始化了一个 posts 数组。这允许我们拥有一个状态,而无需编写真正的 React 类;相反,它只依赖于原始函数。useState 函数期望一个参数,即状态变量的初始值。在这种情况下,这是常量 initialPosts 数组。这返回 posts 状态变量和一个 setPosts 函数,你可以使用它来更新本地状态。
然后,我们使用map函数遍历posts数组,这再次执行了内部回调函数,逐个传递数组项作为参数。第二个参数简单地称为i,代表我们正在处理的数组元素的索引。map函数返回的所有内容随后都由 React 渲染。
我们只是通过将每个帖子的数据放入 ES6 花括号中返回 HTML。这些花括号告诉 React 将它们内部的代码解释和评估为 JavaScript。
如前述代码所示,我们依赖于useState函数返回的帖子。这种数据流非常方便,因为我们可以在应用程序的任何位置更新状态,帖子将重新渲染。重要的是,这只能通过使用setPosts函数并将更新的数组传递给它来实现。在这种情况下,React 会注意到状态的改变并重新渲染函数。
前面的方法更简洁,我推荐这种方法以提高可读性。保存时,你应该能够看到渲染的帖子。它们应该看起来像这样:
![图 1.3 – 未加样式演示帖子]

图 1.3 – 未加样式演示帖子
我在这里使用的图片是免费可用的。如果路径与posts数组中的字符串匹配,你可以使用任何其他材料。这些图片可以在本书的官方 GitHub 仓库中找到。
使用 webpack 的 CSS
前面的图中的帖子尚未设计。我已经为返回的 HTML 组件添加了 CSS 类。
而不是使用 CSS 使我们的帖子看起来更好,另一种方法是使用 CSS-in-JS,例如使用 styled components 这样的包,这是一个 React 包。其他替代方案包括 Glamorous 和 Radium。使用这样的库有无数的理由和反对的理由。使用那些其他工具,你无法有效地使用 SASS、SCSS 或 LESS。我需要与其他人合作,例如屏幕和图形设计师,他们可以提供和使用 CSS,但不会编写样式组件。总是有一个原型或现有的 CSS 可以使用,那么我为什么要花时间将这转换为样式组件 CSS,而我可以继续使用标准 CSS 呢?
在这里没有正确或错误的选择;你可以自由地以任何你喜欢的任何方式实现样式。然而,在这本书中,我们将继续使用传统的 CSS。
在我们的webpack.client.config.js文件中,我们已经指定了一个 CSS 规则,如下面的代码片段所示:
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
style-loader将你的打包 CSS 直接注入到 DOM 中。css-loader将解析 CSS 代码中的所有import或url出现。
在/assets/css目录下创建一个style.css文件,并填写以下内容:
body {
background-color: #f6f7f9;
margin: 0;
font-family: 'Courier New', Courier, monospace
}
p {
margin-bottom: 0;
}
.container {
max-width: 500px;
margin: 70px auto 0 auto;
}
.feed {
background-color: #bbb;
padding: 3px;
margin-top: 20px;
}
.post {
background-color: #fff;
margin: 5px;
}
.post .header {
height: 60px;
}
.post .header > * {
display: inline-block;
vertical-align: middle;
}
.post .header img {
width: 50px;
height: 50px;
margin: 5px;
}
.post .header h2 {
color: #333;
font-size: 24px;
margin: 0 0 0 5px;
}
.post p.content {
margin: 5px;
padding: 5px;
min-height: 50px;
}
刷新浏览器将留下你之前相同的旧 HTML。
这个问题发生是因为 webpack 是一个模块打包器,它对 CSS 一无所知;它只知道 JavaScript。我们必须在我们的代码中某个地方导入 CSS 文件。
我们可以使用 webpack 和我们的 CSS 规则,而不是使用index.html并添加一个head标签,将 CSS 直接加载到App.js中。这个解决方案非常方便,因为我们的应用程序中所有需要的 CSS 都会被压缩和捆绑。Webpack 自动化了这个过程。
在你的App.js文件中,在 React import语句之后添加以下内容:
import '../../assets/css/style.css';
webpack 神奇地重建我们的包并刷新我们的浏览器标签。
这样,你就已经通过 React 渲染了假数据,并用 webpack 捆绑的 CSS 进行了样式化。它应该看起来像这样:

图 1.4 – 样式化演示帖子
输出看起来已经很好了。
React 的事件处理和状态更新
对于这个项目,有一个简单的textarea会很好,我们可以点击一个按钮,然后添加一个新的帖子到我们在App函数中编写的静态posts数组中。
在包含feed类的div标签上方添加以下代码:
<div className="postForm">
<form onSubmit={handleSubmit}>
<textarea value={postContent} onChange={(e) =>
setPostContent(e.target.value)}
placeholder="Write your custom post!"/>
<input type="submit" value="Submit" />
</form>
</div>
你可以在 React 中无任何问题地使用表单。React 可以通过给表单一个onSubmit属性来拦截请求的提交事件,这将是一个处理逻辑的函数。
我们将postContent变量传递给textarea的value属性,以获得所谓的受控输入。
使用useState函数创建一个空字符串变量来保存textarea的值:
const [postContent, setPostContent] = useState('');
postContent变量已经被用于我们的新textarea,因为我们已经在value属性中指定了它。此外,我们在帖子表单中直接实现了setPostContent函数。这用于onChange属性或任何在textarea内部输入时被调用的任何事件。setPostContent函数接收e.target.value变量,这是textarea值的 DOM 访问器,然后存储在 React 函数的状态中。
再次查看你的浏览器。表单在那里,但它看起来并不漂亮,所以添加以下 CSS:
form {
padding-bottom: 20px;
}
form textarea {
width: calc(100% - 20px);
padding: 10px;
border-color: #bbb;
}
form [type=submit] {
border: none;
background-color: #6ca6fd;
color: #fff;
padding: 10px;
border-radius: 5px;
font-size: 14px;
float: right;
}
最后一步是实现我们的表单的handleSubmit函数。直接在状态变量和return语句之后添加它:
const handleSubmit = (event) => {
event.preventDefault();
const newPost = {
id: posts.length + 1,
text: postContent,
user: {
avatar: '/uploads/avatar1.png',
username: 'Fake User'
}
};
setPosts([newPost, ...posts]);
setPostContent('');
};
之前的代码看起来比实际要复杂,但我将快速解释它。
我们需要运行event.preventDefault来阻止浏览器实际尝试提交表单并重新加载页面。大多数来自 jQuery 或其他 JavaScript 框架的人都会知道这一点。
接下来,我们将新帖子保存在newPost变量中,我们希望将其添加到我们的 feed 中。
我们在这里伪造了一些数据来模拟真实世界的应用。对于我们的测试用例,新的帖子 ID 是我们状态变量中的帖子数加一。React 希望我们给 ReactDOM 中的每个子元素一个唯一的 ID。通过计算posts.length中的帖子数,我们模拟了真实后端为我们帖子提供唯一 ID 的行为。
我们新帖子的文本来自postContent状态变量。
此外,我们目前还没有一个用户系统,我们的 GraphQL 服务器可以使用它来给我们提供最新的帖子,包括匹配的用户和他们的头像。我们可以通过为所有创建的新帖子创建一个静态用户对象来模拟这一点。
最后,我们再次更新了状态。我们通过使用 setPosts 函数并传递一个由新帖子以及当前 posts 数组通过解构赋值合并而成的数组来做到这一点。之后,我们通过将空字符串传递给 setPostContent 函数来清空 textarea。
现在,继续使用你的工作 React 表单。别忘了,你创建的所有帖子都不会持久化,因为它们只保存在浏览器的本地内存中,并没有保存到数据库中。因此,刷新会删除你的帖子。
使用 React Helmet 控制文档头
在开发 web 应用程序时,你必须控制你的文档头。你可能想根据你展示的内容更改标题或描述。
React Helmet 是一个优秀的包,它提供了这些功能,包括动态覆盖多个头信息和服务器端渲染。让我们看看我们如何做到这一点:
-
使用以下命令安装 React Helmet:
head tags inside your template. This has the advantage that, before React has been rendered, there is always the default document head. For our case, you can directly apply a title and description in App.js. -
在文件顶部导入
react-helmet:import { Helmet } from 'react-helmet'; -
在
postForm div上面直接添加Helmet:<Helmet> <title>Graphbook - Feed</title> <meta name="description" content="Newsfeed of all your friends on Graphbook" /> </Helmet>
如果你刷新浏览器并仔细观察浏览器标签栏上的标题,你会看到它从 Graphbook 变为 Graphbook - Feed。这种行为发生是因为我们在 index.html 中已经定义了一个标题。当 React 完成渲染后,新的文档头就会应用。
使用 webpack 进行生产构建
我们 React 设置的最后一步是进行生产构建。到目前为止,我们只使用了 webpack-dev-server,但这自然包括一个未优化的开发构建。此外,webpack 会自动启动一个 web 服务器。在下一章中,我们将介绍 Express.js 作为我们的 web 服务器,这样我们就不需要 webpack 来启动它了。
生产版本的包会合并所有 JavaScript 文件,但也会将所有 CSS 文件合并成两个单独的文件。这些文件可以直接在浏览器中使用。为了打包 CSS 文件,我们将依赖于另一个 webpack 插件,称为 MiniCss:
npm install --save-dev mini-css-extract-plugin
我们不想更改当前的 webpack.client.config.js 文件,因为它是为开发工作制作的。将以下命令添加到你的 package.json 文件的 scripts 对象中:
"client:build": "webpack --config webpack.client.build.config.js"
此命令使用单独的生产 webpack 配置文件运行 webpack。让我们创建这个文件。首先,克隆原始的 webpack.client.config.js 文件,并将其重命名为 webpack.client.build.config.js。
在新文件中更改以下内容:
-
mode需要设置为production,而不是development。 -
需要引入
MiniCss插件:const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -
替换当前的 CSS 规则:
{ test: /\.css$/, use: [{ loader: MiniCssExtractPlugin.loader, options: { publicPath: '../' } }, 'css-loader'], },我们不再使用
style-loader;相反,我们使用MiniCss插件。该插件遍历完整的 CSS 代码,将其合并到一个单独的文件中,并从我们并行生成的bundle.js文件中删除import语句。 -
最后,将插件添加到配置文件底部的插件中:
new MiniCssExtractPlugin({ filename: 'bundle.css', }) -
删除整个
devServer属性。
当您运行新的配置时,它不会启动服务器或浏览器窗口;它只会创建生产 JavaScript 和 CSS 包,并将它们包含在我们的index.html文件中。根据我们的webpack.client.build.config.js文件,这三个文件将被保存到dist/client文件夹中。
您可以通过执行npm run client:build来运行此命令。
如果您查看dist/client文件夹,您将看到三个文件。您可以在浏览器中打开index.html文件。遗憾的是,图片已损坏,因为图片 URL 不再正确。我们必须暂时接受这一点,因为当我们的后端工作正常时,它将自动修复。
这样,我们就完成了 React 的基本设置。
有用的开发工具
当您使用 React 时,您想知道为什么您的应用程序以这种方式渲染。您需要知道组件接收了哪些属性以及它们当前的状态看起来如何。由于这些信息在 DOM 或 Chrome DevTools 的任何其他地方都没有显示,您需要一个单独的插件。
幸运的是,Facebook 已经为您解决了这个问题。访问chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi并安装 React Developer Tools。此插件允许您检查 React 应用程序和组件。当您再次打开 Chrome DevTools 时,您将在行尾看到两个新的标签页 - 一个称为Components,另一个称为Profiler:
![Figure 1.5 – React developer tools]
![Figure_1.5_B17337.jpg]
Figure 1.5 – React developer tools
您只能在开发模式下运行 React 应用程序时才能看到这些标签页。如果 React 应用程序正在运行或以生产模式打包,这些扩展将不起作用。
注意
如果您看不到这个标签页,您可能需要完全重新启动 Chrome。您还可以在 Firefox 上找到 React Developer Tools。
第一个标签页允许您查看、搜索和编辑您的 ReactDOM 的所有组件。
左侧面板看起来与 Chrome DevTools 中的常规 DOM 树(Elements)非常相似,但您将看到所有组件都在树中,而不是显示 HTML 标记。ReactDOM 将此树渲染成真实的 HTML,如下所示:
![Figure 1.6 – React component tree]
![Figure_1.6_B17337.jpg]
Figure 1.6 – React component tree
Graphbook 当前版本的第一个组件应该是<App />。
通过点击一个组件,你的右侧面板将显示其属性、状态和上下文。你可以尝试使用 App 组件,这是唯一的真实 React 组件:

图 1.7 – React 组件状态
App 函数是我们应用程序的第一个组件。这就是为什么它没有接收任何属性。子组件可以从父组件接收属性;没有父组件,就没有属性。
现在,测试 App 函数并尝试操作状态。你会看到状态的改变会重新渲染你的 ReactDOM 并更新 HTML。你可以编辑 postContent 变量,它会在 textarea 内插入新文本。正如你将看到的,所有的事件都会被抛出,并且你的处理器会运行。更新状态总是触发重新渲染,所以尽量减少状态更新,以尽可能少地使用计算资源。
摘要
在本章中,我们设置了一个可工作的 React 环境。这对于我们的前端来说是一个很好的起点,因为我们可以使用这个设置编写和构建静态网页。
下一章将主要关注我们的后端设置。我们将配置 Express.js 以接受我们的第一个请求并将所有 GraphQL 查询传递给 Apollo。此外,你还将学习如何使用 Postman 测试你的 API。
第二章:使用 Express.js 设置 GraphQL
我们的前端基本设置和原型现在已完成。现在,我们需要启动我们的 GraphQL 服务器,以便开始实现后端。我们将使用 Apollo 和 Express.js 来构建后端的基础。
本章将解释 Express.js 的安装过程以及我们的 GraphQL 端点的配置。我们将快速浏览 Express.js 的所有基本功能以及我们后端的调试工具。
本章涵盖了以下主题:
-
Express.js 的安装和说明
-
Express.js 中的路由
-
Express.js 中的中间件
-
将 Apollo 服务器绑定到 GraphQL 端点
-
发送我们的第一个 GraphQL 请求
-
后端调试和日志记录
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
开始使用 Node.js 和 Express.js
本书的主要目标之一是设置一个 GraphQL API,然后由我们的 React 前端消费。为了接受网络请求——特别是 GraphQL 请求——我们将设置一个 Node.js 网络服务器。
在 Node.js 网络服务器领域,最显著的竞争对手是 Express.js、Koa 和 Hapi。在这本书中,我们将使用 Express.js。大多数关于 Apollo 的教程和文章都依赖于它。
Express.js 也是目前最常用的 Node.js 网络服务器,它将自己描述为一个 Node.js 网络框架,提供了构建 Web 应用程序所需的所有主要功能。
安装 Express.js 很简单。我们可以像上一章一样使用npm:
npm install --save express
此命令将 Express.js 的最新版本添加到package.json中。
在上一章中,我们直接在src/client文件夹中创建了所有 JavaScript 文件。现在,让我们为我们的服务器端代码创建一个单独的文件夹。这种分离给我们一个整洁的目录结构。我们可以使用以下命令创建此文件夹:
mkdir src/server
现在,我们可以继续配置 Express.js。
设置 Express.js
和往常一样,我们需要一个包含所有主要组件的根文件,以便将它们组合成一个真实的应用程序。
在server文件夹中创建一个index.js文件。此文件是后端的起点。以下是我们的操作方法:
-
首先,我们必须从
node_modules导入express,这是我们刚刚安装的:import express from 'express';我们可以在这里使用
import,因为我们的后端将被 Babel 转换。我们还将计划在第九章中设置 webpack 用于服务器端代码,实现服务器端渲染。 -
接下来,我们必须使用
express命令初始化服务器。结果存储在app变量中。我们后端所做的所有操作都是通过此对象执行的:const app = express(); -
然后,我们必须指定接受请求的路由。在这个简单的介绍中,我们使用
app.get方法接受所有匹配任何路径的 HTTPGET请求。其他 HTTP 方法可以用app.post和app.put捕获:app.get('*', (req, res) => res.send('Hello World!')); app.listen(8000, () => console.log('Listening on port 8000!'));
要匹配所有路径,你可以使用星号,它在编程领域中通常代表任何,正如我们在前面的app.get行中所做的那样。
所有app.METHOD函数的第一个参数是要匹配的路径。从这里,你可以提供无限数量的回调函数,它们将依次执行。我们将在使用 Express.js 进行路由部分中稍后查看此功能。
回调函数总是将客户端请求作为第一个参数接收,将响应作为第二个参数,这是服务器将要发送的。我们的第一个回调将使用send响应方法。
send函数仅仅发送 HTTP 响应。它将 HTTP 体设置为指定的内容。因此,在我们的例子中,体显示为Hello World!,而send函数则负责所有必要的标准 HTTP 头,例如Content-Length。
最后一步是告诉 Express.js 服务器应该在哪个端口上监听请求。在我们的代码中,我们使用app.listen的第一个参数8000。你可以将8000替换为你想要监听的任何端口或 URL。当 HTTP 服务器绑定到该端口并且可以接受请求时,将执行回调。
这是我们可以为 Express.js 设置的 simplest 配置。
在开发中运行 Express.js
为了启动我们的服务器,我们必须在我们的package.json文件中添加一个新的脚本。
让我们在package.json文件的scripts属性中添加以下行:
"server": "nodemon --exec babel-node --watch src/server src/server/index.js"
正如你所见,我们正在使用一个名为nodemon的命令。我们首先需要安装它:
npm install --save nodemon
nodemon是一个运行 Node.js 应用程序的优秀工具。当源代码发生变化时,它可以重新启动你的服务器。
例如,为了使前面的命令生效,请按照以下步骤操作:
-
首先,我们必须安装
@babel/node包,因为我们正在使用 Babel 通过--exec babel-node选项转译后端代码。这允许我们使用import语句:npm install --save-dev @babel/node -
当使用
nodemon跟踪路径或文件时,提供--watch选项将永久跟踪该文件或文件夹上的更改,并重新加载服务器以表示应用程序的最新状态。最后一个参数指的是实际文件,它是后端启动执行的起点。 -
启动服务器:
npm run server
现在,当你打开浏览器并输入http://localhost:8000时,你将看到来自我们的 Express.js 回调函数的文本Hello World!。
第三章,连接到数据库,详细介绍了 Express.js 的路由工作原理。
Express.js 中的路由
理解路由对于扩展我们的后端代码至关重要。在本节中,我们将通过一些简单的路由示例进行实践。
通常,路由处理应用程序如何以及在哪里响应特定的端点和方法。
在 Express.js 中,一个路径可以响应不同的 HTTP 方法,并且可以有多个处理函数。这些处理函数按照它们在代码中指定的顺序依次执行。路径可以是简单的字符串,也可以是复杂的正则表达式或模式。
当你使用多个处理函数时——无论是作为数组提供还是作为多个参数——确保将next传递给每个回调函数。当你调用next时,你将执行权从当前回调函数传递给行中的下一个函数。这些函数也可以是中间件。我们将在下一节中介绍这一点。
这里有一个简单的例子。用当前的app.get行替换它:
app.get('/', function (req, res, next) {
console.log('first function');
next();
}, function (req, res) {
console.log('second function');
res.send('Hello World!');
});
当你刷新浏览器时,查看终端中的服务器日志;你会看到first function和second function都被打印出来。如果你移除next的执行并尝试重新加载浏览器标签页,请求将超时,只有first function会被打印出来。这个问题发生是因为没有调用res.send、res.end或任何替代方法。当不运行next时,第二个处理函数永远不会执行。
如我们之前提到的,Hello World!消息很好,但不是我们能得到的最好的。在开发中,运行两个独立的服务器——一个用于前端,一个用于后端——是完全可行的。
提供我们的生产构建
我们可以通过 Express.js 提供我们的前端生产构建。这种方法对于开发目的来说不是很好,但对于测试构建过程和查看我们的实时应用程序将如何表现是有用的。
再次,用以下代码替换之前的路由示例:
import path from 'path';
const root = path.join(__dirname, '../../');
app.use('/', express.static(path.join(root, 'dist/client')));
app.use('/uploads', express.static(path.join(root,
'uploads')));
app.get('/', (req, res) => {
res.sendFile(path.join(root, '/dist/client/index.html'));
});
path模块提供了许多用于处理目录结构的函数。
我们使用全局的__dirname变量来获取我们的项目根目录。该变量包含当前文件的路径。使用path.join与../../和__dirname一起,我们可以得到我们项目的真实根目录。
Express.js 提供了use函数,当给定的路径匹配时,它会运行一系列命令。当不指定路径执行此函数时,它会对每个请求执行。
我们使用这个特性通过express.static来提供我们的静态文件(头像图像),包括bundle.js和bundle.css,这些文件是通过npm run client:build创建的。
在我们的情况下,首先,我们使用express.static跟随'/'。这样做的结果是,dist目录中的所有文件和文件夹都以'/'开头提供服务。app.use的第一个参数中的其他路径,例如'/example',会导致我们的bundle.js文件能够在'/example/bundle.js'下被下载。
例如,所有头像图像都在'/uploads/'下提供服务。
现在,我们已经准备好让客户端下载所有必要的文件。我们客户端的初始路由是 '/',如 app.get 所指定。对这个路径的响应是 index.html。我们运行 res.sendFile 和返回此文件的文件路径——这就是我们在这里要做的全部。
一定要先执行 npm run client:build。否则,你将收到一个错误消息,指出这些文件未找到。此外,当运行 npm run client 时,dist 文件夹将被删除,因此你必须重新运行构建过程。
现在刷新浏览器将显示来自 第一章,准备你的开发环境 的 后 文件和表单。
下一节将重点介绍 Express.js 中间件函数的强大功能。
使用 Express.js 中间件
Express.js 提供了编写高效后端的方法,无需重复代码。
每个中间件函数都会接收到一个请求、一个响应和 next。它需要运行 next 来将控制权传递给下一个处理函数。否则,你将收到一个超时。中间件允许我们预先或后处理请求或响应对象,执行自定义代码,等等。之前,我们已经介绍了 Express.js 中处理请求的简单示例。
Express.js 可以针对同一路径和 HTTP 方法拥有多个路由。中间件可以决定哪个函数应该被执行。
以下代码是一个简单的示例,展示了通常可以用 Express.js 完成的事情。你可以通过替换当前的 app.get 路由来测试它。
-
根路径
'/'用于捕获任何请求:app.get('/', function (req, res, next) { -
在这里,我们将使用
Math.random在 1 和 10 之间随机生成一个数字:var random = Math.random() * (10 -1) + 1; -
如果数字大于
5,我们将运行next('route')函数跳转到下一个具有相同路径的app.get:if (random > 5) next('route')这个路由将记录
'second'。 -
如果数字小于
0.5,我们将不带任何参数执行next函数,并转到下一个处理函数。这个处理函数将记录'first':else next() }, function (req, res, next) { res.send('first'); }) app.get('/', function (req, res, next) { res.send('second'); })
你不需要复制此代码,因为这只是一个解释示例。当涉及到特殊处理,如管理员用户和错误处理时,这个功能可能会很有用。
安装重要的中间件
对于我们的应用程序,我们已经在 Express.js 中使用了一个内置的中间件:express.static。在这本书中,我们将继续安装其他中间件:
npm install --save compression cors helmet
现在,在服务器的 index.js 文件中添加新包的 import 语句,以便在文件中提供所有依赖项:
import helmet from 'helmet';
import cors from 'cors';
import compress from 'compression';
让我们看看这些包的功能以及我们如何使用它们。
Express Helmet
Helmet 是一个工具,允许你设置各种 HTTP 头来保护你的应用程序。
我们可以在服务器的 index.js 文件中如下启用 Express.js Helmet 中间件。在 app 变量下方直接添加以下代码片段:
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "*.amazonaws.com"]
}
}));
app.use(helmet.referrerPolicy({ policy: 'same-origin' }));
我们在这里同时做了很多事情。在前面的代码中,我们仅通过在第一行使用helmet()函数,就添加了一些X-Powered-By HTTP 头信息,以及一些其他有用的东西。
注意
你可以在github.com/helmetjs/helmet查找默认参数以及 Helmet 的其他功能。在实现安全功能时,始终保持警觉,并尽你所能验证你的攻击防护方法。
此外,为了确保没有人可以注入恶意代码,我们使用了Content-Security-Policy HTTP 头信息,简称 CSP。这个头信息阻止攻击者从外部 URL 加载资源。
如你所见,我们还指定了imgSrc字段,这告诉我们的客户端只有来自这些 URL 的图片应该被加载,包括亚马逊网络服务(AWS)。我们将在第七章“处理图片上传”中学习如何将其上传。
你可以在helmetjs.github.io/docs/csp/了解更多关于 CSP 以及它如何使你的平台更安全的信息。
最后的增强是设置Referrer HTTP 头信息,但仅在向同一主机发出请求时。例如,当我们从域名 A 转到域名 B 时,我们不包含引用者,即用户来自的 URL。这个增强阻止了任何内部路由或请求暴露给互联网。
在你的 Express 路由器中非常高地初始化 Helmet 非常重要,这样所有响应都会受到影响。
使用 Express.js 进行压缩
启用 Express.js 的压缩可以为你和你的用户节省带宽,而且这很容易做到。以下代码也必须添加到服务器的index.js文件中:
app.use(compress());
这个中间件压缩了所有通过它的响应。请记住,在你的路由顺序中将其添加得非常高,以便所有请求都受到影响。
注意
无论何时你都有这样的中间件或多个匹配相同路径的路由,你都需要检查初始化顺序。除非你运行next命令,否则只有第一个匹配的路由会被执行。之后定义的所有路由将不会被执行。
Express.js 中的 CORS
我们希望我们的 GraphQL API 可以从任何网站、应用或系统中访问。一个不错的想法可能是构建一个应用或向其他公司或开发者提供 API,以便他们可以使用它。当你通过 Ajax 使用 API 时,主要问题是 API 需要发送正确的Access-Control-Allow-Origin头信息。
例如,如果你构建了 API,在https://api.example.com下进行宣传,并且尝试在不设置正确头信息的情况下从https://example.com访问它,那么它将不会工作。API 需要在Access-Control-Allow-Origin头信息中至少设置example.com以允许此域名访问其资源。这看起来有点繁琐,但它使你的 API 能够接受跨站请求,这一点你应该始终注意。
允许 index.js 文件:
app.use(cors());
此命令一次性处理了我们通常在跨源请求中遇到的所有问题。它仅仅在 Access-Control-Allow-Origin 中设置了一个带有 * 的通配符,允许来自任何地方的任何人使用您的 API,至少在最初是这样。您始终可以通过提供 API 密钥或仅允许已登录用户访问来保护您的 API。启用 CORS 只允许请求网站接收响应。
此外,该命令还实现了整个应用程序的 OPTIONS 路由。
每次我们使用 CORS 时,都会进行 OPTIONS 方法或请求。这个动作被称为 OPTIONS 预检,实际的 POST 等方法根本不会被浏览器执行。
我们的应用程序现在已准备好适当地服务所有路由并响应正确的头信息。
现在,让我们设置一个 GraphQL 服务器。
结合 Express.js 和 Apollo
首先,我们需要安装 Apollo 和 GraphQL 依赖项:
npm install --save apollo-server-express graphql @graphql-tools/schema
Apollo 提供了一个特定于 Express.js 的包,该包将其自身集成到 web 服务器中。还有一个没有 Express.js 的独立版本。Apollo 允许您使用可用的 Express.js 中间件。在某些情况下,您可能需要为不实现 GraphQL 或无法理解 JSON 响应的专有客户端提供非 GraphQL 路由。仍然有一些原因要提供一些回退到 GraphQL。在这些情况下,您可以依赖 Express.js,因为您已经在使用它了。
为服务创建一个单独的文件夹。一个服务可以是 GraphQL 或其他路由:
mkdir src/server/services/
mkdir src/server/services/graphql
在 graphql 文件夹中创建一个 index.js 文件,作为我们 GraphQL 服务的起点。它必须处理初始化的多项任务。让我们逐个过一遍它们,并将它们添加到 index.js 文件中:
-
首先,我们必须导入
apollo-server-express和@graphql-tools/schema包:import { ApolloServer } from 'apollo-server-express'; import { makeExecutableSchema } from '@graphql-tools/schema'; -
接下来,我们必须将 GraphQL 模式与
resolver函数结合。我们必须从单独的文件中导入相应的模式和解析函数。GraphQL 模式是 API 的表示——即客户端可以请求或运行的数据和函数。解析函数是模式的实现。两者都需要匹配。您不能返回不在模式中的字段或运行突变:import Resolvers from './resolvers'; import Schema from './schema'; -
@graphql-tools/schema包中的makeExecutableSchema函数将 GraphQL 模式和解析函数合并,解析我们将要写入的数据。当您定义一个不在模式中的查询或突变时,makeExecutableSchema函数会抛出一个错误。生成的模式由我们的 GraphQL 服务器执行,解析数据或运行我们请求的突变:const executableSchema = makeExecutableSchema({ typeDefs: Schema, resolvers: Resolvers }); -
我们将其作为
schema参数传递给 Apollo 服务器。context属性包含 Express.js 的request对象。在我们的解析函数中,如果我们需要,可以访问请求:const server = new ApolloServer({ schema: executableSchema, context: ({ req }) => req }); -
此
index.js文件导出初始化的服务器对象,该对象处理所有 GraphQL 请求:export default server;
现在我们正在导出 Apollo Server,它需要在其他地方导入。我发现,在服务层有一个 index.js 文件很方便,这样我们只有在添加新服务时才依赖于这个文件。
在 services 文件夹中创建一个 index.js 文件,并输入以下代码:
import graphql from './graphql';
export default {
graphql,
};
上述代码需要从 graphql 文件夹中的 index.js 文件中导入,并将所有服务重新导出到一个大的对象中。如果我们需要,我们还可以在这里定义更多服务。
为了使我们的 GraphQL 服务器对客户端公开可用,我们将 Apollo Server 绑定到 /graphql 路径。
将 index.js 文件导入到 server/index.js 文件中,如下所示:
import services from './services';
services 对象只包含 graphql 的索引。现在,我们必须使用以下代码将 GraphQL 服务器绑定到 Express.js 网络服务器:
const serviceNames = Object.keys(services);
for (let i = 0; i < serviceNames.length; i += 1) {
const name = serviceNames[i];
if (name === 'graphql') {
(async () => {
await services[name].start();
services[name].applyMiddleware({ app });
})();
} else {
app.use('/${name}', services[name]);
}
}
为了方便,我们遍历 services 对象的所有索引,并使用索引作为服务将被绑定的路由的名称。对于 services 对象中的 example 索引,路径将是 /example。对于一个典型的服务,例如 REST 接口,我们依赖于 Express.js 的标准 app.use 方法。
由于 Apollo Server 有其特殊性,当将其绑定到 Express.js 时,我们需要运行由初始化的 Apollo Server 提供的 applyMiddleware 函数,并避免使用 Express.js 的 app.use 函数。Apollo 会自动将自己绑定到 /graphql 路径,因为这是默认选项。如果您希望它从自定义路由响应,也可以包含一个 path 参数。
Apollo Server 要求我们在应用中间件之前运行 start 命令。由于这是一个异步函数,我们将整个代码块包裹在一个包装的 async 函数中,以便我们可以使用 await 语句。
现在还缺少两件事:模式和解析器。一旦我们完成这些,我们将执行一些测试 GraphQL 请求。模式是我们待办事项列表中的下一个任务。
编写您的第一个 GraphQL 模式
让我们从在 graphql 文件夹内创建一个 schema.js 文件开始。您也可以将多个较小的模式缝合成一个较大的模式。这样做会更干净,当您的应用程序、类型和字段增长时,这会更有意义。对于这本书,一个文件就足够了,我们可以将以下代码插入到 schema.js 文件中:
const typeDefinitions = '
type Post {
id: Int
text: String
}
type RootQuery {
posts: [Post]
}
schema {
query: RootQuery
}
';
export default [typeDefinitions];
上述代码代表了一个基本的模式,它至少能够从 第一章,准备您的开发环境,排除用户,提供伪造的帖子数组。
首先,我们必须定义一个新的类型,称为 Post。Post 类型有一个 id 为 Int 和一个 text 值为 String。
对于我们的 GraphQL 服务器,我们需要一个名为RootQuery的类型。RootQuery类型封装了客户端可以运行的所有查询。它可以是从请求所有帖子到所有用户,仅一个用户的帖子等等。你可以将其与你在常见 REST API 中找到的所有GET请求进行比较。路径将是/posts、/users和/users/ID/posts,以表示 GraphQL API 作为 REST API。当使用 GraphQL 时,我们只有一个路由,并且我们以类似 JSON 的对象发送查询。
我们将要执行的第一个查询将返回所有我们拥有的帖子数组。
如果我们查询所有帖子并希望返回每个用户及其对应的帖子,这将是一个子查询,它不会在我们的RootQuery类型中表示,而是在Post类型本身中。你稍后会看到这是如何实现的。
在类似 JSON 的模式末尾,我们将RootQuery添加到schema属性中。此类型是 Apollo Server 的起点。
然后,我们将向模式添加 mutation 键,我们将实现一个RootMutation类型。它将服务于用户可以运行的所有操作。突变与 REST API 的POST、UPDATE、PATCH和DELETE请求相当。
在文件末尾,我们将模式作为一个数组导出。如果我们想的话,我们可以将其他模式推送到这个数组中合并它们。
这里缺少的最后一件事情是我们解析器的实现。
实现 GraphQL 解析器
现在模式已经准备好了,我们需要匹配的解析器函数。
在graphql文件夹中创建一个resolvers.js文件,如下所示:
const resolvers = {
RootQuery: {
posts(root, args, context) {
return [];
},
},
};
export default resolvers;
resolvers对象持有所有类型作为属性。在这里,我们设置了RootQuery,以与我们在模式中相同的方式持有posts查询。resolvers对象必须等于模式,但必须是递归合并的。如果你想查询子字段,例如帖子的用户,你必须通过包含user函数的Post对象扩展resolvers对象,放在RootQuery旁边。
如果我们发送查询所有帖子的请求,posts函数将被执行。在那里,你可以做任何你想做的事情,但你需要返回与模式匹配的东西。所以,如果你有一个posts数组作为RootQuery的响应类型,你不能返回不同的东西,比如只返回一个帖子对象而不是数组。在这种情况下,你会收到一个错误。
此外,GraphQL 检查每个属性的 数据类型。如果id被定义为Int,你不能返回一个常规的 MongoDB id,因为这些 ID 是String类型。GraphQL 也会抛出一个错误。
注意
如果值类型匹配,GraphQL 会为你解析或转换特定的数据类型。例如,一个值为2.1的string可以无问题地解析为Float。另一方面,一个空字符串不能转换为Float,并且会抛出一个错误。直接拥有正确的数据类型会更好,因为这可以节省你转换,并防止出现不希望的问题。
为了证明一切正常工作,我们将通过向我们的服务器执行实际的 GraphQL 请求来继续。我们的 posts 查询将返回一个空数组,这是 GraphQL 的正确响应。我们稍后会回到 resolver 函数。你应该能够重新启动服务器,这样我们就可以发送一个演示请求。
发送 GraphQL 查询
我们可以使用任何 HTTP 客户端来测试这个查询,例如 Postman、Insomnia 或你习惯使用的任何客户端。下一节将介绍 HTTP 客户端。如果你想要自己发送以下查询,那么你可以阅读下一节,然后回到这里。
当你将以下 JSON 作为 POST 请求发送到 http://localhost:8000/graphql 时,你可以测试我们的新函数:
{
"operationName": null,
"query": "{
posts {
id
text
}
}",
"variables": {}
}
operationName 字段不是运行查询所必需的,但它对于日志记录非常有用。
query 对象是我们想要执行的查询的类似 JSON 的表示。在这个例子中,我们运行 RootQuery 帖子并请求每个帖子的 id 和 text 字段。我们不需要指定 RootQuery,因为它是我们 GraphQL API 的最高层。
variables 属性可以存储我们想要通过它来过滤帖子的用户 ID 等参数。如果你想要使用变量,它们也需要通过它们的名称在查询中定义。
对于不习惯使用 Postman 等工具的开发者,还有一个选项可以在单独的浏览器标签页中打开 /graphql 端点。你将看到一个专为轻松发送查询而制作的 GraphQLi 实例。在这里,你可以插入 query 属性的内容,然后点击播放按钮。由于我们设置了 Helmet 来保护我们的应用程序,我们需要在开发中将其停用。否则,GraphQLi 实例将无法工作。只需将完整的 Helmet 初始化用以下花括号中的 if 语句包裹在 server/index.js 文件中:
if(process.env.NODE_ENV === 'production')
这个简短的条件只在开发环境中激活 Helmet。现在,你可以使用 GraphQLi 或任何 HTTP 客户端发送请求。
当与前面的主体结合时,POST 请求的响应应该如下所示:
{
"data": {
"posts": []
}
}
这里,我们收到了预期的空帖子数组。
进一步来说,我们想要以我们客户端中静态编写的假数据作为响应,使其看起来像是来自我们的后端。从上面的 App.js 中复制 initialPosts 数组到 resolvers 对象上方,但将其重命名为 posts。我们可以用这个填充的 posts 数组来响应 GraphQL 请求。
将 GraphQL resolvers 中的 posts 函数内容替换为以下内容:
return posts;
你可以重新运行 POST 请求并接收两个假帖子。响应不包括我们假数据中的用户对象,因此我们必须在我们的模式中的 post 类型上定义一个用户属性来解决这个问题。
在 GraphQL 模式中使用多个类型
让我们创建一个 User 类型并将其与我们的帖子一起使用。首先,将其添加到模式中:
type User {
avatar: String
username: String
}
现在我们有了 User 类型,我们需要在 Post 类型中使用它。按照以下方式将其添加到 Post 类型中:
user: User
user字段允许我们在我们的帖子中有一个子对象,以及帖子的作者信息。
我们用来测试这个功能的扩展查询看起来是这样的:
"query":"{
posts {
id
text
user {
avatar
username
}
}
}"
你不能仅仅指定用户作为查询的属性。相反,你需要提供一个字段的子选择。当你有多个 GraphQL 类型嵌套在一起时,这是必需的。然后,你需要选择结果应包含的字段。
执行更新后的查询会给我们假数据,这些数据我们已经在我们的前端代码中有了;只是posts数组原样。
我们在查询数据方面已经取得了良好的进展,但我们还希望能够添加和更改数据。
编写你的第一个 GraphQL 突变
我们的客户端已经提供的一项服务是暂时向假数据中添加新帖子。我们可以在后端通过使用 GraphQL 突变来实现这一点。
从架构开始,我们需要添加突变,以及输入类型,如下所示:
input PostInput {
text: String!
}
input UserInput {
username: String!
avatar: String!
}
type RootMutation {
addPost (
post: PostInput!
user: UserInput!
): Post
}
GraphQL 输入不外乎是类型。突变可以在请求内部使用它们作为参数。它们可能看起来很奇怪,因为我们的当前输出类型看起来几乎相同。然而,在PostInput上有一个id属性,例如,这是不正确的,因为后端选择 ID,客户端无法提供它。因此,为输入和输出类型保留单独的对象是有意义的。
接收我们两个新必需输入类型PostInput和UserInput的addPost函数是一个新功能。这些函数被称为突变,因为它们会改变应用程序的当前状态。对此突变的响应是一个普通的Post对象。当使用addPost突变创建新帖子时,我们将直接从后端获取创建的帖子作为响应。
架构中的感叹号告诉 GraphQL 该字段是一个必需的参数。
RootMutation类型对应于RootQuery类型,是一个包含所有 GraphQL 突变的对象。
最后一步是启用 Apollo Server 的架构中的突变,通过将RootMutation类型应用到schema对象:
schema {
query: RootQuery
mutation: RootMutation
}
注意
通常,客户端不会在突变中发送用户。这是因为用户在添加帖子之前先进行认证,通过这种方式,我们已经知道哪个用户发起了 Apollo 请求。然而,我们暂时忽略这一点,稍后在第六章中实现认证,使用 Apollo 和 React 进行认证。
现在,需要在我们名为resolvers.js的文件中实现addPost解析器函数。
将以下RootMutation对象添加到resolvers.js中的RootQuery:
RootMutation: {
addPost(root, { post, user }, context) {
const postObject = {
...post,
user,
id: posts.length + 1,
};
posts.push(postObject);
return postObject;
},
},
此解析器从突变参数中提取post和user对象,这些参数作为函数的第二个参数传入。然后,我们构建postObject变量。我们希望通过解构post输入并添加user对象来将我们的posts数组作为属性添加。id字段只是posts数组的长度加一。
现在,postObject变量看起来就像posts数组中的post。我们的实现与前端已经做的相同。我们的addPost函数的返回值是postObject。为了使其工作,您需要将posts数组的初始化从const更改为let。否则,数组将是静态的,不可更改。
您可以通过您喜欢的 HTTP 客户端运行此突变,如下所示:
{
"operationName": null,
"query": "mutation addPost($post : PostInput!,
$user: UserInput!) {
addPost(post : $post, user: $user) {
id
text
user {
username
avatar
}
}
}",
"variables": {
"post": {
"text": "You just added a post."
},
"user": {
"avatar": "/uploads/avatar3.png",
"username": "Fake User"
}
}
}
首先,我们将单词mutation和实际要运行的函数名——在这个例子中是addPost——包括在query属性内的响应字段选择,传递给用于帖子数据的常规数据查询。
其次,我们使用variables属性来发送我们想要插入后端的数据。我们需要将它们作为参数包含在query字符串中。我们可以在operation字符串中定义这两个参数,使用美元符号和期待的数据类型。带有美元符号的变量随后会被映射到我们希望在后端触发的实际操作。
当我们发送这个突变时,请求将包含一个data对象,包括一个addPost字段。addPost字段包含我们随请求发送的帖子。
如果您再次查询帖子,您将看到现在有三个帖子。太好了——它成功了!
就像我们的客户端一样,这只是一个临时的,直到我们重启服务器。我们将在第三章“连接到数据库”中介绍如何在 SQL 数据库中持久化数据。
接下来,我们将介绍您调试后端的各种方法。
后端调试和日志记录
调试有两个非常重要的事情。首先,我们需要为后端实现日志记录,以防我们收到用户的错误,其次,我们需要查看 Postman 来有效地调试 GraphQL API。
那么,让我们开始记录日志。
Node.js 中的日志记录
Node.js 中最受欢迎的日志包叫做winston。按照以下步骤安装和配置winston:
-
使用
npm安装winston:npm install --save winston -
接下来,为后端的所有辅助函数创建一个新的文件夹:
mkdir src/server/helpers -
然后,在新的文件夹中插入一个
logger.js文件,内容如下:import winston from 'winston'; let transports = [ new winston.transports.File({ filename: 'error.log', level: 'error', }), new winston.transports.File({ filename: 'combined.log', level: 'verbose', }), ]; if (process.env.NODE_ENV !== 'production') { transports.push(new winston.transports.Console()); } const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports, }); export default logger;
此文件可以在我们想要记录日志的任何地方导入。
在前面的代码中,我们为winston定义了标准的transports。传输不过是winston如何将不同的日志类型分开并保存到不同的文件中。
第一个transport生成一个error.log文件,其中只保存真实错误。
第二个传输是一个组合日志,我们将保存所有其他日志消息,例如警告或信息日志。
如果我们在开发环境中运行服务器,我们现在就是这样做的,我们必须添加第三个传输。同时,我们将在服务器开发期间直接将所有消息记录到控制台。
大多数习惯于 JavaScript 开发的人都知道console.log的困难。通过直接使用winston,我们可以在终端中看到所有消息,但我们也无需从console.log中清理代码,只要我们记录的内容有意义即可。
为了测试这一点,我们可以在唯一的变异中尝试winston记录器。
在resolvers.js文件顶部添加以下代码:
import logger from '../../helpers/logger';
现在,我们可以在return语句之前添加以下内容来扩展addPost函数:
logger.log({ level: 'info', message: 'Post was created' });
当您现在发送变异时,您将看到消息被记录到控制台。
此外,如果您查看项目的根目录,您将看到error.log和combined.log文件。combined.log文件应包含来自控制台的操作日志。
现在我们能够记录服务器上的所有操作,我们应该探索 Postman,以便我们可以舒适地发送请求。
使用 Postman 进行调试
Postman是现有最广泛使用的 HTTP 客户端之一。它不仅提供了原始 HTTP 客户端功能,还提供了团队和集合,并允许您同步在 Postman 中保存的所有请求。
您可以通过从www.postman.com/downloads/下载适当的文件来安装 Postman。
注意
许多其他 HTTP 客户端工具对调试您的应用程序很有用。您可以使用您选择的工具。我使用的其他一些优秀客户端包括 Insomnia、SoapUI 和 Stoplight,但还有很多。在我看来,本书我们将使用 Postman,因为它是最受欢迎的。
安装完成后,它应该看起来像这样:

图 2.1 – 安装 Book 集合后的 Postman 屏幕
如您所见,我已在左侧面板中创建了一个名为Book的集合。这个集合包括我们的两个请求:一个请求所有帖子,一个添加新帖子。
例如,以下截图显示了在 Postman 中添加帖子变异的外观:

图 2.2 – Postman 中的添加帖子变异
URL 是localhost,包括预期的端口8000。
请求体看起来与之前我们看到的基本相同。请确保在raw格式旁边选择Content-Type为application/json。
注意
在我的情况下,我需要将查询内联编写,因为 Postman 无法处理 JSON 中的多行文本。如果您的情况不是这样,请忽略它。
由于 Postman 的新版本发布,现在也有选择 GraphQL 而不是 JSON 的选项。如果您这样做,您可以在多行中编写 GraphQL 代码,并在单独的窗口中编写变量。结果应该看起来像这样:

图 2.3 – 选择 GraphQL 的 Postman
如果你添加了一个新的请求,你可以使用 Ctrl + S 快捷键来保存它。你需要选择一个集合和一个名称来保存。使用 Postman(至少在使用 GraphQL API 时)的一个主要缺点是我们只使用 POST。如果能有一种方式来表明我们在做什么那就太好了——例如,一个查询或一个变更。一旦我们实现了它,我们就会学习如何在 Postman 中使用授权。
Postman 还拥有其他一些出色的功能,例如自动化测试、监控和模拟假服务器。
在本书的后面部分,为所有请求配置 Postman 将变得更加复杂。在这种情况下,我喜欢使用 Apollo Client 开发者工具,它们完美地集成到前端并利用 Chrome 开发者工具。Apollo Client 开发者工具的伟大之处在于,它们使用我们在前端代码中配置的 Apollo Client,这意味着它们重用了我们嵌入到前端中的认证。
摘要
在本章中,我们使用 Express.js 设置了我们的 Node.js 服务器,并将 Apollo Server 绑定到响应 GraphQL 端点的请求。我们可以处理查询,返回假数据,并通过 GraphQL 变更来修改数据。
此外,我们可以在我们的 Node.js 服务器中记录每个进程。使用 Postman 调试应用程序会导致经过良好测试的 API,这可以在我们前端后续使用。
在下一章中,我们将学习如何在 SQL 服务器中持久化数据。我们还将实现 GraphQL 类型的模型,并涵盖数据库迁移。我们需要用 Sequelize 通过查询替换我们当前的 resolver 函数。
有很多工作要做,所以请继续阅读以获取更多信息!
第三章:连接到数据库
我们的后端和前端可以使用假数据来通信、创建新帖子,并响应所有帖子的列表。我们列表中的下一步将是使用数据库,如 SQL 服务器,作为数据存储。
我们希望使用 Sequelize 将后端数据持久化到我们的 SQL 数据库。我们的 Apollo 服务器应根据需要使用这些数据来进行查询和突变。为了实现这一点,我们必须为我们的 GraphQL 实体实现数据库模型。
本章将涵盖以下主题:
-
在 GraphQL 中使用数据库
-
在 Node.js 中使用 Sequelize
-
编写数据库模型
-
使用 Sequelize 种植数据
-
使用 Apollo 与 Sequelize
-
使用 Sequelize 执行数据库迁移
技术要求
本章的源代码可在以下 GitHub 仓库中找到:github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition/tree/main/Chapter03。
在 GraphQL 中使用数据库
GraphQL 是一种用于发送和接收数据的协议。Apollo 是您可以使用来实现该协议的许多库之一。无论是 GraphQL(在其规范中)还是 Apollo,都不会直接在数据层上工作。您放入响应中的数据来源,以及您随请求发送的数据保存位置,由开发者决定。
这条逻辑表明,数据库和您使用的服务对 Apollo 来说并不重要,只要您响应的数据与 GraphQL 模式相匹配。
在本项目和书中,我们生活在 Node.js 生态系统之中,因此使用 MongoDB 是非常合适的。MongoDB 为 Node.js 提供了一个出色的客户端库,并且使用 JavaScript 作为其与交互和查询的原生语言选择。
MongoDB 这样的数据库系统的通用替代品是一个典型的 MySQL 服务器,它具有经过验证的稳定性和全球使用率。我经常遇到的一个案例是系统和应用程序依赖于较旧的代码库和数据库,需要进行升级。实现这一点的绝佳方法是获取一个 GraphQL API 层的叠加。在这种情况下,GraphQL 服务器接收所有请求,并且逐个替换 GraphQL 服务器所依赖的现有代码库。在这些情况下,GraphQL 的数据库无关性非常有帮助。
在本书中,我们将通过 Sequelize 使用 SQL 来查看现实世界用例中的此功能。为了未来的目的,这还将帮助您处理现有基于 SQL 的系统的问题。
为开发安装 MySQL
MySQL 是在开发职业生涯中起步的绝佳起点。它也非常适合在您的机器上进行本地开发,因为设置简单。
在您的机器上设置 MySQL 的方法取决于您的操作系统。正如我们在 第一章 中提到的,准备您的开发环境,我们假设您正在使用基于 Debian 的系统。为此,您可以使用以下说明。如果您已经为 MySQL 或 Apache 设置了工作环境,这些命令可能不起作用,或者可能根本不需要。
提示
对于其他操作系统,有优秀的预构建软件包。我建议所有 Windows 用户使用 XAMPP,Mac 用户使用 MAMP。这些提供了在 Linux 上手动执行的操作的简单安装过程。它们还实现了 MySQL、Apache 和 PHP,包括 phpMyAdmin。
重要提示
在设置用于公共和生产的真实 SQL 服务器时,不要遵循这些说明。专业的设置包括许多安全功能来保护您免受攻击。此安装仅应在开发环境中,在您的本地机器上使用。
执行以下步骤以启动 MySQL:
-
首先,您应该始终安装系统上可用的所有更新:
sudo apt-get update && sudo apt-get upgrade -y我们希望安装 MySQL 和一个 GUI 来查看我们数据库内部的内容。MySQL 服务器最常用的 GUI 是 phpMyAdmin。为此,您需要安装一个 Web 服务器和 PHP。我们将安装 Apache 作为我们的 Web 服务器。
重要提示
如果在过程中任何时刻收到一个错误,表明找不到该软件包,请确保您的系统是基于 Debian 的。在其他系统上的安装过程可能会有所不同。您可以在互联网上轻松搜索适合您系统的匹配软件包。
-
使用以下命令安装所有必要的依赖项:
sudo apt-get install apache2 mysql-server php php-pear php-mysql -
安装完成后,您需要在 root shell 中运行 MySQL 设置。您将需要输入 root 密码。或者,您可以运行
sudo -i:su - -
现在,您可以执行 MySQL 安装命令;按照提示步骤进行操作:
mysql_secure_installation您可以忽略这些步骤中的大部分以及安全设置,但在被要求输入您的 MySQL 实例的 root 密码时要小心。
-
我们必须为开发创建一个与 root 用户分开的单独用户。我们不鼓励您使用 root 用户。使用 root 用户登录我们的 MySQL 服务器以完成此操作:
mysql -u root -
现在,运行以下 SQL 命令。
PASSWORD string with the password that you want. This is the password that you will use for the database connection in your application, but also when logging into phpMyAdmin. This command creates a user called devuser, with root privileges that are acceptable for local development.NoteIf you are already using MySQL8, the command that you need execute is a little different. Just run the following lines:**CREATE USER 'devuser'@'%' IDENTIFIED BY 'PASSWORD';****GRANT ALL PRIVILEGES ON *.* TO 'devuser'@'%' WITH GRANT OPTION;****FLUSH PRIVILEGES;**The above commands will create a new user with the same permissions on your MySQL server. -
由于我们的 MySQL 服务器已经设置,您可以安装 phpMyAdmin。在执行以下命令时,您将被要求选择 Web 服务器。使用空格键选择
apache2,然后按 Tab 键导航到 ok。当被要求时,选择 phpMyAdmin 的自动设置方法。您不应手动进行此操作。此外,phpMyAdmin 将要求您输入密码。我建议您选择与 root 用户相同的密码:
sudo apt-get install phpmyadmin -
安装完成后,我们需要设置 Apache 以服务 phpMyAdmin。以下
ln命令在 Apache 公共HTML文件夹的根目录中创建了一个符号链接。现在,Apache 将服务 phpMyAdmin:cd /var/www/html/ sudo ln -s /usr/share/phpmyadmin
现在,我们可以在http://localhost/phpmyadmin下访问 phpMyAdmin,并使用新创建的用户登录。这应该看起来如下所示:

图 3.1 – phpMyAdmin
使用这种方式,我们已经为我们的开发环境安装了数据库。
phpMyAdmin 会根据您的环境选择语言,因此它可能与前一个截图显示的略有不同。
在 MySQL 中创建数据库
在我们开始实现后端之前,我们需要添加一个新的数据库,我们可以使用它。
您可以通过命令行或 phpMyAdmin 来做这件事。因为我们刚刚安装了 phpMyAdmin,我们将使用它。
您可以在 phpMyAdmin 的SQL标签页中运行原始 SQL 命令。创建新数据库的相应命令如下:
CREATE DATABASE graphbook_dev CHARACTER SET utf8 COLLATE utf8_general_ci;
否则,您可以按照下一组步骤使用图形方法。在左侧面板中,点击新建按钮。
您将看到一个如下所示的屏幕。它将显示所有数据库,包括它们的 MySQL 服务器的校对:

图 3.2 – phpMyAdmin 数据库
输入一个数据库名称,例如graphbook_dev,然后选择utf8_general_ci校对。完成这些操作后,点击创建。
您将看到一个页面,上面写着数据库中未找到表,这是正确的。在我们实现了数据库模型,如帖子(posts)和用户(users)之后,这将会改变。
在下一章中,我们将开始设置 Sequelize 在 Node.js 中的配置,并将其连接到我们的 SQL 服务器。
将 Sequelize 集成到我们的 Node.js 堆栈中
我们刚刚设置了一个 MySQL 数据库,我们想在我们的 Node.js 后端中使用它。有许多库可以连接和查询您的 MySQL 数据库。在这本书中,我们将使用 Sequelize。
替代对象关系映射器(ORM)
替代方案包括 Waterline ORM 和 js-data,它们提供了与 Sequelize 相同的功能。这些方案的好处是,它们不仅提供 SQL 方言,还提供了 MongoDB、Redis 等数据库适配器。因此,如果您需要替代方案,请查看它们。
Sequelize 是 Node.js 的 ORM。它支持 PostgreSQL、MySQL、SQLite 和 MSSQL 标准。
通过npm在您的项目中安装 Sequelize。我们还将安装第二个包,称为mysql2:
npm install --save sequelize mysql2
mysql2包允许 Sequelize 与我们的 MySQL 服务器通信。
Sequelize 是围绕不同数据库系统的各种库的包装器。它提供了直观模型使用的出色功能,以及创建和更新数据库结构以及插入开发数据的函数。
通常,您会在开始数据库连接或模型之前运行npx sequelize-cli init,但我更喜欢一种更定制的方法。从我的角度来看,这要干净一些。这也是我们为什么在额外的文件中设置数据库连接而不是依赖模板代码的原因。
传统设置 Sequelize
如果您想查看通常是如何做的,可以查看 Sequelize 文档中的官方教程。我们采取的方法和教程中的方法差异不大,但总是看到另一种做事的方式是好的。文档可以在sequelize.org/master/manual/migrations.html找到。
让我们从在后台设置 Sequelize 开始。
使用 Sequelize 连接到数据库
第一步是初始化 Sequelize 到我们的 MySQL 服务器的连接。为此,我们将创建一个新的文件夹和文件,如下所示:
mkdir src/server/database
touch src/server/database/index.js
在index.js数据库内部,我们将使用 Sequelize 与我们的数据库建立连接。内部,Sequelize 依赖于mysql2包,但我们自己并不使用它,这非常方便:
import Sequelize from 'sequelize';
const sequelize = new Sequelize('graphbook_dev', 'devuser', 'PASSWORD', {
host: 'localhost',
dialect: 'mysql',
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000,
},
});
export default sequelize;
如您所见,我们从node_modules加载 Sequelize,然后创建其实例。以下属性对 Sequelize 很重要:
-
我们将数据库名称作为第一个参数传递,这是我们刚刚创建的。
-
第二个和第三个参数是我们
devuser的凭证。用您为数据库输入的用户名和密码替换它们。devuser有权访问我们 MySQL 服务器中的所有数据库,这使得开发变得容易得多。 -
第四个参数是一个通用选项对象,可以包含更多属性。前面的对象是一个示例配置。
-
我们 MySQL 数据库的
host选项是我们的本地机器别名,localhost。如果不是这种情况,您也可以指定 MySQL 服务器的 IP 或 URL。 -
当然,
dialect是mysql。 -
使用
pool选项,您告诉 Sequelize 每个数据库连接的配置。前面的配置允许最小连接数为零,这意味着 Sequelize 不应该维护一个连接,而应该在其需要时创建一个新的连接。最大连接数为五个。此选项还与您的数据库系统拥有的副本集数量相关。pool选项中的idle字段指定了连接在关闭并被从活动连接池中移除之前可以多久未被使用。当尝试建立到我们的 MySQL 服务器的新的连接时,如果连接被中止,
acquire选项定义了超时时间。在无法创建连接的情况下,此选项有助于防止您的服务器冻结。
执行前面的代码将实例化 Sequelize 并成功创建到我们的 MySQL 服务器的连接。进一步来说,我们需要为我们的应用程序可以运行的每个环境(从开发到生产)处理多个数据库。你将在下一节中看到这一点。
使用 Sequelize 配置文件
我们之前使用 Sequelize 进行数据库连接的设置是可行的,但它并不是为后续部署而设计的。最佳选择是有一个独立的配置文件,该文件根据服务器运行的环境进行读取和使用。
为此,在database文件夹旁边(称为config)的单独文件夹中创建一个新的index.js文件:
mkdir src/server/config
touch src/server/config/index.js
如果你遵循了创建 MySQL 数据库的说明,你的样本配置应该如下代码所示。我们在这里所做的唯一一件事是将我们的当前配置复制到一个新的对象中,该对象以development或production环境为索引:
module.exports = {
"development": {
"username": "devuser",
"password": "PASSWORD",
"database": "graphbook_dev",
"host": "localhost",
"dialect": "mysql",
"pool": {
"max": 5,
"min": 0,
"acquire": 30000,
"idle": 10000
}
},
"production": {
"host": process.env.host,
"username": process.env.username,
"password": process.env.password,
"database": process.env.database,
"logging": false,
"dialect": "mysql",
"pool": {
"max": 5,
"min": 0,
"acquire": 30000,
"idle": 10000
}
}
}
Sequelize 默认期望在这个文件夹中有一个config.json文件,但这个设置将允许我们在后面的章节中采用更定制的方法。development环境直接存储数据库的凭证,而production配置使用环境变量来填充它们。
我们可以移除之前硬编码的配置,并将我们的database/index.js文件的内容替换为要求使用configFile。
它应该看起来如下:
import Sequelize from 'sequelize';
import configFile from '../config/';
const env = process.env.NODE_ENV || 'development';
const config = configFile[env];
const sequelize = new Sequelize(config.database,
config.username, config.password, config);
const db = {
sequelize,
};
export default db;
在前面的代码中,我们使用NODE_ENV环境变量来获取服务器正在运行的环境。我们读取config文件并将正确的配置传递给 Sequelize 实例。环境变量将允许我们在本书的稍后部分切换到新的环境,例如production。
然后,Sequelize 实例被导出以供我们整个应用程序使用。我们使用一个特殊的db对象来做这件事。你将在稍后看到我们为什么要这样做。
接下来,你将学习如何为应用程序将拥有的所有实体生成和编写模型和迁移。
编写数据库模型
通过 Sequelize 创建到我们的 MySQL 服务器的连接后,我们希望使用它。然而,我们的数据库缺少一个我们可以查询或操作的表或结构。创建这些是我们接下来需要做的事情。
目前,我们有两个 GraphQL 实体:User和Post。
Sequelize 允许我们为我们的每个 GraphQL 实体创建数据库模式。当我们在数据库中插入或更新行时,该模式会被验证。我们已经在schema.js文件中为 GraphQL 编写了一个模式,该文件由 Apollo Server 使用,但我们需要为我们的数据库创建第二个模式。字段类型以及字段本身可能在数据库和 GraphQL 模式之间有所不同。
GraphQL 模式可能比我们的数据库模型有更多的字段,或者相反。也许你不想通过 API 导出数据库中的所有数据,或者当你请求数据时,可能想动态地为你的 GraphQL API 生成数据。
让我们为我们的帖子创建第一个模型。在database文件夹旁边创建两个新文件夹(一个叫models,另一个叫migrations):
mkdir src/server/models
mkdir src/server/migrations
将每个模型分别放在单独的文件中,比所有模型放在一个大文件中要干净得多。
你的第一个数据库模型
我们将使用 Sequelize CLI 生成我们的第一个数据库模型。使用以下命令全局安装它:
npm install -g sequelize-cli
这让你能够在终端中运行sequelize命令。
Sequelize CLI 允许我们自动生成模型。这可以通过运行以下命令来完成:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Post --attributes text:text
Sequelize 期望我们在运行sequelize init的默认文件夹中运行命令。由于我们的文件结构不同,因为我们有两个src/server层,所以我们手动指定路径,即前两个参数;即--models-path和--migrations-path。
--name参数为我们的模型提供了一个名称,该名称可以用于使用。--attributes选项指定模型应包含的字段。
小贴士
如果你正在自定义你的设置,你可能想了解 CLI 提供的其他选项。你可以通过添加--help选项轻松查看每个命令的说明:sequelize model:generate --help。
此命令会在你的models文件夹中创建一个post.js模型文件,并在你的migrations文件夹中创建一个名为XXXXXXXXXXXXXX-create-post.js的数据库迁移文件。X图标表示你使用 CLI 生成文件时的日期和时间戳。你将在下一节中看到迁移是如何工作的。
以下是我们为我们创建的模型文件:
'use strict';
const {
Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class Post extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The 'models/index' file will call this method
automatically.
*/
static associate(models) {
// define association here
}
};
Post.init({
text: DataTypes.TEXT
}, {
sequelize,
modelName: 'Post',
});
return Post;
};
在这里,我们正在创建Post类,并从 Sequelize 扩展Model类。然后,我们使用 Sequelize 的init函数创建数据库模型:
-
第一个参数是模型属性。
-
第二个参数是一个
option对象,其中包含了sequelize连接实例和模型名称。模型定制
Sequelize 为我们提供了许多其他选项来定制我们的数据库模型。如果你想查找哪些选项可用,可以在
sequelize.org/master/manual/model-basics.html找到它们。
一个post对象具有id、text和user属性。用户将是一个单独的模型,如 GraphQL 模式所示。因此,我们只需要将id和text配置为帖子的列。
id是我们数据库中唯一标识数据记录的键。在运行model:generate命令时,我们不指定它,因为 MySQL 会自动生成。
text 列只是一个允许我们写入长帖子的 MySQL TEXT 字段。作为替代,还有其他 MySQL 字段类型,如 MEDIUMTEXT、LONGTEXT 和 BLOB,可以保存更多字符。对于我们的用例,一个常规的 TEXT 列应该就足够了。
Sequelize CLI 创建了一个模型文件,导出一个函数,执行后返回实际的数据库模型。你很快就会看到为什么这是一种初始化我们模型的好方法。
让我们看看 CLI 也创建的迁移文件。
你的第一个数据库迁移
到目前为止,MySQL 还不知道我们在其中保存帖子计划。我们的数据库表和列需要被创建,这就是为什么需要创建迁移文件。
迁移文件具有多个优点,如下所示:
-
迁移使我们能够通过常规版本控制系统(如 Git 或 SVN)跟踪数据库更改。我们数据库结构的每次更改都应该包含在迁移文件中。
-
迁移文件使我们能够编写更新,这些更新可以自动应用于我们应用程序的新版本中的数据库更改。
我们的第一个迁移文件创建了一个 Posts 表,并添加了所有必需的列,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Posts', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
text: {
type: Sequelize.TEXT
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('Posts');
}
};
按照惯例,迁移中的模型名称是复数形式,但在模型定义中是单数形式。我们的表名也是复数形式。Sequelize 提供了更改此设置的选择。
迁移有两个属性,如下所示:
-
up属性说明了在运行迁移时应执行的内容。 -
down属性说明了在撤销迁移时执行的内容。
如我们之前提到的,创建了 id 和 text 列,以及两个额外的 datetime 列,用于保存创建和更新时间。
id 字段已将 autoIncrement 和 primaryKey 设置为 true。id 将为表中每个帖子向上计数,从一几乎无限大。这个 id 为我们唯一标识帖子。通过将 allowNull 设置为 false,禁用了此功能,这样我们就可以插入一个空字段值的行。
要执行此迁移,我们将再次使用 Sequelize CLI,如下所示:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
在 phpMyAdmin 中查看。在这里,你会找到名为 posts 的新表。表的结构应该如下所示:
![Figure 3.3 – Posts table structure]
![img/Figure_3.03_B17337.jpg]
Figure 3.3 – Posts table structure
所有列都按照我们的期望创建。
此外,还创建了两个额外的字段 – createdAt 和 updatedAt。这两个字段告诉我们行是何时被创建或更新的。这些字段是由 Sequelize 自动创建的。如果你不希望这样,可以将模型中的 timestamps 属性设置为 false。
每次使用 Sequelize 和其迁移功能时,你将有一个名为 sequelizemeta 的额外表。该表的内容应该如下所示:
![Figure 3.4 – Migrations table]
![img/Figure_3.04_B17337.jpg]
![Figure 3.4 – Migrations table]
Sequelize 保存了所有已执行的迁移。如果我们开发或新发布周期中添加了更多字段,我们可以编写一个迁移来为我们运行所有表更改语句作为更新。Sequelize 跳过了保存在元表中的所有迁移。
一个主要步骤是将我们的模型绑定到 Sequelize。这个过程可以通过运行 sequelize init 自动化,但理解它将教会我们比依赖预制的样板命令多得多的东西。
使用 Sequelize 导入模型
我们希望一次性导入所有数据库模型到一个中央文件。然后,我们的数据库连接生成器将依赖于这个文件。
在 models 文件夹中创建一个 index.js 文件,并使用以下代码:
import Sequelize from 'sequelize';
if (process.env.NODE_ENV === 'development') {
require('babel-plugin-require-context-hook/register')()
}
export default (sequelize) => {
let db = {};
const context = require.context('.', true,
/^\.\/(?!index\.js).*\.js$/, 'sync')
context.keys().map(context).forEach(module => {
const model = module(sequelize, Sequelize);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
return db;
};
当运行 sequelize init 时,前面的逻辑也会生成,但这样,数据库连接是在一个单独的文件中设置的,而不是在加载模型时。通常,当使用 Sequelize 样板代码时,这会在一个文件中完成。此外,我们还引入了一些 webpack 特定的配置。
总结前面代码中发生的事情,我们搜索与当前文件相同的文件夹中所有以 .js 结尾的文件,并使用 require.context 语句加载它们。在开发中,我们必须执行 babel-plugin-require-context-hook/register 钩子来在顶部加载 require.context 函数。此包必须使用 npm 安装,以下命令:
npm install --save-dev babel-plugin-require-context-hook
我们需要在开发服务器的开始时加载 Babel 插件,因此,打开 package.json 文件并编辑 server 脚本,如下所示:
nodemon --exec babel-node --plugins require-context-hook --watch src/server src/server/index.js
当插件加载并运行 require('babel-plugin-require-context-hook/register')() 函数时,require.context 方法对我们可用。确保您将 NODE_ENV 变量设置为 development;否则,这不会工作。
在生产中,require.context 函数包含在 webpack 生成的包中。
加载的模型文件导出一个具有以下两个参数的函数:
-
在创建与我们的数据库连接后,我们的 Sequelize 实例
-
sequelize类本身,包括它提供的各种数据类型,如整数或文本
运行导出的函数导入实际的 Sequelize 模型。一旦所有模型都已导入,我们就遍历它们并检查它们是否有一个名为 associate 的函数。如果是这样,我们执行 associate 函数,并通过这种方式在多个模型之间建立关系。目前,我们还没有设置关联,但这一点将在本章的后面改变。
现在,我们想要使用我们的模型。回到 index.js 数据库文件,并通过我们刚刚创建的聚合 index.js 文件导入所有模型:
import models from '../models';
在文件末尾导出 db 对象之前,我们需要运行 models 包装器来读取所有模型 .js 文件。我们按照以下方式传递我们的 Sequelize 实例作为参数:
const db = {
models: models(sequelize),
sequelize,
};
前述命令中的新数据库对象有 sequelize 和 models 属性。在 models 下,你可以找到 Post 模型以及我们稍后将要添加的每个新模型。
数据库的 index.js 文件已经准备好,现在可以使用了。你应该只导入这个文件一次,因为当你创建多个 Sequelize 实例时,这可能会变得混乱。池功能将无法正常工作,我们最终会拥有比我们之前指定的五个最大连接数更多的连接。
我们必须在根服务器文件夹的 index.js 文件中创建全局数据库实例。添加以下代码:
import db from './database';
我们需要导入 database 文件夹以及该文件夹内的 index.js 文件。加载该文件将实例化 Sequelize 对象,包括所有数据库模型。
从现在开始,我们想要通过我们在 第二章 中实现的 GraphQL API 查询数据库中的某些数据,使用 Express.js 设置 GraphQL。
使用 Sequelize 种植数据
我们应该用我们的假数据填充空的 Posts 表。为了完成这个任务,我们将使用 Sequelize 的数据种植功能来向数据库中种植数据。
创建一个名为 seeders 的新文件夹:
mkdir src/server/seeders
现在,我们可以运行下一个 Sequelize CLI 命令来生成一个模板文件:
sequelize seed:generate --name fake-posts --seeders-path src/server/seeders
种子非常适合将测试数据导入数据库进行开发。我们的 seed 文件有时间和 fake-posts 这两个词,应该看起来如下:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
/*
Add altering commands here.
Return a promise to correctly handle asynchronicity.
Example:
return queryInterface.bulkInsert('Person', [{
name: 'John Doe',
isBetaMember: false
}], {});
*/
},
down: (queryInterface, Sequelize) => {
/*
Add reverting commands here.
Return a promise to correctly handle asynchronicity.
Example:
return queryInterface.bulkDelete('Person', null, {});
*/
}
};
如前述代码片段所示,这里没有做任何事情。它只是一个空的模板文件。我们需要编辑这个文件来创建我们已经在后端拥有的假帖子。这个文件看起来就像我们上一节中的迁移。将文件内容替换为以下代码:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Posts', [{
text: 'Lorem ipsum 1',
createdAt: new Date(),
updatedAt: new Date(),
},
{
text: 'Lorem ipsum 2',
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Posts', null, {});
}
};
在 up 迁移中,我们通过 queryInterface 和它的 bulkInsert 命令批量插入两个帖子。为此,我们将传递一个帖子数组,不包括 id 属性和关联的用户。这个 id 将自动创建,用户稍后保存在单独的表中。Sequelize 的 queryInterface 是 Sequelize 用来与所有数据库通信的通用接口。
在我们的种子文件中,我们需要添加 createdAt 和 updatedAt 字段,因为 Sequelize 不会为 MySQL 中的时间戳列设置默认值。实际上,Sequelize 会自己处理这些字段的默认值,但在种植数据时不会。如果你不提供这些值,种子将失败,因为 createdAt 和 updatedAt 不允许为 NULL。
down 迁移会批量删除表中的所有行,因为这显然是 up 迁移的逆操作。
使用以下命令执行 seeders 文件夹中的所有种子:
sequelize db:seed:all --seeders-path src/server/seeders --config src/server/config/index.js
Sequelize 不会检查或保存是否已经运行了种子,因为我们使用前述命令进行操作。这意味着如果你想的话,可以多次运行种子。
下面的截图显示了填充后的 Posts 表:

图 3.5 – 带有种子数据的帖子表
示例帖子现在在我们的数据库中。
在下一节中,我们将介绍如何使用 Sequelize 与我们的 Apollo Server,以及如何添加用户与其帖子之间的关系。
使用 Sequelize 与 GraphQL
数据库对象在根目录下的 index.js 文件启动服务器时初始化。我们从全局位置将其传递到依赖数据库的位置。这样,我们不需要重复导入数据库文件,而有一个单独的实例为我们处理所有数据库查询。
我们想要通过 GraphQL API 公布的服务需要访问我们的 MySQL 数据库。第一步是在我们的 GraphQL API 中实现帖子。它应该响应我们刚刚插入的数据库中的假帖子。
全球数据库实例
要将数据库传递到我们的 GraphQL 解析器中,我们必须在服务器 index.js 文件中创建一个新的对象:
import db from './database';
const utils = {
db,
};
在这里,我们在 database 文件夹的 import 语句下直接创建了一个 utils 对象。
utils 对象包含了我们的服务可能需要访问的所有实用工具。这可以是从第三方工具到我们的 MySQL 服务器,或任何其他数据库,如前述代码所示。
替换导入 services 文件夹的行,如下所示:
import servicesLoader from './services';
const services = servicesLoader(utils);
前述代码可能看起来有些奇怪,但我们在这里执行的是 import 语句的结果函数,并将 utils 对象作为参数传递。由于 import 语法不允许在一行中完成,我们必须在两行中执行此操作;因此,我们必须首先将 services 文件夹中导出的函数导入到一个单独的变量中。
到目前为止,import 语句的返回值是一个简单的对象。我们必须将其更改以匹配我们的要求。
要做到这一点,请转到 services 文件夹中的 index.js 文件,并按照以下方式更改文件内容:
import graphql from './graphql';
export default utils => ({
graphql: graphql(utils),
});
我们将前述 services 对象包围在一个函数中,然后导出该函数。该函数只接受一个参数,即我们的 utils 对象。
然后,这个对象被传递给一个新的函数,称为 graphql。我们将要使用的每个服务都必须是一个接受此参数的函数。这允许我们将任何我们想要的属性传递到我们应用程序的最深处。
当执行前述导出函数时,结果是之前使用的常规 services 对象。我们只是将其包裹在一个函数中,以传递 utils 对象。
我们正在执行的 graphql 导入需要接受 utils 对象。
打开 graphql 文件夹中的 index.js 文件,并将除顶部的 require 语句之外的所有内容替换为以下代码:
export default (utils) => {
const server = new ApolloServer({
typeDefs: Schema,
resolvers: Resolvers.call(utils),
context: ({ req }) => req
});
return server;
};
再次,我们用接受 utils 对象的函数包围了一切。所有这些的目的都是为了在我们的 GraphQL 解析器中访问数据库,这些解析器被传递给 ApolloServer。
为了实现这一点,我们使用了 JavaScript 的 Resolvers.call 函数。这个函数允许我们设置导出的 Resolvers 函数的所有者对象。我们在这里所说的就是,Resolvers 的作用域是 utils 对象。
因此,在 Resolvers 函数中,现在访问 this 给我们的是 utils 对象作为作用域。目前,Resolvers 只是一个简单的对象,但因为我们使用了 call 方法,所以我们还必须从 resolvers.js 文件中返回一个函数。
在此文件中,将 resolvers 对象包裹在一个函数中,并从函数内部返回 resolvers 对象:
export default function resolver() {
...
return resolvers;
}
我们不能使用之前使用的箭头语法。ES6 箭头语法会自动获取作用域,但我们要让 call 函数在这里接管。
另一种方法是将 utils 对象作为参数传递。我认为我们选择的方法稍微干净一些,但你可以按你喜欢的方式处理。
执行第一次数据库查询
现在,我们可以开始使用数据库了。将以下代码添加到 export default function resolver 语句的顶部:
const { db } = this;
const { Post } = db.models;
如前所述,this 关键字是当前方法的拥有者,它包含 db 对象。我们从上一节中构建的 db 对象中提取了数据库模型。
模型的优点在于你不需要直接对数据库编写原始查询。通过创建模型,你已经告诉 Sequelize 可以使用哪些字段和表。在这个阶段,你可以使用 Sequelize 的方法在你的解析器中运行数据库查询。
我们可以通过 Sequelize 模型查询所有帖子,而不是返回之前的假帖子。将 RootQuery 中的 posts 属性替换为以下代码:
posts(root, args, context) {
return Post.findAll({order: [['createdAt', 'DESC']]});
},
在前面的代码中,我们搜索并选择了我们数据库中所有的帖子。我们使用了 Sequelize 的 findAll 方法,并返回了它的结果。返回值将是一个 JavaScript promise,一旦数据库收集完数据,它就会自动解决。
一个典型的新闻源,如 Twitter 或 Facebook,会根据创建日期对帖子进行排序。这样,最新的帖子在顶部,最旧的帖子在底部。Sequelize 期望我们将作为 findAll 方法的第一个参数传递的排序属性的参数为一个数组数组。结果将按创建日期排序。
重要提示
Sequelize 提供了许多其他方法。您可以查询单个实体,计数它们,找到它们,如果未找到则创建它们,等等。您可以在sequelize.org/master/manual/model-querying-basics.html查找 Sequelize 提供的方法。
您可以使用 npm run server 启动服务器,并再次从 第二章,使用 Express.js 设置 GraphQL,执行 GraphQL 帖子查询。输出将如下所示:
{
"data": {
"posts": [{
"id": 1,
"text": "Lorem ipsum 1",
"user": null
},
{
"id": 2,
"text": "Lorem ipsum 2",
"user": null
}]
}
}
id 和 text 字段看起来很好,但 user 对象是 null。这是因为我们没有定义用户模型或声明用户与帖子模型之间的关系。我们将在下一节中更改这一点。
Sequelize 中的一对一关系
我们需要将每个帖子与一个用户关联起来,以填补我们在 GraphQL 响应中创建的空白。帖子必须有一个作者。没有关联用户的帖子是没有意义的。
首先,我们将生成一个 User 模型和迁移。我们将再次使用 Sequelize CLI,如下所示:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name User --attributes avatar:string,username:string
迁移文件创建了 Users 表并添加了 avatar 和 username 列。数据行看起来像我们假数据中的帖子,但它还包括一个自动生成的 ID 和两个时间戳,就像您之前看到的那样。
由于我们只创建了模型和迁移文件,用户与其特定帖子之间的关系仍然缺失。我们仍然需要添加帖子与用户之间的关系。这将在下一节中介绍。
每个帖子都需要一个额外的字段,称为 userId。此列作为外键,用于引用一个唯一的用户。然后,我们可以连接与每个帖子相关的用户。
注意
MySQL 为不习惯使用外键约束的人提供了很好的文档。如果您是其中之一,您应该阅读有关此主题的内容,请参阅dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html。
使用迁移更新表结构
我们必须编写第三个迁移,将 userId 列添加到我们的 Post 表中,并将其包括在我们的数据库 Post 模型中。
使用 Sequelize CLI 生成模板迁移文件非常容易:
sequelize migration:create --migrations-path src/server/migrations --name add-userId-to-post
您可以直接替换生成的迁移文件的内容,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.addColumn('Posts',
'userId',
{
type: Sequelize.INTEGER,
}),
queryInterface.addConstraint('Posts', {
fields: ['userId'],
type: 'foreign key',
name: 'fk_user_id',
references: {
table: 'Users',
field: 'id',
},
onDelete: 'cascade',
onUpdate: 'cascade',
}),
]);
},
down: async (queryInterface, Sequelize) => {
return Promise.all([
queryInterface.removeColumn('Posts', 'userId'),
]);
}
};
此迁移稍微复杂一些,我将分步骤解释:
-
在
up迁移中,我们使用queryInterface将userId列添加到Posts表中。 -
接下来,我们使用
addConstraint函数添加外键约束。此约束表示用户和帖子实体之间的关系。这种关系存储在Post表的userId列中。 -
当我在没有使用
Promise.all运行迁移时遇到了一些问题,Promise.all确保数组中的所有承诺都得到解决。只返回数组并没有运行addColumn和addConstraint方法。 -
前面的
addConstraint函数接收一个foreign key字符串作为type,这意味着数据类型与Users表中相应的列相同。我们希望给我们的约束起一个自定义名称fk_user_id,以便以后识别。 -
然后,我们正在指定
userId列的references字段。Sequelize 需要一个表,即Users表,以及我们的外键关联的字段,即User表的id列。这些都是建立有效数据库关系所需的一切。 -
此外,我们还将
onUpdate和onDelete约束更改为cascade。这意味着,当用户被删除或其用户 ID 更新时,这种变化会反映在用户的帖子中。删除用户会导致删除该用户的所有帖子,而更新用户的 ID 会更新所有用户帖子的 ID。我们不需要在应用程序代码中处理所有这些,这将是不高效的。注意
在 Sequelize 文档中关于这个主题有更多内容。如果你想了解更多,可以在
sequelize.org/master/manual/query-interface.html找到更多信息。
重新运行迁移以查看发生了什么变化:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
通过 Sequelize 运行迁移的好处是它会遍历 migrations 文件夹中所有可能的迁移。它排除了那些已经保存在 SequelizeMeta 表中的迁移,然后按时间顺序运行剩余的迁移。Sequelize 可以这样做,因为每个迁移的文件名中都包含了时间戳。
运行迁移后,应该会有一个 Users 表,并且 userId 列已经被添加到 Posts 表中。
在 phpMyAdmin 中查看 Posts 表的关系视图。你可以在 结构 视图中找到它,通过点击 关系视图:

图 3.6 – MySQL 外键
如你所见,我们有了外键约束。正确地取了名字,以及级联选项。
如果你运行迁移时收到错误,你可以轻松地撤销它们,如下所示:
sequelize db:migrate:undo --migrations-path src/server/migrations --config src/server/config/index.js
这个命令会撤销最近的迁移。始终要意识到你在做什么。如果你不确定一切是否正常工作,请保留备份。
你也可以一次性撤销所有迁移,或者只撤销到特定的迁移,这样你可以回到特定的日期和时间戳:
sequelize db:migrate:undo:all --to XXXXXXXXXXXXXX-create-posts.js --migrations-path src/server/migrations --config src/server/config/index.js
省略 --to 参数以撤销所有迁移。
这样,我们就建立了数据库关系,但 Sequelize 也必须知道这个关系。你将在下一节中学习如何做到这一点。
Sequelize 中的模型关联
现在我们已经通过外键配置了关系,它需要在我们的 Sequelize 模型内部进行配置。
返回到 Post 模型文件,并用以下代码替换 associate 函数:
static associate(models) {
this.belongsTo(models.User);
}
associate 函数在聚合的 index.js 文件中被评估,该文件中导入了所有模型文件。
我们在这里使用的是 belongsTo 函数,它告诉 Sequelize 每个帖子恰好属于一个用户。Sequelize 在 Post 模型上为我们提供了一个新函数,称为 getUser,用于检索关联的用户。这种命名是按照惯例进行的,正如你所看到的。Sequelize 会自动完成所有这些操作。
不要忘记将 userId 作为可查询字段添加到 Post 模型本身,如下所示:
userId: DataTypes.INTEGER,
User 模型也需要实现反向关联。将以下代码添加到 User 模型文件中:
static associate(models) {
this.hasMany(models.Post);
}
hasMany 函数与 belongsTo 函数正好相反。每个用户都可以在 Post 表中关联多个帖子。这可以是零个或多个帖子。
你可以将新的数据布局与前面的布局进行比较。到目前为止,我们有一个包含帖子和用户的对象大数组。现在,我们将每个对象拆分为两个表。两个表通过外键连接。每次运行 GraphQL 查询以获取所有帖子及其作者时,都需要这样做。
因此,我们必须扩展我们当前的 resolvers.js 文件。将 Post 属性添加到 resolvers 对象中,如下所示:
Post: {
user(post, args, context) {
return post.getUser();
},
},
RootQuery 和 RootMutation 是我们迄今为止拥有的两个主要属性。RootQuery 是所有 GraphQL 查询的起点。
在旧的演示帖子中,我们能够直接返回一个有效且完整的响应,因为我们需要的所有东西都在那里。现在,需要执行第二个查询或 JOIN 来收集完整响应所需的所有必要数据。
Post 实体被引入到我们的 resolvers 中,我们可以为 GraphQL 模式中的每个属性定义函数。响应中只缺少用户;其余都在那里。这就是为什么我们向解析器中添加了 user 函数。
函数的第一个参数是我们返回到 RootQuery 解析器中的 post 模型实例。
然后,我们使用 Sequelize 给我们的 getUser 函数。执行 getUser 函数将运行正确的 MySQL SELECT 查询,从 Users 表中获取正确的用户。它不会运行实际的 MySQL JOIN;它只在一个单独的 MySQL 命令中查询用户。稍后,在 GraphQL 中的聊天和消息 部分,你将了解另一种直接运行 JOIN 的方法,这更有效率。
然而,如果你通过 GraphQL API 查询所有帖子,用户仍然会是 null。我们还没有向数据库中添加任何用户,所以让我们接下来插入它们。
外键数据初始化
添加用户的挑战是我们已经向数据库中引入了外键约束。你可以按照以下说明来学习如何使其工作:
-
首先,我们必须使用 Sequelize CLI 生成一个空的
seeders文件,如下所示:sequelize seed:generate --name fake-users --seeders-path src/server/seeders -
填写以下代码以插入假用户:
'use strict'; module.exports = { up: async (queryInterface, Sequelize) => { return queryInterface.bulkInsert('Users', [{ avatar: '/uploads/avatar1.png', username: 'TestUser', createdAt: new Date(), updatedAt: new Date(), }, { avatar: '/uploads/avatar2.png', username: 'TestUser2', createdAt: new Date(), updatedAt: new Date(), }], {}); }, down: async (queryInterface, Sequelize) => { return queryInterface.bulkDelete('Users', null, {}); } };上述代码看起来像是帖子
seeders文件,但相反,我们现在正在使用正确的字段插入用户。每次插入用户时,我们的 MySQL 服务器都会为每个用户分配一个自动递增的 ID。 -
我们必须维护我们在数据库中配置的关系。调整
posts种子文件以反映这一点,并替换up迁移,以便为每个帖子插入正确的用户 ID:up: (queryInterface, Sequelize) => { // Get all existing users return queryInterface.sequelize.query( 'SELECT id from Users;', ).then((users) => { const usersRows = users[0]; return queryInterface.bulkInsert('Posts', [{ text: 'Lorem ipsum 1', userId: usersRows[0].id, createdAt: new Date(), updatedAt: new Date(), }, { text: 'Lorem ipsum 2', userId: usersRows[1].id, createdAt: new Date(), updatedAt: new Date(), }], {}); }); },
在这里,我们使用原始 MySQL 查询来获取所有用户及其 ID,以便我们可以与我们的帖子一起插入它们。这确保了我们有一个有效的、MySQL 允许我们插入的外键关系。
我们目前存储在表中的帖子没有接收 userId,我们不想为这些帖子编写单独的迁移或种子来修复它们。
这里有两种选择。你可以手动通过 phpMyAdmin 和 SQL 语句截断表,或者你可以使用 Sequelize CLI。使用 CLI 更容易,但无论如何结果都是一样的。以下命令将撤销所有种子:
sequelize db:seed:undo:all --seeders-path src/server/seeders --config src/server/config/index.js
在撤销种子时,表不会被截断,因此 autoIncrement 索引不会重置为 1;相反,它保持在当前索引。多次撤销种子会提高用户或帖子的 ID,这会阻止种子工作。我们通过使用在插入帖子之前检索当前用户 ID 的原始 MySQL 查询来解决这个问题。
在再次运行种子之前,我们遇到了一个问题:我们在 post 种子文件之后创建了 users 种子文件。这意味着帖子是在用户存在之前插入的,因为文件的时序。通常这不会是问题,但因为我们已经引入了外键约束,所以我们不能在底层用户不存在于我们的数据库中时插入带有 userId 的帖子。MySQL 禁止这样做。只需调整假用户种子文件的时序,使其早于帖子种子文件的时序,或者反之亦然。
在重命名文件后,再次运行所有种子,使用以下命令:
sequelize db:seed:all --seeders-path src/server/seeders --config src/server/config/index.js
如果你查看你的数据库,你应该会看到一个填充的 Posts 表,包括 userId。Users 表应如下所示:

图 3.7 – 用户表
现在,你可以重新运行 GraphQL 查询,你应该会看到用户和他们的帖子之间存在一个正常的工作关联,因为 user 字段已被填充。
到目前为止,我们已经取得了很多成就,因为我们可以通过匹配其模式通过 GraphQL API 从我们的数据库中提供数据。
注意
有一些方法可以自动化这个过程,通过额外的 npm 包。有一个包可以自动为您从数据库模型创建 GraphQL 模式。一如既往,当您不依赖于预配置的包时,您将更加灵活。您可以在www.npmjs.com/package/graphql-tools-sequelize找到这个包。
使用 Sequelize 突变数据
通过 GraphQL API 从我们的数据库请求数据是有效的。现在是困难的部分:将新帖子添加到Posts表中。
在我们开始之前,我们必须从我们的resolvers.js文件中导出函数顶部的db对象中提取新的数据库模型:
const { Post, User } = db.models;
目前,我们还没有身份验证来识别创建帖子的用户。我们将伪造这一步,直到身份验证被实现 第六章,使用 Apollo 和 React 进行身份验证。
我们必须编辑 GraphQL 解析器以添加新帖子。将旧的addPost函数替换为新的,如下代码片段所示:
addPost(root, { post }, context) {
return User.findAll().then((users) => {
const usersRow = users[0];
return Post.create({
...post,
}).then((newPost) => {
return Promise.all([
newPost.setUser(usersRow.id),
]).then(() => {
logger.log({
level: 'info',
message: 'Post was created',
});
return newPost;
});
});
});
},
总是如此,前面的突变返回一个承诺。这个承诺在最深层的查询成功执行后解决。执行顺序如下:
-
我们通过
User.findAll方法从数据库中检索所有用户。 -
我们使用 Sequelize 的
create函数将帖子插入到我们的数据库中。我们传递的唯一属性是从原始请求中的post对象,它只包含帖子的文本。MySQL 自动生成帖子的id属性。注意
Sequelize 还提供了一个
build函数,它可以为我们初始化模型实例。在这种情况下,我们必须运行save方法来手动插入模型。create函数为我们一次性完成所有这些。 -
帖子已创建,但
userId尚未设置。您也可以直接将用户 ID 添加到
Post.create函数中。问题在于,即使这在数据库中有所反映,我们也不会建立模型关联。如果我们不显式使用setUser在模型实例上返回创建的帖子模型,我们就无法使用getUser函数,该函数用于返回突变响应的用户。因此,为了解决这个问题,我们必须运行
create函数,解决承诺,然后单独运行setUser。作为setUser的参数,我们静态地取users数组中的第一个用户的 ID。我们通过使用包围在
Promise.all中的数组来解决setUser函数的承诺。这允许我们稍后添加更多的 Sequelize 方法。例如,您也可以为每个帖子添加一个类别。 -
一旦我们正确设置了
userId,返回的值是新建的帖子模型实例。
一切都已就绪。为了测试我们的 API,我们将再次使用 Postman。我们需要更改 addPost 请求。之前添加的 userInput 现在不再需要,因为后端静态地选择数据库中的第一个用户。你可以发送以下请求体:
{
"operationName": null,
"query": "mutation addPost($post : PostInput!) {
addPost(post : $post) {
id text user { username avatar }}}",
"variables":{
"post": {
"text": "You just added a post."
}
}
}
你的 GraphQL 模式必须反映这一变化,因此也要从那里删除 userInput:
addPost (
post: PostInput!
): Post
现在运行 addPost GraphQL 演变现在会将帖子添加到 Posts 表中,如下截图所示:

图 3.8 – 帖子已插入数据库表
由于我们不再使用演示 posts 数组,你可以将其从 resolvers.js 文件中删除。
这样,我们就重新构建了前一章的示例,但我们使用的是后端数据库。为了扩展我们的应用程序,我们将添加两个新的实体,分别称为 Chat 和 Message。
多对多关系
Facebook 为用户提供各种互动方式。目前,我们只有请求和插入帖子的机会。正如在 Facebook 上一样,我们想要与我们的朋友和同事进行聊天。我们将引入两个新的实体来覆盖这一点。
第一个实体称为 Chat,第二个实体称为 Message。
在开始实施之前,我们需要制定一个详细的计划,说明这些实体将使我们能够做什么。
一个用户可以有多个聊天,一个聊天也可以属于多个用户。这种关系使我们能够与多个用户进行群聊,以及仅限于两个用户之间的私密聊天。一条消息属于一个用户,但每条消息也属于一个聊天。
模型和迁移
在将其转换为实际代码时,我们必须生成 Chat 模型。这里的问题是用户和聊天之间存在多对多关系。在 MySQL 中,这种关系需要一个表来分别存储所有实体之间的关系。
这些表称为 user_chats。用户的 ID 和聊天的 ID 在这个表中相互关联。如果一个用户参与多个聊天,他们将在表中有多行,具有不同的聊天 ID。
聊天模型
让我们先创建 Chat 模型和迁移。聊天本身不存储任何数据;我们用它来分组特定用户的消息:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Chat --attributes firstName:string,lastName:string,email:string
生成我们的关联表的迁移,如下所示:
sequelize migration:create --migrations-path src/server/migrations --name create-user-chats
调整由 Sequelize CLI 生成的 users_chats 迁移。我们指定用户和聊天 ID 作为我们关系的属性。迁移内部的自引用会自动为我们创建外键约束。迁移文件应如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.createTable('users_chats', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
userId: {
type: Sequelize.INTEGER,
references: {
model: 'Users',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
chatId: {
type: Sequelize.INTEGER,
references: {
model: 'Chats',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.dropTable('users_chats');
}
};
对于关联表不需要单独的模型文件,因为我们可以在需要关联的模型中依赖这个表。id 列可以省略,因为行只能由用户和聊天 ID 来识别。
通过新的关系表在User模型中将User模型与Chat模型关联起来,如下所示:
this.belongsToMany(models.Chat, { through: 'users_chats' });
对于Chat模型也做同样的操作,如下所示:
this.belongsToMany(models.User, { through: 'users_chats' });
through属性告诉 Sequelize 这两个模型通过users_chats表相关联。通常,当你不使用 Sequelize 并且尝试使用原始 SQL 选择所有合并的用户和聊天时,你需要手动维护这种关联并自己连接三个表。Sequelize 的查询和关联能力非常复杂,所以这一切都为你做好了。
重新运行迁移以使更改生效:
sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js
以下截图显示了你的数据库现在应该看起来是什么样子:
![Figure 3.9 – Database structure]
![Figure 3.09_B17337.jpg]
![Figure 3.9 – Database structure]
你应该在users_chats表的关系视图中看到两个外键约束。命名是自动完成的:
![Figure 3.10 – Foreign keys for the users_chats table]
![Figure 3.10_B17337.jpg]
图 3.10 – users_chats表的外键
这个设置是难点。接下来是消息实体,这是一个简单的一对一关系。一条消息属于一个用户和一个聊天。
消息模型
一条消息就像一个帖子,只不过它只能在聊天中读取,不对所有人公开。
使用 CLI 生成模型和迁移文件,如下所示:
sequelize model:generate --models-path src/server/models --migrations-path src/server/migrations --name Message --attributes text:string,userId:integer,chatId:integer
通过替换以下属性,将缺失的引用添加到创建的迁移文件中:
userId: {
type: Sequelize.INTEGER,
references: {
model: 'Users',
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'cascade',
},
chatId: {
type: Sequelize.INTEGER,
references: {
model: 'Chats',
key: 'id'
},
onDelete: 'cascade',
onUpdate: 'cascade',
},
现在,我们可以再次运行迁移来创建Messages表,使用sequelize db:migrate终端命令。
这些引用也适用于我们的模型文件,在那里我们需要使用 Sequelize 的belongsTo函数来获取所有那些方便的模型方法供我们的解析器使用。将Message模型的associate函数替换为以下代码:
static associate(models) {
this.belongsTo(models.User);
this.belongsTo(models.Chat);
}
在前面的代码中,我们定义了每条消息都与恰好一个用户和一个聊天相关联。
另一方面,我们还必须将Chat模型与消息关联起来。将以下代码添加到Chat模型的associate函数中:
this.hasMany(models.Message);
下一步是调整我们的 GraphQL API 以提供聊天和消息。
GraphQL 中的聊天和消息
到目前为止,我们已经引入了一些带有消息和聊天的新的实体。让我们将这些包括到我们的 Apollo 模式中。在以下代码中,你可以看到我们 GraphQL 模式中更改的实体、字段和参数的摘录:
type User {
id: Int
avatar: String
username: String
}
type Post {
id: Int
text: String
user: User
}
type Message {
id: Int
text: String
chat: Chat
user: User
}
type Chat {
id: Int
messages: [Message]
users: [User]
}
type RootQuery {
posts: [Post]
chats: [Chat]
}
看看我们 GraphQL 模式的以下简短变更日志:
-
User类型由于我们的数据库而获得了id字段。 -
Message类型完全是新的。它有一个文本字段,就像典型的消息一样,还有用户和聊天字段,这些字段是从数据库模型中引用的表中请求的。 -
Chat类型也是新的。一个聊天包含一个消息列表,这些消息作为数组返回。这些可以通过聊天 ID 进行查询,该 ID 保存在消息表中。此外,一个聊天有一个未指定的用户数量。用户和聊天之间的关系保存在我们单独的连接表中。这里有趣的是,我们的模式对此表一无所知;它只是用于我们内部适当保存数据在我们的 MySQL 服务器上。 -
我还添加了一个新的
RootQuery,称为chats。此查询返回所有用户的聊天。
这些因素也应该在我们的解析器中实现。我们的解析器应该如下所示:
Message: {
user(message, args, context) {
return message.getUser();
},
chat(message, args, context) {
return message.getChat();
},
},
Chat: {
messages(chat, args, context) {
return chat.getMessages({ order: [['id', 'ASC']] });
},
users(chat, args, context) {
return chat.getUsers();
},
},
RootQuery: {
posts(root, args, context) {
return Post.findAll({order: [['createdAt', 'DESC']]});
},
chats(root, args, context) {
return User.findAll().then((users) => {
if (!users.length) {
return [];
}
const usersRow = users[0];
return Chat.findAll({
include: [{
model: User,
required: true,
through: { where: { userId: usersRow.id } },
},
{
model: Message,
}],
});
});
},
},
让我们逐个查看这些更改:
-
我们在我们的解析器中添加了
Message属性。 -
我们在
resolvers对象中添加了Chat属性。在那里,我们运行getMessages和getUsers函数,以检索所有关联的数据。所有消息都是按 ID 升序排序的(例如,以在聊天窗口底部显示最新消息)。 -
我添加了一个新的
RootQuery,称为chats,以返回所有字段,如我们的模式所示:a) 在我们得到有效的身份验证之前,我们将静态使用第一个用户来查询所有聊天。
b) 我们正在使用 Sequelize 的
findAll方法并连接任何返回的聊天中的用户。为此,我们在findAll方法中的User模型上使用 Sequelize 的include属性。它运行一个 MySQLJOIN,而不是第二个SELECT查询。c) 将
include语句设置为required会默认运行一个INNER JOIN,而不是LEFT OUTER JOIN。任何不匹配through属性中条件的聊天将被排除。在我们的例子中,条件是用户 ID 必须匹配。d) 最后,我们以相同的方式连接每个聊天中所有可用的消息,没有任何条件。
我们必须在这里使用新的模型。我们不应该忘记在resolver函数内部从db.models对象中提取它们。它必须如下所示:
const { Post, User, Chat, Message } = db.models;
你可以发送这个 GraphQL 请求来测试更改:
{
"operationName":null,
"query": "{ chats { id users { id } messages { id text
user { id username } } } }",
"variables":{}
}
响应应该给我们一个空的chats数组,如下所示:
{
"data": {
"chats": []
}
}
这个空数组被返回,因为我们数据库中没有聊天或消息。你将在下一节中学习如何用数据填充它。
种植多对多数据
测试我们的实现需要数据在我们的数据库中。我们有三个新的表,因此我们将创建三个新的种子文件来获取一些测试数据来工作。
让我们从聊天开始,如下所示:
sequelize seed:generate --name fake-chats --seeders-path src/server/seeders
现在,用以下代码替换新的种子文件。运行以下代码会在我们的数据库中创建一个聊天。我们不需要超过两个时间戳,因为聊天 ID 是自动生成的:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Chats', [{
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Chats', null, {});
}
};
接下来,我们必须插入两个用户和新的聊天之间的关系。我们可以通过在users_chats表中创建两个条目来实现,其中引用它们。现在,生成以下模板种子文件:
sequelize seed:generate --name fake-chats-users-relations --seeders-path src/server/seeders
我们的种子应该看起来与之前的类似,如下所示:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const usersAndChats = Promise.all([
queryInterface.sequelize.query(
'SELECT id from Users;',
),
queryInterface.sequelize.query(
'SELECT id from Chats;',
),
]);
return usersAndChats.then((rows) => {
const users = rows[0][0];
const chats = rows[1][0];
return queryInterface.bulkInsert('users_chats', [{
userId: users[0].id,
chatId: chats[0].id,
createdAt: new Date(),
updatedAt: new Date(),
},
{
userId: users[1].id,
chatId: chats[0].id,
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('users_chats', null, {});
}
};
在 up 迁移中,我们使用 Promise.all 解决所有用户和聊天。这确保了当承诺解决时,所有聊天和用户同时可用。为了测试聊天功能,我们选择数据库返回的第一个聊天和前两个用户。我们取他们的 ID 并将它们保存到我们的 users_chats 表中。这两个用户应该能够通过这个聊天互相交谈。
最后一个没有任何数据的表是 Messages 表。按照以下方式生成种子文件:
sequelize seed:generate --name fake-messages --seeders-path src/server/seeders
再次,按照以下方式替换生成的样板代码:
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const usersAndChats = Promise.all([
queryInterface.sequelize.query(
'SELECT id from Users;',
),
queryInterface.sequelize.query(
'SELECT id from Chats;',
),
]);
return usersAndChats.then((rows) => {
const users = rows[0][0];
const chats = rows[1][0];
return queryInterface.bulkInsert('Messages', [{
userId: users[0].id,
chatId: chats[0].id,
text: 'This is a test message.',
createdAt: new Date(),
updatedAt: new Date(),
},
{
userId: users[1].id,
chatId: chats[0].id,
text: 'This is a second test message.',
createdAt: new Date(),
updatedAt: new Date(),
}],
{});
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Messages', null, {});
}
};
现在,所有种子文件都应该准备好了。在运行种子之前清空所有表是有意义的,这样你就可以使用干净的数据。我喜欢不时地删除数据库中的所有表,并重新运行所有迁移和种子来从零开始测试它们。无论你是否这样做,你至少应该能够运行新的种子。
尝试再次运行 GraphQL 的 chats 查询。它应该看起来如下所示:
{
"data": {
"chats": [{
"id": 1,
"users": [
{
"id": 1
},
{
"id": 2
}
],
"messages": [
{
"id": 1,
"text": "This is a test message.",
"user": {
"id": 1,
"username": "Test User"
}
},
{
"id": 2,
"text": "This is a second test message.",
"user": {
"id": 2,
"username": "Test User 2"
}
}
]}
]
}
}
太好了!现在,我们可以请求用户参与的所有聊天,并获取所有引用的用户及其消息。
现在,我们也想为单个聊天做同样的事情。按照以下步骤操作:
-
添加一个接受
chatId参数的RootQuery聊天:chat(root, { chatId }, context) { return Chat.findByPk(chatId, { include: [{ model: User, required: true, }, { model: Message, }], }); },在这种实现中,我们遇到的问题是所有用户都可以向我们的 Apollo 服务器发送查询,并作为回报,获取完整的聊天历史,即使他们没有被引用在聊天中。我们只有在稍后实现身份验证后才能解决这个问题,如第六章中所述,使用 Apollo 和 React 进行身份验证。
-
将新的查询添加到 GraphQL 模式中的
RootQuery下:chat(chatId: Int): Chat -
按照以下方式发送 GraphQL 请求以测试实现:
{ "operationName":null, "query": "query($chatId: Int!){ chat(chatId: $chatId) { id users { id } messages { id text user { id username } } } }", "variables":{ "chatId": 1 } }
在这里,我们发送这个查询,包括 chatId 作为参数。要传递参数,你必须在查询中定义它及其 GraphQL 数据类型。然后,你可以在你正在执行的特定 GraphQL 查询中设置它,即 chat 查询。最后,你必须将参数的值插入到 GraphQL 请求的 variables 字段中。
你可能还记得上次的响应。新的响应将看起来与 chats 查询的结果非常相似,但我们将只有一个 chat 对象,而不是聊天数组。
我们缺少一个主要功能:发送新消息或创建新的聊天。我们将在下一节创建相应的模式及其解析器。
创建新的聊天
新用户希望与他们的朋友聊天,因此创建一个新的聊天是必不可少的。
最好的方法是接受用户 ID 的列表,这样我们也可以允许群聊。按照以下方式操作:
-
在
resolvers.js文件中添加addChat函数到RootMutation,如下所示:addChat(root, { chat }, context) { return Chat.create().then((newChat) => { return Promise.all([ newChat.setUsers(chat.users), ]).then(() => { logger.log({ level: 'info', message: 'Message was created', }); return newChat; }); }); },Sequelize 为聊天模型实例添加了
setUsers函数。这是由于在聊天模型中使用belongsToMany方法建立关联而添加的。在那里,我们可以直接提供一个用户 ID 数组,这些用户 ID 应与新的聊天相关联,通过users_chats表。 -
修改模式以便运行 GraphQL 变异体。我们必须添加新的输入类型和变异体,如下所示:
input ChatInput { users: [Int] } type RootMutation { addPost ( post: PostInput! ): Post addChat ( chat: ChatInput! ): Chat } -
测试新的 GraphQL
addChat变异体,将以下内容作为请求体:{ "operationName":null, "query": "mutation addChat($chat: ChatInput!) { addChat(chat: $chat) { id users { id } }}", "variables":{ "chat": { "users": [1, 2] } } }
你可以通过检查 chat 对象中返回的用户来验证一切是否正常工作。
创建新消息
我们可以将 addPost 变异体作为基础并对其进行扩展。结果接受一个 chatId 并使用我们数据库中的第一个用户。稍后,认证将成为用户 ID 的来源:
-
将
addMessage函数添加到resolvers.js文件中的RootMutation,如下所示:addMessage(root, { message }, context) { return User.findAll().then((users) => { const usersRow = users[0]; return Message.create({ ...message, }).then((newMessage) => { return Promise.all([ newMessage.setUser(usersRow.id), newMessage.setChat(message.chatId), ]).then(() => { logger.log({ level: 'info', message: 'Message was created', }); return newMessage; }); }); }); }, -
然后,将新的变异体添加到你的 GraphQL 模式。我们还有一个新的消息输入类型:
input MessageInput { text: String! chatId: Int! } type RootMutation { addPost ( post: PostInput! ): Post addChat ( chat: ChatInput! ): Chat addMessage ( message: MessageInput! ): Message } -
你可以像发送
addPost请求一样发送请求:{ "operationName":null, "query": "mutation addMessage($message : MessageInput!) { addMessage(message : $message) { id text }}", "variables":{ "message": { "text": "You just added a message.", "chatId": 1 } } }
现在,一切都已经设置好了。客户端现在可以请求所有帖子、聊天和消息。此外,用户可以创建新的帖子、创建新的聊天室,并发送聊天消息。
摘要
本章的目标是创建一个具有数据库作为存储的工作后端,我们做得相当不错。我们可以添加更多实体,并使用 Sequelize 进行迁移和初始化。当涉及到进入生产环境时,迁移我们的数据库更改对我们来说不会是问题。
在本章中,我们还介绍了 Sequelize 在使用其模型时为我们自动执行的操作,以及它与我们的 Apollo 服务器协同工作的出色表现。
在下一章中,我们将重点介绍如何使用 Apollo React 客户端库与我们的后端以及其背后的数据库。
第二部分:构建应用程序
知道如何编写 React 应用程序,并将其与 GraphQL 和其他技术结合以构建实际用例,这是最困难的部分。本节将帮助你自信地自己构建应用程序,同时教你 React、身份验证、路由、服务器端渲染以及许多其他功能是如何工作的。
在本节中,包含以下章节:
-
第四章, 将 Apollo Hook 集成到 React 中
-
第五章, 可重用 React 组件和 React Hooks
-
第六章, 使用 Apollo 和 React 进行身份验证
-
第七章, 处理图像上传
-
第八章, React 中的路由
-
第九章, 实现服务器端渲染
-
第十章, 实时订阅
-
第十一章, 为 React 和 Node.js 编写测试
第四章:将 Apollo 集成到 React 中
Sequelize 使我们能够轻松访问和查询我们的数据库。帖子、聊天和信息可以瞬间保存到我们的数据库中。React 通过构建用户界面(UI)帮助我们查看和更新我们的数据。
在本章中,我们将向我们的前端引入 Apollo 的 React 客户端,以便将其与后端连接。我们将使用前端查询、创建和更新帖子数据。
本章将涵盖以下主题:
-
安装和配置 Apollo 客户端
-
使用 GQL 和 Apollo 的 Query 组件发送请求
-
使用 Apollo 客户端修改数据
-
实现聊天和信息
-
React 和 GraphQL 中的分页
-
使用 Apollo 客户端 Devtools 进行调试
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
安装和配置 Apollo 客户端
在开发过程中,我们已经多次测试了我们的 GraphQL 应用程序编程接口(API)。现在,我们可以开始实现前端代码的数据层。在后面的章节中,我们将专注于其他任务,例如身份验证和客户端路由。目前,我们的目标是使用我们的 GraphQL API 与我们的 React 应用程序一起使用。
首先,我们必须安装 React Apollo 客户端库。Apollo 客户端是一个 GraphQL 客户端,它提供了与 React 的优秀集成,并能够轻松从我们的 GraphQL API 获取数据。此外,它处理缓存和订阅等操作,以实现与 GraphQL 后端的实时通信。尽管 Apollo 客户端以 Apollo 品牌命名,但它并不依赖于 Apollo Server。只要遵循协议标准,您就可以使用 Apollo 客户端与任何 GraphQL API 或模式一起使用。您很快就会看到客户端如何完美地与我们的 React 设置合并。
总是会有很多替代方案。您可以使用我们构建的当前 API 中的任何 GraphQL 客户端。这种开放性是 GraphQL 的伟大之处:它使用开放标准进行通信。各种库实现了 GraphQL 标准,您可以自由使用其中的任何一个。
重要提示
最知名的替代方案是 Facebook 开发的 Relay 和由 Prisma 背后的人开发的graphql-request。所有这些都是优秀的库,您可以自由使用。我个人主要依赖 Apollo,但 Relay 也非常推荐。您可以在github.com/chentsulin/awesome-graphql找到与 GraphQL 生态系统相关的长列表的包。
除了特殊的客户端库之外,你也可以仅仅使用普通的fetch方法或XMLHttpRequest请求。缺点是你需要自己实现缓存、编写request对象,并将request方法集成到你的应用程序中。我不建议这样做,因为它需要花费很多时间,而你希望将时间投入到你的业务中,而不是实现现有功能。
安装 Apollo 客户端
我们使用npm安装客户端依赖项,如下所示:
npm install --save @apollo/client graphql
为了使 GraphQL 客户端运行,我们需要安装以下两个包:
-
@apollo/client是我们安装的所有包的包装包。Apollo 客户端依赖于所有其他包。 -
graphql是 GraphQL 的参考实现,它提供了解析 GraphQL 查询的逻辑。
你将在本节中看到这些包是如何协同工作的。
要开始手动设置 Apollo 客户端,创建一个新的文件夹和文件,如下所示:
mkdir src/client/apollo
touch src/client/apollo/index.js
我们将在本地的index.js文件中设置 Apollo 客户端。我们的首次设置将代表最基本的配置,以便获得一个可工作的 GraphQL 客户端。
小贴士
下面的代码是从官方 Apollo 文档中摘录的。通常,我建议阅读 Apollo 文档,因为它写得非常好。你可以在www.apollographql.com/docs/react/essentials/get-started.html找到它。
只需插入以下代码:
import { ApolloClient, InMemoryCache, from, HttpLink } from '@apollo/client';
import { onError } from "@apollo/client/link/error";
const client = new ApolloClient({
link: from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path }) =>
console.log('[GraphQL error]: Message: ${message},
Location:
${locations}, Path: ${path}'));
if (networkError) {
console.log('[Network error]: ${networkError}');
}
}
}),
new HttpLink({
uri: 'http://localhost:8000/graphql',
}),
]),
cache: new InMemoryCache(),
});
export default client;
之前的代码使用了所有的新包,除了react-apollo。让我们按以下方式分解代码:
-
首先,在文件顶部,我们导入了
@apollo/client包中所有必需的函数和类。 -
我们实例化了
ApolloClient。为了使其工作,我们传递了一些参数,即link和cache属性。 -
link属性由from命令填充。这个函数遍历一个链接数组,并逐个初始化它们。链接的描述如下:a. 第一个链接是错误链接。它接受一个函数,告诉 Apollo 如果发生错误应该做什么。
b. 第二个链接是 Apollo 的超文本传输协议(HTTP)链接。你必须提供一个统一资源标识符(URI),通过该 URI 我们可以访问我们的 Apollo 或 GraphQL 服务器。Apollo 客户端会将所有请求发送到这个 URI。值得注意的是,执行顺序与刚刚创建的数组顺序相同。
-
cache属性接受一个缓存实现。一个实现可以是默认包InMemoryCache,或者不同的缓存。重要提示
我们的链接可以理解许多更多属性(尤其是 HTTP 链接)。它们提供了许多不同的自定义选项,我们将在稍后查看。你还可以在官方文档中找到它们,网址为
www.apollographql.com/docs/react/。
在前面的代码片段中,我们使用 export default client 行导出了初始化的 Apollo Client。我们现在可以在我们的 React 应用程序中使用它。
使用 Apollo Client 发送 GraphQL 请求的基本设置已完成。在下一节中,我们将通过 Apollo Client 发送我们的第一个 GraphQL 请求。
测试 Apollo Client
在将 GraphQL 客户端直接插入我们的 React 应用程序树之前,我们应该对其进行测试。我们将编写一些临时代码来发送我们的第一个 GraphQL 查询。测试完我们的 GraphQL 客户端后,我们将再次删除代码。按照以下步骤操作:
-
在 Apollo Client 设置的顶部导入包,如下所示:
import { gql } from '@apollo/client'; -
然后,在客户端导出之前添加以下代码:
client.query({ query: gql' { posts { id text user { avatar username } } }' }).then(result => console.log(result));
前面的代码几乎与阿波罗文档中的示例相同,但我已经将他们的查询替换为与我们的后端匹配的一个。
在这里,我们使用了 Apollo Client 的 gql 工具来解析一个 gql 命令,将这个字面量解析为一个 抽象语法树(AST)。ASTs 是 GraphQL 的第一步;它们用于验证深度嵌套的对象、模式以及查询。客户端在解析完成后发送我们的查询。
小贴士
如果你想了解更多关于 ASTs 的信息,Contentful 的人写了一篇关于 ASTs 对 GraphQL 意义的精彩文章,请参阅 www.contentful.com/blog/2018/07/04/graphql-abstract-syntax-tree-new-schema/。
为了测试前面的代码,我们应该启动服务器和前端。一个选项是现在构建前端,然后启动服务器。在这种情况下,执行 npm run server 并然后在第二个终端中打开。然后,你可以通过执行 npm run client 来启动 webpack 开发服务器。应该会自动打开一个新的浏览器标签页。
然而,我们忘记了一些事情:客户端已设置在我们的新文件中,但尚未在任何地方使用。在客户端 React 应用的 index.js 根文件中导入它,在 App 组件导入下方,如下所示:
import client from './apollo';
浏览器应该重新加载,并发送查询。你应该能在浏览器开发者工具的控制台中看到一条新的日志。
输出应该看起来像这样:
![图 4.1 – 手动客户端响应
![img/Figure_4.01_B17337.jpg]
图 4.1 – 手动客户端响应
data 对象看起来与通过 Postman 发送请求时收到的响应非常相似,但现在它有一些新的属性:loading 和 networkStatus。每个都代表一个特定的状态,如下所示:
-
loading,正如你所期望的,表示查询是否仍在运行或已经完成。 -
networkStatus超出了这个范围,并提供了发生事件的精确状态。例如,数字 7 表示没有正在运行的查询产生错误。数字 8 表示发生了错误。您可以在官方 GitHub 仓库中查找其他数字,网址为github.com/apollographql/apollo-client/blob/main/src/core/networkStatus.ts。
现在我们已经验证了查询已成功运行,我们可以将 Apollo Client 连接到 App.js 文件中的 React import 语句。
将 Apollo Client 绑定到 React
我们已经测试了 Apollo Client 并确认它正常工作。然而,React 还没有访问权限。由于 Apollo Client 将在我们的应用程序的每个地方使用,我们可以在根 index.js 文件中设置它,如下所示:
import React from 'react';
import ReactDOM from 'react-dom';
import { ApolloProvider } from '@apollo/client/react';
import App from './App';
import client from './apollo';
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>, document.getElementById('root')
);
正如我们在 第一章 中提到的,准备您的开发环境,您应该只在整个应用程序需要访问新组件时编辑此文件。在前面的代码片段中,您可以看到我们从 @apollo/client/react 中导入了我们最初安装的最后一个包。我们从其中提取的 ApolloProvider 组件是我们 React 应用程序的第一层。它包围了 App 组件,并将我们编写的 Apollo Client 传递给下一层。为此,我们将 client 作为属性传递给提供者。现在,每个下层的 React 组件都可以访问 Apollo Client。
我们现在应该能够从我们的 React 应用程序发送 GraphQL 请求。
在 React 中使用 Apollo Client
Apollo Client 为我们从 React 组件发送请求提供了所有必要的东西。我们已经测试了客户端并确认它正常工作。在继续之前,我们应该清理我们的文件结构,以便在开发过程中更容易操作。我们的前端仍然显示来自静态演示数据的帖子。第一步是将 Apollo Client 移动到我们的 GraphQL API 并获取数据。
按照以下说明将您的第一个 React 组件与 Apollo Client 连接:
-
将
App.js文件克隆到另一个名为Feed.js的文件中。 -
移除所有使用 React
Helmet的部分,移除Feed而不是App。 -
从
App.js文件中,移除我们在Feed组件中留下的所有部分。 -
此外,我们必须在
App组件内部渲染Feed组件。它应该看起来像这样:import React from 'react'; import { Helmet } from 'react-helmet'; import Feed from './Feed'; import '../../assets/css/style.css'; const App = () => { return ( <div className="container"> <Helmet> <title>Graphbook - Feed</title> <meta name="description" content="Newsfeed of all your friends on Graphbook" /> </Helmet> <Feed /> </div> ) } export default App
相应的 Feed 组件应仅包含渲染新闻源的部分。
我们已导入 Feed 组件并将其插入到 App 组件的 return 语句中,以便进行渲染。下一章将重点介绍可重用 React 组件以及如何编写结构良好的 React 代码。现在,让我们看看为什么我们将 App 函数拆分为两个单独的文件。
使用 Apollo Client 在 React 中进行查询
Apollo Client 提供了一种主要方式来从 GraphQL API 请求数据。@apollo/client 包提供的 useQuery 函数允许在功能 React 组件中使用 React Hooks 请求数据。除此之外,如果需要,你仍然可以依赖普通的 client.query 函数来处理基于类的组件。在 Apollo Client 新版本发布之前,有多个这样做的方法,这些方法在新版本发布时已被弃用。以前,你可以使用 Apollo 的 Query 组件,这是一个特殊的 React 组件。这两种方法仍然存在,但已被弃用,因此不建议再使用它们。这就是为什么这些方法将不会在本书中解释的原因。
Apollo useQuery 钩子
Apollo Client 的最新版本带有 useQuery 钩子。你只需要将 GraphQL 查询字符串传递给 useQuery 钩子,它将返回一个包含 data、error 和 loading 属性的对象,你可以使用这些属性来渲染你的 UI。
实现 useQuery 钩子的实际方式非常简单。只需遵循以下说明:
-
从
Feed.js文件的顶部移除演示帖子。 -
移除
useState(initialPosts)行,这样我们就可以查询帖子了。 -
从 Apollo 导入
gql函数和useQuery钩子,并解析查询,如下所示:import { gql, useQuery } from '@apollo/client'; const GET_POSTS = gql'{ posts { id text user { avatar username } } }'; -
在顶部的
Feed函数中执行useQuery钩子,如下所示:const { loading, error, data } = useQuery(GET_POSTS); -
在实际的
return语句之前,添加以下两个语句,如果有任何加载或错误信息,将会渲染:if (loading) return 'Loading...'; if (error) return 'Error! ${error.message}'; -
在这些语句和最后一个
return语句之前,添加以下代码行:const { posts } = data;这将使
posts属性在useQuery函数返回的数据中可用,前提是它不再加载且没有错误。注意,由于我们只遍历
posts属性并返回标记,所以函数现在要干净得多。
与较旧的方法相比,useQuery 钩子易于理解,也允许我们编写可读和可理解的代码。
渲染的输出应该看起来像在 第一章 中所示,准备你的开发环境。由于我们的更改,创建新帖子的表单目前无法正常工作;让我们在下一节中修复这个问题。
使用 Apollo Client 更改数据
我们已经改变了客户端获取数据的方式。下一步是切换我们创建新帖子的方式。在 Apollo Client 之前,我们必须手动将新的假帖子添加到演示帖子数组中,在浏览器的内存中。现在,我们文本区域中的所有内容都通过 addPost 变更发送到我们的 GraphQL API,通过 Apollo Client。
与 GraphQL 查询一样,有一个useMutation钩子,你可以用它向我们的 GraphQL API 发送突变。之前还有一个 HOC 方法和一个单独的Mutation组件,它们也已经弃用。它们仍然存在以保持向后兼容,但本书中不会涉及它们。
Apollo useMutation 钩子
Apollo Client 的最新版本带来了useMutation钩子。这个方法与useQuery钩子的工作方式相同——你只需要传递解析后的突变字符串给它。作为回应,useMutation钩子将返回一个与突变同名的方法,你可以用它来触发那些 GraphQL 请求。
按照以下说明实现useMutation钩子并开始使用它:
-
从
@apollo/client包中导入useMutation钩子,如下所示:import { gql, useQuery, useMutation } from '@apollo/client'; -
使用
gql函数将addPost突变字符串解析到getPost查询下方,如下所示:const ADD_POST = gql' mutation addPost($post : PostInput!) { addPost(post : $post) { id text user { username avatar } } } '; -
在
Feed组件内部,添加以下代码行以获取addPost函数,你可以在Feed组件的任何地方使用它:const [addPost] = useMutation(ADD_POST); -
现在我们已经得到了
addPost函数,我们可以开始使用它了。只需更新handleSubmit函数,如下所示:const handleSubmit = (event) => { event.preventDefault(); addPost({ variables: { post: { text: postContent } } }); setPostContent(''); };如你所见,我们完全去掉了
newPost对象,只发送帖子的文本。我们的 GraphQL API 将在插入数据库时创建一个标识符(ID)。如第三章中所述,连接到数据库,我们静态地添加第一个用户作为帖子的作者。
你可以尝试通过前端添加一个新的帖子,但你不会立即看到它。表单将是空的,但新的帖子不会显示。这是因为我们组件的当前状态(或缓存)尚未收到新的帖子。测试一切是否正常工作的最简单方法是刷新浏览器。
当然,这并不是它应该工作的方式。在突变发送后,新的帖子应该直接在动态中可见。我们现在将修复这个问题。
使用 Apollo Client 更新 UI
在运行addPost突变后,请求通过服务器并成功保存了新的帖子到我们的数据库中,没有任何问题。然而,我们仍然不能立即在前端看到这些变化生效。
在突变之后更新 UI 有两种不同的方式,如下所示:
-
重新获取数据集:这很容易实现,但它重新获取了所有数据,这是低效的。
-
根据插入的数据更新缓存:这更难理解且实现起来更复杂,但它将新数据附加到 Apollo Client 的缓存中,因此不需要重新获取。
我们在不同的场景中使用这些解决方案。让我们看看一些例子。如果服务器上实现了对客户端隐藏的进一步逻辑,并且当请求项目列表时未应用,而插入单个项目时则应用,则重新获取是有意义的。在这些情况下,客户端无法模拟服务器典型响应的状态。
然而,当在列表中添加或更新项目时,如我们的帖子源,更新缓存是有意义的。客户端可以将新帖子插入到源顶部。
我们将首先简单地重新获取请求,然后我们将介绍缓存更新实现。以下部分(和章节)将假设您没有使用 HOC 方法。
重新获取查询
如前所述,这是更新 UI 的最简单方法。唯一的步骤是设置要重新获取查询的查询数组。useMutation函数应如下所示:
const [addPost] = useMutation(ADD_POST, {
refetchQueries: [{query:GET_POSTS}]
});
在refetchQueries数组中输入的每个对象都需要一个query属性。当与这些请求相关联的响应到达时,依赖于这些请求的每个组件都会重新渲染。这还包括不在Feed组件内部的组件。所有使用帖子的GET_POSTS查询的组件都会重新渲染。
您还可以为每个查询提供更多字段,例如发送参数的变量,与refetch请求一起发送。提交表单会重新发送查询,您可以直接在源中看到新帖子。重新获取还会重新加载已显示的帖子,这是不必要的。
现在,让我们看看我们如何能更有效地做到这一点。
更新 Apollo Client 缓存
我们只想明确地将新帖子添加到 Apollo Client 的缓存中。使用缓存可以帮助我们通过不重新获取整个源或重新渲染整个列表来保存数据。要更新缓存,您应该删除refetchQueries属性。
在技术上,至少有两种方式可以在突变请求的响应中更新缓存。第一种方法相当直接且简单。然后你可以引入一个新的属性,称为update,如下面的代码片段所示:
const [addPost] = useMutation(ADD_POST, {
update(cache, { data: { addPost } }) {
const data = cache.readQuery({ query: GET_POSTS });
const newData = { posts: [addPost, ...data.posts]};
cache.writeQuery({ query: GET_POSTS, data: newData });
}
});
当 GraphQL 的addPost突变完成时,新属性会运行。它接收的第一个参数是 Apollo Client 的cache参数,其中保存了整个缓存。第二个参数是我们 GraphQL API 返回的响应。
更新缓存的方式如下:
-
使用
cache.readQuery函数,通过传递query作为参数。它读取缓存中为该特定查询保存的数据。data变量包含我们帖子源中的所有帖子。 -
现在我们已经将所有帖子放入数组中,我们可以添加缺失的帖子。确保你知道是否需要将项目前置或后置。在我们的例子中,我们创建了一个
newData对象,其中包含一个posts数组,该数组由顶部新添加的帖子和解构后的旧帖子列表组成。 -
我们需要将更改保存回缓存。
cache.writeQuery函数接受我们用来发送请求的query参数。这个query参数用于更新我们缓存中保存的数据。第二个参数是要保存的数据。 -
当缓存已更新后,我们的 UI 会反应性地渲染更改。
在现实中,你可以在update函数中做任何你想做的事情,但我们只使用它来更新 Apollo 客户端存储。
第二种方法看起来稍微复杂一些,但代表了官方文档中展示的方式。update函数看起来更复杂一些,但带来了一些性能上的小改进。你可以选择你更喜欢哪一个。
只需替换useMutation钩子的update函数,如下所示:
update(cache, { data: { addPost } }) {
cache.modify({
fields: {
posts(existingPosts = []) {
const newPostRef = cache.writeFragment({
data: addPost,
fragment: gql'
fragment NewPost on Post {
id
type
}
'
});
return [newPostRef, ...existingPosts];
}
}
});
}
上述代码所做的就是使用cache.modify函数,它允许比我们之前所做的更精确的更新。我们不是在 Apollo 客户端中更新整个GET_POSTS查询,而是使用cache对象的cache.writeFragment方法来更新缓存和 UI,只更新新的帖子。这将提高我们组件的性能,尤其是在组件逻辑增长时。
在下一节中,我们将对我们的服务器响应更加乐观,并在请求的响应成功到达之前添加项目。
乐观式 UI
Apollo 提供了一种能够以乐观方式更新 UI 的出色功能。乐观方式意味着在请求完成之前,Apollo 会将新数据或帖子添加到存储中。这种解决方案的优点是用户可以看到新的结果,而不是等待服务器的响应。这种解决方案使得应用程序感觉更快、更响应。
本节期望Mutation组件的update函数已经实现。否则,这个 UI 功能将不会工作。我们需要在useMutation配置的update属性旁边添加optimisticResponse属性,如下所示:
optimisticResponse: {
__typename: "mutation",
addPost: {
__typename: "Post",
text: postContent,
id: -1,
user: {
__typename: "User",
username: "Loading...",
avatar: "/public/loading.gif"
}
}
}
optimisticResponse属性可以是任何从函数到简单对象的东西。然而,返回值需要是一个 GraphQL response对象。你在这里看到的是一个addPost对象,看起来就像我们的 GraphQL API 可以返回它,如果我们的请求成功的话。你需要根据你使用的 GraphQL 模式填写__typename字段。这就是为什么Post和User类型名称在这个假对象内部。
从技术上讲,你也可以在addPost的实际调用旁边添加optimisticResponse属性,在variables属性旁边,但我认为这不是我们需要在每次调用此函数时传递的东西,而实际上应该全局设置到useMutation钩子中。
乐观响应的id属性被设置为-1。React 预期循环中的每个组件都有一个唯一的key属性。我们通常使用帖子的id属性作为key值。由于 MySQL 从 1 开始计数,因此-1永远不会被任何其他帖子使用。另一个优点是,我们可以使用这个id属性来设置列表中帖子项的特殊类。
此外,用户名和用户头像被设置为loading。这是因为我们没有内置的身份验证。React 和 Apollo 没有与当前会话关联的用户,所以我们不能将用户数据输入到optimisticResponse属性中。一旦身份验证就绪,我们就解决这个问题。这是一个处理在收到服务器响应之前没有所有数据的情景的绝佳例子。
要在列表项上设置特定的类,我们在map循环中条件性地设置正确的className属性。将以下代码插入到return语句中:
{posts.map((post, i) =>
<div key={post.id} className={'post ' + (post.id < 0 ?
'optimistic': '')}>
<div className="header">
<img src={post.user.avatar} />
<h2>{post.user.username}</h2>
</div>
<p className="content">
{post.text}
</p>
</div>
)}
对于此示例,CSS 样式可能看起来像这样:
.optimistic {
-webkit-animation: scale-up 0.4s cubic-bezier(0.390,
0.575, 0.565, 1.000) both;
animation: scale-up 0.4s cubic-bezier(0.390, 0.575,
0.565, 1.000) both;
}
@-webkit-keyframes scale-up {
0% {
-webkit-transform: scale(0.5);
transform: scale(0.5);
}
100% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
@keyframes scale-up {
0% {
-webkit-transform: scale(0.5);
transform: scale(0.5);
}
100% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
CSS 动画使您的应用程序更加现代和灵活。如果您在浏览器中查看时遇到问题,您可能需要检查您的浏览器是否支持它们。
您可以在以下屏幕截图中看到结果:

图 4.2 – 加载乐观响应
一旦从我们的 API 收到响应,加载指示器和用户名就会被移除,然后再次执行带有真实数据的update函数。您无需自己处理移除加载帖子,因为这是由 Apollo 自动完成的。任何来自npm包或 GIF 文件的旋转器组件都可以用于我插入加载动画的位置。我使用的文件需要保存在public文件夹下,文件名为loading.gif,这样就可以通过我们在前面代码中添加的 CSS 使用它。
现在一切都已经设置好了,用于发送新帖子。UI 会立即响应并显示新帖子。
然而,关于您朋友和同事的新帖子怎么办?目前,您需要重新加载页面才能看到它们,这并不直观。目前,我们只添加我们自己发送的帖子,但不会收到其他人新帖子的任何信息。我将在下一节中向您展示处理此问题的最快方法。
使用 Apollo 客户端进行轮询
轮询不过是每隔一段时间重新运行一次请求。这是实现我们新闻源更新的最简单方法。然而,轮询与多个问题相关,如下所述:
-
在不知道是否有新数据的情况下发送请求是不高效的。浏览器可能会发送数十个请求,却从未收到任何新帖子。
-
如果我们直接再次发送初始请求,我们将获得所有帖子,包括那些我们已经向用户展示的。
-
在发送请求时,服务器需要查询数据库并计算一切。不必要的请求会耗费金钱和时间。
有些用例中轮询是有意义的。一个例子是实时图表,其中无论是否有数据,每个轴刻度都会显示给用户。由于你想要显示所有内容,因此不需要使用基于中断的解决方案。尽管轮询会带来一些问题,但让我们快速了解一下它是如何工作的。你所需要做的就是填写useQuery Hook 配置中的pollInterval属性,如下所示:
const { loading, error, data } = useQuery(GET_POSTS, { pollInterval: 5000 });
请求每 5 秒重发一次(5,000 毫秒,或ms)。
如你所预期,还有其他方法可以实现 UI 的实时更新。一种方法是用服务器发送事件。正如其名所示,服务器发送事件是由服务器发送到客户端的事件。客户端需要与服务器建立连接,然后服务器就可以向客户端发送消息,单向通信。另一种方法是使用WebSockets,它允许服务器和客户端之间进行双向通信。然而,与 GraphQL 相关最常见的方法是使用Apollo Subscriptions。它们基于 WebSockets,并且与 GraphQL 配合得非常好。我将在第十章“实时订阅”中向你展示 Apollo Subscriptions 是如何工作的。
让我们继续并集成我们剩余的 GraphQL API。
实现聊天和消息
在上一章中,我们编写了一种相当动态的方式来创建与你的朋友和同事的聊天和消息,无论是单独还是分组。还有一些我们没有讨论的事情,比如身份验证、实时订阅和好友关系。然而,首先,我们将利用 React 和 Apollo Client 来发送 GraphQL 请求来锻炼我们的新技能。这是一个复杂的工作,所以让我们开始吧。
获取并显示聊天
我们的新闻推送正如预期那样工作。现在,我们还想涵盖聊天。正如我们的推送一样,我们需要查询当前用户(或者在我们的情况下,是第一个用户)关联的每个聊天。
第一步是使用一些示例聊天来使渲染工作。与我们在第一章中自己写入数据不同,我们现在可以执行chats查询。然后,我们可以将结果复制到新文件中作为静态示例数据,然后再执行实际的useQuery Hook。
让我们开始吧,如下所示:
-
发送 GraphQL 查询。如果你已经知道如何使用 Apollo Client Devtools,那么最佳选项就是使用它们。否则,你可以像之前一样依赖 Postman。代码在下面的代码片段中展示:
query { chats { id users { avatar username } } }请求看起来与我们在 Postman 中测试的请求略有不同。我们将要构建的聊天面板只需要特定的数据。我们不需要在这个面板内渲染任何消息,因此我们不需要请求它们。一个完整的聊天面板只需要聊天本身、ID、用户名和头像。稍后,当查看单个聊天时,我们还将检索所有消息。
接下来,创建一个名为
Chats.js的新文件,位于Feed.js文件旁边。将响应中的完整
chats数组复制到Chats.js文件中的数组中,如下所示。将其添加到文件顶部:const chats = [{ "id": 1, "users": [{ "id": 1, "avatar": "/uploads/avatar1.png", "username": "Test User" }, { "id": 2, "avatar": "/uploads/avatar2.png", "username": "Test User 2" }] } ]; -
在
chats变量之前导入 React。否则,我们将无法渲染任何 React 组件。以下是您需要执行的代码:import React, { useState } from 'react'; -
设置功能 React 组件。我在这里提供了基本的标记。只需将其复制到
chats变量下面。我很快就会解释新组件的逻辑:const usernamesToString = (users) => { const userList = users.slice(1); var usernamesString = ''; for(var i = 0; i < userList.length; i++) { usernamesString += userList[i].username; if(i - 1 === userList.length) { usernamesString += ', '; } } return usernamesString; } const shorten = (text) => { if (text.length > 12) { return text.substring(0, text.length - 9) + '...'; } return text; } const Chats = () => { return ( <div className="chats"> {chats.map((chat, i) => <div key={chat.id} className="chat"> <div className="header"> <img src={(chat.users.length > 2 ? '/public/group.png' : chat.users[1].avatar)} /> <div> <h2>{shorten(usernamesToString (chat.users))}</h2> </div> </div> </div> )} </div> ) } export default Chats目前,该组件相当基础。组件遍历所有聊天,并为每个聊天返回一个新的列表项。每个列表项都有一个图像,该图像来自数组的第二个用户,因为我们定义列表中的第一个用户是当前用户,只要我们没有实现身份验证。如果有超过两个用户,我们使用群组图标。当我们实现了身份验证并且我们知道已登录的用户时,我们可以获取我们正在与之聊天的特定用户头像。
聊天顶部
h2标签内显示的标题是用户的名称。为此,我实现了usernamesToString方法,该方法遍历所有用户名并将它们连接成一个长字符串。结果传递给shorten函数,该函数移除超过最大 12 个字符的所有字符串字符。您可能会注意到,这些辅助函数不在实际组件内部。我个人建议将辅助函数放在组件外部,因为它们将在组件每次渲染时被重新创建。如果辅助函数需要组件的作用域,请将其保留在内部,但如果它们只是在这里进行转换的纯函数,请将其放在外部。
-
我们的新组件需要一些样式。将新的 CSS 复制到我们的
style.css文件中。为了在我们的 CSS 文件中节省文件大小,将两个
.post .header样式替换为也覆盖聊天的样式,如下所示:.post .header > *, .chats .chat .header > * { display: inline-block; vertical-align: middle; } .post .header img, .chats .chat .header img { width: 50px; margin: 5px; }我们必须在
style.css文件的底部追加以下 CSS:.chats { background-color: #eee; width: 200px; height: 100%; position: fixed; top: 0; right: 0; border-left: 1px solid #c3c3c3; } .chats .chat { cursor: pointer; } .chats .chat .header > div { width: calc(100% - 65px); font-size: 16px; margin-left: 5px; } .chats .chat .header h2, .chats .chat .header span { color: #333; font-size: 16px; margin: 0; } .chats .chat .header span { color: #333; font-size: 12px; } -
为了使代码正常工作,我们还必须在
App.js文件中导入Chats组件,如下所示:import Chats from './Chats'; -
在
App.js文件中Feed组件下面的返回语句内渲染Chats组件。当前代码生成以下截图:
![图 4.3 – 聊天面板]

图 4.3 – 聊天面板
在右侧,您可以看到我们刚刚实现的聊天面板。每个聊天都作为单独的行列在那里。
结果还不错,但至少在用户名下方显示每条聊天的最后一条消息会更有帮助,这样你就可以直接看到对话的最后内容。
按照以下说明将最后一条消息添加到聊天面板中:
-
做这件事最简单的方法是在查询中再次添加消息,但查询我们想在面板中显示的每个聊天的所有消息并没有太多意义。相反,我们将在聊天实体中添加一个新属性,称为
lastMessage。这样,我们只会得到最新消息。我们将在后端代码中添加新字段到我们聊天类型的 GraphQL 模式中,如下所示:lastMessage: Message当然,我们还需要实现一个函数来检索
lastMessage字段。 -
在
resolvers对象的Chats属性中添加我们的新resolvers.js函数将按 ID 对所有聊天消息进行排序并取第一个。根据定义,这应该是我们聊天中的最新消息。我们需要自己解析这个承诺并返回数组的第一个元素,因为我们期望只返回一个message对象。如果你直接返回承诺,你将收到来自服务器的null响应,因为数组不是一个有效的单个消息实体的响应。以下代码片段展示了代码:lastMessage(chat, args, context) { return chat.getMessages({limit: 1, order: [['id', 'DESC']]}).then((message) => {return message[0]; }); }, -
你可以在
Chats.js中的静态演示数据内部添加新的属性,对于每个数组项或重新运行 GraphQL 查询并再次复制响应。代码如下所示:"lastMessage": { "text": "This is a third test message." } -
我们可以用一个简单的
span标签在用户名的h2标题下方渲染新消息。直接将其复制到我们的Chats组件中的return语句中,如下所示:<span>{chat?.lastMessage?.text}</span>
前面更改的结果是渲染每行聊天中的最后一条消息。现在它应该看起来像这样:

图 4.4 – 最后一条消息
由于测试数据中的所有内容都显示正确,我们可以引入useQuery钩子来从我们的 GraphQL API 获取所有数据。我们可以删除chats数组。然后,我们将导入所有依赖项并解析 GraphQL 查询,如下面的代码片段所示:
import { gql, useQuery } from '@apollo/client';
const GET_CHATS = gql'{
chats {
id
users {
id
avatar
username
}
lastMessage {
text
}
}
}';
为了使用前面解析的 GraphQL 查询,我们将在我们的功能组件中执行useQuery钩子,如下所示:
const { loading, error, data} = useQuery(GET_CHATS);
if (loading) return <div className="chats"><p>Loading...</p></div>;
if (error) return <div className="chats"><p>{error.message}</p></div>;
const { chats } = data;
当你将前面的代码行添加到Chats函数的开始处时,它将使用 GraphQL 响应中返回的chats数组。在这样做之前,它当然会检查请求是否仍在加载或是否发生了错误。
我们在具有chats类的div标签内渲染加载和错误状态,这样消息就会被包裹在灰色面板中。
你应该已经通过 Postman 运行了上一章的addChat突变。否则,将没有可查询的聊天,面板将是空的。你还需要为任何后续章节执行此突变,因为我们不会为这个功能实现一个特殊的按钮。原因是它背后的逻辑并没有提供关于 React 或 Apollo 的更多知识,因为它只是在 Graphbook 的正确位置执行addChat突变。
接下来,我们希望在打开特定聊天后显示聊天消息。
获取和显示消息
首先,我们必须存储用户通过点击打开的聊天。每个聊天都在一个单独的小聊天窗口中显示,就像在 Facebook 上一样。向Chats组件添加一个新的状态变量来保存所有打开的聊天 ID,如下所示:
const [openChats, setOpenChats] = useState([]);
为了让我们的组件能够向打开的聊天数组中插入或删除内容,我们将添加新的openChat和closeChat函数,如下所示:
const openChat = (id) => {
var openChatsTemp = openChats.slice();
if(openChatsTemp.indexOf(id) === -1) {
if(openChatsTemp.length > 2) {
openChatsTemp = openChatsTemp.slice(1);
}
openChatsTemp.push(id);
}
setOpenChats(openChatsTemp);
}
const closeChat = (id) => {
var openChatsTemp = openChats.slice();
const index = openChatsTemp.indexOf(id);
openChatsTemp.splice(index,1),
setOpenChats(openChatsTemp);
}
当点击聊天时,我们将首先检查它是否尚未打开,通过在openChats数组中使用indexOf函数搜索 ID。
每次打开一个新的聊天时,我们将检查是否有三个或更多的聊天。如果是这样,我们将从数组中删除第一个打开的聊天,并通过使用push函数将其附加到数组中来交换它。我们只保存聊天 ID,而不是整个JavaScript 对象表示法(JSON)对象。
对于closeChat函数,我们只需通过从openChats数组中删除 ID 来撤销这个操作。
最后一步是将onClick事件绑定到我们的组件上。在map函数中,我们可以用以下代码行替换包装div标签:
<div key={"chat" + chat.id} className="chat" onClick={() => openChat(chat.id)}>
在这里,我们使用onClick来调用openChat函数,将聊天 ID 作为唯一参数。此时,新函数已经工作,但更新的状态没有被使用。让我们处理这个问题,如下所示:
-
将一个周围的包装
div标签添加到具有chats类的div标签中,如下所示:<div className="wrapper"> -
为了不搞乱,我们编写的完整代码将引入我们的第一个子组件。为此,在
Chats.js文件旁边创建一个名为Chat.js的文件。 -
在这个新文件中,导入 React 和 Apollo,并解析 GraphQL 查询以获取刚刚打开的所有聊天消息,如下所示:
import React from 'react'; import { gql, useQuery } from '@apollo/client'; const GET_CHAT = gql' query chat($chatId: Int!) { chat(chatId: $chatId) { id users { id avatar username } messages { id text user { id } } } } ';如前述代码片段所示,我们将聊天 ID 作为参数传递给 GraphQL 查询。
-
实际组件将利用解析的查询获取所有消息并在一个小容器中渲染它们。应该按照以下方式添加组件:
const Chat = (props) => { const { chatId, closeChat } = props; const { loading, error, data } = useQuery(GET_CHAT, { variables: { chatId }}); if (loading) return <div className="chatWindow"> <p>Loading...</p></div>; if (error) return <div className="chatWindow"> <p>{error.message}</p></div>; const { chat } = data; return ( <div className="chatWindow"> <div className="header"> <span>{chat.users[1].username}</span> <button onClick={() => closeChat(chatId)} className="close">X</button> </div> <div className="messages"> {chat.messages.map((message, j) => <div key={'message' + message.id} className={'message ' + (message.user.id > 1 ? 'left' : 'right')}> {message.text} </div> )} </div> </div> ) } export default Chat我们执行
useQuery钩子来发送 GraphQL 请求。我们传递props中的chatId属性,以便聊天 ID 必须从父组件传递到子组件。我们还提取了closeChat函数,以便从子组件中调用它,因为实际的关闭按钮位于聊天容器中,而不是父组件中。一旦请求到达,我们在实际渲染完整的聊天之前再次检查请求是否正在加载或是否有错误。然后,我们渲染一个带有
chatWindow类名的div标签,其中显示所有消息。同样,我们再次使用用户 ID 来伪造消息的类名。当我们开始运行身份验证时,我们将替换它。 -
由于我们已经准备好了子组件,我们只需要在
Chats.js文件中添加一行来导入它,如下所示:import Chat from './Chat'; -
然后,为了使用我们新的
Chat组件,只需在带有wrapper类的div标签内添加这三行代码:<div className="openChats"> {openChats.map((chatId, i) => <Chat chatId={chatId} key={"chatWindow" + chatId} closeChat={closeChat} /> )} </div>对于
openChats数组中的每个项目,我们将渲染Chat组件,然后传递chatId属性和closeChat函数。子组件将根据传递的chatId属性自行获取聊天数据。 -
最后缺少的是一些样式。CSS 文件相当大。其他用户的每条消息都应该显示在左侧,而我们的消息显示在右侧,以便区分它们。直接从 GitHub 仓库插入 CSS 代码以节省时间:
github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-2nd-Edition/blob/main/Chapter04/assets/css/style.css。
请看以下截图:

图 4.5 – 聊天窗口
我们忘记了一些重要的事情。我们可以看到聊天中的所有消息,但我们无法添加新的消息,这是至关重要的。让我们在下一节中看看如何实现聊天消息表单。
通过突变发送消息
addMessage 突变已经存在于我们的后端,因此我们可以将其添加到我们的 Chat 组件中。要完全实现此功能,请按照以下说明操作:
-
在直接将其添加到前端之前,我们需要更改
import语句,以便我们也有useMutation和useState函数,如下所示:import React, { useState } from 'react'; import { gql, useQuery, useMutation } from '@apollo/client'; -
然后,像对待其他请求一样,在顶部解析突变,如下所示:
const ADD_MESSAGE = gql' mutation addMessage($message : MessageInput!) { addMessage(message : $message) { id text user { id } } } '; -
现在,我们将保持简单,并直接将文本输入添加到
Chat组件中,但我们将查看在 第五章,可重用 React 组件和 React 钩子中更好的方法。现在,我们需要创建一个状态变量来保存我们新创建的文本输入的当前值。我们需要执行
useMutation钩子来发送 GraphQL 请求创建新的聊天消息。为此,只需添加以下代码:const [text, setText] = useState(''); const [addMessage] = useMutation(ADD_MESSAGE, { update(cache, { data: { addMessage } }) { cache.modify({ id: cache.identify(data.chat), fields: { messages(existingMessages = []) { const newMessageRef = cache.writeFragment({ data: addMessage, fragment: gql' fragment NewMessage on Chat { id type } ' }); return [...existingMessages, newMessageRef]; } } }); } }); const handleKeyPress = (event) => { if (event.key === 'Enter' && text.length) { addMessage({ variables: { message: { text, chatId } } }).then(() => { setText(''); }); } }状态变量和突变函数看起来很熟悉,因为你已经知道了它们。我们为
useMutation钩子做的特别之处是再次提供一个update函数,以有效地更新 Apollo 客户端缓存中的最新数据。为此,我们必须向cache.modify函数提供一个id属性。我们需要这样做的原因是我们想更新特定聊天的messages数组,但我们的缓存中可能有多个。为了更新消息中的正确聊天,我们使用cache.identify函数并提供当前的chat对象,它将自动检测要更新的聊天。handleKeyPress函数将处理文本输入的提交以触发突变请求。 -
我们必须插入渲染一个完全功能性的输入所需的标记。将输入放在消息列表下方,在聊天窗口内。
onChange属性在输入时执行,并将组件的状态更新为输入的值。插入以下代码:<div className="input"> <input type="text" value={text} onChange={(e) => setText(e.target.value)} onKeyPress={handleKeyPress}/> </div>我们使用
onKeyPress事件来处理Enter键的点击,以便我们可以发送聊天消息。 -
让我们快速向
style.css文件添加一些 CSS,使输入字段看起来更好,如下所示:.chatWindow .input input { width: calc(100% - 4px); border: none; padding: 2px; } .chatWindow .input input:focus { outline: none; }
以下截图显示了聊天窗口,其中通过聊天窗口输入插入了一条新消息:

图 4.6 – 聊天窗口中的消息
我们还没有实现并且不会在本书中涵盖的许多功能——例如,如果是一个群聊,在聊天消息旁边显示用户名,在消息旁边显示头像,以及在新消息发送后更新聊天面板中的lastMessage字段,这都有意义。要实现一个像 Facebook 这样的完整社交网络所需的工作量是无法在本书中涵盖的,但你将学习所有必需的技术、工具和策略,以便你可以自己着手进行。我们将要涵盖的下一个重要功能是分页。
React 和 GraphQL 中的分页
当我们说到分页时,大多数情况下,我们指的是数据的批量查询。目前,我们在数据库中查询所有帖子、聊天和消息。如果你想想 Facebook 中与朋友的一次聊天存储了多少数据,你就会意识到一次性获取所有消息和数据是不现实的。一个更好的解决方案是使用分页。使用分页,我们每次请求都有一个页面大小或限制,即我们想要获取多少项。我们还有一个页面或偏移量,从那里我们可以开始选择数据行。
在本节中,我们将探讨如何使用帖子源进行分页,因为这是最直接的例子。在 第五章,可重用 React 组件和 React 钩子 中,我们将专注于编写高效且可重用的 React 代码。Sequelize 默认提供分页功能。我们首先插入一些更多的演示帖子,以便我们可以以每批 10 个帖子进行分页。
在实现前端之前,我们需要对后端进行一些调整,如下所示:
-
在我们的 GraphQL 模式中添加一个新的
RootQuery,如下所示:postsFeed(page: Int, limit: Int): PostFeed -
PostFeed类型仅包含posts字段。在应用程序的开发过程中,您可以返回更多信息,例如项目总数、页面数等。以下代码片段展示了如何实现:type PostFeed { posts: [Post] } -
接下来,我们必须在我们的
resolvers.js文件中实现PostFeed实体。将新的解析函数复制到resolvers文件中,如下所示:postsFeed(root, { page, limit }, context) { var skip = 0; if(page && limit) { skip = page * limit; } var query = { order: [['createdAt', 'DESC']], offset: skip, }; if(limit) { query.limit = limit; } return { posts: Post.findAll(query) }; },
我们构建了一个 Sequelize 可以理解的简单 query 对象,这使得我们可以对帖子进行分页。page 数乘以 limit 参数,以跳过计算出的行数。offset 参数跳过行数,而 limit 参数在指定数量(在我们的例子中是 10)之后停止选择行。
我们的前端需要一些调整以支持分页。使用 npm 安装一个新的 React 包,它为我们提供了无限滚动实现,如下所示:
npm install react-infinite-scroll-component --save
无限滚动是一种让用户通过滚动到浏览器窗口底部来加载更多内容的优秀方法。
您可以自己编程实现,但在这里我们不会涉及。回到 Feed.js 文件,替换 GET_POSTS 查询,并使用以下代码导入 react-infinite-scroll-component 包:
import InfiniteScroll from 'react-infinite-scroll-component';
const GET_POSTS = gql'
query postsFeed($page: Int, $limit: Int) {
postsFeed(page: $page, limit: $limit) {
posts {
id
text
user {
avatar
username
}
}
}
}
';
由于 postsFeed 查询期望除了之前的标准查询之外的其他参数,我们需要编辑我们的 useQuery 钩子,并引入两个新的状态变量。更改的行如下所示:
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, { pollInterval: 5000, variables: { page: 0, limit: 10 } });
在前面的代码片段中,我们从 useQuery 钩子中提取了 fetchMore 函数,该函数用于运行分页请求以加载更多帖子项。我们还创建了一个 hasMore 状态变量,它将确定是否还有更多数据可以从 GraphQL API 加载,而 page 变量将保存当前页——更确切地说,是已经滚动过的页数。
根据我们在 GraphQL 模式中定义的新数据结构,我们从 postsFeed 对象中提取 posts 数组。您可以通过替换以下两行代码来实现:
const { postsFeed } = data;
const { posts } = postsFeed;
将我们当前源中的 div 标签的标记替换为使用我们新的无限滚动包,如下所示:
<div className="feed">
<InfiniteScroll
dataLength={posts.length}
next={() => loadMore(fetchMore)}
hasMore={hasMore}
loader={<div className="loader" key={"loader"}>
Loading ...</div>}
>
{posts.map((post, i) =>
<div key={post.id} className={'post ' + (post.id < 0
? 'optimistic': '')}>
<div className="header">
<img src={post.user.avatar} />
<h2>{post.user.username}</h2>
</div>
<p className="content">{post.text}</p>
</div>
)}
</InfiniteScroll>
</div>
无限滚动包所做的唯一事情是运行 next 属性中提供的 loadMore 函数,只要 hasMore 设置为 true 并且用户滚动到浏览器窗口的底部。当 hasMore 设置为 false 时,事件监听器将被解绑,不再发送更多请求。当没有更多内容可用时,这种行为非常好,因为我们可以停止发送更多请求。
在运行无限滚动器之前,我们需要实现 loadMore 函数。它依赖于我们刚刚配置的 page 变量。loadMore 函数应如下所示:
const loadMore = (fetchMore) => {
const self = this;
fetchMore({
variables: {
page: page + 1,
},
updateQuery(previousResult, { fetchMoreResult }) {
if(!fetchMoreResult.postsFeed.posts.length) {
setHasMore(false);
return previousResult;
}
setPage(page + 1);
const newData = {
postsFeed: {
__typename: 'PostFeed',
posts: [
...previousResult.postsFeed.posts,
...fetchMoreResult.postsFeed.posts
]
}
};
return newData;
}
});
}
让我们快速浏览一下前面的代码,如下所示:
-
fetchMore函数接收一个对象作为参数。 -
我们指定了
variables字段,它随我们的请求发送,以查询分页帖子正确的页面索引。 -
updateQuery函数被定义为实现将需要包含在我们的新闻源中的新数据的逻辑。我们可以通过查看返回的数组长度来检查响应中是否包含任何新数据。如果没有帖子,我们可以将hasMore状态变量设置为false,这将解绑所有滚动事件。否则,我们可以继续,并在newData变量内部构建一个新的postsFeed对象。posts数组由之前的posts查询结果和刚刚获取的帖子填充。最后,newData变量被返回并保存在客户端的缓存中。 -
当
updateQuery函数完成后,UI 将相应地重新渲染。
到目前为止,您的源能够在用户访问窗口底部时加载新帖子。我们不再一次性加载所有帖子,而是只从数据库中获取最新的 10 个帖子。每次您构建具有大量列表和许多行的应用程序时,您都必须添加某种分页,无论是无限滚动还是简单的页面按钮。
我们现在创建了一个新问题。如果 React Apollo 缓存为空,我们可以使用 GraphQL 变异提交一个新的帖子,但 Mutation 组件的 update 函数将抛出错误。我们的新查询不仅存储在其名称下,还存储在发送它的变量下。要从我们客户端的缓存中读取特定分页 posts 请求的数据,我们必须也传递变量,例如页面索引。此外,我们还有一个第二层,postsFeed 作为 posts 数组的父级。将 update 函数更改为使其再次工作,如下所示:
postsFeed(existingPostsFeed) {
const { posts: existingPosts } = existingPostsFeed;
const newPostRef = cache.writeFragment({
data: addPost,
fragment: gql'
fragment NewPost on Post {
id
type
}
'
});
return {
...existingPostsFeed,
posts: [newPostRef, ...existingPosts]
};
}
我们实际上只是将 posts 属性更改为 postsFeed,并更新了函数以更新提取的 posts 数组。
如此复杂的代码需要一些有用的工具来调试。继续阅读以了解更多关于 Apollo Client Devtools 的信息。
使用 Apollo Client Devtools 进行调试
无论您编写还是扩展自己的应用程序,您都必须在开发过程中测试、调试和记录不同的事情。在第一章,准备您的开发环境中,我们探讨了 Chrome 的 React 开发者工具,而在第二章,使用 Express.js 设置 GraphQL中,我们探讨了 Postman 用于测试 API。现在,让我们看看另一个工具。
Apollo Client Devtools是另一个 Chrome 扩展,允许您发送 Apollo 请求。虽然 Postman 在很多方面都很出色,但它不与我们的应用程序集成,并且没有实现所有 GraphQL 特定的功能。Apollo Client Devtools 依赖于我们在本章早期设置的 Apollo 客户端。
每个请求,无论是查询还是突变,都是通过我们应用程序的 Apollo 客户端发送的。开发者工具还提供了诸如自动完成等特性,用于编写请求。它们可以显示我们的 GraphQL API 中实现的模式,我们还可以查看缓存。我们将详细介绍扩展提供的四个主要窗口。
让我们在这里看看一个例子:

图 4.7 – Apollo 客户端开发者工具
GraphiQL窗口如前截图所示。前一个截图中的三个面板描述如下:
-
您可以在左侧文本区域中输入您想要发送的请求。它可以是一个突变或查询,包括输入的标记,例如。您还可以在底部输入变量。
-
发送请求时,响应将显示在中间面板中。
-
在右侧面板中,您可以找到您将运行请求的模式。您可以通过点击根类型手动遍历整个 GraphQL 模式或搜索整个模式。当您忘记一个特定的字段或突变叫什么或它接受哪些参数时,这个特性非常有用。
在顶部栏中,您会找到Prettify按钮,它可以使您的查询更加整洁,便于阅读。Load from cache复选框尝试在可能的情况下直接从缓存中检索任何请求的数据。通过点击Play按钮,您可以运行查询。这些都是用来正确测试我们的 GraphQL 请求的工具。Build按钮将为您提供一个小型的图形界面来编辑您的查询。
接下来是Queries窗口,这是一个有用的显示。这里列出了客户端运行过的所有查询,包括查询字符串和变量。如果您愿意,可以通过点击顶部的按钮重新运行一个查询,如下面的截图所示:

图 4.8 – Apollo Queries 窗口
Mutations窗口实际上与Queries窗口相同,但用于突变。列表为空,只要您没有发送任何突变。
最后一个窗口是 缓存。在这里,你可以看到存储在 Apollo 缓存中的所有数据,如下面的截图所示:

图 4.9 – Apollo 缓存窗口
在左侧面板中,你可以搜索你的数据。右侧面板显示了所选对象在 JSON 中的显示。
你也可以看到我已经对 API 进行了大量的测试,因为左侧面板中有多个 Post 对象。
重置 Apollo 缓存
为了测试目的,我通过突变提交了多个帖子,但后来删除了它们以确保截图清晰。Apollo 没有删除数据库中已删除的旧帖子,因此它们仍然存储在缓存中。你应该在用户退出你的应用程序时删除这些数据,以确保未经授权的用户无法访问。
这就是你需要了解的关于 Apollo 客户端 Devtools 的所有内容。
摘要
在本章中,你学习了如何将你的 GraphQL API 连接到 React。为此,我们使用了 Apollo 客户端来管理组件的缓存和状态,并更新 React 和浏览器的实际 DOM。我们探讨了如何向服务器发送查询和突变。我们还介绍了如何使用 React 和 Apollo 实现分页,以及如何使用 Apollo 客户端 Devtools。
在本章之后,你应该能够随时将 Apollo 客户端集成到你的 React 应用程序中。此外,你应该能够在应用程序的每个组件中使用 Apollo,并能够对其进行调试。
下一章将介绍如何编写可重用的 React 组件。到目前为止,我们已经编写了代码,但并没有太多考虑可读性或良好的实践。这些问题将在下一章中解决。
第五章:可重用 React 组件和 React Hooks
为了达到本书的这一阶段,我们已经做了很多工作,包括使用 Apollo Client 保存、请求、插入和更新数据,与我们的 GraphQL 应用程序编程接口(API)相结合。我们编写的许多代码也将需要多次审查。这尤其重要,因为我们正在快速构建一个应用程序。目前一切正常,但我们在这里做得还不够;为了编写好的 React 应用程序,我们需要遵循一些最佳实践和策略。
本章将涵盖你需要了解的一切,以便编写高效且可重用的 React 组件。它将涵盖以下主题:
-
介绍 React 模式
-
构建我们的 React 应用程序结构
-
扩展 Graphbook
-
记录 React 应用程序
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
介绍 React 模式
使用任何编程语言、框架或库时,你都应该遵循一些常见的策略。它们提供了一种可理解且高效的方式来编写应用程序。
在第四章“将 Apollo Hook 集成到 React 中”,我们探讨了某些模式,例如渲染数组、展开运算符和对象解构。尽管如此,还有一些其他模式你应该了解。
我们将介绍 React 提供的最常用的模式,如下所示:
-
受控组件
-
函数组件
-
条件渲染
-
渲染子组件
这里的许多示例(但并非全部)只是展示了每种方法的形态。其中一些示例可能不会被应用到我们的实际应用代码中,所以如果你对学习模式的基本方面不感兴趣,或者你已经了解了很多,你可以跳过这些示例。
注意
除了我将提供的简要解释之外,还有更多关于这个主题的详细文档。官方 React 文档始终是一个好的起点,但你可以在reactpatterns.com/找到所有 React 模式,包括我们已使用的那些。
受控组件
在前几章中,当我们编写提交新帖子或聊天中的消息输入表单时,我们意外地使用了受控输入。为了提供更好的理解,我将快速解释受控和非受控组件之间的区别,以及何时使用每个组件。
让我们从非受控输入开始。
根据定义,当值不是通过 React 的属性设置,而是仅从真实的浏览器 文档对象模型 (DOM) 保存和获取时,组件就是不受控制的。因此,输入值的检索是从 DOM 节点的引用中获取的,并且不是由 React 组件的状态管理和获取。
以下代码片段显示了用户将能够提交新帖子的表单。我已经排除了完整源列表的渲染逻辑,因为它不是我想向您展示的模式的一部分:
import React, { useState, useRef } from 'react';
import { gql, useQuery, useMutation } from '@apollo/client';
const ADD_POST = gql'
mutation addPost($post : PostInput!) {
addPost(post : $post) {
id
text
user {
username
avatar
}
}
}
';
const Feed = () => {
const textArea = useRef(null)
const [addPost] = useMutation(ADD_POST);
const handleSubmit = (event) => {
event.preventDefault();
addPost({ variables: { post: { text:
textArea.current.value } } });
};
return (
<div className="container">
<div className="postForm">
<form onSubmit={handleSubmit}>
<textarea ref={textArea} placeholder="Write your
custom post!"/>
<input type="submit" value="Submit" />
</form>
</div>
</div>
)
}
export default Feed
在这个例子中,您可以看到我们不再有 useState 钩子,因为 textarea 的值存储在真实的 DOM 节点中,而不是应用程序状态中。
我们运行 React 提供的 useRef 钩子。它准备变量以接受 DOM 节点作为属性。如果您使用基于类的 React 组件,您可以使用 createRef 函数。
在渲染组件的 return 语句中,ref 属性填充了我们刚刚用 DOM 元素创建的引用。
通过使用正常的 JavaScript DOM API 来访问 DOM 节点的值。您可以在发送我们表单的 submit 事件时看到这种行为。值是从 textArea.current.value 字段中提取的。
不受控制组件所需的一切已经在这里展示;没有更多了。您可以比较这种方法与我们的当前帖子表单实现。在我们的实现中,我们设置了状态,监听变化事件,并直接从组件状态而不是从 DOM 元素中保存和读取值。
当使用不受控制组件并直接与 DOM 元素工作时,问题是您离开了正常的 React 工作流程。您不再能够处理条件,因此无法在 React 内部触发其他事件。
尽管如此,DOM 引用可以使得使用为 React 生态系统之外编写的第三方插件变得更加容易。例如,有数千个优秀的 jQuery 插件。我总是推荐使用受控组件的默认方法。在 99% 的情况下,这可以在不离开 React 工作流程的情况下工作。
小贴士
如果您需要更深入地了解哪种方法更适合您的特定情况,请查看goshakkk.name/controlled-vs-uncontrolled-inputs-react/。
函数组件
编写结构良好且可重用 React 组件的一个基本且有效的方法是使用函数组件。我们在整本书的每个地方都已经使用了它们。
在函数组件出现之前,存在无状态函数。正如您可能预料的,无状态函数是函数,而不是 React 组件。它们无法存储任何状态;只能使用属性来传递和渲染数据。属性更新将直接在无状态函数内部重新渲染。
由于 React 的新版本,那些无状态函数不再存在,因为有 React Hooks 允许我们在这些函数中使用状态,这使得它们成为完全功能性的组件。这就是为什么它们现在被称为函数组件。
我们已经编写了很多代码,其中更多的函数组件可以帮助我们提供更结构化和易于理解的代码。
从文件结构开始,我们将为我们的新组件创建一个新的文件夹,如下所示:
mkdir src/client/components
我们的应用程序中的许多部分都需要重新设计。为我们的第一个功能组件创建一个新文件,如下所示:
touch src/client/components/loading.js
目前,我们显示了一个单调乏味的loading.js文件:
import React from 'react';
export default ({color, size}) => {
var style = {
backgroundColor: '#6ca6fd',
width: 40,
height: 40,
};
if(typeof color !== typeof undefined) {
style.color = color;
}
if(typeof size !== typeof undefined) {
style.width = size;
style.height = size;
}
return <div className="bouncer" style={style}></div>
}
在前面的代码片段中,我们正在使用从我们的函数接收的属性中的color和size字段中的简单函数。
我们正在构建一个默认的style对象,它代表加载旋转器的基样式。您可以单独传递color和size值,以调整这些设置。
最后,我们返回一个带有bouncer类的简单div标签。
这里缺少的是 CSS 样式。代码应该看起来像这样;我们只需将其添加到我们的style.css文件中:
.bouncer {
margin: 20px auto;
border-radius: 100%;
-webkit-animation: bounce 1.0s infinite ease-in-out;
animation: bounce 1.0s infinite ease-in-out;
}
@-webkit-keyframes bounce {
0% {
-webkit-transform: scale(0)
}
100% {
-webkit-transform: scale(1.0);
opacity: 0;
}
}
@keyframes bounce {
0% {
-webkit-transform: scale(0);
transform: scale(0);
}
100% {
-webkit-transform: scale(1.0);
transform: scale(1.0);
opacity: 0;
}
}
如前例所示,我们使用 CSS 动画正确显示我们的加载旋转器,并让它以脉冲的方式动画化。
现在我们已经完成了功能组件。您应该将其放置到现有代码中,任何存在加载状态的地方。
首先,将新的加载旋转器导入到文件顶部,如下所示:
import Loading from './components/loading';
然后,您可以按照以下方式渲染函数组件:
if (loading) return <Loading />;
使用npm run server启动服务器,使用npm run client启动前端。现在您应该看到您插入位置处的脉冲蓝色气泡。我在我的帖子源中测试过,看起来相当不错。
函数组件的优势在于它们是最小化和高效的函数,渲染我们应用程序的较小部分。这种方法与 React 完美集成,我们可以改进我们编写的代码。
条件渲染
React 的一个重要能力是条件渲染组件或数据。我们将在接下来要实现的主要功能中大量使用它。
通常,您可以通过使用花括号语法来实现条件渲染。这里提供了一个if语句的示例:
const [shouldRender, setShouldRender] = useState(false);
return (
<div className="conditional">
{(shouldRender === true) && (
<p>Successful conditional rendering!</p>
)}
</div>
)
这段代码是条件渲染的最简单示例。我们有来自组件状态的shouldRender变量,并使用它作为条件。当条件为true时,第二部分——即我们的Successful conditional rendering!文本——也将被渲染。这是因为我们使用了&&字符。如果条件为false,则文本不会渲染。
您可以用您想象中的任何内容替换前面的条件。它可以是一个复杂的条件,例如返回布尔值的函数,或者就像前面的代码一样,它可以是状态变量。
您将在本书的后续步骤和章节中看到更多示例。
渲染子组件
在我们迄今为止编写的所有代码中,我们直接编写了标记,就像它是渲染到真实的 超文本标记语言(HTML)一样。
React 提供的一个很棒的功能是能够将子组件传递给其他组件。父组件决定如何处理其子组件。
我们仍然缺少的是为我们的用户提供一个好的错误信息。因此,我们将使用这个模式来解决这个问题。
在 components 文件夹中,在 loading.js 文件旁边创建一个 error.js 文件,如下所示:
import React from 'react';
export default ({ children }) => {
return (
<div className="error message">
{children}
</div>
);
}
当将子组件传递给另一个组件时,会在组件的属性中添加一个新的属性,称为 children。你通过编写正常的 React 标记来指定 children。
如果你想的话,你可以执行一些操作,比如遍历每个子组件。在我们的例子中,我们通过使用花括号并将 children 变量放在其中来按常规渲染子组件。
要开始使用新的 Error 组件,你可以简单地导入它。新组件的标记如下所示:
if (error) return <Error><p>{error.message}</p></Error>;
添加一些 CSS,一切应该就完成了,如下面的代码片段所示:
.message {
margin: 20px auto;
padding: 5px;
max-width: 400px;
}
.error.message {
border-radius: 5px;
background-color: #FFF7F5;
border: 1px solid #FF9566;
width: 100%;
}
一个工作结果可能看起来像这样:

图 5.1 – 错误信息
你可以将无状态函数模式和子组件模式应用到许多其他用例中。你使用哪一个将取决于你的具体场景。在这种情况下,你也可以使用一个无状态函数,而不是 React 组件。
接下来,我们将探讨如何改进我们的代码结构,以及 React 遵循的基本原则。
结构化我们的 React 应用程序
我们已经通过使用 React 模式改进了一些事情。你应该做一些作业,并在可能的地方引入这些模式。
在编写应用程序时,一个关键目标是保持它们模块化和可读性,同时尽可能易于理解。总是很难判断拆分代码是有用的还是使事情过于复杂。这是通过尽可能多地编写应用程序和代码,你会越来越多地了解的事情。
让我们进一步结构化我们的应用程序。
React 文件结构
我们已经将 Loading 和 Error 组件保存在 components 文件夹中。然而,我们为了提高本书的可读性,并没有将组件的许多部分单独保存到不同的文件中。
我将通过一个例子来解释不可读 React 代码的最重要解决方案。你可以在稍后自己实现这个解决方案,应用到我们应用的其它部分,因为你不应该阅读重复的代码。
目前,我们通过映射 GraphQL 响应中的所有帖子来在我们的动态中渲染帖子。在那里,我们直接渲染所有帖子项的相应标记。因此,这是一个一次性完成所有事情的庞大渲染函数。
为了使这更加直观,我们应该创建一个新的Post组件。将组件分离极大地提高了我们帖子流的可读性。然后,我们可以用一个新的组件替换循环中的返回值,而不是真正的标记。
我们不应该在我们的components文件夹中创建一个post.js文件,而应该首先创建另一个post文件夹,如下所示:
mkdir src/client/components/post
Post组件由多个小的嵌套组件组成。帖子也是一个独立的 GraphQL 实体,因此有一个单独的文件夹是合理的。我们将把所有相关的组件存储在这个文件夹中。
让我们创建这些组件。我们将从帖子头部开始,定义帖子项的顶部部分。在components/post文件夹中创建一个新的header.js文件,如下所示:
import React from 'react';
export default ({post}) => {
return (
<div className="header">
<img src={post.user.avatar} />
<div>
<h2>{post.user.username}</h2>
</div>
</div>
);
}
header组件只是一个函数组件。正如您所看到的,我们正在使用本章早期页面上的 React 模式。
接下来是帖子内容,它代表帖子项的主体。在名为content.js的新文件中添加以下代码:
import React from 'react';
export default ({post}) =>
<p className="content">
{post.text}
</p>
代码与帖子头部的代码几乎相同。在后面的点,您将可以自由地在这两个文件中引入真实的 React 组件或扩展标记。这完全取决于您的实现。
主要文件是位于新post文件夹中的新index.js文件。它应该看起来像这样:
import React from 'react';
import PostHeader from './header';
import PostContent from './content';
const Post = ({ post }) => {
return (
<div className={"post " + (post.id < 0 ? "optimistic":
"")}>
<PostHeader post={post}/>
<PostContent post={post}/>
</div>
)
}
export default Post
之前的代码代表一个非常基本的组件,但与之前直接使用标记来渲染完整的帖子项目不同,我们在这里使用了两个进一步的组件,即PostHeader和PostContent。这两个组件都接收post作为属性。
现在,您可以轻松地在帖子列表中使用新的Post组件。首先,按照以下方式导入组件:
import Post from './components/post';
当有一个index.js文件时,您可以减少路径,直接指向文件夹,index.js文件将被自动选中。然后,只需替换循环中的旧代码,如下所示:
<Post key={post.id} post={post} />
改进之处在于,所有三个组件在第一眼就能给您一个清晰的概述。在循环内部,我们返回一个帖子项。帖子项由标题和正文内容组成。
尽管如此,仍有改进的空间,因为帖子流列表很杂乱。
高效的 Apollo React 组件
我们已经成功地将我们的帖子中的项目替换为 React 组件,而不是原始标记。
我非常不喜欢的一个主要部分是我们定义和传递实际的 GraphQL 查询或突变到组件内的useQuery或useMutation钩子。如果能够一次性定义这些 GraphQL 请求并在需要的地方使用它们,那么我们就可以在不同的组件之间使用它们。
此外,不仅纯 GraphQL 请求应该是可重用的,而且update函数或optimisticResponse对象也应该可以在不同的组件之间重用。
例如,我们将在下一节中解决Feed.js中的这些问题。
使用 Apollo 的片段
GraphQL 片段是一种功能,允许你在不同的更大的 GraphQL 查询之间共享常见的查询或其部分。例如,如果你有多个请求user对象的 GraphQL 查询,并且这个用户的数据结构始终相同,你可以定义一个用户片段,以便在多个不同的 GraphQL 请求中重用相同的属性。
我们将为GET_POSTS查询实现这一点。按照以下说明操作:
-
在
apollo文件夹内创建一个新的queries文件夹和一个fragments文件夹,如下所示:mkdir src/client/apollo/queries mkdir src/client/apollo/fragments -
在
fragments文件夹内创建一个名为userAttributes.js的文件,并填写以下代码行:import { gql } from '@apollo/client'; export const USER_ATTRIBUTES = gql' fragment userAttributes on User { username avatar } ';对于我们迄今为止请求的所有
user对象,我们始终返回用户名和头像图片,因为我们总是在用户个人资料图片中显示名称。在前面的代码片段中,我们实现了匹配的 GraphQL 片段,我们现在可以在任何其他查询中使用它,以便请求确切的数据。 -
在
queries文件夹内创建一个名为getPosts.js的文件。向其中添加以下内容:import { gql } from '@apollo/client'; import { USER_ATTRIBUTES } from '../fragments/userAttributes'; export const GET_POSTS = gql' query postsFeed($page: Int, $limit: Int) { postsFeed(page: $page, limit: $limit) { posts { id text user { ...userAttributes } } } } ${USER_ATTRIBUTES} ';在前面的代码片段中,我们正在使用我们新创建的 GraphQL 片段。首先,我们当然在文件顶部导入它。在 GraphQL 查询内部,我们使用
…userAttributes语法,这与正常的 JavaScript 解构赋值类似,并将具有相同名称的片段展开,以便在 GraphQL 查询的指定位置注入这些属性。最后一步是在实际查询下方添加一个片段,以便能够使用它。 -
最后一步是将
Feed.js文件中我们手动解析的旧GET_POSTS变量替换为这个import语句:import { GET_POSTS } from './apollo/queries/getPosts';
到目前为止,我们已经在主GET_POSTS查询中成功使用了片段,我们也可以通过将其导入任何其他组件来重用它。你可以对其他所有请求重复此操作,通过这样做,可以达到更清晰的代码结构和更高的可重用性。
重用 Apollo Hooks
我们成功地从组件中提取了 GraphQL 查询到单独的可重用文件中。
尽管如此,我们组件中仍有一些逻辑,用于定义如何处理 GraphQL 变异的update函数和optimisticResponse对象。我们也可以将这些提取出来,以进一步提高可重用性并清理代码。
使用 Apollo 或 React Hooks 的一个问题是它们需要在功能组件内执行。尽管如此,我们可以将 Hook 配置的最大部分保存在组件外部,以便它们可重用。
按照以下说明完成addPost变异:
-
在
apollo文件夹内创建一个新的mutations文件夹,如下所示:mkdir src/client/apollo/mutations -
创建一个名为
addPost.js的文件,并将以下代码插入其中:import { gql } from '@apollo/client'; import { USER_ATTRIBUTES } from '../fragments/userAttributes'; export const ADD_POST = gql' mutation addPost($post : PostInput!) { addPost(post : $post) { id text user { ...userAttributes } } } ${USER_ATTRIBUTES} ';令人高兴的是,我们可以重用之前在这里创建的片段,因此我们不需要再次定义它。
-
我们可以在
Feed.js文件中使用这个ADD_POST变量来进行我们的突变。用以下import语句替换实际的解析 GraphQL 查询:import { ADD_POST } from './apollo/mutations/addPost'; -
我们在
useMutation钩子中仍然有update函数和optimisticResponse对象。最简单的方法是将它们移动到addPost.js文件中,如下所示:export const getAddPostConfig = (postContent) => ({ optimisticResponse: { __typename: "mutation", addPost: { __typename: "Post", text: postContent, id: -1, user: { __typename: "User", username: "Loading...", avatar: "/public/loading.gif" } } }, update(cache, { data: { addPost } }) { cache.modify({ fields: { postsFeed(existingPostsFeed) { const { posts: existingPosts } = existingPostsFeed; const newPostRef = cache.writeFragment({ data: addPost, fragment: gql' fragment NewPost on Post { id type } ' }); return { ...existingPostsFeed, posts: [newPostRef, ...existingPosts] }; } } }); } });我们在导入时返回一个函数,这样我们就可以传递所有必需的参数。唯一预期的参数是
postContent状态变量,它是optimisticResponse对象所必需的。 -
再次更新
Feed.js文件中的import语句,同时导入getAddPostConfig函数,如下所示:import { ADD_POST, getAddPostConfig } from './apollo/mutations/addPost'; -
最终的
useState钩子应该看起来像这样:const [addPost] = useMutation(ADD_POST, getAddPostConfig(postContent));我们将
getAddPostConfig作为useMutation钩子的第二个参数执行。它将被返回的对象填充,但仍然会在optimisticResponse对象中保留postContent值,因为每次值发生变化时,getAddPostConfig函数也会被运行。 -
我们甚至可以更进一步。将以下代码行添加到
addPost.js文件中:export const useAddPostMutation = (postContent) => useMutation(ADD_POST, getAddPostConfig(postContent));我们将在我们的组件中替换普通的
useMutation钩子,并围绕它构建一个小包装器。它将接受postContent值,并将其传递给getAddPostConfig函数。优势在于我们只需导入useAddPostMutation函数,执行它之后,所有默认配置都会应用,我们可以在任何组件中使用它,而无需单独导入查询、配置和useMutation钩子。 -
按照以下方式更改
Feed.js文件中addPost.js文件的导入:import { useAddPostMutation } from './apollo/mutations/addPost'; -
用以下代码行替换
useMutation钩子,并在这样做的同时删除useMutation导入:const [addPost] = useAddPostMutation(postContent);我们只需要导入一个函数,就可以在任何我们想要的组件中运行完整的
addPost突变。这个概念可以与迄今为止我们使用的任何查询或突变一起工作。
我们清理了功能组件的大部分 GraphQL 请求逻辑,现在它已经在单独的文件中。我们可以在需要查询或突变的地方使用它们。
我建议你为所有其他位置重复相同的操作,这样你就有了一个很好地填充的 GraphQL 请求集,我们可以在任何需要的地方重用。这将是一个很好的家庭作业练习,以学习这个概念。
接下来,我们将看看如何扩展 Graphbook。
扩展 Graphbook
我们的社会网络仍然有点粗糙。除了我们仍然缺少身份验证之外,所有功能都很基础;撰写和阅读帖子及消息并不特别。
如果你与 Facebook 进行比较,我们还有很多事情要做。当然,我们不可能完全重建 Facebook,但通常的功能应该都有。从我的观点来看,我们应该涵盖以下功能:
-
在帖子中添加下拉菜单以允许删除帖子。
-
使用 React Context API 创建一个全局的
user对象。 -
使用 Apollo 缓存作为 React Context API 的替代方案。
-
实现顶部栏作为第一个渲染在所有视图之上的组件。我们可以从搜索栏中搜索数据库中的用户,并且我们可以从全局
user对象中显示已登录用户。
我们将首先查看第一个功能。
React 上下文菜单
您应该能够几乎独立地编写 React 上下文菜单。所有必需的 React 模式都已解释,并且实现突变现在应该很清晰。
在我们开始之前,我们将制定我们想要遵循的计划。我们的目标是:
-
使用 Font Awesome 渲染简单的图标
-
构建 React 辅助组件
-
处理
onClick事件并设置正确的组件状态 -
使用条件渲染模式来显示下拉菜单,如果组件状态设置正确
-
向菜单中添加按钮并将突变绑定到它们上
继续阅读以了解如何完成任务。
以下是一个预览截图,显示了最终实现的功能应该看起来像什么:

图 5.2 – 带上下文的下拉菜单
我们现在将开始第一个任务,为我们的项目设置 Font Awesome。
React 中的 Font Awesome
如您所注意到的,我们尚未安装 Font Awesome。让我们用 npm 来解决这个问题,如下所示:
npm i --save @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/free-brands-svg-icons @fortawesome/react-fontawesome
Graphbook 依赖于前面的四个包将 Font Awesome 图标导入到我们的前端代码中。
重要提示
Font Awesome 为与 React 一起使用提供了多种配置。最佳、最适用于生产的方案是仅导入我们明确将要使用的图标。对于您的下一个项目或原型,从最简单的方法开始可能是有意义的。您可以在官方页面找到所有信息,链接为 fontawesome.com/v5.15/how-to-use/on-the-web/using-with/react。
为 Font Awesome 创建一个单独的文件将帮助我们有一个干净的导入。将以下代码保存到 fontawesome.js 文件中,位于 components 文件夹内:
import { library } from '@fortawesome/fontawesome-svg-core';
import { faAngleDown } from '@fortawesome/free-solid-svg-icons';
library.add(faAngleDown);
首先,我们从 Font Awesome 核心包中导入 library 对象。对于我们的特定用例,我们只需要一个箭头图像,称为 angle-down。使用 library.add 函数,我们将此图标注册以供以后使用。
重要提示
Font Awesome 有许多版本。在这本书中,我们使用 Font Awesome 5,仅包含免费图标。更多高级图标可以在官方 Font Awesome 网页上购买。您可以在图标库中找到所有图标的概述,以及每个图标的详细描述,链接为 fontawesome.com/icons?d=gallery。
我们只需要在根App.js文件中放置这个文件。这确保了所有自定义的 React 组件都可以显示导入的图标。请将以下import语句添加到文件顶部:
import './components/fontawesome';
由于不会有任何变量来保存导出的方法,因此不需要任何变量。我们只想在我们的应用程序中执行这个文件一次。
当你的应用程序需要一套完整的图标时,你可以直接从@fortawesome/free-brands-svg-icons包中获取所有图标,这个包我们也已经安装了。
接下来,我们将创建一个Dropdown辅助组件。
React 辅助组件
适用于生产的应用程序需要尽可能完美。实现可重用的 React 组件是必须做的事情之一。
你应该注意到,当构建客户端应用程序时,下拉菜单是一个常见的话题。它们是前端的全局部分,出现在我们的组件的各个地方。
最好将我们想要显示的实际菜单标记代码与处理事件绑定和显示菜单的代码分开。
我总是将这类代码称为 React 中的辅助组件。它们不实现任何业务逻辑,但给我们提供了在需要的地方重用下拉菜单或其他功能的机会。
从逻辑上讲,第一步是创建一个新的文件夹来存储所有辅助组件,如下所示:
mkdir src/client/components/helpers
创建一个名为dropdown.js的新文件,作为辅助组件,如下所示:
import React, { useState, useRef, useEffect } from 'react';
export default ({ trigger, children }) => {
const [show, setShow] = useState(false);
const wrapperRef = useRef(null);
useOutsideClick(wrapperRef);
function useOutsideClick(ref) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current &&
!ref.current.contains(event.target)) {
setShow(false);
}
}
document.addEventListener("mousedown",
handleClickOutside);
return () => {
document.removeEventListener("mousedown",
handleClickOutside);
};
}, [ref]);
}
return(
<div className="dropdown">
<div>
<div className="trigger" onClick={() =>
setShow(!show)}>
{trigger}
</div>
<div ref={wrapperRef}>
{ show &&
<div className="content">
{children}
</div>
}
</div>
</div>
</div>
)
}
编写下拉组件不需要很多代码。它也非常高效,因为它几乎适用于你能想到的每一种场景。
在前面的代码片段中,我们使用了基本的的事件处理。当触发div标签被点击时,我们更新显示状态变量。在div触发器内部,我们还渲染了一个名为trigger的属性。trigger属性可以是普通文本、HTML 标签,甚至是 React 组件。它可以通过父组件传递,以便自定义下拉组件的外观。
除了trigger属性外,我们还使用了两个著名的 React 模式,如下所示:
-
当
show变量为true时的条件渲染 -
渲染由父组件提供的子元素
这个解决方案允许我们直接将我们想要渲染的菜单项作为Dropdown组件的子元素填充,正如之前提到的,这是在点击触发器后显示的。在这种情况下,show状态变量为true。
然而,这里仍有一件事不完全正确。如果你通过提供简单的文本或图标作为触发器,以及其他文本作为内容来测试下拉组件,你应该会看到Dropdown组件只有在再次点击触发器时才会关闭;点击浏览器中的其他地方(下拉菜单之外)时不会关闭。
这是一种 React 方法遇到问题的场景。没有 DOM 节点事件,例如useOutsideClick,因此我们无法直接监听任何 DOM 节点的外部点击事件,例如我们的下拉菜单。传统的方法是将事件监听器绑定到整个文档上。在我们的浏览器中点击任何地方都会关闭下拉菜单。
useOutSideClick钩子仅检查点击的元素是否与我们通过useRef钩子设置的引用匹配。
当点击触发按钮时,我们使用 JavaScript 的addEventListener函数将点击事件监听器添加到整个文档中。
重要提示
有许多情况下,可能有必要放弃 React 方法,直接通过标准的 JavaScript 接口使用 DOM。
阅读这篇关于Medium的文章,以获得更好的理解:medium.com/@garrettmac/reactjs-how-to-safely-manipulate-the-dom-when-reactjs-cant-the-right-way-8a20928e8a6。
useEffect钩子仅在组件首次渲染时执行。你可以在那里执行任何类型的逻辑。如果返回值也是一个函数,那么这个函数将在组件卸载时执行。通过这样做,我们不会忘记在组件卸载和从 DOM 中移除时移除所有手动创建的事件监听器。忘记这一点可能会导致许多错误。
如前所述,这是 React 至少有点失败的地方,尽管这不是 React 的错。DOM 和 JavaScript 没有正确的功能。
我们最终可以使用我们的辅助组件并显示帖子的上下文菜单,但首先,我们需要准备所有我们想要渲染的菜单项和组件。
Apollo 的deletePost突变
突变始终位于我们的代码的两个位置。一部分是在后端的 GraphQL API 中编写的,另一部分是在我们的前端代码中编写的。
我们应该从后端实现开始,如下所示:
-
编辑 GraphQL 模式。
deletePost突变需要放在RootMutation对象内部。新的Response类型作为返回值,因为已删除的帖子无法返回,因为它们不存在。注意以下代码片段中我们只需要postId参数,不需要发送完整的帖子:type Response { success: Boolean } deletePost ( postId: Int! ): Response -
添加缺失的 GraphQL 解析器函数。Sequelize 的
destroy函数仅返回一个表示已删除行数的数字。我们返回一个包含success字段的对象。该字段指示我们的前端是否应该抛出错误。代码在以下片段中说明:deletePost(root, { postId }, context) { return Post.destroy({ where: { id: postId } }).then(function(rows){ if(rows === 1){ logger.log({ level: 'info', message: 'Post ' + postId + 'was deleted', }); return { success: true }; } return { success: false }; }, function(err){ logger.log({ level: 'error', message: err.message, }); }); },
在这里唯一特殊的事情是我们需要指定我们想要删除哪些帖子。这是通过在函数调用中包含where属性来完成的。因为我们目前还没有实现身份验证,所以我们无法验证删除帖子的用户,但在我们的例子中,这没有问题。
简而言之,我们的 GraphQL API 现在能够接受deletePost突变。我们不验证发送此突变的用户,所以在我们这个例子中,任何人都可以删除帖子。
我们现在可以再次专注于前端了。
回想一下我们如何实现之前的突变;我们总是为它们创建可重用的函数和配置。我们也应该为delete突变做同样的事情。
让我们从为客户端实现deletePost突变开始,如下所示:
-
在
mutations文件夹内创建一个名为deletePost.js的新文件。 -
按照以下说明导入所有依赖项:
import { gql } from '@apollo/client'; import { useMutation } from '@apollo/client'; -
添加新的
deletePost突变,如下所示:export const DELETE_POST = gql' mutation deletePost($postId : Int!) { deletePost(postId : $postId) { success } } '; -
添加一个新的函数来处理突变的配置,如下所示:
export const getDeletePostConfig = (postId) => ({ update(cache, { data: { deletePost: { success } } }) { if(success) { cache.modify({ fields: { postsFeed(postsFeed, { readField }) { return { ...postsFeed, posts: postsFeed.posts.filter(postRef => postId !== readField('id', postRef)) } } } }); } } });要删除一个项目,我们需要从具有给定
postId值的帖子中清理数组。最简单的方法是返回postsFeed的完整对象,同时在这个过程中,我们让正常的 JavaScriptfilter函数只返回那些没有postId值的帖子。为了读取 Apollo 提供的readField函数。 -
最后,插入
useMutationHook 的包装函数,如下所示:export const useDeletePostMutation = (postId) => useMutation(DELETE_POST, getDeletePostConfig(postId));
我已经移除了optimisticResponse更新,因为如果请求失败,它不直观,因为那时 UI 会首先显示乐观更新,但在失败的情况下,帖子会再次出现,因为 API 调用失败了。这会使你的帖子消失然后再次出现。
我们需要在帖子标题中添加一个新的下拉菜单项,以便我们可以调用deletePost突变。按照以下说明添加它:
-
打开
header.js文件并导入下拉组件、fontawesome和突变,如下所示:import Dropdown from '../helpers/dropdown'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useDeletePostMutation } from '../../apollo/mutations/deletePost'; -
在
return语句之前运行useDeletePostMutationHook,如下所示:const [deletePost] = useDeletePostMutation(post.id); -
将新按钮添加到包含用户名的
div标签下的标题中,如下所示:<Dropdown trigger={<FontAwesomeIcon icon="angle-down" />}> <button onClick={() => deletePost({ variables: { postId: post.id }})}>Delete</button> </Dropdown>
整体解决方案非常简单。我们有一个包装的下拉组件。所有子组件只有在show状态变量改变时才会渲染。这包括我们的deletePost突变和点击触发。突变本身与实际渲染视图的代码是分开的。
你可以从 GitHub 仓库中获取正确的 CSS。
我们现在已经涵盖了帖子的检索和删除。帖子的更新基本上是相同的——而不是添加或删除,你需要通过数据库中的 ID 以及 Apollo 缓存来更新帖子。方法是一样的。
我预计你现在已经准备好处理高级场景了,在这些场景中,需要在不同层之间进行多个组件的通信。因此,当启动服务器和客户端时,你应该看到我在这部分开始时给你提供的预览图像。
为了获得更多的实践,我们将在下一节中为另一个用例重复这个过程。
React 应用程序栏
与 Facebook 相比,我们没有一个突出的应用程序栏。计划是实现类似的功能。它固定在浏览器窗口的顶部,始终位于 Graphbook 的所有部分之上。在完成本节后,你将能够在应用程序栏中搜索其他用户、查看通知以及查看已登录用户。
我们将首先实现一个简单的用户搜索功能,因为它很复杂。
以下截图显示了我们将要构建的预览:
![Figure 5.3 – Search results]
![Figure 5.03_B17337.jpg]
![Figure 5.3 – Search results]
它看起来很基础,但我们在这里所做的是绑定输入的 onChange 事件,并在每次值变化时重新获取查询。从逻辑上讲,这将根据我们 GraphQL API 的响应重新渲染搜索列表。
从 API 开始,我们需要引入一个新的实体。
就像我们的 postsFeed 查询一样,我们从一开始就设置分页,因为以后我们可能想要提供更高级的功能,例如在滚动搜索列表时加载更多项目。
编辑 GraphQL 模式,并填写新的 RootQuery 属性和类型,如下所示:
type UsersSearch {
users: [User]
}
usersSearch(page: Int, limit: Int, text: String!): UsersSearch
UsersSearch 类型期望一个特殊参数,即搜索文本。没有文本参数,请求就没有太多意义。你应该记得从 postsFeed 分页中来的 page 和 limit 参数。
此外,resolver 函数看起来几乎与 postsFeed resolver 函数相同。你可以直接将以下代码添加到 resolvers.js 文件中的 RootQuery 属性,如下所示:
usersSearch(root, { page, limit, text }, context) {
if(text.length < 3) {
return {
users: []
};
}
var skip = 0;
if(page && limit) {
skip = page * limit;
}
var query = {
order: [['createdAt', 'DESC']],
offset: skip,
};
if(limit) {
query.limit = limit;
}
query.where = {
username: {
[Op.like]: '%' + text + '%'
}
};
return {
users: User.findAll(query)
};
},
你应该注意,第一个条件询问提供文本是否大于三个字符。我们这样做是为了避免向我们的数据库发送过多的不必要查询。搜索仅由一个或两个字符组成的用户名会导致我们几乎提供所有用户的信息。当然,这也可以在前端完成,但由于各种客户端可能会使用我们的 API,所以我们需要确保后端也进行这项小的改进。
我们通过 Sequelize 将query对象发送到我们的数据库。代码基本上与之前的postsFeed解析函数类似,只是我们在这里使用了一个 Sequelize 运算符。我们想要找到每个用户名中包含输入文本的用户,而不指定它是在名称的开始、中间还是末尾。因此,我们将使用Op.like运算符,Sequelize 将其解析为一个纯LIKE查询,从而得到我们想要的结果。%运算符在 MySQL 中用于表示任意数量的字符。为了启用此运算符,我们必须导入sequelize包并从中提取Op对象,如下所示:
import Sequelize from 'sequelize';
const Op = Sequelize.Op;
进一步来说,我们可以实现客户端代码,如下所示:
-
在
queries文件夹内创建一个名为searchQuery.js的文件,并将以下代码插入其中:import { gql } from '@apollo/client'; import { USER_ATTRIBUTES } from '../fragments/userAttributes'; import { useQuery } from '@apollo/client'; export const GET_USERS = gql' query usersSearch($page: Int, $limit: Int, $text: String!) { usersSearch(page: $page, limit: $limit, text: $text) { users { id ...userAttributes } } } ${USER_ATTRIBUTES} '; export const getUserSearchConfig = (text) => ({ variables: { page: 0, limit: 5, text }, skip: text.length < 3}) export const useUserSearchQuery = (text) => useQuery(GET_USERS, getUserSearchConfig(text))唯一必需的参数是我们想要传递给搜索的
text参数。如果text参数的长度少于三个字符,我们将传递一个带有true或false的skip属性,以不执行 GraphQL 请求。 -
按照我们的计划,我们将在单独的文件中创建一个应用程序栏。在
components文件夹和index.js文件下方创建一个新的文件夹,命名为bar。用以下代码填充它:import React from 'react'; import SearchBar from './search'; const Bar = () => { return ( <div className="topbar"> <div className="inner"> <SearchBar/> </div> </div> ); } export default Bar此文件作为我们想要在应用程序栏中渲染的所有组件的包装器;它不实现任何自定义逻辑。我们已导入必须创建的
SearchBar组件。 -
SearchBar组件位于一个单独的文件中。只需在bar文件夹中创建一个search.js文件,如下所示:import React, { useState } from 'react'; import { useUserSearchQuery } from '../../apollo/queries/searchQuery'; import SearchList from './searchList'; const SearchBar = () => { const [text, setText] = useState(''); const { loading, error, data } = useUserSearchQuery(text); const changeText = (event) => { setText(event.target.value); } return ( <div className="search"> <input type="text" onChange={changeText} value={text} /> {!loading && !error && data && ( <SearchList data={data}/> )} </div> ); } export default SearchBar我们将当前输入值存储在名为
text的状态变量中。每次文本更改时,useUserSearchQuery钩子都会再次执行,并带有新的text参数。在查询钩子内部,该值合并到变量中,并通过 GraphQL 请求发送。如果请求未加载且没有错误,则将结果传递给SearchList组件的data属性。 -
接下来,我们将实现
SearchList组件。这个组件的行为类似于帖子源,但只有当响应至少包含一个用户时才渲染内容。列表以下拉菜单的形式显示,并在点击浏览器窗口时隐藏。在bar文件夹内创建一个名为searchList.js的文件,并插入以下代码:import React, { useState, useEffect } from 'react'; const SearchList = ({ data: { usersSearch: { users }}}) => { const [show, setShowList] = useState(false); const handleShow = (show) => { if(show) { document.addEventListener('click', handleShow.bind(null, !show), true); } else { document.removeEventListener('click', handleShow.bind(null, !show), true); } setShowList(show); } const showList = (users) => { if(users.length) { handleShow(true); } else { handleShow(false); } } useEffect(() => { showList(users); }, [users]); useEffect(() => { return () => { document.removeEventListener('click', handleShow.bind(null, !show), true); } }); return ( show && <div className="result"> {users.map((user, i) => <div key={user.id} className="user"> <img src={user.avatar} /> <span>{user.username}</span> </div> )} </div> ) } export default SearchList
我们在这里使用带有users依赖项的useEffect钩子,它会在父组件对当前组件设置新属性时执行。在这种情况下,我们检查属性是否至少包含一个用户,然后相应地设置状态,以便使下拉菜单可见。当点击或提供空结果时,下拉菜单会被隐藏。这种方法与帖子下拉菜单非常相似。
现在只需做两件事,如下所示:
-
您应该复制本章官方 GitHub 仓库中的 CSS,以获取正确的样式,或者您可以自己完成。
-
您需要在
App类内部导入 bar 包装组件,并在 React Helmet 和新闻源之间渲染它。
我们应用程序栏的第一个功能现在已经完成。
让我们继续,看看 React 的 Context API、Apollo Consumer 功能以及如何在我们的 React 前端中全局存储数据。
React Context API 与 Apollo Consumer 对比
在我们目前使用的堆栈中处理全局变量的两种方法是:通过新的 React Context API 和 Apollo Consumer 功能。
重要提示
处理全局状态管理还有其他方法。其中最著名的库是 Redux,但还有更多。由于 Redux 的解释会超出本书的主题,我们只关注 React 和 Apollo 提供的工具。
如果您想检查其他方法,请查看 Redux 的网站:redux.js.org/。
从 React 的 16.3 版本开始,有一个 Context API 允许您定义全局提供者,通过深度嵌套的组件提供数据。这些组件不需要您的应用程序通过多个组件从上到下传递数据。相反,它使用所谓的消费者和提供者。当您在应用程序的全球位置设置 user 对象时,这些非常有用,您可以从任何地方访问它。在 React 的早期版本中,您需要从组件到组件传递属性,以便将其传递到 React 组件树底部的正确组件。通过多个组件层传递属性也称为“属性钻取”。
React Context API 的另一种方法是 Apollo Consumer 功能,这是 Apollo 的特定实现。React Context API 是一种通用的做事方式,适用于 Apollo 或您能想到的任何其他东西。
Apollo Consumer 组件的伟大之处在于,它使您能够访问 Apollo 缓存并将其用作数据存储。使用 Apollo Consumer 组件可以节省您处理所有数据,并且您也不需要实现提供者本身;您可以在任何想要的地方消费数据。
这两种方法都会产生以下输出:
![Figure 5.4 – 顶部栏中的用户资料
![Figure 5.04_B17337.jpg]
图 5.4 – 顶部栏中的用户资料
最好的办法是立即向您展示这两种替代方案,以便您可以确定您首选的方法。
React Context API
我们将从 React 方法开始,介绍如何在您的前端存储和访问全局数据。
这里是对这种方法的一个简短说明:
-
上下文:这是一种 React 方法,用于在组件之间共享数据,而无需通过整个树传递。
-
提供者:这是一个全局组件,通常只在代码中的一个位置使用。它使您能够访问特定的上下文数据。
-
消费者:这是一个可以在应用程序的许多不同位置使用的组件,可以读取您所引用的上下文背后的数据。
要开始,在 components 文件夹下创建一个名为 context 的文件夹。在那个文件夹中,创建一个 user.js 文件,我们可以设置 Context API。
我们将逐个步骤进行,如下所示:
-
和往常一样,我们需要导入所有依赖项。此外,我们将设置一个新的空上下文。
createContext函数将返回一个提供者和消费者,在整个应用程序中使用,如下所示:import React, { createContext } from 'react'; const { Provider, Consumer } = createContext(); -
现在,我们想要使用提供者。这里最好的选择是创建一个特殊的
UserProvider组件。稍后,当我们有身份验证时,我们可以调整它以执行 GraphQL 查询,然后在我们的前端共享结果数据。现在,我们将坚持使用假数据。插入以下代码:export const UserProvider = ({ children }) => { const user = { username: "Test User", avatar: "/uploads/avatar1.png" }; return ( <Provider value={user}> {children} </Provider> ); } -
在前面的代码片段中,我们从 Apollo 渲染
Provider组件,并将所有子组件包裹在其中。有一个Consumer组件从Provider中读取。我们将设置一个特殊的UserConsumer组件,通过使用 React 的cloneElement函数克隆它们,将数据传递给底层组件,如下所示:export const UserConsumer = ({ children }) => { return ( <Consumer> {user => React.Children.map(children, function(child){ return React.cloneElement(child, { user }); })} </Consumer> ) }
我们将直接按其名称导出这两个类。
我们需要在代码库的早期引入提供者。最佳方法是将 UserProvider 组件导入到 App.js 文件中,如下所示:
import { UserProvider } from './components/context/user';
按照以下方式使用提供者,并将其包裹在所有基本组件周围:
<UserProvider>
<Bar />
<Feed />
<Chats />
</UserProvider>
在 Bar、Feed 和 Chats 组件的任何地方,我们现在都可以从提供者中读取。
如前所述,我们想在应用程序中显示已登录用户,包括他们的名字。
使用数据的组件是 UserBar 组件。我们需要在 bar 文件夹内创建一个 user.js 文件。插入以下代码:
import React from 'react';
const UserBar = ({ user }) => {
if(!user) return null;
return (
<div className="user">
<img src={user.avatar} />
<span>{user.username}</span>
</div>
);
}
export default UserBar
目前,我们在应用程序栏内渲染一个简单的用户容器,从 user 对象的数据中获取。
要将用户数据传递给 UserBar 组件,我们需要使用 UserConsumer 组件,当然。
打开顶部栏的 index.js 文件,并在 SearchBar 组件下方的 return 语句中添加以下代码:
<UserConsumer>
<UserBar />
</UserConsumer>
显然,您需要在文件顶部导入这两个组件,如下所示:
import UserBar from './user';
import { UserConsumer } from '../context/user';
您现在已成功配置并使用 React Context API 来全局保存和读取数据。
我们提供的解决方案是一个通用的方法,适用于您能想到的所有场景,包括 Apollo。如果您现在查看浏览器,您将能够看到已登录的用户或至少我们在顶部栏中添加的假数据。
然而,我们应该涵盖 Apollo 本身提供的解决方案。
Apollo 消费者
几乎我们之前章节中编写的所有代码都可以保持不变。我们只需要从App类中移除UserProvider组件,因为对于 Apollo 消费者组件来说它不再需要了。
打开context文件夹中的user.js文件,并用以下代码替换其内容:
import React from 'react';
import { ApolloConsumer } from '@apollo/client';
export const UserConsumer = ({ children }) => {
return (
<ApolloConsumer>
{client => {
// Use client.readQuery to get the current logged
// in user.
const user = {
username: "Test User",
avatar: "/uploads/avatar1.png"
};
return React.Children.map(children,
function(child){
return React.cloneElement(child, { user });
});
}}
</ApolloConsumer>
)
}
如您所见,我们从@apollo-client包中导入了ApolloConsumer组件。这个包使我们能够访问我们在第四章“将 Apollo 集成到 React”中设置的 Apollo 客户端。
我们在这里遇到的问题是,我们没有CurrentUser查询,该查询会从 GraphQL 响应登录用户,所以我们无法运行readQuery函数。您通常会针对 Apollo 的内部缓存运行查询,并能够轻松地获取user对象。一旦我们实现了身份验证,我们将解决这个问题。
目前,我们将返回与 React Context API 相同的假对象。Apollo 客户端取代了我们使用 React Context API 的Provider组件。
我希望您能理解这两种解决方案之间的区别。在下一章中,您将看到ApolloConsumer组件的全功能展示,当用户查询建立并且可以通过其缓存的客户端读取时。
记录 React 应用程序
我们在我们的 React 应用程序中投入了大量的工作和代码。说实话,我们可以通过记录代码来改进我们的代码库。我们没有对代码进行注释,我们没有添加 React 组件属性类型定义,并且我们没有自动化的文档工具。当然,我们没有写任何注释,因为您已经从这本书中学到了所有的技术和库,所以不需要注释。然而,请务必始终在本书之外对代码进行注释。
在 JavaScript 生态系统中,存在许多不同的方法和工具来记录您的应用程序。对于这本书,我们将使用一个名为React Styleguidist的工具。它是专门为 React 制作的。您不能用它来记录其他框架或代码。
重要提示
一般而言,这是一个您可以投入数月工作而不会真正结束的领域。如果您正在寻找任何框架或后端和前端的通用方法,我可以推荐 JSDoc,但还有更多。
除了这些,还有许多不同的 React 文档工具。如果您想查看其他工具,请查看此处:blog.bitsrc.io/6-tools-for-documenting-your-react-components-like-a-pro-5027cdfb40c6。
让我们从 React Styleguidist 的配置开始。
设置 React Styleguidist
React Styleguidist 和我们的应用程序依赖于webpack。只需按照以下说明操作,即可获取其工作副本:
-
使用
npm安装 React Styleguidist,如下所示:npm install --save-dev react-styleguidist -
通常,文件夹结构预期为
src/components,但我们有一个client文件夹位于src和components文件夹之间,因此我们必须配置 React Styleguidist 以使其理解我们的文件夹结构。在项目的根目录中创建一个styleguide.config.js文件来配置它,如下所示:
const path = require('path')
module.exports = {
components: 'src/client/components/**/*.js',
require: [
path.join(__dirname, 'assets/css/style.css')
],
webpackConfig: require('./webpack.client.config')
}
我们导出一个包含所有必要信息的对象,用于 React Styleguidist。除了指定 components 路径外,我们还需要我们的主要 CSS 样式文件。你将在本章后面的内容中看到这为什么有用。我们必须定义 webpackConfig 选项,因为我们的 config 文件有一个自定义名称,无法自动找到。
Styleguidist 提供了两种查看文档的方式。一种是在生产模式下静态构建文档,使用以下命令:
npx styleguidist build
这个命令会创建一个 styleguide 文件夹,在该文件夹内包含我们文档的 HTML 文件。当发布应用的新版本时,这是一个极好的方法,这样你可以保存并备份每个版本的文件。
注意
如果你在运行 npx styleguidist 时看到错误,你必须应用一个临时的解决方案。
通过运行 npm install -g yarn 安装 yarn,然后将以下行添加到 package.json 对象的根级别:
"resolutions": {
"react-dev-utils": "12.0.0-next.47"
}
然后,你可以运行 yarn install。这将更新 styleguidist 的一个内部依赖项到一个没有问题的较新版本。
然后,你可以再次运行 npx styleguidist build。只需记住,如果你运行 npm install,它将用旧版本覆盖这个依赖项,你将不得不再次运行 yarn install 来使其工作。
第二种方法,适用于开发情况,允许 Styleguidist 使用 webpack 动态运行并创建文档。以下是执行此操作的命令:
npx styleguidist server
您可以在 http://localhost:6060 下查看结果。文档应该看起来像这样:

图 5.5 – React Styleguidist 文档
在左侧面板中,所有组件都按照我们的文件夹结构顺序列出。这样,你将始终有一个对现有组件的极佳概览。
在主面板中,每个组件都进行了详细说明。你可能已经注意到组件缺少进一步的信息。我们将在下一节中改变这一点。
React PropTypes
React 的一个基本特性是将属性传递给子组件。这些可以是基本字符串、数字,也可以是完整的组件。我们已经在我们的应用中看到了所有这些场景。
对于新加入你的代码库的开发者来说,他们需要阅读所有组件并识别它们可以接受的属性。
React 提供了一种从每个组件内部描述属性的方法。记录你组件的属性使得其他开发者更容易理解你的 React 组件。
我们将通过在Post组件中的示例来查看如何实现这一点。
我们还没有介绍的两个 React 特性如下:
-
如果你的组件有可选参数,最初有默认属性是有意义的。为此,你可以指定
defaultProps作为一个静态属性,就像使用状态初始化器一样。 -
重要的是
propTypes字段,你可以为所有组件填充它们接受的自定义属性。
需要一个新的包来定义属性类型,如下所示:
npm install --save prop-types
这个包包含了我们设置属性定义所需的一切。
现在,打开你的Post组件的index.js文件。我们需要在这个文件的顶部导入新的包,如下所示:
import PropTypes from 'prop-types';
接下来,我们将在export语句之前将新字段添加到我们的组件中,如下所示:
Post.propTypes = {
/** Object containing the complete post. */
post: PropTypes.object.isRequired,
}
上述代码应该有助于每个人更好地理解你的组件。每个开发者都应该知道,这个组件要正常工作需要一个post对象。
PropTypes包提供了我们可以使用的各种类型。你可以使用PropTypes.X访问每个类型。如果是一个必需的属性,你可以像前面的代码一样附加isRequired。
不仅 React 现在会在控制台抛出一个错误,当属性不存在时,React Styleguidist 还能够显示哪些属性是必需的,正如你在下面的截图中所看到的:


图 5.6 – 属性基本文档
然而,什么是post对象?它包含哪些字段?
记录post对象的最佳方式是定义一个帖子应该包含哪些属性,至少对于这个特定的组件来说是这样。替换属性定义,如下所示:
Post.propTypes = {
/** Object containing the complete post. */
post: PropTypes.shape({
id: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
user: PropTypes.shape({
avatar: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
}).isRequired
}).isRequired,
}
在这里,我们使用shape函数。这允许你传递一个包含对象字段的列表。这些字段中的每一个都会从PropTypes包中获取一个类型。
React Styleguidist 的输出现在看起来是这样的:


图 5.7 – 属性详细文档
我们指定的所有字段都分别列出。在撰写这本书的时候,React Styleguidist 还没有提供所有属性的递归视图。正如你所看到的,post对象内部的user对象及其属性没有列出,但它只作为一个第二形状列出。如果你需要这个功能,你当然可以自己实现它,并在官方 GitHub 仓库上发送一个pull请求,或者切换到另一个工具。
重要提示
React 提供了更多属性类型和函数,你可以使用它们来记录所有组件及其属性。要了解更多信息,请访问官方文档reactjs.org/docs/typechecking-with-proptypes.html。
React Styleguidist 的最后一个伟大功能是你可以为每个组件输入示例。你还可以使用 Markdown 添加更多描述。
对于我们的 Post 组件,我们需要在 post 文件夹中 index.js 文件旁边创建一个 index.md 文件。React Styleguidist 建议创建一个 Readme.md 或 Post.md 文件,但那些对我来说不起作用。index.md 文件应该看起来像这样:
Post example:
'''js
const post = {
id: 3,
text: "This is a test post!",
user: {
avatar: "/uploads/avatar1.png",
username: "Test User"
}
};
<Post key={post.id} post={post} />
'''
很遗憾,你将无法直接看到该标记的输出。原因是 Post 组件依赖于 Apollo。如果你只是像 React Styleguidist 那样渲染普通的 Post 组件,Apollo 客户端将不会存在。
为了解决这个问题,我们可以覆盖 React Styleguidist 渲染任何组件的默认方式。按照以下说明操作以使其工作:
-
创建一个新的文件夹,我们可以在这里保存所有特殊的 React Styleguidist 组件,如下所示:
mkdir src/client/styleguide/ -
创建一个名为
Wrapper.js的文件,内容如下:import React from 'react'; import client from '../apollo'; import { ApolloProvider } from '@apollo/client/react'; const Wrapper = ({ children }) => { return ( <ApolloProvider client={client}> {children} </ApolloProvider> ); } export default Wrapper这将是 React Styleguidist 运行的所有组件的标准
Wrapper组件。这样,我们确保我们始终在上下文中拥有 Apollo 客户端。 -
我们最后需要做的是将以下属性添加到
styleguide.config.js文件中:styleguideComponents: { Wrapper: path.join(__dirname, 'src/client/styleguide/Wrapper') },React Styleguidist 将现在使用这个
Wrapper组件。
如果你重新启动 React Styleguidist,它将渲染文档并生成以下输出:

]
图 5.8 – React Styleguidist 示例
现在,你可以看到为什么使用 CSS 样式很有用。不仅 React Styleguidist 可以记录代码,它还可以在文档中执行代码。正如前面的代码所示,在 post 对象内部提供正确的属性使我们能够看到组件应该如何看起来,包括正确的样式。
这个例子展示了我们的 Post 组件是多么的可重用,因为它可以在不运行 Apollo 查询的情况下使用。
基础知识现在应该很清晰了。继续阅读这个主题,因为还有更多东西要学习。
摘要
通过本章,你在编写 React 应用程序方面获得了大量的经验。你已经将多个 React 模式应用于不同的用例,例如通过模式传递子组件和条件渲染。此外,你现在知道如何正确地记录你的代码。
你还学会了如何使用 React Context API,与 Apollo Consumer 功能相比,在我们的应用程序中检索当前登录的用户。
在下一章中,你将学习如何在后端实现身份验证并在前端使用它。
第六章:使用 Apollo 和 React 进行身份验证
在过去的几章中,我们已经走得很远了。现在,我们已经到达了将要为我们的 React 和 GraphQL Web 应用程序实现身份验证的阶段。在本章中,你将学习到构建使用 GraphQL 进行身份验证的应用程序的一些基本概念。
本章涵盖了以下主题:
-
JWT 是什么?
-
Cookie 与 localStorage 的比较
-
在 Node.js 和 Apollo 中实现身份验证
-
用户注册和登录
-
验证 GraphQL 查询和突变
-
从请求上下文中访问用户
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
什么是 JSON Web Tokens?
JSON Web Tokens (JWTs) 仍然是一个相对较新的标准,用于执行身份验证;并不是每个人都了解它们,甚至更少的人使用它们。本节不会提供 JWT 的数学或加密基础理论性的探讨。
例如,在用 PHP 编写的传统 Web 应用程序中,你通常有一个会话 cookie。这个 cookie 识别服务器上的用户会话。会话必须存储在服务器上以检索初始用户。这里的问题是,保存和查询所有用户的会话可能会产生很高的开销。然而,在使用 JWT 时,服务器无需保留任何类型的会话 ID。
通常来说,JWT 包含了识别用户所需的一切。最常见的方法是存储令牌的创建时间、用户名、用户 ID,以及可能的角色,例如管理员或普通用户。出于安全原因,你不应该包含任何个人信息或关键数据。
JWT 存在的原因并不是为了以任何方式加密或保护数据。相反,为了使用服务器等资源进行身份验证,你需要发送一个由你的服务器验证的已签名的 JWT。只有当它是由你的服务器声称为可信的服务创建时,它才能验证 JWT。在大多数情况下,你的服务器将使用其公钥来签名令牌。任何可以读取你与服务器之间通信的人或服务都可以访问令牌,并且可以轻松提取有效载荷。尽管如此,他们无法编辑其内容,因为令牌是用签名签名的。
令牌需要在客户端的浏览器中安全地传输和存储。如果令牌落入错误的手中,那个人可以使用您的身份访问受影响的应用程序,以您的名义发起操作,或读取个人信息。JWT 的撤销也很困难。使用会话 cookie,您可以在服务器上删除会话,用户将不再通过 cookie 进行认证。然而,使用 JWT,我们在服务器上没有任何信息。它只能验证令牌的签名并在您的数据库中找到用户。一种常见的方法是有一个所有不允许的令牌的黑名单。或者,您可以通过指定过期日期来降低 JWT 的有效期。然而,这种解决方案需要用户频繁地重复登录过程,这会使体验变得不那么舒适。
JWT 不需要任何服务器端存储。服务器端会话的妙处在于您可以存储特定应用程序状态,例如记住用户执行的最后操作。没有服务器端存储,您要么需要在 localStorage 中实现这些功能,要么实现一个会话存储,这对于使用 JWT 认证根本不是必需的:
注意
JWT 在开发者社区中是一个重要的话题。有关 JWT 是什么、如何使用以及其技术背景的出色文档有很多。访问以下网页了解更多信息,并查看 JWT 生成演示:jwt.io/。

图 6.1 – JWT 结构
如前图所示,JWT 由三个部分组成:
-
标题: 标题指定了用于生成 JWT 的算法。
-
有效载荷: 有效载荷由所有“会话”数据组成,这些数据被称为声明。前面的只是一个简单的表示,并没有展示 JWT 的全部复杂性。
-
签名: 签名是从标题和有效载荷计算得出的。为了验证 JWT 是否被篡改,签名将与从实际有效载荷和标题中新生成的签名进行比较。
在我们的示例中,我们将使用 JWT,因为它们是一种现代且去中心化的认证方法。尽管如此,您可以在任何时候选择退出此选项,并改用常规会话,这在 Express.js 和 GraphQL 中可以快速实现。
在下一节中,我们将探讨在浏览器内部存储 JWT 的不同方法以及如何在 localStorage 和 cookies 之间传输。
localStorage 与 cookies 的比较
让我们看看另一个关键问题。了解至少认证工作原理及其安全性的基础知识至关重要。您对任何可能导致数据泄露的故障实现负有责任,所以请始终牢记这一点。我们在哪里存储从服务器收到的令牌?
无论你将令牌发送到哪个方向,你都应该始终确保你的通信是安全的。对于像我们这样的 Web 应用程序,请确保所有请求都启用了 HTTPS。一旦用户成功认证,客户端将根据 JWT 认证工作流程接收 JWT。JWT 不绑定到任何特定的存储介质,因此你可以自由选择你喜欢的任何一种。如果我们不在收到令牌时存储它,它将仅在内存中可用。当用户浏览我们的网站时,这是可以的,但当他们刷新页面时,他们需要再次登录,因为我们没有在任何地方存储令牌。
有两种标准选项:将 JWT 存储在localStorage中或存储在 cookie 中。让我们先讨论第一种选项。localStorage是教程中经常建议的选项。这是可以的,假设你正在编写一个单页 Web 应用程序,其中内容根据用户和客户端路由的动作动态更改。我们不遵循任何链接并加载新站点以查看新内容;相反,旧的内容只是被你想要显示的新页面所替换。
将令牌存储在localStorage有以下缺点:
-
localStorage不是在每次请求时都传输。当页面首次加载时,你无法在请求中发送令牌,因此需要认证的资源无法返回给你。一旦你的应用程序加载完成,你必须向服务器发送第二个请求,包括令牌以访问受保护的内容。这种行为的结果是,无法构建服务器端渲染的应用程序。 -
客户端需要实现将令牌附加到发送到服务器的每个请求的机制。
-
由于
localStorage的性质,客户端没有内置的过期日期。如果在某个时刻,令牌达到其过期日期,它仍然存在于客户端的localStorage中。 -
localStorage通过纯 JavaScript 访问,因此容易受到 XSS 攻击。如果有人设法通过未经过滤的输入将自定义 JavaScript 集成到你的代码或网站上,他们可以从localStorage中读取令牌。
然而,使用localStorage有许多优点:
-
由于
localStorage不是在每次请求时自动发送,因此它对任何试图通过随机请求从外部站点执行操作的跨站请求伪造(CSRF)攻击具有安全性。 -
localStorage在 JavaScript 中很容易读取,因为它以键值对的形式存储。 -
它支持更大的数据大小,这对于存储应用程序状态或数据来说非常好。
将如此关键的令牌存储在 Web 存储中的主要问题是您无法保证没有不受欢迎的访问。除非您能确保每个单独的输入都经过清理,并且您不依赖于任何捆绑到您的 JavaScript 代码中的第三方工具,否则始终存在潜在的风险。即使是一个您没有构建的包也可能与创建者共享您的用户的 Web 存储,而您或用户可能从未注意到。此外,当您使用公共内容分发网络(CDN)时,攻击面和您的应用程序的风险都会增加。
现在,让我们来看看 cookie。尽管由于欧盟启动的 cookie 合规性法律而受到负面报道,但它们仍然很棒。抛开 cookie 可以允许公司做的更负面的事情,比如跟踪用户,它们仍然有很多优点。与localStorage相比的一个显著区别是,cookie 会随着每个请求发送,包括您应用程序托管站点的初始请求。
Cookie 具有以下优点:
-
由于每个请求都会发送 cookie,因此服务器端渲染根本不是问题。
-
前端不需要实现任何额外的逻辑来发送 JWT。
-
可以将 cookie 声明为
httpOnly,这意味着 JavaScript 无法访问它们。这可以保护我们的令牌免受 XSS 攻击。 -
Cookie 有一个内置的过期日期,可以设置为在客户端浏览器中使 cookie 失效。
-
可以配置 cookie,使其只能从特定的域或路径中读取。
-
所有浏览器都支持 cookie。
这些优点听起来很好,但让我们考虑一下缺点:
-
Cookie 通常容易受到 CSRF 攻击,在这些攻击中,外部网站向您的 API 发送请求。他们期望您已认证,并希望他们能代表您执行操作。我们无法阻止 cookie 与每个请求一起发送到您的域。常见的预防策略是实施 CSRF 令牌。这个特殊的令牌也由您的服务器传输并保存为 cookie。由于它存储在不同的域下,外部网站无法使用 JavaScript 访问 cookie。您的服务器不会从每个请求中读取 cookie,而只从 HTTP 头中读取。这种行为保证了令牌是由托管在您的应用程序上的 JavaScript 发送的,因为只有这样才能访问令牌。然而,设置用于验证的 XSRF 令牌却需要做很多工作。
-
由于它们以一个大型的逗号分隔的字符串形式存储,访问和解析 cookie 并不直观。
-
它们只能存储少量的数据。
因此,我们可以看到这两种方法都有其优点和缺点。
最常见的方法是使用 localStorage,因为这是最简单的方法。在这本书中,我们将首先使用 localStorage,但稍后将在使用服务器端渲染时切换到 cookies,以便让你体验两种方法。你可能根本不需要服务器端渲染。如果是这种情况,你可以跳过这部分,以及 cookie 实现。
在下一节中,我们将实现使用 GraphQL 的身份验证。
GraphQL 身份验证
现在身份验证的基本知识应该已经清楚。现在,我们的任务是实现一种安全的方法让用户进行身份验证。如果我们查看当前的数据库,我们会看到我们缺少所需的字段。为此,请按照以下步骤操作:
-
让我们准备并添加一个
password字段和一个email字段。正如我们在 第三章 中所学的,连接到数据库,我们必须创建一个迁移来编辑我们的用户表。如果你忘记了这些命令,可以在该章节中查找:sequelize migration:create --migrations-path src/server/migrations --name add-email-password-to-post上述命令为我们生成了新文件。
-
替换其内容,然后尝试自己编写迁移,或者你可以检查以下代码片段中的正确命令:
'use strict'; module.exports = { up: (queryInterface, Sequelize) => { return Promise.all([ queryInterface.addColumn('Users', 'email', { type: Sequelize.STRING, unique : true, } ), queryInterface.addColumn('Users', 'password', { type: Sequelize.STRING, } ), ]); }, down: (queryInterface, Sequelize) => { return Promise.all([ queryInterface.removeColumn('Users', 'email'), queryInterface.removeColumn('Users', 'password'), ]); } }; -
所有字段都是简单的字符串。按照 第三章 中所述,连接到数据库 执行迁移。电子邮件地址必须是唯一的。现在我们需要更新我们为用户的老种子文件,以表示我们刚刚添加的新字段。将以下字段添加到第一个用户:
password: '$2a$10$bE3ovf9/Tiy/d68bwNUQ0.zCjwtNFq9ukg9h4rhKiHCb6x5ncKife', email: 'test1@example.com',对所有用户都这样做,并更改每个用户的电子邮件地址。否则,它将不起作用。密码是哈希格式,代表明文密码 123456789。由于我们在单独的迁移中添加了新字段,我们必须将这些字段添加到模型中。
-
打开并添加以下新行作为字段到
model文件夹中的user.js文件:email: DataTypes.STRING, password: DataTypes.STRING, -
现在清空数据库,运行所有迁移,并再次执行种子文件。
我们首先要做的是让登录过程运行起来。目前,我们只是在模拟作为数据库中的第一个用户登录。
Apollo 登录突变
在本节中,我们将编辑我们的 GraphQL 模式并实现相应的解析函数。按照以下步骤操作:
-
让我们从模式开始,向我们的
schema.js文件中的RootMutation对象添加一个新突变。login ( email: String! password: String! ): Auth上述模式为我们提供了一个接受电子邮件地址和密码的登录突变。两者都是识别和验证用户的必要条件。然后,我们需要向客户端响应一些内容。目前,
Auth类型返回一个令牌,在我们的案例中是一个 JWT。你可能想根据你的需求添加不同的选项:type Auth { token: String } -
模式现在已准备就绪。前往
resolvers文件,并在突变对象中添加登录函数。在我们这样做之前,安装并导入两个新包:jsonwebtoken package handles everything that's required to sign, verify, and decode JWTs.The important part is that all the passwords for our users are not saved as plain text but are first encrypted using hashing, including a random salt. This generated hash cannot be decoded or decrypted as a plain password, but the package can verify if the password that was sent with the login attempt matches the password hash that was saved on the user. -
在
resolvers文件顶部导入这些包:import bcrypt from 'bcrypt'; import JWT from 'jsonwebtoken'; -
login函数接收email和password作为参数。它应该如下所示:login(root, { email, password }, context) { return User.findAll({ where: { email }, raw: true }).then(async (users) => { if(users.length = 1) { const user = users[0]; const passwordValid = await bcrypt.compare(password, user.password); if (!passwordValid) { throw new Error('Password does not match'); } const token = JWT.sign({ email, id: user.id }, JWT_SECRET, { expiresIn: '1d' }); return { token }; } else { throw new Error("User not found"); } }); },上述代码执行以下步骤:
-
我们查询所有邮箱地址匹配的用户。
-
如果找到用户,我们可以继续。由于 MySQL 唯一约束禁止这种情况,因此不可能有多个用户具有相同的地址。
-
接下来,我们使用用户的密码,并使用之前解释的
bcrypt包将其与提交的密码进行比较。 -
如果密码正确,我们使用
jwt.sign函数为jwt变量生成 JWT 令牌。它接受三个参数:负载,即用户 ID 和他们的电子邮件地址;我们用于签名 JWT 的密钥;以及 JWT 将要过期的时长。 -
最后,我们返回一个包含我们的 JWT 的对象。
注意
可能需要重新思考的是错误消息中的详细程度。例如,我们可能不想区分密码错误和不存在用户的情况。这允许可能的攻击者或数据收集者知道哪个电子邮件地址正在使用。
login函数尚未工作,因为我们缺少JWT_SECRET,这是用于签名 JWT 的。在生产中,我们使用环境变量将 JWT 密钥传递到我们的后端代码中,以便我们也可以在开发中使用这种方法。 -
-
对于 Linux 或 Mac,请在终端中直接输入以下命令:
export JWT_SECRET= awv4BcIzsRysXkhoSAb8t8lNENgXSqBruVlLwd45kGdYje JHLap9LUJ1t9DTdw36DvLcWs3qEkPyCY6vOyNljlh2Er952h2gDzYwG8 2rs1qfTzdVIg89KTaQ4SWI1YGY -
export函数为您设置JWT_SECRET环境变量。用随机生成的 JWT 替换提供的 JWT。您可以通过将字符数设置为 128 并排除任何特殊字符来使用任何密码生成器。设置环境变量允许我们在应用程序中读取密钥。您必须在进入生产环境时替换它。 -
在文件顶部插入以下代码:
const { JWT_SECRET } = process.env;此代码从全局 Node.js
process对象中读取环境变量。一旦发布应用程序,请务必替换 JWT,并确保始终安全地存储密钥。在让服务器重新加载后,我们可以发送第一个登录请求。我们将在后面的 React 中学习如何做到这一点,但以下代码展示了使用 Postman 的一个示例:{ "operationName":null, "query": "mutation login($email : String!, $password : String!) { login(email: $email, password : $password) { token }}", "variables":{ "email": "test1@example.com", "password": "123456789" } }这个请求应该返回一个令牌:
{ "data": { "login": { "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e yJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY29tIiwiaWQiOjE sImlhdCI6MTUzNzIwNjI0MywiZXhwIjoxNTM3MjkyNjQzf Q.HV4dPIBzvU1yn6REMv42N0DS0ZdgebFDXUj0MPHvlY" } } }如您所见,我们已经生成并返回了一个签名 JWT。我们可以在每个请求的 HTTP 认证头中继续发送此令牌。然后,我们可以为迄今为止实现的全部 GraphQL 查询或突变启动认证。
让我们继续学习如何设置 React 以与后端上的认证一起工作。
React 登录表单
我们需要处理我们应用程序的不同认证状态:
-
第一种情况是用户未登录,无法查看任何帖子或聊天。在这种情况下,我们需要显示登录表单,以便用户可以验证自己。
-
第二种情况是,通过登录表单发送电子邮件和密码。需要解释响应,如果结果是正确的,我们现在需要将 JWT 保存到浏览器的
localStorage中。 -
当更改
localStorage时,我们还需要重新渲染我们的 React 应用程序以显示登录状态。 -
此外,用户应该能够再次注销。
-
我们还必须能够处理 JWT 过期且用户无法访问任何功能的情况。
登录表单将如下所示:
![Figure 6.2 – 登录表单
![Figure 6.02_B17337.jpg]
Figure 6.2 – 登录表单
要开始使用登录表单,请按照以下步骤操作:
-
在
apollo文件夹内设置一个单独的登录突变文件。我们可能只需要在代码中的一个地方使用这个组件,但将 GraphQL 请求保存在单独的文件中是一个好主意。 -
构建登录表单组件,该组件使用登录突变发送表单数据。
-
创建
CurrentUser查询以检索已登录的用户对象。 -
如果用户未认证或登录到真实应用程序(如新闻源),则条件渲染登录表单。
-
我们将首先在客户端组件的
mutations文件夹内创建一个新的login.js文件:import { gql, useMutation } from '@apollo/client'; export const LOGIN = gql' mutation login($email : String!, $password : String!) { login(email : $email, password : $password) { token } } '; export const useLoginMutation = () => useMutation(LOGIN);与之前的突变一样,我们解析查询字符串并从
useMutation钩子中导出login函数。 -
现在,我们必须实现使用此突变的实际登录表单。为此,我们将在
components文件夹内直接创建一个loginregister.js文件。正如你所预期的,我们在一个组件中处理用户的登录和注册。首先导入依赖项:import React, { useState } from 'react'; import { useLoginMutation } from '../apollo/mutations/login'; import Loading from './loading'; import Error from './error'; -
LoginForm组件将存储表单状态,如果出现错误则显示错误消息,显示加载状态,并发送包含表单数据的登录突变。在import语句下方添加以下代码:const LoginForm = ({ changeLoginState }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [login, { loading, error }] = useLoginMutation(); const onSubmit = (event) => { event.preventDefault(); login({ update(cache, { data: { login } }) { if(login.token) { localStorage.setItem('jwt', login.token); changeLoginState(true); } }, variables: { email, password } }); } return ( <div className="login"> {!loading && ( <form onSubmit={onSubmit}> <label>Email</label> <input type="text" onChange={(event) => setEmail(event.target.value)} /> <label>Password</label> <input type="password" onChange={(event) => setPassword(event.target.value)} /> <input type="submit" value="Login" /> </form> )} {loading && (<Loading />)} {error && ( <Error><p>There was an error logging in!</p> </Error> )} </div> ) }整个 React 组件相当简单。我们只有一个表单和两个输入,并将它们的值存储在两个状态变量中。当表单提交时,会调用
onSubmit函数,这将触发登录突变。突变的update函数将与其他突变有所不同。我们不在 Apollo 缓存中写入返回值;相反,我们在localStorage中存储 JWT。语法相当简单。你可以直接使用localStorage.get和localStorage.set与 Web 存储进行交互。在将 JWT 保存到
localStorage之后,我们调用一个changeLoginState函数,我们将在下一步实现它。这个函数的目的是有一个全局开关,用于将用户从登录状态切换到注销状态,或反之。 -
现在,我们需要导出一个将被我们的应用程序使用的组件。最简单的方法是设置一个包装组件,该组件为我们处理登录和注册情况。
为包装组件插入以下代码:
const LoginRegisterForm = ({ changeLoginState }) => { return ( <div className="authModal"> <div> <LoginForm changeLoginState={changeLoginState} /> </div> </div> ) } export default LoginRegisterForm这个组件只是渲染登录表单并传递
changeLoginState函数。所有用于验证用户的基本功能现在都已准备就绪,但尚未导入或显示在任何地方。打开
App.js文件。在那里,我们将直接显示动态内容、聊天和顶部栏。如果用户未登录,不应允许他们看到一切。继续阅读以更改此设置。 -
导入我们刚刚创建的新表单和从 React 导入的
useEffect钩子:import LoginRegisterForm from './components/loginregister'; -
现在,我们必须存储用户是否已登录,以及在我们应用程序的第一次渲染中,根据
localStorage检查登录状态。将以下代码添加到App组件中:const [loggedIn, setLoggedIn] = useState(!!localStorage.getItem('jwt'));当加载我们的页面时,我们有一个
loggedIn状态变量来存储当前的登录状态。默认值是如果存在令牌则为true,如果不存在则为false。 -
然后,在
return语句中,我们可以使用条件渲染来显示登录表单,当loggedIn状态变量设置为false时,这意味着我们的localStorage中没有 JWT:{loggedIn && ( <div> <Bar changeLoginState={setLoggedIn} /> <Feed /> <Chats /> </div> )} {!loggedIn && <LoginRegisterForm changeLoginState={setLoggedIn} />}如你所见,我们将
setLoggedIn函数传递给登录表单,这使得它能够触发登录状态,以便 React 可以重新渲染并显示登录区域。我们称这个属性为changeLoginState,并在登录突变的update方法中登录表单内部使用它。 -
从官方 GitHub 仓库添加 CSS:
github.com/PacktPublishing/Full-Stack-Web-Development-with-GraphQL-and-React-Second-Edition
一旦我们登录,我们的应用程序将展示常见的帖子动态内容,就像之前一样。认证流程现在正在工作,但还有一个未完成的任务。在下一节中,我们将允许新用户在 Graphbook 上注册。
Apollo 注册突变
你现在应该熟悉创建新的突变。要这样做,请遵循以下步骤:
-
首先,编辑模式以接受新的突变:
signup ( username: String! email: String! password: String! ): Auth我们只需要
username、email和password属性,这些在前面代码中已提及,以接受新用户。如果你的应用程序需要性别或其他信息,你可以在这里添加。当我们尝试注册时,我们需要确保电子邮件地址和用户名尚未被占用。 -
将以下代码复制以实现为新用户注册的解析器:
signup(root, { email, password, username }, context) { return User.findAll({ where: { [Op.or]: [{email}, {username}] }, raw: true, }).then(async (users) => { if(users.length) { throw new Error('User already exists'); } else { return bcrypt.hash(password, 10).then((hash) => { return User.create({ email, password: hash, username, activated: 1, }).then((newUser) => { const token = JWT.sign({ email, id: newUser.id }, JWT_SECRET, { expiresIn: '1d' }); return { token }; }); }); } }); },让我们一步一步地通过这段代码:
-
如我们之前提到的,首先,我们必须检查是否存在具有相同电子邮件或用户名的用户。如果是这样,我们抛出一个错误。我们使用 Sequelize 的
Op.or运算符来实现 MySQL 的 OR 条件。 -
如果用户不存在,我们可以使用
bcrypt对密码进行散列。出于安全原因,您不能保存明文密码。当运行bcrypt.hash函数时,会使用随机盐来确保没有人能够访问原始密码。这个命令需要相当多的计算时间,所以bcrypt.hash函数是异步的,我们必须在继续之前解决这个承诺。 -
加密的密码,包括用户发送的其他数据,随后被插入到我们的数据库中作为新用户。
-
在创建用户后,我们生成一个 JWT 并将其返回给客户端。JWT 允许我们在用户注册后直接登录。如果您不希望这种行为,您只需返回一条消息来指示用户已成功注册。
-
现在,您可以在使用 npm run server 启动后端的同时,再次使用 Postman 测试 signup 突变。这样,我们就完成了后端实现。那么,让我们开始前端的工作。
React 注册表单
注册表单没有什么特别之处。我们将遵循与登录表单相同的步骤:
-
复制
LoginMutation组件,将顶部的请求替换为signup突变,并将signup方法传递给底层的子组件。 -
在顶部,导入所有依赖项,然后解析新的查询:
import { gql, useMutation } from '@apollo/client'; export const SIGNUP = gql' mutation signup($email : String!, $password : String!, $username : String!) { signup(email : $email, password : $password, username : $username) { token } } '; export const useSignupMutation = () => useMutation(SIGNUP);如您所见,这里的
username字段是新的,我们将其与每个signup请求一起发送。逻辑本身并没有改变,所以我们仍然在请求成功后从signup字段中提取 JWT 来登录用户。
很好看到 login 和 signup 突变相当相似。最大的变化是我们有条件地渲染登录表单或注册表单。按照以下步骤操作:
-
将新的突变导入到
loginregister.js文件中:import { useSignupMutation } from '../apollo/mutations/signup'; -
然后,用以下新的组件替换完整的
LoginRegisterForm组件:const LoginRegisterForm = ({ changeLoginState }) => { const [showLogin, setShowLogin] = useState(true); return ( <div className="authModal"> {showLogin && ( <div> <LoginForm changeLoginState={changeLoginState} /> <a onClick={() => setShowLogin(false)}> Want to sign up? Click here</a> </div> )} {!showLogin && ( <div> <RegisterForm changeLoginState={changeLoginState} /> <a onClick={() => setShowLogin(true)}> Want to login? Click here</a> </div> )} </div> ) }您应该注意到我们在组件状态中存储了一个
showLogin变量。这个变量决定是否显示登录或注册组件,这处理了实际的业务逻辑。 -
然后,在导出语句之前添加一个用于注册表单的单独组件:
const RegisterForm = ({ changeLoginState }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [username, setUsername] = useState(''); const [signup, { loading, error }] = useSignupMutation(); const onSubmit = (event) => { event.preventDefault(); signup({ update(cache, { data: { login } }) { if(login.token) { localStorage.setItem('jwt', login.token); changeLoginState(true); } }, variables: { email, password, username } }); } return ( <div className="login"> {!loading && ( <form onSubmit={onSubmit}> <label>Email</label> <input type="text" onChange={(event) => setEmail(event.target.value)} /> <label>Username</label> <input type="text" onChange={(event) => setUsername(event.target.value)} /> <label>Password</label> <input type="password" onChange={(event) => setPassword(event.target.value)} /> <input type="submit" value="Sign up" /> </form> )} {loading && (<Loading />)} {error && ( <Error><p>There was an error logging in!</p> </Error> )} </div> ) }在前面的代码中,我添加了
username字段,这个字段必须提供给突变。现在一切设置完毕,可以邀请新用户加入我们的社交网络,并且他们可以随时登录。
在下一节中,我们将学习如何在我们现有的 GraphQL 请求中使用身份验证。
验证 GraphQL 请求
问题是我们目前并没有在所有地方使用身份验证。我们正在验证用户是否是他们所说的那个人,但在收到聊天或消息请求时并没有重新检查这一点。为了完成这个任务,我们必须在每个 Apollo 请求中发送我们专门为此情况生成的 JWT 令牌。在后端,我们必须指定哪些请求需要身份验证,从 HTTP 授权头中读取 JWT 并验证它。按照以下步骤操作:
-
打开
apollo文件夹中的index.js文件以获取客户端代码。我们的ApolloClient当前配置如第四章中所述,将 Apollo 集成到 React 中。在我们发送任何请求之前,我们必须从localStorage中读取 JWT 并将其添加为 HTTP 授权头。在link属性中,我们已指定了ApolloClient处理过程的链接。在我们配置 HTTP 链接之前,我们必须插入一个第三个预处理钩子,如下所示:const AuthLink = (operation, next) => { const token = localStorage.getItem('jwt'); if(token) { operation.setContext(context => ({ ...context, headers: { ...context.headers, Authorization: 'Bearer ${token}', }, })); } return next(operation); };这里,我们称新的链接为
AuthLink,因为它允许我们在服务器上对客户端进行身份验证。您可以将AuthLink方法复制到需要自定义 Apollo 请求头的其他情况中。在这里,我们只是从localStorage读取 JWT,如果找到,我们使用扩展运算符构建头,并将我们的令牌添加到Authorization字段作为 Bearer Token。这就是客户端需要完成的所有事情。 -
为了澄清问题,请查看以下
link属性以了解如何使用这个新的预处理器。不需要初始化;它只是一个在每次请求时被调用的函数。将link配置复制到我们的 Apollo 客户端设置中:link: from([ onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { graphQLErrors.map(({ message, locations, path }) => console.log('[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}')); if (networkError) { console.log('[Network error]: ${networkError}'); } } }), AuthLink, new HttpLink({ uri: 'http://localhost:8000/graphql', credentials: 'same-origin', }), ]), -
让我们安装一个我们需要的依赖项:
npm install --save @graphql-tools/utils -
对于我们的后端,我们需要一个相当复杂的解决方案。在 GraphQL 的
services文件夹中创建一个名为auth.js的新文件。我们希望能够在我们的模式中用所谓的指令标记特定的 GraphQL 请求。如果我们将此指令添加到我们的 GraphQL 模式中,我们可以在标记的 GraphQL 操作被请求时执行一个函数。在这个函数中,我们可以验证用户是否已登录。查看以下函数并将其保存到auth.js文件中:import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'; function authDirective(directiveName) { const typeDirectiveArgumentMaps = {}; return { authDirectiveTypeDefs: 'directive @${directiveName} on QUERY | FIELD_DEFINITION | FIELD', authDirectiveTransformer: (schema) => mapSchema(schema, { [MapperKind.TYPE]: (type) => { const authDirective = getDirective(schema, type, directiveName)?.[0]; if (authDirective) { typeDirectiveArgumentMaps[type.name] = authDirective; } return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0] ?? typeDirectiveArgumentMaps[typeName]; if (authDirective) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = function (source, args, context, info) { if (context.user) { return resolve(source, args, context, info); } throw new Error("You need to be logged in."); } return fieldConfig; } } }), }; } export default authDirective;从顶部开始,我们从
@graphql/utils包中导入三样东西:-
mapSchema函数接受两个参数。第一个是实际的 GraphQL 模式,然后是一个可以转换模式的函数对象。 -
getDirective函数将读取模式并尝试获取指定的directiveName。基于此,我们可以做我们想要做的任何事情。 -
MapperKind只是一组我们可以使用的类型。我们正在使用它来只为特定类型运行函数。
此函数或指令将读取用户从上下文,并将其传递到我们的解析器中,其中指令在我们的 GraphQL 模式中指定。
-
-
我们必须在
graphql的index.js文件中加载新的authDirective函数,该函数设置了整个 Apollo Server:import authDirective from './auth'; -
在我们创建可执行模式之前,我们必须从
authDirective函数中提取新的模式转换器。在创建可执行模式后,我们必须将其传递给转换器,以便authDirective开始工作。用以下代码替换当前的方案创建:const { authDirectiveTypeDefs, authDirectiveTransformer } = authDirective('auth'); let executableSchema = makeExecutableSchema({ typeDefs: [authDirectiveTypeDefs, Schema], resolvers: Resolvers.call(utils), }); executableSchema = authDirectiveTransformer(executableSchema); -
为了验证我们刚刚所做的工作,请转到 GraphQL 模式并编辑
postsFeedRootQuery,在行尾添加@auth,如下所示:postsFeed(page: Int, limit: Int): PostFeed @auth -
由于我们正在使用一个新的指令,我们还需要在我们的 GraphQL 模式中定义它,以便我们的服务器了解它。将以下代码直接复制到模式的最顶部:
directive @auth on QUERY | FIELD_DEFINITION | FIELD这段简短的内容告诉 Apollo Server,
@auth指令可以与查询、字段和字段定义一起使用,这样我们就可以在所有地方使用它。
如果您重新加载页面并通过 React Developer Tools 手动将 loggedIn 状态变量设置为 true,您将看到以下错误消息:
![Figure 6.3 – GraphQL login error]
![img/Figure_6.03_B17337.jpg]
图 6.3 – GraphQL 登录错误
由于我们之前实现了错误组件,现在如果用户未登录,我们正在正确地接收到 postsFeed 查询的无权限错误。我们如何使用 JWT 来识别用户并将其添加到请求上下文中?
注意
模式指令是一个复杂的话题,因为关于 Apollo 和 GraphQL 有许多重要的事情需要记住。我建议您在官方 Apollo 文档中详细了解指令:www.graphql-tools.com/docs/introduction。
在 第二章 使用 Express.js 设置 GraphQL 中,我们通过提供可执行模式和上下文来设置 Apollo Server,直到现在上下文一直是请求对象。我们必须检查 JWT 是否在请求中。如果是这种情况,我们需要验证它并查询用户以查看令牌是否有效。让我们先验证授权头。在这样做之前,将新依赖项导入到 GraphQL 的 index.js 文件中:
import JWT from 'jsonwebtoken';
const { JWT_SECRET } = process.env;
ApolloServer 初始化的 context 字段必须如下所示:
context: async ({ req }) => {
const authorization = req.headers.authorization;
if(typeof authorization !== typeof undefined) {
var search = "Bearer";
var regEx = new RegExp(search, "ig");
const token = authorization.replace(regEx,
'').trim();
return JWT.verify(token, JWT_SECRET, function(err,
result) {
if(err) {
return req;
} else {
return utils.db.models.User.findByPk(
result.id).then((user) => {
return Object.assign({}, req, { user });
});
}
});
} else {
return req;
}
},
在这里,我们将 ApolloServer 类的 context 属性扩展为一个功能齐全的函数。我们从请求的头部读取 auth 令牌。如果 auth 令牌存在,我们需要移除携带者字符串,因为它不是我们后端创建的原始令牌的一部分。携带者令牌是 JWT 身份验证的最佳方法。
注意
可用的其他身份验证方法还有基本身份验证等,但携带者方法是最佳选择。您可以在 IETF 的 RFC6750 中找到详细说明:tools.ietf.org/html/rfc6750。
之后,我们必须使用 JWT.verify 函数来检查令牌是否与从环境变量中生成的密钥创建的签名匹配。下一步是验证成功后检索用户。将 verify 回调的内容替换为以下代码:
if(err) {
return req;
} else {
return utils.db.models.User.findByPk(result.id).then((
user) => {
return Object.assign({}, req, { user });
});
}
如果前一段代码中的err对象已被填充,我们只能返回普通的请求对象,当它到达auth指令时将触发错误,因为没有附加用户。如果没有错误,我们可以使用我们已经在 Apollo Server 设置中传递的utils对象来访问数据库。如果你需要提醒,请查看第二章,使用 Express.js 设置 GraphQL。在查询用户后,我们必须将其添加到请求对象中,并将合并后的用户和请求对象作为上下文返回。这导致我们的授权指令返回成功响应。
现在,让我们测试这种行为。使用npm run client启动前端,使用npm run server启动后端。别忘了,现在所有 Postman 请求都必须包含有效的 JWT,如果 GraphQL 查询中使用了auth指令。你可以运行登录突变,并将其复制到授权头中运行任何查询。我们现在能够将任何查询或突变标记为授权标志,并因此要求用户登录。
从解析函数中访问用户上下文
目前,我们 GraphQL 服务器的所有 API 函数都允许我们通过从数据库中选择可用的第一个来模拟用户。正如我们刚刚引入了完整的认证,我们现在可以从请求上下文中访问用户。本节将快速解释如何为聊天和消息实体执行此操作。我们还将实现一个名为currentUser的新查询,在我们的客户端中检索登录用户。
聊天和消息
首先,你必须将@auth指令添加到 GraphQL 的RootQuery中的聊天,以确保用户需要登录才能访问任何聊天或消息。
看一下聊天解析函数。目前,我们使用findAll方法获取所有用户,取第一个,并查询该用户的所有聊天。用以下新的解析函数替换此代码:
chats(root, args, context) {
return Chat.findAll({
include: [{
model: User,
required: true,
through: { where: { userId: context.user.id } },
},
{
model: Message,
}],
});
},
在这里,我们不检索用户;而是直接从上下文中插入用户 ID,如前述代码所示。这就是我们必须要做的:所有属于登录用户的聊天和消息都直接从聊天表中查询。
我们需要复制这部分代码以用于聊天、消息以及其他当前我们拥有的所有查询和突变。
CurrentUser GraphQL 查询
JWTs 允许我们查询当前登录的用户。然后,我们可以在顶部栏中显示正确的认证用户。为了请求登录用户,我们在后端需要一个名为currentUser的新查询。在模式中,你只需将以下行添加到RootQuery查询中:
currentUser: User @auth
就像postsFeed和chats查询一样,我们还需要@auth指令来从请求上下文中提取用户。
类似地,在解析函数中,你只需要插入以下三行:
currentUser(root, args, context) {
return context.user;
},
我们立即从上下文中返回用户,因为它已经是一个包含所有适当数据(由 Sequelize 返回)的用户模型实例。在客户端,我们在单独的组件和文件中创建此查询。请注意,你不需要将结果传递给所有子组件,因为这是由ApolloConsumer后来自动完成的。你可以通过查看之前的查询组件示例来了解这一点。只需在queries文件夹中创建一个名为currentUserQuery.js的文件,并包含以下内容:
import { gql, useQuery } from '@apollo/client';
export const GET_CURRENT_USER = gql'
query currentUser {
currentUser {
id
username
avatar
}
}
';
export const useCurrentUserQuery = (options) => useQuery(GET_CURRENT_USER, options);
现在,你可以在App.js文件中导入新的查询,并将以下行添加到App组件中:
const { data, error, loading, refetch } = useCurrentUserQuery();
if(loading) {
return <Loading />;
}
在这里,我们执行了useCurrentUserQuery钩子以确保查询在全局范围内对所有组件执行。此外,我们显示一个加载指示器,直到请求完成,以确保在我们做其他任何事情之前用户已经加载。
每当loggedIn状态变量为true时,我们渲染组件。为了获取响应,我们必须在上一章中实现的bar组件中使用ApolloConsumer。我们在App.js文件中运行currentUser查询,以确保所有子组件可以在渲染之前依赖 Apollo 缓存来访问用户。
而不是在ApolloConsumer内部使用硬编码的假用户,我们可以使用client.readQuery函数从ApolloClient缓存中提取数据,并将其提供给底层的子组件。用以下代码替换当前的消费者:
import React from 'react';
import { ApolloConsumer } from '@apollo/client';
import { GET_CURRENT_USER } from '../../apollo/queries/currentUserQuery';
export const UserConsumer = ({ children }) => {
return (
<ApolloConsumer>
{client => {
const result = client.readQuery({ query:
GET_CURRENT_USER });
return React.Children.map(children,
function(child){
return React.cloneElement(child, { user:
result?.currentUser ? result.currentUser : null
});
});
}}
</ApolloConsumer>
)
}
在这里,我们将从client.readQuery方法中提取的currentUser结果传递给当前组件的所有包装子组件。
从现在开始显示的聊天以及顶部栏中的用户,不再是伪造的;相反,它们被与已登录用户相关的数据填充。
创建新帖子或消息的突变仍然使用静态用户 ID。我们可以通过使用context.user对象中的用户 ID,以与我们在本节之前相同的方式切换到真正的已登录用户。你现在应该能够自己做到这一点。
使用 React 注销
为了完成闭环,我们仍然需要实现注销功能。当用户可以注销时,有两种情况:
-
用户想要注销并点击注销按钮。
-
根据指定的 1 天后 JWT 已过期;用户不再认证,我们必须将状态设置为注销。
按照以下步骤完成此操作:
-
我们将首先在我们的应用程序前端顶部栏添加一个新的注销按钮。为此,在
bar文件夹内创建一个新的logout.js组件。它应该看起来如下:import React from 'react'; import { withApollo } from '@apollo/client/react/hoc'; const Logout = ({ changeLoginState, client }) => { const logout = () => { localStorage.removeItem('jwt'); changeLoginState(false); client.stop(); client.resetStore(); } return ( <button className="logout" onClick={logout}>Logout </button> ); } export default withApollo(Logout);如您所见,当点击登出按钮时,它将触发组件的登出方法。在
logout方法内部,我们从localStorage中删除 JWT 并执行我们从父组件接收到的changeLoginState函数。请注意,我们没有向我们的服务器发送请求来登出;相反,我们从客户端删除了令牌。这是因为我们没有使用黑白名单来禁止或允许某些 JWT 在我们的服务器上进行认证。最简单的方法是在客户端删除令牌,这样服务器和客户端都没有它。我们还重置了客户端缓存。当用户登出时,我们必须删除所有数据。否则,同一浏览器上的其他用户将能够提取所有数据,这是我们必须防止的。为了访问底层的 Apollo Client,我们必须导入包裹在其中的
withApolloLogout组件。在登出时,我们必须执行client.stop和client.resetStore函数,以便删除所有数据。 -
要使用我们新的
Logout组件,打开bar文件夹中的index.js文件,并在顶部导入它。我们可以在顶部的div顶部栏中渲染它,位于其他内部div标签下方:<div className="buttons"> <Logout changeLoginState={changeLoginState}/> </div>在这里,我们将
changeLoginState函数传递给Logout组件。 -
从
Bar组件的 props 中提取changeLoginState函数,如下所示:const Bar = ({ changeLoginState }) => { -
在
App.js文件中,你必须实现一个额外的函数来正确处理当前用户查询。如果我们未登录然后登录,我们需要获取当前用户。如果我们登出,我们需要设置或能够轻松地再次获取当前用户查询。添加以下函数:const handleLogin = (status) => { refetch().then(() => { setLoggedIn(status); }).catch(() => { setLoggedIn(status); }); } -
将此函数不仅传递给
LoginRegisterForm,还传递给Bar组件,如下所示:<Bar changeLoginState={handleLogin} /> -
如果你从官方 GitHub 仓库复制完整的 CSS,当你登录时,你应该在屏幕右上角看到一个新按钮。点击它将你登出,并要求你再次登录,因为 JWT 已被删除。
-
我们实现登出功能的另一种情况是我们使用的 JWT 过期。在这种情况下,我们会自动登出用户,并要求他们再次登录。转到
App组件,并添加以下行:useEffect(() => { const unsubscribe = client.onClearStore( () => { if(loggedIn){ setLoggedIn(false) } } ); return () => { unsubscribe(); } }, []);在这里,我们使用的是
client.onClearStore事件,该事件通过client.onClearStore函数在客户端存储被清除时捕获。 -
要使前面的代码正常工作,我们必须在我们的
App组件中访问 Apollo Client。最简单的方法是在App.js文件中使用withApolloHoC。只需从@apollo/client包中导入它:import { withApollo } from '@apollo/client/react/hoc'; -
然后,通过高阶组件(HoC)导出
App组件——不是直接导出,而是通过 HoC——并提取client属性。以下代码必须直接位于App组件下方:export default withApollo(App);现在,组件可以通过其属性访问客户端。每当客户端恢复被重置时,就会抛出
clearStore事件,正如其名称所暗示的。你很快就会看到为什么我们需要这个。在 React 中监听事件时,我们必须在组件卸载时停止监听。我们在前面的代码中的useEffectHook 中处理这个问题。现在,我们必须重置客户端存储以启动注销状态。当事件被捕获时,我们会自动执行changeLoginState函数。因此,我们可以移除最初传递给注销按钮的changeLoginState部分,因为不再需要它,但这里我们并不想这样做。 -
从
App组件的 props 中提取客户端,如下所示:const App = ({ client }) => { -
前往
apollo文件夹中的index.js文件。在那里,我们已经捕获并遍历了从我们的 GraphQL API 返回的所有错误。我们现在必须遍历所有错误,但检查每个错误是否包含UNAUTHENTICATED错误。然后,我们必须执行client.clearStore函数。将以下代码插入到 Apollo 客户端设置中:onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) { graphQLErrors.map(({ message, locations, path, extensions }) => { if(extensions.code === 'UNAUTHENTICATED') { localStorage.removeItem('jwt'); client.clearStore() } console.log('[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}'); }); if (networkError) { console.log('[Network error]: ${networkError}'); } } }),如你所见,我们访问了错误的
extensions属性。extensions.code字段持有返回的具体错误类型。如果我们没有登录,我们会移除 JWT 然后重置存储。通过这样做,我们在App组件中触发事件,将用户送回登录表单。
进一步的扩展将是提供一个刷新令牌 API 函数。这个功能可以在我们每次成功使用 API 时运行。这个问题是用户将永远保持登录状态,只要他们使用应用程序。通常这并不是问题,但如果其他人正在访问同一台计算机,他们将作为原始用户进行认证。有不同方式实现这些功能以使用户体验更舒适,但我并不是很喜欢这些功能,出于安全原因。
摘要
到目前为止,我们应用程序的主要问题之一是我们没有进行任何认证。现在,每当用户访问我们的应用程序时,我们都可以知道谁登录了。这允许我们保护 GraphQL API,并以正确用户的身份插入新的帖子或消息。在本章中,我们讨论了 JWT、localStorage和 cookie 的基本方面。我们还探讨了散列密码验证和签名令牌的工作原理。然后,我们介绍了如何在 React 中实现 JWT 以及如何触发登录和注销的正确事件。
在下一章中,我们将使用一个可重复使用的组件实现图像上传,该组件允许用户上传新的头像图像。
第七章:处理图片上传
所有社交网络都有一个共同点:每个都允许其用户上传自定义和个人图片、视频或任何其他类型的文档。这个功能可以在聊天、帖子、群组或个人资料中实现。为了提供相同的功能,我们将在 Graphbook 中实现图片上传功能。
本章将涵盖以下主题:
-
设置亚马逊网络服务
-
配置 AWS S3 存储桶
-
在服务器上接受文件上传
-
通过 Apollo 使用 React 上传图片
-
裁剪图片
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
设置亚马逊网络服务
首先,我必须提到,亚马逊——更具体地说,亚马逊网络服务(AWS)——并不是唯一提供托管、存储或计算系统的提供商。有许多这样的提供商,包括以下:
-
Heroku
-
DigitalOcean
-
谷歌云
-
微软 Azure
AWS 提供了运行完整 Web 应用所需的一切,就像所有其他提供商一样。此外,它也被广泛使用,这就是为什么我们在这本书中专注于 AWS。
其服务范围从数据库到对象存储,再到安全服务,等等。此外,AWS 是大多数其他书籍和教程中都会找到的解决方案,许多大型公司也用它来支持其完整的基础设施。
本书使用 AWS 来托管静态文件,例如图片,运行生产数据库以及我们的应用程序的 Docker 容器。
在继续本章之前,您将需要 AWS 账户。您可以在官方网页aws.amazon.com/上创建一个账户。为此,您需要一个有效的信用卡;在阅读本书的过程中,您也可以在免费层上运行几乎所有服务,而不会遇到任何问题。
一旦您成功注册 AWS,您将看到以下仪表板。这个屏幕被称为AWS 管理控制台:

图 7.1 – AWS 管理控制台
下一个部分将介绍使用 AWS 存储文件的选择。
配置 AWS S3 存储桶
对于本章,我们需要一个存储服务来保存所有上传的图片。AWS 为各种用例提供不同的存储类型。在我们的社交网络场景中,我们将有数十人同时访问许多图片。AWS 简单存储服务(S3)是我们场景的最佳选择。按照以下步骤设置 S3 存储桶:
-
您可以通过点击页面顶部的服务下拉菜单,然后在下拉菜单中的存储类别下查找,来访问Amazon S3屏幕。在那里,您将找到一个指向 S3 的链接。点击它后,屏幕将看起来像这样:
![图 7.2 – S3 管理屏幕]()
图 7.2 – S3 管理屏幕
在 S3 中,您可以在特定的 AWS 区域内部创建一个存储桶,在那里您可以存储文件。
前一个屏幕提供了许多与您的 S3 存储桶交互的功能。您可以通过管理界面浏览所有文件,上传您的文件,并配置更多设置。
-
现在,我们将通过点击右上角的创建存储桶,如图 7.2所示,为我们的项目创建一个新的存储桶。您将看到一个表单,如下面的截图所示。要创建存储桶,您必须填写以下内容:

图 7.3 – S3 存储桶向导
存储桶必须在 S3 的所有存储桶中具有唯一名称。然后,我们需要选择一个区域。对我来说,欧洲(法兰克福)eu-central-1是最好的选择,因为它是最接近的源点。选择最适合您的选项,因为存储桶的性能与其访问区域和存储桶区域之间的距离相对应。
然后,您需要取消选择阻止所有公共访问选项,并检查带有警告标志的确认。AWS 显示此警告是因为我们只有在真正需要时才应向 S3 存储桶提供公共访问。它应该看起来像这样:

图 7.4 – S3 存储桶访问
对于我们的用例,我们可以保留在此表单向导中为所有其他选项提供的默认设置。在其他更高级的场景中,其他选项可能会有所帮助。AWS 提供了许多功能,例如完整的访问日志和版本控制。
注意
许多大型公司拥有全球用户,这需要一个高度可用的应用程序。当您达到这一点时,您可以在其他地区创建更多的 S3 存储桶,并且您可以将一个存储桶的复制设置到世界各地其他地区的存储桶。然后,可以使用 AWS CloudFront 和针对每个用户的特定路由器将正确的存储桶分发出去。这种方法为每个用户提供了最佳的可能体验。
通过点击页面底部的创建存储桶完成设置过程。您将被重定向回所有存储桶的表格视图。
生成 AWS 访问密钥
在实现上传功能之前,我们必须创建一个 AWS应用程序编程接口(API)密钥,以授权我们的 AWS 后端,以便将新文件上传到 S3 存储桶。
点击 AWS 管理屏幕顶部的用户名。在那里,您将找到一个名为我的安全凭证的标签页,它导航到一个提供各种选项以保护您的 AWS 账户访问权限的屏幕。
您将看到一个类似这样的对话框:

图 7.5 – S3 身份和访问管理 (IAM) 对话框
您可以点击继续到安全凭证以继续。通常建议使用 AWS IAM,这允许您通过单独的 IAM 用户高效地管理对 AWS 资源的安全访问。在本书中,我们将以我们现在的方式使用根用户,但我建议在编写您的下一个应用程序时查看 AWS IAM。
您现在应该看到凭证页面,其中列出了存储凭证的不同方法。它应该看起来像这样:

图 7.6 – AWS 访问密钥
在列表中,展开前一个屏幕截图中所显示的名为访问密钥(访问密钥 ID 和秘密访问密钥)的选项卡。在此选项卡中,您可以找到您 AWS 账户的所有访问令牌。
要生成新的访问令牌,请点击创建新访问密钥。输出应如下所示:

图 7.7 – AWS 访问密钥
最佳实践是按照提示下载密钥文件并将其安全地保存到某个地方,以防您在任何时候丢失密钥。关闭窗口后,您无法再次检索访问密钥,因此如果您丢失了它们,您将不得不删除旧密钥并生成一个新的。
注意
这种方法可以用来解释 AWS 的基础知识。对于这样一个庞大的平台,您必须采取进一步的步骤来进一步增强您应用程序的安全性。例如,建议每 90 天更换 API 密钥。您可以在docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html上了解更多关于所有最佳实践的信息。
正如您在图 7.7中所见,AWS 给我们提供了两个令牌。两者都是访问我们的 S3 存储桶所必需的。
现在,我们可以开始编写上传机制了。
将图片上传到 Amazon S3
实现文件上传和存储文件始终是一项巨大的任务,尤其是在用户可能希望再次编辑他们的文件时的图片上传。
对于我们的前端,用户应该能够将他们的图片拖放到拖放区域,裁剪图片,然后在完成时提交。后端需要接受文件上传,这并不容易。文件必须被处理并有效地存储,以便所有用户都可以快速访问它们。
由于这是一个庞大的主题,本章仅涵盖了从 React 基本上传图像,使用多部分 POST 请求到我们的 GraphQL API,然后将图像传输到我们的 S3 存储桶。当涉及到压缩、转换和裁剪时,您应该查看有关此主题的更多教程或书籍,包括在前端和后端实现这些技术的技巧,因为有很多东西需要考虑。例如,在许多应用程序中,存储不同分辨率的图像以供用户在不同情况下查看是有意义的,这样可以节省带宽。
让我们从在后台实现上传过程开始。
GraphQL 图像上传突变
当将图像上传到 S3 时,需要使用一个 API 密钥,我们已生成。因此,我们不能直接使用 API 密钥从客户端上传文件到 S3。任何访问我们应用程序的人都可以从 JavaScript 代码中读取 API 密钥,并访问我们的存储桶,而无需我们知道。
直接从客户端将图像上传到存储桶通常是可能的。为此,您需要将文件的名称和类型发送到服务器,然后服务器会生成 统一资源定位符 (URL) 和签名。然后客户端可以使用签名上传图像。这种技术会导致客户端进行多次往返,并且不允许我们进行后处理图像,例如转换或压缩(如果需要)。
一个更好的解决方案是将图像上传到我们的服务器,让 GraphQL API 接收文件,然后向 S3 发送另一个请求——包括 API 密钥——以将文件存储到我们的存储桶中。
我们必须准备我们的后端以与 AWS 通信并接受文件上传。准备工作如下:
-
我们安装官方的
npm包以与 AWS 交互。它提供了使用任何 AWS 功能所需的一切,而不仅仅是 S3。我们还安装了graphql-upload,它提供了一些工具来从任何 GraphQL 请求中解析文件。以下是完成此操作的代码:npm install --save aws-sdk graphql-upload -
在服务器
index.js文件中,我们需要添加graphql-upload包的初始化。为此,在顶部导入 Express 依赖项,如下所示:import { graphqlUploadExpress } from 'graphql-upload'; -
在文件末尾的
graphql案例中,在执行applyMiddleware函数之前,我们需要先初始化它,如下所示:case 'graphql': (async () => { await services[name].start(); app.use(graphqlUploadExpress()); services[name].applyMiddleware({ app }); })(); break; -
接下来要做的事情是编辑 GraphQL 模式,并在其顶部添加一个
Upload标量。该标量用于在上传文件时解析诸如 多用途互联网邮件扩展 (MIME) 类型和解码等细节。以下是您需要的代码:scalar Upload -
将
File类型添加到模式中。此类型返回文件名和图像可以在浏览器中访问的结果 URL。代码如下所示:type File { filename: String! mimetype: String! encoding: String! url: String! } -
创建一个新的
uploadAvatar突变。用户需要登录才能上传头像图像,所以将@auth指令附加到突变上。突变接受之前提到的Upload标量作为输入。代码在下面的代码片段中展示:uploadAvatar ( file: Upload! ): File @auth -
接下来,我们将在
resolvers.js文件中实现突变解析函数。为此,我们将在resolvers.js文件顶部导入和设置我们的依赖项,如下所示:import { GraphQLUpload } from 'graphql-upload'; import aws from 'aws-sdk'; const s3 = new aws.S3({ signatureVersion: 'v4', region: 'eu-central-1', });我们将初始化
s3对象,我们将在下一步上传图像时使用它。需要传递一个region属性,这是我们创建 S3 存储桶的属性。我们将signatureVersion属性设置为版本'v4',因为这被推荐使用。注意
你可以在
docs.aws.amazon.com/general/latest/gr/signature-version-4.html找到关于 AWS 请求签名过程的详细信息。 -
在
resolvers.js文件内部,我们需要添加一个Upload解析器,如下所示:Upload: GraphQLUpload -
在
mutation属性内部,插入uploadAvatar函数,如下所示:async uploadAvatar(root, { file }, context) { const { createReadStream, filename, mimetype, encoding } = await file; const bucket = 'apollo-book'; const params = { Bucket: bucket, Key: context.user.id + '/' + filename, ACL: 'public-read', Body: createReadStream() }; const response = await s3.upload(params).promise(); return User.update({ avatar: response.Location }, { where: { id: context.user.id } }).then(() => { return { filename: filename, url: response.Location } }); },
在前面的代码片段中,我们首先将函数指定为async,这样我们就可以使用await方法解析文件及其详细信息。解析的await file方法的结果包括stream、filename、mimetype和encoding属性。
然后,我们在params变量中收集以下参数,以便上传我们的头像图像:
-
Bucket字段持有我们保存图像的存储桶名称。我使用了'apollo-book'这个名字,但你需要输入你在创建存储桶时输入的名字。你可以在s3对象内部直接指定这个名字,但这种方法更灵活,因为你可以为不同类型的文件拥有多个存储桶,而不需要多个s3对象。 -
Key属性是文件保存的路径和名称。请注意,我们将文件存储在一个新文件夹下,这个文件夹就是用户context变量。在未来的应用中,你可以为每个文件引入某种哈希值。那会很好,因为文件名不应该包含不允许的字符。此外,当使用哈希时,文件不能被程序性地猜测。 -
ACL字段设置了谁可以访问文件的权限。由于社交网络上的上传图像可以被互联网上的任何人公开查看,我们将属性设置为'public-read'。 -
Body字段接收stream变量,这是我们通过解析文件最初获得的。stream变量不过就是图像本身作为一个流,我们可以直接将其上传到存储桶中。
params 变量被传递给 s3.upload 函数,该函数将文件保存到我们的存储桶。我们直接将 promise 函数链接到 upload 方法。在前面的代码片段中,我们使用 await 语句来解决 upload 函数返回的承诺。因此,我们将函数指定为 async。AWS S3 上传的 response 对象包括一个公共 URL,任何人都可以通过该 URL 访问图像。
最后一步是在我们的数据库中设置新的用户头像图片。我们通过设置从 response.Location 的新 URL(S3 在我们解决承诺后提供给我们)来执行 Sequelize 的 User.update 模型函数。
这里提供了一个指向 S3 图像的示例链接:
https://apollo-book.s3.eu-central-1.amazonaws.com/1/test.png
如您所见,URL 前缀是存储桶的名称,然后是区域。后缀当然是文件夹,即用户 ID 和文件名。前面的 URL 将与后端生成的 URL 不同,因为您的存储桶名称和区域将不同。
更新用户后,我们可以返回 AWS 响应以相应地更新 用户界面(UI),而不需要刷新浏览器窗口。
在上一节中,我们生成了访问令牌以授权 AWS 的后端。默认情况下,AWS JWT_SECRET,我们将设置令牌,如下所示:
export AWS_ACCESS_KEY_ID=YOUR_AWS_KEY_ID
export AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_KEY
将您的 AWS 令牌插入到前面的代码中。AWS SDK 会自动检测环境变量。我们不需要在我们的代码中任何地方读取或配置它们。
现在,我们将继续在前端实现所有图像上传功能。
React 图像裁剪和上传
在像 Facebook 这样的社交网络中,有多个位置可以供您选择和上传文件。您可以在聊天中发送图片,将它们附加到帖子中,在您的个人资料中创建相册,等等。现在,我们只看看如何更改我们用户的头像图片。这是一个展示所有技术的好例子。
我们希望达到的结果看起来像这样:


图 7.8 – 裁剪对话框
用户可以选择一个文件,在模态框中直接裁剪它,并使用前面的对话框将其保存到 AWS。
我不是很喜欢使用太多的 npm 包,因为这通常会使您的应用程序变得不必要地大。截至本书编写时,我们无法为显示对话框或裁剪等一切编写自定义 React 组件,无论这可能多么容易。
要使图像上传工作,我们将安装两个新的包。为此,您可以按照以下说明操作:
-
使用
npm安装包,如下所示:react-modal package offers various dialog options that you can use in many different situations. The react-cropper package is a wrapper package around Cropper.js. The react-dropzone package provides an easy implementation for file drop functionality. -
当使用
react-cropper包时,我们可以依赖其包含的App.js文件,直接从包本身导入,如下所示:import 'cropperjs/dist/cropper.css';Webpack 负责打包所有资源,就像我们用自定义 CSS 所做的那样。其余所需的 CSS 都可在本书的官方 GitHub 仓库中找到。
-
我们接下来要安装的包是 Apollo 客户端的扩展,这将使我们能够上传文件,如下所示:
npm install --save apollo-upload-client -
要运行
apollo-upload-client包,我们必须编辑apollo文件夹中的index.js文件,在那里我们初始化 Apollo 客户端及其所有链接。在index.js文件顶部导入createUploadLink函数,如下所示:import { createUploadLink } from 'apollo-upload-client'; -
您必须用新的上传链接替换链表底部的旧
HttpLink实例。现在,我们将传递createUploadLink函数,但使用相同的参数。执行时,将返回一个常规链接。链接应如下所示:createUploadLink({ uri: 'http://localhost:8000/graphql', credentials: 'same-origin', }),需要注意的是,当我们使用新的上传链接并通过 GraphQL 请求发送文件时,我们不会发送标准的
application/jsonContent-Type请求,而是发送一个多部分的FormData请求。这允许我们使用 GraphQL 上传标准file对象。注意
或者,在传输图像时,可以发送一个
base64字符串而不是file对象。这种方法将节省我们目前正在做的工作,因为发送和接收字符串在 GraphQL 中是没有问题的。如果您想将其保存到 AWS S3,则必须将base64字符串转换为文件。然而,这种方法仅适用于图像,并且 Web 应用程序应该能够接受任何文件类型。 -
现在包已经准备好了,我们可以开始为客户实现
uploadAvatar突变组件。在mutations文件夹中创建一个名为uploadAvatar.js的新文件。 -
在文件顶部,导入所有依赖项,并以传统方式使用
gql解析所有 GraphQL 请求,如下所示:import { gql, useMutation } from '@apollo/client'; const UPLOAD_AVATAR = gql' mutation uploadAvatar($file: Upload!) { uploadAvatar(file : $file) { filename url } } '; export const getUploadAvatarConfig = () => ({ update(cache, { data: { uploadAvatar } }) { console.log(uploadAvatar); if(uploadAvatar && uploadAvatar.url) { cache.modify({ fields: { currentUser(user, { readField }) { cache.modify({ id: user, fields: { avatar() { return uploadAvatar.url; } } }) } } }); } } }); export const useUploadAvatarMutation = () => useMutation(UPLOAD_AVATAR, getUploadAvatarConfig());如您所见,我们只是通过将 GraphQL 查询包裹在
useMutation钩子中导出了新的突变。我们还添加了一个update函数,该函数将首先获取当前用户的引用,然后通过引用更新此用户的新头像 URL 来更新缓存。 -
最后,我们需要将
id属性添加到userAttributes片段中。否则,用户引用上头像 URL 的更新只会反映在顶部栏上,而不会反映在所有帖子中。代码如下所示:import { gql } from '@apollo/client'; export const USER_ATTRIBUTES = gql' fragment userAttributes on User { id username avatar } ';
准备工作现在已完成。我们已经安装了所有必需的包,进行了配置,并实现了新的突变组件。我们可以开始编写用户界面对话框来更改头像图像。
为了这本书的目的,我们不是依赖于单独的页面或类似的东西。相反,我们给用户提供了在顶部栏点击他们的图像时更改头像的机会。为此,我们将监听头像上的点击事件,打开一个包含文件拖放区域和提交新图像按钮的对话框。
执行以下步骤以运行此逻辑:
-
总是让您的组件尽可能可重用是一个好主意,因此请在
components文件夹内创建一个avatarModal.js文件。 -
与往常一样,您必须首先导入新的
react-modal、react-cropper和react-dropzone包,然后是突变,如下所示:import React, { useState, useRef } from 'react'; import Modal from 'react-modal'; import Cropper from 'react-cropper'; import { useDropzone } from 'react-dropzone'; import { useUploadAvatarMutation } from '../apollo/mutations/uploadAvatar'; Modal.setAppElement('#root'); const modalStyle = { content: { width: '400px', height: '450px', top: '50%', left: '50%', right: 'auto', bottom: 'auto', marginRight: '-50%', transform: 'translate(-50%, -50%)' } };如前述代码片段所示,我们告诉模态包在浏览器
setAppElement方法的哪个点。对于我们的用例,取rootDOMNode 是可以的,因为这是我们应用程序的起点。模态框在这个 DOMNode 中实例化。模态组件接受一个特殊的
style参数,用于拖拽区域的不同部分。我们可以通过指定modalStyle对象和正确的属性来样式化模态框的所有部分。 -
react-cropper包给用户提供了裁剪图像的机会。结果是file或blob对象,而是一个dataURI对象,格式化为base64。通常这不会是问题,但我们的 GraphQL API 期望我们发送一个真实的文件,而不仅仅是字符串,正如我们之前解释的那样。因此,我们必须将dataURI对象转换为 blob,我们可以将其与我们的 GraphQL 请求一起发送。添加以下函数来处理转换:function dataURItoBlob(dataURI) { var byteString = atob(dataURI.split(',')[1]); var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; var ia = new Uint8Array(byteString.length); for (var i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } const file = new Blob([ia], {type:mimeString}); return file; }让我们不要深入探讨前面函数的逻辑。您需要知道的是,它将所有可读的
blob对象转换为调用函数。它将数据 URI 转换为 blob。 -
我们目前正在实施的新组件被称为
AvatarUpload。它接收isOpen属性,该属性用于设置模态框的可见或不可见。默认情况下,模态框是不可见的。此外,当模态框显示时,拖拽区域将渲染在其中。首先,设置组件本身和所需的变量,如下所示:const AvatarModal = ({ isOpen, showModal }) => { const [file, setFile] = useState(null); const [result, setResult] = useState(null); const [uploadAvatar] = useUploadAvatarMutation(); const cropperRef = useRef(null); }我们需要
file和result状态变量来管理选定的原始文件和裁剪图像。此外,我们使用useRef钩子设置突变和引用,这对于cropper库是必需的。 -
接下来,我们需要设置所有我们将用于处理不同事件和回调的组件函数。将以下函数添加到组件中:
const saveAvatar = () => { const resultFile = dataURItoBlob(result); resultFile.name = file.filename; uploadAvatar({variables: { file: resultFile }}).then(() => { showModal(); }); }; const changeImage = () => { setFile(null); }; const onDrop = (acceptedFiles) => { const reader = new FileReader(); reader.onload = () => { setFile({ src: reader.result, filename: acceptedFiles[0].name, filetype: acceptedFiles[0].type, result: reader.result, error: null, }); }; reader.readAsDataURL(acceptedFiles[0]); }; const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop}); const onCrop = () => { const imageElement = cropperRef?.current; const cropper = imageElement?.cropper; setResult(cropper.getCroppedCanvas().toDataURL()); };saveAvatar函数是主要函数,它将base64字符串转换为 blob。当用户拖放或选择图像时,会调用onDrop函数。此时,我们使用FileReader读取文件,并给我们一个base64字符串,我们将其保存到file状态变量中作为一个对象。useDropZone钩子为我们提供了所有可以用来设置实际拖拽区域的属性。changeImage函数将取消当前的裁剪过程,并允许我们再次上传新文件。每当用户更改裁剪选择时,都会调用
onCrop函数。此时,我们将新的裁剪图像作为base64字符串保存到result状态变量中,以便在原始file变量和result变量之间有清晰的分离。 -
Modal组件接受一个onRequestClose方法,当用户尝试通过点击外部关闭模态框时,会执行showModal函数,例如。我们从父组件接收showModal函数,我们将在下一步中介绍。模态框还接收默认的style属性和一个标签。Cropper组件需要在crop属性中接收一个函数,该函数在每次更改时被调用。同时,Cropper组件从file状态变量接收src属性,如下代码片段所示:return ( <Modal isOpen={isOpen} onRequestClose={showModal} contentLabel="Change avatar" style={modalStyle} > {!file && (<div className="drop" {...getRootProps()}> <input {...getInputProps()} /> {isDragActive ? <p>Drop the files here ...</p> : <p>Drag 'n' drop some files here, or click to select files</p>} </div>) } {file && <Cropper ref={cropperRef} src={file.src} style={{ height: 400, width: "100%" }} initialAspectRatio={16 / 9} guides={false} crop={onCrop}/>} {file && ( <button className="cancelUpload" onClick={changeImage}>Change image</button> )} <button className="uploadAvatar" onClick={saveAvatar}>Save</button> </Modal> )如你所见,
return语句仅包括作为包装器的模态框和一个裁剪器。最后,我们有一个调用saveAvatar的按钮来执行突变,并与之发送裁剪图像或changeImage,后者取消当前图像的裁剪。 -
不要忘记在文件末尾添加
export语句,如下所示:export default AvatarModal -
现在,切换到
bar文件夹中的user.js文件,其中存储了所有其他应用程序栏相关的文件。按照以下方式导入新的AvatarModal组件:import AvatarModal from '../avatarModal'; -
UserBar组件是AvatarUploadModal的父组件。从bar文件夹中打开user.js文件。这就是为什么我们在UserBar组件中处理对话框的isOpen状态变量。我们引入了一个isOpen状态变量,并在用户的头像上捕获onClick事件。将以下代码复制到UserBar组件中:const [isOpen, setIsOpen] = useState(false); const showModal = () => { setIsOpen(!isOpen); } -
将
return语句替换为以下代码:return ( <div className="user"> <img src={user.avatar} onClick={() => showModal()} /> <AvatarModal isOpen={isOpen} showModal={showModal}/> <span>{user.username}</span> </div> );模态组件直接接收
isOpen属性,正如我们之前解释的那样。当点击头像图像时,会执行showModal方法。这个函数更新AvatarModal组件的属性,并显示或隐藏模态框。
使用匹配的 npm run 命令启动服务器和客户端。重新加载浏览器并尝试新功能。当选择图像时,会显示裁剪工具。你可以拖动并调整要上传的图像区域的大小。你可以在以下屏幕截图中看到这个示例:


Figure 7.9 – Cropping in progress
在 S3 存储桶中点击 user 文件夹。多亏了我们编写的突变,顶栏中的头像图像被更新为指向图像在 S3 存储桶位置的新的 URL。
我们所取得的巨大成就是将图像发送到我们的服务器。我们的服务器将所有图像传输到 S3。AWS 响应公共 URL,然后直接放置在浏览器的头像字段中。我们使用 GraphQL API 从后端查询头像图像的方式没有改变。我们返回 S3 文件的 URL,一切正常工作。
摘要
在本章中,我们首先创建了一个 AWS 账户和一个 S3 存储桶,用于从我们的后端上传静态图像。现代社交网络由许多图像、视频和其他类型的文件组成。我们介绍了 Apollo 客户端,它允许我们上传任何类型的文件。在本章中,我们成功地将一张图像上传到我们的服务器,并介绍了如何在 AWS S3 服务器上裁剪图像并保存它们。现在,您的应用程序应该能够随时为用户提供图像服务。
下一章将涵盖客户端路由的基础知识,使用 React Router 实现。
第八章:React 中的路由
目前,我们的用户可以访问一个屏幕和一个路径。当用户访问 Graphbook 时,他们可以登录并查看他们的新闻源和聊天。社交网络的一个要求是用户有自己的个人资料页面。我们将在本章实现此功能。
我们将为我们的 React 应用程序介绍客户端路由。
本章将涵盖以下主题:
-
设置 React Router
-
使用 React Router 的高级路由
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
设置 React Router
路由对于大多数 Web 应用程序都是必不可少的。你无法在一个页面上涵盖你应用程序的所有功能。这将导致过载,并且用户会发现很难理解。在社交网络如 Graphbook 中,分享图片、个人资料或帖子的链接也非常重要。例如,一个有利的特性是能够发送到特定个人资料的链接。这要求每个个人资料都有自己的统一资源定位符(URL)和页面。否则,将无法分享到应用程序单个项目的直接链接。由于搜索引擎优化(SEO)的原因,将内容拆分到不同的页面也非常关键。
目前,我们根据认证状态在浏览器中将完整的应用程序渲染为超文本标记语言(HTML)。只有服务器实现了简单的路由功能。如果路由器只是简单地替换 React 中的正确部分,而不是在跟随链接时完全重新加载页面,那么执行客户端路由可以为用户节省大量工作和时间。应用程序利用 HTML5 历史实现来处理浏览器的历史记录至关重要。重要的是,这也应该适用于不同方向上的导航。我们应该能够使用浏览器中的箭头导航按钮前后导航,而无需重新加载应用程序。此解决方案不应发生不必要的页面重新加载。
你可能知道的一些常见框架,如 Angular、Ember 和 Ruby on Rails,使用静态路由。Express.js 也是这样,我们在本书的第二章**, 使用 Express.js 设置 GraphQL中介绍了它。静态路由意味着你预先配置你的路由流程和要渲染的组件。然后,你的应用程序在单独的步骤中处理路由表,渲染所需的组件,并将结果呈现给用户。
随着 4 版本和我们现在将要使用的 5 版本 React Router 的发布,引入了动态路由。它的独特之处在于,路由发生在你的应用程序渲染运行时。它不需要应用程序首先处理配置以显示正确的组件。这种方法与 React 的工作流程非常契合。路由直接在你的应用程序中发生,而不是在预处理的配置中。
安装 React Router
在过去,有很多 React 路由器,具有各种实现和功能。正如我们之前提到的,我们将为这本书安装和配置第 5 版。如果你搜索其他关于这个主题的教程,请确保遵循这个版本的说明。否则,你可能会错过 React Router 经历的一些变化。
要安装 React Router,只需再次运行 npm,如下所示:
npm install --save react-router-dom
从包名来看,你可能会认为这不是 React 的主要包。原因在于 React Router 是一个多包库。当在多个平台上使用相同工具时,这会很有用。核心包被称为 react-router。
还有两个额外的包。第一个是 react-router-dom 包,我们在前面的代码片段中已经安装了它,第二个是 react-router-native 包。如果你在某个时候计划构建一个 React Native 应用程序,你可以使用相同的路由,而不是使用浏览器的文档对象模型(DOM)来构建真正的移动应用程序。
我们将要采取的第一步是引入一个简单的路由,以便使我们的当前应用程序工作,包括所有屏幕的不同路径。我们将添加的路由在此处详细说明:
-
我们的应用程序的帖子源、聊天和顶部栏,包括搜索框,应该在
/app路由下可访问。路径是自解释的,但你也可以使用/根路径作为主路径。 -
登录和注册表单应该有单独的路径,该路径将在
/根路径下可访问。 -
由于我们没有其他屏幕,我们还需要处理一种情况,即前面的所有路由都不匹配。在这种情况下,我们可以显示一个所谓的 404 页面,但我们将直接重定向到根路径。
在继续之前,我们必须准备一件事。对于开发,我们使用 webpack 开发服务器,正如我们在 第一章,准备你的开发环境 中配置的那样。为了使路由能够直接工作,我们将向 webpack.client.config.js 文件添加两个参数。devServer 字段应如下所示:
devServer: {
port: 3000,
open: true,
historyApiFallback: true,
},
historyApiFallback字段告诉devServer不仅为根路径http://localhost:3000/,而且在它通常会收到 404 错误(例如对于http://localhost:3000/app之类的路径)时也要提供index.html文件。这发生在路径不匹配文件或文件夹时,这在实现路由时是正常的。
在config文件顶部的output字段必须有一个publicPath属性,如下所示:
output: {
path: path.join(__dirname, buildDirectory),
filename: 'bundle.js',
publicPath: '/',
},
publicPath属性告诉 webpack 将包 URL 的前缀添加到绝对路径,而不是相对路径。当此属性未包含时,浏览器在访问我们应用程序的子目录时无法下载包,因为我们正在实现客户端路由。让我们从第一个路径开始,将应用程序的中心部分,包括新闻源,绑定到/app路径。
实现您的第一个路由
在实现路由之前,我们将清理App.js文件。为此,请按照以下步骤操作:
-
在
client文件夹中,在App.js文件旁边创建一个Main.js文件。插入以下代码:import React from 'react'; import Feed from './Feed'; import Chats from './Chats'; import Bar from './components/bar'; export const Main = ({ changeLoginState }) => { return ( <> <Bar changeLoginState={changeLoginState} /> <Feed /> <Chats /> </> ); } export default Main;如您可能已经注意到的,前面的代码基本上与
App.js文件内的登录条件相同。唯一的区别是changeLoginState函数是从属性中取出的,而不是组件本身的直接方法。这是因为我们将这部分从App.js文件中分离出来,并放入一个单独的文件中。这提高了我们将要实现的其它组件的可重用性。 -
现在,打开并替换
App组件的return语句,以反映以下更改:return ( <div className="container"> <Helmet> <title>Graphbook - Feed</title> <meta name="description" content="Newsfeed of all your friends on Graphbook" /> </Helmet> <Router loggedIn={loggedIn} changeLoginState={handleLogin}/> </div> )如果你将先前的方法与旧方法进行比较,你会发现我们插入了一个
Router组件,而不是直接渲染帖子源或登录表单。App.js文件的原有组件现在位于之前创建的Main.js文件中。在这里,我们将loggedIn属性和changeLoginState函数传递给Router组件。删除顶部的依赖项,如Chats和Feed组件,因为我们不再需要它们,多亏了新的Main组件。 -
将以下行添加到我们的
App.js文件的依赖项中:import Router from './router';为了使路由工作,我们必须首先实现我们的自定义
Router组件。通常,使用 React Router 实现路由运行很容易,并且不需要将路由功能分离到单独的文件中,但这使得代码更易读。 -
要做到这一点,在
client文件夹中,在App.js文件旁边创建一个新的router.js文件,内容如下:import React from 'react'; import { BrowserRouter as Router, Route, Redirect, Switch } from 'react-router-dom'; import LoginRegisterForm from './components/loginregister'; import Main from './Main'; export const routing = ({ changeLoginState, loggedIn }) => { return ( <Router> <Switch> <Route path="/app" component={() => <Main changeLoginState= {changeLoginState}/>}/> </Switch> </Router> ) } export default routing;
在顶部,我们导入所有依赖项。它们包括新的Main组件和react-router包。以下是所有从 React Router 包中导入的组件的快速说明:
-
BrowserRouter(或简称Router,如我们在这里所称呼)是一个组件,它使地址栏中的 URL 与 用户界面(UI)保持同步;它处理所有的路由逻辑。 -
Switch组件强制渲染第一个匹配的Route或Redirect组件。我们需要它来停止在用户已经位于重定向尝试导航到的位置时重新渲染 UI。我通常建议您使用Switch组件,因为它可以捕获不可预见的路由错误。 -
Route是一个组件,它试图将给定的路径与浏览器的 URL 匹配。如果情况如此,则渲染component属性。您可以在前面的代码片段中看到,我们并没有直接将Main组件作为参数设置;相反,我们从无状态函数中返回它。这是必需的,因为Route组件的component属性只接受函数,而不是组件对象。这个解决方案允许我们将changeLoginState函数传递给Main组件。 -
Redirect将浏览器导航到指定的位置。该组件接收一个名为to的属性,它由以/开头的路径填充。我们将在下一节中使用这个组件。
前面代码的问题是我们只监听了一个路由,即 /app。如果您未登录,将会有许多未覆盖的错误。最好的做法是将用户重定向到根路径,在那里他们可以登录。
受保护的路由
受保护的路由代表了一种指定只有当用户经过身份验证或具有正确的授权时才能访问的路径的方法。
在 React Router 中实现受保护路由的推荐解决方案是编写一个小的、无状态的函数,该函数根据条件渲染 Redirect 组件或需要经过身份验证的用户指定的路由上的组件。我们将路由的 component 属性提取到 Component 变量中,它是一个可渲染的 React 对象:
-
将以下代码插入到
router.js文件中:const PrivateRoute = ({ component: Component, ...rest }) => ( <Route {...rest} render={(props) => ( rest.loggedIn === true ? <Component {...props} /> : <Redirect to={{ pathname: '/', }} /> )} /> )我们调用
PrivateRoute无状态函数。它返回一个标准的Route组件,该组件接收最初传递给PrivateRoute函数的所有属性。为了传递所有属性,我们使用...rest语法进行解构赋值。在 React 组件的括号内使用该语法将rest对象的所有字段作为属性传递给组件。只有当给定的路径匹配时,Route组件才会被渲染。此外,渲染的组件取决于用户的
loggedIn状态变量,我们必须传递它。如果用户已登录,我们将无问题地渲染Component变量。否则,我们使用Redirect组件将用户重定向到应用程序的根路径。 -
在
Router组件的return语句中使用新的PrivateRoute组件,并替换旧的Route组件,如下所示:<PrivateRoute path="/app" component={() => <Main changeLoginState={changeLoginState} />} loggedIn={loggedIn}/>注意,我们是通过从
Router组件本身的属性中取值来传递loggedIn属性的。它最初从我们之前编辑的App组件接收loggedIn属性。很棒的是,loggedIn变量可以从父App组件随时更新。这意味着当用户注销时,Redirect组件会被渲染,用户会自动导航到登录表单。我们不需要编写单独的逻辑来实现这个功能。然而,我们现在又遇到了一个新的问题。当用户未登录时,我们将请求从
/app重定向到/,但我们没有为初始的'/'路径设置任何路由。这个路径要么显示登录表单,要么在用户登录时将用户重定向到/app,这样做是有意义的。新组件的模式与PrivateRoute组件之前的代码相同,但方向相反。 -
将新的
LoginRoute组件添加到router.js文件中,如下所示:const LoginRoute = ({ component: Component, ...rest }) => ( <Route {...rest} render={(props) => ( rest.loggedIn === false ? <Component {...props} /> : <Redirect to={{ pathname: '/app', }} /> )} /> )上述条件被反转以渲染原始组件。如果用户未登录,将渲染登录表单。否则,他们将被重定向到帖子源。
-
将新的路径添加到路由中,如下所示:
<LoginRoute exact path="/" component={() => <LoginRegisterForm changeLoginState={changeLoginState}/>} loggedIn={loggedIn}/>代码看起来与
PrivateRoute组件的代码相同,但我们现在有一个新的属性,称为exact。如果我们向一个路由传递这个属性,浏览器位置必须匹配 100%。以下表格展示了从官方 React Router 文档中摘取的一个快速示例:

对于根路径,我们将 exact 设置为 true,因为否则路径会与包含 / 的任何浏览器位置匹配,正如您在前面的表中看到的。
注意
React Router 提供了许多更多的配置选项,例如强制使用尾部斜杠、大小写敏感等。您可以在官方文档中找到所有选项和示例,网址为 v5.reactrouter.com/web/api/。
React Router 中的通配符路由
目前,我们已经设置了两个路径,即 /app 和 /。如果用户访问一个不存在的路径,例如 /test,他们将看到一个空屏幕。解决方案是实现一个匹配任何路径的路由。为了简单起见,我们将用户重定向到我们应用程序的根目录,但您也可以轻松地将重定向替换为典型的 404 页面。
将以下代码添加到 router.js 文件中:
const NotFound = () => {
return (
<Redirect to="/"/>
);
}
NotFound 组件很简单。它只是将用户重定向到根路径。将下一个 Route 组件添加到 Router 组件中的 Switch 组件。确保它是列表中的最后一个。代码如下所示:
<Route component={NotFound} />
正如你所见,我们在前面的代码中渲染了一个简单的Route组件。使这个路由特殊的是我们没有传递一个path属性。默认情况下,path属性会被完全忽略,组件会在每次渲染时显示,除非与之前的组件匹配。这就是为什么我们将路由添加到Router组件的底部。当没有路由匹配时,我们将用户重定向到根路径的登录屏幕,或者如果用户已经登录,我们将使用根路径的路由逻辑将他们重定向到不同的屏幕。我们的LoginRoute组件处理最后一个情况。
你可以通过使用npm run client启动前端和npm run server启动后端来测试所有更改。我们现在已经将我们的应用程序从标准单路由应用程序转换为根据浏览器位置区分登录表单和新闻源的应用程序。
在下一节中,我们将探讨如何通过添加参数化路由并根据这些参数加载数据来实现更复杂的路由。
使用 React Router 的高级路由
本章的主要目标是为你用户的个人资料页构建一个页面。我们需要一个单独的页面来显示单个用户输入或创建的所有内容。这些内容不适合放在帖子源旁边。当查看 Facebook 时,我们可以看到每个用户都有自己的地址,我们可以在其中找到特定用户的个人资料页。我们将以相同的方式创建我们的个人资料页,并使用用户名作为自定义路径。
我们必须实现以下功能:
-
我们为用户个人资料添加一个新的参数化路由。路径以
/user/开始,后跟一个用户名。 -
我们将用户个人资料页更改为在 GraphQL 请求的
variables字段中发送所有 GraphQL 查询,包括username路由参数。 -
我们编辑
postsFeed查询以通过提供的username参数过滤所有帖子。 -
我们在后台实现一个新的 GraphQL 查询,通过用户名请求用户,以便显示有关用户的信息。
-
当所有查询完成后,我们渲染一个新的用户个人资料头部组件和帖子源。
-
最后,我们启用在每个页面之间导航而无需重新加载整个页面,只需重新加载更改的部分。
让我们从在下一节中实现个人资料页的路由来开始。
路由中的参数
我们已经准备好了添加新用户路由所需的大部分工作。再次打开router.js文件。添加新的路由,如下所示:
<PrivateRoute path="/user/:username" component={props => <User {...props} changeLoginState={changeLoginState}/>} loggedIn={loggedIn}/>
代码中包含两个新元素,如下所示:
-
我们输入的路径是
/user/:username。正如你所见,用户名前有一个冒号作为前缀,这告诉 React Router 将它的值传递给正在渲染的底层组件。 -
我们之前渲染的组件是一个无状态的函数,它返回
LoginRegisterForm组件或Main组件。这两个组件都没有从 React Router 接收任何参数或属性。然而,现在要求将 React Router 的所有属性都传递给子组件。这包括我们刚刚引入的username参数。我们使用相同的解构赋值与props对象来将所有属性传递给User组件。
这些就是我们接受 React Router 参数化路径所需的所有更改。我们在新的用户页面组件内部读取值。在实现它之前,我们在router.js的顶部导入依赖项,以便使前面的路由工作,如下所示:
import User from './User';
在Main.js文件旁边创建前面的User.js文件。与Main组件一样,我们正在收集在这个页面上渲染的所有组件。你应该保持这个布局,因为你可以直接看到每个页面由哪些主要部分组成。User.js文件应该看起来像这样:
import React from 'react';
import UserProfile from './components/user';
import Chats from './Chats';
import Bar from './components/bar';
export const User = ({ changeLoginState, match }) => {
return (
<>
<Bar changeLoginState={changeLoginState} />
<UserProfile username={match.params.username}/>
<Chats />
</>
);
}
export default User
我们拥有所有常见的组件,包括Bar和Chat组件。如果用户访问朋友的个人资料,他们会在顶部看到常见的应用栏。他们可以在右侧访问他们的聊天,就像在 Facebook 上一样。这是 React 和组件的可重用性派上用场的情况之一。
我们移除了Feed组件,并用新的UserProfile组件替换了它。重要的是,UserProfile组件接收username属性。它的值来自User组件的属性。这些属性是通过 React Router 传递的。如果你在路由路径中有一个参数,比如username,那么这个值就存储在子组件的match.params.username属性中。match对象通常包含 React Router 的所有匹配信息。
从这个点开始,你可以使用这个值实现任何你想要的自定义逻辑。我们现在将继续实现个人资料页面。
在构建用户个人资料页面之前,需要将渲染逻辑提取到单独的组件中以供重用。在post文件夹内创建一个名为feedlist.js的新文件。
在feedlist.js文件中插入以下代码:
-
按照以下方式在顶部导入以下依赖项:
import React, { useState } from 'react'; import InfiniteScroll from 'react-infinite-scroll-component'; import Post from './'; -
然后,只需复制以下
return语句中的主要部分,如下所示:export const FeedList = ({fetchMore, posts}) => { const [hasMore, setHasMore] = useState(true); const [page, setPage] = useState(0); return ( <div className="feed"> <InfiniteScroll dataLength={posts.length} next={() => loadMore(fetchMore)} hasMore={hasMore} loader={<div className="loader" key={"loader"}>Loading ...</div>} > {posts.map((post, i) => <Post key={post.id} post={post} /> )} </InfiniteScroll> </div> ); } export default FeedList; -
现在缺少的是
loadMore函数,我们也可以直接复制。只需将其直接添加到前面的组件中,如下所示:const loadMore = (fetchMore) => { fetchMore({ variables: { page: page + 1, }, updateQuery(previousResult, { fetchMoreResult }) { if(!fetchMoreResult.postsFeed.posts.length) { setHasMore(false); return previousResult; } setPage(page + 1); const newData = { postsFeed: { __typename: 'PostFeed', posts: [ ...previousResult.postsFeed.posts, ...fetchMoreResult.postsFeed.posts ] } }; return newData; } }); } -
只需替换
Feed.js文件return语句的部分。它应该看起来像这样:return ( <div className="container"> <div className="postForm"> <form onSubmit={handleSubmit}> <textarea value={postContent} onChange={(e) => setPostContent(e.target.value)} placeholder="Write your custom post!"/> <input type="submit" value="Submit" /> </form> </div> <FeedList posts={posts} fetchMore={loadMore}/> </div> )
我们现在可以在需要显示帖子列表的地方使用这个FeedList组件,比如在我们的用户个人资料页面上。
按照以下步骤构建用户的个人资料页面:
-
在
components文件夹内创建一个名为user的新文件夹。 -
在
user文件夹内创建一个名为index.js的新文件。 -
按照以下方式在文件顶部导入依赖项:
import React from 'react'; import FeedList from '../post/feedlist'; import UserHeader from './header'; import Loading from '../loading'; import Error from '../error'; import { useGetPostsQuery } from '../../apollo/queries/getPosts'; import { useGetUserQuery } from '../../apollo/queries/getUser';前三行看起来应该很熟悉。然而,目前有两个导入的文件不存在,但我们很快就会改变这一点。第一个新文件是
UserHeader,它负责渲染头像图片、用户名和信息。逻辑上,我们通过一个新的 Apollo 查询钩子getUser请求我们将要在该头部显示的数据。 -
将我们目前正在构建的
UserProfile组件代码插入到依赖项下方,如下所示:const UserProfile = ({ username }) => { const { data: user, loading: userLoading } = useGetUserQuery({ username }); const { loading, error, data: posts, fetchMore } = useGetPostsQuery({ username }); if (loading || userLoading) return <Loading />; if (error) return <Error><p>{error.message}</p> </Error>; return ( <div className="user"> <div className="inner"> <UserHeader user={user.user} /> </div> <div className="container"> <FeedList posts={posts.postsFeed.posts} fetchMore={fetchMore}/> </div> </div> ) } export default UserProfile;UserProfile组件并不复杂。我们同时运行两个 Apollo 查询。这两个查询都设置了variables属性。useGetPostsQuery钩子接收用户名,它最初来自 React Router。这个属性也被传递给useGetUserQuery。 -
现在编辑并创建 Apollo 查询,在编写个人资料头部组件之前。打开
queries文件夹中的getPosts.js文件。 -
要将用户名作为 GraphQL 查询的输入,我们首先必须将查询字符串从
GET_POSTS变量中更改。将前两行更改为以下代码:query postsFeed($page: Int, $limit: Int, $username: String) { postsFeed(page: $page, limit: $limit, username: $username) { -
然后,将最后一行替换为以下代码,以提供传递变量到
useQuery钩子函数的方法:export const useGetPostsQuery = (variables) => useQuery(GET_POSTS, { pollInterval: 5000, variables: { page: 0, limit: 10, ...variables } });如果自定义查询组件接收一个
username属性,它将被包含在 GraphQL 请求中。它被用来过滤我们正在查看的特定用户发布的帖子。 -
在
queries文件夹中创建一个新的getUser.js文件,创建一个查询钩子,这是我们目前所缺少的。 -
按照以下方式在文件顶部导入所有依赖项并使用
gql解析新的查询模式:import { gql, useQuery } from '@apollo/client'; import { USER_ATTRIBUTES } from '../fragments/userAttributes'; export const GET_USER = gql' query user($username: String!) { user(username: $username) { ...userAttributes } } ${USER_ATTRIBUTES} '; export const useGetUserQuery = (variables) => useQuery(GET_USER, { variables: { ...variables }});前面的查询几乎与
currentUser查询相同。我们将在我们的 GraphQL 应用程序编程接口(API)中稍后实现相应的user查询。 -
最后一步是实现
UserProfileHeader组件。这个组件渲染user属性及其所有值。它只是简单的 HTML 标记。将以下代码复制到user文件夹中的header.js文件:import React from 'react'; export const UserProfileHeader = ({user}) => { const { avatar, username } = user; return ( <div className="profileHeader"> <div className="avatar"> <img src={avatar}/> </div> <div className="information"> <p>{username}</p> <p>You can provide further information here and build your really personal header component for your users.</p> </div> </div> ) } export default UserProfileHeader;
如果你在正确设置层叠样式表(CSS)样式方面需要帮助,请查看这本书的官方仓库。前面的代码仅渲染用户数据;你也可以实现聊天按钮等特性,这样用户就可以选择与其他人开始消息交流。目前,我们还没有在任何地方实现这个特性,但解释 React 和 GraphQL 的原则并不必要。
我们已经完成了新的前端组件,但UserProfile组件仍然没有工作。我们在这里使用的查询要么不接受username参数,要么尚未实现。
下一个部分将涵盖后端需要调整的部分。
查询用户资料
使用新的个人资料页面,我们必须相应地更新我们的后端。让我们看看需要做什么,如下所示:
-
我们必须将
username参数添加到postsFeed查询的模式中,并调整解析函数。 -
我们必须为新的
UserQuery组件创建一个模式和解析函数。
我们将从 postsFeed 查询开始:
-
编辑
schema.js文件中的RootQuery类型的postsFeed查询,以匹配以下代码:postsFeed(page: Int, limit: Int, username: String): PostFeed @auth在这里,我已经将
username添加为一个可选参数。 -
现在,前往
resolvers.js文件并查看相应的resolver函数。将函数签名替换为从变量中提取用户名,如下所示:postsFeed(root, { page, limit, username }, context) { -
为了使用新参数,在
return语句上方添加以下代码行:if(username) { query.include = [{model: User}]; query.where = { '$User.username$': username }; }
我们已经介绍了基本的 Sequelize API 以及如何使用 include 参数在 第三章 中查询关联模型,连接到数据库。一个重要点是,我们如何通过用户名过滤与用户关联的帖子。我们将在以下步骤中这样做:
-
在前面的代码中,我们填充了
query对象的include字段,这是我们想要连接的 Sequelize 模型。这允许我们在下一步中过滤关联的User模型。 -
然后,我们创建一个普通的
where对象,在其中写入过滤条件。如果你想通过关联的用户表来过滤帖子,你可以用美元符号包裹你想要过滤的模型和字段名称。在我们的例子中,我们用美元符号包裹User.username,这告诉 Sequelize 查询User模型的表并通过username列的值进行过滤。
对于分页部分不需要调整。GraphQL 查询现在已准备就绪。我们所做的这些小改动的好处是,我们只有一个接受多个参数的 API 函数,既可以显示单个用户资料上的帖子,也可以显示帖子列表,如新闻源。
让我们继续并实现新的 user 查询:
-
在你的 GraphQL 模式中的
RootQuery类型中添加以下行:user(username: String!): User @auth此查询仅接受
username参数,但这次它是新查询中的必需参数。否则,查询就没有意义,因为我们只有在通过用户名访问用户资料时才使用它。 -
在
resolvers.js文件中,使用 Sequelize 实现解析函数,如下所示:user(root, { username }, context) { return User.findOne({ where: { username: username } }); },在前面的代码片段中,我们使用 Sequelize 的
findOne方法并搜索我们提供的参数中的用户名,以找到恰好一个用户。
现在后台代码和用户页面都已准备就绪,我们必须允许用户导航到这个新页面。下一节将介绍使用 React Router 的用户导航。
React Router 中的编程导航
我们创建了一个带有用户资料的新网站,但现在我们必须为用户提供一个链接来访问它。新闻源和登录及注册表单之间的转换由 React Router 自动化,但新闻源到个人资料页面的转换则不是。用户决定他们是否想查看用户的个人资料。React Router 有多种处理导航的方式。我们将扩展新闻源以处理对用户名或头像图像的点击,以便导航到用户的个人资料页面。打开 post 组件文件夹中的 header.js 文件。导入 React Router 提供的 Link 组件,如下所示:
import { Link } from 'react-router-dom';
Link 组件是围绕常规 HTML a 标签的一个小包装器。显然,在标准网络应用或网站上,超链接后面没有复杂的逻辑;你点击它们,就会从头开始加载一个新页面。使用 React Router 或大多数 单页应用 (SPA) JavaScript (JS) 框架,你可以在超链接后面添加更多逻辑。重要的是,在导航到不同路由之间时,不再完全重新加载页面,这现在由 React Router 处理。导航时不会有完整的页面重新加载;相反,只需交换所需的部件,并运行 GraphQL 查询。这种方法节省了用户昂贵的带宽,因为它意味着我们可以避免再次下载所有的 HTML、CSS 和图像文件。
为了测试这个,将用户名和头像图像包裹在 Link 组件中,如下所示:
<Link to={'/user/'+post.user.username}>
<img src={post.user.avatar} />
<div>
<h2>{post.user.username}</h2>
</div>
</Link>
在渲染的 HTML 中,img 和 div 标签被一个共同的 a 标签包围,但它们在 React Router 内部处理。Link 组件接收一个 to 属性,它是导航的目的地。你必须复制一条新的 CSS 规则,因为 Link 组件已经改变了标记。代码在下面的代码片段中展示:
.post .header a > * {
display: inline-block;
vertical-align: middle;
}
如果你现在测试这些更改,点击用户名或头像图像,你应该会注意到页面内容动态变化,但不会完全重新加载。一个进一步的任务是将这种方法复制到应用程序栏的用户搜索列表和聊天中。目前,用户被显示出来,但没有选项通过点击它们来访问他们的个人资料页面。
现在,让我们看看使用 React Router 导航的另一种方式。如果用户已经到达了个人资料页面,我们希望他们通过点击应用程序栏中的按钮返回。首先,我们将在 bar 文件夹中创建一个新的 home.js 文件,并输入以下代码:
import React from 'react';
import { withRouter } from 'react-router';
const Home = ({ history }) => {
const goHome = () => {
history.push('/app');
}
return (
<button className="goHome" onClick={goHome}>Home
</button>
);
}
export default withRouter(Home);
我们在这里使用了多个 React Router 技术。我们通过 withRouter 高阶组件导出 Home 组件,这给了 Home 组件访问 React Router 的 history 对象的权限。这很棒,因为它意味着我们不需要从 React 树的顶部向下传递这个对象。
此外,我们使用history对象将用户导航到新闻源。在render方法中,我们返回一个按钮,当点击时,运行history.push函数。这个函数将新路径添加到浏览器的历史记录中,并将用户导航到'/app'主页面。好事是它和Link组件的工作方式相同,不会重新加载整个网站。
为了让按钮工作,需要做一些事情,如下所示:
-
将组件导入到
bar文件夹的index.js文件中,如下所示:import Home from './home'; -
然后,将
buttonsdiv标签替换为以下代码行:<div className="buttons"> <Home/> <Logout changeLoginState={changeLoginState}/> </div> -
将两个按钮包裹在一个单独的
div标签中,这样更容易正确地对齐它们。你可以替换旧的 CSS 样式用于注销按钮,并添加以下内容:.topbar .buttons { position: absolute; right: 5px; top: 5px; height: calc(100% - 10px); } .topbar .buttons > * { height: 100%; margin-right: 5px; border: none; border-radius: 5px; }
现在我们已经把所有东西都准备好了,用户可以访问个人资料页面并再次导航。我们的最终结果如下:

图 8.1 – 用户个人资料
我们在窗口底部为用户及其帖子有一个大的个人资料标题。在顶部,你可以看到带有当前登录用户的顶部栏。
记住重定向位置
当访客来到你的页面时,他们可能遵循了在其他地方发布的链接。这个链接很可能是对用户、帖子或其他你提供直接访问内容的直接地址。对于未登录的用户,我们配置了应用程序将那个人重定向到登录或注册表单。这种行为是有意义的。然而,一旦那个人登录或使用新账户注册,他们就会被导航到新闻源。更好的做法是记住那个人最初想要访问的目的地。为了做到这一点,我们将对路由器做一些修改。打开router.js文件。使用 React Router 提供的所有路由组件,我们总是可以访问它们内部的属性。我们将利用这一点并保存我们最后重定向的最后一个位置。
在PrivateRoute组件中,用以下代码替换Redirect组件:
<Redirect to={{
pathname: '/',
state: { from: props.location }
}} />
在这里,我们添加了state字段。它接收的值来自父Route组件,该组件持有由 React Router 生成的props.location字段中的最后一个匹配路径。路径可以是用户的个人资料页面或新闻源,因为两者都依赖于需要身份验证的PrivateRoute组件。当触发前面的重定向时,你会在路由器的状态中接收到from字段。
我们想在用户登录时使用这个变量。将LoginRoute组件中的Redirect组件替换为以下代码行:
<Redirect to={{
pathname: (typeof props.location.state !== typeof
undefined) ?
props.location.state.from.pathname : '/app',
}} />
在这里,我为pathname参数引入了一个小条件。如果location.state属性已定义,我们可以依赖from字段。之前,我们在PrivateRoute组件中存储了重定向路径。如果location.state属性不存在,用户不是直接访问超链接,而是只想正常登录。他们将被导航到带有/app路径的新闻源。
您的应用程序现在应该能够处理所有路由场景,这应该允许您的用户舒适地查看您的网站。
摘要
在本章中,我们从单屏应用过渡到了多页布局。我们用于路由的主要库 React Router 现在有三个路径,在这些路径下我们展示了 Graphbook 的不同部分。此外,我们现在还有一个通配符路由,我们可以将用户重定向到一个有效的页面。
在下一章中,我们将通过实现服务器端渲染来继续这种进步,这需要在前后端进行许多调整。
第九章:实现服务器端渲染
在上一章的进展基础上,我们现在使用我们的 React 应用在不同的路径下服务多个页面。目前,所有路由都在客户端直接进行。在本章中,我们将探讨 服务器端渲染(SSR)的优缺点。到本章结束时,你将配置 Graphbook 以从服务器而不是客户端作为预渲染的 HTML 来服务所有页面。
本章涵盖了以下主题:
-
介绍 SSR
-
在 Express.js 中设置 SSR 以在服务器上渲染 React
-
在 SSR 中启用 JSON Web Token(JWT)认证
-
在 React 树中运行所有我们的 GraphQL 查询
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
介绍 SSR
首先,你必须理解使用服务器端渲染和客户端渲染应用之间的区别。在将纯客户端渲染应用转换为支持服务器端渲染(SSR)时,有许多事情需要考虑。在我们的应用中,当前的用户流程从客户端请求一个标准的 index.html 文件开始。这个文件只包含少量内容,例如一个包含一个 div 元素的少量 body 对象,一个带有一些非常基本的 meta 标签的 head 标签,以及一个至关重要的 script 标签,该标签下载捆绑的 index.html 和 bundle.js 文件。然后,客户端的浏览器开始处理我们编写的 React 标记。当 React 完成代码评估后,我们看到的就是我们想要看到的应用的 HTML。所有 CSS 文件或图像都是从我们的服务器下载的,但只有在 React 将 HTML 插入浏览器 文档对象模型(DOM)之后才会下载。在 React 的渲染过程中,Apollo 组件被执行,所有查询都被发送。当然,这些查询由我们的后端和数据库处理。
与 SSR 相比,客户端方法更为直接。在Angular、Ember、React和其他 JavaScript 框架开发之前,传统的方法是拥有一个后端,它实现了所有的业务逻辑,并且有大量的模板或函数返回有效的 HTML。后端查询数据库,处理数据,并将数据插入到 HTML 中。HTML 直接在客户端请求时提供。然后浏览器根据 HTML 下载 JavaScript、CSS 和图像文件。大多数情况下,JavaScript 只负责允许动态内容或布局变化,而不是渲染整个应用程序。这可能包括下拉菜单、手风琴或只是通过Ajax从后端拉取新数据。然而,应用程序的主要 HTML 直接从后端返回,这导致了一个单体应用程序。这个解决方案的一个显著优点是客户端不需要处理所有的业务逻辑,因为这一切已经在服务器上完成了。
然而,当我们谈论 React 应用程序中的 SSR 时,我们指的是不同的事情。在本书的这一部分,我们已经编写了一个在客户端渲染的 React 应用程序。我们不想以稍微不同的方式重新实现后端的渲染。我们也不希望失去在浏览器中动态更改数据、页面或布局的能力,因为我们已经有一个功能完善的应用程序,它为用户提供了许多交互可能性。
一种允许我们利用预渲染的 HTML 以及 React 提供的动态功能的方法被称为通用渲染。在通用渲染中,客户端的第一个请求包括一个预渲染的 HTML 页面。HTML 应该是客户端在自行处理时生成的确切 HTML。如果是这样,React 可以重用服务器提供的 HTML。由于 SSR 不仅涉及重用 HTML,还涉及节省 Apollo 发出的请求,因此客户端也需要一个 React 可以依赖的起始缓存。服务器在发送渲染的 HTML 之前发出所有请求,并将 Apollo 和 React 的状态变量插入到 HTML 中。结果是,在客户端的第一个请求中,我们的前端不应该需要重新渲染或刷新服务器返回的任何 HTML 或数据。对于所有后续操作,如导航到其他页面或发送消息,之前使用的相同客户端 React 代码仍然适用。换句话说,SSR 仅在第一次页面加载时使用。之后,这些功能不需要 SSR,因为客户端代码将继续像之前一样动态工作。
让我们开始编写一些代码。
在 Express.js 中设置 SSR 以在服务器上渲染 React
在这个例子中,第一步是在后端实现基本的 SSR。我们将在稍后扩展这个功能以验证用户的身份验证。经过身份验证的用户允许我们执行 Apollo 或 GraphQL 请求,而不仅仅是渲染纯 React 标记。首先,我们需要一些新的包。因为我们将使用通用渲染的 React 代码,我们需要一个高级的 webpack 配置。因此,我们将安装以下包:
npm install --save-dev webpack-dev-middleware webpack-hot-middleware @babel/cli
让我们快速浏览一下我们要安装的包。我们只需要这些包进行开发:
-
第一个 webpack 模块,称为
webpack-dev-middleware,允许后端服务由 webpack 生成的包,但仅从内存中生成,而不创建文件。这对于需要直接运行 JavaScript 而不想使用单独文件的情况非常有用。 -
第二个包,称为
webpack-hot-middleware,仅处理客户端更新。如果创建了新的包版本,客户端会收到通知,并交换包。 -
最后一个包,称为
@babel/cli,允许我们引入Babel为我们后端提供的出色功能。我们将使用需要转译的 React 代码。
在生产环境中,不建议使用这些包。相反,在部署应用程序之前,一次性构建包。当应用程序上线时,客户端下载包。
在启用 SSR 的开发中,后端使用这些包在 SSR 完成后将打包的 React 代码分发到客户端。服务器本身依赖于普通的src文件,而不是客户端接收的 webpack 包。
我们还依赖于一个额外的关键包,如下所示:
npm install --save node-fetch
为了设置window.fetch方法。Apollo Client 使用它来发送 GraphQL 请求,这就是为什么我们要安装node-fetch作为 polyfill。我们将在本章后面设置 Apollo Client 以用于后端。
在开始主要工作之前,请确保您的NODE_ENV环境变量设置为development。
然后,转到服务器的index.js文件,所有 Express.js 的魔法都在这里发生。我们之前没有涵盖这个文件,因为我们现在要调整它以支持 SSR,包括直接的路由。
首先,我们将为 SSR 设置开发环境,因为这对我们接下来的任务至关重要。按照以下步骤准备您的开发环境以支持 SSR:
-
第一步是导入两个新的 webpack 模块:
webpack-dev-middleware和webpack-hot-middleware。这些模块应该只在开发环境中使用,因此我们应该通过检查环境变量有条件地引入它们。在生产环境中,我们提前生成 webpack 包。为了只在开发中使用新包,请将以下代码放在 Express.js helmet 设置下方:if(process.env.NODE_ENV === 'development') { const devMiddleware = require('webpack-dev-middleware'); const hotMiddleware = require('webpack-hot-middleware'); const webpack = require('webpack'); const config = require('../../webpack.server.config'); const compiler = webpack(config); app.use(devMiddleware(compiler)); app.use(hotMiddleware(compiler)); } -
在加载这些包之后,我们还将需要 webpack,因为我们将解析一个新的 webpack 配置文件。新的配置文件仅用于 SSR。
-
在加载 webpack 和配置文件之后,我们将使用
webpack(config)命令解析配置并创建一个新的 webpack 实例。 -
接下来,我们将创建 webpack 配置文件。为此,我们将创建的 webpack 实例传递给我们的两个新模块。当一个请求到达服务器时,这两个包将根据配置文件采取行动。
与原始配置文件相比,新的配置文件只有几个小的差异,但这些差异影响很大。创建新的webpack.server.config.js文件,并输入以下配置:
const path = require('path');
const webpack = require('webpack');
const buildDirectory = 'dist';
module.exports = {
mode: 'development',
entry: [
'webpack-hot-middleware/client',
'./src/client/index.js'
],
output: {
path: path.join(__dirname, buildDirectory),
filename: 'bundle.js',
publicPath: '/'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|woff|woff2|eot|ttf|svg)$/,
loader: 'url-loader?limit=100000',
},
],
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(),
],
};
与原始的webpack.client.config.js文件相比,我们在前面的配置中做了三项更改,具体如下:
-
在
entry属性中,我们现在有多个入口点。前端代码的index文件,如之前一样,是一个入口点。第二个入口点是新的webpack-hot-middleware模块,它启动客户端和服务器之间的连接。这个连接用于发送客户端通知,以更新到新版本的 bundle。 -
我已经移除了
devServer字段,因为这个配置不需要 webpack 启动自己的服务器。Express.js 是我们要使用的 web 服务器,当加载配置时我们已经在使用它了。 -
插件与客户端的 webpack 配置中的插件完全不同。我们不需要
CleanWebpackPlugin,因为它会清理dist文件夹,也不需要HtmlWebpackPlugin插件,该插件会将 webpack 打包文件插入到index.html文件中;这由服务器以不同的方式处理。这些插件仅适用于客户端开发。现在,我们有HotModuleReplacementPlugin,它启用了NamedModulesPlugin,显示由 HMR 注入的模块的相对路径。这两个插件仅推荐在开发中使用。
webpack 的准备工作现在已完成。
现在,我们必须关注如何渲染 React 代码以及如何服务生成的 HTML。然而,我们不能使用我们已编写的现有 React 代码。首先,我们必须对主文件进行特定的调整:index.js、App.js、router.js和apollo/index.js。我们使用的许多包,如React Router或 Apollo Client,都有默认设置或模块,当它们在服务器上执行时,我们必须进行不同的配置。
我们将从 React 应用程序的根目录开始,即index.js文件。我们将实现一个单独的 SSR index文件,因为需要进行特定的服务器调整。
在server文件夹内创建一个名为ssr的新文件夹。然后,将以下代码插入到ssr文件夹内的index.js文件中:
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import App from './app';
const ServerClient = ({ client, location, context }) => {
return(
<ApolloProvider client={client}>
<App location={location} context={context}/>
</ApolloProvider>
);
}
export default ServerClient
上述代码是我们客户端 index.js 根文件的修改版本。该文件所经历的变化如下列所示:
-
我们现在不再使用
ReactDOM.render函数将 HTML 插入具有rootID 的 DOMNode 中,而是导出一个 React 组件。返回的组件被称为ServerClient。我们没有可以访问的 DOM 来让ReactDOM渲染任何内容,所以在服务器端渲染时我们跳过这一步。 -
ApolloProvider组件现在直接从ServerClient属性接收 Apollo Client,而之前我们是直接在这个文件内部通过从apollo文件夹导入index.js文件并将其传递给提供者来设置 Apollo Client。你很快就会看到我们为什么要这样做。 -
我们所做的最后一个变化是提取了一个
location和一个context属性。我们将这些属性传递给App组件。在原始版本中,没有向App组件传递任何属性。这两个属性都是配置 React Router 以与 SSR 一起工作所必需的。我们将在本章后面实现这些属性。
在更详细地查看我们为什么要进行这些更改之前,让我们为后端创建一个新的 App 组件。在 ssr 文件夹中 index.js 文件旁边创建一个 app.js 文件,并插入以下代码:
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { withApollo } from '@apollo/client/react/hoc';
import Router from '../../client/router';
import { useCurrentUserQuery } from '../../client/apollo/queries/currentUserQuery';
import '../../client/components/fontawesome';
const App = ({ location, context }) => {
const { data, loading, error } = useCurrentUserQuery();
const [loggedIn, setLoggedIn] = useState(false);
return (
<div className="container">
<Helmet>
<title>Graphbook - Feed</title>
<meta name="description" content="Newsfeed of all
your friends on Graphbook" />
</Helmet>
<Router loggedIn={loggedIn}
changeLoginState={setLoggedIn} location={location}
context={context} />
</div>
)
}
export default withApollo(App)
以下是我们所做的几个更改:
-
与原始的客户端
App组件相比,第一个变化是调整了import语句,以便从client文件夹中加载路由器和fontawesome组件,因为它们不存在于server文件夹中。 -
第二个变化是移除了
useEffect钩子和localStorage访问。我们这样做是因为我们构建的认证使用了localStorage访问。这对于客户端认证来说是可行的。这两个useEffect钩子仅在客户端调用。这就是为什么我们在将我们的应用程序移动到 SSR 时移除认证。我们将在稍后的步骤中将localStorage实现替换为 cookies。目前,用户保持从服务器端注销状态。 -
最后一个变化是将两个新属性
context和location传递给前面代码中的Router组件。
React Router 提供了对 SSR 的即时支持。尽管如此,我们仍需要进行一些调整。最好的方式是我们在后端和前端使用相同的路由器,这样我们就不需要定义两次路由,这既低效又可能导致问题。打开 client 文件夹内的 router.js 文件,按照以下步骤操作:
-
将
react-router-dom包的import语句更改为以下形式:import { BrowserRouter, StaticRouter, Route, Redirect, Switch } from 'react-router-dom'; -
插入以下代码以提取正确的路由:
let Router; if(typeof window !== typeof undefined) { Router = BrowserRouter; } else { Router = StaticRouter; }在导入 React Router 包后,我们通过查找
window对象来检查文件是在服务器端还是客户端执行。由于 Node.js 中没有window对象,这是一个足够的检查。另一种方法是在一个单独的文件中设置Switch组件,包括路由。这种方法允许我们在为客户端和服务器端渲染创建两个单独的路由器文件时,直接将路由导入到正确的路由器中。如果我们在客户端,我们使用
BrowserRouter,如果不是,我们使用StaticRouter。在这里,逻辑是,使用StaticRouter时,我们处于一个无状态的环境,其中我们使用固定的位置渲染所有路由。StaticRouter组件不允许通过重定向更改位置,因为在使用 SSR 时无法发生用户交互。其他组件Route、Redirect和Switch可以像以前一样使用。无论提取哪个路由器,我们都会将它们保存在
Router变量中。然后我们在routing组件的返回语句中使用它们。 -
我们准备了
context和location属性,这些属性从顶层的ServerClient组件传递给Router变量。如果我们处于服务器端,这些属性应该被填充,因为StaticRouter对象需要它们。你可以在底部的Routing组件中替换Router标签,如下所示:<Router context={this.props.context} location={this.props.location}>location对象包含路由应该渲染的路径。context变量存储Router组件处理的所有信息,例如重定向。我们可以在渲染Router组件后检查这个变量,以手动触发重定向。这是BrowserRouter和StaticRouter之间的一大区别。在前一种情况下,BrowserRouter会自动重定向用户,但StaticRouter不会。
成功渲染我们的 React 代码的关键组件现在已经准备好了。然而,还有一些模块在我们使用 React 渲染任何内容之前必须初始化。再次打开index.js服务器文件。目前,我们正在为http://localhost:8000根路径上的dist路径提供静态服务。当我们转向 SSR 时,我们必须在/路径上提供由我们的 React 应用程序生成的 HTML。
此外,任何其他路径,如/app,也应该使用 SSR 在服务器上渲染这些路径。删除文件底部的当前app.get方法,该方法位于app.listen方法之前。然后,插入以下代码作为替代:
app.use('/', express.static(path.join(root, 'dist/client'), { index: false }));
app.get('*', (req, res) => {
res.status(200);
res.send('<!doctype html>');
res.end();
});
代码的第一行应该替换旧的静态路由。它引入了一个名为index的新选项,这将禁用在根路径上提供index.html文件。
我们在先前的代码中使用星号 (*) 可以覆盖 Express.js 路由中定义的任何路径。始终记住,我们在 Express.js 中使用的 services 例程可以实现新的路径,例如 /graphql,我们不希望覆盖。为了避免这种情况,将代码放在文件的底部,在 services 设置下方。该路由捕获发送到后端的任何请求。
您可以通过运行 npm run server 命令来尝试此路由。只需访问 http://localhost:8000 即可。
目前,前面的通配符路由仅返回一个空的站点,状态为 200。让我们改变这一点。逻辑步骤将是加载并渲染 ssr 文件夹中的 index.js 文件中的 ServerClient 组件,因为它是 React SSR 代码的起点。然而,ServerClient 组件需要一个初始化的 Apollo Client 实例,正如我们之前解释的那样。我们将为 SSR 创建一个特殊的 Apollo Client 实例。
创建一个 ssr/apollo.js 文件,因为它还不存在。我们将在该文件中设置 Apollo Client。内容几乎与客户端原始设置相同:
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { ApolloLink } from 'apollo-link';
import { HttpLink } from 'apollo-link-http';
import fetch from 'node-fetch';
export default (req) => {
const AuthLink = (operation, next) => {
return next(operation);
};
const client = new ApolloClient({
ssrMode: true,
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path,
extensions }) => {
console.log('[GraphQL error]: Message:
${message},
Location: ${locations}, Path: ${path}');
});
if (networkError) {
console.log('[Network error]:
${networkError}');
}
}
}),
AuthLink,
new HttpLink({
uri: 'http://localhost:8000/graphql',
credentials: 'same-origin',
fetch
})
]),
cache: new InMemoryCache(),
});
return client;
};
然而,我们进行了一些更改,以便在服务器上使客户端工作。这些更改相当大,因此我们为服务器端 Apollo Client 设置创建了一个单独的文件。查看以下更改(如下)以了解前端和 SSR 设置之间的差异:
-
我们不再使用我们之前引入的
createUploadLink函数来允许用户上传图片或其他文件,而是再次使用标准的HttpLink类。您可以使用UploadClient函数,但它在服务器上提供的功能将不会使用,因为服务器不会上传文件。 -
AuthLink函数跳转到下一个链接,因为我们尚未实现服务器端身份验证。 -
HttpLink对象接收fetch属性,该属性由我们在本章开头安装的node-fetch包填充。这用于替代在 Node.js 中不可用的window.fetch方法。 -
我们不是直接导出
client对象,而是导出一个包装函数,它接受一个request对象。我们将其作为参数传递给 Express.js 路由。如您在先前的代码示例中看到的,我们尚未使用该对象,但很快就会改变。
在服务器 index.js 文件顶部导入 ApolloClient 组件,如下所示:
import ApolloClient from './ssr/apollo';
导入的 ApolloClient 函数接受我们的 Express.js 服务器的 request 对象。
在新的 Express.js 通配符路由顶部添加以下行:
const client = ApolloClient(req);
这样,我们就设置了一个新的 client 实例,我们可以将其传递给我们的 ServerClient 组件。
我们可以继续并实现 ServerClient 组件的渲染。为了使未来的代码工作,我们必须加载 React 和当然,ServerClient 组件本身:
import React from 'react';
import Graphbook from './ssr/';
ServerClient 组件以 Graphbook 的名称导入。我们导入 React 是因为我们使用标准的 JSX 语法来渲染我们的 React 代码。
现在我们已经可以访问 Apollo Client 和 ServerClient 组件,在 Express.js 路由中的 ApolloClient 设置下方插入以下两行代码:
const context= {};
const App = (<Graphbook client={client} location={req.url}
context= {context}/>);
我们将初始化的 client 变量传递给 Graphbook 组件。我们使用常规的 React 语法传递所有属性。此外,我们将 location 属性设置为请求对象的 url 对象,以告诉路由器要渲染哪个路径。context 属性被传递为一个空对象。
然而,为什么我们在最后将一个空对象作为 context 传递给路由器呢?
原因是,在将 Graphbook 组件渲染成 HTML 之后,我们可以访问 context 对象并查看是否通常会触发重定向(或其他操作)。正如我们之前提到的,重定向必须由后端代码实现。React Router 的 StaticRouter 组件不会对您使用的 Node.js 网络服务器做出假设。这就是为什么 StaticRouter 不会自动执行它们。使用 context 变量跟踪和后处理这些事件是可能的。
生成的 React 对象被保存到一个新的变量中,该变量被命名为 App。现在,如果你使用 npm run server 启动服务器并访问 http://localhost:8000,应该不会出现任何错误。然而,我们仍然看到一个空页面。这是因为我们只返回了一个空的 HTML 页面;我们还没有将 React 的 App 对象渲染成 HTML。要将对象渲染成 HTML,请在服务器 index.js 文件的顶部导入以下包:
import ReactDOM from 'react-dom/server';
react-dom 包不仅为浏览器提供了绑定,还提供了一个专门用于服务器的模块,这就是为什么我们在导入它时使用 /server 后缀。返回的模块提供了一系列仅适用于服务器的函数。
注意
要了解一些更高级的 SSR 特性和其背后的动态,你应该阅读 react-dom 服务器包的官方文档,请参阅 reactjs.org/docs/react-dom-server.html。
我们可以通过使用 ReactDOM.rendertoString 函数将 React 的 App 对象转换成 HTML。在 App 对象下方插入以下代码行:
const content = ReactDOM.renderToString(App);
此函数生成 HTML 并将其存储在 content 变量中。现在可以将 HTML 返回给客户端。如果您从服务器返回预渲染的 HTML,客户端会遍历它并检查其当前状态是否与返回的 HTML 匹配。比较是通过识别 HTML 中的某些点来进行的,例如 data-reactroot 属性。
如果在任何时候,服务器渲染的 HTML 和客户端将生成的 HTML 之间的标记不匹配,则会抛出错误。应用程序仍然可以工作,但客户端将无法使用 SSR;客户端将用重新渲染的一切替换从服务器返回的完整标记。在这种情况下,服务器的 HTML 响应被丢弃。这当然是非常低效的,并不是我们想要的结果。
我们必须将渲染的 HTML 返回给客户端。我们渲染的 HTML 以根div标签开始,而不是以html标签开始。我们必须将content变量包裹在一个包含周围 HTML 标签的模板中。因此,在ssr文件夹内创建一个template.js文件,并输入以下代码以实现我们渲染的 HTML 的模板:
import React from 'react';
import ReactDOM from 'react-dom/server';
const htmlTemplate = (content) => {
return '
<html lang="en">
<head>
<meta charSet="UTF-8"/>
<meta name="viewport" content="width=device-width,
initial-scale=1.0"/>
<meta httpEquiv="X-UA-Compatible"
content="ie=edge"/>
<link rel="shortcut icon"
href="data:image/x-icon;," type="image/x-icon">
${(process.env.NODE_ENV === 'development')? "":
"<link rel='stylesheet' href='/bundle.css'/>"}
</head>
<body>
${ReactDOM.renderToStaticMarkup(<div id="root"
dangerouslySetInnerHTML={{ __html: content
}}></div>)}
<script src="img/bundle.js"></script>
</body>
</html>
';
};
export default htmlTemplate;
上述代码基本上与通常提供给客户端的index.html文件中的 HTML 标记相同。区别在于这里我们使用了 React 和ReactDOM。
首先,我们导出一个函数,该函数接受带有渲染后的 HTML 的content变量。
然后,我们在head标签内渲染一个link标签,如果我们在生产环境中,这个标签会下载 CSS 包。对于我们的当前开发场景,没有打包的 CSS。
重要的是,我们在body标签内使用了一个新的ReactDOM函数,名为rendertoStaticMarkup。这个函数将 React 的root标签插入到我们的 HTML 模板的 body 中。之前,我们使用的是renderToString方法,它包含了特殊的 React 标签,例如data-reactroot属性。现在,我们使用rendertoStaticMarkup函数生成没有特殊 React 标签的标准 HTML。我们传递给函数的唯一参数是带有root ID 的div标签和一个新属性dangerouslySetInnerHTML。这个属性是常规innerHTML属性的替代品,但用于 React。它允许 React 在根div标签内插入 HTML。正如其名所示,这样做是有风险的,但只有在客户端这样做时才有风险,因为ReactDOM.renderToStaticMarkup函数无法使用这个属性。插入的 HTML 最初是用renderToString函数渲染的,以便包含所有的关键 React HTML 属性和带有root ID 的包装div标签。然后,它可以在浏览器中被前端代码无问题地重用。
我们需要在服务器index文件中引入这个template.js文件,文件顶部如下:
import template from './ssr/template';
模板函数现在可以直接在res.send方法中使用,如下所示:
res.send('<!doctype html>\n${template(content)}');
我们现在不仅返回一个doctype对象,还响应template函数的返回值。正如您应该看到的,template函数接受作为参数的渲染后的content变量并将其组合成一个有效的 HTML 文档。
到目前为止,我们已经成功使我们的第一个服务器端渲染的 React 应用程序版本工作。你可以通过在浏览器窗口中右键单击并选择查看源代码来证明这一点。窗口显示服务器返回的原始 HTML。输出等于template函数的 HTML,包括登录和注册表单。
尽管如此,我们面临两个问题:
-
服务器响应中没有包含描述性 meta
head标签。ReactHelmet肯定出了些问题。 -
当在客户端登录,例如在
/app路径下查看新闻源时,服务器响应没有渲染新闻源或登录表单。通常,React Router 会重定向我们到登录表单,因为我们没有在服务器端登录。然而,由于我们使用了StaticRouter,我们必须单独发起重定向,正如我们之前解释的那样。我们将分步骤实现身份验证。
我们将从第一个问题开始。要修复 React Helmet的问题,在服务器index.js文件的顶部导入它,如下所示:
import { Helmet } from 'react-helmet';
现在,在设置响应状态res.status之前,你可以提取 React Helmet的状态,如下所示:
const head = Helmet.renderStatic();
renderStatic方法专门用于 SSR。我们可以在使用renderToString函数渲染 React 应用程序后使用它。它给我们提供了在整个代码中插入的所有head标签。将这个head变量作为第二个参数传递给template函数,如下所示:
res.send('<!doctype html>\n${template(content, head)}');
返回到ssr文件夹中的template.js文件。将head参数添加到导出函数的签名中。然后,在 HTML 的head标签中添加以下两行新代码:
${head.title.toString()}
${head.meta.toString()}
从 React Helmet提取的head变量为每个meta标签都持有属性。这些标签提供了一个toString函数,它返回一个有效的 HTML 标签,你可以直接将其输入到文档的head对象中。第一个问题应该得到修复:现在所有的head标签都包含在服务器的 HTML 响应中。
让我们专注于第二个问题。当访问PrivateRoute组件时,服务器响应返回一个空的 React root标签。正如我们之前解释的那样,这是因为自然发起的重定向没有到达我们这里,因为我们使用了StaticRouter。由于服务器端渲染的代码没有实现身份验证,所以我们被重定向离开了PrivateRoute组件。首先需要修复的是处理重定向,我们至少应该响应登录表单,而不是一个空的 React root标签。稍后,我们需要修复身份验证问题。
如果不查看服务器响应的源代码,你不会注意到这个问题。前端下载bundle.js文件并自行触发渲染,因为它知道用户的身份验证状态。用户不会注意到这一点。然而,如果服务器直接发送正确的 HTML,这将更加高效。如果用户已登录,HTML 将是错误的,但在未认证用户的情况下,登录表单是由服务器预先渲染的,因为它启动了重定向。
为了解决这个问题,我们可以在 React Router 使用renderToString函数后访问已经被填充的context对象。最终的 Express.js 路由应该看起来像以下代码示例:
app.get('*', (req, res) => {
const client = ApolloClient(req);
const context= {};
const App = (<Graphbook client={client}
location={req.url} context= {context}/>);
const content = ReactDOM.renderToString(App);
if (context.url) {
res.redirect(301, context.url);
} else {
const head = Helmet.renderStatic();
res.status(200);
res.send('<!doctype html>\n${template(content,
head)}');
res.end();
}
});
在服务器上渲染正确路由的条件是检查context.url属性。如果它被填充,我们可以使用 Express.js 启动重定向。这将导航浏览器到正确的路径。如果属性没有被填充,我们可以返回 React 生成的 HTML。
此路由正确渲染了 React 代码,直到需要身份验证的点。SSR 路由正确渲染了所有公共路由,但没有渲染任何安全路由。这意味着我们现在只响应登录表单,因为它是唯一不需要身份验证的路由。
下一步是在与 SSR 结合的情况下实现身份验证,以解决这个问题。
使用 SSR 进行身份验证
你应该已经注意到,我们已经从服务器端的 React 代码中移除了大部分身份验证逻辑。这样做的原因是localStorage不能在页面初始加载时传输到服务器,这是 SSR 可以使用的唯一情况。这导致的问题是我们不能渲染正确的路由,因为我们不能验证用户是否已登录。身份验证必须转移到与每个请求一起发送的 cookies 上。
重要的是要理解,cookies 也会引入一些安全问题。我们将继续使用我们编写的 GraphQL API 的常规 HTTP 授权头。如果我们为 GraphQL API 使用 cookies,我们将使我们的应用程序容易受到潜在的跨站请求伪造(CSRF)攻击。前端代码继续使用 HTTP 授权头发送所有 GraphQL 请求。
我们将只使用 cookies 来验证用户的身份验证状态,并初始化对我们的 GraphQL API 的 SSR 请求。SSR GraphQL 请求将在 HTTP 授权头中包含授权 cookie 的值。我们的 GraphQL API 只读取和验证这个头,不接受 cookies。只要你在加载页面时没有修改数据,并且只查询要渲染的数据,就不会存在安全问题。
小贴士
由于 CSRF 和 XSS 是一个重要的主题,我建议你阅读相关内容,以便全面了解如何保护自己和用户。你可以在www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)找到一篇优秀的文章。
因此,只需按照以下说明来在 SSR 上实现身份验证:
-
首件事是使用
npm安装一个新的包,如下所示:cookies package allows us to easily interact through the Express.js request object with the cookies sent by the browser. Instead of manually parsing and reading through the cookie string (which is just a comma-separated list), you can access the cookies with simple get and set methods. To get this package working, you have to initialize it inside Express.js. -
导入
cookies和jwt包,并从服务器index.js文件顶部的环境变量中提取JWT_SECRET字符串:import Cookies from 'cookies'; import JWT from 'jsonwebtoken'; const { JWT_SECRET } = process.env;要使用
cookies包,我们需要设置一个新的中间件路由。 -
在初始化 webpack 模块和服务流程之前插入以下代码:
app.use( (req, res, next) => { const options = { keys: ['Some random keys'] }; req.cookies = new Cookies(req, res, options); next(); } );这个新的 Express.js 中间件为它处理的每个请求在
req.cookies属性下初始化cookies包。Cookies构造函数的第一个参数是请求,第二个是响应对象,最后一个是一个options参数。这个参数接受一个keys数组,用于对 cookies 进行签名。如果你出于安全原因想要对 cookies 进行签名,则这些键是必需的。在生产环境中,你应该注意这一点。你也可以指定一个secure属性,这确保了 cookies 只会在安全的 HTTPS 连接上传输。 -
我们现在可以提取
authorizationcookie 并验证用户的身份验证。为此,将服务器index.js文件中 SSR 路由的开始部分替换为以下代码:app.get('*', async (req, res) => { const token = req.cookies.get('authorization', { signed: true }); var loggedIn; try { await JWT.verify(token, JWT_SECRET); loggedIn = true; } catch(e) { loggedIn = false; }在这里,我已将
async声明添加到回调函数中,因为我们在这个函数内部使用了await语句。第二步是从请求对象中提取authorizationcookie,使用req.cookies.get。重要的是,我们在options参数中指定了signed字段,因为只有这样它才能成功返回签名的 cookies。提取的值代表我们在用户登录时生成的 JWT。我们可以通过我们在第六章中实现的典型方法来验证它,即使用 Apollo 和 React 进行身份验证。也就是说,我们在验证 JWT 时使用
await语句。如果抛出错误,则用户未登录。状态保存在loggedIn变量中。 -
将
loggedIn变量传递给Graphbook组件,如下所示:const App = (<Graphbook client={client} loggedIn={loggedIn} location={req.url} context={context}/>);现在,我们可以从
ssr文件夹中的index.js文件访问loggedIn属性。 -
从属性中提取
loggedIn状态,并将其传递给ssr文件夹的index.js文件中的App组件,如下所示:<App location={location} context={context} loggedIn={loggedIn}/>在
App组件内部,我们不需要直接将loggedIn状态设置为false,但我们可以获取属性值,因为它在App组件渲染之前就已经确定了。这种流程与客户端流程不同,在客户端流程中,loggedIn状态是在App组件内部确定的。 -
修改
app.js文件中的App组件,以匹配以下代码:const App = ({ location, context, loggedIn: loggedInProp }) => { const { data, loading, error } = useCurrentUserQuery(); const [loggedIn, setLoggedIn] = useState(loggedInProp);在这里,结果是我们将
loggedIn值从我们的 Express.js 路由传递到Graphbook和App组件,再到我们的Router组件。这已经接受loggedIn属性以渲染用户的正确路径。目前,我们还没有在用户成功登录时在后端设置 cookie。 -
打开我们的 GraphQL 服务器的
resolvers.js文件以修复此问题。我们将为login和signup函数更改几行。由于两个解析函数在登录或注册后都需要设置认证令牌,因此它们需要相同的更改。所以,直接在返回语句上方插入以下代码:context.cookies.set( 'authorization', token, { signed: true, expires: expirationDate, httpOnly: true, secure: false, sameSite: 'strict' } );上述函数为用户的浏览器设置 cookie。上下文对象仅是 Express.js 的
request对象,其中我们初始化了 cookies 包。cookies.set函数的属性相当直观,但让我们如下描述它们:a.
signed字段指定在初始化cookies对象时输入的密钥是否应该用于签名 cookie 的值。b.
expires属性接受一个date对象。它表示 cookie 有效的截止时间。您可以设置属性为任何您想要的日期,但我建议设置一个较短的时间,例如一天。在context.cookies.set语句上方插入以下代码,以正确初始化expirationDate变量:const cookieExpiration = 1; const expirationDate = new Date(); expirationDate.setDate( expirationDate.getDate() + cookieExpiration );c.
httpOnly字段确保 cookie 不会被客户端 JavaScript 访问。d.
secure属性与初始化Cookie包时的含义相同。它将 cookie 限制为仅 SSL 连接。在上线时这是必须的,但在开发时不能使用,因为大多数开发者都是在本地开发,没有 SSL 证书。e.
sameSite字段可以取strict或lax作为值。我建议将其设置为strict,因为您希望您的 GraphQL API 或服务器在每次请求时都接收 cookie,但您也希望排除所有跨站请求,因为这可能是危险的。 -
现在,我们应该清理我们的代码。由于我们正在使用 cookies,我们可以从前端代码中移除
localStorage认证流程。在client文件夹中打开App.js文件。移除componentWillMount方法,因为我们从localStorage中读取数据。cookies 会自动与任何请求一起发送,并且不需要像
localStorage那样的单独绑定。这也意味着我们需要一个特殊的logout突变来从浏览器中删除 cookie。JavaScript 无法访问或删除 cookie,因为我们将其指定为httpOnly。只有服务器才能从客户端删除它。 -
在
mutations文件夹内创建一个新的logout.js文件,以便创建一个logout突变钩子。内容应如下所示:import { gql, useMutation } from '@apollo/client'; export const LOGOUT = gql' mutation logout { logout { success } } '; export const useLogoutMutation = () => useMutation(LOGOUT);之前的功能钩子仅发送一个简单的
logout变异,没有任何参数或进一步逻辑。 -
我们应该使用
bar文件夹中logout.js文件内的函数来发送 GraphQL 请求。在文件顶部导入组件,如下所示:import { useLogoutMutation } from '../../apollo/mutations/logout'; -
将
logout方法替换为以下代码,以便在点击logoutMutation函数时发送变异。这会将 GraphQL 请求发送到我们的服务器。 -
要在
schema.js中的 GraphQLRootMutation类型上实现变异,请向后台添加一行代码:logout: Response @auth需要确保尝试注销的用户已被授权,因此我们使用
@auth指令。 -
相应的解析函数如下。将其添加到
resolvers.js文件中的RootMutation属性:logout(root, params, context) { context.cookies.set( 'authorization', '', { signed: true, expires: new Date(), httpOnly: true, secure: false, sameSite: 'strict' } ); return { message: true }; },解析函数是最小的。它通过将过期日期设置为当前时间来删除 cookie。当浏览器收到响应时,由于此时已过期,它会在客户端删除 cookie。与
localStorage相比,这种行为是一个优点。
我们已经完成了所有工作,使授权与 SSR 一起工作。这是一个非常复杂的任务,因为授权、SSR 和 CSR 对整个应用程序都有影响。每个框架都有自己的方法来实现这个功能,所以请也查看它们。
如果您查看渲染后从我们的服务器返回的源代码,您应该看到登录表单被正确返回,就像之前一样。此外,服务器现在能够识别用户是否已登录。然而,服务器尚未返回渲染的新闻源、应用栏或聊天。返回的 HTML 中只包含一个加载消息。客户端代码也没有识别出用户已登录。我们将在下一节中查看这些问题。
使用 SSR 运行 Apollo 查询
通过本质,GraphQL 查询通过 HttpLink 是异步的。我们已经实现了一个 loading 组件,在数据正在获取时向用户显示加载消息。
这与我们在服务器上渲染 React 代码时发生的情况相同。所有路由都被评估,包括我们是否已登录。如果找到正确的路由,所有 GraphQL 请求都会发送。问题是第一次渲染 React 返回加载状态,由我们的服务器发送到客户端。服务器不会等待 GraphQL 查询完成并收到所有响应,然后渲染我们的 React 代码。
我们现在将解决这个问题。以下是我们必须做的事情列表:
-
我们需要实现 SSR Apollo 客户端实例的认证。我们已经在路由中做到了这一点,但现在,我们需要将 cookie 传递到服务器端的 GraphQL 请求中。
-
我们需要使用 React Apollo 特定的方法来异步渲染 React 代码,以便等待所有 GraphQL 请求的响应。
-
重要的是,我们需要将 Apollo 缓存状态返回给客户端。否则,客户端将重新获取所有内容,因为它的状态在页面首次加载时是空的。
让我们开始吧,如下所示:
-
第一步是将 Express.js SSR 路由中的
loggedIn变量传递给ApolloClient函数作为第二个参数。将服务器index.js文件中的ApolloClient调用修改为以下代码:const client = ApolloClient(req, loggedIn);将从
apollo.js文件导出的函数的签名修改为也包括这个第二个参数。 -
用以下代码替换 Apollo Client 设置中的
AuthLink函数:const AuthLink = (operation, next) => { if(loggedIn) { operation.setContext(context => ({ ...context, headers: { ...context.headers, Authorization: req.cookies.get('authorization') }, })); } return next(operation) };这个
AuthLink通过使用 Express.js 提供的request对象将 cookie 添加到 GraphQL 请求中。request对象已经包含了初始化的 cookie 包,我们使用它来提取授权 cookie。这只有在用户之前已经验证为登录状态时才需要执行。 -
在服务器的
index.js文件中导入 Apollo 包中的一个新函数。用以下代码替换对ReactDOM包的导入:import { renderToStringWithData } from "@apollo/client/react/ssr"; -
最初,我们使用
ReactDOM服务器方法将 React 代码渲染为 HTML。这些函数是同步的;这就是为什么 GraphQL 请求没有完成。为了等待所有 GraphQL 请求,替换服务器index.js文件中从rendertoString函数开始直到 SSR 路由结束的所有行。结果应该如下所示:renderToStringWithData(App).then((content) => { if (context.url) { res.redirect(301, context.url); } else { const head = Helmet.renderStatic(); res.status(200); res.send('<!doctype html>\n${template(content, head)}'); res.end(); } });renderToStringWithData函数渲染 React 代码,包括通过 Apollo 请求接收到的数据。由于该方法异步,我们将其余代码包裹在一个回调函数中。现在,如果你查看服务器返回的 HTML,你应该看到正确的标记,包括聊天、图片以及其他所有内容。问题是客户端不知道所有的 HTML 已经存在并且可以被重用。客户端将重新渲染一切。
-
为了让客户端能够重用我们服务器发送的 HTML,我们必须将 Apollo Client 的状态包含在我们的响应中。在先前的回调函数内部,通过插入以下代码来访问 Apollo Client 的状态:
const initialState = client.extract();client.extract方法返回一个包含客户端使用renderToStringWithData函数后存储的所有缓存信息的大对象。 -
状态必须作为第三个参数传递给
template函数。因此,将res.send调用修改为以下代码:res.send('<!doctype html>\n${template(content, head, initialState)}'); -
在
template.js文件中,扩展函数声明并在head变量之后追加state变量作为第三个参数。 -
在 HTML body 中
bundle.js文件上方插入state变量,使用以下代码。如果你将它添加到bundle.js文件下方,它将无法正确工作:${ReactDOM.renderToStaticMarkup(<script dangerouslySetInnerHTML= {{__html: 'window.__APOLLO_STATE__=${JSON.stringify(state).replace (/</g, '\\u003c')}'}}/>)}我们使用
renderToStaticMarkup函数插入另一个script标签。它将一个大型、字符串化的 JSON 对象设置为 Apollo Client 的起始缓存值。该 JSON 对象包含在渲染我们的服务器端 React 应用程序时返回的所有 GraphQL 请求的结果。我们直接将 JSON 对象作为字符串存储在window对象的新字段中。window对象很有用,因为您可以直接全局访问该字段。 -
Apollo 必须了解状态变量。它可以被 Apollo Client 用来用指定数据初始化其缓存,而不是必须再次发送所有 GraphQL 请求。打开客户端
apollo文件夹中的index.js文件。初始化过程的最后一个属性是缓存。我们需要将我们的__APOLLO_STATE__实例设置为缓存的起始值。将cache属性替换为以下代码:cache: new InMemoryCache().restore(window.__APOLLO_STATE__)我们创建了
InMemoryCache实例并运行其restore方法,其中我们插入来自窗口对象的价值。Apollo Client 应该从该变量重新创建其缓存。 -
我们现在已经为 Apollo 设置了缓存。它将不再运行已经存在结果的不必要请求。现在,我们最终可以重用 HTML,只需进行最后一次更改。我们必须在客户端的
index.js文件中将ReactDOM.render更改为ReactDOM.hydrate。这两个函数之间的区别在于,如果我们的服务器正确渲染了 HTML,React 会重用该 HTML。在这种情况下,React 仅附加一些必要的事件监听器。如果您使用ReactDOM.render方法,它将显著减慢初始渲染过程,因为它会将初始 DOM 与当前 DOM 进行比较,并根据需要进行更改。
我们遇到最后一个问题是客户端代码在刷新页面后不显示应用程序的登录状态。服务器返回了包含所有数据的正确标记,但前端将我们重定向到登录表单。这是因为我们在客户端代码的App.js文件中静态地将loggedIn状态变量设置为false。
检查用户是否认证的最佳方式是验证窗口对象上的__APOLLO_STATE__字段是否被填充并且附加了一个currentUser对象。如果是这样,我们可以假设用户能够检索到自己的数据记录,所以他们必须已经登录。为了相应地更改我们的App.js文件,向loggedIn状态变量添加以下条件:
(typeof window.__APOLLO_STATE__ !== typeof undefined && typeof window.__APOLLO_STATE__.ROOT_QUERY !== typeof undefined && typeof window.__APOLLO_STATE__.ROOT_QUERY.currentUser !== typeof undefined)
如您在前面的代码中所见,我们验证 Apollo 启动缓存变量是否包含一个带有currentUser子字段的ROOT_QUERY属性。如果任何查询可以成功检索,则ROOT_QUERY属性会被填充。只有当认证用户成功请求时,currentUser字段才会被填充。
如果你执行npm run server,你会看到现在一切运行得非常完美。看看返回的标记;你会看到登录表单,或者当你登录时,你访问的页面的全部内容。你可以在客户端登录,新闻源会动态获取,你可以刷新页面,所有的帖子都会直接显示,无需进行单个 GraphQL 请求,因为服务器已经将数据与 HTML 一起返回。这不仅适用于/app路径,也适用于你实现的任何路径。
我们现在已经完成了 SSR 的设置。
到目前为止,我们只看了 SSR 的开发部分。当我们到达想要制作生产构建并发布我们的应用的时候,我们还需要考虑一些其他的事情,这些内容我们将在第十二章,使用 CircleCI 和 AWS 进行持续部署中探讨。
摘要
在本章中,我们修改了我们迄今为止编写的大量代码。你学习了提供 SSR 的优势和劣势。React Router、Apollo 以及使用 SSR 进行 cookie 认证的主要原则现在应该已经很清晰了。要让 SSR 运行起来需要做很多工作,并且需要管理你应用中每一次的更改。尽管如此,它为你的用户提供了卓越的性能和用户体验优势。
在下一章中,我们将探讨如何通过Apollo Subscriptions提供实时更新,而不是使用旧的和低效的轮询方法。
第十章: 实时订阅
使用 GraphQL pollInterval属性到 Apollo Hooks 以保持显示更新。更好的解决方案是通过 WebSockets 实现 Apollo 订阅。这允许我们实时刷新用户的用户界面(UI),而不需要手动用户交互或轮询。
本章涵盖以下主题:
-
使用 GraphQL 和 WebSockets
-
实现 Apollo 订阅
-
带订阅的 JWT 身份验证
-
使用 Apollo 订阅进行通知
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
使用 GraphQL 和 WebSockets
在第一章《准备你的开发环境》中,我解释了使 GraphQL 如此有用的所有主要功能。我们提到,超文本传输协议(HTTP)是使用 GraphQL 时的标准网络协议。然而,常规 HTTP 连接的问题在于它们是一次性请求。它们只能响应请求时存在的数据。如果数据库收到有关帖子或聊天的更改,用户将不知道这一点,直到他们执行另一个请求。在这种情况下,UI 会显示过时的数据。
为了解决这个问题,你可以在特定的时间间隔内重新获取所有请求,但这不是一个好的解决方案,因为没有时间范围可以使轮询变得高效。每个用户都会发出不必要的 HTTP 请求,这既不是你所希望的,也不是用户所希望的。
最佳解决方案依赖于 WebSockets 而不是 HTTP 请求。与 HTTP 一样,WebSockets 也是基于传输控制协议(TCP)。WebSockets 的一个主要特性是它们允许客户端和服务器之间的双向通信。可以说,你可以认为 HTTP 也做到了这一点,因为你可以发送一个请求并得到一个响应,但 WebSockets 的工作方式非常不同。一个要求是,Web 服务器通常支持 WebSockets。如果是这样,客户端可以打开到服务器的 WebSocket 连接。建立 WebSocket 连接的初始请求是一个标准的 HTTP 请求。然后服务器应该以 101 状态码响应。这告诉浏览器它同意将协议从 HTTP 更改为 WebSockets。如果连接成功,服务器可以通过这个连接向客户端发送更新。这些更新也被称为消息或帧。与 HTTP 不同,客户端不需要进一步请求来让服务器与浏览器通信,在 HTTP 中,你总是需要先发送一个请求,以便服务器可以响应它。
使用 WebSocket 或 Apollo 订阅将解决我们在使用轮询时遇到的问题。我们有一个始终保持开启的连接。服务器可以在数据添加或更新时向客户端发送消息。使用 WebSocket ws 或 wss 而不是普通的 http 或 https。使用 WebSocket,你还可以为用户节省宝贵的带宽,但这些带宽不包括在 WebSocket 消息中。
一个缺点是,WebSocket 并不是实现 API 的标准方法。如果你在某个时刻将你的 API 公开给第三方,一个标准的 HTTP API 可能更适合。此外,HTTP 的优化程度更高。HTTP 请求可以很容易地通过常见的网络服务器,如 nginx 或 Apache,以及浏览器本身进行缓存和代理,但 WebSocket 则难以做到。对性能影响最大的是,WebSocket 连接会一直保持开启状态,直到用户离开你的网站。对于一两个用户来说这不是问题,但扩展到更多人可能会给你带来一些问题。然而,与轮询相比,它仍然是实时网络通信的一个非常有效的解决方案。
大多数 GraphQL 客户端库都是针对标准 HTTP 协议进行专业化和优化的。这是最常见的方法,所以这是可以理解的。Apollo 背后的团队已经为你考虑到了;他们内置了对 WebSocket 和 GraphQL 订阅实现的支持。你可以使用这些包不仅限于 Apollo,还可以用于许多其他库。让我们开始实现 Apollo 订阅。
深入了解 Apollo 订阅
在 Apollo 客户端早期版本中,你需要安装额外的包来支持 WebSocket。现在,唯一的要求是安装一个额外的包,该包在服务器端实现 WebSocket 支持。
注意
你可以在官方文档中找到关于 Apollo 订阅的出色概述和更多详细信息,请访问www.apollographql.com/docs/react/data/subscriptions/.
第一步是安装所有必要的包以使 GraphQL 订阅工作。使用以下命令通过 npm 安装它们:
npm install --save subscriptions-transport-ws graphql-subscriptions
以下两个包提供了订阅系统所需的模块:
-
graphql-subscriptions包提供了将我们的 GraphQL 后端与发布-订阅(PubSub)系统连接的能力。它允许客户端订阅特定的频道,并让后端将新数据发布给客户端。这是一个内存实现,仅适用于我们后端的一个实例。它不推荐在生产环境中使用,但可以帮助我们在本地环境中使其工作。 -
subscriptions-transport-ws包为我们的 Apollo 服务器或其他 GraphQL 库提供了接受 WebSocket 连接并接受通过 WebSocket 的查询、突变和订阅的选项。让我们看看我们如何实现订阅。
首先,我们将在 GraphQL 模式中 RootQuery 和 RootMutation 类型旁边创建一个新的订阅类型。您可以设置客户端可以订阅的事件或实体,并在新的订阅类型中接收更新。它只能通过添加匹配的解析函数来实现。对于这个新的订阅类型,您不返回真实数据,而是返回一个特殊对象,允许客户端订阅特定实体的事件。这些实体可以是通知、新的聊天消息或帖子的评论。每个都有其自己的订阅频道。
客户端可以订阅这些频道。每当后端发送新的 WebSocket 消息时——例如,数据已更新时——它都会收到更新。后端调用一个 publish 方法,通过订阅将新数据发送给所有客户端。您应该意识到并非每个用户都应该接收所有 WebSocket 消息,因为内容可能包括如聊天消息之类的私人数据。在更新发送到目标特定用户之前应该有一个过滤器。我们将在 使用 Apollo 订阅进行身份验证 部分中看到这个功能。
Apollo 服务器上的订阅
我们现在已经安装了所有必要的包。让我们从后端实现开始,如下所示:
-
如前所述,我们将依赖 WebSocket,因为它们允许前端和后端之间的实时通信。我们首先将为后端设置一个新的传输协议。
打开服务器的
index.js文件。在文件顶部导入一个新的 Node.js 接口,如下所示:import { createServer } from 'http';http接口是 Node.js 默认包含的。它处理传统的 HTTP 协议,使开发者能够轻松使用许多 HTTP 功能。 -
我们将使用该接口创建一个标准化的 Node.js HTTP
server对象,因为 ApolloSubscriptionServer模块期望这样的对象。我们将在本节中很快介绍 ApolloSubscriptionServer模块。在 Express.js 初始化之后,在app变量内添加以下代码行:const server = createServer(app);createServer函数创建了一个新的 HTTPserver对象,基于原始的Express.js实例。我们传递 Express 实例,我们将其保存在app变量中。正如您在前面的代码片段中所见,您只需将app对象作为参数传递给createServer函数。 -
我们将使用新的
server对象而不是app变量,以便我们的后端开始监听传入的请求。从文件的底部移除旧的app.listen函数调用,因为我们将在下一秒替换它。为了使服务器再次开始监听,编辑服务的初始化例程。for循环现在应该看起来像这样:for (let i = 0; i < serviceNames.length; i += 1) { const name = serviceNames[i]; switch (name) { case 'graphql': (async () => { await services[name].start(); app.use(graphqlUploadExpress()); services[name].applyMiddleware({ app }); })(); break; case 'subscriptions': server.listen(8000, () => { console.log('Listening on port 8000!'); servicesname; }); break; default: app.use('/${name}', services[name]); break; } }在这里,我们将旧的
if语句更改为switch语句。此外,我们添加了一个名为subscriptions的第二个服务,除了graphql之外。我们将在graphql服务文件夹旁边创建一个新的subscriptions服务。subscriptions服务需要一个server对象作为参数来开始监听 WebSocket 连接。在初始化SubscriptionServer之前,我们需要开始监听传入的请求。这就是为什么我们在初始化创建 ApolloSubscriptionServer实例的新subscriptions服务之前,在先前的代码片段中使用server.listen方法。在服务开始监听后,我们将server对象传递给服务。当然,服务必须接受这个参数,所以请记住这一点。 -
要将新服务添加到先前的
serviceNames对象中,使用以下内容编辑index.js服务文件:import graphql from './graphql'; import subscriptions from './subscriptions'; export default utils => ({ graphql: graphql(utils), subscriptions: subscriptions(utils), });subscriptions服务也接收utils对象,就像graphql服务一样。 -
现在,在
graphql文件夹旁边创建一个subscriptions文件夹。为了完成前面subscriptions服务的导入,将服务的index.js文件插入到这个文件夹中。在那里,我们可以实现subscriptions服务。作为提醒,我们传递了之前的utils对象和server对象。subscriptions服务必须在单独的函数调用中接受两个参数。 -
如果你创建了一个新的订阅
index.js文件,请在文件顶部导入所有依赖项,如下所示:import { makeExecutableSchema } from '@graphql-tools/schema'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import { execute, subscribe } from 'graphql'; import jwt from 'jsonwebtoken'; import Resolvers from '../graphql/resolvers'; import Schema from'../graphql/schema'; import auth from '../graphql/auth';前面的依赖项几乎与我们在
graphql服务中使用的相同,但我们添加了subscriptions-transport-ws和@graphql-tools/schema包。此外,我们移除了apollo-server-express包。SubscriptionServer是ApolloServer的等价物,但用于 WebSocket 连接而不是 HTTP。通常,在同一个文件中为 HTTP 设置 Apollo Server 和为 WebSocket 设置SubscriptionServer是有意义的,因为这可以避免我们两次处理Schema和Resolvers。不过,没有ApolloServer代码在同一文件中解释订阅的实现会更容易。先前的代码片段中新出现的最后两件事是从graphql包中导入的execute和subscribe函数。你将在下一节中看到为什么我们需要这些。 -
我们通过使用
export default语句导出一个函数并创建一个executableSchema对象(如您在 第二章,使用 Express.js 设置 GraphQL)开始实现新的服务,如下所示:export default (utils) => (server) => { const executableSchema = makeExecutableSchema({ typeDefs: Schema, resolvers: Resolvers.call(utils), schemaDirectives: { auth: auth }, }); }如您所见,我们使用
utils对象,第二个接受我们使用createServer函数在服务器index.js文件中创建的server对象。这种方法解决了在单独的函数调用中传递两个参数的问题。只有在两个函数都调用时,才会创建模式。 -
第二步是启动
SubscriptionServer以接受 WebSocket 连接,从而能够使用 GraphQL 订阅。在executableSchema下插入以下代码:new SubscriptionServer({ execute, subscribe, schema: executableSchema, }, { server, path: '/subscriptions', });在前面的代码中,我们初始化了一个新的
SubscriptionServer实例。我们传递的第一个参数是一个通用的options对象,用于 GraphQL,并对应于ApolloServer类的选项。选项的详细说明如下:a.
execute属性应该接收一个处理和执行传入 GraphQL 请求的所有处理的函数。标准是传递我们从graphql包中导入的execute函数。b.
subscribe属性也接受一个函数。这个函数必须负责将订阅解析为asyncIterator,这不过是一个异步的for循环。它允许客户端监听执行结果并将其反映给用户。c. 我们传递的最后一个选项是 GraphQL 模式。我们以与
ApolloServer相同的方式执行此操作。我们的新实例接受的第二个参数是
socketOptions对象。这个对象包含描述 WebSocket 工作方式的设置,如下所述:d.
server字段接收我们的server对象,这是我们通过index.js文件中的createServer函数从服务器传递的。SubscriptionServer然后依赖于现有的服务器。e.
path字段表示订阅可访问的端点。所有订阅都使用/subscriptions路径。注意
subscriptions-transport-ws包的官方文档提供了对SubscriptionServer的更高级解释。查看以了解其所有功能的概述:github.com/apollographql/subscriptions-transport-ws#subscriptionserver。到目前为止,客户端可以连接到 WebSocket 端点。目前还没有订阅,并且相应的解析器已在我们的 GraphQL API 中设置。
-
打开
schema.js文件来定义我们的第一个订阅。在RootQuery和RootMutation类型旁边添加一个名为RootSubscription的新类型,包括名为messageAdded的新订阅,如下所示:type RootSubscription { messageAdded: Message }目前,如果用户向另一个用户发送一条新消息,这条消息并不会立即显示给接收者。
我向您展示的第一个选项是设置一个间隔来请求新的消息。我们的后端现在能够通过订阅来覆盖这种场景。客户端可以订阅的事件或通道被称为
messageAdded。我们还可以添加更多参数,例如聊天标识符(ID),以便在必要时过滤 WebSocket 消息。当创建新的聊天消息时,它将通过这个通道进行公开。 -
我们已经添加了
RootSubscription,但我们也需要扩展模式根标签。否则,新的RootSubscription类型将不会被使用。按照以下方式更改模式:schema { query: RootQuery mutation: RootMutation subscription: RootSubscription }
我们已经成功配置了树形 GraphQL 主类型。接下来,我们必须实现相应的解析函数。打开resolvers.js文件并执行以下步骤:
-
导入所有允许我们使用
PubSub系统设置 GraphQL API 的依赖项,如下所示:import { PubSub, withFilter } from 'graphql-subscriptions'; const pubsub = new PubSub();graphql-subscriptions包提供的PubSub系统是基于标准 Node.jsEventEmitter类的一个简单实现。当进入生产环境时,建议使用外部存储,如 Redis,与这个包一起使用。 -
我们已经将第三个
RootSubscription类型添加到模式中,但还没有在resolvers对象上添加匹配的属性。以下代码片段包括messageAdded订阅。将其添加到解析器中:RootSubscription: { messageAdded: { subscribe: () => pubsub.asyncIterator(['messageAdded']), } },messageAdded属性不是一个函数,而只是一个简单的对象。它包含一个subscribe函数,该函数返回AsyncIterable。它允许我们的应用程序通过返回一个仅在添加新消息时解决的承诺来订阅messageAdded通道。返回的下一个项目也是一个承诺,它也仅在添加了消息时解决。这种方法使asyncIterator非常适合实现订阅。注意
您可以通过阅读
github.com/tc39/proposal-async-iteration上的提案来了解更多关于asyncIterator如何工作的信息。 -
当订阅
messageAdded订阅时,需要另一个方法将新创建的消息公开给所有客户端。最佳位置是创建新消息的addMessage突变处。用以下代码替换addMessage解析器函数:addMessage(root, { message }, context) { logger.log({ level: 'info', message: 'Message was created', }); return Message.create({ ...message, }).then((newMessage) => { return Promise.all([ newMessage.setUser(context.user.id), newMessage.setChat(message.chatId), ]).then(() => { pubsub.publish('messageAdded', { messageAdded: newMessage }); return newMessage; }); }); },我已经编辑了
addMessage突变,以便从上下文中选择正确用户。现在,您发送的所有新消息现在都保存了正确的用户 ID。这允许我们在使用 Apollo 订阅的认证部分稍后过滤正确的用户 WebSocket 消息。我们使用
pubsub.publish函数向所有已连接并已订阅messageAdded通道的客户发送一个新的 WebSocket 帧。pubsub.publish函数的第一个参数是订阅,在这种情况下是messageAdded。第二个参数是我们保存到数据库的新消息。现在通过asyncIterator订阅了messageAdded订阅的所有客户端都接收到了这条消息。
我们已经完成了后端的准备工作。需要最多工作的是让 Express.js 和 WebSocket 传输协同工作。GraphQL 实现仅涉及新的模式实体,正确实现订阅的解析函数,然后通过 PubSub 系统将数据发布到客户端。
我们必须在前端实现订阅功能以连接到我们的 WebSocket 端点。
Apollo 客户端的订阅
与后端代码一样,在使用订阅之前,我们还需要调整 Apollo 客户端配置。在 第四章 将 Apollo 集成到 React 中,我们使用正常的 HttpLink 链路设置了 Apollo 客户端。后来,我们将其替换为 createUploadLink 函数,这使用户能够通过 GraphQL 上传文件。
我们将通过使用 WebSocketLink 来扩展 Apollo 客户端。这允许我们通过 GraphQL 使用订阅。这两个链接可以并行工作。我们使用标准的 HTTP 协议查询数据,例如聊天列表或新闻源;所有这些实时更新以保持 UI 的更新都依赖于 WebSocket。
要正确配置 Apollo 客户端,请按照以下步骤操作:
-
打开
apollo文件夹中的index.js文件。导入以下依赖项:import { ApolloClient, InMemoryCache, from, split } from '@apollo/client'; import { WebSocketLink } from '@apollo/client/link/ws'; import { onError } from "@apollo/client/link/error"; import { getMainDefinition } from '@apollo/client/utilities'; import { createUploadLink } from 'apollo-upload-client'; import { SubscriptionClient } from 'subscriptions-transport-ws';要使订阅工作,我们需要
SubscriptionClient,它使用WebSocketLink通过 WebSocket 订阅我们的 GraphQL API。我们从
@apollo/client/utilities包中导入getMainDefinition函数。当使用 Apollo 客户端时,它默认安装。此函数的目的是为您提供操作类型,可以是query、mutation或subscription。来自
@apollo/client包的split函数允许我们根据操作类型或其他信息有条件地控制通过不同的 Apollo 链路请求的流程。它接受一个条件和一条链路(或一对链路),从中它组合出一个单个有效的链路,Apollo 客户端可以使用。 -
我们将为
split函数创建两个链路。检测我们发送所有 GraphQL 订阅和请求的协议和端口。在导入下面添加以下代码:const protocol = (location.protocol != 'https:') ? 'ws://': 'wss://'; const port = location.port ? ':'+location.port: '';protocol变量通过检测客户端是否使用http或https来保存 WebSocket 协议。port变量要么是空字符串(如果我们使用端口80来提供我们的前端),要么是任何其他端口,例如我们目前使用的8000。以前,我们必须在这个文件中静态保存http://localhost:8000。有了新的变量,我们可以动态构建所有请求应该发送的 URL。 -
split函数期望两个链接来合并它们成为一个。第一个链接是正常的httpLink链接,我们必须在将结果链接传递给 Apollo Client 的初始化之前设置它。从ApolloLink.from函数中移除createUploadLink函数调用,并在ApolloClient类之前添加它,如下所示:const httpLink = createUploadLink({ uri: location.protocol + '//' + location.hostname + port + '/graphql',credentials: 'same-origin', });我们将服务器的
protocol变量(无论是http:还是https:)与两个斜杠连接起来。hostname变量,例如,是您应用程序的域名,或者在开发中是localhost。连接的结果是localhost:8000/graphql。 -
在
httpLink旁边添加用于订阅的 WebSocket 链接。它是传递给split函数的第二个链接。代码在下面的代码片段中展示:const SUBSCRIPTIONS_ENDPOINT = protocol + location.hostname + port + '/subscriptions'; const subClient = new SubscriptionClient(SUBSCRIPTIONS_ENDPOINT, { reconnect: true, connectionParams: () => { var token = localStorage.getItem('jwt'); if(token) { return { authToken: token }; } return { }; } }); const wsLink = new WebSocketLink(subClient);我们定义了
SUBSCRIPTIONS_ENDPOINT变量。它是通过protocol和port变量构建的,这些变量我们之前已经检测到了,以及应用程序的hostname变量。URI 以与 GraphQL API 相同端口的后端指定的端点结束。URI 是SubscriptionsClient的第一个参数。第二个参数允许我们传递选项,例如reconnect属性。它告诉客户端在失去连接时自动重新连接到后端的 WebSocket 端点。这通常发生在客户端暂时失去了互联网连接或服务器已经宕机的情况下。此外,我们使用
connectionParams字段在用户登录时指定localStorage。它在 WebSocket 创建时发送。我们将
SubscriptionClient初始化为subClient变量。我们将其传递给WebSocketLink构造函数下的wsLink变量,并使用给定的设置。 -
将两个链接合并为一个。这允许我们将组合的结果插入到我们
ApolloClient类的底部。为此,我们导入了split函数。合并两个链接的语法应该如下所示:const link = split( ({ query }) => { const { kind, operation } = getMainDefinition(query); return kind === 'OperationDefinition' && operation === 'subscription'; }, wsLink, httpLink, );split函数接受三个参数。第一个参数必须是一个返回布尔值的函数。如果返回值为true,则请求通过第一个链接发送,即第二个必需参数。如果返回值为false,则操作通过第二个链接发送,我们通过可选的第三个参数传递。在我们的情况下,作为第一个参数传递的函数决定了操作类型。如果操作是订阅,则函数返回true并通过 WebSocket 链接发送操作。所有其他请求都通过 HTTP Apollo 链接发送。我们将split函数的结果保存在link变量中。 -
将前面的
link变量直接插入到onError链接之前。createUploadLink函数不应位于Apollo.from函数内部。
我们现在已经设置了基本的 Apollo 客户端,以支持通过 WebSocket 进行订阅。
在 第五章 可重用 React 组件和 React Hooks 中,我给了你一些作业,将完整的聊天功能拆分成多个子组件。这样,聊天功能就会遵循我们用于帖子源的模式。我们将它拆分成多个组件,以便代码库更干净。我们将使用这个,并查看如何实现聊天的订阅。
如果您没有在多个子组件中实现聊天功能,您可以从官方 GitHub 仓库获取可工作的代码。如果以下示例不明确,我建议您使用仓库中的代码。
以聊天为例是有意义的,因为它们本质上就是实时的:它们需要应用程序处理新消息并将它们显示给接收者。我们将在以下步骤中处理这一点。
我们从聊天功能的主体文件开始,即客户端文件夹中的 Chats.js 文件。我已经重构了 return 语句,使得最初直接来自此文件的全部标记现在完全由其他子组件渲染。您可以在以下代码片段中看到所有更改:
return (
<div className="wrapper">
<div className="chats">
{chats.map((chat, i) =>
<ChatItem chat={chat} user={user}
openChat={openChat} />
)}
</div>
<div className="openChats">
{openChats.map((chatId, i) => <Chat chatId={chatId}
key={"chatWindow" + chatId} closeChat={closeChat}
/> )}
</div>
</div>
)
所有更改都列在这里:
-
我们引入了一个新的
ChatItem组件,它处理for循环的逻辑。将逻辑提取到单独的文件中使其更易于阅读。 -
ChatItem组件期望user、chat和openChat属性。此外,我们还编辑了该组件使用的函数,以便也能利用user对象。 -
我们从
Chats组件的属性中提取user属性。因此,我们必须将Chats组件包裹在UserConsumer组件中,以便它能够传递用户信息。您可以通过在Chats.js文件中包裹导出的组件来应用此更改。 -
openChat和closeChat函数由ChatItem或Chats组件执行。Chats组件的所有其他函数都已移动到以下一个或两个组件:ChatItem和Chat。
我在这里所做的更改与订阅没有直接关系,但代码可读性更高时,理解我想解释的内容会更容易。如果您需要自己实现这些更改的帮助,我建议您查看官方 GitHub 仓库。所有以下示例都是基于这些更改的,但即使没有完整的源代码,也应该能够理解。
然而,更重要的是useGetChatsQuery,它有一个特殊功能。我们想要订阅messageAdded订阅以监听新消息。这可以通过使用 Apollo useQuery Hook 的新函数来实现。
我们需要从useGetChatsQuery Hook 中提取一个subscribeToMore函数。
subscribeToMore函数默认与 Apollo useQuery Hook 的每个结果一起提供。它允许您在创建消息时运行一个update函数。它的工作方式与fetchMore函数相同。我们可以在Chats组件中使用此函数来监听新消息。
让我们看看如何使用此函数在前端实现订阅,如下所示:
-
在
apollo文件夹内创建一个新的subscriptions文件夹。 -
在此
subscriptions文件夹内创建一个新的messageAdded.js文件。我们需要解析 GraphQL 订阅字符串。新的messageAdded订阅必须如下所示:export const MESSAGES_SUBSCRIPTION = gql' subscription onMessageAdded { messageAdded { id text chat { id } user { id __typename } __typename } } ';订阅看起来与我们所使用的所有其他查询或突变完全相同。唯一的区别是我们请求了
__typename字段,因为在使用订阅时,我们的 GraphQL API 的响应中不包括此字段。从我的观点来看,这似乎是当前版本SubscriptionServer中的一个错误。您应该在阅读此书时检查是否还需要这样做。我们指定了请求的操作类型,即
subscription,正如您在前面的代码片段中看到的那样。否则,它将尝试执行默认的查询操作,这会导致错误,因为没有messageAdded查询,只有一个订阅。当新消息添加时,客户端收到的订阅事件包含所有字段,如前述代码片段所示。 -
在
addMessage突变文件中,我们需要重写代码的一部分。我们将传递给writeFragment的片段提取为一个可导出的变量本身,这样我们就可以重用它。代码应该如下所示:export const NEW_MESSAGE = gql' fragment NewMessage on Chat { id type } '; -
在
Chats.js文件中导入新的 GraphQL 查询以及其他一些依赖项,如下所示:import { MESSAGES_SUBSCRIPTION } from './apollo/queries/messageAdded'; import { NEW_MESSAGE } from './apollo/mutations/addMessage'; import { GET_CHAT } from './apollo/queries/getChat'; -
应从
useGetChatsQueryHook 中提取以下属性:const { loading, error, data, subscribeToMore } = useGetChatsQuery(); -
按如下方式导入
withApolloHOC 和UserConsumer:import { withApollo } from '@apollo/client/react/hoc'; import { UserConsumer } from './components/context/user'; -
我们将使用直接的 Apollo 客户端交互。这就是为什么我们需要导出
Chats组件,以便在withApollo高阶组件中包装,并将客户端传递给一个属性。为了正确导出组件,请使用withApollo高阶组件。代码如下所示:const ChatContainer = (props) => <UserConsumer><Chats {...props} /></UserConsumer> export default withApollo(ChatContainer)我们将
Chats组件包裹在UserConsumer组件中,以便访问客户端。此外,我们将其包裹在withApollo高阶组件中,以便访问客户端。 -
这里是关键部分。当组件挂载时,我们需要订阅
messageAdded通道。只有在这种情况下,messageAdded订阅才会用来接收新数据,或者更确切地说,接收新的聊天消息。为了开始订阅 GraphQL 订阅,我们必须添加一个新的useEffect钩子,如下所示:useEffect(() => { subscribeToNewMessages() }, []);
在前面的代码片段中,我们在 React 组件的useEffect钩子中执行了一个新的subscribeToNewMessages方法。
useEffect方法仅在客户端代码上执行,因为 SSR 实现不会抛出此事件。
我们还必须添加相应的subscribeToNewMessages方法。我们将在稍后解释这个函数的每一个细节。将以下代码插入到Chats组件中:
const subscribeToNewMessages = () => {
subscribeToMore({
document: MESSAGES_SUBSCRIPTION,
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData.data || (prev.chats &&
!prev.chats.length)) return prev;
var index = -1;
for(var i = 0; i < prev.chats.length; i++) {
if(prev.chats[i].id ==
subscriptionData.data.messageAdded.chat.id) {
index = i;
break;
}
}
if (index === -1) return prev;
const newValue = Object.assign({},prev.chats[i], {
lastMessage: {
text: subscriptionData.data.messageAdded.text,
__typename:
subscriptionData.data.messageAdded.__typename
}
});
var newList = {chats:[...prev.chats]};
newList.chats[i] = newValue;
return newList;
}
});
}
前面的subscribeToNewMessages方法看起来非常复杂,但一旦我们理解了它的目的,它就非常直接。我们主要依赖于从useGetChatsQuery获取的subscribeToMore函数。这个函数的目的是开始订阅我们的messageAdded通道,并接受订阅的新数据,将其与当前状态和缓存合并,以便直接反映给用户。
document参数接受解析后的 GraphQL 订阅。
第二个参数称为updateQuery。它允许我们插入一个函数,该函数实现了更新 Apollo 客户端缓存的逻辑。这个函数需要接受一个新参数,即subscribeToMore函数传递的先前数据。在我们的例子中,这个对象包含客户端缓存中已经存在的聊天数组。
第二个参数在subscriptionData索引中保存新的消息。subscriptionData对象有一个data属性,该属性下有一个messageAdded字段,其中保存了实际创建的消息。
我们将快速浏览updateQuery函数的逻辑,以便您了解我们如何将订阅的数据合并到应用程序状态中。
如果subscriptionData.data为空或者prev对象中没有之前的聊天,则没有需要更新的内容。在这种情况下,我们返回之前的数据,因为客户端缓存中没有的聊天中发送了消息。否则,我们遍历prev对象中的所有之前的聊天,通过比较聊天 ID 找到订阅返回了新消息的聊天索引。找到的聊天在prev.chats数组中的索引被保存在index变量中。如果找不到聊天,我们可以通过检查index变量来返回之前的数据。如果我们找到了聊天,我们需要用新消息更新它。为此,我们使用之前的数据组合聊天,并将lastMessage设置为新消息的文本。我们通过使用Object.assign函数来实现这一点,其中聊天和新消息被合并。我们将结果保存在newValue变量中。重要的是我们还要设置返回的__typename属性,否则 Apollo Client 会抛出错误。
现在我们有了包含更新后的聊天对象的newValue变量,我们将它写入客户端的缓存。为了将更新的聊天写入缓存,我们在updateQuery函数的末尾返回所有聊天的数组。因为prev变量是只读的,所以我们不能在它里面保存更新的聊天。我们必须创建一个新的数组来写入缓存。我们将newValue聊天对象设置在newList数组中找到原始聊天的索引位置。最后,我们返回newList变量。我们使用prev对象中给出的新数组更新缓存。重要的是,新的缓存必须具有与之前相同的字段。updateQuery函数返回值的模式必须与初始chats查询模式匹配。
现在,你可以通过使用npm run server启动应用程序来直接在浏览器中测试订阅。如果你发送一条新的聊天消息,它将直接显示在右侧的聊天面板中。
然而,我们遇到了一个主要问题。如果你用第二个用户测试这个功能,你会注意到lastMessage字段对两个用户都进行了更新。这是正确的,但新消息在收件人的聊天窗口中是不可见的。我们已经更新了chats GraphQL 请求的客户存储,但我们还没有在打开聊天窗口时执行的单一chat查询中添加消息。
我们将通过使用withApollo高阶组件(HOC)来解决这个问题。Chats组件无法直接访问chat查询缓存。withApollo HOC 给导出的组件提供了一个client属性,这使我们能够直接与 Apollo Client 交互。我们可以使用它来读取和写入整个 Apollo Client 缓存,并且它不仅限于单个 GraphQL 请求。在从updateQuery函数返回更新后的chats数组之前,我们必须读取chat的状态,并在可能的情况下插入新数据。在updateQuery函数中的最终return语句之前插入以下代码:
if(user.id !== subscriptionData.data.messageAdded.user.id) {
try {
const data = client.readQuery({ query: GET_CHAT,
variables: { chatId:
subscriptionData.data.messageAdded.chat.id } });
client.cache.modify({
id: client.cache.identify(data.chat),
fields: {
messages(existingMessages = []) {
const newMessageRef =
client.cache.writeFragment({
data: subscriptionData.data.messageAdded,
fragment: NEW_MESSAGE
});
return [...existingMessages, newMessageRef];
}
}
});
} catch(e) {}
}
在前面的代码片段中,我们使用client.readQuery方法读取缓存。它接受GET_CHAT查询作为参数,以及新发送消息的聊天 ID,以返回单个聊天。GET_CHAT查询是我们打开聊天窗口时在Chat.js文件中发送的相同请求。我们将在try-catch块中包装readQuery函数,因为它在找不到指定的query和variables时抛出未处理的错误。这可能发生在用户尚未打开聊天窗口的情况下,因此没有使用GET_CHAT查询请求此特定聊天的数据。此外,整个块被包裹在一个if条件中,以检查新消息是否来自另一个用户而不是我们自己,因为如果我们自己发送消息,我们不需要将其添加到缓存中,因为我们已经在提交我们这边的新消息时那样做了。
如果消息来自另一个用户,我们使用client.cache.modify函数,因为我们已经知道要将新消息添加到特定聊天中缓存的消息数组。
您可以通过查看聊天窗口并从另一个用户账户发送消息来测试这些新更改。对于您来说,新消息应该几乎立即出现,无需刷新浏览器。
在本节中,我们学习了如何通过 Apollo 订阅订阅来自后端发送的事件。目前,我们使用此功能在动态中更新 UI 以显示新数据。稍后,在“使用 Apollo 订阅的通知”部分,我们将看到另一个订阅可能很有用的场景。尽管如此,还有一件事要做:我们尚未通过 JWT(如我们的 GraphQL API)授权用户进行messageAdded订阅,而且用户在未验证其身份的情况下仍然收到了新消息。我们将在下一节中改变这一点。
使用 Apollo 订阅进行身份验证
在第六章,使用 Apollo 和 React 进行身份验证中,我们通过浏览器本地存储实现了身份验证。后端生成一个签名 JWT,客户端将其与每个请求一起发送在 HTTP 头中。在第九章,实现服务器端渲染中,我们扩展了这种逻辑以支持 cookie,允许 SSR。现在我们引入了 WebSockets,我们需要单独处理它们,就像我们处理 SRR 和我们的 GraphQL API 一样。
当用户在 WebSocket 传输协议的后端未进行身份验证时,他们是如何接收新消息的?
了解这一点的最佳方式是查看你的浏览器开发者工具。假设我们有一个浏览器窗口,我们用用户 A 登录。这个用户与另一个用户 B 聊天。他们互相发送消息,并在各自的聊天窗口中直接接收新的更新。另一个用户 C 不应该能够接收任何 WebSocket 更新。我们应该在现实中模拟这个场景。
如果你使用 Chrome 作为默认浏览器,请访问我们后端的subscriptions端点。
在开发者工具打开的情况下尝试这个场景。你应该看到所有浏览器的相同 WebSocket 帧。它应该看起来像这样:

图 10.1 – WebSocket 消息
在左侧面板中,你可以看到所有的 WebSocket 连接。在我们的例子中,这只是一个subscriptions连接。如果你点击连接,你将找到通过此连接发送的所有帧。前面列表中的第一帧是初始连接帧。第二帧是订阅messageAdded通道的请求,这是由客户端发起的。这两个帧都被标记为绿色,因为客户端发送了它们。
最后两个被标记为红色,因为服务器发送了它们。红色标记的第一帧是服务器对建立的连接的确认。最后一个帧是由我们的后端发送的,以向客户端发布一条新消息。虽然这个帧乍一看可能看起来没问题,但它代表了一个重要的问题。最后一个帧被发送给了所有客户端,而不仅仅是那些属于发送消息的特定聊天室的成员。普通用户不太可能注意到这一点,因为我们的cache.modify函数只有在聊天被找到在客户端存储中时才会更新 UI。然而,一个经验丰富的用户或开发者能够监视我们社交网络的所有用户,因为这在网络标签中是可读的。
我们需要查看我们编写的后端代码,并比较ApolloServer和SubscriptionServer的初始化。我们为ApolloServer有一个context函数,它可以从 JWT 中提取用户。然后它可以在解析函数内部使用,以根据当前登录用户过滤结果。对于SubscriptionServer,目前还没有这样的context函数。我们必须知道当前登录用户,以便为正确的用户过滤订阅消息。我们可以使用标准的 WebSockets 事件,例如onConnect或onOperation,来实现用户的授权。
onOperation函数会在发送每个 WebSocket 帧时执行。最佳做法是在onConnect事件中实现授权,就像从ApolloServer中获取的context函数一样,这样 WebSocket 连接在建立时只认证一次,而不是在发送每个帧时都认证。
在index.js中,从服务器的subscriptions文件夹中,将以下代码添加到SubscriptionServer初始化的第一个参数。它接受一个作为函数的onConnect参数,每当客户端尝试连接到subscriptions端点时,都会执行这个函数。在schema参数之前添加代码:
onConnect: async (params,socket) => {
const authorization = params.authToken;
if(typeof authorization !== typeof undefined) {
var search = "Bearer";
var regEx = new RegExp(search, "ig");
const token = authorization.replace(regEx, '').trim();
return jwt.verify(token, JWT_SECRET, function(err,
result) {
if(err) {
throw new Error('Missing auth token!');
} else {
return utils.db.models.User.findByPk(
result.id).then((user) =>
{
return Object.assign({}, socket.upgradeReq, {
user });
});
}
});
} else {
throw new Error('Missing auth token!');
}
},
这段代码与context函数非常相似。我们依赖于正常的 JWT 认证,但通过 WebSocket 的连接参数。我们在onConnect事件中实现 WebSocket 认证。在ApolloServer的原始context函数中,我们从请求的 HTTP 头中提取 JWT,但在这里,我们使用作为第一个参数传递的params变量。
在客户端最终连接到 WebSocket 端点之前,会触发一个onConnect事件,在那里你可以实现针对初始连接的特殊逻辑。在第一次请求中,我们发送 JWT,因为我们已经配置 Apollo Client 在初始化SubscriptionClient时读取 JWT 到connectionParams对象的authToken参数。这就是为什么我们不是直接从request对象中访问 JWT,而是在前面的代码片段中从params.authToken访问。socket参数也由onConnect函数提供;在那里,你可以访问socket对象中的初始升级请求。从连接参数中提取 JWT 后,我们可以验证它并使用它来认证用户。
在这个onConnect函数的末尾,我们返回upgradeReq变量和用户,就像我们在 Apollo Server 的正常context函数中所做的那样。如果用户未登录,我们不再将req对象返回给context,而是抛出一个错误。这是因为我们只为需要您登录的实体实现订阅,例如聊天或帖子。这允许客户端尝试重新连接,直到它被认证。但是,请不要忘记,每个开放的连接都会消耗您的性能,而且未登录的用户不需要开放的连接,至少对于Graphbook的使用场景来说是这样。
我们现在已经通过前面的代码识别了连接到我们后端的用户,但我们仍然将每个帧发送给所有用户。这是解析函数的问题,因为它们还没有使用上下文。请在resolvers.js文件中将messageAdded订阅替换为以下代码:
messageAdded: {
subscribe: withFilter(() =>
pubsub.asyncIterator('messageAdded'),
(payload, variables, context) => {
if (payload.messageAdded.UserId !== context.user.id) {
return Chat.findOne({
where: {
id: payload.messageAdded.ChatId
},
include: [{
model: User,
required: true,
through: { where: { userId: context.user.id } },
}],
}).then((chat) => {
if(chat !== null) {
return true;
}
return false;
})
}
return false;
}),
}
在本章的早期,我们从graphql-subscriptions包中导入了withFilter函数。这允许我们将asyncIterator包装在过滤器中。这个过滤器的目的是有条件地向应该看到新信息的用户通过连接发送发布。如果一个用户不应该接收发布,withFilter函数的条件返回值应该是false。对于所有应该接收新消息的用户,返回值应该是true。
withFilter函数接受asyncIterator作为其第一个参数。第二个参数是决定用户是否接收订阅更新的函数。我们从函数调用中提取以下属性:
-
payload参数,它是通过addMessage突变发送的新消息。 -
variables字段,它包含所有可以与messageAdded订阅一起发送的 GraphQL 参数,而不是与突变一起发送。在我们的场景中,我们不会在订阅中发送任何变量。 -
context变量,它包含我们在onConnect钩子中实现的所有信息。它包括带有用户作为单独属性的常规context对象。
filter函数为订阅messageAdded通道的每个用户执行。首先,我们通过比较用户 ID 来检查函数执行的用户是否是新消息的作者。在这种情况下,他们不需要收到订阅通知,因为他们已经拥有了数据。
如果情况不是这样,我们将查询数据库以找到新消息被添加的聊天。为了确定用户是否需要接收新消息,我们只选择包含登录用户 ID 和聊天 ID 的聊天。如果在数据库中找到聊天,用户应该看到新消息。否则,他们不允许获取新消息,我们返回false。
请记住,withFilter 函数为每个连接运行。如果有成千上万的用户,我们可能需要非常频繁地运行数据库查询。最好将这样的过滤器函数保持得尽可能小和高效。例如,我们可以查询一次聊天以获取附加的用户,然后手动遍历所有连接。这个解决方案将节省我们昂贵的数据库操作。
关于使用订阅进行身份验证的所有这些就是您需要了解的。我们现在有一个包含 SSR(服务器端渲染)和带有 JWT 认证的实时订阅的工作设置。SSR 不实现订阅,因为对我们应用程序的初始渲染提供实时更新没有意义。接下来,您将看到另一个 Apollo 订阅可能很有用的场景。
使用 Apollo 订阅的通知
在本节中,我将快速引导您了解订阅的第二个用例。向用户显示通知是完美的传统和常见做法,正如您从 Facebook 知道的。我们不是依赖于 subscribeToMore 函数,而是使用 Apollo 提供的 Subscription 组件。这个组件的工作方式与 Query 和 Mutation 组件类似,但用于订阅。
按照以下步骤运行您的第一个 Subscription 组件:
-
在客户端的
apollo文件夹内创建一个subscriptions文件夹。您可以将使用 Apollo 的useSubscription钩子实现的全部订阅保存在这个文件夹中。 -
在文件夹中插入一个
messageAdded.js文件,并粘贴以下代码:import { useSubscription, gql } from '@apollo/client'; export const MESSAGES_SUBSCRIPTION = gql' subscription onMessageAdded { messageAdded { id text chat { id } user { id __typename } __typename } } '; export const useMessageAddedSubscription = (options) => useSubscription(MESSAGES_SUBSCRIPTION, options);useSubscription组件的一般工作流程与useMutation和useQuery钩子相同。首先,我们使用gql函数解析订阅。然后,我们只需返回解析后的 GraphQL 查询的useSubscription钩子。 -
因为我们希望在收到新消息时向用户显示通知,所以我们安装了一个负责显示弹出通知的包。使用
npm安装它,如下所示:npm install --save react-toastify -
要设置
react-toastify,将一个ToastContainer组件添加到应用程序的全局点,所有通知都在这里渲染。这个容器不仅用于新消息的通知,还用于所有通知,所以请明智选择。我决定将ToastContainer附接到Chats.js文件上。在顶部导入依赖项,如下所示:import { ToastContainer, toast } from 'react-toastify'; -
在
return语句中,首先应该渲染的是ToastContainer。添加它,如下所示:<div className="wrapper"> <ToastContainer/> -
在
Chats.js文件中,添加一个import语句来加载订阅钩子,如下所示:import { useMessageAddedSubscription } from './apollo/subscriptions/messageAdded'; -
然后,只需在
Chats组件中其他钩子语句之后调用此订阅钩子,如下所示:useMessageAddedSubscription({ onSubscriptionData: data => { if(data && data.subscriptionData && data.subscriptionData.data && data.subscriptionData.data.messageAdded) toast(data.subscriptionData.data.messageAdded.text, { position: toast.POSITION.TOP_LEFT }); } }); -
添加一个小的
react-toastify包。在App.js文件中导入CSS文件,如下所示:import 'react-toastify/dist/ReactToastify.css';然后,将这些几行代码添加到自定义的
style.css文件中:.Toastify__toast-container--top-left { top: 4em !important; }
您可以在下面的屏幕截图中看到一个通知的示例:

图 10.2 – 通知
整个订阅主题相当复杂,但我们成功地为两个用例实现了它,从而为用户提供了对我们应用程序的重大改进。
摘要
本章旨在为用户提供一个实时用户界面,使他们能够舒适地与其他用户聊天。我们还探讨了如何使这个界面具有可扩展性。你学习了如何为所有实体设置与任何 Apollo 或 GraphQL 后端的订阅。我们还实现了针对 WebSocket 的特定认证,以过滤发布内容,确保它们只被正确用户接收。
在下一章中,你将学习如何通过为你的代码实现自动化测试来验证和测试应用程序的正确功能。
第十一章:为 React 和 Node.js 编写测试
到目前为止,我们已经编写了大量的代码,遇到了各种问题。我们尚未为我们的软件实现自动化测试;然而,在修改应用程序后确保一切正常工作是一种常见的方法。自动化测试极大地提高了软件的质量,并减少了生产中的错误。
为了实现这一目标,我们将在本章中涵盖以下主要主题:
-
如何使用 Mocha 进行测试
-
使用 Mocha 和 Chai 测试 GraphQL 应用程序编程接口(API)
-
使用 Enzyme 和 JSDOM 测试 React
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
使用 Mocha 进行测试
我们面临的问题是,我们必须确保软件的质量,同时不增加手动测试的数量。当发布新更新时,不可能重新检查我们软件的每个功能。为了解决这个问题,我们将使用 Mocha,这是一个用于运行一系列异步测试的 JavaScript 测试框架。如果所有测试都成功通过,则你的应用程序是正常的,可以发布到生产环境中。
许多开发者遵循测试驱动开发(TDD)的方法。通常,当你第一次实现测试时,它们会失败,因为正在被测试的业务逻辑缺失。在实现所有测试之后,我们必须编写实际的应用程序代码以满足测试的要求。在这本书中,我们没有遵循这种方法,但这并不是问题,因为我们也可以在之后实现测试。通常,我倾向于与应用程序代码并行编写测试。
要开始,我们必须使用npm安装所有依赖项以测试我们的应用程序,如下所示:
npm install --save-dev mocha chai @babel/polyfill request
mocha包几乎包含了运行测试所需的一切。除了 Mocha,我们还安装了chai,这是一个断言库。它提供了将测试与许多变量和类型链式连接的出色方法,用于 Mocha 测试内部。我们还安装了@babel/polyfill包,它允许我们的测试将request包作为库来发送测试中的所有查询或突变。我建议你设置NODE_ENV环境变量为production以测试每个功能,就像在实时环境中一样。确保你正确设置了环境变量,以便使用所有生产功能。
我们的第一条 Mocha 测试
首先,让我们向我们的package.json文件的scripts字段添加一个新命令,如下所示:
"test": "mocha --exit test/ --require babel-hook --require @babel/polyfill --recursive"
如果你现在执行npm run test,我们将运行test文件夹中的mocha包,我们将在下一秒创建这个文件夹。前面的--require选项加载指定的文件或包。我们还将加载一个babel-hook.js文件,我们也将创建它。--recursive参数告诉 Mocha 运行test文件夹的完整文件树,而不仅仅是第一层。这种行为很有用,因为它允许我们在多个文件和文件夹中结构化我们的测试。
让我们从添加到项目根目录的babel-hook.js文件开始,紧挨着package.json文件。插入以下代码:
require("@babel/register")({
"plugins": [
"require-context-hook"
],
"presets": ["@babel/env","@babel/react"]
});
这个文件的目的在于提供一个替代的 Babel 配置文件,相对于我们的标准.babelrc文件。如果你比较这两个文件,你应该会看到我们使用了require-context-hook插件。我们在使用npm run server启动后端时已经使用了这个插件。它允许我们使用正则表达式(regex)导入我们的 Sequelize 模型。
如果我们以npm run test开始测试,我们会在文件开始处要求这个文件。在babel-hook.js文件内部,我们加载@babel/register,它根据前面的配置编译测试中随后导入的所有文件。
注意
注意,当运行生产构建或环境时,也会使用生产数据库。所有更改都应用到这个数据库上。请确认你已经在服务器的configuration文件夹中正确配置了数据库凭据。你只需正确设置host、username、password和database环境变量即可。
这给了我们从测试文件中启动后端服务器并在服务器上渲染应用的选择。我们的测试准备工作现在已完成。在项目根目录下创建一个名为test的文件夹来存放所有可运行的测试。Mocha 将扫描所有文件或文件夹,所有测试都将被执行。为了运行一个基本的测试,创建一个app.test.js文件。这是主文件,确保我们的后端正在运行,我们可以在其中定义更多的测试。我们的测试的第一个版本如下所示:
const assert = require('assert');
const request = require('request');
const expect = require('chai').expect;
const should = require('chai').should();
describe('Graphbook application test', function() {
it('renders and serves the index page', function(done) {
request('http://localhost:8000', function(err, res,
body) {
should.not.exist(err);
should.exist(res);
expect(res.statusCode).to.be.equal(200);
assert.ok(body.indexOf('<html') !== -1);
done(err);
});
});
});
让我们更仔细地看看这里发生了什么,如下所示:
-
我们导入 Node.js 的
assert函数。这使我们能够验证变量的值或类型。 -
我们导入
request包,我们用它来向我们的后端发送查询。 -
我们从
chai包中导入两个 Chai 函数,expect和should。这两个函数都不包含在 Mocha 中,但它们都显著提高了测试的功能。 -
测试的开始部分使用
describe函数。因为 Mocha 执行app.test.js文件,所以我们处于正确的范围,可以使用所有 Mocha 函数。describe函数用于结构化你的测试及其输出。 -
我们使用
it函数,它启动第一个测试。
it 函数可以理解为回调函数内我们想要测试的应用程序功能。作为第一个参数,你应该输入一个易于阅读的句子,例如 'it does this and that'。函数本身等待第二个参数中的 callback 函数的完整执行。回调的结果将是所有断言都成功,或者由于某种原因测试失败,或者回调没有在合理的时间内完成。
describe 函数是我们测试输出的标题。然后,对于每个执行的 it 函数,我们都有一个新行。每一行代表一个单独的测试步骤。it 函数将一个 done 函数传递给回调。done 函数必须在所有断言完成后执行,且没有其他事情要做。如果在一定时间内没有执行,当前测试将被标记为失败。在先前的代码片段中,我们首先发送了一个 GET 请求到 http://localhost:8000,这被我们的后端服务器接受。预期的答案将以通过 React 创建的服务器端渲染的 超文本标记语言(HTML)的形式出现。
为了证明响应包含此信息,我们在先前的测试中做出了一些断言,如下所示:
-
我们使用 Chai 的
should函数。它的好处是它是可链式的,并且代表一个直接解释我们正在做什么意义的句子。should.not.exist函数链确保给定的值是空的。如果值是undefined或null等示例,结果将是true。结果是,当err变量被填充时,断言失败,因此我们的测试'渲染并服务首页'也失败了。 -
对于
should.exist这一行也是同样的道理。它确保了res变量,即后端给出的响应,被填充。否则,后端可能存在问题。 -
expect函数也可以像之前的两个函数一样表示一个句子。我们期望res.statusCode的值为200。这个断言可以写成expect(res.statusCode).to.be.equal(200)。如果我们收到 HTTP 状态码为200,我们可以确信一切顺利。 -
如果到目前为止没有失败,我们检查返回的
body变量,即request函数的第三个回调参数,是否有效。对于我们的测试场景,我们只需要检查它是否包含一个html标签。 -
我们执行
done函数。我们将err对象作为参数传递。这个函数的结果与should.not.exist函数类似。如果你将一个填充的错误对象传递给done函数,测试将失败。使用 Chai 语法时,测试变得更易读。
如果你现在执行 npm run test,你会收到以下错误:

图 11.1 – 服务器未运行导致的测试失败
我们第一次的should.not.exist断言失败并抛出了错误。这是因为我们在运行测试时没有启动后端。在第二个终端中使用正确的环境变量通过npm run server启动后端,然后重新运行测试。现在,测试成功,如下所示:
![Figure 11.2 – 测试通过如果服务器运行]
![Figure 11.02 – B17337.jpg]
Figure 11.2 – 测试通过如果服务器运行
输出结果很好,但过程并不十分直观。在部署应用程序或推送新的提交到您的版本控制系统(VCS)时自动运行测试,当前的流程很难实现。我们将在下一个版本中改变这种行为。
使用 Mocha 启动后端
当我们想要运行一个测试时,服务器应该自动启动。有两种方法可以实现这种行为,如下所述:
-
我们将
npm run server命令添加到package.json文件中的test脚本中。 -
我们在
app.test.js文件中导入所有必要的文件以启动服务器。这允许我们对后端运行更多的断言或命令。
最佳选择是在我们的测试中启动服务器,而不是依赖于第二个命令,因为我们可以在后端运行更多的测试。我们需要导入一个额外的包,以便在测试中启动服务器,如下所示:
require('babel-plugin-require-context-hook/register')();
我们使用并执行这个包,因为我们使用require.context函数加载 Sequelize 模型。通过加载这个包,require.context函数对服务器端代码是可执行的。在我们开始在测试中启动服务器之前,插件尚未使用,尽管它已在babel-hooks.js文件中加载。
现在,我们可以在测试中直接加载服务器。在刚刚编写的describe函数顶部添加以下代码行:
var app;
this.timeout(50000);
before(function(done) {
app = require('../src/server').default;
app.on("listening", function() {
done();
});
});
理念是在我们的测试中加载服务器的index.js文件,这会自动启动后端。为此,我们定义一个名为app的空变量。然后,我们使用this.timeout将 Mocha 中所有测试的超时时间设置为50000,因为启动我们的服务器,包括 Apollo Server,需要一些时间。否则,测试可能会因为启动时间过长而失败,这对于标准的 Mocha 超时来说太长了。
我们必须确保在执行任何测试之前服务器已经完全启动。这个逻辑可以通过 Mocha 的before函数实现。使用这个函数,你可以在我们的场景中设置和配置诸如启动后端等事情。为了继续并处理所有测试,我们需要执行done函数来完成before函数的回调。为了确保服务器已经启动,我们不仅在加载index.js文件后运行done函数。我们使用app.on函数绑定服务器的listening事件。如果服务器发出listening事件,我们可以安全地运行done函数,并且所有测试都可以向服务器发送请求。我们也可以直接将require函数的返回值保存到app变量中,以保存server对象。然而,这个顺序的问题在于服务器可能在我们可以绑定listening事件之前就开始监听。我们现在这样做确保服务器还没有启动。
然而,测试仍然没有工作。你会看到一个错误消息,说'TypeError: app.on is not a function'。仔细看看服务器的index.js文件。在文件的末尾,我们没有导出server对象,因为我们只使用它来启动后端。这意味着我们测试中的app变量是空的,我们无法运行app.on函数。解决方案是在服务器index.js文件的末尾导出server对象,如下所示:
export default server;
现在,你可以再次执行测试。一切看起来都应该是正常的,并且所有测试都应该通过。
然而,还有一个最后的问题。如果你比较在将服务器直接导入我们的测试或在一个第二个终端启动之前的行为,你可能会注意到测试并没有完成,或者至少进程没有停止。之前,所有步骤都执行了,我们回到了正常的 shell,并且可以执行下一个命令。这是因为服务器仍然在我们的app.test.js文件中运行。因此,我们必须在所有测试执行完毕后停止后端。在before函数之后插入以下代码:
after(function(done) {
app.close(done);
});
当所有测试完成后,会运行after函数。我们的app对象提供了close函数,该函数终止服务器。作为一个回调,我们传递done函数,一旦服务器停止,该函数就会被执行。这意味着我们的测试也已经完成。
验证正确的路由
我们现在想检查我们应用程序的所有功能是否按预期工作。我们应用程序的一个主要功能是 React Router 在以下两种情况下将用户重定向:
-
用户访问了一个无法匹配的路由。
-
用户访问了一个可以匹配的路由,但他们不允许查看页面。
在两种情况下,用户都应该被重定向到登录表单。在第一种情况下,我们可以采用与第一次测试相同的方法。我们向一个不在我们路由器内的路径发送请求。将以下代码添加到describe函数的底部:
describe('404', function() {
it('redirects the user when not matching path is found',
function(done) {
request({
url: 'http://localhost:8000/path/to/404',
}, function(err, res, body) {
should.not.exist(err);
should.exist(res);
expect(res.statusCode).to.be.equal(200);
assert.ok(res.req.path === '/');
assert.ok(body.indexOf('<html') !== -1);
assert.ok(body.indexOf('class="authModal"') !== -1);
done(err);
});
});
});
让我们快速回顾一下前面测试的所有步骤,如下:
-
我们添加一个新的
describe函数来结构化我们的测试输出。 -
我们在另一个
it函数内部向一个不匹配的路径发送请求。 -
检查与我们在启动服务器时使用的检查相同。
-
我们验证响应的路径是
/根路径。这是在执行重定向时发生的。因此,我们使用res.req.path === '/'条件。 -
我们检查返回的
body变量是否包含具有authModal类的 HTML 标签。这应该在用户未登录且渲染登录或注册表单时发生。
如果断言成功,我们知道 React Router 在第一种场景中工作正确。第二种场景与只能由认证用户访问的私有路由相关。我们可以复制前面的检查并替换请求。我们进行的断言保持不变,但请求的统一资源定位符(URL)不同。在前面一个测试下面添加以下测试:
describe('authentication', function() {
it('redirects the user when not logged in',
function(done) {
request({
url: 'http://localhost:8000/app',
}, function(err, res, body) {
should.not.exist(err);
should.exist(res);
expect(res.statusCode).to.be.equal(200);
assert.ok(res.req.path === '/');
assert.ok(body.indexOf('<html') !== -1);
assert.ok(body.indexOf('class="authModal"') !== -1);
done(err);
});
});
});
如果未认证的用户请求/app路由,他们将被重定向到/根路径。断言验证登录表单是否如之前一样显示。为了区分测试,我们添加一个新的describe函数,使其结构更好。
在本节中,我们学习了如何使用 Mocha 断言我们的应用程序工作正确。我们现在正在验证我们的应用程序是否启动,以及路由是否按预期工作并返回正确的页面。
接下来,我们想要测试我们构建的 GraphQL API,而不仅仅是服务器端渲染(SSR)功能。
使用 Mocha 测试 GraphQL API
我们必须验证我们提供的所有 API 函数是否正确工作。我将通过以下两个示例向您展示如何做到这一点:
-
用户需要注册或登录。这是一个关键特性,我们应该验证 API 是否正确工作。
-
用户通过 GraphQL API 查询或修改数据。对于我们的测试用例,我们将请求登录用户相关的所有聊天。
这两个示例应该解释测试 API 每个部分的所有基本技术。您可以在任何时刻添加更多您想要测试的函数。
测试认证
我们通过添加注册功能扩展了我们的测试认证。我们将向我们的后端发送一个简单的 GraphQL 请求,包括注册新用户所需的所有数据。我们已经发送了请求,所以这里没有什么新的内容。然而,与之前的所有请求相比,我们必须发送一个POST请求,而不是GET请求。此外,注册的端点是/graphql路径,我们的 Apollo Server 在这里监听传入的突变或查询。通常,当用户在 Graphbook 上注册时,认证令牌会直接返回,用户会登录。我们必须保留这个令牌以进行未来的 GraphQL 请求。我们不需要使用 Apollo Client 进行测试,因为我们不需要测试 GraphQL API。
在app变量旁边创建一个全局变量,用于存储注册后返回的JavaScript 对象表示法(JSON)Web 令牌(JWT),如下所示:
var authToken;
在测试内部,我们可以设置返回的 JWT。将以下代码添加到authentication函数中:
it('allows the user to sign up', function(done) {
const json = {
operationName: null,
query: "mutation signup($username: String!, $email :
String!,
$password : String!) { signup(username: $username,
email: $email,
password : $password) { token }}",
variables: {
"email": "mocha@test.com",
"username": "mochatest",
"password": "123456789"
}
};
request.post({
url: 'http://localhost:8000/graphql',
json: json,
}, function(err, res, body) {
should.not.exist(err);
should.exist(res);
expect(res.statusCode).to.be.equal(200);
body.should.be.an('object');
body.should.have.property('data');
authToken = body.data.signup.token;
done(err);
});
});
我们首先创建一个json变量。这个对象以 JSON 体形式发送到我们的 GraphQL API。其内容应该对你来说很熟悉——它几乎与我们之前在 Postman 中测试 GraphQL API 时使用的格式相同。
注意
我们发送的 JSON 代表了一种手动发送 GraphQL 请求的方式。有一些库你可以轻松使用来保存这个请求并直接发送查询,而不需要将其包裹在对象中,例如graphql-request:github.com/prisma-labs/graphql-request。
json对象包含用于创建具有mochatest用户名的用户的模拟注册变量。我们将使用request.post函数发送 HTTP POST请求。要使用json变量,我们将它传递到json字段。request.post函数会自动将正文作为 JSON 字符串添加,并为你添加正确的Content-Type头。当响应到达时,我们运行标准检查,例如检查错误或检查 HTTP 状态码。我们还检查返回的body变量的格式,因为响应的body变量不会返回 HTML,而是返回 JSON。我们确保它是一个使用should.be.an('object')函数的对象。should断言可以直接使用并链接到body变量。如果body是一个对象,我们检查其中是否有data属性。从body.data.signup.token属性读取令牌就足够安全了。
用户现在已创建在我们的数据库中。我们可以使用这个令牌进行后续请求。请注意,在本地机器上再次运行此测试很可能会导致失败,因为用户已经存在。在这种情况下,你可以手动从数据库中删除它。当使用持续集成(CI)运行此测试时,这个问题不会发生。我们将在最后一章中关注这个主题。接下来,我们将向 Apollo Server 进行认证查询并测试其结果。
测试认证请求
在注册请求之后,我们设置了 authToken 变量。如果测试时用户已经存在,您也可以使用登录请求来做这件事。只有查询和我们所使用的断言将会改变。此外,将以下代码插入到 before 认证函数中:
it('allows the user to query all chats', function(done) {
const json = {
operationName: null,
query: "query {chats {id users {id avatar username}}}",
variables: {}
};
request.post({
url: 'http://localhost:8000/graphql',
headers: {
'Authorization': authToken
},
json: json,
}, function(err, res, body) {
should.not.exist(err);
should.exist(res);
expect(res.statusCode).to.be.equal(200);
body.should.be.an('object');
body.should.have.property('data');
body.data.should.have.property(
'chats').with.lengthOf(0);
done(err);
});
});
如您在前面的代码片段中所见,json 对象不包含任何变量,因为我们只查询了已登录用户的聊天记录。我们相应地更改了 query 字符串。与登录或注册请求相比,聊天查询需要用户进行身份验证。我们保存的 authToken 变量被发送在 Authorization 头部中。我们现在再次验证请求是否成功,并在 body 变量中检查 data 属性。请注意,在运行 done 函数之前,我们验证 data 对象是否有一个名为 chats 的字段。我们还检查 chats 字段的长度,这证明了它是一个数组。长度可以静态设置为 0,因为发送查询的用户刚刚注册,还没有任何聊天记录。Mocha 的输出如下所示:

图 11.3 – 认证测试
这就是您需要了解的所有内容,以测试您 API 的所有功能。
接下来,我们将看看 Enzyme,这是一个伟大的测试工具,它允许我们与我们所编写的 React 组件进行交互,并确保它们按预期工作。
使用 Enzyme 测试 React
到目前为止,我们已经成功测试了我们的服务器和所有 GraphQL API 函数。然而,目前我们仍然缺少对前端代码的测试。当我们请求任何服务器路由,如 /app 路径时,我们只能访问最终结果,而不能访问每个组件。我们应该改变这一点,以执行某些无法通过后端测试的组件的功能。首先,在使用 npm 之前安装一些依赖项,如下所示:
npm install --save-dev enzyme @wojtekmaj/enzyme-adapter-react-17ignore-styles jsdom isomorphic-fetch
有关各种包的详细信息,请参阅此处:
-
enzyme和@wojtekmaj/enzyme-adapter-react-17包为 React 提供了特定的功能,以渲染和与 React 树进行交互。这可以通过真实的 文档对象模型(DOM)或浅渲染来实现。在本章中,我们将使用真实的 DOM,因为它允许我们测试所有功能,而浅渲染仅限于组件的第一层。我们需要依赖第三方包来提供 React 适配器,因为目前 Enzyme 对 React 17 没有官方支持。 -
ignore-styles包移除了所有针对 层叠样式表(CSS)文件的import语句。这非常有帮助,因为我们不需要 CSS 进行测试。 -
The
jsdompackage creates a DOM object for us, which is then used to render the React code into. -
isomorphic-fetch包替换了所有浏览器默认提供的fetch函数。这在 Node.js 中不可用,因此我们需要一个 polyfill。
我们首先在其他require语句下面直接导入新的包,如下所示:
require('isomorphic-fetch');
import React from 'react';
import { configure, mount } from 'enzyme';
import Adapter from @wojtekmaj/enzyme-adapter-react-17';
configure({ adapter: new Adapter() });
import register from 'ignore-styles';
register(['.css', '.sass', '.scss']);
要使用 Enzyme,我们导入 React。然后,我们为支持 React 16 的 Enzyme 创建一个适配器。我们将适配器插入到 Enzyme 的configure语句中。在开始前端代码之前,我们导入ignore-styles包以忽略所有 CSS 导入。我还直接排除了Syntactically Awesome Style Sheets(SASS)和 SCSS 文件。下一步是初始化我们的 DOM 对象,所有 React 代码都将在这里渲染。以下是您需要的代码:
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><html><body></body>
</html>', { url: 'http://graphbook.test' });
const { window } = dom;
global.window = window;
global.document = window.document;
我们需要jsdom包,并用一个小的 HTML 字符串初始化它。我们不需要使用服务器或客户端的模板文件,因为我们只想将我们的应用程序渲染到任何 HTML 中,所以它的外观并不重要。第二个参数是一个options对象。我们指定一个url字段,这是渲染 React 代码的主机 URL。否则,在访问localStorage时可能会出错。初始化后,我们提取window对象并定义两个全局变量,这些变量是挂载 React 组件到我们的模拟 DOM 所必需的。这两个属性在行为上类似于浏览器中的document和window对象,但它们不是浏览器中的全局对象,而是在我们的 Node.js 服务器内部的全局对象。
通常,将 Node.js 的global对象与浏览器的 DOM 混合在一起,并在其中渲染 React 应用程序并不是一个好主意。然而,我们只是在测试我们的应用程序,而不是在这个环境中运行生产环境,所以虽然这可能不被推荐,但它有助于使我们的测试更易于阅读。我们将从我们的登录表单开始第一个前端测试。访问我们页面的访客可以直接登录或切换到注册表单。目前,我们没有以任何方式测试这个切换功能。这是一个复杂的例子,但你应该能够快速理解其背后的技术。
要渲染我们的完整 React 代码,我们将为我们的测试初始化一个 Apollo 客户端。导入所有依赖项,如下所示:
import { ApolloClient, InMemoryCache, from } from '@apollo/client';
import { createUploadLink } from 'apollo-upload-client';
import App from '../src/server/ssr';
我们还导入了服务器端渲染的 React 代码的index.js组件。这个组件将接收我们的客户端,我们将在稍后初始化它。为所有前端测试添加一个新的describe函数,如下所示:
describe('frontend', function() {
it('renders and switches to the login or register form',
function(done) {
const httpLink = createUploadLink({
uri: 'http://localhost:8000/graphql',
credentials: 'same-origin',
});
const client = new ApolloClient({
link: from([
httpLink
]),
cache: new InMemoryCache()
});
});
});
上述代码创建了一个新的 Apollo 客户端。客户端不实现任何逻辑,例如身份验证或 WebSockets,因为我们不需要这些来测试从登录表单切换到注册表单。它只是一个渲染我们应用程序所必需的属性。如果你想测试仅在认证时渲染的组件,你当然可以轻松实现它。Enzyme 要求我们传递一个真实的 React 组件,该组件将被渲染到 DOM 中。在client变量下面直接添加以下代码:
class Graphbook extends React.Component {
render() {
return(
<App client={client} context={{}} loggedIn={false}
location= {"/"}/>
)
}
}
上述代码是围绕从服务器的ssr文件夹导入的App变量的小包装。client属性填充了新的 Apollo 客户端。按照给定的说明来渲染和测试你的 React 前端代码。以下代码直接位于Graphbook类下方:
-
我们使用 Enzyme 的
mount函数将Graphbook类渲染到 DOM 中,如下所示:const wrapper = mount(<Graphbook />); -
wrapper变量提供了许多函数来访问或与 DOM 及其内部组件交互。我们使用它来证明第一次渲染显示了登录表单。代码如下所示:expect(wrapper.html()).to.contain('<a>Want to sign up? Click here</a>');wrapper变量的html函数返回由 React 代码渲染的完整 HTML 字符串。我们使用 Chai 的contain函数来检查这个字符串。如果检查成功,我们可以继续。 -
通常,用户点击
wrapper变量。Enzyme 自带这种功能,如下所示:wrapper.find('LoginRegisterForm').find('a').simulate('click');find函数使我们能够访问LoginRegisterForm组件。在组件的标记内部,我们搜索一个a标签,只能有一个。如果find方法返回多个结果,我们无法触发点击等操作,因为simulate函数固定只针对一个可能的靶点。在运行了两个find函数之后,我们执行 Enzyme 的simulate函数。唯一需要的参数是我们想要触发的事件。在我们的场景中,我们在a标签上触发一个click事件,这允许 React 处理其余部分。 -
我们通过执行以下代码来检查表单是否正确更改:
expect(wrapper.html()).to.contain('<a>Want to login? Click here</a>'); done();我们使用
html和contain函数来验证是否正确渲染了所有内容。使用 Mocha 的done方法来完成测试。注意
要获取 API 的更详细概述以及 Enzyme 提供的所有函数,请查看官方文档:
enzymejs.github.io/enzyme/docs/api/。
这部分比较简单。当我们想要验证客户端是否可以发送带有身份验证的查询或突变时,这个过程实际上并没有那么不同。我们已注册了一个新用户并获得了 JWT。我们只需要将 JWT 附加到我们的 Apollo 客户端上,并且路由器需要接收正确的loggedIn属性。这个测试的最终代码如下所示:
it('renders the current user in the top bar', function(done) {
const AuthLink = (operation, next) => {
operation.setContext(context => ({
...context,
headers: {
...context.headers,
Authorization: authToken
},
}));
return next(operation);
};
const httpLink = createUploadLink({
uri: 'http://localhost:8000/graphql',
credentials: 'same-origin',
});
const client = new ApolloClient({
link: from([
AuthLink,
httpLink
]),
cache: new InMemoryCache()
});
class Graphbook extends React.Component {
render() {
return(
<App client={client} context={{}} loggedIn={true}
location= {"/app"}/>
)
}
}
const wrapper = mount(<Graphbook />);
setTimeout(function() {
expect(wrapper.html()).to.contain(
'<div class="user"><img>
<span>mochatest</span></div>');
done();
},2000);
});
在这里,我们使用的是我们在原始前端代码中使用的AuthLink函数。我们将authToken变量传递给 Apollo Client 发出的每一个请求。在Apollo.from方法中,我们在httpLink之前添加它。在Graphbook类中,我们将loggedIn设置为true,将location设置为/app以渲染新闻源。由于请求默认是异步的,而mount方法不会等待 Apollo Client 获取所有查询,所以我们不能直接检查 DOM 以获取正确的内容。相反,我们将断言和done函数包裹在一个setTimeout函数中。运行了 2,000 毫秒的currentUser查询,并且顶栏已经渲染出来以显示已登录用户。通过这两个示例,你现在应该能够使用你的应用程序的前端代码运行任何你想要的测试。
摘要
在本章中,我们学习了测试应用程序自动化的所有基本技术,包括测试服务器、GraphQL API 和用户的客户端。你可以将你学到的 Mocha 和 Chai 模式应用到其他项目中,以在任何时候达到高软件质量。你的个人测试时间将大大减少。
在下一章中,我们将探讨如何提高性能和错误日志记录,以确保我们始终提供良好的用户体验(UX)。
第三部分:准备部署
我们已经到达旅程的终点;你已经完成了工作的主要部分。剩下的最后一部分是将你的应用程序真正发布给广大受众。在本节中,你将学习如何通过 CircleCI 安全且持续地将你的应用程序部署到 AWS。
在本节中,包含以下章节:
- 第十二章, 使用 CircleCI 和 AWS 进行持续部署
第十二章:使用 CircleCI 和 AWS 进行持续部署
在最后两章中,我们通过 Mocha 测试准备我们的应用程序。我们已经构建了一个适用于生产环境的应用程序。
我们现在将生成一个准备就绪的生产构建版本,以便部署。我们已经到达了可以设置 Amazon Elastic Container Service (Amazon ECS)并实现通过持续部署工作流程构建和部署 Docker 镜像的阶段。
持续部署的过程将有助于保持生产环境中的更改较小。保持应用程序中的更改持续且小,将使问题可追踪和可修复,而一次性发布一组多个功能将留下错误的位置供调查,因为仅通过一个发布就会有许多事情发生变化。
本章涵盖了以下主题:
-
生产就绪的打包
-
什么是 Docker?
-
配置 Docker
-
设置 AWS RDS(即 AWS 关系数据库服务)作为生产数据库
-
什么是持续集成/持续部署?
-
设置 CircleCI 的持续部署
-
将我们的应用程序部署到 Amazon Elastic Container Registry (Amazon ELB)和 ECS,使用 AWS 应用程序负载均衡器 (ALB)
技术要求
本章的源代码可在以下 GitHub 仓库中找到:
准备最终的生产构建
我们已经走了很长的路才到达这里。现在是时候审视我们当前如何运行我们的应用程序以及我们应该如何为生产环境做准备的时候了。
目前,我们在开发环境中使用我们的应用程序,同时对其进行工作。它没有针对性能或低带宽使用进行高度优化。我们在代码中包含开发功能,以便我们可以正确地调试它。
在实际生产环境中使用时,我们应该只包含用户所需的内容。当将 NODE_ENV 变量设置为 production 时,我们将移除大部分不必要的开发机制。
通过打包我们的服务器端代码,我们将消除不必要的加载时间并提高性能。为了打包我们的后端代码,我们将设置一个新的 webpack 配置文件。请按照以下说明操作:
-
安装以下两个依赖项:
npm install --save-dev webpack-node-externals @babel/plugin-transform-runtime这些包执行以下操作:
-
webpack-node-externals包在您使用 webpack 打包应用程序时提供了排除特定模块的选项。它减少了最终的包大小。 -
@babel/plugin-transform-runtime包是一个小的插件,它使我们能够重用 Babel 的辅助方法,这些方法通常会被插入到每个处理的文件中。它减少了最终的包大小。
-
-
在其他 webpack 文件旁边创建一个
webpack.server.build.config.js文件,内容如下:const path = require('path'); const nodeExternals = require('webpack-node-externals'); const buildDirectory = 'dist/server'; module.exports = { mode: 'production', entry: [ './src/server/index.js' ], output: { path: path.join(__dirname, buildDirectory), filename: 'bundle.js', publicPath: '/server' }, module: { rules: [{ test: /\.js$/, use: { loader: 'babel-loader', options: { plugins: ["@babel/plugin-transform-runtime"] } }, }], }, node: { __dirname: false, __filename: false, }, target: 'node', externals: [nodeExternals()], plugins: [], };上述配置文件非常简单,并不复杂。让我们看看我们用来配置 webpack 的设置:
-
我们在顶部加载了新的
webpack-node-externals包。 -
我们保存包的
build目录位于dist文件夹内,一个特殊的server文件夹中。 -
mode字段设置为'production'。 -
webpack 的
entry点是服务器的根index.js文件。 -
output属性包含打包我们的代码的标准字段,并将它保存在通过buildDirectory变量指定的文件夹内。 -
我们在
module属性中使用之前安装的@babel/plugin-transform-runtime插件来减少我们的包文件大小。 -
在
node属性内部,你可以设置 Node.js 特定的配置选项。__dirname字段告诉 webpack 全局的__dirname变量使用默认设置,并且没有被 webpack 自定义。对于__filename属性也是如此。 -
target字段接受多个环境,其中生成的包应该在这些环境中工作。在我们的案例中,我们将其设置为'node',因为我们想在 Node.js 中运行我们的后端。 -
externals属性给我们提供了排除特定依赖项从我们的包中的可能性。通过使用webpack-node-externals包,我们防止了所有node_modules包被包含在我们的包中。
-
-
为了使用我们新的构建配置文件,我们在
package.json文件的scripts字段中添加了两个新的命令。因为我们试图生成一个可以公开的最终生产构建,我们必须并行构建我们的客户端代码。将以下两行添加到package.json文件的scripts字段:"build": "npm run client:build && npm run server:build", "server:build": "webpack --config webpack.server.build.config.js"build命令使用&&语法来链式执行两个npm run命令。它首先执行客户端代码的构建过程,然后打包整个服务器端代码。结果是我们在dist文件夹中有一个包含client文件夹和server文件夹的完整文件夹。两者都可以动态导入组件。 -
为了使用新的生产代码启动我们的服务器,我们将在
scripts字段中添加一个额外的命令。旧的npm run server命令会启动未打包的服务器端代码,这不是我们想要的。将以下行插入到package.json文件中:"server:production": "node dist/server/bundle.js"上述命令简单地从
dist/server文件夹执行bundle.js文件,使用普通的node命令启动我们的后端。现在,你应该可以通过运行
npm run build来生成你的最终构建。然而,在开始作为测试启动生产服务器之前,请确保你已经正确设置了所有数据库的环境变量,例如JWT_SECRET。然后,你可以执行npm run server:production命令来启动后端。 -
我们需要以反映相同生产条件的方式运行测试,因为只有这样我们才能验证所有在实时环境中启用的功能都能正确工作。为了确保这一点,我们需要改变执行测试的方式。编辑
package.json文件中的test命令以反映此更改,如下所示:"test": "npm run build && mocha --exit test/ --require babel-hook --require @babel/polyfill --recursive",现在,你应该能够使用生成的生产包测试你的应用程序。
在下一节中,我们将介绍如何使用 Docker 捆绑整个应用程序。
设置 Docker
发布应用程序是一个关键步骤,需要大量的工作和细心。在发布新版本时,许多事情可能会出错。我们已经确保在应用程序上线之前可以对其进行测试。
将我们的本地文件转换成生产就绪包的实际操作,然后将其上传到服务器,这是最繁重的工作。常规应用程序通常依赖于预先配置了应用程序运行所需所有包的服务器。例如,在查看标准的 PHP 设置时,大多数人会租用一个预先配置的服务器。这意味着 PHP 运行时,包括所有扩展,如 MySQL PHP 库,都是通过操作系统的内置包管理器安装的。这个程序不仅适用于 PHP,也适用于几乎所有其他编程语言。这可能适用于一般的网站或不太复杂的应用程序,但对于专业软件开发或部署,这个过程可能会导致以下问题:
-
配置必须由了解应用程序和服务器本身需求的人来完成。
-
第二个服务器需要相同的配置才能允许我们的应用程序运行。在执行此配置时,我们必须确保所有服务器都是标准化的,并且彼此一致。
-
当运行时环境更新时,所有服务器都必须重新配置,无论是由于应用程序需要,还是由于其他原因,如安全更新。在这种情况下,必须再次进行测试。
-
在同一服务器环境中运行的多个应用程序可能需要不同的包版本,或者可能相互干扰。
-
部署过程必须由具备所需知识的人执行。
-
在服务器上直接启动应用程序会使其暴露于服务器上运行的所有服务。由于它们在相同的环境中运行,其他进程可能会接管你的完整应用程序。
-
此外,应用程序的使用并不限于服务器资源的指定最大值。
许多人试图通过引入新的容器化和部署工作流程来避免这些后果。
什么是 Docker?
最重要的软件之一被称为 Docker。它于 2013 年发布,其功能是通过提供自己的运行时环境来在容器内隔离应用程序,而不需要访问服务器本身。
容器的目的是将应用程序从服务器的操作系统隔离出来。
标准虚拟机也可以通过为应用程序运行一个客户操作系统来完成这项任务。在虚拟机内部,所有包和运行时都可以安装,以便为您的应用程序做准备。当然,这种解决方案伴随着显著的开销,因为我们正在运行一个仅用于我们应用程序的第二个操作系统。当涉及许多服务或多个应用程序时,这种方案是不可扩展的。
另一方面,Docker 容器的工作方式完全不同。应用程序本身及其所有依赖项都从操作系统的资源中获取一个部分。所有进程都在这些资源内部由主机系统隔离。
任何支持容器运行时环境(即 Docker)的服务器都可以运行您的 docker 化应用程序。好处在于实际的操作系统被抽象化了。您的操作系统将会非常精简,因为除了内核和 Docker 之外,不需要其他任何东西。
使用 Docker,开发者可以指定容器镜像的组成方式。他们可以直接在他们的基础设施上测试和部署这些镜像。
为了看到 Docker 提供的过程和优势,我们将构建一个包含我们的应用程序及其运行所需的所有依赖项的容器镜像。
安装 Docker
与任何虚拟化软件一样,Docker 必须通过操作系统的常规包管理器进行安装。
我将假设您正在使用基于 Debian 的系统。如果不是这种情况,请前往 docs.docker.com/install/overview/ 获取您系统的正确说明。
按照以下说明继续操作,以启动并运行 Docker:
-
按照以下步骤更新您的系统包管理器:
sudo apt-get update -
我们可以按照以下步骤在我们的系统上安装 Docker 包:
sudo apt-get install docker如果您正在运行已安装 snap 的 Ubuntu 版本,您还可以使用以下命令:
sudo snap install docker
这就是要在您的系统上获取一个可工作的 Docker 复制品所需的所有内容。
接下来,您将通过构建您的第一个 Docker 容器镜像来学习如何使用 Docker。
Docker 化您的应用程序
许多公司已经采用 Docker 并替换了他们的旧基础设施设置,从而在很大程度上减少了系统管理。然而,在将应用程序直接部署到生产之前,还有一些工作要做。
一个主要任务是将您的应用程序 docker 化。术语 dockerize 意味着您需要负责将应用程序包裹在一个有效的 Docker 容器内。
有许多服务提供商将 Docker 与 CI 或持续部署连接起来,因为它们可以很好地协同工作。在本章的最后部分,你将了解什么是持续部署以及如何实现它。我们将依赖这样的服务提供商。它将为我们提供持续部署过程的自动工作流程。让我们首先开始将我们的应用程序 docker 化。
编写你的第一个 Dockerfile
生成应用程序 Docker 镜像的传统方法是,在项目的根目录下创建一个 Dockerfile。但 Dockerfile 是做什么用的?
Dockerfile 是一系列通过 Docker 命令行界面(CLI)运行的命令。此类文件中的典型工作流程如下:
-
Dockerfile从一个基础镜像开始,使用FROM命令导入。这个基础镜像可能包括运行时环境,如 Node.js,或者你的项目可以利用的其他东西。容器镜像从 Docker Hub 下载,Docker Hub 是一个中心容器注册表,你可以在hub.docker.com/找到它。也有从自定义注册表中下载镜像的选项。 -
Docker 提供了许多命令来与镜像和应用程序代码进行交互。这些命令可以在
docs.docker.com/engine/reference/builder/中查找。 -
在镜像配置完成并且所有构建步骤完成后,你需要提供一个命令,当你的应用程序的 Docker 容器启动时将执行该命令。
-
构建步骤的结果将是一个新的 Docker 镜像(见 图 12.1)。该镜像保存在生成它的机器上。
-
可选地,你现在可以将你的新镜像发布到注册表中,其他应用程序或用户可以拉取你的镜像。你也可以将它们作为私有镜像或私有注册表上传。
我们将首先生成一个简单的 Docker 镜像。首先,在你的项目根目录下创建 Dockerfile。文件名没有写任何文件扩展名。
第一项任务是找到一个匹配的基础镜像,我们可以用它来构建我们的项目。我们选择基础镜像的标准是依赖项和运行时环境。由于我们主要使用了 Node.js,没有依赖任何其他需要在 Docker 容器中覆盖的服务端包,我们只需要找到一个提供 Node.js 的基础镜像。目前,我们将忽略数据库,稍后我们将在后续步骤中再次关注它。
Docker Hub 是官方的容器镜像注册表,提供许多最小化镜像。只需在我们的新 Dockerfile 中插入以下行,位于我们项目的根目录下:
FROM node:14
正如我们之前提到的,我们使用FROM命令下载基础镜像。正如前面镜像的名称所表明的,它包含 Node.js 版本 14。您还可以使用许多其他版本。除了不同的版本之外,您还可以找到不同的风味(例如,基于 Alpine Linux 的 Node.js 镜像)。查看镜像的README以了解可用的选项,请访问hub.docker.com/_/node/。
重要提示
我建议您阅读Dockerfile的参考文档。那里解释了许多高级命令和场景,这将帮助您自定义 Docker 工作流程。只需访问docs.docker.com/engine/reference/builder/即可。
在 Docker 运行了FROM命令之后,您将直接在这个基础镜像中工作,并且所有后续命令都将在这个环境中运行。您可以访问底层操作系统提供的所有功能。当然,这些功能受您选择的镜像限制。只有以FROM命令开始的Dockerfile才是有效的。
我们Dockerfile的下一步是创建一个新的文件夹,应用程序将存储并在此运行。将以下行添加到Dockerfile中:
WORKDIR /usr/src/app
WORKDIR命令将目录更改为指定的路径。您输入的路径位于镜像的文件系统中,不会影响您的计算机文件系统。从那时起,RUN、CMD、ENTRYPOINT、COPY和ADDDocker 命令将在新的工作目录中执行。此外,如果该文件夹不存在,WORKDIR命令将创建一个新的文件夹。
接下来,我们需要将我们的应用程序代码放入新文件夹中。到目前为止,我们只确保加载了基础镜像。我们正在生成的镜像还没有包含我们的应用程序。Docker 提供了一个命令将我们的代码移动到最终镜像中。
在我们的Dockerfile的第三行中添加以下代码:
COPY . .
COPY命令接受两个参数。第一个参数是源,可以是文件或文件夹。第二个参数是镜像文件系统中的目标路径。您可以使用正则表达式的一个子集来过滤复制的文件或文件夹。
在 Docker 执行了前面的命令之后,当前目录中所有存在的文件内容都将被复制到/usr/src/app路径。在这个例子中,当前目录是我们的项目文件夹的根目录。现在所有文件都自动包含在最终的 Docker 镜像中。您可以通过所有 Docker 命令以及 shell 提供的命令与这些文件进行交互。
一个重要的任务是安装我们应用程序所依赖的所有npm包。当运行COPY命令时,例如在前面代码中,所有文件和文件夹都会被传输,包括node_modules文件夹。然而,这可能会在尝试运行应用程序时导致问题。许多npm包在安装时会进行编译,或者它们会在不同的操作系统之间进行区分。我们必须确保我们使用的包是干净的,并且在我们希望它们工作的环境中工作。为了实现这一点,我们必须做两件事,如下所示:
-
在项目文件夹的根目录中创建一个
.dockerignore文件,位于Dockerfile旁边,并输入以下内容:node_modules.dockerignore文件类似于.gitignore文件,它排除特殊文件或文件夹不被 Git 跟踪。Docker 在将所有文件发送到 Docker 守护进程之前读取.dockerignore文件。如果它能够读取有效的.dockerignore文件,则所有指定的文件和文件夹都将被排除。前面的两行排除了整个node_modules文件夹。 -
在 Docker 内部安装
npm包。将以下代码行添加到Dockerfile中:RUN command executes npm install inside of the current working directory. The related package.json file and node_modules folder are stored in the filesystem of the Docker image. Those files are directly committed and are included in the final image. Docker's RUN command sends the command that we pass as the first parameter into Bash and executes it. To avoid the problems of spaces in the shell commands, or other syntax problems, you can pass the command as an array of strings, which will be transformed by Docker into valid Bash syntax. Through RUN, you can interact with other system-level tools (such as apt-get or curl).Now that all files and dependencies are in the correct filesystem, we can start Graphbook from our new Docker image. Before doing so, there are two things that we need to do – we have to allow for external access to the container via the IP and define what the container should do when it has started. -
Graphbook 默认使用端口
8000,在此端口下它监听传入的请求,无论是 GraphQL 还是普通 Web 请求。当运行 Docker 容器时,它会获得自己的网络,包括 IP 和端口。我们必须确保端口8000对公众可用,而不仅仅是容器内部。在Dockerfile的末尾插入以下行,以便从容器外部访问端口:EXPOSE 8000你必须理解
EXPOSE命令不会将容器内部的端口8000映射到我们的工作机的匹配端口。通过编写EXPOSE命令,你给使用该镜像的开发者提供了将端口8000发布到运行容器的真实机器上的任何端口的选项。映射是在启动容器时完成的,而不是在构建镜像时。在本章的后面部分,我们将探讨如何将端口8000映射到你的本地机器上的端口。 -
最后,我们必须告诉 Docker 容器启动后应该做什么。在我们的例子中,我们希望启动我们的后端(包括 SSR)。由于这是一个简单的示例,我们将启动开发服务器。
添加
Dockerfile的最后一行,如下所示:CMD [ "npm", "run", "server" ]CMD命令定义了我们的容器启动的方式以及要运行的命令。我们使用 Docker 的exec选项传递字符串数组。Dockerfile只能有一个CMD命令。使用CMD时,exec格式不会运行 Bash 或 shell 命令。容器执行
package.json文件中的server脚本,该脚本已被复制到 Docker 镜像中。
到目前为止,一切都已经完成并准备好生成基本的 Docker 镜像。接下来,我们将继续进行获取并运行容器。
构建和运行 Docker 容器
Dockerfile 和 .dockerignore 文件已准备就绪。Docker 为我们提供了生成真实镜像的工具,我们可以运行或与他人共享这个镜像。仅仅拥有一个Dockerfile并不能使应用程序实现 Docker 化。
确保在/server/config/index.js文件中指定的后端数据库凭据对开发有效,因为它们被静态保存在那里。此外,MySQL 主机必须允许容器内部的远程连接。
在您的本地机器上构建 Docker 镜像,请执行以下命令:
docker build -t sgrebe/graphbook .
此命令要求您已安装 Docker CLI 和守护进程。
我们使用的第一个选项是-t,后面跟着一个字符串(在我们的例子中,是sgrebe/graphbook)。构建完成的镜像将被保存在用户名sgrebe和应用程序名graphbook下。这段文本也被称为tag。docker build命令的唯一必需参数是构建上下文,即 Docker 用于容器的文件集。我们通过在命令末尾添加点来指定当前目录作为构建上下文。此外,build操作期望Dockerfile位于此文件夹内。如果您想从其他地方获取文件,可以使用--file选项指定。
重要提示
如果docker build命令失败,可能是因为缺少一些环境变量。它们通常包括 Docker 守护进程的 IP 和端口。要查找它们,请执行docker-machine env命令,并设置命令返回的环境变量。
当命令完成镜像生成后,它应该在本地上可用。为了证明这一点,您可以通过运行以下命令使用 Docker CLI:
docker images
Docker 的输出应该如下所示:

图 12.1 – Docker 镜像
您应该看到两个容器;第一个是sgrebe/graphbook容器镜像,或者您使用的任何标签名称。第二个应该是node镜像,这是我们用作自定义 Docker 镜像的基础。自定义镜像的大小应该大得多,因为我们安装了所有的npm包。
现在,我们应该能够使用这个新镜像启动我们的 Docker 容器。以下命令将启动您的 Docker 容器:
docker run -p 8000:8000 -d --env-file .env sgrebe/graphbook
docker run命令也只有一个必需参数,即用于启动容器的镜像。在我们的例子中,这是sgrebe/graphbook,或者您指定的任何标签名称。尽管如此,我们定义了一些可选参数,我们需要它们来使我们的应用程序正常工作。您可以在以下内容中找到每个参数的解释:
-
我们将
-p选项设置为8000:8000。该参数用于将实际主机操作系统的端口映射到 Docker 容器内的特定端口。第一个端口是主机机的端口,第二个端口是容器的端口。此选项使我们能够访问暴露的端口8000,应用程序在我们的本地机器的http://localhost:8000下运行。 -
--env-file参数是必需的,用于将环境变量传递到容器中。这些变量可以用来传递NODE_ENV或JWT_SECRET变量,例如,这些变量是我们整个应用程序所必需的。我们将在下一秒创建此文件。 -
您也可以使用
-e选项逐个传递环境变量。然而,提供文件要容易得多。 -
-d选项将容器设置为docker run命令提供了许多更多选项。它允许进行各种高级设置。官方文档的链接是docs.docker.com/engine/reference/run/#general-form。
让我们在项目的根目录中创建.env文件。插入以下内容,将所有占位符替换为每个环境变量的正确值:
NODE_ENV=development
JWT_SECRET=YOUR_JWT_SECRET
AWS_ACCESS_KEY_ID=YOUR_AWS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
.env文件是一个简单的键值列表,您可以在其中指定每行一个变量,我们的应用程序可以从其环境变量中访问这些变量。
在任何阶段都绝对不要将此文件提交到公共领域。请直接将此文件添加到.gitignore文件中。
如果您已填写此文件,您将能够使用我之前向您展示的命令启动 Docker 容器。现在容器以分离模式运行,您将遇到一个问题,即您无法确定 Graphbook 是否已经开始监听。因此,Docker 还提供了一个命令来测试这一点,如下所示:
docker ps
docker ps命令为您提供了一个所有正在运行的容器的列表。您应该在那里找到 Graphbook 容器。输出应如下所示:

12.2 – Docker 运行容器
重要提示
与 Docker 提供的所有命令一样,docker ps命令为我们提供了许多选项来自定义和过滤输出。请在官方文档中了解它提供的所有功能,链接为docs.docker.com/engine/reference/commandline/ps/。
我们运行的容器正在使用我们指定的数据库。您应该能够通过访问http://localhost:8000来像以前一样使用 Graphbook。
如果您查看前面的图示,您将看到所有正在运行的容器都有自己的 ID。这个 ID 可以在各种情况下用来与容器交互。
在开发中,能够访问我们的应用程序生成的命令行输出是有意义的。当以分离模式运行容器时,你必须使用 Docker CLI 来查看输出,使用以下命令。将命令末尾的 ID 替换为你容器 ID:
docker logs 08499322a998
docker logs 命令将显示我们的应用程序或容器最近做出的所有输出。将前面的 ID 替换为 docker ps 命令提供的 ID。如果你想在使用 Graphbook 时实时查看日志,可以添加 --follow 选项。
由于我们以分离模式运行容器,你将无法像以前那样仅使用 Ctrl + C 来停止它。相反,你必须再次使用 Docker CLI。
要再次停止容器,请运行以下命令:
docker stop 08499322a998
要最终将其删除,请运行以下命令:
docker rm 08499322a998
docker rm 命令会停止并从系统中删除容器。对容器内部文件系统所做的任何更改都将丢失。如果你再次启动镜像,将创建一个新的容器,拥有一个干净的文件系统。
当你频繁地使用 Docker 进行工作和开发时,你可能会生成许多镜像来测试和验证你应用程序的部署。这些镜像在你的本地机器上占用大量空间。要删除镜像,你可以执行以下命令:
docker rmi fe30bceb0268
ID 可以从 docker images 命令中获取,该命令的输出你可以看到本节第一张图片。只有当镜像未被用于运行中的容器时,你才能删除它。
我们已经取得了很大的进展。我们已经成功地将我们的应用程序容器化。然而,它仍在开发模式下运行,所以还有很多事情要做。
多阶段 Docker 生产构建
我们当前创建的 Docker 镜像,是从 Dockerfile 生成的,已经很有用了。我们希望我们的应用程序能够在生产模式下进行转译并运行,因为许多内容在开发模式下运行时并未针对公众进行优化。
显然,在生成 Docker 镜像时,我们必须运行后端和前端的构建脚本。
到目前为止,我们已经将所有 npm 包和所有文件及文件夹安装到我们的项目容器镜像中。这对于开发来说是可以的,因为这个镜像尚未发布或部署到生产环境中。当你的应用程序上线时,你希望你的镜像尽可能瘦小和高效。为了实现这一点,我们将使用所谓的多阶段构建。
在 Docker 实现允许多阶段构建的功能之前,你必须依赖诸如使用 shell 命令仅保留容器镜像中实际需要的文件等技巧。我们面临的问题是,我们从项目文件夹中复制了所有用于构建实际分发代码的文件。然而,这些文件在生产 Docker 容器中并不需要。
让我们看看在现实中这看起来如何。你可以备份或删除我们之前编写的第一个 Dockerfile,因为我们现在将从一个空白文件开始。新文件仍然需要被命名为 Dockerfile。所有接下来的代码行都将直接写入这个空白的 Dockerfile。按照以下说明来运行多阶段生产构建:
-
我们的新文件再次以
FROM命令开始。我们将有多个FROM语句,因为我们正在准备一个多阶段构建。第一个应该看起来如下:FROM node:14 AS build我们在这里引入第一个构建阶段。和之前一样,我们使用的是 14 版本的
node镜像。此外,我们添加了AS build后缀,这告诉 Docker,这个阶段以及我们在其中所做的所有操作,将来都可以通过名称build访问。每次新的FROM命令都会启动一个新的阶段。 -
接下来,我们初始化工作目录,就像我们在第一个
Dockerfile中做的那样,如下所示:WORKDIR /usr/src/app -
只复制我们真正需要的文件是至关重要的。如果你能减少需要处理的文件数量,这将极大地提高性能:
COPY .babelrc ./ COPY package*.json ./ COPY webpack.server.build.config.js ./ COPY webpack.client.build.config.js ./ COPY src src COPY assets assets COPY public public我们复制
.babelrc、package.json、package-lock.json和 webpack 文件,这些文件是我们应用程序所需的。它们包括我们生成前端和后端生产构建所需的所有信息。此外,我们还复制了src、public和assets文件夹,因为它们包含了将被转换和捆绑的代码和 CSS。 -
就像在我们的第一个
Dockerfile中一样,我们必须安装所有的npm包;否则,我们的应用程序将无法工作。我们通过以下代码行来完成这项工作:RUN npm install -
在所有包安装成功后,我们可以开始构建过程。我们在本章的第一部分添加了
build脚本。将以下行添加到执行脚本的命令中,该脚本将在 Docker 镜像中生成生产捆绑包:RUN npm run build以下命令将为我们生成一个
dist文件夹,其中将存储可运行的代码(包括 CSS)。在创建完包含所有捆绑包的dist文件夹后,我们将不再需要最初复制到当前构建阶段的多数文件。 -
为了得到一个只包含
dist文件夹和运行应用程序所需的文件的干净 Docker 镜像,我们将引入一个新的构建阶段来生成最终镜像。新的阶段通过第二个FROM语句开始,如下所示:FROM node:14我们在这个构建步骤中构建最终镜像;因此,它不需要自己的名称。
-
再次强调,我们需要为第二个阶段指定工作目录,因为路径不会从第一个构建阶段复制过来:
WORKDIR /usr/src/app -
在继续之前,我们需要确保应用程序可以访问所有环境变量。为此,将以下行添加到
Dockerfile中:ENV NODE_ENV production ENV JWT_SECRET JWT_SECRET ENV username YOUR_USERNAME ENV password YOUR_PASSWORD ENV database YOUR_DATABASE ENV host YOUR_HOST ENV AWS_ACCESS_KEY_ID AWS_ACCESS_KEY_ID ENV AWS_SECRET_ACCESS_KEY AWS_SECRET_ACCESS_KEY我们使用 Docker 的
ENV命令在构建镜像时填充环境变量。 -
由于我们已经给第一个构建阶段起了一个名字,我们可以通过这个名字访问这个阶段的文件系统。要复制第一个阶段的文件,我们可以在
COPY语句中添加一个参数。将以下命令添加到Dockerfile中:COPY --from=build /usr/src/app/package.json package.json COPY --from=build /usr/src/app/dist dist COPY start.sh start.sh COPY src/server src/server正如你应该在前面的代码中看到的那样,我们正在复制
package.json文件和dist文件夹。然而,我们不是从原始项目文件夹中复制文件,而是直接从第一个构建阶段获取这些文件。为此,我们使用--from选项,后面跟着我们想要访问的阶段名称;因此,我们输入名称build。package.json文件是必需的,因为它包含了所有依赖项和scripts字段,该字段包含了如何在生产中运行应用程序的信息。dist文件夹当然是我们的打包应用程序。此外,我们复制了一个我们将创建的
start.sh文件和服务器文件夹,因为在那里我们有所有的数据库迁移。 -
注意,我们只复制
package.json文件和dist文件夹。我们的npm依赖项不包括在dist文件夹中的应用程序构建中。因此,我们还需要在第二个构建阶段安装npm包:npm packages that are really required; npm offers the only parameter, which lets you install only the production packages, as an example. It will exclude all devDependecies of your package.json file. This is really great for keeping your image size low.Then, there are three `npm` packages that are technically not a dependency, which is defined in our `package.json` file, because they are not required to get our application running. Still, they are needed to get our database migrations applied. Add the following `RUN` command to the `Dockerfile`:mysql2包。我们将利用它们在 Docker 容器启动时应用迁移。还有其他方法可以手动触发它们,而不是在 Docker 容器启动时,但这将适用于我们的设置。 -
在这里需要做的最后两件事是公开容器的端口并执行
CMD命令,这将允许容器启动时运行package.json文件中的命令:EXPOSE 8000 CMD [ "sh", "start.sh" ] -
最后,我们需要在项目的根目录下创建一个
start.sh文件,内容如下:sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js --env production npm run server:production在
start.sh文件中,我们有两行。第一行运行所有数据库迁移。最后一行基于生成的生产包启动服务器。
现在,你可以再次执行 docker build 命令并尝试启动容器。唯一的问题是——在生产环境中运行时,数据库凭据是从环境变量中读取的。由于数据库的生产设置不能在我们的本地机器上,它需要存在于真实的服务器上的某个地方。我们也可以通过 Docker 完成这个任务,但这将涉及一个非常高级的 Docker 配置。我们需要将 MySQL 数据保存在单独的存储中,因为 Docker 默认不会持久化任何类型的数据。
个人来说,我喜欢依赖云主机,它为我处理数据库设置。这不仅对整体设置有很大帮助,还提高了我们应用程序的可扩展性。下一节将介绍 Amazon RDS 以及如何为我们的应用程序配置它。你可以使用你喜欢的任何数据库基础设施。
Amazon RDS
AWS 提供 Amazon RDS,这是一个只需点击几下即可轻松设置关系型数据库的工具。简而言之,我将解释如何使用 RDS 创建您的第一个数据库,之后我们将探讨如何正确插入环境变量,以便与应用程序建立数据库连接。
第一步是登录到 AWS 管理控制台,就像我们在第七章中做的那样,处理图像上传。您可以通过点击RDS来找到该服务。
在导航到RDS后,您将看到仪表板,如下面的截图所示:

图 12.3 – AWS RDS
按照以下说明设置 RDS 数据库:
-
通过点击创建数据库按钮初始化一个新的数据库。您将看到一个新屏幕,您应该选择我们新数据库的引擎以及如何创建它,如下面的截图所示:
![图 12.4 – AWS RDS 引擎选择]()
图 12.4 – AWS RDS 引擎选择
我建议您在此处选择MySQL。您还应该能够选择Amazon Aurora或MariaDB,因为它们也与 MySQL 兼容;对于这本书,我选择了 MySQL。此外,请继续使用标准创建方法。通过向下滚动继续操作。
-
您需要指定数据库的使用场景。生产选项仅推荐用于实时应用程序,因为这将包括更高的成本。选择免费层,如下面的截图所示:
![图 12.5 – AWS RDS 模板选择]()
图 12.5 – AWS RDS 模板选择
-
继续向下滚动。接下来,您需要填写数据库凭据,以便在稍后进行后端认证。填写如下所示的详细信息:
![图 12.6 – AWS RDS 数据库凭据]()
图 12.6 – AWS RDS 数据库凭据
-
接下来,您需要选择 AWS 实例,从而确定数据库的计算能力。选择db.t2.micro,它是免费的,并且目前对我们的用例来说已经足够。它应该看起来像以下这样:
![图 12.7 – AWS RDS 实例类型]()
图 12.7 – AWS RDS 实例类型
-
现在将要求您设置连接设置。您必须选择公共访问,并确保是被勾选的。这并不会将您的数据库与公众共享,但如果您在 AWS 安全组中选择它们,则可以从其他 IP 和其他 EC2 实例访问。此外,您还需要创建一个新的子网组,并给出一个新的安全组名称:
![图 12.8 – AWS RDS 网络设置]()
图 12.8 – AWS RDS 网络设置
-
在附加配置部分,我们需要提供一个初始数据库名称,这样在设置完成后,RDS 内部将创建一个数据库:
![图 12.9 – 附加配置窗口]()
![图 12.9 – 额外配置窗口]()
-
通过点击屏幕底部的 创建数据库,完成您第一个 AWS RDS 数据库的设置过程。
您现在应该被重定向到所有数据库的列表。
点击已创建的新数据库实例。如果您向下滚动,您将看到安全组列表。点击具有 CIDR/IP - 入站 类型的组:


图 12.10 – AWS 安全组规则
如果您点击第一条规则,您将能够插入允许访问数据库的 IP。如果您插入 0.0.0.0 IP,它将允许任何远程 IP 访问数据库。这不是推荐用于生产使用的数据库设置,但它使得在开发环境中使用多个环境进行测试变得更容易。
您为数据库指定的凭据必须包含在 .env 文件中,以便运行我们的 Docker 容器,如下所示:
username=YOUR_USERNAME
password=YOUR_PASSWORD
database=YOUR_DATABASE
host=YOUR_HOST
host URL 可以从 Amazon RDS 实例仪表板中获取。它应该看起来像 INSTANCE_NAME.xxxxxxxxxx.eu-central-1.rds.amazonaws.com。
现在,您应该能够再次运行 Docker 镜像的构建,而不会出现任何问题。数据库已经设置好并可用。
接下来,我们将探讨如何通过持续集成自动化生成 Docker 镜像的过程。
配置持续集成
许多人(尤其是开发者)可能听说过 持续集成(CI)或 持续部署(CD)。然而,其中大多数人无法解释它们的含义以及这两个术语之间的区别。那么,CI 和 CD 在现实中究竟是什么?
当谈到发布您的应用程序时,可能看起来很容易将一些文件上传到服务器,然后通过在 shell 中执行一个简单的命令,通过 SSH 启动应用程序。
这种方法可能是一些开发者或更新不频繁的小型应用的解决方案。然而,对于大多数场景来说,这并不是一个好的方法。单词 持续 代表了所有更改或更新都是持续不断地进行测试、集成,甚至发布的。这将是一项大量工作,如果我们继续使用简单的文件上传并采取手动方法,这将非常困难。自动化这个工作流程使得在任何时候更新您的应用程序变得方便。
CI 是一种开发实践,其中所有开发人员每天至少将他们的代码提交到中央项目仓库一次,以便将他们的更改带到主代码流中。集成后的代码将通过自动测试用例进行验证。这将避免在特定时间尝试上线时出现的问题。
CD 更进一步;它基于 CI 的主要原则。每次应用程序成功构建和测试后,更改将直接发布给客户。这正是我们打算实施的。
我们的自动化流程将基于 CircleCI。它是一个提供 CI 和 CD 平台的三方服务,具有大量功能。
要注册 CircleCI,请访问 circleci.com/signup/。
您需要一个 Bitbucket 或 GitHub 账户才能注册。这也将是您的应用程序仓库的来源,我们可以从这里开始使用 CI 或 CD。
要使您的项目通过 CircleCI 启动并运行,您需要在左侧面板中点击 项目 按钮,或者如果您还没有设置任何项目,您将被重定向到那里。注册后,您应该在 CircleCI 内看到您所有的仓库。
通过在项目的右侧点击 设置项目 来选择您想要通过 CircleCI 处理的项目。然后,您将面对以下屏幕:

图 12.11 – CircleCI 项目
问题在于您没有相应地配置您的仓库或应用程序。您需要创建一个名为 .circleci 的文件夹,并在其中创建一个名为 config.yml 的文件,该文件告诉 CircleCI 当有新的提交推送到仓库时应该做什么。CircleCI 将会要求您自己设置,或者它会为您设置。我建议选择我们自行设置,因为这本书将引导您完成这些步骤。
接下来,将 示例配置 设置为 Node。最后一步将是推送一个包含匹配 CircleCI 配置的新提交。
我们将创建一个简单的 CircleCI 配置,以便我们可以测试一切是否正常工作。最终的配置将在稍后的步骤中完成,那时我们已经配置了 Amazon ECS,它将成为我们应用程序的主机。
因此,在我们的项目根目录下创建一个 .circleci 文件夹,并在该新文件夹内创建一个 config.yml 文件。.yml 文件扩展名表示 .yml 文件需要正确的缩进。否则,它们将不是有效的文件,并且无法被 CircleCI 理解。
将以下代码插入到 config.yml 文件中:
version: 2.1
jobs:
build:
docker:
- image: circleci/node:14
steps:
- checkout
- run:
command: echo "This is working"
让我们快速浏览一下文件中的所有步骤,如下所示:
-
文件以
version规范开始。我们使用版本 2.1,因为这是 CircleCI 的当前版本。 -
然后,我们将有一个
作业列表,这些作业将并行执行。因为我们只想做一件事,所以我们只能看到正在运行的build作业。稍后,我们将在这里添加整个 Docker 构建和发布功能。 -
每个作业都会收到一个执行器类型,该类型需要是
machine、docker或macos。我们使用docker类型,因为我们可以依赖 CircleCI 的许多预构建镜像。镜像在单独的image属性中指定。在那里,我指定了版本 14 的node,因为我们需要在 CI 工作流程中使用 Node.js。 -
然后,每个作业都会接收到几个步骤,这些步骤会在将每个提交推送到 Git 仓库时执行。
-
第一步是
checkout命令,它克隆我们仓库的当前版本,以便我们可以在后续步骤中使用它。 -
最后,为了测试一切是否正常工作,我们使用
run步骤。它允许我们在 CircleCI 启动的node:14Docker 镜像中直接执行命令。你想要执行的每个命令都必须以command为前缀。
此配置文件的结果应该是我们已拉取应用程序的当前 master 分支,并在最后打印了文本This is working。为了测试 CircleCI 设置,将此文件提交并推送到你的 GitHub 或 Bitbucket 仓库。
CircleCI 应该会自动通知你,它已经开始为我们仓库的新CI作业。你可以在 CircleCI 左侧面板的作业按钮中找到作业。最新的作业应该位于列表顶部。点击作业以查看详细信息。它们应该如下所示:

图 12.12 – CircleCI 流水线
在前面的屏幕截图中,每个步骤都在窗口底部的单独一行中表示。你可以展开每一行,以查看在执行当前行中显示的特定命令时打印的日志。前面的屏幕截图显示作业已成功完成。
现在我们已经配置了 CircleCI 在每次推送时处理我们的仓库,我们必须看看如何在构建完成后直接托管和部署我们的应用程序。
将应用程序部署到 Amazon ECS
CircleCI 每次我们推送新的提交时都会执行我们的构建步骤。现在,我们想要构建我们的 Docker 镜像并将其自动部署到一台将为我们应用程序提供服务的机器上。
我们的数据库和上传的图像已经在 AWS 上托管,因此我们也可以使用 AWS 来托管我们的应用程序。正确设置 AWS 是一个重要的任务,并且需要大量的时间。我们将使用 Amazon ECS 来运行我们的 Docker 镜像。然而,正确设置网络、安全和容器注册表过于复杂,无法在单章中解释。我建议你参加课程或阅读单独的书籍,以了解和学习 AWS 的高级设置以及获得生产就绪托管所需的配置。现在,我们将使用 ECS 来获取容器,包括数据库连接,使其运行。
在直接前往 Amazon ECS 并创建你的集群之前,我们需要准备两个服务 - 一个是 AWS ALB,代表应用程序负载均衡器,另一个是 Amazon ECR。如果你设置了一个 ECS 集群,将有一个或多个相同服务或任务的实例运行。这些任务需要接收流量,但如果发布新版本,它们也应该在继续服务流量的同时与新的任务交换。这对于 AWS ALB 来说是个好工作,因为它可以在实例之间分割流量,并在任务交换时处理动态端口映射。
登录 AWS 管理控制台,搜索 EC2 服务,然后点击它。然后,按照以下说明操作:
-
在左侧面板向下滚动,直到你看到负载均衡部分,如图中所示:![图 12.13 – AWS 负载均衡部分
![图 12.13 – AWS 目标组创建]()
图 12.13 – AWS 负载均衡部分
-
点击负载均衡器,你会看到一个空列表。现在我们需要在左上角点击创建负载均衡器。在下一页,选择应用程序负载均衡器。
-
我们需要为我们的负载均衡器指定一个名称和方案。我们将选择面向互联网的选项,因为这样我们可以从 AWS 外部访问它:![图 12.14 – AWS ALB 配置
![图 12.14 – AWS ALB 配置]()
图 12.14 – AWS ALB 配置
重要提示
通常情况下,你不会将负载均衡器公开,而是会在你的应用程序前面添加另一个内容分发网络(CDN)、缓存和防火墙来保护它免受分布式拒绝服务(DDoS)攻击或不必要的负载。AWS 上的服务,如 Route53 和 CloudFront,可以很好地协同工作来完成这项任务,但它们超出了本书的范围。
-
在下一步中,我们需要选择一个 VPC,它将把附加资源带入一个私有网络。数据库中应该有一个现有的 VPC 可供选择。请选择它并选择可用区域,如图中所示:![图 12.15 – AWS ALB 网络设置
![图 12.15 – AWS ALB 网络设置]()
图 12.15 – AWS ALB 网络设置
-
接下来,你需要选择与 AWS RDS 数据库一起创建的安全组,如图中所示:![图 12.16 – AWS ALB 安全组
![图 12.16 – AWS ALB 安全组]()
图 12.16 – AWS ALB 安全组
-
如果你继续向下滚动,你会看到你需要指定负载均衡器的路由,即负载或流量需要去哪里,这取决于你需要定义的一些规则。为此,我们需要定义一个目标组。在选择输入下方应该有一个链接,上面写着创建****目标组,如图中所示:![图 12.17 – AWS ALB 路由
![图 12.17 – AWS ALB 路由]()
图 12.17 – AWS ALB 路由
如果你已创建并选择了目标组,它应该看起来像前面的图示,所以让我们来做这件事。
-
在新标签页或窗口中打开链接;如前所述,你应该看到以下屏幕:![图 12.18 – AWS 目标组创建
![图 12.18 – AWS 目标组创建]()
图 12.18 – AWS 目标组创建
-
选择实例,这意味着在一个 VPC 中不同实例之间的负载均衡。为目标组命名,并选择与之前相同的 VPC。
-
之后,你可以点击下一步按钮。你将看到以下屏幕:![图 12.19 – AWS 目标组目标
![图 12.19 – AWS 目标组目标]()
图 12.19 – AWS 目标组目标
此屏幕通常显示所有将包含在您的目标组中的实例。因为我们还没有创建 ECS 集群,所以这是空的。您可以通过点击创建目标组按钮继续。
-
返回向导设置 AWS ALB。在目标组选择旁边的刷新按钮处点击并选择目标组。
-
在屏幕底部点击创建负载均衡器按钮。
最后,您应该已经到达向导的末尾,并看到一个摘要,如下面的截图所示:

图 12.20 – AWS ALB 创建摘要
下一步我们需要准备的是 Amazon ECR 存储库。Amazon ECR 不过是 Docker Hub 或其他任何 Docker 注册中心的替代品。在 Docker 注册中心中,您可以推送为您的应用程序构建的 Docker 镜像。这是我们 ECS 集群运行的基础。
要设置您的 ECR 存储库,在顶部栏中搜索ECR并点击相应的服务。您应该会看到以下屏幕:

图 12.21 – Amazon ECR 概述
要设置您的 Amazon ECR 存储库,请按照以下步骤操作:
-
在右侧点击创建存储库按钮进入创建向导。
-
接下来,您需要为您的存储库提供一个名称,如下面的截图所示:
![图 12.22 – Amazon ECR 设置]()
图 12.22 – Amazon ECR 设置
我们将我们的注册库设置为私有,因为外部没有人应该能够访问它。如果您需要将其设置为对所有人公开,您可以更改此设置。
-
点击创建存储库来设置它。现在您将看到以下更新表:
![图 12.23 – Amazon ECR 存储库已创建]()
图 12.23 – Amazon ECR 存储库已创建
-
之后,您将需要
URI,它显示在注册名称旁边。
现在我们已经准备好开始设置 ECS 集群。要找到 ECS,只需前往顶部服务栏并搜索ECS。如果您点击此服务并进入集群部分,它将显示所有正在运行的 ECS 集群。它应该看起来像以下这样:

图 12.24 – Amazon ECS 集群
配置 ECS 的过程非常复杂,本书将遵循最基础的配置。按照以下说明来使其工作:
-
点击创建集群按钮(见图 12.24)开始。
-
在下一屏,您将被询问想要使用哪种类型的实例。我们将使用以下截图所示的 EC2 Linux 实例:
![图 12.25 – Amazon ECS 集群模板]()
图 12.25 – Amazon ECS 集群模板
-
点击下一步进入集群的配置向导。
-
您将被要求输入集群名称。默认配置是合适的,除非您需要更改。唯一需要更改的选项是实例类型。我建议选择
t2.micro,因为它并不昂贵,这对于开发来说是个不错的选择。实例数量选项指定了我们希望有多少个并行运行的 EC2 实例。对于开发,通常一个就足够了,但如果您需要扩展,您需要增加这个数量:![Figure 12.26 – Amazon ECS 集群配置![Figure_12.26_B17337.jpg]
Figure 12.26 – Amazon ECS 集群配置
-
滚动以提供一些额外的网络配置。由于我们在配置负载均衡器时选择了三个子网,我们现在也应该选择这三个子网:![Figure 12.27 – Amazon ECS 集群网络设置
![Figure_12.27_B17337.jpg]
Figure 12.27 – Amazon ECS 集群网络设置
-
在选择子网后,您需要选择我们也用于 ALB 配置的安全组:![Figure 12.28 – Amazon ECS 安全设置
![Figure_12.28_B17337.jpg]
Figure 12.28 – Amazon ECS 安全设置
-
点击创建集群,AWS 将开始启动所有进程。这可能需要一些时间。
-
一旦 AWS 配置完成,您可以点击查看集群按钮,这将带您进入详细的集群页面。
我们已经定义了 AWS 正在运行一个基于一个 EC2 实例的 ECS 集群。我们之前没有做的一件事是定义这个集群做什么。为此,我们需要转到左侧面板上的任务定义。然后,按照以下说明操作:
-
点击创建新任务定义按钮。
-
选择EC2类型以使您的任务与这种类型的集群兼容。
-
给您的任务定义命名并指定ecsTaskExecutionRole角色。它应该看起来像以下截图:![Figure 12.29 – Amazon ECS 任务定义
![Figure_12.29_B17337.jpg]
Figure 12.29 – Amazon ECS 任务定义
-
滚动并给您的任务指定一个大小,这意味着它将需要的内存大小和 CPU 大小来处理其任务。它应该看起来像以下截图:![Figure 12.30 – Amazon ECS 任务定义大小
![Figure_12.30_B17337.jpg]
Figure 12.30 – Amazon ECS 任务定义大小
此设置需要与您的集群拥有的资源相匹配。
-
现在,暂时点击
:latest。因为我们还没有发布任何镜像,所以它现在不会工作,但我们会稍后修复这个问题。 -
滚动到
8000,因为这是我们用于 Graphbook 的默认端口。0将被您的负载均衡器自动分配。AWS ALB 将动态地将一个空闲端口映射到容器端口。我们不需要关心它将是哪个端口。 -
在环境部分,我们需要添加所有我们的应用程序启动所需的环境变量。以下截图显示的变量应该足以使您的容器运行:
![Figure 12.32 – Amazon ECS 任务定义容器环境变量]()
图 12.32 – Amazon ECS 任务定义容器环境变量
-
唯一有用的设置是在存储和日志部分下。我建议激活 CloudWatch Logs,这样您就可以看到您应用程序的所有日志。它应该看起来像下面的截图:
![图 12.33 – Amazon ECS 任务定义容器日志]()
图 12.33 – Amazon ECS 任务定义容器日志
-
启用此选项后,您可以在对话框底部点击添加按钮。
这些都是您需要完成的重要事项。还有许多更详细的配置您可以进行。对我们来说,它们不是使我们的应用程序运行所必需的,也不在本书的范围之内。
容器定义表应该看起来像下面的截图:

图 12.34 – Amazon ECS 任务定义容器定义
我们刚才所做的是最基本的 ECS 设置。在我们刚刚设置的所有服务中,我们使用了最简单的配置,但我们没有查看大量的高级设置和配置。
现在,您可以在 ECS 任务定义向导的最后点击创建按钮。在 AWS 成功创建任务定义后,返回 ECS 的主集群屏幕。您将看到此集群概览:

图 12.35 – Amazon ECS 集群概览
这只是表明,仍然,您的集群内部没有任何东西在运行。要解决这个问题,请点击左上角的集群名称。
实际问题是,没有创建使用刚刚配置的任务定义的服务,如下面的截图所示:

图 12.36 – Amazon ECS 集群服务
点击表格上方的创建按钮。
然后,您需要提供以下数据:
-
对于启动类型,您需要选择EC2,与之前的步骤相同。
-
然后,您需要选择任务定义家族,它应该与您之前创建的任务定义名称相匹配。
-
此外,您还需要选择我们目前正在查看的集群。
-
您需要为您的服务提供一个服务名称,例如
graphbook-service。 -
对于
1是可以的。这意味着在同一时间此服务中只运行一个任务。这也受到健康百分比的限制。100的最小健康百分比意味着至少应该有一个正确运行的服务在运行,而200的最大百分比意味着只允许运行最多两个健康的任务。这种限制对于您更新服务为新应用程序版本的情况是必要的。在这种情况下,将同时运行两个版本,它们将进行交换。所以,目前我们有200的健康百分比。
这就是您需要提供的信息。其余的设置可以保留为默认设置。结果应类似于以下屏幕截图:

图 12.37 – Amazon ECS 服务配置
您现在可以点击屏幕底部的下一步按钮继续。
下一个屏幕非常重要,因为这需要我们的 AWS ALB 设置。
您需要选择应用程序负载均衡器选项,然后选择我们之前创建的 ALB。设置应与以下屏幕截图中的配置相匹配:

图 12.38 – Amazon ECS 服务负载均衡
此设置将利用我们的 ALB 并对在此服务任务中运行的容器进行动态端口映射。
如果我们向下滚动,我们需要提供有关 ALB 如何映射到容器的详细信息。现在,您应该看到以下信息:

图 12.39 – Amazon ECS 服务负载均衡映射
您需要点击添加到负载均衡器按钮。好事是您只需选择我们之前创建的目标组。选择它后,您应该看到以下屏幕:

图 12.40 – Amazon ECS 负载均衡目标组配置
您现在可以点击屏幕底部的下一步按钮,然后在下一个屏幕中,当询问自动扩展时(我们不需要),您应该能够在摘要屏幕底部点击创建服务选项。
AWS 将尝试现在创建服务并启动任务。问题是到目前为止我们没有推送任何 Docker 镜像。由于这个原因,ECS 服务将无法正确启动任何任务。
那么,让我们来解决这个问题。
设置 CircleCI 与 Amazon ECR 和 ECS
我们将再次从空白的 CircleCI 配置开始;因此,清空旧的 config.yml 文件。
我们之前没有做的一件重要事情是在我们的管道中设置自动化测试。否则,我们的提交将触发自动化管道,并仅部署未经测试的代码,这可能会引起我们想要避免的生产问题。
那么,让我们先这样做。按照以下步骤操作:
-
将以下行插入到我们的
config.yml文件中,如下所示:version: 2.1 jobs: test: docker: - image: circleci/node:14 auth: username: $DOCKERHUB_USERNAME password: $DOCKERHUB_PASSWORD environment: host: localhost username: admin password: passw0rd database: graphbook JWT_SECRET: 1234此配置创建了一个
test作业并拉取了一个 Docker 镜像。这个 Docker 镜像是从 CircleCI 的 Node.js 镜像,我们将在这个镜像中运行应用程序以进行测试。同时,我们传递了凭证以实际拉取镜像,但也传递了一些默认环境变量,我们将在下一步中使用它们。 -
将另一张图片添加到
test作业中:- image: circleci/mysql:8.0.4 command: [--default-authentication- plugin=mysql_native_password] auth: username: $DOCKERHUB_USERNAME password: $DOCKERHUB_PASSWORD environment: MYSQL_ROOT_PASSWORD: passw0rd MYSQL_DATABASE: graphbook MYSQL_USER: admin MYSQL_PASSWORD: passw0rd这个镜像是为 MySQL 数据库准备的,我们可以对其运行迁移和测试脚本。每次管道运行时,它都会从头开始创建。您可以看到,我们同样提供了相同的环境变量。这将使用这些凭证设置 MySQL 数据库,并且 Node.js 容器将在环境变量中拥有这些凭证。
-
如前所述步骤所示,我们正在使用类似
$DOCKERHUB_USERNAME这样的语法将 CircleCI 设置中的变量注入到我们的管道中。这样,我们就不需要反复重复它们,而且它们也不会被提交到我们的代码中。根据以下截图设置环境变量:![图 12.41 – CircleCI 项目环境变量]()
图 12.41 – CircleCI 项目环境变量
-
现在,我们需要添加实际运行测试的功能。我们将在 CircleCI 能够理解的
steps属性内部这样做。只需在之前添加的jobs部分下方添加以下代码:steps: - checkout - run: npm install测试作业的流程相当简单。首先,我们检出我们的代码,然后安装应用程序所需的所有依赖项。
-
然后,我们还需要使用与
Dockerfile中相同的 Sequelize 包来运行数据库迁移。添加以下代码来完成此操作:- run: name: "Install Sequelize" command: sudo npm install -g mysql2 sequelize sequelize-cli -
然后,我们需要等待数据库镜像启动。如果我们不这样做,在执行下一步时,如果数据库尚未运行,命令将失败。添加以下代码等待我们的数据库启动:
- run: name: Waiting for MySQL to be ready command: | for i in 'seq 1 10'; do nc -z 127.0.0.1 3306 && echo Success && exit 0 echo -n . sleep 1 done echo Failed waiting for MySQL && exit 1 -
数据库启动后,我们可以对
test数据库运行数据库迁移。添加以下代码来运行它们:- run: name: "Run migrations for test DB" command: sequelize db:migrate --migrations-path src/server/migrations --config src/server/config/index.js --env production -
现在,我们终于可以针对新创建的数据库运行测试了。只需使用以下代码即可使其工作:
- run: name: "Run tests" command: npm run test environment: NODE_ENV: production如果其中一个测试失败,整个管道将失败。这确保了只有工作的应用程序代码被发布到公共领域。
-
要使我们的自动化测试运行,最后一步是设置 CircleCI 工作流程。您可以复制以下代码以使其运行:
workflows: build-and-deploy: jobs: - test
您可以将此新配置文件提交并推送到您的 Git 仓库,CircleCI 应自动处理它并为您创建一个新的作业。
生成的作业应如下所示:

图 12.42 – CircleCI 测试管道
接下来,我们需要构建 Docker 镜像并将其推送到我们的注册表。多亏了 CircleCI,这个过程相当简单。
在版本规范下面的 config.yml 文件中添加此配置:
orbs:
aws-ecr: circleci/aws-ecr@7.2.0
CircleCI orbs 是一组可以共享或使用而不需要自己编写所有步骤的包配置。我们刚刚添加的这个 orbs 可以构建并推送 Docker 镜像到 Amazon ECR,这是我们之前章节中设置的。
重要提示
您可以在官方 CircleCI orbs 网站上找到所有可用的 CircleCI orbs 及其文档:circleci.com/developer/orbs。
要利用此 orbs,请添加一个工作流程步骤,如下面的截图所示:
- aws-ecr/build-and-push-image:
repo: "graphbook"
tag: "${CIRCLE_SHA1}"
requires:
- test
上述配置将构建并推送您的 Docker 镜像到由 repo 属性指定的 Amazon ECR,它还将等待 test 步骤,因为我们已在 requires 属性中提到这一点。
如果您提交并推送此配置,您将在 CircleCI 管道中看到单独的 ECR 步骤。如果该步骤完成,您将能够在 ECR 存储库中找到新的 Docker 镜像。
现在唯一缺少的是在 Amazon ECS 中使用此 Docker 镜像。如果您记得,我们在 Amazon ECS 任务定义中指定了 Docker 镜像。但是,在每次管道运行后手动更新这是不可行的。为了自动化此过程,请在 CircleCI 配置的顶部添加一个额外的 orbs:
aws-ecs: circleci/aws-ecs@02.2.1
如果我们想要更新并推送新的任务定义到我们的服务,CircleCI 也为我们提供了支持。要利用此 orbs,请将以下代码作为最后一个工作流程步骤添加:
- aws-ecs/deploy-service-update:
requires:
- aws-ecr/build-and-push-image
- test
family: "graphbook-task-definition"
cluster-name: "graphbook-cluster"
service-name: "graphbook-service"
container-image-name-updates: "container=
graphbook-container,tag=${CIRCLE_SHA1}"
此步骤等待测试和 ECR 作业完成。之后,它将在 Amazon ECS 上创建一个具有给定 family 名称的新任务定义修订版。然后,它将更新给定集群中具有给定名称的服务。
提交并推送新的配置文件,您将看到以下具有三个正在运行作业的管道:

图 12.43 – CircleCI CD 管道
Amazon ECS 将需要一些时间来替换当前正在运行的任务,但在此之后,您应用程序的新版本将开始运行。
然而,问题是我们现在如何访问 Graphbook。为此,我们可以进入我们的 AWS ALB,进入 负载均衡器 部分,并点击我们的 ALB。它将显示以下信息:

图 12.44 – AWS ALB DNS 名称
在给定的 DNS 名称下,我们可以访问负载均衡器,并通过它访问我们的应用程序。
如前所述,这并不推荐,但完整的 AWS 设置超出了本书的范围。您应该能够通过该链接访问 Graphbook。
这就是你需要做的全部。它将使用我们的测试套件测试应用程序代码,构建并推送一个新的 Docker 镜像,最后更新任务定义和 ECS 服务,用新的任务定义替换旧的任务。
摘要
在本章中,你学习了如何使用普通的Dockerfile和分阶段构建将你的应用程序 docker 化。
此外,我已经向你展示了如何使用 CircleCI 和 AWS 设置一个典型的 CD 工作流程。你可以在继续使用你的 Docker 镜像的同时,用更复杂的设置替换部署过程。
阅读完本章后,你已经从开发一个完整的应用程序到将其部署到生产环境中的所有内容都学到了。你的应用程序现在应该已经在 Amazon ECS 上运行了。
到目前为止,你已经学到了所有重要的东西,包括使用 Webpack 设置 React,开发本地设置,服务器端渲染,以及如何使用 GraphQL 将所有这些事情结合起来。你也能够通过 CD 频繁地发布更改。展望未来,我们仍然可以改进一些事情——例如,我们应用程序的可扩展性或包拆分,这些内容超出了本书的范围,但有许多资源可以帮助你在这些领域取得进步。
我希望你喜欢这本书,并祝愿你一切顺利!























浙公网安备 33010602011771号