現如今,雖然多數的web應用都使用了大量的JavaScript#,但如何保持客戶端功能的專注性、健壯性和可維護性依然是一個很大的挑戰。
儘管其它程式語言和系統都已經將關注分離和DRY這樣的基本原則視為理所當然的宗旨,但往往在進行瀏覽器端應用開發的時候,這些原則就被忽略了。
造成這現象的部分原因是JavaScript語言本身就在不斷掙扎的歷史,在很長的一段時間內,它都難以獲得開發者的認真關注和對待。
而更重要的原因或許是源自於服務端與客戶端的差異所造成的。雖然在這方面已經有大量的架構風格方面的概念,例如ROCA,闡述瞭如何管理這種差異的方式。但還是缺乏如何實現這些概念的具體步驟的指南1。
這些原因經常導致前端程式碼的高度過程化並且相對缺乏結構性,這種直接的程式碼呼叫方式減少了呼叫的開銷,從而簡化了程式碼呼叫的複雜性,JavaScript和瀏覽器也是因為這一點原因而允許這種呼叫方式的存在。但很快,透過這種方式實現的程式碼就會變得難以維護。
本文將透過一個範例為你展示某個簡單的元件(widget)的演化過程,看看它是如何從一個龐大的、缺乏結構性的程式碼庫進化為一個可重複使用的元件的。
這個範例元件的作用是對一個聯絡人清單透過名稱進行篩選。它的最新成果以及它的全部演化過程都可以在這個GitHub程式碼庫中找到。我們鼓勵讀者們對提交的程式碼進行審閱,並且留下寶貴意見。
依照漸進式增強的原則,我們先從一個基礎的HTML結構開始以描述所用到的資料。這裡用到了h-card這個微格式(microformat),它能夠起到語意化的作用,讓聯絡人的各種資訊顯得有意義:
<!-- index.html --> <ul> <li class="h-card"> <img src="http://example.org/jake.png" alt="avatar" class="u-photo"> <a href="http://jakearchibald.com" class="p-name u-url">Jake Archibald</a> (<a href="mailto:jake@example.com" class="u-email">e-mail</a>) </li> <li class="h-card"> <img src="http://example.org/christian.png" alt="avatar" class="u-photo"> <a href="http://christianheilmann.com" class="p-name u-url">Christian Heilmann</a> (<a href="mailto:christian@example.com" class="u-email">e-mail</a>) </li> <li class="h-card"> <img src="http://example.org/john.png" alt="avatar" class="u-photo"> <a href="http://ejohn.org" class="p-name u-url">John Resig</a> (<a href="mailto:john@example.com" class="u-email">e-mail</a>) </li> <li class="h-card"> <img src="http://example.org/nicholas.png" alt="avatar" class="u-photo"> <a href="http://www.nczonline.net" class="p-name u-url">Nicholas Zakas</a> (<a href="mailto:nicholas@example.com" class="u-email">e-mail</a>) </li> </ul>
有一點請注意,在這裡我們並不關心這個DOM結構是基於server端產生的HTML程式碼,或是由其它元件產生的,只要保證在初始化時,我們的元件能夠依賴這個基礎結構就夠了。這個結構其實為表單項目構成了一個基於DOM的資料結構 [{ photo, website, name, e-mail }]。
有了這個基礎結構之後,我們就可以開始實作我們的元件了。第一步是為使用者提供一個輸入字段,以輸入聯絡人名稱。雖然它並不屬於DOM結構的契約,但我們的元件仍然要負責創建它並動態地加入到DOM結構中去(畢竟,如果沒有我們的元件,那麼添加這個欄位就完全沒有任何意義了)。
// main.js var contacts = jQuery("ul.contacts"); jQuery('<input type="search" />').insertBefore(contacts);
(我們在這裡僅是出於便利性而使用了jQuery,同時也考慮到它的廣泛使用性。如果使用其它的DOM操作類別庫,也是出於同樣的原因。
接下來開始加入所需的功能,對於那些不符合這個新建的欄位中的輸入名稱的聯絡人,這個元件會將它們隱藏起來:
// main.js var contacts = jQuery("ul.contacts"); jQuery('<input type="search" />').insertBefore(contacts). on("keyup", onFilter); function onFilter(ev) { var filterField = jQuery(this); var contacts = filterField.next(); var input = filterField.val(); var names = contacts.find("li .p-name"); names.each(function(i, node) { var el = jQuery(node); var name = el.text(); var match = name.indexOf(input) === 0; var contact = el.closest(".h-card"); if(match) { contact.show(); } else { contact.hide(); } }); }
(引用一個分離的、具名的
函數,比起定義一個匿名函數來說,通常會使得回呼函數更便於管理。請注意,這個事件處理函數依賴特定的DOM環境,它取決於觸發這個事件的元素(它的執行上下文會對應到this指標上)。我們將從這個元素開始遍歷DOM結構,以存取聯絡人列表,並找出所有包含名稱的元素(這是由微格式的語義所定義的)。如果某個名稱的開頭部分與目前輸入的內容不匹配,我們就再次向上遍歷,將相應的容器元素隱藏起來,否則的話,就要確保該元素依然可見。
測試這段程式碼已經提供了我們所需的基本功能,是時候透過寫測試來繼續增強它了
2我们首先编写一个最简单的HTML页面,它将作为我们的测试集的入口。当然我们还需要引用我们的代码以及相应的依赖项(在这个例子中就是jQuery),这和我们之前创建的普通HTML页面的方式是一样的。
<!-- test/index.html --> <p id="qunit"></p> <p id="qunit-fixture"></p> <script src="jquery.js"></script> <script src="../main.js"></script> <script src="qunit.js"></script>
有了这个基础结构之后,我们就要在#qunit-fixture这个元素中加入我们的示例数据了,即一个h-card的列表,还记得我们最开始时的那一段HTML结构吗?每一个测试开始时都会重置这个元素,保证测试数据的完整,也避免任何可能的副作用产生。
我们的第一个测试保证这个组件正确地初始化,而且过滤功能和预期一样工作,能够将不满足输入条件的DOM元素隐藏起来。
// test/test_filtering.js QUnit.module("contacts filtering", { setup: function() { // cache common elements on the module object this.fixtures = jQuery("#qunit-fixture"); this.contacts = jQuery("ul.contacts", this.fixtures); } }); QUnit.test("filtering by initials", function() { var filterField = jQuery("input[type=search]", this.fixtures); QUnit.strictEqual(filterField.length, 1); var names = extractNames(this.contacts.find("li:visible")); QUnit.deepEqual(names, ["Jake Archibald", "Christian Heilmann", "John Resig", "Nicholas Zakas"]); filterField.val("J").trigger("keyup"); // simulate user input var names = extractNames(this.contacts.find("li:visible")); QUnit.deepEqual(names, ["Jake Archibald", "John Resig"]); }); function extractNames(contactNodes) { return jQuery.map(contactNodes, function(contact) { return jQuery(".p-name", contact).text(); }); }
(strictEqual方法能够避免JavaScript在比较对象时会默认忽略类型信息的现象,这可以避免某些微妙的错误出现。)
随后我们将这个测试文件加入我们的测试集中(在QUnit引用的下方添加这个文件的引用),在浏览器中打开这个测试集,它应该告诉我们所有的测试都已通过:
虽然这个widget运行没问题,但还不够吸引人,因此让我们来添加一点简单的动画效果。使用jQuery可以很简单地实现这一点:只要把show和hide方法替换为相应的slideUp和slideDown方法就可以了。这一特性能够让这个朴素的示例的用户体验得到显著的提升。
但是当你再一次运行这个测试集时,结果是过滤功能这次不能正确工作了,因为全部4个联系人都依然显示在页面上:
这是由于动画效果是异步操作(就如AJAX操作一样),因此在动画结束前就已经完成了对过滤结果的检查。我们可以使用QUnit的asyncTest方法推迟检查的时间。
// test/test_filtering.js QUnit.asyncTest("filtering by initials", 3, function() { // expect 3 assertions // ... filterField.val("J").trigger("keyup"); // simulate user input var contacts = this.contacts; setTimeout(function() { // defer checks until animation has completed var names = extractNames(contacts.find("li:visible")); QUnit.deepEqual(names, ["Jake Archibald", "John Resig"]); QUnit.start(); // resumes test execution }, 500); });
每次都打开浏览器检查测试集的结果有些繁琐,因此我们可以使用PhantomJS,这是一个后台浏览器。将它与QUnit runner一起使用可以使测试过程自动化,并在控制台显示测试结果。
$ phantomjs runner.js test/index.html Took 545ms to run 3 tests. 3 passed, 0 failed.
这种方式使得通过持续集成进行自动化测试变得更为方便(当然,它做不到跨浏览器的错误检查,因为PhantomJS只使用了WebKit内核。不过现在也出现了支持Firefox的Gecko和Internet Explorer的Trident引擎的后台浏览器。)
目前为止,我们的代码虽然能够运行,但还不够优雅:由于浏览器不会在隔离的区间内运行JavaScript,因此这段代码会将contacts和onFilter两个变量暴露到全局命名空间内,初学者需要特别当心。不过我们可以自行修改这段代码,以避免变量污染全局命名空间,由于JavaScript中唯一的限定范围机制就是函数,因此我们只需将整个文件简单地封装在一个匿名函数中,并在最后调用这个函数就可以了:
(function() { var contacts = jQuery("ul.contacts"); jQuery('<input type="search" />').insertBefore(contacts). on("keyup", onFilter); function onFilter(ev) { // ... } }());
这种方法被称为立即调用的函数表达式(IIFE)。
现在,我们已经有效地将变量限定为一个自包含的模块中的私有变量了。
我们还可以进一步改善代码,以防止在声明变量时因遗漏var而导致创建了新的全局变量。实现这一点只需激活strict模式,它可以避免许多代码中的陷阱3。
(function() { "use strict"; // NB: must be the very first statement within the function // ... }());
在某个IIFE容器中指定strict模式,可以确保它只在被显式调用的模块中起作用。
有了基于模块的本地变量之后,我们就可以利用这一点来引入本地别名,以达到便利性的目的,比方在我们的测试中可以这样做:
// test/test_filtering.js (function($) { "use strict"; var strictEqual = QUnit.strictEqual; // ... var filterField = $("input[type=search]", this.fixtures); strictEqual(filterField.length, 1); }(jQuery));
现在我们有了两个别名:$和strictEqual,前者是通过一个IIFE参数进行定义的,它只在这个模块内部起作用。
虽然我们的代码已经实现了良好的结构化,不过这个组件会在启动时(例如在这段代码刚刚加载时)自动初始化。这导致了难以预测它的初始化时机,而且使得不同种类的,或是新创建的元素不能够动态地被(重新)初始化。
只需将现有的初始化代码封装在一个函数中,就可以简单地修正这一问题:
// widget.js window.createFilterWidget = function(contactList) { $('<input type="search" />').insertBefore(contactList). on("keyup", onFilter); };
通过这种方式,我们就将这个组件的功能与它的运行程序的生命周期解耦了。初始化的责任就转交给了应用程序,在我们的示例中就是测试工具。这通常意味着需要在应用程序的上下文中加入一些“粘合代码”以管理这些组件。
请注意,我们显式地将函数赋给了全局的window对象,这是让我们的功能可以在IIFE外部访问的最简单方式。但这种方式将模块本身与某个特定的隐式上下文耦合在一起了:而window并不一定是全局对象(例如在Node.js中)。
一个更为优雅的途径是明确指出代码的哪些部分将暴露给外部,并将这些部分聚集在一处。我们可以再次利用IIFE的优势实现这一点:因为IIFE仅仅是一个函数,我们可以在它的底部返回它的公开部分(例如我们所定义的API),并将返回值赋给某个外部(全局)范围内的变量:
// widget.js var CONTACTSFILTER = (function($) { function createFilterWidget(contactList) { // ... } // ... return createFilterWidget; }(jQuery));
这一方式也叫做揭示模块化模式(revealing module pattern),至于使用大写是为了突出全局变量的一种约定。
目前为止,我们的组件不仅功能良好而且结构合理,还包含了一个恰当的API。不过,如果我们继续按照这种方式引入更多的功能,就会导致对相互独立的函数的组合调用,这样很容易产生混乱的代码。对于UI组件这种注重状态的对象来说就更是如此。
在我们的示例, 我们希望允许用户决定过滤条件是否是大小写敏感的,因此我们加入了一个复选框,并相应地扩展了我们的事件处理函数:
// widget.js var caseSwitch = $('<input type="checkbox" />'); // ... function onFilter(ev) { var filterField = $(this); // ... var caseSwitch = filterField.prev().find("input:checkbox"); var caseSensitive = caseSwitch.prop("checked"); if(!caseSensitive) { input = input.toLowerCase(); } // ... }
为了使组件的元素与事件处理函数相关联,这段代码增加了对某个特定DOM上下文的依赖性。解决该问题的一种选择是将DOM查找方法移至某个分离的函数中,由它根据指定的上下文决定查找哪个组件。而更加常见的方式是采用面向对象的途径。(JavaScript本身支持函数式编程与面向对象4编程两种风格,它允许开发者根据任务需求自行选择最为适合的编程风格。)
因此我们可以重写组件的方法,让它通过某个实例追踪它的所有组件:
// widget.js function FilterWidget(contactList) { this.contacts = contactList; this.filterField = $('<input type="search" />'). insertBefore(contactList); this.caseSwitch = $('<input type="checkbox" />'); }
对API的这一改动虽然很小,影响却很大:我们现在不再通过调用createFilterWidget(…)方法,而是通过new FilterWidget(…)来初始化widget,它调用了方法的构造函数,并将上下文传递给一个新创建的对象(this)。为了强调new操作的必要性,按照约定,构造函数名称的首字母都是大写(这一点非常类似于其它语言中的类的命名方式)5。
当然,我们需要根据这个新的结构重新实现功能,首先得加入一个方法,它根据输入内容来隐藏联系人,它的实现和之前在onFilter方法中的实现基本相同:
// widget.js FilterWidget.prototype.filterContacts = function(value) { var names = this.contacts.find("li .p-name"); var self = this; names.each(function(i, node) { var el = $(node); var name = el.text(); var contact = el.closest(".h-card"); var match = startsWith(name, input, self.caseSensitive); if(match) { contact.show(); } else { container.hide(); } }); }
(这里定义的self变量是为了在each这个回调函数中也可以访问到this对象,因为在each函数中也有它自己的this变量,这样就不能够直接访问外部范围中的this对象了。通过在内部引用self对象,它就创建了一个闭包。)
注意filterContacts函数的实现有所变化了,它不再根据上下文查找DOM,而是简单地引用之前定义在构造函数中的元素。字符串匹配功能则被抽取成一个通用目的的函数,这也表示并非所有功能都必须成为某个对象的方法:
function startsWith(str, value, caseSensitive) { if(!caseSensitive) { str = str.toLowerCase(); value = value.toLowerCase(); } return str.indexOf(value) === 0; }
接下来我们将连接事件处理函数,否则这个方法是永远不会被触发的:
// widget.js function FilterWidget(contactList) { // ... this.filterField.on("keyup", this.onFilter); this.caseSwitch.on("change", this.onToggle); } FilterWidget.prototype.onFilter = function(ev) { var input = this.filterField.val(); this.filterContacts(input); }; FilterWidget.prototype.onToggle = function(ev) { this.caseSensitive = this.caseSwitch.prop("checked"); };
现在可以重新运行我们的测试了,它除了之前那些API的小改动之外,并不需要其它的任何调整。但是一个错误出现了,这是由于this对象并非我们所预计的对象。我们已经了解到事件处理函数调用时会将相应的DOM元素作为运行上下文,因此我们需要做出一些调整,使代码能够访问到组件实例。为了实现这一点,我们利用了闭包功能以重新映射执行上下文:
// widget.js function FilterWidget(contactList) { // ... var self = this; this.filterField.on("keyup", function(ev) { var handler = self.onFilter; return handler.call(self, ev); }); }
(call是一个内置的方法,它能够调用任何函数,并将任何传入的对象作为上下文,首个传入参数将对应该函数中的this对象。另一选择是apply方法,它能够接受一个隐式的arguments变量,以避免显式地引用单个的参数,它的形式是:handler.apply(self, arguments).6)
最终的结果是,我们的widget中的每个方法都有着清晰的并且封装良好的职责。
如果使用jQuery,那么现在的API看起来还不够优雅。我们可以添加一个轻量的封装,它提供了另一种对jQuery开发者来说感觉更加自然的API。
jQuery.fn.contactsFilter = function() { this.each(function(i, node) { new CONTACTSFILTER(node); }); return this; };
(在jQuery的插件指南中可以找到更详细的说明。)
这样一来,我们就可以使用jQuery(“ul.contacts”).contactsFilter()这种方式调用组件了。如果将这一方法定义在一个单独的层中,就可以保证我们不依赖于某些特定的系统,因为将来版本的实现也许会为其它不同的系统提供额外的API封装,甚至可能会决定移除对jQuery的依赖或选择替代品。(当然,在这个示例中,弃用jQuery也意味着我们将不得不重写代码内部实现的某些部分。)
希望本文能够表达出编写可维护的JavaScript组件的一些关键原则。当然,并且每个组件都要遵循这个模式,但这里所表现的一概念对于任何组件来说都提供了一些必要的核心功能。
进一步的增加或许要用到异步模块定义(AMD),它不仅改进了代码封装,而且使得模块之间的依赖更加清晰,这就允许你按需加载代码(例如通过RequireJS)。
此外,近来有一些激动人心的新特性正在开发中:下个版本的JavaScript(官方称为ECMAScript 6)将引入一个语言级别的模块系统,当然,和任何新特性一样,它是否能够被广泛接受要取决于浏览器的支持。类似的,Web Components是正在实现的一组浏览器API,它的目的是改善代码封装与可维护性,可以通过使用Polymer来感受一下其中的许多特性。但Web Components的进展如何还有待进一步观望。
对于单页面应用来说这篇规范并不太适用,因为在这种情况下服务端和客户端的角色会有很大的不同。不过对这种方式的对比已经超出了本文的范围。
或许你应该先编写测试方法。
可以使用JSLint以避免这种情况和其它一些常见问题的发生,在我们的代码库中就使用了JSLint Reporter。
JavaScript使用原型而不是类,主要区别在于,类总是以某些方式表现出“独特性”,而任意对象都可以作为原型,作为创建新实例的模板。对于本文来说,这一区别基本可以忽略。
当前流行版本的JavaScript引入了Object.create方法,作为“伪经典”语法的替代品。但原型继承的核心原则还是一样的。
可以使用jQuery.proxy方法将代码改写为this.filterField.on(“keyup”, $.proxy(self, “onFilter”))
以上是JavaScript組件開發指南模組化圖文詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!