NodeJS-示例-全-

NodeJS 示例(全)

原文:zh.annas-archive.org/md5/59094B51B116DA7DDAC7E4359313EBB3

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Node.js 是当今最流行的技术之一。其不断增长的社区以每天产生大量的模块而闻名。这些模块可以作为服务器端应用程序的构建模块。我们在服务器端和客户端都使用相同的语言(JavaScript)使得开发更加流畅。

本书包含 11 章,提供了构建社交网络的逐步指南。像 Facebook 和 Twitter 这样的系统是复杂且具有挑战性的开发。我们将了解 Node.js 的能力,但如果能在具体的上下文中进行学习,将会更加有趣。本书涵盖了基本阶段,如架构和资产管道的管理,并讨论了用户友谊和实时通信等功能。

本书涵盖内容

第一章, Node.js 基础,教授了 Node.js 的基础知识,技术背后的原理,以及其模块管理系统和包管理器。

第二章, 构建项目,揭示了 Gulp 等构建系统的强大功能。在开始构建我们的社交网络之前,我们将规划项目。我们将讨论测试驱动开发和模型-视图-控制器模式。本章将涵盖启动项目所需的 Node.js 模块。

第三章, 管理资产,涵盖了构建 Web 应用程序。因此,我们必须处理 HTML、CSS、JavaScript 和图像。在本章中,我们将介绍资产服务背后的过程。

第四章, 开发模型-视图-控制器层,讨论了我们应用程序的基本结构。我们将创建视图、模型和控制器的类。在接下来的几章中,我们将以这些类为基础。

第五章, 用户管理,讨论了实现用户注册、授权和配置管理。

第六章, 添加友谊功能,解释了现代社交网络背后的主要概念之一——友谊。找到朋友并关注他们的动态是一个重要部分。本章专门讨论了用户之间的这种关系的发展。

第七章, 发布内容,指出每个社交网络的支柱是用户添加到系统中的内容。在本章中,我们将实现发布内容的过程。

第八章, 创建页面和活动,指出为用户提供创建页面和活动的能力将使我们的社交网络更加有趣。用户可以添加任意数量的页面。其他用户将能够加入我们网络中新创建的地方。我们还将添加代码来收集统计数据。

第九章, 标记、分享和点赞,解释了除了发布和审查内容之外,社交网络的用户还应该能够标记、分享和点赞帖子。本章专门讨论了这些功能的开发。

第十章, 添加实时聊天,讨论了用户在当今世界对即时了解一切的期望。他们希望能够更快地相互交流。在本章中,我们将开发一个实时聊天功能,使用户可以即时发送消息。

第十一章 测试用户界面 解释了完成工作的重要性,但覆盖工作功能的测试也很重要。在本章中,我们将看到如何测试用户界面。

本书所需内容

本书基于 Node.js 版本 0.10.36。我们还将使用 MongoDB(www.mongodb.org/)作为数据库,Ractive.js(www.ractivejs.org/)作为客户端框架。

本书适合谁

如果您了解 JavaScript 并想了解如何在后端使用它,那么本书适合您。它将引导您创建一个相当复杂的社交网络。您将学习如何使用数据库并创建实时通信渠道。

约定

在本书中,您会发现一些区分不同信息类型的文本样式。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄都显示如下: "如果 Ractive 组件有一个friends属性,那么我们将渲染一个用户列表。"

代码块设置如下:

<li class="right"><a on-click="goto:logout">Logout</a></li>
<li class="right"><a on-click="goto:profile">Profile</a></li>
<li class="right"><a on-click="goto:find-friends">Find  friends</a></li>

任何命令行输入或输出都以如下形式书写:

sudo apt-get update
sudo apt-get install nodejs
sudo apt-get install npm

新术语重要单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的形式出现在文本中:“它显示他们的名字和一个添加为好友按钮。”

提示

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

第一章:Node.js 基础知识

Node.js 是当今最流行的 JavaScript 驱动技术之一。它是由 Ryan Dahl 于 2009 年创建的,自那时起,该框架已经发展成为一个完善的生态系统。它的包管理器中充满了有用的模块,全世界的开发人员已经开始在他们的生产环境中使用 Node.js。在本章中,我们将学习以下内容:

  • Node.js 构建模块

  • 环境的主要功能

  • Node.js 的包管理

理解 Node.js 架构

在过去,Ryan 对开发网络应用程序很感兴趣。他发现大多数高性能服务器遵循类似的概念。它们的架构类似于事件循环,并且它们使用非阻塞的输入/输出操作。这些操作允许其他处理活动在进行中的任务完成之前继续进行。如果我们想处理成千上万个同时的请求,这些特征是非常重要的。

大多数用 Java 或 C 编写的服务器使用多线程。它们在新线程中处理每个请求。Ryan 决定尝试一些不同的东西——单线程架构。换句话说,服务器收到的所有请求都由单个线程处理。这可能听起来像一个不可扩展的解决方案,但 Node.js 绝对是可扩展的。我们只需运行不同的 Node.js 进程,并使用一个负载均衡器来在它们之间分发请求。

Ryan 需要一个基于事件循环的快速工作的东西。正如他在其中一次演讲中指出的,像谷歌、苹果和微软这样的大公司投入了大量时间开发高性能的 JavaScript 引擎。它们每年都变得越来越快。在那里,事件循环架构得到了实现。JavaScript 近年来变得非常流行。社区和成千上万的开发人员准备贡献,让 Ryan 考虑使用 JavaScript。这是 Node.js 架构的图表:

理解 Node.js 架构

总的来说,Node.js 由三部分组成:

在这三个模块之上,我们有几个绑定,它们公开了低级接口。Node.js 的其余部分都是用 JavaScript 编写的。几乎所有我们在文档中看到的内置模块的 API 都是用 JavaScript 编写的。

安装 Node.js

安装 Node.js 的一种快速简便的方法是访问nodejs.org/download/并下载适合您操作系统的安装程序。对于 OS X 和 Windows 用户,安装程序提供了一个漂亮、易于使用的界面。对于使用 Linux 作为操作系统的开发人员,Node.js 可以在 APT 软件包管理器中找到。以下命令将设置 Node.js 和Node Package ManagerNPM):

sudo apt-get update
sudo apt-get install nodejs
sudo apt-get install npm

运行 Node.js 服务器

Node.js 是一个命令行工具。安装后,node命令将在我们的终端上可用。node命令接受几个参数,但最重要的是包含我们的 JavaScript 的文件。让我们创建一个名为server.js的文件,并将以下代码放入其中:

var http = require('http');
http.createServer(function (req, res) {
   res.writeHead(200, {'Content-Type': 'text/plain'});
   res.end('Hello World\n');
}).listen(9000, '127.0.0.1');
console.log('Server running at http://127.0.0.1:9000/');

提示

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

如果你在控制台中运行node ./server.js,你将拥有 Node.js 服务器在运行。它在本地(127.0.0.1)的端口9000上监听传入的请求。前面代码的第一行需要内置的http模块。在 Node.js 中,我们有require全局函数,它提供了使用外部模块的机制。我们将看到如何定义我们自己的模块。之后,脚本继续使用http模块上的createServerlisten方法。在这种情况下,模块的 API 被设计成我们可以像在 jQuery 中那样链接这两种方法。

第一个(createServer)接受一个函数,也称为回调,每当有新的请求到达服务器时就会调用它。第二个使服务器监听。

在浏览器中得到的结果如下:

运行 Node.js 服务器

定义和使用模块

作为一种语言,JavaScript 没有定义真正的类的机制。事实上,JavaScript 中的一切都是对象。我们通常从一个对象继承属性和函数到另一个对象。幸运的是,Node.js 采用了CommonJS定义的概念——这是一个为 JavaScript 指定生态系统的项目。

我们将逻辑封装在模块中。每个模块都在自己的文件中定义。让我们用一个简单的例子来说明一切是如何工作的。假设我们有一个代表这本书的模块,并将其保存在一个名为book.js的文件中:

// book.js
exports.name = 'Node.js by example';
exports.read = function() {
   console.log('I am reading ' + exports.name);
}

我们定义了一个公共属性和一个公共函数。现在,我们将使用require来访问它们:

// script.js
var book = require('./book.js');
console.log('Name: ' + book.name);
book.read();

现在我们将创建另一个名为script.js的文件。为了测试我们的代码,我们将运行node ./script.js。终端中的结果如下:

定义和使用模块

除了exports,我们还有module.exports可用。两者之间有区别。看看下面的伪代码。它说明了 Node.js 如何构建我们的模块:

var module = { exports: {} };
var exports = module.exports;
// our code
return module.exports;

因此,最终返回module.exports,这就是require产生的。我们应该小心,因为如果在某个时刻我们直接应用一个值到exportsmodule.exports,我们可能得不到我们需要的东西。就像在下面的片段末尾,我们将一个函数设置为一个值,这个函数暴露给外部世界:

exports.name = 'Node.js by example';
exports.read = function() {
   console.log('Iam reading ' + exports.name);
}
module.exports = function() {  ... }

在这种情况下,我们无法访问.name.read。如果我们再次尝试执行node ./script.js,我们将得到以下输出:

定义和使用模块

为了避免这种问题,我们应该坚持两种选项之一——exportsmodule.exports——但要确保我们没有两者都有。

我们还应该记住,默认情况下,require会缓存返回的对象。因此,如果我们需要两个不同的实例,我们应该导出一个函数。这是一个提供 API 方法来评价书籍并且不正常工作的book类的版本:

// book.js
var ratePoints = 0;
exports.rate = function(points) {
   ratePoints = points;
}
exports.getPoints = function() {
   return ratePoints;
}

让我们创建两个实例,并用不同的points值对书籍进行评分:

// script.js
var bookA = require('./book.js');
var bookB = require('./book.js');
bookA.rate(10);
bookB.rate(20);
console.log(bookA.getPoints(), bookB.getPoints());

逻辑上的响应应该是10 20,但我们得到了20 20。这就是为什么导出一个每次产生不同对象的函数是一个常见的做法:

// book.js
module.exports = function() {
   var ratePoints = 0;
   return {
      rate: function(points) {
         ratePoints = points;
      },
      getPoints: function() {
         return ratePoints;
      }
   }
}

现在,我们还应该有require('./book.js')(),因为require返回的是一个函数,而不再是一个对象。

管理和分发包

一旦我们理解了requireexports的概念,我们应该开始考虑将我们的逻辑分组到构建块中。在 Node.js 世界中,这些块被称为模块(或)。Node.js 受欢迎的原因之一就是其包管理。

Node.js 通常带有两个可执行文件—nodenpm。NPM 是一个命令行工具,用于下载和上传 Node.js 包。官方网站npmjs.org/充当中央注册表。当我们通过npm命令创建一个包时,我们将其存储在那里,以便其他开发人员可以使用它。

创建模块

每个模块都应该存在于自己的目录中,该目录还包含一个名为package.json的元数据文件。在这个文件中,我们至少设置了两个属性—nameversion

{
   "name": "my-awesome-nodejs-module",
   "version": "0.0.1"
}

我们可以在同一个目录中放置任何我们喜欢的代码。一旦我们将模块发布到 NPM 注册表并有人安装它,他/她将得到相同的文件。例如,让我们添加一个index.js文件,这样我们的包中就有两个文件了:

// index.js
console.log('Hello, this is my awesome Node.js module!');

我们的模块只做一件事—在控制台上显示一个简单的消息。现在,要上传模块,我们需要导航到包含package.json文件的目录,并执行npm publish。这是我们应该看到的结果:

创建模块

我们准备好了。现在我们的小模块已经列在 Node.js 包管理器的网站上,每个人都可以下载它。

使用模块

总的来说,有三种使用已创建的模块的方法。所有三种方法都涉及包管理器:

  • 我们可以手动安装特定的模块。假设我们有一个名为project的文件夹。我们打开文件夹并运行以下命令:
npm install my-awesome-nodejs-module

管理器会自动下载模块的最新版本,并将其放在一个名为node_modules的文件夹中。如果我们想要使用它,就不需要引用确切的路径。默认情况下,Node.js 在需要时会检查node_modules文件夹。因此,只需require('my-awesome-nodejs-module')就足够了。

  • 全局安装模块是一种常见的做法,特别是当涉及到使用 Node.js 制作命令行工具时。它已经成为一种易于使用的技术来开发这样的工具。我们创建的小模块并不是作为一个命令行程序,但我们仍然可以通过运行以下代码全局安装它:
npm install my-awesome-nodejs-module -g

注意最后的-g标志。这是告诉管理器我们希望这个模块是全局的方式。当进程完成时,我们就没有了node_modules目录。my-awesome-nodejs-module文件夹存储在系统的另一个位置。为了能够使用它,我们必须在package.json中添加另一个属性,但我们将在下一节中更多地讨论这个问题。

  • 解决依赖关系是 Node.js 包管理器的关键特性之一。每个模块可以有任意多的依赖关系。这些依赖关系只是已上传到注册表的其他 Node.js 模块。我们所要做的就是在package.json文件中列出所需的包:
{
    "name": "another-module", 
    "version": "0.0.1", 
    "dependencies": {
        "my-awesome-nodejs-module": "0.0.1"   
    }
}

现在我们不需要明确指定模块,只需执行npm install来安装我们的依赖。管理器会读取package.json文件,并再次将我们的模块保存在node_modules目录中。使用这种技术是很好的,因为我们可以一次添加多个依赖并一次性安装它们。这也使得我们的模块可传输和自我记录。无需向其他程序员解释我们的模块由什么组成。

更新我们的模块

让我们将我们的模块转换成一个命令行工具。一旦我们这样做,用户就可以在他们的终端中使用my-awesome-nodejs-module命令。我们需要在package.json文件中做两个更改:

{
   "name": "my-awesome-nodejs-module",
   "version": "0.0.2",
   "bin": "index.js"
}

添加了一个新的bin属性。它指向我们应用程序的入口点。我们有一个非常简单的例子,只有一个文件—index.js

我们必须进行的另一个更改是更新version属性。在 Node.js 中,模块的版本起着重要作用。如果回顾一下,我们会发现在package.json文件中描述依赖关系时,我们指出了确切的版本。这确保了在将来,我们将获得具有相同 API 的相同模块。version属性中的每个数字都有意义。包管理器使用语义化版本 2.0.0semver.org/)。其格式为MAJOR.MINOR.PATCH。因此,作为开发人员,我们应该递增以下内容:

  • 如果我们进行不兼容的 API 更改,则为 MAJOR 号

  • 如果我们以向后兼容的方式添加新功能/特性,则为 MINOR 号

  • 如果我们有错误修复,则为 PATCH 号

有时,我们可能会看到版本号如2.12.*。这意味着开发人员有兴趣使用确切的 MAJOR 和 MINOR 版本,但他/她同意将来可能会有错误修复。也可以使用值如>=1.2.7来匹配任何等于或大于的版本,例如1.2.71.2.82.5.3

我们更新了package.json文件。下一步是将更改发送到注册表。这可以在包含 JSON 文件的目录中再次使用npm publish来完成。结果将是类似的。我们将在屏幕上看到新的0.0.2版本号:

更新我们的模块

在此之后,我们可以运行npm install my-awesome-nodejs-module -g,新版本的模块将安装在我们的机器上。不同之处在于现在我们有my-awesome-nodejs-module命令可用,如果运行它,它会显示在index.js文件中编写的消息:

更新我们的模块

介绍内置模块

Node.js 被认为是一种可以用来编写后端应用程序的技术。因此,我们需要执行各种任务。幸运的是,我们可以使用一堆有用的内置模块。

使用 HTTP 模块创建服务器

我们已经使用了 HTTP 模块。这可能是 Web 开发中最重要的模块,因为它启动一个在特定端口上监听的服务器:

var http = require('http');
http.createServer(function (req, res) {
   res.writeHead(200, {'Content-Type': 'text/plain'});
   res.end('Hello World\n');
}).listen(9000, '127.0.0.1');
console.log('Server running at http://127.0.0.1:9000/');

我们有一个createServer方法,返回一个新的 web 服务器对象。在大多数情况下,我们运行listen方法。如果需要,有close,它可以停止服务器接受新连接。我们传递的回调函数总是接受requestreq)和responseres)对象。我们可以使用第一个来检索有关传入请求的信息,例如GETPOST参数。

读取和写入文件

负责读写过程的模块称为fs(它源自文件系统)。以下是一个简单的例子,说明如何将数据写入文件:

var fs = require('fs');
fs.writeFile('data.txt', 'Hello world!', function (err) {
   if(err) { throw err; }
   console.log('It is saved!');
});

大多数 API 函数都有同步版本。前面的脚本可以用writeFileSync编写,如下所示:

fs.writeFileSync('data.txt', 'Hello world!');

然而,在此模块中使用函数的同步版本会阻塞事件循环。这意味着在操作文件系统时,我们的 JavaScript 代码会被暂停。因此,在 Node 中,尽可能使用方法的异步版本是最佳实践。

文件的读取几乎是相同的。我们应该以以下方式使用readFile方法:

fs.readFile('data.txt', function(err, data) {
   if (err) throw err;
   console.log(data.toString());
});

使用事件

观察者设计模式在 JavaScript 世界中被广泛使用。这是我们系统中的对象订阅其他对象发生的变化。Node.js 有一个内置模块来管理事件。这里是一个简单的例子:

var events = require('events');
var eventEmitter = new events.EventEmitter();
var somethingHappen = function() {
   console.log('Something happen!');
}
eventEmitter
.on('something-happen', somethingHappen)
.emit('something-happen');

eventEmitter对象是我们订阅的对象。我们使用on方法来实现这一点。emit函数触发事件,执行somethingHappen处理程序。

events模块提供了必要的功能,但我们需要在自己的类中使用它。让我们从上一节的书籍想法中获取并使其与事件一起工作。一旦有人对书进行评分,我们将以以下方式分派事件:

// book.js
var util = require("util");
var events = require("events");
var Class = function() { };
util.inherits(Class, events.EventEmitter);
Class.prototype.ratePoints = 0;
Class.prototype.rate = function(points) {
   ratePoints = points;
   this.emit('rated');
};
Class.prototype.getPoints = function() {
   return ratePoints;
}
module.exports = Class;

我们想要继承EventEmitter对象的行为。在 Node.js 中实现这一点的最简单方法是使用实用程序模块(util)及其inherits方法。定义的类可以像这样使用:

var BookClass = require('./book.js');
var book = new BookClass();
book.on('rated', function() {
   console.log('Rated with ' + book.getPoints());
});
book.rate(10);

我们再次使用on方法订阅rated事件。book类在我们设置了分数后显示了这条消息。然后终端显示了Rated with 10文本。

管理子进程

Node.js 有一些我们无法做到的事情。我们需要使用外部程序来完成相同的任务。好消息是,我们可以在 Node.js 脚本中执行 shell 命令。例如,假设我们想要列出当前目录中的文件。文件系统 API 确实提供了相应的方法,但如果我们能够获得ls命令的输出就更好了:

// exec.js
var exec = require('child_process').exec;
exec('ls -l', function(error, stdout, stderr) {
    console.log('stdout: ' + stdout);
    console.log('stderr: ' + stderr);
    if (error !== null) {
        console.log('exec error: ' + error);
    }
});

我们使用的模块叫做child_process。它的exec方法接受所需的命令作为字符串和一个回调。stdout项是命令的输出。如果我们想处理错误(如果有的话),我们可以使用error对象或stderr缓冲区数据。前面的代码产生了以下截图:

管理子进程

除了exec方法,我们还有spawn。它有点不同,但非常有趣。想象一下,我们有一个命令不仅完成了它的工作,还输出了结果。例如,git push可能需要几秒钟,可能会不断向控制台发送消息。在这种情况下,spawn是一个很好的选择,因为我们可以访问一个流:

var spawn = require('child_process').spawn;
var command = spawn('git', ['push', 'origin', 'master']);
command.stdout.on('data', function (data) {
   console.log('stdout: ' + data);
});
command.stderr.on('data', function (data) {
   console.log('stderr: ' + data);
});
command.on('close', function (code) {
   console.log('child process exited with code ' + code);
});

这里,stdoutstderr都是流。它们会分发事件,如果我们订阅了这些事件,我们将得到命令的确切输出。在前面的例子中,我们运行了git push origin master并将完整的命令响应发送到控制台。

摘要

现在很多公司都在使用 Node.js。这证明它已经足够成熟,可以在生产环境中使用。在本章中,我们了解了这项技术的基本原理。我们涵盖了一些常用的情况。在下一章中,我们将从我们示例应用程序的基本架构开始。这不是一个简单的应用程序。我们将构建我们自己的社交网络。

第二章:设计项目

软件开发是一个复杂的过程。我们不能只是开始编写一些代码,然后期望能够达到我们的目标。我们需要计划和定义我们应用程序的基础。换句话说,在你开始实际编写脚本之前,你必须设计项目结构。在本章中,我们将涵盖以下内容:

  • Node.js 应用程序的基本层

  • 使用任务运行器和构建系统

  • 测试驱动开发

  • 模型-视图-控制器模式

  • REST API 概念

介绍应用程序的基本层

如果我们计划建造一座房子,我们可能会想要从一个非常好的基础开始。如果建筑的基础不牢固,我们就不能建造第一层和第二层。

然而,对于软件来说,情况有些不同。我们可以在没有良好基础的情况下开始开发代码。我们称之为蛮力驱动开发。在这种情况下,我们会一次又一次地生产功能,而实际上并不关心我们代码的质量。结果可能在开始时有效,但从长远来看,它会消耗更多的时间,可能还有金钱。众所周知,软件只是放置在彼此之上的构建块。如果我们程序的下层设计不好,那么整个解决方案都会因此而受到影响。

让我们考虑一下我们的项目——我们想用 Node.js 构建的社交网络。我们从一个简单的代码开始,就像这样:

