NodeJS-高级开发-全-
NodeJS 高级开发(全)
原文:
zh.annas-archive.org/md5/b716b694adad5a9e5b2b3ff42950695d
译者:飞龙
前言
欢迎来到高级 Node.js 开发。本书充满了大量的内容、项目、挑战和真实世界的例子,所有这些都旨在通过实践来教您 Node。这意味着在接下来的章节中,您将很早就开始动手写一些代码,并且您将为每个项目编写代码。您将编写支持我们应用程序的每一行代码。现在,我们需要一个文本编辑器。
本书中的所有项目都很有趣,旨在教会您启动自己的 Node 应用程序所需的一切,从规划到开发,再到测试和部署。现在,当您启动这些不同的 Node 应用程序并在书中前进时,您将遇到错误,这是不可避免的。也许某些东西没有按预期安装,或者您尝试运行一个应用程序,而不是获得预期的输出,您得到了一个非常长的晦涩错误消息。别担心,我会帮助您。我将在各章节中向您展示通过这些错误的技巧和诀窍。让我们继续并开始吧。
这本书适合谁
本书面向任何希望启动自己的 Node 应用程序、转行或作为 Node 开发人员自由职业的人。您应该对 JavaScript 有基本的了解才能跟上本书。
本书涵盖内容
第一章,设置,将是您本地环境的非常基本的设置。我们将学习安装 MongoDB 和 Robomongo。
第二章,MongoDB,Mongoose 和 REST API-第一部分,将帮助您学习如何将您的 Node 应用程序连接到您在本地计算机上运行的 MongoDB 数据库。
第三章,MongoDB,Mongoose 和 REST API-第二部分,将帮助您开始使用 Mongoose 并连接到我们的 MongoDB 数据库。
第四章,MongoDB,Mongoose 和 REST API-第三部分,在与 Mongoose 玩耍后,将解决查询和 ID 验证问题。
第五章,使用 Socket.io 创建实时 Web 应用程序,将帮助您详细了解 Socket.io 和 WebSockets,帮助您创建实时 Web 应用程序。
第六章,生成 newMessage 和 newLocationMessage,讨论如何生成文本和地理位置消息。
第七章,将我们的聊天页面样式化为 Web 应用程序,继续我们关于样式化我们的聊天页面的讨论,并使其看起来更像一个真正的 Web 应用程序。
第八章,加入页面和传递房间数据,继续我们关于聊天页面的讨论,并研究加入页面和传递房间数据。
第九章,ES7 类,将帮助您学习 ES6 类语法,并使用它创建用户类和其他一些方法。
第十章,Async/Await 项目设置,将带您了解 async/await 的工作过程。
为了充分利用本书
要运行本书中的项目,您将需要以下内容:
-
Node.js 的最新版本(在撰写本书时为 9.x.x)
-
Express
-
MongoDB
-
Mongoose
-
Atom
我们将在书的过程中看到其他要求。
下载示例代码文件
您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择 SUPPORT 选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Advanced-Node.js-Development
。我们还有其他书籍和视频的代码包可供下载,网址为github.com/PacktPublishing/
。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含了本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/AdvancedNode.jsDevelopment_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。这里有一个例子:“将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
html, body, #map {
height: 100%;
margin: 0;
padding: 0
}
当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都是这样写的:
$ cd css
粗体:表示一个新术语,一个重要单词,或者您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这里有一个例子:“从管理面板中选择系统信息。”
警告或重要提示会显示在这样的形式下。
提示和技巧会显示在这样的形式下。
第一章:设置
在本章中,您将为本书的其余部分设置本地环境。无论您使用的是 macOS、Linux 还是 Windows,我们都将安装 MongoDB 和 Robomongo。
更具体地,我们将涵盖以下主题:
-
Linux 和 macOS 上的 MongoDB 和 Robomongo 安装
-
Windows 上的 MongoDB 和 Robomongo 安装
为 Linux 和 macOS 安装 MongoDB 和 Robomongo
这一部分是为 macOS 和 Linux 用户准备的。如果你使用的是 Windows,我已经为你写了一个单独的部分。
我们将首先下载并设置 MongoDB,因为这将是我们将使用的数据库。当我们最终将其部署到 Heroku 时,我们将使用第三方服务来托管我们的数据库,但在我们的本地机器上,我们需要下载 MongoDB,以便我们可以启动数据库服务器。这将让我们通过我们的 Node 应用程序连接到它,以读取和写入数据。
为了获取数据库,我们将前往mongodb.com。然后我们可以转到下载页面并下载适当的版本。
在这个页面上,向下滚动并选择 Community Server;这是我们将要使用的。此外,还有不同操作系统的选项,无论是 Windows、Linux、macOS 还是 Solaris。我使用的是 macOS,所以我会使用这个下载:
如果你在 Linux 上,点击 Linux;然后转到版本下拉菜单并选择适当的版本。例如,如果你在 Ubuntu 14.04 上,你可以从 Linux 选项卡下载正确的版本。然后,你只需点击下载按钮并跟随操作。
接下来你可以打开它。我们将只需提取目录,创建一个全新的文件夹在Downloads
文件夹中。如果你在 Linux 上,你可能需要手动将该存档的内容解压到Downloads
文件夹中。
现在这个文件夹包含一个bin
文件夹,在那里我们有所有需要的可执行文件,以便做一些事情,比如连接到数据库和启动数据库服务器:
在我们继续运行任何命令之前。我们将把这个目录重命名为mongo
,然后将它移动到user
目录中。你可以看到现在在user
目录中,我有mongo
文件夹。我们还将在mongo
旁边创建一个全新的目录,名为mongo-data
,这将存储数据库中的实际数据:
所以当我们向Todos
表中插入新记录时,例如,它将存储在mongo-data
文件夹中。一旦你将mongo
文件夹移动到user
目录中,并且你有了新的mongo-data
文件夹,你就可以准备从终端实际运行数据库服务器了。我将进入终端并导航到user
目录中的全新mongo
文件夹,我当前所在的位置,所以我可以cd
到mongo
,然后我将通过在那里添加bin
目录来cd
进入bin
目录:
cd mongo/bin
从这里,我们有一堆可执行文件可以运行:
我们有一些东西,比如 bisondump 和 mongodump。在这一部分,我们将专注于:mongod,它将启动数据库服务器,以及 mongo,它将让我们连接到服务器并运行一些命令。就像当我们输入node
时,我们可以在终端中运行一些 JavaScript 命令一样,当我们输入mongo
时,我们将能够运行一些 Mongo 命令来插入、获取或对数据进行任何我们喜欢的操作。
不过首先,让我们启动数据库服务器。我将使用./
来运行当前目录中的文件。我们将要运行的文件名为mongod
;此外,我们需要提供一个参数:dbpath
参数。dbpath
参数将被设置为刚刚创建的目录的路径,即mongo-data
目录。我将使用~
(波浪号)来导航到用户目录,然后到/mongo-data
,如下所示:
./mongod --dbpath ~/mongo-data
运行这个命令将启动服务器。这将创建一个活动连接,我们可以连接到这个连接来操作我们的数据。当你运行命令时,你看到的最后一行应该是,等待在端口 27017 上连接:
如果你看到这个,这意味着你的服务器已经启动了。
接下来,让我们打开一个新标签,它会在完全相同的目录中启动,这一次,不是运行mongod
,而是运行mongo
文件:
./mongo
当我们运行mongo
时,我们打开了一个控制台。它连接到我们刚刚启动的数据库服务器,从这里,我们可以开始运行一些命令。这些命令只是为了测试一切是否按预期工作。我们稍后将详细介绍所有这些内容。不过现在,我们可以访问db.Todos
,然后我们将调用.insert
来创建一个全新的 Todo 记录。我会像调用函数一样调用它:
db.Todos.insert({})
接下来,在insert
里,我们将传入我们的文档。这将是我们想要创建的 MongoDB 文档。现在,我们将保持事情非常简单。在我们的对象上,我们将指定一个属性,text
,将其设置为一个字符串。在引号内,输入任何你想做的事情。我会说Film new node course
:
db.Todos.insert({text: 'Film new node course'})
只要你的命令看起来像这样,你可以按enter,然后你应该得到一个带有 nInserted 属性的 WriteResult 对象,这个属性是插入的数量的缩写:一个值设置为 1。这意味着创建了一个新的记录,这太棒了!
现在我们已经插入了一条记录,让我们获取一下记录,以确保一切都按预期工作。
我们将调用find
而不带任何参数。我们想返回Todos
集合中的每一个项目:
db.Todos.find()
当我运行这个时,我们会得到什么?我们得到一个看起来像对象的东西:
我们的text
属性设置为我们提供的文本,我们有一个_id
属性。这是每条记录的唯一标识符,我们稍后会讨论。只要你看到文本属性回到你设置的内容,你就可以放心了。
我们可以关闭mongo
命令。但是,我们仍然会让mongod
命令继续运行,因为我还想安装一件东西。它叫做 Robomongo,它是一个用于管理 Mongo 数据库的图形用户界面。当你开始玩 Mongo 时,这将非常有用。你将能够查看数据库中保存的确切数据;你可以操纵它并做各种各样的事情。
在Finder中,我们有我们的mongo-data
目录,你可以看到这里有很多东西。这意味着我们的数据已经成功保存。所有的数据都在这个mongo-data
目录中。要下载和安装 Robomongo,它适用于 Linux、Windows 和 macOS,我们将前往robomongo.org并获取适合我们操作系统的安装程序:
我们可以点击下载 Robo 3T 并下载最新版本;它应该会自动检测你的操作系统。下载适用于 Linux 或 macOS 的安装程序。macOS 的安装程序非常简单。这是其中一种你将图标拖到Applications
文件夹中的安装程序。对于 Linux,你需要解压存档并在bin
目录中运行程序。这将在你的 Linux 发行版上启动 Robomongo。
由于我使用的是 macOS,我只需快速将图标拖到 Applications 中,然后我们可以玩一下程序本身。接下来,我会在 Finder 中打开它。当你第一次打开 Robomongo 时,你可能会在 macOS 上收到如下警告,因为它是一个我们下载的程序,不是来自已识别的 macOS 开发者:
这没问题;大多数从网上下载的程序都不是官方的,因为它们不是来自应用商店。您可以右键单击下载的软件包,选择“打开”,然后再次点击“打开”来运行该程序。当您第一次打开它时,您会看到以下屏幕:
我们有一个小屏幕在后台和一个连接列表;目前该列表为空。我们需要做的是为我们的本地 MongoDB 数据库创建一个连接,以便我们可以连接到它并操作那些数据。我们有创建。我会点击这个,我们唯一需要更新的是名称。我会给它一个更具描述性的名称,比如本地 Mongo 数据库
。我会将地址设置为localhost
,27017
端口是正确的;没有必要更改这些。所以,我会点击“保存”:
接下来,我将双击数据库以连接到它。在小窗口内,我们有我们的数据库。我们已经连接到它;我们可以做各种事情来管理它。
我们可以打开test
数据库,在那里,我们应该看到一个Collections
文件夹。如果我们展开这个文件夹,我们有我们的Todos
集合,然后,我们可以右键单击该集合。接下来,点击“查看文档”,我们应该会看到我们的一个 Todo 项目,就是我们在 Mongo 控制台中创建的那个:
我可以展开它以查看文本属性。电影新节点课程出现了:
如果您看到这个,那么您已经完成了。
下一节是给 Windows 用户的。
为 Windows 安装 MongoDB 和 Robomongo
如果您使用的是 Windows,这是适合您的安装部分。如果您使用的是 Linux 或 macOS,前一节适合您;您可以跳过这一部分。我们的目标是在我们的计算机上安装 MongoDB,这将让我们创建一个本地 MongoDB 数据库服务器。我们将能够使用 Node.js 连接到该服务器,并且我们将能够读取和写入数据库中的数据。这对于 Todo API 来说将是非常棒的,它将负责读取和写入各种与 Todo 相关的信息。
要开始,我们将通过访问mongodb.com来获取 MongoDB 安装程序。在这里,我们可以点击大绿色的下载按钮;此外,我们还可以在此页面上看到几个选项:
我们将使用 Community Server 和 Windows。如果您转到版本下拉菜单,那里的版本都不适合您。顶部的是我们想要的:Windows Server 08 R2 64 位及更高版本,支持 SSL。让我们开始下载这个。它稍微大一点;稍微超过 100 MB,所以下载需要一些时间才能开始。
我会启动它。这是一个基本的安装程序,您需要点击几次“下一步”并同意许可协议。点击“自定义”选项一会儿,尽管我们将继续选择“完整”选项。当您点击“自定义”时,它会显示您的计算机上将安装在哪里,这很重要。在这里,您可以看到对我来说它在C:\Program Files\MongoDB\Server
,然后在3.2
目录中:
这将很重要,因为我们需要进入这个目录才能启动 MongoDB 服务器。我会返回,然后我将使用“完整”选项,这将安装我们需要的一切。现在我们实际上可以开始安装过程。通常,您需要点击“是”,以确认您要安装该软件。我会继续这样做,然后我们就完成了。
现在一旦它安装好了,我们将进入命令提示符并启动服务器。我们需要做的第一件事是进入Program Files
目录。我在命令提示符中。我建议你使用命令提示符而不是 Git Bash。Git Bash 不能用来启动 MongoDB 服务器。我将使用cd/
来导航到我的机器的根目录,然后我们可以使用以下命令来导航到那个路径:
cd Program Files/MongoDB/Server/3.2
这是安装 MongoDB 的目录。我可以使用dir
来打印出这个目录的内容,我们关心的是bin
目录:
我们可以使用cd bin
进入bin
,并使用dir
打印出它的内容。此外,这个目录包含了一大堆我们将用来启动服务器和连接到服务器的可执行文件:
我们将运行的第一个可执行文件是mongod.exe
。这将启动我们的本地 MongoDB 数据库。在我们继续运行这个EXE
之前,我们还需要做一件事。在通用文件资源管理器中,我们需要创建一个目录,用来存储我们所有的数据。为了做到这一点,我将把我的放在我的用户目录下,通过转到C:/Users/Andrew
目录。我将创建一个新文件夹,我会把这个文件夹叫做mongo-data
。现在,mongo-data
目录是我们所有数据实际存储的地方。这就是我们在运行mongod.exe
命令时需要指定的路径;我们需要告诉 Mongo 数据存储在哪里。
在命令提示符中,我们现在可以启动这个命令。我将运行mongod.exe
,作为dbpath
参数传入,传入我们刚刚创建的文件夹的路径。在我的情况下,它是/Users/Andrew/mongo-data
。现在如果你的用户名不同,显然是不同的,或者你把文件夹放在不同的目录中,你需要指定mongo-data
文件夹的绝对路径。不过,一旦你有了这个,你就可以通过运行以下命令启动服务器:
mongod.exe --dbpath /Users/Andrew/mongo-data
你会得到一个很长的输出列表:
你需要关心的唯一一件事是,在最底部,你应该看到等待在端口 27017 上连接。如果你看到这个,那么你就可以开始了。但是现在服务器已经启动,让我们连接到它并发出一些命令来创建和读取一些数据。
创建和读取数据
为了做到这一点,我们将打开第二个命令提示符窗口,并使用cd/Program Files/MongoDB/Server/3.2/bin
进入相同的bin
目录。从这里,我们将运行mongo.exe
。请注意,我们不是运行mongod
命令;我们运行的是mongo.exe
。这将连接到我们的本地 MongoDB 数据库,并且会让我们进入数据库的命令提示符视图。我们将能够发出各种 Mongo 命令来操作数据,有点像我们可以从命令提示符中运行 Node 来运行各种 JavaScript 语句一样。当我们运行这个命令时,我们将连接到数据库。在第一个控制台窗口中,你可以看到连接被接受的显示。我们确实有了一个新的连接。现在在第一个控制台窗口中,我们可以运行一些命令来创建和读取数据。现在我不指望你从这些命令中得到任何东西。我们暂时不讨论 MongoDB 的细节。我只是想确保当你运行它们时,它能按预期工作。
首先,让我们从控制台创建一个新的 Todo。这可以通过db.Todos
来完成,在这个 Todos 集合上,我们将调用.insert
方法。此外,我们将使用一个参数调用insert
,一个对象;这个对象可以有我们想要添加到记录中的任何属性。例如,我想设置一个text
属性。这是我实际需要做的事情。在引号内,我可以放一些东西。我会选择创建新的 Node 课程
。
db.Todos.insert({text: 'Create new Node course'})
现在当我运行这个命令时,它将实际地将数据插入到我们的数据库中,我们应该会得到一个writeResult
对象,其中nInserted
属性设置为1
。这意味着插入了一条记录。
现在我们的数据库中有一个 Todo,我们可以尝试再次使用db.Todos
来获取它。这一次,我们不会调用insert
来添加记录,而是调用find
,不提供任何参数。这将返回我们数据库中的每一个 Todo:
db.Todos.find()
当我运行这个命令时,我们得到一个看起来像对象的东西,其中有一个text
属性设置为Create new Node course
。我们还有一个_id
属性。_id
属性是 MongoDB 的唯一标识符,这是他们用来给您的文档,比如说一个 Todo,在这种情况下,一个唯一的标识符。稍后我们会更多地讨论_id
和我们刚刚运行的所有命令。现在,我们可以使用Ctrl + C来关闭它。我们已经成功断开了与 Mongo 的连接,现在我们也可以关闭第二个命令提示窗口。
在我们继续之前,我还想做一件事。我们将安装一个名为 Robomongo 的程序——一个用于 MongoDB 的图形用户界面。它将让您连接到本地数据库以及真实数据库,我们稍后会详细介绍。此外,它还可以让您查看所有数据,操纵它,并执行数据库 GUI 中可以执行的任何操作。这非常有用;有时您只需要深入数据库,看看数据的确切样子。
为了开始这个过程,我们将转到一个新的标签页,然后转到robomongo.org。
在这里,我们可以通过转到下载来获取安装程序。我们将下载最新版本,我使用的是 Windows。我需要安装程序,而不是便携式版本,所以我会点击这里的第一个链接:
这将开始一个非常小的下载,只有 17MB,我们可以通过点击“下一步”几次来在我们的机器上安装 Robomongo。
我将开始这个过程,确认安装并点击“下一步”几次。在设置内没有必要进行任何自定义操作。我们将使用所有默认设置运行安装程序。现在我们可以通过完成安装程序中的所有步骤来实际运行程序。当您运行 Robomongo 时,您将会看到一个 MongoDB 连接屏幕:
这个屏幕让您配置 Robomongo 的所有连接。您可能有一个用于本地数据库的本地连接,也可能有一个连接到实际生产数据存储的真实 URL。我们稍后会详细介绍这一切。
现在,我们将点击“创建”。默认情况下,您的localhost
地址和27017
端口不需要更改:
我要做的就是更改名称,使其更容易识别。我会选择Local Mongo Database
。现在,我们可以保存我们的新连接,并通过双击连接到数据库。当我们这样做时,我们会得到一个数据库的树形视图。我们有这个test
数据库;这是默认创建的一个,我们可以展开它。然后我们可以展开我们的Collections
文件夹,看到Todos
集合。这是我们在控制台内创建的集合。我会右键单击它,然后转到“查看文档”。当我查看文档时,我实际上可以查看到单独的记录:
在这里,我看到了我的 _id 和 text 属性,它们在上面的图像中显示为 Create new Node course。
如果您看到这个,那么这意味着您有一个本地的 Mongo 服务器在运行,并且这也意味着您已经成功地向其中插入了数据。
总结
在这一章中,你下载并运行了 MongoDB 数据库服务器。这意味着我们有一个本地数据库服务器,可以从我们的 Node 应用程序连接到它。我们还安装了 Robomongo,它让我们连接到本地数据库,这样我们就可以查看和操作数据。当你调试或管理数据,或者对你的 Mongo 数据库进行其他操作时,这将非常方便。我们将在整本书中使用它,你将在后面的章节中开始看到它为什么是有价值的。不过,现在你已经准备好了。你可以继续开始构建 Todo API 了。
第二章:MongoDB、Mongoose 和 REST API – 第一部分
在本章中,您将学习如何将您的 Node 应用程序连接到您在本地计算机上运行的 MongoDB 数据库。这意味着我们将能够在我们的 Node 应用程序内部发出数据库命令,执行诸如插入、更新、删除或读取数据等操作。如果我们要制作 Todo REST API,这将是至关重要的。当有人访问我们的 API 端点时,我们希望操作数据库,无论是读取所有的 Todos 还是添加一个新的。然而,在我们做任何这些之前,我们必须先学习基础知识。
连接到 MongoDB 并写入数据
要从 Node.js 内部连接到我们的 MongoDB 数据库,我们将使用 MongoDB 团队创建的一个 npm 模块。它被称为 node-mongodb-native,但它包括了你需要连接和与数据库交互的所有功能。要找到它,我们将谷歌搜索node-mongodb-native
:
GitHub 仓库,应该是第一个链接,是我们想要的——node-mongodb-native 仓库——如果我们向下滚动,我们可以看一下一些重要的链接:
首先是文档,还有我们的 api-docs;当我们开始探索这个库内部的功能时,这些将是至关重要的。如果我们在这个页面上继续向下滚动,我们会发现大量关于如何入门的示例。我们将在本章中讨论很多这些内容,但我想让你知道你可以在哪里找到其他资源,因为 mongodb-native 库有很多功能。有整个课程专门致力于 MongoDB,甚至都没有涵盖这个库内置的所有功能。
我们将专注于 Node.js 应用程序所需的 MongoDB 的重要和常见子集。要开始,让我们打开上图中显示的文档。当你进入文档页面时,你必须选择你的版本。我们将使用 3.0 版本的驱动程序,有两个重要的链接:
-
参考链接: 这包括类似指南的文章,入门指南和其他各种参考资料。
-
API 链接: 这包括您在使用该库时可用的每个单独方法的详细信息。当我们开始创建我们的 Node Todo API 时,我们将在此链接上探索一些方法。
不过,现在我们可以开始为这个项目创建一个新目录,然后我们将安装 MongoDB 库并连接到我们正在运行的数据库。我假设您在本章的所有部分中都已经运行了您的数据库。我在我的终端中的一个单独标签页中运行它。
如果您使用的是 Windows,请参考 Windows 安装部分的说明来启动您的数据库,如果您忘记了。如果您使用的是 Linux 或 macOS 操作系统,请使用我已经提到的说明,并且不要忘记也包括dbpath
参数,这对于启动 MongoDB 服务器至关重要。
为项目创建一个目录
首先,我要在桌面上为 Node API 创建一个新文件夹。我将使用mkdir
创建一个名为node-todo-api
的新文件夹。然后,我可以使用cd
进入该目录,cd node-todo-api
。从这里,我们将运行npm init
,这将创建我们的package.json
文件,并允许我们安装我们的 MongoDB 库。再次,我们将使用回车键跳过所有选项,使用每个默认值:
一旦我们到达结尾,我们可以确认我们的选择,现在我们的package.json
文件已经创建。接下来我们要做的是在 Atom 中打开这个目录。它在桌面上,node-todo-api
。接下来,在项目的根目录中,我们将创建一个新文件夹,我将称这个文件夹为playground
。在这个文件夹里,我们将存储各种脚本。它们不会是与 Todo API 相关的脚本;它们将是与 MongoDB 相关的脚本,所以我希望将它们放在文件夹中,但我不一定希望它们成为应用的一部分。我们将像以前一样使用playground
文件夹。
在playground
文件夹中,让我们继续创建一个新文件,我们将称这个文件为mongodb-connect.js
。在这个文件中,我们将通过加载库并连接到数据库来开始。现在,为了做到这一点,我们必须安装库。从终端,我们可以运行npm install
来完成这项工作。新的库名称是mongodb
;全部小写,没有连字符。然后,我们将继续指定版本,以确保我们都使用相同的功能,@3.0.2
。这是写作时的最新版本。在版本号之后,我将使用--save
标志。这将把它保存为常规依赖项,它已经是:
npm install mongodb@3.0.2 --save
我们需要这个来运行 Todo API 应用程序。
将mongodb-connect
文件连接到数据库。
现在安装了 MongoDB,我们可以将其移动到我们的mongodb-connect
文件并开始连接到数据库。我们需要做的第一件事是从我们刚刚安装的库中提取一些东西,那就是mongodb
库。我们要找的是一个叫做MongoClient
的构造函数。MongoClient
构造函数允许您连接到 Mongo 服务器并发出命令来操作数据库。让我们继续创建一个名为MongoClient
的常量。我们将把它设置为require
,并且我们将要求我们刚刚安装的库mongodb
。从那个库中,我们将取出MongoClient
:
const MongoClient = require('mongodb').MongoClient;
现在MongoClient
已经就位,我们可以调用MongoClient.connect
来连接到数据库。这是一个方法,它接受两个参数:
-
第一个参数是一个字符串,这将是您的数据库所在的 URL。现在在生产示例中,这可能是 Amazon Web Services URL 或 Heroku URL。在我们的情况下,它将是本地主机 URL。我们稍后会谈论这个。
-
第二个参数将是一个回调函数。回调函数将在连接成功或失败后触发,然后我们可以适当地处理事情。如果连接失败,我们将打印一条消息并停止程序。如果成功,我们可以开始操作数据库。
将字符串添加为第一个参数
对于我们的第一个参数,我们将从mongodb://
开始。当我们连接到 MongoDB 数据库时,我们要使用像这样的 mongodb 协议:
MongoClient.connect('mongodb://')
接下来,它将在本地主机上,因为我们在本地机器上运行它,并且我们已经探索了端口:27017
。在端口之后,我们需要使用/
来指定我们要连接的数据库。现在,在上一章中,我们使用了测试数据库。这是 MongoDB 给你的默认数据库,但我们可以继续创建一个新的。在/
之后,我将称数据库为TodoApp
,就像这样:
MongoClient.connect('mongodb://localhost:27017/TodoApp');
将回调函数添加为第二个参数
接下来,我们可以继续提供回调函数。我将使用 ES6 箭头(=>
)函数,并且我们将通过两个参数。第一个将是一个错误参数。这可能存在,也可能不存在;就像我们过去看到的那样,如果实际发生了错误,它就会存在;否则就不会存在。第二个参数将是client
对象。这是我们可以用来发出读写数据命令的对象:
MongoClient.connect('mongodb://localhost:27017/TodoApp', (err, client) => {
});
mongodb-connect 中的错误处理
现在,在写入任何数据之前,我将继续处理可能出现的任何错误。我将使用一个if
语句来做到这一点。如果有错误,我们将在控制台上打印一条消息,让查看日志的人知道我们无法连接到数据库服务器,console.log
,然后在引号内放上类似Unable to connect to MongoDB server
的内容。在if
语句之后,我们可以继续记录一个成功的消息,类似于console.log
。然后,在引号内,我们将使用Connected to MongoDB server
:
MongoClient.connect('mongodb://localhost:27017/TodoApp', (err, client) => {
if(err){
console.log('Unable to connect to MongoDB server');
}
console.log('Connected to MongoDB server');
});
现在,当你处理这样的错误时,即使错误块运行,成功代码也会运行。我们要做的是在console.log('Unable to connect to MongoDB server');
行之前添加一个return
语句。
这个return
语句并没有做什么花哨的事情。我们所做的只是使用它来阻止函数的其余部分执行。一旦从函数返回,程序就会停止,这意味着如果发生错误,消息将被记录,函数将停止,我们将永远看不到这条Connected to MongoDB server
消息:
if(err) {
return console.log('Unable to connect to MongoDB server');
}
使用return
关键字的替代方法是添加一个else
子句,并将我们的成功代码放在else
子句中,但这是不必要的。我们可以只使用我更喜欢的return
语法。
现在,在运行这个文件之前,我还想做一件事。在我们的回调函数的最底部,我们将在 db 上调用一个方法。它叫做client.close
:
MongoClient.connect('mongodb://localhost:27017/TodoApp', (err, client) => {
if(err) {
return console.log('Unable to connect to MongoDB server');
}
console.log('Connected to MongoDB server');
const db = client.db('TodoApp');
client.close();
});
这关闭了与 MongoDB 服务器的连接。现在我们已经有了这个设置,我们实际上可以保存mongodb-connect
文件并在终端内运行它。它现在还没有做太多事情,但它确实会工作。
在终端中运行文件
在终端中,我们可以使用node playground
作为目录运行文件,文件本身是mongodb-connect.js
:
node playground/mongodb-connect.js
当我们运行这个文件时,我们会得到Connected to MongoDB server
打印到屏幕上:
如果我们进入我们拥有 MongoDB 服务器的选项卡,我们可以看到我们有一个新的连接:连接已接受。正如你在下面的截图中所看到的,该连接已关闭,这太棒了:
使用 Mongo 库,我们能够连接,打印一条消息,并从服务器断开连接。
现在,你可能已经注意到我们在 Atom 中的MongoClient.connect
行中更改了数据库名称,但我们实际上并没有做任何事情来创建它。在 MongoDB 中,与其他数据库程序不同,你不需要在开始使用之前创建数据库。如果我想启动一个新的数据库,我只需给它一个名称,比如Users
。
现在我有一个Users
数据库,我可以连接到它并对其进行操作。没有必要先创建该数据库。我将继续将数据库名称更改回TodoApp
。如果我们进入 Robomongo 程序并连接到我们的本地数据库,你还会看到我们唯一拥有的数据库是test
。TodoApp
数据库甚至从未被创建过,即使我们连接到它。Mongo 不会创建数据库,直到我们开始向其中添加数据。我们现在可以继续做到这一点。
向数据库添加数据
在 Atom 中,在我们调用db.close
之前,我们将向集合中插入一条新记录。这将是 Todo 应用程序。在这个应用程序中,我们将有两个集合:
-
一个
Todos
集合 -
一个
Users
集合
我们可以继续通过调用db.collection
向Todos
集合添加一些数据。db.collection
方法以要插入的集合的字符串名称作为其唯一参数。现在,就像实际数据库本身一样,您不需要首先创建此集合。您只需给它一个名称,比如Todos
,然后可以开始插入。无需运行任何命令来创建它:
db.collection('Todos')
接下来,我们将使用集合中可用的一个方法insertOne
。insertOne
方法允许您将新文档插入到集合中。它需要两个参数:
-
第一个将是一个对象。这将存储我们希望在文档中拥有的各种键值对。
-
第二个将是一个回调函数。当事情失败或顺利进行时,将触发此回调函数。
您将获得一个错误参数,可能存在,也可能不存在,您还将获得结果参数,如果一切顺利,将会提供:
const MongoClient = require('mongodb').MongoClient;
MongoClient.connect('mongodb://localhost:27017/TodoApp', (err, client) => {
if(err){
console.log('Unable to connect to MongoDB server');
}
console.log('Connected to MongoDB server');
const db = client.db('TodoApp');
db.collection('Todos').insertOne({
text: 'Something to do',
completed: false
}, (err, result) => {
});
client.close();
});
在错误回调函数本身内部,我们可以添加一些代码来处理错误,然后我们将添加一些代码来在成功添加时将对象打印到屏幕上。首先,让我们添加一个错误处理程序。就像我们之前做的那样,我们将检查错误参数是否存在。如果存在,那么我们将简单地使用return
关键字打印一条消息,以阻止函数继续执行。接下来,我们可以使用console.log
打印无法插入 todo
。我将传递给console.log
的第二个参数将是实际的err
对象本身,这样如果有人查看日志,他们可以看到出了什么问题:
db.collection('Todos').insertOne({
text: 'Something to do',
completed: false
}, (err, result) => {
if(err){
return console.log('Unable to insert todo', err);
}
在我们的if
语句旁边,我们可以添加我们的成功代码。在这种情况下,我们要做的只是将一些内容漂亮地打印到console.log
屏幕上,然后我将调用JSON.stringify
,我们将继续传入result.ops
。ops
属性将存储所有插入的文档。在这种情况下,我们使用了insertOne
,所以它只会是我们的一个文档。然后,我可以添加另外两个参数,对于筛选函数是undefined
,对于缩进是2
:
db.collection('Todos').insertOne({
text: 'Something to do',
completed: false
}, (err, result) => {
if(err){
return console.log('Unable to insert todo', err);
}
console.log(JSON.stringify(result.ops, undefined, 2));
});
有了这个,我们现在可以继续执行我们的文件,看看会发生什么。在终端中,我将运行以下命令:
node playground/ mongodb-connect.js
当我执行命令时,我们会收到成功消息:已连接到 MongoDB 服务器
。然后,我们会得到一个插入的文档数组:
现在正如我所提到的,在这种情况下,我们只插入了一个文档,如前面的屏幕截图所示。我们有text
属性,由我们创建;我们有completed
属性,由我们创建;我们有_id
属性,由 Mongo 自动添加。_id
属性将是接下来部分的主题。我们将深入讨论它是什么,为什么存在以及为什么它很棒。
目前,我们将继续注意它是一个唯一标识符。这是一个仅分配给此文档的 ID。这就是使用 Node.js 将文档插入到您的 MongoDB 数据库中所需的全部内容。我们可以在 Robomongo 中查看此文档。我将右键单击连接,然后单击刷新:
这显示了我们全新的TodoApp
数据库。如果我们打开它,我们会得到我们的Collections
列表。然后我们可以进入Collections
,查看文档,我们得到了什么?我们得到了我们的一个 Todo 项目。如果我们展开它,我们可以看到我们有我们的 _id,我们有我们的文本属性,我们有我们的完成布尔值:
在这种情况下,Todo 未完成,因此completed
值为false
。现在,我希望您将一个新记录添加到集合中。这将是本节的挑战。
向集合中添加新记录
在 Atom 中,我希望您从db.collection
一直到回调的底部,将代码注释掉。然后,我们将继续添加一些内容。在db.close()
之前,您将输入Insert new doc into the Users collection
。这个文档将有一些属性。我希望您给它一个name
属性;将其设置为您的名字。然后,我们将给它一个age
属性,最后但并非最不重要的是我们可以给它一个location
字符串。我希望您使用insertOne
插入该文档。您需要将新的集合名称传递给集合方法。然后,再往下,您将添加一些错误处理代码,并将操作打印到屏幕上。重新运行文件后,您应该能够在终端中查看您的记录,并且应该能够刷新。在 Robomongo 中,您应该看到新的 Users 集合,并且应该看到您指定的用户的名称、年龄和位置。
希望您能够成功将一个新文档插入到 Users 集合中。为了完成这个任务,您需要调用db.collection
,这样我们就可以访问我们想要插入的集合,这种情况下是Users
:
//Insert new doc into Users(name, age, location)
db.collection('Users')
接下来,我们需要调用一个方法来操作Users
集合。我们想要插入一个新文档,所以我们将使用insertOne
,就像我们在上一小节中所做的那样。我们将把两个参数传递给insertOne
。第一个是要插入的文档。我们将给它一个name
属性;我将把它设置为Andrew
。然后,我们可以设置age
等于25
。最后,我们将location
设置为我的当前位置,Philadelphia
:
//Insert new doc into Users(name, age, location)
db.collection('Users').insertOne({
name: 'Andrew',
age: 25,
location: 'Philadelphia'
}
我们要传入的下一个参数是我们的回调函数,它将在错误对象和结果一起被调用。在回调函数内部,我们将首先处理错误。如果有错误,我们将继续将其记录到屏幕上。我将返回console.log
,然后我们可以放置消息:Unable to insert user
。然后,我将添加错误参数作为console.log
的第二个参数。接下来,我们可以添加我们的成功案例代码。如果一切顺利,我将使用console.log
将result.ops
打印到屏幕上。这将显示我们插入的所有记录:
//Insert new doc into Users(name, age, location)
db.collection('Users').insertOne({
name: 'Andrew',
age: 25,
location: 'Philadelphia'
}, (err, result) => {
if(err) {
return console.log('Unable to insert user', err);
}
console.log(result.ops);
});
现在我们可以继续使用向上箭头键和回车键在终端内重新运行文件:
我们得到了我们插入的文档数组,只有一个。name
、age
和location
属性都来自我们,_id
属性来自 MongoDB。
接下来,我希望您验证它是否确实被插入到 Robomongo 中。通常,当您添加一个新的集合或新的数据库时,您可以右键单击连接本身,单击刷新,然后您应该能够看到添加的所有内容:
如前面的屏幕截图所示,我们有我们的 Users 集合。我可以查看 Users 的文档。我们得到了一个文档,其中名称设置为 Andrew,年龄设置为 25,位置设置为 Philadelphia。有了这个,我们现在完成了。我们已经能够使用 Node.js 连接到我们的 MongoDB 数据库,还学会了如何使用这个 mongo-native 库插入文档。在下一节中,我们将深入研究 ObjectIds,探讨它们究竟是什么,以及它们为什么有用。
ObjectId
现在您已经将一些文档插入到 MongoDB 集合中,我想花一点时间谈谈 MongoDB 中的_id
属性,因为它与您可能已经使用过的其他数据库系统(如 Postgres 或 MySQL)中的 ID 有些不同。
MongoDB 中的 _id 属性
为了开始我们对_id
属性的讨论,让我们继续重新运行mongodb-connect
文件。这将向 Users 集合中插入一个新的文档,就像我们在db.collection
行中定义的那样。我将通过在节点中运行文件来做到这一点。它在playground
文件夹中,文件本身叫做mongodb-connect.js
:
node playground/mongodb-connect.js
我将运行命令,然后我们将打印出插入的文档:
正如我们过去所看到的,我们得到了我们的三个属性,以及 Mongo 添加的一个属性。
关于这个的第一件事是,它不是一个自动递增的整数,就像对于 Postgres 或 MySQL 一样,第一条记录的 ID 是 1,第二条记录的 ID 是 2。Mongo 不使用这种方法。Mongo 被设计为非常容易扩展。扩展意味着你可以添加更多的数据库服务器来处理额外的负载。
想象一下,你有一个每天大约有 200 个用户的 Web 应用程序,你当前的服务器已经准备好处理这个流量。然后,你被某个新闻媒体选中,有 1 万人涌入你的网站。使用 MongoDB,很容易启动新的数据库服务器来处理额外的负载。当我们使用随机生成的 ID 时,我们不需要不断地与其他数据库服务器通信,以检查最高递增值是多少。是 7 吗?是 17 吗?这并不重要;我们只是简单地生成一个新的随机 ObjectId,并将其用于文档的唯一标识符。
现在,ObjectId 本身由几个不同的部分组成。它是一个 12 字节的值。前四个字节是时间戳;我们稍后会谈论这个。这意味着我们在数据中有一个内置的时间戳,指的是 ID 创建时刻的时间。这意味着在我们的文档中,我们不需要有一个createdAt
字段;它已经编码在 ID 中了。
接下来的三个字节是机器标识符。这意味着如果两台计算机生成 ObjectId,它们的机器 ID 将是不同的,这将确保 ID 是唯一的。接下来,我们有两个字节,进程 ID,这只是另一种创建唯一标识符的方式。最后,我们有一个 3 字节的计数器。这类似于 MySQL 会做的。这只是 ID 的 3 个字节。正如我们已经提到的,我们有一个时间戳,它将是唯一的;一个机器标识符;一个进程 ID;最后,只是一个随机值。这就是 ObjectId 的组成部分。
ObjectId 是_id
的默认值。如果没有提供任何内容,你确实可以对该属性做任何你喜欢的事情。例如,在mongodb-connect
文件中,我可以指定一个_id
属性。我将给它一个值,所以让我们用123
;在末尾加上逗号;这是完全合法的:
db.collection('Users').insertOne({
_id: 123,
name: 'Andrew',
age: 25,
location: 'Philadelphia'
}
我们可以保存文件,并使用上箭头键和回车键重新运行脚本:
我们得到了我们的记录,其中_id
属性是123
。ObjectId
是 MongoDB 创建 ID 的默认方式,但你可以为 ID 创建做任何你喜欢的事情。在 Robomongo 中,我们可以刷新我们的 Users 集合,然后得到我们的文档:
我们有我们在上一节中创建的一个,以及我们刚刚创建的两个,都有一个唯一的标识符。这就是为什么唯一的 ID 非常重要。在这个例子中,我们有三个属性:名称、年龄和位置,它们对所有记录都是相同的。这是一个合理的做法。想象两个人需要做同样的事情,比如买东西。仅仅那个字符串是不够唯一标识一个 Todo 的。另一方面,ObjectId 是唯一的,这就是我们将用来将诸如 Todos 之类的事物与诸如Users
之类的事物关联起来的东西。
接下来,我想看一下我们在代码中可以做的一些事情。正如我之前提到的,时间戳被嵌入在这里,我们实际上可以将其提取出来。在 Atom 中,我们要做的是移除_id
属性。时间戳只有在使用ObjectId
时才可用。然后,在我们的回调函数中,我们可以继续将时间戳打印到屏幕上。
db.collection('Users').insertOne({
name: 'Andrew',
age: 25,
location: 'Philadelphia'
}, (err, result) => {
if(err) {
return console.log('Unable to insert user', err);
}
console.log(result.ops);
});
如果你记得,result.ops
是一个包含所有插入的文档的数组。我们只插入一个,所以我将访问数组中的第一个项目,然后我们将访问_id
属性。这将正如你所想的那样:
console.log(result.ops[0]._id);
如果我们保存文件并从终端重新运行脚本,我们只会得到ObjectId
打印到屏幕上:
现在,我们可以在_id
属性上调用一个方法。
调用.getTimestamp
函数
我们要调用的是.getTimestamp
。getTimestamp
是一个函数,但它不需要任何参数。它只是返回 ObjectId 创建的时间戳:
console.log(result.ops[0]._id.getTimestamp());
现在,如果我们继续重新运行我们的程序,我们会得到一个时间戳:
在前面的截图中,我可以看到 ObjectId 是在 2016 年 2 月 16 日 08:41 Z 创建的,所以这个时间戳确实是正确的。这是一个绝妙的方法,可以准确地确定文档是何时创建的。
现在,我们不必依赖 MongoDB 来创建我们的 ObjectIds。在 MongoDB 库中,他们实际上给了我们一个可以随时运行的函数来创建一个 ObjectId。暂时,让我们继续注释掉我们插入的调用。
在文件的顶部,我们将改变我们的导入语句,加载 MongoDB 的新内容,并且我们将使用 ES6 的对象解构来实现这一点。在我们实际使用它之前,让我们花一点时间来谈谈它。
使用对象解构 ES6
对象解构允许你从对象中提取属性以创建变量。这意味着如果我们有一个名为user
的对象,并且它等于一个具有name
属性设置为andrew
和一个年龄属性设置为25
的对象,如下面的代码所示:
const MongoClient = require('mongodb').MongoClient;
var user = {name: 'andrew', age: 25};
我们可以很容易地将其中一个提取到一个变量中。比如说,我们想要获取名字并创建一个name
变量。要在 ES6 中使用对象解构,我们将创建一个变量,然后将其包裹在花括号中。我们将提供我们想要提取的名字;这也将是变量名。然后,我们将把它设置为我们想要解构的对象。在这种情况下,那就是user
对象:
var user = {name: 'andrew', age: 25};
var {name} = user;
我们已经成功解构了user
对象,取出了name
属性,创建了一个新的name
变量,并将其设置为任何值。这意味着我可以使用console.log
语句将name
打印到屏幕上:
var user = {name: 'andrew', age: 25};
var {name} = user;
console.log(name);
我将重新运行脚本,我们得到andrew
,这正是你所期望的,因为这是name
属性的值:
ES6 解构是从对象的属性中创建新变量的一种绝妙方式。我将继续删除这个例子,并且在代码顶部,我们将改变我们的require
语句,以便使用解构。
在添加任何新内容之前,让我们继续并将 MongoClient 语句切换到解构;然后,我们将担心抓取那个新东西,让我们能够创建 ObjectIds。我将复制并粘贴该行,并注释掉旧的,这样我们就可以参考它。
// const MongoClient = require('mongodb').MongoClient;
const MongoClient = require('mongodb').MongoClient;
我们要做的是在require
之后删除我们的.MongoClient
调用。没有必要去掉那个属性,因为我们将使用解构代替。这意味着在这里我们可以使用解构,这需要我们添加花括号,并且我们可以从 MongoDB 库中取出任何属性。
const {MongoClient} = require('mongodb');
在这种情况下,我们唯一拥有的属性是MongoClient
。这创建了一个名为MongoClient
的变量,将其设置为require('mongodb')
的MongoClient
属性,这正是我们在之前的require
语句中所做的。
创建 objectID 的新实例
现在我们有了一些解构,我们可以很容易地从 MongoDB 中取出更多的东西。我们可以添加一个逗号并指定我们想要取出的其他东西。在这种情况下,我们将取出大写的ObjectID
。
const {MongoClient, ObjectID} = require('mongodb');
这个ObjectID
构造函数让我们可以随时创建新的 ObjectIds。我们可以随心所欲地使用它们。即使我们不使用 MongoDB 作为我们的数据库,创建和使用 ObjectIds 来唯一标识事物也是有价值的。接下来,我们可以通过首先创建一个变量来创建一个新的 ObjectId。我会称它为obj
,并将其设置为new ObjectID
,将其作为一个函数调用:
const {MongoClient, ObjectID} = require('mongodb');
var obj = new ObjectID();
使用new
关键字,我们可以创建ObjectID
的一个新实例。接下来,我们可以使用console.log(obj)
将其记录到屏幕上。这是一个普通的 ObjectId:
console.log(obj);
如果我们从终端重新运行文件,我们会得到你期望的结果:
我们得到了一个看起来像 ObjectId 的东西。如果我再次运行它,我们会得到一个新的;它们都是唯一的:
使用这种技术,我们可以在任何地方都使用 ObjectIds。我们甚至可以生成我们自己的 ObjectIds,将它们设置为我们文档的_id
属性,尽管我发现让 MongoDB 为我们处理这些繁重的工作要容易得多。我将继续删除以下两行,因为我们实际上不会在脚本中使用这段代码:
var obj = new ObjectID();
console.log(obj);
我们已经了解了一些关于 ObjectIds 的知识,它们是什么,以及它们为什么有用。在接下来的章节中,我们将看看我们可以如何与 MongoDB 一起工作的其他方式。我们将学习如何读取、删除和更新我们的文档。
获取数据
现在你知道如何向数据库插入数据了,让我们继续讨论如何从中获取数据。我们将在 Todo API 中使用这种技术。人们会想要填充一个他们需要的所有 Todo 项目的列表,并且他们可能想要获取有关单个 Todo 项目的详细信息。所有这些都需要我们能够查询 MongoDB 数据库。
在 Robomongo 文件中获取 todos
现在,我们将基于mongodb-connect
创建一个新文件。在这个新文件中,我们将从数据库中获取记录,而不是插入记录。我将创建一个副本,将这个新文件称为mongodb-find
,因为find
是我们将用来查询数据库的方法。接下来,我们可以开始删除当前插入记录的所有注释掉的代码。让我们开始尝试从我们的 Todos 集合中获取所有的 Todos。现在,如果我转到 Robomongo 并打开Todos
集合,我们只有一条记录:
为了使这个查询更有趣一些,我们将继续添加第二个。在 Robomongo 窗口中,我可以点击插入文档。Robomongo 可以删除、插入、更新和读取所有的文档,这使它成为一个很棒的调试工具。我们可以随时添加一个新的文档,其中text
属性等于Walk the dog
,我们还可以附加一个completed
值。我将completed
设置为false
:
{
text : "Walk the dog",
completed : false
}
现在,默认情况下,我们不会提供_id
属性。这将让 MongoDB 自动生成那个 ObjectId,而在这里我们有我们的两个 Todos:
有了这个,让我们继续在 Atom 中运行我们的第一个查询。
find 方法
在 Atom 中,我们要做的是访问集合,就像我们在mongodb-connect
文件中使用db.collection
一样,将集合名称作为字符串传递。这个集合将是Todos
集合。现在,我们将继续使用集合上可用的一个叫做find
的方法。默认情况下,我们可以不带参数地调用find
:
db.collection('Todos').find();
这意味着我们没有提供查询,所以我们没有说我们想要获取所有已完成或未完成的Todos
。我们只是说我们想获取所有Todos
:无论其值如何,一切。现在,调用 find 只是第一步。find
返回一个 MongoDB 游标,而这个游标并不是实际的文档本身。可能有几千个,那将非常低效。它实际上是指向这些文档的指针,并且游标有大量的方法。我们可以使用这些方法来获取我们的文档。
我们将要使用的最常见的游标方法之一是.toArray.
它确切地做了你认为它会做的事情。我们不再有游标,而是有一个文档的数组。这意味着我们有一个对象的数组。它们有 ID 属性,文本属性和完成属性。这个toArray
方法恰好得到了我们想要的东西,也就是文档。toArray
返回一个 promise。这意味着我们可以添加一个then
调用,我们可以添加我们的回调,当一切顺利时,我们可以做一些像将这些文档打印到屏幕上的事情。
db.collection('Todos').find().toArray().then((docs) => {
});
我们将得到文档作为第一个和唯一的参数,我们还可以添加一个错误处理程序。我们将传递一个错误参数,我们可以简单地打印一些像console.log(无法获取 todos)
的东西到屏幕上;作为第二个参数,我们将传递err
对象:
db.collection('Todos').find().toArray().then((docs) => {
}, (err) => {
console.log('Unable to fetch todos', err);
});
现在,对于成功的情况,我们要做的是将文档打印到屏幕上。我将继续使用console.log
来打印一条小消息,Todos
,然后我将再次调用console.log
。这次,我们将使用JSON.stringify
技术。我将传递文档,undefined
作为我们的过滤函数和2
作为我们的间距。
db.collection('Todos').find().toArray().then((docs) => {
console.log('Todos');
console.log(JSON.stringify(docs, undefined, 2));
}, (err) => {
console.log('Unable to fetch todos', err);
});
我们现在有一个能够获取文档,将其转换为数组并将其打印到屏幕上的脚本。现在,暂时地,我将注释掉db.close
方法。目前,那会干扰我们之前的代码。我们的最终代码将如下所示:
//const MongoClient = require('mongodb').MongoClient;
const {MongoClient, ObjectID} = require('mongodb');
MongoClient.connect('mongodb://localhost:27017/TodoApp', (err, client) => {
if(err){
console.log('Unable to connect to MongoDB server');
}
console.log('Connected to MongoDB server');
const db = client.db('TodoApp');
db.collection('Todos').find().toArray().then((docs) => {
console.log('Todos');
console.log(JSON.stringify(docs, undefined, 2));
}, (err) => {
console.log('Unable to fetch todos', err);
});
//client.close();
});
保存文件并从终端运行它。在终端中,我将继续运行我们的脚本。显然,由于我们用 Robomongo 连接到了数据库,它正在某个地方运行;它正在另一个标签页中运行。在另一个标签页中,我可以运行脚本。我们将通过node
运行它;它在playground
文件夹中,文件本身叫做mongodb-find.js
:
node playground/mongodb-find.js
当我执行这个文件时,我们将得到我们的结果:
我们有我们的Todos
数组和我们的两个文档。我们有我们的_id
,我们的text
属性和我们的completed
布尔值。现在,我们有一种在 Node.js 中查询我们的数据的方法。现在,这是一个非常基本的查询。我们获取Todos
数组中的所有内容,无论它是否具有某些值。
编写一个查询以获取特定值
为了基于某些值进行查询,让我们继续切换我们的Todos
。目前,它们两个的completed
值都等于false
。让我们继续将Walk the dog
的完成值更改为true
,这样我们就可以尝试只查询未完成的项目。在 Robomongo 中,我将右键单击文档,然后单击编辑文档,然后我们可以编辑值。我将把completed
值从false
更改为true
,然后我可以保存记录:
在终端内,我可以重新运行脚本来证明它已经改变。我将通过运行control + C关闭脚本,然后可以重新运行它:
如前面的屏幕截图所示,我们有两个Todos
,一个completed
值为false
,另一个completed
值为true
。默认情况下,待办事项应用程序可能只会显示您尚未完成的Todos
集合。您已经完成的待办事项,比如Walk the dog
,可能会被隐藏,尽管如果您点击了一个按钮,比如显示所有待办事项,它们可能是可访问的。让我们继续编写一个查询,只获取completed
状态设置为false
的Todos
集合。
编写一个查询以获取已完成的待办事项
为了完成这个目标,在 Atom 中,我们将更改调用 find 的方式。我们不再传递0
个参数,而是传递1
个参数。这就是我们所谓的查询。我们可以开始指定我们想要查询Todos
集合的方式。例如,也许我们只想查询completed
值等于false
的Todos
。我们只需设置键值对来按值查询,如下所示:
db.collection('Todos').find({completed: false}).toArray().then((docs) => {
如果我在终端中关闭脚本后重新运行我们的脚本,我们只会得到我们的一个待办事项:
我们的项目有一个text
等于Something to do
。它的completed
状态为false
,所以它显示出来。我们的另一个待办事项,Walk the dog
的text
属性没有显示出来,因为它已经完成。它不匹配查询,所以 MongoDB 不会返回它。当我们开始根据已完成的值、文本属性或 ID 查询我们的文档时,这将会很有用。让我们花点时间来看看我们如何可以通过 ID 查询我们的Todos
中的一个。
按 ID 查询待办事项
我们需要做的第一件事是从我们的查询对象中删除所有内容;我们不再想要按completed
值查询。相反,我们将按_id
属性查询。
现在,为了说明这一点,我将从终端获取completed
值为false
的待办事项的 ID。我将使用command + C进行复制。如果您使用的是 Windows 或 Linux,您可能需要在突出显示 ID 后右键单击,并单击复制文本。现在我已经将文本放入剪贴板,我可以转到查询本身。现在,如果我们尝试像这样添加 ID:
db.collection('Todos').find({_id: ''}).toArray().then((docs) => {
它不会按预期工作,因为我们在 ID 属性中拥有的不是一个字符串。它是一个 ObjectId,这意味着我们需要使用之前导入的ObjectID
构造函数来为查询创建一个 ObjectId。
为了说明这将如何发生,我将继续缩进我们的对象。这将使它更容易阅读和编辑。
db.collection('Todos').find({
_id: '5a867e78c3a2d60bef433b06'
}).toArray().then((docs) => {
现在,我要删除字符串并调用new ObjectID
。new ObjectID
构造函数确实需要一个参数:ID,在这种情况下,我们将其存储为字符串。这将按预期工作。
db.collection('Todos').find({
_id: new ObjectID('5a867e78c3a2d60bef433b06');
})
我们在这里所做的是查询Todos
集合,寻找任何具有与我们拥有的 ID 相等的_id
属性的记录。现在,我可以保存这个文件,通过重新运行脚本来刷新一下,我们将得到完全相同的待办事项:
我可以继续将其更改为Walk the dog
的待办事项,通过复制字符串值,将其粘贴到 ObjectID 构造函数中,并重新运行脚本。当我这样做时,我得到了Walk the dog
的待办事项,因为那是我查询的 ObjectId。
现在,以这种方式查询是我们将使用 find 的方式之一,但除了toArray
之外,我们的光标上还有其他方法可用。我们可以通过转到原生驱动程序的文档来探索其他方法。在 Chrome 中,打开 MongoDB 文档-这些是我在上一章中向您展示如何访问的文档-在左侧,我们有光标部分。
如果您点击,我们可以查看光标上可用的所有方法的列表:
这是从 find 返回的内容。在列表的最底部,我们有我们的toArray
方法。我们现在要看的是称为 count 的方法。从以前的内容,您可以继续并点击 count;它将带您到文档;原生驱动程序的文档实际上非常好。这里有您可以提供的所有参数的完整列表。其中一些是可选的,一些是必需的,通常有一个真实世界的例子。接下来,我们可以确切地找出如何使用count
。
实现计数方法
现在,我们将继续在 Atom 中实现count
。我要做的是将当前查询复制到剪贴板,然后将其注释掉。我将用一个调用count
替换我们对toArray
的调用。让我们继续删除我们传递给 find 的查询。我们要做的是计算Todos
集合中的所有 Todos。我们将不再调用toArray
,而是调用 count。
db.collection('Todos').find({}).count().then((count) => {
正如您在 count 的示例中看到的那样,他们这样调用 count:调用 count,传递一个回调函数,该函数在出现错误或实际计数时调用。您还可以将 promise 作为访问数据的一种方式,这正是我们使用toArray
的方式。在我们的情况下,我们将使用 promise 而不是传递回调函数。我们已经设置好了 promise。我们需要做的就是将docs
更改为count
,然后我们将删除打印 docs 到屏幕的console.log
调用者。在我们打印 Todos 之后,我们将打印Todos count
,并传入值。
db.collection('Todos').find({}).count().then((count) => {
console.log('Todos count:');
}, (err) => {
console.log('Unable to fetch todos', err);
});
这不是一个模板字符串,但我将继续并用一个替换它,用`
替换引号。现在,我可以传入count
。
db.collection('Todos').find({}).count().then((count) => {
console.log(`Todos count: ${count}`);
}, (err) => {
console.log('Unable to fetch todos', err);
});
现在我们已经完成了这一步,我们有一个方法来计算Todos
集合中的所有Todos
的数量。 在终端中,我将关闭之前的脚本并重新运行它:
我们也得到了Todos count
,这是正确的。 我们有一个调用 find 返回Todos
集合中的所有内容的游标。 如果您将所有这些加起来,您将得到这两个 Todo 项目。
再次强调,这些是count
和toArray
;它们只是您可以使用的所有出色方法的一个子集。 我们将使用其他方法,无论是 MongoDB 本机驱动程序还是稍后将看到的 Mongoose 库,但现在让我们继续进行挑战,根据您的了解。
查询用户集合
要开始,让我们进入 Robomongo,打开Users
集合,并查看我们在其中的所有文档。 目前我们有五个。 如果您的数量不完全相同,或者您的有点不同,也没关系。 我将突出显示它们,右键单击它们,并单击递归展开。 这将显示我每个文档的所有键值对:
目前,除了 ID 之外,它们都是相同的。 名字都是 Andrew,年龄是 25,位置是费城。 我将调整其中两个的姓名属性。 我将右键单击第一个文档,并将名称更改为类似Jen
的内容。 然后,我将继续对第二个文档执行相同的操作。 我将编辑该文档并将名称从Andrew
更改为Mike
。 现在我有一个名称为Jen
的文档,一个名称为Mike
的文档,还有三个名称为Andrew
的文档。
我们将查询我们的用户,寻找所有名称等于您在脚本中提供的名称的用户。 在这种情况下,我将尝试查询Users
集合中名称为Andrew
的所有文档。 然后,我将它们打印到屏幕上,并且我期望会得到三个回来。 名称为Jen
和Mike
的两个不应该出现。
我们需要做的第一件事是从集合中获取。 这将是Users
集合,而不是本章中使用的Todos
集合。 在db.collection
中,我们正在寻找Users
集合,现在我们将继续调用find
,传入我们的查询。 我们希望查询所有文档,其中name
等于字符串Andrew
。
db.collection('Users').find({name: 'Andrew'})
这将返回游标。为了真正地获取这些文档,我们必须调用toArray
。现在我们有一个 promise;我们可以将then
调用附加到toArray
上来对docs
做一些事情。文档将作为我们成功处理程序的第一个参数返回,并且在函数本身内部,我们可以将文档打印到屏幕上。我将继续使用console.log(JSON.stringify())
,传入我们的三个经典参数:对象本身,docs
,undefined
和2
来进行格式化:
db.collection('Users').find({name: 'Andrew'}).toArray().then((docs) => {
console.log(JSON.stringify(docs, undefined, 2));
});
有了这个,我们现在就完成了。我们有一个查询,并且它应该可以工作。我们可以通过从终端运行它来进行测试。在终端中,我将关闭之前的连接,然后重新运行脚本:
当我这样做时,我得到了三份文件。它们都有一个name
等于Andrew
,这是正确的,因为我们设置的查询。请注意,具有名称等于Mike
或Jen
的文档找不到了。
我们现在知道如何向数据库中插入和查询数据。接下来,我们将看看如何删除和更新文档。
设置存储库
在我们继续之前,我确实想为这个项目添加版本控制。在这一节中,我们将在本地创建一个新的存储库,创建一个新的 GitHub 存储库,并将我们的代码推送到该 GitHub 存储库中。如果你已经熟悉 Git 或 GitHub,你可以自行操作;你不需要通过这一节。如果你对 Git 还不明白,那也没关系。只需跟着进行,我们将一起完成整个过程。
这一部分将非常简单;这里涉及的内容与 MongoDB 无关。要开始,我将从终端使用git init
初始化一个新的 Git 存储库。这将初始化一个新的仓库,我随时可以像这样运行git status
来查看未跟踪的文件:
这里有我们的playground
文件夹,我们希望将其添加到版本控制下,并且有package.json
。我们还有node_modules
。我们不想跟踪这个目录。这里包含了我们所有的 npm 库。要忽略node_modules
,在 Atom 中我们将在项目的根目录下创建.gitignore
文件。如果你记得的话,这可以让你指定你想要在版本控制之外的文件和文件夹。我将创建一个名为.gitignore
的新文件。为了忽略node_modules
目录,我们只需要像这里显示的那样输入它:
node_modules/
我将保存文件并从终端重新运行git status
。我们看到.gitignore
文件出现了,而node_modules
文件夹却不见了:
接下来,我们要做的是使用两个命令进行第一次提交。首先,我要使用 git add .
将所有内容添加到下一个提交中。然后,我可以使用带有 -m
标志的 git commit
进行提交。这次提交的一个好消息是 初始提交
:
git add .
git commit -m 'Init commit'
在我们离开之前,我想要创建一个 GitHub 仓库并将这段代码上传到上面。这将需要我打开浏览器并转到 github.com。一旦您登录,我们就可以创建一个新的仓库。我要创建一个新的仓库并给它一个名称:
我将使用 node-course-2-todo-api
。如果您愿意,您可以选择其他名称。我要选择这个来保持课程文件的组织。现在我可以继续创建这个仓库,并且正如您可能还记得的,GitHub 实际上给了我们一些有用的命令:
在这种情况下,我们正在从命令行推送一个现有仓库。我们已经经历了初始化仓库、添加文件和进行第一次提交的步骤。这意味着我可以复制以下两行,然后前往终端并将它们粘贴进去:
git remote add origin https://github.com/garygreig/node-course-2-todo-api.git
git push -u origin master
取决于您的操作系统,您可能需要逐个执行这些命令。在 Mac 上,当我尝试粘贴多个命令时,它会运行所有命令,除了最后一个,然后我只需按回车键运行最后一个命令。花点时间为您的操作系统执行这些操作。您可能需要将它们作为一个命令运行,或者您可以粘贴所有内容并按回车键。无论哪种方式,我们的代码都被推送到了 GitHub。我可以通过刷新仓库页面来证明它已经推送上去了:
这里我们有所有的源代码、.gitignore
文件、package.json
,还有我们的playground
目录和我们的 MongoDB 脚本。
到此为止了。下一节我们将探讨如何从 MongoDB 集合中删除数据。
删除文档
在本节中,您将学习如何从 MongoDB 集合中删除文档。在深入探讨可以删除多个文档或只删除一个文档的方法之前,我们需要创建几个更多的 Todos。当前,Todos
集合仅有两个条目,我们需要更多的条目来演示这些涉及删除的方法。
现在,我有两个。我将继续创建第三个,可以通过右键单击然后转到插入文档...来完成。我们将使用 text
属性等于诸如 吃午饭
的新文档,并将 completed
设置为 false
:
{
text: 'Eat lunch',
completed: false
}
现在在保存之前,我会将它复制到剪贴板上。我们将创建一些重复的 Todos,这样我们就可以看到如何基于特定条件删除项目。在这种情况下,我们将删除具有相同文本值的多个 Todos。我将把它复制到剪贴板上,点击保存,然后我将创建两个具有完全相同结构的副本。现在我们有三个除了 ID 不同之外都相同的 Todos,以及两个具有唯一文本属性的 Todos:
让我们继续进入 Atom 并开始编写一些代码。
探索删除数据的方法
我要复制mongodb-find
文件,创建一个名为mongodb-delete.js
的全新文件。在这里,我们将探索删除数据的方法。我还将删除我们在上一部分设置的所有查询。我将保留db.close
方法的注释,因为我们不想立即关闭连接;这将干扰我们即将编写的这些语句。
现在,我们将使用三种方法来删除数据。
-
第一个将使用的是
deleteMany
。deleteMany
方法让我们可以针对多个文档并将它们删除。 -
我们还将使用
deleteOne
,它可以定位一个文档并删除它。 -
最后,我们将使用
findOneAndDelete
。findOneAndDelete
方法让您删除单个项目,并返回这些值。想象一下,我想删除一个 Todo。我删除了 Todo,但我也得到了 Todo 对象,所以我可以告诉用户确切地删除了哪一个。这是一个非常有用的方法。
deleteMany
方法
现在,我们将从deleteMany
开始,并将针对我们刚刚创建的重复项。这一部分的目标是删除 Todos 集合中每一个text
属性等于吃午餐
的 Todo。目前,有五个中的三个符合这个条件。
在 Atom 中,我们可以通过执行db.collection
来开始db.collection
。这将让我们定位到我们的 Todos 集合。现在,我们可以继续使用deleteMany
集合方法,传入参数。在这种情况下,我们只需要一个参数,就是我们的对象,这个对象就像我们传递给 find 的对象一样。有了这个,我们可以定位到我们的 Todos。在这种情况下,我们将删除所有text
等于吃午餐
的 Todo。
//deleteMany
db.collection('Todos').deleteMany({text: 'Eat lunch'});
在 RoboMongo 中我们没有使用任何标点符号,因此在 Atom 中我们也将避免使用标点符号;它需要完全相同。
现在我们可以添加then
调用,当成功或失败时执行一些操作。现在,我们将只添加一个成功案例。我们将得到一个返回到回调的结果参数,并且我们可以将其打印到console.log(result)
屏幕上,稍后我们将看一下这个结果对象的具体内容。
//deleteMany
db.collection('Todos').deleteMany({text: 'Eat lunch'}).then((result) => {
console.log(result);
});
有了这个,我们现在有一个可以删除所有吃午饭
文本值的脚本。让我们继续运行它,看看发生了什么。在终端中,我将运行这个文件。它在playground
文件夹中,我们刚刚称它为mongodb-delete.js
:
node playground/mongodb-delete.js
现在当我运行它时,我们会得到很多输出:
一个真正重要的输出部分,事实上是唯一重要的部分,就在顶部。如果你滚动到顶部,你会看到这个result
对象。我们将ok
设置为1
,表示事情如预期般发生了,我们将n
设置为3
。n
是已删除的记录数。在这种情况下,有三个符合条件的 Todos 被删除了。这就是你如何可以定位和删除许多 Todos。
deleteOne
方法
现在,除了deleteMany
,我们还有deleteOne
,deleteOne
的工作方式与deleteMany
完全相同,只是它删除它看到与条件匹配的第一项,然后停止。
为了确切地说明这是如何工作的,我们将创建两个项目并存放到我们的集合中。如果我刷新一下,你会看到我们现在只有两个文档:
这些是我们开始的内容。我将再次使用剪贴板中的相同数据插入文档。这次我们只创建两个重复的文档。
deleteOne
方法
这里的目标是使用deleteOne
删除文本等于吃午饭
的文档,但因为我们使用的是deleteOne
而不是deleteMany
,其中一个应该保留,另一个应该被删除。
回到 Atom 中,我们可以通过调用db.collection
并指定目标集合的名称开始工作。这次又是Todos
,我们将使用deleteOne
。deleteOne
方法需要相同的条件。我们会对text
等于吃午饭
的文档进行操作。
这一次,我们只是要删除一个文档,而且我们依然会得到完全相同的结果。为了证明这一点,我会像之前用console.log(result)
一样打印到屏幕上:
//deleteOne
db.collection('Todos').deleteOne({text: 'Eat lunch'}).then((result) => {
console.log(result);
});
有了这个,我们现在重新运行我们的脚本,看看发生了什么。在终端中,我将关闭当前的连接并重新运行它:
我们得到一个看起来类似的对象,一堆我们并不关心的无用东西,但是再次滚动到顶部,我们有一个result
对象,其中ok
为1
,被删除的文档数量也是1
。尽管有多个文档满足了这个条件,但它只删除了第一个,并且我们可以通过转到 Robomongo,右键单击上方,再次查看文档来证明这一点。这次,我们有三个 Todos。
我们仍然有一个带有吃午饭
文本的 Todos:
现在我们知道了如何使用这两种方法,我想来看看我最喜欢的方法。这就是findOneAndDelete
。
findOneAndDelete 方法
大多数时候,当我要删除文档时,我只有 ID。这意味着我不知道文本是什么或完成状态是什么,这取决于你的用户界面,这可能非常有用。例如,如果我删除了一个待办事项,也许我想显示,接着说您删除了说吃午饭的待办事项,并配备一个小的撤销按钮,以防他们不小心执行了该操作。获取数据以及删除它可以是非常有用的。
为了探索findOneAndDelete
,我们将再次针对text
等于吃午饭
的待办事项进行操作。我将注释掉deleteOne
,接下来我们可以通过访问适当的集合来开始。方法名为findOneAndDelete
。findOneAndDelete
方法接受一组非常相似的参数。我们唯一需要传递的是查询。这将与我们在上一屏幕截图中使用的相同。不过,这一次,让我们直接针对completed
值设置为false
的待办事项。
现在有两个符合此查询的待办事项,但再次使用的是findOne
方法,这意味着它只会定位到它看到的第一个,即带有text
属性为有事情要做
的。回到 Atom 中,我们可以通过目标completed
等于false
的待办事项完成这个操作。现在,我们不再得到一个带有ok
属性和n
属性的结果对象,而是findOneAndDelete
方法实际上获取了该文档。这意味着我们可以连接一个then
调用,获取我们的结果,并再次使用console.log(result)
打印到屏幕上:
//findOneAndDelete
db.collection('Todos').findOneAndDelete({completed: false}).then((result) => {
console.log(result);
});
现在我们有了这个方法,让我们在终端中测试一下。在终端中,我将关闭脚本,然后再次启动它:
我们可以在结果对象中得到几种不同的东西。我们得到一个设置为1
的ok
,让我们知道事情进行得如计划。我们有一个lastErrorObject
;我们马上就会讨论它;还有我们的value
对象。这就是我们删除的实际文档。这就是为什么findOneAndDelete
方法非常方便。它不仅得到了该文档,还删除了它。
现在在这种特殊情况下,lastErrorObject
中再次只有我们的n
属性,并且我们可以查看删除的待办事项数。lastErrorObject
可能还包含其他信息,但只有在使用其他方法时才会发生,所以到时候我们再看。现在,当你删除待办事项时,我们只会得到一个数字。
有了这个方法,我们现在有三种不同的方法可以针对我们的 MongoDB 文档进行定位并删除它们。
使用 deleteMany 和 findOneAndDelete 方法
我们将进行一项快速挑战,以测试你的能力。在 Robomongo 中,我们可以查看Users
集合中的数据。我将打开它,突出显示所有数据,并递归展开,以便我们可以查看:
我们有 Jen 的名字;我们有 Mike;我们有 Andrew,Andrew 和 Andrew。这是完美的数据。你的数据可能看起来有些不同,但目标是使用两种方法。首先,查找任何重复项,任何具有与另一个文档名称相同的名称的文档。在这种情况下,我有三个名称为 Andrew 的文档。我想要使用deleteMany
来定位并删除所有这些文档。我还想使用findOneAndDelete
来删除另一个文档;无论哪一个都可以。而且我希望你通过 ID 来删除它。
最终,这两个语句都应该在 Robomongo 内显示它们的效果。当完成时,我希望看到这三个文档被删除。它们全部都叫 Andrew,我希望看到名为 Mike 的文档被删除,因为我打算用findOneAndDelete
方法调用来定位它。
首先,我要编写我的脚本,一个用于删除名称为Andrew
的用户,一个用于删除 ID 的文档。为了获取 ID,我将继续编辑,并简单地抓取引号内的文本,然后取消更新并移动到 Atom。
删除重复文档
首先,我们将尝试去除重复用户,我将使用db.collection
来实现这一点。我们将针对Users
集合进行操作,在这种特殊情况下,我们将使用deleteMany
方法。在这里,我们将尝试删除所有name
属性等于Andrew
的用户。
db.collection('Users').deleteMany({name: 'Andrew'});
现在我可以追加一个 then 调用来检查成功或错误,或者我可以像这样离开它,这就是我要做的。如果你使用回调或 promise 的 then 方法,那是完全可以的。只要删除发生了,你就可以继续。
使用 ID 定位文档
接下来,我将写另一个语句。我们再次针对Users
集合进行操作。现在,我们将使用findOneAndDelete
方法。在这种特殊情况下,我将删除_id
等于我已复制到剪贴板的 ObjectId 的 Todo,这意味着我需要创建一个new ObjectID
,并且我还需要在引号内传入剪贴板中的值。
db.collection('Users').deleteMany({name: 'Andrew'});
db.collection('Users').findOneAndDelete({
_id: new ObjectID("5a86978929ed740ca87e5c31")
})
单引号或双引号都可以。确保ObjectID
的大写与你定义的内容完全相同,否则此创建将不会发生。
现在我们创建了ID
并将其作为_id
属性传递,我们可以继续添加then
回调。因为我正在使用findOneAndDelete
,我打算将那个文档打印到屏幕上。在这里,我将获得我的参数results
,然后我将使用我们的漂亮打印方法将其打印到屏幕上,console.log(JSON.stringify())
,传入这三个参数,results
,undefined
和间距,我将使用2
。
db.collection('Users').deleteMany({name: 'Andrew'});
db.collection('Users').findOneAndDelete({
_id: new ObjectID("5a86978929ed740ca87e5c31")
}).then((results) => {
console.log(JSON.stringify(results, undefined, 2));
});
有了这个,我们现在可以继续了。
运行 findOneAndDelete 和 deleteMany 语句
让我们首先注释掉findOneAndDelete
。我们将运行deleteMany
语句。在终端中,我可以关闭当前连接,然后再次启动它,如果我们进入 Robomongo,我们应该看到那三个文档已被删除。我将右键单击Users
并查看文档:
我们刚刚得到了两个文档。任何名为Andrew
的都已被删除,这意味着我们的语句按预期运行了,这太棒了。
接下来,我们可以运行我们的findOneAndDelete
语句。在这种情况下,我们期望那个name
等于Mike
的文档被删除。我将确保保存文件。一旦保存,我就可以进入终端并重新运行脚本。这一次,我们获得了name
为Mike
的文档。我们确实针对了正确的文档,并且似乎已经删除了一个项目:
我可以随时通过刷新 Robomongo 中的集合来验证这一点:
我得到了只有一个文档的集合。我们现在结束了。我们知道如何从我们的 MongoDB 集合中删除文档;我们可以删除多个文档;我们可以只针对一个,或者我们可以针对一个并获取其值。
为删除文档方法进行提交
在我们离开之前,让我们进行提交并将其推送到 GitHub。在终端中,我可以关闭脚本并运行git status
以查看我们有未跟踪的文件。这里,我们有我们的mongodb-delete
文件。我可以使用git add .
添加它,然后我可以提交,使用带有-m
标志的git commit
。在这里,我可以提供提交消息,即Add delete script
:
git commit -m 'Add delete script'
我将进行提交并使用git push
将其推送到 GitHub,默认情况下将使用 origin 远程仓库。当你只有一个远程仓库时,第一个将被称为 origin。这是默认名称,就像 master 是默认分支一样。有了这个,我们现在就结束了。我们的代码已经上传到 GitHub。下一节的主题是更新,你将学习如何更新集合中的文档。
更新数据
你知道如何向 MongoDB 中插入、删除和获取文档。在本节中,你将学习如何更新 MongoDB 集合中的文档。和往常一样,开始之前,我们将复制我们上次写的最后一个脚本,并将其更新用于本节。
我将复制mongodb-delete
文件,重命名为mongodb-update.js
,这就是我们将编写更新语句的地方。我还将删除我们写的所有语句,也就是被删除的数据。现在我们已经准备好了,接下来我们将探索本节将要学习的一个方法。这个方法叫做findOneAndUpdate
。它有点类似于findOneAndDelete
。它允许我们更新一项内容并获得新文档。所以,如果我更新一个待办事项,将其completed
设置为true
,我将在响应中得到那个文档。现在,为了开始,我们将更新我们 Todos 集合中的一项内容。如果查看文档,我们目前有两个。这里的目标将是更新第二项内容,即text
等于Eat lunch
的内容。我们将尝试将completed
值设置为true
,这将是一个很常见的操作。
如果我勾选一个待办事项,我们希望切换完成的布尔值。回到 Atom 中,我们将通过访问适当的集合来启动事情。那将是db.collection
。集合名称是Todos
,我们将使用的方法是findOneAndUpdate
。现在,findOneAndUpdate
将使用到目前为止我们使用过的最多参数,所以让我们去查找它的文档以备将来参考。
在 Chrome 中,我们目前打开了“Cursor”选项卡。这是我们定义count
方法的地方。如果我们滚动到“Cursor”选项卡旁边,我们还有其他选项卡。我们正在寻找的是Collection
。现在,在Collection
部分,我们有我们的 typedefs 和方法。我们在这里看的是方法,所以如果我往下滚动,应该能找到findOneAndUpdate
并单击它。现在,findOneAndUpdate
需要传入一些参数。第一个是filter
。update
参数让我们可以指定要更新的文档。也许我们有文本,或者更有可能的是我们有文档的 ID。接下来是我们想要进行的实际更新。我们不想更新 ID,只想通过 ID 进行筛选。在这种情况下,更新的目标是更新“completed”布尔值。然后我们有一些选项,我们将对其进行定义。我们将仅使用其中之一。我们还有我们的callback
。我们将继续遵循迄今为止的方式,忽略掉回调,而是使用 promises。正如您在文档页面上所看到的,如果没有传入回调,它会返回一个 promise,这正是我们所期望的。让我们开始填写适当的findOneAndUpdate
参数,从filter
开始。我要做的是通过 ID 进行筛选。在 Robomongo 中,我可以获取此文档的 ID。我将编辑它并将 ID 复制到剪贴板中。现在,在 Atom 中,我们可以开始查询第一个对象filter
。我们只需要查找_id
等于我们复制到剪贴板中的值的文档。这就是我们需要的filter
参数。接下来要做的是要应用的实际更新,并且这并不是很直接。我们在这里要做的是了解 MongoDB 的更新操作符。
通过谷歌搜索mongodb update operators
,我们可以查看完整的这些操作符列表以及它们的确切含义。当我这样做时,我们在寻找mongodb.com文档:
现在这个文档是专门针对 MongoDB 的,这意味着它适用于所有驱动程序。在这种情况下,它将与我们的 Node.js 驱动程序配合使用。如果我们继续向下滚动,我们可以查看我们可以访问的所有更新操作符。最重要的,也是我们要开始使用的是$set
操作符。这让我们在更新中设置字段的值,这正是我们想要做的。还有其他操作符,比如增量。这个$inc
让你增加字段的值,就像我们的Users
集合中的age
字段一样。虽然这些操作符非常有用,但我们要开始使用$set
。要使用这些操作符之一,我们需要将其输入,并将其设置为一个对象。在这个对象中,这些就是我们实际要设置的东西。例如,我们想将completed
设置为true
。如果我们尝试像这样在对象的根目录下将completed
设置为true
,那么它不会按预期工作。我们必须使用这些更新操作符,这意味着我们需要这个。现在我们已经使用了设置更新操作符来更新我们的更新,我们可以继续提供我们的第三个和最后一个参数。如果你前往findOneAndUpdate
的文档,我们可以快速查看一下options
。我们关心的是returnOriginal
。
returnOriginal
方法默认为true
,这意味着它返回原始文档,而不是更新后的文档,我们不希望如此。当我们更新文档时,我们希望得到更新后的文档。我们要做的就是将returnOriginal
设置为false
,这将在我们的第三个和最后一个参数中发生。这也将是一个对象,returnOriginal
将被设置为false
。
有了这个,我们就完成了。我们可以添加一个then
调用来对结果进行操作。我将得到我的结果,并可以简单地将其打印到屏幕上,我们可以看一下具体返回了什么:
db.collection('Todos').findOneAndUpdate({
_id: new ObjectID('5a86c378baa6685dd161da6e')
}, {
$set: {
completed:true
}
}, {
returnOriginal: false
}).then((result) => {
console.log(result);
});
现在,让我们从终端运行这个。我将在终端中保存我的文件。我们将运行node
。文件在playground
文件夹中,我们将称它为mongodb-update.js
。我将运行以下脚本:
node playground/mongodb-update.js
我们得到了值属性,就像我们使用findOneAndDelete
时一样,这里有我们的文档,其中completed
值设置为true
,这就是我们刚刚设置的全新值,这太棒了。
如果我们前往 Robomongo,我们可以确认值确实已经更新。我们可以在旧文档中看到这一点,在那里值为 false。我将为 Todos 打开一个新的视图:
我们有一个包含值为 true 的吃午餐任务。既然我们已经完成了这一步,我们知道如何在 MongoDB 集合中插入、删除、更新和读取文档了。为了结束这一节,我想给你提供一个快速挑战。在 Users
集合中,你应该有一个文档。它应该有一个姓名。它可能不是 Jen
;它可能是你设置的其他东西。我想让你把这个名字更新为你的名字。如果它已经是你的名字,那就没问题;你可以将它改为其他的东西。我还希望你使用 $inc
,我们谈论过的增加运算符,将这个值增加 1。现在我不会告诉你增加运算符究竟是如何工作的。我希望你前往文档,点击 运算符
,然后向下滚动查看示例。每个运算符都有示例。学会如何阅读文档对你变得非常有用。现在,各种库的文档并不总是一样的;每个人都有点不一样的做法;但是一旦你学会了如何阅读一个库的文档,那么阅读其他库的文档就会变得容易得多,而我在这门课程中只能教授一部分知识。这门课程的真正目的是让你编写自己的代码,进行自己的研究,并查阅自己的文档,所以你的目标再次是更新这个文档,将姓名设置为当前设置的其他名称,并将年龄增加 1。
要开始工作,我打算在 Robomongo 中获取文档的 ID,因为这是我想要更新的文档。我会将 ID 复制到剪贴板上,现在我们可以专注于在 Atom 中编写该语句了。首先,我们将更新姓名,因为我们已经知道如何做了。在 Atom 中,我将继续复制该语句:
db.collection('Todos').findOneAndUpdate({
_id: new ObjectID('57bc4b15b3b6a3801d8c47a2')
}, {
$set: {
completed:true
}
}, {
returnOriginal: false
}).then((result) => {
console.log(result);
});
我会复制并粘贴它。回到 Atom 中,我们可以开始替换内容。首先,我们将使用新的 ID 替换旧的 ID,并更改我们传递给设置的内容。我们不想更新 completed
,而是想要更新 name
。我会将 name
设置为除了 Jen
之外的其他名称。我将使用我的名字 Andrew
。现在,我们将保持 returnOriginal
设置为 false
。我们想要拿回新文档,而不是原始文档。现在,我们需要做的另一件事是增加年龄。这将通过增加运算符来完成,你应该已经通过 Chrome 中的文档进行了探索。如果你点击 $inc
,它会带你到文档的 $inc
部分,如果向下滚动,你应该能够看到一个示例。在这里,我们有一个增加的示例:
我们像设置 set
一样设置 $inc
。然后,在对象内部,我们指定要递增的内容,以及要递增的程度。可以是-2
,或者在我们的情况下,它将是正数,1
。在 Atom 中,我们可以实现这一点,如下所示的代码:
db.collection('Users').findOneAndUpdate({
_id: new ObjectID('57abbcf4fd13a094e481cf2c')
}, {
$set: {
name: 'Andrew'
},
$inc: {
age: 1
}
}, {
returnOriginal: false
}).then((result) => {
console.log(result);
});
我将 $inc
等于一个对象,并在其中,我们将 age
递增 1
。有了这一点,我们现在完成了。在运行这个文件之前,我将把其他对 findOneAndUpdate
的调用注释掉,只留下新的。我还需要交换集合。我们不再更新 Todos 集合;我们正在更新Users
集合。现在,我们可以开始了。我们将 name
设置为 Andrew
,并将 age
递增 1
,这意味着我们期望 Robomongo 中的年龄为 26 而不是 25。让我们重启终端中的脚本来运行它:
我们可以看到我们的新文档,其中名称确实为 Andrew
,年龄确实为26
,这太棒了。既然你知道如何使用递增运算符,你也可以去学习你在更新调用中可用的所有其他运算符。我可以在 Robomongo 中再次检查一切是否按预期工作。我将刷新Users
集合:
我们在这里有我们的更新文档。好了,让我们通过提交更改来结束本节。在终端中,我将运行 git status
以查看存储库的所有更改:
在这里,我们只有一个未跟踪的文件,我们的mongodb-update
脚本。我将使用 git add .
将其添加到下一次提交中,然后使用 git commit
实际进行提交。我将为 message
提供 -m
参数,以便我们可以指定消息,这将是 Add update script
:
git add .
git commit -m 'Add update script'
现在我们可以运行提交命令并将其推送到 GitHub,这样我们的代码就备份到了 GitHub 存储库中:
git push
更新完成后,我们现在已经掌握了所有基本的 CRUD(创建、读取、更新和删除)操作。接下来,我们将讨论一个叫做 Mongoose 的东西,我们将在 Todo API 中使用它。
总结
在本章中,我们从连接到 MongoDB 并写入数据开始。然后,我们继续了解了在 MongoDB 上下文中的id
属性。在学习更多关于获取数据之后,我们探索了在文档中删除数据的不同方法。
在下一章中,我们将继续与 Mongoose、MongoDB 和 REST API 进行更多的操作。
第三章:MongoDB,Mongoose 和 REST API - 第二部分
在本章中,您最终将离开playground
文件夹,并且我们将开始使用 Mongoose。我们将连接到我们的 MongoDB 数据库,创建一个模型,讨论模型的确切含义,最后,我们将使用 Mongoose 向数据库保存一些数据。
设置 Mongoose
我们不需要在playground
目录中打开的任何文件,所以我们可以关闭它们。我们还将使用 Robomongo 清除TodoApp
数据库。Robomongo 中的数据将与我们将来使用的数据有些不同,最好从头开始。在删除数据库后,无需创建数据库,因为如果您记得,一旦开始向数据库写入数据,MongoDB 将自动创建数据库。有了这个准备,我们现在可以探索 Mongoose,我总是喜欢做的第一件事是查看网站。
你可以通过访问mongoosejs.com来查看网站:
在这里,您可以找到示例,指南,插件的完整列表以及大量的优秀资源。我最常使用的是阅读文档资源。它包括类似教程的指南,具有示例,以及覆盖库的每个功能的文档。这真的是一个很棒的资源。
如果您想了解某些内容或者想使用书中未涵盖的功能,我强烈建议您来到这个页面,获取一些例子,复制和粘贴一些代码,玩弄一下,并弄清楚它是如何工作的。我们现在将介绍大部分基本的 Mongoose 功能。
设置项目的根目录
在我们实际在项目中使用 Mongoose 之前,我们需要做的第一件事是安装它。在终端中,我将使用npm i
来安装它,这是npm install
的缩写。模块名称本身称为mongoose
,我们将安装最新版本,即5.0.6
版本。我们将添加--save
标志,因为我们将需要 Mongoose 用于生产和测试目的。
**npm i mongoose@5.0.6 --save**
一旦我们运行这个命令,它就会开始执行。我们可以进入 Atom 并开始创建我们运行应用程序所需的文件。
首先,让我们在项目的根目录中创建一个文件夹。这个文件夹将被称为server
,与我们的服务器相关的所有内容都将存储在server
文件夹中。我们将创建的第一个文件将被称为server.js
。这将是我们应用程序的根。当您想启动您的 Node 应用程序时,您将运行这个文件。这个文件将准备好一切。
我们在server.js
中需要做的第一件事是加载 Mongoose。我们将创建一个名为mongoose
的变量,并从mongoose
库中获取它。
var mongoose = require('mongoose');
现在我们已经有了mongoose
变量,我们需要继续连接到数据库,因为在 Mongoose 知道如何连接之前,我们无法开始向数据库写入数据。
连接 mongoose 到数据库
连接的过程将与我们在 MongoDB 脚本中所做的非常相似;例如,mongodb-connect
脚本。在这里,我们调用了MongoClient.connect
,传入了一个 URL。对于 Mongoose,我们要做的是调用mongoose.connect
,传入完全相同的 URL;mongodb
是协议,调用//
。我们将连接到我们的localhost
数据库,端口为27017
。接下来是我们的/
,然后是数据库名称,我们将继续使用TodoApp
数据库,这是我们在mongodb-connect
脚本中使用的。
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/TodoApp');
这就是这两个函数的不同之处。MongoClient.connect
方法接受一个回调函数,那时我们就可以访问数据库。Mongoose 要复杂得多。这是好事,因为这意味着我们的代码可以简单得多。Mongoose 会随着时间维护连接。想象一下,我尝试保存一些东西,save new something
。现在显然,当这个保存语句运行时,mongoose.connect
还没有时间去发出数据库请求来连接。那至少需要几毫秒。这个语句几乎会立即运行。
在幕后,Mongoose 将等待连接,然后才会尝试进行查询,这是 Mongoose 的一个巨大优势之一。我们不需要微观管理事情发生的顺序;Mongoose 会为我们处理。
我还想在mongoose.connect
的上面配置一件事。在这门课程中,我们一直在使用 promises,并且我们将继续使用它们。Mongoose 默认支持回调,但回调并不是我喜欢编程的方式。我更喜欢 promises,因为它们更容易链式、管理和扩展。在mongoose.connect
语句的上面,我们将告诉 Mongoose 我们想要使用哪个 promise 库。如果你不熟悉 promise 的历史,它并不一定总是内置在 JavaScript 中的。Promise 最初来自像 Bluebird 这样的库。这是一个开发者的想法,他们创建了一个库。人们开始使用它,以至于他们将其添加到了语言中。
在我们的情况下,我们需要告诉 Mongoose 我们想要使用内置的 promise 库,而不是一些第三方的库。我们将把mongoose.Promise
设置为global.Promise
,这是我们只需要做一次的事情:
var mongoose = require('mongoose');
mongoose.Promise = global.Promise;
mongoose.connect('mongodb://localhost:27017/TodoApp');
我们只需要把这两行放在server.js
中;我们不需要在其他地方添加它们。有了这个配置,Mongoose 现在已经配置好了。我们已经连接到了我们的数据库,并设置它使用 promises,这正是我们想要的。接下来我们要做的是创建一个模型。
创建待办事项模型
现在,正如我们已经讨论过的,MongoDB 中,你的集合可以存储任何东西。我可以有一个具有年龄属性的文档的集合,就是这样。我可以在同一个集合中有一个不同的文档,具有一个名字属性;就是这样。这两个文档是不同的,但它们都在同一个集合中。Mongoose 喜欢保持事情比那更有组织性一些。我们要做的是为我们想要存储的每样东西创建一个模型。在这个例子中,我们将创建一个待办事项模型。
现在,待办事项将具有某些属性。它将有一个text
属性,我们知道它是一个字符串;它将有一个completed
属性,我们知道它是一个布尔值。这些是我们可以定义的。我们要做的是创建一个 Mongoose 模型,这样 Mongoose 就知道如何存储我们的数据。
在mongoose.connect
语句的下面,让我们创建一个名为Todo
的变量,并将其设置为mongoose.model
。model
是我们将用来创建新模型的方法。它接受两个参数。第一个是字符串名称。我将匹配左边的变量名Todo
,第二个参数将是一个对象。
mongoose.connect('mongodb://localhost:27017/TodoApp');
var Todo = mongoose.model('Todo', {
});
这个对象将定义模型的各种属性。例如,待办事项模型将有一个text
属性,所以我们可以设置它。然后,我们可以将 text 设置为一个对象,并且可以配置 text 的具体内容。我们也可以为completed
做同样的事情。我们将有一个 completed 属性,并且我们将要指定某些内容。也许它是必需的;也许我们有自定义验证器;也许我们想设置类型。我们还将添加一个最终的属性completedApp
,这将让我们知道何时完成了一个待办事项:
var Todo = mongoose.model('Todo', {
text: {
},
completed: {
},
completedAt: {
}
});
createdApp
属性可能听起来有用,但如果你记得 MongoDB 的ObjectId
,它已经内置了createdAt
时间戳,所以在这里没有理由添加createdApp
属性。另一方面,completedAt
将增加价值。它让你确切地知道你何时完成了一个 Todo。
从这里开始,我们可以开始指定每个属性的细节,Mongoose 文档中有大量不同的选项可用。但现在,我们将通过为每个属性指定类型来保持简单,例如text
。我们可以将type
设置为String
。它始终将是一个字符串;如果它是布尔值或数字就没有意义了。
var Todo = mongoose.model('Todo', {
text: {
type: String
},
接下来,我们可以为completed
设置一个类型。它需要是一个布尔值;没有其他办法。我们将把type
设置为Boolean
。
completed: {
type: Boolean
},
我们最后一个属性是completedAt
。这将是一个普通的 Unix 时间戳,这意味着它只是一个数字,所以我们可以将completedAt
的type
设置为Number
。
completedAt: {
type: Number
}
});
有了这个,我们现在有一个可用的 Mongoose 模型。这是一个具有几个属性的 Todo 模型:text
,completed
和completedAt
。
为了准确说明我们如何创建这些实例,我们将继续添加一个 Todo。我们不会担心获取数据、更新数据或删除数据,尽管这是 Mongoose 支持的功能。我们将在接下来的部分中担心这些问题,因为我们将开始为 API 的各个路由构建。现在,我们将简要介绍如何创建一个全新的 Todo 的示例。
创建一个全新的 Todo
我将创建一个名为newTodo
的变量,尽管你可以给它取任何你喜欢的名字;这里的名字并不重要。但重要的是你运行 Todo 函数。这是从mongoose.model
返回的构造函数。我们要在它前面加上new
关键字,因为我们正在创建Todo
的一个新实例。
现在,Todo
构造函数确实需要一个参数。它将是一个对象,我们可以在其中指定一些这些属性。也许我们知道我们希望text
等于Cook dinner
之类的东西。在函数中,我们可以指定。text
等于一个字符串,Cook dinner
:
var newTodo = new Todo({
text: 'Cook dinner'
});
我们还没有要求任何属性,所以我们可以到此为止。我们有一个text
属性;这已经足够了。让我们继续探讨如何将其保存到数据库。
将实例保存到数据库
仅仅创建一个新实例并不会实际更新 MongoDB 数据库。我们需要在newTodo
上调用一个方法。这将是newTodo.save
。newTodo.save
方法将负责将text
实际保存到 MongoDB 数据库中。现在,save
返回一个 promise,这意味着我们可以添加一个then
调用并添加一些回调。
newTodo.save().then((doc) => {
}, (e) => {
});
我们将为数据保存成功或出现错误时添加回调。也许连接失败了,或者模型无效。无论如何,现在我们只是打印一个小字符串,console.log(Unable to save todo)
。在上面的成功回调中,我们实际上将得到那个 Todo。我可以将参数称为doc
,并将其打印到屏幕上,console.log
。我将首先打印一条小消息:Saved todo
,第二个参数将是实际的文档:
newTodo.save().then((doc) => {
console.log('Saved todo', doc);
}, (e) => {
console.log('Unable to save todo');
});
我们已经配置了 Mongoose,连接到了 MongoDB 数据库;我们创建了一个模型,指定了我们希望 Todos 具有的属性;我们创建了一个新的 Todo;最后,我们将其保存到了数据库中。
运行 Todos 脚本
我们将从终端运行脚本。我将通过运行node
来启动,我们要运行的文件位于server
目录中,名为server.js
:
**node server/server.js**
当我们运行文件时,我们得到Saved todo
,这意味着事情进行得很顺利。我们在这里有一个对象,有一个预期的_id
属性;我们指定的text
属性;和__v
属性。__v
属性表示版本,它来自 mongoose。我们稍后会谈论它,但基本上它会跟踪随时间的各种模型更改。
如果我们打开 Robomongo,我们会看到完全相同的数据。我要右键单击连接并刷新它。在这里,我们有我们的TodoApp
。在TodoApp
数据库中,我们有我们的todos
集合:
mongoose 自动将 Todo 转换为小写并复数形式。我要查看文档:
我们有一个文档,文本等于 Cook dinner,就是我们在 Atom 中创建的。
创建第二个 Todo 模型
我们使用我们的 mongoose 模型创建了一个 Todo。我希望你做的是创建第二个,填写所有三个值。这意味着你要创建一个新的 Todo,有一个text
值,一个completed
布尔值;继续设置为true
;和一个completedAt
时间戳,你可以设置为任何你喜欢的数字。然后,我希望你继续保存它;如果保存成功,将其打印到屏幕上;如果保存不好,打印一个错误。最后,运行它。
我首先要做的是在下面创建一个新变量。我要创建一个名为otherTodo
的变量,将其设置为Todo
模型的一个new
实例。
var otherTodo = new Todo ({
});
从这里,我们可以传入我们的一个参数,这将是对象,并且我们可以指定所有这些值。我可以将text
设置为任何我喜欢的值,例如Feed the cat
。我可以将completed
值设置为true
,我可以将completedAt
设置为任何数字。任何小于 0 的值,比如-1,都会从 1970 年开始倒数。任何正数都将是我们所在的位置,我们稍后会更多地讨论时间戳。现在,我要选择类似123
的东西,基本上是 1970 年的两分钟。
var otherTodo = new Todo ({
text: 'Feed the cat',
completed: true,
completedAt: 123
});
有了这个,我们现在只需要调用save
。我要调用otherTodo.save
。这实际上是要写入到 MongoDB 数据库的。我要添加一个then
回调,因为我确实想在保存完成后做一些事情。如果save
方法成功,我们将得到我们的doc
,我要将其打印到屏幕上。我要使用我们之前谈到的漂亮打印系统,JSON.stringify
,传入实际对象,undefined
和2
。
var otherTodo = new Todo ({
text: 'Feed the cat',
completed: true,
completedAt: 123
});
otherTodo.save().then((doc) => {
console.log(JSON.stringify(doc, undefined, 2));
})
你不需要这样做;你可以以任何你喜欢的方式打印它。接下来,如果事情进行得不好,我要打印一条小消息:console.log('Unable to save', e)
。它会传递那个错误对象,所以如果有人在阅读日志,他们可以看到调用失败的原因。
otherTodo.save().then((doc) => {
console.log(JSON.stringify(doc, undefined, 2));
}, (e) => {
console.log('Unable to save', e);
});
有了这个,我们现在可以注释掉那个第一个 Todo。这将阻止创建另一个,我们可以重新运行脚本,运行我们全新的 Todo 创建调用。在终端中,我要关闭旧连接并启动一个新连接。这将创建一个全新的 Todo,我们就在这里:
text
属性等于Feed the cat
。completed
属性设置为布尔值true
;注意它周围没有引号。completedAt
等于数字123
;再次,没有引号。我也可以进入 Robomongo 来确认这一点。我要重新获取 Todos 集合,现在我们有两个 Todos:
在值列的右侧,你还会注意到类型列。在这里,我们有 int32 用于 completedAt 和 __v 属性。completed 属性是一个布尔值,text 是一个字符串,_id 是一个 ObjectId 类型。
Robomongo 中隐藏了很多有用的信息。如果你想要什么,他们很可能已经内置了。就是这样。我们现在知道如何使用 Mongoose 建立连接,创建模型,最终将该模型保存到数据库中。
验证器、类型和默认值
在本节中,你将学习如何改进你的 Mongoose 模型。这将让你添加诸如验证之类的东西。你可以使某些属性成为必需项,并设置智能默认值。因此,如果没有提供类似已完成的东西,你可以设置一个默认值。所有这些功能都内置在 Mongoose 中;我们只需要学会如何使用它。
为了说明为什么我们要设置这些东西,让我们滚动到我们的server
文件的底部,删除我们创建的new Todo
上的所有属性。然后,我们将保存文件并进入终端,运行脚本。这将是在server
目录中的node
,文件将被称为server.js
:
**node server/server.js**
当我们运行它时,我们得到了我们的新 Todo,但它只有版本和 ID 属性:
我们在模型中指定的所有属性,text
,completed
和completedAt
,都找不到。这是一个相当大的问题。如果它们没有text
属性,我们不应该将 Todo 添加到数据库中,completed
之类的东西应该有智能默认值。如果没有人会创建一个已经完成的 Todo 项目,那么completed
应该默认为false
。
Mongoose 验证器
现在,为了开始,我们将在 Mongoose 文档中打开两个页面,这样你就知道这些东西的位置,如果将来想深入了解的话。首先,我们将查找验证器。我将搜索“mongoose 验证器”,这将显示我们内置的所有默认验证属性:
例如,我们可以将某些东西设置为“必需的”,所以如果没有提供,当我们尝试保存该模型时,它将抛出错误。我们还可以为数字和字符串设置验证器,为字符串设置minlength
/maxlength
值。
我们要查看的另一个页面是模式页面。要进入这个页面,我们将搜索“mongoose 模式”。这是第一个页面,guide.html
文件:
在这个页面上,你将看到与我们迄今为止所做的略有不同的东西。他们称之为“新模式”,设置所有属性。这不是我们到目前为止所做的事情,但将来我们会做。现在,你可以将这个对象,即“模式”对象,视为我们在 Atom 中拥有的对象,作为我们的mongoose.model
调用的第二个参数传递过去。
自定义 Todo 文本属性
为了开始,让我们自定义 Mongoose 如何处理我们的text
属性。目前,我们告诉 Mongoose 我们希望它是一个字符串,但我们没有任何验证器。我们可以为text
属性做的第一件事是将required
设置为true
。
var Todo = mongoose.model('Todo', {
text: {
type: String,
required: true
},
当你将required
设置为true
时,值必须存在,所以如果我尝试保存这个 Todo,它会失败。我们可以证明这一点。我们可以保存文件,转到终端,关闭一切,然后重新启动它:
我们得到了一个难以理解的错误消息。我们将在一会儿深入研究这个问题,但现在你只需要知道的是,我们得到了一个验证错误:Todo 验证失败,这太棒了。
现在,除了确保text
属性存在之外,我们还可以设置一些自定义验证器。例如,对于字符串,我们有一个minlength
验证器,这很棒。你不应该能够创建一个文本为空字符串的 Todo。我们可以将minlength
设置为最小长度,在这种情况下将是1
:
var Todo = mongoose.model('Todo', {
text: {
type: String,
required: true,
minlength: 1
},
现在,即使我们在otherTodo
函数中提供了一个text
属性,假设我们将text
设置为空字符串:
var otherTodo = new Todo ({
text: ''
});
它仍然会失败。它确实存在,但它没有通过minlength
验证器,其中minlength
验证器必须是1
。我可以保存server
文件,在终端重新启动,我们仍然会失败。
现在,除了required
和minlength
之外,文档中还有一些其他实用程序。一个很好的例子是称为trim
的东西。它对字符串非常有用。基本上,trim
会修剪掉值的开头或结尾的任何空格。如果我将trim
设置为true
,就像这样:
var Todo = mongoose.model('Todo', {
text: {
type: String,
required: true,
minlength: 1,
trim: true
},
它将删除任何前导或尾随空格。因此,如果我尝试创建一个text
属性只是一堆空格的 Todo,它仍然会失败:
var otherTodo = new Todo ({
text: ' '
});
trim
属性将删除所有前导和尾随空格,留下一个空字符串,如果我重新运行,我们仍然会失败。文本字段无效。如果我们提供有效的值,事情将按预期工作。在otherTodo
的所有空格中间,我将提供一个真正的 Todo 值,它将是Edit this video
:
var otherTodo = new Todo ({
text: ' Edit this video '
});
当我们尝试保存这个 Todo 时,首先会发生的是字符串开头和结尾的空格会被修剪。然后,它会验证这个字符串的最小长度为 1,它确实是,最后,它会将 Todo 保存到数据库。我将保存server.js
,重新启动我们的脚本,这一次我们得到了我们的 Todo:
Edit this video
文本显示为text
属性。那些前导和尾随空格已经被移除,这太棒了。只使用三个属性,我们就能够配置我们的text
属性,设置一些验证。现在,我们可以为completed
做类似的事情。
Mongoose 默认值
对于completed
,我们不会require
它,因为完成值很可能默认为false
。相反,我们可以设置default
属性,为这个completed
字段设置一个默认值。
completed: {
type: Boolean,
default: false
},
现在completed
,正如我们在本节中讨论的那样,应该默认为false
。如果 Todo 已经完成,就没有理由创建一个 Todo。我们也可以为completedAt
做同样的事情。如果一个 Todo 开始时没有完成,那么completedAt
就不会存在。只有当 Todo 完成时,它才会存在;它将是时间戳。我要做的是将default
设置为null
:
completed: {
type: Boolean,
default: false
},
completedAt: {
type: Number,
default: null
}
太棒了。现在,我们为我们的 Todo 有一个相当不错的模式。我们将验证用户是否正确设置了文本,并且我们将自己设置completed
和completedAt
的值,因为我们可以使用默认值。有了这个设置,我现在可以重新运行我们的server
文件,这样我们就可以得到一个更好的默认 Todo:
我们有用户提供的text
属性,已经经过验证和修剪。接下来,我们将completed
设置为false
,completedAt
设置为null
;这太棒了。我们现在有一个无懈可击的模式,具有良好的默认值和验证。
Mongoose 类型
如果您一直在玩各种类型,您可能已经注意到,如果您将type
设置为除了您指定的类型之外的其他类型,在某些情况下它仍然可以工作。例如,如果我尝试将text
设置为一个对象,我会得到一个错误。它会说,嘿,你试图使用一个字符串,但实际上出现了一个对象。但是,如果我尝试将text
设置为一个数字,我会选择23
:
var otherTodo = new Todo ({
text: 23
});
这将起作用。这是因为 Mongoose 会将您的数字转换为字符串,实质上是用引号包裹它。对于布尔值也是一样的。如果我传入一个布尔值,就像这样:
var otherTodo = new Todo ({
text: true
});
生成的字符串将是"true"
。我将在将text
设置为true
后保存文件,并运行脚本:
当我这样做时,我得到了text
等于true
,如前面的截图所示。请注意,它确实被引号包裹。重要的是要意识到,在 Mongoose 内部确实存在类型转换。它很容易让你犯错并导致一些意外的错误。但现在,我将把text
设置为一个合适的字符串:
var otherTodo = new Todo ({
text: 'Something to do'
});
为身份验证创建 Mongoose 用户模型
现在,我们将创建一个全新的 Mongoose 模型。首先,你将创建一个新的User
模型。最终,我们将用它进行身份验证。它将存储诸如电子邮件和密码之类的东西,而 Todos 将与该User
关联,因此当我创建一个时,只有我可以编辑它。
我们将研究所有这些,但现在,我们将保持事情非常简单。在User
模型上,你需要设置的唯一属性是email
属性。我们以后会设置其他属性,比如password
,但它将以稍有不同的方式完成,因为它需要是安全的。现在,我们只需坚持email
。我希望你对其进行require
。我也希望你对其进行trim
,所以如果有人在之前或之后添加了空格,那些空格就会消失。最后但并非最不重要的是,继续将type
设置为String
,设置类型,并将minlength
设置为1
。现在,显然,你可以传入一个不是电子邮件的字符串。我们以后会探索自定义验证。这将让我们验证电子邮件是否为电子邮件,但现在这将让我们走上正确的轨道。
创建了你的 Mongoose 模型后,我希望你继续尝试创建一个新的User
。创建一个没有email
属性的User
,然后创建一个具有email
属性的User
,确保当你运行脚本时,数据会如预期般显示在 Robomongo 中。这些数据应该显示在新的Users
集合中。
设置电子邮件属性
首先,我要做的是创建一个变量来存储这个新模型,一个名为User
的变量,并将其设置为mongoose.model
,这是我们可以创建新的User
模型的方法。第一个参数,你知道,需要是字符串模型名称。我将使用与我在变量中指定的完全相同的名称,尽管它可能会有所不同。我只是喜欢保持使用这种模式,其中变量等于模型名称。接下来,作为第二个参数,我们可以指定一个对象,其中我们配置User
应该具有的所有属性。
var User = mongoose.model('User', {
});
现在,正如我之前提到的,我们以后会添加其他属性,但是现在,添加对email
属性的支持就足够了。有几件事我想在这封电子邮件上做。首先,我想设置type
。电子邮件始终是一个字符串,因此我们可以将type
设置为String
。
var User = mongoose.model('User', {
email: {
type: String,
}
});
接下来,我们将对其进行require
。你不能创建一个没有电子邮件的用户,所以我将把required
设置为true
。在required
之后,我们将继续对该电子邮件进行trim
。如果有人在它之前或之后添加了空格,显然是一个错误,所以我们将继续删除User
模型中的那些空格,使我们的应用程序变得更加用户友好。最后但并非最不重要的是,我们要做的是设置一个minlength
验证器。我们以后将设置自定义验证,但现在minlength
为1
就足够了。
var User = mongoose.model('User', {
email: {
type: String,
required: true,
trim: true,
minlength: 1
}
});
现在,我将继续创建这个User
的新实例并保存它。在运行脚本之前,我将注释掉我们的新 Todo。现在,我们可以创建这个User
模型的新实例。我将创建一个名为user
的变量,并将其设置为new User
,传入我们想要在该用户上设置的任何值。
var User = mongoose.model('User', {
email: {
type: String,
required: true,
trim: true,
minlength: 1
}
});
var user = new User({
});
我将首先不运行它,只是为了确保验证有效。现在,在用户变量旁边,我现在可以调用user.save
。save
方法返回一个 promise,因此我可以附加一个then
回调。我将为此添加一个成功案例和一个错误处理程序。错误处理程序将获得该错误参数,成功案例将获得 doc。如果一切顺利,我将使用console.log('User saved', doc)
打印一条消息,然后是doc
参数。对于此示例,不需要为其进行格式化。对于错误处理程序,我将使用console.log('无法保存用户')
,然后是错误对象。
var user = new User({
});
user.save().then((doc) => {
console.log('User saved', doc);
}, (e) => {
console.log('Unable to save user', e);
});
由于我们正在创建一个没有属性的用户,我们希望错误会打印出来。我将保存server.js
并重新启动文件:
我们得到了错误。这是一个名为“路径'email'是必需的”验证错误。 Mongoose 让我们知道我们确实有一个错误。由于我们将required
设置为true
,因此电子邮件确实需要存在。我将继续放一个值,将email
设置为我的电子邮件andrew@example.com
,然后我会在后面放几个空格:
var user = new User({
email: 'andrew@example.com '
});
这一次,事情应该如预期那样进行,trim
应该修剪该电子邮件的末尾,删除所有空格,这正是我们得到的结果:
User
确实已保存,这很好,email
已经被正确格式化。显然,我也可以输入像123
这样的字符串,它也可以工作,因为我们还没有设置自定义验证,但我们有一个相当不错的起点。我们有User
模型,并且我们已经设置好并准备好使用email
属性。
有了这个,我们现在要开始创建 API。在下一节中,您将安装一个名为Postman的工具,它将帮助我们测试我们的 HTTP 请求,然后我们将为我们的 Todo REST API 创建我们的第一个路由。
安装 Postman
在本节中,您将学习如何使用 Postman。如果您正在构建 REST API,Postman 是一种必不可少的工具。我从未与团队合作或在项目中使用 Postman 不是每个开发人员都大量使用的情况。Postman 允许您创建 HTTP 请求并将其发送。这使得测试您编写的所有内容是否按预期工作变得非常容易。显然,我们还将编写自动化测试,但使用 Postman 可以让您玩弄数据并在移动 API 时查看事物是如何工作的。这真的是一个很棒的工具。
我们将转到浏览器并转到getpostman.com,在这里我们可以获取他们的应用程序:
现在我将使用 Chrome 应用程序。要安装它,您只需从 Chrome 商店安装 Chrome 应用程序,单击“添加到 Chrome”,它应该会将您带到可以打开应用程序的页面。现在,要打开 Chrome 应用程序,您必须转到这种奇怪的 URL。它是chrome://apps
。在这里,您可以查看所有应用程序,我们只需单击即可打开 Postman。
现在正如我之前提到的,Postman 允许您发出 HTTP 请求,因此我们将继续并进行一些操作以玩弄用户界面。您无需创建帐户,也无需注册付费计划。付费计划面向需要高级功能的开发团队。我们只是在我们的机器上进行基本请求;我们不需要云存储或类似的东西。我将跳过帐户创建,我们可以直接进入应用程序。
在这里,我们可以设置我们的请求;这是面板中发生的事情:
而且,在白色空间中,我们将能够查看结果。让我们继续向谷歌发出请求。
向谷歌发出 HTTP 请求
在 URL 栏中,我将输入http://google.com
。我们可以点击发送来发送该请求。确保你选择了 GET 作为你的 HTTP 方法。当我发送请求时,它会返回,所有的返回数据都显示在白色空间中:
我们有一些状态码;我们有一个 200,表示一切顺利;我们有时间,大约花了四分之一秒;我们有来自 Google 的头部;我们有 Cookie,但在这种情况下没有;我们有我们的 Body 数据。google.com
的 body 是一个 HTML 网站。在大多数情况下,我们在 Postman 中发送和接收的 body 将是 JSON,因为我们正在构建 REST API。
说明 JSON 数据的工作方式
所以为了说明 JSON 数据是如何工作的,我们将向我们在课程中早些时候使用过的地理编码 URL 发出请求。如果你还记得,我们能够传入一个位置,然后得到一些 JSON 数据,描述了诸如纬度和经度以及格式化地址之类的东西。现在这些应该还在你的 Chrome 历史记录中。
如果你删除了你的历史记录,你可以在地址栏中输入maps.googleapis.com/maps/api/geocode/json?address=1301+lombard+st+philadelphia
。这是我将要使用的 URL;你可以简单地复制它,或者你可以获取任何 JSON API 的 URL。我将它复制到剪贴板中,然后返回到 Postman,用刚刚复制的 URL 替换掉原来的 URL:
现在,我可以继续发送请求。我们得到了我们的 JSON 数据,这太棒了:
当我们发出这个请求时,我们能够看到确切的返回内容,这就是我们将要使用 Postman 的方式。
我们将使用 Postman 来发送请求,添加 Todos,删除 Todos,获取所有的 Todos,并登录;所有这些都将在这里发生。记住,API 不一定有前端。也许它是一个 Android 应用程序;也许它是一个 iPhone 应用程序或 Web 应用程序;也许它是另一个服务器。Postman 让我们能够与我们的 API 进行交互,确保它按预期工作。我们有所有的 JSON 数据返回。在 Body 下的 Raw 视图中,我们有原始数据响应。基本上,它只是未经美化的;没有格式化,没有着色。我们还有一个预览选项卡。预览选项卡对于 JSON 来说是相当无用的。当涉及到 JSON 数据时,我总是坚持使用漂亮的选项卡,这应该是默认的。
现在我们已经安装了 Postman 并且知道了一些如何使用它的知识,我们将继续进行下一部分,我们将实际创建我们的第一个请求。我们将发送一个 Postman 请求来访问我们将要创建的 URL。这将让我们可以直接从 Postman 或任何其他应用程序(无论是 Web 应用程序、移动应用程序还是另一个服务器)中创建新的 Todos。接下来就是这些内容,所以请确保你已经安装了 Postman。如果你能够完成本节的所有内容,那么你已经准备好继续了。
资源创建端点 - POST /todos
在本节中,你将为添加新的 Todos 创建你的HTTP POST
路由。在我们深入讨论之前,我们首先要重构server.js
中的所有内容。我们有数据库配置的东西,应该放在其他地方,我们有我们的模型,也应该放在单独的文件中。我们在server.js
中想要的唯一的东西就是我们的 Express 路由处理程序。
重构 server.js 文件以创建 POST todos 路由
首先,在server
文件夹中,我们将创建一个名为db
的新文件夹,在db
文件夹中,我们将创建一个文件,所有的 Mongoose 配置都将在其中进行。我将把那个文件命名为mongoose.js
,我们需要做的就是将我们的 Mongoose 配置代码放在这里:
var mongoose = require('mongoose');
mongoose.Promise = global.Promise;
mongoose.connect('mongodb://localhost:27017/TodoApp');
删除它,并将其移动到mongoose.js
中。现在,我们需要导出一些东西。我们要导出的是mongoose
变量。因此,当有人需要 mongoose.js 文件时,他们将得到配置好的 Mongoose,并且他们将得到它——他们将从库中得到的mongoose
变量。我将设置module.exports
等于一个对象,并且在该对象上,我们将mongoose
设置为mongoose
:
mongoose.connect('mongodb://localhost:27017/TodoApp');
module.exports = {
mongoose: mongoose
};
现在我们知道,在 ES6 中,这可以简化。如果你有一个属性和一个同名的变量,你可以缩短它,我们可以进一步将其放在一行上:
module.exports = {mongoose};
现在我们有了一个单独的文件中的 Mongoose 配置,该文件可以在server.js
文件中被引用。我将使用 ES6 解构来获取 mongoose 属性。基本上,我们正在创建一个名为mongoose
的本地变量,该变量等于对象上的 mongoose 属性,并且该对象将是从我们刚刚创建的文件中获取的返回结果。它在db
目录中,名为mongoose.js
,我们可以省略扩展名:
var mongoose = require('./db/mongoose');
现在 Mongoose 已经有了自己的位置,让我们对Todo
和User
做同样的事情。这将发生在服务器中的一个名为models
的新文件夹中。
配置 Todo 和 Users 文件
在models
文件夹中,我们将创建两个文件,一个用于每个模型。我将创建两个新文件,名为todo.js
和user.js
。我们可以从server.js
文件中获取 todos 和 Users 模型,然后将它们简单地复制粘贴到相应的文件中。一旦模型被复制,我们可以从server.js
中删除它。Todos 模型将如下所示:
var Todo = mongoose.model('Todo', {
text: {
type: String,
required: true,
minlength: 1,
trim: true
},
completed: {
type: Boolean,
default: false
},
completedAt: {
type: Number,
default: null
}
});
user.js
模型将如下所示。
var User = mongoose.model('User', {
email: {
type: String,
required: true,
trim: true,
minlength: 1
}
});
我还将删除到目前为止我们所拥有的一切,因为server.js
中的这些示例不再必要。我们可以简单地将我们的 mongoose 导入语句留在顶部。
在这些模型文件中,有一些事情我们需要做。首先,我们将在 Todos 和 Users 文件中调用mongoose.model
,因此我们仍然需要加载 Mongoose。现在,我们不必加载我们创建的mongoose.js
文件;我们可以加载普通的库。让我们创建一个变量。我们将称这个变量为mongoose
,然后我们将require('mongoose')
:
var mongoose = require('mongoose');
var Todo = mongoose.model('Todo', {
我们需要做的最后一件事是导出模型,否则我们无法在需要这个文件的文件中使用它。我将设置module.exports
等于一个对象,并且我们将Todo
属性设置为Todo
变量;这正是我们在mongoose.js
中所做的:
module.exports = {Todo};
我们将在user.js
中做完全相同的事情。在user.js
中,我们将在顶部创建一个名为mongoose
的变量,需要mongoose
,然后在底部导出User
模型,module.exports
,将其设置为一个对象,其中User
等于User
:
Var mongoose = require('mongoose');
var User = mongoose.model('User', {
email: {
type: String,
required: true,
trim: true,
minlength: 1
}
});
module.exports = {User};
现在,我们的三个文件都已经格式化。我们有三个新文件和一个旧文件。剩下要做的就是加载Todo
和User
。
在server.js
文件中加载 Todo 和 User 文件
在server.js
文件中,让我们使用解构创建一个变量Todo
,将其设置为require('./models/todo')
,我们可以对User
做完全相同的事情。使用 ES6 解构,我们将获取User
变量,并且我们将从调用require
返回的对象中获取它,需要models/user
:
var {mongoose} = require('./db/mongoose');
var {Todo} = require('./models/todo');
var {User} = require('./models/user');
有了这个设置,我们现在准备开始。我们有完全相同的设置,只是已经重构,这将使测试、更新和管理变得更加容易。server.js
文件只负责我们的路由。
配置 Express 应用程序
现在,让我们开始,我们需要安装 Express。我们已经在过去做过了,所以在终端中,我们只需要运行npm i
,然后是模块名称,即express
。我们将使用最新版本,4.16.2
。
我们还将安装第二个模块,实际上我们可以在第一个模块之后立即输入。没有必要两次运行npm install
。这个叫做body-parser
。body-parser
将允许我们向服务器发送 JSON。服务器然后可以接收该 JSON 并对其进行处理。body-parser
本质上解析主体。它获取该字符串主体并将其转换为 JavaScript 对象。现在,使用body-parser
,我们将安装最新版本1.18.2
。我还将提供--save
标志,这将把 Express 和body-parser
添加到package.json
的依赖项部分:
**npm i express@4.16.2 body-parser@1.18.2 --save**
现在,我可以继续发送这个请求,安装这两个模块,并在server.js
中开始配置我们的应用程序。
首先,我们必须加载刚刚安装的这两个模块。正如我之前提到的,我喜欢在本地导入和库导入之间保留一个空格。我将使用一个名为express
的变量来存储 Express 库,即require('express')
。我们将对body-parser
做同样的事情,使用一个名为bodyParser
的变量,将其设置为从body-parser
中获取的返回结果:
var express = require('express');
var bodyParser = require('body-parser');
var {mongoose} = require('./db/mongoose');
var {Todo} = require('./models/todo');
var {User} = require('./models/user');
现在我们可以设置一个非常基本的应用程序。我们将创建一个名为app
的变量;这将存储我们的 Express 应用程序。我将把它设置为调用express
:
var {User} = require('./models/user');
var app = express();
我们还将调用app.listen
,监听一个端口。我们最终将部署到 Heroku。不过,现在我们将有一个本地端口,端口3000
,并且我们将提供一个回调函数,一旦应用程序启动,它就会触发。我们将使用console.log
来打印Started on port 3000
:
var app = express();
app.listen(3000, () => {
console.log('Started on port 3000');
});
配置 POST 路由
现在,我们有一个非常基本的服务器。我们只需要开始配置我们的路由,正如我承诺的那样,我们将在本节中专注于 POST 路由。这将让我们创建新的 Todos。现在,在您的 REST API 中,有基本的 CRUD 操作,CRUD 代表创建、读取、更新和删除。
当您想要创建一个资源时,您使用POST HTTP
方法,并将该资源作为主体发送。这意味着当我们想要创建一个新的 Todo 时,我们将向服务器发送一个 JSON 对象。它将具有一个text
属性,服务器将获取该text
属性,创建新模型,并将带有 ID、completed 属性和completedAt
的完整模型发送回客户端。
要设置路由,我们需要调用app.post
,传入我们用于每个 Express 路由的两个参数,即我们的 URL 和我们的回调函数,该函数将使用req
和res
对象进行调用。现在,REST API 的 URL 非常重要,关于正确的结构有很多讨论。对于资源,我喜欢使用/todos
:
app.post('/todos', (req, res) => {
});
这是用于资源创建的,这是一个非常标准的设置。/todos
用于创建新的 Todo。稍后,当我们想要读取 Todos 时,我们将使用GET
方法,并且我们将使用GET
从/todos
获取所有 Todos 或/todos
,一些疯狂的数字,以根据其 ID 获取单个 Todo。这是一个非常常见的模式,也是我们将要使用的模式。不过,现在我们可以专注于获取从客户端发送的主体数据。
从客户端获取主体数据
为此,我们必须使用body-parser
模块。正如我之前提到的,body-parser
将获取您的 JSON 并将其转换为一个对象,将其附加到此request
对象上。我们将使用app.use
配置中间件。app.use
使用中间件。如果我们正在编写自定义中间件,它将是一个函数;如果我们正在使用第三方中间件,我们通常只是从库中访问某些内容。在这种情况下,它将作为函数调用bodyParser.json
。这个 JSON 方法的返回值是一个函数,这就是我们需要给 Express 的中间件:
var app = express();
app.use(bodyParser.json());
有了这个设置,我们现在可以向我们的 Express 应用程序发送 JSON。在post
回调中,我想要做的就是简单地console.log
req.body
的值,其中bodyParser
存储了 body。
app.use(bodyParser.json());
app.post('/todos', (req, res) => {
console.log(req.body);
});
现在我们可以启动服务器并在 Postman 中测试一下。
在 Postman 中测试 POST 路由
在终端中,我将使用clear
清除终端输出,然后运行应用程序:
**node server/server.js**
服务器在端口 3000 上运行,这意味着我们现在可以进入 Postman:
在 Postman 中,我们不会像在上一节中那样进行GET
请求。这次,我们要做的是进行 POST 请求,这意味着我们需要将 HTTP 方法更改为 POST,并输入 URL。端口将是localhost:3000
,路径将是/todos
。这是我们要发送数据的 URL:
现在,为了向应用程序发送一些数据,我们必须转到 Body 选项卡。我们要发送 JSON 数据,所以我们将转到原始并从右侧的下拉列表中选择 JSON(application/json):
现在我们已经设置了头部。这是 Content-Type 头部,让服务器知道正在发送 JSON。所有这些都是由 Postman 自动完成的。在 Body 中,我要附加到我的 JSON 的唯一信息是一个text
属性:
{
"text": "This is from postman"
}
现在我们可以点击发送来发送我们的请求。我们永远不会收到响应,因为我们还没有在server.js
中回应它,但是如果我转到终端,你会看到我们有我们的数据:
这是我们在 Postman 中创建的数据。现在它显示在我们的 Node 应用程序中,这太棒了。我们离实际创建 Todo 只差一步。在 post 处理程序中,唯一剩下的事情就是实际使用来自“用户”的信息创建 Todo。
创建 Mongoose 模型的实例
在server.js
中,让我们创建一个名为todo
的变量,以执行之前所做的操作,创建 Mongoose 模型的实例。我们将其设置为new Todo
,传入我们的对象和要设置的值。在这种情况下,我们只想设置text
。我们将文本设置为req.body
,这是我们拥有的对象,然后我们将访问text
属性,就像这样:
app.post('/todos', (req, res) => {
var todo = new Todo({
text: req.body.text
});
接下来,我们将调用todo.save
。这将实际将模型保存到数据库,并且我们将为成功和错误情况提供回调。
app.post('/todos', (req, res) => {
var todo = new Todo({
text: req.body.text
});
todo.save().then((doc) => {
}, (e) => {
});
现在,如果一切顺利,我们将发送回实际的 Todo,它将显示在 then 回调中。我将获取doc
,并在回调函数中使用res.send
发送doc
回去。这将为User
提供非常重要的信息,例如 ID 和completed
和completedAt
属性,这些属性不是由User
设置的。如果事情进展不顺利并且我们遇到错误,那也没关系。我们要做的就是使用res.send
发送错误回去:
todo.save().then((doc) => {
res.send(doc);
}, (e) => {
res.send(e);
});
稍后我们将修改如何发送错误。目前,这段代码将运行得很好。我们还可以设置 HTTP 状态。
设置 HTTP 状态码
如果你记得,HTTP 状态可以让你给别人一些关于请求进展情况的信息。它进行得顺利吗?它进行得不好吗?这种情况。你可以通过访问httpstatuses.com来获取所有可用的 HTTP 状态列表。在这里,你可以查看所有你可以设置的状态:
Express 默认设置的一个是200
。这意味着事情进行得很顺利。我们将用于错误的是400
。400
状态意味着有一些错误的输入,如果模型无法保存,就会出现这种情况。也许User
没有提供text
属性,或者文本字符串为空。无论哪种情况,我们都希望返回400
,这将会发生。在我们调用send
之前,我们要做的就是调用status
,传入400
的状态:
todo.save().then((doc) => {
res.send(doc);
}, (e) => {
res.status(400).send(e);
});
有了这个,我们现在准备在 Postman 中测试我们的POST /todos
请求。
在 Postman 中测试 POST /todos
我将在终端中重新启动服务器。如果你喜欢,你可以用nodemon
启动它。目前,我将手动重新启动它:
**nodemon server/server.js**
我们现在在本地主机 3000 上,进入 Postman,我们可以进行与之前完全相同的请求。我将点击发送:
我们得到了一个 200 的状态。这太棒了;这是默认状态,意味着事情进行得很顺利。JSON 响应正是我们所期望的。我们有我们设置的text
;我们有生成的_id
属性;我们有completedAt
,它被设置为null
,默认值;以及我们有completed
被设置为false
,默认值。
我们还可以测试当我们尝试创建一个没有正确信息的 Todo 时会发生什么。例如,也许我将text
属性设置为空字符串。如果我发送这个请求,我们现在会得到一个 400 Bad Request:
现在,我们有一堆验证代码说Todo 验证失败
。然后,我们可以进入errors
对象来获取具体的错误。在这里,我们可以看到text
字段失败了,message
是Path 'text' is required
。所有这些信息都可以帮助某人修复他们的请求并做出正确的请求。
现在,如果我进入 Robomongo,我将刷新todos
集合。看看最后一个,它确实是我们在 Postman 中创建的:
文本等于这是来自 Postman 的。有了这个,我们现在为 Todo REST API 设置了我们的第一个 HTTP 端点。
现在我还没有详细讨论 REST 是什么。我们稍后会谈论这个。现在,我们将专注于创建这些端点。当我们开始添加认证时,REST 版本将稍后出现。
向数据库添加更多的 Todos
在 Postman 中,我们可以添加更多的 Todos,这就是我要做的。Charge my phone
—我想我从来没有需要被提醒过这个—我们将添加Take a break for lunch
。在 Pretty 部分,我们看到Charge my phone
Todo 已经创建了一个唯一的 ID。我将发送第二个,我们会看到Take a break for lunch
Todo 已经创建:
在 Robomongo 中,我们可以给todos
集合进行最后一次刷新。我将展开最后三个项目,它们确实是我们在 Postman 中创建的三个项目:
现在我们的项目中已经完成了一些有意义的工作,让我们继续提交我们的更改。你可以在 Atom 中看到,server
目录是绿色的,意味着它还没有添加到 Git 中,package.json
文件是橙色的,这意味着它已经被修改,尽管 Git 正在跟踪它。在终端中,我们可以关闭服务器,我总是喜欢运行git status
来进行一次理智检查:
在这里,一切看起来都如预期。我可以使用git add .
添加所有内容,然后再进行一次检查:
在这里,我们在server
文件夹中有四个新文件,以及我们的package.json
文件。
现在,是时候提交了。我要创建一个快速提交。我使用-am
标志,通常会添加修改后的文件。由于我已经使用了 add,我可以简单地使用-m
标志,就像我们在整个课程中一直在做的那样。对于这个,一个好的消息可能是添加 POST /todos 路由和重构 mongoose
:
**git commit -m 'Add POST /todos route and refractor mongoose'**
有了提交,我们现在可以通过将其推送到 GitHub 来结束这些工作,确保它得到备份,并确保它对任何其他在项目上合作的人都可用。记住,仅仅创建一个提交并不能将其上传到 GitHub;您必须使用另一个命令git push
将其推送上去。有了这个,现在是时候进入下一部分了,您将在那里测试您刚刚创建的路由。
测试 POST /todos
在这一部分,您将学习如何为 Todo API 设置测试套件,类似于我们在“测试”部分所做的,我们将为/todos
编写两个测试用例。我们将验证当我们发送正确的数据作为主体时,我们会得到一个包括 ID 在内的200
完成文档;如果我们发送错误的数据,我们期望得到一个包含错误对象的400
。
为测试 POST /todos 路由安装 npm 模块
现在,在我们做任何这些之前,我们必须安装在“测试”部分中安装的所有模块,expect
用于断言,mocha
用于整个测试套件,supertest
用于测试我们的 Express 路由,以及nodemon
。nodemon
模块将让我们创建test-watch
脚本,这样我们就可以自动重新启动测试套件。现在我知道您已经全局安装了nodemon
,但由于我们在package.json
脚本中使用它,所以在本地安装它也是一个好主意。
我们将使用npm i
安装expect
版本22.3.0
,最新版本。接下来是mocha
。最新版本是5.0.1
。之后是nodemon
版本1.15.0
,最后但并非最不重要的是supertest
版本3.0.0
。有了这些,我们只需要加上--save-dev
标志。我们想要保存这些,但不作为常规依赖项。它们仅用于测试,因此我们将它们保存为devDependencies
:
**npm i expect@22.3.0 mocha@5.0.1 nodemon@1.15.0 supertest@3.0.0 --save-dev**
现在,我们可以运行这个命令,一旦完成,我们就可以开始在 Atom 中设置测试文件。
设置测试文件
在 Atom 中,我现在在我的package.json
文件中列出了我的devDependencies
:
现在,我的命令输出可能与您的有些不同。npm 正在缓存我最近安装的一些模块,所以正如您在前面的截图中所看到的,它只是获取本地副本。它们确实被安装了,我可以通过打开node_modules
文件夹来证明。
我们现在将在server
中创建一个文件夹,用于存储所有测试文件,这个文件夹将被称为tests
。我们要担心创建的唯一文件是server.js
的测试文件。我将在 tests 中创建一个名为server.test.js
的新文件。这是我们将在本章中使用的测试文件的扩展名。在server.test
文件中,我们现在可以通过要求很多这些模块来启动事情。我们将要求supertest
模块和expect
。mocha
和nodemon
模块不需要被要求;这不是它们的使用方式。
我们将得到的const expect
变量将等于require('expect')
,我们将对supertest
做同样的事情,使用const
:
const expect = require('expect');
const request = require('supertest');
既然我们已经准备好了,我们需要加载一些本地文件。我们需要加载server.js
,这样我们就可以访问 Express 应用程序,因为我们需要它来进行超级测试,我们还想加载我们的 Todo 模型。正如您稍后将看到的,我们将查询数据库,并且访问此模型将是必要的。现在模型已经导出了一些东西,但server.js
目前没有导出任何东西。我们可以通过在server.js
文件的最底部添加module.exports
并将其设置为一个对象来解决这个问题。在该对象上,我们要做的就是将app
属性设置为app
变量,使用 ES6 对象语法。
module.exports = {app};
现在,我们已经准备好加载这两个文件了。
加载测试文件
首先,让我们创建一个名为app
的本地变量,并且我们将使用 ES6 解构从服务器文件的返回结果中取出它。在这里,我们将从相对路径开始。然后,我们将从tests
返回到server
的上一级目录。文件名只是server
,没有扩展名。我们也可以对 Todo 模型做同样的操作。
我们将创建一个名为Todo
的常量。我们使用 ES6 解构从导出中取出它,文件来自相对路径,返回到上一级目录。然后我们必须进入models
目录,最后,文件名为todo
:
const expect = require('expect');
const request = require('supertest');
const {app} = require('./../server');
const {Todo} = require('./../models/todo');
既然我们已经加载了所有这些,我们准备创建我们的describe
块并添加我们的测试用例。
为测试用例添加描述块
我将使用describe
来对所有路由进行分组。对于一些路由,我将有多个测试用例,添加一个describe
块是很好的,这样您可以在终端中快速查看测试输出。POST Todos 的describe
块将简单地称为POST /todos
。然后,我们可以添加箭头函数(=>
),在其中我们可以开始列出我们的测试用例。第一个测试将验证当我们发送适当的数据时,一切都如预期般进行:
const {Todo} = require('./../models/todo');
describe('POST /todos', () => {
it('should create a new todo')
});
现在,我们可以添加我们的回调函数,这个函数将接受done
参数,因为这将是一个异步测试。您必须指定done
,否则这个测试将无法按预期工作。在回调函数中,我们将创建一个名为text
的变量。这是我们真正需要的唯一设置数据。我们只需要一个字符串,并且我们将在整个过程中使用该字符串。随意给它任何值。我将使用Test todo text
。
describe('POST /todos', () => {
it('should create a new todo',(done) => {
var text = 'Test todo text';
});
});
现在是时候开始通过supertest
发出请求了。我们之前只发出了GET
请求,但POST
请求同样简单。
通过 supertest 进行 POST 请求
我们将调用请求,传入我们要发出请求的应用程序。接下来,我们将调用.post
,这将设置一个POST
请求。我们将前往/todos
,新的事情是我们实际上要发送数据。为了随请求发送数据作为主体,我们必须调用send
,并且我们将传入一个对象。这个对象将被supertest
转换为 JSON,所以我们不需要担心这一点——这只是使用supertest
库的另一个很好的理由。我们将把text
设置为之前显示的text
变量,并且我们可以使用 ES6 语法来完成这个操作:
describe('POST /todos', () => {
it('should create a new todo',(done) => {
var text = 'Test todo text';
request(app)
.post('/todos')
.send({text})
})
});
现在我们已经发送了请求,我们可以开始对请求进行断言。
对 POST 请求进行断言
我们将从状态开始。我将expect
状态等于200
,当我们发送有效数据时,这应该是情况。之后,我们可以对返回的主体进行断言。我们希望确保主体是一个对象,并且它的text
属性等于我们之前指定的属性。这正是它在发送主体时应该做的事情。
在server.test.js
中,我们可以通过创建一个自定义的expect
断言来完成这个操作。如果你还记得,我们的自定义expect
调用确实传递了响应,并且我们可以在函数内部使用该响应。我们要expect
响应体有一个text
属性,并且text
属性等于使用toBe
定义的text
字符串:
request(app)
.post('/todos')
.send({text})
.expect(200)
.expect((res) => {
expect(res.body.text).toBe(text);
})
如果是这样,很好,测试通过了。如果不是,也没关系。我们只需要抛出一个错误,测试就会失败。接下来我们需要做的是调用end
来结束一切,但我们还没有完成。我们要做的是实际检查 MongoDB 集合中存储了什么,这就是我们加载模型的原因。与之前一样,我们不再像之前那样将done
传递给 end,而是传递一个函数。这个函数将在有错误时被调用,并传递一个错误和响应:
request(app)
.post('/todos')
.send({text})
.expect(200)
.expect((res) => {
expect(res.body.text).toBe(text);
})
.end((err, res) => {
});
这个回调函数将允许我们做一些事情。首先,让我们处理可能发生的任何错误。如果状态不是200
,或者body
没有一个等于我们发送的text
属性的text
属性,那么就会出现错误。我们只需要检查错误是否存在。如果存在错误,我们将把它传递给done
。这将结束测试,将错误打印到屏幕上,因此测试确实会失败。我也会return
这个结果。
.end((err, res) => {
if(err) {
return done(err);
}
});
现在,返回它并没有做任何特别的事情。它只是停止函数的执行。现在,我们将向数据库发出请求,获取所有的 Todos,并验证我们添加的一个Todo
是否确实被添加了。
发出请求从数据库中获取 Todos
为此,我们必须调用Todo.find
。现在,Todo.find
与我们使用的 MongoDB 原生find
方法非常相似。我们可以不带参数地调用它来获取集合中的所有内容。在这种情况下,我们将获取所有的 Todos。接下来,我们可以附加一个then
回调。我们将使用这个函数调用所有的todos
,并对其进行一些断言。
.end((err, res) => {
if(err) {
return done(err);
}
Todo.find().then((todos) => {
})
在这种情况下,我们要断言我们创建的 Todo 确实存在。我们将从期望todos.length
等于数字1
开始,因为我们添加了一个 Todo 项目。我们还要做一个断言。我们要expect
这一个唯一的项目有一个text
属性等于使用toBe
在 server.test.js 中定义的text
变量。
Todo.find().then((todos) => {
expect(todos.length).toBe(1);
expect(todos[0].text).toBe(text);
})
如果这两个都通过了,那么我们可以相当肯定一切都按预期工作了。状态码是正确的,响应也是正确的,数据库看起来也是正确的。现在是时候调用done
,结束测试用例了:
Todo.find().then((todos) => {
expect(todos.length).toBe(1);
expect(todos[0].text).toBe(text);
done();
})
我们还没有完成。如果其中任何一个失败,测试仍然会通过。我们必须添加一个catch
调用。
为错误处理添加 catch 调用
catch
将获取我们回调中可能发生的任何错误。然后,我们将能够获取到错误参数,并使用箭头函数将其传递给done
,就像这样:
Todo.find().then((todos) => {
expect(todos.length).toBe(1);
expect(todos[0].text).toBe(text);
done();
}).catch((e) => done(e));
请注意,这里我使用的是语句语法,而不是箭头函数表达式语法。有了这个,我们的测试用例现在可以运行了。我们有一个很好的测试用例,我们需要做的就是在package.json
中设置scripts
来实际运行它。
在package.json
中设置测试脚本
在运行测试之前,我们要设置scripts
,就像我们在测试部分做的那样。我们将有两个:test
,只运行测试;和test-watch
,通过nodemon
运行测试脚本。这意味着每当我们更改应用程序时,测试都会重新运行。
就在test
中,我们将运行mocha
,我们需要提供的唯一其他参数是测试文件的 globbing 模式。我们将获取server
目录中的所有内容,这可能在一个子目录中(稍后会有),所以我们将使用两个星号(**
)。它可以有任何文件名,只要以.test.js
扩展名结尾。
"scripts": {
"test": "mocha server/**/*.test.js",
"test-watch":
},
现在对于test-watch
,我们要做的就是运行nodemon
。我们将使用--exec
标志来指定一个在单引号内运行的自定义命令。我们要运行的命令是npm test
。单独的test
脚本是有用的,test-watch
只是在每次更改时重新运行test
脚本:
"scripts": {
"test": "mocha server/**/*.test.js",
"test-watch": "nodemon --exec 'npm test'"
},
在我们继续之前,我们需要修复一个重大缺陷。正如你可能已经注意到的,在server.test
文件中,我们做出了一个非常大的假设。我们假设数据库中没有任何内容。我们之所以这样假设,是因为我们期望在添加 1 个待办事项后,待办事项的长度为 1,这意味着我们假设它从 0 开始。现在这个假设是不正确的。如果我现在运行测试套件,它会失败。我已经在数据库中有了待办事项。我们要做的是在server.test
文件中添加一个测试生命周期方法。这个方法叫做beforeEach
。
在 server.test.js 文件中添加测试生命周期方法
beforeEach
方法将允许我们在每个测试用例之前运行一些代码。我们将使用beforeEach
来设置数据库的有用方式。现在,我们要做的只是确保数据库是空的。我们将传入一个函数,该函数将使用done
参数调用,就像我们的单独测试用例一样。
const {Todo} = require('./../models/todo');
beforeEach((done) => {
});
这个函数将在每个测试用例之前运行,只有在我们调用done
后才会继续进行测试用例,这意味着我们可以在这个函数中做一些异步的事情。我要做的是调用Todo.remove
,这类似于 MongoDB 的原生方法。我们只需要传入一个空对象;这将清除所有的待办事项。然后,我们可以添加一个then
回调,在then
回调中,我们将调用done
,就像这样:
beforeEach((done) => {
Todo.remove({}).then(() => {
done();
})
});
现在,我们也可以使用表达式语法来缩短这个:
beforeEach((done) => {
Todo.remove({}).then(() => done());
});
有了这个,我们的数据库在每次请求之前都将是空的,现在我们的假设是正确的。我们假设我们从 0 个待办事项开始,并且确实从 0 个待办事项开始,因为我们刚刚删除了所有内容。
运行测试套件
我将继续进入终端,清除终端输出,现在我们可以通过以下命令开始运行测试套件:
**npm run test-watch**
这将启动nodemon
,它将启动测试套件,然后我们得到一个通过的测试,应该创建一个新的待办事项:
我们可以通过调整一些值来验证一切是否按预期工作。我可以添加1
如下:
request(app)
.post('/todos')
.send({text})
.expect(200)
.expect((res) => {
expect(res.body.text).toBe(text + '1');
})
只是为了证明它实际上正在做它所说的。你可以看到我们得到了一个错误,因为这两个不相等。
对于我们的状态也是一样的。如果我将状态更改为其他值,比如201
,测试套件将重新运行并失败。最后但并非最不重要的是,如果我将toBe
更改为3
,如下所示:
expect(todos.length).toBe(3);
这将失败,因为我们总是在清除数据库,因此这里唯一正确的值将是1
。现在我们已经有了这个,我们可以添加我们的第二个测试用例。这将是验证当我们发送错误数据时,待办事项不会被创建的测试用例。
测试用例:不应该使用无效的主体数据创建待办事项
要开始使用这个,我们将使用it
来创建一个全新的测试用例。这个测试用例的文本可能是should not create todo with invalid body data
。我们可以传入带有done
参数的回调函数,并开始进行超级测试请求。
这一次,不需要创建一个text
变量,因为我们不会将文本传递进去。我们要做的是什么都不传递:
it('should not create todo with invalid body data', (done) => {
});
现在,我想让你做的是,像之前一样发出一个请求。你将向相同的 URL 发出一个POST
请求,但是你将发送一个空对象作为send
。这个空对象会导致测试失败,因为我们无法保存模型。然后,你会期望我们得到一个400
,这将是情况,我们在server.js
文件中发送了一个 400。你不需要对返回的主体做出任何假设。
最后,你将使用以下格式;我们将传递一个回调给end
,检查是否有任何错误,然后对数据库做出一些假设。你要做的假设是todos
的长度是0
。由于前面的代码块没有创建Todo
,所以不应该有Todo
存在。beforeEach
函数将在每个测试用例运行之前运行,所以在我们的用例运行之前,should create a new todo
中创建的Todo
将被删除。继续设置。发出请求并验证长度是否为 0。你不需要在前一个测试用例中进行断言,因为这个断言是关于数组的某些内容,而数组将是空的。你也可以不使用以下断言:
.expect((res) => {
expect(res.body.text).toBe(text);
})
因为我们不会对主体做出任何断言。完成后,保存测试文件。确保你的两个测试都通过了。
我要做的第一件事是调用request
,传入我们的app
。我们想再次发出一个post
请求,所以我会再次调用.post
,URL 也将是相同的。现在,在这一点上,我们将调用.send
,但我们不会传递无效的数据。这个测试用例的整个重点是看当我们传入无效数据时会发生什么。应该发生的是我们应该得到一个400
,所以我期望从服务器得到一个400
的响应。现在我们不需要对主体做出任何断言,所以我们可以继续进行.end
,在那里我们将传递我们的函数,该函数将被调用并传入err
参数,如果有的话,以及res
,就像这样:
it('should not create todo with invalid body data', (done) => {
request(app)
.post('/todos')
.send({})
.expect(400)
.end((err, res) => {
});
});
现在,我们要做的是处理任何潜在的错误。如果有错误,我们将return
,这将停止函数的执行,然后我们将调用done
,传入错误,以便测试正确地失败:
.end((err, res) => {
if(err) {
return done(err);
}
});
对Todos
集合的长度做出断言
现在,我们可以从数据库中获取数据,并对Todos
集合的长度做出一些断言。我将使用Todo.find
来获取集合中的每一个Todo
。然后,我将添加一个then
回调,这样我就可以对数据做一些操作。在这种情况下,我将得到todos
,并对其长度做出断言。我们将期望todos.length
等于数字0
。
Todo.find().then((todos) => {
expect(todos.length).toBe(0);
});
在这个测试用例运行之前,数据库中不应该有Todo
,因为我们发送了错误的数据,所以这个测试用例不应该创建任何Todo
。现在我们可以调用done
,并且我们也可以添加我们的catch
回调,就像之前一样。我们将调用catch
,获取错误参数并将其传递给done
:
Todo.find().then((todos) => {
expect(todos.length).toBe(0);
done();
}).catch((e) => done(e));
现在,我们完成了。我可以保存文件了。这将重新启动nodemon
,这将重新启动我们的测试套件。我们应该看到的是我们的两个测试用例,它们都通过了。在终端中,我们确实看到了这一点。我们有两个POST /todos
的测试用例,两者都确实通过了:
在这一节中,设置基本的测试套件花了一些时间,但是在将来,随着我们添加更多的路由,测试将会更容易。我们不需要设置基础设施;我们也不需要创建测试脚本或安装新的模块。
为POST /todos
路由做出提交
最后要做的就是提交。我们添加了一些有意义的代码,所以我们要保存这项工作。如果我运行git status
,您可以看到我们有一些更改的文件以及一些未跟踪的文件,所以我将使用git add .
将所有这些添加到下一个提交中。现在,我可以使用git commit
和-m
标志来实际进行提交。对于这个提交,一个好的提交消息将是测试 POST /todos 路由
。
**git commit -m 'Test POST /todos route'**
我将进行提交,最后,我将使用git push
将其推送到 GitHub。您可以在这种特殊情况下使用git push
。我需要使用git push --force
,这将覆盖 GitHub 上的所有内容。这只是我在这种特定情况下需要做的事情。您应该只运行git push
。运行后,您的代码应该被推送到 GitHub,然后就完成了。我们的路由有两个测试案例,现在是时候继续添加新的路由了。下一个路由将是一个GET
请求,用于获取所有 Todos。
列出资源 - GET /todos
现在我们的测试套件已经就位,是时候创建我们的第二个路由了,即GET /todos
路由,它将负责返回所有的 Todos。这对于任何 Todo 应用程序都是有用的。
创建 GET /todos 路由
您可能要向用户显示的第一个屏幕是他们所有的 Todos 列表。这是您用来获取信息的路由。这将是一个GET
请求,所以我将使用app.get
来注册路由处理程序,URL 本身将与我们的 URL 匹配,/todos
,因为我们想要获取所有的 Todos。稍后当我们获取单个 Todo 时,URL 将看起来像/todos/123
,但现在我们将其与 POST URL 匹配。接下来,我们可以在server.js
中的app.listen
上面添加我们的回调;这将给我们我们的请求和响应对象:
app.get('/todos', (req, res) => {
});
我们所需要做的就是获取集合中的所有 Todos,这一步我们已经在测试文件中完成了。在server.test.js
中,我们使用Todo.find
来获取所有的 Todos。我们将在这里使用相同的技术,但是我们不会传入查询;我们想要返回所有内容。
app.get('/todos', (req, res) => {
Todo.find()
});
稍后当我们添加身份验证时,您将只获取您创建的Todos
,但是现在,没有身份验证,您将获取Todos
集合中的所有内容。
接下来,我们将添加一个then
调用。这个then
调用将使用两个函数,一个是成功案例函数,当承诺被解决时调用,另一个是当承诺被拒绝时调用的函数。成功案例将使用所有的todos
调用,并且我们要做的就是使用res.send
将这些信息发送回去。
app.get('/todos', (req, res) => {
Todo.find().then((todos) => {
res.send()
}, (e) => {
})
});
我们可以传入todos
数组,但这不是完成任务的最佳方式。当您返回一个数组时,您有点束缚自己。如果您想添加另一个属性,无论是自定义状态代码还是其他数据,您都不能,因为您有一个数组。更好的解决方案是创建一个对象,并在该对象上指定todos
,使用 ES6 将其设置为todos
数组:
app.get('/todos', (req, res) => {
Todo.find().then((todos) => {
res.send({todos});
}, (e) => {
})
});
这将让您以后添加其他属性。例如,我可以添加某种自定义状态代码,将其设置为我喜欢的任何值。通过使用对象而不是发送一个数组回来,我们为更灵活的未来打开了可能性。有了这个,我们的成功案例就可以运行了。我们唯一需要做的就是处理错误,错误处理程序将与我们之前使用的一样,res.status
。我们将发送一个400
,并将发送回传入函数的错误对象:
app.get('/todos', (req, res) => {
Todo.find().then((todos) => {
res.send({todos});
}, (e) => {
res.status(400).send(e);
});
});
既然我们已经完成了这一步,我们可以启动服务器并在 Postman 中测试一下。
测试 GET /todos 路由
我将使用以下命令启动服务器:
**node server/server.js**
在 Postman 中,我们可以开始创建一些待办事项。目前,我们的应用程序和应用程序的测试使用相同的数据库。在上一节中我们运行的beforeEach
方法调用不幸地擦除了一切,这意味着我们没有数据可获取。我在 Postman 中要做的第一件事是尝试获取我们应该得到的数据,我们应该得到一个空数组,这仍然可以工作。URL 将是localhost:3000/todos
,确实将是一个 GET 请求。我可以点击发送,这将触发请求,然后我们得到我们的数据:
我们有一个对象,我们有我们的todos
属性,我们有我们的空数组,这是预期的。
现在,您可能已经注意到,每次想要使用它时手动配置路由变得非常乏味,我们将一遍又一遍地使用相同的路由。通过 Postman,我们实际上可以创建一个路由集合,这样我们就可以重新发送请求,而不必手动输入所有信息。在右侧,我可以单击保存旁边的下拉箭头,然后单击另存为。在这里,我可以给我的请求一些详细信息:
我将请求名称更改为GET /todos
;这是我喜欢使用的命名约定,HTTP 方法后跟 URL。我们现在可以暂时将描述留空,并且我们可以创建一个新的集合,因为我们没有任何集合。Postman Echo 集合是 Postman 提供给您探索此功能的示例集合。我们将创建一个名为Todo App
的集合。现在,每当我们想要运行该命令时,我们只需转到集合,点击 GET /todos,点击发送,请求就会触发。
让我们继续设置一个POST
请求来创建一个待办事项,然后我们将运行它,保存它,并重新运行GET
以确保返回新创建的待办事项。
设置 Post 请求以创建待办事项
要创建POST
请求,如果您还记得,我们必须将方法更改为 POST,URL 将保持不变,localhost:3000/todos
:
现在,为了使这个请求成功,我们还必须传递一个 Body 标签。这个标签将是一个原始的 JSON 主体。在这里,我们可以指定我们想要发送的数据。在这种情况下,我们要发送的唯一数据属性是text
,我将其设置为从 Postman 做的一些事情
:
{
"text": "Something to do from postman"
}
现在,我们可以继续执行此操作,然后在下面,我们得到了我们新创建的带有 200 状态代码的 Todo:
这意味着一切都进行得很顺利。我们可以将其保存到我们的集合中,以便稍后可以轻松地重新运行此操作。我将请求名称更改为POST /todos
,遵循相同的语法。然后,我可以选择现有的集合,Todo App 集合,并保存它:
现在,我只需点击请求,使用command + enter,或单击发送按钮,即可发送请求,然后我得到了我的todos
数组,一切看起来都很好。
我总是可以点击 POST,添加第二个,如果我喜欢,可以进行微调,添加数字2
,然后我可以使用command + enter来发送它。我可以重新运行GET
请求,然后在数据库中有两个todos
:
有了这个,我们的GET /todos
请求现在已经完成。我们还在 Postman 中设置了我们的集合,这样就可以更快地触发任何这些 HTTP 请求。
我将通过在终端中进行提交来结束本节。我将关闭服务器并运行git status
。这一次,你会看到我们只有一个文件被修改,这意味着我们可以简单地使用git commit
和-a
标志,而不是使用git add
。-a
标志将所有修改的文件添加到下一个提交中。它不适用于新的、未跟踪的文件,但修改的文件是完全可以的。然后,我可以添加-m
标志来指定我的提交消息。对于这个来说,一个好的提交消息将是Add GET /todos route
:
**git commit -a -m 'Add GET /todos route'**
最后,我们将使用git push
将其推送到 GitHub,现在我们完成了。在下一节中,我们将为GET /todos
编写测试用例。
测试 GET /todos
现在我们的GET /todos
路由已经就位,是时候为它添加一个测试用例了。现在,我们实际上可以编写测试用例之前,我们必须处理一个不同的问题。我们在server.test
文件中的第一件事是删除所有的 Todos,这发生在每个测试之前。GET /todos
路由基本上依赖于数据库中有它可以返回的 Todos。它将处理 Node Todos,但对于我们的测试用例,我们希望数据库中有一些数据。
为了添加这些数据,我们要做的是修改beforeEach
,添加一些种子数据。这意味着我们的数据库仍然是可预测的;当它启动时,它总是看起来完全一样,但它会有一些项目。
为 GET /todos 测试用例添加种子数据
现在,为了做到这一点,我们要做的第一件事是制作一个虚拟 Todos 数组。这些 Todos 只需要text
属性,因为其他所有内容都将由 Mongoose 填充。我可以创建一个名为todos
的常量,将其设置为一个数组,我们将有一个对象数组,其中每个对象都有一个text
属性。例如,这个可以有一个文本为First test todo
,然后我可以在数组的第二个项目中添加第二个对象,其text
属性等于Second test todo
:
const todos = [{
text: 'First test todo'
},{
text: 'Second test todo'
}];
现在,我们实际上可以编写测试用例之前,我们必须使用一个全新的 Mongoose 方法insertMany
修改beforeEach
,它接受一个数组,如前面的代码块所示,并将所有这些文档插入集合中。这意味着我们需要快速调整代码。
不是使用一个简单的箭头函数调用done
,我要加上一些花括号,在回调函数内部,我们将调用Todo.insertMany
,并且我们将使用在前面的代码块中定义的数组调用insertMany
。这将插入数组中的所有 Todos,我们的两个 Todos,然后我们可以做一些像调用done
的事情。我将返回响应,这将让我们链接回调,然后我可以添加一个then
方法,在那里我可以使用一个非常简单的基于表达式的箭头函数。我要做的就是使用表达式语法调用done
:
beforeEach((done) => {
Todo.remove({}).then(() => {
return Todo.insertMany(todos);
}).then(() => done());
});
现在,让我们继续运行测试套件。我现在警告你,其他测试会出问题,因为它们断言的数字现在将不正确。在终端中,我将使用以下命令启动测试套件:
**npm run test-watch**
一旦测试套件启动,我将回到 Atom,正如承诺的那样,两个测试用例都失败了。我们期望3
是1
,我们期望2
是0
。现在所有的都错了2
。
为了解决这个问题,我们将使用两种不同的技术。在 server.test.js 文件中,在 Post todos 测试中,对于第一个测试,我们要做的是只查找text
属性等于Test todo text
的 Todos:
Todo.find({text}).then((todos) => {
expect(todos.length).toBe(1);
expect(todos[0].text).toBe(text);
done();
}).catch((e) => done(e));
这意味着结果的长度仍然是1
,第一项仍然应该有一个text
属性等于上面的文本。对于第二个测试,我们将保持find
调用不变;相反,我们将确保数据库的长度为2
:
Todo.find().then((todos) => {
expect(todos.length).toBe(2);
done();
}).catch((e) => done(e));
Todos 集合中应该只有两个文档,因为这是我们添加的所有内容,这是一个失败的测试,所以不应该添加第三个。有了这个设置,你可以看到我们的两个测试用例现在通过了:
我们现在准备继续并在测试用例中添加一个新的describe
块。
在测试用例中添加一个描述块
我将添加一个describe
块,描述GET /todos
路由,传入我们的箭头函数,然后我们可以添加我们的单个测试用例,it('should get all todos', )
。现在,在这种情况下,所有的todos
都指的是我们之前添加的两个 Todos。我将传入一个带有done
参数的箭头函数,我们准备好了。我们所要做的就是开始 super test 请求——我将在 express 应用程序上request
一些东西——这将是一个 GET 请求,所以我们将调用.get
,传入 URL/todos
:
describe('GET /todos', () => {
it('should get all todos', (done) => {
request(app)
.get('/todos')
)};
});
有了这个设置,我们现在准备做出我们的断言;我们没有在请求体中发送任何数据,但我们将对返回的内容做出一些断言。
在测试用例中添加断言
我们期望返回200
,并且我们还将创建一个自定义断言,期望关于 body 的一些内容。我们将使用响应提供我们的回调函数,并期望res.body.todos
的长度为2
,.toBe(2)
。现在我们有了这个设置,我们所要做的就是添加一个end
调用,并将done
作为参数传递。
describe('GET /todos', () => {
it('should get all todos', (done) => {
request(app)
.get('/todos')
.expect(200)
.expect((res) => {
expect(res.body.todos.length).toBe(2);
})
.end(done);
)};
});
不需要提供一个结束函数,因为我们不是异步地做任何事情。
有了这个设置,我们现在可以继续了。我们可以保存server.test
文件。这将使用nodemon
重新运行测试套件;我们应该看到我们的新测试,并且它应该通过。在终端中,我们就是这样得到的:
我们有POST /todos
部分;这两个测试都通过了,我们有GET /todos
部分,一个测试确实通过了。现在,如果我将状态更改为201
,测试将失败,因为这不是返回的状态。如果我将长度更改为3
,它将失败,因为我们只添加了 2 个 Todos 作为种子数据。
现在我们完成了,让我们继续提交,保存这段代码。我将关闭test-watch
脚本,运行git status
命令,我们有两个修改过的文件,这意味着我可以使用git commit
与-a
标志和-m
标志。记住,-a
标志将修改的文件添加到下一个提交。这次提交的好消息是Add tests for GET /todos
:
**git commit -a -m 'Add tests for GET /todos'**
我要提交,将其推送到 GitHub,然后我们就完成了。
总结
在本章中,我们致力于设置 mongoose,将 mongoose 连接到数据库。我们创建了一些 Todos 模型并运行了测试脚本。接下来,我们研究了 mongoose 验证器、默认值和类型,并自定义了 todo 模型的属性,如测试、完成和完成时间。然后,我们了解了 Postman 的基础知识,并向 Google 发出了 HTTP 请求。我们还研究了配置一些 todo 路由,主要是 POST /todos 和 GET /todos。我们还研究了创建测试用例和测试这些路由。
有了这个设置,我们现在准备继续添加一个全新的路由,这将在下一章中进行。
第四章:MongoDB、Mongoose 和 REST API - 第三部分
在本章中,您将在玩弄 Mongoose 之后解决 Mongoose 查询和 ID 验证的问题。我们将深入了解不同类型的 todo 方法,然后将 API 部署到 Heroku。最后,在学习更多关于 Postman 环境和运行各种测试用例之后,我们将创建我们的测试数据库。
Mongoose 查询和 ID 验证
在这一部分,你将学习一些使用 Mongoose 查询数据的替代方法。现在,在server.test
文件中,我们已经看过一种方法,Todo.find
。我们将再看两种方法,然后我们还将探讨如何验证 ObjectIDs。
为了做到这一切,我们将在playground
文件夹中创建一个新文件。我将把这个文件命名为mongoose-queries.js
,我们需要做的第一件事是加载db
文件夹中的mongoose
文件和models
文件夹中的todo
文件。我将使用 ES6 解构,就像我们在发生这种情况的所有文件中使用的那样,然后我们可以在本地文件中require
。使用相对路径,我们需要返回到playgroundserverdb
的上一级目录,最后我们要找的文件名叫做mongoose
:
const {mongoose} = require('./../server/db/mongoose');
我们可以对todo
做同样的事情;我们将从require
中使得常量Todo
返回结果,文件将遵循相同的路径。我们需要返回到上一级目录并进入server
,但是不是进入db
而是进入models
。然后我们会得到todo
文件:
const {Todo} = require('./../server/models/todo');
现在,在我们实际进行任何查询之前,我们将在 Robomongo 中获取一个现有 Todos 的 ID。在TodoApp
数据库中,我将浏览所有文档,然后我会获取第一个文档的 ID:
我会右键单击进行编辑,然后我可以获取 ID,不包括引号、括号和ObjectId
标识符。有了这个 ID 在剪贴板中,回到 Atom 中我可以创建一个名为id
的变量,并将其设置为我刚刚复制的 ID,然后我们就有了一个 ID,我们可以用它来进行所有的查询。
Todo.find 方法
现在,我明白你之前已经使用过Todo.find
,但我们将讨论一些其他事情。所以我们将从那里开始。Todo.find
允许您查询尽可能多的 Todos。您可以不传递任何参数来获取所有的 Todos,或者您可以按任何条件查询。我们将按_id
查询。现在,Mongoose 非常棒,它不需要您传递 ObjectIDs,因为它实际上可以为您做到这一点。在这种情况下,我们所做的是完全有效的。我们传递一个字符串作为值,Mongoose 将接受该字符串,将其转换为 ObjectID,然后运行查询。这意味着我们不需要手动将我们的字符串转换为 ObjectID。现在,在我们进行查询之后,我们可以附加一个then
回调,我们将得到所有的 Todos,我们将命名该参数,然后可以继续将它们打印到屏幕上,console.log('Todos',)
,第二个参数将是实际的todos
数组:
var id = '5a87f714abd1eb05704c92c9';
Todo.find({
_id: id
}).then((todos) => {
console.log('Todos', todos);
});
除了可以将id
作为字符串传递之外,这里没有什么新的东西。
Todo.findOne 方法
接下来我们要看的方法是一个叫做Todo.findOne
的方法。现在,Todo.findOne
非常类似于 find,唯一的区别是它最多返回一个文档。这意味着它只是简单地获取与您查询匹配的第一个文档。在我们的例子中,我们通过唯一的 ID 进行查询,所以它只会找到一个匹配的项目,但是如果有其他结果,例如,如果我们查询所有 completed 为 false 的 Todos,第一个文档将是唯一返回的,即使有两个匹配查询的。我们调用findOne
的方式与我们用 find 的方式是一样的,为了证明这一点,我实际上要复制代码。我们只需要改变一些东西。我们不再是得到todos
,而是得到todo
,我们只是得到一个单独的文档而不是一组文档。这意味着我可以打印一个Todo
字符串,然后是todo
变量:
Todo.findOne({
_id: id
}).then((todo) => {
console.log('Todo', todo);
});
有了这个,我们现在有足够的例子来运行文件并看看到底会发生什么。
在终端内部,我将通过运行这个文件来开始一些事情,并且我将使用以下命令来运行它:
**nodemon playground/mongoose-queries.js**
当我们运行文件时,我们得到我们的Todos
数组,我们得到一个文档的数组,我们得到我们的Todo
对象:
如果您知道您只是想获取一个单独的项目,我建议使用findOne
而不是find
。您会得到文档而不是数组。当您要查找的 Todo 的 ID 不存在时,这也会使得处理变得更加容易;与其得到一个空数组作为结果,您将得到null
,然后您可以对此进行处理,做任何您喜欢的事情。也许这意味着您返回一个 404,或者也许您希望在找不到 ID 时做其他事情。
Todo.findById 方法
我们要看的最后一个方法是Todo.findById
。现在,findById
非常棒,如果您只是想通过其标识符查找一个文档。除了 ID 之外,没有其他查询方式,您只需将 ID 作为参数传入。您不需��创建一个查询对象,也不需要设置_id
提示。有了这个,我们现在可以做与findOne
相同的事情。我将通过将then
调用粘贴到Todo.findById
中来证明这一点,并且只需将打印语句从Todo
更改为Todo By Id
:
Todo.findById(id).then((todo) => {
console.log('Todo By Id', todo);
});
现在如果我保存文件,nodemon
将重新运行,我们将得到完全相同的结果:
如果您想通过除 ID 之外的其他方式找到一个文档,我建议使用findOne
。如果您想通过 ID 找到一个文档,我总是建议使用findById
。现在,所有这些以及更多内容都可以在文档中找到,所以如果您想深入了解我在这里讨论的任何内容,您可以随时访问mongoosejs.com。点击阅读文档链接,在左侧有一些链接;我们要找的是查询的链接:
您可以了解更多关于如何查询文档的信息,但我们已经基本涵盖了这个页面所讨论的一切。
处理 ID 不存在的情况
现在,我想要谈论的下一件事是当 ID 不正确时会发生什么,这将是一个情况,因为请记住,我们的 API 将从用户那里获取这个 ID,这意味着如果 ID 不正确,我们不希望我们的代码失败,我们希望优雅地处理这些错误。为了证明这一点,我将继续调整 ID。ID 有特定的协议,所以我想让您在您的 ID 中找到一个数字。我将选择第一个字符,因为它恰好是一个数字,然后将其递增一。我将从5
变为6
。现在我们有一个有效的 ID,但是 ID 不会在数据库中,因为我调整了它,显然数据库中的其他 Todo 与此 ID 不匹配。
现在,有了这个设置,你可以看到当我们重新启动服务器时,我们得到了一个空数组的 find 调用,并且对于findOne
和findById
都得到了 null:
当你的 ID 在数据库中找不到匹配项时,不会抛出错误;它仍然会触发成功情况,只是会以一个空数组或 null 的形式触发,这意味着当我们想处理 ID 在数据库中不存在的情况时,我们只需要添加一个if
语句。在Todo.findById
语句中,我可以添加一个if
语句。如果没有待办事项,我们将做一些事情,那个事情就是使用return
来阻止函数的其余部分执行,并且我们会打印一个小消息,console.log('Id not found')
:
Todo.findById(id).then((todo) => {
if(!todo) {
return console.log('Id not found');
}
console.log('Todo By Id', todo);
});
现在,如果我保存文件,最后一个调用应该看起来有点不同:
如前面的屏幕截图所示,我们得到了Id not found
而不是 null 的 Todo,这很完美。现在我们知道如何使用findOne
和findById
进行查询,也知道如何处理查询的 ID 实际上不存在于集合中的情况。我将 ID 设置回原始值,将6
改为5
,如果我保存文件,nodemon 将重新启动,我们将得到我们的文档。
验证 ObjectID
现在,我想谈谈的最后一件事是如何验证 ObjectID。到目前为止,我们已经创建了一个有效的 ObjectID。它只是一个不在集合中的值,但如果我们做一些像加上两个1
这样的事情,我们实际上会得到一个无效的 ID,这将导致程序出错。现在,你可能会想为什么会发生这种情况,但这可能是因为用户是指定 ID 的人。我们将在findById
上添加一个catch
调用。我们将获取错误并简单地使用console.log
将其打印到屏幕上:
Todo.findById(id).then((todo) => {
if(!todo) {
return console.log('Id not found');
}
console.log('Todo By Id', todo);
}).catch((e) => console.log(e));
现在,为了说明这一点,我们不需要所有三个查询。为了清理终端输出,我将注释掉Todo.find
和Todo.findOne
。有了这个设置,我们的无效 ID 和catch
回调,我们可以保存文件,在终端中我们应该会得到一个非常长的错误消息:
对于给定值,我们有一个错误消息,CastError: Cast to ObjectId failed
。这是在警告你,你的ObjectID
不仅不存在于集合中,而且完全无效。现在,使用catch
方法运行这个可以让我们处理错误。我们可以告诉用户,嘿,你发送的 ID 是无效的,但也有另一种方法可以完成。我们要做的是加载 MongoDB 原生驱动程序中的 ObjectID,这是我们以前做过的事情。在mongodb-connect
中我们加载了ObjectID
。在mongoose-queries
中我们要做同样的事情。我将创建一个叫做ObjectID
的常量,并且从mongodb
库中获取它:
const {ObjectID} = require('mongodb');
现在,在ObjectID
上我们有很多实用方法。我们已经看过如何创建新的 ObjectIDs,但我们还可以访问一个叫做ObjectId.isValid
的方法。isValid
方法接受值,本例中是我们的字符串id
,如果它是有效的则返回 true,如果无效则返回 false,这意味着我们可以在运行查询之前添加if
条件来验证 ID。
我们将添加一个if
语句,并检查值是否无效。我将使用感叹号翻转它,然后我们可以调用ObjectID.isValid
。通过翻转它,我们实质上创建了一个测试 ObjectID 是否无效的方法。我要传入的值只是存储在id
变量中的字符串,现在我们可以添加一些代码,当 ID 无效时运行,console.log('ID 无效')
:
if(!ObjectID.isValid(id)) {
console.log('ID not valid');
}
现在,如果我保存文件,我们应该会收到ID 无效
的消息,然后之后我们应该会收到错误消息打印到终端,因为我们仍然有我们的catch
调用,这个查询仍然会运行。在这里我们就得到了。ID 无效
打印到屏幕上:
但现在我们知道如何验证 ID 了,这将在下一节中派上用场。
现在是时候挑战一下了。在设置挑战之前,我将注释掉id
和我们的isValid
调用,然后在下面我将注释掉findById
。我将把它们留在这里;您可以将它们用作挑战中要做的参考。您的挑战是查询用户集合。这意味着您将要继续前进并移入 Robomongo,并从用户集合中获取一个 ID。这里我只有一个文档;如果由于某种原因您没有文档,您可以随时右键单击“插入文档”,然后只需指定电子邮件。
现在,为了在 Atom 内部进行该查询,您需要加载用户 Mongoose 模型,因为目前我们只有 Todo 一个,需要。在下面,我希望您使用User.findById
来查询您在 Robomongo 中选择的 ID。然后,您将继续处理三种情况。将会有查询成功但没有用户的情况。在这种情况下,您将打印类似于未找到用户
的内容。您还将处理找到用户的情况。我希望您继续将用户打印到屏幕上。最后,您将处理可能发生的任何错误。对于这个,您可以简单地将错误对象打印到屏幕上。这次不需要使用isValid
,您只需填写findById
调用即可。
现在,我要做的第一件事是导入用户文件。我将创建一个const
,我将从 require 的返回结果中获取User
变量,并且我们将按照这里的相同路径进行。我们必须从playground
目录中出来,进入server/models
目录,最后文件名是user
:
const {User} = require('./../server/models/user');
现在我们已经导入了用户,我们可以在下面查询它。在编写查询之前,我将在 Robomongo 中获取一个 ID:
我可以编辑文档,突出显示它,复制它,并移回到 Atom。在 Atom 内部,我将设置我的User.findById
调用。我所要做的就是传入 ID;我已经将它放在剪贴板中,并且我将用引号括起来。接下来是回调函数。我将附加一个then
回调,传入两个函数。第一个是当承诺被解决时,第二个是当承诺被拒绝时。对于拒绝,我们要做的就是将错误对象打印到屏幕上,这意味着我们可以使用console.log(e)
。现在,如果事情进展顺利,仍然有一些例外情况。我们要确保用户确实存在。如果 ID 与集合中找到的任何内容不匹配,查询仍将通过。如果没有用户,我们将使用return
停止函数执行,然后我们将继续使用console.log('无法找到用户')
进行打印:
User,findById('57bdb0fcdedf88450bfa2d66').then((user) => {
if(!user) {
return console.log('Unable to find user');
}
}, (e) => {
console.log(e);
});
现在,我们需要处理的最后一种情况是,如果事情确实进展顺利,这意味着查询确实有效,并且 ID 确实在用户集合中找到了。我将添加console.log
,使用我们的漂亮打印技术,user
变量,JSON.stringify
,传入我们的三个参数,user
,undefined
,和数字2
:
User.findById('5a8708e0e40b324268c5206c').then((user) => {
if(!user) {
return console.log('Unable to find user');
}
console.log(JSON.stringify(user, undefined, 2));
}, (e) => {
console.log(e);
});
有了这个,我现在可以保存文件并打开终端,因为它目前是隐藏的,我们的用户出现在终端中:
这太棒了;如果你看到这个,你已经成功完成了挑战。现在我也可以测试我的其他情况是否按预期工作。我将把 ID 末尾的6
改为7
并保存文件:
当它重新启动时,我得到无法找到用户
,这是预期的。接下来,我将把它改回6
,但我将添加几个1
,或者其他任何字符。在这种情况下,我将使用两个1
和两个a
字符。这次我们确实得到了错误,我们无法将该值转换为 ObjectId。让我们撤消对 ID 的更改,现在我们完成了。
我将通过提交我们的更改来结束本节。我将关闭nodemon
,运行git status
命令,我们有一个新文件:
我可以使用git add
将其添加到下一个提交,然后我可以使用git commit
进行提交。这个的一个好消息是Add queries playground file
:
**git commit -m 'Add queries playground file'**
有了这个,我将使用git push
命令将其推送到 GitHub,我们完成了。在下一节中,您将负责创建一个完整的 API 请求。
获取个人资源 - GET /todos/:id
在本节中,您将创建一个用于获取单个待办事项的 API 路由。现在,本节的大部分内容都将是一个挑战,但在我们开始之前,有一件事我想向您展示,那就是如何获取通过 URL 传递的变量。现在,正如我提到的,这个 URL 的结构将是一个GET
请求,/todos
,然后我们将深入到 Todos,获取通过 URL 传递的单个项目的 ID,比如/todos/12345
。这意味着我们需要使 URL 的 ID 部分是动态的。我希望能够获取该值,无论用户传入什么,然后使用它进行查询。我们在mongoose-queries
文件中设置的查询,比如User.findById
,用于通过 Id 获取待办事项。
现在,为了完成这个,让我们进入server.js
文件,并调用app.get
,传入 URL。
挑战接受
第一部分我们已经知道了,/todos/
,但现在我们需要的是一个 URL 参数。URL 参数遵循这种模式:冒号后面跟着一个名称。现在我可以称这个为:todoId
,或者其他任何名称,但是在本节中我们将称之为:id
。这将创建一个id
变量;它将在请求对象上,我们马上就会设置的那个对象上,我们将能够访问该变量。这意味着当有人发出GET /todos/1234324
请求时,回调将触发,我们现在将指定的回调,我们将能够通过传入的 ID 进行查询。现在,我们仍然会得到请求和响应对象,唯一的区别是我们现在将使用请求的某些内容。这个是req.params
。req.params
对象将是一个对象,它将具有键值对,其中键是 URL 参数,比如 id,值是实际放在那里的任何值。为了演示这一点,我将简单地调用res.send
,发送req.params
对象回去:
//GET /todos/12345
app.get('/todos/:id', (req, res) => {
res.send(req.params);
});
这将让我们在 Postman 中测试这个���由,并确切地看到它是如何工作的。在终端中,我可以启动我们的服务器。我将使用以下命令启动:
**nodemon server/server.js**
现在服务器在localhost:3000
上,我们可以对/todos/:id
URL 进行GET
请求。在 Postman 中,我将这样做;我们有 GET 方法,URL 是localhost
,仍然在端口3000/todos/
上,然后我们可以输入任何我们喜欢的东西,比如123
。现在,当我发送这个请求时,我们得到的是req.params
对象,在 Body 中你可以看到它有一个id
属性设置为123
:
这意味着我们能够使用req.params.id
访问 URL 中的值,这正是你需要为挑战做的事情。在 Atom 中,我将通过创建一个var id = req.params.id
变量来开始这个过程。
有了这个准备,你现在知道了完成挑战所需的一切,这将是完成填写这个路由。首先,你将使用我们在mongoose-queries
文件中探索过的 ObjectID isValid
方法来验证 ID。我会留下一个小注释,Valid id using isValid
。现在,如果它不是有效的,你将停止函数执行,并且你将回应一个404
响应代码,因为传入的 ID 是无效的,而且永远不会在集合中。我们将回应一个404
响应代码,让用户知道 Todo 没有找到,你可以发送回一个空的 body,这意味着你可以只调用 send 而不传递任何值。这将类似于没有错误的res.status(400).send(e)
语句,你还会将400
改为404
。
接下来,你将开始查询数据库,这将使用findById
来完成。我希望你拿到 ID 并查询Todos
集合,寻找匹配的文档;有两种情况。有成功的情况,也有错误的情况。如果我们得到一个错误,那就很明显:我们将发送一个400
响应代码,让用户知道请求无效,我们也将继续发送回空值。我们不会发送回错误参数,因为错误消息中可能包含私人信息。我们稍后会加强我们的错误处理。目前,正如你所看到的,我们在很多地方都重复了这个函数。稍后这将被移到一个位置,但现在你可以用400
响应代码回应,并发送一个空的 body。这带我们来到成功的情况。现在,如果有一个 Todo,if todo
,你将继续发送它。如果没有 Todo,if no todo
,这意味着调用成功了,但在集合中找不到 ID。你将继续发送一个404
响应代码和一个空的 body。
现在,这两个语句看起来非常相似;你将发送一个404
,让用户知道他们传入的 ID 与Todos
集合中的任何文档的 ID 都不匹配。现在你知道如何做到这一点,你可以使用任何你需要完成这个任务的东西。这意味着你可以使用mongoose-queries
文件,你可以使用mongoosejs.com文档,你可以使用 Stack Overflow,Google,或者其他任何东西;这不是关于记住如何准确地完成任务,而是关于自己解决这些问题。最终,当这些技术一次又一次地出现时,你会记住很多这些技术,但现在你的目标只是让它工作。完成后,继续在 Postman 应用程序中发送这个请求。这意味着你要从 Robomongo 中获取一个有效的 ID,并将其粘贴到 URL 中。你还可以测试数据库中存在但无效的 ID 以及无效的 ID,比如123
,这不是一个有效的 ObjectID。有了这个准备,你就可以开始挑战了。
挑战步骤 1 - 填写代码
我要做的第一件事是填写代码。我们将验证 ID,如果无效,我们将发送404
响应代码。在文件的顶部,我没有导入 ObjectID,所以我需要去做。就在bodyParser
下面,我可以创建一个变量ObjectID
,并将其设置为require
返回的结果;我们需要mongodb
库。现在我们有了ObjectID
,我们可以继续使用它。我们将编写一个if
语句,if (ObjectID.isValid())
。显然,我们只想在它无效时运行这段代码,所以我将使用感叹号翻转返回结果,然后我将传入id
。现在我们有了一个if
条件,只有在 ID 无效时才会通过。在这种情况下,我们将使用return
来阻止函数执行,然后我将使用res.status
进行响应,将其设置为404
,然后我将调用send
,不带参数,这样我就可以发送一个空的主体。我们完成了第一步。有了这个,我们现在可以继续创建查询了:
//GET /todos/12345
app.get('/todos/:id', (req, res) => {
var id = req.params.id;
if(!ObjectID.isValid(id)) {
return res.status(404).send();
}
});
此时,我们实际上有一些可以测试的东西:我们可以传入无效的 ID,并确保我们得到了 404。在终端内部,我使用nodemon
运行了应用程序,所以它会自动在 Postman 中重新启动。我可以重新运行localhost:3000/todos/123
请求,我们得到了 404,这太棒了:
这不是一个有效的 ObjectID,条件失败了,404 确实返回了。
挑战步骤 2 - 进行查询
接下来,我们将进行查询Todo.findById
。在这里,我们将传入 ID,我们在id
变量中拥有,然后我们将附加我们的成功和错误处理程序,.then
,传入我们的成功回调。这可能会调用个别的 Todo 文档,并且我也会调用catch
,获取错误。我们可以先处理错误处理程序。如果有错误,我们将保持非常简单,res.status
,将其设置为400
,然后我们将继续调用send
,有意地省略错误对象:
Todo.findById(id).then((todo) => {
}).catch((e) => {
res.status(400).send();
});
有了这个,唯一剩下的事情就是填写成功处理程序。我们需要确保实际上找到了一个 Todo。这个查询,如果成功,可能并不总是会返回一个实际的文档。我将使用一个if
语句来检查是否没有 Todo。如果没有 Todo,我们希望用404
响应代码进行响应,就像之前一样。我们将使用return
来停止函数执行,res.status
。这里的状态将是404
,我们将使用send
来响应没有数据:
Todo.findById(id).then((todo) => {
if(!todo) {
return res.status(404).send();
}
}).catch((e) => {
res.status(400).send();
});
挑战步骤 3 - 成功路径
最后一种情况是快乐路径,成功的情况,当一切按计划进行时。ID 是有效的,我们在 Todos 集合中找到了一个与传入的 ID 匹配的文档。在这种情况下,我们要做的就是使用res.send
进行响应,将 Todo 发送回去。现在,你可以像这样发送它res.todo(todo)
;这确实可以工作,但我想稍微调整一下。我不是将 Todo 作为主体发送回去,而是将 Todo 作为todo
属性附加到对象中,使用 ES6 对象定义,这与以下内容相同:
res.send({todo: todo});
这给了我一些灵活性。我可以随时添加其他属性到响应中,比如自定义状态码或其他任何东西。这类似于我们用于GET /todos
的技术。就在这里,res.send({todos})
,不是用数组进行响应,而是用一个具有todos
属性的对象进行响应,这就是数组:
Todo.findById(id).then((todo) => {
if(!todo) {
return res.status(404).send();
}
res.send({todo});
}).catch((e) => {
res.status(400).send();
});
现在我们已经完成了这一切,我们可以测试一下。我将保存文件,删除所有注释,根据需要添加分号,然后我们将从 Robomongo 中获取一个 ID。在 Robomongo 中,我可以获取一个我的 Todos 的 ID。我将选择第二个。我将编辑文档并将其复制到剪贴板。现在在 Postman 中,我们可以继续发出请求,将 ID 设置为我们刚刚复制的 ID 值:
我要发送它。我们在对象中有一个todo
属性,在该todo
属性上,我们有文档的所有属性,_id
,text
,completedAt
和completed
:
现在,我想测试的最后一种情况是,当我们请求一个具有有效 ObjectID 的 Todo,但恰好不存在时会发生什么。我将通过将 ID 中的最后一个数字从a
更改为b
来实现这一点:
如果我发送这个,我们会得到404
响应代码,这太棒了;这正是我在请求 Todo 时所期望发生的。ObjectID 是有效的,只是不在集合中。现在我们已经发出了这个请求,我们实际上可以将其保存在我们的 Todo App 集合中,这样以后就更容易触发这个请求。我将使用 Save As 保存它:
我们可以将请求描述留空,并将请求名称更改为GET /todos/:id
。我将把它添加到我们现有的集合中,Todo App 集合。现在我们有三个路由;这个路由的唯一剩下的事情就是添加一些测试用例,这将是下一节的主题。
为了结束这一切,让我们提交我们的更改并将它们推送到 GitHub。我将关闭服务器并运行git status
。
我们可以看到我们有我们修改过的文件;这意味着我可以运行带有-a
标志和-m
标志的git commit
,然后我将提供我的提交消息。现在,如果您使用-a
标志和-m
标志,您实际上可以使用一个快捷方式,即-am
标志,它执行完全相同的操作。它将把所有修改过的文件添加到提交中;它还将为我们提供一个添加消息的地方。这个的一个好消息将是Add GET /todos/:id
:
**git commit -am 'Add GET /todos/:id'**
有了这个,我将提交并将其推送到 GitHub,我们完成了。在下一节中,我们将继续为这个路由编写测试用例。这将大致是像这个一样具有挑战性的。
测试 GET /todos/:id
在这一部分,我们将为这个路由创建三个测试用例,用于获取单个 Todo 项。一个是确保当我们传入无效的 ObjectID 时,我们会得到404
响应代码。另一个是验证当我们传入有效的 ObjectID,但它不匹配文档时,我们会得到404
响应代码,最后我们将编写一个测试用例,确保当我们传入与文档匹配的 ObjectID 时,该文档实际上会在响应体中返回。
我们将一起编写有效调用的测试用例,然后您将自己编写两个测试用例。这将是本节的挑战。
编写 GET/todos/:id 的测试用例
在server.test.js
中,我们可以从最底部开始添加一个describe
块。我将调用 describe,这个describe
块将被命名为GET /todos/:id
,我们可以将箭头函数(=>
)添加为回调函数。在我们的describe
回调中,我们现在可以设置我们将一起创建的测试用例,it('should return todo doc')
。这将是一个确保当我们传入与文档匹配的有效 ID 时,文档会返回的测试。这将是一个异步测试,所以我们将指定done
参数:
describe('GET /todos/:id', () => {
it('should return todo doc', (done) => {
});
});
现在,为了运行这个测试用例,我们需要一个实际在集合中的 Todo 的 ID,如果你记得,我们确实向集合中添加了两个 Todos,但不幸的是我们没有这些 ID。这些 ID 是在幕后自动生成的;为了解决这个问题,我们要做的是添加 ID 属性,_id
。这意味着我们将能够在我们的测试用例中访问 ID,并且一切都将按预期工作。现在,为了做到这一点,我们必须从 MongoDB 中加载一个 ObjectID,这是我们以前做过的。我将使用 ES6 解构来创建一个常量。我将从要求mongodb
的返回结果中获取ObjectID
:
const {ObjectID} = require('mongodb');
现在,在todos
数组中,我们可以为我们的两个todos
添加一个_id
属性,new ObjectID()
,带有一个逗号-这是为第一个todo
-在下面,我们也可以为第二个todo
添加一个_id
,new ObjectID()
:
const todos = [{
_id: new ObjectID(),
text: 'First test todo'
},{
_id: new ObjectID(),
text: 'Second test todo'
}];
现在我们有了 _ids,我们可以通过从todos
数组中访问它们来访问这些 _ids,我们准备编写测试用例。
测试 1 - 超级测试请求
我们将开始创建我们的超级测试请求。我们将从app
express 应用程序中request
一些东西;这将是一个get
请求,也就是我们要测试的 URL,实际的 URL 将是/todos/id
,其中id
等于todos
中的一个这些 _ids。我将继续使用第一个todo
的_id
。在下面,我们可以通过将字符串更改为模板字符串来修复这个问题,这样我们就可以注入_id
,/todos/
然后我们将添加我们的语法来将一个值注入到模板字符串中。在这种情况下,我们从todos
数组中访问一些东西。我们想要获取第一个项目,这是第一个todo
,我们正在寻找它的_id
属性。现在,这是一个 ObjectID;我们需要将其转换为字符串,因为这是我们将作为 URL 传递的内容。要将 ObjectID 转换为字符串,我们可以使用toHexString
方法:
describe('GET /todos/:id', () => {
it('should return todo doc', (done) => {
request(app)
.get(`/todos/${todos[0]._id.toHexString()}`)
});
});
现在我们已经生成了正确的 ID,我们可以开始对这个请求触发时应该发生的事情进行一些断言。首先,HTTP 状态码。那应该是200
,所以我可以调用expect
,传入200
。下一步:我们确实希望验证返回的 body 与之前在todos
数组中的 body 匹配,特别是text
属性等于我们设置的text
属性。我将创建一个自定义的expect
调用来完成这个任务。我们将传入我们的函数,该函数将使用响应对象调用,现在我们可以使用expect
库进行断言。我将使用expect(res.body.todo)
,我们在res.send({todo})
中设置了它,当我们使用 ES6 对象语法时,那个todo
属性有一个text
属性,它等于我们第一个todo
的text
属性。那将是todos
,获取第一个,从零开始的 todo,我们将获取它的text
属性。有了这个,我们所有的断言都完成了;我们可以调用end
,传入done
,这将结束测试用例。
describe('GET /todos/:id', () => {
it('should return todo doc', (done) => {
request(app)
.get(`/todos/${todos[0]._id.toHexString()}`)
.expect((res) => {
expect(res.body.todo.text).toBe(todos[0].text);
})
.end(done);
});
});
现在我们可以继续在终端内运行这个测试,运行npm run test-watch
。这将启动我们的测试套件,我们应该有我们的新部分和通过的测试用例:
在这里,我们得到了should return todo doc
,这是通过的,太棒了。现在是你自己写两个测试用例的时候了。我会给你 it 调用,这样我们就在同一个页面上,但你要负责填写实际的测试函数,it('should return 404 if todo not found')
。这将是一个异步测试,所以我们将指定done
参数,你的工作是使用一个真实的 ObjectID 发出请求,并调用它的toHexString
方法。这将是一个有效的 ID,但它不会在集合中找到,所以我们应该得到一个 404。现在,你需要设置的唯一期望是状态码;确保你得到了404
。
测试 2-验证无效的 ID
你要编写的第二个测试将验证当我们有一个无效的 ID 时,我们会得到一个404
响应代码,it('should return 404 for non-object ids')
。这也将是一个异步测试,所以我们将指定done
。对于这个测试,你将传入一个 URL,类似于这样:/todos/123
。这确实是一个有效的 URL,但当我们尝试将123
转换为 ObjectID 时,它将失败,这应该触发return res.status(404).send()
代码,我们应该得到一个404
响应代码。再次,你需要为这个测试设置的唯一期望是当你向 URL 发出 get 请求时,状态码是404
。花点时间来完成这两个测试用例,确保当你实际设置了调用时,它们能够按预期工作。如果你完成后在终端中所有的测试用例都通过了,那么你就可以继续了。
对于第一个,我将继续通过创建一个变量来获取HexString
。现在,你不需要创建一个变量;你可以稍微不同地做。我将创建一个名为hexId
的变量,将其设置为new ObjectID
。现在在这个ObjectID
上,我们确实想要调用之前使用过的toHexString
方法。这将获取我们的 ObjectID 并给我们一个字符串,我们可以将该字符串指定为 URL 的一部分。现在,如果你在 get 调用内部执行了这个操作,就像我们在这里做的那样,那么这样做也是可以的;只要测试用例通过就可以。我们将调用request
,传入我们的 app。接下来,我们将发出一个get
请求,所以我会调用get
方法并设置我们的 URL。这个 URL 将是/todos/
,我们将在模板字符串中注入我们的hexId
值。我们需要设置的唯一期望是返回一个404
状态码。我们期望404
。我们可以通过调用end
并传入我们的done
函数来结束这个测试用例:
it('should return 404 if todo not found', (done) => {
var hexId = new ObjectID().toHexString();
request(app)
.get(`/todos/${hexId}`)
.expect(404)
.end(done);
});
it('should return 404 for non-object ids', (done) => {
// /todos/123
});
现在我们可以保存文件,这个测试用例应该重新运行。最后一个测试仍然会失败,但没关系,你可以看到这里,should return todo doc
通过了,should return 404 if todo not found
也通过了:
最后要编写的测试是当我们有一个无效的 ObjectID 时会发生什么。
测试 3-验证无效的 ObjectID
我将调用request
,传入app
,然后我将继续调用get
,设置 URL。我们不需要在这里使用模板字符串,因为我们只会传入一个普通字符串,/todos/123abc
。确实是一个无效的 ObjectID。正如我们所讨论的,ObjectIDs 具有非常特定的结构,而这个不符合这个标准。要了解更多关于 ObjectIDs 的信息,你可以随时回到本章开头的 ObjectID 部分。接下来,我们将开始设置我们的断言,通过调用expect
并期望返回404
,然后我们可以通过调用end
方法并传入done
来结束这个测试:
it('should return 404 for non-object ids', (done) => {
request(app)
.get('/todos/123abc')
.expect(404)
.end(done);
});
有了这个,我们对GET /todos/:id
的测试套件就完成了。在终端中它刚刚重新运行,所有的测试用例都通过了,这太棒了:
我们现在已经为路由设置了一个完整的测试套件,这意味着我们已经完成了,如果数据返回不正确,例如,如果 body 数据附加了一个额外的字符,比如字符1
,测试用例将失败。一切都运行得非常非常好。
剩下要做的就是提交我们的更改。在终端中,我将关闭nodemon
并运行git status
。这里我们唯一的更改是对server.test
文件的更改,这是一个修改过的文件—git 已经在跟踪它,这意味着我可以使用git commit
与-a
或-m
标志或组合的-am
标志,提供一个消息,Add test cases for GET /todos/:id
:
**git commit -am 'Add test cases for GET /todos/:id'**
我将提交并将其推送到 GitHub。在下一节中,我们将稍微改变一下。我们不会继续添加新的路由,而是稍后再做,我们将使用真实的 MongoDB 数据库将我们的应用程序部署到 Heroku。这意味着我们在 Postman 中进行的所有调用都可以在真实服务器上进行,任何人都可以进行这些调用,而不仅仅是我们本地机器上的人,因为 URL 将不再位于本地主机上。
将 API 部署到 Heroku
在本节中,您将把 Todo API 部署到 Heroku,这样任何人都可以访问这些路由,添加和获取 Todo 项目。现在,在我们将其推送到 Heroku 之前,有很多事情需要改变,需要进行一些小的调整,以便为 Heroku 服务器做好准备。其中一个较大的调整是设置一个真实的 MongoDB 数据库,因为目前我们使用的是本地主机数据库,一旦我们将应用程序部署到 Heroku 上,这将不再可用。
首先,我们将进入server
文件并设置app
变量以使用 Heroku 将设置的environment
端口变量,这是我们在上一节部署到 Heroku 时所做的。如果您还记得,我们创建了一个名为port
的变量,并将其设置为process.env.PORT
。这是一个可能设置或可能未设置的变量;如果应用程序在 Heroku 上运行,它将被设置,但如果在本地运行,它将不会被设置。我们可以使用我们的||
(或)语法来设置一个值,如果端口未定义。这将在本地主机上使用,并且我们将坚持使用端口3000
:
var app = express();
const port = process.env.PORT || 3000;
如果process.env.PORT
变量存在,我们将使用它;如果没有,我们将使用3000
。现在,我们需要在app.listen
中用port
替换3000
,这意味着我们调用app.listen
将传入port
,我们的字符串将被切换为模板字符串,这样我们就可以注入实际的端口。在app.listen
中,我将使用Started up at port
,然后我将把实际的端口变量注入到模板字符串中:
app.listen(port, () => {
console.log(`Started on port ${port}`);
});
好了,端口已经设置好了,现在我们可以进入package.json
文件。有两件事我们需要调整。首先,我们需要告诉 Heroku 如何启动项目。这是通过start
脚本完成的。start
脚本是 Heroku 要运行以启动应用程序的命令。在我们的情况下,它将是node
,然后我们将进入server
目录并运行server.js
文件。我在末尾加了一个逗号,start
脚本就准备好了:
"scripts": {
"start": "node server/server.js",
"test":"mocha server/**/*.test.js",
"test-watch":"nodemon --exec 'npm test'"
}
我们需要做的下一件事是告诉 Heroku 我们想要使用哪个版本的 Node。目前默认版本是 Node 的 v5 版本,这将会导致一些问题,因为我们在这个项目中利用了很多 ES6 功能,而这些功能在 Node 的 v6 中是可用的。为了确切地了解您正在使用的 Node 版本,您可以从终端运行node -v
:
这里我使用的是 9.3.0;如果您使用的是不同的 v6 版本,那是完全可以的。在package.json
内部,我们将告诉 Heroku 使用我们在这里使用的相同版本。这是通过设置一个engines
属性来完成的,engines
让我们指定 Heroku 让我们配置的各种版本。其中之一是node
。属性名将是node
,值将是要使用的 Node 版本,6.2.2
:
"engines": {
"node": "9.3.0"
},
现在我们的package.json
文件已经准备好用于 Heroku。Heroku 知道如何启动应用程序,它也知道我们想要使用哪个 Node 版本,所以当我们部署时,我们不会遇到任何奇怪的错误。
有了package.json
,我们需要做的最后一件事就是设置一个数据库,我们将使用 Heroku 的一个插件来完成这个任务。如果您转到 Heroku 的网站并点击任何一个您的应用程序,我们还没有为这个创建一个,所以点击上一节中的一个应用程序。我将继续点击我的一个应用程序。您将看到一个小仪表板,您可以在其中做很多事情:
如前面的截图所示,您可以看到有一个已安装的插件部分,但我们真正想要的是配置我们的插件。当您配置您的插件时,您可以添加各种内置到 Heroku 中的非常酷的工具。现在,并不是所有这些都是免费的,但其中大多数都有一个很好的免费计划:
您可以看到我们有各种与数据库相关的项目;在下面,我们有数据存储工具,我们有监控工具,还有很多非常酷的东西。我们将使用一个名为 mLab 的插件:
这是一个 MongoDB 数据库服务;它有一个很好的免费计划,它将让我们将 MongoDB 与我们的 Heroku 应用程序集成起来。现在,您实际上不需要从网站上做任何事情,因为我们将从终端上做所有的事情。我只是想让您确切地知道这个位于哪里。在下面,您可以看到他们有一个免费的 Sandbox 计划,他们还有一些计划,最高达每月 5000 美元。我们将坚持零美元计划。
创建 Heroku 应用程序
为了进行设置,在终端内部,我们将创建一个新的 Heroku 应用程序,因为目前我们还没有一个。heroku create
是完成这个任务的命令:
应用程序创建完成后,我们需要告诉应用程序我们想要使用mLab
,这是 Mongo Lab 的缩写。为了添加这个插件,我们将运行以下命令:
**heroku addons:create**
现在,这个插件是mongolab:
,在:
之后,我们将指定我们想要使用的计划。我们将使用免费的 Sandbox 计划:
**heroku addons:create mongolab:sandbox**
当我们运行这个命令时,它将配置mLab
与我们的 Heroku 应用程序,我们就可以开始了。现在,如果您运行heroku config
命令,您实际上可以获得您的 Heroku 应用程序的所有配置变量的列表:
现在,我们只有一个配置变量;它是一个 MONGODB_URI。这是mLab
给我们的数据库 URL。这是我们需要连接的,也是我们应用程序唯一可用的。现在,这个 MONGODB_URI 变量,实际上是在process.env
上,当应用程序在 Heroku 上运行时,这意味着我们可以使用类似的技术来处理我们在mongoose.js
文件中所做的事情。在mongoose.js
中,在我们的connect
调用中,我们可以检查process.env.MONGODB_URI
是否存在。如果存在,我们将使用它;如果不存在,在我们的||
语句之后,我们将使用本地主机 URL:
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/TodoApp');
这将确保我们的 Heroku 应用程序连接到实际的数据库,因为连接到本地主机将失败,导致应用程序崩溃。有了这个设置,我们现在准备好开始了。
在终端内部,我将运行git status
来检查我们的更改文件:
我们有三个;一切看起来都很好。我可以运行git commit
,带上-am
标志。这将让我们指定我们的提交消息,为 heroku 设置应用程序
:
**git commit -am 'Setup app for heroku'**
我将提交并将其推送到 GitHub。现在,我们需要将我们的应用程序推送到 Heroku。我将使用以下命令来做到这一点:
**git push heroku master**
记住,当你创建一个 Heroku 应用程序时,它会自动添加 Heroku 远程,并且我们将其发布到主分支。主分支是唯一一个 Heroku 实际上会处理的分支。应用程序正在被推送上去;它应该在几秒钟内准备好。一旦完成,我们可以在浏览器中打开 URL,看看我们得到了什么。
Heroku 日志
我想简要谈一下另一个命令,叫做heroku logs
。heroku logs
命令会显示应用程序的服务器日志。如果出现任何问题,通常会在终端内收到错误消息:
现在,正如你所看到的,我们在底部打印了端口 4765 上启动的消息,这很好;你的端口会有所不同。只要你有这个消息,一切都应该正常。我将运行heroku open
。
这将在我的浏览器中打开应用程序。我将选择复制 URL。然后我会进入 Chrome,并访问它:
现在,访问应用程序的根应该什么也不会发生,因为我们还没有设置根 URL,但如果我们转到/todos
,我们应该会得到我们的todos JSON
返回:
在这里,你可以看到我们有一个空数组,这是预期的,因为我们还没有添加任何 Todo 项目,所以让我们继续做。
我想做的是获取 URL 并转到 Postman。在 Postman 中,我们将进行一些调用。我将创建一个POST /todos
请求;我只需要取出 URL 并将其替换为我刚刚复制的 URL,然后我可以发送该请求,因为请求体数据已经配置好了。我将发送请求。我们得到了我们的 Todo 项目,这不是来自我们的本地机器,而是来自我们的 Heroku 应用,它正在与我们的 Mongo Lab MongoDB 数据库交互:
现在,所有其他命令也应该有效。我将转到GET /todos
,粘贴 URL,然后我们应该能够获取所有的 Todo 项目:
我还要检查当我们尝试获取单个 Todo 时会发生什么。我会复制_id
,将其添加到 URL 上,并发送该请求:
我得到了单个 Todo 项目。所以,无论我们使用哪个调用,一切都按预期工作,这太棒了。我们的应用程序现在在 Heroku 上运行,使用真实的生产数据库,就是这样。现在我们对 Heroku 有了一定了解,在下一节中,我将向你展示一些我们可以在 Postman 中使用的调整和技巧,以便更轻松地在我们的本地环境和 Heroku 环境之间切换。
Postman 环境
在我们回到创建 express 路由之前,我们将花一点时间来探索 Postman 的一个功能,这将使在本地环境和 Heroku 应用之间切换变得更容易。这就是所谓的 Postman 环境。
管理 Postman 环境
现在,为了说明这一点,我将通过运行node server/server.js
命令启动我的本地服务器,在 Postman 中我们将开始发出一些请求。现在,如果你记得,在上一节中,我们向我们的 Heroku 应用程序发出了请求。我点击GET /todos
URL 上的发送,我得到了预期的todos
数组。问题是,实际保存在集合选项卡中的项目,它们都使用了本地主机 URL,没有很好的方法在两者之间切换。为了解决这个问题,我们将创建环境,一个用于我们的本地机器,一个用于 Heroku。这将让我们创建一个变量作为 URL,并且我们可以通过在无环境下拉菜单中切换来更改该变量。为了准确说明这将如何工作,我现在将复制 Heroku URL,然后我将转到无环境下拉菜单,并点击管理环境:
在这里,我们目前没有,但我们可以继续添加两个。
Todo App 本地环境
对于第一个环境,我将称之为Todo App Local
。这将是本地 Todo 应用程序,我们可以设置一组键值对。现在,我们要设置的唯一键是 url。我们将为 Todo App Local 环境设置本地主机 URL,并为 Todo App Heroku 环境设置 Heroku URL,我们将在接下来创建。我们将输入url
为localhost:3000
:
我们不包括路径,因为这将取决于个别路线。我将继续添加该环境。
Todo App Heroku 环境
我们可以创建第二个;这个将被称为Todo App Heroku
,我们将再次设置url
键。不过这一次,我们将其设置为我复制到剪贴板的值,即 Heroku 应用程序 URL:
我将添加,现在我们有了两个环境,我可以关闭那个窗口管理器。
我将关闭所有标签,不保存任何更改,然后我将转到GET /todos
。现在,当前,GET /todos
自动从localhost
获取。我们要做的是用以下语法替换 URL,斜杠之前的所有内容,这将看起来类似于任何模板引擎:两个大括号,后面跟着变量名url
,然后是两个闭合括号,{{url}}
。这将注入 URL,这意味着GET /todos
请求现在是动态的。我们可以根据环境更改它从哪个端点请求,localhost 或 Heroku。我将保存此请求并发送它,你会注意到当你尝试发送此请求时,我们会收到一个错误:
它试图向以大括号开头的 URL 发出请求;这是编码字符,url
,闭合大括号和 todos。这是因为url
变量目前未定义。我们需要切换到一个环境。在环境列表中,我们现在有 Todo App Heroku 和 Todo App Local。如果我点击 Todo App Local 并发送该请求,我会在本地数据库中得到两个项目:
如果我切换到 Todo App Heroku,这将向 Heroku 应用程序发出请求。它将更新 URL,当我们发出请求时,我们会得到不同的数据:
这一次,我们只有一个 Todo 项目,即 Heroku 应用程序上可用的项目。有了这个,GET /todos
现在可以轻松地用来获取本地主机或 Heroku 项目,我们也可以用我们的POST /todos
请求做同样的事情。我将用花括号替换 URL,并在这些花括号中放入url
变量。现在我可以保存这个请求,发送它,它将在 Heroku 应用程序上创建一个新的 Todo:
如果我切换到 Todo App Local,我们可以发送它,现在我们在本地环境中有一个新的 Todo:
最后要更改的请求是GET /todos/:id
请求。我们将再次使用localhost:3000
,然后我们将用url
替换它,就像这样,{{url}}
,现在我们完成了。我们可以保存这个请求,然后发送它。现在,这个有第二个变量:
这是实际的 Todo ID;您也可以将其添加为变量。不过,由于随着我们添加和删除 Todos,它将发生变化,所以我将简单地从本地数据库中获取一个,移动到GET /todos
请求中,替换它,然后发送它,我们就可以得到我们的 todo:
如果我将它设置为一个不存在的 Todo ObjectID,通过将其中一个数字改为6
,我会得到一个404
状态码。一切仍然按预期工作,它也将在 Heroku 环境中工作。我将从 Heroku 环境中获取所有的 todos,获取一个_id
,移动到GET /todos/:id
请求,替换 ID,发送它,我们就可以得到 todo 项目。
希望您开始看到为什么这些 Postman 环境是如此方便。您可以轻松地在两个环境之间切换,精确地改变请求的发生情况。现在,在这种情况下,我们碰巧只有一个变量url
;您可以添加其他变量,稍后我们会添加。不过,现在就是这样,我们有一种在 Postman 中在两个环境之间切换的方法。既然我们已经做到了这一点,我们将回到 Atom 编辑器,开始添加新的路由。还有两个要做。在下一节中,您将学习如何通过 ID 删除 Todos。
删除资源 - DELETE /todos/:id
在这一部分,我们将探讨如何使用 Mongoose 从我们的 MongoDB 集合中删除文档。然后您将负责填写delete
路由,该路由将允许某人通过 ID 删除一个 Todo。
要开始,我们将复制mongoose-queries
文件,将新文件命名为mongoose-remove
。在文件中,我们可以删除初始导入以下的所有内容。我将突出显示文件中的所有内容,包括未注释的代码,然后删除它,我们最终得到一个看起来像这样的文件:
const {ObjectID} = require('mongodb');
const {mongoose} = require('./../server/db/mongoose');
const {Todo} = require('./../server/models/todo');
const {User} = require('./../server/models/user');
Mongoose 为我们提供了三种删除记录的方法;第一种方法允许您删除多个记录。
Todo.remove 方法
这个是Todo.remove
,Todo.remove
的工作方式类似于Todo.find
。您传入一个查询,该查询匹配多个记录,然后删除所有匹配的记录。如果没有匹配,就不会删除任何记录。现在,Todo.find
和Todo.remove
之间的区别,除了删除文档之外,还有一个区别,就是您不能传入一个空参数,然后期望所有文档都被删除。如果您想要从集合中删除所有内容,您需要像这样运行它Todo.remove({})
。如果我们运行这个,我们将删除所有内容。我将添加then
。我们将得到我们的结果,我们可以使用console.log(result)
将结果打印到屏幕上,就像这样:
Todo.remove({}).then((result) => {
console.log(result);
});
现在我们可以运行mongoose-remove
文件,它将从我们的数据库中删除所有的 Todos:
**node playground/mongoose-remove.js**
现在当我们运行remove
方法时,我们再次得到一个result
对象;其中很多内容对我们来说并不有用,但在顶部有一个result
属性。我们可以看到删除确实起作用了,我们得到了1
而不是0
,并且我们知道删除了多少条记录。在这种情况下,记录的数量恰好是3
。
Todo.findOneAndRemove 方法
还有两种其他删除文档的方法,这些方法对我们在本节中将会更有用。第一种将是Todo.findOneAndRemove
。现在,findOneAndRemove
的工作方式类似于findOne
:它将匹配第一个文档,只是它将删除它。这也将返回文档,因此您可以对已删除的数据进行操作。数据将从数据库中删除,但您将获得对象,因此可以将其打印到屏幕上或将其发送回给用户。这与remove
方法不同。在remove
方法中,我们不会得到已删除的文档,我们只会得到一个数字,表示删除了多少个。使用findOneAndRemove
我们会得到这些信息。
Todo.findByIdAndRemove 方法
另一种方法是Todo.findByIdAndRemove
。findByIdAndRemove
方法的工作方式与findById
类似:您将 ID 作为参数传递,然后将其删除。现在,这两种方法都将返回文档,这正是我们想要的。没有必要同时运行它们,我们只需要运行一个。Todo.findByIdAndRemove
方法,这将让我们删除一个Todo ById
,一些 ID 像asdf
,我们将能够附加一个then
方法提供我们的回调,回调将获得文档。您可以称其为文档,或者在这种情况下,我们可以称其为todo
,因为它是一个 Todo 项目:
Todo.findByIdAndRemove('asdf').then((todo) => {
});
现在我们已经有了这个,我们只需要创建一个 Todo,因为我们删除了所有的 Todo,并包括 ID。在 Robomongo 中,我可以右键单击todos
集合并插入一个文档。我们将设置一个text
属性,我将把text
属性设置为Something to do
,然后我们可以保存该记录。我将确保当我点击查看文档时,我们会得到我们的一个文档。
现在显然它缺少一些属性,因为我是在 Robomongo 中创建的,但对我们的目的来说这没关系。我现在要编辑该文档并获取 ID,这是我们可以添加到我们的 playground 文件中以确保文档被删除。在 Atom 中,findByIdAndRemove
方法中,我们将传入我们的字符串。这是字符串 ID,在我们的then
回调中,我们将使用console.log
将 todo 打印到控制台。我将注释掉之前的删除调用,否则它会删除我们要删除的文档:
//Todo.remove({}).then((result) => {
// console.log(result);
//});
Todo.findByIdAndRemove('5aa8b74c3ceb31adb8043dbb').then((todo) => {
console.log(todo);
});
有了这个,我现在可以保存文件,进入终端,并重新运行脚本。我将关闭它然后再次启动:
我们得到了我们的文档,这太棒了,如果我进入 Robomongo 并尝试获取 todos 中的文档,我们将收到一个错误,即没有文档;我们曾经有一个,但我们已经删除了它。现在,在 Atom 中,我们还可以玩findOneAndRemove
。findOneAndRemove
方法与findByIdAndRemove
完全相同,只是它接受查询对象。这将是Todo.findOneAndRemove
;我们将传入查询对象,然后附加我们的then
回调,该回调将使用文档调用:
Todo.findOneAndRemove({_id: '57c4670dbb35fcbf6fda1154'}).then((todo) => {
});
这两者工作方式非常相似,但最大的区别是是否需要查询除了 ID 之外的更多内容。现在你知道如何使用findByIdAndRemove
,我们将进入server
文件并开始填写实际的路由。这将是让我们删除 Todo 的路由。我会为你设置路由,但你需要负责填写回调函数内的所有内容。
创建一个删除路由
创建一个删除路由,我们将使用app.delete
。然后我们将提供 URL,它看起来与我们用于通过 Id 获取单个 Todo 的 URL 相同,/todos/:id
。这将是我们可以在回调函数内访问的 ID。回调函数将获得相同的请求和响应参数,并且我会在内部留下一些注释来指导你朝正确的方向前进,但你需要负责填写每一件事情。首先,获取 id。你将像我们在上面做的那样获取 ID,并且我们这样做是因为接下来你要做的事情是验证 id。如果它无效,返回404
。如果它无效,你将像我们在上面做的那样发送 404。接下来,你将通过 id 删除 todo,这将需要你使用我们刚刚在mongoose-remove
文件中讨论过的函数。你将通过 ID 删除它,有两种可能。我们可能会成功,也可能会出现错误。如果出现错误,你可以以通常的方式回应,发送一个带有空主体的400
状态码。现在,如果成功了,我们需要确保通过检查返回的 doc 来确保 Todo 实际上已被删除;如果没有 doc,则发送404
,以便对方知道找不到 ID 并且无法删除,如果有doc
,则发送带有200
的doc
。现在,我们需要检查 doc 是否存在的原因是因为即使没有删除任何 Todo,findByIdAndRemove
函数仍然会调用其成功情况。
我可以通过删除具有该 ID 的项目后重新运行文件来证明这一点。我将注释掉findOneAndRemove
,进入终端,然后重新运行脚本:
我们得到的 Todo 的值为 null。这意味着如果实际上没有删除任何项目,你希望设置一个if
语句来执行特定的操作。有了这个设置,你就准备好了。你知道如何做所有这些,大部分是在上面的路由中完成的,而删除项目的所有特定内容都是在playground
文件中完成的。
我们需要做的第一件事是从请求对象中获取 ID。我将创建一个名为id
的变量,将其设置为req.params
;这是我们存储所有 URL 参数的地方,然后我们按值获取它。我们已经设置了 id,所以我们将获取id
属性。我将删除注释,然后在下面我们可以验证 ID,if(ObjectID.isValid)
。现在,我们正在检查这个 ID 是否有效,如果有效,我们实际上不想做任何事情,我们只关心它是否无效。所以,我将翻转布尔值,并且在if
条件内,我们现在可以运行一些代码,当 ID 无效时。这段代码将发送回一个404
状态码。我将使用return
来防止函数的其余部分被执行,然后我们将继续响应,设置状态,res.status
等于404
,然后调用send
来启动没有主体数据的响应。现在 ObjectID 有效了,我们可以继续下面实际删除它。
我们将通过调用Todo.findByIdAndRemove
来开始。现在,findByIdAndRemove
只需要一个参数,即要删除的实际id
,我们可以调用then
,传入我们的成功回调,正如我们所知,将使用单个todo
文档调用。现在,在成功的情况下,我们仍然必须确保待办事项实际上已被删除。如果没有待办事项,我们将发送一个 404;如果没有待办事项,我们将使用return
并使用res.status
设置状态为404
,并调用send
来启动响应。现在,如果这个 if 语句不运行,这意味着待办事项实际上已被删除。在这种情况下,我们希望用200
回应,让用户知道一切都进行得很顺利,我们将把todo
参数返回,res.send
,传入todo
。这个待办事项挑战的唯一剩下的事情就是调用catch
。我们将调用 catch,以便处理任何潜在的错误。我们要做的就是使用res.status
进行响应,将其设置为400
,然后调用send
,不带参数发送一个空响应:
app.delete('/todos/:id', (req, res) => {
var id = req.params.id;
if(!ObjectID.isValid(id)) {
return res.status(404).send();
}
Todo.findByIdAndRemove(id).then((todo) => {
if(!todo) {
return res.status(404).send();
}
res.send(todo);
}).catch((e) => {
res.status(400).send();
});
});
有了这个,我们现在可以开始了。我们已经按照我们想要的方式设置了一切,这意味着我们可以从下面删除注释,你会注意到我们下面的方法看起来与上面的方法非常相似,对于我们管理单个待办事项的许多路由来说,情况都是如此。我们总是想要获取那个 ID,我们总是想要验证 ObjectID 确实是一个真正的 ObjectID,在我们的成功和错误情况中,也会发生类似的事情。我们要确保文档实际上已被删除。如果没有,我们将发送404
,有了这个,我们现在可以验证这个路由是否有效。
现在我们可以保存文件并在终端中启动服务器。我将使用clear
命令清除终端输出,然后我们可以运行以下命令:
**node server/server.js**
一旦服务器启动,我们就可以进入 Postman 并开始发送一些请求。首先,我要创建一些待办事项。我将发送POST /todos
,然后我会更改text
属性并再次发送。我将把正文文本更改为Some other todo item
,发送后,现在我们应该有两个待办事项。如果我去GET /todos
并获取它们,我们会得到我们的两个todos
:
现在,我需要其中一个 ID;这将是我们要删除的待办事项,所以我要做的是将其复制到剪贴板,然后我们可以继续创建我们的新路由。这个新路由将使用delete
方法,所以我们将从 GET 切换到 DELETE,然后我们可以提供 URL,使用我们在上一节中创建的环境变量 URL。路由是/todos/id
。我将把 ID 粘贴进去:
现在我可以继续运行请求。当我们运行它时,我们得到了一个状态码 200 OK;一切都进行得很顺利,我们有了我们删除的文档:
如果我回到GET /todos
并重新运行它,现在我们只有一个文档;我们传递给删除的项目确实已被删除。我将保存这个请求到我们的集合中,这样我们就可以不必手动输入所有这些信息就可以发送它。让我们保存为DELETE
,后面跟着路由/todos/:id
:
我们将保存到一个现有的集合,Todo App 集合。现在我们有一个DELETE /todos/:id
路由坐在集合中,我们随时可以访问它。现在,从这里,我们将继续发送请求,这将尝试删除一个 ID 有效但与集合中的 ID 不匹配的 Todo,我们得到404
。现在,如果我通过删除一堆字符使此 ID 无效,并发送它,我们也会得到404
状态码,因为 ID 无效,这太棒了。
有了这个,我们现在可以进行提交。在终端内,我将关闭服务器,运行git status
,您将看到我们有两个文件。
我们有一个新文件,Mongoose playground 文件,以及我们修改过的server
文件。我将使用git add .
将所有这些添加到下一个提交中,并使用git commit
与-m
标志进行提交,Add DELETE/todos/:id route
:
**git commit -m 'Add DELETE /todos/:id route'**
我将提交并将其推送到 GitHub。我们还可以使用以下命令部署我们的应用程序:
**git push heroku master**
现在我们将能够在 Heroku 应用程序中删除我们的 Todos。有了这个,我们现在完成了。在下一节中,我们将为我们刚刚设置的路由编写一些测试用例。
测试 DELETE /todos/:id
在本节中,您将编写一些测试用例,以验证我们的delete
路由是否按预期工作。现在,在我们开始之前,我们要做的是对删除路由进行一些快速更改,以使其与我们的其他路由匹配。我们的其他路由返回一个对象,在该对象上,响应主体上有一个todo
属性,我们对todos
调用也是如此。在响应主体上,我们有todos
属性,它存储数组。对于删除请求,我们从未这样做过。
我要做的是将一个对象作为响应主体发送回来,其中todo
属性等于已删除的todo
,尽管我们将使用 ES6 语法将其发送回来:
Todo.findByIdAndRemove(id).then((todo) => {
if(!todo) {
return res.status(404).send();
}
res.send({todo});
}).catch((e) => {
res.status(400).send();
});
有了这个,我们现在可以继续编写一些测试用例,以验证delete
路由是否按预期工作,这将发生在我们的server.test
文件的最底部。我将为DELETE /todos/:id
路由创建一个新的describe
块。我们将提供箭头函数,并可以继续调用它三次。
测试用例 1 - 应删除一个 todo
第一个测试用例,it('应删除一个 todo')
,这将是第一个测试用例;它将验证当我们传入一个在 Todos 集合中存在的 ID 时,该项目将被删除:
describe('DELETE /todos/:id', () => {
it('should remove a todo', (done) => {
});
});
测试用例 2 - 如果未找到 todo,则应返回 404
接下来,it('如果未找到 todo,则应返回 404')
。如果我们尝试删除 Todo,但实际上没有删除任何东西,我们将发送404
状态码,以便用户知道调用可能不像预期那样工作。是的,调用并没有真正失败,但您从未删除您想要删除的项目,因此我们将认为这是一个失败,这就是我们在发送404
状态码时所做的:
describe('DELETE /todos/:id', () => {
it('should remove a todo', (done) => {
});
it('should return 404 if todo not found', (done) => {
});
});
测试用例 3 - 如果对象 id 无效,则应返回 404
我们要写的最后一个测试是it('如果对象 id 无效,则应返回 404')
。这个测试将验证当我们有一个无效的 ObjectID 时,我们确实会得到一个404
状态码,这是预期的响应状态码:
describe('DELETE /todos/:id', () => {
it('should remove a todo', (done) => {
});
it('should return 404 if todo not found', (done) => {
});
it('should return 404 if object id is invalid', (done) => {
});
});
现在,这两个测试我们稍后会填写一些内容;我们将继续专注于第一个,因为这是我们需要做一些复杂事情的地方。我们不仅需要发送请求,而且在请求返回后,我们还希望断言一些关于它的事情,并且我们还希望查询数据库,确保待办事项实际上已从Todos
集合中删除。我要做的第一件事是弄清楚我想要删除哪个待办事项。我们在上面有两个选项。我将继续删除第二个待办事项,尽管这个选择是无关紧要的;你也可以轻松地用第一个来做这个。在下面,我们将创建一个hexId
变量,就像我们为前一个测试用例所做的那样。我们将把它设置为todos
数组中的第二个项目,然后我们将继续并获取它的_id
属性,调用toHexString
方法:
var hexId = todos[1]._id.toHexString();
现在我们已经有了第二个待办事项的hexId
,我们可以开始担心如何发出请求。我将调用request
,传入我们要发出请求的app
,然后我们可以调用delete
,这将触发一个删除 HTTP 请求。以下 URL 将注入一些变量,所以我将使用模板字符串:它是/todos/
后跟 ID。我将注入hexId
变量。现在我们已经设置好了我们的delete
方法,我们可以继续并开始制定我们的期望。我们期望得到一个200
状态码;我们应该得到一个200
状态码,因为hexId
将存在于数据库中。接下来,我们可以断言数据作为响应体返回。我将进行自定义的expect
调用,传入我们的函数,在这里我们有响应参数发送进来,我们要做的就是断言 ID 就是hexId
变量中的 ID。我们期望res.body
属性有一个todo
属性,其中_id
属性等于hexId
,toBe(hexId)
。如果是这种情况,那么我们可以验证调用基本上按预期工作了:
request(app)
.delete(`/todos/${hexId}`)
.expect(200)
.expect((res) => {
expect(res.body.todo._id).toBe(hexId);
})
我们需要做的最后一件事是查询数据库,确保该项目实际上已被删除。我将调用end
,传入一个回调,这样我们可以在结束测试用例之前做一些异步的事情,如果你记得的话,它会被调用并传入一个错误和响应。如果有错误,我们需要处理它,否则就没有必要查询数据库,if (err)
。我们将return
以防止函数执行,done
,传入该错误,以便 Mocha 渲染错误。现在我们可以继续并进行查询,这实际上将是本节的挑战。
我希望你使用findById
查询数据库。你将尝试查找具有存储在hexId
变量中的 ID 的 Todo 项目。当你尝试查找该 ID 时,它应该失败,并且你应该得到空值。你将在then
调用中创建Todo
变量,并确保它不存在。你可以使用toNotExist
断言来确保某些内容不存在。这将看起来像这样,我们expect(null).toNotExist()
。尽管,你将传入Todo
参数,它将在你的成功处理程序中。现在,这通常会包含 Todo 项目,但由于我们刚刚删除它,它不应该存在;这将完成所有这些。现在,如果有错误,你将执行与我们为POST /todos
测试用例中所做的完全相同的操作。我们只需添加一个catch
子句,将错误传递给done
。现在你知道该怎么做了,你的工作就是完成它。我希望你填写这个,填写查询,确保处理错误,确保调用done
,然后你可以继续运行测试套件,验证这个测试用例是否通过。最后两个测试用例将失败,所以目前我只是将它们注释掉;它们将失败,因为我们指定了一个done
参数,但我们从未调用它,所以测试将在两秒后超时。
首先要做的是调用Todo.findById
,传入hexId
。这是应该已经被删除的项目。现在我们可以调用then
,传入我们的回调,它将使用文档、todo
变量调用,我们要做的就是验证它不存在。我们刚刚删除了它,所以findById
应该返回文档的空值。我们将使用toNotExist
方法来expect
todo
变量不存在,该方法可用于expect
库。现在,我们需要调用done
来完成测试用例。从这里开始,我们可以继续调用catch
。我将调用catch
,获取错误参数并将其传递给done
。这里不需要提供花括号;我们只有一个语句,所以我们可以使用 ES6 中可用的错误函数的快捷方式。有了我们实际的查询,我们可以删除概述应该发生的内容的注释,并运行测试用例:
.end((err, res) => {
if(err){
return done(err);
}
Todo.findById(hexId).then((todo) => {
expect(todo).toBeFalsy();
done();
}).catch((e) => done(e));
});
在终端内,我们现在可以运行测试套件,以验证我们设置的一切是否按预期工作。在终端内,我将运行以下命令启动我们的测试套件与 Nodemon:
**npm run test-watch**
当它运行时,我们看到我们在DELETE
描述块下有一个测试,并且它通过了;它应该在没有任何错误的情况下删除一个传递的 todo:
现在我们已经有了一个测试用例,我们可以填写另外两个。这些测试用例基本上与我们为GET /todos/:id
路线编写的测试用例相同。当你:
-
确切地知道代码的作用;我们知道它的作用,因为我们编写了它
-
实际上确实需要它-我们无法重用它,我们需要稍微调整它,因此复制它是有道理的。
测试用例 4 - 如果未找到 todo,则应返回 404
我将复制应返回 404
测试用例,用于如果未找到 todo,则应返回 404
测试,然后我们将粘贴到delete
路线的完全相同的测试中,我们只需要将.get
更改为.delete
,然后保存文件。这将重新运行测试套件,现在我们在删除下有两个测试;它们都通过了:
您可以看到我们上一个测试仍然失败,所以我们可以继续做同样的事情。我将从should return 404 for non-object ids
中复制代码,该代码验证非 ObjectID 会导致404
状态码。我将把它粘贴到最后一个测试用例中,将.get
方法调用更改为.delete
。如果我保存文件,它将重新运行测试套件,这一次所有 9 个测试用例都通过了:
有了这个,我们现在已经测试了DELETE /todos
。让我们通过在终端内进行提交来结束这一切。
我要运行git status
来查看我所做的更改。我们对server
文件进行了一些小改动,并将我们的测试添加到server.test
文件中。我可以使用git commit
和-am
标志进行提交,对于这个提交,一个好的消息将是测试 DELETE /todos/:id 路由
:
**git commit -am 'Test the DELETE /todos/:id route'**
我将提交并推送到 GitHub,因为我们还没有创建任何视觉上的不同,所以没有必要部署到 Heroku。我们只是稍微调整了server
代码,但这是稍后的事情。现在,一切都很好;我们可以继续下一节,您将在其中创建管理 Todos 的最终路由。这将是一个允许您更新 Todo 的路由。
更新资源 - PATCH /todos/:id
delete
路由现在已经设置并测试完成,所以现在是时候开始管理我们的 Todo 资源的最终路由了。这将是一个路由,让您更新一个 Todo 项目,无论您是想将文本更改为其他内容,还是想将其标记为已完成。现在,这将是我们编写的最复杂的路由;到目前为止,一切都相对简单。我们需要做一些额外的工作才能使这个更新路由按预期工作。
在我们继续创建下面的路由之前,我想要做的第一件事就是安装我们在本课程的前几节中使用过的 Lodash 库。
安装 Lodash 库
如果您记得,Lodash 提供了一些非常好的实用函数,我们将利用其中一些函数来完成我们的更新路由。在终端中,我将使用npm i
和--save
标志来安装它;模块名称本身叫做lodash
,我们将使用最新版本@4.15.0
:
**npm i --save lodash@4.17.5**
现在,一旦这个安装完成,我们可以在顶部require
它,然后我们可以继续添加我们的路由。在server.js
文件的顶部,我们可以创建一个常量;我们将使用下划线作为存储 Lodash 库的变量的名称,然后我们将继续require
它,require('lodash')
。现在,我已经使用常量而不是常规变量来进行其他导入,所以我也可以将这些变量切换为常量:
const _ = require('lodash');
const express = require('express');
const bodyParser = require('body-parser');
const {ObjectID} = require('mongodb');
现在我们已经准备就绪,可以转到文件底部并开始添加新的路由。这个路由将使用 HTTP 的patch
方法;patch
是在想要更新资源时使用的方法。现在记住,这一切都不是铁板钉钉的。我可以有一个delete
路由来创建新的 Todos,我也可以有一个post
路由来删除 todos,但这只是 API 开发的一般准则和最佳实践。我们将通过调用app.patch
来设置一个patch
方法路由。这将允许我们更新 Todo 项目。现在,URL 将与我们管理单个 Todo 项目时的 URL 完全相同,/todos/:id
。然后我们可以设置我们的回调函数,带有我们的请求和响应参数。在回调函数中,我们首先需要做的事情之一是像我们为所有其他路由做的那样获取那个 id。我将创建一个名为id
的变量,并将其设置为req.params.id
。现在,在下一行,我们将创建一个名为body
的变量,这就是我加载 Lodash 的原因。请求体,更新将存储在这里。如果我想将 Todos 的文本设置为其他内容,我将发出一个patch
请求。我将把text
属性设置为我想要的 Todo 文本。问题在于,有人可以发送任何属性;他们可以发送不在 Todo 项目上的属性,或者他们可以发送我们不希望他们更新的属性,例如completedAt
。completedAt
属性将被更新,但不会被用户更新,当用户更新完成的属性时,它将由我们更新。completedAt
将由程序生成,这意味着我们不希望用户能够更新它。
为了只获取我们希望用户更新的属性,我们将使用pick
方法,_.pick
。pick
方法非常棒;它接受一个对象,我们将传入req.body
,然后它接受一个你想要提取的属性数组,如果它们存在的话。例如,如果text
属性存在,我们希望从req.body
中提取出来,添加到 body 中。这是用户应该能够更新的内容,我们将对 completed 做同样的处理。这是用户唯一能够更新的两个属性;我们不需要用户更新 ID 或添加任何在 Mongoose 模型中未指定的其他属性。
app.patch('/todos/:id',(req, res) => {
var id = req.params.id;
var body = _.pick(req.body, ['text', 'completed']);
});
现在我们已经准备就绪,可以开始按照通常的路径进行,首先通过验证我们的 ID 来启动。没有必要重写代码,因为我们以前已经写过了,我们知道它的作用;我们可以简单地从app.delete
块中复制并粘贴到app.patch
中。
if(!ObjectID.isValid(id)){
return res.status(404).send();
}
现在我们可以继续进行patch
的稍微复杂的部分,这将检查completed
值并使用该值来设置completedAt
。如果用户将 Todos 的completed
属性设置为true
,我们希望将completedAt
设置为时间戳。如果他们将其设置为false
,我们希望清除该时间戳,因为 Todo 将不会被完成。我们将添加一个if
语句来检查completed
属性是否为布尔值,并且它在body
中。我们将使用_.isBoolean
实用方法来完成这个任务。我们要检查body.completed
是否为布尔值;如果它是布尔值并且该布尔值为 true,body.completed
,那么我们将继续运行一些代码。如果它是布尔值并且为 true,那么这段代码将运行,否则如果它不是布尔值或者不是 true,我们将运行一些代码。
如果它是一个布尔值并且是true
,我们将设置body.completedAt
。我们在 body 上设置的一切最终都将在模型中更新。现在,我们不希望用户更新所有内容,所以我们从req.body
中挑选了一些内容,但我们可以进行一些修改。我们将body.completedAt
设置为当前时间戳。我们将创建一个新的日期,这是我们以前做过的,但是不再调用toString
,这是我们在前一节中使用的方法,而是使用一个叫做getTime
的方法。getTime
方法返回一个 JavaScript 时间戳;这是自 1970 年 1 月 1 日午夜以来的毫秒数。它只是一个普通的数字。大于零的值是从那一刻开始的毫秒数,小于零的值是过去的,所以如果我有一个-1000 的数字,那就是在 Unix 纪元之前 1000 毫秒,这是那个日期的名称,1970 年 1 月 1 日午夜:
if(_.isBoolean(body.completed) && body.completed) {
body.completedAt = new Date().getTime();
} else {
}
既然我们已经有了这个,我们可以继续填写else
子句。在else
子句中,如果它不是布尔值或者不是true
,我们将继续设置body.completed = false
,我们还将清除completedAt
。body.completedAt
将被设置为null
。当你想要从数据库中删除一个值时,你可以简单地将它设置为 null:
if(_.isBoolean(body.completed) && body.completed) {
body.completedAt = new Date().getTime();
} else {
body.completed = false;
body.completedAt = null;
}
现在我们将按照通常的模式进行:我们将查询以实际更新数据库。我们将要进行的查询与我们在mongodb-update
文件中进行的查询非常相似。在mongodb-update
中,我们使用了一个叫做findOneAndUpdate
的方法。它接受一个查询、更新对象和一组选项。我们将使用一个叫做findByIdAndUpdate
的方法,它接受一个非常相似的参数集。在server
中,我们将调用Todo.findByIdAndUpdate
。findByIdAndUpdate
的第一个参数将是id
本身;因为我们使用了findById
方法,我们可以简单地传入id
,而不是传入一个查询。现在我们可以设置我们对象的值,这是第二个参数。记住,你不能只设置键值对——你必须使用那些 MongoDB 操作符,比如增量或者在我们的情况下$set
。现在,$set
,正如我们所探讨的,接受一组键值对,这些将被设置。在这种情况下,我们已经生成了对象,如下面的代码所示:
$set: {
completed:true
}
我们刚好在app.patch
块中生成了它,它刚好被称为body
。所以我将$set
操作符设置为body
变量。现在我们可以继续进行最终的选项。这些只是一些选项,让你调整函数的工作方式。如果你记得,在mongodb-update
中,我们将returnOriginal
设置为false
;这意味着我们得到了新的对象,更新后的对象。我们将使用一个类似的选项,但名字不同;它叫做new
。它有类似的功能,只是名字不同,因为这是 Mongoose 开发者选择的名字。有了查询,我们就完成了,我们可以添加一个then
回调和一个catch
回调,并添加我们的成功和错误代码。如果一切顺利,我们将得到我们的todo
文档,如果一切不顺利,我们将得到一个错误参数,我们可以继续发送一个400
状态码,res.status(400).send()
:
Todo.findByIdAndUpdate(id, {$set: body}, {new: true}).then((todo) => {
}).catch((e) => {
res.status(400).send();
})
现在,我们需要检查todo
对象是否存在。如果不存在,如果没有todo
,那么我们将继续以404
状态码做出响应,return res.status(404).send()
。如果todo
存在,那意味着我们能够找到它并对其进行更新,所以我们可以简单地将其发送回去,res.send
,我们将其作为todo
属性发送回去,其中 todo 等于todo
变量,使用 ES6 语法:
Todo.findByIdAndUpdate(id, {$set: body}, {new: true}).then((todo) => {
if(!todo)
{
return res.status(404).send();
}
res.send({todo});
}).catch((e) => {
res.status(400).send();
})
现在,我们已经完成了。这并不太糟糕,但比以前的任何路线都要复杂一些,所以我想一步一步地带你走过来。让我们花一点时间来回顾一下我们做了什么以及为什么这样做。首先,我们做的第一件不寻常的事情是创建了body
变量;这包含了用户传递给我们的一部分内容。我们不希望用户能够更新他们选择的任何内容。接下来,我们根据completed
属性更新了completedAt
属性,最后我们调用了findByIdAndUpdate
。通过这三个步骤,我们成功地更新了我们的 Todos。
测试 patch 调用的 Todos
现在,为了测试这个,我将保存server
文件并在终端中启动服务器。我将使用clear
清除终端输出,然后运行npm start
启动应用程序。应用程序正在 3000 端口上运行,所以在 Postman 中,我们可以进行一些请求来看看这是如何工作的。我将切换到 Todo App Local 环境,并进行一个GET /todos
请求,以便我们可以获得一个真正的 ID 用于我们的 Todo 项目,你可以看到我们的测试中有一些旧数据:
我将拿到第二个,它的text
属性等于Second test todo
,然后我将继续创建一个新的请求,将方法从 GET 更改为 PATCH。我们将提供我们的 URL,它将是{{url}}
,然后我们将有/todos/
我们复制的 ID:
现在记住,PATCH 请求完全是关于更新数据,所以我们必须提供数据作为请求体。我将转到 Body | raw | JSON 来做到这一点。让我们继续对 Todo 进行一些更新。我将设置"completed": true
,如果你在 GET /todos 选项卡中查看,你会发现第二个 Todo 的completed
值为false
,所以它应该改变,completedAt
属性应该被添加。请求设置好后,我将发送它:
我们得到了我们的todo
,completed
设置为true
,completedAt
设置为时间戳。现在我也可以继续调整这个,将"completed": true
改为"completed": false
发送请求;这会将"completed": false
设置并清除completedAt
。最后,我们可以继续做一些像设置text
属性的事情。我将把它设置回true
,并添加第二个属性,text
,将其设置为Updates from postman
。我可以发送这个请求,然后在下面我们得到了我们的 Todo,看起来正如我们所期望的那样:
我们有我们的text
更新;我们还有我们的completed
更新和时间戳显示在completedAt
字段中。有了这个,我们现在有了获取、删除、更新和创建 Todo 项目的能力——这些是四个主要的 CRUD 操作。
接下来,我们要做的是编写一些测试来验证patch
是否按预期工作,这样我们就可以自动运行它们并捕捉到我们代码中的任何回归。目前就是这样,我们将继续在终端中提交并推送我们的更改。我们将把它们推送到 Heroku 并测试一下。git status
显示我们只有这两个更改的文件,这意味着我们可以使用git commit
和-am
标志来进行提交。对于这个,一个好的消息是,Add PATCH /todos/:id
:
**git commit -am 'Add PATCH /todos/:id'**
我要提交并将其推送到 GitHub,一旦它在 GitHub 上,我们就可以使用以下命令将其推送到 Heroku:
**git push heroku master**
请记住,主分支是 Heroku 唯一可以访问的分支;我们不会在本书中使用分支,但是如果你已经了解分支并且遇到任何问题,你需要推送到 Heroku 主分支以重新部署你的应用。就像我说的,如果你使用的命令和我一样,这不是一个问题。
现在应用已经部署,我们可以打开它;我们将通过在 Postman 中发出请求来打开它。我将切换到 Todo App Heroku 环境,然后我将继续在 GET /todos 中发出请求:
这些是在 Heroku 上可用的所有待办事项。我将拿到第一个。我将转到 PATCH 请求,替换 ID,并保持相同的主体。我将设置"completed": true
和"text": "来自 Postman 的更新"
:
当我们发送请求后,我们会收到更新后的待办事项。completed
看起来很好,completedAt
也很好,text
也很好。现在我将把它添加到我的集合中;在以后,patch 调用会派上用场,所以我会点击保存为,给它一个我们用于所有的名称,即 HTTP 方法后跟 URL。我将保存到我们现有的集合 Todo App 中:
现在,我们已经完成了这一步;我们的patch
路由已经可以工作了,现在是时候进入下一部分,我们将在那里测试这段代码。
测试 PATCH /todos/:id
在这一部分中,我们,或者更恰当地说是你,将编写两个测试用例来验证patch
是否按预期工作。我们将拿一个未完成的待办事项并将其标记为完成,然后拿一个已完成的待办事项并将其标记为未完成。
为了做到这一点,我们需要调整server.test
文件中的种子数据。server.test
文件中的种子数据是两个待办事项;它们都没有指定completed
属性,这意味着它将默认为false
。对于第二个项目,我们将继续设置它。我们将设置completed: true
,并且我们还将设置completedAt
等于我们想要的任何值。你可以选择任何数字。我将继续使用333
:
const todos = [{
_id: new ObjectID(),
text: 'First test todo'
},{
_id: new ObjectID(),
text: 'Second test todo',
completed: true,
completedAt: 333
}];
现在我们有两个待办事项,可以让我们双向切换。在下面开始之前,我将帮助你创建一个描述和一个It
块,以便我们在同一个页面上,但你将负责填写实际的测试用例。这一部分基本上是一个挑战,因为我们之前已经做了很多这样的事情。首先是describe
块。我们将describe
这组测试;我们将使用方法后跟 URL 来做到这一点,然后我们可以继续添加我们的函数,然后定义我们的两个测试用例:
describe('PATCH /todos/:id', () => {
});
第一个测试将获取我们的第一个待办事项,并将其text
设置为其他内容,我们将把completed
从false
更改为true
,it('should update the todo')
。我们可以为我们的函数提供done
参数,并且我将在接下来的一刻内留下一些注释,让你知道我希望你如何完成这个任务。第二个测试将用于切换第二个待办事项,其中completed
值已经等于true
,然后it('should clear completedAt when todo is not completed')
。这个测试用例将确保当我们去除completed
状态,将其设置为false
时,completedAt
被清除。现在,对于第一个测试用例,你要做的是获取第一项的 ID,获取第一项的 ID
,然后你将发出我们的 patch 请求;你将提供带有 ID 的正确 URL,并且你将使用 send 发送一些数据作为请求体。对于这个,我希望你更新文本,将其设置为你喜欢的任何内容,然后你将设置 completed 为 true
。现在,一旦你发送出去,你将准备好进行断言,你将使用基本系统进行一次断言,断言你得到了一个200
状态码,并且你将进行一次自定义断言。自定义断言将验证响应体是否具有一个text
属性等于你发送的文本,文本已更改
。你将验证completed
是否为true
,并且你还将验证completedAt
是否为一个数字,你可以使用expect
中可用的.toBeA
方法来完成。现在,对于第二个测试,我们将做类似的事情,但我们只是朝着另一个方向前进;我们将获取第二个待办事项的 ID
,你将将text
更新为不同的内容,并且你将将completed
设置为false
。然后你可以进行断言。再次,我们将期望这个得到200
,并且我们将期望响应体现在这些更改,文本被更改为你选择的任何内容。我还希望你检查completed
现在是否为false
,并且检查completedAt
是否为null
,你可以使用expect
上可用的.toNotExist
方法进行断言。这就是你需要完成测试套件的内容。完成后,我希望你运行npm test
,确保两个测试用例都通过。
测试 1 - 完成未完成的待办事项
让我们先填写第一个测试用例,我将首先获取正确的 ID。让我们创建一个名为hexId
的变量,将其设置为第一个待办事项的_id
属性,并调用toHexString
以获取我们可以传递到 URL 的字符串。接下来,我将创建一些虚拟文本;这将是新的更新文本。让我们创建一个名为text
的变量,并将其设置为你喜欢的任何内容。这应该是新文本
。现在我们可以使用request
实际发出请求到我们的 express 应用程序。我们将使用patch
方法;希望你能自己找出,如果你找不到,也许你使用了 super test 的文档,因为我没有明确告诉你如何进行patch
调用。接下来,我们将使用模板字符串作为我们的 URL,/todos/
,然后我们将注入hexId
。现在,在我们进行断言之前,我们确实需要发送一些数据,所以我将调用send
,传递数据。这将是我们想要更改的内容。对于这个测试,我们确实希望将completed
设置为true
。我将设置completed: true
,我们确实希望更新文本,所以我将text
设置为上面的text
变量,并且我可以使用 ES6 略去这部分:
it('should update the todo', (done) => {
var hexId = todos[0]._id.toHexString();
var text = 'This should be the new text';
request(app)
.patch(`/todos/${hexId}`)
.send({
completed: true,
text
})
});
现在我们已经设置好了发送,我们可以开始做出断言。第一个很容易,我们只是期望 200。我将期望 200
作为返回状态码,并在添加自定义断言之前,我们可以调用 end
,传入 done
。现在,我们需要做的最后一件事就是对返回的数据进行断言。我将调用 expect
,传入一个函数;这个函数我们现在知道会被响应调用,我们可以进行自定义断言。我们将对 text
、completed
和 completedAt
进行断言。首先是 text
。我们使用 expect(res.body.todo.text).toBe(text)
,这是我们上面定义的变量。如果这等于返回的数据,那么我们就可以继续了。
接下来,让我们对 completed
属性进行一些断言。我们将使用 expect(res.body.todo.completed)
并检查它是否为 true
,使用 .toBe(true)
。我们将 completed
设置为 true
,所以它应该从 false
变为 true
。现在,在我们自定义的 expect
调用中,我们要做的最后一个断言是关于 completedAt
,确保它是一个数字。我们将使用 expect(res.body.todo.completedAt)
等于一个数字,使用 .toBeA
,在引号中是 number
类型。
it('should update the todo', (done) => {
var hexId = todos[0]._id.toHexString();
var text = 'This should be the new text';
request(app)
.patch(`/todos/${hexId}`)
.send({
completed: true,
text
})
.expect(200)
.expect((res) => {
expect(res.body.todo.text).toBe(text);
expect(res.body.todo.completed).toBe(true);
expect(res.body.todo.completedAt).toBeA('number');
})
.end(done);
});
现在,我们的第一个测试已经完成。我们可以继续删除那些注释,并通过在终端中运行来验证它是否工作。我们的第二个测试将失败;没关系,只要第一个通过,我们就可以继续。我将运行 npm test
,这将触发测试套件。我们可以看到我们的第一个 PATCH
测试成功了;这是我们刚刚填写的,而我们的第二个测试失败了。两秒后我们得到了一个超时,这是预期的,因为我们从未调用 done
。
现在第一个已经设置好了,我们可以继续填写第二个。这两个测试的代码将非常相似。既然我们刚刚编写了代码并且知道它的作用,我们可以复制粘贴。我不喜欢复制粘贴我不理解的代码,但我喜欢高效。既然我知道那段代码的作用,我可以直接粘贴到第二个测试用例中,然后我们可以继续进行一些更改。
测试 2 - 使完成的待办事项变为未完成
我们不想获取 hexId
变量或第一个待办事项,而是想获取第二个待办事项的 hexId
变量,然后我们需要更新发送的数据。我们不想将 completed
设置为 true
;我们已经在上面手动完成了。这次我们要设置为 false
。我们还要更新 text
,所以我们可以保留它。我将继续调整文本值,末尾添加一些感叹号。接下来是断言。我们仍然期望返回 200
作为状态码。这部分很好,我们仍然期望 text
等于 text
。不过,对于 completed
,我们期望它是 false
,并且我们不期望 completedAt
是一个数字;它原来是一个数字,但在此更新后应该已经清除,因为待办事项不再完成。我们可以使用 toNotExist
来断言 completedAt
不存在。
it('should clear completedAt when todo is not completed', (done) => {
var hexId = todos[1]._id.toHexString();
var text = 'This should be the new text!!';
request(app)
.patch(`/todos/${hexId}`)
.send({
completed: false,
text
})
.expect(200)
.expect((res) => {
expect(res.body.todo.text).toBe(text);
expect(res.body.todo.completed).toBe(false);
expect(res.body.todo.completedAt).toNotExist();
})
.end(done);
});
现在我们的测试用例已经完成。我们现在可以删除那些注释,保存文件,并从终端重新运行。我将重新运行测试套件。
我们让两个PATCH
测试都通过了。现在,你可能已经注意到,对于patch
,我们没有编写那些无效的 ObjectIDs 或者找不到 ObjectIDs 的测试用例;你可以添加这些,但我们迄今为止已经做了很多次,我不认为这是必要的练习。不过,这两个测试用例确实验证了我们的patch
方法是否按预期工作,特别是当涉及到patch
需要完成的稍微复杂的逻辑时。有了这个设置,我们已经完成了对最后一个路由的测试。
我们可以继续进行提交,并进入本章的最后一节。在终端中,我将运行git status
。我们会看到有一个修改过的文件,server.test
文件,看起来很好。我们可以使用git commit
和-am
标志进行提交,Add tests for PATCH /todos/:id
:
**git commit -am 'Add tests for PATCH /todos/:id'**
我将进行提交,然后我会花一点时间将其推送到 GitHub 上,有了这个设置,我们就完成了。在下一节,也是本章的最后一节,你将学习如何在本地使用一个单独的测试数据库,这样你在运行测试时就不会总是清空开发数据库中的数据。
创建一个测试数据库
现在我们所有的待办事项路由都已设置并测试完成,在这最后一部分,我们将探讨如何为我们的应用程序创建一个单独的测试数据库。这意味着当我们运行测试套件时,我们不会删除TodoApp
数据库中的所有数据。我们将在Test
和TodoApp
旁边有一个单独的数据库,用于测试 DB。
为了设置好这一切,我们需要一种区分在本地运行应用程序和在本地运行测试套件的方法,这正是我们将要开始的地方。这个问题的根源在于在我们的mongoose.js
文件中,我们要么使用MONGODB_URI
环境变量,要么使用 URL 字符串。这个字符串用于测试和开发,当我说测试时,我指的是当我们运行我们的test
脚本时,当我说开发时,我指的是当我们在本地运行我们的应用程序,这样我们就可以在 Postman 等工具中使用它。我们真正需要的是一种在本地设置环境变量的方法,这样我们总是使用MONGODB_URI
变量,而不是像在mongoose.js
文件中那样有一个默认字符串。为了做到这一点,我们将看一下一个非常特殊的环境变量:process.env.NODE_ENV
,你不必编写这段代码。我马上就要删除它。这个NODE_ENV
环境变量是由 Express 库广泛使用的,但现在几乎所有的 Node 托管公司都已经采用了它。例如,Heroku 默认将这个值设置为字符串production
。这意味着我们将总共有三个环境。我们已经有了一个production
环境。这是我们在 Heroku 上称呼我们的应用程序的方式;当我们在本地运行应用程序时,我们将有一个development
环境,当我们通过 Mocha 运行应用程序时,我们将有一个test
环境。这意味着我们将能够为这三个环境分别设置MONGODB_URI
的不同值,从而创建一个单独的测试数据库。
让我们开始添加一些代码到server.js
文件的顶部。稍后我们会将这些代码移出server.js
,但现在我们先把它放在顶部。让我们创建一个名为env
的变量,并将其设置为process.env.NODE_ENV
:
var env = process.env.NODE_ENV;
现在,这个变量目前只在 Heroku 上设置;我们在本地没有设置这个环境变量。环境变量通常用于远不止 Node。你的计算机可能有接近两打的环境变量,告诉计算机各种各样的东西:某些程序的存在位置,你想使用的库的版本,这类的东西。然而,NODE_ENV
变量是我们需要在package.json
文件中为开发和测试环境进行配置的东西。然后,在下面,我们将能够添加一些if else
语句来根据环境配置我们的应用。如果我们在开发中,我们将使用一个数据库,如果我们在测试中,我们将使用另一个。现在,为了在package.json
中启动这些东西,我们需要调整test
脚本,设置NODE_ENV
环境变量。你可以通过链接多个命令来设置环境变量。我们即将编写的代码也将为 Windows 提供备用方案,因此,无论你是在 macOS、Linux 还是 Windows 上,你都可以编写完全相同的代码。这将在包括 Heroku 在内的所有地方都能够工作。这里的目标只是在运行测试套件之前将NODE_ENV
设置为test
。为了做到这一点,我们将首先使用export
命令。export
命令在 macOS 和 Linux 中可用。这是完成的方式,即使你在 Windows 上也要输入这个,因为当你部署到 Heroku 时,你将使用 Linux。我们将导出NODE_ENV
,将其设置为test
:
"scripts": {
"start": "node server/server.js",
"test": "export NODE_ENV = test mocha server/**/*.test.js",
"test-watch": "nodemon --exec 'npm test'"
}
现在,如果你在 Windows 上,export
命令将失败;export
将触发一个错误,类似于 export 命令未找到。对于 Windows 用户,我们将添加这个||
块,我们将调用SET
。SET
与 export 相同,只是它是该命令的 Windows 版本。在最后的测试之后,我们将添加两个和号来链接这些命令:
"scripts": {
"start": "node server/server.js",
"test": "export NODE_ENV = test || SET NODE_ENV = test && mocha server/**/*.test.js",
"test-watch": "nodemon --exec 'npm test'"
}
所以,让我们来详细分析一下将会发生什么。如果你在 Linux 上,你将运行export
命令;SET
命令永远不会运行,因为第一个已经运行了。然后我们将链接第二个命令,运行mocha
。如果你在 Windows 上,export
命令将失败,这意味着你将运行第二个命令;无论如何,你都会设置NODE_ENV
变量,然后最后你将链接一个调用mocha
的命令。有了这个设置,我们现在有了一种在package.json
中直接设置NODE_ENV
变量的方法。
这是一个快速的跨操作系统更新;正如你在这里所看到的,我们有一个修改过的test
脚本的版本:
"test": "export NODE_ENV=test || SET \"NODE_ENV=test\" && mocha server/**/*.test.js"
原始的测试脚本在 Windows 端有一个问题:它会将环境变量设置为带有末尾空格的字符串 test,而不是只有字符串test
。为了正确地将env
变量设置为test
,而不是test
,我们将把整个设置参数放在引号内,并且我们会转义这些引号,因为我们在 JSON 文件中使用引号。这个命令将在 Linux、macOS 和 Windows 上都能够工作。
现在我实际上不会为scripts
添加一个start
脚本。start
脚本,用于开发环境,将只是默认值。我们将在 Heroku 上将其设置为生产环境,我们将在test
脚本中将其设置为test
,在这种情况下,我们将在server.js
中将其设置为默认值,因为我们倾向于在不经过start
脚本的情况下运行文件。在server.js
文件中,我将默认设置为development
。如果我们处于生产环境,NODE_ENV
将被设置,如果我们处于测试环境,development
将被设置,如果我们处于开发环境,NODE_ENV
将不会被设置,将使用development
,这意味着我们准备添加一些if
语句。如果env
是development
,我们要做一些事情。我们要做的事情是设置 MongoDB URL。否则,如果env
是test
环境。在这种情况下,我们还要设置一个自定义数据库 URL:
if(env === 'development') {
} else if(env === 'test') {
}
现在我们可以继续设置我们的环境变量。我们在整个应用程序中使用了两个环境变量,这两个环境变量都在 Heroku 上设置,因此不必担心生产环境。我们有我们的PORT
环境变量,和我们的MONGODB_URI
变量。在server.js
中,如果我们处于开发环境,我们将继续设置process.env.PORT=3000
。这意味着我们实际上可以删除port
变量的默认值;没有必要设置默认值,因为PORT
已经被设置。它将在 Heroku 上设置为生产环境,它将在本地设置为development
,然后在else if
块中,我们将为我们的最终环境,测试环境,设置为3000
。在mongoose.js
中,我们将为development
和test
设置一个MONGODB_URI
环境变量,这与我们在生产环境上使用的变量名称完全相同。我将删除我们的默认值,将字符串剪切出来,这样它就在我的剪贴板中,然后我可以删除所有设置默认值的多余代码,我们剩下的就是对环境变量的引用:
mongoose.connect(process.env.MONGODB_URI);
现在在server.js
内部,我们可以为两个环境设置环境变量process.env.MONGODB_URI
,我们将把它设置为我刚刚复制的字符串mongodb://localhost:27017/TodoApp
。我们正在使用TodoApp
数据库。
现在,在else if
块下面,我们可以将process.env.MONGODB_URI
设置为我们刚刚复制的字符串,但是不再将其设置为TodoApp
数据库,而是将其设置为TodoAppTest
数据库:
if(env === 'development') {
process.env.PORT = 3000;
process.env.MONGODB_URI = 'mongodb://localhost:27017/TodoApp';
} else if(env === 'test') {
process.env.PORT = 3000;
process.env.MONGODB_URI = 'mongodb://localhost:27017/TodoAppTest';
}
当我们以测试模式运行我们的应用程序时,我们将使用一个完全不同的数据库,因此不会清除我们用于开发的数据库。为了测试一切是否按预期工作,我将在env
变量下方使用console.log
记录环境变量。我将打印带有几个星号的字符串env
,以便在终端输出中易于识别,然后我将把env
变量作为第二个参数传递:
console.log('env *****', env);
现在我们可以继续测试一切是否按预期工作。在终端中,我将使用以下命令启动我们的应用程序:
**node server/server.js**
我们得到一个等于development
的env
,这正是我们所期望的:
现在我们可以在 Postman 中进行测试。在 Postman 中,我将切换到我的本地环境,Todo App Local,然后我将获取所有的 Todos,您可以看到我们有一些剩下的测试数据:
我想要做的是继续调整第一个,使其不同。然后我们将运行我们的测试,并确保调整后的待办事项仍然显示出来,因为当我们运行测试时,我们不应该访问相同的数据库,因此这些数据都不应该被更改。我将复制第一项的 ID,将其移入我的PATCH
调用。我正在更新text
属性和completed
属性,所以很好,我不需要更改。我将继续更改 URL 中的 ID,发送调用,现在我们有了text
属性为Updates from postman
的更新后的待办事项:
接下来,我将进入终端,关闭节点服务器,并使用npm test
运行我们的测试:
我们将env
变量设置为test
,然后运行测试套件;我们所有的测试都通过了,这太棒了。我们设置的一切是否有效的真正测试是,如果我们再次启动服务器并尝试从development
数据库中获取数据。
在 Postman 中,我将最后一次进行GET /todos
请求,我们的待办事项数据仍然如预期般显示出来。尽管测试套件确实运行了,但这并不重要,因为它不再清除这个数据库,而是现在清除了一个全新的数据库,您可以在 Robomongo 中查看。如果我点击连接并点击刷新,我们现在有两个TodoApp
数据库:我们有TodoApp
和TodoAppTest
。这太棒了;一切都设置好了,我们准备好开始了。
现在,在我们离开之前,我想把server.js
中的所有代码移到其他地方;它并不真正属于这里,它只会使服务器文件变得比必要的更复杂。在server
文件夹中,我将创建一个名为config
的全新文件夹,在config
文件夹中,我将创建一个名为config.js
的新文件,在其中我们可以进行所有的环境变量配置。我将复制所有代码并用一个require
调用替换它。这是一个相对文件,所以我们将转到/config/config
:
require('./config/config');
在config.js
内部,我们现在可以复制代码并删除与console.log
相关的行。让我们通过提交更改并部署到 Heroku 来结束这一部分。
在终端中,我将清除终端输出,然后我们可以运行git status
来查看我们更改了哪些文件,我们改变了相当多的文件。
我们还在server
目录中有一些新文件。我将使用git add .
将所有内容添加到下一个提交中,然后再次使用git status
确认一切看起来都很好。现在我们准备提交,我可以继续进行,使用git commit
并使用-m
标志提供我们的消息,设置单独的测试和开发环境
:
**git commit -m 'Setup separate test and development envs'**
我还要将其部署到 Heroku,以便我们可以验证我们在那里没有破坏任何东西:
**git push heroku master**
完成后,我们将通过进入 Postman 并向我们的 Heroku 应用程序发出GET /todos
请求来结束这一部分。在 Postman 中,我将从 Todo App Local 切换到 Todo App Heroku 环境,然后我们可以发送请求:
现在,如前面的屏幕截图所示,我们从真实数据库中获取了两个todo
项目,这意味着 Heroku 应用程序上没有出现任何问题,也不应该有——从技术上讲,我们什么都没有改变。在 Heroku 中,我们所做的就是运行config
文件,但我们不使用默认值,因为它已经设置好了,并且不会通过任何那些语句,因为env
变量将等于字符串 production,因此就 Heroku 而言,没有任何变化,并且它显示出来,因为数据仍然如预期般返回。
这就是本节的全部内容,也是本章的全部内容。在本节中,我们学习了关于 MongoDB、Mongoose API、Postman、测试、路由等各种重要功能。在下一章中,我们将通过添加身份验证来完成 Todo 应用程序。
总结
在本章中,我们学习了 Mongoose 查询和 ID 验证。接下来,我们研究了获取单个资源并进行了一些挑战。在将 API 部署到 Heroku 并探索 Postman 环境之后,我们了解了不同的删除资源的方法。最后,我们研究了创建测试数据库。
在下一章中,我们将学习使用 Socket.io 创建实时 Web 应用程序
第五章:使用 Socket.io 实时 Web 应用程序
在本章中,您将学习有关 Socket.io 和 WebSockets 的知识,它们可以在服务器和客户端之间进行双向通信。这意味着我们不仅要设置一个 Node 服务器,还要设置一个客户端。这个客户端可以是一个 web 应用程序,iPhone 应用程序或 Android 应用程序。对于本书来说,客户端将是一个 web 应用程序。这意味着我们将连接这两个,允许数据在浏览器和服务器之间无缝流动。
现在,我们的 todo 应用程序数据只能单向流动,客户端必须初始化请求。使用 Socket.io,我们将能够立即来回发送数据。这意味着对于实时应用程序,比如电子邮件应用程序,食品订购应用程序或聊天应用程序,服务器不需要等待客户端请求信息;服务器可以说,“嘿,我刚刚收到了一些你可能想要向用户显示的东西,所以在这里!”这将开启一系列可能性,我们将从如何将 Socket.io 集成到 Node 应用程序中开始。让我们开始吧!
创建一个新的 web 应用项目
在您可以将套接字添加到您的 Web 应用程序之前,您需要一个 Web 应用程序来添加它们,这正是我们将在本节中创建的。我们将创建一个基本的 Express 应用程序,并将其上传到 GitHub。然后,我们将部署到 Heroku,这样我们就可以在浏览器中实时查看它。
现在,这个过程的第一步是创建一个目录。我们将一起做一些事情,让我们都朝着正确的方向前进。从桌面开始的过程的第一步是运行mkdir
来为这个项目创建一个新目录;我将把它叫做node-chat-app
。
然后,我们可以使用cd
命令导航到该目录,然后运行一些命令:
mkdir node-chat-app
cd node-chat-app
首先是npm init
。和本书中的所有项目一样,我们将利用 npm,所以我们将运行以下命令:
npm init
然后,我们将使用enter键来使用每个选项的默认值:
当我们完成后,我们可以输入yes
,现在我们有了一个package.json
文件。在我们进入 Atom 之前,我们将运行以下命令来初始化一个新的 Git 仓库:
git init
我们将使用 Git 对这个项目进行版本控制,并且我们还将使用 Git 推送到 GitHub 和 Heroku。有了这个设置,我可以使用clear
命令来清除终端输出,然后我们可以进入 Atom。我们将从打开文件夹并设置我们的基本应用程序结构开始。
设置我们的基本应用程序结构
为了设置基本的应用程序结构,我将打开桌面上刚创建的文件夹,名为node-chat-app
:
在这个文件夹中,我们将开始创建一些目录。现在,不像前几章的其他应用程序,聊天应用程序将有一个前端,这意味着我们将编写一些 HTML。
我们还将添加一些样式和编写一些在浏览器中运行的 JavaScript 代码,而不是在服务器上运行。为了使这个工作,我们将有两个文件夹:
-
一个将被称为
server
,它将存储我们的 Node.js 代码 -
另一个将被称为
public
,它将存储我们的样式,我们的 HTML 文件和我们的客户端 JavaScript
现在,在server
文件夹中,就像我们为 todo API 所做的那样,我们将有一个server.js
文件,它将是我们的 Node 应用程序的根:
这个文件将做一些事情,比如创建一个新的 Express 应用程序,配置公共目录为 Express 提供的静态文件夹,并调用app.listen
来启动服务器。
在public
文件夹中,我们将在本节中创建一个文件,名为index.html
:
index.html
文件将是我们在应用程序中访问时提供的标记页面。现在,我们将制作一个非常简单的页面,只是在屏幕上打印一条消息,以便我们可以确认它被正确地提供出来。在下一节中,我们将担心在客户端集成 Socket.io。
为 DOCTYPE 设置 index.html 文件
不过,现在,在我们的index.html
文件中,我们将提供DOCTYPE
,这样浏览器就知道我们要使用哪个版本的 HTML。我们告诉它使用 HTML,这是指 HTML5。接下来,我们将打开并关闭我们的html
标签:
<!DOCTYPE html>
<html>
</html>
这个标记将让我们提供head
和body
标签,这正是我们需要让事情运转起来的。
- 首先是
head
。在head
内,我们可以提供各种配置标签。现在我们只使用一个,meta
,这样我们就可以告诉浏览器我们想要使用哪个charset
。在meta
标签中,我们将提供charset
属性,将其设置为utf-8
,放在引号内:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
</html>
- 接下来,我们将在
html
标签内提供body
标签。这包含了我们实际要呈现到屏幕上的 HTML,对于这个,我们将呈现一个p
标签,用于段落,然后我们会有一些简单的文本,比如Welcome to the chat app
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<p>Welcome to the chat app</p>
</body>
</html>
这就是目前要显示的全部内容。现在,我们可以离开html
文件,回到server
文件。
为公共目录设置 server.js 文件
在我们的server
文件中,我们想要设置这个服务器来提供public
文件夹。现在,在这种情况下,server.js
文件不在项目的根目录中,这意味着我们必须从server
进入node-chat-app
的上一级目录。然后,我们必须进入public
文件夹。这将使设置 Express 中间件有点困难。我们将看一下一个内置的 Node 模块,它可以很容易地转换路径。
现在,为了向你展示我在说什么,让我们继续使用两个console.log
调用:
console.log();
console.log();
第一个console.log
调用将向我们展示我们以前是如何做的,第二个将向我们展示更好的做法。
在第一个console.log
调用中,我们将提供我们为我们的第一个 Express 应用程序提供的相同路径。我们使用__dirname
来引用当前目录,这种情况下是server
目录,因为文件在server
文件夹内。然后,我们连接它,/public
。现在,在这种情况下,我们在server
文件夹中没有一个public
文件夹;public
文件夹和server
文件夹在完全相同的级别,这意味着我们需要使用..
来进入上一级目录,然后我们需要进入public
:
console.log(__dirname + '/../public');
console.log();
这是旧的做事情的方式,如果我们从终端运行这个,我们可以看到为什么它看起来有点奇怪。我将运行server/server.js
:
nodemon server/server.js
我们得到的是这个路径,如下面的截图所示:
我们进入了Users/Andrew/Desktop/
项目文件夹,这是预期的,然后我们进入server
,离开server
,然后进入public
——这是完全不必要的。我们想要做的是直接从project
文件夹进入public
,保持一个干净、跨操作系统兼容的路径。为了做到这一点,我们将使用一个随 Node 一起提供的名为path
的模块。
join 方法
现在,让我们看一下path
的文档,因为path
有很多方法在这一节中我们不会使用。我们将前往nodejs.org,在那里我们可以找到 Docs 选项卡。我们将进入 Docs 页面,然后进入 API 参考页面:
这是我们可以使用的所有模块的列表。我们正在使用 Path 模块:
在 Path 中,我们将使用的方法是join
,你可以在前面的截图中看到。如果你点击这个方法,你可以看到join
如何工作的一个小例子:
join
方法接受您的部分路径并将它们连接在一起,这意味着在前面截图中显示的示例会得到更简单的路径。在这个例子中,我们可以看到我们从foo
开始。然后我们进入bar
,这也显示出来;然后我们进入baz/asdf
,这确实显示出来。接下来是有趣的部分:我们进入quux
目录,然后我们使用..
退出,您可以看到结果路径并没有显示我们进入和退出,就像我们在终端内的路径一样;相反,它将其解析为最终路径,quux
目录不见了。
我们将使用完全相同的方法来清理我们的路径。在 Atom 中,我们可以通过创建一个名为path
的常量并要求它来加载path
模块:
const path = require('path');
记住,这个不需要安装:它是一个内置模块,您可以在不使用npm
的情况下访问它。接下来,我们将创建一个名为publicPath
的变量。我将使其成为一个常量变量,因为我们将对其进行任何更改,并将调用path.join
:
const path = require('path');
const publicPath = path.join();
我们将在一会儿将一些参数传递给path.join
。在我们这样做之前,我将调用console.log(publicPath)
:
const path = require('path');
const publicPath = path.join();
console.log(__dirname + '/../public');
console.log(publicPath);
现在,在path.join
内,我们要做的是取两个路径__dirname
和'/../public'
,并将它们作为单独的参数传递。我们仍然希望从dirname
目录的server
文件夹开始。然后,作为第二个参数,我们将在引号内指定相对路径。我们将使用..
退出目录,然后使用斜杠进入public
文件夹:
const path = require('path');
const publicPath = path.join(__dirname, '../public');
我将保存server
文件,现在我们应该能够返回终端并看到我们的新路径-在这里:
我们不是进入server
然后退出,而是直接进入public
目录,这是理想的。这是我们要提供给 Express 静态中间件的路径。
现在我们已经设置了这个public
路径变量,让我们在本地设置 Express。在我们开始之前,我们将使用npm i
进行安装。模块名称是express
,我们将使用最新版本@4.16.3
,带有--save
标志。
npm i express@4.16.3 --save
我们将运行安装程序,然后我们可以继续在server.js
中实际使用它。在package.json
中,我们现在将其放在依赖对象中。
配置基本服务器设置
安装 Express 安装程序后,您将创建一个全新的 Express 应用程序,并配置 Express 静态中间件,就像我们之前做的那样,以提供public
文件夹。最后,您将在端口3000
上调用app.listen
。您将提供其中一个小回调函数,以在终端上打印消息,例如服务器在端口 3000 上运行
。
一旦您创建了服务器,您将在终端内启动它,并在浏览器中转到localhost:3000
。如果我们现在去那里,我们会得到一个错误,因为该端口上没有运行服务器。您应该能够刷新此页面并看到我们在index.html
内的段落标签中键入的小消息。
我要做的第一件事是在server.js
内加载 Express,创建一个名为express
的常量并要求我们刚刚安装的库:
const path = require('path');
const express = require('express');
接下来,您需要创建一个app
变量,我们可以在其中配置我们的 Express 应用程序。我将创建一个名为app
的变量,并将其设置为调用express
:
const path = require('path');
const express = require('express');
const publicPath = path.join(_dirname, '../public');
var app = express();
记住,我们不是通过传递参数来配置 Express;相反,我们通过在app
上调用方法来配置 Express,以创建路由、添加中间件或启动服务器。
首先,我们将调用app.use
来配置我们的 Express 静态中间件。这将提供public
文件夹:
const path = require('path');
const express = require('express');
const publicPath = path.join(_dirname, '../public');
var app = express();
app.use();
你需要做的是调用express.static
并传入路径。我们创建一个publicPath
变量,它存储了我们需要的路径:
app.use(express.static(publicPath));
最后要做的一件事是调用app.listen
。这将在端口3000
上启动服务器,并且我们将提供一个回调函数作为第二个参数,以在服务器启动后在终端上打印一条小消息。
我将使用console.log
来打印Server is up on port 3000
:
app.listen(3000, () => {
console.log('Server is up on port 3000');
});
有了这个,我们现在可以在终端内启动服务器,并确保我们的index.html
文件出现在浏览器中。我将使用clear
命令清除终端输出,然后我将使用nodemon
运行服务器,使用以下命令:
nodemon server/server.js
在这里,我们得到了我们的小消息,Server is up on port 3000
。在浏览器中,如果我刷新一下,我们就会得到我们的标记,Welcome to the chat app
,如下面的截图所示:
现在我们已经建立了一个基本的服务器,这意味着在下一节中,我们实际上可以在客户端和后端都添加 Socket.io。
设置 gitignore 文件
现在,在我们开始在 GitHub 和 Heroku 上进行操作之前,我们将首先在 Atom 中设置一些东西。我们需要设置一个.gitignore
文件,我们将在项目的根目录中提供它。
在.gitignore
中,我们唯一要忽略的是node_modules
文件夹。我们不想将任何这些代码提交到我们的仓库,因为它可以使用npm install
生成,并且可能会发生变化。管理这种东西真的很痛苦,不建议你提交它。
接下来我们要做的是为 Heroku 配置一些东西。首先,我们必须使用process.env.PORT
环境变量。我将在publicPath
变量旁边创建一个名为port
的常量,将其设置为process.env.PORT
或3000
。我们将在本地使用它:
const publicPath = path.join(__dirname, '../public');
const port = process.env.PORT || 3000;
现在,我们可以在app.listen
中提供port
,并且可以通过将常规字符串更改为模板字符串来在以下消息中提供它,以获得Server is up on
。我将注入port
变量值:
app.listen(port, () => {
console.log(`Server is up on ${port}`);
});
现在我们已经准备好了,接下来我们需要改变的是为了让我们的应用为 Heroku 设置好,更新package.json
文件,添加一个start
脚本并指定我们想要使用的 Node 版本。在scripts
下,我将添加一个start
脚本,告诉 Heroku 如何启动应用程序。为了启动应用程序,你必须运行node
命令。你必须进入server
目录,启动它的文件是server.js
:
"scripts": {
"start": "node server/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
我们还将指定engines
,这是我们以前做过的。正如你所知,engines
可以让你告诉 Heroku 要使用哪个版本的 Node:
"engines": {
},
这将是重要的,因为我们正在利用一些仅在最新版本的 Node 中才能使用的功能。在engines
中,我将提供与之前使用的完全相同的键值对,将node
设置为9.3.0
:
"engines": {
"node": "9.3.0"
},
如果你使用的是不同版本的 Node,你可以提供这里添加的版本的替代版本。
使用当前未提交的文件进行提交
现在我们已经准备好用所有当前未提交的文件进行提交了。然后你将进入 GitHub 并创建一个 GitHub 仓库,将你的本地代码推送上去。确保代码实际上被推送到 GitHub;你可以通过刷新 GitHub 仓库页面来做到这一点。你应该在仓库中看到你的目录结构。
接下来你需要做的是创建一个 Heroku 应用并部署到它。一旦你的应用部署完成,你应该能够在浏览器上访问应用的 URL。你应该看到与我们在localhost:3000
上看到的完全相同的消息。Welcome to the chat app
消息应该会打印出来,但不是在localhost:3000
上,而是在实际的 Heroku 应用上。
现在我们已经在项目内进行了所有必要的更改。我们已经配置了port
变量,并设置了我们的scripts
和engines
,所以你不需要再进行任何代码更改;你只需要在浏览器和终端中施展你的魔法来完成这个。
第一步是创建一个新的 GitHub 存储库。我们需要一个地方来推送我们的代码。我们可以前往github.com,点击那个大大的新存储库按钮,然后创建一个新的。我会把我的存储库命名为node-course-2-chat-app
。我会将其设置为公共并创建:
现在我们已经创建了存储库,我们有一系列可以使用的命令。我们有一个现有的存储库要推送,所以我们可以复制这些行:
在终端中,在我们实际推送任何东西之前,我们需要进行提交。我会关闭nodemon
并运行git status
命令:
在这里,你可以看到我们有我们预期的文件,我们有public
和server
文件夹,我们有.gitignore
,我们有package.json
。然而,node_modules
不见了。然后,你需要使用git add .
将这些未跟踪的文件添加到下一个提交中。
如果你再次运行git status
命令,你会看到一切看起来都很好:
我们有四个要提交的更改:四个新文件。我会运行git commit
并使用-m
标志来指定消息。由于所有文件都已经添加,所以不需要-a
标志。在引号中,Init commit
就可以完成任务:
git commit -m 'Init commit'
一旦你有了提交,你可以通过运行他们给你的两行将其推送到 GitHub。我会运行这两行:
git remote add origin https://github.com/garygreig/node-course-2-chat-app.git
git push -u origin master
如图所示,它现在已经在 GitHub 上了。我们可以通过刷新页面来确认,而不是看到说明,我们看到了我们创建的文件:
接下来要做的最后一件事是将应用程序放在 Heroku 上。实际上,你不需要去 Heroku 网站应用程序去完成这个;我们可以在终端内运行heroku create
:
让我们继续创建应用程序。我们可以使用以下命令来部署应用程序。我将继续运行它:
git push heroku master
这将把我的本地代码推送到 Heroku。Heroku 将看到新代码被推送,然后会部署它:
一旦它上线了,我们可以使用heroku open
命令在浏览器上打开应用程序的 URL。或者,你也可以随时从终端获取 URL。我会复制在前面截图中显示的 URL,进入浏览器,然后粘贴它:
如前面的屏幕截图所示,我们应该看到我们的应用程序。欢迎使用聊天应用程序显示在屏幕上,有了这个,我们就完成了!我们有一个基本的 Express 服务器,我们有一个后端和一个前端,它已经在 GitHub 上,也已经在 Heroku 上了!
我们已经准备好进入下一节,我们将真正开始集成 Socket.io。
向应用程序添加 Socket.io
现在你已经有一个基本的 Express 应用程序在运行,在本节中,你将配置服务器以允许传入的 WebSocket 连接。这意味着服务器将能够接受连接,我们将设置客户端进行连接。然后,我们将有一个持久连接,我们可以来回发送数据,无论是从服务器到客户端的数据,还是从客户端到服务器的数据。这就是 WebSocket 的美妙之处——你可以在任何方向发送数据。
现在,为了设置 WebSockets,我们将使用一个名为 Socket.io 的库。就像 Express 使设置 HTTP 服务器变得非常容易一样,Socket.io 使设置支持 WebSockets 的服务器和创建与服务器通信的前端变得非常简单。Socket.io 有一个后端和前端库;我们将使用两者来设置 WebSockets。
设置 Socket.io
首先,在终端中,让我们继续安装最新版本的 Socket.io,使用npm i
。模块名是socket.io
,撰写时的最新版本是@2.0.4
。我们将使用--save
dev 标志来更新package.json
文件:
npm i socket.io@2.0.4 --save
一旦这个设置好了,我们可以继续对我们的server
文件进行一些更改。首先,我们将加载库。我将创建一个叫做socketIO
的常量,并将其设置为socket.io
库的require
语句:
const path = require('path');
const express = require('express');
const socketIO = require('socket.io');
有了这个设置,我们现在需要将 Socket.io 集成到我们现有的 Web 服务器中。目前,我们使用 Express 来创建我们的 Web 服务器。我们创建一个新的 Express 应用程序,配置我们的中间件,并调用app.listen
:
var app = express();
app.use(express.static(publicPath));
app.listen(port, () => {
console.log(`Server is up on ${port}`);
});
现在,在幕后,Express 实际上是在使用一个内置的 Node 模块叫做http
来创建这个服务器。我们需要自己使用http
。我们需要配置 Express 来与http
一起工作。然后,只有这样,我们才能添加 Socket.io 支持。
使用 http 库创建服务器
首先,我们将加载http
模块。所以,让我们创建一个叫做http
的常量,这是一个内置的 Node 模块,所以不需要安装它。我们可以简单地输入require('http')
,就像这样:
const path = require('path');
const http = require('http');
const express = require('express');
const socketIO = require('socket.io');
从这里开始,我们将使用这个http
库创建一个服务器。在我们的app
变量下面,让我们创建一个叫做server
的变量。我们将调用http.createServer
:
const path = require('path');
const http = require('http');
const express = require('express');
const socketIO = require('socket.io');
const publicPath = path.join(_dirname, '../public');
const port = process.env.PORT || 3000;
var app = express();
var server = http.createServer()
现在,你可能不知道,但实际上你已经在幕后使用createServer
方法。当你在 Express 应用程序上调用app.listen
时,它实际上调用了这个完全相同的方法,将应用程序作为createServer
的参数传递。createServer
方法接受一个函数。这个函数看起来非常类似于我们的 Express 回调之一,并且会被调用以请求和响应:
var server = http.createServer((req, res) => {
})
现在,正如我所提到的,Express 实际上在幕后使用了http
。它被集成得如此之深,以至于你实际上可以将app
作为参数提供,然后我们就完成了:
var server = http.createServer(app);
在集成 Socket.io 之前,让我们继续完成这个更改。我们将使用 HTTP 服务器而不是 Express 服务器,所以我们将调用server.listen
而不是app.listen
:
server.listen(port, () => {
console.log(`Server is up on ${port}`);
});
再次强调,不需要更改传递给server.listen
方法的参数——它们完全相同,并且非常接近彼此,因此server.listen
的参数与 Express 的app.listen
的参数相同。
现在我们已经完成了这一步,我们实际上并没有改变任何应用程序功能。我们的服务器仍然会在端口3000
上工作,但我们仍然无法访问 Socket.io。在终端中,我可以通过清除终端输出并使用nodemon
命令启动我们的服务器来证明这一点:
nodemon server/server.js
然后,我将在浏览器 URL 中加载localhost:3000
,看看我得到了什么:
如前面的屏幕截图所示,我们得到了我们的 HTML,欢迎来到聊天应用程序。这意味着我们的应用程序仍然在工作,即使我们现在使用的是 HTTP 服务器。
配置服务器使用 Socket.io
接下来要做的事情是配置服务器使用 Socket.io——这就是我们进行这个更改的整个原因。在server
变量旁边,我们将创建一个名为io
的变量。
我们将它设置为调用socket.io
并传入server
,这是我们想要与我们的 WebSockets 一起使用的:
var server = http.createServer(app);
var io = socketIO(server);
现在我们通过 server
变量可以访问服务器,所以我们将它作为第一个且唯一的参数传递进去。现在,我们得到的是我们的 WebSockets 服务器。在这里,我们可以做任何我们想做的事情,无论是发出还是监听事件。这就是我们将在服务器和客户端之间进行通信的方式,我们稍后会在本节中详细讨论。
有了这一切,我们的服务器已经准备就绪;我们已经准备好接受新的连接。问题是我们没有任何连接可以接受。当我们加载我们的网页时,我们什么也没做。我们实际上没有连接到服务器。我们需要手动运行一些 JavaScript 代码来启动连接过程。
现在,当我们将 Socket.io 与我们的服务器集成时,我们实际上获得了一些很酷的东西。首先,我们获得了一个接受传入连接的路由,这意味着我们现在可以接受 WebSocket 连接。此外,我们获得了一个 JavaScript 库,这使得在客户端上使用 Socket.io 变得非常容易。这个库可以在以下路径找到:localhost:3000/socket.io/socket.io.js
。如果你在浏览器中加载这个 JavaScript 文件,你会发现它只是一个非常长的 JavaScript 库。
这包含了我们在客户端需要的所有代码,以建立连接和传输数据,无论是从服务器到客户端,还是从客户端到服务器。
为了从我们的 HTML 文件建立连接,我们将加载它。我将返回到 localhost:3000
。现在,我们可以继续进入 Atom,打开 index.html
,并在 body
标签的底部附近,添加一个 script
标签来加载我们刚刚在浏览器中打开的文件。
首先,我们将创建 script
标签本身,打开和关闭它,为了加载外部文件,我们将使用 src
属性来提供路径:
<body>
<p>Welcome to the chat app</p>
<script src=""></script>
</body>
现在,这个路径是相对于我们的服务器的。它将是 /socket.io/socket.io.js
,这正是我们之前在浏览器中输入的。
<script src="img/socket.io.js"></script>
通过添加 script
标签,我们现在加载了这个库。在浏览器中,由于 socket
库的存在,我们可以访问各种可用的方法。其中一个方法将让我们发起连接请求,这正是我们将在下一行中做的。让我们添加第二个 script
标签。这一次,我们不是加载外部脚本,而是直接在这一行中编写一些 JavaScript:
<script src="img/socket.io.js"></script>
<script>
</script>
我们可以添加任何我们喜欢的 JavaScript,这个 JavaScript 将在 Socket.io 库加载后立即运行。稍后,我们将把它拆分成自己的文件,但目前,我们可以简单地将我们的 JavaScript 代码放在 HTML 文件中。我们将调用 io
:
<script src="img/socket.io.js"></script>
<script>
io();
</script>
io
是一个可用的方法,因为我们加载了这个库。它不是浏览器的原生方法,当我们调用它时,实际上是在发起请求。我们从客户端向服务器发出请求,打开一个 WebSocket 并保持连接。现在,我们从 io
得到的东西非常重要;我们将把它保存在一个叫做 socket
的变量中,就像这样:
<script src="img/socket.io.js"></script>
<script>
var socket = io();
</script>
这创建了我们的连接并将 socket 存储在一个变量中。这个变量对于通信至关重要;这正是我们需要的,以便监听来自服务器的数据并向服务器发送数据。现在我们已经做好了这一切,让我们继续保存我们的 HTML 文件。我们将进入浏览器并打开 Chrome 开发者工具。
无论你使用什么浏览器,无论是 IE、Safari、Firefox 还是 Chrome,你都可以访问一组开发者工具,这使得在你的网页背后轻松调试和查看发生的事情。我们将在这里使用 Chrome 开发者工具进行一些调试,我强烈建议在课程中使用 Chrome,这样你可以完全跟上。
要打开开发者工具,我们转到设置|更多工具|开发者工具。您也可以使用特定于您的操作系统的键盘快捷键。打开开发者工具后,您将看到一个令人震惊的选项集,如下所示:
如果您以前从未使用过 Chrome 开发者工具,您很可能会被带到元素面板。我们现在要使用的面板是网络面板。
网络面板跟踪您的网页发出的所有请求。因此,如果我请求 JavaScript 文件,我将在一个漂亮的列表中看到它,就像前面的屏幕截图所示的那样。
我们将不得不刷新页面才能看到网络请求列表;在这里,我们有五个:
顶部的网络请求是第一个发出的请求,底部的是最后一个发出的请求。第一个是localhost:3000
页面的请求,用于加载欢迎来到聊天应用
的 HTML 文件。第二个是我们在浏览器上看到的 JavaScript 文件的请求,它为我们提供了库,并让我们调用启动连接过程的io
方法。接下来的四个都与启动和维护该连接有关。有了这个,我们现在在客户端和服务器之间有了实时连接,我们可以开始传达任何我们想要传达的内容。
客户端和服务器之间的通信
现在,通信可以是任何东西。在这种情况下,它以事件的形式出现。事件可以从客户端和服务器发出,并且客户端和服务器都可以监听事件。让我们来谈谈在电子邮件应用中可能发生的事件。
在电子邮件应用中,当新邮件到达时,服务器可能会发出一个名为newEmail
的事件。然后客户端将监听该事件。当它触发时,它将获取newEmail
数据并将邮件呈现在其他邮件下方的屏幕上。同样的事情也可能发生在另一个方向上:也许客户端想要创建一封新的电子邮件并将其发送给其他人。它将要求输入收件人的电子邮件地址和消息的内容,然后将在客户端上发出一个事件,服务器将监听该事件。因此,整个服务器/客户端关系完全通过这些事件来运行。
现在,我们将在本章中为我们的特定应用程序创建自定义事件;但现在,我们将看一下一些默认内置事件,让您可以跟踪新用户和断开连接的用户。这意味着我们将能够做一些像在用户加入我们的应用程序时问候用户的事情。
io.on 方法
为了在 Atom 中玩耍,我们将在server.js
中调用io
上的一个方法,称为io.on
:
app.use(express.static(publicPath));
io.on();
io.on
方法允许您注册事件侦听器。我们可以监听特定事件,并在该事件发生时执行某些操作。我们将要使用的一个内置事件是最受欢迎的,称为connection
。这使您可以监听客户端与服务器的新连接,并在该连接到来时执行某些操作。为了执行某些操作,您需要提供一个回调函数作为第二个参数,这个回调函数将使用socket
被调用:
io.on('connection', (socket) => {
});
这个socket
参数与我们在index.html
文件中访问的socket
参数非常相似。这代表了单个 socket,而不是连接到服务器的所有用户。现在,有了这个,我们可以做任何我们想做的事情。例如,我可以使用console.log
打印一条消息,比如新用户已连接
:
io.on('connection', (socket) => {
console.log('New user connected');
});
每当用户连接到我们的应用时,我们将在控制台上打印一条消息。我将保存server.js
文件,进入终端,您将看到消息实际上已经存在:
为了解释原因,我们需要了解有关 WebSockets 的一件事。正如我提到的,WebSockets 是一种持久技术,这意味着客户端和服务器都会保持通信渠道打开,只要它们中的任何一个希望保持打开。如果服务器关闭,客户端实际上没有选择,反之亦然。如果我关闭浏览器选项卡,服务器无法强迫我保持连接打开。
现在,当连接断开客户端时,它仍会尝试重新连接。当我们使用nodemon
重新启动服务器时,大约有四分之一秒的时间服务器是关闭的,客户端会注意到这一点。它会说,“哇,哇,哇!服务器宕机了!让我们尝试重新连接!”最终它会重新连接,这就是为什么我们会看到消息New user connected
。
继续关闭服务器,在客户端内部,您将看到网络请求正在 Chrome 开发者工具中进行:
他们正在尝试重新连接到服务器,您可以看到他们失败了,因为服务器没有启动。现在,回到终端,像这样重新启动服务器:
在客户端内部,我们将尝试再次重新连接。我们将从服务器获得成功的结果,然后我们就回来了!就像这样:
现在,当我们重新连接时,您可以看到我们再次收到了消息,这就是我们第一次将其添加到server.js
文件时看到它的原因。
在客户端添加连接事件
现在,连接事件也存在于客户端。这意味着在客户端,当我们成功连接到服务器时,我们可以执行一些操作。它可能不会立即发生;可能需要一点时间。在 Atom 内部,我们可以在index.html
中添加此事件,就在我们对io
的调用下面。如图所示,我们将调用socket.on
:
var socket = io();
socket.on
我们想要监听一个事件,这个事件与我们在server.js
文件中的事件有些不同。它不是on('connection')
,而是on('connect')
:
var socket = io();
socket.on('connect');
这里的on
方法与我们在server.js
中使用的方法完全相同。第一个参数是事件名称,第二个参数是回调函数。在这种情况下,我们不会获得socket
参数的访问权限,因为我们已经将其作为socket
变量。
在这种情况下,我要做的就是使用console.log
在控制台中打印一条小消息,Connected to server
:
socket.on('connect', () => {
console.log('Connected to server');
});
既然我们已经做到了这一点,我们可以进入浏览器并转到开发者工具中的新选项卡。我们将加载控制台选项卡。控制台选项卡有点像 Node 内部的终端。如果我们在客户端 JavaScript 代码中使用console.log
,这些消息将显示在那里。正如您在前面的屏幕截图中所看到的,我们还有一些错误。这些错误发生在我们的服务器关闭时,我正在向您展示它是如何重新连接的;但是如果我们刷新页面,正如您将看到的,Connected to server
会显示出来,如下所示:
一旦连接发生,客户端和服务器都会触发该事件。客户端打印Connected to server
,服务器打印New user connected
。
有了这个设置,我们现在已经在 Socket.io 中使用了事件系统。我们还没有设置自己的自定义事件,但我们已经利用了一些内置事件。
断开连接事件
在本节中我们要讨论的最后一件事是disconnect
事件,它允许您在连接断开时在服务器和客户端上执行某些操作。我们将在客户端上添加一个事件侦听器,并在服务器上执行相同的操作。
在客户端,紧挨着我们的connect
事件,我们可以再次调用socket.on
来监听一个新事件。再次强调,这里的事件名称是一个内置事件的名称,所以只有在您正确输入时才会起作用。这个事件叫做disconnect
:
socket.on('disconnect');
disconnect
事件将在连接断开时触发。如果服务器宕机,客户端将能够执行某些操作。目前,这个操作只是记录一条消息,console.log('与服务器断开连接')
:
socket.on('disconnect', () => {
console.log('Disconnected from server');
});
现在我们已经有了这条消息,我们可以保存我们的index.html
文件。转到浏览器并刷新以加载我们的新 JavaScript 文件。继续让你的浏览器屏幕小一点,这样我们可以在终端的背景中看到它。
我将转到终端,通过关闭服务器关闭连接,在浏览器内,我们得到与服务器断开连接
打印到屏幕上:
如果我在终端内重新启动服务器,你可以看到我们已经自动连接,因为连接到服务器
打印到屏幕上:
现在,服务器上也存在完全相同的事件。我们可以监听断开连接的客户端,并在他们离开时执行某些操作。为了注册这个事件,你需要进入server.js
,在我们的回调内,你需要像在index.html
文件中一样调用socket.on
在server.js
内。它的签名完全相同。第一个参数是事件名称,disconnect
。回调函数应该做一些简单的事情,比如打印客户端断开连接
。
一旦你做到了这一点,我希望你打开浏览器并打开终端,然后关闭浏览器标签。你应该看到消息在服务器上打印出来——无论你在这里输入了什么消息。打开另一个浏览器标签,关闭它,并确保你得到相同的消息。假设浏览器标签有一个打开的连接,每次关闭一个浏览器标签时,这条消息都应该打印出来。
现在,要做到这一点,你只需要复制io.on
方法中使用的完全相同的签名。socket.on
接受两个参数:第一个是我们要监听的事件名称,disconnect
;第二个参数是事件触发时要运行的函数:
socket.on('disconnect', () => {
});
在这种情况下,我们要做的只是使用console.log
打印用户已断开连接
,就像这样:
socket.on('disconnect', () => {
console.log('User was disconnected');
});
然后,我们将保存文件,这将自动重新启动我们的应用程序。切换到终端,然后切换到浏览器,这样你就可以看到后台的终端。我将打开一个新标签,这样当我关闭当前打开的标签时,Chrome 浏览器不会完全关闭。关闭具有打开连接的标签,并且如下截图所示,在终端内,我们得到用户已断开连接
:
如果我打开一个新标签并转到localhost:3000
,那么将打印新用户已连接
。一旦我关闭它,服务器屏幕上将打印用户已断开连接
。希望你开始看到为什么 WebSockets 如此强大——即时的双向通信使任何实时应用程序都变得轻而易举。
现在,让我们用一个提交来结束这一切。我将关闭我们的服务器并运行git status
。我们可以看到我们只有修改过的文件:
所以,使用git commit
和-am
标志将完成工作。我们可以添加我们的消息,添加连接和断开事件处理程序
:
git commit -am 'Add connect and disconnect event handlers'
我将提交并使用git push
命令将其推送到 GitHub。
有了这个,我们就完成了。在下一节中,我们将进入非常有趣的内容——你将学会如何发出和监听自定义事件。这意味着你可以从服务器向客户端发送任何你喜欢的数据,反之亦然。
发出和监听自定义事件
在前一节中,你学会了如何监听那些内置事件——诸如连接事件和断开连接事件。这些都很好,是一个很好的起点,但在这一节中,我们想要讨论的是发出和监听自定义事件,这就是 Socket.io 变得非常有趣的地方。
当你能够发出和监听自定义事件时,你可以从服务器向客户端发送任何你想要的东西,或者从客户端向服务器发送任何你想要的东西。现在,让我们快速看一下这将是什么样子的一个例子,我们将使用一个示例应用程序,这将是一个电子邮件应用程序:
在左边,我们有我们的服务器,它正在启动一个 Socket.io web 服务器。在右边,我们有我们的电子邮件应用程序,它显示了我们所有当前电子邮件的列表。现在,我们的应用可能需要的一个自定义事件是newEmail
事件:
当有电子邮件到达时,服务器将发出newEmail
事件。例如,如果我注册了一个新服务,该服务会发送一封电子邮件给我确认我的电子邮件。然后,服务器最终收到了那封电子邮件,并发出了一个客户端监听的事件。客户端将监听newEmail
事件,并能够使用 jQuery、React、Ember 或者任何它正在使用的库重新渲染浏览器中的电子邮件列表,向我展示新的电子邮件。
除了只发送事件发生的消息之外,最重要的是发送数据,我们实际上可以做到这一点。当你创建并发出自定义事件时,你可以从服务器向客户端发送任何你喜欢的信息,或者从客户端向服务器发送任何你喜欢的信息。通常,这采用一个具有各种属性的对象的形式。在获取新电子邮件的情况下,我可能想知道电子邮件是谁发来的。我肯定需要知道电子邮件的文本,我还想知道电子邮件何时到达我的服务器,这样我就可以在浏览器中为使用电子邮件应用程序的人渲染所需的内容。
现在,这是从服务器流向客户端的数据,这是我们无法通过 HTTP 请求实现的,但使用 Socket.io 是可以实现的。现在,另一个事件,createEmail
事件,将从客户端流向服务器:
当我在网页浏览器中创建一个新的电子邮件时,我需要从客户端发出该事件,服务器将监听该事件。再次,我们将发送一些数据。虽然数据会有所不同,但我们想知道电子邮件需要发送给谁,我们需要电子邮件的文本,也许我们想要安排它在未来的某个时间发送,所以可以使用scheduleTimestamp
字段。
显然,这些只是示例字段;你真正的电子邮件应用程序的字段可能会有所不同。不过,有了这个,我们已经准备好在我们的应用程序中实际创建这两个事件了。
在应用程序中创建自定义事件
让我们开始在我们的应用程序中创建自定义事件,首先创建newEmail
和createEmail
事件。在我们开始发出或监听自定义事件之前,让我们对我们的客户端 JavaScript 进行一些调整。
将 JavaScript 移到一个单独的文件中
正如你在上一节中可能已经注意到的,我在我们的客户端 JavaScript 代码中意外地使用了 ES6 箭头函数。正如我提到的,我们要避免这样做;项目将在 Chrome 中正确工作,但如果你尝试在手机、Internet Explorer、Safari 或某些版本的 Firefox 上加载它,程序将崩溃。因此,我们将使用常规函数来代替箭头函数,即删除箭头并在参数之前添加function
关键字。我将对on('connect'
监听器和on('disconnect'
监听器进行此操作,添加function
关键字并删除箭头:
socket.on('connect', function () {
console.log('Connected to server');
});
socket.on('disconnect', function () {
console.log('Disconnected from server');
});
我还将把我们的 JavaScript 移动到一个单独的文件中。不再直接在我们的 HTML 文件中编辑客户端 JavaScript,而是有一个单独的文件来存放那些代码。这是一个更好的方法来完成事情。
在public
文件夹中,我们可以为这个 JavaScript 文件创建一个新文件夹。我会创建一个叫做js
的文件夹(当这个应用程序结束时,我们将有多个 JavaScript 文件,所以创建一个文件夹来存放所有这些文件是一个好主意)。不过,现在我们只需要一个index.js
。index.js
文件将在加载index.html
时加载,并且它将包含所有所需的 JavaScript 代码,从我们在上一节中编写的 JavaScript 开始。剪切script
标签中的所有代码,并将其粘贴到index.js
中:
var socket = io();
socket.on('connect', function () {
console.log('Connected to server');
});
socket.on('disconnect', function () {
console.log('Disconnected from server');
});
我们可以保存文件并更新我们的script
标签。不再将代码放在一行中,而是通过提供src
属性加载它,路径为/js/index.js
:
<script src="img/socket.io.js"></script>
<script src="img/index.js"></script>
</body>
现在我们已经有了这个,我们有了与之前完全相同的功能——只是这一次,JavaScript 已经被拆分成了自己的文件。使用nodemon server/server.js
启动服务器。一旦启动,我们可以通过浏览器打开localhost:3000
来加载应用程序。我也会打开开发者工具,这样我们就可以确保一切都按预期工作。在控制台中,我们看到Connected to server
仍在打印:
这是存在于index.js
中的代码,它出现在这里的事实证明文件已经被加载。有了这个,我们现在可以继续进行自定义事件。
现在,我们为我们的示例电子邮件应用程序讨论了两个事件:我们有newEmail
,它是从服务器到客户端的;我们还有createEmail
,它是客户端发出并由服务器监听的事件。我们将从newEmail
开始,为了启动这些事情,我们将进入我们的客户端 JavaScript 并监听该事件。
当该事件触发时,我们想要做一些事情:我们想要获取数据并使用 jQuery、React 或其他一些前端框架将其呈现到浏览器中,以便用户可以在收到电子邮件时立即看到它。
添加一个 newEmail 自定义事件
现在,为了监听自定义事件,我们仍然将使用socket.on
;不过,我们不再指定内置事件的名称,而是提供引号内的第一个参数作为我们自定义事件的名称。在这种情况下,该名称将是newEmail
:
socket.on('newEmail');
现在,socket.on
的第二个参数与内置事件监听器的第二个参数相同。我们将提供一个函数,当事件触发时,这个函数将被调用:
socket.on('newEmail', function () {
});
现在,我们在函数内部要做的就是使用console.log
打印一条消息,New email
:
socket.on('newEmail', function () {
console.log('New email');
});
每当客户端听到这个事件传过来时,这将在 Web 开发者控制台中打印出来。现在我们已经为newEmail
设置了监听器,让我们继续在server.js
中发出这个事件。
emit 方法
在server.js
中,我们要做的是在socket
上调用一个方法。socket
方法有一个叫做emit
的方法,我们将在客户端和服务器上都使用它来发出事件:
io.on('connection', (socket) => {
console.log('New user connected');
socket.emit('');
});
emit
方法与监听器非常相似;不过,与监听事件不同,我们是创建事件。第一个参数是相同的。它将是您要发出的事件的名称。在这种情况下,我们必须与我们在index.js
中指定的完全匹配,即newEmail
。现在,如下面的代码所示,我们将提供newEmail
:
io.on('connection', (socket) => {
console.log('New user connected');
socket.emit('newEmail');
});
现在,这不是一个监听器,所以我们不会提供回调函数。我们要做的是指定数据。现在,默认情况下,我们不必指定任何数据;也许我们只是想发出newEmail
而没有任何内容,让浏览器知道发生了某事。如果我们这样做,在浏览器中刷新应用程序,我们会得到New email
,如下面的屏幕截图所示:
即使我们没有发送任何自定义数据,事件仍在发生。如果您确实想发送自定义数据,这很可能是情况,那很容易。您只需为newEmail
提供第二个参数。现在,您可以提供三个、true 或其他任何参数,但通常您希望发送多个数据,因此对象将成为您的第二个参数:
socket.emit('newEmail', {
});
这将让您指定任何您喜欢的内容。在我们的情况下,我们可能通过指定from
属性来指定电子邮件的发件人;例如,它来自mike@example.com
。也许我们还有电子邮件的text
属性,嘿。发生了什么
,我们可能还有其他属性。例如,createdAt
可以是服务器收到电子邮件的时间戳,如下所示:
socket.emit('newEmail', {
from: 'mike@example.com',
text: 'Hey. What is going on.',
createdAt: 123
});
在服务器和客户端之间,前面代码块中显示的数据将随着newEmail
事件一起从服务器发送到客户端。现在,保存server.js
,在我们的客户端 JavaScript index.js
文件中,我们可以对该数据进行操作。与您的事件一起发出的数据将作为回调函数的第一个参数提供。如下面的代码所示,我们有newEmail
的回调函数,这意味着我们可以将第一个参数命名为email
并对其进行任何操作:
socket.on('newEmail', function (email) {
console.log('New email');
});
我们可能会将其附加到真实网络应用程序中的电子邮件列表中,但就我们的目的而言,我们现在要做的就是将其作为console.log
的第二个参数提供,将其呈现到屏幕上:
socket.on('newEmail', function (email) {
console.log('New email', email);
});
有了这个,我们现在可以测试一切是否按预期工作。
测试 newEmail 事件
如果我去浏览器,使用command +* R*进行刷新,我们在控制台中看到New email
,在这之下我们有Object
。我们可以点击Object
来展开它,然后我们可以看到我们指定的所有属性:
我们有我们的from
属性,text
属性和我们的createdAt
属性。所有这些都如预期般显示,这太棒了!实时地,我们能够将事件和事件数据从服务器传递到客户端,这是我们无法通过 HTTP API 实现的。
添加一个 createEmail 自定义事件
另一方面,我们有一个情况,我们希望从客户端发出事件,尝试向服务器发送一些数据。这是我们的createEmail
事件。在这种情况下,我们将在server.js
中使用socket.on
添加我们的事件监听器,就像我们为任何其他事件监听器所做的那样,就像我们在server.js
中所做的那样。
我们用于连接事件的io.on
方法是一个非常特殊的事件;通常您不会将任何内容附加到io
,也不会调用io.on
或io.emit
,除了我们在此函数中提到的内容。我们的自定义事件监听器将在以下语句中发生,通过调用socket.on
来实现,就像我们为disconnect
所做的那样,传递您要监听的事件的名称——在本例中是createEmail
事件:
socket.emit('newEmail', {
from: 'mike@example.com',
text: 'Hey. What is going on.',
createdAt: 123
});
socket.on('createEmail');
现在,对于createEmail
,我们确实想要添加一个监听器。我们在我们的 Node 代码中,所以我们可以使用箭头函数:
socket.on('createEmail', () => {
});
我们可能期望一些数据,比如要创建的电子邮件,所以我们可以命名第一个参数。我们根据事件发送的数据命名它,所以我将称其为newEmail
。对于这个示例,我们将只是将其打印到控制台,以便我们可以确保事件从客户端正确地传递到服务器。我将添加console.log
并记录事件名称createEmail
。作为第二个参数,我将记录数据,以便我可以在终端中查看它,并确保一切按预期工作:
socket.on('createEmail', (newEmail) => {
console.log('createEmail', newEmail);
});
现在我们已经放置了我们的监听器,并且我们的服务器已经重新启动;但是,我们实际上从未在客户端发出事件。我们可以通过在index.js
中调用socket.emit
来解决这个问题。现在,在我们的connect
回调函数中调用它。我们不希望在连接之前发出事件,socket.emit
将让我们做到这一点。我们可以调用socket.emit
来发出事件。
事件名称是createEmail
:
socket.on('connect', function () {
console.log('Connected to server');
socket.emit('createEmail');
});
然后,我们可以将任何我们喜欢的数据作为第二个参数传递。在电子邮件应用程序的情况下,我们可能需要将其发送给某人,因此我们将为此提供一个地址——类似于jen@example.com
。显然,我们需要一些文本——类似于嘿。我是安德鲁
。此外,我们可能还有其他属性,比如主题,但现在我们将只使用这两个:
socket.emit('createEmail', {
to: 'jen@example.com',
text: 'Hey. This is Andrew.'
})
所以,我们在这里所做的是创建一个客户端脚本,将其连接到服务器,一旦连接,就会触发这个createEmail
事件。
现在,这不是一个现实的例子。在真实世界的应用程序中,用户很可能会填写表单。您将从表单中获取先前提到的数据片段,然后发出事件。稍后我们将稍微处理 HTML 表单;不过,现在我们只是调用socket.emit
来玩这些自定义事件。
保存index.js
,在浏览器中,我们现在可以刷新页面。一旦连接,它将触发该事件:
在终端中,您会看到createEmail
打印:
事件是从客户端发出到服务器。服务器收到了数据,一切都很好。
开发者控制台中的 socket.emit
现在,控制台的另一个很酷的功能是,我们可以访问应用程序创建的变量;最重要的是 socket 变量。这意味着在 Google Chrome 中,在开发者控制台中,我们可以调用socket.emit
,并发出我们喜欢的任何内容。
我可以发出一个动作,createEmail
,并且我可以将一些数据作为第二个参数传递,一个对象,其中我有一个等于julie@example.com
的 to 属性。我还有其他属性——类似于text
,我可以将其设置为Hey
:
socket.emit('createEmail', {to: 'julie@example.com', text: 'Hey'});
这是一个示例,说明我们如何使用开发者控制台使调试应用程序变得更加容易。我们可以输入一个语句,按enter,它将继续发出事件:
在终端中,我们将得到该事件并对其进行处理——无论是创建电子邮件还是执行其他任何我们可能需要的操作。在终端中,您可以看到createEmail
出现了。我们将把它发送给朱莉,然后有文本Hey
。所有这些都从客户端到服务器了:
现在我们已经做好了这一切,并且已经玩过了如何使用这些自定义事件,是时候从电子邮件应用程序转移到我们将要构建的实际应用程序了:聊天应用。
聊天应用中的自定义事件
现在你知道如何触发和监听自定义事件,我们将继续创建两个在聊天应用中实际使用的事件。这些将是newMessage
和createMessage
:
现在,对于聊天应用程序,我们再次有我们的服务器,这将是我们构建的服务器;还有我们的客户端,这将是在聊天应用程序中的用户。很可能会有多个用户都想互相交流。
现在,我们将要处理的第一个事件是newMessage
事件。这将由服务器发出,并在客户端上进行监听:
当有新消息进来时,服务器会将其发送给连接到聊天室的所有人,这样他们就可以在屏幕上显示出来,用户可以继续回复。newMessage
事件将需要一些数据。我们需要知道消息是谁发出的;一个人名的字符串,比如Andrew
,消息的文本,比如嘿,你能六点见面吗
,还有一个createdAt
时间戳。
所有这些数据都将在我们的聊天应用程序中在浏览器中呈现。我们马上就要真正做到这一点,但现在我们只是将其打印到控制台。所以,这是我要你创建的第一个事件。你将创建这个newMessage
事件,从服务器发出它——现在,当用户连接时,你可以简单地发出它——并在客户端上进行监听。现在,在客户端上,当你收到数据时,你可以用console.log
打印一条消息。你可以说一些像收到新消息
的话,打印传递的对象。
接下来,我们要处理的第二个事件是createMessage
。这将从客户端发送到服务器。所以如果我是用户 1,我将从浏览器中触发一个createMessage
事件。这将发送到服务器,服务器将向其他人发出newMessage
事件,这样他们就可以看到我的消息,这意味着createMessage
事件将从客户端发出,服务器将监听该事件:
现在,这个事件将需要一些数据。我们需要知道消息是谁发出的,还有文本:他们想说什么?我们需要这两个信息。
现在,请注意这里的一个不一致之处:我们将from
、text
和createdAt
属性发送到客户端,但当他们创建消息时,我们并没有要求客户端提供createdAt
属性。这个createdAt
属性实际上将在服务器上创建。这将防止用户能够伪造消息创建的时间。有一些属性我们将信任用户提供给我们;还有一些我们将不信任他们提供给我们,其中之一将是createdAt
。
现在,对于createMessage
,你所要做的就是在服务器上设置一个事件监听器,等待它触发,然后,你可以再次简单地打印一条消息,例如创建消息
,然后你可以提供传递给console.log
的数据,将其打印到终端上。现在,一旦你放置了监听器,你将想要发出它。你可以在用户首次连接时发出它,你还可以从 Chrome 开发者工具中发出一些socket.emit
调用,确保所有的消息都显示在终端上,监听createMessage
事件。
我们将从server.js
开始,通过监听createMessage
事件来进行处理,这将发生在server.js
中socket.emit
函数的下面。现在,我们有一个来自createEmail
的旧事件监听器;我们可以删除它,并调用socket.on
来监听我们全新的事件createMessage
:
socket.on('createMessage');
createMessage
事件将需要一个在事件实际发生时调用的函数。我们将想要对消息数据进行一些处理:
socket.on('createMessage', () => {
});
目前,你只需要使用console.log
将其打印到终端,以便我们可以验证一切是否按预期工作。我们将得到我们的消息数据,其中包括from
属性和text
属性,并将其打印到屏幕上。你不必指定我使用的确切消息;我只会说createMessage
,第二个参数将是从客户端传递到服务器的数据:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
});
现在我们已经准备好了监听器,我们可以在index.js
中的客户端发出这个事件。现在,我们目前有一个发出createEmail
事件的调用。我将删除这个emit
调用。我们将首先调用socket.emit
,然后调用emit('createMessage')
:
socket.on('connect', function () {
console.log('Connected to server');
socket.emit('createMessage');
});
接下来,我们将使用必要的数据发出createMessage
。
记住,当你发出自定义事件时,第一个参数是事件名称,第二个是数据。
对于数据,我们将提供一个带有两个属性的对象:from
,这个是Andrew
;和text
,这是消息的实际文本,可能是是的,对我来说没问题
:
socket.emit('createMessage', {
from: 'Andrew',
text: 'Yup, that works for me.'
});
这将是我们发出的事件。我将保存index.js
,转到浏览器,我们应该能够刷新应用程序并在终端中看到数据:
如前面的截图所示,在终端中,我们有createMessage
和我们指定的from
属性,以及文本是的,对我来说没问题
。
现在,我们还可以从 Chrome 开发者工具中发出事件,以便使用 Socket.io 进行调试。我们可以添加socket.emit
,并且可以发出任何我们喜欢的事件,传入一些数据:
socket.emit('createMessage', {from: 'Jen', text: 'Nope'});
我们将发出的事件是createMessage
,数据是一个from
属性;这个是Jen
,和一个文本属性,Nope
:
当我发送这条消息时,消息会实时显示在服务器上,如下截图所示,你可以看到它来自Jen
,文本是Nope
,一切都按预期工作:
现在,这是第一个事件;另一个是newMessage
事件,将由服务器发出并由客户端监听。
新消息事件
要开始这个,我们将在index.js
中添加我们的事件监听器。我们有旧的newEmail
事件监听器。我将继续删除它,然后我们将调用socket.on
来监听新事件newMessage
。newMessage
事件将需要一个回调函数:
socket.on('newMessage', function () {
});
目前,我们将使用console.log
将消息打印到控制台,但稍后,我们将获取这条消息并将其添加到浏览器中,以便用户实际上可以在屏幕上看到它。现在,我们将获取消息数据。我将暂时创建一个名为message
的参数,然后我们可以简单地使用console.log
将其打印到屏幕上,打印事件的名称,以便在终端中易于跟踪,以及从服务器传递到客户端的实际数据:
socket.on('newMessage', function (message) {
console.log('newMessage', message);
});
现在,我们唯一需要做的是简单地从服务器发出newMessage
,确保它显示在客户端。在server.js
中,我们将调用socket.emit
,发出我们的自定义事件newMessage
,而不是发出newEmail
:
io.on('connection', (socket) => {
console.log('New user connected');
socket.emit('newMessage');
});
现在,我们需要一些数据——那条消息的数据。我们也将作为第二个参数提供。它将是一个带有from
属性的对象。它可以来自任何人;我会选择John
:
socket.emit('newMessage', {
from: 'John',
});
接下来,我们将提供text
属性。这也可以是任何东西,比如再见
,最后我们将提供createdAt
属性。这将稍后由服务器生成,以便用户无法伪造消息创建的时间,但现在,我们将只使用某种随机数,比如123123
:
socket.emit('newMessage', {
from: 'John',
text: 'See you then',
createdAt: 123123
});
现在,一旦用户连接到服务器,我们将发出该事件。在浏览器中,我可以继续刷新。我们的newMessage
事件显示出来,数据与我们在server.js
文件中指定的完全一样:
我们有我们的createdAt
时间戳,我们的from
属性和我们的text
属性。将来,我们将直接将这些数据渲染到浏览器中,以便显示出来,某人可以阅读并回复,但现在我们已经完成了。我们在服务器上为createMessage
有了事件监听器,并在客户端为newMessage
有了事件监听器。
这就是本节的全部内容!既然我们已经完成,我们将进行快速提交。我将关闭服务器并运行git status
命令:
如前面的屏幕截图所示,我们这里有很多变化。我们在public.js
文件夹中有我们的新的js
文件,我们还改变了server.js
和index.html
。我将运行git add .
命令将所有内容添加到下一个提交中,然后我将使用git commit
和-m
标志创建一个提交。这个提交的一个好消息是Add newMessage and createMessage events
:
git commit -m 'Add newMessage and createMessage events'
有了这个,我们现在可以将我们的代码推送到 GitHub 上。目前不需要在 Heroku 上做任何事情,因为我们还没有任何可视化的东西;我们将推迟到以后再处理。
在下一节中,我们将连接消息,所以当标签页 1 发出消息时,标签页 2 可以看到。这将使我们更接近在不同的浏览器标签页之间实际实时通信。
广播事件
现在我们已经放置了自定义事件监听器和发射器,是时候实际连接消息系统了,所以当一个用户向服务器发送消息时,它实际上会发送给每个连接的用户。如果我打开两个标签页并从一个标签页发出createMessage
事件,我应该在第二个标签页中看到消息到达。
本地测试时,我们将使用单独的标签页,但在 Heroku 上使用单独的浏览器和单独的网络也可以实现相同的效果;只要每个人在浏览器上有相同的 URL,他们就会连接在一起,无论他们在哪台机器上。现在,对于本地主机,我们显然没有正确的权限,但是当我们部署到 Heroku 时,我们将在本节中进行,我们将能够在您的手机和在您的机器上运行的浏览器之间进行测试。
为所有用户连接创建createMessage
监听器
首先,我们将更新createMessage
监听器。目前,我们只是将数据记录到屏幕上。但是在这里,我们不仅要记录它,我们实际上要发出一个新事件,一个newMessage
事件,给每个人,这样每个连接的用户都会收到从特定用户发送的消息。为了完成这个目标,我们将在io
上调用一个方法,即io.emit
:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
io.emit
});
Socket.emit
向单个连接发出事件,而io.emit
向每个连接发出事件。在这里,我们将发出newMessage
事件,将其指定为我们的第一个参数。与socket.emit
一样,第二个参数是要发送的数据:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
io.emit('newMessage', {
})
});
现在,我们知道我们将从客户端得到from
属性和text
属性——这些出现在index.js
中createMessage
事件的socket.emit
中——这意味着我们需要做的是传递这些属性,将from
设置为message.from
,将text
设置为message.text
:
io.emit('newMessage', {
from: message.from,
text: message.text
})
现在,除了from
和text
,我们还将指定一个createdAt
属性,这将由服务器生成,以防止特定客户端伪造消息创建的时间。createdAt
属性设置为new Date
,我们将调用getTime
方法来获取时间戳,这是我们以前做过的:
io.emit('newMessage', {
from: message.from,
text: message.text,
createdAt: new Date().getTime()
});
现在我们已经完成了这一步,我们实际上已经连接了消息。我们可以继续删除server.js
和index.js
中的发出调用——server.js
和index.js
中的newMessage
发出调用和createMessage
发出调用,确保保存两个文件。有了这个,我们可以继续测试,打开两个连接到服务器并发出一些事件。
测试消息事件
我将使用nodemon server/server.js
命令在终端中启动服务器:
在浏览器中,我们现在可以打开两个标签页,都在localhost:3000
。对于两个标签页,我将打开开发者工具,因为那是我们应用程序的图形用户界面。我们目前还没有任何表单,这意味着我们需要使用 Console 标签来运行一些语句。我们将对第二个标签页做同样的事情:
请注意,一旦我们打开标签页,我们将在终端中收到New user connected
的消息:
现在我们有两个标签页打开了,我们可以继续从任何一个标签页发出createMessage
事件。我将从第二个标签页发出,通过调用socket.emit
来发出一个自定义事件。事件名称是createMessage
,它接受我们刚刚讨论过的这两个属性——from
属性和text
属性——我都会在socket.emit
对象中指定。from
属性将设置为第一个名字Andrew
,text
属性将设置为'This should work'
:
socket.emit('createMessage', {from: 'Andrew', text: 'This should work!'});
有了这个,我们现在可以从浏览器中发出我的事件。它将发送到服务器,服务器将把消息发送给每个连接的用户,包括当前连接的用户发送的消息。我们将按下enter,它就会触发,我们会看到我们收到了newMessage
。我们有刚刚创建的消息,但很酷的是,在另一个标签页中,我们也有这条消息:一个用户的消息已经传达到另一个标签页的用户那里:
有了这个,我们现在有了一个非常基本的消息系统:用户发出一个事件,它传递到服务器,服务器将其发送给所有其他连接的用户。有了这个,我想进行提交并部署到 Heroku,这样我们就可以测试一下。
提交并将消息部署到 Heroku
如果我在终端中运行git status
命令,我会看到我有两个预期的更改文件:
然后我可以使用git commit
命令和-am
标志来指定此提交的消息,比如Emit newMessage on createMessage
就可以完成任务:
git commit -am 'Emit newMessage on createMessage'
然后,我可以继续实际进行提交,将其推送到 GitHub 和 Heroku。git push
命令将把它推送到 GitHub 上。
git push heroku master
命令将把它部署到网络上。
我们将能够打开我们的聊天应用程序,并确保它在任何浏览器、计算机或其他变量下都能正常工作:
如前面的截图所示,我们正在压缩并启动应用程序。看起来一切都完成了。我将使用heroku open
命令打开它。这将在我的默认浏览器中打开它,如下面的截图所示,您将看到我们有Welcome to the chat app
:
在 Firefox 浏览器中使用 Heroku 测试消息传递
现在,为了演示这一点,我将打开一个单独的浏览器。我将打开 Firefox 并输入完全相同的 URL。然后,我将复制这个 URL 并打开 Firefox 浏览器,使其变小,这样我们可以快速在两者之间切换,打开 Heroku 应用程序:
现在,Firefox 也可以通过右上角的菜单访问开发者工具。在那里,我们有一个 Web Developer 部分;我们要找的是 Web Console:
现在我们打开了这个,我们可以进入我们连接到 Heroku 应用程序的 Chrome 标签的开发者工具,我们将使用socket.emit
发出一个事件。我们将发出一个createMessage
事件。我们将在对象内指定我们的自定义属性,然后我们可以继续设置from
为Mike
,并且我们可以将text
属性设置为Heroku
:
socket.emit('createMessage', {from: 'Mike', text: 'Heroku'});
现在,当我继续发出这个事件时,一切应该如预期般工作。我们调用socket.emit
并发出createMessage
。我们有我们的数据,这意味着它将发送到 Heroku 服务器,然后发送到 Firefox。我们将发送这个,这应该意味着我们在 Chrome 开发者工具中得到newMessage
。然后,在 Firefox 中,我们也有这条消息。它是来自Mike
,文本是Heroku
,并且我们的服务器添加了createdAt
时间戳:
有了这个,我们有了一个消息系统——不仅在本地工作,而且在 Heroku 上也可以工作,这意味着世界上的任何人都可以访问这个 URL;他们可以发出事件,而所有其他连接的人都将在控制台中看到该事件。
现在我们已经在各个浏览器中测试过了,我将关闭 Firefox,然后我们将继续进行本节的第二部分。
向其他用户广播事件
在本节的这一部分,我们将讨论一种不同的发出事件的方法。有些事件你希望发送给每个人:新消息应该发送给每个用户,包括发送者,这样它才能显示在消息列表中。另一方面,其他事件应该只发送给其他人,所以如果用户一发出一个事件,它不应该返回给用户一,而应该只发送给用户二和用户三。
一个很好的例子是当用户加入聊天室时。当有人加入时,我想打印一条小消息,比如Andrew joined
,当实际加入的用户加入时,我想打印一条消息,比如welcome Andrew
。所以,在第一个标签中,我会看到welcome Andrew
,在第二个标签中,我会看到Andrew joined
。为了完成这个目标,我们将看一种在服务器上发出事件的不同方法。这将通过广播来完成。广播是向除了一个特定用户之外的所有人发出事件的术语。
我将再次使用nodemon server/server.js
命令启动服务器,并且在 Atom 中,我们现在可以调整我们在server.js
中的io.emit
方法中发出事件的方式。现在,这将是我们做事情的最终方式,但我们也会玩一下广播,这意味着我会将其注释掉,而不是删除它:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
//io.emit('newMessage', {
// from: message.from,
// text: message.text,
// createdAt: new Date().getTime()
//});
});
要进行广播,我们必须指定单个套接字。这让 Socket.io 库知道哪些用户不应该收到事件。在这种情况下,我们在这里调用的用户将不会收到事件,但其他人会。现在,我们需要调用socket.broadcast
:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
//io.emit('newMessage', {
// from: message.from,
// text: message.text,
// createdAt: new Date().getTime()
//});
socket.broadcast
});
广播是一个具有自己发射功能的对象,它的语法与io.emit
或socket.emit
完全相同。最大的区别在于它发送给谁。这将把事件发送给除了提到的套接字之外的所有人,这意味着如果我触发一个createMessage
事件,newMessage
事件将发送给除了我自己之外的所有人,这正是我们在这里可以做的。
它将是相同的,这意味着我们可以继续传递消息事件名称。参数将是相同的:第一个将是newMessage
,另一个将是具有我们属性的对象,from: message.from
和text: message.text
。最后,我们有createdAt
等于一个新的时间戳,new Date().getTime
:
socket.broadcast.emit('newMessage', {
from: message.from,
text: message.text,
createdAt: new Date().getTime()
});
有了这个,我们将看不到我们发送的消息,但其他人会看到。我们可以通过转到 Google Chrome 来证明这一点。我会给两个标签都刷新一下,然后从第二个标签再次发出一个事件。我们实际上可以在 Web 开发者控制台中使用上箭头键重新运行我们之前的命令,这正是我们要做的:
socket.emit('createMessage', {from: 'Andrew', text: 'This should work'});
在这里,我们正在发出一个createMessage
事件,其中from
属性设置为Andrew
,text
属性等于This should work
。如果我按enter发送这条消息,你会注意到这个标签不再接收消息:
然而,如果我去localhost:3000
,我们将看到newMessage
显示出消息数据:
这是因为标签二广播了事件,这意味着它只被其他连接接收,比如标签一或任何其他连接的用户。
当用户连接时发出两个事件
有了广播,让我们进入最后一种发出消息的方式。我们将在socket.io
中发出两个事件,就在用户连接时。现在,在这种情况下,我们实际上不会使用广播,所以我们将注释掉广播对象,并取消注释我们的旧代码。它应该看起来像这样:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
io.emit('newMessage', {
from: message.from,
text: message.text,
createdAt: new Date().getTime()
});
// socket.broadcast.emit('newMessage', {
// from: message.from,
// text: message.text,
// createdAt: new Date().getTime()
//});
});
你将首先调用socket.emit
来向加入的用户发出消息。你的消息应该来自管理员,from Admin
,文本应该说一些像Welcome to the chat app
的东西。
现在,除了socket.emit
,你还将调用socket.broadcast.emit
,这将被发送给除了加入的用户之外的所有人,这意味着你可以继续将from
设置为Admin
,并将text
设置为New user joined
:
// socket.emit from Admin text Welcome to the chat app
// socket.broadcast.emit from Admin text New user joined
这意味着当我们加入聊天室时,我们会看到一条问候我们的消息,其他人会看到一条消息,告诉他们有人加入了。这两个事件都将是newMessage
事件。我们将不得不指定from
(即Admin
),text
(即我们说的任何内容)和createdAt
。
向个人用户问候
为了开始,我们将填写第一个调用。这是对socket.emit
的调用,这个调用将负责问候个别用户:
// socket.emit from Admin text Welcome to the chat app
socket.emit
我们仍然会发送一个newMessage
类型的事件,以及来自text
和createdAt
的完全相同的数据。这里唯一的区别是,我们将生成所有属性,而不是像之前那样从用户那里获取其中一些。让我们从from
开始。这个将来自Admin
。每当我们通过服务器发送消息时,我们将调用Admin
,文本将是我们的小消息,Welcome to the chat app
。接下来,我们将添加createdAt
,它将被设置为通过调用Date().getTime
方法的new Date
:
socket.emit('newMessage', {
from: 'Admin',
text: 'Welcome to the chat app',
createdAt: new Date().getTime()
});
稍后,我们将以姓名问候他们。目前我们没有这些信息,所以我们将坚持使用通用的问候语。有了这个调用,我们可以删除注释,然后继续进行第二个调用。这是广播调用,将提醒除了加入的用户之外的所有其他用户,有新人来了。
在聊天中广播新用户
为了在聊天中广播新用户,我们将使用socket.broadcast.emit
,并发出一个newMessage
事件,提供我们的属性。from
属性再次将被设置为Admin
字符串;text
将被设置为我们的小消息,New user joined
;最后是createdAt
,它将通过调用Date().getTime
方法设置为new Date
:
// socket.broadcast.emit from Admin text New user joined
socket.broadcast.emit('newMessage', {
from: 'Admin',
text: 'New user joined',
createdAt: new Date().getTime()
})
现在我们可以删除第二个调用的注释,一切应该如预期的那样工作。你需要做的下一件事是测试所有这些是否按预期工作,进入浏览器。你可以有几种方法来做到这一点;只要你做到了,实际上并不重要。
测试用户连接
我将关闭我的两个旧标签,并在访问页面之前打开开发者工具。然后,我们可以去localhost:3000
,我们应该在开发者工具中看到一条小消息:
在这里,我们看到了一条新消息,Welcome to the chat app
,打印出来,这太棒了!
接下来,我们想要测试广播是否按预期工作。对于第二个标签,我还会打开开发者工具并再次转到localhost:3000
。再次,我们收到了我们的小消息,“欢迎来到聊天应用”:
如果我们转到第一个标签,我们还会看到有新用户加入,这也太棒了!
现在,我将提交以保存这些更改。让我们关闭服务器并使用git status
命令:
然后,我们可以继续运行带有-am
标志的git commit
命令,并指定消息,“向新用户打招呼并提醒其他人”:
git commit -am 'Greet new user, and alert others'
一旦提交就位,我们可以使用git push
命令将其推送到 GitHub。
现在没有必要立即部署到 Heroku,尽管如果你感兴趣,你可以轻松部署和测试。有了这个,我们现在完成了!
总结
在本章中,我们研究了 Socket.io 和 WebSockets,以实现服务器和客户端之间的双向通信。我们致力于设置一个基本的 Express 服务器、后端和前端,并将其提交到 GitHub 和 Heroku。接下来,我们研究了如何向应用程序添加socket.io
以建立服务器和客户端之间的通信。
然后,我们研究了在应用程序内发出和监听自定义事件。最后,我们通过广播事件来连接消息系统,这样当一个用户向服务器发送消息时,实际上会发送给每个连接的用户,但不包括发送消息的用户。
有了这一切,我们现在有了一个基本但有效的消息系统,这是一个很好的开始!在下一章中,我们将继续添加更多功能并构建用户界面。
第六章:生成 newMessage 和 newLocationMessage
在上一章中,我们研究了 Socket.io 和 WebSockets,以实现服务器和客户端之间的双向通信。在本章中,我们将讨论如何生成文本和地理位置消息。我们研究了生成newMessage
和newLocationMessage
对象,然后为两种类型的消息编写了测试用例。
消息生成器和测试
在本节中,您将把server.js
中的一些功能分解成一个单独的文件,并且我们还将设置我们的测试套件,以便我们可以验证这些实用函数是否按预期工作。
目前,我们的目标是创建一个帮助我们生成newMessage
对象的函数。我们将不再需要每次都定义对象,而是只需将两个参数传递给一个函数,即名称和文本,它将生成对象,这样我们就不必做这项工作了。
使用实用函数生成 newMessage 对象
为了生成newMessage
,我们将制作一个单独的文件,然后将其加载到server.js
中,而不是定义对象。在server
文件夹中,我们将创建一个名为utils
的新目录。
在utils
中,我们将创建一个名为message.js
的文件。这将存储与消息相关的实用函数,而在我们的情况下,我们将创建一个名为generateMessage
的新函数。让我们创建一个名为generateMessage
的变量。这将是一个函数,并将使用我之前提到的两个参数,from
和text
:
var generateMessage = (from, text) => {
};
然后它将返回一个对象,就像我们在server.js
中作为第二个参数传递给 emit 的对象一样。现在我们需要做的就是return
一个对象,指定from
作为 from 参数,text
作为 text 参数,以及createdAt
,它将通过调用new Date
并调用其getTime
方法来生成:
var generateMessage = (from, text) => {
return {
from,
text,
createdAt: new Date().getTime()
};
};
有了这个,我们的实用函数现在已经完成。我们需要做的就是在下面导出它,module.exports
。我们将把它设置为一个对象,该对象具有一个generateMessage
属性,该属性等于我们定义的generateMessage
变量:
var generateMessage = (from, text) => {
return {
from,
text,
createdAt: new Date().getTime()
};
};
module.exports = {generateMessage};
最终,我们将能够将其集成到server.js
中,但在这样做之前,让我们先编写一些测试用例,以确保它按预期工作。这意味着我们需要安装 Mocha,并且还需要安装 Expect 断言库。然后我们将设置我们的package.json
脚本并编写测试用例。
编写测试用例
首先,在终端中,我们将使用npm install
安装两个模块。我们需要 Expect,这是我们的断言库,版本为@1.20.2
,以及mocha
来运行我们的测试套件,版本为5.0.5
。然后,我们将使用--save-dev
标志将它们添加为开发依赖项:
npm install expect@1.20.2 mocha@5.0.5 --save-dev
让我们运行这个命令,一旦完成,我们就可以进入package.json
并设置这些测试脚本。
它们将与我们在上一章的上一个项目中使用的测试用例相同。
在package.json
中,我们现在有两个dev
依赖项,在脚本中,我们可以通过删除旧的测试脚本来开始。我们将添加这两个脚本,test
和test-watch
:
"scripts": {
"start": "node server/server.js",
"test": "echo "Error: no test specified" && exit 1",
"test-watch": ""
},
添加 test-watch 脚本
让我们先填写基础知识。我们将把test
设置为空字符串,然后是test-watch
。我们知道,test-watch
脚本只是调用nodemon
,调用npm test
脚本,nodemom --exec
,然后在单引号内调用npm test
:
"scripts": {
"start": "node server/server.js",
"test": "",
"test-watch": "nodemon --exec 'npm test'"
},
这将完成任务。现在当我们在这里运行nodemon
时,我们实际上正在运行全局安装的nodemon
;我们也可以在本地安装它来修复这个问题。
为了完成这个任务,我们要做的就是运行npm install nodemon
,添加最新版本,即版本1.17.2
,并使用--save-dev
标志进行安装:
npm install nodemon@1.17.2 --save-dev
现在当我们像这样安装 nodemon
时,我们的应用程序不再依赖于全局的 nodemon
安装。因此,如果其他人从 GitHub 获取这个应用程序,他们将能够开始而无需全局安装任何东西。
添加测试脚本
接下来是 test
脚本。它首先必须设置我们将要配置的环境变量;我们稍后会这样做。现在,我们要做的只是运行 mocha
,传入我们要测试的文件的模式。
我们想要测试的文件在 server
目录中。它们可以在任何子目录中,所以我们将使用 **
,而文件,无论它们的名称如何,都将以 test.js
结尾:
"scripts": {
"start": "node server/server.js",
"test": "mocha server/**/*.test.js",
"test-watch": "nodemon --exec 'npm test'"
},
有了这个设置,我们就完成了。现在我们可以运行我们的测试套件。
运行消息实用程序的测试套件
在终端中,如果我运行 npm test
,我们将看到的是我们没有任何测试:
这里有 server-test
文件的 globbing 模式;它无法解析任何文件。我们可以通过简单地添加一个测试文件来解决这个问题。我将为消息实用程序添加一个测试文件,message.test.js
。现在我们可以继续重新运行 npm test
命令。这一次它确实找到了一个文件,我们看到我们没有通过测试,这是一个很好的起点:
在 message.test.js
中,我们需要为刚刚定义的消息函数添加一个测试。现在这个测试将验证我们得到的对象是否符合我们根据传入的参数所期望的。我们将一起设置测试文件的基本结构,然后你将编写单个测试用例。
首先,我们需要使用 var expect = require('expect')
加载 Expect。这将让我们对从我们的 generateMessage
函数返回的值进行断言:
var expect = require('expect');
接下来我们要做的是添加一个 describe
块。在这里,我们将为函数 generateMessage
添加一个 describe
块,并在回调函数中添加该函数的所有测试用例:
describe('generateMessage', () => {
});
在我们实际创建测试用例并填写之前,我们确实需要加载我们正在测试的模块。我将创建一个变量并使用 ES6 解构。我们将取出 generateMessage
,然后我们可以使用 require
来引入它,指定本地路径 ./message
:
var expect = require('expect');
var {generateMessage} = require('./message');
describe('generateMessage', () => {
});
它与我们当前所在的测试文件相同的目录中,所以没有理由进行任何目录移动。有了这个设置,我们现在可以添加单个测试用例,it ('should generate the correct message object')
。这将是一个同步测试,因此无需提供 done。你只需要调用 generateMessage
传入两个值,from
和 text
。你将得到响应,并将响应存储在变量中:
describe('generateMessage', () => {
it('should generate correct message object', () => {
//store res in variable
});
});
然后你将对响应进行一些断言。首先,断言 from 是正确的,断言 from 与你传入的值匹配。你还将断言文本匹配,最后你将断言 createdAt
值是一个数字:
var expect = require('expect');
var {generateMessage} = require('./message');
describe('generateMessage', () => {
it('should generate correct message object', () => {
// store res in variable
// assert from match
// assert text match
// assert createdAt is number
});
});
它不管是什么数字;你将使用 toBeA
方法来检查类型并断言 createdAt
是数字。为了完成这个任务,我将首先定义一些变量。
首先,我将创建一个 from 变量来存储 from 的值。我将使用 Jen
。我还将创建一个 text
变量来存储文本值,Some message
。现在我想做的是创建我的最终变量,它将存储响应,即从 generateMessage
函数返回的 message
,这正是我要调用的。我将调用 generateMessage
,传入两个必要的参数,from
参数和 text
参数:
describe('generateMessage', () => {
it('should generate correct message object', () => {
var from = 'Jen';
var text = 'Some message';
var message = generateMessage(from, text);
接下来,最后一件事,我们需要对返回的对象进行断言。我期望message.createdAt
是一个使用toBeA
和传入类型number
的数字:
describe('generateMessage', () => {
it('should generate correct message object', () => {
var from = 'Jen';
var text = 'Some message';
var message = generateMessage(from, text);
expect(message.createdAt).toBeA('number');
这是你需要做的第一个断言,以验证属性是否正确。接下来,我们将期望该消息内部具有某些属性。我们将使用toInclude
断言来做到这一点,尽管你可以创建两个单独的语句:一个用于message.from
,另一个用于message.text
。所有这些都是有效的解决方案。我将只使用toInclude
并指定消息应该包含的一些内容:
expect(message.createdAt).toBeA('number');
expect(message).toInclude({
});
首先,它应该有一个from
属性等于from
变量。我们可以继续使用 ES6 来定义;对于text
,text
应该等于text
,我们将使用 ES6 来设置。我们甚至可以使用from, text
来进一步简化这个过程:
expect(message.createdAt).toBeA('number');
expect(message).toInclude({from, text});
有了这个,我们的测试用例现在已经完成,我们可以继续删除这些注释轮廓,你需要做的最后一件事是通过在终端运行npm test
来运行测试套件。当我们这样做时,我们会得到什么?我们得到了我们在generateMessage
下的一个测试,应该生成正确的消息对象,它确实通过了,这太棒了:
现在我们有一些测试来验证我们的函数是否按预期工作,让我们继续将其集成到我们的应用程序中,方法是进入server.js
,并用我们的新函数调用替换传递给 emit 函数的所有对象。
将实用函数集成到我们的应用程序中
这个过程的第一步是导入我们刚刚创建的函数。我将在server.js
中创建一个常量来做到这一点。我们将使用 ES6 解构来获取generateMessage
,并且我们将从require
的调用中获取它。现在我们正在要求一个不同目录中的本地文件。我们将从./
开始,进入utils
目录,因为我们当前在server
目录中,然后通过指定文件名message
来获取它:
const socketIO = require('socket.io');
const {generateMessage} = require('./utils/message');
现在我们可以访问generateMessage
,而不是创建这些对象,我们可以调用generateMessage
。在socket.emit
中,我们将用参数generateMessage ('Admin', 'Welcome to the chat app')
替换Welcome to the chat app
和Admin
变量:
socket.emit('newMessage', generateMessage('Admin', 'Welcome to the chat app'));
我们有完全相同的功能,但现在我们使用一个函数来为我们生成该对象,这将使得扩展变得更容易。这也将使得更新消息内部的内容变得更容易。接下来,我们可以更改下面的New user joined。我们也将用对generateMessage
的调用来替换这个。
这次也是来自Admin
,所以第一个参数将是字符串Admin
,第二个参数是文本New user joined
:
socket.emit('newMessage', generateMessage('Admin', 'Welcome to the chat app'));
这个也完成了,最后一个是实际从用户那里发送给用户的,这意味着我们有message.from
和message.text
;这些将是我们的参数。我们将使用这两个参数message.from
和message.text
调用generateMessage
作为第二个参数:
socket.on('createMessage', (message) => {
console.log('createMessage', message);
io.emit('newMessage', generateMessage('Admin', 'New user joined'));
有了这个,我们就完成了。这一部分剩下的最后一件事是测试它是否按预期工作。我将使用nodemon
启动服务器,node
和mon
之间没有空格,server/server.js
:
nodemon server/server.js
一旦服务器启动,我们可以通过打开几个带有开发者工具的标签页来测试一下。
对于第一个标签页,我将访问localhost:3000
。在控制台中,我们应该看到我们的新消息打印出来,即使它现在是由函数生成的,对象看起来是一样的,我们也可以通过打开第二个标签页并打开其开发者工具来测试其他一切是否按预期工作:
这一次,第一个选项卡应该看到一个新消息,这里有一个New user joined
的文本,仍然有效。如果我们从第二个选项卡发出自定义消息,它应该出现在第一个选项卡中。我将使用上箭头键运行我们之前的createMessage
事件发射器之一。
我将触发这个函数,如果我去第一个选项卡,我们确实会收到消息,这太棒了:
这应该有效,在第一个选项卡中打印,也会在第二个选项卡中打印,因为我们调用的是io.emit
而不是广播方法。
现在一切都正常了,我们完成了;我们可以提交并结束这一部分。我将从终端调用git status
。这里我们有新文件和修改过的文件,这意味着我们需要调用git add .
。接下来,我们可以调用git commit
并使用消息标志,create generateMessage utility
:
git commit -m 'create generateMessage utility'
我将把这个推送到 GitHub,这就是这个部分的全部内容。在下一节中,我们将看一下Socket.io
的确认。
事件确认
在这一节中,你将学习如何使用事件确认。这是Socket.io
中的一个很棒的功能。为了准确说明它们是什么以及为什么你想要使用它们,我们将快速浏览一下聊天应用程序的图表。这是我们应用程序中实际存在的两个事件,如果你还记得,第一个是 newMessage 事件,它由服务器发出,并由客户端监听,它发送 from、text 和 createdAt 属性,所有这些属性都是必需的,以便将消息呈现到屏幕上。
我们要更新的事件是 createMessage 事件。这个事件由客户端发出,服务器监听:
我们再次从文本中发送一些数据。现在我们的 createMessage 事件存在的问题是数据只能单向流动。数据来自浏览器内的表单,然后发送到服务器,服务器就有点卡住了。当然,数据可能是有效的,from 和 text 字段可能设置正确。在这种情况下,我们可以发出 newMessage 事件,将其呈现给连接到服务器的每个浏览器,但是如果服务器接收到无效数据,它就无法让客户端知道出了什么问题。
我们需要一种确认我们收到请求并有选项发送一些数据的方法。在这种情况下,我们将为 createMessage 添加一个确认。如果客户端发出有效的请求,并且 from 和 text 属性有效,我们将确认它,发送回无错误消息。如果从客户端发送到服务器的数据无效,我们将确认它,发送回错误消息,这样客户端就知道需要做什么才能发送有效的请求。现在结果看起来会有点像这样,服务器到客户端的数据流将通过回调完成:
你的确认可以是任何你喜欢的。在我们的情况下,它可能是消息数据有效吗?如果你正在创建一个电子邮件应用程序,你可能只在成功发送电子邮件时向客户端发送确认。当有效数据通过管道发送时,你不需要发送数据,这就是当有效数据发送时我们要做的。我们只需要说,嘿,我们收到了那条消息,一切都很顺利,客户端可以对此做出响应。
既然我们已经完成了这一部分,让我们继续将其实现到我们的应用程序中。
设置确认
如果你已经有一个监听器,设置确认真的不难。你只需要快速更改监听器和发射器,一切都会按预期工作。
现在,在这种情况下,监听器恰好在服务器上,发射器将在客户端上,但确认也可以在另一个方向上工作。我可以从服务器发射一个事件,并且可以在客户端上确认它。
为了设置这个,我们将使用socket.emit
在index.js
中发射一个createMessage
事件,并且我们将传递相同的参数。第一个是事件名称,createMessage
,然后我们将传递一些有效的数据,一个具有这两个属性的对象。我们可以将from
设置为Frank
,并且我们可以将text
属性设置为Hi
:
socket.emit('createMessage', {
from: 'Frank',
text: 'Hi'
});
现在有了这个,我们有了一个标准的事件发射器和一个标准的事件监听器。我可以继续使用nodemon
启动应用程序,确保一切都按预期工作,nodemon server/server.js
:
nodemon server/server.js
一旦服务器启动,我们可以在浏览器中访问它,我也会打开开发者工具。然后我们将转到localhost:3000
,你可以看到在终端中我们有createMessage
显示出来,我们还有newMessage
显示在这里。我们有newMessage
用于我们的小Welcome to the chat app
问候语,以及我们从Frank
那里发射的newMessage
:
现在这里的目标是从服务器发送一个确认回到客户端,证明我们已经收到了数据。
从服务器发送确认到客户端
为了完成这个任务,我们必须对监听器和发射器进行更改。如果你只对其中一个进行更改,它将不会按预期工作。我们将从事件发射器开始。我们希望在从服务器发送确认到客户端时运行一些代码。
更新事件发射器
为了从服务器向客户端发送确认,我们将添加一个第三个参数,这将是一个回调函数。当确认到达客户端时,这个函数将被触发,我们可以做任何我们喜欢的事情。现在我们只是使用console.log('Got it')
打印:
socket.emit('createMessage', {
from: 'Frank',
text: 'Hi'
}, function () {
console.log('Got it');
});
现在这就是我们需要做的最基本的事情,为客户端添加一个确认。
更新事件监听器
在服务器上也很简单;我们将在callback
参数列表中添加第二个参数。第一个仍然是被发射的数据,但第二个将是一个我们将称之为callback
的函数。我们可以在socket.on
中的任何地方调用它来确认我们已经收到了请求:
socket.on('createMessage', (message, callback) => {
console.log('createMessage', message);
io.emit('newMessage', generateMessage(message.from, message.text));
callback();
当我们调用这个函数时,就像我们现在要调用它一样,它将会向前端发送一个事件,然后会调用index.js
中的事件发射器中的函数。
这意味着如果我保存这两个文件,我们可以在浏览器中玩一下确认。我将刷新应用程序,我们会得到什么?我们得到了 Got it:
这意味着我们的数据成功传输到了服务器;我们可以通过在终端中看到console.log
语句来证明这一点,服务器通过调用回调函数确认它已经收到了数据:
在开发者工具中,Got it 打印出来了。
现在确认是非常有用的,但当你发送数据回去时,它们会更有用。例如,如果消息的数据无效,我们可能会想要发送一些错误回去,这是我们稍后将要做的事情。不过,现在我们可以通过发送任何我们想要的东西来玩一下确认。
通过向回调提供一个参数来发送数据回去,如果你想添加多个东西,只需指定一个对象,添加尽可能多的属性。不过,在我们的情况下,我们可以将一个字符串作为callback
的唯一参数发送。我将把我的字符串设置为This is from the server
:
socket.on('createMessage', (message, callback) => {
console.log('createMessage', message);
io.emit('newMessage', generateMessage(message.from, message.text));
callback('This is from the server.');
});
这个字符串将被传递到回调函数中,并最终出现在我们的index.js
回调中。这意味着我可以为该值创建一个变量,我们可以称之为data
或者其他你喜欢的名称,并且我们可以将其打印到屏幕上或者对其进行操作。现在我们只是打印到屏幕上:
socket.emit('createMessage', {
from: 'Frank',
text: 'Hi'
}, function (data) {
console.log('Got it', data);
});
如果我保存index.js
,我们可以测试一切是否按预期工作。我将继续刷新应用程序,我们会看到什么?
我们看到了“收到”,这意味着我们收到了确认,我们也看到了数据,从服务器发送到客户端的数据。
确认在实时应用程序中扮演着重要的角色。让我们回到电子邮件应用程序的例子,想象一下,当我发送电子邮件时,我键入了一些值,比如收件人和文本值。我希望得到一个确认,要么是电子邮件成功发送,要么是电子邮件未发送,这种情况下我想知道原因;也许是表单错误,我可以向用户显示一些错误消息,或者服务器正在维护中等等。
无论如何,确认允许请求监听器向请求发射器发送一些内容。现在我们知道如何使用确认,我们将把它们整合到我们的应用程序中。这将在下一节中进行,我们将在index.html
文件中添加一个实际的表单字段,用户可以提交新消息并查看它们。
消息表单和 jQuery
在这一节中,你将向你的index.html
文件中添加一个表单字段。这将在屏幕上呈现一个输入字段和一个按钮,用户将能够与之交互,而不是必须从开发者工具中调用socket.emit
,这对于真实用户来说并不是一个可持续的选项。这只对我们开发人员有效。
现在,为了开始,我们将编辑index.html
,然后我们将转到index.js
。我们将添加一个监听器,等待表单提交,然后在该监听器回调中,我们将使用表单中键入的数据来触发socket.emit
。我们还将花一些时间将所有传入的消息呈现到屏幕上。在本节结束时,我们将拥有一个丑陋但工作的聊天应用程序。
使用 jQuery 库
在我们做任何操作之前,我们将使用一个名为 jQuery 的库来进行 DOM 操作,这意味着我们希望能够处理我们呈现的 HTML,但我们希望能够从我们的 JavaScript 文件中进行操作。我们将使用 jQuery 来使跨浏览器兼容性更容易。为了获取这个库,我们将前往 Google Chrome,转到jquery.com,然后你可以获取最新版本。版本对于这里并不重要,因为我们使用的是所有版本中可用的非常基本的功能:
我将获取最新版本 3.3.1。然后我将右键单击并在新标签中打开压缩的生产版本进行下载:
这里有我们想要加载到我们应用程序中的实际 JavaScript,这意味着我们可以右键单击某个空白区域,点击“另存为”,然后进入我们的项目文件夹,桌面
| node-chat-app
| public
| js
。在js
文件夹中,我将创建一个名为libs
的新目录,我们将在其中存储第三方 JavaScript。在这个目录中保存,关闭标签以及下载区域,现在我们可以继续加载到index.html
中并添加我们的表单。
在 index.html 中添加表单字段
在这里,就在socket.io
和index.js
之间,我们要添加一个新的脚本标签来加载 jQuery。我们必须指定src
属性,路径是/js/libs
,后面跟着一个斜杠和文件名jquery-3.3.1.min.js
:
<script src="img/socket.io.js"></script>
<script src="img/jquery-3.3.1.min.js"></script>
<script src="img/index.js"></script>
现在让我们设置我们的form
标签;这将把我们的表单字段呈现到浏览器上。如果你对这些标签不熟悉,那没关系,跟着做,我会一边解释。
设置表单标签
第一步,我们需要一个form
标签;这会创建一个用户可以提交的表单。这正是我们要用来提交我们的消息的。在这个form
标签上,我们要添加一个属性;就是id
属性,它让我们给这个元素一个唯一的标识符,这样以后用 JavaScript 就很容易定位它:
<form id>
</form>
记住,我们要给这个元素添加一个监听器。当表单被提交时,我们要在我们的 JavaScript 文件中做一些事情。特别是我们要做的是调用socket.emit
。
我要把id
设置为,引号内,message-form
:
<form id="message-form">
</form>
现在我们的表单标签完成了,我们可以在里面添加一些标签。首先,我们要添加一个button
,它会出现在form
的底部
。这个button
在点击时会提交form
。我打开并关闭我的标签,然后在里面可以输入任何我想要出现在button
上的文本。我要选择Send
:
<form id="message-form">
<button>Send</button>
</form>
添加文本字段
现在我们的button
就位了,唯一需要做的就是添加一个小文本字段。这将是用户输入消息的文本字段。这将需要我们使用一个input
标签,而不是打开和关闭一个input
标签,我们将使用自关闭的语法:
<form id="message-form">
<input/>
<button>Send</button>
</form>
因为我们不需要像button
或form
那样在里面放任何东西,我们要给input
添加很多属性,首先是name
,我们要给这个字段一个唯一的名称,类似message
就可以了。我们还要设置类型。input
标签有很多不同的类型。类型可以包括复选框之类的,或者在我们的情况下,我们要在引号内使用的类型是text
:
<input name="message" type="text"/>
我们要添加到input
的最后一个属性叫做placeholder
。我们要把这个值设置为,引号内,一个字符串。在用户实际输入值之前,这个字符串会以浅灰色呈现在字段中。我要告诉用户这就是他们的Message
的地方:
<form id="message-form">
<input name="message" type="text" placeholder="Message"/>
<button>Send</button>
</form>
有了这个,我们实际上可以测试一下我们表单的渲染。
测试表单的渲染
我们可以通过启动服务器使用nodemon
来进行测试:
nodemon server/server.js
服务器已经启动,我要访问 Google Chrome,然后转到localhost:3000
。你会注意到一些很酷的东西,我实际上还没有访问过这个 URL,但你可以看到连接已经发生了。Chrome 进行了一些懒加载,如果它认为你要去一个 URL,它实际上会发出请求;所以当我访问它时,它加载得更快。现在如果我访问localhost:3000
,我们会得到什么?
我们得到了我们的小表单,我们可以输入一个消息,比如Test
,然后发送出去。现在默认情况下,表单非常老式。如果我试图提交这个表单,它实际上会进行完整的页面刷新,然后会把数据,比如我们的消息文本,作为查询字符串添加到 URL 上。这不是我们想要做的,我们想要在表单提交时运行一些自定义 JavaScript。所以我们要附加一个自定义事件监听器并覆盖默认行为。为了完成这个,我们需要使用 jQuery,并且需要选择这个form
字段。
使用 jQuery 选择元素
在我们深入研究index.js
之前,让我们简要谈一下如何使用jQuery
来选择元素。jQuery
,可以通过jQuery
变量访问,将您的选择器作为其参数。然后,我们将添加一个字符串,我们可以选择我们的元素。例如,如果我们想在屏幕上选择所有段落标签,我们将在引号中输入p
:
jQuery('p');
这些与 CSS 选择器非常相似,如果您熟悉它们的话,如图所示,我们已经选择了我们的段落标签。
我还可以选择程序中的所有div
,或者可以按 ID 或类选择元素,这就是我们要做的。为了通过 ID 选择元素,我们首先以井号(#
)开始,然后输入名称。在我们的情况下,我们有一个名为message-form
的form
,如果我执行这个操作,我们确实会得到它:
这将允许我们添加一个事件监听器。
将选择器元素添加到 index.js
在index.js
中,我们将在底部附近添加完全相同的选择器,jQuery
,使用我们的选择器#message-form
进行调用。现在我们将添加一个事件监听器,事件监听器看起来与我们的Socket.io
事件监听器非常相似。我们将调用on
,并且我们将提供这两个参数,事件名称在引号内,submit
,和一个function
,当用户尝试提交form
时将触发该function
:
jQuery('#message-form').on('submit', function(){
});
现在,与我们的Socket.io
事件监听器不同,我们将在function
中得到一个参数,一个e
事件参数,并且我们需要访问它。我们需要访问这个事件参数,以覆盖导致页面刷新的默认行为。在这里,我们将调用e.preventDefault
:
jQuery('#message-form').on('submit', function(){
e.preventDefault();
});
preventDefault
方法可以阻止事件的默认行为,默认情况下,提交事件会经过页面刷新过程。
我们可以通过进入 Google Chrome,刷新页面来测试一切是否正常。我还将从 URL 中删除查询字符串。现在我们可以输入一些消息,比如test
,点击发送,您会看到什么都没有发生。之所以什么都没有发生,是因为我们覆盖了默认行为,要使某些事情发生,我们只需要在index.js
中调用socket.emit
。我们将发出createMessage
:
jQuery('#message-form').on('submit', function(){
e.preventDefault();
socket.emit('createMessage', {
});
});
然后,我们将继续提供我们的数据。现在,from
字段的名称暂时只是大写的User
。我们暂时将其保留为匿名,尽管稍后我们将对其进行更新。现在对于文本字段,这将来自form
。我们将要添加一个选择器并获取值。让我们使用jQuery
来做到这一点:
socket.emit('createMessage', {
from: 'User',
text: jQuery('')
})
});
我们将再次调用jQuery
,并且我们将选择index.html
文件中的输入。我们可以通过其名称name="message"
来选择它:
<input name="message" type="text" placeholder="Message"/>
为了完成这个任务,我们将在index.js
中的socket.emit
中打开括号,将name
设置为message
。这将选择任何具有name
属性等于message
的元素,这就是我们的一个元素,我们可以使用.val
方法获取其值:
socket.emit('createMessage', {
from: 'User',
text: jQuery('[name=message]').val();
})
});
由于我们在对象创建内部,不需要分号。有了这个,我们现在可以继续添加我们的回调函数以进行确认。目前它实际上并没有做任何事情,但这完全没问题。我们必须添加它以满足我们当前设置的确认:
jQuery('#message-form').on('submit', function (e) {
e.preventDefault();
socket.emit('createMessage', {
from: 'User',
text: jQuery('[name=message]').val()
}, function () {
})
});
现在我们已经设置了事件监听器,让我们继续测试一下。
测试更新事件监听器
我将回到 Chrome,刷新页面,输入一些消息,比如This should work
,当我们提交表单时,我们应该在这里看到它显示为新消息:
我将发送它,你可以看到在终端内,我们有一个用户发送This should work
,它也显示在 Chrome 中:
如果我打开第二个连接,情况也是如此,我将打开开发者工具,这样我们就可以看到幕后发生了什么。我将输入一些消息,比如From tab 2
,发送出去:
我们应该在选项卡 1 中看到它,我们确实看到了:
完美,一切都按预期工作。现在显然设置还没有完成;我们希望在发送消息后清除表单值,并且我们希望处理一些其他与用户界面相关的事情,但目前它运行得相当好。
有了一个基本的表单,我们要做的第二件事是将传入的消息渲染到屏幕上。现在再次看起来可能会很丑,但它会完成工作。
将传入的消息渲染到屏幕上
为了完成这个任务,我们必须在我们的index.html
文件内创建一个地方,我们可以在其中渲染消息。再次,我们将给这个元素一个 ID,这样我们就可以在index.js
内部轻松访问它,以便渲染这些消息。
创建一个有序列表来渲染消息
首先,我们要做的是创建一个有序列表,方法是创建一个ol
标签,就像这样:
<body>
<p>Welcome to the chat app</p>
<ol></ol>
这个列表将允许我们向其中添加项目,这些项目将是单独的消息。现在我们将给它一个id
属性。在这种情况下,我将称之为messages
:
<ol id="messages"></ol>
现在这就是我们在index.html
中需要做的全部,所有的重活将在index.js
内部进行。当有新消息到达时,我们希望在有序列表内添加一些内容,以便将其渲染到屏幕上。
在index.js
内部,当新消息到达时,我们可以通过修改回调函数来完成这个任务。
使用 jQuery 在 index.js 中创建元素
我们要做的第一件事是创建一个列表项,我们将再次使用 jQuery 来完成这个任务。我们将创建一个变量,这个变量将被称为li
,然后我们将稍微不同地使用 jQuery:
socket.on('newMessage', function (message) {
console.log('newMessage', message);
var li = jQuery();
});
我们不再使用jQuery
来选择元素,而是使用jQuery
来创建一个元素,然后我们可以修改该元素并将其添加到标记中,使其可见。在引号内,我们将打开和关闭一个li
标签,就像我们在index.html
中一样:
socket.on('newMessage', function (message) {
console.log('newMessage', message);
var li = jQuery('<li></li>');
});
现在我们已经完成了这一步,我们必须继续设置它的文本属性,我将通过调用li.text
来设置li.text
,并传入我想要使用的值。
在这种情况下,文本将要求我们设置一个小模板字符串,在模板字符串内,我们将使用返回的数据。现在我们将使用from
属性和text
属性。让我们从from
开始,然后添加一个小冒号和一个空格来将其与实际的message
分开,最后,我们将在末尾注入message.text
:
var li = jQuery('<li></li>');
li.text(`${message.from}: ${message.text}`);
现在,我们已经创建了一个元素,但我们还没有将它渲染到 DOM 中。我们将使用jQuery
来选择我们创建的全新元素,我们给它一个 ID 为messages
,然后我们将通过调用append
方法向其添加一些内容:
var li = jQuery('<li></li>');
li.text(`${message.from}: ${message.text}`);
jQuery('#messeges').append
这将把它添加为其最后一个子元素,因此列表中已经有三个项目;最新的项目将显示在这三个项目下方,作为有序列表中的第四个项目。我们只需要调用append
作为一个函数,传入我们的列表项:
var li = jQuery('<li></li>');
li.text(`${message.from}: ${message.text}`);
jQuery('#messeges').append(li);
});
有了这个设置,我们就完成了。现在,如果你不熟悉jQuery
,这可能有点令人不知所措,但我保证我们在这里使用的技术将贯穿整本书。到最后,你会更加舒适地选择和创建元素。
测试传入的消息
让我们继续在 Google Chrome 中测试。我将刷新标签 1,当我这样做时,你可以看到我们的两条消息,欢迎来到聊天应用显示出来,Frank 说 Hi:
现在欢迎来到聊天应用应该显示出来。Frank Hi 消息来自index.js
中的socket.emit
:
socket.emit('createMessage', {
from: 'Frank',
text: 'Hi'
}, function (data) {
console.log('Got it', data);
});
我们实际上可以去掉它,我们不再需要自动发送消息,因为我们已经设置了一个form
来完成这项工作。再次保存文件,刷新浏览器,这一次我们有了一个很好的设置,欢迎来到聊天应用:
我将为我们的第二个标签做同样的事情。这一次我们会得到欢迎来到聊天应用,在第一个标签中我们会得到新用户加入;这太棒了:
现在真正的测试将是从一个标签发送消息到另一个标签,“这应该发送到标签 2”。我将发送这条消息,当我点击这个按钮时,它将触发事件发送到服务器,服务器将把它发送给所有连接的人:
在这里,我可以看到“This should go to tab 2”被渲染出来,在我的第二个标签中也收到了这条消息:
现在我们的 UI 或实际用户体验还没有完成;自定义名称和时间戳即将到来,但我们已经有了一个很棒的开始。现在我们有一个表单,我们可以提交消息,并且我们可以在浏览器中看到所有传入的消息,这意味着我们不需要在开发者工具中再做任何关于发送或阅读消息的工作。就是这样,让我们继续通过做出一些工作变更来做出提交。
为消息表单做出提交
我将关闭服务器,清除输出,并运行git status
,以便我们可以仔细检查所有的更改;一切看起来都很好:
我将使用git add
命令将所有文件添加到仓库中,包括我的未跟踪的 jQuery 文件。然后我使用git commit
进行commit
。我将在这里使用-m
标志,这次的好消息是添加消息表单并在浏览器中显示传入消息
:
git commit -m 'Add form for messages and show incoming messages in browser'
一旦我们完成这一步,我们就可以将其push
到 GitHub 上。现在我们有了一些真实的、可见的、有形的东西可以使用,我要花一点时间部署到 Heroku,git push heroku master
就可以搞定:
一旦这个就绪,我们就可以在浏览器中访问它。正如你在我的控制台中看到的,Socket.io
正在尝试重新连接到服务器。不幸的是,我们不会再次将其带回来,所以它会尝试更长时间。
我们在这里,正在验证部署,一切都正常运行。你可以运行heroku open
或直接复制 URL。我将关闭我的两个本地主机标签,然后打开实际的 Heroku 应用。
在这里,我们得到了欢迎来到聊天应用的消息,我们也得到了我们的表单;一切看起来都很好。我将继续打开另一个浏览器,比如 Safari。我也会去聊天应用,然后把这些窗口并排放在一起。在 Safari 中,我会输入一条小消息,“这是在 Heroku 上实时的”,点击发送或按下enter键,它立即出现在另一个浏览器的另一个标签中。这是因为我们的实时 socket 服务器正在传输这些数据:
这可能发生在世界上的任何一台计算机上,你不需要在我的机器上,因为我们使用的是真实的 Heroku URL。现在在 Heroku 上一切都正常了,我们完成了。
地理位置
在本节中,您将开始地理位置的两部分系列的第一部分。我们不仅仅是互发文本,还将设置它,以便我可以将我的实际坐标,即我的经度和纬度,发送给连接到聊天应用程序的其他所有人。然后我们可以呈现一个链接,该链接可以指向任何我们喜欢的地方;在我们的情况下,我们将设置它以打开一个 Google 地图页面,其中标记了发送其位置的用户的实际位置。
现在,为了实际获取用户的位置,我们将使用地理位置 API,在客户端 JavaScript 中可用,并且实际上是一个非常受支持的 API。它在所有现代浏览器上都可用,无论是移动设备还是桌面设备,可以通过谷歌搜索“地理位置 API”找到文档,并查找 MDN 文档页面。
MDN 文档,或者 Mozilla 开发者网络,是我最喜欢的客户端技术文档,例如您的 Web API、CSS 和 HTML 指南:
现在,正如我所提到的,这是一个受支持的功能,除了较旧版本的 Internet Explorer 和 Opera Mini 浏览器外,您几乎可以在任何地方使用它。但是,所有主要的桌面和移动浏览器都将支持此功能,如果浏览器过旧,我们将设置一个小消息来告诉他们他们的浏览器不支持地理位置。如果您想了解更多关于地理位置的信息,或者探索我们在本节中未涵盖的功能,您可以参考此页面,尽管我们将使用地理位置提供的大多数功能。
将发送位置按钮添加到应用程序
首先,我们要做的是向我们的应用程序添加一个新按钮。它将与发送按钮并排,并且会显示类似“发送位置”的内容。当用户点击发送位置按钮时,我们将使用地理位置 API。通常,这将需要用户确认他们是否要与浏览器中的此标签共享其位置,弹出框将会出现,这将由浏览器触发,没有其他方法可以绕过这一点。
您需要确保用户确实希望共享他们的位置。一旦您获得了坐标,您将发出一个事件,该事件将发送到服务器,服务器将将其发送给所有其他连接的用户,我们将能够以一个良好的链接呈现该信息。
首先,我们将添加该按钮,这将是启动整个过程的按钮。在 Atom 中的index.html
中,我们将在我们现有的form
标签下方添加一个按钮。它将位于我们现有的表单之外。我们将添加button
标签,并为其分配send-location
的 ID。至于可见的button
文本,我们可以使用Send Location
作为我们的字符串,并保存文件:
<form id="message-form">
<input name="message" type="text" placeholder="Message"/>
<button>Send</button>
</form>
<button id="send-location">Send Location</button>
如果我们继续在浏览器中刷新应用程序,现在应该会看到我们的发送位置按钮显示出来:
稍后我们将在添加默认样式时修复所有这些问题,但现在这确实完成了工作。
目前,单击此按钮不会执行任何操作,它与form
没有关联,因此不会执行任何奇怪的form
提交或页面重新加载。我们只需要向此按钮添加一个click
监听器,就可以运行任何我们喜欢的代码。在我们的情况下,我们将运行地理位置代码。
给发送位置按钮添加点击监听器
我们将在 Atom 中的index.js
中添加一个click
监听器,并在底部附近添加一些代码。
现在我想做的第一件事是创建一个变量,我将把这个变量称为locationButton
;这将存储我们的选择器。这是一个 jQuery 选择器,它指向我们刚刚创建的按钮,因为我们需要多次引用它,并且将它存储在一个变量中可以节省再次调用的需要。我们将像我们为其他选择器所做的那样调用jQuery
,传入一个参数,一个字符串,我们通过 ID 选择了某个东西,这意味着我们必须以#
开始,并且实际的 ID 是send-location
:
var locationButton = jQuery('#send-location');
现在我们已经准备就绪,可以做任何我们喜欢的事情。在我们的情况下,我们要做的是添加一个点击事件,并且我们希望当有人点击按钮时做一些事情。为了完成这个目标,我们将转到locationButton.on
:
var locationButton = jQuery('#send-location');
locationButton.on
这与执行jQuery
相同,选择 IDsend-location
,这两者都会做同样的事情。第一个解决方案的好处是我们有一个可重用的变量,以后我们会引用它。对同一个选择器进行两次 jQuery 调用会浪费时间,因为它需要 jQuery 来操作 DOM,获取信息,这是很昂贵的。
locationButton.on
将是我们的事件监听器。我们正在监听click
事件,第一个参数是引号内的click
事件,第二个参数像往常一样将是我们的function
:
var locationButton = jQuery('#send-location');
locationButton.on('click', function () {
});
当有人点击按钮时,这个函数将被调用。
检查对地理位置 API 的访问权限
现在我们要做的只是检查用户是否有访问地理位置 API 的权限。如果没有,我们希望继续打印一条消息。
我们将创建一个if
语句。地理位置 API 存在于navigator.geolocation
上,如果它不存在,我们想运行一些代码:
var locationButton = jQuery('#send-location');
locationButton.on('click', function () {
if(navigator.geolocation){
}
});
所以我们要翻转它。如果navigator
上没有地理位置对象,我们要做一些事情。我们将使用return
来防止函数的其余部分执行,并且我们将调用在所有浏览器中都可用的alert
函数,弹出一个默认的警报框,让你点击OK
:
if(navigator.geolocation){
return.alert()
}
我们将使用这个,而不是一个更复杂的模态框。如果你使用类似 Bootstrap 或 Foundation 的东西,你可以实现它们内置的工具。
不过,现在我们将使用alert
,它只需要一个参数(一个字符串,你的消息)您的浏览器不支持地理位置
:
var locationButton = jQuery('#send-location');
locationButton.on('click', function ()
if (!navigator.geolocation) {
return alert('Geolocation not supported by your browser.');
}
现在,不支持此功能的用户将看到一条小消息,而不是想知道是否真的发生了什么。
获取用户的位置
为了实际获取用户的位置,我们将使用地理位置上可用的一个函数。为了访问它,我们将在locationButton.on
函数中的if
语句旁边添加navigator.geolocation.getCurrentPosition
。getCurrentPosition
函数是一个启动过程的函数。它将主动获取用户的坐标。在这种情况下,它将根据浏览器找到坐标,并且这需要两个函数。第一个是你的success
函数,我们可以在这里添加我们的第一个回调。这将使用位置信息调用,我们将把这个参数命名为position
:
navigator.geolocation.getCurrentPosition(function (position) {
}
});
getCurrentPosition
的第二个参数将是我们的错误处理程序,如果出现问题。我们将创建一个function
,当我们无法使用alert
获取位置时,我们将向用户发出一条消息。让我们继续调用alert
第二次,打印一条消息,比如无法获取位置
:
navigator.geolocation.getCurrentPosition(function (position) {
}, function() {
alert('Unable to fetch location.');
});
});
这将打印if
有人被提示与浏览器共享位置,但他们点击了拒绝。我们将说嘿,如果你不给我们那个权限,我们就无法获取位置
。
现在唯一剩下的情况是成功的情况。这是我们要“发出”事件的地方。但在这之前,让我们继续简单地将其记录到屏幕上,这样我们就可以窥探一下“位置”参数内部发生了什么:
navigator.geolocation.getCurrentPosition(function (position) {
console.log(position);
}, function () {
alert('Unable to fetch location.');
});
});
我将把这个记录到屏幕上,我们的服务器将重新启动,在 Google Chrome 中,我们可以打开开发者工具,刷新页面,然后点击“发送位置”按钮。现在这将在桌面和移动设备上运行。一些移动浏览器可能需要您使用 HTTPS,这是我们将为 Heroku 设置的内容,正如您所知,Heroku 的 URL 是安全的,这意味着它在本地主机上不起作用。您可以通过将应用程序部署到 Heroku 并在那里运行来测试移动浏览器。不过,现在我将能够点击“发送位置”。这将继续进行该过程;该过程最多可能需要一秒钟:
现在您可以看到,我确实获得了我的地理位置。但我从未被询问是否要共享我的位置;那是因为我已经获得了许可。在右上角,我可以点击“清除这些设置以供将来访问”,这意味着我需要重新授权。如果我刷新页面并再次点击“发送位置”,您将看到这个小框,这可能会出现在您的页面上。您可以选择阻止它,如果我阻止它,它将打印“无法获取位置”;或者您可以接受它。
我将再次清除这些设置,刷新页面,这次我将接受位置共享,然后我们将在控制台中打印出地理位置:
现在一旦我们得到它,我们就可以继续深入,对象本身非常简单,我们有一个时间戳,精确记录了我们获取数据的时间,如果您要跟踪用户的活动,这将非常有用,但我们不需要。我们还有我们的坐标,还有一些我们不打算使用的属性,比如“准确度”,“高度”,这些都不存在,还有其他相关的属性。我们还有“速度”,它是null
。我们将从这个对象中使用的唯一两个是“纬度”和“经度”,它们确实存在。
这是我们想要传递给服务器的信息,以便服务器可以将其发送给其他人。这意味着我们将进入position
对象,进入coords
对象,并获取这两个。
在用户位置中添加坐标对象
让我们在 Atom 中继续进行,我们将调用socket.emit
并emit
一个全新的事件,一个我们尚未注册的事件。我们将称之为createLocationMessage
:
navigator.geolocation.getCurrentPosition(function (position) {
socket.emit('createLocationMessage', {
});
});
createLocationMessage
事件不会采用标准文本;相反,它将采用那些“经度”和“纬度”坐标。我们将指定它们两个,从“纬度”开始;我们要将“纬度”设置为position.coords.latitude
。这是我们在控制台内探索的变量,我们将对“经度”做同样的操作,将其设置为position.coords.longitude
:
navigator.geolocation.getCurrentPosition(function (position) {
socket.emit('createLocationMessage', {
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
既然我们已经做好了准备,我们实际上可以继续在服务器上监听这个事件,当我们收到它时,我们要做的是将上述数据传递给所有连接的用户。
将坐标数据传递给连接的用户
让我们继续在server.js
中注册一个新的事件监听器。我将删除旧的已注释掉的broadcast
调用,因为在createMessage
中不再需要。就在createMessage
下面,我们将再次调用socket.on
,指定一个监听器来监听这个事件createLocationMessage
,就像我们在index.js
中定义的那样。现在我们使用 ES6,因为我们在 Node 中,这意味着我们可以设置箭头函数。我们将有一个参数,这将是coords
,然后我们可以继续完成箭头函数。
socket.on('createMessage', (message, callback) => {
console.log('createMessage', message);
io.emit('newMessage', generateMessage(message.from, message.text));
callback('This is from the server.');
});
socket.on('createLocationMessage', (coords) => {
});
在这里,我们将能够运行任何我们喜欢的代码。目前我们要做的只是通过调用emit
一个newMessage
事件传递坐标,尽管在本章的后面,我们将会做得更好,设置谷歌地图的 URL。不过,现在我们要调用io.emit
,emit
一个newMessage
事件,并通过调用generateMessage
提供必要的数据:
socket.on('createLocationMessage', (coords) => {
io.emit('newMessage', generateMessage)
});
目前,generateMessage
将采用一些虚假的用户名,我将输入管理员
,并且我们将设置文本,目前我们只是将其设置为坐标。让我们使用模板字符串来设置它。我们将首先注入纬度
,它在coords.latitude
上可用,然后我们将继续添加逗号、空格,然后我们将注入经度
,coords.longitude
:
socket.on('createLocationMessage', (coords) => {
io.emit('newMessage', generateMessage('Admin', `${coords.latitude}, ${coords.longitude}`));
});
现在我们已经设置了这个调用,位置信息将在用户之间传递,我们可以继续证明这一点。
在浏览器中,我将刷新此页面,并且我还将打开第二个标签页。在第二个标签页中,我将点击“发送位置”。它不会提示我是否要共享我的位置,因为我已经告诉它我要与这个标签页共享我的位置。您可以看到我们有我们的管理员消息和我们的纬度
和经度
:
我们也在第二个标签页中有它。如果我拿到这些信息,我们实际上可以谷歌一下,证明它按预期工作。在本章的后面,我们将设置一个漂亮的链接,因此这些信息不可见;它会在那里,但用户实际上不需要知道坐标,他们真正想要的是地图的链接。这就是我们要设置的,但现在我们可以把这个放在谷歌上,谷歌会准确显示我们的位置,坐标确实是正确的。我在费城,这意味着这些本地主机标签的位置被正确获取。
呈现可点击链接,而不是文本坐标
到目前为止,我们已经让数据流动起来,现在我们要让它变得更有用。我们不再将“纬度”和“经度”信息呈现为文本,而是要呈现为可点击的链接。用户将能够点击该链接;当他们从他人那里收到位置时,它将把他们带到谷歌地图上,他们将能够准确查看其他用户的位置。这比简单输出文本“纬度”和“经度”要有用得多。
为了完成这个,我们需要调整如何传输坐标数据。我们发送数据的方式在index.js
中仍然可以,我们仍然会emit
,createLocationMessage
。但是在server.js
中,我们不再需要发出新消息,而是需要完全发出其他内容。我们将设置一个名为newLocationMessage
的新事件,我们将emit
它,然后在index.js
中,我们将编写一个处理程序newLocationMessage
,类似于newMessage
但有明显不同。它不会呈现一些文本,而是帮助我们呈现一个链接。
整理 URL 结构
为了开始之前,我们必须确切地弄清楚我们将使用什么样的 URL 结构来获取数据,纬度
和经度
信息,在 Google 地图中正确显示。实际上有一种非常统一的设置 URL 的方式,这将使得这个过程非常容易。
为了向您展示我们将要使用的确切 URL,让我们继续打开一个新标签。URL 将转到https://www.google.com/maps
。现在从这里我们将提供一个查询参数,查询参数将指定;它被称为q
:
它将期望纬度
和经度
是由逗号分隔的值。现在我们实际上在localhost:3000
标签中有这个。虽然逗号之间会有一点空间,但无论如何我们都可以复制该值,返回到另一个标签,粘贴进去,并删除空格。
有了这个,我们现在有一个可以在我们的应用程序中使用的 URL。现在当我按下enter,我们将在正确的位置看到地图,但您会注意到 URL 已经改变了。这完全没问题;只要我们将用户发送到这个 URL,它最终变成什么并不重要。我要按下enter;您可以立即看到我们得到了一个谷歌地图,当页面加载时,URL 确实会改变。
现在我们看到的与我们输入的完全不同,但实际的图钉,红色的图钉,它在几栋房子内是正确的。有了这个知识,我们可以生成一个遵循相同格式的 URL,在网站内部输出它,我们将有一个可点击的链接,别人可以查看别人的位置。
发出 newLoactionMessage
要开始,让我们继续进入 Atom 到server.js
,而不是发出newMessage
事件,我们将发出newLocationMessage
:
socket.on('createLocationMessage', (coords) => {
io.emit('newLocationMessage', generateMessage('Admin', `${coords.latitude}, ${coords.longitude}`));
});
现在我们在index.js
中没有处理程序,但这完全没问题,我们稍后会在本节中设置。现在我们还需要改变我们发送的数据。目前,我们发送的是纯文本数据;我们想要生成一个 URL。我们实际上将创建一个完全独立的函数来生成位置消息,并且我们将称之为generateLocationMessage
。
io.emit('newLocationMessage', generateLocationMessage('Admin', `${coords.latitude}, ${coords.longitude}`));
现在这个函数将需要一些参数来生成数据;就像我们为generateMessage
函数所做的那样,我们将从名称开始,然后转到这个函数特定的数据,那将是纬度
和经度
。
我要删除我们的模板字符串,我们将传入原始值。第一个值将是coords.latitude
,第二个值将是coords.longitude
。现在是第二个坐标值,但确实是第三个参数:
io.emit('newLocationMessage', generateLocationMessage('Admin', coords.latitude, coords.longitude));
有了这个参数列表设置,我们实际上可以继续定义generateLocation
。我们将能够导出它,在这个文件中要求它,然后一切都会按预期工作。让我们继续在添加到消息文件之前在顶部加载它。我们将同时加载generateLocationMessage
和generateMessage
:
const {generateMessage, generateLocationMessage} = require('./utils/message');
让我们保存server.js
并进入我们的message
文件。
在 message.js 文件中添加 generateLocationMessage
现在我们即将创建的函数将看起来非常类似于这个,我们将输入一些数据,然后返回一个对象。最大的区别是我们也将生成该 URL。而不是from
,text
和createdAt
,我们将有from
,URL
和createdAt
。
我们可以创建一个新变量,我们可以称这个变量为generateLocationMessage
,然后我们可以设置它等于一个接受这三个参数from
,latitude
和longitude
的函数:
var generateLocationMessage = (from, latitude, longitude)
现在我们可以完成箭头函数(=>
)添加箭头和我们的花括号,里面我们可以开始通过返回空对象:
var generateLocationMessage = (from, latitude, longitude) => {
return {
};
};
现在我们将设置来自属性的这三个属性,URL 属性和createdAt
。这里from
将很容易;就像我们为generateMessage
所做的那样,我们只需引用参数。URL 将会有点棘手;现在我们将把它设置为一个空的模板字符串,我们稍后会回来。最后,createdAt
,我们以前做过;我们将把它设置为通过获取new Date
并调用getTime
来获得时间戳:
var generateLocationMessage = (from, latitude, longitude) => {
return {
from,
from,
url: ``,
createdAt: new Date().getTime()
};
};
现在对于 URL,我们需要使用刚刚在浏览器中输入的完全相同的格式,www.google.com/maps
。然后我们必须设置我们的查询参数,添加我们的问号和q
参数,将其设置为latitude
后跟一个逗号,然后是longitude
。我们将注入latitude
,添加一个逗号,然后注入longitude
:
var generateLocationMessage = (from, latitude, longitude) => {
return {
from,
url: `https://www.google.com/maps?q=${latitude},${longitude}`,
createdAt: new Date().getTime()
};
};
现在我们完成了!generateLocationMessage
将按预期工作,尽管您稍后将编写一个测试用例。现在我们可以简单地导出它。我将导出generateLocationMessage
,就像这样:
var generateLocationMessage = (from, latitude, longitude) => {
return {
from,
url: `https://www.google.com/maps?q=${latitude},${longitude}`,
createdAt: new Date().getTime()
};
};
module.exports = {generateMessage, generateLocationMessage};
现在数据将通过调用emit
从客户端流出,传入generateLocationMessage
。我们将获取latitude
和longitude
。在server.js
中,我们将使用我们刚刚在generateLocationMessage
中定义的对象emit
newLocationMessage
事件:
socket.on('createLocationMessage', (coords) => {
io.emit('newLocationMessage', generateLocationMessage('Admin', coords.latitude, coords.longitude));
});
为newLocationMessage
添加事件监听器
将最后一块拼图真正使所有这些工作起来的是为newLocationMessage
事件添加一个事件监听器。在index.js
中,我们可以调用socket.on
来做到这一点。我们将传入我们的两个参数。首先是我们想要监听的事件名称newLocationMessage
,第二个和最后一个参数是我们的function
。一旦事件发生,这将被调用与message
信息:
socket.on('newLocationMessage', function (message) {
});
现在我们有了这个,我们可以开始生成我们想要输出给用户的 DOM 元素,就像我们上面做的一样,我们将制作一个列表项,并在其中添加我们的锚标签,我们的链接。
我们将创建一个名为list item
的变量,并使用jQuery
创建一个新元素。作为第一个参数,我们将传入我们的字符串,并将其设置为列表项:
socket.on('newLocationMessage', function (message) {
var li = jQuery('<li></li>');
});
接下来,我们可以继续创建我们需要的第二个元素。我将创建一个变量,将这个变量称为a
,用返回值再次设置为对jQuery
的调用。这一次我们将创建锚标签。现在锚标签使用a
标签,标签内的内容,那就是链接文本;在我们的情况下,我们将选择My current location
:
socket.on('newLocationMessage', function (message) {
var li = jQuery('<li></li>');
var a = jQuery('<a>My current location</a>');
});
现在我们将在锚标签上指定一个属性。这将是一个非动态属性,意味着它不会来自消息对象,这个将被称为target
,我们将把target
设置为"_blank"
:
var a = jQuery('<a target="_blank">My current location</a>');
当你将目标设置为_blank
时,它告诉浏览器在新标签页中打开 URL,而不是重定向当前标签页。如果我们重定向当前标签页,我将被踢出聊天室。如果我点击了其中一个目标设置为blank
的链接,我们将简单地打开一个新标签页来查看 Google 地图信息:
socket.on('newLocationMessage', function (message) {
var li = jQuery('<li></li>');
var a = jQuery('<a target="_blank">My current location</a>');
});
接下来,我们将设置这些属性的一些属性。我们将使用li.text
设置文本。这将让我们设置人的名字以及冒号。在模板字符串中,我们将注入值message.from
。在该值之后,我们将添加一个冒号和一个空格:
var a = jQuery('<a target="_blank">My current location</a>');
li.text(`${message.from}: `);
接下来,我们将继续更新我们的锚标签,a.attr
。你可以使用这种方法在你选择的 jQuery 元素上设置和获取属性。如果你提供一个参数,比如target
,它会获取值,这种情况下它会返回字符串_blank
。如果你指定两个参数,它实际上会设置值。在这里,我们可以将href
的值设置为我们在message.url
下的 URL:
li.text(`${message.from}: `);
a.attr('href', message.url)
现在你会注意到,对于所有这些动态值,我不是简单地将它们添加到模板字符串中。相反,我使用这些安全方法,比如li.text
和a.attribute
。这可以防止任何恶意行为;如果有人试图注入 HTML,他们不应该使用这段代码进行注入。
有了这个,我们现在可以将锚标签附加到列表项的末尾,这将在我们使用li.append
设置文本后添加它,并且我们将附加锚标签。现在我们可以使用完全相同的语句将所有这些添加到 DOM 中,以便在newLocagtionMesaage
事件监听器中进行复制和粘贴:
socket.on('newLocationMessage', function (message) {
var li = jQuery('<li></li>');
var a = jQuery('<a target="_blank">My current location'</a>);
li.text(`${message.from}: `);
a.attr('href', message.url);
li.append(a);
jQuery('#messages').append(li);
});
有了这个,我们就完成了。现在我要保存index.js
并在浏览器中重新启动。我们做了很多改动,所以如果你有一些拼写错误也没关系;只要你能找到它们,就没什么大不了的。
我将在 Chrome 浏览器中刷新我的两个标签页;这将使用最新的客户端代码启动新的连接,并开始发送一个简单的消息从第二个标签到第一个标签。它在第二个标签中显示出来,如果我切换到第一个标签,我们会看到用户:测试。现在我可以点击“发送位置”,这将花费一到三秒钟来获取位置。然后它将通过Socket.io
链,我们得到了什么?我们得到了链接“我的当前位置”显示给用户一:
对于用户二也是一样。现在如果我点击那个链接,它应该在一个全新的标签页中打开,里面包含正确的 URL、纬度
和经度
信息。
就在这里,我们有了点击“发送位置”按钮的用户的位置。有了这个,我们有了一个很棒的地理位置功能。你所要做的就是点击按钮;它会获取你当前的位置,无论你在哪里,然后渲染一个可点击的链接,这样任何其他人都可以在 Google 地图中查看它。现在在我们离开之前,我希望你为这个全新的generateLocationMessage
函数添加一个单独的测试用例。
为generateLocationMessage
添加测试用例
在终端中,我可以关闭服务器并使用clear
来清除输出。如果我使用npm test
运行我们的测试套件,我们会看到我们有一个测试,并且它通过了:
你的工作是在message.test.js
中添加第二个测试用例。
我们将一起开始。就在这里,我们将添加一个describe
块,描述generateLocationMessage
函数,你将负责在回调函数内添加一个测试用例:
describe('generateLocationMessage', () => {
});
在这里,你将调用it ('should generate correct location object')
。接下来,我们可以继续添加我们的函数,这将是一个同步测试,所以不需要添加done
参数:
describe('generateLocationMessage', () => {
it('should generate correct location object', () => {
});
});
现在,我们将编写一个与generateMessage
事件非常相似的测试用例,尽管不是传递from
和text
,而是传递from
、latitude
和longitude
。然后你将对返回的值进行一些断言。然后我们将运行测试用例,确保一切都通过了终端。
为测试用例添加变量
首先,我将创建两个变量。我将创建一个from
变量,并将其设置为Deb
之类的内容。然后我们可以继续创建一个latitude
变量,我将其设置为15
。然后我们可以创建一个longitude
变量,将其设置为19
之类的内容:
describe('generateLocationMessage', () => {
it('should generate correct location object', () => {
var from = 'Deb';
var latitude = 15;
var longitude = 19;
});
});
然后我将最终创建一个url
变量。url
变量将是最终结果,我期望得到的 URL。现在该 URL 将在引号内www.google.com/maps
,然后我们将根据我们要传入的信息添加适当的查询参数。如果纬度是15
,我们期望在等号后得到15
,如果经度是19
,我们期望在逗号后得到19
:
describe('generateLocationMessage', () => {
it('should generate correct location object', () => {
var from = 'Deb';
var latitude = 15;
var longitude = 19;
var url = 'https://www.google.com/maps?q=15,19';
});
});
现在我们已经准备好了,我们可以调用我们的函数存储响应。我将创建一个名为message
的变量,然后我们将调用generateLocationMessage
,目前不需要,我们可以在下一秒钟内完成。然后我们将传入我们的三个参数from
,latitude
和longitude
:
describe('generateLocationMessage', () => {
it('should generate correct location object', () => {
var from = 'Deb';
var latitude = 15;
var longitude = 19;
var url = 'https://www.google.com/maps?q=15,19';
var message = generateLocationMessage(from, latitude, longitude);
});
});
现在让我们继续并且也执行generateLocationMessage
和generateMessage
:
var expect = require('expect');
var {generateMessage, generateLocationMessage} = require('./message');
现在唯一剩下的事情就是进行我们的断言。
为generateLocationMessage
进行断言
我们将以类似的方式开始。我实际上要将这两行从generateMessage
复制到generateLocationMessage
的测试用例中:
expect(message.createdAt).toBeA('number');
expect(message).toInclude({from, text});
我们期望message.createdAt
属性是一个数字,它应该是,然后我们期望消息包含一个from
属性等于Deb
,我们期望它有一个url
属性等于我们定义的url
字符串:
describe('generateLocationMessage', () => {
it('should generate correct location object', () => {
var from = 'Deb';
var latitude = 15;
var longitude = 19;
var url = 'https://www.google.com/maps?q=15,19';
var message = generateLocationMessage(from, latitude, longitude);
expect(message.createdAt).toBeA('number');
expect(message).toInclude({from, url});
});
});
如果这两个断言都通过了,那么我们就知道从generateLocationMessage
返回的对象是正确的。
运行generateLocationMessage
的测试用例
我将在终端中重新运行测试套件,一切都应该如预期般工作:
就是这样了!我们已经设置好了地理位置,我们的链接已经呈现,我们可以继续进行。我将在终端中添加一个commit
。我将运行clear
命令来清除Terminal
输出,然后我们将运行git status
来查看所有更改的文件,然后我们可以使用git commit
和-am
标志为此添加一条消息,Add geolocation support via geolocation api
:
git commit -am 'Add geolocation support via geolocation api'
我将继续提交并将其推送到 GitHub,并且我们还可以花一点时间将其部署到 Heroku,使用git push heroku master
。
这将部署我们最新的代码,其中包含地理位置信息。我们将能够运行此代码,因为我们将在 HTTPS 上运行,这将在 Chrome 移动浏览器等上运行。Google Chrome 的移动浏览器和其他移动浏览器对何时发送地理位置信息有相当严格的安全准则。它需要通过 HTTPS 连接,这正是我们现在所拥有的。我将在几个标签中打开我们的 Heroku 应用程序。我们将在标签一中打开它,然后在第二个标签中也打开它。我将点击“发送位置”按钮。我需要批准这一点,因为它是不同的 URL,是的,我希望他们能够使用我的位置。它将获取位置,发送位置,第一个标签获取链接。我点击链接,希望我们得到相同的位置。
总结
在本章中,我们致力于生成文本和位置消息。我们研究了生成newMessage
对象,然后为其编写了一个测试用例。然后,我们学习了如何使用事件确认。然后我们添加了消息表单字段,并在屏幕上呈现了一个输入字段和一个按钮。我们还讨论了 jQuery 的概念,并使用它来选择和创建传入消息元素。
在地理位置部分,我们为用户提供了一个新按钮。这个新按钮允许用户发送他们的位置。我们为发送位置按钮设置了一个click
监听器,这意味着每当用户点击它时,我们会根据他们对地理位置 API 的访问执行一些操作。如果他们没有访问地理位置 API,我们只是打印一条消息。如果他们有访问权限,我们会尝试获取位置。
在下一章中,我们将研究如何为我们的聊天页面设置样式,使其看起来更像一个真正的网络应用程序。
第七章:将我们的聊天页面设置为 Web 应用程序
在上一章中,您了解了 Socket.io 和 WebSockets,它们使服务器和客户端之间实现了双向通信。在本章中,我们将继续讨论如何为我们的聊天页面设置样式,使其看起来更像一个真正的 Web 应用程序。我们将研究时间戳和使用 Moment 方法格式化时间和日期。我们将创建和渲染newMessage
和newLocation
消息的模板。我们还将研究自动滚动,使聊天不那么烦人。
设置聊天页面的样式
在这一部分,我们将设置一些样式,使我们的应用看起来不那么像一个未经样式处理的 HTML 页面,而更像一个真正的 Web 应用程序。现在在下面的截图中,左边是 People 面板,虽然我们还没有连接它,但我们已经在页面中给它了一个位置。最终,这将存储连接到个人聊天室的所有人的列表,这将在稍后完成。
在右侧,主要区域将是消息面板:
现在个别的消息仍然没有样式,这将在稍后完成,但我们有一个放置所有这些东西的地方。我们有我们的页脚,这包括我们发送消息的表单,文本框和按钮,还包括我们的发送位置按钮。
现在为了完成所有这些,我们将添加一个我为这个项目创建的 CSS 模板。我们还将向我们的 HTML 添加一些类;这将让我们应用各种样式。最后,我们将对我们的 JavaScript 进行一些小的调整,以改善用户体验。让我们继续深入。
存储模板样式
我们要做的第一件事是创建一个新文件夹和一个新文件来存储我们的样式。这将是我们马上要获取的模板样式,然后我们将加载它到index.html
中,这样在渲染聊天应用程序时就会使用这些样式。
现在我们要做的第一件事是在public
中创建一个新文件夹,将这个文件夹命名为css
。我们将向其中添加一个文件,一个名为styles.css
的新文件。
现在在我们去获取任何样式之前,让我们将这个文件导入到我们的应用程序中,并为了测试和确保它工作,我们将编写一个非常简单的选择器,我们将使用*
选择所有内容,然后在大括号内添加一个样式,将所有内容的color
设置为red
:
* {
color: red;
}
继续制作你的文件,就像这个一样,我们将保存它,然后在index.html
中导入它。在head
标签的底部跟随我们的meta
标签,我们将添加一个link
标签,这将让我们链接一个样式表。我们必须提供两个属性来完成这个操作,首先我们必须告诉 HTML 我们要链接到什么,通过指定rel
或关系属性。在这种情况下,我们要链接一个style sheet
,所以我们将提供它作为值。现在我们需要做的下一件事是提供href
属性。这类似于script
标签的src
属性,它是要链接的文件的路径。在这种情况下,我们在/css
中有一个styles.css
文件:
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/css/styles.css">
</head>
现在我们可以保存index.html
,在浏览器中刷新页面或者首次加载页面,我们看到的是一个丑陋的页面:
我们设法使它比以前更丑,但这很好,因为这意味着我们的样式表文件被正确导入了。
为了获取我们将在聊天应用程序中使用的实际模板,我们将访问一个 URL,links.mead.io/chat-css
。这只是一个将重定向您到一个 Gist 的 bitly 链接,这里有两个选项,我们可以获取压缩的样式模板或未压缩的样式模板:
我将继续获取压缩的文件,可以通过高亮它或点击原始链接来获取,这将带我们到文件。我们将获取我们在那里看到的全部内容,然后转到 Atom 并将其粘贴到我们的styles.css
文件中,显然删除之前的选择器。
现在我们已经完成了这一步,我们可以刷新页面,尽管我们不会看到太多改进。在localhost:3000
中,我将刷新浏览器,显然事情已经有所不同:
这是因为我们需要在我们的 HTML 中应用一些类,以便一切都能正确工作。
调整结构以对齐
我们需要调整结构,添加一些容器元素来帮助对齐。在 Atom 中,我们可以在短短几分钟内完成这项工作。这个模板是围绕一些关键类构建的。第一个类需要应用到body
标签上,通过将class
属性设置为,引号内的chat
:
<body class="chat">
这告诉样式表为这个聊天页面加载这些样式,我们将继续删除Welcome to the chat app
,这已经不再需要了。现在我们要做的下一件事是创建一个div
标签,这个div
将包含我们在左侧看到的People
列表。目前它是空的,但没关系,我们仍然可以继续创建它。
我们将创建一个div
,并给这个div
添加一个类,这个class
将被设置为chat__sidebar
:
<body class ="chat">
<div class="chat">
</div>
这是一种在一些样式表模板中使用的命名约定,这实际上是一个偏好的问题,当你创建样式表时,你可以随意命名它,我碰巧称它为chat__sidebar
。这是一个更大的聊天应用程序中的子元素。
现在在div
标签中,我们将使用h3
标签添加一个小标题,我们将给它一个标题People
,或者你想给侧边栏列表起的任何名字,我们还将提供一个div
,最终将包含个人用户,尽管我提到我们暂时不会将其连接起来。现在我们可以给它一个id
,将其设置为users
,这样我们稍后就可以定位它。这就是我们目前聊天侧边栏所需要的一切:
<div class ="chat__sidebar">
<h3>People</h3>
<div id="user"></div>
</div>
接下来,我们要做的是创建一个div
标签,这个div
将包含主要区域,这意味着它不仅包含我们的聊天消息,还包含底部的小表单,以及侧边栏右侧的所有内容。
这也需要为一些样式创建一个自定义类,这个类叫做chat__main
,在这里我们不仅要添加无序列表,还要添加我们的form
和button
。让我们继续拿出我们当前的标记,从无序列表到发送位置按钮,把它剪切出来,粘贴到chat__main
中:
<div class="chat__main">
<ol id="messages"></ol>
<form id="message-form">
<input name="message" type="text" placeholder="Message"/>
<button>Send</button>
</form>
<button id="send-location">Send Location</button>
</div>
现在我们还没有完成,还有一些需要调整的地方。首先,我们必须为我们的有序列表添加一个类,我们将把class
设置为chat__messages
,这将提供必要的样式,我们需要创建的最后一个div
是底部的灰色条,其中包含form
和Send Location
按钮。我们将创建一个div
来帮助对齐,并且我们将把form
和button
标签放在里面,通过剪切并粘贴到有序列表的div
中:
<div class="chat__main">
<ol id="messages" class="chat__messages"></ol>
<form id="message-form">
<input name="message" type="text" placeholder="Message"/>
<button>Send</button>
</form>
<button id="send-location">Send Location</button>
</div>
现在我们也需要在这里添加一个类,正如你可能已经猜到的那样,将class
属性设置为字符串chat__footer
:
<div class="chat__footer">
<form id="message-form">
<input name="message" type="text" placeholder="Message"/>
<button>Send</button>
</form>
<button id="send-location">Send Location</button>
</div>
现在我们所有的类都已经就位,我们可以转到浏览器,看看当我们刷新页面时会得到什么。
我们有我们样式化的聊天应用程序,我们仍然可以做以前能做的任何事情。我可以发送一条消息,嘿,这应该仍然有效
,按enter,嘿,这应该仍然有效
会显示在屏幕上:
对于发送位置也是一样,我可以发送我的位置,这会发送到服务器,发送到所有客户端,我可以点击我的当前位置链接,位置会显示在 Google 地图上。我们保留了所有旧的功能,同时添加了一套漂亮的样式:
改进用户体验
现在在本节的第二部分中,我想对表单进行一些用户体验改进。
我们要做的一个改进是在成功发送消息后清除文本值。我们还将对发送位置做类似的操作。正如你可能已经注意到的,发送位置的地理位置调用实际上可能需要一秒或两秒的时间才能完成,我们将禁用此按钮,以防有人不知道发生了什么而进行垃圾邮件式的点击。我们还将更新文本,以便显示正在发送位置
,这样某人就知道背景中正在发生一些事情。
为了完成这两件事,我们只需要修改index.js
内的几行。在文件底部附近,我们有两个 jQuery 事件监听器,这两个都将被更改。
更改表单提交监听器
现在我们要改变的第一件事是表单提交监听器。在socket.emit
中,我们从字段中获取值,这就是我们传递的值。接下来我们想要做的是在确认回调函数内清除该值。一旦服务器接收到请求,就没有理由继续保留它,所以我们可以添加相同的jQuery
选择器,定位name
属性等于message
的字段。我们将继续通过再次调用val
来清除它的值,但是不同于不提供参数获取值,我们将通过传递空字符串作为第一个参数来将值设置为空字符串:
jQuery('#message-form').on('submit', function (e) {
e.prevenDefault();
var messageTextbox =
socket.emit('createMessage', {
from: 'User',
text: jQuery('[name=message]').val()
}, function () {
jQuery('[name=message]').val('')
});
});
你可以将值设置为任何你喜欢的东西,但在这种情况下,我们只想清除它,所以我们将使用以下方法调用。
我们两次使用相同的选择器以加快速度,我们将创建一个变量,我们将称该变量为messageTextbox
,然后我们可以将其设置为我们刚刚创建的选择器,现在我们可以在任何需要访问该输入的地方引用messageTextbox
。我们可以像这样引用它,messageTextbox
,接下来,messageTextbox
:
var messageTextbox = jQuery('[name=message]');
socket.emit('createMessage', {
from: 'User',
text: messageTextbox.val()
}, function() {
messageTextbox.val('')
});
现在createMessage
的监听器,位于server.js
内,我们确实使用一个字符串调用回调函数。现在,我们将只是删除那个虚假的传递零参数的值,就像这样:
socket.broadcast.emit('newMessage', generateMessage('Admin, 'New user joined'));
socket.on('createMessage', (message, callback) => {
console.log('createMessage', message);
io.emit('newMessage', generateMessage(message.form, message.text));
callback();
});
这意味着确认函数仍然会被调用,但实际上我们不需要任何数据,我们只需要知道服务器何时响应。现在我们已经做好了,我们可以继续在localhost:3000
内刷新,输入一条消息,这是一条消息
,然后按下enter键,我们会得到清除的值,而且确实已经发送了:
如果我输入一条消息,安德鲁
,然后点击发送按钮,同样的事情也会发生。
更新输入标签
现在我们要做的一件事是快速更新文本框的input
标签。如果我刷新页面,我们当前并没有直接进入消息字段,这样做会很好。关闭自动完成也会很好,因为你可以看到,自动完成并不是一个有用的功能,里面的值通常都是垃圾。
在 Atom 内部,我们要做的是添加两个属性来自定义输入。第一个是autofocus
,它不需要一个值,当 HTML 被渲染时,autofocus
会自动对焦在输入上,第二个我们要添加的是autocomplete
,我们将把它设置为字符串off
:
<div class="chat__footer">
<form id="message-form">
<input name="message" type="text" placeholder="Message" autofocus autocomplete="off"/>
<button>Send</button>
<form>
<button id="send-location">Send Location</button>
有了这个设置,我们可以保存index.html
,回到 Chrome,刷新页面并测试一下。我会输入test
,我们没有自动完成,这很好,我们关闭了它,如果我点击发送按钮,我确实还在发送消息。当我重新加载页面时,我也直接进入了文本框,我不需要做任何事情就可以开始输入。
自定义发送位置
接下来我们要做的是使用更多的 jQuery 来自定义发送位置按钮。现在我们对 jQuery 还不太熟悉,这也不是一个 jQuery 课程。这里的目标是改变按钮文本,并在进行过程时禁用它。当过程完成时,也就是位置被发送或未发送时,我们可以将按钮恢复到正常状态,但在地理位置调用发生时,我们不希望有人不断点击。
为了完成这个任务,我们将对index.js
中的最终监听器进行一些调整,在我们的提交监听器旁边,我们有一个点击监听器。在这里,我们需要对按钮进行一些更改,我们定义的locationButton
变量。我们将设置一个属性来禁用按钮。
为了完成这个任务,我们将引用选择器locationButton
,并调用一个 jQuery 方法。
现在我们只会在确认他们甚至支持它之后禁用它,如果他们不支持这个功能,就没有理由去禁用它。在这里,locationButton.attr
将让我们设置一个属性,我们将把disabled
属性设置为值disabled
。现在这个disabled
也需要加上引号:
var locationButton = jQuery('#send-location');
locationButton.on('click', function () {
if (!navigator.geolocation) {
return alert('Geolocation not supported by your browser.');
}
locationButton.attr('disabled', 'disabled');
现在我们已经禁用了按钮,我们可以实际测试一下,我们从未取消禁用它,所以在点击一次后它就会出现问题,但我们可以确认这行代码有效。在浏览器中,我将刷新一下,点击发送位置,你会立刻看到按钮被禁用了:
现在它会发送位置一次,但如果我再试图点击它,按钮就会被禁用,永远不会再次触发click
事件。这里的目标是只在实际发生过程中禁用它,一旦像这样发送了,我们希望重新启用它,这样别人就可以发送更新的位置。
为了在 Atom 内部完成这个任务,我们将在成功处理程序和错误处理程序中添加一行 jQuery。如果事情进展顺利,我们将引用locationButton
,并使用removeAttr
来移除禁用属性。这只需要一个参数,属性的名称,在这种情况下,我们有一个字符串disabled
:
locationButton.attr('disabled', 'disabled');
navigator.geolocation.getCurrentPosition(function (position) {
locationButton.removeAttr('disabled');
socket.emit('createLocationMessage', {
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
这将移除我们之前定义的disabled
属性,重新启用按钮。我们可以做完全相同的事情,简单地复制并粘贴下一行到function
中。如果由于某种原因我们无法获取位置,也许用户拒绝了对地理位置的请求,我们仍然希望禁用该按钮,以便他们可以再次尝试:
navigator.geolocation.getCurrentPosition(function (position){
locationButton.removeAttr('disabled');
socket.emit('createLocationMessage', {
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
}, function(){
locationButton.removeAttr('disabled');
alert('Unable to fetch location');
});
现在我们已经设置好了,我们可以通过刷新浏览器并尝试发送我们的位置来测试该代码。我们应该看到按钮在一小段时间内被禁用,然后重新启用。我们可以点击它来证明它按预期工作,并且按钮已重新启用,这意味着我们可以在以后的时间再次点击它发送我们的位置。
更新按钮文本
现在我们要做的最后一件事是在过程发生时更新按钮文本。为了完成这个任务,在 Atom 中我们将使用过去使用过的text
方法。
在locationButton.attr
行中,我们将把text
属性设置为Sending location...
。现在,在index.js
文件中,真正的按钮文本是Send Location
,我将把location
转换为小写以保持统一。
var locationButton = jQuery('#send-location');
locationButton.on('click', function (){
if (!navigator.geolocation){
return alert('Geolocation not supported by your browser.');
}
locationButton.attr('disabled', 'disabled').text('Sending location...');
现在我们已经设置好了,我们正在更新过程发生时的文本,唯一剩下的事情就是通过将text
设置为字符串Send location
来将其调整回原始值,我们将在错误处理程序中做完全相同的事情,调用text
传入字符串Send location
:
locationButton.attr('disabled', 'disabled').text('Sending location...');
navigator.geolocation.getCurrentPosition(function (position){
locationButton.removeAttr('disabled').text('Send location');
socket.emit('createLocationMessage', {
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
}, function(){
locationButton.removeAttr('disabled').text('Send location');
alert('Unable to fetch location');
});
现在我们可以继续测试这是否按预期工作,这两行(成功和错误处理程序中)是相同的,无论成功与否,我们都会做同样的事情。
在 Chrome 中,我将再次刷新我的页面,我们将点击发送位置按钮,您可以看到按钮被禁用并且文本已更改,显示“正在发送位置...”:
一旦过程完成并且位置实际上已发送,按钮将返回到其默认状态。
有了这个设置,我们现在比以前有了更好的用户体验。我们不仅拥有一套漂亮的样式,还为我们的表单和发送位置按钮提供了更好的 UI。这就是我们在本节中要停止的地方。
让我们继续通过关闭服务器,运行git status
,运行git add .
来快速提交所有这些文件,最后我们将继续运行git commit
,并使用-m
标志提供消息,Add css for chat page
:
**git commit -m 'Add css for chat page'**
我们可以使用git push
将其推送到 GitHub,并且我现在不打算部署到 Heroku,尽管您可以部署并测试您的应用程序。
Moment 中的时间戳和格式化
在整个课程中,我们已经相当多地使用了时间戳,在待办事项应用程序中生成了它们,并且在聊天应用程序中为所有消息生成了它们,但我们从未将它们格式化为可读的形式。这将是本节的主题,在下一节中我们将把它付诸实践。
到下一节结束时,我们将拥有一个格式化的消息区域,其中包括名称、时间戳和消息,并且我们也将为其提供一些更好的样式。现在在本节中,一切都将围绕时间和时间戳展开,我们不会对应用程序的前端进行任何更改,我们只是要学习 Node 中的时间是如何工作的。
Node 中的时间戳
为了探索这一点,我们将创建一个新的playground
文件,在 Atom 中我们将创建一个playground
文件夹来存储这个文件,在playground
文件夹中我们可以创建一个名为time.js
的新文件。在这里,我们将玩转时间,并将在下一节将我们在这里学到的内容带入应用程序的前端。
我们对时间戳并不陌生,我们知道它们只是整数,无论是正数还是负数,像781
这样的数字是一个完全有效的时间戳,就像几十亿或任何数字一样,所有都是有效的,甚至0
也是一个完全有效的时间戳。现在所有这些数字都是相对于历史上的某一时刻的,这个时刻被称为 Unix 纪元,即 1970 年 1 月 1 日午夜 0 时 0 分 0 秒。这是存储在 UTC 中的,这意味着它与时区无关:
// Jan 1st 1970 00:00:00 am
0
现在我的时间戳0
实际上完美地代表了历史上的这一刻,而像 1000 这样的正数则表示未来,而像-1000 这样的负数则表示过去。时间戳-1000 将代表 1969 年 12 月 31 日 11 点 59 分 59 秒,我们已经从 1970 年 1 月 1 日过去了一秒。
现在,在 JavaScript 中,这些时间戳以毫秒存储自 Unix 纪元以来的时间,而在常规的 Unix 时间戳中,它们实际上是以秒存储的。由于我们在本课程中使用 JavaScript,我们将始终使用毫秒作为我们的时间戳值,这意味着像 1000 这样的时间戳代表了 1 月 1 日的一秒,因为一秒钟有 1000 毫秒。
像 10000 这样的值将是这一天的十秒,依此类推。现在对我们来说,问题从来不是获取时间戳,获取时间戳非常容易,我们只需要调用new Date
调用它的getTime
方法。然而,当我们想要格式化一个类似于之前的人类可读值时,情况将变得更加困难。
我们将要在我们的 Web 应用程序中打印一些不仅仅是时间戳的东西,我们将要打印一些像五分钟前这样的东西,让用户知道消息是五分钟前发送的,或者你可能想打印实际的日期,包括月份、日期、小时、分钟和上午或下午的值。无论你想打印什么,我们都需要谈一谈格式化,这就是默认的Date
对象不足的地方。
是的,有一些方法可以让你从日期中获取特定的值,比如年份、月份或日期,但它们非常有限,定制起来是一个巨大的负担。
日期对象
要讨论确切的问题,让我们继续查看日期的文档,通过谷歌搜索mdn date
,这将带我们到 Mozilla 开发者网络文档页面上的Date,这是一个非常好的文档集:
在这个页面上,我们可以访问所有可用的方法,这些方法都类似于getTime
,返回关于日期的特定信息:
例如,如前面的屏幕截图所示,我们有一个getDate
方法,返回月份的日期,一个从 1 到 31 的值。我们有像getMinutes
这样的方法,返回时间戳的当前分钟数。所有这些都存在于Date
中。
现在问题是这些方法非常不灵活。例如,在 Atom 中,我们有这个小日期,1970 年 1 月 1 日 00:00:10
。这是 1 月的简写版本。现在我们可以获取实际的月份来展示给你,我们将创建一个名为date
的变量。我们将创建new Date
,然后我们将调用一个方法。我将使用console.log
将值打印到屏幕上,我们将调用date.getMonth
:
// Jan 1st 1970 00:00:10 am
var date = new Date();
console.log(date.getMonth());
如文档中所定义的getMonth
方法将返回一个基于 0 的月份值,从 0 到 11,其中 0 是一月,11 是十二月。在终端中,我将使用nodemon
启动我们的应用程序,因为我们将经常重启它。Nodemon 在playground
文件夹中而不是server
文件夹中,文件本身称为time.js
:
**nodemon playground/time.js**
一旦它运行起来,我们看到我们得到了2
,这是预期的:
现在是 2018 年 3 月 25 日,而 3 月的0
索引值将是2
,尽管你通常认为它是 3。
现在前面的结果很好。我们有数字 2 来表示月份,但要获得实际的字符串 Jan 或 January 将会更加困难。没有内置的方法来获取这个值。这意味着如果你想要获得这个值,你将不得不创建一个数组,也许你称这个数组为months
,并且存储所有这样的值:
var date = new Date();
var months = ['Jan', 'Feb']
console.log(date.getMonth());
这将是很好的,对于月份可能看起来并不是那么重要,但是对于月份的日期,比如我们有的1st
,我们只能得到数字 1。实际上将其格式化为 1st、2nd 或 3rd 将会更加困难。对于格式化日期,确实没有一个好的方法集。
当你想要一个相对时间字符串时,事情变得更加复杂,比如三分钟前。在 web 应用程序中打印这个信息会很好,打印实际的月份、日期和年份并不特别有用。如果我们能够说,嘿,这条消息是三小时前发送的,三分钟前发送的,或者三年前发送的,就像很多聊天应用程序所做的那样,那就太酷了。
使用 Moment 进行时间戳
现在当你涉及到这样的格式化时,你的第一反应通常是创建一些实用方法来帮助格式化日期。但是没有必要这样做,因为我们在这一部分要看的是一个名为Moment的了不起的时间库。Moment 几乎是其类别中唯一的库。它被普遍认为是处理时间和 JavaScript 的首选库,我从来没有在一个没有使用 Moment 的 Node 或前端项目上工作过,当你以任何方式处理日期时,它确实是必不可少的。
为了展示 Moment 为什么如此出色,我们首先要在终端内安装它。然后我们将玩弄它的所有功能,它有很多。我们可以通过运行npm i
来安装它,我将使用当前版本moment@
版本2.21.0
,并且我还将使用--save
标志将其添加为一个依赖项,这是我们在 Heroku 上以及本地都需要的一个依赖项:
**npm i moment@2.21.0 --save**
一旦它安装好了,我可以使用clear
来清除终端输出,然后我们可以继续重新启动nodemon
。在playground
文件夹内,是时候引入 Moment 并且看看它对我们能做什么。
首先,让我们试着解决我们之前尝试解决日期问题。我们想要打印月份的简写版本,比如 Jan、Feb 等。第一步将是将之前的代码注释掉,并在顶部加载之前的 Moment,需要它。我将创建一个名为moment
的变量,并通过require
来加载moment
库:
var moment = require('moment');
// Jan 1st 1970 00:00:10 am
//var date = new Date();
//var months = ['Jan', 'Feb']
//console.log(date.getMonth());
然后在这段代码旁边,我们将通过创建一个新的 moment 来开始。现在就像我们创建一个新的日期来获得一个特定的日期对象一样,我们将用 moment 做同样的事情。我将把这个变量称为date
,并且我们将把它设置为调用moment
的结果,之前我们加载的函数,不带任何参数:
var moment = require('moment');
// Jan 1st 1970 00:00:10 am
//var date = new Date();
//var months = ['Jan', 'Feb']
//console.log(date.getMonth());
var date = moment();
这将创建一个代表当前时间点的新 moment 对象。从这里,我们可以尝试使用它非常有用的format
方法来格式化东西。format
方法是我喜欢 Moment 的主要原因之一,它使得打印任何你想要的字符串变得非常简单。现在在这种情况下,我们可以访问我们的date
,然后我们将调用我刚才谈到的方法,format
:
var moment = require('moment');
var date = moment();
console.log(date.format());
在我们讨论传递给格式的内容之前,让我们继续运行它就像这样。当我们在终端内运行时,nodemon
将会重新启动自己,然后我们就有了我们格式化后的日期:
我们有年份、月份、日期和其他值。它仍然不是非常用户友好,但这是朝着正确方向迈出的一步。format
方法的真正威力是当你在其中传递一个字符串时。
现在我们传递到格式方法中的是模式,这意味着我们可以访问一组特定的值,我们可以用来输出某些东西。我们将在接下来的一秒钟内探索所有可用的模式。现在,让我们继续使用一个;就是三个大写的M
模式:
var date = moment();
console.log(date.format('MMM'));
当 Moment 看到格式中的这个模式时,它将继续抓取月份的简写版本,这意味着如果我保存这个文件,并再次在终端中重新启动它。我们现在应该看到当前月份九月的简写版本,即Mar
:
这里我们得到了Sep
,正如我们所期望的那样,我们能够通过使用格式方法来简单地实现这一点。现在格式返回一个字符串,其中只包含你指定的内容。在这里,我们只指定了我们想要月份的简写版本,所以我们得到的只是月份的简写版本。我们还可以添加另一个模式,四个 Y,它打印出完整的年份;在当前情况下,它将以数字形式打印出 2016:
console.log(date.format('MMM YYYY'));
我将继续节省时间,这里我们得到了Mar 2018
:
现在 Moment 有一套很棒的文档,所以你可以使用任何你喜欢的模式。
Moment 文档
在浏览器中,我们可以通过访问momentjs.com来查看。Moment 的文档非常棒。它可以在文档页面上找到,并且为了开始弄清楚如何使用格式,我们将转到显示部分:
显示中的第一项是格式。有一些关于如何使用格式的示例,但真正有用的信息是我们在这里拥有的:
这里有我们可以放入字符串中以我们喜欢的方式格式化日期的所有标记。在上面,你可以看到你可以使用尽可能多的这些标记来创建非常复杂的日期输出。现在我们已经探索了两个。我们探索了MMM
,它就在月份标题下面定义,你可以看到有五种不同的表示月份的方式。
我们用于年份的YYYY
模式也在这里定义了。有三种使用年份的方式。我们刚刚探索了其中一种。每个部分都有,年份、星期几、月份中的日期、上午/下午、小时、分钟、秒,所有这些都有定义,都可以像我们为当前值所做的那样放入格式中:
现在,为了更深入地探索一下,让我们回到 Atom,并利用其中的一些功能。我们要尝试的是打印日期,如Jan 1st 1970
,我们已经有了简写的月份和年份,但现在我们还需要将月份的日期格式化为 1st、2nd、3rd,而不是 1、2、3。
使用 Moment 格式化日期
为了做到这一点,如果我以前没有使用过 Moment,我会在文档中查找日期部分,然后查看可用的选项。我有打印 1 到 31 的 D 模式,打印我们想要的 1st、2nd、3rd 等的 Do 模式,以及对于小于 10 的值,打印带有 0 的数字的 DD 模式。
现在在这种情况下,我们想使用 Do 模式,所以我们只需要在格式中输入它。我将打开终端和 Atom,这样我们就可以看到后台中的刷新,然后我们将输入:
console.log(date.format('MMM Do YYYY'));
保存文件,当它启动时,我们得到了March 25th 2018
,这确实是正确的:
现在我们还可以添加其他字符,比如逗号:
console.log(date.format('MMM Do, YYYY'));
逗号不是格式期望的一部分,所以它只是简单地通过,这意味着逗号会像我们输入的那样显示在 March 25th, 2018
中:
以这种方式使用 format
给了我们很大的灵活性,以便我们可以打印日期。现在 format
只是众多方法中的一个。Moment 有很多方法可以做几乎任何事情,尽管我发现我在大多数项目中使用的方法基本相同。大多数情况下并不需要它们,尽管它们存在是因为它们在某些情况下很有用。
在 Moment 中的 Manipulate 部分
为了快速了解 Moment 还能做些什么,让我们回到文档并转到 Manipulate 部分:
在 Manipulate 下定义的前两个方法是 add
和 subtract
。这让你可以轻松地添加和减去时间。我们可以调用 add
添加七天,我们可以调用 subtract
减去七个月,就像这个例子中所示的那样:
通过这个例子,你可以快速了解你可以添加和减去什么,年份、季度、月份、周数,几乎任何时间单位都可以被添加或减去。
现在来看看这对时间戳的确切影响,我们可以添加和减去一些值。我将调用 date.add
,然后我们将添加一年,将 1
作为值,year
作为单位:
var date = moment();
date.add(1, 'years')
console.log(date.format('MMM Do, YYY'));
现在无论你使用单数还是复数版本都没关系,两者都会起同样的作用。在这里你可以看到我们在终端中得到了 2019
:
如果我将它改为单数形式,我也会得到相同的值。我们可以添加任意多的年份,我将继续添加 100
年:
var date = moment();
date.add(100, 'year')
console.log(date.format('MMM Do, YYY'))
现在我们到了 2118
:
subtract
也是一样的。我们可以链接调用,也可以将其添加为单独的语句。我要像这样减去:
date.add(100, 'year').subtract(9, 'months');
而我们现在是在九月,当我们减去 9 个月时,我们回到了六月:
现在你会注意到我们从 2118
到 2117
,因为减去那 9 个月需要我们改变年份。Moment 真的很擅长处理你扔给它的任何事情。现在我们将继续玩一下 format
。我要添加一个我想要的输出,然后我们需要在文档内部找出要使用的模式。
现在写作的当前时间是 10:35,而且是上午,所以我有一个小写的上午。你的目标是打印一个这样的格式。现在显然,如果你运行代码时是 12:15,你会看到 12:15 而不是 10:35;只有格式很重要,实际值并不那么重要。现在当你尝试打印小时和分钟时,你会有很多选项。对于它们两个,你会有一个像 01 这样的填充版本,或者像 1 这样的未填充版本。
我希望你使用填充版本的分钟和未填充版本的小时,就像这样,6 和 01。如果你填充了小时,它看起来有点奇怪,如果你不填充分钟,它看起来就很糟糕。所以如果碰巧是上午 6:01,我们会想要打印出这样的东西。现在对于小时,你也可以选择使用 1 到 12 或 1 到 24,我通常使用 12 小时制,所以我会使用上午。
在我们开始之前,我要注释掉之前的代码,我希望你从头开始写。我将通过调用没有参数的 moment
来创建一个新变量 date
,然后我们还将调用 console.log
中的 format
,这样我们就可以将格式化的值打印到屏幕上,date.format
:
var date = moment();
console.log(date.format(''))
在引号内,我们将提供我们的模式,并从未填充的小时和填充的分钟开始。我们可以通过查看文档,返回到 Display,然后查看一下,来获取这两个模式。如果我们滚动到下一个,我们将遇到的第一个是 Hour,我们有很多选项:
我们有 24 小时制的选项,我们有 1 到 12;我们想要的是小写的 h,即 1 到 12 不填充。填充版本,即 hh,就在旁边,这不是我们想要的。我们将通过添加一个 h 来开始:
var date = moment();
console.log(date.format('h'));
我也要保存文件,然后在终端中查看:
我们有4
,看起来很好。接下来是填充的分钟,我们将继续找到紧挨着的模式。对于分钟,我们的选择要少得多,要么填充,要么不填充,我们要使用 mm。在我添加 mm 之前,我要添加一个冒号。这将以纯文本形式传递,意味着它不会被更改。我们将添加两个小写的 ms:
console.log(date.format('h:mm'));
然后我们可以保存time.js
,确保在终端中打印出正确的内容,确实是这样,4:22
显示出来了:
接下来要做的是获取小写的 am 和 pm 值。我们可以在 Google Chrome 中找到这个模式,就在小时之前:
在这里,我们可以使用大写 A 表示大写的 AM 和 PM,或者使用小写 a 表示小写的版本。我将在一个空格后面使用小写的a
来使用小写的版本:
var date = moment();
console.log(date.format('h:mm a'))
我可以保存文件,然后在终端中,我确实打印出了4:24
,并且后面有pm
:
一切看起来都很好。这就是本节的全部内容!在下一节中,我们将实际将 Moment 集成到我们的服务器和客户端中,而不仅仅是在playground
文件中。
打印消息时间戳
在这一部分,您将格式化时间戳,并将它们与聊天消息一起显示在屏幕上。目前,我们显示了消息的发送者和文本,但createdAt
时间戳没有被使用。
现在我们需要弄清楚的第一件事是,我们如何将时间戳转换为 Moment 对象,因为归根结底,我们想要调用format
方法来按我们的喜好格式化它。为了做到这一点,你所要做的就是拿到你的时间戳。我们将创建一个名为createdAt
的变量来表示这个值,并将其作为moment
的第一个参数传递进去,这意味着我只需传入createdAt
,就像这样:
var createdAt = 1234;
var date = moment(createdAt);
当我这样做时,我们创建了一个具有与 format、add 和 subtract 相同方法的 moment,但它代表的是不同的时间点。默认情况下,它使用当前时间。如果传入一个时间戳,它就使用那个时间。现在这个数字1234
,只是比 Unix 纪元晚了一秒,但如果我们运行文件,我们应该看到正确的东西打印出来。使用nodemon
命令,在playground
文件夹中,我们将运行time.js
,并且我们会得到5:30 am
,如下面的截图所示:
这是预期的,因为它考虑了我们的本地时区。
从时间戳中获取格式化的值
现在我们已经做好了准备,我们已经拥有了实际获取这些时间戳并返回格式化值所需的一切。我们还可以使用 Moment 创建时间戳,它的效果与我们使用的new Date().getTime
方法完全相同。
为了做到这一点,我们只需调用moment.valueOf
。例如,我们可以创建一个名为someTimestamp
的变量,将其设置为对moment
的调用。我们将生成一个新的 moment,并调用它的valueOf
方法。
这将继续返回自 Unix 纪元以来的毫秒时间戳,console.log
。我们将记录someTimestamp
变量,以确保它看起来正确,这里是我们的时间戳值:
var someTimestamp = moment().valueOf();
console.log(someTimestamp);
更新 message.js 文件
我们要做的第一件事是调整我们的message.js
文件。目前在message.js
中,我们使用new Date().getTime
生成时间戳。我们将切换到 Moment,不是因为它会改变任何东西,而是因为我希望在使用时间时保持一致使用 Moment。这将使维护和弄清楚发生了什么变得更容易。在message.js
的顶部,我将创建一个名为moment
的变量,将其设置为require('moment')
:
var moment = require('moment');
var generateMessage = (from, text) => {
return {
from,
text,
createAt: new Date().getTime()
};
};
我们将继续用valueOf
替换createdAt
属性。我希望你继续做到这一点,调用moment
,在generateMessage
和generateLocationMessage
中调用valueOf
方法,然后继续运行测试套件,确保两个测试都通过。
我们需要做的第一件事是调整generateMessage
的createdAt
属性。我们将调用moment
,调用valueOf
获取时间戳,对generateLocationMessage
也是同样的操作:
var moment = require('moment');
var generateMessage = (from, text) => {
return {
from,
text,
createdAt: moment().valueOf()
};
};
var generateLocationMessage = (from, latitude, longitude) => {
return {
from,
url: `https://www.google.com/maps?q=${latitude},${longitude}`,
createdAt: moment().valueOf()
}
};
现在我们可以保存message.js
。进入终端并使用以下命令运行我们的测试套件:
**npm test**
我们得到了两个测试,它们仍然都通过了,这意味着我们得到的值确实是一个数字,就像我们的测试所断言的那样:
现在我们在服务器上集成了 Moment,我们将继续在客户端上做同样的事情。
在客户端集成 Moment
我们需要做的第一件事是加载 Moment。目前,我们在前端加载的唯一库是 jQuery。我们可以通过几种不同的方式来做到这一点;我将实际上从node_modules
文件夹中获取一个文件。我们已经安装了 Moment,版本为 2.15.1,我们实际上可以获取我们在前端需要的文件,它位于node_modules
文件夹中。
我们将进入node_modules
,我们有一个非常长的按字母顺序排列的文件夹列表,我正在寻找一个名为moment
的文件夹。我们将进入moment
并获取moment.js
。我将右键单击复制它,然后向上滚动到最顶部,关闭node_modules
,然后将其粘贴到我们的js
| libs
目录中。现在我们有了moment.js
,如果你打开它,它是一个非常长的库文件。不需要对该文件进行任何更改,我们只需要加载index.js
。就在我们的 jQuery 导入旁边,我们将添加一个全新的script
标签,然后设置src
属性等于/js/js/moment.js
,就像这样:
<script src="img/socket.io.js"></script>
<script src="img/jquery-3.1.0.min.js"></script>
<script src="img/moment.js"></script>
<script src="img/index.js"></script>
现在我们已经有了这个设置,我们在客户端上就可以访问所有这些 Moment 函数,这意呈现出在index.js
中可以正确格式化消息中返回的时间戳。在做任何更改之前,让我们使用以下命令启动我们的服务器:
**nodemon server/server.js**
我们可以继续进入浏览器,转到localhost:3000
并刷新,我们的应用程序正在按预期工作。如果我打开开发者工具,在控制台选项卡中,我们实际上可以使用 Moment。我们可以通过 moment 访问它,就像我们在 Node 中做的那样。我可以使用moment
,调用format
:moment().format()
。
我们得到了我们的字符串:
如果你成功导入了 Moment,你应该能够进行这个调用。如果你看到这个,那么你就准备好继续更新index.js
了。
更新 newMessage 属性
如果你还记得,在 message 上我们有一个createdAt
属性,分别用于newMessage
和newLocationMessage
。我们所需要做的就是获取该值,传递给moment
,然后生成我们格式化的字符串。
我们可以创建一个名为formattedTime
的新变量,并将其设置为调用moment
传入时间戳message.createdAt
的结果:
socket.on('newMessage', function (message) {
var formattedTime = moment(message.createAt)
现在我们可以继续做任何我们喜欢的事情。我们可以调用 format,传入我们在time.js
中使用的完全相同的字符串,小时,分钟和上午/下午;h:
,两个小写的m
,后面跟着一个空格和一个小写的a
:
var formattedTime = moment(message.createdAt).format('h:mm a');
有了这个,我们现在有了格式化的时间,我们可以继续将其添加到li.text
中。现在我知道我在客户端代码中使用模板字符串。我们很快就会删除这个,所以还不需要进行调整,因为我还没有在 Internet Explorer 或其他浏览器中进行测试,尽管应用程序的最终版本将不包括模板字符串。在from
语句之后,我们将继续注入另一个值,即我们之前创建的formattedTime
。因此,我们的消息应该是像 Admin 这样的名称,后面跟着时间和文本:
socket.on('newMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var li = jQuery('<li></li>');
li.text('${message.from} ${formattedTime}: ${message.text}');
我将继续保存index.js
,并刷新浏览器以加载客户端代码:
如前面的屏幕截图所示,我们看到 Admin 4:49 pm: 欢迎来到聊天应用程序,这就是正确的时间。我可以发送一条消息,这是来自用户
,发送出去,我们可以看到现在是下午 4:50:
这是来自用户的消息,一切都很顺利。
更新 newLocationMessage 属性
现在对于发送位置,我们目前不使用 Moment;我们只更新了newMessage
事件监听器。这意味着当我们打印位置消息时,我们没有时间戳。我们将修改newLocationMessage
,你可以继续使用我们之前使用的相同技术来完成工作。现在在哪里实际上呈现格式化的时间,你可以简单地将其放在li.text
中,就像我们在newMessage
属性的情况下所做的那样。
过程中的第一步将是创建名为formattedTime
的变量。我们实际上可以继续复制以下行:
var formattedTime = moment(message.createdAt).format('h:mm a');
并将其粘贴在var li = jQuery('<li></li>');
行的上面,就像这样:
socket.on('newLocationMessage', function(message) {
var formattedTime = moment(message.createAt).format('h:mm a');
我们想要做的事情与之前完全相同,我们想要获取createdAt
字段,获取一个 moment 对象,并调用format
。
接下来,我们必须修改显示的内容,显示这个formattedTime
变量,并将其放在li.text
语句中:
socket.on('newLocationMessage', function(message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var li = jquery('<li></li>');
var a = jQuery('<a target="_blank">My current location</a>');
li.text(`${message.from} ${formattedTime}: `);
现在我们可以继续刷新应用程序,我们应该看到我们的时间戳用于常规消息。我们可以发送一条常规消息,一切仍然正常:
然后我们可以发送一条我们刚刚更改的位置消息。它应该只需要一秒钟就可以运行起来,我们有我们当前的位置链接。我们有我们的名称和时间戳,这太棒了:
这就是本节的全部内容。让我们继续进行提交以保存我们的更改。
尽管我们还没有完成消息区域,但所有数据都正确显示出来了。只是以一种不太令人愉悦的方式显示出来。不过,现在我们将进入终端并关闭服务器。我将运行git status
,我们有新文件以及一些修改过的文件:
然后,git add .
将会处理所有这些。然后我们可以进行提交,git commit
带有-m
标志,这次的好消息是使用 momentjs 格式化时间戳
:
git commit -m 'Format timestamp using momentjs'
我将使用git push
命令将其推送到 GitHub,然后我们就完成了。
在下一节中,我们将讨论一个模板引擎 Mustache.js。
Mustache.js
现在我们的时间戳已经正确地呈现在屏幕上。我们将继续讨论一个叫做Mustache.js的模板引擎。这将使定义一些标记并多次呈现它变得更容易。在我们的情况下,我们的消息将具有相同的一组元素,以便正确呈现。我们将为用户的名称添加一个标题标记,将文本添加到段落中,所有这些都是一样的。
现在,我们不会像目前在 index.js
中那样,而是在 index.html
中创建一些模板、一些标记,并渲染它们,这意味着我们不需要手动创建和操作这些元素。这可能是一个巨大的负担。
将 mustache.js 添加到目录
现在,为了在实际创建任何模板或渲染它们之前开始,我们确实需要下载库。我们可以通过打开谷歌浏览器并搜索 mustache.js
来获取它,我们要找的是 GitHub 仓库,这种情况下恰好是第一个链接。你也可以访问 mustache.github.io 并点击 JavaScript 链接以到达相同的位置:
现在一旦你到了这里,我们需要获取库的特定版本。我们可以转到分支下拉菜单,从分支切换到标签。这将显示所有已发布的版本;我将在这里使用的版本是最新的 2.3.0。我会获取它,它会刷新仓库,我们要找的是一个名为 mustache.js
的文件。这是我们需要下载并添加到 index.html
中的库文件:
我可以点击 Raw 来获取原始的 JavaScript 文件,并可以右键单击并点击 另存为... 将其保存到项目中。我将进入桌面上的项目,public
| js
| libs
目录,然后在那里添加文件。
现在一旦你把文件放好了,我们可以通过在 index.html
中导入它来开始。在底部附近,我们目前有 jquery
和 moment
的 script
标签。这个看起来会很相似。它将是一个 script
标签,然后我们将添加 src
属性,以便加载新文件,/js/libs
,最后是 /mustache.js
:
<script src="img/moment.js"></script>
<script src="img/mustache.js"></script>
现在有了这个,我们可以继续创建一个模板并渲染它。
创建和渲染 newMessage 的模板
创建一个模板并渲染它,这将让你对 Mustache 能做什么有一个很好的了解,然后我们将继续将其与我们的 newMessage
和 newLocationMessage
回调实际连接起来。为了在 index.html
中开始,我们将通过在 chat__footer
div 旁边定义一个 script
标签来创建一个新模板。
现在在script
标签内,我们将添加我们的标记,但在我们这样做之前,我们必须在script
上提供一些属性。首先,这将是一个可重用的模板,我们需要一种访问它的方式,所以我们会给它一个 id
,我会称这个为 message-template
,我们要定义的另一个属性是一个叫做 type
的东西。type
属性让你的编辑器和浏览器知道 script
标签内存储了什么。我们将把 type
设置为,引号内,text/template
:
<script id = "message-template" type="text/template">
</script>
现在我们可以编写一些标记,它将按预期工作。让我们首先简单地创建一个段落标记。我们将在 script
标签内创建一个 p
标签,并在其中添加一些文本,这是一个模板
,然后我们将关闭段落标记,就是这样,这是我们要开始的地方:
<script id="message-template" type="text/template">
<p>This is a template</p>
</script>
我们有一个 message-template script
标签。我们可以通过注释掉newMessage
监听器内的所有代码,将其渲染到index.js
中。我将注释掉所有那些代码,现在我们可以实现 Mustache.js 渲染方法。
实现 Mustache.js 渲染方法
首先,我们必须获取模板,创建一个名为template
的变量来做到这一点,我们要做的就是使用我们刚刚提供的 ID#message-template
来用jQuery
选择它。现在我们需要调用html
方法,它将返回message-template
内的标记,也就是模板代码,这种情况下是我们的段落标签:
socket.on('newMessage', function (message) {
var template = jquery('#message-template').html();
一旦我们有了这个,我们可以实际上在 Mustache 上调用一个方法,这是因为我们添加了那个script
标签。让我们创建一个名为html
的变量;这是我们最终要添加到浏览器的东西,我们将其设置为对Mustache.render
的调用。
现在Mustache.render
接受你想要渲染的template
:
socket.on('newMessage', function (message) {
var template = jquery('#message-template').html();
var html = Mustache.render(template);
我们将继续渲染它,现在我们可以通过将其添加到messages
ID 中将其显示在浏览器中,就像我们之前做的那样。我们将选择具有 ID 为 messages 的元素,调用append
,并附加我们刚刚渲染的模板,我们可以在 HTML 中访问到它:
socket.on('newMessage', function (message) {
var template = jQuery('#message-template').html();
var html = Mustache.render(template);
jQuery('#messages').append(html);
现在有了这个设置,我们的服务器重新启动了,我们可以通过刷新浏览器来实际操作。我要刷新浏览器:
我们得到了这是我们欢迎消息的模板,如果我输入其他内容,我们也会得到这是一个模板。不是很有趣,也不是很有用,但很酷的是 Mustache 让你注入值,这意味着我们可以设置模板中我们期望传入值的位置。
例如,我们有text
属性。为了引用一个值,你可以使用双大括号的语法,就像这样:
<script id="message-template" type="text/template">
<p>{{text}}</p>
</script>
然后你可以继续输入名称,比如text
。现在为了实际提供这个值,我们必须向 render 方法发送第二个参数。我们不仅仅传递模板,还要传递模板和一个对象:
socket.on('newMessage', function (message) {
var template = jquery('#message-template').html();
var html = Mustache.render(template, {
});
这个对象将拥有你可以渲染的所有属性。现在我们目前期望text
属性,所以我们应该继续提供它。我将把text
设置为message.text
返回的值:
var html = Mustache.render(template, {
text: message.text
});
现在我们以动态方式渲染模板。模板作为可重用的结构,但数据总是会改变,因为它在调用 render 时被传递进来:
有了这个设置,我们可以继续刷新 Chrome,然后在这里我们看到“欢迎来到聊天应用”,如果我输入一条消息,它将显示在屏幕上,这太棒了:
获取所有显示的数据
现在,这个过程的下一步是让所有数据显示出来,我们有一个from
属性和一个createdAt
属性。我们实际上可以通过formattedTime
访问到createdAt
属性。
我们将取消注释formattedTime
行,这是我们实际要转移到新系统的唯一行。我将把它添加到newMessage
回调中:
socket.on('newMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#message-template').html();
var html = Mustache.render(template, {
});
因为我们仍然希望在渲染时使用formattedTime
。在我们对模板做任何其他操作之前,让我们简单地传递这些值。我们已经传递了text
值。接下来,我们可以传递from
,它可以通过message.from
访问,我们还可以传递一个时间戳。你可以随意命名该属性,我将继续称其为createdAt
并将其设置为formattedTime
:
var html = Mustache.render(template, {
text: message.text,
from: message.from,
createdAt: formattedTime
});
提供自定义结构
现在有了这个系统,所有的数据确实都被传递了。我们只需要实际使用它。在index.html
中,我们可以使用所有这些,并且还将提供自定义结构。就像我们之前设置代码时一样,我们将使用我在此项目模板中定义的一些类。
添加列表项标签
我们将从使用li
标签开始。我们将添加一个类,并将这个类命名为message
。在其中,我们可以添加两个div
。第一个div
将是标题区域,我们在其中添加from
和createdAt
的值,第二个div
将是消息的正文:
<script id="message-template" type="text/template">
<li class="message">
<div></div>
<div></div>
</li>
</script>
对于第一个div
,我们将提供一个类,这个类将等于message__title
。这是消息标题信息将要放置的地方。我们将在这里开始,通过提供一个h4
标签,为屏幕呈现一个漂亮的标题,我们将在h4
内放置from
数据,我们可以通过使用那些双花括号{{from}}
来实现:
<script id="message-template" type="text/template">
<li class="message">
<div class="message__title">
<h4>{{from}}</h4>
</div>
对于span
,情况完全相同,这将在下一步发生。我们将添加一个span
标签,在span
标签内,我们将注入createdAt
,添加我们的双花括号,并指定属性名称:
<script id="message-template" type="text/template">
<li class="message">
<div class="message__title">
<h4>{{from}}</h4>
<span>{{createAt}}</span>
</div>
添加消息正文标签
现在我们可以继续进行实际的消息正文。这将在我们的第二个div
内进行,我们将为其指定一个类。第二个div
的类将等于message__body
,对于基本消息,即非基于位置的消息,我们将只需添加一个段落标签,并通过提供两个花括号后跟text
来在其中呈现我们的文本:
<script id="message-template" type="text/template">
<li class="message">
<div class="message__title">
<h4>{{from}}</h4>
<span>{{createdAt}}</span>
</div>
<div class="message__body">
<p>{{text}}</p>
</div>
</li>
</script>
有了这个系统,我们实际上有一个非常好的消息模板渲染系统。代码,标记,都在message-template
内定义,这意味着它是可重用的,而且在index.js
内。我们只需要一点点代码来把一切都连接起来。这是一个更可扩展的解决方案,比起像我们为newLocationMessage
那样管理元素要容易得多。我将保存index.js
,进入浏览器,然后刷新一下。
当我们这样做时,我们现在可以看到消息This is some message
的样式很好。我将发送它;我们得到了名称,时间戳和文本的打印。它看起来比之前好多了:
为 newLocation 消息创建模板
现在我们的发送位置消息看起来仍然很糟糕。如果我点击发送位置,需要几秒钟才能完成,然后就是这样!它没有样式,因为它没有使用模板。我们要做的是为newLocationMessage
添加一个模板。我们将为模板设置标记,然后呈现它并传入必要的值。
在index.html
内,我们可以通过创建第二个模板来开始这样做。第二个模板将与第一个非常相似。我们实际上可以通过复制并粘贴此模板来创建第二个模板。我们只需要将id
属性从message-template
更改为location-message-template
:
<script id="location-message-template" type="text/template">
<li class="message">
<div class="message__title">
<h4>{{from}}</h4>
<span>{{createAt}}</span>
</div>
<div class="message__body">
<p>{{text}}</p>
</div>
</li>
</script>
现在标题区域将是相同的。我们将有我们的from
属性以及createdAt
;正文将会改变。
而不是呈现带有文本的段落,我们将呈现带有链接的段落,使用锚标签。现在,我们将添加锚标签。然后在href
属性内,我们将注入值。这将是从服务器传递到客户端的 URL。我们将添加等号,花括号,我们要添加的值是url
:
<div class="message__body">
<p>
<a href="{{url}}"
</p>
</div>
接下来,我们将继续使用target
属性,将其设置为_blank
,这将在新标签页中打开链接。最后,我们可以关闭锚标签,并在其中添加链接的文本。这个链接的好文本可能是我的当前位置
,就像我们现在的一样:
<script id="location-message-template" type="text/template">
<li class="message">
<div class="message__title">
<h4>{{from}}</h4>
<span>{{createdAt}}</span>
</div>
<div class="message__body">
<p>
<a href="{{url}}" target="_blank">My current location</a>
</p>
</div>
</li>
</script>
这就是我们为模板需要做的全部。接下来,我们将在index.js
中连接所有这些内容,这意味着在newLocationMessage
中,你要做的事情与我们之前在newMessage
中做的事情非常相似。你不再使用 jQuery 来渲染所有内容,而是要渲染模板,传入必要的数据、文本、URL 和格式化的时间戳。
渲染 newLocation 模板
我们要做的第一件事是注释掉我们不再需要的代码;那就是除了变量formattedTime
之外的所有内容:
socket.on('newLocationMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
// var li = jQuery('<li></li>');
// var a = jQuery('<a target="_blank">My current location</a>');
//
// li.text(`${message.from} ${formattedTime}: `);
// a.attr('href', message.url);
// li.append(a);
// jQuery('#message').append(li);
});
接下来,我们将从 HTML 中获取模板,创建一个名为template
的变量,并使用jQuery
通过 ID 选择它。在引号内部,我们将添加我们的选择器。我们要通过 ID 选择,所以我们会添加这个。 #location-message-template
是我们提供的 ID,现在我们要调用html
来获取它的内部 HTML:
socket.on('newLocationMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#location-message-template').html();
接下来,我们将实际渲染模板,创建一个名为html
的变量来存储返回值。我们将调用mustache.render
。这需要两个参数,你要渲染的模板和你要渲染到该模板中的数据。现在数据是可选的,但我们确实需要传递一些数据,所以我们也会提供那个。template
是我们的第一个参数,第二个参数将是一个对象:
socket.on('newLocationMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#location-message-template').html();
var html = Mustache.render(template, {
});
我将从from
设置为message.from
开始,我们也可以用url
做同样的事情,将其设置为message.url
。对于createdAt
,我们将使用formattedTime
变量,createdAt
设置为formattedTime
,这在newMessage
模板中已经定义:
socket.on('newLocationMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#location-message-template').html();
var html = Mustache.render(template, {
from: message.from,
url: message.url,
createdAt: formattedTime
});
现在我们可以访问我们需要渲染的 HTML。我们可以使用 jQuery 选择器来选择 ID 为 messages 的元素,并且我们将调用 append 来添加一个新消息。我们要添加的新消息可以通过html
变量获得:
socket.on('newLocationMessage', function(message) {
var formattedTime = moment(message.createdAt).format('h:mm a');
var template = jQuery('#location-message-template').html();
var html = Mustache.render(template, {
from: message.from,
url: message.url,
createdAt: formattedTime
});
jQuery('#messages').append(html);
});
既然我们已经完全转换了我们的函数,我们可以删除旧的注释掉的代码,保存文件,并在 Chrome 中测试一下。我将刷新页面以加载最新的代码,我会发送一条文本消息来确保它仍然有效,现在我们可以发送一个位置消息。我们应该在短短几秒内看到新数据的渲染,它确实按预期工作:
我们有名字、时间戳和链接。我可以点击链接,确保它仍然有效。
有了这个设置,我们现在有了一个更好的前端模板创建设置。我们不再需要在index.js
中做繁重的工作,我们可以在index.html
中做模板,只需传入数据,这是一个更可扩展的解决方案。
既然我们已经完成了这一切,我们可以关闭服务器并运行git status
提交我们的更改。我们有一个新文件以及一些修改过的文件,git add .
会为我们处理所有这些,然后我们可以进行提交,git commit
带有-am
标志。实际上,我们已经添加了,所以我们可以只使用-m
标志,Add mustache.js for message templates
:
**git commit -m 'Add mustache.js for message templates'**
我将把这个推送到 GitHub,然后我们可以继续快速部署到 Heroku,使用git push heroku master
。我要把这个推上去,只是为了确保所有模板在 Heroku 上的渲染与本地一样。部署应该只需要一秒钟。一旦部署完成,我们可以通过运行heroku open
或者像以前一样获取 URL 来打开它。这里正在启动应用程序:
看起来一切都如预期那样进行。我要获取应用程序的 URL,切换到 Chrome,并打开它:
现在我们正在实时查看我们的应用程序在 Heroku 中,消息数据如预期显示出来。发送位置时也应该是如此,发送位置消息应该使用新的设置,而它确实如预期般工作。
自动滚动
如果我们要构建一个前端,我们最好做到完美。在这一部分,我们将添加一个自动滚动功能。所以如果有新消息进来,它会在消息面板中可见。现在立即来看,这并不是问题。我输入一个a
,按下enter,它就出现了。然而,当我们滚动列表到底部时,你会看到消息开始消失在底部的栏中:
现在我确实可以向下滚动查看最近的消息,但如果能自动滚动到最近的消息就更好了。所以如果有新消息进来,比如123
,我会自动滚动到底部。
显然,如果有人向上滚动阅读旧消息,我们会希望让他们留在那里;我们不会想要把他们滚动到底部,那会和一开始看不到新消息一样让人讨厌。这意味着我们将继续计算一个阈值。如果有人能看到最后一条消息,我们将在有新消息进来时滚动他们到底部。如果我在那条消息之前,我们将继续让他们保持原样,没有理由在他们查阅档案时把他们滚动到底部。
运行高度属性计算
为了做到这一点,我们将不得不进行计算,获取一些属性,主要是各种东西的高度属性。现在来谈谈这些高度属性,确切地弄清楚我们将如何进行这个计算,我已经准备了一个非常简短的部分。让我们继续深入。为了说明我们将如何进行这个计算,让我们看一下以下示例:
我们有这个浅紫色的框,比深紫色的要高。这是整个消息容器。它可能包含的消息要比我们在浏览器中实际看到的要多得多。深紫色区域是我们实际看到的部分。当我们向下滚动时,深紫色区域会向下移动到底部,当我们向上滚动时,它会向上移动到顶部。
现在我们可以访问三个高度属性,这些属性将让我们进行必要的计算,以确定是否应该向用户滚动到底部。这些属性如下:
-
首先是
scrollHeight
。这是我们消息容器的整个高度,不管在浏览器中实际可见多少。这意味着如果我们在可见部分之前和之后有消息,它们仍然会在scrollHeight
中计算。 -
接下来是
clientHeight
。这是可见高度容器。 -
最后,我们有
scrollTop
。这是我们向紫色容器滚动的像素数。
在当前情况下,我们想做什么?我们什么都不想做,用户实际上并没有滚动得那么远。如果每次有新消息进来就把他们带到底部,这对他们来说是一种负担。
在下一个场景中,我们再向下滚动一点:
scrollTop
增加了,clientHeight
保持不变,scrollHeight
也是如此。现在如果我们继续向下滚动列表,最终我们会到达底部。目前,我们不应该做任何事情,但当我们到达底部时,计算看起来会有些不同:
在这里,您可以看到scrollTop
值,即我们可以看到的前一个空间,加上clientHeight
值等于scrollHeight
。这将是我们方程的基础。如果scrollTop
加上clientHeight
等于scrollHeight
,我们确实希望在新消息进来时将用户滚动到底部,因为我们知道他们已经在面板的底部。在这种情况下,我们应该怎么做?当新消息进来时,我们应该滚动到底部。现在有一个小小的怪癖:
我们将考虑到新的messageHeight
,在我们的计算中添加scrollTop
,clientHeight
和messageHeight
,将该值与scrollHeight
进行比较。使用这个方法,我们将再次能够将用户滚动到底部。
让我们继续在 Atom 中连接这个。现在我们知道了如何运行这个计算,让我们继续在index.js
中实际执行。我们将创建一个新的函数,它将为我们完成所有这些繁重的工作。它将根据用户的位置确定是否应该将用户滚动到底部。让我们在index.js
的顶部创建一个函数。它不会接受任何参数,我们将把这个函数称为scrollToBottom
:
var socket = io();
function scrollToBottom () {
}
每次向聊天区域添加新消息时,我们将调用scrollToBottom
,这意味着我们需要在newMessage
和newLocationMessage
中各调用一次。在newLocationMessage
回调函数中,我可以调用scrollToBottom
,不传入任何参数:
socket.on('newMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#message-template').html();
var html = Mustache.render(template, {
text: message.text,
from: message.from,
createdAt: formattedTime
});
jQuery('#message').append(html);
scrollToBottom();
});
当我们附加scrollToBottom
时,我会做同样的事情:
socket.on('newLocationMessage', function (message) {
var formattedTime = moment(message.createAt).format('h:mm a');
var template = jQuery('#message-template').html();
var html = Mustache.render(template, {
from: message.from,
url: message.url,
createdAt: formattedTime
});
jQuery('#message').append(html);
scrollToBottom();
});
现在我们需要做的就是将这个函数连接起来:
-
确定是否应该将它们滚动到底部,以及
-
如果有必要,将它们滚动到底部。
创建一个新变量将消息滚动到底部
首先,我们将选择消息容器,并创建一个新变量来存储它。我们实际上将创建相当多的变量来运行我们的计算,所以我将添加两个注释,选择器
和高度
。这将帮助我们分解这长长的变量列表。
我们可以创建一个变量,我们将把这个变量称为messages
,然后我们将把messages
设置为一个jQuery
选择器调用。我们将选择所有 ID 等于messages
的元素,这只是我们的一个元素:
function scrollToBottom () {
// Selectors
var message = jQuery('#message');
现在我们已经准备好了消息,我们可以专注于获取这些高度。我们将继续获取clientHeight
,scrollHeight
和scrollTop
。首先,我们可以创建一个名为clientHeight
的变量,将其设置为messages
,然后我们将调用prop
方法,这给了我们一种跨浏览器的方法来获取属性。这是一个没有 jQuery 的 jQuery 替代方法。这确保它在所有浏览器中都能正常工作,无论他们如何调用prop
。我们将继续提供,用引号括起来,clientHeight
来获取clientHeight
属性:
function scrollToBottom () {
// Selectors
var message = jQuery('#message');
// Heights
var clientHeight = message.prop('clientHeight');
}
我们将为另外两个值做完全相同的事情两次。scrollTop
将被设置为messages.prop
获取scrollTop
属性,最后scrollHeight
。一个名为scrollHeight
的新变量将存储该值,我们将把它设置为messages.prop
,传入我们想要获取的属性scrollHeight
:
function scrollToBottom() {
//selectors
var messages = jQuery('#messages');
//Heights
var clientHeight = messages.prop('clientHeight');
var scrollTop = messages.prop('scrollTop');
var scrollHeight = messages.prop('scrollHeight');
}
现在我们已经准备就绪,可以开始计算了。
确定计算
我们想要弄清楚scrollTop
加上clientHeight
是否大于或等于scrollHeight
。如果是,那么我们就要滚动用户到底部,因为我们知道他们已经接近底部了,if (clientHeight + scrollTop is >= scrollHeight)
:
var scrollHeight = message.prop('scrollHeight');
if (clientHeight + scrollTop >= scrollHeight) {
}
如果是这样的话,我们将继续做一些事情。现在,我们将使用console.log
在屏幕上打印一条小消息。我们将只打印Should scroll
:
if (clientHeight + scrollTop >= scrollHeight) {
console.log('Should scroll');
}
现在我们的计算还没有完成,因为我们正在运行这个函数。在我们附加新消息之后,我们确实需要考虑到这一点。正如我们在 Atom 中看到的那样,如果我们可以看到最后一条消息,我们确实希望将它们滚动到底部;如果我在列表中更靠上,我们就不会将它们滚动。但是如果我离底部很近,前面几个像素,我们应该将它们滚动到底部,因为这很可能是他们想要的。
考虑新消息的高度
为了完成这个任务,我们必须考虑新消息的高度和上一条消息的高度。在 Atom 中,我们将首先添加一个选择器。
我们将创建一个名为newMessage
的变量,这将存储最后一个列表项的选择器,在滚动到底部之前刚刚添加的选择器。我将使用jQuery
来完成这个任务,但我们不需要创建一个新的选择器,实际上我们可以基于之前的选择器messages
进行构建,然后调用其children
方法:
function scrollToBottom () {
// Selectors
var message = jQuery('#message');
var newMessage = message.children();
这使您可以编写一个特定于消息子级的选择器,这意味着我们有了所有的列表项,因此我们可以在另一个上下文中选择我们的列表项,也许我们想选择所有的段落子级。但在我们的情况下,我们将使用last-child
修饰符选择最后一个子级的列表项:
var newMessage = messages.children('li:last-child');
现在我们只有一个项目,列表中的最后一个列表项,我们可以继续通过创建一个名为newMessageHeight
的变量来获取其高度,就在scrollHeight
变量旁边。我们将把它设置为newMessage
,然后调用其innerHeight
方法:
var scrollHeight = messages.prop('scrolHeight');
var newMessageHeight = newMessage.innerHeight();
这将计算消息的高度,考虑到我们通过 CSS 应用的填充。
现在我们需要考虑第二个到最后一个消息的高度。为此,我们将创建一个名为lastMessageHeight
的变量,并将其设置为newMessage
,然后调用prev
方法。这将使我们移动到上一个子元素,因此如果我们在最后一个列表项,现在我们在倒数第二个列表项,我们可以再次调用innerHeight
来获取其高度:
var newMessageHeight = newMessage.innerHeight();
var lastMessageHeight = newMessage.prev().innerHeight();
现在我们也可以在if
语句中考虑这两个值。我们将把它们相加,newMessageHeight
,我们还将考虑到lastMessageHeight
,并将其加入我们的计算中:
function scrollToBottom() {
//selectors
var messages = jQuery('#messages');
//Heights
var clientHeight = messages.prop('clientHeight');
var scrollTop = messages.prop('scrollTop');
var scrollHeight = messages.prop('scrollHeight');
var newMessageHeight = newMessage.innerHeight();
var lastMessageHeight = newMessage.prev().innerHeight();
if(clientHeight + scrollTop + newMessageHeight + lastMessageHeight >= scrollHeight) {
console.log('Should scroll');
}
}
现在我们的计算完成了,我们可以测试一下是否一切都按预期工作。我们应该在应该滚动时看到Should scroll
。
测试计算
在浏览器中,我将继续刷新,然后打开开发者工具,这样我们就可以查看我们的console.log
语句。您会注意到在较小的屏幕上,样式会移除侧边栏。现在我要按enter几次。显然,我们不应该发送空消息,但现在我们可以,您会看到Should scroll
正在打印:
实际上不会滚动,因为我们的消息容器的高度实际上并没有超过浏览器空间给定的高度,但它确实满足条件。现在随着我们继续向下滚动,消息开始从屏幕底部消失,您会注意到消息前面的计数停止增加。每次打印Should scroll
时计数都会增加,但现在即使我添加了新消息,它仍然停留在 2。
在这种情况下,我们可以滚动到底部并添加一条新消息abc
。这应该会导致浏览器滚动,因为我们离底部很近。当我这样做时,Should scroll
增加到 3,这太棒了。
如果我滚动到列表顶部,输入123
并按回车键,应该不会滚动到 4,这是正确的。如果用户在顶部,我们不希望将其滚动到底部。
在必要时滚动用户
现在唯一剩下的事情就是在必要时实际滚动用户。这将发生在我们的if
语句中,我们可以删除console.log('Should scroll')
的调用,并将其替换为对messages.scrollTop
的调用,这是设置scrollTop
值的 jQuery 方法,我们将其设置为scrollHeight
,这是容器的总高度。这意味着我们将移动到消息区域的底部:
if(clientHeight + scrollTop + newMessageHeight + lastMessageHeight >= scrollHeight) {
messages.scrollTop(scrollHeight);
}
在 Google Chrome 中,我们现在可以刷新页面以获取最新的index.js
文件,然后我会按住回车键一小会儿。正如你所看到的,我们正在自动滚动列表。如果我添加新消息,它将正确显示。
如果我靠近顶部,新消息进来,比如123
,我不会滚动到列表底部,这是正确的。现在,如果我不是在底部,但很接近,新消息进来,我会滚动到底部。但如果我稍微超过最后一条消息,我们将不会滚动到底部,这正是我们想要的。所有这些都是因为我们的计算。
提交与计算相关的更改
让我们在终端中用一个提交来结束这一切。如果我们运行git status
,你会看到我们只有一个更改的文件。我可以使用git commit -am
来进行提交,如果用户接近底部,则滚动到底部
:
**git commit -am 'Scroll to bottom if user is close to bottom'**
我将继续使用git push
命令将其推送到 GitHub,这被认为是项目的第一部分结束。
总结
在本章中,我们研究了如何在 HTML 格式中为基本聊天应用程序添加样式。我们还讨论了时间戳和使用 Moment 方法格式化页面。之后,我们学习了 Mustache.js 的概念,创建和渲染消息模板。最后,我们了解了自动滚动和使用消息高度属性进行计算。有了这些,我们已经有了一个基本的聊天应用程序。
在下一章中,目标是添加聊天室和名称,所以我去注册页面。我输入我想加入的房间和我想使用的名称。然后我被带到一个聊天页面,但只针对特定的房间。因此,如果有两个房间,房间 1 的用户将无法与房间 2 的用户交谈,反之亦然。
第八章:加入页面和传递房间数据
在上一章中,我们研究了如何将我们的聊天页面样式更像一个真正的 Web 应用程序,而不是一个未经样式化的 HTML 页面。在本章中,我们将继续讨论有关聊天页面的内容,并研究加入页面和传递房间数据。我们将更新我们的 HTML 文件,并为聊天页面添加表单字段。
我们将获取名称和房间数值,并将它们从客户端传递到服务器,以便服务器可以跟踪谁在哪个房间,我们可以建立私人通信。我们还将为数据验证创建测试用例。
添加加入页面
这一部分的目标是添加一个加入页面,就像您在下面的截图中看到的那样,您可以在那里提供一个名称,然后提供您想要加入的房间名称。
然后您将能够加入指定的房间,与该房间中的任何其他人交谈,您将无法与其他房间中的其他人进行通信:
这意味着当您点击这个表单时,您将点击Join
,我们将一些自定义信息传递到 URL 中,就像我们知道的那样,它看起来像这样的聊天应用程序:
更新 HTML 文件
为了完成这个目标,我们要做的第一件事是调整当前的 HTML 文件。现在,index.html
将首先加载。实际上,我们不希望这样,当我们转到localhost:3000
时,我们希望显示我们的新加入页面。所以我要做的是将这个页面移动,通过重命名。我们将把index.html
重命名为chat.html
。我将用相同的方法重命名index.js
,将其重命名为chat.js
。
最后但同样重要的是,我将更新加载index.js
的脚本中的引用;相反,我们将加载chat.js
。既然我们已经有了这个,当您访问该站点时,将不再加载 HTML 页面。如果我尝试转到localhost:3000
,我们将收到一个错误,说我们无法获取该路由,服务器没有返回任何内容:
为了解决这个问题,我们将创建一个全新的页面作为index.html
。这将是用户访问应用时加载的页面。现在我们将从非常基本的模板开始,指定我们以前做过的事情,比如DOCTYPE
,将其设置为 HTML5,然后我们将添加我们的html
标签。在这里面,我们可以添加我们的head
和body
标签:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
</body>
</html>
在 HTML 文件中添加头标签
我将首先添加我的head
标签,然后可以继续添加body
标签。然后我们将通过在头部添加一些标签来启动一些事情,比如meta
,这样我们就可以设置我们的字符集,它将具有一个值为utf-8
。我们还将设置一些其他属性:
<head>
<meta charset="utf-8">
</head>
我将设置一个title
标签,这将显示在标签的title
中,我们可以将我们的设置为Join
,然后我们可以添加一个空格,使用|
添加一个竖线,以及我们的应用名称,类似于ChatApp
:这将显示在任何标签标题中。然后我们可以继续链接我们的样式表,就像我们为chat.html
所做的那样。我将从chat.html
中获取样式表引用,复制到头部中:
<head>
<meta charset="utf-8">
<title>Join | ChatApp</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
我将在chat.html
中添加一个title
标签。在chat.html
中,我们可以指定标题,就像我们在index.html
中所做的那样。我们可以给这个页面一个Chat
的标题,中间用空格包围,我们还将给它相同的应用名称,ChatApp
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Chat | ChatApp</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
既然我们已经有了这个,开始更新body
之前,我想做的最后一件事是设置一个viewport
标签。viewport
标签允许您指定有关如何呈现您的站点的某些信息。我即将添加的viewport
标签将使我们的网站在移动设备上显示得更好。它不会被放大,而是会适应您的手机、平板电脑或任何其他设备的宽度。
当我们完成后,我们将把这个标签从index.html
复制到chat.html
,但现在我们将通过添加一个meta
标签来开始。
这一次,与过去不同的是,我们不再指定字符集,而是将其命名为viewport
:
<head>
<meta charset="utf-8">
<title>Join | ChatApp</title>
<meta name="viewport" content="">
<link rel="stylesheet" href="/css/style.css">
</head>
现在我们可以继续添加一些关于我们想要对viewport
做的选项。所有这些都将发生在内容内。这将是一个以逗号分隔的键值对列表,例如width
将是device-width
。这告诉您的浏览器使用设备的宽度
作为网页的宽度
,然后我们可以添加一个逗号,一个空格,并指定我们的下一个键值对。我将使用initial-scale
并将其设置为1
。这将适当地缩放网站,使其看起来不会被放大,最后user-scalable
将设置为no
:
<head>
<meta charset="utf-8">
<title>Join | ChatApp</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="/css/styles.css">
</head>
我们已经设置了样式,使文本变大,并确保用户始终可以看到所有内容,因此没有理由让用户有能力进行缩放。现在,正如前面提到的,我们将复制这个meta
标签到剪贴板,并在chat.html
中也添加它。现在我们已经为index.html
设置好了head
标签,我们可以继续处理body
。
在 HTML 文件中添加 body 标签
我们将使用一组类似的类来设计与此页面配合使用。首先,在body
标签上,我们将添加一个名为centered-form
的类:
<body class="centered-form">
</body>
</html>
这将使表单在屏幕中居中,并为整个网站提供蓝色渐变的背景。接下来,我们可以继续提供小框。这将是我们的表单所在的居中白色框。这将通过div
实现,我们将给这个div
一个类。我们将把这个类设置为,用引号括起来,centered-form__form
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Join | ChatApp</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body class="centered-form">
<div class="centered-form__form">
</div>
</body>
</html>
现在我们已经设置好了这两个类,我们准备继续添加一些将放在白色框中的字段,首先是标题Chat
。
为聊天页面添加表单字段
为了做到这一点,我们将创建一些div
。它们都将看起来相同,所以我们将制作一次并复制它们。我们将添加一个等于form-field
的类。现在我们将使用这个四次:标题,名称,房间名称和按钮。为了完成这个任务,我们将简单地复制这一行并粘贴它四次:
<body class="centered-form">
<div class="centered-form__form">
<div class="form-field"></div>
<div class="form-field"></div>
<div class="form-field"></div>
<div class="form-field"></div>
</div>
现在所有这些都需要放在一个form
标签内。这个页面的整个目标是获取数据并提交它,将用户重定向到聊天页面,然后使用他们提供的名称将他们带入特定的聊天室。这意味着我们希望将这些form
字段包裹在一个form
标签内,通过在div
标签的上方打开它,并在下方关闭它,就像这样:
<body class="centered-form">
<div class="centered-form__form">
<form>
<div class="form-field"></div>
<div class="form-field"></div>
<div class="form-field"></div>
<div class="form-field"></div>
</form>
</div>
</body>
现在我们之前看到的form
标签的默认行为是重新加载当前页面,将数据作为查询字符串发布。我们要做的是指定一个action
属性,这样可以自定义要去哪个页面。在这种情况下,我们将转到/chat.html
,这就是我们刚刚设置的页面。这意味着当有人填写完表单字段后,他们将被重定向到chat
页面,并且下面的数据也将被传递。
现在标题div
很容易,我们要做的是添加一个带有你想要的标题的h3
标签;你可以说聊天
或加入聊天
。然后,我可以继续关闭我的h3
:
<form action="/chat.html">
<div class="form-field">
<h3>Join a Chat</h3>
</div>
然后,我可以继续处理下一个表单字段,即显示名称。我将使用一个标签,标签将描述一个字段,这个字段将是显示名称
,所以我们将像这样打开和关闭label
标签:
<div class="form-field">
<label>Display name</label>
</div>
接下来,我们可以添加一个input
。我们将添加一个input
,就像我们为消息form
上的input
所做的那样。我们将指定type
等于text
。我们希望用户能够输入一些文本。我们还将给它一个name
等于name
。我们将再次使用autofocus
,这将确保当用户首次访问应用时,他们的光标会放在名字字段内:
<div class="form-field">
<label>Display name</label>
<input type="text" name="name" autofocus/>
</div>
接下来是房间名称的字段,它看起来会与上面的字段非常相似,我们将再次从label
开始。这个label
将是一个字符串,类似于Room name
,我们还将添加一个输入,以便用户可以指定房间名称,type="text"
,name
将等于room
:
<div class="form-field">
<label>Room name</label>
<input type="text" name="room"/>
</div>
这个不需要autofocus
,因为在前面的代码中我们已经有了一个autofocus
输入。
既然我们已经做好了这一点,我们可以继续填写最终的form
字段,这将是我们表单的提交按钮,只需创建button
标签,并给它一个文本值。我们可以将我们的设置为Join
:
<div class="form-field">
<button>Join</button>
</div>
有了这个,我们的index.html
文件实际上已经完成了。我们可以在浏览器中加载它。我们的服务器已经自动重新启动,所以快速刷新应该会显示我们的页面 加入聊天应用:
目标是设置这个来接受一个名字,比如Andrew
,和一个房间名,比如Node Course Students
:
点击加入,它会将您加入到这个带有这个名字的房间。目前,它只会将我们重定向到chat.html
,但正如你所看到的,它确实传递了数据:
在这里,我们有name
等于Andrew
,我们有room
名称等于Node Course Students
,就像我们在index.html
中指定的那样。有了这个,我们现在准备开始讨论如何加入Socket.io
中的特定房间,确保我们发出的事件只传递给连接到该房间的其他人。我们已经准备好完成这些工作,所以我们将在下一节开始所有这些工作。
目前我们有一个看起来相当不错的加入页面,这是我们可以加入一个房间的地方。让我们用一个提交来结束这一切,提交我们的更改。
提交更改到 index.html
如果我运行git status
,你会看到我们有一个修改过的文件index.html
,它还认为我们删除了index.js
,尽管我们在下面添加了一些东西,但当我们运行git add .
并重新运行git status
时,它会完全理解发生了什么:
在这里,你可以看到我们复制了index.html
到chat.html
,然后修改了index.html
并将index.js
重命名为chat.js
,这正是我们所做的。我将继续使用git commit
进行提交,使用-m
标志,Add join page that submits to chat.html
:
git commit -m 'Add join page that submits to chat.html'
然后我们可以进行提交,将其推送到 GitHub,就到此为止。
传递房间数据
在上一节中,我们创建了一个小的聊天页面。我们可以输入一个名字,比如Andrew
,和一个房间,比如Node Course
,然后我们可以加入那个房间:
现在当我们这样做时,我们被带到了聊天页面,但实际上在幕后并没有发生任何事情来使用这些值,它们出现在 URL 中,但仅此而已。本节的目标是将这些值从客户端传递到服务器,以便服务器可以跟踪谁在哪个房间,并且我们可以建立私人通信。目前,如果用户一在 Node 课程中,用户二在 React 课程中,他们两个都可以互相交谈,因为这些数据没有被使用。
将数据传递到服务器
现在将这些数据发送到服务器的第一步是找出它在哪里;实际上它在location
对象中。我们将使用控制台来玩一下。
location
是浏览器提供的全局对象,在它上面我们有很多有趣的东西,比如host
,hostname
,href
,origin
和pathname
。我们将使用的是search
。
正如您在下面的截图中所看到的,search
是查询字符串,从问号到单词“course”的末尾,我在这里标出了:
目标是将其转换为更有用的格式。现在我们只有字符串,我们需要解析它。既然我们知道它在哪里,我们实际上将使用一个库来做到这一点。我们可以访问window.location.search
来获取这个值:
我在location.search
前面添加了 window,只是为了确保我们访问全局位置属性,而不是可能存在或不存在的名为 location 的局部变量。这将是获取查询字符串的最可靠方法。
params 和 deparams
现在我们要做的下一件事是讨论 params 和 deparams。在 jQuery 内部,我们实际上可以访问一个函数,我们可以通过调用jQuery.param
来访问它,并且我们可以传入一个对象。我们将设置name
等于您的名字,我将其设置为Andrew
,并且我们将设置age
等于25
之类的东西。现在当我运行这个语句时,jQuery 将把该对象转换为一组可以添加到 URL 上的参数:
在这里,您可以看到我们有name=Andrew
和age=25
。这与我们在 URL 中的格式相似,减去了问号。我们只需要在开头添加一个问号,我们就会得到一个完整的搜索字符串。现在 jQuery 的问题是它不能以另一个方向进行操作;也就是说它不能将字符串转换回对象,这正是我们想要的。
我们希望能够轻松访问这些数据,目前这是不可能的。还有一些编码和+
字符之类的怪癖。这原本是一个空格,但它被表单转换为a +
。我们也希望解码所有这些。幸运的是,我们可以包含一个简单的库,我们可以通过访问links.mead.io/deparam来获取它:
param
接受您的对象并返回字符串,deparam
接受字符串并返回对象。在上面的截图中,我们有一个简单的 Gist。这是一个非常简短的函数,我们将要添加到我们的项目中。让我们转到此页面的原始版本。我们将使用右键点击“另存为”保存它,并将其添加到项目中。我们有我们的public
,js
和libs
文件夹。就在libs
文件夹中,我们将简单地将其保存为deparam.js
:
现在一旦我们保存了那个文件,我们就可以包含它。这将使处理搜索数据变得更加容易。在 Atom 中,我将转到chat.html
。我们不需要在index.html
中使用它,但在chat.html
中,我们将在mustache.js
脚本下面加载它。我们将创建一个新的script
标签,并且我们将设置src
等于,引号内,/js/libs/deparam.js
:
<script src="img/socket.io.js"></script>
<script src="img/jquery-3.3.1.min.js"></script>
<script src="img/moment.js"></script>
<script src="img/mustache.js"></script>
<script src="img/deparam.js"></script>
<script src="img/chat.js"></script>
现在当我们保存chat.html
并返回浏览器时,我们实际上可以刷新页面,并在添加到我们的代码之前在控制台中尝试这个。我们现在可以访问jQuery.deparam
。如果我运行这个语句,我们将得到我们的函数返回,确认它确实存在,我们只需要传递这个字符串,这就是搜索字符串,window.location.search
:
所以我们将搜索字符串传递给deparam
,得到的对象正是我们想要的。我们有一个name
属性等于Andrew
,我们有一个room
属性等于Node Course
。所有那些特殊字符,如&
符号,问号和+
字符,都已被移除并替换为这个格式化的对象。这是我们将在客户端代码中使用的内容,以获取这些值并将它们传递给服务器,这就是我们现在要做的。
Atom 内部所有的事情都将在chat.js
中发生。在这个文件中,我们有我们的connect
回调函数。当我们第一次连接时,就会发生这种情况,当我们连接时,我们将发出一个事件,这将启动加入房间的过程:
socket.on('connect', function () {
console.log('Connected to server');
});
现在Socket.io
内置支持房间的概念,创建小的隔离区域,只有特定的人可以发出和监听事件。所有这些都在服务器上设置,这意味着就在这个函数中。当我们连接到服务器时,我们要做的就是发出一个名为join
的事件;这将开始这个过程。
首先,让我们继续获取我们的参数,就是我们刚学会如何在控制台中deparam
的参数,var params = jQuery.deparam
,然后我们将window.location.search
传递进去,就像我们之前在开发者控制台中做的那样。现在我们有了我们的对象,我们可以继续发出一个事件。接下来,我们将调用socket.emit
,我们要发出的事件将是我们将创建的自定义事件,它将被称为join
:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join')
});
这将从客户端发出,并将被服务器监听。当服务器听到这个join
事件时,它将开始设置房间的过程。现在,并不是所有的事情都会在这一部分发生,但我们可以开始。我们要发送的数据只是params
对象:
socket.emit('join', params)
它可能包括或不包括我们需要的一切。我们将在服务器上进行一些验证,最后但并非最不重要的是,我们将为此设置确认。
如果有人加入房间,我们想知道。我们也想知道如果有人没有加入。这是因为如果他们没有加入房间,很可能是因为他们提供了无效的数据,这意味着我们希望将他们踢回到加入表单,强迫他们提供姓名和房间名。我们可以继续设置我们的function
,这个function
可以带一个参数。我们将自己设置它,所以我们可以决定它是否带有参数,而且它确实有意义带一个参数。在这种情况下,我们将继续提供任何错误。如果有错误,那没问题,我们将能够处理它。如果没有错误,那也很好;我们将继续做其他事情:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join', params, function (err) {
});
});
在这个函数中,如果存在错误,我们可以使用if (err)
来处理。我们还可以添加一个else
子句;如果没有错误,我们想做另一件事:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join', params, function (err) {
if(err) {
} else {
});
});
现在我们暂时不会填写这部分,我们现在要做的是在server.js
中设置join
的监听器。
在 server.js 中设置监听器
这将在server.js
中发生。我们可以将它作为第一个事件放在createMessage
的上面,socket.on('join')
:
socket.on('join');
现在我们已经知道我们将在回调函数中获取一些参数。我们将得到我们的参数。我们可以称这些为params
,并获取callback
函数,因为我们正在设置确认:
socket.on('join', (params, callback) => {
});
在join
函数内,我们要做的第一件事实际上是验证传递过来的数据,包括名称和房间。这两者都是params
上的潜在属性。我们真正关心的是它们都是非空字符串;这意味着它不仅仅是一个空格,不仅仅是一个空字符串,也不是一个非字符串类型,比如数字或对象。
定义 isRealString 函数
为了设置这个验证,我们将在其他地方也要做,比如createMessage
,我们将创建一个单独的utils
文件。在这里,我将称之为validation.js
,这是我们可以放一些我们在整个项目中需要的验证器的地方。
在这一部分,我们将创建一个叫做isRealString
的函数。这将验证一个值是否是字符串类型,而不仅仅是一堆空格;它实际上在其中有真正的字符。我们将把这个设置为一个接受字符串的函数。这将是我们要验证的字符串,实际上并不会太困难。我们将return
,并且我们将return
以下条件。如果它是一个真实的字符串,它将返回true
,如果不是,它将返回false
。首先,我们将使用typeof
。这将获取字符串变量的类型,这需要等于,用引号括起来,string
:
var isRealString = (str) => {
return typeof str === 'string';
};
现在,当前这个对于任何字符串都将返回true
,对于任何非字符串值都将返回false
,但它没有考虑到空格。我们要做的是使用字符串上可用的trim
方法,它接受这样的字符串:
''
并将其转换为这样的字符串,修剪所有空格:
' '
如果你传入这样的字符串,它会将其转换为这样的字符串:
' f '
仅修剪前导和尾随空格:
'f'
它不会修剪任何内部空格,所以如果我有f
空格r
像这样:
' f r '
我仍然会得到f
和r
之间的空格,但所有前导和尾随空格都被移除了:
'f r'
我们将像这样使用它:
var isRealString = (str) => {
return typeof str === 'string' && str.trim().length > 0;
};
在我们调用trim
之后,我们确实需要一个长度大于0
,否则我们就有了一个空字符串。这将是我们的isRealString
函数,我们将继续导出它,module.exports
,将其设置为一个对象,其中我们将isRealString
设置为isRealString
函数:
var isRealString = (str) => {
return typeof str === 'string' && str.trim().length > 0;
};
module.exports = {isRealString};
现在我可以继续保存这个文件。我也要继续在server.js
内部调用这个函数。
在 server.js 中调用 isRealString 函数
我们需要先导入isRealString
函数,然后我们才能验证这两个属性。我们可以在generateMessage
常量的下面创建一个const
,并使用 ES6 解构来获取isRealString
,我们将使用require
来获取它。我们需要一个本地文件./
。它在utils
目录中,文件名是validation
,就像这样:
const {generateMessage, generateLocationMessage} = require('./utils/message');
const {isRealString} = require('./utils/validation');
现在我们可以在join
内部调用isRealString
;这正是我们要做的。我们要检查它们中的任何一个是否不是真实的字符串。如果一个或多个不是真实的字符串,我们将调用callback
并传入错误。我们将添加if (params.name)
作为第一个,并将其传递给isRealString(params.name)
:
socket.on('join', (params, callback) => {
if(isRealString(params.name))
});
现在我们想要检查它是否不是一个真实的字符串。所以我们要翻转它,或者(||
),然后我们要检查另一个属性,房间名,是否不是一个真实的字符串。现在在查询字符串内,房间名属性被称为room
。所以如果它不是isRealString
传入正确的值params.room
,那也会是一个错误:
socket.on('join', (params, callback) => {
if(!isRealString(params.name) || !isRealString(params.room))
});
接下来,我们可以通过添加错误处理程序函数来处理该错误。对于这个,我要做的是用一个小字符串消息Name and room name are required
调用callback
:
socket.on('join', (params, callback) => {
if(!isRealString(params.name) || !isRealString(params.room)) {
callback('Name and room name are required.');
}
});
现在如果不是这种情况,我们仍然希望调用callback
,但我们不希望传递任何参数:
socket.on('join', (params, callback) => {
if(!isRealString(params.name) || !isRealString(params.room)) {
callback('Name and room name are required.');
}
callback();
});
因为记住我们在chat.js
中设置的第一个参数是错误参数,如果两者都有效,我们不想传递任何错误。在server.js
中,我们现在设置了一些非常基本的验证,并且在这里实际上我们不会做任何事情,这将在即将到来的部分中进行。
在 chat.js 中添加错误处理程序
我们要做的就是在chat.js
中添加一些案例:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join', params, function (err) {
if(err) {
} else {
}
});
});
如果这是一个错误,那就是一个相当大的问题,我们将希望通过更改位置下的一个属性将用户发送回应用程序的根部,window.location.href
。在这里,我们可以操纵用户所在的页面,基本上我们将通过将斜杠(/
)值设置为href
属性来将其重定向回到根页面:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join', params, function (err) {
if(err) {
window.location.href = '/';
} else {
}
});
});
现在在我们做任何事情之前,我们可以做任何我们喜欢的事情,也许我们想使用我们选择的框架显示一个模态框,无论是 Foundation、Bootstrap 还是其他任何东西。为了保持简单,我们要做的就是调用alert
传递错误,就像这样:
if(err) {
alert(err);
window.location.href = '/';
} else {
用户将看到一个小小的警报框,然后点击确定,然后被重定向回到主页。现在如果没有错误,我们目前要做的就是使用console.log
打印No error
:
socket.on('connect', function () {
var params = jQuery.deparam(window.location.search);
socket.emit('join', params, function (err) {
if(err) {
alert(err);
window.location.href = '/';
} else {
console.log('No error');
}
});
});
有了这个,让我们继续测试一切是否按预期工作。在浏览器中,我要刷新当前页面。现在,在这里,我们有一个有效的名称和一个有效的房间,所以当我点击刷新按钮时,我们应该看到控制台中没有错误打印,这正是我们得到的:
我们传递的数据确实是有效的。现在我们可以继续转到页面的根部并尝试一些无效的数据。
为了证明这一点,我要做的就是点击加入而不提供任何值。这将带我们到聊天应用程序,你可以看到我们得到了一个小小的警报框,需要名称和房间名称。我们点击确定,这是我们能做的一切,然后我们立即被重定向回到加入聊天:
如果我提供一些有效的数据,比如显示名称为Mike
和房间名称为Developers
,我们将被带到聊天页面,我们将看不到任何错误显示,这太棒了:
现在最后一个快速测试!如果我们只有空格,我将把房间名更改为一堆空格。现在我们点击加入,即使我们在 URL 空间上方有一堆加号表示空格,我们仍然会收到错误:
当我们通过deparam
运行我们的代码时,这些将被替换为空格,错误仍然会发生。现在我们已经有了这个,我们在一个非常好的位置来实际整合下一节的房间。
为新验证函数添加测试用例
首先要做的是为我们刚刚创建的全新验证函数编写一些测试用例,这意味着我们将创建一个名为validation.test.js
的新测试文件。
在这里,我们将加载一个变量称为expect
的期望。我们也可以将其设置为常量。我们将把它设置为require
,并且我们将require
expect
库:
const expect = require('expect');
接下来,我们将加载RealString
,导入isRealString
,并添加三个测试用例。describe
块应该是isRealString
,三个测试用例将如下所示:
-
第一个是
应该拒绝非字符串
值,在这种情况下,我希望你将一个数字对象或其他任何东西传递给isRealString
函数,你应该得到 false。 -
接下来,
应该拒绝只有空格的字符串
。如果我有一个只有一堆空格的字符串,那么它不应该通过isRealString
函数的验证。这也将失败;修剪将删除所有这些空格,长度将为 0。 -
最后,
应该允许包含非空格字符的字符串
。在这种情况下,你可以传入任何你喜欢的东西,一些有效的值。你可以有空格空格 LOTR 代表指环王,开始的空格将被修剪掉,所以这并不重要。你可以添加字母 a,任何有效的字符串都会通过这个。
继续设置这三个测试用例,确保从isRealString
返回正确的布尔值。当你完成后,从终端运行npm test
,确保你的三个测试都通过了。
我们要做的第一件事是通过创建一个变量来导入isRealString
。我们可以将这个变量设置为常量或变量,我会选择常量,我们将使用 ES6 解构来获取isRealString
,并且我们将从 require 调用中获取它,这个 require 调用是我们本地文件./validation
,它就在当前文件validation.test.js
的旁边:
const expect = require('expect');
// import isRealString
const {isRealString} = require('./validation');
现在我们可以添加我们在下面的东西,从我们的描述
块开始。
测试用例 1 - 应该拒绝非字符串值
我们将描述
这个isRealString
函数。然后我们可以添加我们的箭头函数(=>
),在里面,我们可以提供我们的各个测试用例,it
,我将直接复制它,应该拒绝非字符串值
:
describe('isRealString', () => {
it('should reject non-string values')
});
这将是一个同步测试,所以没有理由添加done
参数。在这里,我们将传入一个非字符串值。我将创建一个名为响应的变量,它将存储从isRealString
返回的结果。我们将调用它,传入一些非字符串值。任何值都可以,我将使用一个数字,98
:
describe('isRealString', () => {
it('should reject non-string values', () => {
var res = isRealString(98);
现在在下面,我们可以使用 expect 来断言响应变量是否等于 false,这应该是情况。我们期望响应toBe(false)
:
describe('isRealString', () => {
it('should reject non-string values', () => {
var res = isRealString(98);
expect(res).toBe(false);
});
});
测试用例 2 - 应该拒绝只有空格的字符串
接下来,it('should reject string with only spaces')
。我将粘贴这个文本到我们的it
名称中。然后我们可以继续添加我们的箭头函数(=>
),对于这种情况,我们要做的是创建一个名为响应的变量,传入一些只有空格的字符串。我们仍然会调用isRealString
,然后我们会断言响应为false
,expect(res).toBe(false)
,因为我们没有一个有效的字符串:
it('should reject string with only spaces', () => {
var res = isRealString(' ');
expect(res).toBe(false);
});
测试用例 3 - 应该允许包含非空格字符的字符串
接下来,最后一个测试用例,it ('should allow strings with non-space characters')
。我将把这个文本粘贴到it
函数中,然后我们可以实际设置测试用例。你可以提供一堆不同的值作为isRealString
的参数。我们仍然会创建响应变量。我们仍然会调用isRealString
,但是在这里,我选择传入(' Andrew ')
,这是有效的。trim
函数将在验证过程中删除这些空格:
it('should allow string with non-space characters', () => {
var res = isRealString(' Andrew ');
});
在下面,我们可以期望
响应为 true,toBe(true)
。这就是你需要做的,我们可以继续删除注释,因为我们已经有了实际的代码,并且要做的最后一件事是运行测试用例,以确保我们的代码实际上起作用:
const expect = require('expect');
const {isRealString} = require('./validation');
describe('isRealString', () => {
it('should reject non-string values', () => {
var res = isRealString(98);
expect(res).toBe(false);
});
it('should reject string with only spaces', () => {
var res = isRealString(' ');
expect(res).toBe(false);
});
it('should allow string with non-space characters', () => {
var res = isRealString('D');
expect(res).toBe(true);
});
});
npm test
就可以完成了。这将运行我们的测试套件,现在我们有了isRealString
的三个测试用例,它们都通过了,这太棒了:
现在正如我所提到的,你几乎可以传入任何东西。字母D
将作为一个有效的房间名称或用户名。如果我用D
作为我的字符串重新运行测试套件,测试用例仍然通过。实际上,你传入的内容并不重要,只要它有一个真实的非空格字符。现在我们已经完成了这一步。我们还没有做出提交,因为我们只完成了一半的功能,等我们完成更多之后再提交。
Socket.io 房间
在上一节中,我们在服务器上设置了一个事件监听器,监听加入事件,并进行了一些验证。这至少确保我们有name
和room
名称,这两者都是必需的。
真正的下一步是实际使用Socket.io
库加入房间,这不仅让我们加入房间,还会给我们一组不同的方法。我们可以选择向连接到服务器的每个人发送emit
,或者只向特定房间的人发送,这正是我们要做的。我们希望只向也在“房间”中的其他人发送emit
聊天消息。
现在,为了加入,你要调用socket.join
。socket.join
需要一个字符串name
,我们在params.room
下有这个name
,就像我们在上一节中使用的那样:
socket.on('join', (params, callback) => {
if(!isRealString(params.name) || !isRealString(params.room)) {
callback('Name and room name are required.');
}
socket.join(params.room);
callback();
});
现在我们有一个特殊的地方供在同一个“房间”里的人交谈。现在这是一个字符串,所以它可能是像The Office Fans
这样的东西,或者其他任何东西,你必须通过字符串值加入。不过,现在params.room
就可以完成任务了。
现在你也可以选择使用socket.leave
离开一个房间。socket.leave
,通过它的名称离开房间,例如The Office Fans
,这将把你踢出那个群组,你将不会收到那些私人消息,也就是专门发送给该群组的消息。现在这个过程的下一步是弄清楚如何真正利用这一点:
socket.on('join', (params, callback) => {
if(isRealString(params.name) || !isRealString(params.room)) {
callback('Name and room name are required.');
}
socket.join(params.room);
// socket.leave('The Office Fans');
callback();
});
针对特定用户
将用户添加到房间是很好的,但如果我们不能具体地针对他们和房间中的其他人,那就没有什么用了。事实证明,我们有几种方法可以做到这一点。为了说明我们如何针对特定用户,让我们看看我们在服务器上发送事件的所有方法。
我们使用了io.emit
。这将向每个连接的用户发送事件,这是我们目前对于像createMessage
这样的事情所做的。新消息进来,我们就会将其发送给所有连接的人。接下来,我们使用了socket.broadcast.emit
。我们在newMessage
中使用它,正如我们所知,这会将消息发送给连接到套接字服务器的每个人,除了当前用户。我们在server.js
中使用的最后一个是socket.emit
。这将专门向一个用户发送一个事件。现在我们可以将这些事件转换成它们的房间对应事件。为了将其发送到特定的房间,我们将链接到to
方法。
这将看起来有点像这样。假设我们想向连接到一个房间的每个人发送一个事件,让我们暂时将这个房间称为The Office Fans
。为了做到这一点,我们将调用io.to
。.to
是一个方法,它接受房间名称,就像在加入调用中提供的那样。在我们的情况下,那将是The Office Fans
,就像这样:
socket.join(params.room);
// socket.leave('The Office Fans');
// io.emit ->io.to('The Office Fans')
然后我们将调用emit
。这将向连接到房间The Office Fans
的每个人发送一个事件:
socket.join(params.room);
// socket.leave('The Office Fans');
// io.emit ->io.to('The Office Fans').emit
现在我们也可以用广播做同样的事情,也就是说我们想向房间中的每个人发送一个事件,除了当前用户。为了做到这一点,我们将使用socket.broadcast.to
。这与之前定义的to
方法的工作方式相同,例如传入The Office Fans
,然后我们将调用emit
:
socket.join(params.room);
// socket.leave('The Office Fans');
// io.emit ->io.to('The Office Fans').emit
// socket.broadcast.emit -> socket.broadcast.to('The Office Fans')
这将向The Office Fans
房间中的每个人发送一个事件,除了当前用户,也就是实际调用socket.broadcast
的人。
现在我们使用emit
的最后一种方式是socket.emit
。当我们想要发送一些东西给特定用户时,我们仍然会使用它。没有理由通过房间来定位他们,因为我们只想定位他们:
socket.join(params.room);
// socket.leave('The Office Fans');
// io.emit ->io.to('The Office Fans').emit
// socket.broadcast.emit -> socket.broadcast.to('The Office Fans').emit
// socket.emit
这是我们要向特定房间发出的两种方式。现在为了实际开始连接一些东西,我们可以采取以下两个调用,并将它们移到join
内部,这意味着我们不会告诉某人有人加入了一个房间,直到他们通过调用join
实际加入了房间:
socket.emit('newMessage', generateMessage('Admin', 'Welcome to the chat app'));
socket.broadcast.emit('newMessage', generateMessage('Admin', 'New user joined'));
我们也不会告诉用户他们已经加入了一个房间,直到通话实际上已经进行。如果数据无效,比如名称或房间名称,通话可能不会进行。让我们把这两个调用都删掉,我们只是按原样接受它们并将它们移到下面的加入。暂时我们可以把它们移到我们的评论下面;我会留下评论,这样你以后可以作为参考。现在就在socket.join
行的下面,我们调用socket.emit
,我们emit
一个新消息,欢迎来到聊天应用
:
socket.emit('newMessage', generateMessage('Admin', 'Welcome to the chat app'));
这一行实际上将保持不变,我们仍然只想定位任何特定用户。
下一行将会改变。我们不再广播给每个连接的用户,而是只广播给我们刚刚加入的房间内的用户,使用socket.broadcast.to
,传入params.room
。我们将发出一个新消息,这将让每个人都知道有新用户加入了。
socket.broadcast.to(params.room).emit('newMessage', generateMessage('Admin', 'New user joined'`));
而不是新用户,我们实际上可以指定名称。我们可以访问到。就在这里,我将使用模板字符串首先注入名称,params.name
,然后是已加入
:
socket.broadcast.to(params.room).emit('newMessage', generateMessage('Admin', `${params.name} has joined.`));
测试特定用户设置
现在我们已经准备就绪,我们可以实际测试一下。我们要做的是加入一个房间,然后我们要让第二个用户加入,我们应该看到消息:他们的名字已经加入。我们还要添加第三个用户进来,以确保它实际上只发送消息给一个房间。
在浏览器中,让我们开始创建一个名为用户一
的用户。这个用户将加入一个名为大写A
的房间:
现在我们要继续创建第二个用户,前往localhost:3000
。这个可以叫用户二
,我们要加入房间B
:
如果我在房间A
和房间B
之间切换,你会看到没有加入消息打印出来,因为我们没有加入相同的房间。我们在完全不同的房间,所以我们不应该收到那些消息。
接下来我们要添加第三个用户,这个用户也要加入房间A
,用户三
,房间名称,房间A
,然后我们点击加入。当我们点击加入时,我们可以在标签之间切换并查看我们得到的数据:
这里我们得到了预期的欢迎来到聊天应用。这只会发生是因为我们已经成功加入了房间:
然后在第二个标签中,我们也会收到欢迎来到聊天应用的消息。没有消息欢迎其他用户,因为其他用户加入了房间A
,第一个标签有我们的用户三已加入的消息。这太棒了:
两个用户都在房间A
,所以当有新用户加入时,这个用户应该会收到一条消息,我们已经在这里注入了名称,这是预期的。
摘要
在本章中,我们研究了添加聊天页面。我们建立了一个 HTML 文件,并根据我们的要求定义了head
和body
标签。然后,我们继续传递房间数据。我们研究了params
和deparams
的概念,并创建了用于验证数据的测试用例。在最后一节中,我们讨论了socket.io
房间。我们针对聊天室中的特定用户进行了测试并测试了设置。
对于我们的join
事件监听器来说,事情相对容易,因为我们可以访问到 name 变量和 room 变量。它们实际上是作为参数传递进来的。对于createMessage
和createLocationMessage
来说,情况会更加困难。我们需要找出一种方法来获取给定 socket 的房间,这样我们就可以只向该房间发出emit
。
我们还需要在左侧边栏设置“人员列表”。我们需要弄清楚如何使用io
对象来获取按房间分类的所有人员及其姓名的列表。所有这些都非常重要,因为目前我们发出的消息,例如,将显示给所有用户,而不管他们在哪个房间。为了使其正常工作,为了建立私人房间,我们将持久化这些数据,这将在下一章中讨论。
第九章:ES7 类
正如我们在上一章中讨论的,我们有我们的 socket join
监听器,但问题是监听器内部的信息。一旦回调完成,这些信息就会丢失,比如用户名和房间名,它们没有被持久化在任何地方。我们需要这些信息来完成我们的事件监听器。createMessage
需要知道用户的名称以及房间名称,以便将消息发送到特定的房间,createLocationMessage
也是如此。我们将在本章讨论这一点。
我们将开始学习 ES6 类语法,并在创建users
类和其他一些方法中使用它。我们还将看到如何连接用户列表,在任何用户加入或离开聊天时更新 People 列表。我们还将研究如何向特定房间发送消息,而不是发送给所有用户。
使用 ES6 类存储用户 - 第 I 部分
我们无法访问join
中的数据(用户名和房间名),但我们可以访问一个共同的东西,那就是 socket ID。我们可以访问 socket 变量,socket.id
,并且我们也可以在其他事件监听器中访问它。这将是我们即将创建的数据结构中要使用的内容。我们将创建一个用户数组,可以在其中存储这些信息,当我们想要查找用户时,比如在createMessage
和createLocationMessage
中。我们只需将 ID 传递给某个函数,获取名称和房间名称,然后按照我们的意愿发出事件。
为了完成这项工作,我们将在utils
中创建一个全新的文件。我们将称这个文件为users.js
,这是我们将存储与用户数据结构相关的所有内容的地方。
它将是一个对象数组,每个对象上都会有 ID,这将是 socket ID,一种非常长的字符串:
[{
id: '/#12hjwjhwfcydg',
}]
我们还将有我们的名称。这是用户的显示名称,我的可能是Andrew
;最后是用户加入的房间,这可能是类似于The Office Fans
的东西:
[{
id: '/#12hjwjhwfcydg',
name: 'Andrew',
room: 'The Office Fans'
}]
这是我们需要存储在我们的数据结构中的所有信息,以便用户能够连接一切。
现在真正的工作将在我们将要创建的方法中进行。我们将有四种方法:
-
我们希望能够通过
addUser
方法添加用户;这将需要三个信息,ID、名称和房间名称。 -
当用户离开房间时,我们还需要一种方法来移除用户;记住我们想要在聊天室的左侧边栏中更新 People 列表。我们需要一种方法来添加和移除用户,
removeUser
,我们将通过 socket ID 来移除用户。 -
接下来,我们将有一种方法来获取用户,当我们尝试发送消息时,这将非常方便,就像我们在
createMessage
监听器中所做的那样。我们将希望访问用户的名称以及房间,以便触发newMessage
事件。这将通过一个getUser
方法来实现,它将接受一个 ID,并返回我们在上面定义的对象。 -
我们将要添加的最后一个是
getUserList
。getUserList
方法将获取房间名称,确定在该房间中的确切用户,并返回一个名称数组,并将这些名称打印到客户端。
这四种方法就是我们需要完成这项工作的全部内容。现在我们有几种方法可以做到这一点。我们可以创建一个名为users
的数组,将其设置为空数组,然后定义我们的函数。我们将添加var addUser
,并将其设置为一个接受id
、name
和room
的函数:
var users = [];
var addUser = (id, name, room) => {
}
然后在函数内部,我们将做一些类似于使用users.push
来操作users
数组的操作。然后我们将使用modules.export
导出它,将addUser
函数作为addUsers
属性导出:
var users = [];
var addUser = (id, name, room) => {
users.push({});
}
modules.export = {addUsers};
然后我们会在server.js
中调用addUser
。这种方法也可以,但我们不打算使用这种方法。在这里,我们有一个users
数组,我们可以操作这个数组。它确实完成了工作,但我们打算使用 ES6 类语法。这将让我们创建一个users
类。我们将能够创建该类的新实例,并调用我们将要定义的所有方法。
我要这样做,而不是创建所有用于处理一条信息的特定函数。现在为了完成这个,我们将不得不学习一些新东西;我们将学习关于 ES6 类的知识。
ES6 类语法
为了讨论 ES6 类,让我们继续简单地创建一个。现在创建一个新类的语法一开始看起来可能有点奇怪,它是独特于我们即将做的事情。但我保证一旦你创建了一个或两个类,并添加了一些方法,你会真的习惯于定义方法和类本身。
创建一个人的 ES6 类
为了开始,我们将为一个人创建一个简单的类。这意味着我们正在创建一组数据和一组有用于操作Person
的方法。现在为了开始,我们将使用class
关键字,后面跟着类名Person
。我们将使用大写字母开头来定义我们的类,因为我们将使用new
关键字创建它们的新实例。你不需要使用大写 P;这只是 JavaScript 中的常见约定。如果一个函数是用来与new
一起使用的,比如new Person
,new Object
,或其他任何东西,它应该有一个大写字母开头;这只是一种样式约定。
现在在我们的名字后面,我们可以简单地打开和关闭一些大括号,就这样:
class Person {
}
我们有一个全新的类,甚至可以创建它的一个实例。我们可以创建一个名为me
的变量,并将其设置为new Person
,就像这样调用它作为一个函数:
class Person {
}
var me = new Person();
现在我们有了一个类的新实例,我们可以随心所欲地使用它。目前它什么也不做,但我们已经创建了一个实例。
构造函数
关于类的第一件很棒的事情之一是能够添加一个构造函数。构造函数是一个特殊的函数,它是特定于类的,会自动触发,并允许你初始化你的类的实例。在这种情况下,我们想要在创建new Person
时对个体进行一些自定义。
要定义一个构造函数,我们从名称constructor
开始,但我们不是添加冒号或其他任何东西,我们直接进入我们的函数参数,然后进入大括号:
class Person {
constructor () {
}
}
这是我们的函数,就像一个普通的函数。里面的代码将被执行,括号里是我们的参数,但设置它的语法看起来与我们在对象或其他任何地方所做的事情非常不同。
现在这个constructor
函数会被默认调用。你不需要手动调用它,它实际上会使用你在Person
中指定的参数调用,这意味着我们的Person
构造函数可以接受两个参数;也许我们想要用name
和age
初始化一个新的人。这意味着我们会传入 name 和 age,我可以说 name 是一个字符串,我会把它设置为我的名字,age 是一个像25
这样的数字:
class Person {
constructor (name, age){
}
}
var me = new Person('Andrew', 25);
constructor
函数现在将使用这些数据进行调用,我们可以通过使用console.log
来证明这一点,打印出名字,作为第二个参数年龄:
class Person {
constructor (name, age){
console.log(name, age);
}
}
var me = new Person('Andrew', 25);
现在让我们继续运行这个文件,看看我们得到了什么;它位于server/utils
中。我要关闭nodemon
,并使用以下命令运行它:
**node server/utiles/users.js**
当我运行文件时,我们得到Andrew 25
,因为参数已经正确传递到构造函数中:
现在传递数据实际上并不有用,我们想要做的是修改特定的实例。我们想要设置这个人的名字和年龄,而不是所有人的名字和年龄。为了做到这一点,我们将使用this
关键字。在类方法和构造函数中,this
指的是实例,而不是类,这意味着我们可以在这个个人实例上设置属性,就像这样:this.name = name
。
class Person {
constructor (name, age) {
this.name = name;
}
}
我们可以对年龄做同样的事情,this.age = age
:
class Person {
constructor (name, age) {
this.name = name;
this.age = age;
}
}
使用这种方法可以自定义个体实例。现在我们有了一个对象,我们实际上可以访问这些属性。我们定义的me
变量与this
变量相同,这意味着我们实际上可以访问这些属性。我们将添加console.log
,我将仅打印字符串this.name
以进行格式化,然后我将引用实际的me.name
属性。对于年龄,我们将做同样的事情;我们将打印我们放入的this.age
,只是我们将通过me.age
访问它:
var me = new Person('Andrew', 25);
console.log('this.name', me.name);
console.log('this.age', me.age);
现在我们可以使用nodemon server/utils/users.js
重新运行文件,我们得到了我们期望的结果:
个人已更新;this.name
设置为Andrew
,并且确实显示出来。现在我们对如何初始化类有了基本的了解,让我们继续谈论方法。
方法函数
方法可以是任何函数,它们可以接受参数,也许它们不会接受参数,我们只需要定义它们。在不添加逗号的情况下,我们指定我们的方法名称。我将创建一个名为getUserDescription
的方法:
getUserDescription () {
}
这个方法不会接受任何参数,所以我们可以将参数列表留空。在函数本身内部,我们将返回一个描述,因为方法毕竟被称为getUserDescription
。我们将返回一个模板字符串,将一些值注入其中,一般的流程将是Jen is 1 year(s) old
。
getUserDescription () {
return `Jen is 1 year(s) old`;
}
这就是我们想要打印的内容,但我们想要为这个个人使用那些特定的值,为了做到这一点,我们将再次访问这些属性。我们将注入this.name
,而不是静态名称;而不是静态年龄,我们将注入年龄this.age
:
getUserDescription () {
return `${this.name} is ${this.age} year(s) old`;
}
现在我们可以继续测试getUserDescription
,通过在下面调用它。我们可以创建一个名为description
的变量,将其设置为me.getUserDescription
,然后可以对返回值进行操作,比如使用console.log
将其打印到屏幕上。在 log 参数列表中,我只会传入description
:
class Person {
constructor (name, age){
this.name = name;
this.age = age;
}
getUserDescription() {
return `${this.name} is ${this.age} year(s) old`;
}
}
var me = new Person('Andrew', 25);
var description = me.getUserDescription();
console.log(description);
现在我们可以保存文件,我们应该在终端中看到我们的description
;在这种情况下,Andrew
和25
,Andrew is 25
岁。当我保存文件时,nodemon
会重新启动,然后我们就会在屏幕上看到Andrew is 25 year(s) old
。
这是类的基础知识,有很多与类相关的特性我们暂时不会探索,但是现在这给了我们一切我们需要开始的东西。我们将创建一个users
类,而不是Person
类,我们将创建自定义方法,而不是像getUserDescription
这样的方法。我们还将在进行时添加测试用例,以确保它们按预期工作。
添加users
类
首先,我们将开始添加users
类,然后。我们将完成添加所有的方法。不过,我们现在可以开始定义这个类,我将注释掉我们刚刚添加的Person
类,因为我们确实希望它作为文档存在。但我们不会在应用程序中确切使用它。一旦我们对类更加熟悉,我们稍后会删除它。
现在,我们将开始使用class
关键字创建我们的users
类,class Users
。然后我们将打开和关闭大括号,在这里我们可以指定任何我们喜欢的方法,比如constructor
函数。我们将定义一个constructor
函数,尽管在创建类时它们是完全可选的。我们将通过名称设置我们的constructor
函数,然后是参数列表,然后是打开和关闭大括号:
class Users {
constructor () {
}
}
现在,与person
不同,users
的构造函数不会接受任何参数。当我们创建一个新的users
实例时,我们只想从一个空的用户数组开始。我们将在server.js
中创建这个新实例,当我们首次启动应用程序时,这将发生在代码的顶部。在下面,当有人加入房间、离开房间或以任何方式操纵房间时,我们将实际使用这些方法。这意味着我们需要做的就是将this.users
,即users
属性,设置为一个空数组:
class Users {
constructor () {
this.users = [];
}
}
这将是我们在users.js
文件顶部定义的数组。接下来,我们要做的事情是,既然我们已经有了constructor
函数,那么我们要创建addUser
方法。我们将在constructor
函数的下方创建它,就像我们为getUserDescription
所做的那样。我们将设置参数列表,这个方法将使用一些参数,我们稍后会指定这些参数,并且我们将打开和关闭大括号来编写实际的函数代码:
class Users {
constructor () {
this.users = [];
}
addUser () {
}
}
我们需要的三个参数是id
,name
和room
。为了将用户添加到users
数组中,我们需要这三个信息。一旦我们有了它们,将它们添加到列表中就会变得非常容易。
我将首先创建一个变量user
,这样我们就可以创建一个对象并将其推送到数组中。在users
中,我们将设置一个id
属性等于id
参数,对于name
和最后对于room
也是同样的操作:
addUser (id, name, room) {
var user = {id, name, room};
}
}
现在我们有一个user
对象,具有这三个属性,我们可以继续将其推送到数组中,this.users.push
,将一个对象添加到末尾,我们要添加到数组中的是user
变量:
addUser (id, name, room) {
var user = {id, name, room};
this.users.push(user);
}
既然我们已经准备就绪,基本上我们已经完成了。我要做的最后一件事是继续返回成功创建的用户,return user
就像这样:
addUser (id, name, room) {
var user = {id, name, room};
this.users.push(user);
return user;
}
现在,addUser
已经完成。我们还没有将其连接起来,但我们可以为addUser
添加一个测试用例。
为addUser
添加测试用例
我们将在一个全新的名为users.test.js
的文件中添加测试用例。在这里,我们可以加载用户,对其进行测试,以及进行任何其他可能需要做的事情。现在,第一步是实际导出用户。
目前,在user.js
文件中,我们已经定义了类,但我们没有导出它。导出它与导出其他任何内容都是一样的,没有什么特别的地方。
我们将添加module.exports
,并且我们将在大括号内导出一个对象,其中users
属性等于我们的Users
类定义,确保匹配大小写:
addUser (id, name, room) {
var user = {id, name, room};
this.users.push(user);
return user;
}
}
module.exports = {Users};
既然我们已经准备就绪,我们现在可以在users.test
文件中要求我们的类并创建新的实例了。
在users.test
文件中添加新的实例
让我们开始加载expect
,const expect = require('expect')
,我们还可以继续加载我们的用户文件,const
。使用 ES6 解构,我们将通过本地文件./users
获取Users
:
const expect = require('expect');
const {Users} = require('./users');
现在,我们只是要为添加用户添加一个测试用例。我们将快速创建一个describe
块,大部分繁重的工作将在后面进行。我们将describe
我们的Users
类,然后我们可以添加我们的箭头函数,然后我们可以继续添加一个测试用例,it
,在引号内,should add new user
。我将为这个设置函数。这将是一个同步函数,所以不需要done
参数,我们可以创建一个新的用户实例,var users
,等于一个new Users
:
describe('Users', () => {
it('should add new user', ()=> {
var users = new Users();
});
});
现在,由于我们在constructor
函数中没有传入任何参数,所以在实际创建实例时我们也不会传入任何参数。
接下来要做的是创建一个用户,然后将其属性传递给addUser
,确保最终显示出适当的内容。让我们创建一个名为user
的变量,然后将其设置为一个对象:
it('should add new user', ()=> {
var users = new Users();
var user = {
}
});
我将继续在这个对象上设置三个属性,一个id
等于123
之类的东西,一个name
属性等于像Andrew
这样的名字,你可以使用你的名字,比如Andrew
,还有一个房间名。我将使用The Office Fans
:
describe('Users', () => {
it('should add new user', ()=> {
var users = new Users();
var user = {
id: '123',
name: 'Andrew',
room: 'The office fans'
};
});
});
现在我们已经有了用户,我们可以调用我们刚刚创建的方法,addUser
方法,使用三个必要的参数,id
,name
和room
。我将把响应存储在一个名为resUser
的变量中,并将其设置为users.addUser
,传入这三个信息,user.id
,user.name
和user.room
作为第三个参数:
describe('Users', () => {
it('should add new user', ()=> {
var users = new Users();
var user = {
id: '123',
name: 'Andrew',
room: 'The office fans'
};
var resUser = users.addUser(user.id, user.name, user.room);
});
});
有了这个调用,我们现在可以开始做出我们的断言。
为用户调用做出断言
我们想要做的一个断言是实际的users
数组已经更新,当我们调用this.users.push
时,它应该已经更新。我期望通过调用expect
来期望一些关于users.users
的内容:第一个用户是指users
变量,第二个实际上访问了users
文件中定义的users
数组。然后我们将调用toEqual
。记住对于数组和对象,你必须使用toEqual
而不是toBe
。我们期望它是一个只有一个项目的数组。这个项目应该看起来像我们在代码中定义的user
对象:
var resUser = users.addUser(user.id, user.name, user.room);
expect(users.users).toEqual([user]);
如果这通过了,那么我们知道我们的用户确实被添加到了users
数组中。我将继续保存文件并关闭nodemon
。
运行 addUser 测试用例
我将清除终端输出并运行npm test
,以确保我们全新的测试用例通过:
当我运行它时,它确实通过了。我们有我们的Users
块,should add new user
按预期工作。
添加 removeUser、getUser 和 getUserList 方法
在我们将用户集成到我们的应用程序之前,让我们继续完成它。我们还有三种方法要添加和测试。第一个是removeUser
,它将接受一个参数,要删除的用户的 ID。这也将返回刚刚删除的用户,所以如果我删除 ID 为 3 的用户,我想把它从列表中删除,但我确实想返回这个对象。
我们将留下一个关于返回被删除的用户
的小注释:
removeUser (id) {
//return user that was removed
}
接下来我们要填写的下一个方法是getUser
。getUser
方法将接受与removeUser
完全相同的参数。我们将通过 ID 找到一个用户并返回用户对象,但我们不会从数组中删除它:
getUser (id) {
}
我们要创建的最后一个方法,如上所述,是一个名为getUserList
的方法。这将获取所有用户的列表,只显示他们的名字和房间名:
getUserList (room){
}
这意味着我们将遍历users
数组,寻找所有房间与指定房间匹配的用户。这将返回一个数组,类似于:'Mike', 'Jen', 'Caleb'
,假设这些人在房间里:
getUserList (room) {
['Mike', 'Jen', 'Caleb']
}
现在,请注意,我们没有指定房间或 ID 属性;我们只是返回一个字符串数组。
为测试文件添加种子数据
现在让我们在测试文件中添加一件事。为了使这些方法起作用,我们需要种子数据,我们需要已经存在的用户,否则我们无法删除一个或获取一个,绝对不能获取这些不存在用户所在的房间列表。
为了解决这个问题,在user.test.js
中,我们将添加一个beforeEach
调用,这是我们过去使用过的。beforeEach
调用,正如我们所知,将在每个测试用例之前调用。它将帮助我们初始化一些数据。现在我们要初始化的数据将在beforeEach
调用的上面定义,一个名为users
的变量中:
describe('Users', () => {
var users;
beforeEach(() => {
});
我在beforeEach
之外定义它的原因是为了让它在beforeEach
内部和测试用例内部都可以访问到。
在beforeEach
内部,我们将users
设置为new Users
,我们还将设置users.users
数组。在这里,我们可以指定一个对象数组,这将让我们添加一些初始化数据:
beforeEach(() => {
users = new Users();
users.users = [{
}]
});
让我们提供三个对象。第一个将有一个id
属性等于2
,我们将把name
属性设置为类似Mike
的东西,然后我们可以把room
属性设置为任何我们喜欢的东西,我将使用一个房间名Node Course
:
var users;
beforeEach(() => {
users = new Users();
users.users = [{
id: '1',
name: 'Mike',
room: 'Node Course'
}]
});
我们可以拿这个对象并复制两次。我要加一个逗号,粘贴刚刚复制的内容,然后再做同样的事情,逗号后面是一个粘贴。我要把第二个用户的 id 改成2
,我们会把名字改成Jen
,把房间名改成React Course
。现在对于最后一个用户,我们要改变id
和name
,我们会把id
设为3
,名字设为Julie
,但是我们会把房间名留为Node Course
,这样我们可以测试我们的getUserList
函数是否确实返回了正确的结果:
beforeEach(() => {
users = new Users();
users.users = [{
id: '1',
name: 'Mike',
room: 'Node Course'
},{
id: '2',
name: 'Jen',
room: 'React Course'
},{
id: '3',
name: 'Julie',
room: 'Node Course'
}]
});
测试用例不需要使用我们在这里定义的users
变量。我们仍然可以定义一个自定义的变量,就像我们在添加新用户的情况下定义的那样。如果我运行test-watch
脚本,npm run test-watch
,我们会看到我们的一个测试用例仍然通过:
我要保存文件以重新运行测试套件,现在我们有 6 个通过的测试用例。无论我们是否使用这个,我们仍然可以使用自定义的测试用例。
既然我们已经准备就绪,我们可以继续填写一些这些方法。我们将一起填写getUserList
,你将负责removeUser
和getUser
。
填充 getUserList
为了填写getUserList
,我们将首先找到所有房间与指定room
匹配的用户。为了做到这一点,我们将使用数组的filter
方法,这是我们过去使用过的。让我们创建一个变量,我们将称之为users
,然后将其设置为this.users
,这是users.filter
的数组:
getUserList (room) {
var users = this.users.filter((user) => {
})
}
现在如果你记得,filter
接受一个函数作为它的参数。这个函数会被每个单独的用户调用。我们可以返回true
来保留数组中的这个项目,或者我们可以返回false
来将其从数组中移除。我将返回user.room
,然后我们会检查它是否等于,使用三个等号,room
参数:
getUserList (room) {
var users = this.users.filter((user) => {
return user.room === room;
})
}
如果它们相等,user.room === room
将返回true
,该值将被返回;如果它们不相等,它将返回false
,用户将不会被添加到上面的列表中。现在我们可以使用 ES6 箭头函数的快捷方式。我们将使用这样的简写,而不是添加return
关键字并指定实际的箭头:
getUserList (room){
var users = this.users.filter((user) => user.room === room)
}
这是完全相同的功能,只是不同的技术。现在我们有了所有符合条件的用户的列表。过程的下一步是将对象数组转换为字符串数组。我们只关心获取那个名单。为了做到这一点,我们将使用map
。我将创建一个名为namesArray
的变量,我们将把它设置为users.map
:
getUserList (room){
var users = this.users.filter((user) => user.room === room);
var namesArray = users.map
}
现在我们过去使用了map
,因为我们知道map
也需要一个类似于filter
的函数。它也会被调用以处理单个项目。在这种情况下,是单个用户,但map
让我们返回我们想要使用的值。所以我们将得到一个对象,它将有id
属性、room
属性和name
属性,我们只想要name
属性,所以我们将返回user.name
。我们甚至可以使用箭头函数的简写来进一步简化它。user.name
将被隐式返回:
var users = this.users.filter((user) => user.room === room);
var namesArray = users.map((user) => user.name);
现在我们有了namesArray
数组,我们需要做的就是通过返回namesArray
来返回它:
getUserList (room){
var users = this.users.filter((user) => user.room === room);
var namesArray = users.map((user) => user.name);
return namesArray;
}
现在这将完成任务,在我们进一步简化之前,让我们继续编写一个测试用例以确保它有效。
为getUserList
添加测试用例
在users.test.js
中,我们可以在其他测试用例下面添加测试用例,it ('should return names for node course')
。我们将编写返回Node
课程中所有用户的用例,我们应该得到两个用户,Mike
和Julie
。我们将创建一个变量,我们将称这个变量为userList
,然后我们将调用已经定义的users
变量:
it('should return names for node course', () => {
var userList = users
});
这是我们种子数据的一个。我们不需要像为其他测试用例users.getUserList
那样创建一个自定义的。我们知道getUserList
需要一个参数,你想要获取列表的房间的名称,这个叫做Node Course
。确保你的大写字母对齐。然后我们可以继续在末尾添加一个分号:
it('should return names for node course', () => {
var userList = users.getUserList('Node Course');
});
最后要做的是添加我们的断言,确保我们得到的是预期的结果。我们将expect
userList
等于,使用toEqual
,以下数组。它将是一个数组,其中第一项是Mike
,第二项是Julie
:
it('should return names for node course', () => {
var userList = users.getUserList('Node Course');
expect(userList).toEqual(['Mike', 'Julie']);
});
如果该断言通过,我们知道getUserList
按预期工作,因为这正是我们在上面定义的。
现在我们可以继续复制这个测试用例。对React Course
做完全相同的事情应该返回react
课程的名称,我们将把Node
改为React
,然后我们将更新我们的expect
。React 课程只有一个用户,该用户的name
等于Jen
:
it('should return names for react course', () => {
var userList = users.getUserList('React Course');
expect(userList).toEqual(['Jen']);
});
现在这是一个相当不错的测试用例。如果我们保存users.test.js
,它将重新运行整个测试套件。我们应该看到我们在users
描述块下有三个测试,它们都应该通过,确实是这样的:
我们要创建的下两个方法是removeUser
和getUser
。让我们继续一起编写测试用例的it
语句,你将负责填写方法和填写测试用例:
it('should remove a user', () => {
});
这个方法将获取我们种子用户的 ID,无论是 1、2 还是 3。它将把它传递给removeUser
函数,你的工作是断言用户确实被移除了。接下来,it('should not remove user')
:
it ('should not remove user', () => {
});
在这种情况下,我希望你传入一个不是我们种子user
数组的一部分的 ID,这意味着类似于 44、128 或者基本上任何不是 1、2 或 3 的字符串。在这种情况下,你应该断言数组没有改变;我们仍然应该有这三个项目。
现在这些是我们的removeUser
方法的两个测试用例,接下来是getUser
。我们将添加两个类似的测试用例。首先是it('should find user')
,你应该传入一个有效的 ID,然后你应该得到用户对象。另一个将是it ('should not find user')
,就像it('should not remove a user')
一样。传入一个无效的 ID,并确保你没有得到用户对象。
填写 getUser
我将从getUser
开始,这里的目标是返回 ID 与getUser
中传入的参数的 ID 匹配的用户对象。为了完成这个目标,我将使用filter
。我们将返回this.users.filter
的结果,我们将按 ID 进行过滤,这里我们按房间进行过滤。我们将传入我们的箭头函数,使用表达式语法,参数将是user
,我们将继续返回true
,如果用户的id
属性等于参数的 ID。如果是这种情况,我们确实希望在数组中保留这个user
。最后,我们应该只有一个用户或 0 个用户,我们要做的就是返回第一个项目:
getUser (id){
return this.users.filter((user) => user.id === id)[0]
}
如果数组中有一个用户,我们将得到它的对象;如果没有用户,我们将得到 undefined,这正是我们想要的。现在我们有了getUser
,我们可以为此编写测试用例。我们有两个测试用例,it('should find user')
和it('should not find user')
。
测试用例-应该找到用户
对于it('should find user')
,我将首先创建一个名为userId
的变量,并将其设置为我想要使用的 ID。我需要一个有效的 ID,所以我将继续使用2
。1
、2
或3
在这里都可以工作:
it('should find user', () => {
var userId = '2';
});
接下来,我将继续创建一个user
变量,这将是从getUser
返回的结果。我将把它设置为users.getUser
,我们将尝试通过传入userId
来获取 ID 为2
的用户:
it('should find user', () => {
var userId = '2';
var user = users.getUser(userId);
});
现在我们要做的下一件事是对我们得到的结果进行断言,我们应该得到我们的对象,并且我们可以expect
user.id
等于,使用toBe
,和 ID,userId
变量:
it ('should find user', () => {
var userId = '2';
var user = users.getUser(userId);
expect(user.id).toBe(userId);
});
我将保存测试套件,你可以看到我们所有的测试用例仍然通过,这太棒了。如果它不等于 ID,也许 ID 是3
,你将看到测试用例失败,我们会得到一个非常清晰的错误消息:
我们得到了预期为 2 实际为 3
,显然不是这样。这是最后的测试用例,我们可以继续进行it('should not find user')
。
测试用例-不应该找到用户
在这种情况下,我们将遵循与应该找到用户相似的格式,创建userId
变量,并将其设置为内置用户中不存在的用户 ID,类似于99
将完成任务:
it('should not find user', () => {
var userId = '99';
});
接下来,我们将创建一个user
变量,再次存储从getUser
中返回的结果,users.getUser
,传入我们的userId
:
it('should not find user', () => {
var userId = '99';
var user = users.getUser(userId);
});
现在在这种情况下,我们期望得到 undefined,filter
应该返回空,如果你尝试获取空数组中的第一个项目,你将得到 undefined。我们可以通过在终端中运行node
来证明这一点,在我们的小控制台中,我们可以创建一个空数组,然后访问第一个项目:
**>[][0]**
我们得到了undefined
。我将关闭它,重新启动我们的测试套件,在inside users.test.js
文件中,我们将继续进行断言。我们将expect(user).toNotExist
:
it ('should not find user', () => {
var userId = '99';
var user = users.getUser(userId);
expect(user).toNotExist();
});
我将保存文件,我们所有的测试用例应该仍然通过:
太好了。接下来,我们需要编写removeUser
方法,还需要填写测试用例。
填写 removeUser 方法
在user.js
中,我们可以开始查找用户,如果有的话。这意味着,我们将使用与getUser
方法类似的技术。我将从getUser
方法中复制以下行,并将其粘贴到removeUser
中。
return this.users.filter((user) => user.id === id) [0]
创建一个名为user
的变量,将其设置为前一行。现在你也可以继续调用getUser
。我可以调用this.getUser
,传入id
,就像这样:
removeUser (id) {
var user = this.getUser(id);
}
这两种解决方案都将按预期工作。接下来,如果有用户,我们想要删除它,if(user)
,我们将做一些特殊的事情,无论用户是否存在,我们都将返回user
值:
removeUser (id) {
var user = this.getUser(id);
if (user) {
}
return user;
}
如果它不存在,我们将返回 undefined,这很好,如果它存在,我们将在删除用户后返回对象,这也是我们想要的。我们需要做的就是弄清楚如何从列表中删除它。
为了做到这一点,我将把this.users
设置为this.users
,然后我们将调用filter
找到所有 ID 与上面指定的 ID 不匹配的用户。我们将调用 filter,传入我们的箭头函数,我们将得到单个user
,在箭头表达式语法中,我们要做的就是添加user.id
不等于id
:
if (user) {
this.users = this.users.filter((user) => user.id !== id);
}
这将创建一个新数组,将其设置为this.users
,并且将删除项目,如果有的话。如果没有项目,那就没关系;这个语句永远不会运行,我们将能够继续返回 undefined。
现在我们已经做好了这个准备,我们可以继续编写一个测试用例,以确保它按预期工作。我将保存user.js
,然后在users.test
中,我们将填写it ('should remove a user')
和it ('should not remove user')
测试用例。让我们从should remove a user
开始。
测试用例-应删除用户
我将创建一个变量userId
来存储理想的 ID,可以是1
、2
或3
,我将选择1
,然后我们可以继续并实际删除它,将返回结果存储在一个user
变量中。我将调用users.removeUser
,传入我的userId
变量,即1
:
it('should remove a user', () => {
var userId = '1';
var user = users.removeUser(userId);
});
现在我们有了可能被删除的用户,我们应该能够继续断言一些东西。我们将期望user
对象存在。我们还将期望它的id
等于上面的id
,并且期望被删除的用户具有id
属性,使用toBe(userId)
:
it('should remove a user', () => {
var userId = '1';
var user = users.removeUser(userId);
expect(user.id).toBe(userId);
});
接下来,我们将确保用户已从数组中删除。在这种情况下,我们将期望users.users.length
为2
:
it('should remove a user', () => {
var userId = '1';
var user = users.removeUser(userId);
expect(user.id).toBe(userId);
expect(users.users.length).toBe(2);
});
一开始应该是3
,一旦我们删除了一个用户,它应该是2
。
测试用例-不应删除用户
在should not remove user
的情况下,我们将做一些类似的事情,只是我们将调整userId
。我可以复制我们第一个测试用例的内容,粘贴到第二个测试用例中,我们只需要做一些调整。我将把 ID 更改为无效的 ID,比如99
。我们仍然将使用该 ID 调用removeUser
。在这种情况下,我们不再期望user
具有id
属性,而是使用toNotExist
来判断user
不存在。接下来,我们将期望长度没有改变,并确保长度仍然等于3
:
it ('should not remove user', () => {
var userId = '99';
var user = users.removeUser(userId);
expect(user).toBe(userId);
expect(users.users.length).toBe(3);
});
现在我可以保存users.test
文件。这将重新启动nodemon
中的所有内容,我们应该得到一个通过的测试套件。看起来好像已经运行了,尽管内容没有改变,所以很难弄清楚是否发生了什么。我将关闭它并运行npm test
来验证,你可以看到这里有 12 个测试用例都通过了:
现在我们已经拥有了所有我们需要的方法来跨不同的事件监听器持久化用户;无论他们是发送消息、位置消息,无论他们是连接还是离开,我们都将能够跟踪他们并向正确的人发送正确的东西。
连接用户列表
在这一部分,我们将开始连接我们创建的用户类,并且为了开始,我们将连接People
列表,这意味着当用户加入和离开时,我们需要做一些事情。我们希望保持列表最新,并且每次更新时,我们都希望给客户端一个新的列表副本。这意味着服务器需要向客户端发出一个事件,然后客户端将监听该事件并更新标记。
现在我们可以通过以下命令启动服务器来查看这将发生在哪里:
**nodemon server/server.js**
然后,我将前往localhost:3000
并打开聊天页面。我将输入Andrew
作为显示名称,LOTR
作为房间名称。现在一旦我们进入,我们有我们的 People 列表,当前应该显示我们,因为我们在房间里,当一个新用户加入时,它应该自动显示该用户:
现在没有任何这些正在发生,但是有了我们的新事件,这一切都将发生。
在聊天室中添加 People 列表
现在我们要做的第一件事是确切地弄清楚这个事件会是什么样子。在chat.js
中,我们可以添加一个监听器来弄清楚对我们有用的是什么,客户端真正需要做什么?然后我们可以继续连接服务器来满足这些需求。
就在chat.js
中,就在disconnect
下面,我们将添加一个新的监听器,socket.on
,我们将监听一个全新的事件。这个事件将被称为updateUserList
:
socket.on('disconnect', function() {
console.log('Disconnected from server');
});
socket.on('updateUserList')
现在updateUserList
需要传递一些信息。我们需要显示用户列表而不是当前显示的用户,这意味着我们将期望一个参数,一个users
数组。这个users
数组将只是一个名字数组,就像我们在users
类中返回的那样。
回到chat.js
,目前,我们只是在数据通过时将列表记录到屏幕上,console.log('Users list')
,第二个参数将是实际的users
数组:
socket.on('updateUserList', function(users){
console.log('Users list', users);
});
一旦我们连接好这个,我们只需要添加一些 jQuery 来更新 DOM。更难的部分将是将更新和最新的列表返回给客户端。
添加 jQuery 来更新 DOM
在server.js
中,这个过程的第一步是导入我们努力创建的类。我将继续完成这个,就在我们加载isRealString
的下面。
我们可以创建一个常量,我将继续使用users
属性,这是我们在users.js
底部导出的属性,我们可以使用require
导入它。我将要求本地文件./
。它在utils
文件夹中,这个文件叫做users
:
const {Users} = require('./utils/users');
现在我们已经有了Users
,我们可以创建一个新的实例。这将是users
实例。我们需要一种方法来运行这些方法,所以就在我们的io
变量下面,我们可以创建一个名为users
的新变量,将其设置为new Users
,就像这样:
var users = new Users();
现在我们将能够调用所有用户方法来添加、删除、获取和否则操作数据。
将用户添加到用户列表
这个过程的第一步将是在用户加入聊天室时将用户添加到列表中。我们可以在调用socket.join
之后立即执行。我将删除旧的注释,尽管如果您发现它们是一个很好的参考,您可以选择保留它们。在socket.join
下面,我们将调用users.addUser
,添加我们全新的用户,我们需要传入这三个信息,socket ID,存储在socket.id
中,名称,在params.name
上,最后我们将传入房间名,params.room
:
socket.join(params.room);
users.addUser(socket.id, params.name, params.room);
现在您注意到,如果存在验证错误,即名称或房间名称未提供,这段代码不应该运行,但目前情况并非如此。我们实际上没有停止函数执行,我将使用return
来确保如果数据无效,则下面的代码不会执行:
socket.on('join', (params, callback) => {
if(!isRealString(params.name) || !isRealString(params.room)){
return callback('Name and room name are required.');
}
});
使用唯一 ID 添加用户
这个过程的下一步将是确保没有具有相同 ID 的用户。我将调用users.removeUser
来完成这个任务,传入唯一的参数需要socket.id
就像这样:
socket.join(params.room);
users.removeUser(socket.id);
users.addUser(socket.id, params.name, params.room);
这意味着用户加入房间,我们将他们从任何可能的以前的房间中移除。最后,我们将他们添加到新的房间。现在我们已经做到了这一点,我们可以继续发出那个事件。
向客户端发出事件
我们将发出客户端期望的事件updateUserList
,带有users
数组。如果我们不发出事件,客户端将永远无法获得新列表,而我们刚刚更新了列表,所以我们肯定希望他们获得一个新的副本。这意味着我们希望通过io.to
向聊天室中的每个人发出事件。我们将传入房间名,然后调用emit
,发出事件。
现在我们可以继续填写第一步,我们要传入房间名,params.room
中有这个信息,接下来我们要发出事件,我们刚刚在chat.js
中定义的事件名是updateUserList
。我们需要做的最后一件事是获取用户列表。我们已经有了,users.getUserList
,传入我们想要获取列表的房间名。再次,params.room
,这将是我们传入的唯一参数:
socket.join(params.room);
users.removeUser(socket.id);
users.addUser(socket.id, params.name, params.room);
io.to(params.room).emit('updateUserList', users.getUserList(params.room));
有了这个调用,我们应该能够在终端中查看这个。
我将保存这个文件,这将在终端中重新启动服务器。
在聊天室中测试用户列表
在浏览器中,我可以打开开发者工具查看控制台日志
,然后我将刷新应用程序。如果我刷新应用程序,我们会看到一个用户
列表,我们有Andrew
打印两次:
如果我第二次刷新页面,我们有Andrew
打印三次:
正如您所看到的,这是因为当用户离开聊天应用程序时,我们没有从列表中移除用户。这是本节中的第二个目标。我们目前有一个用户列表。当用户离开时,我们只需要更新它,这将发生在disconnect
监听器的底部附近。
当他们离开聊天室时移除用户
在断开连接的监听器中,我们想要移除用户,然后再次更新用户列表。我将通过几个单独的步骤来实现这一点。首先,我们将创建一个名为user
的变量,存储任何可能被移除的用户,记住removeUser
方法会返回被移除的用户,users.removeUser
传入 ID,socket.id
:
socket.io('disconnect', () => {
var user = users.removeUser(socket.id);
});
现在我们只想在实际移除用户时才执行某些操作,如果这个人没有加入房间,实际上没有理由做任何事情。如果用户被移除,我们将发出两个事件,并且我们将把它们发出给连接到聊天室的每个人,这意味着我们将使用io.to().emit
,就像我们在前面的代码中所做的那样。我们将这样做两次,所以我将复制这行并粘贴,就像这样:
socket.io('disconnect', () => {
var user = users.removeUser(socket.id);
if (user){
io.to().emit();
io.to().emit();
}
});
当有人离开聊天室时更新用户列表
第一个将更新user
列表,第二个将打印一条小消息,比如Andrew 已经离开房间
。第一个将以用户房间属性作为唯一参数,user.room
存储房间字符串,我们将为两者提供这个参数,现在我们可以开始发出我们的事件。
我将首先发出updateUserList
事件,在引号内,updateUserList
,然后我们将继续调用我们在上面刚刚做过的完全相同的方法,users.getUserList
,传入房间,user.room
:
if (user){
io.to(user.room).emit('updateUserList', users.getUserList(user.room));
io.to(user.room).emit();
}
现在当有人离开房间时,他们将从列表中被移除,我们将不再看到那些在 Web 开发者控制台中出现的重复。
发出自定义消息
我们要做的下一件事是发出一条消息。我们将从管理员向所有人发出一条消息,就像我们在上面做的那样。我们向用户致以问候,并告诉所有其他用户有人加入了,现在我们将emit('newMessage')
,并且我们将调用generateMessage
,就像我们以前做过的那样。我们将传入这两个参数,第一个是Admin
。这将是一条管理员消息,第二个可以是一个模板字符串,我们将注入用户的名字,user.name
,然后我们将说用户已经离开:
io.to(user.room).emit('updateUserList', users.getUserList(user.room));
io.to(user.room).emit('newMessage', generateMessage('Admin', `${user.name} has left.`));
现在我们已经做好了一切应该按预期工作。希望在 Chrome 中我们不再看到重复的用户。我将刷新页面,我们看到用户列表只有一个用户,Andrew
:
如果我刷新页面,我们不再会得到重复的用户,因为当我离开时,我被移除了,当我回来时,我被添加了。当页面最终完成刷新时,我被添加了。如果我添加一个新用户,情况也是一样的。暂时,我将把浏览器切换到屏幕的一半宽度。我将打开第二个标签并将其拖到另一半,这样我们可以并排查看这两个标签。我还将打开第二个标签的开发者工具,并且我们将加入完全相同的房间。
让我们去localhost:3000
,我将以Mike
加入,房间名将是相同的,LOTR
。现在当我点击加入
,我应该在两个控制台中看到更新后的列表。我将点击加入
。在右边的浏览器窗口中,我们得到Andrew
,Mike
,在左边的浏览器窗口中,我们也有Andrew
,Mike
,这太棒了:
我还收到一条小消息,说 Mike 已经加入,这是之前的情况;真正的测试是当用户离开时会发生什么。我将把 Andrew 踢出聊天室,在我们的另一个聊天窗口中,我们看到 Andrew 已经离开打印到屏幕上,我们的新用户列表只有一个用户,Mike
:
这太棒了。我们现在可以跟踪用户的进出,这让我们可以做一些很酷的事情,比如打印自定义消息和更新用户列表。
将用户的名字呈现到聊天室
最后要做的事情是实际将名称呈现到屏幕上。我们不只是在控制台中打印它们,我们想要将这些名称添加到列表中,这将在server.js
的事件监听器中发生。我们将像以前一样使用 jQuery。在chat.js
中,我们将对这些users
做一些操作。
添加 jQuery 以将用户添加到列表中
首先让我们创建一个新的 jQuery 元素。我们将创建一个名为ol
的变量。这将使用 jQuery 存储一个新元素。我们将创建一个有序列表。我们将创建ol
标签:
socket.on('updateUserList', function(users){
var ol = jQuery('<ol></ol>');
});
现在我们需要遍历每个用户并对该用户进行一些操作,users.forEach
将让我们完成这个任务。我们将传入我们的函数,并在该函数内部添加单个用户:
socket.on('updateUserList', function(users){
var ol = jQuery('<ol></ol>');
users.forEach(function () {
});
});
函数的参数是名称,user
字符串,我们要做的就是向上面的有序列表附加一些东西。那就是ol.append
:
socket.on('updateUserList', function(users){
var ol = jQuery('<ol></ol>');
users.forEach(function () {
ol.append();
});
});
现在我们想要附加什么?我们想要附加一个列表项,列表项的text
属性将等于名称,这样就可以正确呈现所有内容。我们可以使用 jQuery 通过打开和关闭列表项标签来创建一个新的列表项。然后在 jQuery 的关闭括号后,我们将调用text
,这样我们可以安全地将text
属性设置为用户的名称:
socket.on('updateUserList', function(users){
var ol = jQuery('<ol></ol>');
users.forEach(function (user) {
ol.append(jQuery('<li></li>').text(user));
});
});
现在我们有一个更新的列表,但实际上它并没有呈现到屏幕上,最后一步是通过将其添加到 DOM 来呈现它。
呈现更新的用户列表
现在在chat.html
中,我们有一个地方可以放置它。这是具有id
为users
的div
标签,这意味着我们可以选择它,jQuery
,选择器将以井号(#
)开头,因为我们是按 ID 选择的,我们选择users
,然后我们可以实际添加列表。我将把html
属性设置为我们的有序列表ol
,而不是使用 append,我们不想更新列表,我们想完全清除列表,用新版本替换它:
socket.on('updateUserList', function(users){
var ol = jQuery('<ol></ol>');
users.forEach(function (user) {
ol.append(jQuery('<li></li>').text(user));
});
jQuery('#users').html(ol);
});
现在我们可以保存chat.js
并测试一下。
在聊天室中测试用户名称
在浏览器中,我将关闭控制台,刷新页面,我们会看到数字 1,后面是 Mike:
现在数字 1 来自于我们使用有序列表。如果我添加第二个用户,我们将看到第二个用户。让我们创建第二个用户,我们将给它一个显示名称Jen
,然后我们将进入相同的房间LOTR
,当我们加入时,我们会收到一条小消息,我们会得到我们的两个用户,同样的东西也显示出来:
现在我将转到第二个标签,然后关闭第一个标签。当我这样做时,您的列表会自动更新,我们还会收到消息,说 Mike 已经离开了:
有了这个,我们离完成还有一步。消息仍然发送给每个人,它们不是特定于房间的,但这是我们将在下一节中处理的事情。目前我们完成了。我将进行提交,已经过了一段时间,我们有一个完成的功能,所以让我们继续进行。
为更新的用户列表做出提交
首先,我们将运行git status
,我们有一些新文件以及一些现有文件,我将使用git add .
将所有这些文件添加到下一个提交中。最后,我们可以使用git commit
来实际进行提交,我将使用-m
标志来添加我们的消息,并在引号内添加Add Users class and updateUserList event
:
git commit -m 'Add Users class and updateUserList event'
我们可以继续进行这个提交并将其推送到 GitHub,如果你愿意,可以推送到 Heroku,我会再等一会儿,那里的一切应该也能正常工作。
在下一节中,我们将确保消息,无论是文本消息还是位置消息,只发送给房间里的人。
只向房间发送消息
在上一节中,我们连接了 People 列表,确保新用户进入和离开时列表会更新。在本节中,我们将确保我们的文本和位置消息只发送给同一个房间的用户。目前,它会发送给所有人。我们可以通过打开一个新连接来证明,我将使用Mike
,我们将加入一个不同的房间,The Office Fans works
。当我加入房间时,您可以看到 People 列表确实是正确的,一个房间的用户不会更新另一个房间的 People 列表。不同的是,文本消息不遵循这些规则,基于位置的消息也不遵循这些规则:
我们将有文本消息和位置消息,如果我转到另一个房间的聊天应用程序,我们会得到这两个消息。这是一个问题。我们还有一个名称问题,当前文本消息显示为 User,位置消息显示为 Admin,我们希望确保使用实际用户的名称,无论是 Jen 还是 Andrew。为了完成这个任务,我们需要对server.js
和chat.js
进行一些更改,这实际上是我们要开始的地方。
更新 chat.js 和 server.js 文件
当前,名称User
,我们在浏览器中看到的错误名称来自chat.js
中的socket.emit
函数:
socket.emit('createMessage', {
from: 'User',
text: messageTextbox.val('')
}, function() {
messageTextbox.val('')
});
客户端最初发送了名称,但这将不再是情况,名称由服务器存储,因此我们将从createMessage
中删除这个必需的属性,我们只会发送文本。
socket.emit('createMessage', {
text: messageTextbox.val('')
}, function() {
messageTextbox.val('')
});
现在,我们可以修改server.js
中的事件监听器。在server.js
中,createMessage
获取这两个属性,并将它们直接放入generateMessage
中。相反,我们将使用users.getUser
找到用户,并对其进行操作。
在createMessage
中,我们可以删除createMessage
的console.log
语句,并且我们将创建一个变量 user,将其设置为users.getUser
。这是我们在users.js
中创建的方法,getUser
,它接受id
参数。我们可以像这样传入 ID socket.id
:
socket.on('createMessage', (message, callback) => {
var user = users.getUser(socket.id);
io.emit('newMessage', generateMessage(message.from, message.text));
callback();
});
现在我们可以对user
进行操作。我们只希望在用户实际存在时执行操作,这意味着我们将使用if
语句检查用户是否存在,并确保传递的文本是真实的字符串,使用&&
之后的isRealString
。然后传入message.text
:
socket.on('createMessage', (message, callback) => {
var user = users.getUser(socket.io);
if(user && isRealString(message.text)){
}
io.emit('newMessage', generateMessage(message.from, message.text));
callback();
});
这意味着如果有人试图发送空消息或一堆空格,它不会发送给其他人。现在在if
语句中,我们要做的就是实际发出消息。我们知道它是有效的,所以我们确实想要发出一些东西,我们将io.emit
行剪切出来,并粘贴到if
语句中:
if(user && isRealString(message.text)){
io.emit('newMessage', generateMessage(message.from, message.text));
}
现在,当前的io.emit
行会向所有人发出,而不仅仅是用户连接到的房间,但我们也使用message.from
。我们真的想要使用用户的name
属性。我们现在要做这两个更改,只向用户连接的房间发出此事件,并确保提供他们的名称,而不是message.from
。
向单独的房间发出事件
首先,我们想要发出到特定的房间,我们知道我们可以在io.emit
行中添加一个调用来完成这个操作,传入房间名称,通过user
对象user.room
访问。现在我们只是发出到单独的房间,我们也想要更改我们使用的名称。我们将访问user
对象上的名称,而不是message.from
,user.name
,就这样:
io.to(user.room).emit('newMessage', generateMessage(user.name, message.text));
现在我们有一个更好的系统来发送这些文本消息。我将刷新我的第一个标签和第二个标签,然后我们将发送一些文本消息。我将从我的第二个标签发送数字1
,我们会看到 Andrew,我们会看到数字 1:
在我们的另一个标签页中,消息无处可寻,因为我们只是将它发送给“办公室粉丝”房间的用户:
如果我尝试从第一个标签发送消息,我们将在那里看到它的名字是 Jen,第二个标签看起来也不错;我们没有看到 Jen 的消息。
现在我可以继续加入一个房间。我将使用名字Mike
,我们将再次加入The Office Fans
。当我加入房间时,我看到 Andrew 和 Mike 都连接了:
如果我发送一条消息,比如应该工作
,我会在那里看到它,并且也会在连接到The Office Fans
房间的其他用户的标签中看到它。再一次,它对连接到不同房间的其他人是不可见的。这就是我们需要做的一切,以确保我们的消息发送正确。最后要做的就是为createLocationMessage
进行连接。
现在,正如我们刚才看到的,我们还能够解决验证问题,如果我现在尝试按enter,什么也不会发生。我不会被移出框,焦点不会改变,也不会发送消息,这很好。
为个别房间连接 createLoactionMessage
现在我们要修复createLocationMessage
。你将想要像我们在上面做的那样找到用户,以防出现 createMessage。如果有用户,你将想要将位置发送给同一房间的人。而不是提供Admin
作为名称,你还将想要使用用户的真实姓名。我们需要确保它仍然发送给同一房间的用户,并确保不会发送给其他房间的用户。
为了做到这一点,我将首先获取用户,因为我们需要使用该对象上的信息。我们将创建一个名为 user 的变量,调用users.getUser
,并传入 socket ID,socket.id
。这与我们在createMesssage
中使用的行相同。现在我们只想在找到用户时发送消息,所以我要检查用户对象是否存在。如果存在,我们可以将io.emit
行剪切出来,并将其复制到if
语句内。如果存在,我们将发出newLocationMessage
:
if(user){
io.emit('newLocationMessage', generateLocationMessage('Admin', coords.latitude, coords.longitude));
}
现在我们仍然需要将其发出到特定的房间,通过添加一个调用to
并传入房间名称,user.room
存储了那些信息,最后但并非最不重要的是我们想要更新名称。我们将使用用户的真实姓名,而不是发送静态的Admin
名称,user.name
:
io.to(user.room).emit('newLocationMessage', generateLocationMessage(user.name, coords.latitude, coords.longitude));
有了这个createLocationMessage
,现在已经连接到私人,并发送了正确的信息。在 Chrome 中,我将逐个刷新我的标签,然后在第二个标签上,我将发送位置。这将需要几秒钟来获取,我看到它的名字显示正确:
我们有 Andrew,我们有一个链接,可以在 Google 地图中查看位置。现在,如果我转到第二个标签,也连接到The Office Fans
的用户,我会看到完全相同的位置消息:
如果我去第一个,你会看到 Jen 无法访问那条消息,因为她在另一个房间:
她可以随时与房间里的任何人分享她的位置,但实际上没有人,这条消息不会出现在任何地方,因为没有其他人连接到LOTR
。
有了这个设置,我们现在完成了,我们的消息是私密的,只有在同一房间的人才能看到。让我们继续提交这些更改。
提交单独的房间更改
我将关闭nodemon
服务器,使用clear
,然后我们可以运行git status
来查看我们有什么样的更改:
这里我们只有两个文件。它们被修改了,这意味着我们可以使用git commit
和-am
标志,无论是分开还是相同,它们都有相同的功能集,然后我们提供我们的消息字符串,只向同一房间的人发送消息
:
**git commit -am 'Send messages to only people in same room'**
有了这个设置,我们可以继续使用git push
将其推送到 GitHub,我还将使用git push heroku master
将其部署到 Heroku。一旦在 Heroku 上部署完成,我们可以花一点时间来确保我们刚刚添加的所有这些功能仍然按预期工作。我期望它们仍然可以工作,但肯定值得检查,因为环境有些不同,总是有可能出错。
现在,如果出现问题,提醒一下,你总是可以使用heroku logs
,这将显示服务器上的日志,通常有点神秘,但当出现错误时,你会看到一个非常大的块。通常很容易发现,通常包含有用的信息,说明出了什么问题:
看起来我们的应用成功部署了,所以我可以使用heroku open
在浏览器中打开它,一旦打开,我们实际上可以访问一些聊天室。我将关闭我的本地主机标签,然后以Andrew
的身份加入房间Philadelphia
:
一旦我进入房间,我会发送一条消息,然后我会将第二个用户添加到房间。我们想访问我们的 Heroku 应用网站。我将访问那个,我们将以Vikram
的身份加入房间,我们可以加入完全相同的房间Philadelphia
。当我加入时,我看到人员列表对两者都进行了更新,并且发送消息仍然有效:
第二个标签页的消息确实出现在第一个标签页中,这很好。所有这些都是可能的,因为我们已经连接了server.js
,随时跟踪用户。当他们第一次加入时,我们将他们添加到列表中,当他们发送消息时,我们确保使用他们的信息,当他们离开时,我们将他们从列表中移除。这确保了人员列表始终是最新的,并且消息只发送给了同一房间的其他人。
新功能点子
现在我们已经有了我们的人员列表,并且我们的消息只发送给了同一聊天室的用户,我们完成了。但这并不意味着你必须停止开发聊天应用,学生们总是喜欢添加新功能。
我想给你一些关于你现在可以构建的想法。在添加这些功能时,你可能会遇到困难。这可能会非常痛苦,可能需要很长时间,但我向你保证,在你自己做事情的过程中,你会学到很多。现在你有了所有的技能来做这些功能,所以让我们快速地按照列表进行。
-
一个很棒的想法是使聊天室不区分大小写。目前,如果我以小写
r
去LOTr
,我实际上并不在与我朋友在LOTR
大写R
中的同一个聊天室。不管大小写如何,我们都在同一个房间中会很好。 -
接下来,我想让用户名唯一。目前,我可以复制 URL 并粘贴到新标签页中,现在我有两个名为 Jules 的人:
-
拒绝具有与现有用户相同名称的新用户将是很酷的。
-
接下来,一个想法是在下面添加当前活动聊天室的列表。这可以是一个下拉选择,它将重新填充,类似于 People 列表的重新填充。
-
这绝对是最难的功能,但我认为这将非常酷。这意味着我会在加入按钮和房间名称输入框之间看到一个下拉菜单,其中将列出两个当前活动的房间,
LOTr
和LOTR
,尽管希望如果您首先实现了第一个功能,我们只会看到一个房间。然后,我可以从下拉菜单中选择一个,输入一个名称并以这种方式加入。
这些只是一些关于如何继续使用聊天应用程序的想法。
总结
在本章中,我们看了如何在 ES6 中使用class
关键字创建类。我们创建了一个Person
类,这只是一个例子,然后我们创建了我们将在整本书中实际使用的Users
类。我们看了如何添加自定义方法以及设置我们的constructor
函数。然后,我们以类似的方式创建了removeUser
、getUser
和getUserList
方法。
接下来,我们研究了如何连接我们创建的users
类,并在用户加入或离开聊天室时更新People
列表。然后我们研究了如何向特定房间发送消息,而不是向所有用户发送。最后,我们添加了一些想法,您可以查看以增强聊天室的功能。
在本章中,我们将学习有关 Async/Await 项目设置的内容。
第十章:异步/等待项目设置
在这一章中,我们将学习异步/等待的工作过程,它到底是什么,以及它将如何融入我们已经了解的 Node 知识中。异步/等待是一个不是在所有 Node 版本中都可用的功能。您必须使用 7.6 或更高版本。所以如果您使用的是 V7,只需确保您使用的是 7.6 或更高版本。如果我们转到nodejs.org,您会看到 v9 实际上已经发布了,所以我们现在可以升级到 V9:
如果有更新版本也是完全可以的。只要是 7.6 或更高版本就可以。所以 6.10 不会有我们即将深入研究的语法。
使用异步/等待功能在承诺中
现在,在我们实际使用异步/等待之前,我们将先通过一个只使用承诺的示例来运行。我们将使用我们已经知道的技术来设置一个小的示例项目。现在,当人们听说异步/等待时,他们认为他们需要忘记他们对回调和承诺的所有了解,这是不正确的。异步/等待不是第三个选择;它更像是承诺的增强。我们将通过一个例子来使用普通的承诺,这些是您已经知道如何做的事情。然后我们将看到异步/等待如何增强该代码。因此,为了开始,我们确实需要一个地方来放置所有这些。我将在我的桌面上创建一个全新的项目,async-await
。
我们可以在我们的编辑器中打开它,并确保在您的终端中也打开它。现在,这里的目标是只做一个非常简单的项目。对于这个项目,我们不需要任何 Node 模块,我们只需要一个文件。这个文件可以放在项目的根目录,我们将把它命名为app-promises.js
:
这将是我们的应用程序的版本,只使用承诺。现在,在我们继续之前,我想给你一个快速的想法,关于这一章将会是什么样子。我们将经历三个不同的事情:首先,我们将创建一个非常牵强的例子,这将使我们学会如何在没有太多开销或负担的情况下使用异步/等待。因此,我们将创建常量,比如users
,它只是一个对象数组和常量,比如grades
;也是一个对象数组,这将是数据库的样子。
const users = [];
const grades = [];
显然,从数组中访问某个属性并没有任何异步性,因此我们将继续创建一些新的承诺,将同步过程转换为异步过程。
设置 getUser 项目
因此,第一个项目并不是非常现实,但它将是学习新语法的好方法。然后我们将继续进行另一个小项目,您将需要从我挑选出来的一些 API 中进行两个实际的 API 调用。这些将是异步的,我们将在那里使用异步/等待。最后,我们将把一点异步/等待代码添加回 todo API 中。
因此,我们将通过这个非常牵强的例子开始,我们可以开始创建一些要处理的用户。每个用户对象将有一些属性;我们将从一个id
开始,就像它们在真实数据库中一样。我将给这个一个 id 为1
:
const users = [{
id: 1,
}];
const grades = [];
我们还会有一个名字,一个用户的字符串名字。我要叫第一个Andrew
,然后我们将继续到最后一个属性,一个schoolId
,一个 ID,当学生从一所学校转到另一所学校时会改变。我们可以为这个再编一个 id。我将从101
开始:
const users = [{
id: 1,
name: 'Andrew',
schoolId: 101
}];
const grades = [];
现在我们已经创建了用户编号为 1
,让我们继续克隆他。我将复制它,加上逗号,然后粘贴,我们将为这个示例创建一个更多的用户。这个用户的 id
将是 2
。我们将把名字从 Andrew
改成类似 Jessica
的名字,然后给她一个 schoolId
为 999
。
const users = [{
id: 1,
name: 'Andrew',
schoolId: 101
}, {
id: 2,
name: 'Jessica',
schoolId: 999
}];
const grades = [];
现在我们已经有了一些用户,我们将在这一部分中创建我们的第一个三个函数之一。这个函数叫做 getUser
。它将获取用户的 id
,找到该用户,并返回用户对象:
const grades = [];
const getUser = [id] => {
};
所以,如果 id
是 1
,我们将得到这个对象:
const users = [{
id: 1,
name: 'Andrew',
schoolId: 101
},
如果是 2
,我将得到这个对象:
{
id: 2,
name: 'Jessica',
schoolId: 999
}];
如果是 3
或其他不存在的 id,我将会抛出一个错误。所以,如果 id
与其中一个用户匹配,它将返回一个解析的 promise,否则将返回一个拒绝的 promise。
现在,正如我所提到的,这是一个人为的例子,所以我们将显式地创建新的 promises。我将创建一个新的 promise,传入那个 promise 函数,你记得,它会调用 resolve
和 reject
:
const getUser = (id) => {
return new Promise((resolve, reject) => {
});
};
然后我们将在函数中添加一点逻辑。
数组的 find 方法
我们需要做的第一件事是尝试找到一个匹配项,我将使用数组的 find 方法来完成这个任务。我们将创建一个 const user
来存储匹配项,然后将其设置为 users.find
,传入我们的函数:
const getUser = (id) => {
return new Promise((resolve, reject) => {
const user = user.find(() = {
});
});
};
现在,这个函数会被数组中的每个项目调用一次。这是一个用户数组,所以我们可以称单个项目为 user
。如果我们返回 true
,它将被视为匹配。它将停止并将该对象设置为用户。如果我们返回 false
,它将继续遍历数组,如果没有匹配,用户的值将是未定义的。所以,我们将返回 user.id
,检查它是否等于传入的 id
。
const getUser = (id) => {
return new Promise((resolve, reject) => {
const user = user.find((user) => {
return user.id === id;
});
});
};
现在,我们在这里有一个很好的简写语法的候选者。我们只有一个返回某个值的箭头函数。它只提供值,并且会被隐式返回:
const getUser = (id) => {
return new Promise((resolve, reject) => {
const user = user.find((user) => user.id === id);
这里我们有完全相同的功能。现在,在我们使用它之前,让我们调用 resolve
或 reject
。如果有用户,我们将做一件事;如果没有用户,那没关系,我们将做其他事。在 else
语句中,我们将调用 reject
,在 if
语句中,我们将调用 resolve
:
const getUser = (id) => {
return new Promise((resolve, reject) => {
const user = user.find((user) => user.id === id);
if (user) {
resolve();
} else {
reject();
}
});
};
现在,resolve
只是将用户传入,对于 reject
,我们可以提供一个错误消息,帮助用户找出问题出在哪里。我们可以添加 无法找到 id 为 . 的用户
,然后,我们会把 id
放在旁边。在模板字符串中,我将引用 id
。这是传入 getUser
变量的确切值。
const getUser = (id) => {
return new Promise((resolve, reject) => {
const user = user.find((user) => user.id === id);
if (user) {
resolve(user);
} else {
reject('Unable to find user with id of ${id}.');
}
});
};
现在,在我们继续运行之前,让我们快速使用 getUser
。我将使用 id
为 2
调用 getUser
,应该返回 Jessica
。然后我会添加 then
和 catch
。在 catch
中,我们可以捕获错误。我们将获取错误并将其记录下来,console.log(e)
。
getUser(2).then().catch((e) => {
console.log(e);
});
然后我们可以设置我们的 then
回调;在 then
中,我们将获得对用户的访问,现在,我们将把它记录下来:
getUser(2).then((user) => {
console.log(user);
}).catch((e) => {
console.log(e);
});
运行 getUser 对象测试
要实际运行这个,我们将转到终端,确保我们得到预期的结果。我将使用 nodemon
来完成这个任务。如果你刚刚更新了 Node,它也更新了 npm
,最终意味着你不再可以访问这些全局模块。你可能需要重新运行 npm install -g nodemon
,然后才能使用 nodemon
命令。我将使用 nodemon
运行 app-promises.js
文件,然后,我们有了输出:
我们打印出了 schoolId
为 999
,id
为 2
的 Jessica。这是因为我在 getUser
中传入了 2
:
getUser(2).then((user) => {
console.log(user);
}).catch((e) => {
console.log(e);
});
如果我将这个替换为1
,我应该看到Andrew
。我确实看到了Andrew
。最后,让我们传入一个不存在的 ID,比如21
:
getUser(21).then((user) => {
console.log(user);
}).catch((e) => {
console.log(e);
});
在这里我确实收到消息:无法找到 id 为 21 的用户
:
这是我们要构建的三个项目中的第一个。让我们快速完成其他两个。
设置 getGrades 项目
这将使我们能够实际探索 async/await 语法,使用const getGrades
。getGrades
将与getUsers
非常相似。但是,它不会使用用户数组,而是使用成绩数组:
const getGrades = () => {
};
现在,我们没有设置任何成绩,所以让我们继续设置一些。
为 getGrades 项目创建成绩
我们将继续创建一些成绩。首先,让我们创建一个 id 为1
的成绩。现在,这个1
将附加到Andrew
,所以我们将使用schoolId
来做到这一点。在这种情况下,对于Andrew
,schoolId
是101
。然后,我们将输入实际的grade
。在这种情况下,我将给自己一个86
:
const grades = [{
id: 1,
schoolId: 101,
grade: 86
}];
这是第1
个成绩;让我们继续创建三个成绩。我将复制它,放入一个逗号,并粘贴两次。这个将有一个 id 为2
。我们可以将这个与Jessica
关联起来,所以我们将给她schoolId
值为999
。她非常聪明,所以我们给她100
。最后,id 为3
:我们将保留这个与Andrew
相关联,接下来,我们将给他一个80
的成绩:
const grades = [{
id: 1,
schoolId: 101,
grade: 86
}, {
id: 2,
schoolId: 999,
grade: 100
}, {
id: 3,
schoolId: 101,
grade: 80
}];
所以,我们已经设置了一些成绩,目标是根据他们的schoolId
返回特定学生的所有成绩。如果我传入101
,我期望得到一个包含与 Andrew 相关的对象的数组。如果我传入999
,我期望得到一个包含与 Jessica 相关的对象的数组,如果我传入像55
这样的值,那么该学生将没有成绩,所以我们将返回一个空数组。
返回一个新的 promise
现在,在getGrades
变量中,我们将得到我们用于查找的schoolId
。然后我们将继续返回一个新的 promise;这都是虚构的例子,resolve
和reject
是我们的两个参数:
const getGrades = (schoolId) => {
return new Promise((resolve, reject) => {
});
};
然后,在这里,我们将继续resolve
筛选后的成绩数组,即grades.filter
。我们将通过传递一个箭头函数来筛选这个数组。它将被调用与单个成绩,而不是用户,并且我们将继续隐式返回一些东西。如果我们返回true
,它将被视为匹配,并且该成绩将被解析。如果我们返回false
,那么该成绩将从被解析的数组中删除。在这种情况下,如果grade.schoolId
等于函数调用时的schoolId
,我们希望保留该成绩。
const getGrades = (schoolId) => {
return new Promise((resolve, reject) => {
resolve(grades.filter((grade) => grade.schoolId === schoolId));
});
};
在这种情况下,这就是getGrades
的全部内容;我们可以继续测试。我将调用getGrades
而不是getUser
。我将传入一个有效的schoolId
,比如101
,而不是用户,我们将有grades
,接下来:
getGrades(101).then((grades) => {
console.log(grades);
}).catch((e) => {
console.log(e);
});
如果我保存这个,我们得到什么?我们得到一个包含两个对象的数组,正如预期的那样:
我们有Andrew
的所有成绩,分别是86
和80
。我将继续传入999
;我们得到 Jessica 的成绩,最后,我们传入12
:
getGrades(12).then((grades) => {
console.log(grades);
}).catch((e) => {
console.log(e);
});
如果我传入12
,我们得到一个空数组,这正是我希望的。只剩下一个函数了,然后我们就完成了这一部分,可以继续下一部分了。
设置 getStatus 项目
这个项目将被称为const getStatus
。getStatus
将是一个函数,它将获取userId
,即要获取其状态的用户的id
。现在,这个项目的目标只是返回一条字符串。我们将从他们的名字开始,比如Andrew
,然后我们将添加一些信息;Andrew has a
,在这种情况下,我在课堂上有一个83
的平均分(所以我拿80
,我再加上86
,然后除以2
来生成这个平均分)。因此,我们希望在实际运行getUser
和getGrades
后,从getStatus
中解析出以下字符串:
const getGrades = (schoolId) => {
return new Promise((resolve, reject) => {
resolve(grades.filter((grade) => grade.schoolId === schoolId));
});
};
// Andrew has a 83% in the class
const getStatus = (userId) => {
};
解决getStatus
字符串
我们将继续完成这个,然后就完成了。这意味着我们将使用userId
调用getStatus
。我们将获取状态并将状态记录下来:
getStatus(1).then((status) => {
console.log(status);
}).catch((e) => {
console.log(e);
});
现在,让我们开始,我们需要做什么?首先,我们必须继续return
以保持 promise 链的活动状态,因为我们使用getStatus
函数附加了then
和catch
回调。
接下来,我们将调用getUser
。在我们实际使用getGrades
之前,我们必须获取userId
,找到用户对象,并获取他们的schoolId
。我们还想确保在消息中有姓名的访问权限,因此我们需要从该对象中获取两条信息:getUser
和userId
。我们将添加我们的then
回调。在这个回调中,我们将获得对该用户对象的访问权限,其中包含一些有用的信息。其中一条信息将允许我们实际调用getGrades
。我将在这里return getGrades
,然后我们将传入学生的学校 ID,即user.schoolId
:
// Andrew has a 83% in the class
const getStatus = (userId) => {
return getUser(userId).then((tempUser) => {
return getGrades(user.schoolId);
})
};
现在我们已经调用了getGrades
,我们将接下来访问这些成绩。getGrades
promise 的成功回调将获得grades
数组。然后我们可以继续创建一个average
变量,我们将在下一步中完成,然后我们可以return our string
。因此,这是这个函数的目标,但这是我们在使用 promise 时可能遇到的第一个问题之一:
// Andrew has a 83% in the class
const getStatus = (userId) => {
return getUser(userId).then((tempUser) => {
return getGrades(user.schoolId);
}).then((grades) => {
// average
// return our string
});
};
我们有getStatus
的 promise 链;我们必须调用一个 promise 才能实际启动另一个 promise,而且最终,我想对来自两者的值做一些处理。嗯,我们不能;我们无法在第二个then
函数内访问用户。它是在另一个函数中创建的,即第一个then
回调,这是一个相当常见的问题。
那么,我们该如何解决这个问题呢?我们可以有几种方法来解决。其中大多数都是有点丑陋的解决方法。就在getStatus
变量下面,我可以创建一个名为user
的变量,并首先将其设置为未定义。
const getStatus = (userId) => {
var user;
然后,在第一个then
回调中,当此函数运行时,我将给它一个值。现在,我不能有两个具有相同内容的变量。如果我尝试输入user = user
,我们将遇到一些问题:
const getStatus = (userId) => {
var user;
return getUser(userId).then((user) => {
user = user;
它将获取user
的值并将其设置为then
回调中的用户值。它根本不会使用用户变量。因此,我们必须添加另一个小的解决方法:tempUser
。
const getStatus = (userId) => {
var user;
return getUser(userId).then((tempUser) => {
user = tempUser;
然后我们将继续设置user = tempUser
,这在技术上可以工作。我们现在将可以访问用户变量并完成一些工作。
计算平均值
因此,我们可以在第二个then
回调函数中计算我们的average = 0
的平均值:
// Andrew has a 83% in the class
const getStatus = (userId) => {
var user;
return getUser(userId).then((tempUser) => {
user = tempUser;
return getGrades(user.schoolId);
}).then((grades) => {
var average = 0;
// average
// return our string
});
};
在整个课程中,我们一直在使用 const。我们实际上可以将我们的var
改为let
;let
是 ES6 中var
的等价物,因此这是一个其值可以更改的变量:
// Andrew has a 83% in the class
const getStatus = (userId) => {
let user;
return getUser(userId).then((tempUser) => {
user = tempUser;
return getGrades(user.schoolId);
}).then((grades) => {
let average = 0;
// average
// return our string
});
};
现在我们将从平均值0
开始,然后继续计算更好的平均值,如果有grades
:grades.length
。如果grades.length
大于0
,我们将继续实际运行计算。
}).then((grades) => {
let average = 0;
if (grades.length > 0) {
}
// average
// return our string
});
现在,我们要在这里使用一些数组方法。首先,我们将把平均值设置为某个值。我们将从对象数组开始,将其转换为数字数组。
我们将使用 map 来做到这一点;就是grades.map
。在这里,我们将继续访问单个 grade,我们要做的就是隐式返回grade.grade
。
if (grades.length > 0) {
average = grades_map((grade) => grade.grade)
}
所以,我们有单个grade
对象,我们正在尝试访问它的grade
属性。在这一点上,我们有一个数字数组。我们需要将这些数字转换为总和,然后我们必须将其除以数组的长度。我们将在这里使用reduce
,所以我们在数字数组上调用reduce
。reduce
的工作方式与您过去可能见过的其他一些数组方法有些不同。这个方法接受两个参数,a
和b
;
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => {
});
};
所以,第一次通过时,它将获取前两个成绩,我们将能够对这些成绩做些什么。我们想要做什么?我们想要返回a + b
。然后它将获取前两个成绩的总和,然后再次调用 reduce 函数,将该总和和第三个成绩放在一起。我们将取a + b
来获得添加到新b
上的值,然后我们将继续生成该总和。现在,你实际上可以简化a + b
:
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => a + b);
}
现在,这仅仅给我们提供了总和,所以在Andrew
的情况下,我们还没有计算出平均值83
;我们只是把这两个数字加起来了。你还想要除以grades.length
;这将给我们平均值。我们可以通过打印average
变量来测试这一点,console.log(average)
。
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => a + b) / grades.length;
}
console.log(average);
});
我要保存它。我们有getStatus
和getStatus
for 1
。这完全没问题,我们可以继续使用。在终端中,我们得到83
的打印,这是正确的平均值。
如果我继续为用户2
重新运行,我们会得到100
的打印。一切都运行得非常顺利;undefined
只是出现,因为我们没有返回任何东西,所以状态等于undefined
,这会在console.log
语句中打印出来。所以,我们不仅仅把平均值倒出来,让我们实际返回我们的模板字符串。这是我们在本节中要做的最后一件事情。
返回模板字符串
在平均值的console.log
语句上面,我们将按照这种格式开始,首先是名字,也就是user.name
。然后我们将继续下一部分,有一个,后面跟着他们的成绩。那就是average
。我们将在班级期间加上%
:
return `${user.name} has a ${average}% in the class.`;
现在我们正在返回一些东西,这个值将可以被调用getStatus
的人访问。在这种情况下,就是这里。在终端中,我们看到Jessica 在班级中有 100%
打印到屏幕上:
如果我继续到1
,我们会看到Andrew
有一个83
,如果我输入其他的id
,我们会看到打印无法找到 id 为 123 的用户
。所以,这就是我们构造的起始示例。我知道这里没有太多有趣的东西,但我保证有一个示例可以让理解 async/await 变得更容易。所以,下一节的目标是使用这种新的语法将代码片段简化为大约三行代码:
// Andrew has a 83% in the class
const getStatus = (userId) => {
let user;
return getUser(userId).then((tempUser) => {
user = tempUser;
return getGrades(user.schoolId);
}).then((grades) => {
let average = 0;
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => a + b) / grades.length;
}
return `${user.name} has a ${average}% in the class.`;
});
};
这将是三行代码,更容易阅读和处理。它看起来像同步代码,而不是回调和 promise 链。
Async/await 基础知识
在这一部分,你终于将要使用新的 async/await 功能。我们将创建getStatus
函数的另一个版本并称之为getStatusAlt
,所以我们可以继续并实际定义它:一个 const getStatusAlt
。现在,它仍然是一个函数,所以我们将开始创建一个箭头函数(=>
)。我们仍然会接受一个参数,所以我们将定义userId
:
const getStatusAlt = (userId) => {
};
现在,我们要改变一下。不再使用旧的例子,我们将使用新的 async/await 功能。为了探索这一点,让我们暂时注释掉getStatus
-then
和catch
块代码。我们将用getStatusAlt
的调用来重新创建它,而不是用getStatus
的调用,但我确实想留下旧代码,这样我们可以直接比较和对比差异。
新的 async/await 功能将允许我们以看起来像同步代码的方式编写旧代码,这意味着我们将能够避免像then
回调、promise 链和变通方法之类的东西。使用 async/await,我们将能够避免所有这些东西,创建一个更容易阅读、修改、处理和测试的函数。现在,getStatusAlt
将以一种非常无聊的方式开始。
我们将返回一个字符串,Mike
:
const getStatusAlt = (userId) => {
return 'Mike';
};
这是 JavaScript 的基础知识。你期望得到Mike
。如果我使用consult.log
,他的名字应该通过getStatusAlt
弹出。
const getStatusAlt = (userId) => {
return 'Mike';
};
console.log(getStatusAlt());
让我们继续进行这个,所以我们将保存文件,nodemon
将重新启动,然后我们就可以了。我们在屏幕上打印出Mike
:
这正是我们所期望的。现在,使用 async/await,我们实际上将我们的函数标记为特殊函数。我这里有几个函数。我们将把return 'Mike'
标记为一个特殊的 async 函数。所以在未来,async/await
将是两个单词async
和await
。这不仅仅是单词,而是我们将要输入的实际关键字。
使用异步函数
第一个await
将最终在我们的async
函数内部使用,但在我们能够使用await
之前,我们必须将该函数标记为async
,所以我们将首先这样做。我们将探索它,然后继续使用await
。在getStatusAlt
变量行中,我们要做的就是在参数列表前面加上async
并加上一个空格:
const getStatusAlt = async (userId) => {
return 'Mike';
};
现在,这实际上会改变console.log
的工作方式;为了探索这一点,我们要做的就是保存文件并查看我们得到了什么。我们不再得到字符串Mike
,你可以看到我们现在得到了一个Promise
:
我们得到一个解析为字符串Mike
的 promise,这就是常规函数和async
函数之间的第一个重大区别。常规函数返回字符串并返回字符串;async
函数总是返回 promise。如果你从async
函数返回了某些东西,实际上它返回的是一个 promise 并且解析了这个值。所以这个函数等同于以下代码。你不必写出这个;这只是为了理解。它等同于创建一个返回新 promise 的函数,这个新 promise 得到resolve
和reject
,然后用Mike
调用 resolve:
() => {
return new Promise((resolve, reject) => {
resolve('Mike')
})
}
这两者是相同的,它们具有完全相同的功能。我们创建一个新的 promise,我们解析Mike
或者使用一个简单返回某些东西的async
函数。
所以,这是第一课:当你有一个async
函数时,你返回的任何东西实际上都将被解析,这意味着我们可以改变这种用法。
在console.log
语句的位置,我将调用getStatusAlt
。这次我们得到一个 promise,我们知道,所以我们可以使用then
回调。我们将得到什么?我们将得到返回值作为我们的解决值。
如果我返回字符串,我会得到一个字符串;这里是一个数字,我会得到一个数字;一个布尔值,一个对象,一个函数;无论你从这个函数中明确返回什么,都将作为已解决的值可用,这意味着我可以创建一个名为console.log(name)
的变量:
const getStatusAlt = async (userId) => {
return 'Mike';
};
getStatusAlt().then((name) => {
console.log(name);
});
现在,在nodemon
中我们将得到什么?我们将再次得到Mike
,普通的字符串。因为我们添加了一个基于 promise 的链条,然后我们得到名字并将其打印出来,这里Mike
再次打印出来:
因此,如果返回一个值等同于解决,那么我们如何拒绝?
使用async
函数拒绝错误
如果我想要拒绝一个错误('This is an error'
),我该如何使用新的async
功能来做到这一点?我们只需使用标准的 JavaScript 技术抛出一个新的错误。使用消息抛出一个新的错误('This is an error'
):
const getStatusAlt = async (userId) => {
throw new Error('This is an error');
return 'Mike';
};
这相当于在新的 Promise 中使用reject
参数。当你从async
函数中抛出一个新的错误时,它与拒绝某个值完全相同。因此,在这种情况下,我们可以继续使用该错误,通过添加catch
,就像我们处理常规的 promise 一样。我们将得到错误,如果发生,我将使用console.log
将其打印到屏幕上:
getStatusAlt().then((name) => {
console.log(name);
}).catch(e) => {
console.log(e);
});
它总是会发生,因为我在第 1 行抛出了它。如果我保存文件,nodemon
会重新启动,我们会得到Error: This is an error
打印到屏幕上:
这些是在继续使用await
之前,你需要了解的async
函数的前两个重要事项。返回某些东西等同于解决,抛出错误等同于拒绝;我们总是得到一个 promise。
使用await
函数
到目前为止,我们只使用了功能的一半。我们使用了async
部分,单独使用并不特别有用。当我们将其与await
结合使用时,它变得非常有用,所以让我们继续开始看一下。
await
函数将允许我们重新引入其他函数,getGrades
和getUser
。我们将继续使用await
,然后我们将讨论确切发生了什么。所以,暂时跟我一起,只需输入这一行:const user =
,然后我们将其设置为await
关键字。我们将在接下来讨论这一点;我们将调用getUser
并传入userId
。所以让我们开始分解这一行:
const getStatusAlt = async (userId) => {
const user = await getUser(userId);
};
我们以前做过这个;我们使用userId
调用getUser
。它返回一个 promise。我们创建一个新的变量 user,它是一个常量;新的部分是await
。所以await
关键字,正如我之前提到的,必须在async
函数中使用。我们满足了这个标准。我们有一个async
函数,这意味着我们可以在其中使用await
。
我们在一个 promise 之前使用await
,所以这里我们得到一个 promise。因此,我们正在等待该 promise 要么resolve
要么reject
。如果该 promise 被解决,这个表达式的结果将是解决的值,这意味着解决的值将被存储在用户变量中。如果 promise 被拒绝,它将等同于抛出一个错误,这意味着永远不会创建用户变量。函数将停止执行,我们将在catch
中得到该错误。
让我们继续实际操作一下。我要把一个id
传递给getStatusAlt
。让我们使用Jessica
;我们要做的就是将user
打印到屏幕上。现在,nodemon
将在后台重新启动,我的nodemon
在之前被清除了:
在这里,我们有一个id
为2
的对象,名为Jessica
,学校 id 为999
。
现在,没有await
,我们将得到一个 promise;有了await
,我们实际上能够得到那个值,这相当于我们之前所做的。这相当于我们获得了用户的访问权并对其进行了操作,但是,使用async/await
,我们能够以看起来非常同步的方式做到这一点。
现在,我们可以将这个确切的技术应用到获取成绩上。在user
常量旁边,我们将创建一个名为grades
的常量。我们想要那些成绩。我们不想创建愚蠢的临时变量,添加复杂的链接和嵌套。我们只想得到成绩,所以我们将等待以下 promise,要么resolve
要么reject
。对于从getGrades
返回的那个,我们传入一个有效的学校 id,user.schoolId
:
const getStatusAlt = async (userId) => {
const user = await getUser(userId);
const grades = await getGrades(user.schoolId);
console.log(user, grades);
};
这将返回该用户的grades
数组,我们将能够将它们显示在屏幕上。
所以,在终端中,我们正在获取Jessica
的对象和成绩:
她只有一个成绩,所以我们有一个包含单个对象的数组。我们得到的不是一个 promise;我们得到的是一个普通的数组。对于Andrew
也是一样;他将有这两个成绩:
getStatusAlt(123).then((name) => {
console.log(name);
}).catch((e) => {
console.log(e);
});
它们都会回来,如果我把这个切换到一个无效的id
,我们会得到我们的错误:无法找到 id 为 123 的用户
:
这是因为await
函数会拒绝,这相当于抛出一个错误。我们看到当我们从具有访问权限的async
函数中抛出错误时,可以通过catch
访问它。所以,在这一点上,我们有了我们的用户,我们有了我们的成绩,我们准备继续进行最后一步,也就是真正重要的事情。到目前为止,我们只是获取数据,需要嵌套几行代码。在这一点上,我们可以采用平均值的代码片段,就像它所在的位置一样:
let average = 0;
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => a + b) / grades.length;
}
return `${user.name} has a ${average}% in the class.`;
这段代码依赖于一个user
变量,我们有,还有一个grades
变量,同样,我们也有。我们可以把它复制到async
函数中,像这样:
const getStatusAlt = async (userId) => {
const user = await getUser(userId);
const grades = await getGrades(user.schoolId);
let average = 0;
if (grades.length > 0) {
average = grades.map((grade) => grade.grade).reduce((a, b) => a + b) / grades.length;
}
return `${user.name} has a ${average}% in the class.`;
};
现在,我们设置了我们的平均变量。如果有成绩,我们计算平均值并返回我们的状态:
getStatusAlt(2).then((status) => {
console.log(status);
}).catch((e) => {
console.log(e);
});
在这一点上,我们可以使用状态,并使用console.log
语句打印出来。让我们把它改回一个有效的id
,要么是1
要么是2
,一些存在的id
。这一次,当 JavaScript 运行getStatusAlt
时,它实际上会返回正确的状态:Andrew 在课堂上得了 83 分
或Jessica 在课堂上得了 100 分
:
我们能够做到所有这一切,没有一个回调,没有链接,没有嵌套,只是看起来像同步代码的常规旧代码。
这段代码比我们上面的更可读,更易维护,更易理解。使用async
/await
,我们将能够做到这一点。我们将能够编写更好、更简洁的基于 promise 的代码。现在,你会注意到我在上面的函数中没有使用await
。没有必要,因为我们不需要在其中使用async
。还有一件重要的事情要注意,就是没有顶层的await
。你必须在async
函数中使用await
,所以在我们的情况下,这意味着我们在最后使用了一点点链接,但是当我们处理复杂的链式结构时,我们可以使用async/await
来完成工作。
此时,我不指望你能够自己使用async
/await
。我们将通过另一个使用真实 API 的例子来获得更多真实世界的经验。我很兴奋。
一个真实世界的例子
在这一部分,我们将从我们编造的例子中迈出,然后我们将看一个使用两个真实的 HTTP API 的例子。在此之前,重要的是要注意箭头函数(=>
)并不是唯一支持async
的函数。我碰巧使用了箭头函数(=>
)。我也可以使用带有function
关键字的 ES5 函数;这同样有效:
const getStatusAlt = async function (userId) {
const user = await getUser(userId);
const grades = await getGrades(user.schoolId);
let average = 0;
我可以保存文件,仍然会打印出Jessica has 100%
:
我也可以async
一个 ES6 对象方法,但我会继续使用箭头函数(=>
)。现在,我们将把这个文件抛在脑后,然后转到一个全新的文件来进行我们的真实世界的例子。
使用 async/await 函数创建货币转换器
这个文件将被称为currency-convert.js
,你可能已经猜到,我们将创建一个货币转换器。
基本上,这只是一个带有三个参数的函数。我们将从我们要开始的货币代码开始;在这种情况下,假设我有美元。然后是我们要转换的货币代码;假设我要去加拿大,想知道我的钱值多少;以及我们要转换的金额。
因此,这本质上是在询问23 美元
的加拿大等值物:
// USD CAD 23
我们将能够使用任何我们想要的代码和任何我们想要的值。现在,为了真正获得所有这些信息,我们将使用两个 API。我们基本上会说23 美元相当于 28 加拿大元。你可以在以下国家使用这些
:
// USD CAD 23
// 23 USD is worth 28 CAD. You can spend these in the following countries:
然后我们将列出所有实际接受加拿大元的国家。现在,为了真正获得所有这些信息,我们将使用这两个 API,并且我想在 Chrome 中探索它们。然后,我们将安装 Axios,发出请求,并将所有这些集成到货币转换器中。
探索货币汇率的 API
我们将要使用的第一个 API 在fixer.io上:
这将给我们当前货币数字,所以我们将能够获得这些汇率。如果我们去他们的网站,他们有一个很好的使用页面。你可以点击 URL;它会显示你如果发出了 HTTP 请求会得到的确切数据。
这个 API 和我们将要使用的另一个 API,它们不需要身份验证,所以我们将能够轻松地集成它们。在这里,我们看到基本货币是欧元,我们可以看到欧元在其他货币中的价值。因此,€1 目前价值 1.2411 美元或 1.5997 加拿大元:
这是我们将要使用的第一个端点,我们实际上将使用一个替代方案。在这里,我们可以指定基本查询参数,这将使我们从我们选择的货币开始。这是我们要转换的货币,然后我们得到汇率。
因此,如果我想将美元转换为加拿大元,我将得到基本美元转换图表。我会找到这个数字,然后将23
乘以这个数字,或者我想要转换的任何值。所以这是 API 编号一;让我们去获取 URL,在浏览器中打开它,然后我们将保持它开着。
我们将要使用的另一个 API,你可以在restcountries.eu找到。这个 API 包含一些有关国家的有用信息。如果你去 All 示例,我们可以得到这个 URL:
我们可以在浏览器中打开它,然后我们可以看到 API 返回的国家和国家数据的详尽列表,从阿富汗
开始:关于它的各种信息,顶级域,替代拼写,地区
,纬度和经度,人口
,很多非常好的信息:
它还包括那些货币代码,所以我们实际上将使用不同的端点。他们支持货币端点:
这让您找到使用特定货币的国家,因此让我们使用此 URL 并在浏览器中打开它。这里我们使用cop
;让我们继续将其替换为usd
:
现在,有多个国家使用美元。我们有美属萨摩亚
。
在下面,如果我们向下滚动列表,我们将得到其他东西;例如,这里有津巴布韦。然后,我们有美利坚合众国-这是一个明显的例子-特克斯和凯科斯群岛:
因此,有很多不同的地方使用美元。如果我们将其替换为cad
-加拿大;只有一个:
因此,使用这两个端点,我们将能够转换货币并找出哪些国家支持该货币。我们将把所有这些连接在一起,然后我将让您自己使用 async/await 来获取该信息。
在我们的应用程序中利用 axios
让我们通过关闭事物,清除输出并安装必要的依赖项来在终端内启动事物。我将运行npm init
:
我们可以使用默认值快速生成一个package.json
文件,然后使用npm install axios
。Axios 的当前版本是0.18.1
,我们将添加save
标志:
npm install axios@0.18.0 --save
它将确保将其作为依赖项,并且一切都按预期工作。现在我们可以清除此输出,并且实际上可以在我们的应用程序中利用 Axios。因此,让我们从第一个开始。我们将继续设置一个函数,该函数调用 Fixer 并获取汇率。
getExchangeRate 函数
为此,在 Atom 内部,我们将通过创建一个const axios
来启动事物;我们将require axios
,然后我们将为我们的两个函数之一创建const
。这是第一个端点getExchangeRate
。现在,getExchangeRate
将获取两个信息:from
货币代码和to
货币代码:
// USD CAD 23
// 23 USD is worth 28 CAD. You can spend these in the following countries:
const axios = require('axios');
const getExchangeRate = (from, to)
这将是一个函数。我们将只使用axios
。这是axios.get
,然后我们将传入我刚从浏览器中复制的 URL:
// USD CAD 23
// 23 USD is worth 28 CAD. You can spend these in the following countries:
const axios = require('axios');
const getExchangeRate = (from, to) => {
axios.get(`http://api.fixer.io/latest?base=USD)
}
现在我们想将基础设置为我们来自的任何货币。因此,我可以使用模板字符串,将静态基本值替换为动态值,访问from
和then
函数。我们将快速使用then
。我们将在这里使用then
来操纵值。这将返回 Axios 的承诺,并提供有关 HTTP 请求的大量信息。getExchangeRate
的调用者不关心这一点。他们甚至不应该知道已经发出了 HTTP 请求。他们需要的只是一个数字,这正是我们将给他们的。
在then
回调中,我们将可以访问response
,并且在response
上,我们将能够获取该货币代码。我们将return response.data
。这将使我们进入 JSON 对象。现在,在这里我们有一个rates
对象,其中包含键值对,其中键是货币,因此我们确实要访问rates
并获取to
变量的汇率:
// USD CAD 23
// 23 USD is worth 28 CAD. You can spend these in the following countries:
const axios = require('axios');
const getExchangeRate = (from, to) => {
axios.get(`http://api.fixer.io/latest?base=USD).then((response) => {
return response.data.rates[to]
});
}
因此,在这种情况下,我们将有美元兑加拿大元。我们将使用USD
调用此 URL,然后我们将得到这个值:1.2889
。这正是我们要返回的确切值。让我们在下面测试一下,getExchangeRate
。我将把USD
传递给CAD
(加拿大)元,然后我们将得到我们的rate
并将其记录下来,console.log(rate)
:
// USD CAD 23
// 23 USD is worth 28 CAD. You can spend these in the following countries:
const axios = require('axios');
const getExchangeRate = (from, to) => {
return axios.get(`http://api.fixer.io/latest?base=USD).then((response) => {
return response.data.rates[to]
});
};
getExchangeRate('USD', 'CAD').then((rate) => {
console.log(rate);
});
我将继续保存这个。在后台,在终端中,我们可以再次启动nodemon
,然后运行currency-convert
文件。在这里,我们得到了值1.2889
是当前的货币汇率:
我可以在代码中放入EUR
:
getExchangeRate('USD', 'EUR').then((rate) => {
console.log(rate);
});
我可以弄清楚欧元的汇率是0.80574
,所以我们可以:
我们已经完成了第一个。现在,我们要快速创建的另一个是getCountries
。
getCountries
函数
getCountries
函数将获取一个国家列表,只包括它们的名称,并且我们将通过currencyCode
来获取它:
const getCountries = (currencyCode) => {
};
这个,就像getExchangeRate
一样,也将从axios.get
返回一个 promise,我们要获取的 URL 位于浏览器中:
因此,我们在这里有我们的 URL。我们有一个地方可以将我们的currencyCode
输出,所以我们可以完成这一步。它将是一个模板字符串,我们将去掉CAD
并注入currencyCode
参数:
const getCountries = (currencyCode) => {
return axios.get(`https://restcountries.eu/rest/v2/currency/${currencyCode}`)
};
在这一点上,再次,我们确实想对数据进行一些操作,所以我可以继续使用then
。在then
回调中,我可以继续访问response
。我想对response
做的是,我只想循环遍历它。我想找出所有支持我的货币的国家,然后我想返回一个数组:
const getCountries = (currencyCode) => {
return axios.get(`https://restcountries.eu/rest/v2/currency/${currencyCode}`).then((response) => {
});
};
现在你知道我们将得到所有这些国家,对吧?因此,对于cad
,我们有一个包含单个对象的数组。对于usd
,我们有一个包含多个对象的数组,因此我们将把这个对象数组转换为一个字符串数组。
我们将从美属萨摩亚
开始:
为了做到这一点,我们将使用map
。回到 Atom 中,我们可以通过返回response.data
来快速完成这个任务,它是一个数组,这意味着它可以访问map
方法。然后我们将使用map
。每个单独的项目将是一个country
;每个country
都有一个name
属性,所以我们可以返回country.name
,给我们一个支持该currency
的国家名称数组。在这种情况下,我们可以通过隐式返回country.name
来简化这个过程。现在,让我们在这里测试一下;getCountries
。我们将得到countries
并将其输出到屏幕上。我们只提供一个参数:
const getCountries = (currencyCode) => {
return axios.get(`https://restcountries.eu/rest/v2/currency/${currencyCode}`).then((response) => {
return response.data.map((country) => country.name);
});
};
getCountries('USD').then((countries) => {
console.log(countries);
});
因此,如果我们保存并在终端中检查,我们应该看到确切的返回内容。这里有一个我们可以在其中使用该货币的所有国家的列表,在这种情况下是美元:
我们可以去EUR
看看哪些国家支持它:
getCountries('EUR').then((countries) => {
console.log(countries);
});
如果我们保存文件,我们将在片刻后得到那个列表。
这里有所有支持它的国家,从比利时
一直到津巴布韦
和西班牙
:
所有这些国家都包括在内。接下来是 CAD:
getCountries('CAD').then((countries) => {
console.log(countries);
});
你应该只有一个,加拿大,它确实显示在那里:
因此,此时我们已经有了实际完成任务所需的所有数据。因此,我们将一起在我们的 promises 中构建这个函数的等价物,而你将构建async
的函数。
在这里,让我们开始吧:const convertCurrency
。这将是我们要构建的函数。这是您最终将使async
的函数,但是现在,我们将其保留为常规箭头函数(=>
)。我们将获取我们要转换的货币代码from
和to
,以及我们要转换的金额。
const getCountries = (currencyCode) => {
return axios.get(`https://restcountries.eu/rest/v2/currency/${currencyCode}`).then((response) => {
return response.data.map((country) => country.name);
});
};
const convertCurrency = (from, to, amount) => {
};
在这里,我们可以通过获取这些国家来开始。我将返回getCountries
。我将用我们要转换的货币调用它,然后我们可以添加then
,然后我们将得到countries
列表。接下来,我们将返回一个调用getExchangeRates
- 传入from
和to
,我们也将得到一个承诺,这意味着我们可以添加另一个then
调用。在这里,我们将得到那个汇率:
const convertCurrency = (from, to, amount) => {
return getCountries(to).then((tempCountries) => {
return getExchangeRate(from, to);
}).then((rate) => {
});
};
现在,在then
回调中,我们可以继续计算我们要计算的所有内容。在这种情况下,我们将生成我所说的那个长字符串。
让我们首先创建一个const
;这个常量将被称为exchangedAmount
。我们要做的就是取用户传入的amount
并乘以汇率;所以,在这种情况下,我们成功地将美元从加拿大元转换过来。现在,在下面,我们可以开始处理那个字符串。我们将返回一个模板字符串,在这里,我们将做很多事情。
首先,我们将从amount
开始。from
货币中的amount
是worth
。然后我们将把amount
放在你要去的货币中,它是exchangedAmount
。然后我们将加上to
:
const convertCurrency = (from, to, amount) => {
return getCountries(to).then((countries) => {
return getExchangeRate(from, to);
}).then((rate) => {
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}`;
});
};
这是第一部分。我们实际上可以在继续之前测试一下。接下来,我要将getCountries
调用切换到convertCurrency
调用。我们将把加拿大元转换成美元。让我们转换一百个。现在我们将得到status
,而不是实际得到国家列表:
const convertCurrency = (from, to, amount) => {
return getCountries(to).then((countries) => {
return getExchangeRate(from, to);
}).then((rate) => {
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}`;
});
};
convertCurrency('CAD', 'USD', 100).then((status) => {
console.log(status);
});
我们可以保存 currency-convert 并查看在终端中发生了什么。在这里,我们得到100 CAD 值 73.947 USD
,这是一个很好的第一步。
现在,我们还将添加那个国家列表,这在这个函数中我们无法访问。我们可以通过上次使用的相同步骤。我们将创建tempCountries
。在上面,我们可以创建一个名为 countries 的新变量,并将 countries 设置为tempCountries
,就像这样:
const convertCurrency = (from, to, amount) => {
let countries;
return getCountries(to).then((tempCountries) => {
countries = tempCountries;
return getExchangeRate(from, to);
}).then((rate) => {
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}.`;
});
};
现在我们将能够访问这些国家并对它们进行操作。我们要做什么?我们只是将它们全部连接在一起,用逗号分隔,创建一个漂亮的列表。那将是我们谈论的货币。
然后,我们将添加一个冒号,然后我们将插入以下内容。所以,我们将获取所有这些国家,我们将获取那个数组,然后我们将使用join
将其转换为字符串。我们想在它们之间放什么?我们将放一个逗号和一个空格,我们将创建一个逗号分隔的国家列表,该货币可以在其中使用:
const convertCurrency = (from, to, amount) => {
let countries;
return getCountries(to).then((tempCountries) => {
countries = tempCountries;
return getExchangeRate(from, to);
}).then((rate) => {
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}. ${to} can be used in the following countries: ${countries.join(', ')}`;
});
};
现在我们可以保存 currency-convert 并查看在nodemon
重新启动时发生了什么,100 CAD 值 73 USD。USD 可以在以下国家使用
:
然后我们有一个我们可以在其中使用的所有国家的列表。让我们继续测试一个不同的变体。让我们将美元换成加拿大元:
convertCurrencyAlt('USD', 'CAD', 100).then((status) => {
console.log(status);
});
这一次,我们将得到以下不同的输出:
在这种情况下,加拿大元可以在以下国家使用,这种情况下只有Canada
。一切都按预期运行。问题在于我们使用 promise chaining 来完成所有工作。我们需要使用async
函数而不是那样。
创建 convertCurrencyAlt 作为 async/await 函数
我们将把这个转换成 async/await,并且你将在convertCurrency
函数的末尾完成这个过程。我们将使用创建 convertCurrencyAlt 作为 async 函数
。所以,就像我们在 app-promises 中所做的那样,你将创建一个async
函数。然后你将使用await
获取两个数据片段:使用 await 获取国家和汇率以及我们的两个函数
。所以,你将等待这两个 promise,然后将该值存储在某个变量中。你可以创建一个国家变量和一个rate
变量。最后,你将能够将这两行代码添加到最后:
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}. ${to} can be used in the following countries: ${countries.join(', ')}`;
});
这将计算exchangedAmount
并返回正确的信息:计算 exchangedAmount
和返回状态字符串
。你有两个语句来获取数据,一个用来计算交换金额,最后一个用来实际打印出来。
我们将继续创建const convertCurrencyAlt
。这将是一个async
函数,所以我们必须标记它。然后我们可以继续我们的参数列表,与另一个函数完全相同:from
,to
和amount
。然后我们将放置箭头和箭头函数(=>
),然后打开和关闭大括号。
const convertCurrencyAlt = async (from, to, amount) => {
});
现在我们可以继续进行第一步,即获取国家和获取汇率。我将从国家开始;const countries
等于。我们将等待从getCountries
返回的 promise。我们想要获取哪些国家?那些可以使用to
货币的国家。然后我们将继续获取汇率。所以,const rate
。在这种情况下,我们也要尝试await
一些东西;我们要等待从getExchangeRate
返回的 promise。我们要在这里获取汇率,from
和to
:
const convertCurrencyAlt = async (from, to, amount) => {
const countries = await getCountries(to);
const rate = await getExchangeRate(from, to);
};
所以,此时,我们已经有了所有的数据,我们可以继续计算交换金额并返回字符串。我们已经构建好了,没有必要重新创建。我们可以复制这两行代码,粘贴到下面,然后就完成了。一切都完成了:
const convertCurrencyAlt = async (from, to, amount) => {
const countries = await getCountries(to);
const rate = await getExchangeRate(from, to);
const exchangedAmount = amount * rate;
return `${amount} ${from} is worth ${exchangedAmount} ${to}. ${to} can be used in the following countries: ${countries.join(', ')}`;
};
现在,在下面,我们可以调用convertCurrencyAlt
,而不是调用convertCurrency
,传入完全相同的参数并返回状态。
convertCurrencyAlt('USD', 'CAD', 100).then((status) => {
console.log(status);
});
不同之处在于我们的函数使用async
;更易读,更容易使用。我们将继续保存 currency-convert。这将运行整个过程,获取所有数据,转换它,然后我们将打印状态。最终我们得到什么?在这里,我们得到与输出中显示的完全相同的东西:
在下一节中,我们将讨论在这个示例中可以使用async
的其他一些地方,并且我们还将讨论如何处理和处理错误。
处理错误和等待 async 函数
我们将首先将getExchangeRate
和getCountries
转换为async
函数。它们是很好的候选者,因为我们有 promise,我们可以等待这些 promise。然后我们将讨论错误,我们如何抛出错误以及如何自定义其他代码抛出的错误。这将使它非常有用,并且在实际需要处理错误的现实世界中,使用async
/await
会更容易。
将 getExchangeRate 和 getCountries 转换为 async 函数
所以我们要做的第一件事是转换getExchangeRate
和getCountries
。我要采取的第一步是将其转换为async
函数,否则我们无法使用await
。然后我们将继续设置一个变量,一个const
response,并将其设置为await
。然后我们将等待以下承诺,来自axios.get
返回的承诺。我将复制它,粘贴它,末尾加上一个分号,唯一剩下的事情就是返回这个值。我将取出return
语句,移动到那里,然后我们可以删除之前的所有代码:
const getExchangeRate = async (from, to) => {
const response = await axios.get(`http://api.fixer.io/latest?base=${from}`);
return response.data.rates[to];
}
现在我们有完全相同的功能,而且更好一些。现在,好处并不像从convertCurrency
到convertCurrencyAlt
那样显著,但确实很好,我建议您尽可能使用async
。现在我们需要使用刚刚遵循的相同步骤来转换getCountries
:
- 将
currencyCode
标记为async
:
const getCountries = async (CurrencyCode) => {
- 创建
response
变量并实际等待承诺。我们将等待以下承诺:
axios.get(`https://restcountries.eu/rest/v2/currency/${currencyCode}`);
- 最后一步就是返回完全相同的东西。就是这样:
return response.data.map((country) => country.name);
现在这两个都转换了,我们只需通过保存文件来测试我们的工作,只要我们得到完全相同的输出,我们就会继续讨论错误;如何捕捉它们,如何抛出它们,以及一般来说,我们如何改进出现在我们应用程序中的错误。
好的,新的结果刚刚出现:
这与其他两个是相同的,这意味着我们可以继续进行。现在,我想把讨论转移到错误上。
异步函数中的错误处理
我们要做的是让端点触发错误。我们将看看我们如何处理这些端点,以及如何调整它们成为更有用的东西,因为目前我们将得到一大堆垃圾。
将错误打印到屏幕上
第一步是实际上将错误打印到屏幕上,这样我们就可以看到我们正在处理的是什么。我们将catch
错误,并打印错误,console.log(e)
:
convertCurrencyAlt('USD', 'CAD', 100).then((status) => {
console.log(status);
}).catch((e) => {
console.log(e);
});
现在让我们开始让一些东西失败。我们将通过使getCountries
失败来开始;那是第一个调用。
现在,这个只使用了to
,所以我们要做的就是让它失败,发送一个错误的countryCode
。我将使用MMM
:
convertCurrencyAlt('USD', 'MMM', 100).then((status) => {
console.log(status);
}).catch((e) => {
console.log(e);
});
保存文件,我们将看到在浏览器中得到什么。现在我们将得到一大堆垃圾:
这里返回的实际上是axios
的响应。这里有错误信息;它有状态码之类的东西。你可以看到它是 404。我们有一条消息说未找到。这让我们知道我们提供的countryCode
没有被该端点找到。现在这并不特别有用。
我们想要得到一些更有用的东西,比如一条消息:无法获取使用 MMM 的国家
。那将很好。所以,为了做到这一点,我们将调整getCountries
。我们将继续使用一个普通的try catch
块,并设置如下:
const getCountries = async (CurrencyCode) => {
try{
} catch (e){
}
如果try
块中的代码引发错误,catch
块将运行,否则catch
将永远不会运行。我们要做的就是取const response
和return statement
代码,并将其移动到try
内部:
const getCountries = async (currencyCode) => {
try {
const response = await axios.get(`https://restcountries.eu/rest/v2/current/${currencyCode}`);
return response.data.map((country) => country.name);
} catch(e){
}
};
所以,我们的意思是,每当这两行中的任何一行引发错误时,运行catch
块中的代码并提供错误。现在我们知道错误是什么。它并不包含太多东西,所以我们要做的就是抛出我们自己的错误;一些人类可读的东西:throw new Error
。在这种情况下,我们将坚持使用模板字符串,并且我们将继续设置它:无法获取使用的国家
。然后我们将在句号之前注入currencyCode
:
const getCountries = async (currencyCode) => {
try {
const response = await axios.get(`https://restcountries.eu/rest/v2/current/${currencyCode}`);
return response.data.map((country) => country.name);
} catch(e){
throw new Error(`Unable to get countries that use
${currencyCOde}.`);
}
};
现在,如果我们使用错误的countryCode
保存 currency-convert 应用程序,我们将看到错误无法获取使用 MMM 的国家
。我们可以直接使用e.message
访问消息。
convertCurrencyAlt('USD', 'MMM', 100).then((status) =>{
console.log(status);
}).catch((e) => {
console.log(e.message);
});
这将进一步改善输出。现在我们只有一个字符串。我们可以对该字符串做任何我们想做的事情。非常清楚:无法获取使用 MMM 的国家
。
getExchangeRate 函数的错误处理
现在,让我们继续看我们列表中的下一个,即getExchangeRate
函数。这里实际上有两件事情可能出错;axios
请求本身可能会失败,我们也可能得到一个有效的响应,但to
状态码无效。在这种情况下,就不会有汇率。
现在我们可以通过注释掉一些代码行来实际模拟这一点,这将允许我们测试getExchangeRate
的隔离:
const convertCurrencyAlt = async (from, to, amount) => {
//const countries = await getCountries(to);
const rate = await getExchangeRate(from, to);
//const exchangedAmount = amount * rate;
//
//return `${amount} ${from} is worth ${exchangedAmount} ${to}. ${to} can be used in the following`;
};
现在,如果我将USD
弄乱成QWE
,我们可以保存文件,然后会得到另一个错误请求失败,状态码为 422:
我们要做的是再次进行完全相同的过程。所以,我们将这两行代码放在try catch
块中。然后,如果catch
运行,你将使用以下格式抛出一个新的错误:无法获取 USD 和 CAD 的汇率
。这将是从 USD 货币代码到 CAD 货币代码。我们将通过设置try catch
块来启动这些事情。我们将尝试运行以下代码:
const response = await axios.get(`http://api.fixer.io/latest?base=${from}`);
return response.data.rates[to];
如果它运行将会非常好,但是如果没有,我们也希望通过抛出新的错误来处理。在catch
块内,我们将提供我们的消息;它将只是一个模板字符串。现在,我想提供的消息是无法获取${from}和${to}的汇率
,后面跟一个句号:
const getExchangeRate = async(from, to) =>{
try{
const response = await axios.get(`http://api.fixer.io/latest?base=${from}`);
return response.data.rates[to];
} catch(e){
throw new Error(`Unable to get exchange rate for ${from} and ${to}.`);
}
};
现在我们可以保存这个并查看浏览器中发生了什么:
我们得到无法获取 QWE 和 MMM 的汇率
。现在目前失败是因为from
无效,但是如果from
有效呢?如果我们试图从USD
到MMM
?这一次我们将得到不同的东西:
这里我们只得到undefined
。这是因为getExchangeRate
中的return
语句有response.data.rates
。我们有一个有效的from countryCode
,所以返回有效数据,但to countryCode
不存在,这就是 undefined 的来源。
我们可以通过创建一个变量const rate
来修复这个问题,并将其设置为response.data.rates[to]
。然后,一点点的if
逻辑。如果有rate
,我们将继续返回它。如果没有rate
,我们将继续throw
一个新的错误,这将触发catch
块,将消息打印到屏幕上:
const getExchangeRate = async(from, to) =>{
try{
const response = await axios.get(`http://api.fixer.io/latest?base=${from}`);
const rate = response.data.rates[to];
if(rate){
return rate;
} else{
throw new Error();
}
} catch(e){
throw new Error(`Unable to get exchange rate for ${from} and ${to}.`);
}
};
现在,如果我们使用相同的代码保存代码,我们再次收到消息:无法获取 USD 和 MMM 的汇率
:
如果from
无效,如果to
无效,或者两者都无效,这条消息将显示出来。
现在,我们已经设置了一些小的错误处理,并且可以将应用程序的其余部分重新引入。如果我们使用这里的错误数据运行应用程序,将打印无法获取使用 MMM 的国家
:
让我们切换回到有效的国家代码,比如USD
和CAD
。让我们实际上使用欧元EUR
来进行一些改变,在浏览器中,我们应该得到有效的值,因为这两个国家代码确实是有效的:
在这里,我们得到了汇率,我们得到了所有使用欧元的国家,我们没有得到任何错误消息,这太棒了。因此,通过使用常规技术,例如 JavaScript 中的try
、catch
和throw
新错误,我们能够使用这些async
函数创建一个非常好的设置。
这就是本节的全部内容,也是我们小小的货币转换示例的全部内容。因此,到目前为止,我们已经经历了两个示例:我们经历了应用承诺示例,其中我们有一组人为设置的数据;我们创建了一些函数,并且了解了async
/await
的基础知识。然后我们经历了货币转换示例,我们在其中使用了两个真实的 API,并且增加了更健壮的错误处理。归根结底,它们都将使用完全相同的async
和await
技术。希望您开始看到这如何适用于我们的 Node 应用程序。在下一节,也是最后一节中,我们实际上将使用async
和await
对 Node API 进行一些更改。
总结
在本章中,我们研究了一个新的语法async
/await
。我们研究了项目设置、async
/await
的基础知识和一个真实的示例。我们还研究了使用async
/await
进行错误处理。