Backbone-js-模式和最佳实践-全-
Backbone.js 模式和最佳实践(全)
原文:
zh.annas-archive.org/md5/7eb070d11ff8606a6b72cb434beb5876译者:飞龙
前言
尽管 Backbone.js 为 JavaScript 应用程序提供了结构,但开发者需要自己处理大部分的设计模式和最佳实践。多年来,我和我的 JavaScript 开发团队共同开发了多个 Backbone.js 应用程序,从简单到极其复杂。我们遇到了与布局管理、项目架构、模块化开发等相关的问题。在我开始写这本书之前,我花了很多时间试图找出与 Backbone.js 应用程序开发相关的所有常见问题的解决方案。在这本书中,我详细记录了我的所有发现。
无论你是中级还是高级 Backbone.js 开发者,本书都将指导你通过最佳实践和模式来处理每个 Backbone 组件的不同问题。无论是使用自己的解决方案还是现有的 Backbone 插件,你都将清楚地了解解决任何问题的最佳方式。
与在所有章节中开发单个应用程序不同,本书在每个主题上提供了一个简单而完整的示例。这是因为在一个应用程序中实现本书中给出的所有技巧和模式会相当困难。此外,我们更倾向于提供即时和紧凑的解决方案来解决问题,而不是在一个大型应用程序中包含所有问题和解决方案。本书在短时间内试图涵盖你可能需要用于 Backbone.js 应用程序开发的全部重要点。
本书涵盖的内容
第一章,使用插件开发减少样板代码,从为什么重用代码很重要以及如何通过创建自定义 Backbone.js 小部件和混入来实现这一点的基本原理开始。
第二章,与视图一起工作,讨论了与视图渲染和布局管理相关的不同点。从视图的局部更新开始,讨论嵌套视图或子视图在 JavaScript 模板管理不同过程中的功能以及最佳实践,这一章涵盖了开发者在与视图一起工作时可能遇到的大部分问题。我们通过介绍 Marionette 自定义视图和用于复杂应用程序布局管理的 Layout Manager 插件来总结。
第三章,与模型一起工作,讨论了与 Backbone 模型一起工作时不同的模式,包括数据验证、将模型序列化以获取数据以及将数据保存到服务器。我们还使用 Backbone 的关系插件分析了用于一对一和多对多关系的关系数据模型。
第四章, 使用集合,涵盖了开发者在使用 Backbone 集合时遇到的一些常见问题。我们解释了如何应用基本和多重排序,如何对一个集合应用过滤,以及当从服务器传递混合数据集时如何管理集合。
第五章, 路由最佳实践和子路由,涵盖了你在使用路由器时应遵循的一些最佳实践。我们还讨论了在复杂和大型应用中使用多个路由器或子路由器的优势。
第六章, 使用事件、同步和存储,首先描述了自定义事件对增强应用程序模块化和可重用性的重要性。我们还讨论了使用应用程序级事件管理器作为集中式 PubSub 系统,以及使用Backbone.sync()方法创建不同的数据持久化策略。
第七章, 组织 Backbone 应用 – 结构、优化和部署,是开发者发现非常有用的最重要章节之一,特别是当他们开发复杂的 Backbone 应用时。它讨论了应用程序目录结构,使用 RequireJS 组织和管理工作文件,以及每个 JavaScript 开发者应遵循的不同架构模式以开发大规模应用架构。
第八章, 单元测试、存根、间谍和模拟你的应用,讨论了单元测试 JavaScript 应用的好处,并介绍了 QUnit 和 SinonJS 测试框架。
附录 A, 书籍、教程和参考资料,列出了一些有用的 Backbone.js 资源,你可能觉得它们很有帮助。
附录 B, 服务器端预编译模板,通过示例描述了在服务器端预编译 JavaScript 模板的好处。
附录 C, 使用 AMD 和 Require.js 组织模板,讨论了使用 RequireJS、text!和 tpl!插件存储和组织 JavaScript 模板的过程。
你需要这本书的内容
本书中的大部分代码都可以在简单的文本编辑器(Notepad++或 Sublime Text)中打开。要运行代码,你可以使用任何网络浏览器。对于某些代码,你可能需要设置本地服务器(Apache 或 IIS)。对于 Node.js 相关的功能,你需要设置一个 Node.js 服务器。
这本书面向的对象
本书是为任何具有 Backbone.js 基础知识且正在寻找解决常见 Backbone.js 问题方案的开发者而写的,他们希望通过移除样板代码并开发自定义插件和扩展来加强代码的可重用性,并希望使用最有效的模式来开发大规模 Web 应用程序架构。
本书不是 Backbone.js 或 JavaScript 设计模式的通用介绍。有许多书籍、教程和屏幕录像带提供了详细的通用介绍。虽然本书将在每一章讨论 Backbone.js 组件的基础,但主要优先事项将是为您提供开发健壮、高质量和灵活的代码库的概念。
术语约定
在本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下所示:“我们可以通过使用include指令包含其他上下文。”
代码块将以如下方式设置:
var MainView = Backbone.View.extend({
el : '#main',
render : function(){
this.$el.html(new BaseView().render().el);
}
});
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
var BaseView = Backbone.View.extend({
template : '<h1><%= name %></h1>',
render : function(){
var html = _.template(this.template, {
name : 'Swarnendu De'
});
this.$el.html(html);
return this;
}
});
新术语和重要词汇将以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“点击下一个按钮将您带到下一屏幕”。
注意
警告或重要注意事项将以如下框显示。
小贴士
小技巧和窍门将以如下方式显示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中受益的标题非常重要。
要发送给我们一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
既然您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从www.packtpub.com/support选择您的标题来查看。
海盗行为
在互联网上对版权材料的盗版是一个横跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和为我们提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。
第一章. 通过插件开发减少样板代码
"当你在涉及大量 JavaScript 的 Web 应用上工作时,你最先学到的一件事就是停止将你的数据绑定到 DOM 上。创建 JavaScript 应用变得过于简单,最终结果可能是一堆混乱的 jQuery 选择器和回调函数,它们疯狂地试图在 HTML UI、你的 JavaScript 逻辑和服务器上的数据库之间保持数据同步。对于富客户端应用,一种更结构化的方法通常更有帮助。"
来自backbonejs.org的上一段摘录精确地指定了 Backbone.js 解决的问题。Backbone.js 提供了一种简化 JavaScript 应用程序结构的方法,这在几年前甚至是一个噩梦。今天,我们已经从紧密耦合的基于 jQuery 的应用程序发展到重型前端应用程序,现在应用程序逻辑的大部分现在依赖于 UI 部分。这意味着组织应用程序结构现在是应用程序开发中最重要的一面,应该注意应用程序组件的可重用性、模块化和可测试性。
作为一个非常轻量级的库,Backbone.js,连同实用库 Underscore.js,提供了一套工具,帮助您组织代码,并使开发单页 Web 应用变得更加容易。Backbone 提供了一种简约的解决方案来分离应用程序的关注点;功能包括 RESTful 操作、持久策略、模型、带逻辑的视图、事件驱动的组件通信、模板和路由功能。其简洁的本质、优秀的文档以及庞大的开发者社区使得学习如何使用这个库变得容易。
然而,为了开发一个健壮的系统,我们不仅仅依赖于框架的基本功能组件;我们必须使用许多其他库、插件和可重用的附加组件来支持核心系统。虽然 Backbone.js 及其核心组件提供了一种在基础级别结构化应用程序的方法,但直到我们开发自己的或使用其他开源扩展、插件和有用的模式,这还远远不够。为了创建坚实的软件架构,我们需要充分利用现有组件并遵循适当的设计模式。这正是我们在这本书中想要提供的。
这不是一本通用介绍书,我们期望我们的读者对 Backbone.js 框架有一个基本的了解。如果你是初学者,正在寻找开始 Backbone.js 的好资源,我们建议你参考这本书的附录 A,即书籍、教程和参考资料,其中我们列出了一些有用的资源,以帮助你掌握 Backbone.js。
我们将首先了解如何通过开发自定义扩展、插件和混入来重用我们的代码并减少样板代码。在后面的章节中,我们将开始讨论每个 Backbone.js 组件的常见问题、技巧、模式、最佳实践和开源插件。我们还将了解如何使用 Backbone.js 来构建和设计复杂的 Web 应用程序,并理解基于 JavaScript 应用程序的基本单元测试。此外,我们不是开发跨越所有章节的单个应用程序,而是试图在本书的每个主题上提供简单而完整的示例。在本章中,我们将通过示例学习一些重要主题。这些主题和概念将在后续章节中多次使用。它们如下:
-
Backbone.js 的基本组件: 这包括对 Backbone 组件定义的简要讨论
-
Underscore.js 的使用: 这包括对 Underscore.js 的简要讨论以及使用此库进行基于 JavaScript 的项目的好处
-
使用扩展重用代码: 这包括通过将常用代码块移动到父级类中来重用 Backbone 代码
-
Backbone 混入: 这包括对混入的解释以及如何以及在哪里与 Backbone 一起使用混入
Backbone.js 的基本组件
在进入插件开发部分之前,我们将探讨 Backbone.js 和 Underscore.js 的一些基本概念。Backbone.js 是一个客户端 MV*框架,提供了一套构建 JavaScript 应用程序所需工具和构建块。Backbone.js 提供的重要工具有以下几项:
-
Backbone.Model: 模型是应用程序的实体,存储数据并包含一些与数据相关的逻辑,如验证、转换和数据交互。 -
Backbone.View: 视图将组织您的文档对象模型(DOM)界面到逻辑块中的概念呈现出来,并在其中表示模型和集合数据。视图是组织所有 JavaScript 事件处理程序和通过可选使用 JavaScript 模板在应用程序中添加动态 HTML 内容的优秀工具。由于 Backbone 遵循 MV*模式,Backbone 视图主要作为演示者工作,并负责应用程序功能的大部分。 -
Backbone.Collection: 集合是一组模型。集合包含许多功能以及 Underscore 实用方法,以帮助您处理多个数据模型。 -
Backbone.Router: 路由器提供客户端页面路由的方法,并在浏览器 URL 发生变化时相应地执行。路由器根据 URL 变化维护应用程序状态。 -
Backbone.Events: 事件是 Backbone 中的一个重要概念,因为它们提供了一种使用发布/订阅模式并解耦应用程序组件的机制。
除了这些,还有一些其他工具,例如Backbone.History,它根据路由器管理浏览器历史记录和后退/前进按钮。还有Backbone.Sync,这是一个提供对 Backbone 模型和集合通过网络访问的优雅抽象的单个方法。
使用 Underscore.js
Underscore.js (underscorejs.org/)是一个强大的实用库,为你的 JavaScript 代码提供了大量的函数式编程支持。一般来说,JavaScript 本身提供的实用方法非常有限,大多数时候我们需要自己开发函数或者依赖其他库来获得这些方法。Underscore 提供了一系列高效的实用方法,使其成为 JavaScript 项目的优秀工具。它提供的函数可以分为以下几类:
-
集合(数组或对象)
-
数组
-
函数
-
对象
-
工具
-
连接操作
这些包括迭代、排序、过滤、转换、模板、比较、作用域绑定等功能。使用这个小型库的主要好处如下:
-
它帮助你使 JavaScript 代码更加直观和简洁。
-
除了方便的方法外,Underscore 还实现了更现代浏览器中可用的 JavaScript 函数的跨浏览器版本。Underscore 会检测浏览器是否支持该方法,如果存在,则使用原生实现。这在很大程度上提高了函数的性能。
-
库的压缩和 gzip 版本仅重 4.9 KB,这几乎没有任何理由不利用这个库。
-
该库完全无 DOM——因此你也可以在服务器端 JavaScript 代码中使用它。
-
与 Backbone.js 类似的优秀文档,包含示例,可在
underscorejs.org/找到。
Backbone.js 对 Underscore.js 有硬依赖,如果你使用 Backbone.js 开发应用程序,你必然会用到它。然而,即使你不使用 Backbone,我们也鼓励你在 JavaScript 项目中使用 Underscore.js。它不会增加任何开销,易于集成,即使你并不了解这个库所使用的所有底层工程原理,它也能让你的代码更加健壮。
另有一个名为Lo-dash (lodash.com)的库,它提供了一个 Underscore 库,用于替换 Underscore.js 库。据说它的性能略优于 Underscore.js。你可以尝试使用它们中的任何一个来实现相同的结果。
使用扩展重用代码
与其他库相比,Backbone 是一个非常小的库。任何复杂的应用程序都可以使用 Backbone 来构建和开发,但该框架本身并不包含预构建的小部件或可重用的 UI 组件。在本节中,我们将讨论一些 Backbone 和 JavaScript 技巧,这些技巧将帮助您构建一个可重用的界面库。
对于简单和小的应用程序,代码的可重用性并不总是那么必要。但随着您继续创建具有多个视图、模型和集合的应用程序,您会发现代码的某个部分被重复多次。在这种情况下创建可重用的扩展和插件可以通过增强模块化和减少代码大小来提高应用程序的性能。让我们创建一个简单的 Backbone 视图来了解我们如何创建扩展,如下面的代码片段所示:
var User = Backbone.Model.extend({
defaults: {
name: 'John Doe'
}
});
var UserItemView = Backbone.View.extend({
template: '<span><%= name %></span>',
render: function () {
var tpl = _.template(this.template),
html = tpl(this.model.toJSON());
this.$el.html(html);
return this;
}
});
// Create a view instance passing a new model instance
var userItem = new UserItemView({
model: new User
});
$(document.body).append(userItem.render().el);
命名为 UserItemView 的视图是一个简单的 Backbone 视图,我们想在模板中显示我们的模型数据,并将此视图元素附加到 DOM 中。这是 Backbone 的基本功能,其主要要求是以视图的形式显示模型的数据。如果我们有另一个具有相同模型的类似视图,并且它具有相同的功能,那么 render() 函数也将是相同的。那么,如果我们把通用代码移动到基类并将该类扩展以继承功能,这不是很有益吗?答案是肯定的。让我们看看以下示例部分中我们如何做到这一点。
创建基类
我们创建了一个 BaseView 类,其中添加了如 render() 方法等常见功能。然后所有其他视图类都可以从这个基类扩展,并最终继承渲染功能。以下是一个具有最小渲染功能的 BaseView 类:
// Parent view which has the render function
var BaseView = Backbone.View.extend({
render: function () {
var tpl = _.template(this.template),
data = (this.model) ? this.model.toJSON() : {},
html = tpl(data);
this.$el.html(html);
return this;
}
});
现在,UserItemView 将会看起来更好。我们将扩展 BaseView 类,并只提供以下模板:
// A simpler view class
var UserItemView = BaseView.extend({
template: '<span><%= name %></span>'
});
小贴士
下载示例代码
您可以从您在 www.packtpub.com 购买的所有 Packt 书籍的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给您。
如果您想在视图的 render() 方法中添加一些额外的功能,例如调用另一个函数,您可以覆盖基类的 render 方法。请查看以下示例:
var UserItemView = BaseView.extend({
tagName: 'div',
template: '<span><%= name %></span>',
render: function () {
// Call the parent view's render function
BaseView.prototype.render.apply(this, arguments);
// Add your code here
this.anotherFn();
return this;
},
anotherFn: function () {}
});
根据您的需求,您可以将许多功能移动到您的基类中。例如,在一个非平凡的应用程序中,我们经常需要用另一个视图替换一个视图,通过从 DOM 中移除旧视图来销毁它,并清理其他依赖项。因此,我们可以在 BaseView 中添加一个 close() 方法(如下面的代码所示),它可以处理每个视图移除机制。
var BaseView = Backbone.View.extend({
render: function () {
var tpl = _.template(this.template),
data = (this.model) ? this.model.toJSON() : {},
html = tpl(data);
this.$el.html(html);
return this;
},
close: function () {
// Extra stuff goes here
// Remove the view
this.remove();
}
});
// This is not production-ready code, but it clearly gives you the concept of using custom widgets to reduce boilerplate in your code. It will not always be necessary to extend a Backbone class to create a plugin.
不扩展基类开发插件
有时候我们发现,创建一个构造函数并向其原型添加方法,可能比扩展 Backbone 基础类更好。例如,在以下代码的Pagination插件中,我们不会通过扩展Backbone.Collection来创建PaginationCollection类,我们更倾向于选择一个更简单的类——一个接受两个参数的构造函数:一个集合和每页要显示的项目数量。
// Pagination constructor function
var Pagination = function (collection, noOfItemsInPage) {
if (!collection) {
throw "No collection is passed";
}
this.currentPage = 1;
this.noOfItemsInPage = noOfItemsInPage || 10;
this.collection = collection;
}
// Use Underscore's extend method to add properties to your plugin
_.extend(Pagination.prototype, {
nextPage: function () {},
prevPage: function () {}
});
var User = Backbone.Model.extend({
defaults: {
name: 'John Doe'
}
});
var Users = Backbone.Collection.extend({
model: User
});
var paging1 = new Pagination(10, new Users());
var paging2 = new Pagination(20, new Users());
我们没有添加实际的功能,只是展示了Pagination类可能的样子。当你已经有一个集合并且想要实现分页而不扩展父集合类时,你可以观察到这种好处。我们在构造函数中添加了成员变量,这样这个类的单个实例就可以有自己的变量集。另一方面,方法被添加到类的原型中,这样它们就可以被类的所有实例共享。
当你需要一个不是 Backbone 视图、模型或集合类型的自定义插件时,这种机制可能很有用。
理解 JavaScript 混入
在上一节中,我们看到了从父类原型继承属性提供了大量的可重用性。在某些情况下,我们可能希望在多个视图、模型或集合中重用类似的方法。这可以通过创建一个它们可以扩展的父类来实现;然而,这并不总是一个好的做法,因为它创建了一些不必要的层和无意义的子类型。
例如,假设你想要将UserItemView视图元素(它已经扩展了BaseView)设置为可拖动的。因此,你包含一个扩展BaseView类的DraggableView类,并且你的UserItemView扩展了DraggableView。现在突然出现了需求变更,你被要求将名为UserItemView的视图也设置为可排序视图。你会引入另一个新的类SortableView并将其放在某个链中吗?如果是的话,那么这种多层继承肯定会创建一个绝对难以管理和令人沮丧的逻辑。看看以下图表,它以更好的方式描述了这种情况:

什么是混入?
幸运的是,JavaScript 中有一个可行的替代方案,称为 混入。在一般计算机科学中,混入是一个提供与特定类型相关的一组函数的类。这些混入类不会被实例化,但它们的函数只是被复制到主类中,以在不进入继承链的情况下实现类似继承的行为。看看以下图表来理解这个概念:

