jQuery 设计模式(全)
原文:zh.annas-archive.org/md5/9DBFD51895CA93BE96AC02124FF5B7E1
译者:飞龙
协议:CC BY-NC-SA 4.0
前言
自 2006 年推出以来,jQuery 库已经使 DOM 遍历和操作变得更加容易。这导致了具有越来越复杂用户交互的 Web 页面的出现,从而促进了 Web 作为支持大型应用程序实现的平台的成熟。
本书提供了一系列使 Web 应用程序实现更高效的最佳实践。此外,我们将分析可以应用于 Web 开发的计算机科学中最重要的设计模式。通过这种方式,我们将学习如何利用其他编程领域中广泛使用和测试的技术,这些技术最初是作为模拟复杂问题解决方案的通用方法创建的。
在《jQuery 设计模式》中,我们将分析各种设计模式在 jQuery 实现中的应用以及它们如何用于改进我们的实现组织。通过采用本书中展示的设计模式,您将能够创建更有组织的实现,更快地解决大问题类别。此外,当开发团队使用时,它们可以提高团队之间的沟通并导致同质化实现,其中代码的每个部分都易于被其他人理解。
本书涵盖内容
第一章, jQuery 和复合模式复习 ,将教读者如何通过分析它们在 jQuery 本身实现中的使用来编写代码,使用复合模式和方法链接(流畅接口)。它还演示了与 jQuery 返回的复合集合对象良好配对的迭代器模式。
第二章,观察者模式 ,将教您如何使用观察者模式响应用户操作。它还演示了如何使用事件委托作为一种减少处理动态注入页面元素的代码的内存消耗和复杂性的方法。最后,它将教您如何发出和监听自定义事件,以实现更大的灵活性和代码解耦。
第三章,发布/订阅模式 ,将教您如何利用发布/订阅模式创建一个中心点来发出和接收应用程序级事件,作为解耦代码和业务逻辑与用于呈现的 HTML 之间的一种方式。
第四章, 用模块模式进行分而治之 , 展示并比较了行业中最常用的模块模式。它将教会你如何使用命名空间来将你的应用程序结构化为小的独立模块,遵循关注点分离原则,从而实现可扩展的实现。
第五章, 外观模式 , 将教会你如何使用外观模式将复杂的 API 包装成更适合你应用程序需求的简单 API。它还演示了如何改变应用程序的部分,同时保持相同的模块级 API,避免影响其余的实现。
第六章, 建造者和工厂模式 , 解释了建造者模式和工厂模式的概念和区别。它将教会你何时以及如何使用它们,以改善代码的清晰度,通过将生成复杂结果的过程抽象成单独的专用方法。
第七章, 异步控制流模式 , 将解释 jQuery 的 Deferred 和 Promise API 是如何工作的,并将它们与经典的回调模式进行比较。你将学习如何使用 Promises 来控制异步程序的执行,让它们以顺序或并行的方式运行。
第八章, 模拟对象模式 , 教会你如何创建和使用模拟对象和服务作为一种简化应用程序开发的方式,并在所有部分完成之前提前感受到其功能。
第九章, 客户端模板化 , 展示如何使用 Underscore.js 和 Handlebars.js 模板库作为创建复杂 HTML 结构的更好更快的方式。通过这一章节,你将了解它们的惯例,评估它们的特点,并找到最符合你口味的那一个。
第十章, 插件和小部件开发模式 , 介绍了 jQuery 插件开发的基本概念和惯例,并分析了最常用的设计模式,使你能够识别并使用最适合任何用例的模式。
第十一章 优化模式 ,指导您使用最佳提示创建高效且健壮的实现。您将能够将本章用作改进应用程序性能并降低内存消耗的最佳实践的检查表,然后将其移到生产环境。
本书所需内容
为了运行本书中的示例,您需要在系统上安装 Web 服务器来提供代码文件。例如,您可以使用 Apache、IIS 或 NGINX。为了使 Apache 的安装过程更加简单,您可以使用更完整的开发环境解决方案,例如 XAMPP 或 WAMP Server。
就技术熟练度而言,本书假定您已经有一些使用 jQuery、HTML、CSS 和 JSON 的经验。本书中的所有代码示例都使用 jQuery v2.2.0,并且一些章节还讨论了在 jQuery v1.12.0 中的相应实现,这可以在需要支持旧版浏览器的情况下使用。
本书适合谁
这本书面向现有的 jQuery 开发者或想将自己的技能和理解水平提升到高级水平的新开发者。这是一个详细介绍了如何将各种行业标准模式应用于 jQuery 应用程序的入门,以及一组最佳实践,它可以帮助大型团队协作并创建组织良好、可扩展的实现。
约定
在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。
文本中的代码词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“在上述 CSS 代码中,我们首先为 box
、boxsizer
和 clear
CSS 类定义了一些基本样式。”
代码块设置如下:
$.each([3, 5, 7], function(index){
console.log(this + 1 + '!');
});
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体设置:
$('#categoriesSelector').change(function() {
var $selector = $(this);
var message = { categoryID: $selector.val() };
broker.trigger('dashboardCategorySelect', [message]);
});
我们正在遵循 Google JavaScript 样式指南,除了使用四个空格缩进外,以改善书中代码的可读性。简而言之,我们将大括号放在顶部,并使用单引号表示字符串字面值。
注意
有关 Google JavaScript 样式指南的更多信息,请访问以下 URL:google.github.io/styleguide/javascriptguide.xml
任何命令行输入或输出都以如下形式书写:
npm install jquery
新术语 和重要单词 以粗体显示。屏幕上看到的词语,例如菜单或对话框中的词语,以如下形式出现在文本中:“返回的 jQuery 对象 是一个类似数组的对象 ,它充当包装对象并携带检索到的元素的集合。”
注意
警告或重要提示会出现在这样的框中。
提示
提示和技巧会出现在这样。
第一章:jQuery 和复合模式复习。
直到 Web 2.0 时代开始,Web 只是基于文档的媒体,它所提供的仅仅是连接不同页面/文档和客户端脚本编写,大多数情况下仅限于表单验证。到 2005 年,Gmail 和 Google 地图发布了,JavaScript 证明了自己是大型企业用于创建大规模应用程序并提供丰富用户界面交互的语言。
尽管 JavaScript 自发布以来几乎没有什么变化,但企业界对网页应该具备的功能期望发生了巨大变化。从那时起,Web 开发人员需要提供复杂的用户交互,并最终,"Web 应用程序" 这个术语出现在市场上。因此,开始变得明显,他们应该创建一些代码抽象,定义一些最佳实践,并采用计算机科学提供的所有适用的 设计模式 。JavaScript 作为企业级应用程序的广泛采用帮助了语言的发展,随着 EcmaScript2015 /EcmaScript6 (ES6 )规范的发布,语言得以扩展,以便更轻松地利用更多的设计模式。
2006 年 8 月,John Resig 在 jquery.com
首次发布了 jQuery 库,旨在创建一个方便的 API 来定位 DOM 元素。从那时起,它已成为 Web 开发人员工具包的一个组成部分。jQuery 在其核心中使用了几种设计模式,并通过提供的方法试图敦促开发人员使用它们。复合模式是其中之一,它通过非常核心的 jQuery()
方法向开发人员公开,该方法用于 DOM 遍历,这是 jQuery 库的一个亮点。
在本章中,我们将:
jQuery 和 DOM 脚本编写。
通过 DOM 脚本编写,我们指的是在浏览器加载后修改或操作网页元素的任何过程。DOM API 是一种 JavaScript API,于 1998 年标准化,它为网页开发人员提供了一组方法,允许在加载和解析网页的 HTML 代码后操作浏览器创建的 DOM 树元素。
注意。
要了解有关 文档对象模型 (DOM )及其 API 的更多信息,您可以访问 developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction
。
通过在他们的 JavaScript 代码中利用 DOM API,web 开发者可以操纵 DOM 的节点,并向页面添加新元素或删除现有元素。最初 DOM 脚本的主要用例仅限于客户端表单验证,但随着时间的推移和 JavaScript 获得企业界的信任,开始实现更复杂的用户交互。
jQuery 库的初始版本于 2006 年 8 月首次发布,它试图简化 web 开发者遍历和操纵 DOM 树的方式。其主要目标之一是提供抽象,以产生更短、更易读、更不容易出错的代码,同时确保跨浏览器的互操作性。
jQuery 遵循的这些核心原则在其主页中清晰可见,它将自己呈现为:
…一个快速、小巧且功能丰富的 JavaScript 库。它通过一个易于使用的 API,简化了 HTML 文档遍历和操纵、事件处理、动画和 Ajax,适用于众多浏览器。jQuery 结合了多功能性和可扩展性,改变了数百万人编写 JavaScript 的方式。
jQuery 从一开始提供的抽象 API,以及不同的设计模式是如何编排的,导致在 web 开发者中得到了广泛的接受。因此,根据多个来源(例如 BuiltWith.com (trends.builtwith.com/javascript/jQuery
)),全球访问量最高的网站中有超过 60% 的网站引用了 jQuery 库。
使用 jQuery 操纵 DOM
为了对 jQuery 进行复习,我们将通过一个示例网页进行一些简单的 DOM 操作。在这个例子中,我们将加载一个简单结构的页面,最初看起来像下图所示:
我们将使用一些 jQuery 代码来更改页面的内容和布局,并且为了使其效果清晰可见,我们将设置它在页面加载后约700 milliseconds
运行。我们的操作结果将如下图所示:
现在让我们回顾一下前面示例所需的 HTML 代码:
<!DOCTYPE html>
<html>
<head>
<title>DOM Manipulations</title>
<link rel="stylesheet" type="text/css" href="dom-manipulations.css">
</head>
<body>
<h1 id="pageHeader">DOM Manipulations</h1>
<div class="boxContainer">
<div>
<p class="box">
Doing DOM Manipulations is easy with JS!
</p>
</div>
<div>
<p class="box">
Doing DOM Manipulations is easy with JS!
</p>
</div>
<div>
<p class="box">
Doing DOM Manipulations is easy with JS!
</p>
</div>
</div>
<p class="box">
Doing DOM Manipulations is easy with JS!
</p>
<p class="box">
Doing DOM Manipulations is easy with JS!
</p>
<script type="text/javascript" src="img/jquery-2.2.0.min.js"></script>
<script type="text/javascript" src="img/jquery-dom-manipulations.js"></script>
</body>
</html>
使用的 CSS 代码非常简单,只包含三个 CSS 类,如下所示:
.box {
padding: 7px 10px;
border: solid 1px #333;
margin: 5px 3px;
box-shadow: 0 1px 2px #777;
}
.boxsizer {
float: left;
width: 33.33%;
}
.clear { clear: both; }
前述代码在浏览器中打开并在执行我们的 JavaScript 代码之前,页面看起来像第一个图示所示。在前述 CSS 代码中,我们首先为 box
、boxsizer
和 clear
CSS 类定义了一些基本样式。box
类通过一些填充、一条细边框、周围一些间距和在元素下方创建一个小阴影来为页面中的相关元素添加样式,使它们看起来像一个盒子。boxsizer
类将使用它的元素的宽度设置为其父元素的 1/3,并创建一个三列布局。最后,clear
类将用于元素作为列布局的断点,以使其后的所有元素都位于其下方。boxsizer
和 clear
类最初未被 HTML 代码中定义的任何元素使用,但会在我们将在 JavaScript 中进行的 DOM 操作之后使用。
在我们的 HTML 的 <body>
元素中,我们最初定义了一个带有 ID pageHeader
的 <h1>
标题元素,以便通过 JavaScript 轻松选择。紧接着,在它下面,我们定义了五个段落元素 (<p>
),具有 box
类,前三个元素被包裹在三个 <div>
元素中,然后再包裹在另一个具有 boxContainer
类的 <div>
元素中。
到达我们的两个 <script>
标签时,我们首先从 jQuery CDN 引入了对 jQuery 库的引用。有关更多信息,您可以访问 code.jquery.com/
。在第二个 <script>
标签中,我们引用了带有所需代码的 JavaScript 文件,例如:
setTimeout(function() {
$('#pageHeader').css('font-size', '3em');
var $boxes = $('.boxContainer .box');
$boxes.append(
'<br /><br /><i>In case we need simple things</i>.');
$boxes.parent().addClass('boxsizer');
$('.boxContainer').append('<div class="clear">');
}, 700);
我们所有的代码都包装在一个 setTimeout
调用中以延迟其执行,根据之前描述的用例。setTimeout
函数调用的第一个参数是一个匿名函数,它将在定时器 700 毫秒过期后执行,如第二个参数中定义的那样。
在我们匿名回调函数的第一行,我们使用 jQuery 的 $()
函数遍历 DOM 并定位 ID 为 pageHeader
的元素,并使用 css()
方法将其 font-size
增加到 3em
。接下来,我们向 $()
函数提供了一个更复杂的 CSS 选择器,来定位所有具有 box
类的元素,这些元素是具有 boxContainer
类的元素的后代,然后将结果存储在名为 $boxes
的变量中。
提示
变量命名约定
在开发者中使用命名约定来命名持有特定类型对象的变量是一种常见做法。使用这种约定不仅有助于你记住变量持有的内容,还能使你的代码更易于其他团队成员理解。在 jQuery 开发者中,当变量存储了 $()
函数的结果(也称为 jQuery 集合对象)时,使用以 "$" 符号开头的变量名是常见的。
在获取我们感兴趣的box
元素之后,我们在每个元素的末尾添加两个换行空格和一些额外的斜体文本。然后,我们使用$boxes
变量遍历 DOM 树,使用parent()
方法上升一个级别。parent()
方法返回一个不同的 jQuery 对象,其中包含我们最初选择的框的父<div>
元素,然后我们链式调用addClass()
方法将它们分配给boxsizer
CSS 类。
小贴士
如果您需要遍历所选元素的所有父节点,则可以使用$.fn.parents()
方法。如果您只需要找到与给定 CSS 选择器匹配的第一个祖先元素,请考虑改用$.fn.closest()
方法。
最后,由于boxsizer
类使用浮动来实现三列布局,我们需要清除boxContainer
中的浮动。再次,我们使用简单的.boxContainer
CSS 选择器和$()
函数遍历 DOM。然后,我们调用.append()
方法创建一个带有.clear
CSS 类的新<div>
元素,并将其插入到boxContainer
的末尾。
700 毫秒后,我们的 jQuery 代码将完成,结果是之前显示的三列布局。在其最终状态下,我们的boxContainer
元素的 HTML 代码如下所示:
<div class="boxContainer">
<div class="boxsizer">
<p class="box">
Doing DOM Manipulations is easy with JS!
<br><br><i>In case we need simple things</i>.
</p>
</div>
<div class="boxsizer">
<p class="box">
Doing DOM Manipulations is easy with JS!
<br><br><i>In case we need simple things</i>.
</p>
</div>
<div class="boxsizer">
<p class="box">
Doing DOM Manipulations is easy with JS!
<br><br><i>In case we need simple things</i>.
</p>
</div>
<div class="clear"></div>
</div>
方法链和流畅接口
实际上,在上面的示例中,我们还可以进一步将所有三个与框相关的代码语句合并为一个,其效果如下所示:
$('.boxContainer .box')
.append('<br /><br /><i>In case we need simple things</i>.')
.parent()
.addClass('boxsizer');
这种语法模式被称为方法链 ,并且被 jQuery 和 JavaScript 社区广泛推荐。方法链是流畅接口的面向对象实现模式的一部分,其中每个方法将其指令上下文传递给后续方法。
大多数适用于 jQuery 对象的 jQuery 方法也会返回相同或新的 jQuery 元素集合对象。这使我们能够链式调用多个方法,不仅使代码更易读和表达,而且减少了所需的变量声明。
组合模式
组合模式的关键概念是使我们能够像处理单个对象实例一样处理对象集合。通过在集合上使用方法来操作组合将导致将操作应用于其每个部分。这样的方法可以成功应用,而不管组合集合中包含的元素数量如何,甚至当集合不包含元素时也可以。
另外,组合集合的对象不一定需要提供完全相同的方法。组合对象可以只公开集合对象中对象之间共同的方法,或者可以提供一个抽象的 API,并适当处理每个对象的方法差异。
让我们继续探讨 jQuery 公开的直观 API 如何受到组合模式的高度影响。
jQuery 如何使用组合模式
组合模式是 jQuery 架构的一个组成部分,并且从 $()
函数的核心自身应用。 对 $()
函数的每次调用都会创建并返回一个元素集合对象,这通常简称为 jQuery 对象。 这正是我们看到组合模式的第一个原则的地方; 实际上,$()
函数不是返回单个元素,而是返回一组元素。
返回的 jQuery 对象是一个类似数组的对象,充当包装对象并携带检索到的元素集合。 它还公开了一些额外的属性,如下所示:
提示
简单的类似数组对象定义
类似数组的对象是具有数字 length
属性和相应数量的属性的 JavaScript 对象 { }
,具有连续的数字属性名称。 换句话说,具有 length == 2
属性的类似数组对象预计也应该定义两个属性 "0"
和 "1"
。 给定上述属性,类似数组的对象允许您使用简单的 for
循环访问它们的内容,通过利用 JavaScript 的括号属性访问器的语法:
for (var i = 0; i < obj.length; i++) {
console.log(obj[i]);
}
我们可以轻松地尝试使用 $()
函数返回的 jQuery 对象,并通过使用我们喜爱的浏览器的开发者工具检查上述属性。 要在大多数浏览器上打开开发者工具,我们只需要在 Windows 和 Linux 上按下 F12 ,或在 Mac 上按 Cmd + Opt + I ,然后我们可以在控制台中发出一些 $()
调用并单击返回的对象以检查它们的属性。
在下图中,我们可以看到在之前示例中使用的 $('#pageHeader')
调用的结果在 Firefox 开发者工具中的样子:
$('.boxContainer .box')
调用的结果如下:
jQuery 使用类似数组的对象作为返回元素的包装器,从而使其能够公开一些额外的适用于返回的集合的方法。这是通过原型继承 jQuery.fn
对象来实现的,导致每个 jQuery 对象也可以访问 jQuery 提供的所有方法。这完成了组合模式,该模式提供了适用于集合的方法,这些方法适用于集合的每个成员。因为 jQuery 使用类似数组的对象具有原型继承,所以这些方法可以轻松地作为每个 jQuery 对象的属性访问,就像本章开头的示例中所示:$('#pageHeader').css('font-size', '3em');
。此外,jQuery 还为其 DOM 操作代码添加了一些额外的好处,遵循更小和更不容易出错的代码的目标。例如,当使用 jQuery.fn.html()
方法更改已包含子元素的 DOM 节点的内部 HTML 时,jQuery 首先尝试删除与子元素关联的任何数据和事件处理程序,然后再将它们从页面中删除并附加所提供的 HTML 代码。
让我们看一下 jQuery 如何实现这些适用于集合的方法。对于这个任务,我们可以从 jQuery 的 GitHub 页面下载并查看源代码(github.com/jquery/jquery/releases
),或者使用类似 jQuery 源代码查看器这样的工具,该工具可在james.padolsey.com/jquery
找到。
注意
根据您使用的版本,您可能会在某种程度上获得不同的结果。在编写本书时,作为参考的最新稳定版 jQuery 版本是 v2.2.0。
展示适用于集合的方法如何实现的最简单方法之一是 jQuery.fn.empty()
。您可以通过搜索 "empty:"
或使用 jQuery 源代码查看器并搜索 "jQuery.fn.empty"
来轻松找到它在 jQuery 源代码中的实现。使用其中任一种方式都会带我们到以下代码:
empty: function() {
var elem, i = 0;
for ( ; ( elem = this[ i ] ) != null; i++ ) {
if ( elem.nodeType === 1 ) {
// Prevent memory leaks
jQuery.cleanData( getAll( elem, false ) );
// Remove any remaining nodes
elem.textContent = "";
}
}
return this;
}
如您所见,代码一点也不复杂。jQuery 使用简单的 for
循环遍历集合对象的所有项(称为 this
,因为我们在方法实现内部),对于集合的每个项,即元素节点,它都使用 jQuery.cleanData()
辅助函数清除任何 data-* 属性值,然后立即将其内容设置为空字符串。
注意
关于不同指定节点类型的更多信息,请访问developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
。
与普通 DOM API 相比的优势
为了清楚地展示复合模式提供的好处,我们将在不使用 jQuery 提供的抽象的情况下重新编写我们最初的示例。通过仅使用普通 JavaScript 和 DOM API,我们可以编写一个等效的代码,如下所示:
setTimeout(function() {
var headerElement = document.getElementById('pageHeader');
if (headerElement) {
headerElement.style.fontSize = '3em';
}
var boxContainerElement = document.getElementsByClassName('boxContainer')[0];
if (boxContainerElement) {
var innerBoxElements = boxContainerElement.getElementsByClassName('box');
for (var i = 0; i < innerBoxElements.length; i++) {
var boxElement = innerBoxElements[i];
boxElement.innerHTML +='<br /><br /><i>In case we need simple things</i>.';
boxElement.parentNode.className += ' boxsizer';
}
var clearFloatDiv = document.createElement('div');
clearFloatDiv.className = 'clear';
boxContainerElement.appendChild(clearFloatDiv);
}
}, 700);
再次使用setTimeout
与匿名函数,并将700
毫秒设置为第二个参数。在函数内部,我们使用document.getElementById
来检索已知在页面中具有唯一 ID 的元素,后来在需要检索具有特定类的所有元素时使用document.getElementsByClassName
。我们还使用boxContainerElement.getElementsByClassName('box')
来检索所有具有box
类的元素,这些元素是具有boxContainer
类的元素的后代。
最明显的观察是,在这种情况下,我们需要 18 行代码才能实现相同的结果。相比之下,当使用 jQuery 时,我们只需要 9 行代码,这是后面实现所需行数的一半。使用 jQuery 的$()
函数与 CSS 选择器是检索所需元素的更简单的方法,它还确保与不支持getElementsByClassName()
方法的浏览器的兼容性。然而,除了代码行数和改进的可读性之外,还有更多的好处。作为复合模式的实施者,$()
函数始终检索元素集合,使我们的代码在与我们使用的每个getElement*
方法的差异化处理相比更加统一。我们以完全相同的方式使用$()
函数,无论我们是只想检索具有唯一 ID 的元素,还是具有特定类的一些元素。
作为返回类似数组的对象的额外好处,jQuery 还可以提供更方便的方法来遍历和操作 DOM,例如我们在第一个示例中看到的.css()
、.append()
和.parent()
方法,它们作为返回对象的属性可访问。此外,jQuery 还提供了抽象更复杂的用例的方法,例如没有等效方法可用作 DOM API 的一部分的.addClass()
和.wrap()
。
由于返回的 jQuery 集合对象除了封装的元素不同之外,我们可以以相同的方式使用 jQuery API 的任何方法。正如我们前面所看到的,这些方法适用于检索到的每个元素,而不管元素计数如何。因此,我们不需要单独的for
循环来迭代每个检索到的元素并分别应用我们的操作;相反,我们直接将我们的操作(例如.addClass()
)应用到集合对象上。
为了在后面的示例中继续提供相同的执行安全保证,我们还需要添加一些额外的if
语句来检查null
值。这是必需的,因为,例如,如果未找到headerElement
,将会发生错误,并且其余的代码行将永远不会被执行。有人可能会认为这些检查,如if (headerElement)
和if (boxContainerElement)
在本示例中不是必需的,可以省略。在这个示例中,这似乎是正确的,但实际上这是在开发大型应用程序时发生错误的主要原因之一,其中元素不断地被创建、插入和删除到 DOM 树中。不幸的是,所有语言和目标平台的程序员都倾向于首先编写他们的实现逻辑,然后在以后的某个时候填写这些检查,通常是在测试实现时出现错误后。
遵循复合模式,即使是一个空的 jQuery 集合对象(不包含任何检索到的元素),仍然是一个有效的集合对象,我们可以安全地应用 jQuery 提供的任何方法。因此,我们不需要额外的if
语句来检查集合是否实际包含任何元素,然后应用诸如.css()
之类的方法,仅仅是为了避免 JavaScript 运行时错误。
总的来说,jQuery 使用复合模式提供的抽象使得代码行数减少,更易读、统一,并且有更少的易出错行(比较输入$('#elementID')
与document.getElementById('elementID')
)。
使用复合模式开发应用程序
现在我们已经看到了 jQuery 如何在其架构中使用复合模式,并且还进行了比较以及提供的好处,让我们尝试编写一个自己的示例用例。我们将尝试涵盖本章中早期看到的所有概念。我们将结构化我们的复合对象以成为一个类似数组的对象,操作完全不同结构的对象,提供流畅的 API 以允许链式调用,并且具有应用于集合中所有项目的方法。
一个示例用例
假设我们有一个应用程序,某个时刻需要对数字执行操作。另一方面,它需要操作的项目来自不同的来源,且完全不统一。为了使这个示例有趣,假设数据的一个来源提供普通数字,另一个提供具有包含我们感兴趣数字的特定属性的对象:
var numberValues = [2, 5, 8];
var objectsWithValues = [
{ value: 7 },
{ value: 4 },
{ value: 6 },
{ value: 9 }
];
在我们使用情景的第二个来源返回的对象可能具有更复杂的结构,可能还有一些额外的属性。这些更改不会以任何方式区分我们的示例实现,因为在开发复合对象时,我们只对提供对目标项目的共同部分进行统一处理感兴趣。
复合集合实现
让我们继续并定义构造函数和原型,来描述我们的组合集合对象:
function ValuesComposite() {
this.length = 0;
}
ValuesComposite.prototype.append = function(item) {
if ((typeof item === 'object' && 'value' in item) ||
typeof item === 'number') {
this[this.length] = item;
this.length++;
}
return this;
};
ValuesComposite.prototype.increment = function(number) {
for (var i = 0; i < this.length; i++) {
var item = this[i];
if (typeof item === 'object' && 'value' in item) {
item.value += number;
} else if (typeof item === 'number') {
this[i] += number;
}
}
return this;
};
ValuesComposite.prototype.getValues = function() {
var result = [];
for (var i = 0; i < this.length; i++) {
var item = this[i];
if (typeof item === 'object' && 'value' in item) {
result.push(item.value);
} else if (typeof item === 'number') {
result.push(item);
}
}
return result;
};
在我们的例子中,ValuesComposite()
构造函数非常简单。当使用 new
操作符调用时,它返回一个长度为零的空对象,表示它包装的集合是空的。
注意
有关 JavaScript 基于原型的编程模型的更多信息,请访问 developer.mozilla.org/zh-CN/docs/Web/JavaScript/Introduction_to_Object-Oriented_JavaScript
。
我们首先需要定义一种方法,使我们能够填充我们的组合集合对象。我们定义了 append
方法,该方法检查提供的参数是否是它可以处理的类型之一;在这种情况下,它将参数附加到组合对象上的下一个可用数字属性,并增加 length
属性值。例如,第一个附加的项,无论是具有值属性的对象还是纯数字,都将暴露给组合对象的 "0
" 属性,并可以使用括号属性访问者的语法访问为 myValuesComposition[0]
。
increment
方法被呈现为一个简单的例子方法,可以通过操作所有集合项来操作这些集合。它接受一个数字值作为参数,然后根据它们的类型适当地处理它,将它添加到我们集合的每个项中。由于我们的组合是类似于数组的对象,increment
使用 for
循环来迭代所有集合项,并增加 item.value
(如果项是对象)或存储的实际数字值(当集合项存储的是数字时)。同样地,我们可以继续实现其他方法,例如使我们能够将集合项与特定数字相乘。
为了允许链接我们的组合对象的方法,原型的所有方法都需要返回对对象实例的引用。我们通过简单地在操纵集合的所有方法的最后一行添加 return this;
语句来实现这个目标,例如 append
和 increment
。请记住,例如 getValues
这样不操纵集合但用于返回结果的方法,根据定义,不能链接到传递集合对象实例的后续方法调用。
最后,我们实现 getValues
方法作为检索我们集合中所有项的实际数字值的便捷方式。与 increment
方法类似,getValues
方法抽象了我们集合的不同项类型之间的处理。它遍历集合项,提取每个数字值,并将它们附加到一个 result
数组中,然后返回给它的调用者。
一个例子执行
现在让我们看一个实际的例子,将使用我们刚刚实现的组合对象:
var valuesComposition = new ValuesComposite();
for (var i = 0; i < numberValues.length; i++) {
valuesComposition.append(numberValues[i]);
}
for (var i = 0; i < objectsWithValues.length; i++) {
valuesComposition.append(objectsWithValues[i]);
}
valuesComposition.increment(2)
.append(1)
.append(2)
.append({ value: 3 });
console.log(valuesComposition.getValues());
当在浏览器中执行上述代码时,通过将代码编写到现有页面或直接编写到浏览器控制台中,将记录如下结果:
► Array [ 4, 7, 10, 9, 6, 8, 11, 1, 2, 3 ]
我们正在使用我们的数据源,例如前面显示的numberValues
和objectsWithValues
变量。上述代码遍历它们并将它们的项附加到一个新创建的组合对象实例上。然后,我们通过 2 递增我们的复合集合的值。紧接着,我们使用append
链式三个项目插入,前两个追加数值,第三个追加一个具有值属性的对象。最后,我们使用getValues
方法获取一个包含我们集合所有数值的数组,并在浏览器控制台中记录它。
可选的实现方式
请记住,组合对象不一定要是类似数组的对象,但通常偏好于这样的实现,因为 JavaScript 让创建这样的实现变得很容易。另外,类似数组的实现还有一个好处,就是允许我们使用简单的for
循环迭代集合项。
另一方面,如果不喜欢类似数组的对象,我们可以轻松地在组合对象上使用一个属性来保存我们的集合项。例如,这个属性可以命名为items
,并且可以在我们的方法中使用this.items.push(item)
和this.items[i]
来存储和访问集合的项,分别。
迭代器模式
迭代器模式的关键概念是使用一个负责遍历集合并提供对其项访问的函数。这个函数被称为迭代器,提供了一种访问集合项的方式,而不暴露具体实现和集合对象所使用的底层数据结构。
迭代器提供了关于迭代发生方式的封装级别,将集合项的迭代与其消费者的实现逻辑解耦。
注意
关于单一职责原则 的更多信息,请访问www.oodesign.com/single-responsibility-principle.html
。
jQuery 如何使用迭代器模式
正如我们在本章前面看到的,jQuery 核心$()
函数返回一个类似数组的对象,包装了一组页面元素,并提供了一个迭代函数来遍历它并单独访问每个元素。它实际上进一步提供了一个通用的辅助方法jQuery.each()
,可以迭代数组、类似数组的对象,以及对象属性。
更多技术描述可以在 jQuery API 文档页面api.jquery.com/jQuery.each/
中找到,其中jQuery.each()
的描述如下:
一个通用的迭代器函数,可以无缝地迭代对象和数组。数组和具有长度属性的类数组对象(例如函数的参数对象)通过数值索引迭代,从 0 到 length-1。其他对象通过它们的命名属性进行迭代。
jQuery.each()
辅助函数在 jQuery 源代码的几个地方内部使用。其中一个用途是遍历 jQuery 对象的条目,并对每个条目应用操作,这正如组合模式所建议的那样。简单搜索关键字.each(
会发现有 56 个匹配结果。
注意
在撰写本书时,最新的稳定版本是 v2.2.0,它被用于上述统计信息。
我们可以轻松地跟踪它在 jQuery 源码中的实现,可以通过搜索"each:"
(注意有两个出现)或使用 jQuery 源码查看器搜索"jQuery.each()"
(就像我们在本章早些时候做的那样):
each: function( obj, callback ) {
var length, i = 0;
if ( isArrayLike( obj ) ) {
length = obj.length;
for ( ; i < length; i++ ) {
if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
break;
}
}
} else {
for ( i in obj ) {
if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) {
break;
}
}
}
return obj;
}
这个辅助函数也可以通过使用之前看到的像.append()
这样的方法一样的原型继承在任何 jQuery 对象上访问。你可以轻松找到确切实现这个功能的代码,只需在 jQuery 源码查看器中搜索"jQuery.fn.each()"
或者直接搜索 jQuery 源代码中的each:
(注意有两个出现的地方):
each: function( callback ) {
return jQuery.each( this, callback );
}
使用".each()"
的方法版本可以让我们以更方便的语法直接迭代 jQuery 集合对象的元素。
下面的示例代码展示了如何在我们的代码中使用两种.each()
的方式:
// using the helper function on an array
$.each([3, 5, 7], function(index){
console.log(this + 1);
});
// using the method on a jQuery object
$('.boxContainer .box').each(function(index) {
console.log('I\'m box #' + (index + 1)); // index is zero-based
});
当执行时,前面的代码将在浏览器控制台上记录以下内容:
与组合模式搭配使用
因为组合模式将一个项目集合封装为单个对象,并且迭代器模式可以用于迭代抽象数据结构,所以我们可以很容易地将这两种模式描述为互补的。
可以在哪里使用
迭代器模式可以用于我们的应用程序中抽象化我们从数据结构中访问项目的方式。例如,假设我们需要从以下树形结构中检索大于 4 的所有项目:
var collection = {
nodeValue: 7,
left: {
nodeValue: 4,
left: 2,
right: {
nodeValue: 6,
left: 5,
right: 9
}
},
right: {
nodeValue: 9,
left: 8
}
};
现在让我们实现迭代器函数。因为树形数据结构可以有嵌套,所以我们最终得到下面的递归实现:
function iterateTreeValues(node, callback) {
if (node === null || node === undefined) {
return;
}
if (typeof node === 'object') {
if ('left' in node) {
iterateTreeValues(node.left, callback);
}
if ('nodeValue' in node) {
callback(node.nodeValue);
}
if ('right' in node) {
iterateTreeValues(node.right, callback);
}
} else {
// its a leaf, so the node is the value
callback(node);
}
}
最后,我们得到的实现如下所示:
var valuesArray = [];
iterateTreeValues(collection, function(value) {
if (value > 4) {
valuesArray.push(value);
}
});
console.log(valuesArray);
当执行时,前面的代码将在浏览器控制台上记录以下内容:
► Array [ 5, 6, 9, 7, 8, 9 ]
我们可以清楚地看到迭代器简化了我们的代码。我们再也不需要每次访问满足特定条件的一些项目时烦恼于使用的数据结构的实现细节。我们的实现建立在迭代器公开的通用 API 之上,并且我们的实现逻辑出现在我们为迭代器提供的回调中。
这种封装使我们能够将我们的实现与所使用的数据结构解耦,前提是将提供具有相同 API 的迭代器。例如,在这个例子中,我们可以轻松地将使用的数据结构更改为排序的二叉树或简单数组,并保持我们的实现逻辑不变。
摘要
在本章中,我们对 JavaScript 的 DOM 脚本 API 和 jQuery 进行了复习。我们介绍了复合模式,并看到了它是如何被 jQuery 库使用的。我们看到了复合模式如何简化我们的工作流程,当我们在不使用 jQuery 的情况下重新编写了我们的示例页面之后,并且随后展示了在我们的应用程序中使用复合模式的示例。最后,我们介绍了迭代器模式,并看到了当与复合模式一起使用时它是多么出色。
现在我们已经完成了关于复合模式在我们日常使用 jQuery 方法中发挥重要作用的介绍,我们可以继续下一章,在那里我们将展示观察者模式以及使用 jQuery 在我们的页面中方便地利用它的方式。
第二章:观察者模式
在本章中,我们将展示观察者模式以及我们如何使用 jQuery 在我们的页面中方便地利用它。随后,我们还将解释委托事件观察者模式的变体,当正确应用于网页时,可以简化代码并减少页面所需的内存消耗。
在本章中,我们将:
介绍观察者模式
查看 jQuery 如何使用观察者模式
将观察者模式与使用事件属性进行比较
学习如何避免观察者引起的内存泄漏
介绍委托事件观察者模式并展示其好处
介绍观察者模式
观察者模式的关键概念是有一个对象,通常称为可观察对象或主体,在其生命周期内其内部状态会发生变化。还有其他几个对象,被称为观察者,它们希望在可观察对象/主体的状态发生变化时被通知,以执行一些操作。
观察者可能需要被通知关于可观察对象的任何状态改变,或者仅特定类型的改变。在最常见的实现中,可观察对象维护一个观察者列表,并在适当的状态改变发生时通知它们。如果可观察对象发生状态改变,它会遍历对那种类型的状态改变感兴趣的观察者列表,并执行它们定义的特定方法。
根据观察者模式的定义和计算机科学书籍中的参考实现,观察者被描述为实现了众所周知的编程接口的对象,大多数情况下,该接口对于它们感兴趣的每个可观察对象都是特定的。在状态改变的情况下,可观察对象将执行每个观察者的众所周知方法,因为它在编程接口中被定义。
注意
有关在传统的面向对象编程中如何使用观察者模式的更多信息,您可以访问 www.oodesign.com/observer-pattern.html
。
在 Web 堆栈中,观察者模式通常使用普通的匿名回调函数作为观察者,而不是具有众所周知方法的对象。可以通过等效结果实现观察者模式,因为回调函数保留了对其定义所在环境的变量的引用——这种模式通常被称为闭包 。使用观察者模式而不是回调作为调用或初始化参数的主要优点是观察者模式可以支持单个目标上的几个独立处理程序。
注意
有关闭包的更多信息,您可以访问 developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
。
提示
定义简单回调
回调可以定义为作为另一个函数/方法的参数传递或分配给对象的属性,并且期望在稍后的某个时间点执行的函数。通过这种方式,将我们的回调交给的代码将调用它,将操作或事件的结果传播回定义回调的上下文。
由于将函数注册为观察者的模式被证明更灵活和更简单直接的编程,它在网页堆栈之外的编程语言中也可以找到。其他编程语言通过语言特性或特殊对象(如子例程、lambda 表达式、块和函数指针)提供了等效的功能。例如,Python 也像 JavaScript 一样将函数定义为一等对象,使它们能够被用作回调函数,而 C#则定义了委托作为特殊对象类型,以实现相同的结果。
观察者模式是开发响应用户操作的 Web 界面的一个组成部分,每个 Web 开发人员都在某种程度上使用它,即使在不知情的情况下也是如此。这是因为创建丰富用户界面时,Web 开发人员需要做的第一件事是向页面元素添加事件侦听器,并定义浏览器应该如何响应它们。
传统上,这是通过在需要监听事件的页面元素上使用EventTarget.addEventListener()
方法实现的,例如“点击”,并提供一个回调函数,其中包含需要在事件发生时执行的代码。值得一提的是,为了支持旧版本的 Internet Explorer,需要测试EventTarget.attachEvent()
的存在,并使用它来代替。
注意
有关addEventListener()
和attachEvent()
方法的更多信息,您可以访问developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
和developer.mozilla.org/en-US/docs/Web/API/EventTarget/attachEvent
。
jQuery 如何使用它
jQuery 库在其实现的几个部分中大量使用观察者模式,直接使用addEventListener
方法或创建其自己的抽象来实现。此外,jQuery 提供了一系列抽象和方便的方法,使在 Web 上使用观察者模式变得更容易,并且还在内部使用其中一些方法来实现其他方法。
jQuery 的 on 方法
jQuery.fn.on()
方法是将事件处理程序附加到元素的中央 jQuery 方法,提供了一种简单的方法来采用观察者模式,同时保持我们的代码易于阅读和理解。它将所请求的事件处理程序附加到由$()
函数返回的复合 jQuery 集合对象的所有元素上。
在 jQuery 源码查看器中搜索 jQuery.fn.on
(可在 james.padolsey.com/jquery
找到),或直接在 jQuery 源代码中搜索 on: function
(第一个字符是制表符),将引导我们找到方法的定义,代码共有 67 行。事实上,在内部 on
函数的前 55 行只是处理 jQuery.fn.on()
方法可以被调用的不同方式;接近末尾,我们能看到它实际上使用了内部方法 jQuery.event.add()
:
jQuery.fn.extend({
on: function( types, selector, data, fn ) {
return on( this, types, selector, data, fn );
}
});
function on( elem, types, selector, data, fn, one ) {
/* 55 lines of code handling the method overloads */
return elem.each( function() {
jQuery.event.add( this, types, fn, data, selector );
} );
}
trimmed down version of that method, where some code related to the technical implementation of jQuery and not related to the Observer Pattern has been removed for clarity:
add: function( elem, types, handler, data, selector ) {
/* ... 4 lines of code ... */
elemData = dataPriv.get( elem );
/* ... 13 lines of code ... */
// Make sure that the handler has a unique ID,
// used to find/remove it later
if ( !handler.guid ) {
handler.guid = jQuery.guid++;
}
// Init the element's event structure and main handler,
// if this is the first
if ( !( events = elemData.events ) ) {
events = elemData.events = {};
}
/* ... 9 lines of code ... */
// Handle multiple events separated by a space
types = ( types || "" ).match( rnotwhite ) || [ "" ];
t = types.length;
while ( t-- ) {
/* ... 30 lines of code ... */
// Init the event handler queue if we're the first
if ( !( handlers = events[ type ] ) ) {
handlers = events[ type ] = [];
handlers.delegateCount = 0;
// Only use addEventListener if the special events handler
// returns false
if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) {
if ( elem.addEventListener ) {
elem.addEventListener( type, eventHandle );
}
}
}
/* ... 9 lines of code ... */
// Add to the element's handler list, delegates in front
if ( selector ) {
handlers.splice( handlers.delegateCount++, 0, handleObj );
} else {
handlers.push( handleObj );
}
/* ... 3 lines of code ... */
}
}
现在,让我们通过引用前面高亮的代码,了解 jQuery.event.add()
如何实现观察者模式。
jQuery.event.add()
方法的参数中的 handler
变量存储最初作为参数传递给 jQuery.fn.on()
方法的函数。我们可以称这个函数为我们的观察器函数,因为它在附加到的元素上触发相应事件时被执行。
在第一个高亮的代码区域中,jQuery 创建并给存储在 handler
变量中的观察器函数分配了一个 guid
属性。记住,在 JavaScript 中,可以给函数赋值属性,因为函数是一流对象。jQuery.guid++
语句在分配旧值之后执行,这是因为 jQuery.guid
是 jQuery 和 jQuery 插件在内部使用的全局计数器。观察器函数上的 guid
属性用作标识和定位 jQuery 为每个元素维护的观察器函数列表中的观察器函数的一种方式。例如,jQuery.fn.off()
方法使用它来定位并从与元素关联的观察器函数列表中删除观察器函数。
小贴士
jQuery.guid
是一个页面范围的计数器,它被插件和 jQuery 本身用作集中的方式来检索唯一的整数 ID。它通常用于给元素、对象和函数分配唯一的 ID,以便更容易地在集合中定位它们。每个检索和使用 jQuery.guid
当前值的实现者都有责任在每次使用后也增加属性值(加一)。否则,由于这是一个页面范围的计数器,被 jQuery 插件和 jQuery 自己用于标识,页面可能会面临难以调试的故障。
在第二个和第三个突出显示的代码区域中,jQuery 初始化一个数组来保存每个可能在该元素上触发的事件的观察者列表。需要注意的是,第二个突出显示的代码区域中的观察者列表并不是实际 DOM 元素的属性。正如 jQuery.event.add()
方法开头附近的 dataPriv.get( elem )
语句所示,jQuery 使用单独的映射对象来保存 DOM 元素与其观察者列表之间的关联。通过使用这种数据缓存机制,jQuery 能够避免向 DOM 元素添加额外属性,这些属性是其实现所需要的。
注意
您可以通过搜索 function Data()
在 jQuery 源代码中轻松找到数据缓存机制的实现。这将带您到 Data
类的构造函数,该构造函数后面跟随着在 Data.prototype
对象中定义的类方法的实现。有关更多信息,您可以访问 api.jquery.com/data
。
下一个突出显示的代码区域是 jQuery 检查 EventTarget.addEventListener()
方法是否实际上对该元素可用,然后使用它将事件监听器添加到该元素。在最后一个突出显示的代码区域中,jQuery 将观察者函数添加到其内部列表中,该列表保存了附加到该特定元素的相同事件类型的所有观察者。
注意
根据您所使用的版本,可能会在某种程度上获得不同的结果。编写本书时发布和使用的最新稳定的 jQuery 版本是 v2.2.0。
如果您需要为旧版浏览器(例如低于版本 9 的 Internet Explorer)提供支持,则应使用 jQuery 的 v1.x 版本。编写本书时的最新版本是 v1.12.0,它提供与 v2.2.x 版本完全相同的 API,但也具有在旧版浏览器上运行所需的代码。
为了涵盖旧版浏览器的实现不一致性,jQuery v1.x 中 jQuery.event.add()
的实现要长一些,更复杂一些。其中一个原因是因为 jQuery 还需要测试浏览器是否实际上支持 EventTarget.addEventListener()
,如果不是,则尝试使用 EventTarget.attachEvent()
。
正如我们在前面的代码中看到的,jQuery 的实现遵循观察者模式描述的操作模型,但也融入了一些实现技巧,以使其与 Web 浏览器可用的 API 更有效地配合工作。
文档准备就绪的观察者
jQuery 提供的另一个方便的方法,是被开发人员广泛使用的$.fn.ready()
方法。此方法接受一个函数参数,仅在页面的 DOM 树完全加载后才执行它。这在以下情况下可能会有用:如果您的代码不是最后加载到页面上,而且您不想阻塞初始页面呈现,或者它需要操作的元素被定义在其自身<script>
标签之后。
注意
请记住,$.fn.ready()
方法的工作方式与window.onload
回调和页面的"load"事件稍有不同,它们会等待页面的所有资源加载完毕。有关更多信息,您可以访问api.jquery.com/ready
。
以下代码演示了$.fn.ready()
方法的最常见使用方式:
$(document).ready(function() {
/* this code will execute only after the page has been fully loaded */
})
如果我们尝试找到jQuery.fn.ready
的实现,我们会看到它实际上在内部使用jQuery.ready.promise
来工作:
jQuery.fn.ready = function( fn ) {
// Add the callback
jQuery.ready.promise().done( fn );
return this;
};
/* … a lot lines of code in between */
jQuery.ready.promise = function( obj ) {
if ( !readyList ) {
readyList = jQuery.Deferred();
// Catch cases where $(document).ready() is called
// after the browser event has already occurred.
// Support: IE9-10 only
// Older IE sometimes signals "interactive" too soon
if ( document.readyState === "complete" || ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) {
// Handle it asynchronously to allow ... to delay ready
window.setTimeout( jQuery.ready );
} else {
// Use the handy event callback
document.addEventListener( "DOMContentLoaded", completed );
// A fallback to window.onload, that will always work
window.addEventListener( "load", completed );
}
}
return readyList.promise( obj );
};
正如您在实现中前面高亮显示的代码区域中所见,jQuery 使用addEventListener
来观察document
对象上的DOMContentLoaded
事件何时触发。另外,为了确保它在各种浏览器中都能工作,它还注意到window
对象上的load
事件何时被触发。
jQuery 库还提供了在代码中添加上述功能的更短的方法。由于上述实现实际上不需要对文档的引用,因此我们可以用$().ready(function() {/* ... */ })
来代替。还存在一个$()
函数的重载,它能够达到相同的效果,它的使用方式是$(function() {/* ... */ })
。这两种替代方法使用jQuery.fn.ready
在开发者中受到了严重批评,因为它们通常会导致误解。尤其是第二种更为简短的版本会引起混淆,因为它看起来像一个立即调用的函数表达式 (IIFE ),这是 JavaScript 开发人员大量使用和已学会识别的一种模式。实际上,它只有一个字符($
)的不同,因此在与开发团队讨论之前不建议使用它。
注意
$.fn.ready()
方法也被称为为我们的代码实现惰性初始化/执行模式提供了一种简单的方法。该模式的核心概念是推迟执行一段代码或在以后的时间点加载远程资源。例如,我们可以等待页面完全加载后再添加观察者,或者在下载网页资源之前等待某个特定事件发生。
演示一个样本用例
为了看到观察者模式的实际效果,我们将创建一个示例来展示控制面板的骨架实现。在我们的示例中,用户将能够向其控制面板添加与标题栏中可供选择的一些示例项目和类别相关的信息框。
我们的示例将为我们的项目设有三个预定义的类别:产品 、销售 和广告 。每个类别都将有一系列相关项目,这些项目将出现在类别选择器正下方的区域。用户可以通过使用下拉选择器选择所需的类别,这将更改仪表板的可见选择项目。
我们的仪表板最初将包含关于仪表板用法的提示信息框。每当用户点击类别项之一时,一个新的信息框将出现在我们的三列布局仪表板中。在前述图像中,用户通过点击相关按钮为产品 B 和产品 D 添加了两个新的信息框。
用户还可以通过点击每个信息框顶部右侧的红色关闭按钮来取消这些信息框中的任何一个。在前述图像中,用户取消了产品 D 信息框,然后添加了广告 3 以及销售 类别的第 1、2 和 3 周项目的信息框。
通过仅仅阅读上述描述,我们可以轻松地分离出所有实现我们仪表板所需的用户交互。我们将需要为每一个这些用户交互添加观察者,并在回调函数中编写执行适当 DOM 操作的代码。
具体来说,我们的代码将需要:
现在让我们继续并查看所需的 HTML、CSS 和 JavaScript 代码,以完成前面的示例。让我们从 HTML 代码开始,假设我们将其保存在名为Dashboard Example.html
的文件中,代码如下:
<!DOCTYPE html>
<html>
<head>
<title>Dashboard Example</title>
<link rel="stylesheet" type="text/css" href="dashboard-example.css">
</head>
<body>
<h1 id="pageHeader">Dashboard Example</h1>
<div class="dashboardContainer">
<section class="dashboardCategories">
<select id="categoriesSelector">
<option value="0" selected>Products</option>
<option value="1">Sales</option>
<option value="2">Advertisements</option>
</select>
<section class="dashboardCategory">
<button>Product A</button>
<button>Product B</button>
<button>Product C</button>
<button>Product D</button>
<button>Product E</button>
</section>
<section class="dashboardCategory hidden">
<button>1st week</button>
<button>2nd week</button>
<button>3rd week</button>
<button>4th week</button>
</section>
<section class="dashboardCategory hidden">
<button>Advertisement 1</button>
<button>Advertisement 2</button>
<button>Advertisement 3</button>
</section>
<div class="clear"></div>
</section>
<section class="boxContainer">
<div class="boxsizer">
<article class="box">
<header class="boxHeader">
Hint!
<button class="boxCloseButton">✖</button>
</header>
Press the buttons above to add information boxes...
</article>
</div>
</section>
<div class="clear"></div>
</div>
<script type="text/javascript" src="img/jquery.js"></script>
<script type="text/javascript" src="img/dashboard-example.js">
</script>
</body>
</html>
在前述 HTML 中,我们将所有与仪表板相关的元素放在带有dashboardContainer
CSS 类的<div>
元素内。这将使我们能够有一个中心起点来搜索我们仪表板的元素,并且作用域我们的 CSS。在它内部,我们使用一些 HTML5 语义元素定义了两个<section>
元素,以便使用逻辑区域划分仪表板。
第一个带有dashboardCategories
类的<section>
用于保存我们仪表板的类别选择器。在其中,我们有一个带有 ID categoriesSelector
的<select>
元素,用于过滤可见的类别项目,以及三个带有dashboardCategory
类的子部分,用于包装在单击时将用信息框填充仪表板的<button>
元素。其中两个还具有hidden
类,以便在页面加载时仅显示第一个,通过匹配类别选择器的最初选择选项(<option>
)。此外,在第一节的末尾,我们还添加了一个带有clear
类的<div>
,正如我们在第一章中看到的那样,它将用于清除浮动的<button>
元素。
带有boxContainer
类的第二个<section>
用于保存我们仪表板的信息框。最初,它仅包含一个关于如何使用仪表板的提示。我们使用带有boxsizer
类的<div>
元素来设置框尺寸,以及带有box
类的 HTML5 <article>
元素来添加所需的边框填充和阴影,类似于第一章中的框元素。
每个信息框除了其内容之外,还包含一个带有boxHeader
类的<header>
元素和一个带有boxCloseButton
类的<button>
元素,当点击时,会移除包含它的信息框。我们还使用了✖
HTML 字符代码作为按钮的内容,以获得更漂亮的“x”标记,并避免使用单独的图像来实现此目的。
最后,由于信息框也是浮动的,我们还需要一个带有clear
类的<div>
放置在boxContainer
的末尾。
在前述 HTML 的<head>
中,我们还引用了一个名为dashboard-example.css
的 CSS 文件,其内容如下:
.dashboardCategories {
margin-bottom: 10px;
}
.dashboardCategories select,
.dashboardCategories button {
display: block;
width: 200px;
padding: 5px 3px;
border: 1px solid #333;
margin: 3px 5px;
border-radius: 3px;
background-color: #FFF;
text-align: center;
box-shadow: 0 1px 1px #777;
cursor: pointer;
}
.dashboardCategories select:hover,
.dashboardCategories button:hover {
background-color: #DDD;
}
.dashboardCategories button {
float: left;
}
.box {
padding: 7px 10px;
border: solid 1px #333;
margin: 5px 3px;
box-shadow: 0 1px 2px #777;
}
.boxsizer {
float: left;
width: 33.33%;
}
.boxHeader {
padding: 3px 10px;
margin: -7px -10px 7px;
background-color: #AAA;
box-shadow: 0 1px 1px #999;
}
.boxCloseButton {
float: right;
height: 20px;
width: 20px;
padding: 0;
border: 1px solid #000;
border-radius: 3px;
background-color: red;
font-weight: bold;
text-align: center;
color: #FFF;
cursor: pointer;
}
.clear { clear: both; }
.hidden { display: none; }
正如您在我们的 CSS 文件中所看到的,首先我们在具有dashboardCategories
类的元素下面添加了一些空间,并且为<select>
元素和其中的按钮定义了相同的样式。为了使其与默认浏览器样式区分开来,我们添加了一些填充,圆角边框,悬停鼠标指针时的不同背景颜色以及它们之间的一些空间。我们还定义了我们的<select>
元素应该作为块独自显示在其行中,以及分类项目按钮应该相邻浮动。我们再次使用了boxsizer
和box
CSS 类,就像在第一章,jQuery 和组合模式复习 中所做的一样;第一个用于创建三列布局,第二个实际提供信息框的样式。我们继续定义boxHeader
类,应用于我们信息框的<header>
元素,并定义一些填充,灰色背景颜色,轻微阴影,以及一些负边距,以抵消框填充的效果并将其放置在其边框旁边。
要完成信息框的样式设计,我们还定义了boxCloseButton
CSS 类,它(i)将框的关闭按钮浮动到框的<header>
内的右上角,(ii)定义了20px
的宽度和高度,(iii)覆盖了默认浏览器的<button>
样式以零填充,并且(iv)添加了一个单像素的黑色边框,圆角和红色背景颜色。最后,就像在第一章,jQuery 和组合模式复习 中,我们定义了clear
实用的 CSS 类以防止元素被放置在前面浮动元素的旁边,并且还定义了hidden
类作为隐藏页面元素的方便方式。
在我们的 HTML 文件中,我们引用了 jQuery 库本身以及一个名为dashboard-example.js
的 JavaScript 文件,其中包含我们的仪表板实现。遵循创建高性能网页的最佳实践,我们将它们放在了</body>
标签之前,以避免延迟初始页面渲染:
$(document).ready(function() {
$('#categoriesSelector').change(function() {
var $selector = $(this);
var selectedIndex = +$selector.val();
var $dashboardCategories = $('.dashboardCategory');
var $selectedItem = $dashboardCategories.eq(selectedIndex).show();
$dashboardCategories.not($selectedItem).hide();
});
function setupBoxCloseButton($box) {
$box.find('.boxCloseButton').click(function() {
$(this).closest('.boxsizer').remove();
});
}
// make the close button of the hint box work
setupBoxCloseButton($('.box'));
$('.dashboardCategory button').on('click', function() {
var $button = $(this);
var boxHtml = '<div class="boxsizer"><article class="box">' +
'<header class="boxHeader">' +
$button.text() +
'<button class="boxCloseButton">✖' +
'</button>' +
'</header>' +
'Information box regarding ' + $button.text() +
'</article></div>';
$('.boxContainer').append(boxHtml);
setupBoxCloseButton($('.box:last-child'));
});
});
我们将所有代码放在了一个$(document).ready()
调用中,以延迟其执行直到页面的 DOM 树完全加载。如果我们将代码放在<head>
元素中,这将是绝对必要的,但在任何情况下遵循的最佳实践也是很好的。
首先,我们使用$.fn.change()
方法为categoriesSelector
元素的change
事件添加了一个观察者。实际上,这是$.fn.on('change', /* … */)``方法的一种简写方法。在 jQuery 中,作为观察者使用的函数内的
this关键字的值保存着被触发事件的 DOM 元素的引用。这适用于所有注册观察者的 jQuery 方法,从核心的
\(.fn.on()`到方便的`\) .fn.change()和
\(.fn.click()`方法。所以我们使用`\) ()函数用
元素创建一个 jQuery 对象,并将其存储在\(selector`变量中。然后,我们使用`\)selector.val()来检索所选的值,并通过使用+运算符将其转换为数值。紧接着,我们检索dashboardCategory的元素,并将结果缓存到\(dashboardCategories`变量中。然后,我们通过找到并显示位置等于`selectedIndex`变量值的类别来继续,并将结果的 jQuery 对象存储到`\)selectedItem变量中。最后,我们使用\(.fn.not()`方法使用`\)selectedItem`变量检索并隐藏除了刚刚显示的类别元素之外的所有类别元素。
在下一个代码部分中,我们定义了setupBoxCloseButton函数,该函数将用于初始化关闭按钮的功能。它期望一个带有盒子元素的 jQuery 对象作为参数,并且对于每一个盒子元素,搜索它们的后代以找到我们在关闭按钮上使用的boxCloseButton CSS 类。使用$.fn.click(),这是$.fn.on('click', /* fn */)的一个方便方法,我们注册一个匿名函数,以便在每次点击事件被触发时执行,该函数使用$.fn.closest()方法来查找具有boxsizer类的第一个祖先元素,并将其从页面中删除。紧接着,我们对已经存在于页面中在页面加载时的盒子元素调用此函数一次。在这种情况下,使用提示的盒子元素。
注意
使用$.fn.closest()方法时需要注意的另一件事情是,它从 jQuery 集合的当前元素开始测试给定的选择器,然后再进行其祖先元素的测试。有关更多信息,您可以访问其文档 api.jquery.com/closest。
在最终的代码部分中,我们使用$.fn.on()方法在每个类别按钮上添加点击事件的观察者。在这种情况下,在匿名观察者函数内部,我们使用this关键字,它保存了被点击的<button>的 DOM 元素,并使用$()方法创建一个 jQuery 对象,并将其引用缓存在$button变量中。紧接着,我们使用$.fn.text()方法获取按钮的文本内容,并结合它构建信息框的 HTML 代码。对于关闭按钮,我们使用✖ HTML 字符代码,它将被渲染为更漂亮的“X”图标。我们创建的模板基于最初可见提示框的 HTML 代码;在本章的示例中,我们使用纯字符串拼接。最后,我们将生成的 HTML 代码附加到boxContainer,由于我们期望它是最后一个元素,我们使用$()函数查找它,并将其作为参数传递给setupBoxCloseButton。
与事件属性相比如何
在 DOM Level 2 Events 规范中定义EventTarget.addEventListener()之前,事件监听器的注册方法是通过使用可用于 HTML 元素的事件属性或可用于 DOM 节点的元素事件属性。
注意
有关 DOM Level 2 事件规范和事件属性的更多信息,您可以访问www.w3.org/TR/DOM-Level-2-Events和developer.mozilla.org/en-US/docs/Web/Guide/HTML/Event_attributes。
事件属性是一组可用于 HTML 元素的属性,提供了一种声明性的方法来定义应在触发该元素上特定事件时执行的 JavaScript 代码片段(最好是函数调用)。由于它们的声明性特质和简单易用的方式,这通常是新开发者首次接触网页开发中的事件的方式。
如果我们在上面的示例中使用了事件属性,那么信息框中关闭按钮的 HTML 代码将如下所示:
<article class="box">
<header class="boxHeader">
Hint!
<button onclick="closeInfoBox();"
class="boxCloseButton">✖</button>
</header>
Press the buttons above to add information boxes...
</article>
另外,我们应该更改用于创建新信息框的模板,并将closeInfoBox函数暴露在window对象上,以便可以从 HTML 事件属性中访问:
window.closeInfoBox = function() {
$(this).closest('.boxsizer').remove();
};
使用事件属性而不是观察者模式的一些缺点包括:
它使得更难定义在元素上触发事件时需要执行的多个单独操作
这会使页面的 HTML 代码变得更大,且不易读取
它违反了关注点分离原则,因为它在我们的 HTML 中添加了 JavaScript 代码,可能会使错误更难跟踪和修复
大多数情况下,这会导致事件属性中调用的函数暴露给全局的 window 对象,从而“污染”全局命名空间。
使用元素事件属性不需要对我们的 HTML 进行任何更改,所有的实现都保留在我们的 JavaScript 文件中。我们在 setupBoxCloseButton 函数中需要进行的更改将使它看起来如下所示:
function setupBoxCloseButton($box) {
var $closeButtons = $box.find('.boxCloseButton');
for (var i = 0; i < $closeButtons.length; i++) {
$closeButtons[i].onclick = function() {
this.onclick = null;
$(this).closest('.boxsizer').remove();
};
}
}
请注意,为了方便起见,我们仍然在 DOM 操作中使用 jQuery,但生成的代码仍然具有前述的一些缺点。更重要的是,为了避免内存泄漏,我们还需要在从页面中移除元素之前删除分配给 onclick 属性的函数,如果它包含对其应用的 DOM 元素的引用。
使用当今浏览器提供的工具,我们甚至可以达到事件属性声明性质所提供的便利程度。在下图中,您可以看到 Firefox 开发者工具在我们使用它们来检查附加了事件侦听器的页面元素时为我们提供了有用的反馈:
如前图所示,所有附加了观察者的元素旁都有一个 ev 标志,单击该标志将显示一个对话框,显示当前附加的所有事件侦听器。为了使我们的开发体验更好,我们可以直接看到这些处理程序所在的文件和行。此外,我们可以单击它们以展开并显示它们的代码,或者单击它们前面的标志以导航到其源并添加断点。
使用观察者模式而不是事件属性的最大好处之一,在于当某个事件发生时需要执行多个操作的情况下清晰可见。假设我们还需要在示例仪表板中添加一个新功能,该功能可以防止用户意外双击类别项目按钮并将相同的信息框两次添加到仪表板。新的实现理想上应完全独立于现有的实现。使用观察者模式,我们只需添加以下代码来观察按钮点击并在 700 毫秒内禁用该按钮:
$(document).ready(function() {
$('.dashboardCategory button').on('click', function() {
var $button = $(this);
$button.prop('disabled', true);
setTimeout(function() {
$button.prop('disabled', false);
}, 700);
});
});
上述代码确实完全独立于基本实现,我们可以将其放在同一个或不同的 JS 文件中,并将其加载到我们的页面中。这在使用事件属性时会更加困难,因为它要求我们在同一个事件处理程序函数中同时定义两个动作;结果,它会强烈地耦合两个独立的动作。
避免内存泄漏
正如我们之前所见,使用观察者模式处理网页上的事件有一些强大的优势。当使用EventTarget.addEventListener()方法向元素添加观察者时,我们还需要记住,为了避免内存泄漏,我们在将这些元素从页面中移除之前,还必须调用EventTarget.removeEventListener()方法,以便观察者也被移除。
注意
有关从元素中移除事件侦听器的更多信息,您可以访问developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/removeEventListener,或者查看 jQuery 等效方法,请访问api.jquery.com/off/。
jQuery 库的开发者意识到这样一个实现上的关注点可能会被轻易地忘记或者没有正确处理,从而使得观察者模式的采用看起来更加复杂,因此他们决定将适当的处理封装在jQuery.event实现中。因此,当使用任何 jQuery 的事件处理方法,比如核心的$.fn.on()或者任何方便的方法,比如$.fn.click()或$.fn.change()时,观察者函数由 jQuery 本身跟踪,并且如果我们后来决定将元素从页面中移除,它们将被正确取消注册。正如我们之前在jQuery.event的实现中看到的那样,jQuery 将每个元素的观察者存储在一个单独的映射对象中。每次我们使用一个 jQuery 方法来从页面中移除 DOM 元素时,它首先通过检查映射对象来确保移除这些元素或任何后代元素上附加的任何观察者。因此,即使我们不使用任何显式移除我们添加到创建的元素上的观察者的方法,我们之前使用的示例代码也不会造成内存泄漏。
提示
在混合使用 jQuery 和纯 DOM 操作时要小心
即使所有 jQuery 方法都可以确保您免受由从未取消注册的观察者引起的内存泄漏,但请记住,如果使用纯 DOM API 的方法从文档中移除元素,则无法保护您。如果使用Element.remove()和Element.removeChild()等方法,并且被移除的元素或其后代有附加的观察者,则它们将不会被自动取消注册。当分配给Element.innerHTML属性时也是如此。
介绍委托事件观察者模式
现在我们已经学习了如何使用 jQuery 使用观察者模式的一些高级细节,我们将介绍一种特殊的变体,它完全适用于 Web 平台并提供了一些额外的好处。委托事件观察器模式(简称委托观察器模式)经常用于 Web 开发,并利用了大多数在 DOM 元素上触发的事件具有的冒泡特性。例如,当我们单击页面元素时,单击事件立即在其上触发,然后在达到 HTML 文档根之前还会在所有父元素上触发。使用 jQuery 的 $.fn.on 方法的略有不同的重载版本,我们可以轻松地为触发在特定子元素上的委托事件创建和附加观察者。
注意
术语“事件委托”描述了一种编程模式,其中事件的处理程序不直接附加到感兴趣的元素,而是附加到其祖先元素之一。
如何简化我们的代码
使用委托事件观察器模式重新实现我们的仪表板示例将只需要更改包含的 JavaScript 文件的代码如下:
$(document).ready(function() {
$('#categoriesSelector').change(function() {
var $selector = $(this);
var selectedIndex = +$selector.val();
var $dashboardCategories = $('.dashboardCategory');
var $selectedItem = $dashboardCategories.eq(selectedIndex).show();
$dashboardCategories.not($selectedItem).hide();
});
$('.dashboardCategories').on('click', 'button', function() {
var $button = $(this);
var boxHtml = '<div class="boxsizer"><article class="box">' +
'<header class="boxHeader">' +
$button.text() +
'<button class="boxCloseButton">✖' +
'</button>' +
'</header>' +
'Information box regarding ' + $button.text() +
'</article></div>';
$('.boxContainer').append(boxHtml);
});
$('.boxContainer').on('click', '.boxCloseButton', function() {
$(this).closest('.boxsizer').remove();
});
});
最明显的区别在于新的实现更短。通过仅为适用于多个页面元素的每个操作定义一个观察者,可以获得益处。因此,我们使用 $.fn.on(events, selector, handler) 方法的重载变体。
具体来说,我们向具有 dashboardCategories CSS 类的页面元素添加一个观察者,并监听其任何 <button> 后代触发的 click 事件。类似地,我们向 boxContainer 元素添加一个观察者,该观察者将在其匹配 .boxCloseButton CSS 选择器的任何后代上触发单击事件时执行。
由于上述观察者不仅适用于在注册时存在于页面上的元素,还适用于以后任何时间添加的匹配指定 CSS 选择器的任何元素;我们能够将处理关闭按钮点击的代码解耦,并将其放在一个单独的观察者中,而不是每次添加新信息框时都注册一个新观察者。因此,负责在仪表板中添加新信息框的观察者更简单,只需处理信息框的 HTML 创建和插入到仪表板中,从而实现了更大的关注点分离。此外,我们不再需要在单独的代码片段中处理提示框关闭按钮的观察者注册。
比较内存使用优势
我们现在将比较使用$.fn.on()方法与简单和委托事件观察者模式变体时的内存使用差异。为了实现这一点,我们将打开我们仪表板示例的两个实现,并在 Chrome 上比较它们的内存使用情况。要打开 Chrome 的开发者工具,只需按下F12,然后导航到Timeline选项卡。我们在 Chrome 的Timeline选项卡中按下"record"按钮,然后按下每个类别项按钮 10 次,将 120 个信息框添加到我们的仪表板。在添加所有框之后,我们总共有 121 个打开的框,因为提示框仍然打开,然后停止时间线记录。
我们初始观察者模式实现的时间线结果如下:
为委托事件观察者模式实现重复相同的过程将提供更平滑的时间线,显示较少的对象分配和垃圾收集,如下所示:
正如在前面的图片中所示,我们最终在两种情况下都有 1192 个页面元素,但在第一种实现中,我们使用了 134 个事件侦听器,而在使用事件委托的实现中,我们最初创建了三个事件侦听器,并且实际上从未添加过其他事件侦听器。
最后,正如你在图表中看到的蓝线所示,委托版本的内存消耗保持相对稳定,仅增加了约 200 KB。另一方面,在原始实现中,堆大小增加了五倍多,增加了超过 1 MB。
添加这么多元素可能并不是一个实际的使用情况,但是仪表板可能不会是你页面上唯一的动态部分。因此,在一个相对复杂的网页中,如果我们使用委托事件观察者模式的变体重新实现了它的每个适用部分,我们可能会得到类似的改进。
摘要
在本章中,我们了解了观察者模式,以及它如何使我们网页的 HTML 代码更清晰,以及它如何将其与我们应用程序的代码解耦。我们了解了 jQuery 如何在其方法中添加保护层,以保护我们免受未检测到的内存泄漏的影响,这可能会在不使用 jQuery DOM 操作方法时,通过向元素添加观察者而发生。
我们还尝试了委托事件观察者模式变体,并将其用于重写我们的初始示例。我们比较了这两种实现,并看到它如何简化了在页面加载后应用于许多页面元素的代码编写。最后,我们就普通观察者模式与其委托变体的内存消耗进行了比较,并强调了它如何通过减少所需的附加观察者数量来减少页面的内存消耗。
现在我们已经完成了关于观察者模式如何用于监听用户操作的介绍,我们可以继续下一章,了解自定义事件、发布/订阅模式以及它们如何导致更解耦的实现方式。
第三章:发布/订阅模式
在本章中,我们将展示发布/订阅模式,这是一种设计模式,与观察者模式非常相似,但具有更明确的角色,更适合更复杂的用例。我们将看到它与观察者模式的区别,以及 jQuery 如何采用其某些概念并将其带入其观察者模式实现。
后来,我们将继续并使用此模式重写我们上一章的示例。我们将利用此模式的优势来添加一些额外功能,并减少我们的代码与网页元素之间的耦合。
在本章中,我们将:
引入发布/订阅模式
了解它与观察者模式的区别以及它的优势在哪里
了解 jQuery 如何将一些特性带入其方法中
学习如何使用 jQuery 发射自定义事件
使用此模式重写并扩展来自第二章的示例,观察者模式,
介绍发布/订阅模式
发布/订阅模式是一种消息模式,其中称为发布者的消息发射器向许多被称为订阅者的接收者多播消息,这些接收者已表达了对接收此类消息的兴趣。这种模式的关键概念,也常简称为 Pub/Sub 模式,是提供一种方法以避免发布者和订阅者之间的依赖关系。
这种模式的一个额外概念是使用主题,订阅者使用这些主题来表示他们只对特定类型的消息感兴趣。这样一来,发布者在发送消息之前就可以对订阅者进行过滤,并且只将该消息分发给适当的订阅者,从而减少了双方所需的流量和工作量。
另一个常见的变体是使用一个称为代理的中央,应用程序范围内的对象,它将由发布者产生的消息中继给相关的订阅者。在这种情况下,代理充当了一个众所周知的消息处理程序,用于发送和订阅消息主题。这使我们能够不将不同的应用程序部分耦合在一起,而只引用代理本身以及我们的组件感兴趣的主题。尽管主题可能不是该模式的第一变体中的绝对要求,但该变体在可扩展性方面起着至关重要的作用,因为通常会存在比发布者和订阅者少得多的代理(如果不只有一个)。
通过遵循订阅方案,发布者的代码完全与订阅者解耦,这意味着发布者不必知道依赖于它们的对象。因此,我们不需要在发布者中硬编码每个应该在应用程序的不同部分执行的单独操作。相反,应用程序的组件以及可能的第三方扩展只订阅他们需要知道的主题/事件。在这样的分布式架构中,向现有应用程序添加新功能只需要对其依赖的应用程序组件进行最小到无需更改。
它与观察者模式的不同之处
最基本的区别在于,根据定义,发布/订阅模式是一种单向消息模式,可以传递消息,而观察者模式只是描述如何通知观察者有关主题特定状态更改的方法。
此外,与观察者模式不同,带有代理的发布/订阅模式导致实现的不同部分之间的代码更松散耦合。这是因为观察者需要知道发出事件的主题;然而,另一方面,发布者及其订阅者只需知道使用的代理。
如何被 jQuery 应用
再次提醒,jQuery 库为我们提供了一种方便的方式来利用代码中的发布/订阅模式。开发人员决定通过扩展jQuery.fn.on()和jQuery.fn.trigger()方法的能力来处理和发出自定义事件,而不是通过添加名为"publish"和"subscribe"的新方法并引入新概念来扩展其 API。这样,jQuery 可以使用它已经提供的方便的方法来实现使用发布者/订阅者通信方案。
jQuery 中的自定义事件
自定义事件允许我们使用几乎任何用户定义的字符串值作为我们可以为其添加监听器的通用事件,并在页面元素上手动触发它。作为一个额外但宝贵的特性,自定义事件还可以携带一些额外的数据以传递给事件的监听器。
jQuery 库在任何网页规范实际添加之前就已经添加了自己的自定义事件实现。这样,就证明了在 web 开发中使用它们时可以有多么有用。正如我们在上一章中看到的,在 jQuery 中,有一个特定的部分处理通用元素事件和自定义事件。jQuery.event 对象保存了与触发和监听事件相关的所有内部实现。此外,jQuery.Event 类是 jQuery 为了满足通用元素事件和其自定义事件实现需要而专门使用的包装器。
使用自定义事件实现发布/订阅模式
在上一章中,我们看到 jQuery.fn.on() 方法可以用于在元素上添加事件侦听器。我们还看到它的实现在维护添加的处理程序列表并在需要时通知它们。此外,事件名称似乎具有与主题一样的协调目的。这种实现语义似乎与 Pub/Sub 模式完全匹配。
jQuery.fn.trigger() 方法实际上使用了内部的 jQuery.event.trigger() 方法,在 jQuery 中用于触发事件。它在内部处理程序列表上进行迭代,并使用所请求的事件以及自定义事件定义的任何额外参数执行它们。再次,这也符合 Pub/Sub 模式的操作要求。
因此,jQuery.fn.trigger() 和 jQuery.fn.on() 似乎符合 Pub/Sub 模式的需求,可以分别用于"publish"和"subscribe"方法。由于它们都可在 jQuery.fn 对象上使用,因此我们可以在任何 jQuery 对象上使用这些方法。这个 jQuery 对象将作为发布者和订阅者之间的中间实体,完全符合代理的定义。
一个很好的共同做法,也被很多 jQuery 插件所使用,是使用包含应用程序或插件实现的最外层页面元素作为代理。另一方面,jQuery 实际上允许我们使用任何对象作为代理,因为它实际上只需要一个目标来发出观察我们自定义事件的通知。因此,我们甚至可以使用一个空对象作为我们的代理,比如 $({}),以防使用页面元素看起来太受限制或根据 Pub/Sub 模式不够清晰。这实际上就是 jQuery Tiny Pub/Sub 库所做的事情,还有一些方法别名,这样我们实际上使用的是名为 "publish" 和 "subscribe" 的方法,而不是 jQuery 的 "on" 和 "trigger"。有关 Tiny 的更多信息,您可以访问其仓库页面github.com/cowboy/jquery-tiny-pubsub。
展示一个示例用例
为了了解 Pub/Sub 模式的使用,并方便将其与观察者模式进行比较,我们将重新编写来自第二章中的仪表板示例,The Observer Pattern,并使用这种模式。这还将清楚地演示这种模式如何帮助我们解耦实现的各个部分,并使其更具扩展性和可伸缩性。
在仪表板示例中使用 Pub/Sub
adapt to the Publisher/Subscriber Pattern:
$(document).ready(function() {
window.broker = $('.dashboardContainer');
$('#categoriesSelector').change(function() {
var $selector = $(this);
var message = { categoryID: $selector.val() };
broker.trigger('dashboardCategorySelect', [message]);
});
broker.on('dashboardCategorySelect', function(event, message) {
var $dashboardCategories = $('.dashboardCategory');
var selectedIndex = +message.categoryID;
var $selectedItem = $dashboardCategories.eq(selectedIndex).show();
$dashboardCategories.not($selectedItem).hide();
});
$('.dashboardCategory').on('click', 'button', function() {
var $button = $(this);
var message = { categoryName: $button.text() };
broker.trigger('categoryItemOpen', [message]);
});
broker.on('categoryItemOpen', function(event, message) {
var boxHtml = '<div class="boxsizer"><article class="box">' +
'<header class="boxHeader">' +
message.categoryName +
'<button class="boxCloseButton">✖' +
'</button>' +
'</header>' +
'Information box regarding ' + message.categoryName +
'</article></div>';
$('.boxContainer').append(boxHtml);
});
$('.boxContainer').on('click', '.boxCloseButton', function() {
var boxIndex = $(this).closest('.boxsizer').index();
var message = { boxIndex: boxIndex };
broker.trigger('categoryItemClose', [message]);
});
broker.on('categoryItemClose', function(event, message) {
$('.boxContainer .boxsizer').eq(message.boxIndex).remove();
});
});
就像我们以前的实现一样,我们使用$(document).ready()来延迟执行我们的代码,直到页面完全加载。首先,我们声明我们的代理并将其分配给window对象上的一个新变量,以便在页面上全局可用。对于我们应用程序的代理,我们使用了一个具有我们实现的最外层容器的 jQuery 对象,我们的情况下是具有dashboardContainer类的<div>元素。
提示
即使使用全局变量通常是一个反模式,我们将代理存储为全局变量,因为它是整个应用程序的重要同步点,并且必须对我们实现的每一部分都可用,即使是存储在单独的.js文件中的部分也是如此。正如我们将在下一章关于模块模式的讨论中所讨论的,前面的代码可以通过将代理存储为应用程序命名空间的属性来改进。
为了实现类别选择器,我们首先观察<select>元素的change事件。当所选类别更改时,我们使用一个简单的 JavaScript 对象创建我们的消息,并将所选<option>的value存储在categoryID属性中。然后,我们使用 jQuery 的jQuery.fn.trigger()方法在我们的代理上发布它到dashboardCategorySelect主题。这样,我们从 UI 元素事件移动到一个包含所有所需信息的具有应用程序语义的消息。在我们订阅者的代码中,我们使用jQuery.fn.on()方法在我们的代理上使用dashboardCategorySelect主题作为参数(我们的自定义事件),就像我们监听简单的 DOM 事件一样。然后订阅者使用从接收到的消息中的categoryID,就像我们在前一章的实现中所做的那样,来显示适当的类别项。
按照相同的方法,我们将处理仪表板中添加和关闭信息框的代码分割成发布者和订阅者。为了这个演示的需要,categoryItemOpen 主题的消息只包含我们想要打开的类别的名称。然而,在一个从服务器检索框内容的应用程序中,我们可能会使用类别项 ID。然后订阅者使用消息中的类别项名称创建并插入所请求的信息框。
类似地,categoryItemClose主题的消息包含我们要移除的框的索引。我们的发布者使用jQuery.fn.closest()方法遍历 DOM 并到达我们的boxContainer元素的子元素,然后使用jQuery.fn.index()方法在其同级元素中找到其位置。然后,订阅者使用从接收到的消息中的boxIndex属性和jQuery.fn.eq()方法来过滤并仅从仪表板中移除所请求的信息框。
提示
在更复杂的应用程序中,我们可以将每个信息框元素与一个新检索到的jQuery.guid关联起来,而不是使用框索引,使用一个映射对象。这样,我们的发布者就可以在消息中使用那个guid而不是(与 DOM 相关的)元素索引。订阅者将在映射对象中搜索该guid以定位并删除相应的框。
由于我们正在试图展示 Pub/Sub 模式的优势,这种实现变化不是为了简化与观察者模式的比较而引入的,而是作为读者的推荐练习留下的。
总结以上内容,我们使用了dashboardCategorySelect、categoryItemOpen和categoryItemClose主题作为我们的应用级事件,以便将用户操作的处理与它们的来源(UI 元素)解耦。因此,我们现在有了专门的可重用代码片段,用于操控我们仪表板的内容,这等同于将它们抽象为单独的函数。这使我们能够以编程方式发布一系列消息,以便我们可以,例如,删除所有现有的信息框并添加当前选择类别的所有类别项。或者,更好的是,让仪表板显示每个类别的所有项 10 秒,然后切换到下一个。
扩展实现
为了展示 Pub/Sub 模式带来的可扩展性,我们将通过添加一个计数器来扩展我们当前的示例,用于显示当前在仪表板中打开的框的数量。
对于计数器的实现,我们需要向我们的页面添加一些额外的 HTML,并创建并引用一个新的 JavaScript 文件来保存计数器的实现:
...
</section>
<div style="margin-left: 5px;">
Open boxes:
<output id="dashboardItemCounter">1</output>
</div>
<section class="boxContainer">
...
在示例的 HTML 页面中,我们需要添加一个额外的<div>元素来容纳我们的计数器和一些描述文本。对于我们的计数器,我们使用一个<output>元素,这是一个语义化的 HTML5 元素,用于呈现用户操作的结果。浏览器将像对待常规的<span>元素一样对待它,因此它将出现在其描述的旁边。此外,由于我们的仪表板中最初有一个提示框是打开的,我们使用1作为其初始内容:
$(document).ready(function() {
broker.on('categoryItemOpen categoryItemClose', function (event, message) {
var $counter = $('#dashboardItemCounter');
var count = parseInt($counter.text());
if (event.type === 'categoryItemOpen') {
$counter.text(count + 1);
} else if (event.type === 'categoryItemClose' && count > 0) {
$counter.text(count - 1);
}
});
});
对于计数器实现本身,我们只需要向仪表板的代理添加一个额外的订阅者,该代理是全局可用的,因为我们将其附加到window对象上。我们通过将它们以空格分隔传递给jQuery.fn.on()方法来同时订阅两个主题。在此之后,我们定位具有 ID dashboardItemCounter 的计数器<output>元素,并将其文本内容解析为数字。为了根据消息接收到的主题来区分我们的动作,我们使用 jQuery 传递给我们匿名函数的第一个参数,即event对象,该对象是我们的订阅者。具体来说,我们使用event对象的type属性,该属性保存了接收到的消息的主题名称,并根据其值更改计数器的内容。
注意
有关 jQuery 提供的事件对象的更多信息,请访问api.jquery.com/category/events/event-object/。
类似地,我们也可以重写防止类别项按钮意外双击的代码。所需的一切就是为categoryItemOpen主题添加额外的订阅者,并使用消息的categoryName属性来定位按下的按钮。
使用任何对象作为代理
在我们的示例中,我们将仪表板的最外层容器元素用作我们的代理,但通常也可以使用$(document)对象作为代理。使用应用程序的容器元素被认为是一种很好的语义实践,它还限定了发出的事件。
正如我们在本章前面所描述的,jQuery 实际上允许我们使用任何对象作为代理,甚至是一个空对象。因此,我们可以使用window.broker = $({});之类的东西作为我们的代理,以防我们更喜欢它而不是使用页面元素。
通过使用新构造的空对象,我们还可以轻松创建几个代理,以防特定实现首选这样的情况。此外,如果不喜欢集中式代理,我们可以只将每个发布者作为自己的代理,从而导致实现更像发布/订阅模式的第一种/基本变体。
由于在大多数情况下,声明的变量用于在页面内访问应用程序的代理,因此上述方法之间几乎没有什么区别。只需选择更符合您团队口味的方法,在以后改变主意时,您只需在broker变量上使用不同的赋值即可。
使用自定义事件命名空间
作为本章的结束语,我们将简要介绍 jQuery 提供的自定义事件命名空间机制。事件命名空间的主要好处是它允许我们使用更具体的事件名称来更好地描述它们的目的,同时还帮助我们避免不同实现部分和插件之间的冲突。它还提供了一种方便的方法,可以从任何目标(元素或代理)解绑定给定命名空间的所有事件。
一个简单的示例实现如下所示:
var broker = $({});
broker.on('close.dialog', function (event, message){
console.log(event.type, event.namespace);
});
broker.trigger('close.dialog', ['messageEmitted']);
broker.off('.dialog');
// removes all event handlers of the "dialog" namespace
欲了解更多信息,请访问docs.jquery.com/Namespaced_Events文档页面和 CSS-Tricks 网站上的文章css-tricks.com/namespaced-events-jquery/。
总结
在本章中,我们介绍了发布/订阅模式。我们看到了它与观察者模式的相似之处,并通过比较了解了它的好处。我们分析了发布/订阅模式提供的更明确的角色和额外功能如何使其成为更复杂用例的理想模式。我们看到了 jQuery 开发人员是如何采用其中一些概念并将其带入到他们的观察者模式实现中作为自定义事件的。最后,我们使用发布/订阅模式重新编写了上一章的示例,添加了一些额外功能,并且在我们的应用程序的不同部分和页面元素之间实现了更大程度的解耦。
现在我们已经完成了对发布/订阅模式如何作为解耦实现不同部分的第一步的介绍,我们可以继续下一章,在那里我们将介绍模块模式。在下一章中,我们将学习如何将实现的不同部分分离为独立模块,并如何使用命名空间来实现更好的代码组织,并定义严格的 API 以实现不同模块之间的通信。
第四章:模块模式的分而治之
在本章中,我们将介绍模块和命名空间的概念,并看看它们如何带来更健壮的实现。我们将展示这些设计原则如何在应用程序中使用,通过展示一些最常用的开发模式来创建 JavaScript 中的模块。
在本章中,我们将:
复习模块和命名空间的概念
介绍对象字面量模式
介绍模块模式及其变种
介绍揭示模块模式及其变种
简要介绍 ES5 严格模式和 ES6 模块
解释模块如何用于 jQuery 应用程序产生益处
模块和命名空间
本章的两个主要实践是模块和命名空间,它们一起使用以便结构化和组织我们的代码。我们将首先分析模块的主要概念,即代码封装,然后我们将继续命名空间,用于逻辑上组织实现。
封装实现的内部部分
在开发大规模和复杂的 Web 应用程序时,从一开始就需要一个定义良好,结构化的架构的需求变得清晰。为了避免创建代码混乱的实现,其中我们的代码的不同部分以混乱的方式相互调用,我们必须将应用程序分割为小的,独立的部分。
这些独立的代码片段可以被定义为模块。为了记录这个架构原则,计算机科学已经定义了诸如关注分离之类的概念,其中每个模块的角色,操作和公开 API 都应严格定义并专注于为特定问题提供通用解决方案。
注意
有关封装和关注分离的更多信息,您可以访问developer.mozilla.org/en-US/docs/Glossary/Encapsulation和aspiringcraftsman.com/2008/01/03/art-of-separation-of-concerns/。
避免使用全局变量和命名空间
在 JavaScript 中,window对象也被称为全局命名空间,其中每个声明的变量和函数标识符默认附加在其上。命名空间可以定义为每个标识符必须是唯一的命名上下文。命名空间的主要概念是提供一种逻辑分组应用程序不同和独立一部分所有相关部分的方式。换句话说,它建议我们创建相关函数和变量的组,并使它们在相同的标识符下可访问。这有助于避免不同应用程序部分和所使用的其他 JavaScript 库之间的命名冲突,因为我们只需要在每个不同的命名空间下保持所有标识符唯一。
一个很好的名称空间的例子是 JavaScript 提供的数学函数和常量,它们被分组到名为Math的内置 JavaScript 对象下。由于 JavaScript 提供了 40 多个短命名的数学标识符,如E、PI和floor(),为了避免命名冲突并将它们分组在一起,它们被设计成作为Math对象的属性可访问,该对象充当了这个内置库的命名空间。
没有适当的名称空间,每个函数和变量必须在整个应用程序中具有唯一的名称,不同应用程序部分的标识符之间或者甚至与应用程序使用的第三方库的标识符之间可能发生冲突。最终,虽然模块提供了隔离应用程序每个独立部分的方法,但名称空间提供了一种将不同模块结构化成应用程序架构的方法。
这些模式的好处
基于模块和名称空间设计应用程序架构有助于更好地组织代码并明确分离部分。在这样的架构中,模块用于组合相关的实现部分,而名称空间将它们连接在一起以创建应用程序结构。
这种架构有助于协调大型开发团队,使独立部分的实现可以并行进行。它还可以缩短向现有实现中添加新功能所需的开发时间。这是因为可以轻松定位使用的现有部分,并且添加的实现很少与现有代码发生冲突的可能性。
由此产生的代码结构不仅干净分离,而且由于每个模块被设计来实现单一目标,它们也有很大可能性在其他类似的应用程序中使用。作为额外好处,由于每个模块的角色严格定义,因此在大型代码库中追踪错误的起源也变得更加容易。
广泛接受
社区和企业界意识到,为了编写在 JavaScript 中的可维护的大型前端应用程序,他们应该最终得出一套最佳实践,并应该将这些最佳实践纳入他们实现的每个部分中。
JavaScript 实现中模块和名称空间的接受和采用在社区和企业发布的最佳实践和代码风格指南中清晰可见。
例如,谷歌的 JavaScript 风格指南(可在google.github.io/styleguide/javascriptguide.xml#Naming找到)描述并建议在我们的实现中采用名称空间:
始终使用与项目或库相关的唯一伪命名空间作为全局范围标识符的前缀。
此外,jQuery JavaScript 风格指南(可在 contribute.jquery.org/style-guide/js/#global-variables 获取)建议使用全局变量,以便:
每个项目最多只能公开一个全局变量。
开发人员社区中另一个被接受的例子来自 Mozilla Developer Network。它的对象导向 JavaScript 指南(可在 developer.mozilla.org/en-US/docs/Web/JavaScript/Introduction_to_Object-Oriented_JavaScript#Namespace 获取)还建议使用命名空间,将应用程序的实现封装在一个单一的暴露变量下,使用以下简单的方法:
// global namespace
var MYAPP = MYAPP || {};
对象字面量模式
对象字面量模式可能是将实现的所有相关部分封装在一个作为模块的伞对象下的最简单方式。这种模式的名称准确地描述了它的使用方式。开发人员只需声明一个变量并将需要封装到该模块中的所有相关部分赋值给一个对象即可。
让我们看看如何创建一个模块,以类似于 jquery.guid 的方式为页面提供唯一的整数:
var simpleguid = {
guid: 1,
init: function() {
this.guid = 1;
},
increaseCounter: function() {
this.guid++;
// or simpleguid.guid++;
},
getNext: function() {
var nextGuid = this.guid;
this.increaseCounter();
return nextGuid;
}
};
如上所述,您可以遵循的一个简单规则是将每个实现所需的所有变量和函数定义为对象的属性。我们的代码是可重用的,不会污染全局命名空间,除了为我们的模块定义一个单一变量名,例如在本例中是 simpleguid。
我们可以通过使用 this 关键字(例如 this.guid)或使用模块的全名(例如 simpleguid.guid)在内部访问模块属性。为了在我们的代码中使用上述模块,我们只需通过其名称访问其属性。例如,调用 simpleguid.getNext() 方法将向我们的代码返回下一个顺序数字 guid,并通过增加内部计数器改变模块的状态。
这种模式的一个负面方面是它不提供对模块内部部分的任何隐私。模块的所有内部部分都可以被外部代码访问和覆盖,即使我们理想地只希望公开 simpleguid.init() 和 simpleguid.getNext() 方法。有几种命名约定描述了将下划线 (_) 添加到仅用于内部使用的属性名称的开头或结尾,但从技术上讲,这并不能解决这个缺点。
另一个缺点是,使用对象字面量编写一个大型模块很容易让人感到疲倦。 JavaScript 开发人员习惯于在变量和函数定义后加上分号 (;),尝试使用逗号 (,) 在每个属性后编写一个大型模块很容易导致语法错误。
尽管此模式使得声明模块的嵌套命名空间变得容易,但在需要多层嵌套的情况下,也可能导致代码结构庞大且难以阅读。例如,让我们看一下以下 Todo 应用程序的框架:
var myTodoApp = {
todos: [],
addTodo: function(todo) { this.todos.push(todo); },
getTodos: function() { return this.todos; },
updateTodo: function(todo) { /*...*/ },
imports: {
fromGDrive: function() { /*...*/ },
fromUrl: function() { /*...*/ },
fromText: function() { /*...*/ }
},
exports: {
gDrivePublicKey: '#wnanqAASnsmkkw',
toGDrive: function() { /*...*/ },
toFile: function() { /*...*/ },
},
share: {
toTwitter: function(todo) { /*...*/ }
}
};
幸运的是,这可以通过将对象字面量拆分为每个子模块的多个赋值(最好是到不同的文件)来轻松解决,如下所示:
var myTodoApp = {
todos: [],
addTodo: function(todo) { this.todos.push(todo); },
getTodos: function() { return this.todos; },
updateTodo: function(todo) { /*...*/ },
};
/* … */
myTodoApp.exports = {
gDrivePublicKey: '#wnanqAASnsmkkw',
toGDrive: function() { /*...*/ },
toFile: function() { /*...*/ },
};
/*...*/
模块模式
基本模块模式的关键概念是提供一个简单的函数、类或对象,供应用程序的其余部分使用,通过一个众所周知的变量名。它使我们能够为模块提供一个最小的 API,通过隐藏不需要暴露的实现部分。这样,我们还可以避免用于我们模块的内部使用的变量和实用函数污染全局命名空间。
IIFE 构建块
在本小节中,我们将简要介绍 IIFE 设计模式,因为它是我们将在本章中看到的所有模块模式变体的一个重要部分。立即调用函数表达式(IIFE)是 JavaScript 开发人员中非常常用的设计模式,因为它以清晰的方式隔离了代码块。在模块模式中,IIFE 用于包装所有实现,以避免污染全局命名空间,并向模块本身提供声明的隐私。
每个 IIFE 都创建了一个闭包,其中声明的变量和函数。创建的闭包使得 IIFE 的公开函数能够在其他部分的实现中被执行时保留对其环境余下声明的引用,并且正常访问它们。因此,IIFE 的非公开声明不会泄漏到外部,而是被保持私有,并且只能被创建的闭包中的函数访问。
注意
欲了解更多关于 IIFE 和闭包的信息,您可以访问developer.mozilla.org/en-US/docs/Glossary/IIFE 和 developer.mozilla.org/en-US/docs/Web/JavaScript/Closures。
IIFE 最常用的用法如下:
(function() {
var x = 7;
console.log(x);
// prints 7
})();
由于前面的代码构造在第一眼看起来可能很奇怪,让我们看看它由哪些部分组成。IIFE 几乎等价于声明一个匿名函数,将其赋值给一个变量,然后执行它,如下面的代码所示:
var tmp = function() {
var x = 7;
console.log(x);
};
tmp();
// or
(tmp)();
在前面的代码中,我们定义了一个函数表达式,并使用tmp()执行它。由于在 JavaScript 中,我们可以在标识符周围使用括号而不改变其含义,我们也可以使用(tmp)();来执行存储的函数。最后一步,为了将前面的代码转换为 IIFE,是将tmp变量替换为实际的匿名函数声明。
正如我们之前看到的那样,唯一的区别在于,使用 IIFE 时,我们确实需要声明一个变量来保存函数本身。我们只创建一个匿名函数,并在定义后立即调用它。
由于可以通过几种方式创建 IIFE,这可能看起来像是对 JavaScript 规则的一种练习,JavaScript 开发者社区已经得出结论,将上述代码结构作为此模式的参考点。这种创建 IIFE 的方式被认为具有更好的可读性,并且被大型库所使用,作为其被采用的结果,开发人员可以在大型 JavaScript 实现中轻松识别它。
创建 IIFE 的不常用方式的示例是以下代码结构:
(function() {
// code
}());
简单的 IIFE 模块模式
由于此模式没有实际名称,因此它被认为是定义的模块返回单个实体的事实。为了参考如何使用此模式创建可重用库,我们将重新编写之前看到的simpleguid模块。得到的实现将如下所示:
var simpleguid = (function() {
var simpleguid = {};
var guid;
simpleguid.init = function() {
guid = 1;
};
simpleguid.increaseCounter = function() {
guid++;
};
simpleguid.getNext = function() {
var nextGuid = guid;
this.increaseCounter();
return nextGuid;
};
simpleguid.init();
return simpleguid;
})();
此模式使用 IIFE 定义一个充当模块容器的对象,将属性附加到该对象上,然后将其返回。前面代码的第一行中的变量simpleguid用作模块的命名空间,并赋予了 IIFE 返回的值。在返回对象上定义的方法和属性是模块的唯一公开部分,并构成其公共 API。
再次,这种模式允许我们使用this关键字,以便访问我们模块的公开方法和属性。此外,它还提供了在完成模块定义之前执行任何所需初始化代码的灵活性。
与对象字面量模式不同,模块模式使我们能够在模块中创建实际的私有成员。在 IIFE 中声明的变量,不附加到返回值的变量,比如guid变量,作为私有成员,只能被创建闭包的其他成员在模块内部访问。
最后,如果我们需要定义嵌套的命名空间,我们所需做的就是更改 IIFE 返回的值的赋值。作为应用程序用子模块结构化的示例,让我们看看如何为之前看到的 Todo 应用程序骨架定义导出子模块:
var myTodoApp = (function() {
var myTodoApp = {};
var todos = [];
myTodoApp.addTodo = function(todo) {
todos.push(todo);
};
myTodoApp.getTodos = function() {
return todos;
};
return myTodoApp;
})();
myTodoApp.exports = (function() {
var exports = {};
var gDrivePublicKey = '#wnanqAASnsmkkw';
exports.toGDrive = function() { /*...*/ };
exports.toFile = function() { /*...*/ };
return exports;
})();
鉴于我们应用的命名空间myTodoApp已在之前定义过了,exports子模块可以定义为其上的一个简单属性。要遵循的一个良好实践是为上述每个模块创建一个文件,使用 IIFE 作为代码拆分的标志。一个广泛使用的命名约定,也是由 Google 的 JavaScript 样式指南建议的,是为文件使用小写命名,并使用破折号分隔子模块。例如,按照这个命名约定,前面的代码应该分别定义在名为mytodoapp.js和mytodoapp-exports.js的两个文件中。
它如何被 jQuery 使用
模块模式被 jQuery 本身使用,以隔离 CSS 选择器引擎(Sizzle)的源代码,它为$()函数提供支持,并将其与 jQuery 源代码的其余部分隔离开来。从一开始,Sizzle 就是 jQuery 源代码的一个重要部分,目前大约有 2135 行代码;自 2009 年以来,它已经拆分为一个名为 Sizzle 的独立项目,这样就更容易维护,可以独立开发,并且可以被其他库重复使用:
var Sizzle = (function(window) {
/* 179 lines of code */
function Sizzle(selector, context, results, seed) {
/* 131 lines of code */
}
/*
1804 lines of code , defining methods like:
Sizzle.attr
Sizzle.compile
Sizzle.contains
Sizzle.getText
Sizzle.matches
Sizzle.matchesSelector
Sizzle.select
*/
return Sizzle;
})(window);
jQuery.find = Sizzle;
Sizzle被添加到 jQuery 的源码中的 IIFE 内部,而其主要功能则被返回并分配给jQuery.find以供使用。
注意
关于 Sizzle 的更多信息,请访问github.com/jquery/sizzle。
命名空间参数模块变体
在这个变体中,我们不是从 IIFE 返回对象,然后将其分配给充当模块的命名空间的变量,而是创建命名空间并将其作为参数传递给 IIFE 本身:
(function(simpleguid) {
var guid;
simpleguid.init = function() {
guid = 1;
};
simpleguid.increaseCounter = function() {
guid++;
};
simpleguid.getNext = function() {
var nextGuid = guid;
this.increaseCounter();
return nextGuid;
};
simpleguid.init();
})(window.simpleguid = window.simpleguid || {});
模块定义的最后一行检查模块是否已经定义;如果没有,则将其初始化为空对象文字,并将其分配给全局对象(window)。无论如何,在 IIFE 的第一行中,simpleguid参数都将保存模块的命名空间。
注意
上述表达式几乎等同于写成:
window.simpleguid = window.simpleguid !== undefined ? window.simpleguid : {};
使用逻辑或运算符(||)使表达式更简短且更易读。此外,这是大多数 Web 开发人员已经学会轻松识别的模式,在许多开发模式和最佳实践中都有出现。
再次,这种模式允许我们使用this关键字从模块的导出方法中访问公共成员。同时,它还允许我们保持一些函数和变量私有,这些私有函数和变量只能被模块的其他函数访问。
即使将每个模块定义为自己的 JS 文件被认为是一种良好的做法,此变体还允许我们将大型模块的实现分割到多个文件中。这个好处来自于在将其初始化为空对象之前检查模块是否已经定义。这在某些情况下可能会有用,唯一的限制是每个模块的部分文件都可以访问其自己 IIFE 中定义的私有成员。
此外,为了避免重复,我们可以为 IIFE 的参数使用更简单的标识符,并将我们的模块编写为如下所示:
(function(namespace) {
/* … */
namespace.getNext = function() {
var nextGuid = guid;
this.increaseCounter();
return nextGuid;
};
namespace.init();
})(window.simpleguid = window.simpleguid || {});
当涉及具有嵌套命名空间的应用程序时,这种模式可能开始感觉阅读起来有点不舒服。每个额外的嵌套命名空间级别所定义的模块定义的最后一行将会变得越来越长。例如,让我们看一下我们的 Todo 应用程序的exports子模块将会是怎样的:
(function(exports) {
var gDrivePublicKey = '#wnanqAASnsmkkw';
exports.toGDrive = function() { /*...*/ };
exports.toFile = function() { /*...*/ };
})(myTodoApp.exports = myTodoApp.exports || {});
正如您所见,每个额外级别的嵌套命名空间都需要在作为 IIFE 参数传递的赋值两侧添加。对于具有复杂功能并导致多级嵌套命名空间的应用程序,这可能导致模块定义看起来像这样:
(function(smallModule) {
smallModule.method = function() { /*...*/ };
return smallModule;
})(myApp.bigFeature.featurePart.smallModule = myApp.bigFeature.featurePart.smallModule || {});
此外,如果我们想要提供与原始代码示例相同的安全保证,那么我们需要为每个命名空间级别添加类似的安全检查。考虑到这一点,我们之前看到的 Todo 应用程序的exports模块将需要具有以下形式:
(function(exports) {
var gDrivePublicKey = '#wnanqAASnsmkkw';
exports.toGDrive = function() { /*...*/ };
exports.toFile = function() { /*...*/ };
})((window.myTodoApp = window.myTodoApp || {}, myTodoApp.exports = myTodoApp.exports || {}));
如前所述的代码中所示,我们使用逗号运算符(,)来分隔每个命名空间的存在检查,并将整个表达式包装在额外的括号对中,以便整个表达式作为 IIFE 的第一个参数使用。使用逗号运算符(,)将表达式连接起来将导致它们按顺序计算,并将最后评估的表达式的结果作为 IIFE 的参数传递,并且该结果将用作模块的命名空间。请记住,对于每个额外的嵌套命名空间级别,我们都需要使用逗号运算符(,)添加额外的存在检查表达式。
这种模式的一个缺点,尤其是在用于嵌套命名空间时,是模块的命名空间定义在文件末尾。即使强烈建议为 JS 文件命名,以便它们正确表示包含的模块,例如,mytodoapp.exports.js;但是,没有命名空间在文件顶部附近有时可能会产生反效果或误导性。解决这个问题的一个简单方法是在 IIFE 之前定义命名空间,然后将其作为参数传递。例如,使用这种技术的前述代码将转换为以下形式:
window.myTodoApp = window.myTodoApp || {};
myTodoApp.exports = myTodoApp.exports || {};
(function(exports) {
var gDrivePublicKey = '#wnanqAASnsmkkw';
exports.toGDrive = function() { /*...*/ };
exports.toFile = function() { /*...*/ };
})(myTodoApp.exports);
IIFE 包含的模块变体
像在模块模式的以前变体一样,这种变体实际上并没有一个特定的变体名称,但是通过代码结构的方式进行识别。这种变体的关键概念是将所有模块的代码移至 IIFE 中:
(function() {
window.simpleguid = window.simpleguid || {};
var guid;
simpleguid.init = function() {
guid = 1;
};
simpleguid.increaseCounter = function() {
guid++;
};
simpleguid.getNext = function() {
var nextGuid = guid;
this.increaseCounter();
return nextGuid;
};
simpleguid.init();
})();
这种变体看起来与前一种非常相似,主要区别在于命名空间的创建方式。首先,它将命名空间检查和初始化保持在模块的顶部附近,就像一个标题,使得我们的代码更具可读性,无论我们是否为模块使用单独的文件。与模块模式的其他变体一样,它支持模块的私有成员,并且还允许我们使用this关键字来访问公共方法和属性,使得我们的代码看起来更符合面向对象的特性。
关于具有嵌套命名空间的实现,我们的待办应用程序骨架的exports子模块的代码结构如下所示:
(function() {
window.myTodoApp = window.myTodoApp || {};
myTodoApp.exports = myTodoApp.exports || {};
var gDrivePublicKey = '#wnanqAASnsmkkw';
myTodoApp.exports.toGDrive = function() { /*...*/ };
myTodoApp.exports.toFile = function() { /*...*/ };
})();
如前面的代码所示,我们还从以前的变体中借用了命名空间定义检查,并同样将其应用到嵌套命名空间的每个级别。即使这并非绝对必要,但它带来了我们之前讨论过的好处,比如使我们能够将模块定义分割为多个文件,并且甚至导致应用程序模块导入顺序方面的实现更容错。
揭示模块模式
揭示模块模式是模块模式的一种变体,具有一个广为人知和认可的名称。使得这种模式特殊的是它结合了对象字面量模式和模块模式的最佳部分。模块的所有成员都声明在一个 IIFE 内部,最终返回一个仅包含模块公共成员的对象字面量,并分配给作为我们命名空间的变量:
var simpleguid = (function() {
var guid = 1;
function init() {
guid = 1;
}
function increaseCounter() {
guid++;
}
function getNext() {
var nextGuid = guid;
increaseCounter();
return nextGuid;
}
return {
init: init,
getNext: getNext
};
})();
这种模式与其他变体区别最大的一个主要好处是它允许我们像在全局命名空间中声明代码一样,在 IIFE 内部编写所有模块的代码。此外,这种模式不需要在声明公共和私有成员的方式上做任何变化,使得模块代码看起来统一。
由于返回的对象字面量定义了模块的公开成员,因此这也是一种方便的方法来检查其公共 API,即使它是由其他人编写的。此外,如果我们需要在模块的 API 中公开一个私有方法,我们只需向返回的对象字面量中添加一个额外的属性,而无需更改其定义的任何部分。此外,使用对象字面量使我们能够更改模块 API 的公开标识符,而不需要更改模块内部实现使用的名称。
即使这不太明显,this关键字也可以用于模块的公共成员之间的调用。不幸的是,对于此模式而言,使用this关键字是不鼓励的,因为它会破坏函数声明的统一性,并且很容易导致错误,特别是在将公共方法的可见性更改为私有时。
由于命名空间定义被保留在 IIFE 的体外,这种模式清晰地将命名空间定义与模块的实际实现分开。在嵌套命名空间中使用此模式来定义模块不会影响模块的实现,任何时候它都不会与顶级命名空间模块有所不同。重写我们的 Todo 骨架应用程序的exports子模块,使用此模式将使其看起来像这样:
myTodoApp.exports = (function() {
var gDrivePublicKey = '#wnanqAASnsmkkw';
function toGDrive() { /*...*/ }
function toFile() { /*...*/ }
return {
toGDrive: toGDrive,
toFile: toFile
};
})();
由于这种分离,我们减少了代码重复,并且可以轻松地更改模块的命名空间,而不会对其实现造成任何影响。
使用 ES5 严格模式
对于所有将 IIFE 作为其基本构建块的模块模式的一个小但宝贵的补充,是使用严格模式来执行 JavaScript。这在 JavaScript 的第五版中标准化,是一种选择性的执行模式,具有略微不同的语义,以防止 JavaScript 的一些常见陷阱,但也考虑了向后兼容性。
在此模式下,JavaScript 运行时引擎将防止您意外创建全局变量并污染全局命名空间。即使在不是特别大的应用程序中,也很有可能在变量的初始赋值之前缺少var声明,自动将其提升为全局变量。为了防止这种情况,严格模式在向未声明的变量发出赋值时会抛出错误。以下图像显示了 Firefox 和 Chrome 在发生严格模式违规时抛出的错误。
可以通过在任何其他语句之前添加"use strict";或'use strict';语句来启用此模式。尽管可以在全局范围内启用它,但强烈建议仅在函数范围内启用它。在全局范围内启用它可能会使不符合严格模式的第三方库停止工作或行为异常。另一方面,启用严格模式的最佳位置是在模块的 IIFE 内部。严格模式将递归地应用于该 IIFE 的所有嵌套命名空间、方法和函数。
注意
有关 JavaScript 严格执行模式的更多信息,您可以访问 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode。
引入 ES6 模块
尽管 JavaScript 最初没有像其他编程语言一样内置的打包和命名空间支持,但 Web 开发人员通过定义并采用一些设计模式来填补这些空白。这些软件开发实践解决了 JavaScript 缺失的功能,并允许在这种一些年前大多用于表单验证的编程语言上进行大规模和可扩展的复杂应用程序实现。
直到 2015 年 6 月作为标准发布的 JavaScript 第 6 版(通常称为 ES6),引入了模块的概念作为语言的一部分。
注意
ES6 是 ECMAScript 第 6 版的缩写,也称为 Harmony 或 ECMAScript 2015,其中 ECMAScript 是 JavaScript 的标准化过程使用的术语。规范可在 www.ecma-international.org/ecma-262/6.0/index.html#sec-modules 找到。
作为 ES6 模块的示例,我们将看到 simpleguid 模块的许多编写方式之一:
var es6simpleguid = {};
export default es6simpleguid;
var guid;
es6simpleguid.init = function() {
guid = 1;
};
es6simpleguid.increaseCounter = function() {
guid++;
};
es6simpleguid.getNext = function() {
var nextGuid = guid;
this.increaseCounter();
return nextGuid;
};
es6simpleguid.init();
如果我们将此保存为名为 es6simpleguid.js 的文件,则我们可以通过简单地编写以下代码在不同的文件中导入并使用它:
import es6simpleguid from 'es6simpleguid';
console.log(es6simpleguid.getNext());
由于 ES6 模块 默认处于严格模式,因此今天使用首选模块模式变体编写模块,并启用严格模式,将使您更容易过渡到 ES6 模块。上述某些模式需要进行非常少的更改才能实现这一点。例如,在 IIFE-contained 模块模式变体中,只需要删除 IIFE 和 "use strict"; 语句,用变量替换模块的命名空间,并在其上使用 export 关键字。
不幸的是,在撰写本书时,没有任何浏览器对 ES6 模块提供 100% 的支持。因此,需要特殊的加载程序或工具将 ES6 转译为 ES5,以便我们可以开始使用 ES6 的新功能编写我们的代码。
注意
欲知详情,可访问 ES6 模块加载器的文档页面 github.com/ModuleLoader/es6-module-loader,和 Babel 转译器(之前称为 ES6toES5) babeljs.io/。
在 jQuery 应用程序中使用模块
为了演示模块模式如何带来更好的应用程序结构,我们将重新实现前几章中所见的仪表板示例。我们将包括到目前为止所见的所有功能,包括打开信息框的计数器。所使用的 HTML 和 CSS 代码与前一章完全相同,因此我们的仪表板看起来与以前完全相同:
为了进行演示,我们将将我们的 JavaScript 代码重构为四个小模块,使用简单的 IIFE 封装的 Module 变体。dashboard 模块将充当代码执行的主要入口,也将充当 dashboard 应用程序的中央协调点。categories 子模块将负责实现我们的 dashboard 顶部的上部分。这包括类别选择,适当按钮的呈现和按钮点击的处理。informationBox 子模块将负责我们的 dashboard 的主要部分。它将提供创建和删除 dashboard 中信息框的方法。最后,计数器子模块将负责保持当前打开的信息框数字段最新,并响应用户操作。
为了支持这种多模块架构,我们需要对页面的 HTML 中包含 JavaScript 文件的方式做出一些限制:
<script type="text/javascript" src="img/jquery.js"></script>
<script type="text/javascript" src="img/dashboard.js"></script>
<script type="text/javascript" src="img/dashboard.categories.js"></script> <script type="text/javascript" src="img/dashboard.informationbox.js">
</script>
<script type="text/javascript" src="img/dashboard.counter.js"></script>
提示
即使这种多文件结构使得开发和调试过程变得更加容易,我们仍建议在将应用移至生产环境之前将所有这些文件合并。有几个专门用于此任务的工具存在;例如,非常简单有效的 grunt-contrib-concat 项目,可在 github.com/gruntjs/grunt-contrib-concat 获取。
主要的 dashboard 模块
dashboard 模块的最终代码将如下所示:
(function() {
'use strict';
window.dashboard = window.dashboard || {};
dashboard.$container = null;
dashboard.init = function() {
dashboard.$container = $('.dashboardContainer');
dashboard.categories.init();
dashboard.informationBox.init();
dashboard.counter.init();
};
$(document).ready(dashboard.init);
})();
如我们先前提到的,dashboard 模块将是我们应用的中心点。由于这是我们应用执行的起始点,它的主要职责是为自身和每个子模块执行所有必需的初始化。调用 init() 方法被包装在对 $(document).ready() 方法的调用内,以便其执行被延迟直到页面的 DOM 树完全加载。
需要注意的一点是,在初始化期间,我们进行 DOM 遍历以找到 dashboard 的容器元素,并将其存储到 Module 的一个公共属性 $container 中。此元素将被 dashboard 的所有需要访问 DOM 树的方法使用,以便将它们的代码范围限定在该容器元素内,避免使用复杂选择器不断遍历整个 DOM 树。保留关键 DOM 元素的引用并在不同的子模块中重用它们,可以使应用程序更加灵活,并减少意外干扰页面的机会;从而导致更少且更易于解决的错误。
提示
缓存元素但避免内存泄漏。
请记住,保持对不断添加和移除页面的 DOM 元素的引用会给我们的应用程序增加额外的复杂性。这甚至可能导致内存泄漏,如果我们不小心保留对已从页面中移除的元素的引用。对于这样的元素,如信息框,更安全、更有效的方法可能是对它们触发的事件进行委派处理,并在需要时进行范围限定的 DOM 遍历,以检索具有新引用的元素的 jQuery 对象。
类别模块
让我们继续进行 categories 子模块:
(function() {
'use strict';
dashboard.categories = dashboard.categories || {};
dashboard.categories.init = function() {
dashboard.$container.find('#categoriesSelector').change(function() {
var $selector = $(this);
var categoryIndex = +$selector.val();
dashboard.categories.selectCategory(categoryIndex);
});
dashboard.$container.find('.dashboardCategories').on('click', 'button', function() {
var $button = $(this);
var itemName = $button.text();
dashboard.informationBox.openNew(itemName);
});
};
dashboard.categories.selectCategory = function(categoryIndex) {
var $dashboardCategories = dashboard.$container.find('.dashboardCategory');
var $selectedItem = $dashboardCategories.eq(categoryIndex).show();
$dashboardCategories.not($selectedItem).hide();
};
})();
此子模块的初始化方法使用主模块提供的 $container 元素的引用,并向页面添加了两个观察者。第一个处理 <select> 类别上的 change 事件,并调用 selectCategory() 方法,传递所选类别的数值。该子模块的 selectCategory() 方法然后将处理显示适当的类别项,将其与事件处理代码解耦,并使其成为整个应用程序可重用的功能。
在此之后,我们创建了一个单一的委托事件观察者,处理 <button> 类别项上的 click 事件。它提取了按下的 <button> 的文本,并调用包含所有与信息框相关的实现的 informationBox 子模块的 openNew() 方法。在非演示级别的应用程序中,此类方法的参数可能是一个标识符,而不是用于从远程服务器检索更多详细信息的文本值。
信息框模块
包含与我们仪表板主要区域相关的实现部分的 informationBox 子模块具有以下形式:
(function() {
'use strict';
dashboard.informationBox = dashboard.informationBox || {};
var $boxContainer = null;
dashboard.informationBox.init = function() {
$boxContainer = dashboard.$container.find('.boxContainer');
$boxContainer.on('click', '.boxCloseButton', function() {
var $button = $(this);
dashboard.informationBox.close($button);
});
};
dashboard.informationBox.openNew = function(itemName) {
var boxHtml = '<div class="boxsizer"><article class="box">' +
'<header class="boxHeader">' +
itemName +
'<button class="boxCloseButton">✖' +
'</button>'+
'</header>' +
'Information box regarding ' + itemName +
'</article></div>';
$boxContainer.append(boxHtml);
};
dashboard.informationBox.close = function($boxElement) {
$boxElement.closest('.boxsizer').remove();
};
})();
此子模块初始化代码的第一件事是使用仪表板的 $container 属性来检索并存储容纳信息框的容器的引用到 $boxContainer 变量中,从而进行作用域限定。
openNew() 方法负责创建新信息框所需的 HTML,并使用 $boxContainer 变量将其添加到仪表板中,该变量像模块的私有成员一样,用于缓存先前分配的 DOM 元素的引用。这是一个很好的实践,可以提高应用程序的性能,因为存储的元素从未从页面中移除,并且在初始化和 openNew() 方法调用时都会使用。这样,我们就不再需要在每次调用 openNew() 方法时执行缓慢的 DOM 遍历了。
另一方面,close() 方法负责从仪表板中移除现有的信息框。它接收一个与目标信息框相关的 jQuery 组合集合对象作为参数,这是基于 $.fn.closest() 方法的工作方式,可以是框元素容器或其任何后代。
提示
提供灵活性的方法实现方式可以使它们被大型应用程序中的更多部分使用。对于此方法的下一个逻辑步骤,留给读者作为练习的是使其接受参数,即需要关闭的信息框的索引或标识符。
计数器模块
最后,这里是我们如何将我们在上一章中看到的counter实现重写为一个独立的子模块:
(function() {
'use strict';
dashboard.counter = dashboard.counter || {};
var dashboardItemCounter;
var $counter;
dashboard.counter.init = function() {
$counter = $('#dashboardItemCounter');
var $boxContainer = dashboard.$container.find('.boxContainer');
var initialCount = $boxContainer.find('.boxsizer').length;
dashboard.counter.setValue(initialCount);
dashboard.$container.find('.dashboardCategories').on('click', 'button', function() {
dashboard.counter.setValue(dashboardItemCounter + 1);
});
$boxContainer.on('click', '.boxCloseButton', function() {
dashboard.counter.setValue(dashboardItemCounter - 1);
});
};
dashboard.counter.setValue = function (value) {
dashboardItemCounter = value;
$counter.text(dashboardItemCounter);
};
})();
对于此子模块,我们使用$counter变量作为私有成员来缓存对显示计数的元素的引用。模块的另一个私有成员是dashboardItemCounter变量,它在任何时间点都将保存仪表板中可见信息框的数量。将这些信息保存在模块的成员中可以减少我们需要到达 DOM 树以提取应用程序状态信息的次数,从而使实现更加高效。
提示
将应用程序的状态保留在 JavaScript 对象或模块的属性中,而不是到 DOM 中提取它们,这是一种非常好的做法,可以使应用程序的架构更加面向对象,并且也被大多数现代 Web 开发框架采纳。
在模块初始化期间,我们给计数器变量赋予一个初始值,以便我们不再依赖页面的初始 HTML,并且拥有更健壮的实现。此外,我们附加了两个委托事件观察器,一个用于导致创建新信息框的点击,另一个用于关闭它们的点击。
实现概述
通过以上内容,我们将仪表板骨架应用程序重写为模块化架构。所有可用操作都公开为每个子模块的公共方法,可以通过编程方式调用,这样它们就与触发它们的事件解耦了。
对于读者来说,一个很好的练习是通过在上述实现中采用发布者/订阅者模式来进一步推动解耦。代码已经结构化为模块,这样的更改将更容易实现。
另一个可以以不同方式实现的部分是子模块的初始化方式。我们可以不再明确地在主仪表板模块中协调每个模块的初始化,而是通过在$(document).ready()调用中包装init()方法的调用并在声明后立即进行初始化来独立地初始化每个子模块。另一方面,没有一个中心点来协调初始化并依赖页面事件可能会感觉不够确定。另一种实现方式可能是像发布者/订阅者模式一样,在我们的主模块上暴露一个registerForInit()方法,它将通过数组跟踪已被请求进行初始化的模块。
注意
欲了解更多 jQuery 代码组织技巧,您可以访问learn.jquery.com/code-organization/concepts/。
摘要
在这一章节中,我们学习了模块和命名空间的概念,还有它们在大型应用中采用时带来的好处。我们深入分析了最广泛采用的模式,并比较了它们的优点和局限性。我们通过示例学习了如何使用对象字面量模式、模块模式的变体以及揭示模块模式来开发模块。
我们继续简要介绍 ES5 的严格模式,并看到它如何有益于当今的模块。然后我们学习了一些关于标准化但尚未广泛支持的ES6 模块的细节。最后,我们看到在实施中使用模块模式后,仪表板应用程序的架构如何出现了巨大变化。
现在我们已经完成了关于如何使用模块和命名空间的介绍,我们可以继续下一章节,在下一章节中我们将介绍外观模式。在下一章节中,我们将学习关于外观的哲学,以及它们定义代码抽象的统一方式,使其易于其他开发人员理解和重复使用。
第五章:门面模式
在这一章中,我们将展示门面模式,一种结构设计模式,试图定义开发人员应如何在其代码中创建抽象的统一方式。最初,我们将使用此模式来包装复杂的 API,并公开专注于我们应用程序需求的简化 API。我们将看到 jQuery 如何在其实现中采用此模式的概念,它如何将是 Web 开发者工具箱中的重要组成部分的复杂实现封装成易于使用的 API,以及这对其广泛采用的关键作用。
在这一章中,我们将:
介绍门面模式
记录其关键概念和优势
看看 jQuery 在其实现中如何使用它
编写一个示例实现,其中门面被用于完全抽象和解耦第三方库
介绍门面模式
门面模式是一种处理如何创建实现各部分的抽象的结构性软件设计模式。门面模式的关键概念是抽象出现有实现,并提供一个简化的 API,更好地匹配开发应用程序的用例。根据大多数描述此模式的计算机科学参考书目,门面最常见的实现方式是作为一个专门的类,用于将应用程序的实现分割成更小的代码片段,同时提供一个完全隐藏封装的复杂性的接口。在 Web 开发世界中,还常常使用普通对象或函数来实现门面,利用 JavaScript 将函数视为对象的方式。
在具有模块化结构的应用程序中,例如上一章的示例,通常还会将门面实现为具有自己命名空间的单独模块。此外,对于具有非常复杂部分的较大实现,也可以采用多级门面的方法。再次,门面将作为模块和子模块实现,顶层门面将编排其子模块的方法,并提供一个完全隐藏整个子系统复杂性的 API。
此模式的优势
大多数情况下,门面模式被采用于具有相对高复杂度并在应用程序的多个地方使用的实现部分,其中大量的代码可以被简单调用已创建的门面替换,这不仅减少了代码重复,还有助于增加实现的可读性。由于门面方法通常以它们封装的高级应用概念命名,所以产生的代码也更易于理解。门面通过其方便的方法提供的简化 API,导致实现更易于使用、理解,也更易于编写单元测试。
此外,将复杂实现抽象化为 Facades 在需要改变实现的业务逻辑时证明了其有用性。如果 Facade 具有良好设计的 API,并对未来需求进行了预测,这些更改通常只需要修改 Facade 的代码,而不会影响应用程序的其余实现,并遵循关注点分离原则。
以同样的方式,使用 Facades 将第三方库的 API 抽象化以更好地匹配每个应用程序的需求,提供了我们的代码与所用库之间的一定程度解耦。如果第三方库更改其 API 或需要用另一个替换,应用程序的不同模块不需要重新编写,因为实现更改将被限制在包装 Facade 上。在这种情况下,只需要使用新库 API 提供等效实现,同时保持 Facade 的 API 不变即可。
作为编排方法调用并为特定用例使用明智默认值的示例,请看以下示例实现:
function do (x, y) {
var z = y - x / 2;
var yy = Math.pow(y, 2);
var b = 3 * Math.random(); // add some randomness to the result
var i = 0; // for this case
return LibraryA.doingMethod(x, z, i, yy, b);
}
它是如何被 jQuery 接受的
jQuery 实现的非常大的部分专门用于为不同的 JavaScript API 已经允许我们实现的事物提供更简单、更短、更方便的方法,但需要更多的代码行数和工作量。通过查看 jQuery 提供的 API,我们可以区分出一些相关方法的组。这种分组也可以在源代码结构中看到,将相关 API 的方法放置在彼此附近。
即使单词Facade在 jQuery 的源代码中没有出现,但通过观察相关方法在公开的 jQuery 对象上的定义方式,可以看出这种模式的使用。大多数情况下,形成一组的相关方法被实现并定义为对象字面量的属性,然后通过一次调用$.extend()或$.fn.extend()方法附加到 jQuery 对象上。你可能还记得,从本章开始时,这几乎与计算机科学常用来描述如何实现 Facade 的方式完全匹配,唯一的区别在于,在 JavaScript 中,我们可以创建一个普通对象,而不需要首先定义一个类。因此,jQuery 本身可以被视为一组 Facades,其中每个 Facade 都通过提供便利方法的 API 独立地为库增添了巨大价值。
注意
欲了解更多关于$.extend()和$.fn.extend()的信息,您可以访问api.jquery.com/jQuery.extend/和api.jquery.com/jQuery.fn.extend/。
jQuery 实现中一些承担关键角色并对其采用起到至关重要作用的抽象 API 组如下:
DOM 遍历 API
AJAX API
DOM 操作 API
特效 API
此外,一个很好的例子是 jQuery 的事件 API,它提供了各种方便的方法,用于最常见的使用情况,比相应的纯 JavaScript API 更易于使用。
jQuery DOM 遍历 API
在 jQuery 发布时,网页开发人员只能使用非常有限的getElementById()和getElementsByTagName()方法来定位页面的特定 DOM 元素,因为其他方法,如getElementsByClassName(),并未得到现有浏览器的广泛支持。jQuery 团队意识到,如果有一个简单的 API 可以轻松进行这样的 DOM 遍历,它能够在所有浏览器上以相同的方式工作,像熟悉的CSS 选择器一样有效,并且尽最大努力使这样的实现成为现实。
这一努力的成果是如今著名的 jQuery DOM 遍历 API,通过$()函数公开,它在Level 2 Selector API的querySelectorAll()方法的标准化中扮演了重要角色。其底层实现使用DOM API提供的方法,在 jQuery v2.2.0 中约有 2,135 行代码,而在需要支持旧版浏览器的 v1.x 版本中甚至更多。正如我们在本章中所看到的,由于其复杂性,这一实现现在已成为一个名为Sizzle的独立项目的一部分。
注
有关 Sizzle 和querySelectorAll()方法的更多信息,请访问github.com/jquery/sizzle和developer.mozilla.org/en-US/docs/Web/API/document/querySelectorAll。
尽管其实现复杂,所公开的 API 非常易于使用,主要使用简单的 CSS 选择器作为字符串参数,这使得它成为一个很好的例子,说明外观模式可以完全隐藏其内部工作的复杂性并公开一个方便的 API。由于 Sizzle 的 API 仍然相当复杂,jQuery 库实际上使用自己的 API 包装它,作为额外的 Facade 级别:
// Line 733
function Sizzle( selector, context, results, seed ) { /* ... */ }
// Line 2678
jQuery.find = Sizzle;
jQuery 库首先保留 Sizzle 对内部jQuery.find()方法的引用,然后使用它来实现其所有公开的 DOM 遍历方法,这些方法适用于像$.fn.find()这样的复合对象:
// Line 2769
jQuery.fn.extend( {
find: function( selector ) {
/* 15 lines of code */
for ( i = 0; i < len; i++ ) {
jQuery.find( selector, self[ i ], ret );
}
/* 3 lines of code */
return ret;
}
} );
最后,著名的$()函数实际上可以以多种方式调用,但即使使用 CSS 选择器作为字符串参数调用时,它实际上有一个额外的隐藏复杂性:
// Line 71
jQuery = function( selector, context ) {
return new jQuery.fn.init( selector, context );
};
// Line 2825
rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,
// Line 2735
init = jQuery.fn.init = function( selector, context, root ) {
/* 12 lines of code */
if ( typeof selector === "string" ) {
if (/* ... */) {
/* 3 lines of code */
} else {
match = rquickExpr.exec( selector );
}
// Match html or make sure no context is specified for #id
if ( match && ( match[ 1 ] || !context ) ) {
if ( match[1] ) {
/* 27 lines of code */
// HANDLE: $(#id)
} else {
elem = document.getElementById( match[ 2 ] );
// Support: Blackberry 4.6
// gEBID returns nodes no longer in the document (#6963)
if ( elem && elem.parentNode ) {
// Inject the element directly into the jQuery object
this.length = 1;
this[ 0 ] = elem;
}
this.context = document;
this.selector = selector;
return this;
}
// HANDLE: $(expr, $(...))
} else if ( !context || context.jquery ) {
return ( context || root ).find( selector );
// HANDLE: $(expr, context)
// (which is just equivalent to: $(context).find(expr)
} else {
return this.constructor( context ).find( selector );
}
} /* else ... 21 lines of code */
};
如您所见,在上述代码中,$()实际上是使用$.fn.init()创建一个新对象。它不仅仅是$.fn.find()或jQuery.find()的入口点,而是一个隐藏了一层优化的门面。具体来说,它通过直接调用getElementById()方法,使得 jQuery 在使用简单的 ID 选择器时,通过避免调用$.fn.find()和 Sizzle,而变得更快。
属性访问和操作 API
遵循门面模式原则的另一个非常有趣的抽象,可以在 jQuery 源代码中找到,即$.fn.prop()方法。像$.fn.attr()、$.fn.val()、$.fn.text()和$.fn.html()一样,它属于一系列既是相应主题的获取器又是设置器的方法。该方法的执行模式的区分是通过检查在调用期间传递的参数数量来完成的。这种方便的 API 让我们只需记住更少的方法签名,并且使得设置器只需要一个额外的参数来进行区分。例如,$('#myCheckBox').prop('checked')将根据所选复选框的状态返回 true 或 false。另一方面,$('#myCheckBox').prop('checked', true);将对该复选框进行程序化的选中。在同样的概念中,$('button').prop('disabled', true);将禁用页面上所有<button>元素。
$.fn.prop()方法执行 jQuery 复合对象处理,但 Facade 的实际实现是内部的jQuery.prop()方法。为 Facade 的实现增加复杂性的一个额外问题是,一些 HTML 属性在 DOM 元素上具有与之对应的不同标识符:
jQuery.extend( {
prop: function( elem, name, value ) {
/* 8 lies of code */
if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {
// Fix name and attach hooks
name = jQuery.propFix[ name ] || name;
hooks = jQuery.propHooks[ name ];
}
if ( value !== undefined ) {
if ( hooks && "set" in hooks &&
( ret = hooks.set( elem, value, name ) ) !== undefined ) {
return ret;
}
return ( elem[ name ] = value );
}
if ( hooks && "get" in hooks &&( ret = hooks.get( elem, name ) ) !== null ) {
return ret;
}
return elem[ name ];
},
propHooks: {
tabIndex: {
get: function( elem ) {
var tabindex = jQuery.find.attr( elem, "tabindex" );
return tabindex ?parseInt( tabindex, 10 ) : /*...*/;
}
}
},
propFix: {
"for": "htmlFor",
"class": "className"
}
} );
第一个突出显示的代码区域通过使用propFix和propHooks对象来高效地解决属性到属性标识符不匹配的问题。propFix对象就像一个简单的字典,用于匹配标识符,而propHooks对象则保存一个函数,以一种不那么硬编码的方式进行匹配,通过编程化的测试。这是一个通用的实现,可以通过向这两个对象添加额外的属性来轻松扩展。
其余突出显示的区域负责方法的获取器/设置器模式。总体实现是执行以下任务:
检查是否将值作为参数传递,并且如果属性发现分配成功,则执行分配并返回该值。
或者,如果没有传递值,则返回可检索的请求属性的值。
在我们的应用程序中使用门面
为了演示外观如何被用来封装复杂性,帮助我们执行关注点分离原则,并将第三方库的 API 抽象成更方便的应用程序中心化方法,我们将演示一个非常简单的抽奖应用程序。我们的“元素抽奖”应用程序将使用唯一 ID 填充其容器,并包含随机数的一些抽奖票元素。
中奖号码将通过随机选择抽奖元素之一,基于创建的唯一 ID 中的随机索引来挑选。然后宣布获胜号码是所选元素的数字内容。让我们看看我们应用程序的模块:
(function() {
window.elementLottery = window.elementLottery || {};
var elementIDs;
var $lottery;
var ticketCount = 30;
elementLottery.init = function() {
elementIDs = [];
$lottery = $('#lottery').empty();
elementLottery.add(ticketCount);
$('#lotteryTicketButton').on('click', elementLottery.pick);
};
elementLottery.add = function(n) {
for (var i = 0; i < n; i++) {
var id = this.uidProvider.get();
elementIDs.push(id);
$lottery.append(this.ticket.createHtml(id));
}
};
elementLottery.pick = function() {
var index = Math.floor(Math.random() * elementIDs.length);
var result = $lottery.find('#' + elementIDs[index]).text();
alert(result);
return result;
};
$(document).ready(elementLottery.init);
})();
我们应用程序的主要 elementLottery 模块会在页面完全加载后立即初始化。add 方法用于向抽奖容器元素添加票证。它使用 uidProvider 子模块为票证元素生成唯一标识符,并在 elementIDs 数组上跟踪它们,使用票证子模块构造适当的 HTML 代码,并最终将元素附加到抽奖中。pick 方法用于通过随机选择生成的标识符之一来随机选择获胜者票证,检索具有该 ID 的页面元素,并在警报框中显示其内容作为获胜结果。pick 方法是在初始化阶段添加观察者的按钮点击时触发的:
(function() {
elementLottery.ticket = elementLottery.ticket || {};
elementLottery.ticket.createHtml = function(id) {
var ticketNumber = Math.floor(Math.random() * 1000 * 10);
return '<div id="' + id + '" class="ticket">' + ticketNumber + '</div>';
};
})();
(function() {
elementLottery.uidProvider = elementLottery.uidProvider || {};
elementLottery.uidProvider.get = function() {
return 'Lot' + simpleguid.getNext();
};
})();
ticket 子模块充当一个外观,具有一个用于封装随机数生成和将用作票证的 HTML 代码的单个方法。另一方面,uidProvide 子模块是一个提供单个 get 方法的外观,封装了我们在前几章节中看到的 simpleguid 模块的使用方式。因此,我们可以轻松更改用于生成唯一标识符的库,而我们需要修改现有实现的唯一位置将是 uidProvide 子模块。例如,让我们看看如果我们决定使用生成 128 位唯一标识符的精美 node-uuid 库,它会是什么样子:
(function() {
elementLottery.uidProvider = elementLottery.uidProvider || {};
elementLottery.uidProvider.get = function() {
return uuid.v4();
};
})();
注意
关于 node-uui 库的更多信息,您可以访问 github.com/broofa/node-uuid。
摘要
在本章中,我们了解了外观实际上是什么。我们了解了其哲学以及统一定义代码抽象应该如何创建,以便其他开发人员能够轻松理解并重用它们。
从该模式的最简单用例开始,我们学习了如何使用 Facade 封装复杂的 API,并公开一个更简单的 API,专注于我们应用程序的需求,并更好地匹配其特定的用例。 我们还看到了 jQuery 如何在其实现中采用了这种模式的概念,以及为更基本的 web 开发技术(如 DOM 遍历)提供简单 API 如何对其广泛采用起到了至关重要的作用。
现在我们已经完成了对 Facade 模式如何用于解耦和抽象实现的介绍,我们可以继续下一章,在下一章中,我们将介绍 Builder 和 Factory 模式。 在下一章中,我们将学习如何使用这两种创建型设计模式来抽象生成和初始化新对象的过程,以满足特定用例,并分析它们的采用如何使我们的实现受益。
第六章:生成器和工厂模式
本章中,我们将展示生成器模式和工厂模式,这两种最常用的创建型设计模式之一。这两种设计模式彼此之间有一些相似之处,共享一些共同的目标,并致力于简化复杂结果的创建。我们将分析它们的采用对我们实现的好处,以及它们之间的区别。最后,我们将学习如何正确使用它们,并为我们实现的不同用例选择最合适的模式。
本章中,我们将:
介绍工厂模式
查看 jQuery 如何使用工厂模式
在 jQuery 应用程序中有一个工厂模式示例
介绍生成器模式
比较生成器模式和工厂模式
查看 jQuery 如何使用生成器模式
在 jQuery 应用程序中有一个生成器模式示例
介绍工厂模式
工厂模式是创建型模式组中的一部分,总体上描述了一种用于对象创建和初始化的通用方式。它通常实现为一个用于生成其他对象的对象或函数。根据大多数计算机科学资源,工厂模式的参考实现描述为一个提供返回新创建的对象的方法的类。返回的对象通常是特定类或子类的实例,或者它们公开一组特定的特性。
工厂模式的关键概念是抽象出为特定目的创建和初始化对象或一组相关对象的方式。这种抽象的目的是避免将实现与特定类或每个对象实例需要创建和配置的方式耦合在一起。结果是一种按照关注点分离的概念来进行对象创建和初始化的实现。
结果的实现仅基于其算法或业务逻辑所需的对象方法和属性。这种方法可以通过遵循编程的概念而不是对象类的功能和功能来受益于实现的模块化和可扩展性。这使我们可以灵活地将所使用的类更改为任何其他公开相同功能的对象。
它是如何被 jQuery 采用的
正如我们在早期章节中已经注意到的那样,jQuery 的早期目标之一是提供一种在所有浏览器上都能够正常工作的解决方案。jQuery 1.12.x 版本系列专注于为老旧的 Internet Explorer 6(IE6)提供支持,同时保持与仅关注现代浏览器的较新版本 v2.2.x 相同的 API。
为了拥有类似的结构并最大化两个版本之间的公共代码,jQuery 团队试图在不同的实现层中抽象出大部分兼容性机制。这样的开发实践极大地提高了代码的可读性,并减少了主要实现的复杂性,将其封装成不同的较小的片段。
这个很好的例子是 jQuery 提供的与 AJAX 相关方法的实现。具体来说,在以下代码中,您可以找到它的一部分,就像在 jQuery 的 1.12.0 版本中找到的那样:
// Create the request object
// (This is still attached to ajaxSettings for backward compatibility)
jQuery.ajaxSettings.xhr = window.ActiveXObject !== undefined ?
// Support: IE6-IE8
function() {
// XHR cannot access local files, always use ActiveX for that case
if ( this.isLocal ) {
return createActiveXHR();
}
// Support: IE 9-11
if ( document.documentMode > 8 ) {
return createStandardXHR();
}
// Support: IE<9
return /^(get|post|head|put|delete|options)$/i.test( this.type ) && createStandardXHR() || createActiveXHR();
} :
// For all other browsers, use the standard XMLHttpRequest object
createStandardXHR;
// Functions to create xhrs
function createStandardXHR() {
try {
return new window.XMLHttpRequest();
} catch ( e ) {}
}
function createActiveXHR() {
try {
return new window.ActiveXObject( "Microsoft.XMLHTTP" );
} catch ( e ) {}
}
每次在 jQuery 上发出新的 AJAX 请求时,jQuery.ajaxSettings.xhr方法被用作一个工厂,根据当前浏览器的支持创建一个新的适当的 XHR 对象的实例。更详细地看,我们可以看到jQuery.ajaxSettings.xhr方法协调使用两个更小的工厂函数,每个函数负责特定的 AJAX 实现。此外,我们可以看到它实际上试图避免在每次调用时都运行兼容性测试,而是在适当时直接将其引用连接到较小的createStandardXHR工厂函数。
在我们的应用程序中使用工厂
作为工厂的一个示例用例,我们将创建一个数据驱动的表单,其中我们的用户将能够填写一些动态创建并插入到页面中的字段。我们将假设存在一个包含描述每个需要呈现的表单字段的对象的数组。我们的工厂方法将封装每个表单字段需要被构建的方式,并根据相关对象上定义的特征正确处理每个特定的情况。
这个页面的 HTML 代码非常简单:
<h1>Data Driven Form</h1>
<form></form>
<script type="text/javascript" src="img/jquery.js"></script>
<script type="text/javascript" src="img/datadrivenform.js"></script>
它只包含一个<h1>元素,用于页面标题,以及一个空的<form>元素,用于承载生成的字段。至于使用的 CSS,我们只对<button>元素进行了样式化,与之前的章节中所做的方式相同。
至于应用程序的 JavaScript 实现,我们创建一个模块,并声明dataDrivenForm为这个示例的命名空间。这个模块将包含描述我们表单的数据,生成每个表单元素的 HTML 的工厂方法,当然还有将上述部分组合起来创建结果表单的初始化代码:
(function() {
'use strict';
window.dataDrivenForm = window.dataDrivenForm || {};
dataDrivenForm.formElementHTMLFactory = function (type, name, title) {
if (!title || !title.length) {
title = name;
}
var topPart = '<div><label><span>' + title + ':</span><br />';
var bottomPart = '</label></div>';
if (type === 'text') {
return topPart +
'<input type="text" maxlength="200" name="' +name + '" />' +
bottomPart;
} else if (type === 'email') {
return topPart +
'<input type="email" required name="' + name + '" />' +
bottomPart;
} else if (type === 'number') {
return topPart +
'<input type="number" min="0" max="2147483647" ' +'name="' + name + '" />' +
bottomPart;
} else if (type === 'date') {
return topPart +
'<input type="date" min="1900-01-01" name="' +
name + '" />' +
bottomPart;
} else if (type === 'textarea') {
return topPart +
'<textarea cols="30" rows="3" maxlength="800" name="' +name + '" />' +
bottomPart;
} else if (type === 'checkbox') {
return '<div><label><span>' + title + ':</span>' +
'<input type="checkbox" name="' + name + '" />' +
'</label></div>';
} else if (type === 'notice') {
return '<p>' + name + '</p>';
} else if (type === 'button') {
return '<button name="' + name + '">' + title + '!</button>';
}
};
})();
我们的工厂方法将被调用三个参数。从最重要的开始,它接受表单字段的类型和名称,以及将用作其描述的标题。由于大多数表单字段共享一些共同的特征,比如它们的标题,工厂方法试图将它们抽象出来,以减少代码重复。正如您所见,工厂方法还为每种字段类型包含一些合理的额外配置,比如文本字段的maxlength属性,这是特定用例的特定属性。
将用于表示每个表单元素的对象结构将是一个简单的 JavaScript 对象,它具有type、name和title属性。描述表单字段的对象集合将被分组在一个数组中,并在我们的模块的dataDrivenForm.parts属性上可用。在实际应用中,这些字段通常会通过 AJAX 请求检索,或者被注入到页面的某个部分中。在以下代码片段中,我们可以看到将用于驱动我们的表单创建的数据:
dataDrivenForm.parts = [{
type: 'text',
name: 'firstname',
title: 'First Name'
}, {
type: 'text',
name: 'lastname',
title: 'Last Name'
}, {
type: 'email',
name: 'email',
title: 'e-mail address'
}, {
type: 'date',
name: 'birthdate',
title: 'Date of birth'
}, {
type: 'number',
name: 'experience',
title: 'Years of experience'
}, {
type: 'textarea',
name: 'summary',
title: 'Summary'
}, {
type: 'checkbox',
name: 'receivenotifications',
title: 'Receive notification e-mails'
}, {
type: 'notice',
name: 'By using this form you accept the terms of use'
}, {
type: 'button',
name: 'save'
}, {
type: 'button',
name: 'submit'
}];
最后,我们定义并立即调用了一个init方法来初始化我们的模块:
dataDrivenForm.init = function() {
for (var i = 0; i < dataDrivenForm.parts.length; i++) {
var part = dataDrivenForm.parts[i];
var elementHTML = dataDrivenForm.formElementHTMLFactory(part.type, part.name, part.title);
// check if the result is null, undefined or empty string
if (elementHTML && elementHTML.length) {
$('form').append(elementHTML);
}
}
};
$(document).ready(dataDrivenForm.init);
初始化代码会等待页面的 DOM 完全加载,然后使用工厂方法创建表单元素并将它们附加到页面的<form>元素上。在实际使用之前,上述代码的一个额外关注点是检查工厂方法调用的结果是否有效。
大多数工厂在使用不能处理的情况下被调用时,会返回null或空对象。因此,使用工厂时,检查每次调用的结果是否实际有效是一个很好的常见做法。
正如你所见,仅接受简单参数(例如字符串和数字)的工厂,在许多情况下会导致参数数量增加。即使这些参数只在特定情况下使用,我们的工厂的 API 也开始变得尴尬而冗长,并且需要针对每个特殊情况进行适当的文档编写,以便可用。
理想情况下,工厂方法应尽量接受尽可能少的参数,否则它将开始看起来像一个仅提供不同 API 的 Facade。由于在某些情况下,仅使用单个字符串或数值参数不足以满足要求,为了避免使用大量参数,我们可以遵循一种做法,即设计工厂以接受单个对象作为其参数。
例如,在我们的情况下,我们可以将描述表单字段的整个对象作为参数传递给工厂方法:
dataDrivenForm.formElementHTMLFactory = function (formElementDefinition) {
var topPart = '<div><label><span>' + formElementDefinition.title + ':</span><br />';
var bottomPart = '</label></div>';
if (formElementDefinition.type === 'text') {
return topPart +
'<input type="text" maxlength="200" name="' +formElementDefinition.name + '" />' +
bottomPart;
} /* ... */
};
这种做法适用于以下情况:
当我们创建的工厂是不专注于特定用例的通用工厂,并且我们需要为每个特定用例分别配置它们的结果时。
当构造的对象具有许多可选配置参数且差异很大时。在这种情况下,将它们作为单独的参数添加到工厂方法中将导致调用具有一些null参数,具体取决于我们想要定义哪个确切的参数。
另一种做法,特别是在 JavaScript 编程中,是创建一个工厂方法,该方法接受一个简单的字符串或数字值作为其第一个参数,并可选地提供一个补充对象作为第二个参数。这使我们能够拥有一个简单的通用 API,可以特定于用例,并且还为我们提供了一些额外的自由度来配置一些特殊情况。这种方法被$.ajax( url [, settings ] )方法所使用,该方法允许我们通过只提供 URL 来生成简单的 GET 请求,还接受一个可选的settings参数,允许我们配置请求的任何方面。将上述实现更改为使用此变体留作读者的练习,以便进行实验并熟悉工厂方法的使用。
介绍建造者模式
建造者模式是创建模式组中的一部分,为我们提供了一种在达到可以使用的点之前需要大量配置的对象的创建方法。建造者模式通常用于接受许多可选参数以定义其操作的对象。另一个匹配的案例是创建需要在几个步骤或特定顺序中完成配置的对象。
根据计算机科学的共同范例,建造者模式的常见范式是有一个建造者对象,提供一个或多个设置方法(setA(...),setB(...)),以及一个单独的生成方法,用于构建并返回新创建的结果对象(getResult())。
此模式有两个重要概念。第一个是建造者对象公开一些方法作为配置正在构建的对象的不同部分的一种方式。在配置阶段,建造者对象保留一个内部状态,反映了所提供的设置方法的调用的效果。当用于创建接受大量配置参数的对象时,这可能是有益的,解决了拖尾构造函数的问题。
注意
拖尾构造函数是面向对象编程的反模式,描述了一个类提供了几个构造函数,这些构造函数往往在所需参数的数量,类型和组合上有所不同。具有多个参数可以以许多不同组合使用的对象类通常会导致实现落入这种反模式中。
第二个重要概念是它还提供了一个生成方法,根据前述配置返回实际构造的对象。大多数情况下,请求对象的实例化是惰性进行的,并且实际上是在调用此方法的时候发生的。在某些情况下,建造者对象允许我们调用生成方法超过一次,从而使我们能够使用相同配置生成多个对象。
它如何被 jQuery 的 API 接受
建造者模式也可以作为 jQuery 公开的 API 的一部分找到。具体来说,jQuery 的 $() 函数也可以通过使用 HTML 字符串作为参数来创建新的 DOM 元素。因此,我们可以创建新的 DOM 元素并根据需要设置它们的不同部分,而不必创建所需的最终结果的确切 HTML 字符串:
var $input = $('<input />');
$input.attr('type','number');
$input.attr('min', '0');
$input.attr('max', '100');
$input.prop('required', true);
$input.val(4);
$input.appendTo('form');
$('<input />') 调用返回一个包含未附加到页面的 DOM 树的元素的复合对象。这个未附加的元素只是一个内存对象,直到我们将其附加到页面为止,它既不完全构造也不完全功能。在这种情况下,此复合对象就像一个具有尚未最终化的对象内部状态的构建对象实例。在此之后,我们使用一些 jQuery 方法对其进行一系列操作,这些方法就像建造者模式描述的设置器方法一样。
最后,在我们应用所有必需的配置之后,使得生成的对象以期望的方式行为,我们调用 $.fn.appendTo() 方法。$.fn.appendTo() 方法作为建造者模式的生成方法,将 $input 变量的内存元素附加到页面的 DOM 树上,将其转换为实际附加的 DOM 元素。
当然,通过利用 jQuery 为其方法提供的流式 API,并组合 $.fn.attr() 方法调用,以上示例可以变得更易读且不太重复。此外,jQuery 允许我们使用几乎所有其方法来在构建中的元素上执行遍历和操作,就像我们可以在普通 DOM 元素的复合对象上执行的那样。因此,以上示例可以更完整地如下所示:
$('<input />').attr({
'type':'number',
'min': '0',
'max': '100'
})
.prop('required', true)
.val(4)
.css('display', 'block')
.wrap('<label>') // wrap the input with a <label>
.parent() // traverse one level up, to the <label>
.prepend('<span>Qty:#</span')
.appendTo('form');
结果如下所示:
允许我们将调用 $() 函数的这种过载方式归类为采用建造者模式的实现的标准是:
它返回一个具有包含部分构造元素的内部状态的对象。所包含的元素仅是内存对象,不是页面 DOM 树的一部分。
它为我们提供了操作其内部状态的方法。大多数 jQuery 方法都可以用于此目的。
它为我们提供了生成最终结果的方法。我们可以使用 jQuery 方法,例如 $.fn.appendTo() 和 $.fn.insertAfter(),作为完成内部元素构造并使其成为具有反映其较早内存表示的属性的 DOM 树的一部分的方法。
正如我们已经在第一章 jQuery 和组合模式的复习中看到的,使用 $() 函数的主要方法是将其与 CSS 选择器作为字符串参数调用,然后它将检索匹配的页面元素并以组合对象返回它们。另一方面,当 $() 函数检测到它已被调用的字符串参数看起来像一个 HTML 片段时,它将作为 DOM 元素生成器。这种重载的 $() 函数的调用方式基于提供的 HTML 代码以 < 和 > 不等号符号开始和结束的假设:
init = jQuery.fn.init = function( selector, context ) {
/* 11 lines of code */
// Handle HTML strings
if ( typeof selector === "string" ) {
if ( selector[ 0 ] === "<" &&selector[ selector.length - 1 ] === ">" &&selector.length >= 3 ) {
// Assume that strings that start and end with <> are HTML // and skip the regex check
match = [ null, selector, null ];
} /*...*/
// Match html or make sure no context is specified for #id
if ( match && ( match[ 1 ] || !context) ) {
// HANDLE: $(html) -> $(array)
if ( match[ 1 ] ) {
/* 4 lines of code */
jQuery.merge( this, jQuery.parseHTML( match[ 1 ], /*...*/ ) );
/* 16 lines of code */
return this;
}/*...*/
}/*...*/
}/*...*/
};
正如我们在前面的代码中所看到的,这个重载使用了 jQuery.parseHTML() 辅助方法,最终导致调用 createDocumentFragment() 方法。创建的文档片段然后被用作正在构建的元素树结构的宿主。在 jQuery 完成将 HTML 转换为元素之后,文档片段被丢弃,只返回其托管的元素:
jQuery.parseHTML = function( data, context, keepScripts ) {
/* 17 lines of code */
// Single tag
if ( parsed ) {
return [ context.createElement( parsed[ 1 ] ) ];
}
parsed = buildFragment( [ data ], context, scripts );
/* 5 lines of code */
return jQuery.merge( [], parsed.childNodes );
};
这导致创建一个包含内存中元素树结构的新 jQuery 组合对象。尽管这些元素未附加到页面的实际 DOM 树上,我们仍然可以像对待任何其他 jQuery 组合对象一样对它们进行遍历和操作。
注意
有关文档片段的更多信息,您可以访问:developer.mozilla.org/en-US/docs/Web/API/Document/createDocumentFragment。
内部使用 jQuery 的方法
jQuery 的一个毫无疑问的重要部分是其与 AJAX 相关的实现,其目标是提供一个简单的 API 用于异步调用,同时也可以在很大程度上进行配置。使用 jQuery 源代码查看器并搜索 jQuery.ajax,或直接在 jQuery 的源代码中搜索 "ajax:",将带来上述实现。为了使其实现更加直接并允许其进行配置,jQuery 内部使用一种特殊的对象结构,该结构充当了用于创建和处理每个 AJAX 请求的生成器对象。正如我们将看到的,这不是使用生成器对象的最常见方式,但实际上是一种具有一些修改以适应这个复杂实现要求的特殊变体:
jqXHR = {
readyState: 0,
// Builds headers hashtable if needed
getResponseHeader: function( key ) {/* ... */},
// Raw string
getAllResponseHeaders: function() {/* ... */},
// Caches the header
setRequestHeader: function( name, value ) {/* ... */},
// Overrides response content-type header
overrideMimeType: function( type ) {/* ... */},
// Status-dependent callbacks
statusCode: function( map ) {/* ... */},
// Cancel the request
abort: function( statusText ) {/* ... */}
};
jqXHR 对象公开用于配置生成的异步请求的主要方法是 setRequestHeader() 方法。这个方法的实现相当通用,使得 jQuery 可以使用一个方法设置请求的所有不同 HTTP 标头。
为了提供更大程度的灵活性和抽象性,jQuery 内部使用一个单独的transport对象作为jqXHR对象的包装器。这个传输对象处理实际将 AJAX 请求发送到服务器的部分,像一个与jqXHR对象合作创建最终结果的合作构建器对象。这样,jQuery 可以使用相同的 API 和整体实现从相同或跨域服务器获取脚本、XML、JSON 和 JSONP 响应:
transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR );
// If no transport, we auto-abort
if ( !transport ) {
done( -1, "No Transport" );
} else {
jqXHR.readyState = 1;
/* 12 lines of code */
try {
state = 1;
transport.send( requestHeaders, done );
} catch ( e ) {/* 7 lines of code */}
}
这个构建器模式的实现的另一个特殊之处是,它应该能够以同步和异步方式操作。因此,transport对象的send()方法,它作为包装的jqXHR对象的结果生成方法,不能只返回一个结果对象,而是需要使用回调来调用它。
最后,在请求完成后,jQuery 使用getResponseHeader()方法检索所有必需的响应标头。紧接着,标头被用于正确转换存储在jqXHR对象的responseText属性中的接收到的响应。
如何在我们的应用程序中使用它
作为在使用 jQuery 的客户端应用程序中使用构建器模式的示例用例,我们将创建一个简单的数据驱动多选题测验。与我们之前看到的工厂模式示例相比,构建器模式更适合这种情况的主要原因是结果更复杂,具有更多的配置度。每个问题都将基于一个模型对象生成,该对象将表示其所需的属性。
再次强调,所需的 HTML 非常简单,只包含一个页面标题的<h1>元素,一个空的<form>标签,以及对我们的 CSS 和 JavaScript 资源的一些引用:
<h1>Data Driven Quiz</h1>
<form> </form>
<script type="text/javascript" src="img/jquery.js"></script>
<script type="text/javascript" src="img/datadrivenquiz.js"></script>
除了我们在之前章节中看到的常见的简单样式之外,这个示例的 CSS 还额外定义了:
ul.unstyled > li {
margin: 0;
padding: 0;
list-style: none;
}
为了这个例子的需要,我们将创建一个带有新命名空间dataDrivenQuiz的模块。正如我们在本章前面看到的,我们将假设存在一个数组,其中包含描述需要呈现的每个多选题的模型对象。每个这些模型对象都将具有:
一个title属性,将保存问题
一个options属性,将是一个包含可供选择的答案的数组
一个可选的acceptsMultiple属性,表示我们应该使用单选按钮还是复选框
描述表单问题的模型对象数组将在我们的模块的dataDrivenQuiz.parts属性中可用,同时要牢记我们的实现可以轻松地修改为使用 AJAX 请求获取模型:
dataDrivenQuiz.questions = [{
title: 'Which is the most preferred way to write our JavaScript code?',
options: [
'inline along with our HTML',
'flat inside *.js files',
'in small Modules, one per *.js file'
]
}, {
title: 'What does the $() function returns when invoked with a CSS selector?',
options: [
'a single element',
'an array of elements',
'the HTML of the selected element',
'a Composite Object'
]
}, {
title: 'Which of the following are Design Patterns',
acceptsMultiple: true,
options: [
'Garbage Collector',
'Class',
'Object Literal',
'Observer'
]
}, {
title: 'How can get a hold to the <body> element of a page?',
acceptsMultiple: true,
options: [
'document.body',
'document.getElementsByTagName(\'body\')[0]',
'$(\'body\')[0]',
'document.querySelector(\'body\')'
]
}];
提示
在开始实际实现之前,定义描述问题所需的数据结构使我们能够专注于应用程序的需求,并对其整体复杂性进行估算。
鉴于前述示例数据,现在让我们继续实现我们的构建器:
function MultipleChoiceBuilder() {
this.title = 'Untitled';
this.options = [];
}
dataDrivenQuiz.MultipleChoiceBuilder = MultipleChoiceBuilder;
MultipleChoiceBuilder.prototype.setTitle = function(title) {
this.title = title;
return this;
};
MultipleChoiceBuilder.prototype.setAcceptsMultiple = function(acceptsMultiple) {
this.acceptsMultiple = acceptsMultiple;
return this;
};
MultipleChoiceBuilder.prototype.addOption = function(title) {
this.options.push(title);
return this;
};
MultipleChoiceBuilder.prototype.getResult = function() {
var $header = $('<header>').text(this.title || 'Untitled');
var questionGuid = 'quizQuestion' + (jQuery.guid++);
var $optionsList = $('<ul class="unstyled">');
for (var i = 0; i < this.options.length; i++) {
var $input = $('<input />').attr({
'type': this.acceptsMultiple ? 'checkbox' : 'radio',
'value': i,
'name': questionGuid,
});
var $option = $('<li>');
$('<label>').append($input, $('<span>').text(this.options[i]))
.appendTo($option);
$optionsList.append($option);
}
return $('<article>').append($header, $optionsList);
};
使用 JavaScript 的原型面向对象方法,我们首先为我们的MultipleChoiceBuilder类定义构造函数。当使用new运算符调用构造函数时,它将创建一个新的构建器实例,并将其title属性初始化为"Untitled",将options属性初始化为空数组。
在这之后,我们完成了构建器的构造函数的定义,将其作为模块的成员附加,并继续定义其设置器方法。遵循原型类范例,setTitle()、setAcceptsMultiple()和addOption()方法被定义为构建器原型的属性,并用于修改正在构建的元素的内部状态。另外,为了使我们能够链式调用这些方法的多个调用,从而获得更可读的实现,它们都以return this;语句结束。
我们使用getResult()方法完成构建器的实现,该方法负责收集应用于构建器对象实例的所有参数,并生成包装在 jQuery 组合对象中的结果元素。在其第一行,它创建了一个问题的标题。紧接着,它创建一个带有unstyled CSS 类的<ul>元素,用于容纳问题的可能答案,并使用一个唯一标识符作为问题生成的<input>的name。
在接下来的for循环中,我们将:
为问题的每个选项创建一个<input />元素。
根据acceptsMultiple属性的值,将其type适当设置为checkbox或radio按钮。
使用for循环的迭代编号作为其value。
将我们之前生成的问题的唯一标识符设置为输入的name,以便将答案分组。
最后,在问题的<ul>中添加包含选项文本的<label>,并将其全部包装在一个<li>中。
最后,标题和选项列表都被包装在一个<article>元素中,并作为构建器的最终结果返回。
在上述实现中,我们使用$.fn.text()方法为问题的标题及其可用选择分配内容,而不是使用字符串连接,以便正确转义其中的<和>字符。额外说明,由于一些答案也包含单引号,我们需要在模型对象中使用反斜杠(\')对它们进行转义。
最后,在我们模块的实现中,我们定义并立即调用init方法:
dataDrivenQuiz.init = function() {
for (var i = 0; i < dataDrivenQuiz.questions.length; i++) {
var question = dataDrivenQuiz.questions[i];
var builder = new dataDrivenQuiz.MultipleChoiceBuilder();
builder.setTitle(question.title) .setAcceptsMultiple(question.acceptsMultiple);
for (var j = 0; j < question.options.length; j++) {
builder.addOption(question.options[j]);
}
$('form').append(builder.getResult());
}
};
$(document).ready(dataDrivenQuiz.init);
初始化代码的执行被延迟,直到页面的 DOM 树完全加载完成。然后,init() 方法遍历模型对象数组,并使用 Builder 创建每个问题,并填充我们页面的<form>元素。
对于读者来说,一个很好的练习是扩展上述实现,以支持对测验的客户端评估。首先,这需要您扩展问题对象以包含有关每个选项有效性的信息。然后,建议您创建一个 Builder,该 Builder 将从表单中获取答案,评估它们,并创建一个包含用户选择和测验总体成功的结果对象。
摘要
在本章中,我们学习了 Builder 和 Factory 模式的概念,这两种是最常用的创建型设计模式之一。我们分析了它们的共同目标,它们在抽象生成和初始化特定用例的新对象过程方面的不同方法,以及它们的采用如何使我们的实现受益。最后,我们学习了如何正确使用它们,并如何为任何给定实现的不同用例选择最合适的模式。
现在我们已经完成了对最重要的创建型设计模式的介绍,我们可以继续下一章,介绍用于编写异步和并发程序的开发模式。更详细地说,我们将学习如何通过使用回调和 jQuery Deferred 和 Promises API 来编排顺序或并行运行的异步程序的执行。
第七章:异步控制流模式
本章专注于用于简化异步和并发过程编程的开发模式。
首先,我们将复习 JavaScript 编程中如何使用回调函数以及它们是网页开发的一个组成部分。然后,我们将继续识别它们在大型和复杂实现中的好处和局限性。
接下来,我们将介绍 Promises 的概念。我们将学习 jQuery 的 Deferred 和 Promise API 的工作原理,以及它们与 ES6 Promises 的区别。我们将看到它们在 jQuery 内部的使用方式以简化其实现并导致更可读的代码。我们将分析它们的好处,分类最匹配的用例,并将它们与经典的回调模式进行比较。
到达本章结束时,我们将能够使用 jQuery Deferred 和 Promises 来有效地编排按顺序或并行运行的异步过程的执行。
在本章中,我们将:
对 JavaScript 编程中如何使用回调函数进行复习
介绍 Promises 的概念
学习如何使用 jQuery 的 Deferred 和 Promise API
比较 jQuery Promises 和 ES6 Promises
学习如何使用 Promises 来编排异步任务。
使用回调函数进行编程
回调函数可以被定义为作为调用参数传递给另一个函数或方法(称为高阶函数)的函数,并且预计将在以后的某个时间点执行。通过这种方式,被传递给我们的回调函数的代码片段最终会调用它,将操作或事件的结果传播回定义回调函数的上下文。
回调函数可以根据被调用方法的操作方式分为同步或异步。当回调由阻塞方法执行时,回调被称为同步。另一方面,JavaScript 开发人员更熟悉异步回调,也称为延迟回调,它们被设置为在异步过程完成后或发生特定事件时执行(页面加载,单击,AJAX 响应到达等)。
由于回调函数是许多核心 JavaScript API(如 AJAX)的组成部分,因此在 JavaScript 应用程序中广泛使用。此外,JavaScript 对该模式的实现几乎与上述简单定义所描述的一字不差。这是 JavaScript 将函数视为对象并允许我们将方法引用存储和传递为简单变量的方式的结果。
在 JavaScript 中使用简单回调函数
在 JavaScript 中使用异步回调的最简单的例子之一可能是setTimeout()函数。下面的代码演示了它的一个简单用法,我们将setTimeout()与doLater()函数作为回调参数一起调用,并且在等待 1000 毫秒后,doLater()回调被调用:
var alertMessage = 'One second passed!';
function doLater() {
alert(alertMessage);
}
setTimeout(doLater, 1000);
如简单的前面示例所示,回调在定义的上下文中执行。回调仍然可以访问定义它的上下文的变量,通过创建闭包来实现。即使前面的示例使用了之前定义的命名函数,对于匿名回调也是适用的:
var alertMessage = 'One second passed!';
setTimeout(function() {
alert(alertMessage);
}, 1000);
在许多情况下,使用匿名回调是一种更方便的编程方式,因为它会导致代码更短,也减少了可读性噪音,这是由定义几个只使用一次的不同命名函数而产生的。
将回调设置为对象属性
上述定义的一个小变化也存在,其中回调函数被分配给对象的属性,而不是作为方法调用的参数传递。这在需要在方法调用期间或之后执行几种不同操作的情况下通常使用:
var c = new Countdown();
c.onProgress = function(progressStatus) { /*...*/ };
c.onDone = function(result) { /*...*/ };
c.onError = function(error) { /*...*/ };
c.start();
上述变体的另一个用例是在已实例化和初始化的对象上添加处理程序。这种情况的一个很好的例子是我们为简单(非 jQuery)AJAX 调用设置结果处理程序的方式:
var r = new XMLHttpRequest();
r.open('GET', 'data.json', true);
r.onreadystatechange = function() {
if (r.readyState != 4 || r.status != 200) {
return;
}
alert(r.responseText);
};
r.send();
在上述代码中,我们将一个匿名函数设置在 XMLHttpRequest 对象的onreadystatechange属性上。这个函数充当回调,每当进行中的请求状态发生变化时都会被调用。在我们的回调内部,我们检查请求是否以成功的 HTTP 状态码完成,并显示带有响应主体的警报。就像在这个示例中一样,我们通过调用send()方法而不传递任何参数来启动 AJAX 调用,使用这种变体的 API 通常导致以最小的方式调用它们的方法。
在 jQuery 应用程序中使用回调
在 jQuery 应用程序中使用回调的最常见方式可能是用于事件处理。这是合乎逻辑的,因为每个交互式应用程序都应该首先处理和响应用户操作。正如我们在前面章节中看到的,将事件处理程序附加到元素的最便捷方式之一是使用 jQuery 的$.fn.on()方法。
jQuery 中另一个常见的使用回调的地方是 AJAX 请求,$.ajax()方法起着中心作用。此外,jQuery 库还提供了几个方便的方法来进行 AJAX 请求,这些方法都专注于最常见的用例。由于所有这些方法都是异步执行的,它们也接受一个回调作为参数,以便将检索到的数据返回给发起 AJAX 请求的上下文。其中一个方便的方法是$.getJSON(),它是$.ajax()的一个包装器,并且用作执行意图检索 JSON 响应的 AJAX 请求的更匹配的 API。
其他广泛使用的接受回调的 jQuery API 如下:
诸如$.animate()之类的与效果相关的 jQuery 方法
$(document).ready()方法
现在让我们通过演示一个代码示例来继续,该示例中使用了上述所有方法。
$(document).ready(function() {
$('#fetchButton').on('click', function() {
$.getJSON('AjaxContent.json', function(json) {
console.log('done loading new content');
$('#newContent').css({ 'display': 'none' })
.text(json.data)
.slideDown(function() {
console.log('done displaying new content');
});
});
});
});
前面的代码首先延迟执行,直到页面的 DOM 树完全加载,然后通过使用 jQuery 的$.fn.on()方法,在 ID 为fetchButton的<button>上添加一个点击观察器。每当点击事件触发时,提供的回调将被调用,并启动一个 AJAX 调用来获取AjaxContent.json文件。在此示例中,我们使用一个简单的 JSON 文件,如下所示:
{ "data": "I'm the text content fetched by an AJAX call!" }
当接收到响应并成功解析 JSON 时,回调函数将以解析后的对象作为参数被调用。最后,回调函数本身会在页面中查找 ID 为newContent的页面元素,隐藏它,然后将检索到的 JSON 数据字段设置为其文本内容。紧接着,我们使用 jQuery 的$.fn.slideDown()方法,通过逐渐增加其高度使新设置的页面内容出现。最后,在动画完成后,我们向浏览器控制台输出一个日志消息。
注
关于 jQuery 的$.ajax()、$.getJSON()和$.fn.slideDown()方法更多的文档可以在api.jquery.com/jQuery.ajax/、api.jquery.com/jQuery.getJSON/和api.jquery.com/slideDown/中找到。
请记住,当通过文件系统加载页面时,$.getJSON()方法可能在某些浏览器中无法工作,但在使用 Apache、IIS 或 nginx 等任何 Web 服务器时可以正常工作。
编写接受回调的方法
当编写一个使用一个或多个异步 API 的函数时,这也意味着结果函数结果也是异步的。在这种情况下,很明显,简单地返回结果值不是一个选项,因为结果可能在函数调用已经完成后才可用。
异步实现的最简单解决方案是使用一个回调函数作为函数的参数,正如我们之前讨论的那样,在 JavaScript 中这是很方便的。例如,我们将创建一个异步函数,它生成指定范围内的随机数:
function getRandomNumberAsync (max, callbackFn) {
var runFor = 1000 + Math.random() * 1000;
setTimeout(function() {
var result = Math.random() * max;
callbackFn(result);
}, runFor);
}
getRandomNumberAsync() 函数接受其 max 参数作为生成的随机数的数值上限,还接受一个回调函数作为参数,它将使用生成的结果调用。它使用 setTimeout() 来模拟一个范围在 1000 到 2000 毫秒之间的异步计算。为了生成结果,它使用 Math.random() 方法,将其乘以允许的最大值,最后使用提供的回调函数调用它。调用此函数的简单方法如下所示:
getRandomNumberAsync(10, function(number) {
console.log(number); // returns a number between 0 and 10
});
即使上面的示例使用 setTimeout() 来模拟异步处理,但不管使用哪种异步 API,实现原理都是相同的。例如,我们可以重写上述函数以通过 AJAX 调用来检索其结果:
function getRandomNumberWS (max, callbackFn, errorFn) {
$.ajax({
url: 'https://qrng.anu.edu.au/API/jsonI.php?length=1&type=uint16',
dataType: 'json',
success: function(json) {
var result = json.data[0] / 65535 * max;
callbackFn(result);
},
error: errorFn
});
}
前述实现使用了 $.ajax() 方法,该方法使用一个对象参数调用,该对象封装了请求的所有选项。除了请求的 URL 外,该对象还定义了结果的预期 dataType 和 success 和 error 回调函数,这些函数与我们函数的相应参数配合使用。
或许前面的代码唯一额外需要解决的问题是如何在成功回调内处理错误,以便在创建结果过程中出现问题时通知函数的调用者。例如,AJAX 请求可能会返回一个空对象。为这些情况添加适当的处理留给读者,在阅读本章剩余部分之后。
注意
澳大利亚国立大学(ANU)通过他们的 REST Web 服务向公众提供免费的真正随机数。更多信息,请访问 qrng.anu.edu.au/API/api-demo.php。
调度回调函数
我们现在将继续分析一些在处理接受回调函数的异步方法时常用的控制执行流程的模式。
按顺序排队执行
作为我们的第一个例子,我们将创建一个函数,演示如何排队执行多个异步任务:
function getThreeRandomNumbers(callbackFn, errorFn) {
var results = [];
getRandomNumberAsync(10, function(number) {
results.push(number);
getRandomNumberAsync(10, function(number) {
results.push(number);
getRandomNumberWS(10, function(number) {
results.push(number);
callbackFn(results);
}, function (error) {
errorFn(error);
});
});
});
}
在前面的实现中,我们的函数创建了一个包含三个随机数生成的队列。前两个随机数是从我们的样本 setTimeout() 实现中生成的,第三个是通过 AJAX 调用从上述 Web 服务中检索的。在这个例子中,所有的数字都被收集在 result 数组中,在所有异步任务完成后作为调用参数传递给 callbackFn。
前述的实现相当简单直接,并且只是反复应用了回调模式的简单原则。对于每一个额外或排队的异步任务,我们只需将其调用嵌套在它依赖的任务的回调内部即可。请记住,在不同的用例中,我们可能只关心返回最终任务的结果,并将中间步骤的结果作为参数传递给每个后续的异步调用。
避免回调地狱反模式
尽管编写像上面示例中显示的代码很容易,但当应用于大型和复杂的实现时,可能会导致可读性较差。由代码前面的空格创建的三角形形状和接近末尾的几个});的堆叠是我们的代码可能会导致的反模式的两个迹象,该反模式被称为回调地狱。
注意
欲了解更多信息,请访问callbackhell.com/。
一种避免这种反模式的方法是展开嵌套的回调函数,通过在与它们使用的异步任务相同级别创建单独的命名函数。将这个简单的提示应用到上面的示例后,生成的代码看起来更清晰:
function getThreeRandomNumbers(callbackFn, errorFn) {
var results = [];
getRandomNumberAsync(10, function(number) { // task 1
results.push(number);
task2();
});
function task2 () {
getRandomNumberAsync(10, function(number) {
results.push(number);
task3();
});
}
function task3 () {
getRandomNumberWS(10, function(number) {
results.push(number);
callbackFn(results);
}, errorFn);
}
}
正如您所见,生成的代码确实不会让我们想起回调地狱反模式的特征。另一方面,现在它需要更多的代码行来实现,主要用于现在需要的额外函数声明function taskX () { }。
提示
在上述两种方法之间的一个中间解决方案是将这种异步执行队列的相关部分组织成小型且易于管理的函数。
并行运行
尽管 Web 浏览器中的 JavaScript 是单线程的,但使独立的异步任务并行运行可以使我们的应用程序运行更快。例如,我们将重新编写前面的实现以并行获取所有三个随机数,这样可以使结果的检索速度比以前快得多:
function getRandomNumbersConcurent(callbackFn, errorFn) {
var results = [];
var resultCount = 0;
var n = 3;
function gatherResult (resultPos) {
return function (result) {
results[resultPos] = result;
resultCount++;
if (resultCount === n) {
callbackFn(results);
}
};
}
getRandomNumberAsync(10, gatherResult(0));
getRandomNumberAsync(10, gatherResult(1));
getRandomNumberWS(10, gatherResult(2), errorFn);
}
在前面的代码中,我们定义了gatherResult()辅助函数,它返回一个匿名函数,该函数用作我们的随机数生成器的回调。返回的回调函数使用resultPos参数作为将生成或检索到的随机数存储的数组的索引。此外,它追踪它被调用的次数,以了解是否所有三个并行任务已结束。最后,在第三次和最后一次回调之后,使用results数组作为参数调用callbackFn函数。
除了 AJAX 调用之外,这种技术的另一个很好的应用是访问存储在IndexedDB中的数据。并行从数据库中检索许多值可以带来性能增益,因为数据检索可以在不互相阻塞的情况下并行执行。
注意
有关 IndexedDB 的更多信息,您可以访问 developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB。
介绍 Promise 的概念
Promise,也被计算机科学称为 Futures,被描述为专门用于同步异步、并发或并行过程的特殊对象。它们也被用作代理来在生成完成任务的结果时传播结果。这样,一个 Promise 对象就像是一个合同,其中一项操作最终将完成其执行,任何持有这个合同引用的人都可以声明他们对结果的通知感兴趣。
自从它们作为几个库的一部分被引入到 JavaScript 开发中,它们彻底改变了我们使用异步函数以及在实现中与复杂的同步方案结合使用的方式。这样,Web 开发人员可以创建更灵活、可扩展和可读性更强的实现,使带有回调的方法调用看起来像是一个原始模式,并有效地消除了回调地狱(Callback Hell)的情况。
Promise 的一个关键概念是,异步方法返回一个代表其最终结果的对象。每个 Promise 都有一个最初状态为 Pending 的内部状态。这个内部状态只能改变一次,从 Pending 改变为 Resolved 或 Rejected,通过使用每个实现都提供的 resolve() 或 reject() 方法。这些方法只能调用来改变 Pending Promise 的状态;在大多数情况下,它们只能由 Promise 对象的原始创建者使用,而不是提供给其消费者。resolve() 方法可以用操作的结果作为单一参数来调用,而 reject() 方法通常用引起 Promise 对象被拒绝的 Error 来调用。
另一个 Promise 的关键概念是存在一个 then() 方法,使它们被称为“thenable”,这是一个通用术语,用来描述所有不同实现中的 promises。每个 Promise 对象都暴露了一个 then() 方法,调用者可以用它来提供在 Promise 被解决(Resolved)或拒绝(Rejected)时将被调用的函数。then() 方法可以用两个函数作为参数来调用,第一个函数在 Promise 被解决时被调用,而第二个函数在被拒绝时被调用。第一个参数通常被称为onFulfilled() 回调,而第二个参数被称为 onRejected()。
每个 Promise 都保存着两个内部列表,其中包含作为参数传递给 then() 方法的所有 onFulfilled() 和 onRejected() 回调函数。then() 方法可以针对每个 Promise 调用多次,向适当的内部列表添加新条目,只要相应的参数实际上是一个函数。当 Promise 最终得到解决或拒绝时,它会遍历适当的回调列表,并按顺序调用它们。此外,一旦 Promise 被解决并且之后,每次使用 then() 方法都会立即调用相应的提供的回调。
注意
根据其特性,Promise 在某种程度上可以被比作发布/订阅模式中的代理。它们的主要区别包括它只能用于单个发布,并且即使订阅者在发布之后表达了兴趣,他们也会收到结果通知。
使用 Promises
正如我们之前所说,Promise 的概念彻底改变了 JavaScript 中异步任务的编程方式,并且在很长一段时间内,它们是每个人都热情的新事物。那时,许多专门的库出现了,每个库都提供了一个稍有不同的 Promise 实现。此外,Promise 实现也作为 jQuery 之类的实用程序库的一部分以及诸如 AngularJS 和 EmberJS 之类的 Web 框架的一部分而可用。那时,"CommonJS Promises/A"规范以参考点的形式出现,并且是第一个尝试定义如何跨所有实现实际工作的 Promise。
注意
有关"CommonJS Promises/A"规范的更多信息,您可以访问wiki.commonjs.org/wiki/Promises/A。
使用 jQuery Promise API
基于"CommonJS Promises/A"设计,Promise-based API 首次出现在 jQuery v1.5 中。该实现引入了附加概念 Deferred 对象,它的工作方式类似于Promise 工厂。Deferred 对象公开了一组 Promises 提供的方法的超集,其中附加方法可用于对其内部 Promise 的状态进行操作。此外,Deferred 对象公开了一个promise()方法,并返回实际的 Promise 对象,该对象不公开任何方式来操作其内部状态,只公开像then()这样的观察方法。
换句话说:
只有引用 Deferred 对象的代码才能实际更改其 Promise 的内部状态,无论是解决还是拒绝。
任何具有对 Promise 对象的引用的代码片段都无法更改其状态,而只能观察其状态是否更改。
注意
有关 jQuery 的 Deferred 对象的更多信息,您可以访问api.jquery.com/jQuery.Deferred/。
作为 jQuery 的 Deferred 对象的一个简单示例,让我们看看如何重写本章早些时候看到的 getRandomNumberAsync() 函数,以使用 Promises 而不是回调:
function getRandomNumberAsync (max) {
var d = $.Deferred();
var runFor = 1000 + Math.random() * 1000;
setTimeout(function() {
var result = Math.random() * max;
d.resolve(result);
}, runFor);
return d.promise();
}
getRandomNumberAsync(10).then(function(number) {
console.log(number); // returns a number between 0 and 10
});
我们的目标是创建一个返回最终解决为生成的随机数的 Promise 的异步函数。首先,创建一个新的 Deferred 对象,然后使用 Deferred 的 promise() 方法返回相应的 Promise 对象。当结果的异步生成完成时,我们的方法使用 Deferred 对象的 resolve() 方法设置先前返回的 Promise 的最终状态。
我们函数的调用者使用返回的 Promise 的 then() 方法,附加一个回调函数,一旦 Promise 被解决,就会以结果作为参数调用该回调。此外,还可以传递第二个回调函数,以便在 Promise 被拒绝时得到通知。需要注意的一件重要事情是,通过遵循上述模式,即函数总是返回 Promises 而不是实际的 Deferred 对象,我们可以确保只有 Deferred 对象的创建者可以更改 Promise 的状态。
使用 Promises/A+
在进行了一段时间的实践性实验后,社区确定了 CommonJS Promises/A 的一些限制,并推荐了一些改进方法。结果是创建了 Promises/A+ 规范,作为改进现有规范的一种方式,也是统一各种可用实现的第二次尝试。新规范的最重要部分关注于如何使链接 Promises 工作,使它们更加实用和方便。
注意
有关 Promises/A+ 规范的更多信息,您可以访问 promisesaplus.com/。
最终,Promises/A+ 规范作为 JavaScript 第 6 版的一部分发布,通常称为 ES6,于 2015 年 6 月发布为标准。因此,Promises/A+ 开始在浏览器中原生实现,不再需要使用自定义的第三方库,并推动大多数现有库升级其语义。截至撰写本书时,几乎所有现代浏览器都提供了原生的 Promises/A+ 兼容实现,除了 IE11,使其可以供超过 65% 的网络用户直接使用。
注意
关于浏览器中采用 A+ Promises 的更多信息,您可以访问 caniuse.com/#feat=promises。
使用现在原生实现的 ES6 A+ Promises 重写 getRandomNumberAsync() 函数将如下所示:
function getRandomNumberAsync (max) {
return new Promise(function (resolve, reject) {
var runFor = 1000 + Math.random() * 1000;
setTimeout(function() {
var result = Math.random() * max;
resolve(result);
}, runFor);
});
}
getRandomNumberAsync(10).then(function(number) {
console.log(number); // returns a number between 0 and 10
});
正如你所看到的,ES6 / A+ Promises 是通过使用 Promise 构造函数和 new 关键字创建的。构造函数被调用时带有一个函数作为参数,这使得闭包可以访问到 Promise 被创建的上下文的变量,同时也可以通过参数访问 resolve() 和 reject() 函数,这是改变新创建的 Promise 状态的唯一方法。在 setTimeout() 函数触发其回调后,将用生成的随机数作为参数调用 resolve() 函数,将 Promise 对象的状态更改为已完成。最后,我们函数的调用者使用返回的 Promise 的 then() 方法,方式与我们之前使用 jQuery 的实现完全相同。
比较 jQuery 和 A+ Promises
我们将深入逐步分析 jQuery 和 A+ Promise API 的核心概念,并通过两者的代码进行逐行对比。这将是一个非常有价值的资料,因为在 Promises 的实现逐渐适应 ES6 A+ 规范时,你还可以将其作为参考。
从一开始就了解这两种变体的差异的需求似乎更为重要,因为 jQuery 团队已经宣布版本 3.0 的库将具有符合 Promises/A+ 规范的实现。具体而言,在编写本书时,第一个 beta 版本已经发布,这使得迁移的时间似乎更近了。
注意
关于 jQuery v3.0 A+ Promises 实现的更多信息,请访问 blog.jquery.com/2016/01/14/jquery-3-0-beta-released/。
两种实现之间最明显的区别之一是创建新 Promises 的方式。正如我们所见,jQuery 使用 $.Deferred() 函数像一个工厂一样创建了一个更复杂的对象,该对象直接提供对 Promise 状态的访问,并最终使用单独的方法提取实际的 Promise。另一方面,A+ Promises 使用 new 关键字和一个函数作为参数,运行时将使用 resolve() 和 reject() 函数作为参数调用该函数:
var d = $.Deferred();
setTimeout(function() {
d.resolve(7);
}, 2000);
var p = d.promise(); // jQuery Promise
var p = new Promise(function(resolve, reject) { // Promises/A+
setTimeout(function() {
resolve(7);
}, 2000);
});
此外,jQuery 还提供了另一种创建类似 A+ Promises 工作方式的 Promise 的方法。在这种情况下,$.Deferred() 可以被调用,并以函数作为参数,该函数接收 Deferred 对象作为参数:
var d = $.Deferred(function (deferred) {
setTimeout(function() {
deferred.resolve(7);
}, 2000);
});
var p = d.promise();
正如我们之前讨论的那样,Promise 的第二种可能结果是被 Rejected,这个特性很好地配合了 JavaScript 在同步编程中的经典异常。拒绝一个 Promise 通常用于在处理结果时发生错误的情况,或者在结果无效的情况下。虽然 ES6 Promises 在其构造函数传递给函数的参数中提供了一个 reject() 函数,但在 jQuery 的实现中,reject() 方法仅简单地在 Deferred 对象本身上暴露。
var p = $.Deferred(function (deferred) {
deferred.reject(new Error('Something happened!'));
}).promise();
var p = new Promise(function(resolve, reject) {
reject(new Error('Something happened!'));
});
在两种实现中,可以使用 then() 方法检索 Promise 的结果,该方法可以用两个函数作为参数调用,一个用于处理 Promise 被 Fulfill 的情况,另一个用于处理其被 Rejected 的情况:
p.then(function(result) { // works the same in jQuery & ES6
console.log(result);
}, function(error) {
console.error('An error occurred: ', error);
});
两种实现还提供了方便的方法来处理 Promise 被 Rejected 的情况,但使用不同的方法名。ES6 Promises 提供了 catch() 方法,很好地配合了 try...catch JavaScript 表达式,而 jQuery 的实现则为相同的目的提供了 fail() 方法:
p.fail(function(error) { // jQuery
console.error(error);
});
p.catch(function(error) { // ES6
console.error(error);
});
此外,作为 jQuery 独有的特性,jQuery Promises 还暴露了 done() 和 always() 方法。提供给 done() 的回调在 Promise 被 Fulfill 时被调用,并且等同于使用带有单个参数的 then() 方法,而 always() 方法的回调在 Promise 被 settled 时被调用,无论其结果如何。
注意
要了解更多关于 done() 和 always() 的信息,您可以访问 api.jquery.com/deferred.done 和 api.jquery.com/deferred.always。
最后,两种实现都提供了一个简单的方法,直接创建已经 Resolved 或 Rejected 的 Promises。这可以作为实现复杂同步方案的起始值,或者作为使同步函数操作像异步函数一样的简单方法:
var pResolved = $.Deferred().resolve(7).promise(); // jQuery
var pRejected = $.Deferred().reject(new Error('Something happened!')).promise();
var pResolved = Promise.resolve(7); // ES6
var pRejected = Promise.reject(new Error('Something happened!'));
高级概念
Promises 的另一个关键概念是使它们独特并极大地增加它们的实用性的能力,即轻松创建几个 Promise 的组合,这些 Promise 又是 Promise 本身。组合有两种形式,串行组合将 Promises 连接在一起,而并行组合则使用特殊方法将并发 Promises 的解决方案合并为一个新的解决方案。正如我们在本章前面看到的那样,使用传统的回调方法很难实现这样的同步方案。另一方面,Promises 试图以更方便和可读的方式解决这个问题。
链接 Promises
每次调用then()方法都会返回一个新的 Promise,其最终状态和结果取决于调用then()方法的 Promise,但也取决于附加的回调返回的值。这使我们能够通过连续连接它们来组合 Promise,从而使我们能够轻松地编排异步和同步代码,其中每个链接步骤将其结果传播到下一个步骤,并允许我们以可读且声明性的方式构建最终结果。
现在让我们继续分析调用then()方法的不同方式。由于我们将专注于通过链式调用进行 Promise 组合的概念,这与 jQuery 和 ES6 Promises 的工作方式相同,所以假设有一个p变量,它保存了由以下代码行之一创建的 Promise 对象:
var p = $.Deferred().resolve(7).promise();
//or
var p = Promise.resolve(7);
展示链接能力的最简单用例是调用的回调返回一个(非 promise)值。新创建的 Promise 使用返回的值作为其结果,同时保留与调用then()方法的 Promise 相同的状态:
p.then(function(x) { // works the same in jQuery & ES6
console.log(x); // logs 7
return x * 3;
}).then(function(x) {
console.log(x); // logs 21
});
需要牢记的一个特殊情况是,不返回任何结果的函数会被处理为返回undefined。这实质上从新返回的 Promise 中删除了结果值,现在只保留了父级解决状态:
p.then(function(x) { // works the same in jQuery & ES6
console.log(x); // logs 7
}).then(function(x) {
console.log(x); // logs undefined
});
在调用回调函数返回另一个 Promise 的情况下,其状态和结果将用于由then()方法返回的 Promise:
p.then(function(x) { // for jQuery Promises
console.log(x); // logs 7
var d2 = $.Deferred();
setTimeout(function() {
d2.resolve(x*3);
}, 2000);
return d2.promise();
}).then(function(x) {
console.log(x); // logs 21
});
p.then(function(x) { // for the A+ Promises
console.log(x); // logs 7
return new Promise(function(resolve) {
setTimeout(function() {
resolve(x*3);
}, 2000);
});
}).then(function(x) {
console.log(x); // logs 21
});
前面的代码示例演示了 jQuery 和 A+ Promises 的实现方式,两者都具有相同的结果。在两种情况下,都从第一个then()方法调用中将7记录到控制台,并返回一个新的 Promise,稍后将使用setTimeout()解析它。 2000 毫秒后,setTimeout()将触发其回调,返回的 Promise 将以21作为值解析,并在此时,21也将记录在控制台中。
还有一件额外需要注意的事情是,原始 Promise 已经被解决,而且没有为链接的then()方法提供适当的回调。在这种情况下,新创建的 Promise 解决为相同的状态和结果,就像在其中调用then()方法的 Promise 一样:
p.then(null, function (error) { // works the same in jQuery & ES6
console.error('An error happened!');// does not run, since the promise is resolved
}).then(function(x) {
console.log(x); // logs 7
});
在前面的示例中,作为then()方法的第二个参数传递的具有console.error语句的回调不会被调用,因为 Promise 解析为 7 作为其值。结果,链的回调最终接收到一个新的 Promise,该 Promise 也以7作为其值解析并在控制台中记录。要深入了解 Promise 链式调用的工作原理,有一件事需要牢记,即在所有情况下p != p.then()。
处理抛出的错误
链接的最终概念定义了在调用 then() 回调时抛出异常的情况。Promise/A+ 规范定义了新创建的 Promise 被拒绝,其结果是抛出的 Error。此外,拒绝将在整个 Promise 链中传播,使我们能够仅在链的末尾附近定义错误处理,就能得到有关链中任何错误的通知。
不幸的是,这在撰写本书时最新稳定版本的 jQuery 中并不一致,该版本为 v2.2.0:
$.Deferred().resolve().promise().then(function() {
throw new Error('Something happened!');
// the execution stops here
}).then(null, function(x) {
console.log(x); // nothing gets printed
});
$.Deferred().resolve().promise().then(function() {
try { // this is a workaround
throw new Error('Something happened!');
} catch (e) {
return $.Deferred().reject(e).promise();
}
}).then(function(){
console.log('Success'); // not printed
}).then(null, function(x) { // almost equivalent to .fail()
console.log(x); // logs 'Something happened!''
});
Promise.resolve().then(function() {
throw new Error('Something happened!');
}).then(function(){
console.log('Success'); // not printed
}).then(null, function(x) { // equivalent to .catch()
console.log(x); // logs 'Something happened!''
});
在第一种情况下,抛出的异常会停止 Promise 链的执行。唯一的解决方法可能是在传递给 then() 方法的回调中显式添加 try...catch 语句,如所示的第二种情况所示。
加入 Promise
另一种并发执行 Promise 的编排方式是将它们组合在一起。举个例子,假设存在两个 Promise,p1 和 p2,在分别经过 2000 和 3000 毫秒后以 7 和 11 作为它们的值被解决。由于这两个 Promise 是同时执行的,所以组合后的 Promise 只需要 3000 毫秒就能被解决,因为它是这两个持续时间中较大的一个:
// jQuery
$.when(p1, p2).then(function(result1, result2) {
console.log('p1', result1); // logs 7
console.log('p2', result2); // logs 11
// this can be used to make our code look like A+
var results = arguments;
});
// A+
Promise.all([p1, p2]).then(function(results) {
console.log('p1', results[0]); // logs 7
console.log('p2', results[1]); // logs 11
});
两种 Promise API 都提供了一个专门的函数,允许我们轻松创建 Promise 组合并检索组合的单个结果。当所有部分都被解决时,组合后的 Promise 被解决,而当任何一个部分被拒绝时,它被拒绝。不幸的是,这两种 Promise API 不仅在函数的名称上有所不同,而且在调用方式和提供结果的方式上也有所不同。
jQuery 实现提供了 $.when() 方法,可以用任意数量的参数来调用它们要组合的内容。通过在组合后的 jQuery Promise 上使用 then() 方法,我们可以在组合作为整体时得到通知,并访问每个单独的结果作为回调的参数。
另一方面,A+ Promise 规范为我们提供了 Promise.all() 方法,它用一个数组作为其单个参数调用,该数组包含我们要组合的所有 Promise。返回的组合 Promise 与我们迄今为止看到的 Promise 没有任何区别,并且 then() 方法的回调以一个数组作为其参数被调用,该数组包含组合中所有 Promise 的结果。
jQuery 如何使用 Promise
在 jQuery 添加 Promise 实现到其 API 后,它还开始通过其 API 的其他异步方法来公开它。也许最著名的例子就是 $.ajax() 系列方法,它返回一个 jqXHR 对象,这是一个专门的 Promise 对象,还提供了一些与 AJAX 请求相关的额外方法。
注意
有关 jQuery 的 $.ajax() 方法和 jqXHR 对象的更多信息,您可以访问 api.jquery.com/jQuery.ajax/#jqXHR。jQuery 团队还决定更改库的几个内部部分的实现以使用 Promises,以改进其实现。首先,$.ready() 方法使用 Promises 实现,以便提供的回调即使在其调用之前页面已加载很长时间也会触发。此外,jQuery 提供的一些复杂动画内部使用 Promises 作为动画队列的顺序部分执行的首选方式。
将 Promises 转换为其他类型
使用多个不同的 JavaScript 库进行开发往往会使得我们的项目中出现多种 Promise 实现,而不幸的是,它们往往对参考 Promises 规范的遵从程度不同。组合不同库方法返回的 Promise 往往会导致难以跟踪和解决的问题,因为它们的实现不一致。
为了避免在这种情况下造成混淆,不建议在尝试组合它们之前将所有 Promises 转换为单一类型。对于这种情况,建议使用 Promises/A+ 规范,因为它不仅被社区广泛接受,而且还是 JavaScript 的新发布版本(ES6 语言规范)的一部分,已经在许多浏览器中本地实现。
转换为 Promises/A+
例如,让我们看看如何将 jQuery Promise 转换为大多数最新浏览器中可用的 A+ Promise:
var jqueryPromise = $.Deferred().resolve('I will be A+ compliant').promise();
var p = Promise.resolve(jqueryPromise);
p.then(function(result) {
console.log(result);
});
在上述示例中,Promise.resolve() 方法检测到它已被调用并带有一个 "thenable",并且新创建的 A+ Promise 将其状态和结果绑定到所提供的 jQuery Promise 的状态和结果。这本质上相当于执行以下操作:
var p = new Promise(function (resolve, reject) {
jqueryPromise.then(resolve, reject);
});
当然,这不仅限于通过直接调用 $.Deferred() 方法创建的 Promises。上述技术也可以用于转换由任何 jQuery 方法返回的 Promises。例如,以下是它与 $.getJSON() 方法的使用方式:
var aPlusAjaxPromise = Promise.resolve($.getJSON('AjaxContent.json'));
aPlusAjaxPromise.then(function(result) {
console.log(result);
});
转换为 jQuery Promises
尽管我通常不建议这样做,但也有可能将任何 Promise 转换为 jQuery 变体。新创建的 jQuery Promise 接收 jQuery 提供的所有额外功能,但转换不像前一个那么直接:
var aPromise = Promise.resolve('I will be a jQuery Promise');
var p = $.Deferred(function (deferred) {
aPromise.then(function(result) {
return deferred.resolve(result);
}, function(error) {
return deferred.reject(error);
});
}).promise();
p.then(function(result) {
console.log(result);
});
仅在需要扩展已使用 jQuery Promises 实现的大型 Web 应用程序的情况下,才应使用上述技术。另一方面,您还应考虑升级此类实现,因为 jQuery 团队已经宣布库的 3.0 版本将具有 Promises/A+ 兼容的实现。
注意
要了解有关 jQuery v3.0 A+ Promises 实现的更多信息,您可以访问 blog.jquery.com/2016/01/14/jquery-3-0-beta-released/。
总结 Promise 的好处
总的来说,使用 Promises 而不是简单的回调的好处包括:
有一个统一的方法来处理异步调用的结果
有用于使用回调的可预测的调用参数
为 Promise 的每个结果附加多个处理程序的能力
即使 Promise 已经被解析(或拒绝),也保证适当的附加处理程序将执行
链接异步操作的能力,使它们按顺序运行
轻松创建异步操作的组合,使它们并发运行的能力
处理 Promise 链中错误的便捷方式
使用返回 Promise 的方法消除了直接将一个上下文的函数传递给另一个上下文作为调用参数以及哪些参数用作成功和错误回调的问题。此外,我们在阅读关于方法调用参数的文档之前,已经在一定程度上了解到了如何获取返回 Promise 的任何操作的结果,通过使用 then() 方法。
较少的参数通常意味着较少的复杂性、更小的文档和每次想要执行方法调用时的搜索量较少。更好的是,很有可能只有一个或几个参数,使得调用更加合理和可读。异步方法的实现也变得更加简单,因为不再需要接受回调函数作为额外参数或者需要正确地使用结果来调用它们。
总结
在本章中,我们分析了用于编写异步和并发过程的开发模式。我们还学习了如何有效地编排执行按顺序或并行运行的异步过程。
首先,我们对 JavaScript 编程中如何使用回调进行了复习,并且了解了它们是 Web 开发的一个组成部分。我们分析了在大型和复杂实现中使用它们时的好处和局限性。
就在这之后,我们介绍了 Promise 的概念。我们学习了 jQuery 的 Deferred 和 Promise API 的工作原理,以及它们与 ES6 Promises 的区别。我们还看到了它们在 jQuery 内部的使用位置和方式,作为它们如何导致更可读的代码并简化这样复杂实现的一个例子。
在下一章中,我们将继续学习如何在我们的应用程序中设计、创建和使用 MockObjects 和 Mock Services。我们将分析一个适当的 Mock 对象应该具有的特征,并了解它们如何被用作代表性用例甚至是我们代码的测试用例。
第八章:模拟对象模式
本章中,我们将展示模拟对象模式,这是一种促进应用程序开发的模式,而不实际成为最终实现的一部分。我们将学习如何设计、创建和使用这种行业标准的设计模式,以便更快地协调和完成多部分 jQuery 应用程序的开发。我们将分析一个合适的模拟对象应该具有的特征,并了解它们如何被用作代表性用例,甚至是我们代码的测试用例。
我们将看到良好的应用程序架构如何使我们更容易使用模拟对象和服务,通过匹配应用程序的各个部分,并且意识到在开发过程中使用它们的好处。到本章结束时,我们将能够创建模拟对象和服务,以加速我们应用程序的实现,并且在所有部分完成之前就对其整体功能有所了解。
在本章中,我们将:
介绍模拟对象和模拟服务模式
分析模拟对象和服务应该具有的特征
了解为什么它们与具有良好架构的应用程序更匹配
学习如何在 jQuery 应用程序中使用它们作为推动开发并加速开发的一种方式
介绍模拟对象模式
模拟对象模式的关键概念在于创建和使用一个模拟行为更复杂的对象的虚拟对象,该对象是(或将成为)实现的一部分。模拟对象应该具有与实际(或真实)对象相同的 API,使用相同的数据结构返回类似的结果,并且在其方法如何改变其公开状态(属性)方面操作方式相似。
模拟对象通常在应用程序的早期开发阶段创建。它们的主要用途是使我们能够继续开发一个模块,即使它依赖于尚未实现的其他模块。模拟对象也可以被描述为实现之间交换的数据的原型,起着开发人员之间的契约作用,并且促进了相互依赖模块的并行开发。
提示
就像模块模式的原则解耦了应用程序不同部分的实现一样,创建和使用模拟对象和模拟服务也解耦了它们的开发。
在开始实施每个模块之前为其创建模拟对象清晰地定义了应用程序将使用的数据结构和 API,消除了任何误解,并使我们能够检测到所提供的 API 中的不足。
提示
在开始实际实现之前定义描述问题所需的数据结构,使我们能够专注于应用程序的需求,并了解其整体复杂性和结构。
通过使用为原始实现创建的 Mock 对象,您可以在任何代码更改后始终测试实现的任何部分。通过在修改后的方法上使用 Mock 对象,您可以确保原始用例仍然有效。当修改后的实现是涉及多阶段的用例的一部分时,这非常有用。
如果模块的实现发生变化并导致应用程序其他部分表现异常,Mock 对象尤其有用,可以用于追踪错误。通过使用现有的 Mock 对象,我们可以轻松识别与原始规范不符的模块。此外,相同的 Mock 对象可用作高质量测试用例的基础,因为它们通常包含更真实的样本数据,特别适用于团队遵循测试驱动开发(TDD)范例。
注意
在测试驱动开发(TDD)中,开发人员首先为需要添加的用例或新功能定义测试用例,然后通过尝试满足所创建的测试用例来实施。更多信息,请访问:www.packtpub.com/books/content/overview-tdd。
Mock 对象模式通常被前端网络开发人员用于将客户端开发与后端将公开的网络服务解耦。因此,导致了一些风趣的评论,比如:
“网络服务总是拖延并突然改变,所以使用 Mock 代替。”
总结所有这些,创建 Mock 对象和服务的主要原因包括:
实际对象或服务尚未实现。
实际对象难以为特定用例设置。
我们需要模拟罕见或非确定性的行为。
实际对象的行为难以复现,比如网络错误或 UI 事件。
在 jQuery 应用程序中使用 Mock 对象
为了展示 Mock 对象模式在开发多部分应用程序时的用法,我们将扩展仪表板示例,如我们在第四章 用模块模式进行分而治之中看到的,以显示来自网络开发会议的 YouTube 视频的缩略图。视频引用被分为四个预定义类别,并根据当前的类别选择显示相关按钮,如下所示:
需要引入到 HTML 和 CSS 中的更改是最小的。与第四章 用模块模式进行分而治之现有实现相比,上述实现唯一需要额外的 CSS 是与缩略图宽度相关的:
.box img {
width: 100%;
}
HTML 中的变化旨在组织每个类别的<button>元素。这个变化将使我们的实现更加直观,因为类别及其项不再在 HTML 中静态定义,而是动态创建,由可用数据驱动。
<!-- … -->
<section class="dashboardCategories">
<select id="categoriesSelector"></select>
<div class="dashboardCategoriesList"></div>
<div class="clear"></div>
</section>
<!-- … -->
在上面的 HTML 片段中,带有dashboardCategoriesList CSS 类的<div>元素将被用作不同视频类别的分组按钮的容器。在涵盖了 UI 元素后,让我们现在转向 JavaScript 实现的分析。
定义实际服务需求
在我们的仪表板中显示的视频引用可以从各种来源检索到。例如,您可以直接调用 YouTube 的客户端 API 或通过后端 Web 服务进行 AJAX 调用。在所有上述情况下,将此数据检索机制抽象为一个单独的模块被认为是一种良好的实践,遵循前几章的代码结构建议。
由于这个原因,我们需要向现有实现添加一个额外的模块。这将是一个服务,负责提供允许我们从每个类别中检索最相关视频并单独加载每个视频信息的方法。这将通过分别使用searchVideos()和getVideo()方法来实现。
正如我们已经提到的,每个实现的一个最重要的阶段,尤其是在并行开发的情况下,是对要使用的数据结构进行分析和定义。由于我们的仪表板将使用 YouTube API,我们需要创建一些遵循其数据结构规则的示例数据。在检查了 API 之后,我们得到了一组需要用于我们的仪表板的字段的子集,并且可以继续创建一个具有模拟数据的 JSON 对象来演示所使用的数据结构:
{
"items": [{
"id": { "videoId": "UdQbBq3APAQ" },
"snippet": {
"title": "jQuery UI Development Tutorial: jQuery UI Tooltip | packtpub.com",
"thumbnails": {
"default": { "url": "https://i.ytimg.com/vi/UdQbBq3APAQ/default.jpg" },
"medium": { "url": "https://i.ytimg.com/vi/UdQbBq3APAQ/mqdefault.jpg" },
"high": { "url": "https://i.ytimg.com/vi/UdQbBq3APAQ/hqdefault.jpg" }
}
}
}/*,...*/]
}
注意
有关 YouTube API 的更多信息,请访问:developers.google.com/youtube/v3/getting-started。
我们的服务提供两种核心方法,一种用于在指定类别中搜索视频,另一种用于检索特定视频的信息。用于搜索方法的示例对象结构用于检索一组相关项目,而用于检索单个视频信息的方法使用每个单独项目的数据结构。生成的视频信息检索实现位于名为videoService的单独模块中,该模块将在dashboard.videoService命名空间上可用,我们的 HTML 将包含类似以下的<script>引用:
<script type="text/javascript" src="img/dashboard.videoservice.js"></script>
实现模拟服务
改变服务实现的<script>引用与模拟服务之间的相互转换应该使我们得到一个可工作的应用程序,帮助我们在实际视频服务实现完成之前进展和测试其他实现。因此,模拟服务需要使用相同的dashboard.videoService命名空间,但其实现应该在一个名为dashboard.videoservicemock.js的不同命名的文件中,它简单地添加了“mock”后缀。
正如我们先前提到的,将所有的模拟数据放在一个单独的变量下是一个很好的做法。此外,如果有很多模拟对象,通常会将它们放在一个完全不同的文件中,带有嵌套的命名空间。在我们的案例中,包含模拟数据的文件名为dashboard.videoservicemock.mockdata.js,其命名空间为dashboard.videoService.mockData,同时公开了searches和videos属性,这些属性将被我们的模拟服务的两个核心方法使用。
即使模拟服务的实现应该简单,它们也有自己的复杂性,因为它们需要提供与目标实现相同的方法,接受相同的参数,并且看起来好像它们是以完全相同的方式操作的。例如,在我们的案例中,视频检索服务需要是异步的,其实现需要返回 Promises:
(function() { // dashboard.videoservicemock.js
'use strict';
dashboard.videoService = dashboard.videoService || {};
dashboard.videoService.searchVideos = function(searchKeywords) {
return $.Deferred(function(deferred) {
var searches = dashboard.videoService.mockData.searches;
for (var i = 0; i < searches.length; i++) {
if (searches[i].keywords === searchKeywords) {
// return the first matching search results
deferred.resolve(searches[i].data);
return;
}
}
deferred.reject('Not found!');
}).promise();
};
dashboard.videoService.getVideo = function(videoTitle) {
return $.Deferred(function(deferred) {
var videos = dashboard.videoService.mockData.allVideos;
for (var i = 0; i < videos.length; i++) {
if (videos[i].snippet.title === videoTitle) {
// return the first matching item
deferred.resolve(videos[i]);
return;
}
}
deferred.reject('Not found!');
}).promise();
};
var videoBaseUrl = 'https://www.youtube.com/watch?v=';
dashboard.videoService.getVideoUrl = function(videoId) {
return videoBaseUrl + videoId;
};
})();
如上面模拟服务的实现所示,searchVideos()和getVideo()方法正在遍历带有模拟数据的数组,并返回一个 Promise,该 Promise 在找到合适的模拟对象时被解析,或者在未找到这样的对象时被拒绝。最后,你可以在下面看到包含模拟对象的子模块的代码,遵循了我们先前描述的数据结构。注意,我们将所有类别的模拟对象都存储在allVideos属性中,以便通过模拟的getVideo()方法更简单地进行搜索。
(function() { // dashboard.videoservicemock.mockdata.js
'use strict';
dashboard.videoService.mockData = dashboard.videoService.mockData || {};
dashboard.videoService.mockData.searches = [{
keywords: 'jQuery conference',
data: {
"items": [/*...*/]
}
}/*,...*/];
var allVideos = [];
var searches = dashboard.videoService.mockData.searches;
for (var i = 0; i < searches.length; i++) {
allVideos = allVideos.concat(searches[i].data.items);
}
dashboard.videoService.mockData.allVideos = allVideos;
})();
通过对一些模拟服务实现的实验,你将在很短的时间内熟悉它们的常见实现模式。除此之外,你将能够轻松地创建模拟对象和服务,帮助你设计应用程序的 API,通过使用模拟测试它们,最终确定每个用例的最佳匹配方法和数据结构。
提示
使用 jQuery Mockjax 库
jQuery Mockjax 插件库(可在github.com/jakerella/jquery-mockjax)专注于提供一种简单的方法来模拟或模拟 AJAX 请求和响应。如果你所需要的只是拦截对 Web 服务的 AJAX 请求并返回模拟对象,那么这将减少你完全实现自己的模拟服务所需的代码量。
使用模拟服务
为了向现有的仪表板实现添加我们之前描述的功能,我们需要对categories和informationBox模块进行一些更改,添加将使用我们服务的方法的代码。作为使用新创建的 Mock 服务的典型示例,让我们看一下informationBox模块中openNew()方法的实现:
dashboard.informationBox.openNew = function(itemName) {
var $box = $('<div class="boxsizer"><article class="box">' +
'<header class="boxHeader">' +
'<button class="boxCloseButton">✖</button>' +
itemName +
'</header>' +
'<div class="boxContent">Loading...</div>' +
'</article></div>');
$boxContainer.append($box);
dashboard.videoService.getVideo(itemName).then(function(result) {
var $a = $('<a>').attr('href', dashboard.videoService.getVideoUrl(result.id.videoId));
$a.append($('<img />').attr('src', result.snippet.thumbnails.medium.url));
$box.find('.boxContent').empty().append($a);
}).fail(function() {
$buttonContainer.html('An error occurred!');
});
};
此方法首先以加载中...标签作为其内容打开一个新的信息框,并使用dashboard.videoService.getVideo()方法异步检索请求的视频的详细信息。最后,当返回的 Promise 得到解析时,将加载中...标签替换为包含视频缩略图的锚。
摘要
在这一章中,我们学习了如何设计、创建和使用我们应用程序中的 Mock 对象和 Mock 服务。我们分析了 Mock 对象应具有的特征,并理解了它们如何作为典型用例来使用。我们现在能够使用 Mock 对象和服务来加速我们应用程序的实现,并在其所有单个部分完成之前更好地了解其整体功能。
在下一章中,我们将介绍客户端模板化,并学习如何从可读模板在浏览器中高效生成复杂的 HTML 结构。我们将介绍Underscore.js和Handlebars.js,分析它们的约定,评估它们的特性,并找出哪一个更适合我们的口味。
第九章:客户端模板
本章将演示一些最常用的库,以更快速地创建复杂的 HTML 模板,同时使我们的实现在与传统字符串拼接技术相比更容易阅读和理解。我们将更详细地了解如何使用Underscore.js和Handlebars.js模板库,体验它们的约定,评估它们的特性,并找到最适合我们口味的。
本章结束时,我们将能够通过可读的模板在浏览器中有效地生成复杂的 HTML 结构,并利用每个模板库的独特特性。
在本章中,我们将:
讨论使用专门的模板库的好处
介绍当前客户端模板中的潮流,特别是使用 <% %> 和 {{ }} 作为占位符的家族中的顶级代表
以Underscore.js为例,介绍一族使用<% %>占位符的模板引擎
以Handlebars.js为例,介绍一族使用大括号 {{ }} 占位符的模板引擎
介绍 Underscore.js
Underscore.js是一个 JavaScript 库,提供了一系列实用方法,帮助 Web 开发人员更有效地工作,专注于应用程序的实际实现,而不必为重复的算法问题烦恼。 Underscore.js默认情况下通过全局命名空间的“_”标识符访问,这也正是它的名称的由来。
注意
与 jQuery 中的 $ 标识符一样,underscore "_" 标识符也可以在 JavaScript 中作为变量名使用。
其中提供的实用程序函数之一是_.template()方法,它为我们提供了一种便利的方式,将特定值插入到遵循特定格式的现有模板字符串中。 _.template()方法在模板内部识别三种特殊的占位符符号,用于添加动态特性:
<%= %>符号用作在模板中插入变量或表达式值的最简单方式。
<%- %>符号对变量或表达式进行 HTML 转义,然后将其插入模板中。
<% %>标记用于执行任何有效的 JavaScript 语句作为模板生成的一部分。
_.template()方法接受遵循这些特征的模板字符串,并返回一个纯 JavaScript 函数,通常称为模板函数,可以使用包含将在模板中插入的值的对象调用。模板函数的调用结果是一个字符串值,这是提供的值在模板内插值的结果:
var templateFn = _.template('<h1><%= title %></h1>');
var resultHtml = templateFn({
title: 'Underscore.js example'
});
例如,上面的代码返回<h1>Underscore.js 示例</h1>,等效于以下简写调用:
var resultHtml = _.template('<h1><%= title %></h1>')({
title: 'Underscore.js example'
});
注意
关于_.template方法的更多信息,您可以在此处阅读文档:underscorejs.org/#template。
使Underscore.js模板非常灵活的是<% %>符号,它允许我们执行任何方法调用,并且例如被用作在模板中创建循环的推荐方法。另一方面,过度使用此功能可能会向您的模板添加过多的逻辑,这是许多其他框架中的已知反模式,违反了关注点分离原则。
在我们的应用程序中使用 Underscore.js 模板
作为使用Underscore.js进行模板化的示例,我们现在将其用于重构仪表板示例中一些模块中发生的 HTML 代码生成,正如我们在之前的章节中所看到的。对现有实现所需的修改仅限于categories和informationBox模块,它们通过添加新元素来操作页面的 DOM 树。
此类重构可以应用的第一个地方是categories模块的init()方法。我们可以修改创建<select>类别的可用<option>的代码如下:
var optionTemplate = _.template('<option value="<%= value %>"><%- title %></option>');
var optionsHtmlArray = [];
for (var i = 0; i < dashboard.categories.data.length; i++) {
var categoryInfo = dashboard.categories.data[i];
optionsHtmlArray.push(optionTemplate({
value: i,
title: categoryInfo.title
}));
}
$categoriesSelector.append(optionsHtmlArray.join(''));
如您所见,我们遍历仪表板的类别,以创建并附加适当的<option>元素到<select>类别元素。在我们的模板中,我们使用<%= %>符号来表示<option>的value属性,因为我们知道它将保存一个不需要转义的整数值。另一方面,我们使用<%- %>符号来表示每个<option>的内容部分,以便为每个类别的标题进行转义,以防其值不是 HTML 安全字符串。
我们在for循环之外使用_.template()方法来创建一个单个编译的模板函数,在for循环的每次迭代中重复使用。这样一来,浏览器不仅仅执行一次_.template()方法,而且还会优化生成的模板函数,并使其在for循环中的每次后续执行速度更快。最后,我们使用join('')方法来将optionsHtmlArray变量的所有 HTML 字符串组合在一起,并通过单个操作将结果append()到 DOM 中。
实现相同结果的另一种可能更简单的方法是结合<% %>符号和Underscore.js提供的_.each()方法,使我们能够在模板本身中实现循环。这样,模板将负责对提供的类别数组进行迭代,将复杂性从模块的实现转移到模板中。
var templateSource = ''.concat(
'<% _.each(categoryInfos, function(categoryInfo, i) { %>',
'<option value="<%= i %>"><%- categoryInfo.title %></option>',
'<% }); %>');
var optionsHtml = _.template(templateSource)({
categoryInfos: dashboard.categories.data
});
$categoriesSelector.append(optionsHtml);
如上面的代码所示,我们的 JavaScript 实现不再包含for循环,减少了其复杂性和所需的嵌套。只有一次对_.template()方法的调用,很好地将实现抽象为一个生成 HTML 并为所有类别渲染<option>元素的操作。您还可以看到这种技术与 jQuery 自身遵循的组合逻辑非常契合,其中方法旨在处理元素集合而不是单个项目。
将 HTML 模板与 JavaScript 代码分开
即使引入了上述所有改进,很快就会变得显而易见,在应用逻辑之间编写模板可能不是最佳的方法。一旦您的应用变得足够复杂,或者当您需要使用超过几行的模板时,实现起来会因为应用逻辑和 HTML 模板的混合而感到分散。
解决这个问题的更清晰的方法是将模板存储在页面其他部分的 HTML 代码旁边。这是朝着更好的关注点分离迈出的一大步,因为它适当地将呈现与应用逻辑隔离开来。
为了将 HTML 模板包含在不活动形式的网页中,我们需要使用一个宿主标签,这可以阻止它们被渲染,但也允许我们在需要时以程序方式检索其内容。为此,我们可以在页面的<head>或<body>内使用<script>标签,并指定除我们通常用于 JavaScript 代码的常见的text/javascript之外的任何type。这背后的操作原则是,浏览器在未识别其type属性的情况下不尝试解析、执行或呈现<script>标签的内容。经过一些实验,Underscore.js用户社区基本上采用了这种做法,并同意将text/template指定为这些<script>标签的首选类型,试图使这些实现在开发人员中更加统一。
提示
尽管Underscore.js既不是一个偏执的库,也不含有任何特定于模板变得可用的实现,但使用text/template <script>标签和/或 Ajax 请求都是有价值的技术,被广泛使用且被认为是最佳实践。
作为将复杂模板移入<script>标签中的受益示例,我们将重新构建informationBox模块的openNew()方法。如下所示,在下面的代码中,生成的<script>标签格式清晰,并且我们不再需要对多行模板的定义进行字符串拼接:
<script id="box-template" type="text/template">
<div class="boxsizer">
<article class="box">
<header class="boxHeader">
<button class="boxCloseButton">✖</button>
<%- itemName %>
</header>
<div class="boxContent">Loading...</div>
</article>
</div>
</script>
将 HTML 模板移出我们的代码时的一个好的做法是编写一个抽象的机制来负责检索它们并提供编译后的模板函数。这种方法不仅将实现的其余部分与模板检索机制解耦,而且使其更少重复,并创建了一个专门设计为为应用程序的其余部分提供模板的集中方法。此外,正如我们下面可以看到的,这种方法还允许我们优化模板的检索方式,将好处传播到所有使用它们的地方。
var templateCache = {};
function getEmbeddedTemplate(templateName) {
var compiledTemplate = templateCache[templateName];
if (!compiledTemplate) {
var template = $('#' + templateName).html();
compiledTemplate = _.template(template);
templateCache[templateName] = compiledTemplate;
}
return compiledTemplate;
}
dashboard.informationBox.openNew = function(itemName) {
var boxCompiledTemplate = getEmbeddedTemplate('box-template');
var boxHtml = boxCompiledTemplate({
itemName: itemName
});
var $box = $(boxHtml).appendTo($boxContainer);
/* ... */
};
如上所示的实现中,informationBox 模块的 openNew() 方法只是通过传递与请求模板相关联的唯一标识符来调用 getEmbeddedTemplate() 函数,并使用返回的模板函数生成新框的 HTML,最后将其附加到页面上。实现中最有趣的部分是 getEmbeddedTemplate() 方法,它使用 templateCache 变量作为字典来保存所有先前编译的模板函数。
第一步始终是检查请求的模板标识符是否存在于我们的模板缓存中。如果不存在,则搜索页面的 DOM 树以查找带有相关 ID 的 <script> 标签,并使用其 HTML 内容创建模板函数,然后将其存储在缓存中并返回给调用方。
请记住,在 HTML 模板的所有标识符中使用特定的前缀或后缀是一个好的做法,以避免与其他页面元素的 ID 冲突。为此,在上面的示例中,我们使用了 -template 作为我们框模板标识符的后缀。
理想情况下,模板提供程序方法的实现应该在一个单独的模块中,该模块将被应用程序的所有部分使用,但是,由于在我们的仪表板中只使用了一次,我们通过简单地使用一个函数来满足我们演示的需求。
引入 Handlebars.js
Handlebars.js,或简称 Handlebars,是一种专门的客户端模板库,使 Web 开发人员能够有效地创建语义化模板。使用 Handlebars 进行模板化会导致创建无逻辑的模板,这确保了视图和代码的隔离,有助于保持关注点分离原则。它与 Mustache 模板基本兼容,Mustache 是一个模板语言规范,随着时间的推移已经证明了其有效性,并且有许多主要编程语言的实现。此外,Handlebars 还提供了一组在 Mustache 模板规范之上的扩展,例如辅助方法和局部模板,作为扩展模板引擎并创建更有效模板的一种手段。
注意
你可以在Handlebars 文档中查看所有 Handlebars 的文档。你可以在JavaScript Mustache中获取更多有关 Mustache 的信息。
Handlebars 提供的主要模板表示法是双花括号语法 {{ }}。由于 Handlebars 最初是为 HTML 模板设计的,所以默认情况下也适用于 HTML 转义,降低了未转义值可能到达模板并导致潜在安全问题的几率。如果需要特定部分的模板进行非转义的插值,我们可以使用三个花括号的表示法 {{{ }}}。
此外,由于 Handlebars 阻止我们直接从模板中调用方法,它为我们提供了定义和使用辅助方法和块表达式的能力,以涵盖更复杂的用例,同时帮助我们尽可能地保持模板的清晰和可读性。内置助手集包括 {{#if }} 和 {{#each }} 助手,它们允许我们非常轻松地对数组执行迭代,并根据条件更改模板的结果。
Handlebars 库的中心方法是 Handlebars.compile() 方法,它接受模板字符串作为参数,并返回一个函数,该函数可用于生成符合所提供模板形式的字符串值。然后,可以使用一个对象作为参数调用此函数(与 Underscore.js 中一样),其中的属性将用作对原始模板中定义的所有 Handlebars 表达式(花括号表示法)进行评估的上下文:
var templateFn = Handlebars.compile('<h1>!!!{{ title }}!!!</h1>');
var resultHtml = templateFn({
title: '> Handlebars example <'
});
作为示例,上述代码返回 "<h1>!!!> Handlebars example <!!!</h1>",将插入的标题转换为安全的 HTML 字符串,但是当附加到页面的 DOM 树时,它将以正确的方式呈现。当然,如果我们不需要将编译后的模板函数的引用保留以供将来使用,则可以使用以下简写调用来实现相同的结果:
var resultHtml = Handlebars.compile('<h1>!!!{{ title }}!!!</h1>')({
title: '> Handlebars example <'
});
在我们的应用程序中使用 Handlebars.js
作为使用 Handlebars.js 进行模板化的示例,并且为了展示它与 Underscore.js 模板的区别,我们现在将使用它来重构我们的仪表板示例,就像我们在前一节中所做的那样。与之前一样,重构仅限于 categories 和 informationBox 模块,这些模块通过添加新元素来操作页面的 DOM 树。
categories 模块的 init() 方法的重构实现应该如下所示:
var optionTemplate = Handlebars.compile('<option value= "{{ value }}">{{ title }}</option>');
var optionsHtmlArray = [];
for (var i = 0; i < dashboard.categories.data.length; i++) {
var categoryInfo = dashboard.categories.data[i];
optionsHtmlArray .push(optionTemplate({
value: i,
title: categoryInfo.title
}));
}
$categoriesSelector.append(optionsHtmlArray.join(''));
首先,我们使用了Handlebars.compile()方法,该方法基于提供的模板字符串生成并返回模板函数。与我们在上一节中看到的Underscore.js的实现的主要区别在于,我们现在使用双花括号符号{{ }}来插值我们的模板中的值。除了外观上的差异外,Handlebars.js还默认执行 HTML 字符串转义,以尝试通过将转义作为其主要用例之一来消除 HTML 注入安全漏洞。
正如我们在本章前面所做的那样,我们将在for循环之外创建模板函数,并将其用于为每个<option>元素生成 HTML。所有生成的 HTML 字符串都被收集到一个数组中,最终通过一次操作使用$.append()方法将它们组合并附加到 DOM 树上。
减少我们实现复杂性的下一个渐进步骤是使用模板引擎本身的循环能力将迭代抽象化为我们的 JavaScript 代码之外:
var templateSource = ''.concat(
'{{#each categoryInfos}}',
'<option value="{{@index}}">{{ title }}</option>',
'{{/each}}');
var optionsHtml = Handlebars.compile(templateSource)({
categoryInfos: dashboard.categories.data
});
$categoriesSelector.append(optionsHtml);
Handlebars.js库允许我们通过使用特殊的{{#each }}符号来实现这一点。在{{#each }}和{{/each}}之间,模板的上下文被更改以匹配迭代的每个单独对象,允许直接访问和插值categoryInfos数组中每个对象的{{ title }}。此外,为了访问循环计数器,Handlebars 提供了特殊的@index变量作为循环的上下文的一部分。
注意
您可以阅读handlebarsjs.com/reference.html上的文档,获取 Handlebars 提供的所有特殊符号的完整列表。
将 HTML 模板与 JavaScript 代码分离
和大多数模板引擎一样,Handlebars 也让我们将模板与应用程序的 JavaScript 实现隔离开,并通过将它们包含在页面 HTML 中的<script>标签中,在浏览器中传递它们。此外,Handlebars 有一定的偏好,更喜欢特殊的text/x-handlebars-template作为所有包含 Handlebars 模板的<script>标签的 type 属性。例如,这是根据库推荐的方式定义仪表板框的模板的方式:
<script id="box-template" type="text/x-handlebars-template">
<div class="boxsizer">
<article class="box">
<header class="boxHeader">
<button class="boxCloseButton">✖</button>
{{ itemName }}
</header>
<div class="boxContent">Loading...</div>
</article>
</div>
</script>
提示
尽管如果为<script>标签指定了不同的type,我们的实现仍然可以正常工作,但遵循库的指南显然可以使开发人员之间的实现更加统一。
正如我们在本章前面所做的那样,我们将遵循最佳实践,创建一个单独的函数负责在应用程序中需要的任何地方提供模板:
var templateCache = {};
function getEmbeddedTemplate(templateName) {
var compiledTemplate = templateCache[templateName];
if (!compiledTemplate) {
var template = $('#' + templateName).html();
compiledTemplate = Handlebars.compile(template);
templateCache[templateName] = compiledTemplate;
}
return compiledTemplate;
}
dashboard.informationBox.openNew = function(itemName) {
var boxCompiledTemplate = getEmbeddedTemplate('box-template');
var boxHtml = boxCompiledTemplate({
itemName: itemName
});
var $box = $(boxHtml).appendTo($boxContainer);
/* ... */
};
正如你所看到的,该实现与我们在本章前面看到的Undescore.js示例基本相同。唯一的区别是我们现在使用Handlebars.compile()方法来从检索到的模板生成已编译模板函数。
预编译模板
Handlebars 库的一个额外功能是支持模板预编译。这使我们可以使用一个简单的终端命令预先生成所有模板函数,然后让我们的服务器将它们传送到浏览器,而不是实际的模板。这样,浏览器就可以直接使用预编译的模板,而不需要对每个单独的模板进行编译,使得库和应用程序的执行速度更快。
为了预编译我们的模板,我们首先需要将它们放在单独的文件中。 Handlebars 文档建议我们的文件使用.handlebars扩展名,但如果更喜欢,我们仍然可以使用.html扩展名。在我们的开发机器上安装编译工具(使用npm install handlebars -g),我们可以在终端中发出以下命令来编译模板:
handlebars box-template.handlebars -f box-template.js
这将生成实际上是一个将模板添加到Handlebars.templates的迷你模块定义的box-template.js文件。生成的文件可以像常规 JavaScript 文件一样合并和最小化,并且当被浏览器加载时,模板函数将通过Handlebars.templates['box-template']属性可用。
注意
请记住,如果模板使用.html扩展名,则预编译的模板函数将通过Handlebars.templates['box-template.html']属性可用。
正如您所见,使用模板提供者函数有助于将现有应用程序迁移到预编译模板,因为它允许我们封装模板的检索方式。只需将getEmbeddedTemplate()更改为以下内容即可将其迁移到预编译模板:
function getEmbeddedTemplate(templateName) {
return Handlebars.templates[templateName];
}
注意
有关 Handlebars 中模板预编译的更多信息,请阅读:handlebarsjs.com/precompilation.html。
异步检索 HTML 模板
掌握客户端模板的最后一步是一种开发实践,该实践允许我们动态加载模板并在已加载的网页中使用它们。这种方法可以导致比在每个页面的 HTML 源文件中将所有可用模板嵌入为<script>标签的方法更具可伸缩性的实现。
这种技术的关键要素是仅在需要呈现网页时加载每个模板,通常是在用户操作之后。这种方法的主要优点是:
初始页面加载时间减少,因为页面的 HTML 更小。如果我们的应用程序有很多只在特定情况下使用的模板,例如在特定用户交互后,页面尺寸减小的收益将变得更大。
用户只在实际使用模板时才会下载模板。通过这种方式,可以减少每个页面加载的总下载资源的大小。
对于已经加载的模板的后续请求不会导致额外的下载,因为浏览器的 HTTP 缓存机制将返回缓存的资源。此外,由于浏览器缓存用于所有 HTTP 请求,无论它们来自哪个页面,用户在使用我们的 Web 应用程序时只需下载所需的模板一次。
由于其对用户体验和可伸缩性的好处,这种技术被最流行的电子邮件和社交网络网站广泛使用,根据用户的操作动态加载各种 HTML 模板和 JavaScript 模块。
注意
关于如何使用 jQuery 在页面上动态加载 JavaScript 模块的更多信息,请阅读$.getScript()方法的文档:api.jquery.com/jQuery.getScript/。
采用它在一个已有的实现中
为了说明这个技术,我们将更改informationBox模块的Underscore.js和Handlebars.js实现,以便使用 AJAX 请求获取我们仪表板的盒子模板。
让我们通过分析我们的Underscore.js实现所需的改变来继续:
var templateCache = {};
function getAjaxTemplate(templateName) {
var compiledTemplate = templateCache[templateName];
if (compiledTemplate) {
return $.Deferred().resolve(compiledTemplate);
}
return $.ajax({
mimeType: 'text/html',
url: templateName + '.html'
}).then(function(template) {
templateCache[templateName] = _.template(template);
return templateCache[templateName];
});
}
正如你在上面的代码中所看到的,我们已经实现了getAjaxTemplate()函数作为一种将负责获取模板的机制与使用它的实现解耦的方式。这个实现与我们之前使用的getEmbeddedTemplate()函数有很多相似之处,主要区别在于getAjaxTemplate()函数是异步的,因此返回一个Promise。
getAjaxTemplate()函数首先检查所请求的模板是否已经存在于其缓存中,这是为了进一步减少向服务器发出的 HTTP 请求。如果在缓存中找到模板,则它将作为已解决的 Promise 的一部分返回,否则我们将使用$.ajax()方法启动一个 AJAX 请求从服务器检索它。像以前一样,我们需要对模板 HTML 文件的命名和用于在服务器上存储它们的路径有一个约定。在我们的示例中,我们正在查找与网页本身相同的目录,并只附加.html文件扩展名。在某些情况下,根据所使用的 Web 服务器的不同,还需要额外考虑资源的mimeType定义为text/html。
当 AJAX 请求完成时,then() 方法将以模板内容作为字符串参数执行,用于生成编译后的模板函数。我们的实现最终将编译后的模板函数作为链式 Promise 的结果返回,直接将其添加到缓存中。由于 getAjaxTemplate() 函数是异步的,我们还必须更改 openNew() 方法的实现,并将所有使用返回的模板函数的代码移到 then() 回调内部。除此之外,实现保持不变,并且与之前完全相同地使用模板函数。
dashboard.informationBox.openNew = function(itemName) {
var templatePromise = getAjaxTemplate('box-template');
templatePromise.then(function(boxCompiledTemplate) {
var boxHtml = boxCompiledTemplate({
itemName: itemName
});
var $box = $(boxHtml).appendTo($boxContainer); box);
/* ... */
});
};
当重新实现 getAjaxTemplate() 函数以使用 Handlebars.js 时,结果代码基本与以前相同。唯一的区别在于调用 Handlebars.compile() 方法而不是 Underscore.js 的等价方法。这是一个额外的好处,因为许多客户端模板引擎彼此影响,并已经在它们的模板函数的使用方式方面收敛到非常相似的 API,主要是因为现有实现的积极用户反馈。
function getAjaxTemplate(templateName) {
/* …same as before... */
return $.ajax({ /* …same as before... */ }).then(function(template) {
templateCache[templateName] = Handlebars.compile(template);
return templateCache[templateName];
});
}
注意
请记住,当通过文件系统加载页面时,$.ajax() 方法可能在某些浏览器中无法工作,但在像 Apache、IIS 或 nginx 这样的 Web 服务器上加载时则能正常工作。
凡事适度
尽管这种技术减少了每个网页的总下载量,但也不可避免地增加了所发出的 HTTP 请求的数量。此外,懒加载每个模板的做法有时会增加用户等待的时间,特别是如果模板在页面的初始渲染中是必需的。
在懒加载和将模板嵌入 <script> 标签之间平衡加载模板的方式通常会带来最佳效果。这种混合方法被行业认为是最佳实践,因为它允许我们根据需要微观管理和微调每个实现。根据这种实践,用于页面主要内容呈现的模板被嵌入到其 HTML 中,而其余的模板则在需要时延迟提供,利用浏览器缓存。
此类模板提供程序函数的实现留给读者作为练习。作为提示,此类方法必须是异步的,因为当页面中未找到请求的模板嵌入在 <script> 标签中时,它将必须继续并发出 AJAX 请求从服务器检索它。
提示
请记住,通常更倾向于在服务器端生成页面的完整初始 HTML 内容,而不是使用客户端模板。这不仅会导致初始页面内容的加载时间更短,而且可以防止在 JavaScript 不可用或发生错误时向用户呈现空白页面的情况发生。
总结
在这一章中,我们学习了如何使用两个最常见的客户端模板库:Underscore.js 和 Handlebars.js。我们还学习了它们如何帮助我们更快地创建复杂的 HTML 模板,同时使我们的实现更易于阅读和理解。我们随后分析了它们的惯例,评估了它们的特性,并通过示例学习了它们如何可以有效且高效地在我们的实现中使用。
完成本章后,我们现在能够通过使用可读模板和利用模板库的独特特点,在浏览器中高效生成复杂的 HTML 结构。
在下一章中,我们将学习如何创建 jQuery 插件来将应用程序的部分抽象为可重用和可扩展的实现方式。我们将介绍开发 jQuery 插件最广泛使用的模式,并分析每种模式帮助解决的实现问题。
第十章:插件和小部件开发模式
本章重点介绍了实现 jQuery 插件时使用的设计模式和最佳实践。我们将在这里学习如何将应用程序的部分抽象为单独的 jQuery 插件,促进关注点分离原则和代码的可重用性。
首先分析 jQuery 插件可以实现的最简单方式,学习 jQuery 插件开发的各种约定以及每个插件应满足的基本特性,以遵循 jQuery 原则。然后,我们将介绍最常用的设计模式,并分析每种模式的特点和优势。到本章结束时,我们将能够使用最适合每种情况的开发模式实现可扩展的 jQuery 插件。
在本章中,我们将:
介绍 jQuery 插件 API 及其约定
分析构成优秀插件的特点
学习通过扩展$.fn对象来创建插件
学习如何实现可扩展的通用插件,以使它们在更多用例中可重用
学习如何为插件提供选项和方法
介绍 jQuery 插件开发的最常见设计模式,并分析它们各自有助于解决的常见实现问题
引入 jQuery 插件
jQuery 插件的关键概念在于通过将其功能作为 jQuery 复合集合对象上的方法来扩展 jQuery API。一个 jQuery 插件只是一个定义为$.fn对象上的新方法的函数,该对象是每个 jQuery 集合对象所继承的原型对象。
$.fn.simplePlugin101 = function(arg1, arg2/*, ...*/) {
// Plugin's implementation...
};
通过在$.fn对象上定义方法,我们实际上是扩展了核心 jQuery API 本身,因为这使得该方法在此后创建的所有 jQuery 集合对象上都可用。因此,在网页中加载了插件后,其功能将作为$()函数返回的每个对象的方法可用:
$('h1').simplePlugin101('test', 1);
jQuery 插件 API 的主要约定是,调用插件的 jQuery 集合对象作为其执行上下文可用于插件的方法中。换句话说,我们可以在插件方法中使用this标识符,如下所示:
$.fn.simplePlugin101 = function() {
this.slideToggle();
// "this" is a jQuery object where all
// jQuery methods are available
};
遵循 jQuery 原则
创建插件时的一个目标是使其感觉像 jQuery 本身的一部分。阅读前几章后,您应该熟悉一些所有 jQuery 方法遵循的原则以及使其方法独特的特点。实现遵循这些原则的插件使用户更加熟悉其 API,更具生产力,并且减少了实现错误,从而增加了插件的流行度和采用率。
一个优秀的 jQuery 插件应具备的两个最重要特征如下:
它应该在适用的情况下应用于被调用的 jQuery 集合对象的所有元素
它应该允许其他 jQuery 方法的进一步链接
现在让我们继续分析这些原则的每一个。
在复合集合对象上操作
jQuery 方法最重要的一个特点是,它们应用于被调用的复合集合对象的每个项。例如,$.fn.addClass()方法在分别检查每个类是否已经在每个单独的元素上定义之后,将一个或多个 CSS 类添加到集合的每个项上。
结果,我们的 jQuery 插件也应该遵循这个原则,即在逻辑上合理时操作集合的每个元素。如果您的插件实现中仅使用 jQuery 方法,大多数情况下,您可以免费获得这一点。另一方面,需要牢记的一点是,并非所有的 jQuery 方法都作用于集合对象的每个元素。像$.fn.html()、$.fn.css()和$.fn.data()这样的方法用作 setter 方法时会作用于集合的所有项,但用作 getter 时只作用于第一个元素。
让我们看一个使用$.fn.animate()在 jQuery 对象的所有项目上创建抖动效果的插件的示例实现:
$.fn.vibrate = function() {
this.each(function(i, element) {
// specifically handle every element
var $element = $(element);
if ($element.css('position') === 'static') {
$element.css({ position: 'relative' });
}
});
this.animate({ left: '+=3' }, 30)
.animate({ left: '-=6' }, 60)
.animate({ left: '+=6' }, 60)
.animate({ left: '-=3' }, 30);
return this; // allow further chaining
};
用$('button').vibrate();调用此插件将对页面中的每个匹配元素应用抖动动画。为了实现这一点,插件使用$.fn.animate()方法改变所有匹配元素的left CSS 属性,该方法方便地操作每个元素。另一方面,由于$.fn.css()方法作为 getter 使用时只应用于集合的第一个元素,我们必须使用$.fn.each()方法迭代所有元素,并确保每个元素都不是静态定位,否则left CSS 属性将不会影响其外观。
显然,仅仅使用 jQuery 方法并不总是足够实现插件。在大多数情况下,一个新插件将至少需要使用一个非 jQuery API 来实现,这要求我们迭代集合的项目,并逐个应用插件的逻辑。当集合的每个元素的状态有所不同时,也应该使用相同的方法进行处理。
因此,插件通常会在$.fn.each()的调用中包装几乎所有的实现。通过识别显式迭代所涵盖的常见需求,jQuery 团队和大多数 jQuery 插件样板现在将其作为标准做法的一部分。
允许进一步的链接
通常,当您的插件代码不需要返回任何内容时,为了启用进一步的链式操作,您只需在其最后一行添加一个return this;语句,就像我们在上一个示例中看到的那样。确保所有的代码路径都返回调用上下文(this)的引用或另一个相关的 jQuery 集合对象,就像$.fn.parent()和$.fn.find()一样。或者,当您的所有代码都包裹在另一个 jQuery 方法内部时,例如$.fn.each(),通常的做法是简单地返回该调用的结果,如下所示:
$.fn.myLogPlugin = function() {
return this.each(function(i, element) {
console.log($(element).text());
});
};
请记住,如果您的代码操作了它被调用的集合对象,而不是返回this引用,您可能需要返回插件操作的新集合对象。
注意
您应该避免将插件的实现基于返回值以允许进一步的链式操作。而不是这样做,最好是在其第一次调用时初始化插件,然后提供一些重载的方式来调用它,作为返回值的一种方式。
使用$.noConflict()操作
改进插件实现的第一步是使其在无法访问$标识符的环境中工作。其中一个示例是当网页使用jQuery.noConflict()方法时,它会阻止 jQuery 将自身分配给$全局标识符(或window.$),并且仅将其保留在jQuery命名空间(window.jQuery)上。
注意
jQuery.noConflict()方法允许我们防止 jQuery 与其他库和实现发生冲突,这些库和实现也可能使用$变量。有关更多信息,请访问 jQuery 文档页面:api.jquery.com/jQuery.noConflict/
在这种情况下,插件定义会抛出$ is not defined错误,甚至更糟的是;它可能会尝试使用开发人员保留用于实现的$变量,导致难以调试的错误。
幸运的是,修复这个问题所需的更改很容易实现,并且不会影响插件的功能。我们所要做的就是将插件中所有的$标识符的出现都重命名为jQuery,如下所示:
jQuery.fn.simplePlugin101 = function(arg1, arg2/*, ...*/) {
var $buttons = jQuery('button');
// ...
};
使用 IIFE 包装
遵循的下一个最佳实践是使用 IIFE 包装我们的插件的定义和实现。这不仅使我们的插件看起来像模块模式,而且通过为其增加几个其他好处,使我们的实现更加健壮。
首先,IIFE 模式允许我们在插件定义的上下文中创建和使用私有变量和函数。这些变量与插件的所有实例共享,类似于其他编程语言中静态变量的工作方式,使我们能够将它们用作插件实例之间的同步点:
(function($) {
var callCounter = 0;
function utilityLogMethod(message) {
if (window.console && console.log) {
console.log(message);
}
}
$.fn.simplePlugin101 = function(arg1, arg2/*, ...*/) {
callCounter++;
utilityLogMethod(callCounter);
return this;
};
})(jQuery);
否则,我们将不得不使用类似$.simplePlugin101._callCounter或$.simplePlugin101._utilityLogMethod()这样的东西来模拟隐私,这只是一种命名约定,并不提供任何实际的隐私。
如上例所示,第二个好处是,它允许我们再次使用$标识符来访问 jQuery,而不必担心冲突。为了实现这一点,我们将 jQuery 命名空间变量作为调用参数传递给我们的 IIFE,并使用$标识符来命名相应的参数。通过这种方式,我们有效地将 jQuery 命名空间别名为$,使我们可以在 IIFE 创建的上下文中使用最小的$标识符来使我们的代码简洁可读,即使使用了jQuery.noConflict()也是如此。
另外,在我们的 IIFE 顶部添加use strict;语句有助于消除变量泄漏到全局命名空间的问题。例如,以下代码在调用插件方法时会抛出ReferenceError: assignment to undeclared variable x错误,这使我们能够在插件开发阶段捕获这些错误,从而产生更健壮的最终实现:
(function($) {
'use strict';
$.fn.leakingPlugin = function() {
x = 0;// there is no "var x" declaration,
// so an error is thrown when executed
};
})(jQuery);
$('div').leakingPlugin();
注意
要了解更多关于 JavaScript 严格执行模式的信息,请访问:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode
最后,与所有使用 IIFE 的命名空间别名实践一样,此模式也可以帮助增加在缩小插件源代码时的收益,与直接引用 jQuery 命名空间变量的实现相比。为了最大化此技术的好处,还常常将插件访问的所有全局命名空间变量都别名化,如下所示:
(function ( $, window, document, undefined ) {
// Plugin's implementation...
})( jQuery, window, document );
创建可重复使用的插件
在分析了 jQuery 插件开发的最重要方面之后,我们现在准备分析一个用于更多功能的实现,而不仅仅是一个简单的演示。为了创建一个真正有用且可重复使用的插件,必须设计得这样,使其操作不受其原始用例的要求限制。
最受欢迎的插件,就像最有用的 jQuery 方法一样,是那些提供了高度配置其功能的插件。创建可配置的插件为其实现增加了一定的灵活性,使我们能够满足由相同操作原则控制的其他多个用例的需求。
正如我们之前所说,一个 jQuery 插件只是附加到$.fn对象的函数,因此我们可以将其实现更加抽象和通用,就像我们的模块的简单函数一样。与简单函数一样,区分 jQuery 插件的操作最简单的方法是使用调用参数。一个暴露了许多配置参数的插件有很大的潜力能够满足几种不同用例的要求。
接受配置参数
与我们通常接受高达五个参数但仍具有可管理和相对清晰 API 的函数的实现方式形成对比,这种做法在 jQuery 插件中效果不佳。为了暴露清晰的 API 并保持高可用性,无论暴露了哪些不同的配置选项,大多数 jQuery 插件都提供了一个最小的 API,接受高达三个调用参数。这是通过使用具有特定格式的专用设置对象来实现的,作为一种封装多个选项并将它们作为单个参数传递的方法。另一种方法是使用两个参数暴露 API,其中第一个是定义插件操作的常规值,第二个用于包装不太重要的配置选项。
这些做法的一个很好的例子是$.ajax(settings)方法,它通过单个设置对象作为参数调用以定义其操作方式,但还暴露了另一个重载的方式,以两个参数调用。两个参数重载通过$.ajax(url, settings)调用,其中第一个是 HTTP 请求的目标 URL,第二个是具有其余配置选项的对象。对它们都适用的是,方法本身包含一组明智的默认值,用于替代用户未定义的任何配置参数。此外,第二种重载还将第二个参数定义为可选参数,如果在其调用过程中未提供,则其操作将基于默认设置。
在我们的插件中采用设置对象实践不仅带来所有上述的好处,还允许我们以更具可扩展性的方式扩展其实现,因为添加额外的配置参数对其 API 的其余部分几乎没有影响。作为这一点的例子,我们将在更通用的方式中重新实现我们在本章中早些时候看到的$.fn.vibrate插件,以便使用具有默认值的设置对象来进行配置:
(function($) {
$.fn.vibrate = function(options) {
var opts = $.extend({}, $.fn.vibrate.defaultOptions, options);
this.each(function(i, element) {
var $element = $(element);
if ($element.css('position') === 'static') {
$element.css({ position: 'relative' });
}
});
for (var i = 0, len = opts.loops * 4; i < len; i++) {
var animationProperties = {};
var movement = (i % 2) ? '+=': '-=';
movement += (i === 0 || i === len - 1) ?
opts.amplitude / 2:
opts.amplitude;
var t = (i === 0 || i === len - 1) ?
opts.period / 4:
opts.period / 2;
animationProperties[opts.direction] = movement;
this.animate(animationProperties, t);
}
return this;
};
$.fn.vibrate.defaultOptions = {
loops: 2,
amplitude: 8,
period: 100,
direction: 'left'
};
})(jQuery);
与原始固定实现相比,这个实现接受一个作为调用参数的单个对象,其中包装了四个可以用于使插件操作多样化的不同选项。通过暴露四个定制点,选项对象允许我们通过暴露四个定制点来使插件的操作多样化:
抖动效果应该运行的循环数
动画的振幅,作为控制元素应该离其原始位置移动多少的手段
每个循环的周期,作为控制运动速度的手段
动画的方向,当使用left时是水平的,或者当使用top时是垂直的
通过遵循广泛接受的最佳实践,我们将所有配置选项的默认值定义为一个单独的对象。这种模式不仅允许我们将所有相关值收集到单个对象下,而且还使我们能够使用$.extend()方法有效地将所有已定义选项与未定义选项的默认值组合在一起。因此,我们可以避免明确检查每个单独属性的存在,从而减少了代码的复杂性和大小。
简而言之,$.extend()方法在将后续对象的属性合并到第一个对象中后返回第一个参数传递的对象。因此,返回的对象将包含除了在调用参数中定义的选项对象中定义的默认值之外的所有默认值。
注意
有关$.extend()助手方法的更多信息,您可以访问文档页面:api.jquery.com/jQuery.extend/
此外,我们没有使用简单的变量,而是将默认选项对象公开为插件函数的属性,使用户可以根据自己的需要进行更改。例如,考虑需要特定应用程序的平滑动画的情况。通过设置$.fn.vibrate.defaultOptions.period = 250,开发人员将完全消除在每次调用插件时指定period选项的需要,这将导致具有更少重复代码的实现。
注意
jQuery 库本身采用了此实践来定义$.ajax()方法的默认配置参数。由于此方法的复杂性增加,jQuery 为我们提供了jQuery.ajaxSetup()方法,作为设置每个 AJAX 请求的默认参数的一种方式。
最后,为了创建原始实现的通用变体并利用上述配置选项,我们用使用了for循环来替换了原始实现的$.fn.animate()方法的四个固定调用。在for循环内部,我们构造每次调用$.fn.animate()方法的参数,并在每次循环的后续执行中简要地交替动画移动的方向,并确保第一个和最后一个动作的时间持续时间和所有其他步骤的位移的一半。
最终的实现可以配置为产生不同的动画,根据每个特定用例的需求而变化,从适用于通知用户无效操作的短水平动画,到看起来像漂浮效果的垂直长动画。插件可以以任何组合的前述选项调用,对于缺失选项使用默认值,甚至在没有调用参数的情况下运行,如下所示:
// do the default intense animation on a button
// that appears disabled, to designate an invalid action
$('button.disabled').on('click', function() {
$(this).vibrate();
});
// do a smother shake animation to catch the user's
// attention on an important part of the page
$('.save-button').vibrate({loops: 3, period: 250});
// start a long running levitation effect on the header of the page
$('h1').vibrate({direction: 'top', loops: 1000, period: 5000});
编写具有状态的 jQuery 插件
到目前为止,我们看过的插件实现是无状态的,因为在完成执行后,它们会恢复对 DOM 状态的操作,并且不会在浏览器内存中保留分配的对象。因此,对无状态插件的后续调用始终产生相同的结果。
你可能已经猜到,这种插件的应用范围有限,因为它们无法用于创建与网页用户的一系列复杂交互。为了协调复杂的用户交互,插件需要保持内部状态,以记录到目前为止采取的操作,并适当地改变其操作模式并处理后续交互。比较具有状态和无状态插件的特性可以定义为将普通(静态)函数与是对象的一部分并可以对其状态进行操作的方法进行比较。
另一个流行的插件类别是必须具有内部状态的类别,这是操纵 DOM 树的插件系列。这些插件通常创建复杂的元素结构,如富文本编辑器、日期选择器和日历,通常是通过在用户定义的空白 <div> 元素上构建。
实现一个具有状态的 jQuery 插件
作为实现这类插件的模式的示例,我们将编写一个通用的 元素变异观察器 插件。该插件将为我们提供一种方便的方法,用于添加对来自该插件所调用的任何元素的 DOM 树更改的事件侦听器。为了实现这一点,以下实现使用了 MutationObserver API,在撰写本文时,该 API 已由所有现代浏览器实现,并且可供超过 86% 的网络用户使用。
注意
有关 Mutation Observer 的更多信息,请访问:developer.mozilla.org/en-US/docs/Web/API/MutationObserver
现在让我们继续实施并分析所使用的做法:
(function($) {
$.fn.mutationObserver = function(action) {
return this.each(function(i, element) {
var $element = $(element);
var instance = $element.data('plugin_mutationObserver');
if (!instance) {
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
instance.callbacks.forEach(function(callbackFn) {
callbackFn(mutation);
});
});
});
observer.observe(element, {
attributes: true,
childList: true,
characterData: true
});
instance = {
observer: observer,
callbacks: []
};
$element.data('plugin_mutationObserver', instance);
}
if (typeof action === 'function') {
instance.callbacks.push(action);
}
});
};
})(jQuery);
首先,我们在 IIFE 内部定义我们的插件,正如本章前面建议的那样。在插件在 $.fn 对象上的声明之后,我们使用 $.fn.each() 方法作为直接方法,以确保我们的插件的功能应用于调用它的 jQuery Collection Object 的每个项目。
有状态插件实现的两个主要问题之一是缺乏保留每个插件实例内部状态的机制,以及避免在同一页面元素上多次初始化的方法。为了解决这两个问题,我们需要使用类似哈希表的东西,其中键是元素本身,值是插件实例状态的对象。
幸运的是,这或多或少是$.fn.data()方法的工作原理,通过使用特定的字符串键将 DOM 元素和 JavaScript 对象值关联起来。通过使用$.fn.data()方法和插件的名称作为关联键,我们能够非常容易地存储和检索我们插件的状态对象。
提示
对于这种用例,使用$.fn.data()方法被认为是一种最佳实践,并且被大多数有状态插件实现和样板文件使用,因为它是 jQuery 的一个强大的部分,可以使我们减少插件实现的大小。
如果找不到现有的状态对象,则可以假定插件尚未在该特定元素上初始化,并立即开始初始化。该插件的状态对象将包含负责跟踪观察的 DOM 元素上发生的更改的活动 MutationObserver 实例,并且一个订阅它以获得关于更改通知的所有回调的数组。
创建新的 MutationObserver 实例后,我们将其配置为查找三种特定类型的 DOM 更改,并指示它在发生此类 DOM 更改时调用插件状态对象的所有回调。最后,我们创建状态对象本身来保存观察者和关联的回调,并使用$.fn.data()方法作为设置器,并将其与页面元素关联。
在确保插件在提供的元素上被实例化和初始化之后,我们检查插件是否以函数作为参数调用,如果是,则将其添加到插件的回调列表中。
提示
请记住,对于每个元素使用单个 MutationObserver 实例,并通过迭代回调数组通知 DOM 更改,可以大大减少实现的内存需求,就像我们使用单个委托观察器时一样。
使用我们新实现的插件来观察特定 DOM 元素的更改的示例如下:
$('.container').mutationObserver(function(mutation) {
console.log('Something changed on the DOM tree!');
});
销毁插件实例
有状态插件必须考虑的额外因素是为开发人员提供一种方式来撤销它对页面状态引入的更改。实现这一点的最常见和简单的 API 是使用destroy字面量作为其第一个参数调用插件。让我们继续进行所需的实现更改:
(function($) {
$.fn.mutationObserver = function(action) {
return this.each(function(i, element) {
var $element = $(element);
var instance = $element.data('plugin_mutationObserver');
if (action === 'destroy' && instance) {
instance.observer.disconnect();
instance.observer = null;
$element.removeData('plugin_mutationObserver');
return;
}
if (!instance) {
/* ... */
}
});
};
})(jQuery);
为了使我们的实现适应上述需求,我们所要做的就是在检索插件状态对象后检查插件是否以destroy字符串值作为其第一个参数调用。如果我们发现插件已经被实例化在指定的元素上,并且已经使用了destroy字符串值,我们就可以继续停止 Mutation Observer 本身,并清除$.fn.data()创建的关联,方法是使用$.fn.removeData()方法。最后,在if语句的结尾处,我们添加了一个return语句,因为在完成销毁插件实例后,我们不再需要执行任何其他代码。使用此实现销毁插件实例的示例如下所示:
$('.container').mutationObserver('destroy');
实现获取器和设置器方法
通过使用我们先前展示的与插件的destroy方法的实现相同的技术,我们可以提供几种其他重载的方式来调用我们的插件,这些方式就像普通的方法一样工作。这种模式不仅被普通的 jQuery 插件所使用,而且还被更复杂的插件架构所采用,就像 jQuery-UI 一样。
另一方面,我们可能会得到一个插件实现,结果是大量调用重载,这会使其难以使用和文档化。解决这个问题的一种方法是将 API 的获取器和设置器方法合并成多用途方法。这不仅减少了插件的 API 表面,使开发人员需要记住的方法名称更少,而且还增加了生产力,因为在许多 jQuery 方法中都使用了相同的模式,比如$.fn.html()、$.fn.css()、$.fn.prop()、$.fn.val()和$.fn.data()。
作为对此的演示,让我们看看如何为我们的 MutationObserver 插件添加一个新方法,该方法既作为获取器又作为注册回调的设置器:
(function($) {
$.fn.mutationObserver = function(action, callbackFn) {
var result = this;
this.each(function(i, element) {
var $element = $(element);
var instance = $element.data('plugin_mutationObserver');
/* ... */
if (typeof action === 'function') {
instance.callbacks.push(action);
} else if (action === 'callbacks') {
if (callbackFn && callbackFn.length >= 0) {
// used as a setter
instance.callbacks = callbackFn;
} else {
// used as a getter for the first element
result = instance.callbacks;
return false;// break the $.fn.each() iteration
}
}
});
return result;
};
})(jQuery);
正如上面的代码所示,我们已经创建了一个重载的调用方法,该方法使用callbacks字符串值作为插件调用的第一个参数。这个获取器和设置器方法允许我们检索或覆盖注册在 MutationObserver 上的所有回调,并且与使用函数参数和destroy方法的预先存在的调用插件方法一起使用。
getter 和 setter 的实现基于这样的假设:当尝试将 callbacks 方法用作 getter 时,你不需要传递任何额外的参数;当尝试将其用作 setter 时,你将传递一个额外的数组作为调用参数。为了支持 getter 变体,该变体防止进一步的链式操作,仅对复合集合的第一个元素进行操作,我们不得不声明并使用 result 变量,该变量初始化为 this 标识符的值。如果使用 callbacks getter,则将集合的第一个元素的 callbacks 分配给 result 变量,并通过返回 false 以结束插件方法的执行来退出 $.fn.each() 迭代。
这是我们新实现的 getter 和 setter 方法的一个示例用例:
// retrieve the callbacks
var oldCallbacks = $('.container').mutationObserver('callbacks');
// clear them
$('.container').mutationObserver('callbacks', []);
// add a new one
$('.container').mutationObserver(function() {
console.log('Printed only once');
// restore the old callbacks
$('.container').mutationObserver('callbacks', oldCallbacks);
});
提示
请记住,防止进一步链式调用的调用重载应该有很好的文档记录,因为这种技术与每个人都期望工作的链式原则相冲突。
在我们的仪表板应用程序中使用我们的插件
完成我们的 mutationObserver 插件后,现在让我们看看如何将其用于我们在前几章中在仪表板实现中使用的 counter 子模块的实现:
(function() {
'use strict';
dashboard.counter = dashboard.counter || {};
var $counter;
dashboard.counter.init = function() {
$counter = $('#dashboardItemCounter');
var $boxContainer = dashboard.$container
.find('.boxContainer');
$boxContainer.mutationObserver(function(mutation) {
dashboard.counter.setValue($boxContainer.children().length);
});
};
dashboard.counter.setValue = function (value) {
$counter.text(value);
};
})();
正如你在上面的实现中所看到的,我们的插件很好地抽象并替换了旧的实现,提供了一个通用、灵活和可重用的 API。现在,该实现不再监听页面上不同按钮的点击事件,而是使用 mutationObserver 插件并观察 boxContainer 元素以查看子元素的添加或移除。此外,此实现更改不会影响 counter 模块的功能,因为所有更改都封装在模块中。
使用 jQuery 插件模板
jQuery Boilerplate 项目位于 github.com/jquery-boilerplate/jquery-patterns,提供了几个模板,可用作实现稳健且可扩展插件的起点。这些模板融合了许多最佳实践和设计模式,例如本章前面分析的那些。每个模板都包含了一些良好结合在一起的最佳实践,旨在提供更适合各种用例的良好起点。
或许最广泛使用的模板是 Adam Sontag 和 Addy Osmani 的jquery.basic.plugin-boilerplate,即使它被描述为一个适用于初学者及以上的通用模板,但仍成功地涵盖了 jQuery 插件开发的大多数方面。 使这个模板独特的是它遵循的面向对象的方法,它以这样一种方式呈现,帮助您编写更好结构化的代码,而不会增加引入自定义实现的难度。 让我们继续分析其源代码:
/*!
* jQuery lightweight plugin boilerplate
* Original author: @ajpiano
* Further changes, comments: @addyosmani
* Licensed under the MIT license
*/
;(function ( $, window, document, undefined ) {
var pluginName = "defaultPluginName",
defaults = {
propertyName: "value"
};
function Plugin( element, options ) {
this.element = element;
this.options = $.extend( {}, defaults, options) ;
this._defaults = defaults;
this._name = pluginName;
this.init();
}
Plugin.prototype = {
init: function() { /* Place initialization logic here */ },
yourOtherFunction: function(options) { /* some logic */ }
};
// A really lightweight plugin wrapper around the constructor,
// preventing against multiple instantiations
$.fn[pluginName] = function ( options ) {
return this.each(function () {
if (!$.data(this, "plugin_" + pluginName)) {
$.data(this, "plugin_" + pluginName,
new Plugin( this, options ));
}
});
};
})( jQuery, window, document );
IIFE 之前的分号是为了在不幸的脚本连接(以及可能的最小化)中避免错误,可能缺少结束分号的文件。 正如下面所示,样板使用pluginName变量作为 DRY 方式命名我们的插件并为任何其他情况使用其名称。 作为附加好处,如果我们需要重命名插件,所有我们需要做的就是更改此变量的值,并相应地重命名我们插件的.js文件。
遵循我们之前看到的最佳实践,使用一个变量来保存插件的默认选项,并且正如我们稍后看到的,它使用$.extend()方法将其与用户提供的选项合并。 请记住,如果我们想公开默认选项,所有我们需要做的就是将其定义为插件命名空间的一部分:$.fn[pluginName].defaultOptions = defaults;
实际的插件定义可以在此样板代码的末尾找到。 遵循已经讨论过的最佳实践,它使用$.fn.each()迭代集合的项并返回其结果,这相当于返回this。 然后,它通过使用$.data()方法和带有前缀的插件名称作为关联键,确保每个集合项都存在一个插件状态实例。
Plugin构造函数用于创建插件状态对象,该对象在存储 DOM 元素和最终插件选项作为对象属性后,调用其原型的init()方法。 init()方法是定义初始化代码的建议位置,例如,它可以像本章前面所做的那样实例化新的 MutationObserver。
向您的插件添加方法
默认情况下,作为原型的一部分定义的每个方法仅供内部使用。 另一方面,我们可以轻松地扩展上述实现,以使方法对所有用户可用,如下所示:
$.fn[pluginName] = function ( options, extraParam ) {
return this.each(function () {
var instance = $.data(this, "plugin_" + pluginName);
if (!instance) {
instance = new Plugin( this, options );
$.data(this, "plugin_" + pluginName, instance);
} else if (options === 'yourOtherFunction') {
instance.yourOtherFunction(this, extraParam);
}
});
};
在使用此样板时要遵循的一个准则是通过向Plugin的原型添加额外方法来扩展您的插件。此外,尽量保持对插件定义的任何修改尽可能小,理想情况下是单行方法调用。
为了使实现更具可扩展性,关于插件方法的调用方式以及如果我们想为插件添加一个抽象方法,该方法是为插件的内部或私有使用而设计的,我们可以引入以下更改:
$.fn[pluginName] = function ( options ) {
var restArgs = Array.prototype.slice.call(arguments, 1);
return this.each(function () {
var instance = $.data(this, "plugin_" + pluginName);
if (!instance) {
instance = new Plugin( this, options );
$.data(this, "plugin_" + pluginName, instance);
} else if (typeof options === 'string' && // method name
options[0] !== '_' && // protect private methods
typeof instance[options] === 'function') {
instance[options].apply(instance, restArgs);
}
});
};
在上述实现中,我们使用第一个参数来识别需要调用的方法,然后用剩余的参数来调用它。我们还添加了一个检查,以防止调用以下划线开头的方法,根据通常的约定,这些方法是用于内部或私有使用的。因此,为了向插件的公共 API 添加额外的方法,我们只需在之前看到的Plugin.prototype中声明它。
注意
当您已经在应用程序中使用 jQuery-UI 时,实现插件的另一种绝佳方式是使用$.widget()方法,也称为 jQuery-UI Widget 工厂。其实现抽象了我们在本章中看到的几部分样板代码,并帮助创建复杂而健壮的插件。有关更多信息,您可以阅读文档:api.jqueryui.com/jQuery.widget/
选择一个名字
最后,在学习了我们需要创建 jQuery 插件的最佳实践之后,让我们谈谈命名约定和在哪里发布您的新而闪亮的插件。
正如您可能已经看到的那样,大多数 jQuery 插件使用以下命名约定:jQuery-myPluginName 作为其项目站点和存储库,并将其实现存储在名为jquery.mypluginname.js的文件中。在为插件选择一些可能的名称之后,请花一点时间在网络上搜索以验证是否有其他人使用相同的项目名称。jQuery 文档建议在 NPM 上搜索插件,并使用jquery-plugin关键字来细化您的结果。这显然是发布您的插件的最佳方式,以便其他人可以轻松找到它。
注意
有关 NPM 的更多信息,请访问:www.npmjs.com/
搜索和托管 JavaScript 库的另一个热门地方是 GitHub。您可以在github.com/search?l=JavaScript找到其存储库搜索页面,其中它将搜索结果过滤为仅包含 JavaScript 项目,并搜索现有插件和已使用的项目名称。由于在我们的情况下,我们专注于 jQuery 插件,因此通过搜索遵循前述命名约定的项目名称,jQuery-myPluginName,您将获得更好的结果。
注意
直到最近,开发人员可以在官方的 jQuery 插件注册表 (plugins.jquery.com/)中搜索现有的插件并注册新的插件。不幸的是,它已经停止服务,现在只允许搜索旧的插件,不再接受新的提交。
总结
在本章中,我们学习了如何通过实现和使用插件来扩展 jQuery。我们首先看到了一个 jQuery 插件可以实现的最简单方式的示例,并分析了使一个优秀的插件的特点,以及符合 jQuery 库原则的插件。
我们随后介绍了开发者社区中最常见的用于创建 jQuery 插件的开发模式。我们分析了每种模式解决的实现问题以及更适合它们的使用案例。
完成本章后,我们现在能够将应用程序的部分抽象为可重用和可扩展的 jQuery 插件,这些插件使用最适合每个使用案例的开发模式进行结构化。
在下一章中,我们将介绍几种优化技术,可用于改善我们的 jQuery 应用程序的性能,特别是当它们变得庞大和复杂时。我们将讨论简单的实践,例如使用 CDN 加载第三方库,并继续讨论更高级的主题,例如延迟加载实现的模块。
第十一章:优化模式
本章介绍了几种优化技术,可用于改善 jQuery 应用程序的性能,特别是当它们变得庞大和复杂时。
我们将从捆绑和最小化我们的 JavaScript 文件等简单实践开始,并讨论使用CDN加载第三方库的好处。然后,我们将继续分析一些简单的编写高效 JavaScript 代码的模式,并学习如何编写高效的 CSS 选择器,以提高页面的渲染速度和使用 jQuery 进行 DOM 遍历。
然后,我们将研究特定于 jQuery 的实践,如缓存 jQuery 复合集合对象,如何最小化 DOM 操作,并将委托观察者模式作为享元模式的一个好例子。最后,我们将介绍惰性加载的高级技术,并演示如何根据用户操作逐步加载实施的不同模块。
到本章结束时,我们将能够在实施中应用最常见的优化模式,并将本章用作将应用程序移至生产环境之前的最佳实践和性能提示的检查表。
在本章中,我们将:
学习捆绑和最小化我们的 JavaScript 文件的好处
学习如何通过 CDN 服务器加载第三方库
学习一些简单的 JavaScript 性能提示
学习如何优化我们的 jQuery 代码
介绍享元模式并展示一些例子
学习在用户操作时如何按需惰性加载我们应用的部分
将脚本放置在页面末尾
提高页面初始渲染速度的第一个提示是收集所有所需的 JavaScript 文件,并将它们的<script>标签置于页面末尾,最好就在关闭</body>标签之前。这个改变会对页面的初始渲染时间产生很大的影响,特别是对于使用低速连接的用户(如移动用户)。如果您已经为所有与 DOM 相关的初始化目的使用了$(document).ready()方法,将<script>标签移动到其他位置不会对您的实施功能产生任何影响。
其主要原因是,即使浏览器并行下载页面的 HTML 和其他资源(CSS、图像等),当遇到<script>标签时,浏览器会暂停一切,直到它被下载和执行。为了解决规范的这一限制,HTML5 引入了defer和async等属性作为<script>标签规范的一部分,但不幸的是,直到最近才开始被一些浏览器采用。因此,即使在旧版浏览器上仍广泛使用这种做法来获得良好的页面加载速度。
注:
有关<script>标签的更多信息,请访问:developer.mozilla.org/en-US/docs/Web/HTML/Element/script
捆绑和缩小资源
要使页面加载速度更快,首先要寻找减少 HTTP 请求的数量和总大小的方法。好处在于浏览器下载内容时可以更大块地下载,而不是花时间等待许多小的往返请求完成。这对于低速连接的用户(如移动用户)尤其有益。
资源串联是一个简单的概念,无需任何介绍。这可以手动完成,但最好用捆绑脚本自动化此任务,或者为项目引入一个构建步骤。根据您的开发环境,有不同的捆绑解决方案可供选择。如果您在开发栈中使用grunt或gulp,您可以使用像grunt-contrib-concat(github.com/gruntjs/grunt-contrib-concat)和gulp-concat(github.com/contra/gulp-concat)这样的解决方案。
缩小 JavaScript 文件是一个更复杂的过程,包括一系列应用于目标源代码的代码转换,从简单的空格删除到更复杂的任务如变量重命名。流行的缩小 JavaScript 的解决方案包括:
YUI 压缩器可在yui.github.io/yuicompressor/找到。
谷歌的闭包编译器可在developers.google.com/closure/compiler/找到。
UglifyJS 可在github.com/mishoo/UglifyJS2找到。
再次强调,有各种解决方案可以很好地将上述库与您喜欢的开发环境集成,使缩小成为一个简单的任务。例如,grunt 和 gulp 的集成示例包括grunt-contrib-uglify(github.com/gruntjs/grunt-contrib-uglify)和gulp-uglify(github.com/terinjokes/gulp-uglify)。
作为最后的建议,要记住,你的代码应该尽可能地易读和逻辑结构清晰。将 JavaScript 和 CSS 文件进行捆绑和缩小,最有效的方法是作为开发和部署过程的构建步骤来完成。
使用 IIFE 参数
除了有助于避免污染全局命名空间之外,使用 IIFE 来包装您的实现也对缩小后的 JavaScript 文件大小有益。让我们看看下面的代码,其中jQuery、window和document变量作为调用参数传递到模块的 IIFE 中。
(function ( $, window, document, undefined ) {
if (window.myModule === undefined) {
window.myModule = {};
}
myModule.init = function() { /*...*/ };
$(document).ready(myModule.init);
})( jQuery, window, document );
我们在上一章中看到了类似的模式,作为创建 jQuery 插件的建议模板的一部分。尽管变量别名不影响实现的功能,但它允许代码最小化器在更多地方应用变量重命名,导致以下代码:
(function(b, a, c, d) {
a.myModule === d && (a.myModule = {});
myModule.init = function() { /*...*/ };
b(c).ready(myModule.init);
})(jQuery, window, document);
正如您在上面的代码中可以看到的,所有 IIFE 的调用参数都被缩小器重命名为单个字母标识符,这尤其增加了最小化的收益,特别是如果原始标识符在多个地方使用。
提示
作为附加好处,别名还可以保护我们的模块,防止原始变量意外赋予不同的值。例如,当 IIFE 参数未被使用时,来自不同模块的赋值,如$ = {}或undefined = 7,会破坏所有实现。
使用 CDN
不要从您的网站服务器提供所有的第三方库的 JavaScript 和 CSS 文件,您应该考虑使用内容交付网络(CDN)。使用 CDN 来提供您的网站所使用的库的静态文件可以使它加载更快,因为:
CDN 具有高速连接和多个缓存级别。
CDN 有许多地理分布的服务器,可以更快地传送所请求的文件,因为它们离最终用户更近。
CDN 有助于并行化资源请求,因为大多数浏览器只能同时从任何特定域下载最多四个资源。
而且,如果用户从使用相同 CDN 的另一个网站上缓存了静态资源,他或她将不必再次下载它们,减少了您的网站需要加载的时间。
下面是一个使用 JavaScript 库的最广泛使用的 CDN 列表,您可以在您的实现中使用它们:
code.jquery.com/
developers.google.com/speed/libraries/
cdnjs.com/
www.jsdelivr.com/
使用 JSDelivr API
CDN 世界的新来者是 JSDelivr,由于其独特的功能而备受欢迎。除了简单地提供现有的静态文件外,JSDelivr 还提供一个 API(github.com/jsdelivr/api),允许我们创建和使用带有我们需要加载的资源的自定义捆绑包,帮助我们最小化网站所需的 HTTP 请求。此外,其 API 允许我们以不同级别的特定性(主要、次要或错误修复版本)定位库,甚至允许我们只加载库的特定部分。
例如,看一下以下 URL,它允许我们使用单个请求加载 jQuery v1.11.x 的最新 bug 修复版本,以及 jQuery-UI v1.10.x 和 Bootstrap v3.3.x 的一些部分:cdn.jsdelivr.net/g/jquery@1.11,jquery.ui@1.10(jquery.ui.core.min.js+jquery.ui.widget.min.js+jquery.ui.mouse.min.js+jquery.ui.sortable.min.js),bootstrap@3.3
优化常见的 JavaScript 代码
在本节中,我们将分析一些不特定于 jQuery 的性能提示,并且可以应用于大多数 JavaScript 实现。
编写更好的 for 循环
当使用for循环遍历数组或类似数组的集合时,提高迭代性能的一个简单方法是避免在每个循环中访问length属性。可以通过将迭代length存储到一个单独的变量中,在循环之前声明,甚至与循环一起声明,如下所示:
for (var i = 0, len = myArray.length; i < len; i++) {
var item = myArray[i];
/*...*/
}
此外,如果我们需要迭代不包含假值的数组项,我们可以使用一个更好的模式,通常用于迭代包含对象的数组:
var objects = [{ }, { }, { }];
for (var i = 0, item; item = objects[i]; i++) {
console.log(item);
}
在这种情况下,我们利用了数组的超出边界位置返回undefined的事实,这是假值并且停止迭代。可以在迭代节点列表或 jQuery 复合集合对象时使用此技巧的另一个示例情况如下:
var anchors = $('a'); // or document.getElementsByTagName('a');
for (var i = 0, anchor; anchor = anchors[i]; i++) {
console.log(anchor.href);
}
注意
有关 JavaScript 中真值和假值的更多信息,请访问:developer.mozilla.org/en-US/docs/Glossary/Truthy 和 developer.mozilla.org/en-US/docs/Glossary/Falsy
编写高性能的 CSS 选择器
尽管Sizzle(jQuery 的选择引擎)隐藏了基于复杂 CSS 选择器的 DOM 遍历的复杂性,我们应该了解我们的选择器是如何执行的。了解 CSS 选择器如何匹配 DOM 的元素可以帮助我们编写更有效的选择器,在与 jQuery 一起使用时性能更佳。
有效 CSS 选择器的关键特征是特异性。根据这一点,ID 和类选择器总是比div和*这样结果较多的选择器更高效。在编写复杂的 CSS 选择器时,要记住它们是从右到左进行评估的,并且在递归测试每个父元素直到 DOM 根元素后,选择器将被拒绝。
因此,在执行选择器期间,尽量使最右边的选择器尽可能具体,以尽快减少匹配的元素数量。
// initially matches all the anchors of the page
// and then removes those that are not children of the container
$('.container a');
// performs better, since it matches fewer elements
// in the first step of the selector's evaluation
$('.container .mySpecialLinks');
另一个性能提示是在适用的地方使用子选择器("parent > child"),以消除对 DOM 树层次结构的递归。一个极好的应用案例是目标元素可以在共同祖先元素的特定后代级别找到的情况:
// initially matches all the div's of the page, which is bad
$('.container div') ;
// a lot faster than the previous one,
// since it avoids the recursive class checks
// until reaching the root of the DOM tree
$('.container > div');
// best of all, but can't be used always
$('.container > .specialDivs');
提示
相同的技巧也适用于用于页面样式的 CSS 选择器。尽管浏览器一直在尝试优化任何给定的 CSS 选择器,上述技巧可以极大地减少渲染网页所需的时间。
注意
有关 jQuery CSS 选择器性能的更多信息,您可以访问:learn.jquery.com/performance/optimize-selectors/
编写高效的 jQuery 代码
现在让我们继续分析最重要的 jQuery 特定性能提示。有关 jQuery 最新性能提示的更多信息,请关注 jQuery 学习中心的相关页面:learn.jquery.com/performance
减少 DOM 遍历
由于 jQuery 使 DOM 遍历变得如此简单,许多 web 开发人员在每处都过度使用 $() 函数,甚至在后续的代码行中使用,通过执行不必要的代码来使其实现变慢。操作复杂性如此经常被忽视的主要原因之一是 jQuery 使用优雅和极简的语法。尽管 JavaScript 浏览器引擎在过去几年变得多次更快,性能可与许多编译语言媲美,但是 DOM API 仍然是它们最慢的组件之一,因此开发人员必须尽量减少与它的交互。
缓存 jQuery 对象
将 $() 函数的结果存储到本地变量中,并随后在检索到的元素上操作是消除不必要的相同 DOM 遍历执行的最简单方法。
var $element = $('.boxHeader');
if ($element.css('position') === 'static') {
$element.css({ position: 'relative' });
}
$element.height('40px');
$element.wrapInner('<b>');
在之前的章节中,我们甚至建议将重要页面元素的组合集合对象作为模块的属性进行存储,并在应用程序的各个地方重复使用:
dashboard.$container = null;
dashboard.init = function() {
dashboard.$container = $('.dashboardContainer');
};
提示
当元素不会从页面中移除时,将检索到的元素缓存在模块上是一种非常好的做法。请记住,当处理生命周期较短的元素时,为了避免内存泄漏,您必须确保在从页面中删除它们时清除所有引用,或者在需要时重新检索新的引用,并仅在函数内部缓存它。
作用域元素遍历
而不是为遍历编写复杂的 CSS 选择器:
$('.dashboardContainer .dashboardCategories');
你可以通过使用已经检索到的祖先元素来限定 DOM 遍历,以更高效的方式获得相同的结果。这样做,不仅使用了更简单的 CSS 选择器来匹配页面元素,而且减少了需要检查的元素数量。此外,生成的实现代码重复性较少(更干净),使用的 CSS 选择器简单,因此更易读。
var $container = $('.dashboardContainer');
$container.find('.dashboardCategories');
另外,这种做法与整个模块范围的缓存元素一起使用效果更佳,就像我们在前几章中使用的那样:
$boxContainer = dashboard.$container.find('.boxContainer');
链式调用 jQuery 方法
所有 jQuery API 的特点之一是它们是流式接口实现,使我们能够在单个组合集合对象上链式调用多个方法。
$('.boxContent').html('')
.append('<a href="#">')
.height('40px')
.wrapInner('<b>');
正如我们在前几章中讨论的,链式调用可以减少所用变量的数量,并且以更少的代码重复实现更易读的实现。
不要过度使用
请记住,jQuery 还提供了 $.fn.end() 方法(api.jquery.com/end/)作为从链式遍历中返回的一种方式。
$('.box')
.filter(':even')
.find('.boxHeader')
.css('background-color', '#0F0')
.end()
.end() // undo the filter and find traversals
.filter(':odd') // applied on the initial .box results
.find('.boxHeader')
.css('background-color', '#F00');
尽管这在许多情况下都是一个方便的方法,但你应该避免过度使用它,因为它可能会损害代码的可读性和性能。在许多情况下,使用缓存的元素集合而不是 $.fn.end() 可以获得更快、更可读的实现。
改进 DOM 操作
正如我们之前所说的,广泛使用 DOM API 是使应用程序变慢的最常见因素之一,特别是在用于操作 DOM 树状态时。在本节中,我们将展示一些改进操作 DOM 树性能的技巧。
创建 DOM 元素
创建 DOM 元素最有效的方式是构造一个 HTML 字符串并使用 $.fn.html() 方法将其附加到 DOM 树中。此外,由于在某些用例中这太过限制,你也可以使用 $.fn.append() 和 $.fn.prepend() 方法,虽然稍微慢一些,但可能更适合你的实现。理想情况下,如果需要创建多个元素,你应该尝试通过创建一个定义所有元素的 HTML 字符串,然后将其插入到 DOM 树中,如下所示:
var finalHtml = '';
for (var i = 0, len = questions.length; i < len; i++) {
var question = questions[i];
finalHtml += '<div><label><span>' + question.title + ':</span>' +
'<input type="checkbox" name="' + question.name + '" />' +
'</label></div>';
}
$('form').html(finalHtml);
另一种实现相同结果的方法是使用数组来存储每个中间元素的 HTML,然后在插入到 DOM 树之前将它们连接起来:
var parts = [];
for (var i = 0, len = questions.length; i < len; i++) {
var question = questions[i];
parts.push('<div><label><span>' + question.title + ':</span>' +
'<input type="checkbox" name="' + question.name + '" />' +
'</label></div>');
}
$('form').html(parts.join(''));
注意
自近些年来,这是一个常用的模式,因为它比使用 "+=" 连接中间结果性能更好。
样式和动画
在可能的情况下,通过利用$.fn.addClass()和$.fn.removeClass()方法使用 CSS 类进行样式操作,而不是通过$.fn.css()方法手动操纵元素的样式。当你需要为大量元素设置样式时,这特别有用,因为这是 CSS 类的主要用途,并且浏览器已经花费了数年的时间对其进行优化。
提示
作为最小化操作元素数量的额外优化步骤,你可以在单个公共祖先元素上应用 CSS 类,并使用后代 CSS 选择器来应用你的样式,如此处所示:developer.mozilla.org/en-US/docs/Web/CSS/Descendant_selectors
当你仍然需要使用$.fn.css()方法时,例如,当你的实现需要是命令式的时候,使用接受对象参数的调用重载:api.jquery.com/css/#css-properties。这样,在为元素应用多个样式时,所需的方法调用将被最小化,而且你的代码组织得更好。
此外,避免混合使用操纵 DOM 的方法和从 DOM 中读取数据的方法,因为这会强制页面重新排版,以便浏览器计算页面元素的新位置。
而不是像这样做:
$('h1').css('padding-left', '2%');
$('h1').css('padding-right', '2%');
$('h1').append('<b>!!</b>');
var h1OuterWidth = $('h1').outerWidth();
$('h1').css('margin-top', '5%');
$('body').prepend('<b>--!!--</b>');
var h1Offset = $('h1').offset();
更好地将非冲突的操作分组在一起,像这样:
$('h1').css({
'padding-left': '2%',
'padding-right': '2%',
'margin-top': '5%'
}).append('<b>!!</b>');
$('body').prepend('<b>--!!--</b>');
var h1OuterWidth = $('h1').outerWidth();
var h1Offset = $('h1').offset();
浏览器因此可以跳过对页面的一些重新渲染,从而减少代码执行时的暂停。
注意
有关重排的更多信息,请访问以下页面:developers.google.com/speed/articles/reflow
最后,请注意,v1.x 和 v2.x 中所有由 jQuery 生成的动画都是使用setTimeout()函数实现的。这将在 jQuery 的 v3.x 中发生变化,该版本计划使用requestAnimationFrame()函数,这更适合创建命令式动画。在那之前,你可以使用jQuery-requestAnimationFrame插件 (github.com/gnarf/jquery-requestAnimationFrame),它对 jQuery 进行了猴子补丁,以便在可用时使用requestAnimationFrame()函数进行动画。
操纵分离的元素
操纵 DOM 元素时避免页面不必要的重绘的另一种方法是将元素从页面中分离,并在完成操作后重新附加它。使用分离的内存中元素要快得多,并且不会导致页面重排。
为了实现这一点,我们使用$.fn.detach()方法,与$.fn.remove()相比,它保留了分离元素上的所有事件处理程序和 jQuery 数据。
var $h1 = $('#pageHeader');
var $h1Cont = $h1.parent();
$h1.detach();
$h1.css({
'padding-left': '2%',
'padding-right': '2%',
'margin-top': '5%'
}).append('<b>!!</b>');
$h1Cont.append($h1);
另外,为了能够将操作过的元素放回其原始位置,我们可以在 DOM 中创建并插入一个隐藏的占位符元素。这个空的隐藏元素不会影响页面的渲染,并在将原始项目放回其原始位置后被移除。
var $h1PlaceHolder = $('<div style="display: none;"></div>');
var $h1 = $('#pageHeader');
$h1PlaceHolder.insertAfter($h1);
$h1.detach();
$h1.css({
'padding-left': '2%',
'padding-right': '2%',
'margin-top': '5%'
}).append('<b>!!</b>');
$h1.insertAfter($h1PlaceHolder);
$h1PlaceHolder.remove();
$h1PlaceHolder = null;
注意
欲了解有关 $.fn.detach() 方法的更多信息,请阅读文档:api.jquery.com/detach/
引入 Flyweight 模式
根据计算机科学的说法,Flyweight 是一种对象,用于通过提供与其他对象实例共享的功能和/或数据来减少实现的内存消耗。JavaScript 构造函数的原型可以被定义为 Flyweights,因为每个对象实例都可以使用其原型中定义的所有方法和属性,直到覆盖它们为止。另一方面,经典的 Flyweights 是与它们一起使用的对象系列中的独立对象,并经常在特殊的数据结构中保存共享的数据和功能。
使用委托观察者
在 jQuery 应用程序中,委托观察者是 Flyweights 的一个很好的示例,正如我们在第二章中看到的观察者模式中的仪表板示例一样,它可以通过作为大量元素的集中事件处理程序来大大降低实现的内存需求。通过这种方式,我们可以避免为每个元素设置单独的观察者和事件处理程序的成本,并使用浏览器的事件冒泡机制在单个共同的祖先元素上观察它们并过滤它们的来源。
$boxContainer.on('click', '.boxCloseButton', function() {
var $button = $(this);
dashboard.informationBox.close($button);
});
注意
实际的 Flyweight 对象是与祖先元素附加的回调一起的事件处理程序。
使用 $.noop() 方法
jQuery 库提供了 $.noop() 方法,实际上是一个可以在不同实现之间共享的空函数。使用空函数作为默认回调值通过减少 if 语句的数量来简化和提高实现的可读性。这对于已经封装了复杂功能的 jQuery 插件非常方便。
function doLater(callbackFn) {
setTimeout(function() {
if (callbackFn) {
callbackFn();
}
}, 500);
}
// with $.noop()
function doLater(callbackFn) {
callbackFn = callbackFn || $.noop();
setTimeout(function() {
callbackFn();
}, 500);
}
在这种情况下,无论是由于实现需求还是开发者的个人品味,都导致了使用空函数,$.noop() 方法都是一种降低内存消耗的有效方式,它通过在整个实现的所有不同部分之间共享一个空函数实例来实现。使用 $.noop() 方法的另一个好处是,我们还可以通过简单检查 callbackFn === $.noop() 来检查传递的函数引用是否为空函数。
注意
欲了解更多信息,请参阅文档:api.jquery.com/jQuery.noop/
使用 $.single 插件
在 jQuery 应用程序中,另一个简单的享元模式示例是 James Padolsey 在他的文章 76 bytes for faster jQuery 中描述的 jQuery.single 插件,该插件尝试在单个页面元素上应用 jQuery 方法时消除创建新的 jQuery 对象。该实现非常小,创建一个单一的 jQuery 复合集合对象,在每次调用 jQuery.single() 方法时返回,该对象包含用作参数的页面元素。
jQuery.single = (function(){
var collection = jQuery([1]);
// Fill with 1 item, to make sure length === 1
return function(element) {
collection[0] = element; // Give collection the element:
return collection; // Return the collection:
};
}());
当在 $.fn.on() 这样的观察者和 $.each() 这样的方法迭代中使用时,jQuery.single 插件非常有用。
$boxContainer.on('click', '.boxCloseButton', function() {
// var $button = $(this);
var $button = $.single(this);
// this is not creating any new object
dashboard.informationBox.close($button);
});
使用 jQuery.single 插件的好处在于我们创建的对象更少,因此当释放短生命周期对象的内存时,浏览器的垃圾回收器的工作量也会减少。
作为一个副作用,请注意每次调用$.single()方法时返回的单个 jQuery 对象以及最后一个调用参数将存储到下一次调用该方法之前的事实:
var buttons = document.getElementsByTagName('button');
var $btn0 = $.single(buttons[0]);
var $btn1 = $.single(buttons[1]);
$btn0 === $btn1 // this is true
另外,如果你使用类似 $btn1.remove() 这样的方法,那么该元素将一直保留,直到下一次调用 $.single() 方法将其从插件的内部集合对象中删除为止。
另一个类似但更全面的插件是 jQuery.fly 插件,它可以使用数组和 jQuery 对象作为参数调用。
注意
关于 jQuery.single 和 jQuery.fly 的更多信息,请访问以下链接:james.padolsey.com/javascript/76-bytes-for-faster-jquery/ 和 github.com/matjaz/jquery.fly。
另一方面,处理带有单个页面元素的 $() 方法调用的 jQuery 实现并不复杂,只创建一个简单的对象。
jQuery = function( selector, context ) {
return new jQuery.fn.init( selector, context );
};
/*...*/ init = jQuery.fn.init = function( selector, context, root ) {
/*... else */
if ( selector.nodeType ) {
this.context = this[ 0 ] = selector;
this.length = 1;
return this;
} /* ... */
};
此外,现代浏览器的 JavaScript 引擎在处理短生命周期对象时已经非常高效,因为这些对象通常作为方法调用参数在应用程序中传递。
延迟加载模块
最后,我们将介绍一种高级技术——延迟加载模块。这种实践的关键概念是,在页面加载期间,浏览器仅下载并执行那些在页面的初始渲染过程中所需的模块,而其余的应用程序模块则在页面完全加载后,并且需要响应用户操作时才被请求。RequireJS (requirejs.org/) 是一个常用的 JavaScript 库,用作模块加载器,但对于简单情况,我们可以使用 jQuery 来实现相同的效果。
作为此的示例,我们将在用户首次单击仪表板上的 <button> 后,使用它来延迟加载我们在以前章节中看到的 Dashboard 示例的 informationBox 模块。我们将抽象出负责下载和执行 JavaScript 文件的实现,成为一个通用且可重用的模块,名为 moduleUtils。
(function() {
'use strict';
dashboard.moduleUtils = dashboard.moduleUtils || {};
dashboard.moduleUtils.getModule = function(namespaceString) {
var parts = namespaceString.split('.');
var result = parts.reduce(function(crnt, next){
return crnt && crnt[next];
}, window);
return result;
};
var ongoingModuleRequests = {};
dashboard.moduleUtils.ensureLoaded = function(namespaceString) {
var existingNamespace = this.getModule(namespaceString);
if (existingNamespace) {
return $.Deferred().resolve(existingNamespace);
}
if (ongoingModuleRequests[namespaceString]) {
return ongoingModuleRequests[namespaceString];
}
var modulePromise = $.getScript(namespaceString.toLowerCase() + '.js')
.always(function() {
ongoingModuleRequests[namespaceString] = null;
}).then(function() {
return dashboard.moduleUtils.getModule(namespaceString);
});
ongoingModuleRequests[namespaceString] = modulePromise;
return modulePromise;
};
})();
getModule() 方法接受模块的命名空间作为字符串参数,并返回模块的单例对象本身,或者如果模块尚未加载,则返回假值。这是通过使用 Array.reduce() 方法完成的,该方法用于迭代命名空间字符串的不同部分,使用点(.)作为分隔符,并在先前对象上下文中评估每个部分,从window开始。
注意
有关 Array.reduce() 方法的更多信息,请访问:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce
ensureLoaded() 是 moduleUtils 模块的主要方法,负责检索并执行尚未加载的模块。它首先使用 getModule() 方法检查请求的模块是否已加载,如果是,则将其命名空间对象作为已解决的 Promise 返回。
下一步,如果模块尚未加载,则是检查 ongoingModuleRequests 对象,以验证请求的模块是否尚未下载。为了做到这一点,ongoingModuleRequests 对象将模块的命名空间字符串作为属性,并存储用于从服务器检索 .js 文件的 AJAX 请求的 Promises。如果有一个 Promise 对象可用,那么我们可以推断出 AJAX 请求仍在进行中,并且我们不会启动新的请求,而是返回现有的 Promise。
最后,当上述任何一个都没有返回结果时,我们使用在之前章节中讨论的小写模块文件命名约定,并使用 jQuery 的 $.getScript() 方法发起 AJAX 请求以检索所请求的模块文件。为 AJAX 请求创建的 Promise 被分配为 ongoingModuleRequests 对象的适当属性,并随后返回给方法的调用者。当在以后的时间点,Promise 被完成时,我们重新评估模块并将其作为返回的 Promise 的最终结果返回。此外,无论 AJAX 请求的结果如何,Promise 也会从 ongoingModuleRequests 对象中删除,以便在网络故障时保持实现的可重用性,并释放为请求分配的内存。
注意
请记住,当页面通过文件系统加载时,$.getScript() 方法可能在某些浏览器中无法工作,但在像 Apache、IIS 或 nginx 这样的 Web 服务器上加载时则可以正常工作。有关 $.getScript() 的更多信息,请访问:api.jquery.com/jQuery.getScript/
我们对现有的 informationBox 模块的实现仅做了一个改变,即使其自我初始化,以尝试减少 ensureLoaded() 方法的复杂性。
(function() {
'use strict';
dashboard.informationBox = dashboard.informationBox || {};
var $boxContainer = null;
dashboard.informationBox.init = function() { /* … */ };
$(document).ready(dashboard.informationBox.init);
/*...*/
})();
最后,我们还必须更改 categories 模块的实现,以便在使用 informationBox 模块之前使用 ensureLoaded() 方法。正如您下面所见,我们不得不重构处理仪表板 <button> 的点击事件的代码,因为 ensureLoaded() 方法返回一个 Promise 作为结果:
// in dashboard.categories.init
dashboard.$container.find('.dashboardCategories').on('click', 'button', function() {
var $button = $(this);
var itemName = $button.text();
var p = dashboard.moduleUtils.ensureLoaded('dashboard.informationBox');
p.then(function(){
dashboard.informationBox.openNew(itemName);
});
});
摘要
在本章中,我们学习了几种优化技术,可以用来提高 jQuery 应用程序的性能,特别是当它们变得庞大和复杂时。
我们从简单的实践开始,比如捆绑和缩小我们的 JavaScript 文件,并讨论了使用 CDN 加载第三方库的好处。然后,我们继续分析了一些编写高效 JavaScript 代码的简单模式,并学习了如何编写高效的 CSS 选择器来提高页面的渲染速度,并使用 jQuery 来改进 DOM 遍历。
我们继续使用 jQuery 特定的实践,例如缓存 jQuery 组合集合对象、如何最小化 DOM 操作,并提醒代理观察者模式,作为享元模式的一个很好的例子。最后,我们介绍了惰性加载的高级技术,并演示了如何根据用户操作逐步加载实现的各个模块。
完成本章后,我们现在能够将最常见的优化模式应用于我们的实现,并在将应用程序移至生产环境之前,将本章用作最佳实践和性能提示的检查清单。