精通-JavaScript-单页应用开发-全-
精通 JavaScript 单页应用开发(全)
原文:
zh.annas-archive.org/md5/fa665c7853676e34d22229ab1dd29cdd译者:飞龙
前言
由 Netscape 的 Brendan Eich 于 1995 年创建的 JavaScript,已经从仅在浏览器中使用的玩具脚本语言发展成为世界上用于全栈开发最受欢迎的语言之一。随着 Node.js 的引入,它建立在 Chrome 的 V8 JavaScript 引擎之上,开发者现在能够使用 JavaScript 构建强大且性能卓越的应用程序。随着现代 NoSQL 数据库 MongoDB 的加入,应用程序可以在每个层级上利用 JavaScript。
许多流行的 Web 应用程序部分或全部作为单页应用程序(SPA)实现。使用 SPA,用户只需加载一个网页,该网页会根据用户交互或传入的服务器数据进行动态更新。其优势是提供更平滑的应用程序体验,模仿原生应用程序交互,并且可能需要更少的网络流量和服务器负载。
MEAN 栈是一套从数据库到运行时环境、应用程序框架和前端的 JavaScript 工具集合,代表了一个全栈。本书详细介绍了使用 MEAN 栈和其他 JavaScript 生态系统中的工具构建 SPA 的背景。它涵盖了 SPA 架构和 JavaScript 工具的基础知识。本书随后扩展到更高级的主题,例如使用 MEAN 栈构建、保护和部署 SPA。
本书涵盖内容
第一章, 使用 NPM、Bower 和 Grunt 进行组织,介绍了 JavaScript 前端包管理、构建和任务运行工具。这些工具构成了您设置理想开发环境的基础。
第二章, 模型-视图-任意,超越了原始的 MVC 设计模式,并探讨了其在前端框架中的转换及其演变为模型-视图-*,或模型-视图-任意(MVW),其中控制器层更加开放且通常被抽象为更适合 SPA 环境的其他层。
第三章, SPA 基础 - 创建理想的应用程序环境,向您介绍了 SPA 的常见组件/层,这些组件的最佳实践和变体,以及如何将它们组合起来并为现代 SPA 打下基础。
第四章, REST 是最佳选择 - 与应用程序后端交互,进一步详细介绍了 SPA 架构的后端——特别是关于 REST(表示状态转移)架构模式。您将熟悉使用 JavaScript 和 AJAX 与 REST API 交互的不同方法,并伴有实际示例。
第五章, 一切关于视图,专注于 SPA 架构中的视图概念以及如何在单页容器中初始化视图。它讨论了 JavaScript 模板,并提供了来自不同库的示例,深入探讨了 AngularJS 视图的实现方式。
第六章, 数据绑定,以及为什么你应该拥抱它,教您关于数据绑定的知识,描述了一对一与双向数据绑定,并讨论了在 SPA 中数据绑定的实用性以及为什么您应该使用它。您还将涵盖 ECMAScript/JavaScript 标准的持续演变,以及它现在如何支持某些客户端的原生数据绑定。
第七章, 利用 MEAN 栈,向您介绍 MEAN 栈(MongoDB、Express、AngularJS 和 Node.js)以及它们是如何协同工作的。您将安装和配置 MongoDB 和 Node.js,并在命令行中探索与每个组件的工作方式。您将为新的 SPA 创建数据库,并了解 AngularJS 和 Express,这是栈中的另外两个组件。
第八章, 使用 MongoDB 管理数据,教您如何在 MongoDB 中创建和管理数据库。使用命令行,您将执行 CRUD(创建、读取、更新和删除)功能。
第九章, 使用 Express 处理 Web 请求,使您熟悉 Express 路由中间件以及处理来自和发送到浏览器的请求。在配置 Express 路由器后,您将创建多个路由,当请求时,这些路由将返回动态生成数据给网络浏览器,逻辑地组织您的路由,并处理来自表单的 POST 请求。
第十章, 显示视图,探讨了在 Express 中结合动态视图渲染与 AngularJS。您将配置 Express 应用程序使用 EJS(嵌入式 JavaScript)模板,并使用 Bootstrap 进行基本样式设计。
第十一章, 添加安全和身份验证,教您如何通过防止常见的漏洞,如跨站请求伪造(CSRF),来保护基于 Express 的 SPA。您将为 Node.js 安装 passport-authentication 中间件,并对其进行本地身份验证配置,以及设置会话管理。
第十二章, 连接应用至社交媒体,通过连接到多个社交媒体平台来扩展 SPA。您将使用 Facebook 和 Twitter 策略设置护照身份验证并共享 SPA 数据。
第十三章, 使用 Mocha、Karma 等测试,教您在服务器端和视图中进行测试。
第十四章, 部署和扩展 SPA,将指导您在 MongoLab 上设置生产数据库并将您的 SPA 部署到 Heroku。最后,您将探索在云中扩展您的 SPA。
您需要为这本书准备什么
除了您的网络浏览器和操作系统命令行或终端之外,这本书只需要很少的软件。您需要一个编辑器来编写代码。任何文本编辑器都可以,从记事本到像 Jetbrains WebStorm 这样的 IDE。
本书面向对象
这本书非常适合想要用 JavaScript 构建复杂单页应用(SPA)的 JavaScript 开发者。对 SPA 概念的基本了解将有所帮助,但不是必需的。
惯例
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"我们可以通过使用 include 指令来包含其他上下文。"
代码块如下所示:
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
clean: ['dist/**'],
copy: {
main: {
files: [
任何命令行输入或输出都如下所示:
$ npm install -g grunt-cli
grunt-cli@0.1.13 /usr/local/lib/node_modules/grunt-cli
├── resolve@0.3.1
├── nopt@1.0.10 (abbrev@1.0.7)
└── findup-sync@0.1.3 (lodash@2.4.2, glob@3.2.11)
新术语和重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:"点击下一步按钮将您带到下一屏幕。"
注意
警告或重要注意事项如下所示。
小贴士
小贴士和技巧如下所示。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些可以帮助您充分利用购买的东西。
下载示例代码
您可以从您的账户下载这本书的示例代码文件www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载与勘误。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Mastering-JavaScript-Single-Page-Application-Development。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。
要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。
盗版
互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过 copyright@packtpub.com 联系我们,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您在这本书的任何方面遇到问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章:使用 NPM、Bower 和 Grunt 进行组织
在浏览器渲染互联网的早期阶段,JavaScript 一直是网络开发行业的噩梦。现在,它为像 jQuery 这样具有巨大影响力的库提供动力,并且 JavaScript 渲染的内容(与服务器端渲染的内容相对)甚至被许多搜索引擎索引。曾经被认为主要用来生成弹出窗口和警告框的令人烦恼的语言,现在可能已成为世界上最受欢迎的编程语言。
不仅 JavaScript 现在在前端架构中比以往任何时候都更普遍,而且得益于Node.js运行时,它也已成为一种服务器端语言。我们还看到了文档型数据库的激增,例如 MongoDB,这些数据库存储并返回 JSON 数据。由于 JavaScript 贯穿整个开发栈,JavaScript 开发者现在可以成为全栈开发者,而无需学习传统的服务器端语言。有了合适的工具和知识,任何 JavaScript 开发者都可以创建完全由他们最擅长的语言组成的单页应用程序,并且可以使用像MEAN(MongoDB、Express、AngularJS 和 Node.js)这样的架构来实现。
组织对于任何复杂单页应用程序(SPA)的开发至关重要。如果您从一开始就没有组织起来,您肯定会向您的应用程序引入大量的回归。Node.js 生态系统将帮助您使用一套不可或缺的开源工具来实现这一点,其中我们将讨论三个。
在本章中,您将了解:
-
Node 包管理器(NPM)
-
Bower 前端包管理器
-
Grunt JavaScript 任务运行器
-
如何使用这三个工具共同创建一个有组织的开发环境,这对于创建 SPA 和 MEAN 堆栈架构至关重要。
什么是 Node 包管理器?
在任何全栈 JavaScript 环境中,Node 包管理器将是您设置开发环境和管理服务器端库的首选工具。NPM 可以在全局和隔离环境上下文中使用。我们将首先探讨 NPM 的全局使用。
安装 Node.js 和 NPM
NPM 是Node.js的一个组件,因此在使用它之前,您必须首先安装 Node.js。您可以在 nodejs.org 上找到 Mac 和 Windows 的安装程序。一旦安装了 Node.js,使用 NPM 就非常简单,并且通过命令行界面(CLI)完成。首先确保您安装了最新版本的 NPM,因为它比 Node.js 本身更新得更频繁:
$ npm install -g npm
当使用 NPM 时,-g选项将您的更改应用于全局环境。在这种情况下,您希望您的 NPM 版本应用于全局。如前所述,NPM 可以用于全局和隔离环境中的包管理。在下面的例子中,我们希望将基本开发工具应用于全局,这样您就可以在同一系统上的多个项目中使用它们。
小贴士
在 Mac 和一些基于 Unix 的系统上,您可能需要以超级用户身份运行npm命令(在命令前加上sudo)来全局安装包,这取决于 NPM 是如何安装的。如果您遇到这个问题并且希望不再需要在npm前加上sudo,请参阅docs.npmjs.com/getting-started/fixing-npm-permissions。
配置您的package.json文件
对于您开发的任何项目,您将保留一个本地的package.json文件来管理您的 Node.js 依赖项。此文件应存储在项目目录的根目录中,并且它仅适用于该隔离环境。这允许您在同一系统上拥有多个具有不同依赖链的 Node.js 项目。
当开始一个新的项目时,您可以从命令行自动创建package.json文件:
$ npm init
运行npm init将引导您通过一系列 JSON 属性名称,通过命令行提示来定义,包括您的应用程序的name、version版本号、description描述等。name和version属性是必需的,并且如果没有定义它们,您的 Node.js 包将无法安装。在提示中,一些属性将给出括号内的默认值,这样您只需按Enter键即可继续。其他属性将允许您按Enter键并留空条目,这些条目将不会保存到package.json文件中,或者将保存为空值:
name: (my-app)
version: (1.0.0)
description:
entry point: (index.js)
entry point提示将被定义为package.json中的main属性,除非您正在开发 Node.js 应用程序,否则这不是必需的。在我们的例子中,我们可以省略这个字段。实际上,npm init命令可能强制您保存main属性,因此您必须在之后编辑package.json来删除它;然而,该字段对您的 Web 应用程序没有任何影响。
如果您知道要使用的适当结构,您也可以选择使用文本编辑器手动创建package.json文件。无论您选择哪种方法,您的package.json文件的初始版本应类似于以下示例:
{
"name": "my-app",
"version": "1.0.0",
"author": "Philip Klauzinski",
"license": "MIT",
"description": "My JavaScript single page application."
}
如果您希望您的项目是私有的,并确保它不会意外地发布到 NPM 注册表,您可能希望将private属性添加到您的package.json文件中,并将其设置为true。此外,您还可以删除仅适用于已注册包的一些属性:
{
"name": "my-app",
"author": "Philip Klauzinski",
"description": "My JavaScript single page application.",
"private": true
}
一旦你将 package.json 文件设置成你喜欢的样子,你就可以开始为你的应用程序本地安装 Node.js 包了。这就是依赖的重要性开始显现的地方。
NPM 依赖
在你的 package.json 文件中,可以为任何 Node.js 项目定义三种类型的依赖:dependencies、devDependencies 和 peerDependencies。为了构建基于 Web 的 SPA,你将只需要使用 devDependencies 声明。
devDependencies 是那些在开发你的应用程序时所需的,但不是在它的生产环境或仅仅运行它时所需的。如果其他开发者想为你的 Node.js 应用程序做出贡献,他们需要从命令行运行 npm install 来设置适当的发展环境。有关其他类型依赖的信息,请参阅 docs.npmjs.com。
当将 devDependencies 添加到你的 package.json 文件时,命令行再次发挥作用。让我们以安装 Browserify 为例:
$ npm install browserify --save-dev
这将在本地安装 Browserify 并将其及其版本范围保存到你的 package.json 文件中的 devDependencies 对象中。一旦安装,你的 package.json 文件应该类似于以下示例:
{
"name": "my-app",
"version": "1.0.0",
"author": "Philip Klauzinski",
"license": "MIT",
"devDependencies": {
"browserify": "¹².0.1"
}
}
devDependencies 对象将把每个包存储为一个键值对,其中键是 包名,值是 版本号 或 版本范围。Node.js 使用语义版本,其中版本号的三个数字代表 MAJOR.MINOR.PATCH。有关语义版本格式化的更多信息,请参阅 semver.org。
更新你的开发依赖
你会注意到默认情况下安装的包的版本号前面有一个 ** caret ** (^) 符号。这意味着对于版本号高于 1.0.0 的包,更新只会允许 * patch * 和 * minor * 更新。这是为了防止在更新包到最新版本时,主要版本的变化破坏你的依赖链。
要更新你的 devDependencies 并保存新的版本号,你可以在命令行中输入以下内容:
$ npm update --save-dev
或者,你可以使用 -D 选项作为 --save-dev 的快捷方式:
$ npm update -D
要更新所有全局安装的 NPM 包到它们的最新版本,运行带有 -g 选项的 npm update:
$ npm update -g
有关 NPM 中的语义版本信息的更多信息,请参阅 docs.npmjs.com/misc/semver。
现在你已经设置了 NPM 并知道如何安装你的开发依赖,你可以继续安装 Bower。
Bower
Bower 是一个用于前端 Web 资产和库的包管理器。你将使用它来维护你的前端堆栈并控制库如 jQuery、AngularJS 以及任何其他对你应用程序 Web 界面必要的组件的版本链。
安装 Bower
Bower 也是一个 Node.js 包,所以你将使用 NPM 安装它,就像你在上一节中安装 Browserify 示例时做的那样,但这次你将全局安装该包。这将允许你在系统上的任何位置运行 bower 命令,而无需为每个项目本地安装它:
$ npm install -g bower
你还可以将 Bower 作为开发依赖项本地安装,这样你就可以在同一系统上为不同的项目维护不同的版本,但这通常不是必要的:
$ npm install bower --save-dev
接下来,通过命令行查询版本来检查 Bower 是否已正确安装:
$ bower -v
Bower 还需要在你的系统上安装一个 Git 版本控制系统,或 VCS,以便与包进行交互。这是因为 Bower 直接与 GitHub 通信以获取包管理数据。如果你系统上没有安装 Git,你可以在 git-scm.com 找到 Linux、Mac 和 Windows 的安装说明。
配置你的 bower.json 文件
设置你的 bower.json 文件的过程与 NPM 的 package.json 文件类似。它使用相同的 JSON 格式,既有 dependencies 也有 devDependencies,并且也可以自动创建:
$ bower init
一旦你在命令行中输入 bower init,你将被提示定义几个属性,其中一些默认值在括号内给出:
? name: my-app
? version: 0.0.0
? description: My app description.
? main file: index.html
? what types of modules does this package expose? (Press <space> to? what types of modules does this package expose? globals
? keywords: my, app, keywords
? authors: Philip Klauzinski
? license: MIT
? homepage: http://gui.ninja
? set currently installed components as dependencies? No
? add commonly ignored files to ignore list? Yes
? would you like to mark this package as private which prevents it from being accidentally published to the registry? Yes
小贴士
这些问题可能因你安装的 Bower 版本而异。
在 bower.json 文件中,大多数属性在你不打算将你的项目发布到 Bower 注册表的情况下是不必要的。你很可能会想将你的包标记为私有,除非你计划注册它并允许其他人将其作为 Bower 包下载。
一旦你创建了 bower.json 文件,你可以在文本编辑器中打开它,更改或删除你想要的任何属性。它应该看起来像以下示例:
{
"name": "my-app",
"version": "0.0.0",
"authors": [
"Philip Klauzinski"
],
"description": "My app description.",
"main": "index.html",
"moduleType": [
"globals"
],
"keywords": [
"my",
"app",
"keywords"
],
"license": "MIT",
"homepage": "http://gui.ninja",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"private": true
}
如果你希望保持你的项目私有,你可以在继续之前将你的 bower.json 文件减少到两个属性:
{
"name": "my-app",
"private": true
}
一旦你以你喜欢的样子设置了 bower.json 文件的初始版本,你就可以开始为你的应用程序安装组件。
Bower 组件位置和 .bowerrc 文件
Bower 默认会将组件安装到名为 bower_components 的目录中。这个目录将直接位于你的项目根目录下。如果你希望将你的 Bower 组件安装到不同的目录名下,你必须创建一个名为 .bowerrc 的本地系统文件,并在其中定义自定义目录名:
{
"directory": "path/to/my_components"
}
只需一个具有单个 directory 属性名的对象,就可以定义你的 Bower 组件的自定义位置。在 .bowerrc 文件中可以配置许多其他属性。有关配置 Bower 的更多信息,请参阅 bower.io/docs/config/。
Bower 依赖项
Bower 允许您定义与 NPM 类似的 dependencies 和 devDependencies 对象。然而,与 Bower 的区别在于,dependencies 对象将包含运行您的应用程序所需的组件,而 devDependencies 对象则保留用于测试、转译或其他不需要包含在前端堆栈中的组件。
Bower 包使用 CLI 中的 bower 命令进行管理。这是一个用户命令,因此不需要超级用户(sudo)权限。让我们从将 jQuery 作为应用程序的前端依赖项安装开始:
$ bower install jquery --save
命令行上的 --save 选项会将包和版本号保存到 bower.json 文件中的 dependencies 对象中。或者,您可以使用 -S 选项作为 --save 的快捷方式:
$ bower install jquery -S
接下来,让我们安装 Mocha JavaScript 测试框架作为开发依赖项:
$ bower install mocha --save-dev
在这种情况下,我们将使用命令行上的 --save-dev 来将包保存到 devDependencies 对象中。现在,您的 bower.json 文件应该类似于以下示例:
{
"name": "my-app",
"private": true,
"dependencies": {
"jquery": "~2.1.4"
},
"devDependencies": {
"mocha": "~2.3.4"
}
}
或者,您可以使用 -D 选项作为 --save-dev 的快捷方式:
$ bower install mocha -D
您会注意到,默认情况下,包版本号前面带有波浪线符号 (~),这与 NPM 中的 caret (^) 符号不同。波浪线作为对包版本更新的更严格的保护。使用 MAJOR.MINOR.PATCH 版本号,运行 bower update 只会更新到最新的补丁版本。如果版本号仅由主版本和次要版本组成,bower update 将将包更新到最新的次要版本。
搜索 Bower 注册表
所有注册的 Bower 组件都通过命令行索引和可搜索。如果您不知道要安装的组件的确切包名,可以执行搜索以检索匹配名称的列表。
大多数组件在其 bower.json 文件中都会有一个关键词列表,这样您就可以更容易地找到包,而无需知道确切的名称。例如,您可能想安装 PhantomJS 用于无头浏览器测试:
$ bower search phantomjs
返回的列表将包括任何包名中包含 phantomjs 或在其关键词列表中的包:
phantom git://github.com/ariya/phantomjs.git
dt-phantomjs git://github.com/keesey/dt-phantomjs
qunit-phantomjs-runner git://github.com/jonkemp/...
parse-cookie-phantomjs git://github.com/sindresorhus/...
highcharts-phantomjs git://github.com/pesla/highcharts-phantomjs.git
mocha-phantomjs git://github.com/metaskills/mocha-phantomjs.git
purescript-phantomjs git://github.com/cxfreeio/purescript-phantomjs.git
您可以从返回的列表中看到,PhantomJS 的正确包名实际上是 phantom,而不是 phantomjs。现在您知道了正确的名称,可以继续安装包:
$ bower install phantom --save-dev
现在您已经安装了 Bower 并了解了如何管理您的前端 Web 组件和开发工具,但如何将它们集成到您的 SPA 中呢?这正是 Grunt 发挥作用的地方。
Grunt
Grunt 是 Node.js 的 JavaScript 任务运行器,如果你之前没有使用过它,那么它可能是你从未意识到需要的最佳工具。你会发现它在包括 CSS 和 JavaScript 代码检查和压缩、JavaScript 模板预编译、LESS 和 SASS 预处理等众多任务中非常有用。确实有 Grunt 的替代品,但它们都没有像 Grunt 那样庞大的插件生态系统(截至撰写本文时)。
Grunt 有两个组件:Grunt CLI 和 Grunt 任务运行器 本身。Grunt CLI 允许你在安装了 Grunt 的目录中从命令行运行 Grunt 任务运行器命令。这使得你可以在机器上的每个项目中运行不同版本的 Grunt,从而使每个应用程序更容易维护。有关更多信息,请参阅 gruntjs.com。
安装 Grunt CLI
你将想要全局安装 Grunt CLI,就像你安装 Bower 一样:
$ npm install -g grunt-cli
请记住,Grunt CLI 并非 Grunt 任务运行器。它只是将 grunt 命令从命令行提供给你。这种区别很重要,因为虽然 grunt 命令将在命令行中全局可用,但它始终会在你运行它的目录中寻找本地安装。
安装 Grunt 任务运行器
你将从应用程序根目录(其中包含你的 package.json 文件)安装 Grunt 任务运行器本地版本。Grunt 作为 Node.js 包安装:
$ npm install grunt --save-dev
一旦你在本地安装了 Grunt,你的 package.json 文件应该看起来像以下示例:
{
"name": "my-app",
"version": "1.0.0",
"author": "Philip Klauzinski",
"license": "MIT",
"devDependencies": {
"grunt": "⁰.4.5"
}
}
你会注意到,如果之前没有安装,你的 package.json 文件中已经添加了一个 devDependencies 对象。
现在你已经在本地上安装了 Grunt,让我们开始安装一些插件来与之配合使用。
安装 Grunt 插件
所有 Grunt 任务插件都是 Node.js 包,因此它们也将使用 NPM 进行安装。由于 Grunt 是一个开源项目,因此有成千上万的 Grunt 插件由众多作者编写。每个 Grunt 的 Node.js 包名称都以 grunt 开头。然而,Grunt 团队也维护了许多插件。官方维护的 Grunt 插件都以 grunt-contrib 开头,因此如果你只想使用官方维护的 Grunt 插件,你可以通过这种方式来区分它们。要查看和搜索所有注册的 Grunt 插件,请参阅 gruntjs.com/plugins。
由于你将编写一个 JavaScript SPA,让我们首先安装一个用于 Grunt 的 JavaScript 代码检查 插件。代码检查是指运行一个程序来分析你的代码,以查找错误,在某些情况下,还可以检查格式。始终运行代码检查工具来测试你的 JavaScript 代码的有效语法和格式是一个好主意:
$ npm install grunt-contrib-jshint --save-dev
这将安装官方维护的 JSHint Grunt 插件,并将其添加到 package.json 文件中的 devDependencies 对象中,如下所示示例:
{
"name": "my-app",
"version": "1.0.0",
"author": "Philip Klauzinski",
"license": "MIT",
"devDependencies": {
"grunt": "⁰.4.5",
"grunt-contrib-jshint": "⁰.11.3"
}
}
JSHint是一个流行的工具,用于检测 JavaScript 代码中的错误和潜在问题。Grunt 插件本身将允许你自动化此过程,这样你就可以在开发过程中轻松检查你的代码。
另一个非常有价值的 Grunt 插件是grunt-contrib-watch。此插件允许你运行一个任务,当你在项目中添加、删除或编辑与预定义规则匹配的文件时,它会自动运行其他 Grunt 任务。
$ npm install grunt-contrib-watch --save-dev
安装grunt-contrib-watch插件后,你的package.json文件中的devDependencies对象应如下所示:
"devDependencies": {
"grunt": "⁰.4.5",
"grunt-contrib-jshint": "⁰.11.3",
"grunt-contrib-watch": "⁰.6.1"
}
现在你已经安装了一些 Grunt 插件,让我们开始为它们编写一些任务。为了做到这一点,你首先需要为 Grunt 创建一个本地配置文件。
配置 Grunt
与 NPM 和 Bower 不同,Grunt 不提供用于初始化其配置文件的init命令。相反,可以使用脚手架工具来完成这项工作。项目脚手架工具旨在为开发项目设置一些基本的目录结构和配置文件。Grunt 维护一个官方的脚手架工具,称为grunt-init,该工具在他们的网站上有所提及。grunt-init工具必须与grunt-cli全局包和特定项目的本地grunt包分开安装。如果全局安装,它将非常有用,因为这样就可以与任何项目一起使用。
$ npm install -g grunt-init
我们在这里不会进一步详细介绍grunt-init,但如果你想了解更多,可以访问gruntjs.com/project-scaffolding。
了解 Grunt 配置的最佳方式是手动编写其配置文件。Grunt 的配置保存在一个名为Gruntfile.js的文件中,称为Gruntfile,位于项目的根目录中,与package.json和bower.json一起。如果你不熟悉 Node.js 及其模块和导出概念,Gruntfile的语法可能一开始会有些令人困惑。由于 Node.js 文件在服务器上运行,而不是在浏览器中,因此它们与浏览器中加载的文件在交互方式上并不相同,这与浏览器的globals有关。
理解 Node.js 模块
在 Node.js 中,模块是一个定义在文件内的 JavaScript 对象。模块名称是文件名。例如,如果你想声明一个名为foo的模块,你需要创建一个名为foo.js的文件。为了使foo模块能够被另一个模块访问,它必须被导出。在最基本的形式下,一个模块看起来像以下示例:
module.exports = {
// Object properties here
};
每个模块都有一个本地的exports变量,它允许你使模块对他人可用。换句话说,文件内的module对象指的是当前模块本身,而module的exports属性使该模块对任何其他模块(或文件)可用。
定义模块的另一种方式是通过导出一个函数,这当然本身也是一个 JavaScript 对象:
module.exports = function() {
// Code for the module here
};
当你在文件内部调用 Node.js 模块时,它首先会寻找一个核心模块,所有这些模块都是编译到 Node.js 本身中的。如果名称不匹配核心模块,它将会从你的项目的当前目录或根目录开始寻找名为 node_modules 的目录。这个目录就是所有你的本地 NPM 包,包括 Grunt 插件,将被存储的地方。如果你之前执行了 grunt-contrib-jshint 和 grunt-contrib-watch 的安装,你将看到这个目录现在存在于你的项目中。
现在你对 Node.js 模块的工作方式有了更多的了解,让我们创建一个 Gruntfile。
创建一个 Gruntfile
Gruntfile 使用了之前展示的函数形式的 module.exports。这被称为 包装函数。grunt 模块本身被传递给包装函数。grunt 模块将可用于你的 Gruntfile,因为你已经在本地上安装了 grunt NPM 包:
module.exports = function(grunt) {
// Grunt code here
};
这个例子展示了你的初始 Gruntfile 应该是什么样子。现在让我们进一步展开它。为了配置 Grunt 并使用它运行任务,你需要访问传递给 Gruntfile 的 grunt 模块。
module.exports = function(grunt) {
'use strict';
grunt.initConfig({
pkg: grunt.file.readJSON('package.json')
});
};
这种基本格式是你接下来将要使用的方式。你可以看到这里调用了 grunt.initConfig 方法,并传递了一个配置对象作为参数。这个配置对象就是所有你的 Grunt 任务代码将要放置的地方。在这个例子中显示的 pkg 属性,它被分配了 grunt.file.readJSON('package.json') 的值,允许你直接从你的 package.json 文件中传递关于你的项目的信息。这个属性的用法将在后面的例子中展示。
定义 Grunt 任务配置
大多数 Grunt 任务期望它们的配置定义在具有与任务相同名称的属性中,这是包名的后缀。例如,jshint 是我们之前安装的 grunt-contrib-jshint 包的 Grunt 任务名称:
module.exports = function(grunt) {
'use strict';
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
jshint: {
options: {
curly: true,
eqeqeq: true,
eqnull: true,
browser: true,
newcap: false,
es3: true,
forin: true,
indent: 4,
unused: 'vars',
strict: true,
trailing: true,
quotmark: 'single',
latedef: true,
globals: {
jQuery: true
}
},
files: {
src: ['Gruntfile.js', 'js/src/*.js']
}
}
});
};
在这里,你可以看到配置对象的 jshint 属性被定义,并且被分配了自己的属性,这些属性适用于 jshint Grunt 任务本身。jshint 中定义的 options 属性包含了你在检查 JavaScript 文件时希望验证的设置。files 属性定义了你希望验证的文件列表。有关 JSHint 支持的选项及其含义的更多信息,请参阅 jshint.com/docs/。
现在让我们在 jshint 任务配置下方添加一个额外的配置,用于 grunt-contrib-watch 插件的 watch 任务:
watch: {
jshint: {
files: ['js/src/*.js'],
tasks: ['jshint']
}
}
在这里,我们在 watch 任务下添加了一个额外的 jshint 命名空间,这允许在同一个配置属性中定义其他 目标,并在需要时单独运行。这被称为 多任务。多任务中的目标可以任意命名,如果单独调用多任务,它们将按照定义的顺序运行。目标也可以直接调用,这样做将忽略多任务配置中定义的其他目标:
$ grunt watch:jshint
这个特定的 jshint 目标配置告诉 watch 任务,如果任何匹配 js/src/*.js 的文件被更改,则运行 jshint 任务。
现在你已经在你的 Gruntfile 中定义了前两个 Grunt 任务配置,但为了使用它们,我们必须加载 Grunt 任务本身。
加载 Grunt 插件
你已经将 grunt-contrib-jshint 插件作为 Node.js 模块安装了,但为了执行 jshint 任务,你必须在 Gruntfile 中加载该插件。这是在 grunt.initConfig 调用之后完成的:
grunt.loadNpmTasks('grunt-contrib-jshint');
这是你在 Gruntfile 中加载所有 Grunt 任务的相同方法调用,并且如果不这样做,任何 Grunt 任务都将不可用。让我们为 grunt-contrib-watch 也做同样的事情:
grunt.loadNpmTasks('grunt-contrib-watch');
你的完整 Gruntfile 现在应该看起来像这样:
module.exports = function(grunt) {
'use strict';
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
jshint: {
options: {
curly: true,
eqeqeq: true,
eqnull: true,
browser: true,
newcap: false,
es3: true,
forin: true,
indent: 4,
unused: 'vars',
strict: true,
trailing: true,
quotmark: 'single',
latedef: true,
globals: {
jQuery: true
}
},
files: {
src: ['Gruntfile.js', 'js/src/*.js']
}
},
watch: {
jshint: {
files: ['js/src/*.js'],
tasks: ['jshint']
}
}
});
grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-watch');
};
运行 jshint Grunt 任务
现在你已经加载了插件,你可以简单地从命令行运行 grunt jshint 来执行带有其定义配置的任务。你应该会看到以下输出:
$ grunt jshint
Running "jshint:files" (jshint) task
>> 1 file lint free.
Done, without errors
这将运行你的 JSHint 检查选项与定义的文件,目前只包括 Gruntfile.js。如果看起来像示例文件所示,并且包括对 grunt.loadNpmTasks('grunt-contrib-jshint') 的调用,那么它应该没有错误通过。
现在让我们创建一个新的 JavaScript 文件,并故意包含一些不会通过 JSHint 配置的代码,这样我们就可以看到错误是如何报告的。首先,创建 js/src 目录,该目录在 jshint 任务的 files 属性中定义:
$ mkdir -p js/src
然后在当前目录下创建一个名为 app.js 的文件,并将以下代码放入其中:
var test = function() {
console.log('test');
};
现在再次从命令行运行 grunt jshint。你应该会看到以下输出:
$ grunt jshint
Running "jshint:files" (jshint) task
js/src/app.js
2 | console.log('test');
^ Missing "use strict" statement.
1 |var test = function() {
^ 'test' is defined but never used.
>> 2 errors in 2 files
Warning: Task "jshint:files" failed. Use --force to continue.
Aborted due to warnings.
你会注意到根据 jshint 任务配置选项,js/src/app.js 报告了两个错误。让我们通过将 app.js 中的代码更改为以下内容来修复这些错误:
var test = function() {
'use strict';
console.log('test');
};
test();
现在如果你再次从命令行运行 grunt jshint,它将报告文件没有错误:
$ grunt jshint
Running "jshint:files" (jshint) task
>> 2 files lint free.
Done, without errors.
运行 watch Grunt 任务
如前所述,当运行 watch 任务时,它将等待与配置中定义的文件模式匹配的更改,并运行任何相应的任务。在这种情况下,我们配置了当任何匹配 js/src/*.js 的文件被更改时运行 jshint。由于我们在 watch 任务中定义了一个名为 jshint 的目标,因此 watch 任务可以以两种不同的方式运行:
$ grunt watch
运行 grunt watch 将监视 watch 任务中定义的所有目标配置的变化:
$ grunt watch:jshint
使用冒号 (:) 语法运行 grunt watch:jshint 将只运行与该目标配置匹配的文件模式。在我们的例子中,只定义了一个目标,所以让我们只运行 grunt watch 并看看控制台会发生什么:
$ grunt watch
Running "watch" task
Waiting...
你会看到现在任务在命令行上显示为 Waiting... 的状态。这表明任务正在运行以监视其配置中的匹配更改,如果发生任何这些更改,它将自动运行相应的任务。在我们的 jshint 任务例子中,它将允许你的代码在每次你更改并保存你的 JavaScript 文件时自动进行代码检查。如果发生 JSHint 错误,控制台将发出警报并显示错误。
让我们通过打开一个文本编辑器并再次更改 js/src/app.js 来测试它:
var test = function() {
console.log('test');
};
test()
在这里,我们移除了文件末尾 test() 调用后的 use strict 语句和分号。这应该会引发两个 JSHint 错误:
>> File "js/src/app.js" changed.
Running "jshint:files" (jshint) task
js/src/app.js
2 | console.log('test');
^ Missing "use strict" statement.
4 |test()
^ Missing semicolon.
>> 2 errors in 2 files
Warning: Task "jshint:files" failed. Use --force to continue.
Aborted due to warnings.
现在我们来纠正这些错误,并将文件恢复到之前的状态:
var test = function() {
'use strict';
console.log('test');
};
test();
在任何时间从命令行按 Ctrl + C 可以中止正在运行的 watch 任务,或者任何 Grunt 任务。
定义默认的 Grunt 任务
Grunt 允许你定义一个 default 任务,当你没有参数地简单地输入 grunt 到命令行时,这个任务将会运行。为此,你将使用 grunt.registerTask() 方法:
grunt.registerTask('default', ['jshint', 'watch:jshint']);
这个例子将默认的 Grunt 任务设置为首先运行定义的 jshint 任务,然后运行 watch:jshint 多任务目标。你可以看到传递给 default 任务的都是在数组中,所以你只需在命令行上简单地输入 grunt,就可以为 Grunt 设置运行任意数量的任务:
$ grunt
Running "jshint:files" (jshint) task
>> 2 files lint free.
Running "watch:jshint" (watch) task
Waiting...
从输出中可以看出,jshint 任务最初运行了一次,然后运行 watch:jshint 来等待配置文件模式的额外更改。
定义自定义任务
Grunt 允许你定义你自己的自定义任务,就像你定义默认任务一样。这样,你实际上可以直接在 Gruntfile 中编写你自己的自定义任务,或者你可以从外部文件加载它们,就像你处理 grunt-contrib-jshint 和 grunt-contrib-watch 一样。
别名任务
定义自定义任务的一种方式是简单地按照你想要它们运行的顺序调用一个或多个现有任务:
grunt.registerTask('my-task', 'My custom task.', ['jshint']);
在这个例子中,我们简单地定义了一个名为 my-task 的任务,作为 jshint 的代理。第二个参数是任务的可选描述,它必须是一个字符串。第三个参数,传递一个数组,在这个例子中只包括 jshint,必须始终是一个数组。你也可以省略描述参数,直接在那里传递你的任务数组。这种定义任务的方式被称为 别名任务。
基本任务
当你定义自定义 Grunt 任务时,你不仅限于调用配置中存在的其他任务,你还可以编写可以直接作为函数调用的 JavaScript 代码。这被称为基本任务:
grunt.registerTask('my-task', 'My custom task.', function() {
grunt.log.writeln('This is my custom task.');
});
在这个例子中,我们只是将一个字符串写入任务的命令行输出。输出应该看起来像这样:
$ grunt my-task
Running "my-task" task
This is my custom task.
让我们在此基础上扩展这个例子,向我们的基本任务函数传递一些参数,以及从函数内部访问这些参数:
grunt.registerTask('my-task', 'My custom task.', function(arg1, arg2) {
grunt.log.writeln(this.name + ' output...');
grunt.log.writeln('arg1: ' + arg1 + ', arg2: ' + arg2);
});
你会注意到基本任务有一个属性this.name可用,它只是对任务名称的引用。为了从命令行调用基本任务并传递参数,你将在任务名称后使用冒号来定义每个参数的连续性。这种语法与运行多任务目标的语法类似;然而,在这种情况下,你正在传递任意参数:
$ grunt my-task:1:2
运行此命令将输出以下内容:
Running "my-task:1:2" (my-task) task
my-task output...
arg1: 1, arg2: 2
如果你没有传递参数给一个期望参数的任务,它将简单地将其解析为undefined:
$ grunt my-task
Running "my-task" task
my-task output...
arg1: undefined, arg2: undefined
Done, without errors.
你也可以在自定义任务中调用其他任务:
grunt.registerTask('foo', 'My custom task.', function() {
grunt.log.writeln('Now calling the jshint and watch tasks...');
grunt.task.run('jshint', 'watch');
});
在这个例子中,我们创建了一个名为foo的任务,它定义了一个自定义函数,该函数调用现有的jshint和watch任务:
$ grunt foo
Running "foo" task
Now calling the jshint and watch tasks...
Running "jshint:files" (jshint) task
>> 2 files lint free.
Running "watch" task
Waiting...
想要了解更多关于使用 Grunt 创建自定义任务的信息,请访问 gruntjs.com/creating-tasks。
这些任务的例子只是触及了 Grunt 所能做到的一小部分,但你应该能够从中领悟到它的强大之处,并开始思考在构建自己的 SPA 时,Grunt 任务可能实现的可能性。
摘要
现在你已经学会了如何使用 NPM 设置一个最佳的开发环境,使用 Bower 提供前端依赖,以及使用 Grunt 自动化开发任务,现在是时候开始学习更多关于构建真实应用程序的知识了。在下一章中,我们将深入探讨常见的 SPA 架构设计模式,它们的意义,以及根据你构建的 SPA 类型,最佳的设计模式是什么。
第二章. 模型-视图-任何东西
如果你是一名前端开发者,你可能不熟悉被称为 模型-视图-控制器(MVC)的传统软件架构模式。近年来,这种模式的变体通过 Backbone.js、Ember.js 和 AngularJS 等框架进入了前端软件架构模式。无论你在这些领域的经验如何,本章将通过以下主题区域讨论所谓的 模型-视图-任何东西(MVW)模式的演变及其与 SPA 开发的相关性:
-
原始的 MVC 模式
-
模型-视图-展示(MVP)/模型-视图-视图模型(MVVM)解释
-
视图-交互器-展示器-实体-路由器(VIPER)和其他 MVW 变体
-
AngularJS 和 MVW
-
在 SPA 中使用 MVW 模式
原始的 MVC 模式
MVC 软件架构模式自 20 世纪 70 年代以来以某种形式存在,但随着其在 Ruby on Rails、CakePHP 和 Django 等网络应用框架中的应用,它变得更加流行并被广泛接受。这类 MVC 框架为网络应用开发带来了比以往更高的组织和复杂性水平,并在这样做的同时,为现代单页应用(SPA)的发展铺平了道路。
要了解 MVC 对现代 SPA 开发的相关性,让我们首先分解 MVC 的组件和理念。
模型
MVC 的 模型 组件处理应用的数据。这包括显示给用户的数据、从用户接收的数据以及存储在数据库中的数据。此外,模型处理与数据库的所有 创建、读取、更新、删除(CRUD)操作。许多框架还使用模型来处理应用的业务逻辑,即数据在保存或查看之前应该如何被操作,但这并不一定是标准。
简单来说,MVC 网络应用中的模型是应用数据的表示。这些数据可能包括与应用相关的任何内容,例如当前用户的信息。传统的网络应用框架使用关系数据库,如 MySQL 来存储数据。然而,现代 SPA 架构现在越来越多地倾向于文档导向数据库,通常被称为 NoSQL。MongoDB 和许多其他 NoSQL 数据库使用 JSON 文档来存储记录。这对于前端架构来说非常好,因为 JavaScript 可以直接解析 JSON,在 MEAN 栈的情况下,JSON 数据是架构每一层的原生数据。
让我们以当前网络应用的用户信息为例。我们将称之为 用户模型 :
{
"id": 1,
"name": {
"first": "Philip",
"last": "Klauzinski"
},
"title": "Sr. UI Engineer",
"website": "http://webtopian.com"
}
这样的简单 JSON 文档将从数据库返回到您的应用,由 JavaScript 直接解析。在文档导向数据库中不需要任何 结构化查询语言(SQL),因此有 NoSQL 这个术语。
视图
MVC 的核心组件是 视图,如果你是前端开发者,你很可能最熟悉它。视图体现了用户与之交互的一切,在 Web 应用程序的情况下,是浏览器所消耗的内容。传统的 MVC 框架从服务器端提供视图,但在 JavaScript SPA 和使用类似 MEAN 栈的架构的情况下,视图完全包含在前端。从开发和资产管理角度来看,这使得维护变得更加容易,因为处理服务器端和前端视图的双重性质不存在。
在 JavaScript SPA 中,视图的模板使用 HTML 与某种类型的 Web 模板系统(如 Underscore、Handlebars 或 Jade)混合编写,仅举几个例子。Web 模板系统允许您的 HTML 标记被 JavaScript 解析并评估表达式,这些表达式将动态数据和内容放置在您的视图中。例如,让我们看看一个简单的 Handlebars 模板,它使用了之前提到的用户模型:
<h1>User Information</h1>
<dl>
<dt>Name</dt>
<dd>{{name.first}} {{name.last}}</dd>
<dt>Title</dt>
<dd>{{title}}</dd>
<dt>Website</dt>
<dd>{{website}}</dd>
</dl>
假设有一个 AJAX 请求用于获取当前登录用户的资料,SPA 通过 GET 请求返回用户模型的 JSON 文档。该 JSON 文档中的属性可以直接插入到该请求的视图。在 Handlebars 的例子中,使用一对开闭花括号({{ ... }})或双花括号表示法来标识模板内要解析的表达式。在这种情况下,这些表达式仅仅是用户的姓氏、名字和头衔。有关 Handlebars 模板的更多信息,请参阅 handlebarsjs.com。
控制器
MVC 模式中的 控制器 组件在不同框架之间变化最大,因此作为一般概念,用真正的清晰度来定义它是最困难的。在像 Ruby on Rails 或 CakePHP 这样的传统 Web 应用 MVC 框架中,控制器从用户那里接收以 Web 请求或 动作 的形式输入,在渲染新响应到视图之前对模型进行更改。以下图表显示了 MVC 范式中控制器的流程:

(来自维基百科的图表 - en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller#Components)
通过这种控制器表示法,很容易看出它如何封装大量的应用程序代码,实际上,在与某些 MVC Web 框架一起工作时,很难知道在控制器逻辑、模型业务规则、视图验证规则以及许多其他 Web 应用程序常见组件之间如何划清界限。控制器的这种模糊性质导致许多现代 Web 框架的作者决定完全放弃使用 控制器 这个术语,并采用一个新的概念来替代。
MVC 中的模型和视图组件易于理解,并且可以在 Web 应用程序中区分它们的目的,但 Controller 并不是那么清晰。现在让我们探讨一些最近 Web 应用程序架构模式中取代 Controller 的概念。
MVP 和 MVVM
术语 模型-视图-任何东西 出现在许多包含模型和视图的架构模式兴起之际,但用不同的概念替换了核心组件的 Controller,甚至替换了多个组件。
MVP
MVP 是 MVC 架构模式的一种变体,其中 Presenter 组件取代了 Controller。在这个模式中,Presenter 也充当 Controller 的角色,但它承担了额外的责任,即处理视图的展示逻辑。这种范式背后的推理是通过让视图本身包含很少或没有展示逻辑来增强应用程序的可测试性。
MVP 和 MVC 之间的另一个关键区别是,MVP 中的 Presenter 与视图有一个一对一的关系,这意味着为每个视图定义了一个独特的 Presenter,而 MVC 允许 Controller 与视图有一对多的关系。换句话说,MVC 允许为 Controller 定义任意数量的视图,并且每个视图都映射到该 Controller 的 操作。MVP 只映射一个视图到 Presenter。此外,MVP 禁止视图和模型之间的直接关系,这又是为了通过将业务逻辑排除在视图之外来增强可测试性:

(来自维基百科的图表 - zh.wikipedia.org/wiki/模型-视图-控制器)
MVVM
MVVM 是 MVC 架构模式的一种变体。在这个范式中的 ViewModel 是当前用户会话中模型数据的表示。对 ViewModel 的更改总是在对模型进行任何更改之前进行的。
MVVM 与 MVP 类似,因为视图对模型没有了解,但相比之下,视图与 ViewModel 之间有一个多对一的关系。这意味着多个视图可以映射到一个 ViewModel。ViewModel 组件也与 MVP 中的 Presenter 相对立,因为它对视图没有了解。相反,视图有一个对 ViewModel 的引用,这使得它可以根据 ViewModel 的更改进行更新。
然而,与 SPA 开发中的其他架构模式相比,MVVM 的主要区别在于支持双向数据绑定。这意味着 ViewModel 的变化会自动反映在视图中,而用户在视图中对数据的变化也会自动更新 ViewModel。这使得 MVVM 成为现代 SPA 开发中更可行的模式,因为视图可以更新并保持与 ViewModel 的同步,而无需新的页面请求,这在传统的 MVC 或 MVP 架构中是必需的:

(图来自social.technet.microsoft.com/wiki/contents/articles/13347.mvvm-model-view-viewmodel-part-1.aspx)
数据绑定将在第六章、数据绑定,以及为什么你应该接受它中进一步讨论。
总结 MVC、MVP 和 MVVM 之间的区别
现在你应该对 MVC 架构模式及其 MVP 和 MVVM 变体有一个基本的了解。对这些概念有全面的理解并不是学习 JavaScript SPA 开发所必需的,但了解可以组成多层堆栈的组件类型是很重要的。下面是一个图解,突出了本节中讨论的三个架构模式之间的关键区别:

(图来自geekswithblogs.net/dlussier/archive/2009/11/21/136454.aspx)
VIPER 和其他 MVW 变体
现代架构模式远离 MVC 的主要原因是 MVC 中的控制器通常包含太多的应用程序代码,变得难以管理,因此难以测试。这导致了不仅用其他东西替换控制器,而且在它的位置添加多个层,以进一步在应用程序中建立关注点的分离。
VIPER
在iOS的世界里,苹果的移动操作系统,MVC 长期以来一直被鼓励作为遵循的模式。然而,最近,许多 iOS 开发者已经远离了纯 MVC,并采用了在应用程序架构中建立超过三个层的模式。其中一种模式是VIPER,代表视图(View)、交互器(Interactor)、展示者(Presenter)、实体(Entity)和路由(Routing)(或路由器)。
让我们简要地介绍一下这些组件各自是什么:
-
视图:与 MVC 一样,视图代表用户界面。
-
交互器:包含应用程序中特定行为和对应视图的业务逻辑。交互器类似于 MVC 中的控制器,但它可能与多个模型交互,并且不受限于仅与一个模型交互。
-
展示者:包含视图的逻辑,就像 MVP 一样。
-
实体:是“模型”的另一种说法,简单来说就是用来实现VIPER缩写中的“E”。
-
路由:在应用程序中,每个请求都是通过一个唯一的调用进行的,在 Web 应用程序的情况下,使用浏览器中的 URL 或路由来发起应用程序请求。这一层也可以称为路由器。
从 VIPER 组件的描述中可以看出,它们实际上并不是按照缩写本身的顺序流动,而是为了美观目的而这样排序。下面的图表显示了 VIPER 模式的真正流程,以及浏览器和数据库的表示,以补充对这种流程的理解:

(来自khanlou.com/2014/03/model-view-whatever/的图表)
MVW 的其他变体
到目前为止,我们已经涵盖了传统的 MVC 架构模式、MVP、MVVM 以及最近提出的 VIPER 模式。应该清楚的是,这些在 MVC 之后出现的模式并不代表一个完整的范式转变,而是对传统控制器组件的重构,以体现更多的清晰性,在 VIPER 的情况下,则是进一步的关注点分离。在这些其他模式中,一般范式并没有丢失,因为模型和视图的概念仍然保持不变。这种趋势导致了通用范式术语模型-视图-任何东西或 MVW。
我们剩下的是许多作为 MVC 抽象的架构模式。那么,对于 JavaScript SPA,你应该选择哪种模式呢?这是一个非常主观的话题,所以最好的答案是,你应该根据你正在构建的应用程序类型来选择模式,同时也基于对你来说最有意义和最舒适的东西。
你选择的软件库和框架也应该考虑你使用哪种模式。在这方面,让我们看看 AngularJS 是如何为它的 MVW 版本调整 MVC 的。
AngularJS 和 MVW
AngularJS 是一个用于构建 Web 应用程序的前端 JavaScript 框架,它是 MEAN 堆栈的核心组件。它为开发者提供了使用自定义 HTML 属性和元素来驱动应用程序内行为的能力。它还提供了一些实用的功能,如双向数据绑定和依赖注入。
AngularJS 的简要历史
AngularJS 最初是两位 Google 开发者的一个侧项目,但最终成为了一个官方的 Google 开源项目。自其诞生以来,它在方法论上经历了许多变化,包括从宣传 MVC 作为首选模式转变为不再宣传。相反,AngularJS 团队现在将其标记为一个 JavaScript MVW 框架(截至写作时)。
声明 AngularJS 为 MVW 的原因是对开发者社区中关于 AngularJS 遵循何种模式的广泛辩论和混淆的反应。标签本身可能对某些开发者来说并不重要,但它有助于强调 AngularJS 使用的架构模式比传统的 MVC 更复杂。然而,AngularJS 的确包括控制器组件等。让我们更详细地看看这些组件是什么。
AngularJS 组件
AngularJS 是为了创建网页应用而设计的,因此它包括了一些在传统 MVC 中不存在的概念组件。同时,请记住 AngularJS 只是一个前端框架,所以它与后端框架和数据库解决方案无关。
Template
AngularJS 中的 模板 是一个包含特殊标记的 HTML 文档,允许它被解析以处理动态数据,就像任何网页模板系统一样。AngularJS 使用自己的专有网页模板系统,而不是第三方系统,如 Handlebars。然而,与 Handlebars 一样,AngularJS 也使用双大括号符号来标识 HTML 标记中的表达式:
<html ng-app="myApp">
<head>
<script src="img/angular.js"></script>
<script src="img/app.js"></script>
</head>
<body ng-controller="UsersController">
<ul>
<li ng-repeat="user in users">
{{user.first_name}} {{user.last_name}}
</li>
</ul>
</body>
</html>
这是一个简单的 AngularJS 模板的例子。你可以看到它就像一个正常的 HTML 文档一样构建,但它还包括 AngularJS 表达式。你还会注意到有一些特殊 HTML 属性以 ng- 为前缀,这些属性将不同类型的应用信息传达给 AngularJS 框架。
Directives
指令是 AngularJS 用来在 DOM 中驱动行为的特殊 HTML 标记。一个指令可以通过以 ng 为前缀的自定义 HTML 属性、自定义 HTML 元素名称(如 <my-element></my-element>)、注释或 CSS 类来驱动。
你可以为你的应用定义自己的指令,但 AngularJS 也包括一些预定义的指令用于常见用例。例如,前一个示例中显示的 ng-repeat 属性使用了内置的 ngRepeat 指令,该指令用于在迭代集合时渲染模板标记:
<ul>
<li ng-repeat="user in users">
{{user.first_name}} {{user.last_name}}
</li>
</ul>
在这个例子中,users 对象被迭代,每个 user 的属性从模板中渲染出来。
Model
Model 是当前 View 中可用于表达式的变量数据的表示。一个 View 可用的 Model 被限制在特定的 Scope 或上下文中:
$scope.users = [
{
id: 1,
first_name: 'Peebo',
last_name: 'Sanderson'
},
{
id: 2,
first_name: 'Udis',
last_name: 'Petroyka'
}
];
在这个例子中,一个 users 数组被注册在 $scope 对象上。这使 users 变量暴露给可以访问此特定 Scope 的模板。
Scope
Scope 是一个 JavaScript 对象,它定义了 View 中变量的 Model 上下文。正如前一个示例所示,$scope.users 将在该 Scope 的 View 中以 {{users}} 的形式访问。
Expressions
AngularJS 中的 表达式 就像任何网页模板系统中的表达式一样,如前所述。AngularJS 中使用双大括号符号来标识表达式:
<ul>
<li ng-repeat="user in users">
{{user.first_name}} {{user.last_name}}
</li>
</ul>
在这个例子中,{{user.first_name}}和{{user.last_name}}是 AngularJS 表达式。
编译器
编译器解析模板标记,并对其中的指令和表达式进行评估,以驱动视图中的行为和数据。AngularJS 编译器是框架内部的,通常不会直接访问或与之交互。
过滤器
过滤器用于在视图中格式化表达式,以便以特定方式呈现。例如,视图可能从模型中接收到一个以数字形式表示的货币金额。可以通过添加过滤器到表达式中来格式化用户看到的货币值,并带有货币符号。管道|符号在双大括号符号内用于附加过滤器:
<p><strong>Cost:</strong> {{ total | currency }}
在这个例子中,total代表表达式,而currency代表过滤器。
视图
就像传统的 MVC(模型-视图-控制器)一样,AngularJS 中的视图是用户界面。视图由模板组成,在 AngularJS 应用程序的上下文中,这两个术语在很大程度上是可以互换的。
数据绑定
AngularJS 中的数据绑定是双向的,或者说双向的,因此视图中的数据变化会更新到模型中,而模型中的数据变化会更新到视图中。这是自动完成的,无需任何额外的业务逻辑来处理这些变化。
控制器
控制器在 AngularJS 中实际上是一个视图控制器,因为它是一个纯前端框架。像传统的 MVC 一样,控制器包含业务逻辑,但这种业务逻辑仅与视图相关:
var myApp = angular.module('myApp', []);
myApp.controller('UsersController', function($scope) {
$scope.users = [
{
id: 1,
first_name: 'Peebo',
last_name: 'Sanderson'
},
{
id: 2,
first_name: 'Udis',
last_name: 'Petroyka'
}
];
});
例如,可以创建一个UsersController,其中包含之前显示的users模型,并通过其$scope对象将其暴露在视图中。
依赖注入
术语依赖注入在 JavaScript 中通常与异步向当前网页添加资源的能力相关联。在 AngularJS 中,这个概念类似,但仅限于其他 AngularJS 组件。例如,指令(Directives)、过滤器(Filters)和控制器(Controllers)都是可注入的。
注入器
注入器是依赖项的容器,负责在需要时查找并添加它们。它通过视图中的声明性语法与应用程序代码解耦,通常不会直接访问。
模块
模块是应用程序所有主要组件的容器。它为应用程序提供了一个主命名空间引用,包括所有相关的指令(Directives)、服务(Services)、控制器(Controllers)、过滤器(Filters)以及任何额外的配置信息:
Var myAppModule = angular.module('myApp', []);
如果你的模块依赖于任何其他模块,你可以将它们添加到前一个例子中显示的空数组参数中。
要将模块应用于使用 AngularJS 的 SPA(单页应用),你可以在主页的 HTML 中简单地使用自定义的ng-app属性在你的应用容器元素中声明模块的名称:
<body ng-app="myApp">
服务
服务是区分 AngularJS 和传统 MVC 的组件,因为它用于包含可重用的业务逻辑,你可能在应用程序的不同控制器之间共享这些逻辑。这有助于防止控制器变得过大和复杂,并允许应用程序的不同部分共享一些常用的业务逻辑。例如,货币转换可以编写为一个服务,因为你可能想在多个控制器中使用它。
下面的图表说明了 AngularJS 的组件如何相互交互:

(来自 dzone.com/refcardz/angularjs-essentials 的图表)
AngularJS 2.x(在撰写本文时处于测试版)在架构模式上与 v1.x 不同,这里展示的是 v1.x 版本。
现在你已经更好地理解了构成 AngularJS MVW 架构模式的组件以及这些组件如何与前端 SPA 架构相关,让我们将这些 MVW 原则应用到简单的 JavaScript SPA 示例中。
在 SPA 中使用 MVW 模式
现在应该很清楚,MVW 不是一个精确的架构模式,而是一种范式,其中你有一个模型、一个视图,以及一个模糊的第三组件,或者更多组件,这取决于你决定如何细化你的关注点分离。所有落在那个灰色区域的东西都是基于你正在构建的应用程序类型、作为开发者的你感到舒适的架构组件,以及你正在使用的框架和库。
构建 JavaScript SPA
你的 SPA 的复杂性应该是你选择构建它的技术的一个因素。更具体地说,你不应该假设每个项目你都会使用某个特定的技术栈或框架。这个规则也适用于 MEAN 栈。
让我们以前面的用户模型示例和相应的 Handlebars 模板视图为例,实际上构建一个 SPA,包括用于检索用户模型数据的 AJAX 请求。对于这样简单的东西,使用 AngularJS 和 MEAN 栈无疑是过度设计。让我们从使用你在 第一章**使用 NPM、Bower 和 Grunt 进行组织中设置的 NPM、Bower 和 Grunt 环境 开始。那么我们该如何进行呢?
创建模型
模型是我们之前定义的简单 JSON 用户数据对象。而不是为这个设置数据库,让我们简单地将它放在一个文本文件中,命名为 user.json:
{
"id": 1,
"name": {
"first": "Philip",
"last": "Klauzinski"
},
"title": "Sr. UI Engineer",
"website": "http://webtopian.com"
}
将文件保存到与你的 package.json、bower.json 和 Gruntfile.js 相同的目录下。在这个示例中,你可以随意替换用户信息。
创建视图
本例的视图将是之前定义的包含用户信息的网页模板文档:
<h1>User Information</h1>
<dl>
<dt>Name</dt>
<dd>{{name.first}} {{name.last}}</dd>
<dt>Title</dt>
<dd>{{title}}</dd>
<dt>Website</dt>
<dd>{{website}}</dd>
</dl>
将此文件保存到您的项目根目录,并命名为 user.handlebars。
设置前端资产
在这个例子中,我们不会创建一个复杂的 SPA,因此我们不会使用任何前端框架,但我们确实想安装一些库以简化开发。
如果您遵循了 第一章、使用 NPM、Bower 进行组织 和 Grunt 的示例,您应该已经通过 Bower 安装了 jQuery。如果您尚未安装它,请现在安装:
$ bower install jquery --save
我们将使用 jQuery 在 SPA 中处理 AJAX 请求和 DOM 操作。
现在让我们安装 Handlebars 库以解析我们的网页模板视图:
$ bower install handlebars --save
编译网页模板
在解析表达式之前,网页模板必须编译为 JavaScript。这可以在浏览器中使用 Handlebars 前端库完成,但这意味着加载模板时的执行时间会更长,同时也意味着在初始页面加载时加载更大的库资产文件。对于 SPA 来说,初始页面加载是至关重要的,因为您不希望用户长时间等待您的应用程序下载资产并准备初始视图。此外,如果您想将视图分离到单独的文件中,就像我们用 user.handlebars 做的那样,那么这些视图文件必须在某个时刻异步加载,以便传递给编译器。
预编译网页模板
为了绕过大量资产负载以及服务器往返以获取视图的额外往返,Handlebars 允许您将网页模板 预编译 为 JavaScript,以便它们可以在您的应用程序中立即使用。这使您能够将视图分离到不同的文件中,保持事物组织有序,同时仍然保持较低的初始页面加载。
对于这个例子,让我们全局安装 Handlebars Node.js 包,以便可以在任何目录中使用命令行:
$ npm install handlebars -g
这将允许您在命令行中编译模板,创建一个预编译的 JavaScript 模板文件,您可以在您的 SPA 中使用。从您的项目目录的根目录开始,输入以下命令:
$ handlebars *.handlebars -f templates.js
此命令告诉 Handlebars 编译器将所有扩展名为 .handlebars 的文件(在这种情况下仅为 user.handlebars)编译到一个名为 templates.js 的单个文件中。这可以使您拥有 100 个独立的网页模板视图文件,并将它们预编译到一个 JavaScript 文件中,例如。这是一个好习惯,因为它允许您将每个视图文件映射到服务器端的 REST API 端点。在我们的 SPA 示例中,我们的端点将通过 AJAX 请求 user.json 文件。
处理服务器 HTTP 请求
现在我们将安装 PayloadJS 库以在 SPA 中处理 REST 请求:
$ bower install payloadjs --save
PayloadJS 将允许我们通过使用自定义的 data- HTML 属性在 DOM 中定义行为和参数,轻松地从我们的 SPA 标记中触发 AJAX 请求。
创建 SPA 布局
单页应用(SPA)最重要的部分之一是单页本身,或者说是你应用的布局。这是你将加载的唯一一个服务器端 HTML 页面,用于初始化和显示你的应用。
在你的目录根目录下创建一个名为index.html的文件,并将以下代码输入到其中:
<!doctype html>
<html>
<head>
<title>My Application</title>
</head>
<body>
<p><a href="#" data-url="/user.json" data-template="user" data-selector=".results">Load user data</a></p>
<div class="results"></div>
<script src="img/jquery.min.js"></script>
<script src="img/handlebars.runtime.min.js"></script>
<script src="img/payload.js"></script>
<script src="img/templates.js"></script>
<script>
Payload.deliver({
templates: Handlebars.templates
});
</script>
</body>
</html>
这将是你的 SPA 的主要布局页面。你会注意到已经添加了script标签引用,指向 jQuery、Handlebars、PayloadJS 和我们创建的templates.js文件。这些都是你需要加载以运行此 SPA 的资产。此外,Payload.deliver()命令在页面底部运行,并传递一个对象以覆盖其任何默认初始化选项。此方法简单初始化 PayloadJS 以驱动 DOM 中data-属性指示的行为。在这种情况下,我们正在设置传递给Handlebars.templates的templates属性,因为它是包含我们的 Handlebars 模板的命名空间。
关于使用 PayloadJS 的更多信息,请参阅 payloadjs.com。
服务器端渲染 SPA
现在,你已经放置了所有必要的文件来运行这个简单的 SPA。唯一剩下的事情是运行一个本地服务器来加载和测试index.html文件。让我们使用 NPM 安装一个简单的 HTTP 服务器来完成这个目的:
$ npm install http-server -g
将此包全局安装,以便可以从命令行运行。这个简单的 Node.js HTTP 服务器可以指定任何本地目录作为你的服务器。在这种情况下,我们想要运行当前项目目录的服务器:
$ http-server ./
运行此命令后,你应该能在你的控制台看到以下类似的输出:
Starting up http-server, serving ./
Available on:
http:127.0.0.1:8080
http:192.168.0.2:8080
Hit CTRL-C to stop the server
这表明 HTTP 服务器正在本地运行并可用。
现在,你应该能够打开浏览器并加载 URL localhost:8080,你将看到你创建的index.html页面的内容。页面上唯一可见的内容是带有文本加载用户数据的链接。如果一切设置正确,并且你点击该链接,你应该会注意到短暂的加载中...指示器,随后是user.handlebars模板文件的内容,这些内容是从加载到页面中的user.json文件中获取的:

点击链接后的完整页面应类似于前面的截图。
简单 JavaScript SPA 概述
因此,我们使用以下组件创建了一个简单的 JavaScript SPA,采用通用的 MVW 模式:
-
模型:
user.json -
视图:
user.handlebars -
预编译的模板文件:
templates.js -
SPA 布局页面:
index.html -
HTTP 服务器:Node.js http-server 包
这已经是最简单的情况了,但你仍然创建了一个 SPA。这个例子应该能给你一个关于 JavaScript 在创建单页应用方面多么强大的概念。你可以自由地通过添加更多的模型数据文件、额外的网页模板视图、一些 CSS 和一点自己的创意来扩展这个简单的 SPA。
摘要
你现在应该已经理解了传统的 MVC 模式、MVP、MVVM、VIPER,以及从传统 MVC 和约定过渡到更通用的 MVW 模式的理由。你也应该明白,“模型-视图-任何东西”(Model-View-Whatever),或简称 MVW,这个术语在很大程度上是由 AngularJS 团队推广的,这是为了满足现代单页应用(SPA)需要的新和更复杂的组件集,而这些组件在原始 MVC 模式构思时并不存在。
你现在也应该有能力仅使用几个 Node.js 和 Bower 包来构建一个简单的 JavaScript SPA。现在,让我们继续前进,做一些更大、更好的事情。在下一章中,我们将讨论如何通过扩展我们迄今为止一直在使用的 Node.js 环境,来创建一个理想的 SPA 应用程序开发环境。
第三章. 单页应用(SPA)基础 - 创建理想的应用程序环境
现在,你应该已经相当熟悉 Node.js 生态系统中的模块、任务和包管理。在本章中,我们将更深入地探讨 JavaScript SPA 及其依赖的复杂性。我们将通过以下主题来探索各种数据格式和数据库类型、SPA 封装架构模式等:
-
JSON 和其他数据格式
-
SQL 和 NoSQL 数据库之间的区别
-
何时使用 SQL 相比 NoSQL 数据库
-
展示单页应用程序容器的各种方法
-
提供和管理布局
JSON 数据格式
JavaScript 对象表示法(JSON)是当今大多数 JavaScript 开发者都非常熟悉的东西。尽管其名称如此,JSON 实际上是一个与语言无关的标准,它实际上只是一个文本文档,在它被用作表示具有名称-值对的对象的 数据或作为简单值序列之前,必须首先由 JavaScript 或任何语言解释器进行解析。
JSON 的缩写中包含单词 JavaScript 的原因是因为其格式基于 JavaScript 对象和数组的结构。这就是为什么处理 JSON 数据和 JavaScript 是如此直接,以及为什么在 JavaScript 应用程序中消费 JSON 数据非常有意义。
我们在 第二章 中创建的 user.json 文件的内容,模型-视图-任何 是 JSON 数据交换格式的一个示例:
{
"id": 1,
"name": {
"first": "Philip",
"last": "Klauzinski"
},
"title": "Sr. UI Engineer",
"website": "http://webtopian.com"
}
JSON 遵循标准 JavaScript 对象的格式,但还必须遵守一些重要规则才能有效:
-
属性名必须用双引号格式化
-
值可以是双引号中的字符串、数字、
true或false、对象或数组 -
对象和数组可以嵌套
-
字符串中的双引号必须使用反斜杠转义
这些规则允许 JSON 格式直接解析为原生 JavaScript,同时仍然足够严格,使其成为跨语言之间易于交换的格式。尽管原生 JavaScript 对象表示法不强制要求在属性名周围使用双引号,但对于 JSON 来说这是必需的,以防止发生 JavaScript 保留字异常。
JavaScript 中的保留字不允许用作变量或函数名,因为它们代表语言的一些当前或潜在的未来结构。例如,保留字 class 经常被缺乏经验的开发者误用作 CSS 类的变量名:
Var class = 'myClass';
这个例子会抛出异常,因为 class 是一个保留字。此外,将其用作 JavaScript 对象中的直接属性名也会抛出异常:
{
class: 'myClass'
}
有经验的 JavaScript 开发者会知道不要将这个单词用作属性名,因为它是一个保留字,但如果你的应用程序正在从外部源消费 JSON 数据,你无法控制可能随对象一起拉入的属性名。例如,你可能从运行在另一个服务器上的应用程序中检索数据,该服务器不是 JavaScript,并且没有意识到任何可能消费它的应用程序的保留字限制。如果这个应用程序想要传达 CSS 类信息,它很可能使用单词 "class" 来实现:
{
"class": "myClass"
}
在这个例子中,属性名是有效的,因为它被双引号包围,因此被解析为字符串而不是保留字。因此,要求属性名周围使用双引号的规则被严格执行,没有任何 JSON 解析器会允许没有双引号的属性名。
其他数据格式
JSON 首次由 Douglas Crockford 在 2001 年构想出来。在此之前,数据交换一直是通过使用已经与许多编程语言集成的既定格式来实践的。
XML
在 JSON 众所周知之前,可扩展标记语言(XML)是使用最广泛的网络应用程序数据交换格式之一。XML 首次在 1996 年推出,并成为国际标准。它是一种 标准通用标记语言(SGML)的形式,由 万维网联盟(W3C)创建:
<?xml version="1.0" encoding="UTF-8"?>
<note>
<to>Tobums Kindermeyer</to>
<from>Jarmond Dittlemore</from>
<heading>A Message</heading>
<body>Hello world!</body>
</note>
这是一个简单的 XML 文档示例。如果你之前没有使用过 XML,你很可能至少听说过它。XML 是许多其他数据格式的先驱,包括 SOAP、RSS、Atom 和 XHTML。XHTML 也为许多网络开发者所熟知,在 HTML5 规范引入之前,它是提供网页的推荐标准。注意,前面示例的格式与 HTML 类似。
YAML
YAML 是一个递归缩写词,意味着它指的是自己,即 YAML Ain't Markup Language。除了它的愚蠢名字之外,使 YAML 有趣的是,它的层次结构语法要求使用行和缩进来作为分隔符,而不是 JSON 中使用的结构化括号和方括号:
item-key-one:
- list item 1
- list item 2
item-key-two:
nested_key_one: this is an associative array
nested_key_two: end the associative array
YAML 的语法设计是为了使数据的层次结构更容易被人类阅读,它要求使用行和空格来明确界定其结构。相比之下,其他使用括号等字符定义结构的格式,在压缩格式下可能难以向人类眼睛传达层次结构。
YAML 最初是在与 JSON 大致相同的时间创建的,但它并没有像 JSON 在 Web 开发社区中那样获得那么多的知名度。YAML 在灵活性方面可能比 JSON 更胜一筹,因为它允许更多的功能,如注释和关系锚点,但可能是 JSON 的简单性使其成为 Web 应用程序以及更广泛的数据消费中的更受欢迎的数据格式。
BSON
二进制 JSON (BSON) 是 JSON 的二进制形式,主要用于 MongoDB 面向文档的数据库系统的数据存储格式。BSON 与 JSON 类似,主要区别在于 BSON 支持更复杂的数据类型,如日期、时间戳和ObjectId。在 BSON 和 MongoDB 中,ObjectId是一个 12 字节的唯一标识符,用于存储的对象。MongoDB 要求每个对象都有一个名为_id的唯一标识符字段,而ObjectId是为此字段赋值的默认机制。这个概念与关系型数据库系统中的主键类似。
一个使用ObjectId和Timestamp数据类型的 BSON 文档可能看起来像这样:
{
"_id": ObjectId("542c2b97bac0595474108b48"),
"timestamp": Timestamp(1412180887, 1)
}
当我们在本文中讨论 MongoDB 和面向文档的数据库时,术语 JSON 可能与 BSON 互换使用,其含义是理解这种区别。您可以在 bsonspec.org 上了解更多关于 BSON 规范的信息。
为什么 JSON 如此盛行?
JSON 简单、易于阅读,并以一种几乎所有现有的编程语言都能轻松理解的方式结构化。列表(或数组)和名称-值对(或关联数组)是计算机语言中的基本概念和常见实现。格式越简单,解析起来就越容易,因此更多平台将开发出一种固有的方式来消费该数据格式。JSON 的情况就是这样。
此外,JSON 规范在最初开发后只更改了几次。其创造者 Douglas Crockford 故意没有为规范指定版本号,以便使其成为固定不变的,并且随着时间的推移不能改变。这可能是 JSON 在数据格式中占据主导地位的最大因素。由于它不会随时间改变,因此为消费它而构建的跨众多编程语言和平台的解析器也不需要改变。这创造了一个生态系统,其中 JSON 在每个地方都只有一个版本,使其完全可预测、广泛理解且几乎坚不可摧。
SQL 和 NoSQL 数据库之间的差异
在第二章中,我们简要讨论了面向文档的数据库,也称为 NoSQL 数据库。这个概念对于 MEAN 栈至关重要,因为 MEAN 缩写中的M代表 MongoDB,这是一个广泛使用的 NoSQL 数据库实现。NoSQL 数据库在概念上与传统的关系型,或 SQL,数据库有所不同。
非关系型数据库已经存在了几十年,但直到最近才得到广泛应用。这种流行度的上升导致了术语NoSQL首先被应用于这些类型的数据库。NoSQL 数据库使用量增加的主要原因主要是为了解决处理大数据的问题,即大规模和复杂的数据集,以及在现代 Web 应用程序中水平扩展这些数据。
NoSQL 数据类型
术语 NoSQL 意味着非 SQL,这暗示它是一种非关系型数据库类型。像 MongoDB 这样的面向文档的 NoSQL 数据库将它们的数据存储在由结构化 JSON 对象表示的文档中。这种类型的 NoSQL 数据库中的数据类型由数据本身定义,就像标准 JSON 一样:
{
"id": 1
}
例如,如果你在 NoSQL 数据库中有一个键为id的字段,其值为1,一个数字,你可以轻松地将该值更改为myID,一个字符串,而无需更改对该数据类型的任何其他引用:
{
"id": "myID"
}
以这种方式,该值的数据类型完全取决于其定义。在关系型数据库中,进行这种更改不会那么直接。
关系型数据类型
与面向文档的数据库相比,传统的 SQL 数据库使用表格来结构化其数据。每个表格列都设置为特定的数据类型,存储在该列下的数据必须遵守定义的类型。如果你有一个大型的 SQL 数据库,并且希望更改特定列的类型,这可能会带来潜在的问题,并且可能需要更改在数千行数据上执行。与更改 JSON 文档中的数据类型相比,这相对容易,因为它只涉及更改数据本身,并且没有跨多个记录定义数据类型的表格列的概念。
关系型数据库中的关系术语指的是存储数据的表格关系。每个数据表被视为一个关系,因为其中存储的不同数据以某种方式相互关联,这种关联由将消费它的应用程序和程序定义。SQL 数据库中的一个表可以与 NoSQL 数据库中的一个 JSON 对象相比较。然而,两者之间最大的区别是,表由行和列组成,数据通过列类型和包含相关数据记录的行进一步关联。在 NoSQL 数据库中,没有行和列的概念,数据可以无限嵌套。
为了检索 SQL 数据库中的嵌套数据,还必须在表之间识别关系。由于数据实际上不能嵌套,因此必须使用一个或多个表到一个或多个其他表的引用来创建用于应用程序模型和视图的相关数据集。SQL 是一种编程语言,用于管理从关系型数据库表中提取数据,并以满足应用程序所需的方式对其进行格式化。
ACID 事务
大多数 NoSQL 数据库系统不支持符合ACID属性的事务,ACID代表原子性、一致性、隔离性和耐用性。这一组属性是数据库以可靠方式处理事务所必需的。事务是对数据库的任何更改。这种更改可以是单个表中某个字段的单个值,也可以是跨越多个表并影响这些表中的多行的更改。大多数广泛使用的数据库管理系统都支持事务的 ACID 属性,无论执行的操作有多复杂。
原子性
ACID 的原子性属性指的是数据库内的原子操作,意味着事务所需的更改必须确保全部发生,否则不会发生任何更改。这一属性提供了一种保证,即不会进行部分更改,这可能导致数据集损坏。如果一个原子事务在数据库中的任何一点失败,那么到该点为止所做的更改将回滚到其之前的状态。
一致性
ACID 的一致性属性要求事务只能根据该数据库系统定义的有效数据更改。这包括确保数据不被破坏,在必要时强制执行回滚,以及执行与事务相关的所有必要数据库触发器。
隔离性
ACID 的隔离性属性要求同时执行的事务或并发不会导致相关数据出现数据库错误。这可以涉及不同级别的严格性,取决于所使用的数据库系统。隔离性的主要目标是确保一组并发事务的最终结果与如果你依次回放它们是相同的。隔离性与一致性紧密相关,并且始终应确保一致性得到维护。
耐用性
ACID 的耐用性属性要求在执行过程中事务不会被丢失。你可以想象在事务执行过程中可能会发生任何数量的计算机故障,比如断电。当发生类似情况时,耐用性确保数据库系统记住正在执行中的事务,通过将其记录到磁盘并确保即使在重启后也不会丢失。
MongoDB 和 ACID
确实,许多 NoSQL 数据库系统并不符合 ACID 属性;然而,MongoDB 在一定程度上做到了。正如之前提到的,MongoDB 是一个面向文档的数据库系统,它是 NoSQL 数据库的一个更简洁的子集。以这种方式,MongoDB 能够在单文档级别支持 ACID 事务。它不能支持多文档事务,因此在这方面它不如大多数关系型数据库,后者可以在多个表中支持 ACID 事务,但 MongoDB 在文档级别仍然在面向文档的数据库中脱颖而出。
MongoDB 的写入前日志
MongoDB 吹嘘的另一个超过其他特性的功能是写入前日志(WAL)。这是一组功能,允许数据库系统符合 ACID 的原子性和持久性属性。为此,MongoDB 在执行操作之前,将所有操作及其结果记录到一个内部日志中。这是一种简单而有效的方式来确保文档级别事务的持久性,因为所有操作都在执行之前进行了记录,因此即使在操作突然中断的情况下,也不会丢失发生事件的证据。同样,这个特性确保了原子性,因为它使 MongoDB 能够在确定更改内容并将其与中断操作之前数据库的状态进行比较后,重新启动时能够撤销和重做这些操作。
何时使用 SQL 数据库与 NoSQL 数据库
SQL 数据库和 NoSQL 数据库之间显然存在重大差异,这不仅体现在它们的结构上,还体现在开发者和应用程序如何与之交互。这些差异从架构和功能的角度来看,都可能对应用程序的开发产生严重影响。这就是为什么选择数据库类型不是一件小事,在开始应用程序的开发之前,应该始终对其进行彻底评估。
可扩展性
之前提到,现代 Web 应用程序的需求导致了 NoSQL 数据库的流行。可扩展性,即持续处理不断增长的数据量和数据操作的能力,是这些需求之一。你可以想象这种情况适用于像 Facebook 或 Twitter 这样的社交媒体公司,以及可能与此类资源交互的任何其他社交应用程序。在下面的内容中,可扩展性是在决定为应用程序选择哪种类型的数据库时可能需要考虑的一个特性。
水平扩展
尤其是对于越来越多的现代网络应用来说,水平扩展是必需的。这指的是随着用户基础的扩大,需要在地理上分布服务器和数据库。有效的水平扩展使得应用程序的用户能够从离他们最近的服务器接收数据,而不是从可能位于世界另一端的数据仓库中的单个服务器或服务器组。
在关系型数据库中实现水平扩展当然不是不可能的,但这很困难,并且需要使用复杂的数据库管理系统(DBMS)。另一方面,NoSQL 数据库在设计上更简单,这使得在机器和网络集群之间进行数据复制变得更加简单。
大数据
现代网络应用还需要另一个需求,即大数据,这可以确切地意味着其名称所暗示的:大量数据。然而,更多的时候,大数据指的是数据集之间的高度复杂性,以至于没有使用复杂的分析技术,很难从中分析和提取价值。
NoSQL 数据库非常适合处理大数据,因为它们支持动态模式设计,这简单意味着在存储之前,您不需要为数据集定义特定的模式,正如传统的关系型数据库所要求的。这回到了 NoSQL 中数据类型的灵活性,它不需要字段类型受规则控制,就像表格数据模式中的列一样。此外,关系型数据库表的模式不能更改,否则会影响到该表的所有数据。相比之下,例如,JSON 文档中特定数据集的模式可以随时更改,而不会影响该文档中先前存储的数据集。
如果大数据是您网络应用的可预见需求,那么在选择数据库类型之前,您应该进一步考虑大数据的类型。
操作大数据
操作大数据指的是在分布式应用程序中实时消耗和管理的数据,以支持当前运行过程中的操作。例如,MongoDB 这样的面向文档的数据库就是考虑到操作大数据的支持而构建的,并专注于提供并发读写操作的速度。
MongoDB 以及其他旨在与操作大数据一起工作的 NoSQL 系统,通过利用现代分布式网络计算能力来提高操作效率。然而,传统的数据库系统在开发时并没有考虑到这种能力,因为那时的计算机系统更加孤立。
分析大数据
分析型大数据与操作型大数据有很大不同,因为它的重点是大规模并行处理(MPP)。这意味着消耗了大量的数据,随后为了给使用该应用的应用提供价值,对这些数据进行各种分析。与操作型大数据相比,为分析型大数据设计的数据库系统专注于巨大的吞吐量和数据的回顾性处理,而不是并发、无关操作的速度。
在开发应用时,处理分析型大数据的需求并不总是从一开始就明显。预测这种需求往往很困难,因为当你从一个小的数据集开始时,你可能不知道随着时间的推移,你可能会想要分析哪些大量的数据。幸运的是,这个问题可以通过在确定其需求后实施解决方案来解决。MPP 数据库就是为了这个特定目的而构建的,此外还有MapReduce,它是一种替代实现,用于在集群中的分布式计算机上处理 MPP。
总体考虑因素
在决定为你的应用使用 SQL 或 NoSQL 数据库时,你应该考虑应用在初始发布时的需求以及你预见的未来需求。如果你预期会有一个庞大的用户基础和病毒式增长的可能性,那么你可能需要考虑一个专为处理操作型大数据和可扩展性而构建的 NoSQL 数据库。
如果你正在开发一个预期用户基础较小的应用,或者可能除了数据管理员外没有其他用户,那么关系型 SQL 数据库可能更合适。此外,如果你的应用可能有众多用户,但没有横向扩展的需求,SQL 数据库也可能是一个合适的选择。
还要考虑到许多现代、分布式 Web 应用最初只使用了关系型数据库,后来在现有数据库的基础上实现了 NoSQL 解决方案,以处理不断增长的需求。这也是一个可以在应用生命周期中根据需要规划和适应的场景。
展示 SPA 容器的方案
在单页应用中,容器是应用最初加载并显示给用户的对象。这个概念与软件容器不同,后者是一个应用生活的隔离环境,就像虚拟机一样。
对于单页 Web 应用,容器可以是<body>元素,或者<body>元素内的任何元素。例如,你可能在页面上用<p>元素加载一些初始的静态欢迎文本,然后单页应用(SPA)将在该元素下方动态加载到<div>元素中:
<!doctype html>
<html>
<body>
<p>This is some welcome text.</p>
<div class="container">
The application will be loaded here.
</div>
</body>
</html>
在这样的场景中,页面上总会有一些固定内容,这些内容不需要根据用户与应用程序的交互而改变。这是一个简单的例子,但同样的方法也可以用于常见的 <header>、<footer> 和 <nav> 元素:
<!doctype html>
<html>
<body>
<header>Static header content</header>
<div class="container">
The application will be loaded here.
</div>
<footer>Static footer content</footer>
</body>
</html>
如何定义您的 SPA 容器
定义您的 SPA 容器的正确方式并不存在;这实际上完全取决于您正在构建的应用程序类型以及您的偏好。它也可能取决于您用于提供布局的系统(即用于容纳您的应用程序的 HTML 页面)的客户端限制。
部分页容器
如前几节所示,您可能希望在应用程序加载之前在您的 SPA 布局中显示一些静态内容。当您预计应用程序的初始加载时间较长,或者需要某些用户交互来触发应用程序的加载时,这很有用。
全页容器
如果您的应用程序可以通过XMLHttpRequest(通常称为Asynchronous JavaScript and XML(AJAX))完全控制,那么除非您想要,否则不需要在您的 SPA 布局中加载任何静态内容。您可能在布局中加载静态内容的一个原因是为用户提供在等待应用程序加载期间查看或阅读的内容。当您预计应用程序的初始加载时间较长,并且希望帮助防止用户在应用程序的初始状态准备好之前离开时,这尤其有用。
在 SPA 中的状态指的是在任何时间点上的特定版本的Document Object Model(DOM)。一个加载状态是在等待容器元素加载下一个请求的状态时您可能会在其中显示的状态。某种类型的加载指示器通常足以让用户知道正在发生某些事情,并且应用程序很快就会加载,但当您的应用程序有任何过度的延迟时,用户可能会认为出了问题,并在过程完成之前离开应用程序布局页面。
如何加载您的 SPA 容器
您最初加载 SPA 的方式高度依赖于您应用程序的性质。在您的应用程序加载之前,可能需要满足任何数量的要求。
用户交互时的加载
许多 Web 应用程序在完全加载 SPA 之前需要某种类型用户交互。例如:
-
用户身份验证
-
用户接受进入协议
-
必须显示并由用户参与或忽略的间隔内容,例如广告
这样的场景在 Web 应用程序中相当常见,并且通常很难以流畅的方式解决。
登录页面过渡
在许多 Web 应用程序中,登录界面在安全页面上加载,并使用 HTTP POST 提交到另一个安全页面以验证用户并加载实际的 SPA。这种模式通常由于正在使用的服务器端框架在处理身份验证方面的限制而被采用。
如果您考虑一个用于访问您在手机上登录的银行账户的金融应用程序,您在通过用户名和密码进行身份验证之前,可能看不到比登录界面更多的内容。这通常会将您带到第二个页面,该页面加载了包含您敏感银行信息的完整单页应用程序,而这些信息您不希望其他人拿起您的手机时也能看到。
登录界面可以说是最常见的一种需要用户交互来加载应用程序的使用场景,而且通常处理得并不优雅。如果您的 REST 框架允许这样做,处理这种用例最流畅的方式是将登录界面作为 SPA 的一部分加载,并通过登录表单从 REST 端点请求身份验证。当您从 API 请求中收到正确验证的响应时,您可以将所需的数据加载到现有的 SPA 容器中,并用新的已登录状态替换登录状态。
基于 DOMContentLoaded 事件加载
如果您的 SPA 不需要用户身份验证或任何其他交互来初始加载,或者如果您在页面加载时检测到已经验证的用户,并且可以跳过该步骤,那么您将需要一个方法来自动在初始页面加载时尽快加载您的 SPA。
加载单页应用程序的最佳时机通常是 DOM 完全加载并且可以被浏览器解析的时候。现代浏览器在发生这种情况时会在document对象上触发一个事件,称为DOMContentLoaded,这可以用于此目的。为此,您只需在document上添加一个EventListener来检测事件何时被触发,然后调用一个函数来加载您的应用程序:
<script>
document.addEventListener('DOMContentLoaded', function(event) {
loadMyApp();
});
</script>
或者,如果您正在使用 jQuery,您可以调用方便的 jQuery .ready() 方法来监听 DOMContentLoaded 事件,并在匿名函数中触发您的自定义应用程序代码:
<script>
$(document).ready(function() {
loadMyApp();
});
</script>
基于文档 readystatechange 事件加载
现代浏览器还提供了一个在首次加载页面时在document对象上触发的事件,称为readystatechange。此事件可以用来确定 DOM 的三个状态,这些状态通过以下方式通过document.readyState属性返回:
-
loading- 这是指文档仍在加载且尚未被浏览器完全解析的情况。 -
interactive- 这是指所有 DOM 元素都已加载并可以访问,但某些外部资源可能尚未完全加载,例如图像和样式表。这种状态变化还表明DOMContentLoaded事件已被触发。 -
complete- 这是在所有 DOM 元素和外部资源都已完全加载时。
要使用readystatechange事件在DOMContentLoaded事件的同时加载您的应用程序,您需要将一个函数分配给在readystatechange事件上被调用的函数,然后检查document.readyState属性是否设置为interactive。如果是,那么您可以调用您的应用程序代码:
<script>
document.onreadystatechange = function() {
if (document.readyState === 'interactive') {
loadMyApp();
}
};
</script>
使用这种方法来检测文档的状态提供了更多的灵活性,在您想要为三个文档状态中的任何一个调用自定义应用程序代码时,而不仅仅是DOMContentLoaded事件或interactive状态时。
直接从document.body加载
加载<script>标签的更传统方式是将它们放置在文档的<head>元素中。如果您在您的外部 JavaScript 中使用DOMContentLoaded或readystatechange事件来在适当的时间初始化应用程序代码,那么将<script>标签添加到<head>中对于加载 SPA 来说是完全可以接受的:
<!doctype html>
<html>
<head>
<script src="img/app.js"></script>
</head>
<body>
<div class="container">
The application will be loaded here.
</div>
</body>
</html>
然而,如果您想避免使用这些自定义 DOM 事件,并在需要时精确触发您的应用程序代码,则可以采取不同的更直接的方法。
在当今的网页中加载 JavaScript 的常见技术是将加载您的外部 JavaScript 文件的<script>标签直接放置在页面的<body>元素中。能够这样做的原因在于浏览器解析 DOM 的方式:从上到下。
以此代码为例:
<!doctype html>
<html>
<body>
<div class="container">
The application will be loaded here.
</div>
<script src="img/app.js"></script>
</body>
</html>
从document.body内部和紧接在关闭的</body>标签上方加载外部 JavaScript 文件app.js将确保在加载之前解析所有位于<script>标签之上的 DOM 元素,并且app.js文件将在<div class="container">元素之后精确加载。如果该元素是您将加载 SPA 的地方,那么这种技术确保了app.js中的应用程序代码将在解析container元素之后立即执行。
另一个优点是在 DOM 底部和加载应用程序所需的元素附近加载您的<script>标签是,由于浏览器的自上而下的 DOM 解析,这些<script>标签的加载不会阻塞它们上方内容的加载。一旦到达<script>标签,可能会有一些阻塞阻止浏览器在加载时可用,但用户至少会看到直到那个点已经加载的页面上的一切。
因此,在<body>中靠近 DOM 底部的位置加载<script>标签,而不是使用传统的<head>标签插入,这样可以避免在页面上任何内容可见之前发生阻塞。
使用<script>标签的async属性
防止<script>标签在加载时阻塞浏览器可用性的一个方法是使用async属性。可以将此属性添加到app.js文件中,以确保一旦解析,该文件就会异步加载,并且 DOM 的其余部分将继续解析和加载,无论该脚本的加载何时完成:
<!doctype html>
<html>
<body>
<div class="container">
The application will be loaded here.
</div>
<script src="img/app.js" async></script>
</body>
</html>
这种方法的优点是,同样没有阻塞。然而,缺点是,当你异步加载多个脚本时,无法保证它们加载和最终执行的顺序。这就是为什么尽可能只加载一个压缩的 JavaScript 文件也是一项良好的实践。脚本标签越少,需要解析和下载的外部资源就越少。在使用async属性的情况下,只使用一个<script>标签意味着只需等待一个异步资源加载,而不必担心多个文件加载的不可预测顺序,这可能会破坏你的应用。
使用<script>标签的defer属性
从<body>中直接加载<script>标签而不阻塞文档解析的另一种方法是使用defer属性。与async不同,此属性确保在文档解析完成或DOMContentLoaded事件发生之前,不会加载<script>标签。
使用defer属性,你的<script>标签可以放置在<body>中的任何位置,并始终保证在DOMContentLoaded事件之后加载:
<!doctype html>
<html>
<body>
<script src="img/app.js" defer></script>
<div class="container">
The application will be loaded here.
</div>
</body>
</html>
管理布局
如第二章所述,模型-视图-任意,与 SPA 相关的布局是用于存放、初始化和显示应用的 HTML 页面。布局将包含与上一节中关于如何加载 SPA 容器的示例类似的 HTML 标记。
布局通常是创建单页应用(SPA)所需的唯一原生服务器端组件,其他组件包括原生前端代码以及用于提供数据消费和操作端点的外部 API。
静态布局
布局可以是加载到 Web 服务器上的静态 HTML 页面,并在该页面上调用加载应用所需的资源,在定义的容器元素内。理想情况下,一旦加载了初始 HTML 页面,就不需要访问其他服务器端 HTML 页面来运行你的应用,这就是“单页应用”这个术语的由来。
如果你不需要任何服务器端框架交互来设置环境变量、测试登录状态等,那么静态 HTML 页面是启动 SPA 最快、最简单的方式。
一个静态 HTML 布局页面可能像以下示例那样简单:
<!doctype html>
<html>
<head>
<title>This is a static HTML page</title>
</head>
<body>
<div class="container">
The application will be loaded here.
</div>
<script src="img/app.js"></script>
</body>
</html>
使用静态 HTML 文件简单地作为 Web 服务器上的服务的一个缺点是,您必须直接访问该文件才能加载您的应用程序。例如,如果您的应用程序在 myapp.com 上,并且您的静态 HTML 布局页面命名为 index.html,大多数 Web 服务器会自动将 root 服务器请求路由到该页面,因此用户不需要直接导航到 myapp.com/index.html 来访问它,只需访问 myapp.com 即可。
然而,如果用户访问 myapp.com/profile,他们可能会找到他们的用户资料信息,应用程序布局将不会被加载,服务器将生成一个 HTTP 404,或 未找到,响应。为了提供这种用例并允许您的应用程序有自定义的 URL,需要一个 动态布局。
动态布局
当您控制单页应用程序的后端框架时,例如在使用 MEAN 栈时,您可能希望开发一个更动态的服务器布局页面,以便在应用程序最初加载时从服务器端加载变量和一些基本逻辑。
Express 是一个用于 Node.js 的服务器端 Web 框架,它是 MEAN 栈缩写中的 E。当您使用 MEAN 栈进行开发时,您将使用 Express 来定义和处理所有您的 REST API 端点,但您也想要处理您的主要应用程序入口点。
安装 Express
让我们回到我们一直在使用的 Node.js 环境,使用 NPM、Bower 和 Grunt,并安装 Express:
$ npm install express --save
在这种情况下,我们使用 --save 参数将 Express 保存到我们的主要 NPM 依赖项中,因为它不仅用于开发。
使用 Express 设置基本服务器
一旦您安装了 Express,请在您的应用程序根目录中创建一个名为 server.js 的文件:
$ touch server.js
在此文件中,添加以下代码以包含 Express 模块并初始化您的应用程序对象:
var express = require('express');
var app = express();
您的 server.js 文件中的 app 对象将允许您调用它来定义路由。在我们的 SPA 示例中,我们目前只需要定义一个路由。
使用 Express 进行基本路由
Express 中的路由是指定义用于响应服务器请求的 URL 路径。Express 可以定义任何类型的 HTTP 请求的路由,包括 GET、POST、PUT 和 DELETE 请求,这对于创建 REST API 是必要的。然而,在此阶段,我们只想定义一个用于加载 HTML 页面的路由。
为您的应用程序主入口点定义一个路由将是一个 GET 请求,使用 Express 来做这件事非常简单。在您刚刚创建的 server.js 文件中,在 app 对象定义下方添加以下代码:
app.get('/', function(request, response) {
response.sendFile('/index.html', {root: __dirname});
});
此命令添加了一个路由,它将为您之前创建的 index.html 文件提供服务,作为应用程序的根响应。第二个参数,它定义了一个 root 属性为 __dirname,只是将应用程序的根服务器路径设置为当前目录。
现在我们想使用 Express 来提供我们的应用程序,而不是之前简单的 http-server 模块。
使用 Express 运行服务器
现在你已经设置了 server.js 文件,并添加了一个指向应用程序根目录的基本路由,剩下要做的就是设置一个 HTTP 端口来监听并加载应用程序。在你的 server.js 文件中,向路由定义中添加以下代码:
app.listen(8080, function() {
console.log('App now listening on port 8080');
});
这告诉服务器在 HTTP 端口 8080 上监听以提供应用程序布局。现在你只需要从命令行运行服务器:
$ node server.js
这将运行服务器并在终端显示控制台消息 App now listening on port 8080。
现在转到浏览器中的 localhost:8080,你应该看到我们在 第二章 模型-视图-Whatever* 中创建的简单 SPA 页面。然而,你会在浏览器控制台中注意到一些错误,因为链接到 index.html 的本地 JavaScript 文件找不到。这是因为你还没有为加载静态资源文件定义路由。
使用 Express 加载静态资源
首先,通过在命令行中按 Ctrl + C 停止应用程序。现在再次编辑 server.js 并在 SPA 布局页面路由定义之上添加以下代码:
app.use('/', express.static('./'));
此命令将设置应用程序从根目录加载静态资源。现在如果你再次从命令行运行 nodeserver.js 并在浏览器中重新加载页面,SPA 应该加载所有资源并像之前使用 http-server 一样正常工作。
使用 Express 进行动态路由
如前所述,我们的应用程序应该允许用户访问类似 myapp.com/profile 或在我们的情况下 localhost:8080/profile 的位置,以加载一个将触发与应用程序主根视图不同的视图的动态请求。如果你现在在应用程序中访问 localhost:8080/profile,你将在浏览器中收到以下响应:
Cannot GET /profile
为了解决这个问题,再次停止本地服务器,编辑 server.js 文件,并对应用程序布局路由定义进行以下更改:
app.get('*', function(request, response) {
response.sendFile('/index.html', {root: __dirname});
});
在这里,我们只是将 GET 路由定义中的路径参数从 '/' 更改为 '*'。Express 允许在路由定义中使用正则表达式语法,所以这告诉服务器将所有动态路径请求路由到 index.html 页面,而不仅仅是根 '/' 路径。
保存此更改,现在如果你再次在命令行中运行 node server.js 并在浏览器中访问 localhost:8080/profile,你将再次看到从根路径显示的 SPA,并且所有静态资源文件都应该按预期加载。
在设置此基本的 Node.js Express 服务器之后,你的最终 server.js 文件应该看起来像这样:
var express = require('express');
var app = express();
app.use('/', express.static('./'));
app.get('*', function(request, response) {
response.sendFile('/index.html', {root: __dirname});
});
app.listen(8080, function() {
console.log('App now listening on port 8080');
});
我们简单的 SPA 现在变得更加复杂,能够提供动态布局文件,并使用动态路由通过自定义 URL 加载布局。
摘要
现在,你应该对各种数据交换格式有了更好的理解,例如 JSON、BSON、XML 和 YAML,以及它们在 Web 应用中的使用方式。你应该了解 SQL 和 NoSQL 数据库之间的区别,以及根据应用需求选择使用其中一种数据库的优势,并且你已经学习了 MongoDB 及其将 BSON 作为 JSON 的二进制形式的使用。此外,你还学习了如何使用 Web SPA 容器元素,以及将你的应用初始化和加载到该容器中的各种方法。
这些概念对于理解 SPA(单页应用)开发至关重要,并且有助于理解 MEAN 栈的内部工作原理以及它与其它应用开发架构的不同之处。
既然你已经对 Node.js 应用程序的客户端有了初步的了解,并且使用 Express 构建了一个基本的服务器,那么让我们进一步学习如何使用 Express,并了解如何在 SPA 内部创建 REST API 请求。
第四章. REST 最佳实践 – 与应用程序的后端进行交互
创建 JavaScript 单页应用程序所涉及的大多数开发工作通常将集中在前端,但不可忽视的是至关重要的数据传输层,它与服务器和数据库进行通信。表征状态转移(REST)是万维网和物联网(IoT)客户端和服务器之间数据传输的标准架构风格。每次你使用 Web 应用程序时,很可能会使用 REST 来从 UI 通信数据状态转换。
使用 REST 架构风格为 SPA 带来的好处是,你的应用程序的前端可以完全不受用于在服务器上检索请求的软件类型的影响,只要你的应用程序可以通过超文本传输协议(HTTP),即万维网的标准化应用协议。
在本章中,你将学习:
-
REST 架构风格的基本方面
-
如何为单页 Web 应用程序编写基本的 REST API 端点以执行 CRUD 操作
-
如何使用 AJAX 在你的应用程序前端处理 REST 请求
-
一些 REST 替代方案的基本知识,如 SOAP、WebSockets、MQTT、CoAP 和 DDP
理解 REST 的基本原理
REST 是用于在万维网或简称为 Web 上提供网页和发起请求的架构风格。尽管互联网和 Web 经常被互换使用,但它们在 Web 仅仅是互联网的一部分这一事实上有所不同。
Web 是一组文档,或称为网页,这些网页托管在世界各地的计算机上,并通过超链接或通常所说的链接连接。这些链接通过 HTTP 提供,这是 Web 的通信语言。由于与 Web 的相互关系,REST 经常与 HTTP 混淆,但 HTTP 和 REST 远非同一事物。
理解架构风格与协议的区别
REST 是一种架构风格,而 HTTP 是一种应用层协议。这意味着虽然 HTTP 是网络上的通信语言,但 REST 仅仅是执行网络上的请求和操作的一组规则。通过 REST 架构风格执行的操作通常被称为Web 服务。这样,HTTP 仅仅是使用 REST 的应用程序执行 Web 服务的方法。
架构风格
架构风格,或架构模式,是一组规则,它为开发者提供了构建抽象层作为框架的能力,这些框架旨在实现一个最终将被某种类型的客户端或用户代理消费的通用交互语言。在 Web 的情况下,那个用户代理是网络浏览器。
网络抽象层,或网络框架,可以用任何数量的语言编写,以通过 REST 或 RESTful 服务提供 Web 服务,只要该语言可以在 Web 服务器上托管。当该框架遵循 REST 架构风格时,使用它的任何应用程序的 UI 可以完全 无偏见,即对 RESTful 服务的背后技术保持中立。
协议
在与网络相关的情况下,协议是 互联网协议套件 或 TCP/IP 的抽象层的一部分,为连接的计算机之间提供了一种通用的通信方法。
传输层协议
术语 TCP/IP 是互联网协议套件中最广泛使用的协议的组合:传输控制协议 (TCP) 和 互联网协议 (IP)。
TCP
TCP 是一种传输层协议,位于应用层之下。这意味着服务和信息被 传输 到互联网协议套件的最顶层应用层。
IP
IP 也是一种传输层协议。你很可能已经看到这个协议与术语 IP 地址或互联网协议地址相关联,这是网络上设备的唯一数字标识符。在互联网上,域名通常用于指向 IP 地址,以便人们更容易记住如何到达该地址。
应用层协议
TCP/IP 的应用层是定义通过互联网连接的主机之间通信方法的抽象层。该层指定了多个协议,其中一些最常见的是 HTTP、FTP、SSH 和 SMTP。
HTTP
HTTP 是 TCP/IP 应用层内数据交换的主要协议,并为 RESTful 网络服务提供了通信的基础。HTTP 还负责在浏览器中显示网页,并将网页上的表单数据发送到服务器。
FTP
文件传输协议 (FTP) 是 TCP/IP 应用层中的另一种标准协议,用于在计算机之间传输文件。FTP 通信需要一个 FTP 服务器和一个 FTP 客户端。
SSH
Secure Shell (SSH) 是应用层中另一种常见的协议,它用于允许对非安全网络入口点进行安全的远程登录。为了使 SSH 连接工作,必须有一个 SSH 服务器来接收来自 SSH 客户端的请求。SSH 客户端通常以终端应用程序的形式出现,具有 命令行界面 (CLI)。
SMTP
简单邮件传输协议 (SMTP) 是在 TCP/IP 应用层发送电子邮件或电子邮件的标准方法。SMTP 也可以用来接收电子邮件,并且通常由电子邮件服务器用于此目的。然而,SMTP 通常不用于用户级电子邮件客户端接收电子邮件。相反,这些客户端更常见地使用 POP3 或 IMAP。
POP3 是邮局协议的第三个版本,它是一种标准的应用层协议,用于通过 TCP/IP 连接接收电子邮件。POP3 通常用于将电子邮件下载到本地计算机,然后从主机服务器上删除。
IMAP 是互联网消息访问协议。它也是一种标准的应用层协议,用于通过 TCP/IP 连接接收电子邮件。IMAP 通常用作管理多个客户端通过主机服务器电子邮件收件箱的方式,因此它不会像 POP3 那样在将电子邮件下载到本地计算机后从服务器上删除电子邮件。IMAP 的最新版本也支持跟踪主机服务器上电子邮件的状态,例如已读、已回复或已删除。
使用 HTTP 作为 REST 的传输协议
REST 定义了一套规则,用于在 Web 应用程序或服务中发出 HTTP 请求。HTTP 请求可以通过任何数量的方式发出,但只有遵循这些规则,它们才是 RESTful 的。HTTP 提供了请求所依赖的传输层。
就像与 REST API 交互的 Web 应用程序对用于提供 API 端点的软件框架类型一无所知一样,HTTP 对所有与之通信的服务器上使用的操作系统类型也是一无所知的。
REST 的约束
REST 架构风格由一系列约束或规则所管理,这些规则规定了它应该如何实现、交互和处理数据。REST 最初由美国计算机科学家 Roy Fielding 在 2000 年的一篇博士论文中定义,并伴随着这些约束。
REST 被认为是一种混合架构风格,因为它借鉴了在其构思之前存在的其他架构风格。这些其他架构风格极大地促进了这里概述的 REST 约束。
客户端-服务器
REST 的第一个约束是客户端-服务器架构风格。这个约束存在是为了强制执行 REST 的不可知性,或者说是其基础性的关注点分离:

此图显示了客户端-服务器关系以及它们是如何分离的。客户端,或网页浏览器,只需要显示应用程序的用户界面。UI 可以简单或复杂,只要认为有必要,而不会影响服务器上的 REST 架构。此 REST 约束提供了可伸缩性。
无状态
REST 的第二个约束建立在客户端-服务器约束之上,即客户端和服务器之间的通信必须是无状态的。这意味着任何来自网页浏览器的 REST 服务器的请求都必须提供所有预期信息,这些信息对于请求的上下文和当前会话是必需的,以便期望从服务器获得适当的响应。
服务器将没有存储信息来帮助界定请求,因此使 REST 服务器无状态并将会话状态的压力放在了网页浏览器上:

此图展示了客户端无状态-服务器架构风格,其中网页浏览器的状态可以改变,而 REST 服务器保持一致性。这种 REST 约束提供了可见性、可靠性和可扩展性,这些都是使用 REST 的一些关键好处。
缓存
REST 的第三个约束再次建立在客户端-服务器和无状态约束之上。一个缓存,或为重用存储的数据,可以根据 REST 服务器委托的该请求的可缓存性,允许浏览器用于任何给定的请求。如果服务器的缓存组件指示请求是可缓存的,那么浏览器可以将其缓存以供未来的请求使用。可缓存性通常在多次向特定 REST 端点发出请求的情况下表示,每次请求都可能导致相同的响应:

此图展示了客户端-缓存-无状态服务器架构风格。这种风格与客户端-无状态服务器类似,但增加了客户端缓存的组件。
统一接口
REST 的第四个约束是在系统的组件之间使用统一接口。这指的是 REST 实现中涉及的架构的简单性,其中组件是解耦的。这允许架构的每个组件独立发展,而不会影响其他组件:

此图展示了统一客户端-缓存-无状态服务器架构风格。这结合了前三种架构风格约束,并增加了统一接口的约束。
统一接口约束进一步细分为其自身的四个约束。
资源标识
REST 中的资源是对信息到唯一可识别对象的任何概念映射。这个对象可以是人、地点或事物。在 Web 的例子中,这是一个统一资源标识符(URI)。更具体地说,统一资源定位符(URL)是一种特殊的 URI,它提供了一种查找网络资源的方法,并指定了如何从该资源获取信息表示。URL 也常被称为网络地址。在 REST 的相关性中,URL 也可能被称为端点。
通过表示形式操作资源
REST 中的表示是一组数据,它代表了资源的当前状态。在采用 REST 的 Web 架构中,JSON 文档可以用作表示,在客户端和服务器之间传递,以操作或更改资源。
自描述消息
REST 中的消息是组件之间的通信。根据 REST 服务器无状态的约束,消息必须是自描述的,这意味着它携带了所有必要的信息,以告诉每个组件如何处理。
超媒体作为应用程序状态引擎
超媒体指的是网页或超文本,以及连接它们的超链接。为了保持无状态,RESTful 架构使用超媒体根据从服务器接收到的表示来传达应用程序的状态。
分层系统
REST 的第五个约束是分层系统,这是一个架构组件的层次结构,其中每一层为其上层提供服务并使用下层的服务。以这种方式,每一层只能看到其下一层,因此对任何下层的层都是不可知的。
这个概念应用于用于增强应用程序可扩展性的 Web 分布式服务器。例如,一个网络浏览器可能根据其位置与任何数量的中间服务器通信,但它永远不会意识到它是连接到端服务器还是那些中间服务器之一。
在服务器之间实现负载均衡也使用了分层系统。这允许当主服务器因请求过多而超负荷时,额外的服务器可以承担请求:

此图展示了统一分层客户端缓存无状态服务器。这种架构风格结合了前四种风格,并增加了分层系统的约束。
按需代码
REST 的第六个也是最后的约束是按需代码的架构风格,并且这是唯一的可选约束。在这种风格中,服务器提供一组封装在某种形式中、可以被浏览器消费的可执行代码。这种风格的例子包括 Java 小程序、运行 ActionScript 的 Flash 动画以及运行 JavaScript 的客户端小工具。
使用按需代码可以提高 REST 应用程序的灵活性,但它也通过封装一些功能来减少可见性。这就是为什么按需代码是 REST 的一个可选约束:

此图展示了最终的 REST 架构风格。它结合了之前描述的所有必须用于 REST 的约束,以及可选的按需代码约束。
REST 的好处
REST 约束的设计考虑到了关注点的分离和向前兼容性,这种设计允许 REST 的各个组件独立演变,而不会损害底层架构风格本身。
通过强制实施 REST 的约束,一些特定的架构属性被揭示出来,这揭示了这种架构风格的益处。让我们更深入地探讨 REST 的一些具体好处。
性能
性能是 REST 的主要好处之一,它通过使用缓存、简单的表示形式如 JSON、具有多个服务器和负载均衡的分层系统以及通过统一接口解耦组件来体现。
简单性
简单性是 REST 的另一个关键优势,它主要通过统一资源约束来体现,其中系统的各个组件是解耦的。简单性也体现在服务器组件上,它只需要支持 HTTP 请求,而不需要为任何请求支持状态。
关注点分离
关注点分离有助于 REST 的简单性,同时本身也是一个优势。这体现在独立的客户端-服务器关系、将缓存负担放在前端以及分层系统的使用上。关注点分离是一个常见的模式,不仅体现在架构中,也体现在软件设计上,例如在第二章中讨论的 MVW 架构模式,模型-视图-任何。
可扩展性
通过客户端-服务器关系的简单性和关注点分离属性,REST 在可扩展性方面表现出其架构属性。通过结合这些关键属性,系统变得更加可扩展,因为组件之间关系的复杂性通过具体的指导原则来降低,这些原则规定了它们应该如何协同工作。
可移植性
可移植性是 REST 的一个优势,它通过关注点分离的客户端-服务器模型来体现。这使得应用程序的用户界面层可以可移植,因为它对用于托管 REST 端点的底层服务器软件是中立的。
可移植性也通过代码按需功能体现出来,这使得 REST 能够将应用程序代码从服务器传输到客户端。
可视性
可视性简单来说是指根据组件之间的交互理解系统发生情况的能力。在 REST 中,由于组件的解耦性质以及它们之间几乎不需要相互了解,高可视性是一个优势。这允许在架构内部进行的交互,如对端点的请求,容易被理解。为了确定请求的完整性质,不需要查看请求本身的表示之外的内容。
REST 的代码按需约束实际上会降低可视性,但正因为这个原因,它是可选的。在接下来的内容中,除了在网页上发现的简单 JavaScript 小部件,用于广告、社交网络和其他第三方交互之外,现代网络应用很少使用代码按需功能。
可靠性
可靠性是 REST 的一个优势,主要通过无状态服务器约束来体现。在无状态服务器中,应用程序的故障可以在系统级别进行分析,因为你知道该故障的来源是系统的一个单独、解耦的组件。
例如,如果你在 Web 应用的 UI 中收到一个错误消息,提示用户输入的信息不正确,那么这个错误可以在 UI 级别进行处理。另一方面,如果你在输入正确信息后从服务器收到 HTTP 400 响应代码错误,你可以进一步推断出 REST 服务器端点配置不正确。
RESTful Web 服务
如前所述,REST 架构风格常用于在现代 Web 单页应用中执行读取、更新和删除(创建、读取、更新、删除(CRUD))操作,这些操作被称为 Web 服务。为了在你的应用程序中使用 RESTful Web 服务,你不仅需要一个 HTTP 服务器,还需要一个托管数据库或数据库服务器,以便在数据上执行 CRUD 操作。
使用 MongoDB 设置简单数据库
MongoDB 是 MEAN 栈使用的数据库。它是一个开源的、面向文档的数据库系统,可以很容易地通过下载或包管理器添加到你的栈中,具体取决于你使用的操作系统。
安装 MongoDB
MongoDB 可以安装在运行 Linux、Windows 和 OS X 的系统上。这些操作系统都有可用的直接下载,此外,在 OS X 上还可以使用 Homebrew 安装 MongoDB。Homebrew 是 OS X 上流行的 CLI 包管理器。有关安装 Homebrew 的说明,请访问 brew.sh。
如果你正在运行 OS X 并且已经安装了 Homebrew,你可以使用以下说明通过 CLI 安装 MongoDB。对于其他系统的安装,你可以在 MongoDB 的文档网站上找到说明,网址为docs.mongodb.com/manual/installation/。
使用 Homebrew 在 Mac 上安装 MongoDB
在使用 Homebrew 之前,首先将其更新到最新版本:
$ brew update
接下来,安装mongodb包:
$ brew install mongodb
一旦安装了 MongoDB,你可能希望将其添加到你的命令行PATH中以便于使用。为此,将以下内容添加到你的用户目录下的.profile、.bash_profile或.bashrc文件中,如果你已经有其中之一的话。如果你没有这些文件,那么创建.profile:
export PATH=/usr/local/opt/mongodb/bin:$PATH
一旦将 MongoDB 添加到你的PATH中,你需要在运行它之前创建一个用于存储数据的目录。MongoDB 的默认数据目录是/data/db。你很可能会需要以超级用户身份运行此命令。
创建 MongoDB 数据目录
首先,前往 CLI 并使用sudo创建一个数据库目录:
$ sudo mkdir -p /data/db
接下来,你需要设置目录权限,以便你能够进行读写操作:
$ sudo chown -R $(whoami):admin /data/db
运行 MongoDB
现在应该已经设置好了,你可以继续在 CLI 上使用mongod命令运行 MongoDB:
$ mongod
如果一切设置正确,你应该会看到几行输出,最后一行显示的内容类似于以下行:
I NETWORK [initandlisten] waiting for connections on port 27017
端口 27017 是 MongoDB 的默认端口,但必要时可以使用 CLI 上的 --port 选项进行更改:
$ mongod --port 27018
要在任何时候停止 MongoDB 的运行,请在它运行的命令提示符中按 Ctrl + C。
使用 MongoDB 创建集合
MongoDB 中的集合类似于传统关系数据库中的表。让我们使用我们在示例应用程序中使用的 user.json 文档设置一个测试数据库和集合。从应用程序的根目录运行以下命令:
$ mongoimport --db test --collection users --file user.json
此命令将创建一个名为 test 的数据库和一个名为 users 的集合,然后它将从 user.json 文件中导入数据到 users 集合。运行此命令后,你应该看到两行输出:
connected to: localhost
imported 1 document
此输出指示 user.json 文档已导入到运行在本地的 MongoDB 实例中。
安装 Node.js MongoDB 驱动程序
MongoDB 为几种编程语言提供了驱动程序。我们将使用 Node.js 驱动程序。可以使用 NPM 安装 MongoDB 的 Node.js 驱动程序。转到应用程序的根目录并安装它,并将其保存到本地的 package.json 文件中:
$ npm install mongodb --save
现在,你可以在你的 Node.js 应用程序中使用 MongoDB。首先,让我们向之前创建的 server.js 文件中添加一些额外的行:
var mongo = require('mongodb').MongoClient;
var assert = require('assert');
var url = 'mongodb://localhost:27017/test';
mongo.connect(url, function(err, db) {
assert.equal(null, err);
console.log('Connected to MongoDB.');
db.close();
});
这将设置与本地 MongoDB 测试数据库的连接,如果成功,将在控制台输出一条消息。
如果你将这些行添加到我们在 第三章 中为 server.js 编写的附加代码中,SPA 基础 - 创建理想的应用程序环境,文件的整个内容应如下所示:
var express = require('express');
var app = express();
var mongo = require('mongodb').MongoClient;
var assert = require('assert');
var url = 'mongodb://localhost:27017/test';
mongo.connect(url, function(err, db) {
assert.equal(null, err);
console.log('Connected to MongoDB.');
db.close();
});
app.use('/', express.static('./'));
app.get('*', function(request, response) {
response.sendFile('/index.html', {root: __dirname});
});
app.listen(8080, function() {
console.log('App now listening on port 8080');
});
我们添加的 assert 模块提供了一组简单的断言测试,可用于测试不变量,或不能更改的值。现在让我们保存文件并再次运行服务器:
$ node server.js
如果一切正常,并且你的 Node.js 服务器连接到了数据库,你应该看到以下输出:
App now listening on port 8080
Connected to MongoDB.
这表明你的 Node.js 服务器正在运行并连接到 MongoDB。如果 MongoDB 连接不成功,控制台将抛出错误。
现在我们已经有一个 Node.js 服务器在运行,并且连接到了 MongoDB 中的测试数据库,我们可以开始编写一些 REST API 端点了。
编写基本的 REST API 端点
在 Web 上最常见的一种 RESTful 请求是 HTTP GET 或 Read 操作。一个例子是通过 URL 查看网页的简单请求。GET 请求可以执行以读取任何类型的数据,并且不需要由数据库支持,但为了实现数据的创建、更新和删除操作,必须使用某种类型的数据库或数据存储,以及一个 REST 应用程序编程接口 (API)。
使用 REST 进行 CRUD
使用您迄今为止一直在使用的简单 NPM、Bower 和 Grunt 应用程序执行您的 Web 应用程序的完整 CRUD 操作;我们只需要编写一些 API 端点来实现这一点。让我们回到我们的应用程序 CLI 进行一些更改。
使用 Node.js 和 Express 处理请求数据
在我们能够处理发送到我们服务器的任何 API 请求数据之前,我们必须添加解析这些数据的能力。在大多数情况下,这将是从网页通过表单或其他方式发送的数据。这类数据被称为请求的 正文,为了解析它,我们需要添加另一个 Node.js 包:
$ npm install body-parser --save
这将把 Node.js body-parser 包添加到我们的应用程序依赖项中。现在让我们回到编辑 server.js 并添加一些额外的代码:
var bodyParser = require('body-parser');
app.use(bodyParser.json());
在文件顶部的其他变量声明下方添加 bodyParser 变量声明,然后在其下方调用 app.use(bodyParser.json()),并在所有路由定义之上。现在这将允许我们处理和解析任何作为请求正文的 JSON 数据。
使用 POST 请求创建
Express 通过提供与各自 HTTP 请求类型匹配的方法名称来遵循 REST 术语。在 REST 中,HTTP POST 请求是用于创建操作的标准化方法。对应的 Express 方法是 .post()。让我们使用 Express 设置一个简单的 POST 请求,这将允许我们向 MongoDB 中的用户集合添加额外的记录。
首先,让我们从 server.js 中删除 MongoDB 连接测试代码,并替换为以下代码:
app.post('/api/users', function(request, response) {
console.dir(request.body);
mongo.connect(url, function(err, db) {
db.collection('users')
.insertOne(request.body, function(err, result) {
if (err) {
throw err;
}
console.log('Document inserted successfully.');
response.json(result);
db.close();
});
});
});
确保此代码位于我们创建的 app.use('/', ...) 和 app.get('*', ...) 定义之上,这些定义在 第三章,SPA 基础 - 创建理想的应用程序环境中。
server.js 的全部内容现在应该看起来像以下代码:
var express = require('express');
var app = express();
var mongo = require('mongodb').MongoClient;
var assert = require('assert');
var url = 'mongodb://localhost:27017/test';
var bodyParser = require('body-parser');
app.use(bodyParser.json());
app.post('/api/users', function(request, response) {
console.dir(request.body);
mongo.connect(url, function(err, db) {
db.collection('users')
.insertOne(request.body, function(err, result) {
if (err) {
throw err;
}
console.log('Document inserted successfully.');
response.json(result);
db.close();
});
});
});
app.use('/', express.static('./'));
app.get('*', function(request, response) {
response.sendFile('/index.html', {root: __dirname});
});
app.listen(8080, function() {
console.log('App now listening on port 8080');
});
我们添加的 .post() 请求端点或处理程序将首先记录已解析并从 JSON 转换的 request.body 对象,并将其输出到命令行上的服务器控制台。然后,它将连接到 MongoDB 并调用 MongoDB 的 insertOne() 方法,将 request.body 文档插入到我们数据库中的用户集合中。
有许多库可以优雅地处理这种交互和从请求中插入数据库,但了解 Express 服务器如何与 MongoDB 交互非常重要,因此,出于这个原因,我们使用原生的 MongoDB API 来执行这些操作。
在前端测试 POST 请求
现在我们已经在服务器中设置了 POST 处理程序,让我们通过从前端发送请求来测试它是否工作。从用户表单输入插入信息是常见的做法,因此让我们编辑应用程序布局 index.html 文件并添加一个:
<h2>POST Request</h2>
<form data-url="/api/users" data-method="post">
<p>
<label>
First name:
<input type="text" name="first_name">
</label>
</p>
<p>
<label>
Last name:
<input type="text" name="last_name">
</label>
</p>
<p>
<label>
Title:
<input type="text" name="title">
</label>
</p>
<p>
<label>
Website:
<input type="text" name="website">
</label>
</p>
<p>
<button type="submit">Submit</button>
</p>
</form>
在页面的 <body> 标签下方添加此 HTML 代码。我们再次使用 Payload.js API 向服务器发送请求;这次是一个简单的 POST 请求。注意,<form> 标签的 data-url 属性设置为 API 端点 URL,而 data-method 属性设置为 post。当表单提交时,这将获取表单数据并将其转换为 JSON,然后通过 POST 请求将请求体发送到服务器。
现在从 CLI 运行应用程序,并在浏览器中转到 localhost:8080。你应该在那里看到表单。向表单输入中添加一些示例数据:
First name: Peebo
Last name: Sanderson
Title: Vagrant
Website: http://salvationarmy.org
现在单击一次提交表单。如果一切顺利,你应该会在你的控制台中看到以下内容:
App now listening on port 8080
{ first_name: 'Peebo',
last_name: 'Sanderson',
title: 'Vagrant',
website: 'http://salvationarmy.org' }
Document inserted successfully.
从表单创建的 JSON 文档现在应该被插入到 MongoDB 测试数据库的用户集合中。这意味着现在集合中有两个文档 - 我们最初从 user.json 文件中插入的文档,以及我们从表单 POST 中添加的文档。
现在我们数据库中已经有了一些记录,我们需要一种方法来检索这些文档并在浏览器中显示它们。我们可以通过首先创建一个端点来从数据库中读取数据来实现这一点。
使用 GET 请求进行读取
HTTP GET 请求是 REST 中用于读取操作的标准方法。相应的 Express 方法是 .get()。我们之前在第三章中设置了一个 GET 请求来加载我们的布局页面,但这次我们想要编写一个 REST API 请求,该请求将以 JSON 格式返回 MongoDB 中的用户记录。
首先,在命令行中按 Ctrl + C 停止服务器,然后再次打开 server.js 进行编辑。在我们的 .post() 端点下方,添加以下代码:
app.get('/api/users', function(req, res) {
mongo.connect(url, function(err, db) {
db.collection('users').find()
.toArray(function(err, result) {
if (err) {
throw err;
}
console.log(result.length + ' documents retrieved.');
res.json(result);
db.close();
});
});
});
你会注意到这个处理程序是通过与 .post() 处理程序相同的 URL 请求的,但由于 HTTP 请求方法是 GET 而不是 POST,所以它将被不同地处理。
首先,请求将连接到测试数据库,然后对用户集合调用 MongoDB 的 .find() 方法,这将返回一个游标。在 MongoDB 中,游标是指向数据库查询结果的指针。正如我们在 第三章 中提到的,SPA 基础 - 创建理想的应用程序环境,MongoDB 在内部使用 BSON 数据格式,因此为了将游标格式化以供我们的应用程序使用,我们必须将 BSON 数据转换为 HTTP 可消费的格式。为此,我们将 .toArray() 方法链接到 .find() 操作,这将把结果集转换为文档数组。我们还可以访问结果数组的长度属性,并将检索到的文档数量记录到服务器控制台。
接下来,我们将一个匿名回调函数传递给 .toArray() 方法,并将结果数据作为 JSON 响应返回。
测试前端上的 GET 请求
现在,让我们设置一些 HTML 来测试我们在前端上的 GET 请求。编辑应用程序布局 index.html 页面,并编辑我们添加的用于从 第二章,模型-视图- Whatever 中检索和显示 user.json 文件中的数据的 HTML。这应该就在我们刚刚添加的用于 POST 请求的表单下面:
<h2>GET Request</h2>
<p>
<a href="#"
data-url="/api/users"
data-template="users"
data-selector=".results">Load user data</a>
</p>
<div class="results"></div>
我们现在已将 GET 请求的 URL 从 /user.json 更改为 /api/users。Payload.js 默认会将 API 请求作为 GET 处理,因此除了提供更多透明度外,无需为此 URL 添加 data-method="get" 属性。此外,空的 .results <div> 被标记为我们想要显示结果数据的地方。
我们还更改了这里的 data-template 属性值,从用户(单数)更改为用户(复数)。这表明我们想要加载一个名为 users 的 Handlebars 模板。在你的应用程序目录的根目录中创建一个名为 users.handlebars 的新文件,并将以下代码添加到其中:
{{#each data}}
<p>{{first_name}} {{last_name}}</p>
{{/each}}
现在我们需要重新编译 Handlebars 模板并将它们保存到 templates.js 文件中:
$ handlebars *.handlebars -f templates.js
在命令行中运行此操作,你差不多就可以将 MongoDB 数据加载到模板中了。首先,再次运行服务器,然后在浏览器中转到或刷新 localhost:8080。点击“加载用户数据”链接,你应该会看到下面只显示一个名称:你刚刚插入到数据库中的文档的 first_name 和 last_name 字段。如果你检查控制台,你应该会看到如下输出:
App now listening on port 8080
2 documents retrieved.
因此,实际上从数据库中检索到了两个文档,但在浏览器中只显示了一个名称。为什么是这样呢?原因很简单,但很容易被忽视。我们从 user.json 首次插入的文档中的数据看起来如下:
{
"id": 1,
"name": {
"first": "Philip",
"last": "Klauzinski"
},
"title": "Sr. UI Engineer",
"website": "http://webtopian.com"
}
我们从表单的 POST 请求中添加的新文档看起来是这样的:
{
"first_name": "Peebo",
"last_name": "Sanderson",
"title": "Vagrant",
"website": "http://salvationarmy.org"
}
如您所见,我们从表单创建的文档没有像 user.json 文档那样嵌套着具有首字母和姓氏属性的对象名称,而是明确地具有 first_name 和 last_name 属性,而这些正是我们希望在 Handlebars 模板中显示的属性。
这就是为什么 HTML 视图只显示一个名称的原因,但我们是如何忽略这个问题的呢?这个问题的原因归因于 MongoDB 是一个没有严格数据类型的面向文档的数据库,就像关系型数据库一样。正如我们在 第三章 中讨论的,SPA 基础 - 创建理想的应用程序环境,这是使 NoSQL 面向文档的数据库与传统 SQL 数据库完全不同的一个原因。
因此,当我们从表单 POST 中插入新数据到我们的集合时,MongoDB 并没有检查新文档的格式是否与现有文档的格式匹配。自定义文档结构是面向文档数据库的一个强大功能,但如果不进行规范化,也可能导致应用程序错误和 UI 缺失数据。
现在,让我们编写一个更新端点,以更改我们现有的文档,并使其与另一个匹配。
使用 PUT 请求进行更新
在 REST 中,HTTP PUT请求是用于更新操作的标准方法。对应的 Express 方法是.put()。
现在,按Ctrl + C停止 Node.js 服务器,然后再次打开 server.js 文件,在.get()处理程序下方添加以下代码:
app.put('/api/users', function(req, res) {
mongo.connect(url, function(err, db) {
db.collection('users').updateOne(
{ "id": 1 },
req.body,
function(err, result) {
if (err) {
throw err;
}
console.log(result);
res.json(result);
db.close();
}
);
});
});
我们再次使用相同的端点 URL,但这次只会处理从前端发出的PUT请求。这个方法首先会连接到我们的测试数据库,然后调用 MongoDB 的.updateOne()方法来更新现有的文档。传递给这个方法的第一个参数是一个过滤器,或者是要查找并匹配的数据。.updateOne()方法只会查找与过滤器匹配的第一个文档,然后结束查询。
注意,传递给这个方法的过滤器是{ "id": 1 }。这是从user.json文件中传递进来的id字段。记住,如果未提供,MongoDB 会为每个文档创建自己的内部 id,这个字段称为_id。所以,在我们的原始用户对象中,它将有一个设置为 BSON ObjectId的_id字段,以及我们提供的原始*id*字段设置为 1。由于我们知道我们从表单 POST 创建的新文档没有多余的*id*字段,我们可以安全地基于该字段进行过滤,以找到原始文档并更新它。
我们传递给.updateOne()方法的第二个参数是整个请求体,它将是从表单提交中生成的一个对象。通常,在 PUT 请求中,意图是使用新值更新现有字段,但在这个情况下,我们实际上想要改变文档的结构,以匹配我们使用表单 POST 创建的新记录的结构。
传递给.updateOne()方法的第三个参数是一个匿名回调函数,更新请求的结果会被传递给它。在这里,我们将结果记录到控制台,并以 JSON 格式返回给前端。
在前端测试 PUT 请求
现在,让我们回到应用程序布局 index.html 文件,并在之前添加的 GET 请求 HTML 下方添加一些更多的 HTML。为此,复制POST请求表单中的 HTML,并修改它看起来像以下这样:
<h2>PUT Request</h2>
<form data-url="/api/users" data-method="put">
<p>
<label>
First name:
<input type="text" name="first_name">
</label>
</p>
<p>
<label>
Last name:
<input type="text" name="last_name">
</label>
</p>
<p>
<label>
Title:
<input type="text" name="title">
</label>
</p>
<p>
<label>
Website:
<input type="text" name="website">
</label>
</p>
<p>
<button type="submit">Submit</button>
</p>
</form>
这段代码与 POST 请求的 HTML 相匹配,除了几个小的改动。我们已编辑 <h2> 标题以显示这是一个 PUT 请求表单,并且表单上的 data-method 属性现在设置为 put。保留所有表单输入不变,因为我们希望更新的文档与我们所创建的新文档相匹配。
现在,从命令行重新启动服务器,然后在浏览器中转到或刷新 localhost:8080。你应该会在页面上的 POST 请求和 GET 请求区域下方看到我们添加的新 PUT 请求表单。现在将原始 user.json 对象中的数据输入到相应的表单字段中:
First name: Philip
Last name: Klauzinski
Title: Sr. UI Engineer
Website: http://webtopian.com
现在点击提交按钮一次,检查你的控制台输出。你应该看到大量信息打印到控制台。在最上面,你应该看到以下内容:
{ result: { ok: 1, nModified: 1, n: 1 }
此结果表示已修改了一条记录。如果更新成功,原始的 user.json 文档现在应与我们在表单 POST 中添加的第二份文档的格式相匹配。为了测试这一点,点击“加载用户数据”链接以获取用户文档,并使用 Handlebars 模板和 first_name 以及 last_name 属性列出名称。你现在应该在浏览器中看到两个名称都被列出:
Philip Klauzinski
Peebo Sanderson
为了在 server.js 中完成我们的 RESTful API 端点,让我们添加一个最终的 Delete 处理程序,并使用它来删除两个用户记录中的一个。
使用 DELETE 请求进行删除
HTTP DELETE 请求是用于 REST 中同名的 Delete 操作的标准方法。自然,Express 中对应的方法是 .delete()。
按 Ctrl + C 停止服务器,然后再次打开 server.js 进行编辑。在 .put() 处理程序下方添加以下代码:
app.delete('/api/users', function(req, res) {
mongo.connect(url, function(err, db) {
db.collection('users').deleteOne(
{ "first_name": "Peebo" },
function(err, result) {
if (err) {
throw err;
}
console.log(result);
res.json(result);
db.close();
});
});
});
此处理程序将首先连接到数据库,然后将在用户集合上调用 MongoDB 的 .deleteOne() 方法。传递给 .deleteOne() 方法的第一个参数是一个条件,用于匹配要删除的记录。在这种情况下,我们想要删除从表单 POST 中创建的新记录,因此我们使用 Peebo 的唯一 first_name 值。
传递给 .deleteOne() 方法的第二个参数是一个匿名回调函数,该函数传递删除请求的结果。我们再次将此结果记录到控制台,并将其作为 JSON 返回到前端。
在前端测试 DELETE 请求
再次打开应用程序布局的 index.html 文件,并在之前添加的 PUT 请求表单下方添加以下代码:
<h2>DELETE Request</h2>
<button data-url="/api/users"
data-method="delete"
data-template="user"
data-selector=".delete-response">Delete Peebo</button>
<div class="delete-response"></div>
在这里,我们添加了一个简单的按钮,并包含了发送 HTTP DELETE 请求所需的 Payload.js 属性。
小贴士
应注意,不能在 DELETE 请求中发送请求体,例如表单数据。
再次启动 Node.js 服务器,然后在浏览器中打开或重新加载 index.html。你应该在页面底部看到 删除 Peebo 按钮。单击该按钮一次,然后检查控制台输出。你将看到大量结果信息。在输出的最顶部,你应该看到以下内容:
{ result: { ok: 1, n: 1 }
这里显示的 n: 1 属性表示一条记录已成功删除。要验证这一点,请返回浏览器并滚动到 GET 请求标题下的“加载用户数据”链接。点击该链接,你现在应该只看到原始的 user.json 文档的 first_name 和 last_name。控制台也将指示在用户集合中只找到单个结果:
1 documents retrieved.
恭喜你,你现在已经使用 Express 和 MongoDB 编写了一个完整的 RESTful 端点集,用于执行 CRUD 操作。尽管这些示例方法相当原始,但它们应该为你学习更多知识并在此基础上构建更健壮的单页应用程序提供一个基础。它们还应该帮助你更好地理解 REST 架构风格以及 Node.js 如何与 MongoDB 交互。
REST 的替代方案
无疑,REST 是跨 Web 和物联网最广泛使用的架构风格,但还有许多其他技术、协议和架构风格可供用于 Web 服务和单页 Web 应用程序的数据交换。
TCP 与 UDP
如前所述,TCP 是 HTTP 运行到应用层的传输层协议。TCP 连接的一些有益属性是它们是可靠的、串行的,并且在发送信息时检查错误。然而,这些好处有时会导致不希望的延迟:

互联网协议套件包括许多其他协议,与 TCP 一起。其中之一是 用户数据报协议(UDP)。UDP 也是 TCP/IP 的 传输层 的核心成员。UDP 与 TCP 的主要区别在于 UDP 是 无连接的。这意味着数据单元以自识别信息传输,接收端对该信息没有先前的了解,不知道何时或如何接收。UDP 不会确保接收端点实际上可以接收该信息,因此在使用 UDP 时必须考虑这种风险。
由于 UDP 不使用连接,它本质上不是 可靠的,这也是它与基于连接的协议(如 TCP)的区别所在。TCP 允许在传输过程中进行错误检查和纠正,因为双方都了解对方,这是由于它们的 连接。
通过 UDP 和其他无连接协议发送的消息被称为数据报。UDP 和数据报仅在不需要错误检查和纠正或这些操作在应用层本身执行时才应使用。由于错误检查和纠正几乎总是任何应用程序的必要条件,因此在 UDP 中通常使用在应用层检查错误的应用程序类型模型。使用 UDP 的一些应用程序类型示例包括:
-
流媒体
-
IP 语音(VoIP)
-
大规模多人在线游戏
-
域名系统(DNS)
-
一些虚拟专用网络(VPN)系统
与 UDP 和无连接协议的最明显缺点是没有消息传递的保证,没有错误检查,因此也没有错误纠正。在用户自己与系统交互的应用程序中,这可能会成为一个主要缺点,因为大多数事件都是用户生成的。然而,在一个可能有数百或数千个用户相互交互的系统中,无连接协议允许应用程序免受错误纠正导致的延迟。一个大规模多人在线游戏就是一个很好的例子,其中可能需要在网络上持续传输数千甚至数百万条消息,但同时在保持带有错误检查和纠正的连接的情况下可靠地完成这一点是不可能的。
SOAP
REST 经常被与简单对象访问协议(SOAP)相比较,尽管 SOAP 实际上是一个协议,而不是像 REST 那样的架构风格。比较的原因是因为两者都用于 Web 服务,在这个背景下,REST 等同于 HTTP,这是一个协议。尽管 SOAP 是一个协议,但它也与 HTTP 交互以传输消息以实现 Web 服务。它也可以通过 SMTP 使用。
SOAP 的消息格式是 XML。使用 SOAP 发送的 XML 消息被称为信封。SOAP 信封的结构遵循特定的模式,包括一个强制性的主体元素和一个可选的头部元素。主体还可以包括嵌套的故障结构,这些结构携带有关异常的信息。以下是一个 SOAP 消息的示例:
<env:Envelope >
<env:Header>
<n:shipping >
This is a shipping message
</n:shipping>
</env:Header>
<env:Body>
<env:Fault>
<env:Code>
<env:Value>
env:VersionMismatch
</env:Value>
</env:Code>
<env:Reason>
<env:Text xml:lang="en">
versions do not match
</env:Text>
</env:Reason>
</env:Fault>
</env:Body>
</env:Envelope>
REST 也可以使用 XML 进行数据交换,但在现代 Web 应用程序中更常用 JSON。
WebSockets
WebSockets 是一种允许 Web 浏览器和服务器之间进行交互式通信的协议。在这个上下文中,“交互式”一词意味着服务器可以在浏览器不需要定期轮询服务器以获取新数据的情况下向浏览器推送消息,这在使用 HTTP、AJAX 和 REST 的典型 Web 应用程序中可能会这样做。
你可能之前听说过推送技术。这种模式在许多智能手机应用中都很明显,它们会在新数据可用时立即将更新通知推送到手机上。这也被称为实时数据。HTTP 的局限性在于它不支持接收实时数据的开放连接。相反,HTTP 需要发起一个请求并打开一个连接或套接字,接收响应,下载信息,然后关闭连接。一旦新信息可用,如果没有定期向服务器请求数据,应用程序将不会意识到这一点,这种做法被称为轮询。
2011 年,WebSockets 被正式标准化并由现代网络浏览器支持。该协议允许通过使用开放套接字连接将数据从服务器传输到客户端,允许客户端随意请求数据,同时也允许服务器在实时中将数据推送到客户端:

使用 REST 的 Web 应用程序受 HTTP 的开放/关闭连接约束限制。这对于许多不需要服务器响应的用户交互或可以实施定期服务器轮询而不需要太多开销的 Web 应用程序是有意义的。然而,对于那些想要向用户提供实时数据而不需要用户动作的 Web 应用程序,使用 WebSockets 可能更合适。
MQTT
MQTT 最初代表MQ 遥测传输。它是一种设计用于 TCP/IP 或互联网协议套件之上的消息协议。MQTT 采用发布-订阅或 PubSub 的消息模式,其中事件或消息由发布者发布,可供任何数量的订阅者获取。在后续过程中,订阅者会从任何数量的发布者那里接收消息。在这个模式中,发布者对订阅者是完全不知情的。
与 SOAP 和 WebSockets 相比,MQTT 不是为在 HTTP 上使用 Web 服务而设计的,而是主要用于机器到机器(M2M)通信。MQTT 常用于卫星通信、家庭或智能家居自动化以及移动应用程序。MQTT 被认为是轻量级且代码占用空间小,非常适合可能使用较慢的无线移动网络连接的移动应用程序。
MQTT 中的“MQ”最初来源于 IBM 的消息队列(MQ)协议。然而,消息队列并不是 MQTT 的必要要求,这就是为什么它不再是一个真正的缩写,而只是被称为 MQTT。
MQTT 是结构化信息标准推进组织(OASIS)的一个标准。OASIS 是一个定义物联网和其他技术领域标准的组织。
任何实现 MQTT 的软件都被称为 MQTT 代理,它是一种消息代理架构模式,将应用程序发送的消息转换为接收者的专用格式,或者消息代理本身:

消息代理的目的是接收应用程序接收到的消息并对它们执行某种操作。例如,一些操作可能包括:
-
启动 Web 服务请求
-
将消息转发到其他目的地
-
将消息转换为另一种类型的表示,以便由另一个应用程序或端点消费
-
存储用于发布/订阅事件和响应的消息
-
记录和/或响应应用程序错误
有许多流行的消息代理应用程序和服务可用于单页应用程序中的消息交换。其中一些是 Mosquitto、CloudMQTT、IBM MessageSight 和 ActiveMQ。
AMQP
高级消息队列协议(AMQP)与 MQTT 类似。它是一种用于消息代理的开放标准应用层协议。
对于现代 Web 应用程序来说,RabbitMQ 是最受欢迎的开源消息代理之一,它使用 AMQP。在类似 RabbitMQ 的 AMQP 架构中,消息由应用程序产生,然后排队或存储在 RabbitMQ 服务器中。在某种程度上,队列也是一个缓冲区,因为它可以存储任何数量的信息,直到需要时:

虽然 RabbitMQ 使用 AMQP,但它还包括 MQTT 的适配器。它还支持 HTTP 和流文本导向消息协议(STOMP)。RabbitMQ 是开源的,并且它还包括对其他协议的适配器,特别是 HTTP,这极大地促进了它今天的普及。
CoAP
受限应用协议(CoAP)是一种为机器对机器通信设计的 Web 传输协议。CoAP 服务主要针对的机器是物联网设备。
CoAP 实际上与 HTTP 非常相似,并在其规范中采用 REST 架构风格。与 CoAP 的不同之处在于,它严格遵循 REST 原则,而 HTTP 仅支持 REST,但不强制要求。
由于 CoAP 使用 REST 架构风格,它实际上可以通过 HTTP 连接,因为与任何 RESTful 架构一样,客户端对其访问的 RESTful 服务器是无关的。在这种情况下,使用跨协议代理使 CoAP 服务对 HTTP 客户端可用:

DDP
分布式数据协议(DDP)虽然不太常用,但通过流行的 Meteor JavaScript 框架正在获得认可。DDP 是一种简单的协议,用于显式地从服务器检索表示,并在实时接收有关这些表示修改的更新。
DDP 允许 Meteor 应用程序使用 WebSocket 进行服务,为这些服务提供了一个无连接的框架。使用 JSON 数据,但与 RESTful 架构中明确请求不同,JSON 数据消息可以实时 推送 到应用程序。
DDP 最初是由 Meteor 的创始人为其开发的;然而,它并不仅限于 Meteor,也可以在其他框架中使用。Meteor 对 DDP 的实现完全用 JavaScript 编写,并且是开源的。
摘要
你现在已经学习了 REST 架构风格的基本方面,架构风格与协议之间的区别,REST 与 HTTP 协议之间的关系,以及 REST 的约束。你还学会了使用 Express 和 MongoDB 编写一些基本的 REST API 端点。对 REST 和单页应用程序后端有良好的理解对于成为一名熟练的 Web SPA 开发者至关重要。在下一章中,我们将把重点转向 SPA 开发的前端,学习一些关于 SPA UI 框架和最佳实践的知识,并将我们迄今为止所学的一切应用到视图层。
第五章. 一切都关于视图
用户界面层,或视图,是任何应用程序中最明显的组件。无论底层发生什么,无论是 REST、Websockets、MQTT 还是 SOAP,视图都是所有内容汇聚以提供完整、交互式应用程序体验的地方。就像服务器端一样,视图有其自己的复杂性和从开发角度出发的众多架构选择。现在,我们将探讨一些这些选择以及可以在全面视图层中使用的不同设计模式。
在本章中,我们将介绍以下内容:
-
不同的 JavaScript 模板引擎之间的差异
-
预编译 JavaScript 模板的优势
-
如何优化您的应用程序布局
JavaScript 模板引擎
在应用程序的前端维护视图对于保持其服务器端无关性大有裨益。即使您在底层使用 MVC 框架来为应用程序提供 REST 端点,保持前端视图模板和逻辑也将确保您可以在未来更容易地更换 MVC 后端,而不会显著改变应用程序的逻辑和架构结构。JavaScript 模板引擎提供了一种有效的方法,可以在前端完全管理视图模板。
有许多开源 JavaScript 模板引擎可用。接下来,我们将介绍一些更受欢迎的模板引擎的基础知识:
-
Underscore.js
-
Mustache.js
-
Handlebars.js
-
Pure.js
-
Pug
-
EJS
Underscore.js
Underscore.js库因其有用的 JavaScript 函数式编程辅助工具和实用方法而闻名。其中一种实用方法是_.template()。此方法用于将带有表达式的字符串编译成函数,这些函数用动态值替换那些表达式。
Underscore.js 模板的语法分隔符类似于ERB或嵌入式 Ruby模板语法,在开标签后跟一个等于符号:
<p>Hello, <%= name %>.</p>
在 HTML 中使用的 Underscore.js 模板表达式看起来像前面的示例。变量名将动态传递给编译后的函数,用于此模板。
_.template()方法还可以用于在模板中解析和执行任意 JavaScript 代码。Underscore.js 模板中的 JavaScript 代码使用 ERB 风格标签进行分隔,但开标签后不跟一个等于符号:
<ul>
<% _.each(items, function(item) { %>
<li><%= item.property %></li>
<% } %>
</ul>
正如您在这个示例中可以看到的,模板中的 ERB 标签使脚本能够访问全局_对象,并允许它使用库的_.each()方法遍历该上下文中包含的给定对象或数组,甚至可以从该上下文向上遍历作用域链。脚本对_对象的访问表明,任何附加到window命名空间的全局变量都对脚本可用。
给模板赋予执行任意 JavaScript 代码的能力,在社区中引发了广泛的讨论,普遍的看法是这种做法是不被提倡的。这主要是因为从其他网络脚本语言,如 PHP 中吸取的教训。
将动态业务逻辑的代码与 HTML 直接混合在模板中,可能会导致代码库难以维护和调试,其他开发人员和未来几代人也会遇到困难。这种类型的代码也违反了 MVC 和 MVW 架构模式的原则。不言而喻,开发者编写代码时,选择在模板中包含多少或多少业务逻辑取决于他们自己,但许多 JavaScript 模板引擎的创造者认为,打开这扇门并不是一个选择。因此,无逻辑模板的概念应运而生。
你可以在underscorejs.org了解更多关于 Underscore.js 的信息。
Mustache.js
Mustache.js是流行的Mustache 模板系统在 JavaScript 模板中的实现。Mustache 自诩为一种无逻辑的模板语法。这个概念背后的想法并不是要使模板完全没有逻辑,而是更多地劝阻在模板中包含大量业务逻辑的做法。
Mustache 的名字来源于使用双大括号作为模板的默认分隔符标签,这些大括号形状类似于胡须。Mustache 模板与 Underscore.js 模板的主要区别在于,Mustache 不允许在标签的另一种形式中放置任意 JavaScript 代码;它只允许使用表达式。
在其最简单的形式中,Mustache 模板将 JavaScript 对象中的值直接映射到相应的模板表达式,这些表达式由对象值的键表示。以这里显示的示例对象为例:
{
"name": {
"first": "Udis",
"last": "Petroyka"
},
"age": "82"
}
这个对象中的值可以用以下方式表示在 Mustache 模板中:
<p>{{name.first}} {{name.last}}</p>
<p>Age: {{age}}</p>
在这个例子中,你可以看到,即使嵌套对象值也可以通过使用 JavaScript 点表示法来访问,如{{name.first}}和{{name.last}}所示:
<p>Udis Petroyka</p>
<p>Age: 82</p>
部分
Mustache 模板还包括渲染部分或文本块的能力。这涉及到使用包含开标签和闭标签语法的替代表达式语法。一个部分如何渲染取决于被调用的键的值。
布尔值
给定一个布尔值,一个部分将根据该布尔值是true还是false来渲染或不渲染:
{
"name": {
"first": "Jarmond",
"last": "Dittlemore"
},
"email_subscriber": false
}
部分的分隔符语法由开有大括号、后跟井号#符号和用于开始部分的属性名,以及闭有大括号、后跟斜杠/符号和用于结束部分的属性名组成。这种语法与 HTML 的打开和关闭标签类似:
<p>{{name.first}} {{name.last}}</p>
{{#email_subscriber}}
<p>Content here will not be shown for this user.</p>
{{/email_subscriber}}
在这个例子中,email_subscriber属性被设置为 false,因此模板将渲染以下 HTML:
<p>Jarmond Dittlemore</p>
从本质上讲,使用具有布尔值的部分等同于一个if条件语句。这种用法确实包括逻辑,尽管它是最基本的形式。这样,无逻辑这个术语被证明并不像最初感知的那样严格。
列表
此外,部分可以用来遍历作为给定对象键值的列表项。在部分内部,上下文或变量作用域将转移到正在迭代的键上。以下是一个父键及其对应值列表的例子:
{
"people": [
{ "firstName": "Peebo", "lastName": "Sanderson" },
{ "firstName": "Udis", "lastName": "Petroyka" },
{ "firstName": "Jarmond", "lastName": "Dittlemore" },
{ "firstName": "Chappy", "lastName": "Scrumdinger" }
]
}
给定一个包含人和他们名字的列表,可以使用一个部分来将每个人的名字渲染为 HTML 无序列表:
<ul>
{{#people}}
<li>{{firstName}} {{lastName}}</li>
{{/people}}
</ul>
给定前面的示例对象,此模板代码将渲染以下 HTML:
<ul>
<li>Peebo Sanderson</li>
<li>Udis Petroyka</li>
<li>Jarmond Dittlemore</li>
<li>Chappy Scrumdinger</li>
</ul>
Lambda
对象属性值也可以从lambda或函数中返回,这些函数作为数据传递回当前部分的上下文:
{
"people": [
{ "firstName": "Peebo", "lastName": "Sanderson" },
{ "firstName": "Udis", "lastName": "Petroyka" },
{ "firstName": "Jarmond", "lastName": "Dittlemore" },
{ "firstName": "Chappy", "lastName": "Scrumdinger" }
],
"name": function() {
return this.firstName + ' ' + this.lastName;
}
}
在列表的情况下,lambda 将基于当前列表项的上下文返回一个值,用于迭代:
<ul>
{{#people}}
<li>{{name}}</li>
{{/people}}
</ul>
以这种方式,前面的模板将产生与上一个示例相同的输出:
<ul>
<li>Peebo Sanderson</li>
<li>Udis Petroyka</li>
<li>Jarmond Dittlemore</li>
<li>Chappy Scrumdinger</li>
</ul>
倒置部分
Mustache 模板中的倒置部分是在该部分的键值是false或假值时才渲染的,例如null、undefined、0或一个空列表[]。以下是一个对象的例子:
{
"name": {
"first": "Peebo",
"last": "Sanderson"
},
"email_subscriber": false
}
倒置部分以一个反引号^符号开头,而不是用于标准部分的井号#符号。给定前面的示例对象,以下模板语法可以用来渲染false属性值的 HTML:
<p>{{name.first}} {{name.last}}</p>
{{^email_subscriber}}
<p>I am not an email subscriber.</p>
{{/email_subscriber}}
根据对象中的false属性值,此模板将渲染以下 HTML:
<p>Peebo Sanderson</p>
<p>I am not an email subscriber.</p>
注释
Mustache 模板还允许您在模板中包含注释。使用 Mustache 语法而不是 HTML 注释的优点是,它们不会被渲染到 HTML 输出中,就像标准 HTML 注释那样:
<p>Udis likes to comment{{! hi, this comment won't be rendered }}</p>
<!- This is a standard HTML comment ->
Mustache 注释由一个感叹号或感叹号紧跟在开括号之后表示。前面的模板代码将渲染以下内容:
<p>Udis likes to comment</p>
<!- This is a standard HTML comment ->
如所示,Mustache 模板注释不是渲染的 HTML 的一部分,但标准的 HTML 注释是。使用 Mustache 模板注释的优势在于渲染的 HTML 的负载大小,您希望将其保持尽可能小,并且可能没有很多情况您实际上想要在动态 HTML 中渲染注释。这允许您在模板代码中为其他开发者提供有用的注释,而不会给应用程序的前端带来负担。
部分
Mustache 模板中最有用的功能之一是能够在编译的模板中包含部分,或在运行时渲染的单独模板。从概念上讲,这个功能与服务器端模板语言的包含类似。
部分的语法在开有大括号 > 符号后跟部分名称。一个常见的文件命名约定是在未编译的部分文件名前加上下划线 _。考虑以下两个文件:
user.hbs
<h3>{{name.first}} {{name.last}}</h3>
{{> user-details}}
_user-details.hbs
<ul>
{{^email_subscriber}}
<li>I am not an email subscriber</li>
{{/email_subscriber}}
<li>Age: {{age}}</li>
<li>Profession: {{profession}}</li>
</ul>
在 user.hbs 的第二行指示了包含部分文件的调用。这将解析与 user.hbs 相同上下文中的 _user-details.hbs。在标准的编译器设置中,部分文件名中的下划线会被排除在键名之外,模板将存储在部分命名空间内:
{
"name": {
"first": "Jarmond",
"last": "Dittlemore"
},
"email_subscriber": false,
"age": 24,
"profession": "Student"
}
给定前面的示例对象,模板完全渲染的 HTML 将看起来像以下内容:
<h3>Jarmond Dittlemore</h3>
<ul>
<li>I am not an email subscriber</li>
<li>Age: 24</li>
<li>Profession: Student</li>
</ul>
如示例所示,对象中的键名直接用于与父模板相同上下文的部分。
设置替代定界符
Mustache 模板的一个较不寻常的特性是能够在模板内部的标准 Mustache 定界符标签中设置替代定界符。这是通过在开有大括号定界符后跟一个等号 =,插入新的开定界符,然后是新的闭定界符,以及一个等号后跟标准闭定界符标签来完成的:
{{=<% %>=}}
如果这段代码放在 Mustache 模板的任何地方,那么下面的定界符标签将使用新的语法:
{
"name": {
"first": "Chappy",
"last": "Scrumdinger"
},
"email_subscriber": false,
"age": 96,
"profession": "Oilman"
}
给定前面的对象,可以使用该数据结合替代定界符标签来构建模板:
<p>Standard tags: {{name.first}} {{name.last}}</p>
{{=<% %>=}}
<p>New tags - Age: <%age%></p>
<%={{ }}=%>
<p>Standard tags again - Profession: {{profession}}</p>
在这个例子中,标准标签使用了一次,然后使用集合定界符功能将标签更改为使用 ERB 风格的定界符,然后标签再次更改为原始的标准定界符:
<p>Standard tags: Chappy Scrumdinger</p>
<p>New tags - Age: 96</p>
<p>Standard tags again - Profession: Oilman</p>
生成的 HTML 将看起来像前面的代码,在一个模板中使用两组完全不同的定界符。
你可以在 github.com/janl/mustache.js 上了解更多关于 Mustache.js 的信息,或者了解原始的 Mustache 模板在 mustache.github.io。
Handlebars.js
Handlebars.js 模板也被认为是无逻辑的,并且主要基于 Mustache 模板,但提供了一些额外的功能。它们还排除了 Mustache 模板中一些创建者认为不实用的功能。
Handlebars 是 JavaScript 社区中较突出的模板引擎之一。它被包括 Backbone.js、Ember.js 和流行的 Meteor.js 框架在内的几个主要开源 JavaScript 框架所使用。它使用自己的反应式 Handlebars 模板引擎,称为 Spacebars。由于其流行,我们将在下面更深入地介绍 Handlebars。
显式路径查找与递归路径查找
Handlebars 模板与 Mustache 模板区分开来的一个特性是,Handlebars 不支持递归路径查找,正如 Mustache 模板所做的那样。这涉及到 Handlebars 中的“部分”或“块”。当你处于对象子属性上下文时,Handlebars 不会自动查找作用域链中的表达式引用。相反,你必须显式定义你要查找的变量的路径。这使得 Handlebars 模板中的作用域更有意义和可理解:
{
"name": {
"first": "Peebo",
"last": "Sanderson"
},
"email_subscriber": false,
"age": 54,
"profession": "Singer"
}
给定此对象,以下模板语法将适用于 Mustache 模板:
<!-- Mustache template -->
<p>{{name.first}} {{name.last}}</p>
{{#profession}}
<p>Profession: {{profession}}</p>
{{/profession}}
此模板将渲染 #profession 块作用域内 profession 键的值,因为 Mustache 支持递归路径查找。换句话说,嵌套上下文始终可以访问其上方的父上下文中的变量。然而,Handlebars 默认情况下并不是这样:
<!-- Handlebars template -->
<p>{{name.first}} {{name.last}}</p>
{{#profession}}
<p>Profession: {{this}}</p>
{{/profession}}
如此示例所示,this 关键字用于引用当前块上下文设置的变量。如果直接引用 profession 变量,这将在 Handlebars 中引发错误。
<!-- Handlebars template -->
<p>{{name.first}} {{name.last}}</p>
{{#profession}}
<p>Profession: {{../profession}}</p>
{{/profession}}
此外,Handlebars 可以使用前面代码中显示的 ../ 语法通过显式路径引用查找作用域链中的变量。这种语法模仿了命令行界面中递归文件路径查找的方式。在这个例子中,../profession 引用简单地查找当前块上下文设置的变量:
<p>Peebo Sanderson</p>
<p>Profession: Singer</p>
Handlebars 默认不支持递归路径查找的原因是为了提高速度。通过将路径查找限制在当前块上下文中,Handlebars 模板可以更快地渲染。提供了一个编译时 compat 标志来覆盖此功能并允许递归路径查找,但 Handlebars 的创建者建议不要这样做,并指出这样做会有性能成本。
辅助函数
Handlebars 模板不支持 Mustache 模板中定义的 lambda 表达式,而是使用辅助函数来增加功能。Handlebars 中的辅助函数是一种将视图逻辑抽象化的方式,这些逻辑在其他情况下可能直接在模板中完成,尤其是在使用限制较少的模板引擎(如 Underscore.js)时。相反,你可以编写一个以常规 JavaScript 函数形式存在的辅助函数,将其注册到 Handlebars 命名空间中,并在模板中使用它作为一个单独的表达式或块表达式:
{
"name": {
"first": "Udis",
"last": "Petroyka"
},
"age": "82"
}
给定此示例对象,可以编写一个辅助函数来根据对象属性返回用户的完整姓名:
Handlebars.registerHelper('fullName', function(name) {
return name.first + ' ' + name.last;
});
如此所示,Handlebars 对象提供了一个 registerHelper 方法,它允许你通过定义第一个参数为名称,第二个参数为 lambda 表达式来定义一个辅助函数。lambda 表达式的参数可以直接从模板上下文中在辅助函数被调用的点提供;在这种情况下,作为一个表达式:
<p>Hi, my name is {{fullName name}}.</p>
如前例所示,辅助函数的语法使用辅助函数的名称紧随 Handlebars 开启标签之后,后跟传递给辅助函数的任何参数;在这种情况下,是 name 参数:
<p>Hi, my name is Udis Petroyka.</p>
模板随后将作为 HTML 渲染,显示从辅助函数返回的完整名称,这是通过从模板上下文中传递所需的对象属性来实现的。
辅助函数作为块表达式
Handlebars 模板使用块表达式语法来调用辅助函数。然而,块表达式的上下文完全取决于辅助函数的编写方式。Handlebars 提供了几个内置的块辅助函数。
#if 块辅助函数
Handlebars 提供了一个简单的 #if 块辅助函数,用于根据布尔值或真值与假值解析来渲染内容或不渲染内容。这意味着像 0、null、undefined 和空列表 [] 这样的值将被解析为假。
考虑以下对象:
{
"name": {
"first": "Jarmond",
"last": "Dittlemore"
},
"email_subscriber": false
}
而不是使用标准的 Mustache 风格部分实现,这里的 #if 辅助函数可以用于布尔值:
<p>{{name.first}} {{name.last}}</p>
{{#if email_subscriber}}
<p>I am an email subscriber.</p>
{{/if}}
此模板不会渲染 #if 块内的部分,因为 email_subscriber 是 false。内置的 #if 辅助函数还提供了在 #if 块内包含 {{else}} 部分的选项,如果传递的变量评估为 false,则将渲染该部分:
<p>{{name.first}} {{name.last}}</p>
{{#if email_subscriber}}
<p>I am an email subscriber.</p>
{{else}}
<p>I am not an email subscriber.</p>
{{/if}}
给定示例对象,此模板将渲染以下内容:
<p>Jarmond Dittlemore</p>
<p>I am not an email subscriber.</p>
Handlebars 中的 #if 辅助函数与 Mustache 模板中的部分之间的另一个区别是,#if 辅助函数内的上下文不会改变,而部分内的上下文会改变为被调用的对象属性。
#unless 块辅助函数
Handlebars 中的 #unless 块辅助函数类似于 Mustache 模板中的反转部分功能,也可以被认为是 Handlebars #if 辅助函数的反面。如果传递给 #unless 辅助函数的值是假的,则该块将被渲染:
{
"name": {
"first": "Chappy",
"last": "Scrumdinger"
},
"email_subscriber": false
}
考虑一个类似于先前 #if 示例的模板,并基于前面的对象:
<p>{{name.first}} {{name.last}}</p>
{{#unless email_subscriber}}
<p>I am not an email subscriber.</p>
{{/if}}
此模板将渲染 #unless 块内的内容,因为 email_subscriber 的值为 false:
<p>Chappy Scrumdinger</p>
<p>I am not an email subscriber.</p>
#each 块辅助函数
#each 块辅助函数用于遍历列表和对象。在其最基本的形式中,它就像在列表上下文中使用 Mustache 部分,但它具有额外的功能,使其功能更强大:
{
"people": [
{ "firstName": "Peebo", "lastName": "Sanderson" },
{ "firstName": "Udis", "lastName": "Petroyka" },
{ "firstName": "Jarmond", "lastName": "Dittlemore" },
{ "firstName": "Chappy", "lastName": "Scrumdinger" }
]
}
在列表的 #each 上下文中,可以使用 this 关键字来引用列表中的当前值:
<ul>
{{#each people}}
<li>{{this.firstName}} {{this.lastName}}</li>
{{/each}}
</ul>
这与 Mustache 模板的迭代 lambda 示例类似,但在此情况下不需要 lambda 属性值来访问迭代的对象属性。
由于在此示例中,每个迭代的范围都限制在当前正在迭代的对象上,因此前面的模板也可以更简单地写成以下形式:
<ul>
{{#each people}}
<li>{{firstName}} {{lastName}}</li>
{{/each}}
</ul>
如你所见,由于每个迭代的上下文都设置为该对象,因此不需要this关键字来访问每个对象的属性。
#with块辅助函数
#with块辅助函数的工作方式与 Mustache 模板中的标准部分非常相似,通过将当前块的上下文限制为传入的父键:
{
"name": {
"first": "Peebo",
"last": "Sanderson"
},
"email_subscriber": false,
"age": 54,
"profession": "Singer"
}
给定这个示例对象,可以使用#with辅助函数来限制一个块到name键的上下文中来构建一个模板:
<h1>User Information</h1>
<dl>
<dt>Name</dt>
{{#with name}}
<dd>{{first}} {{last}}</dd>
{{/with}}
<dt>Age</dt>
<dd>{{age}}</dd>
<dt>Profession</dt>
<dd>{{profession}}</dd>
</dl>
此模板将渲染以下 HTML:
<h1>User Information</h1>
<dl>
<dt>Name</dt>
<dd>Peebo Sanderson</dd>
<dt>Age</dt>
<dd>54</dd>
<dt>Profession</dt>
<dd>Singer</dd>
</dl>
Handlebars 与 Mustache 模板的其他差异
Handlebars.js 中的许多功能都是为了让模板在浏览器中渲染得更快而设计的,这些功能使 Handlebars.js 与 Mustache.js 区分开来。Handlebars 中允许这一点的其中一个主要功能是预编译模板,正如我们在第二章中讨论的,“模型-视图-任意”。
预编译模板
预编译模板将它们转换为在渲染之前通常在应用程序中编译的 JavaScript 函数。使用此功能通过跳过该步骤来提高应用程序的速度,并且它还减少了浏览器对应用程序的负载,因为 JavaScript 编译器不需要包含在前端资产负载中。
没有替代分隔符
Handlebars 的创建者还决定,在模板内设置替代分隔符的能力并非必要。如果你没有预先编译你的模板,这将进一步减少应用程序的资产负载。
通常,除了个人偏好之外,你想要更改模板分隔符样式的唯一原因是为了避免与其他模板语言的冲突,例如,使用相同分隔符的服务器端模板语言。如果你要在服务器端模板中的 JavaScript 块内包含 Handlebars 模板,这个问题就会出现。然而,如果你预先编译你的模板或者通过将它们保存在自己的外部 JavaScript 文件中从服务器端模板中抽象它们,那么这个问题可以完全避免,并且不需要设置替代分隔符。
你可以在handlbarsjs.com了解更多关于 Handlebars.js 的信息。
Pure.js
Pure.js 是一个 JavaScript 模板引擎,它将无逻辑模板的概念推向了比 Mustache 和 Handlebars 更极端的程度。Pure.js 不使用在渲染之前必须插值的特殊模板表达式语法。相反,它仅使用纯 HTML 标签和 CSS 选择器,结合 JSON 数据,在 DOM 中渲染值。因此,Pure.js 使用完全无逻辑的视图,因为没有模板标记可以包含任何逻辑。
标记
使用纯 HTML,一个简单的 Pure.js 模板可以构建如下:
<p class="my-template">
Hello, my name is <span></span>.
</p>
空的<span>元素是你可以为特定模板添加数据的地方,但你可以使用任何 HTML 标签。
var data = {
name: 'Udis Petroyka'
};
var directive = {
'span': 'name'
};
在这个例子中,我们在 data 变量中提供了模板的数据,然后提供了一个所谓的 directive,它告诉模板引擎如何映射这些数据:
$p('.my-template').render(data, directive);
Pure.js 在全局范围内提供了一个 $p 对象,该对象提供了与模板交互的方法。在这种情况下,我们调用 render() 方法,并将 data 和 directive 作为参数传递:
<p class="my-template">
Hello, my name is <span>Udis Petroyka</span>.
</p>
这将是这个简单示例的渲染结果。您可以在 beebole.com/pure/. 上了解更多关于 Pure.js 的信息。
Pug
Pug,正式名称为 Jade,是一个在 Node.js 社区中非常突出的 JavaScript 模板引擎。它主要受到 HTML 抽象标记语言(Haml)的影响,最初设计用来通过使用比原始 HTML 更简洁、更简洁的语法来简化 ERB 模板的编写。因此,Pug 需要编译其表达式,以及标记语言本身。
Pug 与 YAML 类似,其层次结构通过缩进来表示,定界符使用空格。这意味着不需要关闭元素标签:
doctype html
html(lang="en")
head
title= pageTitle
body
h1 This is a heading
if thisVariableIsTrue
p This paragraph will show.
else
p This paragraph will show instead.
如此例所示,Pug 可以用作 HTML 的简单缩写语法。它还可以包含带有变量的简单条件,所有这些都遵循相同的流畅语法。通过在标签名后包含括号并定义属性,可以添加 HTML 元素属性,例如示例中的 html(lang="en")。用变量填充的元素通过在标签名后放置等号并跟随 JavaScript 键名来表示,如示例中的 title= pageTitle 所示:
{
pageTitle: 'This is a dynamic page title',
thisVariableIsTrue: true
}
使用这个示例 JavaScript 对象,前面的模板将渲染以下 HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<title>This is a dynamic page title</title>
</head>
<body>
<h1>This is a heading</h1>
<p>This paragraph will show.</p>
</body>
</html>
内联变量还可以使用另一种语法,允许访问顶层属性和嵌套属性。考虑以下对象:
{
"name": {
"first": "Jarmond",
"last": "Dittlemore"
},
"email_subscriber": false,
"age": 24,
"profession": "Student"
}
一个 Pug 模板可以编写来访问这个对象中的所有变量,如下所示:
h1#title Hello, my name is #{name.first} #{name.last}.
if email_subscriber
p I am an email subscriber.
else
p i am not an email subscriber.
h2.age Age
p= 24
h2.profession Profession
p I am a #{profession}.
在这个模板中,您可以看到使用了内联变量语法 #{},以及一个条件和用 = 语法填充变量的元素。
您还会注意到,h1 标签后面紧跟着一个 # 符号和单词 title,而 h2 标签后面跟着 .className。这展示了 Pug 的另一个特性,它允许使用标准的 CSS 选择器语法来包含 ID 和类。从这个模板渲染的 HTML 将如下所示:
<h1 id="title">Hello, my name is Jarmond Dittlemore.</h1>
<p>I am not an email subscriber.</p>
<h2 class="age">Age</h2>
<p>24</p>
<h2 class="profession">Profession</h2>
<p>I am a Student.</p>
这个例子展示了使用 Pug 编写比标准 HTML 结合另一种模板语法更简洁的写作方式,这可能是它变得如此受欢迎的原因。您可以在 pug-lang.com 上了解更多关于 Pug 的信息。
嵌入式 JavaScript (EJS)
EJS 是一个类似于 Underscore.js 的 JavaScript 模板引擎,它也使用 ERB <% %> 样式的定界符。或者,它还允许使用 [% %] 样式的标签作为定界符。
就像 Underscore.js 一样,EJS 允许在使用标准 <% %> ERB 风格语法时解析任意 JavaScript,并允许使用等于号 = 后跟开标签 <%= %> 来评估表达式:
<ul>
<% for (var i = 0; i < people.length; i++) { %>
<li><%= firstName %> <%= lastName %></li>
<% } %>
</ul>
此模板可用于遍历一个对象列表,其中键名作为具有不同值的变量进行评估:
{
"people": [
{ "firstName": "Peebo", "lastName": "Sanderson" },
{ "firstName": "Udis", "lastName": "Petroyka" },
{ "firstName": "Jarmond", "lastName": "Dittlemore" },
{ "firstName": "Chappy", "lastName": "Scrumdinger" }
]
}
使用示例模板遍历此对象将渲染以下 HTML:
<ul>
<li>Peebo Sanderson</li>
<li>Udis Petroyka</li>
<li>Jarmond Dittlemore</li>
<li>Chappy Scrumdinger</li>
</ul>
同步模板加载
在典型的 EJS 用例中,每个模板都存储在一个具有专有 .ejs 扩展名的文件中。通过创建一个新的 EJS 对象,提供模板文件的路径,并使用要插入的数据调用渲染方法,从 JavaScript 代码编译模板。
假设 EJS 模板保存在您项目中的一个文件中,文件名为 templates/people.ejs。然后可以编写以下 JavaScript 代码来将其渲染为示例中显示的 HTML:
var data = {
"people": [
{ "firstName": "Peebo", "lastName": "Sanderson" },
{ "firstName": "Udis", "lastName": "Petroyka" },
{ "firstName": "Jarmond", "lastName": "Dittlemore" },
{ "firstName": "Chappy", "lastName": "Scrumdinger" }
]
};
var html = new EJS({url: 'templates/people.ejs'}).render(data);
全局 EJS 对象是一个构造函数,您可以通过创建一个新实例来解析模板,并对其调用方法进行渲染。请注意,由于文件路径在 JavaScript 中被引用,必须进行 同步 调用来最初加载模板以进行解析。这保持了您应用程序初始页面加载的低延迟,但可能会根据加载的模板的复杂性和应用程序运行的服务器速度导致交互时的响应时间更长。
一旦在您的 JavaScript 代码中创建了渲染后的 HTML,您只需将其插入到应用程序的 DOM 中:
var html = new EJS({url: 'templates/people.ejs'}).render(data);
document.body.innerHTML = html;
异步数据加载
EJS 的一个独特功能是能够使用从外部源异步加载数据来渲染模板。使用前面的示例,假设 JSON 数据在一个名为 people.json 的外部文件中:
new EJS({url: 'templates/people.ejs'}).update('element_id', 'people.json');
在此示例中,调用的是 .update() 方法而不是 .render()。对象实例也没有被分配给变量,因为 DOM 插入由 .update() 方法以及通过传递 DOM 节点 ID 来处理。为了使此方法工作,不能使用其他 CSS 选择器来将 HTML 注入 DOM;只有 ID 才有效。
缓存
EJS 默认在模板首次同步加载后缓存模板。这提供了一个优势,即多次使用的模板在第一次请求之后将始终加载得更快,而未使用的模板不会占用任何内存。这种方法与在应用程序初始页面加载时将所有模板加载到内存中的预编译方法形成鲜明对比。这两种方法都有优缺点,因此必须仔细选择最适合您特定应用程序的最佳方法。
可以通过在传递给任何模板实例化的选项对象中包含一个 cache 键来关闭任何模板的缓存:
var html = new EJS({url: 'templates/people.ejs', cache: false}).render(data);
视图辅助函数
EJS 包含一些与 Handlebars 模板中辅助函数概念类似的视图辅助函数。它们允许使用更短的语法来表示一些常见的 HTML 元素。以下我们将展示几个示例。
link_to 视图辅助函数
link_to 视图辅助函数提供了一个简单的模板语法,以确保 HTML 超链接:
<p>Here is a <%= link_to('link', '/link/path') %> to a path.</p>
link_to 视图辅助函数的第一个参数是链接的显示文本,第二个参数是要传递给链接的 href 属性的路径。注意,视图辅助函数的定界符使用的是开表达式定界符语法。以下示例将渲染以下 HTML:
<p>Here is a <a href="/link/path">link</a> to a path.</p>
img_tag 视图辅助函数
此 img_tag 视图辅助函数提供了一个在渲染的 HTML 中包含图像的简单语法:
<p><%= img_tag('/path/to/image.png', 'Description text') %></p>
link_to 视图辅助函数的第一个参数是图像的路径,第二个参数是图像的 alt 属性的文本。以下示例将渲染以下 HTML:
<p><img src="img/image.png" alt="Description text"></p>
form_tag 视图辅助函数
form_tag 视图辅助函数提供了一个构建 HTML 表单的语法,可以与其他视图辅助函数结合使用,以创建输入元素:
<%= form_tag('/path/to/action', {method: 'post', multipart: true}) %>
<%= input_field_tag('user_input', 'value text here') %>
<%= submit_tag('Submit') %>
<%= form_tag_end() %>
在此示例中,使用了四个视图辅助函数来构建表单。form_tag 视图辅助函数创建表单的主体,提供表单操作作为第一个参数,并使用大括号中的 JavaScript 对象语法在第二个参数中提供其他表单属性。input_field_tag 视图辅助函数用于创建标准输入文本字段,输入名称作为第一个参数,可选地,输入值作为第二个参数。submit_tag 视图辅助函数创建一个带有按钮文本的表单提交输入,作为第一个参数传递。最后,使用 form_tag_end 视图辅助函数关闭表单的主体。此示例将渲染以下 HTML:
<form action="/path/to/action" method="post" enctype="multipart/form-data">
<input type="text" id="user_input" name="user_input" value="value text here">
<input type="submit" value="Submit">
</form>
EJS 还包括许多其他视图辅助函数,用于常见的 HTML 元素,使用 _tag 后缀。
部分模板
EJS 有自己的部分实现,它使用同步模板加载技术,在模板定界符标签内工作。要使用此功能,需要在父模板中直接调用部分模板文件。考虑以下两个模板文件:
templates/parent.ejs
<p>This is the parent template.</p>
<%= this.partial({url: 'templates/partial.ejs %>
templates/partial.ejs
<p>This is the partial template.</p>
注意,调用部分模板时,使用表达式定界符和 this.partial 方法的调用引用了父模板中的 URL。要在另一个模板中加载部分模板,只需从你的 JavaScript 代码中初始化父模板即可:
var html = new EJS({url: 'templates/parent.ejs'}).render();
document.body.innerHTML = html;
最终渲染的 HTML 将如下所示:
<p>This is the parent template.</p>
<p>This is the partial template.</p>
这些示例提供了 EJS 的简要概述,但稍后我们将更深入地使用这个模板引擎。有关 EJS 模板的更多信息,请访问 embeddedjs.com。
优化你的应用程序布局
构建一个 JavaScript 单页应用(SPA)通常涉及许多抽象层,包括自定义应用程序代码、第三方库、前端框架、任务运行器、转译器等等。所有这些最终可能导致前端应用程序需要下载大量的 JavaScript 代码,因此应始终采取措施尽可能最小化这种影响。
让我们回到我们迄今为止一直在工作的 Node.js 示例应用程序。在 第二章 中,我们为应用程序编写了 index.html 布局页面,其中包含了以下脚本标签,用于第三方库和编译后的模板:
<script src="img/jquery.min.js"></script>
<script src="img/handlebars.runtime.min.js"></script>
<script src="img/payload.js"></script>
<script src="img/templates.js"></script>
与一个完整规模的应用程序可能包含的 JavaScript 文件数量相比,这实际上是一个最小示例。
UglifyJS 和 grunt-contrib-uglify
用于最小化和连接 JavaScript 文件的常见工具是 UglifyJS。我们可以利用这个工具在命令行上,并通过 Grunt 任务运行器和 grunt-contrib-uglify 任务来自动化它:
$ npm install grunt-contrib-uglify --save-dev
一旦安装,打开 Gruntfile.js 并立即在 watch 任务上方添加以下任务:
uglify: {
options: {
preserveComments: false
},
main: {
files: {
'js/all.min.js': [
'bower_components/jquery/dist/jquery.js',
'bower_components/handlebars/handlebars.runtime.js',
'bower_components/payloadjs/payload.js',
'js/src/templates.js'
]
}
}
}
这设置了 uglify 任务,以移除所有注释(preserveComments 选项设置为 false),打乱或缩短变量和函数名称,并将指示的 JavaScript 文件列表连接到单个目标文件名 all.min.js。使用此设置,UglifyJS 将根据输入文件创建尽可能小的 JavaScript 下载大小。
接下来,确保在 Gruntfile.js 的底部加载新的 uglify 任务,与其他任务一起:
grunt.loadNpmTasks('grunt-contrib-uglify');
现在,你只需在命令行上运行任务:
$ grunt uglify
运行任务后,你应该看到类似以下内容的输出:
Running "uglify:main" (uglify) task
File all.min.js created: 322.28 kB → 108.6 kB
>> 1 file created.
Done, without errors.
你会注意到 CLI 输出显示了 JavaScript 文件的原始大小,以及第二行中最终输出的大小;在这个例子中,显示 322.28 kB → 108.6 kB。在这种情况下,它被压缩到原始大小的一半以下。
现在,你可以更改你的 index.html 布局文件,使其仅调用一个 JavaScript 文件:
<!doctype html>
<html>
<head>
<title>My Application</title>
</head>
<body>
<div id="app"></div>
<script src="img/all.min.js"></script>
</body>
</html>
将 <script> 标签放置在页面底部也确保了在 JavaScript 完全下载之前,任何位于其上方的内容都将被加载并可见给用户。这是通过防止用户在看到任何内容之前出现延迟来优化 SPAs 的另一种常见做法。
grunt-contrib-handlebars
如果您在应用程序中使用 Handlebars 模板,则 grunt-contrib-handlebars 任务可用于从命令行和通过监视任务轻松预编译它们。在 第二章 中,我们创建了项目根目录下的示例 user.handlebars 文件,在 第四章 中,我们创建了 users.handlebars。现在,在 js/templates 目录下创建一个新的目录,并将文件移到那里。接下来,将文件重命名为 user.hbs 和 users.hbs 以便简洁。.hbs 扩展名也广泛接受为 Handlebars 文件:
/
js/
templates/
user.hbs
users.hbs
接下来,安装 grunt-contrib-handlebars 插件:
$ npm install grunt-contrib-handlebars --save-dev
安装完成后,将以下任务配置添加到 Gruntfile.js 文件中,位于 uglify 任务配置之上:
handlebars: {
options: {
namespace: 'Handlebars.templates',
processName: function(file) {
return file.replace(/js\/templates\/|\.hbs/g, '');
},
partialRegex: /.*/,
partialsPathRegex: /\/partials\//
},
files: {
src: 'js/templates/**/*.hbs',
dest: 'js/src/templates.js' }
}
Handlebars 的 Grunt 插件为您做出的假设比 Handlebars 命令行工具默认的假设要少,因此此配置为您做了几件事情。
选项配置
首先,options 对象传递了四个参数。namespace 选项简单地告诉编译器使用哪个全局命名空间来存储编译后的 Handlebars 模板函数。Handlebars.templates 是使用命令行工具的默认命名空间,因此我们将采用这个。
processName 参数传递一个函数,该函数接受一个 Handlebars 文件作为参数,并使用它来创建 Handlebars.templates 命名空间中该模板的键名。在这种情况下,我们使用正则表达式来获取路径和文件名,并移除除了文件名前缀之外的所有内容,因此 user.hbs 的编译模板函数,例如,将在 Handlebars.templates.user 下可用。
partialRegex 选项接受一个正则表达式,用于识别部分文件名的模式。默认情况下,这是一个以下划线 _ 为前缀的文件,但在此情况下,我们将使用目录来存储部分,因此 partialRegex 选项设置为 .*,这意味着它将识别给定路径上的任何文件作为部分。
partialsPathRegex 选项接受一个正则表达式,用于识别部分目录的路径。我们将其设置为 /\/partials\//,这将评估为主模板路径下传递的 /partials 目录。结合 partialRegex 选项,这告诉编译器解析 /partials 目录中的每个文件作为部分,并将编译后的模板函数添加到 Handlebars.partials 命名空间中。
文件配置
传递给 Handlebars Grunt 任务的文件配置对象用于告诉编译器使用什么文件模式来查找编译模板,以及定义编译模板的输出文件名:
files: {
src: 'js/templates/**/*.hbs',
dest: 'js/src/templates.js'
}
在这个例子中,我们已经定义了模板 src 目录位于根路径下的 js/templates/,并且解析该目录及其所有子目录下扩展名为 .hbs 的所有文件。递归目录查找由 /**/*.hbs 语法表示。
dest 键告诉编译器使用所有模板的最终编译输出创建 js/src/templates.js 文件。
运行 Grunt Handlebars 任务
为了运行 handlebars 任务,我们首先需要在 Gruntfile.js 的底部加载该插件:
grunt.loadNpmTasks('grunt-contrib-handlebars');
接下来,从命令行运行 grunt handlebars 命令:
$ grunt handlebars
运行任务后,您应该会看到类似以下内容的输出:
Running "handlebars:files" (handlebars) task
>> 1 file created.
Done, without errors.
现在,如果您查看 js/src/ 目录,应该会看到在那里创建了一个 templates.js 文件,它与我们在 第一章 中创建的 app.js 文件相邻,即 使用 NPM、Bower 和 Grunt 进行组织。现在我们在这里存储 templates.js 文件,请继续删除根目录中的原始 templates.js 文件,并编辑 Grunt uglify 任务中的 files 对象,使其如下所示:
files: {
'js/all.min.js': [
'bower_components/jquery/dist/jquery.js',
'bower_components/handlebars/handlebars.runtime.js',
'bower_components/payloadjs/payload.js',
'js/src/templates.js'
]
}
现在我们已经将新的 templates.js 文件添加到 uglify 任务中,以便将其包含在完整的压缩应用程序 JavaScript 中。
监视更改
现在您正在加载压缩的 JavaScript 文件,您可能希望添加一个监视任务,在您开发时创建该文件,这样您就不必不断从 CLI 运行命令。
对于这个例子,让我们假设我们想要检测 js/src 目录中任何文件的更改,我们在那里积极工作。编辑 Gruntfile.js 中的 watch 任务配置,并在该任务的 jshint 目标下方直接添加以下内容:
uglify: {
files: ['js/src/*.js'],
tasks: ['uglify:main']
}
这告诉 Grunt 在检测到匹配模式的文件更改时运行 uglify 任务的 main 目标。此外,将 jshint 监视任务上面的 uglify 监视任务更改为以下内容:
jshint: {
files: ['js/src/*.js', '!js/src/templates.js'],
tasks: ['jshint']
}
这告诉监视任务在运行 jshint 任务时忽略对 templates.js 的更改。我们想忽略此文件,因为它已编译并且不会通过 JSHint 测试。
将相同的文件忽略路径添加到文件顶部附近的 main jshint 任务 files 配置中:
files: {
src: [
'Gruntfile.js',
'js/src/*.js',
'!js/src/templates.js'
]
}
这将防止 JSHint 在运行 jshint 任务时将 templates.js 与其定义的规则进行比较。
我们还需要一个监视 Handlebars 模板文件更改的 watch 任务。在 watch 任务中,在 uglify 目标下方添加以下配置:
handlebars: {
files: ['js/templates/**/*.hbs'],
tasks: ['handlebars']
}
这将监视 Handlebars 模板和部分文件的任何更改,并相应地运行 handlebars 任务。这样做将生成 templates.js 文件,然后触发 uglify 监视任务运行,并将完整的应用程序 JavaScript 编译为 all.min.js。
接下来,从命令行运行 Grunt watch 命令:
$ grunt watch
Running "watch" task
Waiting...
现在打开user.hbs,将标记修改为以下示例所示。注意,{{name.first}}和{{name.last}}表达式已更新为我们之前在 MongoDB 中创建的属性,参见第四章,最佳实践 - 与应用程序的后端交互:
<h3>{{first_name}} {{last_name}}</h3>
<p>{{title}}</>
保存文件后,检查运行watch任务的控制台。你应该会看到以下类似的输出:
>> File "js/templates/user.hbs" changed.
Running "handlebars:files" (handlebars) task
>> 1 file created.
Done, without errors.
Completed in 0.979s - Waiting...
>> File "js/src/templates.js" changed.
Running "uglify:main" (uglify) task
File all.min.js created: 322.28 kB → 108.6 kB
>> 1 file created.
Done, without errors.
对user.hbs的更改触发了两个任务的连锁反应,并且你的应用程序 JavaScript 被编译成最新版本。如果你打开编译后的templates.js文件,你会看到已经创建了一个用户及其属性,并关联了模板函数:
this["Handlebars"]["templates"]["user"] = Handlebars.template({...}});
this["Handlebars"]["templates"]["users"] = Handlebars.template({...}});
接下来,当handlebars任务仍在运行时,将user.hbs文件移动到js/templates/partials目录。这将再次触发监视任务。当它完成后,再次打开templates.js,你会注意到Handlebars.templates.user属性不再定义。相反,使用.registerPartial()函数调用来代替:
Handlebars.registerPartial("user", Handlebars.template({...});
这将在父模板中使用 Handlebars 部分语法包含user.hbs部分时调用user.hbs部分。现在打开users.hbs,将其修改为使用user.hbs部分:
{{#each data}}
{{> user}}
{{/each}}
这将遍历提供的用户数据。在第四章,最佳实践 - 与应用程序的后端交互中,我们留下了只有一个条目的测试数据库,所以现在让我们添加另一个条目以使此示例更具说明性。
在另一个控制台会话中,使用 Express 运行你的本地 Node.js 服务器:
$ node server.js
App now listening on port 8080
现在在浏览器中转到 localhost:8080,并使用 POST 请求表单添加另一个条目到数据库:
First name: Peebo
Last name: Sanderson
Title: Singer
添加额外记录后,点击 GET 请求表单下的“加载用户数据”链接。你应该会看到以下类似的输出:
Philip Klauzinski
Sr. UI Engineer
Peebo Sanderson
Singer
此内容是通过在users.hbs中循环遍历 MongoDB 中的用户数据并填充user.hbs部分中的表达式来渲染的。
整合所有内容
使用单个、压缩的 JavaScript 文件作为你的应用程序代码,预编译你的 JavaScript 模板,并在应用程序布局页面的底部加载 JavaScript,这些都是优化你的 SPA 下载时间的好做法。将所有 JavaScript 包含在一个文件中而不是多个文件中,与压缩 JavaScript 一样重要,因为它减少了客户端为加载你的 SPA 而必须发出的 HTTP 请求的数量。同样的做法也应该用于 CSS,并且可以使用 Grunt 和插件如grunt-contrib-cssmin和grunt-postcss来实现。
摘要
你现在应该对一些流行的 JavaScript 模板引擎之间的区别有了很好的理解,包括如何使用它们进行基本视图,以及它们的一些优缺点。你还应该了解使用预编译模板和浏览器中编译的模板之间的区别。此外,你已经学习了在布局文件中使用的优化方法,以最小化应用程序的下载大小,包括压缩、合并成一个文件,以及在文档底部包含 JavaScript。在下一章中,我们将通过分解数据绑定技术来进一步深入到视图层。
数据绑定,以及为什么你应该拥抱它
单页应用的视图层远远超出了通过 JavaScript 模板引擎或其他方式静态显示 HTML 和数据。一个现代的 JavaScript 应用必须处理实时更新并具有反应性。在第四章中描述的一些协议,如 WebSockets、MQPP 和 DDP,可以用来主动检索应用的数据更新,但将这些更改绑定到 DOM 并在视图中显示的能力必须在应用的前端处理。这就是数据绑定发挥作用的地方。
在本章中,你将学习:
-
什么是数据绑定?
-
单向和双向数据绑定的区别
-
AngularJS 的数据绑定实现
-
其他流行的数据绑定实现
-
如何使用原生 JavaScript 实现数据绑定?
-
数据绑定的某些用例是什么?
第六章:什么是数据绑定?
在高层次上,数据绑定是一种软件设计模式,它指定了将更改直接绑定到你的底层应用数据,或模型,到视图的能力,通过视觉上自动反映这些更改。这可以通过使用 JavaScript 的多种方式来完成,并且这实际上取决于你使用的 JavaScript 版本及其能力和限制。在 Web 应用的情况下,这些能力和限制当然是由用户的浏览器控制的,这也是为什么在 JavaScript 社区中有如此多的数据绑定实现。
如果你曾经使用过任何流行的 JavaScript 框架,或者至少阅读过关于它们的资料,你可能已经听说过数据绑定。你也可能从未尝试过自己实现它,考虑到现在有那么多库和框架提供了这种功能。这些实现中的一些给你带来的优势是通过在浏览器中使用多种方法和功能检测来提供跨浏览器兼容性。其他框架,如 Ember.js 和 Knockout.js,使用它们自己的专有数据绑定实现,这些实现可以在大多数浏览器中工作,但如果你只想使用数据绑定功能,可能需要加载一个可能很大的库。
使用库或框架进行复杂的数据观察通常比编写自定义 JavaScript 来自己实现更受欢迎,这也说明了 AngularJS 等框架的流行——它经常因其数据绑定功能而受到赞誉。利用这些功能是一回事,但理解它们是如何工作的以及框架底层发生了什么则是另一回事。首先,让我们更深入地分解一下数据绑定的概念。
单向数据绑定
单向,或单向,数据绑定是指应用程序数据模型的更改被更新,并随后反映在视图中。数据模型的初始更改可能来自任何地方,无论是当前用户提交表单,还是另一台计算机上不同用户的帖子编辑,或者是应用程序的主服务器直接推送的当前数据变化。当数据的变化自动与动态模板合并并在视图中更新,而不需要用户的干预时,这被称为单向数据绑定:

单向数据绑定通过将 ViewModel 与模板合并在视图中进行可视化。
在这里,你可以看到单向数据绑定设计模式的一个简单表示。视图更新的方式完全取决于应用程序的前端 JavaScript 编写方式,可以以任何数量的方式完成,但概念模式本身保持不变。
使用 JavaScript 模板引擎,如第五章中讨论的,关于视图的一切在编译模板中的表达式绑定到动态数据时,在模板级别提供单向数据绑定。然而,要更新视图以反映数据的实时变化,必须使用额外的代码来观察模型更改并相应地触发视图更新。
双向数据绑定
双向,或双向,数据绑定包括单向数据绑定模式,但还允许用户更改视图中数据的表示,并反映在底层的模型本身中。有了这种模式,视图中显示的数据始终是模型当前状态的表示,即使用户在视图中更改了这些数据,而没有通过表单或其他方式明确提交:

注意
双向数据绑定通过从 ViewModel 的更改合并到模板中在视图中进行可视化,并且用户对视图中数据表示的更改会合并回 ViewModel。
此图显示了双向数据绑定设计模式。为了使此模式工作,必须有一些类型的观察者持续监视数据的变化,并在两个方向上同步它。这自然需要一个更复杂的客户端架构,并且可以使用流行的框架,如 AngularJS,来掌握主动权。
使用现代 JavaScript 框架进行数据绑定
由于数据绑定设计模式带来的复杂性,有一些独立的 JavaScript 库,例如 Rivets.js 和 Knockout.js,可以为你提供这项功能。许多完整的 JavaScript 框架也包含它们自己的数据绑定实现,作为核心功能之一。
使用 AngularJS 进行数据绑定
由谷歌维护的 AngularJS 是现代 JavaScript 框架中最受欢迎的之一。正如在第二章中讨论的,模型-视图-任意是一个自诩的 MVW 框架。除了其 MVW 架构模式的实现外,它还包括一个强大的数据绑定设计模式,这通常是它最被吹嘘的功能。
AngularJS 的单向数据绑定
当视图中的一个表达式被与该视图关联的控制器中的模型值填充时,就实现了 AngularJS 的单向数据绑定。考虑以下控制器和模型数据:
var myApp = angular.module('myApp', []);
myApp.controller('UserController', function UserController($scope) {
$scope.user = {
firstName: 'Peebo',
lastName: 'Sanderson'
};
});
在此控制器的作用域上定义的用户模型可以用以下模板标记在视图中表示:
<body ng-app="myApp">
<div ng-controller="UserController">
<p>
<strong>First Name:</strong> {{user.firstName}}<br>
<strong>Last Name:</strong> {{user.lastName}}
</p>
</div>
</body>
就像许多其他 JavaScript 模板引擎一样,AngularJS 模板中使用双大括号语法来表示要评估的表达式。此外,AngularJS 允许在空 HTML 元素上使用 ng-bind 属性,以替代双大括号语法来表示表达式:
<body ng-app="myApp">
<div ng-controller="UserController">
<p>
<strong>First Name:</strong>
<span ng-bind="user.firstName"></span><br>
<strong>Last Name:</strong>
<span ng-bind="user.lastName"></span>
</p>
</div>
</body>
这种语法更冗长,但可能对某些人来说更可取。在任何情况下,模型属性的更改都将自动更新到视图,这些属性通过它们各自的模板表达式绑定。这样,AngularJS 提供了底层 DOM 操作层,将模型更改与视图更新连接起来,而无需任何其他代码。
AngularJS 的双向数据绑定
当视图中的可编辑值,如文本输入,被分配给当前控制器作用域的模型属性时,就实现了 AngularJS 的双向数据绑定。当用户更改该属性的值时,模型将自动更新,并且该更改将传播回视图,以任何绑定到该模型属性的任何表达式。
使用前一个示例中的控制器和模型,考虑以下模板标记:
<body ng-app="myApp">
<div ng-controller="UserController">
<p>
<strong>First Name:</strong> {{user.firstName}}<br>
<strong>Last Name:</strong> {{user.lastName}}
</p>
<p>
<label>
<input type="text" ng-model="user.firstName">
</label><br>
<label>
<input type="text" ng-model="user.lastName">
</label>
</p>
</div>
</body>
文本输入被赋予 ng-model 属性,在视图最初加载时将模型属性作为值分配。当用户更改这些输入中的任何一个的值时,$scope.user 模型将被更新,然后该更改将在输入上方的段落块中反映出来,其中相同的属性通过它们各自的表达式绑定到 DOM。从这个视图中的变化到模型,再到视图的往返是双向数据绑定的一个简单示例。
AngularJS 的脏检查
AngularJS 使用轮询方法来查找模型和视图之间的差异,这种方法被称为脏检查。这种检查是在定义的间隔内进行的,这被称为消化周期。对于每个消化周期,作用域通过注册监听器来注册特殊方法,称为观察者,以监视传递给它们的绑定表达式的变化:
$scope.$watch(watchExpression, listener);
如第二章所述,模型-视图-Whatever 中的 作用域 是一个 JavaScript 对象,它为视图中的变量表达式定义了模型上下文。观察者将绑定模型表达式与其先前值进行比较,如果其中任何一个被发现是 脏的 或不同的,则执行监听器回调,并将更改同步到视图。
AngularJS 允许根据您的需求在对象的多个深度级别上进行脏检查。为此提供了三种类型的观察,分别对应三个深度级别。这些级别提供了灵活的数据绑定功能,但深度越大,性能问题就越多。
通过引用进行脏检查
AngularJS 中脏检查的标准方法是在绑定表达式的整个值改变为新值时进行观察。这被称为通过引用进行脏检查。如果表达式表示一个对象或数组,并且只对其属性或成员进行更改,则更改将不会被检测到。这是脏检查的最深层次,因此性能最佳。
例如,考虑一个具有多个属性的用户对象被应用于作用域:
$scope.user = {
firstName: 'Peebo',
lastName: 'Sanderson',
age: 54
};
现在可以绑定一个观察表达式,通过引用到对象的一个属性:
$scope.$watch('user.firstName', listener);
$scope.user.firstName = 'Udis';
由于 user.firstName 已更改,这将在后续的消化周期中被捕获,并将触发监听器函数。现在考虑相反的情况,我们观察用户对象本身:
$scope.$watch('user', listener);
$scope.user.lastName = 'Petroyka';
// The entire value of $scope.user has not changed
在这种情况下,在更改 user.lastName 之后,观察者没有捕获到任何内容。这是因为观察者正在寻找用户对象本身的更改 - 而不是其单个属性:
$scope.user = {
firstName: 'Udis',
lastName: 'Petroyka,
age: 82
};
// The entire value of $scope.user has changed
如果您要替换整个用户对象本身,观察者会发现值是 脏的,然后在下一个消化周期中调用监听器。
通过集合内容进行脏检查
如果您需要观察对象或数组的浅层更改,AngularJS 提供了另一种观察方法,称为 $watchCollection。在这种情况下,浅层 意味着观察者只会对对象或数组的第一个级别的更改做出响应,**- 深层** 属性更改,或嵌套对象或数组的更改将不会被检测到。AngularJS 将此称为通过集合内容进行脏检查:
$scope.$watchCollection(obj, listener);
在这种情况下,从上一个示例中更改用户对象的属性将被观察者捕获并触发 监听器:
$scope.$watchCollection('user', listener);
$scope.user.firstName = 'Jarmond';
// A property of the object has changed
通过集合内容进行脏检查的性能不如通过引用进行脏检查,因为必须在内存中保留被观察对象或数组的副本。
通过值进行脏检查
AngularJS 还允许您观察对象或数组中任何嵌套数据的更改。这被称为通过值进行脏检查:
$scope.$watch(watchExpression, listener, true);
您可以使用$watch方法实现此监视方法,就像使用引用检查一样,但添加一个设置为 true 的第三个参数。此参数告诉监视器您是否想要检查对象相等性,它默认为 false。当监视器通过引用检查相等性时,它执行简单的!==条件。然而,当$watch 的第三个参数设置为 true 时,它使用内部 angular.equals 方法进行深度比较。
angular.equals 方法可以用来比较任何两个值,它支持值类型、正则表达式、对象和数组。如果被比较的属性是一个函数或其名称以 $ 开头,它将被忽略。忽略函数的原因很明显,至于 $ 前缀,可能是为了避免 AngularJS 内部功能被覆盖。
通过值进行脏检查是 AngularJS 中最全面的数据绑定形式,但同时也是性能最差的。这是因为必须将任何复杂对象或数组被比较的完整副本保留在内存中,就像通过集合内容进行脏检查一样,但除此之外,还必须在每个消化周期中对整个对象或数组进行深度遍历。为了在您的应用程序中保持内存效率,使用这种类型的数据绑定时应该格外小心。
何时使用脏检查进行数据绑定
数据绑定的脏检查方法有其优点和缺点。AngularJS 向我们保证,只要您在一个视图中不进行数千次绑定,内存就不会成为问题。然而,缺点是,由于消化周期的延迟,模型的变化并不总是实时显示。如果您正在设计一个希望显示真正实时、双向数据绑定的应用程序,那么 AngularJS 可能不是您的解决方案。
使用 Ember.js 进行数据绑定
Ember.js 是一个流行的开源 JavaScript 框架,用于构建 Web 应用程序。它在提供的功能上与 AngularJS 相似,但在数据绑定方面采取了相当不同的方法。
Ember.js 运行一个内部循环,类似于 AngularJS 中的消化周期,称为**运行循环**。它不对绑定模型数据使用脏检查,但维护运行循环以执行其他内部功能,例如按特定顺序执行工作队列。在运行循环中安排操作的主要原因是提供内存管理和优化框架的效率。
Ember.js 使用属性访问器来提供数据绑定,这意味着它使用直接对象属性来获取和设置绑定模型的值。有了这种机制,它可以放弃脏检查来使用数据绑定。
计算属性
Ember.js 通过对象属性访问器内部使用计算属性来设置和获取值。这意味着属性被定义为执行某种类型操作以产生最终返回值的函数。为此,使用内部Ember.Object.extend方法扩展了原生的 JavaScript 对象类型,并使用Ember.computed方法返回计算属性:
var User = Ember.Object.extend({
firstName: null,
lastName: null,
fullName: Ember.computed('firstName', 'lastName', function() {
return `${this.get('firstName')} ${this.get('lastName')}`;
})
});
对于这个扩展的User对象,firstName和lastName属性是静态的,但fullName属性是通过传递给它的参数'firstName'和'lastName'字符串计算得出的。这告诉计算方法,扩展对象的这些属性将用于计算fullName返回的值。
现在,要访问fullName返回的值,必须首先使用静态的firstName和lastName属性创建一个新的User对象:
var currentUser = User.create({
firstName: 'Chappy',
lastName: 'Scrumdinger'
});
一旦使用给定的firstName和lastName值创建了一个currentUser对象,就可以计算并返回fullName属性:
currentUser.get('fullName'); // returns "Chappy Scrumdinger"
这种扩展对象的约定有点冗长,但它允许 Ember.js 在内部处理计算属性,同时跟踪绑定对象,并在各种用户代理或浏览器之间规范化 JavaScript 的不一致性:
Ember.js 通过对象属性访问器内部使用计算属性来设置和获取值。这意味着属性被定义为执行某种类型操作以产生最终返回值的函数。为此,使用内部Ember.Object.extend方法扩展了原生的 JavaScript 对象类型,并使用Ember.computed方法返回计算属性:
Ember.js 在其数据绑定实现中使用计算属性,这意味着可以直接使用属性访问器,并且不需要进行脏检查。对于单向绑定,你可以获取对象的属性,但不能设置它:
var User = Ember.Object.create({
firstName: null,
lastName: null,
nickName: Ember.computed.oneWay('firstName')
});
在这个例子中,使用Ember.computed.oneWay方法将nickName属性作为一个别名应用于firstName属性的单一方向绑定:
var currentUser = User.create({
firstName: 'Peebo',
lastName: 'Sanderson'
});
当创建一个新的User对象时,可以访问它的nickName属性:
currentUser.get('nickName'); // returns "Peebo"
由于这只是一个单向绑定,因此nickName属性不能用来设置别名的firstName属性:
currentUser.set('nickName', 'Chappy');
currentUser.get('firstName'); // returns "Peebo"
通常,你可能只需要在应用程序中返回绑定值,而不是从视图隐式设置它们。使用 Ember.js,可以使用Ember.computed.oneWay方法来实现这个目的,并将节省你额外的性能担忧。
Ember.js 也通过计算属性提供双向数据绑定。
Ember.js 也通过计算属性提供双向数据绑定。这也使用了一个别名范式;然而,计算的双向别名允许获取和设置别名的属性:
var User = Ember.Object.extend({
firstName: null,
lastName: null,
nickName: Ember.computed.alias('firstName')
});
在这种情况下,我们使用Ember.computed.alias方法通过计算nickName属性来为别名的firstName属性实现双向数据绑定:
var currentUser = User.create({
firstName: 'Udis',
lastName: 'Petroyka'
});
当现在创建一个新的User对象时,可以通过nickName属性来设置和获取别名的firstName属性:
currentUser.get('nickName'); // returns "Udis"
currentUser.set('nickName', 'Peebo');
currentUser.get('firstName'); // returns "Peebo"
现在,随着双向数据绑定,视图同步开始发挥作用。在这个场景中关于 Ember.js 有一点需要注意,尽管它不使用脏检查,但在模型值改变后,它不会立即更新绑定到模型上的值。属性访问器确实用于聚合绑定数据的更改,但它们只有在下一个运行循环时才会同步,就像 AngularJS 和其消化周期一样。在这方面,你可以推断出,与 Ember.js 相比,AngularJS 的数据绑定实际上并没有太大区别,并且在这方面的框架之间也没有任何优势。
请记住,这些框架中实现的内部循环机制是为了性能优化而设计的。在这种情况下,区别在于 AngularJS 使用其消化周期来检查绑定值的更改,以及其其他内部操作,而 Ember.js 总是知道其绑定值的更改,并且只使用其运行循环来同步它们。
很可能,每个框架都提供了一些相对于其他框架的优势,这取决于你正在构建的应用类型。在选择框架来构建应用时,始终了解这些内部机制非常重要,这样你就可以考虑它们可能对你特定用例的性能产生的影响。
使用 Rivets.js 的数据绑定
有时候,你可能希望使用更小、更模块化的库来构建一个单页应用(SPA),这些库为你提供特定的功能,而不是使用像 AngularJS 或 Ember.js 这样的完整前端框架。这可能是因为你正在构建一个不需要 MVW 架构模式复杂性的简单应用,或者你可能只是不想受框架约定的限制。
Rivets.js 是一个轻量级库,主要围绕数据绑定设计,尽管它提供了一些额外的功能,但它对你的应用架构几乎没有假设。在这方面,如果你只是想向模块化应用添加数据绑定层,它是一个不错的选择。
使用 Rivets.js 的一向数据绑定
Rivets.js 使用一个内部构造,称为 绑定器,来定义在绑定属性值发生变化时如何更新 DOM。该库提供了一系列内置绑定器,但也允许你定义自己的自定义绑定器。
在 Rivets.js 中,当绑定模型上的属性发生变化时,单向绑定器会更新 DOM。正如你所预期的单向场景一样,更新视图不会更新模型。
考虑以下对象:
var dog = {
name: 'Belladonna',
favoriteThing: 'Snacks!'
};
使用 Rivets.js 的绑定器语法,这些属性可以绑定到视图,如下所示:
<h1 rv-text="dog.name"></h1>
<p>
My favorite thing is:
<span rv-text="dog.favoriteThing"></span>
</p>
Rivets.js 使用rv-自定义属性前缀在 HTML 元素上定义不同类型绑定器的行为。rv-text 属性是一个内置绑定器,它将绑定值直接插入 DOM 中,就像任何 JavaScript 模板引擎可能做的那样。就此而言,还有一个使用单括号的表达式插值语法:
<h1>{ dog.name }</h1>
<p>My favorite thing is: { dog.favoriteThing }</p>
使用这两个示例中的任何一个,视图将渲染以下 HTML:
<h1>Belladonna</h1>
<p>My favorite thing is: Snacks!</p
更改绑定模型上的任何属性也会更新视图:
dog.name = 'Zoe'; // binder in View is updated
dog.favoriteThing = 'Barking!'; // binder in View is updated
视图中渲染的 HTML 将反映这些更改:
<h1>Zoe</h1>
<p>My favorite thing is: Barking!</p>
定义您自己的单向绑定器
如果 Rivets.js 中预定义的许多绑定器都无法满足您的需求,您始终可以定义自己的:
rivets.binders.size = function(el, val) {
el.style.fontSize = val;
};
在此示例中,我们创建了一个名为 size 的绑定器,可以根据模型值动态更改元素的 CSS 字体大小属性:
var dog = {
name: 'Belladonna',
favoriteThing: 'Snacks!',
size: '2rem'
};
然后,可以在视图中如下使用自定义绑定器:
<h1>{ dog.name }</h1>
<p>
My favorite thing is:
<span rv-size="dog.size">{ dog.favoriteThing }</span>
</p>
这将使视图以dog.favoriteThing值显示,其字体大小是正文文本的两倍,正如在绑定的狗模型中定义的那样。
使用 Rivets.js 进行双向数据绑定
当模型通过同步视图中的绑定值更新时,Rivets.js 中的双向绑定器的行为与单向绑定器相同,但它们也会在视图中的绑定值被用户更改时更新模型。这种行为可能由表单输入或其他类型的事件触发,例如点击按钮。
Rivets.js 包含一些预定义的双向绑定器。正如您所期望的,它为最常见的用例提供支持——一个文本输入:
<input type="text" rv-value="dog.name">
使用rv-value属性将模型属性绑定到输入元素将使用绑定模型的值预先填充该输入的值,并且当用户更改输入的值时,它也会更新模型值。
定义您自己的双向绑定器
在 Rivets.js 中定义自定义的双向绑定器需要采取更明确的方法,与单向绑定器相比。这是因为您必须定义如何绑定和解除绑定到元素,以及当绑定值更改时运行的数据绑定例程:
rivets.binders.validate = {
bind: function(el) {
adapter = this.config.adapters[this.key.interface];
model = this.model;
keypath = this.keypath;
this.callback = function() {
value = adapter.read(model, keypath);
adapter.publish(model, keypath, !value);
}
$(el).on('focus', this.callback);
},
unbind: function(el) {
$(el).off('blur', this.callback);
},
routine: function(el, value) {
$(el)value ? 'removeClass' : 'addClass';
}
};
使用此示例中显示的特殊属性定义,我们正在告诉 Rivets.js 绑定到onfocus输入,并在onblur输入时解除绑定。此外,我们定义了一个在值更改时运行的例程,当值为空时,向输入添加className为 invalid,当值被填充时移除。
使用原生 JavaScript 实现数据绑定
使用原生 JavaScript 编写自己的数据绑定实现可以相当容易地完成。如果您不需要为您的应用程序使用全面的框架或库,并且只想利用数据绑定设计模式的好处,使用原生 JavaScript 来实现它是合乎逻辑的选择。这将为您带来以下好处:
-
您将了解数据绑定实际上是如何在底层工作的
-
你将拥有一个更精简的前端,它不包括你可能甚至没有使用的额外库代码。
-
当你只想要数据绑定的额外好处时,你不会局限于由特定框架定义的架构。
对象获取器和设置器
JavaScript 中的Object类型具有原生的get和set属性,可以用作特定对象上任何属性名的getter和setter。getter是一个返回对象动态计算值的函数,而setter是一个用于将值传递给对象上给定属性的函数,就像你正在分配该值一样。当定义了设置器并传递了值时,该设置器的属性名本身实际上不能持有值;然而,它可以用来在完全不同的变量上设置值。
get和set属性默认为undefined,就像对象上的任何未分配属性一样,因此它们可以很容易地定义为任何用户定义对象的函数,而不会影响 JavaScript 的本地Object原型。当在直观的设计模式(如数据绑定)中适当使用时,这可以是一个强大的工具。
对象初始化器
可以使用对象初始化器为对象定义getter和setter,这通常是通过使用字面量表示法定义对象来完成的。例如,假设我们想在名为user的对象上为firstName属性创建一个getter和一个setter:
var firstName = 'Udis';
var user = {
get firstName() {
return firstName;
},
set firstName(val) {
firstName = val;
}
};
在这个例子中,我们可以通过简单地使用标准对象字面量语法来使用user.firstName属性通过get和set``firstName变量的值:
console.log(user.firstName); // Returns "Udis"
user.firstName = 'Jarmond';
console.log(user.firstName); // Returns "Jarmond"
console.log(firstName); // Returns "Jarmond"
在这个例子中,设置user.firstName = 'Jarmond'实际上并没有改变user.firstName属性的值;相反,它调用了属性定义的设置器方法,并设置了独立的firstName变量的值。
Object.defineProperty()方法
有时你可能想要修改现有的对象,以便在你的应用程序中为该对象提供数据绑定。为此,可以使用Object.defineProperty()方法向预定义对象添加特定属性的getter和setter:
var user = {};
Object.defineProperty(user, 'firstName', {
get: function() {
return firstName;
}
set: function(val) {
firstName = val;
},
configurable: true,
enumerable: true
});
此方法将你想要定义属性的的对象作为第一个参数,你正在定义的属性名作为第二个参数,以及一个descriptor对象作为第三个参数。descriptor对象允许你使用get和set键名来定义属性的getter和setter,并且它还允许一些其他键来进一步描述属性。
如果configurable键为true,则允许更改属性的配置以及删除属性本身。它默认为false。如果enumerable键为true,则允许在遍历父对象时使属性可见。它也默认为false。
使用Object.defineProperty()是一种更简洁的方式来声明对象的属性getter和setter,因为你可以明确配置该属性的行为,同时还能将属性添加到预定义的对象中。
设计 getter 和 setter 数据绑定模式
现在,我们可以通过创建一个 DOM 元素和我们已经定义了getter和setter的user对象之间的双向绑定来进一步扩展这个例子。让我们考虑一个文本输入元素,它在页面加载时预先填充了firstName值:
<input type="text" name="firstName" value="Jarmond">
现在,我们可以根据这个输入的值来定义我们的getter和setter,以便在模型和视图之间建立响应式绑定:
var firstName = document.querySelector('input[name="firstName"]');
var user = {};
Object.defineProperty(user, 'firstName', {
get: function() {
return firstName.value;
},
set: function(val) {
firstName.value = val;
},
configurable: true,
enumerable: true
});
如果你创建一个包含输入元素的页面并运行上面的代码,然后你可以使用浏览器中的开发者控制台设置user.firstName的值,并看到它自动更新 DOM 中输入元素的值:
user.firstName = 'Chappy';
此外,如果你在文本输入中更改了值,然后在开发者控制台中检查user.firstName属性的值,你会看到它反映了输入的更改值。通过这种简单的getter和setter的使用,你已经在架构上实现了使用原生 JavaScript 的双向数据绑定。
同步视图中的数据
为了进一步扩展这个例子,使得模型在视图中的表示始终保持同步,并且工作方式类似于 Rivets.js 数据绑定模式,我们只需简单地给我们的输入添加一个 oninput 事件回调来以期望的方式更新 DOM:
firstName.oninput = function() {
user.firstName = user.firstName;
};
现在,如果我们希望当这个输入框的值发生变化时,DOM 中其他表示这些数据的地方也能更新,我们只需要将所需的行为添加到该属性的 setter 中。让我们使用一个自定义的 HTML 属性data-bind来传达属性在 DOM 中的表示,而不仅仅是文本输入本身。
首先,创建一个包含以下 HTML 的静态文件:
<p>
<label>
First name:
<input type="text" name="firstName" value="Udis">
</label>
</p>
然后,在 HTML 下方,就在文档的</body>标签关闭之前,在<script>标签内添加以下 JavaScript 代码:
var firstName = document.querySelector('input[name="firstName"]');
var user = {};
Object.defineProperty(user, 'firstName', {
get: function() {
return firstName.value;
},
set: function(val) {
var list = document.querySelectorAll(
'[data-bind="firstName"]'
), i;
for (i = 0; i < list.length; i++) {
list[i].innerHTML = val;
}
firstName.value = val;
},
configurable: true,
enumerable: true
});
user.firstName = user.firstName;
firstName.oninput = function() {
user.firstName = user.firstName;
};
现在,在浏览器中加载页面,并观察<strong data-bind="firstName">元素将根据输入的值填充为Udis。这是通过调用user.firstName属性的 setter 并把它赋给相应的getter作为user.firstName = user.firstName来实现的。这看起来可能有些冗余,但实际上,这里正在执行的是 setter 方法中定义的代码,并使用从getter获取的给定值。setter 会在页面上查找任何设置了data-bind属性为firstName的元素,并使用输入中的firstName值更新该元素的内容,该值在模型中表示为user.firstName。
接下来,将光标放在文本输入中并更改值。注意,随着你输入,<strong> 元素中代表的名字会改变,每个表示都与模型保持同步。最后,使用你的开发者控制台更新模型的值:
user.firstName = 'Peebo';
注意到文本输入和 <strong> 元素中的表示都是自动更新并同步的。你已经成功使用少量原生 JavaScript 创建了一个双向数据绑定和视图同步设计模式。
将设计模式抽象为可重用方法
你可以通过创建一个方法来进一步抽象你的数据绑定设计模式,该方法可以用于将此行为应用于任何预定义对象的属性:
function dataBind(obj, prop) {
var input = document.querySelector('[name="' + prop + '"]');
input.value = obj[prop] || input.value;
Object.defineProperty(obj, prop, {
get: function() {
return input.value;
},
set: function(val) {
var list = document.querySelectorAll(
'[data-bind="' + prop + '"]'
), i;
for (i = 0; i < list.length; i++) {
list[i].innerHTML = val;
}
input.value = val;
},
configurable: true,
enumerable: true
});
obj[prop] = obj[prop];
input.oninput = function() {
obj[prop] = obj[prop];
};
}
在这里,我们创建了一个名为 dataBind 的方法,它接受一个对象和一个属性作为参数。属性名称用作 DOM 中要绑定到模型的元素的标识符:
// For the input
var input = document.querySelector('[name="' + prop + '"]');
// For other elements
var list = document.querySelectorAll('[data-bind="' + prop + '"]');
接下来,只需定义一个对象,并在其上调用 dataBind 方法,此外还需要传入要绑定到 DOM 的属性名称。此方法还允许你在模型中设置属性的初始值,如果设置了,它将在绑定时反映在视图上。如果没有设置,它将显示输入本身设置的值(如果有的话):
var user = {};
user.firstName = 'Peebo';
dataBind(user, 'firstName');
如果你修改你刚刚创建的页面中的代码以使用抽象的 dataBind 方法,你会发现它的工作方式与之前完全相同,但现在它可以被重用来绑定 DOM 中多个相应元素与多个对象属性。这种模式当然可以进一步抽象化,并与建模模式结合,在其中它可以作为一个强大的数据绑定层在 JavaScript SPA 中使用。开源库 inbound.js 是这种模式提升到更高层次的优秀例子。你可以在 inboundjs.com 上了解更多信息。
考虑 DOM 变化
之前例子中,在视图同步方面的一个缺点是,只有用户输入会触发从视图设置模型。如果你想要全面的、双向的数据绑定,其中视图中绑定值的任何更改都会同步到相应的模型属性,那么你必须能够通过任何方式观察 DOM 变化或更改。
让我们再次看看之前的例子:
var user = {};
user.firstName = 'Peebo';
dataBind(user, 'firstName');
现在,如果你编辑文本输入的值,模型上的 firstName 属性将更新,并且 <strong data-bind="firstName"> 元素的 内容也将更新:
<input type="text" name="firstName" value="Jarmond">
console.log(user.firstName); // returns "Jarmond"
现在让我们改用开发者控制台并更改 <strong data-bind="firstName"> 元素的 innerHTML:
document.querySelector('strong[data-bind="firstName"]')
.innerHTML = 'Udis';
完成这些操作后,你会注意到输入的值没有更新,模型数据也没有更新:
console.log(user.firstName); // returns "Jarmond"
你通过控制台创建的 DOM 变化现在破坏了你的双向数据绑定和视图同步。幸运的是,有一个原生的 JavaScript 构造函数可以用来避免这个陷阱。
MutationObserver
MutationObserver构造函数提供了观察 DOM 变更的能力,无论这些变更是从哪里触发的。在大多数情况下,用户输入可能足以触发模型更新,但如果你正在构建一个可能由其他来源(如通过 Websockets 推送的数据)触发的 DOM 变更的应用程序,你可能希望将这些变更同步回你的模型。
MutationObserver 通过提供一个特殊类型的监听器,在 DOM 变更时触发回调,其工作方式与原生的 addEventListener 类似。这种事件类型是独特的,因为它通常不会由直接的用户交互触发,除非开发者控制台被用来操作 DOM。相反,通常是应用程序代码在更新 DOM,而这个事件是由这些变更直接触发的。
一个简单的MutationObserver可以如下实例化:
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
console.log(mutation);
});
});
接下来,必须定义一个配置来传递给新观察者对象的observe方法:
var config = {
attributes: true,
childList: true,
characterData: true
};
这个对象被称为MutationObserverInit。它定义了特殊属性,这些属性被MutationObserver实现用来指定元素应该被观察得多紧密。至少需要将attributes、childList或characterData中的一个设置为 true,否则将抛出错误:
-
attributes:告诉观察者是否应该观察元素属性的变更。 -
childList:告诉观察者是否应该观察元素子节点的添加和删除。 -
characterData:告诉观察者是否应该观察元素数据的变更。
此外,还可以定义四个额外的、但可选的MutationObserverInit属性:
-
subtree:如果为 true,告诉观察者除了元素本身外,还要观察元素后代的变更。 -
attributeOldValue:如果与属性设置为 true 一起为 true,告诉观察者在变更之前保存元素属性的老值。 -
characterDataOldValue:如果与characterData设置为 true 一起为 true,告诉观察者在变更之前保存元素的老数据值。 -
attributeFilter:一个数组,指定了不应与设置为 true 的属性一起观察的属性名称。
配置定义后,现在可以在一个 DOM 元素上调用观察者:
var elem = document.querySelector('[data-bind="firstName"]');
observer.observe(elem, config);
在此代码到位后,任何对具有属性data-bind="firstName"的元素的变更都将触发在观察者对象的MutationObserver构造函数实例化中定义的回调,并且它将记录传递给迭代器的变更对象。
使用 MutationObserver 扩展 dataBind
现在让我们通过使用MutationObserver构造函数来触发具有 data-bind 属性的元素变更时的回调,进一步扩展我们的dataBind方法:
function dataBind(obj, prop) {
var input = document.querySelector('[name="' + prop + '"]');
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
var val = mutation.target.innerHTML;
if (obj[prop] !== val) {
console.log(
'Inequality detected: "' +
obj[prop] + '" !== "' + val + '"'
);
obj[prop] = mutation.target.innerHTML;
}
});
});
var config = {
attributes: true,
childList: true,
characterData: true
};
var list = document.querySelectorAll(
'[data-bind="' + prop + '"]'
), i;
for (i = 0; i < list.length; i++) {
observer.observe(list[i], config);
}
input.value = obj[prop] || input.value;
Object.defineProperty(obj, prop, {
get: function() {
return input.value;
},
set: function(val) {
var list = document.querySelectorAll(
'[data-bind="' + prop + '"]'
), i;
for (i = 0; i < list.length; i++) {
list[i].innerHTML = val;
}
input.value = val;
},
configurable: true,
enumerable: true
});
obj[prop] = obj[prop];
input.oninput = function() {
obj[prop] = obj[prop];
};
}
MutationObserver构造函数仅接受一个回调函数作为其唯一参数。此回调函数传递一个突变对象,可以遍历以定义每个突变的回调:
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
var val = mutation.target.innerHTML;
if (obj[prop] !== val) {
console.log(
'Inequality detected: "' +
obj[prop] + '" !== "' + val + '"'
);
obj[prop] = mutation.target.innerHTML;
}
});
});
注意,在MutationObserver实例化回调中,我们在设置模型属性之前,对绑定模型属性与mutation.target.innerHTML进行不等式比较,后者是被观察的 DOM 元素的内容。这是很重要的,因为它确保我们只在直接触发此特定 DOM 节点的 DOM 突变时设置绑定的模型属性,而不是作为设置器的结果。如果我们不执行此检查,所有设置器都会触发回调,这会再次调用设置器,从而导致无限递归。这当然是不希望的。
使用dataBind方法的新版本,在浏览器中再次测试 HTML 页面并更新输入值:
<input type="text" name="firstName" value="Chappy">
console.log(user.firstName); // returns "Chappy"
接下来,使用开发者控制台更改绑定的模型属性,你将看到它在 DOM 中的输入和<strong data-bind="firstName">元素中更新,正如预期的那样:
user.firstName = 'Peebo';
最后,使用开发者控制台更改<strong data-bind="firstName">元素的innerHTML并触发一个突变事件:
document.querySelector('strong[data-bind="firstName"]')
.innerHTML = 'Udis';
这次,你将看到输入元素的值也会更新。这是因为突变事件被观察对象触发并检测到,然后触发了回调函数。在回调函数内部,进行了obj[prop] !== val的比较,并发现为真,因此对新值调用了设置器,随后更新了输入值和从user.firstName属性返回的值:
console.log(user.firstName); // returns "Udis"
你现在已经实现了双向数据绑定和全面视图同步,使用了原生的getters和setters以及MutationObserver构造函数。请记住,这里给出的示例是实验性的,尚未在实际应用中使用。在您的应用中采用这些技术时应谨慎行事,并且测试应该是首要的。
为什么使用数据绑定?
数据绑定提供了一层抽象,可以消除大量额外应用连接、自定义事件发布和订阅以及模型对视图的评估的需求。当不使用框架或某种类型的数据绑定时,这些通常由针对应用本身特定的自定义应用代码处理。如果没有仔细规划和使用定义的架构模式,这可能会导致大量辅助代码,进而导致代码库不可扩展、扩展性不好,并且对新开发者来说难以接受和学习。
如果你认为数据绑定是你想要包含在应用中的组件,那么考虑你的选择,其中一些我们已经在这里列出,并据此选择。你可能需要使用像 AngularJS 这样的完整 JavaScript 框架来构建你的应用,或者你可能只想结合你自己的定制架构使用数据绑定的附加抽象层。同时,考虑你选择带来的性能影响,以及你是否需要双向数据绑定,它更占用内存,或者只需要单向数据绑定,这有助于保持你的应用性能更优。
单向数据绑定的用例
在现代单页应用中最常见的绑定形式是单向数据绑定。在最基本的情况下,单向数据绑定只需要在渲染时将动态模型值绑定到模板中相应的表达式。如果模型在模板已经渲染后发生变化,将数据同步到视图是某些框架(如 AngularJS、Ember.js 和 Rivets.js)的附加好处。
如果你正在构建一个需要向用户显示实时、频繁变化的数据的应用,并且这些数据不需要被用户操作,这是一个使用带有视图同步的单向数据绑定的好例子。一个更具体的例子是跟踪股票报价并实时显示价格变化的应用。在这种情况下,模型数据完全是供用户查看的,但由于股票报价不能被用户更改,因此从视图到模型不需要任何更改。在这种情况下,双向数据绑定监听器将没有用,只会产生额外的和不必要的工作量。
双向数据绑定的用例
在单页应用中,双向数据绑定不像单向数据绑定那样常用,但它确实有其位置。在决定将双向数据绑定行为附加到 DOM 并使用额外内存之前,完全理解你应用的需求是很重要的。
在线聊天或实时消息应用是双向数据绑定最常见的例子之一。无论应用提供的是一对一消息还是多用户消息,双向数据绑定都可以用于同步每个用户的视图,实现双向同步。当用户查看应用时,其他用户的新消息会被添加到模型中,并在视图中显示。同样,查看应用的用户在视图中输入新消息,这些消息会被添加到模型中,下载到服务器,然后显示给其他用户在自己的视图中。
摘要
你现在已经学会了什么是数据绑定,单向数据绑定和双向数据绑定的区别,数据绑定在现代 JavaScript 框架和库中的实现方式,以及数据绑定在现实世界中的某些用例。你还了解了单向和双向数据绑定实现之间的架构差异,以及如何使用现代原生 JavaScript 中的获取器和设置器来编写自己的数据绑定实现。此外,你还学习了 MutationObserver 构造函数及其如何根据突变事件在 DOM 中触发行为。
接下来,我们将把迄今为止关于不同架构组件所学的所有知识,包括 MongoDB、Express、AngularJS 和 Node.js,结合起来学习如何开始将它们全部整合起来,以充分利用 MEAN 全栈。
利用 MEAN 堆栈
MEAN 堆栈是 MongoDB、Express、AngularJS 和 Node.js 的缩写。它代表仅使用 JavaScript 进行全栈开发的实践。自然,你需要一些 HTML 和 CSS 来将内容渲染到浏览器中,并使其看起来更美观。
MongoDB 是一个基于文档的 NoSQL 数据库,它以可以像普通 JavaScript 对象一样处理的方式存储数据。由于数据和数据库方法本质上都是 JavaScript,MongoDB 与基于 JavaScript 的应用程序配合得很好。此时,如果你正确地输入了代码,控制台将不会有除了新行之外的输出。打开你最喜欢的浏览器
Express 是一个用 JavaScript 编写的 Web 应用程序框架,它在 Node.js 上运行得很好。它与 Sinatra 等其他框架类似,但更不显眼,也没有太多偏见。Express 本质上是一种路由和中间件,用于处理 Web 请求和响应。
AngularJS 是一个主要用于构建单页 Web 应用程序的前端 JavaScript 框架。它是由 Google 精心策划的一个强大且具有指导性的框架,已经成为更受欢迎的 JavaScript MV*工具包之一。
在本章中,你将在开始构建你自己的单页应用程序框架的同时,探索以下 MEAN 堆栈组件:
-
使用 REPL 从命令行运行 Node.js 代码
-
编写和运行 Node.js 脚本
-
在 Mongo shell 中安装 MongoDB 以及基本的 CRUD 操作
-
通过标准方法和 Express 生成器安装 Express
-
Express 路由、中间件和视图渲染
-
使用 Angular 构建前端应用程序的基本组件
第七章:Node.js 环境
Node.js 是一个用于执行 JavaScript 的运行时环境。使用它,你可以构建强大的软件,例如完整的后端 Web 服务器。在第一章,使用 NPM、Bower 和 Grunt 进行组织中,你开始使用一些基于 Node.js 的工具,如 Grunt、NPM 和 Node 包管理器。
Node.js 功能强大,速度极快。它基于 Chrome 浏览器中使用的 V8 JavaScript 引擎,并针对速度进行了优化。它使用非阻塞 I/O,允许它同时处理多个请求。
本章假设你已经安装了 Node 运行时。如果没有,请访问nodejs.org,并按照你操作系统的安装说明进行安装。本书使用了 Node 版本 4.3.2。
运行 REPL
Node.js 提供了一种从命令行运行 JavaScript 代码的方法,称为读取-评估-打印循环或 REPL。你只需在控制台中命令行输入node即可启动 REPL。这是一个开始探索 Node.js 一些可能性的好方法,如下面的命令所示:
$ node
> var sum = 1 + 2;
undefined
> sum
3
console.log(sum);
3
undefined
注意
JavaScript 中的变量赋值返回 undefined。
在这里,可能看起来有两个返回值,第一个是3,第二个是undefined。在这种情况下,console.log()是一个用于将内容输出到屏幕上的函数,但实际上它返回undefined。这在编写 Node.js 代码时很有用,你希望将内容输出到屏幕上,类似于其他编程语言中的打印语句。
要退出 REPL,按Ctrl + C两次。
编写 hello Node.js 脚本
单独使用 REPL 本身不会非常有用。Node.js 允许开发者通过将程序编写出来并保存为具有.js文件扩展名的文本文件来创建程序。
这其中一个优点是,你可以使用任何文本编辑器和几乎任何 IDE 来编写 Node.js 程序。
为了展示 Node.js 的强大功能,让我们先构建一个简单的 Web 服务器。它不会做很多事情,除了处理 HTTP 请求,但这是一个很好的开始。
让我们创建一个新的文件,命名为hello.js,并用你喜欢的文本编辑器或基于文本的 IDE 打开它。然后,输入以下代码行:
var http = require('http');
var serverResponse = function(req, res){
res.end("Hello Node");
}
var server = http.createServer(serverResponse);
server.listen(3000);
保存文件,并在控制台中导航到存储该文件的目录。然后,输入以下命令:
$ node hello
在这一点上,如果你正确地输入了代码,控制台将不会有除了新行之外的输出。打开你喜欢的浏览器,并在地址栏中输入localhost:3000。
你应该在浏览器的主窗口中看到Hello Node。就这样,你已经编写了一个 Web 服务器。
你可以通过在控制台中输入 Ctrl + C 来停止服务器。
在这么一小段代码中有很多事情在进行,所以让我们来看看程序正在做什么:
-
在
varhttp=require('http');代码的第一行中,类似于某些编程语言中的导入语句,Node.js 使用require来导入代码模块。Node.js 自带了许多内置代码模块。HTTP 模块是一个内置模块,正如你可能想象的那样,它提供了 HTTP 服务。可以使用包含模块名称的字符串来导入内置模块。通常,它们会被分配给一个变量来使用。非内置模块则使用包含模块完整路径的字符串来导入。 -
在接下来的两行中,
var serverResponse = function(req, res)和res.end("Hello Node"),这里的serverResponse函数是一个回调函数,我们将将其传递给我们所创建的 Web 服务器。当我们进入Express.js时,我们将更详细地介绍请求和响应对象,但重要的是要意识到我们正在设置req来处理 HTTP 请求对象,以及res来处理响应。响应对象上的end函数发送传递给它的任何文本,并告诉服务器响应已完成。 -
在下一行代码中,
var server = http.createServer(serverResponse);,我们实际上是通过在之前导入的 HTTP 对象上调用createServer函数来创建一个 Web 服务器。我们将serverResponse函数传递给createServer函数,它成为服务器的回调函数。我们将刚刚创建的服务器引用存储在名为 server 的变量中。 -
在代码的最后一行,
server.listen(3000);,我们将对刚刚创建的服务器对象调用listen函数。我们将传递一个整数作为端口号,表示我们希望它监听的端口号。这段代码实际上启动了服务器,并使其在端口3000上监听请求。
使用 NPM 设置 Node.js 项目
随着 Node.js 项目的变大,拥有一个能够正确管理它的工具变得非常重要。使用 Node Package Manager 和 package.json 文件,可以更容易地处理诸如一致的依赖管理、版本管理和环境管理等问题。
幸运的是,创建 Node.js 的聪明人创造了一种使用 NPM 来实现这一目标的方法。在命令行中运行 npm init 将会设置一个 Node.js 项目并构建一个 package.json 文件,该文件用于管理你的 Node.js 项目。让我们用你的 hello Node 项目来试一试。请注意,一些提示是可选的,可以留空,如下面的命令所示:
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help json` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
name: (SPA-js) hello-node
version: (1.0.0)
description: my new program
entry point: (hello.js)
test command:
git repository:
keywords:
author: Your Name
license: (ISC)
About to write to /Users/your-home/helloNode/package.json:
{
"name": "hello-node",
"version": "1.0.0",
"description": "my new program",
"main": "hello.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"author": "John Moore",
"license": "ISC"
}
Is this ok? (yes)
在此按 Enter 键将会在当前目录中创建 package.json 文件。在您的 IDE 或文本编辑器中打开它并查看。当你在里面时,让我们对脚本部分进行以下小的修改:
"scripts": {
"test": "echo "Error: no test specified" && exit 1",
"start": "node hello"
}
package.json 的脚本部分允许你使用 NPM 运行代码。在这种情况下,输入 npm start 将运行 node hello 命令并启动你的 Web 服务器。目前这并不是一个超级高效的快捷方式,但你可以通过这种方式创建许多高效且有用的命令别名。
NPM 做的非常重要的一件事是管理依赖。在下一节中,关于 Express,你将看到如何将 NPM 模块及其版本存储在 package.json 文件中。当作为团队的一部分工作时,这非常重要。通过复制项目的 package.json 文件,开发者可以通过运行 NPM 安装来重新创建项目环境。
开始使用 Express
Express 自称为 Node.js 的快速、无偏见、极简主义 Web 框架。Express 是一个非常强大且灵活的框架,它运行在 Node.js 之上,但仍然允许你访问 Node 的所有功能。在核心上,Express 作为一组路由和中间件功能运行。
我们将在后面的章节中详细讨论路由和中间件。基本上,路由处理 Web 请求。中间件由可以访问请求和响应对象并调用堆栈中下一个中间件的函数组成。
如果使用 Node.js 随意搭建一个 Web 服务器如此简单,为什么我们还需要像 Express 这样的东西呢?
答案是,您不需要。您可以完全独立地,仅通过编写自己的 Node.js 代码来构建一个功能齐全的 Web 应用程序。Express 已经为您做了很多艰苦的工作和重活。由于 Express 中间件易于添加,添加诸如安全、身份验证和路由等功能相对简单。而且,当您有一个令人兴奋的新 Web 应用程序要构建时,谁愿意从头开始构建这些功能呢?
安装 Express
当然,您会使用 NPM 来安装 Express。这里有两种方法可以做到这一点。
标准方法只是使用 NPM 拉取 Express 项目并将其添加到 package.json 中的引用。这只是将模块添加到 Node.js 项目中。您将构建一个应用程序脚本,在 Express 中调用它,并在那个 WAR 文件中利用它。
第二种方法是安装 Express 生成器,并使用它来生成一个入门级 Web 应用程序。这种方法简单易行,但它会为您构建整个应用程序的结构,包括文件夹结构。有些人可能更喜欢自己完成所有这些工作,以确保设置方式精确无误。
我们将尝试两种方法,并使用 Express 生成器来构建您将在本书剩余部分构建的单页应用程序的框架。
标准方法
再次强调,标准方法只是将模块拉下来并添加到您的项目中。在您的控制台中,在刚刚创建的 package.json 文件所在的目录中,输入以下命令:
$ npm install express --save
小贴士
在 Mac 上,您可能需要在 npm install 前面输入 sudo。如果您在 Mac 上,我建议您在每次使用 NPM 安装任何东西时都使用 sudo。
-save 部分告诉 npm 将 Express 添加到您的 package.json 文件中的依赖项。现在,请打开您的 package.json 文件并查看依赖项部分。
如果您正在使用 git 或其他源代码控制系统与其他开发者共享代码库,您通常不会在远程仓库中存储依赖项。相反,package.json 文件将包含对所需模块及其版本的引用。
另一位开发者可以拉取您的代码,运行 npm install,并安装所有依赖项。
如果您查看 package.json 文件所在的文件夹,您将看到一个名为 node_modules 的新文件夹。这是您使用 npm 安装的依赖项存储的地方。不要移动这个文件夹或更改它,因为 require 函数会在这里查找模块。通常,这个文件夹会被添加到 .gitignore 文件中,以确保其文件不会存储在远程 git 仓库中。
表达生成器
另一种设置 Express 应用程序的方法是,Express 创建了一个生成器,这是一个用于快速搭建 Express 应用程序框架的工具。它假设了一些在 Express 应用程序中常用的约定,并配置了主应用程序、package.json以及甚至目录结构。
生成器是通过以下命令全局安装的,而不是在特定项目中使用:
$ npm install express-generator -g
-g告诉 NPM 全局安装模块及其依赖项。它们不会安装在你项目的npm_modules文件夹中,而是在系统上的全局模块文件夹中。
设置你的 Express 应用程序
现在我们已经全局安装了 express 生成器,让我们使用它来开始构建本书其余部分将要构建的应用程序。选择一个你希望这个项目存在的文件夹。Express 生成器将在这个文件夹内创建一个新的文件夹,所以如果你是一个包含其他内容的家庭或项目文件夹,那也是可以的。
使用以下命令在你的控制台中导航到这个文件夹:
$ express -e giftapp
create : giftapp
create : giftapp/package.json
create : giftapp/app.js
create : giftapp/public
create : giftapp/public/javascripts
create : giftapp/public/images
create : giftapp/routes
create : giftapp/routes/index.js
create : giftapp/routes/users.js
create : giftapp/public/stylesheets
create : giftapp/public/stylesheets/style.css
create : giftapp/views
create : giftapp/views/index.ejs
create : giftapp/views/error.ejs
create : giftapp/bin
create : giftapp/bin/www
install dependencies:
$ cd giftapp && npm install
run the app:
$ DEBUG=giftapp:* npm start
如你所见,express 生成器创建了一些文件,包括app.js,这是你的主应用程序文件。
我们在 express 命令后输入的-e修饰符告诉生成器我们想要使用ejs(嵌入式 JavaScript)前端模板。Express 支持多种模板语言,包括 Handlebars 和 Jade。如果你没有添加任何修饰符,Express 生成器将假设你想要使用 Jade。对于这个项目,我们将使用ejs,它本质上是一种嵌入 JavaScript 代码的 HTML。
生成器的最终输出会告诉你下一步你需要执行的操作,以便真正搭建你的应用程序。导航到你的新giftapp目录并运行npm install(如果你在 Mac 或 Linux 机器上,请记住使用sudo)。此时,npm install命令可能需要几分钟,因为它正在安装多个依赖项。
下一个命令将以DEBUG模式启动你的新 Express 应用程序——你将看到所有请求都记录在控制台。在浏览器中导航到localhost:3000将显示一个欢迎使用 Express页面。
恭喜你,这个页面正在由你自己的强大 Express Web 应用程序提供服务。目前它还没有做什么,但已经为你搭建了很多基础。
探索主脚本
打开 giftapp 的新package.json文件。注意以下 scripts 对象:
"scripts": {
"start": "node ./bin/www"
}
这意味着当运行npm start时,实际调用的脚本位于./bin/www。所以,让我们打开你 bin 目录下的www文件并查看。
你会看到这个文件正在引入许多东西,包括app.js;这是你的主应用程序文件:
var app = require('../app');
下一段代码设置了应用程序的端口号。它检查是否有设置包含所需端口号的环境变量。如果没有,它将其设置为3000。在生产环境中部署应用程序时,通常你会使用端口 80用于 HTTP 或端口 443用于 HTTPS:
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
下一个部分创建服务器并开始监听正确的端口:
var server = http.createServer(app);
...
server.listen(port);
现在,我们将跳过这个文件的其余部分,看看 app.js 文件中有什么。打开它看看。
查看主应用
App.js 是主应用文件,它加载路由和中间件并配置应用。这个文件中有几个重要的部分。一般来说,在 Express 应用中,加载和利用的顺序以及在这个文件中中间件被调用的顺序是很重要的。让我们来看看。
加载依赖
当你打开 app.js 时,你首先会遇到对 require 函数的多次调用,如下所示:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var routes = require('./routes/index');
var users = require('./routes/users');
这个系统被称为 CommonJS。第一组模块包括依赖项,如 Express 和 cookies 解析器。路由和用户模块是由 Express 生成器为路由创建的。你创建的路由也将需要在主文件中导入。
配置应用
接下来,通过调用 express 函数声明了一个 app 变量,并设置了一些配置变量,如下所示:
var app = express();
...
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
第一项配置,视图,告诉 Express 在哪里查找视图模板。这些模板通常在向网络应用发送请求时渲染成 HTML。
第二项配置设置了视图引擎。正如我们之前讨论的,我们将使用 ejs 或嵌入式 JavaScript 模板。
应用级别中间件
在这个文件中接下来我们看到的是对 app 对象的 use 函数的一堆调用,如下所示:
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
在 Express 中,对应用对象 use 函数的调用是应用级别的中间件。发送到应用的请求将执行与 app.use 中设置的路径匹配的每个函数。如果没有设置路径,中间件函数默认为根路径 /,并将对每个请求进行调用。
例如,app.use(cookieParser()); 表示对于发送到应用的每个请求,都会调用 cookieParser 函数,因为它默认为根路径。然而,app.use('/users',users); 只会在请求以 /users 开头时应用。
中间件的调用顺序与声明顺序一致。这将在我们添加身份验证到我们的应用或想要处理 POST 数据时变得非常清楚。如果你在尝试管理需要身份验证的请求之前不解析 cookies,它将不起作用。
我们的第一条 Express 路由
Express 使用一个称为路由的机制,通过其 Router 对象来处理 HTTP 请求,例如来自网络浏览器的请求。在后面的章节中,我们将更深入地探讨 Express 路由。检查 Express 生成器为我们创建的第一个路由是很有必要的。打开你的 routes/index.js 文件,如下面的代码块所示:
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});
module.exports = router;
要创建一组新的路由,我们必须通过调用 Express Router 函数创建一个新的路由器对象。你会看到我们首先需要 Express,然后做完全一样的事情。
下一个表达式是对路由对象 get 函数的调用。在 Express 中,这是路由级别的中间件。这个函数设置中间件,形式为包含的匿名函数,它响应 HTTP GET请求,例如在浏览器地址栏中输入 URL 或点击链接。
函数的第一个参数是要匹配的路径。当路径匹配时,在这种情况下是根路径,函数被调用。这里的匿名函数接收请求对象、响应对象和下一个对象。
函数调用响应对象的 render 函数。这个函数在 views 目录中查找名为 index 的模板并渲染它,传递第二个参数中的对象。模板可以访问该对象的所有属性,在这种情况下,只是标题,并将它们渲染到响应中。
最后,我们将看到module.exports=router;表达式。这允许 Node.js 模块系统使用 required 函数加载此代码,并将路由对象分配给一个变量。在我们的app.js文件顶部附近,你会看到var routes = require('./routes/index');。
渲染第一个视图
当你启动 Express 服务器,导航到localhost:3000,并看到默认的 Express 页面时,发生了什么?
请求进入 Web 应用程序,根据请求类型GET和路径/被路由到 index。然后中间件调用响应对象的 render 函数,并告诉它获取 index 模板,传递一个具有名为 title 的属性的对象。
打开你的views/index.ejs文件,查看以下模板代码:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
</body>
</html>
如果你之前使用过其他动态模板语言,你可能已经理解这很正常,这是包含一些动态元素的普通 HTML。那些被<%...%>包含的动态元素由服务器处理。这些标签不会发送到浏览器,只是干净的 HTML。
在这种情况下,三个标签是相同的,都渲染了传递给响应对象 render 方法的调用中的对象的标题属性。作为一个快速实验,改变传递的标题值。
探索 MongoDB
记住,MEAN 栈中的M是一个开源的文档型数据库。因为它不使用 SQL 且不是关系型数据库,所以被认为是一个 NoSQL 数据库。它很好地与基于 JavaScript 的工具集成,因为它不是存储在表中,而是存储在文档中,这些文档可以被我们的 Node.js 应用程序当作 JSON 处理。
注意
技术上,MongoDB 以 BSON 格式存储数据,BSON 是 Binary JSON 的缩写。
设置 MongoDB
在你的系统上运行 MongoDB 的第一步是安装。访问www.mongodb.org/downloads#production,你将找到 Windows、Mac、Linux 或 Solaris 的最新安装下载。那里也有链接到使用 Homebrew(Mac)和 yum(Linux)等工具安装 MongoDB 的说明。
每个操作系统的最新安装说明可以在 docs.mongodb.org/manual/ 找到。操作系统之间存在差异,安装说明可能会随着新版本的发布而更改。我建议遵循官方的安装说明。
安装完成后,你可以在控制台中输入 mongod 来启动 MongoDB 服务。
当 MongoDB 守护进程运行时,你将无法在该控制台中输入任何其他命令。默认情况下,此进程将在 端口 27017 上运行并绑定到 IP 地址 127.0.0.1。这两个都可以通过启动时的命令标志或 .conf 文件进行更改。就我们的目的而言,默认设置就足够了。
使用 MongoDB 命令行界面
我们将开始使用随 MongoDB 安装一起提供的命令行界面来处理 MongoDB。由于你必须运行 MongoDB 守护进程才能与数据库一起工作,因此你需要打开一个新的控制台。
在我们正在构建的应用程序中,我们将依赖 Node.js 插件来处理我们的数据库操作。然而,了解 MongoDB 的工作原理以及它与 SQL 数据库的不同之处是有益的。
要做到这一点,最好的方式是亲自动手,从 MongoDB 命令行界面运行一些基本操作。
选择数据库
让我们选择我们想要工作的数据库。打开你的命令行并输入以下命令:
$mongo
> use test
switched to db test
>
从命令行运行 mongo 而不是 mongod 将启动 MongoDB 命令行界面,这允许直接向运行的 MongoDB 守护进程输入命令。
use 命令用于选择我们当前正在使用的数据库。但是,等等;我们从未创建过测试数据库。这是正确的,我们确实还没有创建。我们可以使用 showdbs 命令列出我们计算机上 MongoDB 所知的所有数据库:
> show dbs
local 0.078GB
如果你已经完成了所有前面的示例,那么你已经有了一个名为 test 的本地数据库。本地数据库存储启动日志和复制环境中的副本信息。我们可以使用本地数据库并添加我们自己的数据,但这并不是一个好主意。让我们创建一个我们自己的数据库。
插入文档
记住,MongoDB 中的记录被称为文档。它们与传统关系型数据库中的行有松散的联系,但更加灵活。
如果我们现在插入一个文档,新数据库将使用以下命令创建:
> db.cat.insert({name:"Tom",color:"grey"})
WriteResult({ "nInserted" : 1 })
> show dbs
local 0.078GB
test 0.078GB
db.cat.insert() 命令将插入参数中的文档到名为 cat 的集合中。
在 MongoDB 中,集合是一组文档。这类似于关系型数据库中的表,它是一组记录。与关系型数据库不同,集合中的文档不必都是同一类型或具有相同的数据集。
我们可能会收到通知,我们插入的文档看起来像是一个普通的 JavaScript 对象。本质上,它就是这样。这是在 MEAN 栈中将 MongoDB 作为一部分使用时的一个优点——从前端到数据库都是 JavaScript。
当我们输入 showdbs 时,我们将看到测试数据库现在已经创建。我们还通过向其中插入文档在测试数据库中创建了一个猫集合。当使用 shell 工作时,这需要小心。很容易因为打字错误而意外创建不需要的数据库和集合。
查找文档
现在我们已经有一个数据库,数据库中的一个集合,并且已经将一个文档插入到该集合中,我们需要能够检索该文档。最基本的方法是使用 MongoDB 的查找方法。
让我们看看我们的文档:
> db.cat.find()
{ "_id" : ObjectId("565c010dd9d61e2dc614181f"), "name" : "Tom", "color" : "grey" }
db.collection.find() 方法是 MongoDB 读取数据的基本方法。它是 MongoDB CRUD 操作中的 R——创建、读取、更新、删除。
如您所见,MongoDB 已经为我们添加了一个 _id 字段,如下所示:
> db.cat.insert({name:"Bob",color:"orange"})
WriteResult({ "nInserted" : 1 })
> db.cat.find({},{_id:0,name:1})
{ "name" : "Tom" }
{ "name" : "Bob" }
我们在这里插入了一只名叫 Bob 的新橘猫,这次我们使用的是一种稍微不同的查找方法。它接受两个参数。
第一个参数是查询条件。这告诉 MongoDB 选择哪些文档。在这种情况下,我们使用了一个空对象,因此选择了所有文档。
第二个参数是投影,它限制了 MongoDB 返回的数据量。我们告诉 MongoDB 我们想要名称字段,但抑制默认会返回的 _id 字段。
在下一章中,我们将探讨如何限制返回的文档数量并对其进行排序。
看看以下命令:
> db.cat.find({color:"orange"},{_id:0,name:1})
{ "name" : "Bob" }
我们已经调整了查询,使其包含查询条件,仅选择颜色为橘色的猫。Bob 是唯一的橘猫,因此这是唯一返回的文档。同样,我们将抑制 _id 并告诉 MongoDB 我们只想看到名称字段。
更新文档
如果我们想更改数据库中的记录怎么办?有几种方法可以做到这一点,但我们将从最简单的一种开始。MongoDB 提供了一个名为 update 的方法来修改数据库中的文档,如下所示:
> db.cat.update({name:"Bob"},{$set:{color:"purple"}})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
> db.cat.find({color:"orange"},{_id:0,name:1})
> db.cat.find({color:"purple"},{_id:0,name:1})
{ "name" : "Bob" }
与查找方法类似,更新方法的第一个参数告诉 MongoDB 选择哪些文档。然而,默认情况下,MongoDB 将一次只更新一个文档。要更新多个文档,我们将添加第三个参数,一个修饰符对象,{multi:true}。
我们选择了名称字段等于 Bob(只有一个)的文档。然后,我们将使用 $set 操作符将 Bob 的颜色改为紫色。
我们可以通过使用以下命令查询 orange 猫来验证这一点:
> db.cat.find({color:"orange"},{_id:0,name:1})
> db.cat.find({color:"purple"},{_id:0,name:1})
{ "name" : "Bob" }
没有文档返回。现在对紫色 cat 的查询返回了名为 Bob 的 cat 文档。
删除文档
没有一组 CRUD 操作是完整的,没有 D 或删除操作。MongoDB 提供了 remove 方法来实现这一点,如下所示:
> db.cat.remove({color:"purple"})
WriteResult({ "nRemoved" : 1 })
> db.cat.find({},{_id:0,name:1})
{ "name" : "Tom" }
remove 方法具有与其他 MongoDB CRUD 方法相当类似的签名。第一个参数,正如你可能推测的那样,是一个选择器,用于选择 MongoDB 应该移除哪些文档。
在前面的示例中,我们移除了所有包含颜色属性purple值的文档。只有一个,所以再见了可怜的Bob。我们通过调用 find 方法进行了验证,现在它只返回Tom。
remove 方法的默认操作是删除所有找到的文档,所以请谨慎使用。传递一个空的选择器将删除集合中的所有文档,如下所示:
> db.cat.remove({})
WriteResult({ "nRemoved" : 1 })
> db.cat.find()
>
就这样,我们没有了猫。
MongoDB 没有内置的回滚功能,所以没有真正的方法可以撤销这样的删除。在生产环境中,这是为什么复制和定期数据库备份很重要的原因之一。
创建你的 SPA 数据库
因此,现在我们已经对 MongoDB 进行了一些操作,让我们创建我们将用于构建 SPA 的开发数据库。
在后面的章节中,我们将使用一个名为mongoose to model的 Node.js 插件来验证、查询和操作我们的数据。
现在,让我们只设置数据库。在你的 mongo shell 中,输入以下命令:
> use giftapp
switched to db giftapp
记住,我们实际上还没有创建数据库。为此,我们需要将一个文档放入一个集合中。由于我们将在以后让 Mongoose 为我们做所有繁重的工作,我们将先在测试集合中放入一些内容以开始。以下代码作为示例:
> db.test.insert({test:"here is the first document in the new database"})
WriteResult({ "nInserted" : 1 })
> db.test.find()
{ "_id" : ObjectId("565ce0a2d9d61e2dc6141821"), "test" : "here is the first document in the new database" }
> show dbs
giftapp 0.078GB
local 0.078GB
test 0.078GB
在这里,我们将向测试集合中插入一个文档。我们可以通过调用 find 方法来验证它是否在那里。最后,我们将运行showdbs并查看我们的giftapp数据库是否成功创建。
从 AngularJS 开始
我们现在已经将应用堆栈的大部分组件放在一起了。我们有一个运行时环境,Node.js。我们已经安装并设置了 Web 应用框架 Express。我们刚刚设置了数据库,MongoDB。
在任何 SPA 中缺失的一个关键部分是——无论后端堆栈看起来如何,都有一个前端框架来使 SPA 魔法发生。
有许多用于 SPAs 的前端库和框架,但最受欢迎的,也是 MEAN 中的A,是 AngularJS,也称为 Angular。
Angular 是一个开源的前端框架,特别适合构建 SPA。它非常受欢迎,目前由 Google 维护。
注意
在这本书中,我们将使用 AngularJS 版本 1.4.8,该版本于 2015 年 11 月发布。AngularJS 2.0 于 2014 年宣布,在出版时,刚刚推出了生产版本。2.0 版本引入了破坏性的非向后兼容更改。今天的大多数开发都是使用 1.x 分支上的某个版本进行的,计划在未来继续支持。
将 AngularJS 安装到应用中
最终,Angular 是一个包含一些可选插件文件的单一 JavaScript 文件。有几种方法可以将 Angular 添加到您的前端应用程序中。您可以下载您感兴趣的包,或者使用 Bower 这样的工具来加载它。
我们开始的最简单方式是直接指向公开可用的 CDN 上的 Angular 文件。我们可以从 cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.8/angular.js 包含 Angular 1.4.8。
让我们打开我们的 index.ejs 文件,并包含一个链接到以下文件的脚本标签:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
<script src="img/angular.js"></script>
</body>
</html>
小贴士
当可能时,将链接到外部 JavaScript 文件的脚本标签包含在 HTML 的关闭 body 标签之前是一个好的做法。这是出于性能考虑。您可以在 stevesouders.com/hpws/rule-js-bottom.php 上了解更多信息,并查看一些展示这一做法理由的示例。
如果您重新启动服务器并加载 localhost:3000 上的默认页面,您不会看到太多。您正在加载 AngularJS,但还没有明显的效果。我们将分层次构建一些示例前端代码。
在添加 AngularJS 脚本后,我们首先想要做的是将 html 标签修改为以下代码所示:
<html ng-app>
头部元素上的 ng-app 属性被称为 Angular 指令。当 Angular 加载到页面时,它会进入一个引导阶段,寻找如 ng-app 这样的指令。这个指令告诉 Angular 应用程序的根元素在哪里。
您可以将此属性视为标记一个 Angular 将要管理的区域的方法。在我们的例子中,我们希望在整个页面上使用 Angular 组件,因此我们将 HTML 元素声明为根元素。
在下一节中,我们将构建一个将成为我们的根应用程序的模块。
构建 AngularJS 的第一个模块
AngularJS 是设计成模块化的,并包含一个注册模块的功能。模块充当其他对象的容器,例如 Angular 服务、指令和控制器。
Angular 模块也是可注入的。这意味着模块可以被注入到其他模块中并被消费。Angular 有一个独特的依赖注入系统,我们很快就会看到并大量使用。
在 giftapp 的 public/javavscripts 目录下创建一个新的 JavaScript 文件,命名为 app.js,并将以下代码输入到其中:
var giftAppModule = angular.module('giftAppModule', []);
giftAppModule.value('appName', 'GiftApp');
第一行使用 angular.module() 函数创建并注册了一个 Angular 模块。此函数的第一个参数是一个字符串,Angular 将使用它作为模块的名称,giftAppModule。第二个参数是我们希望注入到该模块中的依赖项数组。目前我们没有,所以数组是空的。
然后我们将模块分配给giftAppModule变量。尽管这个变量名恰好与模块名相同,但它是不相关的;我们可以将其命名为任何其他名称。你不必将模块分配给一个变量名,但这样做很有用,因为它允许我们更干净地添加资产到模块中。
下一行,giftAppModule.value('appName','GiftApp');通过调用value函数在模块上创建了一个新的服务。Angular 中的服务是一个单例,它可以被注入。Angular 包含多种类型的服务。值服务是最简单的一种,它创建一个可以被注入和使用的名称值对。我们将在我们的控制器中使用它。
最后,我们希望在index.ejs模板中将我们的新模块加载并作为根 Angular 应用程序Bootstrap。
看看下面的代码:
<!DOCTYPE html>
<html ng-app="giftAppModule">
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
<script src="img/angular.js"></script>
<script src="img/app.js"></script>
</body>
</html>
这里有两个重要的更改需要注意。首先,在 HTML 元素中,我们通过将ng-app指令的值设置为giftAppModule来添加了对我们刚刚创建的giftAppModule的引用。下一个更改是在关闭body标签之前添加了一个新的script标签,该标签加载了我们刚刚创建的app.js文件。
加载顺序在这里很重要,Angular 必须在app.js之前加载,否则会失败。注意我们用来加载app.js的路径:/javascripts/app.js。这之所以有效,是因为 Express app.js中的一段代码将静态文件请求指向公共目录,app.use(express.static(path.join(__dirname,'public')));。
如果服务器停止,启动服务器并重新加载页面在这个阶段没有任何可见的变化。要开始对页面进行更改,我们需要添加一个控制器和一个 Angular 表达式。
添加控制器
在 Angular 中,控制器是一个 JavaScript 对象,它通过 Angular 的$scope对象向视图公开数据和功能。
打开你的app.js文件,并添加以下代码:
var giftAppModule = angular.module('giftAppModule', []);
giftAppModule.value('appName', 'GiftApp');
giftAppModule.controller("GreetingController", ['$scope','appName', function($scope)
{
$scope.name = appName;
$scope.greeting = "Hello Angular"
}
]
);
附加的代码在giftAppModule上创建了一个名为GreetingController的控制器构造函数。这还不是真正的控制器,直到它在页面上使用ng-controller指令被调用时才成为如此。
函数的第一个参数是控制器的名称。第二个参数是一个数组,包含我们希望注入的依赖项。数组中的最后一项是函数本身。Angular 文档称这为数组注解,并且这是创建构造函数的首选方法。
数组第一部分的模块名称字符串映射到函数的参数。每个参数的顺序必须相同。
接下来,我们需要将控制器添加到我们的index.ejs html中,如下所示:
<!DOCTYPE html>
<html ng-app="giftAppModule">
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
<div ng-controller="GreetingController">
</div>
<script src="img/angular.js"></script>
<script src="img/app.js"></script>
</body>
</html>
在这里,我们将添加一个div标签,并给它一个值为GreetingController的ng-controller属性。当我们加载这个页面时,Angular 将创建一个新的GreetingController对象,并将一个子作用域附加到页面的这部分。
再次,如果你在浏览器中刷新,你将不会看到任何不同。通常,为了向用户显示数据,你会使用 Angular 表达式。
使用 Angular 表达式显示数据
Angular 表达式是放置在双大括号{{}}之间的代码片段。Angular 评估这些(JavaScript 的eval()函数没有被使用,因为它不是一个安全的机制)。
Angular 文档将表达式称为 JavaScript,因为它们有一些相当大的差异。例如,Angular 表达式没有控制循环。
Angular 表达式是在当前作用域的上下文中评估的。
让我们对index.ejs文件进行以下修改:
<!DOCTYPE html>
<html ng-app="giftAppModule">
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
<div ng-controller="GreetingController">
{{ greeting }} from {{ name }} {{ 2+3 }}
</div>
<script src="img/angular.js"></script>
<script src="img/app.js"></script>
</body>
</html>
我们在div中添加了一些 Angular 表达式,其中调用了GreetingController。Angular 使得GreetingController的作用域对象在div内部可用。
现在,如果你重新加载这个页面,在欢迎使用 Express下,你应该看到以下行代码:
Hello Angular from GiftApp 5
Angular 评估了表达式并将它们作为字符串显示。包含问候和名称的表达式从作用域中提取那些值。最后的表达式只是进行了一点算术运算。
双向数据绑定
Angular 提供的主要功能之一被称为双向数据绑定。这意味着在视图中更改数据会更新模型中的数据。同样,模型中更改的数据会在视图中反映出来。
打开app.js并添加以下属性到作用域中:
var giftAppModule = angular.module('giftAppModule', []);
giftAppModule.value('appName', 'GiftApp');
giftAppModule.controller("GreetingController", ['$scope','appName', function($scope, appName){
$scope.name = appName;
$scope.greeting = "Hello Angular";
$scope.newName = "Bob";
}]
);
我们添加了一个newName属性并将其赋值为字符串Bob。
现在,我们需要对index.ejs文件进行以下修改:
<!DOCTYPE html>
<html ng-app="giftAppModule">
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
<div ng-controller="GreetingController">
{{ greeting }} from {{ name }} {{ 2+3 }} {{ newName }}
<p><input type=""text" ng-model="newName"></p>
</div>
<script src="img/angular.js"></script>
<script src="img/app.js"></script>
</body>
</html>
我们对这个文件做了两个修改。第一个是我们在算术表达式之后添加了{{newName}}表达式。这将在屏幕上渲染Bob字符串。第二个修改是添加了一个文本输入控件,并添加了ng-model="newName"指令。这个指令将文本框中的值绑定到作用域上的newName属性。
当页面加载时,文本框中的值是Bob。但如果我们在文本框中输入除了Bob之外的内容会发生什么呢?表达式的渲染值几乎瞬间改变。
这是一个双向数据绑定的明显例子。视图中的数据更改无缝地影响模型。
摘要
在本章中,你学习了如何仅使用基于 JavaScript 的工具从数据库到前端构建一个全栈应用程序。在前面的章节中,我们探讨了 MEAN 栈组件。现在我们已经开始将它们组合起来。
你从查看 Node.js 开始,这是我们的基于 JavaScript 的运行环境。你使用了 Node.js 的 REPL 在命令行上执行 JavaScript 代码。然后你编写了一个脚本,一个小型 Web 服务器,它可以由 Node.js 运行
你学习了两种设置 Express 应用程序的方法。此外,你还使用了 express generator 来构建一个功能框架以构建应用程序。你了解了路由和中间件——Express 的两个关键组件。
MongoDB 是一个 NoSQL 数据库,它将数据作为灵活的文档存储在集合中,而不是像关系数据库那样存储在记录/表模型中。你已经在 Mongo 中运行了每个基本的 CRUD(创建、读取、更新、删除)方法,即插入、查找、更新和删除。
在下一章中,我们将深入探讨 MongoDB,通过命令行界面获得经验。
使用 MongoDB 管理数据
MongoDB 是 MEAN 栈的数据库,我们已经探索了一些其更基本的功能。它是一个功能强大、可扩展的 NoSQL 数据库,因其在大数据和 Web 应用中的广泛流行而受到青睐。它恰好是开源的,并且支持广泛的操作系统和平台。
MongoDB 可以通过 MongoDB shell 访问,这是一个使用类似 JavaScript 语法的命令行界面。
在本章中,我们将更深入地探索 MongoDB,并将其开始整合到我们的 SPA 中。你将探索 MongoDB shell 中的各种 CRUD 操作,以及使用 Node.js 插件来访问单页应用内的数据库。
我们将涵盖以下主题:
-
NoSQL 数据库
-
使用 shell 命令操作 MongoDB
-
将 MongoDB 整合到 SPA 中
-
MongoDB 性能
第八章:探索 NoSQL 数据库模型
MongoDB 是众多 NoSQL 数据库之一。目前,根据那些关注数据库的人收集的统计数据,它恰好是使用中最受欢迎的 NoSQL 数据库。基于 SQL 的关系型数据库已经为我们服务了几十年,那么 NoSQL 究竟有什么大不了的?
定义 NoSQL
MongoDB 通常被称为 NoSQL 数据库。NoSQL 是一个流行的术语,适用于 MongoDB 和其他几个数据库引擎。但这是什么意思呢?
首先,没有一个权威机构对 NoSQL 的定义有标准定义。这个术语最早在 1998 年由 Carlo Strozzi 提出,用来描述一个没有 SQL 界面的开源关系型数据库。然而,今天这个术语的使用有所不同。NoSQL 数据库通常有两个定义特征。
NoSQL
如其名所示,大多数 NoSQL 数据库不使用 SQL 来访问数据库。然而,也有一些 NoSQL 数据库允许使用类似 SQL 或从 SQL 派生的语言。因此,有些人将 NoSQL 理解为“不仅限于 SQL”。
MongoDB 数据库通常通过类似 JavaScript 的语法访问。
非关系型
NoSQL 数据库不使用关系模型,其中数据存储在结构化的列或行表格中。在 MongoDB 的情况下,数据作为文档存储在集合中。
在关系型数据库中,数据存储在表格中,就像电子表格中的表格一样。
分布式
MongoDB 和其他 NoSQL 数据库被设计为分布式,以便在集群中良好工作。这使得在云中托管多个服务器上的 NoSQL 数据库变得更容易,并提供了安全性、备份、性能和扩展性。
MongoDB 支持分片。分片是一个将数据库的部分托管在不同的服务器上的过程。这使得 MongoDB 非常快,并且具有高度的可扩展性。
尽管这超出了本书的范围,但 MongoDB 的分布式特性使其对大数据项目具有吸引力。当然,它使 MongoDB 成为 Web 应用的诱人解决方案,这也是它目前最流行的用途。
MongoDB 的特点
Mongo 有一些你应该知道的功能,这些功能使其与其他数据库不同。它们如下所述。
文档模型
NoSQL 数据库使用了多种模型。其中一些包括图模型、键值模型、对象模型等。这些其他模型超出了本书的范围。
MongoDB 使用文档模型。数据存储在 MongoDB 数据库中文档的集合中。
这里是一个 MongoDB 文档的例子:
{
"_id" : ObjectId("566d9d4c1c09d090fd36ba82"),
"name" : "John",
"address" : {
"street" : "77 Main street",
"city" : "Springfield" }
}
如你所见,MongoDB 中的文档是 JSON 的一种形式。在这种情况下,文档甚至包含一个子文档,即地址。
数据库本身以二进制编码文档,并以称为 BSON 的形式存储。不过,不用担心,你不需要自己关心编码或解码任何数据,这一切都是在幕后处理的。
JSON 和 BSON 之间的一大区别是 BSON 支持许多 JSON 不支持的数据类型。这包括二进制数据、正则表达式、符号、日期等。例如,日期在 JSON 输出中可能被表示为一个简单的字符串。然而,将日期作为日期类型存储在 BSON 中,允许在查询或插入时进行有效的日期比较和操作。
在大多数情况下,这并不是你需要担心的事情。MongoDB 将无缝地将数据转换为可用的 JSON。然而,当我们到达 Mongoose 时,数据验证将成为一个重要的特性,将由中间件处理。
无模式
MongoDB 的一个特性,以及一些其他 NoSQL 数据库的特性是,它没有固定的模式。
在 MongoDB 中,文档存储在称为集合的组中。存储在集合中的文档在概念上应该是相关的,但数据库软件本身并没有强制执行这一点的限制。这与那些严格定义可以输入到表中的数据的数据库形成鲜明对比。
这里存在一个风险,即随机文档可以放入任何集合,使得集合的组织变得没有意义。你可以在名为 pets 的集合中插入反映汽车数据的文档,但这并没有太多意义,并且可能使该集合中的数据难以进行有意义的查询。
这值得深思。
开源
MongoDB 是一个开源数据库。服务器本身、驱动程序、工具和文档都适用多种不同的许可证。
MongoDB 的完整许可信息可在www.mongodb.org/licensing找到。
为什么使用 MongoDB?
你可以选择许多数据库来构建一个单页 Web 应用程序。例如,MySQL 是 Web 应用程序中流行的数据库。你为什么想要选择 MongoDB 而不是 MySQL 之类的数据库呢?
最终,几乎任何数据库都能完成这项工作,但 MongoDB 中的一些特性使其特别适合用于单页应用程序(SPAs)。
支持良好
MongoDB 在多个操作系统和平台上享有广泛的支持。MongoDB 提供了 Windows、多种 Linux 版本、Mac 和 Solaris 的下载和安装程序。
在云端运行 MongoDB 的流行方式之一是在 平台即服务(PaaS)上。PaaS 是一种服务,通常由亚马逊等供应商提供,允许开发者在云中构建 Web 应用程序,而无需管理基础设施的麻烦。MongoDB 维护了一个支持平台列表,可在 docs.mongodb.org/ecosystem/platforms/ 查找。
MongoDB 在许多流行的编程语言中得到支持。快速浏览 MongoDB 的驱动程序页面 docs.mongodb.org/ecosystem/drivers/ 可以看到,截至本书编写时,MongoDB 已经支持 C、C++、C#、Java、Node.js、Perl、PHP、Python、Motor、Ruby 和 Scala 的驱动程序。此外,社区支持的 Go 和 Erlang 驱动程序无疑也将很快推出。
数据模型
由于 MongoDB 的数据模型基于 JSON,因此它非常适合用于 Web 应用程序。JSON 输出可以直接通过前端 JavaScript 和 AngularJs 等 JavaScript 框架进行消费。
由于 JSON 是一种面向对象的数据格式,因此数据与自身也是面向对象的编程语言配合得很好。数据结构可以在你编写的软件中非常容易地进行建模。
受欢迎度
作为一名开发者,你使用的工具的受欢迎程度相对重要。一方面,不受欢迎的框架不会像受欢迎的框架那样得到开发社区的注意。使用流行的开源工具可以确保有活跃的开发正在进行。
这包括书籍和学习资源、平台可用性和语言支持等方面。
受欢迎度也可以是质量的一个指标,至少可以表明它适合流行的应用程序类型。MongoDB 在 大数据 领域非常受欢迎,在那里非结构化数据是日常运营的基础。然而,MongoDB 在一些最受欢迎的 Web 应用程序类型——如 CMS 和地理空间数据——方面表现得尤为出色。
MongoDB 非常受欢迎。根据 MongoDB 2015 年的新闻稿(www.mongodb.com/press/mongodb-overtakes-postgresql-4-most-popular-dbms-db-engines-ranking),MongoDB 已经超越了 PostgreSQL,成为第四大最受欢迎的数据库。根据该新闻稿,当时它是前五名中唯一的非关系型数据库。同样,根据该新闻稿,MongoDB 在过去两年中的受欢迎程度增长了超过 160%。
MongoDB 正在越来越多地被用于比许多其他数据库更广泛的地方。所有迹象都表明,它将长期存在,并在所有最受欢迎的平台上得到支持。
掌握 MongoDB
MongoDB 附带一个交互式 shell,我们在上一章中已经简要使用过。为了刷新你的记忆,在通过输入mongod启动 MongoDB 守护进程后,你可以在单独的终端窗口中通过输入mongo来访问 shell。
主要,你将通过在应用程序中使用本地代码来访问 MongoDB。然而,了解 MongoDB shell 对于使用它是无价的。有时你将需要直接访问 shell,特别是进行调试。你可能还需要管理云中的 MongoDB 实例。
你应该很好地掌握 MongoDB shell。
获取信息
在 MongoDB shell 中,你可以做的一件最重要的事情是管理你的数据库。使用 shell 命令从 MongoDB 中获取元信息是最容易的。以下是一些你可以在 MongoDB shell 中使用的基本命令,以获取信息。
help - 这将输出 MongoDB shell 中可用的基本命令列表。对于操作数据库的方法的帮助,你将使用db.help()方法。在 MongoDB shell 中输入 help 会输出以下内容:
-
db.help(): db 方法帮助 -
db.mycoll.help(): 集合方法帮助 -
sh.help(): 分片辅助工具 -
rs.help(): 副本集辅助工具 -
help admin: 管理帮助 -
help connect: 连接到数据库的帮助 -
help keys: 键快捷键 -
help misc: 一些需要知道的事情 -
help mr: Mapreduce -
show dbs: 显示数据库名称 -
show collections: 显示当前数据库中的集合 -
show users: 显示当前数据库中的用户 -
show profile: 显示最近系统.profile 条目,时间s>= 1 m* -
show logs: 显示可访问的记录器名称 -
show log [name]: 打印内存中最后一段日志;global是默认值 -
use <db_name>: 设置当前数据库 -
db.foo.find(): 列出foo集合中的对象 -
db.foo.find( { a : 1 } ): 列出foo集合中 a 等于 1 的对象 -
it: 上一条评估的结果;用于进一步迭代 -
DBQuery.shellBatchSize = x: 设置 shell 上显示的默认项目数 -
exit: 退出 Mongo shell
从数据库中收集信息的最重要命令之一是以show开头的命令。例如,showdbs将给出系统上当前可访问的数据库名称列表。showcollections将列出当前数据库中的集合。
这里没有列出的一项是检索你当前操作数据库的方法。要这样做,只需输入db,shell 就会输出当前数据库的名称。
插入和更新数据
在上一章中,我们使用 insert 方法插入了一些记录。在这里,你将稍微不同地做这件事,以便你可以设置并加载一些数据到你的giftapp数据库中,这是我们上一章为构建的 SPA 创建的。
我们将使用两种方法来插入你尚未使用过的数据。一种方法是在 MongoDB shell 中执行 JavaScript 文件,这将设置并执行命令。我们将使用此方法插入一些文档。我们将使用的另一种方法是批量操作,它允许我们设置一些数据,然后执行并批量插入。
在 MongoDB shell 中运行脚本
MongoDB shell 允许你加载和执行 JavaScript 文件。在你的giftapp目录中,创建一个名为scripts的新文件夹,并创建一个名为db-init.js的 JavaScript 文件:
db = db.getSiblingDB('giftapp');
var user1 = {firstName:"Mark", lastName:"Smith", email:"msmith@xyzzymail.org"};
var user2 = {firstName:"Sally", lastName:"Jones", email:"sjones@xyzzymail.org"};
var users = [user1, user2];
db.users.insert(users);
第一行db=db.getSiblingDB('giftapp'),告诉 MongoDB shell 在未以某种方式选择giftapp数据库的情况下,要使用哪个数据库。我们需要使用这个方法,因为use命令在 JavaScript 中是无效的。
接下来,你使用 JavaScript 对象字面量表示法创建了两个对象user1和user2。这些对象代表Mark Smith和Sally Jones的用户数据。然后你创建了一个名为users的数组,它包含这两个用户对象。
接下来,我们在users集合上调用insert方法,并将users数组传递给它。如果在giftapp数据库中没有users集合,当我们执行此脚本时,将会创建一个。
注意,当将数组传递给insert方法时,MongoDB 将单独插入每个文档。这是一个强大的功能,允许轻松高效地插入多个文档。
我们有两种方法可以加载和执行此脚本。
在不运行 MongoDB shell 的终端的命令行中,导航到脚本存储的目录,并输入以下内容:
$ mongo localhost:27017/test db-init.js
MongoDB shell version: 3.0.4
connecting to: localhost:27017/test
不幸的是,没有真正有用的输出告诉你插入已完成。如果你启动 MongoDB shell,或在已运行的终端中使用,你可以通过以下操作进行验证:
> db.users.count()
2
> db.users.find()
{ "_id" : ObjectId("566dcc5b65d385d7fa9652e3"), "firstName" : "Mark", "lastName" : "Smith", "email" : "msmith@xyzzymail.org" }
{ "_id" : ObjectId("566dcc5b65d385d7fa9652e4"), "firstName" : "Sally", "lastName" : "Jones", "email" : "sjones@xyzzymail.org" }
count方法返回集合中的文档数量。这里有两个。我们已经探讨了find方法。这里我们使用不带参数的find,它返回集合中的所有文档。你可以看到Mark和Sally现在作为单独的文档存储在users集合中。
如果你多次运行此脚本,将会创建许多Mark和Sally文档。如果你想清理集合并重新开始,可以使用drop方法,并使用以下命令进行验证:
> db.users.drop()
true
> db.users.count()
0
> db.users.find()
>
我承诺给你第二种运行脚本的方法,我们将继续探讨。首先,让我们对脚本进行一些小的修改:
db = db.getSiblingDB('giftapp');
var now = new Date();
var user1 = {firstName:"Mark", lastName:"Smith", email:"msmith@xyzzymail.org", created: now};
var user2 = {firstName:"Sally", lastName:"Jones", email:"sjones@xyzzymail.org", created: now};
var users = [user1, user2];
db.users.insert(users);
我们添加了一个名为now的变量,它包含一个新的Date对象。以这种方式创建Date对象将对象中的日期和时间设置为当前日期和时间。接下来,我们在Mark和Sally中添加了一个名为created的字段,并将其值设置为now,我们的日期对象。
在运行 MongoDB shell 的终端中,执行以下操作:
> db.users.drop()
true
> db.users.count()
0
> load('/[path to your directory]/giftapp/scripts/db-init.js')
true
> db.users.count()
2
> db.users.find()
{ "_id" : ObjectId("566dd0cb1c09d090fd36ba83"), "firstName" : "Mark", "lastName" : "Smith", "email" : "msmith@xyzzymail.org", "created" : ISODate("2015-12-13T20:10:51.336Z") }
{ "_id" : ObjectId("566dd0cb1c09d090fd36ba84"), "firstName" : "Sally", "lastName" : "Jones", "email" : "sjones@xyzzymail.org", "created" : ISODate("2015-12-13T20:10:51.336Z") }
这里,我们使用load方法运行脚本,传递脚本的路径。我们看到两个用户已经被添加到集合中,并且find方法检索了它们的文档。
如果你查看Mark和Sally文档上创建的字段,你会看到一些新东西。Date可能看起来有点不同。内部,MongoDB 将日期存储为自 1970 年 1 月 1 日以来的 64 位整数,表示毫秒数。负数用于表示该日期之前。
将日期和时间存储为整数而不是字符串,允许进行日期计算和比较。
幸运的是,MongoDB 以某种可使用和可读的格式输出日期。我们将在后面的章节中探索以更人性化的方式显示日期。
执行批量操作
在单次传递中将多个文档插入到 MongoDB 集合中的一种方法是使用 MongoDB 的Bulk API。这允许我们设置一个有序或无序操作的列表,然后在我们选择执行时运行它们。我们可以使用 MongoDB shell 命令来实验这个。
看看下面的命令:
> var bulk = db.users.initializeUnorderedBulkOp()
> bulk.insert(
... { firstname: "John",
... lastname: "Smith",
... email: "jiggy@zzxxyy3.com",
... created: new Date()
... }
... );
> bulk.insert(
... { firstname: "Jane",
... lastname: "Smothers",
... email: "janes@zzxxyy3.com",
... created: new Date()
... }
... );
> bulk.execute()
BulkWriteResult({
"writeErrors" : [ ],
"writeConcernErrors" : [ ],
"nInserted" : 2,
"nUpserted" : 0,
"nMatched" : 0,
"nModified" : 0,
"nRemoved" : 0,
"upserted" : [ ]
})
在第一行,我们在用户上打开了一个无序批量操作,并将其分配给名为bulk的变量。我们也可以将其作为一个有序操作,但我们目前不关心插入执行的顺序。
然后我们在批量操作中添加两个insert命令,一个用于John Smith,另一个用于Jane Smothers。然后我们可以调用bulk操作的execute。返回的值告诉我们没有错误,并且插入了两个文档。
让我们看看我们的集合现在:
> db.users.find().pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"firstname" : "John",
"lastname" : "Smith",
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"firstname" : "Jane",
"lastname" : "Smothers",
"email" : "janes@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:28:00.383Z")
}
我在find方法的末尾添加了pretty方法,以便整理我们的输出并使其更易于阅读。正如你所见,John和Jane已经被添加到我们的集合中。
查找、修改和删除数据
查询是我们如何在数据库中搜索和返回数据的方式。我们一直在使用查询,每次我们使用find方法时。我们知道,单独的find会返回集合中的每个文档。这并不太有用。
特定结果
通常,我们希望查询一个集合并返回特定的结果。我们只想那些出口花生或我们想要在法国居住的客户名单。
要指定我们想要特定字段与特定值匹配的文档,我们这样做:
> db.users.find({lastname:"Smith"}).pretty()
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"firstname" : "John",
"lastname" : "Smith",
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z")
}
在这里,我调用了查找操作,并传递了一个包含单个字段的对象:lastname。这被称为条件。该字段的值是Smith。正如你所见,这返回了John Smith的记录。对于多个字段,您可以通过逗号分隔字段。
等一下,我不应该也看到Mark Smith的文档吗?如果你仔细看,Mark Smith和Sally Jones的firstName和lastName是驼峰式命名的。也就是说,N是一个大写字母。因此,MongoDB 不认为这是一个相同的字段。
这很好地说明了无模式数据库的一个危险,并且是需要记住的事情。我们将在更新部分修复这个问题。
假设我们想要获取 lastName 字段匹配 Smith 或 Jones 的用户文档。你可以用几种方式编写这个查询,但在比较相同字段时,最佳方式是使用 $in 运算符,如下面的命令所示:
> db.users.find({lastName: { $in: ['Jones', 'Smith']}}).pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
查询运算符
MongoDB 附带了许多以美元符号开头的运算符。它们用于在查询条件中进行修改和比较。
查询运算符包括比较运算符,如 $eq:等于,$gt:大于,和 $lte:小于或等于。以下是一个示例:
> db.users.find({payrate: {$gt: 45}})
这将返回 users 集合中所有 payrate 字段值大于 45 的文档。
逻辑运算符包括 $or、$and、$not 和 $nor。如果你熟悉逻辑运算符,那么这些运算符的行为就像你预期的那样。以下是一个示例:
db.find({$and: [{firstName: "Steve"},{lastName: "Smith"}]})
此查询返回所有 firstName 字段等于 Steve 且 lastName 字段等于 Smith 的文档。
MongoDB 包含两个元素运算符:$exists:检查字段是否存在,和 $type:检查指定字段的类型。请查看以下命令:
> db.users.find({car: { $exists: true })
此查询返回 users 集合中所有具有 car 字段的文档。
MongoDB 包含了许多其他运算符。这些包括正则表达式匹配和地理空间比较等。还有比较数组的运算符。
要获取运算符的更完整列表,请参阅 MongoDB 文档中关于运算符的说明,请参阅docs.mongodb.org/v3.0/reference/operator/query/。
投影
我们在上一章中简要介绍了投影,但为了刷新你的记忆,投影指定了查询中返回的字段。我们并不总是想要返回文档中的所有字段,因此投影允许我们限制数据到我们感兴趣的字段。
投影将是 find 方法的第二个参数,如下面的命令所示:
> db.users.find({},{ email: 1 })
{ "_id" : ObjectId("566dd0cb1c09d090fd36ba83"), "email" :
"msmith@xyzzymail.org" }
{ "_id" : ObjectId("566dd0cb1c09d090fd36ba84"), "email" :
"sjones@xyzzymail.org" }
{ "_id" : ObjectId("566dff161c09d090fd36ba85"), "email" :
"jiggy@zzxxyy3.com" }
{ "_id" : ObjectId("566dff161c09d090fd36ba86"), "email" : "janes@zzxxyy3.com"
}
我们通过将一个空对象作为 find 方法的第一个参数传递来指定我们想要集合中的所有文档。然后,我们使用投影来告诉 MongoDB 我们想要查看 email 字段。
你会注意到 _id 字段在结果中返回。这是一个默认值。为了抑制它,我们在投影中的查找操作中将其值设为 0,如下面的命令所示:
> db.users.find({},{ email: 1, _id: 0 })
{ "email" : "msmith@xyzzymail.org" }
{ "email" : "sjones@xyzzymail.org" }
{ "email" : "jiggy@zzxxyy3.com" }
{ "email" : "janes@zzxxyy3.com" }
在此查询中,email 被包含,而 _id 被排除。
还有许多投影运算符。你可以在 MongoDB 文档中找到有关这些运算符的详细信息,请参阅docs.mongodb.org/v3.0/reference/operator/query/。
查询修饰符
如其名所示,查询修改器用于修改查询返回的数据。这包括执行诸如排序或返回最大结果数等操作。
在 MongoDB 中有两种修改器形式(我更喜欢第一种)。看看下面的命令:
db.collection.find( { <query> } )._addSpecial( <option> )
db.collection.find( { $query: { <query> }, <option> } )
让我用一个例子来说明:
> db.users.find({},{ email:1, _id:0 }).sort({ email:1 })
{ "email" : "janes@zzxxyy3.com" }
{ "email" : "jiggy@zzxxyy3.com" }
{ "email" : "msmith@xyzzymail.org" }
{ "email" : "sjones@xyzzymail.org" }
在这里,我正在选择用户集合中的所有文档。我只返回email字段(并抑制_id字段)。然后按email的升序排序。如果我们想按email字段降序排序文档,我们将在修改器中将值设置为-1,如下面的命令所示:
> db.users.find({},{ email:1, _id:0 }).sort({ email:-1 })
{ "email" : "sjones@xyzzymail.org" }
{ "email" : "msmith@xyzzymail.org" }
{ "email" : "jiggy@zzxxyy3.com" }
{ "email" : "janes@zzxxyy3.com" }
修改数据
要修改 MongoDB 文档,你通常使用update方法。
看看下面的命令:
> db.users.find({lastname:"Smothers"}).pretty()
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"firstname" : "Jane",
"lastname" : "Smothers",
"email" : "janes@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:28:00.383Z")
}
> db.users.update({lastname:"Smothers"},{$set:{
email:"jsmothers@xxaayy4.com"}})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
> db.users.find({lastname:"Smothers"}).pretty()
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"firstname" : "Jane",
"lastname" : "Smothers",
"email" : "jsmothers@xxaayy4.com",
"created" : ISODate("2015-12-13T23:28:00.383Z")
}
在这里,我们执行一个find操作只是为了显示Jane Smothers的文档。我们想更改Jane的电子邮件地址,所以我们使用update方法。update方法的第一参数与find方法中用于选择文档或文档集的相同标准。第二个参数是更新的指令。
在这里,我们使用了$set运算符来更改电子邮件地址。如果文档中没有email字段,$set运算符将创建一个新的字段。
重要的是要注意,默认情况下,update只会更新单个文档。要更新多个文档,你需要在更新选项中设置一个多选项。
让我们修复用户集合,将firstname和lastname字段的格式改为驼峰式:
> db.users.update({ lastname: { $exists: true }}, {$rename:
{'lastname':'lastName','firstname':'firstName'}}, { multi: true })
WriteResult({ "nMatched" : 2, "nUpserted" : 0, "nModified" : 2 })
> db.users.find().pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z"),
"lastName" : "Smith",
"firstName" : "John"
}
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"email" : "jsmothers@xxaayy4.com",
"created" : ISODate("2015-12-13T23:28:00.383Z"),
"lastName" : "Smothers",
"firstName" : "Jane"
}
update方法的第一参数使用$exists运算符来选择没有驼峰式lastname字段的任何文档。第二个参数使用$rename运算符将firstname和lastname字段名更改为驼峰式。最后一个参数将多选项设置为true,告诉 MongoDB 更新所有匹配的文档。
结果显示我们匹配了两个文档,并更新了两个文档。运行find方法显示所有文档现在具有相同的字段名。
默认情况下,如果update方法的查询部分没有匹配任何文档,MongoDB 不会做任何事情。我们可以使用upsert选项告诉 MongoDB 如果没有匹配的文档,则创建一个新的文档:
> db.users.update(
... { email: "johnny5@fbz22.com"},
... {
... firstName: "Johnny",
... lastName: "Fiverton",
... email: "johnny5@zfb22.com",
... created: new Date()
... },
... { upsert: true })
WriteResult({
"nMatched" : 0,
"nUpserted" : 1,
"nModified" : 0,
"_id" : ObjectId("566eaec7fa55252158538298")
})
> db.users.find().pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z"),
"lastName" : "Smith",
"firstName" : "John"
}
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"email" : "jsmothers@xxaayy4.com",
"created" : ISODate("2015-12-13T23:28:00.383Z"),
"lastName" : "Smothers",
"firstName" : "Jane"
}
{
"_id" : ObjectId("566eaec7fa55252158538298"),
"firstName" : "Johnny",
"lastName" : "Fiverton",
"email" : "johnny5@zfb22.com",
"created" : ISODate("2015-12-14T11:57:59.196Z")
}
在这里,我们选择email字段匹配johnny5@fbz22.com的文档。正如我们所知,没有文档匹配此查询。update方法的第二个参数列出我们想要更改的数据。最后,我们将upsert选项设置为true。
写入结果显示没有文档匹配或修改,但有一个文档被插入更新。
使用find操作显示Johnny Fiverton的记录已被添加。
你可能已经注意到这次我们没有使用 $set 操作符。如果更新中的第二个参数没有使用操作符,MongoDB 将用第二个参数中的数据替换整个文档。这是一件需要注意的事情;当你不希望替换整个文档时,请使用 $set。
MongoDB 文档中提供了 update 操作符的列表:docs.mongodb.org/v3.0/reference/operator/update/。
删除数据
到目前为止,我们已经涵盖了 CRUD(创建、读取、更新、删除)的创建、读取和更新组件。剩下的部分是删除文档。对于删除,MongoDB 有 remove 方法。
Remove 有一个相当熟悉的签名。
查看以下命令:
> db.users.remove({ email: "johnny5@zfb22.com" })
WriteResult({ "nRemoved" : 1 })
> db.users.find().pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z"),
"lastName" : "Smith",
"firstName" : "John"
}
{
"_id" : ObjectId("566dff161c09d090fd36ba86"),
"email" : "jsmothers@xxaayy4.com",
"created" : ISODate("2015-12-13T23:28:00.383Z"),
"lastName" : "Smothers",
"firstName" : "Jane"
}
然后就是和 Johnny 说再见了。
你可能可以推断出 remove 的第一个参数是查询。在这里,我们选择了所有 email 字段匹配 johnny5@zfb22.com 的文档。在这种情况下,只有一个。写入结果告诉我们删除的文档数量为一条。
一个注意事项:默认情况下,删除将删除所有匹配的文档。如果查询是一个空对象,删除将删除集合中的所有内容。然而,索引将保持完整。为了确保你只删除单个文档,你需要将 justOne 参数,即删除的第二个可选参数,设置为 1,如下面的命令所示:
db.users.remove( { lastName: "Smith" }, 1 )
这将从一个用户集合中删除单个 Smith。
游标
在 MongoDB 中,调用 db.collection.find() 的结果实际上是一个 cursor。游标是查询结果的指针。在 MongoDB 壳中,如果你没有将 cursor 赋值给变量,游标将被自动迭代并输出。这就是我们到目前为止所做的一切:
> var cursor = db.users.find({},{ email:1, _id: 0 })
> cursor
{ "email" : "msmith@xyzzymail.org" }
{ "email" : "sjones@xyzzymail.org" }
{ "email" : "jiggy@zzxxyy3.com" }
{ "email" : "janes@zzxxyy3.com" }
> cursor
>
在这里,我们创建了一个名为 cursor 的变量,并将其赋值为 find 方法返回的 cursor。然后我们通过手动输入其名称并按 Enter 键来迭代 cursor。再次输入 cursor 名称并按 Enter 键将不会产生任何作用,因为 cursor 已经被迭代过了。
这本身并不是很有用,但我们可以用游标做很多事情。例如,如果我们想将所有文档放入一个数组中,我们可以这样做:
> var cursor = db.users.find({},{ email:1, _id: 0 })
> var myDocs = cursor.toArray()
> myDocs
[
{
"email" : "msmith@xyzzymail.org"
},
{
"email" : "sjones@xyzzymail.org"
},
{
"email" : "jiggy@zzxxyy3.com"
},
{
"email" : "janes@zzxxyy3.com"
}
]
MongoDB 提供了大量的内置游标方法。MongoDB JavaScript 游标方法的文档可以在以下位置找到:docs.mongodb.org/manual/reference/method/#js-query-cursor-methods。
将 MongoDB 集成到 SPA 中
所有这些命令行操作都很棒,但我们需要开始将我们的 MongoDB 数据库集成到我们的 SPA 中。在未来的章节中,我们将介绍 node 的 mongoose 插件,它将允许我们进行数据建模,并为我们完成大量的繁重工作。
目前,我们将以简单的方式将 MongoDB 连接添加到我们的 SPA 中,这将突出如何集成数据库并显示一些动态数据。
添加 NPM 模块
对于这一章节,我们需要两个模块来连接并轻松访问 Express 应用程序内部的 MongoDB 数据库。这些模块是mongodb和monk。
在你的终端中,导航到你的giftapp目录,并输入以下命令(如果你使用的是 Mac 或 Linux,请记住在命令前加上sudo):
npm install mongodb --save
...
npm install monk -save
你的package.json文件的依赖关系部分现在应该看起来像这样:
"dependencies": {
"body-parser": "~1.13.2",
"cookie-parser": "~1.3.5",
"debug": "~2.2.0",
"ejs": "~2.3.3",
"express": "~4.13.1",
"mongodb": "².1.1",
"monk": "¹.0.1",
"morgan": "~1.6.1",
"serve-favicon": "~2.3.0"
}
将 MongoDB 添加到主应用程序中
接下来,我们需要使 MongoDB 数据库在主应用程序中可访问。我们将在app.js文件中添加几行代码:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
在第一个高亮部分,我们使用require方法加载mongodb和monk模块。然后通过调用monk并分配连接到变量db来实例化数据库连接。
接下来,我们编写一小段中间件。请注意,这个中间件必须出现在路由中间件之前。中间件将数据库连接附加到请求对象,然后通过调用next函数将其传递给下一个中间件。
编写查询
现在让我们从你的数据库中获取一些数据并将其显示在浏览器上。为此,我们需要添加一个新的路由。打开你的routes/users.js文件,我们将添加几行代码:
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.get('/show', function(req, res, next)
{
var db = req.db;
var collection = db.get('users');
collection.find({},{},function(err,docs)
{
if(!err)
{
res.json(docs);
}
else
{
res.send('error');
}
});
});
module.exports = router;
我们将在稍后的章节中深入探讨 Express 路由,但在这里我们所做的是在/users路径之后为/show路径创建一个新的路由器。我们使用monk的get方法将数据库从请求对象中别名化,并设置我们感兴趣的集合。
然后我们在集合上调用monk的find方法,传递一个空查询。根据我们的命令行实验,空查询应该返回集合中的所有记录。
在这里find方法的最后一个参数是一个callback函数,当查询返回时执行。该函数的第一个参数接收如果查询导致错误,则接收一个错误。第二个参数接收查询返回的文档。
我们检查以确保没有错误,如果没有错误,我们使用响应对象的json函数输出文档。正如其名所示,输出以 JSON 格式返回给浏览器。
确保你的 MongoDB 守护进程仍在运行,或者在终端窗口中重新启动它。在另一个终端窗口中,导航到你的giftapp目录,并输入npm start来启动服务器。
在浏览器中导航到localhost:3000/users/show将显示如下内容:
[{"_id":"566dd0cb1c09d090fd36ba83","firstName":"Mark","lastName":"Smith","email":"msmith@xyzzymail.org","created":"2015-12-13T20:10:51.336Z"},{"_id":"566dd0cb1c09d090fd36ba84","firstName":"Sally","lastName":"Jones","email":"sjones@xyzzymail.org","created":"2015-12-13T20:10:51.336Z"},{"_id":"566dff161c09d090fd36ba85","email":"jiggy@zzxxyy3.com","created":"2015-12-13T23:26:42.165Z","lastName":"Smith","firstName":"John"},{"_id":"566dff161c09d090fd36ba86","email":"jsmothers@xxaayy4.com","created":"2015-12-13T23:28:00.383Z","lastName":"Smothers","firstName":"Jane"}]
它看起来并不美观,但这是一个包含所有文档的数组,文档格式为 JSON。我们本可以将其作为 Web 服务来消费,但让我们用一种更美观的方式来做这件事。
在页面上显示数据
让我们格式化我们的数据并将其放入 HTML 页面,使其看起来更美观。在你的views文件夹中,创建一个名为users的新文件夹。在该文件夹内,创建一个名为show.ejs的新文件,并在其中包含以下代码:
<!DOCTYPE html>
<html>
<head>
<title>Show Users</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>User List</h1>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email Address</th>
</tr>
</thead>
<tbody>
<% users.forEach(function(user, index){ -%>
<tr>
<td><%= user.firstName %></td>
<td><%= user.lastName %></td>
<td><%= user.email %></td>
</tr>
<% }); %>
</tbody>
</table>
</body>
</html>
我们在这里创建了一个嵌入的 JavaScript 文档,它包含一个名为users的项目集合。我们使用forEach函数遍历它,将每个实例分配给一个名为user的变量。
对于我们创建的每个遍历,我们都会创建一个表格行。该表格行包含用户的首字母、姓氏和电子邮件地址的表格数据元素。
仅此还不够;我们必须查询数据库并将数据传递到页面。为此,我们需要更改我们刚刚创建的路由以渲染此模板,并将检索到的文档传递给它。
这里是users路由文件的变化:
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show', { users: docs });
}else{
res.send('error');
}
});
});
module.exports = router;
这里唯一的实际变化是我们已经注释掉了使用响应的json方法将结果作为 JSON 发送回浏览器的行。相反,我们使用响应的render函数来选择users/show.ejs模板,并将检索到的文档作为名为users的属性传递。
现在,如果你重新启动giftapp服务器并导航到localhost:3000/users/show,你应该能看到以下内容:

你可以看到使用 Express 与 MongoDB 结合使用如何给我们带来很多便利和灵活性,以便将数据发送到浏览器。发送 JSON 格式的数据很简单,动态渲染页面也很简单。
随着我们继续构建我们的 SPA,我们将更多地依赖于构建返回 JSON 数据的 Web 服务。
MongoDB 数据库性能
诸如复制和分片等主题超出了本书的范围。然而,开发者可以采取一些措施来优化 MongoDB 数据库的性能。
主要,我们将讨论覆盖索引和调整查询以提升性能。
索引
在许多数据库系统中,当适当的时候,在字段中添加一个索引可以加快查询速度。在索引字段上执行查询时,查询会被优化。MongoDB 也不例外。
索引的缺点是它们会增加一些额外的写入操作时间。它们也在数据库中占用额外的空间。明智地索引是有意义的。在考虑添加索引时,你想考虑你是否预期读取操作多于写入操作。这将有利于添加额外的索引。
让我们在用户集合中添加一个索引。我们将说我们经常通过用户的姓氏查找我们的用户。在lastname字段上添加一个索引是有意义的,如下面的命令所示:
> db.users.createIndex({lastname:1})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"ok" : 1
}
我们使用集合的createIndex方法命令,传递一个包含单个字段的对象。该字段具有lastname键和值为1。这告诉 MongoDB 我们想要创建一个索引,在这个索引中,我们将集合中的lastname字段以升序存储。
在内部,这会创建一个所有姓氏按升序排列的列表,以及指向文档的指针。基于lastname字段的读取操作是高效的,因为 MongoDB 引擎不需要搜索集合中的每个文档来找到匹配的值,它只需搜索姓氏列表。
写入操作将会稍微慢一些,因为它们还需要更新索引。
优化查询
网络应用程序的性能可能会受到缓慢的数据读取操作的影响。优化数据库操作可以帮助扩展操作,同时也有助于提高感知性能,增强用户满意度。
开发者可以通过优化查询显著影响性能。减少查询所需时间的主要方法包括减少返回的数据量以及使用索引来提高查找效率。
使用限制
当limit()方法添加到查询中时,它会限制查询返回的记录数。限制返回的记录数意味着更少的数据传输,从而提高性能并减少资源使用。
查看以下命令:
> db.users.find().limit(2).pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba84"),
"firstName" : "Sally",
"lastName" : "Jones",
"email" : "sjones@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
我们在这里添加了limit函数来执行无查询的查找,并给它一个参数为 2。这告诉 MongoDB 返回两个文档,你可以在这里看到。
注意,我们仍然可以通过链式调用在末尾添加pretty()函数。
使用投影
我们已经讨论了投影作为限制每个文档返回的字段数的一种方法。投影是减少数据传输的另一种工具,如下面的命令所示:
> db.users.find({},{email:1,_id:0}).limit(2)
{ "email" : "msmith@xyzzymail.org" }
{ "email" : "sjones@xyzzymail.org" }
在这个查询中,我们添加了一个投影来显示email并抑制_id。我们保留了limit函数。结果是两个文档,每个文档只包含email字段。
使用提示()
使用hint()函数强制 MongoDB 使用特定的索引进行查询。
如果你还记得,我们之前在users集合的lastname字段上创建了一个索引。然而,这不会帮到我们,因为我们已经将文档更改为了使用驼峰式命名的字段名lastName。让我们看一下:
db.users.getIndexes()
[
{
"v" : 1,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "giftapp.users"
},
{
"v" : 1,
"key" : {
"lastname" : 1
},
"name" : "lastname_1",
"ns" : "giftapp.users"
}
]
你可以看到_id和lastname都是索引。让我们删除lastname并添加lastName:
> db.users.dropIndex({ 'lastname':1})
{ "nIndexesWas" : 2, "ok" : 1 }
> db.users.createIndex({ lastName:1 })
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"ok" : 1
}
> db.users.getIndexes()
[
{
"v" : 1,
"key" : {
"_id" : 1
},
"name" : "_id_",
"ns" : "giftapp.users"
},
{
"v" : 1,
"key" : {
"lastName" : 1
},
"name" : "lastName_1",
"ns" : "giftapp.users"
}
]
现在我们可以执行查询,确保我们使用lastName索引:
> db.users.find({ lastName: "Smith" }).hint({ lastName:1 }).pretty()
{
"_id" : ObjectId("566dd0cb1c09d090fd36ba83"),
"firstName" : "Mark",
"lastName" : "Smith",
"email" : "msmith@xyzzymail.org",
"created" : ISODate("2015-12-13T20:10:51.336Z")
}
{
"_id" : ObjectId("566dff161c09d090fd36ba85"),
"email" : "jiggy@zzxxyy3.com",
"created" : ISODate("2015-12-13T23:26:42.165Z"),
"lastName" : "Smith",
"firstName" : "John"
}
分析性能
如果你想要深入了解查询,可以在查询上使用附加的explain()方法。
查看以下命令:
> db.users.find({},{email:1,_id:0}).limit(2).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "giftapp.users",
"indexFilterSet" : false,
"parsedQuery" : {
"$and" : [ ]
},
"winningPlan" : {
"stage" : "LIMIT",
"limitAmount" : 2,
"inputStage" : {
"stage" : "PROJECTION",
"transformBy" : {
"email" : 1,
"_id" : 0
},
"inputStage" : {
"stage" : "COLLSCAN",
"filter" : {
"$and" : [ ]
},
"direction" : "forward"
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "Mac-695b35ca77e.local",
"port" : 27017,
"version" : "3.0.4",
"gitVersion" : "nogitversion"
},
"ok" : 1
}
为了理解输出,请查阅 MongoDB 文档docs.mongodb.org/v3.0/reference/explain-results/。
摘要
MongoDB 是一个灵活且可扩展的 NoSQL 数据库。它是非关系型的,将记录作为集合中的文档来维护,而不是作为表中的行。MongoDB 是无模式的;其集合是灵活的,不强制执行特定的数据结构。
MongoDB 文档以二进制编码的 JSON 或 BSON 形式存储。其文档的面向对象特性使得 MongoDB 非常适合与 JavaScript 等面向对象语言一起使用。
与所有数据库一样,MongoDB 提供 CRUD 操作。MongoDB 的操作使用类似 JavaScript 的语法执行。
作为开发者,优化 MongoDB 性能包括减少查询返回的数据量以及有效地使用索引。
在下一章中,你将开始使用 Express Web 应用程序框架处理你的 SPA 的 Web 请求。
第九章:使用 Express 处理 Web 请求
Express 是一个基于 Node.js 构建的强大、无偏见的 Web 应用程序框架。它提供了一个高度可插拔的接口和一些基本对象来处理 HTTP 请求响应生命周期。
我们已经开始了与 Express 的合作,使用 Express 生成器开始了我们的单页应用(SPA)。现在是时候进一步构建事物并了解 Express 的强大功能了。
Express 的真正力量来自于其最小化和无偏见的特点。它非常灵活和可扩展,使其成为许多 Web 应用程序、单页应用、混合应用甚至基于套接字的应用程序的好工具。
本章更详细地介绍了 Express,从内置对象开始。我们将构建许多路由,将应用程序代码组织到逻辑模块中。我们将详细学习 Express 中的请求和响应对象,并开发自己的中间件功能来处理 AJAX 请求。
我们将通过为我们的 SPA 提供一个 RESTful API 来结束,配置它以使用不同的数据格式进行响应。
本章涵盖了以下主题:
-
配置 Express
-
Express 请求和响应对象
-
在 GET 和 POST 请求中传递变量
-
开发 Express 中间件
-
构建 RESTful 服务
-
将路由组织到逻辑模块中
详细考察 Express
Express 在 Node 的 HTTP 服务器之上代表一个非常薄的层,但它有一些内置的对象,这些对象对于熟悉它们非常重要。这些包括App、Request、Response和Router对象。这些对象以及一些插件提供了 Express 框架的所有核心功能。
应用对象
在 Express 中,app对象通常指的是 Express 应用程序。这是惯例,也是调用express()函数的结果。打开你的app.js文件,看看读取var app = express()的那一行。这是我们创建应用程序并将其分配给变量app的地方。我们可以使用任何变量名,但惯例是使用app。我们将遵循惯例,并将此对象称为app。
让我们更仔细地看看我们的app.js文件,看看我们是如何使用app对象的:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
//Database middlewear
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
app API 包括一个重要的属性、事件和多个方法。要查看 Express 应用程序 API 中的完整功能列表,您可以在expressjs.com/en/api.html查看文档,但在这里我们将介绍一些最重要的功能。
app.locals
app.locals是一个在应用程序本身中持久存在的 JavaScript 对象。添加到该对象中的任何属性或函数都将在整个app中可用。这对于创建辅助函数或应用级别的值非常有用。
app.locals对象可以通过请求对象通过req.app.locals在中间件中访问。
在你的app.js文件中app.set();调用之后添加以下行:app.locals.appName="MyGiftApp";
现在打开你的 routes/users.js 文件并按如下方式修改它:
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show',
{
users: docs,
appName: req.app.locals.appName
}
);
}else{
res.send('error');
}
});
});
module.exports = router;
在 show 路由内部,我们在 res.render() 的第二个参数中添加了一些数据。我们将 req.app.locals.appname 映射到属性 appName。这使得它可以在我们的模板中使用。
现在,打开你的 views/users/show.ejs 模板文件并按如下方式修改它:
<!DOCTYPE html>
<html>
<head>
<title>Show Users</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>User List: <%= appName %></h1>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email Address</th>
</tr>
</thead>
<tbody>
<% users.forEach(function(user, index){ -%>
<tr>
<td><%= user.firstName %></td>
<td><%= user.lastName %></td>
<td><%= user.email %></td>
</tr>
<% }); %>
</tbody>
</table>
</body>
</html>
我们为 appName 属性添加了一个输出标签。
现在,确保 MongoDB 守护程序正在运行,并启动或重启你的应用程序。在你的浏览器中,导航到 :localhost:3000/users/show,你应该看到如下内容:

我们已成功添加了一个应用程序级别的本地属性,并在我们的模板中显示了它。
app.set()
在我们通过调用 express 函数创建应用程序之后,我们看到几个对 app.set() 的调用,设置视图目录和视图引擎的路径。set 函数接受两个参数。第一个参数是一个字符串,包含 Express 应用程序设置中的一个设置名称。一些应用程序设置包括以下内容:
-
casesensitiverouting:一个布尔值,默认禁用。当启用时,它忽略路由的大小写。/route和/Route将被视为相同的路由。 -
env:一个用于环境模式的字符串设置。默认是development或NODE_ENV环境变量设置的值。 -
etag:用于ETag响应头的设置。它有一个合理的默认值,但如果你想更改它,我建议参考文档。 -
jsonpcallbackname:一个字符串,指定 JSONP 响应的默认回调函数。 -
jsonspaces:一个数值,当指定时,它将使用指定的空格数美化并缩进返回的 JSON 响应。 -
queryparser:默认情况下,这设置为extended,但你可以用它来禁用查询解析或设置一个更简单或定制的查询解析函数。 -
strictrouting:一个布尔值,默认禁用,将/route视为/route/。 -
views:一个字符串或数组,告诉 Express 在哪里查找显示模板。如果值是一个数组,Express 将按数组中出现的顺序查找它们。 -
viewcache:一个布尔值,在生产环境中默认为 true,这告诉 Express 缓存视图模板。在开发中这通常是不希望的。 -
viewengine:一个字符串 - 默认引擎扩展(例如ejs)。 -
x-powered-by:一个布尔值,默认为 true,发送一个X-Powered-By:ExpressHTTP 头。我认为关闭这个功能通常是个好主意,给潜在的黑客提供更少的信息。请在设置视图引擎的行之后,向你的app.js文件中添加app.set('x-powered-by',false);。
app.enable()
任何接受布尔值的 app 设置都可以通过 app.enable() 打开;例如,要启用视图缓存,你可以使用 app.enable('viewcache');。
app.disable()
如果你有一个启用函数,你应该有一个禁用函数,对吧?app.disable() 将任何 app 设置的布尔值设置为 false,将其关闭。
app.listen()
在底层,express() 调用返回的应用程序对象是一个 JavaScript 函数。记住,JavaScript 中的函数是对象,可以像其他任何对象一样传递。当我们调用 app.listen() 时,它实际上调用了 Node 的原生 http.createServer() 函数,并将自身、app 函数作为回调传递。
如果我们想使用 HTTPS,情况会有所不同,我们将在后面的章节中介绍。
对于我们的目的,我们会使用 app.listen() 并将我们希望监听的端口作为参数传递。然而,Express 生成器已经为我们设置了 bin/www 中的代码,如下所示:
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('giftapp:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
与简单地调用 app.listen() 不同,Express 生成器已经设置了此方法,它本质上做的是同样的事情,但为服务器对象添加了一些事件监听器以处理错误等。
app.METHOD()
app.METHOD() 通过实际方法路由进入服务器的请求。没有 METHOD 函数,实际函数是特定 HTTP 请求方法的下划线形式。换句话说,你会使用 app.get() 或 app.post() 方法。
这里可能会有一些小的混淆点,因为 app.get('somevalue') 也可以用来返回一个 app 设置。
通常,我们将请求传递给 Express 路由器,并以更模块化的方式处理路由。
app.all()
app.all() 与 app.METHOD() 类似,但它匹配所有 HTTP 请求方法。它通常用于通过中间件轻松地向路径或应用程序的某个部分添加全局功能。
例如,如果你想在不添加到每个单独的路由或方法的情况下,为 app 的某个部分添加身份验证,你可能做些像这样的事情:
app.all('/protected/', authenticationRequired);
这将使所有以 /protected/ 路径开始的请求(无论方法如何)通过 authenticationRequired 中间件传递。
请求对象
Express 中的请求对象包含与 HTTP 请求相关的数据。默认情况下,它将包含查询字符串、参数、头部、POST 参数等属性。它是中间件(如路由)提供的回调函数的第一个参数,按照惯例,通常称为 req:
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show', {
users: docs,
appName: req.app.locals.appName
});
}else{
res.send('error');
}
});
});
在我们的 routes/users 文件中,这里是我们对 URI /show 的 GET 请求的一个路由。你可以看到回调函数的第一个参数是 req。这是请求对象。我们从请求对象中获取对数据库的引用,以及 app.locals.appName 属性的引用。
req.params
请求对象的 params 属性使我们能够访问通过 URL 传递给服务器的参数。
让我们修改 routes/users 文件以添加一个新的路由:
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.get('/show/:id', function(req, res, next)
{
var db = req.db;
var collection = db.get('users');
collection.findOne({ "_id": req.params.id },{}, function(err,User)
{
if(!err)
{
res.render('users/user',
{
user: User,
appName: req.app.locals.appName
}
);
}
else
{
res.send('error');
}
});
});
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show', {
users: docs,
appName: req.app.locals.appName
});
}else{
res.send('error');
}
});
});
module.exports = router;
我们添加了一个新的路由,匹配/show/:id。:id部分将匹配 URL 的变量部分;在这种情况下,我们期望一个 ID,并将其作为名为id的属性放置在req.params对象上。
我们对用户集合数据库发出findOne查询。findOne返回一个对象(第一个匹配项),其中find返回一个包含所有匹配项的数组。在这种情况下,我们只对单个匹配项感兴趣;我们正在寻找具有特定_id的用户。
然后我们渲染users/user模板,传递我们的值。我们还没有用户模板,所以让我们在views/users目录中创建user.ejs:
<!DOCTYPE html>
<html>
<head>
<title><%= appName %>: <%= user.firstName %> <%= user.lastName
%></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= user.firstName %> <%= user.lastName %></h1>
<ul>
<li>Email: <%= user.email %></li>
<li>Id: <%= user._id %></li>
</ul>
<p><a href="/users/show">< Back</a></p>
</body>
</html>
传递到模板中的包含我们的用户数据的对象被称为user。在这里,我们可以访问它的所有属性,firstName、lastName、email和_id。为了使生活更加简单,我们添加了一个链接回到显示路由。
让我们稍微修改一下show.ejs以添加导航:
<!DOCTYPE html>
<html>
<head>
<title>Show Users</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>User List: <%= appName %></h1>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email Address</th>
</tr>
</thead>
<tbody>
<% users.forEach(function(user, index){ -%>
<tr>
<td><a href=""show/<%= user._id%>""><%= user.firstName %></a></td>
<td><%= user.lastName %></td>
<td><%= user.email %></td>
</tr>
<% }); %>
</tbody>
</table>
</body>
</html>
我们添加了一个链接到show/<%=user._id%>,,这将创建我们需要导航到单个用户显示路由的 URL。
启动或重启你的服务器。每次更改路由或主应用程序时都需要重启,但对于简单的模板更改则不需要。
导航到localhost:3000/users/show并点击你用户的一个名字。你应该会看到如下内容:

当然,因为 Mongo 生成_id字段,你的不会匹配我的。嗯,它们可能匹配,但那将是一个天文巧合。
req.body
请求对象上的body属性包含通常作为 POST 请求一部分发送的名称值对。为了访问这些,你需要向你的app添加body解析中间件。
Express 生成器已经为我们设置好了,通过要求一个body解析器然后在两行中添加中间件:
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
这两行允许我们解析以application/json或application/x-www-form-urlencoded发送回的数据。
在我们的路由中,我们可以访问通过req.body传入的参数。当我们开始构建资源路由时,我们会做很多这样的事情。这里有一个例子(不需要将其添加到我们的代码中):
router.post('/user', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.insert({ firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email},
function(err){
if(!err){
res.redirect('/users/show');
}else{
res.send('error');
}
});
});
在这里,我们接受对users/user的 POST 请求。我们使用monk进行插入(将记录添加到我们的 MongoDB 数据库中)。插入函数的第一个参数是一个对象,我们使用req.body中的firstName、lastName和email字段来填充要插入的文档的相同属性。假设没有错误,我们将重定向到users/show,显示包括我们的新用户在内的用户列表。
req.query
另一种从请求中获取数据的方法是使用附加到 URL 的查询字符串。如果你不熟悉这个,查询字符串是在 URL 问号后附加的名称值对数据。
例如,在http:www.mymadeupdomain.org/foo?name=john+smith&q=foo中,查询字符串部分是name=john+smith&q=foo。要在我们app内部访问它,我们会使用req.query.name和req.query.q。这将分别返回johnsmith和foo,在john和smith之间没有加号。加号是 URL 编码的一部分,因为空格在 URL 中无法翻译。
如果没有查询字符串,req.query将包含一个空对象。
注意
我应该何时使用查询字符串而不是参数?
没有最好的答案。一般来说,当你想要多个路由处理不同类型的操作时,你应该使用路由参数。我们大多数时候都会采取这种方法。如果你想要一个灵活地接收不同类型数据的单个GET请求路由,并且希望用户能够将其添加到书签中,查询字符串就很好。Google 使用查询字符串进行搜索:www.google.com/search?q=things。
req.cookies
req.cookies需要使用 cookie 解析器中间件,Express 生成器已经为我们方便地安装了它,并使我们能够访问请求中的 cookie。如果没有 cookie,req.cookies的值将是一个空对象。
通过名称访问 cookie:req.cookies.userID将给我们一个名为userID的 cookie。
注意
我们将在稍后更详细地探讨 cookie,但 cookie 解析器对于诸如身份验证和安全等操作是必需的。如果你打算直接使用 cookie,或者不使用 cookie,最好都保留它。
req.xhr
这是一个简单的布尔值,如果X-Requested-With请求头是XMLHttpRequest,则为true。这种情况通常发生在由 jQuery 等库发出的 AJAX 请求中。
这对于 SPA 很有用,因为我们可能希望在请求来自浏览器位置变化时响应 HTML 页面,但在随后的请求来自通过 AJAX 发出请求的客户端代码时响应数据。
让我们看看/routes/users.js中的/show/:id路由:
router.get('/show/:id', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.findOne({ ""_id"": req.params.id }, {}, function(err,User){
if(!err){
if(req.xhr){
User.appName = req.app.locals.appName;
res.json(User);
} else {
res.render('users/user',
{
user: User,
appName: req.app.locals.appName
});
}
}else{
res.send('error');
}
});
});
因此,我们检查请求是否通过XMLHTTPRequest,即 AJAX 发出。如果是,我们将appName添加到User对象中,然后将其作为 JSON 返回。如果不是,我们像平常一样渲染并返回页面。
这非常方便,我们稍后会使用这个机制。
req.accepts()
req.accepts是一个函数,它检查请求的Accept头,如果匹配则返回true。它可以接受字符串、数组、扩展名或 MIME 类型,如果没有匹配项,则返回最佳匹配或false(undefined,在 JavaScript 中为假值)。
例如,假设浏览器返回了以下头信息:Accept:text/*。application/json:req.accepts('html')将匹配text/*部分并返回html。req.accepts(['image/png','application/json'])将返回json。
与req.xhr类似,这对于灵活响应同一路由上的不同类型的请求非常有用。
req.get()
req.get()是一个函数,它返回请求中发送的 HTTP 头的值。该函数接受一个字符串,进行不区分大小写的匹配。此函数的别名是req.header()。
例如,req.get('content-type')返回 HTTP 请求中的内容类型头,如字符串application/json或text/html。
响应对象
Express 响应对象是一个 JavaScript 对象,它代表我们将从服务器发送回客户端的响应。我们看到它与请求对象配对,并且像使用req进行请求一样,惯例是使用res。
res.app
res.app对象与req.app属性相同。它是对应用的引用,但在此情况下附加到响应对象。这为访问应用属性提供了一些灵活性。
res.cookie()
这是一个响应对象方法,允许我们设置 cookie 并将其与响应一起发送。它接受一个名称、值和一个可选的包含参数的对象。
这里有一个例子:
res.cookie('userName', 'Joe', { maxage: 900000, secure: true, signed:true });
这将设置一个值为Joe的userNamecookie。cookie 在响应后的 900,000 秒后过期。cookie 仅用于 HTTPS,并且需要签名。还可以设置 cookie 的域和路径,以及实际的过期日期。
此方法清除指定的 cookie:
res.clearCookie()
这将清除我们之前设置的 cookie:
res.clearCooke('userName');
res.download()
res.download将给定路径的文件作为附件传输。它接受路径、可选的文件名和一个可选的回调函数,一旦文件传输完成:
res.download('/reports/TPS-coversheet.pdf', 'coversheet.pdf, function(err){
if(err){
//handle error here
} else {
//do something appropriate
}
});
我们开始下载位于/reports/TPS-coversheet的文件,但将其传输为coversheet.pdf。一旦完成,我们检查是否有错误,并在任何情况下做适当的事情。
res.json()
此方法发送 JSON 响应,非常直接。它可以接受任何 JavaScript 对象。使用 MongoDB 数据库的好处是,我们通常可以直接使用res.json()传递原始数据库响应:
res.json({dog: 'Fido', breed: 'Sheltie' commands: {sit: true, stay: false});
在这里,我们通过传递一个包含名为Fido的Sheltie和它所知道的命令的对象来响应 JSON。
res.jsonp()
此方法返回被回调函数包裹的 JSON 数据,也称为 JSONP。默认情况下,函数将被命名为 callback。但可以通过使用app.set('jsonpcallbackname','someFunction');来覆盖它。在这种情况下,我们得到以下结果:
res.jsonp({dog: 'Fido');
//returns someFunction({""dog"": ""Fido""})
当然,必须放置适当的客户端代码来处理响应。
res.redirect()
我们已经使用过这个了。这会将带有适当 HTTP 状态码的重定向发送回请求者。如果没有指定状态码,则使用302。
这里是我们之前查看的内容:
router.post('/user', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.insert({ firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email},
function(err){
if(!err){
res.redirect('/users/show');
}else{
res.send('error');
}
});
});
在插入操作之后,将新文档添加到我们的数据库中,我们向浏览器发送一个重定向,使其跳转到 /users/show。因为没有指定状态,所以将返回一个 302 状态码。
路径非常灵活,可以是完整的 URL:res.redirect('https://www.google.com/search?q=food');,也可以是相对路径:res.redirect('../dashboard/show');。
res.redirect(301, 'http://www.whitehouse.gov');
这将发送一个永久重定向到 whitehouse.gov,从而混淆了 Google 并破坏了你的 SEO。有关各种重定向代码的更多信息,请查看官方 HTTP 规范,注意 3xx 状态码:www.w3.org/Protocols/rfc2616/rfc2616-sec10.html。
res.render()
这是我们已经使用过的另一个方法,它发送回由 view 模板编译的渲染后的 HTML。该方法参数包括模板视图、包含本地变量的可选对象以及可选的回调函数。
让我们看一下我们 /show 路径在 routes/users.js 文件中的内容:
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show',
{
users: docs,
appName: req.app.locals.appName
}
);
}else{
res.send('error');
}
});
});
正如我们所看到的,这个对 res.render() 的调用渲染了 /views/users/show 路径下的模板。它使用户和 appName 本地对象对模板可用。
如果我们在渲染方法中添加一个回调函数,则需要显式调用 res.send():
res.render('users/show', {
users: docs,
appName: req.app.locals.appName
}, function(err, html){
if(!err){
res.cookie('rendered':""someValue"")
res.send(html);
} else {
res.send(""There's been a horrible error."");
}
});
在这里,我们添加了一个具有两个参数的回调函数,一个错误(如果有),以及渲染后的 html。这允许我们添加错误处理程序,在响应对象上设置一个 cookie,然后发送响应。
res.send()
我们已经看到 res.send() 是一个用于发送 HTTP 响应的方法。res.send() 非常灵活,可以接受多种类型的参数,包括 Buffer、对象、数组或字符串。
res.send() 会根据参数适当地调整 HTTP Content-Type 头。当参数是一个字符串时,Content-Type 将是 text/html,当是一个对象或数组时,将是 application/json,而当它是一个 Buffer 对象时,将被设置为 application/octet-stream。这些默认值可以通过调用 res.set() 并传入不同的 Content-Type 来覆盖。
我们还可以链式调用 status() 来传递一个 HTTP 状态码:
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show', {
users: docs,
appName: req.app.locals.appName
});
}else{
res.status(500).send(""There has been a major error"");
}
});
});
通过将状态与 500 HTTP 状态码链式调用,我们可以发送一条消息,表明发生了内部服务器错误,并附带我们的消息。
路由对象
路由对象在 Express 文档中被描述为一个仅提供中间件和路由功能的 mini-application。路由器作为中间件使用,因此可以作为 app.use() 或另一个路由器的 use() 方法的参数,这使得嵌套和组织路由变得容易。
我们通过调用 express.Router() 函数来创建一个路由对象:
var router = express.Router();
在我们的路由文件中,我们总是使用 module.exports=router 来导出路由。这允许我们通过 require() 加载路由作为模块,然后像使用任何其他中间件一样使用它。
让我们再次审查一下 app.js 文件:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
//Database middlewear
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app
我们需要索引路由,将其分配给变量routes,然后我们需要用户路由,将其分配给变量users。接着,我们使用app.use函数将路由添加到app中,匹配根路径和/users路径。
注意,Express 会按顺序尝试匹配路由。由于每个路由都会匹配根路径,它会首先查找那里,如果找不到以/users开头的匹配项,Express 会接着匹配下一个路由。在我们的/users路由中,我们知道我们有一个 show 路由,所以/users/show会被匹配到那里。
router.METHOD()
这与app.METHOD()的工作方式完全相同。我们添加小写的 HTTP 动词作为函数,传递一个匹配的路由和一个回调函数。我们已经看到这个模式了:
router.get('/something', function(req, res, next) {
res.send(""something loaded"");
});
这里需要注意的是,res.send()、res.render()和res.end()都会终止响应。这意味着next()中的任何内容都不会被调用。把它想象成从 JavaScript 函数中返回。在那之后就没有更多的事情可以做了。然而,你可以通过不终止来连续调用多个路由:
router.get('/something', function(req, res, next) {
res.locals.foo = ""bar"";
next()
});
router.get('/something', function(req, res, next) {
res.send(res.locals.foo);
//send s 'bar'
});
这两条路由都匹配/something,所以第一条会被调用,并将foo添加到响应对象的locals属性中。然后它调用next,调用下一个匹配的路由,发送res.locals,foo的值。
router.all()
router.all()的工作方式类似于router.METHOD(),除了它匹配所有 HTTP 动词,如 get、post 等。这对于向一系列路由添加全局功能非常有用。例如,假设你有一个api路由,并确保对api中任何路由的每次调用都是经过认证的:
router.all('/api/*', someAuthenticationMiddleware);
将此放在路由文件的最顶部,会使所有以/api/开头的 URL 调用都通过someAuthenticationMiddleware中间件。
router.param()
router.param()是一种强大的方式,可以根据路由参数添加回调功能。例如,假设在我们的users路由文件中,每次我们获取一个id参数。
让我们再次深入到我们的routes/users.js文件:
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.param('id', function(req, res, next, id)
{
var db = req.db;
var collection = db.get('users');
collection.findOne({ ""_id"": id }, {}, function(err,User)
{
if(err)
{
res.send(err);
}else if(User){
req.user = User;
next();
}
else
{
res.send(new Error('User not found.')
);
}
});
});
router.get('/show/:id', function(req, res, next)
{
if(req.xhr)
{
User.appName = req.app.locals.appName;
res.json(req.user);
}
else
{
res.render('users/user',
{
user: req.user,
appName: req.app.locals.appName
});
}
});
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
//res.json(docs);
res.render('users/show', {
users: docs,
appName: req.app.locals.appName
});
}else{
res.send('error');
}
});
});
module.exports = router;
我们使用router.param()来查找任何具有id参数的路由调用。回调函数在用户上进行数据库查找。如果有错误,我们通过发送错误来终止。如果找到了用户,将其添加到请求对象中。然后我们调用next()来传递请求到匹配的路由。
编写我们自己的中间件
正如我们所看到的,Express 被设计为高度依赖可插拔的中间件来为我们的应用程序添加功能。让我们自己编写一段中间件,这样我们就可以轻松地在app的任何地方切换响应到 JSON 格式。
在你的giftapp项目文件夹中添加一个utils目录,并在该目录中创建一个名为json.js的文件:
var isJSON = function(req, res, next){
if(req.xhr || req.headers['accepts'] == 'application/json'){
req.isJSON = true;
}
next();
}
module.exports = isJSON;
我们创建的isJSON函数接受所有 Express 中间件接受的三个参数 - 请求对象、响应对象和指向下一个的引用。我们检查请求对象的xhr值是否为true,或者请求的accepts头是否为application/json。如果任一条件为真,我们可以假设客户端正在请求JSON而不是HTML。
我们在请求对象中添加一个isJSON属性,将其设置为true。
现在,让我们修改我们的app.js文件,在应用程序需要的地方包含这个中间件:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = ""My Gift App"";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
//Database middlewear
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
首先,我们在模块中引入,将其分配给变量isJSON。注意,在这里我们需要使用一个明确的路径。如果我们仅仅使用一个模块名称,Node 将尝试在node_modules目录中查找它。
然后,我们使用app.use(isJSON)将我们的中间件添加到应用程序中。我们在文件中的位置很重要,因为中间件是按顺序调用的。在我们的情况下,它可以在任何地方,只要它出现在使用它的路由之前:
Next, we'll modify our routes/users.js file to use the middleware:var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.param('id', function(req, res, next, id) {
var db = req.db;
var collection = db.get('users');
collection.findOne({ ""_id"": id }, {}, function(err,User){
if(err){
res.send(err);
}else if(User){
req.user = User;
next();
} else {
res.send(new Error('User not found.'));
}
});
});
router.get('/show/:id', function(req, res, next) {
if(req.isJSON){
User.appName = req.app.locals.appName;
res.json(req.user);
} else {
res.render('users/user', {
user: req.user,
appName: req.app.locals.appName
});
}
});
router.get('/show', function(req, res, next) {
var db = req.db;
var collection = db.get('users');
collection.find({}, {}, function(err,docs){
if(!err){
if(req.isJSON)
{
res.send(docs);
}
else
{
res.render('users/show',
{
users: docs,
appName: req.app.locals.appName
});
}
}else{
res.send('error');
}
});
});
module.exports = router;
我们修改了我们的两个路由,根据新的isJSON标志有条件地发送 JSON 或 HTML。重启服务器然后浏览任一路由应该没有区别,因为你实际上并没有请求 JSON。
如果你想测试这个功能,你可以使用浏览器插件如Postman或终端请求如curl来发出一个xhr请求,并查看数据以JSON格式返回。
开发 RESTful API
让我们通过构建一些资源路由作为 RESTful API 的一部分来进一步设置我们的 SPA,这样我们就可以稍后将其连接到数据库和客户端代码。我们很幸运,Express 有一个充满活力的开发者社区,他们构建了许多附加组件,我们将使用其中一个用于资源路由。
安装资源路由
我们需要做的第一件事是安装我们的模块,它将为我们提供一些资源路由:
npm install resource-routing -save
这安装了我们将要使用的资源路由插件,并保存了对package.json文件的引用。
接下来,我们需要在我们的app.js文件中进行一些设置:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var app = express();
routing.expose_routing_table(app, { at: ""/my-routes"" });
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = ""My Gift App"";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
//Database middlewear
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
routing.resources(app, controllers, ""giftlist"");
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
我们使用require()引入资源路由模块,并将其分配给变量routing。然后我们创建一个指向控制器目录的快捷变量,这是我们接下来要构建的。
我们添加以下代码,routing.expose_routing_table(app,{at:"/my-routes"});,这允许我们在my-routes URL 查看我们的路由表。显然,这不是我们会在生产环境中保留的东西,但它是一个有用的调试工具。
最后,我们使用routing.resources(app,controllers,"giftlist");为giftlists设置我们的资源路由。目前这不会做任何事情,因为我们还没有设置我们的控制器。
构建礼品清单控制器
默认情况下,我们的资源路由器会为我们构建一系列标准的 RESTful 路由,包括:
GET /giftlist giftlist_controller.index
GET /giftlist.format giftlist_controller.index
GET /giftlist/new giftlist_controller.new
GET /giftlist/new.format giftlist_controller.new
POST /giftlist giftlist_controller.create
POST /giftlist:format giftlist_controller.create
GET /giftlist/:id giftlist_controller.show
GET /giftlist/:id.format giftlist_controller.show
GET /giftlist/:id/edit giftlist_controller.edit
GET /giftlist/:id/edit.format giftlist_controller.edit
PUT /giftlist/:id giftlist_controler.update
PUT /giftlist/:id.format giftlist_controller.update
DELETE /giftlist/:id giftlist_controller.destroy
DELETE /giftlist/:id.format giftlist_controller.destroy
如你所见,这些路由为我们提供了基本的 CRUD(创建、读取、更新、删除)功能。
然而,只有当控制器和路由实际存在时,这些路由才会被创建,因此我们需要构建它们。在你的 giftapp 文件夹中创建一个名为 giftlist_controller.js 的控制器目录。我们的插件在加载我们的控制器时会添加 _controller 部分,所以请确保命名正确。现在,我们将模拟我们的路由以确保它们正常工作:
exports.index = function(req, res){
res.send('giftlist index');
};
exports.new = function(req, res){
res.send('new giftlist');
};
exports.create = function(req, res){
res.send('create giftlist');
};
exports.show = function(req, res){
res.send('show giftlist'+ req.params.id);
};
exports.edit = function(req, res){
res.send('edit giftlist');
};
exports.update = function(req, res){
res.send('update giftlist');
};
exports.destroy = function(req, res){
res.send('destroy giftlist');
};
如您所见,我们的每个路由处理程序都是一个接收请求和响应对象的函数。
重新启动你的服务器并导航到 localhost:3000/giftlist/17,你应该会看到:
show giftlist 17
以不同的数据格式响应
我们的资源路由也可以支持不同的数据格式,所以让我们也模拟这些,我们还会在我们的 giftlist_controller.js 中使用我们的 isJSON 属性:
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({""title"":""giftlist index""})
}else{
res.send('<h1>giftlist index</h1>');
}
};
exports.new = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({""title"":""new giftlist""})
}else{
res.send('<h1>new giftlist</h1>');
}
};
};
exports.create = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({""title"":""create giftlist""})
}else{
res.send('<h1>create giftlist</h1>');
}
};
};
exports.show = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({ ""title"":""show giftlist"", ""giftlist"":req.params.id })
}else{
res.send('<h1>show giftlist' + req.params.id + '</h1>');
}
};
};
exports.edit = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({ ""title"":""edit giftlist"", ""giftlist"":req.params.id })
}else{
res.send('<h1>edit giftlist' + req.params.id + '</h1>');
}
};
};
exports.update = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({ ""title"":""update giftlist"", ""giftlist"":req.params.id })
}else{
res.send('<h1>update giftlist' + req.params.id + '</h1>');
}
};
};
exports.destroy = function(req, res){
exports.index = function(req, res){
if(req.params.format == ""json"" || req.isJSON){
res.json({ ""title"":""delete giftlist"", ""giftlist"":req.params.id })
}else{
res.send('<h1>delete giftlist' + req.params.id + '</h1>');
}
};
};
在这里,我们为每个路由添加了测试,以查看客户端是否请求 JSON 数据。如果是,我们返回 JSON。否则,我们返回 HTML。
我们通过两种方式检查客户端是否期望 JSON 格式。
首先,我们查看 req.params.format 是否为 json。使用这种资源路由中间件,在 URL 后添加 .:format 将该格式添加到 req.params 对象中作为格式值。换句话说,输入 URL localhost:3000/giftlist.json 会触发 giftlist_controller.index 路由,将格式参数设置为 json。
第二种方法是依赖于我们中间件设置的 req.isJSON 参数。
在下一章中,我们将将这些资源路由连接到我们数据库上的 CRUD 操作,并开始将数据渲染到页面上,以完善我们的 SPA。
摘要
在本章中,我们更详细地探讨了 Express,这是一个基于 Node.js HTTP 服务的 Node.js 网络应用程序框架。你了解到 Express 是一个在 Node.js HTTP 服务之上构建的极其灵活且无偏见的网络框架。
在其核心,Express 提供了对请求、响应、应用程序和路由对象的访问。使用这些对象,我们可以以复杂的方式操纵网络请求并做出响应。
主要使用 Express 意味着编写或使用中间件插件,请求通过这些插件流动。我们学习了如何使用这些插件,并编写了一些我们自己的实用中间件。我们详细研究了路由,并使用了一个资源路由插件来开始为我们的 SPA 构建 RESTful API。我们使 API 变得灵活,能够根据请求以 JSON 或 HTML 数据格式进行响应。
下一章将涵盖前端。具体来说,你将学习关于视图模板以及 AngularJS 的内容。
第十章。显示视图
大多数 SPA 的核心和灵魂是一个动态的前端。SPA 将与显示逻辑相关的许多繁重工作转移到浏览器上。现代浏览器拥有快速且强大的 JavaScript 引擎,可以处理比几年前多得多的计算。实际上,Node.js 是建立在 VB 引擎之上的,它是 Chrome 浏览器的一个标准部分。
然而,最重要的是,SPA 的主要思想是给用户提供接近桌面应用程序的体验。完整的页面加载已成为过去式,被状态中的快速变化所取代。
在本章中,我们将构建我们自己的 SPA 的核心。这将是一个用户可以构建 giftlists 并与其他用户分享的仪表板。我们还需要在后台构建更多路由和数据结构,但我们将专注于前端。我们将构建一个 Express 视图,该视图将加载 AngularJS - 一个专门为快速创建 SPA 而设计的 JavaScript 工具包。
我们将构建 AngularJS 路由、视图、服务和控制器,以实现 SPA 的核心功能。使用 AngularJS 插件 UI-router,我们将管理我们应用程序的状态。我们还将实现服务以与终端进行通信,以便数据可以在我们的应用程序中自由流动。
在本章中,我们将涵盖以下主题:
-
在 Express 中开发初始仪表板视图
-
实现 AngularJS
-
AngularJS 路由
-
使用 AngularJS
$resource访问 RESTful 端点
设置我们的仪表板
由于这是一个 SPA,我们需要设置一个单页来包含我们的应用程序。在我们的案例中,我们将构建一个用户仪表板。该仪表板将允许用户创建 giftlists(例如生日愿望清单),选择他们想要与之分享的人,并查看与他们分享的列表。在下一章中,我们将构建身份验证,以便单个用户只能看到他们自己的仪表板,但到目前为止,我们需要在没有身份验证的情况下进行一些模拟。
我们需要一些路由和一个视图。我们还将使用 Bootstrap 为我们的视图添加一些样式。
构建视图
我们需要为我们的仪表板创建一个视图。在您的视图目录中创建一个名为 dash 的新文件夹。在该文件夹内,创建一个名为 dashboard.ejs 的文件:
<!DOCTYPE html>
<html>
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
</head>
<body>
<h1><%= user.firstName %> <%= user.lastName %> Dashboard</h1>
<div>
<h2>My Lists</h2>
</div>
<div>
<h2>Lists Shared With Me</h2>
</div>
</body>
</html>
所以这里目前还没有什么特别激动人心的东西。我们已经设置了一些占位符,并假设我们将有一个 user 对象来显示。我们目前还看不到我们的视图 - 为了做到这一点,我们需要一个 route 来渲染视图。
让我们设置显示仪表板的 route。在您的 routes 目录中,创建一个名为 dashboard.js 的新文件:
var express = require('express');
var router = express.Router();
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.param('id', function(req, res, next, id) {
var db = req.db;
var collection = db.get('users');
collection.findOne({ "_id": id }, {}, function(err,User){
if(err){
res.send(err);
}else if(User){
req.user = User;
next();
} else {
res.send(new Error('User not found.'));
}
});
});
router.get('/:id', function(req, res, next){
res.render('dash/dashboard', {user: req.user});
});
module.exports = router;
我们在这里做了一些事情。首先,我们设置了我们的中间件来响应带有 id 参数的路由,就像我们为用户路由所做的那样。接下来,我们设置了一个用于显示仪表板的路由。
除非你记住了用户的 ID,否则测试我们的新视图将会很困难。让我们通过修改列出我们用户的视图来让它变得容易一些。打开views/users/show.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Show Users</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>User List: <%= appName %></h1>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email Address</th>
<th>Dashboard</th>
</tr>
</thead>
<tbody>
<% users.forEach(function(user, index){ -%>
<tr>
<td><a href="show/<%= user._id%> "><%= user.firstName %></a></td>
<td><%= user.lastName %></td>
<td><%= user.email %></td>
<td><a href="/dash/<%= user._id %>">View</a></td>
</tr>
<% }); %>
</tbody>
</table>
</body>
</html>
我们在我们的用户表中添加了一个新列,其中包含指向每个用户仪表板的链接。我们仍然不能显示我们的仪表板。我们必须修改我们的app.js文件:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp')
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
这里的两个关键变化是,我们导入了仪表板路由器,然后我们将任何对/dash的请求映射到该router。
确保你的 MongoDB 守护进程仍在运行,如果不是,请重新启动它。启动或重新启动你的服务器。导航到你的用户列表http://localhost:3000/users/show,然后点击表格右侧的一个view链接:

URL 应该看起来像这样:http://localhost:3000/dash/566dd0cb1c09d090fd36ba83。你应该看到一个看起来像这样的页面:

现在我们已经设置了一个视图模板和路由来显示页面。接下来我们需要做的是构建一些数据。
连接到初始数据
我们的应用程序将允许用户构建giftlists并与其他用户共享。我们想稍微考虑一下我们想要如何表示我们的数据。一个好的数据模型将为我们服务,即使我们添加和更改功能。
正如我们所学的,MongoDB 非常灵活,我们可以在文档中嵌套文档。这可能可行;我们可以让每个用户都有一个列表数组。问题是,我们的单个用户文档将非常可变,并且很容易增长到巨大的大小。如果我们想在将来做一些像共享列表这样的操作,这也不提供很多灵活性。
我们现在想要的关系类型是一对多关系。一个用户可以有多个列表。我们将通过在列表本身上存储拥有该列表的用户的引用来实现这一点。如果我们以后想要让多个用户共同拥有一个列表,这个更改将会非常直接。
我们想使用我们的giftapp数据库,并且我们将创建一个新的giftlists集合。在一个新的终端窗口中启动 MongoDB 命令行工具。注意,你需要复制你其中一个用户的精确ID,因为这与我的不同:
>use giftapp
switched to db giftapp
> db.giftlist.insert({'name':'Birthday List', 'gifts':[{'name':'ball'},
{'name':'pony'},{'name':'gift card'}], 'owner_id':
566dff161c09d090fd36ba85"})
WriteResult({ "nInserted" : 1 })
> db.giftlist.insert({'name':'Christmas List', 'gifts':[{'name':'TV'},
{'name':'Corvette'},{'name':'gift card'}], 'owner_id':
566dff161c09d090fd36ba85"})
WriteResult({ "nInserted" : 1 })
这里重要的是插入语句的格式。让我们稍微分解一下。
db.giftlist.insert({
'name':'Christmas List',
'gifts':[{'name':'TV'},{'name':'Corvette'},{'name':'gift card'}],
'owner_id': 566dff161c09d090fd36ba85")
}
})
我们将这个对象插入到giftlist集合中,如果它还不存在,将会创建。该对象有一个名称属性和一个gifts属性。gifts属性是一个包含名称属性的对象数组。
我们还有一个owner_id属性。这个属性是对giftlist所属用户的引用。它只是用户_id的字符串。由于 MongoDB 是一个非关系型数据库,我们将把它放在这里,以便在users集合中进行查找。
我们知道我们将通过所有者来查找信息,所以让我们添加一个索引:
> db.giftlist.ensureIndex({'owner_id':1})
{
"createdCollectionAutomatically" : false,
"numIndexesBefore" : 1,
"numIndexesAfter" : 2,
"ok" : 1
}
现在,让我们通过在命令行运行查询来看看我们得到了什么:
>db.giftlist.find({'owner':{'$ref':'users','$id':ObjectId('566dff161c09d090fd36ba85')}}).pretty()
{
"_id" : ObjectId("569bd08d94b6b374a00e8b49"),
"name" : "Birthday List",
"gifts" : [
{
"name" : "ball"
},
{
"name" : "pony"
},
{
"name" : "gift card"
}
],
"owner_id" : 566dff161c09d090fd36ba85"
}
{
"_id" : ObjectId("569bd0d794b6b374a00e8b4a"),
"name" : "Christmas List",
"gifts" : [
{
"name" : "TV"
},
{
"name" : "Corvette"
},
{
"name" : "gift card"
}
],
"owner_id" : "566dff161c09d090fd36ba85"
}
正如我们所期望的那样。
现在,让我们修改我们的dashboard.js路由:
var express = require('express');
var router = express.Router();
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
router.param('id', function(req, res, next, id)
{
var db = req.db;
var collection = db.get('giftlist');
collection.find({'owner_id':id}, {}, function(err,giftlists)
{
if(err){
res.send(err);
}else if(giftlists)
{
req.giftlists = giftlists;
collection = db.get('users');
collection.findOne({"_id":id}, function(err, user)
{
if(err){
res.send(err);
} else
{
req.user = user;
next();
}
});
} else {
res.send(new Error('User not found.'));
}
});
});
router.get('/:id', function(req, res, next){
res.render('dash/dashboard', {user: req.user, giftlists: req.giftlists});
});
module.exports = router;
我们已经修改了对router.param()的调用,以便根据传入的用户id搜索giftlists集合。如果我们得到了一个giftlist,我们接着搜索users集合以获取用户数据。
是的,这里有两个数据库调用。这是为了灵活性而牺牲性能的一点点权衡。记住,我们之前决定不在用户文档中嵌入giftlists。这种权衡是你自己应用中需要仔细思考的问题。
让我们也修改我们的dashboard.ejs视图模板:
<!DOCTYPE html>
<html>
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
</head>
<body>
<h1><%= user.firstName %> <%= user.lastName %> Dashboard</h1>
<div>
<h2>My Lists</h2>
<ul>
<% giftlists.forEach(function(giftlist, index){ -%>
<li><%= giftlist.name %></li>
<% }); %>
</ul>
</div>
<div>
<h2>Lists Shared With Me</h2>
</div>
</body>
</html>
现在我们有一个无序列表,它渲染了每个giftlists的名称。当我们开始添加 AngularJS 时,我们将链接每个这些到显示列表的状态。导航到用户仪表板页面,你应该看到如下内容:

现在我们有一个用户giftlists的列表以及与他们共享的列表的占位符。在不久的将来,当我们添加 AngularJS 时,我们也将添加添加、编辑和共享列表的代码。
目前,我们的仪表板有些丑陋。让我们稍微改进一下。
实现 Bootstrap
如果你之前没有听说过Bootstrap,它是一个非常流行的 CSS 框架。插入Bootstrap可以帮助前端开发者通过编写很少的手动代码来实现布局、绘制按钮和实现控件等功能。
你可以获取Bootstrap,并在getbootstrap.com查看其文档。
让我们稍微美化一下我们的dashboard.ejs模板:
<!DOCTYPE html>
<html>
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<ul class="list-unstyled">
<% giftlists.forEach(function(giftlist, index){ -%>
<li><a class="btn btn-link" href="#" role="button"><%= giftlist.name %></a></li>
<% }); %>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
</div>
</body>
</html>
在文档的头部,你会看到三行新内容。第一行是一个meta标签,它为移动设备设置了视口。接下来的两行从 CDN 加载了Bootstrap和Bootstrap主题。
我们然后将之前放在H1标签内的内容放入多个元素中,这将绘制页面顶部的导航栏。
下一个部分是一个具有container类的div元素。这对于Bootstrap布局是必要的。Bootstrap使用一个网格系统进行布局,包括行和列。基本上,在每一行中,有 12 个宽度相等的列。
类似于col-xs-12的类告诉Bootstrap,当视口非常小(如手机)时,该特定元素应占据容器的整个宽度。col-md-6类使得元素在屏幕宽度中等或更大时宽度为半宽(六个列)。通过组合这些类,我们可以有一个基于屏幕宽度的可变布局,这是所谓的响应式设计的主要组成部分。
全屏查看我们的仪表板,我们看到如下内容:

在全尺寸下,我们的仪表板分为两个等宽的列。您还可以看到我们的顶部nav栏正在渲染Mark Smith Dashboard。现在,如果您将浏览器的一侧拖动以使其变窄,就像手机屏幕一样,您会看到这个:

我们现在将列堆叠在一起,这对于移动设备格式来说更有意义。让我们添加一个按钮元素来添加新的列表,我们稍后会将其连接起来:
<!DOCTYPE html>
<html>
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<button class="btn btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</button>
<ul class="list-unstyled">
<% giftlists.forEach(function(giftlist, index){ -%>
<li><a class="btn btn-link" href="#" role="button"><%= giftlist.name %></a></li>
<% }); %>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
</div>
<script src="img/jquery.min.js"></script>
<script src="img/bootstrap.min.js" ></script>
</body>
</html>
我们添加了一个具有btn-primary类的按钮。在该按钮内部,我们有一个带有几个glyphicon类的 span。这些类实际上使用一种字体来绘制不同类型的常见符号。
查看我们的页面,我们会看到一个带有加号的漂亮蓝色按钮:

我们将开发更多的视觉组件作为 AngularJS 视图。
实现 AngularJS
现在是时候通过实现一个更健壮的 AngularJS 应用程序来实现我们的大部分视图逻辑了。我们首先需要做的是将 AngularJS 代码添加到我们的dashboard.ejs视图模板中:
<!DOCTYPE html>
<html ng-app>
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<button class="btn btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</button>
<ul class="list-unstyled">
<% giftlists.forEach(function(giftlist, index){ -%>
<li><a class="btn btn-link" href="#" role="button"><%= giftlist.name %></a></li>
<% }); %>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
</div>
</body>
</html>
我们在 CDN 上链接了 AngularJS 版本 1.4.8,以及一个名为 UI-router 的插件。我们将深入讨论 UI-router。我们还向打开的html标签添加了 AngularJS 指令ng-app。当 AngularJS 加载时,它会查找这个指令以确定它应该管理文档的哪个部分。大多数应用程序将通过这种方式从顶级开始由 Angular 管理,尽管可以将 AngularJS 引导到文档的任何部分。
我们的 AngularJS 模块
AngularJS 使用模块打包应用程序、应用程序的部分和依赖项。我们将使用 AngularJS 完成的每一件事都将通过使用模块或使用代码来完成,例如控制器,这些控制器已被添加到模块中。
这是 AngularJS 架构的核心部分。模块是应用程序部分的容器,并允许 AngularJS 正确地Bootstrap您的应用程序。
目前,我们的模块将保持简单,然后我们会随着进展逐步添加功能。在public/javascripts目录下创建一个名为giftapp.js的新文件:
var giftAppModule = angular.module('giftAppModule', ['ui.router']);
我们通过调用angular.module()函数来创建我们的模块。第一个参数是模块的名称。第二个参数是一个数组,包含我们想要注入到模块中的依赖项列表。在这种情况下,我们目前只注入了 UI-router。
现在,我们需要将我们的模块添加到我们的dashboard.ejs模板中:
<!DOCTYPE html>
<html ng-app="giftAppModule">
<head>
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<button class="btn btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</button>
<ul class="list-unstyled">
<% giftlists.forEach(function(giftlist, index){ -%>
<li><a class="btn btn-link" href="#" role="button"><%= giftlist.name %></a></li>
<% }); %>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
</div>
<script src="img/giftapp.js"></script>
</body>
</html>
我们只需使用一个普通的script标签来加载我们的模块。同时,我们更改了ng-app指令,使其将我们的新模块作为页面的主要应用程序入口点。
使用 UI-router 控制状态
在应用程序中,状态可以指很多事物,但在我们的 SPA 中,它指的是一组特定的视图、控制器和数据,可以通过 URL 更改器来调用。到目前为止,开发者处理 AngularJS 应用程序状态最流行的方式是使用一个名为 UI-router 的插件。
UI-router 插件允许我们优雅地控制状态,并且非常灵活。
让我们在应用程序中实现 UI-router。首先,我们将在dashboard.ejs模板中从 CDN 引用 UI-router:
<!DOCTYPE html>
<html ng-app="giftapp">
<head >
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div ui-view></div>
</div>
<script src="img/giftapp.js"></script>
</body>
</html>
我们在 CDN 上链接了 UI-router 并使用正常的script标签加载它。我们模板中的另一个主要变化是在div元素上实现了一个ui-view指令作为属性。ui-view指令告诉 UI-router 在哪里加载它将要绘制的视图。
下一步是编辑我们的giftapp.js应用程序文件以添加路由:
angular.module('giftapp', ['ui.router'])
.config(
['$stateProvider', '$urlRouterProvider',
function ($stateProvider, $urlRouterProvider) {
$urlRouterProvider
.otherwise('/dash');
$stateProvider
.state('dash', {
url:'/dash',
templateUrl: '/templates/dash-main.tpl.html'
})
.state('add', {
url:'/add',
templateUrl: '/templates/dash-add.tpl.html'
});
}]);
首先,我们确保将ui.router模块注入到我们的模块中。我们将一个config函数链接到我们的模块声明。使用数组表示法,我们将$stateProvider和$urlRouteprovider注入到config函数中。
在那个函数内部,魔法发生了。首先,我们调用$urlRouterProvider.otherwise('/dash');,这设置了默认路由。当我们加载页面时,除非另一个路由通过 URL 片段触发,否则#/dash将被附加到 URL 上。
接下来,我们在$stateProvider上设置两个状态。目前,每个状态都有一个名称、URL 和templateURL属性。模板 URL 指向一个用于加载的可视模板的 URL。
让我们模拟我们的两个模板。在public/templates下创建一个新的目录。
这是我们的dash-main.tpl.html:
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<a class="btn btn-primary" role="button" ui-sref="add" href="#/add">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</a>
<ul class="list-unstyled">
<li><a class="btn btn-link" href="#" role="button">Angular Router List</a></li>
<li><a class="btn btn-link" href="#" role="button">Angular Router List 2</a></li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
这是我们的dash-add.tpl.html文件:
<div class="row">
<div class="col-md-12">
<h2>Add a new list</h2>
<form class="form-horizontal">
<div class="form-group">
<label for="listname" class="col-sm-2 control-label">List Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="listname" placeholder="Name">
</div>
</div>
<div class="form-group">
<label for="item[]" class="col-sm-2 control-label">Item 1</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="Item[]">
</div>
</div>
<div class="form-group">
<label for="item[]" class="col-sm-2 control-label">Item 1</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="Item[]">
</div>
</div>
<div class="form-group">
<label for="item[]" class="col-sm-2 control-label">Item 1</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="Item[]">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<a href="#/dash" class="btn btn-default">
Save
</a>
</div>
</div>
</form>
</div>
</div>
在这里,我们模拟了一个可以用来添加新列表的表单。我们稍后会完善它,并将其实际连接到后端以存储数据。
AngularJS 控制器
目前,我们的模板基本上只是简单的 HTML,使用 AngularJS 方法将我们的 DOM 与数据和功能链接起来。控制器包含业务逻辑,但不应该直接用于操作 DOM。
在使用 UI-router 时,我们可以轻松地将控制器附加到状态,使它们的$scope可用于我们的视图。
在public/javascripts内部创建一个新的控制器文件夹。创建一个名为dashMainController.js的新 JavaScript 文件:
angular.module('giftappControllers',[])
.controller('DashMainController', ['$scope', function($scope) {
$scope.lists = [{'name':'Christmas List'}, {'name':'Birthday List'}];
}]);
我们创建了一个名为giftAppControllers的新模块,它没有依赖项。然后,我们构建了一个名为DashMainController的控制器。使用数组表示法,我们注入$scope然后声明一个构造函数。
在那个函数内部,我们将一个列表数组附加到$scope上,这将使它可用于任何引用此控制器的视图。
接下来,我们需要将那个文件加载到dashboard.ejs视图模板中:
<!DOCTYPE html>
<html ng-app="giftapp">
<head >
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div ui-view></div>
<!-- div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<a class="btn btn-primary" role="button" ui-sref="add">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</a>
<a class="btn btn-primary" role="button" ui-sref="dash">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</a>
<ul class="list-unstyled">
<% giftlists.forEach(function(giftlist, index){ -%>
<li><a class="btn btn-link" role="button"><%= giftlist.name %></a></li>
<% }); %>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div -->
</div>
<script src="img/giftapp.js"></script>
<script src="img/dashMainController.js"></script>
</body>
</html>
你会注意到你可以在主模块之后加载控制器模块。
接下来,我们需要编辑我们的主要giftapp.js模块以使用新的控制器作为路由的一部分:
angular.module('giftapp', ['ui.router', 'giftappControllers' ])
.config(
['$stateProvider', '$urlRouterProvider',
function ($stateProvider, $urlRouterProvider) {
$urlRouterProvider
.otherwise('/dash');
$stateProvider
.state('dash', {
url:'/dash',
templateUrl: '/templates/dash-main.tpl.html',
controller: 'DashMainController'
})
.state('add', {
url:'/add',
templateUrl: '/templates/dash-add.tpl.html',
});
}]);
我们首先将新的控制器模块注入到我们的giftapp模块中。这使得DashMainController在模块中可用。然后,我们将它的名称(作为一个字符串)设置为dash状态的控制器属性。
我们最后应该做的事情是修改我们的模板,以便利用我们新的控制器。控制器中添加到$scope的任何方法或属性都将在视图中可用。
这是我们的新dash-main.tpl.html:
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<a class="btn btn-primary" role="button" ui-sref="add" href="#/add">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</a>
<ul class="list-unstyled">
<li ng-repeat="list in lists"><a class="btn btn-link" href="#" role="button">{{ list.name }}</a></li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
我们不依赖于预制的列表项,而是依赖于由 AngularJS 本身提供的ng-repeat指令。ng-repeat指令遍历可迭代的事物 - 在这种情况下是一个名为 list 的数组。对于数组的每个成员,指令将绘制一个li元素,并将实例分配给变量 list(本质上创建了一个新的作用域)。由于我们的列表对象都有 name 属性,我们可以在标记表达式中通过{{list.name}}访问它。
确保我们的数据库和服务器正在运行,刷新我们的仪表板应该看起来像这样:

圣诞清单和生日清单来自我们新控制器中的$scope。点击添加清单按钮将我们带到添加状态,页面突然看起来像这样:

因此,我们现在已经拥有了一个单页 Web 应用程序的核心。我们有模型、视图和控制器。我们有管理状态的方法。
在模拟这个功能时,我们确实移除了与数据库的连接。所以,让我们按照 AngularJS 的方式将其添加回来。
与后端通信
因此,我们现在需要将我们的前端连接到后端。我们不想在页面加载时渲染数据,而是想使用 AJAX 进行连接并执行所有的 CRUD 操作。幸运的是,Angular 有一个相当优雅的方式来处理这个问题。
创建一个 AngularJS 工厂
假设我们的应用程序的不同部分可能需要访问一些相同的数据端点,或者一些其他功能。一个处理这个问题的好方法是使用 AngularJS 提供者。提供者本质上是一个可注入的单例,并且有多个选项可用 - 请参阅docs.angularjs.org/guide/providers。
我们将要使用的提供者类型是一个工厂。让我们首先在我们的public/javascripts目录内创建一个services目录。在那个目录内创建一个名为giftlistFactory.js的新文件:
angular.module('giftlistServices', [])
.factory('List', function(){
return {}
});
我们为服务创建了一个新的模块,并在该模块上创建了一个名为List的工厂。这个工厂目前还没有做什么,但我们会做到这一点。
接下来,我们将使用dashboard.ejs模板中的script标签来加载这个文件:
<!DOCTYPE html>
<html ng-app="giftapp">
<head >
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div ui-view></div>
</div>
<script src="img/giftapp.js"></script>
<script src="img/dashMainController.js"></script>
<script src="img/giftlistFactory.js"></script>
</body>
</html>
现在我们已经加载了这个模块,我们可以将其注入到我们的控制器中。打开dashMainController.js并编辑以下内容:
angular.module('giftappControllers',['giftlistServices'])
.controller('DashMainController', ['$scope','List', function($scope,List) {
$scope.lists = [{'name':'Christmas List'}, {'name':'Birthday List'}];
}]);
我们将giftlistServices模块注入到我们的giftappControllers模块中。在我们的DashMainController中,我们注入了List工厂。目前,List只返回一个空对象,但未来放入那里的任何内容都将对控制器可用。
使用 AngularJS $resource
开发 AngularJS 的聪明人意识到,在 SPA 中人们想要做很多事情都是与 RESTful 服务进行交互。他们有了在他们的$http服务(提供 AJAX 功能)之上构建工厂的想法,这将提供一个与 RESTful 接口交互的简单方法。这正是$resource所做的事情。
我们将首先加载ngResource模块,该模块公开了$resource。在我们的dashboard.ejs模板中,添加一个script标签以从 CDN 加载模块:
<!DOCTYPE html>
<html ng-app="giftapp">
<head >
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
<script src="img/angular-resource.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div ui-view></div>
</div>
<script src="img/giftapp.js"></script>
<script src="img/dashMainController.js"></script>
<script src="img/giftlistFactory.js"></script>
</body>
</html>
现在我们已经加载了模块,让我们编辑我们的工厂以利用$resource。打开giftlistFactory并做出以下编辑:
angular.module('giftlistServices', ['ngResource'])
.factory('List', function($resource){
return $resource('/giftlist/:id',{id: '@_id'})
});
你可以看到我们在我们的模块中注入了ngResource模块。这使我们能够将$resource注入到我们的List工厂中。最后,我们返回调用$resouce并带有路径/giftlist/:id的结果。这与第二个参数结合,设置了一系列可选地包含id的函数。
记得我们之前构建的资源丰富的控制器吗?现在我们将使用一些硬编码的数据进行编辑。打开controllers/giftlist_controller.js:
exports.index = function(req, res){
var db = req.db;
var collection = db.get('giftlist');
collection.find({'owner_id':'566dd0cb1c09d090fd36ba83'}, {}, function(err,giftlists){
if(err){
res.send(err);
}else if(giftlists){
res.json(giftlists);
};
});
};
目前,只需编辑index。你可以看到我已经硬编码了查询的owner_id以匹配我在数据库中工作的用户。你应该相应地匹配你的user id,因为它将不同于我的。
现在,编辑你的dashMainController.js文件:
angular.module('giftappControllers',['giftlistServices'])
.controller('DashMainController', ['$scope','List', function($scope,List) {
$scope.lists = List.query();
}]);
我们将$scope.lists的值设置为对List资源进行查询的结果。在这种情况下,结果是对象数组。如果你重新启动你的服务器然后重新加载页面,你会看到这个:

摘要
在这一章中,你构建了 SPA UI 方面的主要部分。你首先在 Express 中构建了一个视图。你包括了Bootstrap以进行一些简单的样式、布局和响应式设计。然后你重构了页面以利用 AngularJS。
你使用 AngularJS 设置了模块、路由、模板和一个控制器。然后你构建了一个工厂并将$resource注入其中。你开始从 RESTful 端点访问数据,然后通过将数据映射到控制器中的$scope来在应用程序中显示这些数据。
第十一章。添加安全和身份验证
在前面的章节中,我们模拟了用户,以便我们可以测试各种功能,但显然这不是我们希望应用程序继续工作的方式。我们希望只有授权用户才能添加和编辑他们的列表并与他人分享。我们的应用程序目前安全性不高。
身份验证几乎是每个 Web 应用程序的基本功能。我们有一个很好的选项来管理用户注册、登录和访问受保护的路由。我们将为 Node.js 安装 Passport 身份验证中间件,对其进行本地身份验证配置,并设置会话管理。我们将保护仪表板路由,以确保只有经过身份验证的用户才能看到自己的仪表板。
在本章中,我们将使用 Node.js 和 Express 中间件通过防止常见的漏洞,如跨站请求伪造(CSRF)来保护我们的单页应用(SPA)。我们还将讨论在部署期间我们将处理的其他安全关注点。
本章将涵盖以下主题:
-
设置 Passport 以实现用户身份验证
-
创建用于注册和登录的本地身份验证策略
-
使用 Mongoose 模型用户
-
保护路由
-
在我们的应用程序中添加安全头并防止 CSRF 攻击
使用 Passport 添加身份验证
Passport 是一个具有单一目的的 Node.js 插件,即验证请求。也就是说,确保只有登录的用户和应该能够发出某些请求的人才能这样做。身份验证是每个 Web 应用程序的基本安全功能,包括单页应用(SPAs)。
Passport 非常灵活,允许通过多种不同的方式实现身份验证,这些方式被称为策略。策略包括使用简单的用户名和密码登录,或者使用OAuth通过 Facebook 或 Twitter 登录。Passport 提供了超过 100 种不同的策略,我们应该用于身份验证。本章将重点介绍本地身份验证策略,而下一章将集成社交媒体策略。
与大多数与 Express 一起使用的插件一样,Passport 是中间件,因此它的使用对我们来说很熟悉。这是一个很好的架构,因为它在我们的应用程序中保持了关注点的分离。
安装 Passport
我们需要做的第一件事是安装 Passport。获取实际的 Passport 模块非常简单,我们只需使用 npm 安装它:
$ npm install passport --save
passport@0.3.2 node_modules/passport
|- pause@0.0.1
|_ passport-strategy@1.0.0
我们还需要单独安装我们将为 Passport 使用的每个策略:
$ npm install passport-local -save
passport-local@1.0.0 node_modules/passport-local
|_ passport-strategy@1.0.0
我们最不需要的就是一些中间件来管理用户会话。为此,我们将安装express-session包:
$ npm install express-session -save
express-session@1.13.0 node_modules/express-session
|- utils-merge@1.0.0
|- cookie-signature@1.0.6
|- parseurl@1.3.1
|- cookie@0.2.3
|- on-headers@1.0.1
|- depd@1.1.0
|- crc@3.4.0
|- uid-safe@2.0.0 (base64-url@1.2.1)
接下来,我们需要将 Passport 添加到我们的应用程序中。打开我们的主 app.js 文件并做出以下修改:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp');
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var passport = require('passport');
var expressSession = require('express-session');
app.use(expressSession({secret: 'someKeyYouPick'}));
app.use(passport.initialize());
app.use(passport.session());
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
在任何路由之前,确保我们引入并初始化 Passport 非常重要,以确保我们的身份验证对路由可用。在初始化expressSession时,设置一个与我在这里给出的不同的密钥。它可以是任何字符串。
我们几乎准备好了。Express 本地策略假定存储在 MongoDB 数据库中的用户。我们已经有了一个带有用户表的 MongoDB 数据库,但我们确实需要强制一致性,并且有一个简单的方式来建模我们的数据。
使用 Mongoose 配置数据库
我们将使用一个名为 Mongoose 的包。Mongoose 是 Node.js 的数据建模工具,在 Express 包中广泛使用。在我们之前直接访问数据库的地方,我们现在将让 Mongoose 为我们做很多繁重的工作。
安装和配置 Mongoose
与其他模块一样,我们将使用npm来安装mongoose:
$ npm install mongoose -save
mongoose@4.3.7 node_modules/mongoose
|- ms@0.7.1
|- regexp-clone@0.0.1
|- hooks-fixed@1.1.0
|- async@0.9.0
|- mpromise@0.5.4
|- mpath@0.1.1
|- muri@1.0.0
|- sliced@0.0.5
|- kareem@1.0.1
|- bson@0.4.21
|- mquery@1.6.3 (bluebird@2.9.26)
|_ mongodb@2.1.4 (es6-promise@3.0.2, readable-stream@1.0.31, kerberos@0.0.18, mongodb-core@1.2.32)
现在我们将在app.js文件中添加初始化 Mongoose 的代码:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp');
var mongoose = require('mongoose');
mongoose.connect('localhost:27017/giftapp');
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var passport = require('passport');
var expressSession = require('express-session');
app.use(expressSession({secret: 'mySecretKey'}));
app.use(passport.initialize());
app.use(passport.session());
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
在这里,我们引入 Mongoose 库,并用我们本地数据库的 URL 初始化它。
创建用户模型
Mongoose 使用预定义的数据模型来验证、存储和访问 MongoDB 数据库。我们需要创建一个将代表我们的用户文档的模型。在我们的db中已经有一个users集合,所以让我们将其删除,以避免任何冲突或混淆。
确保你有一个 Mongo daemon 正在运行的终端窗口打开。如果没有,只需打开一个新的终端并输入mongod来启动它。在第二个终端窗口中,通过输入mongo来启动 MongoDB 命令行工具。
一旦运行起来,输入以下内容:
> use giftapp
switched to db giftapp
> show collections
giftapp
giftlist
system.indexes
test
users
> db.users.drop()
true
> show collections
giftapp
giftlist
system.indexes
test
我们确保使用的是礼品应用数据库。然后我们运行showcollections来列出集合,并看到有一个users集合。我们运行db.users.drop()集合方法来删除该集合。然后我们再次显示集合,以检查用户集合是否已被移除。
完成这些后,创建一个名为 models 的新文件夹。在该文件夹内,创建一个名为user.js的文件:
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
module.exports = mongoose.model('User', new Schema({
id: String,
username: String,
email: String,
password: String,
firstName: String,
lastName: String
}));
在文件顶部引入 mongoose,然后使用mongoose.model()函数创建一个名为User的模型。该函数接受一个字符串,该字符串成为模型名称,以及一个对象,该对象代表实际的模型。在我们的情况下,我们有一个id、username、email、password、firstName和lastName,每个都被定义为字符串。Mongoose 将确保我们数据库中存储的每个User文档都符合此格式定义。
注意
Passport 本地策略的默认设置是存在一个username和password字段。如果你只想使用email和password或某种其他方案,这可以更改。
设置 Passport 策略
现在我们必须设置一个本地 Passport 策略。我们需要扩展这个策略来处理用户登录和注册。
初始化 Passport
创建一个名为passport的新目录。创建一个名为init.js的文件:
var User = require('../models/user');
module.exports = function(passport){
passport.serializeUser(function(user, done) {
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
}
这段代码给 Passport 提供了访问我们的User模型。序列化和反序列化函数用于从数据库中查找用户(反序列化)以及将用户信息存储到User的会话中(序列化)。
现在让我们使用主app.js文件中的init函数来初始化 Passport:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var mongoose = require('mongoose');
mongoose.connect('localhost:27017/giftapp');
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var flash = require('connect-flash');
app.use(flash());
var passport = require('passport');
var expressSession = require('express-session');
app.use(expressSession({secret: 'mySecretKey'}));
app.use(passport.initialize());
app.use(passport.session());
var initializePassport = require('./passport/init');
initializePassport(passport);
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
在我们的init文件中,我们需要将导出的函数分配给变量名initializePassport,然后我们通过传递一个 Passport 实例来调用该函数。
我们还在 Passport 代码之前添加了一个新的库,connect-flash。这允许我们在会话中存储闪存消息,如无效密码,并将它们传递回显示给视图。我们需要使用npm安装此软件:
$ npm install connect-flash --save
connect-flash@0.1.1 node_modules/connect-flash
创建注册策略
现在让我们构建并要求注册策略。
首先,我们需要添加一个用于散列用户密码的库,这样我们就不在我们的数据库中存储未加密的密码,这可不是什么好消息。我们将使用一个名为bycrypt-nodejs的模块,它可以通过npm轻松安装:
$ npm install bcrypt-nodejs --save
bcrypt-nodejs@0.0.3 node_modules/bcrypt-nodejs
在你的passport目录中,创建一个名为signup.js的新文件:
var LocalStrategy = require('passport-local').Strategy;
var User = require('../models/user');
var bCrypt = require('bcrypt-nodejs');
module.exports = function(passport){
passport.use('signup', new LocalStrategy({
passReqToCallback : true
},
function(req, username, password, done) {
//this is asynchronous
process.nextTick(function () {
console.log('inside signup');
// see if user already exists
User.findOne({'username': username}, function (err, user) {
if (err) {
console.log('Error in SignUp: ' + err);
return done(err);
}
// user exists
if (user) {
console.log('User already exists');
return done(null, false, req.flash('message', 'User
Already Exists'));
} else {
//create a new User and store to the
database
var user = new User();
user.username = username;
user.email = req.param('email');
user.password =
bCrypt.hashSync(password,
bCrypt.genSaltSync(10), null);
user.firstName =
req.param('firstName');
user.lastName = req.param('lastName');
user.save(function (err) {
if (err) {
console.log('save error ' +
err);
throw err;
}
console.log("saving")
return done(null, user);
});
}
});
});
})
);
}
我们需要我们需要的模块,其中包括对 Mongoose User模块的引用。我们通过调用passport.use()来设置策略。第一个参数是策略的名称,在这种情况下,是signup。下一个参数是构造一个新的LocalStrategy的调用。
这个调用接收一个对象,在这个例子中包含passReqToCallback = true。这使得请求对象对回调函数可用,即下一个。这对于我们获取注册信息非常重要。
回调函数设置了一个名为newSignup的新函数,它做了大部分工作。我们首先搜索是否存在具有指定用户名的用户。如果存在,我们退出并设置一个闪存消息,表明用户已存在。如果用户不存在,我们创建一个新的用户。最后,我们将函数传递给 Node.js 事件循环的下一个 tick 来执行。
你会注意到,由于这个调用的异步性质,实际的回调功能是在process.nextTick()的调用中执行的。
现在让我们编辑我们的init.js文件,包括并初始化我们的注册策略:
var signup = require('./signup');
var User = require('../models/user');
module.exports = function(passport){
passport.serializeUser(function(user, done) {
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
signup(passport);
}
我们只需在我们的init函数中调用我们的注册模块,并调用导出的函数。
创建登录策略
因此,现在我们有了创建用户的注册策略,我们需要一个策略让用户登录。在passport目录中创建一个新文件,命名为signin.js:
var LocalStrategy = require('passport-local').Strategy;
var User = require('../models/user');
var bCrypt = require('bcrypt-nodejs');
module.exports = function(passport){
passport.use('login', new LocalStrategy({
passReqToCallback : true
},
function(req, username, password, done) {
User.findOne({ 'username' : username },
function(err, user) {
if (err)
return done(err);
if (!user){
// username not found
return done(null, false, req.flash('message', 'Username
or password incorrect.'));
}
if (!bCrypt.compareSync(password, user.password)){
//password is invalid
return done(null, false, req.flash('message', 'Username
or password incorrect.'));
}
//success condition
return done(null, user);
}
);
})
);
}
再次,我们在依赖项中需要。然后我们创建并导出一个函数,当调用时,创建一个新的用于登录的 Passport 策略。
我们首先做的事情是查询数据库,看看是否存在具有我们username的用户。如果用户不存在,我们在闪存中设置一个错误消息,并返回done函数的结果,第二个参数为 false。
假设我们匹配了username,下一步是使用bCrypt.compareSync()函数来检查传入的密码是否与数据库中用户的散列密码匹配。如果不匹配,我们再次在闪存中设置错误消息,然后返回done,第二个参数为false。
最后,假设username返回一个user,并且password匹配,我们只需通过返回done来认证,第二个参数是user。
现在,我们将在init.js文件中加载和初始化登录策略:
var signup = require('./signup');
var login = require('./login');
var User = require('../models/user');
module.exports = function(passport){
passport.serializeUser(function(user, done) {
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
signup(passport);
login(passport);
}
就像注册策略一样,我们只需引入登录策略模块然后调用导出的函数。
创建认证路由
现在我们已经设置了 Passport,我们还不能注册或登录用户。我们需要设置路由和视图来渲染注册和登录体验。
在您的routes文件夹中创建一个新的文件,命名为login.js:
var express = require('express');
var router = express.Router();
module.exports = function(passport){
router.get('/', function(req, res) {
res.render('login/login', { message: req.flash('message') });
});
router.post('/', passport.authenticate('login', {
successRedirect: '/dash',
failureRedirect: '/login',
failureFlash : true
}));
router.get('/signup', function(req, res){
res.render('login/signup',{message: req.flash('message')});
});
router.post('/signup', passport.authenticate('signup', {
successRedirect: '/dash',
failureRedirect: '/login/signup',
failureFlash : true
}));
router.get('/signout', function(req, res) {
req.logout();
res.redirect('/login');
});
return router;
}
如您所见,我们导出了一个设置登录、注册和注销路由的函数。当函数被调用时,我们期望传递一个passport实例。当用户成功登录时,他们将被重定向到/dash路径。
现在让我们将路由添加到我们的主app.js文件中:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
//Database stuff
var mongodb = require('mongodb');
var mongoose = require('mongoose');
mongoose.connect('localhost:27017/giftapp');
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var flash = require('connect-flash');
app.use(flash());
var passport = require('passport');
var expressSession = require('express-session');
app.use(expressSession({secret: 'mySecretKey'}));
app.use(passport.initialize());
app.use(passport.session());
var initializePassport = require('./passport/init');
initializePassport(passport);
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
var login = require('./routes/login')(passport);
app.use('/login', login);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
您会注意到我们必然需要将 Passport 的引用传递给路由。
创建认证视图
现在我们已经有了注册和登录的路由,我们需要视图来渲染并显示user。
在您的视图目录中创建一个登录文件夹。在该文件夹中,创建一个新的模板,命名为signup.ejs:
<!DOCTYPE html>
<html>
<head >
<title>Signup</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"> Giftapp Signup</a>
</div>
</div>
</nav>
<div class="container">
<% if(message){ %>
<div class="row">
<div class="col-md-4 col-md-offset-4" role="alert">
<%= message %>
</div>
</div>
<% } %>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form method="post" action="/login/register">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username"
name="username" placeholder="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password"
name="password" placeholder="Password">
</div>
<div class="form-group">
<label for="email">Email address</label>
<input type="email" class="form-control" id="email"
name="email" placeholder="Email">
</div>
<div class="form-group">
<label for="firstName">First Name</label>
<input type="text" class="form-control" id="firstName"
name="firstName" placeholder="First Name">
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input type="text" class="form-control" id="lastName"
name="lastName" placeholder="Last Name">
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
</div>
</div>
</div>
</body>
</html>
这是一个相当标准的注册表单,我们使用了一些Bootstrap类来使界面更美观。如果您启动服务器并导航到http://localhost:3000/login/signup,您将看到以下内容:

如果您填写了表单——确保至少有username和password-,您应该创建一个user并重定向到/dash URL。目前看起来并不令人印象深刻,但如下所示:

现在,如果您启动 MongoDB 命令行并查看,您将再次看到一个users集合。Mongoose 为我们自动创建了它。如果您创建了一些用户,您可以看到他们在这里:
> db.users.find({}).pretty()
{
"_id" : ObjectId("56ae11990d7ca83f048f3c2a"),
"lastName" : "Moore",
"firstName" : "John",
"password" :
"$2a$10$OFhNNsA5MKrWCyFG9nATq.CIpTYZ5DH.jr8FnJYYFzgH7P4qM5QZy",
"email" : "john@johnmoore.ninja",
"username" : "ninja",
"__v" : 0
}
{
"_id" : ObjectId("56ae18d10d7ca83f048f3c2b"),
"lastName" : "Blanks",
"firstName" : "Billy",
"password" :
"$2a$10$NZQz8Nq4hBjSuU5yvO1Lnen.sy.sxEWwht0nPrIlP3aKC0jUrgSTq",
"email" : "billy@fakeemailaddress.com",
"username" : "billy",
"__v" : 0
}
您可以看到我们有两个用户。我使用注册表单创建了这些用户。他们的密码散列只存储在数据库中。为了在生产环境中完善注册,您至少想要验证用户的email地址,但出于开发目的,我们能够随意创建假账户会更容易一些。
现在我们需要一个用于登录的表单。在您的views/login目录中创建一个新的login.ejs模板:
<!DOCTYPE html>
<html>
<head >
<title>Signup</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-
theme.min.css">
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"> Giftapp Login</a>
</div>
</div>
</nav>
<div class="container">
<% if(message){ %>
<div class="row">
<div class="col-md-4 col-md-offset-4" role="alert">
<%= message %>
</div>
</div>
<% } %>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form method="post" action="/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username"
name="username" placeholder="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password"
name="password" placeholder="Password">
</div>
<button type="submit" class="btn btn-default">Submit</button>
</form>
</div>
</div>
</div>
</body>
</html>
访问http://localhost:3000/login会给我们一个如下所示的页面:

使用正确的用户凭据登录将带你到我们仍然无聊的/dash路由。使用错误的凭据登录将返回到登录路由,并填充闪存消息:

认证请求
我们应用程序的主要部分是用户仪表板,用户将能够创建giftlists。之前,我们会通过在仪表板 URL 中传递用户id来访问用户的仪表板。显然,这里没有认证,这不是一种安全的方式。
现在我们希望用户在查看自己的仪表板之前先登录。如果他们直接访问仪表板 URL,他们应该被重定向到登录页面。
我们将按照典型的 Express 风格来处理这个问题,通过编写一个中间件来处理为路由添加认证。
添加认证检查中间件
Passport 为我们提供了会话访问权限,以检查用户是否当前已认证。我们可以使用这个功能轻松地保护应用程序的整个部分,或者按路由逐个添加认证。
在你的utils目录下创建一个名为authenticated.js的新文件:
var authenticated = function (req, res, next) {
if (req.isAuthenticated()){
return next();
} else {
res.redirect('/login');
}
}
module.exports = authenticated;
我们的认证函数设置了所有 Express 中间件的签名——带有请求、响应和next参数。我们检查请求对象的isAuthenticied()函数的返回值——这是 Passport 为我们提供的。
如果我们已认证,我们只需通过调用next()将请求传递下去。如果我们未认证,我们将请求重定向到/login路由,渲染我们的登录页面。
将中间件插入路由中
接下来,我们想在需要使用新中间件的地方插入它,在我们的仪表板路由文件中:
var express = require('express');
var router = express.Router();
var isAuthenticated = require('../utils/authenticated');
router.get('/', isAuthenticated, function(req, res, next) {
res.send('respond with a resource');
});
router.param('id', function(req, res, next, id) {
var db = req.db;
var collection = db.get('giftlist');
collection.find({'owner_id':id}, {}, function(err,giftlists){
if(err){
res.send(err);
}else if(giftlists){
req.giftlists = giftlists;
collection = db.get('users');
collection.findOne({"_id":id}, function(err, user){
if(err){
res.send(err);
} else {
req.user = user;
next();
}
});
} else {
res.send(new Error('User not found.'));
}
});
});
router.get('/:id', function(req, res, next){
res.render('dash/dashboard', {user: req.user, giftlists: req.giftlists});
});
module.exports = router;
我们将新模块导入到路由器文件中,将其分配给isAuthenticated变量。接下来,我们将中间件添加到主路由。
重新启动你的服务器应该会注销你。如果你想在不需要重新启动服务器的情况下注销,你可以访问http://localhost:3000/login/signout的注销路由。然后你可以尝试访问/dash,你将被重定向回登录页面。以有效用户身份重新登录将把你重定向到仪表板,并且可以正确渲染。
修改仪表板路由
之前,我们设置了dash/:id路由,使用param函数查找用户。这已经不再适合我们的需求。我们想要的是在用户登录后显示认证用户的仪表板。幸运的是,Passport 已经为我们缓存了会话中需要的用户数据,所以我们每次渲染仪表板时不需要查找用户。
让我们对dashboard路由器做一些修改:
var express = require('express');
var router = express.Router();
var isAuthenticated = require('../utils/authenticated');
router.get('/', isAuthenticated, function(req, res, next) {
var db = req.db;
var collection = db.get('giftlist');
collection.find({'owner_id':req.user._id}, {}, function(err,giftlists){
if(err){
res.send(err);
}else {
giftlists = giftlists || [];
res.render('dash/dashboard', {user: req.user, giftlists: giftlists});
}
});
});
module.exports = router;
现在我们稍微简化了代码。我们的/:id路由已经不存在了,剩下的唯一路由是主路由,它由对/dash的 GET 请求触发。
由于 Passport,我们已经在请求中缓存了用户数据,因此节省了我们一次数据库查询,有助于我们的代码性能提升。然后我们查找该用户拥有的礼品清单,接着渲染我们之前构建的仪表板模板,其中包含我们的单页应用前端。
我们得到以下结果:

因此,我们已经认证了用户并将user对象存储在会话中,使其在请求期间可用。
保护 Express
认证可能是从前端保护 Web 应用程序最重要的且最具挑战性的主题之一。当然,有很多不同的威胁向量。
Helmet
我们可以采取的最简单的方法之一来保护我们的 Express 应用是安装并使用名为 Helmet 的安全中间件。Helmet 添加了多个安全头和策略,以及防止一些攻击,例如clickjacking。
它在幕后做了大部分工作,无需我们进行配置。有关更详细的信息,以及找到其他配置方式,请参阅。
要开始使用 Helmet,首先使用npm安装它:
$ npm install helmet --save
helmet@1.1.0 node_modules/helmet
|- nocache@1.0.0
|- hide-powered-by@1.0.0
|- dont-sniff-mimetype@1.0.0
|- dns-prefetch-control@0.1.0
|- ienoopen@1.0.0
|- hpkp@1.0.0
|- x-xss-protection@1.0.0
|- hsts@1.0.0 (core-util-is@1.0.2)
|- frameguard@1.0.0 (lodash.isstring@3.0.1)
|- connect@3.4.0 (parseurl@1.3.1, utils-merge@1.0.0, finalhandler@0.4.0)
|_ helmet-csp@1.0.3 (lodash.isfunction@3.0.6, platform@1.3.0, camelize@1.0.0, lodash.assign@3.2.0, content-security-policy-builder@1.0.0, lodash.reduce@3.1.2, lodash.some@3.2.3)
您实际上可以通过子模块的名称看到一些保护措施的内容。
接下来,我们只需将模块添加到我们的主app.js文件中:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');
var isJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
var helmet = require('helmet');
//Database stuff
var mongodb = require('mongodb');
var monk = require('monk');
var db = monk('localhost:27017/giftapp');
var mongoose = require('mongoose');
mongoose.connect('localhost:27017/giftapp');
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var flash = require('connect-flash');
app.use(flash());
var passport = require('passport');
var expressSession = require('express-session');
app.use(expressSession({secret: 'mySecretKey'}));
app.use(passport.initialize());
app.use(passport.session());
var initializePassport = require('./passport/init');
initializePassport(passport);
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use('helmet');
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
var login = require('./routes/login')(passport);
app.use('/login', login);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
由此,我们已经缓解了大量常见的 Web 安全漏洞。
请注意,app.use('helmet')必须在任何路由之前,否则这些路由的保护将不会生效。
CSRF
最常见的 Web 攻击向量之一是跨站请求伪造(CSRF)。CSRF 是一种攻击,其中一些不受信任的来源,如另一个网站或甚至一封电子邮件,利用用户的认证状态在另一个应用程序上执行特权代码。您可以在www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)找到有关 CSRF 的更多详细信息。
再次,中间件来拯救:
$ npm install csurf --save
csurf@1.8.3 node_modules/csurf
|- cookie@0.1.3
|- cookie-signature@1.0.6
|- http-errors@1.3.1 (inherits@2.0.1, statuses@1.2.1)
|_ csrf@3.0.1 (rndm@1.2.0, base64-url@1.2.1, scmp@1.0.0, uid-safe@2.1.0)
然后,以相同的方式将csurf中间件插入到我们的app.js中,需要 Helmet 并在使用它之前。请注意,app.use('csurf')必须在cookie parser和express-session中间件之后,因为它使用这两个中间件。
接下来,我们编辑我们的login路由文件:
var express = require('express');
var router = express.Router();
module.exports = function(passport){
router.get('/', function(req, res) {
res.render('login/login', { message: req.flash('message'), csrfToken:
req.csrfToken() });
});
router.post('/', passport.authenticate('login', {
successRedirect: '/dash',
failureRedirect: '/login',
failureFlash : true
}));
router.get('/signup', function(req, res){
console.log('signing up');
res.render('login/signup',{message: req.flash('message'), csrfToken:
req.csrfToken()});
});
router.post('/register', passport.authenticate('signup', {
successRedirect: '/dash',
failureRedirect: '/login/signup',
failureFlash : true
}));
router.get('/signout', function(req, res) {
req.logout();
res.redirect('/login');
});
return router;
}
我们将 CSRF 令牌添加到传递给login和signup页面的数据中。
接下来,我们在login和signup页面添加一个隐藏字段:
<!DOCTYPE html>
<html>
<head >
<title>Signup</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"> Giftapp Signup</a>
</div>
</div>
</nav>
<div class="container">
<% if(message){ %>
<div class="row">
<div class="col-md-4 col-md-offset-4" role="alert">
<%= message %>
</div>
</div>
<% } %>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form method="post" action="/login/register">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control"
id="username" name="username" placeholder="username">
</div>
<div class="form-group">
<label for="passwordd">Password</label>
<input type="password" class="form-control" id="password"
name="password" placeholder="Password">
</div>
<div class="form-group">
<label for="email">Email address</label>
<input type="email" class="form-control" id="email"
name="email" placeholder="Email">
</div>
<div class="form-group">
<label for="firstName">First Name</label>
<input type="text" class="form-control"
id="firstName" name="firstName" placeholder="First Name">
</div>
<div class="form-group">
<label for="lastName">Last Name</label>
<input type="text" class="form-control" id="lastName"
name="lastName" placeholder="Last Name">
</div>
<input type="hidden" name="_csrf" value="<% csrfToken %>">
<button type="submit" class="btn btn-default">Submit</button>
</form>
</div>
</div
</div>
</body>
</html>
重新启动我们的服务器,重新加载我们的login或signup页面,并查看源代码,您将看到一个如下所示的隐藏输入标签:
<input type="hidden" name="_csrf" value="UBDtLOuZ-emrxDagDmIjxxsomxFS2pSeXKb4">
那个隐藏字段会随我们的请求一起返回并检查其有效性。
采取额外的安全措施
我们已经采取了一些基本但强大的步骤来保护我们的应用程序免受一些最大威胁。对可能威胁和安全措施的全面探讨超出了本书的范围,但有一些值得注意的考虑因素。
实施 HTTPS
当你部署到生产环境时实施 HTTPS 可以防止多种中间人攻击,并防止数据在传输过程中被拦截和修改。我们将在关于部署到生产环境的章节中探讨这一点。
此外,你可以设置你的 cookie 为安全模式。同样,我们将在讨论部署时涵盖这一点。
避免以 root 身份运行
我们在本地使用端口3000的原因之一是,在许多环境中,在端口80(标准 HTTP 端口)上运行需要以 root 身份运行。一个众所周知的安全策略是所有进程都应该尽可能少地运行权限。同样,这也是我们在部署时要注意的事情——主要是由 PaaS 提供商来完成。
验证用户输入
目前,我们的应用程序在输入验证方面做得很少——除了username和password在注册和登录时是必需的外。但我们可以也应该在客户端和服务器端检查用户输入。
我们将在下一章中添加一些输入验证。
摘要
我们本章开始时安装并配置了 Node.js 的 Passport 中间件。Passport 为我们提供了一个认证框架,包括创建新用户、登录和确保特定路由的安全。然后我们构建了登录和注册的本地认证策略。
我们创建了登录和注册的路由和视图模板,并将成功的尝试重定向到我们的主仪表板 URL。我们能够通过依赖 Passport 缓存用户会话来减少数据库查询。
最后,我们通过使用 Helmet 添加安全头到请求,并使用csurf来减轻 CSRF 尝试,增强了我们应用程序的安全性。我们在讨论将应用程序迁移到生产环境时的几个额外安全问题时结束。
第十二章。将应用连接到社交媒体
许多网络应用使用第三方身份验证进行注册和登录。特别是,使用像 Facebook 和 Twitter 这样的流行社交媒体网站来验证用户已经变得非常流行。因为这些网站已经对用户进行了某些验证工作,所以使用它们来验证用户可以节省一些时间。
在本章中,我们将设置 Passport 策略,使用 Facebook 和 Twitter 账户注册和验证用户。我们将使用一个流行的协议,称为 OAuth 2。
此外,我们将完成构建用户创建和分享礼物清单的功能。在本章中,我们将涵盖以下内容:
-
使用 Facebook 验证用户
-
使用 Twitter 验证用户
-
在仪表板中处理礼物清单创建
-
添加分享按钮
连接到 Facebook
我们将通过允许用户使用他们的 Facebook 账户创建账户和登录来开始与社交媒体的集成。我们需要做的第一件事是设置一个 Facebook 开发者账户并构建一个 Facebook 应用。
设置你的 Facebook 开发者账户和应用
为了使用 Facebook 验证用户,你必须有一个 Facebook 应用。幸运的是,Facebook 使设置这一点变得非常简单。
如果你没有 Facebook 开发者账户,请立即访问developers.facebook.com/并注册一个开发者账户。只需遵循说明并同意服务条款。接下来,我们需要设置一个应用。从开发者仪表板中,选择我的应用下拉菜单中的添加新应用。你将得到一个类似于以下截图的模态窗口:

选择网站:

给你的新应用起一个名字,并选择创建新的 Facebook App ID:

为你的新应用选择一个类别(实际上任何类别都可以)。确保不要选择这是另一个应用的测试版本吗?选项。然后点击创建 App ID:

从这里,我建议你选择跳过快速入门,我们将手动设置你的应用程序。在下一屏幕上,选择设置:

你需要在这里输入你的电子邮件地址并点击保存更改。接下来,点击应用审核:

对于你希望将此应用及其所有实时功能提供给公众吗?选择是。然后返回到你的仪表板:

你将需要你的 App ID 和 App Secret 值。Facebook 将强制你输入密码以显示你的 App Secret。
App Secret 正如其名——是秘密。你应该保护它,不要将其检查到公共源代码控制中。
设置 Passport 策略
接下来我们需要做的是在 Passport 中设置策略。打开你的终端并导航到你的 giftapp 根目录:
$ npm install passport-facebook --save
passport-facebook@2.1.0 node_modules/passport-facebook
|__ passport-oauth2@1.1.2 (uid2@0.0.3, passport-strategy@1.0.0, oauth@0.9.14)
这里,我们已经安装了 Passport Facebook 模块,它允许我们使用 OAuth 2 通过 Facebook 登录。
注意
OAuth 是一个开放协议,允许通过简单和标准的方法在网页、移动和桌面应用程序中进行安全的授权。你可以在 oauth.net/2/ 找到有关 OAuth 2(该协议的最新版本)的更多信息。
配置 Facebook
现在我们需要制定我们的策略。在你的 giftapp 目录内,创建一个名为 config 的新目录,并添加一个名为 authorization.js 的新文件:
module.exports = {
facebookAuth : {
clientID: '695605423876152', // App ID
clientSecret: 'd8591aa38e06a07b040f20569a', // App secret
callbackURL: 'http://localhost:3000/login/FBcallback'
}
}
我们只是存储了一个包含我们稍后需要的几个值的对象。clientID 是我们的 App ID。clientSecret 是我们的 App Secret(不,那不是我的真实秘密)。最后一个值是我们的 callBackURL。这是一个 Facebook 在授权后会重定向到的 URL。
如果你使用 Git 仓库来存储你的源代码,将这个 config 文件添加到你的 .gitignore 文件中是个好主意。
设置 Facebook 认证的路由
接下来我们需要做的是设置几个路由。在你的 routes 目录中,打开你的路由文件,login.js:
var express = require('express');s
var router = express.Router();
module.exports = function(passport){
router.get('/', function(req, res) {
res.render('login/login', { message: req.flash('message'), csrfToken:
req.csrfToken() });
});
router.post('/', passport.authenticate('login', {
successRedirect: '/dash',
failureRedirect: '/login',
failureFlash : true
}));
router.get('/signup', function(req, res){
console.log('signing up');
res.render('login/signup',{message: req.flash('message'), csrfToken:
req.csrfToken()});
});
router.post('/register', passport.authenticate('signup', {
successRedirect: '/dash',
failureRedirect: '/login/signup',
failureFlash : true
}));
router.get('/signout', function(req, res) {
req.logout();
res.redirect('/login');
});
router.get('/facebook', passport.authenticate('facebook', scope:['emails']));
router.get('/FBcallback',
passport.authenticate('facebook',
{
successRedirect: '/dash',
failureRedirect: '/login' }));
return router;
}
第一个新路由将被用于使用 Facebook 登录。回调 URL 在认证后使用。如果失败,用户将被重定向到登录页面。如果成功,用户将被发送到仪表板。
注意在 facebook 路由上调用 passport.authenticate 的第二个参数。这个对象包含一个 scope 属性,它接受一个数组。这个数组由 Facebook 需要额外权限才能访问的数据字段字符串组成。Facebook 需要额外权限来访问用户的电子邮件地址。
完成设置 Passport 策略
现在我们有更多步骤来设置策略。在你的 Passport 目录中,创建一个名为 facebook.js 的新文件:
var FacebookStrategy = require('passport-facebook').Strategy;
var User = require('../models/user');
var auth = require('../config/authorization');
module.exports = function(passport){
passport.use('facebook', new FacebookStrategy({
clientID: auth.facebookAuth.clientID,
clientSecret: auth.facebookAuth.clientSecret,
callbackURL: auth.facebookAuth.callbackURL,
profileFields: ['id', 'displayName', 'email']
},
function(accessToken, refreshToken, profile, cb) {
User.findOne({ 'facebook.id': profile.id }, function (err, user) {
if(err){
return cb(err)
} else if (user) {
return cb(null, user);
} else {
var newUser = new User();
newUser.facebook.id = profile.id;
newUser.facebook.token = accessToken;
newUser.facebook.name = profile.displayName;
if(profile.emails){
newUser.email = profile.emails[0].value;
}
newUser.save(function(err){
if(err){
throw err;
}else{
return cb(null, newUser);
}
});
}
});
}
));
}
我们首先引入我们的依赖项,包括由 passport-facebook 模块提供的 Strategy 对象、我们的 User 模型以及包含我们的 Facebook 凭据的授权配置文件。
我们接着创建一个模块,该模块定义了我们的 Facebook 认证策略。它接受一个配置对象作为其第一个参数,我们使用配置文件中的 facebook 授权值来定义它。最后一个属性 profileFields 设置了我们期望从 Facebook 返回的配置文件中接收的字段。
第二个参数是一个在授权策略被使用时被调用的函数。它从 Facebook 接收accessToken、refreshToken、profile和callback作为参数。
我们使用用户的findOne函数来查看用户是否基于从 Facebook 返回的profile.id已存在。我们首先检查是否有错误。如果有,我们将其返回给回调。如果没有错误且用户存在,则将用户对象传递回回调,错误字段为 null。最后,如果用户不存在,我们创建一个新的用户,将该用户保存到数据库中,然后将新的用户对象传递回回调。
注意,我们不一定总是从 Facebook 收到电子邮件,因此在我们尝试访问它之前,我们需要测试在配置文件中是否返回了该属性。
记住,如果您想删除users集合,可以使用 Mongo 控制台。输入use giftapp来选择数据库,然后输入db.users.drop()来删除集合。
修改用户模型以存储 Facebook 数据
让我们对我们的User模型做一些修改。我们的 Facebook 授权将给我们一些之前没有的数据,并且有一些东西我们需要存储。打开你的模型目录中的user.js文件并编辑以下内容:
var mongoose = require('mongoose');
var userSchema = mongoose.Schema({
id: String,
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
facebook: {
id: String,
token: String
}
});
module.exports = mongoose.model('User',userSchema);
在这里,我们将使用mongoose.Schema函数开始构建我们的模式。我们在用户中添加了一个Facebook对象,用于存储 ID 和令牌。请注意,这个新的 ID 是由 Facebook 提供的,并且与User对象顶层的 ID 不同。
令牌是一个 Facebook 为每个应用程序提供的唯一id。我们需要存储这个信息以确保认证能够正确工作。
完成与 Facebook 的连接
我们几乎准备好了。我们只需要完成最后几个步骤,以完成使用 Facebook 进行认证和注册的工作。
重建我们的主页
让我们简化一下自己的工作,并在视图目录内重写我们的index.ejs文件:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<div class="container">
<div class="jumbotron">
<h1 class="text-center">Welcome to Giftapp</h1>
<hr>
<p class="text-center">
<a class="btn btn-default btn-lg" href="/login/" role="button">Log in</a>
<a class="btn btn-default btn-lg" href="/login/signup" role="button">Sign Up</a>
<a class="btn btn-primary btn-lg" href="/login/facebook" role="button">Log in or sign up with Facebook</a>
</p>
</div>
</div>
</body>
</html>
在这里,我们使用 Bootstrap 的jumbotron创建了一个简单的欢迎页面。我们有三个按钮,实际上这些按钮是作为链接样式设计的:一个用于登录,一个用于注册,还有一个用于 Facebook 注册/登录。
页面,在http://localhost:3000,将看起来如下所示:

您可以测试这些按钮。不幸的是,点击我们的 Facebook 按钮会出现错误:

这是因为我们必须在 Facebook 应用程序内特别启用 URL。Facebook 强制执行这项安全措施。对我们来说没问题。回到你的 Facebook 应用程序仪表板的设置:

一旦进入这里,点击+ 添加平台并选择网站。在 URL 字段中输入http://localhost。现在您应该能够使用 Facebook 进行注册和认证。
您可能希望自行实现的一个功能是检查数据库中是否已存在用户,通过检查 Facebook 返回的任何电子邮件地址与数据库中已有的用户电子邮件地址进行对比。这将有助于避免重复账户。
连接到 Twitter
Passport 和 OAuth 2 的一个优点是,我们可以使用许多不同的策略来与第三方进行身份验证。让我们设置 Twitter 身份验证。
添加 Twitter 应用
与 Facebook 类似,我们需要在我们的应用程序与 Twitter 通信时在 Twitter 上设置一个应用。前往 apps.twitter.com 并创建一个新的应用:

填写名称、描述和两个 URL。在撰写本文时,Twitter 不允许使用 http://localhost 作为 URL,因此您必须使用 http://127.0.0.1。
现在点击到 密钥和访问令牌 并获取您的 Consumer Key 和 Consumer Secret。我们将把这些添加到我们的 authorization.js 配置文件中:
module.exports = {
facebookAuth : {
clientID: '695605423876152', // App ID
clientSecret: 'd85fR1nkOz2056801c8f9a', // App secret
callbackURL: 'http://localhost:3000/login/FBcallback'
},
twitterAuth : {
'consumerKey' : 'JksPJ0z46tf10/15/16asUdgxW1lJp',
'consumerSecret' : 'IV095CmDyUsSOZo21GnejiShTXWzzxxybalubae82P4hfLa',
'callbackURL' : 'http://127.0.0.1:3000/login/twitterCallback'
}
}
我们在我们的授权 config 文件中添加一个 twitterAuth 部分,其中包含我们需要的密钥以及回调。这一切都与 Facebook 非常相似。
设置我们的 Twitter 策略
现在我们需要采取步骤构建我们的 Twitter 策略。
首先,我们需要安装 Passport Twitter 策略模块:
$ npm install passport-twitter --save
passport-twitter@1.0.4 node_modules/passport-twitter
|--- passport-oauth1@1.0.1 (passport-strategy@1.0.0, utils-merge@1.0.0,
oauth@0.9.14)
|__ xtraverse@0.1.0 (xmldom@0.1.22)
现在在您的 Passport 目录中创建一个 twitter.js 文件:
var TwitterStrategy = require('passport-twitter').Strategy;
var User = require('../models/user');
var auth = require('../config/authorization');
module.exports = function(passport){
passport.use('twitter', new TwitterStrategy({
consumerKey : auth.twitterAuth.consumerKey,
consumerSecret : auth.twitterAuth.consumerSecret,
callbackURL : auth.twitterAuth.callbackURL
},
function(token, tokenSecret, profile, cb) {
User.findOne({ 'twitter.id': profile.id }, function (err, user) {
if(err){
return cb(err)
} else if (user) {
return cb(null, user);
} else {
// if there is no user, create them
var newUser = new User();
// set all of the user data that we need
newUser.twitter.id = profile.id;
newUser.twitter.token = token;
newUser.twitter.username = profile.username;
newUser.twitter.displayName =
profile.displayName;
newUser.save(function(err){
if(err){
throw err;
}else{
return cb(null, newUser);
}
});
}
});
}
));
}
这种策略与我们的 Facebook 策略非常相似。我们使用授权配置设置我们的密钥和回调。然后我们检查是否已存在具有相同 Twitter ID 的用户。如果没有,我们将使用 Twitter 发送给我们的数据创建一个新的用户,并将记录保存到数据库中。
谈到数据库,我们现在需要修改我们的 User 模型以处理我们的 Twitter 数据:
var mongoose = require('mongoose');
var userSchema = mongoose.Schema({
id: String,
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
facebook: {
name: String,
id: String,
token: String
},
twitter: {
id: String,
token: String,
username: String,
displayName: String
}
});
module.exports = mongoose.model('User',userSchema);
就像 Facebook 部分,我们添加一个 Twitter 属性来存储我们从 Twitter 分离获取的数据。
接下来,我们需要在我们的 routes/login.js 文件中添加 Twitter 身份验证的路由:
var express = require('express');
var router = express.Router();
module.exports = function(passport){
router.get('/', function(req, res) {
res.render('login/login', { message: req.flash('message'), csrfToken:
req.csrfToken() });
});
router.post('/', passport.authenticate('login', {
successRedirect: '/dash',
failureRedirect: '/login',
failureFlash : true
}));
router.get('/signup', function(req, res){
console.log('signing up');
res.render('login/signup',{message: req.flash('message'), csrfToken:
req.csrfToken()});
});
router.post('/register', passport.authenticate('signup', {
successRedirect: '/dash',
failureRedirect: '/login/signup',
failureFlash : true
}));
router.get('/signout', function(req, res) {
req.logout();
res.redirect('/login');
});
router.get('/facebook', passport.authenticate('facebook', {scope:
['email']}));
router.get('/FBcallback',
passport.authenticate('facebook', { successRedirect: '/dash',
failureRedirect: '/login' }));
router.get('/twitter', passport.authenticate('twitter'));
// handle the callback after twitter has authenticated the user
router.get('/twitterCallback',
passport.authenticate('twitter',
{
successRedirect : '/dash',
failureRedirect : '/login'
}));
return router;
}
再次,Twitter 路由与我们在 Facebook 身份验证中使用的路由非常相似。我们有主要的授权路由和用于回调的路由。
现在我们只需要对我们的 passport/init.js 文件进行一些编辑,以包含 Twitter 策略:
var signup = require('./signup');
var login = require('./login');
var facebook = require('./facebook');
var twitter = require('./twitter');
var User = require('../models/user');
module.exports = function(passport){
passport.serializeUser(function(user, done) {
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, function(err, user) {
done(err, user);
});
});
signup(passport);
login(passport);
facebook(passport);
twitter(passport);
}
我们在这里需要做的唯一改变是导入 Twitter 策略并初始化它。到这一点,我们的 Twitter 策略应该可以工作。让我们让它对用户来说更容易一些。
将 Twitter 授权添加到我们的主页
与我们的 Facebook 策略一样,让我们在我们的 index.ejs 文件中添加一个 Twitter 登录按钮:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
</head>
<body>
<div class="container">
<div class="jumbotron">
<h1 class="text-center">Welcome to Giftapp</h1>
<hr>
<p class="text-center">
<a class="btn btn-default btn-lg" href="/login/" role="button">Log in</a>
<a class="btn btn-default btn-lg" href="/login/signup" role="button">Sign Up</a>
<a class="btn btn-primary btn-lg" href="/login/facebook" role="button">Log in or sign up with Facebook</a>
<a class="btn btn-primary btn-lg" href="/login/twitter" role="button">Log in or sign up with Twitter</a>
</p>
</div>
</div>
</body>
</html>
我们已添加了一个 Twitter 登录按钮。
注意,为了测试,请从 http://127.0.0.1:3000/ 开始,而不是 http://localhost:3000。这样做的原因是您需要域与回调 URL 中的会话 cookie 匹配。当您这样做时,您将看到以下内容:

点击 Twitter 登录按钮将带您重定向到 Twitter,它将要求您登录或为您的应用授权:

点击登录应该会带您到仪表板。
现在我们已经通过 Facebook 和 Twitter 登录,让我们看看我们的giftapp数据库中的users集合。通过在命令行中输入 mongo 启动您的 MongoDB 客户端:
> use giftapp
switched to db giftapp
> db.users.find({}).pretty()
{
"_id" : ObjectId("56bbdc4308ca9a596f0006d8"),
"email" : "john@thisisafakeemail.com",
"facebook" : {
"name" : "John Moore",
"token" : "CAAJ4pkIxqDgBABrsfsds345435ZAZAE73UZCjehrtjWQ8YhGWVxdoa6VA0OuydPPuQO8wJWBDO4ZCylNX7dMPOJL4VW7WX3nZCLt1b16Mghgdfdg34543543",
"id" : "101539512345670"
},
"__v" : 0
}
{
"_id" : ObjectId("56bd252d32d162207ab35be4"),
"twitter" : {
"displayName" : "John Moore",
"username" : "JohnMooreNinja",
"token" : "4867525577-dvlIz4uEZMgMZHFd6tRjhgjhgjgf8WrGv",
"id" : "486633445565657"
},
"__v" : 0
}
因此,在我们的users集合中有两个用户,一个有一组 Facebook 凭证,另一个有 Twitter 凭证。您会注意到 Twitter 配置文件不包括电子邮件。
分享 giftlists
目前,我们的 giftlist 功能实际上并不起作用。我们希望用户能够创建他们可以分享的 giftlists。
完善 giftlist 模型
由于我们使用 Mongoose 为我们的用户建模数据,让我们也用它来为我们的giftlists建模。在您的models文件夹中,创建一个名为giftlist.js的新文件:
var mongoose = require('mongoose');
var giftlistSchema = mongoose.Schema({
id: String,
user_id: String,
name: String,
gifts: [{name: String}]
sharedWith [{user_id: String}]
});
module.exports = mongoose.model('Giftlist',giftlistSchema);
这个模型相当简单。一个 giftlist 有一个 ID,一个名称,一个gift对象的列表,以及一个user_id字段。我们将用拥有 giftlist 的用户 ID 填充user_id。在一个关系型数据库中,这将是一个外键,定义了用户和 giftlist 之间的一对多关系。
gifts 字段是一个只期望有 name 属性的 object 数组。我们还有一个与我们已经分享的 giftlist 的用户列表。我们将分享功能留到以后。
连接 UI
下一步,我们希望允许用户从我们的 SPA 仪表板创建新的 giftlists。
由于我们打算通过 Ajax 发送数据,我们需要做一些工作来使 CSRF 令牌对 Angular 应用可用。为此有两个步骤;首先,我们希望在dashboard.js路由中传递令牌:
var express = require('express');
var router = express.Router();
var isAuthenticated = require('../utils/authenticated');
router.get('/', isAuthenticated, function(req, res, next) {
var db = req.db;
var collection = db.get('giftlist');
console.log("routing dash for " + req.user._id);
collection.find({'owner_id':req.user._id}, {}, function(err,giftlists){
if(err){
res.send(err);
}else {
giftlists = giftlists || [];
res.render('dash/dashboard', {user: req.user, giftlists: giftlists, csrfToken: req.csrfToken()});
}
});
});
module.exports = router;
我们将令牌传递给渲染函数。
接下来,我们将在dashboard.ejs模板中添加一些内容:
<!DOCTYPE html>
<html ng-app="giftapp">
<head >
<title>Dashboard for <%= user.firstName %> <%= user.lastName %> </title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<script src="img/angular.min.js"></script>
<script src="img/angular-ui-router.min.js"></script>
<script src="img/angular-resource.js"></script>
</head>
<body>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#"><%= user.firstName %> <%= user.lastName %> Dashboard</a>
</div>
</div>
</nav>
<div class="container">
<div ui-view></div>
</div>
<script src="img/dashMainController.js"></script>
<script src="img/giftappFormController.js"></script>
<script src="img/giftlistFactory.js"></script>
<script>
angular.module("csrfValue", [])
.value("csrfToken","<%= csrfToken %>");
</script>
</body>
</html>
我们在我们的页面内创建一个新的 Angular 模块,并向其中添加一个值。一个值基本上是一个可注入的名称值对,我们可以在我们的应用程序中使用它。我们在仪表板模板中这样做,因为我们需要服务器将csrfToken提供给 UI。
我们还添加了一个 script 标签来加载一个新控制器脚本文件,我们将使用它来处理和提交表单。
连接表单
接下来,我们需要将 giftlist 表单连接到控制器,并让控制器与后端通信。
创建控制器
在您的javascripts/controllers目录中创建一个名为giftappFormController.js的新文件:
angular.module('giftappControllers')
.controller('GiftappFormController', ['$scope','List','csrfToken', '$state', function($scope, List, csrfToken, $state) {
$scope.formData = {};
$scope.formData.items = [];
$scope.formData._csrf = csrfToken;
$scope.create = function() {
console.log("create");
var myList = new List($scope.formData);
myList.$save(function(giftList){
console.log(giftList);
$state.go('dash');
});
}
}]);
我们在我们的giftappControllers模块中添加了一个新的控制器。我们将一些东西注入到控制器中,包括我们的 List 资源,以及$state。我们还注入了csrfToken。我们目前还不能访问它,但稍后我们会将其模块注入到我们的模块中。
在控制器内部,我们在$scope上设置了一个名为formData的对象。这个对象将保存用户在我们表单中输入的数据。我们还添加了一个名为create的函数到作用域中,当用户提交表单时将被调用。我们创建了一个新的列表资源实例,将我们的数据添加到其中,并将其保存到后端。保存后,我们触发状态改变以返回仪表板。
由于我们的模块实际上是在insidedashMainController.js中定义的,这是我们想要注入包含我们的csrfToken值的模块的地方:
angular.module('giftappControllers',['giftlistServices','csrfValue'])
.controller('DashMainController', ['$scope','List', function($scope,List) {
$scope.lists = List.query();
}]);
通过简单地将模块名称添加到我们的模块依赖中,我们就可以访问模块内的值服务。
规范化表单
下一步,我们需要在public/templates/dash-add.tpl.html模板中添加一些 AngularJS 指令:
<div class="row">
<div class="col-md-12">
<h2>Add a new list</h2>
<form class="form-horizontal" ng-submit="create()">
<div class="form-group">
<label for="listname" class="col-sm-2 control-label">List
Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="listname"
placeholder="Name" ng-model="formData.name">
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Item 1</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-
model="formData.items[0]">
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Item 2</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-
model="formData.items[1]">
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Item 3</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-
model="formData.items[2]">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default btn-lg btn-
block">
<span class="glyphicon glyphicon-flash"></span> Create
List
</button>
</div>
</div>
</form>
{{formData}}
</div>
</div>
第一次更改是在表单中添加ng-submit指令。在提交表单时,控制器中的$scope.create()函数将被调用。
我们然后将输入与$scope.formdata通过ng-model指令连接起来。这创建了两向数据绑定。为了演示这一点,我们在模板中添加了{{formData}}。这将显示$scope.formdata中保存的所有数据,并且是调试表单的好方法。显然,这不是在生产模板中留下的事情。
连接到后端控制器
现在既然我们的表单已经连接到我们的控制器,我们需要将我们的控制器连接到后端以存储和从数据库中检索我们的数据。打开你的controllers/giftlist_controller.js文件:
var Giftlist = require('../models/giftlist');
exports.index = function(req, res){
Giftlist.find({'user_id':req.user._id}, {}, function(err,giftlists){
if(err){
res.send(err);
}else if(giftlists){
res.json(giftlists);
};
});
};
exports.create = function(req, res){
var newGiftlist = new Giftlist();
newGiftlist.name = req.body.name;
newGiftlist.user_id = req.user._id;
var gifts = [];
req.body.items.forEach(function(item){
gifts.push({name:item});
});
newGiftlist.gifts = gifts;
newGiftlist.save(function(err){
if(err){
throw err
} else {
res.json(newGiftlist);
}
});
};
exports.show = function(req, res){
Giftlist.findOne({_id:req.params.id}, function(err, list){
if(req.params.format == "json" || req.isJSON){
res.json(list);
}else{
res.render('giftlist/show',{giftlist:list});
}
});
};
我们在我们的giftlist模型中引入了依赖,并编辑了索引、显示和创建路由以利用 Mongoose 数据库功能。因为我们希望能够轻松地将列表与未登录我们的仪表板的人分享,所以显示的非 JSON 请求将渲染在单独的页面上。
在你的视图目录中,创建一个新的giftlist目录,并创建一个名为show.ejs的模板:
<!DOCTYPE html>
<html>
<head>
<title>Show Users</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>User List: <%= appName %></h1>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Email Address</th>
<th>Dashboard</th>
</tr>
</thead>
<tbody>
<% users.forEach(function(user, index){ -%>
<tr>
<td><a href="show/<%= user._id%> "><%= user.firstName %></a></td>
<td><%= user.lastName %></td>
<td><%= user.email %></td>
<td><a href="/dash/<%= user._id %>">View</a></td>
</tr>
<% }); %>
</tbody>
</table>
</body>
</html>
这是一个相当直接的模板,用于渲染列表名称和列表上的礼物。
添加在社交媒体上分享列表的能力
接下来,我们希望允许用户轻松地分享他们的列表。我们需要对dash-main模板进行一些小的调整:
<div class="row">
<div class="col-xs-12 col-md-6">
<h2>My Lists</h2>
<a class="btn btn-primary" role="button" ui-sref="add" href="#/add">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add List</a>
<ul class="list-unstyled">
<li ng-repeat="list in lists"><a class="btn btn-link"
href="/giftlist/{{list._id}}" role="button">{{ list.name }}</a></li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
<h2>Lists Shared With Me</h2>
</div>
</div>
我们添加到链接中的 URL 将触发控制器中的显示路由,并传递我们想要显示的列表的 ID。
接下来,我们将向我们的giftlist/show.ejs模板添加分享按钮:
<!DOCTYPE html>
<html>
<head >
<title>Giftlist: <%= giftlist.name %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css">
<!-- You can use open graph tags to customize link previews.
Learn more: https://developers.facebook.com/docs/sharing/webmasters -->
<meta property="og:url"
content="http://localhost:3000/giftlist/<%= giftlist._id %>" />
<meta property="og:type"
content="website" />
<meta property="og:title"
content="Giftlist App" />
<meta property="og:description"
content="<%= giftlist.name %>" />
</head>
<body>
<div id="fb-root"></div>
<script>(function(d, s, id)
{
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src =
"//connect.facebook.net/en_US/sdk.js#xfbml=1&version=v2.5&appId=228887303845448";
fjs.parentNode.insertBefore(js, fjs);
}
(document, 'script', 'facebook-jssdk'));
</script>
<nav class="nav navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="#">Giftlist</a>
</div>
</div>
</nav>
<div class="container">
<h1><%= giftlist.name %></h1>
<div class="row">
<div class="col-md-12">
<ul>
<% giftlist.gifts.forEach(function(gift, index){ -%>
<li><%= gift.name %></li>
<% }); -%>
</ul>
<a href="https://twitter.com/share" class="twitter-share-button" data-via="JohnMooreNinja" data-size="large" data-hashtags="giftapp">Tweet</a>
<script>!function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https';if(!d.getElementById(id)){js=d.createElement(s);js.id=id;js.src=p+'://platform.twitter.com/widgets.js';fjs.parentNode.insertBefore(js,fjs);}}(document, 'script', 'twitter-wjs');
</script>
<div class="fb-like"></div>
</div>
</div>
</div>
</body>
</html>
我们添加了一些开放图标签,以及一些代码来启用 Twitter 和 Facebook 分享。
Twitter 有一个基于表单的便捷向导来设置 Twitter 分享按钮。你可以在about.twitter.com/resources/buttons#tweet找到它。你将需要为你的应用 ID 特别配置 Facebook 按钮。Facebook 也有一个基于表单的配置工具,在developers.facebook.com/docs/plugins/like-button。
摘要
我们通过设置与 Facebook 和 Twitter 的 Passport 策略来开始本章,为每个社交媒体网站设置开发者账户是一个简单但必要的步骤。
然后,我们利用我们的Mongoose Giftlist模型以及我们资源丰富的控制器,从 SPA 内部启用创建礼品列表。我们通过构建一个新的 AngularJS 控制器来启用前端代码和 AJAX 功能。为了能够将数据发布到后端,我们添加了一个可注入的值服务来携带 CSRF 令牌。
有几件事情留给我们来完成应用的完善。这包括动态添加更多礼品输入的方式,以及与其他注册用户共享列表。
第十三章. 使用 Mocha、Karma 和更多进行测试
测试是软件开发的一个基本组成部分,尤其是在处理与最终用户和不同客户端交互的应用程序时,例如 JavaScript SPAs。由于可能消费应用程序的客户端种类繁多,因此网络应用程序代码的结果往往难以预测,因此应该考虑到所有可能的场景并进行适当的测试。
在本章中,我们将涵盖以下主题:
-
单元测试、集成测试和端到端(E2E)测试是什么?
-
如何使用 Mocha、Chai 和 Sinon.js 进行 JavaScript 单元测试
-
如何配置 Karma 使用 Jasmine 测试 AngularJS
-
如何使用 AngularJS 进行单元测试
-
如何使用 AngularJS 进行端到端测试
测试类型
软件行业中已知有许多种测试类型,但有三类测试被持续使用,尤其是在网络应用开发中。具体如下:
-
单元测试
-
集成测试
-
端到端测试,也称为功能测试
这三种测试类型构成了所谓的软件测试金字塔。金字塔可以分解为更细粒度的测试形式,但从高视角来看,它看起来是这样的:

单元测试
软件测试金字塔的底层是单元测试。单元测试针对应用中最小的部分,即单元,在隔离于应用其余部分的情况下进行。一个单元通常是一个单独的方法或对象实例。当你对单元进行隔离测试时,这意味着测试不应该与任何应用依赖项交互,例如网络访问、数据库访问、用户会话以及在实际应用环境中可能需要的任何其他依赖项。相反,单元测试应该只执行本地内存中的操作。
任何单元测试的目标应该是仅测试应用程序的一个功能,并且该功能应封装在单元中。如果该单元有任何依赖项,它们应该被模拟或模拟,而不是调用实际的依赖项。我们将在本章后面进一步讨论这一点。
知道你将进行单元测试将帮助你编写更小、更专注的方法,因为它们更容易测试。许多人会争论说,你应该在编写任何应用程序代码之前先编写测试。然而,这并不总是实用的,因为你可能被推入一个快速的开发周期,没有时间进行编写单元测试的繁琐过程。对现有代码编写单元测试也可能很繁琐,但这是可以接受的,并且比完全没有单元测试要好。
让我们看看一些知名的 JavaScript 单元测试框架,它们可以快速轻松地集成到新的或现有应用程序中。
Mocha
Mocha 是一个流行的 JavaScript 单元测试框架,在 Node.js 社区中广泛使用。让我们回顾一下本书开头提到的 Node.js 示例项目,并安装 Mocha,以便我们可以尝试一些单元测试示例:
$ npm install mocha -g
全局安装 mocha,这样你就可以轻松地从任何目录访问它。
现在,让我们在项目的根目录下创建一个 test 目录来存储测试相关文件:
$ mkdir test
在 test 目录下创建一个名为 test.js 的文件,并打开它进行编辑。将以下代码放入文件并保存:
var assert = require('assert');
describe('String', function() {
describe('#search()', function() {
it('should return -1 when the value is not present', function() {
assert.equal(-1, 'text'.search(/testing/));
});
});
});
要运行测试,请在 test 目录下的控制台输入以下命令:
$ mocha test.js
你应该在控制台中看到以下输出:
String
#search()
should return -1 when the value is not present
1 passing (8ms)
使用 Mocha 的 describe 方法,此单元测试对 String 构造函数的 search 方法执行简单的 断言。在测试中,断言简单地说就是评估某事是否为 true。在此示例中,我们正在测试当 search 方法的参数在搜索上下文中未找到时,它返回 -1 的断言。
使用 Chai 进行断言
之前的例子使用了 Node.js 的 assert 模块,但使用 Mocha,你将希望使用一个完整的断言库来构建一个实质性的测试环境。Mocha 与多个 JavaScript 断言库兼容,包括以下内容:
-
Should.js
-
Expect.js
-
Chai
-
Better-assert
-
Unexpected
Chai 是一个流行的活跃的开源断言库,因此我们将在此章的 Mocha 断言示例中使用它。首先,在你的本地 Node.js 环境中安装 chai:
$ npm install chai --save-dev
Chai 包含三种断言风格,should、expect 和 assert,允许你选择你最喜欢的风味。
应该风格断言
应该风格断言 在 Chai 中通过 chai.should() 访问。此接口允许使用许多 JavaScript 开发者熟悉的链式方法语法,特别是如果你与 jQuery 等库合作过。链式方法名称使用自然语言来使编写测试更加流畅。此外,Chai 的 should 方法扩展了 Object.prototype,这样你就可以直接将其链接到你要测试的变量,如下所示:
var should = require('chai').should(); // Execute the should function
var test = 'a string';
test.should.be.a('string');
此示例将执行一个简单的断言,检查给定的变量是否为字符串。
期望风格断言
期望风格断言 在 Chai 中通过 chai.expect 访问。此接口与 should 类似,因为它使用方法链,但它不扩展 Object.prototype,因此它以更传统的方式使用,如下所示:
var expect = require('chai').expect;
var test = 'a string';
expect(test).to.be.a('string);
此示例执行与上一个示例相同的断言,但使用 Chai 的 expect 方法而不是 should。请注意,对 expect 方法的 require 调用不会执行它,就像 should 一样。
断言风格断言
断言风格断言 在 Chai 中通过 chai.assert 访问。此接口使用更传统的断言风格,类似于 Node.js 原生的 assert 模块:
var assert = require('chai').assert;
var test = 'a string';
assert.typeOf(test, 'string');
这个示例执行了与前面两个示例相同的断言,但使用的是 Chai 的 assert 方法。注意,这个示例调用了 assert.typeOf 方法,这与原生的 JavaScript typeof 操作符类似,而不是像 should 和 expect 那样使用自然语言方法名称。
使用 Mocha 和 Chai 进行测试并不偏袒 Chai 中可用的任何特定断言风格,但最好选择一种并坚持下去,以便建立测试模式。我们将在这个章节的其余示例中使用 should 断言风格。
使用 Mocha 和 Chai 的 Should-style 断言
现在,让我们回到我们的原始 Mocha 测试示例 test.js,并在其下方添加一个类似的测试,但使用 Chai 的 should 断言方法:
var should = require('chai').should();
describe('String', function() {
describe('#search()', function() {
it('should return -1 when the value is not present', function() {
'text'.search(/testing/).should.equal(-1);
});
});
});
这执行了前面展示的相同测试,但使用的是 Chai 的 should 方法。然而,在这个场景中使用 Chai 的优势在于,它提供了 Node.js 默认提供的额外测试,并且 Chai 测试也兼容浏览器。
在控制台,运行 Mocha 测试:
$ mocha test.js
这应该从您的两个测试中产生以下输出:
String
#search()
should return -1 when the value is not present
String
#search()
should return -1 when the value is not present
2 passing (9ms)
现在,让我们编写一个更有趣的测试,它可能在现实世界的应用场景中使用。一个 JavaScript 单页应用(SPA)通常会处理 DOM,因此我们应该相应地测试这种交互。以下方法可以作为示例:
module.exports = {
addClass: function(elem, newClass) {
if (elem.className.indexOf(newClass) !== -1) {
return;
}
if (elem.className !== '') {
newClass = ' ' + newClass;
}
elem.className += newClass;
}
};
addClass 方法简单地将 className 添加到 DOM 元素上,如果该元素尚未具有该 className。我们通过 module.exports 定义它,以便它可以作为 Node.js 模块使用。为了测试这段代码,将其保存为名为 addClass.js 的新文件,位于您的 test 目录下。
现在,回到 test.js 文件,在其他两个测试代码下方添加以下单元测试代码:
var addClass = require('./addClass').addClass;
describe('addClass', function() {
it('should add a new className if it does not exist', function() {
var elem = {
className: 'existing-class'
};
addClass(elem, 'new-class');
elem.className.split(' ')[1].should.equal('new-class');
});
});
由于单元测试的无依赖性约束,我们在这里通过定义一个简单的 JavaScript 对象 elem 并给它一个 className 属性来模拟或模拟一个 DOM 元素,就像一个真实的 DOM 对象一样。这个测试严格编写来断言,在元素上调用 addClass 并传入一个新且不存在的 className 时,实际上会向该元素添加该 className。
从命令行运行测试现在应该产生以下输出:
String
#search()
should return -1 when the value is not present
String
#search()
should return -1 when the value is not present
addClass
should add a new className if it does not exist
3 passing (10ms)
在浏览器中运行 Mocha 测试
Mocha 从命令行运行起来足够简单,但它还附带了一些资产,允许您轻松地在浏览器中运行测试。由于我们目前正在处理前端 JavaScript 代码,最好在它实际运行的环境中进行测试。为此,让我们首先在项目根目录下创建一个名为 test.html 的文件,并向其中添加以下标记:
<!doctype html>
<html>
<head>
<title>Mocha Tests</title>
<link rel="stylesheet" href="node_modules/mocha/mocha.css">
</head>
<body>
<div id="mocha"></div>
<script src="img/mocha.js"></script>
<script src="img/chai.js"></script>
<script>mocha.setup('bdd');</script>
<script src="img/addClass.js"></script>
<script src="img/test.js"></script>
<script>
mocha.run();
</script>
</body>
</html>
Mocha 提供了 CSS 和 JavaScript 资产,以便在浏览器中查看测试。对于 DOM 结构的要求仅仅是定义一个带有 mocha ID 的 <div>。样式应包含在 <head> 中,而 JavaScript 应包含在 <div id="mocha"> 之下。此外,调用 mocha.setup('bdd') 告诉 Mocha 框架使用其 行为驱动开发(BDD) 接口进行测试。
现在,请记住,我们的 JavaScript 文件是以 Node.js 模块编写的,因此我们必须修改它们的语法才能在浏览器环境中正确运行。对于我们的 addClass.js 文件,让我们将方法定义为全局 window 对象上的 DOM:
window.DOM = {
addClass: function(elem, newClass) {
if (elem.className.indexOf(newClass) !== -1) {
return;
}
if (elem.className !== '') {
newClass = ' ' + newClass;
}
elem.className += newClass;
}
};
接下来,修改 test.js 以从 window 上下文加载 chai.should 和 DOM.addClass,而不是作为 Node.js 模块,然后让我们继续删除我们创建的原始 Node.js assert 模块测试:
// Chai.should assertion
var should = chai.should();
describe('String', function() {
describe('#search()', function() {
it('should return -1 when the value is not present', function() {
'text'.search(/testing/).should.equal(-1);
});
});
});
// Test the addClass method
var addClass = DOM.addClass;
describe('addClass', function() {
it('should add a new className if it does not exist', function() {
var elem = {
className: 'existing-class'
};
addClass(elem, 'new-class');
elem.className.split(' ')[1].should.equal('new-class');
});
});
您现在应该在 test.js 中有两个测试。最后,从项目的根目录运行一个本地 Node.js 服务器,以便您可以在浏览器中查看 test.html 页面:
$ http-server
使用全局的 http-server 模块,您的本地服务器将在 localhost:8080 上对浏览器可用,测试文件在 localhost:8080/test.html。在浏览器中访问该页面,您将看到测试自动运行。如果一切设置正确,您应该看到以下输出:

Sinon.js
由于单元测试中隔离的要求,通常需要通过提供 spies、stubs、mocks 或模仿真实对象行为的对象来模拟依赖项。Sinon.js 是一个流行的 JavaScript 库,它为测试提供了这些工具,并且与任何单元测试框架兼容,包括 Mocha。
间谍
测试间谍是可以在回调依赖项的位置使用的函数,也用于 监视 或记录参数、返回值以及任何其他与应用程序中使用的相关数据。间谍在 Sinon.js 中通过 sinon.spy() API 提供。它可以用来创建一个匿名函数,该函数在测试序列中的每次调用时都会记录数据:
var spy = sinon.spy();
此示例的一个用例是测试在 publish 和 subscribe 设计模式中,从另一个函数正确调用回调函数,如下所示:
it('should invoke the callback on publish', function() {
var spy = sinon.spy();
Payload.subscribe('test-event', spy);
Payload.publish('test-event');
spy.called.should.equal(true);
});
在此示例中,使用 spy 来充当 Payload.js 自定义事件的回调。该回调通过 Payload.subscribe 方法注册,并预期在发布自定义事件 test-event 时被调用。sinon.spy() 函数将返回一个对象,该对象具有几个属性,可以提供有关返回函数的信息。在这种情况下,我们正在测试 spy.called 属性,如果函数至少被调用一次,则该属性将为 true。
sinon.spy() 函数还可以用来包装另一个函数并监视它,如下所示:
var spy = sinon.spy(testFunc);
此外,sinon.spy() 可以用来替换对象上的现有方法,并表现得与原始方法完全一样,但增加了通过 API 收集关于该方法数据的好处,如下所示:
var spy = sinon.spy(object, 'method');
存根
测试 stubs 是在 spies 的基础上构建的。它们是具有访问完整测试间谍 API 的间谍函数,但增加了改变其行为的方法。存根通常用于在测试运行时强制函数内部发生某些事情,以及当你想要防止某些事情发生时。
例如,假设我们有一个 userRegister 函数,该函数将新用户注册到数据库中。该函数有一个回调,当用户成功注册时返回,但如果保存用户失败,则在该回调中返回错误,如下所示:
it('should pass the error into the callback if save fails', function() {
var error = new Error('this is an error');
var save = sinon.stub().throws(error);
var spy = sinon.spy();
registerUser({ name: 'Peebo' }, spy);
save.restore();
sinon.assert.calledWith(spy, error);
});
首先,我们将创建一个 Error 对象并将其传递给我们的回调。然后,我们将为实际的 save 方法创建一个存根,用其替换并抛出错误,将 Error 对象传递给回调。这取代了任何实际的数据库功能,因为我们不能依赖真实依赖项进行单元测试。最后,我们将 callback 函数定义为间谍。当我们为我们的测试调用 registerUser 方法时,我们将间谍作为其回调传递给它。在一个有真实 save 方法的场景中,save.restore() 将将其恢复到原始状态并移除存根行为。
Sinon.js 还内置了自己的断言库,以便在处理间谍和存根时提供额外的功能。在这种情况下,我们将调用 sinon.assert.calledWith() 来断言间谍被调用并传递了预期的错误。
模拟
Sinon.js 中的模拟建立在间谍和存根的基础上。它们是像 spies 一样的假方法,具有添加额外行为的能力,就像 stubs 一样,但还允许你在实际运行测试之前定义 期望。
小贴士
模拟在每个单元测试中只应使用一次。如果你在一个单元测试中使用了多个模拟,那么你很可能没有按照预期使用它们。
为了演示模拟的使用,让我们考虑一个使用 Payload.js localStorage API 方法的例子。我们可以定义一个名为 incrementDataByOne 的方法,用于将 localStorage 的值从 0 增加到 1:
describe('incrementDataByOne', function() {
it('should increment stored value by one', function() {
var mock = sinon.mock(Payload.storage);
mock.expects('get').withArgs('data').returns(0);
mock.expects('set').once().withArgs('data', 1);
incrementDataByOne();
mock.restore();
mock.verify();
});
});
注意,在这里我们不会定义间谍或存根,而是定义一个模拟变量,它将 Payload.storage 对象 API 作为其唯一参数。然后,在对象上创建模拟以测试其方法是否符合期望。在这种情况下,我们将设置期望,即数据的初始值应该从 Payload.storage.get API 方法返回 0,然后调用 Payload.storage.set 并传递 1 后,它应该从原始值增加 1。
Jasmine
Jasmine 是 Node.js 社区中另一个流行的单元测试框架,它也被用于大多数 AngularJS 应用程序,并在 AngularJS 核心文档中有所引用。Jasmine 在许多方面与 Mocha 相似,但它包含了自己的断言库。Jasmine 使用 expect 风格的断言,这与前面提到的 Chai expect 风格的断言类似:
describe('sorting the list of users', function() {
it('sorts in ascending order by default', function() {
var users = ['Kerri', 'Jeff', 'Brenda'];
var sorted = sortUsers(users);
expect(sorted).toEqual(['Brenda', 'Jeff', 'Kerri']);
});
});
正如你在本例中看到的,Jasmine 使用 describe 和 it 方法调用进行测试,这与 Mocha 中使用的相同,因此从一种框架切换到另一种框架非常直接。了解 Mocha 和 Jasmine 都非常有用,因为它们在 JavaScript 社区中都被广泛使用。
Karma 测试运行器
Karma 是一个允许你在浏览器中自动运行测试的 JavaScript 测试运行器。我们已经演示了如何在浏览器中手动运行 Mocha 单元测试,但当你使用像 Karma 这样的测试运行器时,这个过程设置和操作起来要容易得多。
使用 Karma、Mocha 和 Chai 进行测试
Karma 可以与多个单元测试框架一起使用,包括 Mocha。首先,让我们安装我们需要与 Karma、Mocha 和 Chai 一起工作的 Node.js 模块:
$ npm install karma karma-mocha karma-chai --save-dev
这将在你的本地开发环境中安装 Karma 及其针对 Mocha 和 Chai 的 Node.js 插件,并将它们保存在你的 package.json 文件中。现在,为了使 Karma 在你的系统浏览器中启动测试,我们还需要安装相应的插件,如下所示:
$ npm install karma-chrome-launcher karma-firefox-launcher --save-dev
这将安装 Chrome 和 Firefox 浏览器的 launcher 模块。如果你系统上没有这些浏览器或其中之一,那么安装你有的一个或两个浏览器的启动器。Karma 提供了所有主流浏览器的启动器插件。
接下来,我们需要为 Karma 创建一个配置文件来运行我们的测试并启动适当的浏览器。在项目根目录创建一个名为 karma.conf.js 的文件,并将以下代码添加到其中:
module.exports = function(config) {
'use strict';
config.set({
frameworks: ['mocha', 'chai'],
files: ['test/*.js'],
browsers: ['Chrome', 'Firefox'],
singleRun: true
});
};
此配置只是告诉 Karma 我们正在使用 Mocha 和 Chai 测试框架,我们希望加载测试目录下的所有 JavaScript 文件,并且我们希望将测试启动到 Chrome 和 Firefox 浏览器中,或者你选择的浏览器中。singleRun 参数告诉 Karma 运行测试然后退出,而不是继续运行。
现在,我们只需要从 CLI 运行 Karma 来在定义的浏览器中运行我们的测试。由于 Karma 是本地安装的,你必须添加从项目根目录到模块的相对路径才能运行它,如下所示:
$ ./node_modules/karma/bin/karma start karma.conf.js
你会注意到这个命令还指定了你想要为你的 Karma 实例使用的配置文件,但如果你在命令中省略了它,它将默认使用你在根目录中创建的 karma.conf.js 文件。
或者,如果您想从任何目录全局运行 Karma,您可以安装karma-cli模块,就像您在第一章,使用 NPM、Bower 和 Grunt 进行组织中做的那样:
$ npm install karma-cli -g
提示
确保添加-g参数,以便karma作为一个全局 Node.js 模块可用。
现在,您可以从 CLI 简单地运行以下命令:
$ karma start
运行此命令将自动打开指定的浏览器,并产生类似于以下命令的输出:
28 08 2016 18:02:34.147:INFO [karma]: Karma v1.2.0 server started at
http://localhost:9876/
28 08 2016 18:02:34.147:INFO [launcher]: Launching browsers Chrome, Firefox
with unlimited concurrency
28 08 2016 18:02:34.157:INFO [launcher]: Starting browser Chrome
28 08 2016 18:02:34.163:INFO [launcher]: Starting browser Firefox
28 08 2016 18:02:35.301:INFO [Chrome 52.0.2743 (Mac OS X 10.11.6)]:
Connected on socket /#TJZjs4nvaN-kNp3QAAAA with id 18074196
28 08 2016 18:02:36.761:INFO [Firefox 48.0.0 (Mac OS X 10.11.0)]:
Connected on socket /#74pJ5Vl1sLPwySk4AAAB with id 24041937
Chrome 52.0.2743 (Mac OS X 10.11.6):
Executed 2 of 2 SUCCESS (0.008 secs / 0.001 secs)
Firefox 48.0.0 (Mac OS X 10.11.0):
Executed 2 of 2 SUCCESS (0.002 secs / 0.002 secs)
TOTAL: 4 SUCCESS
如果您从输出的开头开始跟随,您可以看到 Karma 会在端口 9876上启动自己的服务器,然后一旦启动,就会启动指定的浏览器。您的两个测试在每个浏览器中都会成功运行,因此输出最后的一行记录了总共4 SUCCESS。
进行此类测试的原因是让您的单元测试可以在多个浏览器中运行,并确保它们在所有浏览器中都能通过。在前端 JavaScript 中,总有可能一个浏览器的工作方式与另一个不同,因此应该尽可能多地测试各种场景,以确保您的应用程序在可能使用这些浏览器的任何最终用户中都不会出现错误。
这也是一种很好的方式来帮助您定义您想要支持的应用程序浏览器,以及您可能想要检测并通知用户不支持哪些浏览器。当您想要使用可能不被较老、过时的浏览器支持的现代 JavaScript 技术和方法时,这是一种常见的做法。
使用 Karma 和 Jasmine 测试 AngularJS
AngularJS 社区已经接受 Jasmine 作为其首选的单元测试框架,并且它也可以与 Karma 一起使用。现在,让我们安装我们的依赖项以与 Karma 和 Jasmine 一起工作:
$ npm install jasmine karma-jasmine --save-dev
这将安装 Jasmine 单元测试框架及其对应的 Karma 插件,并将其保存到您的package.json文件中。
现在,让我们将 AngularJS 安装到我们的示例项目中,只是为了测试示例代码,这样我们可以学习如何将单元测试应用到我们的实际 AngularJS 应用中。
AngularJS 可在 NPM 和 Bower 上使用。我们将使用 Bower 进行以下示例,因为这是针对前端代码的:
$ bower install angular --save
将angular保存为依赖项。接下来,将angular-mocks库安装为开发依赖项:
$ bower install angular-mocks --save-dev
angular-mocks库为您提供了ngMock模块,您可以在 AngularJS 应用程序中使用它来模拟服务。此外,您还可以使用它来扩展其他模块,使它们同步行为,从而为更直接的测试提供支持。
现在,让我们将karma.conf.js文件更改为反映使用 Jasmine 而不是 Mocha,并添加angular-mocks。您的配置应类似于以下代码块:
module.exports = function(config) {
'use strict';
config.set({
frameworks: ['jasmine'],
files: [
'bower_components/angular/angular.js',
'bower_components/angular-mocks/angular-mocks.js',
'test/angular-test.js'
],
browsers: ['Chrome', 'Firefox'],
singleRun: true
});
};
这里,我们将 Karma 配置中的frameworks参数更改为仅使用 Jasmine。Jasmine 可以作为 Mocha 和 Chai 的替代品添加,因为 Jasmine 包含自己的断言方法。此外,我们已从bower_components目录将angular.js和angular-mocks.js添加到我们的files数组中,以便使用ngMock测试 AngularJS 代码。在测试目录下,我们将加载一个名为angular-test.js的新文件。
现在,让我们使用 Jasmine 和ngMock为DashMainController的简化版本编写一些测试,这是我们为礼物应用在第十章中编写的,显示视图。在测试目录下创建一个名为angular-test.js的文件,并添加以下代码:
var giftappControllers = angular.module('giftappControllers', []);
angular.module('giftappControllers')
.controller('DashMainController', ['$scope', function($scope, List) {
$scope.lists = [
{'name': 'Christmas List'},
{'name': 'Birthday List'}
];
}]);
这将加载giftappControllers模块到内存中,并随后注册DashMainController。我们在这里排除了任何其他服务和工厂,以确保对控制器的隔离测试。接下来,让我们编写一个简单的 Jasmine 测试来断言$scope.lists数组的长度为2:
describe('DashMainController', function() {
var $controller;
beforeEach(module('giftappControllers'));
beforeEach(inject(function(_$controller_) {
$controller = _$controller_;
}));
describe('$scope.lists', function() {
it('has a length of 2', function() {
var $scope = {};
var testController = $controller('DashMainController', {
$scope: $scope
});
expect($scope.lists.length).toEqual(2);
});
});
});
在对DashMainController的初始describe调用中,我们将初始化一个$controller变量,该变量将用于表示 AngularJS 的$controller服务。此外,我们还将调用两次 Jasmine 的beforeEach方法。这允许在每次测试运行之前执行代码,并进行任何必要的设置。在这种情况下,我们需要在第一次调用beforeEach时初始化giftappControllers模块,然后必须将本地的$controller变量分配给 AngularJS 的$controller服务。
为了访问 AngularJS 的$controller服务,我们将使用 angular-mock 的inject方法,该方法将一个函数包装成一个可注入的函数,利用 Angular 的依赖注入器。此方法还包括一个约定,即在参数名两侧放置一个下划线,它将正确注入而不会与你的本地变量名冲突。在这里,我们将使用_$controller_参数,该参数被inject方法解释为 Angular 的$controller服务。这允许我们使用本地的$controller变量来替代它,并保持命名约定的一致性。
使用这段代码后,你就可以运行测试了,具体步骤如下:
$ karma start
这将产生类似于以下命令的输出:
03 09 2016 01:42:58.563:INFO [karma]: Karma v1.2.0 server started at
http://localhost:9876/
03 09 2016 01:42:58.567:INFO [launcher]: Launching browsers Chrome, Firefox
with unlimited concurrency
03 09 2016 01:42:58.574:INFO [launcher]: Starting browser Chrome
03 09 2016 01:42:58.580:INFO [launcher]: Starting browser Firefox
03 09 2016 01:42:59.657:INFO [Chrome 52.0.2743 (Mac OS X 10.11.6)]:
Connected on socket /#sXw8Utn7qjVLwiqKAAAA with id 15753343
Chrome 52.0.2743 (Mac OS X 10.11.6):
Executed 1 of 1 SUCCESS (0.038 secs / 0.03 secs)
Chrome 52.0.2743 (Mac OS X 10.11.6):
Executed 1 of 1 SUCCESS (0.038 secs / 0.03 secs)
Firefox 48.0.0 (Mac OS X 10.11.0):
Executed 1 of 1 SUCCESS (0.001 secs / 0.016 secs)
TOTAL: 2 SUCCESS
你应该看到测试在所有浏览器中都通过了,因为$scope.lists数组长度为2,正如 Jasmine 断言所测试的那样。
集成测试
软件测试金字塔的第二层是集成测试。集成测试涉及至少测试两个相互交互的代码单元,因此在其最简单形式中,集成测试将测试两个单元测试的结果,这样它们就能按照预期与你的应用程序集成。
集成测试背后的想法是通过测试更大的部分,或称为 组件,来构建在您的单元测试之上。所有单元测试可能都通过了,因为它们是在隔离状态下进行测试的,但当您开始测试这些单元之间的交互时,结果可能并非如您所预期。这就是为什么仅仅单元测试不足以充分测试一个单页应用程序(SPA)。集成测试允许您在进入端到端测试之前,测试应用程序各个组件中的关键功能。
端到端测试
软件测试金字塔的顶层是 端到端测试,缩写为 E2E,也称为 功能测试。端到端测试的目标是在整体上测试您应用程序功能的真实功能。例如,如果您在应用程序中有一个用户注册功能,端到端测试将确保用户能够通过用户界面正确注册,添加到数据库中,显示给用户一条成功注册的消息,以及可能发送给用户的电子邮件,或者应用程序可能需要的任何其他后续操作。
angular-seed 项目
为了演示一个简单的 AngularJS 应用程序,其中包含单元测试和端到端测试的示例,AngularJS 创建了 angular-seed 项目。这是一个开源项目,可在 GitHub 上找到。现在让我们安装它,以便我们可以使用 AngularJS 运行一些简单的单元测试和端到端测试。
让我们将 angular-seed 仓库从 GitHub 克隆到一个新的、干净的工程目录中,如下所示:
$ git clone https://github.com/angular/angular-seed.git
$ cd angular-seed
angular-seed 项目既有 NPM 依赖项,也有 Bower 依赖项,但您只需要运行 NPM install 命令,它将为您安装 Bower 依赖项:
$ npm install
这将安装许多工具和库,其中一些您已经见过,包括 Jasmine、Karma、AngularJS 和 angular-mocks。接下来,您只需使用以下命令行启动 NPM 服务器:
$ npm start
这将运行几个任务,然后为您启动一个 Node.js 服务器。您应该会看到以下输出:
> angular-seed@0.0.0 prestart /angular-seed
> npm install
> angular-seed@0.0.0 postinstall /angular-seed
> bower install
> angular-seed@0.0.0 start /angular-seed
> http-server -a localhost -p 8000 -c-1 ./app
Starting up http-server, serving ./app
Available on:
http://localhost:8000
Hit CTRL-C to stop the server
现在,在网页浏览器中转到 http://localhost:8000,您将看到一个简单的布局显示。它由两个视图标签 view1 和 view2 组成,view1 在页面加载后默认显示。每个视图在第一次查看时请求加载一个部分模板文件,然后将其缓存以供后续查看。
让我们先运行 angular-seed 单元测试,以便我们可以看到它们的设置。Karma 用于启动 Jasmine 单元测试,就像我们之前用示例控制器测试所做的那样;然而,默认情况下,它们在 karma.conf.js 中将 singleRun 属性设置为 false,这是为了持续集成。这允许 Karma 在你进行更改时监视你的代码,以便每次你保存文件时都运行单元测试。这样,你将立即从测试运行器获得反馈,并知道是否有任何测试失败,这将防止你走上一条错误的道路。
要在持续集成模式下运行 angular-seed 测试,只需从 CLI 运行以下 NPM test 命令:
$ npm test
这将产生类似于以下输出的结果:
> angular-seed@0.0.0 test /angular-seed
> karma start karma.conf.js
03 09 2016 13:02:57.418:WARN [karma]: No captured browser, open
http://localhost:9876/
03 09 2016 13:02:57.431:INFO [karma]: Karma v0.13.22 server started at
http://localhost:9876/
03 09 2016 13:02:57.447:INFO [launcher]: Starting browser Chrome
03 09 2016 13:02:58.549:INFO [Chrome 52.0.2743 (Mac OS X 10.11.6)]:
Connected on socket /#A2XSbQWChmjkstjNAAAA with id 65182476
Chrome 52.0.2743 (Mac OS X 10.11.6):
Executed 5 of 5 SUCCESS (0.078 secs / 0.069 secs)
这个输出显示,5 of 5 单元测试已成功执行。请注意,命令会继续运行,因为它处于持续集成模式。你还将打开一个等待文件更改以便重新运行测试的 Chrome 浏览器窗口,测试结果将立即打印回 CLI。
项目还包括一个命令,用于以 singleRun 模式运行 Karma,就像我们之前的 Karma 示例一样。为此,按 Ctrl + C 关闭当前运行的 Karma 实例。这将关闭 Chrome 浏览器窗口。
接下来,你将使用以下 NPM 运行命令一次性启动 Karma 并关闭:
$ npm run test-single-run
你将看到与之前相同的输出,但浏览器窗口将打开和关闭,测试将成功运行,CLI 将带你回到命令提示符。
现在我们已经使用 angular-seed 项目进行了一些简单的单元测试,让我们继续进行端到端测试。
使用 AngularJS 和 angular-seed 进行端到端测试
AngularJS 强调端到端测试的重要性,并且他们有自己的测试框架 Protractor 来实现这一点。Protractor 是一个基于 WebdriverJS(或简称 Webdriver,Selenium 项目的一个组件)的 Node.js 开源应用程序。
Selenium 已经存在很长时间,在 Web 开发社区中极为知名。它包含多个工具和库,允许进行网页浏览器自动化。WebdriverJS 是这些库之一,它被设计来测试 JavaScript 应用程序。
Protractor 与 Karma 类似,它也是一个测试运行器,但它被设计来运行端到端测试而不是单元测试。angular-seed 项目中的端到端测试是用 Jasmine 编写的,Protractor 用于启动和运行它们。
首先,我们需要安装 Webdriver,因为 Protractor 是建立在它之上的。项目附带以下脚本来完成此操作:
$ npm run update-webdriver
这将产生类似于以下输出的结果,安装最新的 Webdriver 版本:
Updating selenium standalone to version 2.52.0
downloading https://selenium-release.storage.googleapis.com/2.52/selenium-
server-standalone-2.52.0.jar...
Updating chromedriver to version 2.21
downloading
https://chromedriver.storage.googleapis.com/2.21/chromedriver_mac32.zip...
chromedriver_2.21mac32.zip downloaded to /angular-
seed/node_modules/protractor/selenium/chromedriver_2.21mac32.zip
selenium-server-standalone-2.52.0.jar downloaded to /angular-
seed/node_modules/protractor/selenium/selenium-server-standalone-2.52.0.jar
一旦成功安装了 Webdriver,再次运行以下 NPM 服务器命令,同时 Karma 正在运行,以便 Protractor 可以与 Web 应用程序交互:
$ npm start
接下来,由于 Protractor 默认设置为使用 Chrome 进行测试,我们需要绕过 Selenium 服务器,因为它使用的是 Chrome 的新版本不支持的一个 Java NPAPI 插件。幸运的是,Protractor 可以直接针对 Chrome 和 Firefox 进行测试,从而绕过这个问题。要使用 Chrome 或 Firefox 的直接服务器连接,打开 E2E-tests 目录中的protractor.conf.js文件,在底部添加一个名为directConnect的新配置属性,并将其设置为true。Protractor 配置文件现在应类似于以下代码块:
//jshint strict: false
exports.config = {
allScriptsTimeout: 11000,
specs: [
'*.js'
],
capabilities: {
'browserName': 'chrome'
},
baseUrl: 'http://localhost:8000/',
framework: 'jasmine',
jasmineNodeOpts: {
defaultTimeoutInterval: 30000
},
directConnect: true
};
请记住,directConnect设置仅适用于 Chrome 和 Firefox。如果您决定在另一个浏览器中运行测试,您希望将其设置为false,或者从配置中删除该属性,否则将抛出错误。使用 Chrome 和 Firefox 通过directConnect运行测试还可以在运行测试时提高速度,因为 Selenium 服务器被绕过。
现在,随着服务器的运行,在 angular-seed 根目录中打开另一个 CLI 会话,并运行以下命令以使用 Protractor:
$ npm run protractor
控制台输出将指示 ChromeDriver 正在直接使用,并且有一个 WebDriver 实例正在运行。您应该看到类似于以下命令的输出:
> angular-seed@0.0.0 protractor /angular-seed
> protractor e2e-tests/protractor.conf.js
[14:04:58] I/direct - Using ChromeDriver directly...
[14:04:58] I/launcher - Running 1 instances of WebDriver
Started
...
3 specs, 0 failures
Finished in 1.174 seconds
[14:05:00] I/launcher - 0 instance(s) of WebDriver still running
[14:05:00] I/launcher - chrome #01 passed
注意输出中指示的3 specs吗?这表示运行了这三个端到端测试。让我们通过在编辑器中打开e2e-tests/scenarios.js文件来更仔细地查看这些测试。
在此文件的开始部分,您将看到一个用于描述您正在测试的应用程序的describe方法调用:
describe('my app', function() {
...
});
这个describe块用于包含应用程序的所有端到端测试。现在,让我们检查第一个测试:
it('should automatically redirect to /view1 when location hash/fragment is empty', function() {
browser.get('index.html');
expect(browser.getLocationAbsUrl()).toMatch("/view1");
});
这个测试断言,当#!路由为空时,应用程序将重定向浏览器中的 URL 到/#!/view1。这是因为应用程序配置为在加载时自动加载view111部分,因此 URL 应该在加载时反映该部分的路径。您会注意到,当您在浏览器中加载应用程序到http://localhost:8000时,它确实会重定向到http://localhost:8000/#!/view1。这使用 WebDriver 直接连接到 Chrome 来运行应用程序并通过browser API 方法测试功能,并结合一个expect断言,断言 URL 与测试路径匹配。
scenarios.js中的第二个测试稍微详细一些,如下面的代码块所示:
describe('view1', function() {
beforeEach(function() {
browser.get('index.html#!/view1');
});
it('should render view1 when user navigates to /view1', function() {
expect(element.all(by.css('[ng-view]
p')).first().getText()).toMatch(/partial for view 1/);
});
});
此测试断言部分路由/#!/view1在视图中显示的文本确实是预期的。如果你在浏览器中加载应用程序时观察开发者控制台,你会注意到它自动发起一个 AJAX 请求以检索本地文件view1.html,该文件包含此视图的部分。从此视图中显示的后续文本正是端到端测试所寻找的内容。此测试再次使用browser API 方法,并额外使用element API 方法来访问 DOM 选择器,结合一个expect断言,以检查视图中的文本是否与测试字符串匹配。
scenarios.js中的第三个也是最后一个测试与第二个测试非常相似,但它用于测试在/#!/view2路径上渲染的部分路由显示的文本。要查看该文本,首先在浏览器中运行的 angular-seed 应用程序中点击view2链接。你会看到 URL 更新为 view2,控制台将显示发起了一个新的 AJAX 请求以检索本地文件 view2.html,并且渲染的视图已更新,显示了文本(This is the partial for view 2)。现在,让我们看一下测试,如下所示:
describe('view2', function() {
beforeEach(function() {
browser.get('index.html#!/view2');
});
it('should render view2 when user navigates to /view2', function() {
expect(element.all(by.css('[ng-view]
p')).first().getText()).toMatch(/partial for view 2/);
});
});
为了使此测试生效,浏览器必须首先被引导到/#!/view2路由,以便显示相应的视图。这是通过在it方法调用之前运行的beforeEach方法实现的。如前所述,Jasmine 提供了beforeEach方法,用于在每次运行测试之前执行任何必要的设置。在这种情况下,它运行代码,指导browser API 方法执行一个get请求到/#!/view2 URL,这将随后更新应用程序的视图以显示view2部分。只有在此完成后,才会运行测试。此测试还使用element API 方法访问 DOM 并找到它想要与expect断言匹配的文本(This is the partial for view 2)。
对于实际应用来说,端到端测试肯定应该更加彻底,但 angular-seed 项目是一个很好的起点,可以开始尝试对 AngularJS 应用程序进行单元测试和端到端测试。一旦你了解了所有的工作原理,熟悉了 Protractor 和 WebDriver API,并且能够自信地使用 Jasmine 和 Protractor,你就可以开始为你的 AngularJS 应用程序编写自定义测试了。
摘要
在本章中,你学习了单元测试、集成测试和端到端测试之间的区别,以及它们如何以及应该结合起来为 JavaScript SPA 提供全面测试。你了解了 Mocha 和 Jasmine 单元测试框架,以及如何使用它们编写单元测试,包括如何使用 Jasmine 编写 AngularJS 的单元测试。你还学习了如何使用 Karma 启动多个浏览器来测试单元测试的跨浏览器兼容性,以及可以添加到你的测试堆栈中的各种其他工具,包括 Chai 和 Sinon.js。
现在你已经拥有了构建和测试 JavaScript SPA 所需的所有工具,我们将带你进入最后一章,学习部署和扩展。
第十四章。部署和扩展 SPA
在构建了应用程序的核心功能之后,现在是将 SPA 移入一个类似生产环境,并且可以从互联网访问的环境中。为此,我们将使用 平台即服务(PaaS)。
PaaS 是一种基于云的服务,允许开发者在其管理的环境中启动应用程序。在 PaaS 之前,开发人员或运维工程师必须执行许多设置和维护任务,例如提供硬件、安装操作系统软件和确保正常运行时间。
有许多 PaaS 提供商,但我选择了 Heroku。一个原因是你可以免费在沙盒中启动一个应用程序,这将允许你在准备好时对应用程序进行实验和扩展。将应用程序部署到 Heroku 也相当简单,因为,正如你将看到的,Heroku 使用 Git 进行部署。
我们还将在云中设置一个生产数据库。我们将使用 MongoLab,它还提供了一个带有足够内存的免费沙盒层,可以开始使用。
我们将简要讨论以下扩展应用程序的关注点来结束本章:
-
使用 Grunt 任务运行器打包应用程序
-
在线设置生产数据库
-
将 SPA 移入云中
-
扩展时的考虑
部署打包
我们的应用程序仍然相当小且不复杂,但我们将首先设置一个自动化流程来打包我们的应用程序以便部署。
设置 Grunt 以进行部署
我们将使用 Grunt JavaScript 任务运行器设置一些自动化任务来打包我们的文件以便部署。这里我们不需要做很多事情,但你将了解可以做什么,并能够探索丰富的 Grunt 插件来进一步自定义你的自动化任务。
安装 Grunt
如果你还没有安装,请使用 NPM 安装 grunt CLI:
$ npm install -g grunt-cli
grunt-cli@0.1.13 /usr/local/lib/node_modules/grunt-cli
|- resolve@0.3.1
|- nopt@1.0.10 (abbrev@1.0.7)
|_ findup-sync@0.1.3 (lodash@2.4.2, glob@3.2.11)
为了使 Grunt 正确运行,你需要在项目根目录中放置两个文件。第一个文件是一个 package.json 文件,用于声明依赖项。你已经在根目录中有一个了。下一个你需要的是 Gruntfile.js 文件,你将在其中加载 grunt 模块并配置 Grunt 可以运行的任务。请创建这个文件在你的根目录中,并添加以下代码:
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
});
};
这是 Gruntfile 的框架。我们导出一个函数,该函数期望接收一个对 grunt 对象的引用作为其参数。在这个函数内部,我们调用 grunt.initConfig() 函数,并传递一个配置对象。目前,这个配置对象只有一个属性,即对 package.json 文件的引用。
Grunt 的强大之处在于可以利用其活跃社区提供的成千上万的插件。在撰写本书时,gruntjs.com/plugins 上列出了超过 5,000 个 Grunt 插件。如果你想要运行某个自动化任务,很可能已经有某人创建了一个插件来支持它。
注意
官方维护的 Grunt 插件通常命名为 grunt-contrib-X。你可以一般地信任这些插件的质量,尽管有许多优秀的非官方维护插件。
安装 Grunt 插件
Grunt 的一个不错的特点是插件使用 NPM 安装。让我们安装一些我们将要使用的有用插件:
$ npm install grunt-contrib-clean--save-dev
grunt-contrib-clean@1.0.0node_modules/grunt-contrib-clean
|- async@1.5.2
|_ rimraf@2.5.2 (glob@7.0.0)
$ sudonpm install grunt-contrib-uglify--save-dev
grunt-contrib-uglify@0.11.1node_modules/grunt-contrib-uglify
|- uri-path@1.0.0
|- maxmin@2.1.0 (figures@1.4.0, pretty-bytes@3.0.1, gzip-size@3.0.0)
|- chalk@1.1.1 (escape-string-regexp@1.0.5, supports-color@2.0.0, has-ansi@2.0.0, strip-ansi@3.0.1, ansi-styles@2.2.0)
|- uglify-js@2.6.2 (uglify-to-browserify@1.0.2, async@0.2.10, source-map@0.5.3, yargs@3.10.0)
|_ lodash@4.5.1
$ sudonpm install grunt-contrib-htmlmin--save-dev
grunt-contrib-htmlmin@0.6.0node_modules/grunt-contrib-htmlmin
|- chalk@1.1.1 (escape-string-regexp@1.0.5, supports-color@2.0.0, strip-ansi@3.0.1, has-ansi@2.0.0, ansi-styles@2.2.0)
|- pretty-bytes@2.0.1 (number-is-nan@1.0.0, get-stdin@4.0.1, meow@3.7.0)
|_ html-minifier@1.2.0 (relateurl@0.2.6, change-case@2.3.1, concat-stream@1.5.1, cli@0.11.1, clean-css@3.4.9, uglify-js@2.6.2)
$ sudonpm install grunt-contrib-copy--save-dev
grunt-contrib-copy@0.8.2node_modules/grunt-contrib-copy
|- file-sync-cmp@0.1.1
|_ chalk@1.1.1 (supports-color@2.0.0, escape-string-regexp@1.0.5, ansi-styles@2.2.0, strip-ansi@3.0.1, has-ansi@2.0.0)
我们为 clean、uglify、htmlmin 和 copy 任务安装了 Grunt 插件。Clean 会清理目录中的文件。Uglify 最小化 JavaScript 文件。Htmlmin 最小化 HTML 文件。Copy 任务复制文件。--save-dev 标志会将这些模块添加到你的 package.json 文件中作为 devdependencies。你只需要在开发环境中使用这些包,而不是在生产环境中。
在我们继续之前,让我们在我们的项目根目录中创建一个 dist 文件夹。我们的生产就绪资产将在这里找到。
配置 Gruntfile
现在,我们需要修改我们的 Gruntfile 以加载插件:
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
});
//load the task plugins
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-htmlmin');
grunt.loadNpmTasks('grunt-contrib-clean');
};
在这里,我们为每个要加载的 Grunt 插件调用 grunt.loadNPMTasks(),并传递要加载的模块名称。
接下来,我们需要在我们的 Gruntfile 中配置每个任务。请注意,每个插件都将有自己的配置属性。请查阅你使用的每个插件的文档,以了解其配置方式。打开你的 Gruntfile.js 并进行以下编辑:
module.exports = function(grunt) {
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
clean: ['dist/**'],
copy: {
main: {
files: [
{expand: true, src: ['*'], dest: 'dist/',
filter: 'isFile'},
{expand: true, src: ['bin/**'], dest:
'dist/', filter:
'isFile'},
{expand: true, src: ['config/**'], dest:
'dist/', filter:
'isFile'},
{expand: true, src: ['models/**'], dest:
'dist/', filter:
'isFile'},
{expand: true, src: ['passport/**'], dest:
'dist/', filter:'isFile'},
{expand: true, src: ['public/**'], dest:
'dist/', filter:'isFile'},
{expand: true, src: ['routes/**'], dest:
'dist/', filter: 'isFile'},
{expand: true, src: ['scripts/**'], dest:
'dist/', filter: 'isFile'},
{expand: true, src: ['utils/**'], dest:
'dist/', filter:'isFile'},
{expand: true, src: ['views/**'], dest:
'dist/', filter:
'isFile'}
]
}
},
uglify: {
options: {
mangle: false
},
my_target: {
files: {
'dist/public/javascripts/giftapp.js': ['dist/public/javascripts/giftapp.js'],
'dist/public/javascripts/controllers/dashMainController.js': ['dist/public/javascripts/controllers/dashMainController.js'],
'dist/public/javascripts/controllers/giftappFormController.js': ['dist/public/javascripts/controllers/giftappFormController.js'],
'dist/public/javascripts/services/giftlistFactory.js': ['dist/public/javascripts/services/giftlistFactory.js']
}
}
},
htmlmin:{
options: {
removeComments: true,
colapseWhitespace: true
},
dist: {
files: {
'dist/public/templates/dash-add.tpl.html': 'dist/public/templates/dash-add.tpl.html',
'dist/public/templates/dash-main.tpl.html': 'dist/public/templates/dash-main.tpl.html'
}
}
}
});
//load the task plugins
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-htmlmin');
grunt.loadNpmTasks('grunt-contrib-clean');
//register the default task
grunt.registerTask('default', ['clean','copy','uglify','htmlmin']);
};
我们做的第一个更改是在 grunt.InitConfig() 函数内部添加了一些任务配置属性。当运行 grunt 时,这些属性会装饰 grunt 对象,并告诉各种任务如何执行。
第一个任务配置是为 clean。此任务配置为删除 dist 文件夹中的所有文件和文件夹。clean 配置接受路径数组;路径定义的语法对于 grunt-contrib 插件来说相当标准。有关 Grunt 的 URL 谓词的信息,请参阅 gruntjs.com/configuring-tasks#globbing-patterns。
其他任务配置类似,但接受对象,可以包括一些选项、一个目标和要操作文件的列表。有关 grunt 插件的配置选项,请访问 gruntjs.com/plugins,点击插件名称以获取文档。
配置之后,下一节是加载此 Gruntfile 将要使用的每个插件的地方。我们通过将插件名称作为参数传递给 grunt.loadNPMTasks() 函数来完成此操作。Grunt 将在我们的 node_modules 文件夹中查找这些插件。如果我们想使用自定义任务,例如我们自己编写的任务,我们可以通过传递路径的调用 grunt.loadTasks() 来加载它们。
我们最后做的事情是注册一个任务。我们通过调用 grunt.registerTask() 来完成这个操作。这需要两个参数。第一个是一个字符串,即任务的名称。在我们的例子中,这是默认任务。Grunt 要求所有 Gruntfiles 都必须注册一个默认任务。下一个参数是一个字符串数组,包含作为此任务一部分运行所需的任何任务和目标的名称。
现在,我们只是在运行任务而没有列出任何单独的目标。如果我们想在任务上运行目标,语法将是 task:target。例如,如果我们为我们的 uglify 任务定义了一个测试目标,我们将在数组中注册它为 ['uglify:test']。
运行 Grunt
运行 grunt 简单得不能再简单了。
首先,确保已经安装了 grunt CLI,如下所示:
$ npm install -g grunt-cli
Password:
/usr/local/bin/grunt -> /usr/local/lib/node_modules/grunt-cli/bin/grunt
grunt-cli@1.2.0 /usr/local/lib/node_modules/grunt-cli
|- grunt-known-options@1.1.0
|- nopt@3.0.6 (abbrev@1.0.9)
|- resolve@1.1.7
|_ findup-sync@0.3.0 (glob@5.0.15)
从存放你的 Gruntfile 的目录中,只需运行 grunt 后跟你要运行的任务的名称。要运行默认任务,你可以省略 taskname。现在,让我们尝试运行 grunt:
$ grunt
Running "clean:0" (clean) task
>> 53 paths cleaned.
Running "copy:main" (copy) task
Copied 35 files
Running "uglify:my_target" (uglify) task
>> 4 files created.
Running "htmlmin:dist" (htmlmin) task
Minified 2 files
如果你现在查看你的 dist 文件夹,你会注意到它不再为空。Grunt 已经清理了它,移动了一些文件,并对一些内容进行了压缩。请注意,第一次你在空的 dist 文件夹中运行此操作时,清理任务会报告 0 个路径被清理。当你随后再次运行时,你应该会看到 dist 文件夹中实际被清理的文件数量。
你可能还会注意到,每个任务都在运行一个目标。复制正在运行 main,uglify 正在运行 my_target。默认情况下,如果没有指定目标,Grunt 将运行第一个定义的目标。
如果你打开你的 dist/public/javascripts/giftapp.js 文件,你应该会看到它已经被压缩:
angular.module("giftapp",["ui.router","giftappControllers"]).config(["$stateProvider","$urlRouterProvider",function($stateProvider,$urlRouterProvider){$urlRouterProvider.otherwise("/dash"),$stateProvider.state("dash",{url:"/dash",templateUrl:"/templates/dash-main.tpl.html",controller:"DashMainController"}).state("add",{url:"/add",templateUrl:"/templates/dash-add.tpl.html",controller:"GiftappFormController"})}]);
代码压缩使我们的文件更小,并且稍微难以阅读。它可以显著提高文件在网页上的性能。为了获得更显著的性能提升,我们可能需要考虑将脚本文件连接起来,并使用像 Closure compiler 这样的工具使它们更加高效。
注意
没有必要压缩服务器端 JavaScript 代码。压缩的主要原因是为了减少与客户端的数据传输。
设置我们的生产配置
当我们将应用程序迁移到生产环境时,我们将遇到一个问题,那就是我们的开发和生产环境之间会有所不同。目前,我们所有的数据库引用都指向我们的本地 MongoDB 数据库。
我们将使用 Git 将我们的文件推送到生产环境,我们也不想在 Git 仓库中存储配置变量。我们也不想在 Git 中存储 node_modules 或将其推送到生产环境,因为它们可以通过我们的 package.json 文件即时获取。
创建一个 .gitignore 文件
在你的项目根目录中创建一个名为 .gitignore 的文件。此文件包含一个列表,其中包含我们不希望 Git 存储或跟踪的文件和路径:
node_modules
config
.idea
dist/config
我们一行一行地列出我们希望 Git 忽略的文件和文件夹。第一个是 node_modules。再次强调,没有理由存储这些文件。接下来,我想忽略 config 文件夹中的任何内容,其中包含敏感信息。
在这里,我忽略了 .idea 文件夹。你可能没有这个文件夹。这是一个由我的开发环境创建的文件夹,用于存储项目信息。我使用的是 JetBrains 的 JavaScript IDE Webstorm。无论你使用什么,你都会想要排除你的 IDE 文件,如果有的话。最后,我明确排除了 dist/config,它将是 config 的一个副本。
创建基于环境的配置模块
我们希望配置能够动态处理。如果你在开发环境中,使用你本地机器的配置。如果你在生产环境中,你将想要使用该环境的适当配置变量。
在生产环境设置中,最安全的方法是在应用程序中设置可读的环境变量。我们将在设置部署环境时设置它们,但现在我们可以设置阶段。
在你的根 giftapp 文件夹中,创建一个名为 appconfig.js 的新文件,使用以下代码:
module.exports = function(){
if(process.env.NODE_ENV&&process.env.NODE_ENV === 'production'){
return{
db: process.env.DB,
facebookAuth : {
clientID: process.env.facebookClientID,
clientSecret: process.env.facebookClientSecret,
callbackURL: process.env.facebookCallbackURL,
},
twitterAuth : {
'consumerKey': process.env.twitterConsumerKey,
'consumerSecret': process.env.twitterConsumerSecret,
'callbackURL': process.env.twitterCallbackURL
}
}
} else {
varauth = require('./config/authorization');
return {
db: 'localhost:27017/giftapp',
facebookAuth : {
clientID: auth.facebookAuth.clientID,
clientSecret: auth.facebookAuth.clientSecret,
callbackURL: auth.facebookAuth.callbackURL
},
twitterAuth : {
'consumerKey': auth.twitterAuth.consumerKey,
'consumerSecret': auth.twitterAuth.consumerSecret,
'callbackURL': auth.twitterAuth.callbackURL
}
}
}
};
我们首先检查是否存在 NODE_ENV 环境变量以及它是否设置为 production。如果是,我们将不得不从环境变量中获取我们的数据库以及 Facebook 和 Twitter 的授权信息。我们将在设置部署环境时设置环境变量。
如果我们的测试失败,我们假设我们处于开发环境,然后手动设置我们的数据库。我们从 config 目录中获取 authorization.js 文件并使用它来设置 Twitter 和 Facebook 的授权变量。
使用新的配置文件
现在,我们需要使用我们的配置文件。打开你的主 app.js 文件并进行一些编辑:
var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
varcookieParser = require('cookie-parser');
varbodyParser = require('body-parser');
varisJSON = require('./utils/json');
var routing = require('resource-routing');
var controllers = path.resolve('./controllers');
var helmet = require('helmet');
varcsrf = require('csurf');
varappconfig = require('./appconfig');
varconfig = appconfig();
//Database stuff
varmongodb = require('mongodb');
var monk = require('monk');
vardb = monk(config.db);
var mongoose = require('mongoose');
mongoose.connect(config.db);
var routes = require('./routes/index');
var users = require('./routes/users');
var dashboard = require('./routes/dashboard');
varauth = require('./routes/auth')
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.set('x-powered-by', false);
app.locals.appName = "My Gift App";
// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(isJSON);
var flash = require('connect-flash');
app.use(flash());
var passport = require('passport');
varexpressSession = require('express-session');
app.use(expressSession({secret: 'mySecretKey'}));
app.use(passport.initialize());
app.use(passport.session());
varinitializePassport = require('./passport/init');
initializePassport(passport);
//Database middleware
app.use(function(req,res,next){
req.db = db;
next();
});
app.use(helmet());
app.use(csrf());
app.use('/', routes);
app.use('/users', users);
app.use('/dash', dashboard);
app.use('/auth', auth);
var login = require('./routes/login')(passport);
app.use('/login', login);
routing.resources(app, controllers, "giftlist");
routing.expose_routing_table(app, { at: "/my-routes" });
// catch 404 and forward to error handler
app.use(function(req, res, next) {
var err = new Error('Not Found');
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;
首先,我们加载我们的 appconfig.js 文件并将其分配给变量 appconfig。记住,我们的 appconfig 模块导出一个函数。我们需要调用这个函数来运行代码并访问动态设置的属性。因此,我们调用 appconnfig() 并将返回的对象分配给变量 config。
最后,我们在调用 monk() 时使用 config.db 来创建数据库对象。你现在应该能够启动你的数据库和服务器,并且功能上应该没有差异。
接下来,我们需要在我们的 passport OAuth 策略中使用 appconfig。让我们从 passport/facebook.js 开始:
varFacebookStrategy = require('passport-facebook').Strategy;
var User = require('../models/user');
varappconfig = require('../appconfig')
varauth = appconfig();
module.exports = function(passport){
passport.use('facebook', new FacebookStrategy({
clientID: auth.facebookAuth.clientID,
clientSecret: auth.facebookAuth.clientSecret,
callbackURL: auth.facebookAuth.callbackURL,
profileFields: ['id', 'displayName', 'email']
},
function(accessToken, refreshToken, profile, cb) {
User.findOne({ 'facebook.id': profile.id }, function (err, user) {
if(err){
return cb(err)
} else if (user) {
return cb(null, user);
} else {
for(key in profile){
if(profile.hasOwnProperty(key)){
console.log(key + " ->" + profile[key]);
}
}
var newUser = new User();
newUser.facebook.id = profile.id;
newUser.facebook.token = accessToken;
newUser.facebook.name = profile.displayName;
if(profile.emails){
newUser.email = profile.emails[0].value;
}
newUser.save(function(err){
if(err){
throw err;
}else{
return cb(null, newUser);
}
});
}
});
}
));
}
再次从我们应用程序的根目录中引入 appconfig.js。然后我们调用返回的函数并将其分配给变量 auth。我们不应该需要额外的更改,重启我们的服务器应该会显示我们的更改已经生效。
最后,让我们对 passport/twitter.js 文件做同样的事情:
varTwitterStrategy = require('passport-twitter').Strategy;
var User = require('../models/user');
varappconfig = require('../appconfig')
varauth = appconfig();
module.exports = function(passport){
passport.use('twitter', new TwitterStrategy({
consumerKey : auth.twitterAuth.consumerKey,
consumerSecret : auth.twitterAuth.consumerSecret,
callbackURL : auth.twitterAuth.callbackURL
},
function(token, tokenSecret, profile, cb) {
User.findOne({ 'twitter.id': profile.id }, function (err, user) {
if(err){
return cb(err)
} else if (user) {
return cb(null, user);
} else {
// if there is no user, create them
var newUser = new User();
// set all of the user data that we need
newUser.twitter.id = profile.id;
newUser.twitter.token = token;
newUser.twitter.username = profile.username;
newUser.twitter.displayName = profile.displayName;
newUser.save(function(err){
if(err){
throw err;
}else{
return cb(null, newUser);
}
});
}
});
}
));
}
正如你所见,我们对 Twitter 授权策略文件进行了完全相同的修改。再次测试,它应该会以完全相同的方式工作。
设置云数据库
我们的单页应用(SPA)很快就会在云端运行,并且需要连接到一个数据库。将我们的应用迁移到云端需要我们的数据库也能从网络上访问。在本地机器上运行数据库是行不通的。
虽然有众多基于云的数据库服务,但我发现 MongoLab 是最容易设置、使用和维护的之一。他们提供免费的沙盒数据库,非常适合开发和实验。对于生产级应用,你可能需要考虑更高的订阅率。
我们将用于部署应用的 PaaS 平台 Heroku 与 MongoLab 配合得非常好,甚至提供一键添加 Mongolab 的服务。现在我们先手动设置,这样你可以更好地了解事情是如何运作的。
创建 MongoLab 账户
你需要做的第一件事是在 MongoLab 上设置你的账户。这非常简单。访问 mongolab.com/signup/ 并填写表格。注册后,你将被带到如下所示的仪表板:

目前,在我的账户中,我配置了两个数据库。如果你刚刚注册,这里将不会显示任何内容。你可以从这个仪表板设置和管理数据库以及您的账户。
创建数据库
现在,你已经在 MongoLab 上有一个账户,但没有数据库。我们需要创建一个新的数据库。幸运的是,MongoLab 让我们做这件事变得非常简单。在您的仪表板上,点击标记为 创建新 的按钮:

在创建新订阅页面,有许多不同的选择来设置新的部署。我们想要设置一个沙盒部署,它是免费的,并且会给你 500 MB 的存储空间。我选择了美国东部地区的亚马逊云服务,并选择了 单节点 | 沙盒。
滚动到页面底部,命名您的数据库,然后点击标记为“创建新的 MongoDB 部署”的按钮。我命名为 giftapp。哇!你现在已经是这个闪亮的新云基础 MongoDB 部署的骄傲所有者了。
设置用户访问数据库
目前,我们还没有从我们的应用连接到数据库。要做到这一点,你需要为数据库访问设置用户名和密码。从您的仪表板,点击您的新数据库名称,然后在下一个屏幕上点击 用户 选项卡:

从这里,记下标准的 MongoDB URI,它将包括你即将设置的用户名和密码。点击 添加数据库用户:

你将看到前面的弹出窗口。填写它;不要勾选只读。现在,你有一个可以访问数据的数据库和用户。记下 URI;你将需要使用它来访问这个数据库。如果你想测试它,你可以将其插入到你的 appconfig 文件中,以替换你的本地数据库。
将应用程序部署到 Heroku
现在,我们已经为 Web 部署准备好了大部分组件。我们将把我们的应用程序部署到支持 Node 的 PaaS 平台 Heroku。
准备使用 Heroku
将你的应用程序部署到 Heroku 需要几个简单的步骤。你需要安装 Git、设置 Heroku 账户、在 Heroku 上创建一个新的项目,然后它就准备好部署了。
设置 Git
部署到 Heroku 使用 Git 完成,因此你需要安装它。如果你还没有安装 Git,请访问 git-scm.com/book/en/v2/Getting-Started-Installing-Git 并按照你操作系统的说明进行操作。
在安装 Git 之后,你需要在 giftapp 文件夹中初始化一个 Git 仓库。从根目录开始,在你的命令行中输入以下命令:
$ gitinit
在初始化 Git 之后,你想要添加所有文件并将它们提交到你的仓库中:
$ git add .
$ git commit -m "initil commit"
就到这里了。
注册 Heroku 账户
下一步你需要做的是注册一个 Heroku 账户,如果你还没有的话。就像 MongoLab 一样,这个过程很简单。访问 signup.heroku.com/login 并填写以下截图所示的表单:

现在你已经拥有了一个免费的 Heroku 账户。
安装 HerokuToolbelt
Heroku 提供了一个名为 HerokuToolbelt 的 CLI 应用程序来管理应用程序。你需要安装这个工具来将你的应用程序部署到 Heroku。要为你的操作系统安装,请访问 toolbelt.heroku.com/ 并按照你操作系统的说明进行操作。
安装完成后,你可以从命令行登录 Heroku:
$ heroku login
Enter your Heroku credentials.
Email: john@notreallymyemail.com
Password (typing will be hidden):
Authentication successful.
现在,你可以开始了。
设置 Heroku 项目
现在我们需要实际设置你的 giftapp 项目以便部署到 Heroku。在你的终端中,确保你位于 giftapp 项目的根目录:
$ heroku create
Creating app... done, stack is cedar-14
https://guarded-lake-23534.herokuapp.com/ https://git.heroku.com/guarded-lake-23534.git
创建命令为我们创建了一个 Heroku 应用程序。在这种情况下,应用程序将通过 guarded-lake-23534.herokuapp.com/ 访问。现在导航到该 URL 不会很有趣,因为我们还没有实际部署任何内容。
注意
Heroku 为你生成一个随机的应用程序名称。你可以在创建调用中传递你想要的应用程序名称,只要它是唯一的,你的应用程序就会在那里运行。你也可以将自定义域名指向你的 Heroku 应用程序,但前提是你有一个付费账户——请参考 Heroku 文档获取更多信息。
创建命令所做的第二件事是为我们创建了一个远程 Git 仓库。这就是我们将如何部署我们的文件。我们还需要执行几个额外的步骤来部署我们的应用。
部署到 Heroku
现在大部分设置工作已经完成,是时候采取最后一步将我们的应用上线了。
定义 Procfile
我们需要一个Procfile来告诉 Heroku 在部署时运行什么。Procfile 是一个文件,它告诉 Heroku 的 dynos 运行哪些命令。为了使 Heroku 能够读取此文件,你必须将文件命名为 Procfile(不带任何扩展名),并将其包含在项目的顶级目录中。
在你的giftapp项目根目录下创建一个名为Procfile的文件,并向其中添加以下行:
web: node ./dist/bin/www
这告诉 Heroku 在dist目录中运行 www 脚本。
应用环境变量
如果你记得,我们的应用程序需要使用一些环境变量才能正确运行。为了设置这些变量,登录到你的 Heroku 账户并导航到你的仪表板dashboard.heroku.com/login。
点击你的应用名称以访问其仪表板。点击设置标签,然后会显示config变量。这就是我们将添加所有需要的环境变量的地方,如下面的截图所示:

你可以看到我已经添加了NODE_ENV、PORT和facebookClientID的变量。你将做同样的事情,并为appconfig.js文件中使用的每个环境变量添加一个,包括 DB。
确保调整你的 Facebook 和 Twitter 回调 ID 以使用你的新 Heroku 域名。如果你对此感到困惑,请滚动到设置页面的底部,它将会显示出来。
我将留给你做的另一个步骤是授权你的 Heroku 域名为你的 Facebook 和 Twitter 应用。只需进入你的应用设置并添加新域名。
部署
现在一切准备就绪,部署变得非常简单。我们将使用简单的 Gitpush。首先,让我们确保我们的dist文件已经准备好,并且通过运行以下代码确保所有内容都已正确提交到git:
$ grunt
Running "clean:0" (clean) task
>> 53 paths cleaned.
Running "copy:main" (copy) task
Copied 39 files
Running "uglify:my_target" (uglify) task
>> 4 files created.
Running "htmlmin:dist" (htmlmin) task
Minified 2 files
Done, without errors.
$ git add .
$ git commit -m "heroku commit"
[master 42e6f79] heroku commit
17 files changed, 451 insertions(+), 207 deletions(-)
create mode 100644 Procfile
create mode 100644 appconfig.js
create mode 100644 dist/Procfile
create mode 100644 dist/appconfig.js
create mode 100644 dist/bin/www
太好了,一切都已经检查完毕。如果你想进行一次理智的检查,你可以始终尝试git status。
现在,让我们将我们刚刚提交的内容推送到 Heroku:
$ git push heroku master
Counting objects: 234, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (207/207), done.
Writing objects: 100% (234/234), 46.90 KiB | 0 bytes/s, done.
Total 234 (delta 95), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
remote:
remote: NPM_CONFIG_LOGLEVEL=error
remote: NPM_CONFIG_PRODUCTION=true
remote: NODE_ENV=production
remote: NODE_MODULES_CACHE=true
remote:
remote: -----> Installing binaries
remote: engines.node (package.json): unspecified
remote: engines.npm (package.json): unspecified (use default)
remote:
remote: Resolving node version (latest stable) via semver.io...
remote: Downloading and installing node 5.6.0...
remote: Using default npm version: 3.6.0
remote:
remote: -----> Restoring cache
remote: Skipping cache restore (new runtime signature)
remote:
remote: -----> Building dependencies
remote: Pruning any extraneous modules
remote: Installing node modules (package.json)
remote: giftapp@0.0.0 /tmp/build_4e2a4d5757c9fb2834a0950c5e35235f
remote:
remote: -----> Discovering process types
remote: Procfile declares types -> web
remote:
remote: -----> Compressing...
remote: Done: 14.2M
remote: -----> Launching...
remote: Released v11
remote: https://guarded-lake-23534.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy.... done.
To https://git.heroku.com/guarded-lake-23534.git
* [new branch] master -> master
我们已经上线了!我省略了推送输出的很大一部分。它非常长。如果你查看这里的内容,你可以看到当我们将它推送到 Heroku 时HerokuToolbelt会做什么。你可以看到我们的应用正在启动。你可以看到部署是成功的。
扩展 SPA
我想以一些关于扩展应用的想法来结束。扩展应用是一种有点神秘的艺术,但如果你构建的应用变得非常受欢迎,你确实需要做好准备。
使用 PaaS 工具的好处之一是它们显著简化了扩展过程。
扩展数据库
在数据库扩展方面有许多选项和考虑因素。您只需看看 MongoLab 提供的不同套餐就可以看到这一点。MongoDB 本身支持分片和复制,因为它是为了扩展而构建的。
扩展的担忧包括数据库的大小——存储量有多少,以及性能——通常是一个 RAM 的因素或使用专用集群。
MongoLab 和其他提供商提供多种高存储和高性能计划的组合,以及几个步骤,允许您逐步增加扩展,而不会错过任何一步,或安装或配置新的硬件。
理解其使用情况很重要,这只有在应用程序运行一段时间后才会显现出来,以及这将如何影响存储。如果您的应用程序正在增长并对数据库进行大量访问,您将想要考虑其性能。随着数据库的填充,您将想要管理其存储。
服务器扩展
Heroku 使您能够非常容易地将应用程序扩展到所需的确切大小;这是他们建立业务的一部分。在 Heroku 上,应用程序在 dynos 中运行。将 dyno 想象为一个运行您的应用程序的轻量级容器。
Heroku 拥有多个不同性能级别的不同 dynos 运行,您可以从命令行或仪表板中添加和删除它们,甚至可以在不同的性能级别上进行操作。
Heroku 还提供了一些付费的附加功能,例如性能监控。
不想为 Heroku 做推销,其他 PaaS 提供商也提供类似的选择。Amazon Web Services 是 Node.js 的一个流行选择。还有其他选择,例如 Modulus 和 Digital Ocean。如果您正在部署一个现实世界的商业应用程序,寻找适合您的正确解决方案是值得的。
摘要
我们本章开始时使用 Grunt 将我们的应用程序打包到 dist 文件夹中。我们使用了通过 NPM 安装的 Grunt 插件,例如压缩我们的代码。我们花了一些时间探索 Grunt 插件生态系统,这是一个相当庞大且得到良好支持的生态系统。
我们随后准备将我们的应用程序部署到网络上,通过设置基于云的数据库来完成。我们在 MongoLab 上创建了一个账户,然后创建了一个新的数据库。接着,我们向数据库中添加了一个用户,以便我们能够从应用程序中访问它。
我们随后在 Heroku 上设置了一个账户,并准备将我们的应用程序部署出去。我们安装了 HerokuToolbelt,这使我们能够创建一个新的 Heroku 应用程序部署。我们通过 Heroku 仪表板访问我们的应用程序并添加了环境变量。最后,我们使用 Git 将我们的项目推送到 Heroku。
我们简要地涉及了诸如数据库和服务器扩展等主题。使用 PaaS 和基于网络的数据库管理系统,我们为当我们的应用程序像 Facebook 一样受欢迎时做好了准备。
感谢您阅读完整本书。如果您已经遵循了所有指示,您现在拥有了一个使用 JavaScript 编写的云部署、测试过的单页应用(SPA)。您现在拥有了从头到尾构建应用、从数据库到展示层的所有技能,使用的是现代 JavaScript。您应该对 SPA 架构、模块化代码和关注点分离有牢固的理解,并且应该与 MEAN 堆栈的各个方面都进行过合作。恭喜您,做得很好!


浙公网安备 33010602011771号