我们有一个 ListItemView 类,它扩展了 BaseView 类,并代表列表的一个单独的项目。现在我们想让这些项目可拖动。我们如何实现这一点?在 ListItemView 类中添加一些处理拖动功能的方法怎么样?这种方法是可行的,但如果还有更多组件也需要可拖动,那么我们必须创建一个具有这些方法的可重用对象,并在所有所需的类中使用该对象。这就是混入概念——一组将被复制到需要此功能的类中的方法。
创建经典混入
最基本的混入定义将是一个具有一些属性(如下面的代码片段所示)的简单对象:
// A simple object with some methods
var DraggableMixin = {
startDrag: function () {
// It will have the context of the main class
console.log('Context = ', this);
},
onDrag: function () {}
}
// UserItemView already extends BaseView
var UserItemView = BaseView.extend({
tagName: 'div',
template: '<%= name %>'
});
我们将使用 Underscore 方法,_.extend(),将混入属性复制到主类的原型:
// We just copy the Mixin's properties into the View
_.extend(UserItemView.prototype, DraggableMixin, {
otherFn: function () {}
});
var itemView = new UserItemView();
// Call the mixin's method
itemView.startDrag();
注意,与阻力相关的现在方法已从 DraggableMixin 复制到其原型。同样,我们可以使用相同的 _.extend() 方法将 SortableMixin 的方法复制到实现可排序行为,而不需要创建任何多层继承。
有时你可能不想在类中复制混入的所有方法。在这种情况下,只需在类中创建一个属性,并将从混入中复制所需函数到该属性中:
UserItemView.prototype.startDrag = DraggableMixin.startDrag;
当你只需要混入的部分功能时,这很有帮助。
创建功能混入
定义混入还有其他一些方法。以下是一个功能模式的示例:
// Functional mixin
var DraggableMixin = function (config) {
this.startDrag = function () {};
this.onDrag = function () {};
return this;
}
// DraggableMixin method is called passing the config object
DraggableMixin.call(UserItemView.prototype, {
foo: 'bar'
});
// SortableMixin.call(UserItemView.prototype);
new UserItemView().startDrag();
这里的混入作为一个动词使用,这种功能方法在社区中得到了很好的接受。this 函数始终指向接收器,即 UserItemView。功能完全相同,但有一个主要区别——这次不再需要 _.extend() 方法,混入方法也不是复制,而是克隆。这并不是一个大问题——只是每次使用混入时都会重新定义函数。然而,这也可以通过在混入中缓存函数来最小化。让我们看看我们如何在下一节中实现这一点。
缓存混入函数
我们可以通过将混入包裹在闭包中来缓存初始函数定义:
// Functional mixin with cache
var DraggableMixin = (function () {
var startDrag = function () {};
var onDrag = function () {};
return function (config) {
this.startDrag = startDrag;
this.onDrag = onDrag;
return this;
};
})();
尽管混入被多次调用,闭包只执行一次来定义方法。然而,这又提出了另一个问题——在混入方法内部,我们如何使用我们传递的 config 对象?这个问题可以通过使用一个名为 curry 的有趣模式来解决。
使用柯里化组合函数和参数
如 Douglas Crockford 在其书籍 JavaScript: The Good Parts 中所述:
"柯里化允许我们通过组合一个函数和一个参数来生成一个新的函数。"
假设你有一个函数和一组参数。你希望以某种方式将这些参数与函数结合,以便当你调用该函数而不传递任何内容时,这些参数仍然可用于该函数。请看以下示例:
// Simple function
function foo(){
console.log(arguments);
}
// We want this bar object to be available in the foo() function
var bar = {
name: 'Saswata Guha'
};
// Calling foo() without passing anything. Using curry, the
// function will have the bar object in its scope
foo();
curry() 模式的定义相当简单,即该方法被添加到函数原型中,因此当它被调用在任何函数上时,它会将传递给自身的参数与主函数的参数合并,如下面的代码片段所示:
// Definition of curry
Function.prototype.curry = function () {
var slice = Array.prototype.slice,
args = slice.apply(arguments),
that = this;
return function () {
return that.apply(null, args.concat(slice.apply(arguments)));
};
};
现在我们来看如何将 curry 应用到我们的 DraggableMixin 函数中,以便 config 对象对所有方法都可用,如下面的代码片段所示:
// Functional mixin with cache
var DraggableMixin = (function () {
var startDrag = function (options) {
console.log('Options = ', options);
};
var onDrag = function () {};
return function (config) {
this.startDrag = startDrag.curry(config);
this.onDrag = onDrag;
return this;
};
})();
DraggableMixin.call(UserItemView.prototype, {
foo: 'bar'
});
因此,当我们对 startDrag() 方法调用 curry 时,我们传递了在应用混入时接收到的 config 对象,它作为参数对 startDrag 可用。你可以使用经典或函数式方法来定义混入,尽管我个人更喜欢后者。
混入是一个重要的概念,许多流行的 JavaScript 库如 Sencha 和 Dojo 都遵循这个概念。虽然这个概念相当简单,但在应用程序中找到一个合适的上下文来使用混入有点困难。然而,一旦你意识到它的用途,你很快就会发现在你的应用程序中强制重用性是有益的。
摘要
如果你曾经检查过 Backbone 的注释源代码(backbonejs.org/docs/backbone.html),你可能会发现库的体积非常小(v1.1.0 版本的生产文件只有 6.4 KB)。它的唯一目的是以最少的复杂性提高你代码的结构和可维护性。因此,一旦你开始使用 Backbone,你就会发现,在开发的每一步,你都需要编写自定义小部件和插件。在本章中,我们学习了 Backbone.js 的基础知识以及与 Backbone.js 一起使用 Underscore.js 的实用性。我们还看到了如何开发可重用组件和自定义插件可以减少代码中的样板。最后,我们了解了 JavaScript 插件的概念,并讨论了定义混入的不同方法。在接下来的章节中,我们将多次使用这些概念。
在下一章中,我们将讨论与 Backbone 视图相关的问题以及可能的解决方案。我们还将看到自定义视图插件或混入如何解决大多数问题。
第二章:与视图一起工作
Backbone 视图作为应用程序的表现层。简单来说,你可以将其定义为你 HTML 元素的抽象层。它不包含任何自己的 HTML 标记,但它包含使用 JavaScript 模板呈现模型数据的逻辑。
如果你阅读了 Backbone 视图的注释源代码,你会发现 Backbone.View 是一个方法非常少的类,包括一个空的 initialize() 方法和几乎空的 render() 方法,通常意味着任何自定义视图类都可以重写它们。在本章中,我们将调查一些常见问题以及开发者开发现实世界 Backbone.js 应用程序时面临的问题的解决方案。
Backbone 的一些基本问题与视图渲染或更新以及维护应用程序中的多个视图有关。我们将基于复杂性分析以下主题:
-
视图的基本用法:我们将学习 Backbone 视图的基本概念、其属性、函数和事件处理。
-
部分更新视图:我们将学习如何在不重新渲染整个视图的情况下仅更新视图的一部分。
-
嵌套视图:随着应用程序布局复杂性的增加,我们感到需要维护多个视图的层次结构。嵌套视图或子视图在很大程度上简化了事件处理和布局管理。我们将探讨以下主题:
-
当我们需要使用子视图时
-
如何初始化和渲染嵌套视图
-
在大量嵌套视图和复杂的视图 DOM 结构的情况下,如何避免 DOM 重新流
-
当你删除父视图时,如何清理资源(子视图、事件)
-
-
模板:模板是 Backbone 的一个重要部分,与视图结合使用以创建可重用的 HTML 标记副本。我们将讨论以下主题:
-
存储和加载模板文件的不同选项
-
模板预编译和将预编译的模板存储在客户端的优点
-
模板辅助函数的使用
-
-
Marionette 视图:我们可以使用 Marionette 库的自定义视图扩展来减少视图样板代码。
-
布局管理器:我们可以使用 Backbone 布局管理器插件简化复杂的布局架构。
视图的基本用法
Backbone 视图是提供逻辑结构的工具,用于你的应用程序的 HTML 标记。视图通过 JavaScript 模板表示 Backbone 模型或集合的数据。对于关联模型或集合的任何更改,你不需要重新绘制整个页面,只需更新相关的视图即可。一个基本的视图可以这样定义:
var UserView = Backbone.View.extend({
render: function () {
var html = "Backbone.js rocks!";
this.$el.html(html);
return this;
}
});
// create an instance
var userView = new UserView();
$('#container').append(userView.render().el);
在这里,我们创建了一个简单的 HTML 标记,将其放置在这个视图元素内部,并在 DOM 中显示视图。让我们通过查看所有步骤来进一步理解这个概念。
理解 el 属性
什么是this.$el属性?它是指向el的 jQuery 包装版本的属性。每个视图都拥有一个el属性,该属性要么持有视图最终将要渲染的 DOM 引用,要么是一个充当视图主要元素的 HTML 元素。在先前的例子中,我们没有指定el属性。因此,当我们实例化视图时,el元素就对我们可用,尽管它还没有在 DOM 中渲染。我们必须通过将视图元素附加到#container元素来显式地进行渲染。然而,如果我们已经在视图定义中或创建其实例时提到了指向#container元素的el属性,我们就不需要将其特别附加到文档中。如下代码片段所示:
var UserView = Backbone.View.extend({
...
el: '#container'
});
// render it to document body
new UserView.render();
这会产生与第一个例子相同的结果。然而,当你创建多个UserView类的实例时,这种方法会引发问题,因为它们都指向el中给出的相同元素,并且因为最后一个实例将覆盖前面的实例。然而,如果你每次创建视图实例时都传递el属性,这可以最小化问题,尽管这不是一个好的做法。此外,与视图销毁相关的问题仍然存在——如果你销毁这个视图,它也会移除#container元素——因此,如果你稍后创建另一个UserView实例并将相同的#container元素作为el属性传递,它将引发错误。一个好的做法是让视图创建自己的元素,并让父视图或布局管理器负责渲染视图。
有一些其他属性与 Backbone 视图的el属性相关;这些是tagName、id、className和attributes。tagName属性期望一个 HTML 标签名作为值,该值将用于创建视图的主要元素。例如,如果你将tagName指定为'ul',Backbone 创建的el元素将是一个空的UL元素。默认情况下,tagName的值为'div',也就是说,如果没有指定tagName,视图元素将是一个DIV元素。
id和className属性分别指定元素的 ID 和 CSS 类。attributes属性包含所有 HTML 属性作为一个对象:
var UserView = Backbone.View.extend({
tagName : 'p',
id : 'user_details',
className : 'user-details',
attributes : {
'data-name' : 'User Details'
}
});
生成的视图元素将看起来像这样:
<p data-name="User Details" id="user_details" class="user-details"></p>
监听视图事件
你可以使用视图的events属性将 DOM 事件监听器附加到 DOM 元素上。这些事件只能在视图元素及其子元素上注册:
var UserView = Backbone.View.extend({
html: '<button id="btn">Click me</button>',
events: {
'click #btn': 'onButtonClick'
},
render: function () {
this.$el.html(this.html);
return this;
},
onButtonClick: function () {
console.log('Button clicked');
}
});
我们在按钮上添加了一个click事件,并定义了当用户点击该按钮时要调用的处理程序。
Backbone 将所有视图事件委托出去,这样即使元素尚未在 DOM 中渲染,事件也会附加到它上。所以,如果你为视图 DOM 内尚未可用的元素添加一个事件散列,事件将在元素渲染时立即附加到它上。
使用模板显示模型数据
这是最重要的部分,因为视图的主要目的是显示与之关联的数据。在最简单的情况下,每个 Backbone 视图都关联到一个模型,并且它会随着模型的变化而更新自己:
var User = Backbone.Model.extend({});
// UserView definition
var UserView = Backbone.View.extend({
// We will use Underscore template
template: _.template('Hello <%= firstName %> <%=lastName %>!'),
render: function () {
if (!this.model) {
throw "Model is not set for this view";
}
var html = this.template(this.model.toJSON());
this.$el.html(html);
return this;
}
});
var userView = new UserView({
// Set a model for this view
model: new User({
firstName: 'Payel',
lastName: 'Pal'
})
});
$('#container').append(userView.render().el);
之前的代码很容易理解;在这里,我们在render()函数中将model实例传递给视图,并将模型值设置为模板。一旦渲染,视图将显示包含模型数据的 HTML 标记。
我们还需要确保模型任何属性的更改都能立即反映在视图中。我们可以通过监听模型的change事件来实现这个功能:
initialize: function () {
this.listenTo(this.model, 'change', this.render);
// Or, this.model.on('change', this.render, this);
}
...
// Change an attribute of the model
userView.model.set('lastName', 'Dey');
在initialize()方法中,我们监听模型的change事件并重新渲染视图。我们可以使用on()和listenTo()来实现这个功能,但listenTo()方法的优势在于,如果视图被销毁,它会自动解绑使用listenTo()方法添加的所有事件。另一方面,如果你使用on()方法绑定事件,你必须明确地解绑这些事件。
在某些情况下,一个模型可能有很多属性,你可能不希望每次属性变化时都重新渲染整个视图。相反,只更新视图的这部分似乎更实际。让我们在下一节中详细了解如何部分更新视图。
部分更新视图
部分视图更新是一个许多开发者请求的常见功能。要求是重新渲染视图的一部分,而不是整个视图。这在有复杂视图和大量数据,但只需要更改一小部分的情况下非常重要。每次小更改都重新渲染整个视图可能会影响性能。另一方面,这个解决方案相当简单。在以下示例中,如果address属性发生变化,那么视图的 DOM 中的地址部分将被更新,而整个视图将不会重新渲染:
...
template : _.template('<p><% name %></p><p><%= address %></p>'),
initialize: function() {
this.listenTo(this.model, 'change:address', this.showChangedAddress);
},
showChangedAddress: function () {
// we are using the same main view template here though
// another subtemplate for only the address part can
// anyway be used here
var html = this.template(this.model.toJSON()),
// Selector of the element whose value needs to be updated
addressElSelector = ".address",
// Get only the element with "address" class
addressElement = $(addressElSelector, html);
// Replace only the contents of the .address element
this.$(addressElSelector).replaceWith(addressElement);
}
...
在这个例子中,我们在render函数中使用模型数据填充模板。我们不是监听模型的change事件,而是监听change:address事件。在showChangedAddress()方法内部,我们首先使用模板和最新的模型数据创建 HTML 字符串。然后我们从这个 HTML 字符串中提取address DOM 元素。最后,我们只需用最新的一个替换视图当前的address DOM 元素。
同样的功能也可以通过子视图或子视图来实现,这确实是一个更好的解决方案。然而,在某些情况下,为如此小的更改创建一个新的子视图可能是多余的,而先前的解决方案可能更有利。在下一节中,我们将了解实际场景中(以及如何)我们应该使用子视图。
理解嵌套视图
嵌套视图或子视图基本上是一个子视图。当我们的视图复杂并且我们想要为了简化、更好的事件处理和更好的模型-视图关系而将其一部分分离出来时,子视图的必要性就出现了。
为了给您举个例子,假设我们有一组相似的数据,并且我们需要为每种数据类型显示一个列表项。在这种情况下,最好有独立的视图和模型,这样可以为每个模型附加的视图提供控制其行为的选择。当您点击一个项目时,您可能需要使用该项目的数据进行进一步处理。如果该项目是一个子视图,我们可以从附加到它的模型中轻松获取数据。我们将在下面的例子中解释这个概念。
我们在第一章中看到了UserItemView,通过插件开发减少样板代码,它使用了User模型。现在,让我们介绍一组将作为列表显示的用户数据:
var User = Backbone.Model.extend();
// Users collection
var Users = Backbone.Collection.extend({
model: User
});
// Add some data in the collection
var users = new Users([{
id: 1,
name: 'John Doe'
}, {
id: 2,
name: 'Dan Smith'
}]);
初始时,我们将只使用一个视图通过创建UsersView来渲染整个集合:
var UsersView = Backbone.View.extend({
tagName: 'ul',
render: function () {
var html = '';
// Iterate over the collection and
// add each name as a list item
this.collection.each(function (model) {
html += '<li>' + model.get('name') + '</li>';
}, this);
this.$el.html(html);
return this;
}
});
var usersView = new UsersView({
// add the collection instance
collection: users
});
// Display the view
$(document.body).append(usersView.render().el);
在render()方法中,我们遍历整个集合数据,创建一个 HTML 列表项,并将其附加到UsersView类的元素中。这工作得很好,显示了名字列表。
上述实现绝对没问题,除非您想通过点击用户的名字来接收用户的数据。在这种情况下,我们必须在列表项的 HTML 标记中添加用户 ID,这样您就可以从浏览器的event对象中访问它:
html += '<li data-id="' + model.get('id) + '">' + model.get('name') + '</li>';
在click事件中,我们调用showUserName()方法来显示user模型的名字:
...
events: {
'click li': 'showUserName'
},
showUserName: function (e) {
var userId = $(e.target).attr('data-id'),
user = this.collection.get(userId);
if (!user) {
return;
}
console.log('Clicked user\'s name =', user.get('name'));
}
...
列表元素的data-id属性可以从event对象的target属性中提取,并且具有相同data-id属性的模型可以从collection中获取。这种方法在您的应用程序中有许多视图时效果良好。以这种方式管理事件对于大型应用程序来说变得繁琐。那么,我们如何解决这个问题呢?我们使用子视图!
了解何时使用子视图
之前的模式类似于我们在基于简单 jQuery 的应用程序中通常使用的模式,其中所有数据和事件都紧密耦合到 DOM 上。以这种方式继续事件绑定最终会导致在后期阶段出现很多复杂性。子视图可以在很大程度上简化这个过程。我们将每个列表项分开,并为每个列表项引入一个UserItemView变量:
var UserItemView = Backbone.View.extend({
tagName: 'li',
template: _.template( '<%= name %>'),
events: {
'click': 'showUserName'
},
render: function () {
var html = this.template(this.model.toJSON());
this.$el.html(html);
return this;
},
showUserName: function () {
console.log('Clicked user\'s name =', this.model.get('name'));
}
});
这很简单。我们只为每个模型定义一个视图。在UsersView的render()方法中,我们消除了丑陋的 HTML 字符串,因为我们只需要创建每个子视图(UserItemView)的实例,并将它们的元素附加到主视图中:
render: function () {
var userItemView;
// clean up the view first
this.$el.empty();
// iterate over the collection and add each name as a list item
this.collection.each(function (model) {
userItemView = new UserItemView({
model: model
});
this.$el.append(userItemView.render().el);
}, this);
return this;
}
我们创建新的 UserViewItem 实例,将模型传递给它,并在主视图中渲染它。现在的事件监听器是子视图特定的,子视图方法可以直接访问附加到其上的模型。这使得应用程序的流程更清晰,并且消除了通过 ID 查找特定模型所需的时间,尽管这个时间很小。如果你的视图有多个类似的子项,并且每个子项都需要自己的事件集,那么子视图是正确的做法。
在本章的最后部分,我们将探讨一个很棒的库,MarionetteJS,它提供了一些有用的现成 BackboneJS 扩展。ItemView 和 CollectionView 扩展提供了一种类似于前一个示例的功能,但以更稳健和灵活的方式。
避免多次 DOM 重排
我们使用了 jQuery 的 $.append() 方法将子视图元素添加到主视图中。发现如果数据集很大,逐个将视图元素添加到 DOM 中可以创建严重的性能问题;这将影响应用程序的 UI 响应性。在现代浏览器中,性能下降甚至可以感觉到,因为每次添加都会导致 DOM 重排,并迫使浏览器重新计算 DOM 树的大小和位置。
可以通过使用 DocumentFragment 来避免多次 DOM 重排,这由 John Resig 在 ejohn.org/blog/dom-documentfragments 中描述为 一个可以容纳 DOM 节点的轻量级容器。我们可以在 DocumentFragment 内部收集所有的视图元素,然后将这个片段添加到 DOM 中。这将导致整个集合的单次重排,从而提高性能。
让我们看看使用单个重排的 render() 方法:
render: function () {
// create a document fragment
var fragment = document.createDocumentFragment();
this.collection.each(function (model) {
// add each view element to the document fragment
fragment.appendChild(new UserItemView({
model: model
}).render().el);
}, this);
// append the fragment to the DOM
this.$el.html(fragment);
return this;
}
如果有多个子视图并且视图的 HTML 结构很复杂,这个过程可以增强性能。一般来说,不是很多开发者使用它,你应该只在 HTML 标记非常复杂时才使用它。对于简单的 HTML 标记,测试显示性能几乎没有变化。
重新渲染父视图
想象以下场景,我们需要显示公司详情以及其员工列表。在这里我们将创建两个视图:一个 Company 视图作为主视图,一个 Employee 视图作为子视图,代表列表中的每个员工:

因此,将有一个 Company 模型和 Employees 集合。我们将以类似于之前讨论的方式渲染完整的视图以及子视图。如果 Company 模型发生变化,我们将重新渲染 Company 视图,但这意味着我们也必须重新渲染所有子视图。我们真的需要这样做吗?实际上我们不需要,也不应该这样做,因为这将会增加开销。
在大多数情况下,当你重新渲染父视图时,它不应该每次都重新初始化其子视图。因此,最好在父视图的initialize()方法中初始化子视图,并将它们添加到一个数组中,稍后在render()方法中使用:
var ParentView = Backbone.View.extend({
initialize: function () {
this.subViews = [];
// Initializing the child views
this.subViews.push(new ChildView(), new ChildView()];
},
render: function () {
this.$el.html(this.template);
// Render each child view
_(this.subViews).each(function (view) {
this.$el.append(view.render().el);
}, this);
return this;
}
});
这样,对父render()方法的多次调用将保持视图的状态,并且只会重新渲染子视图。
移除父视图
在上一个场景中,当公司详情及其员工列表被显示时,我们假设了一个需要销毁这个完整视图并显示新视图的情况。现在,我们如何销毁一个视图?我们只需在它上面调用remove()方法,它就会解除使用listenTo()方法注册的所有事件。它还会从 DOM 中移除完整的视图及其子视图元素。
我们在这里是否解除了子视图的事件?不是的。子视图确实从 DOM 中移除了,但我们没有在它们上面调用remove()方法。因此,模型仍然存在,附加到模型上的事件仍然引用视图(或视图的方法)。结果,即使视图的el属性从 DOM 中移除,这些视图对象也不会被垃圾回收。
为了防止这些内存泄漏,我们在移除父视图时应该始终跟踪子视图。例如,在上一个部分中,我们看到了如何将子视图存储在this.subViews数组中。我们可以在Company视图类中覆盖remove()方法,在移除主视图之前单独销毁子视图:
var Company = Backbone.View.extend({
...
remove: function () {
_(this.subViews).each(function (view) {
this.stopeListening(view);
view.remove();
}, this);
Backbone.View.prototype.remove.call(this, arguments);
}
});
这将确保在父视图之前移除所有子视图。因此,为了解决内存泄漏问题,请记住以下几点:
-
总是保留对当前顶级视图的引用
-
在父视图中保留所有子视图的引用
-
确保每个事件都被解除绑定
此外,如果你使用的是 Backbone.js 版本低于 V9.9.0,仅调用remove()方法将不会清理事件,你必须显式地解除它们。使用on()方法而不是listenTo()方法注册的事件也是如此。对于 Backbone 的旧版本,你可能需要使用以下类似代码:
remove: function () {
this.unbind(); // Unbind all local event bindings
// Unbind reference all such items
this.model.unbind('change', this.render, this);
// Remove this view
Backbone.View.prototype.remove.call(this, arguments);
// Delete the jQuery wrapped object variable
delete this.$el;
// Delete the variable reference to this node
delete this.el;
}
小贴士
有一些工具可以帮助你检查你的应用程序是否泄漏内存。你可以使用 Chrome 开发者工具(developers.google.com/chrome-developer-tools/docs/javascript-memory-profiling)来跟踪它,或者你可以使用 Backbone-Debugger(github.com/Maluen/Backbone-Debugger)。
使用模板进行工作
模板是 Backbone 应用程序开发的一个组成部分。使用 Backbone,Underscore.js 提供了一个内置的微模板引擎,尽管我们也可以使用其他流行的模板引擎,如 Handlebars、Mustache 或 Jade。在下一节中,我们将介绍一些有趣的模板模式,这些模式将帮助你在大型应用程序中管理模板并提高它们的性能。
将模板存储在 HTML 文件中
在最简单的情况下,我们以两种方式存储模板;我们要么直接将它们作为视图属性内联添加到视图中,要么将它们添加到index.html文件中的script标签内。在前面的例子中,我们已经看到了第一种情况。让我们看看第二种选项:
<script type="text/template" id="tpl_user_details">
<h3> <%= name %> </h3>
<p><%= about %></p>
</script>
在这里,我们只需将模板字符串放在一个script标签内,并给它一个类型text/template,这样它就不会被当作 JavaScript 来评估。你可以始终通过脚本 ID 来检索模板:
var userDetailsTpl = $('#tpl_user_details').html();
小贴士
Underscore 模板的默认定界符有时很烦人,看起来很丑。Mustache 风格的{{}}看起来更干净,并且大多数开发者都更喜欢它。你可以通过使用_.templateSettings 属性轻松地将你的 Underscore 定界符转换为 Mustache 风格:
_.templateSettings = {
interpolate: /\{\{(.+?)\}\}/g
};
当应用程序规模较小时,这两种模板存储方式都运行良好。然而,一旦应用程序的规模开始增加,管理 JavaScript 文件中的大量模板字符串以及包含所有模板的巨大 HTML 文件就变得相当困难。有许多选项可以存储模板并使用它们。例如,我们可以为我们的模板创建单独的 HTML 文件;这种方法给我们带来了诸如语法高亮、适当的缩进以及单独管理模板的选项等好处。然而,这种技术将导致另一个严重问题——模板需要通过 AJAX 请求单独加载。在大型项目中,通过多个 XHR 请求加载模板是一个糟糕的想法,并且会对性能造成巨大影响;避免这样做。
让我们看看一些其他选项,这些选项可能有助于你以更好的方式组织你的模板。
将模板存储在 JavaScript 文件中
许多开发者建议,尽管模板是一段 HTML 标记,但它并不完全是 HTML,将标记保存在 JavaScript 文件中是一个更好的选择。我们可以创建包含应用程序所有模板的单个或多个.js文件。模板将以字符串格式存储,但你可以通过使用join()方法以更易于阅读的方式呈现它们:
var TplManager = {
templates: {}
};
TplManager.templates.userProfile = [
'<h3> <%= name %> </h3>',
'<img src="img/>"',
'<p>Address : <%= address %></p>'
].join('\n');
TplManager.templates.userLogin = [
'<ul>',
'<li>Username: <input type="text" /></li>',
'<li>Password: <input type="password" /></li>',
'</ul>'
].join('\n');
你可以为你的模块维护单独的模板文件,例如,User.js和Dashboard.js。你也可以有特定于应用程序的模板命名空间,例如,App.User和App.Dashboard。关键点是你可以在以后将它们合并和压缩以获得一个文件,这可以大大提高应用程序的性能。
对于大型应用程序,你可能不希望在 JavaScript 文件中这样存储模板,因为在其中你将无法获得任何格式化和突出显示 HTML 代码的便利。然而,这种模式的实用性不容否认,尤其是在我们得到一个包含所有预编译模板的单一压缩 JavaScript 文件时。在附录 B,服务器端预编译模板中,我们详细讨论了这一过程。
随着 Require.js 和异步模块定义(AMD)的流行,大多数开发者今天更倾向于将单个模板存储在单独的模板或 HTML 文件中。当整个项目的源代码被优化后,它将创建一个包含所有模板的单一压缩文件。这种方法现在是一种流行的方法,我们在附录 C,使用 AMD 和 Require.js 组织模板中详细解释了这一功能。
预编译模板
模板编译是什么?一般来说,我们创建模板作为字符串,并在其中包含模板表达式。一旦我们传递这个字符串进行编译,模板库就会分析这个字符串以创建一个可以应用数据的格式。这个编译后的函数随后返回另一个函数,我们传递数据并返回一个包含数据集成 HTML 字符串的函数。这个过程被称为模板编译。
为什么我们需要预编译模板?这是因为当我们多次使用模板字符串,比如TplManager.templates.userProfile时,相同的编译过程会每次重复。这显然是额外的劳动,将显著影响应用程序的性能。你可以比较 Igor Hlina(twitter.com/srigi)进行的这个 jsperf 测试(jsperf.com/underscore-templates-classic-vs-precompiled)中的差异。测试表明,模板的预编译比经典方法快 99%。
通过预编译模板并缓存它们,你可以大幅度减少开销。让我们给我们的模板管理器添加一个方法,这个方法只会编译一次模板,并在每次调用时返回缓存的版本:
var TplManager = {
templates: {},
cachedTemplates: {},
// Returns compiled template
getCachedTemplate: function (tplName) {
// If compiled template already exists, return that
if (this.cachedTemplates.hasOwnProperty(tplName)) {
return this.cachedTemplates[tplName];
}
if (this.templates.hasOwnProperty(tplName)) {
// Compile and store the template functions
this.cachedTemplates[tplName] = _.template(this.templates[tplName]);
}
return this.cachedTemplates[tplName];
}
};
TplManager.getCachedTemplate('userProfile');
因此,我们可以从getCachedTemplate方法访问编译后的模板。这是一个没有太多错误处理的非优化解决方案,但这个概念可以应用于你所有的模板。
小贴士
_.template()方法通常接受两个参数。如果你传递模板字符串和数据,它将发送包含数据的完整 HTML 字符串给你。然而,如果你只传递模板字符串,它将返回一个函数,该函数接受数据作为参数。
避免在模板中进行评估
我从 Sencha 库中学到了许多模板最佳实践。Sencha 的 XTemplate 功能不允许你在模板字符串中添加任何 JavaScript 代码评估,但它提供了一系列变量和选项来添加自定义函数,这有助于保持模板的整洁;我在创建复杂模板时从未遇到过任何问题。
Underscore.js 模板和大多数其他模板引擎都提供了一种在模板内部评估 JavaScript 代码的功能。一方面,这看起来相当难看,另一方面,随着项目中模板数量的增加,它也增加了复杂性。在模板中放置一些 JavaScript 逻辑会使代码管理变得非常困难。因此,建议将 JavaScript 代码与你的 HTML 标记分离:
<h3>
<%= companyName %>
</h3>
<ul>
<% employees.forEach(function (employee) { %>
<li>
<%= employee.name %>
</li>
<% }); %>
</ul>
当展示员工列表时,我们需要遍历列表并显示员工姓名。Underscore.js 没有提供任何内置机制来完成这项工作,但我们可以在这里使用一个子模板,将评估部分从这段代码中排除。这个子模板将很简单,例如:
<li> <%= name %> </li>
你将在 JavaScript 代码中遍历列表,使用这个子模板仅渲染这个 li 元素,然后将元素附加到主元素上。虽然这样做可能需要更多努力,但它将帮助你避免在模板中进行 JavaScript 评估。
另一方面,有一些模板引擎,如 HandleBars.js,提供了内置逻辑(例如循环、传递上下文、if-unless 块辅助函数等)。因此,如果你觉得子模板需要更多的工作,你可以选择一个更好的模板库,它可能不像 Underscore 那样轻量级,但提供了更多的内置辅助函数。
避免在模板中进行评估的另一个想法是使用辅助函数。让我们在下一节中看看它们。
使用模板辅助函数
使用模板辅助函数相当简单。想象一下这样的场景:在你的应用程序中展示用户资料时,在用户头像的位置,你可能需要显示用户的照片,或者必须显示默认的头像图片。这就是你在模板中编写该条件的方式:
<% if(typeof(avatar) === 'undefined') %>
<img src="img/<%= avatar %>" />
<% } else { %>
<img src="img/default_avatar.png" />
<% } %>
这是一个选项,但我们已经决定不在我们的模板中评估 JavaScript。辅助函数在这里可能很有用。尝试这个函数:
// A cleaner template
var tplString = '<img src="img/<%= getAvatar(avatar) %>" />';
var data = this.model.getJSON();
var html = _.template(tplString, _.extend(data, {
// A template helper function to be merged with data
getAvatar: function (avatar) {
return avatar || "images/default_avatar.png";
}
}));
因此,当你将数据传递给 _.template() 方法时,你需要确保模板方法作为属性或子属性存在。问题是为什么我们需要将辅助函数作为数据的一部分添加?原因是大多数模板库,包括 Underscore 的模板,都会创建一个数据对象,并将其作为函数的上下文传递给它。因此,辅助函数是在数据的上下文中调用的,并且只能以这种方式可用。
小贴士
有许多模板引擎内置了一些之前的解决方案。如果您正在开发一个小型应用程序,您可能会发现 Underscore 的微模板解决方案足够用于开发。但如果您打算在应用程序中使用复杂的模板,我们建议选择 Handlebars,这是一个流行且广受欢迎的模板引擎。
理解自动模型-视图数据绑定
当附加模型的任何属性发生变化时,我们会刷新视图以显示更新后的数据。在视图的 initialize() 方法中,我们为模型附加了一个 change 事件监听器,如下所示:
this.listenTo(this.model, 'change', this.render);
然而,有一些选项可以自动处理这种数据绑定,您不需要为每个模型-视图关系处理它。这个原则比 Backbone 的 MV* 模式更接近 MVVM 设计模式,您可以在 Knockout.js 和 Meteor.js 等框架中找到它。
对于 Backbone,有多个插件,例如 Backbone.Stickit (nytimes.github.io/backbone.stickit/)、Backbone.ModelBinder (github.com/theironcook/Backbone.ModelBinder) 和 Rivets.js (www.rivetsjs.com/)。这些插件提供了类似的数据绑定功能。我们在这里不会讨论每个插件;然而,这些插件的实现过程简单且相似。如果您希望使用此类功能,请查看这些插件并使用适合您需求的插件。
使用 Marionette 的 ItemView、CollectionView 和 CompositeView
Marionette (marionettejs.com/) 是一个用于 Backbone.js 的复合应用程序库。由 Derick Bailey 开发,它是一组常见的模式和解决方案,用于解决 Backbone 的问题。这是一个很棒的库,许多开发者都将其用于他们的基于 Backbone 的应用程序。
关于 Marionette 的一个重要事情是它为视图、区域等提供了几个独立的包,并允许您自由使用它们,而无需使用完整的库。在本节中,我们将探讨 Marionette 的 ItemView、CollectionView 和 CompositeView 功能。这些视图解决了我们在上一节中讨论的许多问题。
ItemView
ItemView 代表一个项目的单个视图,它可以是模型视图或集合视图。它扩展了 Marionette.View 类,这是一个具有许多可重用函数的核心视图。Marionette.View 负责触发、委派和取消委派事件。
如果您计划使用 Marionette,具有模型或集合的视图应该扩展 ItemView 类。它提供了一系列功能,包括:
-
一个
serializeData()方法,这是一个通用方法,用于返回视图附加的模型或集合的数据。 -
一个
close()方法,负责从 DOM 中移除视图并清理资源。这与我们在 第一章 中学习的BaseView类的close()方法类似,通过插件开发减少样板代码。 -
一些自定义事件,例如:
-
'render' / onRender事件 -
'before:render' / onBeforerender事件 -
'close' / onClose事件 -
'before:close' / onBeforeClose事件
-
让我们看看以下代码中所示的基本 ItemView 类定义:
var UserItemView = Marionette.ItemView.extend({
tagName: 'li',
template: _.template('<%= firstName %> <%= lastName %>'),
onRender: function () {
// After render functionality here
},
onClose: function () {
// Do some cleanup here
}
});
我们将创建一个实例并按照以下方式传递模型:
var userItemView = new UserItemView({
model: new Backbone.Model({
firstName: 'Sudipta',
lastName: 'Kundu'
})
});
$(document.body).append(userItemView.render().el);
// Close and destroy the view after 2 seconds
setTimeout(function () {
// userItemView.close();
}, 2000);
这是一个简单的 ItemView 类的示例,其中我们传递模型并使用其方法来显示数据。看看,我们没有提供任何 render() 方法定义。这是因为 ItemView 默认提供简单的渲染功能。ItemView 有一个 serializeData() 方法,它发送模型数据或附加到该视图的集合数据,而 render() 方法将此数据应用于其模板,并自动用生成的 HTML 内容填充视图。以下是 Marionette 中 serializeData() 方法的样子:
// Serialize the model or collection for the view. If a model is
// found, '.toJSON()' is called. If a collection is found,
'// .toJSON()'is also called, but is used to populate an 'items'
// array in the resulting data. If both are found, defaults to
// the model. You can override the 'serializeData' method in your
// own view definition, to provide custom serialization for your
// view's data.
serializeData: function () {
var data = {};
if (this.model) {
data = this.model.toJSON();
} else if (this.collection) {
data = {
items: this.collection.toJSON()
};
}
return data;
}
因此,ItemView 期望一个模板、一个模型或一个集合,并且它减少了渲染视图的初始样板代码。正如你所见,许多基本和可重用的功能都在 ItemView 类中处理。它提供了我们在 BaseView 类中讨论的所有功能。将其用作视图的基础类可以为基于 Backbone.js 的应用程序提供很多灵活性。
CollectionView
CollectionView 类,正如其名所示,显示指定集合中每个模型项的项列表。其功能与前面的示例类似,但具有更强大的子视图功能。CollectionView 类为每个数据项创建一个 ItemView 实例并将其元素追加到主视图的 el。
CollectionView 的某些常见功能包括:
-
创建、添加和删除子视图。
-
当集合为空时显示空视图。
-
集合的
'add'、'remove'和'reset'事件的自动渲染和重新渲染,其中集合视图自动渲染更改。 -
提供了多个有用的自定义事件:
-
'render' / onRender事件 -
'before:render' / beforeRender事件 -
'closed' / 'collection:closed'事件 -
'before:item:added' / 'after:item:added'事件 -
'item:removed'事件 -
从子视图中冒泡的
'itemview:*'事件
-
-
包含一个
close()方法,在关闭之前移除子视图。
现在,让我们使用之前的 UserItemView 类作为子项并创建一个 CollectionView 类:
// Create a collection view and pass the item view class
var UsersView = Marionette.CollectionView.extend({
tagName: 'ul',
itemView: UserItemView
});
var usersView = new UsersView({
collection: new Backbone.Collection([{
firstName: 'Sandip',
lastName: 'Maity'
}, {
firstName: 'Debopam',
lastName: 'Biswas'
}])
});
$(document.body).append(usersView.render().el);
比较一下,与我们在本章早期开发的用于显示项目列表的代码相比,代码量是多么的小。我们只需在CollectionView实例中将类名UserItemView作为itemView传递,它就会负责从渲染到在父视图被移除时销毁子项的所有事情。
Marionette 的CollectionView在很大程度上减少了你的代码中的样板代码。如果你正在开发一个具有多个列表视图的应用程序,你可以使用 Marionette 的集合视图生成更干净的代码,因为它自己提取了大部分可重用功能。
使用CompositeView
CompositeView扩展自Marionette.CollectionView类。一般来说,你可以将其视为ItemView和CollectionView的组合,其中它接受一个表示单个数据集的模型和一个显示多个数据的集合。这在你有层次结构或树状结构时特别有用。你可以将其与我们提到的重新渲染父视图部分联系起来。在那里,我们必须一起显示Company模型和Employees集合的数据,而CompositeView将是一个提供紧凑解决方案的绝佳工具。
组合视图除了基本的CollectionView功能外,还提供了一些特定的功能,具体说明如下:
-
CompositeView的模型数据应用于其模板属性。 -
它有一个
itemViewContainer属性,用于指定集合视图将在哪个元素内渲染。itemViewContainer属性可以是 jQuery 选择器或 jQuery 对象,或者它可以是返回 jQuery 选择器或 jQuery 对象的函数。 -
当
itemViewContainer不足以指定ItemView的确切位置时,覆盖CollectionView的appendHtml()方法可能提供所需的结果,如下面的代码片段所示:appendHtml: function (galleryView, imageView, index) { // Put the imageView i.e. ItemView instances // inside element with class "box-result" galleryView.$(".box-result").append(imageView.el); }
假设我们有一个场景,需要显示公司详情以及员工列表。因此,将会有一个Company模型,如下面的代码片段所示:
// Company model
var Company = Backbone.Model.extend({
defaults: {
name: '',
specialty: ''
}
});
同样,每个员工也必须有一个Employee模型,如下所示:
// Employee model
var Employee = Backbone.Model.extend({
defaults: {
name: ''
}
});
让我们定义一个Employees集合来表示员工列表,如下所示:
// Employees collection
var Employees = Backbone.Collection.extend({
model: Employee
});
对于组合视图,我们希望将每个员工表示为一个单独的ItemView实例,以便于事件委托:
// Create an ItemView instance for the child items
var EmployeeItemView = Marionette.ItemView.extend({
tagName: 'li',
template: _.template('<%= name %>')
});
现在,我们可以定义一个组合视图,它将一起显示模型数据和集合数据,如下面的代码所示:
// Create a collection view and pass the item view class
var CompanyView = Marionette.CompositeView.extend({
template: _.template(['<h2><%= name %> </h2>',
'<span><%= specialty %> </span>',
'<ul class="employees"></ul>'
].join('')),
itemView: EmployeeItemView,
itemViewContainer: '.employees',
// Add a company details to this view's model and collection
addCompany: function (data) {
if (!data) return;
if (data.employees) {
this.collection = new Employees(data.employees);
}
delete data.employees;
this.model = new Company(data);
}
});
在这里,我们首先定义了一个模板,其中我们为员工列表留出了位置。然后我们提到了 itemView 选项,即 EmployeeView 类,该类将由集合使用以创建实例,并用每个员工的数据填充它。这些项目视图将堆叠在 itemViewContainer 属性中提到的元素中。现在,让我们创建复合视图实例,向其中添加一个公司,并渲染它,如下面的代码片段所示:
var companyView = new CompanyView();
// Add a company details
companyView.addCompany({
name: 'Innofied',
specialty: 'Team of JavaScript specialists',
employees: [{
name: 'Swarnendu De'
}, {
name: 'Sandip Saha'
}]
});
$(document.body).append(companyView.render().el);
我们得到的结果如下面的截图所示:

因此,您可以看到复合视图提供了一种紧凑的机制,可以在单个视图中显示与模型关联的模型和集合。您可能有一个树状结构的数据,为此需要创建多个复合视图。默认情况下,复合视图的渲染机制是层次性的,如果未覆盖,则 itemView 属性为 CompositeView 类型。
我们希望我们向您提供了所有 Marionette 视图的基本概念。讨论这些视图的高级内容超出了本书的范围,但 Marionette 文档将为您提供框架的完整描述。我们在 附录 A 中提到了关于 Marionette 的资源和书籍,书籍、教程和参考资料。
使用 Layout Manager
当您在应用程序中处理多个视图时,管理诸如多个视图渲染、向元素添加动画或用另一个视图替换视图等活动往往变得困难。让我们看看一个优秀的扩展,LayoutManager (github.com/tbranyen/backbone.layoutmanager),它为在应用程序中组装布局和视图提供了逻辑基础。
Marionette 也提供了类似的功能,通过其 RegionManager,但我们选择在这里讨论 LayoutManager 插件,因为并非每个人都使用 Marionette,并且此插件可以独立与您的 Backbone 应用程序一起工作。如果您已经使用 Marionette,我建议您验证 RegionManager 是否满足您的需求。或者,您可以使用 LayoutManager 插件与 Marionette 一起使用。
LayoutManager 扩展为解决许多痛点提供了解决方案:
-
如果您计划从外部文件动态加载模板,它处理视图的异步渲染
-
它将布局定义为 HTML 结构,并将视图分配到布局配置中给出的适当元素
-
它提供了执行以下活动的功能:
-
插入视图,将数据应用到给定的模板中,并自动渲染它们
-
根据多个选择标准检索或删除视图
-
通过从视图或从作为上下文的模型/集合中解绑所有事件来清理视图
-
我们将通过创建一个简单的布局来探讨这些点,如下面的截图所示:

有一个用户列表,当你点击用户项时,用户的详细信息将显示在布局的右侧。你可以在我们的示例代码中找到包含所有 HTML、CSS 和其他文件的完整代码示例。在这里,我们将描述关键部分。
我们首先创建一个用户模型和集合,如下代码片段所示:
// Change template delimiter to Mustache type
_.templateSettings = {
interpolate: /\{\{(.+?)\}\}/g
};
// User Model
var User = Backbone.Model.extend({
defaults: {
avatar: '',
name: '',
email: '',
phone: '',
twitter: ''
}
});
// Users collection
var Users = Backbone.Collection.extend({
model: User
});
我们将为这个页面创建三个视图:UserList、UserItem 和 UserDetails 视图。UserItem 视图将作为 UserList 视图的子视图。首先,让我们为这三个视图编写模板:
<!—- Layout manager template -->
<script type="text/template" id="tpl_main_content">
<div id="main_content">
<div class="user-list"></div>
<div class="user-details"></div>
</div>
</script>
<!—- User item template -->
<script type="text/template" id="tpl_user_item">
<a class="name" href="#">{{name}}</a>
</script>
<!—- User details template -->
<script type="text/template" id="tpl_user_details">
<div class="avatar"><img src="img/{{avatar}}" /></div>
<ul>
<li><strong>Name:</strong> {{name}}</li>
<li><strong>Email:</strong> {{email}}</li>
<li><strong>Phone:</strong> {{phone}}</li>
<li><strong>Twitter:</strong> {{twitter}}</li>
</ul>
</script>
视图模板相当简单。定义页面结构的布局模板在这里是最重要的。任务有三个方面:你必须根据需要将布局分成多个部分,添加适当的样式以对齐它们,然后在 LayoutManager 配置中定义你的视图,这些视图将自动在这些部分中渲染。
首先,我们将定义用户列表项,它将只显示用户的姓名。
// UserItem sub view
var UserItem = Backbone.View.extend({
tagName: 'li',
template: '#tpl_user_item',
manage: true,
// LayoutManager uses serialize method to apply the data into template
serialize: function () {
return this.model.toJSON();
}
});
注意这里有两个新属性:manage 和 serialize。manage 属性是一个布尔属性,用于确定视图是否被视为布局。如果你打算在布局管理器内部使用视图并处理其渲染函数,则必须将 manage 属性设置为 true。你也可以将其全局设置为 true,对于特定视图,如果需要,可以将其设置为 FALSE。
LayoutManager 使用 serialize() 方法将数据应用到视图的模板中。serialize() 方法的默认实现返回一个空对象。你应该重写它以发送你想要显示的数据。在这里,我们发送与视图关联的模型数据。
LayoutManager 为视图提供了两个自定义事件,beforeRender 和 afterRender,因为它自己处理渲染函数。当使用 beforeRender() 方法时,视图的元素尚未可用,但如果你将视图插入到布局中,LayoutManager 会跟踪它,并在父视图在 DOM 中可用时进行渲染。我们可以使用此方法在用户集合上迭代并插入 UserItem 视图到列表视图中:
// User List view
var UserList = Backbone.View.extend({
tagName: 'ul',
className: 'nav nav-tabs nav-stacked',
manage: true,
// Before rendering the list,
//insert all the child list items into it
beforeRender: function () {
this.collection.each(function (model) {
// insertview method inserts the views
// directly inside the parent view
this.insertView(new UserItem({
model: model
}));
}, this);
}
});
有两个类似的方法 insertView/insertViews 和 setView/setViews。这两个函数都根据给定的选择器名称将视图插入到布局中。setView() 方法有一个额外的 insert 参数,它是一个布尔值,用于确定视图是否会替换选择器的完整内容,或者只是简单地附加到它。我们创建 UserItem 视图,将模型附加到它们,并将它们插入到 UserList 视图中。子视图将自动在内部渲染。
我们完成了基本列表定义。现在,让我们定义布局管理器的功能如下:
// Create a collection with some data
var users = new Users([{
name: 'John Doe',
avatar: 'avatar.png',
phone: '+88-888-8888',
twitter: 'johndoe',
email: 'johndoe@example.com'
}, {
name: 'Swarnendu De',
avatar: 'avatar.png',
phone: '+99-999-9999',
twitter: 'swarnendude',
email: 'swarnendude@example.com'
}]);
// Define the main layout
var MainLayout = Backbone.Layout.extend({
template: "#tpl_main_content",
// Assign the view to specific selectors
views: {
'.user-list': new UserList({
collection: users
})
}
});
LayoutManager 也是一种 Backbone.View 类型,你可以像对任何其他 Backbone 视图一样渲染它。在 views 属性中,我们可以指定一个或多个视图实例。在我们的例子中,我们创建了 UserList 实例,将其集合传递给它,并让 LayoutManager 负责其他所有渲染到 .user-list 元素中的工作。
因此,到目前为止,我们已经渲染了包含用户列表的布局。剩下的唯一动作是在我们点击用户项时显示用户详情。让我们定义 UserDetails 视图,这是一个简单的视图:
// User Details view
var UserDetails = Backbone.View.extend({
manage: true,
template: '#tpl_user_details',
serialize: function () {
return this.model.toJSON();
},
// Set the selected model
setModel: function (model) {
if (model) {
this.model = model;
}
return this;
}
});
这与我们的 UserItem 视图定义完全相同,只是多了一个 setModel() 方法,该方法将模型设置为所选的模型。现在,当我们在列表项上点击时,我们将插入这个视图。为此,我们将向 UserItem 视图添加一个点击事件处理器,如下面的代码所示:
var UserItem = Backbone.View.extend({
...
events: {
'click a': 'showDetails'
},
showDetails: function () {
// Check Whether details view exists
var detailsView = mainLayout.getView('.user-details');
// If details view doesn't exist, create one,
// set the new model and render it
if (!detailsView) {
mainLayout.setView('.user-details', new UserDetails().setModel(this.model).render());
} else {
// Set the latest clicked model and re-render
detailsView.setModel(this.model).render();
}
}
});
我们使用 getView 方法,可以根据多个标准检索视图,如选择器、模型或函数。我们检查详情视图是否可用。如果没有,我们创建一个 DetailsView 实例,设置模型,并渲染它。否则,我们重置模型并重新渲染视图。
因此,我们已经完成了完整的布局管理。观察到大多数渲染功能都是由管理器本身处理的。这只是一个基本示例;LayoutManager 可以提供更多选项和功能,并消除你 90%的视图管理任务。务必彻底阅读它们的文档,因为你在应用程序中能够使用其中的大部分。
摘要
在本章中,我们讨论了大多数 Backbone 开发者会遇到的一些重要问题,并学习了多种解决方案来解决这些问题。首先,我们讨论了部分视图渲染和嵌套视图。任何 Backbone 应用都需要处理嵌套视图,如果我们能正确维护它们的初始化、DOM 重新流和清理,这将大大提高整个应用程序的性能。
我们讨论了不同的模板处理方法,看到了从外部文件加载预编译模板的多种解决方案,组织了应用程序内的模板,并了解了辅助函数如何消除模板内 JavaScript 代码的评估,并帮助我们创建更干净的模板。
最后,我们了解了一些非常重要的扩展:Marionette 的 ItemView、CollectionView、CompositeView 和 LayoutManager。所有这些扩展通过移除大量样板代码并通过大量管理视图提供了极大的灵活性。
在下一章中,我们将讨论 Backbone 模型;我们将探讨模型数据验证、不同的验证插件、模型序列化和关系数据模型。
第三章:与模型一起工作
JavaScript 模型是客户端数据管理的重要组成部分。在具有状态的状态 JavaScript 应用程序中,本地或远程数据存储在模型中,并且模型提供了一系列函数来处理这些数据,例如转换、验证、数据持久化等。Backbone 模型与这些模型没有区别,并提供类似的功能,例如设置/获取数据、验证、保存到或从服务器获取、删除属性以及与服务器同步。
在本章中,我们将讨论 Backbone 开发者通常遇到的一些模型基本问题,并提出一些可能的解决方案。此外,我们还将介绍一些有趣的模型插件和扩展,这些插件可以帮助减少代码中的样板代码。以下是需要涵盖的主要点:
-
模型的基本用法:学习 Backbone 模型的基础知识,例如重要方法、属性和数据操作。
-
验证数据:我们将看到如何使用 Backbone 模型执行基本数据验证。此外,我们还将分析一个重要的插件
Backbone.Validation,它可以帮助我们减少大量的样板验证代码。 -
序列化模型:发送到服务器或从服务器接收的数据格式可能与模型期望的格式不同。在本节中,我们将看到如何通过重写
parse()和toJSON()方法来帮助模型直接与服务器通信。 -
理解关系型数据模型:我们将借助 Backbone 关系插件来阅读嵌套模型和集合的分析。
模型的基本用法
模型是 Backbone 最重要的组件之一。从存储数据开始,它们提供了很多功能,包括数据逻辑、验证、数据交互等。一个模型可以通过扩展Backbone.Model类来定义,如下所示:
var User = Backbone.Model.extend({});
模型由一个attributes属性组成,该属性存储其内部的数据。你可以使用get()方法获取模型数据,并通过使用set()方法在attributes中设置数据:
var newUser = new User({
name : 'Jayanti De',
age : 40
});
var name = newUser.get('name'); // Jayanti De
newUser.set('age', 42);
console.log(newUser.toJSON());
// Output => {"name": "Jayanti De", "age": 42}
模型的toJSON()方法返回一个 JSON 对象,其中包含模型属性的副本。注意,输出现在将age设置为新的值。每次通过set()方法更改任何属性时,都会在模型上触发一个change事件:
newUser.on('change' , function(model, options){
console.log(model.changed); // Output => {"age" : 42}
});
每个更改的属性都会触发change事件:
newUser.on('age:change' , function(model, newAge){
console.log(newAge); // Output => 42
});
当你只想部分更新视图时,这非常有用,因为在这种情况下会触发change和change:age事件。你可以只监听特定属性的变化并相应地采取行动。
使用默认属性
在某些情况下,你可能希望你的模型在添加新数据之前具有一组默认值。Backbone 提供了一个defaults属性,你可以在这里指定初始数据,如下面的代码片段所示:
var User = Backbone.Model.extend({
defaults: {
name: 'John Doe',
age: 20
}
});
console.log(new User().get('name'));
// Output => John Doe
当为模型的每个实例添加时,任何未指定的属性将自动设置为默认值。
避免在 defaults 属性中使用对象引用
确保您永远不要在 defaults 属性中直接使用任何对象或数组。这是因为 JavaScript 中的对象是通过引用共享的,如果添加到 defaults 中,这些对象将在模型的各个实例之间共享。以下是一个示例来解释这种情况:
var User = Backbone.Model.extend({
defaults : {
hobbies : []
}
});
var user1 = new User(),
user2 = new User();
user1.get('hobbies').push('photography');
user2.get('hobbies').push('biking');
console.log(user1.get('hobbies'));
// Output => ["photography", "biking"]
您会看到 hobbies 数组现在成为模型两个实例之间的共享属性。这不是一个期望的情况,您应该始终避免将对象作为默认属性。可以通过使用函数而不是对象来为 defaults 属性解决问题:
defaults: function() {
return {
hobbies: []
}
}
console.log(user1.get('hobbies'));
// Output => ["photography"]
这个函数会在每次创建模型实例时执行,因此总是会为 defaults 发送一个新的对象。
与服务器进行数据交互
Backbone 模型通过提供一系列有趣的方法,如 fetch()、save()、sync() 和 destroy(),使得与服务器进行数据操作变得非常简单。让我们逐一查看这些方法。我们将使用之前相同的用户模型。
var User = Backbone.Model.extend({
url: '/users'
});
创建一个模型
通常,如果您将新值设置到模型中并对其调用 save() 方法,您的服务器应该在数据库中创建一个新的模型。从下一次开始,模型将携带这个 id 属性,再次调用 save() 方法应该只更新模型而不是创建一个新的模型:
var user = new User({
name : 'Ashim De',
age : 55
});
user.save({
success : function(){},
error : function(){}
});
由于目前还没有 id 属性,因此发送一个 POST 请求到 /users URL,服务器会发送一个包含新 ID 的响应。
更新一个模型
更新一个模型的过程也类似。如果存在 id 属性,相同的 save() 方法会向服务器发送一个带有新属性的 PUT 请求:
var user = new User({
id: 23,
name: 'Shankha De',
age: 14
});
// Send PUT request to the server
user.save();
获取一个模型
如果存在 id 属性,模型的 fetch() 方法会发送一个 GET 请求以检索并填充模型:
var user = new User({
id: 23
});
// Sends GET request to /users/23
user.fetch();
删除一个模型
使用 destroy() 方法删除一个模型。此方法向服务器发送一个带有模型 ID 的 DELETE 请求:
var user = new User({
id: 23
});
user.destroy({
success: function () {}
});
验证数据
在 Backbone 中,验证由 model.validate() 方法处理。默认情况下,Backbone.Model 类本身没有 validate() 方法。然而,开发者被鼓励添加一个 validate() 方法,该方法在每次使用 validate: true 传递时被模型调用。所有更改的值都会发送到 validate() 方法的一个属性副本。让我们看看一个简单的数据验证示例:
var User = Backbone.Model.extend({
validation: { emailRegEx: /^\s*[\w\-\+_]+(\.[\w\-\+_]+)*\@[\w\-\+_]+\.[\w\-\+_]+(\.[\w\-\+_]+)*\s*$/
},
defaults: {
name: '',
email: ''
},
validate: function (attr) {
if (attr.name.length === 0) {
return 'Name is required';
}
if (attr.email.length === 0) {
return 'Email is required';
}
if (!this.validation.emailRegEx.test(attr.email)) {
return 'Please provide a valid email';
}
}
});
// Define the user view
var UserView = Backbone.View.extend({
initialize: function () {
this.model.on('invalid', this.handleError, this);
},
handleError: function (model, error, options) {
alert(error);
}
});
var user = new User();
var userView = new UserView({
model: user
});
// Set new attributes
user.set({
name: '',
email: 'johndoe#www.com'
}, {
validate: true
});
在这里,我们创建了一个具有两个属性 name 和 email 的模型,添加了一个 validate() 方法来测试这些属性的值,并定义了一个视图来处理可能出现的验证错误。
由于我们是在单个 set() 方法中设置两个值,所以 validate() 方法只会被调用一次。然而,一旦它发现一个无效属性,它就会返回一个错误。如果我们想在表单上一起显示所有错误怎么办?在这种情况下,我们必须返回一个包含所有错误信息的数组或对象,如下面的代码片段所示:
validate: function (attr) {
var errors = {};
if (attr.name.length === 0) {
errors['name'] = 'Name is required';
}
if (attr.email.length === 0) {
errors['email'] = 'Email is required';
}
if (!this.validation.emailRegEx.test(attr.email)) {
// If already there is an error for email,
// then skip other errors for email
errors['email'] = errors['email'] || 'Please provide a valid email';
}
return errors;
}
// Set both empty values
user.set({
name: '',
email: 'johndoe#www.com'
}, { validate: true });
现在,我们将接收到一个包含所有错误的对象。当你需要单独显示数据,即使它们一个接一个地设置时,这很有用。例如,当我们在 blur 事件上验证字段时,这将非常有用。
使用 Backbone.Validation 插件
因此,我们刚刚看到了数据验证的简单实现。然而,当有大量具有多个验证标准的表单字段时,validate() 方法会变得很大,包含多个嵌套的 if-else 条件。从头开始创建完整的验证逻辑可能会使其更加复杂和耗时。幸运的是,有一个叫做 Backbone.Validation 的出色插件(thedersen.com/projects/backbone-validation/),它通过提供多个内置验证方法和简化与视图的验证绑定,使事情变得容易得多。让我们使用这个插件重新实现之前的验证。
配置验证规则
有许多内置验证器,例如 required、maxLength、minLength、max、min、length 和 pattern。它们的使用方法如下所示:
var User = Backbone.Model.extend({
validation: {
name: {
required: true
},
email: {
required: true,
pattern: 'email'
}
},
defaults: {
name: '',
email: ''
}
});
存在一些现有的验证模式,如电子邮件、数字和 URL。或者,你可以使用正则表达式作为模式。同样,你可能需要为属性定义完整的验证功能,而不仅仅是正则表达式。在这种情况下,你可以向属性添加自定义方法验证器。查看以下示例:
var User = Backbone.Model.extend({
validation: {
// Do not return anything if validation is passed
name: function (value, attr, computedState) {
if (!value) {
return 'Name is required';
}
},
// the method will be called on model's scope
email: 'validateEmail'
},
validateEmail: function (value, attr, computedState) {
if (!value) {
return 'Email is required';
}
}
});
你可以直接将自定义方法添加到属性中作为一个函数,或者你可以将方法名作为字符串添加,就像我们在这里做的那样。每个属性可以为每个验证规则有一个错误消息,或者它可以为所有验证规则有一个单一的错误消息。例如,在以下代码中,我们为 email 的 required 和 format 验证提供了单独的消息:
{
name: {
required: true,
msg: 'Name is required'
},
email: [{
required: true,
msg: 'Email is required'
}, {
pattern: 'email',
msg: 'Please provide a valid email'
}]
}
使用 preValidate() 方法预先验证模型
此插件还提供了另一个重要的功能,即在不接触模型本身的情况下预先验证模型的属性,如下所示:
var errorMessage = model.preValidate('attributeName', 'Value');
因此,该属性将与其分配的验证器集进行验证,如果验证失败,返回值将是一个错误信息。
如果你的应用程序需要多个表单验证,Backbone.Validation 插件非常有效。它从你的代码库中移除了许多样板代码,并提供了一个简单而健壮的验证机制。
序列化模型
到目前为止,我们在前几章的示例中使用的模型数据都是简单的具有属性的数据对象。然而,可能存在服务器发送不同数据格式的情况,你需要从中提取关键部分并将其应用于相关模型。例如,考虑以下数据:
{
"name": "John Doe",
"email": "johndoe@example.com"
}
代替发送前面的数据,服务器返回以下数据:
{
"user": {
"name": "John Doe",
"email": "johndoe@example.com"
}
}
这份数据不能直接应用于具有 name 和 email 属性的模型。如果我们现在对模型调用 fetch() 方法,它将只是向模型添加另一个名为 user 的属性。可以帮助我们克服这个问题的方法称为 parse()。默认情况下,这个方法只是传递服务器响应,模型应用从 parse() 方法接收到的任何内容。这是在 Backbone.js 中如何定义的:
parse: function (resp, options) {
return resp;
}
然而,我们可以覆盖 parse() 方法来修改原始服务器响应,并只发送属性 hash。对于这种情况,parse() 方法应返回一个具有 name 和 email 属性的对象,如下面的代码片段所示:
var User = Backbone.Model.extend({
url: 'server.json',
defaults: {
name: '',
email: ''
},
// Returns the attribute hash
parse: function (response) {
return response.user;
}
});
var user = new User();
user.fetch({
success: function () {
console.log(user.get('name')); // John Doe
}
});
在这里,server.json 文件包含新格式化的数据。在 parse() 方法中,我们正在解析响应并返回 Backbone 模型可以接受的数据。
小贴士
记住,fetch() 方法不会清除你的模型,但只会扩展属性。所以,在我们的例子中,如果服务器只发送一个电子邮件作为响应,之前的电子邮件将被更新,但名称仍然保持不变。
与从服务器获取数据类似,向服务器发送数据也可能遇到相同的问题,即服务器可能期望它发送给模型的确切格式。现在,如果我们对模型调用 save() 方法,它将以以下格式发送数据:
{
name : 'Swarnendu De',
email: 'swarnendu@email.com'
}
因此,如果服务器期望的数据格式与现在发送的格式相同,我们需要覆盖 toJSON() 方法,这相当简单。在下面的代码中,我们创建了一个具有 user 属性的新对象,并从 toJSON() 方法返回该对象:
// Add this method to model
toJSON: function () {
return {
user: _.clone(this.attributes)
}
}
// Let's set new data and send that to the server
user.set({
name: 'Swarnendu',
email: 'swarnendu@email.com'
});
user.save();
请求将以以下数据发送到服务器,这正是我们所寻找的:
{
"user": {
"name": "Swarnendu",
"email": "swarnendu@email.com"
}
}
然而,这个过程有一个缺点。在大多数情况下,我们使用 toJSON() 方法直接获取模型属性哈希。由于我们在这里覆盖了这个方法,返回的数据将与预期数据不同。因此,你需要决定你是否将采用这种方法来序列化模型或单独实现服务器端交互。如果你选择这个过程,记得在使用 toJSON() 方法时相应地应用模型数据。或者,你也可以克隆 model.attributes 属性以获取 hash 属性:
var jsonData = _.clone(this.attributes);
小贴士
最好不直接使用 model.attributes 属性。直接操作 hash 属性可能会引起一些意外的后果,因为对象将通过引用传递。
理解关系型数据模型
我们到目前为止所讨论的所有示例都使用了简单的模型来表示数据。然而,在任何非平凡的应用中,数据结构都要复杂得多,实体之间的关系是多重关系的。对于任何中等或大型应用,都存在大量的一对一、一对多和多对一关系。保持这些关系与服务器同步通常变得是一项繁琐的工作,尤其是在使用多个请求保存或检索数据时。
在研究这本书的过程中,我发现大多数 Backbone 开发者在学习阶段某个时候都遇到过嵌套模型和集合的问题。幸运的是,有一个名为 Backbone-relational 的优秀插件(backbonerelational.org/),由 Paul Uithol 开发,通过使用单个 save() 或 fetch() 方法同步模型及其所有相关模型,最小化了 Backbone 模型手动管理。它提供了一些很棒的特性,包括以下内容:
-
双向关系,通过事件通知相关模型的变化
-
控制关系序列化的方式
-
将模型属性中的嵌套对象自动转换为模型实例
-
轻松检索一组相关模型
-
确定
HasMany集合的类型
我们将通过一个简单的公司-员工关系示例来解释 Backbone-relational 插件的原理:
var Company = Backbone.RelationalModel.extend({
defaults: {
name: ''
},
relations: [{
// 'type' can be HasOne or HasMany
// or a direct reference to a relation
type: Backbone.HasMany,
// 'key' refer to an attribute name of the related model
key: 'employees',
relatedModel: 'Employee',
// a collection of the related models
collectionType: 'Employees',
// defines the reverse relation with this model
reverseRelation: {
key: 'worksIn',
includeInJSON: 'id'
// 'relatedModel' is automatically set to 'Company';
// the 'relationType' to 'HasOne'.
}
}]
});
var Employee = Backbone.RelationalModel.extend({
defaults: {
name: '',
worksIn: null
}
});
var Employees = Backbone.Collection.extend({
model: Employee
});
在这里,我们创建了一个公司-员工的一对多关系。在公司模型配置中,你需要定义用于创建 Backbone.Relation 实例的关系类型。type 关系属性可以是 Backbone.HasMany、Backbone.HasOne 或对特定关系实例的直接引用。你还需要指定公司模型中包含所有员工模型的属性。一旦完成基本配置,我们将定义 Employee 模型和 Employees 集合。现在让我们用一些虚拟数据来测试这个关系:
var innofied = new Company({
name: 'Innofied'
});
var john = new Employee({
name: 'John Doe',
worksIn: innofied
});
var swarnendu = new Employee({
name: 'Swarnendu De',
worksIn: innofied
});
// 'employees' in 'innofied' now contains
// 'John Doe and Swarnendu De'
alert(innofied.get('employees').pluck('name'));
我们现在已经创建了一个完全管理的关联。当你从 innofied.employees 中添加或删除模型或更新 employee.worksIn 时,关系的另一侧会自动更新。
之前提到的代码只是 Backbone-relational 模型的基本示例。一旦你阅读了他们的完整文档,你会发现该插件提供了许多可以极大地增强应用开发过程的特性。
摘要
本章讨论了围绕 Backbone 模型的一些基本问题,以及我们如何在项目中解决这些问题。我们学习了基本的数据验证,以及如何从我们的 validate 方法中收集所有错误消息。此外,我们还看到了如何使用 Backbone 验证插件通过提供许多内置特性来减少数据验证时的努力。
如果服务器发送的数据格式与模型期望的不同,我们现在知道如何覆盖parse()方法来解决这个问题。同样,我们也覆盖了toJSON()方法来改变将传递给服务器的数据格式。
对于大多数非平凡应用,嵌套模型关系是一个基本要求,Backbone-relational 插件可以为此提供一个现成的解决方案。该插件被 Backbone 社区广泛接受,并且许多项目目前正在成功使用它。
在使用模型时,有一些重要的话题需要讨论,例如集合、事件和同步。我们将在接下来的章节中分别详细讨论这些点。事件和同步功能在第六章处理事件、同步和存储中进行了详细讨论。在下一章中,我们将讨论 Backbone 集合的不同功能,基本和多种排序,过滤机制,以及具有多种模型类型的集合。
第四章:处理集合
Backbone 集合的目的非常直接。作为一个有序的模型集合,集合提供了一系列有用的方法来操作,包括一组 Underscore.js 工具方法。集合包括添加、删除、排序和过滤模型的功能,以及保存到或从服务器获取数据。集合监听其模型上触发的事件——如果集合的模型上触发了一个事件,它也会在集合本身上触发。当你想监听模型的属性更改事件时,这个功能相当重要。我们将通过本章的 集合的基本用法 部分中的示例来探讨它。
在前面的章节中,我们看到了许多简单集合的实现,用于在列表视图中显示多个项目。然而,可能存在你想要根据多个标准对列表进行排序或你想要过滤列表项以仅显示特定类型的情况。在这种情况下,你必须修改集合以重新结构化模型位置或获取符合过滤条件的数据。让我们看看在本章中我们将学习什么:
-
集合的基本用法:理解 Backbone 集合的基本用法以及与集合的数据操作
-
排序集合:集合的基本和多重排序
-
过滤集合:执行基本过滤,避免使用重复的集合重新过滤,以及使用完整数据指针进行过滤
-
包含多种模型类型的集合:当从服务器传递混合数据集且每种类型属于不同的模型时管理集合
集合的基本用法
我们将通过一个简单的示例开始研究 Backbone 集合的不同特性。假设我们有一个 User 模型和 Users 集合。
// Model definition
var User = Backbone.Model.extend({
initialize: function () {
this.on('change', function () {
console.log('User model changed!');
});
}
});
// Collection definition
var Users = Backbone.Collection.extend({
model: User,
url : '/users',
initialize: function () {
this.on('change', function () {
console.log('Users collection changed!');
});
}
});
var users = new Users(),
newUser = new User({
name: 'Jayashi De',
age: 21
});
users.add([newUser]);
// Change an attribute of the model
newUser.set('age', 22);
在前面的代码中,已经描述了简单的模型和集合定义。在这里,我们试图证明,正如我们在本章引言中提到的,任何在模型上触发的事件也会在集合上触发。当你运行此代码时,模型更改处理程序首先运行,然后是集合更改事件处理程序。
Backbone 集合提供了一组丰富的方法,以及一系列 Underscore 工具方法来操作它。由于集合处理多个数据,你会发现 Underscore 工具方法在操作它时非常有用。讨论所有这些方法和它们的函数性超出了本书的范围,但我们将看到如何使用 fetch() 和 save() 方法通过 AJAX 请求从服务器检索数据并将数据保存到服务器,在下一节中。
使用集合执行数据操作
您可以使用 AJAX 请求将数据保存到服务器并从服务器获取数据。然后需要将结果应用到集合上。然而,Backbone 通过提供一些方法,如fetch()和save(),简化了与服务器直接交互的整个过程。我们将使用之前章节中使用的相同集合来演示如何使用集合执行所有数据操作。
从服务器获取数据
从服务器获取数据相当简单。您只需在集合上调用fetch()方法,如下面的代码行所示,然后向我们在集合配置中添加的 URL 发送一个GET请求。它接收一个包含对象的JSON数组,这些对象被添加到集合中作为模型。
users.fetch();
在接收到数据后,集合的set()方法会自动被调用以更新集合。如果不存在模型,则将其添加;如果模型已存在,则最新数据与它合并;如果集合中有一个模型在新数据中不存在,则将其移除。
将数据保存到服务器
与从服务器获取数据不同,集合没有将数据作为一个整体存储到服务器的方法。相反,需要调用每个单独模型的save()方法,如下面的代码片段所示:
var user = users.get(1);
user.save();
save()方法将模型的 ID 附加到服务器的 URL(/users/1)并向该 URL 发送一个PUT请求。因此,要将整个集合保存到服务器,你需要遍历集合并对每个模型单独调用save()方法。
对集合进行排序
使用 Backbone 按多个属性对集合进行排序相当简单,因为已经内置了用于此目的的方法。要对集合进行排序,向集合中添加一个comparator,它通常是一个可以接受单个模型或两个连续模型进行比较的函数,或者它可以是指向其模型属性的字符串。每当模型被添加到集合中时,比较器就会相应地排序集合。稍后更改模型的属性不会自动启动排序功能,您需要再次在集合上调用sort()方法以重新排序。让我们看看排序集合的一个简单示例:
var User = Backbone.Model.extend();
var Users = Backbone.Collection.extend({
model: User,
comparator: 'age'
});
var users = new Users();
users.add([{
name: 'John Doe',
age: 29
}, {
name: 'Richard Smith',
age: 35
}, {
name: 'Swarnendu De',
age: 29
}, {
name: 'Emily Johnson',
age: 25
}, {
name: 'Sarah Castle',
age: 40
}, {
name: 'Ben Cooper',
age: 29
}]);
console.log(users.pluck('name')); // ["Emily Johnson", "John Doe", // "Swarnendu De", "Ben Cooper", "Richard Smith", "Sarah Castle"]
console.log(users.pluck('age')); // 25, 29, 29, 29, 35, 40
如您所见,输出是一个按年龄排序的集合。同样,我们可以通过将name作为比较器来实现字母顺序。前述功能也可以通过以下两种选项来复制:
// Underscore's sortBy() comparator
comparator: function (model) {
return model.get('age');
}
// Underscore's sort() comparator
comparator: function (model1, model2) {
return model1.get('age') < model2.get('age');
}
第一种情况很简单;它向比较器提供了一个字符串属性。第二种情况提供了两个模型(model1和model2)之间的比较。如果model1的age属性大于model2的age属性,则model1和model2将互换位置以保持升序。
按多个属性对集合进行排序
注意,在前一节提到的示例中,有三个 29 岁的值。如果我们想根据name属性对这些模型进行排序怎么办?字符串比较也可以像我们为数字做的那样进行;功能将是简单的(见以下示例):
comparator: function (model1, model2) {
// If age is same, then sort by name
if (model1.get('age') === model2.get('age')) {
return model1.get('name') > model2.get('name');
} else {
return model1.get('age') > model2.get('age');
}
}
console.log(users.pluck('name'));
console.log(users.pluck('age'));
上述代码仅按name属性对集合进行排序。如果有两个模型的年龄值相同,结果将如下所示:
["Emily Johnson", "Ben Cooper", "John Doe", "Swarnendu De", "Richard Smith", "Sarah Castle"]
[25, 29, 29, 29, 35, 40]
当集合中存在比较器并且向其中添加数据时,总会触发一个sort事件。当你专门在集合上调用sort()方法时,它也会被触发。
过滤集合
过滤集合是一个相当简单的概念;在这里,我们希望根据某些标准获取数据的一部分。例如,如果你有一个项目列表,并且你只想显示所有项目的一个子集,你可以过滤附加的集合。默认情况下,Backbone 提供了一些内置函数来处理基本过滤。where()和findWhere()方法产生类似的功能,尽管findWhere()只返回第一个符合条件的数据模型。
执行基本过滤
where()方法接受一组模型属性并返回匹配的模型数组。
var users = new Backbone.Collection([
{ name: 'John', company: 'A' },
{ name: 'Bill', company: 'B' },
{ name: 'Rick', company: 'A' }
]);
users.where({
company: 'A'
});
结果将是一个包含两个模型的数组,它们的公司是A。然而,请注意,过滤集合并不会改变原始集合数据;相反,它只是返回一个包含结果的数组。如果有一个 Backbone 视图正在将集合数据作为列表显示,过滤集合对列表没有任何影响。
那么,我们该如何解决这个问题呢?一个简单的任务可以是——让我们用过滤后的数据重置集合并重新渲染列表。以下代码将正常工作,并且集合将只包含过滤后的数据:
var filteredData = users.where({
company: 'A'
});
// Reset the collection with array with filtered data
users.reset(filteredData);
// A collection with only filtered data
console.log(users);
现在,如果你重新渲染列表,它将只显示过滤后的数据。这看起来不错;然而,如果你想要重新过滤集合,它将不会应用于完整的数据集合,而是应用于之前过滤的数据。这是错误的;建议避免可能导致后期严重问题的模式。当你只过滤一次集合时,不应该有任何问题,但如果相同的集合在其他地方也被使用,多次过滤集合肯定会引起问题。例如,以下代码将因为相同的原因返回零结果:
users.where({
company: 'B'
});
让我们尝试一些选项来找到这个问题的解决方案。我们首先尝试使用重复集合。
使用重复集合过滤集合
对于上一节中我们发现的问题,存在多种解决方案。例如,每当集合被过滤时,我们可以创建另一个集合实例,并且始终用过滤后的数据重置这个第二个集合。这样,主集合就不会被改变,将第二个集合传递给视图实例将产生期望的结果。
var filteredData = users.where({
company: 'A'
});
// Create a new collection that will only hold filtered data
var filteredCollection = new Backbone.Collection();
// Reset this collection every time
// there is a new set of filtered data
filteredCollection.reset(filteredData);
console.log(filteredCollection, users);
这次,原始集合保持其状态,而新的过滤集合提供了必要的功能。这个过程对于显示过滤后的数据集可以非常有益。这个过程的主要缺点是,你需要创建集合的另一个新实例来过滤它。
使用完整数据指针的自过滤
在应用过滤器之前保留完整数据集的引用也可以消除多次过滤的缺点。如果我们能在集合本身的一个属性中保存初始数据,然后对其应用过滤器,集合数据会发生变化,但原始数据仍然可用。因此,如果我们需要重新在集合上应用另一个过滤器,我们可以首先使用全部数据重置它,然后应用新的过滤器。为了通过示例理解这个概念,我们将定义一个自定义的FilterCollection类:
var FilterCollection = Backbone.Collection.extend({
_totalData: [],
_isFiltered: false,
initialize: function (data) {
// The initial data sent to collection will be saved
if (data) {
this._setTotalData(data);
}
// If some data is added later,
// that should reflect in _totalData
this.on('add', function () {
this._setTotalData();
}, this);
},
// Every time a new data has been added to the collection
_setTotalData: function (data) {
this._totalData = data || this.toJSON();
},
// Apply a new filter to the collection
applyFilter: function (criteria) {
// Clear the previous filter
this.clearFilter();
// Apply new filter
this.reset(this.where(criteria));
// Mark this as filtered
this._isFiltered = true;
},
// Clear all filters applied to this collection
clearFilter: function () {
// skip first reset event while the collection
// has the original data
if (this._isFiltered) {
// Reset the collection with complete data set
this.reset(this._totalData);
this._isFiltered = false;
}
}
});
我们创建一个自定义的Collection类,它有一个_totalData属性,这个属性应该包含该集合的全部数据。在initialize方法中,我们检查是否已向集合传递了任何数据;如果有,我们将该数据保存到这个变量中。我们还包括一个add事件监听器,以便新添加的数据能够得到反映。
现在,一旦你在集合上调用applyFilter()方法,它首先使用全部数据重置集合,然后在这个集合上应用过滤器。这样,每次你使用这个方法过滤集合时,你都不必担心它是否被应用到之前过滤过的集合上。让我们通过一个测试用例来分析这个功能:
var filteredCollection = new FilterCollection ([
{ name: 'John', company: 'A' },
{ name: 'Bill', company: 'B' },
{ name: 'Rick', company: 'A' }
]);
// Add another data to check whether add event is working or not
filteredCollection.add({
name: 'John',
company: 'C'
});
// Filter with company
filteredCollection.applyFilter({
company: 'A'
});
// Filter with name
filteredCollection.applyFilter({
name: 'John'
});
console.log(filteredCollection);
// Shows two data both with name : 'John'
之前,在第二次过滤后,你只收到一组数据,因为集合已被过滤两次,返回的模型名称为John和公司A。但现在,因为每次过滤之前集合都会被刷新,所以你会得到正确的结果。
之前的代码尚未准备好投入生产,你需要使其更加复杂,以便_totalData始终包含最新的数据。无论如何,这种模式在某些情况下很有用,保持一个可过滤的集合扩展或Filterable混入可以立即解决问题。
理解多模型类型集合
有时候,我们从服务器接收到的数据是混合的,我们需要将完整的数据放入单个集合中。例如,假设服务器正在发送一家公司的完整员工详细信息。现在,有不同类型的员工——开发者、经理、设计师等等,你希望为这些中的每一个都有不同的模型类型。集合应该如何将所有类型的模型放在一起?以下是一个可以让你获得所需功能的例子:
var Employee = Backbone.Model.extend();
var Developer = Employee.extend();
var Manager = Employee.extend();
var Employees = Backbone.Collection.extend({
url: 'employees.json',
model: function (attrs, options) {
// For each data, check the attribute type
switch (attrs.type) {
case "Developer":
return new Developer(attrs, options);
break;
case "Manager":
return new Manager(attrs, options);
break;
}
}
});
var employees = new Employees();
employees.fetch();
console.log(employees);
集合请求的模型要么是模型本身,要么是每次向集合添加数据时创建的该模型的实例,或者可以包含一个函数,数据属性会被传递给它,你可以根据数据检查相关模型并传递其一个实例。在这里,在前面的例子中,我们做了同样的事情,根据数据属性的类型返回了不同的模型。
摘要
与集合一起工作是 Backbone 的基本要求,与集合相关的问题也与模型相关。例如,几乎每个开发者都会遇到的最常见问题是嵌套集合,而解决这个问题与模型内部数据解析的方式有关。我们在第三章与模型一起工作中讨论了关系数据插件,它巧妙地解决了嵌套模型和集合的问题。强烈建议在处理任何此类数据关系时使用此插件。
本章讨论了如何对集合进行排序和过滤。通过示例描述了简单的排序和多个排序过程。我们还看到了一些过滤集合的方法,这些方法在不同情况下可能很有用。
集合可以在内部持有不同类型的模型数据——这个解决方案通过一个例子进行了描述。一般来说,任何数据集都需要一些实用方法来处理。大量 Underscore.js 实用方法使得我们处理 Backbone 集合变得更加容易。
在下一章中,我们将讨论 Backbone 路由器的必要性以及为什么在简单应用中使用多个子路由器是有益的。
第五章. 路由最佳实践和子路由
路由器是 Backbone 中最有用的对象之一;它主要用于使用哈希片段或标准 URL 路由应用程序 URL。在 Backbone 的早期版本中,Backbone.Controller被用来处理路由以及默认控制器任务,而不是Backbone.Router。后来,它被改为Backbone.Router,因为路由器旨在仅处理路由客户端页面并将它们通过 URL 连接到事件和动作,而功能逻辑必须由演示者(即 Backbone 视图)负责。路由器的概念相当简单——它将方法名称与 URL 片段匹配并调用该方法。然后,该方法负责所需的事件和动作。
在本章中,我们将学习一些最佳实践,例如如何为中型和大型应用程序组织路由器,以及哪些类型的任务应由路由器处理。主要涵盖的主题如下:
-
与路由器一起工作:这提供了一个基本示例,说明了路由器的工作原理,以及在使用路由器时可能出现的错误分析。
-
与路由器一起工作的最佳实践:我们将探讨一些在使用路由器时应遵循的良好实践。
-
子路由——组织复杂应用程序的关键:一旦应用程序增长,维护单个路由器变成了一项艰巨的任务。将应用程序路由器划分为多个子路由是一种更可取的管理方式。
与路由器一起工作
Backbone 路由器提供方法,通过使用哈希片段或标准 URL(根据历史 API)来路由客户端页面。使用哈希片段或历史 API 的路由可能看起来像这样:
// Hash fragment
http://www.foo.com/#user/23
// Standard URL
http://www.foo.com/user/23
当 URL 片段与特定路由匹配时将触发路由和动作,这些路由和动作在路由器的routes对象中定义:
routes: {
'users' : 'showUsers',
'user/:id' : 'showUserDetails',
'user/:id/update' : 'updateUser'
}
现在,让我们看看如何创建一个基本的路由器。假设我们正在开发一个包含几个模块的应用程序,其中User模块是一个重要的模块。
var AppRouter = Backbone.Router.extend({
routes: {
'users': 'showUsers',
'user/:id': 'showUserDetails',
'user/:id/update': 'updateUser',
'user/:id/remove': 'removeUser'
},
showUsers: function () {
// Get all the user details from server and
// show the users view
},
showUserDetails: function (userId) {
// Get the user details for the user id as received
},
updateUser: function (userId) {},
removeUser: function (userId) {}
});
非常简单!有许多选项可以修改路由以获得预期的结果。例如,您可以在路由中使用“splat”或可选部分;有关路由的详细概述,请参阅 Backbone.js API backbonejs.org/#Router。
在前面的示例中,所有的显示方法(showUsers()和showUserDetails())很可能会创建视图实例,向服务器发送 AJAX 请求以获取其详细信息,然后在 DOM 中显示视图。同样,更新和删除方法也会向服务器发送请求以执行所需操作。现在,假设User模块在这个路由器中还有其他多个方法,除了这些 CRUD 方法。此外,整个应用程序还有许多类似的模块,其路由也将添加到这个路由器中。结果,路由器很快就会变得庞大,有数百行代码;这是超出我们控制范围的事情。
在与路由器一起工作时,我们应该注意一些事项,以避免出现此类情况。在下一节中,我们将探讨一些良好的实践,这些实践将使我们的路由器变得简单、灵活且易于维护。
与路由器一起工作的最佳实践
当应用程序规模较小时,使用骨干路由器相对容易。然而,随着复杂性的增加,除非遵循某些规则,否则维护路由器会变得困难。在下一节中,我们将讨论在使用路由器时应注意的一些要点。
避免在路由方法中使用大量功能代码
尽管路由器的基本任务是监控路由并触发函数,但它也管理应用程序的一些业务逻辑。在 MVC 架构中,控制器的任务是处理客户端发送的数据请求,并处理从服务器响应中来的数据。同样,对于路由器,由于 URL 片段反映了应用程序数据的一部分,数据通信、调用视图方法或更新模型属性都是通过路由器方法完成的。
我在初级开发者代码中经常看到的一个趋势是,他们经常在路由器方法中包含大量功能代码。一方面,这增加了路由器的大小,另一方面,它也复杂化了逻辑。始终建议尽可能保持路由器方法尽可能短,通过将功能逻辑推送到视图并使用事件而不是回调来实现。在下一节中,我们将看到如何保持我们的路由器整洁。此外,我们将在第六章“与事件、存储和同步一起工作”中更详细地探讨事件委托、自定义事件和回调方法。
在路由器方法中实例化视图
我见过许多开发者将应用程序视图实例化在路由器方法中。虽然在这种情况下在路由器方法中实例化视图或修改 DOM 元素没有限制,但避免在路由器中执行此类操作是一种良好的实践。这与我在本节中提到的第一个要点有些相关。在下面的代码中,我们实例化了一个UserView类,并在路由器方法中将其渲染到 DOM 中:
Backbone.Router.extend({
routes: {
"users": "showUsers"
},
showUsers: function () {
var usersView = new UsersView();
usersView.render();
$("#user_list").html(usersView.el);
}
});
这看起来很简单,并且工作得完美。但是,如果有 20 个或更多这样的方法,它不会使路由器变得杂乱吗?为什么不创建一个控制器或高级应用程序对象,并将方法添加到那里?然后你可以从路由器方法中调用这个控制器方法,如下所示:
Backbone.Router.extend({
routes: {
"users": "showUsers"
},
showUsers: function() {
UserController.showUsers();
}
});
var UserController = {
showUsers: function() {
var usersView = new UsersView();
usersView.render();
$("#user_list").html(usersView.el);
}
}
现在对showUsers()方法功能性的任何更改都不会强迫你接触路由器。实际上,并没有太大的可见差异——但就我个人而言,因为我已经多次使用第二种模式并从中受益,我可以向你保证,关注点的分离将产生一个更干净的路由器,以及可维护的代码库。
此外,在此上下文中,我们建议你查看Marionette.AppRouter (github.com/marionettejs/backbone.marionette/blob/master/docs/marionette.approuter.md) 和 Marionette.Controller (github.com/marionettejs/backbone.marionette/blob/master/docs/marionette.controller.md)。Marionette 的AppRouter和Controller与我们的UserController和基础路由器工作方式相同。控制器实际上执行工作(例如组装数据、实例化视图、在区域中显示它们)并可以更新 URL 以反映应用程序的状态(例如,显示的内容)。路由器只是根据在地址栏中输入的 URL 触发控制器动作。这两个类都非常有用,如果需要,你可以选择其中的任何一个。
使用正则表达式进行选择性路由
如果你只想在特定条件匹配时触发路由器方法,正则表达式就能派上用场。正则表达式在路由方面非常灵活,Backbone 完全支持它们。实际上,当它们被添加到路由表时,所有的路由首先都会被转换为RegExp对象。
然而,与其它字符串 URL 片段不同,JavaScript 不允许你将正则表达式作为routes对象的属性添加:
// This will give error
routes : {
/^user\/(\d+)/ : 'showUserDetails'
}
解决方案是在路由器的initialize()方法中的routes对象中添加正则表达式:
initialize: function () {
this.route(/^user\/(\d+)/, 'showUserDetails');
},
showUserDetails: function (id) {
console.log(id);
}
这是一个例子,其中在#user之后的 URL 片段中只允许使用数字。如果你尝试打开以#user/abc结尾的 URL,showUserDetails()方法将不会被调用,但使用 URL 片段#user/123时,将触发相同的方法。因此,这是一个使用正则表达式作为路由的非常通用的例子。正则表达式对于更复杂的 URL 片段级别非常有用,以便提供一种限制级别。
子路由——组织复杂应用程序的关键
子路由是将应用程序的路由器划分为多个特定模块路由器的想法。在小型或中型应用程序中,你可能永远不需要这样的东西。然而,对于具有多个模块的应用程序,处理所有路由的单个路由器很快就会变成一个庞大且难以管理的类。因此,总是将主路由器拆分为一组特定模块的路由器更为可取。
Backbone.Subroute (github.com/ModelN/backbone.subroute),由 Dave Cadwallader 开发的一个出色的扩展,提供了我们所讨论的功能。它允许基础路由器将所有特定模块的路由委托给与该模块关联的子路由器。让我们通过以下两个示例来了解路由器和子路由器之间的区别。
一体化路由器
以下是为处理应用程序中所有模块路由的单个路由器编写的代码:
var App = {};
var App.BaseRouter = Backbone.Router.extend({
routes: {
// Generic routes
'': 'showHome',
'logout': 'doLogout',
// User specific routes
'users/view/:id': 'showUserDetails',
'users/search': 'searchUsers',
// Company specific routes
'company/:id': 'showCompanyDetails',
'company/users': 'showCompanyDetails'
},
showHome: function () {},
doLogout: function () {},
showUserDetails: function () {},
searchUsers: function () {},
showCompanyDetails: function () {},
showCompanyDetails: function () {},
});
现在,让我们使用 Backbone.Subroute 来看看如何定义基础路由器和特定模块路由器。使用 Subroute,基础路由器变成了一个微小的路由器,它只负责路由重定向。
基础路由器
以下是为基础路由器编写的代码:
var App.BaseRouter = Backbone.Router.extend({
routes: {
// Generic routes
'': 'showHome',
'logout': 'doLogout',
// Route all users related routes to users subroute
'users/*subroute': 'redirectToUsersModule',
// Route all company related routes to company subroute
'company/*subroute': 'redirectToCompanyModule'
},
showHome: function () {},
doLogout: function () {},
redirectToUsersModule: function () {
if (!App.usersRouter) {
App.usersRouter = new App.UsersRouter('/users');
}
},
redirectToCompanyModule: function () {
if (!App.companyRouter) {
App.companyRouter = new App.CompanyRouter('/company');
}
},
});
Users 模块路由器
以下是为 Users 模块路由器编写的代码:
var App.UsersRouter = Backbone.SubRoute.extend({
routes: {
'': 'showUsers',
'view/:id': 'showUserDetails',
'search': 'searchUsers'
},
showUsers: function () {},
showUserDetails: function () {},
searchUsers: function () {}
});
看看基础路由器。我们在这里做的事情非常简单——我们使用通配符(或 splat)来触发一个懒加载实例化子路由器并传递哈希的初始参数。现在,因为 Backbone.Subroute 扩展了 Backbone 的 Router 类,我们可以预期在 /users/ 或 /company/ 之后传递的任何内容都应该由相应的子路由器处理,即 App.UsersRouter 或 App.CompanyRouter。
我们可以添加任意多的子路由器,而基础路由器不会关心它们。同样,子路由器也不知道它们的前缀是什么。模块名称或前缀的任何更改都应该仅在基础路由器中进行,而无需修改相关的子路由器。
Backbone.Subroute 是一个小巧而优秀的插件,你应该始终将其包含在你的应用程序中,以保持你的基础路由器整洁。随着你的应用程序中模块的增加,你会越来越理解子路由器的优势。
摘要
Backbone 路由器的功能非常简单且易于学习。对于简单的应用程序,你将不会在维护它时遇到任何问题。一旦你的应用程序增长并且路由器变得庞大,问题就会逐渐出现。在本章中,我们讨论了路由器管理的最佳实践,那些你应该始终遵守的。我们还学习了子路由,它通过将主路由器拆分为多个特定模块的路由器并分配任务来提供帮助。
在下一章中,我们将讨论 Backbone 事件、自定义事件、存储和同步。
第六章. 与事件、同步和存储一起工作
在前面的章节中,我们分别详细讨论了每个 Backbone 组件(视图、模型、集合和路由器)。在本章中,我们将讨论自定义事件、Backbone.sync() 方法以及 Backbone.LocalStorage。尽管这些主题之间并不完全相关,但我们将它们放在一个章节中,因为我们需要在进入下一章的应用程序架构和模式之前涵盖它们。
事件始终被认为是 JavaScript 中最强大的概念之一。它们是观察者模式(一个著名的松散耦合设计模式)的表示,并被大多数 JavaScript 库使用。在 Backbone 中,Backbone.Events 是一个非平凡的模块,可以与任何对象一起使用以具有事件相关功能。这是在 Backbone 文档中定义 Backbone.Events 的方式(backbonejs.org/#Events):
事件是一个可以混合到任何对象中的模块,它赋予对象绑定和触发自定义命名事件的能力。
在本章中,我们将讨论为什么事件对于 Backbone 应用程序开发很重要,以及我们如何使用它们来实现更高的重用性和更结构化的应用程序架构。本章将涵盖的主要内容包括:
-
自定义事件:自定义事件是由应用程序为了某个目的而初始化的,这个目的不是我们使用的基库所服务的。我们将学习如何在 Backbone 中创建和使用自定义事件。
-
事件分发器:有时,我们寻求一个应用级的事件管理器,它可以作为一个基于事件通信的集中工具。应用程序的不同组件可以通过这个事件管理器相互交互,而无需直接相互通信。在本节中,我们将学习如何使用我们的应用程序创建和使用这样的事件分发器。
-
方法覆盖:在本主题中,我们将学习如何通过覆盖 Backbone 的
sync()方法来创建针对公共 REST API 或LocalStorage的不同持久化策略。 -
离线存储:
Backbone.LocalStorage适配器可以与任何 Backbone 模型或集合一起使用,将数据保存到LocalStorage数据库中。
理解自定义事件
在 JavaScript 中,创建和使用自定义事件并不是什么大问题——所有主要的 JavaScript 库都严重依赖于它们自己的事件来实现组件的松散耦合。每个组件都有一组自定义事件,以实现更好的重用性和与应用程序的集成。
在 Backbone 中创建一个自定义事件相当简单——任何扩展了 Backbone.Events 类的对象都会获得所有与事件相关的功能,即监听、触发和删除事件。Backbone 的 View、Model、Collection 和 Router 是扩展了 Backbone.Events 类的主要组件,并且当需要时,你可以在它们中的任何一个上触发一个自定义事件:
var myView = new Backbone.View();
myView.on('myevent', function () {
console.log('"myevent" is fired');
});
myView.trigger('myevent');
在这里,我们创建了一个 Backbone 视图实例,向其注册一个自定义事件,并触发该事件。一旦事件被触发,注册的函数就会立即按预期运行。
小贴士
避免回调,使用自定义事件
这个标题并不意味着你应该总是使用事件而不是回调方法。这取决于程序员想要实现的目标,这绝对是一个架构选择。我们打算讨论这个点,以便理解在哪些情况下事件比回调提供了更多的灵活性。每当有需要通知他人关于任何任务特定条件的情况时,你会发现自定义事件比回调函数是一个更好的选择。入门级开发者常常陷入这个陷阱,最终使用回调方法,这通常提供了一种更私密和隔离的方法。
在接下来的部分,我们将通过一个简单的示例来展示自定义事件必要性的例子。
自定义事件的一个简单案例研究
假设我们有一个 登录 对话框,一旦用户输入用户名-密码组合并点击 提交 按钮,就会向服务器发送一个请求来验证登录。验证成功后,用户关闭对话框并执行一些其他任务,例如存储 cookies、更改页面标题等。在这种情况下,我们通常使用回调方法,并将所有登录后的功能放在其中。如果登录成功后需要调用 10 个不同的函数,每个函数在单独的对象上执行,那会怎样?你可能需要在那个回调方法中包含所有这些对象引用来逐个调用方法。虽然这可以无问题地实现,但为什么不在用户登录成功后只触发一个自定义事件 loggedin 呢?已经监听 loggedin 事件的 10 个对象就可以相应地调用相关方法:
var Login = Backbone.View.extend({
'click #login-btn': 'doLogin',
doLogin: function () {
var me = this;
// send a login request
$.ajax({
url: '/login',
method: 'POST',
data: {
username: 'foo',
password: 'foo'
},
success: function (response) {
me.trigger('loggedin', response);
}
});
}
});
var loginView = new Login();
login.doLogin();
需要执行那些登录后任务的其它组件应该已经监听了那个 loginView 的 loggedin 事件,并且回调函数将在事件触发时立即执行:
// in some other component
loginView.on('loggedin', function(response){
// Do something
});
在触发事件时,你也可以传递一些参数;这些数据将在事件回调方法中以参数的形式可用:
// Pass multiple data
me.trigger('loggedin', response, 'foo', 'bar');
// All the passed params will be available as function arguments
loginView.on('loggedin', function(response, foo, bar){
console.log(response, foo, bar);
});
使用事件分发器
注意上一个场景中的一个细节:要监听 loggedin 事件,应用程序的所有其他组件都应该有对该 loginView 的引用。这真的是必需的吗?对于一些组件来说,保持对 loginView 的引用可能看起来无关紧要,但它们必须这样做,因为它们需要在这个 loginView 对象上监听 loggedin 事件。即使在开发简单应用程序时,这种依赖注入也可能很痛苦。有时我们可能需要一个充当中央事件管理器的通用对象,并且可以在整个应用程序中用来触发和监听事件。最简单的事件分发器可以按以下方式定义:
var vent = _.extend({}, Backbone.Events);
// Listen to a custom event
vent.on('customevent', function(){
console.log('Custom event fired');
});
// Fire the event
vent.trigger('customevent');
当我们将 vent 变量在应用程序级别公开时,它可以作为一个中央事件分发器来发布和订阅事件。这种模式被称为 Pub/Sub 模式,在基于模块或小部件的应用程序架构中使用时非常有益。
小贴士
您应该理解在什么情况下这将是一个好的选择。当您有太多组件需要监听(正如我们在登录示例中看到的那样)或者当您有一些完全无关的对象需要相互通信时,应使用事件分发器。
在使用通用事件分发器工作时,您可能会遇到的主要问题是,当通过单个事件分发器注册了太多事件时,发布者和订阅者的数量可能会失去控制。例如,假设我们有两个模块,User 和 Company,并且这两个模块分别订阅了事件分发器上的名为 addcomment 的事件;它们被定义为如下:
vent.on('addcomment', user.addComment);
vent.on('addcomment', company.addComment);
注意,事件名称相同,但要调用的函数不同。因此,如果您想通知另一个订阅者同一事件,您需要首先清除该事件的全部其他订阅者,然后发布该事件。然而,还有一些其他简单的解决方案可以解决这个问题,例如创建多个事件分发器或使用不同的事件命名空间。
创建多个事件分发器
为单个模块或功能定义单独的事件分发器可以为我们之前描述的问题提供一个解决方案:
App.userVent = _.extend({}, Backbone.Events);
App.documentVent = extend({}, Backbone.Events);
现在,不同分发器的相同事件名称永远不会冲突:
App.userVent.on('addcomment', user.addComment);
App.documentVent.on('addcomment', company.addComment);
使用不同的事件命名空间
这就像在自定义事件中使用特定的命名约定一样简单:
App.vent.trigger('before:login');
App.vent.trigger('after:login');
App.vent.trigger('user:add:comment');
在事件名称中添加冒号并不会使事件变得特殊,但它确实使其不同且独特,因为它现在与应用程序的某些特定模块相关联。这是 JavaScript 开发者广泛使用的一种约定,我们鼓励您在需要时使用它。
使用 listenTo() 方法避免内存泄漏
内存管理是任何应用程序都非常重要的一部分。通常,对于前端开发,开发者不会太在意内存泄漏;然而,当我们开发单页前端密集型应用时,这一点并不成立。这类应用处理许多前端组件和最少的页面刷新次数,这可能会为内存泄漏创造几个机会。在开发这类应用时,我们应该始终小心地清理对象销毁时的事件。为了通过例子来理解这一点,假设我们有一个显示其模型数据的视图。每当在该模型上触发change事件时,视图的render()方法就会被调用:
// Memory leak
var MyView = Backbone.View.extend({
tpl: '<%= name %>',
model: new Backbone.Model({
name: 'Suramya'
}),
initialize: function () {
this.model.on('change', this.render, this);
},
render: function () {
var html = _.template(this.tpl, this.model.toJSON());
this.$el.html(html);
return this;
}
});
var myView = new MyView();
$(document.body).append(myView.render().el);
myView.model.set('name', 'Arup');
myView.remove();
现在,正如你所看到的,我们在模型上注册了一个change事件,以便每当其任何属性发生变化时,render方法就会被调用。然后我们创建了一个视图实例,更改了模型的name属性,并销毁了视图。remove()方法销毁了view实例,并将视图从 DOM 中移除。
整个过程按预期工作,尽管存在一个小问题。当你用 JavaScript 创建一个视图时,你创建了 DOM 节点并将事件监听器绑定到它们上。当你从 DOM 中移除节点时,它们的监听器仍然持有对它们的引用。结果,你的 JavaScript 引擎不会自动回收这些节点,因为作用域中仍然有对它们的引用。在我们的情况下,即使视图被销毁,模型上的change事件监听器仍然存在,我们需要明确处理它。我们如何做到这一点?我们可以在视图中添加一个close()方法,并在销毁视图之前在这个方法中解绑所有此类事件:
close: function () {
this.model.off('change', this.render, this);
this.remove();
}
现在一切都被正确清理了。但我们是否需要为所有视图都这样做?不,因为 Backbone V9.9 引入了一个listenTo()方法,它告诉一个对象去监听另一个对象的某个事件:
this.listenTo(this.model, 'change', this.render);
这与on()方法的工作方式完全相同,但它的优势在于它允许对象跟踪事件,并且可以在稍后一次性移除它们。因此,我们不需要额外的close()方法来在销毁视图之前解绑所有事件。相反,Backbone 视图的remove()方法现在会通过调用stopListening()方法来清理任何已绑定的事件。
因此,当你想自己处理处理器,并且不会出现事件清理或僵尸处理等场景时,请使用on()方法。否则,选择listenTo(),我们将在 Backbone 视图的上下文中发现它非常有用。
覆盖 Backbone 的 sync()方法
Backbone 为所有数据通信提供了一个单一的网关。所有的数据请求都是通过 sync() 方法发送的,每当进行任何 CRUD 操作时都会调用这个方法。这个 sync() 方法执行多项任务,例如设置 URL、参数和内容类型,以及模拟不支持 PUT 和 DELETE 请求的老式浏览器的 HTTP 请求。每当我们在模型或集合上调用 fetch() 或 save() 方法时,都会执行 sync() 方法。
但我们何时需要覆盖这个方法呢?有时,你可能需要一个单独的 REST API 方法的实现,而 Backbone 并不提供。这可能适用于某个模型或集合,或者这种实现可能在整个项目中持续存在。这就是 Backbone 默认如何编写方法映射的方式:
var methodMap = {
'create': 'POST',
'update': 'PUT',
'patch': 'PATCH',
'delete': 'DELETE',
'read': 'GET'
};
现在,你可能有一个特定的模型或集合,它将监听除默认之外的其他 API,比如 Google 或 Twitter API,而你无法更改它。或者,你可能想实现一个使用浏览器本地存储来操作数据的离线存储。在这种情况下,你需要覆盖该集合或模型的 sync() 方法,或者如果它在整个应用程序中是通用的,你需要覆盖 Backbone.sync() 方法。让我们通过一个例子来理解其重要性。在这里,我们希望我们的 User 模块直接与公共 API FooApi 交互:
// FooApi is a public api with 'add', 'edit', 'read'
// and 'delete' methods
var User = Backbone.Model.extend({
sync: function (method, model, options) {
options || (options = {});
switch (method) {
case 'create':
FooApi.add(options.data);
break;
case 'update':
FooApi.edit(options.data);
break;
case 'delete':
FooApi.delete({
id: options.data.id
});
break;
case 'read':
FooApi.read({
id: options.data.id
});
break;
}
// Other stuff
}
});
var user = new User({
name: 'Manali',
age: 29
});
// This will call FooApi.add() method
user.save();
看看我们是怎样使用 switch case 来调用每个数据操作方法的 FooApi 方法。现在,当你对用户实例调用 save() 方法时,它将直接调用 FooApi.add() 方法。你可以用类似的方式使用其他数据操作。所以,这就是 Backbone 的 sync() 方法被覆盖以创建一个映射其他 API 的模型和方法包装器的方式。
使用 Backbone.LocalStorage 适配器的离线存储
在上一节中,我们看到了如何通过覆盖 Backbone.sync() 方法来提供定制的数据操作,包括模型和集合。大多数时候,我们使用 HTML5 的 LocalStorage 功能在浏览器中存储我们的数据以供离线浏览。这对于开发移动网站和移动网络应用时存储小数据来说是一个相当常见的需求。LocalStorage 通信也可以通过使用 sync() 方法以与上一节中通过覆盖 sync() 方法所用的相同技术来完成。
我们不会自己创建这个解决方案,而是会查看一个优秀的适配器,Backbone.LocalStorage (documentup.com/jeromegn/backbone.localStorage),这是由 Jerome Gravel-Niquet 开发的,并被 Backbone.js 开发者社区广泛用于与 LocalStorage 交互。这个适配器可以插入到任何模型或集合中,从而使得它们可以使用 save() 或 fetch() 方法与 LocalStorage 通信,如下所示:
var Users = Backbone.Collection.extend({
model: Backbone.Model,
localStorage: new Backbone.LocalStorage("users")
});
var users = new Users();
// Add items to collection
users.add([{
name: 'Soumendu De'
}, {
name: 'Bikash Debroy'
}])
// Sync collection data to localstorage
users.each(function (user) {
user.save();
});
在这里,我们通过将 Backbone.LocalStorage 的实例传递给 LocalStorage 属性来定义一个集合。这是将集合附加到 LocalStorage 并在其上执行所有数据操作所需的唯一配置。此外,此配置适用于模型和集合。如果你想了解此适配器中 sync() 功能是如何实现的,请查看适配器代码——它很小,而且写得相当不错。
另一个流行的 LocalStorage 适配器是 Backbone.dualStorage (github.com/nilbus/Backbone.dualStorage)。网上有相当多的适配器可供选择,并且它们都提供了类似的功能。因此,如果你想跟随我们未提及的工具,你完全自由地这样做。
摘要
JavaScript 中的事件是其中最有趣的概念之一;关于这个主题有很多文章和书籍。我们并没有在本章中尝试查看所有细节,但我们分析了在 Backbone 应用程序中使用自定义事件和事件分发器如何为应用架构提供巨大的灵活性和可扩展性。我们鼓励你探索 JavaScript 事件、函数作用域和事件分发器或 PubSub 模式,如果你需要更详细的想法的话。
在本章中,我们也学习了 Backbone 的 sync() 方法以及我们如何覆盖 sync() 方法以获取用于公共 API 或 HTML5 LocalStorage 的自定义数据操作。
此外,我们还研究了 Backbone 的各种组件,讨论了它们的最佳实践,以及与它们相关的几个插件和扩展,以及一些常见问题。在下一章中,我们将看到如何使用不同的设计模式和架构来组织 Backbone 应用程序。
第七章. 组织 Backbone 应用程序 – 结构、优化和部署
在本书的前几章中,我们探讨了 Backbone.js 的各个组件,并学习了几个有助于创建更好应用程序的良好实践。然而,Backbone 本身并不提供任何应用程序结构或关于如何组织应用程序源代码的指导。这使得初级程序员很难理解如何创建文件夹结构、添加适当的命名空间、按适当顺序加载脚本文件,并遵循模式创建健壮的应用程序架构。
几乎每个 Backbone.js 开发者,在某个时刻都会遇到这个问题。您可以在网络上找到许多文章(检查附录 A 中的应用程序架构博客链接,书籍、教程和参考资料),其中开发者们描述了他们如何尝试结构化他们的 Backbone 代码库。但这又使得任务变得困难,因为您可能需要从众多不同意见中选择一个特定的解决方案,并理解这是否是最佳解决方案。在本章中,我们将逐步探讨如何为小型和大型应用程序组织结构的过程。
-
应用程序目录结构:代码组织对于开始开发非平凡 JavaScript 应用程序至关重要。本节展示了一个样板目录结构,可能有助于您概念化应用程序结构。
-
异步模块定义:与在 HTML 文件中堆叠多个 JavaScript 文件不同,异步模块定义(AMD)以微妙的方式帮助定义模块并异步加载其依赖项。
-
应用程序架构:本节提供了一个完整的逐步指南,介绍您在应用程序架构中应遵循的模式和最佳实践,以使其灵活且易于维护。
理解应用程序目录结构
在文件系统中进行代码组织在应用程序开发中扮演着重要角色。它为以下问题提供了解决方案:
-
管理视图、模型、集合和路由的关注点分离
-
定义应用程序的清晰入口点
-
正确的命名空间
我们将要提出的目录结构并不是适用于每个应用程序的通用解决方案。由于 JavaScript 没有提供固有的代码组织机制,没有一种适用于所有应用程序的最佳模式;它完全取决于具体情况。您可以使用以下结构,许多开发者都在他们的项目中使用它而没有问题:

我们将所有静态资源都保存在assets文件夹内。如果您有其他类型的静态资源,可以添加更多文件夹。模板存储在一个单独的目录中,与views文件夹结构相匹配。我们将根据需要动态加载这些模板,并在之后对其进行优化,以创建包含所有模板的单个文件(有关更多详细信息,请参阅附录 C,使用 AMD 和 Require.js 组织模板)。main.js文件是应用的入口点。在下一节讨论使用 AMD 时,您将看到它的用法。app.js文件包含作为应用最高父类的应用类。所有如utility.js或helper.js之类的实用文件,主要包含辅助方法,都位于util文件夹中。test文件夹是存储所有测试脚本的主体目录。config和mixin文件夹分别用于存储配置和可重用混合文件。这种文件结构是基本的,可以作为您应用的模板。
最近,对于大型和复杂的应用程序,另一种模式变得流行起来——模块化方法。在这种情况下,我们将完整的应用程序划分为多个小型模块;每个模块将为应用添加特定的功能。我们将在本章的后面部分探讨它,但我们可以在这里讨论这种模式的文件结构。一个模块包括它自己的视图、模型和集合。您可以每个模块有一个templates文件夹,并将该模块的模板分别放置在该文件夹中,或者您可以将它们作为一个单独的templates文件夹保留在完整项目中。我们将选择后者;app文件夹将类似于以下截图:

如您所见,这里没有单独的models、collections或views文件夹;相反,有一个包含应用所有模块的modules目录。每个模块都包含一个main.js文件,该文件作为该模块的起点。
现在,您将如何从使用这种模块化模式中受益呢?实际上,这不仅仅是对目录结构的改变,而是一种全新的应用架构。我们注意到,对于不太熟悉模块模式的初级开发者来说,从这种结构开始可能会觉得有些困难——可能是因为这是一个新概念。然而,一旦您开始使用它,您会发现它相当容易操作,并且也很灵活。使用这种结构的优点如下:
-
模块通常相互独立。因此,您可以在其他地方重新使用一个模块,只需进行最小限度的修改。
-
模块通常不会直接相互通信;它们使用一个公共媒介进行通信。因此,您可以在其他模块保持不变的情况下更改或删除一个模块。
-
随着每个模块封装其功能,您的代码库变得更加模块化和灵活。例如,一个
User模块执行所有与用户相关的功能;应用程序的任何其他部分都不会处理任何与用户相关的任务。
之前的结构并不是使应用程序模块化的唯一方式。还有其他几个概念,您可以根据需求选择其中任何一个。例如,我在使用这个框架时经常使用 AuraJS 目录结构(来自github.com/aurajs/todomvc的TodoMVC应用程序)。它既相似又不同,且很有用。所以,如果您了解多个这样的目录结构但不知道选择哪一个,就选择我们之前提到的那个。遵循标准结构没有坏处;这比选择一个无结构的项目目录要好。
使用异步模块定义(AMD)进行工作
到目前为止,我们已经学会了在 HTML 文件中的SCRIPT标签内添加所有我们的脚本文件。浏览器会同步加载这些文件,因此我们始终需要确保如果一个文件依赖于另一个文件,那么后者应该始终在前者之前加载。由于所有这些依赖项的引用都是通过全局变量进行的,因此这些依赖项必须按正确的顺序加载,并且开发者在添加新的脚本文件到应用程序之前必须注意它们。尽管这个过程运行得很好,但随着依赖项数量的增加,管理大型应用程序可能会变得困难。AMD 提供了解决这个问题的方案。
AMD 是一种机制,用于定义一个模块,使得模块及其依赖项可以异步加载。因此,可以并行加载多个 AMD 模块,一旦最后一个依赖模块加载完成,主模块就会执行。此外,AMD 通过封装模块定义来避免使用全局变量,并提供了一种将多个模块加载到单个文件中的方法,从而消除了显式命名空间的需求。
目前,支持 AMD 的最流行的脚本加载器是 Require.js (requirejs.org)。它提供了模块模式的实现,并允许我们使用其 map 配置创建一个集中管理的依赖映射。详细讨论 Require.js 超出了本章的范围。因此,如果您想全面了解这个概念,我们建议您首先访问他们的网站,然后再继续阅读以下部分。
将 Require.js 添加到您的项目中
当require.js加载您应用程序的所有模块时,它是您需要在index.html文件中包含的唯一文件。在HEAD标签内添加以下脚本标签:
<script data-main="app/main" src="img/require.js"></script>
data-main 属性指定了作为应用程序起点的 JavaScript 文件。在这种情况下,它是我们的 main.js 文件。一旦 require.js 文件被加载,它会查找 data-main 属性的入口点并加载该脚本。我们将把整个 require.js 配置以及所有库及其依赖项添加到该文件中。你不需要为任何文件添加 .js 扩展名,因为 RequireJS 会自动添加。
配置依赖项
我们将把所有库文件及其路径和依赖项添加到 main.js 文件中:
// File: app/main.js
require.config({
baseUrl: 'libs',
paths: {
jquery: 'jquery',
underscore: 'underscore/underscore',
backbone: 'backbone/backbone'
},
shim: {
// We assume the backbone file here is a non-AMD file
backbone: {
exports: 'Backbone',
deps: ['underscore', 'jquery']
}
}
});
我们在 data-main 入口文件中调用 require.config() 方法,并向它传递一个包含一组属性的配置对象。有许多属性可以作为配置选项使用,但我们将只讨论目前最重要的那些。你可以在 require.js API 中找到完整的列表(requirejs.org/docs/api.html):
-
baseUrl: 此配置定义了根路径,因此你不需要在文件路径中每次都包含它。 -
paths: 此配置指定了每个文件的快捷别名以及文件路径相对于baseUrl的位置。 -
shim: 此配置仅适用于非 AMD 文件,即尚未调用define()方法的脚本。它对 AMD 文件不起作用。 -
exports: 此配置是该模块的全局变量名。 -
deps: 此配置是一个依赖项数组,必须在相应模块加载之前先加载。
如果你想要直接使用库文件,需要查找其 AMD 启用版本。否则,你必须通过 shim 选项进行访问。
定义模块
RequireJS 提供了两个重要的方法——define() 和 require(),分别用于模块定义和依赖项加载。define() 方法接受一个可选的模块 ID、一个可选的数组,该数组包含此模块可能需要的依赖项,以及一个函数,该函数按顺序执行以实例化模块。Backbone 模型最基本的模块定义看起来像这样:
// File: app/models/user.js
define([
'jquery',
'underscore',
'backbone'
],
function ($, _, Backbone) {
var User = Backbone.Model.extend({
defaults: ['name', 'age']
});
return User;
});
现在,这个模型可以像其他依赖项一样在另一个文件中使用。有趣的是,RequireJS 确保特定文件只加载一次,无论你在多个文件中包含它的次数有多少。现在,让我们创建一个 Users 集合并使用我们的 User 模型:
// File: app/collections/users.js
define(function (require) {
var $ = require('jquery'),
_ = require('underscore'),
Backbone = require('backbone'),
UserModel = require('app/models/user');
var Users = Backbone.Collection.extend({
model: UserModel
});
return Users;
});
这听起来很简单,对吧?此外,请注意,我们加载依赖项的方式与我们用于模型定义的方式不同。这种模式被称为Sugar语法,它利用require()方法来加载依赖项。你可以使用这两种语法之一来定义你的模块。当有大量依赖项时,使用Sugar语法比仅仅将它们作为函数的参数更容易组织依赖变量。
因此,使用 AMD,你可以以相同的方式定义所有文件。脚本依赖项的加载方式与我们之前看到的方式相同,而文本依赖项可以使用 RequireJS 的text插件(github.com/requirejs/text)来加载。我们已经在附录 C 中详细讨论了这一点,使用 AMD 和 Require.js 组织模板,当时我们使用此插件加载外部模板文件。在下一节中,我们将看到如何使用这些概念启动完整的应用程序架构。
创建应用程序架构
Backbone 的核心前提始终是尝试发现构建 JavaScript Web 应用时最有用的最小数据结构(模型和集合)和用户界面(视图和 URL)原语。
Jeremy Ashkenas,Backbone.js、Underscore.js 和 CoffeeScript 的创造者
如 Jeremy 所提到的,Backbone.js 至少在近期内没有打算提高其标准以提供应用程序架构。Backbone 将继续作为一个轻量级工具,以产生开发 Web 应用所需的最小功能。那么,我们应该责怪 Backbone.js 没有包括这种功能,尽管在开发者社区中对此有巨大的需求吗?当然不是!Backbone.js 只提供创建应用程序骨架所需的组件,并给我们完全的自由,以我们想要的方式构建应用程序架构。
如果正在开发一个规模较大的 JavaScript 应用程序,请记住为规划底层架构投入足够的时间,这个架构应该是最有意义的。它通常比你最初想象的要复杂。
Addy Osmani,大型 JavaScript 应用程序架构模式书籍的作者
因此,当我们开始深入探讨创建应用程序架构的更多细节时,我们不会谈论简单的应用程序或类似待办事项列表应用程序的内容。相反,我们将研究如何构建中等或大型应用程序的结构。在与许多开发者讨论后,我们发现他们面临的主要问题是,在线博客文章和教程提供了多种方法来组织应用程序。虽然大多数这些教程都讨论了良好的实践,但很难从中选择一个。考虑到这一点,我们将探索一系列你应该遵循的步骤,以确保你的应用程序在长期内既健壮又易于维护。
管理项目目录
这是创建稳固的应用程序架构的第一步。我们已经在前面的章节中详细讨论了这一点。如果你习惯于使用另一种目录布局,那就继续使用吧。如果应用程序的其他部分组织得当,目录结构就不会很重要。
使用 AMD 组织代码
我们将在我们的项目中使用 RequireJS。如前所述,它附带了一系列功能,例如以下内容:
-
在一个 HTML 文件中添加大量的脚本标签并自己管理所有依赖项可能适用于中等规模的项目,但对于大型项目来说,这种方法最终会失败。这样的项目可能有数千行代码;管理如此规模的代码库需要在每个单独的文件中定义小的模块。使用 RequireJS,你不需要担心你有多少个文件——你只需知道,如果正确遵循标准,它肯定能工作。
-
全局命名空间永远不会被触及,你可以自由地为与之最匹配的东西赋予最佳名称。
-
调试 RequireJS 模块比其他方法容易得多,因为你知道每个模块定义中每个依赖项的依赖关系和路径。
-
你可以使用
r.js,这是 RequireJS 的一个优化工具,它可以最小化所有的 JavaScript 和 CSS 文件,来创建生产就绪的构建。
设置应用程序
对于 Backbone 应用程序,必须有一个中心化的对象来整合应用程序的所有组件。在简单的应用程序中,大多数人通常只是让主路由器作为中心对象工作。但这对大型应用程序肯定不起作用,你需要一个Application对象,它应该作为父组件工作。这个对象应该有一个方法(通常是init()),它将作为应用程序的入口点并初始化主路由器以及 Backbone 历史记录。此外,你的Application类应该扩展Backbone.Events,或者它应该包含一个指向Backbone.Events类实例的属性。这样做的好处是app或Backbone.Events实例可以作为中心事件聚合器,你可以在其上触发应用程序级事件。
一个非常基本的 Application 类将类似于以下代码片段:
// File: application.js
define([
'underscore',
'backbone',
'router'
], function (_, Backbone, Router) {
// the event aggregator
var PubSub = _.extend({}, Backbone.Events);
var Application = function () {
// Do useful stuff here
}
_.extend(Application.prototype, {
pubsub: new PubSub(),
init: function () {
Backbone.history.start();
}
});
return Application;
});
Application 是一个简单的类,具有 init() 方法和 PubSub 实例。init() 方法作为应用程序的起点,而 PubSub 作为应用程序级的事件管理器。您可以为 Application 类添加更多功能,例如启动和停止模块,以及添加用于视图布局管理的区域管理器。建议尽可能保持此类尽可能简短。
使用模块模式
我们经常看到中级开发者最初使用基于模块的架构时感到有些困惑。对于他们来说,从简单的 MVC 架构过渡到模块化 MVC 架构可能有点困难。虽然本章讨论的点适用于这两种架构,但我们始终应优先使用模块化概念,以实现更好的可维护性和组织。
在目录结构部分,我们看到了模块由一个 main.js 文件、其视图、模型和集合组成。main.js 文件将定义模块并具有管理该模块其他组件的不同方法。它作为模块的起点。一个简单的 main.js 文件将类似于以下代码:
// File: main.js
define([
'app/modules/user/views/userlist',
'app/modules/user/views/userdetails'
], function (UserList, UserDetails) {
var myVar;
return {
initialize: function () {
this.showUserList();
},
showUsersList: function () {
var userList = new UserList();
userList.show();
},
showUserDetails: function (userModel) {
var userDetails = new UserDetails({
model: userModel
});
userDetails.show();
}
};
});
正如您所看到的,此文件的责任是初始化模块并管理该模块的组件。我们必须确保它只处理父级任务;它不应包含其视图理想中应该有的方法。
这个概念并不复杂,但您需要正确设置它,以便在大型应用程序中使用。您甚至可以采用现有的应用程序和模块设置,并将其与您的 Backbone 应用程序集成。例如,Marionette 为 Backbone 应用程序提供了一个应用程序基础设施。您可以使用其内置的 Application 和 Module 类来构建您的应用程序。它还提供了一个通用的 Controller 类——这是 Backbone 库中没有的,但可以用作中介来提供通用方法,并在模块之间作为共同媒介。
您还可以使用 AuraJS (github.com/aurajs/aura),这是一个由 Addy Osmani (addyosmani.com) 和许多人开发的框架无关的事件驱动架构;它与 Backbone.js 工作得相当好。AuraJS 的详细讨论超出了本书的范围,但您可以从其文档和示例 (github.com/aurajs/todomvc) 中获取大量有用的信息。它是一个出色的样板工具,可以为您的应用程序提供一个起点,我们强烈推荐它,尤其是如果您不使用 Marionette 应用程序基础设施。以下是使用 AuraJS 的一些好处;它们可能有助于您为应用程序选择此框架:
-
AuraJS 是框架无关的。尽管它与 Backbone.js 配合得很好,但你即使不使用 Backbone.js,也可以用它来构建你的 JavaScript 模块架构。
-
它利用了模块模式,使用外观(沙盒)和中介者模式进行应用级和模块级通信。
-
它抽象了你使用的实用库(例如模板和 DOM 操作),这样你就可以在需要时随时替换替代方案。
管理对象和模块通信
保持应用程序代码可维护的最重要方法之一是减少模块和对象之间的紧密耦合。如果你遵循模块模式,你永远不应该让一个模块直接与另一个模块通信。松散耦合在你的代码中增加了一层限制,一个模块的变化永远不会强制应用程序其他部分发生变化。此外,它还允许你在其他地方重用相同的模块。但是如果没有直接关系,我们如何进行通信呢?在这种情况下,我们使用的两个重要模式是观察者和中介者模式。
使用观察者/PubSub 模式
PubSub 模式不过是我们在第六章中讨论的事件分发概念,处理事件、同步和存储。它作为触发事件的对象(发布者)和接收通知的另一个对象(订阅者)之间的消息通道。

我们之前提到,我们可以将应用级事件聚合器作为Application对象的属性。这个事件聚合器可以作为其他模块通信的公共通道工作,而且无需直接交互。
即使在模块级别,你也可能只需要为该模块提供一个公共事件分发器;该模块的视图、模型和集合可以使用它来相互通信。然而,通过分发器发布太多事件有时会使管理它们变得困难,你必须足够小心地理解哪些事件应该通过通用分发器发布,哪些事件应该在特定组件上触发。无论如何,这种模式是设计解耦系统的最佳工具之一,你应该始终为你的基于模块的应用程序准备一个可供使用的模式。
使用中介者模式
有时候,你可能会发现你的应用程序模块之间存在太多的关系,你需要一个中心控制点来帮助管理所有通信。这个集中式系统被称为中介者;它作为一组模块之间的共享主题工作,通过不明确引用模块来促进松散耦合。所有模块都将引用这个中介者。
调解模式在某种程度上类似于观察者模式,但它不作为一个广播系统工作。它包括一组对所有共享此调解者的模块都适用的方法。调解者可以是一个具有多个必需方法的简单对象:
var Mediator = {
method1: function(){},
method2: function(){}
};
任何模块都可以访问这个调解者的任何方法。
| 调解者最好应用于两个或更多对象具有间接工作关系,并且业务逻辑或工作流程需要规定这些对象的交互和协调时。 | ||
|---|---|---|
| --Addy Osmani |
一旦我们查看一个简单的示例,调解者的概念就会变得更加清晰。假设我们有两个模块:User和Event。User模块有一个getUserDetails()方法,可以根据用户 ID 检索用户的详细信息。Event模块有一个loadEvents()方法,其任务是加载用户当前位置附近的所有事件。现在,获取当前登录用户的 ID 或当前位置是一个不是特别模块特定的功能,最好将其保存在Mediator实例中。看看以下示例:
// Mediator
define(['util'], function (Util) {
var Mediator = {
getLoggedinUser: function () {
return Util.getCookie('userid');
},
getUserCurrentLocation: function () {
// returns user's current location
}
};
return Mediator;
});
// User module
define(['app/mediator'],
function (Mediator) {
var User = function () {};
User.prototype.getUserDetails = function () {
var userId = Mediator.getLoggedinUser();
// Load user's details with the loggedin user id
}
return User;
});
// Event module
define(['app/mediator'],
function (Mediator) {
var Event = function () {};
Event.prototype.loadEvents = function () {
var userLocation = Mediator.getUserCurrentLocation();
// load events nearby user's location
}
Event.prototype.showEventDetails = function () {}
return Event;
});
如您所见,我们只是在模块定义中传递Mediator实例,并将可重用和共享的方法放在调解者内部,以便可以从任何模块访问它们。这是一个基本示例;我们希望它能传达使用调解者的概念。在完整的应用程序级别,调解者可能负责很多功能。不了解其正确使用方法就使用调解者不是一个好主意——让我们来看看使用调解模式的优缺点:
-
优点如下:
-
使用调解模式的最大优点是它强制模块之间的通信渠道从多对多变为多对一。因此,模块将不会直接相互通信,而是通过
mediator对象进行通信。 -
它消除了模块之间的紧密耦合,从而减少了大型应用程序的架构复杂性。
-
-
缺点如下:
-
这种模式的最大缺点是它可能引入一个单点故障。
-
通过调解者进行来回沟通有时可能会导致性能下降。
-
无论如何,这两种模式——观察者和调解者,如果您已经注意到,它们是实施起来最简单的之一。如果使用得当,它们可以成为组织和管理您应用程序的最佳资源。使用它们并不是什么大问题;您甚至可以在小型和中型应用程序中实现这些概念。每当您感到需要模块或组件通信时,调解者或 PubSub 模式就可以派上用场。
理解视图管理
Backbone 视图是非常轻量级的组件,你几乎需要在每个应用中添加一些自定义函数来处理事件绑定、适当的布局、数据集成和生命周期管理。因此,始终有一个处理这些常见功能的基础视图是更可取的;所有其他视图都将从这个视图扩展出来。为此,我们建议你选择 MarionetteJS,它提供了三个极其有用的视图类:ItemView、CollectionView和CompositeView。这些类,连同 Marionette 的基础View类,简化了一个人可能需要为其应用视图使用的最重要的样板功能。
视图管理的两个更重要方面是布局管理器和模板处理器。我们在第二章与视图一起工作中详细讨论了这两个主题。在一个大型应用中,一个页面可能包含多个视图,主要任务涉及创建、切换和销毁这些视图。虽然你可以自己处理这种布局管理,但现有的强大布局管理器将帮助你维护这些视图并清理内存。你可以选择Backbone.LayoutManager插件或Marionette.RegionManager扩展来完成这项工作。这两个都提供了类似的功能,并且在开发者社区中得到了良好的接受。
对于模板,我们建议你在开发大型应用时注意以下重要点:
-
使用 Handlebars 而不是 Underscore 的模板引擎,尽管选择其他模板引擎没有限制。只需确保你不在模板中评估 JavaScript 代码——正如我们在第二章与视图一起工作中讨论的那样,这会增加复杂性。
-
将你的视图模板保存在单独的文件中。
-
总是预先编译你的模板。我们在第二章、附录 B在服务器端预编译模板和附录 C使用 AMD 和 Require.js 组织模板中讨论的许多过程描述了如何预编译你的模板并将它们加载。
理解其他重要特性
在开发复杂应用时,还有一些其他需要注意的事项。具体如下:
-
多个路由器:与一个巨大的路由器类别相比,拥有多个路由器总是更可取。我们已经在第五章路由最佳实践和子路由中讨论了子路由的概念。
-
实用方法:每个应用都需要一套通用的实用方法,这些方法可以被应用的任何组件使用。根据需求,你应该始终有一个或多个“实用”类,并且这些类应该负责所有常见的实用方法。
-
DOM 处理:你在视图中与 DOM 交互越多,后期维护就会越困难。始终尽量减少直接 DOM 操作。
-
错误处理器:准备一个通用的错误/异常处理器;它应该作为一个错误/警告的单一点,并向用户显示消息。
-
内存管理:在单页大型应用中,内存泄漏是一个真正需要关注的问题。因此,你必须非常小心,不要初始化全局变量,在它们不再使用时清理引用,以及当相关元素或组件被移除时解绑事件。
摘要
本章讨论了基于 Backbone.js 的应用开发中最重要的主题之一。在框架层面,学习 Backbone 相当容易,开发者可以在非常短的时间内完全掌握它。用几页简单应用开发从未成为问题。但当涉及到大型复杂应用时,布局架构变得相当混乱,包括什么和不包括什么。在本章中,我们试图讨论与应用架构相关的每一个点,并说明了何时以及为什么应该使用特定的模式。此外,这些模式中的大多数都成功应用于多个大型应用。因此,你可以毫不犹豫地采用这些概念。
到目前为止,我们讨论了几乎所有与 Backbone.js 应用开发相关的内容。然而,没有适当的测试,任何项目都是不完整的,这就是我们在下一章和最后一章将要学习的内容,第八章,单元测试、存根、间谍和模拟你的应用。
第八章:单元测试、存根、间谍和模拟你的应用
大多数开发者认为测试是必要的,但现实中只有少数人真正进行测试驱动开发。测试是 JavaScript 开发过程中的一项最佳实践。因此,我们决定包括一个关于如何对基于 Backbone 的应用程序进行单元测试的章节。
许多流行的测试库,如 QUnit、Jasmine、Mocha 和 SinonJS,都可用于单元测试 JavaScript 应用程序。在本章中,我们将向你展示如何使用 QUnit,这是一个简单但强大的测试平台,学习起来也很容易。在后一部分,我们将探讨 SinonJS 以了解测试间谍、存根和模拟。QUnit 和 SinonJS 一起为测试应用中的每个部分提供了一个强大的工具。本章要讨论的主要内容包括:
-
为什么单元测试很重要:测试是一种习惯。在开发过程中继续这样做可能最初需要额外的时间,但在团队工作或开发复杂应用程序时,这是必不可少的。
-
使用 QUnit 进行测试: 我们将探讨
QUnit的基本方面,并了解如何将其用于Backbone.js组件。 -
使用 SinonJS 进行间谍、存根和模拟: 在单元测试中,监视 JavaScript 函数的行为并在需要时从测试环境中控制其行为是绝对必要的。我们将简要探讨这个概念,使用
SinonJS测试框架。
理解单元测试为什么重要
如果你已经知道测试的好处,并在开发 JavaScript 应用程序时遵循最佳实践,你可以跳过这一部分。如果你仍然想知道为什么你应该在实际编写干净且易于维护的代码时测试你的应用程序,以下是一些需要考虑的原因:
-
测试永远不会浪费时间。你不需要反复运行代码来查看它是否工作。你可以一次性运行所有测试用例来查看是否一切按预期工作。测试让你有信心代码工作正常。
-
单元测试创建和运行都非常快。
-
更新你的代码无需担忧。你的测试将告诉你函数是否按预期工作。你会发现这非常有帮助,尤其是在团队工作中。
-
一旦你开始为你的代码编写单元测试,你很快会发现你正在编写比以前更模块化、更灵活、更易于测试的代码。
-
在 测试驱动开发(TDD)中,你首先编写失败的测试用例,然后开发代码。在这种情况下,一个通过测试的用例确保你开发的代码没有问题地正常工作。
测试很有趣。当然,它并不容易,而且不是一天之内就能掌握的。它也不太难——许多开发者都在做这件事,你也可以做到。
使用 QUnit 进行测试
QUnit (qunitjs.com),由 jQuery 团队维护的一个轻量级单元测试框架,与其他框架相比,它相当容易使用。本书不会详细讨论 QUnit,但我们将了解它的简单功能,并探讨我们如何使用它与我们的 Backbone 组件一起使用。
断言是任何单元测试框架中最基本的元素。您需要比较您的实际实现值与测试产生的结果。断言是提供这种比较功能的方法。QUnit 只有八个断言;我们将在下一节中使用其中一些。让我们在这里讨论几个:
-
ok (state, message): 如果第一个参数为真,则通过 -
equal (actual, expected, message): 如果actual和expected相等,则返回 true -
deepEqual (actual, expected, message): 这是一个深度递归比较断言,它作用于原始类型、数组、对象、正则表达式、日期和函数 -
strictEqual (actual, expected, message): 这是一个严格的类型和值比较断言 -
throws (block, actual, message): 这是一个断言,用于测试当运行回调时是否抛出异常
还有几个断言:notEqual()、notDeepEqual() 和 notStrictEqual()。这些断言的功能与它们的对应项正好相反。除此之外,QUnit 还有一系列用于启动测试的测试方法。它们如下:
-
asyncTest(): 这将添加一个异步测试来运行 -
expect(): 这指定了在测试中预期运行多少个断言 -
module(): 这包含在单个标签下的相关测试组 -
test(): 这将添加一个要运行的测试
设置 QUnit 相对直接。首先,我们将创建一个 test 目录并将其放在我们的项目目录中。这个 test 文件夹将包含我们项目的所有测试文件。然后,在这个文件夹内部,我们将创建一个 HTML 文件,它将在我们的浏览器中显示所有测试结果。通常,QUnit 会提供 qunit.js 和 qunit.css 文件。您只需在您的 HTML 文件中包含 QUnit 网站上提供的以下代码片段(qunitjs.com),然后您就完成了 QUnit 的设置:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>QUnit Example</title>
<link rel="stylesheet" href="/resources/qunit.css">
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture"></div>
<script src="img/qunit.js"></script>
<script src="img/tests.js"></script>
</body>
</html>
tests.js 文件将包含所有您的测试用例。根据您的需求,您可以拥有多个测试文件。如果您发现这一部分有点复杂,难以理解所有断言方法的定义,请不要担心。在下一节中,我们将向您展示一个简单的 QUnit 测试用例,其中包含这些断言的一些,您将看到开始使用 QUnit 是多么简单。
执行基本测试用例
我们学习了 QUnit 的基本和重要的 API 方法。现在,让我们使用其中的一些方法来创建一个简单的测试用例。我们将编写一个检查一个数字是否为素数的方法。然后我们将从我们的测试中对 isPrime() 方法进行几次调用,并如下分析结果:
// Function to check a prime number
function isPrime(number) {
var start = 2;
while (start <= Math.sqrt(number)) {
if (number % start++ < 1) return false;
}
return number > 1;
}
test('Test a prime number', function () {
// tells you how many assertions are there in the test
expect(2);
// following two assertions check with two numbers
// whether they are prime number or not
ok(isPrime(3), '3 is a prime number');
equal(isPrime(8), false, '8 is not a prime number');
});
以下代码片段中共享了一个相当简单的示例,以展示如何轻松地开始使用 QUnit。我们首先使用 expect() 方法来确保在这个测试用例中我们将进行两个断言。如果我们进行超过两个断言,那么这个测试将失败。现在,这两个断言,即 ok() 和 equal(),使用两个不同的输入调用 isPrime() 方法,并检查这些输入值是否为素数。当你运行这个测试时,你可以看到两个测试都通过了。
理解 QUnit 的模块(module)、setup 和 teardown 方法
为了组织多个测试用例,我们需要一种能够提供块结构并将多个测试组合在一起的东西。module() 方法允许我们将测试用例分组在一起。此外,它引入了两个方法,setup() 和 teardown(),它们在每个测试用例前后运行,如下面的代码片段所示:
// First module
module('1st module', {
setup: function () {
// Runs before each test
},
teardown: function () {
// Runs after each test
}
});
test('Test 1', function () {});
test('Test 2', function () {});
// Second module
module('2nd module');
test('Test 1', function () {});
当你需要实例化一个将在多个测试中使用的对象(例如视图或集合)时,setup() 方法非常有用。另一方面,teardown() 方法主要用于清理你添加为全局变量的资源。
使用 QUnit 测试 Backbone.js 组件
现在我们已经了解了 QUnit 的基础知识,让我们尝试使用一些 Backbone 组件。我们首先从 Backbone 模型开始,我们将创建一个简单的 User 模型,如下面的代码所示:
var User = Backbone.Model.extend({
defaults: {
name: 'Swapan Guha',
age: 56
}
});
module('User model tests', {
setup: function () {
this.user = new User();
this.user.set('age', 62);
}
});
test('Can be instantiated with a default name and age to be set', function () {
equal(this.user.get('name'), 'Swapan Guha');
equal(this.user.get('age'), 64);
});
在这里,我们使用 Backbone 模型的一个默认值和我们在 setup() 方法中更改的另一个属性进行了测试,但故意使用另一个值进行测试。因此,这个测试应该因为一个断言案例而失败。以下截图显示了它在浏览器中的样子:

使用 SinonJS 的测试间谍、存根和模拟
我们使用单元测试来测试应用程序的一个组件。这个组件可以是一个函数、一个对象、一个变量,或者任何尚未知的任何结果,而你的单元测试想要确保该特定组件是否运行良好。通常,除了测试单独的组件外,你可能还会发现测试方法的行为同样重要。例如,一个方法被调用的次数、它返回的内容、是否抛出了任何异常、它被调用的参数等等。为了执行这些类型的测试,我们使用测试间谍、存根和模拟。
有一些测试库支持测试间谍、桩和模拟。然而,我们发现SinonJS非常易于使用,并且也很健壮。SinonJS与QUnit无缝工作,你也可以与或不与QUnit一起使用它。SinonJS在其网站上给出的定义如下:
独立的 JavaScript 测试间谍(spies)、桩(stubs)和模拟(mocks)。无依赖,与任何单元测试框架兼容。
使用间谍进行测试
我们首先需要了解什么是间谍。根据SinonJS网站上的定义,间谍如下:
测试间谍是一个记录所有调用参数、返回值、
this的值以及抛出的异常(如果有)的函数。测试间谍可以是一个匿名函数,也可以是包装现有函数的函数。
所以你的下一个问题应该是为什么应该使用间谍。我们使用测试间谍来测试回调和其他方法的行为,以及了解它们是如何工作的。一旦你查看与间谍相关的某些 API 方法,你将找到更详细的答案:
-
called(): 如果间谍至少被调用一次,则返回 true -
calledOnce(): 如果间谍正好被调用一次,则返回 true -
returned(): 如果间谍至少一次返回了提供的值,则返回 true
这些是间谍 API 支持的少数方法。希望现在你能理解为什么使用间谍——它允许你测试函数的多个特性,了解它是否只被调用一次,或者检查它返回的值。间谍允许你测试函数的完整流程的每一个可能性。现在让我们看看如何从以下代码片段中使用间谍:
// A User model definition
var User = Backbone.Model.extend({
defaults: {
name: ''
},
// Split the name to provide an array of first and last name
getNameAsArray: function () {
return this.get('name').split(' ');
}
});
test('should call getNameAsArray and return an array',function () {
this.user = new User({
name: 'Krishnendu Saha'
});
// Added a spy on the the "getNameAsArray" method
sinon.spy(this.user, 'getNameAsArray'); // or this.spy()
this.user.getNameAsArray();
// We check whether the method is called only once
ok(this.user.getNameAsArray.calledOnce);
// We check whether the returned value of this
// method is an array
equal(_.isArray(this.user.getNameAsArray.returnValues[0]),true);
});
我们使用了相同的User模型,并为其添加了一个getNameAsArray()方法。我们监视了这个方法来测试它是否只被调用一次并返回一个数组。之前的测试用例通过得很好。
因此,你可以使用间谍来验证以下任何或所有情况:
-
检查回调的调用
-
验证回调是否以特定参数执行
-
验证内部函数是否提供正确的返回值
-
验证某种简单的调用行为
使用桩进行测试
另一方面,测试桩(test stub)是从间谍(spy)扩展而来,并为其添加了一些额外的功能。它是一个具有预编程行为的函数,并支持完整的间谍 API。它被用来替换(或模拟)现有方法的某些行为。当你想要防止直接调用某个特定方法,或者为了测试错误处理而强制方法抛出错误时,它非常有用。和间谍一样,桩可以是匿名的,也可以包装现有的函数。当用桩包装现有函数时,原始函数不会被调用。
一个匿名桩可以定义为如下:
var stub = sinon.stub();
作为对象方法的包装器,它可以定义为如下:
var stub = sinon.stub(object, "method");
在这里,函数object.method被替换为一个匿名存根函数。你还可以将一个额外的函数作为stub()函数的第三个参数添加,它将作为object.method的间谍,并替换原始方法如下:
var stub = sinon.stub(object, "method", function(){});
为了通过一个真实示例了解间谍是如何工作的,我们可以使用之前使用的相同的User模型。如下代码片段所示:
// We will use the same User model definition here
module("Should work when getNameAsArray method is called", {
setup: function () {
this.user = new User();
// Use a stub to replace the getNameAsArray method
this.userStub = sinon.stub(this.user, "getNameAsArray");
this.userStub.returns([]);
},
// Restore the original method
teardown: function () {
this.userStub.restore();
}
});
test('should call getNameAsArray and must return an empty array', function () {
this.user.getNameAsArray();
// Should return an empty array
equal(_.isArray(this.user.getNameAsArray.returnValues[0]), true);
equal(this.user.getNameAsArray.returnValues[0].length, 0);
});
在这里,我们存根了User模型的getNameAsArray()方法,并返回一个空数组。所以当你调用getNameAsArray()方法时,不是方法而是存根将被调用。我们确保存根返回一个空数组。
现在的测试就像我们之前做的那样简单。我们只需在User实例上调用getNameAsArray()方法,并检查返回值的长度。
使用模拟进行测试
模拟(以及模拟期望)是具有预编程行为(如存根)的假方法(如间谍),以及预编程的期望。如果模拟没有被按预期使用,它将使你的测试失败。
这是SinonJS网站上给出的模拟定义(sinonjs.org/docs/#mocks)。模拟与存根非常相似,但它们自带内置的期望。它们实现了间谍和存根 API。使用模拟,你可以定义测试中应该发生的所有期望。当所有这些事情都完成时,你断言这些事情是否按照计划发生。因此,你定义期望,如果它们没有满足,测试就会失败。
现在,我们如何使用模拟?我们模拟一个对象,对其方法设置期望,并对这些期望应用修饰符。然后我们验证测试是否通过了所有期望。为了更好地理解,让我们通过以下代码片段探索一个简单的模拟示例:
test('should call getNameAsArray once and check it is called on the user model', function () {
this.user = new User({
name: 'Subodh Guha'
});
var mock = sinon.mock(this.user);
// We set the expectations here
mock.expects('getNameAsArray').once().on(this.user);
// Execution happens here
this.user.getNameAsArray();
// Now we verify whether the expectations are met or not
mock.verify();
});
这里我们使用相同的User模型,并创建一个带有User实例的模拟。然后我们在模拟上设置期望,以查看getNameAsArray()方法是否只在该User实例上调用一次。所有这些期望都是事先设置的,并在最后一起验证。
模拟与存根的区别
现在,因为存根和模拟在功能上相似,你可能会想知道为什么和何时你应该使用模拟而不是存根。根据网站上的说明,你只有在想要在测试中提供替代功能和一个期望时才使用模拟。你可以看到的主要区别如下:
-
模拟对象用于定义期望,即在特定场景中,我们期望
Foo()方法使用一组参数被调用。模拟记录并验证这样的期望,即foo()方法是否实际上使用这些参数被调用。 -
相反,存根有不同的目的——它们不记录或验证期望,而是允许我们“替换”假对象的行为和状态,以便利用测试场景。
要使用存根来测试生命周期,请按照以下步骤进行:
-
设置数据:准备待测试的对象及其存根协作者。
-
练习:测试功能。
-
验证状态:使用断言来检查对象的状态。
-
清理:清理资源。
要使用模拟来测试生命周期,请按照以下步骤进行:
-
设置数据:准备待测试的对象。
-
设置期望:在主对象使用的模拟中准备期望。
-
练习:测试功能。
-
验证期望:验证在模拟中是否调用了正确的方法。
-
验证状态:使用断言来检查对象的状态。
-
清理:清理资源。
如你所见,模拟有预状态和后状态。我们在测试之前设置期望,并在之后验证它。无论如何,存根和模拟的目的是消除测试一个类或函数的所有依赖项,这样你的测试就可以更加专注于它们试图证明的内容。
摘要
我们在附录 A 中包含了与QUnit和SinonJS相关的书籍和教程,书籍、教程和参考。你可以参考它们来获取这两个技术的更详细的信息。
本章描述了一些测试概念。你对QUnit和SinonJS的力量以及如何广泛地使用它们进行单元测试 JavaScript 应用程序有了了解。尽管这仅仅触及了表面,但我们也不打算在这本书中涵盖测试的所有内容。我们只是试图让你意识到测试是应用程序开发过程中的一个绝对重要的部分,你应该养成在开发时编写测试用例的习惯。这将使你的代码更加结构化、灵活,并且更容易让你的队友使用。
附录 A. 书籍、教程和参考资料
Backbone.js 拥有优秀的文档,提供了对其所有组件的详细概述。然而,当你开始开发自己的应用程序时,你可能发现这些文档对于学习这项技术还不够全面。在这本书中,针对不同主题提到了各种资源。以下是一些额外的资源。我在使用 Backbone.js 时发现它们非常有帮助,并推荐你也遵循它们。
参考书籍
Backbone.js 的相关书籍众多,适合初学者和高级开发者。然而,我认为以下三本书对于任何 Backbone.js 开发者来说都是必备的:
-
《开发 Backbone.js 应用》,作者阿迪·奥斯曼尼:如果你是一个初学者并且想开始学习 Backbone.js,这是最好的书籍。它几乎涵盖了关于 Backbone.js 的所有内容。阿迪还提供了这本书的在线版本——你可以在以下教程部分找到它。
-
《构建 Backbone 插件》,作者德里克·贝利:这是一本优秀的书籍,德里克在其中讨论了插件开发、Backbone.js 组件的不同问题及其解决方案。
-
《Backbone.Marionette.js:温和的介绍》,作者大卫·苏尔茨:这本书讨论了 Backbone.Marionette 扩展的不同组件以及详细的示例。
教程
你可以在网上找到很多教程。以下是一些推荐的教程,它们被开发者广泛采用:
-
《开发 Backbone.js 应用》,作者阿迪·奥斯曼尼 (
addyosmani.github.io/backbone-fundamentals/) -
德里克·贝利的博客文章(
lostechies.com/derickbailey/) -
《大型规模 JavaScript 应用架构模式》,作者阿迪·奥斯曼尼:
-
《可扩展的 JavaScript 应用架构》,作者尼古拉斯·扎卡斯 (
www.youtube.com/watch?v=vXjVFPosQHw) -
如果你是一个初学者,你可能觉得 Backbone 教程非常有帮助,可以理解初始概念(
backbonetutorials.com)
单元测试
QUnit 和 SinonJS 的文档提供了关于测试框架的完整细节。以下是一些书籍和教程,你可能觉得它们很有用,可以帮助你掌握这些框架:
-
《使用 QUnit 和 SinonJS 进行 Backbone.js 应用单元测试》,作者阿迪·奥斯曼尼 (
addyosmani.com/blog/unit-testing-backbone-js-apps-with-qunit-and-sinonjs/) -
《QUnit 食谱》,作者jQuery 基金会 (
qunitjs.com/cookbook/) -
SinonJS,作者:Christian Johansen (
cjohansen.no/talks/2011/xp-meetup/#1) -
像间谍一样使用 Sinon.js 进行单元测试,作者:Elijah Manor (
www.elijahmanor.com/unit-test-like-a-secret-agent-with-sinon-js/)
其他插件和教程
除了之前提到的资源外,Backbone.js 维基还提供了一个重要插件和教程的更新列表,以下是一些例子:
-
Backbone 插件和扩展在
github.com/jashkenas/backbone/wiki/Extensions,-Plugins,-Resources。 -
在
github.com/jashkenas/backbone/wiki/Tutorials,-blog-posts-and-example-sites可以找到教程、博客文章和示例网站。 -
在
backplug.io/列出了按受欢迎程度排序的多个 Backbone.js 插件。 -
Backbone-Debugger 在
github.com/Maluen/Backbone-Debugger。这是一个 Chrome 浏览器扩展程序,在处理 Backbone 应用程序时可能很有帮助。类似的 Firebug 扩展程序可以在github.com/dhruvaray/spa-eye找到。
附录 B. 服务器端预编译模板
在第二章中,我们学习了在应用程序中使用预编译模板的优势。此外,我们还看到了将模板存储在index.html文件中作为内联或作为单独的模板文件的一些选项。我们还看到了如何使用模板管理器预编译和缓存模板以避免每次编译的开销。然而,这个预编译过程仍然会在你启动应用程序时运行,这肯定会占用一定的时间。等等!这些模板不是静态资源吗?那么没有数据的模板编译版本也将是静态资源。对吧?那么为什么不保留一个包含所有预编译模板的单独文件,并在应用程序启动时立即使用它呢?如果你得到一个包含所有模板已经预编译和压缩的文件,它肯定会提高你的应用程序性能。这正是我们在这里要尝试的——我们将开发一个脚本在服务器端预编译模板,它将遍历所有模板文件并创建一个单独的模板管理器文件。在这里我们使用 Node.js,但你也可以使用任何服务器端技术来得到相同的结果。完整的代码示例在代码样本中给出。
为了预编译,我们需要一个支持预编译的模板引擎。在这里我们将使用 Underscore.js,但你也可以自由选择你想要的模板引擎来实现相同的结果。以下 Node.js 示例展示了如何实现这个功能:
// load the file system node module
var fs = require('fs'),
// load the underscore.js
_ = require('../../../lib/underscore.js');
var templateDir = './templates/',
template,
tplName,
// create a string which when evaluated will create the
// template object with cachedTemplates
compiledTemplateStr = 'var Templates = {cachedTemplates : {}}; \n\n';
// Iterate through all the templates in templates directory
fs.readdirSync(templateDir).forEach(function (tplFile) {
// Read the template and store the string in a variable
template = fs.readFileSync(templateDir + tplFile, 'utf8');
// Get the template name
tplName = tplFile.substr(0, tplFile.lastIndexOf('.'));
// Add template function's source to cachedTemplate
compiledTemplateStr += 'Templates.cachedTemplates["' + tplName + '"] = ';
compiledTemplateStr += _.template(template).source + '\n\n';
});
// Write all the compiled code in another file
fs.writeFile('compiled.js', compiledTemplateStr, 'utf8');
上述代码相当直观。我们创建了一个完整的 JavaScript 代码片段作为字符串,并将其返回到前端。以下是完成此操作的步骤:
-
首先,我们浏览
templates目录中的每个模板文件并检索其内容。 -
我们已经定义了一个对象
Templates.cachedTemplates,我们需要将每个模板文件的 内容存储在这个对象中,以模板文件名作为属性,模板字符串作为其值。 -
Underscore 的
_.template()方法通常返回一个函数。它还提供了一个名为source的属性,它提供了该特定函数的文本表示。以下行将给出函数的源代码:_.template(template).source -
我们将所有的函数字符串逐个放入
Templates.cachedTemplates中,一旦循环结束,我们就将整个内容写入另一个 JavaScript 文件。
现在假设客户端请求包含项目完整模板内容的templates.js文件。在服务器端,我们可以编写以下代码,将compiled.js文件的内容发送到浏览器:
// While templates.js file is loaded, it will
// send the compiled.js file's content
app.get('/templates.js', function (req, res) {
res
.type('application/javascript')
.send(fs.readFileSync('compiled.js', 'utf8'));
});
因此,客户端对template.js文件的请求将显示类似于以下代码的内容:
var Templates = {
cachedTemplates: {}
};
Templates.cachedTemplates["userLogin"] = function (obj) {
var __t, __p = '',
__j = Array.prototype.join,
print = function () {
__p += __j.call(arguments, '');
};
with(obj || {}) {
__p += '<ul>\r\n <li>Username:\r\n <input type="text" value="' +
((__t = (username)) == null ? '' : __t) +
'" />\r\n </li>\r\n <li>Password:\r\n <input type="password" value="' +
((__t = (password)) == null ? '' : __t) +
'" />\r\n </li>\r\n</ul>\r\n';
}
return __p;
}
最终输出是一个TemplateManager对象,其属性为模板的文件名,属性值为模板的编译版本。这样,你所有的模板文件都将被添加到TemplateManager对象中。然而,对于这段代码,你需要确保每个模板的文件名是不同的。否则,同名文件的模板将被另一个模板覆盖。
你不需要理解这个编译后的模板函数定义,因为这个函数将在库内部使用。请放心,一旦你用数据对象调用这个函数,你将得到正确的 HTML 输出:
var user = new Backbone.Model({
username: 'hello',
password: 'world'
});
// Get the html
var html = Templates.cachedTemplates.userLogin(user.toJSON());
这种预编译 JavaScript 模板的解决方案非常有效,你可以在你的项目中自由地使用这个概念。我们已经成功地在多个项目中使用了这个概念。
附录 C. 使用 AMD 和 Require.js 组织模板
异步模块定义(AMD)是一个用于定义模块和异步加载模块依赖的 JavaScript API。这是一个相对较新但非常稳健的概念,许多开发者现在都在采用它。在第七章 组织 Backbone 应用程序 – 结构、优化和部署 中,我们详细介绍了使用 Require.js 的 AMD。如果您需要更多关于这个库的细节,我们建议您访问requirejs.org/以获取完整的概述,然后回到这一节。
通常,Require.js 将每个文件的内容视为 JavaScript。因此,如果我们不将模板文件作为 JavaScript 文件加载,我们就不能以相同的方式加载它们。幸运的是,对于模板,有一个text插件允许我们加载基于文本的依赖项。我们使用此文件加载的任何文件都将被视为文本文件,我们接收到的内容将是一个字符串;它可以很容易地与您的模板方法一起使用。要使用此插件,只需在文件路径前加上text!,文件内容将以纯文本形式检索;为此,请遵循以下示例:
// AMD Module definition with dependencies
define([
'backbone',
'underscore',
// text plugin that gets any file content as text
'text!../templates/userLogin.html'
],
function (Backbone, _, userLoginTpl) {
'use strict';
var UserLogin = Backbone.View.extend({
// Compile the template string that we received
template: _.template(userLoginTpl),
render: function () {
this.$el.html(this.template({
username: 'johndoe',
password: 'john'
}));
return this;
}
});
return UserLogin;
});
使用此机制的好处是,您可以通过创建单独的模板文件来组织您的模板,并且它们将自动包含在您的模块中。由于这涉及到异步加载,文件将通过 AJAX 请求下载,这是我们之前决定为不好的做法。然而,Require.js 附带了一个r.js优化工具,它可以构建模块并可以通过内联这些模板与相应的模块来节省这些额外的 AJAX 请求。
使用 requirejs-tpl 插件进行预编译
使用 AMD,我们简化了模板组织过程,但最终结果仍然是一个未编译的模板字符串。在第二章 与视图一起工作 中,我们看到了模板编译如何每次都影响应用程序性能,我们也分析了预编译模板的好处。如果我们有一种可以加载这些模板文件并提供已编译的模板字符串的方法,那会很有用吗?幸运的是,对于 Require.js 有多个tpl插件可用于自动化模板编译,您可以直接在模块定义中使用这些插件。让我们看看由 ZeeAgency 开发的类似插件(github.com/ZeeAgency/requirejs-tpl)。依赖项加载与text插件完全相同,您只需使用tpl!插件前缀而不是text!:
define(['tpl!your-template-path.tpl'], function (template) {
return template ({
your: 'data'
});
});
现在,r.js提供了优化和打包的预编译模板。tpl!插件肯定比text!插件更方便和有用。
使用 Require.js 进行模板组织是维护模板的最佳方式之一;现在很多 JavaScript 开发者都在选择这种方式。如果你正在使用 AMD 为你的 Backbone 应用程序开发,那就毫不犹豫地选择它吧。


浙公网安备 33010602011771号