如何開發(fā)屬于自己的框架
譯文對于大多數(shù)開發(fā)人員來說,從零開始構建一套框架聽起來似乎有些陌生甚至聞所未聞。這么干的家伙肯定是瘋了,對吧?既然市面上已經(jīng)堆滿了各式各樣的JavaScript框架,為什么我們還要費力搞出自己的一套東西?
我們原本希望尋找一套框架以幫助The Daily Mail網(wǎng)站構建起新的內(nèi)容管理系統(tǒng)。項目的主要目標在于保證編輯流程與文章中的所有元素實現(xiàn)深度交互(包括圖片、嵌入對象以及呼出對話框等等),并使其能夠支持拖拽操作、模擬化與自我管理等功能。
當下我們所能獲取到的所有框架都或多或少地以開發(fā)人員所定義的靜態(tài)用戶界面為設計基礎。而我們則既需要可編輯文本,又需要動態(tài)渲染UI元素。
Backbone的定位實在太低級了一些,它最多只能提供基本對象結構與消息收發(fā)機制。我們需要在此基礎上建立大量抽象關系才能滿足自身的實際需要,考慮到這一點、我們決定自己動手重新構建基礎。
我們還曾決定利用AngularJS框架來構建采用相對靜態(tài)UI的中小型瀏覽器應用程序。不過遺憾的是,AngularJS是一套徹頭徹尾的黑盒環(huán)境——它不會將任何便捷的API擴展或者能夠服務于我們創(chuàng)建對象的操作機制直接擺在臺面上——指令、控制器、服務皆是如此。再有,盡管AngularJS能夠在view與scope表達之間建立響應式連接,但卻不允許我們在不同模式下對這些響應式連接進行定義,因此任何一款中型應用程序都會變得像jQuery應用那樣充斥著事件監(jiān)聽器(listener)與回調(diào)機制。二者之間的惟一區(qū)別就是,AngularJS框架會利用watcher取代listener、我們所操作的也將是scope而非DOM。
經(jīng)過總結,我們理想中的框架需要滿足以下條件:
• 能夠以聲明方式實現(xiàn)應用程序開發(fā),并能為view帶來響應式綁定模式。
• 能夠在應用程序內(nèi)的不同模型之間建立響應式數(shù)據(jù)綁定,從而通過聲明而非指令方式對數(shù)據(jù)擴展加以管理。
• 在綁定結構中插入校驗與翻譯機制,這樣我們就能將view與數(shù)據(jù)模型加以對接、而不必再通過AngularJS進行查看。
• 對與DOM元素相對接的組件進行精確控制。
• View管理的靈活性允許開發(fā)者自動操作DOM變更,并在渲染比DOM操作效率更高時利用實例中的任意模板引擎對某些部分進行重新渲染。
• 擁有動態(tài)創(chuàng)建UI的能力。
• 有能力觸及數(shù)據(jù)響應背后的深層機制并精確控制view更新與數(shù)據(jù)流。
• 能夠?qū)υ摽蚣芴峁┑慕M件進行功能性擴展,并創(chuàng)建新的組件。
在現(xiàn)有解決方案中,我們實在找不到能夠滿足需求的選項。在這種情況下,我們決定以并行方式開發(fā)Milo,并將其作為應用程序的構建基礎。
為什么選擇Milo這個名稱?
之所以選擇Milo這個名稱,是由于Milo Minderbinder這位由Joseph Heller撰寫的《第二十二條軍規(guī)》一書中的戰(zhàn)爭商人。盡管是從軍營中的炊事工作起步,但他很快建立起了屬于自己的暴利貿(mào)易企業(yè),并將成果與每一個人“共享”。
作為一套框架,Milo具備模塊接駁機制(Binder),能夠?qū)OM元素與組件相對接(通過特殊的ml-bind屬性)。而且這套模塊接駁機制還允許我們在不同數(shù)據(jù)源之間建立實時響應式連接(Model與Data類組件正是這樣的數(shù)據(jù)源)。
巧合的是,Milo正好也是MaIL Online網(wǎng)站的縮寫,而且要不是為了給Mail Online打造獨特的運作環(huán)境、我們永遠也不會創(chuàng)建出這樣一套框架。
管理view
Binder
Milo當中的view由組件負責管理,而這些組件基本上可以算是JavaScript類的各項實例、分別管理與之對應的DOM元素。很多框架都會利用組件還作為UI元素管理概念,但其中最引人注目的無疑要數(shù)Ext JS。我們在很多場景中使用過Ext JS(我們要替換掉的遺留應用就是用它構建起來的),但同時也發(fā)現(xiàn)了兩種令人避之而惟恐不速的缺陷。
This is where binder comes in.首先,Ext JS讓我們無法輕松對標記加以管理。構建一套UI的惟一方式就是將所有組件配置放在一起進行分層嵌套。這種作法會給標記渲染帶來不必要的復雜性,開發(fā)人員也將失去應有的控制能力。我們需要一種能夠以內(nèi)聯(lián)方式手動制作HTML標記并創(chuàng)建組件的方法。
Binder會掃描我們的標記并從中查找ml-bind屬性,這樣它才能讓組件完成實例化并將其與對應元素相綁定。該屬性中包含著與對應組件相關的信息;其中可能包括組件 class、facet以及必須具備的組件名稱。
- <div ml-bind=”ComponentClass[facet1, facet2]:componentName”>
- Our milo component
- </div>
我們會在接下來的部分進一步討論facet,但目前讓我們先看看該如何獲取這項屬性值并利用一條正則表達式將其提取出來。
- var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
- var result = value.match(bindAttrRegex);
- // result is an array with
- // result[0] = ‘ComponentClass[facet1, facet2]:componentName’;
- // result[1] = ‘ComponentClass’;
- // result[2] = ‘facet1, facet2’;
- // result[3] = ‘componentName’;
有了這些信息,我們接下來要做的就是對整個ml-bind屬性進行遍歷、提取此類值并通過創(chuàng)建實例對各個元素加以管理。
- var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
- function binder(callback) {
- var scope = {};
- // we get all of the elements with the ml-bind attribute
- var els = document.querySelectorAll('[ml-bind]');
- Array.prototype.forEach.call(els, function(el) {
- var attrText = el.getAttribute('ml-bind');
- var result = attrText.match(bindAttrRegex);
- var className = result[1] || 'Component';
- var facets = result[2].split(',');
- var compName = results[3];
- // assuming we have a registry object of all our classes
- var comp = new classRegistry[className](el);
- comp.addFacets(facets);
- comp.name = compName;
- scope[compName] = comp;
- // we keep a reference to the component on the element
- el.___milo_component = comp;
- });
- callback(scope);
- }
- binder(function(scope){
- console.log(scope);
- });
所以只需要利用正則表達式與DOM遍歷,大家就可以利用自定義語法創(chuàng)建出符合特定業(yè)務邏輯與背景需求的迷你框架。只需少許代碼,我們已經(jīng)設置出一套能夠容納模塊化、自我管理組件的架構,并能夠隨意加以運用。我們還可以創(chuàng)建出便捷的聲明式語法、從而對HTML中的組件進行實例化及配置;不過與AngularJS不同,我們能夠根據(jù)實際需求對這些組件進行管理。
#p#
由職責驅(qū)動的設計方案
Ext JS令人不滿的第二大原因在于,它所采用的類結構在分層方面極具剛性且存在跳躍性,這使其很難與我們的組件類實現(xiàn)有機結合。我們希望編寫出一套全面的特性列表,一篇文章中可能包含的任何組件都能從中找到對應特性。舉例來說,某個組件具備可編輯特性,可以作為事件加以監(jiān)聽,甚至能夠成為拖拽目標或者本身就屬于拖拽對象。這還只是所需特性當中的一小部分。我們在初步名單中包含了大約十五種不同的功能類型,囊括了多數(shù)特定組件有可能用到的特性。
將這些特性整理成為某種分層式結構不僅僅令人頭痛,同時也會給我們對特定組件類的功能變更帶來嚴重局限(我們隨后投入大量精力處理這個問題)。有鑒于此,我們決定采取另一套更具靈活性的面向?qū)ο笤O計方案。
我們已經(jīng)了解過由職責驅(qū)動的設計方案。與最常見的類及其所包含數(shù)據(jù)特性定義模式相反,職責驅(qū)動設計更多將關注重點放在對象負責執(zhí)行的操作身上。由于我們需要處理的是復雜度頗高且無法預測的數(shù)據(jù)模型,因此這種方式無疑非常合適。這類解決方案還允許我們?nèi)蘸蟾鶕?jù)實際需要對細節(jié)加以調(diào)整。
在職責驅(qū)動設計當中,我們摒棄了“角色(Role)”這一關鍵性組成部分。一個角色指的是一系列相關責任的集合。在我們的項目當中,我們將角色設定為編輯、拖拽、拖拽區(qū)、可選或者事件等等。但大家要如何將這些角色體現(xiàn)在代碼當中?考慮到這一點,我們借鑒了裝飾者模式的作法。
裝飾者模式允許我們將特性添加到單一對象當中,無論靜態(tài)或者動態(tài),而且完全不會對同一類中其它對象的特性造成影響。盡管類特性的運行時操作在此次項目中并非必要,但我們對這種處理思路所提供的封裝方式很感興趣。Milo在具體實施過程中采取了混合型方式,即將被稱為facet的對象作為屬性附加到該組件實例當中。作為配置對象,facet會引用該組件、而組件則是facet的“持有者”。這種機制允許我們根據(jù)每個組件類對facet加以定制。
大家可以將facet視為高級、可配置混合類型,它們在持有者對象上擁有自己的命名空間甚至是自己的init方法——該方法需要被該facet子類所覆蓋。
- function Facet(owner, config) {
- this.name = this.constructor.name.toLowerCase();
- this.owner = owner;
- this.config = config || {};
- this.init.apply(this, arguments);
- }
- Facet.prototype.init = function Facet$init() {};
因此我們可以將這個示例Facet類劃入子類并為自己需要的每種特性類型創(chuàng)建特定facet。Milo預先內(nèi)置有多種不同類型的facet,例如DOM facet、負責提供能夠在其持有者組件元素上執(zhí)行的DOM功能集合,外加List與Item facet、二者能夠共同創(chuàng)建出重復組件列表。
這些facet隨后會被我們稱為的FacetedObject類匯聚到一起,這是一個抽象類、繼承自全部組件。FacetedObject還擁有一個名為createFacetedClass的類方法,能夠?qū)⑵渥陨韯澣胱宇惒⑺邪琭acets屬性的facet附加至該類。通過這種方式,當FacetedObject轉(zhuǎn)化為實例后就會接入其全部facet類,并可以通過迭代方式實現(xiàn)組件引導。
- function FacetedObject(facetsOptions /*, other init args */) {
- facetsOptions = facetsOptions ? _.clone(facetsOptions) : {};
- var thisClass = this.constructor
- , facets = {};
- if (! thisClass.prototype.facets)
- throw new Error('No facets defined');
- _.eachKey(this.facets, instantiateFacet, this, true);
- Object.defineProperties(this, facets);
- if (this.init)
- this.init.apply(this, arguments);
- function instantiateFacet(facetClass, fct) {
- var facetOpts = facetsOptions[fct];
- delete facetsOptions[fct];
- facets[fct] = {
- enumerable: false,
- value: new facetClass(this, facetOpts)
- };
- }
- }
- FacetedObject.createFacetedClass = function (name, facetsClasses) {
- var FacetedClass = _.createSubclass(this, name, true);
- _.extendProto(FacetedClass, {
- facets: facetsClasses
- });
- return FacetedClass;
- };
在Milo中,我們還創(chuàng)建出一個包含匹配createComponentClass類方法的基礎Component類、旨在進一步實現(xiàn)抽象化,不過其基本原則仍然保持不變。由于關鍵特性仍然由可配置facet負責管理,我們可以在一種聲明樣式內(nèi)創(chuàng)建多種不同組件類,而且完全不必編寫太多自定義代碼。下面來看如何在Milo當中使用一部分可以直接使用的facet。
- var Panel = Component.createComponentClass(‘Panel’, {
- dom: {
- cls: ‘my-panel’,
- tagName: ‘div’
- },
- events: {
- messages: {‘click’: onPanelClick}
- },
- drag: {messages: {...},
- drop: {messages: {...},
- container: undefined
- });
在這里,我們已經(jīng)創(chuàng)建出一個名為Panel的組件類且已經(jīng)與DOM功能方法相對接,它會在init上自動設置其CSS類、能夠監(jiān)聽DOM事件并在init上設置點擊處理程序、能夠作為拖拽對象也可以作為拖拽目標。作為最后一個facet,container負責確保該組件對自己的scope進行設置并擁有實際起效的子組件機制。
Scope
接下來的話題讓我們討論了很長一段時間,即附加至文檔的所有組件到底應該采用扁平化結構還是樹狀結構——在樹狀結構中,子分支只能接受來自父分支的訪問。
在某些情況下,我們肯定需要scope機制的介入,但其更多涉及到的是實施層而非框架層。舉例來說,我們擁有多套包含有圖片的圖片組。這些組能夠輕松追蹤其子圖片的當前狀態(tài),而無需涉及通用scope。
我們最終決定在文檔中建立一套組件scope樹狀結構。引入scope能讓很多工作變得更易于打理,也讓我們可以使用更多通用組件命名方式,但這種機制顯然需要加以管理。如果大家刪除了某個組件,則必須將其從父scope當中移除出去。如果大家對某個組件進行移動,則必須將其由原scope內(nèi)移除再添加至另一個scope當中。
事實上,scope是一種特殊的散列或者映射對象,scope當中容納的第一個子分支都擁有該對象的屬性。在Milo當中,scope存在于容器facet之上,而后者本身幾乎沒有任何功能性可言。不過scope對象卻擁有一系列不同類型的方法,能夠?qū)ψ陨磉M行操作與迭代。但為了避免命名空間沖突,這些方法在命名時都會以下劃線作為起始字符。
- var scope = myComponent.container.scope;
- scope._each(function(childComp) {
- // iterate each child component
- });
- // access a specific component on the scope
- var testComp = scope.testComp;
- // get the total number of child components
- var total = scope._length();
- // add a new component ot the scope
- scope._add(newComp);
#p#
消息收發(fā)——同步與異步
我們希望能在不同組件之間實現(xiàn)松散耦合,因此我們決定將消息收發(fā)功能附加到所有組件與facet之上。
消息機制實施工作的第一步在于建立起方法集合,旨在對訂閱者數(shù)組進行管理。方法與數(shù)組以混合方式存在于對象當中,并借此實現(xiàn)消息收發(fā)功能。
對于消息機制實施方案的第一步,我們姑且采用一套簡化版本,具體代碼如下所示:
- var messengerMixin = {
- initMessenger: initMessenger,
- on: on,
- off: off,
- postMessage: postMessage
- };
- function initMessenger() {
- this._subscribers = {};
- }
- function on(message, subscriber) {
- var msgSubscribers = this._subscribers[message] =
- this._subscribers[message] || [];
- if (msgSubscribers.indexOf(subscriber) == -1)
- msgSubscribers.push(subscriber);
- }
- function off(message, subscriber) {
- var msgSubscribers = this._subscribers[message];
- if (msgSubscribers) {
- if (subscriber)
- _.spliceItem(msgSubscribers, subscriber);
- else
- delete this._subscribers[message];
- }
- }
- function postMessage(message, data) {
- var msgSubscribers = this._subscribers[message];
- if (msgSubscribers)
- msgSubscribers.forEach(function(subscriber) {
- subscriber.call(this, message, data);
- });
- }
任何使用這種混合類型的對象都能利用postMessage方法自行實現(xiàn)消息收發(fā)(由對象本身或者任何其它代碼實現(xiàn)),而該代碼的訂閱功能可通過其它擁有相同名稱的方法實現(xiàn)開啟與關閉。
現(xiàn)在,我們的消息機制已經(jīng)具備以下特性:
• 附加外部消息來源(包括DOM消息、窗口消息、數(shù)據(jù)變更以及其它消息機制等)——例如由Events facet利用其通過Milo消息機制來顯示DOM事件。這項功能依靠單獨的MessageSource類及其子類實現(xiàn)。
• 定義定制化消息收發(fā)API,從而將消息與外部消息數(shù)據(jù)翻譯成內(nèi)部消息。例如Data facet就能利用其翻譯變更內(nèi)容,并將DOM事件輸入到數(shù)據(jù)變更事件(詳見下文model)當中。這項功能領先單獨的MessengerAPI類及其子類實現(xiàn)。
• 模式訂閱(利用正則表達式)例如model(如下所示)利用內(nèi)部模式訂閱以實現(xiàn)深層model變更訂閱。
• 利用以下語法作為訂閱機制的組成部分,進而實現(xiàn)背景信息(即訂閱者中的對應值)定義:
- component.on('stateready',
- { subscriber: func, context: context });
• 利用once方法創(chuàng)建只發(fā)送一次的訂閱機制。
• 在postMessage中將回調(diào)作為第三項參數(shù)進行傳遞(我們將postMessage當中的參數(shù)數(shù)量視為變量,但希望能用更為一致的消息收發(fā)API來代替這種變量參數(shù)機制)。
• 其它。
我們在開發(fā)消息機制時犯下了一個嚴重的設計錯誤,即所有消息都會被同步發(fā)出。由于JavaScript采用單線程機制,大量有待執(zhí)行的復雜消息操作會構成長隊列,而且很容易造成UI卡死。通過調(diào)整讓Milo擁有異步式消息發(fā)送機制并非難事(所有訂閱者利用setTimeout(subscriber, 0)接受其自身執(zhí)行block的調(diào)用),變更框架及應用程序的其余部分則相對更難——雖然大多數(shù)消息都能夠以異步方式發(fā)送,但也有一些必須采用同步發(fā)送機制(主要是那些內(nèi)部包含數(shù)據(jù)或者需要調(diào)用preventDefault的DOM事件)。在默認情況下,消息現(xiàn)在會以異步方式發(fā)送,但我們可以利用以下方式在消息發(fā)出時使其遵循同步機制:
- component.postMessageSync('mymessage', data);
或者在創(chuàng)建訂閱時:
- component.onSync('mymessage', function(msg, data) {
- //...
- });
我們作出的另一項設計決策是將消費機制方法顯示在使用它們的對象之上。最初,這些方法只是簡單被混雜在對象當中,但我們并不希望將所有方法都顯示出來、而且也不可能使用彼此獨立的消息機制。因此我們通過調(diào)整讓消息機制作為以Mixin抽象類為基礎的單獨類。
Mixin類允許我們將某個類的各項方法顯示在宿主對象之上,在這種情況下,當這些方法被調(diào)用時、其背景仍然是Mixin而非宿主對象。
事實證明這是一套非常便捷的處理機制——我們可以對需要顯示的方法進行全面控制,并根據(jù)需要變更其名稱。它還允許我們在同一對象上配備兩種消息機制,并將其用于model。
總體而言,Milo消息機制確實是一套穩(wěn)定可靠的解決方案,而且足以在瀏覽器內(nèi)以及Node.js中發(fā)揮作用。在構成我們這套產(chǎn)品內(nèi)容管理系統(tǒng)的數(shù)萬行代碼中,它一直擁有相當出色的使用頻率。
下期預告
在下一篇文章中,我們將探討Milo項目最實用但可能也是最復雜的部分。Milo方案不僅允許用戶安全而且深入地進行屬性訪問,同時也能夠從任意層級對事件訂閱作出調(diào)整。
我們將一同探索實施記錄,了解我們?nèi)绾卫胏onnector對象實現(xiàn)數(shù)據(jù)源的單向或者雙向綁定。
原文鏈接:http://code.tutsplus.com/articles/rolling-your-own-framework--cms-21810