Backbone-js-精要-全-

Backbone.js 精要(全)

原文:zh.annas-archive.org/md5/64482e63025f00d5cedd23e143eda1bd

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们这些 JavaScript 开发者生活在激动人心的时代。每个月我们都会遇到一个令人难以置信的新浏览器功能、开源库或软件方法,它们承诺将大大改进之前的技术。然后,就在一种新技术出现之后,另一种几乎不可避免地紧随其后,承诺带来更多的创新。

在这个不断变化的新技术领域中,只有少数库能够在长时间内保持不仅相关而且至关重要的地位,尽管有新的挑战者。对于许多 Web 程序员来说,jQuery 是第一个这样的库,但近年来,另一个库已经证明了自己同样不可或缺。那个库就是 Backbone。

Backbone 为开发者提供了一系列基础组件,他们可以从这些组件中构建任何类型的 Web 应用。从其简单而灵活的类系统,到基于事件的数据容器和简化 AJAX 的数据容器,再到 DOM 操作和单页用户界面组件,Backbone 提供了构建网站底层框架所需的一切。

然而,一开始 Backbone 的强大功能和灵活性可能会让人感到畏惧。面对分布在四个基本类中的超过一百种不同的方法,对于新的 Backbone 开发者来说,确定使用哪些方法以及何时使用它们可能具有挑战性。此外,虽然 Backbone 对其核心功能非常“有意见”,但它对几乎所有其他事情都采取了故意无偏见的方法。这允许开发者选择最适合他们应用的方法,但与此同时,所有这些选择都可能让不熟悉 Backbone 的人感到畏惧。

在这本书中,我们提供了两样东西,这两样东西都源于多年使用 Backbone 创建和维护真实世界基于 Web 的应用的经验。首先,我们将向您提供对 Backbone 所有基本功能的理解。完成这本书后,您将学会使用 Backbone 创建强大且可维护的 Web 应用所需的一切。

但与此同时,除了解释 Backbone 本身之外,我们还将解释 Backbone 编程的更广泛、更“元”的层面。从如何实现类的重要细节的建议,到探讨 Backbone 如何与更大的 JavaScript 生态系统结合以变得更加强大,我们努力不仅展示如何使用 Backbone,而且如何有效地使用它。

欢迎来到 Backbone 基础知识。

本书涵盖的内容

第一章,使用 Backbone 构建单页网站,介绍了 Backbone 并解释了为什么它是一个如此受欢迎的框架选择

第二章,使用 Backbone 类的面向对象 JavaScript,详细介绍了 Backbone 的类系统,这是对 JavaScript 原生系统的一个重大改进

第三章, 使用模型访问服务器数据,开始我们探索骨干的数据管理、事件监听和 AJAX 能力,使用骨干的模型类

第四章, 使用集合组织模型,继续我们探索骨干的数据管理能力,这次使用多个数据集和集合类

第五章, 使用视图添加和修改元素,检查了骨干的 DOM 渲染和事件处理视图类

第六章, 使用路由器创建客户端页面,介绍了骨干单页架构的核心,即路由器类

第七章, 将方钉插入圆孔 – 高级骨干技术,探讨了用于解决棘手问题的先进骨干模式

第八章, 扩展规模 – 确保复杂应用程序的性能,分析了骨干应用程序中性能问题的原因,以及如何防止这些问题

第九章, 我在思考什么?记录骨干代码,考虑了记录应用程序的不同策略,包括 JSDoc 和 Docco

第十章, 驱除虫害 – 如何测试骨干应用程序,推荐了测试骨干应用程序的最佳实践,并提供了来自 Mocha 和 Sinon 库的示例

第十一章, (不)重新发明轮子 – 利用第三方库,预览了各种第三方库,有些是针对骨干的,有些则不是,这些库可以提升骨干应用程序的性能

第十二章, 概述和进一步阅读,回顾了之前讨论的主题,并考虑了它们在现实世界中的应用

你需要这本书的什么

作为一本基于网络的科技书籍,骨干精华需要非常少,只需要某种形式的文本编辑器和网络浏览器。骨干本身与几乎所有浏览器兼容,包括从 Internet Explorer 7 开始的版本,尽管推荐使用现代网络浏览器。同样,虽然你可以使用纯文本编辑器编写骨干代码,但我们建议使用现代 IDE,如 Sublime、Eclipse 或 WebStorm,因为它可以提供许多有用的功能来辅助开发。

当然,要使用 Backbone,您需要下载(并在您的 HTML 中包含)库本身,该库可在www.backbonejs.com找到。此外,您还需要下载 Backbone 的两个依赖项:jQuery (www.jquery.com) 和 Underscore (www.underscorejs.org/)。技术上只需要 Underscore,但您可能还想包含 jQuery,以便利用 Backbone 的 View 类。

一旦您有了编辑器、浏览器以及所有三个库都已下载并包含在您的 HTML 中,您就可以开始使用 Backbone 了。

本书面向的对象

本书面向对 JavaScript 和 HTML 有基本了解的读者,并且至少对 jQuery 库有所熟悉。虽然本书不需要任何正式的计算机科学知识,但它使用了行业术语,如“引用”或“继承系统”,这些术语可能对没有至少一些编程背景的读者来说可能不熟悉。

如果您不熟悉 jQuery,我们建议您在开始本书之前,先阅读 Packt 出版的由 Jonathan Chaffer 编写的优秀作品《Learning jQuery》。如果您对 Web 开发完全陌生,您最好先通过互联网上许多优秀的免费教程之一来熟悉 JavaScript。

惯例

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:"我们可以通过使用include指令来包含其他上下文。"

代码块设置如下:

<tr>
    <td>Fake Book</td>
    <td>This is a description of a fake book</td>
    <td><a href=""/buy/book1"">Buy Fake Book</a></td>
</tr>

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:"例如,您可能在一个页面上有一个提交按钮,当它触发页面的模型保存后,您希望它将用户重定向到不同的路由。"

注意

警告或重要注意事项以如下框中显示。

小贴士

小贴士和技巧如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢它的哪些方面。读者反馈对我们非常重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请参阅我们的作者指南,网址为www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

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

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

互联网上版权材料的盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。

询问

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章:使用 Backbone 构建单页面网站

在本章中,你将了解 Backbone 是什么,以及为什么你想要使用它来创建网络应用。特别是,我们将探讨以下主题:

  • Backbone 的历史以及它如何融入网络开发的更大历史

  • Backbone 的 单页面 架构的优势

  • 真实世界中的公司如何使用 Backbone 为他们的网站提供动力

什么是 Backbone?

Backbone 是由 Jeremy Ashkenas 在 2010 年创建的,它是 JavaScript 库家族中一个全新的分支的一部分。根据你询问的对象不同,这种类型的库可以被称作富应用框架、单页面库、厚客户端库,或者仅仅是 JavaScript 框架。无论你选择如何称呼它们,Backbone 及其相关库,如 Angular、Ember 和 CanJS,都提供了可以用来构建功能强大、超越传统网站,成为完整网络应用的工具。

Backbone 由以下五个主要工具组成:

  • 一个类系统,它使得实践面向对象编程变得容易

  • 一个 Model 类,它允许你存储和操作任何类型的数据,以及使用 AJAX 与远程服务器交换这些数据

  • 一个 Collection 类,它允许你在模型组上执行相同的数据操作和传输

  • 一个 View 类,它可以用来渲染构成页面的 DOM 元素,也可以用来管理在这些元素上发生的任何用户交互

  • 一个 Router 类,它允许你仅使用一个 HTML 文件创建一个包含任意数量虚拟页面的整个网站

虽然在概念上非常简单,但所有这些组件结合起来,允许你创建具有前所未有的复杂性和鲁棒性的网站,这些网站在 万维网WWW)上以前从未见过。

为什么选择 Backbone?

为什么你会想要在你的项目中选择 Backbone 真正有两个方面。首先,是“为什么根本要使用富应用框架?”的问题,其次,是“为什么选择 Backbone 而不是其他替代品?”的问题。让我们先从第一个问题开始。

要真正理解 单页面应用SPA)的价值,了解之前发生的事情是至关重要的。所有之前的网站都可以分为三大类,我将它们称为静态、基于服务器和 JavaScript 辅助。每种类型都与网络开发历史的不同时期相对应。

网络开发简史

在许多方面,网络开发的历史可以看作是从基于服务器的逻辑到基于客户端逻辑的演变。这个故事始于 1993 年,当时推出了世界上第一个真正的网络浏览器:Mosaic。在当时,网络甚至还没有 JavaScript(或者更不用说 CSS 了),只有 HTML。在那些早期日子里,大多数网站都是简单的静态网站,任何包含动态元素的网站都必须完全基于服务器。JavaScript 的第一个版本要到两年后的 1995 年底才被引入,而且要过好几年,这种语言才对除了简单的表单验证之外的其他事情有用。

网络开发简史

1997 年 Yahoo!的无 JavaScript 网站

幸运的是,网络确实在发展,很快 JavaScript 开发者见证了整个新的 JavaScript 库的诞生,如 Dojo、MochiKit、YUI,当然还有 jQuery。这些库使得开发者能够轻松地操作 DOM,避免当时普遍存在的跨浏览器问题,并利用一种新引入的技术,即AJAX。换句话说,它们使得开发者能够创建一种新的网站类型,即 JavaScript 辅助但仍然主要基于服务器的网络应用程序。

即使有了这些进步,服务器仍然控制着网站基础设施的两个关键部分:导航和页面渲染。这个问题要到几年后,随着现代 JavaScript 框架的引入,即第一个且最受欢迎的 Backbone,才得到解决。使用 Backbone,网络开发者终于能够仅使用客户端技术 JavaScript、HTML 和 CSS 来控制整个网站,这意味着他们可以创建一种全新的网络应用程序,即厚客户端或单页网站。

今天,即使 Backbone 和相关库的出现,许多开发者仍然继续创建前三种类型的网站,只要他们的目标适度,这是完全合理的。换句话说,如果你只是想向朋友展示你的婚礼照片,那么你可能不需要 Backbone 的全部功能。然而,如果你的目标是构建一个强大而健壮的网络应用程序,那么 Backbone 网站的优势是显而易见的。

Backbone 和单页应用程序的优势

虽然采用厚客户端架构对网站有许多好处,但它们可以归纳为三个主要类别:资产控制、更简单的数据管理和性能提升。

完整的用户界面资产控制

开发传统多页网站的一个挑战是共享 HTML 资产。在这样的网站上,HTML 是通过服务器端工具生成的,例如 Django 模板、ERBs 或 JavaServer PagesJSPs),但当然,客户端逻辑也严重依赖于相同的 HTML。在较小的组织中,这意味着程序员经常需要在 JavaScript 和服务器端语言之间分配注意力,这可能会因为频繁的上下文切换而感到沮丧。

在团队分离的大型组织中,HTML 资产通常由服务器端团队管理。这有时会使客户端团队甚至难以对网站的 HTML 进行最基本的更改,因为他们必须跨越界限工作。当他们未能这样做时,结果往往是他们创建了服务器端团队工作的并行版本,这种重复必然会导致错误。

Backbone 驱动的厚客户端应用程序通过让网站的 HTML 明确受客户端团队的严格控制来解决这些问题,无论是以模板系统、原始 HTML 文件还是 DOM 操作 JavaScript 逻辑的形式。两个团队之间的任何交互都通过精心协商的 API 集合发生,使得两组都能够专注于自己的核心专业领域,而不会相互干扰。

简化数据管理和事件触发

随着应用的扩展,管理其各个组件之间的交互可能会变得困难。解决这个问题的强大方法之一是使用基于事件的控制系统,但在 Backbone 出现之前,这样的系统在 JavaScript 中很少见。诚然,DOM 事件长期以来一直是网络开发的一部分,但没有像 Backbone 这样的框架,开发者只能局限于用户生成的事件。要真正实现基于事件系统的强大功能,还需要数据驱动的事件,这是 Backbone 的一个重要组成部分。

另一个常见的扩展挑战来自 JavaScript 对面向对象编程(OOP)的支持不足。OOP 允许程序员将大型、复杂的逻辑组织成更小、更易于管理的类,这在扩展应用程序时非常有用。虽然 JavaScript 有内置的类系统,但它相当不寻常,并且往往劝阻开发者采用 OOP 技术。Backbone 通过提供一个更友好的系统来解决此问题,虽然它仍然在 JavaScript 语言的限制内构建,但看起来更接近于在像 Java 这样的强大 OOP 语言中找到的系统。

提高性能

在互联网上,速度至关重要,一个网站速度的重要因素是其 HTML 文件的大小。在多页应用中,每次用户访问新页面时,他们的浏览器都必须发送请求并等待服务器的响应。当响应返回时,它不仅仅包含该页面的唯一 HTML。相反,响应包含了一切内容的 HTML,包括任何常见的网站组件,如菜单或页脚。当用户访问下一页时,他们又必须下载相同的公共组件 HTML,即使它没有变化。

此外,不仅仅是重复的 HTML 被下载:表格中的多行、列表中的多个搜索结果或任何其他重复内容也必须多次下载其 HTML。例如,考虑以下 HTML:

<tr>
    <td>Fake Book</td>
    <td>This is a description of a fake book</td>
    <td><a href=""/buy/book1"">Buy Fake Book</a></td>
</tr>
<tr>
    <td>Another Fake Book</td>
    <td>I hope you like fake book titles because plenty more are coming in future chapters...</td>
    <td><a href=""/buy/book2"">Buy Another Fake Book</a></td>
</tr>

在前面的代码中,只有两本书的名称、描述和 URL 是唯一的,但即便如此,所有非唯一部分的代码也必须与之一起下载。如果网站显示 50 本书,用户就需要下载 50 份书行 HTML。即使一个网站没有公共组件或重复元素,当用户访问新页面时,浏览器仍需要经历整个请求-响应周期,然后重新加载和重新绘制页面,所有这些都需要时间。

在单页应用中,这些问题都不存在。网站的基础 HTML 只下载一次,之后所有的页面转换都完全通过 JavaScript 完成。由于客户端知道如何渲染公共和重复的组件,因此根本不需要下载它们的任何 HTML。在 Backbone 网站上,服务器只通过 AJAX 发送唯一数据,如果没有唯一数据需要下载,用户可以无需向服务器发出任何新的请求而继续前进。

Backbone 及其竞争对手

我们刚才讨论的许多优势适用于任何单页应用,而不仅仅是 Backbone。这意味着即使你使用 Backbone 的竞争对手库,如 Ember 或 Angular,你也可以实现许多这些好处。无论你是否考虑过使用这些框架,你可能至少会想知道:“Backbone 能否提供我现在和未来构建网站所需的一切?”

回答这个问题时首先要考虑的是 Backbone 是否有活跃的社区并且会继续积极开发。Backbone 用户在这方面可以感到安心:在撰写本书时,Backbone 的 GitHub 页面有超过 1500 名关注者和超过 21000 颗星,比其最接近的竞争对手(Ember)多出 400 名关注者和 7000 颗星。其他框架如 CanJS 和 Google 的 Angular 在 GitHub 上的关注度更少。虽然这当然不能使 Backbone 优于那些库,但它显示了其社区的力量,并应该让你有信心 Backbone 将会存在很多年。

选择 Backbone 时感到自信的另一个原因是,它只尝试执行一组特定的任务,将其他一切留给外部库。这意味着,如果你在未来发现了一个更好的模板系统、依赖管理工具或其他任何库,你可以轻松地切换到使用它。其他框架将模板系统等紧密耦合到框架中,这让你在未来的选择上更少。

然而,可能是 Backbone 活力的最大指标是已经使用它来完成惊人成就的公司。从 USA Today 到 Pandora、Hulu、Gawker Media、AirBnB、Khan Academy、Groupon,甚至沃尔玛,这些多样化的公司都使用 Backbone 来创建强大的网络应用。如果 Backbone 足够强大以支持这些大型公司,那么它几乎可以肯定也足以支持你的项目。

另有一家公司也使用 Backbone,那就是我所在的公司——Syapse。在 Syapse,我们构建了一个精准医疗数据平台,帮助医院以结构化格式接收遗传数据,从各种内部健康信息技术系统中提取患者的临床数据,并在一个交互式网络应用中将这些数据一起展示出来。通过这个界面,医生可以在上下文中看到他们的患者的遗传和临床数据,从而能够选择最适合患者自身遗传特征的药物。

创建像 Syapse 这样的应用并不容易,而且在癌症等严重疾病面前,几乎没有犯错的空间。然而,使用 Backbone,Syapse 已经在短短三年内从一名开发者成长为一个拥有六人客户端团队的团队,拥有超过 21,000 行代码(不包括库)。如果不是 Backbone 的可扩展性,我们根本无法如此快速地成长,至少在过程中不会对架构进行重大更改。

简而言之,尽管 Backbone 本身可能还不到五年历史,但其在现实世界中的应用已经证明了其价值和可扩展性。如果你的目标是创建一个强大且健壮的网络应用,单个开发者可以轻松启动,同时也能由一个完整的团队进行扩展和维护,那么 Backbone 是一个不错的选择。

摘要

在本章中,我们探讨了 Backbone 如何代表网络开发的新篇章,以及为什么如果你的目标是创建强大且可扩展的网络应用,Backbone 是你项目的最佳框架。

在下一章中,我们将开始探讨构成 Backbone 的组件,特别是其易于使用的类系统。我们还将查看 Backbone 的姐妹库 Underscore,它也是由 Jeremy Ashkenas 创建的,并且是 Backbone 本身的要求。

第二章:使用 Backbone 类的面向对象 JavaScript

在本章中,我们将探讨以下主题:

  • JavaScript 的类系统与传统面向对象语言的类系统之间的区别

  • 如何通过新、原型和原型使 JavaScript 的类系统成为可能

  • 扩展,Backbone 创建子类的一种更简单的机制

  • 利用 Underscore 的方法,它是 Backbone 的依赖之一(就像 jQuery 一样)

JavaScript 的类系统

使用 JavaScript 的程序员可以使用类以与其他语言程序员相同的方式封装逻辑单元。然而,与这些语言不同,JavaScript 依赖于一种不太流行的继承形式,称为基于原型的继承。由于 Backbone 类在本质上只是 JavaScript 类,因此它们也依赖于原型系统,并且可以像任何其他 JavaScript 类一样进行子类化。

例如,假设你想创建一个自己的 Book 子类,它是 Backbone Model 类的子类,并具有模型没有的附加逻辑,例如与书籍相关的属性和方法。以下是如何仅使用 JavaScript 的原生面向对象功能创建此类的方法:

// Define Book's Initializer
var Book = function() {
    // define Book's default properties
    this.currentPage = 1;
    this.totalPages = 1;
}

// Define book's parent class
Book.prototype = new Backbone.Model();

// Define a method of Book
Book.prototype.turnPage = function() {
    this.currentPage += 1;
    return this.currentPage;
}

如果你从未在 JavaScript 中使用过原型,前面的代码可能看起来有点令人畏惧。幸运的是,Backbone 提供了一种更简单、更易于阅读的机制来创建子类。然而,由于该系统建立在 JavaScript 的原生系统之上,因此首先了解原生系统的工作方式非常重要。这种理解将有助于你以后进行更复杂的类相关任务,例如调用在父类上定义的方法。

新关键字

new 关键字是 JavaScript 类系统的一个相对简单但极其有用的部分。关于 new 的第一件事是,它不会以与其他语言相同的方式创建对象。在 JavaScript 中,每个变量要么是函数、对象或原始值,这意味着当我们提到 时,我们实际上指的是一个特别设计的初始化函数。创建这种类样式的函数就像定义一个修改 this 的函数然后使用 new 关键字调用该函数一样简单。

通常,当你调用一个函数时,它的 this 是显而易见的。例如,当你调用一个书籍对象的 turnPage 方法时,turnPage 中的 this 方法将被设置为这本书对象,如下所示:

var simpleBook = {currentPage: 3, pages: 60};
simpleBook.turnPage = function() {
    this.currentPage += 1;
    return this.currentPage;
}
simpleBook.turnPage(); // == 4

调用一个未附加到对象上的函数(换句话说,一个不是方法的函数)会导致 this 被设置为全局作用域。在网页浏览器中,这意味着窗口对象:

var testGlobalThis = function() {
    alert(this);
}
testGlobalThis(); // alerts window

当我们在调用初始化函数之前使用 new 关键字时,会发生三件事(实际上四件事,但我们将在解释原型时解释第四件事):

  • JavaScript 为我们创建了一个全新的对象 ({})

  • JavaScript 将初始化函数内部的 this 方法设置为新创建的对象

  • 函数执行完毕后,JavaScript 会忽略正常的返回值,而是返回创建的对象

如你所见,尽管 new 关键字很简单,但它仍然很重要,因为它允许你将初始化函数视为真正的类。同时,它这样做并没有违反 JavaScript 的原则,即所有变量必须是函数、对象或原始数据类型。

原型继承

虽然一切都很好,但如果 JavaScript 没有真正的类概念,我们如何创建子类呢?实际上,JavaScript 中的每个对象都有两个特殊的属性来解决这个问题:prototype__proto__(隐藏的)。这两个属性可能是 JavaScript 中最常被误解的方面,但一旦你了解了它们的工作原理,实际上它们的使用非常简单。

当你在对象上调用方法或尝试检索属性时,JavaScript 首先检查对象是否在其自身中定义了该方法或属性。换句话说,如果你定义了一个这样的方法:

book.turnPage = function() 
    this.currentPage += 1;
};

当你调用 turnPage 时,JavaScript 将首先使用这个定义。

小贴士

然而,在实际代码中,你几乎永远不会想在对象中直接放置方法,原因有两个。首先,这样做会导致这些方法被复制,因为你的类每个实例都将有自己的单独副本。其次,以这种方式添加方法需要额外的步骤,而这个步骤在你创建新实例时很容易被忘记。

如果对象中没有定义 turnPage 方法,JavaScript 将接下来检查对象的 hidden __proto__ 属性。如果这个 __proto__ 对象没有 turnPage 方法,那么 JavaScript 将查看对象 __proto__ 属性上的 __proto__。如果没有这个方法,JavaScript 将继续检查 __proto____proto____proto__,并继续检查每个后续的 __proto__,直到链被耗尽。

原型继承

这与传统面向对象语言中的单类继承类似,只不过 JavaScript 不是通过类链,而是使用原型链。就像在面向对象语言中我们最终只有一个方法副本一样,但方法不是定义在类本身上,而是定义在类的原型上。

在 JavaScript 的将来版本(ES6)中,将能够直接操作 __proto__ 对象,但到目前为止,查看 __proto__ 属性的唯一方法是通过浏览器调试工具(例如,Chrome 开发者工具调试器):

原型继承

这意味着你不能使用以下这行代码:

book.__proto__.turnPage();

此外,你不能使用以下代码:

book.__proto__ = {
    turnPage: function() {
        this.currentPage += 1;
    }
};

但是,如果您不能直接操作__proto__,您如何利用它呢?幸运的是,您可以操作__proto__,但您只能通过间接操作prototype来实现。您还记得我提到new关键字实际上做了四件事吗?第四件事是它将新创建的对象的__proto__属性设置为初始化函数的prototype属性。换句话说,如果您想为每个您创建的Book新实例添加一个turnPage方法,您可以将这个turnPage方法赋值给Book初始化函数的prototype属性,例如:

var Book = function() {};
Book.prototype.turnPage = function() {
    this.currentPage += 1;
};
var book = new Book();
book.turnPage();// this works because book.__proto__  == Book.prototype

由于这些概念经常引起混淆,让我们简要回顾一下:

  • 每个对象都有一个prototype属性和一个隐藏的__proto__属性。

  • 当一个对象首次创建时,它的__proto__属性被设置为构造函数的prototype属性,并且不能被更改。

  • 当 JavaScript 在对象上找不到属性或方法时,它会检查__proto__链的每个步骤,直到找到它或直到链结束。

小贴士

下载示例代码

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

扩展 Backbone 类

随着这些解释的完成,我们终于可以深入了解 Backbone 的子类化系统,该系统围绕 Backbone 的extend方法展开。要使用extend,您只需从您的新子类将基于的类中调用它,extend将返回新的子类。这个新的子类将它的__proto__属性设置为父类的prototype属性,使得使用新子类创建的对象可以访问父类的所有属性和方法。以下是一个代码片段的例子:

var Book = Backbone.Model.extend();
// Book.prototype.__proto__ == Backbone.Model.prototype;
var book = new Book();
book.destroy();

在前面的例子中,最后一行之所以有效,是因为 JavaScript 会查找__proto__链,找到Model方法的destroy,并使用它。换句话说,我们原始类的所有功能都被我们的新类继承了。

当然,如果extend只能创建父类的精确克隆,那么它就不会那么吸引人,这也是为什么extend将其第一个参数作为一个properties对象。这个对象上的任何属性或方法都将被添加到新类的prototype中。例如,让我们通过添加一个属性和一个方法来让我们的Book类变得更有趣:

var Book = Backbone.Model.extend({
    currentPage: 1,
    turnPage: function() {
        this.currentPage += 1;
    }
});
var book = new Book();
book.currentPage; // == 1
book.turnPage(); // increments book.currentPage by one

extend 方法还允许你创建静态属性或方法,换句话说,就是存在于类上而不是从该类创建的对象上的属性或方法。这些静态属性和方法作为第二个 classProperties 参数传递给 extend。以下是一个快速示例,说明如何向我们的 Book 类添加一个静态方法:

var Book = Backbone.Model.extend({}, {
    areBooksGreat: function() {
        alert("yes they are!");
    }
});
Book.areBooksGreat(); // alerts "yes they are!"
var book = new Book();
book.areBooksGreat(); // fails because static methods must be called on a class

如你所见,与原生的 JavaScript 方法相比,Backbone 对继承的处理方法有几个优点。首先,单词 prototype 在之前提到的任何代码中都没有出现过;虽然你仍然需要理解 prototype 的工作原理,但你不必为了创建一个类而思考它。另一个好处是,整个类定义都包含在一个单一的 extend 调用中,使得类的所有部分在视觉上保持在一起。此外,当我们使用 extend 时,构成类的各个逻辑部分按照大多数其他编程语言中的顺序排列,首先定义超类,然后是初始化器和属性,而不是相反。

应用父类方法

然而,为了充分发挥类系统的全部功能,仅仅在子类上定义新方法是不够的;有时,我们需要将父类的方法与子类上的附加逻辑结合起来。在传统的面向对象语言中,这通常是通过引用一个特殊的超对象来完成的;但在 JavaScript 中,不存在这样的对象,这意味着我们必须利用 JavaScript 的 applycall 方法。

例如,Backbone.Model 有一个 destroy 方法,但如果我们想让我们的 Book 类也有自己的 destroy 方法怎么办?这个方法可能需要处理一定数量的页面并将它们销毁(减少总页数),但同时我们可能还想保留 Backbone 版本以供其原始用途(即销毁服务器端的 Book 版本)。

幸运的是,因为 Backbone 已经为我们正确配置了 Book 类的原型,所以从子类方法调用父类方法相当简单,如下所示:

var Book = Backbone.Model.extend({
    destroy: function(optionsOrPagesToDestroy) {
        if (typeof optionsOrPagesToDestroy === 'number') {
            // optionsOrPagesToDestroy is pagesToDestroy: call our version
            this.totalPages -=  optionsOrPagesToDestroy;
        } else {
            // optionsOrPagesToDestroy is an options object: call the Backbone version
            Backbone.Model.prototype.destroy.apply(this, arguments);
        }
    }
});

使前面的代码能够正常工作的关键是 apply 方法,这是 JavaScript 中每个函数的方法(因为函数在 JavaScript 中也是对象,所以它们可以像任何其他对象一样拥有方法)。apply 方法允许你将函数作为从 apply 给出的第一个参数调用来调用。换句话说,apply 允许你在函数被调用时改变其 this 方法。

如前所述,在正常情况下,this 将设置为调用函数的对象。但是,当你使用 apply 时,你可以将其改为你想要的任何变量,如下所示:

var Book = Backbone.Model.extend({
    currentPage: 1,
    turnPage: function() {
        this.currentPage += 1;
    }
});
var simpleBook = {currentPage: 20};
Book.prototype.turnPage.apply(simpleBook); //  simpleBook.currentPage == 21

你还可以使用 apply 将常规参数传递给一个方法,通过提供第二个(数组)参数来实现。实际上,即使这与你要调用的函数无关,也可以使用 apply,在这种情况下,你可以简单地传递 null 作为第一个参数:

var Book = Backbone.Model.extend();
var book = new Book();
book.alertMessage = function(message, secondMessage) {
    alert(message + ' ' + secondMessage);
}
book.alertMessage.apply(null, ['hello', 'world']);// alerts "hello world"

