NodeJS-移动应用开发学习手册-全-

NodeJS 移动应用开发学习手册(全)

原文:zh.annas-archive.org/md5/4B062FCE9E3A0F235CC690D228FCDE03

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

MERN 堆栈可以被视为共享一个共同点的一组工具,即语言 JavaScript。本书以食谱的形式探讨了如何使用 MERN 堆栈构建遵循 MVC 架构模式的 Web 客户端和服务器应用程序。

MVC 架构模式的模型和控制器由关于使用 ExpressJS 和 Mongoose 构建 RESTful API 的章节涵盖。这些章节涵盖了关于 HTTP 协议、方法类型、状态码、URL、REST 和 CRUD 操作的核心概念。之后,它转向了特定于 ExpressJS 的主题,如请求处理程序、中间件和安全性,以及关于 Mongoose 的特定主题,如模式、模型和自定义验证。

MVC 架构模式的视图由关于 ReactJS 的章节涵盖。ReactJS 是一个基于组件的 UI 库,具有声明性 API。本书旨在提供构建 ReactJS Web 应用程序和组件的基本知识。除了 ReactJS,本书还包含了一个关于 Redux 的整个章节,从核心概念和原则到存储增强器、时间旅行和异步数据流等高级功能的解释。

此外,本书还涵盖了使用 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)

  • 最好至少 1GB RAM 和 1.6GHz 处理器或更快

下载示例代码文件

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

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

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

  2. 选择“支持”选项卡。

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

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

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

  • Windows 的 WinRAR/7-Zip

  • Mac 的 Zipeg/iZip/UnRarX

  • Linux 的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/MERN-Quick-Start-Guide。如果代码有更新,将在现有的 GitHub 存储库上更新。

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

下载彩色图片

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

实战代码

访问以下链接查看代码运行的视频:

goo.gl/ymdYBT

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。 例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。"

代码块设置如下:

 { 
        "dependencies": { 
          "express": "4.16.3", 
          "node-fetch": "2.1.1", 
          "uuid": "3.2.1" 
        } 
      } 

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

npm install

粗体:表示新术语、重要单词或屏幕上看到的单词。 例如,菜单或对话框中的单词会以这种形式出现在文本中。 例如:"从管理面板中选择系统信息。"

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

提示和技巧会出现在这样的形式中。

章节

在本书中,您会发现一些经常出现的标题(准备工作如何做...让我们测试一下...它是如何工作的...还有更多...参见)。

为了清晰地说明如何完成一个食谱,使用以下章节:

准备工作

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

如何做...

本节包含了遵循该食谱所需的步骤。

让我们测试一下...

本节包括有关如何测试如何做...部分中给出的代码的详细步骤。

它是如何工作的...

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

还有更多...

本节包括有关食谱的其他信息,以使您对食谱更加了解。

参见

本节为食谱提供了其他有用信息的链接。

第一章: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

查看以下视频以查看代码的运行情况:

goo.gl/1zwc6F

介绍

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 应用程序中信息的内部表示:

  • 模型:管理应用程序的业务逻辑,确定数据应该如何存储、创建和修改

  • 视图:数据或信息的任何可视表示

  • 控制器:解释用户生成的事件并将其转换为命令,以便模型和视图相应地更新:

关注点分离SoC)设计模式将前端与后端代码分开。遵循 MVC 架构模式,开发人员能够遵循关注点分离设计模式,从而实现一致和可管理的应用程序结构。

以下章节中的食谱实现了这种架构模式,以分离前端和后端。

安装和配置 MongoDB

官方的 MongoDB 网站提供了包含二进制文件的最新软件包,可用于在 Linux、OS X 和 Windows 上安装 MongoDB。

准备就绪

访问 MongoDB 的官方网站www.mongodb.com/download-center,选择 Community Server,然后选择您首选的软件操作系统版本并下载。

安装 MongoDB 并进行配置可能需要额外的步骤。

如何做...

访问 MongoDB 的文档网站docs.mongodb.com/master/installation/获取说明,并在教程部分检查您特定平台的内容。

安装后,可以以独立方式启动MongoDB-的守护进程mongod-的实例:

  1. 打开一个新的终端

  2. 创建一个名为data的新目录,其中包含 Mongo 数据库

  3. 输入mongod --port 27017 --dbpath /data/启动一个新实例并创建一个数据库

  4. 打开另一个终端

  5. 输入mongo --port 27017连接到 Mongo shell 实例

还有更多...

作为替代方案,您可以选择使用数据库即服务DBaaS)如 MongoDB Atlas,它在撰写本文时允许您创建一个带有 512MB 存储空间的免费集群。另一个简单的选择是 mLab,尽管还有许多其他选择。

安装 Node.js

官方 Node.js 网站提供了包含 LTS 和 Current(包含最新功能)二进制文件的两个包,可用于在 Linux、OS X 和 Windows 上安装 Node.js。

准备工作

为了本书的目的,我们将安装 Node.js v10.1.x。

如何做...

要下载最新版本的 Node.js:

  1. 访问官方网站nodejs.org/en/download/

  2. 选择当前|最新功能

  3. 选择您喜欢的平台或操作系统OS)的二进制文件

  4. 下载并安装

如果您喜欢通过包管理器安装 Node.js,请访问nodejs.org/en/download/package-manager/并选择您喜欢的平台或操作系统。

安装 npm 包

Node.js 的安装包括一个名为npm的包管理器,它是默认和最广泛使用的用于安装 JavaScript/Node.js 库的包管理器。

NPM 包列在 NPM 注册表registry.npmjs.org/中,您可以在其中搜索包,甚至发布您自己的包。

还有其他 NPM 的替代方案,如 Yarn,它与公共 NPM 注册表兼容。您可以自由选择您喜欢的包管理器;但是,为了本书的目的,配方中使用的包管理器将是 NPM。

准备工作

NPM 期望在您的project文件夹的根目录中找到一个package.json文件。这是一个描述项目细节的配置文件,例如其依赖关系、项目名称和项目作者。

在您能够在项目中安装任何包之前,您必须创建一个package.json文件。以下是您通常会采取的创建项目的步骤:

  1. 在您喜欢的位置创建一个新的project文件夹,然后将其命名为mern-cookbook或者您自己选择的其他名称。

  2. 打开一个新的终端。

  3. 更改当前目录到您刚刚创建的新文件夹。通常使用终端中的cd命令来完成。

  4. 运行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 为您的项目安装新的包。

如何做...

  1. 打开一个新的终端

  2. 将当前目录更改为您新创建的project文件夹所在的位置

  3. 运行以下命令来安装chalk包:

      npm --save-exact install chalk

现在,您将能够在 Node.js 中使用require来使用包。按照以下步骤来查看如何使用它:

  1. 创建一个名为index.js的新文件,并添加以下代码:
      const chalk = require('chalk') 
      const { red, blue } = chalk 
      console.log(red('hello'), blue('world!')) 
  1. 然后,打开一个新的终端并运行以下命令:
 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

查看以下视频以查看代码的运行情况:

goo.gl/xXhqWK

介绍

ExpressJS 是首选的 Node.js web 应用程序框架,用于构建强大的 Web 应用程序和 API。

在本章中,配方将专注于构建一个完全功能的 Web 服务器并理解核心基础知识。

在 ExpressJS 中进行路由

路由是指应用程序在通过 HTTP 动词或 HTTP 方法请求资源时如何响应或操作的。

HTTP代表超文本传输协议,它是万维网WWW)数据通信的基础。WWW 中的所有文档和数据都由统一资源定位符URL)标识。

HTTP 动词或 HTTP 方法是客户端-服务器模型。通常,Web 浏览器充当客户端,在我们的情况下,ExpressJS 是允许我们创建一个能够理解这些请求的服务器的框架。每个请求都期望发送一个响应给客户端,以识别所请求资源的状态。

请求方法可以是:

  • 安全:在服务器上执行只读操作的 HTTP 动词。换句话说,它不会改变服务器状态。例如:GET

  • 幂等:当发送相同的请求时,具有相同效果的 HTTP 动词。例如,发送PUT请求以修改用户的名字,如果正确实现,应在发送多个相同请求时对服务器产生相同的效果。所有安全方法也是幂等的。例如,GETPUTDELETE方法都是幂等的。

  • 可缓存:可以缓存的 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对象有多种可用于向客户端发送状态代码和数据的方法。例如:response.status(200).send('Some Data!')

如何做...

RequestResponse对象作为参数传递给route方法内定义的路由处理程序

路由方法

这些派生自 HTTP 动词或 HTTP 方法。路由方法用于定义应用程序对特定 HTTP 动词的响应。

ExpressJS 路由方法的名称与 HTTP 动词相对应。例如:app.get()对应于GET HTTP 动词,或者app.delete()对应于DELETE HTTP 动词。

一个非常基本的路由可以写成以下形式:

  1. 创建一个名为1-basic-route.js的新文件

  2. 首先包括 ExpressJS 库,然后初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 添加一个新的路由方法来处理路径"/"的请求。第一个参数指定路径或 URL,下一个参数是路由处理程序。在路由处理程序内部,让我们使用response对象发送状态码200(OK)和文本给客户端:
      app.get('/', (request, response, nextHandler) => { 
          response.status(200).send('Hello from ExpressJS') 
      }) 
  1. 最后,使用listen方法在端口1337上接受新连接:
      app.listen( 
         1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行以下命令:

 node 1-basic-route.js 
  1. 在浏览器中打开一个新标签,并访问端口1337上的localhost以查看结果:
      http://localhost:1337/

有关 ExpressJS 支持的 HTTP 方法的更多信息,请访问官方 ExpressJS 网站expressjs.com/en/guide/routing.html#route-methods

路由处理程序

路由处理程序是回调函数,接受三个参数。第一个是request对象,第二个是response对象,最后一个是callback,它将处理程序传递给链中的下一个请求处理程序。也可以在路由方法内使用多个callback函数。

让我们看一个如何在路由方法内编写路由处理程序的工作示例:

  1. 创建一个名为2-route-handlers.js的新文件

  2. 包括 ExpressJS 库,然后初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 添加两个路由方法来处理相同路径"/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!') 
      }) 
  1. 添加一个route方法来处理路径"/two"上的请求。在route方法内定义了两个路由处理程序来处理相同的请求:
      app.get('/two', 
          (request, response, nextHandler) => { 
             response.type('text/plain') 
             response.write('Hello ') 
             nextHandler() 
         }, 
          (request, response, nextHandler) => { 
             response.status(200).end('Moon!') 
         } 
      ) 
  1. 使用listen方法在端口1337上接受新连接:
      app.listen( 
         1337, 
         () => console.log('Web Server running on port 1337'), 
     ) 
  1. 保存文件

  2. 打开终端并运行:

    node 2-route-handlers.js  
  1. 要查看结果,请在浏览器中打开一个新标签并访问:
 http://localhost:1337/one http://localhost:1337/two  

可链接的路由方法

使用app.route(path)可以使路由方法可链接,因为path是为单个位置指定的。这可能是处理多个路由方法时最好的方法,因为除了使代码更易读且不太容易出现拼写错误和冗余之外,还允许同时处理多个路由方法。

  1. 创建一个名为3-chainable-routes.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 使用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!') 
      }) 
  1. 使用listen方法在端口1337上接受新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node 3-chainable-routes.js
  1. 要查看结果,请在浏览器中打开一个新标签并访问:
      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。

  1. 创建一个名为modular-router.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 为你的迷你应用程序定义一个路由器,并添加一个请求方法来处理"/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}`) 
      }) 
  1. 将你的模块化迷你应用程序挂载到"/first"路径和"/second"路径:
      app.use('/first', miniapp) 
      app.use('/second', miniapp) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行以下命令:

 node modular-router.js
  1. 要查看结果,请在 Web 浏览器中导航到:
      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。

编写中间件函数

中间件函数主要用于对requestresponse对象进行更改。它们按顺序执行,但如果一个中间件函数不将控制传递给下一个中间件函数,请求将被搁置。

准备工作

中间件函数具有以下签名:

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存在时:

  1. 创建一个名为middleware-functions.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 编写一个中间件函数,将属性allowed添加到request对象:
      app.use((request, response, next) => { 
          request.allowed = Reflect.has(request.query, 'allowme') 
          next() 
      }) 
  1. 添加一个请求方法来处理"/"路径的请求:
      app.get('/', (request, response, next) => { 
          if (request.allowed) { 
              response.send('Hello secret world!') 
          } else { 
              response.send('You are not allowed to enter') 
          } 
      }) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node middleware-functions.js

  1. 要查看结果,请在 Web 浏览器中导航到:
http://localhost:1337/
      http://localhost:1337/?allowme

工作原理...

就像路由处理程序一样,中间件函数需要将控制权传递给下一个处理程序;否则,我们的应用程序将一直挂起,因为没有向客户端发送数据,连接也没有关闭。

如果在中间件函数内向requestresponse对象添加了新属性,则下一个处理程序将可以访问这些新属性。就像我们之前编写的代码中,request对象中的allowed property对下一个处理程序是可用的。

编写可配置的中间件函数

编写中间件函数的常见模式是将中间件函数包装在另一个函数中。这样做的结果是可配置的中间件函数。它们也是高阶函数,即返回另一个函数的函数。

const fn = (options) => (response, request, next) => {  
    next()  
} 

通常会将对象用作options参数。但是,没有什么能阻止您按照自己的方式进行操作。

准备工作

在这个示例中,您将编写一个可配置的日志记录器中间件函数。在开始之前,请创建一个包含以下内容的新package.json文件:

