Constructor Function:JavaScript 設計模式的返璞歸真

JavaScript 是個瘋狂的語言,語言本身非常隨便自由,從不試圖引導程式設計師遵守特定的模式。我參與的 Gaia Project 無論是商業需求或是架構上也是很瘋狂的;試圖用 Web 湊出一個手機介面以及功能,但同時間新需求又來的又快又急。在一開始的幾年,我們一直沒有機會慢下腳步,想想每個人喜歡用的 module pattern、singleton pattern 等等是不是符合未來的需求。但最近一年以來,幾位同事終於慢慢的意識到,使用 JavaScript 一開始發明時提供的 constructor function pattern,才是最安全,符合最多 use case 的作法。以下整理我認為的幾個關鍵的優點,供各位參考:

優點 1:沒有封裝(encapsulation)

許多人認為沒有封裝,曝露實作細節是一個缺點,但我認為不做任何封裝的益處比壞處更多。直覺上大家會認為 JavaScript 元件需要封裝,是因為大家習慣使用黑盒子式的 library (例如 jQuery)。jQuery 作為一個灑到外面給大眾使用的函式庫,隱藏細節來保留未來升級的空間是有必要的。但在自己或是協作專案中,藉由封裝來防禦這樣的「使用者」是沒有必要的——大不了就是開一張票給同事請他改掉。若有試著閱讀過 jQuery 程式碼和 build system 的朋友,也會發現,jQuery 專案本身各 function 在它內部的 scope 也是攤平,獨立測試的。與其用封裝作防禦,不如用完整的測試來定義 public method 的行為;這就要談下一個優點。

優點 2:可測試性

Test-Driven Development 似乎非常流行,但我們得承認不是所有的元件都有清晰的需求與憑空定義的 interface,可以先開完會、冥想一下,然後寫完測試,凍結測試再開工。元件的形狀與介面勢必是個隨著需求或是撰寫過程發現的 edge case 而演化出來的。在 Gaia 開發的歷史一開始是不要求單元測試(unit test)的;許多使用封裝模式設計的元件隨著需求慢慢的長大,長到最後發現無法切出可測試的獨立單元。如果使用 constructor function,就可以保證永遠找的到可測試的單元:「單元」可以是掛在 prototype 上的一個方法,也可以直接測試一個 instance。我個人的習慣是測試 instance,原因如下。

優點 3:各 instance 與 prototype 預設是分離的

Gaia 開發的歷史,遭遇的另一個問題是,許多元件都被設計成在環境中只會被使用一次。這樣的元件,勢必沒有可供單元測試項目獨立使用的 instance。當時最後使用的醜陋解法是確定測試時會改變的 state property,在每個測試的 teardown() 將它重設,但更絕望的是有些元件的 state 根本就封裝在自己的 scope 內,這就完全沒救而且嚴重的傷害的我們的測試涵蓋率。分析哪個 property 是 state 也一個手動而且容易出錯的過程。若當初使用 constructor function,每個 instance 就可以是用完即丟的測試單元,另外,console.log(instance) (或是 JSON.stringify(instance))可以直接印出 instance 上面的所有被設定的 property,跟它的 prototype 上的 property 或是 method 完全切開。

但在這邊要注意的是每個 instance 不會真的用完即丟,尤其是和 DOM API 有互動的元件。我們最後定義了一組通用的 method 名稱,要求元件都要有 stop() method 讓 instance 把自己從 DOM 上分離下來。

優點 4:「天然的感覺」

懂 JavaScript 的 prototype inheritance 老實說就跟日文的五段動詞一樣,大家學的時候都知道好像要學到那邊才算學會了什麼,但是在那之前總是卡一堆人(苦笑)。Prototype inheritance(或是簡單的,constructor 與 instance 的關係)在學習曲線上的問題是看起來沒有實際的例子可以參考,object 內部的繼承關聯也沒有被 JavaScript Engine 暴露出來。但這件事情在 ECMAScript 5 之後已經有了改善;我們現在有 Object.create()Object.getPrototypeOf(),還有原本就有的 constructor 屬性與 instanceof 運算子。要找實際的例子就更令人例外了:其實大家每天使用的 DOM API 都只是某個 constructor 的 instance,本身沒有 method,執行的時候其實是執行它的 prototype 上的 method;不相信的話,打開 console 看看 Object.getPrototypeOf(navigator) 是回傳哪個 object,或是 navigator.constructornavigator.constructor.prototype 是什麼。