JavaScript 函数也有类似的方法:callcallapply 之间的区别在于它们向被调用的函数提供参数的方式。与 apply 不同,call 预期其参数是单独传递的,而不是作为一个单一的数组参数。以下是一个代码片段的例子:

var book = new Book();
book.alertMessage = function(message, secondMessage) {
    alert(message + ' ' + secondMessage);
}
book.alertMessage.call(null, 'hello', 'world'); // alerts "hello world"

通过使用这两种方法之一,并记住每个类的每个方法都可通过其 prototype 属性访问,你不仅可以调用父类的方法,还可以调用祖父类、曾祖父类甚至完全不相关的类的方法。出于可读性的考虑,你不应该在完全不相关的类上频繁使用这种技术,但偶尔它可能非常有帮助。

注意

值得注意的是,这种技术仅在基于原型的继承语言(如 JavaScript)中可行;在真正的面向对象语言(如 Java)中实现 applycall 将是不可能的。

介绍 Underscore

除了 jQuery 之外,Backbone 还需要一个名为 Underscore 的库。Underscore 是由 Jeremy Ashkenas(Backbone 的创造者)编写的,其中许多函数与我们之前讨论的主题相关。由于 Underscore 是 Backbone 所必需的,所以如果你使用 Backbone,你将已经可以使用它。所有这些函数都可以通过 _ 字符访问(类似于 jQuery 函数是通过 $ 字符访问的)。

在我们讨论 callapply 方法时,你可能已经意识到这在 JavaScript 中比在其他语言中更灵活。当一个函数正常调用时,它会自动保留 this 方法,但当函数以不寻常的方式调用时——例如通过 window.setTimeout 或作为 jQuery 事件处理程序或 AJAX 调用的回调——情况就不同了。window.setTimeout 会将其更改为全局窗口对象,而 jQuery 事件回调会将 this 更改为触发事件的元素,jQuery AJAX 回调将设置为 AJAX 调用创建的 HTTP 请求。以下是一个快速示例:

var exampleObject = {};
exampleObject.alertThis = function() {
     alert(this);
};
window.setTimeout(exampleObject.alertThis); // alerts window

解决这个问题的方法是使用额外的函数,并使用 apply 来包装原始函数,这样我们就可以强制 JavaScript 保留我们的 this 方法:

      var exampleObject = {};
exampleObject.alertThisBuilder = function() {
    var alertThis = function() {
        alert(this);
    }
    var correctThis = this;
    return function() {
        alertThis.apply(correctThis);
    }
};
var alertThis = exampleObject.alertThisBuilder();
window.setTimeout(alertThis); // alerts exampleObject

这可以工作,但说实话:它很丑。幸运的是,Underscore 有两个解决方案:bindbindAll

让我们从 bind 开始。bind 函数允许你强制一个函数(作为第一个参数提供),保留你作为第二个参数提供的特定 this 值:

var simpleBook = {};
simpleBook.alertThis = function() {
     alert(this);
};
simpleBook.alertThis = _.bind(simpleBook.alertThis, simpleBook);
window.setTimeout(simpleBook.alertThis); // now alerts simpleBook, not window

Underscore 还有一个相关的 bindAll 函数,它可以用来永久地 bind 一个方法:

var Book = Backbone.Model.extend({
     initialize: function() {
          _.bindAll(this, 'alertThis');
     },
     alertThis: function() {
          alert(this);
     }
});
var  book = new Book();
window.setTimeout(book.alertThis); // alerts book, not window

如您所见,bindAll 允许您使用类的 setTimeout 或作为回调函数来处理 jQuery 事件处理器或 AJAX 操作,而不会丢失 this

虽然 bindAll 非常强大,但重要的是不要过度使用它,因为它会为它绑定的每个方法创建一个新的副本。如果在类内部使用,这将导致该类的每个实例都有自己独立的方法副本。当您只有少量绑定的方法和/或只有少量实例时,这完全没问题,但当您在将被多次实例化的类上使用大量方法时,您可能不想这样做。

更多 Underscore

bindbindAll 函数只是 Underscore 为网络开发者提供的大量功能的一小部分。虽然对 Underscore 提供的所有功能的全面解释超出了本书的范围,但花几分钟时间检查 Underscore 中一些最有用的函数是值得的。如果您想了解关于 Underscore 的更多内容,而不仅仅是本章中涵盖的内容,我强烈建议您阅读其网页 (underscorejs.org/),其中包含了库中每个方法的良好编写的文档。

每个、Map 和 Reduce

每个 JavaScript 开发者都知道如何使用 for 循环进行迭代,但 Underscore 提供了三种强大的替代原生循环的方法:eachmapreduce。虽然这三个方法(以及许多其他 Underscore 方法)都包含在支持 ES5 的浏览器中,但不幸的是,旧浏览器没有对这些方法的支持。这些替代循环如此方便,以至于您可能会发现自己再也不用原生 for 循环了。幸运的是,Underscore 提供了其版本,允许您在所有主要浏览器支持 ES5 之前填补这一差距。

让我们从每个用法的示例开始:

var mythicalAnimals = ['Unicorn', 'Dragon', 'Honest Politician'];
_.each(mythicalAnimals, function(animalName, index) {
    alert('Animal #' + index + ' is ' + animalName);
});

这将等同于使用 JavaScript 的原生 for 循环编写的 preceding 代码:

      var mythicalAnimals = ['Unicorn', 'Dragon', 'Honest Politician'];
for (var index = 0; index < mythicalAnimals.length; index++) {
    var animalName = mythicAnimals[index];
    alert('Animal #' + index + ' is ' + animalName);
}

或者,如果我们改用 for/in 语法:

var mythicalAnimals = ['Unicorn', 'Dragon', 'Honest Politician'];
for (var index in mythicalAnimals) {
    var animalName = mythicalAnimals[index];
    alert('Animal #' + index + ' is ' + animalName);
}

如您所见,原生实现都要求您在循环内部提取值(在这种情况下,animalName),而 Underscore 的版本会自动提供它。

Underscore 的 mapreduce 方法甚至更强大。map 方法允许您将变量数组转换为另一个(不同的)变量数组,而 reduce 则将变量数组转换为一个单一变量。例如,假设您已经使用 jQuery 的 text 方法提取了一组数字,但由于它们来自 DOM,这些数字实际上是字符串(换句话说,是 5 而不是 5)。通过使用与 each 几乎相同的语法的 map,您可以轻松地将所有这些字符串转换为实际的数字,如下所示:

var stringNumbers = ["5", "10", "15"];
var BASE = 10; // when we parse strings in to numbers in
               // JavaScript we have to specify which base to use
var actualNumbers = _.map(stringNumbers, function(numberString, index) {
    return parseInt(numberString, BASE);
}); // actualNumbers == [5, 10, 15]

如前例所示,map 函数内部返回的值被添加到 map 操作返回的数组中。这使得你可以将任何类型的数组转换为另一种类型的数组,只要你能够定义一个位于中间并执行所需转换的函数。

然而,如果你想要合并或汇总所有这些数字呢?在这种情况下,你将希望使用 reduce,如下所示:

var total = _.reduce(actualNumbers, function(total, actualNumber) {
    return total +  actualNumber;
}, 0); // total == 30

reduce 函数比之前的函数稍微复杂一些,因为它有一个名为 memo 的最后一个参数。这个参数作为 reduce 最终返回的对象的起始值,然后随着每个值的迭代,memo 被替换为 reduce 函数(传递给 reduce 的函数)返回的任何值。reduce 函数还将其前一个 memo 作为其第一个参数传递,因此每次迭代都可以选择以它想要的方式修改前一个值。这使得 reduce 可以用于比简单地相加两个数字更复杂的操作。

扩展和默认值

另一个使用 Underscore 可以简化操作的常见操作是将一个对象的属性复制到另一个对象上,这可以通过 Underscore 的 extend(不要与 Backbone 的 extend 类混淆)和 defaults 方法来解决。例如,假设你正在使用第三方小部件,例如 jQuery UI 组件,它在创建时需要一个配置参数。你可能希望为网站中的每个小部件保留一些相同的配置选项,但同时也可能希望某些小部件有自己的独特选项。

让我们假设你已经用两个对象定义了这两组配置,一个用于通用配置,另一个用于特定小部件的配置:

var commonConfiguration = {foo: true, bar: true};
var specificConfiguration = {foo: false, baz: 7};

extend 方法接受传递给它的第一个参数,并将每个后续参数的属性复制到它上面。在这种情况下,它允许你首先对一个新对象应用通用选项,然后应用特定选项,如下所示:

var combined = _.extend({}, commonConfiguration, specificConfiguration);
// combined == {foo: false, bar: true, baz: 7}

defaults 方法的工作方式与此类似,但它只复制对象尚未拥有的值。我们可以通过简单地改变参数顺序,用 defaults 重新编写前面的示例:

var combined = _.defaults({}, specificConfiguration , commonConfiguration);
// combined == {foo: false, bar: true, baz: 7}

如其名所示,defaults 方法在你想要为对象指定默认值但又不想替换任何已指定的值时非常有用。

提取和调用

map 的最常见用途之一是获取对象数组中的某些属性,或者对数组中的每个对象调用某个方法。Underscore 提供了额外的便利方法来完成这项任务:pluckinvoke

pluck 方法允许你从数组的每个成员中提取单个属性值。例如,假设我们有一个表示虚构书籍的对象数组,如下所示:

var fakeBooks = [
    {title: 'Become English Better At', pages: 50, author: 'Bob'},
    {title: 'You Is Become Even Better at English', pages: 100, author: 'Bob'},
    {title: 'Bob is a Terrible Author', pages: 200, author: 'Fred the Critic'}
];

如果你只想创建包含这些书籍标题的列表,你可以使用如下所示的pluck方法:

var fakeTitles = _.pluck(fakeBooks, 'title');// fakeTitles == ['Become English Better At', ...]

invoke方法的工作方式与pluck方法类似,不同之处在于它假设提供的属性是一个方法,运行它并将结果添加到返回的数组中。以下示例可以最好地说明这一点:

var Book = Backbone.Model.extend({
    getAuthor: function() {
        // the "get" method returns an attribute of a Model;
        // we'll learn more about it in the following chapter
        return this.get('author');
    }
});
var books = [new Book(fakeBooks[0]),
                     new Book(fakeBooks[1]),
                     new Book(fakeBooks[2])];
var authors = _.invoke(books, 'getAuthor'); // == ['Bob', 'Bob', 'Fred the Critic']

进一步阅读

Underscore 还有许多其他有用的函数,正如我们之前提到的,所有这些函数都有很好的文档记录,可以在underscorejs.org/找到。由于每个 Backbone 程序员都保证可以使用 Underscore,因此没有理由不利用这个优秀的库;即使在 Underscore 网站上花上几分钟,也会让你意识到它所能提供的一切。

摘要

在本章中,我们探讨了 JavaScript 的本地类系统是如何工作的,以及newthisprototype关键字/属性如何构成其基础。我们还学习了 Backbone 的extend方法如何使创建新的子类变得更加方便,以及如何使用applycall来调用父方法(或提供回调函数时)以保留所需的this方法。最后,我们探讨了 Underscore(Backbone 的依赖之一)解决常见问题的多种方式。在下一章中,我们将深入探讨四个 Backbone 类中的第一个,即Model。我们将学习如何使用 Model 在客户端组织我们的数据,以及如何与远程服务器交换这些数据。

第三章:使用模型访问服务器数据

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

  • 使用 Backbone 的主要类 Model 来处理数据

  • 创建新的 Model 子类

  • 获取和设置 Model 属性,并在这些属性发生变化时触发其他代码

  • 将这些属性存储到远程服务器并从服务器检索它们

  • 使用 Model 从 Underscore 借用的几个便利方法

模型的目的

模型 在 Backbone 中是所有数据交互的核心,无论是客户端代码内部还是与远程服务器通信时。虽然可以使用普通的 JavaScript 对象代替 Model,但模型提供了三个主要优势:

  • 模型使用 Backbone 的类系统,这使得定义方法、创建子类等变得容易

  • 模型允许其他代码监听并响应 Model 属性的变化

  • 模型简化并封装了与服务器通信所使用的逻辑

如前一章所述,我们可以使用 extend 创建自己的 Model 子类:

var Cat = Backbone.Model.extend({
     // The properties and methods of our "Cat" class would go here
});

一旦我们创建了那个类,我们可以使用 JavaScript 的 new 关键字实例化新的 Model 对象,并且(可选地)我们可以传入一组初始属性和选项,如下所示:

var garfield = new Cat({
    name: 'Garfield',
    owner: 'John'
}, {
    estimateWeight: true
});

属性、选项和属性

当我们谈论 Backbone 中的属性时,它们通常听起来与常规 JavaScript 属性很相似。毕竟,属性和属性都是存储在 Model 对象上的键值对。这两者之间的区别在于,属性在技术意义上根本不是模型的属性;相反,它们是模型属性的一个属性的属性。每个 Model 类都有一个名为 attributes 的属性,而属性本身则存储在那个 attributes 属性的属性中。以下是一个代码片段的例子:

var book = new Backbone.Model({pages: 200});
book.renderBlackAndWhite = true;
book.renderBlackAndWhite // is a property of book
book.attributes.pages; // is an attribute of book
book.attributes.currentPage = 1; // is also an attribute of book}

注意

属性与属性

如前述代码片段所示,Backbone 的 Models 类也可以有常规的非属性属性。如果您需要在模型中存储一些数据,您可以选择使用属性或属性。通常,只有当数据将要同步到服务器或您希望代码的其他部分能够监听数据变化时,才应使用属性。如果您的数据不满足这些要求,最好将其作为常规 JavaScript 属性而不是属性存储,因为将此类数据作为属性存储将在保存 Model 类时给您带来更多的工作。

在纯粹的概念层面上,任何 Model 类设计用来存储的核心持久信息都应该属于其属性,而任何次要的或派生的信息,或者仅设计在用户刷新页面之前持续存在的信息,应该作为属性存储。

如果你想在创建新的Model类时传递属性,你可以简单地将其作为第一个参数提供,Backbone 会自动添加它们。你还可以在扩展你的Model类时定义默认属性,这样所有新的Model都将具有这些属性,如下所示:

var Book = Backbone.Model.extend({

    defaults: {publisher: 'Packt Publishing'}
});
var book = new Book();
book.attributes.publisher; // 'Packt Publishing'

然而,重要的是要记住,在 JavaScript 中,对象是通过引用传递的,这意味着你提供的任何对象将与你的Model类的实例共享,而不是在实例之间复制。换句话说,看看以下代码片段:

var Book = Backbone.Model.extend({
    defaults: {publisher: {name: 'Packt Publishing'}}
});
var book1 = new Book();
book1.attributes.publisher.name // == 'Packt Publishing'
var book2 = new Book();
book2.attributes.publisher.name = 'Wack Publishing';
book1.attributes.publisher.name // == 'Wack Publishing'!

由于你可能不希望对一个模型所做的更改影响到你的其他模型,因此你应该避免在默认设置中设置对象。如果你确实需要将一个对象作为默认值设置,你可以在模型的initialize方法中这样做,或者使用更高级的基于函数的默认形式,我们将在第七章中介绍,将方钉嵌入圆孔 - 高级 Backbone 技术

虽然为模型设置默认属性很容易,但为模型首次创建时添加默认直接属性则不然。设置这些属性的最佳方式是使用模型的initialize方法。例如,如果你想在你创建Book模型时设置renderBlackAndWhite属性,你可以按照以下代码片段中描述的操作进行:

var Book = Backbone.Model.extend({
    initialize: function(attributes, options) {
        this.renderBlackAndWhite =  options.renderBlackAndWhite;
    }
});

上述代码将每个新创建的书的renderBlackAndWhite属性设置为在创建书时传入的renderBlackAndWhite选项。

获取器和设置器

虽然attributes属性(在幕后)只是一个 JavaScript 对象,但这并不意味着我们应该将其视为这样的对象。例如,你永远不应该通过直接更改Model类的attributes属性来设置属性:

var book = new Book({pages: 200});
book.attributes.pages = 100; // don't do this!

相反,Backbone 的Model的属性应该使用模型的set方法设置:

book.set('pages', 100); // do this!

set方法有两种形式或签名。第一种(之前已展示)接受两个参数:一个用于数据的键,一个用于其值。如果你只想一次设置一个值,这种形式非常好,但如果你需要设置多个值,你可以使用第二种形式,它只接受一个包含所有set值的参数:

book.set({pages: 50, currentPage: 49});

此外,模型还有一个unset方法,它只接受一个参数,其工作方式与 JavaScript 的delete语句类似,但除此之外,它还允许任何监听模型的代码知道这个变化:

book.unset('currentPage');// book.attributes.currentPage == undefined
delete book.attributes.currentPage; don't do this!

如你所猜,通过 Backbone 检索属性是通过使用get方法完成的,它只接受一个参数,并返回该键的值:

book.set({pages: 50});
var bookPages = book.get('pages'); // == 50
bookPages = book.attributes.pages;// same effect, but again don't do this!

get方法非常简单;我实际上想在这里展示它的整个源代码:

get: function(attr) {
    return this.attributes[attr];
}

现在,你可能正在想,如果这就是get的全部内容,为什么不直接使用attributes对象呢?毕竟,它们之间没有区别,对吧?

book.get('pages'); // good
book.attributes.pages; // bad?

实际上,使用get方法有两个好处,如下所示:

  • 第一个好处是使用get可以使你的代码更短,更易读,并且更一致,因为你的get调用将与你的set调用相匹配。

  • 第二个好处,并且仅限于程序性优势,是使用get提供了未来可扩展性的选项。像许多 Backbone 方法一样,get不仅是为了即插即用而设计,也是为了由你,Backbone 开发者,进行扩展。

让我们暂时想象一下,你正在构建一个 Backbone 应用程序,有一天,你意识到你需要知道何时某些代码访问了某个属性。也许,当你添加审计或日志功能时,你需要这样做,或者你可能只是试图调试一个棘手的问题。

如果你已经编写了直接访问属性的应用程序,你需要找到代码中获取属性的所有地方,然后更新这些代码。然而,如果你已经使用get方法,你只需简单地覆盖相关Model(或Models)上的get方法,就可以从代码的单个位置访问所有现有的get逻辑。

监听事件

使用setunset方法的主要优势之一,正如我们刚才描述的,是它们允许代码的其他部分监听 Model 属性的变化。这与在 jQuery 中监听 DOM 事件类似,并且使用Modelonoff方法来完成:

var book = new Backbone.Model({pages: 50});
book.on('change:pages', function() { // triggers when the "pages" of book  change
    alert('the number of pages changed!');
});
book.set('pages', 51); // will alert "the number of pages has changed"
book.off('changes:pages');
book.set('pages', 52); // will no longer alert

正如你可以在前面的代码中看到的那样,on方法,就像 jQuery 一样,接受两个参数:一个event函数和一个callback函数。当事件被触发时,Backbone 会调用callback函数。可以使用off方法移除事件监听器。如果你想让你的代码监听多个事件,你可以用空格将它们组合起来,如下所示:

book.on('change destroy', function() {    // this callback will trigger after a change or a destroy
});

Backbone 相较于 jQuery 有一个显著的改进,那就是可选的第三个context参数。如果提供了这个参数,回调将在注册时被绑定(就像你在上一章中提到的_.bind方法一样):