{ 
    "dependencies": { 
        "express": "4.16.3" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

    npm install

操作步骤...

您的可配置中间件函数将执行的操作很简单:当发出请求时,它将打印状态代码和 URL。

  1. 创建一个名为middleware-logger.js的新文件

  2. 导出一个接受对象作为第一个参数的函数。该函数期望对象具有一个名为enable的属性,该属性可以是truefalse

      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 
  1. 保存文件

让我们来测试一下...

我们的可配置中间件函数本身并不实用。创建一个简单的 ExpressJS 应用程序来查看我们的中间件实际工作:

  1. 创建一个名为configurable-middleware-test.js的新文件

  2. 包含我们的middleware-logger.js模块并初始化一个新的 ExpressJS 应用程序:

       const express = require('express') 
       const loggerMiddleware = require('./middleware-logger') 
       const app = express() 
  1. 使用use方法包含我们的可配置中间件函数。当enable属性设置为true时,您的日志记录器将工作,并将每个请求的状态代码和 URL 记录到终端:
      app.use(loggerMiddleware({ 
         enable: true, 
      })) 
  1. 监听端口1337以获取新的连接:
      app.listen( 
           1337, 
           () => console.log('Web Server running on port 1337'), 
         ) 
  1. 保存文件

  2. 打开终端并运行:

 node middleware-logger-test.js
  1. 在浏览器中导航到:
      http://localhost:1337/hello?world
  1. 终端应显示:
 Status Code: 200 URL: /hello?world

还有更多...

如果您想尝试,可以将可配置中间件测试应用程序的enable属性设置为false。不应显示任何日志。

通常,您会希望在生产环境中禁用日志记录,因为此操作可能会影响性能。

禁用所有日志记录的替代方法是使用其他库来执行此任务,而不是使用console。有一些库允许您设置不同级别的日志记录,例如:

日志记录有几个原因很有用。主要原因是:

  • 它检查您的服务是否正常运行,例如,检查您的应用程序是否连接到 MongoDB。

  • 它可以发现错误和漏洞。

  • 它有助于更好地了解应用程序的工作原理。例如,如果您有一个模块化应用程序,您可以看到它在包含在其他应用程序中时是如何集成的。

编写路由器级中间件函数

路由级中间件函数只在路由器内执行。它们通常在仅将中间件应用于挂载点或特定路径时使用。

准备工作

在这个示例中,您将创建一个小型的日志记录器路由器级中间件函数,它将仅记录挂载或位于路由器挂载路径中的路径的请求。在开始之前,请创建一个包含以下内容的新package.json文件:

{ 
    "dependencies": { 
        "express": "4.16.3" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

npm install

操作步骤...

  1. 创建一个名为router-level.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序并定义一个路由器:

      const express = require('express') 
      const app = express() 
      const router = express.Router() 
  1. 定义我们的日志记录中间件函数:
      router.use((request, response, next) => { 
          console.log('URL:', request.originalUrl) 
          next() 
      }) 
  1. 将路由器挂载到路径"/router"
      app.use('/router', router) 
  1. 监听端口1337以获取新的连接:
     app.listen( 
         1337, 
       () => console.log('Web Server running on port 1337'), 
    ) 
  1. 保存文件

  2. 打开终端并运行:

 node router-level.js
  1. 在您的网络浏览器中导航到:
 http://localhost:1337/router/example 
  1. 终端应显示:
 URL: /router/example
  1. 然后,在您的网络浏览器中导航到:
      http://localhost:1337/example
  1. 终端中不应显示任何日志

还有更多...

通过调用next('router')可以将控制权传递回到路由器之外的下一个中间件函数或路由方法。

router.use((request, response, next) => { 
  next('route') 
}) 

例如,通过创建一个期望接收用户 ID 作为查询参数的路由器。当未提供用户 ID 时,可以使用next('router')函数来退出路由器或将控制权传递给路由器之外的下一个中间件函数。路由器之外的下一个中间件函数可以用来在路由器将控制权传递给它时显示其他信息。例如:

  1. 创建一个名为router-level-control.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 定义一个新的路由器:
      const router = express.Router() 
  1. 在路由器内部定义我们的日志中间件函数:
      router.use((request, response, next) => { 
         if (!request.query.id) { 
             next('router') // Next, out of Router 
          } else { 
            next() // Next, in Router 
          } 
      }) 
  1. 添加一个路由方法来处理"/"路径的GET请求,只有在中间件函数将控制权传递给它时才会执行:
       router.get('/', (request, response, next) => { 
         const id = request.query.id 
         response.send(`You specified a user ID => ${id}`) 
      }) 
  1. 在路由器之外添加一个路由方法来处理"/"路径的GET请求。但是,将路由器作为第二个参数包含在路由处理程序中,并添加另一个路由处理程序来处理相同的请求,只有当路由器将控制权传递给它时才会执行:
      app.get('/', router, (request, response, next) => { 
          response 
            .status(400) 
            .send('A user ID needs to be specified') 
    }) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node router-level-control.js
  1. 要查看结果,请在浏览器中导航到:
 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

如何做...

您将构建一个自定义的错误处理程序,将错误消息发送给客户端。

  1. 创建一个名为custom-error-handler.js的新文件

  2. 包含 ExpressJS 库,然后初始化一个新的 ExpressJS 应用程序:

     const express = require('express') 
     const app = express() 
  1. 定义一个新的路由方法来处理"/"路径的GET请求,并且每次都抛出一个错误:
      app.get('/', (request, response, next) => { 
          try { 
             throw new Error('Oh no!, something went wrong!') 
          } catch (err) { 
             next(err) 
           } 
      }) 
  1. 定义一个自定义错误处理程序中间件函数,将错误消息发送回客户端的浏览器:
      app.use((error, request, response, next) => { 
          response.end(error.message) 
      }) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node custom-error-handler.js
  1. 要查看结果,请在您的网络浏览器中导航到:
      http://localhost:1337/

使用 ExpressJS 内置的中间件函数来提供静态资产

在 ExpressJS 的 4.x 版本之前,它依赖于 ConnectJS,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.staticserve-static之间的主要区别是第二个可以在 ExpressJS 之外使用。

准备就绪

在这个示例中,您将看到如何构建一个 Web 应用程序,该应用程序将在特定路径中提供静态资产。在开始之前,创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "express": "4.16.3" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做...

  1. 创建一个名为public的新目录

  2. 进入新的public目录

  3. 创建一个名为index.html的新文件

  4. 添加以下代码:

      <!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> 
  1. 保存文件

  2. public目录中导航回去

  3. 创建一个名为serve-static-assets.js的新文件

  4. 添加以下代码。初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const path = require('path') 
      const app = express() 
  1. 包括express.static可配置的中间件函数,并传递/public目录的路径,其中index.html文件位于其中:
      const publicDir = path.join(__dirname, './public') 
      app.use('/', express.static(publicDir)) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node serve-static-assets.js
  1. 要查看结果,在浏览器中导航到:
      http://localhost:1337/index.html

它是如何工作的...

我们的index.html文件将被显示,因为我们指定了"/"作为查找资产的根目录。

尝试将路径从"/"更改为"/public"。然后,您将能够看到index.html文件和其他要包含在/public目录中的文件,可以在http://localhost:1337/public/[fileName]下访问。

还有更多...

假设您有一个大型项目,其中包含数十个静态文件,包括图像、字体文件和 PDF 文档(涉及隐私和法律事务等)。您决定要将它们保存在单独的文件中,但又不想更改挂载路径或 URI。它们可以在/public下提供,但它们将存在于项目目录中的单独目录中:

首先,让我们创建第一个包含一个名为index.html的单个文件的public目录:

  1. 如果您在上一个示例中没有创建public目录,请创建一个名为public的新目录

  2. 进入新的public目录

  3. 创建一个名为index.html的新文件

  4. 添加以下代码:

      <!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> 
  1. 保存文件

现在,让我们创建一个包含另一个名为second.html的文件的第二个公共目录:

  1. public目录中移出

  2. 创建一个名为another-public的新目录

  3. 进入新的another-public目录

  4. 创建一个名为second.html的新空文件

  5. 添加以下代码:

      <!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> 
  1. 保存文件

正如你所看到的,这两个文件存在于不同的目录中。为了在一个挂载点下提供这些文件:

  1. another-public目录中移出

  2. 创建一个名为router-serve-static.js的新文件

  3. 包括 ExpressJS 和 path 库。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const path = require('path') 
      const app = express() 
  1. 定义一个路由器:
      const staticRouter = express.Router() 
  1. 使用express.static可配置的中间件函数来包含publicanother-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)) 
  1. 将路由器挂载到"/"路径:
       app.use('/', staticRouter) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
           () => console.log('Web Server running on port 1337'), 
       ) 
  1. 保存文件

  2. 打开终端并运行:

 node router-serve-static.js
  1. 要查看结果,在浏览器中导航到:
 http://localhost:1337/index.html
      http://localhost:1337/second.html
  1. 在不同位置的两个不同文件在一个路径下提供服务

如果在不同目录下存在两个或更多同名文件,只会显示找到的第一个文件在客户端上。

解析 HTTP 请求主体

body-parser 是一个中间件函数,用于解析传入的请求主体,并将其作为 request.bodyrequest 对象中可用 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 编码表单,而另一个将以纯文本形式编码其主体。

  1. 创建一个名为 parse-form.js 的文件

  2. 包括 body-parser NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const bodyParser = require('body-parser') 
      const app = express() 
  1. 包括 body-parser 中间件函数来处理 URL 编码请求和纯文本请求:
       app.use(bodyParser.urlencoded({ extended: true })) 
       app.use(bodyParser.text()) 
  1. 添加一个新的路由方法来处理 "/" 路径的 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> 
       `) 
     }) 
  1. 添加一个新的路由方法来处理 "/setdata" 路径的 POST 请求。在终端上显示 request.body 的内容:
      app.post('/setdata', (request, response, next) => { 
          console.log(request.body) 
          response.end() 
      }) 
  1. 监听端口 1337,等待新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开一个终端并运行:

 node parse-form.js
  1. 在您的 Web 浏览器中,导航到:
      http://localhost:1337/
  1. 在第一个输入框中填写任何数据并提交表单:

  2. 在您的 Web 浏览器中,导航回:

      http://localhost:1337/
  1. 在第二个输入框中填写任何数据并提交表单:

  2. 检查终端中的输出

它是如何工作的...

终端输出类似于:

{ 'urlencoded': 'Example' } 
txtencoded=Example 

上面使用了两个解析器:

  1. 第一个 bodyParser.urlencoded() 解析传入的 multipart/form-data 编码类型的请求。结果以 Object 形式在 request.body 中可用

  2. 第二个 bodyParser.text() 解析传入的 text/plain 编码类型的请求。结果以 String 形式在 request.body 中可用

压缩 HTTP 响应

compression 是一个中间件函数,用于压缩将发送到客户端的响应主体。该模块使用支持以下内容编码机制的 zlib 模块 nodejs.org/api/zlib.html

  • gzip

  • deflate

Accept-Encoding HTTP 头用于确定客户端(例如 Web 浏览器)支持哪种内容编码机制,而 Content-Encoding HTTP 头用于告诉客户端响应主体应用了哪种内容编码机制。

compression 是一个可配置的中间件函数。它接受一个 options 对象作为第一个参数,以定义中间件的特定行为,并且还可以传递 zlib 选项。

做好准备

在这个配方中,我们将看到如何配置和使用 compression NPM 模块来压缩发送到客户端的请求主体。在开始之前,创建一个新的 package.json 文件,内容如下:

{ 
    "dependencies": { 
        "compression": "1.7.2", 
        "express": "4.16.3" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

    npm install

如何做到这一点...

  1. 创建一个名为 compress-site.js 的新文件

  2. 包括 compression NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const compression = require('compression') 
      const app = express() 
  1. 包括 compression 中间件函数。指定压缩的 level9(最佳压缩),threshold 或者响应主体应该考虑压缩的最小大小为 0 字节:
      app.use(compression({ level: 9, threshold: 0 })) 
  1. 定义一个路由方法来处理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()) 
     }) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

 node compress-site.js 
  1. 在您的浏览器中,导航到:
      http://localhost:1337/

它是如何工作的...

终端的输出将显示客户端(例如 Web 浏览器)支持的内容编码机制。它可能看起来像这样:

 [ 'gzip', 'deflate', 'sdch', 'br', 'identity' ]

客户端发送的内容编码机制由compression内部使用,以了解是否支持压缩。如果不支持压缩,则响应主体不会被压缩。

如果打开 Chrome Dev Tools 或类似工具并分析所做的请求,则服务器发送的Content-Encoding标头指示compression使用的内容编码机制。

Chrome Dev Tools | Network Tab 显示响应标头

compression库将Content-Encoding标头设置为用于压缩响应主体的编码机制。

threshold选项默认设置为 1 KB,这意味着如果响应大小低于指定的字节数,则不会被压缩。将其设置为 0 或false,即使大小低于 1 KB,也会压缩响应。

使用 HTTP 请求记录器

如前所述,编写请求记录器很简单。但是,编写我们自己的可能需要花费宝贵的时间。幸运的是,还有其他几种选择。例如,一个非常流行的 HTTP 请求记录器是 morgan expressjs.com/en/resources/middleware/morgan.html

morgan是一个可配置的中间件函数,接受两个参数formatoptions,用于指定日志显示的格式以及需要显示的信息类型。

有几种预定义的格式:

  • tiny:最小输出

  • short:与 tiny 相同,包括远程 IP 地址

  • common:标准 Apache 日志输出

  • combined:标准 Apache 组合日志输出

  • dev:显示与微格式相同的信息。但是,响应状态是有颜色的。

准备工作

创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "express": "4.16.3", 
        "morgan": "1.9.0" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做...

让我们构建一个可工作的示例。我们将包括morgan可配置的中间件函数,使用dev格式显示每个请求的信息。

  1. 创建一个名为morgan-logger.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const morgan = require('morgan') 
      const app = express() 
  1. 包括morgan可配置的中间件。将'dev'作为我们将使用的格式作为中间件函数的第一个参数传递:
      app.use(morgan('dev')) 
  1. 定义一个路由方法来处理所有GET请求:
      app.get('*', (request, response, next) => { 
          response.send('Hello Morgan!') 
      }) 
  1. 监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
     ) 
  1. 保存文件

  2. 打开终端并运行:

 node morgan-logger.js
  1. 要在终端中查看结果,请在 Web 浏览器中导航到:
        http://localhost:1337/
        http://localhost:1337/example

管理和创建虚拟域

使用 ExpressJS 管理虚拟域名非常容易。假设您有两个或更多子域,并且希望提供两个不同的 Web 应用程序。但是,您不希望为每个子域创建不同的 Web 服务器应用程序。在这种情况下,ExpressJS 允许开发人员在单个 Web 服务器应用程序中使用vhostexpressjs.com/en/resources/middleware/vhost.html管理虚拟域。

vhost是一个可配置的中间件函数,接受两个参数。第一个是hostname。第二个参数是当hostname匹配时将被调用的请求处理程序。

hostname遵循与路由路径相同的规则。它们可以是字符串,也可以是正则表达式。

准备工作

创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "express": "4.16.3", 
        "vhost": "3.0.2" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做...

使用路由器构建两个迷你应用程序,这两个应用程序将在两个不同的子域中提供服务:

  1. 创建一个名为virtual-domains.js的新文件

  2. 包括vhost NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const vhost = require('vhost') 
      const app = express() 
  1. 定义我们将用来构建两个迷你应用程序的两个路由器:
      const app1 = express.Router() 
      const app2 = express.Router() 
  1. 在第一个路由器中添加一个路由方法来处理"/"路径的GET请求:
      app1.get('/', (request, response, next) => { 
        response.send('This is the main application.') 
      }) 
  1. 在第二个路由器中添加一个路由方法来处理"/"路径的GET请求:
      app2.get('/', (request, response, next) => { 
         response.send('This is a second application.') 
     }) 
  1. 将我们的路由器挂载到我们的 ExpressJS 应用程序上。在localhost下提供第一个应用程序,在second.localhost下提供第二个应用程序:
      app.use(vhost('localhost', app1)) 
      app.use(vhost('second.localhost', app2)) 
  1. 监听端口1337以获取新连接:
      app.listen( 
         1337, 
         () => console.log('Web Server running on port 1337'), 
     ) 
  1. 保存文件

  2. 打开终端并运行:

      node virtual-domains.js 
  1. 要查看结果,请在您的 Web 浏览器中导航到:
        http://localhost:1337/
        http://second.localhost:1337/

还有更多...

vhostrequest对象添加了一个vhost 对象,其中包含完整的主机名(显示主机名和端口)、主机名(不包括端口)和匹配字符串。这样可以更好地控制如何处理虚拟域。

例如,我们可以编写一个允许用户使用他们的名字拥有自己子域的应用程序:

  1. 创建一个名为user-subdomains.js的新文件

  2. 包括vhost NPM 模块。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const vhost = require('vhost') 
      const app = express() 
  1. 定义一个新的路由器。然后,在"/"路径上添加一个路由方法来处理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}`) 
     }) 
  1. 挂载路由器:
       app.use(vhost('*.localhost', users)) 
  1. 监听端口1337以获取新连接:
      app.listen( 
           1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

      node user-subdomains.js 
  1. 要查看结果,请在您的 Web 浏览器中导航到:
        http://john-smith.localhost:1337/
        http://jx-huang.localhost:1337/
        http://batman.localhost:1337/

使用 Helmet 保护 ExpressJS Web 应用程序

Helmet允许保护 Web 服务器应用程序免受常见攻击,例如跨站脚本(XSS)、不安全的请求和点击劫持。

Helmet 是一组 12 个中间件函数,允许您设置特定的 HTTP 头:

  1. 内容安全策略(CSP):这是一种有效的方法,可以列出允许在您的 Web 应用程序中使用什么样的外部资源,例如 JavaScript、CSS 和图像。

  2. 证书透明度:这是一种提供特定域或特定域发行的证书更透明的方式sites.google.com/a/chromium.org/dev/Home/chromium-security/certificate-transparency

  3. DNS 预取控制:这告诉浏览器是否应该对尚未加载的资源(例如链接)执行域名解析(DNS)。

  4. Frameguard:这有助于防止点击劫持,告诉浏览器不要允许将您的 Web 应用程序放在iframe中。

  5. 隐藏 Powered-By:这只是隐藏X-Powered-By头,表示不显示服务器的技术。ExpressJS 默认将此头设置为"Express"

  6. HTTP 公钥固定:这有助于防止中间人攻击,将您的 Web 应用程序的公钥固定到Public-Key-Pins头。

  7. HTTP 严格传输安全性:这告诉浏览器严格坚持您的 Web 应用程序的 HTTPs 版本。

  8. IE 不打开:这可以防止 Internet Explorer 在您的站点上执行不受信任的下载或 HTML 文件,从而防止恶意脚本的注入。

  9. 无缓存:这告诉浏览器禁用浏览器缓存。

  10. 不嗅探 MIME 类型:这会强制浏览器禁用 MIME 嗅探或猜测所提供文件的内容类型。

  11. 引荐政策:引荐头提供了关于请求来源的数据。它允许开发人员禁用它,或者设置更严格的策略来设置引荐头。

  12. XSS 过滤器:这通过设置X-XSS-Protection头来防止反射型跨站脚本(XSS)攻击。

准备就绪

在本教程中,我们将使用 Helmet 提供的大多数中间件函数来保护我们的 ExpressJS Web 应用程序免受常见攻击。在开始之前,请创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "body-parser": "1.18.2", 
        "express": "4.16.3", 
        "helmet": "3.12.0", 
        "uuid": "3.2.1" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

npm install

如何做...

  1. 创建一个名为secure-helmet.js的新文件

  2. 包括 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() 
  1. 生成一个随机 ID,该 ID 将用于noncenonce是一个 HTML 属性,用于白名单内联执行 HTML 代码中允许执行的脚本或样式:
      const suid = uuid() 
  1. 使用 body parser 来解析jsonapplication/csp-report内容类型的 JSON 请求主体。application/csp-report是一个包含json类型的 JSON 请求主体的内容类型,当违反一个或多个 CSP 规则时,浏览器会发送该内容类型:
      app.use(bodyParser.json({ 
          type: ['json', 'application/csp-report'], 
      })) 
  1. 使用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', 
          } 
      })) 
  1. 添加一个路由方法来处理路径"/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() 
      }) 
  1. 使用DNS Prefetch Control中间件禁用资源预取:
      app.use(helmet.dnsPrefetchControl({ allow: false })) 
  1. 使用Frameguard中间件函数禁用您的应用程序在iframe中加载:
      app.use(helmet.frameguard({ action: 'deny' })) 
  1. 使用hidePoweredBy中间件函数替换X-Powered-By标头并设置一个虚假的标头:
      app.use(helmet.hidePoweredBy({ 
          setTo: 'Django/1.2.1 SVN-13336', 
      })) 
  1. 使用ieNoOpen中间件函数禁用 IE 不受信任的执行:
       app.use(helmet.ieNoOpen()) 
  1. 使用noSniff中间件函数禁用 MIME 类型猜测:
      app.use(helmet.noSniff()) 
  1. 使用referrerPolicy中间件函数使标头仅对我们的域名可用:
       app.use(helmet.referrerPolicy({ policy: 'same-origin' })) 
  1. 使用xssFilter中间件函数防止反射型 XSS 攻击:
      app.use(helmet.xssFilter()) 
  1. 添加一个路由方法来处理路径"/"上的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> 
       `) 
     }) 
  1. 在端口1337上监听新连接:
     app.listen( 
          1337, 
         () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

      node secure-helmet.js 
  1. 要查看结果,在您的网络浏览器中导航到:
        http://localhost:1337/

工作原理...

一切都很简单直接地使用Helmet。您通过选择和应用特定的Helmet中间件函数来指定要实施的安全措施,Helmet将设置正确的标头,然后发送给客户端。

在客户端(网络浏览器)中,一切都是自动的。网络浏览器负责解释服务器发送的标头并应用安全策略。这也意味着旧的浏览器可能无法支持或理解所有这些标头。也就是说,如果您考虑应用程序的安全性,那么没有太多好的理由要支持旧的网络浏览器。

例如,如果您使用 Chrome,您应该能够在控制台中看到类似于以下内容:

Chrome Dev Tools | 控制台显示 CSP 违规

  1. 在终端中,您应该能够看到类似于浏览器发送的以下输出:
      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 的官方网站上提供了一个可以与 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目录:

  1. 创建一个名为views的新目录

  2. 在我们的views目录中创建一个名为home.tpl的新文件

  3. 添加以下代码:

      <!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> 
  1. 保存文件

现在,创建一个新的模板引擎,将之前的模板转换为 HTML,并用提供的选项替换%[var]%

  1. 移出views目录

  2. 创建一个名为my-template-engine.js的新文件

  3. 包括 ExpressJS 和 fs(文件系统)库。然后,初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const fs = require('fs') 
      const app = express() 
  1. 使用engine方法注册一个名为tpl的新模板引擎。我们将读取文件内容,并用options对象中指定的内容替换%[var]%
       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) 
          }) 
     }) 
  1. 定义视图所在的路径。我们的模板位于views目录中:
       app.set('views', './views') 
  1. 告诉 ExpressJS 使用我们的模板引擎:
      app.set('view engine', 'tpl') 
  1. 添加一个路由方法来处理"/"路径的GET请求,并渲染我们的主页模板。提供titledescription选项,它们将替换模板中的%title%%description%
     app.get('/', (request, response, next) => { 
          response.render('home', { 
              title: 'Hello', 
               description: 'World!', 
         }) 
      }) 
  1. 监听端口1337以获取新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

    node my-template-engine.js
  1. 在您的浏览器中,导航到:
      http://localhost:1337/

我们刚刚编写的模板引擎不会转义 HTML 字符。这意味着,如果用来自客户端的数据替换这些属性,就应该小心,因为它可能容易受到 XSS 攻击。您可能希望使用官方 ExpressJS 网站上更安全的模板引擎。

调试您的 ExpressJS Web 应用程序

关于 Web 应用程序整个周期的 ExpressJS 上的调试信息非常简单。ExpressJS 在内部使用debug NPM 模块记录信息。与console.log不同,debug日志在生产模式下可以轻松禁用。

准备工作

在这个教程中,您将学习如何调试您的 ExpressJS Web 应用程序。在开始之前,创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "debug": "3.1.0", 
        "express": "4.16.3" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

npm install

操作步骤...

  1. 创建一个名为debugging.js的新文件

  2. 初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 添加一个路由方法来处理任何路径的GET请求:
      app.get('*', (request, response, next) => { 
          response.send('Hello there!') 
      }) 
  1. 监听端口1337以获取新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

  3. 在 Windows 上:

 set DEBUG=express:* node debugging.js
  1. 在 Linux 或 MacOS 上:
 DEBUG=express:* node debugging.js 

  1. 在您的 Web 浏览器中,导航到:
      http://localhost:1337/
  1. 观察您终端的输出以查看日志

工作原理...

DEBUG环境变量用于告诉debug模块调试 ExpressJS 应用程序的哪些部分。在我们之前编写的代码中,express:*告诉调试模块记录与 express 应用程序相关的所有内容。

我们可以使用DEBUG=express:router来显示与 ExpressJS 的路由相关的日志。

还有更多...

您可以在自己的项目中使用 debug NPM 模块。例如:

  1. 创建一个名为myapp.js的新文件

  2. 添加以下代码:

      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'), 
      ) 
  1. 保存文件

  2. 打开终端并运行:

  3. 在 Windows 上:

    set DEBUG=myapp node myapp.js
  1. 在 Linux 和 MacOS 上:
 DEBUG=myapp node myapp.js
  1. 在您的 Web 浏览器中,导航到:

  2. 观察您的终端输出。它会显示类似以下内容:

      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

查看以下视频以查看代码的运行情况:

goo.gl/73dE6u

介绍

表示状态转移REST)是 Web 建立的一种架构风格。更具体地说,HTTP 1.1 协议标准是使用 REST 原则构建的。REST 提供了资源的表示。URL统一资源定位符)用于定义资源的位置并告诉浏览器它的位置。

RESTful API 是遵循这种架构风格的 Web 服务 API。

最常用的 HTTP 动词或方法是:POST, GET, PUT,DELETE。这些方法是持久存储的基础,并被称为CRUD操作(创建、读取、更新和删除)。

在本章中,我们将重点放在使用 ExpressJS 和 Mongoose 构建 RESTful API 上的配方上。

使用 ExpressJS 的路由方法进行 CRUD 操作

ExpressJS 的路由器具有等效的方法来处理 HTTP 方法。换句话说,可以通过此代码处理POSTGETPUTDELETE这些 HTTP 方法:

      /* 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 操作,包括添加新用户、获取用户或用户列表、更新用户数据和删除用户:

  1. 创建一个名为restfulapi.js的新文件

  2. 导入我们需要的包并创建一个 ExpressJS 应用程序:

     const express = require('express') 
      const uuid = require('uuid') 
      const app = express() 
  1. 定义内存数据库:
      let data = [ 
          { id: uuid(), name: 'Bob' }, 
          { id: uuid(), name: 'Alice' }, 
      ] 
  1. 创建一个包含用于进行 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 } 
          } 
      } 
  1. post方法添加一个请求处理程序,该处理程序将用作Create操作。将新用户添加到data数组中:
      app.post('/users/:name', (req, res) => { 
          res.status(201).json(usr.create(req.params.name)) 
      }) 
  1. get方法添加一个请求处理程序,该处理程序将用作ReadRetrieve操作。如果给定id,则在data数组中查找用户。但是,如果给定的id"all",它将返回整个用户列表:
      app.get('/users/:id', (req, res) => { 
          res.status(200).json(usr.read(req.params.id)) 
      }) 
  1. put方法添加一个请求处理程序,该处理程序将用作Update操作。需要提供一个id来更新data数组中的特定用户:
      app.put('/users/:id=:name', (req, res) => { 
          res.status(200).json(usr.update( 
              req.params.id, 
              req.params.name, 
          )) 
      }) 
  1. delete方法添加一个请求处理程序,该处理程序将用作Delete操作。它将在data数组中查找用户并将其删除:
      app.delete('/users/:id', (req, res) => { 
          res.status(200).json(usr.delete(req.params.id)) 
      }) 
  1. 启动应用程序监听端口1337以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件。

  2. 打开终端并运行此代码:

 node restfulapi.js

让我们来测试一下...

为了简单起见,创建一个脚本,将在我们的 RESTful API 服务器上请求并执行 CRUD 操作:

  1. 创建一个名为test-restfulapi.js的新文件。

  2. 添加以下代码:

      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() 
  1. 保存文件。

  2. 打开一个新的终端并运行此代码:

    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”,它将以“users”的名称保存在 MongoDB 中。Mongoose 包含一个内部字典来将常见名称变为复数形式。这意味着如果您的模型名称是一个常见名称,比如“Person”,它将在 MongoDB 中保存为一个名为“people”的集合。

Mongoose 允许使用以下类型来定义模式的路径或文档结构:

  • 字符串

  • 数字

  • 布尔值

  • 数组

  • 日期

  • 缓冲区

  • 混合

  • Objectid

  • Decimal128

可以通过直接使用StringNumberBooleanBufferDate的全局构造函数来声明模式类型:

      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

如何做...

定义一个用户模式,其中包含用户的名字、姓氏和定义用户喜欢的事物的字符串数组:

  1. 创建一个名为mongoose-models.js的新文件

  2. 包含 Mongoose NPM 模块。然后,创建与 MongoDB 的连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UserSchema = new Schema({ 
          firstName: String, 
          lastName: String, 
          likes: [String], 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 定义一个用于添加新用户的函数:
      const addUser = (firstName, lastName) => new User({ 
          firstName, 
          lastName, 
      }).save() 
  1. 定义一个用于通过其id从用户集合中检索用户的函数:
      const getUser = (id) => User.findById(id) 
  1. 定义一个函数,将通过其id从用户集合中删除用户:
      const removeUser = (id) => User.remove({ id }) 
  1. 定义一个事件监听器,一旦连接到数据库,就会执行 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() 
          } 
      }) 
  1. 保存文件。

  2. 打开终端并运行此代码:

    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  

如何做...

  1. 创建一个名为chaining-queries.js的新文件

  2. 包含 Mongoose NPM 模块。然后,创建一个新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UserSchema = new Schema({ 
          firstName: String, 
          lastName: String, 
          age: Number, 
      }) 
  1. 编译模式为模型:
      const User = mongoose.model('User', UserSchema) 
  1. 连接到数据库后,向用户集合添加一个新文档。然后,使用链接语法查询最近创建的用户。此外,使用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() 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行:

    node chaining-queries.js

另请参阅

  • 第一章,MERN Stack 简介安装 NPM 包部分

  • 第一章,MERN Stack 简介安装 MongoDB部分

定义文档实例方法

文档有自己的内置实例方法,如saveremove。但是,我们也可以编写自己的实例方法。

文档是模型的实例。它们可以被显式创建:

      const instance = new Model() 

或者它们可以是查询的结果:

      Model.findOne([conditions]).then((instance) => {}) 

文档实例方法在模式中定义。所有模式都有一个名为method的方法,允许您定义自定义实例方法。

准备工作

在这个示例中,您将为修改和读取文档属性定义模式和自定义文档实例方法。首先确保已安装 MongoDB 并且正在运行。作为替代方案,如果您愿意,云中的 MongoDB DBaaS 实例也可以。在开始之前,创建一个新的package.json文件,其中包含以下代码:

{ 
  "dependencies": { 
    "mongoose": "5.0.11" 
  } 
} 

然后,通过打开终端并运行此代码来安装依赖项:

    npm install

如何做...

  1. 创建一个名为document-methods.js的新文件

  2. 包含 Mongoose NPM 模块。然后,创建一个到 MongoDB 的新连接:

      const mongooconst mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UserSchema = new Schema({ 
          firstName: String, 
          lastName: String, 
          likes: [String], 
      }) 
  1. 定义一个文档实例方法,用于从包含其全名的字符串中设置用户的名和姓:
      UserSchema.method('setFullName', function setFullName(v) { 
          const fullName = String(v).split(' ') 
          this.lastName = fullName[0] || '' 
          this.firstName = fullName[1] || '' 
      }) 
  1. 定义一个用于获取用户全名的文档实例方法,将firstNamelastName属性连接起来:
      UserSchema.method('getFullName', function getFullName() { 
          return `${this.lastName} ${this.firstName}` 
      }) 
  1. 定义一个名为loves的文档实例方法,该方法将期望一个参数,该参数将添加到字符串数组likes中:
      UserSchema.method('loves', function loves(stuff) { 
          this.likes.push(stuff) 
      }) 
  1. 定义一个名为dislikes的文档实例方法,该方法将从likes数组中移除用户之前喜欢的一件事:
      UserSchema.method('dislikes', function dislikes(stuff) { 
          this.likes = this.likes.filter(str => str !== stuff) 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 一旦 Mongoose 连接到数据库,创建一个新用户并使用setFullName方法填充firstNamelastName字段,然后使用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() 
          } 
      }) 
  1. 保存文件。

  2. 打开终端并运行此代码:

       node document-methods.js

还有更多...

文档实例方法也可以使用methods模式属性进行定义。例如:

UserSchema.methods.setFullName = function setFullName(v) { 
    const fullName = String(v).split(' ') 
    this.lastName = fullName[0] || '' 
    this.firstName = fullName[1] || '' 
} 

另请参阅

  • 第一章,MERN Stack 简介安装 NPM 包部分

  • 第一章,MERN Stack 简介安装 MongoDB部分

定义静态模型方法

模型具有内置的静态方法,如findfindOnefindOneAndRemove。Mongoose 还允许我们定义自定义静态模型方法。静态模型方法与文档实例方法的定义方式相同,都是在模式中定义的。

模式有一个名为statics的属性,它是一个对象。statics对象内定义的所有方法都会传递给模型。静态模型方法也可以通过调用static模式方法进行定义。

准备工作

在本示例中,您将定义一个模式和自定义静态模型方法,以扩展模型的功能。首先确保已安装 MongoDB 并且正在运行。或者,如果您愿意,云中的 MongoDB DBaaS 实例也可以。在开始之前,请使用以下代码创建一个新的package.json文件:

{ 
  "dependencies": { 
    "mongoose": "5.0.11" 
  } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

操作步骤

定义一个名为getByFullName的静态模型方法,允许您使用其全名搜索特定用户:

  1. 创建一个名为static-methods.js的新文件

  2. 包括 Mongoose NPM 模块并创建到 MongoDB 的新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UsrSchm = new Schema({ 
          firstName: String, 
          lastName: String, 
          likes: [String], 
      }) 
  1. 定义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) 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UsrSchm) 
  1. 一旦连接,创建一个新用户并保存。然后,使用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) 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行此代码:

    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 Stack 简介安装 NPM 包部分

  • 第一章,MERN Stack 简介安装 MongoDB部分

为 Mongoose 编写中间件函数

Mongoose 中的中间件函数也称为hooks。有两种类型的钩子pre hookspost hooks

pre hookspost 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的上下文是文档、模型、聚合对象或查询对象。

所有类型的中间件都支持预先和后续钩子

准备工作

在本教程中,我们将看到 Mongoose 中这三种类型的中间件函数是如何工作的:

  • 文档中间件

  • 模型中间件

  • 查询中间件

首先确保您已安装 MongoDB 并且正在运行。或者,如果您愿意,云中的 MongoDB DBaaS 实例也可以。在开始之前,创建一个新的package.json文件,其中包含以下代码:

      { 
        "dependencies": { 
          "mongoose": "5.0.11" 
        } 
      } 

然后,通过打开终端并运行来安装依赖项:

 npm install

如何做...

在文档中间件函数中,this的上下文指的是文档。文档具有以下内置方法,您可以为它们定义hooks

  • init:这是在从 MongoDB 返回文档后立即内部调用的。Mongoose 使用 setter 标记文档是否已修改或文档的哪些字段已修改。init初始化文档而不使用 setter。

  • validate:执行文档的内置和自定义验证规则。

  • save:将文档保存到数据库中。

  • remove:从数据库中删除文档。

文档中间件函数

为文档内置方法创建预先和后续钩子:

  1. 创建一个名为1-document-middleware.js的新文件

  2. 包含 Mongoose NPM 模块并创建到您的 MongoDB 的新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UserSchema = new Schema({ 
          firstName: { type: String, required: true }, 
          lastName: { type: String, required: true }, 
      }) 
  1. init文档方法添加prepost钩子:
      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.') 
      }) 
  1. validate文档方法添加prepost钩子:
      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.') 
      }) 
  1. save文档方法添加prepost钩子:
      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}`) 
      }) 
  1. remove文档方法添加prepost钩子:
      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`) 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 一旦建立新连接,创建一个文档并执行一些基本操作,如保存、检索和删除文档:
      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 }) 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行:

 node document-middleware.js
  1. 在终端上,输出应该显示:
      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预钩子将修改firstNamelastName字段,使它们成为大写字符串:

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:更新符合特定查询条件的一个或多个文档

查询中间件函数

为查询内置方法创建预先和后续钩子:

  1. 创建一个名为2-query-middleware.js的新文件

  2. 包含 Mongoose NPM 模块并创建到您的 MongoDB 的新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义一个模式:
      const UserSchema = new Schema({ 
          firstName: { type: String, required: true }, 
          lastName: { type: String, required: true }, 
      }) 
  1. countfindfindOneupdate方法定义预先和后续钩子:
      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`) 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 一旦成功连接到数据库,创建一个文档,保存它,并使用我们为其定义了钩子的方法:
      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() 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行:

 node query-middleware.js
  1. 在终端上,输出应该显示类似于:
      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模型实例方法定义prepost钩子:

  1. 创建一个名为3-model-middleware.js的新文件

  2. 包括 Mongoose NPM 模块并创建到您的 MongoDB 的新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义模式:
      const UserSchema = new Schema({ 
          firstName: { type: String, required: true }, 
          lastName: { type: String, required: true }, 
      }) 
  1. insertMany模型方法定义prepost钩子:
      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) 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 一旦建立了与数据库的连接,就可以使用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() 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行:

 node query-middleware.js
  1. 在终端上,输出应该显示:
      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 Stack 简介安装 NPM 包部分

  • 第一章,MERN Stack 简介安装 MongoDB部分