(玩完之後你會發現 Object.getPrototypeOf(navigator)navigator.constructor.prototype 都會指到一個名為 NavigatorPrototype 的 object,navigator.constructor 會是一個 constructor function。不過執行它不會得到新的 navigator object 而是會 throw。你也不能 new window.constructor() 或是 new Window() 得到另一個 window object。)

總之,無論元件們最後是要從相依性來層層呼叫 new 建立出來,還是偽裝成 DOM API 掛在 window 或是 navigator 底下,實際運作的 object 都應該是 instance 而不是 object dict,因為你所仰賴的 DOM API 就是這樣實作的。從這裡出發也更容易看出與操作 this 關鍵字所指向的 object,進而寫出乾淨的程式碼(例如實作 EventListener interface)。這是我所謂「天然的感覺」。

結語

以上大略是我們選擇 constructor function 建構元件的理由。當然,沒有任何技術分享或是 framework 是適用所有專案的,請保持求真的精神,尋找您自己的真理。


P.S. 圖片跟這篇文章真的沒有關係。

社運訊息網絡與網路自由

Facebook: Page not found

所有自由議題都是相連的,他們在發展的過程互相影響,直到今日,在實際的運作面,他們也會互相碰觸,相輔相成。

陳為廷分享這篇新聞《張志軍來台 網路也戒嚴?在 Facebook 上說

這真的不尋常。昨天本來想看一下中強律師的評論,搜尋發現找不到。

才想到,早上就聽他說在房內被破門前,臉書就莫名被停權了。

加上難攻、和李茂生、沃草先後被限制權限。這絕對不是單一事件。

新聞和評論的臆測成分都很重,非常有可能這些帳號都只是被惡意檢舉才被限制發文的,而不是政府與 Facebook 有何私下的協議。但這個現象又再度驗證了,我們逝去的,去中心化、透明的網路有多麼的重要。有沒有人和他們說他們真正需要的是海盜灣等級的架站、技術,甚至是防禦能力,永不從網路上下架,除非政府從 ISP 端建立防火牆?有沒有人跟他們說,「Facebook 社運」模式(源自於茉莉花革命的 Twitter 社運模式)在動員和擴散上雖然有效,這些工具也是中心化架構的致命弱點?有沒有人帶著上一代網路人的慚愧,和他們說,這是我們所想像的網路,但對不起,因為我們沒有做到,所以你的訊息才會被這樣被封殺?

我一直在私下的管道在 Mozilla 內部說,Open Web 作為組織的任務,勢必代表立場必須隨著時代更新,同意更多的概念是確保網路自由的基石:網路中立性、去中心化、公開規格硬體(Open source hardware)、反言論審查……。立場也不該隨著國家與地區,為了方便而扭曲。這些訊息都是可以在我們在產品上妥協的同時發展,而不是縮限觀點來自圓其說現在的產品。

如果不做這些事情,做低階手機讓接下來的十億人能夠更容易上網,最後到底可以幹嘛?

Use Promise, and what to watch out if you don’t

If you do know Promise, consider the following code; do you know the order of the resulting log? (answered below)

var p = new Promise(function(resolve, reject) {
  console.log(1);
  resolve();
})
p.then(function() {
  console.log(2);
});
console.log(3);
setTimeout(function() {
  console.log(4);
});
p.then(function() {
  console.log(5);
});
console.log(6);
setTimeout(function() {
  console.log(7);
});
console.log(8);

The Promise interface

The Promise interface is one of the few generic interfaces that graduated from being a JavaScript library to be a Web platform API (The other being the JSON interface.) You already know about it if you have heard of Q, or Future, or jQuery.Deferred. They are similar, if not identical, things under different names.

Promise offers better asynchronous control to JavaScript code. It offers a chain-able interface, where you could chain your failure and success callbacks when the Promise instance is “rejected” or “resolved”. Any asynchronous call can be easily wrapped into a Promise instance by calling the actual interface inside the synchronous callback function passed when constructing the instance.

The ability to chain the calls might not be a reason appeal enough for the switch; what I find indispensable is the ability of Promise.all(); it manages all the Promise instances on your behalf and “resolves” the returned promise when all passed instances are resolved. It’s great if you want to run multiple asynchronous action in parallel (loading files, querying databases) and do your things only if everything have returned. (The other utility being Promise.race(), however I’ve not found a use case for myself yet.)

