MERN-快速启动指南-全-
MERN 快速启动指南(全)
原文:
zh.annas-archive.org/md5/2eba3c0d90674379a865fc5b16de4d50
译者:飞龙
前言
MERN 栈可以看作是一组工具的集合,这些工具共享一个共同的基础,即 JavaScript 语言。本书以食谱的形式探讨了如何遵循 MVC 架构模式使用 MERN 栈构建 Web 客户端和服务器应用程序。
MVC 架构模式中的模型和控制器在关于使用 ExpressJS 和 Mongoose 构建 RESTful API 的章节中进行了介绍。这些章节涵盖了 HTTP 协议的核心概念、方法类型、状态码、URL、REST 和 CRUD 操作。随后,它转向了特定于 ExpressJS 的主题,例如请求处理器、中间件和安全,以及特定于 Mongoose 的主题,例如模式、模型和自定义验证。
MVC 架构模式中的视图在关于 ReactJS 的章节中进行了介绍。ReactJS 是一个基于组件的 UI 库,具有声明式 API。本书旨在提供构建 ReactJS Web 应用程序和组件的必要知识。作为 ReactJS 的补充,本书包含了一个关于 Redux 的完整章节,从核心概念和原则到高级功能,如 store enhancers、时间旅行和异步数据流。
此外,本书还涵盖了使用 ExpressJS 和 SocketIO 进行实时通信,以实时交付和交换数据。
在本书结束时,你将了解使用 MVC 架构模式构建全栈 Web 应用程序的核心概念和基本要素。
为了充分利用本书
本书是为对使用 MERN 栈开发 Web 应用程序感兴趣的开发者而编写的。为了能够理解这些章节,你应该已经具备 JavaScript 语言的一般知识和理解。
你需要这本书的内容
为了能够处理食谱,你需要以下内容:
-
你偏好的 IDE 或代码编辑器。在编写食谱代码时使用了 Visual Studio Code (vscode),所以我建议你尝试一下。
-
能够运行 NodeJS 和 MongoDB 的操作系统(O.S),最好是以下之一:
-
macOS X Yosemite/El Capitan/Sierra
-
Linux
-
Windows 7/8/10(如果在 Windows 7 中安装 VSCode,则需要.NET 框架 4.5)
-
-
建议至少有 1 GB 的 RAM 和 1.6 GHz 或更快的处理器。
下载示例代码文件
你可以从www.packtpub.com的账户下载本书的示例代码文件。如果你在其他地方购买了本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/MERN-Quick-Start-Guide
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/
上找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/MERNQuickStartGuide_ColorImages.pdf
。
代码在行动
访问以下链接查看代码运行的视频:
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg
磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块设置如下:
{
"dependencies": {
"express": "4.16.3",
"node-fetch": "2.1.1",
"uuid": "3.2.1"
}
}
任何命令行输入或输出都应如下编写:
npm install
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要注意事项看起来像这样。
小技巧和技巧看起来像这样。
部分
在本书中,您会发现一些频繁出现的标题(准备工作,如何操作,让我们测试一下,它是如何工作的,还有更多...,和也见)。
为了清楚地说明如何完成食谱,请按以下方式使用这些部分:
准备工作
本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。
如何操作...
本节包含遵循食谱所需的步骤。
让我们测试一下...
本节包含关于如何在如何操作部分测试给定代码的详细步骤。
它是如何工作的...
本节通常包含对前节发生情况的详细解释。
还有更多...
本节包含有关食谱的附加信息,以便您对食谱有更多的了解。
也见
本节提供了对食谱其他有用信息的链接。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com
并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至 questions@packtpub.com
。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packtpub.com
与我们联系。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需更多关于 Packt 的信息,请访问 packtpub.com.
第一章:MERN 栈简介
在本章中,我们将涵盖以下主题:
-
MVC 架构模式
-
安装和配置 MongoDB
-
安装 Node.js
-
安装 NPM 包
技术要求
您将需要拥有 IDE、Visual Studio Code、Node.js 和 MongoDB。您还需要安装 Git,以便使用本书的 Git 仓库。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter01
观看以下视频以查看代码的实际效果:
简介
MERN 栈是由四个主要组件组成的解决方案:
-
MongoDB: 使用文档型数据模型的数据库。
-
ExpressJS: 用于构建 Web 应用程序和 API 的 Web 应用程序框架。
-
ReactJS: 一个用于构建用户界面的声明式、基于组件和同构的 JavaScript 库。
-
Node.js: 基于 Chrome 的 V8 JavaScript 引擎构建的跨平台 JavaScript 运行时环境,允许开发者构建各种工具、服务器和应用程序。
构成 MERN 栈的这些基本组件是开源的,因此由一群优秀的开发者维护和开发。将这些组件联系在一起的是一种共同的语言,JavaScript。
本章的食谱将主要关注设置开发环境以与 MERN 栈一起工作。
您可以自由使用您选择的代码编辑器或 IDE。然而,如果您在选择 IDE 时遇到困难,我建议您尝试使用 Visual Studio Code。
MVC 架构模式
大多数现代 Web 应用程序实现了 MVC 架构模式。它由三个相互连接的部分组成,这些部分将 Web 应用程序中信息的内部表示分离出来:
-
模型(Model): 管理应用程序的业务逻辑,确定数据应该如何存储、创建和修改
-
视图(View): 数据或信息的任何视觉表示
-
控制器(Controller): 解析用户生成的事件,并将它们转换为模型和视图更新的命令:
关注点分离(Separation of Concern)(SoC) 设计模式将前端与后端代码分离。遵循 MVC 架构模式,开发者能够遵循 SoC 设计模式,从而实现一致且可管理的应用程序结构。
以下章节中的食谱实现了这种架构模式,以分离前端和后端。
安装和配置 MongoDB
官方 MongoDB 网站提供了包含用于在 Linux、OS X 和 Windows 上安装 MongoDB 的二进制文件的最新包。
准备工作
访问 MongoDB 的官方网站 www.mongodb.com/download-center
,选择 Community Server,然后选择您首选的操作系统版本的软件并下载它。
安装 MongoDB 并配置它可能需要额外的步骤。
如何操作...
访问 MongoDB 的文档网站 docs.mongodb.com/master/installation/
以获取说明,并在教程部分查找您特定平台的信息。
安装完成后,可以以独立方式启动 mongod-
,这是 MongoDB-
的守护进程:
-
打开一个新的终端
-
创建一个名为
data
的新目录,该目录将包含 Mongo 数据库 -
输入
mongod --port 27017 --dbpath /data/
以启动一个新的实例并创建一个数据库 -
打开另一个终端
-
输入
mongo --port 27017
以将 Mongo 壳连接到实例
更多...
作为一种替代方案,您可以选择使用 数据库即服务(DBaaS)如 MongoDB Atlas,在撰写本文时,它允许您创建一个包含 512 MB 存储空间的免费集群。另一个简单的替代方案是 mLab,尽管还有许多其他选项。
安装 Node.js
官方 Node.js 网站提供了两个包含 LTS 和 Current(包含最新功能)二进制的软件包,用于在 Linux、OS X 和 Windows 上安装 Node.js。
准备工作
为了本书的目的,我们将安装 Node.js v10.1.x 版本。
如何操作...
要下载 Node.js 的最新版本:
-
访问
nodejs.org/en/download/
的官方网站 -
选择“当前”|“最新功能”
-
选择您首选平台或 操作系统(OS)的二进制文件
-
下载并安装
如果您更喜欢通过包管理器安装 Node.js,请访问 nodejs.org/en/download/package-manager/
并选择您首选的平台或 OS。
安装 npm 软件包
Node.js 的安装包括一个名为 npm
的包管理器,它是默认且最广泛使用的 JavaScript/Node.js 库安装包管理器。
NPM 软件包列在 registry.npmjs.org/
的 NPM 注册表中,您可以在那里搜索软件包,甚至发布您自己的软件包。
除了 NPM 之外,还有其他替代方案,例如 Yarn,它与公共 NPM 注册表兼容。您可以使用您选择的任何包管理器;然而,为了本书的目的,菜谱中使用的包管理器将是 NPM。
准备工作
NPM 期望在您的 project
文件夹根目录下找到 package.json
文件。这是一个配置文件,用于描述您项目的详细信息,例如其依赖项、项目名称和项目作者。
在你能够在你项目中安装任何包之前,你必须创建一个package.json
文件。以下是你通常需要遵循的创建项目的步骤:
-
在你偏好的位置创建一个新的
project
文件夹,并将其命名为mern-cookbook
或你选择的另一个名称。 -
打开一个新的终端。
-
将当前目录更改为你刚刚创建的新文件夹。这通常使用终端中的
cd
命令完成。 -
运行
npm init
来创建一个新的package.json
文件,按照终端中显示的步骤进行。
之后,你应该有一个package.json
文件,其外观可能如下所示:
{
"name": "mern-cookbook",
"version": "1.0.0",
"description": "mern cookbook recipes",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Eddy Wilson",
"license": "MIT"
}
在此之后,你将能够使用 NPM 为你的项目安装新的包。
如何操作...
-
打开一个新的终端
-
将当前目录更改为你新创建的
project
文件夹所在的位置 -
运行以下行来安装
chalk
包:
npm --save-exact install chalk
现在,你将能够通过 Node.js 中的 require 在你的项目中使用这个包。通过以下步骤了解你可以如何使用它:
- 创建一个名为
index.js
的新文件,并添加以下代码:
const chalk = require('chalk')
const { red, blue } = chalk
console.log(red('hello'), blue('world!'))
- 然后,打开一个新的终端并运行以下命令:
node index.js
它是如何工作的...
NPM 将连接到 NPM 注册表并查找名为 react 的包,如果存在,则会下载并安装它。
以下是一些你可以与 NPM 一起使用的有用标志:
-
--save
:这将安装并将包名和版本添加到你的package.json
文件的dependencies
部分。这些依赖项是在项目生产期间使用的模块。 -
--save-dev
:这与--save
标志的工作方式相同。它将在package.json
文件的devDependencies
部分安装并添加包名。这些依赖项是在项目开发期间使用的模块。 -
--save-exact
:这保留了已安装包的原始版本。这意味着,如果你与他人共享你的项目,他们将能够安装与你使用的完全相同的包版本。
虽然这本书将为你提供在每个食谱中安装必要包的逐步指南,但你被鼓励访问 NPM 文档网站docs.npmjs.com/getting-started/using-a-package.json
以了解更多信息。
第二章:使用 ExpressJS 构建 Web 服务器
在本章中,我们将介绍以下食谱:
-
ExpressJS 中的路由
-
模块化路由处理程序
-
编写中间件函数
-
编写可配置的中间件函数
-
编写路由级别的中间件函数
-
编写错误处理中间件函数
-
使用 ExpressJS 内置的中间件函数来提供静态资源
-
解析 HTTP 请求体
-
压缩 HTTP 响应
-
使用 HTTP 请求记录器
-
管理和创建虚拟域名
-
使用 helmet 保护 ExpressJS Web 应用程序
-
使用模板引擎
-
调试你的 ExpressJS Web 应用程序
技术要求
你将需要有一个 IDE、Visual Studio Code、Node.js 和 MongoDB。你还需要安装 Git,以便使用本书的 Git 仓库。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter02
查看以下视频以查看代码的实际操作:
简介
ExpressJS 是构建健壮的 Web 应用程序和 API 的首选事实上的 Node.js Web 应用程序框架。
本章中的食谱将专注于构建一个功能齐全的 Web 服务器和了解核心基础。
ExpressJS 中的路由
路由指的是当通过 HTTP 动词或 HTTP 方法请求资源时,应用程序如何响应或行动。
HTTP 代表 超文本传输协议,它是 万维网(WWW)数据通信的基础。万维网中的所有文档和数据都由一个 统一资源定位符(URL)标识。
HTTP 动词或 HTTP 方法是 客户端-服务器 模型。通常,网页浏览器充当 客户端,在我们的案例中,ExpressJS 是允许我们创建能够理解这些请求的 服务器 的框架。每个请求都期望发送一个响应到客户端,以便识别它所请求的资源的状态。
请求方法可以是:
-
安全:一个只执行服务器上的只读操作的 HTTP 动词。换句话说,它不会改变服务器状态。例如:
GET
。 -
幂等:当发送相同请求时,对服务器产生相同效果的 HTTP 动词。例如,发送一个
PUT
请求来修改用户的首名,如果正确实现,当发送多个相同请求时,应该在服务器上产生相同的效果。所有 安全 方法也都是幂等的。例如,GET
、PUT
和DELETE
方法都是幂等的。 -
可缓存: 可以缓存的 HTTP 响应。并非所有方法或 HTTP 动词都可以缓存。只有当响应的 状态码 和用于发出请求的方法都是可缓存的时,响应才是可缓存的。例如,GET 方法是可缓存的,以下状态码也可以缓存:
200
(请求成功)、204
(无内容)、206
(部分内容)、301
(永久移动)、404
(未找到)、405
(方法不允许)、410
(已删除或内容已从服务器永久删除)、414
(URI 太长)。
准备工作
理解路由是构建健壮的 RESTful API 最重要的核心方面之一。
在本食谱中,我们将了解 ExpressJS 如何处理或解释 HTTP 请求。在开始之前,创建一个包含以下内容的 package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
ExpressJS 执行理解客户端请求的全部工作。请求可能来自浏览器,例如。一旦请求被解释,ExpressJS 就将所有信息保存到两个对象中:
-
请求: 这包含有关客户端请求的所有数据和信息。例如,ExpressJS 解析 URI 并在
request.query
上提供其参数。 -
响应: 这包含将发送给客户端的数据和信息。在将信息发送给客户端之前,也可以修改响应的头部。
response
对象有几种方法可用于向客户端发送状态码和数据。例如:response.status(200).send('Some Data!')
。
如何操作...
Request
和 Response
对象作为参数传递给在 route
方法内部定义的 路由处理程序。
路由方法
这些是从 HTTP 动词或 HTTP 方法派生出来的。路由方法用于定义应用程序对特定 HTTP 动词的响应。
ExpressJS 路由方法具有与 HTTP 动词等效的名称。例如:app.get()
对应于 GET
HTTP 动词,或 app.delete()
对应于 DELETE
HTTP 动词。
一个非常基本的路由可以写成以下形式:
-
创建一个名为
1-basic-route.js
的新文件 -
首先包含 ExpressJS 库并初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 添加一个新的路由方法来处理对路径
"/"
的请求。第一个参数指定路径或 URL,下一个参数是路由处理程序。在路由处理程序内部,让我们使用response
对象发送状态码200 (OK)
和文本到客户端:
app.get('/', (request, response, nextHandler) => {
response.status(200).send('Hello from ExpressJS')
})
- 最后,使用
listen
方法在端口1337
上接受新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行以下命令:
node 1-basic-route.js
- 在浏览器中打开一个新标签页,并访问
localhost
端口1337
以查看结果:
http://localhost:1337/
有关 ExpressJS 支持哪些 HTTP 方法的更多信息,请访问官方 ExpressJS 网站上的 expressjs.com/en/guide/routing.html#route-methods
。
路由处理程序
路由处理器是接受三个参数的回调函数。第一个参数是 request
对象,第二个参数是 response
对象,最后一个参数是 callback
,它将处理器传递给链中的下一个请求处理器。在路由方法内部也可以使用多个 callback
函数。
让我们看看如何在路由方法内部编写路由处理器的实际示例:
-
创建一个名为
2-route-handlers.js
的新文件 -
包含 ExpressJS 库,然后初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 添加两个路由方法来处理同一路径
"/one"
的请求。使用response
对象的type
方法设置发送给客户端的响应内容类型为text/plain
。使用write
方法向客户端发送部分数据。要最终发送数据,请使用响应对象的end
方法。调用nextHandler
将处理器传递给链中的下一个处理器:
app.get('/one', (request, response, nextHandler) => {
response.type('text/plain')
response.write('Hello ')
nextHandler()
})
app.get('/one', (request, response, nextHandler) => {
response.status(200).end('World!')
})
- 向处理路径
"/two"
的请求的route
方法添加一个route
方法。在route
方法内部定义了两个路由处理器来处理相同的请求:
app.get('/two',
(request, response, nextHandler) => {
response.type('text/plain')
response.write('Hello ')
nextHandler()
},
(request, response, nextHandler) => {
response.status(200).end('Moon!')
}
)
- 使用
listen
方法在端口1337
上接受新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node 2-route-handlers.js
- 要查看结果,请在您的网页浏览器中打开一个新标签页并访问:
http://localhost:1337/one http://localhost:1337/two
链式路由方法
路由方法可以通过使用 app.route(path)
来实现链式调用,因为 path
是为单个位置指定的。当处理多个路由方法时,这可能是最佳方法,因为除了使代码更易读、减少错误和冗余外,它还允许同时使用多个路由方法。
-
创建一个名为
3-chainable-routes.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 使用
route
方法链式调用三个路由方法:
app
.route('/home')
.get((request, response, nextHandler) => {
response.type('text/html')
response.write('<!DOCTYPE html>')
nextHandler()
})
.get((request, response, nextHandler) => {
response.end(`
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebApp powered by ExpressJS</title>
</head>
<body role="application">
<form method="post" action="/home">
<input type="text" />
<button type="submit">Send</button>
</form>
</body>
</html>
`)
})
.post((request, response, nextHandler) => {
response.send('Got it!')
})
- 使用
listen
方法在端口1337
上接受新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node 3-chainable-routes.js
- 要查看结果,请在您的网页浏览器中打开一个新标签页并访问:
http://localhost:1337/home
还有更多...
路由路径可以是字符串或正则表达式。路由路径在内部使用 path-to-regexp
NPM 包 www.npmjs.com/package/path-to-regexp
转换为正则表达式。
path-to-regexp
以一种方式帮助您以更易读的方式编写路径正则表达式。例如,考虑以下代码:
app.get(/([a-z]+)-([0-9]+)$/, (request, response, nextHandler) => {
response.send(request.params)
})
// Output: {"0":"abc","1":"12345"} for path /abc-12345
这可以写成如下形式:
app.get('/:0-:1', (request, response, nextHandler) => {
response.send(request.params)
})
// Outputs: {"0":"abc","1":"12345"} for /abc-12345
或者更好:
app.get('/:id-:tag', (request, response, nextHandler) => {
response.send(request.params)
})
// Outputs: {"id":"abc","tag":"12345"} for /abc-12345
看看这个表达式:/([a-z]+)-([0-9]+)$/
。正则表达式中的括号被称为 捕获括号;当它们找到匹配项时,会记住它。在上面的例子中,对于 abc-12345
,会记住两个字符串,{"0":"abc","1":"12345"}
。这是 ExpressJS 找到匹配项、记住其值并将其与键关联的方式:
app.get('/:userId/:action-:where', (request, response, nextHandler) => {
response.send(request.params)
})
// Route path: /123/edit-profile
// Outputs: {"userId":"123","action":"edit","where":"profile"}
模块化路由处理器
ExpressJS 有一个内置的名为 router 的类。路由器只是一个允许开发者编写可挂载和模块化路由处理器的类。
路由器是 ExpressJS 核心路由系统的一个实例。这意味着,来自 ExpressJS 应用程序的所有路由方法都是可用的:
const router = express.Router()
router.get('/', (request, response, next) => {
response.send('Hello there!')
})
router.post('/', (request, response, next) => {
response.send('I got your data!')
})
准备工作
在这个菜谱中,我们将看到如何使用路由器来制作一个模块化应用程序。在你开始之前,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
假设你想要在你的 ExpressJS 主应用程序中编写一个模块化迷你应用程序,该应用程序可以被挂载到任何 URI。你想要能够选择挂载它的路径,或者你只是想要将相同的路由方法和处理程序挂载到其他几个路径或 URI。
-
创建一个名为
modular-router.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 为你的迷你应用程序定义一个路由器,并添加一个请求方法来处理对路径
"/home"
的请求:
const miniapp = express.Router()
miniapp.get('/home', (request, response, next) => {
const url = request.originalUrl
response
.status(200)
.send(`You are visiting /home from ${url}`)
})
- 将你的模块化迷你应用程序挂载到
"/first"
路径,以及"/second"
路径:
app.use('/first', miniapp)
app.use('/second', miniapp)
- 在端口
1337
上监听新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行以下命令:
node modular-router.js
- 要查看结果,请在你的网络浏览器中导航到:
http://localhost:1337/first/home
http://localhost:1337/second/home
你将看到两个不同的输出:
You are visting /home from /first/home
You are visting /home from /second/home
如所示,一个路由器被挂载到两个不同的挂载点。路由器通常被称为迷你应用程序,因为它们可以被挂载到 ExpressJS 应用程序的特定路由上,并且不仅一次,还可以多次挂载到不同的挂载点、路径或 URI。
编写中间件函数
中间件函数主要用于在request
和response
对象中进行更改。它们按顺序依次执行,但如果一个中间件函数没有将控制权传递给下一个函数,则请求将挂起。
准备工作
中间件函数具有以下签名:
app.use((request, response, next) => {
next()
})
签名与编写路由处理程序非常相似。实际上,可以为特定的 HTTP 方法和特定的路径路由编写一个中间件函数,例如,它看起来像这样:
app.get('/', (request, response, next) => {
next()
})
因此,如果你想知道路由处理程序和中间件函数之间的区别是什么,答案是简单的:它们的目的。
如果你正在编写路由处理程序,并且修改了request
对象和/或response
对象,那么你正在编写中间件函数。
在这个菜谱中,你将看到如何使用中间件函数来限制对某些路径或路由的访问,这些路径或路由依赖于某个条件。在你开始之前,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
我们将编写一个中间件函数,该函数允许在查询参数allowme
存在时仅访问根路径"/"
:
-
创建一个名为
middleware-functions.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 编写一个中间件函数,该函数将
allowed
属性添加到request
对象中:
app.use((request, response, next) => {
request.allowed = Reflect.has(request.query, 'allowme')
next()
})
- 添加一个请求方法来处理对路径
"/"
的请求:
app.get('/', (request, response, next) => {
if (request.allowed) {
response.send('Hello secret world!')
} else {
response.send('You are not allowed to enter')
}
})
- 在端口
1337
上监听新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node middleware-functions.js
- 要查看结果,在你的网页浏览器中,导航到:
http://localhost:1337/
http://localhost:1337/?allowme
它是如何工作的...
就像处理路由处理程序一样,中间件函数需要将控制权传递给下一个处理程序;否则,我们的应用程序将会挂起,因为没有数据发送到客户端,连接也没有关闭。
如果在中间件函数内部的request
或response
对象中添加了新属性,下一个处理程序将能够访问这些新属性。就像我们之前编写的代码一样,request
对象中的allowed property
对下一个处理程序是可用的。
编写可配置的中间件函数
编写中间件函数的常见模式是将中间件函数包装在另一个函数中。这样做的结果是一个可配置的中间件函数。它们也是高阶函数,也就是说,一个返回另一个函数的函数。
const fn = (options) => (response, request, next) => {
next()
}
通常,一个对象被用作options
参数。然而,你完全可以用自己的方式来做。
准备工作
在这个菜谱中,你将编写一个可配置的日志中间件函数。在开始之前,创建一个新的package.json
文件,内容如下:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
你的可配置中间件函数要执行的操作很简单:当发起请求时,它会打印状态码和 URL。
-
创建一个名为
middleware-logger.js
的新文件 -
导出一个接受一个对象作为第一个参数的函数。该函数期望对象具有一个名为
enable
的属性,可以是true
或false
:
const logger = (options) => (request, response, next) => {
if (typeof options === 'object'
&& options !== null
&& options.enable) {
console.log(
'Status Code:', response.statusCode,
'URL:', request.originalUrl,
)
}
next()
}
module.exports = logger
- 保存文件
让我们测试一下...
我们的可配置中间件函数本身并没有什么用处。创建一个简单的 ExpressJS 应用程序来查看我们的中间件实际上是如何工作的:
-
创建一个名为
configurable-middleware-test.js
的新文件 -
包含我们的
middleware-logger.js
模块并初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const loggerMiddleware = require('./middleware-logger')
const app = express()
- 使用
use
方法包含我们的可配置中间件函数。当enable
属性设置为true
时,你的日志记录器将工作并记录每个请求的状态码和 URL 到终端:
app.use(loggerMiddleware({
enable: true,
}))
- 监听
1337
端口以接收新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node middleware-logger-test.js
- 在你的浏览器中,导航到:
http://localhost:1337/hello?world
- 终端应该显示:
Status Code: 200 URL: /hello?world
还有更多...
如果你想实验,将可配置中间件测试应用程序的enable
属性设置为false
。不应该显示任何日志。
通常,你希望在生产环境中禁用日志,因为这个操作可能会影响性能。
关闭所有日志的另一种方法是使用其他库来完成这项任务,而不是使用console
。还有一些库允许你设置不同的日志级别,例如:
-
Winston:
www.npmjs.com/package/winston
日志记录有几个有用的原因。主要原因包括:
-
它检查您的服务是否正常运行,例如,检查您的应用程序是否连接到 MongoDB。
-
它发现错误和漏洞。
-
它有助于您更好地理解应用程序的工作方式。例如,如果您有一个模块化应用程序,您可以看到它如何与其他应用程序集成。
编写路由级别中间件函数
路由级别中间件函数仅在路由器内部执行。它们通常用于将中间件应用于仅挂载点或特定路径时。
准备工作
在这个菜谱中,您将创建一个小的日志记录路由级别中间件函数,该函数将仅记录挂载在路由器挂载路径或位于该路径中的请求。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
-
创建一个名为
router-level.js
的新文件 -
初始化一个新的 ExpressJS 应用程序并定义一个路由器:
const express = require('express')
const app = express()
const router = express.Router()
- 定义我们的日志记录中间件函数:
router.use((request, response, next) => {
console.log('URL:', request.originalUrl)
next()
})
- 将路由器挂载到路径
"/router"
app.use('/router', router)
- 监听端口
1337
以便接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node router-level.js
- 在您的网页浏览器中导航到:
http://localhost:1337/router/example
- 终端应显示:
URL: /router/example
- 然后,在您的网页浏览器中导航到:
http://localhost:1337/example
- 终端中不应显示任何日志
还有更多...
通过调用 next('router')
,可以在路由器外部将控制权交回下一个中间件函数或路由方法。
router.use((request, response, next) => {
next('route')
})
例如,通过创建一个期望接收用户 ID 作为查询参数的路由器。当未提供用户 ID 时,可以使用 next('router')
函数退出路由器或将控制权传递给路由器外部的下一个中间件函数。路由器外部的下一个中间件函数可以在路由器将控制权传递给它时显示其他信息。例如:
-
创建一个名为
router-level-control.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 定义一个新的路由器:
const router = express.Router()
- 在路由器内部定义我们的日志记录中间件函数:
router.use((request, response, next) => {
if (!request.query.id) {
next('router') // Next, out of Router
} else {
next() // Next, in Router
}
})
- 向路由器外部添加一个路由方法来处理路径
"/"
的GET
请求,该请求只有在中间件函数将控制权传递给它时才会执行:
router.get('/', (request, response, next) => {
const id = request.query.id
response.send(`You specified a user ID => ${id}`)
})
- 向路由器外部添加一个路由方法来处理路径
"/"
的GET
请求。然而,将路由器作为第二个参数包含在路由处理程序中,并添加另一个路由处理程序来处理相同的请求,只有当路由器将控制权传递给它时:
app.get('/', router, (request, response, next) => {
response
.status(400)
.send('A user ID needs to be specified')
})
- 监听端口
1337
以便接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node router-level-control.js
- 要查看结果,请在浏览器中导航到:
http://localhost:1337/
http://localhost:1337/?id=7331
它是如何工作的...
当导航到第一个 URL (http://localhost:1337/
) 时,显示以下消息:
A user ID needs to be specified
这是因为路由器中的中间件函数检查查询中是否提供了 id
,因为它没有提供,所以它使用 next('router')
将控制权传递给路由器外部的下一个处理程序。
另一方面,当导航到第二个 URL (localhost:1337/?id=7331
) 时,显示以下消息:
You specified a user ID => 7331
这是因为在查询中提供了一个id
,所以路由器中的中间件函数会通过next()
将控制权传递给路由器内的下一个处理器。
编写错误处理程序中间件函数
ExpressJS 默认包含一个内置的错误处理程序,它在所有中间件和路由处理器结束时执行。
触发内置错误处理程序的方法有很多。一种是在路由处理器内部发生错误时的隐式触发。例如:
app.get('/', (request, response, next) => {
throw new Error('Oh no!, something went wrong!')
})
另一种触发内置错误处理程序的方式是在将error
作为参数传递给next(error)
时是明确的。例如:
app.get('/', (request, response, next) => {
try {
throw new Error('Oh no!, something went wrong!')
} catch (error) {
next(error)
}
})
栈跟踪会在客户端显示。如果NODE_ENV
设置为生产环境,则不会包括栈跟踪。
可以编写一个自定义错误处理程序中间件函数,其外观与路由处理器非常相似,除了错误处理程序函数中间件期望接收四个参数:
app.use((error, request, response, next) => {
next(error)
})
考虑到next(error)
是可选的。这意味着,如果指定了,next(error)
将控制权传递给下一个错误处理程序。如果没有定义其他错误处理程序,则控制权将传递给内置错误处理程序。
准备工作
在这个菜谱中,我们将看到如何创建一个自定义错误处理程序。在你开始之前,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行来安装依赖项:
npm install
如何操作...
你将构建一个自定义错误处理程序,将错误消息发送到客户端。
-
创建一个名为
custom-error-handler.js
的新文件 -
包含 ExpressJS 库,然后初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 定义一个新的路由方法来处理路径
"/"
的GET
请求并每次都抛出错误:
app.get('/', (request, response, next) => {
try {
throw new Error('Oh no!, something went wrong!')
} catch (err) {
next(err)
}
})
- 定义一个自定义错误处理程序中间件函数,将错误消息发送回客户端的浏览器:
app.use((error, request, response, next) => {
response.end(error.message)
})
- 监听端口
1337
以接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node custom-error-handler.js
- 要查看结果,在你的网页浏览器中导航到:
http://localhost:1337/
使用 ExpressJS 内置的中间件函数来提供静态资源
在 ExpressJS 4.x 版本之前,它依赖于 ConnectJS,这是一个 HTTP 服务器框架github.com/senchalabs/connect
。实际上,为 ConnectJS 编写的几乎所有中间件在 ExpressJS 中也得到了支持。
从 ExpressJS 4.x 版本开始,它不再依赖于 ConnectJS,并且所有之前内置的中间件函数都被移动到了单独的模块expressjs.com/en/resources/middleware.html
。
ExpressJS 4.x 及更高版本只包含两个内置中间件函数。第一个已经看到:内置错误处理程序中间件函数。第二个是负责提供静态资源的express.static
中间件函数。
express.static
中间件函数基于 serve-static
模块 expressjs.com/en/resources/middleware/serve-static.html
。
express.static
和 serve-static
之间的主要区别在于后者可以在 ExpressJS 之外使用。
准备工作
在本教程中,您将了解如何构建一个将在特定路径下提供静态资源的 Web 应用程序。在开始之前,创建一个包含以下内容的 package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
-
创建一个名为
public
的新目录 -
进入新的
public
目录 -
创建一个名为
index.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Web Application</title>
</head>
<body>
<section role="application">
<h1>Welcome Home!</h1>
</section>
</body>
</html>
-
保存文件
-
从
public
目录中退出 -
创建一个名为
serve-static-assets.js
的新文件: -
添加以下代码。初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const path = require('path')
const app = express()
- 包含
express.static
可配置中间件函数,并传递包含index.html
文件的/public
目录的路径:
const publicDir = path.join(__dirname, './public')
app.use('/', express.static(publicDir))
- 监听端口
1337
以便接受新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node serve-static-assets.js
- 要查看结果,请在浏览器中导航到:
http://localhost:1337/index.html
它是如何工作的...
我们的 index.html
文件将被显示,因为我们指定了 "/"
作为查找资源的根目录。
尝试将路径从 "/"
更改为 "/public"
。然后,您将能够看到 index.html
文件,以及其他您想要包含在 /public
目录中的文件,它们将在 http://localhost:1337/public/[fileName]
下可访问。
还有更多...
假设你有一个大项目,该项目提供数十个静态文件,包括图像、字体文件和 PDF 文档(关于隐私和法律事宜的文件)等。你决定想要将它们保存在单独的文件中,但你不想更改挂载路径或 URI。它们可以位于 /public
下,例如,但它们将存在于项目目录中的单独目录中:
首先,让我们创建第一个 public
目录,它将包含一个名为 index.html
的单个文件:
-
如果在之前的步骤中没有创建,请创建一个名为
public
的新目录: -
进入新的
public
目录 -
创建一个名为
index.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Web Application</title>
</head>
<body>
<section role="application">
<h1>Welcome Home!</h1>
</section>
</body>
</html>
- 保存文件
现在,让我们创建第二个公共目录,它将包含另一个名为 second.html
的文件:
-
从
public
目录中退出 -
创建一个名为
another-public
的新目录 -
进入新的
another-public
目录 -
创建一个名为
second.html
的新空文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Simple Web Application</title>
</head>
<body>
<section role="application">
Welcome to Second Page!
</section>
</body>
</html>
- 保存文件
如您所见,这两个文件存在于不同的目录中。要在单个挂载点下提供这些文件,请执行以下操作:
-
从
another-public
目录中退出 -
创建一个名为
router-serve-static.js
的新文件: -
包含 ExpressJS 和 path 库。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const path = require('path')
const app = express()
- 定义一个路由器:
const staticRouter = express.Router()
- 使用
express.static
可配置中间件函数包含两个目录,public
和another-public
:
const assets = {
first: path.join(__dirname, './public'),
second: path.join(__dirname, './another-public')
}
staticRouter
.use(express.static(assets.first))
.use(express.static(assets.second))
- 将路由器挂载到
"/"
路径:
app.use('/', staticRouter)
- 在端口
1337
上监听新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node router-serve-static.js
- 要查看结果,在浏览器中导航到:
http://localhost:1337/index.html
http://localhost:1337/second.html
- 在一个路径下为不同位置的两个不同文件提供服务
如果在不同目录下存在同名文件,客户端只会显示找到的第一个。
解析 HTTP 请求体
body-parser
是一个中间件函数,它解析传入的请求体,并将其作为 request.body
可用 expressjs.com/en/resources/middleware/body-parser.html
。
此模块允许应用程序将传入请求解析为:
-
JSON
-
文本
-
原始(缓冲原始传入数据)
-
URL 编码表单
当传入请求被压缩时,该模块支持自动解压缩 gzip 和 deflate 编码。
准备工作
在这个菜谱中,您将了解如何使用 body-parser
NPM 模块解析来自两个不同表单的内容体,这两个表单以两种不同的方式编码。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"body-parser": "1.18.2",
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
将向用户显示两个表单,它们都将以两种不同的方式将数据发送到我们的 Web 服务器应用程序。第一个是一个 URL 编码表单,而另一个将将其体编码为纯文本。
-
创建一个名为
parse-form.js
的文件 -
包含
body-parser
NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
- 包含
body-parser
中间件函数来处理 URL 编码请求和文本 plain 请求:
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.text())
- 添加一个新的路由方法来处理
"/"
路径的GET
请求。提供包含两个表单的 HTML 内容,这两个表单使用不同的编码提交数据:
app.get('/', (request, response, next) => {
response.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebApp powered by ExpressJS</title>
</head>
<body>
<div role="application">
<form method="post" action="/setdata">
<input name="urlencoded" type="text" />
<button type="submit">Send</button>
</form>
<form method="post" action="/setdata"
enctype="text/plain">
<input name="txtencoded" type="text" />
<button type="submit">Send</button>
</form>
</div>
</body>
</html>
`)
})
- 添加一个新的路由方法来处理
"/setdata"
路径的POST
请求。在终端显示request.body
的内容:
app.post('/setdata', (request, response, next) => {
console.log(request.body)
response.end()
})
- 在端口
1337
上监听新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node parse-form.js
- 在您的网页浏览器中,导航到:
http://localhost:1337/
-
在第一个输入框中填写任何数据并提交表单:
-
在您的网页浏览器中,返回到:
http://localhost:1337/
-
在第二个输入框中填写任何数据并提交表单:
-
在终端中检查输出
它是如何工作的...
终端输出类似以下内容:
{ 'urlencoded': 'Example' }
txtencoded=Example
上文使用了两个解析器:
-
第一个解析器
bodyParser.urlencoded()
解析multipart/form-data
编码类型的传入请求。结果作为Object
可在request.body
中访问 -
第二个解析器
bodyParser.text()
解析text/plain
编码类型的传入请求。结果作为String
可在request.body
中访问
压缩 HTTP 响应
compression 是一个中间件函数,它压缩将发送给客户端的响应体。此模块使用支持以下内容编码机制的 zlib
模块 nodejs.org/api/zlib.html
:
-
gzip
-
deflate
Accept-Encoding
HTTP 响应头用于确定客户端(例如,网页浏览器)支持哪种内容编码机制,而 Content-Encoding
HTTP 响应头用于告知客户端已应用于响应体的内容编码机制。
compression
是一个可配置的中间件函数。它接受一个 options
对象作为第一个参数,以定义中间件的具体行为,并传递 zlib
选项。
准备工作
在本食谱中,我们将了解如何配置和使用 compression
NPM 模块来压缩发送给客户端的请求体。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"compression": "1.7.2",
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
-
创建一个名为
compress-site.js
的新文件 -
包含
compression
NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const compression = require('compression')
const app = express()
- 包含
compression
中间件函数。指定压缩的level
为9
(最佳压缩)和threshold
,或响应体应考虑压缩的最小字节数,为0
字节:
app.use(compression({ level: 9, threshold: 0 }))
- 定义一个路由方法来处理路径
"/"
的GET
请求,该方法将提供我们期望被压缩的样本 HTML 内容,并将打印客户端接受的编码:
app.get('/', (request, response, next) => {
response.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebApp powered by ExpressJS</title>
</head>
<body>
<section role="application">
<h1>Hello! this page is compressed!</h1>
</section>
</body>
</html>
`)
console.log(request.acceptsEncodings())
})
- 监听端口
1337
以接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node compress-site.js
- 在您的浏览器中,导航到:
http://localhost:1337/
它是如何工作的...
终端输出将显示客户端(例如,网页浏览器)支持的内容编码机制。它可能看起来像这样:
[ 'gzip', 'deflate', 'sdch', 'br', 'identity' ]
客户端发送的内容编码机制被 compression
内部使用,以确定是否支持压缩。如果不支持压缩,则不压缩响应体。
如果打开 Chrome 开发者工具或类似工具并分析请求,服务器发送的 Content-Encoding
响应头指示 compression
使用的编码机制。
Chrome 开发者工具 | 网络标签显示响应头
compression
库将 Content-Encoding
响应头设置为用于压缩响应体的编码机制。
threshold
选项默认设置为 1 KB,这意味着如果响应大小低于指定的字节数,则不进行压缩。将其设置为 0
或 false
以在大小低于 1 KB 时压缩响应。
使用 HTTP 请求记录器
如前所述,编写请求记录器很简单。然而,编写我们自己的可能需要宝贵的时间。幸运的是,还有其他几种替代方案。例如,一个非常流行的 HTTP 请求记录器是 morgan expressjs.com/en/resources/middleware/morgan.html
。
morgan 是一个可配置的中间件函数,它接受两个参数 format
和 options
,用于指定日志显示的格式以及需要显示的信息类型。
有几种预定义的格式:
-
tiny
: 最小输出 -
short
: 与tiny
相同,包括远程 IP 地址 -
common
: 标准的 Apache 日志输出 -
combined
: 标准的 Apache 合并日志输出 -
dev
: 显示与tiny
格式相同的信息。然而,响应状态是彩色的。
准备中
创建一个包含以下内容的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"morgan": "1.9.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
让我们构建一个工作示例。我们将包含具有 dev
格式的 morgan 可配置中间件函数来显示每个请求的信息。
-
创建一个名为
morgan-logger.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const morgan = require('morgan')
const app = express()
- 包含可配置的
morgan
中间件。将'dev'
作为我们将用作中间件函数的第一个参数的格式:
app.use(morgan('dev'))
- 定义一个路由方法来处理所有
GET
请求:
app.get('*', (request, response, next) => {
response.send('Hello Morgan!')
})
- 监听端口
1337
以接收新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node morgan-logger.js
- 要在终端中查看结果,请在您的网络浏览器中导航到:
http://localhost:1337/
http://localhost:1337/example
管理和创建虚拟域名
使用 ExpressJS 管理虚拟域名非常简单。想象一下,你有两个或更多子域名,并且你想要提供两个不同的网络应用程序。然而,你不想为每个子域名创建不同的网络服务器应用程序。在这种情况下,ExpressJS 允许开发者在单个网络服务器应用程序内使用 vhost expressjs.com/en/resources/middleware/vhost.html
来管理虚拟域名。
vhost 是一个可配置的中间件函数,它接受两个参数。第一个参数是 hostname
。第二个参数是当 hostname
匹配时将被调用的请求处理器。
hostname
遵循与路由路径相同的规则。它们可以是字符串或正则表达式。
准备中
创建一个包含以下内容的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"vhost": "3.0.2"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
使用 Router 构建 two 个微型应用程序,它们将在两个不同的子域名中提供:
-
创建一个名为
virtual-domains.js
的新文件 -
包含
vhost
NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const vhost = require('vhost')
const app = express()
- 定义两个我们将使用来构建两个微型应用程序的路由器:
const app1 = express.Router()
const app2 = express.Router()
- 在第一个路由器中添加一个路由方法来处理路径
"/"
的GET
请求:
app1.get('/', (request, response, next) => {
response.send('This is the main application.')
})
- 在第二个路由器中添加一个路由方法来处理路径
"/"
的GET
请求:
app2.get('/', (request, response, next) => {
response.send('This is a second application.')
})
- 将我们的路由器挂载到我们的 ExpressJS 应用程序上。在
localhost
下提供第一个应用程序,在second.localhost
下提供第二个应用程序:
app.use(vhost('localhost', app1))
app.use(vhost('second.localhost', app2))
- 在端口
1337
上监听新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node virtual-domains.js
- 要查看结果,请在您的网页浏览器中导航到:
http://localhost:1337/
http://second.localhost:1337/
还有更多...
vhost
将一个 vhost 对象
添加到 request
对象中,该对象包含完整的域名(显示域名和端口)、域名(不带端口)和匹配字符串。这为您提供了更多控制虚拟域处理方式的能力。
例如,我们可以编写一个应用程序,允许用户拥有以他们名字为子域:
-
创建一个名为
user-subdomains.js
的新文件 -
包含
vhost
NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const vhost = require('vhost')
const app = express()
- 定义一个新的路由器。然后,添加一个路由方法来处理路径
"/"
上的GET
请求。使用vhost
对象访问子域数组:
const users = express.Router()
users.get('/', (request, response, next) => {
const username = request
.vhost[0]
.split('-')
.map(name => (
name[0].toUpperCase() +
name.slice(1)
))
.join(' ')
response.send(`Hello, ${username}`)
})
- 挂载路由器:
app.use(vhost('*.localhost', users))
- 在端口
1337
上监听新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node user-subdomains.js
- 要查看结果,请在您的网页浏览器中导航到:
http://john-smith.localhost:1337/
http://jx-huang.localhost:1337/
http://batman.localhost:1337/
使用 Helmet 保护 ExpressJS 网络应用程序
Helmet 允许保护网络服务器应用程序免受常见攻击,例如 跨站脚本 (XSS)、不安全的请求和点击劫持。
Helmet 是一组 12 个中间件函数,允许您设置特定的 HTTP 头部:
-
内容安全策略 (Content Security Policy, CSP)
: 这是允许在您的网络应用程序中允许外部资源(如 JavaScript、CSS 和图像)的一种有效方式。 -
证书透明度 (Certificate Transparency)
: 这是为特定域名或特定域名颁发的证书提供更多透明度的一种方式sites.google.com/a/chromium.org/dev/Home/chromium-security/certificate-transparency
。 -
DNS 预取控制 (DNS Prefetch Control)
: 这告诉浏览器是否应该对尚未加载的资源(如链接)执行域名解析(DNS)。 -
Frameguard
: 这通过告诉浏览器不允许您的网络应用程序被放入iframe
中来帮助防止 点击劫持。 -
隐藏 Powered-By
: 这只是隐藏了指示不显示服务器所使用技术的X-Powered-By
头部。ExpressJS 默认将其设置为"Express"
。 -
HTTP 公钥固定 (Public Key Pinning)
: 这通过将您的网络应用程序的公钥固定到Public-Key-Pins
头部来帮助防止 中间人攻击。 -
HTTP 严格传输安全 (Strict Transport Security)
: 这告诉浏览器严格坚持您的网络应用程序的 HTTPs 版本。 -
IE 无打开 (IE No Open)
: 这防止了 Internet Explorer 在您的站点上下文中执行不受信任的下载或 HTML 文件,从而防止恶意脚本的注入。 -
无缓存 (No Cache)
: 这告诉浏览器禁用浏览器缓存。 -
不要嗅探 MIME 类型
:这强制浏览器禁用 MIME 嗅探或猜测服务文件的类型。 -
Referrer Policy
:引用头提供了关于请求来源的数据给服务器。它允许开发者禁用它,或为设置referrer
头设置更严格的策略。 -
XSS Filter
:通过设置X-XSS-Protection
头来防止反射型跨站脚本(XSS)攻击。
准备工作
在这个菜谱中,我们将使用 Helmet 提供的大多数中间件函数来保护我们的 ExpressJS 网络应用程序免受常见攻击。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"body-parser": "1.18.2",
"express": "4.16.3",
"helmet": "3.12.0",
"uuid": "3.2.1"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
-
创建一个名为
secure-helmet.js
的新文件: -
包含 ExpressJS、helmet 和 body NPM 模块:
const express = require('express')
const helmet = require('helmet')
const bodyParser = require('body-parser')
const uuid = require('uuid/v1')
const app = express()
- 生成一个随机 ID,该 ID 将用于
nonce
,这是一个用于白名单的 HTML 属性,用于指定哪些脚本或样式允许在 HTML 代码中内联执行:
const suid = uuid()
- 使用 body parser 解析
json
和application/csp-report
内容类型的 JSON 请求体。application/csp-report
是一种包含类型为json
的 JSON 请求体的内容类型,当浏览器违反一个或多个 CSP 规则时,浏览器会发送它:
app.use(bodyParser.json({
type: ['json', 'application/csp-report'],
}))
- 使用
Content Security Policy
中间件函数来定义指令。defaultSrc
指定资源可以从中加载的位置。self
选项指定只从自己的域名加载资源。我们将使用none
,这意味着不会加载任何资源。然而,因为我们正在白名单scriptSrc
,我们将能够加载 JavaScript 脚本,但仅限于我们指定的nonce
的那些脚本。reportUri
用于告诉浏览器将我们的Content Security Policy
违规报告发送到何处:
app.use(helmet.contentSecurityPolicy({
directives: {
// By default do not allow unless whitelisted
defaultSrc: [`'none'`],
// Only allow scripts with this nonce
scriptSrc: [`'nonce-${suid}'`],
reportUri: '/csp-violation',
}
}))
- 添加一个路由方法来处理路径
"/csp-violation"
的POST
请求,以接收来自客户端的违规报告:
app.post('/csp-violation', (request, response, next) => {
const { body } = request
if (body) {
console.log('CSP Report Violation:')
console.dir(body, { colors: true, depth: 5 })
}
response.status(204).send()
})
- 使用
DNS Prefetch Control
中间件来禁用资源的预取:
app.use(helmet.dnsPrefetchControl({ allow: false }))
- 使用
Frameguard
中间件函数来禁用应用程序在iframe
中加载:
app.use(helmet.frameguard({ action: 'deny' }))
- 使用
hidePoweredBy
中间件函数来替换X-Powered-By
头并设置一个假的头:
app.use(helmet.hidePoweredBy({
setTo: 'Django/1.2.1 SVN-13336',
}))
- 使用
ieNoOpen
中间件函数来禁用 IE 不受信任的执行:
app.use(helmet.ieNoOpen())
- 使用
noSniff
中间件函数来禁用 MIME 类型猜测:
app.use(helmet.noSniff())
- 使用
referrerPolicy
中间件函数使头仅对我们自己的域名可用:
app.use(helmet.referrerPolicy({ policy: 'same-origin' }))
- 使用
xssFilter
中间件函数来防止反射型 XSS 攻击:
app.use(helmet.xssFilter())
- 添加一个路由方法来处理路径
"/"
的GET
请求,并服务一个示例 HTML 内容,该内容将尝试从外部源加载图像,尝试执行内联脚本,并尝试加载未指定nonce
的外部脚本。我们还将添加一个有效的脚本,该脚本允许执行,因为将指定一个nonce
属性:
app.get('/', (request, response, next) => {
response.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Web App</title>
</head>
<body>
<span id="txtlog"></span>
<img alt="Evil Picture" src="img/pic.jpg">
<script>
alert('This does not get executed!')
</script>
<script src="img/evilstuff.js"></script>
<script nonce="${suid}">
document.getElementById('txtlog')
.innerText = 'Hello World!'
</script>
</body>
</html>
`)
})
- 监听端口
1337
以接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node secure-helmet.js
- 要查看结果,在你的网页浏览器中,导航到:
http://localhost:1337/
它是如何工作的...
使用 Helmet
,一切的工作原理都非常直观。你通过选择并应用特定的 Helmet
中间件函数来指定你想要实施的安全措施,然后 Helmet
将负责设置正确的头信息并发送到客户端。
在客户端(网页浏览器)中,一切都会自动工作。浏览器负责解释服务器发送的头信息并应用安全策略。这也意味着,如果你的应用考虑了安全性,那么没有太多理由要支持旧版浏览器。
例如,如果你使用的是 Chrome,你应该能在控制台看到类似以下的内容:
Chrome 开发者工具 | 显示 CSP 违规的控制台
- 在终端中,你应该能看到浏览器发送的类似以下输出:
CSP Report Violation: {
"csp-report": {
"document-uri": "http://localhost:1337/",
"referrer": "",
"violated-directive": "img-src",
"effective-directive": "img-src",
"original-policy": "default-src 'none'; script-src
'[nonce]'; report-uri /csp-violation",
"disposition": "enforce",
"blocked-uri": "http://evil.com/pic.jpg",
"line-number": 9,
"source-file": "http://localhost:1337/",
"status-code": 200
}
}
CSP Report Violation: {
"csp-report": {
"document-uri": "http://localhost:1337/",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "default-src 'none'; script-src
'[nonce]'; report-uri /csp-violation",
"disposition": "enforce",
"blocked-uri": "inline",
"line-number": 9,
"status-code": 200
}
}
CSP Report Violation: {
"csp-report": {
"document-uri": "http://localhost:1337/",
"referrer": "",
"violated-directive": "script-src",
"effective-directive": "script-src",
"original-policy": "default-src 'none'; script-src
'[nonce]'; report-uri /csp-violation",
"disposition": "enforce",
"blocked-uri": "http://evil.com/evilstuff.js",
"status-code": 200
}
}
使用模板引擎
模板引擎允许你以更方便的方式生成 HTML 代码。模板或视图可以以任何格式编写,由模板引擎进行解释,将变量替换为其他值,最终转换为 HTML。
一份包含与 ExpressJS 兼容的模板引擎的大列表,可在官方网站 github.com/expressjs/express/wiki#template-engines
上找到。
准备工作
在这个菜谱中,你将构建自己的模板引擎。为了开发和使用自己的模板引擎,你首先需要注册它,然后定义视图所在的位置,最后告诉 ExpressJS 使用哪个模板引擎。
app.engine('...', (path, options, callback) => { ... });
app.set('views', './');
app.set('view engine', '...');
在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"express": "4.16.3"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何实现...
首先创建一个包含简单模板的 views
目录:
-
创建一个名为
views
的新目录 -
在我们的
views
目录内创建一个名为home.tpl
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Using Template Engines</title>
</head>
<body>
<section role="application">
<h1>%title%</h1>
<p>%description%</p>
</section>
</body>
</html>
- 保存文件
现在,创建一个新的模板引擎,它将转换之前的模板为 HTML,并将 %[var]%
替换为提供的选项:
-
离开
views
目录 -
创建一个名为
my-template-engine.js
的新文件 -
包含 ExpressJS 和 fs(文件系统)库。然后,初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const fs = require('fs')
const app = express()
- 使用
engine
方法注册一个名为tpl
的新模板引擎。我们将读取文件内容,并将%[var]%
替换为options
对象中指定的值:
app.engine('tpl', (filepath, options, callback) => {
fs.readFile(filepath, (err, data) => {
if (err) {
return callback(err)
}
const content = data
.toString()
.replace(/%[a-z]+%/gi, (match) => {
const variable = match.replace(/%/g, '')
if (Reflect.has(options, variable)) {
return options[variable]
}
return match
})
return callback(null, content)
})
})
- 定义视图所在的位置。我们的模板位于
views
目录中:
app.set('views', './views')
- 告诉 ExpressJS 使用我们的模板引擎:
app.set('view engine', 'tpl')
- 添加一个路由方法来处理路径
"/"
的GET
请求并渲染我们的主页模板。提供title
和description
选项,它们将替换模板中的%title%
和%description%
:
app.get('/', (request, response, next) => {
response.render('home', {
title: 'Hello',
description: 'World!',
})
})
- 监听端口
1337
以便接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
node my-template-engine.js
- 在您的浏览器中,导航到:
http://localhost:1337/
我们刚刚编写的模板引擎不会转义 HTML 字符。这意味着,如果您用从客户端获取的数据替换这些属性,可能会容易受到 XSS 攻击。您可能想使用来自官方 ExpressJS 网站的模板引擎,它更安全。
调试 ExpressJS 网络应用程序
关于 ExpressJS 中网络应用程序整个周期的调试信息是简单的。ExpressJS 使用内部 debug NPM 模块来记录信息。与 console.log
不同,debug 日志可以在生产模式下轻松禁用。
准备工作
在这个菜谱中,您将了解如何调试您的 ExpressJS 网络应用程序。在您开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"debug": "3.1.0",
"express": "4.16.3"
}
}
然后,通过打开终端并运行来安装依赖项:
npm install
如何做到这一点...
-
创建一个名为
debugging.js
的新文件 -
初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 为任何路径添加处理
GET
请求的路由方法:
app.get('*', (request, response, next) => {
response.send('Hello there!')
})
- 监听端口
1337
以便接收新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
-
在 Windows 上:
set DEBUG=express:* node debugging.js
- 在 Linux 或 MacOS 上:
DEBUG=express:* node debugging.js
- 在您的网络浏览器中,导航到:
http://localhost:1337/
- 观察您的终端输出以获取日志
它是如何工作的...
使用 DEBUG
环境变量来告知 debug 模块要调试 ExpressJS 应用程序的哪些部分。在我们之前编写的代码中,express:*
告诉 debug 模块记录与 express 应用程序相关的所有内容。
我们可以使用 DEBUG=express:router
来显示与 ExpressJS 的 Router 或路由相关的日志。
还有更多...
您可以在自己的项目中使用 debug NPM 模块。例如:
-
创建一个名为
myapp.js
的新文件 -
添加以下代码:
const express = require('express')
const app = express()
const debug = require('debug')('myapp')
app.get('*', (request, response, next) => {
debug('Request:', request.originalUrl)
response.send('Hello there!')
})
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件
-
打开终端并运行:
-
在 Windows 上:
set DEBUG=myapp node myapp.js
- 在 Linux 和 MacOS 上:
DEBUG=myapp node myapp.js
-
在您的网络浏览器中,导航到:
-
观察您的终端输出。它将显示类似以下内容:
Web Server running on port 1337
myapp Request: / +0ms
您可以使用 DEBUG
环境变量来告知 debug
模块显示日志,不仅限于 myapp
,还包括 ExpressJS,如下所示:
在 Windows 上:
set DEBUG=myapp,express:* node myapp.js
在 Linux 和 MacOS 上:
DEBUG=myapp,express:* node myapp.js
第三章:构建 RESTful API
在本章中,我们将涵盖以下菜谱:
-
使用 ExpressJS 的路由方法进行 CRUD 操作
-
使用 Mongoose 进行 CRUD 操作
-
使用 Mongoose 查询构建器
-
定义文档实例方法
-
定义静态模型方法
-
为 Mongoose 编写中间件函数
-
为 Mongoose 的模式编写自定义验证器
-
使用 ExpressJS 和 Mongoose 构建用于管理用户的 RESTful API
技术要求
你需要有一个 IDE,Visual Studio Code,Node.js 和 MongoDB。你还需要安装 Git,以便使用本书的 Git 仓库。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter03
查看以下视频以查看代码的实际效果:
简介
表示状态传输(REST)是构建在 Web 之上的架构风格。更具体地说,HTTP 1.1 协议标准是使用 REST 原则构建的。REST 提供了资源的表示。URL(统一资源定位符)用于定义资源的位置,并告诉浏览器它在何处。
RESTful API 是一种遵循此架构风格的网络服务 API。
最常用的 HTTP 动词或方法是:POST
、GET
、PUT
和DELETE
。这些方法是持久存储的基础,被称为CRUD操作(创建、读取、更新和删除)。
在本章中,我们将专注于使用 ExpressJS 和 Mongoose 的 REST 架构风格构建 RESTful API。
使用 ExpressJS 的路由方法进行 CRUD 操作
ExpressJS 的路由器有处理 HTTP 方法的等效方法。换句话说,HTTP 方法POST
、GET
、PUT
和DELETE
可以通过以下代码处理:
/* Add a new user */
app.post('/users', (request, response, next) => { })
/* Get user */
app.get('/users/:id', (request, response, next) => { })
/* Update a user */
app.put('/users/:id', (request, response, next) => { })
/* Delete a user */
app.delete('/users/:id', (request, response, next) => { })
好好想想,每个 URL 都可以看作一个名词,因此一个动词可以作用于它。实际上,HTTP 方法也被称为 HTTP 动词。如果我们把它们看作动词,当对 RESTful API 发起请求时,它们可以被理解为:
-
发布一个用户
-
获取一个用户
-
更新一个用户
-
删除一个用户。
在MVC(模型-视图-控制器)架构模式中,控制器负责将输入转换为模型或视图可以理解的内容。换句话说,它们将输入转换为动作或命令,并将它们发送到模型或视图以进行相应的更新。
ExpressJS 的路由方法通常充当控制器。它们只是从客户端获取输入,例如来自浏览器的请求,然后将输入转换为动作。这些动作随后被发送到模型,即应用程序的业务逻辑,例如 mongoose 模型,或者发送到视图(一个 ReactJS 客户端应用程序)以进行更新。
准备工作
考虑到我们可以使用 HTTP 方法在资源上调用操作,我们将了解如何根据这些概念构建 RESTful API Web 服务。在开始之前,创建一个新的package.json
文件,包含以下代码:
{
"dependencies": {
"express": "4.16.3",
"node-fetch": "2.1.1",
"uuid": "3.2.1"
}
}
然后,通过打开终端并运行以下行代码来安装依赖项:
npm install
如何实现...
使用内存数据库或包含用户列表的对象数组构建 RESTful API。我们将允许使用 HTTP 方法进行 CRUD 操作,以添加新用户、获取用户或用户列表、更新用户数据以及删除用户:
-
创建一个名为
restfulapi.js
的新文件。 -
导入所需的包并创建一个 ExpressJS 应用程序:
const express = require('express')
const uuid = require('uuid')
const app = express()
- 定义一个内存数据库:
let data = [
{ id: uuid(), name: 'Bob' },
{ id: uuid(), name: 'Alice' },
]
- 创建一个包含执行 CRUD 操作函数的模型:
const usr = {
create(name) {
const user = { id: uuid(), name }
data.push(user)
return user
},
read(id) {
if (id === 'all') return data
return data.find(user => user.id === id)
},
update(id, name) {
const user = data.find(usr => usr.id === id)
if (!user) return { status: 'User not found' }
user.name = name
return user
},
delete(id) {
data = data.filter(user => user.id !== id)
return { status: 'deleted', id }
}
}
- 为
post
方法添加一个请求处理器,该处理器将用作Create
操作。新用户将被添加到data
数组中:
app.post('/users/:name', (req, res) => {
res.status(201).json(usr.create(req.params.name))
})
- 为
get
方法添加一个请求处理器,该处理器将用作Read
或Retrieve
操作。如果提供了id
,则在data
数组中查找用户。然而,如果提供的id
是"all"
,它将返回整个用户列表:
app.get('/users/:id', (req, res) => {
res.status(200).json(usr.read(req.params.id))
})
- 为
put
方法添加一个请求处理器,该处理器将用作Update
操作。需要提供一个id
以更新data
数组中的特定用户:
app.put('/users/:id=:name', (req, res) => {
res.status(200).json(usr.update(
req.params.id,
req.params.name,
))
})
- 为
delete
方法添加一个请求处理器,该处理器将用作Delete
操作。它将在data
数组中查找用户并将其删除:
app.delete('/users/:id', (req, res) => {
res.status(200).json(usr.delete(req.params.id))
})
- 启动应用程序,监听端口
1337
以接收新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
-
保存文件。
-
打开终端并运行以下代码:
node restfulapi.js
让我们测试它...
为了简化,创建一个脚本,该脚本将请求并对我们的 RESTful API 服务器执行 CRUD 操作:
-
创建一个名为
test-restfulapi.js
的新文件。 -
添加以下代码:
const fetch = require('node-fetch')
const r = async (url, method) => (
await fetch(`http://localhost:1337${url}`, { method })
.then(r => r.json())
)
const log = (...obj) => (
obj.forEach(o => console.dir(o, { colors: true }))
)
async function test() {
const users = await r('/users/all', 'get')
const { id } = users[0]
const getById = await r(`/users/${id}`, 'get')
const updateById = await r(`/users/${id}=John`, 'put')
const deleteById = await r(`/users/${id}`, 'delete')
const addUsr = await r(`/users/Smith`, 'post')
const getAll = await r('/users/all', 'get')
log('[GET] users:', users)
log(`[GET] a user with id="${id}":`, getById)
log(`[PUT] a user with id="${id}":`, updateById)
log(`[POST] a new user:`, addUsr)
log(`[DELETE] a user with id="${id}":`, deleteById)
log(`[GET] users:`, getAll)
}
test()
-
保存文件。
-
打开一个新的终端并运行以下代码:
node test-restfulapi.js
它是如何工作的...
我们的 RESTful API 应用程序将本地运行在端口1337
上。在运行测试代码时,它将连接到它并使用不同的 HTTP 方法创建用户、检索用户、更新用户和删除用户。所有操作都将记录在终端中。
如果您想亲自测试,可以替换test
函数内的所有代码,并使用r
函数进行自定义请求。例如,要创建一个名为Smith
的新用户:
r(`/users/Smith`, 'post')
使用 Mongoose 进行 CRUD 操作
开发者选择使用 Mongoose 而不是 Node.js 的官方 MongoDB 驱动程序的原因之一是,它允许您通过使用模式轻松创建数据结构,并且还因为内置的验证。MongoDB 是一个面向文档的数据库,这意味着文档的结构是可变的。
在 MVC 架构模式中,Mongoose 通常用于创建定义数据结构的模型。
这就是典型的 Mongoose 模式定义及其编译成模型的方式:
const PersonSchema = new Schema({
firstName: String,
lastName: String,
})
const Person = connection.model('Person', PersonSchema)
模型名称应该是单数形式,因为 Mongoose 会在保存集合到数据库时将它们转换为复数并转换为小写。例如,如果模型名为 "User",它将在 MongoDB 中保存为名为 "users" 的集合。Mongoose 包含一个内部字典来复数化常见名称。这意味着如果你的模型名称是常见名称,例如 "Person",它将在 MongoDB 中保存为名为 "people" 的集合。
Mongoose 允许以下类型来定义模式路径或文档结构:
-
字符串
-
数字
-
布尔值
-
数组
-
日期
-
缓冲区
-
混合
-
ObjectId
-
十进制 128
可以直接使用 String
、Number
、Boolean
、Buffer
和 Date
的全局构造函数来声明模式类型:
const { Schema} = require('mongoose')
const PersonSchema = new Schema({
name: String,
age: Number,
isSingle: Boolean,
birthday: Date,
description: Buffer,
})
这些模式类型也在导出的 mongoose
对象下的 SchemaTypes
对象中可用:
const { Schema, SchemaTypes } = require('mongoose')
const PersonSchema = new Schema({
name: SchemaTypes.String,
age: SchemaTypes.Number,
isSingle: SchemaTypes.Boolean,
birthday: SchemaTypes.Date,
description: SchemaTypes.Buffer,
})
使用对象作为属性来声明模式类型可以让你对特定的模式类型有更多的控制。以下是一个示例代码:
const { Schema } = require('mongoose')
const PersonSchema = new Schema({
name: { type: String, required: true, default: 'Unknown' },
age: { type: Number, min: 18, max: 80, required: true },
isSingle: { type: Boolean },
birthday: { type: Date, required: true },
description: { type: Buffer },
})
模式类型也可以是数组。例如,如果我们想在一个字符串数组中定义用户喜欢的项目,可以使用以下代码:
const PersonSchema = new Schema({
name: String,
age: Number,
likes: [String],
})
要了解更多关于模式类型的信息,请访问官方 Mongoose 文档网站:mongoosejs.com/docs/schematypes.html
。
准备工作
在本教程中,你将了解如何定义模式和在数据库集合上执行 CRUD 操作。首先,确保你已经安装了 MongoDB 并且它在运行。作为替代,如果你更喜欢,也可以使用云中的 MongoDB DBaaS(数据库即服务)实例。在你开始之前,创建一个包含以下代码的新 package.json
文件:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行以下代码来安装依赖项:
npm install
如何做到这一点...
定义一个包含用户名、姓氏和用户喜欢的字符串数组的内容的用户模式:
-
创建一个名为
mongoose-models.js
的新文件 -
包含 Mongoose NPM 模块。然后,创建一个连接到 MongoDB:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UserSchema = new Schema({
firstName: String,
lastName: String,
likes: [String],
})
- 将模式编译成模型:
const User = mongoose.model('User', UserSchema)
- 定义一个用于添加新用户的函数:
const addUser = (firstName, lastName) => new User({
firstName,
lastName,
}).save()
- 定义一个用于通过用户的
id
从用户集合中检索用户的函数:
const getUser = (id) => User.findById(id)
- 定义一个函数,通过用户的
id
从用户集合中删除用户:
const removeUser = (id) => User.remove({ id })
- 定义一个事件监听器,在数据库连接建立后执行 CRUD 操作。首先,添加一个新用户并保存它。然后,使用其
id
检索相同的用户。接下来,修改用户的属性并保存它。最后,通过其id
从集合中删除用户:
connection.once('connected', async () => {
try {
// Create
const newUser = await addUser('John', 'Smith')
// Read
const user = await getUser(newUser.id)
// Update
user.firstName = 'Jonny'
user.lastName = 'Smithy'
user.likes = [
'cooking',
'watching movies',
'ice cream',
]
await user.save()
console.log(JSON.stringify(user, null, 4))
// Delete
await removeUser(user.id)
} catch (error) {
console.dir(error.message, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件。
-
打开终端并运行以下代码:
node mongoose-models.js
在终端中执行前面的命令,如果成功,将显示类似以下内容,例如,如下代码:
{
"likes": [
"cooking",
"watching movies",
"ice cream"
],
"_id": "[some id]",
"firstName": "Jonny",
"lastName": "Smithy",
"__v": 1
}
参见
-
第一章,MERN Stack 简介,安装 NPM 包部分
-
第一章,MERN Stack 简介,部分 安装 MongoDB
使用 Mongoose 查询构建器
每个 Mongoose 模型都有静态辅助方法来执行多种操作,例如检索文档。当将这些辅助方法传递给回调函数时,操作会立即执行:
const user = await User.findOne({
firstName: 'Jonh',
age: { $lte: 30 },
}, (error, document) => {
if (error) return console.log(error)
console.log(document)
})
否则,如果没有定义回调,则返回一个 查询构建器接口,该接口可以稍后执行:
const user = User.findOne({
firstName: 'Jonh',
age: { $lte: 30 },
})
user.exec((error, document) => {
if (error) return console.log(error)
console.log(document)
})
查询也有一个 .then
函数,它可以作为 Promise
使用。当调用 .then
时,它首先使用 .exec
内部执行查询,然后返回一个 Promise
。这允许我们使用 async/await
。在一个 async
函数内部,例如:
try {
const user = await User.findOne({
firstName: 'Jonh',
age: { $lte: 30 },
})
console.log(user)
} catch (error) {
console.log(error)
}
我们有两种方式可以执行查询。一种是通过提供一个 JSON 对象作为条件,另一种方式允许你使用链式语法创建查询。链式语法对于更熟悉 SQL 数据库的开发者来说会感觉更舒适。例如:
try {
const user = await User.findOne()
.where('firstName', 'John')
.where('age').lte(30)
console.log(user)
} catch (error) {
console.log(error)
}
准备工作
在这个菜谱中,你将使用链式语法和 async/await
函数构建查询。首先,确保你已经安装了 MongoDB 并且它在运行。作为替代,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在你开始之前,创建一个新的 package.json
文件,包含以下代码:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行来安装依赖项:
npm install
如何做到这一点...
-
创建一个名为
chaining-queries.js
的新文件 -
包含 Mongoose NPM 模块。然后,创建一个新的连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UserSchema = new Schema({
firstName: String,
lastName: String,
age: Number,
})
- 将模式编译成模型:
const User = mongoose.model('User', UserSchema)
- 一旦连接到数据库,向用户的集合中添加一个新的文档。然后,使用链式语法查询最近创建的用户。此外,使用
select
方法限制从文档中检索的字段:
connection.once('connected', async () => {
try {
const user = await new User({
firstName: 'John',
lastName: 'Snow',
age: 30,
}).save()
const findUser = await User.findOne()
.where('firstName').equals('John')
.where('age').lte(30)
.select('lastName age')
console.log(JSON.stringify(findUser, null, 4))
await user.remove()
} catch (error) {
console.dir(error.message, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件
-
打开终端并运行:
node chaining-queries.js
参见
-
第一章,MERN Stack 简介,部分 安装 NPM 包
-
第一章,MERN Stack 简介,部分 安装 MongoDB
定义文档实例方法
文档有其内置的实例方法,例如 save
和 remove
。然而,我们也可以编写自己的实例方法。
文档是模型的实例。它们可以被显式创建:
const instance = new Model()
或者它们可以是查询的结果:
Model.findOne([conditions]).then((instance) => {})
文档实例方法在模式中定义。所有模式都有一个名为 method
的方法,它允许你定义自定义实例方法。
准备工作
在这个菜谱中,你将定义一个模式和自定义文档实例方法来修改和读取文档属性。首先,确保你已经安装了 MongoDB 并且它在运行。作为替代,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在你开始之前,创建一个新的 package.json
文件,包含以下代码:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行以下代码来安装依赖项:
npm install
如何做到这一点...
-
创建一个名为
document-methods.js
的新文件 -
包含 Mongoose NPM 模块。然后,创建一个新的 MongoDB 连接:
const mongooconst mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UserSchema = new Schema({
firstName: String,
lastName: String,
likes: [String],
})
- 定义一个用于从包含用户全名的字符串中设置用户名和姓氏的文档实例方法:
UserSchema.method('setFullName', function setFullName(v) {
const fullName = String(v).split(' ')
this.lastName = fullName[0] || ''
this.firstName = fullName[1] || ''
})
- 定义一个用于获取用户全名的文档实例方法,通过连接
firstName
和lastName
属性:
UserSchema.method('getFullName', function getFullName() {
return `${this.lastName} ${this.firstName}`
})
- 定义一个名为
loves
的文档实例方法,它将期望一个参数,该参数将添加到字符串数组likes
中:
UserSchema.method('loves', function loves(stuff) {
this.likes.push(stuff)
})
- 定义一个名为
dislikes
的文档实例方法,该方法将从用户的likes
数组中删除之前喜欢的一项:
UserSchema.method('dislikes', function dislikes(stuff) {
this.likes = this.likes.filter(str => str !== stuff)
})
- 将模式编译成模型:
const User = mongoose.model('User', UserSchema)
- 一旦 Mongoose 连接到数据库,创建一个新的用户,并使用
setFullName
方法填充firstName
和lastName
字段,然后使用loves
方法填充likes
数组。接下来,使用链式语法查询用户集合中的用户,并使用dislikes
方法从likes
数组中删除"snakes"
:
connection.once('connected', async () => {
try {
// Create
const user = new User()
user.setFullName('Huang Jingxuan')
user.loves('kitties')
user.loves('strawberries')
user.loves('snakes')
await user.save()
// Update
const person = await User.findOne()
.where('firstName', 'Jingxuan')
.where('likes').in(['snakes', 'kitties'])
person.dislikes('snakes')
await person.save()
// Display
console.log(person.getFullName())
console.log(JSON.stringify(person, null, 4))
// Remove
await user.remove()
} catch (error) {
console.dir(error.message, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件。
-
打开终端并运行以下代码:
node document-methods.js
更多内容...
也可以使用methods
和schemas
属性定义文档实例方法。例如:
UserSchema.methods.setFullName = function setFullName(v) {
const fullName = String(v).split(' ')
this.lastName = fullName[0] || ''
this.firstName = fullName[1] || ''
}
参见
-
第一章,MERN 栈简介,部分安装 NPM 包
-
第一章,MERN 栈简介,部分安装 MongoDB
定义静态模型方法
模型有内置的静态方法,如find
、findOne
和findOneAndRemove
。Mongoose 允许我们定义自定义静态模型方法。静态模型方法与文档实例方法的定义方式相同。
模式有一个名为statics
的属性,它是一个对象。在statics
对象内部定义的所有方法都会传递给模型。也可以通过调用static
模式方法来定义静态模型方法。
准备工作
在这个菜谱中,你将定义一个模式和自定义静态模型方法来扩展你的模型功能。首先,确保你已经安装了 MongoDB 并且它正在运行。作为替代,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在开始之前,创建一个新的package.json
文件,包含以下代码:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行以下代码来安装依赖项:
npm install
如何做到这一点...
定义一个名为getByFullName
的静态模型方法,它将允许你使用全名搜索特定用户:
-
创建一个名为
static-methods.js
的新文件 -
包含 Mongoose NPM 模块并创建一个新的 MongoDB 连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UsrSchm = new Schema({
firstName: String,
lastName: String,
likes: [String],
})
- 定义
getByFullName
静态模型方法:
UsrSchm.static('getByFullName', function getByFullName(v) {
const fullName = String(v).split(' ')
const lastName = fullName[0] || ''
const firstName = fullName[1] || ''
return this.findOne()
.where('firstName').equals(firstName)
.where('lastName').equals(lastName)
})
- 将模式编译成模型:
const User = mongoose.model('User', UsrSchm)
- 连接后,创建一个新的用户并保存它。然后,使用
getByFullName
静态模型方法在用户集合中通过全名查找用户:
connection.once('connected', async () => {
try {
// Create
const user = new User({
firstName: 'Jingxuan',
lastName: 'Huang',
likes: ['kitties', 'strawberries'],
})
await user.save()
// Read
const person = await User.getByFullName(
'Huang Jingxuan'
)
console.log(JSON.stringify(person, null, 4))
await person.remove()
await connection.close()
} catch (error) {
console.log(error.message)
}
})
-
保存文件
-
打开终端并运行此代码:
node static-methods.js
还有更多...
静态模型方法也可以使用statics
模式属性定义。例如:
UsrSchm.statics.getByFullName = function getByFullName(v) {
const fullName = String(v).split(' ')
const lastName = fullName[0] || ''
const firstName = fullName[1] || ''
return this.findOne()
.where('firstName').equals(firstName)
.where('lastName').equals(lastName)
}
参见
-
第一章,MERN 栈简介,部分安装 NPM 包
-
第一章,MERN 栈简介,部分安装 MongoDB
为 Mongoose 编写中间件函数
Mongoose 中的中间件也称为hooks
。有两种类型的钩子:pre hooks
和post hooks
。
pre hooks
和post hooks
之间的区别非常简单。pre hooks
在调用方法之前调用,而post hooks
在调用之后调用。例如:
const UserSchema = new Schema({
firstName: String,
lastName: String,
fullName: String,
})
UserSchema.pre('save', async function preSave() {
this.fullName = `${this.lastName} ${this.firstName}`
})
UserSchema.post('save', async function postSave(doc) {
console.log(`New user created: ${doc.fullName}`)
})
const User = mongoose.model('User', UserSchema)
一旦与数据库建立连接,在async
函数中:
const user = new User({
firstName: 'John',
lastName: 'Smith',
})
await user.save()
一旦调用save
方法,首先执行pre hook
。文档保存后,然后执行post hook
。在上一个示例中,它将在终端输出中显示以下文本:
New user created: Smith John
Mongoose 中有四种不同的中间件函数:文档中间件、模型中间件、聚合中间件和查询中间件。所有这些都在模式级别上定义。区别在于,当钩子执行时,this
的上下文分别指向文档、模型、聚合对象或查询对象。
所有类型的中间件都支持pre
和post
钩子
准备工作
在这个菜谱中,我们将看到 Mongoose 中这三种类型的中间件函数是如何工作的:
-
文档中间件
-
模型中间件
-
查询中间件
首先,确保你已经安装了 MongoDB 并且正在运行。作为替代方案,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在开始之前,创建一个包含以下代码的新package.json
文件:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
在文档中间件函数中,this
的上下文指的是文档。文档有以下内置方法,并且可以为它们定义hooks
:
-
init
:这是在文档从 MongoDB 返回后立即内部调用的。Mongoose 使用 setter 来标记文档已修改或哪些字段已修改。init
初始化文档而不使用 setter。 -
validate
:这将为文档执行内置和自定义的验证规则。 -
save
:这将在数据库中保存文档。 -
remove
:这将从数据库中删除文档。
文档中间件函数
为文档内置方法创建pre
和post
钩子:
-
创建一个名为
1-document-middleware.js
的新文件 -
包含 Mongoose NPM 模块并创建到 MongoDB 的新连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UserSchema = new Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
})
- 为
init
文档方法添加pre
和post
钩子:
UserSchema.pre('init', async function preInit() {
console.log('A document is going to be initialized.')
})
UserSchema.post('init', async function postInit() {
console.log('A document was initialized.')
})
- 为
validate
文档方法添加pre
和post
钩子:
UserSchema.pre('validate', async function preValidate() {
console.log('A document is going to be validated.')
})
UserSchema.post('validate', async function postValidate() {
console.log('All validation rules were executed.')
})
- 为
save
文档方法添加pre
和post
钩子:
UserSchema.pre('save', async function preSave() {
console.log('Preparing to save the document')
})
UserSchema.post('save', async function postSave() {
console.log(`A doc was saved id=${this.id}`)
})
- 为
remove
文档方法添加pre
和post
钩子:
UserSchema.pre('remove', async function preRemove() {
console.log(`Doc with id=${this.id} will be removed`)
})
UserSchema.post('remove', async function postRemove() {
console.log(`Doc with id=${this.id} was removed`)
})
- 将模式编译成一个模型:
const User = mongoose.model('User', UserSchema)
- 一旦建立新的连接,创建一个文档并执行一些基本操作,例如保存、检索和删除文档:
connection.once('connected', async () => {
try {
const user = new User({
firstName: 'John',
lastName: 'Smith',
})
await user.save()
await User.findById(user.id)
await user.remove()
await connection.close()
} catch (error) {
await connection.close()
console.dir(error.message, { colors: true })
}
})
-
保存文件
-
打开一个终端并运行:
node document-middleware.js
- 在终端上,输出应显示:
A document is going to be validated.
All validation rules were executed.
Preparing to save the document
A doc was saved id=[ID]
A document is going to be initialized.
A document was initialized.
Doc with id=[ID] will be removed
Doc with id=[ID] was removed
当你保存一个文档时,它首先触发 validation
钩子,以确保字段通过内置验证规则或自定义规则的规则。在你的代码中,字段被标记为必填。然后它将触发 save
钩子。之后,使用模型方法从数据库检索最近创建的用户,一旦文档被检索,它将触发 init
钩子。最后,从数据库中删除文档将触发 remove
钩子。
在钩子内部,你可以与文档交互。例如,以下 save
预钩子将修改 firstName
和 lastName
字段,使它们成为大写字符串:
UserSchema.pre('save', async function preSave() {
this.firstName = this.firstName.toUpperCase()
this.lastName = this.lastName.toUpperCase()
})
同样,我们可以在钩子内抛出错误以防止后续的钩子执行。例如:
UserSchema.pre('save', async function preSave() {
throw new Error('Doc was prevented from being saved.')
})
查询中间件函数的定义与文档中间件函数完全相同。然而,this
的上下文不指向文档,而是指向查询对象。查询中间件函数仅支持以下模型和查询函数:
-
count
: 计算匹配特定查询条件的文档数量 -
find
: 返回匹配特定查询条件的文档数组 -
findOne
: 返回匹配特定查询条件的文档 -
findOneAndRemove
: 与findOne
类似。然而,在找到文档后,它会将其删除 -
findOneAndUpdate
: 与findOne
类似,但一旦找到匹配特定查询条件的文档,还可以更新该文档 -
update
: 更新匹配特定查询条件的单个或多个文档
查询中间件函数
为查询内置方法创建预和后钩子:
-
创建一个名为
2-query-middleware.js
的新文件 -
包含 Mongoose NPM 模块并创建一个新的 MongoDB 连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个模式:
const UserSchema = new Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
})
- 为
count
、find
、findOne
和update
方法定义预和后钩子:
UserSchema.pre('count', async function preCount() {
console.log(
`Preparing to count document with this criteria:
${JSON.stringify(this._conditions)}`
)
})
UserSchema.post('count', async function postCount(count) {
console.log(`Counted ${count} documents that coincide`)
})
UserSchema.pre('find', async function preFind() {
console.log(
`Preparing to find all documents with criteria:
${JSON.stringify(this._conditions)}`
)
})
UserSchema.post('find', async function postFind(docs) {
console.log(`Found ${docs.length} documents`)
})
UserSchema.pre('findOne', async function prefOne() {
console.log(
`Preparing to find one document with criteria:
${JSON.stringify(this._conditions)}`
)
})
UserSchema.post('findOne', async function postfOne(doc) {
console.log(`Found 1 document:`, JSON.stringify(doc))
})
UserSchema.pre('update', async function preUpdate() {
console.log(
`Preparing to update all documents with criteria:
${JSON.stringify(this._conditions)}`
)
})
UserSchema.post('update', async function postUpdate(r) {
console.log(`${r.result.ok} document(s) were updated`)
})
- 将模式编译成一个模型:
const User = mongoose.model('User', UserSchema)
- 一旦成功连接到数据库,创建一个文档,保存它,并使用我们为以下方法定义的钩子:
connection.once('connected', async () => {
try {
const user = new User({
firstName: 'John',
lastName: 'Smith',
})
await user.save()
await User
.where('firstName').equals('John')
.update({ lastName: 'Anderson' })
await User
.findOne()
.select(['lastName'])
.where('firstName').equals('John')
await User
.find()
.where('firstName').equals('John')
await User
.where('firstName').equals('Neo')
.count()
await user.remove()
} catch (error) {
console.dir(error, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件
-
打开一个终端并运行:
node query-middleware.js
- 在终端上,输出应显示类似以下内容:
Preparing to update all documents with criteria:
{"firstName":"John"}
1 document(s) were updated
Preparing to find one document with criteria:
{"firstName":"John"}
Found 1 document: {"_id":"[ID]","lastName":"Anderson"}
Preparing to find all documents with criteria:
{"firstName":"John"}
Found 1 documents
Preparing to count document with this criteria:
{"firstName":"Neo"}
Counted 0 documents that coincide
最后,只有一个模型实例方法支持钩子:
insertMany
: 这会验证一个文档数组,并且只有当数组中的所有文档都通过验证时,才会将它们保存到数据库中
如你所猜,模型中间件函数也是以与查询中间件方法和文档中间件方法相同的方式定义的。
模型中间件函数
为 insertMany
模型实例方法创建一个 pre
和 post
钩子:
-
创建一个名为
3-model-middleware.js
的新文件 -
包含 Mongoose NPM 模块并创建一个新的 MongoDB 连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个架构:
const UserSchema = new Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
})
- 为
insertMany
模型方法定义pre
和post
钩子:
UserSchema.pre('insertMany', async function prMany() {
console.log('Preparing docs...')
})
UserSchema.post('insertMany', async function psMany(docs) {
console.log('The following docs were created:n', docs)
})
- 将架构编译成模型:
const User = mongoose.model('User', UserSchema)
- 一旦建立了数据库连接,就可以使用
insertMany
方法一次性插入两个文档:
connection.once('connected', async () => {
try {
await User.insertMany([
{ firstName: 'Leo', lastName: 'Smith' },
{ firstName: 'Neo', lastName: 'Jackson' },
])
} catch (error) {
console.dir(error, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件
-
打开终端并运行:
node query-middleware.js
- 在终端上,输出应该显示:
Preparing docs...
The following documents were created:
[ { firstName: 'Leo', lastName: 'Smith', _id: [id] },
{ firstName: 'Neo', lastName: 'Jackson', _id: [id] } ]
更多内容...
标记字段为必填项很有用,这样可以避免在数据库中保存“null”值。另一种方法是,为在文档创建时未明确定义的字段设置默认值。例如:
const UserSchema = new Schema({
name: {
type: string,
required: true,
default: 'unknown',
}
})
当创建新文档时,如果没有分配路径或属性name
,则它将分配在架构类型选项default
中定义的默认值。
架构类型的default
选项也可以是一个函数。调用此函数返回的值被分配为默认值。
通过在定义架构类型时添加括号,也可以创建子文档或数组。例如:
const WishBoxSchema = new Schema({
wishes: {
type: [String],
required: true,
default: [
'To be a snowman',
'To be a string',
'To be an example',
],
},
})
当创建新文档时,它将期望在wishes
属性或路径中有一个字符串数组。如果没有提供数组,则将使用默认值来创建文档。
参见
-
第一章,MERN 栈简介,部分安装 NPM 包
-
第一章,MERN 栈简介,部分安装 MongoDB
为 Mongoose 的架构编写自定义验证器
Mongoose 有几个内置验证规则。例如,如果你定义了一个具有string
架构类型的属性并将其设置为required
,将执行两个验证规则,一个检查属性是否为有效的string
,另一个检查属性是否不是null
或undefined
。
可以在 Mongoose 中定义自定义验证规则和自定义错误验证消息,以便在将某些属性保存到数据库之前有更多的控制权。
验证规则在架构中定义。所有架构类型都有一个内置验证器required
,这意味着它不能包含undefined
或null
值。required
验证器可以是boolean
类型、一个function
或一个array
。例如:
path: { type: String, required: true }
path: { type: String, required: [true, 'Custom error message'] }
path: { type: String, required: () => true }
字符串架构类型有以下内置验证器:
enum
:这表示字符串只能具有enum
数组中指定的值。例如:
gender: {
type: SchemaTypes.String,
enum: ['male', 'female', 'other'],
}
match
:这使用RegExp
来测试值。例如,只允许以www
开头的值:
website: {
type: SchemaTypes.String,
match: /^www/,
}
-
maxlength
:这定义了一个字符串可以拥有的最大长度。 -
minlength
:这定义了一个字符串可以拥有的最小长度。例如,只允许5
到20
个字符的字符串:
name: {
type: SchemaTypes.String,
minlength: 5,
maxlength: 20,
}
数字架构类型有两个内置验证器:
-
min
:这定义了一个数字可以拥有的最小值。 -
max
:这定义了一个数字可以拥有的最大值。例如,只允许18
到100
之间的数字:
age: {
type: String,
min: 18,
max: 100,
}
未定义的值会通过所有验证器而不会出错。如果你想在值是 undefined
时抛出错误,请务必使用 required
验证器将其设置为 true
当内置验证器无法满足你的要求或你希望执行复杂的验证规则时,你可以选择或属性名为 validate
。它接受一个对象,该对象有两个属性,validator
和 message
,允许我们编写自定义验证器:
nickname: {
type: String,
validate: {
validator: function validator(value) {
return /^[a-zA-Z-]$/.test(value)
},
message: '{VALUE} is not a valid nickname.',
},
}
准备工作
在这个菜谱中,你将看到如何使用自定义验证规则来确保某个字段符合或满足定义的规则。首先,确保你已经安装了 MongoDB 并且它在运行。作为替代,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在开始之前,创建一个新的 package.json
文件,并使用以下代码:
{
"dependencies": {
"mongoose": "5.0.11"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
创建一个用户模式,并确保所有用户名都是字符串类型,最小长度为六个字符,最大长度为 20 个字符,匹配正则表达式,并且是必需的:
-
创建一个名为
custom-validation.js
的新文件 -
包含 Mongoose NPM 模块并创建一个新的数据库连接:
const mongoose = require('mongoose')
const { connection, Schema } = mongoose
mongoose.connect(
'mongodb://localhost:27017/test'
).catch(console.error)
- 定义一个包含
username
字段验证规则的模式:
const UserSchema = new Schema({
username: {
type: String,
minlength: 6,
maxlength: 20,
required: [true, 'user is required'],
validate: {
message: '{VALUE} is not a valid username',
validator: (val) => /^[a-zA-Z]+$/.test(val),
},
},
})
- 将模式编译成模型:
const User = mongoose.model('User', UserSchema)
- 一旦与数据库建立了连接,创建一个新的包含无效字段的文档,并使用
validateSync
文档方法来触发内置和自定义的验证方法:
connection.once('connected', async () => {
try {
const user = new User()
let errors = null
// username field is not defined
errors = user.validateSync()
console.dir(errors.errors['username'].message)
// username contains less than 6 characters
user.username = 'Smith'
errors = user.validateSync()
console.dir(errors.errors['username'].message)
// RegExp matching
user.username = 'Smith_9876'
errors = user.validateSync()
console.dir(errors.errors['username'].message)
} catch (error) {
console.dir(error, { colors: true })
} finally {
await connection.close()
}
})
-
保存文件
-
打开终端并运行:
node custom-validation.js
- 在终端上,输出应显示:
'user is required'
'Path `username` (`Smith`) is shorter than the minimum allowed
length (6).'
'Smith_9876 is not a valid username'
参见
-
第一章,MERN 栈简介,部分 安装 NPM 包
-
第一章,MERN 栈简介,部分 安装 MongoDB
使用 ExpressJS 和 Mongoose 构建管理用户的 RESTful API
在这个菜谱中,你将构建一个 RESTful API,允许创建新用户、登录、显示用户信息和删除用户的个人资料。此外,你还将学习如何使用客户端 API 构建一个 NodeJS REPL,你可以使用它来与你的服务器 RESTful API 交互。
REPL(读取-评估-打印循环)就像一个交互式外壳,你可以依次执行命令。例如,Node.js REPL 可以通过在终端中运行以下命令来打开:
node -i
这里,-i
标志代表交互式。现在,你可以执行 JavaScript 代码,这些代码将在新的上下文中逐个评估。
准备工作
这个菜谱将专注于展示如何使用之前菜谱中看到的内容来集成 Mongoose 与 ExpressJS。首先,确保你已经安装了 MongoDB 并且它在运行。作为替代,如果你愿意,也可以使用云中的 MongoDB DBaaS 实例。在开始之前,创建一个新的 package.json
文件,并使用以下代码:
{
"dependencies": {
"body-parser": "1.18.2",
"connect-mongo": "2.0.1",
"express": "4.16.3",
"express-session": "1.15.6",
"mongoose": "5.0.11",
"node-fetch": "2.1.2"
}
}
然后,通过打开终端并运行以下代码来安装依赖项:
npm install
如何实现...
首先,创建一个名为server.js
的文件,该文件将包含两个中间件函数。一个用于配置会话,另一个确保在调用任何路由之前先建立到 MongoDB 的连接。然后,我们将 API 路由挂载到特定路径:
-
创建一个名为
server.js
的新文件 -
包含所需的库。然后,初始化一个新的 ExpressJS 应用程序并创建到 MongoDB 的连接:
const mongoose = require('mongoose')
const express = require('express')
const session = require('express-session')
const bodyParser = require('body-parser')
const MongoStore = require('connect-mongo')(session)
const api = require('./api/controller')
const app = express()
const db = mongoose.connect(
'mongodb://localhost:27017/test'
).then(conn => conn).catch(console.error)
- 使用
body-parser
中间件将请求体解析为 JSON:
app.use(bodyParser.json())
- 定义一个 ExpressJS 中间件函数,确保在允许执行下一个路由处理器之前,首先确保您的 Web 应用程序连接到 MongoDB:
app.use((request, response, next) => {
Promise.resolve(db).then(
(connection, err) => (
typeof connection !== 'undefined'
? next()
: next(new Error('MongoError'))
)
)
})
- 配置
express-session
中间件,将会话存储在 Mongo 数据库中而不是内存中:
app.use(session({
secret: 'MERN Cookbook Secrets',
resave: false,
saveUninitialized: true,
store: new MongoStore({
collection: 'sessions',
mongooseConnection: mongoose.connection,
}),
}))
- 将 API 控制器挂载到
"/api"
路由:
app.use('/users', api)
- 监听端口 1773 以接收新连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
- 保存文件
然后,创建一个名为api
的新目录。接下来,创建应用程序的模型或业务逻辑。定义一个用户模式,其中包含静态和实例方法,允许用户注册、登录、登出、获取个人资料数据、更改密码和删除个人资料:
-
在
api
目录中创建一个名为model.js
的新文件 -
包含 Mongoose NPM 模块以及将用于生成用户密码散列的
crypto
NodeJS 模块:
const { connection, Schema } = require('mongoose')
const crypto = require('crypto')
- 定义模式:
const UserSchema = new Schema({
username: {
type: String,
minlength: 4,
maxlength: 20,
required: [true, 'username field is required.'],
validate: {
validator: function (value) {
return /^[a-zA-Z]+$/.test(value)
},
message: '{VALUE} is not a valid username.',
},
},
password: String,
})
- 定义一个静态模型方法
login
:
UserSchema.static('login', async function(usr, pwd) {
const hash = crypto.createHash('sha256')
.update(String(pwd))
const user = await this.findOne()
.where('username').equals(usr)
.where('password').equals(hash.digest('hex'))
if (!user) throw new Error('Incorrect credentials.')
delete user.password
return user
})
- 定义一个静态模型方法
signup
:
UserSchema.static('signup', async function(usr, pwd) {
if (pwd.length < 6) {
throw new Error('Pwd must have more than 6 chars')
}
const hash = crypto.createHash('sha256').update(pwd)
const exist = await this.findOne()
.where('username')
.equals(usr)
if (exist) throw new Error('Username already exists.')
const user = this.create({
username: usr,
password: hash.digest('hex'),
})
return user
})
- 定义一个文档实例方法
changePass
:
UserSchema.method('changePass', async function(pwd) {
if (pwd.length < 6) {
throw new Error('Pwd must have more than 6 chars')
}
const hash = crypto.createHash('sha256').update(pwd)
this.password = hash.digest('hex')
return this.save()
})
- 将 Mongoose 模式编译成模型并导出:
module.exports = connection.model('User', UserSchema)
- 保存文件
最后,定义一个控制器,将请求体转换为模型可以理解的操作。然后将其作为包含所有 API 路径的 ExpressJS 路由器导出:
-
在
api
文件夹中创建一个名为controller.js
的新文件 -
导入
model.js
并初始化一个新的 ExpressJS 路由:
const express = require('express')
const User = require('./model')
const api = express.Router()
- 定义一个请求处理器来检查用户是否已登录,另一个请求处理器来检查用户是否未登录:
const isLogged = ({ session }, res, next) => {
if (!session.user) res.status(403).json({
status: 'You are not logged in!',
})
else next()
}
const isNotLogged = ({ session }, res, next) => {
if (session.user) res.status(403).json({
status: 'You are logged in already!',
})
else next()
}
- 定义一个
post
请求方法来处理对"/login"
端点的请求:
api.post('/login', isNotLogged, async (req, res) => {
try {
const { session, body } = req
const { username, password } = body
const user = await User.login(username, password)
session.user = {
_id: user._id,
username: user.username,
}
session.save(() => {
res.status(200).json({ status: 'Welcome!'})
})
} catch (error) {
res.status(403).json({ error: error.message })
}
})
- 定义一个
post
请求方法来处理对"/logout"
端点的请求:
api.post('/logout', isLogged, (req, res) => {
req.session.destroy()
res.status(200).send({ status: 'Bye bye!' })
})
- 定义一个
post
请求方法来处理对"/signup"
端点的请求:
api.post('/signup', async (req, res) => {
try {
const { session, body } = req
const { username, password } = body
const user = await User.signup(username, password)
res.status(201).json({ status: 'Created!'})
} catch (error) {
res.status(403).json({ error: error.message })
}
})
- 定义一个
get
请求方法来处理对"/profile"
端点的请求:
api.get('/profile', isLogged, (req, res) => {
const { user } = req.session
res.status(200).json({ user })
})
- 定义一个
put
请求方法来处理对"/changepass"
端点的请求:
api.put('/changepass', isLogged, async (req, res) => {
try {
const { session, body } = req
const { password } = body
const { _id } = session.user
const user = await User.findOne({ _id })
if (user) {
await user.changePass(password)
res.status(200).json({ status: 'Pwd changed' })
} else {
res.status(403).json({ status: user })
}
} catch (error) {
res.status(403).json({ error: error.message })
}
})
- 定义一个
delete
请求方法来处理对"/delete"
端点的请求:
api.delete('/delete', isLogged, async (req, res) => {
try {
const { _id } = req.session.user
const user = await User.findOne({ _id })
await user.remove()
req.session.destroy((err) => {
if (err) throw new Error(err)
res.status(200).json({ status: 'Deleted!'})
})
} catch (error) {
res.status(403).json({ error: error.message })
}
})
- 导出路由:
module.exports = api
- 保存文件
让我们测试一下...
您已经构建了一个 RESTful API,允许用户订阅或注册、登录、登出、获取个人资料和删除个人资料。这些操作可以通过向服务器发送 HTTP 请求来执行。现在,我们将构建一个小型的 NodeJS REPL 和客户端 API,允许您使用纯 JavaScript 函数与 RESTful API 服务器交互:
-
移动到项目目录的根目录并创建一个名为
client-repl.js
的新文件。 -
包含
node-fetch
NPM 模块,该模块允许向服务器发送 HTTP 请求。同时,包含repl
和vm
Node.js 模块,这些模块允许您创建一个交互式的 Node.js REPL:
const repl = require('repl')
const util = require('util')
const vm = require('vm')
const fetch = require('node-fetch')
const { Headers } = fetch
- 定义一个变量,该变量将在用户登录后包含来自 cookie 的会话 ID。cookie 将用于让服务器识别已登录用户,以便执行诸如获取个人资料信息或更改密码等操作:
let cookie = null
- 定义一个名为
query
的辅助函数,该函数将允许向服务器发送 HTTP 请求。credentials
选项允许从服务器发送和接收 cookie。我们定义headers
,这将告诉服务器要发送的请求体的内容类型为 JSON 内容:
const query = (path, ops) => {
return fetch(`http://localhost:1337/users/${path}`, {
method: ops.method,
body: ops.body,
credentials: 'include',
body: JSON.stringify(ops.body),
headers: new Headers({
...(ops.headers || {}),
cookie,
Accept: 'application/json',
'Content-Type': 'application/json',
}),
}).then(async (r) => {
cookie = r.headers.get('set-cookie') || cookie
return {
data: await r.json(),
status: r.status,
}
}).catch(error => error)
}
- 定义一个方法,允许用户注册:
const signup = (username, password) => query('/signup', {
method: 'POST',
body: { username, password },
})
- 定义一个方法,允许用户登录:
const login = (username, password) => query('/login', {
method: 'POST',
body: { username, password },
})
- 定义一个方法,允许用户注销:
const logout = () => query('/logout', {
method: 'POST',
})
- 定义一个方法,允许用户获取其个人资料:
const getProfile = () => query('/profile', {
method: 'GET',
})
- 定义一个方法,允许用户更改他们的密码:
const changePassword = (password) => query('/changepass', {
method: 'PUT',
body: { password },
})
- 定义一个方法,允许用户删除其个人资料:
const deleteProfile = () => query('/delete', {
method: 'DELETE',
})
- 使用 REPL 导出的对象的 start 方法启动一个新的 REPL 服务器。我们将指定 eval 方法使用 VM 模块执行 JavaScript 代码,然后,如果返回 Promise,它将等待 Promise 解决后再允许用户输入更多命令或更多 JavaScript 代码到 REPL 中。我们还将指定 writer 方法,该方法将格式化打印之前定义的方法的调用结果:
const replServer = repl.start({
prompt: '> ',
ignoreUndefined: true,
async eval(cmd, context, filename, callback) {
const script = new vm.Script(cmd)
const is_raw = process.stdin.isRaw
process.stdin.setRawMode(false)
try {
const res = await Promise.resolve(
script.runInContext(context, {
displayErrors: false,
breakOnSigint: true,
})
)
callback(null, res)
} catch (error) {
callback(error)
} finally {
process.stdin.setRawMode(is_raw)
}
},
writer(output) {
return util.inspect(output, {
breakLength: process.stdout.columns,
colors: true,
compact: false,
})
}
})
- 将之前定义的方法添加到执行 JavaScript 代码的 REPL 服务器上下文中:
replServer.context.api = {
signup,
login,
logout,
getProfile,
changePassword,
deleteProfile,
}
- 保存文件
现在,您可以在终端上运行您的 RESTful API 服务器:
node server.js
在不同的终端中,运行您刚刚创建的 NodeJS REPL 应用程序:
node client-repl.js
在 REPL 中,您可以执行 JavaScript 代码,并且您还可以访问导出的方法。例如,您可以在您的 REPL 中逐行执行以下 JavaScript 代码:
api.signup('John', 'zxcvbnm')
api.login('John', 'zxcvbnm')
api.getProfile()
api.changePassword('newPwd')
api.logout()
api.login('John', 'incorrectPwd')
它是如何工作的...
您的 RESTful API 服务器将接受以下路径的请求:
-
POST/users/login
: 如果 MongoDB 中的users
集合中不存在该用户名,将向客户端发送错误消息。否则,它返回一个欢迎消息。 -
POST/users/logout
: 这将销毁会话 ID。 -
POST/users/signup
: 这将使用定义的密码创建一个新的用户名。然而,如果用户名或密码未通过验证,将向客户端发送错误。如果用户名已存在,也会向客户端发送错误消息。 -
GET/users/profile
: 如果用户已登录,将用户信息发送到客户端。否则,向客户端发送错误消息。 -
PUT/users/changepass/
: 这将更改当前登录用户的密码。然而,如果用户未登录,将向客户端发送错误消息。 -
DELETE/users/delete
:这将从 MongoDB 的users
集合中删除已登录用户的个人资料。会话将被销毁,并向客户端发送确认消息。如果用户未登录,将向客户端发送错误消息
参见
-
第一章,MERN Stack 简介,部分 安装 NPM 包
-
第一章,MERN Stack 简介,部分 安装 MongoDB
第四章:使用 Socket.IO 和 ExpressJS 进行实时通信
在本章中,我们将介绍以下食谱:
-
理解 NodeJS 事件
-
理解 Socket.IO 事件
-
使用 Socket.IO 命名空间进行工作
-
定义和加入 Socket.IO 房间
-
为 Socket.IO 编写中间件
-
将 Socket.IO 与 ExpressJS 集成
-
在 Socket.IO 中使用 ExpressJS 中间件
技术要求
你将需要有一个 IDE、Visual Studio Code、Node.js 和 MongoDB。你还需要安装 Git,以便使用本书的 Git 仓库。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter04
观看以下视频以查看代码的实际应用:
简介
现代网络应用程序通常需要实时通信,其中数据从客户端到服务器以及相反方向(几乎)无延迟地持续流动。
HTML5 WebSocket 协议是为了满足这一需求而创建的。WebSocket 使用单个 TCP 连接,即使在服务器或客户端没有发送任何数据时,该连接也会保持打开状态。这意味着,只要客户端和服务器之间存在连接,就可以在任何时候发送数据,而无需打开到服务器的新的连接。
实时通信在构建聊天应用程序到多用户游戏等方面有多个应用,其中响应时间非常重要。
在本章中,我们将专注于学习如何使用 Socket.IO (socket.io
) 构建实时网络应用程序,并理解 Node.js 事件驱动架构。
Socket.IO 是实现实时通信最常用的库之一。Socket.IO 尽可能使用 WebSocket,但在特定浏览器不支持 WebSocket 时会回退到其他方法。因为你可能希望你的应用程序对任何浏览器都可用,所以直接与 WebSocket 交互可能不是一个好主意。
理解 Node.js 事件
Node.js 具有事件驱动架构。Node.js 的核心 API 大部分是围绕 EventEmitter
构建的。这是一个 Node.js 模块,允许 监听器
订阅某些命名事件,这些事件可以由 发射器 在以后触发。
你可以通过仅包含 events Node.js 模块并创建 EventEmitter
的新实例来轻松定义自己的事件发射器:
const EventEmitter = require('events')
const emitter = new EventEmitter()
emitter.on('welcome', () => {
console.log('Welcome!')
})
然后,你可以通过使用 emit
方法来触发 welcome
事件:
emitter.emit('welcome')
实际上,这很简单。一个优点是你可以为同一事件订阅多个监听器,当使用 emit
方法时,它们将被触发:
emitter.on('welcome', () => {
console.log('Welcome')
})
emitter.on('welcome', () => {
console.log('There!')
})
emitter.emit('welcome')
EventEmitter
API 提供了几个有用的方法,这些方法可以让你更好地控制事件处理。查看官方 Node.js 文档以获取有关 API 的所有信息:nodejs.org/api/events.html
。
准备工作
在这个菜谱中,你将创建一个扩展EventEmitter
的类,它将包含自己的实例方法来触发附加到特定事件的监听器。首先,通过打开终端并运行以下命令来创建一个新的项目:
npm init
如何操作...
创建一个扩展EventEmitter
的类,并定义两个实例方法,分别称为start
和stop
。当调用start
方法时,它将触发所有附加到start
事件的监听器。它将使用process.hrtime
保持起始时间。然后,当调用stop
方法时,它将触发所有附加到stop
事件的监听器,并将自start
方法调用以来时间的差值作为参数传递:
-
创建一个名为
timer.js
的新文件 -
包含 NodeJS 模块的事件:
const EventEmitter = require('events')
- 定义两个常量,我们将使用它们将
process.hrtime
返回的值从秒转换为纳秒,然后转换为毫秒:
const NS_PER_SEC = 1e9
const NS_PER_MS = 1e6
- 定义一个名为
Timer
的类,包含两个实例方法:
class Timer extends EventEmitter {
start() {
this.startTime = process.hrtime()
this.emit('start')
}
stop() {
const diff = process.hrtime(this.startTime)
this.emit(
'stop',
(diff[0] * NS_PER_SEC + diff[1]) / NS_PER_MS,
)
}
}
- 创建一个之前定义的类的新的实例
const tasks = new Timer()
- 将一个循环执行乘法的监听器附加到
start
事件,之后调用stop
方法:
tasks.on('start', () => {
let res = 1
for (let i = 1; i < 100000; i++) {
res *= i
}
tasks.stop()
})
- 将一个事件监听器附加到
stop
事件,该监听器将打印事件start
执行所有附加监听器所需的时间:
tasks.on('stop', (time) => {
console.log(`Task completed in ${time}ms`)
})
- 调用
start
方法以触发所有start
事件监听器:
tasks.start()
-
保存文件
-
打开一个新的终端并运行:
node timer.js
它是如何工作的...
当执行start
方法时,它使用process.hrtime
来保持起始时间,process.hrtime
返回一个包含两个元素的数组,其中第一个元素是一个表示秒数的数字,而第二个元素是表示纳秒的另一个数字。然后,它触发所有附加到start
事件的监听器。
在另一方面,当stop
方法执行时,它使用之前调用process.hrtime
的结果作为参数传递给同一个函数,该函数返回时间差。这有助于测量从start
方法调用到stop
方法调用的时间。
还有更多...
一个常见的错误是假设事件是异步调用的。确实,定义的事件可以在任何时间被调用。然而,它们仍然是同步执行的。以下是一个例子:
const EventEmitter = require('events')
const events = new EventEmitter()
events.on('print', () => console.log('1'))
events.on('print', () => console.log('2'))
events.on('print', () => console.log('3'))
events.emit('print')
前述代码的输出将如下所示:
1
2
3
如果你在一个事件中有一个循环正在运行,下一个事件将不会在之前的循环执行完毕之前被调用。
通过简单地添加一个async
函数作为事件监听器,可以使事件异步执行。这样做,每个函数仍然会按照从第一个listener
定义到最后一个的顺序被调用。然而,事件发射器不会等待第一个listener
完成执行就调用下一个监听器。这意味着你不能保证输出总是按照相同的顺序,例如:
events.on('print', () => console.log('1'))
events.on('print', async () => console.log(
await Promise.resolve('2'))
)
events.on('print', () => console.log('3'))
events.emit('print')
前述代码的输出将如下所示:
1
3
2
异步函数使我们能够编写非阻塞应用程序。如果正确实现,你不会遇到上述问题。
EventEmitter
实例有一个名为 listeners
的方法,当执行并提供一个事件名称作为参数时,返回特定事件的附加监听器数组。我们可以使用此方法以允许按它们附加的顺序执行 async
函数,例如:
const EventEmitter = require('events')
class MyEvents extends EventEmitter {
start() {
return this.listeners('logme').reduce(
(promise, nextEvt) => promise.then(nextEvt),
Promise.resolve(),
)
}
}
const event = new MyEvents()
event.on('logme', () => console.log(1))
event.on('logme', async () => console.log(
await Promise.resolve(2)
))
event.on('logme', () => console.log(3))
event.start()
这将按它们附加的顺序执行并显示输出:
1
2
3
理解 Socket.IO 事件
Socket.IO 是一个基于事件的模块或库,正如你可能猜到的,它是基于 EventEmitter
的。Socket.IO 中的所有内容都通过事件工作。当新的连接建立到 Socket.IO 服务器时,会触发一个事件,并且可以发出事件以向客户端发送数据。
Socket.IO 服务器 API 与 Socket.IO 客户端 API 不同。然而,两者都通过事件从客户端向服务器发送数据,反之亦然。
Socket.IO 服务器事件
Socket.IO 使用到单个路径的单个 TCP 连接。这意味着,默认情况下,连接是到 URL http[s]://host:port/socket.io
。然而,在 Socket.IO 中,它允许您定义命名空间。这意味着不同的端点,但连接仍然保持单个 URL。
默认情况下,Socket.IO 服务器使用 "/"
或根命名空间
当然,您可以定义多个实例,并监听不同的 URL。然而,为了本食谱的目的,我们将假设只创建了一个连接。
Socket.IO 命名空间具有以下事件,您的应用程序可以订阅这些事件:
connect
或connection
:当建立新的连接时,此事件被触发。它将一个套接字对象作为第一个参数提供给监听器,该参数代表与客户端的新连接
io.on('connection', (socket) => {
console.log('A new client is connected')
})
// Which is the same as:
io.of('/').on('connection', (socket) => {
console.log('A new client is connected')
})
Socket.IO 套接字对象具有以下事件:
disconnecting
:当客户端即将从服务器断开连接时,会触发此事件。它向监听器提供一个参数,指定断开连接的原因
socket.on('disconnecting', (reason) => {
console.log('Disconnecting because', reason)
})
disconnected
:类似于断开连接事件。然而,此事件在客户端从服务器断开连接后触发:
socket.on('disconnect', (reason) => {
console.log('Disconnected because', reason)
})
error
:当事件内部发生错误时,会触发此事件
socket.on('error', (error) => {
console.log('Oh no!', error.message)
})
[eventName]
:当客户端发出具有相同名称的事件时,将触发用户定义的事件。客户端可以发出事件,并在参数中提供数据。在服务器上,事件将被触发,并将接收客户端发送的数据
Socket.IO 客户端事件
客户端不一定是网页浏览器。我们也可以编写 Node.js Socket.IO 客户端应用程序。
Socket.IO 客户端事件非常丰富,并提供了对应用程序的大量控制:
connect
:当成功连接到服务器时,此事件被触发
clientSocket.on('connect', () => {
console.log('Successfully connected to server')
})
connect_error
:当尝试连接或重新连接到服务器时发生错误时,会触发此事件
clientSocket.on('connect_error', (error) => {
console.log('Connection error:', error)
})
connect_timeout
:默认情况下,在触发connect_error
和connect_timeout
之前设置的超时时间为 20 秒。在此之后,Socket.IO 客户端可能会再次尝试重新连接到服务器:
clientSocket.on('connect_timeout', (timeout) => {
console.log('Connect attempt timed out after', timeout)
})
disconnect
:当客户端从服务器断开连接时,会触发此事件。提供一个参数,指定断开连接的原因:
clientSocket.on('disconnect', (reason) => {
console.log('Disconnected because', reason)
})
reconnect
:在成功重新连接尝试后触发。提供一个参数,指定在连接成功之前发生了多少次尝试:
clientSocket.on('reconnect', (n) => {
console.log('Reconnected after', n, 'attempt(s)')
})
reconnect_attempt
或reconnecting
:当尝试重新连接到服务器时,会触发此事件。提供一个参数,指定当前尝试连接到服务器的次数:
clientSocket.on('reconnect_attempt', (n) => {
console.log('Trying to reconnect again', n, 'time(s)')
})
reconnect_error
:类似于connect_error
事件。然而,只有当尝试重新连接到服务器时出现错误时才会触发:
clientSocket.on('reconnect_error', (error) => {
console.log('Oh no, couldn't reconnect!', error)
})
reconnect_failed
:默认情况下,最大尝试次数设置为Infinity
。这意味着,这个事件不太可能被触发。然而,我们可以指定一个选项来限制最大连接尝试次数。稍后我们将看到这一点:
clientSocket.on('reconnect_failed', (n) => {
console.log('Couldn'nt reconnected after', n, 'times')
})
ping
:简而言之,这个事件被触发以检查与服务器之间的连接是否仍然活跃:
clientSocket.on('ping', () => {
console.log('Checking if server is alive')
})
pong
:在触发ping
事件后,从服务器接收到响应时触发。提供一个参数,指定延迟或响应时间:
clientSocket.on('pong', (latency) => {
console.log('Server responded after', latency, 'ms')
})
error
:当事件内部发生错误时,会触发此事件:
clientSocket.on('error', (error) => {
console.log('Oh no!', error.message)
})
[eventName]
:当在服务器中触发事件时,由用户定义的事件被触发。服务器提供的参数将由客户端接收。
准备工作
在这个菜谱中,你将使用你刚刚学到的关于事件的知识来构建一个 Socket.IO 服务器和一个 Socket.IO 客户端。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
将构建一个 Socket.IO 服务器来响应一个名为 time
的单个事件。当事件被触发时,它将获取服务器的当前时间,并触发另一个名为 "got time?"
的事件,提供两个参数,当前 time
和一个指定请求次数的 counter
:
-
创建一个名为
simple-io-server.js
的新文件 -
包含 Socket.IO 模块并初始化一个新的服务器:
const io = require('socket.io')()
- 定义将要建立连接的 URL 路径:
io.path('/socket.io')
- 使用根或
"/"
命名空间:
const root = io.of('/')
- 当建立新的连接时,初始化一个
counter
变量为0
。然后,向time
事件添加一个新的监听器,每次有新的请求时,counter
就会增加一,并触发将在客户端定义的"got time?"
事件:
root.on('connection', socket => {
let counter = 0
socket.on('time', () => {
const currentTime = new Date().toTimeString()
counter += 1
socket.emit('got time?', currentTime, counter)
})
})
- 监听端口
1337
以便接收新的连接:
io.listen(1337)
- 保存文件
接下来,构建一个将连接到我们服务器的 Socket.IO 客户端:
-
创建一个名为
simple-io-client.js
的新文件 -
包含 Socket.IO 客户端模块:
const io = require('socket.io-client')
- 初始化一个新的 Socket.IO 客户端,提供服务器 URL 和一个选项对象,其中我们将定义在 URL 中使用的路径,其中将建立连接:
const clientSocket = io('http://localhost:1337', {
path: '/socket.io',
})
- 为
connect
事件添加一个事件监听器。然后,当建立连接时,使用for
循环,触发time
事件 5 次:
clientSocket.on('connect', () => {
for (let i = 1; i <= 5; i++) {
clientSocket.emit('time')
}
})
- 为
"got time?"
事件添加一个事件监听器,该监听器预期将接收两个参数:时间和一个counter
,该counter
指定了向服务器发送了多少次请求,然后在控制台上打印:
clientSocket.on('got time?', (time, counter) => {
console.log(counter, time)
})
-
保存文件
-
打开一个终端并首先运行 Socket.IO 服务器:
node simple-io-server.js
- 打开另一个终端并运行 Socket.IO 客户端:
node simple-io-client.js
它是如何工作的...
所有的工作都是通过事件来完成的。Socket.IO 允许在服务器端定义事件,客户端可以触发。在另一端,它还允许在客户端定义事件,服务器可以触发。
当服务器端发出用户定义的事件时,数据首先发送到客户端。Socket.IO 客户端首先检查是否有该事件的监听器。如果有监听器,它将被触发。当客户端端发出用户定义的事件时,情况也是一样的:
-
在我们的 Socket.IO 服务器
socket
对象中添加了一个名为time
的事件监听器,该监听器可以被客户端端触发 -
在我们的 Socket.IO 客户端中添加了一个名为
"got time?"
的事件监听器,该监听器可以被服务器端触发 -
连接时,客户端首先触发
time
事件 -
之后,在服务器端触发
time
事件,该事件将触发"got time?"
事件,并提供两个参数,当前服务器的time
和一个counter
,该counter
指定了请求被发送的次数 -
然后,在客户端触发
"got time?"
事件,该事件接收两个由服务器提供的参数,即time
和counter
使用 Socket.IO 命名空间进行工作
命名空间是一种在重用相同的 TCP 连接或最小化创建新的 TCP 连接以实现服务器和客户端之间实时通信需求的同时,分离应用程序业务逻辑的方法。
命名空间看起来与 ExpressJS 的路由路径非常相似:
/home
/users
/users/profile
然而,如前所述的食谱中提到的,这些与 URL 无关。默认情况下,在这个 URL http[s]://host:port/socket.io
上创建一个单一的 TCP 连接
当使用命名空间时,重用相同的事件名称是一种良好的实践。例如,假设我们有一个 Socket.IO 服务器,我们使用它来在客户端触发 getWelcomeMsg
事件时触发 setWelcomeMsg
事件:
io.of('/en').on('connection', (socket) => {
socket.on('getWelcomeMsg', () => {
socket.emit('setWelcomeMsg', 'Hello World!')
})
})
io.of('/es').on('connection', (socket) => {
socket.on('getWelcomeMsg', () => {
socket.emit('setWelcomeMsg', 'Hola Mundo!')
})
})
如您所见,我们在两个不同的命名空间中定义了对 getWelcomeMsg
事件的监听器:
-
如果客户端连接到英语或
/en
命名空间,当触发setWelcomeMsg
事件时,客户端将收到"Hello World!"
-
另一方面,如果客户端连接到西班牙语或
/es
命名空间,当触发setWelcomeMsg
事件时,客户端将收到"Hola Mundo!"
准备中
在这个菜谱中,你将了解如何处理包含相同事件名称的两个不同命名空间。在你开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
构建一个 Socket.IO 服务器,该服务器将触发一个 data
事件,并发送一个包含两个属性 title
和 msg
的对象,这些属性将用于在所选语言中填充 HTML 内容。使用命名空间来根据客户端选择的语言(英语或西班牙语)分隔和发送不同的数据:
-
创建一个名为
nsp-server.js
的新文件 -
包含 Socket.IO npm 模块和创建 HTTP 服务器所需的模块:
const http = require('http')
const fs = require('fs')
const path = require('path')
const io = require('socket.io')()
- 使用
http
模块创建一个新的 HTTP 服务器,该服务器将作为 Socket.IO 客户端来服务你稍后创建的 HTML 文件:
const app = http.createServer((req, res) => {
if (req.url === '/') {
fs.readFile(
path.resolve(__dirname, 'nsp-client.html'),
(err, data) => {
if (err) {
res.writeHead(500)
return void res.end()
}
res.writeHead(200)
res.end(data)
}
)
} else {
res.writeHead(403)
res.end()
}
})
- 指定新连接将连接到的路径:
io.path('/socket.io')
- 对于
"/en"
命名空间,添加一个新的事件监听器getData
,当它被触发时,将在客户端上发出一个data
事件,并发送一个包含title
和msg
属性的对象,这些属性使用英语语言:
io.of('/en').on('connection', (socket) => {
socket.on('getData', () => {
socket.emit('data', {
title: 'English Page',
msg: 'Welcome to my Website',
})
})
})
- 对于
"/es"
命名空间,执行相同的操作。但是,发送给客户端的对象将包括西班牙语的title
和msg
属性:
io.of('/es').on('connection', (socket) => {
socket.on('getData', () => {
socket.emit('data', {
title: 'Página en Español',
msg: 'Bienvenido a mi sitio Web',
})
})
})
- 监听端口
1337
以接收新连接,并将 Socket.IO 附加到底层的 HTTP 服务器:
io.attach(app.listen(1337, () => {
console.log(
'HTTP Server and Socket.IO running on port 1337'
)
}))
- 保存文件。
之后,创建一个 Socket.IO 客户端,它将连接到我们的服务器,并根据从服务器接收到的数据填充 HTML 内容。
-
创建一个名为
nsp-client.html
的新文件 -
首先,指定文档类型为 HTML5。在其旁边添加一个
html
标签,并将其语言设置为英语。在html
标签内,包括head
和body
标签:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO Client</title>
</head>
<body>
<!-- code here -->
</body>
</html>
- 在
body
标签内添加前三个元素:一个包含内容标题的标题 (h1
),一个包含来自服务器的消息的p
标签,以及一个用于切换到不同命名空间的按钮。还要包含 Socket.IO 客户端库。Socket.IO 服务器将在此 URL 上提供库文件:http[s]😕/host:port/socket.io/socket.io.js。然后,还包括babel
独立库,该库将转换下一步中的代码为可以在所有浏览器中运行的 JavaScript 代码:
<h1 id="title"></h1>
<section id="msg"></section>
<button id="toggleLang">Get Content in Spanish</button>
<script src="img/socket.io.js">
</script>
<script src="img/babel.min.js">
</script>
- 在
body
标签内的最后script
标签之后,添加另一个script
标签并将其类型设置为"text/babel"
:
<script type="text/babel">
// code here!
</script>
-
之后,在
script
标签内添加以下 JavaScript 代码 -
定义三个常量,它们将包含对我们在
body
中创建的元素的引用:
const title = document.getElementById('title')
const msg = document.getElementById('msg')
const btn = document.getElementById('toggleLang')
- 定义一个 Socket.IO 客户端管理器。它将帮助我们使用提供的配置创建套接字:
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
- 创建一个新的套接字,将其连接到
"/en"
命名空间。我们将假设这是默认连接:
const socket = manager.socket('/en')
- 为
"/en"
和"/es"
命名空间保留两个连接。保留的连接将允许我们在不需要创建新的 TCP 连接的情况下切换到不同的命名空间:
manager.socket('/en')
manager.socket('/es')
- 添加一个事件监听器,一旦套接字连接,就会发出一个
getData
事件以从服务器请求数据:
socket.on('connect', () => {
socket.emit('getData')
})
- 添加一个事件监听器,用于
data
事件,当客户端从服务器接收到数据时会被触发:
socket.on('data', (data) => {
title.textContent = data.title
msg.textContent = data.msg
})
- 为
button
添加一个事件监听器。当它被点击时,切换到不同的命名空间:
btn.addEventListener('click', (event) => {
socket.nsp = socket.nsp === '/en'
? '/es'
: '/en'
btn.textContent = socket.nsp === '/en'
? 'Get Content in Spanish'
: 'Get Content in English'
socket.close()
socket.open()
})
-
保存文件
-
打开一个新的终端并运行:
node nsp-server.js
- 在网络浏览器中,导航到:
http://localhost:1337/
让我们测试一下...
要查看您之前的工作效果,请按照以下步骤操作:
-
一旦您在您的网络浏览器中导航到
http://localhost:1337/
,点击"获取西班牙语内容"
按钮以切换到西班牙语命名空间 -
点击
"获取英语内容"
按钮以切换回英语命名空间
它是如何工作的...
这是服务器端发生的情况:
-
我们定义了两个命名空间
"/en"
和"/es"
,然后向 套接字对象 添加了一个新的事件监听器,名为getData
。 -
当在两个定义的命名空间中的任何一个中触发
getData
事件时,它将发出一个数据事件,并向客户端发送一个包含标题和消息属性的对象
在客户端,在我们的 HTML 文档中的脚本标签内部:
- 初始时,为命名空间
"/en"
创建了一个新的套接字:
const socket = manager.socket('/en')
- 同时,我们为命名空间
"/en"
和"/es"
创建了两个新的 套接字。它们将作为保留连接:
manager.socket('/en')
manager.socket('/es')
-
之后,添加了一个事件监听器
connect
,在连接时向服务器发送请求 -
然后,添加了另一个事件监听器数据,当从服务器接收到数据时会被触发
-
在处理我们按钮的 onclick 事件的监听器内部,我们更改 nsp 属性以切换到不同的命名空间。然而,为了实现这一点,我们首先必须断开 套接字,然后调用 open 方法再次使用新的命名空间建立新的连接
让我们看看关于保留连接的令人困惑的部分之一。当你在同一个命名空间中创建一个或多个 套接字 时,第一个连接会被重用,例如:
const first = manager.socket('/home')
const second = manager.socket('/home') // <- reuses first connection
在客户端,如果没有保留连接,则切换到之前未使用的命名空间将导致创建一个新的连接。
如果你好奇,请从 nsp-client.html
文件中删除这两行:
manager.socket('/en')
manager.socket('/es')
之后,重新启动或再次运行 Socket.IO 服务器。你会注意到切换到不同的命名空间时响应速度变慢,因为创建了一个新的连接而不是重用。
有另一种实现相同目标的方法。我们可以创建两个指向两个不同命名空间的 socket,"/en"
和"/es"
。然后,我们可以为每个 socket 添加两个事件监听器,连接和数据。然而,因为第一个和第二个 socket 将包含相同的事件名称,并且从服务器接收相同格式的数据,所以我们会有重复的代码。想象一下,如果我们必须为具有相同事件名称和接收相同格式数据的五个不同命名空间做同样的事情,会有太多的重复代码行。这就是切换命名空间和重用相同的 socket 对象有帮助的地方。然而,可能存在两种或更多不同命名空间为不同类型的事件使用不同事件名称的情况,在这种情况下,最好为每个命名空间分别添加事件监听器。例如:
const englishNamespace = manager.socket('/en')
const spanishNamespace = manager.socket('/es')
// They listen to different events
englishNamespace.on('showMessage', (data) => {})
spanishNamespace.on('mostrarMensaje', (data) => {})
还有更多...
在客户端,你可能注意到了我们之前没有使用过的一个东西,io.Manager
。
io.Manager
这允许我们预先定义或配置新连接的创建方式。在Manager
中定义的选项,如 URL,将在初始化 socket 时传递。
在我们的 HTML 文件中,在script
标签内,我们创建了一个新的io.Manager
实例,并传递了两个参数;服务器 URL 和一个包含path
属性的对象,该属性指示新连接将建立的位置:
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
想了解更多关于io.Manager
API 的信息,请访问 Socket.IO 官方文档网站提供的socket.io/docs/client-api/#manager
。
之后,我们使用了socket
方法来初始化并创建一个新的 Socket,用于提供的命名空间:
const socket = manager.socket('/en')
这样做,同时处理多个命名空间会更方便,无需为每个命名空间配置相同的选项。
定义和加入 Socket.IO 房间
在命名空间内,你可以定义 socket 可以加入和离开的房间或频道。
默认情况下,一个房间会为连接的socket创建一个随机且不可预测的 ID:
io.on('connection', (socket) => {
console.log(socket.id) // Outputs socket ID
})
在连接时,当发送事件,例如:
io.on('connection', (socket) => {
socket.emit('say', 'hello')
})
在下面发生的事情与此类似:
io.on('connection', (socket) => {
socket.join(socket.id, (err) => {
if (err) {
return socket.emit('error', err)
}
io.to(socket.id).emit('say', 'hello')
})
})
使用join
方法将 socket 包含在房间内。在这种情况下,socket ID 是联合房间,且唯一连接到该房间的客户端是 socket 本身。
因为 socket ID 代表与客户端的唯一连接,并且默认情况下,具有相同 ID 的房间会被创建;服务器发送到该房间的所有数据都只会被该客户端接收。然而,如果有多个客户端或 socket ID 加入具有相同名称的房间,并且服务器发送数据,所有客户端都可能能够接收它。
准备工作
在这个菜谱中,你将看到如何加入一个房间并向连接到该特定房间的所有客户端广播消息。在你开始之前,创建一个新的package.json
文件,内容如下:
{
"dependencies": {
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
构建一个 Socket.IO 服务器,当一个新的套接字连接时,它会通知连接到 "commonRoom"
房间的所有客户端。
-
创建一个名为
rooms-server.js
的新文件 -
包含 Socket.IO NPM 模块并初始化一个新的 HTTP 服务器:
const http = require('http')
const fs = require('fs')
const path = require('path')
const io = require('socket.io')()
const app = http.createServer((req, res) => {
if (req.url === '/') {
fs.readFile(
path.resolve(__dirname, 'rooms-client.html'),
(err, data) => {
if (err) {
res.writeHead(500)
return void res.end()
}
res.writeHead(200)
res.end(data)
}
)
} else {
res.writeHead(403)
res.end()
}
})
- 指定新连接的路径:
io.path('/socket.io')
- 使用根命名空间监听事件:
const root = io.of('/')
- 定义一个方法,该方法将用于向连接到
"commonRoom"
的所有套接字客户端发出updateClientCount
事件,并将连接客户端的数量作为参数:
const notifyClients = () => {
root.clients((error, clients) => {
if (error) throw error
root.to('commonRoom').emit(
'updateClientCount',
clients.length,
)
})
}
- 在连接时,所有新连接的 Socket 客户端将加入
commonRoom
。然后,服务器将发出welcome
事件。之后,通知所有连接的套接字更新连接客户端的数量,并在客户端断开连接时执行相同的操作:
root.on('connection', socket => {
socket.join('commonRoom')
socket.emit('welcome', `Welcome client: ${socket.id}`)
socket.on('disconnect', notifyClients)
notifyClients()
})
- 监听端口
1337
上的新连接并将 Socket.IO 附加到 HTTP 服务器:
io.attach(app.listen(1337, () => {
console.log(
'HTTP Server and Socket.IO running on port 1337'
)
}))
- 保存文件。
然后,构建一个 Socket.IO 客户端,该客户端将连接到 Socket.IO 服务器,并用接收到的数据填充 HTML 内容:
-
创建一个名为
rooms-client.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO Client</title>
</head>
<body>
<h1 id="title">
Connected clients:
<span id="n"></span>
</h1>
<p id="welcome"></p>
<script src="img/socket.io.js">
</script>
<script
src="img/babel.min.js">
</script>
<script type="text/babel">
// Code here
</script>
</body>
</html>
-
在脚本标签内,按照以下步骤添加代码,从第 4 步开始
-
定义两个常量,它们将引用两个我们将根据 Socket.IO 服务器发送的数据进行更新的 HTML 元素:
const welcome = document.getElementById('welcome')
const n = document.getElementById('n')
- 定义一个 Socket.IO 客户端管理器:
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
- 使用根命名空间,这是在 Socket.IO 服务器中使用的:
const socket = manager.socket('/')
- 为
welcome
事件添加一个事件监听器,该事件期望一个包含服务器发送的欢迎消息的参数:
socket.on('welcome', msg => {
welcome.textContent = msg
})
- 为
updateClientCount
事件添加一个事件监听器,该事件期望一个包含连接客户端数量的参数:
socket.on('updateClientCount', clientsCount => {
n.textContent = clientsCount
})
-
保存文件
-
打开一个新的终端并运行:
node rooms-server.js
- 在网页浏览器中,导航到:
http://localhost:1337/
- 在不关闭之前的标签页或窗口的情况下,在网页浏览器中再次导航到:
http://localhost:1337/
- 在两个标签页或窗口中连接的客户端数量应该增加到
2
还有更多...
向多个客户端发送相同的信息或数据,称为广播。我们之前看到的方法会将消息广播给所有客户端,包括生成请求的客户端。
有其他几种广播消息的方法。例如:
socket.to('commonRoom').emit('updateClientCount', data)
它将向 "commonRoom"
中的所有客户端(除了发送者或发起请求的套接字)发出 updateClientCount
事件。
完整的列表请查看 Socket.IO 官方文档的 emit 脚本:socket.io/docs/emit-cheatsheet/
为 Socket.IO 编写中间件
Socket.IO 允许我们在服务器端定义两种类型的中间件函数:
- 命名空间中间件:注册一个函数,该函数在每次新的套接字连接时执行,并具有以下签名:
namespace.use((socket, next) => { ... })
- Socket 中间件:注册一个函数,该函数在每次接收到的数据包上执行,并具有以下签名:
socket.use((packet, next) => { ... })
它的工作方式与 ExpressJS 中间件函数类似。我们可以向 socket
或 packet
对象添加新属性。然后,我们可以调用 next
来将控制权传递给链中的下一个中间件。如果没有调用 next
,则 socket
不会连接,或者接收到的 packet
。
准备工作
在这个菜谱中,你将构建一个 Socket.IO 服务器应用程序,其中你将定义中间件函数来限制对特定命名空间的访问,以及根据某些标准限制对特定套接字的访问。在开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
Socket.IO 服务器应用程序期望用户已登录,以便他们能够连接到 /home
命名空间。使用套接字中间件,我们还将限制对 /home
命名空间的访问权限,仅限于特定用户:
-
创建一个名为
middleware-server.js
的新文件 -
包含 Socket.IO 库并初始化一个新的 HTTP 服务器:
const http = require('http')
const fs = require('fs')
const path = require('path')
const io = require('socket.io')()
const app = http.createServer((req, res) => {
if (req.url === '/') {
fs.readFile(
path.resolve(__dirname, 'middleware-cli.html'),
(err, data) => {
if (err) {
res.writeHead(500)
return void res.end()
}
res.writeHead(200)
res.end(data)
}
)
} else {
res.writeHead(403)
res.end()
}
})
- 指定新连接将进行的路径:
io.path('/socket.io')
- 定义一个用户数组,我们将将其用作内存数据库:
const users = [
{ username: 'huangjx', password: 'cfgybhji' },
{ username: 'johnstm', password: 'mkonjiuh' },
{ username: 'jackson', password: 'qscwdvb' },
]
- 定义一个方法来验证提供的用户名和密码是否存在于用户数组中:
const userMatch = (username, password) => (
users.find(user => (
user.username === username &&
user.password === password
))
)
- 定义一个命名空间中间件函数,该函数将检查用户是否已经登录。如果用户未登录,则客户端无法使用此中间件连接到特定命名空间:
const isUserLoggedIn = (socket, next) => {
const { session } = socket.request
if (session && session.isLogged) {
next()
}
}
- 定义两个命名空间,一个用于
/login
,另一个用于/home
。/home
命名空间将使用我们之前定义的中间件函数来检查用户是否已登录:
const namespace = {
home: io.of('/home').use(isUserLoggedIn),
login: io.of('/login'),
}
- 当一个新的套接字连接到
/login
命名空间时,我们首先将定义一个套接字中间件函数来检查所有传入的包,并禁止对johntm
用户名的访问。然后,我们将添加一个事件监听器来监听进入事件,该事件将期望接收一个包含用户名和密码的纯对象,如果它们存在于用户数组中,则我们设置一个会话对象,该对象将告诉用户是否已登录。否则,我们将向客户端发送一个带有错误信息的loginError
事件:
namespace.login.on('connection', socket => {
socket.use((packet, next) => {
const [evtName, data] = packet
const user = data
if (evtName === 'tryLogin'
&& user.username === 'johnstm') {
socket.emit('loginError', {
message: 'Banned user!',
})
} else {
next()
}
})
socket.on('tryLogin', userData => {
const { username, password } = userData
const request = socket.request
if (userMatch(username, password)) {
request.session = {
isLogged: true,
username,
}
socket.emit('loginSuccess')
} else {
socket.emit('loginError', {
message: 'invalid credentials',
})
}
})
})
- 监听 1337 端口上的新连接并将 Socket.IO 附加到 HTTP 服务器:
io.attach(app.listen(1337, () => {
console.log(
'HTTP Server and Socket.IO running on port 1337'
)
}))
- 保存文件
然后,构建一个 Socket.IO 客户端应用程序,该应用程序将连接到我们的 Socket.IO 服务器,并允许我们尝试登录并测试:
-
创建一个名为
middleware-cli.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO Client</title>
<script src="img/socket.io.js">
</script>
<script
src="img/babel.min.js">
</script>
</head>
<body>
<h1 id="title"></h1>
<form id="loginFrm" disabled>
<input type="text" name="username" placeholder="username"/>
<input type="password" name="password"
placeholder="password" />
<input type="submit" value="LogIn" />
<output name="logs"></output>
</form>
<script type="text/babel">
// Code here
</script>
</body>
</html>
-
在脚本标签内,按照以下步骤添加代码,从步骤 4 开始
-
定义三个常量,以便引用我们将用于获取输入或显示输出的 HTML 元素:
const title = document.getElementById('home')
const error = document.getElementsByName('logErrors')[0]
const loginForm = document.getElementById('loginForm')
- 定义一个 Socket.IO 管理器:
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
- 让我们定义一个命名空间常量,该常量将包含一个对象,其中包含 Socket.IO 命名空间
/home
和/login
:
const namespace = {
home: manager.socket('/home'),
login: manager.socket('/login'),
}
- 为
/home
命名空间添加一个连接事件监听器。它仅在/home
命名空间成功连接到服务器时才会触发:
namespace.home.on('connect', () => {
title.textContent = 'Great! you are connected to /home'
error.textContent = ''
})
- 为
/login
命名空间添加一个loginSuccess
事件监听器。它将请求/home
命名空间再次连接到服务器。如果用户已登录,则服务器将允许此连接:
namespace.login.on('loginSuccess', () => {
namespace.home.connect()
})
- 为
/login
命名空间添加一个loginError
事件监听器。它将显示服务器发送的错误消息:
namespace.login.on('loginError', (err) => {
logs.textContent = err.message
})
- 为登录表单的提交事件添加一个事件监听器。它将发出一个包含在表单中填写的用户名和密码的对象的 enter 事件:
form.addEventListener('submit', (event) => {
const body = new FormData(form)
namespace.login.emit('tryLogin', {
username: body.get('username'),
password: body.get('password'),
})
event.preventDefault()
})
- 保存文件
让我们测试它...
查看我们之前工作的实际效果:
- 首先运行 Socket.IO 服务器。打开一个新的终端并运行:
node middleware-server.js
- 在你的网页浏览器中,导航到:
http://localhost:1337
-
你将看到一个包含两个字段,
username
和password
的登录表单 -
尝试使用随机的无效凭证登录。以下错误将显示:
invalid credentials
- 接下来,尝试使用
johntm
作为username
和任何password
登录。以下错误将显示:
Banned user!
- 之后,使用任意两个有效的凭证登录。例如,使用
jingxuan
作为用户名,qscwdvb
作为密码。以下标题将显示:
Connected to /home
将 Socket.IO 集成到 ExpressJS
Socket.IO 与 ExpressJS 的工作良好。实际上,可以使用相同的端口或 HTTP 服务器运行 ExpressJS 应用程序和 Socket.IO 服务器。
准备工作
在这个菜谱中,我们将看到如何将 Socket.IO 集成到 ExpressJS 中。你将构建一个 ExpressJS 应用程序,该应用程序将提供包含 Socket.IO 客户端应用程序的 HTML 文件。在你开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行来安装依赖项:
npm install
如何实现...
创建一个 Socket.IO 客户端应用程序,该应用程序将连接到你接下来将要构建的 Socket.IO 服务器,并显示服务器发送的欢迎信息。
-
创建一个名为
io-express-view.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO Client</title>
<script src="img/socket.io.js">
</script>
<script
src="img/babel.min.js">
</script>
</head>
<body>
<h1 id="welcome"></h1>
<script type="text/babel">
const welcome = document.getElementById('welcome')
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
const root = manager.socket('/')
root.on('welcome', (msg) => {
welcome.textContent = msg
})
</script>
</body>
</html>
- 保存文件
接下来,构建一个 ExpressJS 应用程序和一个 Socket.IO 服务器。ExpressJS 应用程序将在根路径 "/"
上提供之前创建的 HTML 文件:
-
创建一个名为
io-express-server.js
的新文件 -
初始化一个新的 Socket.IO 服务器应用程序和一个 ExpressJS 应用程序:
const path = require('path')
const express = require('express')
const io = require('socket.io')()
const app = express()
- 定义将连接到 Socket.IO 服务器的 URL 路径:
io.path('/socket.io')
- 定义一个路由方法来提供包含我们的 Socket.IO 客户端应用程序的 HTML 文件:
app.get('/', (req, res) => {
res.sendFile(path.resolve(
__dirname,
'io-express-view.html',
))
})
- 定义一个命名空间
"/"
并发出一个带有欢迎信息的welcome
事件:
io.of('/').on('connection', (socket) => {
socket.emit('welcome', 'Hello from Server!')
})
- 将 Socket.IO 连接到 ExpressJS 服务器:
io.attach(app.listen(1337, () => {
console.log(
'HTTP Server and Socket.IO running on port 1337'
)
}))
-
保存文件
-
打开终端并运行:
node io-express-server.js
- 在你的浏览器中,访问:
http://localhost:1337/
它是如何工作的...
Socket.IO 的 attach
方法期望接收一个 HTTP 服务器作为参数,以便将其附加到 Socket.IO 服务器应用程序上。我们可以将 Socket.IO 附加到 ExpressJS 服务器应用程序上的原因是因为 listen
方法返回 ExpressJS 连接到其下的 HTTP 服务器。
总结来说,listen
方法返回底层 HTTP 服务器。然后,它作为参数传递给 attach
方法。这样,我们就可以与 ExpressJS 共享相同的连接。
还有更多...
到目前为止,我们已经看到我们可以在 ExpressJS 和 Socket.IO 之间共享相同的底层 HTTP 服务器。然而,这并不是全部。
我们定义 Socket.IO 路径的原因实际上在处理 ExpressJS 时非常有用。以下是一个例子:
const express = require('express')
const io = require('socket.io')()
const app = express()
io.path('/socket.io')
app.get('/socket.io', (req, res) => {
res.status(200).send('Hey there!')
})
io.of('/').on('connection', socket => {
socket.emit('someEvent', 'Data from Server!')
})
io.attach(app.listen(1337))
如你所见,我们正在使用相同的 URL 路径为 Socket.IO 和 ExpressJS。我们在 /socket.io
路径上接受 Socket.IO 服务器的新的连接,但我们也使用 GET 路由方法发送 /socket.io
的内容。
即使这个先前的例子实际上不会破坏你的应用程序,也请确保永远不要使用相同的 URL 路径同时从 ExpressJS 提供内容并接受 Socket.IO 的新连接。例如,将之前的代码更改为以下内容:
io.path('/socket.io')
app.get('/socket.io/:msg', (req, res) => {
res.status(200).send(req.params.msg)
})
当你访问 http://localhost:1337/socket.io/message
时,你可能期望你的浏览器显示 message
,但这不会发生,你将看到以下内容:
{"code":0,"message":"Transport unknown"}
这是因为 Socket.IO 会首先解释传入的数据,它不会理解你刚刚发送的数据。此外,你的路由处理程序永远不会被执行。
此外,Socket.IO 服务器默认情况下也会在其定义的 URL 路径下提供自己的 Socket.IO 客户端。例如,尝试访问 localhost:1337/socket.io/socket.io.js
,你将能够看到 Socket.IO 客户端的压缩 JavaScript 代码。
如果你希望提供自己的 Socket.IO 客户端版本,或者它包含在你的应用程序捆绑包中,你可以使用 serveClient
方法在你的 Socket.IO 服务器应用程序中禁用默认行为:
io.serveClient(false)
参见
- 第二章,使用 ExpressJS 构建 Web 服务器,部分 使用 Express.js 内置中间件函数提供静态资源
在 Socket.IO 中使用 ExpressJS 中间件
Socket.IO 命名空间中间件的工作方式与 ExpressJS 中间件非常相似。事实上,Socket 对象也包含一个 request
和一个 response
对象,我们可以像在 ExpressJS 中间件函数中一样,以相同的方式存储其他属性:
namespace.use((socket, next) => {
const req = socket.request
const res = socket.request.res
next()
})
因为 ExpressJS 中间件函数具有以下签名:
const expressMiddleware = (request, response, next) => {
next()
}
我们可以在 Socket.IO 命名空间中间件中安全地执行相同的函数,传递必要的参数:
root.use((socket, next) => {
const req = socket.request
const res = socket.request.res
expressMiddleware(req, res, next)
})
然而,这并不意味着所有 ExpressJS 中间件函数都会直接工作。例如,如果一个 ExpressJS 中间件函数只使用 ExpressJS 内部可用的方法,它可能会失败或产生意外的行为。
准备工作
在这个菜谱中,我们将看到如何将 ExpressJS 的 express-session
中间件集成到 Socket.IO 和 ExpressJS 之间共享会话对象。在你开始之前,创建一个新的 package.json
文件,内容如下:
{
"dependencies": {
"express": "4.16.3",
"express-session": "1.15.6",
"socket.io": "2.1.0"
}
}
然后,通过打开终端并运行以下命令安装依赖项:
npm install
如何做到这一点...
构建一个 Socket.IO 客户端应用程序,该应用程序将连接到您将要构建的 Socket.IO 服务器。包括一个表单,用户可以在其中输入用户名和密码以尝试登录。Socket.IO 客户端只能在用户登录后才能连接到/home
命名空间:
-
创建一个名为
io-express-cli.html
的新文件 -
添加以下 HTML 内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket.IO Client</title>
<script src="img/socket.io.js">
</script>
<script
src="img/babel.min.js">
</script>
</head>
<body>
<h1 id="title"></h1>
<form id="loginForm">
<input type="text" name="username" placeholder="username"/>
<input type="password" name="password"
placeholder="password" />
<input type="submit" value="LogIn" />
<output name="logErrors"></output>
</form>
<script type="text/babel">
// Code here
</script>
</body>
</html>
-
在脚本标签内添加下一步骤中的代码,从第 4 步开始
-
定义常量,使其引用我们将使用的 HTML 元素:
const title = document.getElementById('title')
const error = document.getElementsByName('logErrors')[0]
const loginForm = document.getElementById('loginForm')
- 定义一个 Socket.IO 管理器:
const manager = new io.Manager(
'http://localhost:1337',
{ path: '/socket.io' },
)
- 定义两个命名空间,一个用于
/login
,另一个用于/home
:
const namespace = {
home: manager.socket('/home'),
login: manager.socket('/login'),
}
- 为由服务器端触发的
welcome
事件添加事件监听器,该事件在允许连接到/home
命名空间时触发:
namespace.home.on('welcome', (msg) => {
title.textContent = msg
error.textContent = ''
})
- 为
loginSuccess
事件添加事件监听器,当触发时,将要求/home
命名空间尝试重新连接到 Socket.IO 服务器:
namespace.login.on('loginSuccess', () => {
namespace.home.connect()
})
- 为
loginError
事件添加事件监听器,当提供无效凭据时将显示错误:
namespace.login.on('loginError', err => {
error.textContent = err.message
})
- 为
submit
事件添加事件监听器,当提交表单时将触发。它将发出一个包含提供的username
和password
数据的enter
事件:
loginForm.addEventListener('submit', event => {
const body = new FormData(loginForm)
namespace.login.emit('enter', {
username: body.get('username'),
password: body.get('password'),
})
event.preventDefault()
})
- 保存文件。
然后,构建一个 ExpressJS 应用程序,该应用程序将在根路径"/"
上提供 Socket.IO 客户端和一个包含用户登录逻辑的 Socket.IO 服务器:
-
创建一个名为
io-express-srv.js
的新文件 -
初始化一个新的 ExpressJS 应用程序和一个 Socket.IO 服务器应用程序。还包括
express-session
NPM 模块:
const path = require('path')
const express = require('express')
const io = require('socket.io')()
const expressSession = require('express-session')
const app = express()
- 定义新的连接到 Socket.IO 服务器的新连接路径:
io.path('/socket.io')
- 定义具有给定选项的 ExpressJS 会话中间件函数:
const session = expressSession({
secret: 'MERN Cookbook Secret',
resave: true,
saveUninitialized: true,
})
- 定义一个 Socket.IO 命名空间中间件,它将使用之前创建的会话中间件来生成会话对象:
const ioSession = (socket, next) => {
const req = socket.request
const res = socket.request.res
session(req, res, (err) => {
next(err)
req.session.save()
})
}
- 定义两个命名空间,一个用于
/home
,另一个用于/login
:
const home = io.of('/home')
const login = io.of('/login')
- 定义一个内存数据库或包含
username
和password
属性的对象数组。这些定义了哪些用户被允许登录:
const users = [
{ username: 'huangjx', password: 'cfgybhji' },
{ username: 'johnstm', password: 'mkonjiuh' },
{ username: 'jackson', password: 'qscwdvb' },
]
- 在 ExpressJS 中包含会话中间件:
app.use(session)
- 为
/home
路径添加路由方法,该方法将提供我们之前创建的包含 Socket.IO 客户端的 HTML 文档:
app.get('/home', (req, res) => {
res.sendFile(path.resolve(
__dirname,
'io-express-cli.html',
))
})
- 在
/home
Socket.IO 命名空间中使用会话中间件。然后,检查每个新的 socket 用户是否已登录。如果没有,禁止用户连接到此命名空间:
home.use(ioSession)
home.use((socket, next) => {
const { session } = socket.request
if (session.isLogged) {
next()
}
})
- 一旦连接到
/home
命名空间,这意味着用户可以登录,将发出一个包含欢迎信息的welcome
事件,该信息将显示给用户:
home.on('connection', (socket) => {
const { username } = socket.request.session
socket.emit(
'welcome',
`Welcome ${username}!, you are logged in!`,
)
})
- 在
/login
Socket.IO 命名空间中使用会话中间件。然后,当客户端发出包含提供的用户名和密码的enter
事件时,它验证users
数组中是否存在该配置文件。如果用户存在,将isLogged
属性设置为true
并将username
属性设置为已登录的当前用户:
login.use(ioSession)
login.on('connection', (socket) => {
socket.on('enter', (data) => {
const { username, password } = data
const { session } = socket.request
const found = users.find((user) => (
user.username === username &&
user.password === password
))
if (found) {
session.isLogged = true
session.username = username
socket.emit('loginSuccess')
} else {
socket.emit('loginError', {
message: 'Invalid Credentials',
})
}
})
})
- 在端口
1337
上监听新连接并将 Socket.IO 服务器附加到它:
io.attach(app.listen(1337, () => {
console.log(
'HTTP Server and Socket.IO running on port 1337'
)
}))
-
保存文件
-
打开一个新的终端并运行:
node io-express-srv.js
- 在您的浏览器中,访问:
http://localhost:1337/home
- 使用有效的凭据登录。例如:
* Username: johntm
* Password: mkonjiuh
- 如果您成功登录,在刷新页面后,您的 Socket.IO 客户端应用仍然可以连接到
/home
,并且每次都会显示欢迎信息
它是如何工作的...
当在 ExpressJS 中使用 session 中间件时,在修改 session 对象后,save
方法会在响应结束时自动调用。然而,在使用 Socket.IO 命名空间中的 session 中间件时并非如此,这就是为什么我们手动调用save
方法将 session 保存回存储器。在我们的例子中,存储器是内存,会话在此处保持,直到服务器停止。
基于特定条件禁止访问某些命名空间是可能的,这要归功于 Socket.IO 命名空间中间件。如果控制权没有传递给next
处理器,那么连接就不会建立。这就是为什么在登录成功后,我们要求/home
命名空间再次尝试连接。
参见
- 第二章,使用 ExpressJS 构建 Web 服务器,部分编写中间件函数
第五章:使用 Redux 管理状态
在本章中,我们将介绍以下食谱:
-
定义动作和动作创建器
-
定义 Reducer 函数
-
创建 Redux 存储
-
将动作创建器绑定到分发方法
-
分割和组合 Reducers
-
编写 Redux 存储增强器
-
使用 Redux 进行时间旅行
-
理解 Redux 中间件
-
处理异步数据流
技术要求
您需要有一个 IDE、Visual Studio Code、Node.js 和 MongoDB。您还需要安装 Git,以便使用本书的 Git 仓库。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter05
观看以下视频,了解代码的实际应用:
简介
Redux 是 JavaScript 应用程序的可预测状态容器。它允许开发者轻松地管理他们应用程序的状态。使用 Redux,状态是不可变的。因此,您可以来回切换到应用程序的下一个或上一个状态。Redux 绑定了三个核心原则:
-
单一事实来源:您应用程序的所有状态都必须存储在单个对象树中,位于单个存储中
-
状态是只读的:您不得修改状态树。只有通过分发一个动作,状态树才能改变
-
更改是通过纯函数进行的:这些被称为 Reducers,它们是接受前一个状态和一个动作并计算新状态的函数。Reducers 必须永远不修改前一个状态,而应始终返回一个新的状态
Reducers 的工作方式与 Array.prototype.reduce
函数非常相似。reduce
方法对数组中的每个项目执行一个函数,针对累加器进行操作,以将其减少到单个值。例如:
const a = 5
const b = 10
const c = [a, b].reduce((accumulator, value) => {
return accumulator + value
}, 0)
在变量 c
中,当对 a
和 b
进行累加器 accumulator
的减少操作时得到的结果是 15
,初始值是 0
。这里的 reducer 函数是:
(accumulator, value) => {
return accumulator + value
}
Redux Reducers 的编写方式类似,并且是 Redux 中最重要的概念。例如:
const reducer = (prevState, action) => newState
在本章中,我们将专注于学习如何使用 Redux 管理简单和复杂的状态树。您还将学习如何处理异步数据流。
定义动作和动作创建器
Reducers 接受一个描述将要执行的动作的 action
对象,并根据这个 action
对象决定如何根据状态转换状态。
动作只是普通的对象,并且它们只有一个必需的属性,即动作类型。例如:
const action = {
type: 'INCREMENT_COUNTER',
}
我们还可以提供额外的属性。例如:
const action = {
type: 'INCREMENT_COUNTER',
incrementBy: 2,
}
Actions creators 是返回动作的函数,例如:
const increment = (incrementBy) => ({
type: 'INCREMENT_COUNTER',
incrementBy,
})
准备工作
在这个食谱中,您将看到这些简单的 Redux 概念如何与 Array.prototype.reduce
结合使用,以决定数据应该如何累积或减少。
我们目前不需要 Redux 库来完成此目的。
如何做...
构建一个小的 JavaScript 应用程序,该应用程序将根据提供的动作增加或减少计数器。
-
创建一个名为
counter.js
的新文件 -
将动作类型定义为常量:
const INCREMENT_COUNTER = 'INCREMENT_COUNTER'
const DECREMENT_COUNTER = 'DECREMENT_COUNTER'
- 定义两个动作创建器,用于生成两种类型的动作来
increment
和decrement
计数器:
const increment = (by) => ({
type: INCREMENT_COUNTER,
by,
})
const decrement = (by) => ({
type: DECREMENT_COUNTER,
by,
})
- 将初始累加器初始化为
0
,然后通过传递几个动作来减少它。reducer 函数将根据动作类型决定执行哪种类型的动作:
const reduced = [
increment(10),
decrement(5),
increment(3),
].reduce((accumulator, action) => {
switch (action.type) {
case INCREMENT_COUNTER:
return accumulator + action.by
case DECREMENT_COUNTER:
return accumulator - action.by
default:
return accumulator
}
}, 0)
- 记录结果值:
console.log(reduced)
-
保存文件
-
打开终端并运行:
node counter.js
- 输出:
8
它是如何工作的...
-
遇到的第一个动作类型是
increment(10)
,这将使累加器增加10
。因为累加器的初始值是0
,下一个当前值将是10
-
第二个动作类型告诉 reducer 函数将累加器减少
5
。因此,累加器的值将是5
。 -
最后一个动作类型告诉 reducer 函数将累加器增加
3
。因此,累加器的值将是8
。
定义 reducer 函数
Redux reducer 是纯函数。这意味着,它们没有副作用。给定相同的参数,reducer 必须始终生成相同形状的状态。例如,以下 reducer 函数:
const reducer = (prevState, action) => {
if (action.type === 'INC') {
return { counter: prevState.counter + 1 }
}
return prevState
}
如果我们提供相同的参数执行此函数,结果总是相同的:
const a = reducer(
{ counter: 0 },
{ type: 'INC' },
) // Value is { counter: 1 }
const b = reducer(
{ counter: 0 },
{ type: 'INC' },
) // Value is { counter: 1 }
然而,请注意,尽管返回的值具有相同的形状,但这些是两个不同的对象。例如,比较上面的:
console.log(a === b)
返回false
。
不纯净的 reducer 函数会阻止你的应用程序状态可预测,并使重现相同状态变得困难。例如:
const impureReducer = (prevState = {}, action) => {
if (action.type === 'SET_TIME') {
return { time: new Date().toString() }
}
return prevState
}
如果我们执行此函数:
const a = impureReducer({}, { type: 'SET_TIME' })
setTimeout(() => {
const b = impureReducer({}, { type: 'SET_TIME' })
console.log(
a, // Output may be: {time: "22:10:15 GMT+0000"}
b, // Output may be: {time: "22:10:17 GMT+0000"}
)
}, 2000)
如你所见,在 2 秒后执行函数第二次后,我们得到了不同的结果。为了使其纯净,你可以考虑重新编写之前的不纯净 reducer,如下所示:
const timeReducer = (prevState = {}, action) => {
if (action.type === 'SET_TIME') {
return { time: action.time }
}
return prevState
}
然后,你可以安全地在你的动作中传递一个时间属性来设置时间:
const currentTime = new Date().toTimeString()
const a = timeReducer(
{ time: null },
{ type: 'SET_TIME', time: currentTime },
)
const b = timeReducer(
{ time: null },
{ type: 'SET_TIME', time: currentTime },
)
console.log(a.time === b.time) // true
这种方法使你的状态可预测,并且状态易于重现。例如,你可以重新创建一个场景,即如果你在早上或下午的任何时间传递time
属性,你的应用程序将如何行动。
准备中
现在你已经理解了 reducer 的工作原理,在这个菜谱中,你将构建一个应用程序,该应用程序将根据状态变化而采取不同的行动。
为了这个目的,你目前不需要安装或使用 Redux 库。
如何做...
构建一个应用程序,它会提醒你根据当地时间你应该吃什么类型的餐点。将我们应用程序的所有状态管理在一个单独的对象树中。还提供一种模拟如果时间是00:00a.m
或12:00p.m
时应用程序将显示什么的方式:
-
创建一个名为
meal-time.html
的新文件。 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Breakfast Time</title>
<script
src="img/babel.min.js">
</script>
</head>
<body>
<h1>What you need to do:</h1>
<p>
<b>Current time:</b>
<span id="display-time"></span>
</p>
<p id="display-meal"></p>
<button id="emulate-night">
Let's pretend is 00:00:00
</button>
<button id="emulate-noon">
Let's pretend is 12:00:00
</button>
<script type="text/babel">
// Add JavaScript code here
</script>
</body>
</html>
-
在脚本标签内添加以下步骤中定义的代码,从第 4 步开始。
-
定义一个包含所有状态树和稍后下一个状态的变量
state
:
let state = {
kindOfMeal: null,
time: null,
}
- 创建一个引用 HTML 元素的引用,我们将使用它来显示数据或添加事件监听器:
const meal = document.getElementById('display-meal')
const time = document.getElementById('display-time')
const btnNight = document.getElementById('emulate-night')
const btnNoon = document.getElementById('emulate-noon')
- 定义两个动作类型:
const SET_MEAL = 'SET_MEAL'
const SET_TIME = 'SET_TIME'
- 定义一个设置用户应享用的餐类的动作创建器:
const setMeal = (kindOfMeal) => ({
type: SET_MEAL,
kindOfMeal,
})
- 定义一个设置时间的动作创建器:
const setTime = (time) => ({
type: SET_TIME,
time,
})
- 定义一个当动作被分发时计算新状态的还原函数:
const reducer = (prevState = state, action) => {
switch (action.type) {
case SET_MEAL:
return Object.assign({}, prevState, {
kindOfMeal: action.kindOfMeal,
})
case SET_TIME:
return Object.assign({}, prevState, {
time: action.time,
})
default:
return prevState
}
}
- 添加一个当状态改变时我们将调用的函数,以便我们可以更新我们的视图:
const onStateChange = (nextState) => {
const comparison = [
{ time: '23:00:00', info: 'Too late for dinner!' },
{ time: '18:00:00', info: 'Dinner time!' },
{ time: '16:00:00', info: 'Snacks time!' },
{ time: '12:00:00', info: 'Lunch time!' },
{ time: '10:00:00', info: 'Branch time!' },
{ time: '05:00:00', info: 'Breakfast time!' },
{ time: '00:00:00', info: 'Too early for breakfast!' },
]
time.textContent = nextState.time
meal.textContent = comparison.find((condition) => (
nextState.time >= condition.time
)).info
}
- 定义一个将当前状态和动作传递给还原器以生成新状态树的分发函数。然后,它将调用
onChangeState
函数来通知应用程序状态已更改:
const dispatch = (action) => {
state = reducer(state, action)
onStateChange(state)
}
- 为按钮添加一个事件监听器,模拟时间为
00:00a.m
:
btnNight.addEventListener('click', () => {
const time = new Date('1/1/1 00:00:00')
dispatch(setTime(time.toTimeString()))
})
- 为按钮添加一个事件监听器,模拟时间为
12:00p.m
:
btnNoon.addEventListener('click', () => {
const time = new Date('1/1/1 12:00:00')
dispatch(setTime(time.toTimeString()))
})
- 一旦脚本开始运行,分发一个包含当前时间的动作以更新视图:
dispatch(setTime(new Date().toTimeString()))
- 保存文件。
让我们测试它...
要查看您之前的工作效果:
-
在您的网络浏览器中打开
meal-time.html
文件。您可以通过双击文件或右键单击文件并选择“打开方式”来完成此操作。 -
您应该能够看到您当前的本地区时以及一条消息,说明您应该享用什么类型的餐。例如,如果您的本地时间是
20:42:35 GMT+0800 (CST)
,您应该看到Dinner time!
-
点击按钮
"Let's pretend is 00:00:00"
来查看如果时间是00:00a.m
,您的应用程序会显示什么。 -
以相同的方式,点击按钮
"Let's pretend is 12:00:00"
来查看如果时间是12:00p.m
,您的应用程序会显示什么。
它是如何工作的...
我们可以如下总结我们的应用程序以了解其工作原理:
-
定义了
SET_MEAL
和SET_TIME
动作类型。 -
定义了两个动作创建器:
-
setMeal
函数生成一个具有SET_MEAL
操作类型和kindOfMeal
属性的kindOfMeal
提供的参数的动作 -
setTime
函数生成一个具有SET_TIME
操作类型和time
属性的time
提供的参数的动作
-
-
定义了一个还原函数:
-
对于
SET_MEAL
动作类型,计算一个新的状态,包含新的kindOfMeal
属性 -
对于
SET_TIME
动作类型,计算一个新的状态,包含新的time
属性
-
-
我们定义了一个当状态树改变时将被调用的函数。在函数内部,我们根据新的状态更新了视图。
-
定义了一个
dispatch
函数,它调用还原函数,提供前一个状态和一个动作对象以生成新状态。
创建 Redux 存储
在之前的示例中,我们看到了如何定义还原器和动作。我们还看到了如何创建一个分发函数来分发动作以更新状态。存储是一个对象,它提供了一个小的 API 来将这些功能组合在一起。
Redux 模块公开了 createStore
方法,我们可以使用它来创建存储。它具有以下签名:
createStore(reducer, preloadedState, enhancer)
最后两个参数是可选的。例如,创建一个包含单个还原器的存储库可能看起来像这样:
const TYPE = {
INC_COUNTER: 'INC_COUNTER',
DEC_COUNTER: 'DEC_COUNTER',
}
const initialState = {
counter: 0,
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case TYPE.INC_COUNTER:
return { counter: state.counter + 1 }
case TYPE.DEC_COUNTER:
return { counter: state.counter - 1 }
default:
return state
}
}
const store = createStore(reducer)
调用 createStore
将公开四个方法:
-
store.dispatch(action)
: 其中 action 是一个包含至少一个名为type
的属性的对象,该属性指定了动作类型 -
store.getState()
: 返回整个状态树 -
store.subscribe(listener)
: 其中 listener 是一个回调函数,每当状态树发生变化时都会被触发。可以订阅多个监听器 -
store.replaceReducer(reducer)
: 用新的还原器函数替换当前的还原器函数
准备工作
在这个菜谱中,你将重新构建你在上一个菜谱中构建的应用程序。然而,这次你将使用 Redux。在你开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"redux": "4.0.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
首先,构建一个小型的 ExpressJS 服务器应用程序,其唯一目的是提供 HTML 文件和 Redux 模块
-
创建一个名为
meal-time-server.js
的新文件 -
包含 ExpressJS 和
path
模块,并初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const path = require('path')
const app = express()
- 在
/lib
路径上提供 Redux 库。确保路径指向node_modules
文件夹:
app.use('/lib', express.static(
path.join(__dirname, 'node_modules', 'redux', 'dist')
))
- 在根路径
/
上提供客户端应用程序
app.get('/', (req, res) => {
res.sendFile(path.join(
__dirname,
'meal-time-client.html',
))
})
- 在端口
1337
上监听新的连接:
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
- 保存文件
现在,按照以下步骤使用 Redux 构建客户端应用程序:
-
创建一个名为
meal-time-client.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Meal Time with Redux</title>
<script
src="img/babel.min.js">
</script>
<script src="img/redux.js"></script>
</head>
<body>
<h1>What you need to do:</h1>
<p>
<b>Current time:</b>
<span id="display-time"></span>
</p>
<p id="display-meal"></p>
<button id="emulate-night">
Let's pretend is 00:00:00
</button>
<button id="emulate-noon">
Let's pretend is 12:00:00
</button>
<script type="text/babel">
// Add JavaScript code here
</script>
</body>
</html>
-
在脚本标签内,添加从第 4 步开始的代码
-
从 Redux 库中提取
createStore
方法
const { createStore } = Redux
- 定义你应用程序的初始状态
const initialState = {
kindOfMeal: null,
time: null,
}
- 保持对将用于显示状态或与应用程序交互的 HTML DOM 元素的引用
const meal = document.getElementById('display-meal')
const time = document.getElementById('display-time')
const btnNight = document.getElementById('emulate-night')
const btnNoon = document.getElementById('emulate-noon')
- 定义两个动作类型:
const SET_MEAL = 'SET_MEAL'
const SET_TIME = 'SET_TIME'
- 定义两个动作创建者:
const setMeal = (kindOfMeal) => ({
type: SET_MEAL,
kindOfMeal,
})
const setTime = (time) => ({
type: SET_TIME,
time,
})
- 定义当
SET_TIME
和/或SET_TIME
动作类型被分派时将转换状态的还原器:
const reducer = (prevState = initialState, action) => {
switch (action.type) {
case SET_MEAL:
return {...prevState,
kindOfMeal: action.kindOfMeal,
}
case SET_TIME:
return {...prevState,
time: action.time,
}
default:
return prevState
}
}
- 创建一个新的 Redux 存储
const store = createStore(reducer)
- 将一个回调函数订阅到存储的变化。每当存储发生变化时,这个回调函数都会被触发,并根据存储中的变化更新视图:
store.subscribe(() => {
const nextState = store.getState()
const comparison = [
{ time: '23:00:00', info: 'Too late for dinner!' },
{ time: '18:00:00', info: 'Dinner time!' },
{ time: '16:00:00', info: 'Snacks time!' },
{ time: '12:00:00', info: 'Lunch time!' },
{ time: '10:00:00', info: 'Brunch time!' },
{ time: '05:00:00', info: 'Breakfast time!' },
{ time: '00:00:00', info: 'Too early for breakfast!' },
]
time.textContent = nextState.time
meal.textContent = comparison.find((condition) => (
nextState.time >= condition.time
)).info
})
- 为我们的按钮添加一个
click
事件的监听器,将SET_TIME
动作类型分发给设置时间为00:00:00
btnNight.addEventListener('click', () => {
const time = new Date('1/1/1 00:00:00')
store.dispatch(setTime(time.toTimeString()))
})
- 为我们的按钮添加一个
click
事件的监听器,将SET_TIME
动作类型分发给设置时间为12:00:00
btnNoon.addEventListener('click', () => {
const time = new Date('1/1/1 12:00:00')
store.dispatch(setTime(time.toTimeString()))
})
- 当应用程序首次启动时,分派一个动作来设置时间为当前本地区时:
store.dispatch(setTime(new Date().toTimeString()))
- 保存文件
让我们来测试一下...
要查看之前的工作效果:
- 打开一个新的终端并运行:
node meal-time-server.js
- 在你的网络浏览器中,访问:
http://localhost:1337/
-
你应该能够看到你当前的本地区时以及一条消息,说明你应该吃什么样的餐。例如,如果你的本地时间是
20:42:35 GMT+0800 (CST)
,你应该看到Dinner time!
-
点击按钮
"Let's pretend is 00:00:00"
来查看如果时间是00:00a.m.
,你的应用程序会显示什么 -
同样,点击
"Let's pretend is 12:00:00"
按钮,看看如果时间是12:00p.m.
,您的应用程序会显示什么。
还有更多
你可以使用 ES6 扩展运算符来代替 Object.assign
来合并你的前一个状态和下一个状态,例如,我们重新编写了之前食谱中的 reducer 函数:
const reducer = (prevState = initialState, action) => {
switch (action.type) {
case SET_MEAL:
return Object.assign({}, prevState, {
kindOfMeal: action.kindOfMeal,
})
case SET_TIME:
return Object.assign({}, prevState, {
time: action.time,
})
default:
return prevState
}
}
我们将其重写为以下内容:
const reducer = (prevState = initialState, action) => {
switch (action.type) {
case SET_MEAL:
return {...prevState,
kindOfMeal: action.kindOfMeal,
}
case SET_TIME:
return {...prevState,
time: action.time,
}
default:
return prevState
}
}
这可以使代码更易于阅读。
将动作创建者绑定到 dispatch 方法
动作创建者只是生成动作对象的函数,这些对象可以稍后用于使用 dispatch
方法分发动作。以下是一个例子:
const TYPES = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
}
const actions = {
addItem: (name, description) => ({
type: TYPES.ADD_ITEM,
payload: { name, description },
}),
removeItem: (id) => ({
type: TYPES.REMOVE_ITEM,
payload: { id },
})
}
module.exports = actions
然后,在您的应用程序的某个地方,您可以使用 dispatch
方法分发这些动作:
dispatch(actions.addItem('Little Box', 'Cats'))
dispatch(actions.removeItem(123))
然而,如您所见,每次调用 dispatch
方法似乎是一个重复且不必要的步骤。您可以简单地将动作创建者包装在 dispatch
函数本身周围,如下所示:
const actions = {
addItem: (name, description) => dispatch({
type: TYPES.ADD_ITEM,
payload: { name, description },
}),
removeItem: (id) => dispatch({
type: TYPES.REMOVE_ITEM,
payload: { id },
})
}
module.exports = actions
尽管这似乎是一个不错的解决方案,但存在一个问题。这意味着,您需要首先创建存储,然后定义动作创建者并将它们绑定到 dispatch
方法。此外,由于它们依赖于 dispatch
方法存在,因此在单独的文件中维护动作创建者将变得困难。Redux 模块提供了一个解决方案,这是一个名为 bindActionCreators
的辅助方法,它接受两个参数。第一个参数是一个对象,其键代表动作创建者的名称,值代表返回动作的函数。第二个参数预期是 dispatch
函数:
bindActionCreators(actionCreators, dispatchMethod)
这个辅助方法将所有动作创建者映射到 dispatch 方法。例如,我们可以将之前的例子重写为以下内容:
const store = createStore(reducer)
const originalActions = require('./actions')
const actions = bindActionCreators(
originalActions,
store.dispatch,
)
然后,在您的应用程序的某个地方,您可以调用这些方法,而无需将它们包装在 dispatch
方法周围:
actions.addItem('Little Box', 'Cats')
actions.removeItem(123)
如您所见,我们的绑定动作创建者现在看起来更像常规函数。实际上,通过解构 actions
对象,您可以使用您需要的任何方法。例如:
const {
addItem,
removeItem,
} = bindActionCreators(
originalActions,
store.dispatch,
)
然后,您可以像这样调用它们:
addItem('Little Box', 'Cats')
removeItem(123)
准备工作
在这个食谱中,您将构建一个简单的待办事项应用程序,并且您将使用您刚刚学到的绑定动作创建者的概念。首先,创建一个包含以下内容的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"redux": "4.0.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
为了本食谱的目的,定义一个动作创建者,并使用 bindActionCreators
将其绑定到 dispatch
方法。
首先,构建一个小型的 ExpressJS 应用程序,该应用程序将提供包含我们之后将要构建的待办事项客户端应用程序的 HTML 文件:
-
创建一个名为
bind-server.js
的新文件 -
添加以下代码:
const express = require('express')
const path = require('path')
const app = express()
app.use('/lib', express.static(
path.join(__dirname, 'node_modules', 'redux', 'dist')
))
app.get('/', (req, res) => {
res.sendFile(path.join(
__dirname,
'bind-index.html',
))
})
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
- 保存文件
接下来,在 HTML 文件中构建待办事项应用程序:
-
创建一个名为
bind-index.html
的新文件。 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Binding action creators</title>
<script
src="img/babel.min.js">
</script>
<script src="img/redux.js"></script>
</head>
<body>
<h1>List:</h1>
<form id="item-form">
<input id="item-input" name="item" />
</form>
<ul id="list"></ul>
<script type="text/babel">
// Add code here
</script>
</body>
</html>
-
在脚本标签内,按照以下步骤添加代码,从第 4 步开始。
-
保留将用于应用程序的 HTML DOM 元素的引用:
const form = document.querySelector('#item-form')
const input = document.querySelector('#item-input')
const list = document.querySelector('#list')
- 定义应用程序的初始状态:
const initialState = {
items: [],
}
- 定义一个动作类型:
const TYPE = {
ADD_ITEM: 'ADD_ITEM',
}
- 定义一个动作创建器:
const actions = {
addItem: (text) => ({
type: TYPE.ADD_ITEM,
text,
})
}
- 定义一个还原函数,每当派发
ADD_ITEM
动作类型时,都会向列表中添加一个新项目。状态将只保留 5 个项目:
const reducer = (state = initialState, action) => {
switch (action.type) {
case TYPE.ADD_ITEM: return {
items: [...state.items, action.text].splice(-5)
}
default: return state
}
}
- 创建一个存储库并将
dispatch
函数绑定到动作创建器:
const { createStore, bindActionCreators } = Redux
const store = createStore(reducer)
const { addItem } = bindActionCreators(
actions,
store.dispatch,
)
- 订阅存储库,每当状态改变时,向列表中添加一个新项目。如果项目已经定义,我们将重用它而不是创建一个新的:
store.subscribe(() => {
const { items } = store.getState()
items.forEach((itemText, index) => {
const li = (
list.children.item(index) ||
document.createElement('li')
)
li.textContent = itemText
list.insertBefore(li, list.children.item(0))
})
})
- 将事件监听器添加到表单的
submit
事件上。这样,我们就可以获取输入值并派发一个动作:
form.addEventListener('submit', (event) => {
event.preventDefault()
addItem(input.value)
})
- 保存文件。
让我们测试一下...
为了看到之前的工作效果:
- 打开一个新的终端并运行:
node bind-server.js
- 在你的浏览器中,访问:
http://localhost:1337/
-
在输入框中输入一些内容并按 Enter。列表中应该会出现一个新项目。
-
尝试向列表中添加超过五个项目。最后显示的项目将被移除,并且只保留五个项目在视图中。
拆分和组合还原器
随着应用程序的增长,你可能不希望在一个简单的还原函数中编写所有关于如何转换应用程序状态的逻辑。你可能想要编写更小的还原器,这些还原器专门管理状态的不同部分。
以以下还原函数为例:
const initialState = {
todoList: [],
chatMsg: [],
}
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'ADD_TODO': return {
...state,
todoList: [
...state.todoList,
{
title: action.title,
completed: action.completed,
},
],
}
case 'ADD_CHAT_MSG': return {
...state,
chatMsg: [
...state.chatMsg,
{
from: action.id,
message: action.message,
},
],
}
default:
return state
}
}
你有两个属性管理应用程序两个不同部分的状态。一个管理 Todo 列表的状态,另一个管理聊天消息。你可以将这个还原器拆分为两个还原函数,其中每个管理状态的一部分,例如:
const initialState = {
todoList: [],
chatMsg: [],
}
const todoListReducer = (state = initialState.todoList, action) => {
switch (action.type) {
case 'ADD_TODO': return state.concat([
{
title: action.title,
completed: action.completed,
},
])
default: return state
}
}
const chatMsgReducer = (state = initialState.chatMsg, action) => {
switch (action.type) {
case 'ADD_CHAT_MSG': return state.concat([
{
from: action.id,
message: action.message,
},
])
default: return state
}
}
然而,因为 createStore
方法只接受一个还原器作为第一个参数,所以你需要将它们组合成一个单一的还原器:
const reducer = (state = initialState, action) => {
return {
todoList: todoListReducer(state.todoList, action),
chatMsg: chatMsgReducer(state.chatMsg, action),
}
}
以这种方式,我们能够将我们的还原器拆分为更小的还原器,这些还原器专门管理状态的一小部分,然后稍后将其组合成一个单一的还原器函数。
Redux 提供了一个名为 combineReducers
的辅助方法,它允许你以与我们刚才做的方式类似的方式组合还原器,但不需要重复很多代码;例如,我们可以像这样重写之前组合还原器的方式:
const reducer = combineReducers({
todoList: todoListReducer,
chatMsg: chatMsgReducer,
})
combineReducers
方法是一个 高阶还原器 函数。它接受一个对象映射,指定键到由特定 reducer
函数管理的特定状态的一部分,并返回一个新的还原器函数。例如,如果你运行以下代码:
console.log(JSON.stringify(
reducer(initialState, { type: null }),
null, 2,
))
你会看到生成的状态形状如下所示:
{
"todoList": [],
"chatMsg": [],
}
我们也可以尝试如果我们的组合还原器正在工作并且只管理分配给它们的州的一部分。例如:
console.log(JSON.stringify(
reducer(
initialState,
{
type: 'ADD_TODO',
title: 'This is an example',
completed: false,
},
),
null, 2,
))
输出应该显示生成的状态如下所示:
{
"todoList": [
{
"title": "This is an example",
"completed": false,
},
],
"chatMsg": [],
}
这表明每个还原器只管理分配给它们的州的一部分。
准备工作
在这个菜谱中,你将重新创建与之前菜谱中相同的 To-do 应用程序。然而,你将添加其他功能,如删除和切换 To-do 项。你将定义其他由单独的 reducer 函数管理的应用程序状态。首先,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"redux": "4.0.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
首先,构建一个小型的 ExpressJS 服务器应用程序,该应用程序将提供客户端应用程序和安装在node_modules
中的 Redux 库:
-
创建一个名为
todo-time.js
的新文件: -
添加以下代码:
const express = require('express')
const path = require('path')
const app = express()
app.use('/lib', express.static(
path.join(__dirname, 'node_modules', 'redux', 'dist')
))
app.get('/', (req, res) => {
res.sendFile(path.join(
__dirname,
'todo-time.html',
))
})
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
- 保存文件
接下来,构建 To-do 客户端应用程序。还包括一个单独的 reducer 来管理当前本地时间和随机幸运数字生成器的状态:
-
创建一个名为
todo-time.html
的新文件: -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Lucky Todo</title>
<script
src="img/babel.min.js">
</script>
<script src="img/redux.js"></script>
</head>
<body>
<h1>List:</h1>
<form id="item-form">
<input id="item-input" name="item" />
</form>
<ul id="list"></ul>
<script type="text/babel">
// Add code here
</script>
</body>
</html>
-
在脚本标签内,按照以下步骤添加 JavaScript 代码,从第 4 步开始:
-
保留我们将用于显示数据或与应用程序交互的 HTML 元素的引用:
const timeElem = document.querySelector('#current-time')
const formElem = document.querySelector('#todo-form')
const listElem = document.querySelector('#todo-list')
const inputElem = document.querySelector('#todo-input')
const luckyElem = document.querySelector('#lucky-number')
- 从 Redux 库中获取
createStore
方法和辅助方法:
const {
createStore,
combineReducers,
bindActionCreators,
} = Redux
- 设置动作类型:
const TYPE = {
SET_TIME: 'SET_TIME',
SET_LUCKY_NUMBER: 'SET_LUCKY_NUMBER',
ADD_TODO: 'ADD_TODO',
REMOVE_TODO: 'REMOVE_TODO',
TOGGLE_COMPLETED_TODO: 'TOGGLE_COMPLETED_TODO',
}
- 定义动作创建者:
const actions = {
setTime: (time) => ({
type: TYPE.SET_TIME,
time,
}),
setLuckyNumber: (number) => ({
type: TYPE.SET_LUCKY_NUMBER,
number,
}),
addTodo: (id, title) => ({
type: TYPE.ADD_TODO,
title,
id,
}),
removeTodo: (id) => ({
type: TYPE.REMOVE_TODO,
id,
}),
toggleTodo: (id) => ({
type: TYPE.TOGGLE_COMPLETED_TODO,
id,
}),
}
- 定义一个 reducer 函数来管理保持时间的状态片段:
const currentTime = (state = null, action) => {
switch (action.type) {
case TYPE.SET_TIME: return action.time
default: return state
}
}
- 定义一个 reducer 函数来管理每次用户加载你的应用程序时生成的幸运数字的状态片段:
const luckyNumber = (state = null, action) => {
switch (action.type) {
case TYPE.SET_LUCKY_NUMBER: return action.number
default: return state
}
}
- 定义一个 reducer 函数来管理保持 To-do 项数组的州片段:
const todoList = (state = [], action) => {
switch (action.type) {
case TYPE.ADD_TODO: return state.concat([
{
id: String(action.id),
title: action.title,
completed: false,
}
])
case TYPE.REMOVE_TODO: return state.filter(
todo => todo.id !== action.id
)
case TYPE.TOGGLE_COMPLETED_TODO: return state.map(
todo => (
todo.id === action.id
? {
...todo,
completed: !todo.completed,
}
: todo
)
)
default: return state
}
}
- 将所有 reducers 合并为一个:
const reducer = combineReducers({
currentTime,
luckyNumber,
todoList,
})
- 创建一个 store:
const store = createStore(reducer)
- 将所有动作创建者绑定到 store 的
dispatch
方法:
const {
setTime,
setLuckyNumber,
addTodo,
removeTodo,
toggleTodo,
} = bindActionCreators(actions, store.dispatch)
- 订阅一个监听器到 store,当状态改变时更新持有时间的 HTML 元素:
store.subscribe(() => {
const { currentTime } = store.getState()
timeElem.textContent = currentTime
})
- 订阅一个监听器到 store,当状态改变时更新显示幸运数字的 HTML 元素:
store.subscribe(() => {
const { luckyNumber } = store.getState()
luckyElem.textContent = `Your lucky number is: ${luckyNumber}`
})
- 订阅一个监听器到 store,当状态改变时更新显示 To-do 项列表的 HTML 元素。将
li
HTML 元素的属性draggable
设置为允许用户在视图中拖放项目:
store.subscribe(() => {
const { todoList } = store.getState()
listElem.innerHTML = ''
todoList.forEach(todo => {
const li = document.createElement('li')
li.textContent = todo.title
li.dataset.id = todo.id
li.setAttribute('draggable', true)
if (todo.completed) {
li.style = 'text-decoration: line-through'
}
listElem.appendChild(li)
})
})
- 在列表 HTML 元素上添加一个
click
事件监听器,当项目被点击时切换 To-do 项的completed
属性:
listElem.addEventListener('click', (event) => {
toggleTodo(event.target.dataset.id)
})
- 在列表 HTML 元素上添加一个
drag
事件监听器,当项目被拖出列表外时删除 To-do 项:
listElem.addEventListener('drag', (event) => {
removeTodo(event.target.dataset.id)
})
- 在包含输入 HTML 元素的表上添加一个
submit
事件监听器,该监听器将派发一个新动作来添加一个新的 To-do 项:
let id = 0
formElem.addEventListener('submit', (event) => {
event.preventDefault()
addTodo(++id, inputElem.value)
inputElem.value = ''
})
- 当页面第一次加载时,派发一个动作来设置幸运数字,并定义一个每秒被触发以更新应用程序状态中的当前时间的函数:
setLuckyNumber(Math.ceil(Math.random() * 1024))
setInterval(() => {
setTime(new Date().toTimeString())
}, 1000)
- 保存文件
让我们测试一下...
要查看之前工作的实际效果:
- 打开一个新的终端并运行:
node todo-time.js
- 在你的浏览器中,访问:
http://localhost:1337/
-
在输入框中输入一些内容并按回车键。一个新的项目应该出现在列表中。
-
点击你添加的其中一个项目以标记它为已完成。
-
再次点击标记为已完成的其中一个项目以标记它为未完成。
-
点击并拖动列表外的其中一个项目以从待办事项列表中移除它。
它是如何工作的...
- 定义了三个 reducer 函数,以独立管理具有以下形状的每个状态切片:
{
currentTime: String,
luckyNumber: Number,
todoList: Array.of({
id: Number,
title: String,
completed: Boolean,
}),
}
-
我们使用了 Redux 库中的
combineReducers
辅助方法将这三个 reducer 组合成一个。 -
然后,创建了一个提供组合 reducer 函数的存储。
-
为了方便起见,我们订阅了三个监听函数,每当状态改变时,这些函数就会被触发以更新用于显示状态数据的 HTML 元素。
-
我们还定义了三个事件监听器:一个用于检测用户提交包含用于添加新待办事项的输入 HTML 元素的表单,另一个用于检测用户点击屏幕上显示的待办事项以切换其状态从未完成到完成或反之,最后一个是用于检测用户将元素从列表中拖动以从待办事项列表中移除的事件监听器
编写 Redux 存储增强器
Redux 存储增强器是一个高阶函数,它接受一个存储创建函数并返回一个新的增强存储创建函数。createStore
方法是一个存储创建函数,其签名如下:
createStore = (reducer, preloadedState, enhancer) => Store
而存储增强器函数的签名如下:
enhancer = (...optionalArguments) => (
createStore => (reducer, preloadedState, enhancer) => Store
)
现在看起来可能有点难以理解,但如果你一开始不理解,其实不必担心,因为你可能永远不需要编写存储增强器。这个菜谱的目的是简单地帮助你以非常简单的方式理解它们的目的。
准备工作
在这个菜谱中,你将创建一个存储增强器,通过允许在Map
JavaScript 原生对象中定义 reducer 函数来扩展 Redux 的功能。首先,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"redux": "4.0.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
记住createStore
接受一个 reducer 函数作为第一个参数。我们编写一个存储增强器,允许createStore
方法接受一个包含键值对的Map
对象,其中键是将被管理的属性或状态切片,值是一个reducer
函数。然后,使用Map
对象定义两个 reducer 函数来处理两个状态切片,一个用于计数器,另一个用于设置当前时间:
-
创建一个名为
map-store.js
的新文件。 -
包含 Redux 库:
const {
createStore,
combineReducers,
bindActionCreators,
} = require('redux')
- 定义一个存储增强器函数,它将允许
createStore
方法接受一个Map
对象作为参数。它将遍历Map
中的每个键值对,并将它们添加到一个对象中,然后使用combineReducers
方法将这个对象与 reducer 组合:
const acceptMap = () => createStore => (
(reducerMap, ...rest) => {
const reducerList = {}
for (const [key, val] of reducerMap) {
reducerList[key] = val
}
return createStore(
combineReducers(reducerList),
...rest,
)
}
)
- 定义动作类型:
const TYPE = {
INC_COUNTER: 'INC_COUNTER',
DEC_COUNTER: 'DEC_COUNTER',
SET_TIME: 'SET_TIME',
}
- 定义动作创建者:
const actions = {
incrementCounter: (incBy) => ({
type: TYPE.INC_COUNTER,
incBy,
}),
decrementCounter: (decBy) => ({
type: TYPE.DEC_COUNTER,
decBy,
}),
setTime: (time) => ({
type: TYPE.SET_TIME,
time,
}),
}
- 定义一个包含
Map
实例的map
常量:
const map = new Map()
- 在
map
对象中添加一个新的还原函数,键为counter
:
map.set('counter', (state = 0, action) => {
switch (action.type) {
case TYPE.INC_COUNTER: return state + action.incBy
case TYPE.DEC_COUNTER: return state - action.decBy
default: return state
}
})
- 在
map
对象中添加另一个名为time
的还原函数:
map.set('time', (state = null, action) => {
switch (action.type) {
case TYPE.SET_TIME: return action.time
default: return state
}
})
- 创建一个新的存储,将
map
作为第一个参数,将 存储增强器 作为第二个参数提供给createStore
方法以扩展其功能:
const store = createStore(map, acceptMap())
- 将先前定义的动作创建者绑定到存储的
dispatch
方法:
const {
incrementCounter,
decrementCounter,
setTime,
} = bindActionCreators(actions, store.dispatch)
- 要在 NodeJS 中测试代码,使用
setInterval
全局方法每秒重复调用一个函数。它将首先派发一个动作来设置当前时间,然后,根据标准,它将决定是否增加或减少计数器。之后,在终端中漂亮地打印存储的当前值:
setInterval(function() {
setTime(new Date().toTimeString())
if (this.shouldIncrement) {
incrementCounter((Math.random() * 5) + 1 | 0)
} else {
decrementCounter((Math.random() * 5) + 1 | 0)
}
console.dir(
store.getState(),
{ colors: true, compact: false },
)
this.shouldIncrement = !this.shouldIncrement
}.bind({ shouldIncrement: false }), 1000)
-
保存文件。
-
打开一个新的终端并运行:
node map-store.js
- 当前状态将每秒显示一次,其形状如下:
{
"counter": Number,
"time": String,
}
它是如何工作的...
增强器将存储创建者组合成一个新的。例如,以下行:
const store = createStore(map, acceptMap())
可以写成:
const store = acceptMap()(createStore)(map)
实际上,在某种程度上,它将原始的 createStore
方法包装在另一个 createStore
方法中。
组合可以解释为一组函数,这些函数接受前一个函数的结果参数。例如:
const c = (...args) => f(g(h(...args)))
这将从右到左将函数 f
、g
和 h
组合成一个单独的函数 c
。这意味着,我们也可以像这样编写前面的代码行:
const _createStore = acceptMap()(createStore)
const store = _createStore(map)
其中 _createStore
是组合 createStore
和你的存储增强器函数的结果。
使用 Redux 进行时间旅行
尽管你可能永远不需要编写存储增强器,但有一个特殊的增强器你可能发现对调试你的 Redux 驱动的应用程序进行时间旅行非常有用。你可以通过简单地安装 Redux DevTools 扩展(适用于 Chrome 和 Firefox)来在你的应用程序上启用时间旅行:github.com/zalmoxisus/redux-devtools-extension
。
准备中
在这个菜谱中,我们将看到一个如何充分利用这个功能的例子,并分析你的应用程序状态是如何随时间在浏览器上运行而变化的。首先,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"redux": "4.0.0"
}
}
然后,通过打开终端并运行来安装依赖项:
npm install
确保已安装 Redux DevTools 扩展到你的网络浏览器中。
如何做到这一点...
构建一个计数器应用程序,当应用程序在浏览器上运行时,将随机增加或减少初始指定的计数器 10 次。然而,因为发生得很快,用户将无法注意到自应用程序开始以来状态实际上已经改变了 10 次。我们将使用 Redux DevTools 扩展来导航和分析状态是如何随时间变化的。
首先,构建一个小型的 ExpressJS 服务器应用程序,该应用程序将提供客户端应用程序和安装在 node_modules
中的 Redux 库:
-
创建一个名为
time-travel.js
的新文件 -
添加以下代码:
const express = require('express')
const path = require('path')
const app = express()
app.use('/lib', express.static(
path.join(__dirname, 'node_modules', 'redux', 'dist')
))
app.get('/', (req, res) => {
res.sendFile(path.join(
__dirname,
'time-travel.html',
))
})
app.listen(
1337,
() => console.log('Web Server running on port 1337'),
)
- 保存文件
接下来,构建你的计数器,Redux 驱动的应用程序,具有时间旅行功能:
-
创建一个名为
time-travel.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Time travel</title>
<script
src="img/babel.min.js">
</script>
<script src="img/redux.js"></script>
</head>
<body>
<h1>Counter: <span id="counter"></span></h1>
<script type="text/babel">
// Add JavaScript Code here
</script>
</body>
</html>
-
在脚本标签内添加以下 JavaScript 代码,从第 4 步开始:
-
保留一个对
span
HTML 元素的引用,该元素将在状态更改时显示计数器的当前值:
const counterElem = document.querySelector('#counter')
- 从 Redux 库中获取
createStore
方法 和bindActionCreators
方法:
const {
createStore,
bindActionCreators,
} = Redux
- 定义两个动作类型:
const TYPE = {
INC_COUNTER: 'INC_COUNTER',
DEC_COUNTER: 'DEC_COUNTER',
}
- 定义两个动作创建者:
const actions = {
incCounter: (by) => ({ type: TYPE.INC_COUNTER, by }),
decCounter: (by) => ({ type: TYPE.DEC_COUNTER, by }),
}
- 定义一个根据给定动作类型转换状态的 reducer 函数:
const reducer = (state = { value: 5 }, action) => {
switch (action.type) {
case TYPE.INC_COUNTER:
return { value: state.value + action.by }
case TYPE.DEC_COUNTER:
return { value: state.value - action.by }
default:
return state
}
}
- 创建一个新的 store,提供一个 store enhancer 函数,当 Redux DevTools 扩展安装时,该函数将在
window
对象上可用:
const store = createStore(
reducer,
(
window.__REDUX_DEVTOOLS_EXTENSION__ &&
window.__REDUX_DEVTOOLS_EXTENSION__()
),
)
- 将动作创建者绑定到 store 的
dispatch
方法:
const {
incCounter,
decCounter,
} = bindActionCreators(actions, store.dispatch)
- 订阅一个监听函数到 store,当状态更改时,它会更新
span
HTML 元素:
store.subscribe(() => {
const state = store.getState()
counterElem.textContent = state.value
})
- 让我们创建一个
for
循环,当应用程序运行时,它会随机更新计数器 10 次:
for (let i = 0; i < 10; i++) {
const incORdec = (Math.random() * 10) > 5
if (incORdec) incCounter(2)
else decCounter(1)
}
- 保存文件
让我们测试一下...
要查看之前的工作效果:
- 打开一个新的终端并运行:
node todo-time.js
- 在你的浏览器中访问:
http://localhost:1337/
- 打开浏览器中的开发者工具并查找 Redux 选项卡。你应该看到一个像这样的标签:
Redux DevTools – 标签窗口
- 滑块允许你从最后一个状态移动到应用程序的第一个状态。尝试将滑块移动到不同的位置:
Redux DevTools – 滑块移动
- 当你移动滑块时,你会在浏览器中看到计数器的初始值以及它在 for 循环中改变了十次:
还有更多
Redux DevTools 有一些你可能觉得非常神奇且对调试和管理应用程序状态非常有用的功能。实际上,如果你遵循了之前的食谱,我建议你回到我们编写的项目,启用此增强器并尝试使用 Redux DevTools 进行实验。
Redux DevTools 的许多功能之一是日志监控器,它按时间顺序显示哪个动作被分发以及转换状态的结果:
Redux DevTools – 日志监控器
理解 Redux 中间件
扩展 Redux 功能最简单、最好的方法之一是使用中间件。
Redux 库中有一个名为 applyMiddleware
的 store enhancer 函数,允许你定义一个或多个中间件函数。Redux 中中间件的工作方式很简单,它允许你包装 store 的 dispatch
方法来扩展其功能。与 store enhancer 函数一样,中间件是可组合的,并且具有以下签名:
middleware = API => next => action => next(action)
在这里,API
是一个包含从 store 中获取的 dispatch
和 getState
方法的对象,解构 API
,其签名如下:
middleware = ({
getState,
dispatch,
}) => next => action => next(action)
让我们分析它是如何工作的:
applyMiddleware
函数接收一个或多个中间件函数作为参数。例如:
applyMiddleware(middleware1, middleware2)
- 每个中间件函数都作为
Array
内部保持。然后,内部使用Array.prototype.map
方法,该数组通过调用自身来映射每个中间件函数,提供包含存储的dispatch
和getState
方法的中间件API
对象。类似于以下:
middlewares.map((middleware) => middleware(API))
- 然后,通过组合所有中间件函数,它为
dispatch
方法提供一个带有next
参数的新值。在执行的第一个中间件中,next
参数指的是应用任何中间件之前的原始dispatch
方法。例如,如果应用了三个中间件函数,新的计算出的分发方法的签名将是:
dispatch = (action) => (
(action) => (
(action) => store.dispatch(action)
)(action)
)(action)
-
这意味着如果未调用
next(action)
方法,中间件函数可以中断链路并阻止某些动作被分发。 -
中间件
API
对象中的分发方法允许你调用存储的分发方法,该方法带有之前应用的中件。这意味着,如果你在使用此方法时不够小心,你可能会创建一个无限循环。
最初理解其内部工作原理可能并不简单,但我向你保证,你很快就会明白。
准备工作
在这个菜谱中,你将编写一个中间件函数,当分发未定义的动作类型时,它会警告用户。首先,创建一个包含以下内容的新的 package.json
文件:
{
"dependencies": {
"redux": "4.0.0"
}
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
当使用在您的 reducer 中从未定义的动作类型时,Redux 不会警告您或显示错误。构建一个使用 Redux 来管理其状态的 NodeJS 应用程序。专注于编写一个中间件函数,该函数将检查分发动作的类型是否已定义,否则抛出错误:
-
创建一个名为
type-check-redux.js
的新文件。 -
包含 Redux 库:
const {
createStore,
applyMiddleware,
} = require('redux')
- 定义一个包含允许的动作类型的对象:
const TYPE = {
INCREMENT: 'INCREMENT',
DECREMENT: 'DECREMENT',
SET_TIME: 'SET_TIME',
}
- 创建一个虚拟的 reducer 函数,无论调用哪种动作类型,它都返回其原始状态。我们不需要它来完成这个菜谱的目的:
const reducer = (
state = null,
action,
) => state
- 定义一个中间件函数,该函数将拦截正在分发的每个动作并检查动作类型是否存在于
TYPE
对象中。如果动作存在,允许分发动作,否则抛出错误并通知用户已分发无效的动作类型。此外,让我们在错误消息中为用户提供有关哪些有效类型允许的信息:
const typeCheckMiddleware = api => next => action => {
if (Reflect.has(TYPE, action.type)) {
next(action)
} else {
const err = new Error(
`Type "${action.type}" is not a valid` +
`action type. ` +
`did you mean to use one of the following` +
`valid types? ` +
`"${Reflect.ownKeys(TYPE).join('"|"')}"n`,
)
throw err
}
}
- 创建一个存储并应用定义的中间件函数:
const store = createStore(
reducer,
applyMiddleware(typeCheckMiddleware),
)
- 分发两个动作类型。第一个动作类型是有效的,它存在于
TYPE
对象中。然而,第二个是一个从未定义过的动作类型:
store.dispatch({ type: 'INCREMENT' })
store.dispatch({ type: 'MISTAKE' })
- 保存文件。
让我们测试它...
首先,打开一个新的终端并运行:
node type-check-redux.js
终端输出应显示类似于以下错误:
/type-check-redux.js:25
throw err
^
Error: Type "MISTAKE" is not a valid action type. did you mean to use one of the following valid types? "INCREMENT"|"DECREMENT"|"SET_TIME"
at Object.action [as dispatch] (/type-check-redux.js:18:15)
at Object.<anonymous> (/type-check-redux.js:33:7)
在这个例子中,堆栈跟踪告诉我们错误发生在第18
行,这指向了我们的中间件函数。然而,下一个错误指向第33
行,store.dispatch({ type: 'MISTAKE' })
,这是一个好事,因为它可以帮助你追踪某些从未定义的动作的确切分发位置。
它是如何工作的...
这相当简单,中间件函数检查正在分发的动作的类型,看它是否是TYPE
对象常量的属性。如果是,那么中间件将控制权传递给链中的下一个中间件。然而,在我们的情况下,没有下一个中间件,所以控制权传递给了存储的原始dispatch
方法,它将应用 reducer 并转换状态。另一方面,如果动作类型未定义,中间件函数通过不调用next
函数并抛出错误来中断中间件链。
处理异步数据流
默认情况下,Redux 不处理异步数据流。市面上有多个库可以帮助你处理这些任务。然而,为了本章的目的,我们将使用中间件函数构建自己的实现,以赋予dispatch
方法分发和处理异步数据流的能力。
准备工作
在这个菜谱中,你将构建一个 ExpressJS 应用程序,它有一个非常小的 API,用于在发送 HTTP 请求和处理异步数据流及错误时测试你的应用程序。首先,创建一个包含以下内容的新的package.json
文件:
{
"dependencies": {
"express": "4.16.3",
"node-fetch": "2.1.2",
"redux": "4.0.0"
}
}
然后通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
构建一个简单的 RESTful API 服务器,该服务器在接收到 GET 请求时将有两个端点或响应路径/time
和/date
。然而,在/date
路径上,我们将假装存在内部错误,使请求失败,以便了解如何处理异步请求中的错误:
-
创建一个名为
api-server.js
的新文件 -
包含 ExpressJS 库并初始化一个新的 ExpressJS 应用程序:
const express = require('express')
const app = express()
- 对于
/time
路径,在发送响应之前模拟2s
的延迟:
app.get('/time', (req, res) => {
setTimeout(() => {
res.send(new Date().toTimeString())
}, 2000)
})
- 对于
/date
路径,在发送失败响应之前模拟2s
的延迟:
app.get('/date', (req, res) => {
setTimeout(() => {
res.destroy(new Error('Internal Server Error'))
}, 2000)
})
- 监听
1337
端口以接收新的连接
app.listen(
1337,
() => console.log('API server running on port 1337'),
)
- 保存文件
对于客户端,使用 Redux 构建一个 NodeJS 应用程序,该应用程序将分发同步和异步动作。编写一个中间件函数,允许dispatch
方法处理异步动作:
-
创建一个名为
async-redux.js
的新文件 -
包含
node-fetch
和 Redux 库:
const fetch = require('node-fetch')
const {
createStore,
applyMiddleware,
combineReducers,
bindActionCreators,
} = require('redux')
- 定义三种状态。每种状态代表异步操作的状态:
const STATUS = {
PENDING: 'PENDING',
RESOLVED: 'RESOLVED',
REJECTED: 'REJECTED',
}
- 定义两种动作类型:
const TYPE = {
FETCH_TIME: 'FETCH_TIME',
FETCH_DATE: 'FETCH_DATE',
}
- 定义动作创建者。注意,前两个动作创建者的值属性是异步函数。你稍后定义的中间件函数将负责让 Redux 理解这些动作:
const actions = {
fetchTime: () => ({
type: TYPE.FETCH_TIME,
value: async () => {
const time = await fetch(
'http://localhost:1337/time'
).then((res) => res.text())
return time
}
}),
fetchDate: () => ({
type: TYPE.FETCH_DATE,
value: async () => {
const date = await fetch(
'http://localhost:1337/date'
).then((res) => res.text())
return date
}
}),
setTime: (time) => ({
type: TYPE.FETCH_TIME,
value: time,
})
}
- 定义一个用于从动作对象设置值的通用函数,该函数将在你的 reducer 中使用:
const setValue = (prevState, action) => ({
...prevState,
value: action.value || null,
error: action.error || null,
status: action.status || STATUS.RESOLVED,
})
- 定义你应用程序的初始状态:
const iniState = {
time: {
value: null,
error: null,
status: STATUS.RESOLVED,
},
date: {
value: null,
error: null,
status: STATUS.RESOLVED,
}
}
- 定义一个还原函数。注意,只有一个还原函数处理两个状态片段,即
time
和date
:
const timeReducer = (state = iniState, action) => {
switch (action.type) {
case TYPE.FETCH_TIME: return {
...state,
time: setValue(state.time, action)
}
case TYPE.FETCH_DATE: return {
...state,
date: setValue(state.date, action)
}
default: return state
}
}
- 定义一个中间件函数,该函数将检查派发的动作类型是否具有作为
value
属性的函数。如果是这样,假设value
属性是一个异步函数。首先,我们派发一个动作将状态设置为PENDING
。然后,当异步函数解决时,我们派发另一个动作将状态设置为RESOLVED
或错误情况下设置为REJECTED
:
const allowAsync = ({ dispatch }) => next => action => {
if (typeof action.value === 'function') {
dispatch({
type: action.type,
status: STATUS.PENDING,
})
const promise = Promise
.resolve(action.value())
.then((value) => dispatch({
type: action.type,
status: STATUS.RESOLVED,
value,
}))
.catch((error) => dispatch({
type: action.type,
status: STATUS.REJECTED,
error: error.message,
}))
return promise
}
return next(action)
}
- 创建一个新的存储并将你定义的中间件函数应用到扩展
dispatch
方法的功能:
const store = createStore(
timeReducer,
applyMiddleware(
allowAsync,
),
)
- 将动作创建者绑定到存储的
dispatch
方法:
const {
setTime,
fetchTime,
fetchDate,
} = bindActionCreators(actions, store.dispatch)
- 将一个函数监听器订阅到存储中,并在终端中显示状态树,作为 JSON 字符串,每次状态有变化时:
store.subscribe(() => {
console.log('x1b[1;34m%sx1b[0m', 'State has changed')
console.dir(
store.getState(),
{ colors: true, compact: false },
)
})
- 派发一个同步动作来设置时间:
setTime(new Date().toTimeString())
- 派发一个异步动作来获取并设置时间:
fetchTime()
- 派发另一个异步动作来获取并尝试设置日期。请记住,这个操作应该失败,这是故意的:
fetchDate()
- 保存文件。
让我们测试一下...
要查看你的先前工作效果:
- 打开一个新的终端并运行:
node api-server.js
- 在不关闭之前运行的 NodeJS 进程的情况下,打开另一个终端并运行:
node async-redux.js
它是如何工作的...
-
每当状态有变化时,订阅的监听器函数将在终端中格式化打印当前状态树
-
第一个派发的动作是同步的。它将导致状态树的时间片段更新如下,例如:
time: {
value: "01:02:03 GMT+0000",
error: null,
status: "RESOLVED"
}
- 被派发的第二个动作是异步的。内部,派发了两个动作来反映异步操作的状态,一个是在异步函数仍在执行时,另一个是在异步函数解决时:
time: {
value: null,
error: null,
status: "PENDING"
}
// Later, once the operation is fulfilled:
time: {
value: "01:02:03 GMT+0000",
error: null,
status: "RESOLVED"
}
- 被派发的第三个动作也是异步的。内部,它也会导致两个动作被派发以反映异步操作的状态:
date: {
value: null,
error: null,
status: "PENDING"
}
// Later, once the operation is fulfilled:
date: {
value: null,
error: "request to http://localhost:1337/date failed, reason:
socket hang up",
status: "REJECTED"
}
-
考虑到操作是异步的,终端中显示的输出可能不会总是按相同的顺序
-
注意,第一个异步操作已完成,状态标记为
RESOLVED
,而第二个异步操作已完成,其状态标记为REJECTED
-
状态
PENDING
、RESOLVED
和REJECTED
反映了 JavaScript Promise 可能的三种状态,并且这些名称不是强制的,只是易于记忆
更多内容...
如果你不想编写自己的中间件函数或存储增强器来处理异步操作,你可以选择使用许多 Redux 库中的一个。其中两个最常用或最受欢迎的是这些:
-
Redux Thunk—
github.com/gaearon/redux-thunk
-
Redux Saga—
github.com/redux-saga/redux-saga
第六章:使用 React 构建 Web 应用程序
在本章中,我们将涵盖以下食谱:
-
理解 React 元素和 React 组件
-
组合组件
-
有状态的组件和生命周期方法
-
使用 React.PureComponent 进行操作
-
React 事件处理器
-
组件的条件渲染
-
使用 React 渲染列表
-
在 React 中处理表单和输入
-
理解 refs 以及如何使用它们
-
理解 React 端口
-
使用错误边界组件捕获错误
-
使用 PropTypes 进行类型检查属性
技术要求
你将需要了解 Go 编程语言,以及 Web 应用程序框架的基础知识。你还需要安装 Git,以便使用本书的 Git 仓库。最后,还需要具备在命令行上使用 IDE 进行开发的技能。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter06
查看以下视频,以查看代码的实际效果:
简介
React 是一个用于构建 用户界面(UI)的 JavaScript 库。React 是基于组件的,这意味着每个组件都可以独立于其他组件存在并管理自己的状态。复杂的 UI 可以通过组合组件来创建。
组件通常使用 JSX 语法创建,它具有类似 XML 的语法,或者使用 React.createElement
方法。然而,JSX 是使 React 在构建 Web 应用程序时特别适合声明式编程的原因。
在 MVC 模式下,React 通常与 View 相关联。
理解 React 元素和 React 组件
React 元素可以使用 JSX 语法创建:
const element = <h1>Example</h1>
这被转换成:
const element = React.createElement('h1', null, 'Example')
JSX 是 JavaScript 之上的语言扩展,它允许你轻松地创建复杂的 UI。例如,考虑以下:
const element = (
<details>
<summary>React Elements</summary>
<p>JSX is cool</p>
</details>
)
之前的示例可以不使用 JSX 语法来编写:
const element = React.createElement(
'details',
null,
React.createElement('summary', null, 'React Elements'),
React.createElement('p', null, 'JSX is cool'),
)
React 元素可以是任何 HTML5 标签,任何 JSX 标签都可以自闭合。例如,以下将创建一个包含空内容的段落 React 元素:
const element = <p />
就像使用 HTML5 一样,你可以向 React 元素提供属性,在 React 中称为属性或 props:
const element = (
<input type="text" value="Example" readOnly />
)
React 组件允许你将你的 Web 应用程序的部分作为可重用的代码块或组件进行隔离。它们可以以多种方式定义。例如:
- 函数组件:这些是接受属性作为第一个参数的普通 JavaScript 函数,并返回 React 元素:
const InputText = ({ name, children }) => (
<input
type="text"
name={name}
value={children}
readOnly
/>
)
- 类组件:使用 ES6 类允许你定义生命周期方法和创建有状态的组件。它们通过
render
方法渲染 React 元素:
class InputText extends React.Component {
render() {
const { name, children } = this.props
return (
<input
type="text"
name={name}
value={children}
readOnly
/>
)
}
}
- 表达式:这些保持对 React 元素或组件实例的引用:
const InstanceInputText = (
<InputText name="username">
Huang Jx
</InputText>
)
有一些属性是独特的,并且仅属于 React。例如,children
属性指的是标签内的元素:
<MyComponent>
<span>Example</span>
</MyComponent>
在上一个示例中,MyComponent
接收到的 children
属性将是一个 span
React 元素的实例。如果传递了多个 React 元素或组件作为子元素,则 children
属性将是一个数组。然而,如果没有传递子元素,则 children
属性将为 null
。children
属性不一定是 React 元素或组件;它也可以是一个 JavaScript 函数或 JavaScript 原始值:
<MyComponent>
{() => {
console.log('Example!')
return null
}}
</MyComponent>
React 也认为返回或渲染字符串的功能组件和类组件是有效的 React 组件。例如:
const SayHi = ({ to }) => (
`Hello ${to}`
)
const element = (
<h1>
<SayHi to="John" />, how are you?
</h1>
)
React 组件的名称必须以大写字母开头。否则,React 将将小写 JSX 标签视为 React 元素
在 React 中将组件渲染到 DOM 中 不是一个复杂的过程。React 提供了多种方法,通过 ReactDOM
库将 React 组件渲染到 DOM 中。React 使用 JSX 或 React.createElement
创建一个树或 DOM 树的表示。它是通过使用虚拟 DOM 来实现的,这使得 React 能够将 React 元素转换为 DOM 节点,并仅更新已更改的节点。
这就是通常使用 ReactDOM
库的 render
方法渲染应用程序的方式:
import * as ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
render
方法提供的第一个参数是一个 React 组件或 React 元素。第二个参数告诉你在 DOM 中的哪个位置渲染应用程序。在上一个示例中,我们使用文档对象的 querySelector
方法查找具有 role
属性设置为 "main"
的 DOM 节点。
React 还允许你将 React 组件作为 HTML 字符串渲染,这对于在服务器端生成内容并将内容直接作为 HTML 文件发送到浏览器非常有用:
import * as React from 'react'
import * as ReactDOMServer from 'react-dom/server'
const OrderedList = ({ children }) => (
<ol>
{children.map((item, indx) => (
<li key={indx}>{item}</li>
))}
</ol>
)
console.log(
ReactDOMServer.renderToStaticMarkup(
<OrderedList>
{['One', 'Two', 'Three']}
</OrderedList>
)
)
它将在控制台输出以下内容:
<ol>
<li>One</li>
<li>Two</li>
<li>Three</li>
</ol>
准备工作
在本食谱中,你将创建一个简单的 React 应用程序,使用你学到的关于 React 组件和 React 元素的概念。在你开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个名为 .babelrc
的 Babel 配置文件,并添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
创建一个 React 应用程序,通过编写功能、类和表达式组件来显示欢迎信息:
-
创建一个名为
basics.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个新的功能组件,该组件将渲染一个
span
React 元素,其style
属性中的color
设置为红色:
const RedText = ({ text }) => (
<span style={{ color: 'red' }}>
{text}
</span>
)
- 定义另一个功能组件,该组件将渲染一个
h1
React 元素和作为其children
的RedText
功能组件:
const Welcome = ({ to }) => (
<h1>Hello, <RedText text={to}/></h1>
)
- 定义一个表达式,其中将包含对 React 元素的引用:
const TodoList = (
<ul>
<li>Lunch at 14:00 with Jenny</li>
<li>Shower</li>
</ul>
)
- 定义一个名为
Footer
的类组件,用于显示当前日期:
class Footer extends React.Component {
render() {
return (
<footer>
{new Date().toDateString()}
</footer>
)
}
}
- 将应用程序渲染到 DOM 中:
ReactDOM.render(
<div>
<Welcome to="John" />
{TodoList}
<Footer />
</div>,
document.querySelector('[role="main"]'),
)
- 保存文件。
然后,创建一个 index.html
文件,你将在其中渲染 React 应用程序:
-
创建一个名为
index.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MyApp</title>
</head>
<body>
<div role="main"></div>
<script src="img/basics.js"></script>
</body>
</html>
- 保存文件
让我们测试一下...
要查看之前的工作效果:
- 在项目目录根目录下打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 你应该能够看到 React 应用程序渲染到 DOM 中
组合组件
在 React 中,所有组件都可以被隔离,通过组合组件可以构建复杂的 UI,这使它们具有可复用性。
准备工作
在这个菜谱中,你将使用可复用组件来生成一个包含三个部分的主页:一个页眉、一个带有描述的段落和一个页脚。这三个部分将被编写为三个单独的组件,稍后将被组合起来构建主页。在你开始之前,创建一个包含以下内容的新的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件.babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
在项目根目录中创建一个名为component
的新文件夹。然后,按照以下顺序创建以下三个文件:
-
Header.js
-
Footer.js
-
Description.js
Header
组件将生成一个代表页面标题的h1
React 元素。它期望接收一个title
属性:
-
在
component
目录中创建一个名为Header.js
的新文件 -
添加以下代码:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
export default ({ title }) => (
<h1>{title}</h1>
)
- 保存文件
Footer
组件将生成一个放置在页面末尾的footer
React 元素。它期望接收一个date
属性:
-
在
component
目录中创建一个名为Footer.js
的新文件 -
添加以下代码:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
export default ({ date }) => (
<footer>{date}</footer>
)
- 保存文件
Description
组件将生成一个段落,显示页面的描述:
-
在
component
目录中创建一个名为Description.js
的新文件 -
添加以下代码:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
export default () => (
<p>This is a cool website designed with ReactJS</p>
)
- 保存文件
接下来,从component
目录退回到项目根目录(其中包含package.json
)并创建以下文件:
-
创建一个名为
composing-react.js
的新文件 -
导入 React 和
ReactDOM
库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 导入之前定义的组件:
import Header from './component/Header'
import Footer from './component/Footer'
import Description from './component/Description'
- 定义一个
App
组件,该组件将渲染你之前定义的组件:
const App = () => (
<React.Fragment>
<Header title="Simple React App" />
<Description />
<Footer date={new Date().toDateString()} />
</React.Fragment>
)
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件
然后,创建一个index.html
文件,你将在其中渲染 React 应用程序:
-
创建一个名为
index.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Composing Components</title>
</head>
<body>
<div role="main"></div>
<script src="img/composing-react.js"></script>
</body>
</html>
- 保存文件
让我们测试一下...
要查看之前的工作效果,请执行以下步骤:
- 在您的项目目录根目录下打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 如果你在浏览器开发者工具中检查 DOM 树,你应该能够看到以下 DOM 结构:
<div role="app">
<h1>React App</h1>
<p>This is a cool website designed with ReactJS</p>
<footer>Tue May 22 2018</footer>
</div>
它是如何工作的...
每个 React 组件都写在单独的文件中。然后,我们在主应用程序文件composing-react.js
中导入组件,并使用组合来生成虚拟 DOM 树。每个组件都是可重用的,因为它可以在应用程序的其他部分或其他组件中再次使用,只需导入文件。然后,使用ReactDOM
库中的render
方法来生成虚拟 DOM 树的 DOM 表示。
还有更多...
你注意到我们使用了React.Fragment
吗?这是 React v16 引入的新功能。它允许你返回多个元素而不创建额外的 DOM 节点。组件不能以下方式返回多个 React 组件或元素:
const Example = () => (
<span>One</span>
<span>Two</span>
) // < will trow an error
然而,使用React.Fragment
,你可以做以下操作:
const Example = () => (
<React.Fragment>
<span>One</span>
<span>Two</span>
</React.Fragment>
)
有状态组件和生命周期方法
React 组件可以管理它们自己的状态,并且只有当状态发生变化时才会更新。使用 ES6 类编写的有状态 React 组件如下:
class Example extends React.Component {
render() {
<span>This is an example</span>
}
}
React 类组件有一个state
实例属性来访问它们的内部状态,以及一个props
属性来访问传递给组件的属性:
class Example extends React.Component {
state = { title: null }
render() {
return (
<React.Fragment>
<span>{this.props.title}</span>
<span>{this.state.title}</span>
</React.Fragment>
)
}
}
并且它们的状态可以通过使用setState
实例方法来修改:
class Example extends React.Component {
state = {
title: "Example",
date: null,
}
componentDidMount() {
this.setState((prevState) => ({
date: new Date().toDateString(),
}))
}
render() {
return (
<React.Fragment>
<span>{this.state.title}</span>
<span>{this.state.date}</span>
</React.Fragment>
)
}
}
状态只初始化一次。然后,当组件挂载时,应该只使用setState
方法来修改状态。这样,React 能够检测状态的变化并更新组件。
setState
方法接受一个回调函数作为第一个参数,该回调函数将在将当前状态(按惯例为prevState
)作为第一个参数传递给回调函数,并将当前props
作为第二个参数时执行。这是因为setState
是异步的,状态可能在你在组件的不同部分执行其他操作时被修改。
如果你不需要在更新状态时访问当前状态,你可以直接将一个对象作为第一个参数传递。例如,上一个例子可以写成如下形式:
componentDidMount() {
this.setState({
date: new Date().toDateString(),
})
}
setState
还接受一个可选的回调函数作为第二个参数,该回调函数在状态更新后被调用。因为setState
是异步的,你可能想在状态更新后执行一次操作,所以可以使用第二个回调:
componentDidMount() {
this.setState({
date: new Date().toDateString(),
}, () => {
console.log('date has been updated!')
})
console.log(this.state.date) // null
}
一旦组件挂载,控制台将首先输出null
,即使我们在它之前使用了setState
;这是因为状态是异步设置的。然而,一旦状态更新,控制台将显示“date has been updated”。
当使用setState
方法时,React 将前一个状态与当前给定的状态合并。内部上,它类似于执行以下操作:
currentState = Object.assign({}, currentState, nextState)
每个类组件都有生命周期方法,这些方法让你从组件创建到销毁的过程中控制组件的生命周期,以及控制其他属性,例如知道组件是否接收到新的属性以及组件是否应该更新。这些是所有类组件中存在的生活周期方法:
-
constructor(props)
: 在初始化组件的新实例并在组件挂载之前,这个方法会被调用。props
必须通过super(props)
传递给超类,以便 React 正确设置props
。constructor
方法也很有用,可以用来初始化组件的初始状态。 -
static getDerivedStateFromProps(nextProps, nextState)
: 当组件被实例化并且组件将接收到新的props
时,这个方法会被调用。当状态或其部分依赖于传递给组件的props
中的值时,这个方法很有用。它必须返回一个对象,该对象将与当前状态合并,或者如果接收到新的props
后不需要更新状态,则返回null
。 -
componentDidMount()
: 在组件被挂载并在第一次render
调用之后,这个方法会被调用。它对于与第三方库集成、访问 DOM 或向端点发送 HTTP 请求很有用。 -
shouldComponentUpdate(nextProps, nextState)
: 当组件更新了状态或接收到了新的 props 时,这个方法会被调用。这个方法允许 React 知道是否应该更新组件。如果你在你的组件中没有实现这个方法,它默认返回true
,这意味着每次状态改变或接收到新的 props 时,组件都应该被更新。如果你实现这个方法并返回false
,它将告诉 React 不要更新组件。 -
componentDidUpdate(prevProps, prevState, snapshot)
: 在渲染方法之后或发生更新时(除了第一次渲染),这个方法会被调用。 -
getSnapshotBeforeUpdate(prevProps, prevState)
: 在渲染方法之后或发生更新时,但在componentDidUpdate
生命周期方法之前,这个方法会被调用。这个方法的返回值作为componentDidUpdate
的第三个参数传递。 -
componentWillUnmount()
: 在组件卸载和其实例被销毁之前,这个方法会被调用。如果你使用第三方库,这个方法有助于清理。例如,清除定时器或取消网络请求。 -
componentDidCatch(error, info)
:这是 React v16 的新特性,用于错误处理。我们将在接下来的菜谱中更详细地探讨这个特性。
准备工作
在这个菜谱中,你将使用我们所学到的所有生命周期方法来构建一个组件。首先,创建一个包含以下内容的新的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件作为.babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何实现...
构建一个 LifeCycleTime
组件,其唯一目的是显示当前时间。该组件将每 100 毫秒更新一次,以保持组件与时间变化的同步。我们将在这个组件中使用生命周期方法来完成以下目的:
-
constructor(props)
: 用于初始化组件的初始状态。 -
static getDerivedStateFromProps(nextProps, nextState)
: 用于合并props
和状态。 -
componentDidMount()
: 设置一个每 100 毫秒执行一次的函数,使用setInterval
更新状态为当前时间。 -
shouldComponentUpdate(nextProps, nextState)
: 用于决定组件是否应该被渲染。检查props
是否有一个属性dontUpdate
设置为true
,这意味着组件在状态或props
变化时不应更新。 -
componentDidUpdate(prevProps, prevState, snapshot)
: 用于在控制台简单地记录组件已被更新,并显示snapshot
的值。 -
getSnapshotBeforeUpdate(prevProps, prevState)
: 为了说明这个方法的功能,只需返回一个字符串,该字符串将被传递给componentDidUpdate
的第三个参数。 -
componentWillUnmount()
: 当组件被销毁或卸载时,清除在componentDidMount
中定义的间隔。否则,在组件卸载后,您将看到一个错误信息被显示。
首先,创建一个 index.html
文件,您将在其中渲染 React 应用程序:
-
创建一个名为
index.html
的新文件 -
添加以下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Life cycle methods</title>
</head>
<body>
<div role="main"></div>
<script src="img/stateful-react.js"></script>
</body>
</html>
- 保存文件
接下来,执行以下步骤来构建 LifeCycleTime
组件:
-
创建一个名为
stateful-react.js
的新文件 -
导入 React 和
ReactDOM
库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个
LifeCycleTime
类组件,并使用之前描述的生命周期方法:
class LifeCycleTime extends React.Component {
constructor(props) {
super(props)
this.state = {
time: new Date().toTimeString(),
color: null,
dontUpdate: false,
}
}
static getDerivedStateFromProps(nextProps, prevState) {
return nextProps
}
componentDidMount() {
this.intervalId = setInterval(() => {
this.setState({
time: new Date().toTimeString(),
})
}, 100)
}
componentWillUnmount() {
clearInterval(this.intervalId)
}
shouldComponentUpdate(nextProps, nextState) {
if (nextState.dontUpdate) {
return false
}
return true
}
getSnapshotBeforeUpdate(prevProps, prevState) {
return 'snapshot before update'
}
componentDidUpdate(prevProps, prevState, snapshot) {
console.log(
'Component did update and received snapshot:',
snapshot,
)
}
render() {
return (
<span style={{ color: this.state.color }}>
{this.state.time}
</span>
)
}
}
- 然后,定义一个
App
类组件,该组件将用于测试你之前创建的组件。添加三个按钮:一个按钮会在红色和蓝色之间切换颜色属性,并将其作为属性传递给LifeCycleTime
组件,另一个按钮用于在状态中的dontUpdate
属性之间切换 true 和 false,然后将其作为属性传递给LifeCycleTime
,最后,一个按钮在被点击时将挂载或卸载LifeCycleTime
组件:
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
color: 'red',
dontUpdate: false,
unmount: false,
}
this.toggleColor = this.toggleColor.bind(this)
this.toggleUpdate = this.toggleUpdate.bind(this)
this.toggleUnmount = this.toggleUnmount.bind(this)
}
toggleColor() {
this.setState((prevState) => ({
color: prevState.color === 'red'
? 'blue'
: 'red',
}))
}
toggleUpdate() {
this.setState((prevState) => ({
dontUpdate: !prevState.dontUpdate,
}))
}
toggleUnmount() {
this.setState((prevState) => ({
unmount: !prevState.unmount,
}))
}
render() {
const {
color,
dontUpdate,
unmount,
} = this.state
return (
<React.Fragment>
{unmount === false && <LifeCycleTime
color={color}
dontUpdate={dontUpdate}
/>}
<button onClick={this.toggleColor}>
Toggle color
{JSON.stringify({ color })}
</button>
<button onClick={this.toggleUpdate}>
Should update?
{JSON.stringify({ dontUpdate })}
</button>
<button onClick={this.toggleUnmount}>
Should unmount?
{JSON.stringify({ unmount })}
</button>
</React.Fragment>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要查看之前的工作效果,请执行以下步骤:
- 在您的项目目录根目录下打开一个终端,并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页,并转到:
http://localhost:1337/
- 使用这些按钮来切换组件的状态,并理解生命周期方法如何影响组件的功能。
使用 React.PureComponent 进行操作
React.PureComponent
与 React.Component
类似。区别在于 React.Component
内部实现了 shouldComponentUpdate
生命周期方法,以进行浅比较 state
和 props
,以决定组件是否应该更新。
准备工作
在这个菜谱中,您将编写两个组件,一个扩展 React.PureComponent
,另一个扩展 React.Component
,以便了解当将相同的属性传递给它们时它们的行为。在开始之前,创建一个包含以下内容的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件 .babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
构建一个 React 应用程序来展示和更好地理解 React.PureComponent
的工作原理。创建两个组件:一个将扩展 React.Component
,另一个将扩展 React.PureComponent
。这两个组件将被放置在另一个名为 App
的 React 组件内部,该组件大约每秒更新一次其状态。在两个组件中使用生命周期方法 componentDidUpdate
,在控制台记录哪个组件在父组件 App
更新时被更新。
首先,创建一个 index.html
文件,其中将渲染 react 应用程序:
-
创建一个名为
index.html
的新文件。 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React.PureComponent</title>
</head>
<body>
<div role="main"></div>
<script src="img/pure-component.js"></script>
</body>
</html>
- 保存文件
然后,按照以下步骤构建 React 应用程序:
-
创建一个名为
pure-component.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个扩展
React.PureComponent
类的Button
类组件:
class Button extends React.PureComponent {
componentDidUpdate() {
console.log('Button Component did update!')
}
render() {
return (
<button>{this.props.children}</button>
)
}
}
- 定义一个扩展
React.Component
类的Text
类组件:
class Text extends React.Component {
componentDidUpdate() {
console.log('Text Component did update!')
}
render() {
return this.props.children
}
}
- 定义一个简单的
App
组件,该组件将渲染两个组件。App
组件在挂载后设置计时器,并且大约每秒更新一次状态:
class App extends React.Component {
state = {
counter: 0,
}
componentDidMount() {
this.intervalId = setInterval(() => {
this.setState(({ counter }) => ({
counter: counter + 1,
}))
}, 1000)
}
componentWillUnmount() {
clearInterval(this.intervalId)
}
render() {
const { counter } = this.state
return (
<React.Fragment>
<h1>Counter: {counter}</h1>
<Text>I'm just a text</Text>
<Button>I'm a button</Button>
</React.Fragment>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要查看之前工作的效果,请执行以下步骤:
- 在项目目录的根目录下打开终端并运行:
npm start
- 然后,在您的网页浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 计数器大约每秒增加一次。打开浏览器中的开发者工具并检查控制台输出。您应该看到以下内容:
[N] Text Component did update!
它是如何工作的...
因为 React.PureComponent
内部实现了 shouldComponentUpdate
生命周期方法,所以它不会更新 Button
组件,因为其 state
或 props
没有改变。然而,它确实更新了 Text
组件,因为 shouldComponentUpdate
默认返回 true
,告诉 React 更新组件,即使其 props 或 state 没有改变。
React 事件处理器
React 的事件系统内部使用一个名为 SyntheticEvent
的包装器来处理原生 HTML DOM 事件,以实现跨浏览器支持。React 事件遵循 W3C 规范,可以在 www.w3.org/TR/DOM-Level-3-Events/
找到。
React 事件名称采用驼峰式命名,而不是 HTML DOM 事件的小写。例如,HTML DOM 事件 onclick
在 React 中会被调用为 onClick
。有关支持事件的完整列表,请访问 React 官方文档关于事件的页面:reactjs.org/docs/events.html
准备工作
在这个菜谱中,你将编写一个组件来查看它是如何定义和工作的。在你开始之前,创建一个包含以下内容的新的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个名为 .babelrc
的 Babel 配置文件,并添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
首先,创建一个 index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React Events Handlers</title>
</head>
<body>
<div role="main"></div>
<script src="img/events.js"></script>
</body>
</html>
- 保存文件
接下来,编写一个组件,定义一个用于 onClick
事件的处理器:
-
创建一个名为
events.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个类组件,该组件将渲染一个
h1
React 元素和一个button
React 元素,每次点击时都会触发onBtnClick
方法:
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
title: 'Untitled',
}
this.onBtnClick = this.onBtnClick.bind(this)
}
onBtnClick() {
this.setState({
title: 'Hello there!',
})
}
render() {
return (
<section>
<h1>{this.state.title}</h1>
<button onClick={this.onBtnClick}>
Click me to change the title
</button>
</section>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要查看应用程序的工作情况,请执行以下步骤:
- 在您的项目目录根目录中打开终端并运行以下命令:
npm start
- 然后,在您的网络浏览器中打开一个新标签页,并转到:
http://localhost:1337/
- 点击按钮以更改标题。
它是如何工作的...
React 事件作为 props
传递给 React 元素。例如,我们传递了 onClick
prop 给 button
React 元素,以及一个回调函数的引用,我们期望当用户点击按钮时调用该函数。
还有更多...
你注意到我们经常使用 bind
吗?当一个方法作为 prop 传递给子组件时,它会失去 this
的上下文,因此绑定到上下文是必要的。以下是一个示例:
class Example {
fn() { return this }
}
const examp = new Example()
const props = examp.fn
const bound = examp.fn.bind(examp)
console.log('1:', typeof examp.fn())
console.log('2:', typeof props())
console.log('3:', typeof bound())
显示的输出将是:
1: object
2: undefined
3: object
即使常量 props
有对 Example
类的 examp
实例的 fn
方法的引用,它也会失去 this
的上下文。这就是为什么绑定允许你保持原始上下文。在 React 中,我们将方法绑定到原始的 this
上下文,以便在将函数向下传递给子组件时使用我们自己的实例方法,例如 setState
。否则,this
的上下文将是 undefined
,函数将失败。
组件的条件渲染
通常在构建复杂的 UI 时,您需要根据接收到的状态或 props 来渲染组件或 React 元素。
React 组件允许在花括号内执行 JavaScript,并且可以使用条件三元运算符来决定渲染哪个组件或 React 元素。例如:
const Meal = ({ timeOfDay }) => (
<span>{timeOfDay === 'noon'
? 'Pizza'
: 'Sandwich'
}</span>
)
这也可以写成:
const Meal = ({ timeOfDay }) => (
<span children={timeOfDay === 'noon'
? 'Pizza'
: 'Sandwich'
} />
)
如果将 "noon"
作为 timeOfDay
属性的值传递,它将生成以下 HTML 内容:
<span>Pizza</span>
或者当 timeOfDay
属性未设置为 "noon"
时:
<span>Sandwich</span>
准备工作
在这个菜谱中,你将构建一个组件,该组件根据给定的条件渲染其子组件之一。首先,创建一个包含以下内容的新的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个名为 .babelrc
的 Babel 配置文件,并添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
编写一个 React 组件,该组件将根据传递给组件的condition
属性来决定显示两个不同的 React 元素中的哪一个。如果条件为真,则显示第一个子元素。否则,应显示第二个子元素。
首先,创建一个index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Conditional Rendering</title>
</head>
<body>
<div role="main"></div>
<script src="img/conditions.js"></script>
</body>
</html>
- 保存文件
然后,创建一个包含 React 应用程序逻辑和组件的新文件:
-
创建一个名为
conditions.js
的新文件 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个名为
Toggle
的功能组件,该组件将接收一个condition
属性,该属性将被评估以确定要渲染哪个 React 元素。它期望接收两个 React 元素作为子元素:
const Toggle = ({ condition, children }) => (
condition
? children[0]
: children[1]
)
- 定义一个名为
App
的类组件,该组件将根据定义的条件渲染一个 React 元素。当按钮被点击时,它将切换color
状态:
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
color: 'blue',
}
this.onClick = this.onClick.bind(this)
}
onClick() {
this.setState(({ color }) => ({
color: (color === 'blue') ? 'lime' : 'blue'
}))
}
render() {
const { color } = this.state
return (
<React.Fragment>
<Toggle condition={color === 'blue'}>
<p style={{ color }}>Blue!</p>
<p style={{ color }}>Lime!</p>
</Toggle>
<button onClick={this.onClick}>
Toggle Colors
</button>
</React.Fragment>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要运行和测试应用程序,请执行以下步骤:
- 在您的项目目录根目录中打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 点击按钮以切换显示哪个 React 元素
它是如何工作的...
因为children
属性可以是一个 React 元素的数组,所以我们可以访问每个单独的 React 元素并决定渲染哪一个。我们使用了condition
属性来评估给定的条件是否为真以渲染第一个 React 元素。否则,如果值为假,则渲染第二个 React 元素。
使用 React 渲染列表
React 允许您以数组的形式将 React 元素或组件的集合作为children
传递。例如:
<ul>
{[
<li key={0}>One</li>,
<li key={1}>Two</li>,
]}
</ul>
React 元素或组件的集合必须提供一个特殊的 props 属性,名为key
。该属性让 React 知道在更新发生时,集合中的哪些元素已更改、移动或从数组中删除:
准备工作
在这个菜谱中,您将构建一个实用组件,该组件将映射数组的每个项到组件的 props 并将它们作为列表渲染。在开始之前,创建一个包含以下内容的新的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件.babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何实现...
创建一个名为MapArray
的 React 组件,该组件将负责将数组的项映射到 React 组件。
首先,创建一个index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Rendering Lists</title>
</head>
<body>
<div role="main"></div>
<script src="img/lists.js"></script>
</body>
</html>
- 保存文件
然后,按照以下步骤构建 React 应用程序:
-
创建一个名为
lists.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个名为
MapArray
的函数组件,它将期望接收三个属性:from
,它期望是一个值数组,mapToProps
,它期望是一个将值映射到属性的回调函数,最后是children
,它将接收一个 React 组件,其中数组的值将被映射到:
const MapArray = ({
from,
mapToProps,
children: Child,
}) => (
<React.Fragment>
{from.map((item) => (
<Child {...mapToProps(item)} />
))}
</React.Fragment>
)
- 定义一个
TodoItem
组件,它期望接收两个属性,done
和label
:
const TodoItem = ({ done, label }) => (
<li>
<input type="checkbox" checked={done} readOnly />
<label>{label}</label>
</li>
)
- 定义一个包含待办列表对象值的数组:
const list = [
{ id: 1, done: true, title: 'Study for Chinese exam' },
{ id: 2, done: false, title: 'Take a shower' },
{ id: 3, done: false, title: 'Finish chapter 6' },
]
- 定义一个回调函数,该函数将映射数组的对象值到
TodoItem
组件的预期属性。将id
属性重命名为key
,将title
属性重命名为label
:
const mapToProps = ({ id: key, done, title: label }) => ({
key,
done,
label,
})
- 定义一个
TodoListApp
组件,该组件将使用MapArray
组件为待办列表数组中的每个项目创建TodoItem
实例:
const TodoListApp = ({ items }) => (
<ol>
<MapArray from={list} mapToProps={mapToProps}>
{TodoItem}
</MapArray>
</ol>
)
- 渲染应用程序:
ReactDOM.render(
<TodoListApp items={list} />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要运行和测试应用程序,请执行以下步骤:
- 在您的项目目录根目录中打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 应显示待办事项列表:
待办事项列表
它是如何工作的...
看看下面的代码:
<ol>
<MapArray from={list} mapToProps={mapToProps}>
{TodoItem}
</MapArray>
</ol>
这基本上与编写相同:
<ol>
<React.Fragment>
{from.map((item) => (
<TodoItem {...mapToProps(item) } />
))}
</React.Fragment>
</ol>
然而,MapArray
作为辅助组件来完成同样的工作,同时使代码更易于阅读。
您注意到 TodoItem
组件只期望两个属性吗?然而,我们还在将项目的 id
作为 key
传递。如果没有传递 key
属性,则在渲染组件时将显示警告。
在 React 中使用表单和输入
与表单相关的元素,如 <input>
和 <textarea>
,通常维护自己的内部状态并根据用户输入进行更新。在 React 中,当使用 React 状态管理 与表单相关的元素 的输入时,它被称为 受控组件。
默认情况下,在 React 中,如果输入的 value
属性未设置,则用户输入可以修改输入的内部状态。但是,如果设置了 value
属性,则输入为只读,并且它期望 React 通过使用 onChange
React 事件来管理用户输入,并使用 React 状态来更新输入状态(如果需要)。例如,此 input
React 元素将渲染为只读:
<input type="text" value="Ms.Huang Jx" />
然而,因为 React 期望找到一个 onChange
事件处理器,所以之前的代码将在控制台输出一个警告消息。为了修复这个问题,我们可以向 onChange
属性提供一个回调函数来处理用户输入:
<input type="text" value="Ms.Huang Jx" onChange={event => null} />
因为用户输入由 React 处理,并且在之前的示例中我们没有更新输入的值,所以输入看起来是只读的。之前的代码类似于只设置一个 readOnly
属性而不是提供一个无用的 onChange
属性。
React 还允许你定义不受控组件,这基本上意味着 React 不会控制输入的更新方式或内容。例如,当使用第三方库来处理输入时,不受控组件有一个名为defaultValue
的属性,它类似于value
属性。然而,它允许输入通过用户输入而不是 React 来控制其内部状态。这意味着具有defaultValue
属性的表单相关元素允许其状态通过用户输入进行修改:
<input type="text" defaultValue="Ms.Huang Jx" />
与使用value
属性相反,你现在可以直接在输入框中输入来更改其值,因为输入的内部状态是可变的。
准备工作
在这个菜谱中,你将构建一个简单的登录表单组件。在开始之前,创建一个包含以下内容的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件.babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行来安装依赖项:
npm install
如何操作...
定义一个名为LoginForm
的类组件,该组件将处理username
输入和password
输入。
首先,创建一个index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件。 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Forms and Inputs</title>
</head>
<body>
<div role="main"></div>
<script src="img/forms.js"></script>
</body>
</html>
- 保存文件
接下来,构建LoginForm
组件,并利用 React受控组件赋予的输入状态控制权,也禁止在username
输入中输入数字:
-
创建一个名为
forms.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个名为
LoginForm
的类组件。在类中,定义一个输入变化的事件处理程序,并检查username
输入的值以禁止输入数字:
class LoginForm extends React.Component {
constructor(props) {
super(props)
this.state = {
username: '',
password: '',
}
this.onChange = this.onChange.bind(this)
}
onChange(event) {
const { name, value } = event.target
this.setState({
[name]: name === 'username'
? value.replace(/d/gi, '')
: value
})
}
render() {
return (
<form>
<input
type="text"
name="username"
placeholder="Username"
value={this.state.username}
onChange={this.onChange}
/>
<input
type="password"
name="password"
placeholder="Password"
value={this.state.password}
onChange={this.onChange}
/>
<pre>
{JSON.stringify(this.state, null, 2)}
</pre>
</form>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<LoginForm />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试它...
要运行和测试应用程序,请执行以下步骤:
- 在你的项目目录根目录下打开终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 尝试在
username
输入中输入一个数字,以查看对数字的验证是如何工作的。
它是如何工作的...
我们定义了一个onChange
事件处理程序,它在两个输入元素中使用。然而,我们检查输入的名称是否为username
以决定是否应用验证。使用RegExp
测试输入中的数字并将它们替换为空字符串。这就是为什么在username
输入时不会显示数字的原因。
理解引用及其使用方法
在常规工作流程中,React 组件通过传递props
与其子组件进行通信。然而,在某些情况下,需要访问子组件的实例以进行通信或修改其行为。React 使用refs
来允许我们访问子组件的实例。
重要的是要理解,React 组件的实例为您提供对其实例方法和属性的访问。然而,React 元素的实例是一个 HTML DOM 元素的实例。通过给 React 组件或 React 元素一个 ref
属性来访问引用。它期望值是一个回调函数,该函数将在实例创建时被调用,并提供一个引用作为回调函数的第一个参数传递给实例。
React 提供了一个名为 createRef
的辅助函数,用于定义设置引用的正确函数回调。例如,以下代码获取了 React 组件和 React 元素的引用:
class Span extends React.Component {
render() {
return <span>{this.props.children}</span>
}
}
class App extends React.Component {
rf1 = React.createRef()
rf2 = React.createRef()
componentDidMount() {
const { rf1, rf2 } = this
console.log(rf1.current instanceof HTMLSpanElement)
console.log(rf2.current instanceof Span)
}
render() {
return (
<React.Fragment>
<span ref={this.rf1} />
<Span ref={this.rf2} />
</React.Fragment>
)
}
}
在这个例子中,控制台将输出 true
两次:
true // rf1.current instanceof HTMLSpanElement
true // rf2.current instanceof Span
这证明了我们刚刚学到的。
函数组件没有 refs
。因此,将 ref
属性赋予函数组件将在控制台显示警告并失败。
引用在以下情况下特别有用,用于处理 未受控组件:
-
与第三方库的集成
-
访问 HTML DOM 元素的本地方法,这些方法在其他情况下无法从 React 访问,例如
HTMLElement.focus()
方法 -
使用某些网络 API,例如选择网络 API、网络动画 API 和媒体播放方法
准备工作
在这个菜谱中,你将处理未受控组件,并使用引用向表单 HTML 元素发送一个自定义事件。在你开始之前,创建一个包含以下内容的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 Babel 配置文件 .babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过在终端中运行以下命令安装依赖项:
npm install
如何做到这一点...
定义一个 LoginForm
类组件,该组件将渲染一个包含两个输入的表单:一个用于用户名,另一个用于密码。在表单 React 元素外部包含一个按钮,该按钮将用于在表单 React 元素上触发 onSubmit
事件。
首先,创建一个 index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Refs</title>
</head>
<body>
<div role="main"></div>
<script src="img/refs.js"></script>
</body>
</html>
- 保存文件
现在,开始构建 React 应用程序:
-
创建一个名为
refs.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个名为
LoginForm
的类组件,该组件将渲染表单和一个按钮,当点击按钮时,将使用refs
触发onSubmit
表单事件:
class LoginForm extends React.Component {
refForm = React.createRef()
constructor(props) {
super(props)
this.state = {}
this.onSubmit = this.onSubmit.bind(this)
this.onClick = this.onClick.bind(this)
}
onSubmit(event) {
const form = this.refForm.current
const data = new FormData(form)
this.setState({
user: data.get('user'),
pass: data.get('pass'),
})
event.preventDefault()
}
onClick(event) {
const form = this.refForm.current
form.dispatchEvent(new Event('submit'))
}
render() {
const { onSubmit, onClick, refForm, state } = this
return (
<React.Fragment>
<form onSubmit={onSubmit} ref={refForm}>
<input type="text" name="user" />
<input type="text" name="pass" />
</form>
<button onClick={onClick}>LogIn</button>
<pre>{JSON.stringify(state, null, 2)}</pre>
</React.Fragment>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<LoginForm />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试它...
要运行和测试应用程序,请执行以下步骤:
- 在项目目录的根目录中打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页,并转到:
http://localhost:1337/
它是如何工作的...
-
点击
LogIn
按钮以测试表单onSubmit
事件是否被触发。 -
首先,将表单 DOM 元素的实例引用保存在一个名为
reform
的实例属性中。 -
然后,一旦按钮提交,我们使用
EventTarget
网络 API 的dispatchEvent
方法在表单 DOM 元素上触发一个自定义事件submit
。 -
然后,分发的
submit
方法被 React 的SyntheticEvent
捕获。 -
最后,React 触发传递给表单的
onSubmit
属性的回调方法。
理解 React 端口
React 端口允许我们在父组件生成的 DOM 树之外的不同 DOM 元素中渲染子组件,同时保持 React 树仿佛该组件位于父组件生成的 DOM 树内部。例如,即使子组件位于不同的 DOM 节点中,子组件中生成的事件也会冒泡到 React 父组件。
React 端口是通过 ReactDOM 库的 createPortal
方法创建的,它具有与 render
方法相同的签名:
ReactDOM.createPortal(
ReactComponent,
DOMNode,
)
然而,render
和 createPortal
之间的区别在于,后者返回一个特殊标签,该标签用于在 React 树中标识此元素为 React 端口,并像使用 React 元素一样使用它。例如:
<article>
{ReactDOM.createPortal(
<h1>Example</h1>,
document.querySelector('[id="heading"]'),
)}
</article>
准备工作
在开始之前,创建一个包含以下内容的 package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 .babelrc
的 babel 配置文件,并添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何操作...
首先,创建一个 index.html
文件,其中将渲染 React 应用程序,同时包含一个 HTML header
标签,其中将渲染 React 端口元素:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Portals</title>
</head>
<body>
<header id="heading"></header>
<div role="main"></div>
<script src="img/portals.js"></script>
</body>
</html>
- 保存文件
接下来,构建一个将渲染一个段落和一个 h1
HTML 元素到 header
HTML 元素之外的 React 应用程序:
-
创建一个名为
portals.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个名为
Header
的函数组件,并创建一个端口以将children
渲染到不同的 DOM 元素:
const Header = () => ReactDOM.createPortal(
<h1>React Portals</h1>,
document.querySelector('[id="heading"]'),
)
- 定义一个名为
App
的函数组件,该组件将渲染一个 React 元素和Header
React 组件:
const App = () => (
<React.Fragment>
<p>Hello World!</p>
<Header />
</React.Fragment>
)
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要运行和测试应用程序,请执行以下步骤:
- 在您的项目目录根目录中打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页,并转到:
http://localhost:1337/
- 生成的 HTML DOM 树将类似于以下内容:
<header id="heading">
<h1>React Portals</h1>
</header>
<section role="main">
<p>Hello World!</p>
</section>
它是如何工作的...
即使在 React 树中,Header
组件看起来是在 p
HTML 标签之后渲染的,但实际上渲染的 Header
组件是在它之前。这是因为 Header
组件实际上是在一个出现在主应用程序渲染的 section
HTML 标签之前的 header
HTML 标签上渲染的。
使用错误边界组件捕获错误
错误边界组件是实现了 componentDidCatch
生命周期方法的 React 组件,用于捕获其子组件中的错误。它们在类组件初始化失败时捕获 constructor
方法中的错误,在生命周期方法中,以及在渲染过程中。无法捕获的错误来自异步代码、事件处理程序,以及错误边界组件本身的错误。
componentDidCatch
生命周期方法接收两个参数:第一个是一个error
对象,而第二个接收的参数是一个包含componentStack
属性的对象,该属性具有友好的堆栈跟踪,描述了组件在 React 树中失败的位置。
准备工作
在这个菜谱中,您将构建一个错误边界组件,并在渲染时出现错误时提供一个回退 UI。在开始之前,创建一个包含以下内容的新的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"babel-core": "6.26.3",
"parcel-bundler": "1.8.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 babel 配置文件.babelrc
,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做...
首先,创建一个index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Catching Errors</title>
</head>
<body>
<div role="main"></div>
<script src="img/error-boundary.js"></script>
</body>
</html>
- 保存文件
接下来,定义一个错误边界组件,它将捕获错误并在渲染时显示回退 UI,显示错误发生的位置和错误消息。同时定义一个App
组件并创建一个button
React 元素,当点击按钮时将导致应用程序失败并设置状态:
-
创建一个名为
error-boundary.js
的新文件。 -
导入 React 和 ReactDOM 库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
- 定义一个
ErrorBoundary
组件,当应用程序渲染失败时将显示回退消息:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = {
hasError: false,
message: null,
where: null,
}
}
componentDidCatch(error, info) {
this.setState({
hasError: true,
message: error.message,
where: info.componentStack,
})
}
render() {
const { hasError, message, where } = this.state
return (hasError
? <details style={{ whiteSpace: 'pre-wrap' }}>
<summary>{message}</summary>
<p>{where}</p>
</details>
: this.props.children
)
}
}
- 定义一个名为
App
的类组件,它将渲染一个button
React 元素。一旦按钮被点击,它将故意抛出一个错误:
class App extends React.Component {
constructor(props) {
super(props)
this.onClick = this.onClick.bind(this)
}
onClick() {
this.setState(() => {
throw new Error('Error while setting state.')
})
}
render() {
return (
<button onClick={this.onClick}>
Buggy button!
</button>
)
}
}
- 在
ErrorBoundary
组件内包装App
以渲染应用程序:
ReactDOM.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要运行和测试应用程序,请执行以下步骤:
- 在项目目录的根目录中打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
-
点击
button
以导致应用程序失败 -
显示以下错误的回退 UI:
Error while setting state.
in App
in ErrorBoundary
使用 PropTypes 检查属性类型
React 允许您实现组件属性的运行时类型检查。这有助于捕获错误并确保您的组件正确接收props
。这可以通过只需在您的组件上设置静态propType
属性轻松完成。例如:
class MyComponent extends React.Component {
static propTypes = {
children: propTypes.string.isRequired,
}
render() {
return<span>{this.props.children}</span>
}
}
之前的代码将需要MyComponent
的children
属性是一个string
。否则,如果提供了不同类型的属性,React 将在控制台显示警告。
propTypes
方法是在组件实例创建时被触发的函数,用于检查给定的props
是否与propTypes
模式匹配。
propTypes
具有一个广泛的方法列表,可用于验证属性。您可以在 React 官方文档中找到完整的列表:reactjs.org/docs/typechecking-with-proptypes.html
。
准备工作
在这个菜谱中,您将看到并编写用于检查属性类型的自定义验证规则。在开始之前,创建一个包含以下内容的新的package.json
文件:
{
"scripts": {
"start": "parcel serve -p 1337 index.html"
},
"devDependencies": {
"babel-core": "6.26.3",
"babel-plugin-transform-class-properties": "6.24.1",
"babel-preset-env": "1.6.1",
"babel-preset-react": "6.24.1",
"parcel-bundler": "1.8.1",
"prop-types": "15.6.1",
"react": "16.3.2",
"react-dom": "16.3.2"
}
}
接下来,创建一个 .babelrc
的 babel 配置文件,添加以下内容:
{
"presets": ["env","react"],
"plugins": ["transform-class-properties"]
}
然后,通过打开终端并运行以下命令来安装依赖项:
npm install
如何做到这一点...
首先,创建一个 index.html
文件,React 应用程序将在其中渲染:
-
创建一个名为
index.html
的新文件。 -
添加以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Type Checking</title>
</head>
<body>
<div role="main"></div>
<script src="img/type-checking.js"></script>
</body>
</html>
- 保存文件。
接下来,定义一个期望接收两个 React 元素作为 children
的 Toggle
类组件。使用 PropTypes
创建一个自定义验证规则来检查 children
属性是否是 React 元素的数组,并且组件接收了正好两个 React 元素:
-
创建一个名为
type-checking.js
的新文件。 -
导入 React、ReactDOM 和
PropTypes
库:
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import * as propTypes from 'prop-types'
- 定义一个名为
Toggle
的类组件。使用propTypes
对condition
和children
属性进行类型检查。使用自定义propType
来检查children
是否是 React 元素的数组,并且它包含正好两个 React 元素:
class Toggle extends React.Component {
static propTypes = {
condition: propTypes.any.isRequired,
children: (props, propName, componentName) => {
const customPropTypes = {
children: propTypes
.arrayOf(propTypes.element)
.isRequired
}
const isArrayOfElements = propTypes
.checkPropTypes(
customPropTypes,
props,
propName,
componentName,
)
const children = props[propName]
const count = React.Children.count(children)
if (isArrayOfElements instanceof Error) {
return isArrayOfElements
} else if (count !== 2) {
return new Error(
`"${componentName}"` +
` expected ${propName}` +
` to contain exactly 2 React elements`
)
}
}
}
render() {
const { condition, children } = this.props
return condition ? children[0] : children[1]
}
}
- 定义一个名为
App
的类组件,它将渲染Toggle
组件。提供三个 React 元素作为其children
,以及一个button
,当点击时,将状态中的value
属性从true
切换到false
,反之亦然:
class App extends React.Component {
constructor(props) {
super(props)
this.state = { value: false }
this.onClick = this.onClick.bind(this)
}
onClick() {
this.setState(({ value }) => ({
value: !value,
}))
}
render() {
const { value } = this.state
return (
<React.Fragment>
<Toggle condition={value}>
<p style={{ color: 'blue' }}>Blue!</p>
<p style={{ color: 'lime' }}>Lime!</p>
<p style={{ color: 'pink' }}>Pink!</p>
</Toggle>
<button onClick={this.onClick}>
Toggle Colors
</button>
</React.Fragment>
)
}
}
- 渲染应用程序:
ReactDOM.render(
<App />,
document.querySelector('[role="main"]'),
)
- 保存文件。
让我们测试一下...
要运行和测试应用程序,请执行以下步骤:
- 在你的项目目录根目录打开一个终端并运行:
npm start
- 然后,在您的网络浏览器中打开一个新标签页并转到:
http://localhost:1337/
- 浏览器中的控制台将显示以下警告:
Warning: Failed prop type: "Toggle" expected children to contain exactly 2 React elements
in Toggle (created by App)
in App
- 点击
button
将在第一个和第二个 React 元素之间切换,而第三个 React 元素将被忽略。
如何工作...
我们为 children
属性定义了一个自定义函数验证器。在函数内部,我们首先使用内置的 propTypes
函数来检查 children
是否是 React 元素的数组。如果验证的结果不是一个 Error
实例,那么我们使用 React Children
的 count
工具方法来知道提供了多少个 React 元素,如果 children
中的 React 元素数量不是 2
,则返回一个错误。
更多内容...
你注意到我们使用了 propTypes.checkPropTypes
方法吗?这是一个实用函数,它允许我们在 React 之外检查 propTypes
。例如:
const pTypes = {
name: propTypes.string.isRequired,
age: propTypes.number.isRequired,
}
const props = {
name: 'Huang Jx',
age: 20,
}
propTypes.checkPropTypes(pTypes, props, 'property', 'props')
pTypes
对象作为一个模式提供来自 propTypes
的验证函数。props
常量只是一个包含在 pTypes
中定义的属性的普通对象。
运行前面的示例在控制台不会输出任何警告,因为 props
中的所有属性都是有效的。然而,将 props
对象更改为:
const props = {
name: 20,
age: 'Huang Jx',
}
然后,我们将在控制台输出中看到以下警告:
Warning: Failed property type: Invalid property `name` of type `number` supplied to `props`, expected `string`.
Warning: Failed property type: Invalid property `age` of type `string` supplied to `props`, expected `number`.
checkPropTypes
工具方法具有以下签名:
checkPropTypes(typeSpecs, values, location, componentName, getStack)
在这里,typeSpecs
指的是一个包含 propTypes
函数验证器的对象。values
参数期望接收一个对象,其值需要与 typeSpecs
进行验证。componentName
指的是源名称,这通常是一个在警告消息中用于显示错误起源位置组件的名称。最后一个参数 getStack
是可选的,它期望是一个回调函数,该函数应返回一个 Stack Trace
,并将其添加到警告消息的末尾,以更好地描述错误的确切起源位置。
propTypes
仅在开发中使用,并且在使用 React 的生产构建时,你必须设置打包器将 process.env.NODE_ENV
替换为 "production"
。这样,你的应用程序的生产构建中会移除 propTypes
。