javascript 元件化
作為一名前端工程師,寫元件的能力至關重要。雖然javascript經常被人嘲笑是個小玩具,但是在一代代大牛的前仆後繼的努力下,漸漸的也摸索了一套元件的編寫方式。
下面我們來談談,在現有的知識體系下,如何很好的寫元件。
比如我們要實現這樣一個元件,就是一個輸入框裡面字數的計數。這個應該是個很簡單的需求。
我們來看看,下面的各種寫法。
為了更清楚的演示,下面全部使用jQuery作為基礎語言庫。
最簡陋的寫法
嗯 所謂的入門級寫法呢,就是完完全全的全域性函式全域性變數的寫法。(就我所知,現在好多外包還是這種寫法)
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test</title>
<script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
<script>
$(function() {
var input = $('#J_input');
//用來獲取字數
function getNum(){
|
這段程式碼跑也是可以跑的,但是呢,各種變數混亂,沒有很好的隔離作用域,當頁面變的複雜的時候,會很難去維護。目前這種程式碼基本是用不了的。當然少數的活動頁面可以簡單用用。
作用域隔離
讓我們對上面的程式碼作些改動,使用單個變數模擬名稱空間。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
var textCount = { input:null, init:function(config){ this.input = $(config.id); this.bind(); //這邊範圍對應的物件,可以實現鏈式呼叫 return this; }, bind:function(){ var self = this; this.input.on('keyup',function(){ self.render(); }); }, getNum:function(){ return this.input.val().length; }, //渲染元素 render:function(){ var num = this.getNum(); if ($('#J_input_count').length == 0) { this.input.after('<span id="J_input_count"></span>'); }; $('#J_input_count').html(num+'個字'); } } $(function() { //在domready後呼叫 textCount.init({id:'#J_input'}).render(); }) |
這樣一改造,立馬變的清晰了很多,所有的功能都在一個變數下面。程式碼更清晰,並且有統一的入口呼叫方法。
但是還是有些瑕疵,這種寫法沒有私有的概念,比如上面的getNum,bind應該都是私有的方法。但是其他程式碼可以很隨意的改動這些。當代碼量特別特別多的時候,很容易出現變數重複,或被修改的問題。
於是又出現了一種函式閉包的寫法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
var TextCount = (function(){ //私有方法,外面將訪問不到 var _bind = function(that){ that.input.on('keyup',function(){ that.render(); }); } var _getNum = function(that){ return that.input.val().length; } var TextCountFun = function(config){ } TextCountFun.prototype.init = function(config) { this.input = $(config.id); _bind(this); return this; }; TextCountFun.prototype.render = function() { var num = _getNum(this); if ($('#J_input_count').length == 0) { this.input.after('<span id="J_input_count"></span>'); }; $('#J_input_count').html(num+'個字'); }; //返回建構函式 return TextCountFun; })(); $(function() { new TextCount().init({id:'#J_input'}).render(); }) |
這種寫法,把所有的東西都包在了一個自動執行的閉包裡面,所以不會受到外面的影響,並且只對外公開了TextCountFun建構函式,生成的物件只能訪問到init,render方法。這種寫法已經滿足絕大多數的需求了。事實上大部分的jQuery外掛都是這種寫法。
面向物件
上面的寫法已經可以滿足絕大多數需求了。
但是呢,當一個頁面特別複雜,當我們需要的元件越來越多,當我們需要做一套元件。僅僅用這個就不行了。首先的問題就是,這種寫法太靈活了,寫單個元件還可以。如果我們需要做一套風格相近的元件,而且是多個人同時在寫。那真的是噩夢。
在程式設計的圈子裡,面向物件一直是被認為最佳的編寫程式碼方式。比如java,就是因為把面向物件發揮到了極致,所以多個人寫出來的程式碼都很接近,維護也很方便。但是很不幸的是,javascript不支援class類的定義。但是我們可以模擬。
下面我們先實現個簡單的javascript類:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
var Class = (function() { var _mix = function(r, s) { for (var p in s) { if (s.hasOwnProperty(p)) { r[p] = s[p] } } } var _extend = function() { //開關 用來使生成原型時,不呼叫真正的構成流程init this.initPrototype = true var prototype = new this() this.initPrototype = false var items = Array.prototype.slice.call(arguments) || [] var item //支援混入多個屬性,並且支援{}也支援 Function while (item = items.shift()) { _mix(prototype, item.prototype || item) } // 這邊是返回的類,其實就是我們返回的子類 function SubClass() { if (!SubClass.initPrototype && this.init) this.init.apply(this, arguments)//呼叫init真正的建構函式 } // 賦值原型鏈,完成繼承 SubClass.prototype = prototype // 改變constructor引用 SubClass.prototype.constructor = SubClass // 為子類也新增extend方法 SubClass.extend = _extend return SubClass } //超級父類 var Class = function() {} //為超級父類新增extend方法 Class.extend = _extend return Class })() |
這是拿John Resig的class簡單修改了下。
這邊只是很簡陋的實現了類的繼承機制。如果對類的實現有興趣可以參考我另一篇文章javascript oo實現
我們看下使用方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
//繼承超級父類,生成個子類Animal,並且混入一些方法。這些方法會到Animal的原型上。 //另外這邊不僅支援混入{},還支援混入Function var Animal = Class.extend({ init:function(opts){ this.msg = opts.msg this.type = "animal" }, say:function(){ alert(this.msg+":i am a "+this.type) } }) //繼承Animal,並且混入一些方法 var Dog = Animal.extend({ init:function(opts){ //並未實現super方法,直接簡單使用父類原型呼叫即可 Animal.prototype.init.call(this,opts) //修改了type型別 this.type = "dog" } }) //new Animal({msg:'hello'}).say() new Dog({msg:'hi'}).say() |
使用很簡單,超級父類具有extend方法,可以繼承出一個子類。子類也具有extend方法。
這邊要強調的是,繼承的父類都是一個也就是單繼承。但是可以通過extend實現多重混入。詳見下面用法。
有了這個類的擴充套件,我們可以這麼編寫程式碼了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
var TextCount = Class.extend({ init:function(config){ this.input = $(config.id); this._bind(); this.render(); }, render:function() { var num = this._getNum(); if ($('#J_input_count').length == 0) { this.input.after('<span id="J_input_count"></span>'); }; $('#J_input_count').html(num+'個字'); }, _getNum:function(){ return this.input.val().length; }, _bind:function(){ var self = this; self.input.on('keyup',function(){ self.render(); }); } }) $(function() { new TextCount({ id:"#J_input" }); }) |
這邊可能還沒看見class的真正好處,不急我們繼續往下。
抽象出base
可以看到,我們的元件有些方法,是大部分元件都會有的。
- 比如init用來初始化屬性。
- 比如render用來處理渲染的邏輯。
- 比如bind用來處理事件的繫結。
當然這也是一種約定俗成的規範了。如果大家全部按照這種風格來寫程式碼,開發大規模元件庫就變得更加規範,相互之間配合也更容易。
這個時候面向物件的好處就來了,我們抽象出一個Base類。其他元件編寫時都繼承它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
var Base = Class.extend({ init:function(config){ //自動儲存配置項 this.__config = config this.bind() this.render() }, //可以使用get來獲取配置項 get:function(key){ return this.__config[key] }, //可以使用set來設定配置項 set:function(key,value){ this.__config[key] = value }, bind:function(){ }, render:function() { }, //定義銷燬的方法,一些收尾工作都應該在這裡 destroy:function(){ } }) |
base類主要把元件的一般性內容都提取了出來,這樣我們編寫元件時可以直接繼承base類,覆蓋裡面的bind和render方法。
於是我們可以這麼寫程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
var TextCount = Base.extend({ _getNum:function(){ return this.get('input').val().length; }, bind:function(){ var self = this; self.get('input').on('keyup',function(){ self.render(); }); }, render:function() { var num = this._getNum(); if ($('#J_input_count').length == 0) { this.get('input').after('<span id="J_input_count"></span>'); }; $('#J_input_count').html(num+'個字'); } }) $(function() { new TextCount({ //這邊直接傳input的節點了,因為屬性的賦值都是自動的。 input:$("#J_input") }); }) |
可以看到我們直接實現一些固定的方法,bind,render就行了。其他的base會自動處理(這裡只是簡單處理了配置屬性的賦值)。
事實上,這邊的init,bind,render就已經有了點生命週期的影子,但凡是元件都會具有這幾個階段,初始化,繫結事件,以及渲染。當然這邊還可以加一個destroy銷燬的方法,用來清理現場。
此外為了方便,這邊直接變成了傳遞input的節點。因為屬性賦值自動化了,一般來說這種情況下都是使用getter,setter來處理。這邊就不詳細展開了。
引入事件機制(觀察者模式)
有了base應該說我們編寫元件更加的規範化,體系化了。下面我們繼續深挖。
還是上面的那個例子,如果我們希望輸入字的時候超過5個字就彈出警告。該怎麼辦呢。
小白可能會說,那簡單啊直接改下bind方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var TextCount = Base.extend({ ... bind:function(){ var self = this; self.get('input').on('keyup',function(){ if(self._getNum() > 5){ alert('超過了5個字了。。。') } self.render(); }); }, ... }) |
的確也是一種方法,但是太low了,程式碼嚴重耦合。當這種需求特別特別多,程式碼會越來越亂。
這個時候就要引入事件機制,也就是經常說的觀察者模式。
注意這邊的事件機制跟平時的瀏覽器那些事件不是一回事,要分開來看。
什麼是觀察者模式呢,官方的解釋就不說了,直接拿這個例子來說。
想象一下base是個機器人會說話,他會一直監聽輸入的字數並且彙報出去(通知)。而你可以把耳朵湊上去,聽著他的彙報(監聽)。發現字數超過5個字了,你就做些操作。
所以這分為兩個部分,一個是通知,一個是監聽。
假設通知是 fire方法,監聽是on。於是我們可以這麼寫程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
var TextCount = Base.extend({ ... bind:function(){ var self = this; self.get('input').on('keyup',function(){ //通知,每當有輸入的時候,就報告出去。 self.fire('Text.input',self._getNum()) self.render(); }); }, ... }) $(function() { var t = new TextCount({ input:$("#J_input") }); //監聽這個輸入事件 t.on('Text.input',function(num){ //可以獲取到傳遞過來的值 if(num>5){ alert('超過了5個字了。。。') } }) }) |
fire用來觸發一個事件,可以傳遞資料。而on用來新增一個監聽。這樣元件裡面只負責把一些關鍵的事件丟擲來,至於具體的業務邏輯都可以新增監聽來實現。沒有事件的元件是不完整的。
下面我們看看怎麼實現這套事件機制。
我們首先拋開base,想想怎麼實現一個具有這套機制的類。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
//輔組函式,獲取數組裡某個元素的索引 index var _indexOf = function(array,key){ if (array === null) return -1 var i = 0, length = array.length for (; i < length; i++) if (array[i] === item) return i return -1 } var Event = Class.extend({ //新增監聽 on:function(key,listener){ //this.__events儲存所有的處理函式 if (!this.__events) { this.__events = {} } if (!this.__events[key]) { this.__events[key] = [] } if (_indexOf(this.__events,listener) === -1 && typeof listener === 'function') { this.__events[key].push(listener) } return this }, //觸發一個事件,也就是通知 fire:function(key){ if (!this.__events || !this.__events[key]) return var args = Array.prototype.slice.call(arguments, 1) || [] var listeners = this.__events[key] var i = 0 var l = listeners.length for (i; i < l; i++) { listeners[i].apply(this,args) } return this }, //取消監聽 off:function(key,listener){ if (!key && !listener) { this.__events = {} } //不傳監聽函式,就去掉當前key下面的所有的監聽函式 if (key && !listener) { delete this.__events[key] } if (key && listener) { var listeners = this.__events[key] var index = _indexOf(listeners, listener) (index > -1) && listeners.splice(index, 1) } return this; } }) var a = new Event() //新增監聽 test事件 a.on('test',function(msg){ alert(msg) }) //觸發 test事件 a.fire('test','我是第一次觸發') a.fire('test','我又觸發了') a.off('test') a.fire('test','你應該看不到我了') |
實現起來並不複雜,只要使用this.__events存下所有的監聽函式。在fire的時候去找到並且執行就行了。
這個時候面向物件的好處就來了,如果我們希望base擁有事件機制。只需要這麼寫:
1 2 3 4 5 6 7 8 9 10 11 12 |
var Base = Class.extend(Event,{ ... destroy:function(){ //去掉所有的事件監聽 this.off() } }) //於是可以 //var a = new Base() // a.on(xxx,fn) // // a.fire() |
是的只要extend的時候多混入一個Event,這樣Base或者它的子類生成的物件都會自動具有事件機制。
有了事件機制我們可以把元件內部很多狀態暴露出來,比如我們可以在set方法中丟擲一個事件,這樣每次屬性變更的時候我們都可以監聽到。
到這裡為止,我們的base類已經像模像樣了,具有了init,bind,render,destroy方法來表示元件的各個關鍵過程,並且具有了事件機制。基本上已經可以很好的來開發元件了。
更進一步,richbase
我們還可以繼續深挖。看看我們的base,還差些什麼。首先瀏覽器的事件監聽還很落後,需要使用者自己在bind裡面繫結,再然後現在的TextCount裡面還存在dom操作,也沒有自己的模板機制。這都是需要擴充套件的,於是我們在base的基礎上再繼承出一個richbase用來實現更完備的元件基類。
主要實現這些功能:
- 事件代理:不需要使用者自己去找dom元素繫結監聽,也不需要使用者去關心什麼時候銷燬。
- 模板渲染:使用者不需要覆蓋render方法,而是覆蓋實現setUp方法。可以通過在setUp裡面呼叫render來達到渲染對應html的目的。
- 單向繫結:通過setChuckdata方法,更新資料,同時會更新html內容,不再需要dom操作。
我們看下我們實現richbase後怎麼寫元件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
var TextCount = RichBase.extend({ //事件直接在這裡註冊,會代理到parentNode節點,parentNode節點在下面指定 EVENTS:{ //選擇器字串,支援所有jQuery風格的選擇器 'input':{ //註冊keyup事件 keyup:function(self,e){ //單向繫結,修改資料直接更新對應模板 self.setChuckdata('count',self._getNum()) } } }, //指定當前元件的模板 template:'<span id="J_input_count"><%= count %>個字</span>', //私有方法 _getNum:function(){ return this.get('input').val().length || 0 }, //覆蓋實現setUp方法,所有邏輯寫在這裡。最後可以使用render來決定需不需要渲染模板 //模板渲染後會append到parentNode節點下面,如果未指定,會append到document.body setUp:function(){ var self = this; var input = this.get('parentNode').find('#J_input') self.set('input',input) var num = this._getNum() //賦值資料,渲染模板,選用。有的元件沒有對應的模板就可以不呼叫這步。 self.render({ count:num }) } }) $(function() { //傳入parentNode節點,元件會掛載到這個節點上。所有事件都會代理到這個上面。 new TextCount({ parentNode:$("#J_test_container") }); }) /**對應的html,做了些修改,主要為了加上parentNode,這邊就是J_test_container <div id="J_test_container"> <input type="text" id="J_input"/> </div> */ |
看下上面的用法,可以看到變得更簡單清晰了:
- 事件不需要自己繫結,直接註冊在EVENTS屬性上。程式會自動將事件代理到parentNode上。
- 引入了模板機制,使用template規定元件的模板,然後在setUp裡面使用render(data)的方式渲染模板,程式會自動幫你append到parentNode下面。
- 單向繫結,無需操作dom,後面要改動內容,不需要操作dom,只需要呼叫setChuckdata(key,新的值),選擇性的更新某個資料,相應的html會自動重新渲染。
下面我們看下richebase的實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
var RichBase = Base.extend({ EVENTS:{}, template:'', init:function(config){ //儲存配置項 this.__config = config //解析代理事件 this._delegateEvent() this.setUp() }, //迴圈遍歷EVENTS,使用jQuery的delegate代理到parentNode _delegateEvent:function(){ var self = this var events = this.EVENTS || {} var eventObjs,fn,select,type var parentNode = this.get('parentNode') || $(document.body) for (select in events) { eventObjs = events[select] for (type in eventObjs) { fn = eventObjs[type] parentNode.delegate(select,type,function(e){ fn.call(null,self,e) }) } } }, //支援underscore的極簡模板語法 //用來渲染模板,這邊是抄的underscore的。非常簡單的模板引擎,支援原生的js語法 _parseTemplate:function(str,data){ /** * http://ejohn.org/blog/javascript-micro-templating/ * https://github.com/jashkenas/underscore/blob/0.1.0/underscore.js#L399 */ var fn = new Function('obj', 'var p=[],print=function(){p.push.apply(p,arguments);};' + 'with(obj){p.push(\'' + str .replace(/[\r\t\n]/g, " ") .split("<%").join("\t") .replace(/((^|%>)[^\t]*)'/g, "$1\r") .replace(/\t=(.*?)%>/g, "',$1,'") .split("\t").join("');") .split("%>").join("p.push('") .split("\r").join("\\'") + "');}return p.join('');") return data ? fn(data) : fn }, //提供給子類覆蓋實現 setUp:function(){ this.render() }, //用來實現重新整理,只需要傳入之前render時的資料裡的key還有更新值,就可以自動重新整理模板 setChuckdata:function(key,value){ var self = this var data = self.get('__renderData') //更新對應的值 data[key] = value if (!this.template) return; //重新渲染 var newHtmlNode = $(self._parseTemplate(this.template,data)) //拿到儲存的渲染後的節點 var currentNode = self.get('__currentNode') if (!currentNode) return; //替換內容 currentNode.replaceWith(newHtmlNode) self.set('__currentNode',newHtmlNode) }, //使用data來渲染模板並且append到parentNode下面 render:function(data){ var self = this //先儲存起來渲染的data,方便後面setChuckdata獲取使用 self.set('__renderData',data) if (!this.template) return; //使用_parseTemplate解析渲染模板生成html //子類可以覆蓋這個方法使用其他的模板引擎解析 var html = self._parseTemplate(this.template,data) var parentNode = this.get('parentNode') || $(document.body) var currentNode = $(html) //儲存下來留待後面的區域重新整理 //儲存起來,方便後面setChuckdata獲取使用 self.set('__currentNode',currentNode) parentNode.append(currentNode) }, destroy:function(){ var self = this //去掉自身的事件監聽 self.off() //刪除渲染好的dom節點 self.get('__currentNode').remove() //去掉繫結的代理事件 var events = self.EVENTS || {} var eventObjs,fn,select,type var parentNode = self.get('parentNode') for (select in events) { eventObjs = events[select] for (type in eventObjs) { fn = eventObjs[type] parentNode.undelegate(select,type,fn) } } } }) |
主要做了兩件事,一個就是事件的解析跟代理,全部代理到parentNode上面。另外就是把render抽出來,使用者只需要實現setUp方法。如果需要模板支援就在setUp裡面呼叫render來渲染模板,並且可以通過setChuckdata來重新整理模板,實現單向繫結。
結語
有了richbase,基本上元件開發就沒啥問題了。但是我們還是可以繼續深挖下去。
比如元件自動化載入渲染,區域性重新整理,比如父子元件的巢狀,再比如雙向繫結,再比如實現ng-click這種風格的事件機制。
當然這些東西已經不屬於元件裡面的內容了。再進一步其實已經是一個框架了。實際上最近比較流行的react,ploymer還有我們的brix等等都是實現了這套東西。受限於篇幅,這個以後有空再寫篇文章詳細分析下。
原文 http://purplebamboo.github.io/2015/03/16/javascript-component/
https://github.com/purplebamboo/demo-richbase/tree/master/example