为 Mongoose 的模式编写自定义验证器

Mongoose 有几个内置验证规则。例如,如果您使用模式类型为string并将其设置为required来定义属性,则将执行两个验证规则,一个用于检查属性是否为有效的string,另一个用于检查属性是否不为nullundefined

Mongoose 还可以在 Mongoose 中定义自定义验证规则和自定义错误验证消息,以便更好地控制何时以及何时接受某些属性,然后才能将其保存到数据库中。

验证规则在模式中定义。所有模式类型都有一个内置验证器required,这意味着它不能包含undefinednull值。required验证器可以是booleanfunctionarray类型。例如:

      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:这定义了字符串的最小长度。例如,只允许520个字符之间的字符串:

      name: { 
      type: SchemaTypes.String, 
      minlength: 5, 
      maxlength: 20, 
      } 

数字模式类型有两个内置验证器:

  • min:这定义了数字的最小值。

  • max:这定义了数字的最大值。例如,只允许18100之间的数字:

      age: { 
      type: String, 
      min: 18, 
      max: 100, 
      } 

未定义的值可以通过所有验证器而不出错。如果要在值为undefined时抛出错误,请不要忘记将required验证器设置为true

当内置验证器有时无法满足您的要求,或者您希望执行复杂的验证规则时,您有一个名为validate的选项或属性。这个选项接受一个具有两个属性validatormessage的对象,允许我们编写自定义验证器:

      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 个字符,匹配正则表达式,并且是必需的:

  1. 创建一个名为custom-validation.js的新文件

  2. 包括 Mongoose NPM 模块,并创建与数据库的新连接:

      const mongoose = require('mongoose') 
      const { connection, Schema } = mongoose 
      mongoose.connect( 
          'mongodb://localhost:27017/test' 
      ).catch(console.error) 
  1. 定义包括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), 
              }, 
          }, 
      }) 
  1. 将模式编译成模型:
      const User = mongoose.model('User', UserSchema) 
  1. 一旦与数据库建立连接,创建一个带有无效字段的新文档,并使用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() 
          } 
      }) 
  1. 保存文件

  2. 打开终端并运行:

 node custom-validation.js  
  1. 在终端上,输出应显示:
      'user is required' 
      'Path `username` (`Smith`) is shorter than the minimum allowed             
       length (6).' 
      'Smith_9876 is not a valid username' 

另请参阅

  • 第一章,MERN Stack 简介安装 NPM 包部分

  • 第一章,MERN Stack 简介安装 MongoDB部分

使用 ExpressJS 和 Mongoose 构建 RESTful API 来管理用户

在本教程中,您将构建一个 RESTful API,允许创建新用户、登录、显示用户信息和删除用户配置文件。此外,您还将学习如何构建一个带有客户端 API 的 NodeJS REPL,您可以使用它与服务器的 RESTful API 进行交互。

REPLRead-Eval-Print Loop)类似于一个交互式 shell,您可以依次执行命令。例如,可以通过在终端中运行此命令来打开 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 路由挂载到特定路径:

  1. 创建一个名为server.js的新文件

  2. 包括所需的库。然后,初始化一个新的 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) 
  1. 使用body-parser中间件将请求体解析为 JSON:
      app.use(bodyParser.json()) 
  1. 定义一个 ExpressJS 中间件函数,确保您的 Web 应用在允许执行下一个路由处理程序之前首先连接到 MongoDB:
      app.use((request, response, next) => {
        Promise.resolve(db).then(
        (connection, err) => (
            typeof connection !== 'undefined'
            ? next()
            : next(new Error('MongoError'))
            )
          )
      })
  1. 配置express-session中间件以将会话存储在 Mongo 数据库中,而不是存储在内存中:
      app.use(session({ 
          secret: 'MERN Cookbook Secrets', 
          resave: false, 
          saveUninitialized: true, 
          store: new MongoStore({ 
              collection: 'sessions', 
              mongooseConnection: mongoose.connection, 
          }), 
      })) 
  1. 将 API 控制器挂载到"/api"路由:
      app.use('/users', api) 
  1. 监听端口 1773 以进行新连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

然后,创建一个名为api的新目录。接下来,创建应用程序的模型或业务逻辑。为用户定义一个包含静态和实例方法的模式,这些方法将允许用户注册、登录、注销、获取配置文件数据、更改密码和删除其配置文件:

  1. api目录中创建一个名为model.js的新文件

  2. 包括 Mongoose NPM 模块,还包括将用于生成用户密码哈希的crypto NodeJS 模块:

      const { connection, Schema } = require('mongoose') 
      const crypto = require('crypto') 
  1. 定义模式:
      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, 
      }) 
  1. 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 
      }) 
  1. 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 
      }) 
  1. 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() 
      }) 
  1. 将 Mongoose 模式编译为模型并导出它:
      module.exports = connection.model('User', UserSchema) 
  1. 保存文件

