作為一名前端工程師,寫元件的能力至關重要。雖然javascript經常被人嘲笑是個小玩具,但是在一代代大牛的前仆後繼的努力下,漸漸的也摸索了一套元件的編寫方式。
下面我們來談談,在現有的知識體系下,如何很好的寫元件。
比如我們要實作這樣一個元件,就是一個輸入框裡面字數的計數。這個應該是個很簡單的需求。
我們來看看,下面的各種寫法。
為了更清楚的示範,下面全部使用jQuery作為基礎語言庫。
最簡陋的寫法
嗯 所謂的入門級寫法呢,就是完完全全的全局函數全局變量的寫法。(就我所知,現在好多外包還是這種寫法)
代碼如下:
<!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(){
return input.val().length;
}
//渲染元素
function render(){
var num = getNum();
//沒有字數的容器就建立一個
if ($('#J_input_count').length == 0) {
input.after('<span id="J_input_count"></span>');
};
$('#J_input_count').html(num+'個字');
}
//監聽事件
input.on('keyup',function(){
render();
});
//初始化,第一次渲染
render();
})
</script>
</head>
<body>
<input type="text" id="J_input"/>
</body>
</html>
這段代碼跑也是可以跑的,但是呢,各種變量混亂,沒有很好的隔離作用域,當頁面變的複雜的時候,會很難去維護。目前這種代碼基本是用不了的。當然少數的活動頁面可以簡單用用。
作用域隔離
讓我們對上面的代碼作些改動,使用單個變量模拟命名空間。
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應該都是私有的方法。但是其他代碼可以很随意的改動這些。當代碼量特别特别多的時候,很容易出現變量重複,或被修改的問題。
于是又出現了一種函數閉包的寫法:
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類:
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實作
我們看下使用方法:
//繼承超級父類,生成個子類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實作多重混入。詳見下面用法。
有了這個類的擴充,我們可以這麼編寫代碼了:
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類。其他元件編寫時都繼承它。
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方法。
于是我們可以這麼寫代碼:
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方法:
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。于是我們可以這麼寫代碼:
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,想想怎麼實作一個具有這套機制的類。
//輔組函數,擷取數組裡某個元素的索引 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擁有事件機制。隻需要這麼寫:
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後怎麼寫元件:
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的實作:
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)
}
}
}
})
結語
有了richbase,基本上元件開發就沒啥問題了。但是我們還是可以繼續深挖下去。
比如元件自動化加載渲染,局部重新整理,比如父子元件的嵌套,再比如雙向綁定,再比如實作ng-click這種風格的事件機制。
當然這些東西已經不屬于元件裡面的内容了。再進一步其實已經是一個架構了。實際上最近比較流行的react,ploymer還有我們的brix等等都是實作了這套東西。受限于篇幅,這個以後有空再寫篇文章詳細分析下。
鑒于有人跟我要完整代碼,其實上面都列出來了。好吧 那我就再整理下,放在github了包含具體的demo,請點 這裡 。不過僅僅作為了解使用最好不要用于生産環境。如果覺得有幫助就給我個star吧。
原文位址:
https://github.com/purplebamboo/blog/issues/16