ホームページ >ウェブフロントエンド >jsチュートリアル >JavaScript コンポーネント開発ガイド モジュラーグラフィックスとテキストの詳細な説明
現在、ほとんどの Web アプリケーションは JavaScript を大量に使用していますが、クライアント機能の焦点、堅牢性、保守性をどのように維持するかは依然として大きな課題です。
他の プログラミング言語 やシステムでは、関心事の分離や DRY などの基本原則が当然のこととして採用されていますが、ブラウザサイド アプリケーションの開発ではこれらの原則が無視されることがよくあります。
この現象の理由の 1 つは、JavaScript 言語自体の歴史にあります。JavaScript 言語は長い間、開発者から真剣な注目と扱いを得るのに苦労してきました。 より重要な理由は、これらの理由により、多くの場合、フロントエンド コードの高度な手続き化と相対的な構造の欠如が生じ、この直接コード呼び出し方法により呼び出しコストが削減され、コード呼び出しの複雑さが簡素化され、ブラウザーもこのために使用されます。この呼び出しメソッドは許可されています。しかし、この方法で実装されたコードはすぐに保守できなくなります。 この記事では、例を使用して単純なコンポーネント (ウィジェット) の進化プロセスを示し、それが大規模な非構造化コード ベースから再利用可能なコンポーネントにどのように進化したかを確認します。 連絡先のフィルター このサンプル コンポーネントは、名前で連絡先リストをフィルターするために使用されます。その最新の結果とその進化全体は、この 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 構造がサーバーによって生成された HTML コード、または他のコンポーネントによって生成された 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(); } }); }(別の
function という名前を参照します。これにより、通常コールバック関数は、匿名関数を定義するよりも管理が簡単です)
このテスト このコードは、必要な基本的な機能をすでに提供しています。テスト2を作成して機能の強化を続けます。この例では、使用しているツールは QUnit です。
我们首先编写一个最简单的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 中国語 Web サイトの他の関連記事を参照してください。