最后,定义一个控制器,将请求体转换为模型可以理解的操作。然后将其导出为包含所有 API 路径的 ExpressJS 路由:

  1. api文件夹中创建一个名为controller.js的新文件

  2. 导入model.js并初始化一个新的 ExpressJS 路由:

      const express = require('express') 
      const User = require('./model') 
      const api = express.Router() 
  1. 定义一个请求处理程序,检查用户是否已登录,另一个请求处理程序检查用户是否未登录:
      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() 
      } 
  1. 定义一个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 }) 
          } 
      }) 
  1. 定义一个post请求方法来处理对"/logout"端点的请求:
      api.post('/logout', isLogged, (req, res) => { 
          req.session.destroy() 
          res.status(200).send({ status: 'Bye bye!' }) 
      }) 
  1. 定义一个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 }) 
          } 
      }) 
  1. 定义一个get请求方法来处理对"/profile"端点的请求:
      api.get('/profile', isLogged, (req, res) => { 
          const { user } = req.session 
          res.status(200).json({ user }) 
      }) 
  1. 定义一个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 }) 
          } 
      }) 
  1. 定义一个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 }) 
          } 
      }) 
  1. 导出路由:
      module.exports = api 
  1. 保存文件

让我们来测试一下...

您已经构建了一个 RESTful API,允许用户订阅或注册、登录、注销、获取其个人资料和删除其个人资料。这些操作可以通过向服务器发出 HTTP 请求来执行。现在我们将构建一个小型的 NodeJS REPL 和客户端 API,可以让您使用纯 JavaScript 函数与您的 RESTful API 服务器进行交互:

  1. 转到项目目录的根目录,并创建一个名为client-repl.js的新文件。

  2. 包括node-fetch NPM 模块,允许向服务器发出 HTTP 请求。还包括replvm 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 
  1. 定义一个变量,稍后将包含来自 cookie 的会话 ID,一旦用户登录。该 cookie 将用于让服务器识别已登录用户,以执行诸如获取有关您的个人资料或更改密码的操作:
      let cookie = null 
  1. 定义一个名为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) 
      } 
  1. 定义一个允许用户注册的方法:
      const signup = (username, password) => query('/signup', { 
          method: 'POST', 
          body: { username, password }, 
      }) 
  1. 定义一个允许用户登录的方法:
      const login = (username, password) => query('/login', { 
          method: 'POST', 
          body: { username, password }, 
      }) 
  1. 定义一个允许用户注销的方法:
      const logout = () => query('/logout', { 
          method: 'POST', 
      }) 
  1. 定义一个允许用户获取其个人资料的方法:
      const getProfile = () => query('/profile', { 
          method: 'GET', 
      }) 
  1. 定义一个允许用户更改密码的方法:
      const changePassword = (password) => query('/changepass', { 
          method: 'PUT', 
          body: { password }, 
      }) 
  1. 定义一个允许用户删除其个人资料的方法:
      const deleteProfile = () => query('/delete', { 
          method: 'DELETE', 
      }) 
  1. 使用 REPL 导出对象的 start 方法启动新的 REPL 服务器。我们将指定 eval 方法使用 VM 模块执行 JavaScript 代码,然后,如果返回 Promise,它将等待 Promise 解析后才允许用户输入更多命令或在 REPL 中输入更多 JavaScript 代码。我们还将指定 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, 
              }) 
          } 
      }) 
  1. 将先前定义的方法添加到 REPL 服务器的上下文中,JavaScript 代码将在其中执行:
      replServer.context.api = { 
          signup, 
          login, 
          logout, 
          getProfile, 
          changePassword, 
          deleteProfile, 
      } 
  1. 保存文件

现在您可以在终端上运行您的 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

查看以下视频以查看代码的实际操作:

goo.gl/xfyDBn

介绍

现代 Web 应用程序通常需要实时通信,其中数据不断从客户端流向服务器,反之亦然,几乎没有延迟。

HTML5 WebSocket 协议是为了满足这一要求而创建的。 WebSocket 使用单个 TCP 连接,即使服务器或客户端不发送任何数据,该连接也会保持打开。 这意味着,在客户端和服务器之间存在连接时,可以随时发送数据,而无需打开到服务器的新连接。

实时通信有多个应用场景,从构建聊天应用程序到多用户游戏,响应时间非常重要。

在本章中,我们将专注于学习如何使用 Socket.IO(socket.io)构建实时 Web 应用程序,并理解 Node.js 的事件驱动架构。

Socket.IO 是实现实时通信最常用的库之一。 Socket.IO 在可能的情况下使用 WebSocket,但在特定 Web 浏览器不支持 WebSocket 时会退回到其他方法。 因为您可能希望使您的应用程序可以从任何 Web 浏览器访问,所以必须直接使用 WebSocket 可能看起来不是一个好主意。

理解 Node.js 事件

Node.js 具有事件驱动的架构。 Node.js 的大部分核心 API 都是围绕EventEmitter构建的。 这是一个允许侦听器订阅特定命名事件的 Node.js 模块,稍后可以由发射器触发。

您可以通过包含事件 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并定义两个名为startstop的实例方法。 当调用start方法时,它将触发附加到start事件的所有侦听器。 它将使用process.hrtime保持起始时间。 然后,当调用stop方法时,它将触发附加到stop事件的所有侦听器,并将自start方法调用以来的时间差作为参数传递:

  1. 创建一个名为timer.js的新文件

  2. 包括事件 NodeJS 模块:

      const EventEmitter = require('events') 
  1. 定义两个常量,我们将使用它们将process.hrtime的返回值从秒转换为纳秒,然后转换为毫秒:
      const NS_PER_SEC = 1e9 
      const NS_PER_MS = 1e6 
  1. 定义一个名为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, 
              ) 
          } 
      } 
  1. 创建先前定义的类的新实例:
      const tasks = new Timer() 
  1. 将一个事件监听器附加到start事件,它将有一个循环执行乘法。之后,它将调用stop方法:
      tasks.on('start', () => { 
          let res = 1 
          for (let i = 1; i < 100000; i++) { 
              res *= i 
          } 
          tasks.stop() 
      }) 
  1. 将一个事件监听器附加到stop事件,它将打印事件start执行所有附加监听器所花费的时间:
      tasks.on('stop', (time) => { 
          console.log(`Task completed in ${time}ms`) 
      }) 
  1. 调用start方法来触发所有start事件监听器:
      tasks.start() 
  1. 保存文件

  2. 打开一个新的终端并运行:

 node timer.js

它是如何工作的...

当执行start方法时,它使用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完成执行才调用下一个 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 连接到单个路径。这意味着,默认情况下,连接是建立到 URLhttp[s]://host:port/socket.io。然而,在 Socket.IO 中,它允许您定义命名空间。这意味着不同的终点,但连接仍然保持单一 URL。

默认情况下,Socket.IO 服务器使用"/"或根命名空间

当然,您可以定义多个实例并监听不同的 URL。然而,为了本教程的目的,我们将假设只创建一个连接。

Socket.IO 命名空间具有以下事件,您的应用程序可以订阅:

  • connectconnection:当建立新连接时,将触发此事件。它将socket 对象作为第一个参数提供给监听器,表示与客户端的新连接
      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_errorconnect_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_attemptreconnecting:当尝试重新连接到服务器时会触发此事件。提供一个参数,指定当前尝试连接到服务器的次数:
      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

  1. 创建一个名为simple-io-server.js的新文件

  2. 包括 Socket.IO 模块并初始化一个新服务器:

      const io = require('socket.io')() 
  1. 定义连接将被建立的 URL 路径:
      io.path('/socket.io') 
  1. 使用根目录或"/"命名空间:
      const root = io.of('/') 
  1. 当建立新连接时,将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) 
          }) 
      }) 
  1. 监听端口1337以获取新连接:
      io.listen(1337) 
  1. 保存文件

接下来,构建一个连接到我们服务器的 Socket.IO 客户端:

  1. 创建一个名为simple-io-client.js的新文件

  2. 包括 Socket.IO 客户端模块:

      const io = require('socket.io-client') 
  1. 初始化一个新的 Socket.IO 客户端,提供服务器 URL 和一个选项对象,在该对象中我们将定义 URL 中使用的路径,连接将在该路径上进行:
      const clientSocket = io('http://localhost:1337', { 
          path: '/socket.io', 
      }) 
  1. connect事件添加一个事件监听器。然后,当建立连接时,使用for循环,发出time事件 5 次:
      clientSocket.on('connect', () => { 
          for (let i = 1; i <= 5; i++) { 
              clientSocket.emit('time') 
          } 
      }) 
  1. "got time?"事件上添加一个事件监听器,该事件将期望接收两个参数,时间和一个指定了向服务器发出了多少次请求的计数器,然后在控制台上打印:
      clientSocket.on('got time?', (time, counter) => { 
          console.log(counter, time) 
      }) 
  1. 保存文件

  2. 打开终端并首先运行 Socket.IO 服务器:

    node simple-io-server.js
  1. 打开另一个终端并运行 Socket.IO 客户端:
    node simple-io-client.js

工作原理...

一切都与事件有关。Socket.IO 允许在服务器端定义客户端可以发出的事件。另一方面,它还允许在客户端端定义服务器可以发出的事件。

当服务器端发出用户定义的事件时,数据被发送到客户端。Socket.IO 客户端首先检查是否有该事件的监听器。然后,如果有监听器,它将被触发。当客户端端发出用户定义的事件时,同样的事情也会发生:

  1. 在我们的 Socket.IO 服务器的socket 对象中添加了一个事件监听器time,可以由客户端发出

  2. 在我们的 Socket.IO 客户端中添加了一个事件监听器"got time?",可以由服务器端发出

  3. 在连接时,客户端首先发出time事件

  4. 随后,在服务器端触发time事件,该事件将提供两个参数,当前服务器的time和一个指定了请求次数的counter

  5. 然后,在客户端端触发"got time?"事件,接收服务器提供的两个参数,timecounter

使用 Socket.IO 命名空间

命名空间是一种分隔应用程序业务逻辑的方式,同时重用相同的 TCP 连接或最小化创建新 TCP 连接的需求,以实现服务器和客户端之间的实时通信。

命名空间看起来与 ExpressJS 的路由路径非常相似:

/home 
/users 
/users/profile 

然而,正如前面的配方中提到的,这些与 URL 无关。默认情况下,在此 URLhttp[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事件,并发送一个包含两个属性titlemsg的对象,该对象将用于填充所选语言的 HTML 内容。使用命名空间来分隔并根据客户端选择的语言(英语或西班牙语)发送不同的数据。

  1. 创建一个名为nsp-server.js的新文件

  2. 包括 Socket.IO npm 模块和创建 HTTP 服务器所需的模块:

      const http = require('http') 
      const fs = require('fs') 
      const path = require('path') 
      const io = require('socket.io')() 
  1. 使用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() 
         } 
    }) 
  1. 指定新连接将要进行的路径:
      io.path('/socket.io') 
  1. 对于"/en"命名空间,添加一个新的事件监听器getData,当触发时将在客户端发出一个data事件,并发送一个包含titlemsg属性的对象,使用英语语言:
     io.of('/en').on('connection', (socket) => { 
        socket.on('getData', () => { 
            socket.emit('data', { 
               title: 'English Page', 
               msg: 'Welcome to my Website', 
           }) 
        }) 
   }) 
  1. 对于"/es"命名空间,做同样的事情。但是,发送到客户端的对象将包含西班牙语言中的titlemsg属性:
      io.of('/es').on('connection', (socket) => { 
          socket.on('getData', () => { 
              socket.emit('data', { 
                  title: 'Página en Español', 
                  msg: 'Bienvenido a mi sitio Web', 
              }) 
          }) 
      }) 
  1. 监听端口1337以获取新连接,并将 Socket.IO 附加到底层 HTTP 服务器:
      io.attach(app.listen(1337, () => { 
          console.log( 
              'HTTP Server and Socket.IO running on port 1337' 
          ) 
      })) 
  1. 保存文件。

之后,创建一个 Socket.IO 客户端,将连接到我们的服务器,并根据从服务器接收到的数据填充 HTML 内容。

  1. 创建一个名为nsp-client.html的新文件

  2. 首先,将文档类型指定为 HTML5。在其旁边,添加一个html标签,并将语言设置为英语。在html标签内,还包括headbody标签:

      <!DOCTYPE html> 
      <html lang="en"> 
      <head> 
          <meta charset="UTF-8"> 
          <title>Socket.IO Client</title> 
      </head> 
      <body> 
          <!-- code here --> 
      </body> 
      </html> 
  1. body标签内,添加前三个元素:一个包含内容标题的标题(h1),一个包含来自服务器的消息的p标签,以及一个用于切换到不同命名空间的button。还包括 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> 
  1. body内,在最后的script标签之后,添加另一个script标签,并将其类型设置为"text/babel"
      <script type="text/babel"> 
          // code here! 
      </script> 
  1. 之后,在script标签内,添加以下 JavaScript 代码

  2. 定义三个常量,它们将包含对body中创建的元素的引用:

      const title = document.getElementById('title') 
      const msg = document.getElementById('msg') 
      const btn = document.getElementById('toggleLang') 
  1. 定义一个 Socket.IO 客户端管理器。它将帮助我们使用提供的配置创建套接字:
      const manager = new io.Manager( 
          'http://localhost:1337', 
          { path: '/socket.io' }, 
      ) 
  1. 创建一个新的套接字,将连接到"/en"命名空间。我们将假设这是默认连接:
      const socket = manager.socket('/en') 
  1. "/en""/es"命名空间保留两个连接。保留连接将允许我们在不需要创建新的 TCP 连接的情况下切换到不同的命名空间:
      manager.socket('/en') 
      manager.socket('/es') 
  1. 添加一个事件监听器,一旦套接字连接,就会发出一个getData事件来请求服务器的数据:
      socket.on('connect', () => { 
          socket.emit('getData') 
      }) 
  1. 添加一个data事件的事件监听器,当客户端从服务器接收到数据时将被触发:
      socket.on('data', (data) => { 
          title.textContent = data.title 
          msg.textContent = data.msg 
      }) 
  1. 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() 
      }) 
  1. 保存文件

  2. 打开一个新的终端并运行:

 node nsp-server.js
  1. 在 Web 浏览器中,导航到:
 http://localhost:1337/

让我们来测试一下...

要查看之前的工作效果,请按照以下步骤操作:

  1. 一旦在 Web 浏览器中导航到http://localhost:1337/,单击"Get Content in Spanish"按钮,切换到西班牙语命名空间

  2. 单击"Get Content in English"按钮,切换回英语命名空间

工作原理...

这是服务器端发生的事情:

  1. 我们定义了两个命名空间,"/en""/es",然后向套接字对象添加了一个新的事件监听器getData

  2. 当在任何两个定义的命名空间中触发getData事件时,它将发出一个数据事件,并向客户端发送一个包含标题和消息属性的对象

在客户端,在我们的 HTML 文档的script标签内:

  1. 最初,为命名空间"/en"创建一个新的套接字:
      const socket = manager.socket('/en')
  1. 同时,我们为"/en""/es"命名空间创建了两个新的套接字。它们将充当保留连接:
      manager.socket('/en')
      manager.socket('/es')
  1. 之后,添加了一个事件监听器connect,在连接时向服务器发送请求

  2. 然后,添加了另一个data事件的事件监听器,当从服务器接收到数据时触发

  3. 在处理按钮的onclick事件的事件监听器内部,我们将nsp属性更改为切换到不同的命名空间。但是,为了实现这一点,我们必须首先断开套接字,然后调用open方法,再次使用新的命名空间建立新的连接

让我们看看关于保留连接的一个令人困惑的部分。当您在同一个命名空间中创建一个或多个sockets时,第一个连接会被重用,例如:

const first = manager.socket('/home')
const second = manager.socket('/home') // <- reuses first connection

在客户端,如果没有保留连接,那么切换到以前未使用过的命名空间将导致创建一个新连接。

如果您感到好奇,请从nsp-client.html文件中删除这两行:

manager.socket('/en')
manager.socket('/es')

之后,重新启动或再次运行 Socket.IO 服务器。您会注意到切换到不同命名空间时会有一个缓慢的响应,因为会创建一个新连接而不是重用。

有一种替代方法可以实现相同的目标。我们可以创建两个指向两个不同命名空间"/en""/es"的 socket。然后,我们可以为每个 socket 添加两个事件监听器 connect 和 data。然而,因为第一个和第二个 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.ManagerAPI 的更多信息,请访问官方文档网站提供的 Socket.IO socket.io/docs/client-api/#manager

稍后,我们使用了socket方法来初始化并创建一个提供的命名空间的新 Socket:

const socket = manager.socket('/en') 

这样,就可以更容易地同时处理多个命名空间,而无需为每个命名空间配置相同的选项。

定义和加入 Socket.IO 房间

在命名空间内,您可以定义一个 socket 可以加入和离开的房间或通道。

默认情况下,房间会使用一个随机的不可猜测的 ID 来创建与连接的socket

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 服务器,当新的 socket 连接时,它将通知所有连接的客户端加入"commonRoom"房间。

  1. 创建一个名为rooms-server.js的新文件

  2. 包括 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() 
          } 
      }) 
  1. 指定新连接将被创建的路径:
      io.path('/socket.io') 
  1. 使用根命名空间来监听事件:
      const root = io.of('/') 
  1. 定义一个方法,用于向连接到"commonRoom"的所有套接字客户端发出updateClientCount事件,并提供连接的客户端数量作为参数:
      const notifyClients = () => { 
          root.clients((error, clients) => { 
              if (error) throw error 
              root.to('commonRoom').emit( 
                  'updateClientCount', 
                  clients.length, 
              ) 
          }) 
      } 
  1. 连接后,所有新连接的 Socket 客户端都将加入commonRoom。然后,服务器将发出welcome事件。之后,通知所有连接的套接字更新连接客户端的数量,并在客户端断开连接时执行相同的操作:
      root.on('connection', socket => { 
          socket.join('commonRoom') 
          socket.emit('welcome', `Welcome client: ${socket.id}`) 
          socket.on('disconnect', notifyClients) 
          notifyClients() 
      }) 
  1. 监听端口1337以进行新连接,并将 Socket.IO 附加到 HTTP 服务器:
      io.attach(app.listen(1337, () => { 
          console.log( 
              'HTTP Server and Socket.IO running on port 1337' 
          ) 
      })) 
  1. 保存文件。