var http = require('http');
http.createServer(function (req, res) {
   res.writeHead(200, {'Content-Type': 'text/plain'});
   res.end('Hello World\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

你可能注意到的第一件事是,你向用户提供了文本,但你可能想要提供文件内容。Node.js 类似于 PHP。然而,有一个根本的区别。PHP 需要一个接受请求并将其传递给 PHP 解释器的服务器。然后,PHP 代码被处理,响应再次由服务器传递给用户。在 Node.js 世界中,我们没有一个单独的外部服务器。Node.js 本身扮演着这个角色。开发人员需要处理传入的请求,并决定如何处理它们。

如果我们拿上面的代码并假设我们有一个包含基本 HTML 布局的page.html和一个包含 CSS 样式的styles.css文件,我们的下一步将是这样的(查看书中代码示例的planning文件夹):

var http = require('http');
var fs = require('fs');
http.createServer(function (req, res) {
   var content = '';
   var type = '';
   if(req.url === '/') {
      content = fs.readFileSync('./page.html');
      type = 'text/html';
   } else if(req.url === '/styles.css') {
      content = fs.readFileSync('./styles.css');
      type = 'text/css';
   }
   res.writeHead(200, {'Content-Type': type});
   res.end(content + '\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

我们将检查传入请求的 URL。如果我们只是打开http://127.0.0.1:1337/,我们将收到page.html的代码作为响应。如果page.html文件中有一个请求style.css<link>标签,浏览器也会为此发出请求。URL 不同,但它再次被if子句捕获,然后提供适当的内容。

现在这样做还可以,但我们可能需要提供不是两个而是许多文件。我们不想描述所有这些文件。因此,这个过程应该被优化。每个 Node.js 服务器的第一层通常处理路由。它解析请求的 URL 并决定要做什么。如果我们需要传递静态文件,那么我们最终会将处理逻辑放在一个外部模块中,该模块找到文件,读取它们,并以适当的内容类型发送响应。这可以成为我们架构的第二层。

除了交付文件,我们还需要编写一些后端逻辑。这将是第三层。同样,根据 URL,我们将执行与业务逻辑相关的一些操作,如下所示:

var http = require('http');
var fs = require('fs');
http.createServer(function (req, res) {
   var content = '';
   var type = '';
   if(req.url === '/') {
      content = fs.readFileSync('./page.html');
      type = 'text/html';
   } else if(req.url === '/styles.css') {
      content = fs.readFileSync('./styles.css');
      type = 'text/css';
   } else if(req.url === '/api/user/new') {
         // Do actions like
      // reading POST parameters
      // storing the user into the database
      content = '{"success": true}';
      type = 'application/json';
   }
   res.writeHead(200, {'Content-Type': type});
   res.end(content + '\n');
}).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

请注意我们返回了 JSON 数据。因此,我们的 Node.js 服务器现在充当 API。我们将在本章末讨论这一点。

下面的图表显示了我们刚刚谈到的三个层次:

介绍应用程序的基本层

这些将是我们应用程序的主要层。在接下来的章节中,我们将对它们进行处理。但在那之前,让我们看看在达到那一点之前我们还需要做什么其他工作。

任务运行器和构建系统

除了运行 Node.js 服务器的实践之外,还有其他与 Web 开发任务相关的最佳实践可以考虑。我们正在构建一个 Web 应用程序。因此,我们有客户端 JavaScript 和 CSS 需要以最佳方式交付。换句话说,为了提高网站的性能,我们需要将所有 JavaScript 合并到一个文件中并进行压缩。对 CSS 样式表也是如此。如果这样做,浏览器将减少对服务器的请求。

Node.js 是一个常见的命令行实用工具,除非你想要运行 Web 服务器。有许多可用于打包和优化资产的模块。很棒的是有任务运行器和构建系统可以帮助你管理这些过程。

介绍 Grunt

Grunt 是基于 Node.js 的最流行的任务运行器之一。它可以在包管理器注册表中找到,并且可以通过以下命令安装:

npm install -g grunt-cli

一旦我们在终端中运行了这个命令,我们就会得到一个全局的grunt命令供我们使用。我们需要在项目的根目录中创建一个Gruntfile.js文件,这是我们定义任务的地方。通过任务,我们指的是诸如文件合并和文件压缩等我们想要对特定文件执行的操作。以下是一个简单的Gruntfile.js

module.exports = function(grunt) {
   grunt.initConfig({
      concat: {
         javascript: {
            src: 'src/**/*.js',
            dest: 'build/scripts.js'
         }
      }
   });
   grunt.loadNpmTasks('grunt-contrib-concat');
   grunt.registerTask('default', ['concat']);
}

在本书的第一章中,我们看到了如何定义 Node.js 模块。Grunt 所需的配置只是一个简单的模块。我们导出一个函数,该函数接受一个包含运行器所有公共 API 函数的grunt对象。在initConfig块中,我们放置我们的操作,而使用registerTask,我们组合操作和任务。至少应该有一个任务使用名称default进行定义。这是如果我们在终端中不传递额外参数时 Grunt 运行的内容。

在前面的例子中还有一个最后使用的函数——loadNpmTasks。Grunt 的真正强大之处在于我们有数百个可用的插件。grunt命令是一个接口,你可以用它来控制这些插件完成真正的工作。由于它们都在 Node.js 包管理器中注册,我们需要在package.json文件中包含它们。对于前面的代码,我们需要以下内容:

{
   "name": "GruntjsTest",
   "version": "0.0.1",
   "description": "GruntjsTest",
   "dependencies": {},
   "devDependencies": {
      "grunt-contrib-concat": "0.3.0"
   }
}

让我们继续向我们的 Grunt 设置添加另外两个功能。一旦我们将 JavaScript 合并,我们可能会希望有编译文件的缩小版本;grunt-contrib-uglify就是完成这项工作的模块:

module.exports = function(grunt) {
   grunt.initConfig({
      concat: {
         javascript: {
            src: 'src/**/*.js',
            dest: 'build/scripts.js'
         }
      },
      uglify: {
         javascript: {
            files: {
               'build/scripts.min.js': '<%= concat.javascript.dest %>'
            }
         }
      }
   });
   grunt.loadNpmTasks('grunt-contrib-concat');
   grunt.loadNpmTasks('grunt-contrib-uglify');
   grunt.registerTask('default', ['concat', 'uglify']);
}

我们应该提到uglify任务应该在concat之后运行,因为它们彼此依赖。还有一个快捷方式——<%= concat.javascript.dest %>。我们使用这样的表达式来简化Gruntfile.js文件的维护。

我们有 Grunt 任务来处理我们的 JavaScript。但是,如果我们每次进行更改都必须返回控制台并运行grunt,那将会很烦人。这就是为什么存在grunt-contrib-watch的原因。这是一个模块,它会监视文件更改并运行我们的任务。以下是更新后的Gruntfile.js

module.exports = function(grunt) {
   grunt.initConfig({
      concat: {
         javascript: {
            src: 'src/**/*.js',
            dest: 'build/scripts.js'
         }
      },
      uglify: {
         javascript: {
            files: {
               'build/scripts.min.js': '<%= concat.javascript.dest %>'
            }
         }
      },
      watch: {
         javascript: {
            files: ['<%= concat.javascript.src %>'],
            tasks: ['concat', 'uglify']
         }
      }
   });
   grunt.loadNpmTasks('grunt-contrib-concat');
   grunt.loadNpmTasks('grunt-contrib-uglify');
   grunt.loadNpmTasks('grunt-contrib-watch');
   grunt.registerTask('default', ['concat', 'uglify', 'watch']);
}

为了让脚本工作,我们还需要运行npm install grunt-contrib-watch grunt-contrib-uglify –save。这个命令将安装模块并更新package.json文件。

下面的截图显示了当我们调用grunt命令时终端中的结果:

介绍 Grunt

现在我们可以看到我们的任务是如何运行的,监视任务也开始了。一旦我们保存了一个被监视的文件的更改,合并和压缩操作都会再次触发。

发现 Gulp

Gulp 是一个自动化常见任务的构建系统。与 Grunt 一样,我们可以组合我们的资产管道。但是,两者之间有一些区别:

  • 我们仍然有一个配置文件,但它被称为gulpfile.js

  • Gulp 是基于流的工具。它在工作时不会在磁盘上存储任何东西。Grunt 需要创建临时文件以便将数据从一个任务传递到另一个任务,但是 Gulp 将数据保存在内存中。

  • Gulp 遵循代码优于配置的原则。在gulpfile.js文件中,我们像编写常规的 Node.js 脚本一样编写我们的任务。我们将在一分钟内看到这个演示。

要使用 Gulp,我们必须先安装它。以下命令将全局设置该工具:

npm install -g gulp

我们将使用一些插件——gulp-concatgulp-uglifygulp-rename。将它们添加到我们的package.json文件中后,运行npm install以安装它们。

下一步是在项目的根目录中创建一个新的gulpfile.js文件,并运行gulp命令。让我们保留上一节中的相同任务,并将它们转换为 Gulp:

var gulp = require('gulp');
var concat = require('gulp-concat');
var uglify = require('gulp-uglify');
var rename = require('gulp-rename');

gulp.task('js', function() {
   gulp.src('./src/**/*.js')
   .pipe(concat('scripts.js'))
   .pipe(gulp.dest('./build/'))
   .pipe(rename({suffix: '.min'}))
   .pipe(uglify())
   .pipe(gulp.dest('./build/'))
});
gulp.task('watchers', function() {
   gulp.watch('src/**/*.js', ['js']);
});
gulp.task('default', ['js', 'watchers']);

文件顶部有几个require调用。我们初始化了 Gulp 的公共 API(gulp对象)和我们想要执行的操作所需的插件。我们需要将所有这些模块添加到我们的package.json文件中。在那之后,我们使用(task_name, callback_function)语法定义了三个任务:

  • js:这是获取我们的 JavaScript 文件的任务,将它们传输到连接文件的插件,并保存结果。然后我们将数据发送到uglify模块,对我们的代码进行最小化处理,最后保存一个带有.min后缀的新文件。

  • watchers:通过这个任务,我们可以监视我们的 JavaScript 文件的更改并运行js任务。

  • default:默认情况下,Gulp 运行我们文件的这部分。我们可以通过在终端中的gulp调用中添加一个参数来指定任务。

上述脚本的结果应该如下截图所示。再次,我们可以看到自动化是如何发生的。监视部分也存在。

发现 Gulp

测试驱动开发

测试驱动开发是一种软件开发过程,其中自动化测试驱动新产品或功能的开发周期。从长远来看,它加快了开发速度,并倾向于产生更好的代码。如今,许多框架都有帮助您创建自动化测试的工具。因此,作为开发人员,我们需要在编写任何新代码之前首先编写和运行测试。我们始终检查我们工作的结果是什么。在 Web 开发中,我们通常打开浏览器并与我们的应用程序进行交互,以查看我们的代码行为如何。因此,我们的大部分时间都花在测试上。好消息是我们可以优化这个过程。我们可以编写代码来代替我们的工作。有时,依赖手动测试并不是最佳选择,因为它需要时间。以下是进行测试的几个好处:

  • 测试提高了我们应用程序的稳定性

  • 自动化测试节省了时间,可以用来改进或重构系统的代码

  • 测试驱动开发倾向于随着时间的推移产生更好的代码,因为它让我们考虑更好的结构和模块化方法

  • 持续测试帮助我们在现有应用程序上开发新功能,因为如果我们引入破坏旧功能的代码,自动化测试将失败

  • 测试可以用作文档,特别是对于刚加入团队的开发人员

在过程开始时,我们希望我们的测试失败。之后,我们逐步实现所需的逻辑,直到测试通过。以下图表显示了这个过程:

测试驱动开发

开发人员经常使用帮助他们编写测试的工具。我们将使用一个名为Mocha的测试框架。它适用于 Node.js 和浏览器,并且在自动化测试方面是最受欢迎的解决方案之一。让我们安装 Mocha 并看看 TDD 是如何工作的。我们将运行以下命令:

npm install mocha -g

正如我们在书中已经做了几次,我们将全局安装包。为了这个例子,我们假设我们的应用程序需要一个模块来读取外部的 JSON 文件。让我们创建一个空文件夹,并将以下内容放入test.js文件中:

var assert = require('assert');
describe('Testing JSON reader', function() {
   it('should get json', function(done) {
      var reader = require('./JSONReader');
      assert.equal(typeof reader, 'object');
      assert.equal(typeof reader.read, 'function');
      done();
   });
});

describeit函数是 Mocha 特定的函数。它们是全局的,我们可以随时使用。assert模块是一个原生的 Node.js 模块,我们可以用它来进行检查。一些流行的测试框架有自己的断言方法。Mocha 没有,但它可以很好地与ChaiExpect.js等库一起使用。

我们使用describe来形成一系列测试,使用it来定义逻辑块。我们假设当前目录中有一个JSONReader.js文件,当需要其中的模块时,我们有一个公共的read方法可用。现在,让我们用mocha .\test.js来运行我们的测试。结果如下:

测试驱动开发

当然,我们的测试失败是因为没有这样的文件。如果我们创建文件并将以下代码放入其中,我们的测试将通过:

// JSONReader.js
module.exports = {
   read: function() {
      // get JSON
      return {};
   }
}

JSONReader模块通过read公共方法导出一个对象。我们将再次运行mocha .\test.js。然而,这一次,测试中列出的所有要求都得到了满足。现在,终端应该是这样的:

测试驱动开发

假设我们的JSONReader模块变得越来越大。新的方法出现了,不同的开发人员在同一个文件上工作。我们的测试仍然会检查模块是否存在,以及是否有read函数。这很重要,因为在项目开始的某个地方,程序员已经使用了JSONReader模块,并期望它有可用的read函数。

在我们的测试中,我们只添加了一些断言。然而,在现实世界中,会有更多的describeit块。测试覆盖的案例越多,越好。很多时候,公司在发布新产品版本之前会依赖他们的测试套件。如果有一个测试失败了,他们就不发布任何东西。在书的接下来的几章中,我们经常会写测试。

模型-视图-控制器模式

开始一个新项目或实现一个新功能总是困难的。我们不知道如何组织我们的代码,要写哪些模块,它们将如何通信。在这种情况下,我们经常信任众所周知的实践——设计模式。设计模式是常见问题的可重用解决方案。例如,模型-视图-控制器模式已被证明是 Web 开发中最有效的模式之一,因为它清晰地分离了数据、逻辑和表示层。我们将以这种模式的变体为基础构建我们的社交网络。传统的部分及其职责如下:

模型-视图-控制器模式

  • 模型模型是存储数据或状态的部分。一旦有变化,它就会触发视图的更新。

  • 视图视图通常是用户可以看到的部分。它是数据或模型状态的直接表示。

  • 控制器:用户通过控制器(有时通过视图)进行交互。它可以向模型发送命令以更新其状态。在某些情况下,它还可以通知视图,以便用户可以看到模型的另一个表示。

然而,在 Web 开发中(特别是在浏览器中运行的代码),ViewController共享相同的功能。很多时候,两者之间没有严格的区分。在本书中,控制器也将处理 UI 元素。让我们从 Node.js 环境开始。为了简化示例,我们将把我们的代码放在一个名为server.js的文件中。我们的应用程序只会做一件事——更新存储在内存中的变量的值。

在我们的上下文中,View将生成 HTML 标记。稍后,该标记将被发送到浏览器,如下所示:

var view = {
   render: function() {
      var html = '';
      html += '<!DOCTYPE html>';
      html += '<html>';
      html += '<head><title>Node.js byexample</title></head>';
      html += '<body>';
      html += '<h1>Status ' + (model.status ? 'on' : 'off') + '</h1>';
      html += '<a href="/on">switch on</a><br />';
      html += '<a href="/off">switch off</a>';
      html += '</body>';
      html += '</html>';
      res.writeHead(200, {'Content-Type': 'text/html'});
      res.end(html + '\n');
   }
};

在这段代码中,有一个 JavaScript 对象文字,只有一个render方法。为了构建h1标记的正确内容,我们将使用模型及其status变量。还有两个链接。第一个将model.status更改为true,第二个将其更改为false

Model对象相当小。与View一样,它只有一个方法:

var model = {
   status: false,
   update: function(s) {
      this.status = s;
      view.render();
   }
};

请注意,Model触发了视图的渲染。在这里重要的一点是,模型不应该知道其数据在视图层的表示。它所要做的就是向视图发送信号,通知它已更新。

我们模式的最后一部分是Controller。我们可以将其视为脚本的入口点。如果我们正在构建一个 Node.js 服务器,这是接受requestresponse对象的函数:

var http = require('http'), res;
var controller = function(request, response) {
   res = response;
   if(request.url === '/on') {
      model.update(true);
   } else if(request.url === '/off') {
      model.update(false);
   } else {
      view.render();
   }   
}
http.createServer(controller).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

我们在全局变量中缓存了response参数,以便我们可以从其他函数中访问它。

这类似于本章开头发生的情况,我们在那里使用request.url属性来控制应用程序的流程。当用户访问/on/off URL 时,前面的代码会改变模型的状态。如果没有,它只是触发视图的render函数。

模型-视图-控制器模式很适合 Node.js。正如我们所看到的,它可以很容易地实现。由于它非常受欢迎,有使用这个概念的模块甚至框架。在接下来的几章中,我们将看到这种模式在大型应用程序中的运作方式。

介绍 REST API 概念

REST代表表述性状态转移。根据定义,它是 Web 的一种架构原则。在实践中,它是一组简化客户端-服务器通信的规则。许多公司提供 REST API,因为它们简单且高度可扩展。

为了更好地理解 REST 的确切含义,让我们举一个简单的例子。我们有一个在线商店,我们想要管理系统中的用户。我们在各种控制器中实现了后端逻辑。我们希望通过 HTTP 请求触发那里的功能。换句话说,我们需要这些控制器的应用程序接口。我们首先规划要访问服务器的 URL。如果我们遵循 REST 架构,那么我们可能会有以下路由:

  • GET请求到/users返回系统中所有用户的列表

  • POST请求到/users创建新用户

  • PUT请求到/users/24编辑具有唯一标识号24的用户的数据

  • DELETE请求到/users/24删除具有唯一标识号24的用户的个人资料

有一个定义的资源——user。URL 是使 REST 简单的关键。GET请求用于检索数据,POST用于存储,PUT用于编辑,DELETE用于删除记录。

我们小型社交网络的一些部分将基于 REST 架构。我们将有处理四种类型请求并执行必要操作的控制器。然而,在我们达到本书的那一部分之前,让我们编写一个简单的 Node.js 服务器,接受GETPOSTPUTDELETE请求。以下代码放入一个名为server.js的文件中:

var http = require('http');
var url = require('url');
var controller = function(req, res) {
   var message = '';
   switch(req.method) {
      case 'GET': message = "Thats GET message"; break;
      case 'POST': message = "That's POST message"; break;
      case 'PUT': message = "That's PUT message"; break;
      case 'DELETE': message = "That's DELETE message"; break;
   }
   res.writeHead(200, {'Content-Type': 'text/html'});
   res.end(message + '\n');   
}
http.createServer(controller).listen(1337, '127.0.0.1');
console.log('Server running at http://127.0.0.1:1337/');

req对象有一个method属性。它告诉我们请求的类型。我们可以使用node .\server.js运行前面的服务器,并发送不同类型的请求。为了测试它,我们将使用流行的curl命令:

介绍 REST API 概念

让我们尝试一个更复杂的PUT请求。以下示例使用 cURL。这是一个帮助您运行请求的命令行工具。在我们的情况下,我们将向服务器执行一个PUT请求:

介绍 REST API 概念

我们使用-X选项更改了请求方法。除此之外,我们传递了一个名为book的变量,其值为Node.js by example。然而,我们的服务器没有处理参数的代码。我们将在server.js中添加以下函数:

var qs = require('querystring');
var processRequest = function(req, callback) {
   var body = '';
   req.on('data', function (data) {
      body += data;
   });
   req.on('end', function () {
      callback(qs.parse(body));
   });
}

该代码接受req对象和回调函数,因为收集数据是一个异步操作。body变量填充了传入的数据,一旦收集到所有块,我们通过传递请求的解析主体来触发回调。以下是更新后的控制器:

var controller = function(req, res) {
   var message = '';
   switch(req.method) {
      case 'GET': message = "That's GET message"; break;
      case 'POST': message = "That's POST message"; break;
      case 'PUT': 
         processRequest(req, function(data) {
            message = "That's PUT message. You are editing " + data.book + " book."; 
            res.writeHead(200, {'Content-Type': 'text/html'});
            res.end(message + "\n");   
         });
         return;
      break;
      case 'DELETE': message = "That's DELETE message"; break;
   }
   res.writeHead(200, {'Content-Type': 'text/html'});
   res.end(message + '\n');   
}

请注意,我们在PUT catch 语句中调用了return。我们这样做是为了应用程序流在那里停止并等待请求被处理。这是终端中的结果:

介绍 REST API 概念

摘要

软件开发是一项复杂的任务。像每个复杂的过程一样,它需要规划。它需要一个良好的基础和一个精心设计的架构。在本章中,我们看到了规划一个大型 Node.js 应用程序的几个不同方面。在下一章中,我们将学习如何管理我们的资产。

第三章:管理资产

第一章和第二章是 Node.js 应用程序开发的基本构建块和结构的良好介绍。我们了解了技术的基本知识,并揭示了重要的模式,如模型-视图-控制器。我们谈论了测试驱动开发和 REST API。在本章中,我们将创建我们社交网络的基础。应用程序资产的适当交付和管理是系统的重要组成部分。在大多数情况下,它决定了我们的工作流程。在本章中,我们将讨论以下主题:

  • 使用 Node.js 提供文件

  • CSS 预处理

  • 打包客户端 JavaScript

  • 交付 HTML 模板

使用 Node.js 提供文件

Node.js 与通常的 Linux-Apache-MySQL-PHP 设置不同。我们必须编写处理传入请求的服务器。当用户需要从我们的后端获取图像时,Node.js 不会自动提供。我们社交网络的第一个文件将是server.js,内容如下:

var http = require('http');
var fs = require('fs');
   var path = require('path');

var files = {};
var port = 9000;
var host = '127.0.0.1';

var assets = function(req, res) {
  // ...
};

var app = http.createServer(assets).listen(port, host);
console.log("Listening on " + host + ":" + port);

我们需要三个本地模块,用于驱动服务器和交付资产。前面代码的最后两行运行服务器并在控制台打印消息。

目前,我们应用程序的入口点是assets函数。此方法的主要目的是从硬盘读取文件并提供给用户。我们将使用req.url来获取当前请求路径。当 Web 浏览器访问我们的服务器并在浏览器中请求http://localhost:9000/static/css/styles.css时,req.url将等于/static/css/styles.css。从这一点开始,我们有一些任务要处理:

  • 检查文件是否存在,如果不存在,则向用户发送适当的消息(HTTP 错误代码)

  • 读取文件并找出其扩展名

  • 以正确的内容类型将文件内容发送到浏览器

最后一点很重要。以错误或缺少的内容类型提供文件可能会导致问题。浏览器可能无法正确识别和处理资源。

为了使流程顺利,我们将为提到的每个任务创建一个单独的函数。最短的函数是向用户发送错误消息的函数:

var sendError = function(message, code) {
  if(code === undefined) {
     code = 404;
  }
  res.writeHead(code, {'Content-Type': 'text/html'});
  res.end(message);
}

默认情况下,code变量的值为404,表示“未找到”。然而,有不同类型的错误,如客户端错误(4XX)和服务器错误(5XX)。最好留下更改错误代码的选项。

假设我们有文件的内容和扩展名。我们需要一个函数来识别正确的内容类型并将资源提供给客户端。为了简单起见,我们将执行文件扩展名的简单字符串检查。以下代码正是如此:

var serve = function(file) {
  var contentType;
  switch(file.ext.toLowerCase()) {
    case "css": contentType = "text/css"; break;
    case "html": contentType = "text/html"; break;
    case "js": contentType = "application/javascript"; break;
    case "ico": contentType = "image/ico"; break;
    case "json": contentType = "application/json"; break;
    case "jpg": contentType = "image/jpeg"; break;
    case "jpeg": contentType = "image/jpeg"; break;
    case "png": contentType = "image/png"; break;
    default: contentType = "text/plain";
  }
  res.writeHead(200, {'Content-Type': contentType});
  res.end(file.content);
}

serve方法接受一个带有两个属性的file对象——extcontent。在接下来的几章中,我们可能会向列表中添加更多文件类型。但是,目前,提供 JavaScript、CSS、HTML、JPG 和 PNG 图像就足够了。

我们必须覆盖的最后一个任务是实际读取文件。Node.js 有一个内置模块来读取文件,称为fs。我们将使用其异步方法。使用同步函数,JavaScript 引擎可能会被阻塞,直到特定操作完全执行。在这种情况下,即读取文件。在异步编程中,我们允许程序执行其余的代码。在这种情况下,我们通常传递一个回调函数——当操作结束时将执行的函数:

var readFile = function(filePath) {
  if(files[filePath]) {
        serve(files[filePath]);
    } else {
      fs.readFile(filePath, function(err, data) {
        if(err) {
          sendError('Error reading ' + filePath + '.');
          return;
        }
        files[filePath] = {
          ext: filePath.split(".").pop(),
          content: data
        };
        serve(files[filePath]);
      });
    }
}

该函数接受路径并打开文件。如果文件丢失或读取时出现问题,它会向用户发送错误。一开始,我们定义了一个files变量,它是一个空对象。每次我们读取一个文件,我们都将其内容存储在那里,这样下次读取时,我们就不必再次访问磁盘。每个 I/O 操作,比如读取文件,都需要时间。通过使用这种简单的缓存逻辑,我们提高了应用程序的性能。如果一切正常,我们调用serve方法。

以下是如何组合所有前面的片段:

var http = require('http');
var fs = require('fs');
var path = require('path');
var files = {};
var port = 9000;

var assets = function(req, res) {
  var sendError = function(message, code) { ... }
  var serve = function(file) { ... }
  var readFile = function(filePath) { ... }

  readFile(path.normalize(__dirname + req.url));
}

var app = http.createServer(assets).listen(port, '127.0.0.1');
console.log("Listening on 127.0.0.1:" + port);

发送到服务器的每个 HTTP 请求都由assets处理程序处理。我们从当前目录开始组成文件的路径。path.normalize参数确保我们的字符串在不同的操作系统上看起来都很好。例如,它不包含多个斜杠。

CSS 预处理

CSS 预处理器是接受源代码并生成 CSS 的工具。很多时候,输入与 CSS 语言的语法类似。然而,预处理的主要思想是添加社区所需但缺失的功能。在过去几年里,CSS 预处理已成为热门话题。它带来了许多好处,并且这个概念已经被社区热烈接受。有两种主要的 CSS 预处理器——Less (lesscss.org/) 和 Sass (sass-lang.com/)。Sass 基于 Ruby 语言,需要更多的工作才能在 Node.js 项目中运行。因此,在本书中,我们将使用 Less。

在上一章中,我们谈到了构建系统和任务运行器。CSS 预处理和我们稍后将讨论的其他一些任务应该自动发生。Gulp 似乎是一个不错的选择。让我们继续添加一个package.json文件,我们将在其中描述所有我们需要的与 Gulp 相关的模块:

{
  "name": "nodejs-by-example",
  "version": "0.0.1",
  "description": "Node.js by example",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "gulp": "3.8.8",
    "gulp-less": "1.3.6",
    "gulp-rename": "~1.2.0",
    "gulp-minify-css": "~0.3.11"
  }
}

设置"start": "node server.js"将允许我们输入npm start并运行我们的服务器。我们将开始的依赖关系如下:

  • Gulp 本身

  • gulp-less:这是一个包装了 Less 预处理器的插件

  • gulp-rename:这会更改生成文件的名称

  • gulp-minify-css:这会压缩我们的 CSS

因此,除了server.js,我们现在还有package.json。我们运行npm install,包管理器会添加一个包含模块的node_modules目录。让我们在另一个名为gulpfile.js的文件中定义我们的 Gulp 任务:

var path = require('path');
var gulp = require('gulp');
var less = require('gulp-less');
var rename = require("gulp-rename");
var minifyCSS = require('gulp-minify-css');

gulp.task('css', function() {
  gulp.src('./less/styles.less')
  .pipe(less({
    paths: [ path.join(__dirname, 'less', 'includes') ]
  }))
  .pipe(gulp.dest('./static/css'))
  .pipe(minifyCSS({keepBreaks:true}))
  .pipe(rename({suffix: '.min'}))
  .pipe(gulp.dest('./static/css'));
});

gulp.task('watchers', function() {
  gulp.watch('less/**/*.less', ['css']);
});

gulp.task('default', ['css', 'watchers']);

我们从两个任务开始——csswatchers。第一个任务期望我们有一个less目录和一个styles.less文件。这将是我们所有 CSS 样式的入口点。从 Gulp 任务中可以看到,我们将文件的内容传输到预处理器,并将结果导出到static/css目录。由于 Gulp 中的一切都是流,我们可以继续压缩 CSS,将文件重命名为styles.min.css,并将其导出到相同的文件夹。

我们不希望每次更改文件时都要自己运行构建过程。因此,我们为less文件夹中的文件注册watcherswatcher是一个监视特定文件的过程,一旦这些文件被更改,就会通知系统的其余部分。

在这一步结束时,我们的项目看起来是这样的:

CSS 预处理

打包客户端 JavaScript

与 CSS 一样,我们的目标应该是只向客户端浏览器提供一个 JavaScript 文件。我们不希望强迫用户发出多个请求,因为这样效率较低,意味着网页浏览器需要更长的时间来处理和显示页面的内容。如今,应用程序的客户端部分相当复杂。与复杂系统一样,我们将逻辑分成不同的模块。通常,不同的模块意味着不同的文件。幸运的是,Node.js 充满了可以用来打包 JavaScript 的工具。让我们看看两种最流行的工具。

使用 Gulp 进行合并

作为构建系统,Gulp 有几个模块来连接文件。我们感兴趣的是一个叫做gulp-concat的模块。让我们把它添加到package.json文件中:

"dependencies": {
  "gulp": "3.8.8",
  "gulp-less": "1.3.6",
  "gulp-rename": "1.2.0",
  "gulp-minify-css": "0.3.11",
  "gulp-concat": "2.4.1"
}

下一步是编写一个使用它的任务。同样,我们将使用srcdest Gulp 方法,在它们之间是连接:

var concat = require('gulp-concat');

gulp.task('js', function() {
  gulp.src('./js/*.js')
  .pipe(concat('scripts.js'))
  .pipe(gulp.dest('./static/js'))
});

需要提到的是,文件将按字母顺序添加到最终文件中。因此,每当有一些代码依赖时,我们都应该小心。如果是这种情况,我们应该以这样的方式命名文件,使它们的名称以唯一数字开头——01、02、03 等等。

我们接下来要做的逻辑任务是压缩我们的 JavaScript。和 Less 编译一样,我们希望提供尽可能小的文件。帮助我们实现这一目标的模块是gulp-uglify。同样,我们应该把它添加到package.json文件中("gulp-uglify": "1.0.1")。之后,对我们新创建的任务进行一点调整就可以压缩 JavaScript 了:

var concat = require('gulp-concat');
var uglify = require('gulp-uglify');

gulp.task('js', function() {
  gulp.src('./js/*.js')
  .pipe(concat('scripts.js'))
  .pipe(gulp.dest('./static/js'))
  .pipe(uglify())
  .pipe(rename({suffix: '.min'}))
  .pipe(gulp.dest('./static/js'))
});

请注意,我们再次使用了gulp-rename插件。这是必要的,因为我们想生成一个不同的文件。

使用 RequireJS 在浏览器中进行模块化

在构建软件时,思考的最重要的概念之一是将我们的系统分割成模块。Node.js 有一个很好的内置系统来编写模块。我们在第一章中提到过,Node.js 基础。我们将我们的代码封装在一个单独的文件中,并使用module.exportsexports来创建公共 API。稍后,通过require函数,我们访问创建的功能。

然而,对于客户端 JavaScript,我们没有这样的内置系统。我们需要使用一个额外的库来允许我们定义模块。有几种可能的解决方案。我们将首先看一下的是 RequireJS(requirejs.org/)。我们将从官方网站下载这个库(版本 2.1.16),并像这样包含在我们的页面中:

<script data-main="scripts/main" src="img/require.js">
</script>

这里的关键属性是data-main。它告诉 RequireJS 我们应用的入口点。事实上,我们应该在项目文件夹中有scripts/main.js文件才能让前面的行起作用。在main.js中,我们可以使用require全局函数:

// scripts/main.js
require(["modules/ajax", "modules/router"], function(ajax, router) {
    // ... our logic
});

假设我们的main.js代码依赖于另外两个模块——Ajax 包装器和路由器。我们在一个数组中描述这些依赖关系,并提供一个回调,稍后用两个参数执行。这些参数实际上是对必要模块的引用。

使用另一个全局函数define可以定义模块。这是 Ajax 包装器的样子:

// modules/ajax.js
define(function () {
    // the Ajax request implementation
    ...
    // public API
    return {
        request: function() { ... }
    }
});

默认情况下,RequireJS 在后台异步解析依赖项。换句话说,它为每个所需模块执行 HTTP 请求。在某些情况下,这可能会导致性能问题,因为每个请求都需要时间。幸运的是,RequireJS 有一个解决这个问题的工具(优化器)。它可以将所有模块捆绑成一个单独的文件。这个工具也适用于 Node.js,并且随requirejs包一起分发:

npm install -g requirejs

安装成功后,我们将在终端中有r.js命令。基本调用如下:

// in code_requirejs folder
r.js -o build.js

和 Grunt 和 Gulp 一样,我们有一个文件指导 RequireJS 如何工作。以下是涵盖我们示例的片段:

// build.js
({
    baseUrl: ".",
    paths: {},
    name: "main",
    out: "main-built.js"
})

name属性是入口点,out是结果文件。很好的是我们有paths属性可用。这是一个我们可以直接描述模块的地方;例如,jquery: "some/other/jquery"。在我们的代码中,我们不必写文件的完整路径。只需简单的require(['jquery'], ...)就足够了。

默认情况下,r.js命令的输出是经过压缩的。如果我们在终端中添加一个optimize=none参数到命令中,我们将得到以下结果:

// main-built.js
define('modules/ajax',[],function () {
    ...
});

define('modules/router',[],function () {
    ...
});

require(['modules/ajax', 'modules/router'], function(ajax, router) {
    ...
});
define("main", function(){});

main-built.js文件包含了主模块及其依赖项。

从 Node.js 移动到使用 Browserify 的浏览器

RequireJS 确实解决了模块化的问题。然而,它让我们写更多的代码。此外,我们应该始终按照严格的格式描述我们的依赖关系。让我们看看我们在上一节中使用的代码:

require(['modules/ajax', 'modules/router'], function(ajax, router) {
    ...
});

确实,如果我们使用以下代码会更好:

var ajax = require('modules/ajax');
var router = require('modules/router');

现在代码简单多了。这是我们在 Node.js 环境中获取模块的方式。如果我们能在浏览器中使用相同的方法就好了。

Browserify (browserify.org/)是一个将 Node.js 的require模块带到浏览器中的模块。让我们首先使用以下代码安装它:

npm install -g browserify

同样,为了说明这个工具是如何工作的,我们将创建main.jsajax.jsrouter.js文件。这一次,我们不打算使用define这样的全局函数。相反,我们将使用通常的 Node.js module.exports

// main.js
var ajax = require('./modules/ajax');
var router = require('./modules/router');

// modules/ajax.js
module.exports = function() {};

// modules/router.js
module.exports = function() {};

默认情况下,Browserify 作为一个命令行工具。我们需要提供一个入口点和一个输出文件:

browserify ./main.js -o main-built.js

编译文件中的结果如下:

// main-built.js
(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var  a=typeof require=="function"&&require;if(!u&&a)return  a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module  '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var  l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var  n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return  n[o].exports}var i=typeof require=="function"&&require;for(var  o=0;o<r.length;o++)s(r[o]);return  s})({1:[function(require,module,exports){
var ajax = require('./modules/ajax');
var router = require('./modules/router');
},{"./modules/ajax":2,"./modules/router":3}],2:[function(require,module,exports){
module.exports = function() {};
},{}],3:[function(require,module,exports){
module.exports=require(2)
},{".../modules/ajax.js":2}]},{},[1]);

请注意,编译文件除了模块之外,还包含require函数的定义和实现。这确实只是一小段代码,使 Browserify 成为浏览器中传递模块化 JavaScript 的最受欢迎的方式之一。这是我们接下来几章要使用的方法。

我们已经开始了一个 Gulp 设置。让我们在那里添加 Browserify。我们已经对 JavaScript 进行了合并。让我们用 Browserify 替换它。我们将在package.json文件中添加模块,如下所示:

"dependencies": {
  "gulp": "3.8.8",
  "gulp-less": "1.3.6",
  "gulp-rename": "1.2.0",
  "gulp-minify-css": "0.3.11",
  "gulp-concat": "2.4.1",
  "gulp-uglify": "1.0.1",
  "gulp-browserify": "0.5.0"
}

运行npm install后,我们将安装并准备好使用插件。我们需要做两个更改,用browserify替换concat,并指出应用程序的主文件:

var browserify = require('gulp-browserify');
var uglify = require('gulp-uglify');

gulp.task('js', function() {
  gulp.src('./js/app.js')
  .pipe(browserify())
  .pipe(gulp.dest('./static/js'))
  .pipe(uglify())
  .pipe(rename({suffix: '.min'}))
  .pipe(gulp.dest('./static/js'))
});

现在,src方法只接受一个文件。这是我们的入口点。这是 Browserify 开始解析依赖关系的地方。其余部分都是一样的。我们仍然使用uglify进行最小化和rename来更改文件的名称。

传递 HTML 模板

在前面的章节中,您看到了如何为浏览器打包 CSS 和 JavaScript。在本章的最后,我们将探讨各种传递 HTML 的方式。在客户端应用程序的上下文中,模板仍然包含 HTML。然而,我们需要一种动态的方式来渲染并填充它们的数据。

在脚本标记中定义模板

Ember.js 框架采用了直接将 HTML 模板添加到页面中的概念,使用流行的handlebars (handlebarsjs.com/)模板引擎。然而,由于我们不想搞乱已经存在的标记,我们将它们放在<script>标记中。这样做的好处是,如果我们设置type属性的自定义值,浏览器就不会处理其中的代码。这里有一个演示:

<script type="text/x-handlebars" id="my-template">
   <p>Hello, <strong> </strong>!</p>
</script>

由于标签有一个id属性,我们可以通过以下方式轻松地获取它的内容:

var template = document.querySelector('#my-template').innerHTML;

这种技术的好处是模板在页面上,我们可以立即访问它。此外,模板只在被 JavaScript 处理后显示所需的内容。因此,如果浏览器中未启用 JavaScript,我们不希望显示未经处理的原始模板。这个概念的一个主要问题是,我们将用大量代码淹没我们的 HTML 页面。如果我们有一个大型应用程序,那么用户将不得不下载所有模板,即使他/她只使用其中的一部分。

外部加载模板

将模板定义为外部文件并使用 Ajax 请求加载到页面上也是一种常见做法。以下伪代码使用 jQuery 的get方法来完成这项工作:

$.get('/templates/template.html', function(html) {
    // ...
});

我们有清晰的标记,但用户必须进行额外的 HTTP 请求才能获取模板。这种方法使代码更复杂,因为过程是异步的。它还使处理和渲染内容比前一种方法更慢。

在 JavaScript 中编写 HTML

随着移动应用程序的兴起,许多大公司已经开始开发自己的框架。由于这些公司有足够的资源,他们通常会产生一些有趣的东西。例如,Facebook 创建了一个名为React (facebook.github.io/react/)的框架。它直接在 JavaScript 中定义其模板,如下所示:

<script type="text/jsx">
  var HelloMessage = React.createClass({
     render: function() {
      // Note: the following line is invalid JavaScript,
         // and only works using React parser.
      return <div>Hello {this.props.name}</div>;
     }
  });
</script>

来自 Facebook 的开发人员采用了本节中提到的第一种技术。他们将一些代码放在<script>标签中。为了使事情正常运行,他们有自己的解析器。它处理脚本并将其转换为有效的 JavaScript。

有一些解决方案没有以 HTML 形式的模板。有些工具使用 JSON 或 YAML 编写的模板。例如,AbsurdJS (absurdjs.com/)可以将其模板保存在 JavaScript 类定义中,如下所示:

body: {
  'section.content#home': {
    nav: [
      { 'a[href="#" class="link"]': 'A' },
      { 'a[href="#" class="link"]': 'B' },
      { 'a[href="#" class="link"]': 'C' }
    ]
  },
  footer: {
    p: 'Text in the Footer'
  }
}

预编译模板

将模板传递到客户端的另一种流行方式是使用预编译。这是我们将在项目中使用的方法。预编译是将 HTML 模板转换为 JavaScript 对象的过程,该对象已准备好在我们的代码中使用。这种方法有几个好处,其中一些如下:

  • 我们不必考虑访问 HTML 模板

  • 标记仍然与 JavaScript 代码分开

  • 我们不浪费时间去获取和处理 HTML

不同的客户端框架有不同的工具来预编译模板。我们将在以后详细介绍这一点,但我们将在我们的社交网络应用程序中使用的工具称为 Ractive.js (www.ractivejs.org/)。这是一个最初由 TheGuardian 的人员开发的客户端框架,用于制作新闻应用程序。它跨浏览器,在移动设备上表现良好。

为了将我们的 HTML 转换为 Ractive 预编译模板,我们需要在package.json文件中添加两个新模块:

"ractive": "0.6.1",
"gulp-tap": "0.1.3"

gulp-tap插件允许我们处理发送到 Gulp 管道的每个文件。以下是我们必须添加到gulpfile.js文件的新任务:

var Ractive = require('ractive');
var tap = require('gulp-tap');

gulp.task('templates', function() {
  gulp.src('./tpl/**/*.html')
  .pipe(tap(function(file, t) {
    var precompiled = Ractive.parse(file.contents.toString());
    precompiled = JSON.stringify(precompiled);
    file.contents = new Buffer('module.exports = ' + precompiled);
  }))
  .pipe(rename(function(path) {
    path.extname = '.js';
  }))
  .pipe(gulp.dest('./tpl'))
});

gulp.task('default', ['css', 'templates', 'js', 'watchers']);

Ractive.parse返回预编译模板。由于它是一个 JavaScript 对象,我们使用JSON.stringify将其转换为字符串。我们使用 Browserify 来控制我们的客户端模块化,因此在模板代码前面附加了module.exports。最后,我们使用gulp-rename生成一个 JavaScript 文件。

假设我们有一个包含以下内容的/tpl/template.html文件:

<section>
  <h1>Hello {{name}}</h1>
</section>

当我们运行gulp命令时,我们将收到包含相应标记的 JavaScript 的/tpl/template.js文件:

module.exports =  {"v":1,"t":[{"t":7,"e":"section","f":[{"t":7,"e":"h1","f":["Hello ",{"t":2,"r":"name"}]}]}]}

现在可能看起来很奇怪,但在接下来的几章中,您将看到如何使用这样的模板。

摘要

资产是 Web 应用程序的重要组成部分。通常,公司对这一部分不够重视,这导致加载时间变慢,Web 托管成本增加,特别是当您的网站变得更受欢迎时。在本章中,我们看到找到正确的设置并以最有效的方式交付图像、CSS、JavaScript 和 HTML 是很重要的。

在下一章中,我们将开始在我们的社交网络上大量工作。我们将探索模型-视图-控制器模式的世界。

第四章:开发模型-视图-控制器层

在上一章中,我们学习了如何准备应用程序所需的资源。现在是时候继续前进,开始编写我们社交网络的基本层。在本章中,我们将使用模型-视图-控制器模式,并准备我们的代码基础以实现我们应用程序的未来。以下是本章将讨论的内容:

  • 将代码从上一章转换为更好的文件结构

  • 实现在后端和前端环境中都能工作的路由器

  • 简要介绍 Ractive.js——这是我们将在项目的客户端部分使用的框架

  • 开发应用程序的主文件

  • 实现控制器、视图和模型类

发展当前的设置

编写软件是困难的。通常,这是一个变化的过程。为了发展和扩展我们的系统,我们必须对代码进行更改。我们将从上一章的代码中提取一些新的文件和文件夹。我们将稍微改变架构,以便在开发之后适应。

目录结构

将逻辑分为前端和后端是一种常见的做法。我们将遵循相同的方法。以下是新的文件结构:

目录结构

backend目录将包含在 Node.js 环境中使用的文件。正如我们所看到的,我们将之前在主目录中的文件移动到frontend文件夹中。这些文件产生了放置在static目录中的资源。我们仍然有必要的gulpfile.jspackage.jsonserver.js文件,其中包含了 Node.js 服务器的代码。

形成主服务器处理程序

到目前为止,我们的服务器只有一个请求处理程序——assets。以下是我们在上一章中启动服务器的方式:

var app = http.createServer(assets).listen(port, '127.0.0.1');

除了提供资源,我们还必须添加另外两个处理程序,如下所示:

  • API 处理程序:我们应用程序的客户端部分将通过 REST API 与后端通信。我们在第二章中介绍了这个概念,项目架构

  • 页面处理程序:如果发送到服务器的请求不是用于资源或 API 资源,我们将提供一个 HTML 页面,这是普通用户将看到的页面。

将所有内容保存在一个文件中并不是一个好主意。因此,第一步是将assets函数提取到自己的模块中:

// backend/Assets.js
module.exports = function(req, res) {
...
}

// server.js
var Assets = require('./backend/Assets');

我们将采用类似的方法创建一个backend/API.js文件。它将负责 REST API。我们将使用 JSON 作为数据传输的格式。我们可以使用的最简单的代码如下:

// backend/API.js
module.exports = function(req, res) {
  res.writeHead(200, {'Content-Type': 'application/json'});
  res.end('{}' + '\n');
}

设置正确的Content-Type值很重要。如果缺少或者值错误,那么接收响应的浏览器可能无法正确处理结果。最后,我们返回一个最小的空 JSON 字符串。

最后,我们将添加backend/Default.js。这是将在浏览器中生成用户将看到的 HTML 页面的文件:

// backend/Default.js
var fs = require('fs');
var html = fs.readFileSync(__dirname + '/tpl/page.html').toString('utf8');
module.exports = function(req, res) {
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end(html + '\n');
}

Default.js的内容看起来与API.js类似。我们将再次设置Content-Type值,并使用response对象的end()方法。然而,在这里,我们从外部文件中加载 HTML Unicode 字符串,该文件存储在backend/tpl/page.html中。文件的读取是同步的,并且只在开始时发生一次。以下是page.html的代码:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Node.js by example</title>
  <meta http-equiv="Content-Type" content="text/html;  charset=utf-8" />
  <meta name="description" content="Node.js by examples">
  <meta name="author" content="Packt">
  <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
  <script src="img/ractive.js"></script>
  <script src="img/app.js"></script>
</body>
</html>

这是一个基本的 HTML5 样板代码,包含头部、主体标签、CSS 和 JavaScript 导入。我们的应用程序只需要以下两个 JavaScript 文件才能运行:

  • ractive.js:这是我们将在客户端使用的框架。关于这个更多的内容将在接下来的几节中讨论。

  • app.js:这是我们的客户端 JavaScript。如前一章所述,它是由 Gulp 设置生成的。

在提到后端处理程序之后,我们准备好开始编写将在浏览器中运行的代码。

实现路由器

几乎每个 Web 应用程序都需要一个路由器,它是一个作为前门的组件,接受传入的查询。它分析请求的参数,并决定我们系统的哪个模块将提供结果。

我们在后端(通过 Node.js)和前端(由 Web 浏览器解释)中使用 JavaScript 语言。在本节中,我们将编写一个在应用程序的两侧都能工作的路由器。让我们开始检查 Node.js 部分需要什么:

// frontend/js/lib/Router.js
module.exports = function() {
  return {
    routes: [],
    add: function(path, handler) {
      // ...
    },
    check: function(fragment, params) {
      // ...
    }
  }
};

Router.js导出两种方法。第一个方法通过接受路径和处理程序函数来注册路由,如果当前 URL 与路径匹配,则将调用该处理程序。check函数只是执行实际检查。

这是add方法的样子:

add: function(path, handler) {
  if(typeof path === 'function') {
    handler = path;
    path = '';
  }
  this.routes.push({
    path: path,
    handler: handler
  });
  return this;
}

我们可以跳过path参数,只注册一个匹配每个路由的函数。在某些情况下,支持这种行为是很好的,我们想定义一个默认路由。

check函数稍微复杂一些。它不仅涵盖简单的字符串匹配,还应该支持动态参数。我们将使用:id来表示这些动态参数。例如:

  • /home:这匹配http://localhost/home

  • /user/feed:这匹配http://localhost/user/feed

  • /user/:id/profile:这匹配http://localhost/user/45/profile

  • /user/:id/:action:这匹配http://localhost/user/45/update

为了实现这个功能,我们将以以下方式使用正则表达式:

check: function(f, params) {
  var fragment, vars;
  if(typeof f !== 'undefined') {
    fragment = f.replace(/^\//, '');
  } else {
    fragment = this.getFragment(); 
  }
  for(var i=0; i<this.routes.length; i++) {
    var match, path = this.routes[i].path;
    path = path.replace(/^\//, '');
    vars = path.match(/:[^\s/]+/g);
    var r = new RegExp('^' + path.replace(/:[^\s/]+/g,  '([\\w-]+)'));
    match = fragment.match(r);
    if(match) {
      match.shift();
      var matchObj = {};
      if(vars) {
        for(var j=0; j<vars.length; j++) {
          var v = vars[j];
          matchObj[v.substr(1, v.length)] = match[j];
        }
      }
      this.routes[i].handler.apply({},  (params || []).concat([matchObj]));
      return this;
    }
  }
  return false;
}

让我们逐行浏览该函数。该方法的参数是fparameters。片段实际上是一个路径。这是我们要检查的 URL。在add方法中,我们添加了一个处理程序,一旦匹配,就会触发。如果我们能够向该方法发送额外的变量,那将是很好的。parameters参数涵盖了这个功能。我们可以发送一个数组,稍后将其转换为处理程序的参数。

该函数继续检查片段是否已定义。在 Node.js 环境中,我们必须发送 URL。但是,由于我们将在浏览器中使用相同的代码,我们定义了一个getFragment辅助方法:

getFragment: function() {
  var fragment = '';
  fragment = this.clearSlashes(decodeURI(window.location.pathname  + location.search));
  fragment = fragment.replace(/\?(.*)$/, '');
  fragment = this.root !== '/' ? fragment.replace(this.root, '') : fragment;
  return this.clearSlashes(fragment);
}

这个辅助程序的主要思想是通过使用全局的window.location对象来获取浏览器的当前 URL。您可能会注意到另一个clearSlashes函数。它确切地做了它的名字所暗示的。它从字符串的开头和结尾删除不必要的斜杠:

clearSlashes: function(path) {
  return path.toString().replace(/\/$/, '').replace(/^\//, '');
}

让我们回到check函数。我们将继续循环遍历已注册的路由。对于每个路由,我们执行以下操作:

  • 我们通过提取动态部分(如果有)来准备一个正则表达式;例如,users/:id/:action被转换为test/([\w-]+)/([\w-]+)。我们将在本书中稍后使用这个。

  • 我们检查正则表达式是否与片段匹配。如果匹配,则我们组成一个参数数组并调用路由的处理程序。

有趣的是,如果我们传递我们自己的路径(片段),我们可以在 Node.js 和浏览器环境中使用相同的 JavaScript。

应用程序的客户端将需要另外两种方法。到目前为止,我们已经注册了路由并检查这些规则是否特定匹配 URL。这对于后端可能有效,但在前端,我们需要不断监视当前浏览器位置。这就是为什么我们将添加以下功能:

listen: function() {
  var self = this;
  var current = self.getFragment();
  var fn = function() {
    if(current !== self.getFragment()) {
      current = self.getFragment();
      self.check(current);
    }
  }
  clearInterval(this.interval);
  this.interval = setInterval(fn, 50);
  return this;
}

通过使用setInterval,我们将再次运行fn闭包。它检查当前 URL 是否已更改,如果是,则触发check方法,这已经解释过了。

该类的最后一个添加是navigate函数:

navigate: function(path) {
  path = path ? path : '';
  history.pushState(null, null, this.root + this.clearSlashes(path));
  return this;
}

我们可能希望在代码中更改当前页面。路由是一个很好的工具。一旦我们更改浏览器的 URL,该类就会自动调用正确的处理程序。上述代码使用了 HTML5 历史 API(diveintohtml5.info/history.html)。pushState方法会更改浏览器地址栏的字符串。

通过添加navigate方法,我们完成了我们的路由器,它是一个可以在后端和前端使用的模块。在继续介绍模型-视图-控制器组件之前,我们将简要介绍 Ractive.js—我们将用作用户界面开发的驱动力的框架。

介绍 Ractive.js

Ractive.js 是由著名新闻机构 TheGuardian 开发的框架(www.theguardian.com/)。它简化了 DOM 交互,并提供了诸如双向数据绑定和自定义组件创建等功能。我们现在不打算涵盖框架的所有功能。新功能将在后面的章节中介绍。

在像我们这样的复杂 Web 应用程序中,将不同的逻辑部分拆分成组件非常重要。幸运的是,Ractive.js 为此提供了一个接口。以下是典型组件的外观:

var Component = Ractive.extend({
  template: '<div><h1>{{title}}</h1></div>',
  data: {
    title: 'Hello world'
  }
});
var instance = new Component();
instance.render(document.'body);

template属性包含 HTML 标记或(在我们的情况下)预编译模板。数据对象可以在我们的模板中访问。Ractive.js 使用mustachemustache.github.io/)作为模板语言。我们可以添加另一个名为el的属性,并直接选择组件在初始化后将呈现的位置。然而,还有另一种方式—render方法。该方法接受一个 DOM 元素。在上述代码中,这只是页面的 body。

与浏览器中的 DOM 树类似,我们需要组件的嵌套。框架通过引入自定义标签定义来很好地处理了这一点,如下例所示:

var SubComponent = Ractive.extend({
    template: '<small>Hello there!</small>'
});
var Component = Ractive.extend({
  template: '\
    <div>\
        <h1>{{title}}</h1>\
        <my-subcomponent />\
    </div>\
  ',
  data: {
    title: 'Hello world'
  },
  components: {
    'my-subcomponent': SubComponent
  }
});
var instance = new Component();
instance.render(document.querySelector('body'));

每个组件可能都有一个哈希映射对象(components),用于定义我们的自定义标签。我们可以嵌套任意多个组件。上述代码生成的 HTML 如下所示:

<div>
  <h1>Hello world</h1>
  <small>Hello there!</small>
</div>

在不同的 Ractive.js 组件之间建立通信的几种方式。最方便的一种方式是触发和监听事件。让我们来看一下以下代码片段:

var Component = Ractive.extend({
  template: '<div><h1>{{title}}</h1></div>',
  notifyTheOutsideWorld: function() {
    this.fire('custom-event');
  }
});
var instance = new Component();
instance.on('custom-event', function() {
  this.set('title', 'Hey!');
  instance.render(document.querySelector('body'));
});
instance.notifyTheOutsideWorld();

我们提出了一些新概念。首先,我们定义了一个公共函数—notifyTheOutsideWorld。Ractive.js 允许您注册自定义方法。使用on方法,我们订阅了特定事件,并使用fire来分发事件。

在上面的示例中,我们使用了另一个到目前为止尚未解释的方法。set函数修改了组件的数据对象。我们将经常使用这个函数。

关于 Ractive.js,我们在本章中要提到的最后一件事是它观察组件数据属性变化的功能。下面的代码演示了对title属性的观察:

var Component = Ractive.extend({
  template: '<div><h1>{{title}}</h1></div>'
});
var instance = new Component();
instance.observe('title', function(value) {
    alert(value);
});
instance.set('title', 'Hello!');

上面的示例显示了一个带有Hello!文本的alert窗口。让我们继续定义主应用程序文件的过程,换句话说,我们的社交网络的客户端入口点。

构建应用程序的入口点

在构建 Gulp 设置时,我们为 JavaScript 捆绑创建了一个任务。Browserify 需要一个入口点来解析依赖关系。我们设置为frontend/js/app.js。同样,对于后端,我们将围绕路由构建我们的逻辑。以下代码设置了两个路由,并提供了一个辅助函数来在页面上呈现 Ractive.js 组件:

// frontend/js/app.js
var Router = require('./lib/Router')();
var Home = require('./controllers/Home');
var currentPage;
var body;

var showPage = function(newPage) {
  if(currentPage) { currentPage.teardown(); }
  currentPage = newPage;
  body.innerHTML = '';
  currentPage.render(body);
}

window.onload = function() {

  body = document.querySelector('body');

  Router
  .add('home', function() {
    var p = new Home();
    showPage(p);
  })
  .add(function() {
    Router.navigate('home');
  })
  .listen()
  .check();

}

我们需要在顶部引入Router变量。除此之外,我们还需要获取负责主页的控制器。我们将在下一节中详细了解这一点。现在,我们只会说它是一个 Ractive.js 组件。

我们不希望在页面资源完全加载之前运行任何 JavaScript。因此,我们将在window.onload处理程序中包装我们的引导代码。Ractive.js 组件的持有者将是body标签,我们将创建对它的引用。我们定义了一个名为showPage的辅助函数。它的工作是呈现当前页面并确保最后添加的页面被正确移除。teardown方法是框架的内置函数。它取消呈现组件并删除所有事件处理程序。

在本章中,我们将只有一个页面-主页。我们将使用我们为后端创建的路由器并注册一个/home路由。我们传递给add函数的第二个处理程序基本上是在没有匹配路由的情况下调用的。我们所做的是立即将用户转发到/home URL。最后,我们触发了路由器的监听并触发了初始检查。

在下一节中,我们将定义我们的第一个控制器-将控制我们的主页的组件。

定义控制器

在我们的上下文中,控制器的作用将是编排页面。换句话说,它们将充当管理子组件之间发生的过程的页面包装器。controllers/Home.js文件的内容如下:

module.exports = Ractive.extend({
  template: require('../../tpl/home'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  onrender: function() {
    console.log('Home page rendered');
  }
});

在您查看模板和组件的属性之前,我们必须对onrender说几句话。Ractive.js 组件提供了一个接口,用于定义在组件生命周期的每个阶段内部发生的处理程序。例如,我们几乎每次在组件呈现在页面上后都需要执行一些操作。还有onconstructonteardownonupdate。这无疑是实现业务逻辑的一种好方法。所有这些属性都在框架的官方文档中列出,网址为docs.ractivejs.org/latest/options

我们在向您介绍 Ractive.js 时已经提到了template属性。但是,在下面的代码中,我们没有一个字符串作为值。我们需要另一个 JavaScript 文件-预编译的 HTML 模板。预编译是由构建系统 Gulp 完成的,如下所示:

// gulpfile.js
gulp.task('templates', function() {
  gulp.src('./frontend/tpl/**/*.html')
  .pipe(tap(function(file, t) {
    var precompiled = Ractive.parse(file.contents.toString());
    precompiled = JSON.stringify(precompiled);
    file.contents = new Buffer('module.exports = ' + precompiled);
  }))
  .pipe(rename(function(path) {
    path.extname = '.js';
  }))
  .pipe(gulp.dest('./frontend/tpl'))
});

我们将从frontend/tpl目录获取所有 HTML 文件,并将它们转换为 Ractive.js 和 Browserify 理解的 JavaScript 文件。最后,Gulp 在同一目录中创建一个具有相同名称但扩展名不同的文件。例如,我们的主页模板可以如下所示:

// frontend/tpl/home.html
<header>
  <navigation />
  <div class="hero">
    <h1>Node.js by example</h1>
  </div>
</header>
<appfooter />

当我们在终端中运行gulp时,我们将得到frontend/tpl/home.js,其内容如下:

module.exports =  {"v":1,"t":[{"t":7,"e":"footer","f":["Version:  ",{"t":2,"r":"version"}]}]}

我们不必完全理解这些属性的含义。将 JavaScript 文件转换为 HTML 是框架预留的工作。

如果您检查前面代码中的模板和组件定义,您会注意到有两个子组件,navigationappfooter。让我们看看如何创建它们。

管理我们的视图

再次,视图是 Ractive.js 组件。它们有自己的模板。事实上,Home.js模块也可以被称为视图。浏览器中的模型-视图-控制器模式经常会发生变化,并且不遵循精确的定义。这在我们的应用程序中是这样的,因为我们使用的框架有一些规则,并且提供了一些特定的功能,这些功能与典型的 MVC 不一致。当然,这并没有什么问题。只要我们分开责任,我们的架构就会很好。

navigation视图相当简单。它只定义了需要呈现的模板:

// views/navigation.js
module.exports = Ractive.extend({
  template: require('../../tpl/navigation')
});

为了使事情更有趣并引入模型的定义,我们将在页脚中显示一个版本号。这个数字将来自于在models/Version.js中创建的模型。以下是views/Footer.js文件的代码:

var FooterModel = require('../models/Version');

module.exports = Ractive.extend({
  template: require('../../tpl/footer'),
  onrender: function() {
    var model = new FooterModel();
    model.bindComponent(this).fetch();
  }
});

在解释bindComponent到底发生了什么之前,让我们来看看tpl/footer.html中有什么:

<footer>
  Version: {{version}}
</footer>

我们有一个动态变量,version。如果我们不使用模型,我们必须在组件的data属性中定义它,或者使用this.set('data', value)。然而,FooterModel模块将使我们的生活更轻松,并更新与其绑定的组件的变量。这就是为什么我们将这个模块传递给bindComponent的原因。正如我们将在下一节中看到的,fetch方法将模型的数据与后端的数据同步。

创建一个模型

我们可能会有几个模型,它们都将共享相同的方法。通常,模型向服务器发出 HTTP 请求并获取数据。所以,这是我们需要抽象的东西。幸运的是,Ractive.js 使您能够扩展组件。这是models/Version.js文件的代码:

var Base = require('./Base');
module.exports = Base.extend({
  data: {
    url: '/api/version'
  }
});

我们有models/Base.js,这个文件将包含这些通用函数。它将是一个基类,我们稍后会继承它。

var ajax = require('../lib/Ajax');
module.exports = Ractive.extend({
  data: {
    value: null,
    url: ''
  },
  fetch: function() {
    var self = this;
    ajax.request({
      url: self.get('url'),
      json: true
    })
    .done(function(result) {
      self.set('value', result);
    })
    .fail(function(xhr) {
      self.fire('Error fetching ' + self.get('url'))
    });
    return this;
  },
  bindComponent: function(component) {
    if(component) {
      this.observe('value', function(v) {
        for(var key in v) {
         component.set(key, v[key]);
           }
      }, { init: false });
    }
    return this;
  }
});

我们定义了两个方法——fetchbindComponent。第一个使用一个辅助的 Ajax 包装器。我们现在不打算深入讨论这个细节。它类似于 jQuery 的.ajax方法,并实现了 promise 接口模式。实际的源代码可以在随本书提供的文件中找到。

扩展Base模块的组件应该提供一个 URL。这是模型将发出请求的终点。在我们的情况下,这是/api/version。我们的后端将在这个 URL 上提供内容。

如果你回头检查我们对以/api开头的 URL 所做的事情,你会发现结果只是一个空对象。让我们改变这一点,覆盖/api/version路由的实现。我们将更新backend/API.js如下:

var response = function(result, res) {
  res.writeHead(200, {'Content-Type': 'application/json'});
  res.end(JSON.stringify(result) + '\n');
}
var Router = require('../frontend/js/lib/router')();
Router
.add('api/version', function(req, res) {
  response({
    version: '0.1'
  }, res);
})
.add(function(req, res) {
  response({
    success: true
  }, res);
});

module.exports = function(req, res) {
  Router.check(req.url, [req, res]);
}

我们使用相同的路由器将 URL 映射到特定的响应。所以,在这个改变之后,我们的模型将获取0.1作为值。

最后,让我们揭示bindComponent函数中发生的魔法:

bindComponent: function(component) {
  if(component) {
    this.observe('value', function(v) {
      for(var key in v) component.set(key, v[key]);
    }, { init: false });
  }
  return this;
}

我们观察本地data属性值的变化。在成功的fetch方法调用后进行更新。新值传递给处理程序,我们只是将变量传递给组件。这只是几行代码,但它们成功地带来了一个很好的抽象。在实际的模型定义中,我们只需要指定 URL。Base模块会处理其余部分。

总结

在本章中,我们构建了我们应用程序的基础。我们还创建了我们系统的基础——路由器。控制器现在很好地绑定到路由,并且视图在页面上呈现,当模型的值发生变化时,显示会自动更新。我们还引入了一个简单的模型,它从后端的 API 获取数据。

在下一章中,我们将实现一个真正有效的功能——我们将管理我们系统的用户。

第五章: 管理用户

在第四章中,开发模型-视图-控制器层,我们使用了模型-视图-控制器模式并编写了我们社交网络的基础。我们将应用程序分成了后端和前端目录。第一个文件夹中的代码用于提供资产并生成主页。除此之外,我们还建立了后端 API 的基础。项目的客户端由 Ractive.js 框架驱动。这是我们存储控制器、模型和视图的地方。有了这些元素,我们将继续管理用户。在本书的这一部分,我们将涵盖以下主题:

  • 使用 MongoDB 数据库

  • 注册新用户

  • 使用会话进行用户认证

  • 管理用户的个人资料

使用 MongoDB 数据库

现在,几乎每个网络应用程序都会从数据库中存储和检索数据。其中一个与 Node.js 兼容性很好的最流行的数据库是 MongoDB (www.mongodb.org/)。这就是我们要使用的。MongoDB 的主要特点是它是一个具有不同数据格式和查询语言的 NoSQL 数据库。

安装 MongoDB

与其他流行软件一样,MongoDB 适用于所有操作系统。如果您是 Windows 用户,可以从官方页面www.mongodb.org/downloads下载安装程序。对于 Linux 或 OS X 开发人员,MongoDB 可以通过大多数流行的软件包管理系统获得。我们不会详细介绍安装过程,但您可以在docs.mongodb.org/manual/installation/找到详细的说明。

运行 MongoDB

安装成功后,我们将有一个mongod命令可用。通过在终端中运行它,我们启动一个默认监听端口27017的 MongoDB 服务器。我们的 Node.js 后端将连接到这个端口并执行数据库查询。以下是在执行mongod命令后我们控制台的样子:

运行 MongoDB

连接到数据库服务器

Node.js 的一个好处是存在成千上万的模块。由于社区不断增长,我们几乎可以为遇到的每个任务找到一个模块。我们已经使用了几个 Gulp 插件。现在,我们将在package.json文件中添加官方的 MongoDB 驱动程序:

"dependencies": {
  "mongodb": "1.4.25",
  ..
}

我们必须运行npm install将模块安装到node_modules目录中。一旦过程完成,我们可以使用以下代码连接到服务器:

var MongoClient = require('mongodb').MongoClient;
MongoClient.connect('mongodb://127.0.0.1:27017/nodejs-by-example',  function(err, db) {
  // ...
});

在这段代码中,nodejs-by-example是我们的数据库名称。调用的回调函数使我们能够访问驱动程序的 API。我们可以使用db对象来操作数据库中的集合,换句话说,创建、更新、检索或删除文档。以下是一个示例:

var collection = db.collection('users');
collection.insert({
  name: 'John',
  email: 'john@test.com'
}, function(err, result) {
  // ...
});

现在我们知道如何管理系统中的数据了。让我们继续到下一节并扩展我们的客户端代码。

扩展上一章的代码

向已有的代码库添加新功能意味着重构和扩展已经编写的代码。为了开发用户管理,我们需要更新models/Base.js文件。到目前为止,我们有一个简单的Version模型,我们将需要一个新的User模型。我们需要改进我们的导航和路由,以便用户有页面来创建、编辑和管理他们的账户。

本章附带的代码有很多 CSS 样式的添加。我们不会讨论它们,因为我们更想专注于 JavaScript 部分。它们为应用程序提供了稍微更好的外观。如果您对最终的 CSS 是如何生成感兴趣,请查看本书的代码包。

更新我们的基础模型类

到目前为止,models/Base.js只有两种方法。第一个方法fetch执行一个带有给定 URL 的GET请求。在第二章中,项目架构,我们谈到了 REST API;为了完全支持这种架构,我们必须添加用于创建、更新和删除记录的方法。实际上,所有这些方法都将接近我们已经拥有的方法。这是create函数:

create: function(callback) {
  var self = this;
  ajax.request({
    url: self.get('url'),
    method: 'POST',
    data: this.get('value'),
    json: true
  })
  .done(function(result) {
    if(callback) {
      callback(null, result);
    }
  })
  .fail(function(xhr) {
    if(callback) {
      callback(JSON.parse(xhr.responseText));
    }
  });
  return this;
}

我们运行模型的方法,该方法从其value属性获取数据并执行POST请求。最后,我们触发一个回调。如果出现问题,我们将错误作为第一个参数发送。如果没有问题,那么第一个参数(表示错误状态)为null,第二个参数包含服务器的响应。

我们将遵循相同的方法来更新和删除代码:

save: function(callback) {
  var self = this;
  ajax.request({
    url: self.get('url'),
    method: 'PUT',
    data: this.get('value'),
    json: true
  })
  .done(function(result) { // ...  })
  .fail(function(xhr) { // ... });
  return this;
},
del: function(callback) {
  var self = this;
  ajax.request({
    url: self.get('url'),
    method: 'DELETE',
    json: true
  })
  .done(function(result) { ...  })
  .fail(function(xhr) { ... });
  return this;
}

不同之处在于request方法。对于save操作,我们使用PUT,而要删除数据,我们使用DELETE。请注意,在删除过程中,我们不必发送模型的数据,因为我们只是执行一个简单的操作,从数据库中删除特定的数据对象,而不是进行像createsave请求中所见的更复杂的更改。

更新页面导航和路由

来自第四章的代码,开发模型-视图-控制器层,在其导航中只包含两个链接。我们需要为其添加更多内容——链接到注册、登录和注销,以及个人资料管理访问。frontend/tpl/navigation.html模板片段如下所示:

<nav>
  <ul>
    <li><a on-click="goto:home">Home</a></li>
    {{#if !isLogged }}
      <li><a on-click="goto:register">Register</a></li>
      <li><a on-click="goto:login">Login</a></li>
    {{else}}
      <li class="right"><a on-click="goto:logout">Logout</a></li>
      <li class="right"><a on-click="goto:profile">Profile</a></li>
    {{/if}}
  </ul>
</nav>

除了新的<a>标签,我们还进行了以下两个有趣的添加:

  • 有一个{{#if}}表达式。在我们的 Ractive.js 组件中,我们需要注册一个isLogged变量。它将通过隐藏和显示适当的按钮来控制导航的状态。当用户未登录时,我们将显示注册登录按钮。否则,我们的应用程序将显示注销个人资料链接。关于isLogged变量的更多信息将在本章末讨论,当我们涵盖会话支持时。

  • 我们有on-click属性。请注意,这些属性不是有效的 HTML,但它们被 Ractive.js 解释为产生期望的结果。导航中的每个链接都将分派一个带有特定参数的goto事件,并且当用户触发链接时,这将发生。

在应用程序的主文件(frontend/js/app.js)中,我们有一个showPage函数。该方法可以访问当前页面,是监听goto事件的理想位置。这也是一个很好的选择,因为在同一个文件中,我们有一个对路由器的引用。因此,我们能够更改当前站点的页面。对这个函数进行一点改变,我们就完成了页面的切换:

var showPage = function(newPage) {
  if(currentPage) currentPage.teardown();
  currentPage = newPage;
  body.innerHTML = '';
  currentPage.render(body);
  currentPage.on('navigation.goto', function(e, route) {
    Router.navigate(route);
  });
}

在下一节中,我们将继续编写代码,以在我们的系统中注册新用户。

注册新用户

为了处理用户的注册,我们需要更新前端和后端代码。应用程序的客户端部分将收集数据,后端将其存储在数据库中。

更新前端

我们更新了导航,现在,如果用户点击注册链接,应用程序将将他们转发到/register路由。我们必须调整我们的路由器,并以以下方式注册处理程序:

var Register = require('./controllers/Register');
Router
.add('register', function() {
  var p = new Register();
  showPage(p);
})

与主页一样,我们将创建一个位于frontend/js/controllers/Register.js中的新控制器,如下所示:

module.exports = Ractive.extend({
  template: require('../../tpl/register'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  onrender: function() {
    var self = this;
    this.observe('firstName',  userModel.setter('value.firstName'));
    this.observe('lastName', userModel.setter('value.lastName'));
    this.observe('email', userModel.setter('value.email'));
    this.observe('password', userModel.setter('value.password'));
    this.on('register', function() {
      userModel.create(function(error, result) {
        if(error) {
          self.set('error', error.error);
        } else {
          self.set('error', false);
          self.set('success', 'Registration successful.  Click <a href="/login">here</a> to login.');
        }
      });
    });
  }
});

该控制器附加的模板包含一个带有几个字段的表单——名字、姓氏、电子邮件和密码:

<header>
  <navigation></navigation>
</header>
<div class="hero">
  <h1>Register</h1>
</div>
<form>
  {{#if error && error != ''}}
    <div class="error">{{error}}</div>
  {{/if}}
  {{#if success && success != ''}}
    <div class="success">{{{success}}}</div>
  {{else}}
    <label for="first-name">First name</label>
    <input type="text" id="first-name" value="{{firstName}}"/>
    <label for="last-name">Last name</label>
    <input type="text" id="last-name" value="{{lastName}}" />
    <label for="email">Email</label>
    <input type="text" id="email" value="{{email}}" />
    <label for="password">Password</label>
    <input type="password" id="password" value="{{password}}" />
    <input type="button" value="register" on-click="register" />
  {{/if}}
</form>
<appfooter />

值得一提的是,我们有错误和成功消息的占位符。它们受{{#if}}表达式保护,并且默认情况下是隐藏的。如果我们在控制器中为errorsuccess变量设置值,这些隐藏的div元素将变为可见。为了获取输入字段的值,我们将使用 Ractive.js 绑定。通过设置value="{{firstName}}",我们将创建一个新变量,该变量将在我们的控制器中可用。我们甚至可以监听此变量的更改,如下所示:

this.observe('firstName', function(value) {
   userModel.set('value.firstName', value);
});

输入字段中的数据应发送到与后端通信的model类。由于我们有几个表单字段,创建一个辅助程序可以节省一些代码:

this.observe('firstName', userModel.setter('value.firstName'));

setter方法返回了我们在前面代码中使用的相同闭包:

// frontend/js/models/Base.js
setter: function(key) {
  var self = this;
  return function(v) {
    self.set(key, v);
  }
}

如果我们回头检查controllers/Register.js,我们将看到注册表单中的所有字段。在此表单中,我们有一个按钮触发register事件。控制器订阅了该事件,并触发模型的create函数。根据结果,我们要么显示错误消息,要么显示注册成功消息。

在前面的代码中,我们使用了一个userModel对象。这是User类的一个实例,它扩展了models/Base.js文件中的内容。以下是存储在frontend/js/models/User.js中的代码:

var Base = require('./Base');
module.exports = Base.extend({
  data: {
    url: '/api/user'
  }
});

我们扩展了基本模型。因此,我们自动获得了createsetter函数。对于注册过程,我们不需要任何其他自定义方法。但是,为了登录和退出,我们将添加更多函数。

我们的系统的几个部分将需要这个模型。因此,我们将创建其全局userModel实例。这样做的合适位置是frontend/js/app.js文件。window.onload事件的监听器是这样的代码的良好宿主:

window.onload = function() {
  ...
  userModel = new UserModel();
  ...
};

请注意,我们在变量定义前面漏掉了var关键字。这是我们使userModel在全局范围内可用的方法。

更新后端 API

我们的客户端代码向后端发出POST请求,携带新用户的数据。为了闭环,我们必须在后端 API 中处理请求,并将信息记录在数据库中。让我们首先在backend/API.js中添加一些辅助函数和变量:

var MongoClient = require('mongodb').MongoClient;
var database;
var getDatabaseConnection = function(callback) {
  if(database) {
    callback(database);
    return;
  } else {
    MongoClient.connect('mongodb://127.0.0.1:27017/nodejs-by-example',  function(err, db) {
      if(err) {
        throw err;
      };
      database = db;
      callback(database);
    });
  }
};

在本章的开头,我们学习了如何向 MongoDB 数据库发出查询。我们需要访问驱动程序的 API。有一段代码我们会经常使用。因此,将其包装在一个辅助方法中是一个好主意。getDatabaseConnection函数正是可以用来实现这一点的函数。它只在第一次执行时连接到数据库。之后的每次调用都会返回缓存的database对象。

Node.js 请求处理的另一个常见任务是获取POST数据。GET参数可在每个路由处理程序中的request对象中使用。但是,对于POST数据,我们需要一个特殊的辅助程序:

var querystring = require('querystring');
var processPOSTRequest = function(req, callback) {
  var body = '';
  req.on('data', function (data) {
    body += data;
  });
  req.on('end', function () {
    callback(querystring.parse(body));
  });
};

我们使用request对象作为流,并订阅其data事件。一旦我们接收到所有信息,我们就使用querystring.parse将其格式化为可用的哈希映射(POST参数的键/值)对象,并触发回调。

最后,我们将添加一个电子邮件验证函数。我们在注册和更新用户资料时会用到它。实际的验证是通过正则表达式完成的:

var validEmail = function(value) {
  var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@( (\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0- 9]+\.)+[a-zA-Z]{2,}))$/;
  return re.test(value);
};

现在让我们继续编写代码,接受POST请求并在数据库中注册新用户。到目前为止,我们只向 API 添加了两个路由—/api/version和默认路由。我们将再添加一个/api/user,如下所示:

Router.add('api/user', function(req, res) {
  switch(req.method) {
    case 'GET':
      // ...
    break;
    case 'PUT':
      // ...
    break;
    case 'POST':
      processPOSTRequest(req, function(data) {
        if(!data.firstName || data.firstName === '') {
          error('Please fill your first name.', res);
        } else if(!data.lastName || data.lastName === '') {
          error('Please fill your last name.', res);
        } else if(!data.email || data.email === '' ||  !validEmail(data.email)) {
          error('Invalid or missing email.', res);
        } else if(!data.password || data.password === '') {
          error('Please fill your password.', res);
        } else {
          getDatabaseConnection(function(db) {
            var collection = db.collection('users');
            data.password = sha1(data.password);
            collection.insert(data, function(err, docs) {
              response({
                success: 'OK'
              }, res);
            });
          });
        }
      });
    break;
    case 'DELETE':
      // ...
    break;
  };
});

同一路由将承载不同的操作。为了区分它们,我们将依赖request方法,正如 REST API 概念中所描述的那样。

POST情况下,我们将首先使用processPOSTRequest助手获取数据。之后,我们将运行一系列检查,以确保发送的数据是正确的。如果不正确,我们将用适当的错误消息进行响应。如果一切正常,我们将使用另一个getDatabaseConnection助手,在数据库中创建一个新记录。将用户密码以明文形式存储并不是一个好的做法。因此,在将它们发送到 MongoDB 之前,我们将使用sha1模块对它们进行加密。这是一个在 Node.js 包管理器注册表中可用的模块。在backend/API.js的顶部,我们将添加以下内容:

var sha1 = require('sha1');

为了使这一行起作用,我们必须更新package.json文件,并在控制台中运行npm install

在下一节中,我们将实现GETPUTDELETE情况。除此之外,我们还将向您介绍一个新的登录路由。

用户身份验证与会话

我们实现了在系统中注册新用户的功能。下一步是对这些用户进行身份验证。让我们首先提供一个输入用户名和密码的界面。我们需要在frontend/js/app.js中添加一个新的路由处理程序:

Router
.add('login', function() {
    var p = new Login();
    showPage(p);
})

到目前为止,所有其他页面都使用了相同的思路。我们将初始化一个新的控制器并将其传递给showPage助手。这里使用的模板如下:

// frontend/tpl/login.html
<header>
  <navigation></navigation>
</header>
<div class="hero">
  <h1>Login</h1>
</div>
<form>
  {{#if error && error != ''}}
    <div class="error">{{error}}</div>
  {{/if}}
  {{#if success && success != ''}}
    <div class="success">{{{success}}}</div>
  {{else}}
    <label for="email">Email</label>
    <input type="text" id="email" value="{{email}}" />
    <label for="password">Password</label>
    <input type="password" id="password" value="{{password}}" />
    <input type="button" value="login" on-click="login" />
  {{/if}}
</form>
<appfooter />

在注册过程中,我们使用了类似的占位符来显示错误和成功消息。同样,我们有一个 HTML 表单。但是这次,表单包含了用户名和密码的输入字段。我们还将绑定两个变量,并确保按钮分派login事件。这是我们控制器的代码:

// frontend/js/controllers/Login.js
module.exports = Ractive.extend({
  template: require('../../tpl/login'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  onrender: function() {
    var self = this;
    this.observe('email', userModel.setter('email'));
    this.observe('password', userModel.setter('password'));
    this.on('login', function() {
      userModel.login(function(error, result) {
        if(error) {
          self.set('error', error.error);
        } else {
          self.set('error', false);
          // redirecting the user to the home page
          window.location.href = '/';
        }
      });
    });
  }
});

通过使用相同的setter函数,我们存储了填入我们模型的值。有一个userModel.login方法,类似于userModel.create。它触发一个带有给定数据的POST请求到服务器。在这种情况下,数据是用户名和密码。这次,我们不会使用基本模型中的函数。我们将在/frontend/js/models/User.js文件中注册一个新的模型:

var ajax = require('../lib/Ajax');
var Base = require('./Base');
module.exports = Base.extend({
  data: {
    url: '/api/user'
  },
  login: function(callback) {
    var self = this;
    ajax.request({
      url: this.get('url') + '/login',
      method: 'POST',
      data: {
        email: this.get('email'),
        password: this.get('password')
      },
      json: true
    })
    .done(function(result) {
      callback(null, result);
    })
    .fail(function(xhr) {
      callback(JSON.parse(xhr.responseText));
    });
  }
});

再次,我们使用 Ajax 助手将信息发送到后端 API。请求发送到/api/user/login URL。目前,我们不会处理这样的路由。以下代码放在/backend/API.js中,就在/api/user处理程序的上面:

.add('api/user/login', function(req, res) {
  processPOSTRequest(req, function(data) {
    if(!data.email || data.email === '' ||  !validEmail(data.email)) {
      error('Invalid or missing email.', res);
    } else if(!data.password || data.password === '') {
      error('Please enter your password.', res);
    } else {
      getDatabaseConnection(function(db) {
        var collection = db.collection('users');
        collection.find({ 
          email: data.email,
          password: sha1(data.password)
        }).toArray(function(err, result) {
          if(result.length === 0) {
            error('Wrong email or password', res);
          } else {
            var user = result[0];
            delete user._id;
            delete user.password;
            req.session.user = user;
            response({
              success: 'OK',
              user: user
            }, res);
          }
        });
      });
    }
  });
})

processPOSTRequest函数传递了前端发送的POST数据。我们将保持相同的电子邮件和密码验证机制。如果一切正常,我们将检查提供的凭据是否与数据库中的某些帐户匹配。正确的电子邮件和密码的结果是包含用户详细信息的对象。将 ID 和用户密码返回给用户并不是一个好主意。因此,我们将它们从返回的用户对象中删除。到目前为止,还有一件事我们还没有谈论:

req.session.user = user;

这就是我们存储会话的方式。默认情况下,我们没有可用的session对象。有一个模块提供了这个功能。它被称为cookie-session。我们必须将其添加到package.json并在终端中运行npm install命令。安装成功后,我们必须调整server.js文件:

Router
.add('static', Assets)
.add('api', API)
.add(Default);

var session = require('cookie-session');
var checkSession = function(req, res) {
  session({
    keys: ['nodejs-by-example']
  })(req, res, function() {
    process(req, res);
  });
}
var process = function(req, res) {
  Router.check(req.url, [req, res]);
}
var app = http.createServer(checkSession).listen(port,  '127.0.0.1');
console.log("Listening on 127.0.0.1:" + port);

在将应用程序的流程传递给路由之前,我们运行checkSession函数。该方法使用新添加的模块,并通过附加session对象来修补request对象。所有 API 方法都可以访问当前用户的会话。这意味着我们可以通过简单地检查用户是否经过身份验证来保护对后端的每个请求。

你可能还记得,在本章的开头,我们创建了一个全局的userModel对象。它的初始化发生在window.onload处理程序中,这实际上是我们前端的引导点。我们可以在显示 UI 之前向后端询问当前用户是否已登录。这将帮助我们显示适当的导航按钮。因此,这是frontend/js/app.js的更改方式:

window.onload = function() {
  userModel = new UserModel();
  userModel.fetch(function(error, result) {
    // ... router setting
  });
}

userModel函数扩展了基本模型,其中fetch方法将服务器的响应放入模型的value属性中。从前端获取数据意味着发出GET请求,在这种情况下,是对/api/user URL 的GET请求。让我们看看backend/API.js如何处理这个查询:

.add('api/user', function(req, res) {
  switch(req.method) {
    case 'GET':
      if(req.session && req.session.user) {
        response(req.session.user, res);
      } else {
        response({}, res);
      }
    break;
    …

如果用户已登录,我们返回存储在session对象中的内容。如果没有,后端将返回一个空对象。对于客户端来说,这意味着userModel对象的value属性可能根据当前用户的状态有信息,也可能没有。因此,在frontend/js/models/User.js文件中添加一个新的isLogin方法是有意义的:

isLogged: function() {
  return this.get('value.firstName') &&  this.get('value.lastName');
}

添加了前面的函数后,我们可以在客户端代码的任何地方使用userModel.isLogged()调用,从而知道用户是否已登录。这将起作用,因为我们在应用程序的最开始执行了数据获取。例如,导航(frontend/js/views/Navigation.js)需要这些信息以便显示正确的链接:

module.exports = Ractive.extend({
  template: require('../../tpl/navigation'),
  onconstruct: function() {
    this.data.isLogged = userModel.isLogged();
  }
});

管理用户的个人资料

本章的前几节给了我们足够的知识来更新数据库中保存的信息。同样,我们需要在前端创建一个包含 HTML 表单的页面。这里的区别在于,表单的输入字段应该默认填充当前用户的数据。因此,让我们从为/profile URL 添加路由处理程序开始:

Route
.add('profile', function() {
  if(userModel.isLogged()) {
    var p = new Profile();
    showPage(p);
  } else {
    Router.navigate('login');
  }      
})

如果用户未登录,没有理由允许访问此页面。在调用showPage助手之前进行简单的身份验证检查,如果需要,将用户转发到登录页面。

我们需要为Profile控制器准备的模板与我们用于注册的模板相同。我们只需要更改两件事情——我们需要删除email字段,并将按钮的标签从注册更改为更新。删除email字段并不是绝对必要的,但防止用户更改并将其保留为注册时输入的内容是一个好的做法。控制器的样子如下:

module.exports = Ractive.extend({
  template: require('../../tpl/profile'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  onrender: function() {
    var self = this;
    this.set(userModel.get('value'));
    this.on('updateProfile', function() {
      userModel.set('value.firstName', this.get('firstName'));
      userModel.set('value.lastName', this.get('lastName'));
      if(this.get('password') != '') {
        userModel.set('value.password', this.get('password'));
      }
      userModel.save(function(error, result) {
        if(error) {
          self.set('error', error.error);
        } else {
          self.set('error', false);
          self.set('success', 'Profile updated successfully.');
        }
      });
    });
  }
});

updateProfile事件是页面上按钮触发的事件。我们使用表单中的值更新model字段。只有用户在字段中输入了内容,密码才会更改。否则,后端将保留旧值。

我们将调用userModel.save,它执行对 API 的PUT请求。以下是我们在backend/API.js中处理请求的方式:

.add('api/user', function(req, res) {
  switch(req.method) {
    case 'PUT':
      processPOSTRequest(req, function(data) {
        if(!data.firstName || data.firstName === '') {
          error('Please fill your first name.', res);
        } else if(!data.lastName || data.lastName === '') {
          error('Please fill your last name.', res);
        } else {
          getDatabaseConnection(function(db) {
            var collection = db.collection('users');
            if(data.password) {
              data.password = sha1(data.password);
            }
            collection.update(
              { email: req.session.user.email },
              { $set: data }, 
              function(err, result) {
                if(err) {
                  err('Error updating the data.');
                } else {
                  if(data.password) delete data.password;
                  for(var key in data) {
                    req.session.user[key] = data[key];
                  }
                  response({
                    success: 'OK'
                  }, res);
                }
              }
            );
          });
        }
      });
    break;

通常的字段验证又出现了。我们将检查用户是否已输入了名字和姓氏。只有在有相应数据时才会更新密码。重要的是要注意,我们需要用户的电子邮件来更新个人资料。这是我们在 MongoDB 数据库中引用确切记录的方式。由于我们将电子邮件存储在用户的会话中,因此很容易从那里获取。如果一切顺利,我们将更新session对象中的信息。这是必要的,因为前端从那里获取用户的详细信息,如果我们忘记进行这个更改,我们的 UI 将显示旧数据。

摘要

在本章中,我们取得了很大的进展。我们构建了社交网络的核心功能之一——用户管理。我们学会了如何将数据存储在 MongoDB 数据库中,并使用会话对用户进行身份验证。

在下一章中,我们将实现好友管理的功能。任何社交网络的用户都会熟悉这个功能。在下一章的结束时,用户将能够使用我们的应用程序添加好友。

第六章:添加友谊功能

在第五章管理用户中,我们实现了用户注册和登录系统。现在我们在数据库中有用户信息,我们可以继续社交网络中最重要的特征之一——友谊。在本章中,我们将添加以下逻辑:

  • 查找朋友

  • 标记用户为朋友

  • 个人资料页面上显示已连接的用户

查找朋友

查找朋友的过程涉及对我们当前代码库的一系列更改。以下各节将指导我们完成搜索和显示朋友资料。我们将对我们的 REST API 进行一些改进,并定义一个新的控制器和模型。

添加搜索页面

到目前为止,我们已经有了注册、登录和个人资料管理页面。我们将在导航栏中添加一个新链接——查找朋友。为了做到这一点,我们必须按照以下方式更新frontend/tpl/navigation.html文件:

<li class="right"><a on-click="goto:logout">Logout</a></li>
<li class="right"><a on-click="goto:profile">Profile</a></li>
<li class="right"><a on-click="goto:find-friends">Find  friends</a></li>

我们在最后添加的链接将把用户转发到一个新的路由。与其他页面一样,我们的路由器将捕获 URL 更改并触发处理程序。以下是app.js文件的小更新:

Router
.add('find-friends', function() {
  if(userModel.isLogged()) {
    var p = new FindFriends();
    showPage(p);
  } else {
    Router.navigate('login');
  }
})

如果用户未经身份验证,则不应该能够添加新的朋友。我们将在前端应用一个简单的检查,但我们也将保护 API 调用。必须创建一个新的FindFriends控制器。该控制器的作用是显示一个带有输入字段和按钮的表单。用户提交表单后,我们查询数据库,然后显示与输入字符串匹配的用户。以下是控制器的开始部分:

// frontend/js/controllers/FindFriends.js
module.exports = Ractive.extend({
  template: require('../../tpl/find-friends'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  data: {
    loading: false,
    message: '',
    searchFor: '',
    foundFriends: null
  },
  onrender: function() {
    // ...
  }
});

我们保留了相同的NavigationFooter组件。有几个变量及其默认值。loading关键字将用作指示我们正在向 API 发出请求的标志。查找符合某些条件的朋友可能是一个复杂的操作。因此,向用户显示我们正在处理他/她的查询将是一个很好的做法。message属性将用于显示一切正常的确认或报告错误。最后两个变量保留数据。searchFor变量将承载用户输入的字符串,foundFriends将承载后端返回的用户。

让我们检查一下我们需要的 HTML 标记。frontend/tpl/find-friends.html文件包含以下内容:

<header>
  <navigation></navigation>
</header>
<div class="hero">
  <h1>Find friends</h1>
</div>
<form onsubmit="return false;">
  {{#if loading}}
    <p>Loading. Please wait.</p>
  {{else}}
    <label for="friend-name">
      Please, type the name of your friend:
    </label>
    <input type="text" id="friend-name" value="{{friendName}}"/>
    <input type="button" value="Find" on-click="find" />
  {{/if}}
</form>
{{#if foundFriends !== null}}
  <div class="friends-list">
    {{#each foundFriends}}
      <div class="friend-list-item">
        <h2>{{firstName}} {{lastName}}</h2>
        <input type="button" value="Add as a friend"
         on-click="add:{{id}}"/>
      </div>
    {{/each}}
  </div>
{{/if}}
{{#if message !== ''}}
  <div class="friends-list">
    <p>{{{message}}}</p>
  </div>
{{/if}}
<appfooter />

headernavigation部分保持不变。顶部有一个很好放置的标题,后面是我们提到的表单。如果loading标志的值为true,我们将显示加载中,请稍候消息。如果我们没有在查询后端的过程中,那么我们会显示输入字段和按钮。以下截图展示了这在实践中的样子:

添加搜索页面

模板的下一部分呈现了后端发送的用户。它显示他们的姓名和一个添加为朋友按钮。我们将在接下来的页面中看到这个视图的截图。

HTML 标记的最后部分是用于条件显示消息。如果我们为message变量设置了一个值,那么 Ractive.js 将显示div元素并使我们的文本可见。

编写模型

我们有一个用户界面,可以接受用户的输入。现在,我们需要与后端通信,并检索与表单字段值匹配的用户。在我们的系统中,我们通过模型向 API 发出请求。

因此,让我们创建一个新的frontend/js/models/Friends.js模型:

var ajax = require('../lib/Ajax');
var Base = require('./Base');

module.exports = Base.extend({
  data: {
    url: '/api/friends'
  },
  find: function(searchFor, callback) {
    ajax.request({
      url: this.get('url') + '/find',
      method: 'POST',
      data: {
        searchFor: searchFor
      },
      json: true
    })
    .done(function(result) {
      callback(null, result);
    })
    .fail(function(xhr) {
      callback(JSON.parse(xhr.responseText));
    });
  }
});

friendship功能的端点将是/api/friends。要在用户中进行搜索,我们在 URL 后面添加/find。我们将使用POST请求和searchFor变量的值进行搜索。处理结果的代码再次使用lib/Ajax模块,如果一切正常,它将触发指定的回调。

让我们更新调用新创建的模型及其find函数的控制器。在controllers/FindFriends.js文件的顶部,我们将添加一个require语句:

var Friends = require('../models/Friends');

然后,在控制器的render处理程序中,我们将放置以下片段:

onrender: function() {

  var model = new Friends();
  var self = this;

  this.on('find', function(e) {
    self.set('loading', true);
    self.set('message', '');
    var searchFor = this.get('friendName');
    model.find(searchFor, function(err, res) {

      if(res.friends && res.friends.length > 0) {
        self.set('foundFriends', res.friends);
      } else {
        self.set('foundFriends', null);
        self.set('message', 'Sorry, there is no friends matching <strong>' + searchFor + '<strong>');
      }
      self.set('loading', false);
    });
  });

}

find事件由表单中的按钮触发。一旦我们注册了按钮的点击,我们显示loading字符串并清除任何先前显示的消息。我们获取输入字段的值,并要求模型匹配用户。如果有任何潜在的朋友,我们通过为foundFriends变量设置一个值来呈现它们。如果没有,我们会显示一条消息,说明没有符合条件的用户。一旦我们完成了 API 方法的实现,屏幕将如下所示:

编写模型

从数据库中获取朋友

我们需要在backend/API.js中进行的更改是添加一些新路由。但是,在继续查询用户之前,我们将添加一个辅助函数来获取当前用户的配置文件。我们将保留当前用户的姓名和电子邮件在一个session变量中,但这还不够,因为我们想显示更多的用户信息。因此,以下函数从数据库中获取完整的配置文件:

var getCurrentUser = function(callback, req, res) {
  getDatabaseConnection(function(db) {
    var collection = db.collection('users');
    collection.find({ 
      email: req.session.user.email
    }).toArray(function(err, result) {
      if(result.length === 0) {
        error('No such user', res);
      } else {
        callback(result[0]);
      }
    });
  });
};

我们使用用户的电子邮件作为请求的标准。包含配置文件数据的对象作为回调的参数返回。

由于我们已经拥有关于当前用户的所有信息,我们可以继续实现用户搜索。应该回答这类查询的路由如下:

Router
.add('api/friends/find', function(req, res) {
  if(req.session && req.session.user) {
    if(req.method === 'POST') {      
      processPOSTRequest(req, function(data) {
        getDatabaseConnection(function(db) {
          getCurrentUser(function(user) {
            findFriends(db, data.searchFor, user.friends || []);
          }, req, res);          
        });
      });
    } else {
      error('This method accepts only POST requests.', res);
    }
  } else {
    error('You must be logged in to use this method.', res);
  }
})

第一个if子句确保此路由仅对已注册并已登录的用户可访问。此方法仅接受POST请求。其余部分获取searchFor变量并调用findFriends函数,可以实现如下:

var findFriends = function(db, searchFor, currentFriends) {
  var collection = db.collection('users');
  var regExp = new RegExp(searchFor, 'gi');
  var excludeEmails = [req.session.user.email];
  currentFriends.forEach(function(value, index, arr) {
    arr[index] = ObjectId(value);
  });
  collection.find({
    $and: [
      {
        $or: [
          { firstName: regExp },
          { lastName: regExp }
        ]
      },
      { email: { $nin: excludeEmails } },
      { _id: { $nin: currentFriends } }
    ]
  }).toArray(function(err, result) {
    var foundFriends = [];
    for(var i=0; i<result.length; i++) {
      foundFriends.push({
        id: result[i]._id,
        firstName: result[i].firstName,
        lastName: result[i].lastName
      });
    };
    response({
      friends: foundFriends
    }, res);
  });
}

我们系统中的用户将他们的名字分成两个变量——firstNamelastName。当用户在搜索表单字段中输入时,我们无法确定用户可能指的是哪一个。因此,我们将在数据库中搜索这两个属性。我们还将使用正则表达式来确保我们的搜索不区分大小写。

MongoDB 数据库提供了执行复杂查询的语法。在我们的情况下,我们想获取以下内容:

  • 其名字的第一个或最后一个与客户端发送的条件匹配的用户。

  • 与当前用户已添加的朋友不同的用户。

  • 与当前用户不同的用户。我们不希望向用户提供与他们自己的配置文件的友谊。

$nin变量表示值不在提供的数组中。我们将排除当前用户的电子邮件地址。值得一提的一个小细节是,MongoDB 将用户的 ID 存储在 12 字节的 BSON 类型中。它们不是明文。因此,在发送查询之前,我们需要使用ObjectID函数。该方法可以通过相同的mongodb模块访问——var ObjectId = require('mongodb').ObjectID

当数据库驱动程序返回满足我们条件的记录时,我们会过滤信息并用适当的 JSON 文件进行响应。我们不会发送用户的整个配置文件,因为我们不会使用所有数据。姓名和 ID 就足够了。

将该新路由添加到 API 将使朋友搜索起作用。现在,让我们添加逻辑,将配置文件附加到当前用户。

将用户标记为朋友

如果我们检查新页面的 HTML 模板,我们会发现每个呈现的用户都有一个按钮,可以触发add事件。让我们在我们的控制器中处理这个,并在我们的模型中运行一个类似于查找朋友的过程的函数:

this.on('add', function(e, id) {
  this.set('loading', true);
  model.add(id, function(err, res) {
    self.set('foundFriends', null);
    if(err) {
      self.set('message', 'Operation failed.');
    } else if(res.success === 'OK') {
      self.set('message', 'Operation successful.');
    }
    self.set('loading', false);
  });
});

我们使用相同的技术来处理loading标志。我们将在下面的代码中介绍的模型方法接受用户的id值,并报告链接是否成功。我们需要清除foundFriends数组。否则,当前用户可能会点击同一个个人资料两次。另一个选项是只删除被点击的项目,但这涉及更多的代码。

models/Friends.js中的添加如下:

add: function(id, callback) {
  ajax.request({
    url: this.get('url') + '/add',
    method: 'POST',
    data: {
      id: id
    },
    json: true
  })
  .done(function(result) {
    callback(null, result);
  })
  .fail(function(xhr) {
    callback(JSON.parse(xhr.responseText));
  });
}

addfind方法之间的唯一区别在于,在第一个方法中,我们发送了searchFor,而在第二个方法中,我们发送了id参数。错误处理和结果响应是相同的。当然,端点也经过了调整。

我们展示个人资料,用户点击其中一些,我们的模型向后端发送POST请求。现在是时候实现标记用户为朋友的 API 路由了。为此,我们将通过添加一个名为friends的新数组来更新当前用户的个人资料,其中包含对朋友个人资料的引用:

.add('api/friends/add', function(req, res) {
  if(req.session && req.session.user) {
    if(req.method === 'POST') {
      var friendId;
      var updateUserData = function(db, friendId) {
        var collection = db.collection('users');
        collection.update(
          { email: req.session.user.email },
          { $push: { friends: friendId } }, 
          done
        );
      };
      var done = function(err, result) {
        if(err) {
          error('Error updating the data.', res);
        } else {                
          response({
            success: 'OK'
          }, res);
        }
      };
      processPOSTRequest(req, function(data) {
        getDatabaseConnection(function(db) {
          updateUserData(db, data.id);
        });
      });
    } else {
      error('This method accepts only POST requests.', res);
    }
  } else {
    error('You must be logged in to use this method.', res);
  }
})

前面的方法再次受到保护。我们需要一个经过身份验证的用户和进行POST请求。在获取朋友的 ID 之后,我们使用$push运算符来创建(如果不存在)并填充friends数组。done函数的唯一工作是向浏览器发送响应。

本章的下一步是在用户的个人资料页面上显示添加的朋友。

在个人资料页面显示链接的用户

同样,我们将从更新我们的模板开始。在上一章中,我们创建了frontend/tpl/profile.html。它包含一个我们用于个人资料更新的表单。让我们在它之后添加以下代码:

{{#if friends.length > 0}}
  <div class="hero">
    <h1>Friends</h1>
  </div>
  <div class="friends-list">
    {{#each friends:index}}
      <div class="friend-list-item">
        <h2>{{friends[index].firstName}}  {{friends[index].lastName}}</h2>
      </div>
    {{/each}}
  </div>
{{/if}}

如果 Ractive 组件有一个friends属性,那么我们将渲染一个用户列表。页面将显示用户的名称,看起来像下一个截图:

在个人资料页面显示链接的用户

渲染页面的控制器也应该更新。我们应该使用在前几节中开发的相同的models/Friends模型。这就是为什么我们需要在顶部添加var Friends = require('../models/Friends');。另外三行代码将使记录的获取工作。我们将在控制器的onrender处理程序中添加它们,如下所示:

// controllers/Profile.js
onrender: function() {

  ...

  var friends = new Friends();
  friends.fetch(function(err, result) {
    self.set('friends', result.friends);
  });
}

我们在控制器中还需要做的另一个小的添加是定义friends变量的默认值,如下所示:

  data: {
    friends: []
  },
  onrender: function() {
  ...
  }

这一次,我们不打算更新模型。我们将使用默认的fetch方法,向/api/friends端点发送GET请求。唯一需要做的是在backend/API.js文件中进行添加。我们需要一个路由来找到当前用户的朋友并返回它们:

.add('api/friends', function(req, res) {
  if(req.session && req.session.user) {
    getCurrentUser(function(user) {
      if(!user.friends || user.friends.length === 0) {
        return response({ friends: [] }, res);
      }
      user.friends.forEach(function(value, index, arr) {
        arr[index] = ObjectId(value);
      });
      getDatabaseConnection(function(db) {
        var collection = db.collection('users');
        collection.find({ 
          _id: { $in: user.friends }
        }).toArray(function(err, result) {
          result.forEach(function(value, index, arr) {
            arr[index].id = value.id;
            delete arr[index].password;
            delete arr[index].email;
            delete arr[index]._id;
          });
          response({
            friends: result
          }, res);
        });
      });
    }, req, res);
  } else {
    error('You must be logged in to use this method.', res);
  }
})

这是我们使用getCurrentUser辅助函数的第二个地方。我们没有用户的个人资料。因此,我们需要向 MongoDB 服务器发出一个额外的请求。在这种情况下,$in运算符对我们有帮助。再次,在将它们与查询一起发送之前,我们需要将 ID 转换为适当的格式。最后,在向浏览器响应之前,我们删除敏感信息,如 ID、密码和电子邮件。前端将收到一个包含当前登录用户的所有朋友的漂亮数组。

总结

在本章中,我们使得用户之间创建链接成为可能。我们加强了对前端控制器和模型的了解。我们通过一些复杂的数据库查询扩展了项目的 API,添加了一些新的方法。

在下一章中,我们将学习如何使用 Node.js 上传内容。与其他流行的社交网络一样,发布的信息将显示为用户的动态。

第七章:发布内容

第六章,“添加友谊功能”,是关于添加友谊功能的。在社交网络中与其他用户建立联系的能力很重要。然而,更重要的是提供一个生成内容的接口。在本章中,我们将实现内容创建背后的逻辑。我们将涵盖以下主题:

  • 发布和存储文本

  • 显示用户的动态

  • 发布文件

发布和存储文本

与前几章一样,我们有一个需要在应用程序的前端和后端部分都进行更改的功能。我们需要一个 HTML 表单,接受用户的文本,一个处理与后端通信的新模型,当然,还有 API 的更改。让我们从更新我们的主页开始。

添加一个发布文本消息的表单

我们有一个显示简单标题的主页。让我们使用它,并添加一个<textarea>标签来将内容发送到 API。在本章的后面,我们将使用同一个页面来显示用户的动态。让我们用以下标记替换孤独的<h1>标签:

{{#if posting === true}}
  <form enctype="multipart/form-data" method="post">
    <h3>What is on your mind?</h3>
    {{#if error && error != ''}}
      <div class="error">{{{error}}}</div>
    {{/if}}
    {{#if success && success != ''}}
      <div class="success">{{{success}}}</div>
    {{/if}}
    <label for="text">Text</label>
    <textarea value="{{text}}"></textarea>
    <input type="file" name="file" />
    <input type="button" value="Post" on-click="post" />
  </form>
{{else}}
  <h1>Node.js by example</h1>
{{/if}}

我们仍然有标题,但只有当posting变量等于false时才显示。在接下来的部分中,我们将更新主页的控制器,我们将使用posting来保护内容的表单。在某些情况下,我们不希望使<textarea>可见。

请注意,我们有两个块来显示消息。如果在发布过程中出现错误,第一个块将可见,当一切顺利时,第二个块将可见。表单的其余部分是所需的用户界面——文本区域、输入文件字段和一个按钮。按钮会触发一个发布事件,我们将在控制器中捕获到。

介绍内容模型

我们肯定需要一个模型来管理与 API 的通信。让我们创建一个新的models/Content.js文件,并将以下代码放在那里:

var ajax = require('../lib/Ajax');
var Base = require('./Base');

module.exports = Base.extend({
  data: {
    url: '/api/content'
  },
  create: function(content, callback) {
    var self = this;
    ajax.request({
      url: this.get('url'),
      method: 'POST',
      data: {
        text: content.text
      },
      json: true
    })
    .done(function(result) {
      callback(null, result);
    })
    .fail(function(xhr) {
      callback(JSON.parse(xhr.responseText));
    });
  }
});

该模块扩展了相同的models/Base.js类,它类似于我们系统中的其他模型。需要lib/Ajax.js模块,因为我们将进行 HTTP 请求。我们应该熟悉其余的代码。通过将文本作为参数传递给create函数,向/api/content发出POST请求。

当我们到达文件发布时,该模块将被更新。要创建仅基于文本的记录,这就足够了。

更新主页的控制器

现在我们有了一个合适的模型和形式,我们准备调整主页的控制器。如前所述,posting变量控制表单的可见性。它的值将默认设置为true,如果用户未登录,我们将把它改为false。每个 Ractive.js 组件都可以有一个data属性。它表示所有内部变量的初始状态:

// controllers/Home.js
module.exports = Ractive.extend({
  template: require('../../tpl/home'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  data: {
    posting: true
  }
});

现在,让我们向onrender处理程序添加一些逻辑。这是我们组件的入口点。我们将首先检查当前用户是否已登录:

onrender: function() {
  if(userModel.isLogged()) {
    // ...
  } else {
    this.set('posting', false);
  }
}

从第五章,“管理用户”中,我们知道userModel是一个全局对象,我们可以用它来检查当前用户的状态。如前所述,如果我们有一个未经授权的访问者,我们必须将posting设置为false

下一个逻辑步骤是处理表单中的内容并向 API 提交请求。我们将使用新创建的ContentModel类,如下所示:

var ContentModel = require('../models/Content');
var model = new ContentModel();
var self = this;
this.on('post', function() {
  model.create({
    text: this.get('text')
  }, function(error, result) {
    self.set('text', '');
    if(error) {
      self.set('error', error.error);
    } else {
      self.set('error', false);
      self.set('success', 'The post is saved successfully.<br />What about adding another one?');
    }
  });
});

一旦用户在表单中按下按钮,我们的组件就会触发一个post事件。然后我们将捕获事件并调用模型的create方法。给用户一个合适的响应很重要,所以我们用self.set('text', '')清除文本字段,并使用本地的errorsuccess变量来指示请求的状态。

在数据库中存储内容

到目前为止,我们有一个 HTML 表单,它向 API 提交 HTTP 请求。在本节中,我们将更新我们的 API,以便我们可以在数据库中存储文本内容。我们模型的端点是/api/content。我们将添加一个新的路由,并通过允许只有授权用户访问来保护它:

// backend/API.js
.add('api/content', function(req, res) {
  var user;
  if(req.session && req.session.user) {
    user = req.session.user;
  } else {
    error('You must be logged in in order to use this method.', res);
  }
})

我们将创建一个包含访客会话数据的user本地变量。发送到数据库的每个帖子都应该有一个所有者。因此,有一个快捷方式到用户的个人资料是很好的。

同样的/api/content目录也将用于获取帖子。同样,我们将使用req.method属性来查找请求的类型。如果是GET,我们需要从数据库中获取帖子并将它们发送到浏览器。如果是POST,我们需要创建一个新的条目。以下是将用户的文本发送到数据库的代码:

switch(req.method) {
  case 'POST':
    processPOSTRequest(req, function(data) {
      if(!data.text || data.text === '') {
        error('Please add some text.', res);
      } else {
        getDatabaseConnection(function(db) {
          getCurrentUser(function(user) {
            var collection = db.collection('content');
            data.userId = user._id.toString();
            data.userName = user.firstName + ' ' + user.lastName;
            data.date = new Date();
            collection.insert(data, function(err, docs) {
              response({
                success: 'OK'
              }, res);
            });
          }, req, res);
        });
      }
    });
  break;
};

浏览器发送的数据作为POST变量传递。同样,我们需要processPOSTRequest的帮助来访问它。如果没有.text或者它是空的,API 将返回一个错误。如果一切正常并且文本消息可用,我们将继续建立数据库连接。我们还会获取当前用户的整个个人资料。我们的社交网络中的帖子将与以下附加属性一起保存:

  • userId:这代表了记录的创建者。我们将在生成动态时使用这个属性。

  • userName:我们不想为我们显示的每一篇帖子都调用getCurrentUser。因此,所有者的名称直接与文本一起存储。值得一提的是,在某些情况下,这样的调用是必要的。例如,在更改用户的名称时,将需要这些调用。

  • date:我们应该知道数据的创建日期。这对于数据的排序或过滤是有用的。

最后,我们调用collection.insert,这实际上将条目存储在数据库中。

在下一节中,我们将看到如何检索创建的内容并将其显示给用户。

显示用户的动态

现在,每个用户都能够在我们的数据库中存储消息。让我们继续通过在浏览器中显示记录来展示。我们将首先向获取帖子的 API 添加逻辑。这将很有趣,因为你不仅应该获取特定用户发送的消息,还应该获取他/她的朋友发送的消息。我们使用POST方法来创建内容。接下来的行将处理GET请求。

首先,我们将以以下方式获取用户的朋友的 ID:

case 'GET':
  getCurrentUser(function(user) {
    if(!user.friends) {
      user.friends = [];
    }
    // ...
break;

在上一章中,我们实现了友谊功能,并直接在用户的个人资料中保留了用户的朋友的 ID。friends数组正是我们需要的,因为我们的社交网络中的帖子是通过它们的 ID 与用户的个人资料相关联的。

下一步是建立与数据库的连接,并仅查询与特定 ID 匹配的记录,如下所示:

case 'GET':
  getCurrentUser(function(user) {
    if(!user.friends) {
      user.friends = [];
    }
    getDatabaseConnection(function(db) {
      var collection = db.collection('content');
      collection.find({ 
        $query: {
          userId: { $in: [user._id.toString()].concat(user.friends) }
        },
        $orderby: {
          date: -1
        }
      }).toArray(function(err, result) {
        result.forEach(function(value, index, arr) {
          arr[index].id = ObjectId(value.id);
          delete arr[index].userId;
        });
        response({
          posts: result
        }, res);
      });
    });
  }, req, res);
break;

我们将从content集合中读取记录。find方法接受一个具有$query$orderby属性的对象。在第一个属性中,我们将放入我们的条件。在这种特殊情况下,我们想要获取所有属于friends数组的记录的 ID。为了创建这样的查询,我们需要$in运算符。它接受一个数组。除了用户的朋友的帖子,我们还需要显示用户的帖子。因此,我们将创建一个数组,其中包含一个项目——当前用户的 ID,并将其与friends连接起来,如下所示:

[user._id.toString()].concat(user.friends)

成功查询后,userId属性将被删除,因为它不再需要。在content集合中,我们保留消息的文本和所有者的名称。最后,记录将附加到posts属性上发送。

通过在前面的代码中添加的内容,我们的后端返回了当前用户和他们的朋友发布的帖子。我们所要做的就是更新我们主页的控制器并使用 API 的方法。在监听post事件的代码之后,我们添加以下代码:

var getPosts = function() {
  model.fetch(function(err, result) {
    if(!err) {
      self.set('posts', result.posts);
    }
  });
};
getPosts();

调用fetch方法触发对模型端点/api/content的 API 的GET请求。这个过程被包装在一个函数中,因为当创建新帖子时会发生相同的操作。正如我们已经知道的,如果model.create成功,就会触发回调。我们将在那里添加getPosts(),这样用户就可以在动态中看到他/她的最新帖子:

// frontend/js/controllers/Home.js
model.create(formData, function(error, result) {
  self.set('text', '');
  if(error) {
    self.set('error', error.error);
  } else {
    self.set('error', false);
    self.set('success', 'The post is saved  successfully.<br />What about adding another one?');
    getPosts();
  }
});

getPosts函数产生的结果是存储在名为posts的本地变量中的对象列表。同样的变量可以在 Ractive.js 模板中访问。我们需要遍历数组中的项目,并在屏幕上显示信息,如下所示:

// frontend/tpl/home.html
<header>
  <navigation></navigation>
</header>
<div class="hero">
  {{#if posting === true}}
    <form enctype="multipart/form-data" method="post">
      ...
    </form>
    {{#each posts:index}}
      <div class="content-item">
        <h2>{{posts[index].userName}}</h2>
        {{posts[index].text}}
      </div>
    {{/each}}
  {{else}}
    <h1>Node.js by example</h1>
  {{/if}}
</div>
<appfooter />

在表单之后,我们使用each操作符来显示帖子的作者和文本。

在这一点上,我们网络中的用户将能够创建和浏览以文本块形式的消息。在下一节中,我们将扩展到目前为止编写的功能,并使上传图像与文本一起成为可能。

发布文件

我们正在构建一个单页面应用程序。这类应用程序的特点之一是所有操作都在不重新加载页面的情况下进行。上传文件而不改变页面一直是棘手的。过去,我们使用涉及隐藏 iframe 或小型 Flash 应用程序的解决方案。幸运的是,当 HTML5 出现时,它引入了FormData接口。

流行的 Ajax 是由XMLHttpRequest对象实现的。2005 年,Jesse James Garrett 创造了“Ajax”这个术语,我们开始使用它在 JavaScript 中进行 HTTP 请求。以以下方式执行GETPOST请求变得很容易:

var http = new XMLHttpRequest();
var url = "/api/content";
var params = "text=message&author=name";
http.open("POST", url, true);

http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
http.setRequestHeader("Content-length", params.length);
http.setRequestHeader("Connection", "close");

http.onreadystatechange = function() {
  if(http.readyState == 4 && http.status === 200) {
    alert(http.responseText);
  }
}

http.send(params);

前面的代码生成了一个正确的POST请求,甚至设置了正确的标头。问题在于参数被表示为字符串。形成这样的字符串需要额外的工作。发送文件也很困难。这可能是相当具有挑战性的。

FormData 接口解决了这个问题。我们创建一个对象,它是表示表单字段及其值的键/值对集合。然后,我们将这个对象传递给XMLHTTPRequest类的send方法:

var formData = new FormData();
var fileInput = document.querySelector('input[type="file"]');
var url = '/api/content';

formData.append("username", "John Black");
formData.append("id", 123456);
formData.append("userfile", fileInput.files[0]);

var request = new XMLHttpRequest();
request.open("POST", url);
request.send(formData);

我们所要做的就是使用append方法并指定file类型的input DOM 元素。其余工作由浏览器完成。

为了提供上传文件的功能,我们需要添加文件选择的 UI 元素。以下是home.html模板中表单的样子:

<form enctype="multipart/form-data" method="post">
  <h3>What is on your mind?</h3>
  {{#if error && error != ''}}
    <div class="error">{{error}}</div>
  {{/if}}
  {{#if success && success != ''}}
    <div class="success">{{{success}}}</div>
  {{/if}}
  <label for="text">Text</label>
  <textarea value="{{text}}"></textarea>
  <input type="file" name="file" />
  <input type="button" value="Post" on-click="post" />
</form>

相同的代码,但是有一个新的input元素,类型等于file。到目前为止,我们的控制器中发送POST请求的实现并没有使用FormData接口。让我们改变这一点,并更新controllers/Home.js文件:

this.on('post', function() {
  var files = this.find('input[type="file"]').files;
  var formData = new FormData();
  if(files.length > 0) {
    var file = files[0];
    if(file.type.match('image.*')) {
      formData.append('files', file, file.name);
    }
  }
  formData.append('text', this.get('text'));
  model.create(formData, function(error, result) {
    self.set('text', '');
    if(error) {
      self.set('error', error.error);
    } else {
      self.set('error', false);
      self.set('success', 'The post is saved  successfully.<br />What about adding another one?');
      getPosts();
    }
  });
});

代码已经改变。因此,代码创建了一个新的FormData对象,并使用append方法收集新帖子所需的信息。我们确保用户选择的文件被附加。默认情况下,HTML 输入只提供选择一个文件。但是,我们可以添加multiple属性,浏览器将允许我们选择多个文件。值得一提的是,我们过滤所选文件,并且只使用图像。

经过最新的更改,我们模型的create方法接受FormData对象而不是普通的 JavaScript 对象。因此,我们也必须更新模型:

// models/Content.js
create: function(formData, callback) {
  var self = this;
  ajax.request({
    url: this.get('url'),
    method: 'POST',
    formData: formData,
    json: true
  })
  .done(function(result) {
    callback(null, result);
  })
  .fail(function(xhr) {
    callback(JSON.parse(xhr.responseText));
  });
}

data属性被formData属性替换。现在我们知道前端将选定的文件发送到 API。但是,我们没有处理multipart/form-data类型的POST数据的代码。通过POST请求发送的文件的处理并不简单,processPOSTRequest在这种情况下无法完成任务。

Node.js 拥有一个庞大的社区,有成千上万的模块可用。formidable模块是我们要使用的。它有一个相当简单的 API,并且处理包含文件的请求。文件上传过程中,formidable会将文件保存在服务器硬盘的特定位置。然后,我们会收到资源的路径。最后,我们必须决定如何处理它。

backend/API.js文件中,应用流程分为GETPOST请求。我们将更新POST情况的一个重要部分。以下行包含了formidable的初始化:

case 'POST':
  var formidable = require('formidable');
  var uploadDir = __dirname + '/../static/uploads/';
  var form = new formidable.IncomingForm();
  form.multiples = true;
  form.parse(req, function(err, data, files) {
    // ...
  });
break;

正如我们之前提到的,该模块将上传的文件保存在硬盘上的临时文件夹中。uploadDir变量包含了用户图片的更合适的位置。传递给formidableparse函数的回调在data参数中接收普通文本字段,并在files中上传图像。

为了避免嵌套 JavaScript 回调的长链条,我们将一些逻辑提取到函数定义中。例如,将文件从temporary移动到static文件夹可以按以下方式执行:

var processFiles = function(userId, callback) {
  if(files.files) {
    var fileName = userId + '_' + files.files.name;
    var filePath = uploadDir + fileName;
    fs.rename(files.files.path, filePath, function() {
      callback(fileName);
    });
  } else {
    callback();
  }
};

我们不想混合不同用户的文件。因此,我们将使用用户的 ID 并创建他/她自己的文件夹。还有一些其他问题可能需要我们处理。例如,我们可以为每个文件创建子文件夹,以防止已上传资源的覆盖。然而,为了尽可能保持代码简单,我们将在这里停止。

以下是将帖子保存到数据库的完整代码:

case 'POST':
  var uploadDir = __dirname + '/../static/uploads/';
  var formidable = require('formidable');
  var form = new formidable.IncomingForm();
  form.multiples = true;
  form.parse(req, function(err, data, files) {
    if(!data.text || data.text === '') {
      error('Please add some text.', res);
    } else {
      var processFiles = function(userId, callback) {
        if(files.files) {
          var fileName = userId + '_' + files.files.name;
          var filePath = uploadDir + fileName;
          fs.rename(files.files.path, filePath, function(err) {
            if(err) throw err;
            callback(fileName);
          });
        } else {
          callback();
        }
      };
      var done = function() {
        response({
          success: 'OK'
        }, res);
      }
      getDatabaseConnection(function(db) {
        getCurrentUser(function(user) {
          var collection = db.collection('content');
          data.userId = user._id.toString();
          data.userName = user.firstName + ' ' + user.lastName;
          data.date = new Date();
          processFiles(user._id, function(file) {
            if(file) {
              data.file = file;
            }
            collection.insert(data, done);
          });
        }, req, res);
      });
    }
  });
break;

我们仍然需要与数据库建立连接并获取当前用户的个人资料。这里的不同之处在于,我们向存储在 MongoDB 中的对象附加了一个新的file属性。

最后,我们必须更新主页的模板,以便显示上传的文件:

{{#each posts:index}}
  <div class="content-item">
    <h2>{{posts[index].userName}}</h2>
    {{posts[index].text}}
    {{#if posts[index].file}}
    <img src="img/{{posts[index].file}}" />
    {{/if}}
  </div>
{{/each}}

现在,each循环检查是否有文件与帖子文本一起传输。如果有,它会显示一个显示图像的img标签。通过这最后的添加,我们社交网络的用户将能够创建由文本和图片组成的内容。

总结

在本章中,我们为我们的应用程序做了一些非常重要的事情。通过扩展我们的后端 API,我们实现了内容的创建和传递。前端也进行了一些更改。

在下一章中,我们将继续添加新功能。我们将使创建品牌页面和活动成为可能。

第八章:创建页面和事件

第七章,发布内容,涵盖了发布内容。我们为用户提供了一个界面,可以将文本和图像发送到我们的数据库。稍后,这些资源将显示为主页上的消息源。在本章中,我们将学习如何创建页面和附加到这些页面的事件。以下是我们将要遵循的计划:

  • 重构 API

  • 添加创建页面的表单

  • 在数据库中创建记录

  • 显示当前添加的页面

  • 显示特定页面

  • 在页面上发布评论

  • 显示评论

  • 管理附加到特定页面的事件

重构 API

如果您检查上一章结束时得到的文件,您会发现backend/API.js文件非常大。随着工作的进行,它将变得越来越难处理。我们将重构系统的这一部分。

我们有一堆辅助方法,它们在整个路由处理程序中都被使用。诸如responseerrorgetDatabaseConnection之类的函数可以放在一个外部模块中。我们将在backend目录下创建一个新的api文件夹。新创建的helpers.js文件将承载所有这些实用函数:

// backend/api/helpers.js
var MongoClient = require('mongodb').MongoClient;
var querystring = require('querystring');
var database;

var response = function(result, res) { ... };
var error = function(message, res) { ... };
var getDatabaseConnection = function(callback) { ... };
var processPOSTRequest = function(req, callback) { ... };
var validEmail = function(value) { ... };
var getCurrentUser = function(callback, req, res) { ... };

module.exports = {
  response: response,
  error: error,
  getDatabaseConnection: getDatabaseConnection,
  processPOSTRequest: processPOSTRequest,
  validEmail: validEmail,
  getCurrentUser: getCurrentUser
};

我们将跳过函数的实现,以免用已经看到的代码膨胀本章。我们还复制了一些方法使用的变量。

我们重构的下一步是将所有路由处理程序提取到它们自己的方法中。到目前为止,文件的结构如下:

var Router = require('../frontend/js/lib/router')();
Router
.add('api/version', function(req, res) { ... })
.add('api/user/login', function(req, res) { ... })

整个结构是一堆路由定义及其相应的处理程序。我们经常有一个switch语句来检查请求的类型。实际上,每个函数(reqres)都可以由一个独立的模块表示。再次强调,我们不会粘贴所有创建的文件的内容,但我们会谈论最终结果。重构后,我们将有以下结构:

重构 API

API.js中的行数显著减少。现在,我们只有路由的定义及其处理程序:

var Router = require('../frontend/js/lib/router')();
Router
.add('api/version', require('./api/version'))
.add('api/user/login', require('./api/user-login'))
.add('api/user/logout', require('./api/user-logout'))
.add('api/user', require('./api/user'))
.add('api/friends/find', require('./api/friends-find'))
.add('api/friends/add', require('./api/friends-add'))
.add('api/friends', require('./api/friends'))
.add('api/content', require('./api/content'))
.add('api/pages/:id', require('./api/pages'))
.add('api/pages', require('./api/pages'))
.add(require('./api/default'));
module.exports = function(req, res) {
  Router.check(req.url, [req, res]);
}

新文件导出的函数仍然是相同的。您唯一需要考虑的是辅助函数。您必须在所有新模块中提供它们。例如,friends.js文件包含以下内容:

var ObjectId = require('mongodb').ObjectID;
var helpers = require('./helpers');
var response = helpers.response;
var error = helpers.error;
var getDatabaseConnection = helpers.getDatabaseConnection;
var getCurrentUser = helpers.getCurrentUser;

module.exports = function(req, res) {
  ...
}

查看本章附带的文件以获取完整的源代码。

添加创建页面的表单

我们社交网络中的每个用户都应该能够浏览和创建页面。这是一个全新的功能。因此,我们需要一个新的路由和控制器。

  1. 让我们从更新frontend/js/app.js开始,如下所示:
.add('pages', function() {
  if(userModel.isLogged()) {
    var p = new Pages();
    showPage(p);
  } else {
    Router.navigate('login');
  }    
})
.add(function() {
  Router.navigate('home');
})
  1. 就在默认处理程序的上方,我们将注册一个路由,创建一个名为Pages的新控制器的实例。我们将确保访问者在看到页面之前已登录。在同一文件中,顶部我们将添加var Pages = require('./controllers/Pages');

  2. 让我们深入研究controllers/Page.js文件,看看如何引导控制器:

module.exports = Ractive.extend({
  template: require('../../tpl/pages'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  data: { },
  onrender: function() { }
});
  1. onrender函数仍然是空的,但我们将在接下来的几节中填充它。支持此页面的模板位于frontend/tpl/pages.html中:
<header>
  <navigation></navigation>
</header>
<div class="hero">
  <form enctype="multipart/form-data" method="post">
    <h3>Add a new page</h3>
    {{#if error && error != ''}}
      <div class="error">{{error}}</div>
    {{/if}}
    {{#if success && success != ''}}
      <div class="success">{{{success}}}</div>
    {{/if}}
    <label>Title</label>
    <textarea value="{{title}}"></textarea>
    <label>Description</label>
    <textarea value="{{description}}"></textarea>
    <input type="button" value="Create" on-click="create" />
  </form>
</div>
<appfooter />

代码看起来类似于上一章中创建 UI 以添加内容时使用的代码。我们有成功和错误消息的占位符。有两个变量,titledescription,以及一个分派create事件的按钮。

在数据库中创建记录

让我们继续处理用户按下创建按钮的情况。用户执行此操作后,我们必须获取文本区域的内容并向后端提交请求。因此,我们需要一个新的模型。让我们称之为Pages.js并将其保存在models目录下:

// frontend/js/models/Pages.js
var ajax = require('../lib/Ajax');
var Base = require('./Base');
module.exports = Base.extend({
  data: {
    url: '/api/pages'
  },
  create: function(formData, callback) {
    var self = this;
    ajax.request({
      url: this.get('url'),
      method: 'POST',
      formData: formData,
      json: true
    })
    .done(function(result) {
      callback(null, result);
    })
    .fail(function(xhr) {
      callback(JSON.parse(xhr.responseText));
    });
  }
});

我们已经在上一章中讨论了FormData接口。我们将要使用的 API 端点是/api/pages。这是我们将发送POST请求的 URL。

现在我们已经显示了表单,并且模型已准备好进行后端通信,我们可以继续在控制器中编写代码。onrender处理程序是监听create事件的正确位置:

onrender: function() {
  var model = new PagesModel();
  var self = this;
  this.on('create', function() {
    var formData = new FormData();
    formData.append('title', this.get('title'));
    formData.append('description', this.get('description'));
    model.create(formData, function(error, result) {
      if(error) {
        self.set('error', error.error);
      } else {
        self.set('title', '');
        self.set('description', '');
        self.set('error', false);
        self.set('success', 'The page was created successfully.
      }
    });
  });
}

模型的初始化在顶部。在获取用户填写的数据之后,我们将调用模型的create方法,并在之后处理响应。如果出现问题,我们的应用程序会显示错误消息。

这一部分的最后一步是更新 API,以便我们可以将数据保留在我们的数据库中。仍然没有与/api/pages匹配的路由。因此,让我们添加一个:

// backend/API.js
.add('api/pages', require('./api/pages'))
.add(require('./api/default'));

我们重构了 API,以便处理请求的代码转到新的/backend/api/pages.js文件。在前几行中,有我们的辅助方法的快捷方式:

var ObjectId = require('mongodb').ObjectID;
var helpers = require('./helpers');
var response = helpers.response;
var error = helpers.error;
var getDatabaseConnection = helpers.getDatabaseConnection;
var getCurrentUser = helpers.getCurrentUser;

这是在新的pages集合中创建新记录的代码。它可能看起来有点长,但其中的大部分内容已经在第七章中涵盖了,发布内容

module.exports = function(req, res) {
  var user;
  if(req.session && req.session.user) {
    user = req.session.user;
  } else {
    error('You must be logged in in order to use this  method.', res);
    return;
  }
  switch(req.method) {
    case 'GET': break;
    case 'POST':
      var formidable = require('formidable');
      var form = new formidable.IncomingForm();
      form.parse(req, function(err, formData, files) {
        var data = {
          title: formData.title,
          description: formData.description
        };
        if(!data.title || data.title === '') {
          error('Please add some title.', res);
        } else if(!data.description || data.description === '') {
          error('Please add some description.', res);
        } else {
          var done = function() {
            response({
              success: 'OK'
            }, res);
          }
          getDatabaseConnection(function(db) {
            getCurrentUser(function(user) {
              var collection = db.collection('pages');
              data.userId = user._id.toString();
              data.userName = user.firstName + ' ' + user.lastName;
              data.date = new Date();
              collection.insert(data, done);
            }, req, res);
          });
        }
      });
    break;
  };
}

创建和浏览页面是仅供已登录用户使用的功能。导出函数的前几行检查当前访问者是否有有效的会话。前端发送一个不带文件的POST请求,但我们仍然需要formidable模块,因为它具有良好的编程接口并且易于使用。每个页面都应该有标题和描述,我们将检查它们是否存在。如果一切正常,我们将使用众所周知的getDatabaseConnection函数在数据库中创建新记录。

显示当前添加的页面

很高兴我们开始将创建的页面保存在数据库中。但是,向用户显示页面,以便他们可以访问并添加评论也将是很好的。为了做到这一点,我们必须修改我们的 API,以便返回页面信息。如果您查看前面的代码,您会发现有一个留空的GET情况。以下代码获取所有页面,按日期排序,并将它们发送到浏览器:

case 'GET':
  getDatabaseConnection(function(db) {
    var collection = db.collection('pages');
    collection.find({ 
      $query: { },
      $orderby: {
        date: -1
      }
    }).toArray(function(err, result) {
      result.forEach(function(value, index, arr) {
        arr[index].id = value._id;
        delete arr[index].userId;
      });
      response({
        pages: result
      }, res);
    });
  });
break;

在将 JSON 对象发送到前端之前,我们将删除创建者的 ID。用户的名称已经存在,将这些 ID 仅保留在后端是一个很好的做法。

快速重启后,当我们访问/api/pages时,Node.js 服务器将返回创建的页面。让我们继续前进,并更新我们应用程序客户端的controllers/Pages.js文件。在onrender处理程序中,我们将追加以下代码:

var getPages = function() {
  model.fetch(function(err, result) {
    if(!err) {
      self.set('pages', result.pages);
    } else {
      self.set('error', err.error);
    }
  });
};
getPages();

我们将新添加的逻辑封装在一个函数中,因为当创建新页面时,我们必须经历相同的事情。模型完成了大部分工作。我们将简单地将对象数组分配给pages变量。此变量在组件的模板—frontend/tpl/pages.html—中使用如下:

{{#each pages:index}}
  <div class="content-item">
    <h2>{{pages[index].title}}</h2>
    <p><small>Created by {{pages[index].userName}}</small></p>
    <p>{{pages[index].description}}</p>
    <p><a href="/pages/{{pages[index].id}}" class="button">Visit the page</a></p>
  </div>
{{/each}}

在下一节中,您将学习如何仅显示特定页面。我们在此代码中添加的链接将用户转发到新地址。此链接是一个包含仅一个页面信息的 URL。

展示特定页面

再次,要显示特定页面,我们需要更新我们的 API。我们有返回所有页面的代码,但如果要返回其中一个页面,则没有解决方案。我们肯定会使用页面的 ID。因此,这里是一个可以添加到backend/API.js的新路由:

.add('api/pages/:id', require('./api/pages'))
.add('api/pages', require('./api/pages'))

您应该记住路由的顺序很重要。包含页面 ID 的路由应该在显示页面列表的路由之上。否则,应用程序将不断列出新的 URL,但我们将保持相同的处理程序。如果地址中有任何动态部分,我们的路由器会向函数发送一个额外的参数。因此,在backend/api/pages.js中,我们将module.exports = function(req, res)更改为module.exports = function(req, res, params)。在同一个文件中,我们将从数据库中获取所有页面。在这种情况下,我们希望修改代码,使得函数只返回与 URL 中传递的 ID 匹配的一条记录。到目前为止,我们的 MongoDB 查询看起来是这样的:

collection.find({ 
  $query: { },
  $orderby: {
    date: -1
  }
}

在实践中,我们没有标准。现在,让我们将前面的代码更改为以下内容:

var query;
if(params && params.id) {
  query = { _id: ObjectId(params.id) };
} else {
  query = {};
}
collection.find({ 
  $query: query,
  $orderby: {
    date: -1
  }
}

通过定义一个query变量,我们使得这个 API 方法的响应是有条件的。它取决于 URL 中 ID 的存在。如果有任何这样的 ID,它仍然返回一个对象数组,但里面只有一个项目。

在前端,我们可以使用相同的方法,或者换句话说,相同的控制器来处理两种情况——显示所有页面和仅显示一个页面。我们注册一个新的路由处理程序,将用户转发到相同的Pages控制器,如下所示:

// frontend/js/app.js
.add('pages/:id', function(params) {
  if(userModel.isLogged()) {
    var p = new Pages({ 
      data: {
        pageId: params.id
      }
    });
    showPage(p);
  } else {
    Router.navigate('login');
  }
})

这一次,在控制器初始化期间传递了配置。在data属性中设置值会创建稍后在组件及其模板中可用的变量。在我们的情况下,pageId将通过this.get('pageId')访问。如果变量不存在,那么我们处于显示所有页面的模式。以下行显示单个页面的标题和描述:

// controllers/Page.js
onrender: function() {
  var model = new PagesModel();
  var self = this;

  var pageId = this.get('pageId');
  if(pageId) {
    model.getPage(pageId, function(err, result) {
      if(!err && result.pages.length > 0) {
        var page = result.pages[0];
        self.set('pageTitle', page.title);
        self.set('pageDescription', page.description);
      } else {
        self.set('pageTitle', 'Missing page.');
      }
    });
    return;
  }

  …

到目前为止,我们使用的模型执行POSTGET请求,但在这种情况下我们不能使用它们。它们是为其他功能保留的。我们需要另一种接受页面 ID 的方法。这就是为什么我们将添加一个新的getPage函数:

// models/Pages.js
getPage: function(pageId, callback) {
  var self = this;
  ajax.request({
    url: this.get('url') + '/' + pageId,
    method: 'GET',
    json: true
  })
  .done(function(result) {
    callback(null, result);
  })
  .fail(function(xhr) {
    callback(JSON.parse(xhr.responseText));
  });
}

我们没有任何数据要发送。我们只有一个不同的终端 URL。页面的 ID 附加在/api/pages字符串的末尾。这一部分始于后端的更改,以便我们知道 API 返回一个元素的数组。其余部分是设置pageTitlepageDescription

在模板中,我们使用相同的模式。您可以检查pageId是否存在,这就足以判断我们是否需要显示一个页面还是多个页面:

{{#if pageId}}
  <div class="hero">
    <h1>{{pageTitle}}</h1>
    <p>{{pageDescription}}</p>
  </div>
  <hr />
{{else}}
  <div class="hero">
    <form enctype="multipart/form-data" method="post">
      ...
    </form>
  </div>
  {{#each pages:index}}
    ...
  {{/each}}
{{/if}}

在更改frontend/tpl/pages.html之后,我们为每个页面都有了一个唯一的 URL。然而,一个具有静态标题和描述的页面对于用户来说并不是很有趣。让我们添加一个评论部分。

发布评论到页面

在发送和处理 HTTP 请求的部分之前,我们必须提供一个用户界面来创建评论。我们将在frontend/tpl/pages.html中的页面标题和描述下方添加一个表单:

<form enctype="multipart/form-data" method="post">
  <h3>Add a comment for this page</h3>
  {{#if error && error != ''}}
    <div class="error">{{error}}</div>
  {{/if}}
  {{#if success && success != ''}}
    <div class="success">{{{success}}}</div>
  {{/if}}
  <label for="text">Text</label>
  <textarea value="{{text}}"></textarea>
  <input type="button" value="Post" on-click="add-comment" />
</form>

点击按钮后触发的事件是add-commentPages控制器应该处理它并向后端发送请求。

如果你停下来思考一下评论的外观,你会注意到它们与用户在用户动态中看到的常规用户帖子相似。因此,我们将把评论保存为常规帖子,而不是在pages集合中创建新的集合或存储复杂的数据结构。对于客户端的代码来说,这意味着ContentModel类的一个更多的用例:

// controllers/Pages.js
this.on('add-comment', function() {
  var contentModel = new ContentModel();
  var formData = new FormData();
  formData.append('text', this.get('text'));
  formData.append('pageId', pageId);
  contentModel.create(formData, function(error, result) {
    self.set('text', '');
    if(error) {
      self.set('error', error.error);
    } else {
      self.set('error', false);
      self.set('success', 'The post is saved successfully.');
    }
  });
});

模型的使用方式是相同的,除了一个事情——我们发送了一个额外的pageId变量。我们需要一些东西来区分在主页上发布的帖子和作为评论发布的帖子。API 仍然不会保存pageId。因此,我们必须在backend/api/content.js中进行一点更新,如下所示:

form.parse(req, function(err, formData, files) {
  var data = {
    text: formData.text
  };
  if(formData.pageId) {
    data.pageId = formData.pageId;
  }
  …

当用户发表评论时,数据库中的记录将包含pageId属性。这足以使评论远离主页。另外,从另一个角度来看,这足以仅显示特定页面的评论。

显示评论

我们应该更新返回页面作为对象的 API 方法。除了标题和描述,我们还必须呈现一个新的comments属性。让我们打开backend/api/pages.js并创建一个函数来获取评论:

var getComments = function(pageId, callback) {
  var collection = db.collection('content');
  collection.find({ 
    $query: {
      pageId: pageId
    },
    $orderby: {
      date: -1
    }
  }).toArray(function(err, result) {
    result.forEach(function(value, index, arr) {
      delete arr[index].userId;
      delete arr[index]._id;
    });
    callback(result);
  });
}

在前述方法中的关键时刻是形成 MongoDB 查询。这是我们过滤帖子并仅获取与传递的 ID 匹配的页面所做的地方。以下是对GET请求的更新代码:

getDatabaseConnection(function(db) {
  var query;
  if(params && params.id) {
    query = { _id: ObjectId(params.id) };
  } else {
    query = {};
  }
  var collection = db.collection('pages');
  var getComments = function(pageId, callback) { ... }
  collection.find({ 
    $query: query,
    $orderby: {
      date: -1
    }
  }).toArray(function(err, result) {
    result.forEach(function(value, index, arr) {
      arr[index].id = value._id;
      delete arr[index]._id;
      delete arr[index].userId;
    });
    if(params.id && result.length > 0) {
      getComments(params.id, function(comments) {
        result[0].comments = comments;
        response({
          pages: result
        }, res);
      });
    } else {
      response({
        pages: result
      }, res);
    }
  });
});

有两种类型的响应。第一种是当我们在 URL 中添加了 ID 时使用,换句话说,当我们显示有关页面的信息时。在这种情况下,我们还必须获取评论。在另一种情况下,我们不需要评论,因为我们将仅显示列表。检查params.id是否存在足以决定发送哪种类型的响应。

一旦后端开始返回评论,我们将编写代码在浏览器中显示它们。在frontend/js/controllers/Pages.js中,我们将设置页面的标题和描述。我们可以直接将comments数组传递给模板,并循环遍历帖子,如下所示:

var showPage = function() {
  model.getPage(pageId, function(err, result) {
    if(!err && result.pages.length > 0) {
      var page = result.pages[0];
      self.set('pageTitle', page.title);
      self.set('pageDescription', page.description);
      self.set('comments', page.comments);
    } else {
      self.set('pageTitle', 'Missing page.');
    }
  });
}
showPage();

我们将model.getPage的调用包装在一个函数中,以便我们可以在添加新评论后再次触发它。

这是模板中需要显示帖子下方的小更新:

{{#each comments:index}}
  <div class="content-item">
    <h2>{{comments[index].userName}}</h2>
    <p>{{comments[index].text}}</p>
  </div>
{{/each}}

管理附加到特定页面的事件

本章我们将添加的最后一个功能是与一些创建的页面相关联的事件。到目前为止,我们有评论,实际上是保存在content集合中的普通帖子。我们将扩展实现并创建另一种类型的帖子。这些帖子仍然具有pageId属性,以便它们与动态源的帖子不同。但是,我们将引入一个eventDate变量。

在前端,我们需要一个新的 URL。我们应该保持包含页面 ID 的相同模式。这很重要,因为我们希望在正确的位置显示事件,而不希望将它们与页面列表混在一起。以下是新的路由注册:

// frontend/js/app.js
.add('pages/:id/:events', function(params) {
  if(userModel.isLogged()) {
    var p = new Pages({ 
      data: {
        pageId: params.id,
        showEvents: true
      }
    });
    showPage(p);
  } else {
    Router.navigate('login');
  }
})

Pages控制器的模板肯定需要更改。我们需要支持两种视图。第一个显示一个表单和评论,第二个显示一个表单和事件列表。 showEvents变量将告诉我们要呈现哪种变体:

// frontend/tpl/pages.html
{{#if showEvents}}
  <form enctype="multipart/form-data" method="post">
    <a href="/pages/{{pageId}}" class="button m-right right">View comments</a>
    <h3>Add new event</h3>
    ...
  </form>
  {{#each events:index}} … {{/each}}
{{else}}
  <form enctype="multipart/form-data" method="post">
    <a href="/pages/{{pageId}}/events" class="button right">View events</a>
    <h3>Add a comment for this page</h3>
    ...
  </form>
  {{#each comments:index}} … {{/each}}
{{/if}}

为了在视图之间切换,我们添加了两个额外的链接。当我们检查评论时,我们将看到查看事件,当我们跳转到事件时,我们将看到查看评论

controllers/Pages.js文件也需要进行实质性更新。最重要的是,我们需要添加一个来自模板的add-event事件处理程序。当用户在新事件表单中按下按钮时触发它。它看起来像这样:

this.on('add-event', function() {
  var contentModel = new ContentModel();
  var formData = new FormData();
  formData.append('text', this.get('text'));
  formData.append('eventDate', this.get('date'));
  formData.append('pageId', pageId);
  contentModel.create(formData, function(error, result) {
    ...
  });
});

这类似于添加评论,但是对于额外的eventDate属性。它也应该被设置为去content集合的对象的属性:

// backend/api/content.js
if(formData.pageId) {
  data.pageId = formData.pageId;
}
if(formData.eventDate) {
  data.eventDate = formData.eventDate;
}

同一前端控制器的另一个更改是关于在模板中显示事件(帖子)列表。当我们获取页面的标题和描述时,我们知道我们将收到一个comments属性。后端将在一分钟内更新,但我们将假设我们还将有一个events属性。因此,我们将简单地将数组发送到模板:

self.set('events', page.events);

在后端,我们已经从属于当前页面的content集合中获取了记录。问题在于记录现在是评论和事件的混合体。我们在上一节中添加的getComments函数可以更改为getPageItems,其实现基本上如下所示:

var getPageItems = function(pageId, callback) {
  var collection = db.collection('content');
  collection.find({ 
    $query: {
      pageId: pageId
    },
    $orderby: {
      date: -1
    }
  }).toArray(function(err, result) {
    var comments = [];
    var events = [];
    result.forEach(function(value, index, arr) {
      delete value.userId;
      delete value._id;
      if(value.eventDate) {
        events.push(value);
      } else {
        comments.push(value);                
      }
    });
    events.sort(function(a, b) {
      return a.eventDate > b.eventDate;
    });
    callback(comments, events);
  });
}

我们形成了两个不同的eventscomments数组。根据eventDate的存在,我们将用记录填充它们。在执行回调之前,我们将按日期对事件进行排序,先显示较早的事件。我们要做的最后一件事是使用getPageItem

getPageItems(params.id, function(comments, events) {
  result[0].comments = comments;
  result[0].events = events;
  …
}

总结

在本章中,我们扩展了我们的社交网络。现在每个客户都能够创建自己的页面,在那里留下评论或创建与页面相关的活动。我们的架构中添加了许多新组件。我们成功地重用了前几章的代码,这对于保持我们的代码库较小是很好的。

在第九章标记、分享和点赞中,我们将讨论帖子的标记、点赞和分享。

第九章,“标记、分享和喜欢”

第八章,“创建页面和事件”,是关于创建页面并将事件附加到它们上面。我们还使得评论的发布成为可能。在本书的这一部分,我们将添加三个新功能。几乎每个社交网络都包含一种喜欢帖子的方式。这是一种很好的方式来对你感兴趣的帖子进行排名。分享是另一个流行的过程,包括发布已经存在的帖子。有时,我们想把帖子转发给我们的一些朋友。在这些情况下,我们会标记人。这三个功能将在本章中实现。以下是将指导我们完成开发过程的各个部分:

  • 选择朋友并将他们的 ID 发送到后端

  • 存储标记的用户并在用户的动态中显示它们

  • 分享帖子

  • 喜欢帖子并计算喜欢的数量

  • 显示喜欢的数量

选择朋友并将他们的 ID 发送到后端

我们将从不仅随机用户的标记开始,还包括当前用户的朋友。我们想要构建的功能将放置在主页上。创建新帖子的表单将包含一个复选框列表。非常第一步将是从 API 中获取朋友。在第六章,“添加友谊功能”中,我们已经做到了。我们有一个models/Friends.js文件,查询 Node.js 服务器并返回用户列表。所以,让我们使用它。在controllers/Home.js的顶部,我们将添加以下内容:

var Friends = require('../models/Friends');

稍后,在onrender处理程序中,我们将使用所需的模块。API 的结果将以以下方式设置为本地friends变量的值:

var friends = new Friends();
friends.fetch(function(err, result) {
  if (err) { throw err; }
  self.set('friends', result.friends);
});

控制器在其数据结构中有用户的朋友,我们可以更新模板。我们将通过记录进行循环,并以以下方式为每个用户显示复选框:

// frontend/tpl/home.html
{{#if friends.length > 0}}
<p>Tag friends:
{{#each friends:index}}
  <label>
    <input type="checkbox" name="{{taggedFriends}}"  value="{{friends[index].id}}" />
    {{friends[index].firstName}} 
    {{friends[index].lastName}}
  </label>
{{/each}}
</p>
{{/if}}

Ractive.js 框架很好地处理复选框组。在我们的情况下,JavaScript 组件将接收一个名为taggedFriends的变量。它将是一个选定用户的数组,或者如果用户没有选择任何内容,则为空数组。预期的输出是用户的朋友列表,以复选框和标签的形式呈现。

一旦 Gulp 编译了模板的新版本并且我们点击浏览器的刷新按钮,我们将在屏幕上看到我们的朋友。我们将选择其中一些,填写帖子的内容,然后按下发布按钮。应用程序向 API 发送请求,但没有标记的朋友。需要进行一次更改来修复这个问题。在controllers/Home.js文件中,我们必须使用taggedFriends变量的值,如下所示:

formData.append('text', this.get('text'));
formData.append('taggedFriends', JSON.stringify(this.get('taggedFriends')));
model.create(formData, function(error, result) {
  ...
});

FormData API 只接受 Blob、文件或字符串值。我们不能发送一个字符串数组。因此,我们将使用JSON.stringifytaggedFriends序列化为字符串。在下一节中,我们将使用JSON.parse将字符串转换为对象。JSON接口在浏览器和 Node.js 环境中都可用。

存储标记的用户并在用户的动态中显示它们

现在,除了文本和文件,我们还发送一个用户 ID 列表——应该在帖子中标记的用户。如前所述,它们以字符串的形式传递到服务器。我们需要使用JSON.parse将它们转换为常规数组。以下行是backend/api/content.js模块的一部分:

var form = new formidable.IncomingForm();
form.multiples = true;
form.parse(req, function(err, formData, files) {
  var data = {
    text: formData.text
  };
  if(formData.pageId) {
    data.pageId = formData.pageId;
  }
  if(formData.eventDate) {
    data.eventDate = formData.eventDate;
  }
  if(formData.taggedFriends) {
    data.taggedFriends = JSON.parse(formData.taggedFriends);
  }
  ...

content.js模块是formidable提供的前端发送的数据的地方。在此代码片段的末尾,我们从先前序列化的字符串中重构了数组。

我们可以轻松地进行这种改变并存储data对象。实际上,在客户端,我们将接收包含taggedFriends属性的帖子。然而,我们对显示朋友的名称而不是他们的 ID 感兴趣。如果前端控制器具有 ID 并且需要名称,那么它应该执行另一个 HTTP 请求到 API。这可能会导致大量的 API 查询,特别是如果我们显示了许多消息。为了防止这种情况,我们将在后端获取帖子时获取标记的人的名称。这种方法有自己的缺点,但与前面提到的变体相比仍然更好。

让我们创建一个包装所需逻辑的函数,并在保存信息到数据库之前使用它:

// backend/api/content.js
var getFriendsProfiles = function(db, ids, callback) {
  if(ids && ids.length > 0) {
    var collection = db.collection('users');
    ids.forEach(function(value, index, arr) {
      arr[index] = ObjectId(value);
    });
    collection.find({ 
      _id: { $in: ids }
    }).toArray(function(err, friends) {
      var result = [];
      friends.forEach(function(friend) {
        result.push(friend.firstName + ' ' + friend.lastName);
      });
      callback(result);
    });  
  } else {
    callback([]);
  }
}

我们为 MongoDB 查询准备了用户的 ID。在这种情况下,需要$in运算符,因为我们希望获取与ids数组中的任何项目匹配的 ID 的记录。当 MongoDB 驱动程序返回数据时,我们创建另一个包含朋友名称的数组。GetFriendsProfiles将在接下来的几页中使用,我们将更新帖子的动态获取。

实际的数据存储仍然是相同的。唯一的区别是data对象现在包含taggedFriends属性:

getDatabaseConnection(function(db) {
  getCurrentUser(function(user) {
    var collection = db.collection('content');
    data.userId = user._id.toString();
    data.userName = user.firstName + ' ' + user.lastName;
    data.date = new Date();
    processFiles(user._id, function(file) {
      if(file) {
        data.file = file;
      }
      collection.insert(data, done);
    });
  }, req, res);
});

如果我们创建一个新帖子并检查数据库中的记录,我们会看到类似于这样的东西:

{
  "text": "What a nice day. Isn't it?",
  "taggedFriends": [
    "54b235be6fd75df10c278b63",
    "5499ded286c27ff13a36b253"
  ],
  "userId": "5499ded286c27ff13a36b253",
  "userName": "Krasimir Tsonev",
  "date": ISODate("2015-02-08T20:54:18.137Z")
}

现在,让我们更新数据库记录的获取。我们有我们朋友的 ID,但我们需要他们的名称。因此,在同一个content.js文件中,我们将放置以下代码:

var numberOfPosts = result.length;
var friendsFetched = function() {
  numberOfPosts -= 1;
  if(numberOfPosts === 0) {
    response({
      posts: result
    }, res);
  }
}
result.forEach(function(value, index, arr) {
  arr[index].id = ObjectId(value._id);
  arr[index].ownPost = user._id.toString() ===  ObjectId(arr[index].userId).toString();
  arr[index].numberOfLikes = arr[index].likes ?  arr[index].likes.length : 0;
  delete arr[index].userId;
  delete arr[index]._id;
  getFriendsProfiles(db, arr[index].taggedFriends,  function(friends) {
    arr[index].taggedFriends = friends;
    friendsFetched();
  });
});

我们在results数组中有来自数据库的项目。遍历帖子仍然是相同的,但在forEach调用之后不发送响应。对于列表中的每个帖子,我们需要向 MongoDB 数据库发送请求并获取朋友的名称。因此,我们将初始化numberOfPosts变量,并且每次朋友名称的请求完成时,我们将减少该值。一旦它减少到 0,我们就知道最后一个帖子已经处理完毕。之后,我们将向浏览器发送响应。

这是frontend/tpl/home.html文件的一个小更新,将使taggedFriends数组可见:

{{#each posts:index}}
  <div class="content-item">
    <h2>{{posts[index].userName}}</h2>
    {{posts[index].text}}
    {{#if posts[index].taggedFriends.length > 0}}
      <p>
        <small>
          Tagged: {{posts[index].taggedFriends.join(', ')}}
        </small>
      </p>
    {{/if}}
    {{#if posts[index].file}}
    <img src="img/{{posts[index].file}}" />
    {{/if}}
  </div>
{{/each}}

除了所有者、文本和图片(如果有的话),我们还检查是否有任何标记的人。如果有任何标记的人,那么我们将使用给定的分隔符连接taggedFriends数组的所有元素。结果看起来像下面的截图:

存储标记用户并在用户的动态中显示它们

分享帖子

我们应用的分享功能将为当前用户提供重新发布已创建帖子的选项。我们应该确保用户不分享自己的记录。因此,让我们从那里开始。API 返回帖子并知道谁创建了它们。它还知道哪个用户正在发出请求。以下代码创建了一个名为ownPost的新属性:

// backend/api/content.js
getCurrentUser(function(user) {
  ...
  getDatabaseConnection(function(db) {
    var collection = db.collection('content');
    collection.find({ 
      ...
    }).toArray(function(err, result) {
      result.forEach(function(value, index, arr) {
        arr[index].id = ObjectId(value._id);
        arr[index].ownPost = user._id.toString() ===  ObjectId(arr[index].userId).toString();
        delete arr[index].userId;
        delete arr[index]._id;
      });
      response({ posts: result }, res);
    });
  });
}, req, res);

这是准备帖子并将其发送到浏览器的逻辑。getCurrentUser属性返回当前发出请求的用户。user._id变量正是我们需要的。这个 ID 实际上分配给了每个帖子的userId属性。因此,我们将简单地比较它们,并确定是否允许分享。如果ownPost变量等于true,那么用户就不应该能够分享帖子。

在上一节中,我们添加了一个新的标记朋友的标记以显示标记的朋友。它们下方的空间似乎是放置分享按钮的好地方:

{{#if posts[index].taggedFriends.length > 0}}
  <p>
    <small>
      Tagged: {{posts[index].taggedFriends.join(', ')}}
    </small>
  </p>
{{/if}}
{{#if !posts[index].ownPost}}
<p><input type="button" value="Share"  on-click="share:{{posts[index].id}}" /></p>
{{/if}}

在这里,新的ownPost属性开始发挥作用。如果帖子不是由当前用户发布的,那么我们将显示一个按钮,用于触发share事件。Ractive.js 为我们提供了发送数据的机会。在我们的情况下,这是帖子的 ID。

主页的控制器应该监听这个事件。controllers/Home.js的快速更新添加了监听器,如下所示:

this.on('share', function(e, id) {
  var formData = new FormData();
  formData.append('postId', id);
  model.sharePost(formData, getPosts);
});

model对象是ContentModel类的一个实例。分享是一个新功能。因此,我们需要向不同的 API 端点发送查询。新的sharePost方法如下所示:

// frontend/js/models/Content.js
sharePost: function(formData, callback) {
  var self = this;
  ajax.request({
    url: this.get('url') + '/share',
    method: 'POST',
    formData: formData,
    json: true
  })
  .done(function(result) {
    callback(null, result);
  })
  .fail(function(xhr) {
    callback(JSON.parse(xhr.responseText));
  });
}

我们在上一章中多次使用了与前面相似的代码。它向特定 URL 的后端发送一个POST请求。在这里,URL 是/api/content/share。还要提到的是,formData包含我们想要分享的帖子的 ID。

让我们继续,在 API 中进行必要的更改。我们已经定义了将承载此功能的 URL——/api/content/share。需要在backend/API.js中添加一个新路由,如下所示:

.add('api/content/share', require('./api/content-share'))

下一步涉及创建content-share控制器。像每个其他控制器一样,我们将从要求助手开始。我们将跳过这部分,直接转到处理POST请求:

// backend/api/content-share.js
case 'POST':
  var formidable = require('formidable');
  var form = new formidable.IncomingForm();
  form.parse(req, function(err, formData, files) {
    if(!formData.postId) {
      error('Please provide ID of a post.', res);
    } else {
      var done = function() {
        response({
          success: 'OK'
        }, res);
      };
      // ...
    }
  });
break;

上述方法期望一个postId变量。如果没有这样的变量,那么我们将以错误响应。代码的其余部分再次涉及formidable模块的使用和定义done函数以发送成功操作的响应。以下是更有趣的部分:

getDatabaseConnection(function(db) {
  getCurrentUser(function(user) {
    var collection = db.collection('content');
    collection
    .find({ _id: ObjectId(formData.postId) })
    .toArray(function(err, result) {
      if(result.length === 0) {
        error('There is no post with that ID.', res);
      } else {
        var post = result[0];
        delete post._id;
        post.via = post.userName;
        post.userId = user ._id.toString();
        post.userName = user.firstName + ' ' + user.lastName;
        post.date = new Date();
        post.taggedFriends = [];
        collection.insert(post, done);
      }
    });
  }, req, res);

在找到应该分享的帖子后,我们将准备一个将保存为新记录的对象。我们需要对原始帖子执行一些操作:

var post = result[0];
delete post._id;
post.via = post.userName;
post.userId = user ._id.toString();
post.userName = user.firstName + ' ' + user.lastName;
post.date = new Date();
post.taggedFriends = [];
collection.insert(post, done);

我们确实不需要_id属性。MongoDB 将创建一个新的。第三行定义了一个via属性。我们将在一分钟内讨论这个问题,但简而言之,它用于显示帖子的原始作者。via后面的行设置了新记录的所有者。日期也被更改了,由于这是一个新帖子,我们清除了taggedFriends数组。

共享的帖子现在在数据库中,并显示在用户的动态中。让我们使用via属性,并以以下方式显示帖子的原始创建者:

// frontend/tpl/home.html
{{#each posts:index}}
<div class="content-item">
  <h2>{{posts[index].userName}}</h2>
  <p>{{posts[index].text}}</p>
  {{#if posts[index].via}}
  <small>via {{posts[index].via}}</small>
  {{/if}}
  …

我们将检查变量是否可用,如果是,那么我们将在帖子文本下面添加一小段文字。结果将如下所示:

分享帖子

喜欢帖子并计算喜欢的数量

我们的社交网络用户应该能够看到一个喜欢按钮。点击它,他们将向 API 发送一个请求,我们的任务是计算这些点击。当然,每个用户只允许点击一次。与上一节一样,我们将从更新用户界面开始。让我们以以下方式在分享旁边添加另一个按钮:

// frontend/tpl/home.html
<input type="button" value="Like"  on-click="like:{{posts[index].id}}" />
{{#if !posts[index].ownPost}}
<input type="button" value="Share"  on-click="share:{{posts[index].id}}" />
{{/if}}

新按钮分派了一个like事件,我们将再次传递帖子的 ID。这实际上类似于share事件。此外,喜欢的动作将使用与后端相同类型的通信。因此,重构我们的代码并仅使用一个函数来处理这两个功能是有意义的。在上一节中,我们在models/Content.js文件中添加了sharePost方法。让我们以以下方式将其更改为usePost

usePost: function(url, formData, callback) {
  var self = this;
  ajax.request({
    url: this.get('url') + '/' + url,
    method: 'POST',
    formData: formData,
    json: true
  })
  .done(function(result) {
    callback(null, result);
  })
  .fail(function(xhr) {
    callback(JSON.parse(xhr.responseText));
  });
}

因为唯一不同的是 URL,我们将其定义为参数。formData接口仍然包含帖子的 ID。以下是我们控制器的更新代码:

// controllers/Home.js
this.on('share', function(e, id) {
  var formData = new FormData();
  formData.append('postId', id);
  model.usePost('share', formData, getPosts);
});
this.on('like', function(e, id) {
  var formData = new FormData();
  formData.append('postId', id);
  model.usePost('like', formData, getPosts);
});

我们跳过了定义另一个方法,并使模型的实现更加灵活。我们可能需要添加一个新操作,最后的微调将派上用场。

根据 API 的更改,我们遵循了相同的工作流程。需要响应/api/content/like的新路由,可以创建如下:

// backend/API.js
add('api/content/like', require('./api/content-like'))

content-like 控制器仍然不存在。我们将创建一个新的 backend/api/content-like.js 文件,其中将包含与喜欢相关的逻辑。像保护未经授权用户的方法和使用 formidable 获取 POST 数据这样的常规操作都存在。这次,我们不会使用集合的 insert 方法。相反,我们将使用 update。我们将构建一个稍微复杂一些的 MongoDB 查询,并更新一个名为 likes 的新属性。

update 方法接受四个参数。第一个是条件。符合我们条件的记录将被更新。第二个包含了我们想要更新的指令。第三个参数包含了额外的选项,最后一个是一个回调函数,一旦操作结束就会被调用。这是我们的查询的样子:

getDatabaseConnection(function(db) {
  getCurrentUser(function(user) {
    var collection = db.collection('content');
    var userName = user.firstName + ' ' + user.lastName;
    collection.update(
      {
        $and: [
          { _id: ObjectId(formData.postId) },
          { "likes.user": { $nin: [userName] } }
        ]
      },
      { 
        $push: { 
          likes: { user: userName }
        }
      },
      {w:1}, 
      function(err) {
        done();
      }
    );
  }, req, res);
});

代码确实有点长,但它完成了它的工作。让我们逐行来看一下。第一个参数,我们的条件,确保我们将要更新正确的帖子。因为我们使用了 $and 运算符,数组中的第二个对象也应该是有效的。你可能注意到在 $and 下面几行,$push 运算符向一个名为 likes 的数组中添加了一个新对象。每个对象都有一个包含点击喜欢按钮的用户的名字的 name 属性。所以,在我们的 "likes.user": { $nin: [userName] } 条件中,这意味着只有当 userName 不在 likes 数组的一些元素中时,记录才会被更新。这可能看起来有点复杂,但它确实是一种强大的运算符组合。如果没有这个,我们可能最终会对数据库进行多次查询。

{w: 1} 选项总是在传递回调时改变其值。

记录更新后,我们将简单地调用 done 方法并向用户发送响应。

通过对 API 的更改,我们成功完成了这个功能。现在帖子在浏览器中的样子如下:

喜欢帖子和计算喜欢次数

显示喜欢的次数

我们将喜欢的内容保存在一个数组中。很容易对其中的元素进行计数,找出一篇帖子被喜欢的次数。我们将进行两个小改动,使这成为可能。第一个是在 API 中,那是我们准备帖子对象的地方:

// backend/api/content.js
result.forEach(function(value, index, arr) {
  arr[index].id = ObjectId(value._id);
  arr[index].ownPost = user._id.toString() ===  ObjectId(arr[index].userId).toString();
  arr[index].numberOfLikes = arr[index].likes ?  arr[index].likes.length : 0;
  delete arr[index].userId;
  delete arr[index]._id;
});

一个新的 numberOfLikes 属性被附加上。记录一开始没有 likes 属性。所以,在使用之前我们必须检查它是否存在。如果我们有 numberOfLikes 变量,我们可以将前端喜欢按钮的标签更新为以下代码:

<input type="button" value="Like ({{posts[index].numberOfLikes}})" on-click="like:{{posts[index].id}}" />

每个帖子创建后都没有喜欢。所以,按钮的标签是喜欢(0),但第一次点击后,它会变成喜欢(1)。以下截图展示了这在实践中的样子:

显示喜欢的次数

总结

本章讨论了当今社交网络中最常用的一些功能——标记、分享和喜欢。我们更新了应用程序的两侧,并验证了我们在之前章节中的知识。

下一章将讨论实时通信。我们将为用户构建一个聊天窗口,他们将能够向其他人发送实时消息。

第十章:添加实时聊天

在前两章中,我们通过添加新功能来扩展了我们的社交网络,以创建页面和分享帖子。在本章中,我们将讨论系统中用户之间的实时通信。我们将使用的技术称为 WebSockets。本书的这一部分计划如下:

  • 了解 WebSockets

  • 将 Socket.IO 引入项目

  • 准备聊天区域的用户界面

  • 在客户端和服务器之间交换消息

  • 仅向用户的朋友发送消息

  • 自定义聊天输出

了解 WebSockets

WebSockets 是一种在服务器和浏览器之间打开双向交互通道的技术。通过使用这种类型的通信,我们能够在没有初始请求的情况下交换消息。双方只需向对方发送事件。WebSockets 的其他好处包括较低的带宽需求和延迟。

有几种从服务器传输数据到客户端以及反之的方式。让我们检查最流行的几种方式,并看看为什么 WebSockets 被认为是实时 Web 应用的最佳选择:

  • 经典的 HTTP 通信:客户端请求服务器的资源。服务器确定响应内容并发送。在实时应用的情况下,这并不是很实用,因为我们必须手动请求更多的数据。

  • Ajax 轮询:它类似于经典的 HTTP 请求,不同之处在于我们的代码会不断向服务器发送请求,例如,每隔半秒一次。这并不是一个好主意,因为我们的服务器将收到大量的请求。

  • Ajax 长轮询:我们再次有一个执行 HTTP 请求的客户端,但这次服务器延迟结果并不立即响应。它会等到有新信息可用时才回应请求。

  • HTML5 服务器发送事件(EventSource):在这种通信类型中,我们有一个从服务器到客户端的通道,服务器会自动向浏览器发送数据。当我们需要单向数据流时,通常会使用这种技术。

  • WebSockets:如前所述,如果我们使用 WebSockets,我们将拥有双向数据流。客户端和服务器双方都可以在不询问对方的情况下发送消息。

服务器发送事件在某些情况下可能有效,但对于实时聊天,我们绝对需要 WebSockets,因为我们希望用户能够互相发送消息。我们将实现的解决方案如下截图所示:

了解 WebSockets

每个用户都将连接到服务器并开始发送消息。我们的后端将负责将消息分发给其他用户。

使用原始 WebSockets API 可能并不那么容易。在下一节中,我们将介绍一个非常有用的 Node.js 模块来处理 WebSockets。

将 Socket.IO 引入项目

Socket.IO(socket.io/)是建立在 WebSockets 技术之上的实时引擎。它是一个使 Web 开发变得简单和直接的层。像现在的每一样新事物一样,WebSockets 也有自己的问题。并非每个浏览器都支持这项技术。我们可能会遇到协议问题和缺少心跳、超时或断开支持等事件。幸运的是,Socket.IO 解决了这些问题。它甚至为不支持 WebSockets 的浏览器提供了备用方案,并采用长轮询等技术。

在后端进行更改之前,我们需要安装该模块。该引擎与每个其他 Node.js 模块一样分发;它可以通过包管理器获得。因此,我们必须以以下方式将 Socket.IO 添加到package.json文件中:

{
  "name": "nodejs-by-example",
  "version": "0.0.2",
  "description": "Node.js by example",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "socket.io": "1.3.3"
    ...
    ...
  }
}

在进行这些更改之后,我们将运行npm install并获取node_modules/socket.io文件夹。安装了该模块后,我们可以开始更新我们的社交网络。让我们在后端目录中添加一个Chat.js文件,其中包含以下代码:

module.exports = function(app) {
  var io = require('socket.io')(app);
  io.on('connection', function (socket) {
    socket.emit('news', { hello: 'world' });
    socket.on('my other event', function (data) {
      console.log(data);
    });
  });
}

新模块导出一个接受 HTTP 服务器的函数。在server.js中,我们可以使用http.createServer来初始化它,如下所示:

var app = http.createServer(checkSession).listen(port, '127.0.0.1');
console.log("Listening on 127.0.0.1:" + port);

var Chat = require('./backend/Chat');
Chat(app);

Socket.IO 完全建立在事件触发和监听的概念上。io变量代表我们的通信中心。每当新用户连接到我们的服务器时,我们都会收到一个连接事件,并且被调用的处理程序会接收一个socket对象,我们将使用它来处理从浏览器到和从浏览器的消息。

在上面的例子中,我们发送(emit)了一个带有news名称的事件,其中包含一些简单的数据。之后,我们开始监听来自客户端的其他事件。

现在,即使我们重新启动服务器,我们也不会收到任何 socket 连接。这是因为我们没有更改前端代码。为了使 Socket.IO 在客户端工作,我们需要在页面中包含/socket.io/socket.io.js文件。我们应用程序的布局存储在backend/tpl/page.html中,在修改后,它看起来像这样:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Node.js by example</title>
  <meta http-equiv="Content-Type" content="text/html;  charset=utf-8" />
  <meta name="description" content="Node.js by examples">
  <meta name="author" content="Packt">
  <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
  <div class="container"></div>
  <script src="img/socket.io.js"></script>
  <script src="img/ractive.js"></script>
  <script src="img/app.js"></script>
</body>
</html>

socket.io.js文件在我们的代码库中不存在。它是 Socket.IO 模块的一部分。引擎会自动注册一个路由,并确保它提供文件。

我们 WebSockets 实现测试的最后一步是连接到服务器。为了简单起见,让我们在frontend/js/app.js文件中添加几行代码:

window.onload = function() {

  ...

  var socket = io('http://localhost:9000');
  socket.on('news', function (data) {
    console.log(data);
    socket.emit('my other event', { my: 'data' });
  });

};

我们将把我们的代码放在onload处理程序中,因为我们希望确保所有外部 JavaScript 文件都已完全加载。然后,我们将初始化到http://localhost:9000的连接,这是 Node.js 服务器运行的相同主机和端口。代码的其余部分只做一件事——监听news事件,并响应其他事件消息。如果我们在浏览器中运行服务器并加载http://localhost:9000,我们将在终端中得到以下结果:

将 Socket.IO 引入项目

我们得到{ my: 'data' }作为输出,因为在backend/Chat.js文件中有console.log(data)

准备聊天区域的 UI

因为实时聊天是我们社交网络的重要部分,我们将为其创建一个单独的页面。就像我们在之前的章节中所做的那样,我们将在主导航中添加一个新链接,如下所示:

<nav>
  <ul>
    <li><a on-click="goto:home">Home</a></li>
    {{#if !isLogged }}
      <li><a on-click="goto:register">Register</a></li>
      <li><a on-click="goto:login">Login</a></li>
    {{else}}
      <li class="right"><a on-click="goto:logout">Logout</a></li>
      <li class="right"><a  
        on-click="goto:profile">Profile</a></li>
      <li class="right"><a on-click="goto:find-friends">Find  friends</a></li>
      <li class="right"><a on-click="goto:pages">Pages</a></li>
      <li class="right"><a on-click="goto:chat">Chat</a></li>
    {{/if}}
  </ul>
</nav>

列表中的最新链接将把用户转到http://localhost:9000/chat的 URL,用户将在那里看到聊天的界面。

让我们通过调整frontend/js/app.js文件来处理/chat路由。让我们对路由进行另一个添加,如下所示:

Router
...
...
.add('chat', function() {
  if(userModel.isLogged()) {
    var p = new Chat();
    showPage(p);
  } else {
    Router.navigate('login');
  }    
})
.add(function() {
  Router.navigate('home');
})
.listen()
.check();

在同一个文件中,我们将需要frontend/js/controllers/Chat.js模块。它将包含客户端的聊天逻辑。我们将从一些简单的东西开始——一个基本的 Ractive.js 组件,可以实现如下:

// frontend/js/controllers/Chat.js
module.exports = Ractive.extend({
  template: require('../../tpl/chat'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  data: {
    output: ''
  },
  onrender: function() {

  }
});

像我们应用程序中的每个其他控制器一样,Chat.js有一个关联的模板,其中包含一个空的<div>元素来显示聊天消息,一个文本字段和一个发送数据到服务器的按钮:

// front/tpl/chat.html
<header>
  <navigation></navigation>
</header>
<div class="hero">
  <h1>Chat</h1>
</div>
<form>
  <div class="chat-output">{{output}}</div>
  <input type="text" value="{{text}}" />
  <a href="#" on-click="send" class="button">Send</a>
</form>
<appfooter />

值得一提的是,如果要更新chat-output元素的内容,需要更改output变量的值。按钮还会触发一个send事件,我们将在下一节中捕获这个事件。在编译资产之后,如果您转到聊天的 URL,您将看到以下屏幕:

准备聊天区域的 UI

在客户端和服务器之间交换消息

我们准备编写一些可工作的 Socket.IO 代码。到目前为止,我们放置了一些代码片段,只是证明了套接字连接的工作。例如,添加到frontend/js/app.js的代码应该移动到frontend/js/controllers/Chat.js,这是负责聊天页面的控制器。因为它充当了这个实时功能的基础,我们将从那里开始。让我们向组件添加一些本地变量,如下所示:

data: {
  messages: ['Loading. Please wait.'],
  output: '',
  socketConnected: false
}

这些变量具有默认值,并且可以在组件模板中使用。第一个变量messages将保存来自聊天用户的所有消息,包括当前用户。output变量用于在屏幕上填充消息容器。最后一个socketConnected控制文本字段和按钮的可见性。如果设置为false,则控件将被隐藏。在与服务器初始化连接或由于某种原因断开连接之前,最好隐藏聊天输入文本字段,直到与服务器的连接初始化。否则,我们可能会因某种原因断开连接。更新后的模板如下所示:

// frontend/tpl/chat.html
<header>
  <navigation></navigation>
</header>
<div class="hero">
  <h1>Chat</h1>
</div>
<form>
  <div class="chat-output"  data-component="output">{{{output}}}</div>
  {{#if socketConnected}}
    <input type="text" value="{{text}}" />
    <a href="#" on-click="send" class="button">Send</a>
  {{/if}}
</form>
<appfooter />

差异在于包裹字段和按钮的{{if}}运算符。在本章末尾,我们将对消息进行着色,并需要传递 HTML 标签。我们将使用{{{output}}}而不是{{output}},以便框架正确显示它们(通过关闭自动转义)。

让我们回到前端控制器。我们提到的代码放在app.js中移动到这里。这是与套接字服务器的实际连接。我们将以以下方式扩展它:

var self = this;
var socket = io('http://localhost:9000');
socket.on('connect', function() {
  self.push('messages', 'Connected!');
  self.set('socketConnected', true);
  self.find('input[type="text"]').focus();
});
socket.on('disconnect', function() {
  self.set('socketConnected', false);
  self.push('messages', 'Disconnected!');
});
socket.on('server-talking', function(data) {
  self.push('messages', data.text);
});

收到connect事件后,我们将Connected!字符串添加到messages数组中。因此,在收到加载中,请稍候。消息后,用户将看到一条确认消息,告知他/她应用程序已经建立了成功的套接字连接。通过将socketConnected设置为true,我们显示输入控件,并为用户提供发送聊天消息的选项。此处理程序中的最后一件事是强制浏览器聚焦在输入字段上,这是一个很好的细节,可以节省用户的鼠标点击。

socket对象可能会分派另一个事件 - disconnect。在这种情况下,我们可以采取两种行动 - 隐藏输入控件,并通过在浏览器中显示Disconnected!字符串来通知用户。

我们监听的最后一个事件是server-talking。这是我们自己的事件 - 后端代码将分派的消息。一开始,data对象将只包含一个text属性,这将是聊天消息。我们将简单地将其附加到messages数组的其余元素中。

我们之前谈到的行监听来自后端的事件。让我们编写一些代码,将信息从客户端发送到服务器:

var send = function() {
  socket.emit('client-talking', { text: self.get('text')});
  self.set('text', '');
}
this.on('send', send);

当用户单击按钮时,将调用send函数。我们使用相同的socket对象及其emit方法将文本传输到服务器。我们还清除输入字段的内容,以便用户可以开始撰写新消息。每次按按钮可能很烦人。以下代码在用户按下Enter键时触发send函数:

this.find('form').addEventListener('keypress', function(e) {
  if(e.keyCode === 13 && e.target.nodeName === 'INPUT') {
    e.preventDefault();
    send();
  }
});

this.find方法返回一个有效的 DOM 元素。我们将keypress监听器附加到form元素,因为input变量并不总是可见。由于事件冒泡,我们能够在上层元素中捕获事件。还值得一提的是,在某些浏览器中,需要不同的代码来监听 DOM 事件。

我们必须处理的最后一件事是在屏幕上显示messages数组的内容。如果您检查到目前为止我们编写的代码,您会发现我们没有更新output变量。以下是一个新的组件方法,将处理这个问题:

updateOutput: function() {
  this.set('output', this.get('messages').join('<br />'));
  var outputEl = this.find('[data-component="output"]');
  outputEl.scrollTop = outputEl.scrollHeight;
}

我们使用join方法而不是循环遍历数组的所有元素。它将数组的所有元素连接成一个由给定参数分隔的字符串。在我们的情况下,我们需要在每条消息后面换行。一旦我们开始接收更多数据,我们将需要将<div>元素滚动到底部,以便用户看到最新的消息。函数的另外两行将容器的滚动条定位在底部。

updateOutput函数应该在新消息到达时被调用。Ractive.js 的观察对于这种情况非常完美:

this.observe('messages', this.updateOutput);

只需要一行代码将messages数组的更新连接到updateOutput方法。添加了这个之后,每次对消息数组进行push操作都会强制渲染chat-output元素。

组件的代码如下:

module.exports = Ractive.extend({
  template: require('../../tpl/chat'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  data: {
    messages: ['Loading. Please wait.'],
    output: '',
    socketConnected: false
  },
  onrender: function() {

    var self = this;
    var socket = io('http://localhost:9000');
    socket.on('connect', function() {
      self.push('messages', 'Connected!');
      self.set('socketConnected', true);
      self.find('input[type="text"]').focus();
    });
    socket.on('disconnect', function() {
      self.set('socketConnected', false);
      self.push('messages', 'Disconnected!');
    });
    socket.on('server-talking', function(data) {
      self.push('messages', data.text);
    });

    var send = function() {
      socket.emit('client-talking', { text: self.get('text')});
      self.set('text', '');
    }

    this.on('send', send);
    this.observe('messages', this.updateOutput);

    this.find('form').addEventListener('keypress', function(e) {
      if(e.keyCode === 13 && e.target.nodeName === 'INPUT') {
        e.preventDefault();
        send();
      }
    });

  },
  updateOutput: function() {
    this.set('output', this.get('messages').join('<br />'));
    var outputEl = this.find('[data-component="output"]');
    outputEl.scrollTop = outputEl.scrollHeight;
  }
});

前端已准备好通过套接字发送和接收消息。但是,后端仍然包含我们开始时的初始示例代码。对Chat模块进行小小的更新将使其能够向用户发送消息:

// backend/Code.js
module.exports = function(app) {
  var io = require('socket.io')(app);
  io.on('connection', function (socket) {
    socket.on('client-talking', function (data) {
      io.sockets.emit('server-talking', { text: data.text });
    });
  });
}

我们仍在监听connection事件。在处理程序中收到的socket对象代表与用户的连接。之后,我们将开始监听client-talking事件,该事件由前端在用户在字段中输入内容或按下按钮或Enter键时触发。一旦接收到数据,我们就会将其广播给系统中的所有用户。io.sockets.emit变量向当前使用服务器的所有客户端发送消息。

仅向用户的朋友发送消息

我们后端的最后一个更改是将接收到的聊天消息分发给我们社交网络中的所有用户。当然,这实际上并不太实用,因为我们可能会与彼此不认识的人交换文本。我们必须相应地更改我们的代码,以便只向我们朋友列表中的用户发送消息。

使用 Socket.IO 时,我们无法像在后端 API 中那样默认访问requestresponse对象。这将使得解决问题变得更有趣,因为我们无法识别发送消息的用户。幸运的是,Socket.IO 让我们可以访问活动会话。它是以原始格式存在的。因此,我们需要解析它并提取用户的个人资料数据。为此,我们将使用cookie Node.js 模块。让我们以以下方式将其添加到package.json文件中:

"dependencies": {
  "cookie": "0.1.2",
  "socket.io": "1.3.3",
  ...
  ...
}

在终端中进行另一个npm install后,我们将能够require该模块。在第八章中,创建页面和事件,我们重构了我们的 API 并创建了backend/api/helpers.js文件,其中包含实用函数。我们将使用仅使用session对象的方式添加另一个类似于getCurrentUser的文件,如下所示:

var getCurrentUserBySessionObj = function(callback, obj) {
  getDatabaseConnection(function(db) {
    var collection = db.collection('users');
    collection.find({ 
      email: obj.user.email
    }).toArray(function(err, result) {
      if(result.length === 0) {
        callback({ error: 'No user found.' });
      } else {
        callback(null, result[0]);
      }
    });
  });
};

如果我们比较这两种方法,我们会发现有两个不同之处。第一个不同之处是我们没有收到通常的请求和响应对象;我们只收到一个回调和一个session对象。第二个变化是即使出现错误,结果也总是发送到回调中。

有了getCurrentUserBySessionObj函数,我们可以修改backend/Chat.js,使其只向当前用户的朋友发送消息。让我们首先初始化所需的辅助程序。我们将在文件顶部添加以下行:

var helpers = require('./api/helpers');
var getCurrentUserBySessionObj =  helpers.getCurrentUserBySessionObj;
var cookie = require('cookie');

我们已经讨论过cookie模块。在 Socket.IO 引擎中可用的会话数据可以通过socket.request.headers.cookie访问。如果我们在控制台中打印该值,将会得到以下截图中的内容:

仅向用户的朋友发送消息

前面的输出是一个 Base64 编码的字符串,我们肯定不能直接使用它。幸运的是,Node.js 有接口可以轻松解码这样的值。以下是一个提取所需 JSON 对象的简短函数:

var decode = function(string) {
  var body = new Buffer(string, 'base64').toString('utf8');
  return JSON.parse(body);
};

我们从 cookie 中传递了字符串,并接收了稍后将在getCurrentUserBySessionObj中使用的普通user对象。

因此,我们有机制来找出当前用户是谁以及他/她的朋友是谁。我们所要做的就是缓存可用的套接字连接和相关用户。我们将引入一个新的全局(对于模块来说)users变量。它将作为一个哈希映射,其中键将是用户的 ID,值将包含套接字和朋友。为了向正确的用户广播消息,我们可以总结以下方法的逻辑:

var broadcastMessage = function(userId, message) {
  var user = users[userId];
  if(user && user.friends && user.friends.length > 0) {
    user.socket.emit('server-talking', { text: message });
    for(var i=0; i<user.friends.length; i++) {
      var friend = users[user.friends[i]];
      if(friend && friend.socket) {
        friend.socket.emit('server-talking', { text: message });
      }
    }
  }
};

这段代码提供了一个接受用户 ID 和文本消息的函数。我们首先检查是否缓存了套接字引用。如果是,我们将确保用户有朋友。如果这也是有效的,我们将开始分发消息。第一个emit项是给用户自己,以便他/她接收自己的消息。其余的代码循环遍历朋友并将文本发送给所有人。

当然,我们必须更新接受套接字连接的代码。以下是相同代码的新版本:

module.exports = function(app) {
  var io = require('socket.io')(app);
  io.on('connection', function (socket) {
    var sessionData = cookie.parse(socket.request.headers.cookie);
    sessionData = decode(sessionData['express:sess']);
    if(sessionData && sessionData.user) {
      getCurrentUserBySessionObj(function(err, user) {
        var userId = user._id.toString();
        users[userId] = {
          socket: socket,
          friends: user.friends
        };
        socket.on('client-talking', function (data) {
          broadcastMessage(userId, data.text);
        });
        socket.on('disconnect', function() {
          users[userId] = null;
        });
      }, sessionData);
    }

  });
}

现在我们将获取 cookie 值并确定当前用户。socket对象和用户的朋友已被缓存。然后,我们将继续监听client-talking事件,但现在,我们将通过broadcastMessage函数发送消息。在最后做了一个小但非常重要的添加;我们监听disconnect事件并移除缓存的数据。这是为了防止向断开连接的用户发送数据。

自定义聊天输出

能够向正确的人发送消息是很好的,但聊天仍然有点混乱,因为屏幕上出现的每条文本消息都是相同的颜色,我们不知道哪个朋友发送的。在本节中,我们将进行两项改进——我们将在消息前附加用户的名称并给文本着色。

让我们从颜色开始,并在backend/api/helpers.js文件中添加一个新的辅助方法:

var getRandomColor = function() {
  var letters = '0123456789ABCDEF'.split('');
  var color = '#';
  for(var i = 0; i < 6; i++ ) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
}

以下函数生成一个有效的 RGB 颜色,可以在 CSS 中使用。你选择用户颜色的时机是在缓存socket对象时:

...
var getRandomColor = helpers.getRandomColor;

module.exports = function(app) {
  var io = require('socket.io')(app);
  io.on('connection', function (socket) {
    var sessionData = cookie.parse(socket.request.headers.cookie);
    sessionData = decode(sessionData['express:sess']);
    if(sessionData && sessionData.user) {
      getCurrentUserBySessionObj(function(err, user) {
        var userId = user._id.toString();
        users[userId] = {
          socket: socket,
          friends: user.friends,
          color: getRandomColor()
        };
        socket.on('client-talking', function (data) {
          broadcastMessage(user, data.text);
        });
        socket.on('disconnect', function() {
          users[userId] = null;
        });
      }, sessionData);
    }

  });
}

因此,除了socket对象和friends,我们还存储了一个随机选择的颜色。还有一个小的更新。我们不再将用户的 ID 传递给broadcastMessage函数。我们发送整个对象,因为我们需要获取用户的名字和姓氏。

以下是更新后的broadcastMessage辅助方法:

var broadcastMessage = function(userProfile, message) {
  var user = users[userProfile._id.toString()];
  var userName = userProfile.firstName + ' ' +  userProfile.lastName;
  if(user && user.friends && user.friends.length > 0) {
    user.socket.emit('server-talking', {
      text: message,
      user: userName,
      color: user.color
    });
    for(var i=0; i<user.friends.length; i++) {
      var friend = users[user.friends[i]];
      if(friend && friend.socket) {
        friend.socket.emit('server-talking', { 
          text: message,
          user: userName,
          color: user.color
        });
      }
    }
  }
};

现在,发送到客户端的data对象包含两个额外的属性——当前用户的名称和他/她随机选择的颜色。

后端已经完成了它的工作。现在我们要做的就是调整前端控制器,以便它使用名称和颜色,如下所示:

// frontend/js/controllers/Chat.js
socket.on('server-talking', function(data) {
  var message = '<span style="color:' + data.color + '">';
  message += data.user + ': ' + data.text;
  message += '</span>';
  self.push('messages', message);
});

我们不再只发送文本,而是将消息包装在<span>标签中。它应用了文本颜色。此外,消息以用户的名称开头。

我们工作的最终结果如下截图所示:

自定义聊天输出

总结

Socket.IO 是最流行的用于开发实时应用程序的 Node.js 工具之一。在本章中,我们成功地使用它构建了一个交互式聊天。我们网络中的用户不仅能够发布出现在其动态中的内容,还能够与其他用户实时交换消息。WebSockets 技术使这一切成为可能。

下一章专门讲解测试。我们将了解一些流行的模块,这些模块将帮助我们编写测试。

第十一章:测试用户界面

在第十章 添加实时聊天中,我们通过添加实时聊天功能扩展了我们的社交网络。我们使用了 WebSockets 和 Socket.IO 来实现系统中用户之间的通信。本书的最后一章专门讨论用户界面测试。我们将探讨两种流行的工具来运行无头浏览器测试。本章涵盖以下主题:

  • 介绍基本的测试工具集

  • 准备我们的项目来运行测试

  • 使用 PhantomJS 运行我们的测试

  • 测试用户的注册

  • 使用 DalekJS 进行测试

介绍基本的测试工具集

在编写测试之前,我们将花一些时间讨论测试工具集。我们需要一些工具来定义和运行我们的测试。

测试框架

在 JavaScript 的上下文中,测试框架是一组函数,帮助你将测试组织成逻辑组。有一些框架函数,比如suitedescribetestit,定义了我们的测试套件的结构。以下是一个简短的例子:

describe('Testing database communication', function () {
  it('should connect to the database', function(done) {
    // the actual testing goes here
  });
  it('should execute a query', function(done) {
    // the actual testing goes here
  });
});

我们使用describe函数将更详细的测试(it)包装成一个组。以这种方式组织组有助于我们保持专注,同时也非常信息丰富。

JavaScript 社区中一些流行的测试框架包括QUnitJasmineMocha

断言库

我们通常在测试时运行一个断言。我们经常比较变量的值,以检查它们是否与我们最初编写程序逻辑时的预期值匹配。一些测试框架带有自己的断言库,一些则没有。

以下一行展示了这样一个库的简单用法:

expect(10).to.be.a('number')

重要的是要提到 API 是这样设计的,以便我们通过阅读测试来理解上下文。

Node.js 甚至有自己内置的名为assert的库。其他选项包括ChaiExpectShould.js

运行器

运行器是一个工具,我们用它在特定的上下文中执行测试,这个上下文很常见是特定的浏览器,但也可能是不同的操作系统或定制的环境。我们可能需要也可能不需要运行器。在这一特定章节中,我们将使用 DalekJS 作为测试运行器。

准备我们的项目来运行测试

现在我们知道了运行测试所需的工具。下一步是准备我们的项目来放置这样的测试。通常在开发过程中,我们通过访问页面并与其交互来测试我们的应用程序。我们知道这些操作的结果,并验证一切是否正常。我们希望用自动化测试做同样的事情。但是,不是我们一遍又一遍地重复相同的步骤,而是会有一个脚本。

为了使这些脚本起作用,我们必须将它们放在正确的上下文中。换句话说,它们应该在我们的应用程序的上下文中执行。

在前一节中,我们提到了 Chai(一个断言库)和 Mocha(一个测试框架)。它们很好地配合在一起。因此,我们将把它们添加到我们的依赖列表中,如下所示:

// package.json
…
"dependencies": {
    "chai": "2.0.0",
    "mocha": "2.1.0",
    ...
}
…

快速运行npm install将在node_modules目录中设置模块。Chai 和 Mocha 被分发为 Node.js 模块,但我们也可以在浏览器环境中使用它们。node_modules中新创建的文件夹包含编译版本。例如,要在浏览器中运行 Mocha,我们必须在我们的页面中包含node_modules/mocha/mocha.js

我们的社交网络是一个单页面应用程序。我们有一个主 HTML 模板,由后端提供,位于backend/tpl/page.html中。Node.js 服务器读取此文件并将其发送到浏览器。其余部分由 JavaScript 代码处理。以下是page.html的样子:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Node.js by example</title>
  <meta http-equiv="Content-Type" content="text/html;  charset=utf-8" />
  <meta name="description" content="Node.js by example">
  <meta name="author" content="Packt">
  <link rel="stylesheet" href="/static/css/styles.css">
</head>
<body>
  <div class="container"></div>
  <script src="img/socket.io.js"></script>
  <script src="img/ractive.js"></script>
  <script src="img/app.js"></script>
</body>
</html>

该文件包含运行应用程序所需的所有外部资源。但是,现在我们需要添加一些标签;其中一些如下:

  • /node_modules/mocha/mocha.css文件包含了测试结果正确显示的样式。这是 Mocha 报告者的一部分。

  • /node_modules/mocha/mocha.js文件是测试框架。

  • /node_modules/chai/chai.js文件是断言库。

  • /tests/spec.js是一个包含实际测试的文件。它目前还不存在。我们将创建一个tests目录,并在其中创建一个spec.js文件。

  • 一个空的div标签充当测试结果的占位符,几行 JavaScript 代码引导 Mocha 框架。

我们不能把所有这些新元素都添加到当前的page.html文件中,因为系统的用户会看到它们。我们将把它们放在另一个文件中,并调整后端,以便在特定条件下提供它。让我们创建backend/tpl/pageTest.html

<!doctype html>
<html lang="en">
<head>
  ...
  <link rel="stylesheet" href="/static/css/styles.css">
  <link rel="stylesheet" href="/node_modules/mocha/mocha.css" />
</head>
<body>
  <div class="container"></div>
  <script src="img/socket.io.js"></script>
  <script src="img/ractive.js"></script>
  <script src="img/app.js"></script>

  <div id="mocha"></div>
  <script src="img/mocha.js"></script>
  <script src="img/chai.js"></script>
  <script>
    mocha.ui('bdd');
    mocha.reporter('html');
    expect = chai.expect;
  </script>
  <script src="img/spec.js"></script>
  <script>
    if (window.mochaPhantomJS) { 
      mochaPhantomJS.run();
   }
    else {
     mocha.run();
   }
  </script>

</body>
</html>

一旦mocha.jschai.js被注入到页面中,我们将配置框架。我们的用户界面将遵循行为驱动开发,报告者将是html。Mocha 有几种类型的报告者,由于我们想在浏览器中显示结果,所以我们使用了这个。我们定义了一个expect全局对象,起到了断言工具的作用。

在接下来的部分中,这些行将会派上用场,我们将使用 PhantomJS 运行我们的测试。这些行基本上会检查是否有window.mochaPhantomJS对象,如果有的话,它将被用来代替默认的mocha

到目前为止,一切都很顺利。我们有工具可以帮助我们运行和编写测试,还有一个包含必要代码的页面。下一步是调整后端,以便使用新的pageTest.html文件:

// backend/Default.js
var fs = require('fs');
var url = require('url');

var html = fs.readFileSync(__dirname +  '/tpl/page.html').toString('utf8');
var htmlWithTests = fs.readFileSync(__dirname +  '/tpl/pageTest.html').toString('utf8');

module.exports = function(req, res) {
  res.writeHead(200, {'Content-Type': 'text/html'});
  var urlParts = url.parse(req.url, true);
  var parameters = urlParts.query;
  if(typeof parameters.test !== 'undefined') {
    res.end(htmlWithTests + '\n');
  } else {
    res.end(html + '\n');
  }
}

我们需要更改的文件是Default.js。这是我们应用程序中Default.js文件路由的处理程序。新添加的htmlWithTests变量包含了新的 HTML 标记。我们使用url模块来查找来自客户端的GET变量。如果有test参数,那么我们将加载包含布局和测试的页面。否则,就是原始的 HTML。

在最后一次更改之后,我们可以运行服务器并打开http://localhost:9000/register?test=1。然而,我们会收到一堆错误消息,抱怨有一些文件丢失。这是因为server.js文件不识别以node_modulestests开头的 URL。这些目录中存在的文件是静态资源。因此,我们可以使用已经定义的Assets模块,如下所示:

// server.js
…
Router
.add('static', Assets)
.add('node_modules', Assets)
.add('tests', Assets)
.add('api', API)
.add(Default);

最后,还有一个文件需要创建——tests/spec.js

describe("Testing", function () {
  it("Test case", function (done) {
    expect(1).to.be.equal(1);
    done();
  });
});

这段代码是一个测试的最简单结构。我们有一个组和一个测试。关键时刻是在测试结束时运行done()

我们知道这个测试通过了。浏览器中的结果如下截图所示:

准备项目运行测试

值得一提的是,加载的页面仍然是相同的,除了右上角和页脚下方的元素。这些新标签是由 Mocha 框架生成的。这就是html报告者显示我们测试结果的方式。

使用 PhantomJS 运行我们的测试

前面几节的结果是在浏览器中运行的自动化测试。然而,这通常是不够的。我们可能需要将测试集成到部署流程中,而在浏览器中进行测试并不总是一个选择。幸运的是,有一种称为无头浏览器的浏览器类型。它是一个没有用户界面的功能性浏览器。我们仍然可以访问页面,点击链接或填写表单,但所有这些操作都是由代码控制的。这对于我们和自动化测试来说是完美的。

有几种流行的无头浏览器。Selenium (github.com/seleniumhq/selenium)就是其中之一。它有很好的文档和庞大的社区。另一个是 PhantomJS。它与 Node.js 兼容良好。所以我们将使用它。

我们已经在测试环境中添加了几个组件。要直接使用 PhantomJS,需要一些补充配置。为了避免额外的复杂性,我们将安装mocha-phantomjs模块。它的目的是简化无头浏览器的使用,特别是与 Mocha 框架的结合。以下命令将在我们的终端中将mocha-phantomjs设置为全局命令:

npm install mocha-phantomjs -g

自 3.4 版本以来,mocha-phantomjs模块使用 PhantomJS 作为对等依赖,这意味着我们不必手动安装浏览器。

安装成功后,我们准备运行测试。我们在控制台中要输入的命令是mocha-phantomjs http://localhost:9000\?test=1。有反斜杠是因为如果不是这样的话,终端可能无法正确解释这行。

结果显示在以下截图中:

使用 PhantomJS 运行我们的测试

这基本上与我们在浏览器中得到的结果相同。好处是现在这个过程发生在终端中。

测试用户注册

让我们使用前几节中构建的设置并编写一个实际的测试。假设我们要确保我们的注册页面可以正常工作。以下是我们想要用我们的测试捕获的两个过程:

  • 填写表单并确保应用程序显示错误消息

  • 填写表单并看到成功消息

我们将使用 PhantomJS 作为我们的无头(虚拟)浏览器。因此,我们所要做的就是加载我们的注册页面并模拟用户交互,比如在字段中输入并按下按钮。

模拟用户交互

我们将解决几个问题。第一个问题是实际模拟用户操作。从 JavaScript 的角度来看,这些操作被转换为由特定 DOM 元素分派的事件。以下辅助方法将成为tests/spec.js文件的一部分:

describe("Testing", function () {

  var trigger = function(element, event, eventGroup, keyCode) {
    var e = window.document.createEvent(eventGroup || 'MouseEvents');
    if(keyCode) {
      e.keyCode = e.which = keyCode;
    }
    e.initEvent(event, true, true);
    return element.dispatchEvent(e);
  }

  it("Registration", function (done) {
    // ... our test here
  });

});

trigger函数接受一个元素、事件的名称、事件组和一个键码。前两个参数是必需的。第三个参数的默认值为MouseEvents,最后一个参数是可选的。我们将使用该方法来触发changeclick事件。

填写并提交注册表单

让我们从填写注册表单的输入字段开始。值得一提的是,我们将要编写的代码在浏览器中运行,因此我们可以访问document.querySelector,例如。以下行在名字字段中输入一个字符串:

var firstName = document.querySelector('#first-name');
firstName.value = 'First name';
trigger(firstName, 'change');

firstName元素发送一个字符串会更新用户界面。然而,我们的客户端框架 Ractive.js 并不知道这个变化。分派change事件可以解决这个问题。

我们将使用相同的模式向姓氏、电子邮件和密码字段添加值:

var lastName = document.querySelector('#last-name');
lastName.value = 'Last name';
trigger(lastName, 'change');

var email = document.querySelector('#email');
email.value = 'wrong email';
trigger(email, 'change');

var password = document.querySelector('#password');
password.value = 'password';
trigger(password, 'change');

电子邮件输入字段的值是无效的。这是故意的。我们希望捕获后端返回错误的情况。要完成操作,我们必须点击注册按钮:

trigger(document.querySelector('input[value="register"]'),  'click');

如果我们现在运行测试,将会看到以下截图:

填写并提交注册表单

测试基本上因超时而失败。这是因为我们没有调用done函数。然而,即使这样,我们也没有任何断言。

现在,事情变得有趣起来。在浏览器中发生的过程是异步的。这意味着我们不能简单地在点击按钮后运行我们的断言。我们应该等一会儿。在这种情况下,使用setTimeout是不可接受的。正确的方法是调整应用程序的代码,以便它通知外部世界特定的工作已经完成。在我们的情况下,这是提交注册表单。更准确地说,我们必须更新s/controllers/Register.js

module.exports = Ractive.extend({
  template: require('../../tpl/register'),
  components: {
    navigation: require('../views/Navigation'),
    appfooter: require('../views/Footer')
  },
  onrender: function() {
    ...
    this.on('register', function() {
      userModel.create(function(error, result) {
        ...
        self.fire('form-submitted');
      });
    });
  }
});

添加的是self.fire('form-submitted')。一旦模型返回响应并且我们处理它,我们就会分派一个事件。对于访问网站的用户,这一行无效。但是对于我们的测试套件来说,这是一种找出后端响应并且用户界面已更新的方法。这时我们需要进行断言。

调整代码的执行顺序

事件的分派很好,但并不能完全解决问题。我们需要到达Register控制器并订阅form-submitted消息。在我们的测试中,我们可以访问全局范围(window对象)。让我们将其用作桥梁,并为当前使用的控制器提供一个快捷方式,如下所示:

// frontend/js/app.js
var showPage = function(newPage) {
  if(currentPage) currentPage.teardown();
  currentPage = newPage;
  body.innerHTML = '';
  currentPage.render(body);
  currentPage.on('navigation.goto', function(e, route) {
    Router.navigate(route);
  });
  window.currentPage = currentPage;
  if(typeof window.onAppReady !== 'undefined') {
    window.onAppReady();
  }
}

app.js文件中,我们切换了应用程序的页面。这是我们调整的完美位置,因为在这一点上,我们知道哪个控制器被呈现。

在继续实际测试之前,您应该做的最后一件事是确保您的社交网络已完全初始化,并且有一个正在呈现的视图。这再次需要访问全局window对象。我们的测试将在window.onAppReady属性中存储一个函数,并且当 PhantomJS 打开页面时,应用程序将运行它。请注意,将对象或变量附加到全局范围并不被认为是一种良好的做法。但是,为了使我们的测试工作,我们需要这样的小技巧。在编译文件进行生产发布时,我们可以随时跳过这一点。

backend/tpl/pageTest.html中,我们有以下代码:

<script src="img/socket.io.js"></script>
<script src="img/ractive.js"></script>
<script src="img/app.js"></script>
<div id="mocha"></div>
<script src="img/mocha.js"></script>
<script src="img/chai.js"></script>
<script>
  mocha.ui('bdd');
  mocha.reporter('html');
  expect = chai.expect;
</script>
<script src="img/spec.js"></script>
<script>
  if (window.mochaPhantomJS) { mochaPhantomJS.run(); }
  else { mocha.run(); }
</script>

如果我们继续使用这些行,我们的测试将失败,因为在执行断言时没有呈现任何 UI。相反,我们应该使用新的onAppReady属性以以下方式延迟调用run方法:

<div id="mocha"></div>
<script src="img/mocha.js"></script>
<script src="img/chai.js"></script>
<script>
  mocha.ui('bdd');
  mocha.reporter('html');
  expect = chai.expect;
</script>
<script src="img/spec.js"></script>
<script>
  window.onAppReady = function() {
    if (window.mochaPhantomJS) { mochaPhantomJS.run(); }
    else { mocha.run(); }
  }
</script>
<script src="img/socket.io.js"></script>
<script src="img/ractive.js"></script>
<script src="img/app.js"></script>

因此,我们包括了 Mocha 和 Chai。我们配置了测试框架,添加了一个在调用onAppReady时执行的函数,然后运行了实际应用程序。

监听form-submitted事件

我们需要编写的最后一行代码是订阅form-submitted事件,当表单提交并且后端处理结果时,控制器会分发此事件。我们的 API 应该首先响应错误,因为我们设置了错误的电子邮件值(email.value = 'wrong email')。以下是我们如何捕获错误消息:

var password = document.querySelector('#password');
password.value = 'password';
trigger(password, 'change');

window.currentPage.on('form-submitted', function() {
  var error = document.querySelector('.error');
  expect(!!error).to.be.equal(true);
  done();
});

trigger(document.querySelector('input[value="register"]'),  'click');

!!error项目将错误变量转换为布尔值。我们将检查错误元素的存在。如果存在,那么测试通过。控制台中的结果如下:

监听 form-submitted 事件

我们验证了错误报告。让我们通过确保当所有字段都正确填写时成功消息出现来结束这个循环:

var submitted = 0;
window.currentPage.on('form-submitted', function() {
  if(submitted === 0) {
    submitted++;
    var error = document.querySelector('.error');
    expect(!!error).to.be.equal(true);
    var email = document.querySelector('#email');
    var validEmail = 'test' + (new Date()).getTime() +  '@test.com';
    email.value = validEmail;
    trigger(email, 'change');
    trigger(document.querySelector('input[value="register"]'),  'click');
  } else {    
    var success = document.querySelector('.success');
    expect(!!success).to.be.equal(true);
    done();
  }
});

form-submitted事件将被分派两次。因此,我们将使用额外的submitted变量来区分这两个调用。在第一种情况下,我们将检查.error,而在第二种情况下,我们将检查.success。运行mocha-phantomjs命令后,我们将得到与之前相同的结果,但这次我们确信整个注册过程都是有效的。请注意,我们附加了一个动态生成的时间戳,以便每次都获得不同的电子邮件。

使用 DalekJS 进行测试

DalekJS 是一个完全用 JavaScript 编写的开源 UI 测试工具。它充当测试运行器。它有自己的 API 来执行用户界面交互。DalekJS 的一个非常有趣的特性是它可以与不同的浏览器一起工作。它能够在 PhantomJS 和流行的浏览器(如 Chrome、Safari、Firefox 和 Internet Explorer)中运行测试。它使用WebDriver JSON-Wire协议与这些浏览器进行通信,并基本上控制它们的操作。

安装 DalekJS

首先,我们需要安装 DalekJS 的命令行工具。它作为一个 Node.js 包进行分发。因此,以下命令将下载必要的文件:

npm install dalek-cli -g

当进程完成时,我们可以在终端中运行dalek命令。下一步是在我们的依赖项中添加dalekjs模块。这是召唤该工具 API 的包。因此,在package.json文件中需要两行:

{
  ...
  "dependencies": {
    "dalekjs": "0.0.9",
    "dalek-browser-chrome": "0.0.11"
    ...
  }
}

我们提到 DalekJS 可以与 Chrome、Safari 和 Firefox 等真实浏览器一起工作。有专门的包来处理所有这些浏览器。例如,如果我们想在 Chrome 浏览器中进行测试,我们必须安装dalek-browser-chrome作为依赖项。

使用 DalekJS API

DalekJS 的工作方式类似于mocha-phantomjs模块。我们在文件中编写我们的测试,然后简单地将该文件传递给我们的终端中的命令。让我们创建一个名为tests/dalekjs.spec.js的新文件,并将以下代码放入其中:

module.exports = {
  'Testing registration': function (test) {
    test
    .open('http://localhost:9000/register')
    .setValue('#first-name', 'First name')
    .setValue('#last-name', 'Last name')
    .setValue('#email', 'wrong email')
    .setValue('#password', 'password')
    .click('input[value="register"]')
    .waitForElement('.error')
    .assert.text('.error').to.contain('Invalid or missing email')
    .setValue('#email', 'test' + (new Date()).getTime() +  '@test.com')
    .click('input[value="register"]')
    .waitForElement('.success')
    .assert.text('.success').to.contain('Registration successful')
    .done();
  }
};

该工具要求我们导出一个对象,其键是我们的测试用例。我们只有一个名为Testing registration的案例。我们传递一个接收test参数的函数,这使我们可以访问 DalekJS API。

该模块的 API 设计得非常易于理解。我们打开一个特定的 URL 并为输入字段设置值。就像在之前的测试中,我们将输入一个错误的电子邮件值,然后点击提交按钮。在这里,.waitForElement方法非常方便,因为操作是异步的。一旦我们检测到.error元素的存在,我们将继续写入正确的电子邮件值并再次提交表单。

要运行测试,我们必须在控制台中键入dalek ./tests/dalekjs.spec.js -b chrome。DalekJS 将打开一个真正的 Chrome 窗口,执行测试并在终端中报告以下内容:

使用 DalekJS API

使用 DalekJS,我们不需要调整我们应用的代码。没有额外的断言库或测试框架。所有这些都包含在一个易于使用和安装的单个模块中。

从另一个角度来看,DalekJS 可能对每个项目都不是有用的。例如,当我们需要与应用程序的代码交互或需要一些未在提供的 API 中列出的东西时,它可能就不那么有用了。

摘要

在本章中,我们看到了如何测试我们的用户界面。我们成功解决了一些问题,并使用了诸如 Mocha、Chai 和 DalekJS 之类的工具。测试我们的代码很重要,但通常还不够。应该存在模拟用户交互并证明我们的软件正常工作的测试。

posted @ 2024-05-23 15:59  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报