var Author = Backbone.Model.extend({
    listenForDeathOfRival: function(rival) {
        rival.on('destroy', function celebrateRivalDeath() {
            alert('Hurray!  I, ' + this.get('name') + ', hated ' + 
                  rival.get('name') + '!');
        }, this); // "this" is our "context" argument
    }
});
var keats = new Author({name: 'John Keats');
var byron = new Author({name; 'Lord Byron'});
byron.listenForDeathOfRival(keats);
keats.destroy(); // will alert "Hurray!  I, Lord Byron, hated  John Keats!"

在之前的代码中,我们定义了一个带有listenForDeathOfRival方法的Author类,该方法接受一个对手(另一个Author类)作为参数,并设置一个监听器以在对手被销毁时触发。我们将原始作者作为context参数传递,因此当回调解决时,它的this方法将被设置为该作者。然后我们在byron上调用listenForDeathOfRival并传入keats,这样byron就会监听keats上的销毁事件。

当我们在最后一行触发济慈的毁灭时,我们触发了由 listenForDeathOfRival 设置的事件监听器,这给出了警报信息。信息能够包含两位作者的名字,因为当回调解析时,济慈的名字可以从 rival 变量中获取,而拜伦的名字则作为 Model 的一个属性可用(因为我们设置事件监听器时将他的模型作为 context 参数传递)。

可用事件

Model 有几个不同的事件可供你监听,如下表所示:

事件名称 触发器
change 当模型的任何属性发生变化时
change:attribute 当指定的属性发生变化时
destroy 当模型被销毁时
request 当模型的 AJAX 方法开始时
sync 当模型的 AJAX 方法完成时
error 当模型的 AJAX 方法返回错误时
invalid 当由模型的 save/isValid 调用触发的验证失败时

此外,还有一些与 Collection 相关的其他事件,将在下一章中进一步解释:

事件名称 触发器
add 当模型被添加到集合中时
remove 当模型从集合中移除时
reset 当模型所属的集合被重置时

模型还有一个其他特殊的事件,称为 all。当其他 Model 事件被触发时,将响应触发此事件。

自定义事件

虽然你不太可能经常使用它们,但在某些场合创建自己的自定义事件可能很有用。这可以通过使用模型的 trigger 方法来完成,它允许你模拟来自 Model 的事件,如下所示:

someModel.trigger('fakeEvent', 5);

你可以通过使用 on 方法以与其他任何非自定义事件相同的方式监听这些事件。传递给 trigger 的任何附加参数(如前例中的 5)都将作为监听该事件的处理器参数传递。

服务器端操作

一旦我们用数据填充了一个 Model 类,我们可能不想丢失这些数据,这就是模型 AJAX 功能发挥作用的地方。每个模型都有三种与服务器交互的方法,可以用来生成四种类型的 HTTP 请求,如下表所示:

方法 RESTful URL HTTP 方法 服务器操作
fetch /books/id GET 获取数据
save(对于新的 Model /books PUT 发送数据
save(对于现有的 Model /books/id POST 发送数据
destroy /books/id DELETE 删除数据

上面表格中的示例 URL 是 Backbone 在尝试执行任何三种 AJAX 方法时默认生成的。Backbone 与使用这种 RESTful 架构组织的服务器端 API 配合得最好。RESTful 服务器端 API 的基本思想是它由客户端可以消费的各种资源的 URL 端点组成。

在这种架构中,不同的 HTTP 方法被用来控制服务器应该对那个资源采取什么操作。因此,为了删除一个 ID 为 7 的书,RESTful API 预期一个 DELETE 请求到 /books/7

当然,除非你通过指定一个 urlRoot 属性来告诉它,否则 Backbone 不会知道一个 Book 模型的服务器端端点将是 /books。你可以通过指定一个 urlRoot 属性来完成:

var Book = new Backbone.Model.extend({
    urlRoot: '/books'
});
new Book().save(); // will initiate a PUT request to "/books"

如果由于某种原因,你的服务器端 API 在不同的域上,你也可以使用 urlRoot 方法来指定一个完全不同的域。例如,为了表明我们的 Book 类应该使用 www.example.com/ 的 papers 端点,我们可以设置 www.example.com/papersurlRoot。然而,由于浏览器强加的跨站脚本限制,这种方法在大多数网站上可能不起作用;因此,除非你采用特殊技术(例如,服务器端代理),否则你很可能希望你的服务器端 API 在提供你的 JavaScript 文件的同一域上。

如果你的服务器端 API 不是 RESTful 的,你仍然可以通过覆盖其内置的 url 方法来使用 Backbone 的 AJAX 功能。虽然这个方法的默认版本只是简单地将模型的 urlRoot 方法与其 ID(如果有的话)组合起来,但你可以重新定义它来指定你希望 Backbone 使用的任何 URL。

例如,假设我们的书模型有一个 fiction 属性,我们想要使用两个不同的端点来保存我们的书:/fiction 用于小说书籍,/nonfiction 用于非小说书籍。我们可以覆盖我们的 url 方法来告诉 Backbone,如下所示:

var Book = Backbone.extend({
    url: function() {
        if (this.get('fiction')) {
            return '/fiction;
        } else {
            return  '/nonfiction';
        }
    }
});
var theHobbit = new Book({fiction: true});
alert(theHobbit.url()); // alerts "/fiction"

在客户端存储 URL

正如我们刚刚提到的,Backbone 允许你为你的模型指定任何你想要的 URL。然而,如果你的网站相当复杂,将实际的 URL 字符串或甚至 URL 生成函数存储在单独的 urls 对象中是个好主意。以下是一个代码片段的例子:

var urls = {
    books: function() {
        return this.get('fiction') ? '/fiction' : '/nonfiction';
    },
    magazines: '/magazines'
};
var Book = Backbone.Model.extend({url:  urls.books});
var Magazine = Backbone.Model.extend({urlRoot:  urls.magazines});

虽然这并不是严格必要的(在较小的网站上可能过于冗余),但在较大的网站上,这种方法有几个好处:

  • 你可以轻松地在任何模型之间共享 URL

  • 你可以在你的模型和非 Backbone AJAX 调用之间轻松共享 URL

  • 查找现有 URL 快速且简单

  • 如果任何 URL 发生变化,你只需要编辑一个文件

识别

到目前为止,我们一直避免讨论 Backbone 如何确定模型(Model)的确切 ID。实际上,Backbone 有一个非常简单的机制:它使用你指定的作为 Model 类的 idAttribute 属性的任何属性。默认的 idAttribute 属性仅仅是 id;所以,如果你的服务器返回的 JSON 包含 id 属性,你甚至不需要指定 idAttribute 属性。然而,由于许多 API 不使用 id(例如,一些使用 _id 代替),Backbone 会监视其属性的变化,当它看到与 idAttribute 属性匹配的属性时,它将设置模型的特殊 id 属性为此属性的值,如下所示:

var Book = Backbone.Model.extend({idAttribute: 'deweyDecimalNumber'})
var warAndPeace = new Book({{deweyDecimalNumber: '082 s 891.73/3'});
warAndPeace.get('deweyDecimalNumber'); // '082 s 891.73/3'
warAndPeace.id; // also '082 s 891.73/3'

除了告诉 Backbone 保存模型时要使用哪个 URL 之外,ID 属性还有一个功能:它的缺失告诉 Backbone 模型是新的,你可以通过使用 isNew 方法看到这一点:

var warAndPeace = new Book({deweyDecimalNumber: '082 s 891.73/3'});
var fiftyShades = new Book();
warAndPeace.isNew(); // false
fiftyShades.isNew(); // true

如果你需要使用其他机制来确定 Model 类是否为新实例,此方法可以被覆盖。不过,关于 isNew 的问题还有另一个:如果新的模型没有 ID,你将如何识别它们?例如,如果你通过 ID 存储一组 Model,你会如何处理新的模型?

var warAndPeace = new Backbone.Model({{id: 55});
var shades = new Backbone.Model();
var bookGroup = {};
bookGroup[warAndPeace.id] =  warAndPeace; // bookGroup = {55: warAndPeace}
bookGroup[shades.id] = shades; // doesn't work because shades.id is undefined

为了解决这个问题,Backbone 为每个模型提供了一个特殊的仅客户端的 ID,或 cid 属性。这个 cid 属性保证是唯一的,但与模型的实际 ID(如果有的话)没有任何联系。它也不保证一致性;如果你刷新页面,你的模型可能会有完全不同的 cid 属性生成。

cid 属性的存在允许你使用模型(Model)的 ID 来执行所有与服务器相关的任务,以及使用其 cid 来执行所有客户端任务,而无需每次创建新模型时都从服务器获取一个新的 ID。通过使用 cid 属性,我们可以解决我们之前的问题,并成功索引新的书籍:

var bookGroup = {};
bookGroup[warAndPeace.cid] = warAndPeace; // bookGroup = {c1: warAndPeace}
bookGroup[fiftyShades .cid] = fiftyShades;
// bookGroup = {c1: warAndPeace, c2: fiftyShades};

从服务器获取数据

Backbone 的三个 AJAX 方法中的第一个,fetch,用于从服务器检索模型的数据。fetch 方法不需要任何参数,但它接受一个可选的 options 参数。此参数可以接受 Backbone 特定的选项,例如 silent(这将防止模型在 fetch 的过程中触发事件),或者任何你通常传递给 $.ajax 的选项,例如 async

你可以通过两种方式之一指定 fetch 请求完成时要执行的操作。首先,你可以在传递给 fetch 的选项中指定一个 successerror 回调:

var book = new Book({id: 55});
book.fetch({
    success: function() {
        alert('the fetch completed successfully');
    },
    error: function() {
        alert('an error occurred during the fetch');
    }
});

fetch 方法同样返回一个 jQuery 承诺(promise),这是一个对象,允许你说“当 fetch 完成时,做 ___”或“如果 fetch 失败,做 ___”。我们可以使用承诺(promises)而不是成功回调(callback)来在 AJAX 操作完成后触发逻辑,这种方法甚至允许我们将多个回调链式调用在一起:

var promise = book.fetch().done(function() {
        alert('the fetch completed successfully');
}).fail(function() {
        alert('an error occurred during the fetch');
});

虽然两种方法都可行,但 Promise 风格稍微更强大,因为它允许你轻松地从单个fetch操作中触发多个回调,或者只在多个fetch调用完成后触发回调。例如,假设我们想要获取两个不同的模型,并且只有在它们都从服务器返回后显示一个警告。使用成功/错误方法可能会有些尴尬,但使用 Promise 风格(结合 jQuery 的when函数),问题就简单解决了:

var warAndPeace = new Backbone.Model({{id: 55});
var fiftyShades = new Backbone.Model({id: 56});
var warAndPeacePromise = warAndPeace.fetch();
var fiftyShadesPromise = fiftyShades.fetch();
$.when( warAndPeacePromise, fiftyShadesPromise).then(function() {
    alert('Both books have now been successfully fetched!');
});

一旦fetch方法完成,Backbone 将获取服务器的响应,假设它是一个表示模型属性的 JSON 对象,并在该对象上调用set。换句话说,fetch实际上只是一个GET请求,后面跟着响应中返回的一系列内容。fetch方法旨在与仅返回模型 JSON 的 RESTful 风格 API 一起工作,因此如果你的服务器返回了其他内容,例如包裹在envelope对象中的相同 JSON,你需要覆盖一个名为parse的特殊Model方法。

fetch方法完成时,Backbone 在调用set之前通过parse传递服务器的响应,并且parse方法的默认实现简单地返回给它的对象而不做任何修改。然而,通过用你自己的逻辑覆盖parse方法,你可以告诉 Backbone 如何将这个响应从服务器发送的任何格式转换为 Backbone 属性对象。

例如,假设你的服务器不是直接返回书籍的数据,而是返回一个包含书籍属性的对象,这些属性包含在一个名为book的键中,如下所示:

{
    book: {
        pages: 300,
        name: 'The Hobbit'
    },
    otherInfo: 'stuff we don't care about'
}

通过覆盖Book类的parse方法,我们可以告诉 Backbone 丢弃otherInfo并仅使用Book属性作为我们书籍的属性:

var Book = Backbone.Model.extend({
    parse: function(response) {
        return response.pages; // Backbone will call this.set(response.pages);
    }
});

将数据保存到服务器

一旦你开始创建模型,你无疑会发现自己想要将它们的数据保存到远程服务器,这就是 Backbone 的save方法发挥作用的地方。正如其名所示,save允许你将模型的属性发送到你的服务器。它以 JSON 格式执行,通过模型url方法的指定 URL,通过 AJAX POSTPUT请求。与fetch一样,save允许你提供successerror回调选项,并且(与fetch类似)它返回一个 promise,可以在save方法完成后触发代码。

下面是一个使用基于 Promise 的回调的save示例:

var book = new Book({
    pages: 20,
    title: 'Ideas for Great Book Titles'
});
book.save().done(function(response) {
    alert(response); // alerts the the response's JSON
});

上述代码揭示了另一个问题:它只有在我们的服务器设置为接收所有模型的属性以 JSON 格式时才会工作。如果我们只想保存一些属性,或者如果我们想发送其他 JSON,例如包装的envelope对象,Backbone 的save方法将无法直接工作。幸运的是,Backbone 通过其toJSON方法提供了一个解决方案。当你保存时,Backbone 通过toJSON传递你的Models属性,而toJSON(就像parse一样)通常什么都不做,因为默认情况下toJSON只是简单地返回传递给它的任何内容。

然而,通过重写toJSON,你可以完全控制 Backbone 发送到你的服务器的内容。例如,如果我们想在另一个对象中包装我们的书籍 JSON 作为book属性,并添加一些其他信息,我们可以如下重写toJSON

var Book = Backbone.Model.extend({
    toJSON: function(originalJson) {
        return {
            data:  originalJson,
            otherInfo: 'stuff'
        };
    }
});
var book = new Book({pages: 100);
book.save(); // will send: {book: {pages: 100}, otherInfo: 'stuff'}

验证

在你向服务器发送任何内容之前,确保你发送的数据实际上是有效的是个好主意。为了解决这个问题,Backbone 为你提供了一个可选的重写方法:validatevalidate方法在每次保存时都会被调用,但其默认实现只是简单地返回true。然而,如果你重写了它,你可以添加任何你想要的验证逻辑,如果该逻辑返回false,则整个save操作将失败并返回false。例如,假设我们想要确保每本新书至少有 10 页:

var Book = Backbone.Model.extend({
    validate: function(attributes) {
        var isValid = this.get('pages') >= 10;
        return isValid;
    }
});
var tooShort = new Book({pages: 5});
var wasAbleToSave = tooShort.save(); // == false

注意

注意,如果验证失败,Backbone 甚至不会返回一个承诺;它只会返回false

因此,如果你在Model类中添加验证逻辑,你将需要在每次save时单独测试失败的验证情况;你不能仅仅依赖于返回的承诺的fail方法,因为不会返回这样的承诺。换句话说,以下代码将不起作用(并且会导致错误,因为false没有fail方法):

tooShort.save().fail(function() {
    // this code will never be reached
});

相反,你应该使用以下代码片段:

var savePromise = tooShort.save();
if (savePromise) {
    savePromise.done(function() {
        // this code will be reached if both the validation and AJAX call succeed
    }).fail(function() {
        // this code will be reached if the validation passes but the AJAX fails
    });
} else {
     // this code will be reached if the validation fails
}

注意

注意,你还可以通过使用isValid方法在任何时候检查你的模型的有效性,它将只返回验证结果(而不是save)。

返回 Underscore

这涵盖了Model的所有核心功能,但在我们继续探索Collections之前,值得提一下Model的一些便利方法。除了需要 Underscore 之外,Backbone 还将其许多Underscore方法作为快捷方法纳入其类中,Model是一个完美的例子。使用这些内置快捷方法的主要优势,除了更易于阅读之外,是它们在模型的属性上而不是在Model本身上操作。

例如,Underscore 有一个名为keys的方法,你可以使用它来获取对象上的所有键。你可以直接使用此方法来获取模型属性的键,如下所示:

var book = new Backbone.Model({pages: 20, title: 'Short Title'};
var attributeKeys = _.keys(book.attributes);
alert(attributeKeys); // alerts ['pages', 'title']

然而,如果你使用 Model 的那个相同方法版本,你可以稍微简化你的代码,并使其稍微易于阅读:

var attributeKeys =  book.keys();
alert(attributeKeys); // alerts ['pages', 'title'];

Model 上总共有六个这样的方法,虽然我们没有时间在这本书中解释所有这些方法,但这里简要总结一下每个方法的作用:

名称 它的作用
keys 这将返回每个属性的键
values 这将返回每个属性的值
pairs 这将返回一个属性键/值对的数组
invert 这将返回键和值颠倒的属性;例如,属性 {'pages': 20} 将变为 {'20': 'pages'}
pick 这将返回仅指定属性的键和值;例如,book.pick('pages') 将返回 {pages: 20},不包含任何标题或其他属性
omit 这将返回除指定属性外的每个属性的键和值;例如,book.omit('pages') 将返回 {title: 'Short Title'}

摘要

在本章中,我们探讨了 Backbone 的 Model 类。你学习了如何使用 getset 来更改属性,如何使用 onoff 来监听事件,以及如何使用 fetchsavedestroy 与远程服务器交换数据。你还学习了如何通过修改 urlurlRootidAttribute 属性来自定义 Backbone 以处理你的服务器 API,以及如何使用 parsetoJSON 来处理不同结构的数据。

在下一章中,我们将探讨 Backbone 的其他数据类 Collection。集合允许你一起存储多个模型,并且(就像模型一样)它们允许你监听变化并向/从服务器发送或检索数据。

第四章:使用 Collections 组织 Models

在本章中,我们将学习以下内容:

  • 创建新的 Collection 子类

  • 向 Collection 中添加和移除 Models

  • 在 Collection 的变化时触发其他代码

  • CollectionsModels 存储和检索到远程服务器

  • 对 Collection 中的 Models 进行沙箱索引

  • 利用从 Underscore 借来的便利方法

与 Collections 一起工作

在 Backbone 中,Models 可能是所有数据交互的核心,但很快你就会发现你需要与多个 Models 一起工作才能完成任何实际的工作,这就是 Collections 类发挥作用的地方。就像 Model 包装一个对象并提供额外的功能一样,Collections 包装一个数组,并且在使用数组时提供了几个优势:

  • Collections 使用 Backbone 的类系统,这使得定义方法、创建子类等变得容易

  • Collections 允许其他代码在 Models 被添加或从该 Collection 中移除,或者当 Collection 中的 Models 被修改时进行监听和响应

  • Collections 简化和封装了与服务器通信的逻辑

我们可以创建一个新的 Collection 子类,如下所示:

var Cats = Backbone.Collection.extend({
     // The properties and methods of Cats would go here
});

一旦我们创建了一个子类,我们可以使用 JavaScript 的 new 关键字实例化它的新实例,就像我们创建新的 Model 实例一样。与 Models 类似,Collections 有两个可选参数。第一个参数是一个初始的 ModelsModel 属性数组,第二个参数是 Collection 的选项。如果你传递一个 Model 属性数组,Backbone 会为你将它们转换为 Models,如下所示:

var cartoonCats = new Cats([{id: 'cat1', name: 'Garfield'}]);
var garfield = cartoonCats.models[0]; // garfield is a  Model

一旦创建了一个 Collection 子类,它将它的 Model 存储在一个名为 models 的隐藏属性中。与属性类似,模型不应该直接使用,而应该依赖 Collection 的方法来与其 Models 一起工作。Backbone 还为每个 Collection 提供了一个 length 属性,该属性始终设置为 Collection 子类中 Models 的数量:

var cartoonCats = new Cats([{name: 'Garfield'}, {name: 'Heathcliff'}]);
cartoonCats.models.length; // 2, but instead you can do ...
cartoonCats.length; // also 2

Collections 和 Models

每个 Collection 类都有一个 model 属性,默认为 Backbone.Model。您可以通过在创建 Collection 子类时将其作为选项提供来设置此属性:

var Cats = Backbone.Collection.extend({model: Cat});
var cats = new Cats(); // cats.model == Cat

然而,这个 model 属性实际上并不限制 Collection 可以持有的 Model 类型,实际上,任何 Collection 都可以持有任何类型的 Model

var Cat = Backbone.Model.extend();
var Cats = Backbone.Collection.extend({model: Cat});
var Dog = Backbone.Model.extend();
var snoopy = new Dog({name: 'Snoopy'});
var cartoonCats = new Cats([snoopy]);
cartoonCats.models[0] instanceof Dog; // true

这是因为 Collectionmodel 属性仅在通过 Collection 创建新的 Models 时使用。这种情况可能发生的一种方式是,当 Collection 使用一个属性数组初始化时,如下面的示例所示:

var snoopyAttributes = {id: 'dog1', name: 'Snoopy'};
var cartoonCats = new Cats([snoopyAttributes]);
cartoonCats.models[0] instanceof Cat; // true

另一种使用 Collectionmodel 属性的方法是通过 Collectioncreate 方法。在 Collection 上调用 create 并传递一个 attributes 对象,本质上等同于调用 new Collection.model(attributes),只不过在提供的属性被转换为 Model 之后,Backbone 会将这个 Model 添加到 Collection 中并保存它:

var cartoonCats = new Cats();
var garfield = cats.create({name: 'Garfield'}); // equivalent to:
// var garfield = new Cat({name: 'Garfield'});// cats.models.push(garfield);
// garfield.save();

添加和重置集合

除了在我们首次创建 Collection 时传入 Modelsattributes,我们还可以通过 add 方法将单个 Modelsattributes,或 Modelsattributes 的数组添加到 Collection 中:

var cats = new Backbone.Collection();
cats.add({name: 'Garfield'});
cats.models[0] instanceof Cat; // true

create 类似,add 方法也会使用 Collectionmodel 属性来创建结果 Models。如果您想替换 Collection 中现有的所有 Models 而不是添加更多,可以使用 reset 方法,如下所示:

var cats = new Backbone.Collection([{name: 'Garfield'}]);
cats.reset([{name: 'Heathcliff'}]);
cats.models[0].get('name'); // "Heathcliff"
cats.length; // 1, not 2, because we replaced Garfield with Heathcliff

索引

为了从 Collection 中获取或删除特定的 Models,Backbone 需要知道如何索引这些 Models。Backbone 以两种方式完成此操作:

  • 使用模型的 id 属性(如果有的话)(正如我们在上一章中讨论的,可以直接或通过设置模型的 idAttribute 属性间接设置)

  • 使用模型的 cid 属性(所有模型都有)

当您将 Modelattributes 添加到 Collection 中时,Backbone 使用前面提到的两种识别形式在 Collection_byId 属性中注册 Model_byId 是 Backbone 的另一个隐藏属性,但也是一个 private 属性(因为其名称以 _ 为前缀)。这意味着,与其他隐藏属性相比,您应该避免直接使用 _byId,而应通过 get 等方法间接使用它。get 方法通过 _byId 返回具有提供 ID 的 Model(如果有的话):

var garfield = new Cat({id: 'cat1', name: 'Garfield'});
var cats = new Backbone.Collection([garfield]);
cats.get('cat1'); // garfield
cats.get(garfield.cid); // also garfield
cats.get('cat2'); // returns undefined

_byId 属性还可以在另一个 Collection 方法中间接使用,该方法为 remove。正如其名称所暗示的,remove 会从 Collection 中移除具有提供 ID 的 Model

var cats = new Backbone.Collection([{
    id: 'cat1',    name: 'Garfield'
}]);
cats.remove('cat1');
cats.length; // 0

您还可以使用您已经熟悉的集合方法 getremoveCollection 中获取和删除 Models,因为它们也存在于 JavaScript 的数组中(并且以相同的方式工作)。以下是您可以使用的方法:

  • push

  • pop

  • unshift

  • shift

  • slice

最后,如果您想使用索引而不是 id 来从 Collection 中检索 Model,可以使用 at,这是 Collection 的最后一个模型访问方法。at 方法接受一个零基索引并返回该索引处的 Model(如果有的话):

var cats = new Backbone.Collection([
    {name: 'Garfield'},    {name: 'Heathcliff'}
]);
cats.at(1); // returns heathcliff

排序

如果您通过指定比较器来告诉 Backbone,它将自动对所有添加到 Collection 中的 Models 进行排序。与模型一样,当创建 Collection 类时,可以将其比较器作为选项提供:

var Cats = Backbone.Collection.extend({comparator: 'name'});
var cartoonCats = new Cats();
cartoonCats.comparator; // 'name'

虽然提供的比较器控制着集合如何内部排序其模型,但你也可以使用 Underscore 的排序方法之一来对集合进行排序,我们将在本章末尾详细说明。

比较器本身可以采用三种形式。第一种也是最简单的一种形式是集合中模型的属性名称。如果使用这种形式,Backbone 将根据指定属性的值对集合进行排序:

var cartoonCats = new Backbone.Collection([
    {name: 'Heathcliff'},
    {name: 'Garfield'}
], {
    comparator: 'name'
});
cartoonCats.at(0);// garfield, because his name sorts first alphabetically

比较器的第二种形式是一个接受单个参数的函数。如果使用这种形式,Backbone 将逐个将任何要排序的模型传递给该函数,然后使用返回的任何值来排序模型。例如,如果你想按字母顺序对模型进行排序,除非该ModelHeathcliff,你可以使用这种形式的比较器:

var Cats = Backbone.Collection.extend({
    comparator: function(cat) {
        if (cat.get('name') == 'Heathcliff') return '0';
        return cat.get('name');
    }
});
var cartoonCats = new Cats([ 
    {name: 'Heathcliff'},    {name: 'Garfield'}
]);
cartoonCats.at(0); // heathcliff, because "0" comes before "garfield" alphabetically

comparator的最终形式是一个接受两个Model参数并返回一个数字的函数,表示第一个参数应该在第二个参数之前(-1)还是之后(1)。如果没有对哪个模型应该排在第一的偏好,函数将返回0。我们可以使用这种风格实现相同的第一个Heathcliff比较器:

var Cats = Backbone.Collection.extend({
    comparator: function(leftCat, rightCat) {
        // Special case sorting for Heathcliff
        if (leftCat.get('name') == 'Heathcliff') return -1;
        // Sorting rules for all other cats
        if (leftCat.get('name') > rightCat.get('name')) return 1;
        if (leftCat.get('name') < rightCat.get('name')) return -1;
        if (leftCat.get('name') == rightCat.get('name')) return 0;
    }
});
var cartoonCats = new Cats([ 
    {name: 'Heathcliff'},    {name: 'Garfield'}
]);
cartoonCats.at(0);// heathcliff, because any comparison of his name will return -1

事件

就像模型一样,集合也有onoff方法,可以在集合中发生某些事件时触发逻辑。下表显示了可能发生的事件:

事件名称 触发
add 当模型或模型被添加到集合中时
remove 当模型或模型被从集合中移除时
reset 当集合被重置时
sort 当集合被排序时(通常在添加/移除之后)
sync 当集合的 AJAX 方法完成时
error 当集合的 AJAX 方法返回错误时
invalid 当由模型的save/isValid调用触发的验证失败时

集合,如模型一样,也有一个特殊的all事件,可以用来监听集合上发生的任何事件。此外,集合的on方法也可以用来监听由集合中的模型触发的任何模型事件,如下所示:

var cartoonCats = new Cats([{name: 'Garfield'}]);
cartoonCats.on('change', function(changedModel) {
    alert( changedModel.get('name') + ' just changed!');
});
cartoonCats.at(0).set('weight', 'a whole lot'); // alerts "Garfield just changed!"

服务器端操作

就像模型一样,集合有一个fetch方法,用于从服务器检索数据,以及一个save方法用于将数据发送到服务器。然而,有一个小差异,即默认情况下,集合的fetch将合并来自服务器的新数据与它已经拥有的任何数据。如果你希望完全用服务器数据替换本地数据,可以在获取数据时传递一个{reset: true}选项。集合还有urlparsetoJSON方法,这些方法控制着fetch/save的工作方式。

所有这些方法的工作方式与在模型上一样。然而,集合没有 urlRoot;虽然集合内部的模型可能有 ID,但集合本身没有,因此集合不需要使用 .urlRoot 生成它们的 URL:

var Cats = Backbone.Collection.extend({
     url: '/cats'
});
var cats = new Cats({name: 'Garfield'});
cats.save(); // saves Garfield to the server
cats.fetch(); // retrieves cats from the server and adds them

下划线方法

Collections,就像 Models 一样,从 Underscore 继承了一些方法,但集合实际上比模型有更多方法...确切地说有 28 个方法。我们不可能在这里详细说明所有这些方法,所以我将简要概述每个方法的作用,然后深入探讨一些更重要的一些方法。

注意,就像在模型上的下划线方法一样,其中一些方法是在 Collection 内部的 Models 的属性上操作,而不是在 CollectionModels 本身上操作。然而,同时,所有返回多个结果的方法都返回普通的 JavaScript 数组,而不是新的集合。这样做是为了性能原因,并且不应该成为问题,因为如果您需要将这些结果作为集合,您可以简单地创建一个新的集合并将结果数组传递进去。

名称 它的作用
each 这遍历集合中的每个模型
map 这通过转换集合中的每个模型返回一个值数组
reduce 这返回由集合中的所有模型生成的一个单一值
reduceRight reduce 相同,但它按反向迭代
find 这返回第一个与测试函数匹配的模型
filter 这返回所有与测试函数匹配的模型
reject 这返回所有不匹配测试函数的模型
every 如果集合中的每个模型都匹配测试函数,则返回 true
some 如果集合中的某些(任何)模型与测试函数匹配,则返回 true
contains 如果集合包含提供的模型,则返回 true
invoke 这将在集合中的每个模型上调用指定的函数,并返回结果
max 这会将转换函数应用于集合中的每个模型,并返回返回的最大值
min 这会将转换函数应用于集合中的每个模型,并返回返回的最小值
sortBy 这根据指示的属性返回排序后的集合模型
groupBy 这根据指示的属性返回集合模型的分组
shuffle 这返回从集合中随机选择的一个或多个模型
toArray 这返回集合模型的数组
size 这返回集合中模型的计数,例如 Collection.length
first 这返回集合中的第一个(或前 N 个)模型
initial 这返回集合中的所有模型,除了最后一个
rest 这返回集合中第一个 N 个模型之后的所有模型
last 返回 Collection 中最后一个(或最后 N 个)Model
without 返回 Collection 中除了提供的 Model 之外的所有 Model
indexOf 返回 Collection 中第一个提供的 Model 的索引
lastIndexOf 返回 Collection 中最后一个提供的 Model 的索引
isEmpty 如果 Collection 不包含任何 Model,则返回 true
chain 返回一个可以连续调用多个 Underscore 方法的 Collection 版本(链式调用);在链的末尾调用 value 方法以获取调用结果
pluck 返回 Collection 中每个 Model 的提供的属性
where 返回 Collection 中所有与提供的属性模板匹配的 Model
findWhere 返回 Collection 中第一个与提供的属性模板匹配的 Model

之前提到的 methods

前面的列表中的一些方法你应该已经很熟悉了,因为我们已经在 第二章 使用 Backbone 类的面向对象 JavaScript 中解释了 eachmapinvokepluckreduce 方法。如果你调用它们的 Underscore 版本,并将 Collection.models 传递给它们,这些方法将按相同的方式工作,如下所示:

var cats = new Backbone.Collection([ 
    {name: 'Garfield'},    {name: 'Heathcliff'}
]);
cats.each(function(cat) {
    alert(cat.get('name'));
}); // will alert "Garfield", then "Heathcliff"

测试方法

剩余的一些方法主要关注测试 Collection 是否通过某种类型的测试。containsisEmpty 方法允许你检查 Collection 是否包含指定的 ModelModels,或者是否包含任何模型:

var warAndPeace = new Backbone.Model();
var books = new Backbone.Collection([warAndPeace]);
books.contains(warAndPeace); // true
books.isEmpty(); // false

对于更高级的测试,你可以使用 everysome 方法,这些方法允许你指定自己的测试逻辑。例如,如果你想知道 Collection 中的任何书籍是否超过了一百页,你可以使用 some 方法,如下所示:

var books = new Backbone.Collection([
    {pages: 120, title: "Backbone Essentials 4: The Reckoning"},
    {pages: 20, title: "Even More Ideas For Fake Book Titles"}
]);
books.some(function(book)  {
    return book.get('pages') > 100;
}); // true
books.every(function(book)  {
    return book.get('pages') > 100;
}); // false

提取方法

另一种使用 Underscore 方法的方式是从 Collection 中提取特定的 ModelModels。最简单的方法是使用 wherefindWhere 方法,它们返回所有(或 findWhere 的情况下,第一个)与提供的属性对象匹配的 Model。例如,如果你想从 Collection 中提取所有恰好有一百页的书籍,你可以使用 where 方法,如下所示:

var books = new Backbone.Collection([
    { 
        pages: 100,        title: "Backbone Essentials 5: The Essentials Return"
    }, {
        pages: 100,        title: "Backbone Essentials 6: We're Not Done Yet?"
    },{
        pages: 25,        title: "Completely Fake Title"
    } 
]);
var hundredPageBooks = books.where({pages: 100});
//  hundredPageBooks array of all of the books except Completely Fake Title
var firstHundredPageBook = books.findWhere({pages: 100});
firstHundredPageBook; // Backbone Essentials 5: The Essentials Return

如果我们需要更复杂的筛选怎么办?例如,如果我们不想提取恰好有一百页的所有书籍,而是想提取任何有一百页或更多页的书籍,我们应该怎么办?对于这种提取,我们可以使用更强大的 filter 方法,或者它的逆方法 reject 方法,如下所示:

var books = new Backbone.Collection([
    { 
        pages: 100,        title: "Backbone Essentials 5: The Essentials Return"
    }, {
        pages: 200,        title: "Backbone Essentials 7: How Are We Not Done Yet?"
    }, {
        pages: 25,        title: "Completely Fake Title"
    }
]);
var hundredPageOrMoreBooks = books.filter(function(book)  {
    return book.get('pages') >= 100;
});
hundredPageOrMoreBooks; // again, this will be an array of all books but the last
var hundredPageOrMoreBooks = books.reject(function(book)  {
    return book.get('pages') < 100;
});
hundredPageOrMoreBooks; // this will still be an array of all books but the last

排序方法

最后,我们有 toArraysortBygroupBy,所有这些都可以让您获取存储在 Collection 中的所有 Models 的数组。然而,虽然 toArray 只简单地返回集合中的所有模型,sortBy 则根据提供的标准返回排序后的模型,而 groupBy 则将模型分组到更高级别的数组中。例如,如果您想获取一个按字母顺序排序的集合中的所有书籍,可以使用 sortBy

var books = new Backbone.Collection([
    {title: "Zebras Are Cool"},
    {title: "Alligators Are Also Cool"},
    {title: "Aardvarks!!"}
]);
var notAlphabeticalBooks = books.toArray();
notAlphabeticalBooks;// will contain Zebras, then Alligators, then  Aardvarks
var alphabeticalBooks = books.sortBy('title');
alphabeticalBooks;// will contain Alligators, then Aardvarks, then Zebras

如果您想根据标题的首字母将它们组织成组,可以使用 groupBy,如下所示:

var firstLetterGroupedBooks = books.groupBy(function(book) {
    return book.get('title')[0];
});
// will be an array of [Alligators,  Aardvarks], [Zebras]

摘要

在本章中,我们探讨了 Backbone 的 Collection 类。您学习了如何添加和删除模型和 Model 属性,如何使用 onoff 方法来监听事件,如何控制集合的排序和索引,以及如何使用 fetchsave 与远程服务器交换数据。我们还考察了集合的许多 Underscore 方法以及它们如何被用来实现集合的全部功能。

在下一章中,我们将探讨 Backbone 的 View 类。视图允许您使用我们已介绍过的模型和集合的数据来渲染一个 HTML 页面,或者其子集。

第五章。使用视图添加和修改元素

在本章中,我们将探讨以下内容:

  • 创建新的View类和实例

  • 使用视图渲染 DOM 元素

  • 将视图连接到模型和集合

  • 使用视图响应 DOM 事件

  • 确定最适合您项目的渲染样式

视图是 Backbone 网站的核心

虽然数据在任何应用程序中都很重要,但没有用户界面的应用程序是不完整的。在网络上,这意味着您有一个 DOM 元素的组合(用于向用户显示信息)和事件处理器(用于接收用户的输入)。在 Backbone 中,这两者都由视图管理;事实上,可以说视图几乎控制了 Backbone 网站上的所有输入和输出。

正如模型封装一个attributes对象和集合封装一个models数组一样,Views在名为el的属性中封装一个 DOM 元素。然而,与属性和模型不同的是,el不是隐藏的,Backbone 不会监视其变化,因此直接引用视图的el是没有问题的:

someModel.attributes.foo = 'bar'; // don't do this
$('#foo').append(someView.el); // feel free to do this

要创建一个新的View子类,只需像创建新的ModelCollection子类一样扩展Backbone.View,如下所示:

var MyView = Backbone.View.extend({
    // instance properties and methods of MyView go here
}, {
    // static properties and methods of MyView go here
});

实例化视图

正如我们在第二章中提到的,使用 Backbone 类的面向对象 JavaScript,视图在实例化时只接受一个options参数。这些选项中最重要的部分是el属性,它定义了视图将封装为其el的 DOM 元素。视图的el选项可以通过以下三种方式之一定义:

  • HTML(new Backbone.View ({el: <div id='foo'></div>})

  • jQuery 选择器(new Backbone.View ({el: '#foo'})

  • DOM 元素(new Backbone.View ({el: document.getElementById('foo')})

您也可以选择不提供el选项,在这种情况下,Backbone 将为您创建视图的el选项。默认情况下,Backbone 将简单地创建一个空的DIV元素(<div></div>),尽管您可以在创建视图时提供其他选项来更改这一点。您可以提供以下选项:

  • tagName:这更改了生成的元素的标签从div到指定的值

  • className:这指定了元素应该具有的 HTML class属性

  • id:这指定了元素应该具有的 HTML id属性

  • attributes:这指定了元素应该具有的 HTML 属性

技术上,您可以使用attributes选项同时指定视图元素的类和 ID,但由于它们对于视图的定义非常重要,Backbone 提供了单独的idclassName选项。

在实例化视图时,除了定义前面的选项外,你也可以选择在 View 类中定义它们。例如,如果你想创建一个生成具有 nifty 类的 <form> 元素的 View 类,你应该这样做:

var NiftyFormView = Backbone.View.extend({
    className: 'nifty',
    tagName: 'form'
});
var niftyFormView = new NiftyFormView();
var niftyFormView2 = new Backbone.View({
    className: 'nifty',
    tagName: 'form'});
// niftyFormView and niftyFormView2 have identical "el" properties

渲染视图内容

虽然 Views 可以通过 el 选项来定义它们的初始元素,但它们很少会保持这个 el 选项不变。例如,一个 list 视图可能会将 <ul> 元素作为其 el 选项,然后填充这个列表的 <li> 元素(可能使用来自集合的数据)。在 Backbone 中,这种内部 HTML 的生成是在视图的 render 方法中完成的。

然而,当你尝试使用未修改视图的 render 方法时,你很快就会注意到 Backbone 默认实现的问题,如下所示:

render: function() {
    return this;
}

如你所见,默认的 render 方法实际上并没有渲染任何内容。这是因为不同的视图可能具有完全不同的内容,所以 Backbone 提供仅一种生成内容的方式并不合理。相反,Backbone 完全将视图的 render 方法的实现留给了你。

在本章的后面部分,我们将考虑你可能在网站上实现 render 方法的各种策略,但在我们到达那里之前,让我们首先检查如何将模型和集合连接到视图,以及视图如何处理事件绑定。

将视图连接到模型和集合

当创建视图时,它可以接受两个重要的选项:ModelCollection。这两个选项都是简单的属性选项,也就是说 Backbone 实际上并没有对它们做任何事情,只是将它们作为属性添加到视图中。即便如此,这些属性在你想要显示或编辑之前生成的数据时非常有用。例如,如果你想将一个视图与一个你创建的 book 模型关联起来,你可以这样做:

var book = new Backbone.Model({ 
    title: 'Another Fake Title? Why?'
});
var bookView = new Backbone.View({model: book});
// book == bookView.model;

当你为你的 book 视图编写 render 方法时,你可以使用该模型来获取生成适当 HTML 所需的数据。例如,以下是一个简单的 render 实现,借鉴了 Backbone 文档的内容:

render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
}

如你所见,假想的 render 方法将模型的 toJSON 方法的输出传递给视图的模板系统,这可能是为了让模板系统可以使用模型的属性来渲染视图。

访问视图的 el 元素

一旦创建了一个视图,你可以通过引用其 el 属性在任何时候访问它所包裹的元素。你还可以通过引用视图的 $el 属性来访问相同元素的 jQuery 包装版本。以下是一个代码示例:

var formView = new Backbone.View({tagName: 'form'});
formView.$el.is('form'); // returns true

Backbone 还提供了一个方便的快捷方式,当你想要访问视图元素内部的元素时:$ 方法。当你使用这个方法时,它实际上等同于从视图元素调用 jQuery 的 find 方法。因为元素搜索仅限于仅查看视图的 el 元素,而不是整个页面的 DOM,所以它的性能将远远优于全局 jQuery 选择。

例如,如果你创建了一个包含 <input> 元素的 <form> 元素的视图,你可以使用视图的 $ 方法来访问 <input> 元素,如下所示:

var formView = new Backbone.View({
    el: '<form><input value="foo" /></form>'
});
var $input = formView.$('input');
$input.val(); // == "foo"

简单谈谈变量命名问题

当在 Backbone(或更一般地,在 JavaScript 中)中处理 jQuery 对象时,可能很难判断给定的变量是指 View 元素还是其 el 元素。为了避免混淆,许多程序员(包括 Backbone 和 jQuery 的作者)在指向 jQuery 对象的任何变量前都加上 $ 符号,如下所示:

var fooForm = new Backbone.View({id: 'foo', tagName: 'form'});
var $fooForm = fooForm.$el;

虽然这种做法在 Backbone 中并非必需,但采用它可能会让你在未来避免困惑。

事件处理

虽然我们之前描述的视图非常适合创建和/或包装现有的 HTML 元素,但它们在响应用户交互方面并不很好。解决这个问题的方法之一是在视图的 initialize 方法内部连接事件处理程序,如下所示:

var FormView = Backbone.View.extend({
    id: 'clickableForm',
    initialize: function() {
        this.$el.on('click', _.bind(this.handleClick, this));
    },
    handleClick: function() {
        alert('You clicked on ' + this.id + '!');
    }
});
var $form = new FormView().$el;
$form.click(); // alerts "You clicked on clickableForm!"

然而,这种做法有两个问题,如下所述:

  • 它的可读性并不高

  • 我们必须在创建事件处理程序时绑定它,这样我们才能从其中引用 this

幸运的是,Backbone 提供了一种更好的方法,形式为一个可选属性,称为 events。我们可以使用这个 events 属性来简化之前的示例:

var FormView = Backbone.View.extend({
    events: {'click': 'handleClick'},
    id: 'clickableForm',

    handleClick: function() {
        alert('You clicked on ' + this.id + '!');
    }
});
var $form = new FormView().$el;
$form.click(); // alerts "You clicked on clickableForm!"

如你所见,使用 events 属性甚至可以让我们不必编写任何 initialize 方法,同时它还负责将 handleClick 事件处理程序绑定到视图本身。此外,通过以这种方式定义事件处理程序,我们让 Backbone 了解它们,以便它可以根据需要管理它们的移除或重新绑定。

当你实例化一个视图时,Backbone 会调用一个 delegateEvents 方法,该方法检查 events 属性,绑定其中找到的所有处理程序,然后使用 jQuery 创建适当事件的监听器。还有一个相应的 undelegateEvents 方法,可以用来移除所有事件处理程序。通常,你不需要自己调用这些方法,因为 Backbone 会为你调用它们。

然而,如果你在未通知 Backbone 的情况下更改视图的 el 元素(例如,yourView.el = $('#foo')[0]),Backbone 就不会知道它需要将事件连接到新元素,你将不得不自己调用 delegateEvents

或者,你不必手动更改视图的 el 元素,然后再调用 delegateEvents,你可以使用视图的 setElement 方法同时完成这两步,如下所示:

var formView = new Backbone.View({el: '#someForm'});
formView.setElement($('#someOtherForm'));
// formView.el == someOtherForm

渲染策略

现在我们已经涵盖了视图的所有功能,是时候回到如何渲染视图的问题了。具体来说,让我们看看当你重写 render 方法时,你可以选择的主要选项,这些选项将在接下来的章节中解释。

简单模板

渲染的第一种,也许是最明显的方法,是使用一个简单、无逻辑的模板系统。Backbone 文档中提供的 render 方法是这种方法的完美示例,因为它依赖于 Underscore 库的 template 方法:

render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
}

template 方法接受一个字符串,该字符串包含一个或多个特别指定的部分,然后将这个字符串与一个对象组合起来,用该对象的值填充指定的部分。以下示例是最好的解释:

var author ={
    firstName: 'Isaac',
    lastName: 'Asimov',
    genre: 'science-fiction'
};
var templateString = '<%= firstName %> <%= lastName %> was a '+
                     'great <%= genre %> author.';
var template = _.template(templateString);
alert(template(author));
// will alert "Isaac Asimov was a great science-fiction author."

虽然 template 函数可以与任何字符串和数据源一起使用,但当它作为 Backbone 视图的一部分使用时,通常与 HTML 字符串和 Backbone.Model 数据源一起使用。Underscore 的 template 函数让我们能够将它们结合起来,轻松创建一个 render 方法。

例如,如果你想创建一个包含一个 <h1> 标签的 <div> 标签,该标签内包含作者姓名和类型,并且围绕类型添加强调(换句话说,一个 <em> 标签),你可以创建一个包含所需 HTML 和用于第一个名字、姓氏和类型的占位符的 template 字符串。然后我们可以使用 _. 模板创建一个 template 函数,并在 render 方法中使用作者模型的属性调用这个 template 函数。

当然,正如我们在第三章中提到的,使用模型访问服务器数据,如果我们不直接访问模型属性,会更安全;因此,我们将想要使用模型的 toJSON 方法。将所有这些放在一起,我们得到以下代码块:

var authorTemplate =  _.template(
    '<h1>' +
        '<%= firstName %> <%= lastName %> was a ' +
        '<em><%= genre %></em> author.' +
    '</h1>'
);
var AuthorView = Backbone.View.extend({
  template: authorTemplate,
  render: function() {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
  }
});
var tolkien = new Backbone.Model({
    firstName: 'J.R.R.', // Tolkien's actual first name was John
    lastName: 'Tolkien',
    genre: 'fantasy'
});
var tolkienView = new AuthorView({model: tolkien});
var tolkientHtml = tolkienView.render().$el.html();
alert(tolkienHtml);
// alerts "<h1>J.R.R. Tolkien was a <em>fantasy</em> author.</h1>"

这种方法的一个主要优点是,因为我们的 HTML 完全分离成一个字符串,我们可以选择将其移动到一个单独的文件中,然后使用 jQuery 或依赖库,如 Require.js 带入。如果团队中有这样的设计师,以这种方式存储的 HTML 可以更容易地被设计师处理。

高级模板

除了使用 Underscore 的template方法外,您还可以使用许多高质量的第三方模板库之一,例如 Handlebars、Mustache、Hamljs 或 Eco。所有这些库都提供了将数据对象与模板字符串结合的基本能力,但它们还提供了在模板中包含逻辑的可能性。例如,这里有一个 Handlebars 模板字符串的例子,它使用基于提供的isMale数据属性的if语句,在模板中选择正确的性别代词:

var heOrShe = '{{#if isMale }}he{{else}}she{{/if}}';

如果我们使用 Handlebars 的compile方法将其转换为模板,我们就可以像使用 Underscore 模板一样使用它:

var heOrSheTemplate = Handlebars.compile(heOrShe);
alert(heOrSheTemplate({isMale: false})); // alerts "she"

我们将在第十一章中更详细地讨论 Handlebars,(不)重新发明轮子:利用第三方库,但您现在需要理解的重要事情是,无论您选择哪个模板库,您都可以轻松地将它作为您视图的render方法的一部分。困难的部分是决定您是否想在模板中包含逻辑,如果是的话,包含多少。

基于逻辑

除了依赖模板库外,另一种选择是使用 Backbone 的 View 逻辑、jQuery 方法以及/或字符串连接来驱动您的render方法。例如,您可以在不使用模板的情况下重新实现前面的AuthorView

var AuthorView = Backbone.View.extend({
  render: function() {
    var h1View = new Backbone.View({tagName: 'h1'});
    var emView = new Backbone.View({tagName: 'em'});
    emView.text(this.model.get('genre'));
    h1View.$el.html(this.model.get('firstName') + ' ' +
                    this.model.get('lastName') + ' was a ');
    h1View.$el.append(emView.el, ' author.');
    this.$el.html(h1View.el);
    return this;
  }
});

如您所见,纯逻辑方法既有优点也有缺点。当然,主要优点是我们根本不需要处理任何模板。这意味着我们可以清楚地看到正在使用的逻辑,因为模板库的代码中没有隐藏任何内容。此外,这种逻辑没有限制;您可以做任何在 JavaScript 代码中通常会做的事情。

然而,由于没有使用模板,我们也失去了一部分可读性,如果我们的团队中有设计师想要编辑 HTML,他们会发现that代码非常难以处理。在第一个版本中,他们会看到熟悉的 HTML 结构,但在第二个版本中,他们即使(可能)不熟悉编程,也必须与 JavaScript 代码打交道。另一个缺点是,由于我们将逻辑与 HTML 代码混合在一起,我们无法将 HTML 存储在单独的文件中,就像它是模板一样。

综合方法

前三种方法都有优点和缺点,您可以选择结合其中一些方法,而不是仅仅满足于一种。例如,您没有理由不能使用模板系统(为了简单起见是 Underscore 的,为了强大是外部的),然后在需要做不适合模板的事情时切换到使用逻辑。

例如,假设你想要渲染一个带有由模型属性派生的class HTML 属性的<ul>,这似乎是使用 JavaScript 逻辑更容易做到的事情。然而,假设你还想让这个<ul>包含基于模板的文本的<li>元素,并用集合中模型的属性填充;这似乎是我们最好用模板来处理的事情。幸运的是,没有任何东西阻止你结合这两种方法,如下所示:

var ListItemView = Backbone.View({
    tagName: 'li',
    template: _.template('<%= value1 %> <%= value2 %> are both ' +
                         'values, just like <%= value3 %>'),
    render: function() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    }
});
var ListView = Backbone.View.extend({
  tagName: 'ul',
  render: function() {
    this.$el.toggleClass('boldList', this.model.get('isBold'));
    this.$el.toggleClass('largeFontList',
                         this.model.get('useLargeFont'));
    this.$el.empty();
    this.collection.each(function(model) {
        var itemView = new ListItemView({model: model});
        this.$el.append(itemView.render().el);
    }, this);
    this.$el.html(this.template(this.model.toJSON()));
    return this;
  }
});

当然,混合方法意味着你不会总是得到两种方法的所有好处。最明显的问题将是,通过 Backbone 的视图或其他 JavaScript 逻辑渲染的任何 HTML 将无法被非编程设计师访问。如果你的团队中没有这样的角色,那么这种限制不会让你烦恼,但如果你有,你可能应该尽量将尽可能多的 HTML 放在模板中。

其他渲染考虑因素

除了决定你能在渲染方法中依赖模板以及你能依赖多少之外,还有一些其他的事情在你编写它们时需要考虑。这些选择都不是完全二元的,但你应努力追求尽可能一致的方法。这意味着真正的疑问不在于你应该选择哪一个,而在于你将在何时选择一个而不是另一个。最终,一个一致的战略将使得你和你的同事在编写新的render方法或编辑现有的方法时不需要思考应该使用哪种方法。

子视图

在 Backbone 中,你通常会想要为容器元素(如<ul>元素)创建一个视图,并为它的子元素(如<li>元素)创建另一个视图。在创建这些子视图时,你可以选择让子视图创建子元素,或者你可以让父视图创建它们并将一个 jQuery 选择器作为子视图的el元素传递,如下所示:

var ListView1 = Backbone.View.extend({
    render: function() {
        this.$el.append(new ListItemView().render().el);
    }
});
var ListView2 = Backbone.View.extend({
    render: function() {
        this.$el.append('<li>');
        new ListItemView({el: this.$('li')}).render();
    }
});

在视图中生成子元素的主要优势是封装。通过采取这种方法,你可以将所有与子元素相关的逻辑都保持在子视图中。这将使得代码更容易处理,因为你不需要同时考虑子视图和父视图。

另一方面,当你使用模板时,可能不方便将模板分成几部分来渲染整体容器元素。此外,在父视图中渲染子元素可以在 DOM 生成视图和纯粹的事件处理视图之间提供良好的分离,这可以帮助你更好地整理逻辑。

两种方法都不必走向极端。虽然你可以有一个单页 View,其中包含所有内容的单个模板,或者你可以让每个子 View 生成其 DOM 元素,但你也可以选择在你的 Views 中采用混合方法。实际上,除非你想要一个用于顶级渲染的单页模板,该模板生成整个 DOM,否则你可能想要采取结合的方法,所以真正的问题是你希望依赖每种技术的程度有多大?你越依赖父 View,就越容易看到大局;但如果你更多地依赖子 View 中的 DOM 生成,就越容易封装你的逻辑。

可重复渲染与一次性渲染

你还应该考虑是否设计你的render方法只渲染一次(通常在 View 首次创建时),或者是否希望能够在响应变化时多次调用你的render方法。只设计为渲染一次的render方法编写和操作起来最简单;当你的 View 的 DOM 元素需要更改时,你只需再次调用 View 的render方法,DOM 就会更新。

然而,这种方法也可能导致性能问题,尤其是如果涉及的 View 包含大量子 View。为了避免这种情况,你可能想要编写render方法,而不是盲目地替换其内容,而是根据变化来更新它们。例如,如果你有一个<ul>元素,其中一些子<li>元素需要根据用户操作出现或消失,你可能希望你的render方法首先检查<li>元素是否已经存在,然后简单地应用更改它们的显示样式,而不是每次都从头开始重建整个<ul>

再次强调,你可能根据你编写的 View 类型混合两种方法。如果你将性能视为高优先级,那么你可能会仔细思考如何在render方法中重用之前生成的 DOM 元素。另一方面,如果性能不太重要,为一次性 DOM 生成设计你的render方法将使它的逻辑更加简单。此外,如果出现性能问题,你总是可以更改相关的render方法以重用现有元素。

返回值 – 这个或这个.$el

Backbone 文档中的示例render方法在完成时返回this。这种方法允许你通过简单地调用render方法的返回值来轻松访问el属性,如下所示:

var ViewThatReturnsThis = Backbone.View.extend({render: function() {
        // do the rendering
        return this;
    }
});
var childView = new ViewThatReturnsThis();
parentView.$el.append(childView.render().$el);

然而,你可以让它更容易访问 View 的el,通过返回那个el而不是这个:

var ViewThatReturns$el = Backbone.View.extend({render: function() {
        // do the rendering
        return this.$el;
    }
});
var childView = new ViewThatReturns$el();
parentView.$el.append(childView.render());

render方法返回$el的缺点是,你不能再从render方法的返回值链式调用其他 View 方法:

parentView.$el.append(childView.render().nextMethod());// won't work

因此,这个选择实际上取决于你计划对视图进行多少次后渲染以及你计划从视图外部调用多少次这些后渲染方法(如果你在渲染方法内部调用它们,它们的返回值是不相关的)。如果你不确定,最安全的做法是遵循 Backbone 文档中的 render 方法,并返回 this,因为它提供了最大的灵活性(代价是代码稍微不那么简洁)。

小贴士

无论你选择什么,你可能会希望在整个视图之间保持一致性。如果不这样做,你将发现自己每次想要使用视图的 render 方法时,都需要不断查找。

摘要

在本章中,我们探讨了 Backbone 的 View 类。你学习了如何通过实例化视图来创建 DOM 元素,以及如何将现有元素传递给新的视图。你还学习了如何使用视图在视图的 el 上设置事件处理器,以及如何使用 undelegatedelegate 方法移除/重新附加这些处理器。最后,我们考虑了 Backbone(故意)为 render 方法提供的空实现,这为我们提供了许多不同的选项来实现自己的 render 方法,以及哪些因素影响了这些选择。

在下一章中,我们将探讨 Backbone 的最后一个类,即 Router 类。这个类允许我们仅使用视图而不是单独的 HTML 文件来模拟传统网页。

第六章. 使用路由器创建客户端页面

在本章中,我们将检查 Backbone 的 Router 类,以了解以下内容:

  • 如何通过模拟浏览器历史记录来创建虚拟页面

  • 如何创建新的 Router 子类和实例

  • 定义路由模式的各种机制

  • 如何处理特殊案例,例如不存在的路由

  • 如何使用页面视图来一致地渲染路由

Backbone 路由器使单页应用程序成为可能

到目前为止,我们查看的所有 Backbone 功能都是对先前存在功能的增强;Backbone 的类改进了原生 JavaScript 类——ModelsCollections——以及增强对象和数组的功能,而 Views 则改进了 jQuery 已经提供的 DOM 操作和事件绑定。

然而,Routers 打破了这一趋势。Routers 不是增强现有功能,而是允许你做一件完全新的事情:仅使用一个 HTML 页面创建整个网站。如 第一章 中所述,使用 Backbone 构建单页网站,单页应用程序相对于传统多页网站具有几个优点:最显著的是性能增强和完全的客户端控制。Backbone 的 Router 类使得所有这些成为可能。

路由器的工作原理

在传统的多页网站上,浏览器在无需额外努力的情况下提供路由和页面历史记录。当用户访问网站上的 URL 时,浏览器会请求该 URL 的内容,然后当用户移动到另一个 URL 时,浏览器会跟踪用户浏览历史记录中的变化。然而,在由 Backbone 驱动的网站上,这两者都必须由 Router 来处理。当用户访问新页面时,Router 确定要渲染哪些 Views,当他们离开该页面时,Router 会通知浏览器新的历史记录条目。这使得由 Backbone 驱动的网站可以像传统网站一样运行,包括允许用户使用浏览器的 Back 按钮。

Router 可以通过两种不同的方法来实现这一点,Backbone 允许你决定使用哪种方法。第一种,也是默认的方法是基于哈希的路由,它利用了 URL 片段(也称为命名锚点)。你可能之前见过带有这些片段的 URL,例如 www.example.com/foo#bar。通过使用这些片段来定义网站的页面,Backbone 可以知道要加载哪个页面,并告诉浏览器如何跟踪用户在页面之间的移动。

如果你不想让你的用户在 URL 中看到哈希值,Backbone 提供了一个基于最近添加的 HTML5 pushState 技术的第二个选项。虽然这确实有助于使 URL 看起来更干净,但 pushState 方法存在一个显著的缺点。尽管 pushState 在大多数网络浏览器中都可用,但像 Internet Explorer 9 及以下版本这样的旧浏览器不支持它。如果你尝试在一个不支持 pushState 的浏览器中使用基于 pushState 的路由,Backbone 将回退到基于哈希的路由。即使只有 1%的用户使用旧浏览器,这也意味着这 1%的用户将看到与其它用户不同的 URL,这可能会在(例如)不同浏览器的用户分享链接时造成混淆。

基于 pushState 的路由也有一个小的缺点:如果一个用户在 pushState 驱动的网站上刷新页面,浏览器将请求从服务器获取该 URL。这可以通过简单地让服务器在浏览器请求此类 URL 时返回你的应用程序的单个 HTML 页面来解决。然而,鉴于这两个缺点,使用 pushState 仅在你非常关心 URL 的外观并且相信所有用户都将使用现代浏览器的情况下推荐。

Backbone.history

由于可以同时使用多个Routers(尽管通常不推荐这样做,我们将在本章后面讨论),Backbone 有一个名为Backbone.history的独立全局对象,用于处理历史管理。然而,重要的是要理解,这个对象实际上并没有替换你的浏览器历史;相反,它只是帮助管理添加到历史记录的内容以及何时添加。

当你加载一个使用 Backbone Router的页面时,你需要调用这个历史对象的start方法,以便告诉 Backbone 开始路由。这个start方法还允许你告诉 Backbone 你需要使用哪种路由技术。如果你想 Backbone 依赖于基于哈希的路由,你可以简单地调用这个this方法而不带任何参数:

Backbone.history.start();

如果你希望使用基于 pushState 的路由,你需要提供一个额外的参数来表示这一点:

Backbone.history.start({pushState: true});

路由和页面的区别

路由和页面是非常相似的概念,并且经常被 Backbone 程序员互换使用。这是自然的,因为路由本质上只是 Backbone 对页面的实现。然而,两者之间至少在将多页网站的页面与 Backbone 单页应用的路由进行比较时,存在重要的区别。

在传统的多页网站上,用户访问的每个新页面都需要你向网站服务器发送一个新的请求。然而,在一个由 Backbone 驱动的网站上,用户可以导航到他们想要的任意多页(路由),只有在需要获取新数据时才会发送新的请求。仅此一项功能就使得 Backbone 驱动的网站速度显著提高。

另一个重要的区别是,标准网页会触发整个页面的加载,而路由只会触发特定的 JavaScript 函数。这意味着与传统应用不同,传统应用必须限制其创建的页面数量(以及因此的 HTTP 请求数量),路由可以用于几乎任何可能的状态变化。路由可以以类似传统网页的方式使用,或者用于对页面进行更小的更改。它们甚至(很少)可以在 DOM 没有任何变化的情况下使用。

当然,虽然 Backbone 路由有很多优点,但它们也有一些缺点。最重要的缺点是,由于它们不会刷新页面,现有的 DOM 元素、样式更改和事件绑定不会被自动清除。这意味着你需要自己处理这些清理任务。幸运的是,这并不难做,特别是如果你依赖于我们很快将要介绍的页面视图。

创建一个新的 Router

与所有 Backbone 类一样,你可以通过使用extend来创建一个新的Router子类,其中第一个参数提供了类的实例属性和方法,第二个参数提供了静态属性和方法:

var MyRouter = Backbone.Router.extend({
    // instance methods/properties go here
}, {
    // static methods/properties go here
);

Views类似,Routers在实例化时只接受一个options参数。这个参数完全是可选的,它唯一真正接受的选项是routes选项。正如之前提到的,一旦Router被创建,你需要在它能够处理路由之前运行Backbone.history.start()

myRouter = new Backbone.Router({
    routes: {
        'foo': function() {
            // logic for the "/foo" or "#foo" route would go here }
    }
});
Backbone.History.start(); // siteRouter won't work without this

创建路由

在创建Router时传递路由的问题在于,你必须提供所有路由函数以及你的路由,这(特别是如果你网站上有很多路由)很容易变得混乱。相反,许多 Backbone 程序员将他们的路由定义在Router类本身上,如下所示:

var SiteRouter = Backbone.Router.extend({
    routes: {
        'foo': 'fooRoute'
    },
    fooRoute: function() {
        // logic for the "/foo" or "#foo" route would go here }
});
var siteRouter = new SiteRouter();
Backbone.History.start(); // siteRouter won't work without this

如你所见,这种方法简化了路由的定义,使它们更类似于ModelsCollectionsevents属性。你不需要与路由本身一起定义路由方法,只需给路由指定一个路由方法的名字,Backbone 就会在Router类内部查找该方法。

还有一种第三种方法可以添加路由,那就是使用 Backbone 的route方法。这个方法接受route作为第一个参数,路由的名称作为第二个参数,路由的处理函数作为第三个参数。如果省略第三个参数,Backbone 将在Router上查找与route本身同名的方法:

For example:SiteRouter = new Backbone.Router({
    initialize: function() {
        this.route('foo', 'fooRoute');
    },
    fooRoute: function() {
        // logic for the "/foo" or "#foo" route would go here}
});

正如ModelsCollectionson方法允许你在创建类的实例之后创建事件绑定一样,Routersroute方法允许你在任何时间定义一个新的路由。这意味着你可以在创建Router类之后动态地创建所有路由。然而,正如你通常希望在用户首次访问你的网站时所有页面都可用一样,你通常也希望所有路由都可用,这意味着你通常会在你的Routers initialize方法中创建它们。

使用route方法的主要优势是你可以将逻辑应用到你的路由上。例如,假设你在网站上有多条仅针对管理员的路由。如果你的t可以检查用户ModelisAdmin属性,那么它可以使用这个属性在Router类初始化时动态地添加或删除这些仅限管理员的路由,如下所示:

// NOTE: In a real case user data would come from the server
var user = new Backbone.Model({isAdmin: true});SiteRouter = new Backbone.Router({
     initialize: function(options) {
        if(user.get('isAdmin')) {
            this.addAdminRoutes();
        }
    },
    addAdminRoutes: function() {
        this.route('adminPage1', 'adminPage1Route');
        this.route('adminPage2', 'adminPage2Route');
        // etc.
    }
});

如前一个示例所示,使用route方法的另一个优势是你可以将路由分组组织成单独的函数。如果你想要一起开启或关闭某些路由组,这可能会很有用;但它也可以简单地用来组织常量路由。实际上,你甚至可以在一个完全不同的文件中定义一些路由,只要你的Router可以访问它们,如下所示:

// File #1
window.addFooRoutes = function(router) {
    router.route('foo', function() {
        //...
    })
}

// File #2SiteRouter = new Backbone.Router({
    initialize: function(options) {
        if(options.includeFooRoutes) {
            addFooRoutes(this);
        }
    }
});

路由样式

除了有选择如何连接你的路由之外,你还可以选择如何定义路由。到目前为止,你看到的都是简单路由;它们定义了一串字符,当 Backbone 在 URL 中看到这个确切的字符串时,它会触发相应的路由方法。然而,有时你可能想要更灵活的路由。

例如,假设我们有一个销售书籍的网站,我们想要有一个“了解特定书籍”的路由。如果我们网站上有很多书籍,为每本书单独创建路由可能会变得痛苦:

var SiteRouter = new Backbone.Router({
    initialize: function(options) {
        this.route('book/1', 'book1Route'); // for the book with ID 1
        this.route('book/2', 'book2Route'); // for the book with ID 2
        this.route('book/3', 'book3Route'); // for the book with ID 3
        // this will get old fast
    }
});

幸运的是,有两种替代方案可以解决这个问题:路由字符串和正则表达式路由。首先,让我们看看正则表达式路由如何解决这个问题:

SiteRouter = new Backbone.Router({
    initialize: function(options) {
        // This regex will match "book/" followed by a number
        this.route(/^book\/(\d+)$/, 'bookRoute');
    },
    bookRoute: function(bookId) {
        // book route logic would go here
    }
});

如前一个示例所示,我们能够使用一个匹配所有可能书籍路由的正则表达式创建一个单独的路由。因为这个表达式包含一个组(正则表达式中被括号包围的部分),Backbone 会将匹配的路由部分传递给路由函数作为参数,这样我们就可以在路由函数内部知道书籍的 ID。如果我们要在正则表达式中包含多个组,Backbone 会将每个匹配的路由部分作为单独的参数传递给我们的路由函数。

正则表达式功能强大,这使得它们成为定义路由的好选择。然而,它们有一个主要的缺点:它们对人类来说很难阅读,尤其是在你几个月后再次查看它们时。正如古老的编程格言所说:你遇到了一个问题,并试图使用正则表达式来解决它。现在你有两个问题。因此,Backbone 为定义路由提供了第三个选项:路由字符串。

路由字符串类似于正则表达式;在这一点上,它们允许你定义动态路由,但它们的限制性更大。你只能定义组(也称为 参数),通配符组(也称为 splat),以及可选部分。通过放弃正则表达式的一些功能,路由字符串获得了很大的可读性。与使用 (\d+) 匹配书籍 ID 的路由部分相比,路由字符串将使用更易读的 bookId

正如正则表达式路由一样,Backbone 将将路由的参数作为参数传递给路由函数,如下所示:

SiteRouter = new Backbone.Router({
    initialize: function(options) {
        this.route('book/:bookId', 'bookRoute');
    },
    bookRoute: function(bookId) {
        // book route logic would go here
    }
});

如果我们想要使路由的一部分是可选的,我们可以用括号将其包裹(例如,book/(:bookId))。然而,这里有一个问题;当路由字符串看到 / 字符时,它们会停止匹配参数(无论是否可选),这意味着如果你的书 ID 中包含 /,它们将不起作用。例如,如果我们尝试导航到 book/example/with/slash/5,我们的路由就不会被触发。为了解决这个问题,Backbone 提供了一个 wildcardsplat 选项,它将匹配 route 类的剩余部分。这些 splats 就像参数一样,但它们使用 * 字符而不是 : 字符:

SiteRouter = new Backbone.Router({
    initialize: function(options) {
        this.route('book/*bookId', 'bookRoute');
    },
    bookRoute: function(bookId) {
        // book route logic would go here
    }
});
// if we now navigate to "book/example/with/slash/5 " our bookRoute 
// method will receive an argument of "example/with/slash/5 "

由于 Backbone 对你是否使用简单路由、正则表达式或路由字符串并不特别关心,你可以在任何给定的 Router 中自由组合这三种类型。然而,你可能发现,每次都使用最易读的形式是最好的。换句话说,你应该先尝试使用简单路由,然后如果你需要添加一些变量,再切换到路由字符串,只有在你无法使用路由字符串定义路由时才使用正则表达式。

关于路由冲突的注意事项

无论你使用哪种方法来创建你的路由,都应该小心避免定义相同的路由两次,或者定义两个相互重叠的路由,例如 foosplatThatCouldBeFoo。虽然 Backbone 允许你定义这样的路由,并且即使有这些路由也能继续正常工作,但它会静默地忽略第一个匹配之后的任何路由。以下是一个代码片段的例子:

new Backbone.Router({
    routes: {
        'foo': function() {alert('bar')},
        ':splatThatCouldBeFoo': function() {alert('baz')},
    }
}) 
Backbone.history.start();
// navigating to #foo alerts('bar')

可以通过故意定义特定的路由,然后是更不具体的重叠路由来利用这种行为,但这通常不推荐。虽然几个简单的重叠路由不太可能引起问题,但如果太多,它们可能会使你的Router难以操作。当你有这种重叠路由时,你必须首先停下来理解所有涉及的路线,然后才能对它们中的任何一个进行更改,然后如果你犯了一个错误,很容易就会创建一个难以找到的错误。

跟随斜杠

在我们离开路由之前,还有一个值得注意的细节:跟随斜杠。通常,网络开发者不需要考虑跟随斜杠,因为大多数网络服务器将foofoo/视为相同。然而,从技术上讲,它们是不同的,Backbone 也将它们视为不同,这意味着当用户导航到foo/时,foo路由不会被触发。

幸运的是,如果你使用路由字符串,可以通过在每个字符串的末尾添加(/)来轻松解决这个问题。如果你使用正则表达式,你可以通过在每个正则表达式的末尾添加\/?来实现类似的效果。然而,如果你使用简单的字符串,你可能需要定义两个不同的路由(foofoo/),或者你只需小心不要创建任何指向foo/的链接。

重定向

通常用户通过点击锚点标签(也称为超链接)来遍历网站。对于由 Backbone 驱动的网站来说,也是如此;即使你使用基于 hash 的路由,你仍然可以为他们创建正常的超链接:

<a href="#foo">Click here to go to the "foo" route</a>

然而,有时你可能希望使用 JavaScript 将用户移动到不同的路由。例如,你可能在页面上有一个提交按钮,在它触发页面的Model保存后,你希望它将用户重定向到不同的路由。这可以通过使用Routers navigate方法来完成,如下所示:

var router = new Backbone.Router({
    routes: {
        foo: function() {
            alert('You have navigated to the "foo" route!');
        }
    }
});
router.navigate('foo', {trigger: true});

重要的是要注意,将trigger选项作为navigate的第二个参数添加。如果没有这个额外的参数,Backbone 会将用户带到路由的 URL,但不会实际触发路由的逻辑。

navigate方法还可以接受另一个选项:replace。如果提供了这个选项,Backbone 将导航到指定的路由,但不会在浏览器的历史记录中添加条目。以下是一行代码的示例:

router.navigate('bar', {replace: true, trigger: true});

不创建历史记录条目意味着,例如,如果用户访问了另一个页面然后点击浏览器上的后退按钮而不是回到被替换的页面,他们将被带到浏览器历史记录中的上一个页面。由于这种行为可能会让用户感到困惑,建议你将replace: true的使用限制在临时路由上,例如加载页面的路由。

404 和其他错误

到目前为止,我们一直关注路由匹配时会发生什么,但用户访问一个没有匹配路由的 URL 时会发生什么呢?这种情况可能是因为一个过时的链接或用户输入了错误的 URL。在一个传统的网站上,服务器将通过抛出404错误来处理这种情况,但在 Backbone 驱动的网站上,Router类默认情况下将不会做任何事情。这意味着,如果你想在网站上有一个404页面,你必须自己创建它。

实现这一点的其中一种方法是通过start方法。此方法根据当前 URL 是否找到任何匹配的路由返回truefalse

if (!Backbone.history.start()) {
    // add logic to handle the "404 Page Not Found" case here
}

然而,由于该方法只会在页面首次加载时被调用一次,因此它不会允许你捕获页面加载后发生的非匹配路由。为了捕获这些情况,你需要定义一个特殊的404路由,你可以使用路由字符串的splat语法来完成:

var SiteRouter = Backbone.Router.extend({
    initialize: function(options) {
        this.route('normalRoute/:id', 'normalRoute');
        this.route('*nothingMatched', 'pageNotFoundRoute');
    },
    pageNotFoundRoute: function(failedRoute) {
        alert( failedRoute + ' did not match any routes');
    }
});

路由事件

通常,基于路由的逻辑是由Router类本身处理的。然而,有时你可能希望触发额外的逻辑。例如,你可能希望在用户访问几个路由之一时,向页面添加某个元素。在这种情况下,你可以利用 Backbone 在每次路由发生时触发事件的特性,允许你从Router类外部监听和响应路由变化。

你可以通过使用on方法以与ModelsCollections中的事件相同的方式监听路由事件,如下所示:

var router = new Backbone.Router();
router.on('route:foo', function() {
    // do something whenever the route "foo" is navigated to
});

下表显示了可以在Router类上监听的三种不同的路由事件:

事件名称 触发
route 当任何路由匹配时触发
route:name 当匹配指定名称的路由时触发
all 当在Router类上触发任何事件时触发

此外,Backbone 还提供了一个可以在Backbone.history对象上监听的route事件,如下所示:

Backbone.history.on('route', function() { ... });

监听history而不是特定Router的优点是,它将捕获来自你网站上所有Routers的事件,而不仅仅是特定的一个。

多个路由器

通常,你只需要一个Router来驱动你的整个网站。然而,如果你有理由这样做,你可以轻松地包含多个Routers,Backbone 将愉快地允许这样做。如果同一页面上有两个或更多Routers匹配特定的路由,Backbone 将触发第一个具有匹配路由定义的Router的路由。

你应该使用多个Routers的主要原因有两个。第一个原因是将你的路由分成逻辑组。例如,在先前的例子中,我们使用条件语句在当前用户是管理员时将某些仅限管理员的路由添加到Router类中。如果我们的网站有足够的这些路由,那么为仅限管理员的路由创建一个单独的Router可能是有意义的,如下所示:

var NormalRouter = Backbone.Router({
     routes: {
        // routes for all users would go here
    }
};
var AdminRouter = Backbone.Router({
     routes: {
        // routes for admin users only would go here
    }
};
new NormalRouter();
if (user.get('isAdmin') {
    new AdminRouter();
}

需要使用多个 Routers 的另一个原因是当你有两个不同的网站需要共享一些公共代码时。例如,一个图书销售网站可能希望有一个主网站供购物者购买书籍,并为出版商提供一个完全不同的网站来提交新书。然而,尽管这些网站是不同的,它们可能都希望共享一些公共代码,例如一个 Book Model 或书籍渲染的 View。通过使用多个 Routers,我们的图书销售商可以在两个网站之间共享他们想要的任何代码,同时保持每个网站页面/路由的完全独立。

在实践中,使用多个 Routers 可能会让人感到困惑,尤其是当它们有重叠的路由时。由于你可以轻松地动态添加路由(就像我们在早期的管理员示例中所做的那样),通常你不需要依赖多个 Routers,并且通过坚持只使用一个,你可能会避免混淆。

页面视图

在我们完成这一章之前,讨论一下 Views 如何与 Routers 交互是值得的。在本章中,我们故意对你在路由函数内部实际应该做什么保持模糊,Backbone 的美妙之处在于它完全将这个决定留给了你。

然而,对于许多 Backbone 用户来说,一个非常常见的模式是创建一个特殊的 Page View,然后在每个路由处理方法中实例化该 View 的不同子类。以下代码片段是一个例子:

var Book = Backbone.Model.extend({urlRoot: '/book/'});
var Page = Backbone.View.extend({render: function() {
        var data = this.model ? this.model.toJSON() : {};
        this.$el.html(this.template(data));
        return this;
    }
});
var BookPage = Page.extend({
   template: 'Title: <%= title %>' 
});
var SiteRouter = new Backbone.Router({
    route: {
        'book/:bookId(/)': 'bookRoute',
    },
    bookRoute: function(bookId) {
        var book = new Book({id:  bookId});
        book.fetch().done(function() {
            var page = new BookPage({model: book});
            page.render();
        });
    }
});

如果你的网站有多个部分,例如侧导航栏和主要内容区域,你可以将这些部分拆分成它们自己的 View 类,然后让这些部分成为你的 Page View 类的默认使用部分。然后,你可以在 Page View 的子类中根据需要覆盖这些默认设置,以允许这些 Views 改变你网站的各个部分。

例如,假设大多数时候,你的网站只有一个侧导航栏,但在某些页面上,你希望向其中添加一些额外的链接。通过使用 Page View 架构,这样的场景很容易实现:

var StandardSidebar = Backbone.View.extend({
    // Logic for rendering the standard sidebar });Page = Backbone.View.extend({
    sideBarClass:  StandardSidebar,

    render: function() {
        // render the base page HTML
        this.$el.html('<div id="sidebar"></div>' +
                      '<div id="content"></div>');

        // render the sidebar
        var sidebar = new this.sideBarClass({
            el: this.$('#sidebar')
        });
        sidebar.render();

        // logic for rendering the content area would go here
        return this;
    }
});
var AlternateSidebar  = Backbone.View.extend({
    // Logic for rendering the alternate sidebar });
var AlternateSidebarPage = Page.extend({
   sidebarClass: AlternateSidebar
});

正如前一个示例所示,我们的 AlternateSidebarAlternateSidebarPage 类非常简短。多亏了继承的力量,它们不需要重新定义任何现有的逻辑,而是可以完全专注于它们的不同之处:渲染替代侧边栏的逻辑。虽然你的网站可能甚至没有侧边栏,但它无疑是由可组合的部分组成的,通过将这些部分拆分成单独的 View 类,你的路由可以简单地使用它们希望渲染的适当类。

摘要

在本章中,我们探讨了 Backbone 的 Router 类。你学习了 Backbone 如何通过在 Router 类上创建 routes 来模拟页面,以及 Routers 可以如何使用基于 hash 或基于 pushState 的路由来操作。你还了解了添加路由的三种不同方式(通过 routes 选项、routes 属性或 route 方法)以及三种类型的路由(简单路由、路由字符串和正则表达式)。最后,你看到了如何处理缺失的路由、响应路由事件、使用多个 Routers,以及最重要的是如何将页面视图与可组合的子 Views 结合起来,以增强你的路由方法。

在下一章中,我们将探讨 Backbone 的更多高级用法,例如使用方法代替 Backbone 属性,如 model,或者将方法集混合到多个类中。

第七章。将方钉塞入圆孔——高级 Backbone 技巧

在本章中,我们将探讨 Backbone 的各种高级用法,包括以下内容:

  • 用方法代替属性

  • 覆盖类的构造函数

  • 使用混入(mixins)在类之间共享逻辑

  • 实现发布/订阅模式

  • 在 Backbone Views 中包装来自其他库的控件

提升一个层次

在前几章中,我们讨论了使用 Backbone 的四个类的基本方法,虽然这些基本方法已经足够让你在 Backbone 中变得高效,但我们故意省略了一些更复杂的细节。在本章中,我们将探讨这些省略的细节以及它们如何与某些高级 Backbone 技巧相关。

然而,在我们继续之前,重要的是要注意,本章中讨论的所有技术都是为了解决特定问题而设计的,而不是作为通用模式。虽然使用这些技术中任何一项都没有“错误”,但它们会使你的代码更加复杂,因此,更难以直观理解和维护。因此,建议除非你确实需要解决特定问题,否则避免使用本章中讨论的技术。

用方法代替属性

Backbone 类的许多属性都可以定义为函数而不是原始数据类型。实际上,我们在 ModelsCollectionsurl 属性中已经看到了这种行为的一个例子。正如我们在第三章使用模型访问服务器数据中学到的,这个属性可以是一个简单的字符串,但如果需要逻辑来生成更复杂的 URL,则可以使用返回字符串的函数。

这种相同的方法可以用于许多 Backbone 属性,包括以下内容:

  • 对于模型:

    • defaults

    • url

    • urlRoot

  • 对于视图:

    • attributes

    • className

    • events

    • id

    • tagName

  • 对于路由器:

    • routes

例如,假设你想要一个 View,它可以根据是否有 Collection 生成 <input><select>。通过为 ViewstagName 属性使用函数而不是字符串,我们可以做到这一点:

var VariableTagView = Backbone.View.extend({
    tagName: function() {
        if (this.collection) {
            return 'select';
        } else {
            return 'input';
        }
    }
});

将 Collection.model 作为工厂方法使用

Collection 类也有一个可以用函数替换的属性,但与其它属性不同,这个属性通常不是一个原始数据类型……它是一个 Backbone Model 子类。正如在第四章使用集合组织模型中讨论的,Collection 类的 model 属性决定了该 Collection 将创建哪种类型的 Model。通常,每个 Collection 类只能有一个 model 属性,因此只能创建一种类型的 Model

然而,有一种方法可以绕过这个限制:如果我们用返回新Models的函数替换我们的Collectionmodel属性,Backbone 将愉快地忽略model属性实际上根本不是Model子类的事实。例如,假设我们的服务器有一个返回两种类型书籍(虚构和非虚构)JSON 的"/book" API 端点。假设我们有两个不同的Model类,每个类对应一种类型的书籍。如果我们想要有一个能够从单个端点获取两种类型书籍的 JSON 的Collection类,但使用适当的model函数来实例化每种类型的书籍,我们可以使用一个工厂model函数,如下所示:

var FictionBook = Backbone.Model.extend({
    // insert logic for fiction books here});
var NonFictionBook = Backbone.Model.extend({
    // insert logic for non-fiction books here});
var FictionAndNonFictionBooks = Backbone.Collection.extend({
    model: function(attributes, options) {
        if (attributes.isFiction) {
            return new FictionBook(attributes, options);
        } else {
            return new NonFictionBook(attributes, options);
        }
    },
    url: '/book'});

覆盖类构造函数

每当 Backbone 类被实例化时,它都会运行其initialize方法中定义的代码。然而,这段代码是在新对象实例化之后才运行的。这意味着即使您为Model类定义了一个initialize方法,该Model类的属性已经在您的initialize代码被调用之前设置了。

通常,这是好事,因为像Model类的属性已经设置这样的东西很方便,但有时我们希望在发生之前就掌握控制权。例如,让我们重新想象我们之前的虚构和非虚构书籍的场景,但这次我们不是有一个可以创建两种类型书籍的单个Collection类,我们想要一个只能创建一种类型的Collection,并且我们希望这种类型由我们给Collection类实例化的第一本书决定。

换句话说,如果给我们的Collection类提供的第一个书具有isFiction属性,我们希望我们的Collection类具有FictionBookModels属性;否则,我们希望它具有NonFictionBookModels属性。如果我们在这个initialize方法内部这样做,那么在initialize方法运行之前,“model”选项已经在Collection类上设置了,并且我们传递给它的任何Model属性已经转换成了Models。因此,我们需要添加在initialize之前运行的逻辑。我们可以通过覆盖Collection Constructor方法的构造函数来实现这一点,如下所示:

var FictionOrNonFictionBooks = Backbone.Collection.extend({
    constructor: function(models, options) {
        if (models[0].isFiction) {
            options.model = FictionBook;
        } else {
            options.model = NonFictionBook;
        }
        return Backbone.Collection.apply(models, options);
    }
});

注意我们如何在我们的覆盖版本中仍然调用了原始的Collection Constructor方法;如果我们没有这样做,Backbone 的正常逻辑将不会运行,并且在我们完成时甚至可能没有正确的Collection类。

与本章讨论的其他技术一样,不建议您经常覆盖构造函数。如果可能的话,您应该覆盖initialize方法。构造函数的覆盖主要应留给那些您希望在 Backbone 对象实际创建之前预处理传入参数的情况。

类混合

Backbone 提供了一个非常强大的类系统,但有时,即使是这个类系统也不够用。例如,假设你有几个类,它们都包含几个相同的方法。流行的不要重复自己DRY)编程原则建议你应该消除方法的重复版本,而是在代码中的一个地方定义它们。

通常,你可以通过将这些方法重构为这些类的一个公共父类来实现这一点。但如果这些类不能共享一个公共父类怎么办?例如,如果其中一个类是Model类,而另一个是Collection类呢?

在这种情况下,你需要依赖一种称为混入(mixin)的东西。混入只是一个包含一个或多个你想要在几个类之间共享的方法(甚至原始属性)的对象。例如,如果我们想在几个类之间共享一些与日志记录相关的功能,我们可以创建一个包含这些方法的混入,如下所示:

var loggingMixin = {
    startLogging: function() {
        Logger.startLogging(this);
    },
    stopLogging: function() {
        Logger.stopLogging(this);
    }
}

一旦你定义了你的混入(mixin),下一步就是将其“混合”到你的类定义中。这意味着修改类的prototype以包含混入中的方法。例如,假设我们有一个书Model

var Book = Backbone.Model.extend({
    defaults: {currentPage: 1},

    read: function() {
        // TODO: add logic to read a book here
    }
});

因为 Backbone 的语法使得创建我们的新类变得如此简单,所以我们很容易忽略这样一个事实:我们实际上在这里定义了一个原型对象,但如果我们稍作改变,它突然就变得明显了:

var bookPrototype = {
    defaults: {currentPage: 1},

    read: function() {
        // TODO: add logic to read a book here
    }
};Book = Backbone.Model.extend(bookPrototype);

一旦我们分离了书的原型(prototype),我们就可以用我们的混入来增强它。我们可以手动做这件事:

bookPrototype.startLogging = loggingMixin.startLogging;
bookPrototype.stopLogging = loggingMixin.stopLogging;

然而,如果我们有很多方法要混入,或者我们的混入后来添加了新方法,这就不太适用了。相反,我们可以使用 Underscore 的extend函数来通过单个命令将混入中的所有方法扩展到Book原型上:_.extend(bookPrototype, loggingMixin);

就这一行代码,我们就为我们的Book类添加了一整套与日志记录相关的功能。此外,我们还可以通过仅使用一行代码将这些方法与任何其他类共享,而无需更改任何相关类的父类。

当然,重要的是要注意,因为我们是在现有原型上应用混入,所以它将覆盖与原型共有的任何属性。换句话说,假设我们的Book类已经有一个自己的startLogging方法来阻止日志记录:

bookPrototype = {
    defaults: {currentPage: 1},

    read: function() {
        // TODO: add logic to read a book here
    },
    startLogging: $.noop // don't log this class
};

我们将通过应用混入来擦除这个方法,即使我们不想这样做,也会在我们的类上实现日志记录。进一步来说,即使Book没有这样的方法,未来的开发者可能会添加一个,然后,当它不起作用时感到困惑。如果未来的开发者没有看到(或没有理解)混入行,他可能会花几个小时查看Book的父类,试图弄清楚发生了什么。

由于这个问题,在设计你的类时,通常最好尽可能多地依赖标准面向对象编程,并且只有在无法通过正常类层次结构共享方法时才使用混入。然而,在这些情况下,混入可以是一个强大的工具,并作为 JavaScript 中但大多数其他语言中不可能实现的另一个例子(祝你好运,将方法混入一个 Java 类!)。

发布/订阅

Backbone 类通常最终会紧密耦合在一起。例如,一个 View 类可能会监听 Model 类数据的变化,然后,当数据发生变化时,它可能会查看 Modelattributes 来确定要渲染的内容。这种做法将 View 类耦合到 Model 类,这在正常情况下是好事,因为它允许你定义两个类之间所需的确切关系,同时仍然保持你的代码相对简单和可维护。

当你只有少数 ModelsViews 时,以这种方式管理它们之间的关系很容易。然而,如果你正在构建一个特别复杂的用户界面,那么这种相同的耦合可能会变成一种障碍。想象一下,有一个单页面上有大量 CollectionsModelsViews,它们都在监听并响应彼此的变化。每当发生单个变化时,它都可能引起连锁反应,导致进一步的改变,然后这些改变又可能导致更多的改变,如此等等!这几乎使得理解正在发生什么变化以及它们将产生什么影响变得不可能;在最坏的情况下,它们可以创建无限循环或使修复错误变得困难。

对于这样一个复杂系统,解决方案是使用发布/订阅模式(或简称 pub/sub)来解耦各个组件(CollectionsModelsViews)。在这个模式中,所有组件仍然通过事件进行通信,但不是每个 CollectionModel 类都有自己的事件总线,所有涉及的 CollectionModel 类共享一个单一的全局事件总线,消除了它们之间的直接连接。每当一个组件想要与另一个组件通信时,它会发布(触发)一个其他组件已订阅(监听)的事件。

要在 Backbone 中实现这种模式,我们需要一个全局事件总线,而实际上 Backbone 已经为我们提供了一个:Backbone 对象本身。就像 ModelsCollections 一样,Backbone 对象既有 "on"(订阅)方法,也有 "trigger"(发布)方法。这两个方法的工作方式与其他 Backbone 对象相同。

常常从发布/订阅模式中受益的一种应用类型是游戏,因为它们通常有很多不同的 UI 组件被各种不同的数据源更新。让我们想象一下,我们正在构建一个三人游戏,其中每个玩家都由一个Model类表示。由于我们希望保持玩家Model与服务器更改同步,我们以周期性的间隔获取这些Model

var Player = Backbone.Model.extend();
var bob = new Player({name: 'Robert', score: 2});
var jose = new Player({name: 'Jose', score: 7});
var sep = new Player({name: 'Sepehr', score: 4});
window.setInterval(1000, function() {
    bob.fetch();
    jose.fetch();
    sep.fetch();
});

让我们进一步想象,我们有一个计分板View,它需要根据玩家分数的变化来更新自己。当然,在现实世界中,如果我们只有一个View,我们很可能甚至不需要发布/订阅模式,但让我们使这个例子尽可能简单。为了避免将View直接耦合到玩家Model,我们将让它监听 Backbone 对象上的scoreChange事件:

var Scoreboard = Backbone.View.extend({
    renderScore: function(player, score) {
        this.$('input[name="' + player + '"]').html(score);
    });
});
var scoreboard = new Scoreboard();
Backbone.on('scoreChange', scoreboard.renderScore, scoreboard);

现在,为了使这些scoreChange事件发生,我们需要让我们的Player类触发它们。我们还需要一种方法来判断分数是否已更改,但幸运的是,每个Model类都有一个hasChanged方法,它正好可以做到这一点。我们可以在重写的fetch方法中使用这个方法来触发我们的scoreChange事件,如下所示:

Player = Backbone.Model.extend({
    fetch: function() {
        var promise = Backbone.Model.prototype.fetch.apply(
                          this, arguments
                      );
        promise.done(function() {
            if (this.hasChanged('score')) {
                Backbone.trigger('scoreChange', this.get('name'), 
                                 this.get('score'));
            }
        });
        return promise;
   }
});

如您所见,Model类和View现在是完全独立的。Model类通过事件本身传递任何View所需的信息,当它调用Backbone.trigger时;否则,所有涉及的类都对彼此一无所知。

当然,与任何这些高级技术一样,这种方法也有其缺点,主要是封装性不足。当你有一个View直接获取Model类时,你知道连接两个类的确切代码,如果你想要重构或更改该代码,很容易确定会受到什么影响。然而,在使用发布/订阅模式的情况下,确定可能受到潜在更改影响的代码可能会困难得多。

在许多方面,这类似于使用全局变量和使用局部变量之间的权衡:虽然前者在最初更强大且易于处理,但它们缺乏限制性可能会使它们在长期使用中更难以处理。因此,尽可能避免依赖发布/订阅模式,而是依赖绑定到特定 Backbone 对象的监听器。然而,当你遇到需要代码中多个不同部分之间通信的问题时,依赖这种模式并在 Backbone 对象上监听/触发事件可以提供一种更优雅的解决方案。

包装其他库的组件

Backbone 是一个非常强大的框架,但它不能做所有事情。如果你想向你的网站添加日历小部件、富文本编辑器或节点树,你可能想使用另一个库,该库可以为你提供这样的组件(例如,jQuery UI、TinyMCE 或 jsTree)。然而,仅仅因为你想要使用除 Backbone 之外的工具,并不意味着你必须放弃 Backbone 类的所有便利和功能。事实上,创建包装第三方组件的 Backbone View 类有许多好处。

首先,将组件包装在 View 中允许你指定使用此组件的通用方式。例如,假设你想要在你的网站上使用 JQuery UI 日历小部件(或 datepicker)。在 jQuery UI 中,如果你想让你的日历包含月份选择控件,你必须在每个创建日历的代码位置提供 changeMonth: true 选项:

// File #1
$('#datepicker1').datepicker({changeMonth: true});
// File #2
$('#datepicker2').datepicker({changeMonth: true});

然而,假设你创建了一个如下包装 datepicker 小部件的 View 类:

var CalendarView = Backbone.View.extend({
    initialize: function() {
        this.$el.datepicker({changeMonth: true});
    }
});

你可以在代码中需要日历的任何地方使用这个类,你不必担心忘记提供 changeMonth 选项:

// File #1
new CalendarView({el: '#datepicker1'});
// File #2
new CalendarView({el: '#datepicker2'});

如果你的团队中有一个完全不同的开发者想要添加一个日历,即使他对 jQuery UI 或 changeMonth 选项一无所知,他仍然能够仅通过使用你创建的 View 来创建一个适合你网站的日历。这种将特定组件相关的所有逻辑封装起来并定义自己使用接口的能力是这种包装方法的主要好处之一。

另一个好处是能够轻松地更改组件。例如,假设有一天,我们决定我们希望我们网站上的所有日历都也具有一个年份选择的下拉菜单(换句话说,我们希望它们都具有 changeYear: true 选项)。如果没有包装的 Backbone View,我们就必须找到网站上所有使用 datepicker 小部件的地方并更改其选项,但有了我们的 View,我们只需要在代码中的一个地方更新。

当然,这种技术也有其缺点。一个明显的缺点是,如果我们向我们的网站添加任何不使用相同选项的组件,例如没有月份选择器的 datepicker 小部件,我们就需要重构我们的包装 View 以允许这种可能性。

另一个问题是有包装的组件可以在不调用其包装 View 上的 remove 方法的情况下从 DOM 中移除自己,这阻止了该 View 被垃圾回收。通常情况下,这不会成为问题,因为内存中一些额外的“僵尸” View 小部件不会对性能产生重大影响。然而,如果你有大量这些包装小部件,那么你可能想使用小部件的事件系统来确保当其包装小部件离开页面时,包装 View 总是会调用 remove 方法。例如:

CalendarView = Backbone.View.extend({
    initialize: function() {
        this.$el.datepicker({
            changeMonth: true,
                         onClose: _.bind(function() {
                             this.remove();
                         }, this)
        });
    }
});

最后,虽然将小部件的使用细节隐藏在 View 中可能很方便,但它也可能无意中隐藏了有关此小部件的相关细节,从而让其他开发者无法了解。例如,一个小部件可能有一个特定的副作用,但由于其他开发者只是使用小部件的包装 View 而从未阅读原始小部件的文档,他们可能不会意识到这个副作用。

因此,确保这样的 Views 被适当文档化是很重要的。如果被包装的组件有任何形式的副作用,无论是性能上的还是其他方面,这种副作用也应该在 View 本身上仔细记录,或者你应该确保团队中的所有开发者都理解底层组件的工作原理。

摘要

在本章中,我们探讨了使用 Backbone 的几种高级技术。首先,我们学习了 Backbone 的许多属性可以被方法所替代,特别是如何使用 Collection 类的 model 属性以这种方式生成不同类型的 Models。然后,我们学习了如何覆盖类 constructor 以在 initialize 之前访问和/或操作其参数,以及如何使用混入(mixins)在原本无关的类之间共享方法。最后,我们考察了如何使用 Backbone 对象本身来实现发布/订阅(pub/sub)模式,以及如何使用 Backbone Views 来“包装”来自其他 JavaScript 库的组件。

在下一章中,我们将探讨 Backbone 的性能影响以及如何避免最常见的性能陷阱。

第八章。扩展 – 确保复杂应用程序的性能

在本章中,我们将探讨 Backbone 中最常见的性能问题,以及如何避免它们。特别是,我们将涵盖以下内容:

  • 基于 CPU 的性能问题

  • 与内容大小相关的基于带宽的性能问题

  • 与请求数量相关的基于带宽的性能问题

  • 基于内存的性能问题

Backbone 与性能

Backbone 的核心只是一个 JavaScript 库,因此它不会添加任何 JavaScript 本身没有的新性能挑战。事实上,Backbone 的创建者已经非常注意使库性能良好,并且在与其他库的性能比较中,Backbone 通常处于领先地位,如果不是最顶尖的。

然而,尽管 Backbone 本身不会创建性能问题,但它确实为创建 Web 应用程序提供了全新的方式,并且由于此类应用程序可能比传统网站复杂得多,因此暴露了全新的潜在性能问题领域。在本章中,我们将探讨这些问题,以及它们的技术细节,并讨论避免或减轻这些问题的方法。

性能问题的原因

当用户遇到性能问题时,是因为他已经超出了他系统资源(带宽、内存或处理能力)之一的容量。在调试任何性能问题之前,了解这些因素中的哪一个负责是至关重要的,这可以通过两种方式之一来确定。首先,可以使用性能分析工具(如所有主要浏览器中包含的工具)来测量每种资源的使用量,这应该会迅速清楚地表明哪种资源被过度使用。这些工具的解释超出了本书的范围,但我强烈建议您熟悉您最喜欢的浏览器中可用的工具。

对于大多数问题,然而,甚至不需要使用性能分析工具,因为它们的原因可以通过它们的表现来确定。带宽问题仅在从您的服务器检索或发送数据时出现(在大多数应用程序中,只有检索操作涉及足够的带宽以成为问题)。如果它们在加载时出现,那么可能是由大量大型静态资源(如图像)引起的,但如果它们在之后出现,更有可能是由 AJAX 调用引起的。在 Backbone 应用程序中,这意味着从ModelsCollections进行的fetch操作(或很少见的savedestroy操作)。

CPU 性能问题仅在用户的计算机被迫对您让它做的事情进行深入思考时才会出现。例如,一系列嵌套的for循环,或渲染复杂的可视化(如图表)可能导致此类性能问题。这种性能问题通常很容易识别,因为它仅在用户触发这种计算密集型代码时才会发生。

性能问题的最终来源,也是最难解决的,是内存。与通常有明显的触发器(如 AJAX 操作的开始或图表的渲染)的其他两个问题不同,内存问题可能没有任何明显或明显的来源。事实上,内存问题可能在用户真正开始注意到问题之前几秒甚至几分钟就开始出现,迫使你回溯所有触发的代码以尝试找到原因。由于内存问题是最常见的类型,并且它们是最难理解和解决的,因此我们将重点放在它们上。然而,在我们这样做之前,让我们检查其他两个来源,以及一些避免它们的常识性方法。

与 CPU 相关的性能问题

如前所述,Backbone 本身通常不会是 CPU 相关性能问题的源头,因为这些问题的原因往往是由特定组件引起的,而不是整个网站架构。然而,Backbone 可以通过使重复相同工作变得容易而贡献于这类问题。例如,让我们假设你正在创建一个仪表板页面,该页面将使用一个主要的View类来展示用户的各种数据,以及多个子Views来渲染这些数据中的每一个。此外,让我们假设这些数据将定期更新。通常,你会将数据的更新与 AJAX 响应或用户事件绑定,但在某些情况下,你可能更愿意使用setInterval语句。例如,onScroll事件已知存在问题,因此许多开发者避免使用它们,而是依赖于setInterval定期检查滚动。

只要只有一个setInterval事件在运行,这种方法就会工作得很好,但如果你决定为每个子View创建一个单独的setInterval事件会怎样呢?对于只有少数子Views的情况,这仍然可能工作,但最终,太多的这种间隔将消耗用户的 CPU 资源,导致性能问题。在最坏的情况下,虽然你的开发机器可以处理页面,但用户的(较弱的)机器可能无法处理,导致用户报告你无法复制的错误。

在此类情况下,解决方案简单明了:不要不必要地重复执行计算密集型任务。在前面的例子中,你不需要每个子View都响应自己的setInterval事件来更新,你可以在主View中启动一个单独的setInterval进程,然后让它触发子Views的更新(可能通过使用前一章中描述的 pub/sub 模式)。

事件委托

开发者可以通过创建过多的事件处理器,以另一种方式轻易地对用户的浏览器造成不必要的压力。例如,假设你想创建一个大表格(可能是 20 行×20 列),因此你为表格创建了一个父View,并为每个单元格创建了大量子View。到目前为止,一切顺利!现在假设,你为这些子View中的每一个添加了一个click事件处理器。没有意识到,你刚刚创建了 400 个事件处理器。如果你为单元格内的<input>元素添加另一个事件处理器,比如一个change处理器,你又会增加 400 个,以此类推。

给定足够的Views和足够的事件处理器,这最终可能会造成性能问题,但幸运的是,JavaScript 自带了一个我们可以用来解决这个问题内置机制:事件冒泡。每当在 DOM 中发生事件时,它首先在相关的 DOM 元素上触发事件处理器,然后冒泡到该元素的每个后续父元素。换句话说,如果一个click事件发生在<td>元素上,浏览器将首先解决绑定到该<td>元素的任何事件处理器,然后(除非某个事件处理器返回了 false),它将调用<td>元素父<tr>元素上的处理器,然后是那个<tr>父元素的<table>

$(document.body).append('<table><tr><td></td></tr></table>');
$('td').on('click', function(e) {
    e.target; // will be the td element
    e.currentTarget; // will also be the td element
});
$('tr').on('click', function(e) {
    e.target; // will still be the td element
    e.currentTarget; // will be the tr element
});
$('table').on('click', function(e) {
    e.target; // will still be the td element
    e.currentTarget; // will be the table element
});
$('td').click(); // first the td handler will trigger, then the tr
                 // handler, and finally the table handler

我们可以利用这个事实,并改变我们的事件绑定策略,通过将事件绑定到父table元素的View而不是每个子View来提高性能。然后,这个父View事件处理器可以通过使用事件的target属性来确定哪个子View引发了事件,从而触发相关子View上的适当逻辑。虽然这种方法需要稍微多做一些工作,但它使我们能够将 400 个点击事件处理器减少到单个事件处理器,并且在特别复杂的页面(例如我们假设的表格页面)上,使用这种事件代理可以显著减少对浏览器的压力。

与带宽相关的性能问题

虽然确实,近年来典型用户的带宽显著增长,但带宽仍然是网络开发者面临的一个持续问题。然而,许多开发者并没有意识到,实际上存在两个主要的带宽问题来源。第一个问题相当明显:强迫用户下载过大的文件。但是,还有一个不那么明显的问题:强迫用户一次性下载过多的文件。

下载过大的文件

让我们先从最明显的原因开始。如果用户需要下载的文件太大,无论是图片、视频还是 JavaScript 代码文件,你的网站都会加载得很慢。然而,你可以在使用任何文件的大小上做出很大的改变,只需在 Web 服务器级别启用压缩即可。在 Apache Web 服务器上,这可以通过使用mod_deflate来实现,而大多数其他 Web 服务器都有类似选项。这样做会使服务器以用户浏览器可以轻松解压缩的方式压缩发送给用户的文件……而用户甚至不知道有任何解压缩正在进行。

然而,如果开启压缩没有足够帮助,那么你的下一步取决于文件类型。如果你的问题来自图片或视频,那么你只需找到一种方法来使用更小的文件,例如,通过降低它们的分辨率。然而,如果你的主要问题是 JavaScript 文件,那么还有一个选择:使用压缩程序。

最小化程序解析你的代码,创建一个新的优化版本,该版本消除了注释,移除了额外的空白,并使用更短的名字重命名变量。使用此类程序的唯一缺点是,它会使你在生产服务器上调试问题变得更加困难,只要你有一个匹配的开发环境,你不需要对文件进行最小化,这就不应该是一个问题。此外,如果最小化确实成为一个问题,你总是可以暂时将服务器切换回未最小化的文件来进行调试。

服务器压缩和最小化这两种技术结合起来,可以在文件大小上产生重大差异。例如,未压缩的 jQuery 库(版本 1.11.0)大小为 276 KB,而压缩版本仅为 82 KB,而最小化后的压缩 jQuery 代码版本仅为 33 KB。换句话说,仅仅通过使用这两种技术,就有可能将 jQuery 的占用空间减少近十倍!

下载过多文件

不幸的是,即使你减少了代码文件和资源的带宽,还有一个更微妙但需要关注的带宽问题,这与用户下载的字节数无关。要理解这个问题,你必须了解浏览器如何处理数据请求,无论是来自 DOM(如<link><script>标签)的还是 AJAX 请求。

当浏览器打开到特定远程计算机的连接时,它会跟踪已经打开到该计算机域的连接数量,如果已经打开太多,它会暂停,直到之前的某个请求完成。在发生这种情况之前可以发生的连接数因浏览器而异:在 Internet Explorer 7 中,只有两个,但在大多数现代浏览器中,是六或八个。由于这个限制,并且因为每个请求,无论大小,都需要一定的时间(也称为延迟),实际使用的带宽可能无关紧要。解决带宽问题主要有两种方法。

首先,显然是要减少请求数量。如果你的问题是同时获取过多的模型集合可以非常有助于解决这个问题;而不是单独获取每个模型类,只需在你的服务器上创建一个可以一次性返回所有模型的端点,然后使用集合类来获取它们。尽管你将下载相同数量的数据,但这种 API 的改变将导致请求量显著减少。同样,如果你的问题是图像过多,你可以将所有图像合并成一个单独的sprites文件,然后使用 CSS 只显示一次图像。

另一个选项,如果你的应用程序确实需要大量的请求,可以使用子域名。当浏览器计算未完成的连接数时,它不仅查看源域名,还查看其子域名。这意味着你可以从example.com/获取最大数量的请求,然后,从foo.example.combar.example.com等获取相同数量的请求。这个技巧通常用于更快地提供 CSS 和图像,但也可以同样容易地用于进行大量的并发获取(只要适当地更新你的url方法以从正确的子域名获取)。

最后,还有一个解决带宽问题的方案,这并不是真正解决了这些问题,而是使它们对用户来说更加容易接受。如果你知道你将发起一个请求,这个请求的时间足够长以至于用户会注意到,你可以给用户一个视觉等待指示器,比如添加一个动画旋转图像或改变光标的 CSS 属性以等待。如果你在开始fetch操作之前进行这些更改,然后你可以使用该操作的成功和失败回调(或者如果你使用延迟样式,一个单一的complete回调)来撤销这些更改。虽然这不会使你的数据下载速度更快,但它会在用户体验上有所区别。

与内存相关的性能问题

与内存相关的问题是最难调试和解决的,而且不幸的是,当你刚开始使用 Backbone 时,它们也是最有可能遇到的。再次强调,这并不是因为 Backbone 本身有内存问题,而是因为 Backbone 提供的可能性如果开发者不小心,可能会自己给自己挖坑。

然而,在我们继续之前,首先解释一下浏览器是如何管理内存的非常重要。正如你可能已经知道的,JavaScript 中的内存是由浏览器而不是开发者管理的,使用的是称为垃圾回收器的东西。这意味着你不需要告诉浏览器我已经完成对这个变量的使用。相反,你只需停止使用那个变量,浏览器就会弄清楚它已经变成了garbage,这通常会使它自动清理那个变量。

问题在于垃圾回收器对垃圾的简单解释。本质上,任何没有被其他变量引用的变量都被认为是垃圾。例如:

var bookReference = {
    fakeBook: new Book({title: 'Hamlet, Part 2: The Reckoning'})
};
delete bookReference.fakeBook;
// fakeBook has no references, and will be collected as "garbage"

问题在于开发者往往没有意识到他们留下了变量的引用,因此迫使浏览器继续使用其内存,尽管程序员认为它是垃圾。让我们看看一个例子:

var exampleModel = new Backbone.Model();
var exampleView = new Backbone.View();
exampleModel.on('change', exampleView.render);
$(document.body).append(exampleView.el);
exampleView.remove();
// exampleView will NOT be garbage collected

在这个例子中,当我们调用exampleView.remove()并从 DOM 中移除exampleView时,似乎我们消除了对exampleView的所有引用,但实际上,还有一个引用被遗留下来,隐藏在exampleModel内部。这个引用是在我们调用exampleModelon方法并传递exampleView.render时创建的。通过这样做,我们告诉Model等待发生变化,然后调用exampleView.render,这需要它存储对exampleView.render的引用。由于我们没有删除exampleModel,这个引用仍然存在,不会被垃圾回收,从而在浏览器内存中留下一个所谓的僵尸View

解决这个问题的方法之一是使用off方法手动删除这个引用:

example.model.off('change');

然而,管理这样的引用很快就会变得繁琐。幸运的是,Backbone 的创建者为View(以及其他三个 Backbone 类)添加了一个方法来帮助解决这个问题,称为listenTo。这个方法与on非常相似,有两个重要的区别。首先,它是在监听对象(在这种情况下,是View)上调用,而不是在被监听的对象(在这种情况下,是Model)上调用,其次,它不接收上下文参数。相反,回调的上下文将始终设置为listenTo被调用的对象。

正如存在off方法用于on一样,也存在一个stopListening方法,它可以移除由listenTo创建的监听器。然而,你不需要经常调用stopListening,因为它会作为Viewremove方法的一部分自动调用,这使得它非常方便。

让我们使用 listenTo 重新尝试我们的上一个例子:

exampleModel = new Backbone.Model();exampleView = new Backbone.View();
exampleModel.listenTo('change', exampleView.render);
$(document.body).append(exampleView.el);
exampleView.remove();
// exampleView WILL be garbage collected

这次,就像之前一样,我们有一个 View 正在监听 Model 的变化。然而,因为我们使用了 listenTo 而不是 on,作为副作用创建的引用将在调用 stopListening 时被移除。由于我们在 exampleView 上调用了 remove,并且这个方法会自动为我们调用 stopListening,所以我们的 View 被正确地垃圾回收,而无需我们做任何额外的工作。

然而,不幸的是,listenTo 无法解决所有潜在泄漏的引用。一方面,你可能仍然需要不时地使用 on 方法。这样做的主要原因是为了监听来自非 Backbone 代码的事件,例如 jQuery UI 小部件。你也可能因为(与 listenTo 不同)它接受一个上下文参数而想使用 on,但多亏了 Underscore 的 bind 方法,你不需要这样做;你可以在将回调函数传递给 listenTo 之前,简单地在你想要的上下文中绑定回调函数。然而,即使你完全避免使用 on,你仍然需要记得在 View 上调用 remove。如果你不这样做,你仍然需要使用 offstopListening 来清除事件绑定引用。最后,事件处理器并不是引用的唯一来源。

例如,父 View 和子 View 经常相互引用,除非你完全删除引用 View,否则它所引用的 View 实际上不会被垃圾回收。好消息是,在小规模上,你不需要过于担心这样的引用,实际上,试图过度优化应用程序中每一行代码的性能可能会适得其反。任何给定的 ModelView 通常只占用很少的内存,所以即使你创建了防止其被垃圾回收的泄漏引用,对应用程序性能的实际影响也将是最小的。如果用户甚至没有注意到泄漏,然后在关闭浏览器或刷新时回收内存,那么显然你不需要花费时间担心它。

相反,当你处理大量对象时,你主要需要关注管理你的引用。如果你正在设计一个将在整个应用程序中使用的页面 View,或者为具有许多单独子 View 的大表格创建 View,那么你可能希望对每个创建的引用格外小心,并确保在完成使用后清理所有这些引用。

摘要

在本章中,我们学习了 JavaScript 代码在一般情况下,以及 Backbone 代码特别情况下,如何导致性能问题。我们了解了这类问题的三个主要原因(即带宽、CPU 和内存),以及可以用来解决这些问题的技术和方法。特别是,我们学习了漏斗式事件绑定或其他引用如何阻止垃圾回收,以及如何使用listenTo或手动清理引用来使垃圾回收按预期工作。

在下一章中,我们将探讨良好代码文档化的好处,学习如何解决一些 Backbone 特定的文档挑战,并考虑众多高质量的文档工具中哪一个是用于记录你的项目的最佳选择。

第九章。我在想什么?记录 Backbone 代码

在本章中,我们将考虑各种用于记录你的 Backbone 代码的选项,包括以下内容:

  • “非文档”的文档方法

  • 如何在没有正式结构的情况下明确地记录你的代码

  • 如何使用形式化的结构,如 JSDoc,明确地记录你的代码

  • 如何使用 Docco,Backbone 创建者的一个替代文档工具'

Backbone 和文档

文档在任何软件项目中都很重要,但在文档方面,Backbone 项目有一些独特的考虑因素。然而,在我们深入探讨这些考虑因素之前,重要的是要确定你为什么要记录你的代码,以及你打算使用什么策略来记录它。

许多开发者有一种错误的印象,认为文档是给其他开发者看的,但实际上,没有比这更远离真相的了。大多数软件不是一次性写成的,而是在一段时间内不断迭代。每个新版本都可能在前一个版本之后几天、几个月甚至几年后发生,当你回到几个月/几年前写的代码时,代码几乎看起来像是另一个人的作品。

由于这个原因,即使你是你团队中唯一的开发者,即使你确切地知道你代码库中每一行的当前工作方式,记录你的代码也很重要,至少是为了你未来的利益。进一步来说,如果你不是你团队中唯一的开发者,文档就变得更加重要,因为它也可以作为你和你同事之间的桥梁。

文档方法

记录 JavaScript 代码有三种主要方法,它们都不是万能的解决方案。你需要根据你团队的大小和项目的雄心来确定哪种方法对你和你的团队来说最有意义。

三种主要方法可以被称为非文档、简单文档和强大文档方法。

通常情况下,你的团队越大,这个团队所属的组织也越大,你就越有可能想要强大的文档,尽管大小并不是这里唯一需要考虑的因素。其他因素包括你的团队成员对外部文档的需求,以及是否有任何代码是面向客户的(例如,如果你为你的客户提供了一个公开的 API 来扩展你的应用程序)。

非文档方法

首先,让我澄清一下,非文档方法并不意味着完全避免文档。相反,它依赖于使用除明确代码注释之外的其他形式的文档。

例如,考虑以下代码行:

var Book = Backbone.Model.extend();

现在,如果我们愿意,我们可以写一个注释来描述这一行代码,如下所示:

/**
 * This defines a book model.
 */Book = Backbone.Model.extend();

然而,添加这样的注释并没有真正告诉我们什么我们不知道的事情,因为我们的变量名选择(Book)已经告诉我们这个类是什么。仅仅通过为我们的类选择一个描述性的变量名,我们就已经记录了它所做的事情,而不需要额外的文档。

然而,类变量的名称并不是唯一重要的名称。函数名,尤其是方法名,也可以在解释函数/方法做什么时非常有帮助。考虑以下:

     bookNav: function() {
         router.navigate('bookPage', {silent: true, trigger: true});
    }
vs.:
     navigateSilentlyToBookPage: function() {
         router.navigate('bookPage', {silent: true, trigger: true});
    }

只需输入几个额外的字符,我们就消除了阅读方法定义以理解它的需要。进一步来说,在阅读时,这个特定的例子中的定义可能并不那么困难;然而,如果函数更长或更复杂,可能就不那么容易了。

函数可以用来传达文档的另一种方式是将它们分解成单独的函数。例如,假设我们有一个View方法,它增加了一个计数器,保存了一个Model,更新并渲染了另一个View方法,最后改变了 URL,如下所示:

    example: function(router) {
        this.model.set('counter', this.model.get('counter') + 1);
        this.model.save();

        this.siblingView.model = this.model;
        this.siblingView.render();

        var url = this.url();
        router.navigate(url, {silent: true, trigger: false});
    }

命名这样的方法会有困难:

"incrementCounterAndSaveModelAndUpdateAndRenderSiblingViewAndRefreshURL"听起来并不顺口。你可以简化为updateCounterAndRefresh,但这样就会失去一些更冗长的名称提供的非文档化信息。

一个更好的方法是使用更简单的名称,但将方法的不同部分分离成它们自己的方法,如下所示:

    incrementCounterAndSave: function() {
        this.model.set('counter', this.model.get('counter') + 1);
        this.model.save();
    },
    updateAndRenderSiblingView: function() {
        this.siblingView.model = this.model;
        this.siblingView.render();
    },
    refreshURL: function(router) {
        var url = this.url();
        router.navigate(url, {silent: true, trigger: false});
    },
    updateCounterAndRefresh: function(router) {
        this.incrementAndSaveCounter();
        this.updateAndRenderSiblingView();
        this.refreshURL(router);
    }

正如你所见,我们的updateCounterAndRefresh方法的内容现在几乎就像一个英语文档字符串,有效地记录了正在发生的事情,而不需要任何实际的文档。此外,如果我们想对这个代码进行单元测试(我们将在下一章讨论),使用这些单独的方法会比最初更容易。

此外,前面的代码只是一个相对简单的例子,而你的实际方法可能实际上比六行要长得多。在现实世界的项目中,利用使用许多(命名良好的)方法而不是长而单一的代码方法的技术尤为重要,因为这将大大提高代码的可读性和测试的便捷性。

类、函数和其他变量名不是非文档化的唯一形式。如果正确使用,文件名和文件夹结构也可以提供大量信息。考虑一个名为Book.js的文件。单独来看,我们无法知道这个文件是否包含一个Book模型、一个Book视图、两者都有,或者完全是其他东西。然而,如果这个文件被重命名为"BookView.js",那就很明显了。同样,如果文件保留了名为"Book.js"的名字,但存储在名为"views"的文件夹中,内容也会很明显,而不需要任何额外的文档。

非文档化对其他方法的好处

在我们继续介绍其他两种文档方法之前,值得注意的是,我们刚才描述的策略并不仅在你选择非文档方法时有用。实际上,这对所有程序员都是极好的建议。无论你生成什么文档来补充你的代码,最终你和你同行开发者都将不得不与代码本身而不是文档一起工作。通过努力使代码尽可能易于阅读和指导,你提供了虽然与显式文档提供的不同,但同样非常有价值的益处,尤其是在长期来看。

简单的文档方法

非文档对于编写可维护的代码至关重要,但它确实有其局限性。例如,虽然你可以使用表达性的变量名来描述许多变量,但你不能使用它们来描述 Backbone 本身的部分。例如,当一个 View 接收一个 model 选项时,唯一更具有表达性地重命名它的方法就是创建一个全新的属性:

var BookView = Backbone.View.extend({
    initialize: function() {
        this.bookModel = this.model;
    }
});

现在,上述代码并没有什么问题,但从某种意义上说,它跨越了 代码作为文档用代码替换文档 之间的界限。在这些情况下,一个更自然的解决方案可能是简单地使用注释,如下所示:

// This View takes a ""Book"" ModelBookView = Backbone.View.extend();

然而,许多程序员发现很容易错过这样的单行注释,因此他们只将它们保存下来以记录特定的代码行。对于类或方法文档,这些开发者依赖于一种特殊的多行注释形式,它以一个额外的前置星号以及(可选的)后续每行的前置星号标记:

/**
 * This View takes a ""Book"" Model
 */BookView = Backbone.View.extend();
When used this way throughout your code, these documentation sections form easy-to-read alternating blocks, making it trivial to skim through to what you're looking for without having to actually read the code in between:BookView = Backbone.View.extend({
    /**
     * This ""foo"" method does foo stuff
     */
    foo: function() {
        doSomeFooStuff();
    },
    /*
     * This ""bar"" method takes a ""Baz"" argument and does bar stuff
     */
    bar: function(baz) {
        doBarStuffWith(baz);
    }
});

大多数现代代码编辑器也会将此类文档与代码的其他部分区分开来,使其更容易浏览。

通过使用这些多行注释来描述你的类和方法,以及使用单行注释来解释函数内部的复杂代码,你可以非常有效地记录 View 拥有的 Model 类别或哪些路由使用特定的 View。此外,虽然编写这样的注释确实比依赖非文档花费更多时间,但它们所花费的额外几秒钟可能会为你节省数小时甚至数天的工作时间。

强健的文档方法

上述两种方法中的任何一种都足以让大多数程序员在不花费太多时间编写文档的情况下获得文档的好处。然而,如果你正在寻找一个更通用的文档结构,或者如果你和你的团队不是文档的唯一受众,那么使用外部工具如 JSDoc 或 Docco 可能会更有益。

在大型组织中,不同团队负责代码的不同部分的情况并不少见。当一个团队需要使用另一个团队管理的组件或库时,第一个团队可能不想阅读第二个团队的代码,或者更确切地说,他们可能甚至没有访问权限。在这些情况下,稳健的文档可以通过提供一种方式,让团队在不直接阅读代码的情况下理解彼此的代码,从而非常有用。

另一个重要的场景是用户定制。随着 Backbone 所提供的强大和稳健的应用程序,通常只需一段时间,客户就会要求定制某些功能,或者如果他们自己不能编写 JavaScript,就会要求顾问为他们定制。在这些情况下,最佳解决方案通常是向客户公开代码的一部分以便他们操作,而这个公共定制 API 将需要文档,以便客户(或他们的承包商)可以学习如何使用它。

在这两种情况下,可以使用 JSDoc 或 Docco 这样的工具来生成单独的文档文件,这些文件可以在不共享代码本身的情况下共享。然而,与此同时,开发者仍然可以边编写代码边编写文档;他们不需要维护(例如)一个单独的 MS Word 文档。

许多团队也在他们的文档中使用 JSDoc 的结构,而实际上从未真正使用这个工具来生成面向外部的文档。对于这些团队,JSDoc 的主要好处在于其注解,这使得团队可以以类似的方式编写文档,并且以未来团队成员更可能理解的方式。

JSDoc

JSDoc (www.usejsdoc.org) 是最古老且最受欢迎的 JavaScript 文档工具。早在 1999 年,在 JSDoc 工具甚至存在之前,开发者就使用其语法(从 JavaDoc 借用)来记录非常早期的 JavaScript 代码。JSDoc 工具本身,现在已进入第三版,被整合到几个不同的其他工具中(包括 Google 的 Closure 编译器),并且几乎在所有主要的代码编辑器中都可以找到对其语法的支持。

使用 JSDoc,开发者可以轻松地将他的内联文档转换为外部 HTML 文件,如下面的截图所示:

JSDoc

JSDoc 通过依赖于文档字符串中包含的注解来工作。例如,考虑以下代码:

    /**
     * This ""bar"" method takes a ""Baz"" argument and does bar stuff
     */
    bar: function(baz) {
        doBarStuffWith(baz);
    }

与使用上述代码不同,使用 JSDoc 的开发者会使用 @param 注解,如下所示:

    /**
     * Does bar stuff
     * @param {Baz} baz this argument is used to do bar stuff
     */
    bar: function(baz) {
        doBarStuffWith(baz);
    }

"@param 注释告诉 JSDoc 该方法接受一个 baz 参数,并且它应该是一个 "Baz" 类的实例。完整的注释列表可以在 JSDoc 网站上找到,它们相当直接,所以我们在这里不会讨论所有内容。然而,其中两个(@property@param)在与 Backbone 代码一起使用时可能会有些问题,所以让我们来看看如何正确使用它们。

让我们假设我们想要记录一个 Backbone Model。为了做到这一点,我们可能会编写以下代码:

/**
  * This model represents a book in our application.
  * @class
  */
var Book = Backbone.Model({ ...

现在,让我们假设我们的 Book Model 可以有三个不同的属性:标题、描述和页数。现在的问题是,我们如何记录它们?JSDoc 中没有 @attribute 注释,因为 属性 是 Backbone 特有的,而 @param 注释似乎只能指定单个属性的参数;它不能(例如)告诉我们两个属性应该是字符串,而其他属性应该是整数。

幸运的是,可以通过使用多个 @param@property 注释来解决这个问题(使用哪一个取决于你;因为 Backbone 为属性和选项都创建了属性,所以两者实际上都是参数和属性)。考虑以下内容:

/**
  * This model represents a book in our application.
  * @param {object} attributes
  * @param {string} attributes.title book's title
  * @param {string} attributes.description description of the book
  * @param {integer} attributes.pageLength number of pages
  */Book = Backbone.Model({ ...

如果你只是非正式地使用 JSDoc(也就是说,你并不打算生成外部文档),你甚至可以通过省略初始的 @param {object} 属性注释来稍微简化上述内容。

Docco

Docco(jashkenas.github.io/docco/) 因其采取了与 JSDoc 完全不同的方法,并且是由 Backbone 本身的创造者(Jeremy Ashkenas)编写的而引人注目。与专注于创建 API 文档的 JSDoc 不同,Docco 专注于生成教程和/或解释给定代码块的工作原理。Docco 与 JSDoc 的不同之处还在于它使用单行注释,而不是多行注释来生成文档。

下面是使用 Docco 生成的文档示例;实际上,它是使用 Backbone 的源代码生成的(你可以在 Backbone 网站上找到原始版本):

Docco

如前例所示,Docco 生成的文档有两列。在右边是正在记录的原始源代码,不包括任何注释,而在左边是与该源代码对应的注释。例如,以下是用于生成前例的 Backbone 的原始行:

//     Backbone.js 1.1.2

//     (c) 2010-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
//     Backbone may be freely distributed under the MIT license.
//     For all details and documentation:
//     http://backbonejs.org

(function(root, factory) {

  // Set up Backbone appropriately for the environment. Start with AMD.
  if (typeof define === 'function' && define.amd) {
    define(['underscore', 'jquery', 'exports'], function(_, $, exports) {
      // Export global even in AMD case in case this script is loaded with
      // others that may still expect a global Backbone.
      root.Backbone = factory(root, exports, _, $);
    });

  // Next for Node.js or CommonJS. jQuery may not be needed as a module.
  } else if (typeof exports !== 'undefined') {
    var _ = require('underscore');
    factory(root, exports, _);

  // Finally, as a browser global.
  } else {
    root.Backbone = factory(root, {}, root._, (root.jQuery || root.Zepto || root.ender || root.$));
  }

}(this, function(root, Backbone, _, $) {

  // Initial Setup
  // -------------

  // Save the previous value of the `Backbone` variable, so that it can be
  // restored later on, if `noConflict` is used.
  var previousBackbone = root.Backbone;

Docco 吸引人的一个关键部分就是它的简单性:没有注释,甚至没有多行注释,只有普通的 // 单行注释。Docco 还很有价值,因为它生成的文档类型:如果你想创建教程或代码的遍历,Docco 比起 JSDoc 是一个更好的选择。

最终,你将使用哪种文档系统或系统将取决于你的需求和未来的期望。即使你选择了简单的文档,你也绝不能低估正确记录你的代码的需求。如果你这样做,未来的你(以及可能的其他同事)将会后悔。

当然,就像编程中的大多数事情一样,也可能有太多的文档。在添加文档时,你应该始终记住,随着你重构和更新代码,为该代码编写的任何文档都必须相应地进行更新。这当然不应该阻止你添加文档,考虑到它的许多好处,但在我们继续进行测试(它也有同样的缺点)之前,我们不应该不提及其持续维护的成本。

摘要

在本章中,我们学习了如何记录 Backbone JavaScript 代码,以及在进行文档记录时应关注的具体领域。我们探讨了三种不同的文档选项——非文档、简单文档和强大文档——并考虑了两种用于生成强大文档的流行工具:JSDoc 和 Docco。

在下一章中,我们将探讨如何测试你的 Backbone 代码。特别是,我们将探讨流行的测试运行框架 QUnit 和 Mocha,以及用于创建间谍、存根和模拟的 Sinon 库。

第十章. 驱虫之道 – 如何测试 Backbone 应用程序

在本章中,我们将探讨测试 Backbone 应用程序的挑战,包括以下内容:

  • 选择单元测试框架

  • 在 BDD 和 TDD 测试风格之间做出决定

  • 在测试中模拟相关组件

  • 使用 Selenium 创建验收测试

JavaScript 中的测试?

在许多年里,测试 JavaScript 代码的想法是可笑的。毕竟,谁会为一些表单验证脚本编写测试套件呢?此外,即使你正在做一些更有趣的事情,实际上可以从测试中受益,但当时没有 JavaScript 库简化测试,所以不得不从头开始。

2008 年,随着一波新的自动化 JavaScript 测试工具的出现,所有这些库都专注于单元测试或对代码特定部分的测试(通常是一个单独的方法或小型类)。最早的两个单元测试库 QUnit 和 Jasmine 提供了在浏览器中进行测试的对抗性方法(TDD 与 BDD),而第三个库 JS Test Driver 则提供了命令行测试。紧随其后的是其他一些单元测试库,包括 Buster.js 和 Mocha.js,以及测试支持或mock库,如 JsMockito、JSMock 和 Sinon.JS。

现在,随着 JavaScript 开发者创建越来越先进的 Web 应用程序(尤其是 Backbone 所可能实现的类型),测试已经成为绝对必要的。测试可以防止由于代码的复杂性而难以甚至无法找到的 bug。更重要的是,也许,一个合适的测试套件允许开发者安全地重构他们的代码,这对于保持代码库可维护性是一个频繁且必要的任务。

到目前为止,如果你正在使用 Backbone 构建一个严肃的 Web 应用程序,那么测试套件不仅仅是一种奢侈,更是一种必需品。

选择哪个库

为现代 Web 应用程序构建适当的测试能力需要以下三个主要部分:

  • 单元测试框架:一个用于测试代码特定单元(通常是函数或小型类)的工具

  • 模拟库:一个通过创建对象的假版本来简化测试的工具

  • 接受测试框架:一个用于测试完整用户体验的工具,例如登录您的网站或订购产品

根据你选择的单元测试库,你可能还需要下载额外的工具。例如,许多库提供替代的测试报告风格,这些风格必须单独下载并包含在内。此外,如果你使用的是一个无法在命令行中运行测试的库,你可能希望使用无头浏览器(如非常流行的 PhantomJS)来添加此功能。这样做将使自动化测试更容易,例如,可以定期运行或在代码提交时运行。虽然可以选择多种不同的模拟库(我们将在稍后解释为什么你需要一个),但我们推荐使用最受欢迎的 Sinon.JS。Sinon 既强大又易于使用,因此它几乎适合任何项目。同样,接受测试的主要选项只有一个,那就是 Selenium。我们将在本章末尾讨论 Selenium,所以现在我们将专注于单元测试库的选择。

测试库的质量非常高,功能差异也很多,不可能在这本书中全部正确地涵盖它们。然而,我们确实需要为这一章的示例选择一个库,因此我们选择了 Mocha.js。Mocha 是可用的较新库之一,也是功能最强大和最稳健的库之一。Mocha 可以使用传统的 xUnit/TDD 风格,这种风格被 QUnit 等库所使用,也可以使用由 Jasmine(至少在 JavaScript 中)普及的 BDD 风格。此外,虽然它可以在浏览器中运行,但也可以通过使用 PhantomJS 在命令行中运行。

在所有这些之上,Mocha 为其用户提供选择四种不同的断言库。虽然其他主要测试库将断言作为库本身的一部分(这确实更方便:需要下载的文件更少),但 Mocha 更倾向于给开发者选择。因为断言的实现方式可能存在细微的差异,Mocha 允许你选择风格,这样你可以选择最吸引你的风格。

然而,尽管我们将在这章中使用 Mocha,但我们将要展示的大部分内容实际上将与许多其他流行的测试库的工作方式相似(如果不是完全相同)。

开始使用 Mocha

要使用 Mocha,你首先需要从项目的 GitHub 页面(可在github.com/mochajs/mocha找到)下载mocha.cssmocha.js文件。你还需要下载一个断言库;在这个例子中,我们将使用Expect.js(你可以从github.com/Automattic/expect.js下载)。

接下来,你需要创建一个新的 HTML 页面,并为你的应用程序的所有 JavaScript 文件以及所有库文件(例如,Backbone 和 jQuery)添加脚本标签。你还需要添加一些 Mocha 模板代码。总的来说,你应该得到一个看起来像以下这样的 HTML 文件:

<html>
<head>
<!-- External Library Files -->
<script src="img/underscore.js"></script>
<script src="img/jQuery.js"></script>
<script src="img/Backbone.js"></script>

<!-- Application-Specific Files -->
<script src="img/SomeModel.js"></script>
<script src="img/SomeView.js"></script>
<script src="img/SomeOtherNonBackboneCode.js"></script>

<!-- Test Library Files -->
<script src="img/mocha.js"></script>
<link rel="stylesheet" type="text/css" href="/mocha.css"></link>
<script src="img/expect.js"></script>
</head>
<body>
<div id="mocha"></div>
<!-- Test Code -->
<script>
mocha.setup('bdd'); // start Mocha in BDD mode
// *INSERT TESTS HERE*
mocha.run();
</script>
</body>
</html>

TDD 与 BDD:有什么区别?

在前面的代码中,我们调用 mocha.setup(bdd),这启动 Mocha 在 BDD 模式下(而不是 TDD 模式)。但这究竟是什么意思?

测试驱动开发TDD)是一种开发风格,其中开发者开始所有工作都是通过编写一个(失败的)测试用例。然后开发者只添加足够多的代码到他的应用程序中,以使该测试通过,但不超过这个程度。一旦测试通过,开发者就会编写一个新的(失败的)测试,并重复这个循环。

TDD 的优势有两个。首先,通过始终先编写测试,开发者保证他的代码总是完全被测试覆盖。其次,TDD 风格自然地迫使开发者以可测试的方式编写代码,这意味着采用像写几个短函数而不是一个长函数这样的实践。正如我们在上一章中提到的,这些实践的好处超出了测试环境。

然而,TDD 也有其缺点。也许,最显著的一个是它可能导致过多的、无用的测试,这些测试需要大量的时间来维护。此外,由于其本质,TDD 非常重视代码的“如何”而不是“做什么”,但许多程序员会争论代码的“做什么”对测试来说更为重要。

这种区别在重构时尤其重要。根据定义,当你改变代码执行的方式而不改变其执行的内容时,就需要进行重构操作。由于基于 TDD 的测试非常重视“如何”,每当发生重构时,测试都需要更新以匹配新的代码。

行为驱动开发BDD)与 TDD 非常相似,但试图通过关注代码执行的内容来解决与 TDD 相关的问题。这主要通过对组织测试和测试套件的语法进行稍微更详细的描述来实现。当然,一个人不一定需要不同的语法来编写关注代码执行内容的测试,但 BDD 语法的优点是它自然地鼓励这种行为。

考虑以下使用 Mocha 的 TDD 语法和更好的断言库编写的关于 Backbone Modelset 方法的假设测试。它通过创建 testModel,设置一个属性(a)的值(1),然后确认属性/值对已添加到 Model 的测试属性中:

suite('Model', function() {
    var testModel;
    setUp(function() {
        testModel = new Backbone.Model();
    });
    suite('set', function() {
        test('set adds a value to the model\'s attributes', function() {
            testModel.set('a', 1);
            assert(testModel.attributes.a === 1);
        });
    });
});

如您所见,在 TDD 代码中,测试是通过使用test函数来注册的,而测试组则被组织成套件。此外,测试通过使用assert语句来验证代码的有效性。现在,让我们看看使用 BDD 语法创建的类似想象中的测试:

describe('Model', function() {
    var testModel;
    beforeEach(function() {
        testModel = new Backbone.Model();
    });
    describe('#set', function() {
        it('sets a value that "get" can retrieve', function() {
            testModel.set('a', 1);
            expect(testModel.get('a')).to.be(1);
        });
    });
});

注意这两个测试是多么相似:虽然第一个例子使用了suite,而第二个使用了describe,但这两个函数都有分组测试的相同效果。同样,在 BDD 示例中,测试仍然被定义,并且仍然包含有效性检查,但测试是通过it函数定义的,而不是断言,我们有期望(expect 调用)。

然而,有两个重要的区别,不仅仅是函数名称的差异。首先,后者例子读起来更像一个英语句子,这使得在代码中阅读以及稍后查看测试结果都更容易。其次,更重要的是,第一个测试强调了set的工作方式,而第二个则强调了它应该做什么。虽然 TDD 或 BDD 风格中并没有内在的东西强迫测试以某种特定方式编写,但这个例子突出了语法如何仍然可以影响测试的设计。如果 Backbone 开发者将来改变 Backbone 以使用不同于attributes的名称,这可能会变得很重要,因为他们将不得不重写第一个测试,而第二个则无需调整即可继续工作。

最终,使用任何一种测试风格都可以测试代码的功能,因此,这实际上只是一个关于你更喜欢哪种风格的问题。然而,如果你对此没有意见,BDD 语法可能是一个更好的起点,这不仅因为其改进的可读性,还因为它自然强调描述代码应该做什么。出于这些原因,我们将继续在本章的其余部分使用 BDD 语法。

描述、beforeEach 和 it

如果你之前在其他语言中使用过测试库,你可能已经理解了前面的代码,如果没有,让我来解释一下。测试从对 Mocha 的describe函数的调用开始。这个函数创建了一个套件(或分组)的测试,你可以选择嵌套更多的describe函数来进一步组织你的测试。使用describe语句的一个好处是,当你运行测试时,你可以选择只运行由describe定义的特定套件,而不是一次性运行所有测试。describe函数接受两个参数:一个字符串,表示正在描述的内容,以及一个函数,用于包装describe函数内的所有测试。

接下来,我们有一个 beforeEach 调用,正如你可能想象的那样,它定义了在每个测试之前运行的代码。这可能是一个有用的地方,用于分离 describe 语句中所有测试的共同代码(例如,在前面的例子中创建一个新的 Backbone.Model 类)。Mocha 还有一个等效的 afterEach 函数,在每个测试完成后以类似的方式运行,可以用来清理测试的副作用。Mocha 还有一个 beforeafter 函数,它们类似,但它们只在每个 describe 语句(即每个测试套件)中运行一次,而不是在每个测试中运行一次。

在另一个 describe(这次是为了将相关的 set 测试分组),我们来到了 it 函数,它实际上定义了一个特定的测试。和 describe 一样,it 函数接受两个参数:一个字符串,描述了它(被测试的代码)应该做什么,以及一个函数,用于测试是否发生了指示的行为。如果传递给 it 函数的函数在完成时没有抛出错误,Mocha 会认为这个测试已经通过,而如果抛出了错误,它将标记该测试为失败(并在测试输出中提供抛出错误的堆栈跟踪,以便您可以轻松地调试相关代码)。

最后,我们有 expect 函数(如果选择了不同的断言库,它也可以被称为 assert 或其他名称)。expect 函数接受一个参数,然后使用类似 jQuery 的链式调用来断言关于第一个参数的内容。以下是一些示例:

expect(1).to.be(1); // asserts that 1 === 1
expect(1).not.to.be(2); // asserts that 1 !== 2
expect(1).to.eql("1") // asserts that 1 == "1"
expect(1).to.be.ok(); // asserts that 1 is "truthy" (evaluates true when used as a boolean)
expect(1).to.be.a('number'); // asserts typeof 1 === 'number'

Expect.js 库还有其他几种断言形式,所有这些都可以在它们的 GitHub 页面上找到详细说明。

运行我们的测试

现在我们已经了解了所有的工作原理,让我们尝试实际运行我们的测试。为此,只需将以下行替换为我们之前提供的 describe 代码即可:

// *INSERT TESTS HERE*

或者,你也可以选择将 describe 代码放入一个单独的文件中,并用一个引用此文件的脚本标签替换插入行。无论哪种方式,保存文件并在您最喜欢的网络浏览器中打开它。你应该会看到类似以下内容:

运行我们的测试

我们的测试通过了!

介绍模拟

只要我们正在测试的代码相对简单,Mocha 和 Expect.js 就足够我们测试了。然而,代码很少保持简单,特别是有两个复杂问题可能会让我们需要另一个工具,即所谓的 mocking 库,让我们能够创建对象的假版本或模拟。

首先,让我们想象我们想要测试一个虚构的 ExampleModel 类的以下方法:

foo: function() {
    this.bar += 1;
    this.baz();
}

现在,我们将想要测试我们的 foo 方法是否调用了 baz 方法。然而,与此同时,我们(可能)已经有一个针对 baz 方法本身的单独测试。这让我们陷入了两难:如何在 foo 的测试代码中测试 foo 调用 baz 而不重复 baz 的测试代码?

或者,让我们考虑另一个可能想要测试的虚构方法:

fetchThenDoFoo: function() {
    this.fetch().done(this.foo);
}

在这种情况下,我们想要测试在 fetch 操作完成后是否调用了 foo,但要真正测试这一点,我们需要通过 AJAX 从服务器实际获取一个 Model 类。这反过来会使我们的测试需要活跃的服务器,从而使测试速度显著减慢,并可能导致测试对服务器产生副作用。

解决这两个问题的方法是使用一个模拟库,如 Sinon.js。为此,只需从 sinonjs.org/ 下载 sinon.js,然后通过一个 script 标签将此文件包含在运行测试的 HTML 页面上。

一旦 Sinon 可用,我们就可以用它来解决我们的测试问题。首先,让我们用它来为我们的 foo 方法创建一个特殊的模拟类型 stub,如下所示:

describe('foo', function() {
    var bazStub,
        example;
    beforeEach(function() {
        example = new ExampleModel();
        // Replace the real "baz" with a fake one that does nothing
        bazStub = sinon.stub(example, 'baz');
    });
    it('calls baz', function() {
        example.foo();
        expect(bazStub.calledOnce).to.be(true); // did foo call baz?
    });
    afterEach(function() {
        // Restore the original baz (in case another test uses it)
        baz.restore();
    });
});

如您在前面的代码中所见,我们能够使用 Sinon 创建一个 stub 来替换正常的 baz 方法。虽然这个 stub 实际上没有做什么,但它确实跟踪了它被调用的次数(以及使用了哪些参数,尽管我们没有测试这一点),这使得我们能够编写一个测试,确保 foo 会调用 baz,而无需重复任何 baz 的测试代码。

对于我们的第二个问题,即测试 AJAX 方法,我们可以使用一个特定的 AJAX 模拟工具,如 MockJax。然而,Sinon 的功能如此强大,我们实际上真的不需要使用任何其他工具;考虑以下测试:

describe('fetchThenDoFoo', function() {

    var fetchStub,
        fooStub,
        example;
    beforeEach(function() {
        example = new ExampleModel();
        // Replace the real "fetch" with a fake one that returns an
        // already-resolved $.Deferred
        var deferred = new $.Deferred().resolve();
        fetchStub = sinon.stub(example, 'fetch').returns(deferred);
        // Since we only want to test whether or not foo was called,
        // we can also use stub for it
        fooStub = sinon.stub(example, 'foo');
    });
    it('calls foo after fetch completes', function() {
        example.fetchThenDoFoo();
        expect(fooStub.calledOnce).to.be(true);
    });
    afterEach(function() {
        // Restore the original versions of our stub functions
        fetchStub.restore();
        fooStub.restore();
    });
});

在这个例子中,我们使用了两个 stub 函数。我们使用 fooStub 函数的方式与我们在上一个例子中使用 bazstub 函数的方式相似,以检查 foo 是否被调用,但我们的 fetchStub 扮演了不同的角色。通过在 stub 创建时链式调用 returns 方法,我们创建了一个 stub 函数,与之前的 stub 函数不同,它实际上做了些事情:它返回了我们的已解析的延迟函数。由于 jQuery 将已解析的延迟函数处理方式与它处理完成的 AJAX 调用的方式相同(通过调用调用的任何完成代码),我们模拟了 AJAX 调用的返回,而不涉及任何实际的服务器。

Sinon 还有许多与 stub 相关的其他有用方法,以及其他类型的模拟函数,如 spymock,所有这些都在他们的网站上得到了很好的文档说明。Sinon 还有一个名为 sandbox 的功能,它可以在每次测试运行后(当 sandbox 被清理时)自动激活,从而消除所有的 baz.restore() 代码。同样,你可以在 Sinon 的网站上找到这个功能的所有详细信息。

Selenium

到目前为止,我们一直专注于单元测试,但值得一提的是,还有许多其他形式的测试可以惠及 Backbone 应用程序,例如负载测试、冒烟测试和可用性测试。然而,对所有这些测试的详细讨论超出了本书的范围,并且在大多数工作环境中,这些测试通常不由开发团队负责。相反,它们将由质量保证(QA)部门管理。

然而,有一种测试类型,即验收测试,值得特别提及。在缺乏质量保证(QA)部门的较小组织中,验收测试通常由开发者创建和维护,即使在较大的组织中,开发者协助创建和维护这些测试的情况也并不少见。

验收测试通过检查用户是否能够执行特定操作来测试您网站的功能。一个特定的验收测试可能会检查用户是否可以登录网站、更改密码或下订单。与测试较小功能部分(如单个placeOrder函数)的单元测试不同,验收测试验证了用于完成特定用户交互的所有不同功能。

在网络上,有一个工具几乎完全主导了验收测试:Selenium WebDriver (www.seleniumhq.org/)。Selenium WebDriver 允许您创建自动化的测试,这些测试可以完美地模拟真实用户在您网站上的操作。Selenium 测试可以点击按钮、填写文本字段、滚动,以及做几乎任何实际用户能做的事情。

Selenium 支持多种不同的语言,包括 JavaScript。它可以直接通过 Selenium WebDriver 使用(code.google.com/p/selenium/wiki/WebDriverJs)或者被一个提供不同语法的库(如 Nightwatch.js (nightwatchjs.org/))包装。以下是从 Nightwatch.js 主页上的一个示例,展示了使用 Nightwatch.js 编写的简单验收测试:

module.exports = {
    'Demo test Google' : function (client) {
        client
          .url('http://www.google.com')
          .waitForElementVisible('body', 1000)
          .assert.title('Google')
          .assert.visible('input[type=text]')
          .setValue('input[type=text]', 'rembrandt van rijn')
          .waitForElementVisible('button[name=btnG]', 1000)
          .click('button[name=btnG]')
          .pause(1000)
          .assert.containsText('ol#rso li:first-child',
            'Rembrandt - Wikipedia')
          .end();
  }
};

如前例所示,Nightwatch.js(以及 Selenium 本身)使用与您已经用于 CSS 和 jQuery 相同的选择器,以及如waitForElementVisibility之类的特殊方法来控制时间并防止自动化脚本在测试中移动过快。这允许您模拟任何您想要的用户故事,无论它多么简单或复杂,然后,反复重复这个故事来测试您的网站。

然而,基于 Selenium 的测试确实有其局限性,最大的局限性在于 Selenium 在用户级别操作,对代码级别发生的事情没有意识。如果一个 Selenium 测试失败,它不会提供堆栈跟踪或指向失败的代码行;相反,它只会简单地提醒你某个特定操作失败了,而调试工作将取决于你手动完成,就像处理用户提交的缺陷一样。理想情况下,你应该尽量在你的单元测试套件中捕捉尽可能多的失败,并且只依赖基于 Selenium 的验收测试来发现那些在单元测试中“漏网之鱼”的缺陷。

摘要

在本章中,我们学习了如何结合各种工具来测试您的 Backbone 应用程序。我们了解了 TDD 和 BDD 之间的区别,以及 QUnit 和 Jasmine 等主要测试库之间的差异。我们深入探讨了如何使用 Mocha 框架创建测试套件,以及如何使用 Sinon 库在该套件中模拟代码的部分。

在下一章中,我们将探讨各种其他第三方工具,这些工具可以帮助您创建 Backbone 应用程序。我们将查看通用工具,例如 Require.js 依赖管理系统,以及 Backbone 特定的工具,例如 BackGrid 和 BackSupport。

第十一章。(不)重新发明轮子 – 利用第三方库

在本章中,我们将简要介绍通用和 Backbone 特定的第三方库的混合,所有这些库都可以为 Backbone 开发者带来好处。特别是,我们将查看以下内容:

  • 依赖管理工具 Require.js 和 Bower

  • 表生成工具 Backbone Paginator 和 BackGrid

  • HTML 模板工具 Handlebars

  • 任务自动化工具 Grunt

  • 替代语言 CoffeeScript

  • 通用 Backbone 工具库 BackSupport

Backbone 生态系统

在 Backbone 存在的五年间,其流行度导致了数百个相关第三方库的开发。此外,还发布了众多其他通用库,这些库对 Backbone 开发者来说也可能非常有价值。虽然我们没有足够的空间深入探讨这些库,但本章将为你提供每个库的预览,以便你可以识别出对你最有益的库。

在本章中,当我们介绍各个库时,请记住,对于每个我们预览的库,都存在几个(有时甚至几十个)我们没有介绍的竞争性库。虽然我们可以尝试列出每个类别中每个可用的库,但这样的列表会很快过时,因此我们选择只关注最受欢迎的库。如果本章中的任何库看起来对你有用,我们强烈建议你在互联网上搜索,看看还有哪些类似的库可供选择,因为你可能会找到一个更适合你的库。

使用 RequireJS 进行依赖管理

在现代 IDE 中,开发者只需输入文件名的一两个字符就可以打开文件。这个功能,加上保持代码组织的一般愿望,促使开发者将代码分离成多个文件。在 Backbone 中,这通常意味着为每个 CollectionModelViewRouter 创建一个文件,即使在小型项目中,这也可能积累很多文件。

所有这些文件都带来了两个问题。首先,每个文件都需要单独下载,正如我们在第八章《扩展:确保复杂应用程序的性能》中学到的,浏览器一次只能下载 2 到 8 个文件。其次,由于不同文件之间的依赖关系(例如,“视图 A”需要“集合 B”,“集合 B”需要“模型 C”,依此类推),文件加载的顺序可能会变得越来越难以管理。RequireJS (requirejs.org/)解决了这两个问题。

RequireJS 通过将你的代码组织成模块来实现这一点。每个模块可以可选地依赖于一个或多个其他模块,而 RequireJS 将负责以保留所有依赖关系的方式将这些模块拼接在一起。RequireJS 还提供了一个相关的工具,称为 RequireJS 优化器,它允许你将多个模块合并成一个文件。优化器还可以“压缩”你的代码,以及“混淆”它,使其他人更难理解(以防止竞争对手阅读你的源代码文件)。

这里是一个示例 RequireJS 模块,用于 BookList 视图类:

// All RequireJS modules start by calling a special "define" function
define([
    // The module's dependencies are the first argument
    'collections/Books', // dependency on collections/Books.js
    'models/Book'       // dependency on models/Book.js

// The function that defines the module is the second argument
], function(
    // The variable names for each dependency make up the arguments to that function    Books, // alias the "collections/Books" module as "Books"
    Book   // alias the "models/Book" module as "Book"
) {
    // The actual module itself goes here
    var BookList = Backbone.View.extend({
        // Logic for our BookList View would go here; presumably it
        // would use both Book and Books
    });
    // To tell RequireJS what variable this module should "define" simply return
    // that variable at the end
    return BookList;
    // in other words, whatever is returned will be what is passed in
    // to other modules that depend on this one
});

RequireJS 管理依赖项的风格被称为AMD风格。还有一种竞争风格,称为Common JS,它被其他依赖项管理库(如 Browserify 或 Hem)使用。Common JS 模块看起来显著不同;以下是我们之前示例使用 Browserify 的 Common JS 语法的重写:

// Dependencies are brought in by using the "require" function
// The module aliases are defined on the same line using the 
// standard JavaScript syntax for declaring a variable
var books = require('collections/Books');
var book = require('models/Book');
// Just as before, the contents of the module are defined using
// standard JavaScriptBookList = Backbone.View.extend({
    // Logic for our BookList View would go here; presumably it would
    // use both Book and Books
});
// Instead of returning what the module defines, in CommonJS modules
// are "exported" by assigning them to a "module.exports"module.exports.BookList = BookList;

这种方法的缺点是,模块的实际顺序并没有为你处理,就像在 RequireJS 中那样。相反,必须使用require语句单独指定模块的顺序:

<script src="img/fileWithModuleDefinitions.js"/>
<script>
require('collections/Books');
require('models/Book');
require('views/BookList');
</script>

使用 Bower 进行外部依赖项管理

除了使用 RequireJS 或类似库来管理代码的依赖项之外,许多程序员还使用另一个名为Bower (bower.io/)的工具来管理他们的外部库依赖项。和 Bower 与 Python 的pip或 Node.js 的 NPM 类似,Bower 提供了一个简单的命令行界面,可以轻松安装外部库。值得注意的是,NPM 本身也可以用于管理客户端上的库,但这个工具主要设计用于服务器端开发者,而 Bower 主要设计用于客户端。要安装一个库,例如 jQuery,你只需在命令行中运行以下命令:

bower install jquery

多个 Bower 依赖项可以存储在一个bower.json文件中,允许你使用单个命令安装应用程序的所有依赖项。以下是一个此类文件可能的样子:

{
    "name": "your-project",
    "version": "0.0.1",
    "ignore": [
        "**/*.txt"
    ],
    "dependencies": {
        "backbone": "1.0.0",
        "jquery": "~2.0.0"
    },
     "devDependencies": {
        "mocha": "¹.17.1"
    }
}

正如你所见,前面的文件定义了项目的依赖关系。它包括 Backbone 和 jQuery 但不包括 Underscore;作为 Backbone 的依赖项,Underscore 将被自动下载(jQuery 在技术上不是 Backbone 的依赖项,因为库在没有它的情况下也能工作,所以我们必须单独要求它)。它还允许我们将 Mocha 作为devDependency包含进来,这意味着它将在开发环境中下载,但不在生产环境中。使用这样的需求文件可以让你将外部库从源代码控制系统中分离出来,并在新版本发布时轻松更新它们。它还允许你轻松管理这些库的不同构建版本(例如,调试版本与压缩版本)。

使用 Backbone Paginator 进行分页

许多 Backbone 开发者面临的一个非常常见的任务是对分页数据进行渲染。例如,你可能有一个可以返回数百个结果的搜索页面,但你只想显示前二十个结果。为此,你本质上需要两个 Collection 类:一个用于所有结果,另一个用于正在显示的结果。然而,在这两个之间切换可能会很棘手,你可能实际上并不想一次性获取数百个结果。相反,你可能只想获取前二十个,但仍然能够知道总共有多少个结果,以便你可以将此信息显示给用户。

Backbone Paginator (github.com/backbone-paginator/backbone.paginator) 是一个专门为这个目的创建的 Collection 类。Backbone Paginator 最初是两个独立的库,但这两个库已经合并,使 Backbone Paginator 成为处理 Backbone 中分页数据的主要工具。

Backbone Paginator 可以在以下三种模式之一中使用:

  • client:当你想一次性获取整个 Collection 时使用。

  • server:当你想将大部分 Collection 留在服务器上,只获取相关部分时使用。

  • infinite:用于创建一个 Collection 类来支持类似 Facebook 的无限滚动视图。

要使用 Backbone Paginator,你只需扩展其 PageableCollection 类来创建你自己的可分页 Collection 类,如下所示:

var BookResults = Backbone.PageableCollection.extend({
    model: BookResult,
    queryParams: {
        currentPage: 'selected_page',
        pageSize: 'num_records'
    },
    state: {
        firstPage: 0,
        currentPage: 5,
        totalRecords: 500
		},
		url: 'www.example.com/api/book_search_results'
});

正如你所见,一个 PageableCollection 类与一个普通的 Collection 类非常相似:它有 modelurl 属性,可以扩展,等等。然而,它也有两个特殊的属性。

第一个属性是 queryParams,它告诉 PageableCollection 如何解释服务器响应中的分页信息,这与 parse 方法通常告诉 Backbone 如何解释服务器响应的方式非常相似。第二个属性是 statePageableCollection 使用它来跟踪用户当前所在的页面、每页有多少个结果等等。

完整的 Backbone Paginator 库为 queryParamsstate 提供了几个其他选项,以及一系列分页方法,如 getPage(用于跳转到特定结果页)或 setSorting(用于更改结果的排序方式)。如果你想自己实现分页视图,你可以在 Backbone Paginator 的 GitHub 页面上找到完整的文档。然而,实际上,你可能根本不需要创建自己的分页数据 View,因为已经有一个非常强大的现有 View 可以利用:BackGrid。

使用 Backgrid.js 渲染表格

有几个不同的专门用于在 Backbone 中渲染表格的 View 库,还有许多流行的非 Backbone 特定库(如 jqGridDataTables),你可以轻松地将它们用于 Backbone。然而,BackGrid (backgridjs.com/) 从其他库中脱颖而出,因为它结合了强大的功能集、简单的设计以及对 Backbone Paginator 的原生支持。

下面是一个使用 BackGrid 生成的表格示例:

使用 Backgrid.js 渲染表格

要使用 BackGrid,你只需像扩展任何其他 View 一样扩展它,然后使用一个额外的 columns 选项来实例化它:

var BookResultsGrid =  Backgrid.Grid.extend();
var grid = new BookResultsGrid({
    columns: [
        {name: 'bookTitle', label: 'title', cell: 'string'},
        {name: 'numPages', label: '# of Pages', cell: 'integer'},
        {name: 'authorName', label: 'Name of the Author',
         cell: 'string'}
    ],
    collection: bookResults
});
grid.render();

如你所见,你提供的额外 columns 选项告诉 BackGrid 使用 Model 的哪个属性来为列的数据(name)使用,该列的标题文本应该是什么(label),以及 BackGrid 应如何格式化该列的单元格(cell)。一旦你向 BackGrid 提供了这些列和一个 Collection 类,BackGrid 将使用提供的 Collection 中的每个 Model 来生成一个 <tr> 元素,从而生成一个 <table> 元素作为其 el

如果你只想显示信息的表格,那么你只需要做这些,但 BackGrid 也可以选择性地用于编辑信息。要使用此功能,你只需在每个你想使其可编辑的列中传递一个额外的 editable: true 选项。当 BackGrid 渲染其表格时,用户将能够点击可编辑列中的任何单元格以将选定的单元格切换到 编辑 模式(例如,将纯文本替换为 HTML <input> 标签),并且用户所做的任何更改都将自动更新到相应的 Model

BackGrid 还具有许多其他功能,例如通过扩展 Backgrid.Cell 定义自己的自定义单元格类型的能力。你可以在 Backgrid 的网站上找到这些功能的完整列表,该网站拥有优秀的教程式文档和 API 参考文档。

使用 Handlebars 模板

正如我们在第五章中讨论的,“使用视图添加和修改元素”,使用模板来渲染视图的 HTML 提供了许多好处。虽然你可以简单地使用 Underscore 的 template 函数,但如果你需要一个更强大的模板语言,有许多不同的库可供选择。对于本章,我们将使用 Handlebars (handlebarsjs.com/) 作为我们的模板引擎。你可能还想考虑的其他库包括 Mustache (github.com/janl/mustache.js)、Embedded JS (embeddedjs.com/) 或 Hogan.js (twitter.github.io/hogan.js/)。

Handlebars 是从另一个模板库(Mustache)创建的,它提供了大量的模板逻辑,形式为“辅助函数”。例如,以下是一个 Handlebars 模板,它使用“each”辅助函数和“if”辅助函数来渲染一个以“先生”或“女士”开头的人名列表,具体取决于个人的性别:

<ul>
    {{#each people}}
    <li>
        {{#if this.isMale}}Mr.{{else}}Ms.{{/if}} {{this.lastName}}
    </li>
    {{/each}}
</ul>

Handlebars 还允许你定义自己的自定义辅助函数,这使得它非常易于扩展。这些辅助函数非常灵活,如果你愿意,你可以在模板中创建一个完整的子语言。

一旦你编写了一个模板,你既可以将其直接包含在你的 JavaScript 代码中(用引号括起来以使其成为一个有效的字符串),也可以将其存储在单独的文件中,并使用 RequireJS 或类似工具将其引入。然后,编译模板并编写一个使用此模板的View render方法就变得简单了,如下所示:

var template = '<ul>' +
    '{{#each people}}' +
    '<li>' +
        '{{#if this.isMale}}Mr.{{else}}Ms.{{/if}} {{this.lastName}}' +
    '</li>' +
    '{{/each}}' +
'</ul>';
var compiledTemplate = Handlebars.compile(template);
var TemplatedView = Backbone.View.extend({
    render: function() {
        var templatedHtml = compiledTemplate(this.model.toJSON());
        this.$el.html(templatedHtml);
        return this;
    }
});
new TemplatedView({
    model: new Backbone.Model({isMale: 'true', lastName: 'Smith'})
}).render().$el.html(); // will be "Mr. Smith"

使用 Grunt 自动化任务

在许多软件项目中,有一些任务通常是自动化的。例如,就像传统的 C++或 Java 项目需要将源代码编译成字节码一样,JavaScript 项目可能需要使用 RequireJS 或 CoffeeScript 来编译其源代码。项目可能还需要连接文件、运行 linting 程序以验证源代码,或者组装其他 Web 组件,如精灵图像文件或由 SCSS/Less 生成的 CSS 文件。

大多数这些任务都不是语言特定的:你实际上不需要使用 JavaScript 代码来启动 RequireJS 优化器;你只需要一个命令行。正因为如此,可以使用为另一种语言(如 Java 的 Ant 或 Maven,或 Python 的 Fabric)设计的工具来自动化这些任务,如果你的服务器端团队使用这种语言,那么让每个人都使用相同的工具可能会有所帮助。

然而,如果你没有服务器端团队(或者如果该团队使用Node.js),你可能希望有一个特定的 JavaScript 构建工具,这就是 Grunt 的用武之地。以下是一个可以用来运行 RequireJS 优化器的示例 Grunt 配置文件:

module.exports = function(grunt) {
    grunt.initConfig({
        requirejs: {
            app: {
                options: {
                    findNestedDependencies: true,
                    mainConfigFile: 'public/js/config.js',
                    baseUrl : 'public/js',
                    name : 'app',
                    out : 'build.js',
                    optimize : 'none'
                }
            }
        }
    });
    grunt.loadNpmTasks('grunt-contrib-requirejs');
    grunt.registerTask('default', ["requirejs"]);
};

虽然对上述代码的完整解释超出了本书的范围,但可以说,通过使用此配置文件,你可以在命令行中运行单个命令来调用 RequireJS 优化器。更重要的是,你可以将 RequireJS 优化器作为整个部署过程的一步,然后通过单个命令调用整个过程。你还可以使用 Grunt 为不同的环境设置不同的流程,例如为设置开发环境设置一个流程,为设置生产服务器设置另一个流程。

使用 CoffeeScript 的新语法

你可能会认为作为 Backbone(以及UnderscoreDocco)的创建者,Jeremy Ashkenas 手头的工作已经足够多了……但你会错的。在这三个库之间创建的过程中,Jeremy 还找到了时间创建一种全新的编程语言,称为CoffeeScript(coffeescript.org/)。

CoffeeScript 对许多 Web 开发者来说很有趣,因为它具有以下两个关键特性:

  1. CoffeeScript 编译成 JavaScript,这意味着你可以用它进行开发,然后将它编译成用户浏览器真正能理解的语言。

  2. CoffeeScript 提供的语法和功能集与 Python 或 Ruby 等语言比与“纯”JavaScript 有更多共同之处。

这最好通过例子来解释。以下是如何在 CoffeeScript 中创建一个View类,用于h1元素:

class HeaderView extends Backbone.View
    tagName: 'h1'
    initialize: ->
        @render
    render: ->
        $(@el).text 'Hello World!'
header = new  HeaderView

这里是我们刚刚展示的一些关键差异:

  • CoffeeScript 有自己声明类的语法。

  • CoffeeScript 使用缩进来标记函数的开始/结束位置,而不是使用花括号("{}")。

  • CoffeeScript 使用->来声明函数,而不是function() {}this可以引用为@,函数调用中的括号是可选的。

这些(以及许多其他)特性使 CoffeeScript 在 JavaScript 之上有了显著的改进。然而,使用它也有一个缺点。因为它编译成 JavaScript,所以调试可能很棘手,因为当浏览器报告错误时,它将使用 JavaScript 行号而不是 CoffeeScript 行号。此外,当使用浏览器的调试器时,你将遍历 JavaScript 代码,而不是 CoffeeScript 代码。虽然一种名为“源映射”的新技术可以帮助减少这些问题,但它并不能完全消除它们。

如果你愿意忍受上述不便,CoffeeScript 可以为你提供一套语法和功能集,而 JavaScript 本身在许多年内甚至可能永远都不会有。此外,由于它是由 Jeremy Ashkenas 创建的,你可以放心,Backbone 将始终与 CoffeeScript 兼容。事实上,Jeremy 为 Backbone 添加了至少一个功能,即隐藏的__super__属性,专门用于支持 CoffeeScript。

使用 BackSupport 让生活更轻松

几年前我开始使用 Backbone 时,我受到启发编写了 BackSupport (github.com/machineghost/BackSupport),以帮助消除我发现自己在反复编写的许多样板代码。例如,考虑这个基本的View类:

var BookView = ParentBookView.extend({
    className: ParentBookView.prototype.className + ' book-view',
    initialize: function(options) {
        this.template = options.template ;
        if (!this.template ) {
            throw new Error('The template option is required!');
        }
        _.bindAll(this, ''render');
    },
    render: function() {
        this.$el.html(this.template(this.model.toJSON()));
   }
});

现在,如果我们能将所有这些通用代码缩减到只针对我们类的部分,那会怎样?这正是 BackSupport 发挥作用的地方。让我们看看使用 BackSupport 重新创建的相同View类:

BookView = ParentBookView.extend2({
    boundMethods: ['render'],
    className: "book-view",
    propertyOptions: ['template'],
    requiredOptions: ['template'],
});

如你所见,BackSupport 简化了我们的许多逻辑,以至于我们甚至在第二版中都不需要initialize方法!

这里展示了所有 BackSupport 的功能:

  • extend2:Backbone 的 extend 的这种替代形式足够智能,可以合并而不是替换诸如 classNameeventsdefaults 这样的属性。这允许您更轻松地创建使用这些属性的子类,而不会丢失父类中的值。

  • boundMethods:BackSupport 将自动对包含在这个属性中的每个方法调用 _.bindAll,因此我们不需要在 initialize 方法中手动执行。

  • propertyOptions:BackSupport 将自动将包含在这个属性中的任何选项转换为这个类实例的新属性,从而让我们免于在 initialize 中进行 this.foo = options.foo 的繁琐操作。

  • requiredOptions:如果类实例化时没有提供这些选项,BackSupport 将抛出错误,这为我们提供了一个简单的方法来确保它们被提供,而无需添加额外的 initialize 逻辑。

  • render:BackSupport 提供了一套方法,使使用模板变得更加容易。这些方法对您使用的模板系统完全无关,要选择特定的模板系统,您只需要覆盖相关的 BackSupport 方法。

虽然这个简单的例子展示了 BackSupport 提供的功能,但它还拥有许多其他出色的便利功能,您可以在其 GitHub 页面上了解更多信息。

摘要

在本章中,我们了解了 Backbone 社区提供的各种工具。特别是,我们探讨了 RequireJS 和 Bower 用于依赖管理,Backbone Paginator 和 BackGrid 用于渲染分页表格,以及 Handlebars 用于模板。我们还探讨了使用 Grunt 进行构建管理,CoffeeScript 用于替代语法,以及作者自己的工具 BackSupport,用于解决 Backbone 中许多小不便的通用解决方案。

在下一章中,我们将通过回顾本书中涵盖的所有内容来总结全文。我们还将简要探讨如何将所学知识应用于实际案例。最后,我们将探讨更多关于 Backbone 的学习资源。

第十二章。总结和进一步阅读

在本章中,我们首先回顾了前几章中我们所涵盖的所有内容。然后我们看看 Backbone 是如何被用来驱动一个真实世界的医疗应用的,最后,我们展望一下你如何继续你的 Backbone 学习。特别是我们将涵盖以下内容:

  • 总结各种 Backbone 组件的角色

  • 了解 Backbone 今天是如何被用来驱动 Syapse 的

  • 考虑一下我们所学的所有内容是如何应用到 Syapse 用例中的

  • 看看进一步学习 Backbone 的机会

将所有这些放在一起

在这本书的前半部分,我们研究了四个 Backbone 类(CollectionModelRouterView),以及它们如何组合在一起构建一个 Web 应用。为了回顾,一个由 Backbone 驱动的网站从Router类开始,该类用于将 URL 映射到应用的虚拟页面。Router构成了 MVC Controller层的一半,而View类构成了另一半。View类还负责 Web 应用的 MVC View层,因为View类不仅渲染构成网站的页面,还监听并响应用户生成的事件。

当然,数据或 MVC Model层对于几乎任何应用都是必不可少的,这个层由ModelCollection类处理。代表单个数据对象的Model类,既用于管理客户端的数据,也用于向服务器发送和接收数据。Collection类持有Model类的集合,但除此之外,它们在管理数据和将其传输到/从服务器方面被用于类似的方式。ModelCollection类主要被View类使用,这些类将它们的数据渲染到 DOM 元素中。

所有这些类不仅设计为直接使用,而且还可以扩展到具有特定于您应用程序的逻辑的新子类。第三方 Backbone 库,如 BackGrid 和 BackSupport,使用相同的扩展机制来提供进一步扩展您应用程序功能的组件。然而,Backbone 特定的库并不是您可以在 Backbone 应用程序中使用的唯一第三方库。通过将非 Backbone 组件包装在您自己的自定义 Backbone 类中,您可以干净地集成模板系统或 jQuery UI 小部件等工具到您的应用程序中。即使不能包装在 Backbone 类中的库,如 Underscore、RequireJS 或 Mocha,也可以独立使用以添加功能,而不会失去 Backbone 本身的任何好处。

简而言之,这总结了到目前为止我们所学的所有内容,但由于我们最初是逐个介绍这些信息的,所以我们一次只关注一个组件。随着这本书的完成,回顾一下所有这些部分如何共同用于实现一个真实世界的用例将是有益的。这个用例是 Syapse。

Syapse 是什么?

如第一章中所述,使用 Backbone 构建单页网站,Syapse(www.syapse.com)是一个由 Backbone 驱动的 Web 应用程序,用于请求和提供精准医疗结果。Syapse 的客户是使用基因测序来分析患有严重疾病(如癌症)的患者的实验室和医院。一旦测序,这些基因图谱可以与大量研究相结合,根据患者的 DNA 确定针对特定患者的最佳治疗和剂量。

让我们来看看 Syapse 是如何构建的。

俯瞰全局

Syapse 的客户端代码使用 Require.js 进行组织(参见第十一章中的使用 RequireJS 进行依赖管理(不)重新发明轮子:利用第三方库)。每个模块要么是一个类,要么是一个单例实例(用于实用库),要么是一个函数(用于路由)。Syapse 有两个不同的网站,一个用于实验室,另一个用于诊所,因此我们使用 RequireJS 为每个网站编译单独的 JavaScript 文件。

每个这些文件都有自己的 Backbone Router,允许每个网站拥有完全不同的 URL 和页面集(参见第六章中的多路由)。

使用路由器创建客户端页面)。这些Router类构成了 RequireJS 依赖树的顶层,引入(或依赖于)网站的所有路由。这些路由模块随后引入网站的用户界面Views类,这些类反过来又引入网站的ModelCollection类。

应用程序中的每个其他类都继承自四个“基础”类。这些基础类基于 BackSupport 类(参见第十一章中的使用 BackSupport 简化生活(不)重新发明轮子:利用第三方库),并用于定义 Syapse 特有的新功能。例如,由于 Syapse 使用 Handlebars 模板库,基础View类包含了渲染 Handlebars 模板的逻辑(参见第十一章中的使用 Handlebars 进行模板化(不)重新发明轮子:利用第三方库)。

Syapse 使用 Page View 模式(参见第五章中的 页面视图使用视图添加和修改元素),为实验室界面使用一个基 Page View 类,为诊所界面使用另一个。这些渲染了网站在页面之间共享的所有部分,例如导航菜单,这两个 Page View 模式共享一个共同的基 View 类,允许它们重用两个网站共有的通用页面渲染逻辑。

视图层

Syapse 实验室网站中的页面分为三个部分,每个部分都有自己的 View 类:一个左侧导航部分、一个页眉区域和一个主要内容区域。在页面实例化时,这些部分可以可选地被覆盖,这样每个路由只需修改其独特的部分。例如,大多数路由不会更改左侧导航栏,因此当这些路由实例化页面 View 类时,它们只是依赖于这个 View 类的默认导航栏。

每个 View 的子类都处理页面一部分的渲染。因为 Syapse 依赖于 The combined approach(参见第五章,使用视图添加和修改元素) 来进行渲染,这些 View 类使用 Handlebars 模板和其他子 View 类的组合来生成其内容。对于特别复杂的页面,这可能会导致不仅子 View 类,还有孙子、曾孙,有时甚至曾曾孙 View 类。只要可能,Syapse 都会使用多个较小的 View 类而不是一个大的类,以使每个 View 类的逻辑尽可能简单。

为了保持一致性,这些 View 类中的所有渲染方法都返回这个结果。此外,为了保持一致性,每个 View 类都设计为可重新渲染的,这样它就可以轻松地监听和响应其 Model 类中的更改事件(参见第五章中的 其他渲染考虑因素使用视图添加和修改元素)。

数据层

在服务器端,Syapse 使用 Python 和 Django REST 框架库来驱动其所有 API,虽然这给了服务器团队对 API 的很大控制权,但他们仍然受到库的限制,不能总是创建返回理想 Backbone JSON 的 API。因此,Syapse 的许多 ModelCollection 类都使用 parsetoJSON 方法重写来从 API 中提取或发送正确的 JSON(参见第三章中的 从服务器获取数据将数据保存到服务器使用模型访问服务器数据)。

有时,Syapse 的 API 不仅返回特定 ModelCollection 类的数据,还返回补充信息。例如,返回患者 Model 类的 API 可能包括该患者医生的 ID,但由于最终用户希望看到名称而不是 ID,API 响应还包括一个 ID 到名称的单独映射。为了跟踪这些补充信息,Syapse 依赖于一个全局的 pub/sub 系统(参见第七章 将方钉嵌入圆孔 - 高级 Backbone 技巧 中的 发布/订阅 Chapter 7,将方钉嵌入圆孔:高级 Backbone 技巧)。每当 Model 类在获取过程中解析这样的补充信息时,它就会触发一个带有此信息作为额外参数的“补充信息”特殊事件。然后,一个或多个网站范围的 Collection 缓存会监听此事件,聚合补充信息并将其提供给 View 类。

支持层

Syapse 的所有代码都使用 Selenium 进行验收测试,以及 Mocha/Sinon 进行单元测试(参见第十章 如何测试 Backbone 应用程序 中的 Testing? In JavaScript?Selenium Chapter 10,如何排除错误:如何测试 Backbone 应用程序)。通过使用表达式的测试名称和 BDD 测试输出风格(参见第十章 TDD 与 BDD:有什么区别? Chapter 10,如何排除错误:如何测试 Backbone 应用程序),我们确保 Syapse 的测试输出非常具体,如下所示:

支持层

对于文档,Syapse 主要依赖于内联文档,使用 JSDoc 注释而不实际渲染文档页面(参见第九章 我在想什么?记录 Backbone 代码 中的 健壮的文档方法 Chapter 9,我在想什么?记录 Backbone 代码)。然而,Syapse 还有一个面向客户的 JavaScript API,其文档比生成的 JSDoc API 页面和基于 Docco 的教程都要详细得多。

构建自己的 Syapse

您的应用程序可能像 Syapse 一样,是一个旨在帮助解决关键问题(如对抗癌症)的严肃工具。或者,您的应用程序可能更有趣,比如一个游戏或个人项目。在两种情况下,Backbone 都提供了您构建网站所需的一切,并且在整个生命周期中继续添加和维护网站。

然而,没有一本书能解释清楚像 Backbone 这样灵活且强大的库的每一个可能的细微差别。在核心上,Backbone 努力只做好几件重要的事情,并将其他所有事情留给您,即程序员。这不仅意味着巨大的力量和灵活性,还意味着您必须自己做出大量决定,以确定您想要如何使用 Backbone。为了做出正确的决定,真正利用 Backbone 提供的一切,您无疑会想要继续尽可能多地学习关于 Backbone 以及一般网络开发的知识。

进一步阅读

了解 Backbone 的地方有很多,但或许,最好的地方就是 Backbone 的自身源代码。作为 Backbone 和 Docco 的作者,杰里米·阿什肯纳斯(Jeremy Ashkenas)使用 Docco 文档工具提供 Backbone 源代码的注释版本,您可以在backbonejs.org/docs/backbone.html找到它。

然而,您不需要注释版本来阅读 Backbone 的代码。实际上,每当 Backbone 的某个部分看起来令人困惑或难以理解时,学习更多的一个最好的方法是将调试语句抛入 Backbone 源代码本身,然后使用浏览器的调试工具运行您的应用程序。通过在调试器中遍历代码,您可以看到逻辑在 Backbone 的类和方法中逐步推进,而且由于源代码写得非常好且易于阅读,这项任务将比几乎任何其他主要的 JavaScript 库都要简单得多。

Backbone 还提供了一个位于 github.com/jashkenas/backbone/wiki 的维基百科。除了基本 Backbone 信息外,这个维基还包含了插件和开发工具的集合、使用 Backbone 的公司列表,以及大量的教程和信息性博客文章。这个后者列表(可在 github.com/jashkenas/backbone/wiki/Tutorials,-blog-posts-and-example-sites 找到)尤其有价值,有超过五十个不同的高质量网站,您可以在这些网站上了解更多关于 Backbone 的信息。

Backbone 的另一个信息来源是 Packt Publishing,它提供了许多专注于 Backbone 的书籍。虽然它们确实覆盖了与本书相同的一些基础知识,但你可能会发现 Andrew Bugess 编写的 BackboneJS Blueprints 中的示例应用程序或 Vadim Mirgood 编写的 Backbone.js Cookbook 中的食谱很有价值。如果你更喜欢更深入地了解你可以使用的有用 Backbone 模式,那么 Swarnendu De 的 Backbone.js Patterns and Best Practices 可能对你很有用。此外,如果你对 第十章 消除虫子:如何测试 Backbone 应用程序 感兴趣,那么 Ryan Roemer 的 Backbone.js Testing 是你学习 Backbone 测试的完美文本。

然而,尽管书籍很棒,但它们永远无法完全跟上新兴 Backbone 技术的最新发展,这就是某些网站可以非常有价值的地方。一个令人难以置信的资源是 Stack Overflow (www.stackoverflow.com),任何倾向的程序员都可以在这里找到他们技术问题的答案,包括关于 Backbone 的问题。然而,当你没有具体问题时,Stack Overflow 同样很有价值。因为该网站在每个问题都有 标签投票,你可以搜索带有 backbone.js 标签的问题,然后按投票排序;顶部的问题很可能是有教育意义的。在撰写本文时,Stack Overflow 特点了超过 12,000 个不同的 Backbone 问题及其答案(总共有超过 17,000 个问题)。

另一个类似的问题和答案网站,它不是以编程为重点的是 Quora (www.quora.com/)。虽然 Stack Overflow 限制自己只回答客观问题,但 Quora 没有这样的限制,因此非常适合回答更主观的问题,例如“Backbone.js 的优势是什么”(www.quora.com/What-are-the-advantages-of-Backbone-js)。

另一个了解 Backbone 的优秀地方是 Backbone Google Group (groups.google.com/forum/#!forum/backbonejs),这里有一个活跃的社区。另一方面,如果你更喜欢关注一系列新闻文章,Reddit 上的 Backbone 子版块 (www.reddit.com/r/backbonejs/) 是一个很好的资源,可以帮助你保持最新信息。最后,在更广泛的意义上,Hacker News (news.ycombinator.com/)、Lobsters (lobste.rs) 和 Dzone (www.dzone.com/links/index.html) 都提供了各种编程相关新闻和文章的连续更新,包括许多关于 JavaScript(特别是 Backbone)的文章。

摘要

在本章中,我们回顾了前十一章的内容,并总结了如何使用 Backbone 构建健壮的 Web 应用的模式。我们还特别探讨了 Backbone 如何被用于构建抗癌应用 Syapse。最后,我们考察了其他你可以学习更多关于 Backbone 的地方,包括 Packt 出版的其他优秀书籍。我们希望你喜欢这本书,并祝愿你在使用 Backbone 创建强大的 Web 应用时一切顺利。

posted @ 2025-09-24 13:53  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报