之后,构建一个 Socket.IO 客户端,该客户端将连接到 Socket.IO 服务器并使用接收到的数据填充 HTML 内容:

  1. 创建一个名为rooms-client.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 在脚本标签中,按以下步骤添加代码,从第 4 步开始

  2. 定义两个常量,它们将引用两个 HTML 元素,我们将根据 Socket.IO 服务器发送的数据进行更新:

      const welcome = document.getElementById('welcome') 
      const n = document.getElementById('n') 
  1. 定义一个 Socket.IO 客户端管理器:
      const manager = new io.Manager( 
          'http://localhost:1337', 
          { path: '/socket.io' }, 
      ) 
  1. 使用 Socket.IO 服务器中使用的根命名空间:
      const socket = manager.socket('/') 
  1. welcome事件添加事件侦听器,该事件预期包含服务器发送的欢迎消息作为参数:
      socket.on('welcome', msg => { 
          welcome.textContent = msg 
      }) 
  1. updateClientCount事件添加事件侦听器,该事件预期包含一个参数,该参数将包含连接的客户端数量:
      socket.on('updateClientCount', clientsCount => { 
          n.textContent = clientsCount 
      }) 
  1. 保存文件

  2. 打开一个新的终端并运行:

 node rooms-server.js
  1. 在 Web 浏览器中,导航到:
http://localhost:1337/
  1. 在不关闭上一个选项卡或窗口的情况下,再次在 Web 浏览器中导航到:
http://localhost:1337/
  1. 两个选项卡或窗口中连接的客户端数量应该增加到2

还有更多...

向多个客户端发送相同的消息或数据称为广播。我们已经看到的方法向所有客户端广播消息,包括生成请求的客户端。

还有其他几种广播消息的方法。例如:

socket.to('commonRoom').emit('updateClientCount', data) 

这将向commonRoom中的所有客户端发出updateClientCount事件,但不包括发出请求的发送方或套接字。

有关完整列表,请查看 Socket.IO 发射速查表的官方文档:socket.io/docs/emit-cheatsheet/

为 Socket.IO 编写中间件

Socket.IO 允许我们在服务器端定义两种类型的中间件函数:

  • 命名空间中间件:注册一个函数,该函数将在每个新连接的 Socket 上执行,并具有以下签名:
      namespace.use((socket, next) => { ... }) 
  • Socket 中间件:注册一个函数,该函数将在每个传入的数据包上执行,并具有以下签名:
      socket.use((packet, next) => { ... }) 

它的工作方式类似于 ExpressJS 中间件函数。我们可以向socketpacket对象添加新属性。然后,我们可以调用next将控制传递给链中的下一个中间件。如果未调用next,则不会连接socket,或者接收到的packet

准备工作

在这个示例中,您将构建一个 Socket.IO 服务器应用程序,在其中定义中间件函数以限制对某个命名空间的访问,以及根据某些条件限制对某个套接字的访问。在开始之前,请创建一个包含以下内容的新的package.json文件:

{ 
  "dependencies": { 
    "socket.io": "2.1.0" 
  } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

    npm install

如何做...

Socket.IO 服务器应用程序将期望用户已登录,以便他们能够连接到/home命名空间。使用 socket 中间件,我们还将限制对/home命名空间的访问权限:

  1. 创建一个名为middleware-server.js的新文件

  2. 包括 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() 
          } 
      }) 
  1. 指定新连接将建立的路径:
      io.path('/socket.io') 
  1. 定义一个用户数组,我们将将其用作内存数据库:
      const users = [ 
          { username: 'huangjx', password: 'cfgybhji' }, 
          { username: 'johnstm', password: 'mkonjiuh' }, 
          { username: 'jackson', password: 'qscwdvb' }, 
      ] 
  1. 定义一个方法来验证提供的用户名和密码是否存在于用户数组中:
      const userMatch = (username, password) => ( 
          users.find(user => ( 
              user.username === username && 
              user.password === password 
          )) 
      ) 
  1. 定义一个命名空间中间件函数,该函数将检查用户是否已经登录。如果用户未登录,客户端将无法使用此中间件连接到特定命名空间:
      const isUserLoggedIn = (socket, next) => { 
          const { session } = socket.request 
          if (session && session.isLogged) { 
              next() 
          } 
      } 
  1. 定义两个命名空间,一个用于/login,另一个用于/home/home命名空间将使用我们之前定义的中间件函数来检查用户是否已登录:
      const namespace = { 
          home: io.of('/home').use(isUserLoggedIn), 
          login: io.of('/login'), 
      } 
  1. 当一个新的 socket 连接到/login命名空间时,首先我们将为检查所有传入的数据包定义一个 socket 中间件函数,并禁止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', 
                  }) 
              } 
          }) 
      }) 
  1. 监听端口 1337 以获取新连接并将 Socket.IO 附加到 HTTP 服务器:
      io.attach(app.listen(1337, () => { 
          console.log( 
              'HTTP Server and Socket.IO running on port 1337' 
          ) 
      })) 
  1. 保存文件

之后,构建一个 Socket.IO 客户端应用程序,它将连接到我们的 Socket.IO 服务器,并允许我们尝试登录和测试:

  1. 创建一个名为middleware-cli.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 在脚本标签内,从步骤 4 开始,添加以下代码

  2. 定义三个常量,它们将引用我们将用于获取输入或显示输出的 HTML 元素:

      const title = document.getElementById('home') 
      const error = document.getElementsByName('logErrors')[0] 
      const loginForm = document.getElementById('loginForm') 
  1. 定义一个 Socket.IO 管理器:
      const manager = new io.Manager( 
          'http://localhost:1337', 
          { path: '/socket.io' }, 
      ) 
  1. 让我们定义一个命名空间常量,其中包含一个包含 Socket.IO 命名空间/home/login的对象:
      const namespace = { 
          home: manager.socket('/home'), 
          login: manager.socket('/login'), 
      } 
  1. /home命名空间添加一个connect事件的事件监听器。只有当/home命名空间成功连接到服务器时才会触发:
      namespace.home.on('connect', () => { 
          title.textContent = 'Great! you are connected to /home' 
          error.textContent = '' 
      }) 
  1. /login命名空间添加一个loginSuccess事件的事件监听器。它将要求/home命名空间再次连接到服务器。如果用户已登录,则服务器将允许此连接:
      namespace.login.on('loginSuccess', () => { 
          namespace.home.connect() 
      }) 
  1. /login命名空间添加一个loginError事件的事件监听器。它将显示服务器发送的错误消息:
      namespace.login.on('loginError', (err) => { 
          logs.textContent = err.message 
      }) 
  1. 为登录表单的提交事件添加事件监听器。它将发出输入事件,提供一个包含在表单中填写的用户名和密码的对象:
      form.addEventListener('submit', (event) => { 
          const body = new FormData(form) 
          namespace.login.emit('tryLogin', { 
              username: body.get('username'), 
              password: body.get('password'), 
          }) 
          event.preventDefault() 
      }) 
  1. 保存文件

让我们来测试一下...

查看我们之前的工作的效果:

  1. 首先运行 Socket.IO 服务器。打开一个新的终端并运行:
 node middleware-server.js
  1. 在您的网络浏览器中,导航到:
 http://localhost:1337
  1. 您将看到一个带有两个字段usernamepassword的登录表单

  2. 尝试使用随机无效的凭据登录。将显示以下错误:

      invalid credentials 
  1. 接下来,尝试使用johntm作为username和任何password登录。将显示以下错误:
      Banned user! 
  1. 之后,使用另外两个有效凭据之一登录。例如,使用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 服务器,并显示服务器发送的欢迎消息。

  1. 创建一个名为io-express-view.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 保存文件

接下来,构建一个 ExpressJS 应用程序和一个 Socket.IO 服务器。ExpressJS 应用程序将在根路径"/"上提供先前创建的 HTML 文件:

  1. 创建一个名为io-express-server.js的新文件

  2. 初始化一个新的 Socket.IO 服务器应用程序和一个 ExpressJS 应用程序:

      const path = require('path') 
      const express = require('express') 
      const io = require('socket.io')() 
      const app = express() 
  1. 定义新连接将连接到 Socket.IO 服务器的 URL 路径:
      io.path('/socket.io') 
  1. 定义一个路由方法来提供包含我们的 Socket.IO 客户端应用程序的 HTML 文件:
      app.get('/', (req, res) => { 
          res.sendFile(path.resolve( 
              __dirname, 
              'io-express-view.html', 
          )) 
      }) 
  1. 定义一个命名空间"/"并发出一个带有欢迎消息的welcome事件:
      io.of('/').on('connection', (socket) => { 
          socket.emit('welcome', 'Hello from Server!') 
      }) 
  1. 将 Socket.IO 附加到 ExpressJS 服务器:
      io.attach(app.listen(1337, () => { 
          console.log( 
              'HTTP Server and Socket.IO running on port 1337' 
          ) 
      })) 
  1. 保存文件

  2. 打开终端并运行:

 node io-express-server.js
  1. 在您的浏览器中访问:
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)) 

正如您所看到的,我们在 Socket.IO 和 ExpressJS 中使用相同的 URL 路径。我们接受新连接到/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 服务器还默认提供其自己的 Socket.IO 客户端,位于定义的 URL 路径下。例如,尝试访问localhost:1337/socket.io/socket.io.js,您将能够看到 Socket.IO 客户端的最小化 JavaScript 代码。

如果您希望提供自己版本的 Socket.IO 客户端,或者如果它包含在您的应用程序的捆绑包中,您可以使用serveClient方法在 Socket.IO 服务器应用程序中禁用默认行为。

io.serveClient(false) 

另请参阅

  • 第二章,使用 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命名空间:

  1. 创建一个名为io-express-cli.html的新文件

  2. 添加以下 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> 
  1. 在脚本标签中添加从第 4 步开始的下一步中的代码

  2. 定义引用我们将使用的 HTML 元素的常量:

      const title = document.getElementById('title') 
      const error = document.getElementsByName('logErrors')[0] 
      const loginForm = document.getElementById('loginForm') 
  1. 定义一个 Socket.IO 管理器:
      const manager = new io.Manager( 
          'http://localhost:1337', 
          { path: '/socket.io' }, 
      ) 
  1. 定义两个命名空间,一个用于/login,另一个用于/home
      const namespace = { 
          home: manager.socket('/home'), 
          login: manager.socket('/login'), 
      } 
  1. welcome事件添加一个事件监听器,该事件将在允许连接到/home命名空间时由服务器端触发:
      namespace.home.on('welcome', (msg) => { 
          title.textContent = msg 
          error.textContent = '' 
      }) 
  1. loginSuccess事件添加一个事件监听器,当触发时,将要求/home命名空间尝试重新连接到 Socket.IO 服务器:
      namespace.login.on('loginSuccess', () => { 
          namespace.home.connect() 
      }) 
  1. loginError事件添加一个事件监听器,当提供无效凭据时将显示错误:
      namespace.login.on('loginError', err => { 
          error.textContent = err.message 
      }) 
  1. submit事件添加一个事件监听器,当提交表单时将触发该事件。它将发出一个带有包含提供的usernamepassword的数据的enter事件:
      loginForm.addEventListener('submit', event => { 
          const body = new FormData(loginForm) 
          namespace.login.emit('enter', { 
              username: body.get('username'), 
              password: body.get('password'), 
          }) 
          event.preventDefault() 
      }) 
  1. 保存文件。

在此之后,构建一个 ExpressJS 应用程序,该应用程序将在根路径"/"上提供 Socket.IO 客户端,并包含用于记录用户的逻辑的 Socket.IO 服务器:

  1. 创建一个名为io-express-srv.js的新文件

  2. 初始化一个新的 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() 
  1. 定义新连接到 Socket.IO 服务器的路径:
      io.path('/socket.io') 
  1. 使用给定选项定义一个 ExpressJS 会话中间件函数:
      const session = expressSession({ 
          secret: 'MERN Cookbook Secret', 
          resave: true, 
          saveUninitialized: true, 
      }) 
  1. 定义一个 Socket.IO 命名空间中间件,该中间件将使用先前创建的会话中间件生成会话对象:
      const ioSession = (socket, next) => { 
          const req = socket.request 
          const res = socket.request.res 
          session(req, res, (err) => { 
              next(err) 
              req.session.save() 
          }) 
      } 
  1. 定义两个命名空间,一个用于/home,另一个用于/login
      const home = io.of('/home') 
      const login = io.of('/login') 
  1. 定义一个内存数据库或包含usernamepassword属性的对象数组。这些属性定义了允许登录的用户:
      const users = [ 
          { username: 'huangjx', password: 'cfgybhji' }, 
          { username: 'johnstm', password: 'mkonjiuh' }, 
          { username: 'jackson', password: 'qscwdvb' }, 
      ] 
  1. 在 ExpressJS 中包含会话中间件:
      app.use(session) 
  1. /home路径添加一个路由方法,用于提供我们之前创建的包含 Socket.IO 客户端的 HTML 文档:
      app.get('/home', (req, res) => { 
          res.sendFile(path.resolve( 
              __dirname, 
              'io-express-cli.html', 
          )) 
      }) 
  1. /home Socket.IO 命名空间中使用会话中间件。然后,检查每个新的 socket 是否已登录。如果没有,禁止用户连接到此命名空间:
      home.use(ioSession) 
      home.use((socket, next) => { 
          const { session } = socket.request 
          if (session.isLogged) { 
              next() 
          } 
      }) 
  1. 一旦连接到/home命名空间,也就是用户可以登录,就会发出一个带有欢迎消息的welcome事件,该消息将显示给用户:
      home.on('connection', (socket) => { 
          const { username } = socket.request.session 
          socket.emit( 
              'welcome', 
              `Welcome ${username}!, you are logged in!`, 
          ) 
      }) 
  1. /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', 
                  }) 
              } 
          }) 
      }) 
  1. 监听端口1337以获取新连接,并将 Socket.IO 服务器附加到该端口:
      io.attach(app.listen(1337, () => { 
          console.log( 
              'HTTP Server and Socket.IO running on port 1337' 
          ) 
      })) 
  1. 保存文件

  2. 打开一个新的终端并运行:

 node io-express-srv.js  
  1. 在浏览器中访问:
 http://localhost:1337/home
  1. 使用有效的凭据登录。例如:
      * Username: johntm
      * Password: mkonjiuh
  1. 如果您成功登录,刷新页面后,您的 Socket.IO 客户端应用程序仍然能够连接到/home,并且每次都会看到欢迎消息

工作原理...

当在 ExpressJS 中使用会话中间件时,在修改会话对象后,响应结束时会自动调用save方法。然而,在 Socket.IO 命名空间中使用会话中间件时并非如此,这就是为什么我们需要手动调用save方法将会话保存回存储中的原因。在我们的情况下,存储是内存,会话会一直保存在那里直到服务器停止。

根据特定条件禁止访问某些命名空间是可能的,这要归功于 Socket.IO 命名空间中间件。如果控制权没有传递给next处理程序,那么连接就不会建立。这就是为什么在登录成功后,我们要求/home命名空间再次尝试连接。

另请参阅

  • 第二章,使用 ExpressJS 构建 Web 服务器编写中间件函数部分

第五章:使用 Redux 管理状态

在这一章中,我们将涵盖以下的配方:

  • 定义动作和动作创建者

  • 定义减速器函数

  • 创建 Redux 存储

  • 将动作创建者绑定到分派方法

  • 拆分和组合减速器

  • 编写 Redux 存储增强器

  • 使用 Redux 进行时间旅行

  • 了解 Redux 中间件

  • 处理异步数据流

技术要求

您需要一个 IDE、Visual Studio Code、Node.js 和 MongoDB。您还需要安装 Git,以便使用本书的 Git 存储库。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter05

查看以下视频,看看代码是如何运行的:

goo.gl/mU9AjR

介绍

Redux 是 JavaScript 应用程序的可预测状态容器。它允许开发人员轻松管理其应用程序的状态。使用 Redux,状态是不可变的。因此,可以在应用程序的下一个或上一个状态之间来回切换。Redux 遵循三个核心原则:

  • 唯一的真相来源:应用程序的所有状态必须存储在一个单一存储中的单个对象树中

  • 状态是只读的:您不能改变状态树。只有通过分派动作,状态树才能改变

  • 使用纯函数进行更改:这些被称为减速器的函数接受先前的状态和一个动作,并计算一个新的状态。减速器绝不能改变先前的状态,而是始终返回一个新的状态

减速器的工作方式与Array.prototype.reduce函数非常相似。reduce方法对数组中的每个项目执行一个函数,以将其减少为单个值。例如:

const a = 5 
const b = 10 
const c = [a, b].reduce((accumulator, value) => { 
    return accumulator + value 
}, 0) 

在对累加器进行ab的减速时,得到的值是15,初始值为0。这里的减速器函数是:

(accumulator, value) => { 
    return accumulator + value 
} 

Redux 减速器的编写方式类似,它们是 Redux 的最重要概念。例如:

const reducer = (prevState, action) => newState 

在本章中,我们将专注于学习如何使用 Redux 管理简单和复杂的状态树。您还将学习如何处理异步数据流。

定义动作和动作创建者

减速器接受描述将执行的动作的action对象,并根据此action对象决定如何转换状态。

动作只是普通对象,它们只有一个必需的属性,需要存在,即动作类型。例如:

const action = { 
    type: 'INCREMENT_COUNTER', 
} 

我们也可以提供额外的属性。例如:

const action = { 
    type: 'INCREMENT_COUNTER', 
    incrementBy: 2, 
} 

动作创建者只是返回动作的函数,例如:

const increment = (incrementBy) => ({ 
    type: 'INCREMENT_COUNTER', 
    incrementBy, 
}) 

准备工作

在这个配方中,您将看到如何使用Array.prototype.reduce来应用这些简单的 Redux 概念,以决定如何累积或减少数据。

我们暂时不需要 Redux 库来实现这个目的。

如何做...

构建一个小型的 JavaScript 应用程序,根据提供的动作来增加或减少计数器。

  1. 创建一个名为counter.js的新文件

  2. 将动作类型定义为常量:

      const INCREMENT_COUNTER = 'INCREMENT_COUNTER' 
      const DECREMENT_COUNTER = 'DECREMENT_COUNTER' 
  1. 定义两个动作创建者,用于生成增加减少计数器的两种动作:
      const increment = (by) => ({ 
          type: INCREMENT_COUNTER, 
          by, 
      }) 
      const decrement = (by) => ({ 
          type: DECREMENT_COUNTER, 
          by, 
      }) 
  1. 将初始累加器初始化为0,然后通过传递多个动作来减少它。减速器函数将根据动作类型决定执行哪种动作:
      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) 
  1. 记录结果值:
      console.log(reduced) 
  1. 保存文件

  2. 打开终端并运行:

       node counter.js

  1. 输出:8

它是如何工作的...

  1. 减速器遇到的第一个动作类型是increment(10),它将使累加器增加10。因为累加器的初始值是0,下一个当前值将是10

  2. 第二个动作类型告诉减速器函数将累加器减少5。因此,累加器的值将是5

  3. 最后一个动作类型告诉减速器函数将累加器增加3。结果,累加器的值将是8

定义减速器函数

Redux 减速器是纯函数。这意味着它们没有副作用。给定相同的参数,减速器必须始终生成相同形状的状态。例如,以下减速器函数:

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。

不纯的减速器函数会导致您的应用程序状态不可预测,并且难以重现相同的状态。例如:

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 秒后第二次执行函数后,我们得到了不同的结果。为了使其纯净,您可以考虑将先前的不纯减速器重写为:

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属性,您的应用程序将如何运行。

