单页-Web-应用-全-
单页 Web 应用(全)
原文:Single page web applications
译者:飞龙
第一部分. 介绍 SPA
在阅读这一页的时间里,将有 3500 万人分钟的时间被花费在等待传统网站页面加载上。这足以让好奇号飞往火星并返回 96 次。传统网站的生产力成本令人震惊,对企业来说可能是毁灭性的。一个慢速的网站可能会让用户离开你的网站——并进入那些微笑着欢迎竞争对手的口袋。
传统网站速度慢的一个原因是流行的 MVC 服务器框架专注于向一个本质上无知的客户端连续提供一页又一页的静态内容。例如,当我们点击传统网站幻灯片中的一条链接时,屏幕会闪白,并在几秒钟内重新加载:导航、广告、标题、文本和页脚都会再次渲染。然而,唯一改变的是幻灯片图片,也许还有描述文本。更糟糕的是,没有指示器表明页面上的某些元素何时变得可用。例如,有时一个链接可以在它出现在网页上时立即点击;而其他时候,我们必须等待重绘完成 100%加上五秒钟。这种缓慢、不一致且笨拙的体验对于一个日益复杂的 Web 消费者来说变得越来越无法接受。
准备学习另一种——我们甚至可以说更好的——开发 Web 应用的方法,即单页 Web 应用(SPA)。SPA 在浏览器中提供桌面应用。结果是高度响应式的体验,它让用户感到惊喜和愉悦,而不是困惑和烦恼。在第一部分中,我们了解到:
-
什么是单页应用以及它相较于传统网站的优势
-
如何使用 SPA 方法使我们的 Web 应用变得更加响应和吸引人
-
如何提高我们的 JavaScript 技能以进行 SPA 开发
-
如何构建一个示例 SPA
产品设计越来越被视为商业和企业级 Web 应用成功的关键因素。SPA 通常是提供最佳用户体验的最佳选择。因此,我们预计以用户为中心的设计需求将推动 SPA 的采用和复杂化。
第一章. 我们的第一个单页应用
本章涵盖
-
定义单页 Web 应用
-
比较最流行的单页应用平台——Java、Flash 和 JavaScript
-
编写我们的第一个 JavaScript 单页应用
-
使用 Chrome 开发者工具检查应用
-
探索单页应用的用户优势
本书面向至少具备一些 JavaScript、HTML 和 CSS 经验的 Web 开发者、架构师和产品经理。如果你从未涉足过 Web 开发,这本书并不适合你,尽管你仍然可以购买它(快去,爸爸需要一辆新车)。市面上有许多书籍都出色地教授初学者网站开发和设计,但这本书并不属于此类。
本书 确实 致力于成为使用 JavaScript 从头到尾设计和构建大规模单页网络应用(SPA)的指南。事实上,正如 图 1.1 所描绘的,我们使用 JavaScript 作为数据库、网络服务器 以及 浏览器应用的语言。
图 1.1. JavaScript 从头到尾

我们在过去六年中一直领导着众多大规模商业和企业级 SPA 的发展。在这段时间里,我们不断更新我们的实践以应对遇到的挑战。我们将这些实践分享在这本书中,因为它们帮助我们更快地开发、提供更好的用户体验、确保质量和改进团队沟通。
1.1. 定义、一点历史和一些重点
单页应用(SPA)是一种在浏览器中交付的应用,使用过程中不会重新加载页面。像所有应用一样,它的目的是帮助用户完成一项任务,例如“撰写文档”或“管理网络服务器”。我们可以将 SPA 视为从网络服务器加载的胖客户端。
1.1.1. 一点历史
单页应用(SPA)已经存在很长时间了。让我们看看一些早期的例子:
-
井字棋—
rintintin.colorado.edu/~epperson/Java/TicTacToe.html。嘿,我们并没有说这会很好看。这个应用挑战我们与一个强大而冷酷的电脑对手在井字棋游戏中一较高下。需要 Java 插件——请参阅www.java.com/en/download/index.jsp。您可能需要授予浏览器运行此小程序的权限。 -
Flash 空间人—
games.whomwah.com/spacelander.html。这是 Duncan Robertson 大约在 2001 年编写的一个早期 Flash 游戏。需要 Flash 插件——请参阅get.adobe.com/flashplayer/。 -
JavaScript 贷款计算器—
www.mcfedries.com/creatingawebpage/mortgage.htm。这个计算器似乎和 JavaScript 一样古老,但它运行得很好。不需要插件。
精明的读者——甚至是一些粗心大意的读者^([1])——会注意到我们提供了三个最受欢迎的单页应用(SPA)平台的例子:Java 小程序、Flash/Flex 和 JavaScript。同样的读者可能还会注意到,只有 JavaScript SPA 在使用过程中不需要第三方插件的额外开销或安全担忧。
¹ 如果您在吃胸前的薯片时阅读这一章,您就是粗心大意的。
今天,JavaScript SPA 通常是最好的选择之一。但 JavaScript 在大多数 SPA 应用中需要一段时间才能变得具有竞争力,甚至变得可行。让我们看看为什么。
1.1.2. 为什么 JavaScript SPA 用了这么久才出现?
Flash 和 Java 小程序到 2000 年已经发展得很好。Java 被用来通过浏览器提供复杂的应用程序,甚至是一个完整的办公套件.^([2]) Flash 已经成为提供丰富浏览器游戏和后来视频的平台。另一方面,JavaScript 仍然主要被限制在抵押贷款计算器、表单验证、悬停效果和弹出窗口等小应用中。问题是,我们无法依赖 JavaScript(或它使用的渲染方法)在流行的浏览器上提供一致的关键功能。尽管如此,JavaScript SPA 承诺在 Flash 和 Java 之上提供许多诱人的优势:
² Applix (VistaSource) Anywhere Office
-
无需插件—用户无需担心插件的安装、维护和操作系统兼容性问题即可访问应用程序。开发者也不必担心单独的安全模型,这可以减少开发和维护的烦恼.^([3])
³ 你能说出“同源策略”吗?如果你曾经用 Flash 或 Java 进行过开发,你几乎肯定熟悉这个挑战。
-
减少冗余—使用 JavaScript 和 HTML 的 SPA 应该比需要额外运行时环境的插件使用显著更少的资源。
-
单一客户端语言—网络架构师和大多数开发者必须了解许多语言和数据格式—HTML、CSS、JSON、XML、JavaScript、SQL、PHP/Java/Ruby/Perl 等等。为什么要在我们的页面上用 Java 编写小程序,或者用 ActionScript 编写 Flash 应用程序,当我们已经在其他地方使用 JavaScript 呢?在客户端使用单一编程语言是一种减少复杂性的好方法。
-
更流畅和互动的页面—我们都见过网页上的 Flash 或 Java 应用程序。通常,应用程序显示在某个地方的框中,许多细节与围绕它的 HTML 元素不同:图形小部件不同,右键单击不同,声音不同,与页面的其他部分的交互有限。使用 JavaScript SPA,整个浏览器窗口就是应用程序界面。
随着 JavaScript 的成熟,其大多数弱点要么已被修复,要么得到了缓解,其优势的价值也增加了:
-
网页浏览器是世界上最广泛使用的应用程序—许多人始终打开浏览器窗口,并全天使用它。访问 JavaScript 应用程序只需多点击一个书签即可。
-
浏览器中的 JavaScript 是全球最广泛分布的执行环境之一—到 2011 年 12 月,每天有近一百万台 Android 和 iOS 移动设备被激活。这些设备都内置了强大的 JavaScript 执行环境。在过去三年中,全球已发货超过十亿的强大 JavaScript 实现,这些实现被用于手机、平板电脑、笔记本电脑和台式计算机。
-
JavaScript 的部署非常简单——一个 JavaScript 应用程序可以通过托管在 HTTP 服务器上,向超过十亿的网页用户提供服务。
-
JavaScript 对于跨平台开发非常有用——现在我们可以使用 Windows、Mac OS X 或 Linux 创建 SPAs,并且我们可以将单个应用程序部署到所有桌面机器上,以及平板电脑和智能手机。我们可以感谢浏览器之间标准实现的趋同,以及像 jQuery 和 PhoneGap 这样的成熟库,它们平滑了不一致性。
-
JavaScript 意外地快,有时甚至可以与编译型语言相媲美——其加速得益于 Mozilla Firefox、Google Chrome、Opera 和 Microsoft 之间持续的激烈竞争。现代 JavaScript 实现享受着高级优化,如 JIT 编译到原生机器代码、分支预测、类型推断和多线程.^([4])
⁴ 请参阅
iq12.com/blog/as3-benchmark/和jacksondunstan.com/articles/1636,了解与 Flash ActionScript 3 的比较。 -
JavaScript 已经发展出了高级功能——这些功能包括原生 JSON 对象、原生 jQuery 风格的选择器以及更一致的 AJAX 功能。使用成熟的库如 Strophe 和 Socket.IO,推送消息变得容易得多。
-
HTML5、SVG 和 CSS3 标准和支持已经取得了进步——这些进步使得可以渲染出像素级的图形,其速度和质量可以与 Java 或 Flash 相媲美。
-
JavaScript 可以在整个 Web 项目中使用——现在我们可以使用出色的 Node.js Web 服务器和像 CouchDB 或 MongoDB 这样的数据存储,它们都使用 JSON,这是一种 JavaScript 数据格式。我们甚至可以在服务器和浏览器之间共享库。
-
桌面、笔记本电脑甚至移动设备都变得更加强大——多核处理器的普遍性和数 GB 的 RAM 意味着以前在服务器上完成的处理现在可以分配到客户端浏览器。
由于这些优势,JavaScript SPAs 正变得越来越受欢迎,对经验丰富的 JavaScript 开发人员和架构师的需求也在不断增长。曾经为许多操作系统(或 Java 或 Flash)开发的应用程序现在正以单个 JavaScript SPA 的形式交付。初创公司已经将 Node.js 作为首选的 Web 服务器,而移动应用开发者正在使用 JavaScript 和 PhoneGap,通过单一代码库为多个移动平台创建“原生”应用程序。
JavaScript 并不完美,我们不必走得太远就能找到遗漏、不一致和其他不喜欢的方面。但这是所有语言的共性。一旦你熟悉了其核心概念,遵循最佳实践,并了解哪些部分需要避免,JavaScript 开发可以变得愉快且富有成效。
生成的 JavaScript:一个目的地,两条路径
我们发现直接使用 JavaScript 开发 SPA 更容易。我们称之为原生JavaScript SPA。另一种出人意料地受欢迎的方法是使用生成JavaScript,其中开发者用另一种语言编写代码,然后将其转换为 JavaScript。这种转换发生在运行时或单独的生成阶段。显著的 JavaScript 生成器包括:
-
Google Web Toolkit (GWT)—见
code.google.com/webtoolkit/。GWT 将 Java 代码生成 JavaScript。 -
Cappuccino—见
cappuccino.org/。Cappuccino 使用 Objective-J,这是 Mac OS X 中 Objective-C 语言的克隆。Cappuccino 本身是 Cocoa 应用程序框架的移植,同样来自 OS X。 -
CoffeeScript—见
coffeescript.org/。CoffeeScript 将一种提供一些语法糖的自定义语言转换为 JavaScript。
由于谷歌使用 GWT(Google Web Toolkit)为 Blogger、Google Groups 以及许多其他网站提供服务,我们可以安全地说,生成的 JavaScript SPA 被广泛使用。这引发了一个问题:为什么要在一种高级语言中编写代码,然后再将其转换为另一种语言?以下是一些生成 JavaScript 仍然受欢迎的原因,以及为什么这些原因不像以前那样有说服力:
-
熟悉性—开发者可以使用更熟悉或更简单的语言。生成器和框架允许他们在不学习 JavaScript 的复杂性情况下进行开发。问题是,翻译过程中最终会丢失一些东西。当这种情况发生时,开发者必须检查生成的 JavaScript 并理解它,以便正确地完成工作。我们觉得直接在 JavaScript 中工作比通过语言抽象层工作更有效。
-
框架—开发者欣赏 GWT 提供的为服务器和客户端构建的匹配库的统一系统。这是一个有说服力的论点,尤其是如果团队已经拥有大量专业知识和正在生产中的产品。
-
多个目标—开发者可以让生成器为多个目标编写代码,例如为 Internet Explorer 编写一个文件,为世界上其他浏览器编写另一个文件。虽然为不同的目标生成代码听起来不错,但我们认为部署单一 JavaScript 源代码给所有浏览器使用更为有效。多亏了浏览器实现的趋同以及成熟的跨浏览器库如 jQuery,现在编写一个无需修改即可在所有主要浏览器上运行的复杂 SPA(单页应用)变得容易得多。
-
成熟度——开发者认为 JavaScript 在大型应用开发中结构不足。然而,JavaScript 已经发展成为一个更好的语言,具有令人印象深刻的优点和可管理的缺点。来自强类型语言(如 Java)的开发者有时会感到缺乏类型安全是不可原谅的。而一些来自包容性框架(如 Ruby on Rails)的开发者则哀叹明显的结构缺失。幸运的是,我们可以通过代码验证工具、代码标准和成熟库的使用来缓解这些问题。
我们认为原生 JavaScript SPA 通常是今天更好的选择。这正是我们在本书中设计和构建的内容。
1.1.3. 我们的关注点
本书展示了如何使用 JavaScript 从头到尾开发引人入胜、健壮、可扩展和可维护的单页应用程序(SPAs)。^([5]) 除非另有说明,从现在起当我们提到 SPA 时,我们指的是原生 JavaScript SPA,其中业务和表示逻辑直接用 JavaScript 编写并由浏览器执行。这种 JavaScript 使用 HTML5、CSS3、Canvas 或 SVG 等技术渲染界面。
⁵ 这本书的另一个标题可能是 使用最佳实践构建单页 Web 应用程序。但这样似乎太冗长了。
SPAs 可以使用任何数量的服务器技术。由于大量网络应用迁移到浏览器,服务器需求通常显著减少。图 1.2 说明了业务逻辑和 HTML 生成如何从服务器迁移到客户端。
图 1.2. 数据库、服务器和客户端的责任

我们在第七章和第八章中关注后端,在那里我们使用 JavaScript 作为控制语言的网络服务器和数据库。你可能没有这样的选择,或者可能更喜欢不同的后端。没关系——本书中使用的绝大多数 SPA 概念和技术无论你使用什么后端技术都能很好地工作。但如果你想要使用端到端的 JavaScript,我们为你提供了解决方案。
我们的客户端库包括用于 DOM 操作的 jQuery,以及用于历史管理和事件处理的插件。我们使用 TaffyDB2 提供高性能、以数据为中心的模型。Socket.IO 在 Web 服务器和客户端之间提供无缝的近实时消息传递。在服务器上,我们使用 Node.js 作为基于事件的网络服务器。Node.js 使用 Google V8 JavaScript 引擎,擅长处理数万个并发连接。我们还在 Web 服务器上使用 Socket.IO。我们的数据库是 MongoDB,这是一个使用 JavaScript 原生数据格式 JSON 存储数据的 NoSQL 数据库,它还具有 JavaScript API 和命令行界面。所有这些都是经过验证且流行的解决方案。
与传统的网站相比,SPA 开发需要更大规模的 JavaScript 编码,因为大部分应用程序逻辑从服务器移动到浏览器。单个 SPA 的开发可能需要许多开发者同时编码,并可能导致超过 10 万行代码。以前为服务器端开发保留的惯例和纪律现在在这个规模上变得必不可少。另一方面,服务器软件被简化,并降级为身份验证、验证和数据服务。在我们继续举例时,请记住这一点。
1.2. 构建我们的第一个 SPA
现在是时候开发一个 SPA 了。我们将使用最佳实践,并在进行中解释它们。
1.2.1. 定义目标
我们第一个 SPA 的目标是提供一个位于浏览器窗口右下角的聊天滑块,类似于你在 Gmail 或 Facebook 上可能看到的一个。当我们加载应用程序时,滑块将被收起;当我们点击滑块时,它将展开,如图 1.3 所示。再次点击将收起它。
图 1.3. 聊天滑块的收起和展开

SPAs 通常除了打开和关闭聊天滑块之外,还会做许多其他事情——比如发送和接收聊天消息。为了使这个介绍相对简单和简短,我们将省略这些烦人的细节。用一句名言来歪曲,一天之内是无法征服 SPAs 的。不必担心,我们将在第六章和第八章中回到发送和检索消息。
在接下来的几节中,我们将为 SPA 开发设置一个文件,介绍一些我们最喜欢的工具,开发聊天滑块的代码,并强调一些最佳实践。我们在这里给了你很多要吸收的东西,并不期望你现在就能理解一切——尤其是我们使用的某些 JavaScript 技巧。在接下来的几章中,我们将对每个这些主题有更多要说的,但现在,放松,不要为小事烦恼,了解这片土地的情况。
1.2.2. 开始文件结构
我们将在一个文件 spa.html 中创建我们的应用程序,只使用 jQuery 作为我们的一个外部库。通常,最好为 CSS 和 JavaScript 使用单独的文件,但从一个文件开始对于开发和示例来说很方便。我们首先定义我们将放置样式和 JavaScript 的位置。我们还将添加一个<div>容器,我们的应用程序将在这里写入 HTML 实体,如图 1.1 所示:
列表 1.1. 浅尝辄止——spa.html

现在我们有了准备好的文件,让我们设置 Chrome 开发者工具来检查应用程序的当前状态。
1.2.3. 设置 Chrome 开发者工具
让我们使用 Google Chrome 打开我们的列表——spa.html。我们应该看到一个空白的浏览器窗口,因为我们还没有添加任何内容。但是,在幕后正在进行活动。让我们使用 Chrome 开发者工具来检查它们。
我们可以通过点击 Chrome 右上角的扳手图标,选择工具,然后开发者工具(菜单 > 工具 > 开发者工具)来打开 Chrome 开发者工具。这将显示开发者工具,如图 1.4 所示。如果我们看不到 JavaScript 控制台,我们可以通过点击左下角的激活控制台按钮来显示它。控制台应该是空的,这意味着我们没有 JavaScript 警告或错误。这是好的,因为我们目前没有 JavaScript。控制台上方的元素部分显示了我们的页面 HTML 和结构。
图 1.4. Google Chrome 开发者工具

虽然我们在本书中使用了 Chrome 开发者工具,但其他浏览器也有类似的功能。例如,Firefox 有 Firebug,而 IE 和 Safari 都提供了自己的开发者工具版本。
当我们在本书中呈现列表时,我们通常会使用 Chrome 开发者工具来确保我们的 HTML、CSS 和 JavaScript 都能很好地协同工作。现在让我们创建我们的 HTML 和 CSS。
1.2.4. 开发 HTML 和 CSS
我们需要在 HTML 中添加一个单独的聊天滑块容器。让我们从在 spa.html 文件的 <style> 部分中设置容器样式开始。以下列表显示了 <style> 部分的调整:
列表 1.2. HTML 和 CSS—spa.html


当我们在浏览器中打开 spa.html 时,我们应该看到滑块收起,如图 1.5 所示。我们使用的是液体布局,其中界面适应显示大小,滑块始终锚定在右下角。我们没有为我们的容器添加边框,因为它们会增加容器宽度,可能会阻碍开发,因为我们必须调整容器大小以适应这些边框。在创建和验证基本布局之后添加边框很方便,就像我们在后面的章节中所做的那样。
图 1.5. 聊天滑块收起—spa.html

现在我们已经放置了视觉元素,是时候使用 JavaScript 使页面交互了。
1.2.5. 添加 JavaScript
我们希望在我们的 JavaScript 中采用最佳实践。一个将有所帮助的工具是 Douglas Crockford 编写的 JSLint。JSLint 是一个 JavaScript 验证器,确保我们的代码不会违反许多合理的 JavaScript 最佳实践。我们还希望使用 jQuery,这是由 John Resig 编写的文档对象模型 (DOM) 工具包。jQuery 提供了简单的跨浏览器工具,可以轻松实现滑块动画。
在我们开始编写 JavaScript 之前,让我们概述一下我们想要做什么。我们的第一个脚本标签将加载 jQuery 库。我们的第二个脚本标签将包含 我们的 JavaScript,我们将将其分为三个部分:
-
一个声明我们 JSLint 设置的标题。
-
一个名为
spa的函数,用于创建和管理聊天滑块。 -
一条在浏览器文档对象模型 (DOM) 准备就绪时启动
spa函数的行。
让我们更仔细地看看 spa 函数需要做什么。根据经验,我们知道我们想要一个声明模块变量和包含配置常数的部分。我们需要一个切换聊天滑块的功能。我们还需要一个接收用户点击事件并调用切换函数的功能。最后,我们需要一个初始化应用程序状态的功能。让我们更详细地绘制一个大纲:
列表 1.3. JavaScript 开发,第一次遍历——spa.html
/* jslint settings */
// Module /spa/
// Provides chat slider capability
// Module scope variables
// Set constants
// Declare all other module scope variables
// DOM method /toggleSlider/
// alternates slider height
// Event handler /onClickSlider/
// receives click event and calls toggleSlider
// Public method /initModule/
// sets initial state and provides feature
// render HTML
// initialize slider height and title
// bind the user click event to the event handler
// Start spa once DOM is ready
这是一个良好的开始!让我们保持注释不变,并添加我们的代码。我们为了清晰起见,将注释用粗体表示。
列表 1.4. JavaScript 开发,第二次遍历——spa.html
/* jslint settings */
// Module /spa/
// Provides chat slider capability
//
var spa = (function ( $ ) {
// Module scope variables
var
// Set constants
configMap = { },
// Declare all other module scope variables
$chatSlider,
toggleSlider, onClickSlider, initModule;
// DOM method /toggleSlider/
// alternates slider height
//
toggleSlider = function () {};
// Event handler /onClickSlider/
// receives click event and calls toggleSlider
//
onClickSlider = function ( event ) {};
// Public method /initModule/
// sets initial state and provides feature
//
initModule = function ( $container ) {
// render HTML
// initialize slider height and title
// bind the user click event to the event handler
};
}());
// Start spa once DOM is ready
现在,让我们再次审视 spa.html,如 列表 1.5 所示。我们加载 jQuery 库,然后包含我们自己的 JavaScript,其中包含我们的 JSLint 设置、spa 模块以及一条在 DOM 准备就绪后启动模块的命令。spa 模块现在完全可用。如果你一开始没有完全理解,不要担心——这里有很多内容,我们将在接下来的章节中更详细地介绍。这只是一个例子,展示你可以做什么:
列表 1.5. JavaScript 开发,第三次遍历——spa.html


不要过于担心 JSLint 验证,我们将在接下来的章节中详细说明其用法。但现在,我们将介绍一些值得注意的概念。首先,脚本顶部的注释设置了我们的验证偏好。其次,这个脚本和设置没有错误或警告地通过了验证。最后,JSLint 要求在函数使用之前声明它们,因此脚本从下到上读取,最高级别的函数在最后。
我们使用 jQuery,因为它为基本的 JavaScript 功能提供了优化且跨浏览器的实用工具:DOM 选择、遍历和操作;AJAX 方法;以及事件。例如,jQuery 的 $(selector).animate(...) 方法提供了一种简单的方式来执行其他情况下相当复杂的事情:在指定的时间内将聊天滑块的长度从收缩状态动画到扩展状态(反之亦然)。这种运动开始缓慢,加速,然后减速至停止。这种类型的运动——称为 缓动——需要了解帧率计算、三角函数以及在不同流行浏览器中的实现细节。如果我们自己编写,将需要数十行额外的代码。
$jQuery(document).ready(function) 也为我们节省了很多工作。它只在实际可以操作 DOM 之后才运行函数。传统上,我们使用 window.onload 事件来做这件事。由于各种原因,window.onload 对于更复杂的前端应用(SPAs)来说并不是一个高效的解决方案——尽管在这里影响不大。但是,编写适用于所有浏览器的正确代码是痛苦且冗长的.^([6])
如前例所示,jQuery 的好处通常远远超过其成本。在这种情况下,它缩短了我们的开发时间,减少了脚本长度,并提供了强大的跨浏览器兼容性。使用它的成本介于低和可忽略之间,因为它的库在最小化时很小,而且用户可能已经在他们的设备上缓存了它。图 1.6 显示了完成的聊天滑块。
图 1.6. 完成的聊天滑块在 spa.html 中的动作

现在我们已经完成了聊天滑块的第一种实现,让我们看看应用程序实际上是如何使用 Chrome 开发者工具工作的。
1.2.6. 使用 Chrome 开发者工具检查我们的应用程序
如果你熟悉使用 Chrome 开发者工具,你可以跳过这一部分。如果不熟悉,我们强烈建议你在家里尝试。
让我们打开我们的文件 spa.html,在 Chrome 中。加载后,让我们立即打开开发者工具(菜单 > 工具 > 开发者工具)。
你可能首先注意到的是,我们的模块如何将 DOM 改变以包含<div class="spa-slider" ... >元素,如图 1.7 所示。随着我们继续,我们的应用程序将添加更多类似这样的动态元素。
图 1.7. 检查元素—spa.html

我们可以通过点击开发者工具顶部菜单中的“源”按钮来探索 JavaScript 执行。然后选择包含 JavaScript 的文件,如图 1.8 所示。
图 1.8. 选择源文件—spa.html

在后面的章节中,我们将把我们的 JavaScript 放入单独的文件中。但在这个例子中,它就像图 1.9 所示的那样在我们的 HTML 文件中。我们需要向下滚动以找到我们想要检查的 JavaScript。
图 1.9. 查看源文件—spa.html

当我们导航到第 76 行时,我们应该看到一个if语句,如图 1.10 所示。我们希望在执行此语句之前检查代码,因此我们点击左侧边缘以添加断点。每当 JavaScript 解释器在脚本中到达此行时,它将暂停,这样我们就可以检查元素和变量,更好地理解正在发生的事情。
图 1.10. 设置断点—spa.html

现在,让我们回到浏览器并点击滑块。我们会看到 JavaScript 在行 76 的红色箭头处暂停,如图 1.11 所示。当应用程序暂停时,我们可以检查变量和元素。我们可以打开控制台部分,输入各种变量并按回车键以查看它们在暂停状态下的值。我们看到if语句的条件为真(slider_height是 16,configMap.retracted_height也是 16),我们甚至可以检查像configMap对象这样的复杂变量,如控制台底部所示。当我们完成检查后,我们可以通过点击行 76 的左侧边缘来移除断点,然后点击右上角的继续按钮(在监视表达式上方)。
图 1.11. 在断点处检查值—spa.html

一旦我们点击继续,脚本将从行 76 继续执行并完成切换滑块。让我们回到元素标签页,查看 DOM 如何变化,如图 1.12 所示。在这张图中,我们可以看到由spa-slider类提供的 CSS height属性(见右下角的匹配 CSS 规则)已被元素样式覆盖(元素样式比来自类或 ID 的样式具有更高的优先级)。如果我们再次点击滑块,我们可以实时观察高度的变化,因为滑块在收缩。
图 1.12. 查看 DOM 变化—spa.html

我们对 Chrome 开发者工具的简要介绍仅展示了它们帮助我们理解和改变应用程序“内部”发生的事情能力的一小部分。我们将继续使用这些工具来开发这个应用程序,并建议您花些时间阅读mng.bz/PzIJ上的在线手册。这是值得花时间的事情。
1.3. 一个编写良好的 SPA 的用户优势
现在我们已经构建了我们的第一个 SPA,让我们考虑 SPA 相对于传统网站的主要优势:它提供了一个实质上更吸引人的用户体验。SPA 可以提供两个世界的最佳结合:桌面应用程序的即时性和网站的便携性和可访问性。
-
一个 SPA 可以像桌面应用程序一样渲染—SPA 只会在需要时重绘界面中需要改变的部分。相比之下,传统的网站在许多用户操作时都会重新绘制整个页面,导致浏览器从服务器检索并重新绘制页面上的所有内容时出现暂停和“闪烁”。如果页面很大、服务器繁忙或网络连接慢,这种闪烁可能需要几秒钟或更长时间,用户不得不猜测页面何时可以再次使用。与 SPA 的快速渲染和即时反馈相比,这是一种糟糕的体验。
-
一个 SPA 可以像桌面应用程序一样响应——SPA 通过尽可能将工作(临时)数据和处理从服务器移动到浏览器来最小化响应时间。SPA 拥有做出大多数决策所需的数据和业务逻辑,因此可以快速做出决策。只有数据验证、身份验证和永久存储必须保留在服务器上,原因我们在第六章(kindle_split_017.html#ch06)到第八章(kindle_split_020.html#ch08)中讨论。传统的网站大多数应用程序逻辑都在服务器上,用户必须等待请求/响应/重绘周期来响应他们的输入。这可能会花费几秒钟,而 SPA 的响应几乎是即时的。
-
一个 SPA 可以像桌面应用程序一样通知用户其状态——当一个 SPA 确实需要等待服务器时,它可以动态渲染进度条或忙碌指示器,这样用户就不会因为延迟而感到困惑。相比之下,传统的网站,用户实际上必须猜测页面何时加载和可用。
-
一个 SPA 几乎像网站一样普遍可访问——与大多数桌面应用程序不同,用户可以从任何网络连接和良好的浏览器访问 SPA。今天,这个列表包括智能手机、平板电脑、电视、笔记本电脑和台式电脑。
-
一个 SPA 可以像网站一样即时更新和分发——用户不需要做任何事情就能意识到这些好处——当他们重新加载浏览器时,它就会工作。维护多个并发软件版本的麻烦在很大程度上被消除了。7 作者曾参与过一天内构建和更新多次的 SPA。桌面应用程序通常需要下载和行政访问权限来安装新版本,版本之间的间隔可能是几个月或几年。
⁷ 但并非完全如此:如果服务器-客户端数据交换格式发生变化,而许多用户在他们的浏览器中加载了先前的软件版本,会发生什么?这可以通过一些预先考虑得到解决。
-
一个单页应用(SPA)就像网站一样跨平台——与大多数桌面应用程序不同,一个编写良好的 SPA 可以在提供现代 HTML5 浏览器的任何操作系统上运行。虽然这通常被认为是开发者的好处,但对于拥有多种设备组合的用户来说,这极其有用——比如在工作时使用 Windows,在家中使用 Mac,使用 Linux 服务器,使用 Android 手机,以及使用亚马逊平板电脑。
所有这些好处意味着你可能希望将你的下一个应用程序制作成 SPA。那些每次点击后都会重新渲染整个页面的笨拙网站往往会让越来越复杂的用户感到疏远。一个编写良好的 SPA 的沟通和响应式界面,加上互联网的易用性,有助于让我们的客户留在他们应该的地方——使用我们的产品。
1.4. 摘要
单页应用已经存在一段时间了。直到最近,Flash 和 Java 一直是使用最广泛的 SPA 客户端平台,因为它们的性能、速度和一致性超过了 JavaScript 和浏览器渲染。但最近,JavaScript 和浏览器渲染已经达到了一个转折点,它们克服了最大的缺陷,同时提供了相对于其他客户端平台的显著优势。
我们专注于使用原生 JavaScript 和浏览器渲染创建 SPA,当我们提到 SPA 时,除非另有说明,否则我们指的是原生 JavaScript SPA。我们的 SPA 工具链包括 jQuery、TaffyDB2、Node.js、Socket.IO 和 MongoDB。所有这些都是经过验证的、流行的解决方案。你可以选择使用这些技术的替代方案,但 SPA 的基本结构将不会因具体技术决策而改变。
我们开发的简单聊天滑动应用演示了 JavaScript SPA 的许多功能。它对用户输入立即做出响应,并且它使用存储在客户端的数据而不是服务器来做出决策。我们使用 JSLint 确保我们的应用程序不包含常见的 JavaScript 错误。我们还使用 jQuery 选择和动画 DOM,并处理用户点击滑动条时的事件。我们探讨了使用 Chrome 开发者工具来帮助我们理解应用程序的工作方式。
一个单页应用(SPA)可以提供两种世界的最佳结合——桌面应用的即时性和网站的便携性和可访问性。JavaScript SPA 可在支持现代网络浏览器且不要求任何专有插件的超过十亿台设备上运行。只需稍加努力,它就可以支持运行多种不同操作系统的桌面、平板电脑和智能手机。SPA 容易更新和分发,通常无需用户采取任何行动。所有这些优势都解释了为什么你可能想要将你的下一个应用做成 SPA。
在下一章中,我们将探讨一些对于 SPA 开发至关重要但经常被忽视或误解的 JavaScript 概念。然后,我们将在此基础上改进和扩展本章中开发的示例 SPA。
第二章:重新介绍 JavaScript
本章涵盖
-
变量作用域、函数提升和执行上下文对象
-
解释变量作用域链及其使用原因
-
使用原型创建 JavaScript 对象
-
编写自执行的匿名函数
-
使用模块模式和私有变量
-
利用闭包以娱乐和盈利
本章回顾了我们需要了解的独特的 JavaScript 概念,如果我们想要构建一个具有重大规模的本地 JavaScript 单页应用程序。来自第一章的列表 2.1 的代码片段展示了我们将要讨论的概念。如果你理解了所有这些 如何 和 为什么 概念,那么你可以快速浏览或跳过本章,直接在第三章中开始构建 SPA。
要在家中跟随学习,你可以将本章中的所有列表复制粘贴到 Chrome 开发工具的控制台中,然后按 Return 键来查看它们的执行结果。我们强烈鼓励你加入这个乐趣。
列表 2.1. 应用 JavaScript


编码标准和 JavaScript 语法
对于初学者来说,JavaScript 的语法可能有些难以理解。在继续学习之前,了解变量声明块和对象字面量非常重要。如果你已经熟悉它们,可以自由地跳过这个侧边栏。关于我们认为重要的 JavaScript 语法和良好的编码标准,请参阅附录 A。
变量声明块
var spa = "Hello world!";
JavaScript 变量的声明遵循 var 关键字。一个变量可以包含任何类型的数据:数组、整数、浮点数、字符串等等。变量类型没有指定,因此 JavaScript 被认为是一种 弱类型 语言。即使一个值已经赋给变量,通过赋值一个不同类型的值,值的类型也可以改变,因此它也被认为是一种 动态 语言。
可以通过在 var 关键字后用逗号分隔来链式地声明和赋值 JavaScript 变量:
var book, shopping_cart,
spa = "Hello world!",
purchase_book = true,
tell_friends = true,
give_5_star_rating_on_amazon = true,
leave_mean_comment = false;
关于变量声明块的最佳格式有许多观点。我们更喜欢将未定义的变量声明放在顶部,然后是带有定义的变量声明。我们也更喜欢在行尾使用逗号,如所示,但我们并不对此过于执着,JavaScript 引擎也不关心这一点。
对象字面量
一个 对象字面量 是由逗号分隔的属性列表定义的对象,这些属性包含在大括号内。属性使用冒号而不是等号来设置。对象字面量还可以包含数组,数组是由方括号包围的逗号分隔的成员列表。可以通过将函数设置为某个属性的值来定义方法:
var spa = {
title: "Single Page Web Applications", //attribute
authors: [ "Mike Mikowski", "Josh Powell" ], //array
buy_now: function () { //function
console.log( "Book is purchased" );
}
}
在整本书中广泛使用了对象字面量和变量声明块。
2.1. 变量作用域
我们讨论的起点是变量的行为以及变量何时在或不在作用域内。
在 JavaScript 中,变量由函数作用域限定,它们要么是全局的,要么是局部的。全局变量在所有地方都可以访问,而局部变量只能在它们被声明的位置访问。在 JavaScript 中,定义变量作用域的唯一块是函数。仅此而已。全局变量是在函数外部定义的,而局部变量是在函数内部定义的。简单,对吧?
另一种看待方式是,函数就像一个监狱,函数内部定义的变量就像囚犯。就像监狱包含囚犯并且不允许他们逃出监狱围墙一样,函数包含局部变量并且不允许它们逃出函数之外,如下面的代码所示:

JavaScript 1.7, 1.8, 1.9+ 和块作用域
JavaScript 1.7 引入了一个新的块作用域构造函数,即let语句。不幸的是,尽管存在 JavaScript 1.7、1.8 和 1.9 的标准,但甚至 1.7 也没有在所有浏览器中得到一致的部署。直到浏览器兼容这些 JavaScript 更新之前,我们将假装 JavaScript 1.7+不存在。不过,让我们看看它是如何工作的。

要使用 JavaScript 1.7,将版本放在<script>标签的type属性中:
<script type="application/javascript;version=1.7">
这只是对 JavaScript 1.7+的一个简要介绍;还有很多其他的变化和新特性。
如果事情真的这么简单就好了。你可能会遇到的第一个 JavaScript 作用域问题是,在函数内部,只需省略var声明,就可以声明一个全局变量,如图 2.1 所示。并且与所有编程语言一样,全局变量几乎总是个坏主意。
图 2.1。如果你在函数中声明局部变量时忘记了 var 关键字,你将创建一个全局变量。


这并不好——不要让你的囚犯逃走。这种问题经常出现在我们忘记在for循环中声明计数器时使用var。尝试以下对prison函数的定义,一次一个:
// wrong
function prison () {
for( i = 0 ; i < 10; i++ ) {
//...
}
}
prison();
console.log( i ); // i is 10
delete window.i;
// permissible
function prison () {
for( var i = 0; i < 10; i++ ) {
//...
}
}
prison();
console.log( i ); // i is not defined
// best
function prison () {
var i;
for ( i = 0; i < 10; i++ ) {
// ...
}
}
prison();
console.log( i ); // i is not defined
我们更喜欢这个版本,因为将变量声明在函数顶部可以使它的作用域非常清晰。在for循环初始化器内部声明变量可能会让一些人误以为变量的作用域仅限于for循环,就像在其他一些语言中那样。
我们将这个逻辑扩展到解决和组合函数顶部声明的所有 JavaScript 声明和大多数赋值,以便变量的作用域清晰:
function prison() {
var prisoner = 'I am local!',
warden = 'I am local too!',
guards = 'I am local three!'
;
}
通过使用逗号合并局部变量定义,我们使它们易于查看,也许更重要的是,减少了打字错误意外地创建全局变量而不是局部变量的可能性。你也注意到了它们排得有多整齐吗?看看末尾的分号是如何在视觉上起到变量声明块闭合标签的作用的?我们在附录 A 中的 JavaScript 编码标准中讨论了这一点以及其他用于可读性和可理解性的 JavaScript 格式化方法。JavaScript 的另一个有趣特性,变量提升,与这种声明局部变量的方法有关。让我们接下来看看这一点。
2.2. 变量提升
当在 JavaScript 中声明一个变量时,其声明被认为是提升到其函数作用域的顶部,并且变量被赋予undefined的值。这导致变量在函数的任何地方声明都存在于整个函数中,尽管它的值在赋予值之前是未定义的,如图 2.2 所示。
图 2.2. JavaScript 变量声明被“提升”到它们出现的函数的开始处,但初始化保持原位。JavaScript 引擎实际上并没有重写代码:每次函数被调用时,声明都会被重新提升。


将图中的代码与尝试访问未在局部或全局作用域中声明的变量进行对比,这会导致运行时 JavaScript 错误,该错误将停止 JavaScript 在该语句处执行:

因为变量声明总是提升到你的函数作用域的顶部,所以最佳实践是在函数顶部声明你的变量,最好使用单个var语句。这与 JavaScript 的行为相匹配,并避免了我们在上一幅图中展示的那种混淆。

这种作用域和提升行为有时会结合在一起导致一些令人惊讶的行为。看看以下代码:

当执行监狱代码并请求console.log()中的regular_joe时,JavaScript 引擎首先检查regular_joe是否在局部作用域中声明。因为regular_joe没有在局部作用域中声明,JavaScript 引擎然后检查全局作用域,发现它在那里定义,并返回该值。这被称为沿着作用域链向上查找。但如果变量也在局部作用域中声明呢?

这看起来反直觉或令人困惑吗?让我们来看看 JavaScript 在幕后是如何处理提升的。
2.3. 高级变量提升和执行上下文对象
我们到目前为止所讨论的所有概念通常被认为是成为成功的 JavaScript 开发者所必需了解的。让我们更进一步,看看引擎内部发生了什么:你将成为少数几个真正理解 JavaScript 如何工作的开发者之一。我们将从 JavaScript 的一个更“神奇”的功能开始:变量和函数提升。
2.3.1. 提升
就像所有形式的魔法一样,当秘密被揭露时,技巧几乎让人失望。秘密在于当 JavaScript 引擎进入作用域时,它会遍历代码两次。在第一次遍历中,它初始化变量,在第二次遍历中执行代码。我知道,很简单;我不知道为什么通常不会用这些术语来描述。让我们更详细地了解一下 JavaScript 引擎在第一次遍历中做了什么,因为它有一些有趣的影响。
在第一次遍历中,JavaScript 引擎遍历代码并做三件事:
-
声明并初始化函数参数。
-
声明局部变量,包括分配给局部变量的匿名函数,但不会初始化它们。
-
声明并初始化函数。
列表 2.2. 第一次遍历

在第一次遍历中,不会将值分配给局部变量,因为可能需要执行代码来确定值,而第一次遍历不会执行代码。值分配给参数,因为确定参数值所需的任何代码都是在参数传递给函数之前运行的。
我们可以通过将它们与上一节末尾展示的函数提升代码进行比较来证明在第一次遍历中设置了参数的值。
列表 2.3. 变量在声明之前是未定义的

在监狱函数中声明regular_joe之前,它是未定义的,但如果regular_joe也被作为参数传递,它在声明之前就有值。
列表 2.4. 变量在声明之前有值

如果你感到头晕,那没关系。虽然我们解释了 JavaScript 引擎在执行函数时遍历两次,并且在第一次遍历中存储变量,但我们还没有看到它是如何存储变量的。了解 JavaScript 引擎如何存储变量可能会消除任何剩余的困惑。JavaScript 引擎将变量存储在称为执行上下文对象的对象上的属性中。
2.3.2. 执行上下文和执行上下文对象
每次函数被调用时,都会有一个新的执行上下文。执行上下文是一个概念,即运行中的函数的概念——它不是一个对象。就像在跑步或跳跃的上下文中考虑一名运动员一样。我们可以说一个跑步的运动员,而不是一个在跑步上下文中的运动员,就像我们可以说一个运行中的函数,但术语不是这样用的。我们说执行上下文。
执行上下文由函数执行期间发生的一切组成。这与函数声明是不同的,因为函数声明描述了函数执行时会发生什么。执行上下文就是函数的执行。
在函数中定义的所有变量和函数都被认为是执行上下文的一部分。执行上下文是开发人员在谈论函数的作用域时所指的一部分。如果变量在当前执行上下文中可访问,则认为该变量“在作用域内”,这另一种说法是,如果变量在函数运行时可以访问,则该变量在作用域内。
执行上下文中的变量和函数存储在执行上下文对象上,这是执行上下文 ECMA 标准的实现。执行上下文对象是 JavaScript 引擎中的一个对象,而不是 JavaScript 中可以直接访问的变量。虽然间接访问它很容易,因为每次使用变量时,你都是在访问执行上下文对象的属性。
之前我们讨论了 JavaScript 引擎如何对执行上下文进行两次遍历,声明和初始化变量,但变量存储在哪里呢?JavaScript 引擎将变量声明和初始化为执行上下文对象的属性。为了了解变量是如何存储的,请查看表 2.1。
表 2.1. 执行上下文对象
| 代码 | 执行上下文对象 |
|---|
|
var example_variable = "example",
another_example = "another";
|
{
example_variable: "example",
another_example: "another"
};
|
可能你从未听说过执行上下文对象。这并不是在网页开发者社区中常见讨论的话题,可能是因为执行上下文对象被埋藏在 JavaScript 的实现中,并且在开发过程中无法直接访问。
理解执行上下文对象对于理解本章的其余部分至关重要,因此让我们回顾一下执行上下文对象的生命周期以及创建它的 JavaScript 代码。
列表 2.5. 执行上下文对象——第一次遍历

现在已经声明和分配了参数和函数,并且已经声明了局部变量,接下来进行第二次遍历,执行 JavaScript 代码并分配局部变量的定义。
列表 2.6. 执行上下文对象——第二次遍历

这可以深入很多层,因为函数可以在执行上下文中被调用。在执行上下文中调用一个函数会在现有执行上下文中创建一个新的嵌套执行上下文。好吧,又开始头晕了;现在是时候看图了。请看图 2.3。
图 2.3. 调用一个函数创建一个执行上下文。

-
<script>标签内的所有内容都在全局执行上下文中。 -
调用
first_function在全局执行上下文中创建一个新的执行上下文。当first_function运行时,它可以访问其被调用的执行上下文中的变量。在这种情况下,first_function可以访问全局执行上下文中定义的变量以及first_function中定义的局部变量。这些变量被称为在作用域内。 -
调用
second_function在first_function执行上下文中创建一个新的执行上下文。second_function可以访问first_function执行上下文中的变量,因为它是在其中被调用的。second_function还可以访问全局执行上下文中的变量以及second_function中定义的局部变量。这些变量被称为在作用域内。 -
再次调用
second_function,这次是在全局执行上下文中。这个second_function无法访问first_function执行上下文中的变量,因为这次second_function不是在first_function执行上下文中被调用的。换句话说,当这次调用second_function时,它无法访问在first_function中定义的变量,因为它不是在first_function内部被调用的。这个second_function执行上下文也无法访问之前调用second_function时创建的变量,因为它们发生在不同的执行上下文中。换句话说,当你调用一个函数时,你无法访问上次调用该函数时创建的局部变量,下次调用该函数时你也不会访问这次函数调用中的局部变量。这些不可访问的变量被称为超出作用域。
JavaScript 引擎查找执行上下文对象以访问“在作用域内”的变量的顺序被称为作用域链,它与原型链一起描述了 JavaScript 访问变量及其属性的顺序。我们将在接下来的几节中讨论这些概念。
2.4。作用域链
到目前为止,我们主要将变量作用域限制在全局和局部。这是一个好的起点,但作用域更为复杂,如上一节中嵌套执行上下文讨论所暗示的。变量作用域更准确地可以看作是一个链,如图 2.4 所示。当寻找变量的定义时,JavaScript 引擎首先查看局部执行上下文对象。如果定义不在那里,它就会跳到作用域链上,到创建它的执行上下文中,并在该执行上下文对象中寻找变量定义,依此类推,直到找到定义或达到全局作用域。
图 2.4。在运行时,JavaScript 会搜索作用域层次结构以解析变量名。

让我们修改一个早期的例子来说明作用域链。在列表 2.7 中的代码将打印以下内容:
I am here to save the day!
regular_joe is assigned
undefined
列表 2.7. 作用域链示例——在每次调用作用域中定义regular_joe

在运行时,JavaScript 会搜索作用域层次结构以解析变量名。它从当前作用域开始,然后逐级向上到顶级作用域,即window(浏览器)或global(Node.js)对象。它使用找到的第一个匹配项并停止搜索。请注意,这表明深层嵌套作用域中的变量可以通过替换它们当前作用域中的变量来隐藏更全局作用域中的变量。这可能是好是坏,这取决于你是否期望它发生。在实际代码中,你应该尽可能使变量名唯一:我们刚刚查看的代码,其中相同的名称被引入到三个不同的嵌套作用域中,几乎不是最佳实践的例子,并且仅用于说明这一点。
在列表中,从三个作用域请求了一个名为regular_joe的变量的值:
-
列表中的最后一条语句
console.log(regular_joe)是在全局作用域中。JavaScript 开始搜索全局执行上下文对象的regular_joe属性。它找到一个值为I am here to save the day的属性并使用它。 -
在
supermax函数的最后一条语句中,我们看到console.log(regular_joe)。这个调用是在supermax执行上下文中进行的。JavaScript 开始搜索supermax执行上下文对象的regular_joe属性。它找到一个值为regular_joe is assigned的属性并使用它。 -
在
prison函数的最后一条语句中,我们看到console.log(regular_joe)。这个调用是在supermax执行上下文中的prison执行上下文中进行的。JavaScript 开始搜索prison执行上下文对象的regular_joe属性。它找到一个值为undefined的属性并使用它。
在这个例子中,regular_joe的值在所有三个作用域中都被定义。在代码的下一个版本中,在列表 2.8 中,我们只在全局作用域中定义它。现在程序会打印三次“我来到这里是为了拯救世界!”:
列表 2.8. 作用域链示例——只在单个作用域中定义regular_joe

重要的是要记住,当我们请求变量值时,结果可能来自作用域链中的任何位置。这取决于我们控制并理解我们的值是从链中的哪个位置派生出来的,否则我们可能会陷入痛苦的编码混乱。附录 A 中的 JavaScript 编码标准概述了帮助我们完成这项工作的多种技术,我们将随着我们的进展使用它们。
全局变量和窗口对象
我们通常所说的 全局 变量是执行环境顶级对象的属性。浏览器顶级对象是 window 对象;在 node.js 中,顶级对象称为 global,变量作用域的工作方式不同。
window 对象包含许多属性,这些属性本身包含对象、方法(onload、onresize、alert、close...)、DOM 元素(document、frames...)和其他变量。所有这些属性都是通过使用语法 window. 属性 来访问的。
window.onload = function(){
window.alert('window loaded');
}
node.js 的顶级对象称为 global。因为 node.js 是一个网络服务器而不是浏览器,所以可用的函数和属性与浏览器有很大的不同。
当浏览器中的 JavaScript 检查全局变量的存在时,它会查看 window 对象。
var regular_joe = 'Global variable';
console.log( regular_joe ); // 'Global variable'
console.log( window.regular_joe ); // 'Global variable'
console.log( regular_joe === window.regular_joe ); // true
JavaScript 有一个与作用域链平行的概念,称为 原型链,它定义了对象查找其属性定义的位置。让我们看看原型和原型链。
2.5. JavaScript 对象和原型链
JavaScript 对象是基于原型的,而今天其他最广泛使用的语言都使用基于类的对象。在基于类的系统中,一个对象是通过描述它将如何看起来来定义一个类的。在基于原型的系统中,我们创建一个看起来像我们希望所有该类型对象都看起来像的对象,然后告诉 JavaScript 引擎我们想要更多看起来像那样的对象。
不要过分夸张这个比喻,但如果建筑是一个基于类的系统,建筑师会绘制房子的蓝图,然后根据那个蓝图建造房子。如果建筑是基于原型的,建筑师会建造一栋房子,然后根据那栋房子建造其他房子。
让我们基于之前的囚犯例子来构建,并比较在每个系统中创建一个具有姓名、囚犯 ID、刑期年数和缓刑年数属性的单个囚犯需要什么。
表 2.2. 简单对象创建:类与原型
| 基于类 | 基于原型 |
|---|---|
| --- | --- |
|
public class Prisoner {
public int sentence = 4;
public int probation = 2;
public string name = "Joe";
public int id = 1234;
}
|
var prisoner = {
sentence : 4,
probation : 2,
name : 'Joe',
id : 1234
};
|
当只有一个对象实例时,基于原型的对象更简单、更快地编写。在基于类的系统中,你必须定义一个类,定义一个构造函数,然后实例化一个属于该类的对象。基于原型的对象简单地定义在原地。
基于原型的系统在简单的一个对象用例中表现出色,但它也可以支持具有相似特性的多个对象的更复杂用例。让我们以之前的囚犯例子为例,让代码改变囚犯的 name 和 id,但保持相同的预设刑期和缓刑期限。
正如你在 表 2.3 中可以看到,两种编程方式遵循相似的顺序,如果你习惯了类,适应原型不应该有很大困难。但问题在于细节,如果你来自基于类的系统,没有学习基于原型的方法就跳入 JavaScript,很容易在看似简单的事情上绊倒。让我们逐步分析这个顺序,看看我们能学到什么。
表 2.3. 多个对象:类与原型
| 基于类 | 基于原型 |
|---|
|
/* step 1 */
public class Prisoner {
public int sentence = 4;
public int probation = 2;
public string name;
public string id;
/* step 2 */
public Prisoner( string name,
string id ) {
this.name = name;
this.id = id;
}
}
/* step 3 */
Prisoner firstPrisoner
= new Prisoner("Joe","12A");
Prisoner secondPrisoner
= new Prisoner("Sam","2BC");
-
定义类
-
定义类构造函数
-
实例化对象
|
// * step 1 *
var proto = {
sentence : 4,
probation : 2
};
//* step 2 *
var Prisoner =
function(name, id){
this.name = name;
this.id = id;
};
//* step 3 *
Prisoner.prototype = proto;
// * step 4 *
var firstPrisoner =
new Prisoner('Joe','12A');
var secondPrisoner =
new Prisoner('Sam','2BC');
-
定义原型对象
-
定义对象构造函数
-
将构造函数链接到原型
-
实例化对象
|
在每个方法中,我们首先为我们的对象创建模板。在基于类的编程中,模板被称为 类,在基于原型的编程中,被称为 原型对象,但它们都服务于相同的目的:作为创建对象的框架。
其次,我们创建一个构造函数。在基于类的语言中,构造函数定义在类内部,因此在实例化对象时可以清楚地知道哪个构造函数与哪个类相对应。在 JavaScript 中,对象构造函数设置在原型之外,因此需要额外的步骤来将它们链接在一起。
最后,实例化对象。
JavaScript 使用 new 操作符与其基于原型的根源不同,可能是因为试图使其对熟悉基于类继承的开发者更易于理解。不幸的是,我们认为这模糊了问题,使得本应不熟悉(因此需要研究)的东西看起来很熟悉,导致开发者盲目尝试,直到遇到问题,花费数小时试图找出由将 JavaScript 错误地视为基于类系统而引起的错误。
作为使用 new 操作符的替代方案,已经开发并使用了 Object.create 方法,以给 JavaScript 对象创建添加更多基于原型的感觉。我们在整本书中仅使用 Object.create 方法。使用 Object.create 从 表 2.3 中的基于原型的示例创建囚犯看起来是这样的:
列表 2.9. 使用 Object.create 创建对象
var proto = {
sentence : 4,
probation : 2
};
var firstPrisoner = Object.create( proto );
firstPrisoner.name = 'Joe';
firstPrisoner.id = '12A';
var secondPrisoner = Object.create( proto );
secondPrisoner.name = 'Sam;
secondPrisoner.id = '2BC';
Object.create 方法接受一个原型作为参数并返回一个对象;通过这种方式,你可以在原型对象上定义公共属性和方法,并使用它来创建具有相同属性的多达多个对象。必须手动在每个对象上设置 name 和 id 是一件痛苦的事情,因为重复代码并不干净。作为替代方案,使用 Object.create 的一个常见模式是使用一个工厂函数来创建并返回最终对象。我们命名所有的工厂函数为 make<object_name>。
列表 2.10. 使用工厂函数与 Object.create 的结合

尽管在 JavaScript 中有几种创建对象的方法(这是另一个经常被争论的开发者话题),但通常认为使用 Object.create 是一种最佳实践。我们更喜欢这种方法,因为它清楚地说明了原型是如何设置的。不幸的是,new 操作符可能是创建对象最常用的方法。我们说不幸,因为它误导开发者认为语言是类基础的,并掩盖了基于原型的系统的细微差别。
针对旧浏览器的 Object.create
Object.create 在 IE 9+、Firefox 4+、Safari 5+ 和 Chrome 5+ 中工作。为了与旧浏览器兼容(我们指的是 IE 6、7 和 8!),我们需要在它不存在时定义 Object.create,并在已经实现了它的浏览器中保持不变。
// Cross-browser method to support Object.create()
var objectCreate = function ( arg ){
if ( ! arg ) { return {}; }
function obj() {};
obj.prototype = arg;
return new obj;
};
Object.create = Object.create || objectCreate;
现在我们已经看到了 JavaScript 如何使用原型来创建具有相同属性的对象,让我们深入原型链,并讨论 JavaScript 引擎是如何实现查找对象上属性值的。
2.5.1. 原型链
在基于原型的 JavaScript 中,对象的属性实现和功能与基于类的系统不同。尽管它们有足够的相似之处,大多数时候我们可以在没有明确理解的情况下过得去,但当差异显现出来时,我们就会在挫败感和生产力损失中付出代价。就像学习原型和类之间的基本差异值得一开始就学习一样,学习原型链也同样重要。
JavaScript 使用 原型链 来解析属性值。原型链描述了 JavaScript 引擎如何从对象到对象的原型,再到原型的原型,以定位对象的属性值。当我们请求一个对象的属性时,JavaScript 引擎首先在对象上直接查找该属性。如果在那里找不到属性,它会查看原型(存储在对象的 __proto__ 属性中)并查看原型是否包含请求的属性。
如果 JavaScript 引擎在对象的原型中找不到属性,它会检查原型的原型(原型本身也是一个对象,因此它也有一个原型)。依此类推。当 JavaScript 达到通用的 Object 原型时,原型链结束。如果 JavaScript 在链中的任何地方都找不到请求的属性,它将返回 undefined。随着 JavaScript 引擎沿着原型链检查,细节可能会变得复杂,但就本书的目的而言,我们只需要记住,如果一个属性在对象上找不到,则会检查原型。
沿着原型链的这种攀升类似于 JavaScript 引擎沿着作用域链攀升以找到变量定义的过程。正如你在 图 2.5 中可以看到的,这个概念几乎与 图 2.4 中的作用域链相同。
图 2.5. 在运行时,JavaScript 会搜索原型链以解析属性值。

您可以使用 __proto__ 属性手动遍历原型链。
var proto = {
sentence : 4,
probation : 2
};
var makePrisoner = function( name, id ) {
var prisoner = Object.create( proto );
prisoner.name = name;
prisoner.id = id;
return prisoner;
};
var firstPrisoner = makePrisoner( 'Joe', '12A' );
// The entire object, including properties of the prototype
// {"id": "12A", "name": "Joe", "probation": 2, "sentence": 4}
console.log( firstPrisoner );
// Just the prototype properties
// {"probation": 2, "sentence": 4}
console.log( firstPrisoner.__proto__ );
// The prototype is an object with a prototype. Since one
// wasn't set, the prototype is the generic object prototype,
// represented as empty curly braces.
// {}
console.log( firstPrisoner.__proto__.__proto__ );
// But the generic object prototype has no prototype
// null
console.log( firstPrisoner.__proto__.__proto__.__proto__ );
// and trying to get the prototype of null is an error
// "firstPrisoner.__proto__.__proto__.__proto__ is null"
console.log( firstPrisoner.__proto__.__proto__.__proto__.__proto__ );
如果我们请求 firstPrisoner.name,JavaScript 将直接在对象上找到囚犯的名字并返回 Joe。如果我们请求 firstPrisoner.sentence,JavaScript 不会在对象上找到属性,但会在原型中找到它并返回值 4。如果我们请求 firstPrisoner.toString(),我们将得到字符串 [object Object],因为基本 Object 原型有那个方法。最后,如果我们请求 firstPrisoner.hopeless,我们将得到 undefined,因为该属性在原型链中无处可寻。这些结果总结在 表 2.4 中。
表 2.4. 原型链


另一种演示原型链的方法是看看当我们改变由原型设置的对象上的值时会发生什么。
列表 2.11. 覆盖原型


那么,如果您在思考,当我们改变原型对象上的属性值时会发生什么?
原型变异
原型继承提供的一种强大且可能危险的行为是能够一次性突变基于原型的 所有 对象。对于那些熟悉静态变量的,原型上的属性就像从原型创建的对象的静态变量。让我们再次检查我们的代码。
var proto = {
sentence : 4,
probation : 2
};
var makePrisoner = function( name, id ) {
var prisoner = Object.create( proto );
prisoner.name = name;
prisoner.id = id;
return prisoner;
};
var firstPrisoner = makePrisoner( 'Joe', '12A' );
var secondPrisoner = makePrisoner( 'Sam', '2BC' );
如果在先前的示例之后检查 firstPrisoner 或 secondPrisoner,我们会发现继承属性 sentence 被设置为 4。
... // Both of these output '4'
console.log( firstPrisoner.sentence );
console.log( secondPrisoner.sentence );
如果我们改变原型对象,例如通过设置 proto.sentence = 5,那么在 之后 和 之前 创建的所有对象都将反映这个值。因此 firstPrisoner.sentence 和 secondPrisoner.sentence 被设置为 5。
...
proto.sentence = 5;
// Both of these output '5'
console.log( firstPrisoner.sentence );
console.log( secondPrisoner.sentence );
这种行为有优点也有缺点。重要的是,它在 JavaScript 环境中是一致的,并且我们知道这一点,因此我们可以相应地编写代码。
既然我们已经知道了对象是如何通过原型从其他对象继承属性的,那么让我们看看函数是如何工作的,因为它们可能表现得与您预期的不同。我们还将调查这些差异如何提供一些有用的功能,我们将在整本书中利用这些功能。
2.6. 函数——深入了解
函数是 JavaScript 中的第一等对象。它们可以被存储在变量中,赋予属性,甚至可以作为参数传递给函数调用。它们用于控制变量作用域并提供私有变量和方法。理解函数是理解 JavaScript 的关键之一,也是构建专业单页应用的重要基础。
2.6.1. 函数和匿名函数
JavaScript 中函数的一个重要特性是它是一个对象,就像任何其他对象一样。我们可能都见过像这样声明的 JavaScript 函数:
function prison () {}
但我们也可以将函数存储在变量中:
var prison = function prison () {};
我们可以通过将其制作成匿名函数来减少冗余(以及名称不匹配的机会),匿名函数只是对没有名称的函数声明的标签。以下是将匿名函数保存到局部变量的示例:
var prison = function () {};
保存到局部变量的函数是以我们预期函数调用的方式调用的:

2.6.2. 自执行匿名函数
在 JavaScript 中,我们经常面临的一个问题是,在全局作用域中定义的任何内容都可以在任何地方访问。有时你不想与每个人分享,也不想第三方库与你共享它们的内部变量,因为这很容易导致库之间的冲突,并造成难以诊断的问题。利用我们对函数的了解,我们可以将整个程序包裹在一个函数中,调用该函数,然后我们的变量就不会被任何外部代码访问。
var myApplication = function () {
var private_variable = "private";
};
myApplication();
//outputs an error saying the variable in undefined.
console.log( private_variable );
但这样做既啰嗦又别扭。如果我们可以不定义一个函数,将其保存到变量中,然后执行该函数,那就太好了。确实,有一个简写的方法。猜猜看……我们确实有!
(function () {
var private_variable = "private";
})();
//outputs an error saying the variable in undefined.
console.log( private_variable );
这被称为自执行匿名函数,因为它是在没有给出名称或保存到变量中定义的,并且立即执行。我们做的只是将函数用括号包围,然后使用一对括号来执行该函数,如表 2.5 所示。与显式调用的函数并排看时,语法并不令人惊讶。
表 2.5. 显式调用与自执行函数的比较。它们具有相同的效果:创建一个函数然后立即调用它
| 显式调用 | 自执行函数 |
|---|
|
var foo = function () {
// do something
};
foo();
|
(function () {
// do something
})();
|
自执行匿名函数用于包含变量作用域并防止变量泄漏到代码的其他地方。这可以用来创建不会与应用程序代码冲突的 JavaScript 插件,因为它们不会向全局命名空间添加任何变量。在下一节中,我们将演示一个更高级的使用案例,我们在整本书中都使用了它。它被称为模块模式,它允许我们定义私有变量和方法。首先,让我们看看在自执行匿名函数中变量作用域是如何工作的。如果这看起来很熟悉,那是因为它与之前完全一样,只是使用了新的语法:

在这里,全局命名空间被global_var变量污染,并存在与我们在代码或项目中使用的同名外部 JavaScript 库中的变量冲突的风险。全局命名空间的污染是你在 JavaScript 圈中可能会经常听到的术语——这就是它所指的。
自执行的匿名函数可以解决的一个问题是,全局变量可能会被第三方库覆盖,甚至可能无意中被你自己的代码覆盖。通过将一个值作为参数传递给自执行的匿名函数,你可以保证该参数的值将是你在该执行上下文中期望的,因为外部代码无法影响它。
首先,让我们看看如何将一个参数传递给一个自执行的匿名函数。

如果这个语法让你感到困惑,它只是将值 sandwich 作为第一个参数传递给匿名函数。让我们将这个语法与一个普通函数进行比较:
var eatFunction = function (what_to_eat) {
var sentence='I am going to eat a ' + what_to_eat;
console.log( sentence );
};
eatFunction( 'sandwich' );
// is the same as
(function (what_to_eat) {
var sentence = 'I am going to eat a ' + what_to_eat;
console.log(sentence);
})('sandwich');
唯一的不同之处在于已经移除了变量 eatFunction,并且函数定义被括号包围。
防止变量被覆盖的一个著名例子是使用 jQuery 和 Prototype JavaScript 库。它们都大量使用了单字符变量 $。如果你在应用程序中包含这两个库,那么最后添加的库将控制 $。将变量传递给自执行的匿名函数的技术可以用来确保 jQuery 可以在一段代码中使用 $ 变量。
对于这个例子,你应该知道 jQuery 和 $ 变量是彼此的别名。通过将 jQuery 变量传递给使用它作为 $ 参数的自执行的匿名函数,你可以防止 $ 被 Prototype 库接管:

2.6.3. 模块模式——将私有变量引入 JavaScript
能够将我们的应用程序包裹在一个自执行的匿名函数中,以保护我们的应用程序免受第三方库(以及我们自身)的影响,这真是太好了,但单页应用程序非常大,无法在一个文件中定义。如果有一种方法可以将该文件拆分成模块,每个模块都有自己的私有变量,那就太好了。好吧,你可以看到我要去哪里了...我们可以做到!
让我们看看如何将我们的代码拆分成多个文件,同时仍然利用自执行的匿名函数来控制变量的作用域。
你还没有习惯自执行的匿名函数的语法吗?
让我们再看看这个看起来很奇怪的语法:
var prison = (function() {
return "Mike is in prison";
})();
实际上等同于以下语法:
function makePrison() {
return 'Mike is in prison';
}
var prison = makePrison();
在这两种情况下,prison 的值都是“Mike is in prison”。唯一的实际区别是,不需要保存 makePrison 函数,因为它只需要使用一次,函数被创建并调用,而不需要保存到任何地方。

我们的自执行匿名函数立即执行,并返回一个具有 prisoner 和 sentence 属性的对象。匿名函数没有被存储在 prison 变量中,因为匿名函数已经执行了——匿名函数的返回值 被存储在 prison 变量中。
我们不是将变量 prisoner_name 和 jail_term 添加到全局作用域,而是只添加变量 prison。在更大的模块中,全局变量的减少可能更为显著。
我们对象的一个问题是,在自执行的匿名函数停止执行后,定义在匿名函数中的变量就消失了,因此它们不能被更改。prisoner_name 和 jail_term 不是保存到变量 prison 的对象的属性,因此不能通过这种方式访问。它们是用于在匿名函数返回的对象上定义 prisoner 和 sentence 属性的变量,并且这些属性可以通过 prison 变量访问。

prison.prisoner 没有更新是因为几个原因。首先,jail_term 不是 prison 对象或原型的属性;它是在创建对象并保存到 prison 变量时的执行上下文中的一个变量,并且由于函数已经执行完毕,该执行上下文不再存在。其次,这些属性在匿名函数执行时只设置一次,并且永远不会更新。为了使它们更新,我们必须将这些属性转换为每次调用时访问变量的方法。

即使自执行的匿名函数已经执行完毕,变量 prisoner_name 和 jail_term 仍然可以通过 prisoner 和 setJailTerm 方法访问。prisoner_name 和 jail_term 现在像私有属性一样作用于 prison 对象。它们只能通过匿名函数返回的对象上的方法访问,不能直接在对象或对象的原型上访问。你也许听说过闭包很难。等等,对不起... 我还没有解释这是如何成为闭包的,对吧?好的,让我们退后几步,然后逐步接近它。
什么是闭包?
作为一种抽象概念,闭包可能难以理解,所以在回答“什么是闭包?”这个问题之前,我们需要先设定一些背景知识。请耐心等待,因为在本节结束时,你将得到这个问题的答案。
当程序运行时,它们会占用并使用计算机的内存来完成各种事情,例如存储变量的值。如果程序运行后不再释放不再需要的内存,计算机最终会崩溃。在一些语言中,如 C 语言,内存管理必须由程序员处理,程序员会花费大量时间编写代码以确保内存被释放。
其他语言,如 Java 和 JavaScript,通过从计算机内存中移除不再需要的代码来自动释放内存。这些自动化的系统被称为垃圾回收器,可能是因为占位空间的无用变量很臭。关于哪种系统更好,自动或手动,各有各的看法,但这本书的范围之外。只需知道 JavaScript 有一个垃圾回收器。
当一个函数执行完毕时,一种简单的方法是将其内部创建的所有内容从内存中移除。毕竟,函数已经执行完毕,所以看起来我们不再需要访问执行上下文中的任何内容了。
var prison = function () {
var prisoner = 'Josh Powell';
};
prison();
一旦prison执行完毕,我们就不再需要访问prisoner变量,所以乔希可以自由地走了。这种模式比较冗长,所以让我们将其转换回自执行的匿名函数模式。
(function () {
var prisoner = 'Josh Powell';
})();
这里也是同样的情况:函数执行完毕后,犯人变量不再需要保留在内存中。再见,乔希!
让我们把这段代码放入我们的模块模式中。

在匿名函数执行完毕后,我们仍然不需要访问prisoner变量。因为字符串Josh Powell现在存储在prison.prisoner中,所以没有必要在模块中保留prisoner变量,因为它不再可访问。尽管看起来可能不是这样,prison.prisoner的值是字符串Josh Powell;它并不指向prisoner变量。
var prison = (function () {
var prisoner = 'Josh Powell';
return {
prisoner: function () {
return prisoner;
}
}
})();
// outputs 'Josh Powell'
console.log( prison.prisoner() );
现在,每次执行prison.prisoner时都会访问prisoner变量。prison.prisoner()返回prisoner变量的当前值。如果垃圾回收器来移除它,调用prison.prisoner将返回undefined而不是Josh Powell。
现在,最后,我们可以回答“什么是闭包?”的问题。闭包是通过在创建变量的执行上下文外部保持对变量的访问,防止垃圾回收器从内存中移除变量的过程。当prisoner函数被保存在prison对象上时,就创建了闭包。闭包是通过在当前执行上下文外部保存一个函数,该函数具有对prisoner变量的动态访问权限,从而创建的,这阻止了垃圾回收器从内存中移除prisoner变量。
让我们看看几个闭包的更多例子。
var makePrison = function ( prisoner ) {
return function () {
return prisoner;
}
};
var joshPrison = makePrison( 'Josh Powell' );
var mikePrison = makePrison( 'Mike Mikowski' );
// outputs 'Josh Powell', prisoner variable is saved in a closure.
// The closure is created because of the anonymous function returned
// from the makePrison call that accesses the prisoner variable.
console.log( joshPrison() );
// outputs 'Mike Mikowski',the prisoner variable is saved in a closure.
// The closure is created because of the anonymous function returned
// from the makePrison call that accesses the prisoner variable.
console.log( mikePrison() );
闭包的另一个常见用途是在 Ajax 调用返回时保存变量。在使用 JavaScript 对象的函数时,this指向对象:
var prison = {
names: 'Mike Mikowski and Josh Powell',
who: function () {
return this.names;
}
};
// returns 'Mike Mikowski and Josh Powell'
prison.who();
如果你使用 jQuery 在方法中发起 Ajax 调用,那么this不再指向你的对象;它指向 Ajax 调用:
var prison = {
names: 'Josh Powell and Mike Mikowski',
who: function () {
$.ajax({
success: function () {
console.log( this.names );
}
});
}
};
// outputs undefined, 'this' is the ajax object
prison.who();
那么如何引用该对象呢?闭包来帮忙!记住,闭包是通过将一个可以访问当前执行上下文中变量的函数保存到当前执行上下文之外的变量中创建的。在下面的例子中,它是通过将 this 保存到 that 中,并在 Ajax 调用返回时执行的函数中访问 that 创建的。Ajax 调用是异步的,所以响应是在发出 Ajax 调用的执行上下文之外到达的。

即使 who() 在 Ajax 调用返回之前已经执行完毕,但 that 变量没有被垃圾回收,并且可以由 success 方法使用。
希望我们已经以一种易于理解的方式介绍了闭包,使得我们能够轻松地掌握它们是什么以及它们是如何工作的。现在我们已经了解了闭包是什么,让我们深入探讨闭包的机制,看看它们是如何实现的。
2.6.4. 闭包
闭包是如何工作的?现在我们理解了 什么是 闭包,但还不了解 它是如何实现的。答案在于执行上下文对象。让我们看看上一节的一个例子:
var makePrison = function (prisoner) {
return function () {
return prisoner;
}
};
var joshPrison = makePrison( 'Josh Powell' );
var mikePrison = makePrison( 'Mike Mikowski' );
// outputs 'Josh Powell'
console.log( joshPrison() );
// outputs 'Mike Mikowski'
console.log( mikePrison() );
当调用 makePrison 时,会创建一个针对该 特定 调用的执行上下文对象,并将 prisoner 赋值为传入的值。请记住,执行上下文对象是 JavaScript 引擎的一部分,在 JavaScript 中无法直接访问。
在前面的例子中,我们调用了两次 makePrison,并将结果保存到 joshPrison 和 mikePrison 中。因为 makePrison 的返回值是一个函数,当我们将其赋值给 joshPrison 变量时,对该 特定 执行上下文对象的引用计数为 1,并且因为计数仍然大于零,所以该 特定 执行上下文对象被 JavaScript 引擎保留。如果该计数降至零,那么 JavaScript 引擎就会知道该对象可以被垃圾回收。
当再次调用 makePrison 并将其赋值给 mikePrison 时,会创建一个新的执行上下文对象,并且对该执行上下文对象的引用计数也设置为 1。此时,我们有两个指向两个执行上下文对象的指针,它们的引用计数都是 1,尽管它们都是由执行相同函数创建的。
如果我们再次调用 joshPrison,它将使用在 makePrison 调用并保存到 joshPrison 时的执行上下文对象中的值。除了关闭网页(聪明人)之外,清除保留的执行上下文对象的唯一方法就是删除 joshPrison 变量。当我们这样做时,对该执行上下文对象的引用计数降至 0,它可能被 JavaScript 随意移除。
让我们同时启动几个执行上下文对象,看看会发生什么:
列表 2.12. 执行上下文对象
var curryLog, logHello, logStayinAlive, logGoodbye;
curryLog = function ( arg_text ){
var log_it = function (){ console.log( arg_text ); };
return log_it;
};
logHello = curryLog('hello');
logStayinAlive = curryLog('stayin alive!');
logGoodbye = curryLog('goodbye');
// This creates no reference to the execution context,
// and therefore the execution context object can be
// immediately purged by the JavaScript garbage collector
curryLog('fred');
logHello(); // logs 'hello'
logStayinAlive(); // logs 'stayin alive!'
logGoodbye(); // logs 'goodbye'
logHello(); // logs 'hello' again
// destroy reference to 'hello' execution context
delete window.logHello;
// destroy reference to 'stayin alive!' execution context
delete window.logStayinAlive;
logGoodbye(); // logs 'goodbye'
logStayinAlive(); // undefined - execution context destroyed
我们必须记住,每次调用函数时都会创建一个唯一的执行上下文对象。函数执行完成后,执行对象会立即被丢弃除非调用者保留对其的引用。如果你返回一个数字,通常你不能保留对函数执行上下文对象的引用。另一方面,如果你返回一个更复杂的结构,如函数、对象或数组,创建对执行上下文的引用通常是通过将返回值存储到变量中实现的——有时是错误地实现的。
有可能创建多层深度的执行上下文引用链。当我们需要时(例如,考虑对象继承),这确实是一件好事。但有时我们并不希望有这种闭包,因为它们可能会造成内存使用失控(例如,考虑内存泄漏)。在附录 A 中提供了规则和工具,可以帮助你避免意外的闭包。
闭包——再谈一次!
在继续之前,因为闭包是 JavaScript 中如此重要且令人困惑的一部分,让我们再尝试一次解释。如果你已经完全掌握了闭包,请随意继续。
var menu, outer_function,
food = 'cake';
outer_function =function (){
var fruit, inner_function;
fruit = 'apple';
inner_function= function() {
return { food: food, fruit: fruit };
}
return inner_function;
};
menu= outer_function();
// returns { food: 'cake',fruit: 'apple' }
menu();
当outer_function执行时,它创建了一个执行上下文。inner_function定义在这个执行上下文中
因为inner_function是在outer_function执行上下文中定义的,所以它可以访问outer_function中所有作用域内的变量——在这个例子中是food、fruit、outer_function、inner_function和menu。
当outer_function执行完毕后,你可能预期该执行上下文内部的所有内容都会被垃圾回收器销毁。你会错的。
它没有被销毁,因为在全局作用域中的变量menu中保存了对inner_function的引用。因为inner_function需要保留在它声明时作用域内的所有变量的访问权限,它“覆盖”了outer_function执行上下文,以防止垃圾回收器将其移除。这是一个闭包。
这又带我们回到了第一个例子,让我们来分析为什么在 Ajax 调用返回后scoped_var仍然是可访问的。

它是可访问的,因为成功方法是在调用sendAjaxRequest时创建的执行上下文中定义的,而scoped_var当时是有效的。如果你对闭包仍然感到困惑,不要灰心。闭包是 JavaScript 中较为复杂的概念之一,如果在阅读本节几次之后你仍然不理解,请继续前进;这可能是一个需要更多实践经验才能理解的概念。希望到本书结束时,你将拥有足够的实践经验,使其变得自然而然。
由此,我们深入探讨了 JavaScript 的细节,虽然这次回顾并不全面,但重点在于我们认为对于开发大规模 SPA 所必需的概念。我们希望您享受这次旅程。
2.7. 摘要
在本章中,我们介绍了一些概念,虽然这些概念并非 JavaScript 独有,但在其他广泛使用的编程语言中有时也找不到。对这些主题的了解对于编写单页应用至关重要——如果没有这些知识,在构建应用的过程中你可能会感到迷茫。
理解变量作用域、变量提升和函数提升是揭开 JavaScript 中变量神秘面纱的基础。理解执行上下文对象对于理解作用域和提升的工作方式至关重要。
知道如何在 JavaScript 中使用原型创建对象,使得使用原生 JavaScript 编写可重用代码成为可能。如果没有理解基于原型的对象,工程师通常会回退到使用库来编写可重用代码,依赖于库提供的基于类的模型,而实际上这个库是在基于原型的模型之上构建的包装。如果你在学习了基于原型的方法之后仍然偏好使用基于类的系统,你仍然可以利用基于原型的模型来处理简单用例。对于构建我们的单页应用,我们将使用基于原型的模型,原因有两个:我们相信它对于我们的用例来说更简单易用,而且这是 JavaScript 的方式,我们正在用 JavaScript 进行编码。
编写自执行的匿名函数将包含你的变量作用域,帮助你防止无意中污染全局命名空间,并帮助你编写不与其他库冲突的库和代码库。
理解模块模式以及如何使用私有变量,可以使你为你的对象培养一个深思熟虑的公共 API,并隐藏所有其他对象不需要访问的混乱的内部方法和变量。这使你的 API 既整洁又清晰,并使你明确知道哪些方法是应该消费的,哪些是 API 的私有辅助方法。
最后,我们花了很多时间深入探讨最困难的 JavaScript 概念之一:闭包。如果你至今还没有完全理解闭包,希望本书中的足够实践经验能帮助你巩固理解。
带着这些概念,让我们继续进入下一章,开始构建一个具有生产质量的 SPA。
第二部分:SPA 客户端
一个单页应用(SPA)客户端提供的不仅仅是传统网站的用户界面(UI)。尽管有些人说 SPA 客户端可以像桌面应用程序一样响应,但更准确的说法是,编写良好的 SPA 客户端就是桌面应用程序。
与桌面应用程序一样,SPA 客户端与传统网页有显著的不同。当我们用 SPA 替换传统网站时,整个软件栈都会发生变化——从数据库服务器到 HTML 模板。那些有远见卓识,成功从传统网站过渡到 SPA 的公司已经明白,旧的做法和结构必须改变。他们重新聚焦工程人才、纪律和客户端测试。服务器仍然很重要,但它的重点在于提供 JSON 数据服务。
所以,让我们忘记我们关于传统网站客户端开发所知道的一切。好吧,不是一切——了解 JavaScript、HTML5、CSS3、SVG、CORS 和其他一些缩写词仍然是有益的。但我们需要记住,当我们进入这些章节时,我们将构建一个桌面应用程序,而不是传统网站。在第二部分中,我们将学习如何:
-
构建和测试一个高度可扩展、可测试和强大的 SPA 客户端
-
让后退按钮、书签和其他历史控制功能按预期工作
-
设计、实现和测试健壮的功能模块及其 API
-
让我们的用户界面在移动设备和桌面电脑上无缝工作
-
组织模块和命名空间,以极大地提高测试、团队开发和面向质量的设计。
我们没有讨论的是如何使用特定的 SPA 框架库。我们有很多原因(参见第六章侧边栏中的深入讨论)。我们想解释编写良好的 SPA 的内部工作原理,而不是仅适用于单个框架库的实现复杂性。相反,我们使用经过六年和许多商业产品优化的架构。这个架构鼓励可测试性、可读性和面向质量的设计。它还使得在多个客户端开发者之间分配工作变得简单且愉快。采用这种方法,那些想要使用框架库的读者可以做出明智的决定,并更成功地进行使用。
第三章:开发 Shell
本章涵盖
-
描述 Shell 模块及其在我们架构中的位置
-
结构化你的文件和命名空间
-
创建和设计功能容器
-
使用事件处理器切换功能容器
-
使用锚接口模式来管理应用程序状态
在本章中,我们描述了Shell,这是我们架构的一个必需组件。我们开发了一个包含我们的功能容器的页面布局,然后调整 Shell 来渲染它们。接下来,我们展示了 Shell 如何通过扩展和收缩聊天滑块来管理功能容器。然后,我们让它捕获用户点击事件以打开和关闭滑块。最后,我们使用 URI 锚点作为我们的状态 API,采用锚点接口模式。这为用户提供他们期望的浏览器控件——如前进和后退按钮、浏览器历史记录和书签。
到本章结束时,我们将为可扩展和可管理的 SPA 打下基础。但让我们不要过于超前。首先,我们必须理解 Shell。
3.1. 理解 Shell
Shell是我们 SPA 的主控制器,并在我们的架构中是必需的。我们可以将 Shell 模块的作用与飞机的机身进行比较:
飞机的壳体(也称为单体或机身)为车辆提供形状和结构。座椅、托盘桌和发动机等组件通过各种紧固件附着在其上。所有组件都设计成尽可能独立地工作,因为没有人喜欢当阿姨米莉打开她的托盘桌时,飞机突然向右急剧倾斜。
Shell 模块为我们应用程序提供形状和结构。像聊天、登录和导航这样的功能模块通过 API“附着”到 Shell 上。所有功能模块都设计成尽可能独立地工作,因为没有人喜欢当阿姨米莉在她的聊天滑块中输入“ROTFLMAO!!! UR totally pwned!”时,应用程序立即关闭她的浏览器窗口。
Shell 只是我们在许多商业项目中精炼的架构的一部分。这个架构——以及 Shell 的位置——在图 3.1 中展示。我们喜欢首先编写 Shell,因为它是我们架构的核心。它协调功能模块与业务逻辑和通用浏览器接口(如 URI 或 cookies)之间的关系。当用户点击后退按钮、登录或执行任何其他改变应用程序可书签状态的操作时,Shell 协调这种变化。
图 3.1. 我们 SPA 架构中的 Shell

对于熟悉模型-视图-控制器(MVC)架构的您来说,可能会认为 Shell 是主控制器,因为它协调所有从属功能模块的控制器。
Shell 负责以下事项:
-
渲染和管理功能容器
-
管理应用程序状态
-
协调功能模块
下一章将详细介绍功能模块的协调。本章涵盖渲染功能容器和管理应用程序状态。首先让我们准备我们的文件和命名空间。
3.2. 设置文件和命名空间
我们将根据 附录 A 中找到的代码标准来设置我们的文件和命名空间。特别是,我们将为每个 JavaScript 命名空间创建一个 JavaScript 文件,并使用自执行的匿名函数来防止全局命名空间的污染。我们还将设置具有并行结构的 CSS 文件。这种约定加快了开发速度,提高了质量,并简化了维护。随着我们向项目中添加更多模块和开发者,其价值也会增加。
3.2.1. 创建文件结构
我们为我们的应用程序的根命名空间选择了 spa。我们同步 JavaScript 和 CSS 文件名、JavaScript 命名空间和 CSS 选择器名称。这使得跟踪哪个 JavaScript 与哪个 CSS 相关联变得容易得多。
规划目录和文件
网页开发者通常将他们的 HTML 文件放在一个目录中,然后将他们的 CSS 和 JavaScript 放在子目录中。我们没有看到打破传统的原因。让我们创建如 列表 3.1 所示的目录和文件:
列表 3.1. 文件和目录,第一次遍历

现在我们已经建立了基础,让我们开始安装 jQuery。
安装 jQuery 和插件
jQuery 及其插件通常提供为压缩版或普通版文件。我们几乎总是安装普通版文件,因为这有助于调试,而且我们无论如何都会在我们的构建系统中进行压缩。不用担心它们的功能——我们将在本章的后面部分详细介绍。
jQuery 库提供了有用的跨平台 DOM 操作和其他实用工具。我们使用的是版本 1.9.1,可以从 docs.jquery.com/Downloading_jQuery 获取。让我们将其放置在我们的 jQuery 目录中:
...
+-- js
| +-- jq
| | +-- jquery-1.9.1.js
...
jQuery 的 uriAnchor 插件提供了管理 URI 锚组件的实用工具。它可以在 github 上找到 github.com/mmikowski/urianchor。让我们将其放置在相同的 jQuery 目录中:
...
+-- js
| +-- jq
| | +-- jquery.uriAnchor-1.1.3.js
...
我们文件和目录现在应该看起来像 列表 3.2:
列表 3.2. 添加 jQuery 和插件后的文件和目录
spa
+-- css
| +-- spa.css
| `-- spa.shell.css
+-- js
| +-- jq
| | +-- jquery-1.9.1.js
| | `-- jquery.uriAnchor-1.1.3.js
| +-- spa.js
| `-- spa.shell.js
+-- layout.html
`-- spa.html
现在我们已经将所有文件放置到位,是时候开始编写一些 HTML、CSS 和 JavaScript 了。
3.2.2. 编写应用程序 HTML
当我们打开我们的浏览器文档(spa/spa.html)时,我们可以享受到目前为止我们所创造的所有的单页应用(SPA)优点。当然,因为这个文件是空的,所以提供的优点仅限于一个无错误的、高度安全的空白页,什么也不做。让我们改变“空白页”部分。
浏览器文档(spa/spa.html)将始终保持小巧。它的唯一作用是加载库和样式表,然后启动我们的应用程序。让我们启动我们最喜欢的文本编辑器,并添加我们通过本章所需的全部代码,如 列表 3.3 所示:
列表 3.3. 应用程序 HTML—spa/spa.html

在场的性能意识开发者可能会问:“为什么我们不把脚本放在 body 容器的末尾,就像传统的网页一样?”这是一个合理的问题,因为这样通常可以使页面渲染更快,因为静态 HTML 和 CSS 可以在 JavaScript 加载完成之前显示。但是,单页应用(SPAs)并不这样工作。它们使用 JavaScript 生成 HTML,因此将脚本放在头部外部并不会导致渲染更快。相反,我们保持所有外部脚本在 head 部分以改善组织和可读性。
3.2.3. 创建根 CSS 命名空间
我们的根命名空间是 spa,根据我们在附录 A 中的约定,我们的根样式表应该命名为 spa/css/spa.css。我们之前创建了此文件,但现在我们需要填充它。因为这是我们的根样式表,它将比我们的其他 CSS 文件包含更多部分。让我们再次使用我们最喜欢的文本编辑器来添加所需的规则,如图列表 3.4 所示:
列表 3.4. 根 CSS 命名空间—spa/css/spa.css


根据我们的代码标准,这个文件中所有的 CSS ID 和类名都以前缀 spa- 开头。现在我们已经创建了根应用程序 CSS,我们将创建相应的 JavaScript 命名空间。
3.2.4. 创建根 JavaScript 命名空间
我们的根命名空间是 spa,根据我们在附录 A 中的约定,我们的根 JavaScript 应该命名为 spa/js/spa.js。所需的 JavaScript 最少是 var spa = {};。但是,我们想要添加一个初始化应用程序的方法,并确保代码能够通过 JSLint 检查。我们可以使用附录 A 中的模板,并对其进行简化,因为我们不需要所有部分。让我们用我们第二喜欢的文本编辑器打开文件,并按照列表 3.5 中的内容进行填充:
列表 3.5. 根 JavaScript 命名空间—spa/js/spa.js

我们想要确保我们的代码没有任何常见错误或不良实践。附录 A 展示了如何安装和运行有价值的 JSLint 实用工具,它正是这样做的。它描述了文件顶部所有的 /*jslint ... */ 开关的含义。除了附录,我们还在第五章中进一步讨论了 JSLint。
让我们在命令行中输入 jslint spa/js/spa.js 来检查我们的代码——我们不应该看到任何警告或错误。现在我们可以打开我们的浏览器文档(spa/spa.html),并看到合同规定的“hello world”演示,如图图 3.2 所示。
图 3.2. 强制性的“hello world”截图

现在我们已经向世界打招呼,并因成功的甜美味道而信心倍增,让我们开始一个更雄心勃勃的探索。在下一节中,我们将开始构建我们的第一个“真实世界”SPA。
3.3. 创建功能容器
外壳创建并管理我们的功能模块将使用的容器。例如,我们的聊天滑块容器将遵循流行惯例,并锚定在浏览器窗口的右下角。外壳负责滑块容器,但不会管理容器内部的行为——这留给了聊天功能模块,我们将在 第六章 中讨论。
让我们把聊天滑块放置在一个相对完整的布局中。图 3.3 显示了我们希望看到的容器线框图。
图 3.3. 应用程序容器线框图

当然,这只是一个线框图。我们需要将其转换为 HTML 和 CSS。让我们讨论一下我们可能如何做到这一点。
3.3.1. 选择策略
我们将在 spa/layout.html 单一布局文档文件中开发我们的功能容器的 HTML 和 CSS。只有在我们将容器调整到满意的程度之后,我们才会将代码移动到外壳的 CSS 和 JavaScript 文件中。这种方法通常是开发初始布局最快和最有效的方法,因为我们可以在不担心与其他代码交互的情况下进行操作。
我们首先编写 HTML,然后稍后添加样式。
3.3.2. 编写外壳 HTML
HTML5 和 CSS3 的一个显著特点是我们真的可以分离样式和内容。线框图显示了我们想要的容器以及它们的嵌套方式。这就是我们自信地编写容器 HTML 所需要的一切。让我们打开我们的布局文档 (spa/layout.html) 并输入 列表 3.6 中所示的 HTML:
列表 3.6. 创建容器的 HTML——spa/layout.html

现在我们应该验证 HTML 以确保它没有错误。我们喜欢使用备受推崇的 Tidy 工具,它可以找到缺失的标签和其他常见的 HTML 错误。您可以在 infohound.net/tidy/ 在线找到 Tidy,或从 tidy.sourceforge.net/ 下载源代码。如果您使用的是 Ubuntu 或 Fedora 等 Linux 发行版,Tidy 可能已经在标准软件仓库中可用。现在让我们给这些容器添加一些样式。
3.3.3. 编写外壳 CSS
我们将编写 CSS 以提供一种 液体布局,其中内容的宽度和高度将调整以填充浏览器窗口,除非在最极端的大小之外。我们将为我们的功能容器提供背景颜色,这样我们就可以轻松地看到它们。我们还将避免任何边框,因为它们可以改变 CSS 盒子的大小。这会给我们的快速原型过程带来不必要的繁琐。一旦我们对容器的展示感到满意,我们就可以返回添加必要的边框。
液体布局
随着我们的布局变得越来越复杂,我们可能需要使用 JavaScript 来提供其流动性。通常,我们会使用窗口大小调整事件处理器来确定浏览器窗口的大小,然后重新计算并应用新的 CSS 尺寸。我们在第四章中展示了这一技术。
让我们将 CSS 添加到布局文档的 <head> 部分 (spa/layout.html)。我们可以在 spa.css 样式表链接之后立即放置它,如图 3.7 节所示。所有更改都以粗体显示:
列表 3.7. 为容器创建 CSS—spa/layout.html
...
<head>
<title>HTML Layout</title>
<link rel="stylesheet" href="css/spa.css" type="text/css"/>
<style>
.spa-shell-head, .spa-shell-head-logo, .spa-shell-head-acct,
.spa-shell-head-search, .spa-shell-main, .spa-shell-main-nav,
.spa-shell-main-content, .spa-shell-foot, .spa-shell-chat,
.spa-shell-modal {
position : absolute;
}
.spa-shell-head {
top : 0;
left : 0;
right : 0;
height : 40px;
}
.spa-shell-head-logo {
top : 4px;
left : 4px;
height : 32px;
width : 128px;
background : orange;
}
.spa-shell-head-acct {
top : 4px;
right : 0;
width : 64px;
height : 32px;
background : green;
}
.spa-shell-head-search {
top : 4px;
right : 64px;
width : 248px;
height : 32px;
background : blue;
}
.spa-shell-main {
top : 40px;
left : 0;
bottom : 40px;
right : 0;
}
.spa-shell-main-content,
.spa-shell-main-nav {
top : 0;
bottom : 0;
}
.spa-shell-main-nav {
width : 250px;
background : #eee;
}
.spa-x-closed .spa-shell-main-nav {
width : 0;
}
.spa-shell-main-content {
left : 250px;
right : 0;
background : #ddd;
}
.spa-x-closed .spa-shell-main-content {
left : 0;
}
.spa-shell-foot {
bottom : 0;
left : 0;
right : 0;
height : 40px;
}
.spa-shell-chat {
bottom : 0;
right : 0;
width : 300px;
height : 15px;
background : red;
z-index : 1;
}
.spa-shell-modal {
margin-top : -200px;
margin-left : -200px;
top : 50%;
left : 50%;
width : 400px;
height : 400px;
background : #fff;
border-radius : 3px;
z-index : 2;
}
</style>
</head>
...
当我们打开我们的浏览器文档 (spa/layout.html) 时,我们应该看到一个看起来与我们的线框图惊人相似的页面,如图 3.4 节所示。当我们调整浏览器窗口大小时,我们可以看到功能容器也会相应调整大小。我们的流动布局确实有一个限制——如果我们使宽度或高度小于 500 像素,将显示滚动条。我们这样做是因为我们无法将内容挤压到这个大小以下。
图 3.4. 容器的 HTML 和 CSS—spa/layout.html

我们可以使用 Chrome 开发者工具来尝试一些我们新定义的样式,这些样式在初始显示中并未使用。例如,让我们将类 spa-x-closed 添加到 spa-shell-main 容器中。这将关闭页面左侧的导航栏。移除该类将恢复导航栏,如图 3.5 节所示。
图 3.5. 在 Chrome 开发者工具中双击 HTML 添加类

3.4. 渲染功能容器
我们创建的布局文档 (spa/layout.html) 是一个很好的基础。现在我们将使用它在我们的 SPA 中。第一步是让 Shell 渲染容器,而不是使用静态 HTML 和 CSS。
3.4.1. 将 HTML 转换为 JavaScript
我们需要我们的 JavaScript 来管理所有文档更改;因此,我们需要将之前开发的 HTML 转换为 JavaScript 字符串。我们将保持 HTML 缩进以方便可读性和维护性,如图 3.8 节所示:
列表 3.8. 连接 HTML 模板
varmain_html =String()
+'<div class="spa-shell-head">'
+ '<div class="spa-shell-head-logo"></div>'
+ '<div class="spa-shell-head-acct"></div>'
+ '<div class="spa-shell-head-search"></div>'
+'</div>'
+'<div class="spa-shell-main">'
+ '<div class="spa-shell-main-nav"></div>'
+ '<div class="spa-shell-main-content"></div>'
+'</div>'
+'<div class="spa-shell-foot"></div>'
+'<div class="spa-shell-chat"></div>'
+'<div class="spa-shell-modal"></div>';
我们并不担心连接字符串的性能惩罚。一旦进入生产阶段,JavaScript 压缩器会为我们连接字符串。
配置您的编辑器!
一位专业的开发者应该使用专业级的文本编辑器或 IDE。其中大多数都支持正则表达式和宏。我们应该能够自动化将 HTML 转换为 JavaScript 连接字符串。例如,备受尊敬的 vim 编辑器可以被配置为通过两个按键将 HTML 格式化为 JavaScript 连接字符串。我们可以在我们的 ~/.vimrc 文件中添加以下内容:
vmap <silent> ;h :s?^\(\s*\)+ '\([^']\+\)',*\s*$?\1\2?g<CR>
vmap <silent> ;q :s?^\(\s*\)\(.*\)\s*$? \1 + '\2'?<CR>
一旦我们重启 vim,我们可以直观地选择要更改的 HTML。当我们按下 ;q 时,选择将被格式化;当我们按下 ;h 时,我们将撤销格式。
3.4.2. 将 HTML 模板添加到我们的 JavaScript 中
现在是时候大胆地迈出一步,创建我们的 Shell。当我们初始化 Shell 时,我们希望它用功能容器填充我们选择页面的元素。在此期间,我们还想缓存 jQuery 集合对象。我们可以使用附录 A 中的模块模板[kindle_split_022.html#app01]以及我们刚刚创建的 JavaScript 字符串来完成此操作。让我们打开我们的文本编辑器并创建列表 3.9 中所示的文件。请仔细注意注释,因为它们提供了有用的细节:
列表 3.9. 启动 Shell——spa/js/spa.shell.js


现在我们有一个渲染功能容器的模块,但我们仍然需要填充 CSS 文件,并指导根命名空间模块(spa/js/spa.js)使用 Shell 模块(spa/js/spa.shell.js)而不是展示传统的“hello world”文本。让我们开始吧。
3.4.3. 编写 Shell 样式表
使用我们在附录 A 中介绍的命名空间约定 appendix A,我们知道我们需要将我们的spa-shell-*选择器放入一个名为 spa/css/spa.shell.css 的文件中。我们可以直接将我们在spa/layout.html中开发的 CSS 复制到该文件中,如列表 3.10 所示:
列表 3.10. Shell CSS,第 1 版——spa/css/spa.shell.css


所有选择器都有spa-shell-前缀。这有几个好处:
-
它显示这些类是由 Shell 模块(spa/js/spa.shell.js)控制的。
-
它防止了与第三方脚本和我们的其他模块的命名空间冲突。
-
当我们在调试和检查文档 HTML 时,我们可以立即看到哪些元素是由 shell 模块生成的。
所有这些好处都阻止了我们陷入 CSS 选择器名称混乱的地狱。任何管理过大量样式表的人都应该知道我们说的是什么。
3.4.4. 指导应用程序使用 Shell
现在让我们修改我们的根命名空间模块(spa/js/spa.js),让它使用 Shell 而不是盲目地将“hello world”复制到 DOM 中。以下粗体的调整应该能解决问题:
/*
* spa.js
* Root namespace module
*/
...
/*global $, spa */
var spa = (function () {
var initModule = function ( $container ) {
spa.shell.initModule( $container );
};
return { initModule: initModule };
}());
我们现在应该能够打开我们的浏览器文档(spa/spa.html)并看到类似于图 3.6 的内容。我们可以使用 Chrome 开发者工具来确认我们的 SPA 生成的文档(spa/spa.html)与我们的布局文档(spa/layout.html)相匹配。
图 3.6. 这就像又一次的似曾相识——spa/spa.html

在这个基础上,我们将开始工作,让 Shell 管理功能容器。现在休息一下可能也是个好主意,因为下一部分相当雄心勃勃。
3.5. 管理功能容器
Shell 负责渲染和控制功能容器。这些是“顶级”容器——通常是DIV元素,它们包含功能内容。Shell 初始化并协调应用程序中的所有功能模块。Shell 指导功能模块创建和管理功能容器内的所有内容。我们将在第四章中进一步讨论功能模块。第四章。
在本节中,我们首先编写一个方法来展开和缩回聊天滑动功能容器。然后我们将构建点击事件处理器,以便用户可以根据需要随时打开或关闭滑动。然后我们将检查我们的工作,并讨论下一个重要的事情——使用 URI 哈希片段管理页面状态。
3.5.1. 编写展开或缩回聊天滑动的方法
我们对聊天滑动功能将抱有适度的雄心。我们需要它达到生产质量,但不需要过于奢华。以下是我们要实现的要求:
-
允许开发者配置滑动动作的速度和高度。
-
创建一个单一的方法来展开或缩回聊天滑动。
-
避免出现滑动同时展开和缩回的竞态条件。
-
允许开发者传递一个可选的回调函数,在滑动动作完成后调用。
-
编写测试代码以确保滑动正常工作。
让我们调整 Shell 以满足这些要求,如列表 3.11 所示.^([1]) 所有更改都以粗体显示。请查看注释,因为它们详细说明了更改如何与要求相关:
¹ 现在是感谢您最喜欢的天体 jQuery 的时候了,因为没有它这将困难得多。
列表 3.11. Shell,修订以展开和缩回聊天滑动—spa/js/spa.shell.js


如果你正在家中参与其中,让我们首先通过在命令行中键入jslintspa/js/spa.shell.js来使用 JSLint 检查我们的代码——我们不应该看到任何警告或错误。接下来,让我们重新加载浏览器文档(spa/spa.html),并查看聊天滑动在 3 秒后展开,在 8 秒后缩回。现在我们有了滑动的效果,我们可以使用用户的鼠标点击来切换其位置。
3.5.2. 添加聊天滑动点击事件处理器
大多数用户期望点击聊天滑动并看到它展开或缩回,因为这是一种常见的约定。以下是我们要实现的要求:
-
设置工具提示文本以提示用户操作,例如“点击缩回。”
-
添加一个点击事件处理器来调用
toggleChat。 -
将点击事件处理器绑定到 jQuery 事件。
让我们调整 Shell 以满足这些要求,如列表 3.12 所示。所有更改再次以粗体显示,注释详细说明了更改如何与要求相关。
列表 3.12. Shell,修订以处理聊天滑动点击事件—spa/js/spa.shell.js


在家练习的人应该再次通过在命令行中键入 jslint spa/js/spa.shell.js 来检查我们的代码。我们不应该看到任何警告或错误。
我们认为 jQuery 事件处理器的一个方面非常重要,需要记住:返回值被 jQuery 解释为指定其继续处理事件。我们通常从我们的 jQuery 事件处理器返回 false。以下是这样做的作用:
-
它告诉 jQuery 阻止默认操作——比如跟随链接或选择文本——的发生。在事件处理器中调用
event.preventDefault()可以获得相同的效果。 -
它告诉 jQuery 停止事件在父 DOM 元素上触发相同的事件(这种行为通常称为 冒泡)。在事件处理器中调用
event.stopPropagation()可以获得相同的效果。 -
它结束了处理器执行。如果点击的元素在此处理器之后还有其他绑定的事件处理器,下一个处理器将被执行。(如果我们不希望后续处理器执行,我们可以调用
event.preventImmediatePropagation()。)
这三个动作通常是我们要让事件处理器执行的动作。很快我们将编写不希望这些动作的事件处理器。这些事件处理器将返回 true 值。
Shell 不必一定处理点击事件。它可以通过将滑块操作作为对聊天模块的回调来提供这种能力——我们鼓励这样做。但是,因为我们还没有编写那个模块,所以我们目前还在 Shell 中处理点击事件。
现在让我们给 Shell 样式添加一点风采。列表 3.13 展示了更改:
列表 3.13.为 Shell 添加一些风采—spa/css/spa.shell.css

当我们重新加载浏览器文档 (spa/spa.html) 时,我们可以点击滑块,看到它像 图 3.7 中所示的那样扩展。
图 3.7. 扩展聊天滑块—spa/spa.html

滑块扩展的速度比收缩慢得多。我们可以通过更改 Shell 中的配置(spa/js/spa.shell.js)来改变滑块的速度,例如:
...
configMap = {
main_html : String()
...
chat_extend_time : 250,
chat_retract_time : 300,
...
},
...
在下一节中,我们将调整我们的应用程序以更好地管理其状态。完成之后,所有浏览器历史功能,如书签、前进按钮和后退按钮,都将按用户期望的方式为聊天滑块工作。
3.6. 管理应用程序状态
在计算机科学中,状态是应用程序中信息的唯一配置。桌面和 Web 应用程序通常试图在会话之间保持某种状态。例如,当我们保存一个文档处理文档并在稍后日期再次打开它时,文档会被恢复。应用程序也可能恢复窗口大小、我们的首选项以及光标和页面位置。我们的 SPA 也需要管理状态,因为使用浏览器的用户已经习惯了某些行为。
3.6.1. 理解浏览器用户期望的行为
桌面和 Web 应用程序在维护状态方面差异很大。如果桌面应用程序不提供“返回”功能,则可以省略上一个按钮。但在 Web 应用程序中,我们有一个浏览器的前进按钮——这是最常用的浏览器控制之一——直接面对用户,请求被点击——而我们无法将其删除。
同样,对于前进按钮、书签按钮和查看历史记录,用户期望这些历史记录控制能够正常工作。如果它们不能正常工作,我们的用户会变得烦躁,我们的应用程序将永远不会赢得 Webby 奖。表 3.1 展示了这些历史记录控制的大致桌面应用程序对应项。
表 3.1. 浏览器与桌面控制对比
| 浏览器控制 | 桌面控制 | 评论 |
|---|---|---|
| 后退按钮 | 撤销 | 回到先前的状态 |
| 向前按钮 | 重做 | 从最近的“撤销”或“后退”动作恢复状态 |
| 书签 | 保存为 | 存储应用程序状态以供将来使用或参考 |
| 查看历史记录 | 撤销历史记录 | 查看撤销/重做序列中的步骤 |
因为我们确实希望赢得 Webby 奖,我们必须确保这些历史记录控制按用户期望的方式工作。接下来,我们将讨论提供用户期望的行为的策略。
3.6.2. 选择一个策略来管理历史记录控制
提供历史记录控制的最佳策略应满足以下要求:
-
历史记录控制应按用户期望的方式工作,如表 3.1 所示。
-
支持历史记录控制的开发应相对便宜。与没有历史记录控制的开发展开相比,它不应需要更多的时间或复杂性。
-
应用程序应表现良好。应用程序不应比响应用户动作花费更长的时间,用户界面也不应因此变得更加复杂。
让我们以聊天滑块和以下用户交互为例,考虑一些策略:
-
苏珊访问我们的 SPA 并点击聊天滑块以打开它。
-
她将 SPA 添加到书签,然后浏览到其他网站。
-
之后,她决定返回我们的应用程序并点击她的书签。
让我们考虑三种策略,使苏珊的书签按预期工作。请不用担心记住它们;我们只是想说明它们的相对优点:^([2])
² 有其他策略——比如使用持久 cookie 或 iframe——但这些策略实际上过于有限且复杂,不值得考虑。
策略 1——在点击时,事件处理程序直接调用toggleChat例程,并忽略 URI。当苏珊返回到她的书签时,滑块将显示在其默认位置——关闭。苏珊对此并不满意,因为书签没有按预期工作。开发者詹姆斯也不满意,因为他的产品经理认为应用程序的可用性不可接受,并不断催促他。
策略 2—在点击时,事件处理器直接调用 toggleChat 例程,然后修改 URI 以记录此状态。当苏珊返回到她的书签时,应用程序必须识别 URI 中的参数并对其采取行动。苏珊很高兴。开发者詹姆斯则 不高兴,因为他现在必须支持两种将滑块打开的条件:运行时点击事件和加载时 URI 参数。而且詹姆斯的产品经理也不太高兴,因为支持这种双路径方法速度较慢,且容易出错和出现不一致。
策略 3—在点击时,事件处理器更改 URI 然后立即返回。Shell hashchange 事件处理器捕获更改,并将任务调度到 toggleChat 例程。当苏珊返回到她的书签时,URI 由相同的例程解析,并恢复打开的滑块。苏珊很高兴,因为书签按预期工作。开发者詹姆斯也很高兴,因为他可以使用 一条代码路径实现所有可书签状态。而且詹姆斯的产品经理也很高兴,因为开发速度快且相对无错误。
我们首选的解决方案是 策略 3,因为它支持所有历史控制(要求 A)。它解决了并最小化了开发问题(要求 B)。并且通过仅调整在使用历史控制时需要更改的页面部分来确保应用程序性能(要求 C)。这种 URI 总是驱动页面状态的解决方案,我们称之为 锚点接口模式,如图 图 3.8 所示。
图 3.8. 锚点接口模式

我们将在 第四章 中回到这个模式。现在我们已经选择了我们的策略,让我们来实现它。
3.6.3. 当发生历史事件时更改锚点
URI 的锚点组件指示浏览器显示页面的一部分。锚点的其他常见名称有 书签组件 或 哈希片段。锚点始终以 # 符号开头,在以下代码中用 粗体 表示:
http://localhost/spa.html#!chat=open
传统上,网络开发者使用锚点机制来使用户能够轻松地在长文档的各个部分之间“跳转”。例如,顶部有目录的网页可能会将所有章节标题链接到文档中相应的部分。每个部分可能在末尾都有一个“返回顶部”的链接。博客和论坛仍然广泛使用这种机制。
锚点组件的一个特例是,当它更改时,浏览器不会重新加载页面。锚点组件仅是客户端控制,这使得它成为存储我们应用程序状态的理想位置。这种技术被许多单页应用程序(SPAs)所使用。
我们将我们希望在浏览器历史记录中保留的应用程序状态变化称为历史事件。因为我们决定打开或关闭聊天是一个历史事件(你错过了会议),所以我们可以让我们的点击事件处理器更改锚点以表达聊天滑块的状态。我们可以使用uriAnchorjQuery 插件来完成这项繁重的工作。让我们修改 Shell,以便用户点击可以像列表 3.14 中所示的那样更改 URI。所有更改都以粗体显示。
列表 3.14. uriAnchor jQuery 插件在 spa/js/spa.shell.js 中的应用
...
//------------------- BEGIN EVENT HANDLERS -------------------
onClickChat = function ( event ) {
if ( toggleChat( stateMap.is_chat_retracted ) ) {
$.uriAnchor.setAnchor({
chat : ( stateMap.is_chat_retracted ? 'open' : 'closed' )
});
}
return false;
};
//-------------------- END EVENT HANDLERS --------------------
...
现在我们点击滑块,我们会看到 URI 中的锚点发生变化——但只有当toggleChat成功并返回 true 时。例如,当我们点击打开和关闭聊天滑块时,我们会看到以下内容:
http://localhost/spa.html#!chat=closed
关于那个感叹号
在示例 URI 中,跟随哈希符号(#!)的感叹号用于通知 Google 和其他搜索引擎,该 URI 可能被索引以供搜索。我们将在第九章中详细介绍搜索引擎优化。
我们需要确保当锚点发生变化时,只有需要调整的应用程序部分发生变化。这使得应用程序运行得更快,并避免了当页面的某些部分不必要地被清除和重新渲染时发生的令人烦恼的“闪烁”。例如,假设 Susan 在打开聊天滑块时正在查看一千个用户资料的列表。如果她点击返回按钮,应用程序应该简单地关闭滑块——资料不应该被重新渲染。
我们问自己三个问题,以确定事件的变化是否值得历史支持:
-
用户将多么强烈地希望书签已发生的变化?
-
用户将多么强烈地希望恢复到变化之前的状态?
-
这将花费多少?
虽然使用锚点接口模式维护状态的增量成本通常很小,但在某些情况下,它可能很昂贵或不可能。例如,当用户点击返回按钮时,在线购买可能很难撤销。在这种情况下,我们需要完全避免历史条目的创建。幸运的是,我们的uriAnchor插件支持这一点。
3.6.4. 使用锚点驱动应用程序状态
我们希望锚点组件始终驱动可书签的应用程序状态。这确保了历史功能始终按预期工作。以下伪代码概述了我们如何处理历史事件的方式:
-
当发生历史事件时,更改 URI 的锚点组件以反映变化后的状态:
-
接收事件的处理器调用 Shell 实用程序来更改锚点。
-
事件处理器随后退出。
-
-
Shell 的
hashchange事件处理器会注意到 URI 的变化并对其做出反应:-
它将当前状态与由新锚点提出的状态进行比较。
-
它试图根据比较结果更改需要调整的应用程序部分。
-
如果它无法执行所需的更改,它将保持当前状态,并将锚恢复以匹配它。
-
现在我们已经绘制了伪代码,让我们开始将其转换为实际内容。
将 Shell 修改为使用锚组件
让我们修改 Shell 以使用锚组件来驱动应用程序状态,如 列表 3.15 所示。这里有很多新的代码,但不要气馁——所有内容都将适时解释:
列表 3.15. 使用锚来驱动应用程序状态——spa/js/spa.shell.js






现在我们已经调整了代码,我们应该看到所有历史控制——前进按钮、后退按钮、书签和浏览器历史记录——都按预期工作。如果我们手动将其更改为我们不支持参数或值,锚应该“自动修复”——例如,尝试在浏览器地址栏中替换锚为 #!chat=barney 并按 Return。
现在我们有了历史控制功能,让我们讨论我们如何使用锚来驱动应用程序状态。我们将首先展示我们如何使用 uriAnchor 来编码和解码锚。
理解 Urianchor 如何编码和解码锚
我们使用 jQuery 的 hashchange 事件来识别锚组件的变化。应用程序状态使用 独立 和 依赖 键值对的概念进行编码。以下是一个用 粗体 标示的锚示例:
http://localhost/spa.html#!chat=profile:on:uid,suzie|status,green
在此示例中,独立 键是 profile,其值为 on。进一步定义 profile 状态的键是 依赖 键,它们遵循冒号 (😃 分隔符。这包括键 uid,其值为 suzie,以及键 status,其值为 green。
uriAnchor 插件,js/jq/jquery.uriAnchor-1.1.3.js,负责为我们编码和解码依赖和独立值。我们可以使用 $.uriAnchor .setAnchor() 方法来更改浏览器 URI,以匹配先前的示例:
var anchorMap = {
profile : 'on',
_profile : {
uid : 'suzie',
status : 'green'
}
};
$.uriAnchor.setAnchor( anchorMap );
可以使用 makeAnchorMap 方法来读取和解析锚到一个映射中:
var anchorMap = $.uriAnchor.makeAnchorMap();
console.log( anchorMap );
// If the URI anchor component in the browser is
// http://localhost/spa.html#!chat=profile:on:uid,suzie|status,green
//
// Then console.log( anchorMap ) should show the
// following:
//
// { profile : 'on',
// _profile : {
// uid : 'suzie',
// status : 'green'
// }
// };
//
希望你现在更好地理解了如何使用 uriAnchor 来编码和解码在 URI 锚组件中表示的应用程序状态。现在让我们更详细地看看我们如何使用 URI 锚组件来驱动应用程序状态。
理解锚变化如何驱动应用程序状态
我们的历史控制策略是,任何改变可书签状态的任何事件都应该做两件事:
-
更改锚。
-
立即返回。
我们向 Shell 添加了 changeAnchorPart 方法,这使得我们能够更新锚的一部分,同时确保独立和依赖键值得到适当处理。它统一了锚管理的逻辑,并且这是我们的应用程序修改锚的唯一方式。
当我们说“立即返回”时,我们的意思是锚点更改后,事件处理器的任务就完成了。它不会更改页面元素。它不会更新变量或标志。它不会通过“GO”或收集 200 美元。它只是直接返回到其调用事件。这在我们 onClickChat 事件处理器中得到了说明:
onClickChat = function ( event) {
changeAnchorPart({
chat: ( stateMap.is_chat_retracted ? 'open' : 'closed' )
});
return false;
};
此事件处理器使用 changeAnchorPart 来更改锚点的 chat 参数,然后立即返回。因为锚点组件已更改,这触发了 hashchange 浏览器事件。Shell 监听 hashchange 事件并根据锚点内容采取行动。例如,如果 Shell 注意到 chat 值已从 opened 变为 closed,它将关闭聊天滑块。
你可能会把锚点——通过 changeAnchorPart 方法修改的锚点——视为可书签状态的 API。这种方法的优点是,它无关紧要 为什么 锚点被更改——可能是我们的应用程序修改了它,或者用户点击了书签,或者玩弄了前进或后退按钮,或者直接在浏览器地址栏中输入。无论如何,它总是正确工作,并且只使用单个执行路径。
3.7. 摘要
我们已经完成了 Shell 的两个主要责任的实现。我们创建了并设计了功能容器,并创建了一个框架,使用 URI 锚点驱动应用程序状态。我们更新了聊天滑块以帮助说明这些概念。
我们与 Shell 的合作尚未完成,因为我们尚未解决其第三大主要责任:协调功能模块。下一章将展示如何构建功能模块,如何从 Shell 配置和初始化它们,以及如何调用它们。将功能隔离到它们自己的模块中大大提高了可靠性、可维护性、可扩展性和工作流程。它还鼓励使用和开发第三方模块。所以请留下来——这是真正落地的地方。
第四章. 添加功能模块
本章涵盖
-
定义功能模块以及它们如何融入我们的架构
-
比较功能模块和第三方模块
-
解释分形 MVC 设计模式及其在我们架构中的作用
-
为功能模块设置文件和目录
-
定义和实现功能模块 API
-
实现常用功能模块功能
在开始之前,你应该已经完成了本书的第一章–第三章。你还应该有第三章的项目文件,因为我们将在它们的基础上构建。我们建议你将第三章中创建的所有文件和整个目录结构复制到一个新的“第四章”目录中,以便你可以在那里更新它们。
功能模块为 SPA 提供了一种定义明确且范围有限的 capability。在本章中,我们将第三章中引入的聊天滑块功能移动到一个功能模块中,并提高了其功能。除了聊天滑块之外,其他功能模块的例子可能包括图片查看器、账户管理面板,或者用户可能在其中组装图形对象的工坊。
我们设计的功能模块与我们的应用程序接口类似,就像第三方模块一样——具有定义明确的 API 和强大的隔离性。这使得我们能够更早地以更高的质量发布,因为我们可以专注于创建我们的增值核心模块,同时将次要模块留给第三方。这种策略还提供了一条清晰的增强路径,因为我们可以在时间和资源允许的情况下,选择性地用更好的模块替换第三方模块。作为额外的优势,我们的模块易于在多个项目中重用。
4.1. 功能模块策略
第三章中讨论的 Shell 负责应用范围内的任务,如管理 URI 锚点或 cookies,并将特定功能的任务调度到精心隔离的功能模块。这些模块有自己的视图、控制器,以及 Shell 与他们共享的一部分模型。架构概述如图 4.1 所示图 4.1:^([1])
¹ 作者将此图贴在办公桌旁边的墙上。
图 4.1. SPA 架构中的功能模块(以白色显示)

样本功能模块可能包括spa.wb.js用于在工坊上绘图,spa.acct.js用于账户管理功能,如登录或登出,以及spa.chat.js用于聊天界面。由于我们似乎在聊天方面取得了进展,因此在本章中我们将重点关注该模块。
4.1.1. 与第三方模块的比较
功能模块与第三方模块非常相似,它们为现代网站提供各种功能.^([2]) 示例第三方模块包括博客评论(DisQus或LiveFyre)、广告(DoubleClick或ValueClick)、分析(Google或Overture)、分享(AddThis或ShareThis)和社交服务(Facebook“赞”或Google“+1”按钮)。它们非常受欢迎,因为网站运营商可以在极小的成本、努力和维护下,将高质量的功能添加到他们的网站上,而如果他们自己开发这些功能,成本将会非常高昂.^([3]) 通常,第三方模块是通过在静态网页中包含一个脚本标签或在 SPA 中添加一个函数调用来添加到网站上的。如果没有第三方模块,许多网站上的许多功能将无法实现,因为成本将是不切实际的。
² 要了解更多关于第三方模块及其创建的信息,请参阅 Ben Vinegar 和 Anton Kovalyov 的《第三方 JavaScript》(Manning,2012 年)。
³很难准确衡量第三方模块的流行程度,但很难找到一个没有至少一个第三方模块的商业网站。例如,在撰写本文时,我们在TechCrunch.com上发现了至少 16 个主要第三方模块在使用中,仅分析服务就有至少五个——以及惊人的 53 个脚本标签。
编写良好的第三方模块具有以下共同特征:
-
它们在自己的容器中渲染,这可能是由它们提供的,或者它们直接附加到文档上。
-
它们提供了一个定义良好的 API 来控制它们的行为。
-
它们通过仔细隔离它们的 JavaScript、数据和 CSS 来避免污染宿主页面。
第三方模块有一些缺点。主要问题是“第三方”有自己的商业目标,这可能与我们的目标相冲突。这可以以许多方式表现出来:
-
我们依赖于它们的代码和服务。 如果它们失败或停业,它们的服务可能会丢失。如果它们在发布时出错,甚至可能阻止我们的网站工作。遗憾的是,这种情况比应该发生的要频繁得多。
-
它们通常比自定义模块慢,因为服务器嘈杂或功能膨胀。如果一个第三方模块运行缓慢,它可能会减慢我们整个应用程序的速度。
-
隐私是一个关注点,因为每个第三方模块都有自己的服务条款,其中他们的律师几乎总是保留在第一时间改变的权利。
-
由于数据、样式不匹配或缺乏灵活性,功能往往无法无缝集成。
-
如果我们不能将它们的第三方数据集成到我们的 SPA 中,跨功能通信可能很困难或不可能。
-
模块的定制可能很困难或不可能。
我们的功能模块保留了第三方模块的积极特性,但由于没有第三方,我们避免了它们的缺点。这意味着对于给定的功能,Shell 提供了一个容器,功能模块填充并控制它,如图 4.2 所示。figure 4.2。功能模块为 Shell 提供了一致的 API,用于配置、初始化和使用。通过使用独特和协调的 JavaScript 和 CSS 命名空间,并且不允许调用任何外部调用(除了共享工具),功能与其他功能保持隔离。
图 4.2. Shell 和功能模块的责任

将功能模块开发成第三方模块的样子,让我们能够利用第三方风格 JavaScript 的好处:
-
团队可以更有效率,因为开发者可以根据模块分配责任。让我们面对现实:如果你在一个团队中工作,唯一不是第三方模块的就是你负责的那个模块。不负责模块的团队成员只需要了解它的 API 就能使用它。
-
当模块只管理它们负责的应用程序部分,并且它们针对我们的使用进行了优化,没有未使用或不想要的特性的冗余时,应用程序往往表现良好。
-
代码维护和重用要容易得多,因为模块被整齐地隔离。许多更复杂的 jQuery 插件,如日期选择器,实际上是第三方应用程序。想想使用日期选择器插件有多容易,而不是自己编写一个。
当然,开发我们的功能模块像第三方模块一样,还有一个其他巨大的优势:我们很好地定位了使用第三方模块来处理我们网络应用的非核心功能,然后根据时间和资源的允许,选择性地用我们自己的功能模块替换它们,这些模块可以更好地集成、更快、更少侵入性,或者以上所有。
4.1.2. 功能模块和分形 MVC 模式
许多网络开发者熟悉 模型-视图-控制器 (MVC) 设计模式,因为它在许多框架中都有介绍,如 Ruby on Rails、Django(Python)、Catalyst(Perl)、Spring MVC(Java)或 MicroMVC(PHP)。由于许多读者熟悉这个模式,我们将解释我们的 SPA 架构如何与之相关,特别是与功能模块相关。
让我们回顾一下 MVC 是一种用于开发应用程序的模式。它的部分包括:
-
模型,它提供应用程序的数据和业务规则。
-
视图,它提供了模型数据的感官(通常是视觉的,但也很常见的是音频)表示。
-
控制器,它将用户的请求转换为更新应用程序的模型和/或视图的命令。
熟悉网络 MVC 框架的开发者应该对这一章的大部分内容感到舒适。传统网络开发者对 MVC 框架的看法与我们的 SPA 架构之间最大的区别如下:
-
我们的 SPA 尽可能地将应用程序移动到浏览器中。
-
我们认识到 MVC 模式就像在分形中一样重复。
分形 是一种在所有级别上显示自相似性的模式。一个简单的例子在图 4.3 中展示,从远处看,我们看到一个一般模式,当我们更仔细地看时,我们看到模式在更精细的细节级别上重复。
图 4.3. 箱形分形

我们的 SPA 架构在多个级别上采用重复的 MVC 模式,所以我们称之为 分形模型-视图-控制器,或 FMVC。这个概念并不新鲜,开发者们至少已经讨论了十年。我们看到的分形有多少是一个视角问题。当我们从远处看我们的网络应用,如图 4.4 所示,我们看到一个单独的 MVC 模式——控制器处理 URI 和用户输入,与模型交互,并在浏览器中提供视图。
图 4.4. 从远处看我们的网络应用

当我们稍微放大一些,如图图 4.5 所示,我们看到 Web 应用程序被分为两部分:服务器端,它使用 MVC 模式向客户端提供数据,以及 SPA,它使用 MVC 允许用户查看和与浏览器模型交互。服务器的模型包括数据库中的数据,而视图是发送到浏览器的数据表示,控制器是协调数据管理和与浏览器通信的代码。在客户端,模型包括从服务器接收到的数据,视图是用户界面,控制器是协调客户端数据与界面的逻辑。
图 4.5. 我们的 Web 应用稍微近一点

当我们进一步放大,如图图 4.6 所示,我们看到了更多的 MVC 模式。例如,服务器应用程序使用 MVC 模式提供 HTTP 数据 API。服务器应用程序使用的数据库也采用自己的 MVC 模式。在客户端,客户端应用程序使用 MVC 模式,而 Shell 调用从属功能模块,这些模块本身也使用 MVC 模式。
图 4.6. 我们的 Web 应用近距离观察

几乎所有现代网站都符合这种模式,即使开发者没有意识到这一点。例如,一旦开发者从DisQus或LiveFyre添加评论功能到他们的博客——或者几乎任何其他第三方模块——他们就在添加另一个 MVC 模式。
我们的 SPA 架构采用了这种分形 MVC 模式。换句话说,无论整合第三方功能还是我们自己编写的功能模块,我们的 SPA 工作方式几乎相同。图 4.7 显示了我们的聊天模块将如何使用自己的 MVC 模式。
图 4.7. MVC 模式在我们聊天功能模块中的体现

我们已经讨论了功能模块在我们架构中的位置,它们与第三方模块的相似之处,以及它们如何采用分形 MVC。在下一节中,我们将应用这些概念并创建我们的第一个功能模块。
4.2. 设置功能模块文件
我们将要创建的第一个 SPA 功能模块将是聊天功能模块,我们将在此章节中将其称为 Chat。我们选择这个功能是因为我们在第三章中已经对其进行了大量工作,并且转换有助于突出功能模块的定义特征。
4.2.1. 规划文件结构
我们建议您将第三章中创建的整个目录结构复制到一个新的“chapter_4”目录中,这样我们就可以在那里更新它们。让我们回顾一下我们在第三章中留下的文件结构,如列表 4.1 所示:
列表 4.1. 第三章中的文件结构
spa
+-- css
| +-- spa.css
| `-- spa.shell.css
+-- js
| +-- jq
| | +-- jquery-1.9.1.js
| | `-- jquery.uriAnchor-1.1.3.js
| +-- spa.js
| `-- spa.shell.js
+-- layout.html
`-- spa.html
这里是我们希望做出的更改:
-
为 Chat 创建一个命名空间样式表。
-
为 Chat 创建一个命名空间 JavaScript 模块。
-
为浏览器模型创建一个占位符。
-
创建一个为所有其他模块提供常用例程的实用模块。
-
修改浏览器文档以包含新文件。
-
删除我们用来开发布局的文件。
当我们完成时,我们的更新后的文件和目录应该看起来像列表 4.2。所有我们需要创建或修改的文件都显示为粗体:
列表 4.2. Chat 的修订版文件结构

既然我们已经确定了要添加或修改的文件,让我们启动我们信任的文本编辑器并完成工作。我们将按照我们展示的顺序逐一考虑每个文件。
4.2.2. 填充文件
我们要考虑的第一个文件是 Chat 风格表,spa/css/spa.chat.css。我们将创建一个文件,并用列表 4.3 中显示的内容填充它。最初,它将是一个占位符:^([4])
⁴ 占位符是有意不完整或占位符资源。例如,在第五章中,我们创建了一个“占位符”数据模块,它模拟与服务器的通信。
列表 4.3. 我们的风格表(占位符)—spa/css/spa.chat.css
/*
* spa.chat.css
* Chat feature styles
*/
接下来,让我们创建我们的 Chat 功能模块,spa/js/spa.chat.js,如列表 4.4 所示,使用我们的模块模板附录 A。这只是一个初步的版本,我们将让它填充聊天滑块容器中的某些简单 HTML:
列表 4.4. 我们的 Chat 模块,功能有限—spa/js/spa.chat.js


现在,让我们创建我们的模型,如列表 4.5 所示。这同样是一个占位符。像所有我们的模块一样,文件名(spa.model.js)表示它提供的命名空间(spa.model):
列表 4.5. 我们的模型(占位符)—spa/js/spa.model.js
/*
* spa.model.js
* Model module
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.model = (function (){ return {}; }());
让我们创建一个通用实用模块,这样我们就可以在所有模块之间共享常用例程,如列表 4.6 所示。makeError方法可以用来轻松创建错误对象。setConfigMap方法提供了一种简单且一致的方式来更改模块的设置。因为这些是公共方法,我们详细说明它们的使用,以供其他开发者参考:
列表 4.6. 公共实用工具—spa/js/spa.util.js
/*
* spa.util.js
* General JavaScript utilities
*
* Michael S. Mikowski - mmikowski at gmail dot com
* These are routines I have created, compiled, and updated
* since 1998, with inspiration from around the web.
*
* MIT License
*
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.util = (function () {
var makeError, setConfigMap;
// Begin Public constructor /makeError/
// Purpose: a convenience wrapper to create an error object
// Arguments:
// * name_text - the error name
// * msg_text - long error message
// * data - optional data attached to error object
// Returns : newly constructed error object
// Throws : none
//
makeError = function ( name_text, msg_text, data ) {
var error = new Error();
error.name = name_text;
error.message = msg_text;
if ( data ){ error.data = data; }
return error;
};
// End Public constructor /makeError/
// Begin Public method /setConfigMap/
// Purpose: Common code to set configs in feature modules
// Arguments:
// * input_map - map of key-values to set in config
// * settable_map - map of allowable keys to set
// * config_map - map to apply settings to
// Returns: true
// Throws : Exception if input key not allowed
//
setConfigMap = function ( arg_map ){
var
input_map = arg_map.input_map,
settable_map = arg_map.settable_map,
config_map = arg_map.config_map,
key_name, error;
for ( key_name in input_map ){
if ( input_map.hasOwnProperty( key_name ) ){
if ( settable_map.hasOwnProperty( key_name ) ){
config_map[key_name] = input_map[key_name];
}
else {
error = makeError( 'Bad Input',
'Setting config key |' + key_name + '| is not supported'
);
throw error;
}
}
}
};
// End Public method /setConfigMap/
return {
makeError : makeError,
setConfigMap : setConfigMap
};
}());
最后,我们可以通过修改我们的浏览器文档来加载新的 JavaScript 和 CSS 文件,将这些更改全部结合起来。首先我们将加载我们的样式表,然后是我们的 JavaScript。JavaScript 库的包含顺序 确实 重要:第三方库应该首先加载,因为它们通常是先决条件,这种做法也有助于克服偶尔的第三方命名空间混乱(见侧边栏“为什么我们的库最后加载”)。我们的库接下来,必须按命名空间层次结构排序——例如,提供 spa、spa.model 和 spa.model.user 命名空间的模块必须按此顺序加载。任何超出此范围的排序都是惯例,并不是必需的。我们喜欢这种惯例:根 -> 核心工具 -> 模型 -> 浏览器工具 -> Shell -> 功能模块。
为什么我们的库最后加载
我们喜欢我们的库对命名空间拥有最终决定权,因此我们最后加载它们。如果某些流氓第三方库声称 spa.model 命名空间,我们的库在加载时会“取回”它。如果发生这种情况,我们的单页应用(SPA)有很大机会继续运行,尽管第三方功能可能不会工作。如果库的顺序被颠倒,我们的 SPA 几乎肯定会被完全破坏。我们宁愿修复第三方评论功能的问题,也不愿向 CEO 解释为什么我们的网站在午夜时分 完全停止工作。
让我们更新我们的浏览器文档,如 列表 4.7 所示。从 第三章 的更改以粗体显示:
列表 4.7. 浏览器文档的更改—spa/spa.html



现在,让我们按照 列表 4.8 中的说明配置和初始化 Shell。所有更改都以粗体显示:
列表 4.8. Shell 修订版—spa/js/spa.shell.js
...
// configure uriAnchor to use our schema
$.uriAnchor.configModule({
schema_map : configMap.anchor_schema_map
});
// configure and initialize feature modules
spa.chat.configModule( {} );
spa.chat.initModule( jqueryMap.$chat );
// Handle URI anchor change events
...
我们现在完成了第一次遍历。尽管这是一项相当多的工作,但其中许多步骤对于未来的功能模块可能不再需要。现在让我们看看我们创造了什么。
4.2.3. 我们所造成的
当我们加载我们的浏览器文档 (spa/spa.html) 时,聊天滑块应看起来像 图 4.8。
图 4.8. 我们更新的浏览器文档—spa/spa.html

Say hello to chat 文本显示 Chat 已正确配置和启动,并且它已提供了聊天滑块内容。但这次展示远非令人印象深刻。在下一节中,我们将显著改进聊天界面。
4.3. 设计方法 API
根据我们的架构,Shell 可以调用 SPA 中的任何从属模块。功能模块应该只调用共享实用模块;功能模块之间的调用是不允许的。功能模块所需的数据或能力只能来自 Shell,以模块公共方法提供的参数形式,例如在配置或初始化期间。图 4.9 展示了这种分层结构。
图 4.9. 功能模块的近距离视图—允许的调用

这种隔离是故意的,因为它有助于防止特定功能的缺陷传播到应用层或其他功能。5
⁵ 功能模块之间的通信应始终由 Shell 或模型协调。
4.3.1. 锚接口模式
回想一下 第三章 中我们希望 URI 锚始终驱动页面状态,而不是相反。有时执行路径可能难以追踪,因为 Shell 负责 URI 锚的管理,而 Chat 负责滑块展示。我们依靠 锚接口模式 来支持 URI 锚和用户事件驱动的状态,在两种情况下都使用相同的 jQuery hashchange 事件。这种单一的应用程序状态更改路径确保了历史安全 URL、一致的行为,并有助于加速开发,因为只有一个状态更改机制。该模式在 图 4.10 中展示。
⁶ “历史安全”意味着浏览器历史控制,如前进、后退、书签和浏览器历史,都按用户期望的方式工作。
图 4.10. Chat 的锚接口模式

我们已经在上一章中实现了 Chat 的许多行为。现在,让我们将剩余的聊天代码移动到它自己的模块中。同时,我们也要指定 Chat 和 Shell 将用于通信的 API。这将立即为我们带来好处,并使代码重用变得更加简单。API 规范需要详细说明所需的资源以及将提供的功能。它们应被视为“活文档”,并在 API 发生更改时进行更新。
我们希望 Chat 提供的一个常见公共方法是 configModule,我们将使用它来在初始化之前更改设置。Chat,像每个功能模块一样,通常应该有一个初始化方法 initModule,然后我们将使用它来指导模块向用户提供其功能。我们还想让 Chat 提供一个 setSliderPosition 方法,以便 Shell 可以请求滑块位置。我们将在以下部分设计这些方法的 API。
4.3.2. Chat 配置 API
当我们 配置 一个模块时,我们调整那些我们希望在用户会话期间不会改变的设置。使用 Chat,以下设置符合这一标准:
-
一个提供调整
chatURI 锚参数能力的函数。 -
提供发送和接收消息方法的对象(来自模型)。
-
提供与用户列表交互方法的对象(来自模型)。
-
任何数量的行为设置,如滑块打开高度、滑块打开时间和滑块关闭时间。
JavaScript 参数的真相
记住,只有简单的值——字符串、数字和布尔值——是直接传递给函数的。JavaScript 中的所有复杂数据类型(如对象、数组和函数)都是按引用传递的。这意味着它们永远不会像某些其他语言中那样被复制。相反,传递一个内存位置值。这通常比复制要快得多,但缺点是很容易意外更改通过引用传递的对象或数组。
当一个函数期望一个函数引用作为参数时,这个引用通常被称为回调。回调功能强大,但它们可能变得难以管理。我们通过使用 jQuery 全局自定义事件在第五章和第六章中展示了如何减少回调的使用。
根据这些预期,我们可以制定 Chat configModule API 规范,如列表 4.9 所示。此文档不用于 JavaScript:
列表 4.9. Chat API 规范——spa/js/spa.chat.js
// Begin public method /configModule/
// Example : spa.chat.configModule({ slider_open_em : 18 });
// Purpose : Configure the module prior to initialization
// Arguments :
// * set_chat_anchor - a callback to modify the URI anchor to
// indicate opened or closed state. This callback must return
// false if the requested state cannot be met
// * chat_model - the chat model object provides methods
// to interact with our instant messaging
// * people_model - the people model object which provides
// methods to manage the list of people the model maintains
// * slider_* settings. All these are optional scalars.
// See mapConfig.settable_map for a full list
// Example: slider_open_em is the open height in em's
// Action :
// The internal configuration data structure (configMap) is
// updated with provided arguments. No other actions are taken.
// Returns : true
// Throws : JavaScript error object and stack trace on
// unacceptable or missing arguments
//
现在我们已经有了 Chat 配置的 API,让我们为 Shell 中的setChatAnchor回调制定一个规范。列表 4.10 是一个很好的起点。此文档不用于 JavaScript:
列表 4.10. Shell API 规范——setChatAnchor回调——spa/js/spa.shell.js
// Begin callback method /setChatAnchor/
// Example : setChatAnchor( 'closed' );
// Purpose : Change the chat component of the anchor
// Arguments:
// * position_type - may be 'closed' or 'opened'
// Action :
// Changes the URI anchor parameter 'chat' to the requested
// value if possible.
// Returns :
// * true - requested anchor part was updated
// * false - requested anchor part was not updated
// Throws : none
//
现在我们已经完成了 Chat 配置 API 和 Shell 回调 API 的设计,让我们继续到 Chat 初始化。
4.3.3. Chat 初始化 API
当我们初始化我们的功能模块之一时,我们要求它渲染 HTML 并开始向用户提供其功能。与配置不同,我们预计功能模块在用户会话期间可能会被初始化多次。在 Chat 的情况下,我们希望发送一个 jQuery 集合作为参数。jQuery 集合将包含一个元素——我们想要附加聊天滑块的元素。让我们绘制如列表 4.11 所示的 API。此文档不用于 JavaScript:
列表 4.11. Chat API 规范——spa/js/spa.chat.js
// Begin public method /initModule/
// Example : spa.chat.initModule( $('#div_id') );
// Purpose :
// Directs Chat to offer its capability to the user
// Arguments :
// * $append_target (example: $('#div_id')).
// A jQuery collection that should represent
// a single DOM container
// Action :
// Appends the chat slider to the provided container and fills
// it with HTML content. It then initializes elements,
// events, and handlers to provide the user with a chat-room
// interface
// Returns : true on success, false on failure
// Throws : none
//
本章我们将指定的最后一个 API 将是 Chat setSliderPosition方法。这将用于打开和关闭聊天滑块。我们将在下一节中处理这个问题。
4.3.4. Chat setSliderPosition API
我们决定让 Chat 提供公共方法setSliderPosition,这将使 Shell 能够请求滑块位置。我们将滑块位置与 URI 锚点关联的决定引发了一些我们需要解决的问题:
-
聊天可能无法总是调整滑块到请求的位置。例如,它可能决定滑块不能打开,因为用户未登录。我们将
setSliderPosition返回true或false,这样 Shell 就会知道请求是否成功。 -
如果 Shell 调用
setSliderPosition回调,并且回调无法满足请求(换句话说,它返回false),Shell 需要将 URI 锚点chat参数重置为请求之前的值。
让我们指定一个符合这些要求的 API,如列表 4.12 所示。这份文档不用于 JavaScript:
列表 4.12. setSliderPosition的聊天 API 规范——spa/js/spa.chat.js
// Begin public method /setSliderPosition/
//
// Example : spa.chat.setSliderPosition( 'closed' );
// Purpose : Ensure chat slider is in the requested state
// Arguments:
// * position_type - enum('closed', 'opened', or 'hidden')
// * callback - optional callback at end of animation.
// (callback receives slider DOM element as argument)
// Action :
// Leaves slider in current state if it matches requested,
// otherwise animate to requested state.
// Returns :
// * true - requested state achieved
// * false - requested state not achieved
// Throws : none
//
定义了这个 API 之后,我们几乎准备好编写一些代码了。但在我们这样做之前,让我们看看配置和初始化是如何通过我们的应用程序级联的。
4.3.5. 配置和初始化级联
我们的配置和初始化遵循一个常见的模式。首先,我们浏览器文档中的一个脚本标签配置和初始化我们的根命名空间模块spa.js。然后我们的根模块配置和初始化 Shell 模块spa.shell.js。然后 Shell 模块配置和初始化我们的功能模块spa.chat.js。这种配置和初始化的级联在图 4.11 中显示。
图 4.11. 配置和初始化级联
![04fig11_alt.jpg]
我们的所有模块都提供了一个公共的initModule方法。我们只提供config-Module方法,如果我们需要支持设置。在开发这个阶段,只有聊天可以配置。
当我们加载浏览器文档(spa/spa.html)时,它会加载我们所有的 CSS 和 JavaScript 文件。接下来页面中的一个脚本执行初始维护并初始化根命名空间模块(spa/js/spa.js),向它提供一个页面元素(spa div)供其使用:
$(function (){
// housekeeping here ...
// if we needed to configure the root module,
// we would invoke spa.configModule first
spa.initModule( $('#spa' ) );
}());
当初始化时,根命名空间模块(spa/js/spa.js)执行任何根级别的维护工作,然后配置和初始化 Shell(spa/js/spa.shell.js),为它提供一个页面元素($container)供其使用:
var initModule = function ( $container ){
// housekeeping here ...
// if we needed to configure the Shell,
// we would invoke spa.shell.configModule first
spa.shell.initModule( $container );
};
Shell(spa/js/spa.shell.js)随后执行任何 Shell 级别的维护工作,并配置和初始化所有其功能模块,例如聊天(spa/js/spa.chat.js),为它提供一个页面元素(jqueryMap.$chat)供其使用:
initModule = function ( $container ) {
// housekeeping here ...
// configure and initialize feature modules
spa.chat.configModule( {} );
spa.chat.initModule( jqueryMap.$chat );
// ...
};
我们需要对这个级联感到舒适,因为所有功能模块都是一样的。例如,我们可能希望将聊天(spa/js/spa.chat.js)的一些功能拆分到一个从属模块中,该模块处理在线用户列表——我们将它称为名单——并在 spa/js/spa.chat.roster.js 中创建其文件。然后我们将让聊天使用spa.chat.roster.configModule方法配置该模块,并使用spa.chat.roster.initModule方法初始化它。聊天还会向名单提供一个 jQuery 容器,名单将在其中显示用户列表。
现在我们已经回顾了配置和初始化的级联过程,我们准备将我们的应用程序更新到我们设计的 API。我们将进行一些改动,这可能会暂时破坏一些功能,所以如果你在家中进行操作,请不要慌张——我们很快就会修复这些问题。
4.4. 实现功能 API
本节的主要目标是实现我们定义的 API。而且,正如他们所说,“代码已经准备好”,我们还想处理一些次要目标:
-
完成将 Chat 配置和实现移动到其自己的模块。Shell 唯一需要担心的是 Chat 的 URI 锚管理。
-
更新聊天功能,使其看起来更“健谈”。
我们需要更新的文件以及它们需要如何更改的总结在列表 4.13 中展示。
列表 4.13. 我们在 API 实现过程中将更改的文件
spa
+-- css
| +-- spa.chat.css # Move chat styles from spa.shell.css, enhance
| `-- spa.shell.css # Remove chat styles
`-- js
+-- spa.chat.js # Move capabilities from the Shell, implement APIs
`-- spa.shell.js # Removed Chat capabilities
# and add setSliderPosition callback per API
我们将按照展示的顺序修改这些文件。
4.4.1. 样式表
我们希望将所有 Chat 样式移动到它们自己的样式表(spa/css/spa.chat.css)中,并在这样做的同时改进我们的布局。我们的本地 CSS 布局专家提供了一个很好的计划,如图 4.12 所示。
图 4.12. 元素和选择器的 3D 视图—spa/css/spa.chat.css

注意我们如何像处理 JavaScript 一样命名空间我们的 CSS。这有诸多优点:
-
我们不需要担心与其他模块发生冲突,因为我们保证所有类名都有一个唯一的前缀:
spa-chat。 -
与第三方包的冲突几乎总是可以避免。即使有些奇怪的情况它们没有避免,修复(更改前缀)也是微不足道的。
-
这对调试非常有帮助,因为当我们检查由 Chat 控制的元素时,其类名会指引我们到原始的功能模块,
spa.chat。 -
名称表明了什么包含(因此控制)什么。例如,注意
spa-chat-head-toggle包含在spa-chat-head中,而spa-chat-head又包含在spa-chat中。
这部分样式大多是样板代码(抱歉,CSS 布局专家)。但我们有几个点会使我们的工作变得特别。首先,spa-chat-sizer元素需要有一个固定的高度。这将即使在滑块缩回时也为聊天和消息区域提供空间。如果没有这个元素,当滑块缩回时,滑块内容会被“挤压”,这对用户来说至多也是令人困惑的。其次,我们的布局专家希望我们移除所有对绝对像素的引用,转而使用相对测量,如em和百分比。这将使我们的 SPA 在低密度和高密度显示上都能同样良好地展示。
像素与相对单位
HTML 专家在开发 CSS 时经常采取严肃的扭曲来使用相对度量,完全避免使用px单位,以便他们的创作可以在任何尺寸的显示上良好工作。我们已经观察到一种现象,这让我们重新考虑了这种努力的价值:浏览器对其像素维度撒谎。
考虑最新的超高清分辨率笔记本电脑、平板电脑和智能手机的显示器。这些设备上的浏览器不会直接将浏览器中的px与物理屏幕像素相关联。相反,它们将px单位标准化,以便观看体验近似于具有每英寸 96 到 120 像素密度的传统桌面显示器。
结果是,在智能手机浏览器上渲染的10 px正方形框实际上每边可能有 15 或 20 个物理像素。这意味着px也已成为一个相对单位,与其他所有单位(%,in,cm,mm,em,ex,pt,pc)相比,它通常更可靠。我们拥有其他设备,例如 10.1 英寸和 7 英寸的平板电脑,它们具有相同的 1280 x 800 分辨率和相同的操作系统。一个400 px的正方形框可以适应 10.1 英寸的平板电脑屏幕;但在 7 英寸的平板电脑上则不行。为什么?因为每个px使用的物理像素数量在较小的平板电脑上更高。看起来较大平板电脑的缩放比例是每px 1.5 像素,而较小平板电脑是每px 2 像素。
我们不知道未来会怎样,但最近我们在使用px单位时感到的罪恶感少了很多。
在所有这些计划之后,我们现在可以将符合规格的 CSS 添加到 spa.chat.css 中,如列表 4.14 所示:
列表 4.14. 添加增强的 Chat 样式—spa/css/spa.chat.css


现在我们有了 Chat 的样式表,我们可以从 spa/css/spa.shell.css 中的 Shell 样式表中移除之前的定义。首先,让我们从绝对定位选择器的列表中移除.spa-shell-chat。更改应如下所示(我们可以省略注释):
.spa-shell-head, .spa-shell-head-logo, .spa-shell-head-acct,
.spa-shell-head-search, .spa-shell-main, .spa-shell-main-nav,
.spa-shell-main-content, .spa-shell-foot, */* .spa-shell-chat */*
.spa-shell-modal {
position : absolute;
}
我们还希望从 spa/css/spa.shell.css 中移除任何.spa-shell-chat类。如下所示,有两个要删除:
/* delete these from spa/css/spa.shell.css
.spa-shell-chat {
bottom : 0;
right : 0;
width : 300px;
height : 15px;
cursor : pointer;
background : red;
border-radius : 5px 0 0 0;
z-index : 1;
}
.spa-shell-chat:hover {
background : #a00;
} */
最后,让我们隐藏模态容器,以免它妨碍我们的聊天滑块:
...
.spa-shell-modal {
...
display: none;
}
...
到目前为止,我们应该能够打开我们的浏览器文档(spa/spa.html),在 Chrome 开发者工具的 JavaScript 控制台中看不到任何错误。但聊天滑块将不再可见。保持冷静,继续前进——我们将在下一节完成修改 Chat 时修复这个问题。
4.4.2. 修改 Chat
我们现在将修改 Chat 以实现我们之前设计的 API。以下是我们的计划更改:
-
添加我们更详细的聊天滑块的 HTML。
-
将配置扩展以包括滑块高度和缩回时间等设置。
-
创建将
em单位转换为px(像素)的getEmSize实用工具。 -
更新
setJqueryMap以缓存更新聊天滑块的许多新元素。 -
添加
setPxSizes方法,使用像素单位设置滑块尺寸。 -
实现与我们的 API 匹配的
setSliderPosition公共方法。 -
创建
onClickToggle事件处理器以更改 URI 锚点并立即返回。 -
更新
configModule公共方法文档以匹配我们的 API。 -
更新
initModule公共方法以匹配我们的 API。
让我们更新 Chat 以实现这些更改,如列表 4.15 所示。我们之前设计的 API 规范已复制到该文件中,并在实现过程中用作指南。这加速了开发并确保了未来维护的准确文档。所有更改均以粗体显示:
列表 4.15. 修改 Chat 以符合 API 规范—spa/js/spa.chat.js


到目前为止,我们应该能够加载浏览器文档(spa/spa.html),并且在 Chrome 开发者工具的 JavaScript 控制台中不会看到任何错误。我们应该看到聊天滑块的上部。但如果我们点击它,我们应该在控制台中看到类似于“set_chat_anchor 不是函数”的错误消息。我们将在清理 Shell 时修复这个问题。
4.4.3. 清理 Shell
现在,我们将通过更新 Shell 来完成我们的更改。这是我们想要做的事情:
-
删除大多数聊天滑块设置和功能,因为这些已移动到 Chat。
-
修改
onHashchange事件处理器,如果无法设置请求的滑块位置,则回退到有效位置。 -
添加
setChatAnchor方法以满足我们之前设计的 API。 -
改善
initModule文档。 -
将
initModule更新为使用我们之前设计的 API 配置 Chat。
让我们按照列表 4.16 所示修改 Shell。注意,我们之前开发的所有新 API 规范都直接放置在这个文件中,并在实现过程中用作指南。所有更改均以粗体显示:
列表 4.16. 清理 Shell—spa/js/spa.shell.js


当我们打开浏览器文档(spa/spa.html)时,我们现在应该看到类似于图 4.13 的内容。我们认为这个修订后的聊天滑块显著更炫酷。它还没有显示消息——我们将在第六章中介绍这一功能。
图 4.13. 我们更炫酷的 Chat 滑块

现在代码运行良好,让我们通过分析应用程序的执行过程来查看一些关键修订。
4.4.4. 执行过程
本节突出了我们在上一节中对应用程序所做的修订。我们查看应用程序的配置和初始化方式,然后探索当用户点击聊天滑块时会发生什么。
当我们加载浏览器文档(spa/spa.html)时,一个脚本初始化我们的根命名空间(spa/js/spa.js),向它提供一个页面元素(#spa div)供其使用:
$(function (){ spa.initModule( $('#spa') ); });
根命名空间模块(spa/js/spa.js)随后初始化 Shell(spa/js/spa.shell.js),向它提供一个页面元素($container)供其使用:
var initModule = function ( $container ){
spa.shell.initModule( $container );
};
Shell(spa/js/spa.shell.js)随后配置并初始化 Chat(spa/js/spa.chat.js)。但这次这两个步骤都有点不同。配置现在与之前定义的 API 匹配。set_chat_anchor 配置是遵循我们之前创建的规范的回调:
...
// configure and initialize feature modules
spa.chat.configModule({
set_chat_anchor : setChatAnchor,
chat_model : spa.model.chat,
people_model : spa.model.people
});
spa.chat.initModule(jqueryMap.$container);
...
Chat 初始化也有细微的不同:Shell 现在提供的是一个容器,Chat 将会将其聊天滑块 附加 到这个容器上。如果你信任模块作者,这是一个很好的安排。我们确实信任他们。
...
// * set_chat_anchor - a method modify to modify the URI anchor to
// indicate opened or closed state. Return false if requested
// state cannot be met.
...
当用户点击滑块切换按钮时,Chat 使用 set_chat_anchor 回调请求将 URI 锚点的 chat 参数更改为 打开 或 关闭,然后返回。Shell 仍然处理 hashchange 事件,如我们在 spa/js/spa.shell.js 中看到的:
initModule = function ( $container ){
...
$(window)
.bind( 'hashchange', onHashchange )
...
因此,当用户点击滑块时,hashchange 事件被 Shell 捕获,它将调度到 onHashchange 事件处理器。如果 URI 锚点的聊天组件已更改,此例程将调用 spa.chat.setSliderPosition 请求新的位置:
// Begin adjust chat component if changed
if ( ! anchor_map_previous
|| _s_chat_previous !== _s_chat_proposed
) {
s_chat_proposed = anchor_map_proposed.chat;
switch ( s_chat_proposed ) {
case 'opened' :
is_ok = spa.chat.setSliderPosition( 'opened' );
break;
case 'closed' :
is_ok = spa.chat.setSliderPosition( 'closed' );
break;
...
}
}
// End adjust chat component if changed
如果位置有效,滑块将移动到请求的位置,并将 URI 锚点的 chat 参数更改为。
我们所做的更改导致了一个符合我们设计目标实现。URI 控制聊天滑块状态,我们还把所有 Chat UI 逻辑和代码移动到了我们的新功能模块中。滑块看起来和运行得都更好。现在让我们添加一些在许多功能模块中常见的其他公共方法。
4.5. 添加常用方法
在功能模块中,一些公共方法被频繁使用,因此它们值得单独讨论。第一个是一个重置方法(removeSlider);第二个是一个窗口调整大小方法(handleResize)。我们将实现这两个方法。首先,让我们在模块作用域变量部分的底部在 Chat 中声明这些方法名称,并在模块末尾将它们作为公共方法导出,如列表 4.17 所示。变化以粗体显示:
列表 4.17. 声明方法函数名称——spa/js/spa.chat.js
...
jqueryMap = {},
setJqueryMap, getEmSize, setPxSizes, setSliderPosition,
onClickToggle, configModule, initModule,
removeSlider, handleResize
;
//----------------- END MODULE SCOPE VARIABLES ---------------
...
// return public methods
return {
setSliderPosition : setSliderPosition,
configModule : configModule,
initModule : initModule,
removeSlider : removeSlider,
handleResize : handleResize
};
//------------------- END PUBLIC METHODS ---------------------
}());
现在方法名称已声明,我们将在以下部分实现它们,从移除方法开始。
4.5.1. 移除滑块方法
我们发现我们想要为许多功能模块实现一个 remove 方法。例如,如果我们实现身份验证,我们可能希望在用户登出时完全移除聊天滑块。通常,这种动作是为了提高性能或增强安全性——假设 remove 方法能够很好地删除过时的数据结构。
我们的方法需要删除 Chat 添加的 DOM 容器,并按照顺序撤销我们的初始化和配置。列表 4.18 包含了 removeSlider 方法的代码更改。更改内容以粗体显示:
列表 4.18. removeSlider 方法——spa/js/spa.chat.js
...
// End public method /initModule/
// Begin public method /removeSlider/
// Purpose :
// * Removes chatSlider DOM element
// * Reverts to initial state
// * Removes pointers to callbacks and other data
// Arguments : none
// Returns : true
// Throws : none
//
removeSlider = function () {
// unwind initialization and state
// remove DOM container; this removes event bindings too
if ( jqueryMap.$slider ) {
jqueryMap.$slider.remove();
jqueryMap = {};
}
stateMap.$append_target = null;
stateMap.position_type = 'closed';
// unwind key configurations
configMap.chat_model = null;
configMap.people_model = null;
configMap.set_chat_anchor = null;
return true;
};
// End public method /removeSlider/
// return public methods
...
我们不试图在任何一个 remove 方法上表现得过于聪明。目的是破坏任何先前的配置和初始化,仅此而已。我们仔细确保数据指针被移除。这是很重要的,因为这样可以让数据结构的引用计数降到 0,从而允许垃圾回收完成其工作。这就是为什么我们总是在模块的顶部列出潜在的 configMap 和 stateMap 键的原因之一——这样我们可以看到我们需要清理什么。
我们可以通过打开 Chrome 开发者工具的 JavaScript 控制台并输入以下内容来测试 removeSlider 方法(别忘了按 Return!):
spa.chat.removeSlider();
当我们检查浏览器窗口时,我们可以看到聊天滑块已经被移除。如果我们想将其恢复,我们可以将以下几行代码输入到 JavaScript 控制台中:
spa.chat.configModule({ set_chat_anchor: function (){ return true; } });
spa.chat.initModule( $( '#spa') );
我们通过 JavaScript 控制台“恢复”的聊天滑块并不完全功能,因为我们为 set_chat_anchor 回调提供了 null 函数。在实际使用中,我们总是会从 Shell 中重新启用聊天模块,在那里我们可以访问所需的回调。
我们可以用这个方法做更多的事情——比如让滑块优雅地消失——但我们将其留作读者的练习。现在让我们实现另一个通常由功能模块所需的方法,handleResize。
4.5.2. 处理 handleResize 方法
第二个在许多功能模块中常见的通用方法是 handleResize。通过良好的 CSS 使用,大多数内容在 SPA 中都可以在合理大小的窗口中工作。但有些情况下大多数是不行的,需要一些重新计算。让我们首先实现如列表 4.19 所示的 handleResize 方法,然后讨论其用法。更改内容以粗体显示:
列表 4.19. 添加 handleResize 方法——spa/js/spa.chat.js


handleResize 事件不会调用自身。现在我们可能会想为每个功能模块实现一个 window.resize 事件处理器,但这不是一个好主意。问题是 window.resize 事件触发的频率因浏览器而异。假设我们有五个功能模块,它们都拥有 window.resize 事件处理器,并且用户决定调整浏览器的大小。如果 window.resize 事件每 10 毫秒触发一次,并且产生的图形变化足够复杂,这很容易将单页应用(SPA)——以及可能运行在其上的整个浏览器和操作系统——拖垮。
一个更好的方法是让 Shell 事件处理器捕获调整大小事件,然后调用所有从属功能模块的 handleResize 方法。这允许我们限制调整大小处理和从单个事件处理器分发。让我们在 Shell 中实现此策略,如 代码清单 4.20 所示。更改以粗体显示:
代码清单 4.20. 添加 onResize 事件处理器——spa/js/spa.shell.js


我们想调整我们的样式表,以便更好地看到我们劳动的成果。在 代码清单 4.21 中,我们调整 spa.css 以减小最小窗口大小,改为使用相对单位,并移除内容周围的冗余边框。更改以粗体显示:
代码清单 4.21. 对 onResize 进行样式更改——spa/css/spa.css


我们现在可以通过打开我们的浏览器文档 (spa/spa.html) 并增加或减少浏览器窗口高度来观察调整大小事件的工作。 比较了达到阈值前后滑块的展示:
图 4.14. 阈值前后聊天滑块大小的比较

当然,总有更多的改进空间。一个很好的增强是让滑块保持与顶部边界的最小距离。例如,如果窗口超过阈值 0.5 em,滑块可以精确地比正常短 0.5 em。这将提供更好的用户体验,最佳的聊天空间,并在调整大小时更加平滑。实现并不困难,留给读者作为练习。
4.6. 摘要
本章展示了如何利用功能模块利用第三方模块的优点,而不必承担所有缺点。我们定义了功能模块是什么,将它们与第三方模块进行比较,并讨论了它们如何适应我们的架构。我们探讨了我们的应用程序——以及大多数网站——如何包含 MVC 模式的分形重复,以及这在功能模块中的表现。然后,我们从 第三章 中开发的代码开始创建一个功能模块。在我们的第一次遍历中,我们添加了所有需要的文件并添加了基本功能。然后我们在第二次遍历中设计了我们的 API 并实现了它们。最后,我们添加了一些常用的功能模块方法并详细说明了它们的用法。
现在是时候将我们的业务逻辑集中到模型中。在接下来的几章中,我们将开发模型并展示如何体现用户、人员和聊天的业务逻辑。我们使用 jQuery 事件来触发 DOM 变化,而不是依赖于脆弱的回调,并模拟一个“实时”的聊天会话。请继续跟随我们——这是我们如何将我们的 SPA 从一个花哨的演示转变为一个几乎完整的客户端应用程序。
第五章. 构建模型
本章涵盖
-
定义模型及其在架构中的位置
-
模型、数据和模拟模块之间的关系
-
为模型设置文件
-
启用触摸设备
-
设计
people对象 -
构建
people对象并测试 API -
更新 Shell 以便用户可以登录和注销
本章基于本书第三章和第四章中编写的代码。在开始之前,你应该有第四章的项目文件,因为我们将在此基础上添加内容。我们建议你将第四章中创建的整个目录结构复制到一个新的“chapter_5”目录中,并在那里更新它。
在本章中,我们设计和构建模型中people对象的部分。模型为 Shell 和功能模块提供业务逻辑和数据。模型独立于用户界面(UI),并将其从逻辑和数据管理中隔离。模型本身通过使用数据模块与 Web 服务器隔离。
我们希望我们的 SPA 使用people对象来管理人员列表,包括用户以及他们正在聊天的人。在修改和测试模型后,我们更新 Shell,以便用户可以登录和注销。在此过程中,我们添加了触摸控制,这样我们就可以在智能手机或平板电脑上使用我们的 SPA。让我们先更好地了解模型的作用以及它如何融入我们的架构。
5.1. 理解模型
在第三章中,我们介绍了 Shell 模块,该模块负责应用范围内的任务,如 URI 锚点管理和应用布局。Shell 将特定功能的任务调度到我们在第四章中引入的精心隔离的功能模块。这些模块有自己的视图、控制器,以及 Shell 与它们共享的模型的一部分。架构概述如图 5.1 所示。图 5.1.^([1])
¹ 使用共享工具的模块组被虚线框包围。例如,聊天、头像和 Shell 模块都使用“浏览器工具”和“基本工具”,而数据和模型模块只使用“基本工具”。
图 5.1. 我们 SPA 架构中的模型

模型将所有业务逻辑和数据集中在一个命名空间中。Shell 或功能模块从不直接与 Web 服务器通信,而是与模型交互。模型本身通过使用数据模块与 Web 服务器隔离。这种隔离导致开发速度更快,质量更高,正如我们很快将看到的。
本章开始介绍模型的发展和运用。在第六章中,我们将完成这项工作。让我们看看在这两章中我们将取得什么成果,以及模型将需要具备的相应能力。
5.1.1. 我们将要构建的内容
在我们讨论模型之前,参考一个示例应用是有用的。图 5.2 展示了我们计划在第六章结束时添加到我们的 SPA 中的功能。Shell 将管理登录过程——我们可以在右上角看到已登录的用户。聊天功能模块将管理聊天窗口,它显示在右下角。而 头像 功能模块将管理代表左侧显示的人的彩色框。让我们考虑每个模块所需的业务逻辑和数据:
图 5.2. 我们近未来 SPA 的一个愿景

-
Shell 需要表示当前用户以管理登录和登出过程。它需要方法来确定当前用户是谁,并在需要时更改用户。
-
聊天功能模块还需要能够检查当前用户(例如,本例中的“Josh”),并确定他是否有权发送或接收消息。它还需要确定用户正在与哪个人聊天(如果有的话)。它还需要查询在线人员名单,以便在聊天滑块的左侧显示他们。最后,它还需要发送消息和选择聊天对象的方法。
-
头像功能模块还需要检查当前用户(“Josh”),并确定他是否有权查看和交互头像。它还需要当前用户标识,以便在蓝色中突出显示相关的头像。它还需要确定用户正在与哪个人聊天(“Betty”),以便在绿色中突出显示此人的头像。最后,它还需要为所有当前在线的人设置和检索头像细节(如颜色和位置)的方法。
我们模块所需的企业逻辑和数据有很多重叠。例如,我们知道 Shell、聊天和头像模块都需要当前用户对象。我们还知道我们需要为聊天和头像提供在线用户名单。关于如何管理这种重叠,有几个策略浮现在脑海中:
-
在每个功能模块中构建所需的逻辑和数据。
-
在不同的功能模块中构建逻辑和数据的一部分。例如,我们可能会考虑聊天是
people对象的所有者,而头像是chat对象的所有者。然后我们将在模块之间进行调用以共享信息。 -
建立一个中心模型来整合我们的逻辑和数据。
在不同模块中维护并行数据和方法的第一个选项既有趣又容易出错,而且劳动密集。如果我们这样做,我们可能会更愿意寻找一个令人兴奋的炸薯条翻工的工作。而且是的,我 确实 想要薯条。
第二个选项效果更好,但只能维持一段时间。一旦逻辑和数据达到中等复杂度,跨模块的依赖关系数量就会导致令人恐惧的“SPA-意大利面”代码。
在我们的经验中,使用模型是迄今为止最好的选择,同时也提供了不那么明显的益处。让我们看看一个写得好的模型应该做什么。
5.1.2. 模型做什么
模型是 Shell 和我们的所有功能模块访问 SPA 中数据和业务逻辑的地方。如果我们需要登录,我们就调用模型提供的方法。如果我们想要获取人员列表,我们就从模型中获取。如果我们想要获取头像信息...嗯,你明白了。任何我们想要在功能模块之间共享的数据或逻辑,或者对于应用程序来说是核心的,都应该放入模型中。如果你对模型-视图-控制器(MVC)架构感到舒适,那么你对模型也应该感到舒适。
虽然所有业务逻辑和数据都是通过模型访问的,但这并不意味着我们必须只使用一个(可能非常大的)JavaScript 文件来提供它。我们可以使用命名空间将模型拆分成更易于管理的部分。例如,如果我们有一个包含people对象和chat对象的模型,我们可以将people逻辑放在 spa.model.people.js 中,将chat逻辑放在 spa.model.chat.js 中,然后在我们的主模型文件 spa.model.js 中合并它们。使用这种技术,无论模型使用的文件数量如何,Shell 看到的接口都不会改变。
5.1.3. 模型不做什么
模型不需要浏览器。 这意味着模型不能假设存在document对象或浏览器特定的方法,如document.location可用。Shell 和(特别是)功能模块渲染模型数据的表示是良好的 MVC 卫生习惯。这种分离使得自动化单元和回归测试变得简单得多。我们发现,随着你进入浏览器交互,自动化测试的价值随着实现成本的上升而大大降低。但通过避免 DOM,我们可以测试到 UI 的所有内容,而无需运行浏览器。
单元和回归测试
开发团队必须决定何时投资于自动化测试。自动化模型 API 的测试几乎总是好的投资,因为测试可以隔离以使用每个 API 调用相同的数据。自动化 UI 的测试要昂贵得多,因为有许多变量不容易控制或预测。例如,模拟用户可能点击一个按钮然后另一个按钮的速度可能既困难又昂贵,或者预见当用户参与时数据如何在系统中传播,或者知道网络性能有多快可能很难。出于这些原因,网页测试通常是通过一些工具如 HTML 验证器和链接检查器手动进行的。
一个设计良好的 SPA 应该有独立的 Data、Model 和功能模块(视图 + 控制器)层。我们确保我们的 Data 和 Model 有明确定义的 API,并且与功能模块隔离,因此我们不需要使用浏览器来测试这些层。相反,我们可以使用 Node.js 或 Java 的 Rhino 等 JavaScript 执行环境以低成本地执行自动单元和回归测试。根据我们的经验,视图和控制层仍然最好由真实的人手动测试。
模型不提供通用工具。 相反,我们使用一个不需要 DOM 的通用工具库(spa/js/spa.util.js)。我们单独打包这些工具,因为我们将在多个 SPA 中使用它们。另一方面,模型通常是为特定的 SPA 定制的。
模型不直接与服务器通信。 我们有一个名为 Data 的单独模块来处理这个问题。Data 模块负责从服务器获取模型所需的所有数据。
现在我们对我们架构中模型的作用有了更好的理解,让我们设置本章所需的文件。
5.2. 设置模型和其他文件
我们需要添加和修改一些文件来支持构建我们的模型。我们还希望现在就添加 Avatar 功能模块的文件,因为我们很快就会需要它们。
5.2.1. 规划文件结构
我们建议您将第四章中创建的整个目录结构复制到一个新的“chapter_5”目录中,这样我们就可以在那里更新它们。让我们回顾一下我们在第四章中留下的文件结构,如列表 5.1 所示:
列表 5.1. 第四章的文件结构
spa
+-- css
| +-- spa.chat.css
| +-- spa.css
| `-- spa.shell.css
+-- js
| +-- jq
| | +-- jquery-1.9.1.js
| | `-- jquery.uriAnchor-1.1.3.js
| +-- spa.js
| +-- spa.chat.js
| +-- spa.model.js
| +-- spa.shell.js
| `-- spa.util.js
`-- spa.html
这里是我们计划进行的修改:
-
创建 我们命名的 Avatar CSS 样式表。
-
修改 我们命名的 Shell CSS 样式表以支持用户登录。
-
包含 jQuery 插件以实现统一的触摸和鼠标输入。
-
包含 jQuery 插件以实现全局自定义事件。
-
包含 浏览器数据库的 JavaScript 库。
-
创建 我们命名的 Avatar 模块。这是第六章的占位符。
-
创建 我们命名的 Data 模块。这将提供一个接口来访问“真实”数据。
-
创建 我们命名的 Fake 模块。这将提供一个接口来访问我们用于测试的“虚假”数据。
-
创建 我们命名的浏览器实用工具模块,以便我们可以共享需要浏览器的通用例程。
-
修改 我们命名的 Shell 模块以支持用户登录。
-
修改 浏览器文档以包含新的 CSS 和 JavaScript 文件。
我们更新的文件和目录应该看起来像列表 5.2。我们用粗体标出我们将要创建或修改的所有文件:
列表 5.2. 更新的文件结构
![144fig01_alt.jpg]
既然我们已经确定了要添加或修改的文件,让我们启动我们信任的文本编辑器并完成工作。结果,考虑每个文件的顺序正好是呈现的顺序。如果您在家中参与,可以在我们遍历代码时构建文件。
5.2.2. 填充文件
我们首先考虑的文件是 spa/css/spa.avtr.css。我们将创建该文件,并用 列表 5.3 中显示的内容填充它。最初,它将是一个 占位符:
列表 5.3. 我们的 Avatar 样式表(占位符)—spa/css/spa.avtr.css
/*
* spa.avtr.css
* Avatar feature styles
*/
接下来的三个文件是库文件。让我们将它们下载到 spa/js/jq 目录中。
-
spa/js/jq/jquery.event.ue-0.3.2.js 文件可在
github.com/mmikowski/jquery.event.ue获取。它提供统一的触摸和鼠标输入。 -
spa/js/jq/jquery.event.gevent-0.1.9.js 文件可在
github.com/mmikowski/jquery.event.gevent获取,并且是使用全局自定义事件所必需的。 -
spa/js/jq/taffydb-2.6.2.js 文件提供我们的客户端数据库。它可能位于
github.com/typicaljoe/taffydb。它不是一个 jQuery 插件,如果我们处理的是一个更大的项目,我们会将其放在一个单独的 spa/js/lib 目录中。
接下来的三个 JavaScript 文件—spa/js/spa.avtr.js、spa/js/spa.data.js 和 spa/js/spa.fake.js—将是占位符。它们的内容显示在 列表 5.4、5.5 和 5.6 中。它们大部分是相同的——每个都有一个标题,然后是我们的 JSLint 选项,然后是一个与文件名一致的命名空间声明。我们用 粗体 标出了独特部分:
列表 5.4. 创建头像功能模块—spa/js/spa.avtr.js
/*
* spa.avtr.js
* Avatar feature module
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.avtr = (function () { return {}; }());
列表 5.5. 创建数据模块—spa/js/spa.data.js
/*
* spa.data.js
* Data module
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.data = (function () { return {}; }());
列表 5.6. 创建模拟数据模块—spa/js/spa.fake.js
/*
* spa.fake.js
* Fake module
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.fake = (function () { return {}; }());
记住,/*jslint ...*/ 和 /*global ...*/ 部分是在我们运行 JSLint 检查代码中的常见错误时使用的。/*jslint ...*/ 部分设置验证的首选项。例如,browser : true 告诉 JSLint 验证器假设我们将在此浏览器中运行此 JavaScript,因此我们将有一个 document 对象(以及其他事物)。/*global $, spa */ 部分告诉 JSLint 验证器变量 $ 和 spa 是在此模块外部定义的。如果没有这些信息,验证器会抱怨这些变量在使用之前没有被定义。参见 附录 A 以获取我们 JSLint 设置的完整讨论。
接下来我们可以添加我们的浏览器工具文件,spa/js/spa.util_b.js。此模块提供仅在浏览器环境中工作的常用例程。换句话说,浏览器工具通常不会与 Node.js 一起工作,而我们的标准工具 (spa/js/spa.util.js) 会。 显示了该模块在我们的架构中。
图 5.3. 浏览器实用工具模块提供了需要浏览器运行的实用工具

我们的浏览器实用工具将提供encodeHtml和decodeHtml实用工具,不出所料,这些工具可以用来编码和解码 HTML 中使用的特殊字符,如&或<。它还将提供getEmSize实用工具,可以计算浏览器中em单位像素的数量。共享这些实用工具确保它们得到一致的实施,同时也最小化了我们需要编写的代码量。让我们启动我们的文本编辑器并创建如图 5.7 所示的文件。方法以粗体显示:
² 这些方法对于防止当我们展示来自用户输入的数据时的跨站脚本攻击非常重要。
列表 5.7. 创建浏览器实用工具模块—spa/js/spa.util_b.js


需要考虑的最后一个文件是浏览器文档。我们将更新它以使用所有新的 CSS 和 JavaScript 文件,如列表 5.8 所示。从第四章的更改以粗体显示:
列表 5.8. 更新浏览器文档—spa/spa.html


现在一切准备就绪,让我们来谈谈如何将触摸控制添加到我们的 SPA 中。
5.2.3. 使用统一的触摸鼠标库
智能手机和平板电脑目前在全球范围内销量超过传统的笔记本电脑和台式电脑。我们预计移动设备销量将继续超过传统计算设备,并作为活跃的 SPA-兼容设备百分比的增长。不久,大多数希望使用我们网站的潜在客户可能都在使用触摸设备。
我们认识到这一趋势,并在本章中包含了统一的触摸鼠标界面库—jquery.event.ue-0.3.2.js。这个库,尽管并不完美,但在使应用程序在触摸和指针界面之间无缝工作方面做了很多魔法;它处理了多点触控、捏合缩放、拖放和长按,以及更常见的其他事件。我们将详细说明它在本章和未来章节中更新我们的 UI 时的使用方法。
我们现在已经准备好了将要应用更改的文件。当我们加载我们的浏览器文档(spa/spa.html)时,我们应该看到与我们在第四章中离开时相同的页面,没有任何错误。现在让我们开始构建我们的模型。
5.3. 设计人员对象
在本章中,我们将构建模型中的人员对象部分,如图 5.4 所示。
图 5.4. 在本节中,我们以人员对象开始我们的模型设计

我们预计我们的模型将分为两个部分:一个chat对象和一个people对象。这是我们在第四章中首先草拟的规范:
...
// * chat_model - the chat model object provides methods
// to interact with our instant messaging
// * people_model - the people model object which provides methods
// to interact with the list of people the model maintains
...
为people对象提供的描述——“一个提供与 Model 维护的人员列表交互方法的对象”——是一个好的开始,但它对于实现来说还不够详细。让我们从我们将用来表示我们列表中每个人员的对象开始设计people对象。
5.3.1. 设计人员对象
我们已经决定people对象应该管理一个人员列表。经验表明,一个人可以用一个对象很好地表示。因此,我们的people对象将管理许多person对象。以下是我们认为每个person对象应该拥有的最小属性:
-
id—服务器 ID。这将定义所有从后端发送的对象。 -
cid—客户端 ID。这应该始终定义,通常将与 ID 相同;但如果我们在客户端创建一个新的person对象而后端尚未更新,则服务器 ID 将是未定义的。 -
name—人员的姓名。 -
css_map—显示属性的映射。我们需要这个来支持头像。
一个person对象的 UML 类图显示在表 5.1 中:
表 5.1. 人员对象的 UML 类图
| person |
|---|
| 属性名称 |
| id |
| cid |
| name |
| css_map |
| 方法名称 |
| get_is_user() |
| get_is_anon() |
不使用客户端 ID 属性
这些天,我们很少使用单独的客户端 ID 属性。相反,我们使用一个单一的 ID 属性,并为客户端生成的 ID 应用一个唯一的前缀。例如,一个客户端 ID 可能看起来像x23,而一个来自后端的 ID 可能看起来像50a04142c692d1fd18000003(特别是如果你在使用 MongoDB)。因为后端生成的 ID 永远不会以x开头,所以很容易确定任何 ID 的生成位置。大多数应用程序逻辑不需要担心 ID 的来源。唯一变得重要的时候是我们与后端同步时。
在我们考虑person对象应该有哪些方法之前,让我们考虑people对象可能需要管理的用户类型。图 5.5 显示了我们希望用户看到的内容草稿,以及一些关于人员的注释。
图 5.5. 我们 SPA 的草稿以及关于人员的注释

看起来people对象需要识别四种类型的人员:
-
当前用户
-
匿名人士
-
与用户聊天的人员
-
其他在线人士
目前我们只关注当前用户和匿名人士——我们将在下一章中关注在线人士。我们希望有方法帮助我们识别这些类型的用户:
-
get_is_user()—如果person对象是当前用户,则返回true。 -
get_is_anon()—如果person对象是匿名的,则返回true。
现在我们已经详细说明了person对象,让我们考虑people对象将如何管理它们。
5.3.2. 设计人员对象 API
people 对象 API 将由方法和 jQuery 全局自定义事件组成。我们首先考虑方法调用。
设计人员方法调用
我们希望我们的模型始终有一个当前用户对象可用。如果某人未登录,则用户对象应该是 匿名 的 person 对象。当然,这暗示我们应该提供一种让某人登录和注销的方法。聊天滑块左侧列出的人员列表表明我们希望维护一个可以与之聊天的在线人员列表,并且我们希望他们按字母顺序返回。考虑到这些要求,这个方法列表似乎是合适的:
-
get_user()—返回当前用户person对象。如果当前用户未登录,则返回匿名person对象。 -
get_db()—获取包括当前用户在内的所有person对象的集合。我们希望人员列表始终按字母顺序排列。 -
get_by_cid( <client_id> )—获取与唯一客户端 ID 关联的person对象。虽然可以通过获取集合并通过客户端 ID 搜索person对象来实现相同的功能,但我们预计这个功能会被频繁使用,因此一个专用方法可以帮助避免错误并提供优化机会。 -
login( <user_name> )—以指定的用户名登录。我们将避免在本书范围内讨论登录认证的复杂性,因为其他地方有许多示例。当用户登录时,当前用户对象应更改为反映新的身份。我们还应该发布一个名为spa-login的事件,并将当前用户对象作为数据。 -
logout()—将当前用户对象重置为匿名人员。我们应该发布一个名为spa-logout的事件,并将前一个用户对象作为数据。
login() 和 logout() 方法描述都指出,我们将作为它们响应的一部分发布事件。下一节将讨论这些事件是什么以及为什么使用它们。
设计人员事件
我们使用事件来异步发布数据。例如,如果人员列表发生变化,模型可能希望发布一个 spa-listchange 事件,该事件共享更新的人员列表。图 5.6 展示了事件是如何广播给订阅的功能模块和 Shell 的。
³ 事件机制的其他名称包括 推送通信 或 pub-sub(即 发布-订阅)。
图 5.6. 事件从我们的模型中广播,可以被我们的功能模块或 Shell 中的订阅方法接收

我们希望模型至少发布两种事件类型作为 people 对象 API 的一部分^([4])。
⁴ 我们为所有发布的事件名称使用命名空间前缀 (
spa-)。这有助于避免与第三方 JavaScript 和库潜在的冲突。
-
当登录过程完成时,应发布
spa-login事件。这不会立即发生,因为登录过程通常需要与后端进行往返。应将更新后的当前用户对象作为事件数据提供。 -
当注销过程完成时,应发布
spa-logout事件。应将前一个用户对象作为事件数据提供。
事件通常是一种更佳的方式用于异步数据的分发。经典的 JavaScript 实现使用回调函数,这会导致代码变得混乱,难以调试和模块化。事件允许模块代码保持独立,同时使用相同的数据。出于这些原因,我们在从模型分发异步数据时强烈倾向于使用事件。
由于我们已经在使用 jQuery,因此使用 jQuery 全局自定义事件作为发布机制是一个明智的选择。我们已经创建了一个全局自定义事件插件来提供这种功能^([5])。jQuery 全局自定义事件性能良好,并且与其他 jQuery 事件具有相同的熟悉界面。任何 jQuery 集合都可以订阅特定的全局自定义事件,并在事件发生时调用一个函数。事件通常与数据相关联。例如,spa-login 事件可能会传递最新的用户对象。当一个元素从文档中移除时,任何订阅在该删除元素上的函数将自动移除。列表 5.9 说明了这些概念。我们可以打开浏览器文档 (spa/spa.html),打开 JavaScript 控制台,并测试:
⁵ 在版本 1.9.0 之前,jQuery 本地支持此功能。当然,他们在我们即将付印之前将其移除,只是为了让我们的生活更有趣。
列表 5.9. 使用 jQuery 全局自定义事件



如果你已经熟悉 jQuery 事件处理,这可能是老生常谈,但这是个好消息。如果不熟悉,也不要过于担心。只需高兴这种行为与其他所有 jQuery 事件保持一致。它也非常强大,经过充分的测试,并且利用了与 jQuery 内部方法相同的代码。为什么要在可以使用一个事件机制的情况下学习两个呢? 这就是使用 jQuery 全局自定义事件的强大论据——也是反对使用引入冗余且细微不同的事件机制的“框架”库的强大论据。
5.3.3. 记录 people 对象 API
现在我们将所有这些思考整合到一个相对简洁的格式中,我们可以将其放入我们的 Model 模块以供参考。列表 5.10 是一个很好的初次尝试:
列表 5.10. people 对象 API
// The people object API
// ---------------------
// The people object is available at spa.model.people.
// The people object provides methods and events to manage
// a collection of person objects. Its public methods include:
// * get_user() - return the current user person object.
// If the current user is not signed-in, an anonymous person
// object is returned.
// * get_db() - return the TaffyDB database of all the person
// objects - including the current user - pre-sorted.
// * get_by_cid( <client_id> ) - return a person object with
// provided unique id.
// * login( <user_name> ) - login as the user with the provided
// user name. The current user object is changed to reflect
// the new identity.
// * logout()- revert the current user object to anonymous.
//
// jQuery global custom events published by the object include:
// * 'spa-login' is published when a user login process
// completes. The updated user object is provided as data.
// * 'spa-logout' is published when a logout completes.
// The former user object is provided as data.
//
// Each person is represented by a person object.
// Person objects provide the following methods:
// * get_is_user() - return true if object is the current user
// * get_is_anon() - return true if object is anonymous
//
// The attributes for a person object include:
// * cid - string client id. This is always defined, and
// is only different from the id attribute
// if the client data is not synced with the backend.
// * id - the unique id. This may be undefined if the
// object is not synced with the backend.
// * name - the string name of the user.
// * css_map - a map of attributes used for avatar
// presentation.
//
现在我们已经为people对象完成了一个规范,让我们构建它并测试 API。之后,我们将调整 Shell 以使用 API,这样用户就可以登录和登出了。
5.4. 构建 people 对象
现在我们已经设计了people对象,我们可以开始构建它了。我们将使用 Fake 模块为 Model 提供模拟数据。这将使我们能够在没有服务器或功能模块的情况下继续进行。Fake 是快速开发的关键推动者,我们将继续模拟直到我们真正实现。
让我们重新审视我们的架构,看看 Fake 如何能帮助我们提高开发效率。我们的完整实现架构如图 5.7 所示。figure 5.7。
图 5.7. 我们 SPA 架构中的 Model

好吧,这很棒,但我们不能一步到位。我们更愿意在没有要求网络服务器或 UI 的情况下进行开发。我们希望在现阶段专注于 Model,不被其他模块分散注意力。我们可以使用 Fake 模块来模拟数据和服务器连接,并可以使用 JavaScript 控制台直接进行 API 调用,而不是使用浏览器窗口。图 5.8 说明了我们以这种方式开发时所需的模块。
图 5.8. 我们在开发过程中使用了一个名为 Fake 的模拟数据模块

让我们清除所有未使用的代码,看看剩下哪些模块,如图 5.9 所示。figure 5.9。
图 5.9. 这里是我们用于开发和测试 Model 的所有模块

通过使用 Fake 模块和 JavaScript 控制台,我们能够专注于 Model 的开发和测试。这对于像 Model 这样重要的模块特别有益。随着我们的进展,我们应该记住,在本章中,“后端”是由 Fake 模块模拟的。现在我们已经概述了开发策略,让我们开始着手 Fake 模块的开发。
5.4.1. 创建一个模拟的 people 列表
我们所说的“真实”数据通常是从网络服务器发送到浏览器的。但如果我们工作了一整天,感到疲惫,没有精力处理“真实”数据怎么办?没关系——有时模拟一下是可以的。在本节中,我们将公开、诚实地讨论如何模拟数据。我们希望提供您想知道的所有关于模拟数据的信息,但可能因为害怕而不敢询问。
在开发过程中,我们将使用名为 Fake 的模块来为应用程序提供模拟数据和函数。我们将在模型中设置一个isFakeData标志,以指示它使用 Fake 模块而不是使用来自数据模块的“真实”Web 服务器数据和函数。这使我们可以独立于服务器进行快速、专注的开发。因为我们已经很好地概述了person对象将如何表现,我们应该能够很容易地伪造我们的数据。首先,我们想创建一个返回假人列表数据的方法。让我们打开我们的文本编辑器并创建spa.fake.getPeopleList,如列表 5.11 所示:
列表 5.11. 将模拟用户列表添加到 Fake—spa/js/spa.fake.js
/*
* spa.fake.js
* Fake module
*/
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa */
spa.fake = (function () {
'use strict';
var getPeopleList;
getPeopleList = function () {
return [
{ name : 'Betty', _id : 'id_01',
css_map : { top: 20, left: 20,
'background-color' : 'rgb( 128, 128, 128)'
}
},
{ name : 'Mike', _id : 'id_02',
css_map : { top: 60, left: 20,
'background-color' : 'rgb( 128, 255, 128)'
}
},
{ name : 'Pebbles', _id : 'id_03',
css_map : { top: 100, left: 20,
'background-color' : 'rgb( 128, 192, 192)'
}
},
{ name : 'Wilma', _id : 'id_04',
css_map : { top: 140, left: 20,
'background-color' : 'rgb( 192, 128, 128)'
}
}
];
};
return { getPeopleList : getPeopleList };
}());
我们在这个模块中引入了'use strict'指令,如粗体所示。如果你对大规模 JavaScript 项目认真负责——我们知道你是——我们鼓励你考虑在命名空间函数作用域内使用严格指令。在严格模式下,JavaScript 更有可能在执行不安全操作时抛出异常,例如使用未声明的全局变量。它还禁用了令人困惑或考虑不周的功能。虽然很诱人,不要在全局作用域中使用严格指令,因为它可能会破坏其他,不那么有远见的第三方开发者的 JavaScript,他们不像你那样开明。现在让我们使用这个假人列表在我们的模型中。
5.4.2. 开始构建人对象
我们现在将开始在模型中构建people对象。当它初始化时(使用spa.model.initModule()方法),我们将首先使用与创建其他person对象相同的makePerson构造函数创建匿名person对象。这确保了这个对象具有与其他person对象相同的方法和属性,无论构造函数的未来变化如何。
接下来,我们将使用spa.fake.getPeopleList()提供的假人列表来创建一个包含person对象的 TaffyDB 集合。TaffyDB 是一个为在浏览器中使用而设计的 JavaScript 数据存储库。它提供了许多数据库式功能,例如通过匹配属性选择对象数组。例如,如果我们有一个名为people_db的 TaffyDB person对象集合,我们可以这样选择具有名为 Pebbles 的人的数组:
found_list = people_db({ name : 'Pebbles' }).get();
为什么我们喜欢 TaffyDB
我们喜欢 TaffyDB,因为它专注于在浏览器中提供丰富的数据管理功能,并且它不会尝试做其他任何事情(比如引入一个与 jQuery 冗余的不同的事件模型)。我们喜欢使用像 TaffyDB 这样的最优、专注的工具。如果出于某种原因,我们需要不同的数据管理功能,我们可以用另一个工具(或自己编写)替换它,而无需重构整个应用程序。请参阅www.taffydb.com以获取关于这个实用工具的详细文档。
最后,我们将导出 people 对象,以便我们可以测试我们的 API。在此阶段,我们将提供两种与 person 对象交互的方法:spa.model.people.get_db() 将返回 TaffyDB 人员集合,而 spa.model.people.get_cid_map() 将返回一个以客户端 ID 为键的映射。让我们启动信任的文本编辑器并开始我们的模型,如 列表 5.12 所示。这只是我们的第一次尝试,所以不要觉得你必须现在就理解一切:
列表 5.12. 开始构建模型—spa/js/spa.model.js


当然,还没有调用 spa.model.initModule()。让我们通过更新我们的根命名空间模块 spa/js/spa.js 来修复它,如 列表 5.13 所示:
列表 5.13. 将模型初始化添加到根命名空间模块—spa/js/spa.js

现在,让我们加载我们的浏览器文档 (spa/spa.html),以确保页面与之前一样工作——如果它没有工作或控制台中有错误,我们做错了什么,应该回溯到这一步。尽管它可能看起来一样,但底层代码正在以不同的方式工作。让我们打开 Chrome 开发者工具 JavaScript 控制台来测试 people API。我们可以获取人员集合并探索 TaffyDB 的一些好处,如 列表 5.14 所示。类型输入以 粗体 显示;输出以 斜体 显示:
列表 5.14. 与模拟人员互动并喜欢它


这项测试表明,我们已经成功构建了 people 对象的一部分。在下一节中,我们将完成这项工作。
5.4.3. 完成人员对象
我们需要更新模型和 Fake 模块,以确保 people 对象 API 符合我们之前编写的规范。让我们先更新模型。
更新模型
我们希望 people 对象完全支持 user 的概念。让我们考虑我们需要添加的新方法:
-
login( <用户名> )将启动登录过程。我们需要创建一个新的person对象并将其添加到人员列表中。当登录过程完成后,我们将发出一个spa-login事件,该事件将当前用户对象作为数据发布。 -
logout()将启动登出过程。当用户登出时,我们将从人员列表中删除用户person对象。当登出过程完成后,我们将发出一个带有先前用户对象作为数据的spa-logout事件。 -
get_user()将返回当前用户person对象。如果有人尚未登录,用户对象将是匿名person对象。我们将使用模块状态变量 (stateMap.user) 来存储当前用户person对象。
我们需要添加其他一些功能来支持这些方法:
-
由于我们将使用 Socket.IO 连接向 Fake 模块发送和接收消息,因此我们将在
login( <用户名> )方法中使用模拟的sio对象。 -
由于我们将使用
login( <username> )创建一个新的person对象,我们将使用makeCid()方法为已登录用户创建一个客户端 ID。我们将使用模块状态变量 (stateMap.cid_serial) 来存储创建此 ID 所使用的序列号。 -
由于我们将从人员列表中移除用户
person对象,我们需要一个方法来移除用户。我们将使用removePerson( <client_id> )方法来完成这个操作。 -
由于登录过程是异步的(它只在假模块返回
userupdate消息时完成),我们将使用completeLogin方法来完成这个过程。
让我们按照 列表 5.15 中的说明更新模型。所有更改都以 粗体 显示:
列表 5.15. 完成模型中的 people 对象——spa/js/spa.model.js


现在我们已经更新了模型,我们可以继续进行假模块的更新。
更新假模块
我们的假模块需要更新以提供模拟 Socket.IO 连接对象 sio。我们希望它能够模拟我们需要的登录和登出功能:
-
模拟的
sio对象必须提供注册消息回调的能力。我们只需要支持单个消息userupdate的回调来测试登录和登出。在模型中,我们为此消息注册了completeLogin方法。 -
当用户登录时,模拟的
sio对象将从模型接收到一个adduser消息,以及一个包含用户数据的映射作为其参数。我们通过等待三秒钟然后执行userupdate回调来模拟服务器响应。我们故意延迟这个响应,以便我们可能发现登录过程中的任何竞争条件。 -
目前我们不需要担心使用模拟的
sio对象进行登出,因为模型目前处理这个条件。
让我们按照 列表 5.16 中的说明更新假模块。所有更改都以 粗体 显示:
列表 5.16. 在假数据中添加带有延迟的模拟套接字对象——spa/js/spa.fake.js


现在我们已经完成了模型和假数据的更新,我们可以测试登录和登出了。
5.4.4. 测试人员对象 API
正如我们计划的那样,隔离模型使我们能够在不设置服务器或准备用户界面的时间和成本的情况下测试登录和登出过程。除了节省成本之外,它还确保了更高的质量,因为我们的测试结果不会受到接口或数据错误的扭曲,并且我们在测试已知的数据集。这种方法还允许我们在不需要其他开发小组完成其组件的情况下继续进行。
让我们加载我们的浏览器文档 (spa/spa.html) 来确保应用程序像以前一样工作。然后我们可以打开 JavaScript 控制台并测试 login、logout 和其他方法,如 列表 5.17 所示。输入的文本以 粗体 显示;输出以 斜体 显示:
列表 5.17. 使用 JavaScript 控制台测试登录和登出


这项测试令人放心。我们已经证明 people 对象很好地实现了其目标。我们可以登录和注销,并且模型的行为符合定义。而且由于模型不需要 UI 或服务器,因此很容易创建一个测试套件来确保所有方法都符合其设计规范。这个套件可以通过使用 jQuery 和 Node.js 在没有浏览器的情况下运行。请参阅附录 B,了解如何实现这一点。
这可能是一个休息的好时机。在下一节中,我们将更新我们的界面,以便用户可以登录和注销。
5.5. 在 Shell 中启用登录和注销
到目前为止,我们已经将模型开发与 UI 隔离,如图 5.10 所示:
图 5.10. 使用 JavaScript 控制台测试模型

现在我们已经彻底测试了模型,我们希望用户通过 UI 而不是 JavaScript 控制台进行登录和注销。我们现在将使用 Shell 来实现这一点,如图 5.11 所示。
图 5.11. 在本节中,我们向 Shell 添加图形登录功能

当然,在我们能够构建 UI 之前,我们必须就其工作方式达成一致。我们将在下一节中这样做。
5.5.1. 设计用户登录体验
我们希望保持用户体验简单且熟悉。按照流行的惯例,我们希望用户点击页面右上角开始登录过程。我们设想的步骤如图 5.12 所示。
图 5.12. 用户看到的登录过程

-
如果用户未登录,右上角(“用户区域”)将提示“请登录”。当用户点击此文本时,将出现登录对话框。
-
一旦用户完成对话框表单并点击“确定”按钮,登录处理就开始了。
-
登录对话框被移除,用户区域在登录过程中显示“...处理中...”(我们的 Fake 模块总是需要三秒钟来完成这一步骤)。
-
一旦登录过程完成,用户区域将显示已登录用户的姓名。
已登录用户可以通过点击用户区域来注销。这将文本恢复为“请登录”。
现在我们已经设计了用户体验,我们可以更新 Shell 来实现它。
5.5.2. 更新 Shell JavaScript
因为我们把所有数据处理和逻辑都放在了我们的模型中,所以 Shell 只需处理视图和控制角色。正如他们所说,我们还可以轻松添加对触摸设备(如平板电脑和手机)的支持。让我们按照列表 5.18 所示修改 Shell。更改以粗体显示:
列表 5.18. 更新 Shell 以添加登录—spa/js/spa.shell.js


一旦我们熟悉了 jQuery 全局自定义事件的发布-订阅特性,我们做出的更改就很容易理解了。现在让我们调整 CSS,以正确显示用户区域。
5.5.3. 更新 Shell 样式表
我们的样式表更改并不复杂。我们添加或修改了一些选择器,使用户区域看起来更美观,并在过程中清理了一些冗余。列表 5.19 显示了我们需要在 粗体 中进行的更改:
列表 5.19. 在 Shell 样式表中添加用户区域样式—spa/css/spa.shell.css


现在我们已经设置了 CSS,让我们测试一下这些更改。
5.5.4. 使用 UI 测试登录和登出
当我们加载浏览器文档 (spa/spa.html) 时,我们应该在窗口右上角的用户区域看到一个“请登录”的页面。当我们点击这个按钮时,我们应该看到一个如图 图 5.13 所示的对话框。
图 5.13. 登录对话框截图

一旦我们输入用户名并点击“确定”,对话框应该关闭,我们将在用户区域看到“...处理中...”^([6]) 三秒钟,之后应该发布 spa-login 事件。Shell 中订阅此事件的处理器应该更新窗口右上角的用户名,如图 图 5.14 所示。
⁶ 在我们公开网站之前,我们可能会使用一个漂亮的“进行中”动画图形来代替文本。许多网站提供免费的高质量自定义“进行中”图形。
图 5.14. 登录完成后的截图

通过在整个过程中让用户了解正在发生的事情,我们确保了良好的用户体验。这是良好设计的标志——始终如一地提供即时反馈可以使即使是相对较慢的应用程序看起来更加敏捷和响应。
5.6. 总结
在本章中,我们介绍了模型,并讨论了它如何融入我们的架构。我们概述了模型应该做什么以及不应该做什么。然后我们设置了构建和测试模型所需的文件。
我们设计、指定、开发和测试了模型的一部分——people 对象。我们使用 Fake 模块为模型提供受控的数据集,并使用 JavaScript 控制台测试 people 对象 API。以这种方式隔离模型导致了更快的开发和更受控的测试。我们还修改了我们的 SPA,以使用鼠标触摸插件,以便移动用户可以使用它。
在最后一节中,我们修改了 Shell,向用户展示了登录和登出功能。我们使用了 people 对象提供的 API 来实现这一功能。我们还确保了通过我们的单页应用(SPA)在用户输入后立即提供反馈,从而保证了良好的用户体验。
在下一章中,我们将向模型添加 chat 对象。这将使我们能够完成聊天功能模块并创建头像功能模块。然后,我们将准备客户端以与真实网络服务器一起工作。
第六章. 完成模型和数据模块
本章涵盖
-
设计模型中的
chat对象部分 -
实现
chat对象并测试其 API -
完成聊天功能模块
-
创建新的头像功能模块
-
使用 jQuery 进行数据绑定
-
使用数据模块与服务器通信
本章完成了在第五章开始的工作,即模型和功能模块。在开始之前,你应该拥有第五章的项目文件[kindle_split_016.html#ch05],因为我们将在其基础上添加内容。我们建议你将第五章中创建的整个目录结构复制到“chapter_6”目录中,并在那里更新它们。
在这一章中,我们设计并构建了模型中的 chat 对象部分。然后,通过使其使用并响应 chat 对象 API 来完成聊天滑块 UI。我们还添加了一个头像功能模块,它也使用 chat 对象 API 来显示在线人员的屏幕表示。我们讨论了如何使用 jQuery 实现数据绑定。最后,我们通过添加数据模块来完成 SPA 的客户端部分。
让我们先从设计 chat 对象开始。
6.1. 设计聊天对象
在本章中,我们将构建模型中的 chat 对象部分,如图 6.1 第六章所示。
图 6.1. 我们将在本章中处理模型的 chat 对象

在上一章中,我们设计了、构建并测试了模型中的 people 对象部分。在这一章中,我们将设计、构建并测试 chat 对象。让我们回顾一下我们在第四章首次提出的 API 规范第四章:
...
// * chat_model - the chat model object provides methods
// to interact with our instant messaging
// * people_model - the people model object which provides methods
// to interact with the list of people the model maintains
...
提供给 chat 对象的描述——“一个提供与即时通讯交互的方法的对象”——是一个良好的开端,但对于实现来说过于宽泛。让我们首先通过分析我们希望它实现什么来设计 chat 对象。
6.1.1. 设计方法和事件
我们知道我们希望 chat 对象提供即时通讯功能,但我们需要详细确定这些功能是什么。让我们考虑图 6.2,它显示了带有关于我们聊天界面一些注释的 SPA 模拟。
图 6.2. 我们单页应用(SPA)的模拟——聊天焦点

根据经验,我们知道我们可能需要初始化一个聊天室。我们还预计用户可能会更改聊天对象(他们正在与之聊天的人),并可能向这个人发送消息。根据我们对头像的讨论,我们知道用户可能会更新头像信息。用户不会是唯一驱动 UI 的来源,因为我们预计其他人会加入和离开聊天室,发送和接收消息,以及更改头像信息。基于这种分析,我们可以列出需要通过chat对象 API 公开的能力:
-
提供加入或离开聊天室的方法。
-
提供一个方法来更改聊天对象。
-
提供一个方法向其他人发送消息。
-
提供一个方法告诉服务器用户已更新头像。
-
如果聊天对象因任何原因更改,发布一个事件。例如,如果聊天对象离线或用户选择了新的聊天对象。
-
当消息面板需要因任何原因更改时,发布一个事件。例如,如果用户发送或接收消息。
-
如果在线人员列表因任何原因发生变化,发布一个事件。例如,如果有人加入或离开聊天室,或者如果某个用户的头像被移动。
我们的chat对象 API 将使用两个通信通道。一个通道是经典的方法-返回值机制。这个通道是同步的——数据传输按照已知的顺序进行。chat对象可以调用外部方法并接收返回值。其他代码可以调用chat对象的公共方法并从返回值中获取信息。
聊天对象将使用的另一个通信通道是事件机制。这个通道是异步的——事件可能在任何时间发生,无论chat对象的操作如何。chat对象将接收事件(如来自服务器的消息)并为 UI 发布事件。
让我们首先考虑我们将提供的同步方法,开始设计chat对象。
设计聊天方法
正如我们在第五章中讨论的那样,方法是一个公开暴露的函数,如spa.model.chat.get_chatee,它可以用来执行操作并同步返回数据。根据我们的要求,这个方法列表似乎是合适的:
-
join()——加入聊天。如果用户是匿名的,则此方法应终止并返回false。 -
get_chatee()——返回我们正在与之聊天的人的person对象。如果没有聊天对象,则返回null。 -
set_chatee( <person_id> )——将聊天对象设置为唯一由person_id标识的person对象。此方法应发布一个包含聊天对象信息的spa-setchatee事件。如果在在线人员集合中找不到匹配的person对象,则将聊天对象设置为null。如果请求的人已经是聊天对象,则返回false。 -
send_message( <msg_text> )—向聊天对象发送消息。我们应该发布一个包含消息信息的spa-updatechat事件作为数据。如果用户是匿名或聊天对象为null,此方法不应采取任何操作并返回false。 -
update_avatar( <update_avatar_map> )—调整person对象的头像信息。参数(update_avatar_map)应包括person_id和css_map属性。
这些方法似乎符合我们的要求。现在让我们更详细地考虑chat对象应该发布的事件。
设计聊天事件
如我们之前讨论的,事件用于异步发布数据。例如,如果收到消息,chat对象需要通知订阅的 jQuery 集合更改,并提供更新展示所需的数据。
我们预计在线人员集合和聊天对象会经常变化。这些变化不一定是由用户的行为引起的——例如,聊天对象可能随时发送消息。以下是应将这些更改通知给功能模块的事件:
-
当在线人员列表更改时,应发布
spa-listchange。应提供更新的人员集合作为数据。 -
当聊天对象更改时,应发布
spa-setchatee。应提供包含旧聊天对象和新聊天对象的映射作为数据。 -
当发送或接收新消息时,应发布
spa-updatechat。应提供包含消息信息的映射作为数据。
正如我们在第五章中所做的那样,我们将使用 jQuery 全局事件作为我们的发布机制。现在我们已经考虑了我们需要的方法和事件,让我们继续进行文档和实现。
6.1.2. 记录聊天对象 API
让我们将我们的计划整合成一个 API 规范,我们可以将其放入模型代码中供参考。
列表 6.1. chat对象 API—spa/js/spa.model.js
// The chat object API
// -------------------
// The chat object is available at spa.model.chat.
// The chat object provides methods and events to manage
// chat messaging. Its public methods include:
// * join() - joins the chat room. This routine sets up
// the chat protocol with the backend including publishers
// for 'spa-listchange' and 'spa-updatechat' global
// custom events. If the current user is anonymous,
// join() aborts and returns false.
// * get_chatee() - return the person object with whom the user
// is chatting. If there is no chatee, null is returned.
// * set_chatee( <person_id> ) - set the chatee to the person
// identified by person_id. If the person_id does not exist
// in the people list, the chatee is set to null. If the
// person requested is already the chatee, it returns false.
// It publishes a 'spa-setchatee' global custom event.
// * send_msg( <msg_text> ) - send a message to the chatee.
// It publishes a 'spa-updatechat' global custom event.
// If the user is anonymous or the chatee is null, it
// aborts and returns false.
// * update_avatar( <update_avtr_map> ) - send the
// update_avtr_map to the backend. This results in an
// 'spa-listchange' event which publishes the updated
// people list and avatar information (the css_map in the
// person objects). The update_avtr_map must have the form
// { person_id : person_id, css_map : css_map }.
//
// jQuery global custom events published by the object include:
// * spa-setchatee - This is published when a new chatee is
// set. A map of the form:
// { old_chatee : <old_chatee_person_object>,
// new_chatee : <new_chatee_person_object>
// }
// is provided as data.
// * spa-listchange - This is published when the list of
// online people changes in length (i.e. when a person
// joins or leaves a chat) or when their contents change
// (i.e. when a person's avatar details change).
// A subscriber to this event should get the people_db
// from the people model for the updated data.
// * spa-updatechat - This is published when a new message
// is received or sent. A map of the form:
// { dest_id : <chatee_id>,
// dest_name : <chatee_name>,
// sender_id : <sender_id>,
// msg_text : <message_content>
// }
// is provided as data.
//
现在我们已经完成了chat对象的规范,让我们实现它并测试 API。之后,我们将调整 Shell 和功能模块以使用chat对象 API 提供新的功能。
6.2. 构建聊天对象
现在我们已经设计了chat对象 API,我们可以构建它。正如第五章中所述,我们将使用假模块和 JavaScript 控制台来避免使用 Web 服务器或 UI。随着我们的进展,我们应该记住,在本章中,“后端”是由假模块模拟的。
6.2.1. 使用加入方法启动聊天对象
在本节中,我们将在模型中创建chat对象,以便我们:
-
使用
spa.model.people.login(<username>)方法登录。 -
使用
spa.model.chat.join()方法加入聊天室。 -
注册一个回调,每当模型从后端接收到
listchange消息时,发布一个spa-listchange事件。这表示用户列表已更改。
我们的chat对象将依赖于people对象来处理登录并维护在线人员列表。它不会允许匿名用户join聊天室。让我们按照列表 6.2 中的说明在模型中开始构建chat对象。更改以粗体显示:
列表 6.2. 开始我们的chat对象—spa/js/spa.model.js


这是我们的chat对象的第一版实现。我们不想添加更多方法,而是想测试我们迄今为止创建的方法。在下一节中,我们将更新 Fake 模块以模拟我们需要测试的服务器交互。
6.2.2. 更新 Fake 以响应 chat.join
现在我们需要更新 Fake 模块,使其能够模拟我们需要测试join方法的服务器响应。我们需要做的更改包括:
-
将已登录用户包含在模拟人员列表中。
-
模拟从服务器接收
listchange消息。
第一步很简单:我们创建一个人员映射并将其推入 Fake 管理的人员列表中。第二步更复杂,所以请跟我来:chat对象仅在用户登录并加入聊天后才会注册一个处理程序来处理来自后端的listchange消息。因此,我们可以添加一个私有的send_listchange函数,只有在这个处理程序注册后才会发送模拟的人员列表。让我们按照列表 6.3 中的说明应用这些更改。更改以粗体显示:
列表 6.3. 更新 Fake 以模拟加入服务器消息—spa/js/spa.fake.js


现在我们已经完成了chat对象的一部分,让我们像在第五章中处理people对象那样对其进行测试。
6.2.3. 测试 chat.join 方法
在我们继续构建chat对象之前,我们应该确保我们迄今为止已实现的特性按预期工作。首先,让我们加载我们的浏览器文档(spa/spa.html),打开 JavaScript 控制台,并确保 SPA 没有显示 JavaScript 错误。然后,使用控制台,我们可以像列表 6.4 中所示测试我们的方法。输入的文本以粗体显示;输出以斜体显示:
列表 6.4. 在没有 UI 或服务器的情况下测试 spa.model.chat.join()


我们已经完成并测试了chat对象的第一部分,其中我们可以登录、加入聊天并检查人员列表。现在我们希望chat对象能够处理发送和接收消息。
6.2.4. 向聊天对象添加消息功能
发送和接收消息并不像看起来那么简单。正如 FedEx 会告诉你的,我们必须处理物流——消息的传输和接收管理。我们需要做的是:
-
维护一个记录chatee(用户正在与之聊天的人)的记录。
-
将发送者 ID、姓名和接收者 ID 等元数据与消息一起发送。
-
优雅地处理可能由于潜在连接而导致我们的用户向离线人员发送消息的情况。
-
当从后端接收到消息时发布 jQuery 自定义全局事件,以便我们的 jQuery 集合可以订阅这些事件并对其执行操作。
首先,让我们按照列表 6.5 所示更新我们的模型。更改以粗体显示:
列表 6.5. 向模型添加消息功能--spa/js/spa.model.js


我们已经完成了chat对象的第二次实现,其中我们添加了消息功能。和以前一样,我们在添加更多功能之前想要检查我们的工作。在下一节中,我们将更新 Fake 模块以模拟所需的服务器交互。
6.2.5. 更新 Fake 以模拟消息
现在我们需要更新 Fake 模块,以便它可以模拟我们测试消息方法所需的服务器响应。我们需要进行的更改包括:
-
通过回复一个来自当前聊天对象的
updatechat消息来模拟对发出的updatechat消息的响应。 -
模拟来自 Wilma 人员的未经请求的
updatechat消息。 -
模拟对发出的
leavechat消息的响应。当用户注销时发送此消息。我们可以在这一点上解绑聊天消息回调。
让我们更新 Fake 以采用这些更改,如列表 6.6 所示。更改以粗体显示:
列表 6.6. 向 Fake 添加模拟消息—spa/js/spa.fake.js


现在我们已经更新了chat对象和 Fake,我们可以测试消息功能。
6.2.6. 测试聊天消息
现在我们可以测试设置聊天对象、发送消息和接收消息。让我们加载我们的浏览器文档(spa/spa.html)并打开 JavaScript 控制台,确保没有错误。然后我们可以按照列表 6.7 所示进行测试。输入的文本以粗体显示;输出以斜体显示:
列表 6.7. 测试消息交换


我们的chat对象几乎完成了。我们现在需要添加头像支持。一旦我们完成了这个,我们将更新用户界面。
6.3. 将头像支持添加到模型
由于我们可以构建在chat对象的消息基础设施之上,因此添加头像功能相对简单。我们提出这一功能的主要原因是展示近实时消息的其他用途。它在会议上的良好表现只是锦上添花。首先,我们将更新模型。
6.3.1. 将头像支持添加到聊天对象
为了使 chat 对象支持头像,我们需要做的更改相对较小。我们只需要添加 update_avatar 方法,该方法将发送一个包含描述哪个头像更改以及如何更改的映射的 updateavatar 消息到后端。我们期望后端在头像更新时发送 listchange 消息,处理该消息的代码已经编写并测试过了。
让我们更新模型,如 列表 6.8 所示。更改以 粗体 显示:
列表 6.8. 更新模型以支持头像—spa/js/spa.model.js


我们已经完成了为 chat 对象添加的所有方法和事件。在下一节中,我们将更新 Fake 模块以模拟服务器交互以支持头像。
6.3.2. 修改 Fake 以模拟头像
我们下一步是修改 Fake 模块以支持在用户将头像拖动到新位置或点击头像以更改其颜色时向后端发送 updateavatar 消息。当 Fake 收到此消息时,它应该:
-
模拟向服务器发送
updateavatar消息。 -
模拟从服务器接收一个包含更新人员列表的
listchange消息。 -
执行为
listchange消息注册的回调,并提供更新的人员列表。
这些三个步骤可以按照 列表 6.9 所示完成。更改以 粗体 显示:
列表 6.9. 修改 Fake 以支持头像—spa/js/spa.fake.js


现在我们已经有了 chat 对象和 Fake 的更新,我们可以测试头像了。
6.3.3. 测试头像支持
这是我们的最终模型测试。再次,让我们加载我们的浏览器文档 (spa/spa.html) 并确保单页应用 (SPA) 仍然像以前一样工作。我们将打开 JavaScript 控制台并测试我们的 update_avatar 方法,如 列表 6.10 所示。输入的文本以 粗体 显示;输出以 斜体 显示:
列表 6.10. 测试 update_avatar 方法


我们已经完成了 chat 对象。与第五章中的 people 对象一样,测试令人放心,我们可以添加一个测试套件,用于在没有服务器或浏览器的情况下使用。
6.3.4. 测试驱动开发
所有那些测试驱动开发 (TDD) 狂热者可能都在看着所有这些手动测试,想着“天哪,为什么不直接将其放入一个可以自动运行的测试套件中呢?”作为有抱负的狂热者,我们可以,我们也做到了。查看 附录 B 了解如何使用 Node.js 自动化此过程。
我们实际上在测试套件中发现了一些问题。大多数都是针对测试的,所以我们将在附录中留下 那些。但有两个真正的错误我们需要修复:我们的注销机制并不完全正确,因为它没有正确清除用户列表,并且在调用 spa.model.chat.update_avatar 方法后,chatee 对象没有被正确更新。现在让我们修复这两个问题,如 列表 6.11 所示。更改以粗体显示:
列表 6.11. 修复注销和聊天对象更新—spa/js/spa.model.js


这是一个休息的好时机。在本章的剩余部分,我们将回到 UI,使用模型提供的 chat 和 people 对象 API 完成聊天功能模块。我们还将创建一个头像功能模块。
6.4. 完成聊天功能模块
在本节中,我们将更新图 6.3 中所示的聊天功能模块。现在我们可以利用模型的 chat 和 people 对象来提供模拟的聊天体验。让我们回顾一下我们之前模拟的聊天界面,并决定如何修改它以与 chat 对象一起工作。图 6.4 展示了我们想完成的内容。我们可以将这个模拟提炼成我们希望添加到聊天功能模块的功能列表。这些包括:
图 6.3. 我们 SPA 架构中的聊天功能模块

图 6.4. 我们想要的聊天界面

-
将聊天滑块的样式设计改为包含人员列表。
-
当用户登录时,执行以下操作:加入聊天,打开聊天滑块,更改聊天滑块标题,并显示在线人员列表。
-
在在线人员列表更改时更新在线人员列表。
-
在在线人员列表中突出显示聊天对象,并在列表更改时更新显示。
-
赋予用户发送消息和从在线人员列表中选择聊天对象的能力。
-
在消息日志中显示用户、其他人和系统的消息。这些消息都应该看起来不同,并且消息日志应该从底部向上平滑滚动。
-
修改界面以支持触摸控制。
-
当用户注销时,执行以下操作:更改聊天滑块的标题,清除消息日志,并收回滑块。
让我们从更新 JavaScript 开始。
6.4.1. 更新聊天 JavaScript
我们需要更新聊天 JavaScript 以添加我们刚刚讨论的功能。主要更改包括:
-
修改 HTML 模板以包含人员列表。
-
创建
scrollChat、writeChat、writeAlert和clearChat方法来管理消息日志。 -
创建用户输入事件处理器
onTapList和onSubmitMsg,允许用户从人员列表中选择聊天对象并发送消息。确保支持触摸事件。 -
创建
onSetchatee方法来处理模型发布的spa-setchatee事件。这将改变聊天对象的显示,更改聊天滑块的标题,并在消息窗口中提供系统警报。 -
创建
onListchange方法来处理模型发布的spalistchange事件。这将渲染带有高亮聊天对象的名单。 -
创建
onUpdatechat方法来处理模型发布的spa-update-chat事件。这将显示用户、服务器或其他人发送的新消息。 -
创建
onLogin和onLogout方法来处理模型发布的spa-login和spa-logout事件。onLogin处理器将在用户登录时打开聊天滑块。onLogout处理器将清除消息记录,重置标题,并关闭聊天滑块。 -
订阅所有模型发布的事件,然后绑定所有用户输入事件。
关于那些事件处理器名称
我们知道有些人可能会想,“为什么方法名 onSetchatee 不是 onSetChatee?” 好吧,确实有原因。
我们为事件处理器命名的约定是 on<Event>[<Modifier>],其中 Modifier 是一个可选参数。这通常工作得很好,因为大多数事件都是单音节词。例如包括 onTap 或 onTapAvatar。这个约定很方便,这样我们就可以精确地追踪处理器所处理的事件。
就像所有约定一样,总有一些边缘情况可能会让人困惑。例如,在 onListchange 的情况下,我们遵循了我们的约定:事件名是 listchange,而不是 listChange。因此 onListchange 是正确的,而 onListChange 则不是。同样的规则也适用于 onSetchatee 和 onUpdatechat。
让我们更新 JavaScript 文件,如列表 6.12 所示。更改以粗体显示:
列表 6.12. 更新聊天 JavaScript 文件—spa/js/spa.chat.js


模板系统与您
我们的 SPA 使用简单的字符串连接来生成 HTML,这对于我们的目的来说完全是可以接受的。但总有需要更复杂 HTML 生成的时候。那就是考虑使用模板系统的时候了。
模板系统将数据转换为显示元素。我们可以根据开发者用来指导元素生成的语言来广泛地划分模板系统。嵌入式风格允许我们直接在模板中嵌入宿主语言——在我们的例子中,是 JavaScript。工具箱风格提供了一种独立于宿主语言的特定领域模板语言(DSL)。
我们不推荐使用任何 嵌入式风格 系统,因为它们使得将业务逻辑与显示逻辑混合变得过于容易。最流行的 JavaScript 嵌入式风格 系统可能是由 underscore.js 的 template 方法提供的,但还有很多其他的选择。
我们注意到,在其他语言中,工具包样式系统随着时间的推移逐渐变得受欢迎。这可能是因为这些系统倾向于鼓励显示和业务逻辑的清晰分离。许多好的工具包样式模板系统可用于 SPA。在撰写本文时,流行的、经过良好测试的工具包样式模板系统包括Handlebars、Dust和Mustache。我们认为它们都值得你考虑。
现在我们已经放置了 JavaScript,让我们修改样式表以匹配。
6.4.2. 更新样式表
现在我们将更新增强界面的样式表。首先,我们希望更新我们的根样式表,以防止在大多数元素上选择文本。这消除了在触摸设备上特别明显的令人烦恼的用户体验。更新显示在列表 6.13。更改以粗体显示:
列表 6.13. 更新根样式表—spa/css/spa.css


现在我们需要更新我们的聊天样式表。主要更改包括:
-
设计一个在线人员列表,显示在滑块的左侧。
-
使滑块更宽,以容纳人员列表。
-
设计消息窗口样式。
-
移除所有
spa-chat-box*和spa-chat-msgs*选择器。 -
为从用户、聊天对象和系统接收的消息添加样式。
这些更新显示在列表 6.14。更改以粗体显示:
列表 6.14. 更新聊天样式表—spa/css/spa.chat.css


现在我们已经放置了样式表,让我们看看我们的更新后的聊天用户界面工作得怎么样。
6.4.3. 测试聊天 UI
当我们加载浏览器文档(spa/spa.html)时,现在我们应该在右上角的用户区域看到一个显示“请登录”的页面。当我们点击这个按钮时,我们可以像以前一样登录。用户区域将显示“...处理中...”3 秒钟,然后显示用户名在用户区域。那时,聊天滑块应该打开,界面应该看起来像图 6.5 中所示。
图 6.5. 登录后的更新聊天界面

几秒钟后,我们将收到 Wilma 的第一条消息。我们可以回复,然后选择 Pebbles 并发送给她一条消息。聊天界面应该类似于图 6.6。
图 6.6. 使用一段时间后的聊天滑块

我们现在已经使用模型的chat和peopleAPI 提供了我们在聊天功能模块中想要的所有功能。现在我们想添加头像功能模块。
6.5. 创建头像功能模块
在本节中,我们创建如图 6.7 所示的头像功能模块。
图 6.7. 我们 SPA 架构中的头像功能模块

chat对象已经提供了管理头像信息的功能。我们只需要决定一些细节。让我们回顾一下如图 6.8 所示的头像 UI。
图 6.8. 我们希望展示的化身

每个在线人物都有一个形状像盒子、带有粗边框并在中心显示其名字的化身。代表用户的化身应该有蓝色边框。聊天对象的化身应该有绿色边框。当我们轻触或点击一个化身时,它应该改变颜色。在长按或触摸化身后,其外观应该改变,并且我们应该能够将其拖动到新的位置。
我们将使用功能模块的典型流程来开发我们的化身模块:
-
使用隔离的命名空间为功能模块创建一个 JavaScript 文件。
-
为功能模块创建一个以命名空间为前缀的样式表文件。
-
更新浏览器文档以包含新的 JavaScript 和样式表文件。
-
调整外壳以配置和初始化新模块。
在接下来的几节中,我们将遵循以下步骤。
6.5.1. 创建化身 JavaScript
在添加化身功能模块的第一步是创建 JavaScript 文件。由于该模块使用了与聊天模块相同的许多事件,我们可以将 spa/js/spa.chat.js 复制到 spa/js/spa.avtr.js,然后相应地进行调整。列表 6.15 是我们新创建的功能模块文件。由于它与聊天模块非常相似,我们不提供深入的讨论。但有趣的部分已经进行了注释:
列表 6.15. 创建化身 JavaScript—spa/js/spa.avtr.js


现在我们已经完成了模块的 JavaScript 部分,我们可以创建相关的样式表。
6.5.2. 创建化身样式表
我们的化身模块通过绘制矩形来图形化地表示用户。我们可以定义一个单独的类 (spa-avtr-box) 来设置矩形的样式。然后,我们可以修改这个类来突出显示用户 (spa-x-is-user),突出显示聊天对象 (spa-x-is-chatee),或者突出显示正在拖动的矩形 (spa-x-is-drag)。这些选择器在 列表 6.16 中展示:
列表 6.16. 创建化身样式表—spa/css/spa.avtr.css


模块文件完成后,我们现在需要调整两个额外的文件:外壳和浏览器文档。
6.5.3. 更新外壳和浏览器文档
如果我们想使用新创建的功能模块,我们需要更新外壳以配置和初始化它,如 列表 6.17 所示:
列表 6.17. 更新外壳以配置和初始化化身—spa/js/spa.shell.js

创建功能模块的最后一步是更新浏览器文档以包含 JavaScript 和样式表文件。这一步在 第五章 中已经完成,但为了完整性,这里再次展示 列表 6.18 中的更改:
列表 6.18. 更新浏览器文档以包含化身—spa/spa.html
...
<!-- our stylesheets -->
<link rel="stylesheet" href="css/spa.css" type="text/css"/>
<link rel="stylesheet" href="css/spa.shell.css" type="text/css"/>
<link rel="stylesheet" href="css/spa.chat.css" type="text/css"/>
<link rel="stylesheet" href="css/spa.avtr.css" type="text/css"/>
...
<!-- our javascript -->
...
<script src="js/spa.shell.js" ></script>
<script src="js/spa.chat.js" ></script>
<script src="js/spa.avtr.js" ></script>
...
化身功能模块的创建和集成已完成。现在让我们测试一下。
6.5.4. 测试头像功能模块
当我们加载浏览器文档(spa/spa.html)时,我们应该在右上角的用户区域看到一个显示“请登录”的页面。当我们点击这个按钮时,我们可以像以前一样登录。一旦聊天滑块打开,我们应该看到一个类似于图 6.9 的界面。
图 6.9. 登录后的头像显示

我们现在可以通过按住并拖动它们来拖动头像(它们都从左上角开始)。轻触头像会导致颜色变化。经过一些点击和拖动后,我们应该看到一个类似于图 6.10 的界面。用户头像有一个蓝色边框,聊天对象的头像有一个绿色边框,任何正在被拖动的头像都有一个黑白红配色方案:
图 6.10. 活跃的头像

我们已经实现了本章开头讨论的所有功能。现在让我们看看我们是如何完成我们工作的一个方面,这个方面是当今的一个热门话题——数据绑定。
6.6. 数据绑定和 jQuery
数据绑定是一种机制,确保当模型数据发生变化时,界面会相应地改变以反映它;反之,当用户更改界面时,模型数据也会相应更新。这并不新鲜——如果你曾经从事过 UI 工作,你肯定已经作为常规实现了数据绑定。
我们在本章中使用了 jQuery 方法来实现数据绑定。当我们的 SPA 中的模型数据发生变化时,我们发布 jQuery 全局自定义事件。我们的 jQuery 集合订阅特定的自定义全局事件,并在事件发生时调用函数来更新它们的展示。当用户在屏幕上修改数据时,他们会触发事件处理器,调用方法来更新模型。这很简单,并且在如何以及何时更新数据和展示方面提供了相当大的灵活性。使用 jQuery 进行数据绑定并不难,也不是神秘神奇的——这是一个好事。
警惕携带礼物的 SPA“框架”库
一些 SPA“框架”库承诺“自动双向数据绑定”,这听起来确实不错。但尽管有令人印象深刻的演示,我们还是学到了一些关于这种承诺的注意事项:
-
我们需要学习库的语言——它的 API 和术语,以便让它做那些一个精心打扮的演讲者能做的事情。这可能是一个重大的投资。
-
库的作者通常有一个关于 SPA 应该如何构建的愿景。如果我们的 SPA不符合这个愿景,那么改造可能会变得昂贵。
-
库可能很大、有 bug,并且提供了一个可能出错的新层级的复杂性。
-
库的数据绑定可能往往不符合我们的 SPA 要求。
让我们关注最后一点。也许我们希望用户能够编辑表格中的一行,并在完成编辑后,要么接受整个行,要么取消(在这种情况下,行应恢复到旧值)。此外,当用户完成编辑行后,我们希望用户接受或取消整个编辑过的表格。然后我们才会考虑将表格保存到后端。
框架库支持这种合理交互“开箱即用”的概率很低。因此,如果我们选择一个库,我们需要创建一个自定义覆盖方法来绕过默认行为。如果我们不得不这样做几次,我们很容易就会得到更多的代码、更多的层、更多的文件和更多的复杂性,比我们最初自己编写这个该死的东西还要多。
经过几次良好的尝试后,我们学会了谨慎地对待框架库。我们发现它们可能会给单页应用(SPA)增加复杂性,而不是使开发变得更好、更快或更容易理解。这并不意味着我们永远不应该使用框架库——它们有自己的位置。但我们的示例 SPAs(以及生产中的许多)仅使用 jQuery、一些插件和一些专门的工具(如 TaffyDb)就能很好地工作。通常,简单更好。
现在让我们通过添加数据模块并进行一些小的调整来完成单页应用(SPA)的客户端部分。
6.7. 创建数据模块
在本节中,我们创建了如图 6.11 所示的数据模块 链接。
图 6.11. 我们单页应用(SPA)架构中的数据模型

这将为客户端准备使用来自服务器的“真实”数据和服务的功能,而不是我们的模拟模块。完成本节后,应用程序将无法工作,因为所需的网络功能尚未到位。这将在第七章 链接 和第八章 链接 中介绍。
我们需要将 Socket.IO 库添加到我们加载的库列表中,因为这将是我们消息传输机制。这如列表 6.19 所示完成。变化以粗体显示:
列表 6.19. 在浏览器文档中包含 Socket.IO 库—spa/spa.html
...
<!-- third-party javascript -->
<script src="socket.io/socket.io.js" ></script>
<script src="js/jq/taffydb-2.6.2.js" ></script>
...
我们希望确保在模型或外壳之前初始化数据模块,如图 6.20 所示 列表。变化以粗体显示:
列表 6.20. 在根命名空间模块中初始化数据—spa/js/spa.js

接下来,我们更新数据模块,如图 6.21 所示 列表。此模块管理我们架构中与服务器所有的连接,并且客户端和服务器之间所有的数据通信都通过此模块进行。这个模块所做的一切可能目前还不清楚,但不用担心——我们将在下一章中更详细地介绍 Socket.IO。变化以粗体显示:
列表 6.21. 更新数据模块—spa/js/spa.data.js


在准备使用服务器数据的过程中,我们的最后一步是告诉模型停止使用假数据,如列表 6.22 所示。更改以粗体显示:
列表 6.22. 更新模型以使用“真实”数据—spa/js/spa.model.js
...
spa.model = (function () {
'use strict';
var
configMap = { anon_id : 'a0' },
stateMap = {
...
},
isFakeData = false,
...
在这次最后的更改之后,当我们加载我们的浏览器文档(spa/spa.html)时,我们会发现我们的单页应用(SPA)将无法像以前那样工作,我们会在控制台中看到错误。如果我们想在没有服务器的情况下继续开发,我们可以轻松地“切换开关”,将isFakeData赋值还原为true。1 现在,我们已经准备好将服务器添加到我们的 SPA 中。
¹ 浏览器可能会抱怨找不到 Socket.IO 库,但这应该是无害的。
6.8. 摘要
在本章中,我们完成了对模型的工作。我们系统地设计、指定、开发和测试了chat对象。与第五章一样,我们使用来自假模块的模拟数据来加速开发。然后,我们更新了聊天功能模块,使其使用模型提供的chat和people对象 API。我们还创建了头像功能模块,它也使用了相同的 API。然后,我们讨论了使用 jQuery 进行数据绑定。最后,我们添加了一个数据模块,该模块将使用 Socket.IO 与 Node.js 服务器通信。在第八章 8 中,我们将设置服务器以与数据模块协同工作。在下一章中,我们将熟悉 Node.js。
第三部分。SPA 服务器
当用户在传统网站上导航时,服务器会消耗大量的处理能力来生成并发送一页又一页的内容到浏览器。SPA 服务器则截然不同。大部分的业务逻辑——以及所有的 HTML 模板和展示逻辑——都被移到了客户端。服务器仍然很重要,但它变得更加精简,更专注于像持久数据存储、数据验证、用户认证和数据同步等服务。
从历史上看,网络开发者不得不花费大量时间开发逻辑来转换一种数据格式到另一种,就像把泥土从一个大而潮湿的土堆铲到另一个土堆一样——而且几乎一样低效。网络开发者还必须掌握许多不同的语言和工具包。一个传统的网站堆栈可能需要详细了解 SQL、Apache2、mod_rewrite、mod_perl2、Perl、DBI、HTML、CSS 和 JavaScript。学习所有这些语言并在它们之间切换是昂贵且令人烦恼的。更糟糕的是,如果我们需要将一些逻辑从一个应用程序的部分移动到另一个部分,我们就必须用完全不同的语言重写它。在第三部分中我们学到:
-
Node.js 和 MongoDB 的基础知识
-
如何停止在数据转换上浪费服务器周期,转而在整个 SPA 堆栈中使用 JSON 数据格式
-
如何仅使用一种语言——JavaScript 构建 HTTP 服务器应用程序并与之交互
-
SPA 部署的挑战以及我们如何解决它们
我们在我们的堆栈中端到端使用 JSON 和 JavaScript。这消除了数据转换的开销。并且它显著减少了我们需要掌握的语言和开发环境数量。结果是更好的产品,其开发、交付和维护的成本显著降低。
第七章. 网络服务器
本章涵盖
-
在支持 SPA 时,网络服务器的作用
-
使用 Node.js 作为网络服务器语言
-
使用 Connect 中间件
-
使用 Express 框架
-
配置 Express 以支持 SPA 架构
-
路由和 CRUD
-
使用 Socket.IO 进行消息传递以及我们为什么关心
本章讨论了服务器支持 SPA 所需的逻辑和代码。它还提供了 Node.js 的良好入门介绍。如果在阅读本章后你真的非常兴奋,并想使用 Node.js 构建一个完全可投入生产的应用程序,我们建议查看书籍《Node.js 实战》(Manning 2013)。
7.1. 服务器的作用
单页应用(SPA)将传统网站服务器上发现的大部分业务逻辑移动到了浏览器。但我们需要一些服务器端的“阴”来匹配客户端的“阳”。有些领域需要网络服务器参与以实现预期效果——例如,安全性——或者服务器比客户端更适合执行任务。SPA 网络服务器最常见的职责包括身份验证和授权、数据验证以及数据存储和同步。
7.1.1. 身份验证和授权
身份验证是确保某人就是他们所说的那个人的过程。服务器是必需的,因为我们绝不应该仅依赖于客户端提供的数据。如果身份验证仅由客户端处理,恶意黑客可能会逆向工程身份验证机制并创建必要的凭证来冒充用户并窃取他们的账户。身份验证通常是通过用户输入用户名和密码来启动的。
越来越多的开发者转向第三方身份验证服务,例如由 Facebook 或 Yahoo 提供的服务。当与第三方进行身份验证时,用户需要提供第三方服务的凭证——通常是用户名和密码。例如,如果我们使用 Facebook 身份验证,用户将被期望向 Facebook 服务器提供他们的 Facebook 账户的用户名和密码。然后第三方服务器与我们的服务器通信以验证用户。对用户的好处是他们可以重用他们已经记住的用户名和密码。对开发者的好处是他们可以将实现的大部分繁琐细节外包出去,并获得访问第三方用户群体的权限。
授权是确保只有那些应该有权访问数据的人或系统能够接收数据的流程。这可以通过将权限绑定到用户来实现,这样当用户登录时,会有一个记录表明他们被允许看到什么。确保授权在服务器上处理非常重要,这样就不会向客户端发送未经授权的数据。否则,我们的恶意黑客可能会再次逆向工程我们的应用程序并访问他们不应该看到的敏感信息。授权的一个附带好处是,因为它只发送用户有权看到的数据,所以它最小化了发送到客户端的数据量,可能使交易变得更快。
7.1.2. 验证
验证是一个质量控制过程,确保只能保存准确和合理的数据。验证有助于防止错误被保存并传播到其他用户或系统。例如,航空公司可能会验证当用户选择购买机票的航班日期时,他们选择的是有可用座位的未来日期。如果没有这种验证,航空公司可能会超额预订航班,预订不存在航班的座位,或者预订已经起飞航班的座位。
验证在客户端和服务器端都发生很重要:它应该在客户端实现以获得快速响应,并且应该在服务器上验证,因为它永远不应该信任客户端的代码是有效的。服务器接收无效数据可能会导致各种问题:
-
编程错误可能会损坏或省略 SPA 中的客户端验证。
-
不同的客户端可能缺乏验证——Web 服务器应用程序通常有多个客户端访问同一个服务器。
-
一个曾经有效的选项可能在数据提交时变得无效(比如说,用户点击提交后,有人刚刚预订了座位)。
-
我们的恶意黑客可能会再次出现,并试图通过用损坏的数据填充我们的数据存储来劫持或破坏网站。
不当的服务器验证的经典例子是 SQL 注入攻击,这些攻击让许多本应知道更好的知名组织感到尴尬。我们不想加入那个行列,对吧?
7.1.3. 数据的保留和同步
虽然单页应用(SPA)可以在客户端保存数据,但这些数据是临时的,并且很容易在 SPA 的控制之外被修改或删除。在大多数情况下,客户端应仅用于临时存储,服务器负责长期存储。
数据可能还需要在多个客户端之间同步,比如当一个人的在线状态需要与查看他们主页的每个人共享时。实现这一点的最简单方法是让客户端将状态发送到服务器,让服务器保存它,然后将状态广播给所有经过身份验证的客户端。同步也可以用于临时数据;例如,当我们使用聊天服务器向经过身份验证的客户端发送消息时:尽管服务器不存储数据,但它有将消息路由到正确的经过身份验证的客户端的关键任务。
7.2. Node.js
Node.js 是一个使用 JavaScript 作为其控制语言的平台。当我们将其用作 HTTP 服务器时,它在哲学上与 Twisted、Tornado 或 mod_perl 相似。相比之下,许多其他流行的 Web 服务器平台被分为两个组件:HTTP 服务器和应用进程容器。例如,包括 Apache/PHP、Passenger/Ruby 或 Tomcat/Java。
将 HTTP 服务器和应用程序一起编写使我们能够轻松完成在具有独立 HTTP 和应用程序组件的平台上的某些任务。例如,如果我们想将日志写入内存数据库,我们可以这样做,而无需担心 HTTP 服务器在哪里结束,应用程序服务器在哪里开始。
7.2.1. 为什么选择 Node.js?
我们选择 Node.js 作为我们的服务器平台,因为它具备使它成为现代 SPA(单页应用)优秀选择的特性:
-
服务器就是应用程序。结果是无需担心设置和与单独的应用程序服务器接口。所有控制都在一个地方,由一个进程完成。
-
服务器应用程序的语言是 JavaScript,这意味着我们可以消除在一种语言中编写服务器应用程序而在另一种语言中编写 SPA 的认知负担。这也意味着我们可以在客户端和服务器之间共享代码,这有许多优点。例如,我们可能在 SPA 和服务器上使用相同的数据验证库。
-
Node.js 是非阻塞和事件驱动的。简而言之,这意味着在适度硬件上,单个 Node.js 实例可以处理成千上万的并发打开连接,如实时消息中使用的连接,这通常是现代 SPA 高度期望的特性。
-
Node.js 速度快,支持良好,并且拥有快速增长的模块和开发者群体。
Node.js 处理网络请求的方式与大多数其他服务器平台不同。大多数 HTTP 服务器维护一个进程或线程池,以准备服务传入的请求。相比之下,Node.js 只有一个事件队列,它会处理每个传入的请求,并且甚至将传入请求的部分处理拆分为主事件队列中的单独事件。在实践中,这意味着 Node.js 不会在处理其他事件之前等待长时间的事件完成。如果某个数据库查询耗时较长,Node.js 会继续处理其他事件。当数据库查询完成时,一个事件会被放入队列,以便控制例程可以使用结果。
不再拖延,让我们深入了解 Node.js,看看如何用它创建一个网络服务器应用程序。
7.2.2. 使用 Node.js 创建“Hello World”
让我们去 Node.js 网站(nodejs.org/#download)下载并安装 Node.js。有许多下载和安装它的方法;如果你不熟悉命令行,最简单的方法可能是使用操作系统的安装程序。
Node 包管理器npm与 Node.js 一起安装。它类似于 Perl 的CPAN、Ruby 的gem或 Python 的pip。根据我们的命令,它下载并安装包,同时解决依赖关系。这比我们手动做要容易得多。现在我们已经安装了 Node.js 和npm,让我们创建我们的第一个服务器。Node.js 网站(nodejs.org)有一个简单的 Node 网络服务器的示例,所以我们将使用它。让我们创建一个名为 webapp 的目录并将其作为工作目录。然后我们可以在其中创建一个名为 app.js 的文件,并包含列表 7.1 中的代码:
列表 7.1. 创建一个简单的 Node 服务器应用程序—webapp/app.js
/*
* app.js - Hello World
*/
/*jslint node : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global */
var http, server;
http = require( 'http' );
server = http.createServer( function ( request, response ) {
response.writeHead( 200, { 'Content-Type': 'text/plain' } );
response.end( 'Hello World' );
}).listen( 3000 );
console.log( 'Listening on port %d', server.address().port );
打开终端,导航到我们保存 app.js 文件的目录,并使用以下命令启动服务器:
node app.js
你应该看到Listening on port 3000。当我们打开一个网络浏览器(在同一台计算机上)并访问http://localhost:3000时,我们应该在浏览器中看到Hello World出现。哇,这很简单!只用七行代码就创建了一个服务器。我不知道你现在感觉如何,但我很高兴能在几分钟内编写并运行一个网络服务器应用程序。现在让我们来分析一下代码的含义。
我们的第一部分是带有 JSLint 设置的常规标题。它允许我们验证我们的服务器 JavaScript,就像我们验证客户端 JavaScript 一样:
/*
* app.js - Hello World
*/
/*jslint node : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global */
下一个行声明我们将使用的模块作用域变量:
var http, server;
下一个行告诉 Node.js 包含http模块以供此服务器应用程序使用。这类似于使用 HTML script标签包含 JavaScript 文件以供浏览器使用。http模块是一个 Node.js 核心模块,用于创建 HTTP 服务器,我们将模块存储在变量http中:
http = require( 'http' );
接下来我们使用http.createServer方法创建一个 HTTP 服务器。我们提供一个匿名函数,每当 Node.js 服务器接收到请求事件时都会调用该函数。该函数接收一个request对象和一个response对象作为参数。request对象是客户端发送的 HTTP 请求:
server = http.createServer( function ( request, response ) {
在我们的匿名函数内部,我们开始定义对 HTTP 请求的响应。下一行使用response参数创建 HTTP 头。我们提供一个200的 HTTP 响应代码来指示成功,并提供一个具有Content-Type属性和text/plain值的匿名对象。这告诉浏览器在消息中期望什么类型的内容:
response.writeHead( 200, { 'Content-Type': 'text/plain' } );
下一个行使用response.end方法向客户端发送字符串‘Hello World',并让 Node.js 知道我们已经完成了这个响应:
response.end( 'Hello World' );
我们随后关闭匿名函数和对createServer方法的调用。代码随后在http对象上链式调用listen方法。listen方法指示http对象监听 3000 端口:
}).listen( 3000 );
我们最后一行代码在启动此服务器应用程序时打印到控制台。我们能够使用我们之前创建的server对象的一个属性来报告正在使用的端口号:
console.log( 'Listening on port %d', server.address().port );
我们已经使用 Node.js 创建了一个非常基本的服务器。花些时间在http.createServer方法中传递给匿名函数的request和response参数上玩耍是值得的。让我们从在列表 7.2 中记录request参数开始。新行以粗体显示:
列表 7.2. 向节点服务器应用程序添加简单的日志记录—webapp/app.js
/*
* app.js - Basic logging
*/
...
var http, server;
http = require( 'http' );
server = http.createServer( function ( request, response ) {
console.log( request );
response.writeHead( 200, { 'Content-Type': 'text/plain' } );
response.end( 'Hello World' );
}).listen( 3000 );
console.log( 'Listening on port %d', server.address().port );
当我们重新启动网络应用程序时,我们将在运行 Node.js 应用程序的终端中看到对象被记录,如列表 7.3 所示。现在不必太在意对象的架构;我们稍后会讲解我们需要了解的部分。
列表 7.3. 请求对象
{ output: [],
outputEncodings: [],
writable: true,
_last: false,
chunkedEncoding: false,
shouldKeepAlive: true,
useChunkedEncodingByDefault: true,
sendDate: true,
_hasBody: true,
_trailer: '',
finished: false,
... // down another 100 or so lines of code
request对象的一些显著属性包括:
-
ondata——当服务器开始从客户端接收数据时会被调用的一个方法,例如当设置POST变量时。这与大多数框架从客户端获取参数的方法有显著不同。我们将抽象化这部分,以便在变量中提供完整的参数列表。 -
headers——请求中的所有头部信息。 -
url——请求的页面,不包含主机。例如,www.singlepagewebapp.com/test的url将是/test。 -
method——用于发送请求的方法:GET或POST。
带着对这些属性的知识的武装,我们可以开始编写一个基本的路由器,如列表 7.4 所示。变化以粗体显示:
列表 7.4. 向节点服务器应用程序添加简单的路由—webapp/app.js

我们可以继续编写我们自己的路由器,对于简单的应用程序来说,这是一个合理的选择。然而,我们对我们的服务器应用程序有更大的抱负,我们希望使用 Node.js 社区开发和测试过的框架。我们将考虑的第一个框架是 Connect。
7.2.3. 安装和使用 Connect
Connect 是一个可扩展的 中间件 框架,它为 Node.js 网络服务器添加了基本认证、会话管理、静态文件服务和表单处理等功能。它不是唯一的框架,但它是简单且相对标准的。Connect 允许我们在请求接收和最终响应之间注入 中间件 函数。通常,中间件函数接收一个传入的请求,对其进行一些操作,然后将请求传递给下一个中间件函数或使用response.end方法结束响应。
熟悉 Connect 和中间件模式最好的方式是使用它。让我们确保 webapp 是我们的工作目录,并安装 connect。在命令行中输入以下内容:
npm install connect
这将创建一个名为 node_modules 的文件夹,并在其中安装 Connect 框架。node_modules 目录是所有 Node.js 应用程序模块的文件夹。npm 将在此目录中安装模块,当我们编写自己的模块时,它们将放在这里。我们可以按照 列表 7.5 中的说明修改我们的服务器应用程序。变更以粗体显示:
列表 7.5. 修改节点服务器应用程序以使用 Connect—webapp/app.js
/*
* app.js - Simple connect server
*/
...
var
connectHello, server,
http = require( 'http' ),
connect = require( 'connect' ),
app = connect(),
bodyText = 'Hello Connect';
connectHello = function ( request, response, next ) {
response.setHeader( 'content-length', bodyText.length );
response.end( bodyText );
};
app.use( connectHello );
server = http.createServer( app );
server.listen( 3000 );
console.log( 'Listening on port %d', server.address().port );
这个 Connect 服务器的行为与上一节中的第一个节点服务器非常相似。我们定义了第一个中间件函数 connectHello,然后告诉 Connect 对象 app 使用这个方法作为其唯一的中间件函数。由于 connectHello 函数调用了 response.end 方法,它结束了服务器响应。让我们在此基础上添加更多的中间件。
7.2.4. 添加 Connect 中间件
假设我们想要记录每次有人访问页面时的情况。我们可以使用 Connect 提供的内置中间件函数来实现。列表 7.6 展示了添加 connect.logger() 中间件函数。变更以粗体显示:
列表 7.6. 使用 Connect 向节点服务器应用程序添加日志—webapp/app.js
/*
* app.js - Simple connect server with logging
*/
...
var
connectHello, server,
http = require( 'http' ),
connect = require( 'connect' ),
app = connect(),
bodyText = 'Hello Connect';
connectHello = function ( request, response, next ) {
response.setHeader( 'content-length', bodyText.length );
response.end( bodyText );
};
app
.use( connect.logger() )
.use( connectHello );
server = http.createServer( app );
server.listen( 3000 );
console.log( 'Listening on port %d', server.address().port );
我们所做的只是在我们定义的 connectHello 中间件之前添加了 connect.logger() 作为中间件。现在每次客户端向服务器应用程序发出 HTTP 请求时,首先被调用的中间件函数是 connect.logger(),它将日志信息打印到控制台。随后被调用的下一个中间件函数是我们定义的 connectHello,它像之前一样向客户端发送 Hello Connect 并结束响应。当我们将浏览器指向 http://localhost:3000 时,我们应该在 Node.js 控制台日志中看到以下内容:
Listening on port 3000
127.0.0.1 - - [Wed, 01 May 2013 19:27:12 GMT] "GET / HTTP/1.1" 200\
13 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.31 \
(KHTML, like Gecko) Chrome/26.0.1410.63 Safari/537.31"
尽管 Connect 比 Node.js 是一个更高级的抽象,但我们希望有更多的功能。是时候升级到 Express 了。
7.2.5. 安装和使用 Express
Express 是一个轻量级 Web 框架,其设计灵感来自 Sinatra,一个轻量级的 Ruby Web 框架。在 SPA 中,我们不需要充分利用 Express 提供的每个功能,但它确实提供了比 Connect 更丰富的功能集——实际上,它是建立在 Connect 之上的。
确保 webapp 是我们的工作目录,并安装 Express。与之前使用 Connect 时的命令行不同,我们将使用名为 package.json 的清单文件来告诉 npm 我们的应用程序需要哪些模块和版本才能正确运行。这在将应用程序安装到远程服务器或有人在我们机器上下载并安装我们的应用程序时非常有用。让我们创建 package.json 以安装 Express,如 列表 7.7 所示:
列表 7.7. 为 npm install 创建清单—webapp/package.json
{
"name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies": {
"express" :"3.2.x"
}
}
name 属性是应用程序的名称;它可以是我们想要的任何名称。version 属性是应用程序的版本,它应该使用主要、次要和补丁版本方案(<major>.<minor>.<patch>)。将 private 属性设置为 true 告诉 npm 不要发布您的应用程序。最后,dependencies 属性描述了我们想要 npm 安装的模块和版本。在这种情况下,我们只有一个模块,即 express。让我们首先删除现有的 webapp/ node_modules 目录,然后使用 npm 安装 Express:
npm install
当使用 npm 命令添加新模块时,我们可以使用 --save 选项来自动更新 package.json 以包含新模块。这在开发过程中非常有用。注意我们如何指定我们想要的 Express 版本为 "3.2.x",这意味着我们想要 Express 版本 3.2,带有最新的补丁。这是一个推荐的版本声明,因为补丁很少破坏 API,而是修复错误或帮助确保向后兼容性。
现在让我们编辑 app.js 以使用 Express。我们将在这个实现中使用 'use strict' 预言和几个部分分隔符,如 列表 7.8 所示。变化以 粗体 显示:
列表 7.8. 使用 Express 创建一个 node 服务器应用程序—webapp/app.js
/*
* app.js - Simple express server
*/
...
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
http = require( 'http' ),
express = require( 'express' ),
app = express(),
server = http.createServer( app );
// ------------- END MODULE SCOPE VARIABLES ---------------
// ------------- BEGIN SERVER CONFIGURATION ---------------
app.get( '/', function ( request, response ) {
response.send( 'Hello Express' );
});
// -------------- END SERVER CONFIGURATION ----------------
// ----------------- BEGIN START SERVER -------------------
server.listen( 3000 );
console.log(
'Express server listening on port %d in %s mode',
server.address().port, app.settings.env
);
// ------------------ END START SERVER --------------------
在查看这个小型示例时,可能不会立即明显地看出为什么 Express 更容易使用,所以让我们逐行分析并查看。首先,我们加载 express 和 http 模块(如 粗体 所示):
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'usestrict';
var
http = require( 'http' ),
express = require( 'express' ),
app = express(),
server = http.createServer(app );
// ------------- END MODULE SCOPE VARIABLES ---------------
然后,我们使用 express 创建一个 app 对象。该对象具有设置应用程序路由和其他属性的函数。我们还创建了 HTTP server 对象,我们将在以后使用它(如 粗体 所示):
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'usestrict';
var
http = require( 'http' ),
express = require( 'express'),
app = express(),
server = http.createServer( app );
// ------------- END MODULE SCOPE VARIABLES ---------------
接下来,我们通过使用 app.get 方法来定义我们应用程序的路由:
// ------------- BEGIN SERVER CONFIGURATION ---------------
app.get( '/', function ( request, response) {
response.send( 'Hello Express' );
});
// --------------END SERVER CONFIGURATION----------------
由于 Express 提供了丰富的 get 等方法,Node.js 中的路由变得简单。app.get 的第一个参数是一个与请求 URL 进行比较的模式。例如,如果我们的开发机器上的浏览器向 http://localhost:3000 或 http://localhost:3000/ 发送请求,则 GET 请求字符串将是 ‘/’,这与模式匹配。
第二个参数是一个当匹配发生时执行的回调函数。request 和 response 对象是提供给回调函数的参数。查询字符串参数可以在 request.params 中找到。
我们的第三和最后一部分开始启动服务器并将日志记录到控制台:
// ----------------- BEGIN START SERVER -------------------
server.listen( 3000 );
console.log(
'Express server listening on port %d in %s mode',
server.address().port, app.settings.env
);
现在我们有一个工作的 Express 应用程序,让我们添加一些中间件。
7.2.6. 添加 Express 中间件
由于 Express 是建立在 Connect 之上的,我们也可以使用类似的语法调用并传递中间件。让我们为我们的应用程序添加日志中间件,如 7.9 所示。变化以 粗体 显示。
列表 7.9. 将 Express 日志中间件添加到我们的应用程序中—webapp/app.js
/*
* app.js - Simple express server with logging
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
app.use( express.logger() );
app.get( '/', function ( request, response ) {
response.send( 'Hello Express' );
});
// -------------- END SERVER CONFIGURATION ----------------
Express 提供了所有的 Connect 中间件方法,这样我们就不需要在页面中引入 Connect。运行前面的代码会导致应用程序将请求记录到控制台,就像上一节中的connect.logger所做的那样。
我们可以使用 Express 的app.configure方法来组织我们的中间件,就像列表 7.10 中所示。变更以粗体显示。
列表 7.10. 使用 configure 组织我们的 Express 中间件—webapp/app.js
/*
* app.js - Express server with middleware
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
app.configure( function () {
app.use( express.logger() );
app.use( express.bodyParser() );
app.use( express.methodOverride() );
});
app.get( '/', function ( request, response ) {
response.send( 'Hello Express' );
});
// -------------- END SERVER CONFIGURATION ----------------
...
此配置添加了两个新的中间件方法:bodyParser和methodOver-ride。bodyParser解码表单,将在以后广泛使用。methodOverride用于创建 RESTful 服务。configure方法还允许我们根据应用程序运行的 Node.js 环境更改配置。
7.2.7. 使用 Express 的环境
Express 支持根据环境设置切换配置的概念。示例环境包括development、testing、staging和production。Express 可以通过读取NODE_ENV环境变量来确定正在使用哪个环境,然后相应地设置其配置。如果你使用 Windows,你可以这样启动服务器应用程序:
SET NODE_ENV=production node app.js
使用 Mac 或 Linux,设置如下:
NODE_ENV=production node app.js
如果你使用的是其他东西,我们非常有信心你能弄明白。
当我们运行 Express 服务器应用程序时,我们可以使用任何字符串作为环境名称。如果没有设置NODE_ENV变量,它将默认使用development。
让我们调整我们的应用程序以适应提供的环境。我们希望在每一个环境中都使用bodyParser和methodOverride中间件。在开发环境中,我们希望应用程序记录 HTTP 请求和详细错误。在生产环境中,我们只想记录错误摘要,如列表 7.11 所示。变更以粗体显示:
列表 7.11. 使用 Express 支持不同的环境—webapp/app.js

我们可以通过在开发模式下运行应用程序(node app.js)并在浏览器中加载页面来测试这些配置。你应该在 Node.js 控制台中看到日志输出。接下来,我们可以停止服务器,并以生产模式运行它(NODE_ENV=production node app.js)。当我们重新加载浏览器中的页面时,日志中应该没有条目。
现在我们已经对 Node.js、Connect 和 Express 的一些基本原理有了很好的理解,让我们继续学习更高级的路由方法。
7.2.8. 使用 Express 提供静态文件服务
如你所预期,使用 Express 提供静态文件需要添加一些中间件和一点重定向。让我们将第六章中的 spa 目录的内容复制到公共目录中,如列表 7.12 所示。
列表 7.12. 添加静态文件目录
webapp
+-- app.js
+-- node_modules/...
+-- package.json
`-- public # contents of 'spa' copied here
+-- css/...
+-- js/...
`-- spa.html
现在我们可以调整应用程序以提供静态文件,如列表 7.13 所示。变更以粗体显示。
列表 7.13. 使用 Express 提供静态文件—webapp/app.js

现在我们运行应用程序(node app.js)并将浏览器指向 http://localhost:3000,我们应该看到我们离开第六章时的 SPA。尽管如此,我们目前还不能登录,因为后端还没有准备好。
现在我们对 Express 中间件有了很好的了解,让我们看看高级路由,这对于 Web 数据服务是必需的。
7.3. 高级路由
到目前为止,我们的应用程序所做的一切只是为 Web 应用程序的根提供路由,并向浏览器返回一些文本。在本节中,我们将:
-
使用 Express 框架为管理
user对象提供 CRUD 路由。 -
为所有用于 CRUD 的路由设置响应属性,例如内容类型。
-
使代码通用,以便它适用于所有 CRUD 路由。
-
将路由逻辑放入一个单独的模块中。
7.3.1. 用户 CRUD 路由
CRUD 操作(创建、读取、更新、删除)是数据持久存储通常需要的重大操作。如果您需要复习或第一次听说 CRUD,维基百科有一个深入的讨论。在 Web 应用程序中用于实现 CRUD 的常见设计模式被称为 REST,或 表征状态转移。REST 使用严格和定义良好的语义来定义动词 GET、POST、PUT、PATCH 和 DELETE 的作用。如果您了解并喜爱 REST,请随意实现它;它是在分布式系统之间交换数据的完全有效的方法,Node.js 甚至有许多模块旨在帮助实现这一点。
我们已经为用户对象实现了基本的 CRUD 路由,并且出于几个原因没有在这个示例中实现 REST。一个挑战是许多浏览器尚未实现本地的 REST 动词,因此 PUT、PATCH 和 DELETE 通常通过传递额外的表单参数或 POST 中的头信息来实现。这意味着开发者无法轻易地知道正在使用哪个动词,而必须通过发送数据的头信息进行搜索。尽管 REST 动词看起来与 CRUD 操作相似,但 REST 并不是 CRUD 的完美映射。最后,当处理状态码时,Web 浏览器可能会造成障碍。例如,我们本应将 302 状态码传递给客户端 SPA,但浏览器可能会拦截该代码并尝试“做正确的事”,将重定向到不同的资源。这可能不是我们总希望的行为。
我们可以通过列出所有用户开始。
创建获取用户列表的路由
我们可以创建一个简单的路由来提供用户列表。请注意,我们将响应对象的 contentType 设置为 json。这会将 HTTP 头信息设置为让浏览器知道响应是 JSON 格式,如列表 7.14 所示。变更以粗体显示:
列表 7.14. 创建一个获取用户列表的路由—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
...
// all configurations below are for routes
app.get( '/', function ( request, response ) {
response.redirect( '/spa.html' );
});
app.get( '/user/list', function ( request, response ) {
response.contentType( 'json' );
response.send({ title: 'user list' });
});
// -------------- END SERVER CONFIGURATION ----------------
...
用户列表路由期望一个 HTTP GET 请求。如果我们正在检索数据,这完全没问题。在我们的下一个路由中,我们将使用 POST 以便我们可以向服务器发送大量数据。
创建一个创建用户对象的路由
当我们创建一个创建用户对象的路由时,我们需要处理来自客户端的 POST 数据。Express 提供了一个快捷方法 app.post,它处理与提供的模式匹配的 POST 请求。我们可以在我们的服务器应用程序中添加以下内容,如 列表 7.15 所示。变化以粗体显示:
列表 7.15. 创建一个创建用户对象的路由—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// -------------BEGIN SERVERCONFIGURATION ---------------
...
app.get( '/user/list', function ( request,response ){
response.contentType( 'json');
response.send({title: 'userlist' });
});
app.post( '/user/create', function ( request, response ) {
response.contentType( 'json' );
response.send({ title: 'user created' });
});
// --------------END SERVER CONFIGURATION----------------
...
我们还没有对提交的数据做任何处理;我们将在下一章中介绍。如果我们用浏览器导航到 http://localhost:3000/user/create,我们会看到一个 404 错误和消息 Cannot GET /user/create。这是因为浏览器正在发送一个 GET 请求,而这个路由只处理 POST 请求。相反,我们可以使用命令行来创建一个用户:
curl http://localhost:3000/user/create -d {}
服务器应该响应:
{"title":"User created"}
CURL 和 WGET
如果你使用的是 Mac 或 Linux 机器,你可以使用 curl 来测试你的 API 并跳过浏览器。我们可以通过向 user/create 发送 POST 来测试我们刚刚创建的 URL:
curl http://localhost:3000/user/create -d {}
{"title":"User created"}
-d 用于发送数据,而空对象字面量不会发送任何数据。与其打开浏览器来测试一个路由,使用 curl 可以显著加快你的开发时间。要了解更多关于 curl 的功能,请在命令提示符下输入 curl -h。
使用 wget 也可以得到类似的结果:
wget http://localhost:3000/user/create --post-data='{}' -O -
要了解更多关于 wget 的功能,请在命令提示符下输入 wget -h。
现在我们有一个创建用户对象的路由,我们想要创建一个读取用户对象的路由。
创建一个读取用户对象的路由
读取用户对象的路由与创建路由类似,但使用 GET 方法,并通过 URL 传递一个额外的参数:用户的 ID。此路由是通过在路由路径中使用冒号来定义参数创建的,如 列表 7.16 所示。变化以粗体显示:
列表 7.16. 创建一个读取用户对象的路由—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// ------------- BEGIN SERVERCONFIGURATION ---------------
...
app.post( '/user/create', function ( request, response) {
response.contentType( 'json');
response.send({title: 'usercreated' });
});
app.get( '/user/read/:id', function ( request, response ) {
response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' found'
});
});
// -------------- END SERVER CONFIGURATION ----------------
...
路由末尾的用户 :id 参数可以通过 request.params 对象访问。/user/read/:id 路由使得用户 ID 可在 request.params[id] 或 request.params.id 处访问。如果请求的 URL 是 http://localhost:3000/user/read/12,那么 request.params.id 的值将是 12。试一试,并注意这个路由无论 id 的值是什么都有效——只要有一个有效的值就可以接受。更多示例见 表 7.1。
表 7.1. 路由及其结果
| 在浏览器中尝试这些 | Node.js 终端中的输出 |
|---|---|
| /user/read/19 | |
| /user/read/spa | |
| /user/read/ | 无法获取 /user/read/ |
| /user/read/? | 无法获取 /user/read/? |
虽然捕获任何路由都很好,但如果我们的 ID 始终是数字呢?我们不希望路由器拦截 ID 不是数字的路径。Express 通过在路由定义中添加正则表达式模式[(0-9)]+,提供了仅接受包含数字的路由的能力,如列表 7.17 所示。变化以粗体显示:
列表 7.17. 将路由限制为仅接受数字 ID—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
...
app.get( '/user/read/:id([0-9]+)', function ( request, response ) {
response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' found'
});
});
// -------------- END SERVER CONFIGURATION ----------------
...
表 7.2 显示,路由现在将仅接受数字 ID。
表 7.2. 路由及其结果
| 在浏览器中尝试这些操作 | 结果 |
|---|---|
| /user/read/19 | |
| /user/read/spa | 无法获取 /user/read/spa |
创建更新或删除用户的路由
目前更新和删除用户的路由与读取用户的路由几乎相同,尽管在下一章中它们对用户对象执行的操作将大不相同。我们在列表 5.18 中添加了更新和删除用户的路由。变化以粗体显示:
列表 7.18. 定义 CRUD 路由—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
...
app.get( '/user/read/:id([0-9]+)', function ( request, response ) {
response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' found'
});
});
app.post( '/user/update/:id([0-9]+)',
function ( request, response ) {
response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' updated'
});
}
);
app.get( '/user/delete/:id([0-9]+)',
function ( request, response ) {
response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' deleted'
});
}
);
// -------------- END SERVER CONFIGURATION ----------------
...
创建这些基本路由很简单,但你可能已经注意到,我们必须为每个响应设置contentType。这很容易出错且效率低下——更好的方法是我们能够为所有这些用户 CRUD 操作的响应设置contentType。理想情况下,我们希望创建一个拦截所有传入用户路由并设置响应contentType为json的路由。有两个问题阻碍了我们:
-
一些请求使用
GET方法,而另一些使用POST。 -
在设置响应的
contentType之后,我们希望路由器像以前一样工作。
幸运的是,Express 再次发挥了作用。除了app.get和app.post方法外,还有一个app.all方法可以拦截任何类型的路由。Express 还允许我们通过在路由回调方法中设置和调用第三个参数来将控制权交回给路由器,以查看是否有其他路由与请求匹配。按照惯例,第三个参数被称为next,它立即将控制权传递给下一个中间件或路由。我们在列表 7.19 中添加了app.all方法。变化以粗体显示:
列表 7.19. 使用app.all()设置常用属性—webapp/app.js
/*
* app.js - Express server with advanced routing
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
...
// all configurations below are for routes
app.get( '/', function ( request, response ) {
response.redirect( '/spa.html' );
});
app.all( '/user/*?', function ( request, response, next ) {
response.contentType( 'json' );
next();
});
app.get( '/user/list', function ( request, response ) {
// REMOVE response.contentType( 'json' );
response.send({ title: 'user list' });
});
app.post( '/user/create', function ( request, response ) {
// REMOVE response.contentType( 'json' );
response.send({ title: 'user created' });
});
app.get( '/user/read/:id([0-9]+)',
function ( request, response ) {
// REMOVE response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' found'
});
}
);
app.post( '/user/update/:id([0-9]+)',
function ( request, response ) {
// REMOVE response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' updated'
});
}
);
app.get( '/user/delete/:id([0-9]+)',
function ( request, response ) {
// REMOVE response.contentType( 'json' );
response.send({
title: 'user with id ' + request.params.id + ' deleted'
});
}
);
// -------------- END SERVER CONFIGURATION ----------------
...
在路由模式/user/*?中,*将匹配任何内容,而?使其成为可选的。/user/*?将匹配以下任何路由:
-
/user -
/user/ -
/user/12 -
/user/spa -
/user/create -
/user/delete/12
现在我们已经设置了用户路由,很容易想象随着我们添加对象类型,路由的数量会激增。我们真的需要为每种对象类型定义五个新路由吗?幸运的是,不需要。我们可以使这些路由通用,并将它们放在自己的模块中。
7.3.2. 通用 CRUD 路由
我们已经知道我们可以使用路由参数来接受来自客户端的参数,但我们也可以使用它们来使我们的路由通用。我们只需要告诉 Express 使用 URI 的一部分作为参数。这将解决问题:
app.get( '/:obj_type/read/:id([0-9]+)',
function ( request, response) {
response.send({
title: request.params.obj_type + ' with id '
+ request.params.id + 'found'
});
}
);
现在我们请求 /horse/read/12 时,将在请求参数 request.params.obj_type 中获取对象类型(horse),响应 JSON 将是 { title: "horse with id 12 found" }。将此逻辑应用于我们的其他方法,我们最终得到 列表 7.20 中的代码。所有更改都以 粗体 显示。
列表 7.20. 完整的通用 CRUD 路由—webapp/app.js
/*
* app.js - Express server with generic routing
*/
...
// ------------- BEGIN SERVER CONFIGURATION ---------------
...
// all configurations below are for routes
app.get( '/', function ( request, response ) {
response.redirect( '/spa.html' );
});
app.all( '/:obj_type/*?', function ( request, response, next ) {
response.contentType( 'json' );
next();
});
app.get( '/:obj_type/list', function ( request, response ) {
response.send({ title: request.params.obj_type + ' list' });
});
app.post( '/:obj_type/create', function ( request, response ) {
response.send({ title: request.params.obj_type + ' created' });
});
app.get( '/:obj_type/read/:id([0-9]+)',
function ( request, response ) {
response.send({
title: request.params.obj_type
+ ' with id ' + request.params.id + ' found'
});
}
);
app.post( '/:obj_type/update/:id([0-9]+)',
function ( request, response) {
response.send({
title: request.params.obj_type
+ ' with id ' + request.params.id + ' updated'
});
}
);
app.get( '/:obj_type/delete/:id([0-9]+)',
function ( request, response) {
response.send({
title: request.params.obj_type
+ ' with id ' + request.params.id + ' deleted'
});
}
);
// --------------END SERVER CONFIGURATION----------------
...
现在我们启动应用程序(node app.js)并将浏览器指向 http://localhost:3000,我们将看到我们熟悉的 SPA,如图 图 7.1 所示:
图 7.1. 浏览器中的我们的 SPA——http://localhost:3000

这表明我们的静态文件配置允许浏览器读取所有的 HTML、JavaScript 和 CSS 文件。但我们仍然可以访问我们的 CRUD API。如果我们将浏览器指向 http://localhost:3000/user/read/12,我们应该看到类似的内容:
{
title: "user with id 12 found"
}
如果我们在 <root_directory>/user/read/12 有一个文件(别笑,你知道这种情况会发生),我们的情况下,将返回文件而不是 CRUD 响应。这是因为 express.static 中间件被添加到路由器之前,如下所示:
...
app.configure( function () {
app.use( express.bodyParser() );
app.use( express.methodOverride() );
app.use( express.static( __dirname + '/public' ) );
app.use( app.router );
});
...
如果我们反转了顺序,将路由器放在前面,将返回 CRUD 响应而不是静态文件。这种安排的好处可能是对 CRUD 请求的更快响应;缺点是文件访问更慢且更复杂。聪明的做法是将所有 CRUD 请求放在单个根名称下,例如 /api/1.0.0/,这样动态内容和静态内容就可以整洁地分离。
现在我们已经拥有了管理任何对象类型的干净、通用的路由器的基础。显然,这并没有考虑到授权问题,但我们会稍后讨论这个逻辑。首先,让我们先将所有路由逻辑移动到一个单独的模块中。
7.3.3. 将路由放置在单独的 Node.js 模块中
将所有路由定义在主 app.js 文件中,就像在 HTML 页面中编写客户端 JavaScript 一样——它会弄乱您的应用程序,并且没有保持职责的清晰分离。让我们首先更仔细地看看 Node.js 模块系统,这是 Node.js 包括模块化代码的方式。
Node 模块
Node 模块是通过 require 函数加载的。
var spa = require( './routes' );
传递给 require 的字符串指定要加载的文件的路径。需要记住一些不同的语法规则,所以请耐心。为了您的方便,这些内容在 表 7.3 中描述。
表 7.3. Node 对 require 的搜索路径逻辑
| 语法 | 搜索路径,按优先级顺序 |
|---|
|
require(
'./routes.js'
);
require(
'./routes'
);
require(
'../routes.js'
);
require(
'routes'
);
|
app/routes.js
app/routes.js
app/routes.json
app/routes.node
../routes.js
app/node_modules/routes.js
app/node_modules/routes/index.js
<system_install>/node_modules/routes.js
<system_install>/node_modules/routes/index.js
|
| 此语法也用于引用核心 node.js 模块,例如 http 模块。 |
|---|
在一个 node 模块内部,使用 var 声明的变量被限制在模块内部,不需要一个自执行的匿名函数来保持变量不在全局作用域中,就像客户端所需要的那样。相反,有一个 module 对象。分配给 module.exports 属性的值作为 require 方法的返回值。让我们创建一个路由模块,如 列表 7.21 所示:
列表 7.21. 创建路由模块—webapp/routes.js
module.exports = function () {
console.log( 'You have included the routes module.' );
};
module.exports 的值可以是任何数据类型,如函数、对象、数组、字符串、数字或布尔值。在这种情况下,routes.js 将 module.exports 的值设置为匿名函数。让我们在 app.js 中 require routes.js 并将返回值存储在 routes 变量中。然后我们可以像 列表 7.22 所示那样调用返回的函数。更改以粗体显示:
列表 7.22. 包含一个模块并使用返回值—webapp/app.js
/*
* app.js - Express server with sample module
*/
...
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
http = require( 'http' ),
express = require( 'express' ),
routes = require( './routes' ),
app = express(),
server = http.createServer( app );
routes();
// ------------- END MODULE SCOPE VARIABLES ---------------
...
当我们在命令提示符中键入 node app.js 时,我们应该看到以下内容:
You have included the routes module.
Express server listening on port 3000 in development mode
现在我们已经添加了我们的路由模块,让我们将我们的路由器配置移动到它那里。
将路由移动到模块中
当我们创建一个非平凡的应用程序时,我们喜欢在主应用程序文件夹中定义我们的路由到一个单独的文件。在一个具有大量路由的大应用中,我们可以在一个路由文件夹中定义它们,文件夹中有我们需要的文件数量。
由于我们的下一个应用程序将不是平凡的,让我们在根 spa 目录中创建一个名为 routes.js 的文件,并将现有的路由复制到 module.exports 函数中。它应该看起来像 列表 7.23。
列表 7.23. 将路由放置在单独的模块中—webapp/routes.js
![ch07list23-0.jpg]
![ch07list23-1.jpg]
现在我们可以调整 webapp/app.js 以使用路由模块,如 列表 7.24 所示。更改以粗体显示。
列表 7.24. 更新服务器应用程序以使用外部路由—webapp/app.js
![ch07list24-0.jpg]
![ch07list24-1.jpg]
这使得我们的 app.js 变得相当干净:它加载所需的库模块,创建我们的 Express 应用程序,配置我们的中间件,添加我们的路由,并启动服务器。它没有做的是通过执行任何请求的操作将数据持久化到数据库中。我们将在设置 MongoDB 并将其连接到我们的 Node.js 应用程序之后,在下一章中设置它。在此之前,让我们先看看我们可能需要的一些其他事情。
7.4. 添加认证和授权
现在我们已经为在对象上执行 CRUD 操作创建了路由,我们应该添加认证。我们可以通过艰难的方式自己编写代码,或者通过简单的方式利用另一个 Express 中间件。嗯。想想...想想,选择哪一个?
7.4.1. 基本认证
基本身份验证是 HTTP/1.0 和 1.1 标准中客户端在请求时提供用户名和密码的方式;通常被称为 基本认证。记住,中间件是按照添加到应用程序的顺序调用的,所以如果你想使应用程序授权访问路由,中间件需要在路由中间件之前添加。这就像 列表 7.25 中所示的那样容易完成。更改以 粗体 表示:
列表 7.25. 将基本认证添加到我们的服务器应用程序——webapp/app.js
/*
* app.js - Express server with basic auth
*/
...
// -------------BEGIN SERVERCONFIGURATION ---------------
app.configure( function () {
app.use( express.bodyParser() );
app.use( express.methodOverride() );
app.use( express.basicAuth( 'user', 'spa' ) );
app.use( express.static( __dirname + '/public' ) );
app.use( app.router );
});
...
在这个例子中,我们硬编码了应用程序,使其期望用户为 user,密码为 spa。basicAuth 也接受一个函数作为第三个参数,该函数可以用来提供更高级的机制,例如在数据库中查找用户详细信息。该函数应该返回 true 如果用户有效,当用户无效时返回 false。当我们重新启动服务器并重新加载浏览器时,它应该打开一个类似于 图 7.2 的警告对话框,在允许访问之前要求有效的用户名和密码。
图 7.2. Chrome 的身份验证对话框

如果我们输入错误的密码,它会持续提示,直到我们输入正确。按下取消按钮将带我们到一个显示“未经授权”的页面。
基本身份验证不建议在生产应用程序中使用。它以纯文本形式发送每个请求的凭据——安全专家称这为 大攻击向量。即使我们使用 SSL(HTTPS)加密传输,我们也只有一层安全在客户端和服务器之间。
自己构建身份验证机制现在正变得过时。许多初创公司甚至更大、更成熟的公司都在使用来自 Facebook 或 Google 等第三方身份验证服务。有许多在线教程展示了如何集成这些服务;Node.js 中间件 Passport 可以帮助你入门。
7.5. Web sockets 和 Socket.IO
Web sockets 是一种令人兴奋的技术,它正在获得广泛的浏览器支持。Web sockets 允许客户端和服务器通过单个 TCP 连接保持持久、轻量级和双向的通信通道。这使得客户端或服务器能够在没有 HTTP 请求-响应周期开销和延迟的情况下实时推送消息。在 Web sockets 之前,开发者采用了替代但效率较低的技巧来提供类似的功能。这些技术包括使用 Flash sockets;长轮询,其中浏览器向服务器打开一个请求,并在有响应或请求超时时重新初始化请求;以及服务器在很短的间隔内轮询(例如,每秒一次)。
Web 服务器的问题在于规范尚未最终确定,旧浏览器永远不会支持它。Socket.IO 是一个 Node.js 模块,它优雅地解决了后一个问题,因为它在可用的情况下提供浏览器到服务器的 WebSocket 消息传递,但如果 WebSocket 不可用,它将降级使用其他技术。
7.5.1. 简单 Socket.IO
让我们创建一个简单的 Socket.IO 应用程序,该应用程序每秒更新服务器上的计数并将当前计数推送到已连接的客户端。我们可以通过更新我们的 package.json 来安装 Socket.IO,如 列表 7.26 所示。更改以粗体显示:
列表 7.26. 安装 Socket.IO—webapp/package.json
{
"name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies" :{
"express" :"3.2.x",
"socket.io" : "0.9.x"
}
}
现在,我们可以运行 npm install 来确保 Express 和 Socket.IO 都已安装。
让我们添加两个文件,一个名为 webapp/socket.js 的服务器应用程序和一个名为 webapp/socket.html 的浏览器文档。让我们首先构建一个可以提供静态文件并且有一个每秒递增一次的计时器的服务器应用程序。既然我们知道我们将使用 Socket.IO,我们将包括该库。列表 7.27 显示了我们的新 socket.js 服务器应用程序:
列表 7.27. 开始服务器应用程序—webapp/socket.js


当我们启动服务器—node socket.js—我们在终端看到它正在记录一个不断递增的数字。现在,让我们创建 webapp/socket.html 中所示的 [webapp/socket.html](http://webapp/socket.html) 来显示这个数字。我们将包括 jQuery,因为它使获取 body 标签变得简单:
列表 7.28. 创建浏览器文档—webapp/socket.html
<!doctype html>
<!--socket.html- simple socket example -->
<html>
<head>
<script type="text/javascript"
src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"
></script>
</head>
<body>
Loading...
</body>
</html>
我们现在应该能够加载 http://localhost:3000 并看到几乎空白的页面。让 Socket.IO 向客户端发送此信息只需要在我们的服务器应用程序中添加两行,如 列表 7.29 所示。更改以粗体显示:
列表 7.29. 将 WebSocket 添加到服务器应用程序—webapp/socket.js

浏览器文档只需要额外的六行来启用 Socket.IO,如 列表 7.30 所示。更改以粗体显示:
列表 7.30. 将 WebSocket 添加到浏览器文档—webapp/socket.html
<!doctype html>
<!-- socket.html - simple socket example -->
<html>
<head>
<script type="text/javascript"
src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"
></script>
<script src="/socket.io/socket.io.js"></script>
<script>
io.connect().on('message', function ( count ) {
$('body').html( count );
});
</script>
</head>
<body>
Loading...
</body>
</html>
JavaScript 文件 /socket.io/socket.io.js 由 Socket.IO 安装提供,因此无需创建一个;它也是一个“神奇”的文件,实际上并不存在于服务器上,所以不要去寻找它。io.connect() 返回一个 Socket.IO 连接,而 on 方法与 jQuery 中的 bind 方法类似,它告诉它监视某种 Socket.IO 事件。在这种情况下,我们正在寻找的事件是通过连接发送的任何消息。然后我们使用 jQuery 更新 body 以新的计数。你是在服务器上寻找 socket.io.js 文件,对吧?
如果我们在浏览器中打开 http://localhost:3000/,我们应该看到计数器在增加。当我们打开另一个标签到同一位置时,我们应该看到另一个计数器以相同的数量和速率增加,因为 countIdx 是服务器应用程序的模块作用域变量。
7.5.2. Socket.IO 和信息服务器
当我们使用 Socket.IO 在客户端和服务器之间路由消息时,我们正在创建一个信息服务器。另一个信息服务器的例子是 Openfire,它使用 XMPP 协议来提供消息,这是 Google Chat 和 Jabber 所使用的协议。信息服务器必须维护与所有客户端的连接,以便它们可以快速接收和响应消息。它们还应该通过避免不必要的数据来最小化消息的大小。
传统的网络服务器,如 Apache2,是糟糕的信息服务器,因为它们为每个连接创建和分配一个进程(或线程),并且每个进程必须在其连接持续期间存在。正如你可能猜到的,在几百或几千个连接之后,网络服务器将耗尽所有用于服务连接的进程所使用的资源。Apache2 从未为此而设计;它被编写为一个内容服务器,其理念是尽可能快地响应请求推送数据,然后尽可能快地关闭连接。对于这些类型的用途,Apache2 是一个很好的选择——只需问问 YouTube。
与之相比,Node.js 是一个优秀的信息服务器。多亏了其事件模型,它不会为每个连接创建一个进程。相反,它在连接打开或关闭时做一些记账,并在之间进行一些维护。因此,它可以在普通的硬件上处理成千上万的并发连接。Node.js 不会在其打开的连接之一或多个上发生信息事件——如请求或响应——之前做任何显著的工作。
Node.js 可以处理的消息客户端数量取决于服务器遇到的实际工作量。如果客户端相对安静且服务器任务轻量,服务器可以处理很多客户端。如果客户端很健谈且服务器任务更重,服务器可以处理的客户端就少得多。在一个高流量环境中,负载均衡器可能会在提供消息的 Node.js 服务器集群、提供动态网页内容的另一个 Node.js 服务器集群以及提供静态内容的 Apache2 服务器集群之间路由流量。
使用 Node.js 而不是 XMPP 等其他消息协议的好处有很多。这里只列举几个:
-
Socket.IO 使得在 Web 应用中进行跨浏览器消息传递几乎变得微不足道。我们之前曾为生产应用程序使用过 XMPP。相信我们:仅为了软件,它就要复杂得多。
-
我们可以避免维护一个单独的服务器和配置。这又是一个巨大的优势。
-
我们可以使用原生的 JSON 协议而不是不同的语言。XMPP 是 XML,需要复杂的软件来编码和解码。
-
我们不必担心(至少最初)那些困扰其他消息平台的“同源”策略。这个浏览器策略阻止内容加载到浏览器中,如果它不是来自使用它的 JavaScript 相同的服务器。
现在我们来看一个一定会给人留下深刻印象的 Socket.IO 用法:动态更新我们的 SPA。
7.5.3. 使用 Socket.IO 更新 JavaScript
使用单页应用程序(SPA)的一个挑战是确保客户端软件与服务器应用程序匹配。想象一下,如果鲍比在我们的浏览器中加载了我们的 SPA,五分钟后我们更新了我们的服务器应用程序。现在鲍比遇到了问题,因为我们的更新后的服务器使用了一种新的数据格式,而鲍比的 SPA 仍然期望使用旧的数据格式。解决这种情况的一种方法是在我们发送消息宣布服务器更新后,强制鲍比重新加载整个 SPA——比如说,我们发送了消息后。但我们还可以做得更复杂——我们可以在 SPA 中仅更新已更改的 JavaScript,而不必强制整个应用程序重新加载。
那么,我们如何进行这种神奇的更新?需要考虑三个部分:
-
监视 JavaScript 文件以检测它们何时被修改。
-
通知客户端文件已更新。
-
当通知客户端更改时更新客户端侧的 JavaScript。
第一部分,检测文件何时被修改,可以使用本机 node 文件系统模块fs完成。第二部分是向浏览器发送 Socket.IO 通知,如前所述,通过接收通知时注入新的 script 标签来更新客户端。我们可以从上一个示例中更新我们的服务器应用程序,如列表 7.31 所示。更改以粗体显示:
列表 7.31. 更新服务器应用程序以监视文件—webapp/socket.js


现在我们已经准备好了服务器应用程序,让我们看看客户端,从我们将要更新的 JavaScript 文件开始,然后是索引页面。我们的数据文件,webapp/js/data.js,包含一行将一些文本赋值给变量的代码,如列表 7.32 所示:
列表 7.32. 创建数据文件—webapp/js/data.js
var b = 'SPA';
我们浏览器文档的更改需要更实质一些,如列表 7.33 所示。更改以粗体显示:
列表 7.33. 更新浏览器文档—webapp/socket.html

现在,我们可以让魔法发生。首先,让我们启动我们的服务器应用程序(在命令行中输入node socket.js)。接下来,让我们打开我们的浏览器文档(webapp/socket.html)。我们应该在我们的浏览器主体中看到SPA。然后,让我们编辑 webapp/js/ data.js 文件,将SPA的值更改为生活的意义是一个芜菁或一些其他同样简洁的评论。当我们返回浏览器时,我们应该看到显示从SPA更改为上述简洁评论的变化(无需重新加载浏览器)。可能会有几秒钟的延迟,因为watchFile命令可能需要这么长时间才能注意到文件变化.^([1])
¹ 在生产环境中,我们通常希望将文件轮询(
fstats)保持在最低限度,因为它可能会严重影响性能。fileWatch方法可以设置选项,以便更频繁地轮询文件。例如,我们可能每 30,000 毫秒(30 秒)轮询一次,而不是默认的 0(我们只能假设这意味着“检查非常、非常频繁”)。
7.6. 概述
在本章中,我们看到了尽管 SPA 的大部分逻辑已经转移到客户端,但服务器仍然负责身份验证、数据验证和数据存储。我们已经设置了一个 Node.js 服务器,并使用 Connect 和 Express 中间件使路由、日志和身份验证变得更容易。
将路由和配置逻辑分离到不同的文件中使其更容易理解,Express 为我们提供了定义不同环境的不同配置的能力。Express 为我们提供了创建适用于所有对象类型的 CRUD 路由的工具。
我们还没有解决如何验证和存储数据的问题——这将在下一章中解决,当我们将应用程序和数据结合在一起时。
第八章. 服务器数据库
本章涵盖
-
在 SPA 中数据库的作用
-
使用 JavaScript 作为 MongoDB 的数据库语言
-
理解 Node.js MongoDB 驱动程序
-
实现 CRUD 操作
-
使用 JSV 进行数据验证
-
使用 Socket.IO 推送数据更改到客户端
本章基于我们在第七章中编写的代码。我们建议将那个章节的整个目录结构复制到一个新的“chapter_8”目录中,并更新那里的文件。
在本章中,我们将数据库添加到我们的 SPA 中,以实现持久数据存储。这完成了我们使用 JavaScript 端到端——在数据库、服务器和浏览器上的愿景。当我们完成时,我们将能够启动我们的 Node.js 服务器应用程序,并邀请我们的朋友使用他们的电脑或触摸设备登录 SPA。他们可以互相聊天或更改每个人都可以在近实时看到的人物形象。让我们通过更仔细地研究数据库的作用来开始。
8.1. 数据库的作用
我们使用数据库服务器来提供数据的可靠、持久存储。我们依赖服务器来扮演这个角色,因为客户端存储的数据是短暂的,容易受到应用程序错误、用户错误和用户篡改的影响。客户端数据也难以进行点对点共享,并且仅在客户端在线时可用。
8.1.1. 选择数据存储
在选择服务器存储解决方案时,我们有许多选项需要考虑:关系数据库、键值存储和 NoSQL 数据库等。但最佳选择是什么?就像生活中的许多问题一样,答案是“这取决于。”我们曾与使用这些解决方案中的许多用于不同目的的 Web 应用程序合作过。许多人已经撰写了大量关于各种数据存储优点的内容,例如关系数据库(如 MySQL)、键值存储(如 memcached)、图数据库(如 Neo4J)或文档数据库(如 Cassandra 或 MongoDB)。尽管作者们倾向于保持中立,认为这些都有其位置,但这些解决方案相对优缺点的讨论超出了本书的范围。
让我们设想我们已经创建了一个作为文字处理器的 SPA。我们可能会使用循环冗余文件系统数据存储来存储大量文件,但使用 MySQL 数据库进行索引。此外,我们可能将身份验证对象存储在 MongoDB 中。在任何情况下,用户几乎肯定会期望将他们的文档保存到服务器上进行长期存储。有时用户可能希望从本地磁盘上的文件读取或保存,我们几乎肯定应该提供这个选项。但随着网络、远程存储的价值和可靠性以及可访问性的持续提高,本地存储的使用案例正在不断减少。
我们选择 MongoDB 作为我们的数据存储,原因有很多:它已被证明是可靠的,它具有可扩展性,性能良好,并且——与一些其他 NoSQL 选项不同——它定位为通用数据库。我们发现它非常适合 SPA,因为它使我们能够从 SPA 的一端到另一端使用 JavaScript 和 JSON。它的命令行界面使用 JavaScript 作为其查询语言,因此我们可以在探索数据库的同时轻松测试 JavaScript 结构,或者使用与我们服务器或浏览器环境完全相同的表达式来操作数据。它使用 JSON 作为其存储格式,其数据管理工具是为 JSON 量身定制的。
8.1.2. 消除数据转换
考虑一下用 MySQL/Ruby on Rails(或 mod_perl、PHP、ASP、Java 或 Python)和 JavaScript 编写的传统网络应用程序:开发者必须编写代码将 SQL -> Active Record -> JSON 在客户端的路径上转换,然后在返回的路径上将 JSON -> Active Record -> SQL 转换(见图 8.1)。这涉及到三种语言(SQL、Ruby、JavaScript)、三种数据格式(SQL、Active Record、JSON)和四种数据转换。在最坏的情况下,这会浪费大量的服务器资源,这些资源本可以用于其他更好的用途。在最坏的情况下,每个转换都提供了引入错误的机会,并且可能需要大量的努力来实现和维护。
图 8.1. 网络应用程序中的数据转换

我们使用 MongoDB、Node.js 和原生 JavaScript SPA,因此我们的数据映射如下:在客户端的路径上 JSON -> JSON -> JSON,然后在返回的路径上 JSON -> JSON -> JSON(见图 8.2)。我们使用一种语言(JavaScript)、一种数据格式(JSON)和没有数据转换。这给曾经复杂系统带来了强大的简单性。
图 8.2. 使用 MongoDB、Node.js 和 SPA,无需数据转换

这种设置的简单性也使我们能够在决定放置应用程序逻辑的位置时更加灵活。
8.1.3. 将逻辑移动到需要的位置
在我们的传统网络应用程序示例中,考虑一下我们如何选择放置某些应用程序逻辑的位置。也许我们应该将其放在存储的 SQL 存储过程中?或者也许我们应该将逻辑嵌入到服务器应用程序中?也许我们应该将逻辑放在客户端?如果我们需要从一个层移动到另一个层,通常需要付出大量的努力,因为层使用不同的语言和数据格式。换句话说,犯错误通常代价高昂(例如,想象一下将逻辑从 Java 重写到 JavaScript)。这导致了妥协的“安全”选择,限制了应用程序的能力。
使用单一语言和数据格式大大减少了改变主意的成本。这使我们能够在开发过程中更加富有创新性,因为犯错误的成本极低。如果我们需要将一些逻辑从服务器移动到客户端,我们可以使用相同的 JavaScript,几乎不需要或不需要修改。
现在,让我们更深入地了解我们选择的数据库,MongoDB。
8.2. MongoDB 简介
根据 MongoDB 网站,MongoDB 是“一个可扩展、高性能的开源 NoSQL 数据库”,使用基于文档的存储和动态模式,提供“简单和强大”。让我们一步步了解这意味着什么:
-
可扩展,高性能—MongoDB 被设计为水平扩展,使用成本较低的服务器。在使用关系型数据库的情况下,唯一容易扩展数据库的方法就是购买更好的硬件。1 使用 MongoDB,您可以轻松地添加另一台服务器以提供更多容量或性能。
¹ 是的,你可以创建关系型数据库集群和副本,但它们通常需要相当多的专业知识来配置和维护。购买更快的服务器要容易得多。
-
文档型存储—MongoDB 以 JSON 文档格式存储数据,而不是以列和行组成的表。文档,大致相当于 SQL 行,存储在集合中,集合类似于 SQL 表。
-
动态模式—与关系型数据库需要模式来定义可以在哪些表中存储哪些数据不同,MongoDB 不需要。你可以在集合中存储任何 JSON 文档。同一集合中的单个文档可以具有完全不同的结构,文档结构在文档更新期间也可能完全改变。
关于性能的第一个要点将吸引每个人,尤其是运营经理。接下来的两个要点对 SPA 开发者特别有趣,值得详细探讨。如果你已经熟悉 MongoDB,可以直接跳到第 8.3 节,在那里我们将它与我们的 Node.js 应用程序连接起来。
8.2.1. 文档型存储
MongoDB 以 JSON 文档的形式存储数据,这使得它非常适合大多数 SPA。我们的 SPA 中的 JSON 文档可以存储和检索,无需转换.^([2]) 这很有吸引力,因为我们不必花费开发或处理时间在原生格式之间转换数据。当我们发现客户端数据中的问题时,检查它是否在数据库中找到非常简单,因为格式是相同的。
² 将此与关系型数据库进行比较,在关系型数据库中,我们首先必须将其转换为 SQL 以存储文档,然后在检索时将其转换回 JSON。
这不仅使开发和应用程序更简单,还提供了性能优势。服务器不必在格式之间转换数据,而是直接传递。这对托管和扩展应用程序的成本也有影响,因为服务器需要执行的工作更少。在这种情况下,工作并没有转移到客户端;它只是因为单一的数据格式而“消失”。这并不一定意味着 Node.js + MongoDB 比 Java + PostgreSQL 更快——许多其他因素会影响应用程序的整体速度,但它在其他条件相同的情况下,单一数据格式应该提供更好的性能。
8.2.2. 动态文档结构
MongoDB 不限制文档的结构。我们不需要定义结构,只需开始向集合中添加文档即可。我们甚至不需要先创建一个集合——向不存在的集合中插入数据会创建它。将此与关系型数据库进行比较,在关系型数据库中,你必须明确定义表和模式,任何数据结构的变化都需要对模式进行更改。拥有一个不需要模式的数据库有一些有趣的好处:
-
文档结构灵活。 MongoDB 将存储文档,无论其结构如何。如果文档结构频繁更改或无结构,MongoDB 将无需调整即可存储它们。
-
应用程序更改通常不需要数据库更改。 当我们更新文档以包含新的或不同的属性时,我们可以部署应用程序,它将立即开始存储新的文档结构。另一方面,我们可能需要调整代码以处理之前保存的文档中不存在的文档属性。
-
没有模式更改会导致停机和服务延迟。 我们不必锁定数据库的部分来适应文档结构更改。但就像之前一样,我们可能需要调整我们的应用程序。
-
不需要对模式设计有专业知识。 无模式意味着有一个完整的知识领域,不需要掌握这些知识就可以构建应用程序。这意味着应用程序对通才来说更容易构建,并且可能需要更少的规划才能启动。
但没有模式也有一些缺点:
-
没有文档结构强制。 数据库级别不强制执行文档结构,对它们结构的任何更改都不会自动传播到现有文档。当多个应用程序使用相同的集合时,这可能会特别痛苦。
-
没有文档结构定义。 数据库中没有地方让数据库工程师或应用程序确定数据应该具有的结构。通过检查文档来确定集合的目的更困难,因为没有保证结构在文档之间是相同的。
-
定义不明确。 文档数据库在数学上没有明确定义。在关系数据库中存储数据时,通常有一些经过数学证明的最佳实践,以使数据访问尽可能灵活和快速。对于 MongoDB,优化并没有那么明确,尽管一些传统方法,如创建索引,是支持的。
现在我们已经对 MongoDB 存储数据的方式有了感觉,让我们开始使用它。
8.2.3. 从 MongoDB 开始
一种开始使用 MongoDB 的好方法是安装它,然后使用 MongoDB shell 与集合和文档交互。首先,让我们从 MongoDB 网站www.mongodb.org/downloads安装 MongoDB,然后启动 mongodb 进程。启动程序因操作系统而异,请参阅文档以获取详细信息(docs.mongodb.org/manual/tutorial/manage-mongodb-processes/)。一旦我们启动了数据库,让我们打开一个终端,通过输入 mongo (mongo.exe 在 Windows 上) 来启动 shell。你应该会看到类似以下内容:
MongoDB shell version: 2.4.3
connecting to: test
>
在与 MongoDB 交互时,一个重要的概念是您不需要手动创建数据库或集合:它们在需要时自动创建。为了“创建”一个新的数据库,发出使用该数据库的命令。为了“创建”一个集合,向该集合中插入一个文档。如果您在查询中引用了一个不存在的集合,查询不会失败;它将表现得像该集合存在一样,但实际上只有在您插入文档时才会创建它。表 8.11 展示了一些常见操作。我们建议您使用“spa”作为 database_name 依次尝试它们。
表 8.1. 基本 MongoDB shell 命令
| 命令 | 描述 |
|---|---|
| show dbs | 显示此 MongoDB 实例中所有数据库的列表。 |
| use database_name | 将当前数据库切换到 database_name。如果数据库尚不存在,则在第一次向该数据库的集合中插入文档时创建它。 |
| db | 当前数据库。 |
| help | 获取一般帮助。db.help() 将提供关于 db 方法的帮助。 |
| db.getCollectionNames() | 获取当前数据库中所有集合的列表。 |
| db.collection_name | 当前数据库中的一个集合。 |
| db.collection_name.insert({ ‘name': ‘Josh Powell' }) | 将字段 name 值为“Josh Powell”的文档插入到 collection_name 集合中。 |
| db.collection_name.find() | 返回 collection_name 集合中的所有文档。 |
| db.collection_name.find({ ‘name': ‘Josh Powell' }) | 返回所有字段 name 值为“Josh Powell”的 collection_name 集合中的文档。 |
| db.collection_name.update({ ‘name': ‘Josh Powell' }, {'name':
‘Mr. Joshua C. Powell'}) | 查找所有名为“Josh Powell”的文档,并将它们替换为 {’name’: ‘Mr. Joshua C. Powell’ }。 |
| db.collection_name.update({ ‘name': ‘Mr. Joshua C. Powell' }, {$set: {‘job': ‘Author'} }) | 查找所有名为“Mr. Joshua C. Powell”的文档,并使用 $set 属性添加或修改提供的属性。 |
|---|---|
| db.collection_name.remove({ {‘name': ‘Mr. Joshua C. Powell'}. | 从 collection_name 集合中删除所有字段 name 值为“Mr. Joshua C. Powell”的文档。 |
| exit | 退出 MongoDB shell。 |
当然,MongoDB 的功能远不止表格中展示的那么多。例如,有方法可以对数据进行排序、返回现有字段的子集、更新文档、增加或修改属性、操作数组、添加索引等等。要深入了解 MongoDB 提供的一切,请参阅MongoDB in Action(Manning 2011)、在线 MongoDB 手册(docs.mongodb.org/manual/)或Little MongoDB Book(openmymind.net/mongodb.pdf)。我们已经运行了一些基本的 MongoDB 命令,现在让我们将应用程序连接到 MongoDB。首先,我们需要准备项目文件。
8.3. 使用 MongoDB 驱动程序
在特定语言中的应用程序需要一个数据库驱动程序来高效地与 MongoDB 交互。没有驱动程序,与 MongoDB 交互的唯一方式是通过 shell。已经为各种语言编写了多个 MongoDB 驱动程序,包括 Node.js 中的 JavaScript 驱动程序。一个好的驱动程序可以处理与数据库交互的许多底层任务,而不会打扰开发者。一些例子包括在连接丢失的情况下重新连接到数据库、管理副本集的连接、缓冲区池和游标支持。
8.3.1. 准备项目文件
在本章中,我们将在第七章中完成的工作基础上继续。我们将把第七章的整个文件结构复制到一个新的“chapter_8”目录中,在那里我们将继续我们的工作。列表 8.1 展示了我们完成复制后的文件结构。我们将要删除的文件和目录以粗体显示:
列表 8.1. 从第七章复制文件
chapter_8
`-- webapp
|-- app.js
|-- js
| `-- data.js
|-- node_modules
|-- package.json
|-- public
| |-- css/
| |-- js/
| `-- spa.html
|-- routes.js
|-- socket.html
`-- socket.js
让我们删除 js 目录、socket.html文件和 socket.js 文件。我们还应该删除 node_modules 目录,因为在模块安装过程中将重新生成该目录。我们的更新结构应该看起来像列表 8.2:
列表 8.2. 删除我们不再需要的某些文件和目录
chapter_8
`-- webapp
|-- app.js
|-- package.json
|-- public
| |-- css/
| |-- js/
| `-- spa.html
`-- routes.js
现在我们已经复制并整理了目录,我们准备将 MongoDB 附加到我们的应用程序上。我们的第一步是安装 MongoDB 驱动器。
8.3.2. 安装并连接到 MongoDB
我们发现 MongoDB 驱动器是许多应用的优秀解决方案。它简单、快速且易于理解。如果我们需要更多功能,我们可能会考虑使用对象文档映射器(ODM)。ODM 类似于常用于关系数据库的对象关系映射器(ORM)。有几个选项可供选择:Mongoskin、Mongoose和Mongolia等。
我们将使用基本的 MongoDB 驱动程序来构建我们的应用程序,因为我们的大部分关联和高级数据建模都是在客户端处理的。我们不希望有任何 ODM 验证功能,因为我们将通过通用的 JSON 模式验证器来验证我们的文档结构。我们做出这个选择是因为 JSON 模式验证器是符合标准的,并且可以在客户端和服务器上运行,而 ODM 验证目前只能在服务器上运行。
我们可以使用我们的package.json来安装 MongoDB 驱动程序。和之前一样,我们将指定模块的主版本和次版本,但请求最新的补丁版本,如列表 8.3 所示。更改以粗体显示:
列表 8.3. 更新npm install的清单—webapp/package.json
{ "name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies" : {
"express" : "3.2.x",
"mongodb" : "1.3.x",
"socket.io" : "0.9.x"
}
}
我们可以通过运行npm install来安装清单中的所有模块,包括 MongoDB 驱动程序。让我们编辑routes.js文件以包含mongodb并建立连接,如列表 8.4 所示。更改以粗体显示:
列表 8.4. 打开 MongoDB 连接—webapp/routes.js

我们也可以从我们的服务器应用程序中移除基本认证,如列表 8.5 所示。
列表 8.5. 从我们的服务器应用程序中移除基本认证—webapp/app.js

现在我们可以启动我们的服务器应用程序(在命令提示符中输入node app.js)并查看以下输出:
Express server listening on port 3000 in development mode
** Connected to MongoDB **
现在我们已经将我们的服务器应用程序连接到 MongoDB,让我们探索基本的创建-读取-更新-删除(CRUD)操作。
8.3.3. 使用 MongoDB CRUD 方法
在我们进一步更新服务器应用程序之前,我们希望熟悉 MongoDB 的 CRUD 方法。让我们打开一个终端并输入mongo来启动 MongoDB shell。然后我们可以在集合中创建一些文档(使用insert方法),如列表 8.6 所示。我们的输入以粗体显示:
列表 8.6. 在 MongoDB 中创建一些文档
> use spa;
switched to db spa
> db.user.insert({
"name" : "Mike Mikowski",
"is_online" : false,
"css_map":{"top":100,"left":120,
"background-color":"rgb(136, 255, 136)"
}
});
> db.user.insert({
"name" : "Mr. Joshua C. Powell, humble humanitarian",
"is_online": false,
"css_map":{"top":150,"left":120,
"background-color":"rgb(136, 255, 136)"
}
});
> db.user.insert({
"name": "Your name here",
"is_online": false,
"css_map":{"top":50,"left":120,
"background-color":"rgb(136, 255, 136)"
}
});
> db.user.insert({
"name": "Hapless interloper",
"is_online": false,
"css_map":{"top":0,"left":120,
"background-color":"rgb(136, 255, 136)"
}
});
我们可以通过读取这些文档来确保它们已经被正确添加(使用find方法),如列表 8.7 所示。我们的输入以粗体显示:
列表 8.7. 从 MongoDB 读取文档
> db.user.find()
{ "_id" : ObjectId("5186aae56f0001debc935c33"),
"name" : "Mike Mikowski",
"is_online" : false,
"css_map" : {
"top" : 100, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
},
{ "_id" : ObjectId("5186aaed6f0001debc935c34"),
"name" : "Mr. Josh C. Powell, humble humanitarian",
"is_online" : false,
"css_map" : {
"top" : 150, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
{ "_id" : ObjectId("5186aaf76f0001debc935c35"),
"name" : "Your name here",
"is_online" : false,
"css_map" : {
"top" : 50, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
{ "_id" : ObjectId("5186aaff6f0001debc935c36"),
"name" : "Hapless interloper",
"is_online" : false,
"css_map" : {
"top" : 0, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
注意,MongoDB 会自动为任何insert的文档添加一个唯一的 ID 字段,名为_id。嗯,尽管我们作者之一的name字段显然是正确的(尽管可能有点过于保守),但它似乎过于正式。让我们去除这种拘谨,并使用update方法来更新文档,如列表 8.8 所示。我们的输入以粗体显示:
列表 8.8. 在 MongoDB 中更新文档
> db.user.update(
{ "_id" : ObjectId("5186aaed6f0001debc935c34") },
{ $set : { "name" : "Josh Powell" } }
);
db.user.find({
"_id" : ObjectId("5186aaed6f0001debc935c34")
});
{ "_id" : ObjectId("5186aaed6f0001debc935c34"),
"name" : "Josh Powell",
"is_online" : false,
"css_map" : {
"top" : 150, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
我们不禁注意到一个 不幸的闯入者 已经进入了我们的数据库。就像一部 星际迷航 登陆小组成员中的红衫队员一样,一个不幸的闯入者不应该在场景结束时还活着。我们不愿意打破传统,所以让我们立即派遣这个闯入者,并 删除 文档(使用 remove 方法),如 列表 8.9 所示。我们的输入以 粗体 显示:
列表 8.9. 从 MongoDB 删除文档
> db.user.remove(
{ "_id" : ObjectId("5186aaff6f0001debc935c36") }
);
> db.user.find()
{ "_id" : ObjectId("5186aae56f0001debc935c33"),
"name" : "Mike Mikowski",
"is_online" : false,
"css_map" : {
"top" : 100, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
{ "_id" : ObjectId("5186aaed6f0001debc935c34"),
"name" : "Josh Powell",
"is_online" : false,
"css_map" : {
"top" : 150, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
{ "_id" : ObjectId("5186aaf76f0001debc935c35"),
"name" : "Your name here",
"is_online" : false,
"css_map" : {
"top" : 50, "left" : 120,
"background-color" : "rgb(136, 255, 136)"
}
}
我们现在已经使用 MongoDB 控制台完成了创建-读取-更新-删除操作。现在让我们更新我们的服务器应用程序以支持这些操作。
8.3.4. 将 CRUD 添加到服务器应用程序
由于我们使用 Node.js,与 MongoDB 的交互将不同于大多数其他语言,因为 JavaScript 是基于事件的。现在我们在数据库中已经有了一些文档可以操作,让我们更新我们的路由器以使用 MongoDB 获取用户对象列表,如 列表 8.10 所示。更改以 粗体 显示:
列表 8.10. 更新我们的路由器以检索用户列表—webapp/routes.js

在浏览器中查看结果之前,你可能想要安装一个浏览器扩展或插件,使 JSON 更易于阅读。我们在 Chrome 上使用 JSONView 0.0.32,在 Firefox 上使用 JSONovich 1.9.5。这两个插件都可以在各自的供应商插件网站上找到。
我们可以通过在终端中输入 node app.js 来启动我们的应用程序。当我们将浏览器指向 http://localhost:3000/user/list 时,我们应该看到一个类似于 图 8.3 的 JSON 文档展示。
图 8.3. 通过 Node.js 从 MongoDB 到客户端的响应

我们现在可以添加剩余的 CRUD 操作,如 列表 8.11 所示。更改以 粗体 显示:
列表 8.11. 将 MongoDB 驱动和 CRUD 添加到我们的路由器—routes.js


现在我们已经从客户端通过 Node.js 服务器到 MongoDB,然后再返回,用户 CRUD 操作正在工作。现在我们希望应用程序验证从客户端接收到的数据。
8.4. 验证客户端数据
MongoDB 没有定义可以添加到集合中的内容的机制。在保存之前,我们需要自己验证客户端数据。我们希望数据传输如图 8.5 所示:
图 8.4. 验证客户端数据——代码路径

图 8.5. 对象类型验证

我们的第一步是定义哪些类型的对象是有效的。
8.4.1. 验证对象类型
如现在所示,我们接受任何路由并将对象传递给 MongoDB,甚至没有验证它是否是允许的类型。例如,一个用于创建马的 POST 请求将工作。以下是一个使用 wget 的示例。我们的输入以 粗体 显示:
# Create a new MongoDB collection of horses
wget http://localhost:3000/horse/create \
--header='content-type: application/json' \
--post-data='{"css_map":{"color":"#ddd"},"name":"Ed"}'\
-O -
# Add another horse
wget http://localhost:3000/horse/create \
--header='content-type: application/json' \
--post-data='{"css_map":{"color":"#2e0"},"name":"Winney"}'\
-O -
# Check the corral
wget http://localhost:3000/horse/list -O -
[ {
"css_map": {
"color": "#ddd"
},
"name": "Ed",
"_id": "51886ac7e7f0be8d20000001"
},
{
"css_map": {
"color": "#2e0"
},
"name": "Winney",
"_id": "51886adae7f0be8d20000002"
}]
这甚至比看起来更糟糕。MongoDB 不仅会存储文档,它还会创建一个全新的集合(就像我们在示例中所做的那样),这会消耗相当多的资源。我们不能这样上线,因为一个简单的脚本小子可以在几分钟内通过运行一个创建数千个新 MongoDB 集合的脚本而轻易地压垮服务器(s)。我们应该只允许访问经过批准的对象类型,如图 8.5 所示。
³在我的 64 位开发机器上,每个几乎为空的集合大约占用 64MB 的磁盘空间。
这很容易实现。我们可以创建一个允许的对象类型的映射,然后在路由器中检查它。让我们修改 routes.js 文件来实现这一点,如图 8.12 所示。变化以粗体显示:
列表 8.12。验证传入的路由—routes.js


我们不希望仅仅确保对象类型被允许。我们还想确保客户端数据结构如我们所期望的那样。让我们接下来这么做。
8.4.2。验证对象
浏览器客户端发送一个 JSON 文档到服务器以表示一个对象。正如许多读者可能已经知道的那样,JSON 已经取代了 XML 在许多 Web API 中的应用,因为它更紧凑,通常更容易处理。
XML 提供的一个非常出色的功能是能够定义一个文档类型定义(DTD),它描述了允许的内容。JSON 有一个类似但不够成熟的类似标准,可以确保文档内容类似于 DTD。它被称为JSON schema。
JSV是一个使用 JSON schema 的验证器。它可以在浏览器和服务器上使用,所以我们不需要编写或维护两个不同的(并且总是微妙地冲突的)验证库。以下是我们需要验证我们的对象的步骤:
-
安装 JSV 节点模块
-
创建一个 JSON schema
-
加载 JSON schemas
-
创建一个验证函数
-
验证传入数据
我们的第一步是安装 JSV。
安装 Jsv Node 模块
将 package.json 文件更新为包含 JSV 4.0.2。现在它应该看起来像列表 8.13:
列表 8.13。更新清单以包含 JSV—webapp/package.json
{ "name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies" :{
"express" :"3.2.x",
"mongodb" :"1.3.x",
"socket.io" :"0.9.x",
"JSV" : "4.0.x"
}
}
当我们运行npm install时,npm应该能够检测到更改并安装 JSV。
创建一个 Json Schema
在我们能够验证用户对象之前,我们必须决定允许哪些属性以及它们可能取哪些值。JSON schema 为我们提供了一个很好的、标准的机制来描述这些约束,如图 8.14 所示。务必仔细注意注释,因为它们解释了约束。
列表 8.14。创建用户 schema—webapp/user.json


你可能已经注意到,我们定义了一个约束对象的模式,并在该对象内约束对象。这说明了 JSON 模式可以无限递归。JSON 模式也可以扩展其他模式,就像 XML 一样。如果你想了解更多关于 JSON 模式的信息,请查看官方网站 jsonschema.org。现在我们可以加载我们的模式并确保我们收到的任何用户对象只包含我们允许的数据。
加载 Json 模式
当服务器启动时,让我们将我们的模式文档加载到内存中。这将避免在服务器应用程序运行时进行昂贵的文件查找。我们可以按对象类型映射(objTypeMap)中定义的对象类型加载每个模式文档(如图 列表 8.15 所示)。变化以粗体显示:^([4])
⁴ Windows 用户需要将文件系统路径中的正斜杠(
/)替换为双反斜杠(\\)。
列表 8.15. 在我们的路由器中加载模式—webapp/routes.js


现在我们已经加载了我们的模式,我们可以创建一个验证函数。
创建一个验证函数
现在我们已经加载了 user JSON 模式,我们想要将传入的客户端数据与模式进行比较。列表 8.16 展示了一个简单的函数来完成这项工作。变化以粗体显示:
列表 8.16. 添加一个验证文档的功能—webapp/routes.js


现在我们有了 JSON 模式加载和验证函数,我们可以验证传入的客户端数据。
验证传入的客户端数据
现在我们可以完成验证。我们只需要调整接受客户端数据的路由(创建和更新)以使用验证器。在每种情况下,如果错误列表为空,我们想要执行请求的操作。否则,我们想要返回一个错误报告,如图 列表 8.17 所示。变化以粗体显示:
列表 8.17. 创建和更新带有验证的路由—webapp/routes.js`


现在我们已经完成了验证,让我们看看我们做得怎么样。首先,我们应该确保所有我们的模块都通过 JSLint (jslint user.json app.js routes.js),然后启动应用程序(node app.js)。然后我们可以使用我们熟练的 wget 技巧来 POST 好的和坏的数据,如图 列表 8.18 所示。我们的输入以粗体显示:
列表 8.18. 使用熟练的 wget 技巧 POST 好的和坏的数据
# Try invalid data
wget http://localhost:3000/user/create \
--header='content-type: application/json' \
--post-data='{"name":"Betty",
"css_map":{"background-color":"#ddd",
"top" : 22 }
}' -O -
--2013-06-07 22:20:17-- http://localhost:3000/user/create
Resolving localhost (localhost)... 127.0.0.1
Connecting to localhost (localhost)|127.0.0.1|:3000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 354 [application/json]
Saving to: á€~STDOUTမ
...
{ "error_msg": "Input document not valid",
"error_list": [
{
"uri": "urn:uuid:8c05b92a...",
"schemaUri": "urn:uuid:.../properties/css_map/properties/left",
"attribute": "required",
"message": "Property is required",
"details": true
}
]
}
...
# Oops, we missed the "left" property. Let's fix that:
wget http://localhost:3000/user/create \
--header='content-type: application/json' \
--post-data='{"name":"Betty",
"css_map":{"background-color":"#ddd",
"top" : 22, "left" : 500 }
}' -O -
--2013-05-07 22:24:02-- http://localhost:3000/user/create
Resolving localhost (localhost)... 127.0.0.1
Connecting to localhost (localhost)|127.0.0.1|:3000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 163 [application/json]
Saving to: á€~STDOUTမ
...
{
"name": "Betty",
"css_map": {
"background-color": "#ddd",
"top": 22,
"left": 500
},
"_id": "5189e172ac5a4c5c68000001"
}
...
# Success!
使用 wget 更新用户留作读者练习。
在下一节中,我们将 CRUD 功能移动到一个单独的模块中。这将导致代码更干净、更容易理解,并且更易于维护。
8.5. 创建一个单独的 CRUD 模块
在这一点上,CRUD 操作和路由的逻辑包含在如图 图 8.6 所示的 routes.js 文件中。
图 8.6. 代码中的路径

我们的服务器正在接受来自客户端的调用,验证数据,并将其保存到数据库中。验证和保存数据的唯一方式是通过调用路由进行 HTTP 调用。如果这就是我们应用所需的所有内容,那么可能就没有必要进一步抽象化,在这里停止。但我们的单页应用还需要通过 WebSocket 连接创建和修改对象。因此,我们将创建一个 CRUD 模块,其中包含验证和管理数据库中文档的所有逻辑。然后,路由器将使用 CRUD 模块进行任何所需的 CRUD 操作。
在我们创建 CRUD 模块之前,我们想强调为什么我们等到现在才创建它。我们喜欢保持我们的代码尽可能直接和简单,但不要过于简单。如果我们必须在代码中只执行一次操作,我们通常更喜欢将其内联或至少作为一个局部函数。但当我们发现我们需要执行两次或更多次操作时,我们希望进行抽象化。尽管这可能不会节省初始编码时间,但它几乎总是节省维护时间,因为我们把逻辑集中到一个单独的例程中,并避免了可能导致实现差异的微妙错误。当然,需要良好的判断力来确定这一哲学的适用范围。例如,我们认为抽象化所有的 for 循环通常不是一个好主意,尽管用 JavaScript 完全可能做到。
在我们将 MongoDB 连接和验证移动到一个单独的 CRUD 模块之后,我们的路由器将不再关心数据存储的实现,而更像是一个控制器:它将请求调度到其他模块,而不是自己执行操作,如图 8.7 所示。
图 8.7. 服务器上的代码路径

创建 CRUD 模块的第一步是准备文件结构。
8.5.1. 准备文件结构
自本章开始以来,我们的文件结构一直保持一致。现在我们需要添加一个额外的模块,我们需要对其进行一些思考。我们当前的结构如图 8.19 所示:
列表 8.19. 我们当前的文件结构
chapter_8
`-- webapp
|-- app.js
|-- node_modules/
|-- package.json
|-- public
| |-- css/
| |-- js/
| `-- spa.html
|-- user.json
`-- routes.js
我们更愿意将我们的模块保存在一个名为 lib 的单独目录中。这将整理 webapp 目录,并将我们的模块与 node_modules 目录分开。node_modules 目录应仅包含通过 npm install 添加的外部模块,以便它可以被删除和重新创建,而不会干扰我们的模块。列表 8.20 展示了我们希望如何组织我们的文件。变更以粗体显示:
列表 8.20. 一个新的启迪性文件结构
chapter_8
`-- webapp
|-- app.js
|-- lib
| |-- crud.js
| |-- routes.js
| `-- user.json
|-- node_modules/
|-- package.json
|-- public
|-- css/
|-- js/
`-- spa.html
我们迈向文件启迪的第一步是将我们的路由文件移动到 webapp/lib。一旦我们这样做,我们需要更新我们的服务器应用程序以指向新的路径,如图 8.21 所示。变更以粗体显示:
列表 8.21. 修改 app.js 以引入移动后的 routes.js—webapp/app.js
/*
* app.js - Express server with routing
*/
...
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
http = require( 'http' ),
express = require( 'express' ),
routes = require( './lib/routes' ),
app = express(),
server = http.createServer( app );
// ------------- END MODULE SCOPE VARIABLES ---------------
...
我们接下来的步骤是将 CRUD 模块包含到我们的路由模块中,如列表 8.22 所示。变更以粗体显示:
列表 8.22. 调整路由模块以要求 CRUD—webapp/lib/routes.js
/*
* routes.js - module to provide routing
*/
...
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
loadSchema, checkSchema, configRoutes,
mongodb = require( 'mongodb' ),
fsHandle = require( 'fs' ),
JSV = require( 'JSV' ).JSV,
crud = require( './crud' ),
...
我们可以创建我们的 CRUD 模块并草拟其 API。我们将使用 module.exports 来共享 CRUD 方法,如列表 8.23 所示。
列表 8.23. 创建 CRUD 模块—webapp/lib/crud.js


当我们使用 node app.js 启动服务器时,它应该在没有错误的情况下运行:
** CRUD module loaded **
Express server listening on port 3000 in development mode
** Connected to MongoDB **
注意,我们添加了两个超出基本 CRUD 操作的公共方法。第一个是 makeMongoId,它提供了创建 MongoDB ID 对象的能力。第二个是 checkType,我们打算用它来检查允许的对象类型。现在我们的文件已经就绪,我们可以将 CRUD 逻辑移动到适当的模块。
8.5.2. 将 CRUD 移动到自己的模块
我们可以通过复制路由模块中的方法来完成 CRUD 模块,然后将特定的 HTTP 参数替换为通用的参数。我们不会深入细节,因为我们认为转换是显而易见的。完成的模块在列表 8.24 中显示。请注意注释,因为它们提供了一些额外的见解:
列表 8.24. 将逻辑移动到我们的 CRUD 模块—webapp/lib/crud.js


路由模块现在变得简单多了,因为大部分逻辑和许多依赖都已经被移动到了 CRUD 模块。一个修订后的路由文件应看起来像列表 8.25。变更以粗体显示:
列表 8.25. 我们修改后的路由模块—webapp/lib/routes.js


现在我们的路由模块变得更小,并使用 CRUD 模块来服务路由。也许更重要的是,我们的 CRUD 模块已经准备好在下一节中我们将构建的聊天模块中使用。
8.6. 构建聊天模块
我们希望我们的服务器应用程序能够为我们的单页应用(SPA)提供聊天功能。到目前为止,我们一直在服务器上构建客户端、UI 和支持框架。参见图 8.8 以了解聊天实现后我们的应用程序应如何看起来。
图 8.8. 完成的聊天应用程序

我们将在本节结束时拥有一个可工作的聊天服务器。我们将首先创建一个聊天模块。
8.6.1. 启动聊天模块
Socket.IO 应已安装在我们的 webapp 目录中。请确保您的 webapp/package.json 清单中列出了正确的模块:
{ "name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies" : {
"express" : "3.2.x",
"mongodb" : "1.3.x",
"socket.io" : "0.9.x",
"JSV" : "4.0.x"
}
}
一旦我们的清单与示例匹配,我们就可以运行 npm install,npm 将确保安装 socket.io 和所有其他所需的模块。
现在我们可以构建我们的聊天消息模块。我们想包括 CRUD 模块,因为我们确信我们将在消息中使用它。我们将构建一个 chatObj 对象,并使用 module.exports 导出它。最初,这个对象将只有一个名为 connect 的方法,它将接受 http.Server 实例(server)作为参数,并开始监听套接字连接。我们的第一次尝试如下所示 清单 8.26:
清单 8.26. 我们对聊天消息模块的第一次尝试—webapp/lib/chat.js
/*
* chat.js - module to provide chat messaging
*/
/*jslint node : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global */
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
chatObj,
socket = require( 'socket.io' ),
crud = require( './crud' );
// ------------- END MODULE SCOPE VARIABLES ---------------
// ---------------- BEGIN PUBLIC METHODS ------------------
chatObj = {
connect : function ( server ) {
var io = socket.listen( server );
return io;
}
};
module.exports = chatObj;
// ----------------- END PUBLIC METHODS -------------------
你可能还记得 第六章,客户端将使用 /chat 命名空间向服务器发送消息—adduser、updatechat、leavechat、disconnect 和 updateavatar。让我们设置我们的聊天客户端来处理这些消息,如下所示 清单 8.27。更改以粗体显示:
清单 8.27. 设置我们的应用程序并概述消息处理器—webapp/lib/chat.js

让我们回到路由模块,我们将包括聊天模块,然后使用 chat.connect 方法初始化 Socket.IO 连接。我们提供 http.Server 实例(server)作为参数,如 清单 8.28 所示。更改以粗体显示:
清单 8.28. 更新路由模块以初始化聊天—webapp/lib/routes.js
/*
* routes.js - module to provide routing
*/
...
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
configRoutes,
crud = require( './crud' ),
chat = require( './chat' ),
makeMongoId = crud.makeMongoId;
// ------------- END MODULE SCOPE VARIABLES ---------------
// ---------------- BEGIN PUBLIC METHODS ------------------
configRoutes = function ( app, server ) {
...
chat.connect( server );
};
module.exports = { configRoutes : configRoutes };
// ----------------- END PUBLIC METHODS -------------------
当我们使用 node app.js 启动服务器时,我们应该在 Node.js 服务器日志中看到 info - socket.io started。我们也可以像以前一样访问 http://localhost:3000 来管理用户对象或在浏览器中查看我们的应用程序。
我们已经声明了所有的消息处理器,但现在我们需要让它们做出响应。让我们从 adduser 消息处理器开始。
为什么选择 WebSocket?
WebSocket 相比于浏览器中使用的其他近实时通信技术具有一些明显的优势:
-
一个 WebSocket 数据帧只需要两个字节来维护数据连接,而 AJAX HTTP 调用(在长轮询中使用)通常每个数据帧传输千字节的信息(实际数量根据 Cookie 的数量和大小而变化)。
-
WebSocket 与长轮询相比具有优势。它们通常使用大约 1-2% 的网络带宽,并且延迟减少到三分之一。WebSocket 还往往更受防火墙欢迎。
-
WebSocket 是全双工的,而大多数其他解决方案不是,并且需要相当于两个连接。
-
与 Flash 套接字不同,WebSocket 在几乎所有平台上的任何现代浏览器上都能工作,包括智能手机和平板电脑等移动设备。
虽然 Socket.IO 倾向于使用 WebSocket,但知道如果 WebSocket 不可用,它将协商最佳连接,这让人感到安慰。
8.6.2. 创建 adduser 消息处理器
当用户尝试登录时,客户端会向我们的服务器应用程序发送包含用户数据的 adduser 消息。我们的 adduser 消息处理器应该:
-
尝试使用 CRUD 模块在 MongoDB 中查找提供的用户名对应的用户对象。
-
如果找到了请求的用户对象,使用找到的对象。
-
如果没有找到请求的用户对象不是,则使用提供的用户名创建一个新的用户对象并将其插入数据库。使用这个新创建的对象。
-
更新 MongoDB 中的用户对象以指示用户在线(
is_online: true)。 -
更新
chatterMap以存储用户 ID 和 socket 连接作为键值对。
让我们按照列表 8.29 中所示实现这个逻辑。更改以粗体显示:
列表 8.29. 创建 adduser 消息处理器—webapp/lib/chat.js


调整到回调方法的思考可能需要一段时间,但通常当我们调用一个方法,并且该方法完成时,我们提供的回调将被执行。本质上它将过程代码转换为这样:
var user = user.create();
if (user ) {
//do things withuser object
}
进入类似这样的事件驱动代码:
user.create( function ( user ){
// do things with user object
});
我们使用回调,因为 Node.js 中的许多函数调用都是异步的。在前面的例子中,当我们调用user.create时,JavaScript 引擎将继续执行后续代码,而不会等待调用完成。在结果准备好后立即使用结果的保证方法之一是使用回调.^([5]) 如果你熟悉 jQuery AJAX 调用,它使用回调机制:
⁵ 另一种机制称为承诺,通常比普通的回调更灵活。承诺库包括 Q (
npm install q) 和 Promised-IO (npm install promised-io)。jQuery for Node.js 也提供了一套丰富且熟悉的承诺方法。附录 B 展示了 jQuery 与 Node.js 的使用。
$.ajax({
'url': '/path',
'success': function ( data ) {
// do things with data
}
});
我们现在可以将浏览器指向 localhost:3000 并登录。我们鼓励在家练习的人尝试一下。现在让我们开始聊天。
8.6.3. 创建 updatechat 消息处理器
实现登录功能需要相当多的代码。我们的应用程序现在在 MongoDB 中跟踪用户,管理他们的状态,并向所有连接的客户端广播在线人员列表。处理聊天消息相对简单,尤其是在我们完成登录逻辑之后。
当客户端向我们的服务器应用程序发送updatechat消息时,它是在请求将消息发送给某人。我们的updatechat消息处理器应该:
-
检查聊天数据并检索收件人。
-
确定目标收件人是否在线。
-
如果收件人在线,通过他们的 socket 将聊天数据发送给收件人。
-
如果收件人不在线,将新的聊天数据发送给发送者在其 socket 上。新的聊天数据应通知发送者目标收件人不在线。
让我们按照列表 8.30 中所示实现这个逻辑。更改以粗体显示:
列表 8.30. 添加 updatechat 消息处理器—webapp/lib/chat.js


现在,我们可以将我们的浏览器指向 localhost:3000 并登录。如果我们以不同的用户身份登录到另一个浏览器窗口,我们可以互相传递消息。一如既往,我们鼓励在家中的玩家尝试一下。目前唯一不起作用的功能是断开连接和头像。让我们先处理断开连接的问题。
8.6.4. 创建断开连接消息处理程序
客户端可以通过两种方式之一关闭会话。首先,用户可以点击浏览器窗口右上角的用户名来注销。这会向服务器发送一个leavechat消息。其次,用户可以关闭浏览器窗口。这会导致服务器收到一个disconnect消息。在两种情况下,Socket.IO 都很好地清理了套接字连接。
当我们的服务器应用程序收到leavechat或disconnect消息时,它应该采取相同的两个行动。首先,它应该将关联客户端的人标记为离线(is_online : false)。其次,它需要向所有已连接客户端广播更新的在线人员列表。这个逻辑在列表 8.31 中显示。变化以粗体显示:
列表 8.31. 添加断开连接方法—webapp/lib/chat.js


现在我们可以打开多个浏览器窗口,将它们指向 http://localhost:3000,并通过点击每个窗口右上角来以不同的用户身份登录。然后我们可以在用户之间发送消息。我们故意留下了一个缺陷作为读者的练习:服务器应用程序将允许同一用户在多个客户端登录。这是不应该发生的。你应该可以通过检查adduser消息处理程序中的chatterMap来修复这个问题。
我们还有一个尚未实现的功能:同步头像。
8.6.5. 创建更新头像消息处理程序
WebSocket 消息可用于所有类型的服务器-客户端通信。当我们需要与浏览器进行近乎实时通信时,它通常是最佳选择。为了展示 Socket.IO 的另一种用途,我们在聊天中加入了用户可以移动和改变颜色的头像。当任何人更改头像时,Socket.IO 会立即将这些更新推送给其他用户。让我们通过图 8.9、8.10 和 8.11 来了解一下这看起来是什么样子。
图 8.9. 登录时的头像

图 8.10. 移动头像

图 8.11. 其他用户登录时的头像

这个客户端代码已经在第六章中演示过,我们现在已经到了将所有这些放在一起的时刻。现在我们已经设置了 Node.js 服务器、MongoDB 和 Socket.IO,使服务器端代码变得非常小。我们只需在 lib/chat.js 中添加一个与其它消息处理程序相邻的消息处理程序,就像列表 8.32 中所示:
列表 8.32. 看看这些头像—webapp/lib/chat.js
/*
* chat.js - module to provide chat messaging
*/
...
// ---------------- BEGIN PUBLIC METHODS ------------------
chatObj = {
connect : function ( server ) {
var io = socket.listen( server );
// Begin io setup
io
.set( 'blacklist' , [] )
.of( '/chat' )
.on( 'connection', function ( socket ) {
...
// End disconnect methods
// Begin /updateavatar/ message handler
// Summary : Handles client updates of avatars
// Arguments : A single avtr_map object.
// avtr_map should have the following properties:
// person_id = the id of the persons avatar to update
// css_map = the css map for top, left, and
// background-color
// Action :
// This handler updates the entry in MongoDB, and then
// broadcasts the revised people list to all clients.
//
socket.on( 'updateavatar', function ( avtr_map ) {
crud.update(
'user',
{ '_id' : makeMongoId( avtr_map.person_id ) },
{ css_map : avtr_map.css_map },
function ( result_list ) { emitUserList( io ); }
);
}); // End /updateavatar/ message handler
}
);
// End io setup
return io;
}
};
module.exports = chatObj;
// ----------------- END PUBLIC METHODS -------------------
让我们用node app.js启动服务器,将我们的浏览器指向 http://localhost:3000/,并登录。我们还要打开第二个浏览器窗口,并用不同的用户名登录。在这个时候,我们可能只能看到一个头像,因为两个可能重叠。我们可以通过长按拖动来移动头像。我们可以通过点击或轻触来改变它的颜色。这在桌面和触摸设备上都有效。无论如何,我们的服务器应用程序在近乎实时地同步头像。
消息传递是实现近乎实时协作的关键。通过 Web sockets,我们可以创建应用程序,让遥远的人们可以一起解决谜题、设计引擎或绘制图片——可能性是无限的。这是实时网络的承诺,我们每天都在看到更多这样的应用。
8.7. 概述
在本章中,我们设置了 MongoDB,将其连接到 Node.js,并执行了一些基本的 CRUD 操作。我们介绍了 MongoDB,并讨论了它的许多优点和缺点。我们还展示了如何使用客户端相同的代码在将数据插入数据库之前对其进行验证。这种重用避免了在服务器端用一种语言编写验证器,然后在浏览器端用 JavaScript 重写它的熟悉痛苦。
我们介绍了 Socket.IO,并展示了如何使用它来提供聊天消息。我们将 CRUD 功能移动到一个单独的模块中,以便它可以轻松地为 HTTP API 和 Socket.IO 提供服务。我们还使用消息在许多客户端之间提供近乎实时的头像同步。
在下一章中,我们将探讨如何使我们的单页应用(SPA)为生产做好准备。我们将回顾我们在托管 SPA 时遇到的一些问题,并讨论如何解决这些问题。
第九章。为生产准备我们的 SPA
本章内容涵盖
-
优化 SPA 以适应搜索引擎
-
使用 Google Analytics
-
在内容分发网络(CDN)上放置静态内容
-
记录客户端错误
-
缓存和缓存清除
本章基于我们在第八章中编写的代码。我们建议将那一章的整个目录结构复制到一个新的“chapter_9”目录中,并更新那里的文件。
我们已经完成了一个响应式 SPA 的编写,使用了经过良好测试的架构,但仍然存在一些挑战,这些挑战更多是关于操作而不是编程。
我们需要调整我们的 SPA,以便用户可以使用 Google 和其他搜索引擎找到他们需要的内容。我们的 Web 服务器需要与索引我们内容的爬虫机器人进行交互,因为这些爬虫不会执行我们的 SPA 用来生成内容的 JavaScript。我们还想使用分析工具。在传统的网站上,分析数据通常是通过添加到每个 HTML 页面的 JavaScript 片段来收集的。由于 SPA 中的所有 HTML 都是由 JavaScript 生成的,我们需要采取不同的方法。
我们还希望调整我们的 SPA 以提供关于流量、用户行为和错误的详细日志记录。服务器日志为传统网站提供了许多这样的洞察。SPA 将大多数用户交互逻辑移动到客户端,因此需要不同的方法。我们希望我们的 SPA 非常响应。提高响应时间的一种方法是通过使用内容分发网络(CDN)来提供静态文件和数据。另一种方法是使用 HTTP 和服务器缓存。
让我们从使我们的 SPA 内容可搜索开始。
9.1. 优化我们的 SPA 以适应搜索引擎
当谷歌和其他搜索引擎索引网站时,它们不会执行 JavaScript。这似乎让 SPA 与传统的网站相比处于巨大的劣势。不在谷歌上可能意味着一个企业的死亡,这个令人畏惧的陷阱可能会诱使不知情的人放弃 SPA。
SPAs(单页应用程序)实际上在搜索引擎优化(SEO)方面比传统网站有优势,因为谷歌和其他搜索引擎已经认识到了这个挑战。他们已经为 SPAs 创建了一种机制,不仅可以让它们的动态页面被索引,还可以针对爬虫优化它们的页面。本节重点介绍最大的搜索引擎谷歌,但其他大型搜索引擎如雅虎和必应也支持相同的机制。
9.1.1. 谷歌如何爬取 SPA
当谷歌索引一个传统网站时,它的网络爬虫(称为Googlebot)首先扫描和索引顶级 URI(例如,www.myhome.com)的内容。一旦完成,它就会跟随该页面上所有的链接,并索引这些页面。然后它跟随后续页面上的链接,依此类推。最终,它会索引网站上所有相关域的内容。
当 Googlebot 尝试索引一个 SPA 时,它在 HTML 中看到的是一个空的容器(通常是一个空的div或body标签),因此没有可以索引的内容和可以爬取的链接,它相应地索引了该网站(在其桌旁的圆形“文件夹”中)。
如果这就是故事的结局,那么许多 Web 应用程序和网站的 SPA 时代也就结束了。幸运的是,谷歌和其他搜索引擎已经认识到了 SPA 的重要性,并提供了工具,允许开发者向爬虫提供比传统网站更好的搜索信息。
使我们的 SPA 可爬取的第一个关键是要意识到我们的服务器可以判断一个请求是由爬虫还是由使用网络浏览器的人发起,并相应地做出反应。当我们的访客是使用网络浏览器的人时,正常响应,但对于爬虫,返回一个针对爬虫优化的页面,以显示爬虫可以轻松读取的格式中我们想要展示的内容。
对于我们网站的首页,一个针对爬虫优化的页面看起来是什么样子?它可能是我们希望在搜索结果中出现的标志或其他主要图像,一些 SEO 优化的文本解释应用程序的功能,以及一个仅指向我们希望 Google 索引的页面的 HTML 链接列表。页面没有 CSS 样式或应用于它的复杂 HTML 结构。也没有 JavaScript 或链接到我们不希望 Google 索引的区域(如法律免责声明页面或其他我们不希望人们通过 Google 搜索进入的页面)。图 9.1 展示了页面可能如何呈现给浏览器和爬虫。
图 9.1. 主页的客户和爬虫视图

页面上的链接不会被爬虫以人类跟随链接的方式跟随,因为我们应用了特殊的字符 #!(发音为 hash bang)在我们的 URI 锚组件中。例如,如果在我们单页应用(SPA)中,用户页面链接看起来像 /index.htm#!page=user:id,123,爬虫会看到 #! 并知道要查找具有 URI /index.htm?_escaped_fragment_=page=user:id,123 的网页。知道爬虫会遵循这个模式并查找这个 URI,我们可以编程服务器响应这个请求,返回一个通常由浏览器中的 JavaScript 渲染的网页的 HTML 快照。这个快照将被 Google 索引,但任何点击我们 Google 搜索结果中列表的人都会被带到 /index.htm#!page=user:id,123。SPA JavaScript 将从那里接管并按预期渲染页面。
这为单页应用(SPA)开发者提供了针对 Google 和用户特别定制网站的机会。不必编写既对人类可读又吸引人,同时又能被爬虫理解的文本,页面可以针对每个进行优化,而不必担心另一个。我们可以控制爬虫在我们网站上的路径,从而将人们从 Google 搜索结果引导到一组特定的入口页面。这需要工程师进行更多的工作来开发,但它在搜索结果位置和客户保留方面可以带来巨大的回报。
在撰写本文时,Googlebot 通过带有用户代理字符串 Googlebot/2.1 (+[www.googlebot.com/bot.html](http://www.googlebot.com/bot.html)) 的请求向服务器宣布自己是一个爬虫。我们的 Node.js 应用程序可以在中间件中检查这个用户代理字符串,如果匹配,则发送回针对爬虫优化的主页。否则,我们可以正常处理请求。或者,我们可以将其钩入我们的路由中间件,如 列表 9.1 所示:
列表 9.1. 在 routes.js 文件中检测 Googlebot 并提供替代内容

这种安排看起来测试起来可能很复杂,因为我们没有谷歌爬虫。谷歌提供了一项服务,作为其网站管理工具的一部分,用于测试公开可用的生产网站(support.google.com/webmasters/bin/answer.py?hl=en&answer=158587),但一个更简单的方法是伪造我们的用户代理字符串。这曾经需要一些命令行技巧,但 Chrome 开发者工具使得这个过程变得非常简单,只需点击一个按钮并勾选一个复选框即可:
-
通过点击位于谷歌工具栏右侧带有三个水平线的按钮,然后从菜单中选择工具并点击开发者工具,可以打开 Chrome 开发者工具。
-
屏幕左下角有一个齿轮图标:点击该图标,可以看到一些高级开发者选项,例如禁用缓存和开启
XmlHttpRequests 的日志记录。 -
在第二个标签页,标记为“覆盖”的旁边,点击用户代理标签旁边的复选框,并从下拉菜单中选择任何数量的用户代理,从 Chrome 到 Firefox,再到 IE、iPad 等。谷歌爬虫代理不是默认选项。为了使用它,选择“其他”,并将用户代理字符串复制并粘贴到提供的输入框中。
-
现在这个标签页正在伪造自己为谷歌爬虫,当我们打开我们网站上的任何 URI 时,我们应该能看到爬虫页面。
显然,不同的应用程序在处理网络爬虫方面会有不同的需求,但始终只返回一个页面给谷歌爬虫可能是不够的。我们还需要决定我们想要公开哪些页面,并为我们的应用程序提供将_escaped_fragment_=key=value URI 映射到我们想要显示的内容的方法。无论情况如何,这本书都应该为你提供决定如何最好地抽象化爬虫内容以适应你应用程序的工具。你可能想要做得更复杂,将服务器响应与前端框架结合起来,但通常我们采取更简单的方法,为爬虫创建自定义页面,并将它们放在一个单独的路由文件中。
此外,还有许多合法的爬虫,因此一旦我们调整了服务器以适应谷歌爬虫,我们就可以将它们也包括在内。
9.2. 云服务和第三方服务
许多公司提供帮助构建和管理应用程序的服务,这些服务可以大大节省开发和维护成本。如果我们是一家小公司,我们可能想利用其中的一些服务。三个重要的服务——网站分析、客户端日志和 CDN——对于 SPA 开发尤为重要。
9.2.1. 网站分析
网络开发者工具包中的一个重要工具是获取他们正在工作的网站的分析能力。在传统网站上,开发者已经依赖于像谷歌分析和新 relic 这样的工具来提供关于人们如何使用网站以及找到应用程序或业务性能瓶颈(网站如何有效地产生销售额)的详细分析。使用相同的工具采取略有不同的方法将使它们在 SPA 上同样有效。
谷歌分析提供了一个简单的方法来获取关于我们的 SPA 及其各种状态的流行度统计,以及流量如何进入我们的网站。我们可以在传统网站上使用谷歌分析,通过将一小段 JavaScript 代码粘贴到网站上每个 HTML 页面上,并对页面进行一些小的修改来分类页面。我们也可以用这种方法来处理我们的 SPA,但那样我们只能得到初始页面加载的统计分析。我们可以使用两条路径来启用我们的 SPA 充分利用谷歌分析:
-
使用谷歌事件跟踪标签变化
-
使用 Node.js 记录服务器端
我们将从查看谷歌事件开始。
谷歌事件
谷歌长期以来一直认识到记录和分类页面事件的需求——SPA 开发可能相对较新,但 Ajax 技术已经存在很长时间(在网页年代,这真的是很长……自 1999 年以来!)跟踪事件很容易,尽管它比跟踪页面浏览量需要更多手动工作。在传统网站上,JavaScript 代码片段会调用 _gaq 对象上的 _trackPageView 方法。这允许我们传递自定义变量来设置代码片段所在页面的信息。该调用通过请求一个图像并在请求末尾传递参数将信息发送到谷歌。这些参数被谷歌服务器用来处理关于该页面浏览的信息。使用谷歌事件在 _gaq 对象上执行不同的调用:它调用 _trackEvent 并传递一些参数。然后 _trackEvent 会加载一个带有一些参数的图像,谷歌使用这些参数来处理关于该事件的信
设置和使用事件跟踪的步骤相当直接:
-
在谷歌分析网站上设置我们网站的跟踪。
-
调用
_trackEvent方法。 -
查看报告。
_trackEvent 方法需要两个必需参数和三个可选参数:
_trackEvent(category, action, opt_label, opt_value, opt_noninteraction)
参数细节如下:
-
category是必需的,用于命名属于该组的事件。它将在我们的报告中显示,用于分类事件。 -
action是必需的,用于定义我们通过每个事件跟踪的具体操作。 -
opt_label是一个可选参数,用于添加关于事件的额外数据。 -
opt_value是一个可选参数,用于提供关于事件的数值数据。 -
opt_noninteraction是一个可选参数,用于告诉谷歌不要将此事件用于跳出率计算。
例如,如果在我们 SPA 中想要跟踪用户打开聊天窗口的时刻,我们可能会进行以下_trackEvent调用:
_trackEvent( 'chat', 'open', 'home page' );
这个调用将出现在报告中,让我们知道发生了聊天事件,用户打开了聊天窗口,并且用户是在主页上完成的。另一个调用可能是:
_trackEvent( 'chat', 'message', 'game' );
这将记录一个聊天事件发生,用户发送了消息,并且是在游戏页面上完成的。像传统网站的方法一样,如何组织和跟踪不同的事件取决于开发者决定。作为一个捷径,我们可以在不将每个事件编码到客户端模型中的情况下,将_trackEvent调用插入到客户端路由器(监视哈希标签变化的代码)中,然后解析这些变化为类别、操作和标签,并使用这些变化作为参数调用_trackEvent方法。
服务器端 Google Analytics
如果我们想要获取有关从服务器请求的数据的信息,服务器端跟踪是有用的,但它不能用来跟踪不向服务器端发出请求的客户端交互,这在 SPA 中相当多。它可能看起来不那么有用,因为它不能跟踪客户端操作,但能够跟踪通过客户端缓存的请求是有用的。它可以帮助我们追踪运行过慢的服务器请求和其他行为。尽管这仍然能够提供有价值的见解,但如果我们必须选择一个,我们会选择客户端。
由于 JavaScript 在服务器上使用,我们似乎可以修改 Google Analytics 代码以便从服务器端使用。这不仅可能,而且像许多看似好主意的事情一样,社区可能已经实现了它。快速搜索结果显示了node-googleanalytics和nodealytics作为社区开发的项目。
9.2.2. 记录客户端错误
在传统网站上,当服务器发生错误时,错误会被写入日志文件。在 SPA 中,当客户端遇到类似错误时,没有现成的记录机制。我们不得不手动编写代码来跟踪错误,或者寻求第三方服务的帮助。自行处理提供了灵活性,可以做我们想要做的任何关于错误的事情,但使用第三方服务则给我们提供了将时间和资源用于其他事情的机会。此外,他们可能已经实现了比我们能够实现得多的功能。而且,这并不是全有或全无——我们可以在使用第三方服务的同时,如果需要跟踪或升级服务不提供的方式的错误,我们可以自行实现所需的功能。
第三方客户端日志
有几个第三方服务收集和汇总我们应用程序生成的错误和指标数据:
-
Airbrake专注于 Ruby on Rails 应用程序,但提供了实验性的 JavaScript 支持。
-
Bugsense专注于移动应用程序解决方案。他们的产品与 JavaScript SPAs 和原生移动应用程序兼容。如果我们有一个以移动为重点的应用程序,他们可能是一个不错的选择。
-
Errorception专注于记录 JavaScript 错误,因此是 SPA 客户端的一个好选择。它们不如 Airbrake 或 Bugsense 那样成熟,但我们喜欢它们的活力。Errorception维护一个开发者博客(
blog.errorception.com/),在那里我们可以获得 JavaScript 错误记录的见解。 -
New Relic正迅速成为网络应用程序性能监控的行业标准。其性能监控包括请求/响应周期中每个步骤的错误日志和性能指标,从数据库查询耗时到浏览器渲染 CSS 样式耗时。该服务为客户端和服务器上的性能提供了令人印象深刻的洞察力。
在撰写本文时,我们倾向于更喜欢 New Relic 或 Errorception。虽然 New Relic 提供了更多数据,但我们发现 Errorception 在处理 JavaScript 错误时更胜一筹,并且易于设置。
手动记录客户端错误
当具体到这些服务时,它们都使用这两种方法之一来发送 JavaScript 错误:
-
使用
window.onerror事件处理器捕获错误。 -
将代码包裹在
try/catch块中,并发送它捕获的内容。
window.onerror事件是大多数第三方应用的基础。onerror会在运行时错误发生时触发,但不会在编译错误时触发。由于浏览器支持的差异和潜在的安全漏洞,onerror有些争议,但它是我们日志记录客户端 JavaScript 错误的重大武器。

try/catch方法需要在我们的 SPA 中的主要调用周围包裹try/catch块。这将捕获我们应用程序生成的任何同步错误;不幸的是,它也会阻止它们冒泡到window.onerror或显示在错误控制台中。它不会捕获异步调用中的任何错误,如事件处理器中或setTimeout或setInterval函数中做出的调用。这意味着必须将所有代码包裹在我们的异步函数中的try/catch块中。
<script>
setTimeout( function () {
try {
var obj;
obj.push( 'string' );
} catch ( error ) {
// do something with error
}
}), 1);
</script>
对于我们所有的异步调用都必须这样做会变得乏味,并阻止错误报告到控制台。将代码包裹在try/catch块中也会阻止该块中的代码预先编译,导致其运行速度变慢。对于 SPA 的一个良好折衷方法是,将我们的init调用包裹在try/catch块中,在catch内部将错误记录到控制台,并通过 Ajax 发送,然后使用window.onerror来捕获所有我们的异步错误并通过 Ajax 发送。无需手动将异步错误记录到控制台,因为它们仍然会自动出现在那里。
<script>
$(function () {
try {
spa.initModule( $('#spa') );
} catch ( error ) {
// log the error to the console
// then send it to a third party logging service
}
});
window.onerror = function ( error ) {
// do something with asynchronous errors
};
</script>
现在我们已经了解了客户端发生的错误,我们可以专注于如何更快地向网站访客提供内容。
9.2.3. 内容分发网络
内容分发网络(CDN)是一个建立起来以尽可能快地交付静态文件的网络。它可能只是一个位于我们应用程序服务器旁边的单个 Apache 服务器,或者是一个拥有数十个数据中心的全局基础设施。无论如何,设置一个单独的服务器来交付我们的静态文件是有意义的,这样就不会给我们的应用程序服务器带来负担。Node.js 特别不适合交付大型静态内容文件(图像、CSS、JavaScript),因为这种使用无法利用 Node.js 的异步特性。具有预分叉的 Apache 则更适合。
由于我们精通 Apache,我们可以自己搭建一个“单服务器 CDN”,直到我们准备好扩展网站;否则,我们还可以使用许多第三方 CDN。其中三个大的是亚马逊、Akamai 和 Edgecast。亚马逊有 Cloudfront 产品,而 Akamai 和 Edgecast 则通过其他公司如 Rackspace、Distribution Cloud 等进行转售。实际上,市场上有很多 CDN 公司,甚至有一个网站专门用于选择合适的提供商:www.cdnplanet.com。
使用全球分布式的 CDN 的另一个好处是,我们的内容是从最近的服务器提供的,这使得提供这些文件所需的时间大大缩短。当我们考虑性能优势时,使用 CDN 通常是一个容易做出的选择。
9.3. 缓存和缓存失效
缓存对于使我们的应用程序运行快速至关重要。没有比客户端缓存更快的检索数据方式,而服务器缓存通常比再次请求和计算相同信息要好得多。在我们的 SPA 中有许多地方有缓存数据的潜力,从而加快应用程序的这一部分。我们将逐一介绍:
-
Web 存储
-
HTTP 缓存
-
服务器缓存
-
数据库缓存
在缓存时考虑数据新鲜度至关重要。我们不希望向应用程序用户提供过时的数据,但与此同时,我们希望尽可能快速地响应用户请求。
9.3.1. 缓存机会
每个这些缓存都有不同的职责,并以不同的方式与客户端交互,以加快应用程序。
-
Web 存储在客户端存储字符串,并且对应用程序是可访问的。使用这些存储从服务器检索并处理的数据的完成 HTML。
-
HTTP 缓存是客户端缓存,它存储来自服务器的响应。为了正确控制这种缓存方式,需要学习很多细节,但一旦学习和实施,我们几乎可以免费获得大量的缓存。
-
服务器缓存使用 Memcached 和 Redis,通常用于缓存处理过的服务器响应。这是第一种可以存储不同用户数据的缓存形式,这样如果某个用户请求某些信息,下次其他人请求时,它已经缓存了这些信息,从而节省了数据库的访问。
-
数据库缓存或查询缓存,是数据库用来缓存查询结果,以便如果开启,后续相同的查询将返回缓存而不是再次收集数据。
图 9.2 显示了具有所有缓存机会的典型请求/响应周期。我们可以看到每个缓存级别如何通过在各个阶段缩短周期来加速响应。HTTP 缓存和数据库缓存实现起来最简单,通常只需要设置一些配置,而 Web 存储和服务器缓存则更复杂,需要开发者付出更多努力。
图 9.2. 使用缓存缩短请求/响应周期

9.3.2. Web 存储
Web 存储,也称为DOM 存储,分为两种类型:本地存储和会话存储。它们被所有现代浏览器支持,包括 IE8+。它们是简单的键/值存储,其中键和值都必须是字符串。会话存储只存储当前标签页会话中的数据——关闭标签页将关闭会话并清除数据。本地存储将存储缓存,没有过期日期。在任何情况下,数据只对存储它的网页可用。对于 SPA 来说,这意味着整个网站都可以访问存储。使用 Web 存储的一个极好方法是存储处理过的 HTML 字符串,这样就可以绕过整个请求/响应周期,直接显示结果。图 9.3 显示了详细信息。
图 9.3. Web 存储

我们使用本地存储来存储我们希望在当前浏览器会话之外持久化的非敏感信息。我们使用会话存储来存储不会在当前会话中持久化的数据。
由于 Web 存储只能保存字符串值,通常保存 JSON 或 HTML。在 SPA 中使用 HTTP 缓存保存 JSON 是多余的,我们将在下一节讨论,并且仍然需要一些处理才能使用。通常,存储 HTML 字符串更好,这样我们就可以节省客户端创建它的处理工作。这种存储可以抽象为一个 JavaScript 对象,它为我们处理细节。
会话存储只存储当前会话的数据,因此我们有时可以不必过多考虑过时数据的问题——但并非总是如此。当我们确实需要担心过时数据时,一种强制数据刷新的方法是将时间编码到缓存键中。如果我们希望数据每天过期,我们可以在键中包含当天的日期。如果我们希望数据每小时过期,我们也可以将小时编码到其中。这不会处理每个场景,但在执行方面可能是最简单的,如列表 9.2 所示:
列表 9.2. 在缓存键中编码时间

9.3.3. HTTP 缓存
HTTP 缓存发生在浏览器根据服务器在头部设置的某些属性或根据行业标准的默认缓存指南缓存从服务器发送的数据时。尽管它可能比 Web 存储慢,因为结果仍然需要处理,但它通常更简单,并且仍然比服务器端缓存快。图 9.4 显示了 HTTP 缓存在请求/响应周期中的位置。
图 9.4. HTTP 缓存

HTTP 缓存用于在客户端存储服务器响应,以避免再次进行往返。它可以遵循两种模式:
-
直接从缓存中提供服务,不检查服务器的新鲜度。
-
检查服务器的新鲜度,如果新鲜则从缓存中提供服务,如果过时则从服务器响应中提供服务。
直接从缓存中提供服务而不检查数据的新鲜度是最快的,因为我们避免了往返服务器的过程。对于图像、CSS 和 JavaScript 文件来说,这样做更安全,但我们也可以设置我们的应用程序,使其能够缓存数据一段时间。例如,如果我们有一个每天午夜只更新某些类型数据的应用程序,那么我们可以指示客户端缓存数据直到午夜过后。
有时这并不提供足够最新的信息。在这种情况下,浏览器可以被指示再次与服务器联系,以查看数据是否仍然新鲜。
让我们深入探讨,看看这种缓存是如何工作的。HTTP 缓存通过让客户端查看从服务器发送的响应头来实现。客户端寻找三个主要属性:max-age、no-cache和last-modified。每个属性都向客户端传达了数据缓存多长时间。
Max-Age
为了让客户端能够使用其缓存中的数据而不需要尝试联系服务器,初始响应的头部必须在 Cache-Control 头部中设置 max-age。这个值告诉客户端在再次请求之前应该缓存数据多长时间。max-age 的值以秒为单位。这既是一个强大的功能,也是一个潜在的危险功能。它之所以强大,是因为这是访问数据的最快方式;一旦数据被加载,使用这种方式缓存数据的程序将会非常快。它之所以危险,是因为客户端不再检查服务器是否有变化,因此我们必须对此格外小心。
当使用 Express 时,我们可以使用 max-age 属性设置 Cache-Control 头部。
res.header("Cache-Control", "max-age=28800");
一旦以这种方式设置缓存,唯一打破缓存并强制客户端发出新请求的方法是更改文件名。
显然,每次将文件推送到生产环境时都更改文件名是不理想的。幸运的是,更改传递给文件的参数将打破缓存。这通常是通过附加一个版本号或一些整数来完成的,我们的构建系统会随着每次部署递增这个整数。有许多实现这种方法的方式,但我们更喜欢有一个单独的文件,其中包含我们的递增值,并将该数字附加到文件名末尾。因为索引页面是静态的,我们可以设置我们的部署工具生成最终的 HTML 文件,并在我们的包含文件末尾包含版本号。让我们看看 列表 9.3 的例子,看看缓存破坏者在最终的 HTML 中会是什么样子。
列表 9.3. 打破 max-age 缓存

max-age 的另一个用途是将它设置为 0,这告诉客户端内容应该始终重新验证。当设置为这种情况时,客户端将始终检查服务器以确保内容仍然有效,但服务器仍然可以回复一个 302 响应,通知客户端数据不是过时的,应该从缓存中提供。设置 max-age=0 的副作用是,中间服务器——那些位于客户端和最终服务器之间的服务器——只要它们也在响应中设置了一个警告标志,仍然可以响应一个过时的缓存。
现在,如果我们希望防止中间服务器永远使用其缓存,那么我们就需要查看 no-cache 属性。
无缓存
根据规范,no-cache属性的工作方式与设置max-age=0非常相似,以至于容易造成混淆。它告诉客户端在使用缓存中的数据之前,需要先与服务器进行重新验证,但它也告诉中间服务器,即使有警告信息,它们也不能提供过时的内容。在过去的几年中,出现了一个有趣的情况,因为 IE 和 Firefox 开始将此设置解释为在任何情况下都不应该缓存此数据。这意味着客户端在保留数据之前甚至不会询问服务器它最后接收的数据是否新鲜;客户端永远不会将其缓存中的数据存储。这可能导致带有no-cache头部的资源加载变得不必要地缓慢。如果期望的行为是防止客户端缓存资源,那么应该使用no-store属性。
No-Store
no-store属性通知客户端和中间服务器,永远不要在它们的缓存中存储有关此请求/响应的任何信息。尽管这有助于提高此类传输的隐私性,但它绝不是一种完美的安全形式。在正确实现的系统中,任何数据痕迹都将消失;有可能数据会通过不当或恶意编码的系统,从而容易受到窃听。
Last-Modified
如果没有设置Cache-Control,则客户端将依赖于基于last-modified日期的算法来确定缓存数据的时间长度。通常这相当于自last-modified日期以来的三分之一时间。因此,如果图像文件是三天前最后修改的,当它被请求时,客户端将默认从缓存中提供它一天,然后再与服务器再次检查。这导致资源从缓存中提供的时间量在很大程度上是随机的,取决于文件上次推送到生产环境的时间长度。
还有许多其他与缓存相关的属性,但掌握这些基本属性将显著加快应用程序的加载时间。HTTP 缓存允许我们的应用程序的客户在无需再次请求信息或以最小的开销询问服务器资源是否仍然新鲜的情况下,提供之前看到的资源。这加快了后续请求中的应用程序,但对于其他客户端发出的相同请求呢?HTTP 缓存对此没有帮助;相反,数据需要在服务器上进行缓存。
9.3.4. 服务器缓存
服务器响应客户端动态数据请求最快的方式是从缓存中提供服务。这样做可以减少查询数据库并将查询响应序列化为 JSON 字符串所需的时间。图 9.5 展示了服务器缓存在请求/响应周期中的位置。
图 9.5. 服务器缓存

在服务器上缓存数据的两种流行方法是 Memcached 和 Redis。根据memcached.org,“Memcached 是一个用于小块任意数据的内存键值存储。”它专门设计为从数据库、API 调用或处理 HTML 中检索数据的临时缓存。当服务器内存不足时,它将自动根据最近最少使用(LRU)算法开始丢弃数据。Redis 是一个高级键值存储,可以用于存储更复杂的数据结构,如字符串、散列、列表、集合和有序集合。
缓存的整体思路是减少服务器负载并加快响应时间。当接收到数据请求时,应用程序首先检查该查询的响应是否已存储在缓存中。如果应用程序找到数据,它就会将其提供给客户端。如果没有缓存数据,它就会进行相对昂贵的数据库查询,并将数据转换为 JSON。然后,它在缓存中存储数据,并使用结果回复客户端。
当我们使用缓存时,必须考虑何时需要“清除”缓存。如果只有我们的应用程序向缓存写入数据,那么当数据发生变化时,它可以清除或重新生成缓存。如果有其他应用程序也向缓存写入数据,那么我们需要它们也更新缓存。有几种方法可以解决这个问题:
-
我们可以在一定时间后使缓存失效并强制刷新数据。如果我们每小时这样做一次,那么一天中将有高达 24 次没有缓存的响应。显然,这并不适用于所有应用程序。
-
我们可以检查数据的最后更新时间,如果它与缓存的最后更新时间相同或更早。这比第一个选项处理时间更长,但它可能不会像复杂请求那样耗时,并且我们可以确保数据是新鲜的。
我们选择哪种选项取决于我们应用程序的需求。
服务器缓存对于我们的 SPA 来说过于冗余。MongoDB 为我们示例数据集提供了出色的性能。我们并不处理 MongoDB 的响应——我们只是将其传递给客户端。
那么,我们应该在何时考虑将服务器缓存添加到我们的 Web 应用程序中?当我们发现我们的数据库或 Web 服务器成为瓶颈时。通常,它将减少服务器和数据库的负载,并提高响应时间。在购买昂贵的全新服务器之前尝试它肯定值得。但请记住,服务器缓存需要另一个服务(如 Memcached 或 Redis)来监控和维护,并且它也增加了我们应用程序的复杂性。
Node.js 为 Memcached 和 Redis 都提供了驱动程序。让我们将 Redis 添加到我们的应用程序中,并使用它来缓存有关我们用户的数据。我们可以访问redis.io并遵循说明在我们的系统上安装 Redis。一旦安装并运行,我们可以通过使用命令redis-cli启动 Redis 外壳来确认它可用。
让我们更新 npm 清单以安装 Redis 驱动程序,如 列表 9.4 所示。变更以粗体显示:
列表 9.4. 更新 npm 清单以包含 redis—webapp/package.json
{ "name" : "SPA",
"version" : "0.0.3",
"private" : true,
"dependencies" : {
"express" : "3.2.x",
"mongodb" : "1.3.x",
"socket.io" : "0.9.x",
"JSV" : "4.0.x",
"redis" : "0.8.x"
}
}
在我们开始之前,让我们考虑一下我们需要能够用缓存做什么。两个想到的事情是 设置 缓存键值对和 通过键获取 缓存值。我们还可能希望能够 删除 缓存键。有了这些,让我们通过在 lib 目录中创建一个 cache.js 文件并填充它以 node 模块模式和获取、设置和从缓存中删除的方法来设置节点模块。参见 列表 9.5 了解如何将 Node.js 连接到 Redis 并设置缓存文件的框架。
列表 9.5. 启动 redis 缓存—webapp/cache.js
/*
* cache.js - Redis cache implementation
*/
/*jslint node : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global */
// ------------ BEGIN MODULE SCOPE VARIABLES --------------
'use strict';
var
redisDriver = require( 'redis' ),
redisClient = redisDriver.createClient(),
makeString, deleteKey, getValue, setValue;
// ------------- END MODULE SCOPE VARIABLES ---------------
// ---------------- BEGIN PUBLIC METHODS ------------------
deleteKey = function ( key ) {};
getValue = function ( key, hit_callback, miss_callback ) {};
setValue = function ( key, value ) {};
module.exports = {
deleteKey : deleteKey,
getValue : getValue,
setValue : setValue
};
// ----------------- END PUBLIC METHODS -------------------
现在,让我们开始填充这些方法;完成的方法在 列表 9.6 中。我们将从 setValue 开始,因为它是最简单的。Redis 有很多不同的数据类型,根据我们缓存的数据类型,这些类型可能很有用。在这个例子中,我们将坚持使用基本的字符串键值对。使用 Redis 驱动程序设置值就像调用 redis.set( key, value ); 一样简单。因为没有回调,我们将假设这个方法有效,并让调用异步执行并忽略失败。如果我们想的话,我们可以做些更复杂的事情,比如在 Redis 中增加一个值来跟踪失败。我们鼓励感兴趣的读者探索这种方法。
getValue 方法接受三个参数:要搜索的 key、缓存命中的回调(hit_callback)和缓存未命中的回调(miss_callback)。当这个方法被调用时,它请求 Redis 返回与键关联的值。如果命中(值不是 null),它将使用值作为参数调用 hit_callback。如果未命中(值是 null),它将调用 miss_callback。查询数据库的任何逻辑都留给调用者,因为我们希望这段代码专注于缓存。
deleteKey 方法调用 redis.del 并传入 Redis 键。我们不使用回调,因为我们将会异步执行并假设它有效。
makeString 工具用于在我们将键和值呈现给 Redis 之前将它们转换。我们需要这样做,因为否则 Redis Node 驱动程序会在键和值上使用 toString() 方法。这会导致看起来像 [Object object] 的字符串,这不是我们想要的。
我们更新的缓存模块在 列表 9.6 中展示。变更以粗体显示:
列表 9.6. 最终 Redis 缓存文件—webapp/lib/cache.js


现在我们已经设置了缓存文件,我们可以在 crud.js 文件中利用它,通过添加五行代码,如 列表 9.7 所见。变更以粗体显示:
列表 9.7. 从缓存中读取—webapp/lib/crud.js


我们确保当对象被删除时,键从 Redis 数据库中移除。但这远非理想。它不能确保所有缓存的实例数据都被删除;它只能确保与用于删除项目的键关联的缓存数据被移除。例如,我们可以删除刚刚被解雇的员工通过 ID,但用户可能仍然登录并导致系统混乱,因为信息可能使用用户名和密码键进行缓存。在更新对象时也可能出现同样的问题。
这不是一个容易解决的问题,这也是为什么服务器缓存通常被推迟到系统需要扩展时才投入时间解决的原因。一些可能的解决方案包括在一段时间后过期缓存记录(最小化缓存不匹配窗口),在删除或更新用户时清除整个用户缓存(更安全,但会导致更多的缓存未命中),或者手动跟踪缓存对象(对开发者来说更容易出错)。
服务器缓存中有许多更多的机会和挑战——足够写一本书,但希望这足以让你开始。现在让我们看看最终的缓存方法:在数据库中缓存数据。
9.3.5. 数据库查询缓存
查询缓存发生在数据库缓存特定查询的结果时。在关系型数据库中,这尤为重要,因为需要将结果转换为应用程序可以读取的形式。查询缓存存储这个转换后的结果。请查看图 9.6 以了解查询缓存在请求/响应周期中的位置。
图 9.6. 查询缓存

使用 MongoDB,这由操作系统文件系统自动处理。MongoDB 不是缓存特定查询的结果,而是试图将整个索引保持在内存中,当整个数据集可以保持在内存中时,这会导致查询非常快。MongoDB,或者更确切地说,操作系统的子系统内存,将根据服务器的需求动态分配内存。这意味着 MongoDB 将拥有所有可用的空闲 RAM,而无需猜测要分配多少,并且当需要时将自动释放内存给其他进程。像最近最少使用算法这样的缓存行为是根据操作系统的行为工作的。
9.4. 总结
在本章中,我们回答了在托管 SPA 网站时出现的一些常见问题。我们展示了如何调整我们的 SPA 以便它可以被搜索引擎索引,如何使用分析工具(如 Google Analytics),以及如何将应用程序错误记录到服务器上。最后,我们讨论了如何在应用程序的每一层进行缓存,每一层缓存提供的实际好处,以及如何利用它。
我们关于如何构建健壮、可测试和可扩展的 SPA 的建议几乎已经完成。我们强烈建议您阅读附录 A 和 B,因为它们都涵盖了重要主题,并且进行了深入探讨。附录 A 展示了我们在本书的大部分内容中使用的代码标准;附录 B 展示了如何使用测试模式和自动化来轻松识别、隔离和修复软件缺陷。
在本书的第一部分中,我们构建了我们的第一个单页应用(SPA)并讨论了为什么 SPA 对于许多网站来说是一个极佳的选择。特别是,SPA 可以提供传统网站无法比拟的极响应和交互式的用户体验。接下来,我们回顾了一些需要理解以成功实施大型 SPA 的 JavaScript 编程概念。
在第二部分中,我们继续使用经过良好测试的架构设计和实现了 SPA。我们没有使用“框架”库,因为我们想展示 SPA 的内部工作原理。您应该能够使用这个架构来开发自己的 SPA,或者利用必要的经验来学习许多框架库之一,以判断它是否提供了您需要的工具。
在第三部分中,我们设置了一个 Node.js 和 MongoDB 服务器,为我们的 SPA 提供了 CRUD 后端。我们使用了 Socket.IO 来提供客户端和服务器之间响应式和轻量级的全双工通信。我们还消除了在传统网站中经常看到的数据格式之间的数据打包过程。
最后,我们发现整个堆栈都使用 JavaScript 作为其语言,JSON 作为其数据格式。这种优雅的简洁性在开发过程的每一步都提供了累积的好处。例如,使用单一语言提供了在客户端和服务器之间移动和共享代码的机会,这可以显著减少我们代码的大小和复杂性。它还节省了我们的时间,避免了混淆,因为语言或数据格式之间的上下文切换很少。而且好处还扩展到测试阶段,因为我们不仅可以有显著更少的代码需要测试,而且我们还可以几乎为所有代码使用相同的测试框架,而不需要浏览器测试套件的额外开销和费用。
我们希望您喜欢这本书,并且像我们撰写它一样学到很多。继续学习单页网页应用的最佳方式是继续开发它们。我们努力为您提供了使用 JavaScript 端到端完成所需的所有工具。
附录 A. JavaScript 编码标准
本附录涵盖:
-
探讨为什么编码标准很重要
-
一致地布局和记录代码
-
一致地命名变量
-
使用命名空间隔离代码
-
组织文件并确保一致的语法
-
使用 JSLint 验证代码
-
使用体现标准的模板
编码标准是有争议的。几乎每个人都同意你应该有一个,但似乎很少有人同意标准应该是什么。让我们考虑一下为什么编码标准对 JavaScript 尤其重要。
A.1. 为什么我们需要编码标准
对于像 JavaScript 这样的松散类型、动态语言,有一个明确的标准几乎比严格的语言更重要。JavaScript 的极大灵活性可能使其成为编码语法和实践的潘多拉盒子。而严格的语言天生提供结构和一致性,JavaScript 则需要纪律和适用的标准来实现同样的效果。
下面是我们多年来使用和修订的标准。它相当全面和连贯,我们在整本书中一直使用它。在这里的表述并不非常简洁,因为我们添加了许多解释和示例。其中大部分都被压缩成了一份三页的速查表,可在github.com/mmikowski/spa找到。
我们并不自以为是地认为这个编码标准适合所有人:你应该根据自己的需要使用或忽略这个标准。无论如何,我们希望讨论的概念能鼓励你审视自己的实践。我们强烈建议任何团队在开始大型项目之前就达成一致的标准,以避免经历自己的巴别塔。
经验和研究表明,我们将花费更多的时间维护代码而不是编写它。因此,我们的标准更倾向于可读性而不是创建速度。我们发现,编写为了被理解的代码往往在第一次编写时更加仔细和结构化。
我们发现,一个成功的编码标准:
-
最小化编码错误的可能性。
-
结果是适合大规模项目和团队的代码——一致、可读、可扩展和可维护。
-
鼓励代码的效率、效果和重用。
-
鼓励使用 JavaScript 的优势并避免其弱点。
-
被开发团队的每个成员使用。
马丁·福勒曾著名地说:“任何傻瓜都能写出计算机能理解的代码。优秀的程序员写出人类能理解的代码。”虽然明确和全面的标准不能保证 JavaScript 的可读性,但它们确实能有所帮助——就像词典和语法指南有助于确保英语的可读性一样。
A.2. 代码布局和注释
以一致和深思熟虑的方式布局你的代码是提高理解度的一种最佳方式。它也是代码标准中更具争议性的问题之一。¹ 所以当你阅读这一节时,放松一下。喝一杯无咖啡因的拿铁,做一个薄荷茶叶足疗,打开你的心扉。这会很有趣。真的。
¹ 无数的开发者花费了无数小时狂热地争论制表位的使用——如果你需要更多证据,请在互联网上搜索“制表位与空格”。
A.2.1. 为可读性布局你的代码
如果我们从这本书中省略所有标题、标点、空格和大写字母,会怎样呢?嗯,这本书可能会提前几个月出版,但我们的读者可能会发现它难以理解。也许这就是为什么我们的编辑坚持要求我们格式化和应用写作约定,以便你,亲爱的读者,有理解内容的机会。
JavaScript 代码有两个需要理解它的受众——将执行它的机器和将维护或扩展它的人类。通常,我们的代码会被阅读得比编写得多。我们格式化和应用约定到我们的代码中,以便我们的同事开发者(包括几周后的我们自己)有理解内容的机会。
使用一致的缩进和行长度
我们可能都注意到了报纸上的文本栏长度通常在 50 到 80 个字符之间。超过 80 个字符的行对于人眼来说越来越难以跟随。Bradyhurst 的权威著作《版式设计要素》建议行长度在 45-75 个字符之间,以获得最佳的阅读理解和舒适度,其中 66 个字符被认为是最佳选择。
较长的行在电脑显示屏上阅读起来也很有难度。如今,越来越多的网页采用多列布局——尽管这通常被认为实施起来非常昂贵。一个网页开发者愿意付出这样的麻烦,唯一的理由可能是存在长行的问题(或者如果他们按小时收费)。
支持更宽的制表位(4-8 个空格)的人说,这使他们的代码更易读。但他们也经常主张较长的行长度来补偿宽的制表位。我们采取另一种方法:较短的制表位宽度(2 个空格)和较短的行长度(78 个字符)共同作用,提供更窄、更易读的文档,每行内容丰富。短的制表位宽度也认识到,像 JavaScript 这样的事件驱动语言通常比纯过程性语言缩进更多,这是由于回调和闭包的普遍存在。
-
每级代码缩进两个空格。
-
使用空格而不是制表位进行缩进,因为没有制表位位置的标准。
-
限制行长度为 78 个字符。
较窄的文档在所有显示设备上都表现更好,允许个人在两个高清显示器上同时打开六个文件视图,或者轻松地在笔记本电脑、平板电脑或智能手机上的较小屏幕上阅读单个文档。它们也适合作为电子阅读器或印刷书籍格式中的列表,这使得我们的编辑器非常高兴.^([2])
²本书中列表的行长度限制实际上是 72 个字符,失去最后六个字符是痛苦的。
在段落中组织代码
英语和其他书面语言以段落的形式呈现,以帮助读者理解何时一个主题完成,另一个主题要呈现。计算机语言也受益于这一惯例。这些段落可以作为整体进行注释。通过适当使用空白^([3)),我们的 JavaScript 可以像格式良好的书籍一样阅读。
³空白是指空格、换行符或制表的任意组合。但不要使用制表符。
-
将代码组织成逻辑段落,并在每个段落之间留空白行。
-
每行最多包含一个语句或赋值,尽管我们确实允许每行有多个变量声明。
-
在运算符和变量之间留空白,以便更容易地找到变量。
-
每个逗号后都要留空白。
-
在段落内对齐相同类型的运算符。
-
将注释缩进与它们解释的代码相同。
-
在每条语句的末尾放置分号。
-
在控制结构中的所有语句周围放置花括号。控制结构包括
for、if和while构造等。也许最常见的是违反这一指南的做法是省略单行if语句的花括号。不要这样做。始终使用花括号,这样就可以轻松添加语句而不会意外引入错误。
列表 A.1. 不像这样

列表 A.2. 但像这样

当我们布局代码时,我们希望追求清晰度,而不是减少字节数。一旦我们的代码达到生产阶段,我们的 JavaScript 将在到达用户之前被连接、压缩和压缩。因此,我们用来帮助理解的工具——空白、注释和更具描述性的变量名——对性能的影响很小或没有影响。
保持一致的换行
如果语句不超过最大行长度,我们应该将其放在一行上。但这种情况通常是不可能的,所以我们必须将其分成两行或更多行。以下指南将有助于减少错误并提高认知:
-
在运算符之前换行,这样就可以轻松地查看左列中的所有运算符。
-
缩进语句的后续行一个级别,例如在我们的例子中是两个空格。
-
在逗号分隔符后换行。
-
将结束括号或括号单独放在一行上。这清楚地表明了语句的结束,而不必强迫读者水平扫描分号。
列表 A.3. 不像这样

列表 A.4. 但像这样

我们将在附录的稍后部分安装 JSLint,这将帮助我们检查我们的语法。
使用 K&R 风格括号
K&R 风格括号平衡了垂直空间的使用与可读性。在格式化对象和映射、数组、复合语句或调用时应该使用它。复合语句包含一个或多个用大括号括起来的语句。例如包括if、while和for语句。像alert( ‘I have been invoked!' );这样的调用调用一个函数或方法。
-
尽可能使用单行。例如,当短数组声明可以放在一行时,不要不必要地将它拆分为三行。
-
将开括号、花括号或方括号放在开行末尾。
-
在分隔符内缩进代码(括号、花括号或方括号)一级——例如,两个空格。
-
将关闭括号、花括号或方括号放在与开行相同的缩进级别上。
列表 A.5. 不像这样


列表 A.6. 但像这样

调整元素以垂直对齐确实有助于理解,但如果没有强大的文本编辑器,也可能很耗时。Vim、Sublime、WebStorm 等提供的垂直文本选择有助于对齐值。WebStorm 甚至提供了自动对齐映射值的工具,这是一个节省时间的好方法。如果你的编辑器不支持垂直选择,我们强烈建议你考虑更换编辑器。
使用空白空间来区分函数和关键字
许多语言都有文章的概念——像an、a或the这样的词。文章的一个目的是让读者或听者知道下一个词将是名词或名词短语。空白空间可以与函数和关键字一起使用,以达到类似的效果。
-
在函数关键字和开括号
(之间没有空格。 -
在关键字后跟一个空格,然后是其开括号,
(。 -
在格式化 for 语句时,在每个分号后添加一个空格。
列表 A.7. 不像这样

列表 A.8. 但像这样

这种约定与其他动态语言(如 Python、Perl 或 PHP)很常见。
一致引用
我们更喜欢单引号作为字符串分隔符,因为 HTML 标准属性分隔符是双引号。HTML 通常在 SPA 中经常引用。使用单引号的 HTML 分隔符需要更少的字符转义或编码。结果是更短、更容易阅读,且出错的可能性更小。
列表 A.9. 不像这样
html_snip = "<input name=\"alley_cat\" type=\"text\" value=\"bone\">";
列表 A.10. 但像这样
html_snip = '<input name="alley_cat" type="text" value="bone">';
许多语言如 Perl、PHP 和 Bash 都有插值和非插值引号的概念。插值引号会扩展其中找到的变量值,而非插值引号则不会。通常,双引号(")是插值的,而单引号(‘)不是。JavaScript 引号从不插值,但单双引号的使用没有行为上的差异。因此,我们的使用与其他流行语言保持一致。
A.2.2. 注释以解释和记录
注释甚至可能比它们引用的代码更重要,因为它们可以传达一些不明显的关键细节。这在事件驱动编程中尤为明显,因为回调的数量可能会使跟踪代码执行变得耗时。但这并不意味着添加更多的注释总是更好的。战略性地放置、信息丰富且维护良好的注释非常受重视,而一堆不准确的注释可能比没有注释更糟。
战略性地解释代码
我们的标准旨在最小化注释并最大化其价值。我们通过使用约定来使代码尽可能自明来最小化注释。我们通过将它们与描述的段落对齐并确保其内容对读者有价值来最大化它们的值。
列表 A.11. 不像这样
var
welcome_to_the = '<h1>Welcome to Color Haus</h1>',
houses_we_use = [ 'yellow','green','little pink' ],
the_results, make_it_happen, init;
// get house spec
var make_it_happen = function ( house ) {
var
sync = houses_we_use.length,
spec = {},
i;
for ( i = 0; i > sync; i++ ) {
...
// 30 more lines
}
return spec;
};
var init = function () {
// houses_we_use is an array of house colors.
// make_it_happen is a function that returns a map of building specs
//
varthe_results = make_it_happen( houses_we_use );
//And place welcome messageinto our DOM
$('#welcome').text( welcome_to_the );
//And now our specifications
$('#specs').text( JSON.stringify( the_results ) );
};
init();
列表 A.12. 但像这样

一致的、有意义的变量名可以用更少的注释提供更多信息。我们关于变量命名的部分将在附录中稍后出现,但让我们先看看一些亮点。所有指代函数的变量都以动词作为其首词——get_spec_map、run_init。其他变量被命名以帮助我们理解其内容——welcome_html是一个 HTML 字符串,house_color_list是一个颜色名称数组,而spec_map是一个规格映射。这有助于减少我们需要添加或维护的注释数量。
记录你的 API 和待办事项
注释也可以为你的代码提供更正式的文档。但我们需要小心——关于一般架构的文档不应该被埋在几十个 JavaScript 文件中的一个,而应该放入一个专门的架构文档中。但关于函数或对象 API 的文档可以,并且通常应该直接放置在代码旁边。
-
通过指定其目的、使用的参数或设置、返回的值以及抛出的任何异常来解释任何非平凡函数。
-
如果你禁用代码,请用以下格式的注释解释原因:
//TODO 日期 用户名 - 注释。用户名和日期对于决定注释的新鲜度很有价值,也可以由自动化工具用来报告代码库中的待办事项。
列表 A.13. 函数 API 文档示例
// BEGIN DOM Method /toggleSlider/
// Purpose : Extends and retracts chat slider
// Required Arguments :
// * do_extend (boolean) true extends slider, false retracts
// Optional Arguments :
// * callback (function) executed after animation is complete
// Settings :
// * chat_extend_time, chat_retract_time
// * chat_extend_height, chat_retract_height
// Returns : boolean
// * true - slider animation activated
// * false - slider animation not activated
// Throws : none
//
toggleSlider = function( do_extend, callback ) {
// ...
};
// END DOM Method /toggleSlider/
列表 A.14. 禁用代码示例
// BEGIN TODO 2012-12-29 mmikowski - debug code disabled
// alert( warning_text );
// ... (lots more lines) ...
//
// END TODO 2012-12-29 mmikowski - debug code disabled
有些人说,你应该总是立即删除代码,并在需要时从源代码控制中恢复它。但我们发现,注释掉我们可能还会用到的代码比尝试找到禁用代码原始版本的版本然后合并它更有效率。代码禁用一段时间后,你可以安全地将其删除。
A.3. 变量名
你有没有注意到,书籍通常在其代码列表中包含一个临时的命名约定?例如,你会看到像 person_str = ‘fred’; 这样的行。作者通常这样做是因为他不想后来插入一个笨拙的、消耗时间和注意力的提醒,说明变量代表什么。名字本身就是显而易见的。
每个编写代码的人都会使用命名约定,无论他们是否意识到这一点。⁴ 一个好的命名约定在所有团队成员都理解并使用它时提供最大的价值。当他们这样做时,他们就可以从单调的代码追踪和艰难的注释维护中解放出来,并可以专注于代码的目的和逻辑。
⁴ 就像“如果你选择不决定,你仍然做出了选择”(出自 Rush 的《Freewill》,收录于《Permanent Waves》专辑,1980 年)
A.3.1. 使用命名约定减少和改进注释
一致且具有描述性的名称对于企业级 JavaScript 应用程序至关重要,因为它们可以极大地加快认知速度,并有助于避免常见错误。考虑以下完全有效且现实的 JavaScript 代码:
列表 A.15. 示例 A
var creator = maker( 'house' );
现在,让我们使用我们即将讨论的命名约定重写它:
列表 A.16. 示例 B
var make_house = curry_build_item({ item_type : 'house' });
示例 B 显然更具有描述性。根据我们的约定,我们可以得出以下结论:
-
make_house是一个对象构造函数。 -
被调用的函数是一个柯里化函数——它使用闭包来维护状态并返回一个函数。
-
被调用的函数接受一个字符串参数,该参数指示一个
type。 -
变量在局部作用域内。
现在,我们可以通过查看代码的上下文来找出示例 A 的所有这些信息。这可能需要我们花费 5、30 或 60 分钟来追踪所有函数和变量。然后,我们还需要记住所有这些,在处理或围绕这段代码时。这不仅会浪费时间,还可能使我们失去最初想要完成的目标的焦点。
这种可避免的开销将在每次新开发者使用此代码时产生。记住,离开这段代码几周后,任何开发者——包括原始作者——实际上都相当于一个新开发者。显然,这是效率低下且容易出错的。
让我们看看如果使用注释提供与示例 B 相同数量的意义,示例 A 会是什么样子:
列表 A.17. 带注释的示例 A
// 'creator' is an object constructor we get by
// calling 'maker'. The first positional argument
// of 'maker' must be a string, and it directs
// the type of object constructor to be returned.
// 'maker' uses a closure to remember the type
// of object the returned function is to
// meant to create.
var creator = maker( 'house' );
不仅带有注释的示例 A 比示例 B 更冗长,而且编写它也花费了更长的时间,这可能是我们试图传达与命名约定相同数量的信息。更糟糕的是:随着时间的推移,随着代码的变化和开发者的懒惰,注释容易变得不准确。让我们假设我们决定几周后更改几个名称:
列表 A.18. 变量名更改后的示例 A,带有注释

哎呀,我们忘记更新引用我们刚刚更改的变量名的注释了。现在,注释完全错误且具有误导性。不仅如此,所有这些注释都使代码变得混乱,因为列表的长度是九倍。最好一点注释都没有。与如果我们想更改示例 B 中的变量名相比:
列表 A.19. 变量名更改后的示例 B
var make_abode = curry_make_item({ item_type : 'abode' });
这些修订立即正确,因为没有注释需要调整。正如这所示,一个经过深思熟虑的命名约定是自我文档化代码的绝佳方式,由原始作者以更高的精度进行,而不需要注释的杂乱,这些注释几乎无法维护。它有助于加快开发速度,提高质量,并简化维护。
A.3.2. 使用命名指南
变量名可以传达很多信息,正如我们上面所展示的。让我们回顾一些我们认为最有用的指南。
使用公共字符
尽管我们团队中许多人可能认为将变量命名为queensrÿche_album_name很聪明,但试图在键盘上找到ÿ键的人可能会有不同的、并且明显更负面的看法。最好将变量名限制在大多数世界键盘上可用的字符。
-
在变量名中使用 a-z, A-Z, 0-9, 下划线和$字符。
-
不要以数字开头变量名。
通信变量作用域
我们的 JavaScript 文件和模块有一一对应的关系,类似于 Node.js(我们将在附录中详细说明)。我们发现区分在模块中任何地方都可用和作用域更有限的变量是有用的。
-
当变量是全模块作用域时使用驼峰式命名(它可以在模块命名空间的任何地方访问)。
-
当变量不是全模块作用域时使用下划线(模块命名空间内函数的局部变量)。
-
确保所有模块作用域变量至少有两个音节,以便作用域清晰。例如,我们可以在变量
config中使用更描述性和显然是模块作用域的configMap。
认识到变量类型的重要性
虽然 JavaScript 允许你对变量类型进行快速和宽松的处理,但这并不意味着你应该这样做。考虑以下示例:
列表 A.20. 类型隐式转换
var x = 10, y = '02', z = x + y;
console.log ( z ); // '1002'
在这种情况下,JavaScript 将 x 转换为字符串,并将其与 y (02) 连接,得到字符串 1002。这可能不是预期的结果。类型转换的结果可能产生更深远的影响:
列表 A.21. 类型转换的阴暗面
var
x = 10,
z = [ 03, 02, '01' ],
i , p;
for ( i in z ) {
p = x + z[ i ];
console.log( p.toFixed( 2 ) );
}
// Output:
// 13.00
// 12.00
// TypeError: Object 1001 has no method 'toFixed'
我们发现,这种 非故意 的类型转换比 故意 的类型转换更为常见,这通常会导致难以找到和解决的错误。我们很少 故意 改变变量的类型,因为(原因之一)这样做几乎总是太混乱或难以管理,以至于不值得这种好处。5 因此,当我们命名我们的变量时,我们通常希望传达我们打算让它包含的变量类型
⁵ 更新版本的 Firefox 的 JavaScript JIT 编译器认识到这个事实,并使用一种称为 类型推断 的技术,在现实世界的代码中实现 20-30% 的性能提升。
命名布尔值
当布尔值表示状态时,我们使用单词 is;例如,is_retracted 或 is_stale。当我们使用布尔值来指导动作时,比如在函数参数中,我们使用单词 do,例如 do_retract 或 do_extend。当我们使用布尔值来表示所有权时,我们使用 has;例如,has_whiskers 或 has_wheels。表 A.1 展示了一些例子。
表 A.1. 正则表达式命名示例
| 指标 | 局部作用域 | 模块作用域 |
|---|---|---|
| bool [通用] | bool_return | boolReturn |
| do (请求动作) | do_retract | doRetract |
| has (表示包含) | has_whiskers | hasWhiskers |
| is (表示状态) | is_retracted | isRetracted |
命名字符串
我们之前的例子表明,如果我们知道我们正在使用一个字符串变量,那么这很有用。表 A.2 是一个我们常用字符串的指标图表。
表 A.2. 字符串命名示例
| 指标 | 局部作用域 | 模块作用域 |
|---|---|---|
| str [通用] | direction_str | directionStr |
| id (标识符) | email_id | emailId |
| date | email_date | emailDate |
| html | body_html | bodyHtml |
| msg (消息) | employee_msg | employeeMsg |
| name | employee_name | employeeName |
| text | email_text | emailText |
| type | item_type | itemType |
命名整数
JavaScript 不暴露整数作为支持的变量类型,但在许多情况下,除非我们提供整数,否则语言无法正常工作。例如,在遍历数组时,使用浮点数作为索引是不正确的:
var color_list = [ 'red', 'green', 'blue' ];
color_list[1.5] = 'chartreuse';
console.log( color_list.pop() ); // 'blue'
console.log( color_list.pop() ); // 'green'
console.log( color_list.pop() ); // 'red'
console.log( color_list.pop() ); // undefined - where did 'chartreuse' go?
console.log( color_list[1.5] ); // oh, there it is
console.log( color_list ); // shows [1.5: "chartreuse"]
其他内置函数也期望整数值,例如字符串 substr() 方法。因此,当使用数字是整数很重要时,可以使用指标,如 表 A.3 所示。
表 A.3. 整数命名示例
| 指标 | 局部作用域 | 模块作用域 |
|---|---|---|
| int [通用] | size_int | sizeInt |
| none (约定) | i, j, k | (不允许在模块作用域中) |
| count | employee_count | employeeCount |
| index | employee_index | employeeIndex |
| time(毫秒) | retract_time | retractTime |
命名数字
如果理解我们正在处理非整数数字很重要,我们可以使用其他指标(见 表 A.4)。
表 A.4. 示例数字名称
| 指标 | 本地作用域 | 模块作用域 |
|---|---|---|
| num [通用] | size_num | sizeNum |
| none(约定) | x, y, z | (在模块作用域中不允许) |
| coord(坐标) | x_coord | xCoord |
| ratio | sales_ratio | salesRatio |
命名正则表达式
我们通常在正则表达式前加上 regex,如 表 A.5 中所示。
表 A.5. 示例正则表达式名称
| 指标 | 本地作用域 | 模块作用域 |
|---|---|---|
| regex | regex_filter | regexFilter |
命名数组
在命名数组时,我们发现以下几条准则很有用:
-
数组变量名应该是一个单数名词,后跟“list”这个词。
-
对于模块作用域的数组,首选名词-“List”形式。
表 A.6 展示了一些示例。
表 A.6. 示例数组名称
| 指标 | 本地作用域 | 模块作用域 |
|---|---|---|
| list | timestamp-list | timestampList |
| list | color_list | colorList |
命名映射
JavaScript 官方并没有 map 数据类型——它只有对象。但我们发现区分仅用于存储数据(maps)的简单对象和功能齐全的对象是有用的。这种映射结构类似于 Java 中的 map,Python 中的 dict,PHP 中的 关联数组,或 Perl 中的 hash。
当我们命名一个映射时,我们通常希望强调开发者的意图并在名称中包含 map 这个词。通常结构是一个名词后跟 map 这个词,并且总是单数。见 表 A.7 中的示例映射名称。
表 A.7. 示例映射名称
| 指标 | 本地作用域 | 模块作用域 |
|---|---|---|
| map | employee_map | employeeMap |
| map | receipt_timestamp_map | receiptTimestampMap |
有时映射的键是一个不寻常或区分性的特征。在这种情况下,我们在名称中指明键,例如,receipt_timestamp_map。
命名对象
对象通常有一个具体的“现实世界”对应物,我们相应地命名它们:
-
对象变量名应该是一个名词,后跟一个可选的修饰符——
employee或receipt。 -
确保模块作用域的对象变量名有两个或更多音节,以便作用域清晰——
storeEmployee或salesReceipt。 -
在 jQuery 对象前加上 $. 这是当今的一个常见约定,jQuery 对象(或有时称为集合)在单页应用(SPAs)中很常见。
表 A.8 展示了一些示例。
表 A.8. 示例对象名称
| 指标 | 本地作用域 | 模块作用域 |
|---|---|---|
| none(单数名词) | 员工 | 店铺员工 |
| none(单数名词) | 收据 | 销售收据 |
| $ | $area_tabs | $areaTabs |
如果我们期望 jQuery 集合包含多个条目,我们将其变为复数。
函数命名
函数几乎总是对一个对象执行操作。因此,我们总是喜欢将动作动词作为函数名称的第一部分:
-
函数名应始终包含一个动词后跟一个名词,例如
get_record或empty_cache_map。 -
模块作用域的函数应始终包含两个或更多音节,以便作用域清晰,例如
getRecord或emptyCacheMap。 -
使用一致的动词意义。表 A.9 显示了常见动词的一致意义。
表 A.9. 示例函数名称
指示符 指示符的含义 本地作用域 模块作用域 fn [通用] 通用函数指示符。 fn_sync fnSync curry 返回由参数指定的函数。 curry_make_user curryMakeUser destroy, remove 删除数据结构,例如数组。意味着数据引用将根据需要整理。 destroy_entry, remove_element destroyEntry, removeElement empty 在不删除容器的情况下删除数据结构的一些或全部成员——例如,删除数组中的所有元素但保持数组完整。 empty_cache_map emptyCacheMap fetch 从外部源返回数据,例如从 AJAX 或 WebSocket 调用。 fetch_user_list fetchUserList get 从对象或其他内部数据结构返回数据。 get_user_list getUserList make 返回新构造的对象(不使用 new 操作符) make_user makeUser on 事件处理器。事件应为一个单词,如 HTML 标记中的用法。 on_mouseover onMouseover save 将数据保存到对象或其他内部数据结构。 save_user_list saveUserList set 初始化或更新由参数提供的值。 set_user_name setUserName store 将数据发送到外部源进行存储,例如通过 AJAX 调用。 store_user_list storeUserList update 与 set 类似,但有“之前已初始化”的含义 update_user_list updateUserList
我们找到了 make 构造函数动词,以及 fetch/get 和 store/save 之间的区别,这在跨开发团队传达意图方面特别有价值。此外,使用 onEventname 作为事件处理器已成为常见且有用。一般形式为 ononMouseover 不是 onMouseOver,或 on_dragstart 不是 on_drag_start。
未知类型变量命名
有时我们真的不知道我们的变量包含什么数据类型。这种情况通常有两种情况:
-
我们编写一个多态函数——一个可以接受多种数据类型的函数。
-
我们从外部数据源接收数据,例如 AJAX 或 WebSocket 数据流。
在这些情况下,变量的主要特征是其数据类型的不可确定性。我们确定了一个做法,确保名字中包含单词 data(见 表 A.10)。
表 A.10. 示例数据名称
| 本地作用域 | 模块作用域 | 备注 |
|---|---|---|
| http_data, socket_data | httpData, socketData | 从 HTTP 源或 WebSocket 接收到的未知数据类型 |
| arg_data, data | --- | 接收到的参数数据类型未知 |
现在我们已经审查了我们的命名指南,让我们开始使用它们。
A.3.3. 应用指南
在我们应用命名指南之前和之后,让我们比较一下对象原型的变化。
列表 A.22. 不像这样

列表 A.23. 但像这样

这些示例是从两篇网页示例中摘录的——可以在本书资源中找到的 [listings/apx0A/bad_dog.html](http://listings/apx0A/bad_dog.html) 和 [listings/apx0A/good_dog.html](http://listings/apx0A/good_dog.html)。我们鼓励您下载并比较它们,看看哪个更易于理解和维护。
A.4. 变量声明和赋值
变量可以被赋值为函数指针、对象指针、数组指针、字符串、数字、null 或 undefined。一些 JavaScript 实现可能在整数、32 位有符号和 64 位双精度浮点数之间进行内部区分,但没有正式的接口来强制这种类型。
-
使用
{}或[]来创建新的对象、映射或数组,而不是使用new Object()或new Array()。记住,映射是一个没有方法的简单数据对象。如果您需要对象继承,请使用 第二章 和本附录的 A.5 节中展示的createObject工具。 -
使用工具来复制对象和数组。当变量被赋值时,简单的变量,如布尔值、字符串或数字会被复制。例如,
new_str = this_str将会将底层数据(在这种情况下,是一个字符串)复制到new_str。JavaScript 中的复杂变量,如数组和对象,在赋值时不会被复制;相反,数据结构的指针会被复制。例如,second_map = first_map将导致second_map指向与first_map相同的数据,并且对second_map的任何操作都会反映在first_map上。正确地复制数组和对象并不总是显而易见或容易。我们强烈建议使用经过良好测试的工具来完成此目的,例如 jQuery 提供的工具。 -
首先在功能范围内使用单个
var关键字明确声明所有变量。JavaScript 通过函数来管理变量作用域,并且不提供块级作用域。因此,如果你在函数内部任何地方声明一个变量,在函数调用时它将被立即初始化为undefined的值。将所有变量声明放在前面可以识别这种行为。这也有助于使代码更易于阅读和检测未声明的变量(这是不可接受的)。

声明一个变量与向其赋值不同:声明通知 JavaScript 引擎该变量存在于某个作用域中。赋值为变量提供一个值(而不是undefined)。为了方便,你可以使用var语句将声明和赋值结合起来,但这不是必需的。
-
不要使用代码块,因为 JavaScript 不提供块级作用域.^([6]) 在代码块中定义变量可能会让熟悉其他 C 系列语言的程序员感到困惑。请在功能作用域中定义变量。
⁶ 这基本上是正确的,但截至版本 1.7,Firefox 的 JavaScript 引入了
let语句,它可以用来提供块级作用域。但是,它不是所有主流浏览器都支持的,因此应该忽略。 -
将所有函数分配给变量。这强调了 JavaScript 将函数视为一等对象的事实。
// BAD function getMapCopy( arg_map ) { ... }; // GOOD var getMapCopy = function ( arg_map ) { ... }; -
在函数中需要三个或更多参数时,请使用命名参数,因为位置参数容易忘记,并且不具备自文档化的特性。
// BAD var coor_map = refactorCoords( 22, 28, 32, 48); // BETTER var coord_map = refactorCoords({ x1:22, y1:28, x2:32, y2:48 }); -
每个变量赋值使用一行。如果可能,按字母顺序或逻辑分组排序。多个声明可以放在同一行上:
![]()
A.5. 函数
函数在 JavaScript 中扮演着核心角色:它们组织代码,提供变量作用域的容器,并提供执行上下文,可以用来构建基于原型的对象。因此,尽管我们对函数的指导原则不多,但我们非常重视它们。
-
使用工厂模式进行对象构造,因为它更好地说明了 JavaScript 对象的实际工作方式,速度快,并且可以用来提供类似类的功能,如对象计数。
var createObject,extendObject, sayHello, sayText, makeMammal, catPrototype, makeCat, garfieldCat; // **Utility function to set inheritance // Cross-browser method to inherit Object.create() // Newer js engines (v1.8.5+) support itnatively var objectCreate =function (arg ) { if ( ! arg ) { return {}; } function obj() {}; obj.prototype =arg; return new obj; }; Object.create = Object.create|| objectCreate; // **Utility function to extend an object extendObject = function ( orig_obj, ext_obj ) { varkey_name; for( key_name in ext_obj ){ if ( ext_obj.hasOwnProperty( key_name ) ) { orig_obj[ key_name ] = ext_obj[ key_name ]; } } }; // ** object methods... sayHello = function () { console.warn( this.hello_text + ' says ' + this.name ); }; sayText = function ( text ) { console.warn( this.name + ' says ' + text ); }; // ** makeMammal constructor makeMammal = function ( arg_map ) { var mammal = { is_warm_blooded : true, has_fur : true, leg_count : 4, has_live_birth : true, hello_text : 'grunt', name : 'anonymous', say_hello : sayHello, say_text : sayText }; extendObject( mammal, arg_map ); return mammal; }; // ** use mammal constructor to create cat prototype catPrototype = makeMammal({ has_whiskers : true, hello_text : 'meow' }); // ** cat constructor makeCat = function( arg_map ) { var cat = Object.create( catPrototype ); extendObject( cat, arg_map ); return cat; }; // ** cat instance garfieldCat = makeCat({ name : 'Garfield', weight_lbs : 8.6 }); // ** cat instance method invocations garfieldCat.say_hello(); garfieldCat.say_text('Purr...'); -
避免使用伪类对象构造函数——那些带有
new关键字的。如果你不带new关键字调用这样的构造函数,全局命名空间会被破坏。如果你必须保留这样的构造函数,它的第一个字母应该大写,以便识别为伪类构造函数。 -
在使用函数之前声明所有函数——记住,声明函数与赋值给它们是不同的。
-
当函数需要立即调用时,将函数用括号括起来,以便清楚地表明产生的值是函数的结果,而不是函数本身:
spa.shell = (function () { ... }());
A.6. 命名空间
早期的许多 JavaScript 代码相对较小,并且单独在单个网页上使用。这些脚本可以使用全局变量,几乎没有影响。但随着 JavaScript 应用程序变得更加雄心勃勃,第三方库变得普遍,其他人想要全局 i 变量的可能性急剧增加。当两个代码库声称相同的全局变量时,所有的事情都可能变得混乱。^([[7)]
作者曾经在一个应用程序中工作,其中一个第三方库突然错误地声明了全局变量
util(他们本应该使用 JSLint...)。尽管我们的应用程序只有三个命名空间,util就是其中之一。这种冲突导致我们的应用程序崩溃,诊断和解决这个问题花了四个小时。我们非常不满意。
我们可以通过在单个全局函数内部使用所有其他变量作为示例来大大减少这个问题:
var spa = (function () {
// other code here
var initModule = function () {
console.log( 'hi there' );
};
return { initModule : initModule };
}());
我们称这个单独的全局函数(在这个例子中是 spa)为我们的 命名空间。我们分配给它的函数在加载时执行,当然,在该函数内部分配的任何局部变量都不会对全局命名空间可用。请注意,我们确实使 init-Module 方法可用。因此,其他代码可以调用初始化函数,但不能访问其他内容。而且它必须使用我们的 spa 前缀:
// from another library, call the spa initialization function
spa.initModule();
我们可以细分命名空间,这样我们就不必被迫将一个 50KB 的应用程序压缩到一个文件中。例如,我们可以创建 spa、spa.shell 和 spa.slider 的命名空间:
// In the file spa.js:
var spa = (function () {
// some code here
}());
// In the file spa.shell.js:
var spa.shell = (function () {
// some code here
}());
// In the file spa.slider.js:
var spa.slider = (function () {
// some code here
}());
这种命名空间对于在 JavaScript 中创建可管理的代码至关重要。
A.7. 文件名和布局
命名空间是我们文件命名和布局的基础。以下是一些一般性指南:
-
使用 jQuery 进行 DOM 操作。
-
在构建自己的代码之前调查第三方代码,如 jQuery 插件——平衡集成成本和膨胀与标准化和代码一致性的好处。
-
避免在 HTML 中嵌入 JavaScript 代码;使用外部库代替。
-
在上线前压缩、混淆和 gzip JavaScript 和 CSS。例如,在准备阶段使用 Uglify 压缩和混淆 JavaScript,在交付时使用 Apache2/ mod_gzip 对文件进行 gzip。
JavaScript 文件指南如下:
-
在我们的 HTML 中首先包含第三方 JavaScript 文件,这样它们的函数就可以被评估并准备好用于我们的应用程序。
-
按命名空间的顺序包含我们的 JavaScript 文件。例如,如果根命名空间
spa尚未加载,则无法加载命名空间spa.shell。 -
给所有 JavaScript 文件添加 .js 后缀。
-
将所有静态 JavaScript 文件存储在名为
js的目录下。 -
根据提供的命名空间命名 JavaScript 文件,每个文件一个命名空间。例如:
spa.js // spa.* namespace spa.shell.js // spa.shell.* namespace spa.slider.js // spa.slider.* namespace -
使用模板开始任何 JavaScript 模块文件。一个可以在本附录的末尾找到。
我们在 JavaScript 和 CSS 文件以及类名之间保持平行结构:
-
为每个生成 HTML 的 JavaScript 文件创建一个 CSS 文件。例如:
spa.css // spa.* namespace spa.shell.css // spa.shell.* namespace spa.slider.css // spa.slider.* namespace -
给所有 CSS 文件添加 .css 后缀。
-
将所有 CSS 文件存储在名为 css 的目录下。
-
根据模块的名称前缀 CSS 选择器。这种做法有助于极大地避免与第三方模块的类产生意外交互。例如:
spa.css defines #spa, .spa-x-clearall spa.shell.css defines #spa-shell-header, #spa-shell-footer, .spa-shell-main -
使用
-x- 为状态指示符和其他共享类名。例如包括spa-x-select和spa-x-disabled。将这些放在根命名空间样式表中,例如spa.css。
这些是简单的指南,易于遵循。结果的组织和一致性使得 CSS 和 JavaScript 之间的关联更容易理解。
A.8. 语法
本节是对 JavaScript 语法以及我们遵循的指南的概述。
A.8.1. 标签
语句标签是可选的。只有以下这些语句应该有标签:while、do、for、switch。标签应始终为大写单数名词:
var
horseList = [ Anglo-Arabian', 'Arabian', 'Azteca', 'Clydsedale' ],
horseCount = horseList.length,
breedName, i
;
HORSE:
for ( i = 0; i < horseCount; i++ ) {
breedName = horseList[ i ];
if ( breedName === 'Clydsedale' ) { continue HORSE; }
// processing for non-bud horses follows below
// ...
}
A.8.2. 语句
下文列出了常见的 JavaScript 语句,以及我们建议的使用方式。
Continue
我们避免使用 continue 语句,除非我们使用标签。否则,它往往会模糊控制流。标签的包含也使得 continue 更健壮。
// discouraged
continue;
// encouraged
continue HORSE;
DO
do 语句应具有以下形式:
do {
// statements
} while ( condition );
总是以分号结束 do 语句。
For
for 语句应具有以下形式之一:
for ( *initialization; condition; update* ) {
// statements
}
for ( *variable in object* ) {
if ( *filter* ) {
// statements
}
}
第一种形式应与数组和已知迭代次数的循环一起使用。
第二种形式应与对象和映射一起使用。请注意,添加到对象原型的属性和方法将包含在枚举中。使用 hasOwnProperty 方法来过滤真正的属性:
for ( *variable in object* ) {
if ( object.hasOwnProperty( *variable* ) ) {
// statements
}
}
If
if 语句应具有以下形式之一。else 关键字应开始其自己的行:
if ( *condition* ) {
// statements
}
if ( *condition* ) {
// statements
}
else {
// statements
}
if ( *condition* ) {
// statements
}
else if ( *condition* ) {
// statements
}
else {
// statements
}
Return
return 语句不应在返回值周围使用括号。返回值表达式必须与 return 关键字在同一行上开始,以避免插入分号。
Switch
switch 语句应具有以下形式:
switch ( *expression* ) {
case expression:
// statements
break;
case *expression*:
// statements
break;
default:
// statements
}
每组语句(除了默认情况)应结束于 break、return 或 throw;只有在使用标签和伴随注释时才应使用 fall-through,并且即使如此,也应重新考虑其必要性。简洁性真的值得牺牲可读性吗?可能不是。
Try
try 语句应具有以下形式之一:
try {
// statements
}
catch ( *variable* ) {
// statements
}
try {
// statements
}
catch ( *variable* ) {
// statements
}
finally {
// statements
}
While
while 语句应具有以下形式:
while ( *condition* ) {
// statements
}
应避免使用 while 语句,因为它们往往会引起无限循环条件。尽可能使用 for 语句。
With
应避免使用 with 语句。使用 object.call() 方法族来调整函数调用期间 this 的值。
A.8.3. 其他语法
当然,JavaScript 不仅仅是标签和语句。以下是我们遵循的一些额外指南:
避免使用逗号运算符
避免使用逗号运算符(如在某些for循环结构中找到的)。这不适用于逗号分隔符,它在对象字面量、数组字面量、var 语句和参数列表中使用。
避免使用赋值表达式
避免在if和while语句的条件部分使用赋值——不要写成if ( a = b ) { ...,因为这不清楚你是否打算测试相等性或成功的赋值。
总是使用 === 和 !== 比较运算符
几乎总是最好使用===和!==运算符。==和!=运算符会进行类型强制转换。特别是,不要使用==来比较非真值。我们的 JSLint 配置不允许类型强制转换。如果你想测试一个值是否为真值或假值,可以使用如下结构:
if ( is_drag_mode ) { // is_drag_mode is truthy!
runReport();
}
避免混淆加号和减号
注意不要将一个+后面直接跟另一个+或++。这种模式可能会令人困惑。在它们之间插入括号以明确你的意图。
// confusing:
total= total_count + +arg_map.cost_dollars;
// better:
total = total_count + (+arg_map.cost_dollars);
这可以防止+ +被误读为++。同样的规则也适用于减号,-。
不要使用 Eval
小心——eval有邪恶的别名。不要使用Function构造函数。不要将字符串传递给setTimeout或setInterval。使用解析器而不是eval将 JSON 字符串转换为内部数据结构。
A.9. 验证代码
JSLint 是由 Douglas Crockford 编写和维护的 JavaScript 验证工具。它非常受欢迎且非常有用,可以查找代码错误并确保遵循基本指南。如果你正在创建专业级的 JavaScript,你应该使用 JSLint 或类似的验证器。它帮助我们避免了许多类型的错误,并显著缩短了开发时间。
A.9.1. 安装 JSLint
-
从
code.google.com/p/jslint4java/下载最新的 jslint4java 发行版,例如jslint4java-2.0.2.zip。 -
根据您平台上的说明进行解包和安装。
如果你正在运行 OS X 或 Linux
你可以将 jar 文件移动,例如sudo mv jslint4java-2.0.2.jar /usr/local/lib/,然后在/usr/local/bin/jslint中创建以下包装器:
#!/bin/bash
# See http://code.google.com/p/jslint4java/
for jsfile in $@;
do /usr/bin/java \
-jar /usr/local/lib/jslint4java-2.0.1.jar \
"$jsfile";
done
确保 jslint 是可执行的——sudo chmod 755 /usr/local/bin/jslint
如果你已经安装了 Node.js,你可以安装不同的版本,例如:npm install-g jslint。这个版本运行得更快,尽管它没有与本书中的列表进行测试。
A.9.2. 配置 JSLint
我们的模块模板包括 JSLint 的配置。这些设置用于匹配我们的编码标准:
/*jslint browser : true, continue : true,
devel : true, indent : 2, maxerr : 50,
newcap : true, nomen : true, plusplus : true,
regexp : true, sloppy : true, vars : false,
white : true
*/
/*global $, spa, <other external vars> */
-
browser : true—允许浏览器关键字如document、history、clearInterval等。 -
continue : true—允许continue语句。 -
devel : true—允许开发关键字如alert、console等。 -
indent : 2—期望两个空格缩进。 -
maxerr : 50—在 50 个错误后终止 JSLint。 -
newcap : true—容忍前导下划线。 -
nomen : true—容忍未大写的构造函数。 -
plusplus : true—容忍 ++ 和 --。 -
regexp : true—允许有用但可能危险的正则表达式构造。 -
sloppy : true—不要求使用use strict预言。 -
vars : false—不允许在功能作用域内多次使用var语句。 -
white : true—禁用 JSLint 的格式检查。
A.9.3. 使用 JSLint
我们可以在需要检查代码有效性时随时从命令行使用 JSLint。语法如下:
jslint filepath1 [filepath2, ... filepathN]
# example: jslint spa.js
# example: jslint *.js
我们编写了一个 git 提交钩子,在允许将更改的 JavaScript 文件提交到仓库之前,对其进行测试。以下 shell 脚本可以添加为 repo/.git/hooks/pre-commit。
#!/bin/bash
# See www.davidpashley.com/articles/writing-robust-shell-scripts.html
# unset var check
set -u;
# exit on error check
# set -e;
BAIL=0;
TMP_FILE="/tmp/git-pre-commit.tmp";
echo;
echo "JSLint test of updated or new *.js files ...";
echo " We ignore third_party libraries in .../js/third_party/...";
git status \
| grep '.js$' \
| grep -v '/js/third_party/' \
| grep '#\s\+\(modified\|new file\)' \
| sed -e 's/^#\s\+\(modified\|new file\):\s\+//g' \
| sed -e 's/\s\+$//g' \
| while read LINE; do
echo -en " Check ${LINE}: ... "
CHECK=$(jslint $LINE);
if [ "${CHECK}" != "" ]; then
echo "FAIL";
else
echo "pass";
fi;
done \
| tee "${TMP_FILE}";
echo "JSlint test complete";
if grep -s 'FAIL' "${TMP_FILE}"; then
echo "JSLint testing FAILED";
echo " Please use jslint to test the failed files and ";
echo " commit again once they pass the check.";
exit 1;
fi
echo;
exit 0;
您可能需要根据您的需求对其进行一些修改。另外,请确保它是可执行的(在 Mac 或 Linux 中,chmod 755 pre-commit)。
A.10. 模块模板
经验表明,将模块分解为一致的章节是一种有价值的实践。它有助于我们的理解和导航,并提醒我们良好的编码实践。在数百个模块和多个项目中,我们确定下来的模板如下,其中穿插了一些示例代码:
列表 A.24. 推荐的模块模板


A.11. 摘要
对于一个或多个开发者高效工作,需要良好的编码标准。我们提出的标准是全面且一致的,但我们认识到它可能并不适合每个团队。无论如何,我们希望它能鼓励我们的读者思考常见问题以及公约如何解决或减轻这些问题。我们强烈建议任何团队在开始大型项目之前就达成一致的标准。
代码将被阅读得多于编写,因此我们优化了可读性。我们限制行宽为 78 个字符,并使用两个空格缩进。我们不允许使用制表符。我们将行分组为逻辑段落,以帮助读者理解我们的意图,并保持行的一致性。我们使用 K&R 风格进行括号,并使用空格来区分关键字和函数。我们倾向于在定义字符串字面量时使用单引号。我们更喜欢使用公约而不是注释来传达代码的功能。描述性和一致的变量名是传达我们的意图的关键,而不需要过度使用注释。当我们注释时,我们通过段落进行战略性的文档记录。非平凡的内部接口是一致记录的。
我们通过使用命名空间来保护我们的代码不受其他脚本的不当交互。我们使用自执行函数来提供命名空间。我们将根命名空间细分以组织我们的代码,并提供合理的文件大小和范围。我们的 JavaScript 文件每个都包含一个命名空间,它们的文件名反映了它们提供的命名空间。我们为 CSS 选择器和文件创建了一个并行命名空间。
我们安装并配置了 JSLint。在允许代码被提交到我们的代码库之前,我们总是使用 JSLint 来验证我们的代码。我们使用一致的设置进行验证。我们展示了一个模块模板,它体现了许多提出的约定,并在头部包含了我们的 JSLint 设置。
编码标准旨在通过引入一种通用方言和一致的结构,使开发者从繁琐的任务中解放出来。这使他们能够将创造性的精力集中在重要的逻辑上。一个好的标准为大型项目的成功提供了至关重要的意图清晰度。
附录 B. 测试 SPA
本附录涵盖
-
设置测试模式
-
选择测试框架
-
设置 nodeunit
-
创建测试套件
-
调整 SPA 模块以进行测试设置
本附录基于我们在第八章中完成的代码。在开始之前,你应该有第八章中的项目文件,因为我们将在此基础上添加内容。我们建议您将第八章中创建的整个目录结构复制到一个新的“appendix_B”目录中,并在那里更新它们。
我们是测试驱动开发的粉丝,并参与过gonzo项目,其中测试的生成是自动化的。使用排列工具通过简单地描述 API 及其预期行为来自动生成数千个回归测试。如果开发者修改了代码,它必须通过回归测试后才能被提交到仓库。当引入新的 API 时,开发者将描述添加到配置中,然后自动生成数百或数千个新测试。这种做法导致了卓越的质量,因为代码覆盖率很高,我们很少出现任何类型的回归。
虽然我们喜欢这类回归测试,但在这个附录中我们不会如此雄心勃勃。我们只有足够的空间和时间让你试试水,而不是给你洗个澡。相反,我们将设置测试模式,讨论它们的使用,然后使用 jQuery 和一个测试框架创建一个测试套件。我们测试的时间比我们希望的更晚——我们更喜欢在编写代码的同时编写测试,因为这有助于阐明代码应该做什么。而且,就像是为了证明这一点一样,我们在编写这个附录时发现了并修复了两个问题。^([1)] 现在让我们讨论我们希望 SPA 拥有的测试模式。
¹ 如果您想知道,它们是:1) 在注销时,在线人员列表没有被正确清除,2) 在聊天者的头像更新后,对
spa.model.chat.get_chatee()的调用返回了一个过时的对象。这两个错误已在第六章中修复。
B.1. 设置测试模式
在开发 SPA 时,我们至少使用四种不同的测试模式。这些模式通常应按以下顺序使用:
-
使用模拟数据(模式 1)在不使用浏览器的情况下测试模型。
-
使用模拟数据(模式 2)测试用户界面。
-
使用实时数据(模式 3)在不使用浏览器的情况下测试模型。
-
使用实时数据(模式 4)测试模型和用户界面。
我们需要能够轻松地在测试模式之间切换,以便我们能够快速识别、隔离和解决问题。这个目标的推论是,我们应该为所有模式使用相同的代码。我们希望在无需浏览器的情况下运行测试(模式 1 和 3),以及在带有浏览器的情况下运行测试(模式 2 和 4)。
图 B.1 显示了我们在使用模拟数据(模式 1)不使用浏览器测试模型时使用的模块。这种测试模式通常应首先使用,以确保模型 API 按设计工作。
图 B.1. 使用假数据(模式 1)在没有浏览器的情况下测试模型

图 B.2 展示了在用假数据(模式 2)测试用户界面时使用的模块。这是在模型测试之后隔离视图和控制器相关错误的好模式。
图 B.2. 使用假数据(模式 2)测试视图和控制器

图 B.3 展示了在用实时数据(模式 3)测试模型而不使用浏览器时使用的模块。这有助于隔离服务器 API 的问题。
图 B.3. 使用测试套件和实时数据(模式 3)测试模型

图 B.4 展示了在用实时数据(模式 4)测试用户界面时使用的模块。这允许用户测试整个栈,实际上就是整个应用程序。测试狂热者(或者像我们这样的有志狂热者)称这为 集成测试。
图 B.4. 使用实时数据(模式 4)进行集成测试

如果我们在其他模式下做好测试工作,我们可以在模式 4 中找到的问题数量就会最小化。一旦我们在模式 4 中找到问题,我们应该尝试在更简单的模式下隔离它,从模式 1 开始。当谈到有效地解决问题时,模式 4 就像月亮——它是一个有趣的地方去参观,但你不想在那里生活。
在本节中,我们将进行必要的更改,以便我们可以使用浏览器界面同时使用实时数据和假数据(模式 2 和 4)。以下是我们需要做的事情:
-
创建
spa.model.setDataMode模型方法以在假数据和实时数据之间切换。 -
在初始化时更新 Shell 以检查 URI 查询参数
fake的值。然后使用spa.model.setDataMode设置数据模式。
spa.model.setDataMode 方法很容易添加到模型中,因为我们只需要更改模块作用域的 isFakeData 变量。以下列表显示了更新。更改以 粗体 显示:
列表 B.1. 将 setDataMode 添加到模型中—webapp/public/js/spa.model.js

我们下一步是调整 Shell,使其在初始化时读取 URI 查询参数,然后调用 spa.model.setDataMode(你知道,我们刚刚添加的方法)。这个更改是手术性的,如下列所示。更改以 粗体 显示:
列表 B.2. 在 Shell 中设置数据模式—webapp/public/js/spa.shell.js
...
//------------------- BEGIN PUBLIC METHODS -------------------
// Begin Public method /initModule/
...
//
initModule = function ( $container ) {
var data_mode_str;
// set data to fake if URI query argument set
data_mode_str
= window.location.search === '?fake'
? 'fake' : 'live';
spa.model.setDataMode( data_mode_str );
// load HTML and map jQuery collections
stateMap.$container = $container;
$container.html( configMap.main_html );
setJqueryMap();
...
首先,让我们进入我们的 webapp 目录并安装模块(npm install),然后启动节点应用程序(node app.js)。当我们用 fake 标志打开浏览器文档(http://localhost:3000/spa.html?fake)时,将使用假数据与界面(模式 2)进行测试。如果我们不使用 fake 标志打开浏览器文档(http://localhost:3000/spa.html),则将使用实时数据(模式 4)。在接下来的章节中,我们将讨论如何在没有浏览器的情况下测试我们的 SPA(模式 1 和 3)。首先,让我们决定一个测试框架。
² 是的,我们知道查询参数解析是一个 hack。在生产环境中,我们会使用一个更健壮的库函数。
B.2. 选择测试框架
我们设计我们的 SPA 架构,以便我们可以轻松地在不使用浏览器的情况下测试模型。我们发现,当模型完全按设计工作,修复用户界面错误的开销往往微不足道。我们还发现,人类在界面测试方面通常(但不总是)比脚本更有效。
我们将使用 Node.js 来测试模型,而不是浏览器。这将使我们能够在开发期间和部署之前轻松自动运行测试套件。而且因为我们不依赖于浏览器,测试编写、维护和扩展都更简单。
Node.js 有许多经过多年使用和改进的测试框架。让我们明智地选择一个,而不是自己编写。以下是我们发现的一些有趣的框架列表:^([3])
³ 请参阅
github.com/joyent/node/wiki/modules#testing以获取完整的列表。
-
jasmine-jquery—可以“监视”jQuery 事件。
-
mocha—流行的测试框架,类似于 nodeunit 但具有更好的报告功能。
-
nodeunit—流行的,具有简单而强大的工具。
-
patr—使用 promises(类似于 jQuery
$.Deferred对象)进行异步测试。 -
vows—流行的异步 BDD 框架。
-
zombie—流行的全栈无头测试框架,具有 WebKit 引擎。
Zombie 是一个包容性的测试框架,旨在测试用户界面以及模型。它甚至包括自己的 WebKit 渲染引擎实例,以便测试可以检查渲染的元素。我们不会在这里追求这种测试,因为它安装、设置和维护成本高昂且繁琐——这是一个附录,不是另一本书。尽管我们发现 jasmine-jquery 和 patr 因其列出的原因而有趣,但我们觉得它们没有我们需要的支持水平。Mocha 和 vows 很流行,但我们想从简单开始。
这就留下了 nodeunit,它流行、强大、简单,并且与我们的 IDE 集成良好。让我们设置它。
B.3. 设置 nodeunit
在我们安装 nodeunit 之前,我们需要确保 Node.js 已按第七章中概述的方式安装。一旦 Node.js 可用,我们需要安装两个npm包来使 node-unit 准备好运行我们的测试套件:
-
jquery—我们需要安装 jQuery 的 Node.js 版本,因为我们的模型使用全局自定义事件,这需要 jQuery 和 jquery.event.gevent 插件。作为额外的奖励,安装此包提供了一个模拟的浏览器环境。所以如果我们想测试 DOM 操作,我们就可以做到。 -
nodeunit—这提供了 nodeunit 命令行工具。当我们运行测试套件时,我们将使用nodeunit命令而不是node。
我们喜欢在系统范围内安装这些包,这样它们就可以被所有 Node.js 项目使用。我们可以使用 -g 开关并将它们作为 root(或在 Windows 上是管理员)安装。以下适用于 Linux 和 Mac:
列表 B.3. 在系统范围内安装 jQuery 和 nodeunit
$ sudo npm install -g jquery
$ sudo npm install -g nodeunit
注意,你可能需要通过设置 NODE_PATH 环境变量来告诉你的执行环境在哪里可以找到系统 Node.js 库。在 Linux 或 Mac 上,这可以通过向你的 ~/.bashrc 文件中添加以下内容来完成:
$ echo 'export NODE_PATH=/usr/lib/node_modules' >> ~/.bashrc
这将确保每次你启动一个新的终端会话时都会设置 NODE_PATH。[1] 现在,我们已经安装了 Node.js、jQuery 和 nodeunit,让我们为测试准备我们的模块。
⁴ 对于当前会话,输入
export PATH=/usr/lib/node_modules。根据 Node.js 的安装方式,路径可能会有所不同。在 Mac 上,你可能尝试 /usr/local/share/npm/lib/node_modules。
B.4. 创建测试套件
到 第六章 为止,我们已经有了使用已知数据(多亏了 Fake 模块)和定义良好的 API 成功测试我们模型的所有成分。图 B.5 展示了我们计划如何测试模型:[2]
⁵ 精明的闯入者会注意到这个图是一个懒散的、像素完美的复制之前展示过的图。我们应该按列英寸来付费...
图 B.5. 使用测试套件和模拟数据测试模型(模式 1)

在我们开始测试之前,我们需要让 Node.js 加载我们的模块。让我们接下来这样做。
B.4.1. 让 Node.js 加载我们的模块
Node.js 处理全局变量的方式与浏览器不同。与浏览器 JavaScript 不同,文件中的变量默认是局部的。实际上,Node.js 将所有库文件包裹在一个匿名函数中。我们使变量在所有模块中可用的方法是将其作为顶层对象的属性。在 Node.js 中,顶层对象不是 window,像在浏览器中那样,而是称为——等等——global。
我们的设计模块是为了浏览器使用。但通过巧妙的方法,我们可以让 Node.js 在稍作修改后使用它们。以下是我们的做法:我们的整个应用程序都在 spa 的单个命名空间(对象)中运行。因此,如果我们在我们加载模块之前在 Node.js 测试脚本中声明一个 global.spa 属性,一切应该都能按预期工作。
在所有这些内容从我们的短期记忆中消失之前,让我们开始我们的测试套件,webapp/public/nodeunit_suite.js,如下所示列表。
列表 B.4. 在测试套件中声明我们的命名空间—webapp/public/nodeunit_suite.js

我们只需要调整根 JavaScript 文件(webapp/public/js/spa.js)以完成模块的加载。我们的调整使得测试套件能够使用正确的全局 spa 变量,如下所示列表。变更以粗体显示:
列表 B.5. 调整我们的根 SPA JavaScript—webapp/public/js/spa.js

现在我们已经创建了一个 global.spa 变量,我们可以像处理我们的浏览器文档(webapp/public/spa.html)一样加载我们的模块。首先,我们将加载第三方模块,如 jQuery 和 TaffyDB,并确保它们的全局变量也可用(如果你必须知道的话,是 jQuery、$ 和 TAFFY)。然后我们可以加载我们的 jQuery 插件,然后是我们的 SPA 模块。我们不会加载我们的 Shell 或功能模块,因为我们不需要它们来测试模型。让我们在意识中仍然保留这些想法的同时更新我们的单元测试文件。变化以粗体显示:
列表 B.6. 添加库和我们的模块—webapp/public/nodeunit_suite.js
...
/*global $, spa */
// third-party modules and globals
global.jQuery = require( 'jquery' );
global.TAFFY = require( './js/jq/taffydb-2.6.2.js' ).taffy;
global.$ = global.jQuery;
require( './js/jq/jquery.event.gevent-0.1.9.js' );
// our modules and globals
global.spa = null;
require( './js/spa.js' );
require( './js/spa.util.js' );
require( './js/spa.fake.js' );
require( './js/spa.data.js' );
require( './js/spa.model.js' );
// example code
spa.initModule();
spa.model.setDataMode( 'fake' );
var $t = $( '<div/>' );
$.gevent.subscribe(
$t, 'spa-login',
function ( event, user ){
console.log( 'Login user is:', user );
}
);
spa.model.people.login( 'Fred' );
哎呀,我们在列表的末尾偷偷加入了一个简短的测试脚本。虽然我们最终希望使用 nodeunit 来运行这个文件,但我们将首先使用 Node.js 来运行它,以确保它正确地加载了库。确实,当我们使用 Node.js 运行我们的测试套件时,我们会看到类似以下的内容:
$ node nodeunit_suite.js
Loginuser is: { cid: 'id_5',
name: 'Fred',
css_map: { top:25, left: 25, 'background-color': '#8f8' },
___id: 'T000002R000003',
___s: true,
id:'id_5' }
如果你在家中参与,请耐心等待。我们将在三秒后看到任何输出,因为模拟模块在完成登录请求之前会暂停这么长时间。输出后还需要另外八秒,Node.js 才能完成运行。这是因为模拟模块在模拟服务器时使用计时器(计时器由 setTimeout 和 setInterval 方法创建)。在这些计时器完成之前,Node.js 会认为程序“正在运行”并且不会退出。我们稍后会回到这个问题。现在让我们熟悉一下 nodeunit。
B.4.2. 设置单个 nodeunit 测试
现在我们有了 Node.js 加载我们的库,我们可以专注于设置我们的 node-unit 测试。首先,让我们独自熟悉一下 nodeunit。运行成功测试的步骤如下:
-
声明测试函数。
-
在每个测试函数中,使用
test.expect( <count> )告诉test对象期望多少个断言。 -
在每个测试中运行断言;例如
test.ok( true );。 -
在每个测试结束时,使用
test.done()告诉测试对象这个测试已完成。 -
按顺序导出要运行的测试列表。每个测试都将在前一个测试完成后才运行。
-
使用
nodeunit <filename>运行测试套件。
列表 B.7 展示了一个使用这些步骤进行单个测试的 nodeunit 脚本。请阅读注释,因为它们提供了有价值的见解:
列表 B.7. 我们的第一个 nodeunit 测试—webapp/public/nodeunit_test.js

当我们运行 nodeunit nodeunit_test.js 时,我们应该看到以下输出:

现在让我们将我们的 nodeunit 经验与我们要测试的代码结合起来。
B.4.3. 创建我们的第一个真实测试
我们现在将第一个示例转换为真实测试。我们可以使用 nodeunit 和 jQuery 延迟对象来避免测试事件驱动代码的陷阱。首先,我们依赖于 nodeunit 在先前的测试通过执行test.done()声明完成之前不会继续到新的测试。这使得测试更容易编写和理解。其次,我们可以使用 jQuery 中的延迟对象在所需的spa-login事件发布后调用test.done()。这然后允许脚本继续到下一个测试。让我们更新我们的测试套件,如列表 B.8 所示。更改以粗体显示:
列表 B.8. 我们的第一个真实测试—webapp/public/nodeunit_suite.js
...
// our modules and globals
global.spa = null;
require( './js/spa.js' );
require( './js/spa.util.js' );
require( './js/spa.fake.js' );
require( './js/spa.data.js' );
require( './js/spa.model.js' );
// Begin /testAcct/ initialize and login
var testAcct = function ( test ) {
var $t, test_str, user, on_login,
$defer = $.Deferred();
// set expected test count
test.expect( 1 );
// define handler for 'spa-login' event
on_login = function (){ $defer.resolve(); };
// initialize
spa.initModule( null );
spa.model.setDataMode( 'fake' );
// create a jQuery object and subscribe
$t = $('<div/>');
$.gevent.subscribe( $t, 'spa-login', on_login );
spa.model.people.login( 'Fred' );
// confirm user is no longer anonymous
user = spa.model.people.get_user();
test_str = 'user is no longer anonymous';
test.ok( ! user.get_is_anon(), test_str );
// declare finished once sign-in is complete
$defer.done( test.done );
};
// End /testAcct/ initial setup and login
module.exports = { testAcct : testAcct };
当我们使用nodeunit ./nodeunit_suite.js运行测试套件时,我们应该看到以下输出:

现在我们已经成功实现了一个单个测试,让我们规划出我们希望在套件中拥有的测试,并讨论我们将如何确保它们按正确的顺序执行。
B.4.4. 映射事件和测试
当我们在第 5 和 6 章手动测试模型时,在输入下一个测试之前等待某些进程完成是自然而然的。对人类来说,显然我们必须在测试消息之前等待登录完成。但对测试套件来说,这并不明显。
我们必须为我们的测试套件规划一系列的事件和测试,以便其能够正常工作。编写测试套件的一个好处是,它使我们能够更全面地分析和理解我们的代码。有时我们在编写测试时发现的错误比在运行测试时更多。
让我们先为我们的套件设计一个测试计划。我们希望测试模型,就像我们的想象用户 Fred 一样,让我们的 SPA 经受考验。以下是 Fred 需要做的事情,带有标签:
-
testInitialState—测试模型的初始状态。 -
loginAsFred—以 Fred 的身份登录并测试在进程完成之前用户对象。 -
testUserAndPeople—测试在线用户列表和用户详情。 -
testWilmaMsg—接收 Wilma 的消息并测试消息细节。 -
sendPebblesMsg—将聊天对象更改为 Pebbles 并发送给她一条消息。 -
testMsgToPebbles—测试发送给 Pebbles 的消息的内容。 -
testPebblesResponse—测试 Pebbles 发送的响应消息的内容。 -
updatePebblesAvtr—更新 Pebbles 头像的数据。 -
testPebblesAvtr—测试 Pebbles 头像的更新。 -
logoutAsFred—以 Fred 的身份登出。 -
testLogoutState—测试登出后模型的状态。
我们的测试框架 nodeunit 按照呈现的顺序运行测试,并且不会继续到下一个测试,直到先前的测试声明它已完成。这对我们来说是有利的,因为我们想确保在运行某些测试之前特定事件已经发生。例如,我们想在测试在线人员列表之前,用户登录事件发生。让我们绘制我们的测试计划,其中包含在我们可以从每个测试继续之前需要发生的事件,如图 列表 B.9 所示。请注意,我们的测试名称与计划中的标签完全匹配,并且它们是可读的:
列表 B.9. 详细说明带有阻塞事件的测试计划
// Begin /testInitialState/
// initialize our SPA
// test the user in the initial state
// test the list of online persons
// proceed to next test without blocking
// End /testInitialState/
// Begin /loginAsFred/
// login as 'Fred'
// test user attributes before login completes
// proceed to next test when both conditions are met:
// + login is complete (spa-login event)
// + the list of online persons has been updated
// (spa-listchange event)
// End /loginAsFred/
// Begin /testUserAndPeople/
// test user attributes
// test the list of online persons
// proceed to next test when both conditions are met:
// + first message has been received (spa-updatechat event)
// (this is the example message from 'Wilma')
// + chatee change has occurred (spa-setchatee event)
// End /testUserAndPeople/
// Begin /testWilmaMsg/
// test message received from 'Wilma'
// test chatee attributes
// proceed to next test without blocking
// End /testWilmaMsg/
// Begin /sendPebblesMsg/
// set_chatee to 'Pebbles'
// send_msg to 'Pebbles'
// test get_chatee() results
// proceed to next test when both conditions are met:
// + chatee has been set (spa-setchatee event)
// + message has been sent (spa-updatechat event)
// End /sendPebblesMsg/
// Begin /testMsgToPebbles/
// test the chatee attributes
// test the message sent
// proceed to the next test when
// + A response has been received from 'Pebbles'
// (spa-updatechat event)
// End /testMsgToPebbles/
// Begin /testPebblesResponse/
// test the message received from 'Pebbles'
// proceed to next test without blocking
// End /testPebblesResponse/
// Begin /updatePebblesAvtr/
// invoke the update_avatar method
// proceed to the next test when
// + the list of online persons has been updated
// (spa-listchange event)
// End /updatePebblesAvtr/
// Begin /testPebblesAvtr/
// get 'Pebbles' person object using get_chatee method
// test avatar details for 'Pebbles'
// proceed to next test without blocking
// End /testPebblesAvtr/
// Begin /logoutAsFred/
// logout as fred
// proceed to next test when
// + logout is complete (spa-logout event)
// End /logoutAsFred/
// Begin /testLogoutState/
// test the list of online persons
// test user attributes
// proceed without blocking
// End /testLogoutState/
此计划是线性的,易于理解。在下一节中,我们将将我们的计划付诸实践。
B.4.5. 创建测试套件
现在,我们可以添加一些实用工具,并逐步添加测试到我们的套件中。在每一步中,我们将运行套件以检查我们的进度。
添加初始状态和登录测试
我们将从编写一些实用工具并添加前三个测试开始我们的测试套件,以检查初始模型状态,让弗雷德登录,然后检查用户和人员列表属性。我们发现测试通常分为两类:
-
验证测试中使用了多个断言(例如
user.name === 'Fred')来检查程序数据的正确性。这些测试通常不会阻塞。 -
控制测试执行诸如登录、发送消息或更新头像等操作。这些测试很少有很多断言,并且通常在满足基于事件的条件之前会阻塞进度。
我们发现最好接受这种自然划分,并相应地命名我们的测试。验证测试命名为 test<something>,而控制测试则根据它们执行的操作命名,例如 loginAsFred.。
loginAsFred 测试要求在允许 nodeunit 继续执行 testUserAndPeople 测试之前,登录必须完成 并且 在线用户列表已更新。这是通过 $t jQuery 集合订阅 spa-login 和 spa-listchange 事件来实现的。然后测试套件使用 jQuery 延迟对象来确保这些事件在 loginAsFred 执行 test.done() 之前发生。
让我们更新测试套件,如图 列表 B.10 所示。像往常一样,请阅读注释,因为它们提供了额外的见解。我们在 列表 B.9 中为测试计划构建的注释以粗体显示:
列表 B.10. 添加我们的前两个测试—webapp/public/nodeunit_suite.js


当我们运行测试套件(nodeunitnodeunit_suite.js)时,我们应该看到如下输出:

套件大约需要 12 秒才能将控制权返回到控制台,因为 JavaScript 有活动计时器需要完成。不用担心,当我们完成测试套件时,这将成为一个非问题。现在让我们添加消息事务的测试。
添加消息事务测试
现在,我们将从我们的测试计划中添加接下来的四个测试。这些测试是一个很好的逻辑组,因为它们都测试了发送和接收消息的问题。测试包括 testWilmaMsg、sendPebblesMsg、testMsgToPebbles 和 testPebblesResponse。我们认为这些名称很好地总结了每个测试的功能。
当我们添加我们的测试时,我们需要更多的 jQuery 延迟对象以确保序列化进度。列表 B.11 展示了这种实现。请阅读注释,因为它们详细说明了在这些新测试上如何实现阻塞。所有更改都以粗体显示:
列表 B.11. 为消息事务添加测试—webapp/public/nodeunit_suite.js


当我们运行我们的测试套件(nodeunitnodeunit_suite.js)时,我们应该看到如下输出:

套件从执行中返回所需的时间与之前一样长,但现在我们看到了新的测试。具体来说,套件现在正在等待并测试威尔玛向用户发送的消息。现在让我们添加更多测试以完成测试套件。
为头像、注销和注销状态添加测试
现在,我们将通过添加计划中的剩余四个测试来完成我们的测试套件。同样,我们使用延迟对象来确保在允许一个测试继续到另一个测试之前,某些事件已被接收。列表 B.12 展示了额外的测试。所有更改都以粗体显示:
列表 B.12. 额外测试—webapp/public/nodeunit_suite.js
...
var
// utility and handlers
makePeopleStr, onLogin, onListchange,
onSetchatee, onUpdatechat, onLogout,
// test functions
testInitialState, loginAsFred, testUserAndPeople,
testWilmaMsg, sendPebblesMsg, testMsgToPebbles,
testPebblesResponse, updatePebblesAvtr, testPebblesAvtr,
logoutAsFred, testLogoutState,
// event handlers
loginEvent, changeEvent, chateeEvent, msgEvent, logoutEvent,
loginData, changeData, msgData, chateeData, logoutData,
...
$deferMsgList = [ $.Deferred() ],
$deferLogout = $.Deferred();
...
// event handler for 'spa-setchatee'
...
// event handler for 'spa-logout'
onLogout = function ( event, arg ) {
logoutEvent = event;
logoutData = arg;
$deferLogout.resolve();
};
// Begin /testInitialState/
testInitialState = function ( test ) {
...
$.gevent.subscribe( $t, 'spa-updatechat', onUpdatechat );
$.gevent.subscribe( $t, 'spa-logout', onLogout );
// test the user in the initial state
...
// End /testPebblesResponse/
// Begin /updatePebblesAvtr/
updatePebblesAvtr = function ( test ) {
test.expect( 0 );
// invoke the update_avatar method
spa.model.chat.update_avatar({
person_id : 'id_03',
css_map : {
'top' : 10, 'left' : 100,
'background-color' : '#ff0'
}
});
// proceed to the next test when
// + the list of online persons has been updated
// (spa-listchange event)
$deferChangeList[ 1 ].done( test.done );
};
// End /updatePebblesAvtr/
// Begin /testPebblesAvtr/
testPebblesAvtr = function ( test ) {
var chatee, test_str;
test.expect( 1 );
// get 'Pebbles' person object using get_chatee method
chatee = spa.model.chat.get_chatee();
// test avatar details for 'Pebbles'
test_str = 'avatar details updated';
test.deepEqual(
chatee.css_map,
{ top : 10, left : 100,
'background-color' : '#ff0'
},
test_str
);
// proceed to next test without blocking
test.done();
};
// End /testPebblesAvtr/
// Begin /logoutAsFred/
logoutAsFred = function( test ) {
test.expect( 0 );
// logout as fred
spa.model.people.logout();
// proceed to next test when
// + logout is complete (spa-logout event)
$deferLogout.done( test.done );
};
// End /logoutAsFred/
// Begin /testLogoutState/
testLogoutState = function ( test ) {
var user, people_db, people_str, user_str, test_str;
test.expect( 4 );
test_str = 'logout as Fred complete';
test.ok( true, test_str );
// test the list of online persons
people_db = spa.model.people.get_db();
people_str = makePeopleStr( people_db );
user_str = 'anonymous';
test_str = 'user list provided is expected - ' + user_str;
test.ok( people_str === 'anonymous', test_str );
// test user attributes
user = spa.model.people.get_user();
test_str = 'current user is anonymous after logout';
test.ok( user.get_is_anon(), test_str );
test.ok( true, 'test complete' );
// Proceed without blocking
test.done();
};
// End /testLogoutState/
module.exports = {
testInitialState : testInitialState,
loginAsFred : loginAsFred,
testUserAndPeople : testUserAndPeople,
testWilmaMsg : testWilmaMsg,
sendPebblesMsg : sendPebblesMsg,
testMsgToPebbles : testMsgToPebbles,
testPebblesResponse : testPebblesResponse,
updatePebblesAvtr : updatePebblesAvtr,
testPebblesAvtr : testPebblesAvtr,
logoutAsFred : logoutAsFred,
testLogoutState : testLogoutState
};
// End of test suite
当我们运行我们的测试套件(nodeunitnodeunit_suite.js)时,我们应该看到如下输出:

我们已经按照我们的计划完成了测试套件。我们可以在将更新检查到存储库之前自动运行此套件(想想“提交钩”)。这种做法不应该减慢我们的进度,而应该通过防止回归并确保质量来加速我们的开发。这是在设计产品时将质量融入产品,而不是仅在产品“完成”后对其进行测试的例子。
然而,一个明显的问题仍然存在:当前的测试套件永远不会退出。当然,终端显示了 25 个断言已完成,但控制权从未返回到终端或任何其他调用进程。这阻止了我们自动化测试套件的运行。在下一节中,我们将讨论为什么会发生这种情况以及我们可以做什么。
B.5. 调整 SPA 模块以进行测试
Node.js(以及通过扩展,nodeunit)遇到的一个麻烦问题是它如何知道测试套件的执行何时完成?这是一个经典的计算机科学停机问题的例子,在任何事件驱动语言中都不是微不足道的。一般来说,Node.js 认为一个应用程序完成时,它找不到要执行的代码,并且没有挂起的交易。
到目前为止,我们的代码是为连续使用而设计的,没有考虑到关闭浏览器标签之外的退出条件。当测试者使用模式 2(在浏览器中使用假数据进行测试)并登出时,我们的 Fake 模块会启动一个 setTimeout 以期待另一个登录。
我们的测试套件,就像一些电影类型一样,需要一个明确的结尾。因此,如果我们打算 永远 在 SIGTERM 或 SIGKILL 这一边看到我们的测试套件完成,我们需要使用一个 测试设置。^([6]) 测试设置是测试所需的配置或指令,但不是“生产”使用所需的。
⁶ 让我们明确——我们需要这个程序退出,因为我们的自动化提交钩子将依赖于对退出代码的分析。没有退出意味着没有退出代码,这意味着没有自动化,这当然是不可以接受的。
如您所料,我们宁愿最小化测试设置,以防止它们引入自己的错误。有时,这是不可避免的。在这种情况下,我们需要一个测试设置来阻止我们的 Fake 模块不断重新启动计时器。这将允许我们的套件退出,以便我们可以使用脚本来自动化测试套件的运行并解释结果。
我们可以执行以下步骤来防止 Fake 在登出后重新启动计时器:
-
在测试套件中,将
true参数添加到登出调用中,如下所示:spa.model.people( true )。这个指令(我们称之为 do_not_reset 标志)通知模型,在登出后,我们不想让它重置值以准备另一个登录。 -
在模型
spa.model.people.logout方法中,接受一个可选的do_not_reset参数。将此值作为单个参数传递给chat._leave方法。 -
在模型
spa.model.chat._leave方法中,接受一个可选的do_not_reset参数。在发送leavechat消息到后端时,将此值作为数据传递。 -
将 Fake (webapp/public/js/spa.fake.js) 更改为确保
leavechat回调将接收到的数据视为do_not_reset标志。当leavechat回调看到它接收到的数据值为true时,它应该 不 在登出后重新启动计时器。
虽然这比我们预期的要更多的工作(我们希望没有额外的任务),但这只需要对三个文件进行微调。让我们从测试套件开始,并将 do_not_reset 指令添加到我们的 logout 方法调用中,如 列表 B.13 所示。这个单词的增加以 粗体 显示:
列表 B.13. 将 do_not_reset 添加到套件—webapp/public/nodeunit_suite.js
...
// Begin /logoutAsFred/
logoutAsFred = function( test ) {
test.expect( 0 );
// logout as fred
spa.model.people.logout( true );
// proceed to next test when
// + logout is complete (spa-logout event)
$deferLogout.done( test.done );
};
// End /logoutAsFred/
...
现在,让我们在模型中添加 do_not_reset 参数,如下列所示。更改以 粗体 显示:
列表 B.14. 将 do_not_reset 添加到模型—webapp/public/js/spa.model.js
...
people = (function () {
...
logout = function ( do_not_reset ) {
var user = stateMap.user;
chat._leave( do_not_reset );
stateMap.user = stateMap.anon_user;
clearPeopleDb();
$.gevent.publish( 'spa-logout', [ user ] );
};
...
}());
...
chat = (function () {
...
_leave_chat = function ( do_not_reset ) {
var sio = isFakeData ? spa.fake.mockSio : spa.data.getSio();
chatee = null;
stateMap.is_connected = false;
if ( sio ) { sio.emit( 'leavechat', do_not_reset ); }
};
...
}());
...
最后,让我们更新 Fake 模块,使其在发送 leavechat 消息时考虑 do_not_reset 指令。更改以 粗体 显示:
列表 B.15. 将 do_not_reset 添加到 Fake—webapp/public/js/spa.fake.js
...
mockSio = (function () {
...
emit_sio = function ( msg_type, data ) {
...
if ( msg_type === 'leavechat' ) {
// reset login status
delete callback_map.listchange;
delete callback_map.updatechat;
if ( listchange_idto ) {
clearTimeout( listchange_idto );
listchange_idto = undefined;
}
if ( ! data ) { send_listchange(); }
}
...
更新后,我们可以运行nodeunit nodeunit_suite.js并观察测试套件运行并退出:

套件的退出代码将是失败的断言数量。因此,如果所有测试都通过,退出代码将是 0(我们可以在 Linux 和 Mac 上使用echo $?来检查退出代码)。脚本可以使用这个退出状态(以及其他输出)来做诸如阻止构建部署,或者向相关开发者或项目经理发送电子邮件等事情。
B.6. 摘要
测试是一种帮助我们更快、更好地开发的实践。一个运行良好的项目从一开始就被设计为支持多种测试模式,并且测试代码与代码一起编写,以帮助快速有效地识别和解决问题。几乎每个人在某个时候都曾在项目中工作过,每个进步似乎都伴随着曾经工作良好的东西的匹配失败。一致、早期和精心设计的测试可以防止回归并促进快速进步。
本附录展示了四种测试模式,并讨论了如何设置它们以及何时使用它们。我们选择了 nodeunit 作为我们的测试框架。这样我们就可以在不使用网络浏览器的情况下测试我们的模型。在创建测试套件时,我们使用了 jQuery 延迟对象和测试指令来确保测试按正确的顺序执行。最后,我们展示了如何调整模块以便在测试环境中成功运行测试。
我们希望您觉得我们的演示富有启发性和鼓舞人心。祝您测试愉快!



浙公网安备 33010602011771号