jQuery 设计模式(二)
原文:
zh.annas-archive.org/md5/9DBFD51895CA93BE96AC02124FF5B7E1译者:飞龙
第六章:生成器和工厂模式
本章中,我们将展示生成器模式和工厂模式,这两种最常用的创建型设计模式之一。这两种设计模式彼此之间有一些相似之处,共享一些共同的目标,并致力于简化复杂结果的创建。我们将分析它们的采用对我们实现的好处,以及它们之间的区别。最后,我们将学习如何正确使用它们,并为我们实现的不同用例选择最合适的模式。
本章中,我们将:
-
介绍工厂模式
-
查看 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/
在这种情况下,插件定义会抛出**`变量,导致难以调试的错误。
幸运的是,修复这个问题所需的更改很容易实现,并且不会影响插件的功能。我们所要做的就是将插件中所有的$标识符的出现都重命名为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 加载第三方库,并继续讨论更高级的主题,例如延迟加载实现的模块。