准备工作

现在您已经了解了减速器的工作原理,本教程中,您将构建一个根据状态更改而表现不同的小型应用程序。

为此,您不需要安装或使用 Redux 库。

如何做...

构建一个应用程序,根据您的本地时间提醒您应该吃什么样的餐点。在一个单一的对象树中管理我们应用程序的所有状态。还提供一种模拟应用程序将在00:00a.m12:00p.m时显示的方法:

  1. 创建一个名为meal-time.html的新文件。

  2. 添加以下代码:

      <!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> 
  1. 在脚本标签中添加下一步中定义的代码,从第 4 步开始。

  2. 定义一个变量state,它将包含所有状态树和稍后的下一个状态:

      let state = { 
          kindOfMeal: null, 
          time: null, 
      } 
  1. 创建一个引用 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') 
  1. 定义两种动作类型:
      const SET_MEAL = 'SET_MEAL' 
      const SET_TIME = 'SET_TIME' 
  1. 为用户应该有的餐点定义一个动作创建者:
      const setMeal = (kindOfMeal) => ({ 
          type: SET_MEAL, 
          kindOfMeal, 
      }) 
  1. 定义一个动作创建者,用于设置时间:
      const setTime = (time) => ({ 
          type: SET_TIME, 
          time, 
      }) 
  1. 定义一个减速器函数,当动作被分发时计算新的状态:
      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 
          } 
      } 
  1. 添加一个我们在状态改变时将调用的函数,以便更新我们的视图:
      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 
      } 
  1. 定义一个分发函数,通过将当前状态和动作传递给减速器来生成新的状态树。然后,它将调用onChangeState函数来通知您的应用程序状态已经改变:
      const dispatch = (action) => { 
          state = reducer(state, action) 
          onStateChange(state) 
      } 
  1. 为按钮添加一个事件监听器,模拟时间为00:00a.m
      btnNight.addEventListener('click', () => { 
          const time = new Date('1/1/1 00:00:00') 
          dispatch(setTime(time.toTimeString())) 
      }) 
  1. 为按钮添加一个事件监听器,模拟时间为12:00p.m
      btnNoon.addEventListener('click', () => { 
          const time = new Date('1/1/1 12:00:00') 
          dispatch(setTime(time.toTimeString())) 
      }) 
  1. 脚本运行后,分发一个带有当前时间的动作,以便更新视图:
      dispatch(setTime(new Date().toTimeString())) 
  1. 保存文件。

让我们来测试一下...

查看您之前的工作成果:

  1. 在您的网络浏览器中打开meal-time.html文件。您可以通过双击文件或右键单击文件并选择“使用...”来执行此操作。

  2. 您应该能够看到您当前的本地时间和一条消息,说明您应该有什么样的餐点。例如,如果您的本地时间是20:42:35 GMT+0800 (CST),您应该看到“晚餐时间!”

  3. 点击按钮“让我们假装是 00:00:00”来查看如果时间是00:00a.m,您的应用程序将显示什么。

  4. 同样,点击按钮“让我们假装是 12:00:00”来查看如果时间是12:00p.m,您的应用程序将显示什么。

它是如何工作的...

我们可以总结我们的应用程序如下,以了解它的工作原理:

  1. 动作类型SET_MEALSET_TIME已被定义。

  2. 定义了两个动作创建者:

  3. setMeal生成一个带有SET_MEAL动作类型和kindOfMeal属性的动作

  4. setTime生成一个带有SET_TIME操作类型和提供的参数的time属性的操作

  5. 定义了一个 reducer 函数:

  6. 对于操作类型SET_MEAL,计算一个新的状态,具有一个新的kindOfMeal属性

  7. 对于操作类型SET_TIME,计算一个新的状态,具有一个新的time属性

  8. 我们定义了一个函数,当状态树发生变化时将被调用。在函数内部,我们根据新状态更新了视图。

  9. 定义了一个dispatch函数,它调用 reducer 函数,提供先前的状态和一个操作对象以生成一个新的状态。

创建一个 Redux 存储

在以前的教程中,我们已经看到了如何定义 reducers 和 actions。我们还看到了如何创建一个 dispatch 函数来分派操作,以便 reducers 更新状态。存储是一个提供了一个小 API 的对象,将所有这些放在一起。

redux 模块公开了createStore方法,我们可以使用它来创建一个存储。它具有以下签名:

createStore(reducer, preloadedState, enhancer) 

最后两个参数是可选的。例如,创建一个只有一个 reducer 的 store 可能如下所示:

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):用新的 reducer 函数替换当前的 Reducer 函数

准备工作

在这个教程中,您将重新构建您在上一个教程中构建的应用程序。但是,这一次您将使用 Redux。在开始之前,创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "express": "4.16.3", 
        "redux": "4.0.0" 
    } 
} 

然后,通过打开终端并运行来安装依赖项:

npm install

如何做...

首先,构建一个小的 ExpressJS 服务器应用程序,其唯一目的是提供 HTML 文件和 Redux 模块:

  1. 创建一个名为meal-time-server.js的新文件

  2. 包括 ExpressJS 和path模块,并初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const path = require('path') 
      const app = express() 
  1. /lib路径上提供 Redux 库。确保路径指向node_modules文件夹:
      app.use('/lib', express.static( 
          path.join(__dirname, 'node_modules', 'redux', 'dist') 
      )) 
  1. 在根路径/上提供客户端应用程序:
      app.get('/', (req, res) => { 
          res.sendFile(path.join( 
              __dirname, 
              'meal-time-client.html', 
          )) 
      }) 
  1. 在端口1337上监听新的连接:
      app.listen( 
          1337, 
          () => console.log('Web Server running on port 1337'), 
      ) 
  1. 保存文件

现在,按照以下步骤使用 Redux 构建客户端应用程序:

  1. 创建一个名为meal-time-client.html的新文件。

  2. 添加以下代码:

      <!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> 
  1. 在脚本标签内,从第 4 步开始添加下一步的代码。

  2. 从 Redux 库中提取createStore方法:

      const { createStore } = Redux 
  1. 定义应用程序的初始状态:
      const initialState = { 
          kindOfMeal: null, 
          time: null, 
      } 
  1. 保留将用于显示状态或与应用程序交互的 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') 
  1. 定义两种操作类型:
      const SET_MEAL = 'SET_MEAL' 
      const SET_TIME = 'SET_TIME' 
  1. 定义两个操作创建者:
      const setMeal = (kindOfMeal) => ({ 
          type: SET_MEAL, 
          kindOfMeal, 
      }) 
      const setTime = (time) => ({ 
          type: SET_TIME, 
          time, 
      }) 
  1. 定义将在分派SET_TIME和/或SET_TIME操作类型时转换状态的 reducer:
      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 
          } 
      } 
  1. 创建一个新的 Redux 存储:
      const store = createStore(reducer) 
  1. 订阅一个回调函数以更改存储。每当存储更改时,此回调将被触发,并且它将根据存储中的更改更新视图:
      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 
      }) 
  1. 为我们的按钮添加一个click事件的事件监听器,将分派SET_TIME操作类型以将时间设置为00:00:00
      btnNight.addEventListener('click', () => { 
          const time = new Date('1/1/1 00:00:00') 
          store.dispatch(setTime(time.toTimeString())) 
      }) 
  1. 为我们的按钮添加一个click事件的事件监听器,将分派SET_TIME操作类型以将时间设置为12:00:00
      btnNoon.addEventListener('click', () => { 
          const time = new Date('1/1/1 12:00:00') 
          store.dispatch(setTime(time.toTimeString())) 
      }) 
  1. 当应用程序首次启动时,分派一个操作以将时间设置为当前本地时间:
      store.dispatch(setTime(new Date().toTimeString())) 
  1. 保存文件

让我们来测试一下...

查看以前的工作成果:

  1. 打开一个新的终端并运行:
 node meal-time-server.js
  1. 在您的网络浏览器中,访问:

       http://localhost:1337/
  1. 您应该能够看到您当前的本地时间和一条消息,说明您应该吃什么样的饭。例如,如果您的本地时间是20:42:35 GMT+0800 (CST),您应该看到晚餐时间!

  2. 单击按钮“假设现在是 00:00:00”,查看如果时间是00:00a.m,您的应用程序会显示什么。

  3. 同样,点击“假装是 12:00:00”按钮,看看如果时间是 12:00p.m,你的应用程序会显示什么。

还有更多

你可以使用 ES6 扩展运算符来合并你的先前状态和下一个状态,例如,我们重写了前面食谱的减速器函数:

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 文件,我们将在之后构建:

  1. 创建一个名为bind-server.js的新文件

  2. 添加以下代码:

      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'), 
      ) 
  1. 保存文件

接下来,在 HTML 文件中构建待办事项应用程序:

  1. 创建一个名为bind-index.html的新文件。

  2. 添加以下代码:

      <!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> 
  1. 在脚本标记内,从第 4 步开始,按照以下步骤添加代码。

  2. 保留一个将在应用程序中使用的 HTML DOM 元素的引用:

      const form = document.querySelector('#item-form') 
      const input = document.querySelector('#item-input') 
      const list = document.querySelector('#list') 
  1. 定义你的应用程序的初始状态:
      const initialState = { 
          items: [], 
      } 
  1. 定义一个动作类型:
      const TYPE = { 
          ADD_ITEM: 'ADD_ITEM', 
      } 
  1. 定义一个动作创建者:
      const actions = { 
          addItem: (text) => ({ 
              type: TYPE.ADD_ITEM, 
              text, 
          }) 
      } 
  1. 定义一个减速器函数,每当分派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 
          } 
      } 
  1. 创建一个存储,并将dispatch函数绑定到动作创建者:
      const { createStore, bindActionCreators } = Redux 
      const store = createStore(reducer) 
      const { addItem } = bindActionCreators( 
          actions,  
          store.dispatch, 
      ) 
  1. 订阅存储,每当状态改变时向列表中添加一个新项目。如果已经定义了一个项目,我们将重复使用它,而不是创建一个新项目:
      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)) 
          }) 
      }) 
  1. 为表单添加一个submit事件的事件侦听器。这样,我们就可以获取输入值并分派一个动作:
      form.addEventListener('submit', (event) => { 
          event.preventDefault() 
          addItem(input.value) 
      }) 
  1. 保存文件。

让我们来测试一下…

要查看之前的工作成果:

  1. 打开一个新的终端并运行:
 node bind-server.js
  1. 在浏览器中访问:
     http://localhost:1337/
  1. 在输入框中输入一些内容,然后按 Enter。列表中应该会出现一个新项目。

  2. 尝试向列表中添加超过五个项目。显示的最后一个将被移除,视图上只保留五个项目。

分割和组合 reducer

随着应用程序的增长,你可能不希望在一个简单的 reducer 函数中编写应用程序状态的转换逻辑。你可能希望编写更小的 reducer,专门管理状态的独立部分。

举个例子,以下是一个 reducer 函数:

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 
    } 
} 

你有两个属性来管理应用程序的两个不同部分的状态。一个管理待办事项列表的状态,另一个管理聊天消息的状态。你可以将这个 reducer 分割成两个 reducer 函数,每个函数管理状态的一个片段,例如:

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方法只接受一个 reducer 作为第一个参数,你需要将它们合并成一个单一的 reducer:

const reducer = (state = initialState, action) => { 
    return { 
        todoList: todoListReducer(state.todoList, action), 
        chatMsg: chatMsgReducer(state.chatMsg, action), 
    } 
} 

通过这种方式,我们能够将 reducer 分割成更小的 reducer,专门管理状态的一个片段,然后将它们合并成一个单一的 reducer 函数。

Redux 提供了一个名为combineReducers的辅助方法,允许你以类似的方式组合 reducer,但不需要重复大量的代码;例如,我们可以像这样重新编写组合 reducer 的先前方式:

const reducer = combineReducers({ 
    todoList: todoListReducer, 
    chatMsg: chatMsgReducer, 
}) 

combineReducers方法是一个高阶 reducer函数。它接受一个对象映射,指定键到特定reducer函数管理的状态片段,并返回一个新的 reducer 函数。例如,如果你运行以下代码:

console.log(JSON.stringify( 
    reducer(initialState, { type: null }), 
    null, 2, 
)) 

你会看到生成的状态形状如下:

{ 
    "todoList": [], 
    "chatMsg": [], 
} 

我们也可以尝试一下,看看我们组合的 reducer 是否工作,并且只管理分配给它们的状态部分。例如:

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": [], 
} 

这表明每个 reducer 只管理分配给它们的状态片段。

准备工作

在这个教程中,你将重新创建待办事项应用程序,就像在之前的教程中一样。但是,你将添加其他功能,比如删除和切换待办事项。你将定义应用程序的其他状态,这些状态将由单独的 reducer 函数管理。首先,创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "express": "4.16.3", 
        "redux": "4.0.0" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做...

首先,构建一个小的 ExpressJS 服务器应用程序,它将为客户端应用程序提供服务,并安装在node_modules中的 Redux 库:

  1. 创建一个名为todo-time.js的新文件

  2. 添加以下代码:

      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'), 
      ) 
  1. 保存文件

接下来,构建待办事项客户端应用程序。还包括一个单独的 reducer 来管理当前本地时间的状态和一个随机幸运数字生成器:

  1. 创建一个名为todo-time.html的新文件

  2. 添加以下 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> 
  1. 在 script 标签内添加以下 JavaScript 代码,按照下面的步骤开始

  2. 保留我们将用来显示数据或与应用程序交互的 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') 
  1. 从 Redux 库中获取createStore方法和辅助方法:
      const { 
          createStore, 
          combineReducers, 
          bindActionCreators, 
      } = Redux 
  1. 设置 action 类型:
      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', 
      } 
  1. 定义 action creators:
      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, 
          }), 
      } 
  1. 定义一个 reducer 函数来管理状态的一个片段,保存时间:
      const currentTime = (state = null, action) => { 
          switch (action.type) { 
              case TYPE.SET_TIME: return action.time 
              default: return state 
          } 
      } 
  1. 定义一个 reducer 函数来管理状态的一个片段,保存每次用户加载应用程序时生成的幸运数字:
      const luckyNumber = (state = null, action) => { 
          switch (action.type) { 
              case TYPE.SET_LUCKY_NUMBER: return action.number 
              default: return state 
          } 
      } 
  1. 定义一个 reducer 函数来管理状态的一个片段,保存待办事项的数组:
      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 
          } 
      } 
  1. 将所有的 reducer 合并成一个单一的 reducer:
      const reducer = combineReducers({ 
          currentTime, 
          luckyNumber, 
          todoList, 
      }) 
  1. 创建一个 store:
      const store = createStore(reducer) 
  1. 将所有的 action creators 绑定到 store 的dispatch方法上:
      const { 
          setTime, 
          setLuckyNumber, 
          addTodo, 
          removeTodo, 
          toggleTodo, 
      } = bindActionCreators(actions, store.dispatch) 
  1. 订阅一个监听器到 store,当状态改变时更新包含时间的 HTML 元素:
      store.subscribe(() => { 
          const { currentTime } = store.getState() 
          timeElem.textContent = currentTime 
      }) 
  1. 订阅一个监听器到 store,当状态改变时更新包含幸运数字的 HTML 元素:
      store.subscribe(() => { 
          const { luckyNumber } = store.getState() 
          luckyElem.textContent = `Your lucky number is: ${luckyNumber}` 
      }) 
  1. 订阅一个监听器到 store,当状态改变时更新包含待办事项列表的 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) 
          }) 
      }) 
  1. 在列表 HTML 元素上添加一个click事件的事件监听器,以在点击项目时切换待办事项的completed属性:
      listElem.addEventListener('click', (event) => { 
    toggleTodo(event.target.dataset.id) 
      }) 
  1. 在列表 HTML 元素上添加一个drag事件的事件监听器,当拖动项目到列表之外时,将移除一个待办事项:
      listElem.addEventListener('drag', (event) => { 
          removeTodo(event.target.dataset.id) 
      }) 
  1. 在包含输入 HTML 元素的表单上添加一个submit事件的事件监听器,以分派一个新动作来添加一个新的待办事项:
      let id = 0 
      formElem.addEventListener('submit', (event) => { 
          event.preventDefault() 
          addTodo(++id, inputElem.value) 
          inputElem.value = '' 
      }) 
  1. 当页面首次加载时,分发一个动作来设置一个幸运数字,并定义一个每秒触发的函数,以更新应用程序状态中的当前时间:
      setLuckyNumber(Math.ceil(Math.random() * 1024)) 
      setInterval(() => { 
          setTime(new Date().toTimeString()) 
      }, 1000) 
  1. 保存文件

让我们来测试一下...

要查看之前的工作成果:

  1. 打开一个新的终端并运行:
 node todo-time.js
  1. 在浏览器中,访问:
      http://localhost:1337/
  1. 在输入框中输入内容并按回车。列表中应该会出现一个新项目。

  2. 点击其中一个您添加的项目,标记为已完成。

  3. 再次点击其中一个标记为已完成的项目,将其标记为未完成。

  4. 点击并拖动其中一个项目到列表之外,以将其从待办事项列表中移除。

它是如何工作的...

  1. 定义了三个 reducer 函数,分别独立管理具有以下结构的状态切片:
      { 
          currentTime: String, 
          luckyNumber: Number, 
          todoList: Array.of({ 
              id: Number, 
              title: String, 
              completed: Boolean, 
          }), 
      } 
  1. 我们使用了 Redux 库中的combineReducers辅助方法,将这三个 reducer 组合成一个单一的 reducer

  2. 然后,创建了一个存储,提供了组合的 reducer 函数

  3. 为方便起见,我们订阅了三个监听函数,每当状态发生变化时,这些函数就会被触发,以更新用于显示状态数据的 HTML 元素

  4. 我们还定义了三个事件监听器:一个用于检测用户提交包含输入 HTML 元素的表单以添加新的待办事项,另一个用于检测用户点击屏幕上显示的待办事项以切换其状态,最后一个事件监听器用于检测用户拖动列表中的元素以分派一个动作将其从待办事项列表中移除

编写 Redux 存储增强器

Redux 存储增强器是一个高阶函数,它接受一个存储创建函数,并返回一个新的增强存储创建函数。createStore方法是一个存储创建函数,具有以下签名:

createStore = (reducer, preloadedState, enhancer) => Store 

而存储增强器函数具有以下签名:

enhancer = (...optionalArguments) => ( 
createStore => (reducer, preloadedState, enhancer) => Store 
) 

现在可能看起来有点难以理解,但如果一开始不理解也不必担心,因为您可能永远不需要编写存储增强器。这个示例的目的只是帮助您以非常简单的方式理解它们的目的。

准备工作

在这个示例中,您将创建一个存储增强器,以扩展 Redux 的功能,允许在MapJavaScript 原生对象中定义 reducer 函数。首先,创建一个新的package.json文件,内容如下:

