模組化JavaScript元件開發指南

InfoQ - 邵思華發表於2015-05-16

現如今,雖然多數的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,因此這段程式碼會將contactsonFilter兩個變數暴露到全域性名稱空間內,初學者需要特別當心。不過我們可以自行修改這段程式碼,以避免變數汙染全域性名稱空間,由於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的進展如何還有待進一步觀望。

  1. 對於單頁面應用來說這篇規範並不太適用,因為在這種情況下服務端和客戶端的角色會有很大的不同。不過對這種方式的對比已經超出了本文的範圍。
  2. 或許你應該先編寫測試方法。
  3. 可以使用JSLint以避免這種情況和其它一些常見問題的發生,在我們的程式碼庫中就使用了JSLint Reporter。
  4. JavaScript使用原型而不是類,主要區別在於,類總是以某些方式表現出“獨特性”,而任意物件都可以作為原型,作為建立新例項的模板。對於本文來說,這一區別基本可以忽略。
  5. 當前流行版本的JavaScript引入了Object.create方法,作為“偽經典”語法的替代品。但原型繼承的核心原則還是一樣的。
  6. 可以使用jQuery.proxy方法將程式碼改寫為this.filterField.on(“keyup”, $.proxy(self, “onFilter”))

相關文章