JavaScript-现代-Web-开发框架教程-全-

JavaScript 现代 Web 开发框架教程(全)

原文:JavaScript Frameworks for Modern Web Dev

协议:CC BY-NC-SA 4.0

一、Bower

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-0662-1_​1) contains supplementary material, which is available to authorized users.

伟大的事情是由一系列小事情集合起来完成的。—文森特·梵高

包管理的概念,也称为依赖性管理,并不新鲜。此类别中的工具为开发人员提供了一种管理项目所依赖的各种第三方库的机制。广泛使用的例子包括

  • NPM:node . js 的包管理器
  • composer:PHP 中的依赖管理工具
  • pip:PyPA 推荐的安装 Python 包的工具
  • NuGet:微软开发平台的包管理器,包括。网

虽然包管理并不是一个新概念,但是最近才开始被广泛采用的一个实践是将这一概念应用于前端 web 素材的管理,这些素材是作为现代 web 应用的构建块的 JavaScript 库、样式表、字体、图标和图像。随着构建现代 web 应用的基础变得越来越复杂,对这种结构的需求变得越来越明显。曾经依赖于一小部分定义广泛的“一刀切”的第三方库(例如 jQuery)的 Web 应用,现在发现自己正在使用许多更小的库,每个库都有严格定义的用途。这种方法的好处包括更小的模块更容易测试,以及父应用的灵活性更高,可以更容易地扩展第三方库或在必要时完全替换它们。

本章旨在帮助您快速入门并使用 Bower,这是一个前端包管理器,其根源在于 Twitter 的开源计划。涵盖的主题包括

  • 安装和配置 Bower
  • 将 Bower 添加到项目中
  • 查找、添加和删除包
  • 语义版本控制
  • 管理依赖链
  • 创建 Bower 包

入门指南

与 Bower 的所有交互都通过命令行工具进行,该工具可以通过 npm 安装。如果您还没有安装 Bower,那么您应该在继续之前安装它,如清单 1-1 所示。

Listing 1-1. Installing the bower Command-Line Utility via npm

$ npm install -g bower

$ bower --version

1.3.12

Note

Node 的软件包管理器(npm)允许用户在两种环境中安装软件包:本地或全局。在本例中,bower安装在全局上下文中,该上下文通常是为命令行工具保留的。

配置 Bower

Bower 是通过一个(可选的)JSON 文件基于每个项目进行配置的,该文件存在于项目的根文件夹.bowerrc中。出于介绍的目的,我们将只查看这个文件中最频繁更改的设置(参见清单 1-2 )。

Listing 1-2. The .bowerrc File from This Chapter’s Sample Project

// example-bootstrap/.bowerrc

{

"directory": "./public/bower_components"

}

默认情况下,Bower 会将项目的依赖项存储在bower_components文件夹中。您可能想要更改这个位置,而directory设置允许您这样做。

清单

Bower 为开发人员提供了一个单一的入口点,从这里可以找到、添加、升级和删除第三方库。随着这些动作的发生,Bower 用项目依赖项的最新列表更新一个被称为“manifest”的 JSON 文件。清单 1-3 显示了本章示例项目的 Bower 清单。在这个例子中,Bower 意识到了一个单一的依赖,即引导 CSS 框架。

Listing 1-3. Bower Manifest for This Chapter’s Sample Project

// example-bootstrap/bower.json

{

"name": "example-bootstrap",

"version": "1.0.0",

"homepage": "https://github.com/username/project

"authors": [

"John Doe <john.doe@gmail.com>"

],

"dependencies": {

"bootstrap": "3.2.0"

}

}

如果我们通过删除public/bower_components文件夹意外地删除了我们项目的所有依赖项,我们可以通过发出一个命令很容易地将我们的项目恢复到它以前的状态,如下所示。这样做会导致 Bower 将其清单与我们项目的当前文件结构进行比较,确定缺少哪些依赖项,并恢复它们。

$ bower install

这种行为的结果是,我们可以选择在版本控制中忽略项目的/public/bower_components文件夹。通过只提交 Bower 的清单,而不是依赖项本身,我们项目的源代码可以保持在一个更干净的状态,只包含与我们自己的工作直接相关的文件。

Note

对于将项目的依赖关系置于版本控制之外是否是一个好主意,人们有不同的看法。一方面,这样做会产生一个更干净的存储库。另一方面,这也打开了潜在问题的大门。)遇到连接问题。普遍的共识似乎是,如果您正在处理一个“可部署的”项目(例如,一个应用,而不是一个模块),提交您的依赖项是首选的方法。否则,将项目的依赖项置于版本控制之外可能是个好主意。

创建新清单

当您第一次在项目中使用 Bower 时,通常最好让 Bower 为您创建一个新的清单,如下所示。之后,如果需要,您可以进一步修改它。

$ bower init

查找、添加和删除 Bower 包

Bower 的命令行工具为定位、安装和删除软件包提供了许多有用的命令。让我们看看这些命令是如何帮助简化管理项目外部依赖关系的过程的。

查找包

Bower 改进您的开发工作流程的主要方法之一是为您提供一个集中的注册表,从中可以找到第三方库。要搜索 Bower 注册表,只需将search参数传递给 Bower,后跟要搜索的关键字,如清单 1-4 所示。在本例中,只显示了返回的搜索结果列表中的一小段摘录。

Listing 1-4. Searching Bower for jQuery

$ bower search jquery

Search results:

jquery git://github.com/jquery/jquery.git

jquery-ui git://github.com/components/jqueryui

jquery.cookie git://github.com/carhartl/jquery-cookie.git

jquery-placeholder git://github.com/mathiasbynens/jquery-placeholder.git

添加包

每个搜索结果都包括注册软件包的名称,以及可以直接访问它的 GitHub 存储库的 URL。一旦我们找到了想要的包,我们就可以将它添加到我们的项目中,如清单 1-5 所示。

Listing 1-5. Adding jQuery to Our Project

$ bower install jquery --save

bower jquery#*            cached git://github.com/jquery/jquery.git#2.1.3

bower jquery#*          validate 2.1.3 against git://github.com/jquery/jquery.git#*

bower jquery#>= 1.9.1     cached git://github.com/jquery/jquery.git#2.1.3

bower jquery#>= 1.9.1   validate 2.1.3 against git://github.com/jquery/jquery.git#>= 1.9.1

bower jquery#>= 1.9.1     cached git://github.com/jquery/jquery.git#2.1.3

bower jquery#>= 1.9.1   validate 2.1.3 against git://github.com/jquery/jquery.git#>= 1.9.1

bower jquery#>= 1.9.1    install jquery#2.1.3

jquery#2.1.3 public/bower_components/jquery

Note

Bower 不托管任何与其注册表中包含的包相关联的文件;它让 GitHub 来承担这个责任。虽然可以在任何 URL 托管包,但是大多数公共包都可以在 GitHub 上找到。

请注意,在清单 1-5 中,我们将--save选项传递给了 Bower 的install命令。默认情况下,install命令会将请求的包添加到一个项目中,而不会更新它的清单。通过传递--save选项,我们指示 Bower 将这个包永久存储在其依赖项列表中。

清单 1-6 显示了本章示例项目中的 HTML。通过 Bower 将 jQuery 添加到我们的项目后,我们可以像加载任何其他库一样,通过一个script标签来加载它。

Listing 1-6. HTML from Our Sample Project That References the jQuery Package Just Added

// example-jquery/public/index.html

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Bower Example</title>

</head>

<body>

<div id="container"></div>

<script src="/bower_components/jquery/dist/jquery.min.js"></script>

<script>

$(document).ready(function() {

$('#container').html('<p>Hello, world!</p>');

});

</script>

</body>

</html>

开发依赖性

默认情况下,Bower 安装的任何包都被认为是“生产”依赖项,但是这种行为可以通过传递--save-dev选项来覆盖。这样做会将所有已安装的软件包标记为“开发”依赖项。此类包仅用于开发目的,而非项目的最终用户。

一旦我们准备好将应用部署到生产环境中,我们就可以指示 Bower 只安装生产依赖项,如下所示,从而产生一个更精简的版本,其中不包含最终用户不感兴趣的无关文件。

$ bower install -- production

移除包

移除 Bower 包的过程很简单。和前面的例子一样,我们传递--save参数来更新 Bower 的清单,以反映这一变化:

$ bower uninstall jquery --save

语义版本控制

如果您要安装 jQuery(如清单 1-5 所示),然后查看项目的 Bower 清单的内容,您会看到类似于清单 1-7 的内容。

Listing 1-7. Semantic Version (Semver) Number

"dependencies": {

"jquery": "∼2.1.3"

}

我们在清单 1-7 中看到的版本号 2.1.3(暂时忽略字符)就是所谓的语义版本号(简称 semver)。语义版本化是一种标准,它描述了一种通用格式,开发人员可以使用这种格式为他们的项目分配版本号。格式如下所示:

Version X.Y.Z (Major.Minor.Patch)

语义版本化格式要求开发人员创建明确定义的(通过文档或者通过清晰的、自文档化的代码)API,为用户提供进入库的单一入口点。刚刚起步的新项目通常从版本 0.0.0 开始,并随着新版本的创建而逐步升级。版本号低于 1.0.0 的项目被认为是处于重度开发中,因此,允许对其 API 进行大范围的更改,而不改变其主要版本号。但是,版本号为 1.0.0 或更高版本号的项目受以下一组规则的指导,这些规则决定了应如何更改版本号:

  • 当更新导致用户在以前版本中与项目 API 的交互方式发生重大变化时,项目的主版本号应该发生变化。
  • 当新特性以向后兼容的方式添加到项目中时,项目的次版本号应该改变(即,现有的 API 没有被破坏)。
  • 当引入向后兼容的错误修复时,项目的补丁版本号应该改变。

这些规则为开发人员提供了对任何两个版本之间发生的变化程度的洞察。随着我们的 Bower 清单的增长,以及我们开始向我们的项目添加越来越多的依赖项,这样的洞察力将会被证明是有用的。

Note

清单 1-7 中显示的字符告诉 Bower,无论何时运行install命令,都允许自动安装“相对接近”版本 2.1.3 的 jQuery 未来版本。如果在同一个句子中使用短语“相对接近”和“自动安装”让你毛骨悚然,你并不孤单。最佳实践建议您在引用 Bower 的依赖项时避免使用“∨X . y . z”格式。相反,您最好指定希望包含在项目中的依赖项的确切版本。随着未来更新的发布,您可以手动检查它们,并自行决定是否以及何时更新。本章后面的例子将遵循这个建议。

管理依赖链

开发人员从使用 Bower 中获得的主要好处之一是可以轻松地监控和集成对项目整个依赖链的更新。为了说明这一点,让我们看看本章的示例项目中包含的依赖项列表(参见清单 1-8 )。

Listing 1-8. Installing and Listing the Various Bower Packages Required by Our Sample Project

$ bower install

bower bootstrap#3.2.0     cached git://github.com/twbs/bootstrap.git#3.2.0

bower bootstrap#3.2.0   validate 3.2.0 against git://github.com/twbs/bootstrap.git#3.2.0

bower jquery#>= 1.9.0     cached git://github.com/jquery/jquery.git#2.1.3

bower jquery#>= 1.9.0   validate 2.1.3 against git://github.com/jquery/jquery.git#>= 1.9.0

bower bootstrap#3.2.0    install bootstrap#3.2.0

bower jquery#>= 1.9.0    install jquery#2.1.3

bootstrap#3.2.0 public/bower_components/bootstrap

ε──??″

jquery#2.1.3 public/bower_components/jquery

$ bower list

bower check-new     Checking for new versions of the project dependencies..

example-bootstrap#1.0.0 /opt/example-bootstrap

ε□□□□□□□□□□□

ε──??″

多亏了 Bower,我们现在有了一个简单的图表来描述我们的项目所依赖的外部依赖,以及它们之间的关系。我们可以看到,我们有一个 Bootstrap 依赖项,它又有自己对 jQuery 的依赖项。Bower 还打印当前安装的每个组件的特定版本。

Note

许多第三方库不是完全独立的,它们有自己的依赖项。Bootstrap(依赖于 jQuery)就是这样一个例子。当添加这样一个包时,Bower 足够聪明,能够识别这些额外的依赖项,如果它们不存在的话,它会主动将它们添加到您的项目中。然而,需要注意的是,与更复杂的包管理器(例如 npm)不同,Bower 将所有的包存储在一个平面文件夹结构中,这意味着如果不小心的话,您会偶尔遇到版本冲突。

在清单 1-8 中,Bower 告诉我们,比我们的项目目前所依赖的版本(3.2.0)更新的 Bootstrap 版本(3.3.2)已经可用。我们可以通过修改我们项目的清单来引用这个新版本,并重新运行install命令来更新这个依赖,如清单 1-9 所示。

Listing 1-9. Installing Bower Packages After Having Updated the Version of jQuery Our Project Relies On

$ bower install

bower bootstrap#3.3.2     cached git://github.com/twbs/bootstrap.git#3.3.2

bower bootstrap#3.3.2   validate 3.3.2 against git://github.com/twbs/bootstrap.git#3.3.2

bower bootstrap#3.3.2    install bootstrap#3.3.2

bootstrap#3.3.2 public/bower_components/bootstrap

ε──??″

创建 Bower 包

到目前为止,我们的重点是将 Bower 集成到我们自己的项目中。我们已经在我们的项目中初始化了 Bower,并且发现了我们可以如何查找、添加和删除包。然而,在某一点上,你会希望发现自己想要与他人共享自己的包。为此,您需要确保遵循一些简单的准则,从选择有效的名称开始。

选择一个有效的名称

您需要为您的包选择一个在 Bower 的公共注册表中唯一的名称。使用 Bower 的search命令来查找您想要的名称是否可用。其他要求包括

  • 该名称应为“slug”格式;比如my-unique-project
  • 名称应该全部小写。
  • 只允许字母数字字符、点和破折号。
  • 该名称应以字母字符开头和结尾。
  • 不允许连续的点和破折号。
  • 确定名称后,相应地更新项目的bower.json文件的内容。

使用 Semver Git 标签

在本章的前面,我们看了一下语义版本化的概念,这是一个为项目分配有意义的版本号的通用标准。您将希望确保您遵循这个标准,因为这将允许您的软件包的消费者跟踪和集成您未来的更改。

如果您想要共享的包刚刚开始,合适的版本号应该是 0.0.0。当您提交将来的更改并创建新的版本时,您可以根据更新的程度适当地增加该值。当您确定您的项目已经达到了它的第一个“稳定的”里程碑时,将您的版本号更新到 1.0.0 来反映这种状态。

你的项目的每个版本号在 GitHub 上都应该有一个对应的标签。正是 GitHub 标签和您的包的版本之间的这种关系允许消费者在他们的项目中引用特定的版本。

假设您已经将代码提交给 GitHub,参见清单 1-10 中的示例,了解如何创建您的第一个标签。

Listing 1-10. Creating Your First Semver Git Tag

$ git tag -a 0.0.1 -m "First release."

$ git push origin 0.0.1

将包发布到注册表

既然我们已经为我们的包选择了一个合适的名称,并分配了一个版本号(以及 GitHub 上相应的标签),现在是时候将我们的包发布到公共的 Bower registry 了:

$ bower register``my``-``package``-namehttps://github.com/username/``my``-``package``-name.git

Note

请记住,Bower 旨在作为其他开发人员可以在自己的项目中使用的库和组件的集中注册中心。它并不打算作为整个应用的分发机制。

摘要

Bower 是一个简单的命令行工具,它简化了一些与管理前端素材相关的繁琐任务。与来自其他平台的众所周知的包管理器(例如 Node 的 npm)不同,Bower 不是为处理任何一个平台或语言的特定需求而设计的;相反,它倾向于用一种相当通用的方法来处理包管理的概念。创建 Bower 的开发人员有意创建一个非常简单的工具来管理各种各样的前端素材——不仅仅是代码,还有样式表、字体、图像和其他不可预见的未来依赖。

开发外部依赖性很小的普通 web 应用的开发人员可能会发现 Bower 带来的好处没有什么价值。也就是说,琐碎的 web 应用有迅速演变成复杂的 web 应用的趋势,随着这一过程的发生,开发人员通常会逐渐意识到 Bower 的好处。

无论您认为您的项目有多复杂(或多简单),我们都鼓励您尽早考虑将 Bower 集成到您的工作流程中。痛苦的经历告诉我们——项目本身。犯了结构过小的错误,你就有可能造成不断增加的“技术债务”负担,最终你必须为此付出代价。在这些不受欢迎的选择之间取得微妙平衡的过程既是一门科学,也是一门艺术。这也是一个从未完全学会的过程,但必须随着我们的贸易工具的变化而不断调整。

二、Grunt

我很懒。但是是懒人发明了轮子和自行车,因为他们不喜欢走路或搬运东西。—波兰前总统莱赫·瓦文萨

在《编程 Perl》一书中,拉里·沃尔(这种语言的著名创造者)提出了这样一个观点,即所有成功的程序员都有三个重要特征:懒惰、急躁和傲慢。乍一看,这些特点听起来都很消极,但深入一点,你会发现他的说法中隐藏的含义:

  • 懒惰:懒惰的程序员讨厌重复自己。因此,他们倾向于投入大量精力来创建有用的工具,为他们执行重复的任务。他们也倾向于很好地记录这些工具,以省去以后回答关于它们的问题的麻烦。
  • 不耐烦:不耐烦的程序员已经学会对他们的工具期望过高。这种期望教会他们创建软件,不仅对用户的需求做出反应,而且实际上试图预测这些需求。
  • 傲慢:优秀的程序员对自己的工作非常自豪。正是这种自豪感促使他们编写别人不愿批评的软件——这是我们都应该努力争取的工作。

在这一章中,我们将关注这三个特征中的第一个,懒惰,以及 Grunt,一个流行的 JavaScript“任务运行器”,它通过为开发人员提供自动化软件开发中经常出现的重复构建任务的工具包来支持开发人员培养这一特征,例如:

  • 脚本和样式表编译和缩小
  • 测试
  • 林挺
  • 数据库迁移
  • 部署

换句话说,Grunt 帮助那些努力工作得更聪明而不是更努力的开发人员。如果你对这个想法感兴趣,请继续读下去。当你读完这一章后,你将会很快掌握咕噜。在本章中,您将学习如何执行以下操作:

  • 创建可配置的任务,使几乎每个项目都伴随的软件开发的重复方面自动化
  • 使用 Grunt 提供的简单而强大的抽象与文件系统交互
  • 发布普通插件,其他开发者可以从中受益,也可以为之做出贡献
  • 利用 Grunt 现有的社区支持插件库,在撰写本文时已经有超过 4400 个例子

安装咕噜声

在继续之前,您应该确保已经安装了 Grunt 的命令行工具。作为一个 npm 包,安装过程如清单 2-1 所示。

Listing 2-1. Installing the grunt Command-Line Utility via npm

$ npm install -g grunt-cli

$ grunt --version

grunt-cli v0.1.13

Grunt 是如何工作的

Grunt 为开发人员提供了一个工具包,用于创建执行重复性项目任务的命令行工具。这类任务的例子包括 JavaScript 代码的缩减和 Sass 样式表的编译,但是 Grunt 的使用没有限制。Grunt 可以用来创建满足单个项目特定需求的简单任务,也就是您不打算共享或重用的任务,但是 Grunt 真正的强大之处在于它能够将任务打包成可重用的插件,然后其他人可以发布、共享、使用和改进这些插件。在撰写本文时,有超过 4400 个这样的插件。

Grunt tick 由四个核心组件组成,我们现在将介绍这四个组件。

格朗蒂尔

Grunt 的核心是 Gruntfile,一个保存为项目根目录下的Gruntfile.js(见清单 2-2 )的节点模块。在这个文件中,我们可以加载 Grunt 插件,创建我们自己的定制任务,并根据我们项目的需要配置它们。每次运行 Grunt 时,它的第一个任务就是从这个模块中检索行军命令。

Listing 2-2. Sample Gruntfile

// example-starter/Gruntfile.js

module.exports = function(grunt) {

/**

* Configure the various tasks and plugins that we’ll be using

*/

grunt.initConfig({

/* Grunt’s 'file' API provides developers with helpful abstractions for

interacting  with the file system. We’ll take a look at these in greater

detail later in the chapter. */

'pkg': grunt.file.readJSON('package.json'),

'uglify': {

'development': {

'files': {

'build/app.min.js': ['src/app.js', 'src/lib.js']

}

}

}

});

/**

* Grunt plugins exist as Node packages, published via npm. Here, we load the

* 'grunt-contrib-uglify' plugin, which provides a task for merging and minifying

* a project’s source code in preparation for deployment.

*/

grunt.loadNpmTasks('grunt-contrib-uglify');

/**

* Here we create a Grunt task named 'default' that does nothing more than call

* the 'uglify' task. In other words, this task will serve as an alias to

* 'uglify'. Creating a task named 'default' tells Grunt what to do when it is

* run from the command line without any arguments. In this example, our 'default'

* task calls a single, separate task, but we could just as easily have called

* multiple tasks (to be run in sequence) by adding multiple entries to the array

* that is passed.

*/

grunt.registerTask('default', ['uglify']);

/**

* Here we create a custom task that prints a message to the console (followed by

* a line break) using one of Grunt’s built-in methods for providing user feedback.

* We’ll look at these in greater detail later in the chapter.

*/

grunt.registerTask('hello-world', function() {

grunt.log.writeln('Hello, world.');

});

};

任务

任务是 Grunt 的基本构件,只不过是通过 Grunt 的registerTask()方法注册了指定名称的函数。在清单 2-2 中,显示了一个简单的hello-world任务,它向控制台打印一条消息。这个任务可以从命令行调用,如清单 2-3 所示。

Listing 2-3. Running the hello-world Task Shown in Listing 2-2

$ grunt hello-world

Running "hello-world" task

Hello, world.

Done, without errors.

多个任务也可以用一个命令按顺序运行,如清单 2-4 所示。每个任务将按照传递的顺序运行。

Listing 2-4. Running Multiple Grunt Tasks in Sequence

$ grunt hello-world uglify

Running "hello-world" task

Hello, world.

Running "uglify:development" (uglify) task

>> 1 file created.

Done, without errors.

我们刚刚看到的hello-world任务是一个基本的、独立的繁重任务的例子。此类任务可用于实现特定于单个项目需求的简单操作,您不打算重用或共享这些操作。然而,大多数时候,你会发现自己不是与独立的任务交互,而是与打包成 Grunt 插件并发布到 npm 的任务交互,以便其他人可以重用它们并为它们做出贡献。

插件

Grunt 插件是可配置任务的集合(作为 npm 包发布),可以在多个项目中重用。存在成千上万个这样的插件。在清单 2-2 中,Grunt 的loadNpmTasks()方法用于加载grunt-contrib-uglify节点模块,这是一个 Grunt 插件,它将一个项目的 JavaScript 代码合并成一个适合部署的小型文件。

Note

可以在 http://gruntjs.com/plugins 找到所有可用的 Grunt 插件列表。名称以contrib-为前缀的插件由 Grunt 背后的开发者官方维护。

配置

Grunt 以强调“配置胜于代码”而闻名:任务和插件的创建,其功能由每个项目中指定的配置定制。正是这种代码与配置的分离,使得开发人员能够创建易于被其他人重用的插件。在这一章的后面,我们将会看到配置 Grunt 插件和任务的各种方法。

向您的项目添加 Grunt

在本章的前面,我们通过将grunt-cli npm 包作为一个全局模块来安装 Grunt 的命令行工具。我们现在应该可以从命令行访问grunt工具了,但是我们仍然需要为我们打算使用它的每个项目添加一个本地grunt依赖项。下面显示了从项目的根文件夹中调用的命令。这个例子假设 npm 已经在项目中初始化,并且一个package.json文件已经存在。

$ npm install grunt --save-dev

我们项目的package.json文件现在应该包含一个类似于清单 2-5 所示的grunt条目。

Listing 2-5. Our Project’s Updated package.json File

// example-tasks/package.json

{

"name": "example-tasks",

"version": "1.0.0",

"devDependencies": {

"grunt": "0.4.5"

}

}

将 Grunt 与我们的项目集成的最后一步是创建一个 Gruntfile(参见清单 2-6 ),它应该保存在项目的根文件夹中。在我们的 Gruntfile 中,有一个方法叫做loadTasks(),这将在下一节中讨论。

Listing 2-6. Contents of Our Project’s Gruntfile

// example-tasks/Gruntfile.js

module.exports = function(grunt) {

grunt.loadTasks('tasks');

};

保持正常的咕噜声结构

我们希望当你读完这一章的时候,你会发现 Grunt 是一个很有价值的工具,可以自动完成你在日常工作流程中遇到的许多重复、乏味的任务。也就是说,如果我们告诉你我们对咕噜声的最初反应是积极的,那我们就是在撒谎。事实上,一开始我们对这个工具很反感。为了帮助解释原因,让我们来看看 Grunt 官方文档中突出显示的 Grunt 文件(参见清单 2-7 )。

Listing 2-7. Example Gruntfile Provided by Grunt’s Official Documentation

module.exports = function(grunt) {

grunt.initConfig({

pkg: grunt.file.readJSON('package.json'),

concat: {

options: {

separator: ';'

},

dist: {

src: ['src/**/*.js'],

dest: 'dist/<%= pkg.name %>.js'

}

},

uglify: {

options: {

banner: '/*! <%= grunt.template.today("dd-mm-yyyy") %> */\n'

},

dist: {

files: {

'dist/<%= pkg.name %>.min.js': ['<%= concat.dist.dest %>']

}

}

},

qunit: {

files: ['test/**/*.html']

},

jshint: {

files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],

options: {

// options here to override JSHint defaults

globals: {

jQuery: true,

console: true,

module: true,

document: true

}

}

},

watch: {

files: ['<%= jshint.files %>'],

tasks: ['jshint', 'qunit']

}

});

grunt.loadNpmTasks('grunt-contrib-uglify');

grunt.loadNpmTasks('grunt-contrib-jshint');

grunt.loadNpmTasks('grunt-contrib-qunit');

grunt.loadNpmTasks('grunt-contrib-watch');

grunt.loadNpmTasks('grunt-contrib-concat');

grunt.registerTask('test', ['jshint', 'qunit']);

grunt.registerTask('default', ['jshint', 'qunit', 'concat', 'uglify']);

};

清单 2-7 中显示的 Gruntfile 是一个相对简单的项目。我们已经发现这个例子有点笨拙,但是在更大的项目中,我们已经看到这个文件膨胀到这个大小的许多倍。结果是一片混乱,难以阅读和维护。有经验的开发人员绝不会以一种将不相关领域的功能组合到一个单一的整体文件中的方式编写他们的代码,所以我们为什么要以不同的方式处理我们的任务运行程序呢?

保持一个合理的 Grunt 结构的秘密在于 Grunt 的loadTasks()函数,如清单 2-6 所示。在这个例子中,tasks参数引用了一个相对于我们项目的 Gruntfile 的tasks文件夹。一旦这个方法被调用,Grunt 将加载并执行它在这个文件夹中找到的每个节点模块,每次传递一个对grunt对象的引用。这种行为为我们提供了将项目的普通配置组织成一系列独立模块的机会,每个模块负责加载和配置单个任务或插件。清单 2-8 中显示了其中一个较小模块的示例。这个任务可以通过从命令行运行grunt uglify来执行。

Listing 2-8. Example Module (uglify.js) Within Our New tasks Folder

// example-tasks/tasks/uglify.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-uglify');

grunt.config('uglify', {

'options': {

'banner': '/*! <%= grunt.template.today("dd-mm-yyyy") %> */\n'

},

'dist': {

'files': {

'dist/app.min.js': ['src/index.js']

}

}

});

};

使用任务

如前所述,任务是构建 Grunt 的基础——一切都从这里开始。您很快就会发现,Grunt 插件只不过是一个或多个打包到节点模块中并通过 npm 发布的任务。我们已经看到了一些演示创建基本任务的例子,所以让我们来看看一些可以帮助我们充分利用它们的附加特性。

管理配置

Grunt 的config()方法既作为配置的“获取者”又作为配置的“设置者”。在清单 2-9 中,我们看到一个基本的 Grunt 任务如何通过使用这个方法来访问它的配置。

Listing 2-9. Managing Configuration Within a Basic Grunt Task

module.exports = function(grunt) {

grunt.config('basic-task', {

'message': 'Hello, world.'

});

grunt.registerTask('basic-task', function() {

grunt.log.writeln(grunt.config('basic-task.message'));

});

};

Note

在清单 2-9 中,“点符号”用于访问嵌套的配置值。同样,点符号可以用来设置嵌套的配置值。如果 Grunt 在配置对象中遇到一个不存在的路径,Grunt 将创建一个新的空对象,而不会抛出错误。

任务描述

随着时间的推移,项目有增加复杂性的趋势。伴随着这种额外的复杂性,通常会出现新的繁重任务。随着新任务的增加,经常很容易忘记哪些任务是可用的,它们做什么,以及它们是如何被调用的。幸运的是,Grunt 通过为我们的任务分配描述,为我们提供了解决这个问题的方法,如清单 2-10 所示。

Listing 2-10. Assigning a Description to a Grunt Task

// example-task-description/Gruntfile.js

module.exports = function(grunt) {

grunt.config('basic-task', {

'message': 'Hello, world.'

});

grunt.registerTask('basic-task', 'This is an example task.', function() {

grunt.log.writeln(grunt.config('basic-task.message'));

});

grunt.registerTask('default', 'This is the default task.', ['basic-task']);

};

通过向registerTask()方法传递一个额外的参数,Grunt 允许我们为正在创建的任务提供描述。当从命令行请求帮助时,Grunt 会很有帮助地提供这些信息,如清单 2-11 所示,其中包括 Grunt 提供的信息的摘录。

Listing 2-11. Requesting Help from the Command Line

$ grunt --help

...

Available tasks

basic-task  This is an example task.

default  This is the default task.

...

异步任务

默认情况下,普通任务应该同步运行。任务的函数一返回,就被认为完成了。然而,有时您会发现自己在一个任务中与其他异步方法交互,这些方法必须首先完成,然后您的任务才能将控制权交还给 Grunt。这个问题的解决方案如清单 2-12 所示。在一个任务中,对async()方法的调用将通知 Grunt 它异步执行。该方法将返回一个回调函数,在我们的任务完成后调用。在此之前,Grunt 将暂停任何附加任务的执行。

Listing 2-12. Asynchronous Grunt Task

// example-async/tasks/list-files.js

var glob = require('glob');

module.exports = function(grunt) {

grunt.registerTask('list-files', function() {

/**

* Grunt will wait until we call the done() function to indicate that our

* asynchronous task is complete.

*/

var done = this.async();

glob('*', function(err, files) {

if (err) {

grunt.fail.fatal(err);

}

grunt.log.writeln(files);

done();

});

});

};

任务相关性

复杂的工作流程最好被认为是一系列协同工作以产生最终结果的步骤。在这种情况下,指定一个任务需要一个或多个单独的任务在它之前通常会很有帮助,如清单 2-13 所示。

Listing 2-13. Declaring a Task Dependency

// example-task-dependency/tasks/step-two.js

module.exports = function(grunt) {

grunt.registerTask('step-two', function() {

grunt.task.requires('step-one');

});

};

在这个例子中,step-two任务要求step-one任务在继续之前先运行。任何直接调用step-two的尝试都会导致错误,如清单 2-14 所示。

Listing 2-14. Grunt Reporting an Error When a Task Is Called Before Any Tasks on Which It Depends Have Run

$ grunt step-two

Running "step-two" task

Warning: Required task "step-one" must be run first. Use --force to continue.

Aborted due to warnings.

多任务

除了基本任务之外,Grunt 还支持它所谓的“多任务”多重任务很容易成为 Grunt 最复杂的方面,所以如果你一开始就发现自己很困惑,你并不孤单。然而,在回顾了几个例子之后,它们的目的应该开始变得清晰起来——这时,你就可以很好地掌握 Grunt 了。

在我们继续之前,让我们先看一个简单的例子(参见清单 2-15 ),它展示了一个简单的多任务及其配置。

Listing 2-15. Grunt Multi-Task

// example-list-animals/tasks/list-animals.js

module.exports = function(grunt) {

/**

* Our multi-task’s configuration object. In this example, 'mammals'

* and 'birds' each represent what Grunt refers to as a 'target.'

*/

grunt.config('list-animals', {

'mammals': {

'animals': ['Cat', 'Zebra', 'Koala', 'Kangaroo']

},

'birds': {

'animals': ['Penguin', 'Sparrow', 'Eagle', 'Parrot']

}

});

grunt.registerMultiTask('list-animals', function() {

grunt.log.writeln('Target:', this.target);

grunt.log.writeln('Data:', this.data);

});

};

多任务非常灵活,因为它们被设计为支持单个项目中的多个配置(称为“目标”)。清单 2-15 所示的多任务有两个目标:mammalsbirds。该任务可以针对特定目标运行,如清单 2-16 所示。

Listing 2-16. Running the Grunt Multi-Task Shown in Listing 2-15 Against a Specific Target

$ grunt list-animals:mammals

Running "list-animals:mammals" (list-animals) task

Target: mammals

Data: { animals: [ 'Cat', 'Zebra', 'Koala', 'Kangaroo' ] }

Done, without errors.

多任务也可以在没有任何参数的情况下被调用,在这种情况下,它们被执行多次,每个可用的目标执行一次。清单 2-17 显示了在没有指定目标的情况下调用这个任务的结果。

Listing 2-17. Running the Multi-Task Shown in Listing 2-15 Without Specifying a Target

$ grunt list-animals

Running "list-animals:mammals" (list-animals) task

Target: mammals

Data: { animals: [ 'Cat', 'Zebra', 'Koala', 'Kangaroo' ] }

Running "list-animals:birds" (list-animals) task

Target: birds

Data: { animals: [ 'Penguin', 'Sparrow', 'Eagle', 'Parrot' ] }

在这个例子中,我们的多任务运行了两次,每个可用目标运行一次(mammalsbirds)。注意在清单 2-15 中,我们在多任务中引用了两个属性:this.targetthis.data。这些属性允许我们的多任务获取当前运行的目标的信息。

多任务选项

在多任务的配置对象中,存储在options键下的任何值(参见清单 2-18 )都会得到特殊处理。

Listing 2-18. Grunt Multi-Task with Configuration Options

// example-list-animals-options/tasks/list-animals.js

module.exports = function(grunt) {

grunt.config('list-animals', {

'options': {

'format': 'array'

},

'mammals': {

'options': {

'format': 'json'

},

'animals': ['Cat', 'Zebra', 'Koala', 'Kangaroo']

},

'birds': {

'animals': ['Penguin', 'Sparrow', 'Eagle', 'Parrot']

}

});

grunt.registerMultiTask('list-animals', function() {

var options = this.options();

switch (options.format) {

case 'array':

grunt.log.writeln(this.data.animals);

break;

case 'json':

grunt.log.writeln(JSON.stringify(this.data.animals));

break;

default:

grunt.fail.fatal('Unknown format: ' + options.format);

break;

}

});

};

多任务选项为开发人员提供了一种为任务定义全局选项的机制,然后可以在目标级别覆盖这些选项。在本例中,列出动物('array')的全局格式是在任务级别定义的。目标mammals已经选择覆盖这个值('json'),而任务birds没有。因此,mammals将显示为 JSON,而birds由于继承了全局选项,将显示为数组。

你将会遇到的绝大多数 Grunt 插件都可以配置为多任务。这种方法提供的灵活性允许您在不同的情况下以不同的方式应用相同的任务。一个经常遇到的场景涉及到为每个构建环境创建单独的目标。例如,在编译应用时,您可能希望根据是针对本地开发环境进行编译还是准备发布到生产环境来修改任务的行为。

配置模板

Grunt 配置对象支持模板字符串的嵌入,模板字符串可以用来引用其他配置值。Grunt 喜欢的模板格式遵循 Lodash 和下划线工具库的格式,这将在后面的章节中详细介绍。关于如何使用该功能的示例,请参见清单 2-19 和清单 2-20 。

Listing 2-19. Sample Gruntfile That Stores the Contents of Its Project’s package.json File Under the pkg Key Within Grunt’s Configuration Object

// example-templates/Gruntfile.js

module.exports = function(grunt) {

grunt.initConfig({

'pkg': grunt.file.readJSON('package.json')

});

grunt.loadTasks('tasks');

grunt.registerTask('default', ['test']);

};

Listing 2-20. A Subsequently Loaded Task with Its Own Configuration That Is Able to Reference Other Configuration Values Through the Use of Templates

// example-templates/tasks/test.js

module.exports = function(grunt) {

grunt.config('test', {

'banner': '<%= pkg.name %>-<%= pkg.version %>'

});

grunt.registerTask('test', function() {

grunt.log.writeln(grunt.config('test.banner'));

});

};

清单 2-19 显示了一个样例 Gruntfile,它使用几个与文件系统交互的内置方法之一来加载项目的package.json文件的内容,这些方法将在本章后面详细讨论。这个文件的内容存储在 Grunt 配置对象的pkg键下。在清单 2-20 中,我们看到一个任务能够通过使用配置模板直接引用这些信息。

命令行选项

可以使用以下格式将附加选项传递给 Grunt:

$ grunt count --count=5

清单 2-21 中的例子展示了一个普通任务如何通过grunt.option()方法访问这些信息。调用该任务的结果如清单 2-22 所示。

Listing 2-21. Simple Grunt Task That Counts to the Specified Number

// example-options/tasks/count.js

module.exports = function(grunt) {

grunt.registerTask('count', function() {

var limit = parseInt(grunt.option('limit'), 10);

if (isNaN(limit)) grunt.fail.fatal('A limit must be provided (e.g. --limit=10)');

console.log('Counting to: %s', limit);

for (var i = 1; i <= limit; i++) console.log(i);

});

};

Listing 2-22. Result of Calling the Task Shown in Listing 2-21

$ grunt count --limit=5

Running "count" task

Counting to: 5

1

2

3

4

5

Done, without errors.

提供反馈

Grunt 提供了许多在任务执行过程中向用户提供反馈的内置方法,其中一些您已经在本章中看到过。虽然我们不会在这里列出所有的例子,但是在表 2-1 中可以找到一些有用的例子。

表 2-1。

Useful Grunt Methods for Displaying Feedback to the User

| 方法 | 描述 | | --- | --- | | `grunt.log.write()` | 将消息打印到控制台 | | `grunt.log.writeln()` | 向控制台打印一条消息,后跟一个换行符 | | `grunt.log.oklns()` | 将成功消息打印到控制台,后跟一个换行符 | | `grunt.log.error()` | 向控制台输出一条错误消息,后跟一个换行符 | | `grunt.log.subhead()` | 将粗体消息打印到控制台,后跟换行符 | | `grunt.log.debug()` | 仅当`--debug`标志通过时,才打印一条消息到控制台 |

处理错误

在任务执行过程中,可能会出现错误。当他们这样做时,知道如何恰当地处理他们是很重要的。当遇到错误时,开发人员应该利用 Grunt 的error API,它很容易使用,因为它只提供了两种方法,如表 2-2 所示。

表 2-2。

Methods Available via Grunt’s error API

| 方法 | 描述 | | --- | --- | | `grunt.fail.warn()` | 显示警告并立即中止 Grunt。如果通过了`--force`选项,任务将继续运行。 | | `grunt.fail.fatal()` | 显示警告并立即中止 Grunt。 |

与文件系统交互

作为一个构建工具,Grunt 的大多数插件都以这样或那样的方式与文件系统交互,这并不奇怪。鉴于其重要性,Grunt 提供了有用的抽象,允许开发人员用最少的样板代码与文件系统进行交互。

虽然我们不会在这里列出所有的方法,表 2-3 显示了 Grunt 的file API 中最常用的几种方法。

表 2-3。

Useful Grunt Methods for Interacting with the File System

| 方法 | 描述 | | --- | --- | | `grunt.file.read()` | 读取并返回文件的内容 | | `grunt.file.readJSON()` | 读取文件内容,将数据解析为 JSON,并返回结果 | | `grunt.file.write()` | 将指定的内容写入文件,必要时创建中间目录 | | `grunt.file.copy()` | 将源文件复制到目标路径,必要时创建中间目录 | | `grunt.file.delete()` | 删除指定的文件路径;递归删除文件和文件夹 | | `grunt.file.mkdir()` | 创建一个目录,以及任何缺失的中间目录 | | `grunt.file.recurse()` | 递归到一个目录中,对找到的每个文件执行回调 |

源-目标映射

许多与文件系统交互的繁重任务严重依赖于源-目的地映射的概念,这种格式描述了一组要处理的文件和每个文件对应的目的地。构建这样的映射可能会很乏味,但是谢天谢地,Grunt 提供了解决这一需求的有用快捷方式。

想象一下,您正在处理一个根目录下有一个public文件夹的项目。在这个文件夹中是项目部署后将通过 Web 提供的文件,如清单 2-23 所示。

Listing 2-23. Contents of an Imaginary Project’s public Folder

// example-iterate1

.

ε──??″

ε──??″

■??]

■??]

ε──??″

如您所见,我们的项目有一个包含三个文件的images文件夹。了解了这一点,让我们看看 Grunt 可以帮助我们遍历这些文件的几种方式。

在清单 2-24 中,我们发现一个单调的多任务,类似于我们最近被介绍的那些。这里的关键区别是在我们的任务配置中有一个src键。Grunt 特别关注包含这个键的多任务配置,我们很快就会看到。当出现src键时,Grunt 在我们的任务中提供一个this.files属性,该属性提供一个数组,该数组包含通过node-glob模块找到的每个匹配文件的路径。该任务的输出如清单 2-25 所示。

Listing 2-24. Grunt Multi-Task with a Configuration Object Containing an src Key

// example-iterate1/tasks/list-files.js

module.exports = function(grunt) {

grunt.config('list-files', {

'images': {

'src': ['public/**/*.jpg', 'public/**/*.png']

}

});

grunt.registerMultiTask('list-files', function() {

this.files.forEach(function(files) {

grunt.log.writeln('Source:', files.src);

});

});

};

Listing 2-25. Output from the Grunt Task Shown in Listing 2-24

$ grunt list-files

Running "list-files:images" (list-files) task

Source: [ 'publimg/cat1.jpg',

'publimg/cat2.jpg',

'publimg/cat3.png' ]

Done, without errors.

src配置属性和this.files多任务属性的结合为开发人员提供了一个简洁的语法来迭代多个文件。我们刚刚看到的这个人为的例子相当简单,但是 Grunt 也为处理更复杂的场景提供了额外的选项。让我们来看看。

与清单 2-24 中用于配置我们的任务的 src 键相反,清单 2-26 中的例子演示了文件数组的使用——这是一种稍微冗长但更强大的选择文件的格式。这种格式接受额外的选项,允许我们更好地调整我们的选择。特别重要的是扩展选项,您将在清单 2-27 中看到。由于使用了扩展选项,请密切注意输出与清单 2-26 的不同之处。

Listing 2-26. Iterating Through Files Using the “Files Array” Format

// example-iterate2/tasks/list-files.js

module.exports = function(grunt) {

grunt.config('list-files', {

'images': {

'files': [

{

'cwd': 'public',

'src': ['**/*.jpg', '**/*.png'],

'dest': 'tmp',

'expand': true

}

]

}

});

grunt.registerMultiTask('list-files', function() {

this.files.forEach(function(files) {

grunt.log.writeln('Source:', files.src);

grunt.log.writeln('Destination:', files.dest);

});

});

};

Listing 2-27. Output from the Grunt Task shown in Listing 2-26

$ grunt list-files

Running "list-files:images" (list-files) task

Source: [ 'publimg/cat1.jpg' ]

Destination: timg/cat1.jpg

Source: [ 'publimg/cat2.jpg' ]

Destination: timg/cat2.jpg

Done, without errors.

expand选项与dest选项配对时,它指示 Grunt 为找到的每个条目遍历一次任务的this.files.forEach循环,在循环中我们可以找到相应的dest属性。使用这种方法,我们可以轻松地创建源-目标映射,用于将文件从一个位置复制(或移动)到另一个位置。

监视文件更改

Grunt 最受欢迎的插件之一grunt-contrib-watch,让 Grunt 能够在创建、修改或删除匹配指定模式的文件时运行预定义的任务。当与其他任务结合使用时,grunt-contrib-watch使开发人员能够创建强大的工作流,自动执行以下操作

  • 检查 JavaScript 代码中的错误(即“林挺”)
  • 编译 Sass/L 样式表
  • 运行单元测试

让我们看几个例子,展示这样的工作流付诸行动。

自动化 JavaScript 林挺

清单 2-28 显示了一个基本的咕噜声设置,与本章中已经显示的相似。注册了一个default任务,作为watch任务的别名,允许我们通过简单地从命令行运行$ grunt来开始观察我们项目中的变化。在这个例子中,Grunt 将观察src文件夹中的变化。当它们发生时,jshint任务被触发,它将扫描我们项目的src文件夹,搜索 JavaScript 错误。

Listing 2-28. Automatically Checking for JavaScript Errors As Changes Occur

// example-watch-hint/Gruntfile.js

module.exports = function(grunt) {

grunt.loadTasks('tasks');

grunt.registerTask('default', ['watch']);

};

// example-watch-hint/tasks/jshint.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-jshint');

grunt.config('jshint', {

'options': {

'globalstrict': true,

'node': true,

'scripturl': true,

'browser': true,

'jquery': true

},

'all': [

'src/**/*.js'

]

});

};

// example-watch-hint/tasks/watch.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-watch');

grunt.config('watch', {

'js': {

'files': [

'src/**/*'

],

'tasks': ['jshint'],

'options': {

'spawn': true

}

}

});

};

自动化 Sass 样式表编译

清单 2-29 显示了一个例子,Grunt 被指示观察我们项目的变化。然而,这一次,Grunt 被配置为观察我们项目的 Sass 样式表,而不是观察我们的 JavaScript。随着变化的发生,grunt-contrib-compass插件被调用,它将我们的样式表编译成它们的最终形式。

Listing 2-29. Automatically Compiling Sass Stylesheets As Changes Occur

// example-watch-sass/Gruntfile.js

module.exports = function(grunt) {

grunt.loadTasks('tasks');

grunt.registerTask('default', ['watch']);

};

// example-watch-sass/tasks/compass.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-compass');

grunt.config('compass', {

'all': {

'options': {

'httpPath': '/',

'cssDir': 'public/css',

'sassDir': 'scss',

'imagesDir': 'public/images',

'relativeAssets': true,

'outputStyle': 'compressed'

}

}

});

};

// example-watch-compass/tasks/watch.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-watch');

grunt.config('watch', {

'scss': {

'files': [

'scss/**/*'

],

'tasks': ['compass'],

'options': {

'spawn': true

}

}

});

};

Note

为了让这个示例正常工作,您必须安装 Compass,这是一个开源的 CSS 创作框架。您可以在 http://compass-style.org/install 找到关于如何安装指南针的更多信息。

自动化单元测试

我们关于grunt-contrib-watch的最后一个例子是关于单元测试的。在清单 2-30 中,我们看到一个观察我们项目的 JavaScript 变化的 Gruntfile。随着这些变化的发生,在 Grunt 的grunt-mocha-test插件的帮助下,我们项目的单元测试立即被触发。

Listing 2-30. Automatically Running Unit Tests As Changes Occur

// example-watch-test/Gruntfile.js

module.exports = function(grunt) {

grunt.loadTasks('tasks');

grunt.registerTask('default', ['watch']);

};

// example-watch-test/tasks/mochaTest.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-mocha-test');

grunt.config('mochaTest', {

'test': {

'options': {

'reporter': 'spec'

},

'src': ['test/**/*.js']

}

});

};

// example-watch-test/tasks/watch.js

module.exports = function(grunt) {

grunt.loadNpmTasks('grunt-contrib-watch');

grunt.config('watch', {

'scss': {

'files': [

'src/**/*.js'

],

'tasks': ['mochaTest'],

'options': {

'spawn': true

}

}

});

};

创建插件

一个大型的社区支持插件库是 Grunt 真正闪耀的地方——这个库可以让你立即从 Grunt 中受益,而不需要从头开始创建复杂的任务。如果您需要在您的项目中自动化一个构建过程,很有可能有人已经完成了“繁重”的工作(zing!)给你。

在这一节中,您将发现如何通过自己创建的 Grunt 插件来回馈社区。

入门指南

你首先要做的事情之一是创建一个公共的 GitHub 库来存储你的新插件。我们将要引用的例子包含在本书的源代码中。

一旦您的新存储库准备就绪,将其克隆到您的计算机上。接下来,按照本章前面的“将 Grunt 添加到您的项目中”一节中概述的相同步骤,在其中初始化 Grunt。之后,您的新 Grunt 插件的文件结构应该类似于清单 2-31 中所示。

Listing 2-31. File Structure of Your New Grunt Plugin

.

■??]

■??]

■??]

ε──??″

Note

这里要注意的最重要的一点是,创建 Grunt 插件不需要特殊的结构或知识(除了本章已经介绍过的)。这个过程反映了将 Grunt 集成到一个现有项目中的过程——创建一个 Gruntfile 来加载任务以及任务本身。一旦发布到 npm,其他 Grunt 项目将能够加载你的插件,就像本章中提到的其他插件一样。

创建任务

举例来说,让我们创建一个 Grunt 插件,它能够生成一个报告,详细说明一个项目中包含的文件的类型、大小和数量。清单 2-32 中显示了一个演示这个插件配置的例子。

Listing 2-32. Example Demonstrating the Configuration of Our Plugin

// example-plugin/Gruntfile.js

module.exports = function(grunt) {

grunt.config('file-report', {

'options': {

},

'public': {

'src': ['public/**/*']

},

'images': {

'src': ['public/**/*.jpg', 'public/**/*.png', 'public/**/*.gif']

}

});

grunt.loadNpmTasks('grunt-file-reporter');

grunt.registerTask('default', ['file-report']);

};

我们插件的源代码如清单 2-33 所示。在我们的插件中,注册了一个名为file-report的 Grunt 多任务。当被调用时,任务将遍历清单 2-32 中指定的各种目标文件。当它这样做时,插件将编译一个报告,详细说明它找到的文件的类型、数量和大小。

Listing 2-33. Source Code for Our Plugin

// example-plugin/node_modules/grunt-file-reporter/Gruntfile.js

var fs = require('fs');

var filesize = require('filesize');

var _ = require('lodash');

_.mixin(require('underscore.string'));

module.exports = function(grunt) {

var mime = require('mime');

var Table = require('cli-table');

grunt.registerMultiTask('file-report', 'Generates a report of file types & sizes used within a project', function() {

var report = {

'mimeTypes': {},

'largest': null,

'smallest': null

};

var table = new Table({

'head': ['Content Type', 'Files Found', 'Total Size',

'Average Size', 'Largest', 'Smallest']

});

var addFile = function(file) {

if (grunt.file.isDir(file)) return;

var mimeType = mime.lookup(file);

if (!report.mimeTypes[mimeType]) {

report.mimeTypes[mimeType] = {

'count': 0,

'sizes': [],

'largest': null,

'smallest': null,

'oldest': null,

'newest': null

};

}

var details = report.mimeTypes[mimeType];

details.count++;

var stats = fs.statSync(file);

details.sizes.push(stats.size);

if (!details.largest || stats.size > details.largest.size) {

details.largest = { 'file': file, 'size': stats.size };

}

if (!report.largest || stats.size > report.largest.size) {

report.largest = { 'file': file, 'size': stats.size };

}

if (!details.smallest || stats.size < details.smallest.size) {

details.smallest = { 'file': file, 'size': stats.size };

}

if (!report.smallest || stats.size < report.smallest.size) {

report.smallest = { 'file': file, 'size': stats.size };

}

};

var sum = function(arr) {

return arr.reduce(function(a, b) {

return a + b;

});

};

var displayReport = function() {

var totalSum = 0;

var totalFiles = 0;

var totalSizes = [];

_.each(report.mimeTypes, function(data, mType) {

var fileSum = sum(data.sizes);

totalSum += fileSum;

totalFiles += data.sizes.length;

totalSizes = totalSizes.concat(data.sizes);

table.push([mType, data.count, filesize(fileSum),

filesize(fileSum / data.sizes.length),

_.sprintf('%s (%s)', data.largest.file, filesize(data.largest.size)),

_.sprintf('%s (%s)', data.smallest.file, filesize(data.smallest.size)),

]);

});

table.push(['-', totalFiles, filesize(totalSum),

filesize(totalSum / totalSizes.length),

_.sprintf('%s (%s)', report.largest.file, filesize(report.largest.size)),

_.sprintf('%s (%s)', report.smallest.file, filesize(report.smallest.size)),

]);

console.log(table.toString());

};

this.files.forEach(function(files) {

files.src.forEach(addFile);

});

displayReport();

});

};

我们插件的file-report任务生成的输出如图 2-1 所示。

A978-1-4842-0662-1_2_Fig1_HTML.jpg

图 2-1。

The output generated by the file-report task

发布到国家预防机制

一旦我们的插件准备好了,我们的 Git 库也用最新的代码更新了,向其他人提供它的最后一步是通过 npm 发布它:

$ npm publish

Note

如果这是您第一次向 npm 发布模块,您将被要求创建一个帐户。

摘要

在这一章中,我们已经了解了 Grunt 如何为开发人员提供了一个强大的工具包,用于自动化许多经常伴随软件开发的重复、乏味的任务。你发现了

  • 是什么让 Grunt 滴答作响(任务、插件和配置对象)
  • 如何配置任务和插件
  • 如何使用 Grunt 提供的许多有用的内置工具来提供用户反馈和与文件系统交互
  • 如何创建和分享你自己的 Grunt 插件

相关资源

三、Yeoman

一个人一生中只需要两种工具:让事情进展的 WD-40 和让事情停止的管道胶带。—G. Weilacher

近年来,发展界目睹了各种角色的转换。与本地应用相比,曾经被许多人视为二等公民的 web 应用已经在很大程度上取代了传统的桌面应用,这在很大程度上要归功于现代 Web 开发技术的广泛采用和移动 Web 的兴起。但是随着 web 应用变得越来越复杂,它们所依赖的工具和引导它们存在的步骤也越来越复杂。

这一章的主题是 Yeoman,它是一个流行的项目“脚手架”工具,通过自动化与启动新应用相关的繁琐任务来帮助缓解这个问题。Yeoman 提供了一种创建可重用模板的机制,这些模板描述了项目的初始文件结构、HTML、第三方库和任务运行器配置。这些模板可以通过 npm 与更广泛的开发社区共享,允许开发人员在几分钟内启动遵循一致同意的最佳实践的新项目。

在本章中,您将学习如何:

  • 安装约曼
  • 利用社区已经发布的约曼生成器
  • 用你自己的约曼发电机回馈社区

Note

这一章建立在本书前两章关于鲍尔和咕噜的主题之上。如果您不熟悉这些工具中的任何一个,您可能希望在继续之前阅读该工具的相应章节。

安装约曼

约曼的命令行工具yo可通过 npm 获得。如果您还没有安装 Yeoman,您应该在继续之前安装,如清单 3-1 所示。

Listing 3-1. Installing the yo Command-Line Utility via npm

$ npm install -g yo

$ yo --version

1.4.6

创建您的第一个项目

Yeoman 允许开发人员通过使用可重用的模板快速创建应用的初始结构,Yeoman 称之为“生成器”为了更好地理解这个过程如何改进您的工作流,让我们在专门为本章创建的modernweb生成器的帮助下创建一个新项目。之后,我们将看看这个生成器是如何创建的,为您提供创建和与更广泛的开发社区共享您自己的定制 Yeoman 生成器所需的知识。

我们将使用的生成器将创建一个项目的初始基础,该项目使用以下工具和库:

  • Grunt
  • Bower
  • 框架
  • 安古斯
  • 浏览
  • 指南针

约曼发电机作为全球 npm 模块安装。既然如此,安装我们的生成器的命令应该看起来很熟悉:

$ npm install -g generator-modernweb

Note

这个生成器的名字以generator-为前缀,这是所有约曼生成器都必须遵循的重要约定。在运行时,Yeoman 将通过搜索名称遵循这种格式的全局模块来确定安装了什么(如果有的话)生成器。

现在安装好了我们的发电机,我们可以继续设置我们的第一个项目了。首先,我们创建一个新文件夹来包含它。之后,我们指示 Yeoman 基于我们刚刚安装的生成器创建一个新项目。清单 3-2 展示了这些步骤,以及生成器设计用来提示您的几个问题。

Listing 3-2. Creating Our First Project with the modernweb Generator

$ mkdir my-app

$ cd my-app

$ yo modernweb

? Project Title: My Project

? Package Name: my-project

? Project Description: My awesome project

? Project Author: John Doe

? Express Port: 7000

在回答了生成器的问题之后(您可以放心地接受默认值),Yeoman 将继续创建项目。之后,我们可以使用项目的默认 Grunt 任务轻松地构建和启动它,我们的生成器已经为我们方便地设置了这个任务(参见清单 3-3 )。

Listing 3-3. Our New Project’s Default Grunt Task Will Trigger Various Build Steps and Open the Project Within Our Browser

$ grunt

Running "concat:app" (concat) task

File public/dist/libs.js created.

Running "compass:app" (compass) task

unchanged scss/style.scss

Compilation took 0.002s

Running "browserify" task

Running "concurrent:app" (concurrent) task

Running "watch" task

Waiting…

Running "open:app" (open) task

Running "server" task

Server is now listening on port: 7000

Done, without errors.

如您所见,我们新项目的默认 Grunt 任务为我们执行了几个额外的构建步骤:

  • JavaScript 库被编译成一个简单的脚本。
  • Sass 样式表被编译。
  • 应用本身的源代码通过 Browserify 编译。
  • 创建一个 Express 实例来服务我们的项目。
  • 各种各样的观察脚本被初始化,它们将在发生变化时自动重新编译我们的项目。

我们项目的默认 Grunt 任务的最后一个动作是在一个新的浏览器窗口中启动我们的项目,如图 3-1 所示。

A978-1-4842-0662-1_3_Fig1_HTML.jpg

图 3-1。

Our new project’s home page, opened for us by the default Grunt task

既然我们的新项目已经为进一步开发做好了准备,让我们花点时间熟悉一下我们的生成器为我们准备的各种模板、脚本和简单任务,特别注意这些文件的内容:

  • bower.json
  • Gruntfile.js
  • package.json
  • public/index.html

在 Yeoman 对用户提示和模板支持的帮助下(我们将在下一节详细讨论),生成器将我们对其初始问题的回答与我们项目文件的内容进行了适当的合并。例如,我们项目的package.json文件中的namedescriptionauthor的值已经为我们设置好了(见清单 3-4 )。

Listing 3-4. Contents of Our Project’s package.json File

// package.json

{

"name": "my-project",

"description": "My awesome project",

"author": "John Doe",

"files": [],

"keywords": [],

"dependencies": {},

"browserify": {

"transform": [

"brfs",

"bulkify",

"folderify"

]

},

"browser": {}

}

子命令

最简单的形式是,发生器充当可配置的项目模板,简化了新项目的创建,但这不是它们的唯一目的。除了协助新项目的初始创建,生成器还可以包含其他命令,项目维护人员会发现这些命令在整个开发过程中都很有用。

在清单 3-2 中,我们使用modernweb生成器创建了一个使用 AngularJS 框架构建的新的单页面应用。如果你不熟悉 Angular,不要担心——这个框架的细节现在并不重要。然而,重要的是项目的public/app/routes文件夹的内容。请注意,在这个位置已经为我们创建了一个名为dashboard的文件夹。该文件夹的内容如清单 3-5 所示。

Listing 3-5. Contents of Our Project’s public/app/routes/dashboard Folder

.

■??]

ε──??″

// public/app/routes/dashboard/index.js

module.exports = {

'route': '/dashboard',

'controller': function() {

},

'templateUrl': '/app/routes/dashboard/template.html',

'resolve': {}

};

// public/app/routes/dashboard/template.html

<div class="well">

Welcome to the "/dashboard" route.

</div>

这个项目的设置使得public/app/routes中的每个文件夹在应用中定义一个不同的“hashbang”路径。在这个例子中,项目的dashboard文件夹定义了一条可以在http://localhost:7000/#/dashboard访问的路线。知道了这一点,假设我们想在我们的应用中添加一个新的users路由。为此,我们可以在适当的位置手动创建必要的文件。或者,我们可以使用生成器提供的附加命令来简化这个过程(参见清单 3-6 )。

Listing 3-6. Example of Calling the route Sub-generator to Automate the Process of Creating New Routes Within Our Angular Application

$ yo modernweb:route users

create public/app/routes/users/index.js

create public/app/routes/users/template.html

Route users created.

运行该命令后,参考项目的/public/app/routes文件夹,注意名为users的新文件夹的存在。在这个文件夹中,我们的 Yeoman 生成器已经为我们创建了合适的文件。如果您碰巧还在运行我们在清单 3-3 中创建的服务器,那么您应该也能够看到为我们启动的观察脚本已经检测到了这一变化,并自动重新编译了我们的应用(参见清单 3-7 )。

Listing 3-7. Grunt Automatically Recompiles Application As Changes Are Made

>> File "public/app/routes/users" added.

Running "browserify" task

Done, without errors.

创造你的第一台发电机

本章的剩余部分将集中于创建一个定制的 Yeoman 生成器——与上一节中用于引导一个围绕 AngularJS(以及其他工具)构建的新项目的生成器相同。之后,您将准备好开始创建您自己的生成器,这将允许您快速启动并运行满足您特定需求的工作流。

约曼发电机是节点模块

约曼生成器只不过是一个简单的节点模块,它遵循约曼规定的准则。因此,创建生成器的第一步是创建一个新的节点模块。清单 3-8 显示了所需的命令,以及生成的package.json文件。

Listing 3-8. Creating a New Node Module to Contain the Contents of Our First Yeoman Generator

$ mkdir generator-example

$ cd generator-example

$ npm init

// generator-example/package.json

{

"name": "generator-example",

"version": "1.0.0",

"description": "An example Yeoman generator",

"files": [],

"keywords": [

"yeoman-generator"

],

"dependencies": {}

}

Note

尽管我们遵循的步骤与本章前面提到的创建modernweb生成器的步骤相同,但是我们为新模块分配了一个不同的名称,以免与已经安装的模块冲突。还要注意在我们模块的关键字列表中包含了yeoman-generator。Yeoman 的网站维护了一个列表,列出了 npm 中所有可用的生成器,使得开发人员可以很容易地找到预先存在的生成器来满足他们的需求。如果一个生成器要包含在这个列表中,它必须包含这个关键字,以及在它的package.json文件中的描述。

与任何其他节点模块一样,Yeoman 生成器可以选择依赖外部依赖项。然而,最起码,每个生成器都必须将yeoman-generator模块指定为本地依赖项。本模块将为我们提供由 Yeoman 提供的核心功能,用于创建用户交互、与文件系统交互以及其他重要任务。使用以下命令将该模块安装为本地依赖项:

$ npm install yeoman-generator --save

子发电机

Yeoman 生成器由一个或多个命令组成,每个命令都可以从命令行单独调用。这些命令被 Yeoman 称为“子生成器”,它们被定义在模块根级的文件夹中。对于一些额外的上下文,请回头参考清单 3-2 ,其中我们通过从命令行运行$ yo modernweb创建了一个基于modernweb生成器的新项目。在那个例子中,我们没有指定命令——我们只是将生成器的名称传递给了 Yeoman。因此,约曼执行了该生成器的默认子生成器,按照惯例它总是被命名为app。我们可以通过运行以下命令完成同样的事情:

$ yo modernweb:app

为了更好地理解这是如何工作的,让我们继续创建生成器的默认app子生成器。我们分四步完成:

Create a folder named app at the root level of our module.   Create a folder named templates within our new app folder.   Place various files within our templates folder that we want to copy into the target project (e.g., HTML files, Grunt tasks, a Bower manifest, and so forth).   Create the script shown in Listing 3-9, which is responsible for driving the functionality for this command.   Listing 3-9. Contents of Our Generator’s Default app Command (“Sub-generator”)

// generator-example/app/index.js

var generators = require('yeoman-generator');

/**

* We create our generator by exporting a class that extends

* from Yeoman’s Base class.

*/

module.exports = generators.Base.extend({

'prompting': function() {

/**

* Indicates that this function will execute asynchronously. Yeoman

* will wait until we call the done() function before continuing.

*/

var done = this.async();

/**

* Our generator’s promptmethod (inherited from Yeoman’sBase``

* class) allows us to define a series of questions to prompt the

* user with.

*/

this.prompt([

{

'type': 'input',

'name': 'title',

'message': 'Project Title',

'default': 'My Project',

'validate': function(title) {

return (title.length > 0);

}

},

{

'type': 'input',

'name': 'package_name',

'message': 'Package Name',

'default': 'my-project',

'validate': function(name) {

return (name.length > 0 & /^[a-z0-9\-]+$/i.test(name));

},

'filter': function(name) {

return name.toLowerCase();

}

},

{

'type': 'input',

'name': 'description',

'message': 'Project Description',

'default': 'My awesome project',

'validate': function(description) {

return (description.length > 0);

}

},

{

'type': 'input',

'name': 'author',

'message': 'Project Author',

'default': 'John Doe',

'validate': function(author) {

return (author.length > 0);

}

},

{

'type': 'input',

'name': 'port',

'message': 'Express Port',

'default': 7000,

'validate': function(port) {

port = parseInt(port, 10);

return (!isNaN(port) & port > 0);

}

}

], function(answers) {

this._answers = answers;

done();

}.bind(this));

},

'writing': function() {

/**

* Copies files from our sub-generator’s templates folder to the target

* project. The contents of each file is processed as a Lodash template

* before being written to the disk.

*/

this.fs.copyTpl(

this.templatePath('**/*'),

this.destinationPath(),

this._answers

);

this.fs.copyTpl(

this.templatePath('pkg.json'),

this.destinationPath('package.json'),

this._answers

);

this.fs.delete(this.destinationPath('pkg.json'));

this.fs.copyTpl(

this.templatePath('.bowerrc'),

this.destinationPath('.bowerrc'),

this._answers

);

/**

* Writes a Yeoman configuration file to the target project’s folder.

*/

this.config.save();

},

'install': function() {

/**

* Installs various npm modules within the project folder and updates

* package.json accordingly.

*/

this.npmInstall([

'express', 'lodash', 'underscore.string', 'browserify',

'grunt', 'grunt-contrib-concat', 'grunt-contrib-watch',

'grunt-contrib-compass', 'grunt-concurrent', 'bulk-require',

'brfs', 'bulkify', 'folderify', 'grunt-open'

], {

'saveDev': false

});

/**

* Installs dependencies defined within bower.json.

*/

this.bowerInstall();

},

'end': function() {

this.log('Your project is ready.');

}

});

我们的生成器的app文件夹的内容如图 3-2 所示。

A978-1-4842-0662-1_3_Fig2_HTML.jpg

图 3-2。

The contents of our generator’s app folder. The contents of the templates folder will be copied into the target project

在清单 3-9 中,我们的生成器的默认app命令是通过导出一个从 Yeoman 的Base类扩展而来的类来创建的。在这个类中,定义了四个实例方法:

  • prompting()
  • writing()
  • install()
  • end()

这些方法名在执行过程中起着重要的作用(它们不是随意选择的)。当 Yeoman 运行一个生成器时,它会搜索其名称与下面列出的名称相匹配的原型方法:

  • initializing():初始化方法(检查项目状态,获取配置)。
  • prompting():提示用户输入信息
  • configuring():保存配置文件。
  • default():名称不在此列表中的原型方法将在此步骤中执行。
  • writing():特定于该发生器的写操作发生在这里。
  • 冲突在这里处理(由约曼内部使用)。
  • 安装程序在这里进行(npm,bower)。
  • end():最后调用的函数。清理/关闭消息。

一旦 Yeoman 编译了我们的生成器中存在的各种原型方法的列表,它将按照前面列表中显示的优先级执行它们。

Lodash 模板

在清单 3-9 中,Yeoman 的fs.copyTpl()方法用于将文件从子生成器的templates文件夹复制到目标项目。这种方法不同于约曼的fs.copy()方法,因为它也将找到的每个文件作为 Lodash 模板进行处理。清单 3-10 显示了我们的子生成器的templates/pkg.json文件的内容,在以package.json保存到新项目的文件夹之前,将以这种方式进行处理。

Listing 3-10. Contents of Our Sub-generator’s templates/pkg.json File

// generator-example/app/templates/pkg.json

{

"name": "<%= package_name %>",

"description": "<%= description %>",

"author": "<%= author %>",

"files": [],

"keywords": [],

"dependencies": {},

"browserify": {

"transform": [

"brfs",

"bulkify",

"folderify"

]

},

"browser": {}

}

Note

Yeoman 生成器可以根据用户对提示的回答修改它们的行为和改变模板的内容,这一过程开辟了许多令人兴奋的可能性。它允许创建根据用户的特定需求动态配置的新项目。正是 Yeoman 的这一方面,比其他任何方面都更让这个工具真正有用。

我们现在准备使用新的生成器创建我们的第一个项目。为此,请打开一个新的终端窗口,并创建一个文件夹来包含它。接下来,移动到新文件夹并运行生成器,如清单 3-11 所示。

Listing 3-11. Running Our New Generator for the First Time

$ mkdir new-project

$ cd new-project

$ yo example

Error example

You don’t seem to have a generator with the name example installed.

You can see available generators with npm search yeoman-generator and then install the

with npm install [name].

显然,这不是我们希望的结果。为了理解是什么导致了这个错误,回想一下本章前面的内容,当调用 Yeoman 时,它通过搜索已经安装在全局上下文中的名称以generator-开头的模块来定位生成器。因此,约曼目前不知道我们的新发电机的存在。幸运的是,npm 提供了一个方便的命令来解决这个问题。npm link命令在我们的新模块和节点的全局模块文件夹之间创建一个符号链接。该命令在我们新模块的根级别执行(参见清单 3-12 )。

Listing 3-12. Creating a Symbolic Link with the npm link Command

$ npm link

/Users/tim/.nvm/v0.10.33/lib/node_modules/generator-example -> /opt/generator-example

Npm 的link命令在运行它的文件夹和存储全局安装的节点模块的文件夹之间创建一个符号链接。通过运行这个命令,我们在一个 Yeoman 可以找到的位置放置了一个对新生成器的引用。有了这个链接,让我们再次运行我们的生成器(参见清单 3-13 )。

Listing 3-13. Successfully Running Our New Generator for the First Time

$ yo example

? Project Title: My Project

? Package Name: my-project

? Project Description: My awesome project

? Project Author: John Doe

? Express Port: 7000

在回答了生成器的问题后,约曼将继续构建我们的新项目,就像我们在本章前半部分使用的modernweb生成器一样。一旦这个过程完成,运行 Grunt 的默认任务—$ grunt—来构建和启动这个项目。

定义辅助命令

在本章的前半部分,您了解了 Yeoman 生成器可以包含多个命令,这些命令的用途远远超出了新项目的初始创建。modernweb生成器通过包含一个route命令来演示这一点,该命令可以在 Angular 应用中自动创建新路线(参见本章前面的清单 3-6 )。创建这个命令所涉及的步骤与我们创建生成器的默认app命令时所采取的步骤非常相似:

Create a folder named route at the root level of our module.   Create a folder named templates within our new route folder.   Place various files within our templates folder that we want to copy into the target project.   Create the script shown in Listing 3-14, which is responsible for driving the functionality for the route command.   Listing 3-14. A route Sub-generator That Automates the Creation of New Angular Routes

// generator-example/route/index.js

var generators = require('yeoman-generator');

/*

Our generator’s default appcommand was created by extending Yeoman’sBaseclass. In this example, we extend theNamedBase class, instead. Doing so alerts Yeoman to the fact that this command expects one or more arguments. For example: $ yo example:route my-new-route

*/

module.exports = generators.NamedBase.extend({

'constructor': function(args) {

this._opts = {

'route': args[0]

};

generators.NamedBase.apply(this, arguments);

},

'writing': function() {

this.fs.copyTpl(

this.templatePath('index.js'),

this.destinationPath('public/app/routes/' + this._opts.route + '/index.js'),

this._opts

);

this.fs.copyTpl(

this.templatePath('template.html'),

this.destinationPath('public/app/routes/' + this._opts.route + '/template.html'),

this._opts

);

},

'end': function() {

this.log('Route ' + this._opts.route + ' created.');

}

});

清单 3-14 中显示的脚本看起来与清单 3-9 中显示的非常相似,主要区别是使用了约曼的NamedBase类。通过创建一个从NamedBase扩展而来的子生成器,我们提醒 Yeoman 这个命令需要接收一个或多个参数。

清单 3-15 展示了我们的生成器的新route命令的使用。

Listing 3-15. Creating a New Angular Route Using Our Generator’s route Command

$ yo example:route users

create public/app/routes/users/index.js

create public/app/routes/users/template.html

Route users created.

可组合性

在创建 Yeoman 生成器时,经常会遇到这样的情况:能够从一个子生成器中执行另一个子生成器是非常有用的。例如,考虑我们刚刚创建的生成器。很容易想象这样一个场景,我们可能希望生成器在运行时自动创建几条默认路由。为了实现这个目标,如果我们能够从生成器的app命令中调用它的route命令,那将会很有帮助。约曼的composeWith()方法就是因为这个原因而存在的(见清单 3-16 )。

Listing 3-16. Yeoman’s composeWith() Method Allows One Sub-generator to Call Another

// generator-example/app/index.js (excerpt)

'writing': function() {

this.fs.copyTpl(

this.templatePath('**/*'),

this.destinationPath(),

this._answers

);

this.fs.copy(

this.templatePath('.bowerrc'),

this.destinationPath('.bowerrc'),

this._answers

);

/*

Yeoman’s composeWith method allows us to execute external generators.

Here, we trigger the creation of a new route named "dashboard".

*/

this.composeWith('example:route', {

'args': ['dashboard']

});

this.config.save();

}

在约曼composeWith()方法的帮助下,简单的子生成器可以相互组合(即“组合”)以创建相当复杂的工作流。通过利用这种方法,开发人员可以创建复杂的多命令生成器,同时避免跨命令使用重复的代码。

摘要

Yeoman 是一个简单但功能强大的工具,它自动化了与启动新应用相关的繁琐任务,加快了开发人员从概念到原型的过程。使用时,它允许开发人员将他们的注意力集中在最重要的地方——应用本身。

据最新统计,已有超过 1,500 个 Yeoman 生成器发布到 npm,这使得开发人员可以轻松地试验他们可能没有经验的各种工具、库、框架和设计模式(例如,Bower、Grunt、AngularJS、Knockout、React)。

相关资源

四、PM2

不要等待;时间永远不会“恰到好处”。从你所站的地方开始,使用你所掌握的任何工具,随着你的前进,你会发现更好的工具。—乔治·赫伯特

本节的前几章已经介绍了各种有用的 web 开发工具,我们主要关注客户端开发。在这一章中,我们将通过把我们的焦点转移到服务器上来完善我们对开发工具的介绍。我们将探索 PM2,这是一个命令行工具,它简化了许多与运行节点应用、监控它们的状态以及高效地扩展它们以满足不断增长的需求相关的任务。涵盖的主题包括:

  • 使用流程
  • 监控日志
  • 监控资源使用情况
  • 高级流程管理
  • 跨多个处理器的负载平衡
  • 零停机部署

装置

PM2 的命令行工具pm2可以通过 npm 获得。如果您还没有安装 PM2,您应该在继续之前安装,如清单 4-1 所示。

Listing 4-1. Installing the pm2 Command-line Utility via npm

$ npm install -g pm2

$ pm2 --version

0.12.15

Note

Node 的软件包管理器(npm)允许用户在两种环境中安装软件包:本地或全局。在本例中,bower安装在全局上下文中,该上下文通常是为命令行工具保留的。

使用流程

清单 4-2 显示了一个简单节点应用的内容,它将构成我们与 PM2 最初几次交互的基础。当被访问时,它只显示消息“Hello,world”给用户。

Listing 4-2. Simple Express Application

// my-app/index.js

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.get('/', function(req, res, next) {

res.send('Hello, world.\n');

});

app.listen(8000);

图 4-1 展示了我们在pm2命令行工具的帮助下启动该应用的过程。在这个例子中,我们指示 PM2 通过执行它的index.js脚本来启动我们的应用。我们还为我们的应用向 PM2 提供了一个(可选的)名称(my-app),以便于我们以后引用它。在这样做之前,确保通过运行$ npm install安装项目的依赖项。

A978-1-4842-0662-1_4_Fig1_HTML.jpg

图 4-1。

Launching the application shown in Listing 4-2 with PM2

在调用了 PM2 的start命令后,PM2 显示了一个表格,其中包含了它当前知道的每个节点应用的信息,然后才让我们返回到命令提示符。表 4-1 总结了我们在本例中看到的列的含义。

表 4-1。

Summary of Columns Shown in Figure 4-1

| 标题 | 描述 | | --- | --- | | `App name` | 进程的名称。默认为执行的脚本的名称。 | | `id` | 由 PM2 分配给进程的唯一 ID。可以通过名称或 ID 引用进程。 | | `mode` | 执行的方法(`fork`或`cluster`)。默认为`fork`。在本章的后面会有更详细的探讨。 | | `pid` | 操作系统分配给进程的唯一编号。 | | `status` | 流程的当前状态(如`online`、`stopped`等)。). | | `restart` | PM2 重新启动进程的次数。 | | `uptime` | 自上次重新启动以来,进程已经运行的时间长度。 | | `memory` | 进程消耗的内存量。 | | `watching` | 指示 PM2 在检测到项目文件结构中的更改时是否会自动重新启动该过程。在开发过程中特别有用。默认为`disabled`。 |

如清单 4-3 中 PM2 提供的输出所示,我们的应用现在已经上线,可以使用了。我们可以通过使用curl命令行工具调用我们的应用的唯一路由来验证这一点,如图 4-2 所示。

A978-1-4842-0662-1_4_Fig2_HTML.jpg

图 4-2。

Accessing the sole route defined by our Express application Note

图 4-2 假设在您的环境中存在curl命令行工具。如果您碰巧在没有该工具的环境中工作,您也可以通过在 web 浏览器中直接打开它来验证该应用的状态。

除了start命令,PM2 还提供了许多有用的命令,用于与 PM2 已经知道的进程进行交互,其中最常见的命令如表 4-2 所示。

表 4-2。

Frequently Used Commands for Interacting with PM2 Processes

| 命令 | 描述 | | --- | --- | | `list` | 显示清单 4-4 中所示表格的最新版本 | | `stop` | 停止进程,但不将其从 PM2 列表中删除 | | `restart` | 重新启动该过程 | | `delete` | 停止进程并将其从 PM2 列表中删除 | | `show` | 显示有关指定进程的详细信息 |

简单的命令如stopstartdelete不需要额外的注释。另一方面,图 4-3 显示了当通过show命令请求关于特定 PM2 过程的信息时,您可以期望收到的信息。

A978-1-4842-0662-1_4_Fig3_HTML.jpg

图 4-3。

Viewing details for a specific PM2 process

从错误中恢复

至此,您已经熟悉了与 PM2 互动的一些基本步骤。你已经学会了如何在 PM2 的start命令的帮助下创建新流程。您还发现了如何在诸如liststoprestartdeleteshow等命令的帮助下管理正在运行的进程。然而,我们还没有讨论 PM2 在管理节点流程方面带来的真正价值。我们将从发现 PM2 如何帮助节点应用从致命错误中自动恢复开始讨论。

清单 4-3 显示了我们最初在清单 4-2 中看到的应用的修改版本。然而,在这个版本中,一个未被捕获的异常被定期抛出。

Listing 4-3. Modified Version of Our Original Application That Throws an Uncaught Exception Every Four Seconds

// my-bad-app/index.js

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.get('/', function(req, res, next) {

res.send('Hello, world.\n');

});

setInterval(function() {

throw new Error('Uh oh.');

}, 4000);

app.listen(8000);

如果我们在没有 PM2 的帮助下,通过将它直接传递给node可执行文件来启动这个应用,我们会很快发现自己在第一个错误被抛出的时候就已经不走运了。在将我们转储回命令提示符之前,Node 会简单地将错误消息打印到控制台,如图 4-4 所示。

A978-1-4842-0662-1_4_Fig4_HTML.jpg

图 4-4。

Output provided by Node after crashing from the error shown in Listing 4-3

这种行为在真实的使用场景中不会让我们走得太远。理想情况下,已经发布到生产环境中的应用应该经过彻底的测试,并且没有这种无法捕获的异常。然而,在这种崩溃的情况下,应用至少应该能够在不需要人工干预的情况下恢复在线。PM2 可以帮助我们实现这一目标。

在图 4-5 中,我们通过delete命令从 PM2 的列表中删除了我们现有的流程,并创建了一个清单 4-3 中所示的糟糕的应用的新实例。之后,我们等待几秒钟,然后向 PM2 请求最新的进程列表。

A978-1-4842-0662-1_4_Fig5_HTML.jpg

图 4-5。

PM2 helps Node applications recover from fatal errors

注意到这里有什么有趣的吗?基于statusrestartuptime列中的值,我们可以看到我们的应用已经崩溃了三次。每一次,PM2 都帮助我们重新启动它。最近的进程总共运行了两秒钟,这意味着从现在起两秒钟后我们可以预期另一个崩溃(和自动重启)。

PM2 帮助应用从生产环境中的致命错误中恢复的能力虽然有用,但只是该工具提供的几个有用功能之一。PM2 在开发环境中同样有用,我们很快就会看到。

响应文件更改

设想一个场景,您最近开始了一个新的节点项目。我们假设它是一个用 Express 构建的 web API。如果没有其他工具的帮助,您必须手动重启相关的节点流程,才能看到正在进行的工作的效果——这是一项令人沮丧的工作,很快就会过时。在这种情况下,PM2 可以通过自动监控项目的文件结构来帮助您。当检测到变化,PM2 可以自动重启你的应用,如果你指示它这样做。

图 4-6 展示了这一过程。在这个例子中,我们首先删除当前正在运行的实例my-bad-app。接下来,我们创建一个应用的新实例,如我们最初的例子所示(参见清单 4-2 )。然而,这一次,我们传递了一个额外的标志,--watch,它指示 PM2 监控我们的项目的变化并做出相应的响应。

A978-1-4842-0662-1_4_Fig6_HTML.jpg

图 4-6。

Creating a new PM2 process that will automatically restart itself as changes are detected

随着更改被保存到这个项目的文件中,对 PM2 的list命令的后续调用将显示 PM2 已经重新启动了这个应用多少次,如前面的例子所示。

监控日志

回头参考清单 4-2 ,注意这个应用使用了morgan,一个用于记录传入 HTTP 请求的模块。在本例中,morgan被配置为将此类信息打印到控制台。我们可以通过node可执行文件直接运行我们的应用来查看结果,如图 4-7 所示。

A978-1-4842-0662-1_4_Fig7_HTML.jpg

图 4-7。

Logging incoming requests to Express with morgan

我们最近探索了如何允许 PM2 通过start命令为我们管理这个应用的执行(见图 4-1 )。这样做为我们提供了一些好处,但是它也使我们失去了对应用向控制台生成的输出的即时洞察力。幸运的是,PM2 为我们提供了一个监控这种输出的简单机制。

在图 4-3 中,我们通过show命令向 PM2 请求有关其控制下的特定过程的信息。所提供的信息中包含了 PM2 为该流程自动创建的两个日志文件的路径,一个标记为“输出日志路径”,另一个标记为“错误日志路径”,PM2 会将该流程的标准输出和错误消息分别保存到这两个日志文件中。我们可以直接查看这些文件,但是 PM2 提供了一个更方便的方法来与它们交互,如图 4-8 所示。

A978-1-4842-0662-1_4_Fig8_HTML.jpg

图 4-8。

Monitoring the output from processes under PM2’s control

在这里,我们可以看到如何通过logs命令根据需要监控 PM2 控制下的流程的输出。在本例中,我们监控 PM2 控制下的所有进程的输出。请注意 PM2 是如何在每个条目前添加关于每行输出来源的信息的。当使用 PM2 管理多个流程时,这些信息特别有用,我们将在下一节开始这样做。或者,我们也可以通过将特定进程的名称(或 ID)传递给logs命令来监控该进程的输出(参见图 4-9 )。

A978-1-4842-0662-1_4_Fig9_HTML.jpg

图 4-9。

Monitoring the output from a specific process under PM2’s control

如果您希望随时清除 PM2 生成的日志文件的内容,可以通过调用 PM2 的flush命令来快速完成。该工具的logs命令的行为也可以通过使用两个可选参数进行微调,这两个参数在表 4-3 中列出。

表 4-3。

Arguments Accepted by PM2’s logs Command

| 争吵 | 描述 | | --- | --- | | `–raw` | 显示日志文件的原始内容,去掉进程中带前缀的进程标识符 | | `–lines <``N` | 指示 PM2 显示最后 N 行,而不是默认的 20 行 |

监控资源使用情况

在上一节中,您了解了 PM2 如何帮助您监控标准输出以及在其控制下的流程所生成的错误。同样,PM2 还提供了易于使用的工具来监控这些进程的健康状况,以及监控运行这些进程的服务器的整体健康状况。

监控本地资源

图 4-10 展示了调用 PM2 的monit命令时产生的输出。在这里,我们可以看到一个持续更新的视图,它允许我们跟踪 CPU 处理能力的大小以及由 PM2 管理的每个进程消耗的 RAM 的大小。

A978-1-4842-0662-1_4_Fig10_HTML.jpg

图 4-10。

Monitoring CPU and memory usage via PM2’s monit command

监控远程资源

PM2 的monit命令提供的信息为我们提供了一种快速简单的方法来监控其进程的健康状况。在开发过程中,当我们主要关注在我们自己的环境中消耗的资源时,这个功能特别有用。然而,当一个应用转移到一个远程的生产环境中时,它就没那么有用了,这个环境很容易由多台服务器组成,每台服务器都运行自己的 PM2 实例。

PM2 考虑到这一点,还提供了一个内置的 JSON API,可以通过端口 9615 访问。默认禁用,启用过程如图 4-11 所示。

A978-1-4842-0662-1_4_Fig11_HTML.jpg

图 4-11。

Enabling PM2’s JSON web API

在这个例子中,我们通过调用工具的web命令来启用 PM2 的 web 可访问的 JSON API。PM2 将此功能作为独立于 PM2 本身运行的独立应用的一部分来实现。结果,我们可以看到一个新的进程,pm2-http-interface,现在在 PM2 的控制之下。如果我们希望禁用 PM2 的 JSON API,我们可以像删除其他进程一样删除这个进程,将它的名字(或 ID)传递给delete(或stop)命令。

清单 4-4 显示了通过端口 9615 向运行 PM2 的服务器发出 GET 请求时所提供的输出摘录。正如您所看到的,PM2 为我们提供了许多关于当前在其控制下的每个进程的详细信息,以及运行它的系统。

Listing 4-4. Excerpt of the Information Provided by PM2’s JSON API

{

"system_info": {

"hostname": "iMac.local",

"uptime": 2186

},

"monit": {

"loadavg": [1.39794921875],

"total_mem": 8589934592,

"free_mem": 2832281600,

"cpu": [{

"model": "Intel(R) Core(TM) i5-4590 CPU @ 3.30GHz",

"speed": 3300,

"times": {

"user": 121680,

"nice": 0,

"sys": 176220,

"idle": 1888430,

"irq": 0

}

}],

"interfaces": {

"lo0": [{

"address": "::1",

"netmask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",

"family": "IPv6",

"mac": "00:00:00:00:00:00",

"scopeid": 0,

"internal": true

}],

"en0": [{

"address": "10.0.1.49",

"netmask": "255.255.255.0",

"family": "IPv4",

"mac": "ac:87:a3:35:9c:72",

"internal": false

}]

}

},

"processes": [{

"pid": 1163,

"name": "my-app",

"pm2_env": {

"name": "my-app",

"vizion": true,

"autorestart": true,

"exec_mode": "fork_mode",

"exec_interpreter": "node",

"pm_exec_path": "/opt/my-app/index.js",

"env": {

"_": "/usr/local/opt/nvm/versions/node/v0.12.4/bin/pm2",

"NVM_IOJS_ORG_MIRROR": "https://iojs.org/dist

"NVM_BIN": "/usr/local/opt/nvm/versions/node/v0.12.4/bin",

"LOGNAME": "user",

"ITERM_SESSION_ID": "w0t0p0",

"HOME": "/Users/user",

"COLORFGBG": "7;0",

"SHLVL": "1",

"XPC_SERVICE_NAME": "0",

"XPC_FLAGS": "0x0",

"ITERM_PROFILE": "Default",

"LANG": "en_US.UTF-8",

"PWD": "/opt/my-app",

"NVM_NODEJS_ORG_MIRROR": "https://nodejs.org/dist

"PATH": "/usr/local/opt/nvm/versions/node/v0.12.4/bin",

"__CF_USER_TEXT_ENCODING": "0x1F5:0x0:0x0",

"SSH_AUTH_SOCK": "/private/tmp/com.apple.launchd.kEqu8iouDS/Listeners",

"USER": "user",

"NVM_DIR": "/usr/local/opt/nvm",

"NVM_PATH": "/usr/local/opt/nvm/versions/node/v0.12.4/lib/node",

"TMPDIR": "/var/folders/y3/2fphz1fd6rg9l4cg2t8t7g840000gn/T/",

"TERM": "xterm",

"SHELL": "/bin/bash",

"TERM_PROGRAM": "iTerm.app",

"NVM_IOJS_ORG_VERSION_LISTING": "https://iojs.org/dist/index.tab

"pm_cwd": "/opt/my-app"

},

"versioning": {

"type": "git",

"url": "git@github.com:tkambler/pro-javascript-frameworks.git",

"revision": "18104d13d14673652ee7a522095fc06dcf87f8ba",

"update_time": "2015-05-25T20:53:50.000Z",

"comment": "Merge pull request #28 from tkambler/ordered-build",

"unstaged": true,

"branch": "pm2",

"remotes": ["origin"],

"remote": "origin",

"branch_exists_on_remote": false,

"ahead": false,

"next_rev": null,

"prev_rev": "b0e486adab79821d3093c6522eb8a24455bfb051",

"repo_path": "/Users/user/repos/pro-javascript-frameworks"

}

},

"pm_id": 0,

"monit": {

"memory": 32141312,

"cpu": 0

}

}]

}

高级流程管理

到目前为止,本章的大部分重点都围绕着主要通过命令行与 PM2 进行的交互。像startstoprestartdelete这样的命令本身为我们提供了简单的机制,以快速、一次性的方式管理流程。但是更复杂的场景呢?也许应用需要在运行时指定额外的参数,或者它期望设置一个或多个环境变量。

JSON 应用声明

为了满足这些需求,需要额外的配置,而实现这一点的最佳方式是借助 PM2 所说的“JSON 应用配置”文件。清单 4-5 中显示了一个示例配置文件,它展示了大多数可用的各种选项。

Listing 4-5. Sample of the Various Options Available Within a JSON Application Configuration File

{

"name"              : "my-app",

"cwd"               : "/opt/my-app",

"args"              : ["--argument1=value", "--flag", "value"],

"script"            : "index.js",

"node_args"         : ["--harmony"],

"log_date_format"   : "YYYY-MM-DD HH:mm Z",

"error_file"        : "/var/log/my-app/err.log",

"out_file"          : "/var/log/my-app/out.log",

"pid_file"          : "pids/my-app.pid",

"instances"         : 1, // or 0 => 'max'

"max_restarts"      : 10, // defaults to 15

"max_memory_restart": "1M", // 1 megabytes, e.g.: "2G", "10M", "100K"

"cron_restart"      : "1 0 * * *",

"watch"             : false,

"ignore_watch"      : ["node_modules"],

"merge_logs"        : true,

"exec_mode"         : "fork",

"autorestart"       : false,

"env": {

"NODE_ENV": "production"

}

}

JSON 应用配置文件为我们提供了一种标准格式,以一种易于重复和与他人共享的方式将高级设置传递给 PM2。根据之前的示例(例如,nameout_fileerror_filewatch等),您在这里看到的几个选项应该很熟悉。).其他的将在这一章的后面提到。表 4-4 中提供了每一个的描述。

表 4-4。

Descriptions of the Various Configuration Settings Shown in Listing 4-5

| 环境 | 描述 | | --- | --- | | `name` | 应用的名称。 | | `cwd` | 将从中启动应用的目录。 | | `args` | 要传递给应用的命令行参数。 | | `script` | PM2 启动应用的脚本路径(相对于`cwd`)。 | | `node_args` | 传递给`node`可执行文件的命令行参数。 | | `log_date_format` | 生成日志时间戳的格式。 | | `error_file` | 标准错误消息将被记录到的路径。 | | `out_file` | 将记录突出输出消息的路径。 | | `pid_file` | 应用的 PID(进程标识符)将被记录到的路径。 | | `instances` | 要启动的应用实例的数量。将在下一节详细讨论。 | | `max_restarts` | 在放弃之前,PM2 将尝试重新启动(连续)失败的应用的最大次数。 | | `max_memory_restart` | 如果应用消耗的内存量超过这个阈值,PM2 将自动重启应用。 | | `cron_restart` | PM2 将按照指定的计划自动重启应用。 | | `watch` | PM2 是否应该在检测到文件结构更改时自动重新启动应用。默认为`false`。 | | `ignore_watch` | 如果启用了监视,PM2 应该忽略文件更改的位置数组。 | | `merge_logs` | 如果创建了一个应用的多个实例,PM2 应该为所有实例使用一个输出和错误日志文件。 | | `exec_mode` | 执行方法。默认为`fork`。将在下一节详细讨论。 | | `autorestart` | 自动重启崩溃或退出的应用。默认为`true`。 | | `vizon` | 如果启用,PM2 将尝试从应用的版本控制文件中读取元数据(如果它们存在)。默认为`true`。 | | `env` | 包含要传递给应用的环境变量键/值的对象。 |

本章包括一个microservices项目,它提供了 JSON 配置文件的工作演示。这个项目包含两个应用:一个是带有 API 的weather应用,它返回指定邮政编码的随机温度信息;另一个是main应用,它每两秒钟向 API 发出一次请求,并将结果打印到控制台。清单 4-6 显示了每个应用的主要脚本。

Listing 4-6. Source Code for the main and weather Applications

// microservices/main/index.js

var request = require('request');

if (!process.env.WEATHER_API_URL) {

throw new Error('The WEATHER_API_URL environment variable must be set.');

}

setInterval(function() {

request({

'url': process.env.WEATHER_API_URL + '/api/weather/37204',

'json': true,

'method': 'GET'

}, function(err, res, result) {

if (err) throw new Error(err);

console.log('The temperature is: %s', result.temperature.fahrenheit);

});

}, 2000);

// microservices/weather/index.js

if (!process.env.PORT) {

throw new Error('The PORT environment variable must be set.');

}

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

var random = function(min, max) {

return Math.floor(Math.random() * (max - min + 1) + min);

};

app.get('/api/weather/:postal_code', function(req, res, next) {

var fahr = random(70, 110);

res.send({

'temperature': {

'fahrenheit': fahr,

'celsius': (fahr - 32) * (5/9)

}

});

});

app.listen(process.env.PORT);

一个 JSON 应用配置文件也包含在microservices项目中,其内容如清单 4-7 所示。

Listing 4-7. JSON Application Configuration File for this Chapter’s microservices Projectmicroservices/pm2/development.json

[

{

"name"              : "main",

"cwd"               : "../microservices",

"script"            : "main/index.js",

"max_memory_restart": "60M",

"watch"             : true,

"env": {

"NODE_ENV": "development",

"WEATHER_API_URL": "``http://localhost:7010

}

},

{

"name"              : "weather-api",

"cwd"               : "../microservices",

"script"            : "weather/index.js",

"max_memory_restart": "60M",

"watch"             : true,

"env": {

"NODE_ENV": "development",

"PORT": 7010

}

}

]

此处显示的应用配置文件为 PM2 提供了如何启动该项目中包含的每个应用的说明。在本例中,如果检测到任何一个应用的文件结构发生变化,或者如果它们开始消耗超过 60MB 的内存,PM2 将被指示重新启动每个应用。该文件还为 PM2 提供了要传递给每个进程的单独的环境变量。

Note

在运行这个示例之前,您需要调整这个文件中的cwd设置的值,以便它们引用您计算机上的microservices文件夹的绝对路径。在做了适当的调整后,用一个对 PM2 的调用启动这两个应用,如图 4-12 所示。

A978-1-4842-0662-1_4_Fig12_HTML.jpg

图 4-12。

Launching the main and weather-api applications with PM2

不出所料,PM2 为我们创建了两个实例,配置文件中引用的每个应用都有一个。和前面的例子一样,我们可以在 PM2 的logs命令的帮助下监控生成的输出(见图 4-13 )。

A978-1-4842-0662-1_4_Fig13_HTML.jpg

图 4-13。

Excerpt of the output generated by PM2’s logs command

跨多个处理器的负载平衡

Node I/O 模型的单线程、非阻塞特性使开发人员能够相对轻松地创建能够处理数千个并发连接的应用。虽然 Node 处理传入请求的效率令人印象深刻,但它也带来了一项巨大的开销:无法将计算分散到多个 CPU 上。幸运的是,Node 的核心cluster模块提供了解决这一限制的方法。有了它,开发人员可以编写能够创建自己的子进程的应用——每个子进程运行在单独的处理器上,每个子进程都能够与其他子进程和启动它的父进程共享端口的使用。

在我们结束这一章之前,让我们看一下由 PM2 提供的 Node 的cluster模块的一个方便的抽象。有了这个功能,最初没有利用 Node 的cluster模块的应用可以以一种允许它们充分利用多处理器环境的方式启动。因此,开发人员可以快速扩展他们的应用以满足不断增长的需求,而不必立即被迫增加服务器。

清单 4-8 显示了一个简单的 Express 应用的源代码,我们将在 PM2 的帮助下跨多个处理器进行扩展,而清单 4-9 显示了附带的 JSON 应用配置文件。

Listing 4-8. Express Application to be Scaled Across Multiple CPUs

// multicore/index.js

if (!process.env.port) throw new Error('The port environment variable must be set');

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

app.listen(process.env.port);

Listing 4-9. JSON Application Configuration File with Which Our Application Will Be Launched

// multicore/pm2/development.json

{

"name": "multicore",

"cwd": "../multicore",

"max_memory_restart": "60M",

"watch": false,

"script": "index.js",

"instances": 0, // max

"exec_mode": "cluster",

"autorestart": true,

"merge_logs": true,

"env": {

"port": 9000

}

}

清单 4-9 中所示的应用配置文件包含两个感兴趣的关键项目。首先是instances属性。在这个例子中,我们指定了一个值0,它指示 PM2 为它找到的每个 CPU 启动一个单独的进程。第二个是exec_mode地产。通过指定cluster的值,我们指示 PM2 启动它自己的父进程,它将在节点的cluster模块的帮助下依次为我们的应用启动单独的子进程。

在图 4-14 中,我们通过将应用配置文件的路径传递给 PM2 的start命令来启动应用。之后,PM2 显示了所有已知进程的列表,和前面的例子一样。在本例中,我们看到 PM2 为我们环境中的八个可用 CPU 分别启动了一个单独的进程。我们可以通过使用monit命令监控每个新进程的 CPU 使用情况来验证这一点,如图 4-15 所示。

A978-1-4842-0662-1_4_Fig15_HTML.jpg

图 4-15。

Monitoring CPU usage with PM2’s monit command

A978-1-4842-0662-1_4_Fig14_HTML.jpg

图 4-14。

Launching the application on cluster mode with PM2 Note

当以集群模式启动应用时,PM2 会向控制台打印一条消息,警告该功能仍是测试版功能。然而,据 PM2 的首席开发人员称,只要使用 Node v0.12.0 或更高版本,这种功能对于生产环境来说就足够稳定了。

在继续之前,您可以通过运行$ pm2 delete multicore快速删除本例启动的八个进程中的每一个。

零停机部署

在集群模式下启动应用后,PM2 将开始以循环方式将传入的请求转发给它所控制的八个进程中的每一个——为我们提供了巨大的性能提升。一个额外的好处是,让我们的应用分布在多个处理器上还允许我们发布更新,而不会导致任何停机,我们马上就会看到这一点。

想象一个场景,在 PM2 的控制下,一个应用运行在一个或多个服务器上。随着该应用的更新变得可用,向公众发布它们将涉及两个关键步骤:

  • 将更新的源代码复制到适当的服务器
  • 在 PM2 的控制下重新启动每一个进程

随着这些步骤的进行,将引入一段短暂的停机时间,在此期间,对应用的传入请求将被拒绝——除非采取特殊的预防措施。幸运的是,在集群模式下使用 PM2 启动应用为我们提供了采取这些预防措施所需的工具。

为了避免重新启动我们之前在清单 4-8 中看到的应用时的任何停机时间,我们首先需要对我们的应用的源代码和应用配置文件做一个小的调整。更新后的版本如清单 4-10 所示。

Listing 4-10. Application Designed to Take Advantage of PM2’s gracefulReload Command

// graceful/index.js

if (!process.env.port) throw new Error('The port environment variable must be set');

var server;

var express = require('express');

var morgan = require('morgan');

var app = express();

app.use(morgan('combined'));

app.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

process.on('message', function(msg) {

switch (msg) {

case 'shutdown':

server.close();

break;

}

});

server = app.listen(process.env.port, function() {

console.log('App is listening on port: %s', process.env.port);

});

// graceful/pm2/production.json

{

"name": "graceful",

"cwd": "../graceful",

"max_memory_restart": "60M",

"watch": false,

"script": "index.js",

"instances": 0, // max

"exec_mode": "cluster",

"autorestart": true,

"merge_logs": false,

"env": {

"port": 9000,

"PM2_GRACEFUL_TIMEOUT": 10000

}

}

前面的例子已经演示了 PM2 的restart命令的使用,它可以立即停止和启动指定的进程。虽然这种行为在非生产环境中通常不是问题,但是当我们考虑它对我们的应用在发出该命令时可能正在处理的任何活动请求的影响时,问题就开始出现了。当稳定至关重要时,PM2 的gracefulReload指令是一个更合适的选择。

当被调用时,gracefulReload首先向其控制下的每个进程发送一个shutdown消息,为它们提供采取任何必要预防措施的机会,以确保任何活动连接都不会受到干扰。只有在经过了一段可配置的时间(通过PM2_GRACEFUL_TIMEOUT环境变量指定)后,PM2 才会继续重启进程。

在这个例子中,在收到shutdown消息后,我们的应用通过调用 Express 为我们创建的 HTTP 服务器上的close()方法进行响应。这个方法指示我们的服务器停止接受新的连接,但是允许已经建立的连接完成。只有在十秒钟后(通过PM2_GRACEFUL_TIMEOUT指定),PM2 才会重新启动该进程,此时该进程管理的任何连接都应该已经完成。

图 4-16 展示了通过使用gracefulReload命令启动和随后重启该应用的过程。通过这样做,我们能够在不中断应用用户的情况下发布更新。

A978-1-4842-0662-1_4_Fig16_HTML.jpg

图 4-16。

Gracefully reloading each of the processes under PM2’s control

摘要

PM2 为开发人员提供了一个强大的管理节点应用的实用工具,无论是在生产环境还是非生产环境中,它都同样适用。一些简单的方面,例如当源代码发生变化时,该工具能够自动重启其控制下的进程,这在开发过程中可以节省大量时间。更高级的功能,如跨多个处理器负载平衡应用的能力,以及以不会对用户产生负面影响的方式正常重启这些应用的能力,也为使用大量节点提供了关键功能。

相关资源

五、RequireJS

思考我能控制的事情比担心和烦恼我控制不了的事情更有成效。担忧不是一种思考方式。——彼得·圣安德烈

虽然 JavaScript 现在在 web 应用中扮演着重要得多的角色,但 HTML5 规范(以及现代浏览器)并没有指定检测脚本间依赖关系的方法,也没有指定如何以特定的顺序加载脚本依赖关系。在最简单的场景中,脚本通常用简单的<script>标签在页面标记中引用。这些标签是按顺序计算、加载和执行的,这意味着通常首先包括公共库或模块,然后是应用脚本。(例如,一个页面可能会加载 jQuery,然后加载一个使用 jQuery 来操作文档对象模型[DOM]的应用脚本。)具有容易跟踪的依赖关系层次结构的简单网页非常适合这种模型,但是随着 web 应用的复杂性增加,应用脚本的数量将会增加,并且依赖关系的网络可能变得难以管理,如果不是不可能的话。

异步脚本使得整个过程更加混乱。如果一个<script>标签拥有一个async属性,脚本内容将在后台通过 HTTP 加载,并在可用时立即执行。加载脚本时,页面的其余部分,包括任何后续的脚本标记,将继续加载。当评估和执行应用脚本时,异步加载的大型依赖项(或由慢速源交付的依赖项)可能不可用。即使应用的<script>标签也拥有async属性,开发人员也无法控制所有异步脚本的加载顺序,因此无法确保依赖层次结构得到尊重。

Tip

HTML5 <script>标签属性defer类似于async,但是会延迟脚本的执行,直到页面解析完成。这两个属性都减少了页面呈现延迟,从而改善了用户体验和页面性能。这对于移动设备尤其重要。

RequireJS 就是为了解决这种依赖关系编排问题而创建的,它为开发人员提供了一种编写 JavaScript 模块(“脚本”)的标准方法,这些模块在执行任何模块之前声明它们自己的依赖关系。通过预先声明所有的依赖关系,RequireJS 可以确保在以正确的顺序执行模块的同时,异步加载整个依赖关系层次结构。这种模式称为异步模块定义(AMD),与 Node.js 和 Browserify 模块加载库采用的 CommonJS 模块加载模式形成对比。虽然在各种用例中使用这两种模式肯定有其优点,但开发 RequireJS 和 AMD 是为了解决特定于 web 浏览器和 DOM 缺点的问题。实际上,RequireJS 和 Browserify 在实现中做出的让步通常会被工作流和社区插件所缓解。

例如,RequireJS 可以为它必须加载的非 AMD 依赖项(通常是内容交付网络上的远程库或遗留代码)创建动态垫片。这一点很重要,因为 RequireJS 假设 web 应用中的脚本可能来自多个来源,并且不会全部直接在开发人员的控制之下。默认情况下,RequireJS 不会将所有应用脚本(“打包”)连接到一个文件中,而是选择为它加载的每个脚本发出 HTTP 请求。稍后讨论的 RequireJS 工具 r.js 为生产环境生成打包的包,但是仍然可以从其他位置加载远程的填充脚本。另一方面,Browserify 采取了“包优先”的方法。它假设所有内部脚本和依赖项将被打包到一个文件中,其他远程脚本将被单独加载。这将远程脚本置于 Browserify 的控制之外,但是像bromote这样的插件在 CommonJS 模型中工作,以便在打包过程中加载远程脚本。对于这两种方法,最终结果是相同的:应用在运行时可以使用远程资源。

运行示例

本章包含了许多可以在现代网络浏览器中运行的例子。Node.js 是安装代码依赖项和运行所有 web 服务器脚本所必需的。

要安装示例代码依赖项,请在终端中打开code/requirejs目录并执行命令npm install。这个命令将读取package.json文件,并下载运行每个示例所需的几个包。

本章中的示例代码块在顶部包含一个注释,以指示在哪个文件中可以找到源代码。例如,清单 5-1 中虚构的index.html文件可以在example-000/public目录中找到。(这个目录并不真的存在,找不到也不用担心。)

Listing 5-1. An Exciting HTML File

<!-- example-000/public/index.html -->

<html>

<head></head>

<body><h1>Hello world!</h1></body>

</html>

除非另有说明,否则假设所有示例代码目录都包含一个启动非常基本的 web 服务器的index.js文件。清单 5-2 展示了如何在终端中使用 Node.js 来运行虚构的 web 服务器脚本example-000/index.js

Listing 5-2. Launching an Exciting Web Server

example-000$ node index.js

>> mach web server started on node 0.12.0

>> Listening on :::8080, use CTRL+C to stop

命令输出显示 web 服务器在http://localhost:8080监听。在 web 浏览器中,导航到http://localhost:8080/index.html将呈现清单 5-1 中的 HTML 片段。

使用要求

在 web 应用中使用 RequireJS 的工作流通常包括一些常见步骤。首先,RequireJS 必须加载到一个带有<script>标签的 HTML 文件中。RequireJS 可以作为 web 服务器或 CDN 上的独立脚本引用,也可以与 Bower 和 npm 等包管理器一起安装,然后从本地 web 服务器提供服务。接下来,必须配置 RequireJS,以便它知道脚本和模块位于何处,如何填充不符合 AMD 的脚本,加载哪些插件,等等。一旦配置完成,RequireJS 将加载一个主应用模块,该模块负责加载主要的页面组件,实质上是“启动”页面的应用代码。此时,RequireJS 评估模块创建的依赖关系树,并开始在后台异步加载依赖关系脚本。一旦加载了所有模块,应用代码就开始做它权限内的任何事情。

在接下来的章节中,我们将详细考虑这一过程中的每一步。每一节中使用的示例代码代表了一个简单应用的发展,该应用将显示(半)名人的励志和幽默语录。

装置

RequireJS 脚本可以直接从 http://requirejs.org 下载。它有几种不同的风格:普通的 RequireJS 脚本、与 jQuery 预绑定的普通 RequireJS 脚本,以及包含 RequireJS 及其打包工具 r.js 的 Node.js 包。预绑定的 jQuery 脚本只是为了方便开发人员而提供的。如果您希望将 RequireJS 添加到已经使用 jQuery 的项目中,那么普通的 RequireJS 脚本可以适应现有的 jQuery 安装,不会有任何问题,尽管可能需要对旧版本的 jQuery 进行填充。(填补的脚本将在后面介绍。)

一旦获取了 RequireJS 脚本,就会在 web 应用中使用一个<script>标签引用它。因为 RequireJS 是一个模块加载器,它承担着加载应用可能需要的所有其他 JavaScript 文件和模块的责任。因此,RequireJS <script>标签很可能是唯一一个占据网页的<script>标签。清单 5-3 中给出了一个简化的例子。

Listing 5-3. Including the RequireJS Script on a Web Page

<!-- example-001/public/index.html -->

<body>

<header>

<h1>Ponderings</h1>

</header

<script src="/scripts/require.js"></script>

</body>

配置

在 RequireJS 脚本加载到页面上之后,它会寻找一个配置,这个配置将主要告诉 RequireJS 脚本和模块位于何处。在中,可以通过三种方式之一提供配置选项。

首先,可以在加载 RequireJS 脚本之前创建一个全局require对象。该对象可能包含所有的 RequireJS 配置选项以及一个“启动”回调,一旦 RequireJS 加载完所有的应用模块,就会执行该回调。

清单 5-4 中的脚本块显示了存储在全局require变量中的一个新生成的 RequireJS 配置对象。

Listing 5-4. Configuring RequireJS with a Global require Object

<!-- example-001/public/config01.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

<script>

/*

* Will be automatically attached to the

* global window object as window.require.

*/

var require = {

// configuration

baseUrl: '/scripts',

// kickoff

deps: ['quotes-view'],

callback: function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

}

};

</script>

<script src="/scripts/require.js"></script>

</body>

这个对象上最重要的配置属性baseUrl标识了相对于应用根的路径,RequireJS 应该从该路径开始解析模块依赖关系。deps数组指定了配置后应该立即加载的模块,而callback函数的作用是在模块加载后接收这些模块。这个例子加载了一个模块quotes-view。一旦回调被调用,它就可以访问这个模块上的属性和方法。

清单 5-5 中的目录树显示了quotes-view.js文件相对于config01.html(正在查看的页面)和require.js的位置。

Listing 5-5. Application File Locations

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

ε──??″

ε──??″

注意在deps数组中省略了quotes-view模块的绝对路径和文件扩展名。默认情况下,RequireJS 假定任何给定的模块都是相对于正在查看的页面定位的,并且它包含在具有适当文件扩展名的单个 JavaScript 文件中。在这种情况下,后一个假设是正确的,但第一个不是,这就是为什么指定一个baseUrl属性是必要的。当 RequireJS 试图解析任何模块时,它将组合任何配置的baseUrl值和模块名,然后附加.js文件扩展名以产生相对于应用根的完整路径。

config01.html页面加载时,传递给quotesView.addQuote()方法的字符串将显示在页面上。

第二种配置方法与第一种类似,但在加载 RequireJS 脚本后使用 RequireJS API 来执行配置,如清单 5-6 所示。

Listing 5-6. Configuration with the RequireJS API

<!-- example-001/public/config02.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

<script src="/scripts/require.js"></script>

<script>

// configuration

requirejs.config({

baseUrl: '/scripts'

});

// kickoff

requirejs(['quotes-view'], function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

});

</script>

</body>

在这个例子中,<script>块首先使用由require.js脚本创建的全局requirejs对象,通过调用它的config()方法来配置 RequireJS。然后它调用requirejs来启动应用。传递给config()方法的对象类似于清单 5-4 中的全局require对象,但是缺少其depscallback属性。requirejs函数接受一组应用依赖项和一个回调函数,这种模式在后面介绍模块设计时会变得非常熟悉。

最终效果是一样的:RequireJS 使用它的配置来加载quotes-view模块,一旦加载,回调函数就与它交互来影响页面。

第三种配置方法使用第二种方法的语法,但是将配置和启动代码移到它自己的脚本中。清单 5-7 中的 RequireJS <script>标签使用data-main属性告诉 RequireJS 它的配置和启动模块位于何处。

Listing 5-7. Configuring RequireJS with an External Script

<!-- example-001/public/config03.html -->

<body>

<header>

<h1>Ponderings</h1>

</header>

<section id="quotes"></section>

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

</body>

一旦加载了 RequireJS,它将寻找data-main属性,如果找到,异步加载属性中指定的脚本。清单 5-8 显示了main.js的内容,与清单 5-6 中的<script>块相同。

Listing 5-8. The RequireJS Main Module

// example-001/public/scripts/main.js

// configuration

requirejs.config({

baseUrl: '/scripts'

});

// kickoff

requirejs(['quotes-view'], function (quotesView) {

quotesView.addQuote('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');

quotesView.addQuote('Nunc non purus faucibus justo tristique porta.');

});

Tip

因为data-main脚本是异步加载的,所以包含在 RequireJS 之后的脚本或<script>块可能会首先运行。如果 RequireJS 管理一个应用中的所有脚本,或者在 RequireJS 之后加载的脚本对应用本身没有影响(比如广告商脚本),就不会有冲突。

应用模块和依赖关系

RequireJS 模块由三部分定义:

A module name   A list of dependencies (modules)   A module closure that will accept the output from each dependency module as function arguments, set up module code, and potentially return something that other modules can use

清单 5-9 展示了假模块定义中的每一点。当全局define()函数被调用时,模块被创建。这个函数有三个参数,对应于上面的三点。

Listing 5-9. Module Anatomy

define(``/*#1*/``'m1',``/*#2*/``['d1', 'd2'],``/*#3*/

/*

* Variables declared within the module closure

* are private to the module, and will not be

* exposed to other modules

*/

var privateModuleVariable = "can’t touch this";

/*

* The returned value (if any) will now be available

* to any other module if they specify m1 as a

* dependency.

*/

return {

getPrivateModuleVariable: function () {

return privateModuleVariable;

}

};

})

模块的名字是关键。在清单 5-9 中,明确声明了一个模块名m1。如果省略了模块名(将依赖项和模块闭包作为传递给define()的唯一参数),那么 RequireJS 将假设模块名是包含模块脚本的文件名,没有扩展名.js。这在实践中很常见,但是为了清楚起见,这里显示了模块名称。

Tip

给模块指定特定的名称会带来不必要的复杂性,因为需要依赖脚本 URL 路径来加载模块。如果一个模块被显式命名,而文件名与模块名不匹配,那么需要在 RequireJS 配置中定义一个模块别名,将模块名映射到一个实际的 JavaScript 文件。这将在下一节中介绍。

清单 5-9 中的依赖列表标识了 RequireJS 应该加载的另外两个模块。值d1d2是这些模块的名称,位于脚本文件d1.jsd2.js中。这些脚本看起来类似于清单 5-9 中的模块定义,但是它们将加载自己的依赖项。

最后,模块闭包接受每个依赖模块的输出作为函数参数。这个输出是从每个依赖模块的闭包函数返回的任何值。清单 5-9 中的闭包返回它自己的值,如果另一个模块将m1声明为依赖项,那么这个返回值将被传递给那个模块的闭包。

如果一个模块没有依赖关系,那么它的依赖数组将会是空的,并且它不会收到任何关于它的闭包的参数。

一旦模块被加载,它就存在于内存中,直到应用被终止。如果多个模块声明了同一个依赖项,则该依赖项只加载一次。它从闭包返回的任何值都将通过引用传递给两个模块。然后,给定模块的状态在使用它的所有其他模块之间共享。

一个模块可以返回任何有效的 JavaScript 值,或者根本不返回任何值,如果模块的存在只是为了操纵其他模块或者只是在应用中产生副作用。

清单 5-10 显示了example-002/public目录的结构。这看起来与example-001相似,但是添加了一些额外的模块,即data/quotes.js(一个用于获取报价数据的模块)和util/dom.js(一个为其他模块包装全局window对象以便它们不需要直接访问window的模块)。

Listing 5-10. Public Directory Structure for example-``002

public

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

回想一下,模块的依赖关系是相对于 RequireJS baseUrl值而存在的。当一个模块指定依赖路径时,它是相对于baseUrl路径来指定的。在清单 5-11 中,main.js文件依赖于data/quotes模块(public/scripts/data/quotes.js),而quotes-view.js模块依赖于util/dom ( public/scripts/util/dom.js)。

Listing 5-11. Module Dependency Paths

// example-002/public/scripts/main.js

requirejs(['data/quotes', 'quotes-view'], function (quoteData, quotesView) {

// ...

});

// example-002/public/scripts/data/quotes.js

define([/*no dependencies*/], function () {

// ...

});

// example-002/public/scripts/quotes-view.js

define(['util/dom'], function (dom) {

// ...

});

// example-002/public/scripts/util/dom.js

define([/*no dependencies*/], function () {

// ...

});

图 5-1 显示了加载这些模块时创建的逻辑依赖树。

A978-1-4842-0662-1_5_Fig1_HTML.gif

图 5-1。

RequireJS dependency tree

随着应用依赖性的增加,模块路径会变得单调乏味,但是有两种方法可以减轻这种情况。

首先,模块可以使用前导点符号来指定相对于自身的依赖关系。例如,一个声明了依赖关系./foo的模块会将foo.js作为一个兄弟文件加载,与它自己位于同一个 URL 段上,而一个具有依赖关系../bar的模块会将bar.js从它自己“向上”加载一个 URL 段。这大大减少了依赖性的冗长。

第二,模块可以用路径别名命名,在 RequireJS 配置中定义,如下一节所述。

路径和别名

给一个模块分配一个别名允许其他模块使用该别名作为依赖名,而不是完整的模块路径名。由于各种原因,这可能是有用的,但通常用于简化供应商模块路径,从供应商模块名称中消除版本号,或者处理显式声明自己的模块名称的供应商库。

清单 5-12 中的模块依赖于供应商库 jQuery。如果jquery模块脚本位于/scripts/jquery.js处,则不需要模块别名来加载依赖关系;RequireJS 将根据已配置的baseUrl配置值来定位模块。

Listing 5-12. Specifying a jQuery Module Dependency

define(['jquery'], function ($) {

// ...

});

然而,jquery不太可能位于由baseUrl配置定义的模块根。更有可能的是,jquery脚本将存在于供应商目录中,例如/scripts/vendor/jquery,并且脚本名称将包含 jQuery 版本(例如jquery-2.1.3.min),因为 jQuery 脚本就是这样分发的。更复杂的是,jQuery 明确声明了自己的模块名jquery。如果模块试图使用 jQuery 脚本的完整路径/scripts/vendor/jquery/jquery-2.1.3.min加载jquery,RequireJS 将通过 HTTP 加载脚本,然后无法导入模块,因为它声明的名称是jquery,而不是jquery-2.1.3.min

Tip

显式命名模块被认为是不好的做法,因为应用模块必须使用模块声明的名称,并且包含该模块的脚本文件必须共享其名称或者在 RequireJS 配置中有别名。为 jQuery 做了一个特殊的让步,因为它是一个相当普遍的库。

别名在 RequireJS 配置散列中的paths属性下指定。在清单 5-13 中,别名jquery被分配给vendor/jquery/jquery-2.1.3.min,这是一个相对于baseUrl的路径。

Listing 5-13. Configuration Module Path Aliases

requirejs.config({

baseUrl: '/scripts',

// ... other options ...

paths: {

'jquery': 'vendor/jquery/jquery-2.1.3.min'

}

});

paths对象中,别名是键,它们映射到的脚本是值。一旦定义了模块别名,它就可以在任何其他模块的依赖列表中使用。清单 5-14 显示了正在使用的jquery别名。

Listing 5-14. Using a Module Alias in a Dependency List

// jquery alias points to vendor/jquery/jquery-2.1.3.min

define(['jquery'], function ($) {

// ...

});

因为模块别名优先于实际的模块位置,所以 RequireJS 将在试图在/scripts/jquery.js定位 jQuery 脚本之前解析它的位置。

Note

匿名模块(没有声明自己的模块名)可以用任何模块名作为别名,但是如果命名模块有别名(像jquery),它们必须用它们声明的模块名作为别名。

加载带有代理模块的插件

jQuery、下划线、Lodash、Handlebars 等库都有插件系统,允许开发人员扩展各自的功能。战略性地使用模块别名实际上可以帮助开发人员一次性加载这些库的扩展,而不必在每个使用它们的模块中指定这样的扩展。

在清单 5-15 中,为了简洁起见,jquery脚本位置用名称jquery作为别名,自定义模块util/jquery-all用名称jquery-all作为别名。所有的应用模块将通过指定jquery-all为依赖项来加载jquery。反过来,jquery-all模块加载普通的jquery模块,然后给它附加定制插件。

Listing 5-15. Using Module Aliases to Load jQuery Plugins

requirejs.config({

baseUrl: '/scripts',

// ... other options ...

paths: {

// vendor script

'jquery': 'vendor/jquery/jquery-2.1.3.min',

// custom extensions

'jquery-all': 'util/jquery-all'

}

});

// example-003/public/scripts/util/jquery-all

define(['jquery'], function ($) {

$.fn.addQuotes = function () {/*...*/};

return $;

// or

//return $.noConflict(true);

});

jquery-all代理模块返回 jQuery 对象本身,这允许依赖于jquery-all的模块使用加载的定制扩展访问jquery。默认情况下,jQuery 向全局window对象注册自己,即使它被用作 AMD 模块。如果所有的应用模块都通过jquery-all模块(或者甚至是普通的jquery模块,正如大多数供应商库所做的那样)访问 jQuery,那么就不需要 jQuery 全局变量。可以通过调用$.noConflict(true)将其移除。这将返回jquery对象,并且是清单 5-15 中jquery-all模块的替代返回值。

因为 jQuery 现在是示例应用的一部分,所以负责在 DOM 中呈现报价数据的quotes-view模块不再需要依赖于util/dom模块。它可以将jquery-all指定为依赖项,并一次性加载jquery和自定义的addQuotes()插件方法。清单 5-16 显示了对quotes-view模块所做的更改。

Listing 5-16. Loading jQuery and Custom Plugins in the quotes-view Module

// example-003/public/scripts/quotes-view.js

define(['jquery-all'], function ($) {

var $quotes = $('#quotes');

return {

render: function (groupedQuotes) {

for (var attribution in groupedQuotes) {

if (!groupedQuotes.hasOwnProperty(attribution)) continue;

$quotes.addQuotes(attribution, groupedQuotes[attribution]);

}

}

};

});

使用模块代理来加载jquery的优点是,它消除了在依赖于jquery和定制插件模块的其他模块中指定这两者的需要。例如,如果没有这种技术,应用模块将会有多个依赖项来确保在需要时加载适当的 jQuery 插件,如清单 5-17 所示。

Listing 5-17. Loading Plugins Without a Proxy Module

// scripts/util/jquery-plugin-1.js

define(['jquery'], function ($) {

$.fn.customPlugin1 = function () {/*...*/};

});

// scripts/util/jquery-plugin-2.js

define(['jquery'], function ($) {

$.fn.customPlugin2 = function () {/*...*/};

});

// scripts/*/module-that-uses-jquery.js

define(['jquery', 'util/jquery-plugins-1', 'util/jquery-plugins-2'], function ($) {

// ...

});

在这种情况下,即使jquery-plugin-1jquery-plugin-2没有返回值,它们仍然必须作为依赖项添加,这样它们的副作用——向jquery模块添加插件——仍然会发生。

垫片

支持 AMD 模块格式的库可以直接用于 RequireJS。通过配置 RequireJS 垫片或手动创建垫片模块,仍可使用非 AMD 库。

example-003中的data/quotes模块公开了一个groupByAttribution()方法,该方法迭代引用集合。它创建了一个散列,其中键是人名,值是属于他们的引号数组。这种分组功能可能对其他集合也很有用。

幸运的是,供应商库 undrln 可以提供该功能的通用版本,但它与 AMD 不兼容。对于其他 AMD 模块来说,使用 undrln 作为依赖项,需要一个垫片。Undrln 是作为函数闭包内的标准 JavaScript 模块编写的,如清单 5-18 所示。它将自己分配给全局window对象,页面上的其他脚本可以访问它。

Note

脚本公然模仿了 Lodash API 的一个子集,不兼容 AMD 模块,专门用于本章的例子。

Listing 5-18. The Completely Original Undrln Library

// example-004/public/scripts/vendor/undrln/undrln.js

/**

* undrln (c) 2015 l33th@x0r

* MIT license.

* v0.0.0.0.1-alpha-DEV-theta-r2

*/

(function () {

var undrln = window._ = {};

undrln.groupBy = function (collection, key) {

// ...

};

}());

要创建一个 shim,必须向 RequireJS 配置中添加一些东西。首先,必须在paths下创建一个模块别名,以便 RequireJS 知道填充的模块位于何处。其次,必须将一个垫片配置条目添加到shim部分。两者都被添加到清单 5-19 中的 RequireJS 配置中。

Listing 5-19. Configuration of a Module Shim

// example-004/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

jquery: 'vendor/jquery/jquery-2.1.3.min',

'jquery-all': 'util/jquery-all',

// giving undrln a module alias

undrln: 'vendor/undrln/undrln'

},

shim: {

// defining a shim for undrln

undrln: {

exports: '_'

}

}

});

shim部分下的每个键标识要填充的模块别名(或名称),分配给这些键的对象指定了关于填充程序如何工作的细节。在幕后,RequireJS 通过定义一个空的 AMD 模块来创建一个 shim,该模块返回由脚本或库创建的全局对象。un drn 创建了全局window._对象,因此名字_在 shim 配置中被指定为 un drn 的导出。最终生成的 RequireJS shim 将类似于清单 5-20 中的模块。请注意,这些垫片是在模块加载时动态创建的,并不作为“文件”实际存在于 web 服务器上。(这个规则的一个例外是 r.js 打包工具,稍后讨论,它将生成的 shim 输出写入一个包文件,作为一种优化措施。)

Listing 5-20. Example RequireJS Shim Module

define('undrln', [], function () {

return window._;

});

清单 5-21 中的quotes模块现在可以使用undrln垫片作为依赖项。

Listing 5-21. Using the Undrln Shim As a Dependency

// example-004/public/scripts/data/quotes.js

define(['undrln'], function (_) {

//...

return {

groupByAttribution: function () {

return _.groupBy(quoteData, 'attribution');

},

//...

}

});

通过填充非 AMD 脚本,当非 AMD 脚本依赖于其他 AMD 模块时,RequireJS 可以在后台使用其异步模块加载功能来加载非 AMD 脚本。如果没有这个功能,这些脚本将需要用标准的<script>标签包含在每个页面上,并同步加载以确保可用性。

example-004中运行 web 应用,然后浏览到http://localhost:8080/index.html将显示报价列表。图 5-2 显示了渲染页面和 Chrome 的网络面板,其中列出了所有加载的 JavaScript 模块。注意,Initiator 列清楚地显示了 RequireJS 负责加载所有模块,甚至非 AMD 的undrln.js模块也包含在列表中。

A978-1-4842-0662-1_5_Fig2_HTML.jpg

图 5-2。

RequireJS modules shown loaded in Chrome

填补依赖项

期望填充的脚本具有依赖性是合理的,例如全局范围内的对象。当 AMD 模块指定依赖项时,RequireJS 确保在执行模块代码之前,首先加载依赖项。填补脚本的依赖性在填补配置中以类似的方式指定。一个填充的脚本可能依赖于其他填充的脚本,或者甚至依赖于 AMD 模块,如果这些模块使内容在全局范围内可用的话(通常是一个坏主意,但有时是必要的)。

为了增强示例应用,在example-005的报价页面中添加了一个搜索字段。在搜索字段中输入的术语会在找到它们的任何报价文本中突出显示。到目前为止,所有示例都使用一个视图quotes-view来显示呈现的标记。因为应用的功能越来越多,所以将引入两个新模块来帮助管理功能:search-viewquotes-statesearch-view模块负责监控用户输入的文本字段。当这个字段改变时,视图通知quotes-state模块已经进行了搜索,并向其传递搜索词。quotes-state模块充当所有视图的单一状态源,当它接收到一个新的搜索词时,它触发一个视图可能订阅的事件。

挖掘一些遗留的源代码产生了文件public/scripts/util/jquery.highlight.js,这是一个非 AMD 的 jQuery 插件,突出显示了 DOM 中的文本。当quotes-view模块从quotes-state模块接收到搜索事件时,它使用这个插件根据存储在quotes-state中的搜索词高亮显示 DOM 中的文本。要使用这个遗留脚本,需要在main.js配置中添加一个路径和一个填充条目。highlight插件不导出任何值,但是它需要先加载 jQuery,否则插件在试图访问全局 jQuery 对象时会抛出一个错误。

依赖关系已经被添加到具有deps属性的highlight垫片中,如清单 5-22 所示。该属性包含一个模块名(或别名)数组,该数组应该在 shim 之前加载——在本例中是 jQuery。

Listing 5-22. The highlight Shim Depends on jQuery

// example-005/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

jquery: 'vendor/jquery/jquery-2.1.3.min',

'jquery-all': 'util/jquery-all',

undrln: 'vendor/undrln/undrln',

ventage: 'vendor/ventage/ventage',

highlight: 'util/jquery.highlight'

},

shim: {

undrln: {

exports: '_'

},

highlight: {

deps: ['jquery']

}

}

});

一旦highlight插件被填充,它可能作为另一个模块的依赖项被加载。既然jquery-all模块负责加载定制插件,那么在清单 5-23 中让highlight模块成为它的依赖项之一似乎是明智的。

填充脚本应该只有两种依赖关系:

  • 其他填充脚本立即执行,并可能在全局范围内创建一个或多个可重用的变量或名称空间
  • 作为副作用,AMD 模块还在全局范围内创建了可重用的变量或名称空间(如window.jQuery)

因为 AMD 模块通常根本不干涉全局范围,所以将它们用作填充脚本的依赖项实际上是无用的,因为填充脚本没有办法访问 AMD 模块的 API。如果一个 AMD 模块没有给全局范围增加任何东西,那么它对屏蔽脚本是没有用的。此外,AMD 模块是异步加载的,它们的闭包以特定的顺序执行(在下一节讨论),而填充的脚本将在加载后立即运行。(Rembmer:填充脚本是普通的脚本,一旦被引入 DOM 就运行。生成的 shim 模块简单地将非 AMD 脚本创建的全局导出作为依赖项传递给其他 AMD 模块。)即使经填补的脚本可以访问 AMD 模块的 API,也不能保证该模块在经填补的脚本实际运行时是可用的。

Listing 5-23. Loading the highlight Module As a Dependency of Another Module

// example-005/public/scripts/util/jquery-all.js

define(['jquery', 'highlight'], function ($) {

$.fn.addQuotes = function (attribution, quotes) {

// ...

};

return $;

});

在这种安排下,可能会立即想到两个问题:

Since both the highlight and jquery-all modules declare jquery as a dependency, when is jQuery actually loaded?   Why isn’t a second highlight parameter specified in the jquery-all module closure function?

首先,RequireJS 在评估模块间的依赖关系时,会基于模块层次结构创建一个内部依赖树。通过这样做,它可以确定加载任何特定模块的最佳时间,从叶子开始并向主干移动。在这种情况下,“主干”是jquery-all模块,最远的叶子是highlight依赖的jquery模块。RequireJS 将按照以下顺序执行模块关闭:jqueryhighlightjquery-all。因为jquery也是jquery-all的依赖项,RequireJS 将简单地交付为highlight模块创建的同一个jquery实例。

第二,highlight模块不返回值,只是用于副作用——为 jQuery 对象添加插件。没有参数传递给jquery-all模块,因为highlight返回 none。出于这个原因,仅用于副作用的依赖项应该总是放在模块的依赖项列表的末尾。

加载程序插件

有几个非常有用的 RequireJS loader 插件,它们在大多数项目中都有一席之地。加载器插件是一个外部脚本,用于方便地加载,有时解析特定种类的资源,这些资源可以作为标准 AMD 依赖项导入,即使资源本身可能不是实际的 AMD 模块。

text.js

RequireJS text插件可以通过 HTTP 加载一个纯文本资源,将其序列化为一个字符串,并将其作为一个依赖项交付给 AMD 模块。这通常用于加载 HTML 模板,甚至是来自 HTTP 端点的原始 JSON 数据。要安装插件,必须从项目存储库中复制text.js脚本,并且按照惯例,将它放在与main.js配置文件相同的目录中。(可选的安装方法在插件项目的自述文件中列出。)

示例应用中的quotes-view模块使用 jQuery 插件构建引用列表,一次一个 DOM 元素。这不是很有效,很容易被模板解决方案取代。AMD 兼容的 Handlebars 模板库是这类任务的流行选择。在清单 5-24 中,库被添加到了example-006vendor目录中,并且在main.js配置中创建了一个方便的模块别名。

Listing 5-24. Handlebars Module Alias

// example-006/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

//...

Handlebars: 'vendor/handlebars/handlebars-v3.0.3'

},

//...

});

quotes-view模块呈现自己时,它使用对象散列中的报价数据,其中键是属性(即,每个报价的收款人),值是每个报价的数组。(给定的属性可以与一个或多个报价相关联。)清单 5-25 显示了将被绑定到这个数据结构的模板,位于public/scripts/templates/quotes.hbs文件中。

Listing 5-25. The quotes-view Handlebars Template

{{#each this as |quotes attribution|}}

<section class="multiquote">

<h2 class="attribution">{{attribution}}</h2>

{{#each quotes}}

<blockquote class="quote">

{{#explode text delim="\n"}}

<p>{{this}}</p>

{{/explode}}

</blockquote>

{{/each}}

</section>

{{/each}}

不需要完全熟悉 Handlebars 语法就能理解这个模板遍历数据对象,提取每个属性及其相关的引号。它为属性创建一个<h2>元素,然后为每个报价构建一个<blockquote>元素来保存报价文本。一个特殊的块助手,#explode,在新行(\n)分隔符处将引用文本分开,然后将引用文本的每一段包装在一个<p>标签中。

#explode辅助对象很重要,因为它不是手柄的原生属性。它在文件public/scripts/util/handlebars-all.js中被定义并注册为把手辅助对象,如清单 5-26 所示。

Listing 5-26. #explode Handlebars Helper

// example-006/public/scripts/util/handlebars-all.js

define(['Handlebars'], function (Handlebars) {

Handlebars.registerHelper('explode', function (context, options) {

var delimiter = options.hash.delim || '';

var parts = context.split(delimiter);

var processed = '';

while (parts.length) {

processed += options.fn(parts.shift().trim());

}

return processed;

});

return Handlebars;

});

因为这个模块添加了助手,然后返回 Handlebars 对象,quotes-view模块将把它作为依赖项导入,而不是普通的 Handlebars 模块,就像用jquery-all模块代替jquery一样。清单 5-27 中的配置中添加了适当的模块别名。

Listing 5-27. handlebars-all Module Alias

// example-006/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

paths: {

//...

Handlebars: 'vendor/handlebars/handlebars-v3.0.3',

'handlebars-all': 'util/handlebars-all'

},

//...

});

在清单 5-28 中,quotes-view模块已经被修改为导入handlebars-allquotes.hbs模板。文本模板的模块名非常具体:它必须以前缀text!开头,后跟模板文件的路径,该路径相对于main.js中定义的baseUrl路径。

Listing 5-28. The quotes.hbs Template Imported As a Module Dependency

// example-006/public/scripts/quotes-view.js

define([

'jquery-all',

'quotes-state',

'handlebars-all',

'text!templates/quote.hbs'

],

function ($, quotesState, Handlebars, quotesTemplate) {

var bindTemplate = Handlebars.compile(quotesTemplate);

var view = {

// ...

render: function () {

view.$el.empty();

var groupedQuotes = quotesState.quotes;

view.$el.html(bindTemplate(groupedQuotes));

},

// ...

};

// ...

});

当 RequireJS 遇到带有text!前缀的依赖项名称时,它会自动尝试加载text.js插件脚本,然后该脚本会将指定的文件内容作为字符串加载并序列化。quotes-view闭包中的quotesTemplate函数参数将包含quotes.hbs文件的序列化内容,然后由 Handlebars 编译并用于在 DOM 中呈现模块。

页面加载

当一个网页完全加载时,它触发一个DOMContentLoaded事件(在现代浏览器中)。在浏览器完成 DOM 构建之前加载的脚本通常会监听该事件,以了解何时开始操作页面元素是安全的。如果脚本恰好在结束标签</body>之前被加载,它们可能会认为大部分 DOM 已经被加载了,并且它们不需要监听这个事件。然而,<body>元素中其他地方的脚本,或者更常见的<head>元素,就没有这样的奢侈了。

尽管在应用示例中,RequireJS 是在结束的</body>标记之前加载的,但是清单 5-29 中的main.js文件(配置省略)仍然将一个函数传递给 jQuery,一旦DOMContentLoaded触发,该函数将被执行。如果将 RequireJS <script>标签移动到文档<head>中,就不会破坏任何东西。

Listing 5-29. Using jQuery to Determine If the DOM Is Fully Loaded

// example-006/public/scripts/main.js

// ...

requirejs(['jquery-all', 'quotes-view', 'search-view'],

function ($, quotesView) {

$(function () {

quotesView.ready();

});

});

domReady插件是一种特殊的“加载器”,它只是暂停模块闭包的调用,直到 DOM 完全准备好。像文本插件一样,domReady.js文件必须可以被在main.js配置中定义的baseUrl路径内的 RequireJS 访问。按照惯例,它通常是main.js的兄弟。

清单 5-30 显示了main.js的修改版本(配置省略),其中jquery依赖项被移除,而domReady!插件被添加到依赖项列表中。后面的感叹号告诉 RequireJS,这个模块作为一个加载器插件,而不是一个标准模块。与text插件不同,domReady实际上什么也不加载,所以感叹号后不需要额外的信息。

Listing 5-30. Using the domReady Plugin to Determine If the DOM Is Fully Loaded

// example-007/public/scripts/main.js

// ...

requirejs(['quotes-view', 'search-view', 'domReady!'],

function (quotesView) {

quotesView.ready();

});

i18n

RequireJS 通过i18n加载器插件支持国际化。(i18n 是一个 numeronym,表示数字“18”代表“国际化”一词中“I”和“n”之间的 18 个字符。)国际化是指编写 web 应用,使其内容适应用户的语言和地区(也称为国家语言支持,或 NLS)的行为。i18n插件主要用于翻译网站控件和“chrome”中的文本:按钮标签、标题、超链接文本、字段集图例等等。为了展示这个插件的功能,示例应用中添加了两个新模板,一个用于页眉中的页面标题,另一个用于带有占位符文本的搜索字段。实际的报价数据不会被翻译,因为它可能来自负责提供适当翻译的应用服务器。不过,在这个应用中,为了简单起见,数据被硬编码在data/quotes模块中,并且总是以英文显示。

清单 5-31 中的search.hbs模板也已经从index.html文件中提取出来,现在接受搜索字段的占位符文本作为唯一的输入。search-view模块已经被修改为在 DOM 中呈现内容时使用这个模板。

Listing 5-31. The search.hbs Template Will Display the Placeholder Translation

<!-- example-008/public/scripts/templates/search.hbs -->

<form>

<fieldset>

<input type="text" name="search" placeholder="{{searchPlaceholder}}" />

</fieldset>

</form>

清单 5-32 显示了将由新的header-view模块呈现的新的header.hbs模板。该模板接受一个输入,即页面标题。

Listing 5-32. The header.hbs Template Will Display the Page Title Translation

<!-- example-008/public/scripts/templates/header.hbs -->

<h1>{{pageTitle}}</h1>

清单 5-33 中的header-view模块不仅演示了如何使用text插件导入模板依赖,还演示了如何使用i18n插件导入语言模块依赖。熟悉的加载器语法看起来几乎是一样的:插件名后面跟一个感叹号和一个相对于配置的模块路径baseUrl,在这里是nls/lang。当加载一个模板时,它的序列化字符串内容被传递给模块的闭包,但是i18n插件加载一个包含翻译文本数据的语言模块,并将该模块的对象传递给闭包。在清单 5-33 中,这个对象可以通过lang参数访问。

Listing 5-33. The header-view Module Depends on the i18n Language Object

// example-008/public/scripts/header-view.js

define([

'quotes-state',

'jquery-all',

'handlebars-all',

'text!templates/header.hbs',

'i18n!nls/lang'

], function (quotesState, $, Handlebars, headerTemplate, lang) {

// ...

});

language 模块是一个常规的 AMD 模块,但是它没有向define()传递依赖项列表和闭包,而是使用了一个简单的对象文字。这个对象文字遵循一个非常特殊的语法,如清单 5-34 所示。

Listing 5-34. Default English Language Module

// example-008/public/scripts/nls/lang.js

define({

root: {

pageTitle: 'Ponderings',

searchPlaceholder: 'search'

},

de: true

});

首先,root属性保存了当插件解析语言翻译时将用于获取翻译数据的键/值对。该对象中的键只是简单的键,通过这些键可以以编程方式访问翻译的文本。例如,在search模板中,当模板绑定到语言对象的关键字searchPlaceholder时,{{searchPlaceholder}}将被替换为字符串值。

其次,root属性的兄弟是各种 IETF 语言标签,用于活动和非活动的翻译,它们应该基于浏览器的语言设置来解析。在这个例子中,德语de语言标签被赋值为true。如果有西班牙语翻译,可以添加一个值为truees-es属性。对于法语翻译,可以添加一个fr-fr属性,对于其他语言也是如此。

当在默认语言模块中启用新的语言标签时,必须将对应于语言代码的目录作为模块文件的兄弟。目录可以在清单 5-35 中看到。

Listing 5-35. Directory Structure for NLS Modules

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

★★★★★★★★★★★★★★

创建特定于语言的目录后,必须在其中创建与默认语言模块文件同名的语言模块文件。这个新的语言模块将只包含默认语言模块中的root属性的翻译内容。清单 5-36 显示了pageTitlesearchPlaceholder属性的德语(de)翻译。

Listing 5-36. German (de) Translation Module

// example-008/public/scripts/nls/de/lang.js

define({

pageTitle: 'Grübeleien',

searchPlaceholder: 'suche'

});

当默认语言模块用i18n插件加载时,它会检查浏览器的window.navigator.language属性,以确定应该使用什么语言环境和语言翻译。如果默认语言模块指定了一个兼容的、已启用的语言环境,i18n插件会加载特定于语言环境的模块,然后将其与默认语言模块的root对象合并。特定于区域设置的模块中缺少的翻译将用默认语言模块中的值填充。

图 5-3 显示了谷歌 Chrome 浏览器的语言设置为德语时报价页面的外观。

A978-1-4842-0662-1_5_Fig3_HTML.jpg

图 5-3。

Switching the browser language loads the German translation Note

window.navigator.language属性受不同浏览器中不同设置的影响。例如,在 Google Chrome 中,它只反映用户的语言设置,而在 Mozilla Firefox 中,它也会受到页面 HTTP 响应中的Accept-Language标题的影响。

缓存破坏

应用服务器通常缓存脚本文件、图像、样式表等资源,以消除在为自上次读取以来没有更改的资源提供服务时不必要的磁盘访问。缓存的资源通常存储在内存中,并与某个键相关联,通常是资源的 URL。当在指定的缓存期间内出现对给定 URL 的多个请求时,将使用键(URL)从内存中提取资源。这在生产环境中具有显著的性能优势,但是在开发或测试环境中,每次进行代码更改或引入新资源时使缓存失效会变得很繁琐。

当然,缓存可以在每个环境的基础上切换,但是一个更简单的解决方案,至少对于 JavaScript(或任何由 RequireJS 加载的资源),可能是利用 RequireJS 缓存破坏特性。缓存破坏是对每个资源请求的 URL 进行变异的行为,其方式是资源仍然可以被获取,但永远不会在缓存中找到,因为它的“键”总是不同的。这通常是通过包含一个查询字符串参数来实现的,该参数会在页面重新加载时发生变化。

清单 5-37 中的配置脚本添加了一个urlArgs属性。这将把查询字符串参数bust={timestamp}追加到 RequireJS 生成的所有请求中。每次页面加载时都会重新计算时间戳,以确保参数值发生变化,从而使 URL 变得唯一。

Listing 5-37. The urlArgs Configuration Property Can Be Used to Bust Cache

// example-009/public/scripts/main.js

requirejs.config({

baseUrl: '/scripts',

urlArgs: 'bust=' + (new Date().getTime()),

paths: {

// ...

},

shim: {

// ...

}

});

图 5-4 显示bust参数确实应用于 RequireJS 发起的每一个请求,甚至是像header.hbs这样的 XHR 对文本资源的请求。

A978-1-4842-0662-1_5_Fig4_HTML.jpg

图 5-4。

The bust parameter is appended to each RequireJS request

虽然这个特性的有用性是显而易见的,但是它也会产生一些问题。

首先,RequireJS 尊重 HTTP 缓存头,因此即使将urlArgs用作缓存破坏机制,RequireJS 仍然可以请求(并接收)资源的缓存版本,这取决于缓存是如何实现的。如果可能,在每个环境中始终提供适当的缓存头。

其次,要注意一些代理服务器会丢弃查询字符串参数。如果开发或登台环境包括模拟生产环境的代理,则破坏缓存的查询字符串参数可能无效。一些开发人员使用urlArgs来指定生产环境中的特定资源版本(例如version=v2),但是由于这个原因,通常不鼓励这样做。这是一种不可靠的版本控制技术。

最后,一些浏览器将具有不同 URL 的资源视为不同的、可调试的实体。例如,在 Chrome 和 Firefox 中,如果在源代码中为http://localhost:8080/scripts/quotes-state.js?bust=1432504595280设置了一个调试断点,当新的资源 URL 变为http://localhost:8080/scripts/quotes-state.js?bust=1432504694566时,如果页面被刷新,它将被删除。重置断点可能会变得繁琐,尽管debugger关键字可以用来通过强制浏览器暂停执行来规避这个问题,但它仍然需要勤奋的开发人员来确保在代码投入生产之前删除所有的debugger断点。

需要优化器

RequireJS 优化器 r.js 是一个用于 RequireJS 项目的构建工具。它可以用来将所有 RequireJS 模块连接成一个文件,缩小源代码,将构建输出复制到一个不同的目录,等等。本节介绍该工具及其基本配置。接下来将介绍几种常见场景的具体示例。

使用 r.js 最常见的方式是为 Node.js 安装 RequireJS npm 包,作为全局包或本地项目包。本节中的示例将使用在安装所有 npm 模块时创建的本地 RequireJS 安装。

配置 r.js

大量的参数可以作为参数传递给 r.js 工具来控制它的行为。幸运的是,这些参数也可以在常规的 JavaScript 配置文件中传递给 r.js,这使得终端命令明显更短。对于重要的项目,这是首选的配置方法,也是本章中唯一涉及的方法。

目录example-010中的代码文件已经被移动到一个标准的src目录中,一个新文件rjs-config.js已经被放置在根目录中。不出所料,这个文件包含 r.js 配置。其内容如清单 5-38 所示。

Listing 5-38. r.js Configuration

// example-010/rjs-config.js

({

// build input directory for application code

appDir: './src',

// build output directory for application code

dir: './build',

// path relative to build input directory where scripts live

baseUrl: 'public/scripts',

// predefined configuration file used to resolve dependencies

mainConfigFile: './src/public/scripts/main.js',

// include all text! references as inline modules

inlineText: true,

// do not copy files that were combined in build output

removeCombined: true,

// specific modules to be built

modules: [

{

name: 'main'

}

],

// uglify the output

optimize: 'uglify'

})

熟悉构建工具的开发人员会立即识别出配置中存在的输入/输出模式。

属性指定了相对于配置文件的项目“输入”目录,未编译的源代码就在这个目录中。

属性指定了相对于配置文件的项目“输出”目录,当 r.js 工具运行时,编译和缩小的输出将被写入该目录。

baseUrl属性告诉 r.js 项目脚本相对于 appDir 属性的位置。这不应该与main.js文件中的baseUrl属性混淆,后者告诉 RequireJS 模块相对于 web 应用根的位置。

mainConfigFile属性指向实际的 RequireJS(不是 r.js)配置。这有助于 r.js 理解模块是如何相互关联的,以及存在什么样的模块别名和垫片(如果有的话)。可以省略这个属性,在 r.js 配置中指定所有这些路径,尽管这超出了本例的范围。

inlineText属性设置为true可以确保所有引用了文本插件前缀text!的文本文件都将在最终的构建输出中用 RequireJS 模块进行编译。默认情况下启用该选项,但为了清楚起见,在该项目中明确设置了该选项。

默认情况下,r.js 会将所有脚本(打包和解包的)缩小并复制到输出目录中。removeCombined属性切换这种行为。在这种情况下,只有打包、编译的脚本以及打包输出中无法包含的任何其他脚本才会被复制到输出目录中。

modules数组列出了所有要编译的顶级模块。因为这是一个单页面应用,所以只需要编译实际的main模块。

最后,optimize属性指示 r.js 对所有脚本应用丑陋转换,从而最小化所有 JavaScript 代码。

运行 r.js 命令

构建项目只是在终端中运行r.js命令,通过它的-o标志将配置文件的路径传递给它,如清单 5-39 所示。

Listing 5-39. Running the r.js Command

example-010$ ../node_modules/.bin/r.js -o rjs-config.js

终端输出显示了 r.js 在构建过程中编译和复制了哪些文件。检查清单 5-40 中的构建输出文件显示了 r.js 到底优化和复制了什么。

Listing 5-40. Build Directory Content

example-010/build$ tree

.

■??]

■??]

ε──??″

■??]

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

──μ──??∮

──μ──??∮

──★??∮

★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

ε──??″

ε──??″

9 directories, 24 files

public/scripts目录中,有几样东西立即凸显出来。

首先,require.jsmain.js脚本都存在。由于这些脚本是在index.html中引用的唯一文件,它们的出现是意料之中的。其他脚本,如quotes-view.jsquotes-state.js脚本明显不存在,但检查main.js的内容会发现原因:它们已经根据 r.js 构建设置进行了打包和缩小。

第二,本地化文件nls/lang.js现在丢失了,因为它已经作为main.js的一部分被包含进来。nls/de/lang.js脚本仍然是构建输出的一部分,尽管它的内容已经被缩减了。任何在默认语言环境下浏览示例 web 页面的用户都将获得优化的体验,因为 RequireJS 不必进行外部 AJAX 调用来加载默认语言翻译。来自德国的用户将产生额外的 HTTP 请求,因为打包的输出中没有包括德语本地化文件。这是本地化插件的一个限制,r.js 必须尊重。

第三,把手模板,尽管在main.js中被编译为构建输出的一部分,也被复制到了public/scripts/templates目录中。发生这种情况是因为 RequireJS 插件目前无法看到构建过程,因此无法在 r.js 配置文件中使用removeCombined选项。幸运的是,因为这些模板已经被包装在 AMD 模块中,并与main.js连接在一起,所以 RequireJS 不会试图用 AJAX 请求加载它们。如果部署规模是这个项目的一个问题,如果需要,可以创建一个后期构建脚本或任务来删除templates目录。

第四,vendor / ventage目录已经被复制到build目录,尽管它的核心模块ventage.js已经与main.js连接在一起。虽然 RequireJS 可以在编译后自动删除单个模块文件(如ventage.js),但它不会清理与模块相关联的其他文件(在本例中,是单元测试和包定义文件,如package.jsonbower.json),因此它们必须手动删除,或者作为后期构建过程的一部分。

摘要

RequireJS 是一个非常实用的 JavaScript 模块加载器,在浏览器环境中运行良好。它异步加载和解析模块的能力意味着它不仅仅依靠捆绑或打包脚本来获得性能优势。不过,为了进一步优化,可以使用 r.js 优化工具将 RequireJS 模块合并到一个精简的脚本中,以最大限度地减少加载模块和其他资源所需的 HTTP 请求数量。

尽管 RequireJS 模块必须以 AMD 格式定义,但 RequireJS 可以填充非 AMD 脚本,以便在必要时 AMD 模块可以导入遗留代码。填补的模块还可能具有可由 RequireJS 自动加载的依赖项。

text插件允许模块将外部文本文件依赖项(如模板)作为字符串导入。这些文本文件像任何其他模块依赖项一样被加载,甚至可能被 r.js 优化器内联到构建输出中。

本地化由i18n模块加载器支持,它可以根据浏览器的区域设置动态加载文本翻译模块。虽然主要的语言环境翻译模块可以被优化并与 r.js 连接,但是额外的语言环境翻译模块总是会加载 HTTP 请求。

模块的执行可以被pageLoad插件推迟,这可以防止模块的闭包在 DOM 完全呈现之前执行。这可以有效地消除对 jQuery 的ready()函数的重复调用,或者手动搜索订阅DOMContentLoaded事件所需的跨浏览器代码。

最后,RequireJS 配置可以自动将查询字符串参数附加到所有 RequireJS HTTP 请求中,为开发环境提供了一个廉价但有效的缓存破坏特性。

六、Browserify

少即是多。—路德维希·密斯·凡·德罗

Browserify 是一个 JavaScript 模块加载器,它通过充当代码的“预处理程序”来解决该语言当前缺乏对浏览器中导入模块的支持的问题。与 CSS 扩展(如 SASS 和 LESS)为样式表带来了增强的语法支持一样,Browserify 通过递归扫描源代码来增强客户端 JavaScript 应用对全局require()函数的调用。当 Browserify 找到这样的调用时,它会立即加载引用的模块(使用 Node.js 中可用的相同的require()函数)并将它们组合成一个单一的、缩小的文件——一个“包”——然后可以在浏览器中加载。

这种简单而优雅的方法为浏览器带来了 CommonJS(在 Node.js 中加载模块的方法)的强大和便利,同时也消除了异步模块定义(AMD)加载器(如 RequireJS)所需的额外复杂性和样板代码(在第五章中描述)。

在本章中,您将学习如何

  • 区分 AMD 和 CommonJS 模块加载器
  • 创建模块化前端 JavaScript 应用,这些应用遵循由 Node.js 等工具普及的简单模块管理模式
  • 可视化项目的依赖关系树
  • 使用 Browserify 的姊妹应用 Watchify,在发生变化时尽快编译您的应用
  • 使用第三方浏览器插件(“转换”)来扩展工具的核心功能

Note

本章的部分内容讨论了本书前几章已经涉及的概念,包括 Bower(第一章)和咕噜声(第二章)。如果您不熟悉这些工具,建议您在继续之前先了解这些材料。

AMD API 与 CommonJS

异步模块定义 API,在第五章的中有所介绍,是 JavaScript 当前缺乏对内联加载外部模块支持的一个巧妙的解决方案。通常被称为“浏览器优先”的方法,AMD API 通过要求开发人员将他们的每个模块包装在回调函数中,然后可以根据需要异步加载(即“延迟加载”)来实现其将模块带到浏览器的目标。清单 6-1 中所示的模块演示了这一过程。

Listing 6-1. Defining and Requiring an AMD Module

// requirejs-example/public/app/weather.js

define([], function() {

return {

'getForecast': function() {

document.getElementById('forecast').innerHTML = 'Partly cloudy.';

}

};

});

// requirejs-example/public/app/index.js

define(['weather'], function(weather) {

weather.getForecast();

});

AMD API 既聪明又有效,但是许多开发人员也发现它有点笨拙和冗长。理想情况下,JavaScript 应用应该能够引用外部模块,而不会像 AMD API 那样增加复杂性和样板代码。幸运的是,有一种流行的替代方法 CommonJS 可以解决这个问题。

虽然大多数人倾向于将 JavaScript 与 web 浏览器联系在一起,但事实是,JavaScript 已经在许多其他环境中广泛使用了一段时间——远在 Node.js 出现之前。这种环境的例子包括 Rhino,一个由 Mozilla 创建的服务器端运行时环境,以及 ActionScript,一个由 Adobe 曾经流行的 Flash 平台使用的衍生品,近年来已经失宠。这些平台都通过创建自己的方法来解决 JavaScript 缺乏内置模块支持的问题。

意识到这个问题需要一个标准的解决方案,一组开发人员聚集在一起,提出了 CommonJS,一种定义和使用 JavaScript 模块的标准化方法。Node.js 遵循类似的方法,JavaScript 的下一个重大更新也是如此(ECMAScript 6,也就是 ES6 Harmony)。这种方法也可以用来编写模块化的 JavaScript 应用,这些程序可以在今天使用的所有 web 浏览器中运行,尽管还需要一些其他工具的帮助,比如本章的主题 Browserify。

安装浏览器

在进一步操作之前,您应该确保已经安装了 Browserify 的命令行工具。作为一个 npm 包,安装过程如清单 6-2 所示。

Listing 6-2. Installing the browserify Command-Line Utility via npm

$ npm install -g browserify

$ browserify --version

10.2.4

Note

Node 的软件包管理器(npm)允许用户在两种环境中安装软件包:本地或全局。在本例中,browserify安装在全局上下文中,该上下文通常是为命令行工具保留的。

创建您的第一个包

Browserify 的吸引力在于它的简单性;熟悉 CommonJS 和 Node 的 JavaScript 开发人员会发现自己很快就能如鱼得水。举例来说,考虑清单 6-3 ,它显示了我们在清单 6-1 中看到的简单的基于 RequireJS 的应用的基于 CommonJS 的等价物。

Listing 6-3. Front-End Application That Requires Modules via CommonJS

// simple/public/app/index.js

var weather = require('./weather');

weather.getForecast();

// simple/public/app/weather.js

module.exports = {

'getForecast': function() {

document.getElementById('forecast').innerHTML = 'Partly cloudy.';

}

};

与我们基于 RequireJS 的例子不同,这个应用不能直接在浏览器中运行,因为浏览器缺少通过require()加载模块的内置机制。在浏览器能够理解这个应用之前,我们必须首先借助于browserify命令行工具或者通过 Browserify 的 API 将它编译成一个包。

使用 Browserify 的命令行工具编译该应用的命令如下:

$ browserify app/index.js -o public/dist/app.js

在这里,我们将应用主文件 public/ app/index.js的路径传递给browserify工具,并指定编译后的输出应该保存到public/dist/app.js,这个脚本在项目的 HTML 中引用(参见清单 6-4 )。

Listing 6-4. HTML File Referencing Our Compiled Browserify Bundle

// simple/public/index.html

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Browserify - Simple Example</title>

</head>

<body>

<div id="forecast"></div>

<script src="/dist/app.js"></script>

</body>

</html>

除了使用 Browserify 的命令行工具,我们还可以选择通过 Browserify 的 API 以编程方式编译这个应用。这样做将允许我们轻松地将这个步骤合并到一个更大的构建过程中(使用 Grunt 等工具开发)。清单 6-5 显示了这个项目的browserify繁重任务。

Listing 6-5. Grunt Task That Compiles the Application via Browserify’s API

// simple/tasks/browserify.js

module.exports = function(grunt) {

grunt.registerTask('browserify', function() {

var done = this.async();

var path = require('path');

var fs = require('fs');

var src = path.join('public', 'app', 'index.js');

var target = path.join('public', 'dist', 'app.js');

var browserify = require('browserify')([src]);

browserify.bundle(function(err, data) {

if (err) return grunt.fail.fatal(err);

grunt.file.mkdir(path.join('public', 'dist'));

fs.writeFileSync(target, data);

done();

});

});

};

可视化依赖关系树

如果你碰巧更喜欢视觉学习,图 6-1 所示的图表可能会对传达 Browserify 编译过程中发生的事情大有帮助。这里我们看到 Browserify 在编译本章的advanced项目时遇到的各种依赖关系的可视化。

A978-1-4842-0662-1_6_Fig1_HTML.jpg

图 6-1。

Visualizing the advanced project’s dependency tree

将此图表视为页面上的静态呈现并不公平。为了获得完整的效果,您应该编译项目,并通过在项目的文件夹中运行npm start在浏览器中查看图表。这样做将允许您将鼠标悬停在图表的各个部分上,每个部分都代表 Browserify 在编译过程中遇到的一个依赖项。虽然在图 6-1 中并不明显,但对图表的深入分析表明,我们应用的定制代码只占 Browserify 生成的包总大小的很小一部分(9.7kB)。这个项目将近 2MB 的代码中的绝大部分由第三方依赖项组成(例如 Angular、jQuery、Lodash 等。),这是一个重要的事实,将在本章后面再次引用。

Note

您可能还对研究browserify-graphcolony命令行工具(也可以通过 npm 获得)感兴趣,您可以使用它们来生成项目依赖树的额外可视化。

发生变化时创建新的包

利用 Browserify 的项目不能直接在浏览器中运行,它们必须首先被编译。为了最有效地使用该工具,重要的是项目的设置方式要能够在源代码发生变化时自动触发这一步骤。让我们来看看实现这一点的两种方法。

用 Grunt 监视文件变化

在 Grunt 的第二章中,您发现了像grunt-contrib-watch这样的插件是如何让开发者在应用源代码发生变化时触发构建步骤的。很容易理解如何将这些工具应用到使用 Browserify 的项目中,并在检测到变更时触发新包的创建。通过运行本章的simple项目的默认 Grunt 任务,可以看到这个过程的一个例子,如清单 6-6 所示。

Listing 6-6. Triggering the Creation of New Browserify Builds with Grunt

$ grunt

Running "browserify" task

Running "concurrent:serve" (concurrent) task

Running "watch" task

Waiting...

Running "server" task

App is now available at: http://localhost:7000

>> File "app/index.js" changed.

Running "browserify" task

Done, without errors.

Completed in 0.615s at Fri Jun 26 2015 08:31:25 GMT-0500 (CDT) - Waiting...

在这个例子中,运行默认的 Grunt 任务触发了三个步骤:

  • 立即创建了 Browserify 包。
  • 启动了一个 web 服务器来托管该项目。
  • 执行一个监视脚本,当检测到源代码更改时,该脚本触发新 Browserify 包的创建。

这种简单的方法通常可以很好地服务于大多数小型项目;然而,随着小项目逐渐演变成大项目,开发人员通常会对随之而来的不断增长的构建时间感到沮丧,这是可以理解的。在你尝试每一个更新之前必须等待几秒钟,这可能会很快破坏你可能希望达到的任何“流畅”感。幸运的是,Browserify 的姐妹应用 Watchify 可以在这些情况下帮助我们。

使用 Watchify 监视文件更改

如果说 Browserify(完整编译应用)可以被认为是切肉刀,那么 Watchify 可以被认为是削皮刀。当被调用时,Watchify 最初完整地编译指定的应用;然而,这个过程完成后,Watchify 并没有退出,而是继续运行,观察项目源代码的变化。当检测到更改时,Watchify 只重新编译那些已更改的文件,从而大大加快构建时间。Watchify 通过在每次构建中维护自己的内部缓存机制来实现这一点。

与 Browserify 一样,Watchify 可以通过命令行或提供的 API 调用。在清单 6-7 中,本章的simple项目是在 Watchify 命令行工具的帮助下编译的。在本例中,传递了参数-v来指定 Watchify 应该以详细模式运行。因此,Watchify 会在检测到更改时通知我们。

Listing 6-7. Installing Watchify via npm and Running It Against This Chapter’s simple Project

$ npm install -g watchify

$ watchify public/app/index.js -o public/dist/app.js -v

778 bytes written to public/dist/app.js (0.03 seconds)

786 bytes written to public/dist/app.js (0.01 seconds)

与 Browserify 一样,Watchify 提供了一个方便的 API,允许我们将其集成到一个更大的构建过程中(参见清单 6-8 )。我们只需对先前在清单 6-7 中显示的 Browserify 任务做一些小小的调整就可以做到。

Listing 6-8. Grunt Task Demonstrating the Use of Watchify’s API

// simple/tasks/watchify.js

module.exports = function(grunt) {

grunt.registerTask('watchify', function() {

var done = this.async();

var browserify = require('browserify');

var watchify = require('watchify');

var fs = require('fs');

var path = require('path');

var src = path.join('public', 'app', 'index.js');

var target = path.join('public', 'dist', 'app.js');

var targetDir = path.join('public', 'dist');

var browserify = browserify({

'cache': {},

'packageCache': {}

});

browserify = watchify(browserify);

browserify.add(src);

var compile = function(err, data) {

if (err) return grunt.log.error(err);

if (!data) return grunt.log.error('No data');

grunt.file.mkdir(targetDir);

fs.writeFileSync(target, data);

};

browserify.bundle(compile);

browserify.on('update', function() {

browserify.bundle(compile);

});

browserify.on('log', function(msg) {

grunt.log.oklns(msg);

});

});

};

在这个例子中,我们用watchify包装我们的browserify实例。之后,我们根据需要通过订阅由我们包装的实例发出的update事件来重新编译项目。

使用多个包

在前面的“可视化依赖关系树”一节中,我们看了一个交互式图表,它允许我们可视化 Browserify 在编译本章的advanced项目时遇到的各种依赖关系(见图 6-1 )。我们可以从这个图表中得到的最重要的事实之一是,项目的定制代码(在/app中找到)只占捆绑包总大小 1.8MB 的很小一部分(9.7kB),换句话说,这个项目的绝大部分代码由第三方库(例如 Angular、jQuery、Lodash 等)组成。)不太可能经常改变。让我们来看看如何利用这些知识。

本章的extracted项目在各方面都与advanced项目相同,除了一个例外:extracted项目的构建过程创建了两个独立的包,而不是编译一个单独的 Browserify 包:

  • /dist/vendor.js:第三方依赖关系
  • /dist/app.js:自定义应用代码

通过采用这种方法,浏览器可以更有效地访问发布的项目更新。换句话说,当项目的定制代码发生变化时,浏览器只需要重新下载/dist/app.js。将这种方法与advanced项目的方法进行对比,在该项目中,每次更新(无论多小)都迫使客户重新下载该项目的近 2MB 包。

清单 6-9 显示了extracted项目的 HTML 文件。如您所见,这里我们引用了两个独立的包,/dist/vendor.js/dist/app.js

Listing 6-9. HTML for This Chapter’s extracted Project

// extracted/public/index.html

<!DOCTYPE html>

<html ng-app="app">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Browserify - Advanced Example</title>

<link rel="stylesheet" href="/css/style.css">

</head>

<body class="container">

<navbar ng-if="user_id"></navbar>

<div ng-view></div>

<footer><a href="/disc.html">View this project’s dependency tree</a></footer>

<script src="/dist/vendor.js"></script>

<script src="/dist/app.js"></script>

</body>

</html>

清单 6-10 显示了extracted项目的 Gruntfile。注意正在设置的特殊配置值(browserify.vendor_modules)。

Listing 6-10. Gruntfile for This Chapter’s extracted Project

// extracted/Gruntfile.js

module.exports = function(grunt) {

grunt.initConfig({

'browserify': {

'vendor_modules': [

'angular',

'bootstrap-sass',

'jquery',

'angular-route',

'angular-sanitize',

'restangular',

'jquery.cookie',

'lodash',

'underscore.string',

'lodash-deep'

]

}

});

grunt.loadTasks('tasks');

grunt.registerTask('default', ['compass', 'browserify', 'browserify-vendor', 'init-db', 'concurrent']);

};

清单 6-11 显示了extracted项目的browserify任务的内容。这个任务很大程度上模仿了advanced项目中的相应任务,只有一个主要的例外。在这个任务中,我们遍历在项目的 Gruntfile 中定义的第三方模块,对于每个条目,我们指示 Browserify 从编译的包中排除引用的模块。

Listing 6-11. The extracted Project’s browserify Grunt Task

// extracted/tasks/browserify.js

module.exports = function(grunt) {

grunt.registerTask('browserify', function() {

var done = this.async();

var path = require('path');

var fs = require('fs');

var target = path.join('public', 'dist', 'app.js');

var vendorModules = grunt.config.get('browserify.vendor_modules') || [];

var browserify = require('browserify')([

path.join('app', 'index.js')

], {

'paths': ['app'],

'fullPaths': true,

'bundleExternal': true

});

vendorModules.forEach(function(vm) {

grunt.log.writelns('Excluding module from application bundle: %s', vm);

browserify.exclude(vm);

});

browserify.bundle(function(err, data) {

if (err) return grunt.fail.fatal(err);

grunt.file.mkdir(path.join('public', 'dist'));

fs.writeFileSync(target, data);

grunt.task.run('disc');

done();

});

});

};

最后,清单 6-12 显示了extracted项目的browserify-vendor繁重任务的内容。当运行时,这个任务将创建一个单独的 Browserify 包,只包含我们在清单 6-10 中定义的第三方模块。

Listing 6-12. The extracted Project’s browserify-vendor Grunt Task

// extracted/tasks/browserify-vendor.js

module.exports = function(grunt) {

grunt.registerTask('browserify-vendor', function() {

var done = this.async();

var path = require('path');

var fs = require('fs');

var target = path.join('public', 'dist', 'vendor.js');

var vendorModules = grunt.config.get('browserify.vendor_modules') || [];

var browserify = require('browserify')({

'paths': [

'app'

],

'fullPaths': true

});

vendorModules.forEach(function(vm) {

browserify.require(vm);

});

browserify.bundle(function(err, data) {

if (err) return grunt.fail.fatal(err);

grunt.file.mkdir(path.join('public', 'dist'));

fs.writeFileSync(target, data);

done();

});

});

};

要查看这个过程的运行情况,在您的终端中导航到extracted项目并运行$ npm start。将安装任何缺少的 npm 模块,并运行项目的默认 Grunt 任务。随着这一过程的进行,将会创建两个独立的包。包含项目定制代码/dist/app.js的包只有 14kB 大小。

节点方式

正如本章介绍中提到的,Browserify 通过递归扫描源代码来编译项目,以搜索对全局require()函数的调用。当找到这些调用时,Browserify 通过 Node 使用的同一个require()函数加载它们引用的模块。之后,Browserify 将它们合并成一个浏览器能够理解的包。

在这方面,使用 Browserify 的项目最好被认为是客户端节点应用。当这个概念——以及它所包含的一切——被牢记在心时,浏览器功能的许多方面往往会让新来者感到困惑,从而变得更容易理解。现在让我们来看两个这样的方面:模块解析和依赖关系管理。

模块解析和 NODE_PATH 环境变量

节点应用能够以多种方式引用模块。例如,这里我们看到一个简单的节点应用,它需要一个模块,方法是提供其位置的相对路径:

var animals = require('./lib/animals');

以类似的方式,这个示例也可以提供这个模块的完整的绝对路径。无论哪种方式,期望节点找到该模块的位置都是相当明显的。现在考虑下面的例子,其中模块仅由名称引用:

var animals = require('animals');

在这种情况下,Node 将首先尝试在其核心库中定位被引用的模块。这个过程可以在加载模块时看到,比如节点的文件系统模块fs。如果没有找到匹配,Node 将继续搜索名为node_modules的文件夹,从调用require()的模块的位置开始,沿着文件系统向上搜索。当遇到这些文件夹时,Node 将检查它们是否包含与所请求的相匹配的模块(或包)。这个过程将一直持续到找到匹配为止,如果没有找到匹配,就会抛出异常。

这种简单而强大的方法几乎完全围绕着node_modules文件夹,通过这种方法可以在 Node 中进行模块解析。然而,Node 提供了一个经常被忽略的方法,该方法允许开发人员通过定义额外的文件夹来增强这种行为,如果前面的步骤一无所获,则应该允许 Node 在这些文件夹中搜索模块。让我们看看本章的path-env项目,它演示了如何实现这一点。

清单 6-13 显示了该项目的package.json文件的摘录。特别重要的是已经定义的start脚本。基于此处显示的设置,当$ npm start在这个项目中运行时,在应用运行之前,NODE_PATH环境变量将被更新,以包含对这个项目的/lib文件夹的引用。因此,Node 会将此文件夹添加到它用来解析命名模块位置的文件夹中。

Listing 6-13. This Project’s npm start Script Updates the NODE_PATH Environment Variable

// path-env/package.json

{

"name": "path-env",

"version": "1.0.0",

"main": "./bin/index.js",

"scripts": {

"start": "export NODE_PATH=$NODE_PATH:./lib && node ./bin/index.js"

}

}

Note

在 OS X 和 Linux 上,通过运行export ENVIRONMENT_VARIABLE=value从终端设置环境变量。在 Windows 命令行中使用的命令是set ENVIRONMENT_VARIABLE=value

设置NODE_PATH环境变量的意义乍一看可能并不明显;然而,这样做可以对复杂项目的整洁性和可维护性产生显著的积极影响。为什么呢?因为当使用这种方法时,它基本上允许开发人员创建一个名称空间,通过该名称空间,应用的模块(那些不是作为独立 npm 包存在的模块)可以按名称引用,而不是按冗长的相对路径引用。清单 6-14 展示了一个简单的例子,展示了它在实践中的样子。

Listing 6-14. Several of the Modules Contained Within the path-env Project

// path-env/bin/index.js

var api = require('app/api');

// path-env/lib/app/api/index.js

var express = require('express');

var path = require('path');

var app = express();

var animals = require('app/models/animal');

app.use('/', express.static(path.join(__dirname, '..', '..', '..', 'public')));

app.get('/animals', function(req, res, next) {

res.send(animals);

});

app.listen(7000, function() {

console.log('App is now available at:``http://localhost:7000

});

module.exports = app;

// path-env/lib/app/models/animal/index.js

module.exports = [

'Aardvarks', 'Cats', 'Dogs', 'Lemurs', 'Three-Toed Sloths', 'Zebras'

];

注意这个例子缺少相对的模块引用。例如,注意这个项目的主脚本bin/index.js如何能够通过require('app/api');加载一个负责初始化 Express 的定制模块。另一种方法是使用相对路径:require('../lib/app/api');。任何在复杂的节点应用中工作过并且遇到过类似于require('../../../../models/animal');的模块引用的人都会很快体会到这种方法带来的代码清晰度的提高。

Note

重要的是要记住,NODE_PATH环境变量的使用只在节点(或浏览器)应用的上下文中有意义——而不是包。当创建旨在与其他人共享的可重用包时,您应该只依赖于 Node 的默认模块解析行为。

利用 Browserify 中的 NODE_PATH

到目前为止,我们已经关注了NODE_PATH环境变量如何对服务器端节点应用产生积极的影响。既然我们已经打下了基础,那么让我们看看如何在用 Browserify 编译的基于浏览器的客户端应用的上下文中应用这个概念。

清单 6-15 显示了本章advanced项目的browserify Grunt 任务,它负责通过 Browserify 的 API 编译应用。特别重要的是使用了paths选项,它允许我们为 Browserify 提供一组路径,这些路径应该在编译开始前附加到NODE_PATH环境变量中。正是这种设置使我们能够轻松利用本节前面的示例中展示的相同优势。

Listing 6-15. The browserify Grunt Task for This Chapter’s advanced Project

// advanced/tasks/browserify.js

module.exports = function(grunt) {

grunt.registerTask('browserify', function() {

var done = this.async();

var path = require('path');

var fs = require('fs');

var target = path.join('public', 'dist', 'app.js');

var browserify = require('browserify')([

path.join('app', 'index.js')

], {

'paths': [

'app'

],

'fullPaths': true

});

browserify.bundle(function(err, data) {

if (err) return grunt.fail.fatal(err);

grunt.file.mkdir(path.join('public', 'dist'));

fs.writeFileSync(target, data);

grunt.task.run('disc');

done();

});

});

};

为了简单地展示这种方法如何对这个项目产生积极的影响,请考虑清单 6-16 。这里我们看到一个小模块,它负责加载lodash和集成两个第三方工具underscore.stringlodash-deep。最终导出的值是包含所有三个模块的组合功能的单个对象。

Listing 6-16. Module Responsible for Loading Lodash and Integrating Various Third-Party Plugins

// advanced/app/utils/index.js

var _ = require('lodash');

_.mixin(require('underscore.string'));

_.mixin(require('lodash-deep'));

module.exports = _;

作为提供给 Browserify 的paths值的结果,我们的应用现在可以通过简单地调用require('app/utils');从任何位置引用这个模块。

依赖性管理

直到最近,“依赖管理”的概念(在很大程度上)在基于浏览器的客户端项目环境中还是一个陌生的概念。然而,这种趋势已经迅速转变,这在很大程度上要归功于 Node 的迅速普及,以及在它之上构建的其他工具——本书已经介绍了其中的一些(例如,Bower、Grunt 和 Yeoman)。这些工具有助于将急需的工具和指导带到曾经是(现在仍然是)客户端开发的蛮荒的“西部”。

关于依赖性管理,Bower 通过为客户端开发人员提供一种易于使用的机制来管理应用所依赖的各种第三方库,从而帮助解决了这一需求。对于不熟悉这个概念并且没有使用 Browserify 等客户端编译器的开发人员来说,Bower 一直是并且将继续是管理项目依赖项的一个可行选项;然而,随着开发人员开始看到 Browserify 等工具提供的优势,Bower 已经开始显示出年龄的迹象。

在本节的开始,我们提到使用 Browserify 的项目最好被认为是客户端节点应用。关于依赖性管理,这种说法尤其重要。回想一下,在 Browserify 的编译过程中,项目的源代码被扫描以寻找对全局require()函数的调用。找到后,这些调用在 Node 中执行,随后返回值可供客户端应用使用。这里的重要含义是,当使用 Browserify 时,当开发人员只依赖 npm(节点的包管理器)时,依赖性管理会大大简化。虽然从技术上来说,是的,可以指导 Browserify 如何加载由 Bower 安装的包,但通常情况下,这只是比它的价值更麻烦。

定义浏览器特定的模块

考虑一个场景,您想要创建一个新模块,并打算通过 npm 发布和共享它。您希望这个模块在节点和浏览器中都可以工作(通过 Browserify)。为此,Browserify 支持在项目的package.json文件中使用browser配置设置。定义后,该设置允许开发人员覆盖用于定位特定模块的位置。为了更好地理解这是如何工作的,让我们看两个简单的例子。

清单 6-17 显示了一个简单包的内容。在这个包中,有两个模块,lib/node.jslib/browser.js。根据这个包的package.json文件,这个包的main模块是lib/node.js。换句话说,当这个包在一个节点应用中被名字引用时,这就是节点将要加载的模块。但是,请注意,已经定义了一个额外的配置设置:"browser": "./lib/browser.js"。该设置的结果是,Browserify 将加载该模块,而不是由main指定的模块。

Listing 6-17. Module Exposing Two Distinct Entry Points: One for Node, the Other for Browserify

// browser1/package. json

{

"name": "browser1",

"version": "1.0.0",

"main": "./lib/node.js",

"browser": "./lib/browser.js"

}

// browser1/lib/browser.js

module.exports = {

'run': function() {

console.log('I am running within a browser.');

}

};

// browser1/ lib/node.js

module.exports = {

'run': function() {

console.log('I am running within Node.');

}

};

正如您马上会看到的,Browserify 的browser配置设置不需要局限于简单地覆盖一个包的main模块的位置。它还可以用来覆盖一个包中多个模块的位置。举例来说,考虑列出 6-18 。在这个例子中,我们没有为我们的package.json文件的browser设置提供一个字符串,而是提供了一个对象,允许我们指定多个特定于浏览器的覆盖。

Listing 6-18. Module Exposing Multiple, Distinct Modules for Node and Browserify

// browser2/package.json

{

"name": "browser2",

"version": "1.0.0",

"main": "./lib/node.js",

"browser": {

"./lib/node.js": "./lib/browser.js",

"./lib/extra.js": "./lib/extra-browser.js"

}

}

如清单 6-17 所示,实现该模式的模块将向自身公开不同的入口点:一个用于节点,另一个用于通过 Browserify 编译的应用。然而,这个例子将这个概念向前推进了一步。当这个模块被编译时,如果它试图加载位于lib/extra.js的模块,位于lib/extra-browser的模块将被替换。通过这种方式,browser设置允许我们创建具有不同行为的模块,这取决于这些模块是在节点中运行还是在浏览器中运行。

使用转换扩展浏览器功能

开发人员可以在 Browserify 的核心功能基础上创建插件,称为转换,在创建新的包时利用编译过程。此类转换通过 npm 安装,一旦它们的名称包含在应用的package.json文件中的browserify.transform数组中,它们就被启用。让我们看几个有用的例子。

短消息

brfs转换简化了内联加载文件内容的过程。它扩展了 Browserify 的编译过程来搜索对fs. readFileSync()方法的调用。找到后,引用文件的内容会立即加载并返回。

清单 6-19 显示了本章transforms-brfs项目的package.json文件的摘录。在本例中,brfs模块已经安装并包含在browserify.transform配置设置中。

Listing 6-19. Excerpt from the package.json File for This Chapter’s transforms-brfs Project

// transforms-brfs/package.json

{

"name": "transforms-brfs",

"dependencies": {

"browserify": "¹⁰.2.4",

"brfs": "¹.4.0"

},

"browserify": {

"transform": [

"brfs"

]

}

}

清单 6-20 显示了该项目的/app/index.js模块的内容。在这个例子中,brfs转换将加载/app/templates/lorem.html的内容,该内容随后被分配给tpl变量。

Listing 6-20. Loading a Template via fs.readFileSync()

// transforms-brfs/app/index.js

var fs = require('fs');

var $ = require('jquery');

var tpl = fs.readFileSync(__dirname + '/templates/lorem.html', 'utf8');

$('#container').html(tpl);

文件夹化

brfs转换非常相似,folderify转换允许您以内联方式加载文件的内容。然而,folderify允许你快速加载多个文件的内容,而不是一次只操作一个文件。举例来说,考虑清单 6-21 ,它显示了本章的transforms-folderify应用的内容。

Listing 6-21. Loading the Contents of Multiple Files with folderify

// transforms-folderify/app/index.js

var $ = require('jquery');

var includeFolder = require('include-folder');

var folder = includeFolder(__dirname + '/templates');

for (var k in folder) {

$('#container').append('<p>' + k + ': ' + folder[k] + '</p>');

}

和前面的例子一样,这个项目的package.json文件已经被修改,在它的browserify.transform数组中包含了folderify。编译时,Browserify 将搜索对include-folder模块的引用。当调用它返回的函数时,Browserify 将加载它在指定文件夹中找到的每个文件的内容,并以对象的形式返回它们。

使膨胀

使用bulkify转换,开发人员可以通过一次调用导入多个模块。为了更好地理解这是如何工作的,请看清单 6-22 ,它显示了本章transforms-bulkify项目的主应用文件内容的摘录。

Listing 6-22. Main Application File for This Chapter’s transforms-bulkify Project

// transforms-bulkify/app/index.js

var bulk = require('bulk-require');

var app = angular.module('app', [

'ngRoute'

]);

var routes = bulk(__dirname, [

'routes/**/route.js'

]).routes;

app.config(function($routeProvider) {

var defaultRoute = 'dashboard';

_.each(routes, function(route, route_name) {

route = route.route;

route.config.resolve = route.config.resolve || {};

$routeProvider.when(route.route, route.config);

});

$routeProvider.otherwise({

'redirectTo': defaultRoute

});

});

这个特殊的例子演示了 Browserify 在 Angular 应用的上下文中的使用。如果你不熟悉 Angular(在第八章的中有所涉及),不要担心——这个例子的重要方面是bulk()方法允许我们require()多个模块匹配一个或多个指定的模式(在这个例子中是routes/**/route.js)。

图 6-2 显示了该项目的文件结构。如您所见,app/routes模块包含三个文件夹,每个文件夹代表我们的 Angular 应用中的一条路线。bulkify转换允许我们通过对bulk()的一次调用来快速require()每个模块。之后,我们能够迭代生成的对象,并将每条路线传递给 Angular。

A978-1-4842-0662-1_6_Fig2_HTML.jpg

图 6-2。

File structure for this chapter’s transforms-bulkify project

浏览器填充垫片

使用 Browserify 的开发人员偶尔会发现他们需要导入不符合一般做事方式的模块。考虑一个第三方的Foo库,一旦被加载,它就把自己分配给全局window.Foo变量(参见清单 6-23 )。这样的库可以在browserify-shim转换的帮助下导入。

Listing 6-23. Third-Party Foo Library That Assigns Itself to the Global Foo Variable

// transforms-shim/app/vendor/foo.js

function Foo() {

console.log('Bar');

}

在通过 npm 本地安装了browserify- shim模块之后,通过将它的名称添加到项目的package.json文件中的已启用转换列表中来启用它,如清单 6-19 所示。接下来,在应用的package.json文件的根级别创建一个browserify-shim对象,它将作为这个转换的配置对象(参见清单 6-24 )。在这个例子中,这个对象中的每个键都代表一个不正确暴露的模块的路径,而相应的值指定了该模块为自己分配的全局变量。

Listing 6-24. Configuring browserify-shim Within a Project’s package.json File

// transforms-shim/package.json

{

"name": "transforms-shim",

"version": "1.0.0",

"main": "server.js",

"browserify": {

"transform": [

"browserify-shim"

]

},

"browserify-shim": {

"./app/vendor/foo.js": "Foo"

}

}

随着browserify-shim转换的安装和配置,位于app/vendor/foo.js的模块现在可以通过require()正确导入。

摘要

Browserify 是一个强大的工具,它扩展了在节点内创建模块并将其导入浏览器的直观过程。在它的帮助下,基于浏览器的 JavaScript 应用可以被组织成一系列小的、易于理解的、紧密集中的模块,这些模块一起工作形成一个更大、更复杂的整体。更重要的是,目前没有模块管理系统的应用可以立即使用 Browserify。将一个完整的应用重构为更小的组件的过程不是一蹴而就的,最好一步一步来。在 Browserify 的帮助下,只要时间和资源允许,您就可以做到这一点。

相关资源

七、Knockout

复杂系统的特征是简单的元素,按照本地规则作用于本地知识,产生复杂的、模式化的行为。—大卫·韦斯特

Knockout 是一个 JavaScript 库,负责将 HTML 标记绑定到 JavaScript 对象。它不是一个完整的框架。它没有状态路由器、HTTP AJAX 功能、内部消息总线或模块加载器。相反,它侧重于 JavaScript 对象和 DOM 之间的双向数据绑定。当 JavaScript 应用中的数据发生变化时,绑定到挖空视图的 HTML 元素会收到自动更新。同样,当 DOM 输入发生时——例如通过表单域操作——Knockout 捕获输入变化并相应地更新应用状态。

Knockout 使用称为 observables 的专用对象和自定义绑定语法来表达应用数据与标记的关系,而不是低级的命令式 HTML 元素操作。内部机制是完全可定制的,因此开发人员可以使用定制的绑定语法和行为来扩展 Knockout 的功能。

作为一个独立的 JavaScript 库,Knockout 没有依赖关系。要实现 Knockout 不能执行的应用功能,通常需要其他库的存在,因此它可以很好地与许多其他常用库一起使用,如 jQuery、下划线、Q 等。与严格的 DOM 操作相比,Knockout API 在更高的层次上表示数据绑定操作,因此从抽象的角度来看,Knockout 更接近主干或角度,但其纤细的、面向视图的特性集意味着它的占用空间要小得多。

Knockout 将在所有现代浏览器中完全发挥作用,并且在撰写本文时,还将扩展到 Firefox 3+、Internet Explorer 6+和 Safari 6+。鉴于它的最新特性,它的向后兼容性尤其令人印象深刻,即带有自定义标记标签的 HTML5 兼容组件。淘汰赛团队煞费苦心地让淘汰赛开发体验在各种浏览器环境中无缝衔接。

本章通过一个管理厨房食谱的示例应用来探索 Knockout 的特性和 API。所有章节代码示例都将带有注释前缀,以表明示例代码实际驻留在哪个文件中。例如,在清单 7-1 中,index.js文件可以在本书源代码分发的knockout/example-000目录中找到。

Listing 7-1. Not a Real Example

// example-000/index.js

console.log('this is not a real example');

要运行示例,首先安装 Node.js(参考您系统的 Node.js 文档),然后运行knockout目录中的npm install来安装所有示例代码依赖项。每个示例目录将包含一个运行简单 Node.js web 服务器的index.js文件。要运行每个示例,需要启动该服务器,然后在 web 浏览器中导航到指定的 URL。例如,要运行清单 7-1 中的index.js文件,在终端提示符下导航到knockout/example-000目录并运行node index.js

所有示例页面都在一个<script>标签引用中包含核心剔除脚本。您可以从 http://knockoutjs.com 或一些著名的内容交付网络下载该脚本。Knockout 也可以作为 Bower 包或 npm 模块安装,并且与 AMD 和 CommonJS 兼容。敲除文档包含所有这些安装方法的详细说明。

视图、模型和视图模型

Knockout 区分应用用户界面中的两种信息来源:数据模型,表示应用的状态;视图模型,表示该状态如何显示或传达给用户。这两种模型都是作为 JavaScript 对象在应用中创建的。Knockout 为视图模型提供了一种以视图(HTML)友好方式表示数据模型的方法,同时在视图和数据模型之间建立了双向通信,以便输入影响应用状态,而应用状态影响视图表示数据的方式。

因为 HTML 是在 web 浏览器中表示数据的技术,所以挖空视图模型可以直接绑定到预先存在的 HTML 文档元素,也可以使用 HTML 模板创建新元素。Knockout 甚至可以创建完整的可重用 HTML 组件(带有自己的属性和行为的自定义 HTML 标签)。

本章包含的示例应用 Omnom Recipes 在可浏览的主/详细用户界面中显示配方数据(“数据模型”)。该界面的两个部分——配方列表和每个配方的详细信息——都是逻辑组件,非常适合挖空视图模型。每一个都有自己的视图模型,应用将协调它们之间的交互。最终,用户会想要添加或编辑食谱,因此将为此引入额外的 HTML 标记和视图模型。

清单 7-2 显示了作为tree命令输出的example-001目录中的示例应用结构。

Listing 7-2. Example Application Structure

example-001$ tree --dirsfirst

.

■??]

◆θ★★★★★★★★★★★★★★★★★★★★★★

──μ──??∮

──ζ──??∮

──★★★★★★★★★★★★★★★★★★★★★★★★★

──μ──??∮

──μ──??∮

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

──★??∮

◆θ★★★★★★★★★★★★★★★★★★★★★★

■??]

ε──??″

index.js文件负责启动一个 web 服务器,该服务器将服务于对public目录中文件的请求。当应用的 web 页面向 AJAX 请求食谱数据时,web 服务器将在recipes.json中序列化数据并将其返回给客户端。

public目录中,当用户访问http://localhost:8080时,默认情况下会提供index.html文件。该文件包含用挖空属性增强的应用标记。index.html文件还引用了public/styles中的app.css样式表、public/scripts/vendor中的两个供应商脚本和public/scripts中的三个应用脚本。

挖空视图模型可以应用于整个页面,也可以应用于页面上的特定元素。对于重要的应用,建议使用多视图模型来保持模块化。在 Omnom Recipes 应用中,用户界面作为两个逻辑“组件”存在:配方列表和所选配方的详细视图。应用没有对整个页面使用单一的视图模型,而是将剔除逻辑分成两个 JavaScript 模块,分别位于public/scripts : recipe-list.jsrecipe-details.jsapp.js模块使用这两种视图模型,并协调它们在页面上的活动。

图 7-1 显示了应用的屏幕截图,配方列表清晰可见,配方详情在左侧。

A978-1-4842-0662-1_7_Fig1_HTML.jpg

图 7-1。

Omnom Recipes screenshot Note

为了避免混淆,示例应用使用简单的 JavaScript 闭包,而不是客户端框架或面向模块的构建工具来组织模块。这些闭包通常将单个对象分配给全局window对象的一个属性,该属性将被其他脚本使用。例如,recipe-list.js文件创建了一个全局对象window.RecipeList,用于app.js文件。虽然完全有效,但是应该根据示例应用的简单需求来看待这个架构决策。

食谱清单

包含整页标记和挖空模板的index.html文件分为三个关键的顶级元素:

  • <header>元素,它包含静态 HTML 内容,不会被 Knockout 操作
  • <nav id="recipe-list">元素,包含一个无序的食谱列表,将由 Knockout 操作
  • <section id="recipe-details">元素,显示配方信息,也将被剔除操作

虽然配方列表元素很小,但它包含了许多不同的特定于淘汰的绑定。这部分 HTML 的视图模型将被绑定到<nav>元素。记住这一点,通过检查清单 7-3 中的标记,可以推断出许多关于淘汰绑定如何工作的事情。

Listing 7-3. Recipe List Markup and Bindings

<!-- example-001/public/index.html -->

<nav id="recipe-list">

<ul data-bind="foreach: recipes">

<li data-bind="text: title,

click: $parent.selectRecipe.bind($parent),

css: {selected: $parent.isSelected($data)}"></li>

</ul>

</nav>

首先,很明显,淘汰绑定应用于带有data-bind属性的 HTML 元素。这不是唯一的装订方法,但却是最常见的。元素<ul><li>都有形式为binding-name: binding-value的绑定。

第二,多个绑定可以作为逗号分隔的列表应用于一个元素,如<li>元素所示,它有对textclickcss的绑定。

第三,具有更复杂值的绑定,比如<li>元素上的css绑定,使用键/值散列({key: value, ... })来定义特定的绑定选项。

最后,绑定值可以引用 JavaScript 原语、视图模型属性、视图模型方法或任何有效的 JavaScript 表达式。

配方列表剔除绑定揭示了将被绑定到<nav>元素的剔除视图模型的某些事情。开发人员将立即识别出foreach流控制语句,并正确推断出recipes将是由视图模型公开的某个集合,foreach将在其上循环。

无序列表中的<li>元素没有自己的 HTML 内容,因此也可以推断出该元素是一种模板元素,将为recipes集合中的每一项进行绑定和呈现。与大多数foreach循环一样,期望循环中的对象(循环的“上下文”)是集合的一个元素是合理的。列表项的text绑定引用了当前迭代的 recipe 对象的title属性,并将在呈现时作为<li>元素的文本内容注入。

clickcss绑定都引用了特殊的$parent对象,这告诉 Knockout 绑定值应该针对与foreach绑定的视图模型,而不是当前的配方对象。(视图模型是“父”上下文,配方是它的“子”上下文。)

每当列表项的click事件被触发时,click绑定就调用视图模型上的selectRecipe()方法。它通过将$parent引用传递给方法的bind()函数,将方法绑定到视图模型。这确保了selectRecipe()方法中this的值不会引用处理程序执行时附加的 DOM 元素(DOM 的默认行为)。

相比之下,$parent(视图模型)对象上的isSelected()方法由css绑定调用,但是 Knockout 而不是 DOM 管理调用,确保方法中的this值引用视图模型而不是 DOM 元素。

css绑定指示 Knockout 在满足特定条件时将特定的 CSS 类应用于 DOM 元素。css绑定值是选择器/函数对的散列,每当呈现 DOM 元素时,Knockout 都会对其进行评估。如果isSelected()方法返回true,那么selected CSS 类将被添加到列表项元素中。另一个特殊变量$data被传递给isSelected()$data变量总是指当前对象上下文,在此例中是一个单独的配方对象。一些敲除绑定,如text,默认在当前对象上下文上操作;其他的,像foreach,作为副作用会引起上下文切换。

在清单 7-4 中,每个特殊变量的上下文对象和值都显示在 HTML 注释中。为清楚起见,绑定已被缩写。

Listing 7-4. Changing Contexts with Knockout Bindings

<!-- example-001/public/index.html -->

<nav id="recipe-list">

<!-- context: viewmodel -->

<!-- $parent === undefined -->

<!-- $data === viewmodel -->

<ul data-bind="foreach: ...">

<! -- context: recipe -->

<!-- $parent === viewmodel -->

<!-- $data === recipe -->

<li data-bind="text: ..."></li>

</ul>

</nav>

清单 7-5 中的配方列表模块创建视图模型对象,当页面被渲染时,挖空将绑定到配方列表标记。该模块的create()方法接受一系列配方对象——从服务器加载的 JSON 数据——并返回一个带有数据属性和方法的视图模型对象。几乎所有的挖空视图模型都需要访问全局window.ko对象上的辅助函数,所以它被作为参数传递给模块的闭包函数。

Listing 7-5. Recipe List View Model

// example-001/public/scripts/recipe-list.js

'use strict';

window.RecipeList = (function (ko) {

return {

create: function (recipes) {

var viewmodel = {};

// properties

viewmodel.recipes = recipes;

viewmodel.selectedRecipe = ko.observable(recipes[0]);

// methods

viewmodel.selectRecipe = function (recipe) {

this.selectedRecipe(recipe);

};

viewmodel.isSelected = function (recipe) {

return this.selectedRecipe() === recipe;

};

return viewmodel;

}

};

}(window.ko));

Note

视图模型对象本身可以以开发者选择的任何方式来创建。在示例代码中,每个视图模型都是由工厂方法创建的简单对象文字。经常可以看到 JavaScript 构造函数模式被用来创建视图模型,但是视图模型仅仅是对象,可以按照开发人员认为合适的方式来构造。

除了selectedRecipe属性,食谱列表视图模型完全不起眼。模板的foreach绑定被应用到recipes属性(一个普通 JavaScript 对象的数组),每个列表项上的click绑定调用selectRecipe()方法(传递给它一个特定的配方),当每个列表项被呈现时,调用isSelected()方法来确定被评估的配方是否已经被分配给selectedRecipe属性。事实上,这并不完全正确。selectedRecipe的值实际上不是一个配方对象,而是一个函数——一个引人注目的可观察对象。

可观察值是一种特殊类型的函数,它保存一个值,并且可以在该值发生变化时通知潜在的订阅者。HTML 元素和 observables 之间的绑定会自动创建由 Knockout 在后台管理的订阅。观察值是用全局ko对象上的特殊工厂函数创建的。清单 7-5 中的selectedRecipe是在调用ko.observable(recipes[0])时创建的。它的初始值是recipes数组中的第一个元素。当不带参数调用selectedRecipe()时,它返回它包含的值(在本例中,是recipes[0]中的对象)。传递给selectedRecipe()的任何值都将成为它的新值。虽然selectedRecipe()属性没有绑定到配方列表模板中的任何元素,但是当用户通过视图模型的方法与配方列表交互时,它会被操纵。这个元素的变化值将被用作下一个页面组件的输入:recipe details。

食谱详情

当点击配方列表中的配方时,配方详情显示在右窗格中(参见图 7-1 )。清单 7-6 中的标记显示了用于在 DOM 中呈现菜谱细节视图模型的 HTML 元素和剔除绑定。

Listing 7-6. Recipe Details Markup and Bindings

<!-- example-001/public/index.html -->

<section id="recipe-details">

<h1 data-bind="text: title"></h1>

<h2>Details</h2>

<p>Servings: <span data-bind="text: servings"></span></p>

<p>Approximate Cook Time: <span data-bind="text: cookingTime"></span></p>

<h2>Ingredients</h2>

<ul data-bind="foreach: ingredients">

<li data-bind="text: $data"></li>

</ul>

<h2>Instructions</h2>

<ol data-bind="foreach: instructions">

<li data-bind="text: $data"></li>

</ol>

<a data-bind="visible: hasCitation,

attr: {href: citation, title: title}"

target="_blank">Source</a>

</section>

有些绑定,如<h1> text绑定,从视图模型属性中读取一个值,并将其字符串值注入 HTML 元素。

因为“细节”标题下的段落具有静态内容(文本“服务:”和“大约烹饪时间:”等),所以在每个段落的末尾使用<span>标签来锚定servingscookingTimes属性的挖空绑定。

配料列表使用foreach绑定遍历字符串集合,因此每个循环中的上下文对象是由$data变量表示的字符串。每个字符串都成为列表项的文本内容。

底部的<a>标签作为引用链接到食谱的来源网站。如果配方没有引用,锚将不会显示。元素的visible绑定检查视图模型的hasCitation可观察值,如果值为空,隐藏锚元素。像菜谱列表中使用的css绑定一样,attr绑定将一个键/值散列作为它的绑定值。散列键(hreftitle)是要在锚点上设置的元素属性,值是视图模型上将被绑定到每个属性的属性。

配方细节视图模型比配方列表视图模型有更多的成员。清单 7-7 显示了配方细节视图模型是以类似的方式创建的,通过调用带有特定配方对象的RecipeDetails.create()函数,该配方对象将用于向视图模型添加数据。这个模块在全局ko对象上使用了几个函数,因此,像食谱列表一样,它作为参数传递给模块闭包。

Listing 7-7. Recipe Details View Model

// example-001/public/scripts/recipe-details.js

'use strict';

window.RecipeDetails = (function (ko) {

return {

create: function (recipe) {

var viewmodel = {};

// add properties and methods...

return viewmodel;

}

};

}(window.ko));

对于 recipe 对象上的每个属性,recipe details 视图模型都有相应的可观察属性,如清单 7-8 所示。只有当它们包含的值预计会改变时,可观测量才真正有用。如果值应该是静态的,那么可以使用普通的 JavaScript 属性和值。在 recipe details 视图模型中使用 Observables,因为只有一个视图模型实例绑定到页面。当在配方列表中选择一个新配方时,配方细节视图模型将用新配方的值进行更新。因为它的属性是可观察的,所以页面的标记会立即改变。

Listing 7-8. Recipe Details View Model Properties

// example-001/public/scripts/recipe-details.js

// properties

viewmodel.title = ko.observable(recipe.title);

viewmodel.servings = ko.observable(recipe.servings);

viewmodel.hours = ko.observable(recipe.cookingTime.hours);

viewmodel.minutes = ko.observable(recipe.cookingTime.minutes);

viewmodel.ingredients = ko.observableArray(recipe.ingredients);

viewmodel.instructions = ko.observableArray(recipe.instructions);

viewmodel.citation = ko.observable(recipe.citation);

viewmodel.cookingTime = ko.computed(function () {

return '$1 hours, $2 minutes'

.replace('$1', this.hours())

.replace('$2', this.minutes());

}, viewmodel);

清单 7-8 显示了两种新的可观测量:ko.observableArray()ko.computed()

可观察数组监视它们的值(普通的 JavaScript 数组)的添加、删除和索引变化,因此如果数组发生变化,可观察数组的任何订户都会得到通知。虽然本例中的成分和指令没有改变,但稍后将引入代码来操作集合,并显示可观察数组的自动绑定更新。

计算的可观察值基于视图模型上可观察值公开的其他值生成或计算一个值。ko.computed()函数接受回调,该回调将被调用来生成计算出的可观察值,并且可选地接受一个上下文对象,该对象在回调中充当this的值。当被模板绑定引用时,计算出的可观察值将是其回调返回的值。清单 7-8 中的cookingTime属性创建一个格式化的字符串,其中插入了来自hoursminutes观察值的值。如果hoursminutes发生变化,cookingTime计算出的可观测值也将更新其订户。

Note

因为hoursminutes实际上是函数(尽管它们在剔除绑定表达式中被视为属性),所以必须在计算出的可观察对象的主体中调用每一个函数,以便检索其值。

清单 7-9 中的菜谱细节视图模型方法相当简单。hasCitation()方法测试citation属性的非空值,而update()方法接受配方并用新值更新视图模型上的可观察属性。该方法不绑定到视图,但是当选择配方列表视图模型中的配方时,将使用该方法。

Listing 7-9. Recipe Details View Model Methods

// example-001/public/scripts/recipe-details.js

// methods

viewmodel.hasCitation = function () {

return this.citation() !== '';

};

viewmodel.update = function (recipe) {

this.title(recipe.title);

this.servings(recipe.servings);

this.hours(recipe.cookingTime.hours);

this.minutes(recipe.cookingTime.minutes);

this.ingredients(recipe.ingredients);

this.instructions(recipe.instructions);

this.citation(recipe.citation);

};

将视图模型绑定到 DOM

这两个视图模型工厂都被附加到全局window对象上,并且可以用来创建将被绑定到页面上的单独的视图模型实例。清单 7-10 中所示的app.js文件是将两个配方视图模型联系在一起的主脚本。

Listing 7-10. Binding View Models to the DOM

// example-001/public/scripts/app.js

(function app ($, ko, RecipeList, RecipeDetails) {

// #1

var getRecipes = $.get('/recipes');

// #2

$(function () {

// #3

getRecipes.then(function (recipes) {

// #4

var list = RecipeList.create(recipes);

// #5

var details = RecipeDetails.create(list.selectedRecipe());

// #6

list.selectedRecipe.subscribe(function (recipe) {

details.update(recipe);

});

// #7

ko.applyBindings(list, document.querySelector('#recipe-list'));

ko.applyBindings(details, document.querySelector('#recipe-details'));

}).fail(function () {

alert('No recipes for you!');

});

});

}(window.jQuery, window.ko, window.RecipeList, window.RecipeDetails));

app模块负责从服务器加载一组初始配方数据,等待 DOM 进入就绪状态,然后实例化视图模型实例并将每个实例绑定到适当的元素。下表描述了清单 7-10 中显示的每个步骤注释(例如// #1)。

A jQuery promise is created that will resolve at some point in the future, when the data obtained from the GET /recipes request becomes available.   The function passed to $() will be triggered when the DOM has been completely initialized to ensure that all Knockout template elements will be present before any binding attempts.   When the jQuery promise resolves, it passes the list of recipes to its resolution handler. If the promise fails, an alert is shown to the user indicating that a problem occurred.   Once the recipe data has been loaded, the list view model is created. The recipe array is passed as an argument to RecipeList.create(). The return value is the actual recipe list view model object.   The recipe details view model is created in a similar fashion. Its factory function accepts a single recipe, and so the selectedRecipe property on the recipe list is queried for a value. (The recipe list view model chooses the very first recipe in its data array for this value, by default.)   After the recipe details view model has been created, it subscribes to change notifications on the recipe list’s selectedRecipe observable. This is the manual equivalent of a DOM subscription created by Knockout when an observable is bound to an HTML element. The function provided to the subscribe() method will be invoked whenever selectedRecipe changes, receiving the new value as an argument. When the callback fires the recipe details view model uses any newly selected recipe to update itself, thereby changing the values of its own observable properties.   Finally, view models are bound to the DOM when the global ko.applyBindings() function is invoked. In Listing 7-10 this function receives two arguments: the view model to be bound, and the DOM element to which the view model will be bound. Any binding attribute Knockout encounters on this element or its descendants will be applied to the specified view model. If no DOM element is specified, Knockout assumes that the view model applies to the entire page. For simplistic pages this might be appropriate, but for more complex scenarios, using multiple view models that encapsulate their own data and behavior is the better option.

查看模型和表单

挖空视图模型属性可以绑定到表单控件。许多控件,如<input>元素,共享类似value的标准绑定;但是其他的像<select>有特定于元素的绑定。例如,options绑定控制着<select>标签中<option>元素的创建。一般来说,到目前为止,表单字段绑定的行为很像示例代码中看到的绑定,但是复杂的表单可能很棘手,有时需要更有创意的绑定策略。

本节中的示例建立在 recipe details 模板和视图模型上。具体来说,引入了“编辑”模式,由此查看特定配方的用户可以选择通过表单字段来改变其细节。使用了相同的视图模型,但是在 recipe details 模板中添加了新的表单域元素,增加了两者的复杂性。

切换到“编辑”模式

配方详细信息标记的顶部和底部添加了三个按钮。图 7-2 和 7-3 显示了按钮呈现时的外观。

A978-1-4842-0662-1_7_Fig3_HTML.jpg

图 7-3。

In “edit” mode, the Save and Cancel buttons are visible

A978-1-4842-0662-1_7_Fig2_HTML.jpg

图 7-2。

In “view” mode, the Edit button is visible

编辑按钮将页面从查看模式切换到编辑模式(并为正在查看的配方的每个部分显示适当的表单字段)。在编辑模式下,“编辑”按钮本身是隐藏的,但另外两个按钮,“保存”和“取消”是可见的。如果用户单击保存按钮,对配方所做的任何更改都将被保存;相反,如果用户点击取消按钮,编辑会话将被中止,配方细节将恢复到其原始状态。

清单 7-11 中显示的每个按钮的敲除绑定与目前讨论的绑定略有不同。

Listing 7-11. Editing Button Markup

<!-- example-002/public/index.html -->

<div>

<!-- in read-only view -->

<button data-bind="click: edit, visible: !isEditing()">Edit</button>

<!-- in edit view -->

<button data-bind="click: save, visible: isEditing">Save</button>

<button data-bind="click: cancelEdit, visible: isEditing">Cancel</button>

</div>

首先,每个按钮都有一个 click 事件处理程序,它调用视图模型上的一个方法:edit()save()cancelEdit()。但是与前面的例子不同,这些方法不使用bind()函数来确保视图模型中this的值。相反,视图模型中所有出现的关键字this都被替换为对对象文字viewmodel的引用,如清单 7-12 所示。这些按钮的新属性和方法也被添加到 recipe details 视图模型中。为了简洁起见,清单 7-12 省略了recipe-list.js中没有改变的部分。

Listing 7-12. Methods reference the viewmodel object, not this

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.previousState = null;

viewmodel.isEditing = ko.observable(false);

// methods

viewmodel.edit = function () {

viewmodel .previousState = ko.mapping.toJS(viewmodel);

viewmodel .isEditing(true);

};

viewmodel.save = function () {

// TODO save recipe

viewmodel .isEditing(false);

};

viewmodel.cancelEdit = function () {

viewmodel .isEditing(false);

ko.mapping.fromJS(viewmodel.previousState, {}, viewmodel);

};

因为视图模型本身被赋给了RecipeDetails.create()闭包中的一个变量,所以它的方法可以通过名字来引用它。通过完全避免this,事件绑定被简化,潜在的错误被避免。

其次,每个按钮都有一个附加到视图模型的isEditing observable 的visible绑定,但是只有 Edit 按钮作为函数直接调用该方法。它还拥有唯一一个使用否定(!)运算符的绑定,该运算符将绑定值转换为表达式。表达式中计算的任何可观察值都必须作为函数调用,以检索其值。如果一个可观察对象本身被用作绑定值,就像保存和取消按钮的visible绑定一样,当剔除评估绑定时,它将被自动调用。

所有这三种方法,edit()save()cancelEdit(),都操纵isEditing可观察值的值,该值决定了在表单上显示哪个或哪些按钮(以及,稍后将演示的,显示哪些表单字段)。当调用edit()方法时,编辑开始,当用户保存配方或取消编辑会话时,编辑结束。

为了确保当用户取消编辑会话时对配方的改变被丢弃,当编辑会话开始时,视图模型序列化其状态,以预期可能的回复。如果编辑会话被取消,则先前的状态被反序列化,并且每个可观察属性的值被有效地重置。

挖空映射插件用于在edit()cancelEdit()方法中序列化和反序列化视图模型的状态:

// serializing the view model

viewmodel.previousState = ko.mapping.toJS(viewmodel);

// deserializing the view model

ko.mapping.fromJS(viewmodel.previousState, {}, viewmodel);

Tip

Knockout 的贴图插件与核心的 Knockout 库分开发布。当前版本可从 http://knockoutjs.com/documentation/plugins-mapping.html 下载。要安装插件,只需在 HTML 页面的核心剔除标签<script>之后添加一个<script>标签引用到插件脚本。它将自动在全局ko对象上创建ko.mapping名称空间属性。

映射插件序列化/反序列化拥有可观察属性的对象,在序列化过程中读取它们的值,在反序列化过程中设置它们的值。当edit()方法调用ko.mapping.toJS(viewmodel)时,它接收一个普通的 JavaScript 对象文字,其属性名称与视图模型的属性名称相同,但是包含普通的 JavaScript 数据,而不是可观察的函数。当编辑会话被取消时,为了将这些值推回到视图模型自己的可观察值中,cancelEdit()方法使用三个参数调用ko.mapping.fromJS():

  • 包含要写入视图模型的可观察属性的数据的普通 JavaScript 对象文字
  • 一个对象文字,将普通 JavaScript 状态对象上的属性映射到视图模型上的可观察属性(如果该对象为空,则假定两者的属性共享相同的名称)
  • 将接收对象文字数据的视图模型

Note

Knockout mapper 插件可以通过其toJS()fromJS()函数将视图模型序列化/反序列化为普通的 JavaScript 对象文字,或者通过其toJSON()fromJSON()函数将其序列化/反序列化为 JSON 字符串。这些函数对于将 JSON 数据绑定到简单表单的 CRUD(创建+读取+更新+删除)视图模型特别有用。

虽然表单上有 Save 按钮,但是它的方法只在视图模型中被存根化。它的功能将在后面的示例中添加。

更改配方标题

无论配方详细信息视图处于编辑模式还是只读模式,配方标题均可见。当用户点击编辑按钮时,标签和输入字段在<h1>标签下变得可见,因此用户可以在必要时更新配方标题。包含<div>元素控件上的visible绑定通过订阅视图模型上的isEditing可观察对象来显示和隐藏该字段。输入字段的值通过value绑定绑定到视图模型的title可观察值。默认情况下,value绑定只会在可观察对象绑定的字段失去焦点时刷新可观察对象中的数据。当清单 7-13 中的标题输入失去焦点时,<h1>标签的内容将立即用新的标题值更新,因为两者都绑定到了title可观察对象。渲染后的场景如图 7-4 所示。

A978-1-4842-0662-1_7_Fig4_HTML.jpg

图 7-4。

Editing the recipe title Listing 7-13. Recipe Title Markup

<!-- example-002/public/index.html -->

<h1 data-bind="text: title"></h1>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<label for="recipe-title">Title:</label>

<input data-bind="value: title" name="title" id="recipe-title" type="text" />

</div>

更新食谱和烹饪时间

在清单 7-14 中,当表单进入编辑模式时,食谱的只读食用量<p>元素被隐藏。在它的位置上显示了一个<select>元素,其中有许多可供用户选择的份量选项。再一次,isEditing被用来决定显示哪些元素。

Listing 7-14. Serving Size Markup

<!-- example-002/public/index.html -->

<h2>Details</h2>

<!-- in read-only view -->

<p data-bind="visible: !isEditing()">

Servings: <span data-bind="text: servings"></span>

</p>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<label for="recipe-servings">Servings:</label>

<select data-bind="options: servingSizes,

optionsText: 'text',

optionsValue: 'numeral',

value: servings,

optionsCaption: 'Choose...'"

name="recipeServings"

id="recipe-servings">

</select>

</div>

清单 7-14 中的<select>标签声明了新的特定于元素的剔除绑定,以控制它使用视图模型数据的方式。options绑定告诉 Knockout 视图模型上的哪个属性持有将用于在标签内创建<option>元素的数据集。绑定值是属性的名称(在本例中是servingSizes),一个只读引用数据的简单数组。

对于原始值,比如字符串或数字,options绑定假设每个原始值都应该是其<option>元素的文本和值。对于复杂的对象,optionsTextoptionsValue绑定告诉 Knockout 数组中每个对象的哪些属性将用于生成每个<option>元素的文本和值。清单 7-15 中定义了服务量对象。请注意,文本值是每个数字的名称,而数字值是相应的数字。当用户选择一份食物时,数字值将被分配给viewmodel.servings()

Listing 7-15. Recipe Serving Size Data in the View Model

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.servings = ko.observable(recipe.servings);

viewmodel.servingSizes = [

{text: 'one', numeral: 1},

{text: 'two', numeral: 2},

{text: 'three', numeral: 3},

{text: 'four', numeral: 4},

{text: 'five', numeral: 5},

{text: 'six', numeral: 6},

{text: 'seven', numeral: 7},

{text: 'eight', numeral: 8},

{text: 'nine', numeral: 9},

{text: 'ten', numeral: 10}

];

<select>标签的value绑定将下拉列表的选定值与视图模型上的可观察值联系起来。当<select>标签被渲染时,这个值会在 DOM 中自动为用户选择;当用户选择一个新值时,有界可观测值将被更新。

最后,optionsCaption绑定在 DOM 中创建一个特殊的<option>元素,它出现在下拉选项列表的顶部,但是永远不会被设置为视图模型上的选定值。这仅仅是一个装饰性的增强,给用户一些关于如何使用下拉菜单的指导。

图 7-5 和 7-6 显示了折叠和展开的食用量下拉菜单。

A978-1-4842-0662-1_7_Fig6_HTML.jpg

图 7-6。

Choosing a new value from the Servings drop-down

A978-1-4842-0662-1_7_Fig5_HTML.jpg

图 7-5。

Servings drop-down with a pre-selected value

烹饪时间字段也如图 7-5 所示,不包含特殊绑定。清单 7-16 中显示的两个输入字段(小时和分钟)都是数字字段,它们使用简单的value绑定来更新视图模型上的可观察值。它们通过前面讨论的相同的可见性机制来显示和隐藏。

Listing 7-16. Cooking Time Markup

<!-- example-002/public/index.html -->

<!-- in read-only view -->

<p data-bind="visible: !isEditing()">

Approximate Cook Time: <span data-bind="text: cookingTime"></span>

</p>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<label for="recipe-hours">Approximate Cook Time:</label>

<input data-bind="value: hours"

name="hours"

id="recipe-hours"

type="number" />

<input data-bind="value: minutes"

name="minutes"

id="recipe-minutes"

type="number" />

</div>

回想一下,当烹饪时间以只读模式显示给用户时,使用清单 7-17 中的cookingTime计算可观测值,而不是hoursminutes可观测值。当这些可观测量的值基于清单 7-16 中的输入绑定发生变化时,计算出的可观测量会为视图重新生成格式化字符串。还要注意,计算的可观察对象不再有上下文参数,因为在可观察对象内部,视图模型变量是通过名称引用的,而不是通过关键字this来解析。

Listing 7-17. View Model Hours, Minutes, and Computed Cooking Time

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.hours = ko.observable(recipe.cookingTime.hours);

viewmodel.minutes = ko.observable(recipe.cookingTime.minutes);

viewmodel.cookingTime = ko.computed(function () {

return '$1 hours, $2 minutes'

.replace('$1',``viewmodel

.replace('$2',``viewmodel

});

添加和移除配料

在只读模式下,配方成分呈现为无序列表。为了维护表单,当 recipe details 视图进入编辑模式时,为列表中的每个项目生成一个输入,如图 7-7 所示。每个配料旁边的减号按钮允许用户删除任何或所有配料,而输入列表下方的空输入字段和加号按钮可用于添加新配料。任何成分输入中的文本更改都会更新视图模型的ingredients数组中的值。

A978-1-4842-0662-1_7_Fig7_HTML.jpg

图 7-7。

Creating and editing recipe ingredients

添加新的配料比就地编辑现有的配料更直接。清单 7-18 中的标记显示了对表单配料部分的部分更改。出现了一个只读无序列表,在它下面是一个包含所有新表单字段的<div>元素。一个注释块指出了现有成分的<input>元素将会放在哪里(稍后讨论),但是新的成分字段显示在它的下面。

Listing 7-18. New Ingredients Markup

<!-- example-002/public/index.html -->

<h2>Ingredients</h2>

<!-- in read-only view -->

<ul data-bind="foreach: ingredients, visible: !isEditing()">

<li data-bind="text: $data"></li>

</ul>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<!-- ingredient list inputs here... -->

<input data-bind="value: newIngredient"

type="text"

name="new-ingredient"

id="recipe-new-ingredient"/>

<button data-bind="click: commitNewIngredient"

class="fa fa-plus"></button>

</div>

为了添加新的配料,用户在新的配料<input>字段中输入文本,然后点击它旁边的加号按钮。<input>被绑定到视图模型上可观察的newIngredient,加号按钮的click事件调用commitNewIngredient()方法,如清单 7-19 所示。

Listing 7-19. Creating a New Ingredient in the View Model

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.ingredients = ko.observableArray(recipe.ingredients);

viewmodel.newIngredient = ko.observable('');

// methods

viewmodel.commitNewIngredient = function () {

var ingredient = viewmodel.newIngredient();

if (ingredient === '') return;

viewmodel.ingredients.push(ingredient);

viewmodel.newIngredient('');

};

commitNewIngredient()方法评估newIngredient可观察值的内容,以确定它是否为空。如果是,用户没有在<input>中输入任何文本,因此该方法过早返回。如果不是,则将newIngredient的值推入ingredients可观测值数组,并清除newIngredient可观测值。

Tip

可观察数组与普通 JavaScript 数组共享一个几乎相同的 API。大多数数组操作,比如push()pop()slice()splice()等等,都可以在可观察数组上进行,并且在被调用时会触发更新通知给可观察数组的订阅者。

当新的成分被添加到ingredients后,Knockout 会更新 DOM 以反映这一变化。在编辑模式下隐藏的只读列表会自动获取一个新的列表项元素,现有的<input>元素的可编辑列表,如清单 7-20 所示,也会获得一个新条目。

Listing 7-20. Ingredients Markup

<!-- example-002/public/index.html -->

<h2>Ingredients</h2>

<!-- in read-only view -->

<ul data-bind="foreach: ingredients, visible: !isEditing()">

<li data-bind="text: $data"></li>

</ul>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<ul data-bind="foreach: ingredients" class="listless">

<li>

<input data-bind="value: $data,

valueUpdate: 'input',

attr: {name: 'ingredient-' + $index()},

event: {input: $parent.changeIngredient.bind($parent, $index())}"

type="text" />

<button data-bind="click: $parent.removeIngredient.bind($parent, $index())"

class="fa fa-minus"></button>

</li>

</ul>

<!-- new ingredient input here... -->

</div>

对于ingredients可观察数组中的每个成分,在新的成分字段上方呈现一个输入。这些输入嵌套在一个无序列表中,它们的值都绑定到数组中的特定成分,由foreach循环中的$data变量表示。通过将字符串“ingredient-”与特殊的$index observable 公开的循环的当前索引连接起来,attr绑定用于为每个<input>元素命名。像绑定表达式中使用的任何可观察对象一样,必须调用$index来检索它的值。

由可观察数组公开的绑定只适用于数组本身,而不适用于它们包含的元素,这一点怎么强调都不为过。当每个成分被绑定到一个 DOM <input>元素时,它被包装在$data可观察对象中,但是在这个可观察对象和包含它的可观察数组之间没有通信。如果$data中的值因为输入而改变,数组将会被忽略,并且仍然包含它自己的未改变数据的副本。这是一个令人惊恐的来源,但有几个应对策略可以让它变得可以忍受。

首先,observable ingredients数组可以填充对象,每个对象将成分文本公开为可观察属性(类似于{ ingredient: ko.observable('20 mushrooms') })。每个<input>value绑定将使用每个对象的$data.ingredient属性来建立一个双向绑定。可观察数组仍然不知道其成员的变化,但因为每个元素都是一个通过可观察对象跟踪其自身数据的对象,所以这成为一个争论点。

清单 7-20 中采用的第二种方法是通过valueUpdateevent绑定监听每个<input>元素上的变化事件,然后告诉视图模型在ingredients可观察数组中的特定成分值发生变化时替换它们。两种方式都不“正确”——两者都有各自的优点和缺点。

每次 DOM input事件在每个<input>元素上触发时,valueUpdate绑定首先指示 Knockout 更改$data的值。(记住:一旦一个元素失去焦点,Knockout 通常会更新$data,而不是当它收到输入时。)其次,添加了一个 Knockout event绑定,它在每次 DOM input事件触发时调用视图模型上的changeIngredient()方法。默认情况下,Knockout 将$data的当前值提交给changeIngredient(),但是由于新值将替换旧值,视图模型必须知道ingredients数组中的哪个索引是目标。使用bind(),$index的值作为第一个参数绑定到方法,确保$data的值是第二个。

清单 7-21 中的代码显示了changeIngredient()方法访问ingredients可观察数组中的实际底层数组,以替换给定索引处的值。

Listing 7-21. Changing a Recipe Ingredient in the View Model

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.ingredients = ko.observableArray(recipe.ingredients);

// methods

viewmodel.changeIngredient = function (index, newValue) {

viewmodel.ingredients()[index] = newValue;

};

不幸的是,当可观察数组的底层数组结构发生变化时,可观察数组不会自动通知任何订阅者,这意味着其他 DOM 元素,比如显示成分的只读无序列表,将保持不变。为了减轻这一点,视图模型监听它自己的isEditing可观察值,如清单 7-22 所示。当传递给可观察对象的值是false(意味着用户或者保存了对配方的更改,或者取消了编辑会话)时,视图模型通过调用其valueHasMutated()方法,强制通知ingredients可观察对象数组的任何订阅者。这确保了在“查看”模式下显示的只读无序列表将准确地反映出ingredients数组中任何改变的值。

Listing 7-22. Forcing Observable Arrays to Notify Their Subscribers of Underlying Changes

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.isEditing = ko.observable(false);

viewmodel.isEditing.subscribe(function (isEditing) {

if (isEditing) return;

// force refresh

//

viewmodel.ingredients.valueHasMutated();

});

每个配方<input>旁边是一个减号按钮,用于从ingredients可观察数组中删除给定的配料。它的 click 事件被绑定到removeIngredient()方法,像changeIngredient()一样,该方法也必须接收$index的值,以便视图模型知道要删除哪个元素。可观察数组公开了一个splice()方法,如清单 7-23 所示,该方法可用于移除特定索引处的元素。使用这种方法而不是直接操作底层数组,可以确保ingredients可观察数组的订阅者立即得到变化的通知。

Listing 7-23. Removing a Recipe Ingredient

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.ingredients = ko.observableArray(recipe.ingredients);

// methods

viewmodel.removeIngredient = function (index) {

viewmodel.ingredients.splice(index, 1);

};

说明

配方说明与配方成分非常相似,但有两个显著的不同。首先,指令以有序列表的形式呈现,因为指令必须一步一步地执行。第二,指令可以在列表中升级或降级。图 7-8 显示了订购说明字段和与之相关的按钮的屏幕截图。

A978-1-4842-0662-1_7_Fig8_HTML.jpg

图 7-8。

Creating and editing recipe instructions

将不讨论与配料用例(创建指令、删除指令、更新现有指令)重叠的配方指令用例,因为两者的标记、剔除绑定和视图模型结构本质上是相同的,但操作的是instructions可观察数组。然而,数组内的指令降级和升级是新特性,在清单 7-24 中增加了 up 和 down <button>标记。

Listing 7-24. Instructions Markup

<!-- example-002/public/index.html -->

<h2>Instructions</h2>

<!-- in read-only view -->

<ol data-bind="foreach: instructions, visible: !isEditing()">

<li data-bind="text: $data"></li>

</ol>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<!-- existing instructions -->

<ul data-bind="foreach: instructions" class="listless">

<li>

<input data-bind="value: $data,

valueUpdate: 'input',

attr: {name: 'instruction-' + $index()},

event: {input: $parent.changeInstruction.bind($parent, $index())}"

type="text" />

<button data-bind="click: $parent.demoteInstruction.bind($parent, $index())"

class="fa fa-caret-down"></button>

<button data-bind="click: $parent.promoteInstruction.bind($parent, $index())"

class="fa fa-caret-up"></button>

<button data-bind="click: $parent.removeInstruction.bind($parent, $index())"

class="fa fa-minus"></button>

</li>

</ul>

<!-- new instruction input here... -->

</div>

像减号按钮一样,up 和 down 按钮都使用 Knockout click绑定来调用视图模型上的方法,将相关的项目索引作为参数传递给每个按钮。

清单 7-25 展示了这两种方法是如何操作instructions可观察数组的。promoteInstruction()方法计算索引,如果索引为零,则提前退出(第一条指令不能被提升)。然后,它使用其splice()方法从可观察数组中选取给定索引处的指令,通过减 1 计算该指令的新索引(例如,从索引 2 到 1 将是列表中的提升),然后将该指令拼接回新索引处的可观察数组。demoteInstruction()方法正好相反。它防止在列表“末端”的指令被进一步降级;否则,它会通过重新拼接可观察数组来将指令在列表中下移。在这两种情况下,任何绑定到instructions属性的 DOM 元素都会自动得到变更通知。

Listing 7-25. Promoting and Demoting Recipe Instructions in the View Model

// example-002/public/scripts/recipe-details.js

// properties

viewmodel.instructions = ko.observableArray(recipe.instructions);

viewmodel.promoteInstruction = function (index) {

if (index === 0) return;

var instruction = viewmodel.instructions.splice(index, 1);

var newIndex = index - 1;

viewmodel.instructions.splice(newIndex, 0, instruction);

};

viewmodel.demoteInstruction = function (index) {

var lastIndex = (viewmodel.instructions.length - 1);

if (index === lastIndex) return;

var instruction = viewmodel.instructions.splice(index, 1);

var newIndex = index + 1;

viewmodel.instructions.splice(newIndex, 0, instruction);

};

引用

考虑到指令和成分的复杂性,添加引用字段是一件相当普通的事情。单个文本<input>使用value绑定来更新视图模型的citation可观察值。渲染后的场景如图 7-9 所示。

A978-1-4842-0662-1_7_Fig9_HTML.jpg

图 7-9。

Updating a Recipe’s Citation

引文超链接上的visible绑定已被更改为复合表达式。现在,清单 7-26 中的超链接只有在配方详情视图处于只读模式(!isEditing())且配方实际上有引用时才会显示。

Listing 7-26. Citation Field Markup

<!-- example-002/public/index.html -->

<a data-bind="visible: hasCitation() && !isEditing(),

attr: {href: citation, title: title}"

target="_blank">Source</a>

<div data-bind="visible: isEditing" class="edit-field">

<label>Citation:</label>

<input name="citation" type="text" data-bind="value: citation" />

</div>

定制组件

受流行的 web components . js poly fill(http://webcomponents.org)的启发,Knockout 提供了一个定制的组件系统,该组件系统使用定制的标签名称、标记和行为来生成可重用的 HTML 元素。

在 Omnom Recipes 应用中,recipe details 视图包含两个可编辑的列表,即配料和说明,它们在标记和视图模型属性和方法方面有许多相似的特征。只需一点努力,自定义组件就可以在应用中替换这两个列表。目标是将 DOM 中复杂的标记和绑定表达式简化为新的定制元素,如清单 7-27 中所设想的。

Listing 7-27. Input List Element

<!-- example-003/public/index.html -->

<!-- editable ingredients list -->

<input-list params="items: ingredients,

isOrdered: false"></input-list>

<!-- ... -->

<!-- editable instructions list -->

<input-list params="items: instructions,

isOrdered: true"></input-list>

淘汰组件是几个东西的交集:

  • 为页面上自定义组件的每个实例创建视图模型的工厂函数
  • 一个 HTML 模板,它有自己的挖空绑定,将在使用组件的任何地方注入
  • 一个定制的标签注册,告诉 Knockout 在哪里可以找到模板,以及当它在页面上遇到组件标签时如何实例化它的视图模型

输入列表视图模型

recipe details 视图模型已经拥有了用于操作其ingredientsinstructions数组的属性和方法,但是有必要将这段代码抽象出来,并将其移动到自己的模块input-list.js中,以便 Knockout 可以将它专门用于新的输入列表组件。

清单 7-28 显示了输入列表模块的简化版本。它的结构与其他视图模型工厂模块相同,在全局InputList对象上公开了一个create()方法。这个工厂方法接受一个params参数,该参数将用于向输入列表组件传递一个对可观察数组(params.items)的引用,以及一系列可选设置,这些设置将决定输入列表在绑定到呈现模板时的行为:params.isOrderedparams.enableAddparams.enableUpdateparams.enableRemove

defaultTo()函数是一个简单的实用函数,它返回params对象上缺失属性的默认值。

Listing 7-28. Input List View Model

// example-003/public/scripts/input-list.js

'use strict';

window.InputList = (function (ko) {

function defaultTo(object, property, defaultValue) {/*...*/}

return {

create: function (params) {

var viewmodel = {};

// properties

viewmodel.items = params.items; // the collection

viewmodel.newItem = ko.observable('');

viewmodel.isOrdered = defaultTo(params, 'isOrdered', false);

viewmodel.enableAdd = defaultTo(params, 'enableAdd', true);

viewmodel.enableUpdate = defaultTo(params, 'enableUpdate', true);

viewmodel.enableRemove = defaultTo(params, 'enableRemove', true);

// methods

viewmodel.commitNewItem = function () {/*...*/};

viewmodel.changeItem = function (index, newValue) {/*...*/};

viewmodel.removeItem = function (index) {/*...*/};

viewmodel.promoteItem = function (index) {/*...*/};

viewmodel.demoteItem = function (index) {/*...*/};

return viewmodel;

}

};

}(window.ko));

params.itemsparams.isOrdered属性对应于清单 7-27 中的绑定属性。当在页面上使用一个组件时,它的绑定属性的值通过params对象被引用传递给组件的视图模型。在这个场景中,输入列表组件将被赋予访问配方细节视图模型上的ingredientsinstructions可观察数组的权限。

清单 7-28 中的输入列表方法已经被修订,因为它们与清单 7-25 中的方法几乎相同。然而,这些方法不是引用成分或指令,而是引用抽象的items可观察数组。组件用从params.items接收的数据填充这个数组。与recipe-details.js模块中的newIngredientnewInstruction可观察对象的行为方式完全相同,newItem可观察对象保存新项目输入的值。然而,它并不与 recipe details 视图模型共享,因为它只与输入列表相关。

因为输入列表组件现在将处理页面上的配料和说明列表的操作,所以之前执行这些操作的 recipe details 视图模型中的属性和方法已经被移除。

输入列表模板

一个可重用的组件需要一个抽象的、可重用的模板,所以与编辑指令和成分相关的标记也被收集到一个 HTML 模板中。每次在页面上创建输入列表组件的实例时,Knockout 都会将模板注入 DOM,然后将输入列表视图模型的新实例绑定到它。

由于输入列表组件可以容纳有序列表和无序列表,因此模板必须使用淘汰绑定来智能地决定显示哪种列表。只有有序列表才会有升级和降级按钮,而项目可以从这两种列表中添加和删除。由于输入列表视图模型公开了从其params对象接收的布尔属性,模板可以根据这些属性的值改变其行为。例如,如果视图模型属性isOrderedtrue,模板将显示一个有序列表;否则它将显示一个无序列表。同样,与添加新项目或删除现有项目相关的字段和按钮分别由enableAddenableRemove属性切换。

模板标记通常被添加到 DOM 的非解析元素中,如<template><script type="text/html">元素。在清单 7-29 中,完整的组件标记和所有绑定都显示在一个<template>标签中。当组件注册到框架时,Knockout 将使用元素的id在 DOM 中查找模板内容。

Listing 7-29. Input List Component Template

<!-- example-003/public/index.html -->

<template id="item-list-template">

<!-- ko if: isOrdered -->

<!-- #1 THE ORDERED LIST -->

<ol data-bind="foreach: items" class="listless">

<li>

<input data-bind="value: $data,

valueUpdate: 'input',

attr: {name: 'item-' + $index()},

event: {input: $parent.changeItem.bind($parent, $index())}"

type="text" />

<button data-bind="click: $parent.demoteItem.bind($parent, $index())"

class="fa fa-caret-down"></button>

<button data-bind="click: $parent.promoteItem.bind($parent, $index())"

class="fa fa-caret-up"></button>

<button data-bind="click: $parent.removeItem.bind($parent, $index()),

visible: $parent.enableRemove"

class="fa fa-minus"></button>

</li>

</ol>

<!-- /ko -->

<!-- ko ifnot: isOrdered -->

<!-- #2 THE UN-ORDERED LIST -->

<ul data-bind="foreach: items" class="listless">

<li>

<input data-bind="value: $data,

valueUpdate: 'input',

attr: {name: 'item-' + $index()},

event: {input: $parent.changeItem.bind($parent, $index())}"

type="text" />

<button data-bind="click: $parent.removeItem.bind($parent, $index()),

visible: $parent.enableRemove"

class="fa fa-minus"></button>

</li>

</ul>

<!-- /ko -->

<!-- ko if: enableAdd -->

<!-- #3 THE NEW ITEM FIELD -->

<input data-bind="value: newItem"

type="text"

name="new-item" />

<button data-bind="click: commitNewItem"

class="fa fa-plus"></button>

<!-- /ko -->

</template>

在输入列表模板中有许多标记需要消化,但它实际上只是无序成分列表和有序说明列表的组合,带有一个共享的新项目字段。

特殊绑定注释—ko ifko ifnot注释块——包装模板的一部分,以确定注释块中的元素是否应该添加到页面中。这些注释块评估视图模型的属性,并相应地改变模板处理控制流。这不同于visible元素绑定,后者仅仅隐藏已经存在于 DOM 中的元素。

Tip

ko注释块绑定中使用的语法被称为无容器控制流语法。

输入列表模板中的所有字段和按钮都绑定到输入列表视图模型上的属性和方法。例如,如果点击一个降级按钮,输入列表视图模型将操作其内部的items集合,该集合实际上是对配方细节视图模型中的instructions可观察数组的引用,通过items绑定共享。该模板基于isOrdered属性确定显示哪种类型的列表,而添加和移除控件基于enableAddenableRemove属性进行切换。因为这些属性是从视图模型中的params对象读取的,所以它们中的任何一个都可以作为绑定属性添加到<input-list>组件标签中。通过这种方式,组件抽象并封装了针对任何集合的所有操作,这些操作可以表示为输入列表。

注册输入列表标签

一旦定义了组件视图模型和模板,组件本身必须用挖空注册。这告诉 Knockout 在 DOM 中遇到组件的自定义标记时如何解析组件实例,以及在呈现组件内容时使用什么模板和视图模型。

清单 7-30 中的app.js脚本已经更新,可以在 DOM 准备好之后,但在任何剔除绑定应用到页面之前(使用ko.applyBindings())立即注册输入列表组件。这确保了 Knockout 有时间在 DOM 中呈现组件的标记,所以在任何视图模型被绑定到它之前。

Listing 7-30. Registering the Input List Component

// example-003/public/scripts/app.js

(function app ($, ko, InputList /*...*/) {

// ...

$(function () {

// register the custom component tag before

// Knockout bindings are applied to the page

ko.components.register('input-list', {

template: {

element: 'item-list-template'

},

viewModel: InputList.create

});

// ...

});

}(window.jQuery, window.ko, window.InputList /*...*/));

在清单 7-30 中,ko.components.register()函数接收两个参数:新组件定制标签的名称,input-list和一个选项散列,该散列为 Knockout 提供构建组件所需的信息。

Knockout 使用自定义标记名来标识 DOM 中的<input-list>元素,并用 options hash 中指定的模板内容替换它。

因为输入列表元素的标记已经在一个<template>元素中定义,所以淘汰组件系统只需要知道它应该使用什么元素 ID 来在 DOM 中查找该元素。选项散列中的template对象在其element属性中包含这个 ID。对于较小的组件,整个 HTML 模板可以作为一个字符串直接分配给template属性。

为了构建组件的视图模型,一个工厂函数被分配给选项散列的viewModel属性。该属性还可以引用常规的构造函数,但是使用工厂函数可以避免当事件绑定在视图模型中重新分配关键字this时出现的潜在问题。不管采用哪种方法,视图模型函数都将接收一个params对象,其中填充了来自模板绑定声明的值。

Tip

Knockout 可以通过 RequireJS 自动加载组件模板和查看模型功能。有关更多详细信息,请参考脱模组件文档。RequireJS 模块加载器包含在第五章的中。

既然输入列表组件已经用 Knockout 注册了,那么可编辑成分和说明列表的复杂标记可以用简单的<input-list>实例代替。清单 7-31 展示了产生的更轻、更干净的页面标记。

Listing 7-31. Editing Instructions and Ingredients with the Input List Component

<!-- example-003/public/index.html -->

<h2>Ingredients</h2>

<!-- in read-only view -->

<ul data-bind="foreach: ingredients, visible: !isEditing()">

<li data-bind="text: $data"></li>

</ul>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<input-list params="items: ingredients,

isOrdered: false"></input-list>

</div>

<h2>Instructions</h2>

<!-- in read-only view -->

<ol data-bind="foreach: instructions, visible: !isEditing()">

<li data-bind="text: $data"></li>

</ol>

<!-- in edit view -->

<div data-bind="visible: isEditing" class="edit-field">

<input-list params="items: instructions,

isOrdered: true"></input-list>

</div>

不仅输入列表的复杂性被隐藏在新的<input-list>标签后面,而且列表的某些方面,比如添加和删除项目的能力,都是通过绑定属性来控制的。这提高了灵活性和可维护性,因为常见的行为被捆绑到单个元素中。

订阅:廉价信息

此时,recipe details 视图模型操作配方数据,但不保存更改。它也无法将配方更改传递给配方列表,因此,即使用户修改了配方的标题,配方列表也会继续显示配方的原始标题。从用例的角度来看,只有当菜谱细节被发送到服务器并成功持久化时,菜谱列表才应该被更新。需要一种更复杂的机制来促进这一工作流程。

淘汰可观察对象实现了淘汰可订阅对象的行为,这是一个更抽象的对象,它不保存值,但充当一种其他对象可能订阅的事件机制。Observables 利用了 subscribable 接口,通过 subscribable 发布自己的更改,DOM 绑定(甚至其他视图模型)会监听 subscribable。

可订阅性可以直接作为属性附加到视图模型上,或者通过引用对其事件感兴趣的任何对象来传递。在清单 7-32 中,一个 subscribable 在app.js文件中构造,并作为参数传递给配方列表和配方细节模块。注意,与可观察对象不同,可订阅对象必须用关键字new进行实例化。

Listing 7-32. Knockout Subscribable Acting As a Primitive Message Bus

// example-004/public/scripts/app.js

var bus = new ko.subscribable();

var list = RecipeList.create(recipes, bus);

var details = RecipeDetails.create(list.selectedRecipe(), bus);

为了有效地将更新的配方发布到订户,配方细节视图模型已经以多种方式进行了修改。

首先,subscribable 作为一个名为bus的参数传递给 recipe details 工厂函数。当配方细节改变时,配方细节模块将使用这个可订阅的来引发事件。

其次,视图模型现在跟踪配方的 ID,因为这个值将用于更新服务器上的配方数据。保存更改后,配方列表也将使用 ID 来替换过时的配方数据。

最后,save()方法已经更新,可以触发bus订户的recipe.saved事件,将修改后的配方数据作为参数传递给任何订户。修改后的save()方法如清单 7-33 所示。

Listing 7-33. Recipe Details View Model Saving a Modified Recipe

// example-004/public/scripts/recipe-details.js

viewmodel.save = function () {

var savedRecipe = {

id: viewmodel.id,

title: viewmodel.title(),

ingredients: viewmodel.ingredients(),

instructions: viewmodel.instructions(),

cookingTime: {

hours: viewmodel.hours(),

minutes: viewmodel.minutes()

},

servings: viewmodel.servings(),

citation: viewmodel.citation()

};

bus.notifySubscribers(savedRecipe, 'recipe.saved');

viewmodel.isEditing(false);

};

subscribers 上的notifySubscribers()方法接受两个参数——订户将收到的数据对象和引发的事件的名称。app.js模块订阅可订阅的bus上的recipe.saved事件,如清单 7-34 所示,并发起一个 AJAX 请求将修改后的食谱数据发送到服务器。因为配方细节视图模型和app.js模块共享对bus对象的引用,所以配方细节视图模型触发的任何事件都可以在app.js模块中处理。

Listing 7-34. Saved Recipe Is Persisted to the Server

// example-004/public/scripts/app.js

var bus = new ko.subscribable();

bus.subscribe(function (updatedRecipe) {

$.ajax({

method: 'PUT',

url: '/recipes/' + updatedRecipe.id,

data: updatedRecipe

}).then(function () {

bus.notifySubscribers(updatedRecipe, 'recipe.persisted');

})

}, null, 'recipe.saved');

subscribable 的subscribe()方法接受三个参数:

  • 当指定的事件在 subscribable 上被触发时要执行的回调函数
  • 将被绑定到回调函数中的this关键字的上下文对象(或者null,如果this关键字在回调函数中从未被使用)
  • 订阅回调的事件的名称(例如,recipe.saved)

如果 AJAX 更新成功,app.js 模块会在 subscribable 上触发一个recipe.persisted事件来通知侦听器。对bussubscribe 的引用也被传递给了 recipe list 视图模型,它主动监听recipe.persisted事件。当事件触发时,配方列表接收保存在列表 7-35 中的数据,并根据持久化的接收者 ID 更新其内部配方集合和选定的配方。

Listing 7-35. Updating the Recipe List with a Persisted Recipe

// example-004/public/scripts/recipe-list.js

window.RecipeList = (function (ko) {

return {

create: function (recipes, bus) {

var viewmodel = {};

// properties

viewmodel.recipes = ko.observableArray(recipes);

viewmodel.selectedRecipe = ko.observable(recipes[0]);

// ...

bus.subscribe(function (updatedRecipe) {

var recipes = viewmodel.recipes();

var i = 0,

count = recipes.length;

while (i < count) {

if (recipes[i].id !== updatedRecipe.id) {

i += 1;

continue;

}

recipes[i] = updatedRecipe;

viewmodel.recipes(recipes);

viewmodel.selectRecipe(recipes[i]);

break;

}

}, null, 'recipe.persisted');

// ...

}

};

}(window.ko));

尽管可订阅性并不是在应用中引发事件的唯一方式,但是它们对于简单的用例来说是有效的,可以在模块之间创建一个解耦的通信链。

摘要

许多前端框架提供了引人注目的特性和插件套件,但 Knockout 真正关注的是应用中 HTML 视图和数据模型之间的交互。Knockout 的可观察性减轻了从 HTML DOM 元素手动提取数据和将数据推入 HTML DOM 元素的痛苦。开发人员可以向页面上的任何元素添加data-bind属性,通过双向绑定将标记粘合到一个或多个视图模型。

虽然表单数据可以直接绑定到视图模型属性,但是 DOM 事件绑定也可以调用挖空视图模型上的方法。这些方法对查看模型可观察属性所做的任何更改都会立即反映在 DOM 中。像visiblecss这样的绑定决定元素如何显示给用户,而像textvalue这样的绑定决定元素的内容。

可观察对象是保存视图模型数据值的特殊对象。当它们的值改变时,observables 会通知任何感兴趣的订阅者,包括绑定的 DOM 元素。原始可观察值保存单个值,而可观察数组保存集合。发生在可观察数组上的突变可以被绑定到集合的 HTML 元素跟踪和镜像。当迭代一个可观察数组的元素时,foreach绑定特别有用,尽管如果一个可观察数组的单个成员被改变或替换,必须要特别考虑。

敲除模板和视图模型可以抽象成具有独特 HTML 标签的可重用组件。可以将这些组件添加到页面中,并绑定到其他视图模型属性,就像绑定任何标准 HTML 元素一样。将状态和行为封装在组件中减少了页面上的总标记,还保证了应用的类似部分(例如,绑定到集合的输入列表)无论在哪里使用都具有相同的行为。

最后,可订阅对象 observables 背后的基本构件——可以用作原始消息总线,通知订阅者已发布的事件,并可能在需要的地方传递有效数据。

相关资源

八、AngularJS

构建大型应用的秘密是永远不要构建大型应用。将你的应用分成小块。然后,将这些可测试的小部分组装到您的大应用中。—贾斯汀·迈耶,JavaScriptMVC 的创造者

AngularJS 成功吸引了开发人员社区的大量关注,这是有充分理由的:该框架解决许多通常与单页面应用开发相关的挑战的独特方法与流行的替代方法有很大不同。这些差异为 Angular 赢得了一大批忠实粉丝,以及越来越多直言不讳的评论家。

随着本章的深入,你将会了解到 Angular 区别于其他单页面应用框架的一些独特的特性。我们还将提供一些指导,说明什么类型的项目可能最能从 Angular 中受益,以及其他替代方案可能更适合什么类型的项目。在我们结束这一章之前,我们还将花一点时间讨论 Angular 的历史,它的当前状态,以及这个框架的未来。

构建 Web 应用的声明式方法

Angular 最显著的特点是它允许开发人员以一种所谓的“声明式”方式创建 web 应用,而不是大多数开发人员习惯的“命令式”方法。这两种方法之间的差别是微妙的,但必须理解它才能真正体会 Angular 给桌面带来的独特好处。让我们看一下演示每种方法的两个例子。

命令式方法

命令式的:具有表达命令而不是陈述或问题的形式的—Merriam-Webster.com

当大多数人想到“编程”时,命令式方法通常是他们所想到的。使用这种方法,开发者指导计算机如何做某事。结果,期望的行为(有希望)得以实现。举例来说,考虑清单 8-1 ,它显示了一个简单的 web 应用,该应用使用命令式方法来显示一个无序的动物列表。

Listing 8-1. Simple, Imperative Web Application

// example-imperative/public/index.html

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<title>Imperative App</title>

</head>

<body>

<ul id="myList">

</ul>

<script src="/bower_components/jquery/dist/jquery.js"></script>

<script>

var App = function App() {

this.init = function() {

var animals = ['cats', 'dogs', 'aardvarks', 'hamsters', 'squirrels'];

var $list = $('#myList');

animals.forEach(function(animal) {

$list.append('<li>' + animal + '</li>');

});

};

};

var app = new App();

app.init();

</script>

</body>

</html>

在这个例子中,我们的应用所期望的行为——创建一个动物列表——是由于我们明确地指示计算机如何着手创建它而实现的:

We start our application by creating a new instance of the App class and calling its init() method.   We specify our list’s entries in the form of an array (animals).   We create a reference to the desired container of our list ($list).   Finally, we iterate through each of our array’s entries and append them, one by one, to the container.

当使用命令式方法创建应用时,该应用的源代码是控制该应用做什么以及何时做的主要来源。简而言之,命令式应用告诉计算机如何运行。

声明式方法

陈述性的:具有陈述的形式而不是问题或命令的形式的—Merriam-Webster.com

编程的声明性方法采用大多数人熟悉的传统的命令式方法,并彻底颠覆了它。当开发人员使用这种方法时,他们将精力集中在描述想要的结果上,而将实现该结果的必要步骤留给计算机本身。

举例来说,清单 8-2 显示了一个简单的 web 应用,与清单 8-1 中显示的非常相似。这里,在 Angular 的帮助下,使用更具声明性的方法显示了一个无序的动物列表。

Listing 8-2. Declarative Web Application Developed with Angular

// example-declarative/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Declarative App</title>

</head>

<body>

<div ng-controller="BodyController">

<ul>

<li ng-repeat="animal in animals">{{animal}}</li>

</ul>

</div>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.controller('BodyController', function($scope) {

$scope.animals = ['cats', 'dogs', 'aardvarks', 'hamsters', 'squirrels'];

});

</script>

</body>

</html>

清单 8-2 中显示的 HTML 包含了几项重要的内容,但是现在,请注意页面中使用的各种非标准属性(例如,ng-appng-controllerng-repeat)。这些属性演示了指令的使用,这是 Angular 最突出和最受欢迎的特性之一。

简而言之,Angular 指令允许开发人员用他们自己的定制扩展来增强 HTML 的语法。这些扩展可以以类、定制属性、注释甚至全新的 DOM 元素的形式出现,我们很快就会看到。当 Angular 遇到这些指令时,它会自动执行与它们相关联的任何功能。这可能包括函数的执行、模板的加载等等。Angular 还包括几个自己的内置指令(比如清单 8-2 中使用的指令),其中很多我们将在本章中讨论。

当使用声明性方法创建 web 应用时,确定该应用中的控制流的责任从源代码转移到了接口。我们没有明确说明应用加载后需要发生什么(如清单 8-1 所示),而是让应用的界面自己描述需要发生什么。角度指令有助于实现这一点。

对于新手来说,应用开发的命令式方法和声明式方法之间的差异可能看起来很微妙,但是随着我们的继续,我想您会发现有很多令人兴奋的地方。

模块:构建松耦合应用的基础

当我们不再把复杂的应用作为一个单一的实体来对待,而是作为一个小组件的集合来一起工作以达到预期的目标时,它们就不再复杂了。Angular 模块是所有 Angular 项目的基本构建模块,它为我们提供了一种以这种方式构建应用的便捷模式。

再看一下清单 8-2 并注意这个例子对 Angular 的module()方法的调用,它既是 setter 又是 getter。在这里,我们创建了一个模块,将我们的应用作为一个整体。为了使用 setter 语法定义新模块,我们提供了新模块的名称,以及引用该模块所依赖的其他模块的名称数组。在这个例子中,我们的模块没有依赖项,但是为了使用 setter 语法,我们仍然传递了一个空数组。另一方面,清单 8-3 展示了一个具有两个依赖关系的新app模块的创建。

Listing 8-3. Creating a New Angular Module with Dependencies

/**

* Creates a new module that depends on two other modules - module1andmodule2``

*/

var app = angular.module('app', ['module1', 'module2']);

一旦定义了一个模块,我们就可以通过使用module()方法的 getter 语法来获取对它的引用,如清单 8-4 所示。

Listing 8-4. Angular’s module() Method Serves As a Getter when No Dependencies Array Is Passed

/**

* Returns a reference to a pre-existing module named app``

*/

var app = angular.module('app');

在这一章中,我们将会看到 Angular 为构建应用提供的许多工具。当我们这样做时,请记住这些工具总是在模块的上下文中使用。每个 Angular 应用本身就是一个依赖于其他模块的模块。了解了这一点,我们可以将角度应用的一般结构想象成如图 8-1 所示。

A978-1-4842-0662-1_8_Fig1_HTML.gif

图 8-1。

Every Angular application is a module, and Angular modules may specify other modules as dependencies

指定引导模块

物理世界中的重大建筑项目通常始于基石的铺设,即所有其他块围绕其设置的第一块基石。类似地,每个 Angular 项目都有自己的基石——代表应用本身的模块。初始化该模块(及其依赖项)的过程被称为 Angular 的“引导”过程,可以通过两种可能的方式之一启动。

自动引导

回头参考清单 8-2 ,注意附在页面html标签上的ng-app指令。当这个页面完成加载后,Angular 将自动检查这个指令是否存在。如果找到了,它引用的模块将作为应用的基础模块——代表应用本身的模块。该模块将自动初始化,此时应用将准备就绪。

手动引导

对于大多数应用,Angular 的自动引导过程应该足够了。然而,在某些情况下,对该过程何时发生进行更大程度的控制可能是有用的。在这种情况下,可以手动启动 Angular 的引导过程,如清单 8-5 所示。

Listing 8-5. Deferring Angular’s Bootstrap Process Until the Completion of an Initial jQuery-based AJAX Request

$.ajax({

'url': '/api/data',

'type': 'GET'

}).done(function() {

angular.bootstrap(document, ['app']);

});

在这个例子中,我们推迟 Angular 的引导过程,直到一个初始 AJAX 请求完成。只有这样,我们才调用 Angular 的bootstrap()方法,传递一个 DOM 对象作为我们应用的容器(它的“根元素”),以及一个数组,指定一个名为app(代表我们应用的模块)的模块作为依赖项。

Note

大多数情况下,角度应用将作为页面中唯一的应用存在;但是,多个角度应用可以在同一页面中共存。当他们这样做时,只有一个人可以利用 Angular 的自动引导过程,而其他人必须在适当的时间手动引导自己。

指令:DOM 的抽象层

通过使用原型继承,JavaScript 为开发人员提供了一种创建带有自定义内置行为的命名函数(类的 JavaScript 等价物)的机制。然后,其他开发人员可以实例化和使用这样的类,而不需要理解它们的内部工作原理。清单 8-6 中的例子演示了这个过程。

Listing 8-6. Prototypal Inheritance in Action

// example-prototype/index.js

function Dog() {

}

Dog.prototype.bark = function() {

console.log('Dog is barking.');

};

Dog.prototype.wag = function() {

console.log('Tail is wagging.');

};

Dog.prototype.run = function() {

console.log('Dog is running.');

};

var dog = new Dog();

dog.bark();

dog.wag();

dog.run();

这个将复杂行为抽象到简单接口背后的过程是一个基本的面向对象编程概念。同样,Angular 指令可以被视为 DOM 的抽象层,它为开发人员提供了一种创建复杂 web 组件的机制,只需使用简单的 HTML 标记就可以使用这些组件。清单 8-7 提供了一个例子,应该有助于澄清这个概念。

Listing 8-7. Example Demonstrating the Creation of a Simple Angular Directive

// example-directive1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'templateUrl': '/templates/news-list.html'

};

});

</script>

</body>

</html>

在为我们的应用创建了一个模块之后,我们通过调用我们的模块的directive()方法,传递一个名称和一个工厂函数来定义一个新的指令,这个工厂函数负责将描述我们的新指令的对象返回给 Angular。我们的工厂函数返回的对象可能会指定几个不同的选项,但是在这个简单的示例中,只使用了三个选项:

  • restrict:指定该指令是否应该与 Angular 找到的匹配属性(A)、类(C)或 DOM 元素(E)成对出现(或者三者的任意组合)。在这个例子中,E的值指定 Angular 应该只将我们的指令与标记名匹配的 DOM 元素配对。通过传递AEC,我们可以很容易地指定所有三个。
  • replace:值为true表示我们的组件应该完全替换与之配对的 DOM 元素。值false将允许我们创建一个指令,以某种方式简单地增加一个现有的 DOM 元素,而不是用别的东西完全替换它。
  • Angular 在这个 URL 上找到的标记一旦被插入到 DOM 中,就会代表我们的指令。也可以通过使用template选项直接传递模板的内容。

Note

关于我们新指令的名称,注意当我们在 Angular 中创建它时使用了 camelCase 格式,当我们在 HTML 中引用它时使用了破折号分隔的格式。这种差异是由于 HTML 标记不区分大小写的特性造成的。当 Angular 解析我们的 HTML 时,它会自动为我们解决命名约定中的这些差异。

现在,当我们在浏览器中加载应用时,Angular 会自动将新定义的指令与它找到的任何匹配的 DOM 元素配对。因此,<news-list>标签的所有实例将被图 8-2 中所示的元素替换。

A978-1-4842-0662-1_8_Fig2_HTML.jpg

图 8-2。

Our newly defined directive

我们刚刚讨论的基本示例只不过是用不同的模板替换了一个定制的 DOM 元素(我们将在下一节中通过添加我们自己的定制逻辑来构建这个示例)。但是,您应该已经开始注意到角度指令给开发人员带来的强大功能和便利。通过使用清单 8-7 中所示的简单标签将复杂组件注入应用的能力为开发人员提供了一种方便的机制,用于抽象简单外观背后的复杂功能,从而更易于管理。

掌握控制权

在上一节中,我们逐步创建了一个简单的 Angular 指令,最终,它只是用我们选择的单独模板替换了一个自定义 DOM 元素。这是指令本身的一个有用的应用,但是为了理解指令的全部功能,我们需要通过应用我们自己的定制逻辑来进一步应用这个例子,这将允许我们的指令实例以有趣的方式运行。我们可以在示波器和控制器的帮助下做到这一点。

范围和原型继承

一开始,角度范围可能有点难以理解,因为它们直接关系到 JavaScript 的一个更令人困惑的方面:原型继承。Angular 的新手经常会发现作用域是一个比较混乱的概念,但是对它们的深入理解对于使用这个框架来说是必不可少的。在我们继续之前,让我们花几分钟时间来探索它们的目的和工作原理。

在大多数“经典的”面向对象语言中,继承是通过使用类来完成的。另一方面,JavaScript 实现了一种完全不同的继承结构,称为原型继承,其中所有的继承都是通过使用对象和函数来完成的。清单 8-8 展示了这个过程的一个实例。

Listing 8-8. Example of Prototypal Inheritance, in Which Car Extends Vehicle

// example-prototype2/index.js

/**

* @class Vehicle

*/

var Vehicle = function Vehicle() {

console.log(this.constructor.name, 'says: I am a vehicle.');

};

Vehicle.prototype.start = function() {

console.log('%s has started.', this.constructor.name);

};

Vehicle.prototype.stop = function() {

console.log('%s has stopped.', this.constructor.name);

};

/**

* @class Car

*/

var Car = function Car() {

console.log(this.constructor.name, 'says: I am a car.');

Vehicle.apply(this, arguments);

};

Car.prototype = Object.create(Vehicle.prototype);

Car.prototype.constructor = Car;

Car.prototype.honk = function() {

console.log('%s has honked.', this.constructor.name);

};

var vehicle = new Vehicle();

vehicle.start();

vehicle.stop();

var car = new Car();

car.start();

car.honk();

car.stop();

/* Result:

Vehicle says: I am a vehicle.

Vehicle has started.

Vehicle has stopped.

Car says: I am a car.

Car says: I am a vehicle.

Car has started.

Car has honked.

Car has stopped.

*/

在这个例子中,定义了一个Vehicle函数。我们通过扩充它的原型来给它分配start()stop()实例方法。之后,我们定义了一个Car函数,只是这一次,我们用一个继承自Vehicle的函数替换了它的原型。最后,我们给Car分配一个honk实例方法。当运行这个例子时,请注意这样一个事实,即Vehicle的新实例可以启动和停止,而Car的新实例可以启动、停止和鸣响。这是工作中的原型继承。

这是一个需要掌握的重要概念——在 Angular 的引导阶段,会发生一个类似的过程,创建一个父对象(称为$rootScope)并附加到应用的根元素。之后,Angular 将继续解析 DOM 以搜索指令(Angular 将这个过程称为“编译”)。当遇到这些指令时,Angular 将创建继承自其最近祖先的新对象,并将它们分配给每个指令所附加的 DOM 元素。实际上,Angular 为我们应用中的每个组件创建了一个特殊的沙箱——用 Angular 的术语来说,就是一个“范围”。结果可以被可视化为类似于图 8-3 所示的东西。

A978-1-4842-0662-1_8_Fig3_HTML.jpg

图 8-3。

A web application with various components created with the help of directives. On the right, portions of the DOM are highlighted where new scopes have been created

用控制器操纵范围

角度控制器只不过是一个函数,它的唯一目的是操纵一个范围对象,在这里我们可以开始为我们的应用组件添加一些智能。清单 8-9 展示了我们在清单 8-7 中看到的例子的扩展版本。唯一的区别是向负责描述我们的指令的对象添加了一个controller属性。该指令使用的模板内容如清单 8-10 所示。

Listing 8-9. Extended Version of Listing 8-7 Example That Adds Custom Behavior to Our New Directive

// example-directive2/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, $http) {

$http.get('/api/news').then(function(result) {

$scope.items = result.data;

});

},

'templateUrl': '/templates/news-list.html'

};

});

</script>

</body>

</html>

Listing 8-10. Contents of Our Directive’s Template

// example-directive2/public/templates/news-list.html

<div class="row">

<div class="col-xs-8">

<div ng-repeat="item in items">

<div class="media">

<div class="media-left">

<a href="#">

<img class="media-object" ng-src="{{item.img}}">

</a>

</div>

<div class="media-body">

<h4 class="media-heading" ng-bind="item.title"></h4>

</div>

</div>

</div>

</div>

</div>

在清单 8-9 中,请特别注意我们为指令的controller属性分配函数的部分。请注意,我们的控制器函数接收两个参数:$scope$http。目前,不要关心这些参数是如何传递给我们的控制器的——我们将在接下来的服务部分讨论这个问题。现在,要意识到的重要事情是,在我们的控制器中,$scope变量指的是 Angular 在 DOM 中第一次遇到我们的指令时自动为我们创建的对象。在这一点上,我们的控制器有机会改变该对象,结果,由于 Angular 对双向数据绑定的支持,可以看到这些变化反映在 DOM 中。

双向数据绑定

数据绑定描述了 Angular 将模板与 JavaScript 对象(即范围)链接起来的能力,允许模板引用范围内的属性,然后将这些属性呈现给浏览器。图 8-4 说明了这一过程。

A978-1-4842-0662-1_8_Fig4_HTML.gif

图 8-4。

Process by which data binding allows Angular applications to render data that is referenced within a scope object

Angular 对数据绑定的支持并不仅限于这种单向过程,即在一个范围内引用的数据显示在一个视图中。该框架还提供实现相反效果的指令,允许指令的范围随着其视图内发生的变化而更新(例如,当表单字段的值改变时)。当 Angular 的数据绑定实现被描述为“双向”时,这就是所指的。

Note

双向数据绑定的主题将在本章后面的“创建复杂表单”一节中详细讨论。

在清单 8-9 中,我们的控制器使用 Angular 的$http服务从我们的 API 中获取一个数组,该数组包含来自国家公共电台和 The Onion 的标题。然后,它将该数组赋给我们指令的$scope对象的items属性。要查看这些信息如何在 DOM 中得到反映,请注意清单 8-10 中显示的ng-repeat指令。这个核心 Angular 指令允许我们从模板中迭代数组,为数组中包含的每一项创建新的<div class="media">...</div>元素。最后,Angular 内置的ng-srcng-bind指令允许我们动态地将图像 URL 和文本内容分配给模板中适当的元素。

在浏览器中加载该应用后的最终结果如图 8-5 所示。

A978-1-4842-0662-1_8_Fig5_HTML.jpg

图 8-5。

Our application after having been loaded in the browser

通过服务和依赖注入实现松散耦合

在上一节中,我们介绍了将 Angular 应用组织为一系列嵌套作用域的基本过程,这些作用域可以由控制器操作,并通过双向数据绑定由模板引用。仅使用这些概念,有可能构建相当简单的应用(如本章中包含的一些示例所示),但是如果没有计划,构建任何更复杂的应用的尝试将很快陷入成长的烦恼。在这一节中,我们将发现服务如何支持开发人员构建松散耦合的角度应用,以适应增长。

依赖注入

在我们深入研究服务之前,有必要花一点时间来讨论一下依赖注入,这是一个对于客户端框架来说相当新的概念,Angular 非常依赖它。

首先,看一下清单 8-11 ,它显示了一个非常基本的 Node.js 应用,只有一个依赖项,即fs模块。在这个例子中,我们的模块负责通过require()方法检索fs模块。

Listing 8-11. Node.js Application That Depends on the fs Module

var fs = require('fs');

fs.readFile('∼/data.txt', 'utf8', function(err, contents) {

if (err) throw new Error(err);

console.log(contents);

});

我们在这里看到的模式,其中一个模块“需要”一个依赖,直观上是有意义的。一个模块需要另一个组件,所以它出去得到它。然而,依赖注入的概念颠覆了这个概念。清单 8-12 显示了 Angular 中依赖注入的一个简单例子。

Listing 8-12. Dependency Injection in Action Within Angular

var app = angular.module('app', []);

app.controller('myController', function($http) {

$http.get('/api/news').then(function(result) {

console.log(result);

});

});

像 Angular 这样实现依赖注入的框架规定了一个通用的模式,通过这个模式,模块可以将自己注册到一个中心控制点。换句话说,当一个应用被初始化时,模块有机会说,“这是我的名字,你可以在这里找到我。”之后,在程序执行的整个过程中,加载的模块可以简单地通过将它们指定为构造函数(或类)的参数来引用它们的依赖关系。它们被指定的顺序没有区别。

回头参考清单 8-12 。在这个例子中,我们创建了一个新的app模块来表示我们的应用。接下来,我们在应用的模块中创建一个名为myController的控制器,传递一个构造函数,每当需要一个新实例时就会调用这个函数。注意传入控制器构造函数的$http参数;这是工作中依赖注入的一个例子。我们的控制器所指的$http依赖项是 Angular 的核心代码库中包含的一个模块。在我们的应用的引导阶段,Angular 以服务的形式注册了这个模块——与您将要学习如何为自己创建的服务类型相同。

Note

按照惯例,Angular 提供的核心服务、API、属性都以$为前缀。为了防止可能的冲突,最好在您自己的代码中避免遵循此约定。

瘦控制器和胖服务

再看一下清单 8-9 ,它展示了使用控制器为应用的指令增加智能的过程。在这个例子中,我们的控制器创建了一个 AJAX 请求,从我们的 API 返回一组新闻标题。虽然这样做可行,但是这个例子并没有解决在整个应用中共享这些信息的真实和可预见的需求。

虽然我们可以让其他感兴趣的组件自己复制这个 AJAX 请求,但这并不理想,原因有很多。如果我们能够将收集这些标题的逻辑抽象到一个可以在整个应用中重用的集中式 API 中,我们会处于一个更好的位置。这样做将为我们提供许多好处,包括能够在 API 的消费者不知道的情况下,在单个位置更改获取这些信息的 URL。

我们马上就会看到,角度服务为我们提供了实现这一目标所需的工具。服务为我们提供了一种创建定义良好的接口的机制,这些接口可以在整个应用中共享和重用。当 Angular 应用的大部分逻辑以这种方式构建时,我们可以开始看到控制器的真实面目:只不过是一层薄薄的胶水,负责以对特定视图最有意义的方式将范围与服务绑定在一起。

在 Angular 中,存在三大类服务类型(其中一个被命名为“服务”),即工厂、服务和提供者。让我们来看看每一个。

工厂

清单 8-13 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑移到了一个工厂中。

Listing 8-13. Angular headlines Factory That Provides an API for Fetching News Headlines

// example-directive3/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.factory('headlines', function($http) {

return {

'fetch': function() {

return $http.get('/api/news').then(function(result) {

return result.data;

});

}

};

});

</script>

</body>

</html>

在清单 8-13 中,headlines工厂用一个fetch()方法返回一个对象,当这个方法被调用时,它将查询我们的 API 以获取标题,并以承诺的形式返回它们。

在大多数角度应用中,工厂是最常用的服务类型。工厂第一次作为依赖项被引用时,Angular 将调用工厂的函数并将结果返回给请求者。对该服务的后续引用将收到与第一次引用该服务时最初返回的结果相同的结果。换句话说,工厂可以被认为是单例的,因为它们从来不会被调用超过一次。

服务

清单 8-14 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑转移到了一个服务中。

Listing 8-14. Angular headlines Service That Provides an API for Fetching News Headlines

// example-directive4/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.service('headlines', function($http) {

this.fetch = function() {

return $http.get('/api/news').then(function(result) {

return result.data;

});

};

});

</script>

</body>

</html>

在 Angular 中,服务的功能几乎和工厂一样,只有一个关键的区别。虽然简单地调用工厂函数,但是服务函数是通过关键字new作为构造函数调用的,允许它们以实例化的类的形式定义。您选择使用哪一种很大程度上取决于风格偏好,因为两者可以实现相同的最终结果。

在这个例子中,我们没有像在工厂中那样返回一个对象,而是将一个fetch()方法分配给this,这个对象最终由我们的服务的构造函数返回。

提供者

清单 8-15 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑移到了一个提供者中。

Listing 8-15. Angular headlines Provider That Provides an API for Fetching News Headlines

// example-directive5/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.config(function(headlinesProvider) {

headlinesProvider.limit = 10;

});

app.provider('headlines', function() {

this.$get = function($http) {

var self = this;

return {

'fetch': function() {

return $http.get('/api/news', {

'params': {

'limit': self.limit || 20

}

}).then(function(result) {

return result.data;

});

}

};

};

});

</script>

</body>

</html>

与工厂和服务完全负责确定自己的设置不同,Angular 提供程序允许开发人员在其父模块的配置阶段配置它们。这样,提供者可以被认为是可配置的工厂。在这个例子中,我们定义了一个headlines提供者,它的功能与我们在清单 8-13 中创建的工厂相同,只是这一次,fetch()方法将一个可配置的limit参数传递给我们的 API,允许它指定它将接收的结果数量的限制。

在清单 8-15 中,我们在提供者中的this.$get处定义了一个工厂函数。当headlines提供者作为依赖项被引用时,Angular 将调用这个函数并将其结果返回给请求者,就像它在清单 8-13 中对我们的工厂所做的那样。相比之下,请注意我们的提供者的fetch()方法是如何引用在模块的config块中定义的limit属性的。

创建路线

用 Angular 等框架构建的所谓“单页应用”为用户提供了更类似于传统桌面应用的流畅体验。他们通过预加载所有(或大部分)所需的各种资源(例如,脚本、样式表等)来实现这一点。)在单个预先页面加载中。然后,对不同 URL 的后续请求被拦截,并通过后台 AJAX 请求进行处理,而不需要完全刷新页面。在本节中,您将学习如何在 Angular 的ngRoute模块的帮助下管理这样的请求。

清单 8-16 建立在之前清单 8-13 中显示的例子之上。然而,这一次,我们在应用中添加了两条路径,允许用户导航到标有“仪表板”和“新闻标题”的部分只有在用户导航到/#/headlines路线后,我们的newsList指令才会被注入页面。为实现这一目标,采取了以下步骤:

Define a configuration block that will be executed during our application’s bootstrap phase. Within this function, we reference the $routeProvider service provided by Angular’s angular-route package, which must be installed in addition to Angular’s core library.   Define an array, routes, within which objects are placed that define the various routes to be made available by our application. In this example, each object’s route property defines the location at which the route will be loaded, while the config property allows us to specify a controller function and template to be loaded at the appropriate time.   Iterate through each entry of the routes array and pass the appropriate properties to the when() method made available by the $routeProvider service. This approach provides us with a simple method by which multiple routes can be defined. Alternatively, we could have made two separate, explicit calls to the $routeProvider.when() method without using an array.   Utilize the $routeProvider.otherwise() method to define a default route to be loaded in the event that no route (or an invalid route) is referenced by the user.   Listing 8-16. Angular Application That Defines Two Routes, dashboard and headlines

// example-router1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script src="/modules/news-list.js"></script>

<script>

var app = angular.module('app', ['ngRoute', 'newsList']);

app.config(function($routeProvider) {

var routes = [

{

'route': '/dashboard',

'config': {

'templateUrl': '/templates/dashboard.html'

}

},

{

'route': '/headlines',

'config': {

'controller': function($log) {

$log.debug('Welcome to the headlines route.');

},

'templateUrl': '/templates/headlines.html'

}

}

];

routes.forEach(function(route) {

$routeProvider.when(route.route, route.config);

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

路线参数

实际上,在典型的角度应用中存在的大多数路线被设计成提供基于每条路线期望的一个或多个参数值而变化的动态内容。清单 8-17 中所示的例子演示了如何实现这一点。

Listing 8-17. Angular Application with Routes That Vary Their Content Based on the Value of an Expected Parameter

// example-router2/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script>

var app = angular.module('app', ['ngRoute']);

app.config(function($routeProvider) {

var routes = [{

'route': '/dashboard',

'config': {

'templateUrl': '/templates/dashboard.html',

'controller': function($scope, $http) {

return $http.get('/api/animals').then(function(result) {

$scope.animals = result.data;

});

},

}

},

{

'route': '/animals/:animalID',

'config': {

'templateUrl': '/templates/animal.html',

'controller': function($scope, $route, $http) {

$http.get('/api/animals/' + $route.current.params.animalID).then(function(result) {

$scope.animal = result.data;

});

}

}

}];

routes.forEach(function(route) {

$routeProvider.when(route.route, route.config);

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

路线解析

如果操作正确,单页应用可以为用户提供比标准应用更好的体验。也就是说,这些改进不是没有代价的。在单页应用的整个生命周期中,协调各种 API 调用是非常具有挑战性的。在我们继续之前,让我们接触一下 Angular 的ngRoute模块提供的一个特别有用的特性,它可以帮助我们驯服这种复杂性:分辨率。

解决方案允许我们定义一个或多个步骤,这些步骤必须在转换到特定路线之前发生。如果为路径定义的任何解析碰巧返回承诺,则只有在每个解析完成后,到所需路径的转换才会完成。清单 8-18 中所示的示例显示了实际的路由解析。

Listing 8-18. Route Resolutions in Action

// example-router3/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script>

var app = angular.module('app', ['ngRoute']);

app.config(function($routeProvider) {

$routeProvider.when('/dashboard', {

'templateUrl': '/templates/dashboard.html',

'controller': function($scope, animals, colors) {

$scope.animals = animals;

$scope.colors = colors;

},

'resolve': {

'animals': function($http) {

return $http.get('/api/animals').then(function(result) {

return result.data;

});

},

'colors': function($http) {

return $http.get('/api/colors').then(function(result) {

return result.data;

});

}

}

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

在本例中,定义了一条路线,在对 API 进行两次相应的调用以获取该信息后,该路线显示动物和颜色的列表。我们在 route 的resolve对象中创建请求,而不是直接从 route 的控制器中请求这些信息。因此,当我们的路由控制器函数被调用时,我们可以肯定地知道请求已经完成。

创建复杂表单

HTML 表单很难管理。一个主要关注点是验证,通过该过程,用户可以意识到问题(例如,未填写的必填字段)并被引导到问题的解决方案。此外,复杂的表单经常需要额外的逻辑,允许它们根据用户对前面问题的回答来改变内容。在接下来的几页中,我们将通过几个例子来展示 Angular 如何帮助简化这些挑战。

确认

设计良好的 HTML 表单会仔细考虑用户体验。他们没有假设用户完全理解他们被要求做什么。当存在问题时,他们还会特意通知用户,以及解决问题所需的步骤。幸运的是,Angular 的声明性语法允许开发人员轻松创建遵守这些规则的表单。

清单 8-19 显示了我们第一个例子的 HTML,而清单 8-20 显示了附带的控制器。

Listing 8-19. HTML Form That Implements Validation and Displays Dynamic Feedback to the User

// example-form1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Form</title>

<link rel="stylesheet" href="/css/style.css">

</head>

<body ng-controller="formController">

<form name="myForm" ng-class="formClass" ng-submit="submit()" novalidate>

<div class="row">

<div ng-class="{

'has-error': !myForm.first_name.$pristine && !myForm.first_name.$valid,

'has-success': !myForm.first_name.$pristine && myForm.first_name.$valid

}">

<label>First Name</label>

<input

type="text"

name="first_name"

ng-model="model.first_name"

class="form-control"

ng-minlength="3"

ng-maxlength="15"

ng-required="true">

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.required">

First name is required.

</p>

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.minlength">

First name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.maxlength">

First name can have no more than 15 characters.

</p>

</div>

<div ng-class="{

'has-error': !myForm.last_name.$pristine && !myForm.last_name.$valid,

'has-success': !myForm.last_name.$pristine && myForm.last_name.$valid

}">

<label>Last Name</label>

<input

type="text"

name="last_name"

ng-model="model.last_name"

class="form-control"

ng-minlength="3"

ng-maxlength="15"

ng-required="true">

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.required">

Last name is required.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.minlength">

Last name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.maxlength">

Last name can have no more than 15 characters.

</p>

</div>

</div>

<div class="row">

<div>

<button type="submit" ng-disabled="myForm.$invalid">Submit</button>

<button type="button" ng-click="reset()">Reset</button>

</div>

</div>

</form>

<hr>

<div class="output" ng-bind="output"></div>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/app/index.js"></script>

</body>

</html>

Listing 8-20. Controller That Has Been Attached to the Document’s <body> Element

// example-form1/public/app/index.js

var app = angular.module('app', []);

app.controller('formController', function($scope, $http, $log) {

$scope.formClass = null;

$scope.model = {};

$http.get('/api/model').then(function(result) {

$scope.model = result.data;

});

$scope.submit = function() {

if (!$scope.myForm.$valid) return;

$http.post('/api/model', {

'model': $scope.model

}).then(function() {

alert('Form submitted.');

}).catch(function(err) {

alert(err);

});

};

$scope.reset = function() {

$scope.model = {};

$http.post('/api/model', {

'model': $scope.model

});

};

/**

* Angular’s built-in $watch() method (available within every controller)

* enables us to watch for and respond to changes that occur within variables

* defined at the $scope level. Here we save the contents of our a form as

* a JSON string to $scope.output, which is referenced by our template.

*/

$scope.$watch('model', function() {

$scope.output = angular.toJson($scope.model, 4);

}, true);

});

当 Angular 编译我们的应用时,它会将一个内置的form指令应用到模板中包含的<form>元素。该指令将创建一个特殊控制器FormController的新实例,该实例用于管理表单实例。最后,基于我们表单的name属性的值(在本例中是myForm),Angular 将把对新创建的FormController实例的引用分配给表单的父范围,允许我们的控制器在$scope.myForm与我们新创建的表单进行交互。

FormController的实例提供了许多有用的属性和方法,您可以在清单 8-19 和清单 8-20 中看到这些属性和方法。例如,请注意我们如何在ng-disabled指令的帮助下动态地启用或禁用表单的提交按钮。在这个例子中,我们设置了这个指令来引用表单的$invalid属性,该属性将总是返回TRUEFALSE来指示表单中包含的任何输入是否处于无效状态。

清单 8-19 还应用了额外的内置角度指令(ng-minlengthng-maxlengthng-required)来在我们的表单中实现一些简单的验证规则。在每个输入的正下方,我们的模板引用了myForm对象上的各种属性来确定当前存在什么错误(如果有的话)。基于这些信息,它可以向用户隐藏或显示适当的反馈。

请注意清单 8-19 中的ng-model指令在我们表单的每个输入字段上的使用。这个指令(专门设计用于表单控件)允许我们实现双向数据绑定,这个概念在本章前面已经简单提到过。随着在每个字段中输入的值发生变化,我们的范围也将更新,属性由ng-model引用。由于双向数据绑定,相反的效果也成立。如果我们的控制器要修改由ng-model引用的值,匹配的表单输入也会相应地更新。值得注意的是,ng-model指令是我们确定表单输入值的首选方法。在 Angular 中,输入的name属性仅用于验证目的。

图 8-6 、图 8-7 和图 8-8 显示了用户将在其浏览器中看到的最终结果。

A978-1-4842-0662-1_8_Fig8_HTML.jpg

图 8-8。

Our form in its final state, after the user has entered all of their information

A978-1-4842-0662-1_8_Fig7_HTML.jpg

图 8-7。

As the user enters their information, the form dynamically displays the appropriate feedback, based on the information that has been submitted. Here we notify the user that the “First Name” field should be at least three characters long

A978-1-4842-0662-1_8_Fig6_HTML.jpg

图 8-6。

Our form in its initial state. The example that is included with this chapter includes a preview of our scope’s model object that will automatically update as data is entered into the form

条件逻辑

表单通常需要额外的逻辑来确定在什么情况下应该显示某些问题或其他信息。一种常见的情况是,只有在用户选择了“电子邮件”作为首选联系方式后,表单才会要求用户输入电子邮件地址。我们的下一个例子,如清单 8-21 所示,将建立在前一个例子的基础上,展示如何通过使用ng-if指令来实现这样的逻辑。图 8-9 和图 8-10 显示了浏览器中渲染的最终结果。

A978-1-4842-0662-1_8_Fig10_HTML.jpg

图 8-10。

Our form displaying the appropriate input field once a value has been chosen for “Contact Method”

A978-1-4842-0662-1_8_Fig9_HTML.jpg

图 8-9。

The initial state of our form, before a value has been selected for “Contact Method” Listing 8-21. Excerpt from Our Example’s Template Showing the HTML Added to Our Previous Example

// example-form2/public/index.html

<div class="row">

<div ng-class="{

'has-error': !myForm.contact_method.$pristine && !myForm.contact_method.$valid,

'has-success': !myForm.contact_method.$pristine && myForm.contact_method.$valid

}">

<label>Contact Method</label>

<select

name="contact_method"

ng-model="model.contact_method"

ng-required="true">

<option value="">Select One</option>

<option value="email">Email</option>

<option value="phone">Phone</option>

</select>

<p ng-show="

!myForm.contact_method.$pristine &&

myForm.contact_method.$error.required">

Contact method is required.

</p>

</div>

<div ng-if="model.contact_method == 'email'" ng-class="{

'has-error': !myForm.email.$pristine && !myForm.email.$valid,

'has-success': !myForm.email.$pristine && myForm.email.$valid}">

<label>Email Address</label>

<input

type="email"

name="email"

ng-model="model.email"

ng-required="true">

<p ng-show="

!myForm.email.$pristine &&

myForm.email.$error.required">

Email address is required.

</p>

</div>

<div ng-if="model.contact_method == 'phone'" ng-class="{

'has-error': !myForm.phone.$pristine && !myForm.phone.$valid,

'has-success': !myForm.phone.$pristine && myForm.phone.$valid}">

<label>Phone Number</label>

<input

type="tel"

name="phone"

ng-model="model.phone"

ng-required="true">

<p ng-show="

!myForm.phone.$pristine &&

myForm.phone.$error.required">

Phone number is required.

</p>

</div>

</div>

可重复部分

对于最后一个例子,让我们看看 Angular 如何帮助我们根据用户的输入创建一个使用可重复部分的表单。在清单 8-22 中,我们创建了一个表单,要求用户为他们的每只宠物创建“类型”和“名字”条目。添加后,每个条目还会提供一个链接,允许用户删除它。

Listing 8-22. Template (and Accompanying Controller) Demonstrating the use of Repeatable Sections

// example-form3/public/index.html

<div class="row">

<div>

<h2>Pets</h2> <small><a ng-click="addPet()">Add Pet</a></small>

</div>

</div>

<div class="row" ng-repeat="pet in model.pets">

<div>

<label>Pet Type</label>

<select

ng-attr-name="pet_type{{$index}}"

ng-model="pet.type"

required>

<option value="">Select One</option>

<option value="cat">Cat</option>

<option value="dog">Dog</option>

<option value="Goldfish">Goldfish</option>

</select>

</div>

<div ng-class="{

'has-error': !myForm.last_name.$pristine && !myForm.last_name.$valid,

'has-success': !myForm.last_name.$pristine && myForm.last_name.$valid

}">

<label>

Pet’s Name <small class="pull-right">

<a ng-click="removePet(pet)">Remove Pet</a></small>

</label>

<input

type="text"

ng-attr-name="pet_name{{$index}}"

ng-model="pet.name"

ng-minlength="3"

ng-maxlength="15"

required>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.required">

Last name is required.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.minlength">

Last name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.maxlength">

Last name can have no more than 15 characters.

</p>

</div>

</div>

// example-form5/public/app/index.js

$scope.addPet = function() {

$scope.model.pets.push({});

};

$scope.removePet = function(pet) {

$scope.model.pets.splice($scope.model.pets.indexOf(pet), 1);

};

在清单 8-22 中,我们使用 Angular 的ng-repeat指令来迭代作用域的model.pets数组中的条目。注意我们如何能够在由ng-repeat创建的范围内引用{{$index}}来确定我们在数组中的当前位置。使用这些信息,我们为每个条目分配一个唯一的名称,以便进行验证。

我们的模板在该部分的顶部为用户提供了一个全局的“添加宠物”链接,当单击该链接时,调用已经在我们的控制器中定义的addPet()方法。这样做会将一个空对象附加到我们的作用域的model.pets数组中。当我们的ng-repeat指令遍历每个条目时,我们也为用户提供了一个删除链接。单击这个链接将当前条目从我们的model.pets数组传递给我们作用域的removePet()方法,后者将它从数组中移除。

图 8-11 显示了浏览器中渲染的最终结果。

A978-1-4842-0662-1_8_Fig11_HTML.jpg

图 8-11。

Our final example, as presented to the user

摘要

在这一章的开始,我们花了一点时间来比较传统的,“命令式”开发方法和 Angular 喜欢的“声明式”方法。虽然每种方法都有其优点和缺点,但很难否认 Angular 的方法特别适合解决与表单开发相关的问题。这不是巧合。

随着时间的推移,Angular 已经慢慢发展成为一个能够支持大型应用的框架,但这并不是它的初衷。Angular 最初的重点实际上是形式发展,Angular 背后的共同创造者之一 miko he very 欣然承认。这是一个需要注意的重要事实,因为它说明了 Angular 特别适合的项目类型(以及那些可能存在更合适的替代方案的项目)。

自从最初发布以来,Angular 吸引了大量的评论,大部分是正面的。该框架对指令和依赖注入的实现对客户端开发的前景产生了巨大的影响,并提出了开发人员应该从类似框架中得到什么的巨大问题。

也就是说,一段时间以来,对框架提出合理批评的开发人员数量一直在稳步增长。这种批评主要围绕着与 Angular 使用所谓的“脏检查”作为双向数据绑定实现的一部分有关的性能问题。这种批评是公平的,因为 Angular 的双向数据绑定实现效率很低。然而,根据作者的经验,Angular 的性能对于它所设计的绝大多数用例来说是绰绰有余的。在这本书出版的时候,一个重要的重写版本(2.0 版)也正在进行中,这将解决许多问题,如果不是全部的话。

如果你目前想知道 Angular 是否适合你的项目,没有简单的“是”或“否”的答案;这完全取决于你的具体需求。不过,总的来说,我是个超级粉丝。基于 Web 的应用变得越来越复杂,功能也越来越丰富。只有当开发人员拥有工具,能够抽象出简单接口背后的复杂性时,他们才能创建和维护这样的应用。通过使用诸如指令之类的工具,Angular 以非常令人兴奋的方式将这个广为人知的概念扩展到了 DOM。

相关资源

  • 角度:??、??、??

九、Kraken

一个组织的学习能力,以及快速将学习转化为行动的能力,是最终的竞争优势。——杰克·韦尔奇

就开发平台而言,Node 仍然是这个领域的新生事物。但是,正如许多知名和受尊敬的组织所证明的那样,JavaScript 作为服务器端语言所带来的好处已经对他们开发和部署软件的方式产生了巨大的影响。在对 Node 的众多赞誉中,道琼斯的项目经理迈克尔·约尔马克宣称“简单的事实是 Node 重新发明了我们创建网站的方式。开发人员只需几天,而不是几周就能构建关键功能。”( https://www.joyent.com/blog/the-node-firm-and-joyent-offer-node-js-training

LinkedIn 移动工程总监 Kiran Prasad 表示:“在服务器端,我们的整个移动软件堆栈完全构建在 Node 中。一个原因是规模。第二个是 Node,它向我们展示了巨大的性能提升。”( https://nodejs.org/download/docs/v0.6.7/

Node 无疑在开发社区中产生了一些相当大的波澜,尤其是当您考虑到它相对年轻的时候。尽管如此,我们还是要明确一点:这个平台远非完美。JavaScript 非常具有表现力和灵活性,但是它的灵活性也很容易被滥用。虽然基于节点的项目享受着快速的开发周期和令人印象深刻的性能提升,但它们经常受到语言本身和整个开发社区整体缺乏约定的困扰。虽然这个问题在小型、集中的开发团队中可能不明显,但随着团队规模和分布的增长,它会很快出现——只要问问 PayPal ( www.paypal-engineering.com/2013/11/ )的工程总监 Jeff Harrell 就知道了:

We especially like the ubiquity of Express, but we find it doesn't expand well in many development teams. Express is nondescript, and it allows you to set up the server in any way you think fit. This is very flexible, but not consistent with large teams ... As time goes by, as more and more teams choose node.js and turn it into Kraken.js, we see the emergence of patterns; It is not a framework in itself, but a convention layer above express, allowing it to be extended to larger development organizations. We want our engineers to focus on building their applications, not just setting up their environment.

本章将向您介绍 Kraken,一个由 PayPal 开发人员为您带来的基于 Express 的应用的安全和可伸缩层。本章涵盖的主题包括

  • 环境感知配置
  • 基于配置的中间件注册
  • 结构化路线注册
  • 灰尘模板引擎
  • 国际化和本地化
  • 增强的安全技术

Note

Kraken 建立在 Express 的坚实基础之上,Express 是 Node 的极简 web 框架,它的 API 已经成为这一类框架事实上的标准。因此,本章假定读者已经对 Express 有了基本的工作熟悉。本章还讨论了本书 Grunt、Yeoman 和 Knex/Bookshelf 章节中的概念。如果您不熟悉这些主题,您可能希望在继续之前阅读这些章节。

环境感知配置

随着应用的开发、测试、试运行和部署,它们自然会经历一系列相应的环境,每个环境都需要自己独特的配置规则集。例如,考虑图 9-1 ,它展示了应用在持续集成和交付部署管道中移动的过程。

A978-1-4842-0662-1_9_Fig1_HTML.gif

图 9-1。

Application that requires unique settings based on its environment

随着图 9-1 中的应用在每个环境中前进,告诉它如何连接到它所依赖的各种外部服务的设置必须相应地改变。Kraken 的confit库通过为节点应用提供一个简单的、环境感知的配置层,为开发人员提供了实现这一目标的标准约定。

Confit 通过加载一个默认的 JSON 配置文件(通常命名为config.json)来运行。Confit 然后试图根据环境变量NODE_ENV的值加载一个额外的配置文件。如果找到特定于环境的配置文件,它指定的任何设置都会递归地与默认配置中定义的设置合并。

本章的confit-simple项目提供了一个简单的应用,它依赖于confit来确定其配置。清单 9-1 展示了confit初始化的过程,而清单 9-2 展示了项目的/config文件夹的内容,其中confit被指示搜索配置文件。

Listing 9-1. Initializing confit

// confit-simple/index.js

var confit = require('confit');

var prettyjson = require('prettyjson');

var path = require('path');

var basedir = path.join(__dirname, 'config');

confit(basedir).create(function(err, config) {

if (err) {

console.log(err);

process.exit();

}

console.log(prettyjson.render({

'email': config.get('email'),

'cache': config.get('cache'),

'database': config.get('database')

}));

});

Listing 9-2. Contents of the /config Folder

// Default configuration

// confit-simple/config/config.json

{

// SMTP server settings

"email": {

"hostname": "email.mydomain.com",

"username": "user",

"password": "pass",

"from": "My Application <noreply@myapp.com>"

},

"cache": {

"redis": {

"hostname": "cache.mydomain.com",

"password": "redis"

}

}

}

// Development configuration

// confit-simple/config/development.json

{

"database": {

"postgresql": {

"hostname": "localhost",

"username": "postgres",

"password": "postgres",

"database": "myapp"

}

},

"cache": {

"redis": {

"hostname": "localhost",

"password": "redis"

}

}

}

// Production configuration

// confit-simple/config/production.json

{

"database": {

"postgresql": {

"hostname": "db.myapp.com",

"username": "postgres",

"password": "super-secret-password",

"database": "myapp"

}

},

"cache": {

"redis": {

"hostname": "redis.myapp.com",

"password": "redis"

}

}

}

在继续之前,请注意我们项目的默认配置文件在email属性下提供了电子邮件服务器的连接设置,而项目的特定于环境的配置文件都没有提供这样的信息。相比之下,默认配置在嵌套的cache:redis属性下为 Redis 缓存服务器提供连接设置,而两种特定于环境的配置都为此属性提供覆盖信息。

还要注意,默认配置文件在email属性上方包含一个注释。注释不是 JSON 规范的一部分,如果我们试图使用 Node 的require()方法来解析这个文件的内容,通常会导致抛出错误。然而,Confit 会在试图解析文件之前去掉这样的注释,允许我们根据需要在配置中嵌入注释。

清单 9-3 显示了当项目在NODE_ENV环境变量设置为development的情况下运行时,记录到控制台的输出。

Listing 9-3. Running the confit-simple Project in development Mode

$ export NODE_ENV=development && node index

email:

hostname: email.mydomain.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

cache:

redis:

hostname: localhost

password: redis

database:

postgresql:

hostname: localhost

username: postgres

password: postgres

database: myapp

Note

在清单 9-3 中,从终端运行$ export NODE_ENV=development来设置NODE_ENV环境变量的值。该命令仅适用于 Unix 和类 Unix 系统(包括 OS X)。Windows 用户将需要运行$ set NODE_ENV=development。同样重要的是要记住,如果没有设置NODE_ENV环境变量,confit将假设应用运行在development环境中。

如清单 9-3 所示,confit通过将config/development.json环境配置文件的内容与默认的config/config.json文件合并来编译我们项目的配置对象,优先于development.json中指定的任何设置。因此,我们的配置对象继承了仅存在于config.json中的email设置,以及在开发环境的配置文件中定义的cachedatabase设置。在清单 9-1 中,这些设置通过使用配置对象的get()方法来访问。

Note

除了访问顶级配置设置(例如,database,如清单 9-1 所示),我们的配置对象的get()方法还可以用来访问使用:作为分隔符的深层嵌套配置设置。例如,我们可以用config.get('database:postgresql')直接引用项目的postgresql设置。

在清单 9-4 中,我们再次运行confit-simple项目,只是这次我们用值production设置了NODE_ENV环境变量。正如所料,输出显示我们的配置对象从config.json继承了email属性,同时也从production.json继承了cachedatabase属性。

Listing 9-4. Running the confit-simple Project in production Mode

$ export NODE_ENV=production && node index

email:

hostname: email.mydomain.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

cache:

redis:

hostname: redis.myapp.com

password: redis

database:

postgresql:

hostname: db.myapp.com

username: postgres

password: super-secret-password

database: myapp

游击手

正如前面的例子所示,Confit 是为处理 JSON 配置文件而设计的。作为一种配置格式,JSON 很容易使用,但是在灵活性方面偶尔会有一些不足。Confit 有益地弥补了这个缺点,它支持插件,称之为“游击手处理程序”。举例来说,考虑清单 9-5 ,其中使用了包含在confit’s核心库中的两个游击手处理程序importconfig

Listing 9-5. Demonstrating the Use of the import and config Shortstop Handlers

// confit-shortstop/config/config.json

{

// The import handler allows us to set a property’s value to the contents

// of the specified JSON configuration file.

"app": "import:./app",

// The config handler allows us to set a property’s value to that of the

// referenced property. Note the use of the . character as a delimiter,

// in this instance.

"something_else": "config:app.base_url"

}

// confit-shortstop/config/app.json

{

// The title of the application

"title": "My Demo Application",

// The base URL at which the web client can be reached

"base_url": "https://myapp.com

// The base URL at which the API can be reached

"base_api_url": "https://api.myapp.com

}

清单 9-6 显示了本章的confit-shortstop项目运行时打印到控制台的输出。在这个例子中,import shortstop 处理程序允许我们用一个单独的 JSON 文件的内容填充app属性,使我们能够将特别大的配置文件分解成更小、更容易管理的组件。config处理程序允许我们通过引用另一个部分中预先存在的值来设置配置值。

Listing 9-6. Output of This Chapter’s confit-shortstop Project

$ node index.js

app:

title:        My Demo Application

base_url:https://myapp.com

base_api_url:https://api.myapp.com

something_else:https://myapp.com

虽然confit本身只包括对我们刚刚提到的两个游击手处理程序(importconfig)的支持,但是在shortstop-handlers模块中可以找到几个非常有用的附加处理程序。我们来看四个例子。

清单 9-7 显示了本章confit-shortstop-extras项目的主脚本(index.js)。这个脚本很大程度上反映了我们已经在清单 9-1 中看到的那个,有一些小的不同。在这个例子中,额外的处理程序是从shortstop-handlers模块导入的。此外,不是通过传递项目的config文件夹(basedir)的路径来实例化confit,而是传递一个选项对象。在这个对象中,我们继续为basedir指定一个值,但是我们也传递一个protocols对象,为confit提供我们想要使用的附加游击手处理程序的引用。

Listing 9-7. index.js Script from the confit-shortstop-extras Project

// confit-shortstop-extras/index.js

var confit = require('confit');

var handlers = require('shortstop-handlers');

var path = require('path');

var basedir = path.join(__dirname, 'config');

var prettyjson = require('prettyjson');

confit({

'basedir': basedir,

'protocols': {

// The file handler allows us to set a property’s value to the contents

// of an external (non-JSON) file. By default, the contents of the file

// will be loaded as a Buffer.

'file': handlers.file(basedir /* Folder from which paths should be resolved */, {

'encoding': 'utf8' // Convert Buffers to UTF-8 strings

}),

// The require handler allows us to set a property’s value to that

// exported from a module.

'require': handlers.require(basedir),

// The glob handler allows us to set a property’s value to an array

// containing files whose names match a specified pattern

'glob': handlers.glob(basedir),

// The path handler allows us to resolve relative file paths

'path': handlers.path(basedir)

}

}).create(function(err, config) {

if (err) {

console.log(err);

process.exit();

}

console.log(prettyjson.render({

'app': config.get('app'),

'something_else': config.get('something_else'),

'ssl': config.get('ssl'),

'email': config.get('email'),

'images': config.get('images')

}));

});

在本例中,使用了四个额外的游击手处理器(从shortstop-handlers模块导入):

  • file:使用指定文件的内容设置属性
  • require:使用节点模块的导出值设置属性(对于只能在运行时确定的动态值特别有用)
  • glob:将属性设置为包含文件名与指定模式匹配的文件的数组
  • path:将属性设置为被引用文件的绝对路径

清单 9-8 显示了这个项目的默认配置文件。最后,清单 9-9 显示了这个项目运行时打印到控制台的输出。

Listing 9-8. Default Configuration File for the confit-shortstop-extras Project

// confit-shortstop-extras/config/config.json

{

"app": "import:./app",

"something_else": "config:app.base_url",

"ssl": {

"certificate": "file:./certificates/server.crt",

"certificate_path": "path:./certificates/server.crt"

},

"email": "require:./email",

"images": "glob:../publimg/**/*.jpg"

}

Listing 9-9. Output from the confit-shortstop-extras Project

$ export NODE_ENV=development && node index

app:

title:        My Demo Application

base_url:https://myapp.com

base_api_url:https://api.myapp.com

something_else:https://myapp.com

ssl:

certificate_path: /opt/confit-shortstop-extras/config/certificates/server.crt

certificate:

"""

-----BEGIN CERTIFICATE-----

MIIDnjCCAoYCCQDy8G1RKCEz4jANBgkqhkiG9w0BAQUFADCBkDELMAkGA1UEBhMC

VVMxEjAQBgNVBAgTCVRlbm5lc3NlZTESMBAGA1UEBxMJTmFzaHZpbGxlMSEwHwYD

VQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFDASBgNVBAMUCyoubXlhcHAu

Y29tMSAwHgYJKoZIhvcNAQkBFhFzdXBwb3J0QG15YXBwLmNvbTAeFw0xNTA0MTkw

MDA4MzRaFw0xNjA0MTgwMDA4MzRaMIGQMQswCQYDVQQGEwJVUzESMBAGA1UECBMJ

VGVubmVzc2VlMRIwEAYDVQQHEwlOYXNodmlsbGUxITAfBgNVBAoTGEludGVybmV0

IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAxQLKi5teWFwcC5jb20xIDAeBgkqhkiG

9w0BCQEWEXN1cHBvcnRAbXlhcHAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A

MIIBCgKCAQEAyBFxMVlMjP7VCU5w70okfJX/oEytrQIl1ZOAXnErryQQWwZpHOlu

ZhTuZ8sBJmMBH3jju+rx4C2dFlXxWDRp8nYt+qfd1aiBKjYxMda2QMwXviT0Td9b

kPFBCaPQpMrzexwTwK/edoaxzqs/IxMs+n1Pfvpuw0uPk6UbwFwWc8UQSWrmbGJw

UEfs1X9kOSvt85IdrdQ1hQP2fBhHvt/xVVPfi1ZW1yBrWscVHBOJO4RyZSGclayg

7LP+VHMvkvNm0au/cmCWThHtRt3aXhxAztgkI9IT2G4B9R+7ni8eXw5TLl65bhr1

Gt7fMK2HnXclPtd3+vy9EnM+XqYXahXFGwIDAQABMA0GCSqGSIb3DQEBBQUAA4IB

AQDH+QmuWk0Bx1kqUoL1Qxtqgf7s81eKoW5X3Tr4ePFXQbwmCZKHEudC98XckI2j

qGA/SViBr+nbofq6ptnBhAoYV0IQd4YT3qvO+m3otGQ7NQkO2HwD3OUG9khHe2mG

k8Z7pF0pwu3lbTGKadiJsJSsS1fJGs9hy2vSzRulgOZozT3HJ+2SJpiwy7QAR0aF

jqMC+HcP38zZkTWj1s045HRCU1HdPjr0U3oJtupiU+HAmNpf+vdQnxS6aM5nzc7G

tZq74ketSxEYXTU8gjfMlR4gBewfPmu2KGuHNV51GAjWgm9wLfPFvMMYjcIEPB3k

Mla9+pYx1YvXiyJmOnUwsaop

-----END CERTIFICATE-----

"""

email:

hostname: smtp.myapp.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

images:

- /opt/confit-shortstop-extras/publimg/cat1.jpg

- /opt/confit-shortstop-extras/publimg/cat2.jpg

- /opt/confit-shortstop-extras/publimg/cat3.jpg

基于配置的中间件注册

Express 通过一系列可配置的“中间件”功能来处理传入的 HTTP 请求,如图 9-2 所示。

A978-1-4842-0662-1_9_Fig2_HTML.gif

图 9-2。

Series of Express middleware calls

在这个过程的每一步,主动中间件功能都能够

  • 修改传入的请求对象
  • 修改传出响应对象
  • 执行附加代码
  • 结束请求-响应循环
  • 调用系列中的下一个中间件函数

举例来说,考虑清单 9-10 ,它展示了一个简单的 Express 应用,该应用依赖于三个中间件模块:morgancookie-parserratelimit-middleware。当该应用处理传入的 HTTP 请求时,会发生以下步骤:

The morgan module logs the request to the console.   The cookie-parser module parses data from the request’s Cookie header and assigns it to the request object’s cookies property.   The ratelimit-middleware module rate-limits clients that attempt to access the application too frequently.   Finally, the appropriate route handler is called.   Listing 9-10. Express Application That Relies on Three Middleware Modules

// middleware1/index.js

var express = require('express');

// Logs incoming requests

var morgan = require('morgan');

// Populates req.cookieswith data parsed from theCookie header

var cookieParser = require('cookie-parser');

// Configurable API rate-limiter

var rateLimit = require('ratelimit-middleware');

var app = express();

app.use(morgan('combined'));

app.use(cookieParser());

app.use(rateLimit({

'burst': 10,

'rate': 0.5,

'ip': true

}));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

这种方法为开发人员提供了相当大的灵活性,允许他们在请求-响应周期的任何时候执行自己的逻辑。它还允许 Express 通过将执行不重要任务的责任委托给第三方中间件模块来维护相对较小的内存占用。尽管这种方法很灵活,但随着应用和开发团队的规模和复杂性的增长,管理起来也会很麻烦。

Kraken 的meddleware模块通过为 Express 应用提供基于配置的中间件注册流程,简化了中间件管理。这样,它为开发人员提供了一种标准化的方法来指定 Express 应用应该依赖哪些中间件模块,应该以什么顺序加载它们,以及应该传递给每个模块的选项。清单 9-11 显示了前一个例子的更新版本,其中meddleware模块管理所有中间件功能的注册。

Listing 9-11. Configuration-based Middleware Registration with the meddleware Module

// middleware2/index.js

var express = require('express');

var confit = require('confit');

var meddleware = require('meddleware');

var app = express();

var path = require('path');

confit(path.join(__dirname, 'config')).create(function(err, config) {

app.use(meddleware(config.get('middleware')));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

});

// middleware2/config/config.json

{

"middleware": {

"morgan": {

// Toggles the middleware module on / off

"enabled": true,

// Specifies the order in which middleware should be registered

"priority": 10,

"module": {

// The name of an installed module (or path to a module file)

"name": "morgan",

// Arguments to be passed to the module’s factory function

"arguments": ["combined"]

}

},

"cookieParser": {

"enabled": true,

"priority": 20,

"module": {

"name": "cookie-parser"

}

},

"rateLimit": {

"enabled": true,

"priority": 30,

"module": {

"name": "ratelimit-middleware",

"arguments": [{

"burst": 10,

"rate": 0.5,

"ip": true

}]

}

}

}

}

在 Krakenmeddleware模块的帮助下,该应用中第三方中间件管理的所有方面都从代码转移到了标准化的配置文件中。结果是应用不仅更有条理,而且更容易理解和修改。

事件通知

由于中间件功能是通过meddleware模块向 Express 注册的,相应的事件由应用发出,为开发人员提供了一种简单的方法来确定加载什么中间件功能以及以什么顺序加载(参见清单 9-12 )。

Listing 9-12. Events Are Emitted As Middleware s Registered via the meddleware Module

var express = require('express');

var confit = require('confit');

var meddleware = require('meddleware');

var app = express();

var path = require('path');

confit(path.join(__dirname, 'config')).create(function(err, config) {

// Listening to all middleware registrations

app.on('middleware:before', function(data) {

console.log('Registering middleware: %s', data.config.name);

});

// Listening for a specific middleware registration event

app.on('middleware:before:cookieParser', function(data) {

console.log('Registering middleware: %s', data.config.name);

});

app.on('middleware:after', function(data) {

console.log('Registered middleware: %s', data.config.name);

});

app.on('middleware:after:cookieParser', function(data) {

console.log('Registered middleware: %s', data.config.name);

});

app.use(meddleware(config.get('middleware')));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

});

结构化路线注册

在上一节中,您了解了 Kraken 的meddleware模块如何通过将加载和配置这些功能所需的逻辑移动到标准化的 JSON 配置文件中来简化中间件功能注册。同样,Kraken 的enrouten模块也运用了同样的概念,将结构带到了经常找不到的地方——快捷路线。

具有少量路线的简单快速应用通常可以使用单个模块来完成,在该模块中定义了每个可用的路线。然而,随着应用的深度和复杂性逐渐增加,这样的组织结构(或缺乏组织结构)会很快变得难以管理。Enrouten 通过提供三种方法来解决这一问题,通过这三种方法可以以一致的结构化方式定义快捷路线。

索引配置

使用 enrouten 的index配置选项,可以指定单个模块的路径。然后,该模块将被加载,并被传递给一个已被挂载到根路径的 Express 路由器实例。该选项为开发人员提供了定义路线的最简单方法,因为它不强制任何特定类型的组织结构。虽然这个选项为新的应用提供了一个很好的起点,但是必须小心不要滥用它。这个选项经常与 enrouten 的directoryroutes配置选项结合使用,我们将很快介绍这两个选项。

清单 9-13 显示了一个简单的 Express 应用,它的路线是在confitmeddlewareenrouten的帮助下配置的,还有附带的confit配置文件。清单 9-14 显示了传递给 enrouten 的index选项的模块内容。本节中的后续示例将以此示例为基础。

Listing 9-13. Express Application Configured with confit, meddleware, and enrouten

// enrouten-index/index.js

var express = require('express');

var confit = require('confit');

var handlers = require('shortstop-handlers');

var meddleware = require('meddleware');

var path = require('path');

var configDir = path.join(__dirname, 'config');

var app = express();

confit({

'basedir': configDir,

'protocols': {

'path': handlers.path(configDir),

'require': handlers.require(configDir)

}

}).create(function(err, config) {

app.use(meddleware(config.get('middleware')));

app.listen(7000);

console.log('App is available at:``http://localhost:7000

});

// enrouten-index/config/config.json

{

"middleware": {

"morgan": {

"enabled": true,

"priority": 10,

"module": {

"name": "morgan",

"arguments": ["combined"]

}

},

"enrouten": {

"enabled": true,

"priority": 30,

"module": {

"name": "express-enrouten",

"arguments": [

{

"index": "path:../routes/index"

}

]

}

}

}

}

Listing 9-14. Contents of the Module Passed to Enrouten’s index Option

// enrouten-index/routes/index.js

module.exports = function(router) {

router.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

router.route('/api/v1/colors')

.get(function(req, res, next) {

res.send([

'blue', 'green', 'red', 'orange', 'white'

]);

});

};

目录配置

清单 9-15 展示了 enrouten 的directory配置选项的使用。设置后,enrouten将递归扫描指定文件夹的内容,搜索导出接受单个参数的函数的模块。对于它找到的每个模块,enrouten将传递一个 Express 路由器实例,该实例已被安装到由该模块在目录结构中的位置预先确定的路径上,这是一种“约定优于配置”的方法。

Listing 9-15. Setting Enrouten’s directory Configuration Option

// enrouten-directory/config/config.json

{

"middleware": {

"enrouten": {

"enabled": true,

"priority": 10,

"module": {

"name": "express-enrouten",

"arguments": [{ "directory": "path:../routes" }]

}

}

}

}

图 9-3 显示了该项目的/routes文件夹的结构,清单 9-16 显示了/routes/api/v1/accounts/index.js模块的内容。基于这个模块在/routes文件夹中的位置,它定义的每条路由的 URL 都将以/api/v1/accounts为前缀。

A978-1-4842-0662-1_9_Fig3_HTML.jpg

图 9-3。

Structure of This Project’s /routes Folder Listing 9-16. The /api/v1/accounts Controller

// enrouten-directory/routes/api/v1/accounts/index.js

var _ = require('lodash');

var path = require('path');

module.exports = function(router) {

var accounts = require(path.join(APPROOT, 'models', 'accounts'));

/**

* @route /api/v1/accounts

*/

router.route('/')

.get(function(req, res, next) {

res.send(accounts);

});

/**

* @route /api/v1/accounts/:account_id

*/

router.route('/:account_id')

.get(function(req, res, next) {

var account = _.findWhere(accounts, {

'id': parseInt(req.params.account_id, 10)

});

if (!account) return next(new Error('Account not found'));

res.send(account);

});

};

路线配置

Enrouten 的directory配置选项通过基于指定文件夹的布局自动确定应用 API 的结构,提供了一种支持“约定胜于配置”的方法。这种方法提供了一种以有组织和一致的方式构建快速路线的快速简单的方法。然而,复杂的应用可能最终会发现这种方法相当局限。

具有大量复杂、深度嵌套路由的 API 的应用可能会从 enrouten 的routes配置选项中获得更大的好处,该选项允许开发人员为应用的每个路由创建完全独立的模块。然后在配置文件中指定 API 端点、方法、处理程序和特定于路由的中间件——这是一种有组织的方法,允许最大程度的灵活性,但代价是稍微有些冗长。

清单 9-17 显示了本章enrouten-routes项目配置文件的摘录。这里,我们将一个对象数组传递给 enrouten 的routes配置选项,其中的条目描述了 Express 提供的各种路线。注意,除了指定路由、HTTP 方法和处理程序之外,每个条目还可以选择指定一组特定于路由的中间件功能。因此,这个应用能够应用一个中间件功能,负责在一个路由接一个路由的基础上授权进入的请求。如清单 9-17 所示,auth中间件功能没有应用于用户最初登录的路径,允许他们在发出后续请求之前登录。

Listing 9-17. Specifying Individual Routes via Enrouten’s routes Configuration Option

// enrouten-routes/config/config.json (excerpt)

"arguments": [{

"index": "path:../routes",

"routes": [

{

"path": "/api/v1/session",

"method": "POST",

"handler": "require:../routes/api/v1/session/create"

},

{

"path": "/api/v1/session",

"method": "DELETE",

"handler": "require:../routes/api/v1/session/delete",

"middleware": [

"require:../middleware/auth"

]

},

{

"path": "/api/v1/users",

"method": "GET",

"handler": "require:../routes/api/v1/users/list",

"middleware": [

"require:../middleware/auth"

]

},

// ...

]

}]

清单 9-18 显示了负责处理这个应用的/api/v1/users路由的传入 GET 请求的模块的内容。该模块导出一个函数,该函数接受标准的req, res, next快速路由处理程序签名。

Listing 9-18. The /routes/api/v1/users/list Route Handler

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

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

models.User.fetchAll()

.then(function(users) {

res.send(users);

})

.catch(next);

};

灰尘模板

许多流行的 JavaScript 模板引擎(例如,Mustache 和 Handlebars)标榜自己是“无逻辑的”——这一属性描述了它们帮助开发人员在应用的业务逻辑和表示层之间保持清晰分离的能力。如果维护得当,这种分离使得在呈现给用户的界面中发生重大变化成为可能,同时只需要最少的(如果有的话)幕后伴随变化(反之亦然)。

所谓的“无逻辑”模板引擎通过强制执行一组严格的规则来实现这一目标,这些规则防止开发人员创建通常被称为“意大利面条代码”的代码,这是一种以难以理解甚至更难解开的方式将代码与表示相结合的混乱局面。任何曾经处理过类似于清单 9-19 中所示的 PHP 脚本的人都会立即理解在这两个关注点之间保持一层隔离的重要性。

Listing 9-19. Spaghetti Code, an Unmaintainable Mess

<?php

print "<!DOCTYPE html><head><title>";

$result = mysql_query("SELECT * FROM settings") or die(mysql_error());

print $result[0]["title"] . "</title></head><body><table>";

print "<thead><tr><th>First Name</th><th>Last Name</th></tr></thead><tbody>";

$users = mysql_query("SELECT * FROM users") or die(mysql_error());

while ($row = mysql_fetch_assoc($users)) {

print "<tr><td>" . $row["first_name"] . "</td><td>" . $row["last_name"] . "</td></tr>";

}

print "</tbody></table></body></html>";

?>

无逻辑模板引擎试图通过禁止在应用的视图中使用逻辑来防止开发人员创建杂乱无章的代码。这种模板通常能够引用所提供的信息有效载荷中的值,遍历数组,并基于简单的布尔逻辑打开和关闭其内容的特定部分。

不幸的是,这种相当严厉的方法经常带来它希望防止的问题,尽管是以一种意想不到的方式。尽管无逻辑的模板引擎(如 Handlebars)阻止在模板本身中使用逻辑,但它们并没有首先否定逻辑存在的必要性。准备数据以供模板使用所需的逻辑必须存在于某个地方,通常,使用无逻辑模板引擎会导致与表示相关的逻辑溢出到业务层。

Dust 是 Kraken 青睐的 JavaScript 模板引擎,它试图通过采用一种更好地被认为是“少逻辑”而不是严格意义上的“少逻辑”的方法来解决这个问题通过允许开发人员在他们的模板中以“助手”的形式嵌入稍微高级一点的逻辑,Dust 允许表示层逻辑留在它应该在的地方,表示层,而不是业务层。

背景和参考

当使用 Dust 模板时,两个主要组件开始发挥作用:模板本身和一个(可选的)对象文字,该对象文字包含要从模板中引用的任何数据。在清单 9-20 中,这个过程由一个指定 Dust 作为其渲染引擎的 Express 应用演示。注意本例中adaro模块的使用。adaro模块作为 Dust 的一个方便的包装器,抽象出一些额外的设置,否则这些设置将是 Dust 与 Express 集成所必需的。默认情况下,它还包括一些方便的助手函数,我们将在本章后面介绍。

Listing 9-20. Express Application Using Dust As Its Rendering Engine

// dust-simple/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

/**

* By default, Dust will cache the contents of an application’s templates as they are

* loaded. In a production environment, this is usually the preferred behavior.

* This behavior will be disabled in this chapter’s examples, allowing you to modify

* templates and see the result without having to restart Express.

*/

app.engine('dust', adaro.dust({

'cache': false

}));

app.set('view engine', 'dust');

app.use('/', express.static('./public'));

var data = {

'report_name': 'North American Countries',

'languages': ['English', 'Spanish'],

'misc': {

'total_population': 565000000

},

'countries': [

{

'name': 'United States',

'population': 319999999,

'english': true,

'capital': { 'name': 'Washington D.C.', 'population': 660000 }

},

{

'name': 'Mexico',

'population': 118000000,

'english': false,

'capital': { 'name': 'Mexico City', 'population': 9000000 }

},

{

'name': 'Canada',

'population': 35000000,

'english': true,

'capital': { 'name': 'Ottawa', 'population': 880000 }

}

]

};

app.get('/', function(req, res, next) {

res.render('main', data);

});

app.listen(8000);

在清单 9-20 中,一个包含北美国家数组的对象文字(被 Dust 称为“上下文”)被传递给一个 Dust 模板,其内容如清单 9-21 所示。在这个模板中,通过将所需的键放在一对花括号中来引用数据。嵌套属性也可以通过使用点符号({misc.total_population})来引用。

Listing 9-21. Accompanying main Dust Template

// dust-simple/views/main.dust

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>App</title>

<link href="/css/style.css" rel="stylesheet">

</head>

<body>

{! Dust comments are created using this format. Data is referenced by wrapping the

desired key within a single pair of curly brackets, as shown below. !}

<h1>{report_name}</h1>

<table>

<thead>

<tr>

<th>Name</th>

<th>Population</th>

<th>Speaks English</th>

<th>Capital</th>

<th>Population of Capital</th>

</tr>

</thead>

<tbody>

{! Templates can loop through iterable objects !}

{#countries}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{?english}Yes{:else}No{/english}</td>

{#capital}

<td>{name}</td>

<td>{population}</td>

{/capital}

</tr>

{/countries}

</tbody>

</table>

<h2>Languages</h2>

<ul>

{#languages}

<li>{.}</li>

{/languages}

</ul>

<h2>Total Population: {misc.total_population}</h2>

</body>

</html>

部分

在 Dust 进行渲染的过程中,它通过将一个或多个“上下文”应用到相关的模板来获取引用的数据。最简单的模板只有一个上下文,引用传递的 JSON 对象的最外层。例如,考虑清单 9-21 中所示的模板,其中使用了两个引用{report_name}{misc.total_population}。Dust 通过在清单 9-20 所示的对象中搜索匹配的属性(从最外层开始)来处理这些引用。

Dust 部分提供了一种方便的方法,通过这种方法可以创建额外的上下文,允许模板访问嵌套的属性,而不需要从最外层开始的引用。例如,考虑清单 9-22 ,其中创建了一个新的上下文{#misc}...{/misc},允许使用更短的语法访问嵌套的属性。

Listing 9-22. Creating a New Dust Section

// Template

<h1>{report_name}</h1>

{#misc}

<p>Total Population: {total_population}</p>

{/misc}

// Rendered Output

<h1>Information About North America</h1>

<p>Total Population: 565000000</p>

循环

在前面的例子中,创建了一个新的 Dust 部分(和相应的上下文)。因此,新部分的内容可以直接访问被引用的对象文字的属性。同样,Dust 部分也可以用来遍历数组的条目。清单 9-23 通过创建一个引用countries数组的新部分来演示这个过程。与上一个例子中只应用了一次的部分不同,{#countries} ... {/countries}部分将被应用多次,对它引用的数组中的每个条目应用一次。

Listing 9-23. Iterating Through an Array with Sections

// Template

{#countries}

{! The current position within the iteration can be referenced at $idx !}

{! The size of the object through which we are looping can be referenced at $len !}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{capital.name}</td>

<td>{capital.population}</td>

</tr>

{/countries}

// Rendered Output

<tr>

<td>United States</td>

<td>319999999</td>

<td>Washington D.C.</td>

<td>660000</td>

</tr>

<tr>

<td>Mexico</td>

<td>118000000</td>

<td>Mexico City</td>

<td>9000000</td>

</tr>

<tr>

<td>Canada</td>

<td>35000000</td>

<td>Ottawa</td>

<td>880000</td>

</tr>

清单 9-24 展示了一个模板遍历一个数组的过程,该数组的条目是原始数据类型(即不是对象)。对于每次迭代,值本身可以通过{.}语法直接引用。

Listing 9-24. Iterating Through an Array Containing Primitive Data Types

// Template

<ul>

{#languages}<li>{.}</li>{/languages}

</ul>

// Rendered Output

<ul>

<li>English</li>

<li>Spanish</li>

</ul>

制约性

Dust 基于是否通过简单的真实性测试,为有条件地呈现内容提供内置支持。清单 9-25 中显示的模板通过根据每个国家的english属性是否引用“真”值来呈现文本“是”或“否”来演示这个概念。

Listing 9-25. Applying Conditionality Within a Dust Template

// Template

{#countries}

<tr>

<td>{name}</td>

<td>{?english}Yes{:else}No{/english}</td>

{!

The opposite logic can be applied as shown below:

<td>{^english}No{:else}Yes{/english}</td>

!}

</tr>

{/countries}

// Rendered Output

<tr>

<td>United States</td>

<td>Yes</td>

</tr>

<tr>

<td>Mexico</td>

<td>No</td>

</tr>

<tr>

<td>Canada</td>

<td>Yes</td>

</tr>

Note

在模板中应用条件性时,理解 Dust 将应用的规则很重要,因为它决定了属性的“真实性”。空字符串、布尔 false、空数组、null 和 undefined 都被认为是 false。数字 0、空对象以及基于字符串的“0”、“null”、“undefined”和“false”都被认为是真的。

部分的

Dust 最强大的特性之一 partials 允许开发者在其他模板中包含模板。因此,复杂的文档可以被分解成更小的部分(即“片段”),以便于管理和重用。清单 9-26 中显示了一个演示这个过程的简单例子。

Listing 9-26. Dust Template That References an External Template (i.e., “Partial”)

// Main Template

<h1>{report_name}</h1>

<p>Total Population: {misc.total_population}</p>

{>"countries"/}

{!

In this example, an external template - countries - is included by a parent

template which references it by name (using a string literal that is specified

within the template itself). Alternatively, the name of the external template

could have been derived from a value held within the template’s context, using

Dust’s support for "dynamic" partials. To do so, we would have wrapped the

``countries string in a pair of curly brackets, as shown here:

{>"{countries}"/}

!}

// "countries" template

{#countries}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{capital.name}</td>

<td>{capital.population}</td>

</tr>

{/countries}

// Rendered Output

<h1>Information About North America</h1>

<p>Total Population: 565000000</p>

<tr>

<td>United States</td>

<td>Yes</td>

</tr>

<tr>

<td>Mexico</td>

<td>No</td>

</tr>

<tr>

<td>Canada</td>

<td>Yes</td>

</tr>

阻碍

考虑一个常见的场景,其中创建了一个由多个页面组成的复杂 web 应用。这些页面中的每一个都显示一组独特的内容,同时与其他页面共享通用元素,如页眉和页脚。借助 Dust blocks,开发人员可以在一个位置定义这些共享元素。之后,希望从它们继承的模板可以这样做,同时还保留了在必要时覆盖其内容的能力。

让我们看一个例子,应该有助于澄清这一点。清单 9-27 显示了定义站点整体布局的 Dust 模板的内容。在这个实例中,指定了一个默认的页面标题{+title}App{/title},以及一个用于正文内容的空占位符。

Listing 9-27. Dust Block from Which Other Templates Can Inherit

// dust-blocks/views/shared/base.dust

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>{+title}App{/title}</title>

<link href="/css/style.css" rel="stylesheet">

</head>

<body>

{+bodyContent/}

</body>

</html>

清单 9-28 显示了一个 Dust 模板的内容,它继承了清单 9-27 中的例子。它首先将父模板作为一部分嵌入到自身中({>"shared/base"/})。接下来,它将内容注入到已定义的{+bodyContent/}占位符{<bodyContent}...{/bodyContent}中。在这个例子中,我们的模板选择不覆盖父模板中指定的默认页面标题。

Listing 9-28. Dust Template Inheriting from a Block

// dust-blocks/views/main.dust

{>"shared/base"/}

{<bodyContent}

<p>Hello, world!</p>

{/bodyContent}

过滤

Dust 包括几个内置的过滤器,允许模板在渲染之前修改值。举例来说,考虑这样一个事实,Dust 将自动对模板中引用的任何值进行 HTML 转义。换句话说,如果一个上下文包含一个content键,其值与此处显示的值相匹配:

<script>doBadThings();</script>

Dust 会自动将该值呈现为

&lt;script&gt;doBadThings()&lt;/script&gt;

虽然我们在这里看到的行为通常是需要的,但遇到需要禁用这种行为的情况并不罕见。这可以通过使用过滤器来实现:

{content|s}

在本例中,|s过滤器禁用引用值的自动转义。表 9-1 包含 Dust 提供的内置过滤器列表。

表 9-1。

List of Built-in Filters Provided by Dust

| 过滤器 | 描述 | | --- | --- | | `s` | 禁用 html 转义 | | `h` | 强制 HTML 转义 | | `j` | 强制 JavaScript 转义 | | `u` | 用`encodeURI()`编码 | | `uc` | 用`encodeURIComponent()`编码 | | `js` | Stringifies 一个 JSON 文本 | | `jp` | 解析 JSON 字符串 |

创建自定义过滤器

除了提供几个核心过滤器之外,Dust 还让开发人员可以通过创建他们自己的定制过滤器来轻松扩展这种行为,如清单 9-29 所示。在本例中,创建了一个定制的formatTS过滤器。当被应用时,该过滤器将把引用的时间戳转换成人类可读的格式(例如,1776 年 7 月 4 日)。

Listing 9-29. Defining a Custom Dust Filter

// dust-filters/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

var moment = require('moment');

app.engine('dust', adaro.dust({

'cache': false,

'helpers': [

function(dust) {

dust.filters.formatTS = function(ts) {

return moment(ts, 'X').format('MMM. D, YYYY');

};

}

]

}));

app.set('view engine', 'dust');

app.use('/', express.static('./public'));

app.get('/', function(req, res, next) {

res.render('main', {

'events': [

{ 'label': 'Moon Landing', 'ts': -14558400 },

{ 'label': 'Fall of Berlin Wall', 'ts': 626616000 },

{ 'label': 'First Episode of Who\'s the Boss', 'ts': 464529600 }

]

});

});

// dust-filters/views/main.dist (excerpt)

<tbody>

{#events}

<tr>

<td>{label}</td>

<td>{ts|formatTS}</td>

</tr>

{/events}

</tbody>

上下文助手

除了存储数据,Dust 上下文还能够存储函数(称为“上下文助手”),其输出可以在以后被传递到的模板引用。这样,Dust 上下文就不仅仅是简单的原始信息负载,而是一个视图模型,是应用的业务逻辑和视图之间的中介,能够以最合适的方式格式化信息。

清单 9-30 中的例子展示了这个特性,其中一个应用向用户展示了一个服务器表。每个条目显示一个名称,以及一条指示每个服务器是否在线的消息。标题显示系统的整体健康状况,由systemStatus()上下文助手生成。请注意,模板能够引用我们的上下文助手,就像引用任何其他类型的值一样(例如,对象文字、数组、数字、字符串)。

Listing 9-30. Dust Context Helper

// dust-context-helpers1/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

var offlineServers = _.filter(this.servers, { 'online': false });

return offlineServers.length ? 'Bad' : 'Good';

}

});

});

// dust-context-helpers1/views/main.dust (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{?online}Yes{:else}No{/online}</td>

</tr>

{/servers}

</tbody>

</table>

如本例所示,每个 Dust 上下文助手都接收四个参数:chunkcontextbodiesparams。让我们来看几个演示它们用法的例子。

矮胖的人或物

上下文助手的chunk参数为它提供了对正在呈现的模板的当前部分的访问——被 Dust 称为“块”举例来说,考虑清单 9-31 ,其中上下文助手与模板中定义的默认内容配对。在这个例子中,systemStatus()上下文助手可以通过调用chunk.write()方法,用自己的值覆盖块的默认内容“未知”。助手可以通过返回chunk作为它的值来表明它已经选择这样做。

Listing 9-31. Dust Context Helper Paired with Default Content

// dust-context-helpers2/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

if (!this.servers.length) return;

if (_.filter(this.servers, { 'online': false }).length) {

return chunk.write('Bad');

} else {

return chunk.write('Good');

}

}

});

});

// dust-context-helpers2/views/main.dust (excerpt)

<h1>System Status: {#systemStatus}Unknown{/systemStatus}</h1>

语境

根据模板的决定,context参数为上下文助手提供了对上下文活动部分的方便访问。清单 9-32 中显示的模板通过为每个被传递的服务器引用一次isOnline()上下文助手来演示这一点。每次,isOnline()助手通过context.get()获取活动部分的online属性的值。

Listing 9-32. The context Argument Provides Context Helpers with Access to the Active Section

// dust-context-helpers3/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'isOnline': function(chunk, context, bodies, params) {

return context.get('online') ? 'Yes' : 'No';

}

});

});

// dust-context-helpers3/views/main.dust (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{isOnline}</td>

</tr>

{/servers}

</tbody>

</table>

身体

想象一个场景,其中模板的大部分内容由一个或多个上下文助手决定。Dust 没有强迫开发人员以笨拙的方式连接字符串,而是允许这些内容保留在它应该在的地方——模板中——作为上下文助手可以选择呈现的选项。

清单 9-33 通过将四个不同的内容主体传递给description()上下文助手来演示这一点。助手的bodies参数为它提供了对该内容的引用,然后它可以通过向chunk.render()传递适当的值来选择呈现该内容。

Listing 9-33. Selectively Rendering Portions of a Template via the bodies Argument

// dust-context-helpers4/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false },

{ 'name': 'IRC Server', 'online': true }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'isOnline': function(chunk, context, bodies, params) {

return context.get('online') ? 'Yes' : 'No';

},

'description': function(chunk, context, bodies, params) {

switch (context.get('name')) {

case 'Web Server':

return chunk.render(bodies.web, context);

break;

case 'Database Server':

return chunk.render(bodies.database, context);

break;

case 'Email Server':

return chunk.render(bodies.email, context);

break;

}

}

});

});

// dust-context-helpers4/index.js (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th><th>Description</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{isOnline}</td>

<td>

{#description}

{:web}

A web server serves content over HTTP.

{:database}

A database server fetches remotely stored information.

{:email}

An email server sends and receives messages.

{:else}

-

{/description}

</td>

</tr>

{/servers}

</tbody>

</table>

参数

除了引用被调用的上下文的属性(通过context.get()),上下文助手还可以访问模板传递给它的参数。清单 9-34 中所示的例子通过将每个服务器的uptime属性传递给formatUptime()上下文助手来演示这一点。在这个例子中,helper 将提供的值params.value转换成更容易阅读的形式,然后将它写到块中。

Listing 9-34. Context Helpers Can Receive Parameters via the params Argument

// dust-context-helpers5/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true, 'uptime': 722383 },

{ 'name': 'Database Server', 'online': true, 'uptime': 9571 },

{ 'name': 'Email Server', 'online': false, 'uptime': null }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'formatUptime': function(chunk, context, bodies, params) {

if (!params.value) return chunk.write('-');

chunk.write(moment.duration(params.value, 'seconds').humanize());

}

});

});

// dust-context-helpers5/views/main.dust (excerpt)

{#servers}

<tr>

<td>{name}</td>

<td>{?online}Yes{:else}No{/online}</td>

<td>{#formatUptime value=uptime /}</td>

</tr>

{/servers}

在清单 9-35 中,我们看到了一个稍微复杂一点的上下文助手参数演示。在这个例子中,parseLocation()助手接收一个引用了上下文属性的字符串:value="{name} lives in {location}"。为了正确解释这些参考资料,必须首先借助 Dust 的helpers.tap()方法评估参数。

Listing 9-35. Parameters That Reference Context Properties Must Be Evaluated

// dust-context-helpers6/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

var morgan = require('morgan');

app.use(morgan('combined'));

var engine = adaro.dust();

var dust = engine.dust;

app.engine('dust', engine);

app.set('view engine', 'dust');

app.use('/', express.static('./public'));

app.all('/', function(req, res, next) {

res.render('main', {

'people': [

{ 'name': 'Joe', 'location': 'Chicago' },

{ 'name': 'Mary', 'location': 'Denver' },

{ 'name': 'Steve', 'location': 'Oahu' },

{ 'name': 'Laura', 'location': 'Nashville' }

],

'parseLocation': function(chunk, context, bodies, params) {

var content = dust.helpers.tap(params.value, chunk, context);

return chunk.write(content.toUpperCase());

}

});

});

app.listen(8000);

// dust-context-helpers6/views/main.dust

{#people}

<li>{#parseLocation value="{name} lives in {location}" /}</li>

{/people}

异步上下文助手

辅助函数为 Dust 提供了强大的功能和灵活性。它们允许上下文对象充当视图模型——应用的业务逻辑和用户界面之间的智能桥梁,能够获取信息并针对特定用例进行适当格式化,然后将其传递给一个或多个视图进行呈现。尽管这很有用,但就如何将这些辅助函数应用于强大的效果而言,我们实际上才刚刚开始触及皮毛。

除了直接返回数据,Dust helper 函数也能够异步返回数据,清单 9-36 中的例子演示了这个过程。这里我们创建了两个上下文助手,cars()trucks()。前者返回一个数组,后者返回一个解析为数组的承诺。从模板的角度来看,这两个函数的使用是相同的。

Listing 9-36. Helper Functions Can Return Promises

// dust-promise1/index.js (excerpt)

app.get('/', function(req, res, next) {

res.render('main', {

'cars': function(chunk, context, bodies, params) {

return ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'];

},

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

resolve(['Chevrolet Colorado', 'GMC Canyon', 'Toyota Tacoma']);

});

}

});

});

// dust-promise1/views/main.dust (excerpt)

<h1>Cars</h1>

<ul>{#cars}<li>{.}</li>{/cars}</ul>

<h2>Trucks</h1>

<ul>{#trucks}<li>{.}</li>{/trucks}</ul>

在承诺被拒绝的情况下,Dust 还为有条件地显示内容提供了一种方便的方法。清单 9-37 展示了这一过程。

Listing 9-37. Handling Rejected Promises

// dust-promise2/index.js (excerpt)

app.get('/', function(req, res, next) {

res.render('main', {

'cars': function(chunk, context, bodies, params) {

return ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'];

},

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

reject('Unable to fetch trucks.');

});

}

});

});

// dust-promise2/views/main.dust (excerpt)

<h1>Cars</h1>

<ul>{#cars}<li>{.}</li>{/cars}</ul>

<h2>Trucks</h1>

<ul>{#trucks}

<li>{.}</li>

{:error}

An error occurred. We were unable to get a list of trucks.

{/trucks}</ul>

有能力以承诺的形式向模板提供信息是有用的,原因有很多,但当这种功能与 Dust 的流媒体接口配合使用时,事情就变得有趣多了。为了更好地理解这一点,请考虑清单 9-38 ,它很大程度上反映了我们之前的例子。然而,在这种情况下,我们利用 Dust 的流接口,在渲染时将模板的一部分下推到客户端,而不是等待整个过程完成。

Listing 9-38. Streaming a Template to the Client As Data Becomes Available

// dust-promise2/index.js

var Promise = require('bluebird');

var express = require('express');

var adaro = require('adaro');

var app = express();

var engine = adaro.dust();

var dust = engine.dust;

app.engine('dust', engine);

app.set('view engine', 'dust');

app.use('/', express.static('./public'));

app.get('/', function(req, res, next) {

dust.stream('views/main', {

'cars': ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'],

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

setTimeout(function() {

resolve(['Chevrolet Colorado', 'GMC Canyon', 'Toyota Tacoma']);

}, 4000);

});

}

}).pipe(res);

});

app.listen(8000);

根据所讨论的模板的复杂性,这种方法对用户体验的影响通常是巨大的。这种方法允许我们在内容可用时将内容推送到客户端,而不是强迫用户等待整个页面加载完毕后再继续。因此,用户在访问应用时感觉到的延迟通常会大大减少。

除尘助手

在上一节中,我们探讨了如何通过使用上下文助手来扩展上下文对象,以包含与特定视图相关的逻辑。以类似的方式,Dust 允许在全局级别定义辅助函数,使它们可用于所有模板,而无需在其上下文中显式定义。Dust 附带了许多这样的助手。通过利用它们,开发人员可以更容易地解决在使用更严格、无逻辑的模板解决方案时经常遇到的许多挑战。

清单 9-39 显示了 JSON 数据的摘录,本节的其余示例将引用这些数据。

Listing 9-39. Excerpt of the JSON Data Passed to a Dust Template

// dust-logic1/people.json (excerpt)

[{

"name": "Joe", "location": "Chicago", "age": 27,

"education": "high_school", "employed": false, "job_title": null

}, {

"name": "Mary", "location": "Denver", "age": 35,

"education": "college", "employed": true, "job_title": "Chef"

}]

逻辑助手

清单 9-40 展示了 Dust 逻辑助手@eq的用法,通过它我们可以在两个指定值keyvalue之间进行严格的比较。在本例中,第一个值job_title引用当前上下文中的一个属性。第二个值"Chef",被定义为模板中的一个文字值。

Listing 9-40. Using a Dust Logic Helper to Conditionally Display Content

// dust-logic1/views/main.dust (excerpt)

{#people}

{@eq key=job_title value="Chef"}

<p>{name} is a chef. This person definitely knows how to cook.</p>

{:else}

<p>{name} is not a chef. This person may or may not know how to cook.</p>

{/eq}

{/people}

知道了这一点,想象一个场景,我们想要在两个数字之间执行严格的相等检查,其中一个作为上下文属性被引用,而另一个在模板中被指定为文字。为了做到这一点,我们必须将我们的文字值转换成适当的类型,如清单 9-41 所示。

Listing 9-41. Casting a Literal Value to the Desired Type

{#people}

{@eq key=age value="27" type="number"}

<p>{name} is 27 years old.</p>

{/eq}

{/people}

Dust 提供了许多逻辑助手,可以用来进行简单的比较。它们的名称和描述在表 9-2 中列出。

表 9-2。

Logic Helpers Provided by Dust

| 逻辑助手 | 描述 | | --- | --- | | `@eq` | 严格等于 | | `@ne` | 不严格等于 | | `@gt` | 大于 | | `@lt` | 不到 | | `@gte` | 大于或等于 | | `@lte` | 小于或等于 |

Switch 语句

经常使用的@select助手提供了一种方法,通过这种方法我们可以模仿switch (...)语句,使得模板可以基于指定的值指定内容的多种变化(参见清单 9-42 )。

Listing 9-42. Mimicking a switch Statement with the @select Helper

{@gte key=age value=retirement_age}

<p>{name} has reached retirement age.</p>

{:else}

<p>

{@select key=job_title}

{@eq value="Chef"}Probably went to culinary school, too.{/eq}

{@eq value="Professor"}Smarty pants.{/eq}

{@eq value="Accountant"}Good with numbers.{/eq}

{@eq value="Astronaut"}Not afraid of heights.{/eq}

{@eq value="Pilot"}Travels frequently.{/eq}

{@eq value="Stunt Double"}Fearless.{/eq}

{! @none serves as a default case !}

{@none}Not sure what I think.{/none}

{/select}

</p>

{/gte}

迭代助手

Dust 为解决迭代中经常遇到的问题提供了三个有用的助手。例如,清单 9-43 演示了@sep助手的使用,通过它我们可以定义除最后一次迭代之外的每次迭代的内容。

Listing 9-43. Ignoring Content During a Loop’s Last Iteration with @sep

// dust-logic1/views/main.dust (excerpt)

{#people}{name}{@sep}, {/sep}{/people}

// output

Joe, Mary, Wilson, Steve, Laura, Tim, Katie, Craig, Ryan

Dust 总共提供了三个解决迭代挑战的助手。这些在表 9-3 中列出。

表 9-3。

Iteration Helpers

| 迭代助手 | 描述 | | --- | --- | | `@sep` | 为每个迭代呈现内容,最后一次除外 | | `@first` | 仅呈现第一次迭代的内容 | | `@last` | 仅呈现最后一次迭代的内容 |

数学表达式

使用 Dust 的@math助手,模板可以根据数学表达式的结果调整它们的内容。这种调整可以通过两种方式之一进行。第一个在清单 9-44 中演示,其中数学表达式的结果在模板中被直接引用。第二个在清单 9-45 中演示,其中内容根据调用@math助手的结果有条件地呈现。

Listing 9-44. Directly Referencing the Result of a Mathematical Expression

// dust-logic1/views/main.dust (excerpt)

{#people}

{@lt key=age value=retirement_age}

<p>{name} will have reached retirement age in

{@math key=retirement_age method="subtract" operand=age /} year(s).</p>

{/lt}

{/people}

Listing 9-45. Conditionally Rendering Content Based on the Result of a Call to the @math Helper

// dust-logic1/views/main.dust (excerpt)

{#people}

{@lt key=age value=retirement_age}

{@math key=retirement_age method="subtract" operand=age}

{@lte value=10}{name} will reach retirement age fairly soon.{/lte}

{@lte value=20}{name} has quite a ways to go before they can retire.{/lte}

{@default}{name} shouldn’t even think about retiring.{/default}

{/math}

{/lt}

{/people}

Dust 的@math助手支持的各种“方法”包括:addsubtractmultiplydividemodabsfloorceil

上下文转储

在开发过程中很有用,Dust 的@contextDump助手允许您快速呈现当前上下文对象(JSON 格式),提供对 Dust 在调用它的部分中看到的值的洞察。此处显示了其用法示例:

{#people}<pre>{@contextDump /}</pre>{/people}

自定义助手

在本章的前面,您学习了如何创建上下文帮助器,使用它们可以扩展上下文对象以包含自定义功能。同样,自定义 Dust helpers 也可以在全局级别创建。清单 9-46 展示了如何应用这一点。

Listing 9-46. Creating and Using a Custom Dust Helper

// dust-logic1/index.js (excerpt)

dust.helpers.inRange = function(chunk, context, bodies, params) {

if (params.key >= params.lower && params.key <= params.upper) {

return chunk.render(bodies.block, context);

} else {

return chunk;

}

}

// dust-logic1/views/main.dust (excerpt)

{#people}

{@gte key=age value=20}

{@lte key=age value=29}<p>This person is in their 20's.</p>{/lte}

{/gte}

{@inRange key=age lower=20 upper=29}<p>This person is in their 20's.</p>{/inRange}

{/people}

在这个示例的模板中,创建了一个循环,在这个循环中,我们遍历上下文中定义的每个人。对于每个人,如果他们碰巧在 20 岁左右的年龄段,就会显示一条消息。首先,使用预先存在的逻辑助手@gte@lt的组合来显示该消息。接下来,使用已经在全局级别定义的定制@inRange助手再次显示消息。

现在您已经熟悉了 Kraken 所依赖的许多基本组件,让我们继续创建我们的第一个真正的 Kraken 应用。

我们去找 Kraken

在本书关于开发工具的第一部分中,我们介绍了四个有用的工具,它们有助于管理许多与 web 开发相关的任务,其中包括:Bower、Grunt 和 Yeoman。Kraken 依赖于这些工具中的每一个,还有一个约曼生成器,它将帮助我们构建项目的初始结构。如果您还没有这样做,您应该通过 npm 全局安装这些模块,如下所示:

$ npm install -g yo generator-kraken bower grunt-cli

用约曼创建一个新的 Kraken 项目是一个互动的过程。在这个例子中,我们向生成器传递新项目的名称(app),此时它开始提示我们一些问题。图 9-4 显示了创建本章的app项目所采取的步骤。

A978-1-4842-0662-1_9_Fig4_HTML.jpg

图 9-4。

Creating a Kraken application using the Yeoman generator

一旦您回答了这些问题,生成器将创建项目的初始文件结构,并开始安装必要的依赖项。之后,你应该找到一个新的包含项目内容的app文件夹,它应该如图 9-5 所示。

A978-1-4842-0662-1_9_Fig5_HTML.jpg

图 9-5。

Initial file structure for the app project

Kraken 的 Yeoman generator 已经自动创建了一个新的 Express 应用,这个程序是使用本章前面介绍的模块组织的。我们可以立即启动当前状态的项目,如清单 9-47 所示。之后,可以在本地地址访问该项目(见图 9-6 )。

A978-1-4842-0662-1_9_Fig6_HTML.jpg

图 9-6。

Viewing the Project in the Browser for the First Time Listing 9-47. Launching the Project for the First Time

$ npm start

> app@0.1.0 start /Users/tim/temp/app

> node server.js

Server listening on http://localhost:8000

Application ready to serve requests.

Environment: development

正如你所看到的,我们的项目已经被预先配置(在confitmeddleware的帮助下)使用了许多有用的中间件模块(例如cookieParsersession等)。).为了进一步了解所有这些是如何组合在一起的,清单 9-48 显示了项目index.js脚本的内容。

Listing 9-48. Contents of Our New Project’s index.js Script

// app/index.js

var express = require('express');

var kraken = require('kraken-js');

var options, app;

/*

* Create and configure application. Also exports application instance for use by tests.

* Seehttps://github.com/krakenjs/kraken-js#options

*/

options = {

onconfig: function (config, next) {

/*

* Add any additional config setup or overrides here. config is an initialized

* confit (https://github.com/krakenjs/confit/

*/

next(null, config);

}

};

app = module.exports = express();

app.use(kraken(options));

app.on('start', function () {

console.log('Application ready to serve requests.');

console.log('Environment: %s', app.kraken.get('env:env'));

});

我们在这里看到的kraken-js模块只不过是一个标准的 Express 中间件库。然而,Kraken 并没有简单地给 Express 增加一些额外的功能,而是负责配置一个完整的 Express 应用。它将在许多其他模块的帮助下完成这项工作,包括本章已经介绍过的模块:confitmeddlewareenroutenadaro

如清单 9-48 所示,Kraken 被传递了一个包含onconfig()回调函数的配置对象,该对象将在 Kraken 为我们完成初始化confit后被调用。在这里,我们可以提供我们不想直接在项目的 JSON 配置文件中定义的任何最后的覆盖。在此示例中,没有进行此类覆盖。

控制器、模型和测试

在本章的“结构化路线组织”一节中,我们发现了enrouten如何有助于使定义快速路线的杂乱方式变得有序。默认情况下,一个新的 Kraken 项目被设置为使用 enrouten 的directory配置选项,允许它递归地扫描指定文件夹的内容,搜索导出接受单个参数的函数的模块(即router)。对于它找到的每个模块(称为“控制器”),enrouten将传递一个 Express 路由器实例,该实例已经安装到由该模块在目录结构中的位置预先确定的路径上。通过查看 Kraken 为我们的项目创建的默认控制器,我们可以看到这个过程的运行,如清单 9-49 所示。

Listing 9-49. Our Project’s Default Controller

// app/controllers/index.js

var IndexModel = require('../models/index');

module.exports = function (router) {

var model = new IndexModel();

/**

* The default route served for us when we access the app at: http://localhost:8000

*/

router.get('/', function (req, res) {

res.render('index', model);

});

};

除了为我们的项目创建一个默认控制器,Kraken 还负责创建一个相应的模型,IndexModel,你可以在清单 9-49 中看到引用。我们将很快讨论 Kraken 与模型的关系,但首先,让我们走一遍创建我们自己的新控制器的过程。

第三章介绍了约曼,展示了生成器能够提供子命令,这些子命令能够为开发人员提供功能,这些功能的有用性远远超出了项目的初始创建。Kraken 的 Yeoman generator 利用了这一点,提供了一个controller子命令,用它可以快速创建新的控制器。举例来说,让我们创建一个新的控制器,负责管理一组 RSS 提要:

$ yo kraken:controller feeds

在为生成器的controller子命令指定了我们想要的路径feeds之后,会自动为我们创建五个新文件:

  • controllers/feeds.js:控制器
  • models/feeds.js:型号
  • test/feeds.js:测试套件
  • public/templates/feeds.dust:灰尘模板
  • locales/US/en/feeds.properties:国际化设置

现在,让我们把注意力放在这里列出的前三个文件上,从模型开始。在下一节中,我们将看看附带的 Dust 模板和内部化设置文件。

模型

清单 9-50 显示了我们项目的新feeds模型的初始状态。如果你期待一些复杂的东西,你可能会失望。正如您所看到的,这个文件只不过是一个通用的存根,我们希望用我们自己的持久层来替换它。

Listing 9-50. Initial Contents of the feeds Model

// models/feeds.js

module.exports = function FeedsModel() {

return {

name: 'feeds'

};

};

不同于其他许多“全栈”框架,它们试图为开发人员提供解决所有可能需求(包括数据持久性)的工具,Kraken 采取了一种极简主义的方法,不试图重新发明轮子。这种方法认识到,开发人员已经可以访问各种各样得到良好支持的库来管理数据持久性,本书涵盖了其中的两个库:Knex/Bookshelf 和 Mongoose。

举例来说,让我们更新这个模块,以便它导出一个书架模型,能够在 SQLite 数据库中存储的feeds表中获取和存储信息。清单 9-51 显示了feeds型号的更新内容。

Listing 9-51. Updated feeds Model That Uses Knex/Bookshelf

// models/feeds.js

var bookshelf = require('../lib/bookshelf');

var Promise = require('bluebird');

var feedRead = require('feed-read');

var Feed = bookshelf.Model.extend({

'tableName': 'feeds',

'getArticles': function() {

var self = this;

return Promise.fromNode(function(callback) {

feedRead(self.get('url'), callback);

});

}

});

module.exports = Feed;

Note

清单 9-51 中显示的更新模型假设您已经熟悉 Knex 和 Bookshelf 库,以及配置它们的必要步骤。如果不是这样,你可能想读读第十二章。无论如何,本章的app项目提供了这里显示的代码的完整功能演示。

控制器

清单 9-52 显示了我们项目的新feeds控制器的初始内容。与我们项目附带的原始控制器一样,这个控制器引用了 Kraken 为我们方便地创建的相应模型,我们已经看到了。

Listing 9-52. Initial Contents of the feeds Controller

// controllers/feeds.js

var FeedsModel = require('../models/feeds');

/**

* @url http://localhost:8000/feeds

*/

module.exports = function (router) {

var model = new FeedsModel();

router.get('/', function (req, res) {

});

};

在默认状态下,feeds控制器完成的任务很少。让我们更新这个控制器,以包含一些额外的路由,允许客户端与我们的应用的Feed模型进行交互。清单 9-53 中显示了feeds控制器的更新版本。

Listing 9-53. Updated feeds Controller

var Feed = require('../models/feeds');

module.exports = function(router) {

router.param('feed_id', function(req, res, next, id) {

Feed.where({

'id': id

}).fetch({

'require': true

}).then(function(feed) {

req.feed = feed;

next();

}).catch(next);

});

/**

* @url http://localhost:8000/feeds

*/

router.route('/')

.get(function(req, res, next) {

return Feed.where({})

.fetchAll()

.then(function(feeds) {

if (req.accepts('html')) {

return res.render('feeds', {

'feeds': feeds.toJSON()

});

} else if (req.accepts('json')) {

return res.send(feeds);

} else {

throw new Error('Unknown Accept value: ' + req.headers.accept);

}

})

.catch(next);

});

/**

* @url http://localhost:8000/feeds/:feed_id

*/

router.route('/:feed_id')

.get(function(req, res, next) {

res.send(req.feed);

});

/**

* @url http://localhost:8000/feeds/:feed_id/articles

*/

router.route('/:feed_id/articles')

.get(function(req, res, next) {

req.feed.getArticles()

.then(function(articles) {

res.send(articles);

})

.catch(next);

});

};

有了这些更新,客户现在能够

  • 列表订阅源
  • 获取关于特定源的信息
  • 从特定的订阅源获取文章

在下一节中,我们将看看 Kraken 为我们应用的这一部分创建的测试套件。使用这个测试套件,我们可以验证我们定义的路由是否如预期的那样工作。

测试套件

清单 9-54 显示了 Kraken 为我们的新控制器创建的测试套件的初始内容。这里我们看到一个测试,它是在 SuperTest 的帮助下定义的,SuperTest 是 SuperAgent 的一个扩展,SuperAgent 是一个用于发出 HTTP 请求的简单库。

Listing 9-54. Test Suite for the feeds Controller

// test/feeds.js

var kraken = require('kraken-js');

var express = require('express');

var request = require('supertest');

describe('/feeds', function() {

var app, mock;

beforeEach(function(done) {

app = express();

app.on('start', done);

app.use(kraken({

'basedir': process.cwd()

}));

mock = app.listen(1337);

});

afterEach(function (done) {

mock.close(done);

});

it('should say "hello"', function(done) {

request(mock)

.get('/feeds')

.expect(200)

.expect('Content-Type', /html/)

.expect(/"name": "index"/)

.end(function (err, res) {

done(err);

});

});

});

在这个例子中,向我们的应用的/feeds端点发出一个 GET 请求,并做出以下断言:

  • 服务器应该用 HTTP 状态代码 200 来响应。
  • 服务器应该用一个包含字符串htmlContent-Type头来响应。
  • 响应的主体应该包含字符串"name": "index"

鉴于我们最近对新控制器所做的更新,这些断言不再适用。让我们用一些相关的测试来代替它们。清单 9-55 显示了测试套件的更新内容。

Listing 9-55. Updated Contents of the feeds Test Suite

// test/feeds/index.js

var assert = require('assert');

var kraken = require('kraken-js');

var express = require('express');

var request = require('supertest');

describe('/feeds', function() {

var app, mock;

beforeEach(function(done) {

app = express();

app.on('start', done);

app.use(kraken({'basedir': process.cwd()}));

mock = app.listen(1337);

});

afterEach(function(done) {

mock.close(done);

});

it('should return a collection of feeds', function(done) {

request(mock)

.get('/feeds')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert(res.body instanceof Array, 'Expected an array');

done();

});

});

it('should return a single feed', function(done) {

request(mock)

.get('/feeds/1')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert.equal(typeof res.body.id, 'number',                     'Expected a numeric id property');

done();

});

});

it('should return articles for a specific feed', function(done) {

request(mock)

.get('/feeds/1/articles')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert(res.body instanceof Array, 'Expected an array');

done();

});

});

});

我们更新的测试套件现在包含三个测试,旨在验证我们的每个新控制器的路由是否正常工作。例如,考虑第一个测试,它将向我们的应用的/feeds端点发出 GET 请求,并做出以下断言:

  • 服务器应该用 HTTP 状态代码 200 来响应。
  • 服务器应该用一个包含字符串jsonContent-Type头来响应。
  • 服务器应该以数组的形式返回一个或多个结果。

Note

回想一下,我们的应用的Feed模型是在 Knex 和 Bookshelf 库的帮助下创建的。您在这个项目中看到的引用数据来自 Knex“种子”文件(seeds/developments/00-feeds.js),我们可以用样本数据填充我们的数据库。在任何时候,都可以通过从命令行运行$ grunt reset-db将这个项目的 SQLite 数据库重置为初始状态。如果这些概念对你来说不熟悉,你可能想阅读第十二章。

图 9-7 显示了当我们项目的test Grunt 任务被调用时打印到控制台的输出。

A978-1-4842-0662-1_9_Fig7_HTML.jpg

图 9-7。

Running the test suite

国际化和本地化

Kraken 为创建能够自适应以满足多种语言和地区的独特需求的应用提供了内置支持,这是大多数希望在多种多样的市场中广泛使用的产品的重要要求。在这一节中,我们将了解完成这一任务的两个步骤,国际化和本地化,以及如何在 Kraken 应用的上下文中应用它们,该应用的模板是在服务器上生成的。

国际化(通常简称为 i18n)是指开发能够支持多个地区和方言的应用的行为。实际上,这是通过避免在应用的模板中直接使用特定于地区的单词、短语和符号(例如,货币符号)来实现的。取而代之的是占位符,这些占位符是在请求模板时根据发出请求的用户的位置或设置填充的。举个例子,考虑清单 9-56 中显示的 Dust 模板,它负责呈现本章app项目的主页。

Listing 9-56. Dust Template for the Home Page of app Project

// app/public/templates/index.dust

{>"layouts/master" /}

{<body}

<div class="panel panel-default">

<div class="panel-heading">

<h3 class="panel-title">{@pre type="content" key="greeting" /}</h3>

</div>

<div class="panel-body">

<form method="post" action="/sessions">

<div class="form-group">

<label>{@pre type="content" key="email_address" /}</label>

<input type="email" name="email" class="form-control">

</div>

<div class="form-group">

<label>{@pre type="content" key="password" /}</label>

<input type="password" name="password" class="form-control">

</div>

<button type="submit" class="btn btn-primary">

{@pre type="content" key="submit" /}

</button>

</form>

</div>

</div>

{/body}

这里的基本语义应该是熟悉的,基于之前在本章关于灰尘的部分中讨论过的内容。正如你所看到的,这个模板不是直接嵌入内容,而是依赖于 Kraken 提供的一个特殊的 Dust 助手,@pre,通过它我们可以引用存储在单独的、特定于地区的内容文件中的内容。清单 9-57 中显示了这个特定模板的相应内容文件。

Listing 9-57. Corresponding Content Files for the Dust Template Shown in Listing 9-56

// app/locales/US/en/index.properties

# Comments are supported

greeting=Welcome to Feed Reader

submit=Submit

email_address=Email Address

password=Password

// app/locales/ES/es/index.properties

greeting=Bienvenida al Feed Reader

submit=Presentar

email_address=Correo Electrónico

password=Contraseña

Note

注意这个例子的模板public/templates/index.dust的位置,以及它对应的内容属性文件locales/US/en/index.propertieslocales/ES/es/index.properties的位置。Kraken 被配置为一对一地将 Dust 模板与内容属性文件配对,方法是根据它们的路径和文件名进行匹配。

国际化(i18n)主要关注创建能够支持本地化内容注入的应用,与之相反,本地化(l10n)指的是创建特定于地区和方言的内容文件(如本例中所示)的过程。清单 9-58 中显示的控制器展示了 Kraken 如何帮助开发者将这些概念结合在一起,为用户提供满足他们特定需求的内容。

Listing 9-58. Serving a Locale-Specific Version of the Home Page

// app/controllers/index.js

module.exports = function (router) {

/**

* The default route served for us when we access the app

* at http://localhost:8000

*/

router.get('/', function (req, res) {

res.locals.context = { 'locality': { 'language': 'es', 'country': 'ES' } };

res.render('index');

});

};

这个例子是我们最初在清单 9-49 中看到的控制器的更新版本,它负责呈现我们的应用的主页。在这里,我们通过将内容文件分配给传入 Express response 对象的locals.context属性来指定用于定位内容文件的国家和语言。如果没有指定这样的值,Kraken 的默认行为是使用美国英语。渲染模板的英文版和西班牙文版分别如图 9-8 和图 9-9 所示。

A978-1-4842-0662-1_9_Fig9_HTML.jpg

图 9-9。

Spanish version of the application’s home page

A978-1-4842-0662-1_9_Fig8_HTML.jpg

图 9-8。

English version of the application’s home page

检测位置

清单 9-58 中显示的示例演示了将特定区域设置手动分配给传入请求的过程。但是,它没有演示自动检测用户所需本地化设置的过程。

清单 9-59 展示了一种基于accept-language HTTP 请求头的值来确定位置的简单方法。在这个例子中,我们已经从我们的路由中删除了用于确定用户位置的逻辑,并将它放在了一个更合适的位置——一个将为每个传入请求调用的中间件功能。

Listing 9-59. Detecting Locality Based on the Value of the accept-language HTTP Request Header

// app/lib/middleware/locale.js

var acceptLanguage = require('accept-language');

/**

* Express middleware function that automatically determines locality based on the value

* of the accept-language header.

*/

module.exports = function() {

return function(req, res, next) {

var locale = acceptLanguage.parse(req.headers['accept-language']);

res.locals.context = {

'locality': { 'language': locale[0].language, 'country': locale[0].region }

};

next();

};

};

// app/config/config.json (excerpt)

"middleware":{

"locale": {

"module": {

"name": "path:./lib/middleware/locale"

},

"enabled": true

}

}

Note

虽然很有帮助,但是accept-language HTTP 请求头并不总是反映发出请求的用户所需的本地化设置。务必为用户提供一种自行手动指定此类设置的方法(例如,作为“设置”页面的一部分)。

安全

鉴于 Kraken 出身于全球在线支付处理商 PayPal,该框架高度重视安全性也就不足为奇了。Kraken 在张亿嘟嘟的帮助下做到了这一点,该库按照开放 Web 应用安全项目(OWASP)的建议,用许多增强的安全技术扩展了 Express。这些扩展以多个可独立配置的中间件模块的形式提供。在本节中,我们将简要介绍 Kraken 帮助保护 Express 免受常见攻击的两种方法。

Note

这份材料绝不应被视为详尽无遗。它仅用于作为在 Kraken/快速应用环境中实现安全性的起点。强烈建议在 Web 上实现安全性的读者,通过阅读完全致力于这一主题的许多优秀书籍中的几本,来深入研究这一主题。

防御跨站点请求伪造攻击

为了理解跨站点请求伪造(CSRF)攻击背后的基本前提,理解大多数 web 应用对其用户进行身份验证的方法是很重要的:基于 cookie 的身份验证。该过程如图 9-10 所示。

A978-1-4842-0662-1_9_Fig10_HTML.gif

图 9-10。

Cookie-based authentication

在一个典型的场景中,用户将他们的凭证提交给一个 web 应用,然后该应用将这些凭证与文件中的凭证进行比较。假设凭证是有效的,那么服务器将创建一个新的会话——本质上是一个代表用户成功登录尝试的记录。然后,属于该会话的唯一标识符以 cookie 的形式传输给用户,cookie 由用户的浏览器自动存储。浏览器向应用发出的后续请求将自动附加存储在该 cookie 中的信息,允许应用查找匹配的会话记录。因此,应用能够验证用户的身份,而不需要用户在每次请求时重新提交用户名和密码。

CSRF 攻击利用应用和用户浏览器之间存在的信任关系(即会话),诱使用户向应用提交非预期的请求。让我们看一个例子,它应该有助于解释这是如何工作的。图 9-11 展示了用户登录可信应用的过程——在这种情况下,本章源代码中包含的csrf-server项目。

A978-1-4842-0662-1_9_Fig11_HTML.jpg

图 9-11。

Signing into a trusted application

图 9-12 显示了用户成功登录应用后出现的欢迎屏幕。在这里,我们可以看到用户的一些基本信息,包括他们的姓名和他们的帐户是何时创建的。

A978-1-4842-0662-1_9_Fig12_HTML.jpg

图 9-12。

Successful sign-in attempt

在这一点上,想象一个场景,用户离开应用(没有退出)并访问另一个网站,在用户不知道的情况下,有恶意的意图(见图 9-13 )。这个恶意网站的副本可以在本章的csrf-attack项目中找到。在这个例子中,恶意网站用免费糖果和蝴蝶的诱人承诺引诱用户点击按钮。

A978-1-4842-0662-1_9_Fig13_HTML.jpg

图 9-13。

Malicious web site attempting to convince the user to click a button

清单 9-60 显示了这个恶意网站的 HTML 摘录,这将有助于解释当用户点击这个按钮时会发生什么。如您所见,单击该按钮将触发对原始应用的/transfer-funds路由的 POST 请求的创建。

Listing 9-60. Malicious Web Form

// csrf-attack/views/index.dust (excerpt)

<form method="post" action="``http://localhost:7000/transfer-funds

<button type="submit" class="btn btn-primary">

Click Here for Free Candy and Butterflies

</button>

</form>

点击按钮后,用户没有收到承诺的免费糖果和蝴蝶,而是收到一条消息,表明所有的资金都已从他们的帐户中转出,如图 9-14 所示。

A978-1-4842-0662-1_9_Fig14_HTML.jpg

图 9-14。

Successful CSRF attack

可以采取几个不同的步骤来抵御这种性质的攻击。Kraken 抵御它们的方法被称为“同步器令牌模式”在这种方法中,为每个传入的请求生成一个随机字符串,客户端随后可以将该字符串作为模板上下文的一部分或通过响应头进行访问。重要的是,这个字符串不是作为 cookie 存储的。客户端发出的下一个 POST、PUT、PATCH 或 DELETE 请求必须包含这个字符串,然后服务器会将其与之前生成的字符串进行比较。只有在匹配的情况下,请求才会被允许进行。

让我们看看这在实践中是如何工作的。图 9-15 显示了本章app项目的签到页面。回头参考清单 9-56 来查看这个页面的底层 HTML。

A978-1-4842-0662-1_9_Fig15_HTML.jpg

图 9-15。

Sign-in page for this chapter’s app project

在其当前状态下,任何使用此表单登录的尝试都将导致图 9-16 所示的错误。这里我们看到一条来自 Kraken 的错误消息,警告我们缺少“CSRF 令牌”

A978-1-4842-0662-1_9_Fig16_HTML.jpg

图 9-16。

Kraken’s “CSRF token missing” Error

这个错误可以通过在应用的登录表单中添加一个单独的隐藏输入来解决。清单 9-61 显示了我们的应用更新的 Dust 模板的摘录,以及渲染输出的摘录。

Listing 9-61. Inserting a Hidden _csrf Field into the Sign-In Form

// app/public/templates/index.dust (excerpt)

<form method="post" action="/sessions">

<input type="hidden" name="_csrf" value="{_csrf}">

<!-- ... ->

</form>

// Rendered output

<form method="post" action="/sessions">

<input type="hidden" name="_csrf" value="OERRGi9AGNPEYnNWj8skkfL9f0JIWJp3uKK8g=">

<!-- ... ->

</form>

这里我们创建了一个名为_csrf的隐藏输入,张亿嘟嘟已经将它的值自动传递给模板上下文中同名的属性。我们在本例中看到的值OERRGi9AGNPEYnNWj8skkfL9f0JIWJp3uKK8g=,是张亿嘟嘟为我们生成的随机散列(即“同步器令牌”)。当我们提交此表单时,张亿嘟嘟将验证此值是否与之前提供给我们的值相匹配。如果它们匹配,则允许请求继续进行。否则,将引发错误。这种方法允许应用通过要求额外的、不作为 cookie 的一部分存储的识别信息来防御 CSRF 攻击,使得攻击者更加难以欺骗用户执行非预期的操作。

配置内容安全策略标题

张亿嘟嘟为开发人员提供了一种配置应用内容安全策略(CSP)的便捷机制。这些规则向支持浏览器提供关于各种资源(例如,脚本、样式表、图像等)的位置的指令。)可以加载。定义时,这些规则以Content-Security-Policy响应头的形式传递给浏览器。

作为一个例子,参见清单 9-62 ,其中张亿嘟嘟的csp中间件模块被提供了一个配置对象,该对象指定只有图像可以从任何域加载。所有其他资源必须来自应用的域。

Listing 9-62. Configuring an Application’s Content Security Policy

app.use(lusca({

'csp': {

'default-src': '\'self\'',

'img-src': '*'

}

});

Note

有关可通过Content-Security-Policy标题配置的各种选项的完整列表,请访问位于 https://owasp.org 的开放 Web 应用安全项目(OWASP)。

摘要

节点社区深受所谓的“Unix 哲学”的影响,这种哲学提倡创建小型的、紧密集中的模块,旨在做好一件事。这种方法通过培育一个开放源代码模块的大型生态系统,使 Node 作为一个开发平台蓬勃发展。PayPal 将这一理念铭记于心,将 Kraken 构建成一个模块的集合,而不是一个单一的整体框架,为基于 Express 的应用扩展和提供结构。通过采用这种方法,PayPal 已经成功地为 Node 生态系统贡献了几个模块,开发者可以从中受益,无论他们是否选择整体使用 Kraken。

相关资源

十、Mach

一个系统省略某些异常的特征和改进,但反映一套设计思想,比一个包含许多好的但独立且不协调的思想的系统要好。—佛瑞德·P·布鲁克斯

Node.js web 服务器并不缺乏。快速/连接、Kraken 和风帆都是受欢迎的选择。Mach 在这个领域是一个相对年轻的项目,尽管它的前身 Strata.js 多年来一直拥有强大的追随者。Mach 是由前 Twitter 开发者迈克尔·杰克逊创建的,他有几个明确的原则:

  • HTTP 请求被无缝地传递给 JavaScript 函数。
  • 面向承诺的接口允许 HTTP 响应被异步延迟。HTTP 错误也可能通过承诺链传播。(参见第十四章关于承诺如何运作的详细描述。)
  • 请求和响应都可以利用 Node.js 流,因此大量数据可以以块的形式发送和接收。
  • 可组合中间件很容易扩展 Mach 的核心能力。

选择使用哪种 web 服务器——实际上,任何库、框架或一般编程语言的选择——应该由项目的特定用例决定。尽管 Mach 可以为任何基于 web 的应用提供很多功能,但它也可以是 HTTP 客户端和代理,将请求路由到虚拟主机(如 Apache 和 nginx),并重写 URL(如 Apache 的 mode_rewrite 模块)。Mach 的功能相当于 Node.js 模块,但是它的一些特性也可以在浏览器中使用,这使得它的用例表面积更大。

章节示例

本章包含了一些可运行的例子,包括在本章的示例代码中。在适用的地方,代码清单引用它们相应的文件,在清单的顶部有一个注释,如清单 10-1 所示。

Listing 10-1. Not a Real Example

// example-000/no-such-file.js

console.log('this is not a real example');

本章中的大多数示例启动 Node.js web 服务器。除非另有说明,否则假设可以通过运行每个清单中提到的 JavaScript 文件来启动服务器。例如,清单 10-2 中的命令将运行index.js文件,启动example-001目录中的 Mach web 服务器。

Listing 10-2. Launching an Example Web Server

example-001$ node index.js

>> mach web server started on node 0.10.33

>> Listening on 0.0.0.0:8080, use CTRL+C to stop

装置

Mach 是一个 Node.js 模块,可以和 Node.js 包管理器npm一起安装。本章中的例子也使用了 Q promise 库和一些其他的 npm 模块。所有这些都被列为示例代码的package.json文件中的依赖项,因此只需在示例代码目录中运行npm install就可以下载并安装每个模块:

code/mach$ npm install

Mach,网络服务器

Mach web 服务器从表面上看与许多其他 web 服务器相似,从成熟的模式和设计中获取灵感,只有当它能提供重要的东西时才重新发明轮子。在 Mach web 服务器中创建路由来处理 HTTP 请求是一个相当简单的过程,对于使用过其他面向 REST 的 web 服务器(如 Express (JavaScript)、Sinatra (Ruby)、Nancy(。NET),以此类推。

将 Mach 作为应用依赖项导入后,通过调用mach.stack()创建 Mach 应用栈。这是处理 HTTP 请求的第一步。清单 10-3 将堆栈分配给app变量。

Listing 10-3. Creating the Application Stack

// example-001/index.js

'use strict';

var mach = require('mach');

// ... load other modules ...

// create a stack

var app = mach.stack();

// ...

每个 Mach 应用被称为“堆栈”,因为每个 HTTP 连接都将穿过中间件层——可组合功能的一小部分——这些层可能会在请求被传递到路由之前处理请求,在响应被传递到调用客户端之前处理响应。中间件还可能产生对 web 服务器环境很重要的其他副作用。

Mach 本身带有通用 web 服务器中间件,这将在下一节中介绍。清单 10-4 中的 web 服务器使用一个中间件mach.logger,在 web 服务器接收请求时将 HTTP 诊断信息打印到终端。

Listing 10-4. Adding Middleware to the Application Stack

// example-001/index.js

'use strict';

var mach = require('mach');

// ... load other modules ...

// create a stack

var app = mach.stack();

// add some middleware

app.use(mach.logger);

// ...

路由只是一个与特定 HTTP 方法和 URL 模式配对的函数,当服务器收到 HTTP 请求时,它将处理这些请求。路由通常是在中间件之后最后添加到应用堆栈中的,因此中间件有机会在路由有机会与每个路由交互之前解析请求信息,并在路由有机会与每个路由交互之后操纵响应信息。

应用堆栈公开映射到标准 HTTP 请求方法的函数方法,如清单 10-5 所示。通过调用适当的方法,后跟 URL 模式和路由回调,将路由附加到堆栈。当一个路由与一个传入的请求匹配时,它将接收一个连接(通常缩写为conn),通过该连接它可以响应请求。

Listing 10-5. Adding HTTP Routes to the Application Stack

// example-001/index.js

// add some routes

app.get('/book', function (conn) {/*...*/});

app.get('/book/:id', function (conn) {/*...*/});

app.delete('/book/:id', function (conn) {/*...*/});

app.post('/book', function (conn) {/*...*/});

app.put('/book/:id', function (conn) {/*...*/});

app.get('/author', function (conn) {/*...*/});

app.get('/library', function (conn) {/*...*/});

app.get('/', function (conn) {/*...*/});

// ...

当所有的中间件和路由都连接到应用栈时,web 服务器就可以监听请求了。将应用堆栈传递给Mach.serve()会创建一个 HTTP 侦听器,以默认的 HTTP 方案、主机和端口:http://localhost:5000为请求提供服务。可以添加其他选项来更改此默认行为。在清单 10-6 中,一个新的端口号(8080)作为第二个参数传递给Mach.serve(),以强制 HTTP 侦听器服务该端口。

Listing 10-6. Serving Up the Application Stack on Port 8080

// example-001/index.js

// serve the stack on a port

mach.serve(app, 8080);

// or mach.serve(app, {port: 8080});

如果需要更多选项,它们可以作为选项散列传递给Mach.serve()。表 10-1 中描述了键和值。为方便起见,本章中的示例将使用端口号的简写形式。

表 10-1。

Mach Server Options

| 选项属性 | 描述 | | --- | --- | | `host` | 仅监听到该主机名的连接。默认情况下没有限制。 | | `port` | 侦听此端口上的连接。默认为 5000。 | | `socket` | 通过 Unix 套接字监听连接。主机和端口被忽略。 | | `quiet` | `true`抑制启动和关闭消息。默认为`false`。 | | `timeout` | 接收 SIGINT 或 SIGTERM 信号后,强制关闭连接并终止之前等待的时间。 | | `key` | SSL 连接的私钥(HTTPS)。 | | `cert` | SSL 连接的公共 X509 证书(HTTPS)。 |

HTTP 路由

Mach routes 可以处理来自最常见的 HTTP 方法的请求,甚至一些不常见的方法:

  • 得到
  • 邮政
  • 删除
  • 选择
  • 微量

清单 10-7 中的 HTTP GET 路由在一个假数据库中查找所有书籍,并将记录作为一个 JSON 对象数组发送给客户机。

Listing 10-7. Anatomy of a Route

// example-001/index.js

app.get('/book', function (conn) {

/*

* 1\. Routes return promises. Q can adapt the callback-

* driven database module so that its result (or error)

* is passed through a promise chain. The makeNodeResolver()

* method will provide a callback to feed the deferred.

*/

var deferred = Q.defer();

db.books.all(deferred.makeNodeResolver());

/*

* 2\. Adding handlers to the promise chain by calling

* promise.then()

*/

return deferred.promise.then(function (books) {

/*

* 3\. The Connection.json() method returns a promise.

* The HTTP status code will be sent as an HTTP header

* in the response, and the array of books will be

* serialized as JSON.

*/

return conn.json(200, books);

}, function (err) {

/*

* 4\. An HTTP 500 will be delivered to the client on

* error. The error’s message will be used in the

* serialized JSON response.

*/

return conn.json(500, {error: err.message});

});

});

在这条路线中发生的几件事对几乎所有创建的路线都是共同的。

首先,创建一个 deferred,它将最终生成一个从路由返回的承诺。(参见第十四章关于承诺如何运作的详细解释,特别是价值和错误如何沿着承诺链传递。)这里 Q promise 库创建一个 deferred,然后用makeNodeResolver()创建一个特殊的回调。这个回调直接传递给database.books.all()方法,并将生成的任何值或错误提供给承诺链。

其次,两个处理程序被附加到 deferred 的承诺上:一个处理程序接收需要返回给客户机的图书数据,另一个处理程序在记录获取失败时接收来自数据库的任何错误。

第三,每个处理程序通过调用带有 HTTP 状态和有效负载的conn.json(),将各自的数据转换成 HTTP 响应。这个方法是语法糖,封装了conn.send()方法(稍后将详细介绍),设置适当的Content-Type头,序列化 JSON 对象,并返回一个要沿承诺链传递的承诺。当这个承诺被解析时,实际的 HTTP 响应将被发送。

在终端会话中,curl HTTP 工具可以向/book路由发出 HTTP GET 请求。响应正文包含 JSON 格式的序列化图书数据:

example-001$ curl -X GET http://localhost:8080/book

[{"id":1,"title":"God Emperor of Dune","author":"Frank Herbert"... }]

在运行 Mach 服务器的终端会话中,mach.logger中间件将GET /book的请求细节写入标准输出:

example-001$ node index.js

>> mach web server started on node 0.12.0

>> Listening on :::8080, use CTRL+C to stop

::1 - - [17/Mar/2015 19:58:07] "GET /book HTTP/1.1" 200 - 0.002

URL 参数

URL 参数是表示应用数据(如唯一标识符)的 URL 路径段。常见的是用类似于/<entity-type>/<entity-id>/<entity-particular>的模式编写 REST URLs。清单 10-8 中的代码定义了通过标识符获取特定书籍的路径。实际参数:id由冒号前缀标识。一个路由可以有任意数量的参数,但是每个参数必须有一个唯一的名称,并且必须是一个完整的 URL 段。

参数将作为属性在conn.params对象上对路线可用。每个属性名将是不带冒号前缀的 URL 参数名。Mach 也将所有属性值解析为字符串。因为 id 在数据库中是数字,所以这个参数在被数据库查询使用之前使用Number函数进行转换。

Listing 10-8. REST Route with a Single URL Parameter

// example-001/index.js

app.get('/book/:id', function (conn) {

var id = Number(conn.params.id);

var deferred = Q.defer();

db.book.findByID(id, deferred.makeNodeResolver());

return deferred.promise.then(function (book) {

if (!book) {

return conn.json(404);

}

return conn.json(200, book);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

与清单 10-8 中的一般/book路径不同,该路径搜索数据库中可能存在也可能不存在的特定实体。如果数据库操作成功,但是获取的book对象是null,则不存在该 ID 的记录,并且路由解析为一个空的 HTTP 404 响应。

查询字符串和请求正文

当 Mach 自动解析 URL 参数并使它们在conn.params对象上可用时,必须调用getParams()方法来解析查询字符串和请求体。因为请求体是流式的,所以默认情况下不会自动执行解析。由开发人员决定是否以及何时进行解析。(如果这听起来很乏味,不要担心:稍后介绍的params中间件可以自动化这个过程。)

在清单 10-9 中,/author路径接受一个查询参数genre,然后传递一个 JSON 数组,该数组中包含了该流派书籍的作者。连接的getParams()方法返回一个承诺,将经过解析的params对象传递给解析回调。params对象的每个属性都将是来自 URL、查询字符串或请求体的命名参数。

Listing 10-9. Extracting Values from a Query String

// example-001/index.js

app.get('/author', function (conn) {

return conn.getParams().then(function (params) {

var deferred = Q.defer();

db.author.findByGenre(params.genre, deferred.makeNodeResolver());

return deferred.promise.then(function (authors) {

return conn.json(200, authors);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

});

清单 10-10 中的curl命令向服务器发送恐怖流派参数,响应包含一个作者记录,在genres数组中有一个匹配条目。

Listing 10-10. Using cURL to Send a Request with a Query String

example-001$ curl -X GET http://localhost:8080/author?genre=Horror

[{"id":6,"name":"Dan Simmons","website":"http://www.dansimmons.com/

getParams()方法还有另外两个有用的特性。它接受单个对象参数,其中键表示要解析的白名单参数,值表示每个参数的解析函数。当在清单 10-11 中解析请求主体时,白名单中没有指定的任何主体参数都将被忽略。原始 JavaScript 函数StringNumberDate都解析字符串并返回反序列化的对象。当params对象被传递给 promise 的解析回调时,每个属性都将被正确地输入。自定义函数也可以用来反序列化带有专有数据格式的请求体参数。

一旦解析了参数,就会在数据库中创建一个新的 book 记录,然后序列化并在响应体中返回给客户机。

Listing 10-11. Extracting Values from a Request Body

// example-001/index.js

app.post('/book', function (conn) {

return conn.getParams({

title: String,

author: String,

seriesTitle: String,

seriesPosition: Number,

publisher: String,

publicationDate: Date

}).then(function (params) {

var book = Book.fromParams(params);

var deferred = Q.defer();

db.book.save(book, deferred.makeNodeResolver());

return deferred.promise.then(function (result) {

return conn.json(result.isNew ? 201 : 200, book);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

});

Mach 可以反序列化 URL 编码、多部分和 JSON 格式的请求体。对于其他格式,可以添加定制中间件,在请求体到达路由处理器之前对其进行反序列化,或者可以在conn.request.content访问原始请求体流。

清单 10-12 显示了两个以 URL 编码和 JSON 格式发布新书数据的curl命令,以及每个 HTTP 响应生成的输出。

Listing 10-12. Sending a POST Request Body with cURL

example-001$ curl -X POST``http://localhost:8080/book

-H "Content-Type: application/x-www-form-urlencoded" \

-d "title=Leviathan%20Wakes&author=James%20S.A.%20Corey&publisher=Orbit&publicationDate=2011-06-15T05%3A00%3A00.000Z"

{"id":10,"title":"Leviathan Wakes","author":"James S.A. Corey","publisher":"Orbit"...}

example-001$ curl -X POST``http://localhost:8080/book

-H "Content-Type: application/json" \

-d @new-book.json

{"id":11,"title":"Ready Player One","author":"Ernest Cline","publisher":"Random House NY"...}

当来自不同来源的参数(即 URL 参数、查询字符串参数、主体参数)具有相同的名称时,将应用以下解决方案:

URL parameters always take precedence over query string and request body parameters.   Query string parameters take precedence over request body parameters.   Nonconflicting request body parameters are included.

发送响应

到目前为止,这些路由只提供了 JSON 响应,但是 Mach 可以将任何有效的 HTTP 响应内容传输到客户机。

Connection对象上最低级的响应方法是Connection.send()。该方法接受 HTTP 状态代码和流、缓冲区或字符串,以便在响应正文中传递。在Connection对象上的许多其他响应方法(如json()html())仅仅是通过在调用send()之前添加适当的头来操纵响应的门面。

表 10-2 显示了每个 Mach 响应方法,通常传递给每个方法的内容类型,以及每个方法用于各种 HTTP 响应头的默认值(如果有的话)。除了back()之外,HTTP 状态代码可以被指定为每个方法的第一个参数,后面是响应主体内容。虽然状态代码是一个可选参数,但本章中的示例总是显式设置它。

表 10-2。

Mach Response Methods

| 方法 | 有效载荷 | 回应标题默认值 | | --- | --- | --- | | `Connection.send()` | 流、缓冲区或字符串 | (无) | | `Connection.redirect()` | 位置 | `302 Redirect Location:` | | `Connection.back()` | 位置 | `302 Redirect Location:` | | `Connection.text()` | 文本字符串 | `Content-Type: text/plain` | | `Connection.html()` | HTML 字符串 | `Content-Type: text/html` | | `Connection.json()` | JSON 对象或字符串 | `Content-Type: application/json` | | `Connection.file()` | 文件内容(流、缓冲区、字符串或路径) | 如果文件扩展名可以确定适当的 MIME 类型,则设置`Content-Type`。如果一个指定的大小在一个选项散列中被传递给`file()`,或者如果有效负载是一个 can Node.js 可以解析并统计以确定文件大小的文件路径,则`Content-Length`被设置。 |

redirect()back()方法不传递响应体,而是操纵响应中的Location头,将客户端定向到另一个页面。file()方法接受文件内容(以流、缓冲区或字符串的形式)或文件路径,然后读入流中,并将文件内容传递给客户机。

也许 web 服务器对 web 浏览器最常见的响应是 HTML 响应。然而,HTML 页面很少再作为完整的文件存储;开发人员将 HTML 分解成可重用的组件,将标记与模板语言混合,并将模板绑定到动态数据以创建有效的 HTML。

在清单 10-13 中,swig 模板库将两个 swig 模板编译成函数library()(显示用户的图书库)和err500()(显示任何服务器错误)。当 route 处理一个传入的请求时,它从数据库加载图书数据,并使用library()函数将该数据绑定到library.swig模板。这会产生一个有效的 HTML 字符串,然后作为响应体传递给conn.html()。如果在此过程中出现错误,err500()功能会对错误模板和错误消息执行相同的操作。

Listing 10-13. Sending an HTML Response

// example-001/index.js

var swig = require('swig');

// ...

var library = swig.compileFile('./library.swig');

var err500 = swig.compileFile('./err500.swig');

app.get('/library', function (conn) {

var deferred = Q.defer();

db.book.all(deferred.makeNodeResolver());

return deferred.promise.then(function (books) {

return conn.html(200, library({books: books}));

}, function (err) {

return conn.html(500, err500({err: err.message}));

});

});

在清单 10-13 中使用conn.html()而不是conn.send()的好处纯粹是为了方便,因为html()会自动设置适当的Content-Type: text/html标题。conn.text()方法同样适用于text/plain内容类型。

对于 Mach 不换行的内容类型,可以在调用conn.send()之前手动设置头。例如,清单 10-14 中的路由通过在路由返回承诺之前在连接的响应上显式设置一个Content-Type: application/xml头,将库数据作为 XML 而不是 HTML 来传递。然后,图书数据在发送到客户机之前被序列化为 XML 字符串。

Listing 10-14. Setting the Content-Type Header Manually

// example-001/index.js

var xmlify = require('./xmlify');

// ...

app.get('/library.xml', function (conn) {

var deferred = Q.defer();

db.book.all(deferred.makeNodeResolver());

conn.response.setHeader('Content-Type', 'application/xml');

return deferred.promise.then(function (books) {

return conn.send(200, xmlify('books', data));

}, function (err) {

return conn.send(500, xmlify('err', err.message));

});

});

并非所有响应方法都发送内容。conn.redirect()方法将向 HTTP 客户端发送一个Location头,并附带一个它应该跟随的 URL,大概是因为请求的内容在给定的路由上不再可用。相比之下,conn.back()方法只是将客户端引导回它的引用者。如果请求的Referer头为空(例如,用户直接在浏览器的 URL 栏中键入地址),可选的 URL 参数作为后备。

清单 10-15 显示了从 web 应用的根到/library路由的简单重定向。

Listing 10-15. Sending a Redirect Response

// example-001/index.js

// ...

app.get('/', function (conn) {

return conn.redirect('/library');

});

建立联系

到目前为止,显然Connection对象是所有与客户端通信的中心。它保存了关于每个 HTTP 请求和响应的技术细节,并为中间件和路由提供了与 HTTP 响应进行交互和操作的方法。

一个Connection对象有几个对中间件和路由都重要的属性:

  • location
  • request
  • response

位置

属性包含关于连接请求的 URL 目标的信息。表 10-3 显示了它包含的属性和数据。

表 10-3。

Connection Location Data

| 位置属性 | 描述 | 例子 | | --- | --- | --- | | `href` | 完整的 URL。 | `http://user:pass@webapp.com:8080/admin/dashboard.html#news?showWelcome=1` | | `protocol` | 带有尾随冒号的 URL 方案。 | `http:`,`https:` | | `auth` | URL 身份验证凭据(如果提供)。 | `user:pass` | | `host` | 完整的 URL 主机,包括任何非标准端口号(例如,不是 80 或 443)。 | `webapp.com:8080` | | `hostname` | 主机名称 URL。 | `webapp.com` | | `port` | URL 主机端口。 | `8080` | | `pathname` | 没有查询字符串的 URL 路径。 | `/admin/dashboard.html#news` | | `search` | 带问号前缀的 URL 查询字符串。 | `?showWelcome=1` | | `queryString` | 不带问号前缀的 URL 查询字符串。 | `showWelcome=1` | | `query` | 解析为对象哈希的 URL 查询字符串。 | `{showWelcome: 1}` |

如果 location 对象的 API 看起来很熟悉,那是因为 Mach 从现代 web 浏览器中的window.location对象获得了一些灵感。

一些Connection属性作为组合Location和标题数据的有用外观,如表 10-4 所示。

表 10-4。

Location Property Facades on the Connection Object

| 连接属性 | 描述 | 例子 | | --- | --- | --- | | `path` | `Location.pathname` + `Location.search`。 | `/admin/dashboard.html#news?showWelcome=1` | | `auth` | 授权头的值,或`Location.auth`。 | `user:pass` | | `isSSL` | `true`如果`Location.protocol`是“https:”,否则`false`。 | `true` |

请求和响应消息

连接公开了requestresponse属性,它们都是Message的实例,后者是封装 HTTP 消息管道的内部 Mach 类型。

邮件标题

清单 10-14 中的例子说明了如何使用conn.response.setHeader()在响应消息上设置一个单独的头。响应消息还公开了一个执行与Message.setHeader()相同功能的addHeader()方法,但是有一个警告。如果设置了头,它将覆盖以前任何同名的头键/值对。如果添加了一个头,Mach 认为应该将它附加到任何预先存在的同名头上,从而有效地创建一个多值头。

要获取一个特定的标题,用所需的标题名调用Message.getHeader()。如果消息中存在标头,将返回该值。

可以通过Message.headers属性对标题进行整体操作,该属性获取并设置内部标题散列,其关键字是标题名称(如Content-Type)以及相关的标题值。

消息 Cookies

HTTP 请求和响应在 HTTP 服务器之间传递 cookie 值。这些 cookies 是分别存储在CookieSet-Cookie头中的键/值对。Mach 消息解析这些 cookie 值,并通过Message.cookies属性将它们公开为一个对象散列,而Message.getCookie()Message.setCookie()方法的行为类似于它们的面向头的对应物。

消息内容

请求和响应主体作为流存在于每个对象的Message.content属性中。这些流可以通过其他转换流传输,或者在每个Message对象上被完全替换。如果在设置content属性值时使用字符串而不是流,它将自动转换为流。

几个Message方法提供了对其内容流的可选访问。Message.bufferContent()方法将把流读入内存缓冲区,并返回一个结果承诺。当承诺解析时,缓冲区将可供调用代码使用。可以传递一个可选的length参数来限制读入缓冲区的数据量。如果超过实际缓冲区长度,承诺将失败。当消费代码需要将请求或响应主体作为一个整体来处理时,这种方法很有用。如果一个Message已经被缓冲,它的isBuffered属性将返回true

Message.stringifyContent()方法返回内容字符串值的承诺。可以提供可选的长度和编码参数来限制转换的数据量,并对其进行适当的编码。像Message.bufferContent()一样,如果提供了一个最大长度,字符串超过了那个长度,承诺就失效了。

Connection.getParams()方法在幕后调用Message.parseContent()方法,但是这个方法也可能被直接调用,如果需要的话,可能在中间件中调用。它根据媒体类型(例如,URL 编码的)对消息内容应用适当的解析器,并返回解析结果字符串的承诺。它还接受一个最大的length参数。

通用中间件

Mach 与许多通用中间件模块捆绑在一起,这些模块封装了相当标准的 web 服务器功能,尽管 web 服务器可以不使用任何一个中间件而运行。它们都是可选的,可以根据需要添加。

本章中的每个例子都使用了mach.logger中间件在 Mach web 服务器运行时向终端写入 HTTP 请求/响应输出。清单 10-16 展示了通过将中间件传递给app.use()方法,将它附加到应用堆栈上。

Listing 10-16. mach.logger Middleware

// example-002/index.js

// add some middleware

app.use(mach.logger);

// add some routes...

在引擎盖下,中间件只是带有特定签名的功能。稍后将深入研究这个概念,但是一般来说,app.use()方法将首先接受中间件函数,然后是可选的配置参数。

中间件添加到 Mach 应用的顺序很重要,因为每个中间件都可能修改请求和响应。有些中间件,比如Mach.file,可能会阻止连接到达其他中间件或路由处理器。

当 web 服务器收到请求时,它会以上游方式通过中间件传递请求。每个中间件依次处理请求,传递请求,直到链被一个中间件终止,被一个路由处理,或者如果不能被正确处理就产生一个错误。然而,一旦处理了请求,连接就通过中间件链传递回下游,给每个中间件一个评估响应的机会。图 10-1 中的图表粗略地说明了中间件是如何相对于请求和响应流进行评估的。

A978-1-4842-0662-1_10_Fig1_HTML.gif

图 10-1。

Order in which Mach middleware evaluates requests and responses

随着更多中间件添加到示例中,中间件顺序对应用的影响将变得更加明显。

这是什么样的内容?

Mach.contentTypeMach.charset中间件是两个非常简单的函数,如果Content-Type响应头完全丢失,或者没有指定charset值,它们会自动调整响应头。如果路由使用Message.send()提供同质内容(比如 XML 数据),这些就很有用。可以在中间件中指定一个全局覆盖,而不是操纵每个路由中的Content-Type头。这两个中间件都被添加到清单 10-17 中的应用堆栈中。

Listing 10-17. Setting Default Header Values with Mach.contentType and Mach.charset

// example-002/index.js

// ...

app.use(mach.charset);

app.use(mach.contentType, 'application/xml');

// ...

默认情况下,Mach.charset使用utf-8编码,这足以满足大多数目的。可以用传递给app.use()的第二个字符串参数来指定另一种编码。默认情况下,Mach.contentType将使用text/html,但在这种情况下,替代值application/xml被指定。

如上所述,中间件添加到应用堆栈的顺序很重要。在这种情况下,Mach.charset被添加在Mach.contentType之前,考虑到一个charset值是Content-Type头值的一部分(这意味着需要首先设置头值),这看起来似乎是违反直觉的。不过,回想一下,响应是以“下游”方向通过中间件的。由于在路由向响应中添加内容之前,无法确定响应的内容类型和字符集,所以这些中间件将以相反的顺序执行它们的工作。

清单 10-18 中的curl命令显示了一个简单的路由,它从磁盘流式传输一个 XML 文件,而没有在路由中指定一个Content-Type头。冗长的curl请求输出显示Content-Type头已经用 Mach 中间件指定的默认内容类型和字符集进行了设置。

Listing 10-18. Automatically Setting XML Content Headers

// example-002/index.js

// ...

app.use(mach.charset);

app.use(mach.contentType, 'application/xml');

var insultsFilePath = path.join(__dirname, 'insults.xml');

app.get('/insults', function (conn) {

conn.send(200, fs.createReadStream(insultsFilePath));

});

example-002$ curl -v -X GET http://localhost:8080/insults

* Hostname was NOT found in DNS cache

*   Trying ::1...

* Connected to localhost (::1) port 8080 (#0)

> GET /insults HTTP/1.1

> User-Agent: curl/7.37.1

> Host: localhost:8080

> Accept: */*

>

< HTTP/1.1 200 OK

< Content-Type: application/xml;charset=utf-8

< Date: Sat, 28 Mar 2015 18:05:13 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

<?xml version="1.0" encoding="UTF-8"?>

<insults source="The Curse of Monkey Island">

<insult value="Throughout the Caribbean my great deeds are celebrated!">

<reply>Too bad they’re all fabricated.</reply>

</insult>

<insult value="Coming face to face with me must leave you petrified.">

<reply>Is that your face? I thought it was your backside!</reply>

</insult>

<insult value="I can’t tell which of my traits has you the most intimidated.">

<reply>Your odor alone makes me aggravated, agitated, and infuriated!</reply>

</insult>

</insults>

我的王国换一个文件

Mach.file中间件从磁盘上的物理目录中提供静态文件(如.html.css.js)。当请求进入应用堆栈管道时,Mach.file试图将请求 URL pathname匹配到磁盘上的路径,如果匹配成功,Mach.file将静态文件内容传输到连接响应。如果没有与请求路径匹配的静态文件,连接将被传递到下一个中间件(或路由)。

使用Mach.file中间件只是将中间件功能附加到应用堆栈上,并指定静态文件内容将被提供的目录。在清单 10-19 中,一个选项散列作为第二个参数传递给app.use()。该对象包含用于配置Mach.file的几个选项,包括必需的root目录选项。在这个例子中指定了example-003/public目录。

Listing 10-19. Mach.file Middleware

// example-003/index.js

var path = require('path');

// ...

var publicDir = path.join(__dirname, 'public');

app.use(mach.file, {

root: publicDir

// ...other options...

});

// routes

Tip

因为rootMach.file唯一需要的选项,所以目录路径字符串可以作为app.use()的第二个参数,代替选项 hash。

清单 10-20 中的目录树显示了将从example-003/public目录提供的静态内容。Mach 将该目录视为 web 服务器根目录的一部分,因此静态文件和目录都将拥有相对于/(例如http://localhost:8080/styles/index.css)的 URL。

Listing 10-20. Content of the Public Directory

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

◆θ★★★★★★★★★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

★★★★★★★★★★★★★★

由于静态文件内容是只读的,Mach.file将只服务于带有 HTTP 方法 GET 和 HEAD 的请求。它还会拒绝试图访问指定静态文件目录之外的文件路径的请求,向客户端发回一个403 Forbidden响应。

启动 web 服务器后,浏览器可能会指向http://localhost:8080/index.html。Mach 将服务于静态的index.html页面及其素材,所有这些都在图 10-2 中进行了精彩的描述。

A978-1-4842-0662-1_10_Fig2_HTML.jpg

图 10-2。

Serving a static HTML page with Mach.file

您可能已经注意到,index.html文件被显式包含在 URL 中。通常的做法是将一个index.html(或者一些等价的“默认”.html文件)映射到 web 服务器根目录,或者一些其他嵌套的目录路径。然而,如果从 URL 中删除了文件名,Mach.file中间件将生成一个404 Not Found响应。为了改变这种行为并自动提供索引文件,可以将一个index属性添加到选项散列中。如果该属性为“truthy”,那么Mach.file将自动在 URL 路径的任何终止段搜索index.html文件,包括应用根。如果需要更细粒度的控制,该属性还可能包含要搜索的文件名数组,并按数组顺序排列优先级。清单 10-21 显示了该属性及其可能的值。

Listing 10-21. The Mach.file index Option Searches for Index Files at Directory Paths

// example-003/index.js

// ...

app.use(mach.file, {

root: publicDir,

index: true

//or, index: ['index.html', 'index.htm', ...]

});

添加了index选项并重启 web 服务器后,访问http://localhost:8080将自动向浏览器提供index.html文件。

Mach.file中间件还可以为没有索引文件的目录生成目录列表。清单 10-22 中的autoIndex选项激活该功能。

Listing 10-22. The Mach.file autoIndex Option Creates a Directory Listing for Directories Without Index Files

// example-003/index.js

// ...

app.use(mach.file, {

root: publicDir,

autoIndex: true

});

浏览到http://localhost:8080/images显示所有图像的目录列表、它们的大小、MIME 类型和最后修改的时间戳,如图 10-3 所示。

A978-1-4842-0662-1_10_Fig3_HTML.jpg

图 10-3。

Auto-indexing the images directory

每个图像文件名都是指向图像本身的超链接,而Parent Directory超链接将浏览器定向到父 URL 段,在本例中是网站根目录。如果同时使用了indexautoIndex选项,任何带有index选项的索引页优先显示,而不是目录列表。

闭嘴

现代浏览器通过在每个请求中发布一个Accept-Encoding: gzip头,自动向 web 服务器请求压缩资源。压缩可以显著减小响应大小,提高满足每个 HTTP 请求所需的速度和带宽。作为交换,服务器支付少量压缩成本,浏览器支付解压缩成本。

Mach 的gzip中间件自动压缩任何带有以下Content-Type头的响应:

  • text/*
  • application/javascript
  • application/json

响应体通过 Node.js zlib模块压缩,压缩后的响应设置了以下头:

  • Content-Encoding: gzip
  • Content-Length: [compressed content length]
  • Vary: Accept-Encoding

Tip

Vary头告诉任何中间 HTTP 缓存,这个响应的变化应该根据特定的头进行缓存,在本例中是Accept-Encoding。如果浏览器 A 请求未压缩的响应,而浏览器 B 请求同一 URL 的压缩响应,HTTP 缓存将存储这两个响应,而不是只存储第一个响应。

清单 10-23 中的代码在设置静态文件服务器之前介绍了Mach.gzip中间件。当一个响应向上游传播时,Mach.gzip将评估请求头以查看Accept-Encoding: gzip是否存在,然后评估响应头以确定Content-Type是否是压缩的候选者。如果两个条件都为真,响应体将被压缩,如清单 10-23 中的curl请求所示。

Listing 10-23. Mach.gzip Compresses Response Bodies

// example-004/index.js

// ...

app.use(mach.gzip);

var publicDir = path.join(__dirname, 'public');

app.use(mach.file, {

root: publicDir,

index: true

});

example-004$ curl -X GET -H "Accept-Encoding: gzip" -v http://localhost:8080/index.html

* Hostname was NOT found in DNS cache

*   Trying ::1...

* Connected to localhost (::1) port 8080 (#0)

> GET /index.html HTTP/1.1

> User-Agent: curl/7.37.1

> Host: localhost:8080

> Accept: */*

> Accept-Encoding: gzip

>

< HTTP/1.1 200 OK

< Content-Type: text/html

< Last-Modified: Tue, 31 Mar 2015 13:52:50 GMT

< Content-Encoding: gzip

< Vary: Accept-Encoding

< Date: Tue, 31 Mar 2015 14:14:09 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

A978-1-4842-0662-1_10_Figa_HTML.jpg

用于对压缩算法(压缩级别、内存消耗、压缩策略等)进行精细控制。),当附加了Mach.gzip中间件时,可以将zlib选项对象传递给应用堆栈。每个选项的技术细节超出了本章的范围。更多细节请参考 Node.js zlib文档。

看看那身体

早些时候,在清单 10-11 中,Connection.getParams()方法用于从查询字符串和 POST 请求体中解析和提取数据。然而,在单独的路线中执行这个步骤会很快变得乏味。Mach.params中间件解除了开发人员的这一责任,自动解析查询字符串和请求主体数据,在连接被传递到路由之前将数据附加到Connection.params(URL 参数数据所在的位置)。

在清单 10-24 中,当数据被发送到路由时,发送主体参数被附加到conn.params对象上。然后,该对象被添加为数据库记录。来自curl命令的输出显示Mach.params中间件按照预期执行。

Listing 10-24. Parsing a Request Body Automatically with Mach.params

// example-005/index.js

// ...

// Mach.params

app.use(mach.params);

app.post('/hero', function (conn) {

var deferred = Q.defer();

db.hero.save(conn.params, deferred.makeNodeResolver());

return deferred.promise.then(function (result) {

return conn.json(201, result);

}, function (err) {

return conn.json(500, {err: err.message});

});

});

example-005$ curl -X POST``http://localhost:8080/hero

-H "Content-Type: application/x-www-form-urlencoded" \

-d "name=Minsc&race=Human&class=Ranger&subclass=Berserker&alignment=Neutral%20Good&companion=Boo"

{"id":6,"isNew":true}

Tip

请记住,URL 参数将始终优先于查询字符串和请求正文参数。如果任何参数源之间存在命名冲突,Mach 首先支持 URL 参数,然后查询字符串参数,最后请求主体参数。

为了验证发布的数据确实被添加到数据库中,可以向清单 10-25 中的路由发送一个请求,该请求带有两个查询字符串参数skiptake。这些参数允许客户通过定义一个偏移量(skip)和从该偏移量加载的英雄数量(take)来浏览可能是大量英雄的集合。因为Mach.params处理请求体和查询字符串,所以不需要手动解析它们。

以下两个curl请求可分别用于查询记录 1–3 和 4–6。张贴的英雄,明斯克,是最后一页数据中的最后一个英雄。

Listing 10-25. Parsing a Query String Automatically with Mach.params params

// example-005/index.js

// ...

// Mach.params

app.use(mach.params);

// ...

app.get('/hero'/*?skip=#&take=#*/, function (conn) {

var skip = Number(conn.params.skip || 0),

take = Number(conn.params.take || 0);

var deferred = Q.defer();

db.hero.page(skip, take, deferred.makeNodeResolver());

return deferred.promise.then(function (heroes) {

return conn.json(200, heroes);

}, function (err) {

return conn.json(500, {err: err.message});

})

});

example-005$ curl -X GET http://localhost:8080/hero?skip=0\&take=3

[{"id":1,"name":"Dynaheir"...},{"id":2,"name":"Imoen"...},{"id":3,"name":"Khalid"...}]

example-005$ curl -X GET http://localhost:8080/hero?skip=3\&take=3

[{"id":4,"name":"Xan"...},{"id":5,"name":"Edwin"...},{"id":6,"name":"Minsc"...}]

在 web 应用中识别和跟踪用户本身就是一个话题。Mach 为简单的安全用例提供了基本的身份验证支持,并提供了可以容纳更多安全用例的持久会话支持。

与所有其他中间件一样,Mach.basicAuth中间件被添加到应用堆栈中,并且需要一个简单的验证函数作为其唯一的参数。这个函数有两个参数,用户名和密码,这两个参数都是从随请求一起发送的身份验证凭证中解析出来的。验证函数可能返回三个值之一:

  • 经过验证的用户的用户名
  • 如果验证失败,则为“falsy”值
  • 将通过有效用户的用户名解析的承诺,或通过虚假值拒绝的承诺

清单 10-26 中的 web 服务器将为任何经过验证的用户提供一个index.html文件。Mach.basicAuth中间件将拦截每个请求,并在数据库中查询任何提供的凭证。db.user.byCredential()方法返回一个承诺,该承诺将由经过身份验证的用户解决,或者因出错而被拒绝。如果被解析,用户名被返回并通过承诺链传播,最终被设置为Connection.remoteUser的值。如果出现错误,将返回一个布尔值false,向客户端发送一个带有适当的WWW-Authenticate头值的401 Unauthorized响应。

Listing 10-26. Securing Routes with Basic Authentication

// example-006/index.js

// ...

// Mach.basicAuth

app.use(mach.basicAuth, function (username, password) {

return db.user.byCredential(username, password).then(function (user) {

return user.username;

}, function (/*err*/) {

return false;

});

});

var indexPath = path.join(__dirname, 'index.html');

app.get('/', function (conn) {

return conn.html(200, fs.createReadStream(indexPath));

});

当服务器正在运行并且用户试图访问http://localhost:8080时,他将被提示输入凭证以响应基本认证挑战。图 10-4 显示了 Chrome 显示的模态窗口。

A978-1-4842-0662-1_10_Fig4_HTML.jpg

图 10-4。

A browser prompts the user for credentials when Basic Authentication fails

空载时段的速度

用户通过身份验证后,通常会在服务器会话中跟踪特定于用户的数据。将Mach.session中间件添加到应用堆栈中可以自动支持会话 cookie。在Mach.session options 对象上唯一需要的配置属性是一个用于加密会话数据的秘密会话密钥。清单 10-27 显示了在定义任何路由之前添加到堆栈中的会话中间件。

Listing 10-27. Adding Session Middleware to the Application Stack

// example-007/index.js

// ...

var sessionSecret = 'c94ac0cf8f3b89bf9987d1901863f562592b477b450c26751a5d6964cbdce9eb085c013d5bd48c7b4ea64a6300c2df97825b9c8b677c352a46d12b8cc5879554';

// Mach.session

app.use(mach.session, {

secret: sessionSecret

});

var quizView = swig.compileFile('./quiz.swig');

app.get('/', function (conn) {

return conn.html(200, quizView(conn.session));

});

// ...

清单 10-27 中的路由向浏览器发送一个 HTML 测验,如清单 10-28 所示。这个测验是一个 swig 模板,它将会话对象的namequestcolour属性作为每个输入的值进行插值。第一次访问该路由时,会话对象将为空,因此这些输入将没有值。

Listing 10-28. A Perplexing Quiz (What Will Be Your Answers?)

<h1>Questions, three.</h1>

<form method="post" action="/questions/three">

<fieldset>

<h2>What... is your name?</h2>

<div>

<input name="name" type="text" value="{{name}}" />

</div>

<h2>What... is your quest?</h2>

<div>

<input name="quest" type="text" value="{{quest}}" />

</div>

<h2>What... is your favourite colour?</h2>

<div>

<input name="colour" type="text" value="{{colour}}" />

</div>

<div>

<button>Cross the Bridge of Death</button>

</div>

</fieldset>

</form>

当表单被发送到/questions/three路由时,如清单 10-29 所示,表单值从请求和会话对象中提取,并用于填充会话对象。然后,用户被重定向到成功页面,在该页面中,他或她可以选择再次参加测验。

Listing 10-29. Setting Session Properties in a Route

// example-007/index.js

// ...

var successView = swig.compileFile('./success.swig');

var errView = swig.compileFile('./err.swig');

app.post('/questions/three', function (conn) {

return conn.getParams().then(function (params) {

conn.session.name = params.name;

conn.session.quest = params.quest;

conn.session.colour = params.colour;

return conn.html(201, successView());

}, function (err) {

return conn.html(500, errView(err));

});

});

当用户返回测验页面时,字段会自动填充上一次给出的每个问题的答案。回想一下,在清单 10-28 中,会话被绑定到测验模板,由于值先前存储在会话对象中,它们现在也可用于模板。图 10-5 显示了预先填充的表单值以及用于将浏览器连接到服务器端会话的会话 cookie。

A978-1-4842-0662-1_10_Fig5_HTML.jpg

图 10-5。

Mach session cookie

由于默认情况下Mach.session使用 cookie 存储,当中间件被添加到堆栈中时,有许多额外的特定于 cookie 的选项属性可以被设置,如表 10-5 中所述。

表 10-5。

Mach.session cookie options

| 财产 | 描述 | | --- | --- | | `name` | cookie 的名称。默认为`_session`。 | | `path` | Cookie 路径。默认为`/`。 | | `domain` | Cookie 域。默认为`null`。 | | `secure` | 只把饼干送到 HTTPS。默认为`false`。 | | `expireAfter` | cookie 过期的秒数。默认为`0`(永不过期)。 | | `httpOnly` | `true`将此 cookie 限制为 HTTP/S API。默认为`true`。 |

然而,Mach 会话存储并不局限于 cookies。它本身支持内存和 Redis 会话。要更改中间件的会话存储机制,请从mach/middleware/session/*中选择require()适当的模块。通过设置选项对象上的store属性,将该模块的新实例添加到会话配置中。清单 10-30 展示了如何用 Redis 会话存储替换默认的 cookie 会话存储。

Listing 10-30. Using Redis As a Session Store

// example-008/index.js

// ...

var RedisStore = require('mach/lib/middleware/session/RedisStore');

// Mach.session

app.use(mach.session, {

store: new RedisStore({url: 'redis://127.0.0.1:6379'})

});

// ...

改造后的班底

Mach 的modified中间件可以简单地通过使用标准的 HTTP 头,通知 HTTP 客户端自从上一次请求资源以来,所请求的资源没有被修改。在传递响应之前,Mach.modified可以处理两种资源修改场景。

ETag 和 If-无-匹配

Web 服务器可以通过在响应的ETag头中包含某种版本标识符(通常是消息摘要)来识别所请求资源的特定版本。在对同一资源的后续请求中,可以在If-None-Match请求头中将该标识符发送回服务器。如果资源没有改变——也就是说,如果它的版本标识符没有改变 web 服务器可能用一个304 Not Modified响应来响应,在响应体中省略实际的资源。当这种情况发生时,客户端知道资源没有改变,并且它必须继续使用它从上一个请求中接收到的数据。清单 10-31 中的代码展示了如何将每个图书对象的摘要作为ETag响应头添加到每个图书路径中。

Listing 10-31. Adding the ETag Header to Each Book Response

// example-009/index.js

var jsonHash = require('./json-hash');

// ...

app.use(mach.modified);

app.get('/book/:id', function (conn) {

var id = Number(conn.params.id);

var deferred = Q.defer();

db.book.findByID(id, deferred.makeNodeResolver());

return deferred.promise.then(function (book) {

if (!book) {

return conn.json(404);

}

conn.response.setHeader('ETag', jsonHash(book));

return conn.json(200, book);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

app.put('/book/:id', function (conn) {

var book = Book.fromParams(conn.params);

var deferred = Q.defer();

db.book.save(book, deferred.makeNodeResolver());

return deferred.promise.then(function (result) {

conn.response.setHeader('ETag', jsonHash(book));

return conn.json(result.isNew ? 201 : 200, book);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

清单 10-32 中的第一个curl请求获取一本书,弗兰克·赫伯特的《沙丘》。响应中的ETag头显示消息摘要cf0fdc372106caa588f794467a17e893,响应体包含序列化的 JSON book 数据。(ETag消息摘要可能因您的操作系统而异。对于每个curl命令,使用您在 HTTP 响应头中收到的ETag进行进一步的比较。)

第二个curl请求使用了相同的 URL,但是也包含了一个If-None-Match头,带有在之前的响应中发送的ETag值。因为 book 实体在服务器上没有改变(因此它的消息摘要保持不变),Mach 发送一个没有响应体的304 Not Modified响应。

Listing 10-32. Using ETag and If-None-Match Headers to Test for Content Modification

example-009$ curl -v -X GET http://localhost:8080/book/1

...

< HTTP/1.1 200 OK

< ETag: cf0fdc372106caa588f794467a17e893

< Content-Type: application/json

< Date: Mon, 06 Apr 2015 01:39:11 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

{"id":1,"title":"God Emperor of Dune","author":"Frank Herbert"...}

example-009$ curl -v -H "If-None-Match: cf0fdc372106caa588f794467a17e893" -X GET http://localhost:8080/book/1

...

< HTTP/1.1 304 Not Modified

< ETag: cf0fdc372106caa588f794467a17e893

< Content-Type: application/json

< Content-Length: 0

< Date: Mon, 06 Apr 2015 01:39:31 GMT

< Connection: keep-alive

<

在清单 10-33 中,第一个curl请求执行一个 HTTP PUT,将弗兰克·赫伯特的全名分配给《沙丘》这本书。第二个curl请求与清单 10-32 中的第二个请求相同,但是这次服务器用 HTTP 200 OK响应,因为消息摘要不同,反映了更新的图书资源。随后的获取将在响应的ETag头中使用较新的消息摘要。

Listing 10-33. Updated ETag Header Passes the If-None-Match Check

example-009$ curl -X PUT``http://localhost:8080/book/1

-H "Content-Type: application/x-www-form-urlencoded" \

-d "title=God%20Emperor%20of%20Dune&author=Franklin%20Patrick%20Herbert&publisher=Victor%20Gollancz&publicationDate=2003-03-13T06:00:00.000Z&seriesTitle=Dune%20Chronicles&seriesPosition=4"

{"id":1,"title":"God Emperor of Dune","author":"Franklin Patrick Herbert"...}

example-009$ curl -v -H "If-None-Match: cf0fdc372106caa588f794467a17e893" -X GET http://localhost:8080/book/1

...

< HTTP/1.1 200 OK

< ETag: 2595cd82c364b04473358bb2d0153774

< Content-Type: application/json

< Date: Mon, 06 Apr 2015 01:54:33 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

{"id":1,"title":"God Emperor of Dune","author":"Franklin Patrick Herbert"...}

上次修改和如果修改自

Last-Modified响应头类似于前一节中提到的ETag头,但是它包含一个时间戳,而不是版本标识符,该时间戳指示资源最后一次更改的时间。当一个 HTTP 客户端发出请求时,它可以在一个If-Modified-Since头中提供时间戳,然后在服务器上与资源的时间戳进行比较。web 服务器将只提供资源的较新版本;否则,它将发出304 Not Modified响应,指示客户端应该依赖于之前的资源,因为未修改的资源不会包含在响应体中。

清单 10-34 中的代码使用每个作者记录上的lastModified时间戳来设置每个响应中的Last-Modified头值。当作者记录被更新时,这个时间戳由数据库自动改变。

Listing 10-34. Adding the Last-Modified Header to Each Author Response

// example-009/index.js

app.get('/author/:id', function (conn) {

var id = Number(conn.params.id);

var deferred = Q.defer();

db.author.findByID(id, deferred.makeNodeResolver());

return deferred.promise.then(function (author) {

if (!author) {

return conn.json(404);

}

conn.response.setHeader('Last-Modified', author.lastModified);

return conn.json(200, author);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

app.put('/author/:id', function (conn) {

var author = Author.fromParams(conn.params);

var deferred = Q.defer();

db.author.save(author, deferred.makeNodeResolver());

return deferred.promise.then(function (result) {

conn.response.setHeader('Last-Modified', author.lastModified);

return conn.json(result.isNew ? 201 : 200, author);

}, function (err) {

return conn.json(500, {error: err.message});

});

});

在清单 10-35 中,第一个curl请求获取作者休豪伊,响应通知客户端最后一次修改休的记录是在2015-04-06T00:26:30.744Z上。在第二个请求中,这个 ISO 日期字符串被用作If-Modified-Since头的值,作为响应,Mach 发送一个304 Not Modified Response

Listing 10-35. Using Last-Modified and If-Modified-Since Headers to Test for Content Modification

example-009$ curl -v -X GET http://localhost:8080/author/1

...

< HTTP/1.1 200 OK

< Last-Modified: 2015-04-06T00:26:30.744Z

< Content-Type: application/json

< Date: Mon, 06 Apr 2015 01:41:31 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

{"id":1,"name":"Hugh Howey","website":"http://www.hughhowey.com

example-009$ curl -v -H "If-Modified-Since: 2015-04-06T00:26:30.744Z" -X GET http://localhost:8080/author/1

...

< HTTP/1.1 304 Not Modified

< Last-Modified: 2015-04-06T00:26:30.744Z

< Content-Type: application/json

< Content-Length: 0

< Date: Mon, 06 Apr 2015 01:42:27 GMT

< Connection: keep-alive

<

可以预见,一旦记录被更新(因此,它的lastModified日期被更改),Mach 的响应将在响应体中包含更新的 JSON 数据,以及一个新的Last-Modified响应头。清单 10-36 显示了这个带有两个curl请求的交换。

Listing 10-36. Updated Last-Modified Header Passes the If-Modified-Since Check

example-009$ curl -X PUT``http://localhost:8080/author/1

-H "Content-Type: application/x-www-form-urlencoded" \

-d "name=Hugh%20C.%20Howey&website=http%3A%2F%2F``www.hughhowey.com&genres=Science%20Fiction%2CFantasy%2CShort%20Stories

{"id":1,"name":"Hugh C. Howey","website":"http://www.hughhowey.com

example-009$ curl -v -H "If-Modified-Since: 2015-04-06T00:26:30.744Z" -X GET http://localhost:8080/author/1

...

< HTTP/1.1 200 OK

< Last-Modified: 2015-04-06T02:09:01.783Z

< Content-Type: application/json

< Date: Mon, 06 Apr 2015 02:09:09 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

{"id":1,"name":"Hugh C. Howey","website":"http://www.hughhowey.com

这些不是你要找的路线...

Mach 可以用Mach.rewrite中间件重写请求 URL。虽然不像 Apache 的mod_rewrite模块这样复杂,但是Mach.rewrite既简单又灵活,足以处理常见的重写用例。

Mach.rewrite添加到应用堆栈时,必须提供两个必需的参数:

  • 匹配传入请求 URL 的正则表达式对象(或将被转换为正则表达式对象的字符串)
  • 请求将被静默转发到的路由路径

考虑一个用例,作者将他的博客从基于 PHP 的系统迁移到运行 Mach 的 Node.js 系统。搜索引擎已经将他对这个世界的有价值的贡献编入索引,所以他的网址是永久固定的。通过用Mach.rewrite设置重写规则,他可以确保他的旧 URL 仍然对外界可用,同时将它们映射到他的新路由方案。

清单 10-37 中的Mach.rewrite中间件使用一个复杂的正则表达式对象来建立参数的捕获组,这些参数将作为新博客文章路径的 URL 参数输入:yearmonthdayslug。在正则表达式之后,按照位置顺序,用每个提取的捕获组的占位符来定义表示重写的 URL 路由的字符串。在引擎盖下Mach.rewrite使用String.prototype.replace()方法对提取的值进行插值。

Listing 10-37. Rewriting a URL with Parameters

// example-010/index.js

var blogView = swig.compileFile(path.join(__dirname, 'blog.swig'));

var errView = swig.compileFile(path.join(__dirname, 'err.swig'));

app.use(

mach.rewrite,

// converts: /index.php/blog/2015-04-02/bacon-ipsum-dolor-amet

new RegExp('\/index\.php\/blog\/([\\d]{4})-([\\d]{2})-([\\d]{2})\/([^\/]+)'),

// into: /blog/2015/04/02/bacon-ipsum-dolor-amet

'/blog/$1/$2/$3/$4'

);

// :year=$1, :month=$2, :day=$3, :slug=$4

app.get('/blog/:year/:month/:day/:slug', function (conn) {

var year = Number(conn.params.year || 0),

month = Number(conn.params.month || 0),

day = Number(conn.params.day || 0),

slug = conn.params.slug || '';

var deferred = Q.defer();

db.posts.find(year, month, day, slug, deferred.makeNodeResolver());

return deferred.promise.then(function (post) {

if (post) {

return conn.html(200, blogView({posts: [post]}));

}

return conn.html(404, errView({message: 'I haven\t written that yet.'}))

}, function (err) {

return conn.html(500, errView(err));

});

});

对于一个 HTTP 客户端,比如图 10-6 所示的网络浏览器(或者一个搜索引擎机器人),重写的 URL 仍然是完全有效的,尽管在内部它们已经变得不同了。这不同于 HTTP 重定向或转发,在 HTTP 重定向或转发中,客户端负责解释响应头,然后加载另一个页面。在这种情况下,客户一无所知。

A978-1-4842-0662-1_10_Fig6_HTML.jpg

图 10-6。

Rewritten URLs appear unmodified to the HTTP client

清单 10-38 中的重写规则执行完全相同的工作,但是使用一个简单的字符串而不是正则表达式进行请求 URL 匹配,因为它不捕获任何参数。请注意,Mach.rewrite会在将任何字符串转换成正则表达式之前自动转义它。如果您自己对这些字符串进行转义,它们将变成双重转义,您的匹配规则将失败。

Listing 10-38. Rewriting a URL with No Parameters

// example-010/index.js

app.use(

mach.rewrite,

'/index.php/blog',

'/blog'

);

app.get('/blog', function (conn) {

var deferred = Q.defer();

db.posts.all(deferred.makeNodeResolver());

return deferred.promise.then(function (posts) {

return conn.html(200, blogView({posts: posts}));

}, function (err) {

return conn.html(500, errView(err));

});

});

拥有最多的主机

Mach.mapper的独特之处在于,它在 Mach 的正常路由机制之上执行自己的路由方式。到目前为止,一直假设路由路径存在于单个主机(本地主机),并且都与该主机的名称相关。Mach.mapper中间件通过引入中间件过滤器改变了这种模式,该过滤器可以通过主机名和 URL 路径名来路由请求,这与 Apache 的虚拟主机的精神非常相似,但占用的内存要少得多。

为了演示 Mach 的映射特性是如何工作的,执行清单 10-39 中的echo命令,将两个别名添加到您计算机上的/etc/hosts文件中。因为/etc/hosts在类 Unix 系统上受到保护,所以sudo命令用于提升权限。如果这个命令失败,你也可以用 vim 或 nano 这样的文本编辑器手动添加别名到/etc/hostscat命令将把/etc/hosts的内容输出到终端,这样您就可以验证条目是否已经被添加。

Listing 10-39. Adding Aliases to /etc/hosts

example-011$ sudo echo "127.0.0.1 house-atreides.org" >> /etc/hosts

example-011$ sudo echo "127.0.0.1 house-harkonnen.org" >> /etc/hosts

example-011$ cat /etc/hosts

...

127.0.0.1 house-atreides.org

127.0.0.1 house-harkonnen.org

Tip

如果您的计算机运行 Microsoft Windows 操作系统,您将需要修改文件C:\Windows\System32\drivers\etc\hosts。该文件通常受 Windows 保护,因此您需要使用以管理员权限运行的文本编辑器来修改它。

一旦修改了/etc/hosts文件,使用清单 10-40 中所示的ping命令来验证每个别名都解析为127.0.0.1(本地主机)。

Listing 10-40. Using ping to Test Aliases in /etc/``hosts

example-011$ ping -t 3 house-atreides.org

PING house-atreides.org (127.0.0.1): 56 data bytes

64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.044 ms

64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.118 ms

64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.074 ms

--- house-atreides.org ping statistics ---

3 packets transmitted, 3 packets received, 0.0% packet loss

round-trip min/avg/max/stddev = 0.044/0.079/0.118/0.030 ms

清单 10-41 中的 web 服务器演示了Mach.mapper是如何工作的。它像任何正常的 Mach web 服务器一样开始:创建一个应用栈,添加一些中间件,然后事情有点不同。还创建了两个额外的独立应用堆栈— atreidesAppharkonnenApp—每个堆栈都被分配了一个路由。事实上,所有的应用栈都有相同的路径,GET /about

Listing 10-41. Mach.mapper Middleware Maps Apps to Hostnames

// example-011/index.js

// ...

var app = mach.stack();

app.use(mach.logger);

app.use(mach.params);

app.use(mach.file, path.join(__dirname, 'public'));

var atreidesApp = mach.stack();

atreidesApp.get('/about', function (conn) {

var pagePath = path.join(__dirname, 'atreides.html');

return conn.html(200, fs.createReadStream(pagePath));

});

var harkonnenApp = mach.stack();

harkonnenApp.get('/about', function (conn) {

var pagePath = path.join(__dirname, 'harkonnen.html');

return conn.html(200, fs.createReadStream(pagePath));

});

app.use(mach.mapper, {

'http://house-atreides.org/

'http://house-harkonnen.org/

});

app.get('/about', function (conn) {

var pagePath = path.join(__dirname, 'about.html');

return conn.html(200, fs.createReadStream(pagePath));

});

通过检查每个 route 函数的主体可以清楚地看到,这些应用在被调用时都会呈现不同的 HTML 页面。这些路由可以共存,因为Mach.mapper中间件在其选项散列中将atreidesApp应用栈映射到hose-atreides.org主机名,将harkonnenApp映射到house-harkonnen.org主机名。当 web 服务器接收到请求时,它们会通过Mach.mapper中间件,在那里对Connection.hostname属性进行评估。如果它与 mapping options 对象上的任何键匹配,则连接将被传递给与该键相关联的应用堆栈,以便进一步处理。这有几个有趣的结果:

  • 因主机名而异的应用栈可能有相同的路由,比如GET /about
  • 由于中间件直接连接到应用堆栈,每个堆栈可能有不同的中间件。
  • Mach.mapper之前添加到托管应用堆栈的任何中间件将被应用到Mach.mapper管理的所有应用堆栈。
  • Mach.mapper之前添加到托管应用堆栈的任何路由将在Mach.mapper有机会进行基于主机名的路由之前被评估。因为没有Mach.mapper主机名不会被评估,所以主机应用堆栈上具有相同 URL 路径名值的路由将被解析,而不管主机名如何。
  • Mach.mapper之后添加到托管应用堆栈的任何路由将充当“失效”路由。如果没有映射的应用堆栈可以处理对连接主机名的请求,那么将评估这些路由。

Tip

Mach.mapper添加主机时,协议很重要,但端口号不重要,因此可以安全地省略端口号。Mach 只监听一个端口。主机名密钥应该总是以斜杠结尾。

运行 web 服务器,然后启动 web 浏览器并导航到 URL http://localhost:8080/about。这将打开如图 10-7 所示的页面,该页面来自托管应用堆栈上定义的/about路由。该路由处理了该请求,因为主机名localhostMach.mapper配置中的任何主机名都不匹配。

A978-1-4842-0662-1_10_Fig7_HTML.jpg

图 10-7。

The /about route from localhost

清单 10-42 中的页面源代码显示,两个超链接锚,一个是阿崔迪斯家族的,一个是哈肯南家族的,都链接到不同的主机。点击任一链接,将呈现由Mach.mapper定义的映射路线页面。请注意,尽管在声明映射的应用堆栈时端口号并不重要,但它们必须包含在页面超链接中,否则浏览器将尝试自动使用端口 80。

Listing 10-42. Anchors on the Default /about Page Link to Different Hosts

<h1>Great Houses of Arrakis</h1>

<h2>

<a href="http://house-atreides.org:8080/about

</h2>

<h2>

<a href="http://house-harkonnen.org:8080/about

</h2>

图 10-8 显示了完全渲染后的阿崔迪斯“关于”页面上的房子。图 10-9 显示了众议院哈肯南“关于”页面。

A978-1-4842-0662-1_10_Fig9_HTML.jpg

图 10-9。

The /about route from house-harkonnen.``org

A978-1-4842-0662-1_10_Fig8_HTML.jpg

图 10-8。

The /about route from house-atreides.``org

查看两个“关于”页面的源代码会发现一些有趣的事情。两个页面上引用的图像,例如清单 10-43 中的src属性,没有指定主机名前缀。

Listing 10-43. Images Do Not Have Hostname Prefixes

<img class="flag" srcimg/Atreides_guidon_pennant.svg" />

这是可能的,因为将example-011/public目录公开为静态资源目录的Mach.file中间件是在Mach.mapper之前添加到托管应用堆栈的,因此会影响上游的所有应用堆栈。所有静态资源(图像、字体、脚本等)都可以存储在同一个位置,无论主机名如何,所有应用堆栈都可以使用这些资源。当然,如果需要的话,每个应用栈可以使用另一个Mach.file中间件来公开不同的静态素材目录。

定制中间件

创建定制的 Mach 中间件相对简单。创建定制中间件时,通常涉及三个“层”:

A top-level function that is responsible for capturing an “app” and any options that are passed to the middleware via app.use(). This layer returns...   a function that will receive an incoming request connection. This function may do one of two things. It may manipulate the connection directly and send a response without passing the connection through the remainder of the application stack (an authentication failure, for example), or...   it may send the request downstream and then handle the response when the application stack’s promise chain has resolved.

清单 10-44 中的中间件展示了工作中的所有三个阶段。

Listing 10-44. Custom Middleware Module That Adds an API Version Header to the Response

// example-012/api-version.js

'use strict';

// layer 1

function apiVersion(app, options) {

// layer 2

return function (conn) {

// layer 3

return conn.call(app).then(function () {

conn.response.headers['X-API-Version'] = options.version;

});

};

}

module.exports = apiVersion;

顶层函数apiVersion()通过module.exports公开。当中间件附加到应用栈时,它将被传递给app.use()。它捕获应用实例和选项对象(第 1 层),将两者保存在一个闭包中以供进一步处理。当接收到请求时,返回的函数(第 2 层)接收连接对象并做出决定。这个特定的中间件只关心将“API 版本”头添加到响应中,所以此时它调用Connection.call()方法,将应用本身作为唯一的参数传递。

在这一点上,一些歧义是必要的。在 Mach 中,通过调用Mach.stack()创建的“应用堆栈”是一个接受连接并返回Connection.call()值的函数。这个过程与 Mach 中间件功能所做的是一样的。事实上,这几乎与路由的功能相同:无论是Connection.call()还是所有路由都返回作为单个承诺链存在的承诺对象!

这种相似性的实际含义是,Mach 中间件功能接收的“应用”可能是下游中间件的另一部分,也可能是路由,这取决于中间件/路由添加到应用堆栈的顺序。然后,通过将app对象传递给conn.call(),定制中间件将连接传播到下游的所有东西,不管是什么。当conn.call()返回的承诺解决时(第 3 层),所有下游中间件和/或路由已经处理了连接对象,定制中间件可以决定它必须对响应做什么(如果有的话)。

在清单 10-44 中,一旦响应再次向上游移动,API 版本号就被分配给响应对象上的自定义X-API-Version头。如果这个中间件被设计成在将请求传递到下游之前修改请求,那么它应该在调用conn.call()之前就这样做了。

定制中间件以与 Mach 的原生中间件相同的方式附加到应用栈,如清单 10-45 所示。在这个例子中,apiVersion中间件将接收一个版本号为 1.2 的 options 对象,它将作为一个定制的头值添加到每个响应中。请注意,Mach.gzip被添加到堆栈中的apiVersion之后,这意味着apiVersion中间件接收的“app”参数将是Mach.gzip的中间件函数,因为它存在于堆栈的下游。

Listing 10-45. Adding Custom Middleware to the Application Stack

// example-012/index.js

var apiVersion = require('./api-version');

// create a stack

var app = mach.stack();

// custom middleware

app.use(apiVersion, {version: '1.2'});

// out-of-the-box middleware

app.use(mach.gzip);

app.get('/numbers', function (conn) {

return conn.json(200, [4, 8, 15, 16, 23, 42]);

});

当在清单 10-46 中查询 web 服务器时,可以在详细的响应中看到X-API-Version头。

Listing 10-46. API Version Middleware Response Header

example-012$ curl -v -X GET http://localhost:8080/numbers

* Hostname was NOT found in DNS cache

*   Trying ::1...

* Connected to localhost (::1) port 8080 (#0)

> GET /numbers HTTP/1.1

> User-Agent: curl/7.37.1

> Host: localhost:8080

> Accept: */*

>

< HTTP/1.1 200 OK

< Content-Type: application/json

< X-API-Version: 1.2

< Date: Fri, 10 Apr 2015 01:41:42 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

[4,8,15,16,23,42]

Mach,HTTP 客户端

Mach 不仅仅是一个 HTTP 服务器。其内部架构允许它在多种环境中扮演多种角色。事实上,对 Mach 源代码的研究表明,Mach 中特定于服务器的部分是作为扩展实现的。这意味着 Mach 的核心对象,比如ConnectionLocationMessage,可以跨越多个用例。

清单 10-47 中的代码类似于目前给出的 web 服务器示例。创建了一个 Mach 应用栈来服务 HTTP 请求,添加了文件中间件来服务来自example-013/public的静态内容,并且向栈注册了一个单独的路由GET /mach/tags。然而,这个路径中的代码利用 Mach 的 HTTP 客户端特性向 Github API 发送一个对 Mach 的所有存储库标签的 GET 请求。

Listing 10-47. Mach As Both Server and Client

// example-013/index.js

var app = mach.stack();

app.use(mach.logger);

app.use(mach.file, {

root: path.join(__dirname, 'public'),

index: true

});

app.get('/releases', function (conn) {

function addUserAgent(conn) {

conn.request.setHeader('User-Agent', 'nicholascloud/mach');

}

return``mach.get('https://api.github.com/repos/mjackson/mach/tags', addUserAgent)

var tags = [];

JSON.parse(conn.responseText).forEach(function (tagData) {

tags.push(tagData.name);

});

return tags.sort(semver.rcompare);

}).then(function (tags) {

return conn.json(200, tags);

}, function (err) {

return conn.json(500, {err: err.message});

});

});

Mach 的 HTTP 客户端方法看起来很像 Mach 的路由方法,但是它们存在于 Mach 模块本身,而不是应用堆栈上。Mach 可以对任何标准的 HTTP 方法发出请求。

在清单 10-47 中,Mach.get()方法接收请求 URL 作为它的第一个参数,并接收一个可选函数,在它作为第二个参数发送之前修改连接的请求。这个请求连接到 Github API,并获取mjackson/mach存储库的标记信息。因为 Github API 在所有传入请求中都需要一个User-Agent头,所以addUserAgent()函数通过添加我自己的源代码分支作为代理来修改传出的请求(根据 Github 的指南)。

像 Mach API 的其他部分一样,Mach.get()方法返回一个承诺。如果承诺被解析,它的值将是带有响应消息属性的连接对象。如果被拒绝,将向失败回调传递一个错误。

Github JSON 数据作为字符串存在于Connection.responseText属性中(或者作为流存在于Connection.response.content)。一旦这些数据被反序列化,就提取标记名,按降序排序,然后沿着 promise 链传递。

当用清单 10-48 中的curl查询 web 服务器时,所有 Mach 的发布标签都以 JSON 数组的形式提交。

Listing 10-48. Fetching Mach Releases with cURL

example-013$ curl http://localhost:8080/releases

["v1.3.4","v1.3.3","v1.3.2","v1.3.1","v1.3.0"...]

清单 10-49 中的 HTML 页面使用这些 JSON 数据。注意,它也使用Mach.get()连接到本地 web 服务器。因为 Mach 的环境相关特性是作为扩展实现的,所以 Mach 在服务器和浏览器代码中都很有用。

Note

因为 Mach 是 Node.js 模块,所以它可以被任何 CommonJS 模块加载器使用,比如 Browserify 或 WebPack。所有其他的使用,比如清单 10-49 中显示的普通脚本包含,应该使用 Mach Github 库中的全局 Mach 构建。

浏览到http://localhost:8080查看所有 Mach 版本的链接列表。

Listing 10-49. Mach.get() in the Browser

<!-- example-013/public/index.html -->

<h1>Mach Releases</h1>

<h2>Git you one!</h2>

<ul id="tags"></ul>

<script src="/vendor/mach.min.js"></script>

<script>

(function (mach, document) {

var href = 'https://github.com/mjackson/mach/releases/tag/:tag

var ul = document.querySelector('#tags');

mach.get('/releases').then(function (conn) {

var tags = JSON.parse(conn.responseText);

tags.forEach(function (tag) {

var li = document.createElement('li');

var a = document.createElement('a');

a.innerHTML = tag;

a.setAttribute('href', href.replace(':tag', tag));

a.setAttribute('target', '_blank');

li.appendChild(a);

ul.appendChild(li);

});

});

}(window.mach, window.document))

</script>

Mach,HTTP 代理

虽然在技术上是中间件,但是 Mach 的 HTTP 代理功能可以单独使用来创建完整的 HTTP 代理服务器,或者与现有的应用堆栈集成来代理某些路由。对于逐段迁移 web 应用,同时仍然将调用代理到遗留 web 应用,或者对于通过 web 应用本身将调用代理到外部或第三方服务来避免浏览器中的同源问题,这可能是一个有用的工具。

清单 10-50 中的代码创建了一个简单的 Mach 应用,它同时服务于一个根应用路径和来自public目录的静态文件。在路由声明之后,通过使用另一个服务器的 HTTP 方案、主机名和端口调用Mach.proxy()来创建代理应用。对于这个例子,当 web 应用运行时,它将在端口 8080 上监听一些请求,同时将其他请求代理到另一个在端口 8090 上运行的 web 服务器。当两者都被传递给app.use()时,这个代理应用栈成为Mach.proxy的中间件选项参数。

Listing 10-50. Proxying Requests to Another Web Server

// example-014/web.js

var app = mach.stack();

app.use(mach.logger);

app.use(mach.file, path.join(__dirname, 'public'));

app.get('/', function (conn) {

var pagePath = path.join(__dirname, 'index.html');

return conn.html(200, fs.createReadStream(pagePath));

});

var apiProxy = mach.createProxy('``http://localhost:8090

app.use(mach.proxy, apiProxy);

mach.serve(app, 8080);

通常,中间件在路由之前被添加到应用堆栈中,以便它们有机会检查请求,如果不满足某些条件,就中断中间件承诺链,或者修改请求并传递它以供进一步处理。不幸的是,Mach.proxy相当愚蠢,这意味着它不区分请求;任何通过Mach.proxy的请求都将被发送到代理服务器。如果应用混合使用本地路由和代理路由,有两种方法可以处理这种“限制”:

  • 添加应用路由后,添加代理中间件。这确保了如果一个应用路由可以处理一个请求,它将处理它,在它到达Mach.proxy之前停止连接的传播。这就是清单 10-50 中采用的方法。
  • 将代理中间件封装在一个轻量级的定制中间件函数中,该函数区分并只将某些请求转发给代理。因为它过滤请求,所以定制中间件可以在任何路由之前添加到堆栈中。清单 10-51 中说明了这种替代方法。

Listing 10-51. Wrapping a Proxy Application in Custom Middleware

// example-014/web2.js

var apiProxy = mach.createProxy('``http://localhost:8090

app.use(function (app) {

return function (conn) {

if (conn.location.pathname.indexOf('/api') !== 0) {

// not an API method, call the app stack normally

return conn.call(app);

}

// API method, call the proxy app stack

return conn.call(apiProxy);

};

});

app.get('/', function (conn) { /*...*/ });

毫不奇怪,接收代理请求的模拟 API 服务器是另一个 Mach 服务器。它公开了两个常规的 JSON 路由,如清单 10-52 所示:一个用于获取投票的统计列表,另一个用于提交单个投票。

Listing 10-52. API Server’s Routes

// example-014/api.js

var votes = require('./votes');

// ...

app.get('/api/vote', function (conn) {

var tallies = {};

var voteCount = votes.length;

votes.forEach(function (vote) {

var tally = tallies[vote] || {

count: 0,

percent: 0.0

};

tally.count += 1;

tally.percent = Number((tally.count / voteCount * 100).toFixed(2));

tallies[vote] = tally;

return tallies;

});

return conn.json(200, tallies);

});

app.post('/api/vote', function (conn) {

console.log(conn.params);

var vote = conn.params.vote || '';

if (!vote) {

return conn.json(400, {err: 'Empty vote submitted.'});

}

votes.push(vote);

return conn.json(201, {count: 1});

});

mach.serve(app, 8090);

Note

要运行example-014,必须用 Node.js 启动web.js(或web2.js)和api.js,web 服务器将在端口 8080 监听 HTTP 请求,API 服务器将在端口 8090 监听。

web 服务器呈现一个 HTML 页面作为小型投票应用的用户界面。尽管众所周知“你不投票给国王”,农民仍然喜欢受欢迎程度的竞赛,这个网络应用纵容了他们。图 10-10 显示了http://localhost:8080的渲染页面。

A978-1-4842-0662-1_10_Fig10_HTML.jpg

图 10-10。

Voting for a new monarch

提交表单时,事件处理程序会找到选中的选项值,并向 web 服务器发送包含投票数据的请求。清单 10-53 中的sendVote()方法向 web 服务器上的POST /api/data发出一个 AJAX 请求,然后这个请求被代理到记录投票的 API 服务器。

一旦提交完成,清单 10-53 中的getTallies()函数在GET /api/vote查询 web 服务器以获取投票结果。同样,这个请求被代理,JSON 数据被返回给客户机。

Listing 10-53. Submitting a Vote

// example-014/index.html

var formPoll = document.querySelector('#poll');

// ...

function sendVote(vote) {

function serializeVote(conn) {

conn.request.setHeader('Content-Type', 'application/json');

conn.request.content = JSON.stringify({

vote: vote

});

}

return mach.post('/api/vote', serializeVote);

}

function getTallies() {

return mach.get('/api/vote').then(function (conn) {

return JSON.parse(conn.responseText);

});

}

formPoll.addEventListener('submit', function (e) {

// ...

var vote = checkbox.value;

sendVote(vote).then(function () {

// ...

return getTallies().then(function (tallies) {

// show tally data...

});

}).catch(function (error) {

showError(error.err || error.message || 'The night is dark and full of errors.');

});

});

一旦响应被接收和解析,网页显示代理的计数数据,如图 10-11 所示。

A978-1-4842-0662-1_10_Fig11_HTML.jpg

图 10-11。

Tallies are displayed when a vote is submitted

如果在代理请求期间出现错误(例如,如果 API 服务器离线),它们将作为 HTTP 请求错误返回给客户端。因为这些错误与基础设施相关,而与应用无关,所以在定制的中间件包装器中处理它们并提供更有意义的错误可能是明智的。

摘要

虽然 Mach 肯定不是唯一可用的 Node.js web 服务器,甚至也不是最受欢迎的,但它具有很强的简单性和简洁的 API,这使它非常灵活。它的核心架构确保它的通用组件随处可用,而它的特定于环境的组件作为扩展加载。

一套插入到基于承诺的 API 中的通用中间件使得请求和响应链易于利用和操作。当需要更多功能时,定制中间件很容易编写。

通过按需解析请求查询和主体,请求和响应消息建立在节点的本地流上,并将响应内容以块的形式传递给客户端。这确保了在 HTTP 操作期间使用尽可能低的内存和处理开销。请求和响应内容也可以通过管道传输,转换到缓冲区进行内存操作,由各种格式处理程序解析,并转换为具有不同编码的字符串。

除了作为 HTTP 服务器的角色之外,Mach 还可以完成其他几个重要的 HTTP 相关角色:

  • 重写请求 URL
  • 将请求映射到虚拟主机
  • 充当 HTTP 代理
  • 发送 HTTP 客户端请求

Mach 的新想法是对 Node.js web 服务器的有益补充。

十一、Mongoose

如果[陌生感]没有表现出有趣的行为,人类的大脑会很快适应陌生感。—丹·西蒙斯

MongoDB 是一个流行的跨平台文档数据库,通常与其他非关系数据存储(如 CouchDB、Cassandra、RavenDB 等)归为“NoSQL”类。它是 Node.js 开发人员中数据存储的流行选择,因为它的“记录”存储为普通的 JSON 对象,它的查询接口和存储函数是用普通的 JavaScript 编写的。

在 MongoDB 中存储、访问和操作数据并不十分复杂,但是 Node.js 库(如 Mongoose)可以帮助应用开发人员将 MongoDB 文档映射到具有明确模式、验证和行为的应用对象上——所有这些概念(从设计上讲)都不是 MongoDB 的一部分。Mongoose 实现了 MongoDB 原生的查询接口,但也为开发人员提供了一个可组合的、流畅的接口,该接口简化了部分查询 API。

虽然 MongoDB 不是本章的直接主题,但是在深入研究 Mongoose 之前,有必要建立一些关于 MongoDB 如何工作的基本概念。如果您已经熟悉 MongoDB,可以跳过下一节。

基本 MongoDB 概念

关系数据库服务器托管数据库模式(有时简称为数据库),它封装了相关的实体,如表、视图、存储过程、函数等。数据库表又包含元组(也称为行或记录)。元组由多个字段组成,每个字段包含预定数据类型的值。元组是一维的,它的定义(它的字段可以保存的数据类型)是在表级别确定的。因此,一个表中的所有元组共享相同的结构,尽管它们各自的字段值可能不同。元组字段的名称和数据类型被称为元组的模式。

MongoDB 有一个表面上相似的数据层次,如表 11-1 所示。

表 11-1。

Understanding MongoDB by Analogy to Relational Database Systems

| 关系型数据库管理系统 | MongoDB | | --- | --- | | 计算机网络服务器 | 计算机网络服务器 | | 计划 | 数据库ˌ资料库 | | 桌子 | 募捐 | | 元组 | 文件 | | 田 | 财产 |

表 11-2 定义了描述 Mongoose 组件的关键术语以及它们之间的关系。清单 11-1 中的代码展示了这些术语是如何出现在代码中的。本章将详细介绍每一个,但是因为其中许多是密切相关的,所以随着本章的进行,您可能希望回头参考这一部分。

表 11-2。

Mongoose Terms and Definitions

| 学期 | 定义 | | --- | --- | | 计划 | 为文档实例的属性定义数据类型、约束、默认值、验证等。在应用级别实施 | | 模型 | 创建或获取文档实例的构造函数 | | 文件 | 由 Mongoose 模型创建或获取的实例对象;将拥有 Mongoose 特有的属性和方法以及数据属性 | | 对象 | 仅包含文档中数据属性的普通 JavaScript 对象 |

Listing 11-1. Mongoose Terms and Definitions in Code

// albumSchema is a schema

var albumSchema = mongoose.Schema({/*...*/});

// Album is a model

var Album = mongoose.model('Album', albumSchema);

// Album is a model

Album.findById(/*...*/, function (err, album) {

// album is a document

console.log(album);

});

// Album is a model

Album.findById(/*...*/)

.lean(true)

.exec(function (err, album) {

// album is a JSON object (because of lean(true))

console.log(album);

});

// Album is a model

Album.findById(/*...*/)

.exec(function (err, album) {

// album is a document

// toObject() returns a JSON object

console.log(album.toObject());

});

与 RDBMS 元组不同,MongoDB 文档不是一维的。它们是完整的 JSON 对象,可能包含其他对象或数组。事实上,同一集合中的文档甚至不需要具有相同的属性,因为 MongoDB 集合实际上是无模式的。MongoDB 集合可以保存任何形状或大小的文档对象(在 MongoDB 的存储限制内)。然而在实践中,集合倾向于保存相似“形状”的文档,尽管有些可能有可选属性,或者可能包含表示一些任意数据的属性。但是一般来说,应用通常假设数据存在于特定的“形状”中,所以尽管 MongoDB 不强制文档模式,但是应用经常这样做。

默认情况下,MongoDB 文档被自动分配一个名为_id的代理主键。这个键有一个特殊的类型(MongoDB 的ObjectId类型),被用作 MongoDB 的主集合索引。如果需要,MongoDB 可以使用不同的字段作为主键。附加字段可以作为简单键或复合键添加到集合中的辅助索引中。

MongoDB 不支持外键的概念,这是 RDBMS 数据库的一个强大特性。相反,MongoDB 依靠嵌套文档的能力来存储数据关联。考虑所有 RDBMS 示例的经典三位一体:客户、邮政地址和购物车订单。在 RDBMS 系统中,可能会有从邮政地址到客户的外键(用于标识居住地),以及从订单到一个或多个邮政地址的外键(用于标识送货和帐单地址)。然而,在 MongoDB 客户文档中,将邮政地址作为嵌套对象存储在客户文档和订单文档中就足够了。考虑列出 11-2 。

Listing 11-2. Duplication Sometimes Acceptable in MongoDB

// customer

{

"_id": 1001,

"name": "...",

"postalAddress" {

"street": "...",

"city": "...",

"state": "...",

"zip": "..."

}

}

// order

{

"_id": 2001,

"customer": 1001,

"items": [

{"sku": 3001, "qty": 2}

],

"shippingAddress" {

"street": "...",

"city": "...",

"state": "...",

"zip": "..."

}

}

从商业角度来看,这种对参照完整性的“违反”是可以接受的,原因有很多:

  • 也许命令永远不会改变。如果订单中有错误—例如,送货地址错误—整个订单将被重新创建以抵消错误订单。新订单中会添加正确的送货地址。
  • 如果客户更改了邮政地址,旧订单不会更新为新地址,因此不存在数据完整性问题。
  • 也许更改邮政地址总是发生在客户领域,而不是订单领域。
  • 也许客户可以用一个不应该添加到客户记录中的“临时”地址(运送礼品)来覆盖送货地址。
  • 如果来自订单的邮政指标不同于来自客户的邮政指标(例如,一位 C 级主管想知道上个月有多少订单被运送到密苏里州,而不管这个月谁实际上住在密苏里州),则该数据已经被隔离。
  • 也许磁盘空间很便宜,不实施参照完整性所获得的速度超过了任何潜在的成本。

虽然外键和参照完整性对 RDBMS 数据库至关重要,但强大的 MongoDB 文档设计往往会使这个问题变得没有实际意义。

最后,尽管 MongoDB 的查询 API 对于 SQL 从业者来说可能看起来有点令人畏惧,但很快就会发现,在大多数情况下,查找数据涉及相同的概念:选择(find)、过滤(where)、应用复合条件(andorin)、聚合(group)、分页(skiplimit)等等。查询的组成和执行方式主要在语法上有所不同。

一个简单的 Mongoose 例子

Mongoose 是 Node.js 应用的库。要使用 Mongoose 进行开发(并遵循本章中的示例),您需要在您选择的平台上安装 Node.js 和 MongoDB。两者的默认安装过程和配置应该足以运行本章的示例代码。

Note

本章假设您熟悉 Node.js 应用和模块,并且知道如何用npm安装它们。MongoDB 的工作知识会很有帮助,但这不是必需的,因为在示例章节中,与 MongoDB 的交互主要是通过 Mongoose 进行的。一些例子将演示如何直接查询 MongoDB 来验证 Mongoose 操作的结果。

本节演示了 Mongoose 的基本概念,这些概念将在本章的后面部分详细讨论。这个例子包括三个步骤:

Create a basic Mongoose schema that reflects the structured data in a JSON file.   Read the JSON file and import the data into MongoDB with a Mongoose model.   Run a basic web server that will use a Mongoose model to fetch data from MongoDB and deliver it to a web browser.

下面每个清单的第一行将显示示例代码所在的文件路径。后续示例将说明是否应该在终端中使用 Node.js 执行特定的示例文件。

为 JSON 数据创建一个 Mongoose 模式

Mongoose 文档表示应用中的领域数据。对于本章的示例应用,音乐专辑的 JSON 文件定义了要添加到 MongoDB 的初始数据集。清单 11-3 显示了example-001/albums.json的结构:一个相册对象的数组,每个包含关于作曲家、标题、出版年份、曲目列表等等的信息。

Listing 11-3. Album JSON Data File

// example-001/albums.json

[

{

"composer": "Kerry Muzzey",

"title": "Renaissance",

"price": 4.95,

"releaseDate": "2014-01-13T06:00:00.000Z",

"inPublication": true,

"genre": ["Classical", "Trailer Music", "Soundtrack"],

"tracks": [

{

"title": "The Looking Glass",

"duration": {

"m": 3,

"s": 20

}

}

//additional tracks...

]

}

//additional albums...

]

mongose 是一个对象数据映射器(ODM ),所以 mongose 数据访问的核心是模型函数,可以用来查询它们所代表的 MongoDB 集合。Mongoose 模型必须有一个可以引用它的名称,以及一个强制它将访问和操作的数据形状的模式。清单 11-4 中的代码创建了一个与example-001/albums.json中的 JSON 数据紧密匹配的相册模式。稍后将详细介绍模式,但是很明显模式定义了给定 Mongoose 模型的属性及其数据类型。最后,通过将名称(“相册”)与模式配对来创建模型函数。这个模型函数被分配给example-001/album-model.js文件中的module.exports,这样它就可以根据需要导入 Node.js 应用中的其他模块。

Tip

Mongoose 模式定义了模型的数据结构。模型功能提供了处理存储的文档数据的查询界面。模型必须有名称和模式。

Listing 11-4. Mongoose Album Schema and Model

// example-001/album-model.js

'use strict';

var mongoose = require('mongoose');

var albumSchema = mongoose.Schema({

composer: String,

title: String,

price: Number,

releaseDate: Date,

inPublication: Boolean,

genre: [String],

tracks: [

{

title: String,

duration: {

m: Number,

s: Number

}

}

]

});

var Album = mongoose.model('Album', albumSchema);

module.exports = Album;

使用 Mongoose 导入数据

既然已经定义了Album模式和模型,Node.js 脚本就可以从albums.json读取数据,并使用Album模型在 MongoDB 中创建文档。导入脚本需要做三件事:

Connect to a running MongoDB server with Mongoose   Read and parse the contents of the albums.json file.   Use the Album model to create documents in MongoDB.

mongose 通过一个 URI 连接到 MongoDB,它标识了 mongose 将使用的协议、服务器和数据库。在清单 11-5 中,URI 简单地指向本地 MongoDB 实例:mongodb://localhost/music。如果在 MongoDB 实例上还没有数据库,Mongoose 将主动创建数据库,因此不需要手动创建。如果 MongoDB 连接失败,mongose 将引发一个error事件,如果成功,mongose 将引发一个open事件。清单 11-5 展示了如何用回调函数处理这两个事件。一旦发出open事件,就会读取并解析albums.json文件,并将相册数组传递给Album模型的Album.create()方法。这将在 MongoDB 中创建相册文档,稍后可以使用Album模型对其进行查询。

Listing 11-5. Importing Album Data with Mongoose

// example-001/import-albums.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

var file2json = require('./file2json');

var fs = require('fs');

var path = require('path');

// connect to the "music" database on localhost;

// the database will be automatically created

// if it does not exist

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', function (err) {

console.error(err);

process.exit(1);

});

db.once('open', function importAlbums() {

var albumsFile = path.join(__dirname, 'albums.json');

file2json(albumsFile, 'utf8', function (err, albums) {

if (err) {

console.error(err);

return process.exit(1);

}

console.log('creating %d albums', albums.length);

// use the model to create albums in bulk;

// the collection will be automatically created

// if it does not exist

Album.create(albums, function (err) {

if (err) {

console.error(err);

return process.exit(1);

}

process.exit(0);

});

});

});

在运行脚本之前,MongoDB 需要在本地运行。一些 MongoDB 安装会将 MongoDB 配置为自动启动,但其他安装会将决定权留给用户。要确定 MongoDB 是否正在运行,只需在终端中执行mongo命令。如果 MongoDB 正在运行,您应该会看到类似于清单 11-6 的输出。你可以在任何时候按 Ctrl+c 来终止这个进程。

Listing 11-6. MongoDB Terminal Client, mongo

$ mongo

MongoDB shell version: 2.6.7

connecting to: test

>

如果您收到一个错误,通过执行mongod -f,后跟默认 MongoDB 配置文件的位置,手动启动 MongoDB 服务器。该文件的位置因系统而异,因此您可能需要查阅 MongoDB 安装文档。例如,在安装了家酿 MongoDB 的 OS X 系统上,可以在/usr/local/etc/mongod.conf找到配置文件。清单 11-7 展示了如何使用这个配置文件路径手动启动守护进程。

Listing 11-7. Starting mongod Manually.

$ mongod -f /usr/local/etc/mongod.conf

一旦启动了mongod服务器,您就可以使用 Node.js 运行example-001/import-albums.js脚本。

Listing 11-8. Running the Import Script

example-001$ node import-albums.js

creating 3 albums

在清单 11-9 中,启动了mongo终端客户端,随后是一系列命令(在每个>提示符后),以验证已经创建了music数据库和albums集合。show dbs命令显示正在运行的 MongoDB 实例托管的所有数据库。要查看数据库中的集合,首先通过发出use <db>命令切换到该数据库上下文,其中<db>是目标数据库的名称。接下来,执行show collections来查看数据库拥有的集合列表——在本例中是albumssystem.indexes(MongoDB 管理的集合)。

Listing 11-9. Verifying Album Data Has Been Added to MongoDB

$ mongo

MongoDB shell version: 2.6.7

connecting to: test

> show dbs

admin     (empty)

local     0.078GB

music     0.078GB

> use music

switched to db music

> show collections

albums

system.indexes

>

选择了music数据库后,您可以发出一些基本的查询来查看导入过程中添加的专辑数据。在数据库上下文中,通过db对象访问数据库集合。集合作为db对象的属性存在,对集合执行的操作分别是每个集合对象上的方法。例如,要查看albums集合中的记录数量,可以对集合调用db.albums.count()方法,如清单 11-10 所示。同样,为了查询相册记录,可以使用db.albums.find()方法和 criteria(“where”子句)和 projection(“select”子句)参数来控制返回什么数据。

Listing 11-10. Querying Album Data in the albums Collection

> db.albums.count()

3

> db.albums.find({}, {composer: 1})

{ "_id" : ObjectId("54c537ca46a13e0f4cebda82"), "composer" : "Kerry Muzzey" }

{ "_id" : ObjectId("54c537ca46a13e0f4cebda88"), "composer" : "Audiomachine" }

{ "_id" : ObjectId("54c537ca46a13e0f4cebdaa3"), "composer" : "Jessica Curry" }

因为在清单 11-10 中,criteria 参数(传递给db.albums.find()的第一个对象)为空,所以返回所有记录。然而,projection 对象指定了查询返回的单个属性:composer。除了默认情况下返回的_id,所有其他属性都被排除,除非投影参数另有规定,否则将始终包括在内。

用 Mongoose 查询数据

一旦相册数据被加载到 MongoDB 中,您就可以使用清单 11-4 中的相同模型来查询该数据。

清单 11-11 中的代码使用 Node.js http模块创建一个基本的 web 服务器,它可以接收 HTTP 请求并返回 JSON 数据作为响应。在这个例子中,web 服务器对任何 URL 查询都返回相同的响应(为了简单起见)。当接收到请求时,使用Albummongose 模型来查询 MongoDB 中的相册文档。它的find()函数由一个标准参数、一个投影参数和一个回调函数调用。除了回调之外,这个语法与清单 11-10 中用于检查相册文档的db.albums.find()方法相同。

Listing 11-11. Querying MongoDB with Mongoose

// example-001/http-server.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

var http = require('http');

var url = require('url');

/*

* The http server will handle requests and responses

*/

var server = http.createServer(function (req, res) {

Album.find({}, {composer: 1}, function (err, albums) {

var statusCode = err ? 500 : 200;

var payload = err ? err : albums;

res.writeHead(statusCode, {'Content-Type': 'application/json'});

res.write(JSON.stringify(payload, null, '  '));

res.end();

});

});

/*

* Connect to the MongoDB instance and report

* errors if any occur.

*/

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', function (err) {

console.error(err);

process.exit(1);

});

db.once('open', function () {

/*

* The MongoDB connection is open, start

* listening for HTTP requests.

*/

server.listen(8080);

console.log('listening on port 8080');

});

在清单 11-12 中,使用命令node http-server.jsexample-001目录启动 web 服务器。按 Ctrl+c 将停止服务器。

Listing 11-12. Running the HTTP Server

example-001$ node http-server.js

listening on port 8080

从 MongoDB 获取的相册数据现在可以通过导航到http://localhost:8080或者通过发出清单 11-13 中所示的curl终端命令在网络浏览器中查看。

Listing 11-13. Sending a curl Request to the HTTP Server

$ curl -v http://localhost:8080/

* Hostname was NOT found in DNS cache

*   Trying 127.0.0.1...

* Connected to localhost (127.0.0.1) port 8080 (#0)

> GET / HTTP/1.1

> User-Agent: curl/7.37.1

> Host: localhost:8080

> Accept: */*

>

< HTTP/1.1 200 OK

< Content-Type: application/json

< Date: Thu, 29 Jan 2015 01:20:09 GMT

< Connection: keep-alive

< Transfer-Encoding: chunked

<

``

{

"_id": "54c7020c342ee81670b261ef",

"composer": "Kerry Muzzey"

},

{

"_id": "54c7020c342ee81670b261f5",

"composer": "Audiomachine"

},

{

"_id": "54c7020c342ee81670b26210",

"composer": "Jessica Curry"

}

本章的其余部分将基于存储在 Mongoose 数据库中的这个 Mongoose 模式、模型和相册数据。

使用模式

Mongoose 模式是描述 MongoDB 文档的结构和数据类型的简单对象。虽然 MongoDB 本身是无模式的,但是 Mongoose 在应用级别强制文档使用模式。模式是通过调用 Mongoose 模块的Schema()函数来定义的,传递给它一个对象散列,其中键表示文档属性,值表示每个属性的数据类型。返回值是一个类型为Schema的对象,带有额外的助手属性和函数,用于扩展或增加模式的定义。

数据类型

对于标量属性,Mongoose 使用本地 JavaScript 数据类型StringBooleanNumberDate,如清单 [11-14 所示。

Listing 11-14. Primitive Types in a Mongoose Schema

// example-001/album-model.js

var albumSchema = mongoose.Schema({

composer: String,

title: String,

price: Number,

releaseDate: Date,

inPublication: Boolean

// other properties...

});

作为对象文字或数组的属性使用每种类型的文字符号({}[])。嵌套的对象文字是内联编写的,对它们自己的属性使用相同的 Mongoose 模式类型。数组类型只包含一个元素,它定义了将占用数组的对象的类型。这种类型可以是任何有效的 Mongoose 数据类型,包括内联定义为数组第一个元素的对象文字。在清单 11-15 中,genre被声明为一个字符串数组,而tracks被声明为一个对象文字数组。

Listing 11-15. Complex Types in a Mongoose Schema

// example-001/album-model.js

var albumSchema = mongoose.Schema({

// ...other properties

genre: [String],

tracks: [

{

title: String,

duration: {

m: Number,

s: Number

}

}

]

});

Mongoose 本身提供了两种特殊的对象类型:ObjectIdMixed

当在 MongoDB 中创建一个文档时,它会被分配一个_id属性,作为记录的惟一标识符。这个属性使用 MongoDB 自己的ObjectId数据类型。Mongoose 通过mongoose.Schema.Types.ObjectId暴露这种类型。这种类型很少直接使用。例如,当通过 ID 查询文档时,通常使用标识符的字符串表示。

Note

当模式属性包含任意数据时(记住,MongoDB 是无模式的),可以用类型mongoose.Schema.Types.Mixed来声明它。如果一个属性被标记为Mixed,Mongoose 将不会跟踪对它所做的更改。当 mongose 持久化一个文档时,它会在内部创建一个查询,该查询只添加或更新已经更改的属性,由于没有跟踪一个Mixed属性,应用必须在属性发生更改时通知 mongose。由 mongose 模型创建的文档公开了一个markModified(path)方法,该方法将迫使 mongose 认为由path参数标识的属性是脏的。

将 mongose 模式属性设置为空对象文字(没有属性的文字)将导致 mongose 将其视为Mixed

最后,因为 Mongoose 是一个 Node.js 库,所以它利用 Node 的Buffer类型来存储大块的二进制数据,比如图像、音频或视频素材。因为二进制数据可能非常大,所以许多应用将二进制素材的 URL 引用存储在内容交付网络上,如亚马逊的简单存储服务(S3),而不是将二进制数据存储在数据存储中,如 MongoDB。然而,不同的应用有不同的用例,Mongoose 模式足够灵活,可以支持任何一种方法。

嵌套模式

Mongoose 模式可能是嵌套的;也就是说,一个模式可以引用另一个模式作为属性类型。如果较大的模式共享通用的自定义数据类型,例如客户和订单模式共享邮政地址数据类型,这可能特别有用。在清单 11-16 中,专辑曲目模式被声明为独立于专辑模式,并被指定为albumSchema.tracks属性的数据类型。

Listing 11-16. Nested Mongoose Schemas

// breaking apart schemas...

var trackSchema = mongoose.Schema({

title: String,

duration: {

m: Number,

s: Number

}

});

var albumSchema = mongoose.Schema({

// ...

tracks: [trackSchema]

});

默认属性值

向模式属性中添加合理的默认值可以指示 Mongoose 在创建文档时填充缺失的数据。这对于不是可选的但通常保存一些已知值的文档属性很有用。

在清单 11-17 中,专辑模式的ms属性(分和秒)缺省为零,因为一首曲目完全可能不到一分钟长,或者正好是 X 分零秒。相册模式中的releaseDate属性也有一个默认值:函数Date.now。当默认值是一个函数时,Mongoose 将调用该函数,将其返回值转换为属性的type,然后将该值赋给属性。

Listing 11-17. Default Property Values

// adding default property values...

var trackSchema = mongoose.Schema({

// ...

duration: {

m: {type: Number, default: 0},

s: {type: Number, default: 0}

}

});

var albumSchema = mongoose.Schema({

// ...

price: {type: Number, default: 0.0},

releaseDate: {type: Date, default: Date.now},

// ...

});

向属性添加默认值要求类型赋值看起来有点不同。注意m: Number已经变成了m: {type: Number, default: 0}。通常,给属性分配一个对象散列会导致属性具有一个Mixed或对象类型,但是对象文字中出现的type属性会缩短处理过程,并告诉 Mongoose 散列中的其他键/值对是属性设置。

必需的属性

required属性可用于非可选属性的类型定义。保存文档时,文档模式所需的任何缺少的属性都将引发验证错误,该错误将被传递给保存操作的回调。在清单 11-18 中,专辑作曲者、专辑标题、音轨标题、甚至音轨时长对象都是必需的。

Listing 11-18. Required Properties

// adding required attributes

var trackSchema = mongoose.Schema({

title: {type: String, required: true},

duration: {

required: true,

type: {

m: {type: Number, default: 0},

s: {type: Number, default: 0}

}

}

});

var albumSchema = mongoose.Schema({

composer: {type: String, required: true},

title: {type: String, required: true},

// ...

});

如果使用一个字符串来代替一个必需属性的布尔值,那么当出现验证错误时,该字符串将被用作错误消息,如清单 11-19 所示。(文档验证将很快介绍。)

Listing 11-19. Custom Error Message for a Required Property

var trackSchema = mongoose.Schema({

title: {type: String, required: 'Missing track title!'},

// ...

});

次要索引

Mongoose 文档在保存到 MongoDB 时会自动获得一个索引属性_id。但是,可以将辅助索引添加到模式中,以提高查询其他字段时的性能。

MongoDB 支持简单(单字段)和复合(多字段)索引。在清单 11-20 中,以下索引被添加到曲目和专辑模式中:

  • 音轨标题(简单)
  • 专辑作者(简单)
  • 相册标题(简单)
  • 专辑名称+专辑作者(复合)
  • 类型专辑(简单)

Listing 11-20. Adding Secondary Indexes to Schemas

// adding secondary indexes...

var trackSchema = mongoose.Schema({

title: {type: String, required: true, index: true},

// ...

});

var albumSchema = mongoose.Schema({

composer: {type: String, required: true, index: true},

title: {type: String, required: true, index: true},

// ...

genre: {type: [String], index: true},

// ...

});

albumSchema.index({composer: 1, title: 1});

通过将一个index字段附加到属性类型声明并将其设置为true,可以在属性级别添加简单索引。另一方面,必须使用Schema.index()方法为模式整体定义复合索引。传递给index()的对象包含对应于要索引的模式属性的属性名,以及一个可能是1-1的数值。

MongoDB 按升序或降序对索引进行排序。复合索引是用一个数值而不是一个布尔值(像简单索引一样)来定义的,以指示每个字段应该被索引的顺序。对于简单的索引,顺序并不重要,因为 MongoDB 可以用两种方式进行搜索。但是对于复合索引,顺序非常重要,因为它限制了当查询使用复合索引时 MongoDB 可以执行的排序操作的种类。MongoDB 文档深入介绍了复合索引策略。

在清单 11-20 中,除了作曲家和标题这两个字段的简单索引之外,还为这两个字段添加了一个复合索引。用户很可能会根据作曲家、标题或两者来搜索专辑。

模式验证

当文档被持久化时,Mongoose 将执行模式验证规则。验证规则是为特定模式属性定义的函数,它评估属性值并返回布尔值以指示有效性。清单 11-21 演示了如何将属性验证器附加到模式对象上。

Listing 11-21. Validating Schema Properties

// adding schema validation...

var trackSchema = mongoose.Schema({/*...*/});

var albumSchema = mongoose.Schema({

// ...

tracks: [trackSchema]

});

albumSchema.path('tracks').validate(function (tracks) {

return tracks.length > 0;

}, 'Album has no tracks.');

模式的path()方法返回一个SchemaType的实例,该对象封装了模式属性的定义——在本例中是tracks属性,它是相册的 track 对象数组。SchemaType.validate()方法将验证函数附加到模式的属性上。第一个参数是实际的验证函数,它接收要验证的值作为唯一的参数。validate()的第二个参数是验证错误发生时使用的消息。

当保存一个相册文档时,这个函数将作为 Mongoose 验证过程的一部分执行,评估tracks属性以确保相册至少有一个音轨。

验证规则也可以作为属性定义的一部分附加到模式属性。清单 11-22 中的tracks定义包含了validate属性。此属性的值是一个两元素数组(元组),其中验证函数是元素 0,错误消息是元素 1。

Listing 11-22. Declaring Property Validators Inline

function validateTrackLength (tracks) {

return tracks.length > 0;

}

var albumSchema = mongoose.Schema({

// ...

tracks: {

type: [trackSchema],

validate: [validateTrackLength, 'Album has no tracks.']

}

});

虽然 Mongoose 验证过程本身是异步的,但简单的验证函数,如清单 11-22 中的那些,是同步的。对于大多数情况,同步验证是完全可以接受的,但是对于其他情况,可能需要异步验证器。异步验证函数接受第二个参数——一个名为respond(按照惯例)的回调——当异步验证完成时,这个回调将被调用。一个truefalse值被传递到respond以分别指示成功或失败的验证。清单 11-23 展示了如何让专辑曲目的验证函数异步。

Listing 11-23. Asynchronous Property Validators

albumSchema.path('tracks').validate(function (tracks, respond) {

process.nextTick(function () {

respond(tracks.length > 0);

});

}, 'Album has no tracks.');

为了查看工作中的验证功能,可以删除example-002/albums.json中每个专辑的曲目,这样 JSON 数据就类似于清单 11-24 。

Listing 11-24. Albums Without Tracks

// example-002/albums.json

[

{

"composer": "Kerry Muzzey",

"title": "Renaissance",

"price": 4.95,

"releaseDate": "2014-01-13T06:00:00.000Z",

"inPublication": true,

"genre": ["Classical", "Trailer Music", "Soundtrack"],

"tracks": []

},

{

"composer": "Audiomachine",

"title": "Tree of Life",

"price": 9.49,

"releaseDate": "2013-07-16T05:00:00.000Z",

"inPublication": true,

"genre": ["Classical", "Trailer Music"],

"tracks": []

},

{

"composer": "Jessica Curry",

"title": "Dear Esther",

"price": 6.99,

"releaseDate": "2012-02-14T06:00:00.000Z",

"inPublication": true,

"genre": ["Classical", "Video Game Soundtrack"],

"tracks": []

}

]

只要文档被持久化,就会发生验证;也就是说,每当调用Model.create()或者在文档实例上调用save()方法时。如果验证失败,错误将作为第一个参数传递给每个方法的回调。(后面会详细讨论文档。)

如果再次运行导入过程,当调用Album.create()从不完整的 JSON 数据创建新的 Mongoose 文档时,验证器将在example-002/import-albums.js中触发。清单 11-25 中的控制台输出显示了引发的序列化ValidationError,以及出现在其errors集合中的tracks属性的ValidatorError

Listing 11-25. Console Output when Schema Validation Fails

example-002$ node import-albums.js

creating 3 albums

{ [``ValidationError

message: 'Validation failed',

name: 'ValidationError',

errors:

{ tracks:

{ [``ValidatorError

message: 'Album has no tracks.',

name: 'ValidatorError',

path: 'tracks',

type: 'user defined',

value: [] } } }

在拆分专辑和曲目模式并添加默认属性值、必需属性、二级索引和验证之后,专辑模式与example-001中的简单模式相比发生了很大变化。清单 11-26 显示了更健壮的版本。

Listing 11-26. More Robust Album Schema

// example-002/album.js

'use strict';

var mongoose = require('mongoose');

var trackSchema = mongoose.Schema({

title: {type: String, required: true, index: true},

duration: {

required: true,

type: {

m: {type: Number, default: 0},

s: {type: Number, default: 0}

}

}

});

var albumSchema = mongoose.Schema({

composer: {type: String, required: true, index: true},

title: {type: String, required: true, index: true},

price: {type: Number, default: 0.0},

releaseDate: {type: Date, default: Date.now},

inPublication: Boolean,

genre: {type: [String], index: true},

tracks: [trackSchema]

});

albumSchema.index({composer: 1, title: 1});

albumSchema.path('tracks').validate(function (tracks) {

return tracks.length > 0;

}, 'Album has no tracks.');

var Album = mongoose.model('Album', albumSchema);

module.exports = Album;

模式引用

虽然 MongoDB 是一个无关系的数据存储,但是集合中文档之间的关系可以通过充当外键的非正式引用来创建。当然,对象外键的完整性强制和解析完全由应用来完成。Mongoose 通过群体引用构建这些非正式的关系——模式之间的链接,支持文档图的自动急切加载(和手动延迟加载)。以音乐应用为例,用户很可能会创建自己的个人专辑库。因为相册文档可能很大,所以最好避免在每个库文档中复制相册数据。取而代之的是,将从库文档创建对单个相册的引用,这是一种多对多的关系。当 Mongoose 加载库时,可以解析这些引用,以便返回填充了相册文档的完整库对象图。

为了简单起见,在example-003/library.json中定义了一个库。如清单 11-27 所示,这个库通过作曲家和标题引用专辑。当数据被导入时,每个相册都需要被解引用到相应的 MongoDB 相册文档中的一个文档 ID。

Listing 11-27. Library JSON Data

// example-003/library.json

{

"owner": "Nicholas Cloud",

"albums": [

{

"composer": "Kerry Muzzey",

"title": "Renaissance"

},

{

"composer": "Audiomachine",

"title": "Tree of Life"

},

{

"composer": "Jessica Curry",

"title": "Dear Esther"

}

]

}

库导入脚本类似于专辑导入脚本,如清单 11-28 所示,但是它执行一个额外的重要步骤。在读取library.json文件并将其转换为普通的 JavaScript 对象后,相册数据被解析为在example-001/import-albums.js中导入的实际相册文档对象。

Listing 11-28. Importing Library Data into MongoDB

// example-003/import-library.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

var Library = require('./library-model');

var file2json = require('./file2json');

var fs = require('fs');

var path = require('path');

function handleError(err) {

console.error(err);

process.exit(1);

}

function resolveAlbums(libraryJSON, cb) {

/*

* [3] use a compound $or criteria to look up multiple

* album documents

*/

var albumCriteria = {

$or: libraryJSON.albums

};

Album.find(albumCriteria, cb);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function importLibrary () {

/*

* [1] read the library.json file data and convert it to

* a normal JS object

*/

var libraryFile = path.join(__dirname, 'library.json');

file2json(libraryFile, 'utf8', function (err, libraryJSON) {

if (err) return handleError(err);

/*

* [2] look up album documents that match each composer/title

* in the library JSON data

*/

resolveAlbums(libraryJSON, function (err, albumDocuments) {

if (err) return handleError(err);

console.log('creating library');

/*

* [4] assign the album documents to the library object

*/

libraryJSON.albums = albumDocuments;

/*

* [5] then create a library document from the JSON data and

* save the document

*/

var libraryDocument = new Library(libraryJSON);

libraryDocument.save(function (err) {

if (err) return handleError(err);

process.exit(0);

});

});

});

});

清单 11-28 中注释了导入流程中的每一步,但是有几个步骤涉及到尚未引入的概念。

在步骤[3]中,创建了一个复合的$or criteria 对象,用于根据作曲者和标题过滤 MongoDB 相册文档。本章稍后将介绍$or criteria 属性,但现在只需理解 MongoDB 将检查albums集合中的所有文档,并确定文档是否匹配$or数组中的任何作曲/标题对,如清单 11-29 所示。由于之前导入的所有三张专辑都至少与该标准中的一对匹配,因此它们都将作为结果返回。

Listing 11-29. Library Import $or Criteria

{ $or:

[ { composer: 'Kerry Muzzey', title: 'Renaissance' },

{ composer: 'Audiomachine', title: 'Tree of Life' },

{ composer: 'Jessica Curry', title: 'Dear Esther' } ] }

在步骤[4]中,找到的专辑文档被分配给libraryJSON.albums属性,替换现有的作曲者/标题数据数组。当库文档被保存时,Mongoose 将执行清单 11-30 中的库模式。与前面的属性描述不同,albums属性是一个引用属性,它将保存一个由type属性定义的ObjectId数组。ref属性告诉 Mongoose,这个字段也可以在查询期间(如果指定的话)或者在保存库文档时用相册文档填充。

Listing 11-30. Library Schema

// example-003/library-model.js

'use strict';

var mongoose = require('mongoose');

var librarySchema = mongoose.Schema({

owner: String,

albums: [{type: mongoose.Schema.Types.ObjectId, ref: 'Album'}]

});

var Library = mongoose.model('Library', librarySchema);

module.exports = Library;

mongose 文档可能都被转换为它们的ObjectIds。mongose 足够聪明,可以自动执行这种转换,因此将相册文档添加到albums属性将通过模式检查。或者,导入脚本可以从每个相册文档中提取_id属性,并将其放入albums数组中。结果将是相同的。

最后,在步骤[5]中,通过调用Library构造函数并传入原始 JSON 数据以分配给每个文档属性,创建了一个单独的文档实例。也可以在没有构造函数参数的情况下创建文档,强制地将数据分配给实例上的每个属性,但是使用构造函数参数简写是很常见的。在文档被创建之后,它的save()方法被一个回调函数调用,如果持久化过程失败,这个回调函数会被传递一个错误。这不同于相册导入脚本,在相册导入脚本中,通过使用模型的静态create()函数在 MongoDB 中一次创建多个相册文档。清单 11-31 展示了不同之处。

Listing 11-31. Creating a Single Document and Multiple Documents

// create a single document

var libraryDocument = new Library(plainJSONLibrary);

libraryDocument.save(function (err) {...});

// create multiple documents at once

Albums.create(arrayOfJSONAlbums, function (err) {...});

在清单 11-32 中,库导入脚本的运行方式与专辑导入脚本完全相同。

Listing 11-32. Running the Library Import Script

example-003$ node import-library.js

creating library

导入完成后,可通过mongo终端客户端验证库数据。清单 11-33 中的输出显示,Mongoose 确实通过将每个相册对象转换为其标识符来满足库模式。(下一节,使用模型和文档,将研究如何使用模式引用属性来急切地加载被引用的文档。)

Listing 11-33. Verifying the Library Import in MongoDB

example-003$ mongo

MongoDB shell version: 2.6.7

connecting to: test

> use music

switched to db music

> db.libraries.find()

{ "_id" : ObjectId("54ed1dfdb11e8ae7252af342"), "owner" : "Nicholas Cloud", "albums" : [ ObjectId("54ed1dcb6fb525ba25529bd1"), ObjectId("54ed1dcb6fb525ba25529bd7"), ObjectId("54ed1dcb6fb525ba25529bf2") ], "__v" : 0 }

模式中间件

每当特定的 MongoDB 文档被验证、保存或从文档集合中删除时,Mongoose 都会在 schema 对象上引发事件。在这些操作的每一个之前和之后都会引发事件。对这些事件的订阅分别用模式的pre()post()方法来分配。订阅只是一个接收与每个事件相关的参数的函数或中间件。事件后中间件只是在事件完成后观察文档,但事件前中间件实际上可能会在事件完全处理之前中断文档的生命周期。

在清单 11-34 中,一个持续时间对象被添加到库模式中,与每个专辑音轨中的duration对象相同。然而,这个对象将保存整个库的计算总长度。一个事件前中间件功能被附加到存储event的库模式。在库被保存之前,这个函数将迭代每个专辑和每个音轨来计算所有音轨的长度,然后将计算出的值赋给duration对象的属性。中间件函数接收一个参数,回调函数next()。当持续时间总和完成时,调用next()来触发任何附加到模式的中间件功能。

Listing 11-34. Pre-save Middleware

// example-004/library-model.js

'use strict';

var mongoose = require('mongoose');

var librarySchema = mongoose.Schema({

owner: String,

albums: [{type: mongoose.Schema.Types.ObjectId, ref: 'Album'}],

duration: {

h: {type: Number, default: 0},

m: {type: Number, default: 0}

}

});

librarySchema.pre('save', function (next) {

var hours = 0, mins = 0;

/*

* iterate over all albums and add hours

* and minutes

*/

this.albums.forEach(function (album) {

album.tracks.forEach(function (track) {

hours += track.duration.h;

mins += track.duration.m;

});

});

/*

* divide total mins by 60 seconds and

* add that to hours, then assign remaining

* minutes back to mins

*/

hours += (mins / 60);

mins = (mins % 60);

this.duration = {h: hours, m: mins};

next();

});

var Library = mongoose.model('Library', librarySchema);

module.exports = Library;

事件前中间件可以以同步或异步方式执行。清单 11-34 中的代码是同步的,这意味着其他中间件功能只有在持续时间总和完成后才会被调度。为了改变这种行为并立即一个接一个地调度它们,用一个附加的布尔参数调用模式的pre()方法,该参数将处理函数标记为异步中间件。

中间件函数本身也接收一个额外的参数,如清单 11-35 所示的done()函数回调。在同步中间件中,当前一个中间件功能已经完成并调用next()时,控制被传递给下一个中间件功能。异步中间件仍然是这种情况,但是在未来的事件循环中,当异步操作完成时,也必须调用done()函数。清单 11-35 中的执行顺序是

Schedule the duration summation process for the next event loop pass.   Invoke next() to pass control to the next piece of middleware.   At some future point in time, signal that this middleware operation is complete by invoking done().   Listing 11-35. Asynchronous Pre-save Middleware

// example-005/library-model.js

// ...

librarySchema.pre('save', true, function (next, done) {

var hours = 0, mins = 0;

process.nextTick(function () {                // #1

/*

* iterate over all albums and add hours

* and minutes

*/

this.albums.forEach(function (album) {

album.tracks.forEach(function (track) {

hours += track.duration.h;

mins += track.duration.m;

});

});

/*

* divide total mins by 60 seconds and

* add that to hours, then assign remaining

* minutes back to mins

*/

hours += (mins / 60);

mins = (mins % 60);

this.duration = {h: hours, m: mins};

done();                                     // #3

});

next();                                       // #2

});

var Library = mongoose.model('Library', librarySchema);

module.exports = Library;

如果在同步、事件前中间件函数中出现错误,它应该作为唯一的参数传递给next()。然而,在异步函数中产生的错误应该传递给done()。传递给这些回调的任何错误都将导致触发事件的操作失败,并将被传递给最终的操作回调(例如,传递给文档的save()方法的回调)。

事件后中间件函数不接收控制流参数,而是在事件操作完成后接收文档的副本。

使用模型和文档

Mongoose 模型是一个创建文档实例的构造函数。这些实例符合 Mongoose 模式,并公开了一组用于文档持久性的方法。模型与 MongoDB 集合相关联。事实上,当保存一个 Mongoose 文档时,如果它所对应的集合还不存在的话,它将被创建。按照惯例,模型以它们所代表的名词的单数形式命名(例如,Album),但是集合以复数形式命名(例如,albums)。

通过用模型名和模型模式调用mongoose.model()来创建模型构造函数。使用这个构造函数创建的所有文档,无论是直接在用户代码中创建,还是在 Mongoose 执行查询并返回文档实例时间接创建,都将符合模型的模式。清单 11-36 显示了负责创建Album构造函数的代码,导入脚本使用该函数在 MongoDB 中创建相册文档。

Listing 11-36. Album Model

// example-006/album-model.js

//...schema definition...

var Album = mongoose.model('Album', albumSchema);

module.exports = Album;

当用mongoose.model()函数注册了一个 mongose 模型时,mongose 就可以通过在关系属性中引用的名称来解析该模型。这种技术早先被用于创建库模式和Album模型之间的引用,如清单 11-37 所示。

Listing 11-37. Library Schema References Album Model

// example-006/library-model.js

// ...

var librarySchema = mongoose.Schema({

// ...

albums: [{type: mongoose.Schema.Types.ObjectId, ref: 'Album'}],

// ...

});

可以使用模型构造函数创建新文档,或者使用模型查询方法从 MongoDB 数据存储中获取新文档。每个文档都可以在 MongoDB 集合中保存或删除自己。这非常类似于 RDBMS 库中常用的 ActiveRecord 数据访问模式。在清单 11-38 中,用Album构造函数创建了一个新的相册文档实例。相册数据被分配给由相册架构定义的每个属性(具有适当的数据类型)。最后,在文档上调用save()方法,当在 MongoDB 中创建了相关联的文档时,调用它的回调。

Listing 11-38. Creating and Saving a New Document Instance

// example-006/add-album-instance.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

function handleError(err) {

console.error(err);

process.exit(1);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function addAlbumInstance() {

var album = new Album();

album.composer = 'nervous_testpilot';

album.title = 'Frozen Synapse';

album.price =  8.99;

album.releaseDate = new Date(2012, 8, 6);

album.inPublication = true;

album.genre = ['Dance', 'DJ/Electronica', 'Soundtrack'];

album.tracks = [

{

title: 'Welcome to Markov Geist',

duration: {m: 1, s: 14}

},

// ...additional tracks...

];

album.save(function (err) {

if (err) return handleError(err);

console.log('album saved', album);

process.exit(0);

});

});

保存相册后,脚本输出显示文档数据:

example-006$ node add-album-instance.js

album saved { __v: 0,

inPublication: true,

title: 'Frozen Synapse',

composer: 'nervous_testpilot',

_id: 54f117e4a27cc5375e156c6d... }

可以查询 MongoDB 来验证该文档实际上是在albums集合中创建的,如清单 11-39 所示。

Listing 11-39. Verifying the Mongoose Document Has Been Created in MongoDB

example-006$ mongo

MongoDB shell version: 2.6.7

connecting to: test

> use music

switched to db music

> db.albums.find({composer: 'nervous_testpilot'}, {_id: 1, composer: 1, title: 1})

{ "_id" : ObjectId("54f117e4a27cc5375e156c6d"), "title" : "Frozen Synapse", "composer" : "nervous_testpilot" }

也可以通过将对象散列直接传递给模型构造函数来设置文档实例属性。当文档数据已经存在于普通 JavaScript 对象中时,例如反序列化的 JSON web 请求体,或者从平面文件解析的 JSON 数据,这可能特别有用。清单 11-40 修改了前面的例子,从一个 JSON 文件加载新的相册数据,然后使用Album模型构造函数从新的 JSON 数据创建一个文档。由于 JSON 数据符合相册模式(或者,在releaseDate日期字符串的情况下,可以直接转换为属性类型Date),相册实例将被持久化而不会出错。

Listing 11-40. Alternative Way to Create a Document with Property Data

// example-007/add-album-instance-alt.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

var file2json = require('./file2json');

var path = require('path');

function handleError(err) {

console.error(err);

process.exit(1);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function addAlbumInstance() {

var albumFile = path.join(__dirname, 'album.json');

file2json(albumFile, 'utf8', function (err, albumJSON) {

var album = new Album(albumJSON);

album.save(function (err) {

if (err) return handleError(err);

console.log('album saved', album);

process.exit(0);

});

});

});

文档实例方法

文档不仅仅是数据:它们还可能包括自定义行为。创建文档实例时,Mongoose 会创建一个原型链,其中包含模式对象的methods属性上定义的函数的副本。以这种方式定义的文档方法可以用关键字this访问特定的文档实例。

清单 11-41 显示了在专辑模式中定义的两个实例方法:一个根据前一首曲目的标题查找下一首专辑曲目,另一个根据共享的流派查找相似的专辑。findSimilar()方法使用的查询语法将在处理查询一节中介绍,但现在您只需知道它能有效地找到与实例专辑风格重叠且与实例专辑不共享同一_id的专辑(因此实例本身被排除在列表之外)。

Listing 11-41. Defining Document Instance Methods in a Schema

// example-008/album-model.js

// ...

var albumSchema = mongoose.Schema({/*...*/});

albumSchema.methods.nextTrack = function (previousTrackTitle) {

var i = 0, len = this.tracks.length;

for (i; i < len; i += 1) {

if (this.tracks[i].title !== previousTrackTitle) {

continue;

}

// return the next track, or, if this is the last track,

// return the first track

return this.tracks[i + 1] || this.tracks[0];

}

throw new Error('unable to find track ' + previousTrackTitle);

};

albumSchema.methods.findSimilar = function (cb) {

var criteria = {

_id: {$ne: this._id},

genre: {$in: this.genre}

};

this.model('Album').find(criteria)

.exec(cb);

};

var Album = mongoose.model('Album', albumSchema);

module.exports = Album;

清单 11-42 中的脚本加载名为“文艺复兴”的专辑,然后调用album.nextTrack()来确定“失宠”之后的曲目然后它调用album.findSimilar()加载与文艺复兴相关的专辑,并将它们的标题和流派打印到终端。输出显示,每个专辑确实有重叠的流派,并且实例专辑本身不包括在结果中。

Listing 11-42. Using Document Instance Methods

// example-008/index01.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

function handleError(err) {

console.error(err);

process.exit(1);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function () {

Album.findOne({title: 'Renaissance'})

.exec(function (err, album) {

if (err) return handleError(err);

var nextTrack = album.nextTrack('Fall from Grace');

console.log('next track:', nextTrack.title);

album.findSimilar(function (err, albums) {

if (err) return handleError(err);

console.log('this album:', album.title, album.genre);

albums.forEach(function (album) {

console.log('similar album:', album.title, album.genre);

});

process.exit(0);

});

});

});

example-008$ node index01.js

next track: Fall from Grace (Choir Version)

this album: Renaissance ["Classical","Trailer Music","Soundtrack"]

similar album: Tree of Life ["Classical","Trailer Music"]

similar album: Dear Esther ["Classical","Video Game Soundtrack"]

similar album: Frozen Synapse ["Dance","Electronica","Soundtrack"]

文档虚拟

像实例方法一样,虚拟 getter 和 setter 属性可以通过模式添加到文档中。这些虚拟属性的行为类似于普通的数据属性,但是在保存文档时不会持久化。对于基于文档数据计算和返回值,或者解析包含或可以转换为其他文档属性值的数据,它们非常有用。

清单 11-43 中的相册模式中添加了一个虚拟的 getter 和 setter,它们定义了一个属性composerInverse,该属性将获得作曲家名字的反转版本(“姓氏,名字”),并在给定反转形式的情况下正确设置作曲家的名字(“名字,姓氏”)。

Listing 11-43. Virtual Document Properties

// example-08/album-model.js

var albumSchema = mongoose.Schema({/*...*/});

// ...

albumSchema.virtual('composerInverse').get(function () {

var parts = this.composer.split(' '); //first last

if (parts.length === 1) {

return this.composer;

}

return [parts[1], parts[0]].join(', '); //last, first

});

albumSchema.virtual('composerInverse').set(function (inverse) {

var parts = inverse.split(', '); //last, first

if (parts.length === 1) {

this.composer = inverse;

}

this.composer = [parts[1], parts[0]].join(' '); //first last

});

// ...

传递给Schema.virtual()方法的字符串参数定义了文档实例创建后属性所在的文档路径。通过指定从根文档开始的完整路径,文档虚拟也可以分配给子文档和嵌套对象。例如,如果composer属性的值是一个具有firstNamelastName属性的对象,那么虚拟对象可能位于composer.inverse

清单 11-44 中的脚本和后续输出显示了composerInverse属性的作用。

Listing 11-44. Getting and Setting a Virtual Property

// example-008/index02.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

function handleError(err) {

console.error(err);

process.exit(1);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function () {

Album.find({}).exec(function (err, albums) {

if (err) return handleError(err);

albums.forEach(function (album) {

console.log('album.composer:', album.composer);

var inverse = album.composerInverse;

console.log('album.composerInverse:', inverse);

album.composerInverse = inverse;

console.log('album.composer:', album.composer);

console.log(/*newline*/);

});

process.exit(0);

});

});

example-008$ node index02.js

album.composer: Kerry Muzzey

album.composerInverse: Muzzey, Kerry

album.composer: Kerry Muzzey

album.composer: Audiomachine

album.composerInverse: Audiomachine

album.composer:  Audiomachine

album.composer: Jessica Curry

album.composerInverse: Curry, Jessica

album.composer: Jessica Curry

album.composer: nervous_testpilot

album.composerInverse: nervous_testpilot

album.composer:  nervous_testpilot

静态模型方法

静态方法也可以添加到模型中(而不是文档实例中),通常用于在查询集合时封装复杂的标准结构。清单 11-45 中的inPriceRange()方法被附加到相册模式的statics属性中。它接收两个数字参数,代表价格范围的下限和上限,并查找价格在该范围内的专辑。

Listing 11-45. Adding a Static Method to a Model

// example-009/album-model.js

var albumSchema = mongoose.Schema({/*...*/});

// ...

albumSchema.statics.inPriceRange = function (lower, upper, cb) {

var criteria = {

price: {$gte: lower, $lte: upper}

};

this.find(criteria)

.exec(cb);

};

// ...

当稍后从模式中创建相册模型时,statics上的任何方法都将被绑定到该模型。实例方法中的this的值是文档本身,而静态方法中的this关键字的值是模型构造函数(例如Album)。任何可以在模型上调用的函数,比如find()create(),都可以通过静态方法访问。

清单 11-46 中的脚本接收两个价格作为命令行参数,然后在这些价格范围内查找专辑。在Album模型上调用inPriceRange()方法,就像任何其他静态方法一样。以这种方式封装查询是维护独立关注点的好方法,因为查询逻辑被隔离到模型中,不会污染应用的其他部分。

Listing 11-46. Using Static Model Methods// example-009/index.js

'use strict';

var mongoose = require('mongoose');

var Album = require('./album-model');

var lower = Number(process.argv[2] || 0);

var upper = Number(process.argv[3] || lower + 1);

console.log('finding albums between $%s and $%s', lower.toFixed(2), upper.toFixed(2));

function handleError(err) {

console.error(err);

process.exit(1);

}

mongoose.connect('mongodb://localhost/music');

var db = mongoose.connection;

db.on('error', handleError);

db.once('open', function () {

Album.inPriceRange(lower, upper, function (err, albums) {

if (err) return handleError(err);

console.log('found albums:', albums.length);

albums.forEach(function (album) {

console.log(album.title, '$' + album.price.toFixed(2));

});

process.exit(0);

});

});

example-009$ node index.js 5.00 10.00

finding albums between $5.00 and $10.00

found albums: 3

Tree of Life $9.49

Dear Esther $6.99

Frozen Synapse $8.99

example-009$ node index.js 9.00 10.00

finding albums between $9.00 and $10.00

found albums: 1

Tree of Life $9.49

example-009$ node index.js 20.00

finding albums between $20.00 and $21.00

found albums: 0

Note

下一节中的查询示例不使用静态模型方法进行封装。这样做是为了简化每个示例,尽管在真正的可维护应用中,这可能被认为是不好的做法。

使用查询

Mongoose 查询是由零个或多个指定查询参数的属性组成的普通对象。(空查询对象匹配所有内容。)这些 criteria 对象的属性共享 MongoDB 的原生查询语法。模型公开了几种不同的查询方法,它们使用 criteria 对象来过滤和返回 Mongoose 文档。

对于下面的例子,web 服务器通过 Mongoose 模型提供对 MongoDB 数据的访问。要启动 web 服务器,确保您的 MongoDB 实例正在运行,然后在每个示例目录中执行清单 11-47 中的命令。(每个代码示例顶部的注释揭示了它所在的目录。)脚本输出将通知您 web 服务器正在端口 8080 上运行。所有与 web 服务器的交互都将通过适用于大多数平台的 cURL 终端工具来演示,尽管每个示例都可以在任何标准的 HTTP 客户机上运行。

Listing 11-47. Starting the Web Server in Example 10

example-XYZ$ node index.js

listening on port 8080

Model.find()

基本的 CRUD 操作可以很容易地映射到相应的 Mongoose 模型函数。例如,清单 11-48 中的路径是一个通用路径,它使用Album.find()来定位包含与 criteria 对象中的属性相匹配的属性的相册文档。criteria 对象从 URL 查询字符串中获取composertitle参数,如果它们是作为请求的一部分发送的话。如果在 criteria 对象上设置了一个或两个参数,Mongoose 将只返回具有匹配属性的文档(类似于传统 SQL 中的一个where子句)。如果没有发送参数,criteria 对象将保持为空,Mongoose 将找到所有相册文档。

Listing 11-48. Finding Albums That Match a Given Criteria

// example-010/album-routes.js

/**

* GET /album(?composer={string}&title={string})

* @param req

* @param cb

*/

routes.GET['^\/album(?:\\?.+)?$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var criteria = {};

if (req.query.composer) {

criteria.composer = req.query.composer;

}

if (req.query.title) {

criteria.title = req.query.title;

}

Album.find(criteria)

.sort({composer: 1, title: 1})

.lean(true)

.exec(function (err, albums) {

if (err) return cb(500, err);

cb(200, albums);

});

};

Album.find()方法将返回一个 mongoseQuery对象,该对象公开了用于操作查找操作结果的其他方法。

Note

可以通过几种方式调用模型方法。第一个如清单 11-48 所示,返回一个带有流畅接口的Query对象,该接口允许查询选项链接在一起,直到调用Query.exec()方法。第二种方法完全避免了Query对象。如果回调作为最后一个参数传递给模型的查询方法(如find({}, function () {...})),底层查询将立即执行,错误或结果将传递给回调。对于简单的查询,第二种方法更简洁。

第一个Query指令是Query.sort(),它接受一个使用 MongoDB 排序符号的对象。这个对象中的属性告诉 MongoDB 文档中的哪些属性应该用于排序,以及每个排序应该按哪个方向排序(1表示升序,-1表示降序)。当列表 11-48 中的结果被取出时,它们将首先按照作曲家排序,然后按照专辑名称排序。

Query.sort()之后,调用Query.lean()方法来指示 mongose 提交普通 JSON 对象,而不是 mongose 文档作为结果。默认情况下,mongose 将总是获取文档,这些文档带有 mongose 特有的属性和方法,用于验证、持久化和管理文档对象。因为这个路由(以及这个文件中的大多数路由)只是序列化结果并将它们返回给客户机,所以最好将它们作为只填充了数据的普通旧 JavaScript 对象(或 JSON 对象)来获取。

一旦准备好了一个查询,它的exec()方法就会被传递一个回调来接收来自Album.find()操作的错误或数据。结果将是一个 album 对象数组,该数组匹配用于执行查询的任何标准(如果有的话)。

清单 11-49 中显示了几个curl命令以及各种查询字符串参数。在每种情况下,输出都是来自 web API 的序列化 JSON 数组。

Note

以下示例使用了在我的计算机上生成的 MongoDB 标识符。这些标识符在您的计算机上会有所不同。您可以使用mongo终端客户端来发现分配给 MongoDB 文档的标识符,如前面的示例所示。

Listing 11-49. Using curl to Find Albums with Various Criteria

example-010$ curl -X GET http://localhost:8080/album?composer=Kerry%20Muzzey

[{"_id":"54ed1dcb6fb525ba25529bd1","composer":"Kerry Muzzey","title":"Renaissance"... ]

example-010$ curl -X GET http://localhost:8080/album?title=Dear%20Esther

[{"_id":"54ed1dcb6fb525ba25529bf2","composer":"Jessica Curry","title":"Dear Esther"... ]

example-010$ curl -X GET "``http://localhost:8080/album?composer=Audiomachine&title=Tree%20of%20Life

[{"_id":"54ed1dcb6fb525ba25529bd7","composer":"Audiomachine","title":"Tree of Life"... ]

Model.findById( )

虽然Album.find()将总是获取一个文档数组(即使它的标准指定了一个惟一的标识符),但是Album.findById()将只找到一个匹配给定标识符的文档(如果有的话)。清单 11-50 中的路径通过albumID获取单个相册——这是作为最后一个 URL 段而不是查询字符串传递的参数。在返回的Query上再次调用lean()方法,以消除整个 Mongoose 文档实例中不必要的属性和方法。

Listing 11-50. Finding a Single Album That Matches a Given Criteria

// example-010/album-routes.js

/**

* GET /album/{id}

* @param req

* @param cb

*/

routes.GET['^\/album\/([a-z0-9]+)$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var albumID = req.params[0];

Album.findById(albumID)

.lean(true)

.exec(function (err, album) {

if (err) return cb(500, err);

cb(200, album);

});

};

example-010$ curl -X GET http://localhost:8080/album/54f3a4df056601726f867685

{"_id":"54f3a4df056601726f867685","composer":"nervous_testpilot","title":"Frozen Synapse"... }

之前,导入脚本example-007/add-album-instance-alt.js创建了一个额外的相册,其中一个反序列化的 JSON 对象被传递给Album构造函数来创建一个相册实例。清单 11-51 演示了 HTTP POST 路由中的相同过程。请求的主体是序列化的相册数据,首先转换成 JSON 对象,然后传递给Album模型构造函数。一旦创建了文档实例,save()方法就会验证数据(使用 album 模式中定义的规则)并创建新的 MongoDB 文档。

Listing 11-51. Creating a New Album Document

// example-010/album-routes.js

/**

* POST /album

* @param req

* @param cb

*/

routes.POST['^\/album$'] = function (req, cb) {

console.log(req.body);

cb = httpd.asJSON(cb);

var albumJSON = req.body;

var album = new Album(albumJSON);

album.save(function (err) {

if (err) return cb(500, err);

cb(201, album.toObject());

});

};

如果验证失败,或者如果相册不能被创建,一个错误将被传递给最终的回调,并作为一个HTTP 500 Internal Server Error传递给客户端。如果创建了相册文档,数据将作为序列化的 JSON 传递回客户机。与之前使用Query.lean()确保只有数据被序列化的方法不同,当调用toObject()方法时,相册文档会以 JSON 格式返回自己的数据。这相当于lean()在查询链中手动执行的过程。

清单 11-52 中的curl请求读取example-010/new-album.json的内容并将其设置为请求体。Content-Type通知 web 服务器相应地反序列化有效负载。

Listing 11-52. Creating a New Album with a curl Request

example-010$ curl -X POST``http://localhost:8080/album

> -d @new-album.json \

> -H "Content-Type: application/json"

{"_id":"54f66ed2fa4af12b43fee49b","composer":"Aphelion","title":"Memento"... }

example-010/new-album.json中的相册数据缺少releaseDate属性,这种情况不会导致模式验证在导入时失败,因为不需要releaseDate。的确,releaseDate默认为Date.now,如果用mongo客户端查询,也正是如此。不幸的是,事实上,专辑并没有在今天发布,所以有必要创建另一个路径来更新新制作的专辑文档。

model.findbyidandupdate()

相册实例可以用多种方式更新。Album.findById()方法可以获取文档,可以用更新的数据设置其属性,然后将其保存回数据存储。或者可以使用Album.findByIdAndUpdate()方法一次性完成所有工作,并返回最新更新的相册文档,这正是清单 11-53 中采用的方法。

Listing 11-53. Finding and Updating an Album by ID

// example-010/album-routes.js

/**

* PUT /album/{id}

* @param req

* @param cb

*/

routes.PUT['^\/album\/([a-z0-9]+)$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var albumID = req.params[0];

var updatedFields = req.body;

Album.findByIdAndUpdate(albumID, updatedFields)

.lean(true)

.exec(function (err, album) {

if (err) return cb(500, err);

cb(200, album);

});

};

像清单 11-51 一样,序列化的 JSON 对象在 HTTP 请求的主体中发送。然而,这个请求是一个 PUT 请求,并且在 URL 中包括专辑标识符。请求正文中发送的唯一数据是要更新的属性。没有必要通过网络发送完整的文档,因为 Mongoose 会适当地应用增量。一旦请求体被反序列化,相册 ID 和更新的字段就被传递给findByIdAndUpdate()。如果更新操作成功,更新后的文档将被传递给最终的查询回调,假设没有发生错误。

清单 11-54 中的curl命令创建一个 PUT 请求,该请求带有一个简单的 JSON 有效载荷,为releaseDate指定一个新值。当请求完成时,打印的响应显示更新的相册数据。

Listing 11-54. Finding and Updating an Album by ID with curl

example-010$ curl -X PUT http://localhost:8080/album/54f66ed2fa4af12b43fee49b \

> -d '{"releaseDate": "2013-08-15T05:00:00.000Z"}' \

> -H "Content-Type: application/json"

{"_id":"54f66ed2fa4af12b43fee49b"..."releaseDate":"2013-08-15T05:00:00.000Z"... }

Model.findByIdAndRemove( )

为了从 MongoDB 中删除一个文档,DELETE路由使用Album.findByIdAndRemove()方法来查找 MongoDB 文档,然后将其从albums集合中删除。如果操作成功,被移除的专辑将被传递给清单 11-55 中的最终回调。

Listing 11-55. Finding and Removing an Album by ID

// example-010/album-routes.js

/**

* DELETE /album/{id}

* @param req

* @param cb

*/

routes.DELETE['^\/album\/([a-z0-9]+)$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var albumID = req.params[0];

Album.findByIdAndRemove(albumID)

.lean(true)

.exec(function (err, album) {

if (err) return cb(500, err);

cb(200, album);

});

};

example-010$ curl -X DELETE http://localhost:8080/album/54f3aa9447429f44763f2603

{"_id":"54f66ed2fa4af12b43fee49b","composer":"Aphelion","title":"Memento"... }

文档实例也有一个remove()方法,可以像它的save()方法一样被调用。在清单 11-56 中,通过 ID 获取一个相册实例。这次没有调用Query.lean(),因为它是拥有remove()方法的文档,而不是它的普通 JSON 表示。一旦获取了实例,就用回调函数调用remove(),如果失败,将收到一个错误,如果成功,将收到一个已删除文档实例的副本。

Listing 11-56. Removing a Document Instance

Album.findById(albumID)

.exec(function (err, albumInstance) {

albumInstance.remove(function (err, removedAlbum) {

// album has been removed

});

});

Model.count()

另一个有用的模型方法是count(),它接收与find*()方法相同类型的 criteria 对象,但是返回简单的记录计数,而不是完整的对象。清单 11-57 中的 HTTP 路由使用与一般相册搜索相同的查询参数,并在 HTTP 响应中返回结果计数。

Listing 11-57. Counting Albums That Match Criteria

// example-011/album-routes.js

/**

* GET /album/count(?composer={string}&title={string})

* @param req

* @param cb

*/

routes.GET['^\/album\/count(?:\\?.+)?$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var criteria = {};

if (req.query.composer) {

criteria.composer = req.query.composer;

}

if (req.query.title) {

criteria.title = req.query.title;

}

Album.count(criteria)

.exec(function (err, count) {

if (err) return cb(500, err);

cb(200, count);

});

};

example-011$ curl -X GET http://localhost:8080/album/count

4

example-011$ curl -X GET http://localhost:8080/album/count?composer=Jessica%20Curry

1

查询。填充( )

之前,在清单 11-28 中,一个脚本被用来向 MongoDB 添加一个音乐库。库模式定义了一个数组属性albums,它包含对相册文档的引用,如清单 11-58 所示。

Listing 11-58. Album References in the Library Schema

var librarySchema = mongoose.Schema({

// ...

albums: [{type: mongoose.Schema.Types.ObjectId, ref: 'Album'}],

// ...

});

带有外部引用的 Mongoose 文档可以通过解析对其他文档对象的引用来获取,也可以不解析。清单 11-59 中的路径通过 ID 获取一个库,然后调用Query.populate()方法急切地获取该库的相关相册。Mongoose 很聪明,它知道尽管从技术上讲albums是一个数组,但它包含的对象实际上引用了其他相册文档。

Listing 11-59. Populating Albums with a Library Model

// example-011/library-routes.js

/**

* GET /library/(id)

* @param req

* @param cb

*/

routes.GET['^\/library\/([a-z0-9]+)$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var libraryID = req.params[0];

Library.findById(libraryID)

.populate('albums')

.lean(true)

.exec(function (err, library) {

if (err) return cb(500, err);

if (!library) return cb(404, {

message: 'no library found for ID ' + libraryID

});

cb(200, library);

});

}

图 11-1 显示了 HTTP 响应的格式化版本。albums系列中的每张专辑都已完全被取消引用。因为查询链中也调用了Query.lean(),所以 Mongoose 将库和相册数据转换成普通的 JSON 对象。

A978-1-4842-0662-1_11_Fig1_HTML.jpg

图 11-1。

Library population results

使用查询运算符查找文档

在这一点上,相册和库路径由基本的 CRUD 操作(创建、读取、更新和删除)组成,这些操作构成了许多 web APIs 的基础,但是还可以做更多的工作来使 API 更加健壮。MongoDB 支持许多有用的查询操作符,它们以特定的方式过滤数据。

$lt 和$gt 运算符

$lt$gt操作符可用于查找值小于($lt)或大于($gt)某个值的文档。清单 11-60 中的路由允许客户端搜索在特定日期当天、之前或之后发行的专辑,该特定日期作为查询参数传递给该路由。

Listing 11-60. Finding Albums by Release Date

// example-011/album-routes.js

/**

* GET /album/released/MM-DD-YYYY

* GET /album/released/MM-DD-YYYY/before

* GET /album/released/MM-DD-YYYY/after

* @param req

* @param cb

*/

routes.GET['^\/album\/released\/([\\d]{2}-[\\d]{2}-[\\d]{4})(?:\/(before|after))?$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var date = req.params[0];

var when = req.params[1];

var criteria = {releaseDate: {}};

if (when === 'before') {

criteria.releaseDate.$lt = new Date(date);

} else if (when === 'after') {

criteria.releaseDate.$gt = new Date(date);

} else {

when = null;

criteria.releaseDate = new Date(date);

}

Album.find(criteria)

.select('composer title releaseDate')

.lean(true)

.exec(function (err, albums) {

if (err) return cb(500, err);

if (albums.length === 0) {

return cb(404, {

message: 'no albums ' + (when || 'on') + ' release date ' + date

});

}

cb(200, albums);

});

};

为了查找在特定日期发行的专辑,使用一个普通的 criteria 对象将日期值映射到releaseDate属性:

{releaseDate: new Date(...)}

但是,如果搜索日期之前或之后的专辑,criteria 对象将分别使用$lt$gt运算符:

{releaseDate: {$lt: new Date(...)} }

// or

{releaseDate: {$gt: new Date(...)} }

要查找特定日期之前发行的专辑,可以使用$lte(“小于或等于”)操作符。同样,$gte操作符会查找从某个特定日期开始发行的专辑。为了找到除了提供的日期之外的任何一天发行的所有专辑,$ne(“不相等”)操作符会相应地进行过滤。如果单独使用的话,它的反函数$eq在功能上等同于直接在 criteria 对象上设置releaseDate值。

为了保持较小的响应,在执行查询之前调用了Query.select()方法。该方法限制从每个结果对象返回的属性。在这种情况下,查询只选择了composertitlereleaseDate属性,它们都包含在一个空格分隔的字符串中。所有其他属性都会被忽略。

清单 11-61 显示了为每种发布日期查询返回的经过过滤的 JSON 数据。

Listing 11-61. Using curl to Find Albums by Release Date

example-011$ curl -X GET http://localhost:8080/album/released/01-01-2013

{"message":"no albums on release date 01-01-2013"}

example-011$ curl -X GET http://localhost:8080/album/released/01-01-2013/before

[{"_id":"54ed1dcb6fb525ba25529bf2","composer":"Jessica Curry","title":"Dear Esther","releaseDate":"2012-02-14T06:00:00.000Z"},{"_id":"54f3a4df056601726f867685","composer":"nervous_testpilot","title":"Frozen Synapse","releaseDate":"2012-09-06T05:00:00.000Z"}]

example-011$ curl -X GET http://localhost:8080/album/released/01-01-2013/after

[{"_id":"54ed1dcb6fb525ba25529bd1","composer":"Kerry Muzzey","title":"Renaissance","releaseDate":"2014-01-13T06:00:00.000Z"},{"_id":"54ed1dcb6fb525ba25529bd7","composer":"Audiomachine","title":"Tree of Life","releaseDate":"2013-07-16T05:00:00.000Z"}]

注意,即使Query.select()过滤器没有指定包含的_id属性,它仍然存在于每个响应中。要忽略此属性,需要在选择字符串中添加一个否定。在_id属性前加一个减号会阻止它被选中:

Album.find(...)

.select('-_id composer title releaseDate')

// ...

Note

当执行包含选择(指定要提取的属性)时,_id属性是唯一可以指定排除的属性。否则,排除的和包含的属性不能混合。查询要么只选择特定的属性,要么只排除特定的属性,但不能两者都选。如果Query.select()字符串中的任何属性被求反(除了_id,所有指定的属性都必须被求反,否则将引发错误。

$in 和$nin 运算符

选择属性值与某些可能性子集相匹配的文档通常很有帮助。$in操作符(及其逆操作符$nin)针对数组中的每个元素测试文档属性值。如果文档的属性与数组中的至少一个元素匹配,则该文档满足条件。例如,要查找两位作曲家的专辑,可以使用清单 11-62 中的 criteria 对象。

Listing 11-62. Using the $in Query Operator to Filter by Composer

{composer: {$in: ['Kerry Muzzey', 'Jessica Curry']}}

$nin操作符的作用正好相反:只有当属性值不包含在指定的集合中时,它才会匹配。

$in$nin都适用于具有标量值的属性(如字符串、数字、日期等)。),但是它们也可以用于在集合中进行搜索。清单 11-63 中的 web route 接受一个音乐流派作为 URL 参数,并在 HTTP 响应中返回相关流派。

Listing 11-63. Using the $in Query Operator to Filter by Genre

// example-011/album-routes.js

/**

* GET /album/genre/(genre)/related

* @param req

* @param cb

*/

routes.GET['^\/album\/genre\/([a-zA-Z]+)/related$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var principalGenre = req.params[0];

var criteria = {

genre: {$in: [principalGenre]}

};

Album.find(criteria)

.lean(true)

.select('-_id genre')

.exec(function (err, albums) {

if (err) return cb(500, err);

var relatedGenres = [];

albums.forEach(function (album) {

album.genre.forEach(function (albumGenre) {

// don’t include the principal genre

if (albumGenre === principalGenre) return;

// ensure duplicates are ignored

if (relatedGenres.indexOf(albumGenre) < 0) {

relatedGenres.push(albumGenre);

}

});

});

cb(200, {genre: principalGenre, related: relatedGenres});

});

};

example-011$ curl -X GET http://localhost:8080/album/genre/Dance/related

{"genre":"Dance","related":["Electronica","Soundtrack"]}

为了确定什么构成“相关”流派,criteria 对象选择具有主要流派的专辑作为每个文档的genre数组中的元素。然后,它会编译结果集中已分配给专辑的所有其他流派的列表,并将该列表返回给客户端。虽然Album.genre是一个数组,但是 MongoDB 知道遍历它来寻找与$in操作符中的元素匹配的值。Query.select()方法排除了_id属性,只包含了genre属性,因为只有它包含了这条路线感兴趣的数据。

$in操作符对于查找标量值数组中的元素很有用,但是在搜索复杂对象数组时需要不同的方法。例如,Album.tracks中的每个子文档都有自己的属性和值。若要搜索包含符合某些条件的曲目的专辑,可以从专辑本身开始,使用曲目的完整属性路径来引用曲目的属性。在清单 11-64 中,将获取拥有任何具有与 criteria 对象中的值tracks.title相匹配的title属性的音轨的专辑。

Listing 11-64. Using a Subdocument Path in a Criteria Object

// example-012/album-routes.js

/**

* GET /album(?composer={string}&title={string}&track={string})

* @param req

* @param cb

*/

routes.GET['^\/album(?:\\?.+)?$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var criteria = {};

// ...

if (req.query.track) {

criteria['tracks.title'] = req.query.track;

}

// ...

Album.find(criteria)

.lean(true)

.exec(function (err, albums) {

if (err) return cb(500, err);

cb(200, albums);

});

};

example-012$ curl -X GET http://localhost:8080/album?track=The%20Looking%20Glass

{"_id":"54ed1dcb6fb525ba25529bd1","composer":"Kerry Muzzey","title":"Renaissance"... }

$and 和$or 运算符

简单标准对象可以使用普通对象表示法来查询属性。例如,要查找出版中的专辑,清单 [11-65 中的简单标准对象就足够了。

Listing 11-65. Simple Criteria Object

Album.find({inPublication: true}, function (err, albums) {/*...*/});

然而,这种方法对于复杂的复合查询是不够的,比如清单 11-66 中的伪查询。

Listing 11-66. Painful Pseudo-Query

(select albums that

(

(are in publication and were released within the last two years) or

(are categorized as classical and priced between $9 and $10)

)

)

幸运的是,$and$or操作符可以用来构建一个 criteria 对象,该对象将产生所需的相册集。两个操作符都接受 criteria 对象的数组,这些对象可能包含简单查询,也可能包含复杂查询,这些复杂查询还包含$and$or或任何其他有效的查询操作符。$and操作符使用其数组中的每个 criteria 对象执行逻辑AND操作,只选择匹配所有指定标准的文档。相比之下,$or操作符执行逻辑OR操作,选择符合任何标准的文档。

在清单 11-67 中,专辑推荐路径由一个使用两个复合操作符的 criteria 对象组成。注意,简单标准对象中的关键字是属性名,而复合标准对象中的关键字是复合运算符,后跟简单和/或复杂标准对象的数组。

Listing 11-67. Using $and and $or to Find Album Recommendations

// example-012/album-routes.js

/**

* GET /album/recommended

* @param req

* @param cb

*/

routes.GET['^\/album\/recommended$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var nowMS = Date.now();

var twoYearsMS = (365 * 24 * 60 * 60 * 1000 * 2);

var twoYearsAgo = new Date(nowMS - twoYearsMS);

var criteria = {

$or: [

// match all of these conditions...

{ $and: [{inPublication: true}, {releaseDate: {$gt: twoYearsAgo}}] },

// OR

// match all of these conditions...

{ $and: [{genre: {$in: ['Classical']}}, {price: {$gte: 5, $lte: 10}}] }

]

};

Album.find(criteria)

.lean(true)

.select('-_id -tracks')

.exec(function (err, albums) {

if (err) return cb(500, err);

cb(200, albums);

});

};

example-012$ curl -X GET http://localhost:8080/album/recommended

[{"composer":"Kerry Muzzey","title":"Renaissance","price":4.95... },

{"composer":"Audiomachine","title":"Tree of Life","price":9.49... },

{"composer":"Jessica Curry","title":"Dear Esther","price":6.99... }]

$regex 运算符

通常,搜索匹配精确文本字段查询的文档会产生次优结果。正则表达式可用于扩大这些搜索,以便选择具有类似特定查询参数的字段的文档。在基于 SQL 的语言中,like操作符可以用于这个目的,但是 MongoDB 更喜欢正则表达式。$regex操作符向 criteria 对象属性添加一个正则表达式,选择匹配正则表达式的文档,排除不匹配的文档。它通常与$options操作符成对出现,后者可能包含任何有效的正则表达式标志,如i(不区分大小写)。清单 11-68 中的路由接受一个查询参数owner,它被转换成一个正则表达式,并应用于每个库文档的owner属性。

Listing 11-68. Finding a Library with a Regular Expression

// example-012/library-routes.js

/**

* GET /library?

* @param req

* @param cb

*/

routes.GET['^\/library(?:\\?.+)?$'] = function (req, cb) {

cb = httpd.asJSON(cb);

var criteria = {};

if (req.query.owner) {

criteria.owner = {

$regex: '^.*' + req.query.owner + '.*$',

$options: 'i'

}

} else {

return cb(404, {message: 'please specify an owner'});

}

Library.find(criteria)

.populate('albums')

.exec(function (err, libraries) {

if (err) return cb(500, err);

cb(200, libraries);

});

};

criteria 对象指定将应用正则表达式的属性,以及一个包含表达式($regex属性)和匹配时要应用的任何选项($options属性)的对象。在清单 11-69 中,curl命令使用所有者cloud作为查询字符串参数。由于上面清单 11-68 中的正则表达式用正则表达式通配符.*包围了查询参数,并且由于正则表达式选项指定了不区分大小写的选项i,所以 route 将返回 MongoDB 中唯一的库,归Nicholas Cloud所有。清单 11-69 显示了curl命令和 HTTP 响应输出。

Listing 11-69. Finding a Library by Owner with cURL

curl -X GET http://localhost:8080/library?owner=cloud

[{"_id":"54ed249312c06b3726d3abcd","owner":"Nicholas Cloud"... ]

高级查询运算符

在 Mongoose 查询中可以使用更多的 MongoDB 操作符,虽然对每一个操作符的深入分析都需要更多的页面,但是表 11-3 提供了对其他高级查询操作符的高级概述。

表 11-3。

Additional Advanced Query Operators

| 操作员 | 描述 | | --- | --- | | `$not`,`$nor` | 组合查询子句并选择相应匹配的文档的负逻辑运算符 | | `$exists` | 选择存在指定属性的文档(记住,MongoDB 文档在技术上是无模式的) | | `$type` | 选择指定属性属于给定类型的文档 | | `$mod` | 选择指定字段上的模运算符返回指定结果的文档(例如,选择价格能被 3.00 整除的所有相册) | | `$all` | 选择具有包含所有指定元素的数组属性的文档 | | `$size` | 选择具有给定大小的数组属性的文档 | | `$elemMatch` | 选择数组中子文档匹配多个条件的文档 |

摘要

MongoDB 是无模式的,在设计上非常灵活,但是应用开发人员经常在应用代码中添加对数据的约束,以实施业务规则、确保数据完整性、符合现有的应用抽象,或者实现任何数量的其他目标。Mongoose 认识到并接受了这一现实,并在应用代码和数据存储之间找到了平衡点。

Mongoose 模式给自由格式的数据增加了约束。它们定义要存储的数据的形式和有效性,实施约束,创建文档之间的关系,并通过中间件公开文档生命周期。

模型提供了完整但可扩展的查询接口。符合 MongoDB 查询语法的 Criteria 对象用于查找特定的数据。可链接的查询方法使开发人员能够控制属性选择、引用群体以及是检索完整文档还是普通 JSON 对象。可以将封装复杂标准对象和更复杂查询的定制静态方法添加到模型中,以保持应用关注点的适当分离。

最后,Mongoose 文档可以用包含域逻辑的定制实例方法,以及帮助计算属性操作的定制 getters 和 setters 进行扩展。

十二、Knex 和 Bookshelf

关于我死亡的报道是夸大其词。——塞缪尔·兰霍恩·克莱门斯(马克·吐温)

在这一章中,我们将探索两个库,它们一起工作来减轻 Node.js 开发人员在使用关系数据库时经常遇到的许多困难。第一个是 Knex,它提供了一个灵活且一致的接口,用于与几个著名的 SQL 平台(如 MySQL 和 PostgreSQL)进行交互。第二个是 Bookshelf,它在此基础上为开发人员提供了一个强大的对象关系映射(ORM)库,该库简化了对组成应用数据结构的实体以及它们之间存在的各种关系进行建模的过程。熟悉 Backbone.js 及其对模型和集合中结构化数据的强调的读者将很快发现自己对 Bookshelf 如鱼得水,因为该库遵循许多相同的模式并提供许多相同的 API。

在本章中,您将学习如何执行以下操作:

  • 使用 Knex 查询构建器创建 SQL 查询
  • 在 promises 的帮助下,创建复杂的数据库交互,而不需要借助嵌套的回调函数
  • 通过使用事务来确保应用数据的完整性
  • 借助 Knex 迁移脚本管理对数据库模式的更改
  • 使用 Knex 种子脚本用样本数据引导您的数据库
  • 定义 Bookshelf 模型之间的一对一、一对多和多对多关系
  • 基于 Bookshelf 关系,使用急切加载有效地检索复杂的对象图

Note

本章中的大多数例子都大量使用了 Bookshelf 和 Knex 提供的基于承诺和下划线的 API。因此,不熟悉这些概念的读者被鼓励先阅读第十七章,它涵盖了 Q,以及第十九章,它涵盖了下划线和连字符。

耐克斯

Knex 为 MySQL、PostgreSQL、MariaDB 和 SQLite3 提供了一个数据库抽象层(DBAL ),这是一个统一的接口,开发人员可以通过它与这些结构化查询语言(SQL)数据库进行交互,而不必关心每个平台之间存在的语法和响应格式的细微变化。由这种关系数据库支持的应用可以受益于 Knex 的许多特性,包括:

  • 一个基于承诺的接口,允许对异步流程进行更清晰的控制
  • 一个流接口,用于根据需要通过应用有效地传输数据
  • 统一的界面,通过它可以为每个支持的平台创建查询和模式
  • 交易支持

除了库本身之外,Knex 还提供了一个命令行工具,开发人员可以使用它来执行以下操作:

  • 创建、实施和(必要时)恢复数据库迁移,脚本化的模式更改,然后可以用应用的源代码提交
  • 创建数据库“种子”脚本,这是一种一致的方法,通过这种方法可以用本地开发和测试的样本数据填充应用的数据库

本章将更详细地介绍这些主题。

安装命令行工具

在进一步操作之前,您应该确保已经安装了 Knex 提供的命令行工具。作为一个 npm 包,安装过程如清单 12-1 所示。

Listing 12-1. Installing the knex Command-Line Utility via npm

$ npm install -g knex$ knex --version

Knex CLI version:  0.7.3

将 Knex 添加到项目中

除了安装knex命令行工具,您还需要添加knex npm 模块,作为您打算使用它的每个项目中的本地依赖项,以及支持的数据库库,如清单 12-2 所示。

Listing 12-2. Installing Knex and a Supported Database Library As a Local Project Dependency via npm

$ npm install knex --save

# Supported database libraries include (be sure to --save):

$ npm install mysql

$ npm install mariasql

$ npm install pg

$ npm install sqlite3

Note

SQLite 在磁盘上的单个文件中实现了一个独立的、无服务器的数据库,并且不需要额外的工具。如果您目前无法访问 MySQL 之类的数据库服务器,那么sqlite3库将为您提供一种快速而简单的方法来开始使用 Knex,而不需要额外的设置。本章引用的例子将使用这个库。

配置 Knex

现在有了依赖项,剩下的就是在项目中初始化 Knex。清单 12-3 展示了如果您碰巧使用 MySQL、PostgreSQL 或 MariaDB,这个过程会是什么样子,而清单 12-4 展示了如何初始化 Knex 以用于 SQLite3。

Listing 12-3. Initializing Knex for Use with MySQL, PostgreSQL, or MariaDB (Substitute mysql for pg or mariasql As Needed)

var knex = require('knex')({

'client': 'mysql',

'connection': {

'host': '127.0.0.1',

'user': 'user',

'password': 'password',

'database': 'database'

},

'debug': false // Set this to true to enable debugging for all queries

});

Listing 12-4. Initializing Knex for Use with SQLite3

// example-sqlite-starter/lib/db.js

var knex = require('knex')({

'client': 'sqlite3',

'connection': {

'filename': 'db.sqlite'

}

});

如您所见,SQLite3 所需的配置设置比其他功能更全的解决方案所需的配置设置要简单得多。我们不提供连接设置,而是简单地提供一个文件名(db.sqlite),SQLite 将在其中存储数据。

SQL 查询生成器

Knex 的主要重点是为开发人员提供一个统一的界面,通过这个界面,他们可以与多个基于 SQL 的数据库进行交互,而不必担心它们之间存在的语法和响应格式的微小变化。为此,Knex 提供了许多方法,其中大部分属于两类:查询构建器方法和接口方法。

查询生成器方法

查询生成器方法是帮助开发人员创建 SQL 查询的方法。这种方法的例子包括select()from()where()limit()groupBy()。根据最新统计,Knex 提供了 40 多种这样的方法,使用这些方法可以创建与平台无关的查询。清单 12-5 展示了一个简单的 SQL 查询,以及一个演示如何使用 Knex 创建这样一个查询的例子。

Listing 12-5. Example Demonstrating the Creation of a Simple SQL Query Using Knex

// example-sqlite-starter/example1.js

// SELECT id, name, postal_code FROM cities;knex.select('id', 'name', 'postal_code').from('cities');

虽然清单 12-5 中的例子展示了使用 Knex 创建 SQL 查询的基本方法,但它并没有传达库的真正价值。当我们看一看 Knex 提供的各种接口方法时,这个价值应该开始变得更加明显。有了这些方法,我们就可以开始提交我们的查询并处理它们产生的数据。

接口方法

Knex 提供了许多接口方法,允许我们以几种方便的方式提交和处理我们的查询。在这一节中,我们将了解两种最有用的方法。

承诺

JavaScript 的事件驱动特性使其非常适合高效处理复杂的异步任务。传统上,JavaScript 开发人员通过使用回调函数来管理异步控制流,如清单 12-6 所示。

Listing 12-6. Simple Callback Function

var request = require('request');

request({

'url': 'http://mysite.com

'method': 'GET'

}, function(err, response) {

if (err) throw new Error(err);

console.log(response);

});

回调函数允许我们将特定代码序列的执行推迟到适当的时候。这样的功能很容易理解和实现。不幸的是,随着应用复杂性的增加,它们也很难管理。想象一个场景,在清单 12-6 中收到初始响应后,必须运行额外的异步流程。这样做需要使用额外的嵌套回调函数。随着额外的异步步骤被添加到代码中,我们开始经历许多开发人员所称的“回调地狱”或“末日金字塔”,这些术语描述了这种方法经常导致的大量不可维护的代码。

幸运的是,JavaScript promises 为开发人员提供了一个解决这个问题的便捷方案 Knex 通过基于 promise 的接口广泛使用这个方案来提交和处理查询。清单 12-7 展示了这个 API 的实际应用。

Listing 12-7. Demonstration of the Promise-Based API Provided by Knex

// example-sqlite-starter/example2.js

knex.pluck('id').from('cities').where('state_id', '=', 1)

.then(function(cityIds) {

return knex.select('id', 'first_name', 'last_name').from('users')

.whereIn('city_id', cityIds);

})

.then(function(users) {

return [

users,

knex.select('*').from('bookmarks').whereIn('user_id', _.pluck(users, 'id'))

];

})

.spread(function(users, bookmarks) {

_.each(users, function(user) {

user.bookmarks = _.filter(bookmarks, function(bookmark) {

return bookmark.user_id = user.id;

});

});

console.log(JSON.stringify(users, null, 4));

})

.catch(function(err) {

console.log(err);

});

在此示例中,连续提交了三个查询:

Cities within a particular state are selected.   Users who live within the returned cities are selected.   Bookmarks for each of the returned users are selected.

在我们的最终查询返回后,我们将每个书签附加到适当的用户并显示结果,您可以在清单 12-8 中看到。

Listing 12-8. Data Logged to the Console As a Result of the Code in Listing 12-7

[

{

"id": 1,

"first_name": "Steve",

"last_name": "Taylor",

"bookmarks": [

{

"id": 1,

"url": "http://reddit.com

"label": "Reddit",

"user_id": 1,

"created_at": "2015-03-12 12:09:35"

},

{

"id": 2,

"url": "http://www.theverge.com

"label": "The Verge",

"user_id": 1,

"created_at": "2015-03-12 12:09:35"

}

]

}

]

感谢 Knex 提供的基于 promise 的接口,我们的代码永远不会超出一级缩进,从而确保我们的应用易于理解。更重要的是,如果在这个过程中的任何一点出现错误,我们最终的catch语句都会很方便地捕捉并处理它。

Note

JavaScript promises 是一个强大的工具,可以用一种易于遵循和维护的方式编写复杂的异步代码。如果你对这个概念不熟悉,建议你跳到第十七章,阅读 Q promise 库以获得更多关于这个主题的信息。

用 Node.js 编写应用的最大好处之一是平台能够以非常高效的方式执行 I/O 密集型过程。与 PHP、Python 或 Ruby 等同步语言不同,Node.js 能够在一个线程中同时处理数千个连接,允许开发人员编写能够满足巨大需求的应用,同时使用最少的资源。Node.js 为完成这一壮举提供了几个重要的工具,其中最重要的一个是 streams。

在我们看一看流之前,让我们检查另一个传统 JavaScript 回调函数的例子,如清单 12-9 所示。

Listing 12-9. JavaScript Callback Function That Accepts the Contents of a Loaded File

var fs = require('fs');

fs.readFile('data.txt', 'utf8', function(err, data) {

if (err) throw new Error(err);

console.log(data);

});

在这个例子中,我们使用 Node.js 中可用的本机fs库的readFile()方法来读取文件的内容。一旦数据被加载到内存中(全部),它就被传递给我们的回调函数进行进一步的处理。这种方法简单易懂。然而,这不是很有效,因为我们的应用必须首先将文件的全部内容加载到内存中,然后再将它传递给我们。对于较小的文件来说,这不是一个可怕的问题,但是较大的文件可能会引起问题,这取决于运行该应用的服务器的可用资源。

Node.js 流通过将数据以多个更小的块的形式通过一个或多个函数来解决这个问题。通过这样做,流允许开发人员避免将服务器的大部分可用资源用于任何单个请求。清单 12-10 中所示的例子实现了我们上一个例子的相同目标,没有将整个文件的内容一次性加载到内存中。

Listing 12-10. Pair of Node.js Streams Working Together to Efficiently Load and Display the Contents of a File

// example-read-file-stream/index.js

var fs = require('fs');

var Writable = require('stream').Writable;

var stream = fs.createReadStream('data.txt');

var out = Writable();

out._write = function(chunk, enc, next) {

console.log(chunk.toString());

next();

};

stream.pipe(out);

流是 Node.js 的一个相对未被充分利用的特性,这很不幸,因为它们恰好是该平台更强大的方面之一。幸运的是,Knex 为消费查询结果提供了一个流接口,允许我们利用这些好处,如清单 12-11 所示。

Listing 12-11. Processing the Results of a Query via the Streaming Interface Provided by Knex

var Writable = require('stream').Writable;

var ws = Writable();

ws._write = function(chunk, enc, next) {

console.dir(chunk);

next();

};

var stream = knex.select('*').from('users').stream();

stream.pipe(ws);

在这个例子中,我们对users表的查询结果(对于某些应用来说可能很大)以较小的块流传送到我们的可写流中,而不是完整地传递。这种方法也可以与库的 promise 接口配对,以创建一个更健壮的实现,如清单 12-12 所示。

Listing 12-12. Combining the Streaming and Promise-Based Interfaces Provided by Knex for Better Error Handling

var Writable = require('stream').Writable;

var ws = Writable();

ws._write = function(chunk, enc, next) {

console.dir(chunk);

next();

};

knex.select('*').from('users').stream(function(stream) {

stream.pipe(ws);

}).then(function() {

console.log('Done.');

}).catch(function(err) {

console.log(err);

});

在这个例子中,我们结合了 Knex 提供的流和基于承诺的接口的能力。当回调函数被传递给库的stream()方法时,回调函数接收生成的承诺,而不是被直接返回。相反,会返回一个承诺,一旦流完成,这个承诺就会得到解决。

Note

Knex 提供的流接口兼容 MySQL、PostgreSQL 和 MariaDB 数据库。当前不支持 SQLite3。

处理

使用与 ACID 兼容的关系数据库的最大好处之一在于,它们能够将多个查询分组到一个工作单元(即一个“事务”)中,这个工作单元要么整体成功,要么整体失败。换句话说,如果事务中的单个查询失败,则由于事务中先前运行的查询而发生的任何更改都将被恢复。

举例来说,考虑在你的银行发生的金融交易。假设你想在你表妹生日那天给她寄 25 美元。这些资金必须先从你的账户中取出,然后再存入你堂兄的账户。想象一下这样一个场景:在资金从您的帐户中移除之后,但在资金被插入您堂兄的帐户之前,支持资金交换的应用由于各种原因(例如,一行错误的代码或更大的系统故障)而崩溃。如果没有交易提供的安全网,这些资金基本上会消失得无影无踪。事务允许开发人员确保这样的过程完全发生,不会让数据处于不一致的状态。

Note

缩写 ACID(原子性、一致性、隔离性、持久性)指的是一组描述数据库事务的属性。原子性指的是这样一个事实,即这种事务要么整体成功,要么整体失败。这种事务被称为“原子的”

本章前面的例子已经演示了使用 Knex 创建和提交数据库查询的过程。在我们继续之前,让我们回顾一下另一个没有利用事务的例子。之后,我们将更新这个例子,以利用事务提供的安心感。

在清单 12-13 所示的例子中,声明了一个moveFunds()函数,当被调用时,使用knex对象将指定数量的资金从一个账户转移到另一个账户。该函数返回一个承诺,一旦该过程完成,该承诺将被解决或拒绝,这取决于调用的成功或失败。这里有一个明显的错误,你能发现吗?

Listing 12-13. moveFunds() Function Demonstrating the Process of Moving Funds from One Account to Another Without the Security of Transactions

// example-financial/bad.js

/**

* Moves the specified amount of funds from sourceAccountID to destAccountID

*/

var moveFunds = function(sourceAccountID, destAccountID, amount) {

return knex.select('funds').from('accounts')

.where('id', sourceAccountID)

.first(function(result) {

if (!result) {

throw new Error('Unable to locate funds for source account');

}

if (result.funds < amount) {

throw new Error('Not enough funds are available in account');

}

return knex('accounts').where('id', sourceAccountID).update({

'funds': result.funds - amount

});

}).then(function() {

return knex.select('funds').from('accounts')

.where('id', destAccountID);

}).first(function(result) {

if (!result) {

throw new Error('Unable to locate funds for destination account');

}

return knex('accounts').where('id', destAccountID).update({

'funds': result.funds + amount

});

});

};

/* Move $25 from account 1 to account 2\. */

moveFunds(1, 2, 25).then(function(result) {

console.log('Transaction succeeded.', result);

}).catch(function(err) {

console.log('Transaction failed!', err);

});

在本例中,要实现将资金从源帐户转移到目标帐户的目标,需要执行以下步骤:

The total funds currently available within the source account are determined.   If insufficient funds are available to complete the process, an error is thrown.   The funds to be transferred are deducted from the source account.   The total funds currently available within the destination account are determined.   If the destination account cannot be found, an error is thrown.   The funds to be transferred are added to the destination account.

如果你还没有发现错误,一个明显的问题会在第 5 步出现。如果找不到目标帐户,就会抛出一个错误,但是此时要转移的资金已经从源帐户中扣除了!我们可以尝试用多种方法来解决这个问题。我们可以捕获代码中的错误,然后将资金贷记回源帐户,但这仍然不能解决由于网络问题或应用服务器在此过程中断电并完全崩溃而可能出现的不可预见的错误。

正是在这一点上,数据库事务的威力开始变得明显。在清单 12-14 中,我们的moveFunds()函数被重构,将整个过程包装成一个单一的“原子”事务,这个事务要么成功,要么失败。请注意trx对象的创建,我们的事务感知查询就是从这个对象构建的。

Listing 12-14. Transaction-Aware Implementation of Listing 12-13

// example-financial/index.js

/**

* Moves the specified amount of funds from sourceAccountID to destAccountID

*/

var moveFunds = function(sourceAccountID, destAccountID, amount) {

return knex.transaction(function(trx) {

return trx.first('funds')

.from('accounts')

.where('id', sourceAccountID)

.then(function(result) {

if (!result) {

throw new Error('Unable to locate funds for source account');

}

if (result.funds < amount) {

throw new Error('Not enough funds are available in account');

}

return trx('accounts').where('id', sourceAccountID)

.update({

'funds': result.funds - amount

});

})

.then(function() {

return trx.first('funds')

.from('accounts')

.where('id', destAccountID);

})

.then(function(result) {

if (!result) {

throw new Error('Unable to locate funds for destination account');

}

return trx('accounts').where('id', destAccountID)

.update({

'funds': result.funds + amount

});

});

});

};

/* Move $25 from account 1 to account 2\. */

displayAccounts()

.then(function() {

return moveFunds(1, 2, 25);

}).then(function() {

console.log('Transaction succeeded.');

}).catch(function(err) {

console.log('Transaction failed!', err);

});

正如您所看到的,清单 12-14 中显示的事务感知示例很大程度上类似于清单 12-13 中显示的示例,但是它在一个重要方面有所不同。我们不是通过直接在 knex 对象上调用构建器方法来创建查询,而是首先通过调用 knex.transaction()来启动一个事务。然后,我们提供的回调函数被传递给一个“事务感知”的替身(trx ),我们从这个替身开始创建我们的一系列查询。从现在开始,我们从 trx 对象创建的任何查询要么成功,要么失败。knex.transaction()方法返回一个承诺,一旦事务作为一个整体完成,该承诺将被解决或拒绝,这使我们可以轻松地将该事务集成到一系列更大的基于承诺的操作中。

迁移脚本

正如应用的源代码注定会随着时间的推移而改变一样,它存储的信息结构也是如此。在进行此类更改时,重要的是要以一种可重复、可共享、必要时可回滚并可随时跟踪的方式来实现它们。数据库迁移脚本为开发人员实现这一目标提供了一种方便的模式。

Knex 迁移脚本由两个函数组成,updown,如清单 12-15 所示。脚本的up函数负责以某种期望的方式修改数据库的结构(例如,创建一个表,添加一列),而它的down函数负责将数据库的结构恢复到以前的状态。

Listing 12-15. Knex Migration Script with up Function Creating a New Table and down Function Dropping the Table

// example-sqlite-starter/migrations/20150311082640_states.js

exports.up = function(knex, Promise) {

return knex.schema.createTable('states', function(table) {

table.increments().unsigned().primary().notNullable();

table.string('name').notNullable();

table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable();

});

};

exports.down = function(knex, Promise) {

return knex.schema.dropTable('states');

};

为迁移配置项目

Knex 命令行工具为开发人员提供了创建和管理迁移脚本的简单工具。首先,您需要通过在项目的根文件夹中运行以下命令来创建一个特殊的配置文件:

$ knex init

运行该命令后,将创建一个文件(knexfile.js),其内容类似于清单 12-16 中所示。您应该根据需要修改该文件的内容。每当 Knex 迁移脚本运行时,Knex 将根据该文件的内容和NODE_ENVIRONMENT环境变量的值来确定其连接设置。

Note

在 OS X 和 Linux 上,通过运行export ENVIRONMENT_VARIABLE=value从终端设置环境变量。在 Windows 命令行中使用的命令是set ENVIRONMENT_VARIABLE=value

Listing 12-16. knexfile.js

// example-sqlite-starter/knexfile.js

module.exports = {

'development': {

'client': 'sqlite3',

'connection': {

'filename': './db.sqlite'

}

},

'seeds': {

'directory': './seeds'

}

},

'staging': {

'client': 'postgresql',

'connection': {

'database': 'my_db',

'user': 'username',

'password': 'password'

},

'pool': {

'min': 2,

'max': 10

}

}

}

};

创建您的首次迁移

现在有了 Knex 配置文件,我们可以继续创建我们的第一个迁移脚本了。执行此操作的命令如下所示:

$ knex migrate:make users_table

当创建您自己的迁移时,用描述您的迁移所实现的变化的术语来替换命令的users_table部分。运行这个命令后,Knex 将为您创建一个类似于清单 12-17 中所示的迁移脚本。

Listing 12-17. New Knex Migration Script

exports.up = function(knex, Promise) {

};

exports.down = function(knex, Promise) {

};

创建第一个迁移脚本后,项目的文件结构应该类似于清单 12-18 所示。

Listing 12-18. Excerpt of Project’s File Structure After Creating First Migration

.

Knex 迁移脚本存储在项目根级别的migrations文件夹中。如果这个目录不存在,Knex 会为您创建一个。Knex 自动为迁移脚本的文件名添加时间戳,如清单 12-18 所示。这确保了项目的迁移总是按照创建的顺序进行排序。

现在由我们来修改新创建的迁移脚本中的updown函数。让我们来看看两种可供选择的方法。

使用模式构建器方法定义模式更新

除了提供构造查询的方法,Knex 还提供定义数据库底层结构(模式)的方法。在这些“模式构建器”方法的帮助下,开发人员可以创建平台无关的蓝图,描述构成数据库的各种表、列、索引和关系。然后,这些蓝图可以应用于任何支持的平台,以生成所需的数据库。清单 12-15 中显示的迁移脚本展示了 Knex 模式构建器的运行,而清单 12-19 展示了由脚本的up方法生成的查询。

Listing 12-19. SQL Query Generated Through Use of Schema Builder Methods, As Shown in Listing 12-15

// example-raw-migration/migrations/20150312083058_states.js

CREATE TABLE states (

id integer PRIMARY KEY AUTOINCREMENT NOT NULL,

name varchar(255) NOT NULL,

created_at datetime NOT NULL DEFAULT(CURRENT_TIMESTAMP)

);

Schema builder 方法非常有用,因为它们允许开发人员以一种可以应用于 Knex 支持的每个平台的方式轻松定义模式。它们还需要最少的关于原始 SQL 查询的知识,这使得几乎没有直接使用 SQL 数据库经验的开发人员能够快速上手并运行。也就是说,模式构建器方法也有局限性。为了提供一个通用接口来定义跨多个平台工作的数据库模式,Knex 必须为您做出某些决定——这一点您可能不太适应。具有更多直接使用 SQL 数据库经验的开发人员可能希望完全绕过模式构建器方法,而选择创建自己的 SQL 查询。正如我们将要看到的,这很容易做到。

用原始 SQL 查询定义模式更新

在清单 12-20 中,我们看到一个 Knex 迁移脚本,它通过使用原始 SQL 查询创建了一个新的users表。这是通过使用knex.schema.raw()方法完成的。调用时,此方法返回一个承诺,该承诺将被解析或拒绝,这取决于它接收到的查询是成功还是失败。

Listing 12-20. Knex Migration Script Defined with Raw SQL Queries

// example-raw-migration/migrations/20150312083058_states.js

var multiline = require('multiline');

exports.up = function(knex, Promise) {

var sql = multiline.stripIndent(function() {/*

CREATE TABLE states (

id integer PRIMARY KEY AUTOINCREMENT NOT NULL,

name varchar(255) NOT NULL,

created_at datetime NOT NULL DEFAULT(CURRENT_TIMESTAMP)

);

*/});

return knex.schema.raw(sql);

};

exports.down = function(knex, Promise) {

return knex.schema.raw('DROP TABLE states;');

};

Note

清单 12-20 中所示的例子使用了一个与 Knex: multiline无关的附加库。multiline库非常有用,因为它允许我们定义跨越多行的大块文本,而不需要每行都以连续字符结尾。

运行 Knex 迁移

现在,我们新创建的迁移脚本已经定义好并可以使用了,剩下的唯一任务就是运行迁移,用我们想要的更改更新我们的数据库。执行此操作的命令如下所示:

$ knex migrate:latest

该命令将指示 Knex 按照创建的顺序运行所有尚未运行的可用迁移脚本。一旦完成,我们的数据库将会完全更新我们想要的变化。如果你想知道 Knex 是如何跟踪哪些迁移已经运行,哪些没有运行,答案就在 Knex 自动为自己创建的knex_migrations表中(见图 12-1 )。在这个表中,Knex 维护了一个已经实施的迁移的运行列表。这个表的名称可以通过修改我们用knex init命令创建的配置文件来更改。

A978-1-4842-0662-1_12_Fig1_HTML.jpg

图 12-1。

The knex_migrations table used by Knex to track which migration scripts have already been applied to your database

恢复 Knex 迁移

运行 Knex 迁移脚本的行为不是单行道。它们也可以被撤销,这在开发过程中尤为重要。执行此操作的命令如下:

$ knex migrate:rollback

该命令将指示 Knex 恢复所有由于最近执行knex migrate:latest而运行的迁移脚本。要验证数据库在迁移脚本方面的状态,可以运行以下命令来确定数据库的当前迁移版本:

$ knex migrate:currentVersion

种子脚本

在上一节中,您了解了 Knex 迁移脚本如何使您能够编写数据库结构更改的脚本,这些脚本可以与他人共享,在必要时可以恢复,并在版本控制中进行跟踪。Knex 种子脚本也有类似的目的,但是关注的是数据而不是结构。Seed 脚本提供了一种一致的方式来指定如何用示例数据填充新创建的数据库,以启动并运行新的开发环境。清单 12-21 显示了本章的一个示例项目中包含的种子脚本的内容。

Listing 12-21. Simple Knex Seed Script That Removes All Existing Records from the states Table and Inserts Two New Ones

// example-sqlite-starter/seeds/01-states.js

exports.seed = function(knex, Promise) {

return Promise.join(

knex('states').del(),

knex('states').insert([

{

'id': 1,

'name': 'Georgia'

},

{

'id': 2,

'name': 'Tennessee'

}

]);

);

};

创建种子脚本

您可以使用以下命令指示 Knex 创建新的种子脚本:

$ knex seed:make users

默认情况下,Knex 将新创建的种子脚本保存到项目根路径的seeds文件夹中。您可以通过修改项目的knexfile.js配置文件的内容来自定义这个文件夹(参见清单 12-16 )。

运行种子脚本

为您的应用创建种子脚本后,您可以通过运行以下命令用它们填充您的数据库:

$ knex seed:run

Note

种子脚本总是按字母顺序运行。如果您的种子运行的顺序很重要,请注意给它们起一个合适的名字,以确保它们按照期望的顺序运行。

Bookshelf

Bookshelf 建立在 Knex 的基础上,提供了一个灵活的 ORM 库,简化了创建类(“模型”)的过程,以表示组成应用的各种对象。本节探讨了开发人员使用 Bookshelf 完成以下任务的各种方式:

  • 创建类(“模型”)来表示应用数据库中使用的各种表
  • 使用定制行为扩展模型,以满足其应用的需求
  • 定义模型之间的复杂关系(一对一、一对多、多对多)
  • 在“急切加载”的帮助下,无需求助于复杂的 SQL 查询,即可轻松浏览模型之间存在的各种关系

熟悉 Backbone 的开发人员会很快发现自己对 Bookshelf 如鱼得水,因为它遵循许多相同的模式并实现许多相同的 API。你可以很容易地将 Bookshelf 描述为“服务器的主干”,而且你不会错得太离谱。

什么是对象关系映射器?

关系数据库将信息存储为一个或多个表中的一系列行,每个表都有一个或多个描述所包含记录的各种属性的列,就像您可能在电子表格中组织信息一样。在大多数应用中,创建单独的表来表示每种类型的可用实体(例如,“帐户”、“用户”、“注释”)。然后通过使用“外键”列来定义这些实体之间存在的各种关系,如图 12-2 所示。

A978-1-4842-0662-1_12_Fig2_HTML.gif

图 12-2。

Here, the relationship between users and accounts (an account has one or more users, users belong to accounts) is described via the account_id foreign key column within the users table.

这种存储信息的方法非常强大,并且是应用存储数据的主要方法,这是有很多原因的(所有这些都超出了本书的范围)。不幸的是,这种方法也与大多数应用倾向于用面向对象的方法来查看数据不一致。

诸如 Bookshelf 之类的对象关系映射(ORM)工具允许开发人员与存储在关系数据库中的平面信息表进行交互,这些信息表是一系列相互连接的对象,开发人员可以通过这些对象进行交互和导航,以实现某些期望的目标。实际上,ORM 库为开发人员提供了一个“虚拟对象数据库”,允许他们更容易地与关系数据库表中包含的平面记录进行交互。

创建您的第一个 Bookshelf 模型

Bookshelf 模型可以被认为是一个类,当它被实例化时,表示数据库中的一条记录。在其最简单的形式中,Bookshelf 模型充当数据容器,为获取和设置属性(即列)值以及创建、更新和销毁记录提供内置功能。然而,正如我们将很快看到的,当我们用自己的定制方法扩展 Bookshelf 模型并定义它们之间存在的关系时,Bookshelf 模型变得更加有用。

Bookshelf 模型是通过bookshelf.Model.extend()方法定义的,如清单 12-22 所示。在这个简单的例子中,定义了一个User模型,它的记录将被持久化到我们数据库的users表中。

Listing 12-22. Simple Bookshelf Model That Represents an Application’s Users

// example-bookshelf1/lib/user.js

var knex = require('./db');

var bookshelf = require('bookshelf')(knex);

var User = bookshelf.Model.extend({

'tableName': 'users',

'idAttribute': 'id' // The primary key for our table. Defaults to: 'id'

});

module.exports = User;

创建新实例

在清单 12-23 中,User模型的一个新实例被创建、修改,然后保存到数据库中。

Listing 12-23. Saving a New Instance of User to the Database

// example-bookshelf1/create.js

var User = require('./lib/user');

var user = new User();

user.set({

'first_name': 'Steve',

'last_name': 'Taylor',

'email': 'steve.taylor@mydomain.com'

});

// Individual attributes can also be set as shown below

// user.set('first_name', 'Steve');

user.save().then(function(user) {

// user has been saved

console.log('User saved', user.toJSON());

/*

{

first_name: 'Steve',

last_name: 'Taylor',

email: 'steve.taylor@mydomain.com',

id: 1

}

*/

});

Bookshelf 提供了一个方便的forge()方法,允许我们稍微简化这个例子,如清单 12-24 所示。这个方法只是在幕后为我们创建并返回一个新的User实例,允许我们放弃使用new关键字。

Listing 12-24. Creating a New Instance of the User Model via the forge() Method

// example-bookshelf1/forge.js

User.forge({

'id': 1,

'first_name': 'John'

}).fetch().then(function(user) {

/* An object containing every attribute / value for

this model can be retrieved via the 'toJSON' method. */

console.log(user.toJSON());

});

获取实例

可以用类似的方式检索User模型的实例。在清单 12-25 中,创建了一个User的新实例,其id属性的值为1。当调用fetch()时,Bookshelf 将使用模型上设置的任何属性来构建用于获取所需记录的查询。在本例中,使用的查询将是

SELECT * FROM users WHERE 'id' = 1;

Listing 12-25. Retrieving an Instance of the User Model from the Database

// example-bookshelf1/fetch.js

User.where({

'id': 1

}).fetch().then(function(user) {

// Individual attributes get be retrieved with the get method

// console.log('first_name', user.get('first_name'));

console.log(user.toJSON());

});

销毁实例

正如模型实例可以被保存一样,它们也可以通过destroy()方法被删除,如清单 12-26 所示。

Listing 12-26. Deleting an Instance of the User Model

// example-bookshelf1/destroy.js

User.where({

'id': 1

}).fetch().then(function(user) {

return user.destroy();

}).then(function() {

console.log('User destroyed.');

});

在这个例子中,destroy作为user上的一个实例方法被调用。然而,我们可以指示 Bookshelf 简单地查找并销毁记录,而不需要我们自己首先获取实例,如清单 12-27 所示。

Listing 12-27. Instructing Bookshelf to Destroy the Specified Record

User.where({

'id': 1

}).destroy().then(function() {

console.log('User destroyed.');

});

获取多个模型(集合)

除了通过fetch()方法检索模型的单个实例,我们还可以通过fetchAll()方法检索多个实例,如清单 12-28 所示。

Listing 12-28. Fetching All Instances of User with a Value of John for first_``name

// example-bookshelf1/fetch-collection.js

User.where({

'last_name': 'Doe'

}).fetchAll().then(function(users) {

console.log(JSON.stringify(users.toJSON(), null, 4));

/*

[{

"id": 3,

"first_name": "John",

"last_name": "Doe",

"email": "john.doe@mydomain.com"

},

{

"id": 4,

"first_name": "Jane",

"last_name": "Doe",

"email": "jane.doe@mydomain.com"

}]

*/

});

在这个例子中,我们对fetchAll()的调用返回一个承诺,该承诺解析为多个用户的集合。该集合提供了许多专门为与多个模型交互而设计的内置方法。鉴于 Bookshelf 对遵循主干模式的强烈关注,主干集合中可用的大多数相同方法在这里也是可用的。清单 12-29 展示了一些常见的用例。

Listing 12-29. Commonly Used Bookshelf Collection Methods

/* Iterate through a collection */

users.each(function(user, index) {

console.log(user, index);

});

/* Create an array composed of models matching more specific criteria */

users = users.filter(function(user, index) {

if (user.get('last_name') === 'Smith') return true;

});

/* A simpler method for filtering models, when a function call is not needed */

users = users.where({

'last_name': 'Smith'

});

/* Return the first entry matching the specified criteria */

var johnSmith = users.find(function(user) {

if (user.get('last_name') === 'Smith') return true;

});

/* Returns an array containing the first name of every user */

var firstNames = users.pluck('first_name');

用自定义行为扩展

最简单的情况是,Bookshelf 模型除了充当数据库中记录的容器之外,还提供了读写属性值和执行保存或销毁操作的内置方法。虽然这很有用,但是只有当我们开始用适合我们应用需求的独特行为来扩展 Bookshelf 模型时,Bookshelf 模型才开始发挥其全部潜力。

清单 12-30 展示了这种行为的一个例子。在这里,我们更新了前面示例中的用户模型,以包含一个 sendEmail()方法。这样做可以让我们抽象出向应用的注册用户发送电子邮件所涉及的复杂性。

Listing 12-30. Extending the User Model with a Method for Sending Outbound E-mails from Our Application

var Promise = require('bluebird');

var Handlebars = require('handlebars');

var User = bookshelf.Model.extend({

'tableName': 'users',

/**

* Sends an e-mail to the user. Requires an options object

* with values for subjectandmessage. These values will be

* compiled as Handlebars templates, passed this user’s attributes,

* and the result(s) will be used to generate the outgoing message.

*/

'sendEmail': function(options) {

var self = this;

return Promise.resolve()。then(function() {

var subject = Handlebars.compile(options.subject)(self.toJSON());

var message = Handlebars.compile(options.message)(self.toJSON());

// Use your e-mail library of choice here, along with the

// appropriate connection settings.     });

}

});

User.where({

'id': 1

}).fetch().then(function(user) {

return user.sendEmail({

'subject': 'Welcome, {{first_name}}',

'message': 'We are happy to have you on board, {{first_name}} {{last_name}}.'

});

});

除了那些从 Backbone 继承的方法之外,Bookshelf 收藏还提供了一些自己的方法。清单 12-31 展示了invokeThen()方法的使用,允许我们轻松地调用集合中包含的每个模型的方法。

Listing 12-31. Invoking an Imagined sendEmail( ) Method on Each Model Contained Within a Collection

// example-bookshelf1/invoke-then.js

User.where({

'last_name': 'Doe'

}).fetchAll().then(function(users) {

return users.invokeThen('sendEmail', {

'subject': 'Congratulations on having such a great name, {{first_name}}.',

'message': '{{first_name}} really is a great name. Seriously - way to go.'

});

}).then(function(users) {

console.log('%s users were complimented on their name.', users.length);

});

本例中演示的 invokeThen()方法返回它自己的承诺,只有在集合模型上对 sendEmail()的所有调用都得到解决之后,这个承诺才会得到解决。这种模式也为我们提供了一种同时与多个模型交互的便捷方法。

执行验证

熟悉 Backbone 的人会发现 Bookshelf 的事件系统相当熟悉。关于验证,特别感兴趣的是 Bookshelf 发出的savingdestroying事件。通过利用这些事件,Bookshelf 模型可以定制独特的行为,根据一些期望的标准允许或拒绝这些操作。清单 12-32 显示了一个例子,其中电子邮件地址包含字符串“hotmail.com”的用户被阻止保存到数据库中。

Listing 12-32. Demonstration of Bookshelf’s Event System, Which Allows for Implementation of Custom Validation Rules

// example-bookshelf1/lib/user.js

var User = bookshelf.Model.extend({

'tableName': 'users',

'initialize': function() {

this.on('saving', this._validateSave);

},

'_validateSave': function() {

var self = this;

return Promise.resolve().then(function() {

if (self.get('email').indexOf('hotmail.com') >= 0) {

throw new Error('Hotmail email addresses are not allowed.');

}

});

}

});

为了防止对savedestroy的调用成功,只需进入模型的savingdestroying事件,传递一个对您自己定制的验证函数的引用。如果抛出错误,调用将被阻止。通过使用承诺,异步验证也是可能的。在清单 12-33 中,一个定制的验证函数返回一个最终被拒绝的承诺。

Listing 12-33. Custom Validation Function That Returns a Promise

// example-bookshelf1/validation.js

User.forge({

'first_name': 'Jane',

'last_name': 'Doe',

'email': 'jane.doe@hotmail.com'

}).save().then(function() {

console.log('Saved.');

}).catch(function(err) {

/* Our call to save will result in an error, due to this user’s

hotmail.com e-mail address. */

console.log(err);

});

自定义导出过程

前面的例子已经展示了toJSON()方法的使用,该方法(默认情况下)返回一个对象,该对象包含对其进行调用的模型的所有可用属性/值(或者,如果对集合进行调用,则返回所有可用模型的属性/值)。如果您希望定制该方法返回的数据,您可以通过覆盖toJSON()方法来实现,如清单 12-34 所示。

Listing 12-34. Customizing the Data Returned by Our Model’s toJSON() Method

var User = bookshelf.Model.extend({

'tableName': 'users',

'toJSON': function() {

var data = bookshelf.Model.prototype.toJSON.call(this);

data.middle_name = 'Danger';

return data;

}

});

在这个例子的被覆盖的toJSON()方法中,我们首先调用原型的toJSON()方法,给我们这个方法最初返回的数据,如果它没有被覆盖的话。然后,我们去掉想要隐藏的数据,添加一些我们自己的附加信息,并返回它。

这种模式经常出现的一个常见场景是使用一个User模型,其中保存了敏感的密码信息。修改模型的toJSON()方法来自动去除这些信息,如清单 12-34 所示,有助于防止这些信息在 API 请求中无意泄露。

定义类属性

Bookshelf 的extend()方法,我们在前面的例子中已经看到,接受两个参数:

  • 由创建的模型实例继承的实例属性的对象
  • 要直接分配给模型的类属性的对象

本章前面的例子已经演示了通过extend()分配实例属性的过程,但是我们还没有看到演示类属性使用的例子。清单 12-35 展示了活动中的类属性。

Listing 12-35. Defining the getRecent() Class Method on the User Model

// example-bookshelf1/lib/user.js

var User = bookshelf.Model.extend({

'tableName': 'users'

}, {

/**

* Returns a collection containing users who have signed in

* within the last 24 hours.

*/

'getRecent': function() {

return User.where('last_signin', '>=', knex.raw("date('now', '-1 day')")).fetch();

}

});

// example-bookshelf1/static.js

User.getRecent().then(function(users) {

console.log('%s users have signed in within the past 24 hours.', users.length);

console.log(JSON.stringify(users.toJSON(), null, 4));

});

类级属性提供了一个方便的位置,在这里我们可以定义与所讨论的模型相关的各种帮助方法。在这个虚构的示例中,getRecent()方法返回一个承诺,该承诺解析为一个集合,其中包含在过去 24 小时内登录的所有用户。

用子类扩展

Bookshelf 的extend()方法正确设置了原型链。因此,除了创建直接从 Bookshelf 的Model类继承的模型,开发人员还可以创建相互继承的模型,如清单 12-36 所示。

Listing 12-36. Creating a Base Model That Extends Directly from Bookshelf’s Model Class, from Which Other Models Can Also Extend

// example-bookshelf-extend/lib/base.js

/**

* This model serves as a base from which all other models

* within our application extend.

*

* @class Base

*/

var Base = bookshelf.Model.extend({

'initialize': function() {

this._initEventBroadcasts();

},

'foo': function() {

console.log('bar', this.toJSON());

}

});

// example-bookshelf-extend/lib/user.js

/**

* @class User

*/

var User = Base.extend({

'tableName': 'users'

});

// example-bookshelf-extend/index.js

var User = require('./lib/user');

User.where({

'id': 1

}).fetch().then(function(user) {

user.foo();

});

能够创建扩展到多个继承级别的模型提供了一些有用的机会。我们使用 Bookshelf 的大多数应用都遵循清单 12-36 中所示的方法,其中创建了一个基础模型,应用中的所有其他模型都是从这个基础模型扩展而来的。通过遵循这种模式,我们可以简单地通过修改我们的基类,轻松地将核心功能添加到应用中的所有模型。在清单 12-36 中,用户模型(以及其他从基础模型扩展而来的模型)将继承基础模型的 foo()方法。

关系

诸如 Bookshelf 之类的 ORM 库提供了方便的、面向对象的模式,用于与存储在平面关系数据库表中的数据进行交互。在 Bookshelf 的帮助下,我们可以指定应用模型之间存在的关系。例如,一个帐户可能有许多用户,或者一个用户可能有许多书签。一旦定义了这些关系,Bookshelf 模型就开辟了新的方法,使我们能够更容易地浏览这些关系。

表 12-1 中所示的表格列出了一些更常用的关系。

表 12-1。

Commonly Used Bookshelf Relationships

| 联合 | 关系类型 | 例子 | | --- | --- | --- | | 一对一 | `hasOne` | 用户有一个简档 | | 一对一 | `belongsTo` | 简档有一个用户 | | 一对多 | `hasMany` | 一个帐户有许多用户 | | 一对多 | `belongsTo` | 一个用户属于一个帐户 | | 多对多 | `belongsToMany` | 一本书有一个或多个作者 |

在下面几节中,您将发现这些关系之间的差异,它们是如何定义的,以及它们如何在应用中得到最好的应用。

一对一

一对一的关联是可用的最简单的形式。顾名思义,一对一关联指定一个给定的模型只与另一个模型相关联。基于关联被遍历的方向,该关联可以采取hasOne关系或belongsTo关系的形式。

我们将很快看到的示例背后的数据库模式如图 12-3 所示。在这个例子中,profiles表有一个user_id外键列,它通过这个外键列与users表相关联。

A978-1-4842-0662-1_12_Fig3_HTML.gif

图 12-3。

The database schema behind our one-to-one relationships

有一个并且属于

一个hasOne关系指定一个模型“拥有”另一个模型,而belongsTo关系则相反,它属于另一个模型。换句话说,belongsTo关系与hasOne关系正好相反。用 Bookshelf 定义这些关系的过程如清单 12-37 所示。

Listing 12-37. Defining the hasOne and belongsTo Bookshelf Relationships

// example-bookshelf-relationships1/lib/user.js

/**

* @class User

*

* A User has one Profile

*/

var User = bookshelf.Model.extend({

'tableName': 'users',

/**

* Bookshelf relationships are defined as model instance

* methods. Here, we create a 'profile' method that will

* allow us to access this user’s profile. This method

* could have been named anything, but in this case -

* 'profile' makes the most sense.

*/

'profile': function() {

return this.hasOne(Profile);

}

});

// example-bookshelf-relationships1/lib/profile.js

/**

* @class Profile

*

* A Profile belongs to one User

*/

var Profile = bookshelf.Model.extend({

'tableName': 'profiles',

'user': function() {

return this.belongsTo(User);

}

});

Bookshelf 关系是通过使用特殊的实例方法来定义的,如清单 12-37 所示。有了这些关系的定义,我们现在可以开始以几种方便的方式使用它们。对于初学者,请参见清单 12-38 ,它演示了在已经实例化的模型中加载关系的过程。运行这个例子的输出如清单 12-39 所示。

Listing 12-38. Loading a Relationship on a Model That Has Already Been Instantiated

// example-bookshelf-relationships1/index.js

User.where({

'id': 1

}).fetch().then(function(user) {

return user.load('profile');

}).then(function(user) {

console.log(JSON.stringify(user.toJSON(), null, 4));

});

Listing 12-39. The Resulting Output from Listing 12-38

{

"id": 1,

"first_name": "Steve",

"last_name": "Taylor",

"created_at": "2014-10-02

"profile": {

"id": 1,

"user_id": 1,

"twitter_handle":staylor”,

"city": "Portland",

"state": "OR",

"created_at": "2014-10-02"

}

}

在清单 12-38 中,检索了用户模型的一个实例。当获取时,Bookshelf 模型的默认行为是仅检索关于其自身的信息,而不是关于其相关模型的信息。因此,在本例中,我们必须首先通过 load()方法加载模型的相关概要文件,该方法返回一个承诺,一旦获取了相关的模型,该承诺就会被解析。之后,我们可以通过用户的相关实例方法引用这个用户的概要文件。

当我们开始考虑 Bookshelf 关系可以被“急切加载”的方式时,Bookshelf 关系变得更加有用,如清单 12-40 所示。在这个例子中,我们获取了User模型的一个实例以及它相关的Profile。我们可以通过向fetch()方法传递一个选项对象来做到这一点,在该对象中,我们指定一个或多个我们也感兴趣的关系。返回的承诺解析为已经填充了其profile关系的User的实例。

Listing 12-40. Using “Eager Loading” to Fetch Our User, and Its Related Profile, with a Single Call

// example-bookshelf-relationships1/eager.js

User.where({

'id': 1

}).fetch({

'withRelated': ['profile']

}).then(function(user) {

console.log(JSON.stringify(user.toJSON(), null, 4));

});

一对多

一对多关联构成了最常见的关系的基础。这种关联建立在我们刚刚看到的简单的一对一关联之上,允许我们将一个模型与许多其他模型相关联。这些关系可以采取hasManybelongsTo关系的形式,我们很快就会看到。

我们将要回顾的例子背后的数据库模式如图 12-4 所示。在这个例子中,users表有一个account_id外键列,它通过这个外键列与accounts表相关联。

A978-1-4842-0662-1_12_Fig4_HTML.gif

图 12-4。

The database schema behind our one-to-many relationships

hasMany 和 belongsTo

一个hasMany关系指定一个模型可能有多个(或者根本没有)特定的模型。我们在前面的例子中已经看到的belongsTo关系也适用于一对多的关联。用 Bookshelf 定义这些关系的过程如清单 12-41 所示。清单 12-42 展示了它们的用法。

Listing 12-41. Defining the hasMany and belongsTo Bookshelf Relationships

// example-bookshelf-relationships2/lib/account.js

/**

* @class Account

*

* An Account has one or more instances of User

*/

var Account = bookshelf.Model.extend({

'tableName': 'accounts',

'users': function() {

return this.hasMany(User);

}

});

// example-bookshelf-relationships2/lib/user.js

/**

* @class User

*

* A User belongs to an Account

* A User has one Profile

*/

User = bookshelf.Model.extend({

'tableName': 'users',

'account': function() {

return this.belongsTo(Account);

},

'profile': function() {

return this.hasOne(Profile);

}

});

// example-bookshelf-relationships2/lib/profile.js

/**

* @class Profile

*

* A Profile belongs to one User

*/

Profile = bookshelf.Model.extend({

'tableName': 'profiles',

'user': function() {

return this.belongsTo(User);

}

});

Listing 12-42. Loading an Instance of the Account Model, Along with All of Its Related Users

// example-bookshelf-relationships2/index.js

Account.where({

'id': 1

}).fetch({

'withRelated': ['users']

}).then(function(account) {

console.log(JSON.stringify(account.toJSON(), null, 4));

});

{

"id": 1,

"name": "Acme Company",

"created_at": "2014-10-02",

"users": [

{

"id": 1,

"account_id": 1,

"first_name": "Steve",

"last_name": "Taylor",

"email": "steve.taylor@mydomain.com",

"created_at": "2014-10-02"

},

{

"id": 2,

"account_id": 1,

"first_name": "Sally",

"last_name": "Smith",

"email": "sally.smith@mydomain.com",

"created_at": "2014-10-02"

}

]

}

在清单 12-42 中,我们看到了 Bookshelf 的另一个“急切加载”功能的例子,用它我们可以获取一个模型以及我们感兴趣的任何相关模型。当我们发现我们还可以加载嵌套关系时——那些存在于我们希望获取的对象内部更深处的关系——时,“急切加载”的概念变得更加有趣。只有当我们开始利用 Bookshelf 的急切加载功能时,我们才能开始欣赏它和类似的 ORM 工具提供的“虚拟对象数据库”。清单 12-43 中显示的例子应该有助于澄清这个概念。

Listing 12-43. Eagerly Loading an Account, All of Its Users, and the Profile for Each User

// example-bookshelf-relationships2/nested-eager.js

Account.where({

'id': 1

}).fetch({

'withRelated': ['users', 'users.profile']

}).then(function(account) {

console.log(JSON.stringify(account.toJSON(), null, 4));

});

/*

{

"id": 1,

"name": "Acme Company",

"created_at": "2014-10-02",

"users": [

{

"id": 1,

"account_id": 1,

"first_name": "John",

"last_name": "Doe",

"email": "john.doe@domain.site",

"created_at": "2014-10-02",

"profile": {

"id": 1,

"user_id": 1,

"twitter_handle": "john.doe",

"city": "Portland",

"state": "OR",

"created_at": "2014-10-02"

}

},

{

"id": 2,

"account_id": 1,

"first_name": "Sarah",

"last_name": "Smith",

"email": "sarah.smith@domain.site",

"created_at": "2014-10-02",

"profile": {

"id": 2,

"user_id": 2,

"twitter_handle": "sarah.smith",

"city": "Asheville",

"state": "NC",

"created_at": "2014-10-02"

}

}

]

}

*/

多对多

多对多关联不同于本章已经介绍的一对一和一对多关联,因为它们允许一个记录与一个或多个不同类型的记录相关联,反之亦然。为了帮助澄清这一点,见图 12-5 ,图中举例说明了一个经常被引用的涉及作者和书籍的例子。

A978-1-4842-0662-1_12_Fig5_HTML.gif

图 12-5。

A many-to-many association made possible through the use of a third join table. In this example, an author can write multiple books, and a book can have multiple authors

单个外键列,如前面的例子所示(见图 12-5 ),在这里是不够的。为了对这种关系建模,需要第三个连接表(authors_books ),其中可以存储任何给定记录的多个关系。

belongsToMany

图 12-5 所示的数据库模式可以通过belongsToMany关系用 Bookshelf 建模,如清单 12-44 所示。

Listing 12-44. Modeling a belongsToMany Relationship with Bookshelf

// example-bookshelf-relationships3/lib/author.js

var Author = bookshelf.Model.extend({

'tableName': 'authors',

'books': function() {

return this.belongsToMany(require('./book'));

}

});

// example-bookshelf-relationships3/lib/book.js

var Book = bookshelf.Model.extend({

'tableName': 'books',

'authors': function() {

return this.belongsToMany(require('./author'));

}

});

值得注意的是,当使用belongsToMany关系时,除非特别说明,否则 Bookshelf 会自动做出一些关于数据库模式的假设。Bookshelf 将假设如下:

  • 存在第三个连接表,其名称来自两个相关表的名称,用下划线分隔,并按字母顺序排序。本例:authors_books
  • 连接表中使用的列名来自两个相关表的单数形式,后跟_id。在这个例子中:author_idbook_id

如果您喜欢遵循不同的命名约定,您可以通过修改对this.belongsToMany的调用来实现,如清单 12-45 所示。

Listing 12-45. Modeling a belongsToMany Relationship with Bookshelf, While Providing Specific Table and Column Names

var Author = bookshelf.Model.extend({

'tableName': 'authors',

'books': function() {

return this.belongsToMany(

require('./book'), 'authors_books', 'author_id', 'book_id');

}

});

var Book = bookshelf.Model.extend({

'tableName': 'books',

'authors': function() {

var Author = require('../author');

return this.belongsToMany(Author, 'authors_books', 'book_id', 'author_id');

}

});

使用这种关系的过程如清单 12-46 所示。

Listing 12-46. Example Usage (and Resulting Output) of Code from Listing 12-45

// example-bookshelf-relationships3/index.js

Book.fetchAll({

'withRelated': ['authors']

}).then(function(books) {

console.log(JSON.stringify(books.toJSON(), null, 4));

});

/*

[

{

id: 1,

name: 'Pro JavaScript Frameworks for Modern Web Development',

authors: [{

id: 1,

first_name: 'Tim',

last_name: 'Ambler',

_pivot_book_id: 1,

_pivot_author_id: 1

}, {

id: 2,

first_name: 'Nicholas',

last_name: 'Cloud',

_pivot_book_id: 1,

_pivot_author_id: 2

}]

}

]

*/

摘要

如果您快速调查一下过去几年的数据库状况,很容易得出这样的印象,即所谓的“NoSQL”存储平台已经在很大程度上取代了 MySQL 和 PostgreSQL 等关系数据库的守旧派,但事实并非如此。就像马克·吐温在 1897 年过早报道的死亡一样,关系数据库的死亡也是一种夸张。

关系数据库提供了许多引人注目的特性,其中大多数远远超出了本章的范围。有许多精彩的书籍完全致力于这一主题,我们鼓励您在做出关于项目如何存储信息以及在哪里存储信息的关键决定之前阅读其中几本书。也就是说,在这种系统中要寻找的一个关键特性(本章前面已经介绍过)是对事务的支持:通过这个过程,多个查询可以被组合到一个工作单元中,这个工作单元要么整体成功,要么整体失败。我们在清单 12-13 和清单 12-14 中看到的涉及金融交易所的例子展示了这一概念在关键任务应用中的重要作用。

Knex 提供的平台无关的 API,结合其基于 promise 的接口、事务支持和迁移管理器,为开发人员提供了与关系数据库交互的便利工具。当与它的姐妹应用 Bookshelf(以前有过主干经验的人会立即熟悉的 ORM)配对时,形成了一个强大的组合,简化了处理复杂数据的过程。

相关资源

十三、Faye

网上报价的问题是你永远无法确定它们是否真实可信。—亚伯拉罕·林肯

近年来,基于 web 的应用变得越来越复杂,这在很大程度上要归功于现代 Web 开发技术的广泛采用,如 HTML5、WebSockets 和新标准化的 JavaScript APIs(如地理定位、Web 存储和 Web 音频)。曾经是传统桌面应用专属领域的功能已经在浏览器中找到了新家,允许 web 开发人员创建仅在短短几年前还不可能实现的应用。

然而,随着 web 浏览器的不断成熟,Web 的基本协议 HTTP 已经开始显示出老化的迹象。它实现的简单的“请求-响应”通信模式(其中客户端(例如,web 浏览器)从服务器请求资源并接收响应作为回报)不再解决当今许多最具创新性的 web 应用寻求解决的问题的实时性。处理快速变化的数据的应用(例如多人游戏、社交网站和聊天室)强烈需要执行通常称为“数据推送”的功能,即启动从服务器到客户端的通信。

正如网络浏览器已经成熟一样,用户的期望也越来越高。那些只在用户需要时才提供信息的 Web 应用会很快发现自己被更主动的替代方法边缘化,这些替代方法会在用户感兴趣的事件发生时通知用户。

在本章中,我们将探索 Faye,一个 Node.js 的库和一个浏览器,它为开发人员提供了一个健壮的工具集,用于构建依赖于接近实时通信的应用。涵盖的主题包括

  • HTTP、Bayeux 和 WebSockets
  • 通过 Faye 经由发布-订阅(PubSub)通道进行通信
  • 开发 Faye 扩展
  • 管理安全性

HTTP、Bayeux 和 WebSockets

HTTP 被称为“请求-响应”协议,因为它允许客户端从服务器请求资源并接收响应。该协议也被描述为“无状态的”,因为这些消息对中的每一个都独立于其他消息对运行;没有跨请求维护“状态”(即内存)。这个概念如图 13-1 所示。

A978-1-4842-0662-1_13_Fig1_HTML.gif

图 13-1。

HTTP is a stateless, request-response protocol

HTTP 最大的优势是它所支持的直观的交流模式,这使得网络发展到今天的巨大成功。不幸的是,协议的简单性也带来了很高的成本,因为它在解决事件驱动的消息传递的双向、异步本质方面远远不够。

举例来说,想象一个场景,其中多个用户正在参与一个聊天室(见图 13-2 )。当其他成员发布新消息时,每个用户都需要得到通知,但是 HTTP 的请求-响应特性未能解决如何将来自服务器级的此类事件传递给客户端,如图 13-3 所示。

A978-1-4842-0662-1_13_Fig3_HTML.gif

图 13-3。

HTTP does not allow a server to initiate communication with a client in this manner

A978-1-4842-0662-1_13_Fig2_HTML.gif

图 13-2。

An event-driven messaging platform, in which users must be notified as soon as new messages are posted by fellow participants

求转发到

与 HTTP 不同,WebSocket 协议的目的是允许浏览器与远程服务器建立全双工(双向)的长期 TCP 连接(见图 13-4 )。该协议于 2011 年标准化(RFC 6455 ),现在在大多数流行的 web 浏览器的最新版本中享有广泛的支持。因此,支持浏览器现在可以以真正异步的方式与服务器通信(假设那些服务器被配置为支持这种连接)。清单 13-1 中显示了一个演示 WebSocket API 用法的简单示例。

A978-1-4842-0662-1_13_Fig4_HTML.gif

图 13-4。

The WebSocket Protocol allows clients to establish a long-lived connection with a remote server, with which messages may be passed in both directions Listing 13-1. Simple Example Demonstrating the Use of the WebSocket API Within the Browser

var connection = new WebSocket('ws://domain.com/app');

// The connection has been established

connection.onopen = function() {

connection.send('Hello, world.');

};

// A message has been received from the server

connection.onmessage = function(e) {

console.log('Incoming message', e);

};

随着 WebSocket 协议的引入,Web 翻开了激动人心的新篇章,在这一篇章中,实时消息传递成为可能。然而,重要的是要记住,该协议确实有其局限性——这些局限性乍一看并不明显。这些限制将在关于 Bayeux 协议的下一节中更详细地讨论。

《贝叶议定书》

早在浏览器对 WebSockets 的广泛支持到来之前,Dojo Foundation 的开发人员就已经开始合作解决通过 HTTP 实现异步、事件驱动的消息传递的问题。其结果是一个被称为 Bayeux 协议的创新解决方案,至今仍有重要影响。

Bayeux 通过使用一种称为“长轮询”的技术,在标准 HTTP 请求上实现了低延迟、事件驱动的消息传递。使用这种方法,客户端向远程服务器提交一个 HTTP 请求,然后远程服务器无限期地挂起该请求,而不是立即返回一个响应(在标准 HTTP 事务中会发生这种情况)。当服务器保持这个打开的连接时,它等待被通知需要转发到客户端的任何消息。当消息到达时,服务器将消息转发给客户端并关闭连接。随后,客户端立即建立另一个到服务器的长轮询连接,此时该过程重复进行(见图 13-5 )。

A978-1-4842-0662-1_13_Fig5_HTML.gif

图 13-5。

Asynchronous messaging over HTTP, made possible by Bayeux’s use of long-polling

既然 WebSockets 得到了广泛的支持,你可能想知道为什么本章要讨论一个看似过时的概念,比如 Bayeux 协议。事实证明,贝叶并不像你最初认为的那样过时。尽管 WebSockets 很棒,但它们仍然有其局限性 Bayeux 恰好特别擅长解决这些局限性。

网络挑战

尽管大多数流行的 web 浏览器的最新版本现在都支持 WebSockets,但并不总是能保证客户端和服务器之间的成功连接。WebSocket 协议旨在支持的长期连接与 Web 最初建立时所基于的短期“请求-响应”连接有很大不同,为了使这些连接正常工作,必须正确配置在客户端和服务器之间转发信息的各种网络和代理服务器。没有听说过配置不当的代理服务器会完全阻止这种连接,或者在长时间不使用时毫无警告地丢弃它们。

Bayeux,这一章的主题 Faye 所依赖的协议,从一开始就被设计得非常超前。尽管 Bayeux 最初被设计为通过长轮询 HTTP 连接将异步消息传递引入 Web,但该协议还支持在可能的情况下将这些连接“升级”到更高效、更现代的标准(见图 13-6 )。这种行为的结果是,基于 Bayeux 的服务器能够将异步消息传递带到 Web 上,即使浏览器或网络限制试图从中作梗。

A978-1-4842-0662-1_13_Fig6_HTML.gif

图 13-6。

Bayeux connections are established via HTTP and upgraded when possible

传统浏览器支持

如前所述,WebSockets 现在在所有主流浏览器的最新版本中享有广泛的支持。根据应用的需要,您可能需要为不符合最新标准的浏览器提供支持。Bayeux 协议对标准 HTTP 连接的依赖(可能的话会升级)允许您这样做。

掉线和错过的消息

WebSocket 协议没有提供内置的支持来检测在服务中断的情况下可能丢失了什么(如果有的话)消息。另一方面,基于 Bayeux 的服务器能够在服务中断时保留消息。一旦客户端能够成功地重新建立连接,服务器就可以转发这些消息,从而防止重要消息在此过程中丢失。

关注渠道而非插座

WebSocket API 为开发人员提供了一个简单、定义明确的接口,用于在客户机和服务器之间建立套接字连接,以及在它们之间发送消息(如下一节所述)。但是,它没有为管理这些交互提供任何更高层次的抽象。Faye 采取了另一种方法,选择为用户提供一个通过发布-订阅(PubSub)通道进行交互的界面,同时完全隐藏网络层的内部工作方式。因此,Faye 能够透明地支持许多不同的连接方案,而无需在应用层进行任何更改。

Faye 入门

Faye 包含两个不同的库:一个用于服务器(Node.js ),一个用于浏览器。在我们继续之前,让我们看看在服务器级安装和配置 Faye 的基本步骤。

以下示例演示了如何在现有 Node.js 项目中通过 npm 从命令行安装 Faye:

$ npm install faye --save

Faye 与 Node 内置的httphttps模块协同工作,创建一个能够接受连接的端口。清单 13-2 展示了这一过程。在这个例子中,创建了一个 web 服务器,为我们项目的public文件夹中的静态文件提供服务。然后创建一个新的 Faye 实例并附加到这个服务器上,允许客户端在同一个端口上连接到 web 服务器和 Faye。

Listing 13-2. Initializing Faye Within Node.js

// faye-starter/lib/server.js

var faye = require('faye');

var http = require('http');

var express = require('express');

var app = express();

var server = http.createServer(app);

// Creating a new instance of Faye

var bayeux = new faye.NodeAdapter({

'mount': '/faye' // The path at which Faye can be reached

});

// Attaching Faye to the web server instance we have already created

bayeux.attach(server);

// Serving static files out of our project’s /public folder

app.use('/', express.static(__dirname + '/../public'));

// Our web server and Faye are both reachable at port 7000

server.listen(7000);

现在我们的服务器已经准备好接受连接,我们可以继续在浏览器中配置 Faye(见清单 13-3 )。我们首先创建一个脚本标签来加载 Faye 的客户端库(client.js),它位于我们在清单 13-2 中指定的挂载路径下。接下来,我们创建一个新客户机,并将其配置为连接到挂载路径。

Listing 13-3. Configuring Faye Within the Browser

// faye-starter/public/index.html

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Faye - Starter Example</title>

</head>

<body>

<script src="``http://localhost:7000/faye/client.js"></script

<script>

var client = new Faye.Client('``http://localhost:7000/faye

</script>

</body>

</html>

发布订阅消息

Faye 强调使用发布-订阅(PubSub)主题通道,而不是向开发人员提供直接与套接字连接交互的 API。要查看这个过程的运行情况,请重新访问清单 13-2 (我们配置服务器的例子)并添加清单 13-4 中所示的代码。通过添加这段代码,我们指示我们的服务器每两秒钟向/numbers通道发布一个随机数。

Listing 13-4. Publishing a Message to the /numbers Channel Every Two Seconds

// faye-starter/lib/server.js

setInterval(function() {

// Pass a topic channel, along with a payload of data

bayeux.getClient().publish('/numbers', {

'number': Math.floor((Math.random() * 100) + 1)

});

}, 2000);

现在重新看看清单 13-3 中显示的例子,其中我们配置了 Faye 以便在浏览器中使用。清单 13-5 显示了这个例子的更新版本,其中我们订阅了/numbers频道。当消息被接收时,它们的内容被附加到 DOM 中。还要注意,当我们创建这个订阅时,我们会收到一个带有cancel()方法的对象,允许我们随时取消订阅这个频道。在继续之前,花几分钟时间通过运行faye-starter文件夹中的npm start来看看这个例子。

Listing 13-5. Listening for Messages on the /numbers Channel from Within the Browser

// faye-starter/public/index.html

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>Faye - Starter Example</title>

</head>

<body>

<a href="#" id="cancelbt">Cancel Subscription to /numbers channel</a>

<ul id="container"></ul>

<script src="``http://localhost:7000/faye/client.js"></script

<script src="/bower_components/jquery/dist/jquery.js"></script>

<script>

var client = new Faye.Client('``http://localhost:7000/faye

var subscription = client.subscribe('/numbers', function(data) {

console.log('Incoming message on the /numbers channel', data);

$('#container').append('<li>' + data.number + '</li>');

});

$('#cancelbt').one('click', function() {

console.log('Canceling subscription to /numbers channel');

subscription.cancel();

});

</script>

</body>

</html>

在清单 13-4 中,我们看到了如何在服务器级别发布消息的演示。客户端可以发布自己的消息的过程几乎以完全相同的方式工作。清单 13-6 中显示的例子演示了从基于浏览器的客户端向/foo通道发送Hello, world消息。

Listing 13-6. Publishing a Message from the Browser

client.publish('/foo', {

'text': 'Hello, world.'

}).then(function() {

// Message was received by server

});

通配符频道

除了能够订阅特定通道上的消息,Faye 客户端还可以通过向subscribe()方法传递一个通配符模式来订阅多个通道,如清单 13-7 所示。

Listing 13-7. Using Wildcard Syntax to Subscribe to Multiple Channels Matching a Specified Pattern

/**

* Subscribes the client to a single channel segment. Messages received

* on any channel that exists directly beneath /foo will be logged.

*/

client.subscribe('/foo/*', function(message) {

console.log('Message received', message);

});

/**

* Subscribes the client to *all* channel segments beneath /foo.

*/

client.subscribe('/foo/**', function(message) {

console.log('Message received', message);

});

Faye 对通配符通道的支持提供了几种有趣的可能性,包括创建专用于特定用户(或用户组)的命名空间主题通道。例如,假设一个应用以类似于图 13-7 所示的方式组织其用户。

A978-1-4842-0662-1_13_Fig7_HTML.gif

图 13-7。

An application in which multiple users are grouped under a parent account

在这个应用中,每个用户都属于一个父帐户(可以有多个用户)。给定这种结构,很容易想象这样一种场景:我们可能希望向特定用户或特定帐户中的所有用户发布消息。借助通配符订阅和命名空间通道,我们可以轻松实现这个目标。

本章包含的faye-security项目(见图 13-8 )建立在几个之前讨论过的主题(AngularJS、Knex 和 Bookshelf)之上,创建一个允许注册用户登录并测试 Faye 功能的各个方面的应用——包括通配符订阅和命名空间通道的使用。

A978-1-4842-0662-1_13_Fig8_HTML.jpg

图 13-8。

After signing in, users are presented with a view that allows them to manage channel subscriptions and to publish messages

当提交该应用的登录表单时,会向服务器发出一个请求,要求验证用户提供的用户名和密码。如果该请求成功,服务器将返回一个包含授权用户信息的对象,包括一个将用于验证所有未来请求的令牌。清单 13-8 中显示了/login路线的角度控制器(负责提出该请求)的摘录。

Listing 13-8. A Token is Returned After a Successful Login Attempt

// faye-security/public/app.js (excerpt)

$scope.login = function() {

if (!$scope.loginForm.$valid) return;

$http.post('/api/auth', $scope.model)

.then(function(result) {

$.cookie('token', result.data.token, { 'path': '/' });

$location.path('/messages');

})

.catch(function(err) {

alert('Unable to sign in. Please try again.');

});

};

此外,/messages路由的控制器将自动为用户创建两个通配符订阅,如清单 13-9 所示。

Listing 13-9. Angular Controller for the /messages Route Creates Two Subscriptions Using Faye’s Wildcard Syntax

// faye-security/public/app.js (excerpt)

// Subscribe to channels pertaining to the user’s account

faye.client.subscribe('/accounts/' + me.account_id + '/**');

// Subscribe to channels pertaining directly to the user

faye.client.subscribe('/users/' + me.id + '/**');

这些订阅将允许我们将消息发布给该用户帐户中的每个人(以及直接发布给用户),方法是将这些消息定向到适当前缀的频道。清单 13-10 展示了这个过程的运行。

Listing 13-10. A Message is Published to Each Available Account Every Ten Seconds by the Server

// faye-security/lib/server.js (excerpt)

setInterval(function() {

db.models.Account.where({}).fetchAll().then(function(accounts) {

accounts.forEach(function(account) {

bayeux.getClient().publish(_.sprintf('/accounts/%s/random', account.id), {

'account': account.toJSON()

});

});

});

}, 10000);

用扩展实现安全性

默认情况下,Faye 对客户端允许与哪些通道进行交互没有任何限制。这可能会有问题,原因显而易见:如果没有额外的安全措施,没有什么可以阻止用户与其他用户的频道进行交互。幸运的是,在 Faye 对扩展的支持的帮助下,我们可以很容易地将这些安全措施落实到位。

Faye 提供了一个简单的 API 来创建扩展,这些扩展可以在消息在客户机和服务器之间移动时拦截和(可选地)修改消息。这种扩展既可以在服务器上创建,也可以在浏览器中创建。无论它们是在哪里创建的,过程都是相同的。清单 13-11 展示了一个简单的 Faye 扩展的例子。

Listing 13-11. Simple Faye Extension That Logs All Incoming and Outgoing Messages to the Console

client.addExtension({

'incoming': function(message, callback) {

console.log('Incoming message', message);

callback(message);

},

'outgoing': function(message, callback) {

console.log('Outgoing message', message);

callback(message);

}

});

现在我们已经看到了一个简单的 Faye 扩展是什么样子,让我们将这个概念应用到本章包含的示例 web 应用中。回头参考清单 13-9 并注意这样一个事实:在用户登录后,一个允许我们授权后续请求的令牌作为 cookie 存储在用户的浏览器中。现在参考清单 13-12 ,它展示了应用如何将这些信息集成到 Faye 扩展中。

Listing 13-12. A Faye Extension Is Created That Automatically Appends the User’s Token to All Outgoing Messages

// faye-security/public/app.js (excerpt)

client.addExtension({

'outgoing': function(message, callback) {

message.ext = message.ext || {};

message.ext.token = $.cookie('token');

callback(message);

}

});

清单 13-12 中显示的 Faye 扩展就绪后,所有传出的消息,包括订阅请求,都会自动附加上用户的令牌。我们可以通过在服务器上创建相应的扩展来利用这个事实,如清单 13-13 所示。

Listing 13-13. Server-side Faye Extension That Rejects Subscription Requests to Secure Channels when the Appropriate Credentials Are Not Provided

// faye-security/lib/faye-extensions/auth.js

var db = require('../db');

module.exports = function(bayeux) {

bayeux.addExtension({

'incoming': function(message, callback) {

if (message.channel !== '/meta/subscribe') return callback(message);

var token = message.ext && message.ext.token;

var segments = message.subscription.split('/');

switch (segments[1]) {

case 'accounts':

db.models.User.where({

'token': token,

'account_id': segments[2]

}).fetch({

'require': true

}).then(function(user) {

return callback(message);

}).catch(function(err) {

message.error = 'Permission denied.';

return callback(message);

});

break;

case 'users':

db.models.User.where({

'token': token,

'id': segments[2]

}).fetch({

'require': true

}).then(function(user) {

return callback(message);

}).catch(function() {

message.error = 'Permission denied.';

return callback(message);

});

break;

default:

return callback(message);

break

}

}

});

};

当服务器在/meta/subscribe通道上收到消息时,清单 13-13 中所示的扩展检查指定的通道是否属于我们的安全名称空间之一(/accounts/users)。如果是,扩展会检查令牌是否存在,并使用该信息从数据库中查找相应的用户。如果找到匹配,则允许消息继续发送。否则,由于将error属性分配给消息,订阅将被拒绝。

摘要

在本章中,您已经熟悉了许多概念,这些概念允许您在基于浏览器的应用中实现接近实时的通信。您成功地在服务器和浏览器上安装和配置了 Faye。您发现了 Faye 如何通过使用发布-订阅主题通道来促进客户机之间的消息传递。然后,通过使用命名空间通道和通配符订阅,您将这个概念向前推进了一步。您还了解了如何扩展 Faye,允许您监视、修改和(可选地)拒绝在客户机和服务器之间传递的消息。

根据我们的经验,在 Web 上实现异步的、事件驱动的通信的最佳方法包括一个利用 WebSockets 的计划,有适当的后备解决方案,并在上面提供一个方便的抽象层,它不会强迫您考虑网络层。Faye 通过将 WebSockets 的速度和效率与 Bayeux 协议的稳定性结合起来,很好地满足了这些需求,从而创建了一种坚如磐石的方法来实现 Web 上的异步、事件驱动的通信。

相关资源

十四、Q

我是一个有想法的人。努力工作不是我的强项。—Q,星际迷航:航海家

JavaScript 是一种异步语言。无论是在浏览器中还是在服务器上,开发人员都可以指示 JavaScript 运行时“调度”代码在未来某个时间点运行。该特性通常用于延迟 CPU 密集型或长时间运行的操作的开始,让应用有时间完成其当前任务,然后再继续执行更耗费人力的任务。这个特性如此强大,以至于传统的同步语言如 Java、C#、PHP 和 Ruby 都纷纷效仿并采用了它。一些语言,比如 C#,已经实现了异步执行模型作为一种语言特性(通过asyncawait关键字);其他语言,如 PHP,支持与 React 等外部库的异步性(不要与 FaceBoook 的 JavaScript 库 React 混淆)。无论哪种情况,异步代码和同步代码都必然会相遇。

q 是一个 JavaScript 库,它封装了接口背后的异步行为,读起来很像同步代码,这是本章的主题。q 产生承诺,可以链接在一起的特殊对象,以消除嵌套的回调,传播值和错误,并通常在异步代码中管理流控制。然而,在深入探讨 Q 之前,有必要走一小段弯路,研究一下为什么异步代码难以编写和管理。

时机就是一切

同步代码非常容易阅读,因为计算机一次执行一条语句。由同步代码(例如,通过方法调用)生成的返回值在返回后立即可供调用代码使用。具有结构化异常处理特性的语言提供了try/catch/finally块,可以用来预测和处理出现的错误,这样微不足道的(或可恢复的)错误就不会对应用造成致命影响。但是结构化异常处理只适用于同步代码;它的行为类似于一个goto语句,导致代码“跳转”到应用中的某个其他点,并在该点继续执行语句。

异步代码的行为稍有不同。在 JavaScript 中,异步代码被安排在未来的某个时间点运行(有时就在当前正在执行的代码之后)。这打破了同步模型,因为未来的代码将只在当前堆栈展开后运行。那么,在异步代码中创建的返回值和错误也必须在将来代码实际运行时进行处理。

许多语言(包括 JavaScript)都用回调来解决这个问题,回调是作为异步代码的参数传递的函数,一旦代码运行,就调用这些函数来处理错误和“返回值”Node.js 运行时严重依赖 JavaScript 的调度功能,它甚至为所有回调函数指定了标准签名,以便正确处理和传播异步错误。

不幸的是,嵌套的异步代码会很快变得复杂。考虑清单 14-1 中的例子。

Listing 14-1. Asynchronous Node.js Example

// example-001/index.js

'use strict';

var fs = require('fs');

var path = require('path');

var playerStats = require('./player-stats');

function getPlayerStats(gamesFilePath, playerID, cb) {

// fs.readFile() is asynchronous

fs.readFile(gamesFilePath, {encoding: 'utf8'}, function (err, content) {

if (err) {

return cb(err);

}

var games = JSON.parse(content);

var playerGames = games.filter(function (game) {

return game.player === playerID;

});

// playerStats.calcBest() is asynchronous

playerStats.calcBest(playerGames, function (err, bestStats) {

if (err) {

return cb(err);

}

// playerStats.calcAvg() is asynchronous

playerStats.calcAvg(playerGames, function (err, avgStats) {

if (err) {

return cb(err);

}

cb(null, {best: bestStats, avg: avgStats});

});

});

});

}

var gamesFilePath = path.join(__dirname, 'games.json');

getPlayerStats(gamesFilePath, 42, function (err, stats) {

if (err) {

console.error(err);

return process.exit(1);

}

console.log('best:', stats.best);

console.log('avg: ', stats.avg)

});

在本例中,JavaScript 代码被调度了四次:

The declaration and invocation of getPlayerStats()   The invocation of fs.readFile()   The invocation of playerStats.calcBest()   The invocation of playerStats.calcAvg()

很容易想象playerStats可能是一个对查询响应缓慢的外部服务。但是如果这段代码是同步的,如清单 14-2 所示,所有的事情都会被调度一次。每个函数和方法都将按顺序被调用,所有这些都被分组到一个try/catch块中以处理任何同步错误,并且统计数据将在收到时被写入控制台。

Listing 14-2. Synchronous Node.js Example

// example-002/index.js

'use strict';

var fs = require('fs');

var path = require('path');

var playerStats = require('./player-stats');

try {

var gamesFilePath = path.join(__dirname, 'games.json');

// fs.readFileSync() is synchronous

var content = fs.readFileSync(gamesFilePath, {encoding: 'utf8'});

var games = JSON.parse(content);

var playerGames = games.filter(function (game) {

return game.player === 42;

});

// playerStats.calcBestSync() is synchronous

console.log('best:', playerStats.calcBestSync(playerGames));

// playerStats.calcAvgSync() is synchronous

console.log('avg :', playerStats.calcAvgSync(playerGames));

} catch (e) {

console.error(e);

process.exit(1);

}

这个同步例子更容易理解,尽管每个语句在完成之前都会阻塞执行流程。异步回调驱动的代码虽然减轻了这个缺点,但仍然有许多严重的问题。

首先,没有回调签名必须遵守的真正规范的标准。Node.js 约定是被最广泛采用的,但是模块作者可以(并且确实)创建不遵循该标准的 API。当 JavaScript 模块包装或模拟现有的非 JavaScript API 时,通常会发生这种情况。为了熟悉 Node.js 约定,模块作者可能会决定模仿该 API 的回调签名模式。

第二,回调手动传播错误。每个回调都必须检查err对象,并决定如何处理它,或者将它转发给另一个执行相同操作的回调。结果往往是许多样板错误检查代码。在同步代码中,异常会自动在堆栈中向上传播,直到被一个catch块处理。

也很容易遗漏或不恰当地处理异步代码中出现的同步错误。在清单 14-3 中,try/catch块包装同步JSON.parse调用,然后在成功时传播解析的 JavaScript 对象,或者在解析失败时传播捕获的异常。

Listing 14-3. Improperly Invoking a Callback Within a try/catch Block

// example-003/improper-async-error-handling.js

'use strict';

var fs = require('fs');

var path = require('path');

function readJSONFile(filePath, cb) {

fs.readFile(filePath, function (err, buffer) {

try {

var json = JSON.parse(buffer.toString());

cb(null, json);

} catch (e) {

console.log('where did this error come from?', e.message);

cb(e);

}

});

}

var gamesFilePath = path.join(__dirname, 'games.json');

readJSONFile(gamesFilePath, function (err, json) {

if (err) {

return console.error('parsing json did not succeed :(');

}

console.log('parsing json was successful :)');

throw new Error('should never happen, right?');

});

假设games.json文件存在,并且有有效的 JSON 数据。在这个例子中,在数据被解析之后,回调将在try块中被调用。但是请注意当回调中抛出异常时会发生什么。这个异常会将堆栈展开回到try块中,并导致catch块捕获该异常,再次调用回调,并返回回调生成的完全相同的错误。这可能会产生意想不到的后果。如清单 14-4 所示,处理这个错误的适当方法是避免在try/catch块中调用回调。

Listing 14-4. Improperly Invoking a Callback Within a try/catch Block

// example-003/proper-async-error-handling.js

'use strict';

var fs = require('fs');

var path = require('path');

function readJSONFile(filePath, cb) {

fs.readFile(filePath, function (err, buffer) {

var json, err;

try {

json = JSON.parse(buffer.toString());

} catch (e) {

err = e;

}

if (err) {

console.log('where did this error come from?', e.message);

return cb(err);

}

cb(null, json);

});

}

var gamesFilePath = path.join(__dirname, 'games.json');

readJSONFile(gamesFilePath, function (err, json) {

if (err) {

return console.error('parsing json did not succeed :(');

}

console.log('parsing json was successful :)');

throw new Error('should never happen, right?');

});

最后,嵌套回调是一把双刃剑。一方面,每个回调都可以访问它自己的闭包和包围它的闭包中的数据;另一方面,嵌套很快导致复杂和紧密耦合的代码。程序的真正流程可能会变得模糊不清,产生一个不可维护的生态系统,容易滋生 bug。

承诺与回访

为了减轻异步回调带来的挑战,JavaScript 社区的成员起草了许多建议和规范,最终形成了 Promises/A+规范。该规范以及其他相关规范定义了一种方法,将异步操作封装在一个称为“promise”的特殊对象中,该对象可以与其他承诺相结合,创建一种异步链,通过该链可以传播值和错误,并在必要时进行处理。

根据 Promises/A+规范的定义,承诺用三种状态表示其异步操作:挂起、完成和拒绝。如果我们从回调的角度考虑这个问题,它将对应于清单 14-5 。

Listing 14-5. Callback Equivalents to Promise States

// invoking the function means the operation is "pending"

asyncFunction(function asyncCallback (err, asyncData) {

if (err) {

// if an error occurred the operation is "rejected"

}

// otherwise the operation is "fulfilled"

});

promise 也称为“thenable ”,因为它有一个接受两个可选回调的then()方法:第一个在 promise 的异步操作完成时调用,第二个在操作被拒绝时调用。完整签名显示在清单 14-6 中。

Listing 14-6. Thenable Method Signature

/**

* @param {Function} [onFulfilled]

* @param {Function} [onRejected]

* @returns {Promise}

*/

promise.then(onFulfilled, onRejected)

但是等等!承诺的全部意义不就是消除回调吗?不,承诺的要点是简化将异步操作链接在一起的过程,这个过程通常会将开发人员引向嵌套回调的道路。注意,promise 的then()方法实际上也返回了一个承诺。这个承诺也将根据原始承诺的回调中发生的情况要么被履行,要么被拒绝。通过利用这个特性,可以重写清单 14-7 中的玩家统计代码,以几乎消除嵌套。

Listing 14-7. Promises Reduce Nesting

// example-004/index.js

'use strict';

var fs = require('fs');

var path = require('path');

var playerStats = require('./player-stats');

var Q = require('q');

function getPlayerStats(gamesFilePath, playerID, cb) {

// load() returns a promise

playerStats.load(gamesFilePath, playerID)

.then(function (games) {

// Q.all() returns a promise

return Q.all([

// calcBest() returns a promise

playerStats.calcBest(games),

// calcAvg() returns a promise

playerStats.calcAvg(games)

]);

})

.done(function (allStats) {

cb(null, {best: allStats[0], avg: allStats[1]});

}, function (err) {

cb(err);

});

}

var gamesFilePath = path.join(__dirname, 'games.json');

getPlayerStats(gamesFilePath, 42, function (err, stats) {

if (err) {

console.error(err);

return process.exit(1);

}

console.log('best:', stats.best);

console.log('avg: ', stats.avg)

});

在清单 14-7 中,只有最后的done()调用接收解析和拒绝回调;所有其他调用只接收一个解析回调(来自playerStats模块的函数)。被连续调用的变量称为承诺链。如果这些中间的then()调用之一产生了错误,会发生什么呢?与异步回调模型不同,promises 将通过 promise 链自动传播错误,直到错误被处理(类似于结构化异常处理)。有一些特定的规则和用例可以改变这种行为,但一般来说,它完全按照人们的预期工作。

这个例子的其他有趣的方面将在后面解释(比如实现和拒绝回调的返回值如何影响承诺链)。然而,很明显,承诺可以减少回调嵌套和自动化错误传播,这是异步 JavaScript 代码中出现的两个主要问题。

阿 q 的承诺

q 是一个开源的 JavaScript 库,它实现了 Promises/A+规范,但它并不是开发者唯一可用的库。其他几个库,如 when.js 和 Bluebird,也提供了这些名称,这是一个值得注意的事实,因为该规范声明的目标是提供“一个可互操作的基础,所有 Promises/A+符合 promise 的实现都可以依赖这个基础来提供。”这意味着任何符合规范的 promise 库都可以与任何其他符合规范的库一起使用。开发者不必被迫在一系列竞争对手中做出选择。大多数有前途的库都提供辅助功能来补充核心的可移植接口。开发者可以根据需要自由选择和混合解决不同问题的 promise 库。(不幸的是,不符合 Promises/A+规范的库,比如 jQuery。延期,不会这样整合。)

q 是本章的主题,有几个强有力的理由:

  • 它符合 Promises/A+规格。
  • 它是由 Kris Kowal 写的,他是规范的一个贡献者。
  • 它在 JavaScript 社区(客户端和服务器端)享有广泛的采用和支持。
  • 谷歌支持的流行浏览器框架 AngularJS 大量借鉴了 q。

本章的其余部分将根据异步回调驱动代码上的承诺来研究 Q 的实现。

延期和承诺

虽然 Promises/A+规范定义了可命名对象的行为方式,但它并没有明确说明异步操作应该如何触发提供给可命名对象的回调。它只定义了表示承诺中异步操作状态的规则,以及值和错误如何通过承诺链传播。实际上,许多 promise 库使用一个名为 deferred 的对象来操纵 promise 的状态。延迟通常是首先创建的,连接起来处理异步操作的解析,然后生成一个承诺供以后调用代码使用。清单 14-8 展示了如何创建一个延期并返回它的承诺。

Listing 14-8. Creating a Deferred

var Q = require('q');

function asyncFunc() {

// create a deferred

var d = Q.defer();

// perform some async operation and

// manipulate the *deferred*

// return the deferred’s promise

return d.promise;

}

// the function returns a thenable!

asyncFunc().then(function () {

// success :)

}, function () {

// error :(

});

在清单中,14-8 Q.defer()被调用来创建一个延迟对象,当异步代码实际运行时,这个延迟对象将被用来在将来操纵承诺的状态(稍后会详细介绍)。这里重要的是被延迟的拥有一个承诺——一个从asyncFunc()返回的承诺,通过调用它的then()方法可以将回调附加到该承诺上。对asyncFunc()的实际调用和对返回承诺的状态更改的订阅都被安排在一起。然而asyncFunc()选择解决还是拒绝它的延期(从而改变承诺返回的状态)完全取决于开发者。

清单 14-9 是前面提到的虚构的playerStats模块的calcAvg()函数的简单实现。使用归约运算对一系列数字求和,然后除以序列长度(得出平均值)是一种同步运算。为了使其异步,代码被包装在 Node.js 函数process.nextTick()中,该函数调度代码在事件循环的下一次迭代中运行。(用setTimeout()setImmediate()也可以完成同样的操作。)如果计算成功,则使用d.resolve()将承诺置于 resolved 状态,该状态接受一些要传递给任何附加到承诺的解析回调的值。同样,如果出现错误(例如,games数组的长度为零,产生一个被零除的错误),promise 通过d.reject()被置于拒绝状态。

Listing 14-9. Using a Deferred in the calcAvg() Implementation

// example-004/player-stats.js

var Q = require('q');

module.exports = {

// load: function (gamesFilePath, playerID) {...}

// calcBest: function (games) {...},

calcAvg: function (games) {

var stats = {

totalRounds: 0,

avgRoundsWon: 0,

avgRoundsLost: 0

};

var deferred = Q.defer();

process.nextTick(function () {

if (games.length === 0) {

deferred.reject(new Error('no games'));

return;

}

var wins = 0, losses = 0;

games.forEach(function (game) {

if (game.rounds === 0) return;

stats.totalRounds += game.rounds;

wins += game.won;

losses += game.lost;

});

stats.avgRoundsWon = (wins / stats.totalRounds * 100)

.toFixed(2) + '%';

stats.avgRoundsLost = (losses / stats.totalRounds * 100)

.toFixed(2) + '%';

deferred.resolve(stats);

});

return deferred.promise;

}

};

清单 14-10 展示了延迟和承诺如何被用来包装异步的回调驱动的 API。

Listing 14-10. Using a Deferred to Wrap an Asynchronous, Callback-Driven API

// example-005/callbackdb/database.js

'use strict';

module.exports = {

customer: {

// requires a callback

find: function (criteria, cb) {

cb(null, {

id: criteria.id,

name: 'Nicholas Cloud'

});

}

}

};

// example-005/callbackdb/find-customer-callback.js

var Q = require('q'),

db = require('./database');

function loadCustomer(customerID) {

var d = Q.defer();

// db.customer.find() is asynchronous

db.customer.find({id: customerID}, function (err, customer) {

if (err) {

return d.reject(err);

}

d.resolve(customer);

});

return d.promise;

}

loadCustomer(1001).then(function (customer) {

console.log('found', customer.id, customer.name);

}, function (err) {

console.error(err);

});

这种包装异步代码的模型非常普遍,事实上,Q 提供了许多方便的方法来减轻编写样板代码的负担。q 的延迟对象有一个makeNodeResolver()方法,当被调用时,创建一个伪回调,可以传递给任何标准的基于异步回调的函数。然而,当这个回调被调用时,它只是用适当的值或错误来改变 deferred 的状态,无论哪一个恰好被传递给回调。清单 14-11 展示了一个解析器如何取代手工编写的回调函数。

Listing 14-11. Making a Node Resolver Callback

// example-005/callbackdb/database.js

'use strict';

module.exports = {

customer: {

// requires a callback

find: function (criteria, cb) {

cb(null, {

id: criteria.id,

name: 'Nicholas Cloud'

});

}

}

};

// example-005/callbackdb/find-customer-makenoderesolver.js

var Q = require('q'),

db = require('./database');

function loadCustomer(customerID) {

var d = Q.defer();

// db.customer.find() is asynchronous

var deferredCallback = d.makeNodeResolver();

db.customer.find({id: customerID}, deferredCallback);

return d.promise;

}

loadCustomer(2001).then(function (customer) {

console.log('found', customer.id, customer.name);

}, function (err) {

console.error(err);

});

在这种情况下,调用loadCustomer()的客户端代码期望一个承诺,但是数据库 API 期望一个回调,所以makeNodeResolver()自然适合。如果反过来也是正确的——如果客户端代码期望将回调传递给loadCustomer()函数,但是数据库实际上返回了一个承诺——对数据库的承诺调用nodeify()方法将适当地调用回调。清单 14-12 中的承诺正是以这种方式实现的。

Listing 14-12. Passing a Traditional Asynchronous Callback to a Promise with nodeify()

// example-005/promisedb/database.js

'use strict';

var Q = require('q');

module.exports = {

customer: {

// returns a promise; does not use callbacks

find: function (criteria) {

return Q({

id: criteria.id,

name: 'Nicholas Cloud'

});

}

}

};

// example-005/promisedb/find-customer-nodeify.js

var Q = require('q'),

db = require('./database');

function loadCustomer(customerID, cb) {

// db.customer.find() returns a promise

db.customer.find({id: customerID})

.nodeify(cb);

/* equivalent to:

*

* db.customer.find({id: customerID}).then(function (customer) {

*   cb(null, customer);

* }, function (err) {

*   cb(err);

* });

*/

}

loadCustomer(3001, function (err, customer) {

if (err) {

return console.err(err);

}

console.log('found', customer.id, customer.name);

});

值和错误

用简单的值或错误来解决延迟问题通常可以满足大多数需求,但是 Promises/A+规范和 Q 的实现定义了许多 promise 解决规则,让开发人员可以进一步控制 promise 状态。

用承诺值解决延期

延迟的行为会根据传递给它们的resolve()方法的“值”而改变。如果该值是一个普通对象或原语,它将按原样传递给附属于延期者承诺的解析回调。如果“值”是另一个承诺,如清单 14-13 所示,第二个承诺的状态将被“转发”给第一个承诺的适当回调:如果第二个承诺被解析,第一个承诺的解析回调将接收它的值;如果它被拒绝,第一个的拒绝回调将收到它的错误。

Listing 14-13. Resolving a Deferred with a Promise

// example-006/index.js

'use strict';

var Q = require('q'),

airport = require('./airport'),

reservation = require('./reservation');

function findAvailableSeats(departingFlights) {

var d = Q.defer();

process.nextTick(function () {

var availableSeats = [];

departingFlights.forEach(function (flight) {

var openFlightSeats = reservation.findOpenSeats(flight);

availableSeats = availableSeats.concat(openFlightSeats);

});

// resolve the deferred with an object value

if (availableSeats.length) {

d.resolve(availableSeats);

} else {

d.reject(new Error('sorry, no seats available'));

}

});

return d.promise;

}

function lookupFlights(fromAirport, toAirport, departingAt) {

var d = Q.defer();

process.nextTick(function () {

var departingFlights = airport.findFlights(

fromAirport, toAirport, departingAt

);

// resolve the deferred with another promise

d.resolve(findAvailableSeats(departingFlights));

});

return d.promise;

}

lookupFlights('STL', 'DFW', '2015-01-10').then(function (seats) {

console.log('available seats:', seats);

}, function (err) {

console.error('sorry:', err);

});

因为第一个延迟最终取决于第二个承诺的解决或拒绝,所以只要第二个承诺也是未决的,它就将保持未决状态。一旦第二个承诺被解决或拒绝,被推迟的承诺也会跟着做,调用适当的回调。

在回调中转发值、错误和承诺

一旦解析或拒绝回调收到一个值或错误,就会发生几件事。如果已经到达承诺链的末尾(或者如果没有其他链接的承诺),通常客户端代码会对该值做一些事情或者记录错误。

然而,因为当调用then()时,变量总是返回另一个承诺,所以可以使用解析和拒绝回调来操作值和错误,然后将它们转发给新的承诺,由后面的回调来处理。

操作一个值非常简单。只需改变或转换传递给解析回调的值并返回它。在清单 14-14 中,一个数组在数据库承诺的解析回调中被操作,然后在值被适当过滤后被返回。

Listing 14-14. Returning a Value in a Resolution Callback

// example-007/index.js

'use strict';

var db = require('./database');

function findPalindromeNames() {

// db.customers.find() returns a promise

return db.customer.find().then(function (customers) {

// return a filtered array that will be forwarded

// to the next resolution callback

return customers.filter(function (customer) {

// filter customers with palindrome names

var name = customer.name.toLowerCase();

var eman = name.split('').reverse().join('');

return name === eman;

}).map(function (customer) {

// return only customer names

return customer.name;

});

});

}

findPalindromeNames().then(function (names) {

console.log(names);

});

解析回调也可以将错误转发到承诺链的下游。如果是,那么将调用下一个拒绝回调,并返回错误。在清单 14-15 中,如果用户提交了太多的猜测(对于一些虚构的竞赛),就会创建一个错误,并在一个名称的解析回调中抛出。该错误将传播到承诺链中的下一个拒绝回调。

Listing 14-15. Throwing an Error in a Resolution Callback

// example-008/index.js

'use strict';

var db = require('./database');

var MaxGuessError = require('./max-guess-error');

var MAX_GUESSES = 5;

function submitGuess(userID, guess) {

// db.user.find() returns a promise

return db.user.find({id: userID}).then(function (user) {

if (user.guesses.length === MAX_GUESSES) {

throw new MaxGuessError(MAX_GUESSES);

}

// otherwise update the user...

});

}

submitGuess(1001, 'Professor Plum').then(function () {

// won’t get called if there is an error

console.log('guess submitted');

}, function (maxGuessError) {

// oops, an error occurred!

console.error('invalid guess');

console.error(maxGuessError.toString());

});

回想一下,在传统的异步回调模型中,抛出的异常必须手动处理和解释(这意味着不可预测的异常通常会逃过审查)。q 自动处理这个;在可调用回调中抛出的任何异常都将被捕获并适当地传播,即使所有可调用回调都是异步执行的。

拒绝回电遵循类似的规则,但有一个心理扭曲。他们不接受价值观;相反,它们会收到错误,所以开发人员可能会合理地期望从拒绝回调返回错误会触发承诺链下游的下一个拒绝回调。但这是不正确的。在清单 14-16 中,链中的最后一个承诺将被解决,而不是被拒绝,即使从submitGuess()中的拒绝回调返回一个错误。

Listing 14-16. Returning an Error in a Rejection Callback

// example-009/index.js

'use strict';

var db = require('./database');

var NotFoundError = require('./not-found-error');

function submitGuess(userID, guess) {

// db.user.find() returns a promise

return db.user.find({id: userID}).then(function (user) {

/*

* database generates an error so this promise

* won’t be resolved

*/

}, function (err) {

var notFoundError = new NotFoundError(userID);

notFoundError.innerError = err;

return notFoundError;

});

}

submitGuess(1001, 'Colonel Mustard').then(function (value) {

/*

* oops, this promise was resolved, and

* value === notFoundError!

*/

console.log('guess submitted');

console.log(value);

}, function (notFoundError) {

/*

* you expect this promise to get rejected...

* but you are wrong

*/

console.error('an error occurred');

console.error(notFoundError);

});

这似乎违反直觉。如果从拒绝回调中返回一个错误,人们可能会合理地认为它会传播,但事实并非如此。然而,再一看,这开始有意义了,因为它允许开发人员处理不需要传播的错误,并且仍然通过返回一些值来优雅地解决承诺链。

如果清单 14-16 中的代码被修改为当数据库变得不可用时对猜测进行排队,那么即使产生了错误,解析承诺链也是有意义的,如清单 14-17 所示。

Listing 14-17. Muffling an Error in a Rejection Callback

// example-010/index.js

'use strict';

var db = require('./database');

var guessQueue = require('./guess-queue');

function submitGuess(userID, guess) {

// db.user.find() returns a promise

return db.user.find({id: userID}).then(function (user) {

/*

* database generates an error so this promise

* won’t be resolved

*/

}, function (err) {

console.error(err);

/*

* database is probably offline, queue for future

* processing

*/

return guessQueue.enqueue(userID, guess);

});

}

submitGuess(1001, 'Miss Scarlett').then(function (value) {

/*

* guess is queued when the database connection

* fails, so the error is suppressed

*/

console.log('guess submitted');

}, function (notFoundError) {

console.error('an error occurred');

console.error(notFoundError);

});

与解析回调一样,为了正确地将下一个承诺的状态设置为 rejected,必须在拒绝回调中抛出错误,如清单 14-18 所示。

Listing 14-18. Throwing an Error in a Rejection Callback

// example-011/index.js

'use strict';

var db = require('./database');

var NotFoundError = require('./not-found-error');

function submitGuess(userID, guess) {

// db.user.find() returns a promise

return db.user.find({id: userID}).then(function (user) {

/*

* database generates an error so this promise

* won’t be resolved

*/

}, function (err) {

/*

* error is *thrown*, not returned

*/

var notFoundError = new NotFoundError(userID);

notFoundError.innerError = err;

throw notFoundError;

});

}

submitGuess(1001, 'Mrs. Peacock').then(function (value) {

/*

* since error was thrown within the promise

* the promise will not be resolved

*/

}, function (notFoundError) {

/*

* the promise is rejected, as expected!

*/

console.error('an error occurred');

console.error(notFoundError);

});

正如延迟可以用其他承诺来解决一样,启用回调也可以返回承诺,当这些承诺被解决或拒绝时,将会影响回调链的状态。解决和拒绝回调都可能返回承诺。在清单 14-19 中,如果数据库调用成功,则返回第二个承诺,否则抛出异常。

Listing 14-19. Returning Another Promise in a Resolution Callback

// example-012/index.js

'use strict';

var db = require('./database');

var MaxGuessError = require('./max-guess-error');

var MAX_GUESSES = 5;

function submitGuess(userID, guess) {

// db.user.find() returns a promise

return db.user.find({id: userID}).then(function (user) {

if (user.guesses.length === MAX_GUESSES) {

throw new MaxGuessError(MAX_GUESSES);

}

// otherwise update the user

user.guesses.push(guess);

return db.user.update(user);

});

}

submitGuess(1001, 'Professor Plum').then(function () {

/*

* should be called with the database has

* finished updating the user

*/

console.log('guess submitted');

}, function (maxGuessError) {

console.error('invalid guess');

console.error(maxGuessError.toString());

});

将简单的价值观转化为承诺

q 可以将任何值转化为承诺,只需调用Q作为函数,并将值作为唯一的参数。清单 14-20 包装了一个简单的字符串,该字符串将被用作传递给承诺链中下一个解析处理程序的值。

Listing 14-20. Turning a Value into a Promise

// example-013/index.js

'use strict';

var Q = require('q');

Q('khan!').then(function (value) {

console.log(value); //khan!

});

这可能看起来微不足道,但是这是将现有的、带有实际返回值的同步代码包装到基于 promise 的 API 中的一种便捷方式。您可以不带值地调用Q,这将创建一个处于已解决状态的空承诺。

用另一个库的承诺调用Q,也会将该承诺包装在 Q 的接口中。当开发人员在处理另一个没有对等物的 promise 库时,希望使用 Q 的 promise 方法如nodeify()时,这非常有用。

报告进度

有时异步操作需要很长时间才能完成。在此期间,向客户端代码提供一些进度指示可能会有所帮助,无论是作为简单的指示器(例如,完成百分比),还是在数据可用时交付它们(例如,由 EventEmitter 引发的事件)。q 通过向then()添加第三个回调参数来扩充 Promises/A+规范,如清单 14-21 所示,该参数可用于在进度事件发生时捕获它们。

Listing 14-21. Q’s Thenable Method Signature

/**

* @param {Function} [onFulfilled]

* @param {Function} [onRejected]

* @param {Function} [onProgress]

* @returns {Promise}

*/

promise.then(onFulfilled, onRejected, onProgress)

虽然 Promises/A+规范没有为进度通知建立模式,但是 Q 仍然符合,因为它的所有名称仍然支持规定的then()方法签名。

正如实现和拒绝回调在延迟的被实现或拒绝时被调用一样,进度回调在延迟的notify()方法被调用时也被调用。该方法接受单个参数,然后将该参数传递给进度回调。在清单 14-22 中,一个长时间运行的异步操作跟踪它做一些工作的尝试次数(也许调用一个经常没有响应的 API)。每次尝试时,计数器都会递增,其值会传递给notify()方法。进度回调立即接收这些数据。一旦解决了延迟,承诺链就完成了,最后的done()回调被调用。

Listing 14-22. Notifying the Deferred’s Promise

<!-- example-014/index.html -->

<form>

<p>The UI thread should respond to text field input, even though many DOM elements are being added.</p>

<input type="text" placeholder="type something here" />

</form>

<div id="output"></div>

<script>

(function () {

var Q = window.Q;

var output = document.querySelector('#output');

function writeOutput(msg) {

var pre = document.createElement('pre');

pre.innerHTML = msg;

output.insertBefore(pre, output.firstChild);

}

function longAsync() {

var d = Q.defer();

var attempts = 0;

var handle = setInterval(function () {

// each time the scheduled code runs,

// send a notification with the attempt

// number

attempts += 1;

d.notify(attempts);

if (attempts === 1200) {

clearInterval(handle);

return d.resolve();

}

}, 0);

return d.promise;

}

// not using the rejection callback, only the

// resolution and progress callbacks

longAsync().then(function () {

writeOutput('(done)');

}, null, function (attempts) {

writeOutput('notification: ' + attempts);

});

}());

</script>

值得注意的是,虽然任何附加到可调用对象的解析或拒绝回调都将根据承诺链中已经发生的情况来调用,但只有那些在通知事件之前附加的进度回调才会实际接收到更新。考虑清单 14-23 中的代码和清单 14-24 中产生的控制台输出。

Listing 14-23. Notifying a Deferred’s Promise Before Progress Callback Is Added

// example-015/index.js

'use strict';

var Q = require('q');

function brokenPromise() {

var d = Q.defer();

process.nextTick(function () {

console.log('scheduled first');

d.notify('notifying');

d.resolve('resolving');

console.log('logging');

});

return d.promise;

}

var promise = brokenPromise();

process.nextTick(function () {

console.log('scheduled second');

promise.then(function (value) {

console.log(value);

}, null, function (progress) {

console.log(progress);

});

});

在清单 14-23 中,一个延迟被创建,然后被异步通知和解析。只有在 deferred 的方法被调用之后,回调才会被附加到then()。清单 14-24 中的控制台输出反映了代码作为 Node.js 脚本运行时发生的情况。

Listing 14-24. Console Output Without Notification

$ node index.js

scheduled first

logging

scheduled second

resolving

logging语句显示在notifyingresolving之前,因为当函数brokenPromise()中的预定代码实际运行时,没有回调附加到延期者的承诺上。在调用了brokenPromise()之后,更多的代码被安排来将一个解析回调和一个进度回调附加到承诺上。当预定的代码运行时,进度回调被完全忽略,而解析回调接收它的值。为什么呢?因为进度回调是在调用 deferred 的notify()方法后调度的代码中添加的。根据 Promises/A+规范,当新的回调被添加到一个可调用对象中时,解决和拒绝肯定会被传播,但是 Q 将通知视为“实时”事件,只传播到通知时附加的回调。

一切都结束了

为了进一步模仿同步代码约定,Q 提供了catch()finally()方法,它们在同步的结构化异常处理中并行处理各自的对应方法。

catch()方法实际上是then(null, onRejection)的别名。和then()一样,catch()不会中断承诺链,但是它允许开发人员处理承诺链中任意点的错误。清单 14-25 中的代码使用catch()来拦截潜在的 HTTP 故障。因为catch()本身返回一个承诺,它的回调可以返回任何值(或者抛出另一个错误)以便在承诺链中稍后处理。

Listing 14-25. Catching Errors in a Promise Chain

// example-016/index.js

'use strict';

var Q = require('q');

var api = require('./api');

var InvalidTeamError = require('./invalid-team-error');

function loadTeamRoster(teamID) {

// api.get() returns a promise

return api.get('/team/' + teamID + '/roster')

.catch(function (err) {

/*

* throw a meaningful exception rather than

* propagate an HTTP error

*/

if (err.statusCode === 404) {

throw new InvalidTeamError(teamID);

}

});

}

loadTeamRoster(123).then(function (roster) {

console.log(roster);

}).catch(function (err) {

console.error(err.message);

});

finally()方法的行为类似于then(),但有一点需要注意:它可能不会改变它接收到的任何值或错误,尽管它可能会返回一个全新的承诺,并沿着承诺链进行传播。如果它没有返回任何东西,那么它收到的原始值或错误将被传递。

finally()方法的真正目的反映了try/catch块的finally部分的目的。它允许代码在执行线程继续执行之前清理资源。清单 14-26 展示了如何使用finally()块关闭数据库连接。不管连接或更新是否成功,finally()回调中的代码将一直运行,如果数据库句柄保持打开,就清理它。

Listing 14-26. Cleaning Up Resources in a Promise Chain

// example-017/index.js

'use strict';

var Q = require('q');

var db = require('./database');

var user = {

id: 1001,

name: 'Nicholas Cloud',

occupation: 'Code Monkey'

};

db.connect().then(function (conn) {

return conn.user.update(user)

.finally(function () {

if (conn.isOpen) {

conn.close();

}

});

});

当调用finally()时,它实际上并没有终止承诺链。但是在代码中可能会有这样的地方,当不再添加处理程序时,您希望在一系列异步操作中处理最终值或错误。这可以通过多种方式实现。终止一个链的最明显的方法是通过忽略由then()创建的最终承诺来简单地中止它。不幸的是,该承诺可能已经被承诺链中的上游代码拒绝。这意味着 Q 将保留 promise 链中产生的任何错误,如果将来添加了拒绝回调,这些错误将不会被处理。如果一个承诺链在没有拒绝回调的情况下被“终止”,如清单 14-27 所示,错误永远不会被报告——它消失在以太中——并且解决回调永远不会被执行。

Listing 14-27. Improperly Terminating a Promise Chain

// example-018/index01.js

'use strict';

var Q = require('q');

function crankyFunction() {

var d = Q.defer();

process.nextTick(function () {

d.reject(new Error('get off my lawn!'));

});

return d.promise;

}

// no rejection callback to display the error

crankyFunction().then(function (value) {

console.log('never resolved');

});

为了应对这一点,Q 创建的承诺也有一个done()方法,它不返回承诺,并在事件循环的未来回合中抛出任何未处理的错误,以通过其他方式解决。清单 14-28 展示了这种方法。

Listing 14-28. Terminating a Promise Chain with done()

// example-018/index02.js

crankyFunction().done(function (value) {

//...

});

即使没有提供拒绝回调,JavaScript 上下文仍然会终止,因为 Q 的done()方法会自动抛出一个未处理的错误。清单 14-29 中的控制台输出显示了如果crankyFunction()的承诺链被done()方法终止会发生什么。

Listing 14-29. Unhandled Error Thrown by done()

$ node index02.js

/.../node_modules/q/q.js:126

throw e;

^

Error: get off my lawn!

at /.../code/q/example-018/index02.js:7:14

at process._tickCallback (node.js:419:13)

at Function.Module.runMain (module.js:499:11)

at startup (node.js:119:16)

at node.js:906:3

带 Q 的流量控制

承诺链是扁平化异步的、基于回调的 API 的极好方法。它们还以自己的方式模拟开发人员在同步代码中熟悉的结构化异常处理模式。这些特性简化了基于承诺的代码中的流控制,但是可以利用一点创造性来为以下更复杂的流利用承诺,在这些流中,可以将许多异步操作“分组”并作为一个整体对待:

  • 顺序流:独立的异步操作被调度和执行,一次一个,每个操作在前一个操作完成后开始。
  • 并行流:独立、异步的操作同时被调度,并聚合所有结果。
  • 管道流:一次执行一个相关的异步操作,每个操作都依赖于前一个操作中创建的值。

在每种类型的流中,拒绝一个操作通常会触发流的失败。顺序流关注的是副作用,而不是值(这意味着它实际上并不获取或创建将在以后使用的数据),尽管它可以在必要时聚合获取的数据。并行流聚集来自许多不同异步操作的数据,并在所有操作完成时交付其结果。管道流通过一系列操作传递一些数据,因此至少有一个操作将获取或创建数据,并且在流的末尾将有一些值要处理。

顺序流程

清单 14-30 中的函数代表了 web 应用共有的一系列步骤。他们更改用户的密码。当然,每个步骤都是高度简化的,但是三个基本步骤必须按顺序完成:

Change the actual password.   Notify the user (probably via e-mail) that their password has been changed.   Because our company is a Good Corporate Citizen, it forwards the password on to the National Security Agency (NSA).   Listing 14-30. Functions Executed in a Sequential Flow

// example-019/index.js

function changePassword(cb) {

process.nextTick(function () {

console.log('changing password...');

cb(null);

});

}

function notifyUser(cb) {

process.nextTick(function () {

console.log('notifying user...');

var randomFail = Date.now() % 2 === 0;

cb(randomFail ? new Error('fail!') : null);

});

}

function sendToNSA(cb) {

process.nextTick(function () {

console.log('sending to NSA...');

cb(null);

});

}

每个操作的功能都是异步的,并且符合标准的 Node.js 回调模式。在清单 14-30 的例子中,changePassword()sendToNSA()函数总是成功,但有趣的是,根据计算的值,notifyUser()函数有时成功,有时失败。

为了在一个连续的承诺流中编排这三个操作,它们首先以适当的执行顺序被添加到一个“步骤”数组中。通过调用不带任何参数的Q创建一个“空”承诺(lastPromise);这将是连续承诺链中第一个已解决的承诺。

在清单 14-31 中,代码遍历步骤数组,将每个步骤封装在一个承诺中。对于每次迭代,它调用lastPromise上的then()方法,并将结果——一个新生成的承诺——赋回给lastPromise变量。(这在一个循环中建立了一个承诺链。)

在每个解析回调中,代码通过将当前的“步骤”(清单 14-30 中定义的函数之一)传递给Q.denodeify(),将其转换为承诺。同样可以通过设置一个延迟并使用deferred.makeNodeResolver()来手动完成,如清单 14-11 所示,但是Q.denodeify()简化了这个过程。结果是一个承诺,它可以作为承诺链中的下一步从解析回调中返回。

Listing 14-31. Orchestrating a Sequential Flow with Promises

// example-019/index.js

var Q = require('q');

var steps = [changePassword, notifyUser, sendToNSA];

var lastPromise = Q();

steps.forEach(function (step) {

lastPromise = lastPromise.then(function () {

/*

* denodeify and invoke each function step

* to return a promise

*/

return Q.denodeify(step)();

});

});

lastPromise.done(function () {

console.log('all done');

}, function (err) {

console.error(err);

});

最后,解决和拒绝回调被附加到循环创建的最后一个承诺上。

当下一个预定循环执行时,第一步将开始。当它解决时,将调用链中的下一个承诺,依此类推,直到到达顺序流的末尾。如果在链中的任何一点发生错误,它将立即导致调用最终拒绝回调。(没有中间拒绝回调;当任何一个步骤失败时,顺序流应该失败。)如果所有步骤都解决了,最终的解决回调将输出控制台消息:all done

平行流

应用通常会从各种来源获取数据,然后作为一个统一的整体发送给某个客户端。在清单 14-32 中,用户数据和美国各州的列表被同时获取,也许是为了一个网页,用户可以在这个网页上更改他或她的邮寄地址。

Listing 14-32. Functions Executed in a Parallel Flow

// example-20/index01.js

function getUser(id, cb) {

process.nextTick(function () {

cb(null, {id: id, name: 'nick'});

});

}

function getUSStates(cb) {

process.nextTick(function () {

cb(null, ['MO', 'IL' /*, etc.*/]);

});

}

因为这两个异步函数彼此无关,所以应该同时调度它们(而不是一个等待另一个完成),这是有意义的。Q 的效用函数all()接受一组要同时调度的承诺,但是由于清单 14-32 中的函数还不是承诺,它们必须用 Q 的一个函数调用方法进行转换。因为函数符合 Node.js 回调签名,清单 14-33 中的代码将每个函数传递给Q.nfcall() (node-function-call),后者将每个函数包装在承诺中,使用延迟来提供适当的回调。因为getUser()函数接受单个数据参数,所以在创建getUser()承诺时,用户 ID 必须作为第二个参数传递给Q.nfcall()。在内部调用时,q 会将用户 ID 作为第一个参数绑定到getUser()函数。

q 的all()函数本身返回一个承诺,这个承诺将用一个值数组来解析。数组中每个值的顺序位置将对应于传递给Q.all()的数组中承诺的顺序。在这种情况下,用户数据将占用索引 0,而美国州数组将占用索引 1。

如果任何承诺中出现错误,将调用聚合承诺的拒绝回调。

Listing 14-33. Orchestrating a Parallel Flow with Promises

// example-20/index01.js

var Q = require('q');

Q.all([

Q.nfcall(getUser, 123),

Q.nfcall(getUSStates)

]).then(function (results) {

console.log('user:', results[0]);

console.log('states:', results[1]);

}, function (err) {

console.error('ERR', err);

});

因为访问数组中的值是笨拙的,所以可以用spread()方法继续一个承诺,该方法的操作与then()相同,除了它将结果数组“分解”成实际的单个参数,如清单 14-34 所示。

Listing 14-34. Spreading Results

// example-20/index02.js

var Q = require('q');

Q.all([

Q.nfcall(getUser, 123),

Q.nfcall(getUSStates)

]).spread(function (user, states) {

console.log('user:', user);

console.log('states:', states);

}, function (err) {

console.error('ERR', err);

});

q 还提供了一个伴随函数Q.allSettled(),它的行为类似于Q.all(),但有一些关键的不同。首先,它将总是调用聚合承诺的解析回调。其次,每个值将是一个具有state属性的对象,该属性将报告创建该值的承诺的实际状态,以及依赖于state的值的下列属性之一:

  • value,如果承诺解决,它将包含由承诺创建的数据
  • reason,包含承诺被拒绝时产生的任何错误

选择使用Q.all()还是Q.allSettled()将取决于应用代码的性质,但是两者都可以用来创建并行流。

管道流动

当一组数据需要根据一些顺序规则集进行转换时,管道流非常有用。管道和前面介绍的顺序流之间的区别在于,管道中的每一步都将数据传递给下一步,而顺序流主要关注的是创建一系列线性副作用。

清单 14-35 中的管道函数代表了一个简化的过滤系统,也许是为一个寻找客户人才的招聘机构。loadCandidates()函数将“获取”一个可能的候选列表,其他函数将负责根据一些标准来减少选择。注意filterBySkill()groupByStates()实际上是工厂函数。它们接受一些配置参数(所需的技能和状态),然后返回一个函数,该函数接受要在管道中使用的 Node.js 回调。

Listing 14-35. Functions Executed in a Pipeline Flow

// example-021/index.js

function loadCandidates(cb) {

console.log('loadCandidates', arguments);

process.nextTick(function () {

var candidates = [

{name: 'Nick', skills: ['JavaScript', 'PHP'], state: 'MO'},

{name: 'Tim', skills: ['JavaScript', 'PHP'], state: 'TN'}

];

cb(null, candidates);

});

}

function filterBySkill(skill) {

return function (candidates, cb) {

console.log('filterBySkill', arguments);

candidates = candidates.filter(function (c) {

return c.skills.indexOf(skill) >= 0;

});

cb(null, candidates);

};

}

function groupByStates(states) {

var grouped = {};

states.forEach(function (state) {

grouped[state] = [];

});

return function (candidates, cb) {

console.log('groupByStates', arguments);

process.nextTick(function () {

candidates.forEach(function (c) {

if (grouped.hasOwnProperty(c.state)) {

grouped[c.state].push(c);

}

});

cb(null, grouped);

});

};

}

loadCandidates()函数被直接添加到steps数组中,但是filterBySkill()groupByStates()函数是用它们的初始值调用的。

像串行和并行流一样,管道流使用承诺链来协调执行顺序。然而,在清单 14-36 中,每个步骤创建的结果——传递给每个承诺的解析回调的值——被放入一个数组中,并作为参数传递给序列中的下一个承诺。在并行流程示例中,Q.nfcall()用于调用每个步骤;在这个例子中,使用了Q.nfapply()(节点-功能-应用)。每个调用都模仿其本地 JavaScript 对应物(Function.prototype.call()Function.prototype.apply()),这就是为什么使用数组将结果传递给每个步骤,而不是将结果作为直接参数传递。这是必要的,因为管道的第一步loadCandidates()不接受任何参数(除了回调)。向Q.nfapply()传递一个空数组可以确保函数被正确调用。

Listing 14-36. Orchestrating a Pipeline Flow with Promises

// example-021/index.js

var Q = require('q');

var steps = [

loadCandidates,

filterBySkill('JavaScript'),

groupByStates(['MO', 'IL'])

];

var lastPromise = Q();

steps.forEach(function (step) {

lastPromise = lastPromise.then(function (result) {

var args = [];

if (result !== undefined) {

args.push(result);

}

return Q.nfapply(step, args);

});

});

lastPromise.done(function (grouped) {

console.log('grouped:', grouped);

}, function (err) {

console.error(err);

});

当管道完成时,传递给最后一个异步回调的最后一个值将是传递给done()解析回调的值。如果任何异步操作产生错误,将调用拒绝回调。

对于清单 14-35 中的每个异步函数,每个回调都传递一个值。即使 Promises/A+规范规定只能将一个值作为解析参数传递,也可以将多个值传递给这些回调。q 通过将传递给异步函数回调的所有值打包成一个数组,然后传递给 promise 的 resolution 回调,缓解了这种差异。然后,这个数组需要被传递给Q.nfapply(),因为它包含了所有要用作下一个函数步骤的参数的数据。

摘要

回调是处理异步代码的标准机制。它们为开发人员提供了一种控制流机制,以便在事件循环的下一次循环之后“继续”执行。但是回调会很快变成嵌套的、复杂的、难以管理的。

使用像 Q 这样的 promise 库来封装异步操作,来“扁平化”代码,可以极大地改进代码库。q 能够自动传播值和错误,以异步方式链接回调,在长时间运行的异步操作中报告进度,并在承诺链结束时处理未处理的错误,这使它成为任何开发人员工具箱中的强大工具。

q 可以用来管理琐碎的、线性的程序流,但是稍加创新也可以适应更复杂的流。本章研究了顺序流、并行流和管道流,但是 Q 的实用方法在编排其他流时为开发人员提供了额外的灵活性。

相关资源

十五、Async.js

总是有新的东西,总是有我没想到的东西,有时并不可怕。—罗伯特·乔丹

协调软件流程可能很麻烦,尤其是当异步操作在不同时间完成时。第十六章展示了如何用承诺来解决这个问题。本章讨论 Async.js,这是一个回调驱动的 JavaScript 库,它提供了一套强大的函数来管理异步集合操作和控制流。

第十六章讲述了异步代码可能会出现问题的三种常见流程:顺序流程、并行流程和管道流程。为了用承诺来处理这些流,第十六章展示了如何用 Q 的助手方法来适应每个面向回调的任务,以便每个任务都可以方便地包装在承诺中。然而,Async.js 库包含了回调驱动的异步编程方法,但是这种方法避免了回调驱动的代码(如嵌套回调)带来的许多缺点。

许多 Async.js 控制流函数遵循类似的模式:

The first argument to each control flow function is typically an array of functions to be executed as tasks. Task function signatures will vary a bit based on the exact Async.js control flow function used, but they will always receive a Node.js-style callback as a last argument.   The last argument to each control flow function is a final callback function to be executed when all tasks are complete. The final control flow function also receives a Node.js-style callback and may or may not receive additional arguments as well.   Note

Node.js 样式的回调只是一个回调函数,它总是将错误作为它的第一个参数。当回调被调用时,要么将一个错误对象作为其唯一的参数传递,要么将null作为错误值传递,并将任何其他值作为附加参数传递。

清单 15-1 展示了这种模式通常是如何应用的。

Listing 15-1. Flow Control Function Pattern

var tasks = [

function (/*0..n args, */ cb) {/*...*/},

function (/*0..n args, */ cb) {/*...*/},

function (/*0..n args, */ cb) {/*...*/}

];

function finalCallback (err, result) {/*...*/};

async.someFlowControlFunction(tasks, finalCallback);

本章的其余部分将研究大量的控制流函数,以及它们与这个通用模式的不同之处。因为所有的流都以相似的方式组织任务和处理错误和值,所以通过对比可以更容易地理解每一个。

Note

Async.js 中 async 的含义与组织异步操作有关。库本身不保证任务函数异步执行。如果开发人员将 Async.js 与同步函数一起使用,每个函数都将同步执行。这条规则有一个半例外。async.memoize()函数(与控制流无关)使函数可缓存,因此后续调用不会实际运行该函数,而是返回缓存的结果。Async.js 强制每个后续调用都是异步的,因为它假设原始函数本身是异步的。

顺序流程

顺序流程是指一系列步骤必须按顺序执行的流程。一个步骤可能直到前一个步骤完成后才开始(除了第一个步骤),如果任何一个步骤失败,整个流程都会失败。清单 15-2 中的函数是更改一个虚构用户密码的步骤,与在第十六章中引入顺序流程的场景相同。然而,这些步骤略有不同。

首先,每一个都被包装在一个工厂函数中,该函数接受一些初始数据并返回一个基于回调的函数,以用作顺序流中的一个步骤。

第二,第一步(包装在changePassword()函数中的任务)实际上将新凭证作为操作结果传递给它的回调函数。顺序流中的步骤不需要生成结果,但是如果一个步骤确实将结果传递给了它的回调函数,那么它对序列中的其他步骤没有影响。如果某些(或所有)步骤依赖于前面步骤的结果,则需要管道流。(管道将在本章后面讨论。)

Listing 15-2. Sequential Steps

// example-001/async-series.js

'use strict';

var async = require('async');

var userService = require('./user-service');

var emailService = require('./email-service');

var nothingToSeeHere = require('./nothing-to-see-here');

function changePassword(email, password) {

return function (cb) {

process.nextTick(function () {

userService.changePassword(email, password, function (err, hash) {

// new credentials returned as results

cb(null,``{email: email, passwordHash: hash}

});

});

};

}

function notifyUser(email) {

return function (cb) {

process.nextTick(function () {

// the email service invokes the callback with

// no result

emailService.notifyPasswordChanged(email, cb);

});

};

}

function sendToNSA(email, password) {

return function (cb) {

process.nextTick(function () {

// the nothingToSeeHere service invokes the

// callback with no result

nothingToSeeHere.snoop(email, password, cb);

});

}

}

在清单 15-3 中,每个工厂函数都用它的初始数据执行,返回添加到一个steps数组中的任务函数。这个数组成为async.series()的第一个参数,后面是一个最终的回调函数,它接收序列执行过程中产生的任何错误,或者是序列中每一步产生的结果数组。如果生成了任何结果,它们将按照它们在steps数组中对应步骤的顺序存储。例如,changePassword()的结果将是results数组中的第一个元素,因为changePassword()作为第一个任务被调用。

Listing 15-3. Sequential Flow

// example-001/async-series.js

var email = 'user@domain.com';

var password = 'foo!1';

var steps = [

//returns function(cb)

changePassword(email, password),

//returns function(cb)

notifyUser(email),

//returns function(cb)

sendToNSA(email, password)

];

async.series(steps, function (err, results) {

if (err) {

return console.error(err);

}

console.log('new credentials:', results[0]);

});

因为这些步骤是异步的,所以不能像调用同步函数那样一次调用一个。但是 Async.js 在内部跟踪每个步骤的执行,只有在调用了前一个步骤的回调时才调用下一个步骤,因此创建了一个顺序流。如果顺序流中的任何步骤向其回调传递了一个错误,该系列将被中止,并且最后一个系列回调将因该错误而被调用。当出现错误时,results值将未定义。

本章中使用的工厂函数是向每个步骤传递初始数据的便捷方式,但它们不是必需的。为了支持 JavaScript 的本地函数绑定工具,工厂可以被删除,如清单 15-4 所示,但是当步骤被实际添加到数组中时,代码变得更加难以阅读。对于不需要初始数据或绑定的简单场景,匿名任务函数可以直接在steps数组中声明。(但是,以一种促进可读性和可维护性的方式命名和声明函数总是一个好主意。)

Listing 15-4. Series Steps with Argument Binding

function changePassword(email, password, cb) {/*...*/}

function notifyUser(email, cb) {/*...*/}

function sendToNSA(email, password, cb) {/*...*/}

var steps = [

changePassword.bind(null, email, password),

notifyUser.bind(null, email),

sendToNSA.bind(null, email, password)

];

在本章的剩余部分,我们将使用工厂函数而不是bind(),但是开发者可以自由选择他们觉得最自然的方法。

平行流

有时,并行运行独立的任务,然后在所有任务完成后汇总结果会很有帮助。JavaScript 是一种异步语言,因此它没有真正的并行性,但连续调度长时间的非阻塞操作将释放事件循环来处理其他操作(如浏览器环境中的 UI 更新,或服务器环境中的额外请求)。可以在事件循环的一个回合中调度多个异步任务,但是无法预测每个任务将在未来的哪个回合完成。这使得从每个任务中收集结果并将它们返回给调用代码变得困难。幸运的是,async.parallel()函数为开发人员提供了这样做的方法。

清单 15-5 展示了两个包装 jQuery GET 请求的函数。第一个获取给定userID的用户数据,第二个获取美国各州的列表。很容易想象,这些功能可能是用户个人资料页面的一部分,用户可以在该页面上更新电话号码、邮政地址等个人信息。当页面加载时,一次性获取这些信息是有意义的。不过,这是两个不同的 API 调用,所以即使它们被同时调度,结果也需要在将来的某个时间点进行处理。

Listing 15-5. Parallel Steps

// example-002/views/async-parallel.html

function getUser(userID) {

return function (cb) {

$.get('/user/' + userID).then(function (user) {

cb(null, user);

}).fail(cb);

};

}

function getUSStates(cb) {

$.get('/us-states').then(function (states) {

cb(null, states);

}).fail(cb);

}

在清单 15-6 中,Async.js 被导入到一个带有标准<script>标签的虚构网页中。使用async.parallel()函数调度任务,像async.series()一样,它接受一组要执行的任务函数和一个接收错误或聚合结果的最终回调函数。并行任务只是接受单个回调参数的函数,一旦任务函数中的异步操作完成,就应该调用这个回调参数。所有回调都符合 Node.js 回调约定。

清单 15-6 中的getUser()函数是一个工厂,它接受一个userID参数并返回一个接受常规 Node.js 风格回调的函数。因为getUSStates()没有实际参数,所以不需要包装在工厂函数中,而是直接使用。

这两个函数都使用 jQuery 的 AJAX API 获取数据。AJAX promises 将数据从成功的 AJAX 调用传递给传递给 promise 的then()方法的任何回调,而将错误传递给传递给 promise 的fail() method方法的任何回调。因为fail()回调的签名接受单个错误参数,所以从 Async.js 传递给每个任务的回调也可以用作对fail()的回调。

Listing 15-6. Parallel Flow

<!-- example-002/views/async-parallel.html -->

<h1>User Profile</h1>

<form>

<fieldset>

<div>

<label>First Name</label>

<input type="text" id="first-name" />

</div>

<div>

<label>US States</label>

<select id="us-states"></select>

</div>

</fieldset>

</form>

<script>

(function (async, $) {

function getUser(userID) {

return function (cb) {

$.get('/user/' + userID).then(function (user) {

cb(null, user);

}).fail(cb);

};

}

function getUSStates(cb) {

$.get('/us-states').then(function (states) {

cb(null, states);

}).fail(cb);

}

var userID = 1001;

async.parallel([

getUser(userID),

getUSStates

], function (err, results) {

if (err) {

return alert(err.message);

}

var user = results[0],

states = results[1];

$('#first-name').val(user.firstName);

// ...

$('#us-states').append(states.map(function (state) {

return $('<option></option>')

.html(state)

.attr('value', state);

}));

});

}(window.async, window.jQuery));

</script>

Async.js 库将遍历tasks数组中的每个任务,一个接一个地调度它们。当每个任务完成时,它的数据被存储,一旦所有任务完成,传递给async.parallel()的最后一个回调被调用。

结果按照传递给async.parallel()的任务的顺序排序,而不是按照任务实际解决的顺序。如果任何并行任务中出现错误,该错误将被传递给最终回调,所有未完成的并行任务一旦完成将被忽略,最终回调中的results参数将为undefined

管道流动

当一系列任务中的每个任务都依赖于前一个任务的值时,就需要一个管道流(或瀑布)。清单 15-7 表示一个虚构的公司奖励计划的任务,其中计算用户的年龄(基于出生日期),如果用户的年龄达到一定的阈值,用户将获得现金奖励。

每个函数接收一些输入,然后将一些输出传递给它的回调函数。每个函数的输出成为系列中下一个函数的输入。

The getUser() factory function accepts a userID and returns another function that, when invoked, looks up a user record. It passes the user record to its callback.   The calcAge() function accepts a user argument and invokes its callback with the calculated age of the user.   The reward() function accepts a numeric age argument and invokes its callback with the selected reward if the age meets certain thresholds.   Listing 15-7. Waterfall (Pipeline) Steps

// example-003/callback-waterfall

'use strict';

var db = require('./database');

function getUser(userID, cb) {

process.nextTick(function () {

// pass cb directly to find because

// it has the same signature:

// (err, user)

db.users.find({id: userID}, cb);

});

}

function calcAge(user, cb) {

process.nextTick(function () {

var now = Date.now(),

then = user.birthDate.getTime();

var age = (now - then) / (1000 * 60 * 60 * 24 * 365);

cb(null, Math.round(age));

});

}

function reward(age, cb) {

process.nextTick(function () {

switch (age) {

case 25: return cb(null, '$100');

case 35: return cb(null, '$150');

case 45: return cb(null, '$200');

default: return cb(null, '$0');

}

});

}

如果用嵌套的回调来组织,这个管道将会相当难看并且难以维护。如果奖励计划中增加了额外的步骤,就需要对代码进行梳理和重组,以适应流水线流程中的新步骤。捕获错误并通过回调传播它们也是手动进行的。清单 15-8 中的示例代码展示了在没有 Async.js 的情况下如何运行这些任务。

Listing 15-8. A Waterfall of Nested Callbacks

// example-003/callback-waterfall

function showReward(userID, cb) {

getUser(userID, function (err, user) {

if (err) {

return cb(err);

}

calcAge(user, function (err, age) {

if (err) {

return cb(err);

}

reward(age, cb);

});

})

}

showReward(123, function (err, reward) {

if (err) {

return console.error(err);

}

console.log(reward);

});

幸运的是,Async.js 使得组织一个既可维护又能优雅地处理错误的管道流变得相对容易。清单 15-9 中的代码使用async.waterfall()来组织要执行的一系列任务,然后提供一个最终回调来捕获管道任务引发的任何错误,或者在没有错误发生的情况下接收最终的reward值。

Listing 15-9. Waterfall (Pipeline) Flow

// example-003/async-waterfall.js

'use strict';

var async = require('async');

var db = require('./database');

function getUser(userID) {

// using a factory function to pass in

// the userID argument and return another

// function that will match the callback

// signature that async.waterfall expects

return function (cb) {

process.nextTick(function () {

// pass cb directly to find because

// it has the same signature:

// (err, user)

db.users.find({id: userID}, cb);

});

};

}

// the calcAge and reward functions

// do not change

async.waterfall([

getUser(1000),

calcAge,

reward

], function (err, reward) {

if (err) {

return console.error(err);

}

console.log('reward:', reward);

});

async.series()async.parallel()一样,在任何瀑布任务中传递给回调的错误将立即暂停管道并调用带有错误的最终回调。

重复使用管道

管道对于处理数据非常有帮助,以至于async.seq()会采用一系列函数,就像async.waterfall()一样,并将它们组合成一个单一的、可重用的管道函数,可以多次调用。当然,这可以通过使用闭包来包装async.waterfall()来手动完成,但是async.seq()是一个方便的函数,可以省去开发人员的麻烦。

清单 15-10 显示了一系列用于处理虚拟手机账单的函数。createBill()函数接受一个调用计划,并用该计划和正常月费率创建一个bill对象。carrierFee()在这个数额上追加一大块零钱,只是因为电话公司可以这么做。prorate()功能随后确定是否将一些金额记入用户的贷方(例如,如果用户在计费周期的中间开始了新的计划)。最后,govtExtortion()会在交付账单之前将计算好的税款附加到账单上。

Listing 15-10. Sequence (Pipeline) Steps

// example-004/async-seq.js

'use strict';

var async = require('async');

var dateUtil = require('./date-util');

function createBill(plan, cb) {

process.nextTick(function () {

var bill = {

plan: plan,

total: plan.billAmt

};

cb(null, bill);

});

}

function carrierFee(bill, cb) {

process.nextTick(function () {

bill.total += 10;

cb(null, bill);

});

}

function prorate(bill, cb) {

if (!bill.plan.isNew) {

return cb(null, bill);

}

process.nextTick(function () {

bill.plan.isNew = false;

var days = dateUtil().daysInMonth();

var amtPerDay = bill.plan.billAmt / days;

var prorateAmt = ((bill.plan.billDay - 1) * amtPerDay);

bill.total -= prorateAmt;

cb(null, bill);

});

}

function govtExtortion(bill, cb) {

process.nextTick(function () {

bill.total = bill.total * 1.08;

cb(null, bill);

});

}

async.seq()创建管道与使用async.waterfall()非常相似,如清单 15-11 所示。主要区别在于,async.seq()并不立即调用这些步骤,而是返回一个pipeline()函数,该函数将用于稍后运行任务。pipeline()函数接受将被传递到第一步的初始参数,消除了在定义管道时工厂函数或绑定值到第一步的需要。此外,与大多数其他async函数不同,async.seq()是可变的(接受可变数量的参数)。它不接受像async.waterfall()这样的任务数组,而是接受每个任务函数作为参数。

在清单 15-11 中,pipeline()函数被创建,然后用两个参数调用:一个plan对象,它将被传递给createBill(),以及一个最终回调,为用户接收一个错误或最终bill对象。

Listing 15-11. Sequence (Pipeline) Flow

// example-004/async-seq.js

var pipeline = async.seq(

createBill,

carrierFee,

prorate,

govtExtortion

);

var plan = {

type: 'Lots of Cell Minutes Plan!+',

isNew: true,

billDay: 15,

billAmt: 100

};

pipeline(plan, function (err, bill) {

if (err) {

return console.error(err);

}

//bill = govtExtortion(prorate(carrierFee(createBill(plan))))

console.log('$', bill.total.toFixed(2));

});

环路流量

重复直到满足某种条件的流称为循环。Async.js 有几个循环函数,帮助协调要执行的异步代码和要在其中测试的条件。

当某些条件保持为真时循环

前两个函数async.whilst()async.doWhilst(),类似于许多编程语言中众所周知的whiledo/while循环结构。当某个条件评估为真时,每个循环运行。一旦条件评估为假,循环停止。

async.whilst()async.doWhilst()功能几乎相同,除了async.whilst()在循环中的任何代码运行之前执行条件评估,而async.doWhilst()在执行条件评估之前执行循环的一次迭代。async.doWhilst()中的循环代码保证至少运行一次,而如果初始条件为假,则async.whilst()中的循环代码可能根本不会运行。

清单 15-12 显示了async.whilst()被用来调用一个 API 十次,以获得某个竞赛的随机“获胜者”。在循环运行之前,会检查一个姓名数组,以确定是否已经选出了 10 名获胜者。重复这个过程,直到数组的长度为 10。如果在循环中的一个 API 调用过程中出现错误,那么async.whilst()流将被终止,最后一个回调将被调用,并显示错误;否则,一旦循环条件评估为 false,将调用最终回调。

Listing 15-12. Looping While Some Condition Remains True

<!-- example-005/views/async-whilst.html -->

<h1>Winners!</h1>

<ul id="winners"></ul>

<script>

(function (async, $) {

function pickWinners(howMany, cb) {

var winners = [];

async.whilst(

// condition test:

// continue looping until we have enough winners

function () { return winners.length < howMany; },

// looping code

function (cb) {

$.get('/employee/random').done(function (employee) {

var winner = employee.firstName + ' ' + employee.lastName;

// avoid potential duplicates

if (winners.indexOf(winner) < 0) {

winners.push(winner);

}

cb(null);

}).fail(function (err) {

cb(err);

});

},

// final callback

function (err) {

// if there is an error just ignore it

// and pass back an empty array, otherwise

// pass the winners

cb(null, err ? [] : winners);

}

);

}

pickWinners(3, function (err, winners) {

$('ul#winners').append(winners.map(function (winner) {

return $('<li></li>').html(winner);

}));

});

}(window.async, window.jQuery));

</script>

清单 15-13 中的代码显示了使用async.doWhilst()代替async.whilst()循环的简短修改。请注意,参数的顺序已经改变。循环函数现在是async.doWhilst()的第一个参数,条件测试是第二个。这在结构上反映了do/while循环语法。

Listing 15-13. Looping Once and Then Continuing While Some Condition Remains True

<!-- example-005/views/async-dowhilst.html -->

<h1>Winners!</h1>

<ul id="winners"></ul>

<script>

(function (async, $) {

function pickWinners(howMany, cb) {

var winners = [];

async.doWhilst(

// looping code

function (cb) {

$.get('/employee/random').done(function (employee) {

var winner = employee.firstName + ' ' + employee.lastName;

// avoid potential duplicates

if (winners.indexOf(winner) < 0) {

winners.push(winner);

}

cb(null);

}).fail(function (err) {

cb(err);

});

},

// condition test is now the second function

// argument

function () { return winners.length < howMany; },

// final callback

function (err) {

// if there is an error just ignore it

// and pass back an empty array, otherwise

// pass the winners

cb(null, err ? [] : winners);

}

);

}

pickWinners(3, function (err, winners) {

$('ul#winners').append(winners.map(function (winner) {

return $('<li></li>').html(winner);

}));

});

}(window.async, window.jQuery));

</script>

循环,直到某个条件变为假

async.whilst()async.doWhilst()函数密切相关的是async.until()async.doUntil()函数,它们遵循相似的执行模式,但不是在某些条件为真时执行循环,而是执行循环直到某些条件测试为假。

清单 15-14 中的代码展示了如何在浏览器中创建一个简单的 HTTP 心跳来测试 API 端点的可用性。Heartbeat()构造函数用async.until()创建一个循环,该循环将重复执行,直到_isStopped属性的值被设置为true. Heartbeat()为止,该函数公开了一个stop()方法,当该方法在对象创建后被调用时,将阻止循环继续。循环的每一轮都向服务器发出 HTTP 请求,如果请求成功,循环将isAvailable属性设置为true;如果失败,则将isAvailable设置为false。为了创建循环迭代之间的延迟,一个setTimeout()函数将回调调用包装在循环中,安排循环的未来迭代在稍后运行(在本例中是每三秒钟一次)。

Listing 15-14. Looping Until Some Condition Becomes False

<!-- example-006/views/async-until.html -->

<section id="output"></section>

<script>

(function (async, $) {

var output = document.querySelector('#output');

function write() {

var pre = document.createElement('pre');

pre.innerHTML = Array.prototype.join.call(arguments, ' ');

output.appendChild(pre);

}

function Heartbeat(url, interval) {

var self = this;

this.isAvailable = false;

this.isStopped = false;

this.writeStatus = function () {

write(

'> heartbeat [isAvailable: %s, isStopped: %s]'

.replace('%s', self.isAvailable)

.replace('%s', self.isStopped)

);

};

async.until(

// test condition

function () { return self.isStopped; },

// loop

function (cb) {

$.get(url).then(function () {

self.isAvailable = true;

}).fail(function () {

self.isAvailable = false;

}).always(function () {

self.writeStatus();

// delay the next loop by scheduling

// the callback invocation in the

// future

setTimeout(function () {

cb(null);

}, interval);

});

},

// final callback

function (/*err*/) {

self.isAvailable = false;

self.writeStatus();

}

);

}

Heartbeat.prototype.stop = function () {

this.isStopped = true;

};

var heartbeat = new Heartbeat('/heartbeat', 3000);

setTimeout(function () {

// 10 seconds later

heartbeat.stop();

}, 10000);

}(window.async, window.jQuery));

</script>

async.doUntil()函数的行为类似于async.doWhilst():它在评估测试条件之前首先运行循环。它的签名也交换了测试条件函数和循环函数的顺序。

重试循环

循环的一个常见用例是重试循环,在这种情况下,任务会被尝试给定的次数。如果任务失败,但没有达到重试限制,它会再次执行。如果达到重试限制,任务将中止。async.retry()函数通过为开发人员处理重试逻辑来简化这个过程。建立循环就像指定重试限制、要执行的任务以及处理错误或接收结果的最终回调一样简单。

清单 15-15 演示了一个简单的 API 调用,用于在某场音乐会或电影中预订座位。可用座位按从最优先到最不优先的顺序排列。执行限制是数组的长度。每次任务运行时,它都会移动数组,从集合中删除第一个(最可取的)座位。如果预订失败,它将继续这个过程,直到没有剩余的座位。

Listing 15-15. Retry Loop

<!-- example-007/views/async-retry -->

<section id="output"></section>

<script>

(function (async, $) {

var output = document.querySelector('#output');

function write() {

var pre = document.createElement('pre');

pre.innerHTML = Array.prototype.join.call(arguments, ' ');

output.appendChild(pre);

}

function reserve(name, availableSeats) {

console.log(availableSeats);

return function (cb) {

var request = {

name: name,

seat: availableSeats.shift()

};

write('posting reservation', JSON.stringify(request));

$.post('/reservation', request)

.done(function (confirmation) {

write('confirmation', JSON.stringify(confirmation));

cb(null, confirmation);

}).fail(function (err) {

cb(err);

});

};

}

var name = 'Nicholas';

var availableSeats = ['15A', '22B', '13J', '32K'];

async.retry(

availableSeats.length,

reserve(name, availableSeats),

function (err, confirmation) {

if (err) {

return console.error(err);

}

console.log('seat reserved:', confirmation);

}

);

}(window.async, window.jQuery));

</script>

每次任务运行时,它都会调用回调函数。如果任务成功并向回调传递了一个值,那么最后的async.retry()回调将使用该值被调用(在本例中为confirmation)。如果出现错误,将重复循环,直到达到循环限制。最后一个错误被传递给最终回调;除非手动累积,否则之前的误差会丢失。清单 15-16 展示了一种潜在的方法,通过收集数组中的错误,然后将数组本身作为err参数传递给回调函数。如果重试循环失败,最终回调的错误将是在循环的每一轮中生成的每个错误的数组。

Listing 15-16. Accumulating Errors in a Retry Loop

function reserve(name, availableSeats) {

var errors = [];

return function (cb) {

// ...

$.post('/reservation', body)

.done(function (confirmation) {

cb(null, confirmation);

}).fail(function (err) {

errors.push(err);

cb(errors);

});

};

}

无限循环

无限循环在同步编程中是个坏消息,因为它们会阻止 CPU 和任何其他代码的执行。但是异步无限循环没有这个缺点,因为像所有其他代码一样,它们被 JavaScript 调度器安排在事件循环的未来循环中。其他需要运行的代码可以“插嘴”并请求调度。

可以用async.forever()调度无限循环。该函数将任务函数作为第一个参数,将最终回调函数作为第二个参数。该任务将继续无限期运行,除非它向其回调传递一个错误。使用等待时间为 0 的setTimeout()setImmediate()连续调度异步操作会在一个循环中产生几乎没有响应的代码,所以最好用更长的等待时间填充每个异步任务,至少几百毫秒。

清单 15-17 中的循环在无限循环的每一次循环中都发出一个 HTTP GET 请求,为用户的仪表板加载股票信息。每次 GET 请求成功时,股票信息被更新,循环在再次执行前等待三秒钟。如果在循环过程中出现错误,则使用错误调用最后一个回调,并终止循环。

Listing 15-17. Infinite Loop

<!-- example-008/views/async-forever.html -->

<ul id="stocks"></ul>

<script>

(function (async, $) {

$stockList = $('ul#stocks');

async.forever(function (cb) {

$.get('/dashboard/stocks')

.done(function (stocks) {

// refresh the stock list with new stock

// information

$stockList.empty();

$stockList.append(stocks.map(function (stock) {

return $('<li></li>').html(stock.symbol + ' $' + stock.price);

}));

// wait three seconds before continuing

setTimeout(function () {

cb(null);

}, 3000);

}).fail(cb);

}, function (err) {

console.error(err.responseText);

})

}(window.async, window.jQuery));

</script>

批量流动

本章介绍的最后一种控制流是批处理。批处理是通过将一些数据划分成块,然后一次对每个块进行操作来创建的。批处理有一些阈值,用于定义可以放入块中的数据量。在块上的工作开始后添加到批处理流中的数据被排队,直到工作完成,然后在新的块中被处理。

异步队列

异步队列是在批处理流中处理项目的一种方式。可以通过使用两个参数调用async.queue()来创建队列。第一个是任务函数,将为每个将被添加到队列中的数据项执行该函数。第二个是一个数字,表示队列将并发调度以处理数据的任务工作线程的最大数量。在清单 15-18 中,创建了一个队列来为添加到队列中的任何 URL 发出 HTTP 请求。当每个请求完成时,每个 HTTP 请求的结果将被添加到results散列中。任何时候可以运行的 HTTP 请求的最大数量是三。如果在三个请求正在进行的时候有额外的 URL 被添加到队列中,它们将被保留以供将来处理。当工人被释放时(当请求完成时),他们将根据需要被分配到排队的 URL。在给定的时间内,不会有超过三个 HTTP 请求正在进行。

Listing 15-18. Using Queue for Sequential Batches

// example-009/index.js

'use strict';

var async = require('async');

var http = require('http');

var MAX_WORKERS = 3;

var results = {};

var queue = async.queue(function (url, cb) {

results[url] = '';

http.get(url, function (res) {

results[url] = res.statusCode + ' Content-Type: ' + res.headers['content-type'];

cb(null);

}).on('error', function (err) {

cb(err);

});

}, MAX_WORKERS);

var urls = [ // 9 urls

'http://www.appendto.com

'http://www.nodejs.org

'http://www.npmjs.org

'http://www.nicholascloud.com

'http://www.devlink.net

'http://javascriptweekly.com

'http://nodeweekly.com

'http://www.reddit.com/r/javascript

'http://www.reddit.com/r/node

];

urls.forEach(function (url) {

queue.push(url, function (err) {

if (err) {

return console.error(err);

}

console.log('done processing', url);

});

});

队列将在其生命周期的某些点发出许多事件。可以将函数分配给队列对象上相应的事件属性,以处理这些事件。这些事件处理程序是可选的;无论有没有它们,队列都将正常运行。

当队列第一次达到活动工作者的最大数量时,它将调用分配给queue.saturated的任何函数。当队列正在处理所有项目并且没有其他项目排队时,它将调用分配给queue.empty的任何函数。最后,当所有的工人都完成并且队列为空时,任何分配给queue.drain的函数都会被调用。清单 15-19 中的函数处理每一个引发的事件。

Listing 15-19. Queue Events

// example-009/index.js

queue.saturated = function () {

console.log('queue is saturated at ' + queue.length());

};

queue.empty = function () {

console.log('queue is empty; last task being handled');

};

queue.drain = function () {

console.log('queue is drained; no more tasks to handle');

Object.keys(results).forEach(function (url) {

console.log(url, results[url]);

});

process.exit(0);

};

Note

emptydrained事件略有不同。当empty被触发时,尽管队列中没有剩余的项目,工人可能仍然是活动的。当drained被触发时,所有工人都停止工作,队列完全为空。

异步货物

async.cargo()函数类似于async.queue(),它将一些任务函数要处理的项目排队。然而,它们的不同之处在于工作负荷是如何划分的。async.queue()运行多个工作线程,直到达到最大并发限制——它的饱和点。async.cargo()一次运行一个工作线程,但是将队列中要处理的项目分成预定大小的有效负载。当 worker 被执行时,它将被赋予一个有效载荷。当它完成时,它将被给予另一个,直到所有的有效载荷被处理。那么,货物的饱和点是当满载的有效载荷准备好被处理时。工人启动后添加到货物中的任何物品都将被归入下一个待处理的有效负载中。

通过将任务函数作为第一个参数提供给async.cargo(),并将最大有效载荷大小作为第二个参数来创建货物。task 函数将接收一个要处理的数据数组(长度达到最大有效负载大小),并在操作完成后调用一个回调函数。

清单 15-20 中的代码展示了如何使用async.cargo()将一系列数据库更新打包到一个虚构的事务中,一次一个有效负载。task 函数遍历提供给它的“更新”对象,将每个对象转换成某个虚拟关系数据存储中的一个UPDATE查询。一旦所有的查询都被添加到事务中,事务就被提交,回调就被调用。

Listing 15-20. Using Cargo for Parallel Batches

// example-010/index-01.js

'use strict';

var async = require('async');

var db = require('db');

var MAX_PAYLOAD_SIZE = 4;

var UPDATE_QUERY = "UPDATE CUSTOMER SET ? = '?' WHERE id = ?;";

var cargo = async.cargo(function (updates, cb) {

db.begin(function (trx) {

updates.forEach(function (update) {

var query = UPDATE_QUERY.replace('?', update.field)

.replace('?', update.value)

.replace('?', update.id);

trx.add(query);

});

trx.commit(cb);

});

}, MAX_PAYLOAD_SIZE);

var customerUpdates = [ // 9 updates to be processed in payloads of 4

{id: 1000, field: 'firstName', value: 'Sterling'},

{id: 1001, field: 'phoneNumber', value: '222-333-4444'},

{id: 1002, field: 'email', value: 'archer@goodisis.com'},

{id: 1003, field: 'dob', value: '01/22/1973'},

{id: 1004, field: 'city', value: 'New York'},

{id: 1005, field: 'occupation', value: 'Professional Troll'},

{id: 1006, field: 'twitter', value: '@2cool4school'},

{id: 1007, field: 'ssn', value: '111-22-3333'},

{id: 1008, field: 'email', value: 'urmom@internet.com'},

{id: 1009, field: 'pref', value: 'rememberme=false&colorscheme=dark'}

];

customerUpdates.forEach(function (update) {

cargo.push(update, function () {

console.log('done processing', update.id);

});

});

货物对象与队列对象具有相同的事件属性,如清单 15-21 所示。主要区别在于,一旦添加了最大数量的有效载荷项目,就达到了货物的饱和极限,此时工人将开始工作。

可以根据需要将可选的函数处理程序分配给事件属性。

Listing 15-21. Cargo Events

// example-010/index-01.js

cargo.saturated = function () {

console.log('cargo is saturated at ' + cargo.length());

};

cargo.empty = function () {

console.log('cargo is empty; worker needs tasks');

};

cargo.drain = function () {

console.log('cargo is drained; no more tasks to handle');

};

Note

async.queue()async.cargo()都调度任务函数在事件循环的下一个节拍运行。如果项目被同步地一个接一个地添加到队列或货物中,那么每个项目的阈值将按预期被应用;队列将节流最大数量的工作人员,货物将划分最大数量的要处理的项目。但是,如果项是异步添加到每个任务中的,如果项是在事件循环的下一个直接循环之后添加的,则任务函数可能会在低于其最大容量的情况下被调用。

清单 15-22 中的代码从customerUpdates数组中取出每个更新,并将其推送到 cargo,然后将下一次推送安排在 500 毫秒后,在事件循环的下一次循环中发生。因为 cargo 会立即调度它的任务,所以UPDATE查询每次会运行一个——可能两个——更新,这取决于完成一个任务和调度下一个任务需要多长时间。

Listing 15-22. Adding Items to Cargo Asynchronously

// example-010/index-02.js

(function addUpdateAsync() {

if (!customerUpdates.length) return;

console.log('adding update');

var update = customerUpdates.shift();

cargo.push(update, function () {

console.log('done processing', update.id);

});

setTimeout(addUpdateAsync, 500);

}());

要保证队列和货物都满足最大阈值,请将项目同步推送到彼此。

摘要

本章介绍了一些常见的同步控制流,并演示了如何使用 Async.js 来为异步代码调整这些模式。表 15-1 显示了每个流程和相应的 Async.js 函数。

表 15-1。

Flows and Corresponding Async.js Functions

| 流动 | Async.js 函数 | | --- | --- | | 连续的 | `async.series()` | | 平行的 | `async.parallel()` | | 管道 | `async.waterfall()`,`async.seq()` | | 环 | `async.whilst()` / `async.doWhilst()`,`async.until()` / `async.doUntil()` | |   | `async.retry()`,`async.forever()` | | 一批 | `async.queue()`,`async.cargo()` |

顺序和并行流程允许开发人员执行多个独立的任务,然后根据需要汇总结果。管道流可用于将任务链接在一起,其中每个任务的输出成为后续任务的输入。为了将异步任务重复给定的次数,或者根据某些条件,可以使用循环流。最后,批处理流可以将数据分成块,一批接一批地异步处理。

通过巧妙地组织异步函数任务,协调每个任务的结果,并将错误和/或任务结果交付给最终回调,Async.js 帮助开发人员避免嵌套回调,并将传统的同步控制流操作带入 JavaScript 的异步世界。

十六、Underscore 和 Lodash

你必须是那种能把事情做好的人。但是要把事情做好,你必须热爱做的过程,而不是次要的结果。—安·兰德

JavaScript 是一种实用的工具语言,由于其简单的 API 和稀疏的类型系统,它在很大程度上非常有用。这是一种容易学习和掌握的语言,因为它的表面积很小。虽然这一特性非常有助于提高生产率,但遗憾的是,这意味着 JavaScript 类型在历史上一直缺乏使语言更强大的高级特性,例如集合和散列的原生函数迭代构造。

为了填补这一空白,Jeremy Ashkenas 在 2009 年创建了一个名为 Underscore.js 的库,其中包含 100 多个用于操作、过滤和转换散列和集合的函数。这些函数中的许多,比如map()reduce(),包含了函数式语言中常见的概念。其他的,像isArguments()isUndefined()是特定于 JavaScript 的。

随着 Underscore 在许多 web 应用中变得无处不在,发生了两件令人兴奋的事情。首先,ECMAScript 5 规范在同一年发布。它在原生 JavaScript 对象上有许多类似 Underscore 的方法,比如Array.prototype.map()Array.prototype.reduce()Array.isArray()。虽然 ECMAScript 5(以及 ECMAScript 6 和 7)扩展了几个关键类型的 API,但它只包含了 Underscore. js 提供的一小部分功能。

其次,Underscore 被引入了一个名为 Lodash 的新项目,目标是显著提高性能并扩展其 API。因为 Lodash 在添加自己的函数的同时实现了所有的 Underscore 函数,所以 Underscore 是 Lodash 的子集。所有相应的 ECMAScript 规范函数也是 Lodash 的一部分。清单 16-1 中的表格显示了映射到其本地 ECMAScript 对应物的 Underscore 和 Lodash 函数。

表 16-1。

Underscore and Lodash Functions Compared to Current (and Proposed) Native JavaScript Implementations

| ECMAScript 5 | Underscore/Lodash | | --- | --- | | `Array.prototype.every()` | `all()` / `every()` | | `Array.prototype.filter()` | `select()` / `filter()` | | `Array.prototype.forEach()` | `each()` / `forEach()` | | `Array.isArray()` | `isArray()` | | `Object.keys()` | `keys()` | | `Array.prototype.map()` | `map()` | | `Array.prototype.reduce()` | `inject()` / `foldl()` / `reduce()` | | `Array.prototype.reduceRight()` | `foldr()` / `reduceRight()` | | `Array.prototype.some()` | `some()` | | ECMAScript 6 | Underscore/Lodash | | `Array.prototype.find()` | `find()` | | `Array.prototype.findIndex()` | `findIndex()` | | `Array.prototype.keys()` | `keys()` | | ECMAScript 7 | Underscore/Lodash | | `Array.prototype.contains()` | `include()` / `contains()` |

因为 Underscore 和 Lodash 共享一个 API,所以 Lodash 可以作为 Underscore 的替代。然而,反过来就不一定了,因为 Lodash 提供了额外的功能。例如,虽然 Underscore 和 Lodash 都有一个clone()方法,但是只有 Lodash 实现了一个cloneDeep()方法。由于这些额外的特性,开发人员通常选择 Lodash 而不是 Underscore,但是性能优势也是有形的。根据逐个函数的性能基准测试,Lodash 比 Underscore 平均快 35%。对于forEach()map()reduce()等函数,它通过支持简单循环而不是本地委托来实现这种性能提升。

本章主要关注还没有(或计划要)在 JavaScript 中实现的 Underscore 和 Lodash 的特性(清单 16-1 和清单 16-2 中的函数)。Mozilla 的优秀文档涵盖了每个本机函数,Underscore 和 Lodash API 文档也涵盖了它们的每个实现。

但是 Underscore 和 Lodash 为对象和集合提供的不仅仅是一些方便的函数,其中一些将在本章中探讨。

Note

为简洁起见,本章的其余部分仅提及 Underscore,但是要理解,除非另有说明,Underscore 和 Lodash 是可以互换的。

安装和使用

Underscore 可以作为库直接导入 web 浏览器或任何服务器端 JavaScript 环境,如 Node.js。它没有外部依赖性。

可以直接从 Underscore 网站( http://underscorejs.org )下载 Underscore. js 脚本,或者用 npm、Bower、Component 之类的包管理器安装。

在浏览器中,您可以直接将 Underscore 作为脚本,或者使用 AMD 或 CommonJS 兼容的模块加载程序(如 RequireJS 或 Browserify)加载它。在 Node.js 中,这个包只是一个 CommonJS 模块。

访问 Underscore 对象(它的实用函数在其上)取决于库是如何加载的。当 Underscore 被加载到带有script标签的浏览器中时,库会将自己附加到window._上。对于模块加载器在任何环境下创建的变量,习惯上给模块分配实际的 Underscore 字符,如清单 16-1 所示。

Listing 16-1. Loading the Underscore Library in a Node.js Module

// example-001/index.js

'use strict';

var _ = require('underscore');

console.log(_.VERSION);

// 1.8.2

所有 Underscore 函数都存在于_(“Underscore”)对象中。因为 Underscore 是一个工具库,除了一些设置之外,它不保存任何状态(但是我们将在本章后面详细介绍)。所有函数都是幂等的,这意味着多次向任何函数传递一个值都会产生相同的结果。一旦加载了 Underscore 对象,就可以立即使用它。

Underscore 的实用函数主要作用于集合(数组和类似数组的对象,如参数)、对象文字和函数。Underscore 最常用于过滤和转换数据。许多 Underscore 功能相互补充,可以协同工作以创建强大的组合。因为这非常有用,所以 Underscore 内置了对函数链的支持,函数链可以创建简洁的管道,一次对数据应用多种转换。

聚合和索引

集合中的数据片段通常共享相似的模式,但具有使每个模式都唯一的标识属性。在一组数据中区分这两种类型的关系(共性和个性)有助于快速筛选和处理与聚合标准匹配的对象子集。

Underscore 有许多执行这些任务的函数,但是在处理集合时,有三个特定的函数非常有用:countBy()groupBy()indexBy()

计数比()

对具有某些共同特征的对象进行计数是归纳数据的一种常用方法。给定一个 URL 集合,可以设想一些分析过程来确定有多少 URL 属于特定的顶级域(例如。com,。组织,。edu 等。).Underscore 的countBy()函数是这项任务的理想候选函数。它对数组中的每个元素调用回调,以确定元素属于哪个类别(在本例中,URL 属于哪个顶级域)。回调返回一些表示这个类别的字符串值。最终结果是一个对象,其中的键表示从回调返回的所有类别,数字计数表示属于每个类别的元素数量。清单 16-2 展示了一个原始实现,它产生一个计数为 2 的对象。org 域名和一个。com 域。

Listing 16-2. Counting Elements by Some Criteria

// example-002/index.js

'use strict';

var _ = require('underscore');

var urls = [

'http://underscorejs.org

'http://lodash.com

'http://ecmascript.org

];

var counts = _.countBy(urls, function byTLD(url) {

if (url.indexOf('.com') >= 0) {

return '.com';

}

if (url.indexOf('.org') >= 0) {

return '.org';

}

return '?';

});

console.log(counts);

// { '.org': 2, '.com': 1 }

如果集合中的项目是具有属性的对象,并且特定属性的值表示要计数的数据,则不需要迭代器函数。要测试的属性的名称可以作为替代。注意,在清单 16-3 中,最终结果中的键将是每个对象上检查的属性值。

Listing 16-3. Counting Elements by Some Property

// example-003/index.js

'use strict';

var _ = require('underscore');

var urls = [

{scheme: 'http', host: 'underscorejs', domain: '.org'},

{scheme: 'http', host: 'lodash', domain: '.com'},

{scheme: 'http', host: 'ecmascript', domain: '.org'},

];

var counts = _.countBy(urls, 'domain');

console.log(counts);

// { '.org': 2, '.com': 1 }

如果集合中的一个或多个对象缺少要测试的属性,最终的结果对象将包含一个undefined键,并与这些对象的数量成对出现。

groupby(群件)

Underscore 的groupBy()功能类似于countBy(),但是groupBy()没有将结果减少为数字计数,而是将元素放入结果对象的分类集合中。清单 16-4 中的 URL 对象分别被放入每个相应顶级域名的集合中。

Listing 16-4. Grouping Elements by Some Property

// example-004/index.js

'use strict';

var _ = require('underscore');

var urls = [

{scheme: 'http', host: 'underscorejs', domain: '.org'},

{scheme: 'http', host: 'lodash', domain: '.com'},

{scheme: 'http', host: 'ecmascript', domain: '.org'},

];

var grouped = _.groupBy(urls, 'domain');

console.log(grouped);

/*

{

'.org': [

{ scheme: 'http', host: 'underscorejs', domain: '.org' },

{ scheme: 'http', host: 'ecmascript', domain: '.org' }

],

'.com': [

{ scheme: 'http', host: 'lodash', domain: '.com' }

]

}

*/

Note

如果需要更大程度的控制来对元素进行分类,那么groupBy()函数也可以使用迭代器函数作为它的第二个参数(而不是属性名)。

值得一提的是,通过简单地查询每个分组数组的长度,可以容易地从分组对象中导出计数。根据应用环境,分组可能比计数更有利。清单 16-5 展示了如何获得一组分组数据的计数,以及从groupBy()结果创建计数对象的函数。

Listing 16-5. Deriving Counts from Grouped Objects

// example-005/index.js

'use strict';

var _ = require('underscore');

var urls = [

{scheme: 'http', host: 'underscorejs', domain: '.org'},

{scheme: 'http', host: 'lodash', domain: '.com'},

{scheme: 'http', host: 'ecmascript', domain: '.org'},

];

var grouped = _.groupBy(urls, 'domain');

var dotOrgCount = grouped['.org'].length;

console.log(dotOrgCount);

// 2

function toCounts(grouped) {

var counts = {};

for (var key in grouped) {

if (grouped.hasOwnProperty(key)) {

counts[key] = grouped[key].length;

}

}

return counts;

}

console.log(toCounts(grouped));

// { '.org': 2, '.com': 1 }

indexBy()

识别集合中数据之间的差异也很有用,尤其是当这些差异可以作为唯一标识符时。根据已知的标识符从集合中找出单个对象是一个非常常见的场景。如果手动完成,这将需要遍历集合中的每个元素(可能用一个whilefor循环)并返回第一个拥有匹配的惟一标识符的元素。

假设有一个航空公司网站,客户在上面选择出发机场和目的地机场。用户通过下拉菜单选择每个机场,然后显示每个机场的附加数据。这些附加数据是从数组中的 airport 对象加载的。在每个下拉菜单中选择的值是唯一的机场代码,应用将使用这些代码来查找完整、详细的机场对象。

幸运的是,创建这个应用的开发人员使用 Underscore 的indexBy()函数从airports数组创建了一个索引对象,如清单 16-6 所示。

Listing 16-6. Indexing Objects by Property

// example-006/index.js

'use strict';

var _ = require('underscore');

var airports = [

{code: 'STL', city: 'St Louis', timeZone: '-6:00'},

{code: 'SEA', city: 'Seattle', timeZone: '-8:00'},

{code: 'JFK', city: 'New York', timeZone: '-5:00'}

];

var selected = 'SEA';

var indexed = _.indexBy(airports, 'code');

console.log(indexed);

/*

{

STL: {code: 'STL', city: 'St Louis', timeZone: '-6:00'},

SEA: {code: 'SEA', city: 'Seattle', timeZone: '-8:00'},

JFK: {code: 'JFK', city: 'New York', timeZone: '-5:00'}

}

*/

var timeZone = indexed[selected].timeZone;

console.log(timeZone);

// -8:00

indexBy()函数的行为有点像groupBy(),除了每个对象都有一个索引属性的唯一值,所以最终结果是一个对象,其键(必须是唯一的)是每个对象的指定属性的值,其值是拥有每个属性的对象。在清单 16-6 中,indexed对象的键是每个机场代码,值是相应的机场对象。

将索引对象与相对稳定的引用数据一起保存在内存中是一项基本的缓存实践。这导致了一次性的性能损失(索引过程),以避免多次迭代损失(每次需要一个对象时都必须遍历数组)。

挑剔

开发人员经常从集合和对象中提取想要的数据,或者省略不想要的数据。这样做可能是为了易读性(当数据将被显示给用户时),为了性能(当数据将通过网络连接发送时),为了隐私(当从对象或模块的 API 返回的数据应该是稀疏的时),或者为了一些其他目的。

从集合中选择数据

Underscore 有许多实用函数,可以根据某些标准从对象集合中选择一个或多个元素。在某些情况下,这个标准可能是一个函数,它评估每个元素并返回 true 或 false(元素是否“通过”标准测试)。在其他情况下,标准可以是将与每个元素(或每个元素的一部分)进行相等比较的一位数据,其成功或失败决定了元素是否“匹配”所使用的标准。

过滤器( )

filter()函数使用标准函数方法。给定一个元素数组和一个函数,filter()将函数应用于每个元素,并返回一个只包含通过标准测试的元素的数组。在清单 16-7 中,一组扑克牌被过滤,因此只返回黑桃。

Listing 16-7. Filtering an Array with a Criteria Function

// example-007/index.js

'use strict';

var _ = require('underscore');

var cards = [

{suite: 'Spades', denomination: 'King'},

{suite: 'Hearts', denomination: '10'},

{suite: 'Clubs', denomination: 'Ace'},

{suite: 'Spades', denomination: 'Ace'},

];

var filtered = _.filter(cards, function (card) {

return card.suite === 'Spades';

});

console.log(filtered);

/*

[

{ suite: 'Spades', denomination: 'King' },

{ suite: 'Spades', denomination: 'Ace' }

]

*/

哪里( )

where()函数与filter()相似,但使用比较标准方法。它的第一个参数是一个对象数组,但它的第二个参数是一个 criteria 对象,它的键和值将与数组中每个元素的键和值进行比较。如果一个元素包含 criteria 对象中的所有键和相应的值(使用严格相等),该元素将被包含在由where()返回的数组中。

在清单 16-8 中,一组棋盘游戏对象被一个指定最小玩家数量和游戏时间的对象过滤。疫情被排除在外,因为它与 criteria 对象的playTime值不匹配,尽管它与minPlayer值匹配。

Listing 16-8. Filtering an Array by Criteria Comparison

// example-008/index.js

'use strict';

var _ = require('underscore');

var boardGames = [

{title: 'Ticket to Ride', minPlayers: 2, playTime: 45},

{title: 'Pandemic', minPlayers: 2, playTime: 60},

{title: 'Munchkin Deluxe', minPlayers: 2, playTime: 45}

];

var filtered = _.where(boardGames, {

minPlayers: 2,

playTime: 45

});

console.log(filtered);

/*

[

{ title: 'Ticket to Ride', minPlayers: 2, playTime: 45 },

{ title: 'Munchkin Deluxe', minPlayers: 2, playTime: 45 }

]

*/

find()和 findWhere()

filter()where()函数总是返回集合。如果没有对象通过标准测试,则每个对象都返回一个空集。开发人员可以使用这些函数在集合中查找单个对象(例如,通过某个唯一标识符),但随后必须使用索引零从结果数组中找出该对象。幸运的是,Underscore 提供了补充filter()where()find()findWhere()功能。它们各自返回第一个通过标准检查的对象,或者如果集合中没有对象通过,则返回undefined。在清单 16-9 中,一个集合被搜索了两次以寻找特定的条目。请注意,即使多个项目满足传递给findWhere(){what: 'Dagger'}标准对象,也只会返回集合中的第一个匹配项。

Listing 16-9. Finding a Single Item in a Collection

// example-009/index.js

'use strict';

var _ = require('underscore');

var guesses = [

{who: 'Mrs. Peacock', where: 'Lounge', what: 'Revolver'},

{who: 'Professor Plum', where: 'Study', what: 'Dagger'},

{who: 'Miss Scarlet', where: 'Ballroom', what: 'Candlestick'},

{who: 'Reverend Green', where: 'Conservatory', what: 'Dagger'}

];

var result = _.find(guesses, function (guess) {

return guess.where === 'Ballroom';

});

console.log(result);

// { who: 'Miss Scarlet', where: 'Ballroom', what: 'Candlestick' }

result = _.findWhere(guesses, {what: 'Dagger'});

console.log(result);

// { who: 'Professor Plum', where: 'Study', what: 'Dagger' }

从对象中选择数据

到目前为止,当应用不需要一部分数据时,Underscore 函数会将较大的集合过滤成较小的集合(甚至是单个对象)。对象也是数据的集合,由字符串键而不是有序数字索引;和数组一样,过滤单个对象中的数据也非常有用。

拔毛( )

开发人员可以从集合中的每个对象获取属性值,方法是循环遍历每个元素并在数组中捕获所需的属性值,或者使用Array.prototype.map()(或 Underscore 的等价形式map())。但是更快、更方便的选择是使用 Underscore 的pluck()函数,它将一个数组作为第一个参数,将从每个元素中提取的属性名作为第二个参数。在清单 16-10 中使用了pluck()函数来提取正面朝上的数字。然后将这些值相加(用Array.prototype.reduce())以确定滚动的总值。

Listing 16-10. Plucking Properties from Objects in a Collection

// example-010/index.js

'use strict';

var _ = require('underscore');

var diceRoll = [

{sides: 6, up: 3},

{sides: 6, up: 1},

{sides: 6, up: 5}

];

var allUps = _.pluck(diceRoll, 'up');

console.log(allUps);

// [ 3, 1, 5 ]

var total = allUps.reduce(function (prev, next) {

return prev + next;

}, 0);

console.log(total);

// 9

虽然pluck()对于从对象中选择单个属性非常有用,但是它只对集合进行操作,对于处理单个对象不是很有用。

值( )

ECMAScript 5 规范在Object构造函数上引入了keys()函数,这是一个方便的工具,可以将任何对象文字的键转换成字符串数组。Underscore 有一个相应的keys()实现,但也有一个values()函数,遗憾的是,在本地 JavaScript 中没有对应的函数。values()函数用于从一个对象中提取所有的属性值,对于保存一组“常数”的对象或者作为另一种语言中的枚举来说,它无疑是最有价值的(老爸的笑话)。清单 16-11 展示了这个提取是如何发生的。

Listing 16-11. Extracting Values from an Object Literal

// example-011/index.js

'use strict';

var _ = require('underscore');

var BOARD_TILES = {

IND_AVE: 'Indiana Avenue',

BOARDWALK: 'Boardwalk',

MARV_GARD: 'Marvin Gardens',

PK_PLACE: 'Park Place'

};

var propertyNames = _.values(BOARD_TILES);

console.log(propertyNames);

// [ 'Indiana Avenue', 'Boardwalk', 'Marvin Gardens', 'Park Place' ]

参考数据(例如,美国各州缩写和名称的散列)经常被一次检索和缓存。这些数据通常会通过键来解引用,以便可以提取一些特定的值,但有时不管键是什么,使用所有的值都是有用的,如清单 16-12 中的 Underscore 模板所示。(Underscore 模板将在本章稍后讨论,但是清单 16-12 应该足以让你掌握基本用法。)在BOARD_TILES散列(瓦片名称)中的每个值被呈现为无序列表中的列表项。钥匙是无关紧要的;只有值是重要的,这是values()函数的完美场景。

Listing 16-12. Extracting Values from an Object Literal

<!-- example-011/index.html -->

<div id="output"></div>

<script id="tiles-template" type="text/x-template">

<ul class="properties">

<% _.each(_.values(tiles), function (property) { %>

<li><%- property %></li>

<% }); %>

</ul>

</script>

<script>

(function (_) {

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

var bindTemplate = _.template(template);

var BOARD_TILES = {

IND_AVE: 'Indiana Avenue',

BOARDWALK: 'Boardwalk',

MARV_GARD: 'Marvin Gardens',

PK_PLACE: 'Park Place'

};

var markup = bindTemplate({tiles: BOARD_TILES});

document.querySelector('#output').innerHTML = markup;

}(window._));

</script>

挑选( )

最后,为了将一个对象削减到其键和值的子集,开发人员可以使用 Underscore 的pick()函数。当传入一个目标对象和一个或多个属性名时,pick()将从目标返回另一个仅由这些属性(及其值)组成的对象。在清单 16-13 中,namenumPlayers属性是从带有pick()的棋盘游戏细节中提取出来的。

Listing 16-13. Picking Properties from an Object Literal

// example-012/index.js

'use strict';

var _ = require('underscore');

var boardGame = {

name: 'Settlers of Catan',

designer: 'Klaus Teuber',

numPlayers: [3, 4],

yearPublished: 1995,

ages: '10+',

playTime: '90min',

subdomain: ['Family', 'Strategy'],

category: ['Civilization', 'Negotiation'],

website: 'http://www.catan.com

};

var picked = _.pick(boardGame, 'name', 'numPlayers');

console.log(picked);

/*

{

name: 'Settlers of Catan',

numPlayers: [ 3, 4 ]

}

*/

省略( )

pick()的逆运算是omit(),它返回一个由除指定属性之外的所有属性组成的对象。属性designernumPlayersyearPublishedagesplayTime都从清单 16-14 中omit()创建的结果对象中删除。

Listing 16-14. Omitting Properties from an Object Literal

// example-013/index.js

'use strict';

var _ = require('underscore');

var boardGame = {

name: 'Settlers of Catan',

designer: 'Klaus Teuber',

numPlayers: [3, 4],

yearPublished: 1995,

ages: '10+',

playTime: '90min',

subdomain: ['Family', 'Strategy'],

category: ['Civilization', 'Negotiation'],

website: 'http://www.catan.com

};

var omitted = _.omit(boardGame, 'designer', 'numPlayers',

'yearPublished', 'ages', 'playTime');

console.log(omitted);

/*

{

name: 'Settlers of Catan',

subdomain: [ 'Family', 'Strategy' ],

category: [ 'Civilization', 'Negotiation' ],

website: 'http://www.catan.com

}

*/

除了属性名之外,pick()omit()都接受一个谓词来评估每个属性和值。如果谓词返回true,该属性将包含在结果对象中;如果它返回false,该属性将被排除。清单 16-15 中pick()的谓词将只为数组值向结果对象添加属性;在这种情况下,属性numPlayerssubdomaincategory

Listing 16-15. Picking Properties from an Object Literal with a Predicate Function

// example-014/index.js

'use strict';

var _ = require('underscore');

var boardGame = {

name: 'Settlers of Catan',

designer: 'Klaus Teuber',

numPlayers: [3, 4],

yearPublished: 1995,

ages: '10+',

playTime: '90min',

subdomain: ['Family', 'Strategy'],

category: ['Civilization', 'Negotiation'],

website: 'http://www.catan.com

};

var picked = _.pick(boardGame, function (value, key, object) {

return Array.isArray(value);

});

console.log(picked);

/*

{

numPlayers: [ 3, 4 ],

subdomain: [ 'Family', 'Strategy' ],

category: [ 'Civilization', 'Negotiation' ]

}

*/

链接

Underscore 包含许多常用于创建数据转换管道的实用函数。为了开始一个链,一个对象或集合被传递给 Underscore 的chain()函数。这将返回一个链式包装器,在该包装器上可以流畅地调用许多 Underscore 函数,每个函数都组合了前面函数调用的效果。

清单 16-16 显示了一系列的咖啡店以及每家咖啡店的营业时间。whatIsOpen()函数接受一个数字小时和一个句点('AM''PM')。然后,这些用于评估集合中的咖啡店,并返回在此期间营业的咖啡店的名称。

Listing 16-16. Chaining Functions on a Collection

// example-015/index.js

'use strict';

var _ = require('lodash');

/*

Note that lodash, not underscore, is used for

this example. The cloneDeep() function below

is unique to lodash.

*/

var coffeeShops = [

{name: 'Crooked Tree', hours: [6, 22]},

{name: 'Picasso\'s Coffee House', hours: [6, 24]},

{name: 'Sump Coffee', hours: [9, 16]}

];

function whatIsOpen(hour, period) {

return _.chain(coffeeShops)

.cloneDeep()                           // #1

.map(function to12HourFormat (shop) {  // #2

shop.hours = _.map(shop.hours, function (hour) {

return (hour > 12 ? hour – 12 : hour);

}

return shop;

})

.filter(function filterByHour (shop) { // #3

if (period === 'AM') {

return shop.hours[0] <= hour;

}

if (period === 'PM') {

return shop.hours[1] >= hour;

}

return false;

})

.map(function toShopName (shop) {      // #4

return shop.name;

})

.value();                              // #5

}

console.log(whatIsOpen(8, 'AM'));

// [ 'Crooked Tree', 'Picasso\'s Coffee House' ]

console.log(whatIsOpen(11, 'PM'));

// [ 'Picasso\'s Coffee House' ]

chain()coffeeShops数组包装在 fluent API 中之后,调用以下函数来操作和过滤集合,直到生成所需的数据。

cloneDeep() recursively clones the array and all objects and their properties. In step 2 the array data is actually modified, so the array is cloned to preserve its original state.   map(function to12HourFormat() {/*...*/}) iterates over each item in the cloned array and replaces the second 24-hour number in the hours array with its 12-hour equivalent.   filter(function filterByHour() {/*...*/}) iterates over each modified coffee shop and evaluates its hours based on the period ('AM' or 'PM') specified: the first element for the opening hour and the second for the closing hour. The function returns true or false to indicate whether the coffee shop should be retained or dropped from the results.   map(function toShopName() {/*...*/}) returns the name of each remaining coffee shop in the collection. The result is an array of strings that will be passed to any subsequent steps in the chain.   Finally, value() is called to terminate the chain and return the final result: the array of names of coffee shops that are open during the hour and period provided to whatIsOpen() (or an empty array if none match the criteria).

这看起来似乎很难理解,但是 Underscore 链可以简化为几个容易记住的简单原则:

  • 可以用任何初始值创建链,尽管对象和数组是最典型的起点。
  • 任何对值进行操作的 Underscore 函数都可以作为链式函数使用。
  • 链式函数的返回值成为链中下一个函数的输入值。
  • 链式函数的第一个参数总是它所操作的值。例如,Underscore 的map()函数通常接受两个参数,一个集合和一个回调,但当作为链式函数调用时,它只接受一个回调。这种模式适用于所有的链式函数。
  • 总是调用value()函数来终止一个链,并检索它的最终操作值。如果链不返回值,这是不必要的。

集合或对象的链接函数可能看起来很自然和显而易见,但是 Underscore 也有许多处理原语的函数。清单 16-17 展示了一个链如何包装数字 100,最终生成“99 瓶啤酒”的歌词

Listing 16-17. Chaining Functions on a Primitive

// example-016/index.js

'use strict';

var _ = require('underscore');

_.chain(100)

.times(function makeLyrics (number) {

if (number === 0) {

return '';

}

return [

number + ' bottles of beer on the wall!',

number + ' bottles of beer!',

'Take one down, pass it around!',

(number - 1) + ' bottles of beer on the wall!',

' ♫ ♫ ♪ ♫ ♫ ♪ ♪ ♪ ♫ ♫ ♪ ',

].join('\n');

})

.tap(function orderLyrics (lyrics) {

// reverse the array so the song is in order

lyrics.reverse();

})

.map(function makeLoud (lyric) {

return lyric.toUpperCase();

})

.forEach(function printLyrics (lyric) {

console.log(lyric);

});

times()函数将一个数字作为第一个参数,并为该数字的每个递减值调用一个回调函数。在这个例子中,回调makeLyrics()将被调用,从数字 99(不是 100)开始,以数字 0 结束,总共 100 次迭代。对于每个调用,返回一个重复的“99 瓶”。这会创建一个字符串数组,然后传递给链中的下一个函数。

因为最后链接的函数forEach()创建副作用而不是返回值,所以不需要通过调用value()来终止链。相反,清单 16-18 显示了打印到控制台的结果。

Listing 16-18. The Song to Ruin All Road Trips

99 BOTTLES OF BEER ON THE WALL!

99 BOTTLES OF BEER!

TAKE ONE DOWN, PASS IT AROUND!

98 BOTTLES OF BEER ON THE WALL!

♫ ♪ ♫ ♪ ♫ ♪ ♫ ♪ ♫ ♪ ♫

98 BOTTLES OF BEER ON THE WALL!

98 BOTTLES OF BEER!

TAKE ONE DOWN, PASS IT AROUND!

97 BOTTLES OF BEER ON THE WALL!

♫ ♪ ♫ ♪ ♫ ♪ ♫ ♪ ♫ ♪ ♫

...

功能计时

函数在 JavaScript 的内部事件循环中被调度时执行。像setTimeout()setInterval()和节点的setImmediate()这样的本地函数给了开发者一定程度的控制权,可以控制这些函数何时运行——哪一轮事件循环将处理它们的调用。Underscore 用许多控制功能增强了这些原语,增加了功能调度的灵活性。

延期()

Underscore 的defer()函数模仿 Node.js 环境中setImmediate()的行为;也就是说,defer()调度一个函数在事件循环的下一次循环中执行。这相当于使用延迟为 0 的setTimeout()。由于setImmediate()不是一个 JavaScript 标准函数,在浏览器和服务器环境中使用 Underscoredefer()比在浏览器中多填充setImmediate()能提供更高程度的一致性。

清单 16-19 中的示例代码演示了用户界面中defer()的值。它为流行的纸牌游戏 Dominion 加载一个大型的纸牌信息数据集,然后用纸牌细节填充一个 HTML 表。

当从服务器获取数据并进行处理时,用户会看到这样的消息,“正在加载卡,请耐心等待!”一旦 GET 请求完成,processCards()处理器开始以 10 张为一组处理将近 200 张卡片。对于每个块(除了第一个),处理程序都会推迟处理,这有两个好处。首先,它允许用户界面有时间绘制表中前 10 个已处理的行,其次,它允许用户在窗口绘制之间滚动。因为块大小这么小,所以滚动速度对用户来说相对正常。如果processCards()试图一次呈现所有的表格行,UI 将会冻结,直到所有的 DOM 元素都被添加到表格中。

Listing 16-19. Deferring a Function

<!-- example-017/views/defer.html -->

<p id="wait-msg">Please be patient while cards are loading!</p>

<table id="cards">

<thead>

<tr>

<th>Name</th>

<th>Expansion</th>

<th>Cost</th>

<th>Benefit</th>

<th>Description</th>

</tr>

</thead>

<tbody></tbody>

</table>

<script>

$(function () {

var $waitMsg = $('#wait-msg');

var $cards = $('#cards tbody');

function processCards(cards) {

var BLOCK_SIZE = 10;

// process the first chunk of 10 cards

(function processBlock() {

if (!cards.length) {

$waitMsg.addClass('hidden');

return;

}

// take the first 10 cards from the array;

// splice() will reduce the length of the array

// by 10 each time

var block = cards.splice(0, BLOCK_SIZE);

_.forEach(block, function (card) {

var $tr = $('<tr></tr>');

$tr.append($('<td></td>').html(card.name));

$tr.append($('<td></td>').html(card.expansion));

$tr.append($('<td></td>').html(card.cost));

$tr.append($('<td></td>').html(card.benefits.join(', ')));

$tr.append($('<td></td>').html(card.description));

$cards.append($tr);

});

// defer the next block of 10 cards to

// allow the user to scroll and the UI to

// refresh

_.defer(processBlock);

}());

}

// kick off the process by loading the data set

$.get('/cards').then(processCards);

}());

</script>

debounce()

“去抖动”是指在一段时间内忽略系统中重复的调用、请求、消息等。在 JavaScript 中,如果开发人员预计可能会在短时间内连续进行重复、相同的函数调用,那么对函数进行去抖动会非常有帮助。例如,去抖功能的一个常见场景是,当用户意外地在网页上多次单击提交按钮时,防止表单的提交处理程序被多次调用。

定制的反跳实现将要求开发人员在短时间内(可能只有数百毫秒)使用setTimeout()clearTimeout()对每个重复调用跟踪函数的调用。幸运的是,Underscore 为开发人员提供了处理这种管道的debounce()函数,如清单 16-20 所示。

Listing 16-20. Debouncing a Function

<!-- example-018/debounce.html -->

<button id="submit">Quickly Click Me Many Times!</button>

<script>

(function () {

var onClick = _.debounce(function (e) {

alert('click handled!');

}, 300);

document.getElementById('submit')

.addEventListener('click', onClick);

}());

</script>

在清单 16-20 中,通过调用debounce()创建了一个onClick()函数。debounce()的第一个参数是所有重复调用停止后实际运行的函数。第二个参数是以毫秒为单位的持续时间,在两次调用之间必须经过一段时间才能最终触发回调。例如,如果用户单击了一次#submit按钮,然后在 300 毫秒的时间间隔内再次单击它,那么第一次调用将被忽略,等待计时器将重新启动。一旦等待时间结束,就会调用debounce()回调,提醒用户点击已经被处理。

Note

每次调用去抖功能时,其内部定时器都会复位。指定的时间跨度表示在回调函数执行之前,最后一次调用与其前一次调用(如果有)之间必须经过的最小时间。

在图 16-1 中,超时 300ms 的去抖功能被调用三次。在 A 点的第一次呼叫之后,250 毫秒过去,此时在 B 点发生另一次呼叫,等待计时器复位。B 和下一个呼叫 C 之间的间隔更短:100ms。等待计时器再次复位。在 C 点,进行第三次呼叫,之后满足 300 毫秒的等待持续时间。在点 D,调用去抖动函数的回调。

A978-1-4842-0662-1_16_Fig1_HTML.gif

图 16-1。

A debounced function invoked multiple times

去抖动函数的回调将接收传递给debounce()函数本身的任何参数。例如,在清单 16-20 中,jQuery 的事件对象e被转发给去抖动函数的回调。虽然每次调用可能传递不同的参数,但重要的是要认识到,只有在等待期间的最后一次调用中传递的参数才会被实际转发给回调。debounce()函数接收可选的第三个参数immediate,该参数可能为真或假。将该参数设置为true将调用第一次调用的回调,忽略等待期间的所有后续重复。如果传递给去抖函数的参数不同,捕获第一个传递的参数而不是最后一个可能在战略上是有益的。

油门()

Underscore 的throttle()功能类似于debounce()。它会在指定的时间段内忽略函数的后续调用,但不会在每次函数调用时重置其内部计时器。它有效地确保了在指定的时间段内只发生一次调用,而debounce()保证了在最后一次调用去抖动函数之后的某个时间只发生一次调用。如果一个函数可能会使用相同的参数被多次调用,或者当参数的粒度使得考虑该函数的每次调用没有用时,对该函数进行节流会特别有用。

内存中的 JavaScript 消息总线 postal.js 是通过应用路由消息的一个有用的库。一些应用模块发送消息的频率可能对人类消费没有用处,因此任何向用户显示这些消息的函数都可能是节流的良好候选。

清单 16-21 中的代码演示了这个场景的简化版本。不要担心完全理解 postal.js API 理解postal.publish()将把消息放到总线上,而postal.subscribe()将在收到消息时调用回调就足够了。在本例中,每 100 毫秒发布一次消息。但是,附加到订阅的回调被限制在 500 毫秒。因此,由于时间不一致(JavaScript 事件循环计时器的精度较低),即使在消息总线上放置了 100 个更新,UI 也会显示大约 20 或 21 个更新(大约显示 1/5 的消息)。

Listing 16-21. Using a Throttled Function to Control Status Updates

<!-- example-019/throttle.html -->

<section id="friends"></section>

<script>

$(function () {

var $friends = $('#friends');

function onStatusUpdate(data) {

var text = data.name + ' is ' + data.status;

$friends.append($('<p></p>').html(text));

}

/*

* subscribing to status updates from friends

* with a throttled callback that will only

* fire *once* every 500ms

*/

postal.subscribe({

channel: 'friends',

topic: 'status.update',

callback: _.throttle(onStatusUpdate, 500)

});

}());

</script>

<script>

$(function () {

var i = 1;

var interval = null;

/*

* publishing a status update from a

* friend every 100ms

*/

function sendMessage() {

if (i === 100) {

return clearInterval(interval);

}

i += 1;

postal.publish({

channel: 'friends',

topic: 'status.update',

data: {

name: 'Jim',

status: 'slinging code'

}

});

}

setInterval(sendMessage, 100);

}());

</script>

图 16-2 展示了throttle()defer()的不同之处。一旦在 A 点调用了节流函数,它将忽略所有进一步的调用(在 B 点和 C 点),直到等待时间结束——在本例中是 300 毫秒。一旦过去,D 点的下一个调用将调用 throttled 函数。

A978-1-4842-0662-1_16_Fig2_HTML.gif

图 16-2。

A throttled function invoked multiple times

模板

Underscore 提供了一个微模板系统,可以将模板字符串(通常是 HTML)编译成函数。当用一些数据调用这个函数时,它使用模板字符串的绑定表达式来填充模板,返回一个新的 HTML 字符串。使用过模板工具如 Mustache 或 Handlebars 的开发人员会对这个过程很熟悉。然而,与这些更健壮的模板库不同,Underscore 的模板具有更小的特性集,并且没有真正的模板扩展点。当应用中的模板相当简单,并且您不希望或不需要在应用中产生特定于模板的库的开销时,Underscore 可以是作为模板库的一个强有力的选择。

模板系统通常以一些标记开始,Underscore 也不例外。数据绑定表达式被添加到带有“鳄鱼标签”的模板中(这样命名是因为开始和结束元素<%%>,看起来有点像鳄鱼)。清单 16-22 显示了一个简单的 HTML 块,它稍后将被绑定到一个包含两个属性titlesynopsis的对象文字。

Listing 16-22. Template with “Gator Tags”

<h1><%- title %></h1>

<p class="synopsis"><%- synopsis %></p>

鳄鱼标签有三种。清单 16-22 中使用的标签通过转义任何 HTML 标签序列来生成安全的 HTML 输出。如果电影概要包含一个 HTML 标签,如<strong>,它将被转换为<strong>。相比之下,鳄鱼标签<%=可以用来输出带有 HTML 标记的非转义字符串。第三个 gator 标记是 JavaScript evaluation 标记,它只是以<%开始(稍后将详细介绍这个标记)。所有鳄鱼标签共享同一个结束标签,%>

为了将清单 16-22 中的 HTML 转换成一个填充模板,HTML 字符串首先通过传递给 Underscore 的template()函数进行编译。返回一个可重用的绑定函数。当一个数据对象被传递给这个绑定函数时,任何与原始模板字符串中的绑定表达式匹配的属性都将在最终的计算输出中被替换。Underscore 使用 JavaScript 的with关键字神奇地将这些属性带入模板的范围。清单 16-23 展示了如何将一个简单的模板字符串绑定到一个数据对象,并展示了结果产生的 HTML。

Listing 16-23. Binding an Underscore Template

<!-- example-020/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<h1><%- title %></h1>' +

'<p class="synopsis"><%- synopsis %></p>';

// compile the string into a function

var compiledTemplate = _.template(markup);

// invoke the function with data to

// get the rendered string

var rendered = compiledTemplate({

title: 'Sunshine',

synopsis: 'A team of scientists are sent to re-ignite a dying sun.'

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

<div id="output">

<h1>Sunshine</h1>

<p class="synopsis">A team of scientists are sent to re-ignite a dying sun.</p>

</div>

一旦模板字符串被编译成函数,就可以用不同的数据调用它任意次,以产生不同的呈现标记。应用通常在页面加载期间(或者在应用启动期间,如果 Node.js 是运行时环境)将模板字符串编译成函数,然后在应用的生命周期内根据需要调用每个函数。如果模板字符串没有改变,就没有必要重新编译它们。

模板中的循环和其他任意 JavaScript

许多模板库都包含了常见模板工作的速记标记,比如迭代集合。为了保持其模板系统简洁,Underscore 放弃了语法上的优势,而是允许开发人员用简单有效的 JavaScript 编写模板循环。

在清单 16-24 中,通过在模板中使用 Underscore 的each()函数创建了一个无序的演员列表。这里有两件重要的事情需要注意。首先,普通 JavaScript 在 gator 标记代码块中进行评估。这些块是通过使用 gator 标记创建的,在开始标记中没有 Lodash 符号(例如,<% %>而不是<%- %>)。第二,each()循环从中间分开,有效的模板标记用于在列表项元素中呈现由循环本身创建的actor变量。最后,循环由右大括号、括号和分号结束,就像普通的 JavaScript 循环一样。

Listing 16-24. Looping in a Template

<!-- example-021/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<h1><%- title %></h1>' +

'<p class="synopsis"><%- synopsis %></p>' +

'<ul>' +

'<% _.each(actors, function (actor) { %>' +

'  <li><%- actor %></li>' +

'<% }); %>' +

'</ul>';

// compile the string into a function

var compiledTemplate = _.template(markup);

// invoke the function with data to

// get the rendered string

var rendered = compiledTemplate({

title: 'Sunshine',

synopsis: 'A team of scientists are sent to re-ignite a dying sun.',

actors: ['Cillian Murphy', 'Hiroyuki Sanada', 'Chris Evans']

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

<div id="output">

<h1>Sunshine</h1>

<p class="synopsis">A team of scientists are sent to re-ignite a dying sun.</p>

<ul>

<li>Cillian Murphy</li>

<li>Hiroyuki Sanada</li>

<li>Chris Evans</li>

</ul>

</div>

JavaScript 评估标记也可用于执行任意 JavaScript 代码。清单 16-25 中的模板根据影评人授予电影的 Y 个明星中的 X 个来计算电影的评分百分比。模板使用 Underscore 的内部print()函数在模板输出中呈现计算结果,这是 gator 标记插值的替代方法,有时用于更复杂的表达式中。

Listing 16-25. Arbitrary JavaScript Within a Template

<!-- example-022/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<p>' +

'<%- voted %> out of <%- total %> stars!' +

' (<% print((voted / total * 100).toFixed(0)) %>%)' +

'</p>';

var compiledTemplate = _.template(markup);

var rendered = compiledTemplate({

voted: 4, total: 5

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

<div id="output">

<p>4 out of 5 stars! (80%)</p>

</div>

Note

通常,在模板(应用的“视图”)中执行计算是不好的做法。相反,实际计算的值应该是传递给编译后的模板函数的数据的一部分。清单 16-25 仅用于演示目的。

没有鳄鱼标签的生活

在重要的模板中,Gator 标签可能有点难以控制。幸运的是,Underscore 允许开发人员用正则表达式改变模板标签的语法。将 Underscore 对象的templateSettings属性设置为键/值设置的散列会在页面(或 Node.js 进程)的生命周期内改变 Underscore 的行为,并影响所有呈现的模板。

清单 16-26 展示了如何将 Underscore 的鳄鱼标签语法改为更简洁的小胡子/车把语法。在这种情况下,三种不同类型的标记(求值、插值和转义插值)在全局设置对象上都被分配了一个正则表达式。

Listing 16-26. Changing Template Syntax

<!-- example-023/index.html -->

<div id="output"></div>

<script>

(function (_) {

_.templateSettings = {

// arbitrary JavaScript code blocks: {{ }}

evaluate: /\{\{(.+?)\}\}/g,

// unsafe string interpolation: {{= }}

interpolate: /\{\{=(.+?)\}\}/g,

// escaped string interpolation: {{- }}

escape: /\{\{-(.+?)\}\}/g

};

var markup =

'<h1>``{{- title }}

'<p class="synopsis">``{{- synopsis }}

'<ul>' +

'``{{``_.each(actors, function (actor) {``}}

'  <li>``{{- actor }}

'``{{``});``}}

'</ul>';

var compiledTemplate = _.template(markup);

var rendered = compiledTemplate({

title: 'Sunshine',

synopsis: 'A team of scientists are sent to re-ignite a dying sun.',

actors: ['Cillian Murphy', 'Hiroyuki Sanada', 'Chris Evans']

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

模板系统编译的任何标记现在都必须支持指定的 Mustache 语法。仍然包含 gator 标记的模板将无法正确呈现。

表 16-2 是将模板设置与语法和支持每种语法的正则表达式相匹配的方便参考。

表 16-2。

Global Template Settings

| 环境 | 模板语法 | 正则表达式 | | --- | --- | --- | | `evaluate` | `{{ ... }}` | `/{{(.+?)}}/g` | | `interpolate` | `{{= ... }}` | `/{{=(.+?)}}/g` | | `escape` | `{{- ... }}` | `/{{-(.+?)}}/g` |

访问模板中的数据对象

如前所述,Underscore 使用 JavaScript 的with关键字将模板范围内的数据对象属性作为“第一类”变量进行评估。但是对象本身也可能通过模板中的obj属性被引用。为了修改前面的例子,在清单 16-27 中,在试图计算百分比之前,模板在if/else块中测试数据属性obj.percent。如果数据对象上存在percent属性,则呈现该属性;否则,渲染计算值。

Listing 16-27. The “obj” Variable

<!-- example-024/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<%- voted %> out of <%- total %> stars!' +

'<% if (obj.percent) { %>' +

' (<%- obj.percent %>%)' +

'<% } else { %>' +

' (<% print((voted / total * 100).toFixed(0)) %>%)' +

'<% } %>';

var compiledTemplate = _.template(markup);

var rendered = compiledTemplate({

voted: 4, total: 5, percent: 80.2

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

作为一种微优化(或许也是一种安全特性),可以给作用域对象起一个名字,这样就可以完全避免使用with关键字。这使得模板函数运行得稍微快一些,但是也要求模板中的所有属性都作为命名数据对象的属性被引用。为了指定数据对象的名称,在编译模板时,可以将一个选项对象传递给 Underscore 的template()函数。这个对象的variable属性将分配数据对象的变量名,然后在模板中被引用。清单 16-28 展示了这个设置的实际应用。

Listing 16-28. Setting the Data Object’s Variable Name

<!-- example-025/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<%- movie.voted %> out of <%- movie.total %> stars!' +

'<% if (movie.percent) { %>' +

' (<%- movie.percent %>%)' +

'<% } else { %>' +

' (<% print((movie.voted / movie.total * 100).toFixed(0)) %>%)' +

'<% } %>';

var settings = {variable: 'movie'};

// settings is the *third* parameter

var compiledTemplate = _.template(markup, null, settings);

var rendered = compiledTemplate({

voted: 4, total: 5, percent: 80.1

});

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

Note

可以在 Underscore 的全局设置中设置variable属性。然而,给变量起一个好的和相关的名字是很重要的,所以根据上下文给变量命名更有意义。本节中的示例没有定义像dataitem这样的通用变量,而是使用变量名movie,并在编译电影模板时通过向template()传递一个设置对象来应用它。

默认模板数据

虽然不是模板系统的一部分,Underscore 的defaults()函数可以用来确保模板总是有默认数据。这将防止在数据对象缺少一个或多个引用属性的情况下绑定失败。defaults()函数的第一个参数是一个可能缺少属性的对象。任何后面的参数都可以是属性设置为默认值的对象,这将填充第一个对象上任何缺少的属性。返回值是表示所有参数的“合并”属性的对象。清单 16-29 展示了这种对丢失了synopsis属性的data对象的影响。当dataDEFAULTS对象被传递给defaults()函数时,返回的对象包含来自data的标题和来自DEFAULTS的概要。

Listing 16-29. Default Template Values

<!-- example-026/index.html -->

<div id="output"></div>

<script>

(function (_) {

var markup =

'<h1><%- title %></h1>' +

'<p class="synopsis"><%- synopsis %></p>';

// compile the string into a function

var compiledTemplate = _.template(markup);

var DEFAULTS = {

title: 'A Great Film',

synopsis: 'An epic hero defeats and evil villain and saves the world!'

};

var data = {

title: 'Lord of the Rings'

};

// fill in any missing data values with defaults

var merged = _.defaults(data, DEFAULTS);

var rendered = compiledTemplate(merged);

document.querySelector('#output').innerHTML = rendered;

}(window._));

</script>

如果多个默认对象被传递给defaults(),它们将被从头到尾评估。一旦在默认对象上发现缺少的属性,它将在任何后续默认对象上被忽略。

摘要

ECMAScript 的现代和未来实现已经为开发人员提供了大量关于原始类型的实用函数,如StringArrayObjectFunction。不幸的是,世界发展的速度比规范实现的速度要快,所以像 Underscore 和 Lodash 这样的库占据了开发人员需求和语言成熟度的交叉点。

通过超过 100 个实用函数和一个微模板系统,Underscore 使开发人员能够操作、转换和呈现对象和集合中的数据。Underscore 可以在浏览器和服务器环境中使用,并且没有依赖性。它可以用一个简单的script标签添加到网页中,或者作为 AMD 或 CommonJS 模块导入。Bower、npm、component 和 NuGet 等流行的包管理器都可以为开发人员选择的平台下载预构建的 Underscore 包。

Underscore 的强大特性集和无处不在的可用性使其成为 JavaScript 项目的理想且不引人注目的瑞士军刀。

相关资源

posted @ 2024-08-19 17:18  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报