{ 
    "dependencies": { 
        "redux": "4.0.0" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

 npm install

如何做...

记住,createStore接受一个单一的 reducer 函数作为第一个参数。我们编写了一个存储增强器,允许createStore方法接受一个包含键值对的Map对象,其中键是将要管理的状态属性或切片,值是一个reducer函数。然后,使用Map对象定义了两个 reducer 函数来处理状态的两个切片,一个用于计数,另一个用于设置当前时间:

  1. 创建一个名为map-store.js的新文件。

  2. 包括 Redux 库:

      const { 
          createStore, 
          combineReducers, 
          bindActionCreators, 
      } = require('redux') 
  1. 定义一个存储增强函数,允许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, 
              ) 
          } 
      ) 
  1. 定义动作类型:
      const TYPE = { 
          INC_COUNTER: 'INC_COUNTER', 
          DEC_COUNTER: 'DEC_COUNTER', 
          SET_TIME: 'SET_TIME', 
      } 
  1. 定义动作创建者:
      const actions = { 
          incrementCounter: (incBy) => ({ 
              type: TYPE.INC_COUNTER, 
              incBy, 
          }), 
          decrementCounter: (decBy) => ({ 
              type: TYPE.DEC_COUNTER, 
              decBy, 
          }), 
          setTime: (time) => ({ 
              type: TYPE.SET_TIME, 
              time, 
          }), 
      } 
  1. 定义一个map常量,其中包含一个Map的实例:
      const map = new Map() 
  1. map对象添加一个新的 reducer 函数,使用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 
          } 
      }) 
  1. map对象添加另一个 reducer 函数,使用time作为键:
      map.set('time', (state = null, action) => { 
          switch (action.type) { 
              case TYPE.SET_TIME: return action.time 
              default: return state 
          } 
      }) 
  1. 创建一个新的存储,将map作为第一个参数,并将存储增强器作为第二个参数,以扩展createStore方法的功能:
      const store = createStore(map, acceptMap()) 
  1. 将先前定义的动作创建者绑定到存储的dispatch方法:
      const { 
          incrementCounter, 
          decrementCounter, 
          setTime, 
      } = bindActionCreators(actions, store.dispatch) 
  1. 要在 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) 
  1. 保存文件。

  2. 打开一个新的终端并运行:

 node map-store.js
  1. 当前状态将每秒显示一次,具有以下形式:
      { 
          "counter": Number, 
          "time": String, 
      } 

它是如何工作的...

增强器将存储创建者组合成一个新的存储创建者。例如,以下行:

const store = createStore(map, acceptMap()) 

可以写成:

const store = acceptMap()(createStore)(map) 

实际上,这在某种程度上将原始的createStore方法包装到另一个createStore方法中。

组合可以解释为一组函数,这些函数被调用并接受前一个函数的结果参数。例如:

const c = (...args) => f(g(h(...args))) 

这将函数fgh从右到左组合成一个单一的函数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 库:

  1. 创建一个名为time-travel.js的新文件

  2. 添加以下代码:

      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'), 
      ) 
  1. 保存文件

接下来,使用时间旅行功能构建您的计数器,Redux 动力应用程序:

  1. 创建一个名为time-travel.html的新文件

  2. 添加以下 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> 
  1. 在脚本标签中添加以下 JavaScript 代码,按照以下步骤开始,从第 4 步开始

  2. 保留一个引用到span HTML 元素,每当状态改变时将显示计数器的当前值:

      const counterElem = document.querySelector('#counter') 
  1. 从 Redux 库中获取createStore方法和bindActionCreators方法:
      const { 
          createStore, 
          bindActionCreators, 
      } = Redux 
  1. 定义两种动作类型:
      const TYPE = { 
          INC_COUNTER: 'INC_COUNTER', 
          DEC_COUNTER: 'DEC_COUNTER', 
      } 
  1. 定义两个动作创建者:
      const actions = { 
          incCounter: (by) => ({ type: TYPE.INC_COUNTER, by }), 
          decCounter: (by) => ({ type: TYPE.DEC_COUNTER, by }), 
      } 
  1. 定义一个 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 
          } 
      } 
  1. 创建一个新的存储,提供一个存储增强器函数,当安装 Redux DevTools 扩展时,它将在window对象上可用:
      const store = createStore( 
          reducer, 
          ( 
              window.__REDUX_DEVTOOLS_EXTENSION__ && 
              window.__REDUX_DEVTOOLS_EXTENSION__() 
          ), 
      ) 
  1. 将动作创建者绑定到存储的dispatch方法:
      const { 
          incCounter, 
          decCounter, 
      } = bindActionCreators(actions, store.dispatch) 
  1. 订阅一个监听函数到存储,每当状态改变时将更新span HTML 元素:
      store.subscribe(() => { 
          const state = store.getState() 
          counterElem.textContent = state.value 
      }) 
  1. 让我们创建一个for循环,当应用程序运行时,它会随机更新增加或减少计数器 10 次:
      for (let i = 0; i < 10; i++) { 
          const incORdec = (Math.random() * 10) > 5 
          if (incORdec) incCounter(2) 
          else decCounter(1) 
      } 
  1. 保存文件

让我们来测试一下...

要查看之前的工作效果:

  1. 打开一个新的终端并运行:
 node todo-time.js
  1. 在您的浏览器中访问:
      http://localhost:1337/
  1. 打开浏览器的开发者工具,并查找 Redux 选项卡。您应该看到一个类似这样的选项卡:

Redux DevTools – Tab Window

  1. 滑块允许您从应用程序的最后状态移动到最初状态。尝试将滑块移动到不同的位置:

Redux DevTools – Moving Slider

  1. 在移动滑块时,您可以在浏览器中看到计数器的初始值以及在 for 循环中如何改变这些值十次

还有更多

Redux DevTools具有一些功能,您可能会发现令人惊讶和有助于调试和管理应用程序状态。实际上,如果您遵循了之前的示例,我建议您返回我们编写的项目,并启用此增强器,尝试使用 Redux DevTools 进行实验。

Redux DevTools 的众多功能之一是 Log 监视器,它按时间顺序显示分派的动作以及转换状态的结果值:

Redux DevTools – Log Monitor

理解 Redux 中间件

可能最简单和最好的扩展 Redux 功能的方法是使用中间件。

Redux 库中有一个名为applyMiddleware的 store 增强函数,允许您定义一个或多个中间件函数。Redux 中的中间件工作方式很简单,它允许您包装 store 的dispatch方法以扩展其功能。与 store 增强函数一样,中间件是可组合的,并具有以下签名:

middleware = API => next => action => next(action) 

在这里,API是一个包含来自 store 的dispatchgetState方法的对象,解构API,签名如下:

middleware = ({ 
    getState, 
    dispatch, 
}) => next => action => next(action)  

让我们分析它是如何工作的:

  1. applyMiddleware函数接收一个或多个中间件函数作为参数。例如:
      applyMiddleware(middleware1, middleware2) 
  1. 每个中间件函数在内部都被保留为一个Array。然后,在内部使用Array.prototype.map方法,数组通过调用自身提供 store 的dispatchgetState方法的中间件API对象来映射每个中间件函数。类似于这样:
      middlewares.map((middleware) => middleware(API)) 
  1. 然后,通过组合所有中间件函数,使用next参数计算dispatch方法的新值。在执行的第一个中间件中,next参数指的是在应用任何中间件之前的原始dispatch方法。例如,如果应用了三个中间件函数,新计算的 dispatch 方法的签名将是:
      dispatch = (action) => ( 
          (action) => ( 
              (action) => store.dispatch(action) 
          )(action) 
      )(action) 
  1. 这意味着中间件函数可以中断链,并且如果未调用next(action)方法,则可以阻止某个动作的分派

  2. 中间件API对象的 dispatch 方法允许您调用 store 的 dispatch 方法,并应用之前应用的中间件。这意味着,如果在使用此方法时不小心,可能会创建一个无限循环

最初可能不那么简单地理解其内部工作方式,但我向你保证,你很快就会理解。

准备工作

在这个示例中,您将编写一个中间件函数,当分派未定义的动作类型时,它将警告用户。首先,创建一个包含以下内容的新的package.json文件:

{ 
    "dependencies": { 
        "redux": "4.0.0" 
    } 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做…

当在 reducers 中从未定义过的 action 类型被使用时,Redux 不会警告你或显示错误。构建一个 NodeJS 应用程序,该应用程序将使用 Redux 来管理其状态。专注于编写一个中间件函数,该函数将检查分派的动作类型是否已定义,否则会抛出错误:

  1. 创建一个名为type-check-redux.js的新文件。

  2. 包括 Redux 库:

      const { 
          createStore, 
          applyMiddleware, 
      } = require('redux') 
  1. 定义一个包含允许的动作类型的对象:
      const TYPE = { 
          INCREMENT: 'INCREMENT', 
          DECREMENT: 'DECREMENT', 
          SET_TIME: 'SET_TIME', 
      } 
  1. 创建一个虚拟的 reducer 函数,无论调用哪种动作类型,它都会返回其原始状态。我们不需要它来实现这个示例的目的:
      const reducer = ( 
          state = null, 
          action, 
      ) => state 
  1. 定义一个中间件函数,该函数将拦截正在分派的每个操作,并检查操作类型是否存在于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 
          } 
      } 
  1. 创建一个存储并应用定义的中间件函数:
      const store = createStore( 
          reducer, 
          applyMiddleware(typeCheckMiddleware), 
      ) 
  1. 分派两种操作类型。第一个操作类型是有效的,并且存在于TYPE对象中。但是,第二个是一个从未定义的操作类型:
      store.dispatch({ type: 'INCREMENT' }) 
      store.dispatch({ type: 'MISTAKE' }) 
  1. 保存文件。

让我们来测试一下...

首先,打开一个新的终端并运行:

    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对象常量的属性。如果存在,则中间件将控制传递给链中的下一个中间件。但是,在我们的情况下,没有下一个中间件,因此控制权被传递给存储的原始分派方法,该方法将应用减速器并转换状态。另一方面,如果未定义操作类型,则中间件函数通过不调用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路径上,我们将假装存在内部错误,并使请求失败,以查看如何处理异步请求中的错误:

  1. 创建一个名为api-server.js的新文件

  2. 包括 ExpressJS 库并初始化一个新的 ExpressJS 应用程序:

      const express = require('express') 
      const app = express() 
  1. 对于/time路径,在发送响应之前模拟延迟2s
      app.get('/time', (req, res) => { 
          setTimeout(() => { 
              res.send(new Date().toTimeString()) 
          }, 2000) 
      }) 
  1. 对于/date路径,在发送失败响应之前模拟延迟2s
      app.get('/date', (req, res) => { 
          setTimeout(() => { 
              res.destroy(new Error('Internal Server Error')) 
          }, 2000) 
      }) 
  1. 监听端口1337以获取新连接
      app.listen( 
          1337, 
          () => console.log('API server running on port 1337'), 
      ) 
  1. 保存文件

至于客户端,使用 Redux 构建一个 NodeJS 应用程序,该应用程序将分派同步和异步操作。编写一个中间件函数,以使分派方法能够处理异步操作:

  1. 创建一个名为async-redux.js的新文件

  2. 包括node-fetch和 Redux 库:

      const fetch = require('node-fetch') 
      const { 
          createStore, 
          applyMiddleware, 
          combineReducers, 
          bindActionCreators, 
      } = require('redux') 
  1. 定义三种状态。每种状态表示异步操作的状态:
      const STATUS = { 
          PENDING: 'PENDING', 
          RESOLVED: 'RESOLVED', 
          REJECTED: 'REJECTED', 
      } 
  1. 定义两种操作类型:
      const TYPE = { 
          FETCH_TIME: 'FETCH_TIME', 
          FETCH_DATE: 'FETCH_DATE', 
      } 
  1. 定义操作创建者。请注意,前两个操作创建者中的值属性是一个异步函数。稍后定义的中间件函数将负责使 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, 
          }) 
      } 
  1. 定义一个通用函数,用于从操作对象中设置值,该函数将在您的减速器中使用:
      const setValue = (prevState, action) => ({ 
          ...prevState, 
          value: action.value || null, 
          error: action.error || null, 
          status: action.status || STATUS.RESOLVED, 
      }) 
  1. 定义应用程序的初始状态:
      const iniState = { 
          time: { 
              value: null, 
              error: null, 
              status: STATUS.RESOLVED, 
          }, 
          date: { 
              value: null, 
              error: null, 
              status: STATUS.RESOLVED, 
          } 
      } 
  1. 定义一个减速器函数。请注意,它只有一个减速器,处理状态的两个部分,即timedate
      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 
          } 
      } 
  1. 定义一个中间件函数,用于检查分发的动作类型是否具有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) 
      } 
  1. 创建一个新的存储器,并应用你定义的中间件函数来扩展dispatch方法的功能:
      const store = createStore( 
          timeReducer, 
          applyMiddleware( 
              allowAsync, 
          ), 
      ) 
  1. 将动作创建器绑定到存储器的dispatch方法上:
      const { 
          setTime, 
          fetchTime, 
          fetchDate, 
      } = bindActionCreators(actions, store.dispatch) 
  1. 订阅一个函数监听器到存储器,并在每次状态发生变化时在终端显示状态树,以 JSON 字符串的形式。
      store.subscribe(() => { 
          console.log('x1b[1;34m%sx1b[0m', 'State has changed') 
          console.dir( 
              store.getState(), 
              { colors: true, compact: false }, 
          ) 
      }) 
  1. 分发一个同步动作来设置时间:
      setTime(new Date().toTimeString()) 
  1. 分发一个异步动作来获取并设置时间:
      fetchTime() 
  1. 分发另一个异步动作来获取并尝试设置日期。请记住,这个操作应该失败,这是故意的。
      fetchDate() 
  1. 保存文件。

让我们来测试一下...

要查看之前的工作成果:

  1. 打开一个新的终端并运行:
 node api-server.js
  1. 在不关闭先前运行的 NodeJS 进程的情况下,打开另一个终端并运行:
 node async-redux.js

工作原理是这样的...

  1. 每当状态发生变化时,订阅的监听函数将在终端中漂亮地打印出当前状态树。

  2. 第一个分发的动作是同步的。它将导致状态树的时间片段被更新,例如像这样:

      time: { 
          value: "01:02:03 GMT+0000", 
          error: null, 
          status: "RESOLVED" 
      } 
  1. 第二个被分发的动作是异步的。在内部,会分发两个动作来反映异步操作的状态,一个是在异步函数仍在执行时,另一个是在异步函数被执行完成时。
      time: { 
          value: null, 
          error: null, 
          status: "PENDING" 
      } 
      // Later, once the operation is fulfilled: 
      time: { 
          value: "01:02:03 GMT+0000", 
          error: null, 
          status: "RESOLVED" 
      } 
  1. 第三个被分发的动作也是异步的。在内部,它也会导致分发两个动作来反映异步操作的状态。
      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" 
      } 
  1. 请注意,由于操作是异步的,终端显示的输出可能不总是按照相同的顺序进行。

  2. 注意,第一个异步操作被执行完成,并且状态标记为RESOLVED,而第二个异步操作被执行完成,并且其状态标记为REJECTED

  3. 状态PENDINGRESOLVEDREJECTED反映了 JavaScript Promise 可能具有的三种状态,并且它们不是强制性的名称,只是易于记忆。

还有更多...

如果你不想编写自己的中间件函数或存储增强器来处理异步操作,你可以选择使用 Redux 的许多现有库之一。其中两个最常用或最受欢迎的是这些:

第六章:使用 React 构建 Web 应用程序

在本章中,我们将涵盖以下配方:

  • 理解 React 元素和 React 组件

  • 组合组件

  • 有状态组件和生命周期方法

  • 使用 React.PureComponent

  • React 事件处理程序

  • 条件渲染组件

  • 使用 React 渲染列表

  • 在 React 中处理表单和输入

  • 理解引用及其使用方法

  • 理解 React 门户

  • 使用错误边界组件捕获错误

  • 使用 PropTypes 对属性进行类型检查

技术要求

您需要了解 Go 编程语言,还需要了解 Web 应用程序框架的基础知识。您还需要安装 Git,以便使用本书的 Git 存储库。最后,需要能够在命令行上使用 IDE 进行开发。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/MERN-Quick-Start-Guide/tree/master/Chapter06

查看以下视频以查看代码的运行情况:

goo.gl/J7d7Ag

介绍

React 是用于构建用户界面UI)的 JavaScript 库。React 是基于组件的,这意味着每个组件可以独立于其他组件并管理自己的状态。可以通过组合组件创建复杂的 UI。

通常使用 JSX 语法创建组件,该语法具有类似 XML 的语法,或者使用React.createElement方法。但是,JSX 是使 React 以声明方式构建 Web 应用程序的特殊之处。

在 MVC 模式中,React 通常与视图相关联。

理解 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 元素提供属性,称为属性或 props 在 React 中:

const element = ( 
    <input type="text" value="Example" readOnly /> 
) 

React 组件允许您将 Web 应用程序的部分作为可重用的代码或组件隔离出来。它们可以以多种方式定义。例如:

  • 功能组件:这些是接受属性作为第一个参数并返回 React 元素的纯 JavaScript 函数:
      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属性将为nullchildren属性不一定需要是 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 树的表示。它通过使用虚拟 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 应用程序,将显示一个欢迎消息,编写功能、类和表达式组件:

  1. 创建一个名为basics.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个新的功能组件,它将使用红色设置为其样式属性的color呈现一个span React 元素:
      const RedText = ({ text }) => ( 
          <span style={{ color: 'red' }}> 
              {text} 
          </span> 
      ) 
  1. 定义另一个功能组件,它将呈现一个h1 React 元素和RedText功能组件作为其children的一部分:
      const Welcome = ({ to }) => ( 
          <h1>Hello, <RedText text={to}/></h1> 
      ) 
  1. 定义一个包含对 React 元素的引用的表达式:
      const TodoList = ( 
          <ul> 
              <li>Lunch at 14:00 with Jenny</li> 
              <li>Shower</li> 
          </ul> 
      ) 
  1. 定义一个名为Footer的类组件,它将显示当前日期:
      class Footer extends React.Component { 
          render() { 
              return ( 
                  <footer> 
                      {new Date().toDateString()} 
                  </footer> 
              ) 
          } 
      } 
  1. 将应用程序渲染到 DOM 中:
      ReactDOM.render( 
          <div> 
              <Welcome to="John" /> 
              {TodoList} 
              <Footer /> 
          </div>, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

然后,在那里您将渲染 React 应用程序创建一个index.html文件:

  1. 创建一个名为index.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 保存文件

让我们来测试一下...

要查看以前的工作成果:

  1. 在项目目录的根目录打开终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签,转到:
      http://localhost:1337/
  1. 您应该能够看到 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" 
  } 
} 

接下来,创建一个名为.babelrc的 babel 配置文件,添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install

如何做...

在项目的根目录中创建一个名为component的新文件夹。然后,按顺序创建以下三个文件:

  1. Header.js

  2. Footer.js

  3. Description.js