Keep in mind there is one caveat: compare to EventTarget callbacks (i.e. event handlers), this in all Promise callbacks are always window. You should wrap your own function in bind() for specific context.

The not-so-great alternatives

Before the Promise interface assume it’s throne in the Kingdom of Asynchronous Control, there are a few alternatives.

One being the DOMRequest interface. It feels “webby” because it’s inherited from the infamous EventTarget interface. If you have ever add a event listener to a HTML element, you have already worked with EventTarget. A lot of JavaScript developers (or jQuery developers) don’t work with EventTarget interface directly because they use jQuery, which absorb the verboseness of the interface (and difference between browser implementations). DOMRequest, being an asynchronous control interface simply dispatches success and error events, is inherently verbose, thus, unpopular. For example, you may find yourself fighting with DOMRequest interface if you want to do things with IndexedDB.

Another terrible issue with DOMRequest is that it’s usage is entirely reserved for native code, i.e. you can not new DOMRequest() and return the instance for the method of your JavaScript library. (likewise, your JavaScript function cannot inherit EventTarget either, which is the reason people turned to EventEmitter, or hopelessly dispatch custom event on the window object. That also means to mock the APIs inheriting EventTarget and/or returning DOMRequests, you must mock them too.)

Unfortunately, given the B2G project (Firefox OS) was launched back in 2011, many of the Web API methods return DOMRequest, and new methods of these APIs will continue to return DOMRequest for consistency.

The other alternative would be rolling your own implementation of generic asynchronous code. In the Gaia codebase (the front-end system UIs and preload web apps for B2G), there are tons of example because just like many other places in Mozilla, we are infected with Not-Invented-Here syndrome. The practices shoot us in the foot because what thought to be easily done is actually hard to done right. For example, supposedly you have the following function:

function loadSomething(id, callback) {
    if (isThere(id)) {
      getSomething(id, callback);

      return;
    }

    var xhr = new XMLHttpRequest();
    ...
    xhr.onloadend = function() {
      registerSomething(id, xhr.response);
      callback(xhr.response);
    };
    ...
}

To the naïve eyes there is nothing wrong with it, but if you look closely enough you will realize this function does not return the callback asynchronously every time. If I want to use it:

loadSomething(id, function(data) {
  console.log(1, data);
}); 
console.log(2);

The timing of 1 is non-deterministic; it might return before 2, or after. This creates Schrödinger bugs and races that will be hard to reproduce, and fix.

You might think a simple solution to the problem above would be simply wrap the third line in setTimeout(). This did solve the problem but it comes with issues of its own, not to mention it further contribute to the complexity of the code. Wrap the entire function, instead, in a Promise instance, guarantees the callbacks runs asynchronously even if you have the data cached.

(Keep in mind that the example above have lots of detail stripped; good luck finding the same pattern when someone else hides it in a 500-line function between 10 callbacks.)

Not-Invented-Here syndrome also contribute to other issues, like every other software project; more code means more bugs, and longer overhead for other engineers to pick up.

Conclusion

In the B2G project, we want to figure out what’s needed for the Web to be considered a trustworthy application platform. The focus has been enabling hardware access for web applications (however sadly many of the APIs was then restricted to packaged apps because of their proprietary nature and security model), yet I think we should be putting more focus on advancing common JavaScript interfaces like Promise. I can’t say for sure that every innovation nowadays are valid solutions to the problems. However, as the saying goes, the first step toward fixing a problem is to admit there is one. Without advances in this area, browser as an application runtime will be left as-is, fill with legacies for its document reader era and force developers to load common libraries to shim it. It would be “a Web with kilobytes of jquery.js overhead.”, one smart man once told me.

(That’s one reason I kept mention EventTarget v.s. EventEmitter in this post: contrary to Promise v.s. DOMRequest, the EventEmitter use case have not yet been fulfilled by the platform implementations.)


The answer to the question at the beginning is: 1, 3, 6, 8, 2, 5, 4, 7. Since all the callbacks are asynchronous except (1), only (1) happens before (3), (6), and (8). Promise callbacks (2) and (5) are run asynchronous and they return before setTimeouts.