现如今,虽然多数的 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 操作类库,也是出于同样的原因。)
这个 JavaScript 文件本身以及它所依赖的 jQuery 文件都会在 HTML 文件的底部进行引用。
接下来开始加入所需的功能,对于那些不符合这个新建的字段中的输入名称的联系人,这个组件会将它们隐藏起来:
// 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。在这个示例中,我们所使用的工具是 QUnit 。
我们首先编写一个最简单的 HTML 页面,它将作为我们的测试集的入口。当然我们还需要引用我们的代码以及相应的依赖项(在这个例子中就是 jQuery),这和我们之前创建的普通 HTML 页面的方式是一样的。
<!-- test/index.html --> <div id="qunit"></div> <div id="qunit-fixture"></div> <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 参数进行定义的,它只在这个模块内部起作用。
组件 API
虽然我们的代码已经实现了良好的结构化,不过这个组件会在启动时(例如在这段代码刚刚加载时)自动初始化。这导致了难以预测它的初始化时机,而且使得不同种类的,或是新创建的元素不能够动态地被(重新)初始化。
只需将现有的初始化代码封装在一个函数中,就可以简单地修正这一问题:
// 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 开发者来说感觉更加自然的 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”));
关于作者
Frederik Dohr刚刚开始职场生涯时,作为一个 web 开发者有些不情愿地摆弄着 TiddlyWiki,这个 wiki 有时也被称为最早的单页面应用。他在 BT 的开源创新团队 Osmosoft 与众多优秀的同事工作了多年之后离开伦敦而返回德国。目前就职于 innoQ。作为 web 开发来说,他始终坚持保持简洁,同时也产生了许多新的看法。
评论