Header组件将生成一个代表页面标题的h1 React 元素。它期望接收一个title属性:

  1. component目录中创建一个名为Header.js的新文件

  2. 添加以下代码:

      import * as React from 'react' 
import * as ReactDOM from 'react-dom' 
      export default ({ title }) => ( 
          <h1>{title}</h1> 
      ) 
  1. 保存文件

Footer组件将生成一个footer React 元素,将放置在页面的末尾。它将期望接收一个date属性:

  1. component目录中创建一个名为Footer.js的新文件

  2. 添加以下代码:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
      export default ({ date }) => ( 
          <footer>{date}</footer> 
      ) 
  1. 保存文件

Description组件将生成一个段落,显示页面的描述:

  1. component目录中创建一个名为Description.js的新文件

  2. 添加以下代码:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
      export default () => ( 
          <p>This is a cool website designed with ReactJS</p> 
      ) 
  1. 保存文件

接下来,从component目录返回到项目的根目录,即package.json所在的位置,并创建以下文件:

  1. 创建一个名为composing-react.js的新文件

  2. 导入 React 和ReactDOM库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 导入先前定义的组件:
      import Header from './component/Header' 
      import Footer from './component/Footer' 
      import Description from './component/Description' 
  1. 定义一个App组件,它将渲染您先前定义的组件:
      const App = () => ( 
          <React.Fragment> 
              <Header title="Simple React App" /> 
              <Description /> 
              <Footer date={new Date().toDateString()} /> 
          </React.Fragment> 
      ) 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件

然后,创建一个index.html文件,在其中渲染 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 保存文件

让我们来测试一下...

要查看先前的工作效果,请执行以下步骤:

  1. 在项目目录的根目录打开终端并运行:
 npm start
  1. 然后,在您的 Web 浏览器中打开一个新标签,转到:
      http://localhost:1337/ 
  1. 如果您在浏览器的开发者工具中检查 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;这是因为状态是异步设置的。但是,一旦状态更新,控制台将显示“日期已更新”。

使用setState方法时,React 会将先前的状态与当前给定的状态合并。在内部,它类似于执行以下操作:

currentState = Object.assign({}, currentState, nextState) 

每个类组件都有生命周期方法,这些方法让你控制组件的生命周期,从创建到销毁,同时也让你控制其他属性,比如知道组件何时接收到新的属性,以及组件是否应该更新。这些是所有类组件中存在的生命周期方法:

  • constructor(props): 在组件初始化新实例之前调用,组件挂载之前。必须使用super(props)props传递给父类,以便让 React 正确设置propsconstructor方法也很有用,可以初始化组件的初始状态。

  • 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" 
  } 
} 

接下来,创建一个名为.babelrc的 babel 配置文件,添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install  

操作步骤

构建一个LifeCycleTime组件,其唯一目的是显示当前时间。该组件将每 100 毫秒更新一次,以保持与时间变化的同步。我们将在这个组件中使用生命周期方法来实现以下目的:

  • constructor(props): 初始化组件的初始状态。

  • static getDerivedStateFromProps(nextProps, nextState): 用于将props与状态合并。

  • componentDidMount(): 使用setInterval设置一个每 100 毫秒执行一次的函数,该函数将更新当前时间的状态。

  • shouldComponentUpdate(nextProps, nextState): 决定是否应该渲染组件。检查props是否具有设置为truedontUpdate属性,这意味着在stateprops更改时不应该更新组件。

  • componentDidUpdate(prevProps, prevState, snapshot): 简单地在控制台中记录组件已更新并显示snapshot的值。

  • getSnapshotBeforeUpdate(prevProps, prevState): 为了说明这个方法的功能,简单地返回一个字符串,该字符串将作为第三个参数传递给componentDidUpdate

  • componentWillUnmount(): 当组件被销毁或卸载时,清除在componentDidMount中定义的间隔。否则,在组件卸载后,您将看到显示错误。

首先,创建一个index.html文件,您将在其中呈现 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下代码:

      <!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> 
  1. 保存文件

接下来,执行以下步骤来构建LifeCycleTime组件:

  1. 创建一个名为stateful-react.js的新文件

  2. 导入 React 和ReactDOM库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个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> 
              ) 
          } 
      } 
  1. 然后,定义一个App类组件,用于测试先前创建的组件。添加三个按钮:一个用于在红色和蓝色之间切换颜色属性,并将其作为 prop 传递给LifeCycleTime组件,另一个用于在状态中在 true 和 false 之间切换dontUpdate属性,然后将其作为 prop 传递给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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要查看以前的工作情况,请执行以下步骤:

  1. 在项目目录的根目录下打开终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签并转到:
 http://localhost:1337/
  1. 使用按钮来切换组件的状态,并了解生命周期方法如何影响组件的功能。

使用 React.PureComponent

React.PureComponent类似于React.Component。不同之处在于,React.Component在内部实现了shouldComponentUpdate生命周期方法,以对stateprops进行浅层比较,以决定组件是否应该更新。

准备工作

在这个示例中,您将编写两个组件,一个扩展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" 
  } 
} 

接下来,创建一个名为.babelrc的 babel 配置文件,并添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

npm install  

如何做...

构建一个 React 应用程序,以更好地说明和理解React.PureComponent的工作原理。创建两个组件:一个将扩展React.Component,而另一个将扩展React.PureComponent。这两个组件都将放置在另一个名为App的 React 组件中,该组件将每秒更新其状态。在这两个组件的生命周期方法componentDidUpdate中,我们将在控制台上记录当父组件App更新时它们中的哪一个被更新。

首先,创建一个index.html文件,其中将呈现 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

然后,按照以下步骤构建 React 应用程序:

  1. 创建一个名为pure-component.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个扩展React.PureComponent类的Button类组件:
      class Button extends React.PureComponent { 
          componentDidUpdate() { 
              console.log('Button Component did update!') 
          } 
          render() { 
              return ( 
                  <button>{this.props.children}</button> 
              ) 
          } 
      } 
  1. 定义一个扩展React.Component类的Text类组件:
      class Text extends React.Component { 
          componentDidUpdate() { 
              console.log('Text Component did update!') 
          } 
          render() { 
              return this.props.children 
          } 
      } 
  1. 定义一个简单的扩展React.Component类的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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要查看以前的工作情况,请执行以下步骤:

  1. 在项目目录的根目录下打开终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签并转到:
 http://localhost:1337/  
  1. 计数器将每秒增加一次。在浏览器中打开开发者工具并检查控制台输出。您应该会看到以下内容:
      [N] Text Component did update! 

它是如何工作的...

因为React.PureComponent在内部实现了shouldComponentUpdate生命周期方法,所以它不会更新Button组件,因为它的stateprops没有改变。但是,它会更新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 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

接下来,编写一个组件,为onClick事件定义一个事件处理程序:

  1. 创建一个名为events.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个类组件,它将呈现一个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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要查看应用程序的工作情况,请执行以下步骤:

  1. 在项目目录的根目录打开终端并运行:
       npm start
  1. 然后,在您的网络浏览器中打开一个新标签,并转到:
      http://localhost:1337/
  1. 单击按钮以更改标题。

它是如何工作的...

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' 
    } />  
) 

如果将timeOfDay属性值设置为"noon",它将生成以下 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 元素中的哪一个将根据condition显示。如果条件为真,则显示第一个子元素。否则,应显示第二个子元素。

首先,创建一个index.html文件,其中将呈现 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

然后,创建一个包含 React 应用程序逻辑和您的组件的新文件:

  1. 创建一个名为conditions.js的新文件

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个名为Toggle的功能组件,它将接收一个名为condition的属性,该属性将被评估以定义要渲染哪个 React 元素。它期望接收两个 React 元素作为子元素:
      const Toggle = ({ condition, children }) => ( 
          condition 
              ? children[0] 
              : children[1] 
      ) 
  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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开一个终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签并转到:
      http://localhost:1337/
  1. 单击按钮切换显示哪个 React 元素

它是如何工作的...

因为children属性可以是一组 React 元素的数组,我们可以访问每个单独的 React 元素并决定要渲染哪一个。我们使用condition属性来评估给定条件是否为真,以渲染第一个 React 元素。否则,如果值为假,则渲染第二个 React 元素。

使用 React 渲染列表

React 允许您将一组 React 元素或组件作为数组中的children传递。例如:

   <ul> 
      {[ 
         <li key={0}>One</li>, 
         <li key={1}>Two</li>, 
      ]} 
   </ul> 

React 元素或组件的集合必须被赋予一个名为key的特殊 props 属性。当更新发生时,此属性让 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" 
  } 
} 

接下来,创建一个名为.babelrc的 babel 配置文件,添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行来安装依赖项:

 npm install

如何做...

创建一个名为MapArray的 React 组件,它将负责将数组的项目映射到 React 组件。

首先,创建一个index.html文件,其中将呈现 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

然后,执行以下步骤构建 React 应用程序:

  1. 创建一个名为lists.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个名为MapArray的功能组件,它将期望接收三个属性:from,预期为值数组,mapToProps,预期为将值映射到属性的回调函数,最后,children,预期接收一个 React 组件,其中数组的值将被映射到:
      const MapArray = ({ 
          from, 
          mapToProps, 
          children: Child, 
      }) => ( 
          <React.Fragment> 
              {from.map((item) => ( 
                  <Child {...mapToProps(item)} /> 
              ))} 
          </React.Fragment> 
      ) 
  1. 定义一个TodoItem组件,它期望接收两个属性,donelabel
      const TodoItem = ({ done, label }) => ( 
          <li> 
              <input type="checkbox" checked={done} readOnly /> 
              <label>{label}</label> 
          </li> 
      ) 
  1. 定义一个包含对象值的待办事项列表的数组:
      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' }, 
      ] 
  1. 定义一个回调函数,将数组的对象值映射到TodoItem组件的预期属性。将id属性重命名为key,将title属性重命名为label
      const mapToProps = ({ id: key, done, title: label }) => ({ 
          key, 
          done, 
          label, 
      }) 
  1. 定义一个TodoListApp组件,它将使用MapArray组件为待办事项列表数组中的每个项目创建一个TodoItem实例:
      const TodoListApp = ({ items }) => ( 
          <ol> 
              <MapArray from={list} mapToProps={mapToProps}> 
                  {TodoItem} 
              </MapArray> 
          </ol> 
      ) 
  1. 渲染应用程序:
      ReactDOM.render( 
          <TodoListApp items={list} />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开一个终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签并转到:
      http://localhost:1337/
  1. 应该显示一个待办事项列表:

待办事项列表

它是如何工作的...

看一下以下代码:

<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" 
  } 
} 

接下来,创建一个名为.babelrc的 babel 配置文件,添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

 npm install

如何做到...

定义一个名为LoginForm的类组件,它将处理username输入和password输入。

首先,创建一个index.html文件,其中将呈现 React 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

接下来,构建LoginForm组件,并利用 React 对输入状态的控制来禁止在username输入中输入数字:

  1. 创建一个名为forms.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个名为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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <LoginForm />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签并转到:
http://localhost:1337/
  1. 尝试在“用户名”输入中输入一个数字,看看数字验证是如何工作的

它是如何工作的...

我们定义了一个在两个输入元素中使用的onChange事件处理程序。但是,我们检查输入的名称是否为username,以决定是否应用验证。我们使用RegExp来测试输入中的数字,并用空字符串替换它们。这就是为什么在username输入时输入数字不会显示出来。

理解 refs 以及如何使用它们

在通常的工作流程中,React 组件通过传递props与它们的子组件进行通信。然而,有一些情况需要访问子组件的实例以进行通信或修改其行为。React 使用refs允许我们访问子组件的实例。

重要的是要理解,React 组件的实例使您可以访问其实例方法和属性。但是,React 元素的实例是 HTML DOM 元素的实例。通过给 React 组件或 React 元素添加ref属性来访问 refs。它期望值是一个回调函数,一旦实例被创建,就会调用该回调函数,从而在传递给回调函数的第一个参数中提供对实例的引用。

React 提供了一个名为createRef的辅助函数,用于正确设置回调函数以定义 refs。例如,以下代码获取了 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属性将在控制台中显示警告并失败。

Refs在以下情况下特别有用:与不受控制的组件一起使用

  • 与第三方库集成

  • 访问 HTML DOM 元素的本机方法,否则无法从 React 访问,例如HTMLElement.focus()方法

  • 使用某些 Web API,例如 Selection Web API,Web Animations API 和媒体播放方法

准备工作

在这个示例中,您将使用不受控制的组件,并使用 refs 向表单 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 应用程序:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

现在,开始构建 React 应用程序:

  1. 创建一个名为refs.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
import * as ReactDOM from 'react-dom' 
  1. 定义一个名为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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <LoginForm />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录下打开终端并运行:
 npm start
  1. 然后,在 Web 浏览器中打开一个新标签并转到:
http://localhost:1337/

它是如何工作的...

  1. 点击LogIn按钮以测试表单onSubmit事件是否被触发。

  2. 首先,将表单 DOM 元素的实例引用保存在一个名为reform的实例属性中。

  3. 然后,一旦按钮被提交,我们使用EventTarget web API 的dispatchEvent方法在表单 DOM 元素上分派一个自定义事件submit

  4. 然后,React SyntheticEvent捕获了分派的submit方法。

  5. 最后,React 触发传递给表单onSubmit属性的回调方法。

理解 React 门户

React 门户允许我们将子组件渲染到不同的 DOM 元素中,而保持 React 树,就好像组件在父组件生成的 DOM 树中一样。例如,即使子组件位于不同的 DOM 节点中,但在子组件中生成的事件会冒泡到 React 父组件。

React 门户是使用 ReactDOM 库的createPortal方法创建的,它具有与render方法相同的签名:

ReactDOM.createPortal(  
    ReactComponent, 
    DOMNode,  
) 

然而,rendercreatePortal之间的区别在于后者返回一个特殊的标记,该标记用于在 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 应用程序将在其中渲染,还包含一个 HTMLheader标签,其中将渲染一个 React 门户元素:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

接下来,构建一个 React 应用程序,它将在header HTML 元素中渲染一个段落和一个h1 HTML 元素之外的树:

  1. 创建一个名为portals.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个名为Header的函数组件,并创建一个门户,将children渲染到不同的 DOM 元素中:
      const Header = () => ReactDOM.createPortal( 
          <h1>React Portals</h1>, 
          document.querySelector('[id="heading"]'), 
      ) 
  1. 定义一个名为App的函数组件,它将渲染一个 React 元素和Header React 组件:
      const App = () => ( 
          <React.Fragment> 
              <p>Hello World!</p> 
              <Header /> 
          </React.Fragment> 
      ) 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开终端并运行:
 npm start
  1. 然后,在您的 Web 浏览器中打开一个新标签并转到:
http://localhost:1337/
  1. 生成的 HTML DOM 树看起来类似于这样:
      <header id="heading"> 
         <h1>React Portals</h1> 
      </header> 
      <section role="main"> 
         <p>Hello World!</p> 
      </section> 

它是如何工作的...

即使在 React 树中,Header组件似乎是在段落p HTML 标记之后渲染的,但实际上渲染的Header组件是在之前渲染的。这是因为Header组件实际上是在header HTML 标记上渲染的,而header HTML 标记出现在渲染主应用程序的section 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" 
  } 
} 

创建一个名为.babelrc的 babel 配置文件,添加以下内容:

{ 
    "presets": ["env","react"], 
    "plugins": ["transform-class-properties"] 
} 

然后,通过打开终端并运行以下命令来安装依赖项:

 npm install 

如何做...

首先,创建一个index.html文件,React 应用程序将在其中渲染:

  1. 创建一个名为index.html的新文件。

  2. 添加以下 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> 
  1. 保存文件

接下来,定义一个错误边界组件,它将捕获错误并渲染一个回退 UI,显示错误发生的位置和错误消息。还要定义一个App组件,并创建一个button React 元素,当点击时会导致应用程序失败并设置状态:

  1. 创建一个名为error-boundary.js的新文件。

  2. 导入 React 和 ReactDOM 库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
  1. 定义一个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 
              ) 
          } 
      } 
  1. 定义一个名为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> 
              ) 
          } 
      } 
  1. ErrorBoundary组件内部包装App来渲染应用程序:
      ReactDOM.render( 
          <ErrorBoundary> 
              <App /> 
          </ErrorBoundary>, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开一个终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签页,转到:
      http://localhost:1337/
  1. 点击button来导致应用程序失败

  2. 显示一个备用 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> 
   } 
} 

上面的代码将要求MyComponentchildren属性是一个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 应用程序将在其中渲染:

  1. 创建一个名为index.html的新文件

  2. 添加以下 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> 
  1. 保存文件

接下来,定义一个Toggle类组件,它期望接收两个 React 元素作为children。使用PropTypes创建一个自定义验证规则,以检查children属性是否是 React 元素的数组,并且组件是否确切地接收到两个 React 元素:

  1. 创建一个名为type-checking.js的新文件。

  2. 导入 React、ReactDOM 和PropTypes库:

      import * as React from 'react' 
      import * as ReactDOM from 'react-dom' 
      import * as propTypes from 'prop-types' 
  1. 定义一个名为Toggle的类组件。使用propTypes来对conditionchildren属性进行类型检查。使用自定义的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] 
          } 
      } 
  1. 定义一个名为App的类组件,它将渲染Toggle组件。提供三个 React 元素作为其children,以及一个button,当点击时将切换状态的value属性从truefalse,反之亦然:
      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> 
              ) 
          } 
      } 
  1. 渲染应用程序:
      ReactDOM.render( 
          <App />, 
          document.querySelector('[role="main"]'), 
      ) 
  1. 保存文件。

让我们来测试一下...

要运行和测试应用程序,请执行以下步骤:

  1. 在项目目录的根目录打开一个终端并运行:
 npm start
  1. 然后,在您的网络浏览器中打开一个新标签页,转到:
      http://localhost:1337/
  1. 浏览器控制台将显示以下警告:
      Warning: Failed prop type: "Toggle" expected children to contain exactly 2 React       elements 
          in Toggle (created by App) 
          in App 
  1. 点击button将在前两个 React 元素之间切换,而第三个 React 元素将被忽略

它是如何工作的...

我们为children属性定义了一个自定义函数验证器。在函数内部,我们首先使用内置的propTypes函数来检查children是否是 React 元素的数组。如果验证的结果不是Error的实例,那么我们使用 React 的Childrencount实用方法来知道给定了多少个 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指的是源的名称,通常是用于在警告消息中显示Error来源的组件名称。最后一个参数getStack是可选的,预期是一个回调函数,应返回一个Stack Trace,该Stack Trace将添加到警告消息的末尾,以更好地描述错误的确切来源位置。

propTypes仅在开发中使用,并且要使用 React 的生产版本,您必须设置捆绑器以将process.env.NODE_ENV替换为"production"。这样,propTypes将在应用程序的生产版本中被移除。

posted @ 2024-05-23 15:59  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报