前言
套用上篇文章向zepto.js學習如何手動觸發DOM事件 的開頭???
前端在最近幾年實在火爆異常,vue、react、angular各路框架層出不窮,我們們要是不知道個雙向資料繫結,不曉得啥是虛擬DOM,也許就被鄙視了。火熱的背後往往也是無盡的浮躁,學習這些先進流行的類庫或者框架可以讓我們走的更快,但是靜下心來回歸基礎,把基石打牢固,卻可以讓我們走的更穩,更遠。
最近一直在看zepto的原始碼,希望通過學習它掌握一些框架設計的技巧,也將很久不再拾起的js基礎重新溫習鞏固一遍。如果你對這個系列感興趣,歡迎點選watch
,隨時關注動態。這篇文章主要想說一下zepto中事件模組(event.js)的新增事件on
以及移除事件off
實現原理,中間會詳細地講解涉及到的細節方面。
如果你想看event.js全文翻譯版本,請點選這裡檢視
說在前面
在沒有vue和react,甚至angular都沒怎麼接觸的刀耕火種的時代,jQuery或者zepto是我們手中的利器,是刀刃,他讓我們遊刃有餘地開發出相容性好的漂亮的網頁,我們膜拜並感嘆作者帶來的便利,沉浸其中,無法自拔。
但是用了這麼久的zepto你知道這樣寫程式碼
$('.list').on('click', 'li', function (e) {
console.log($(this).html())
})複製程式碼
是怎麼實現事件委託的嗎?為啥此時的this
就是你點中的li
呢?
平常我們可能還會這樣寫。
$('.list li').bind('click', function () {})
$('.list').delegate('li', 'click', function () {})
$('.list li').live('click', function () {})
$('.list li').click(function () {})複製程式碼
寫法有點多,也許你還有其他的寫法,那麼
on
bind
delegate
live
click()
這些新增事件的形式,有什麼區別,內部之間又有什麼聯絡呢?
相信你在面試過程中也遇到過類似的問題(看完這邊文章,你可以知道答案的噢?
)?
接下來我們從原始碼的角度一步步去探究其內部實現的原理。
一切從on
開始
為什麼選擇從
on
新增事件的方式開始說起,原因在於其他寫法幾乎都是on
衍生出來的,明白了on
的實現原理,其他的也就差不多那麼回事了。
祭出一張畫了好久的圖
上面大概是zepto中on
形式註冊事件的大致流程,好啦開始看原始碼啦,首先是on函式,它主要做的事情是註冊事件前的引數處理,真正新增事件是內部函式add。
$.fn.on = function (event, selector, data, callback, one) {
// 第一段
var autoRemove, delegator, $this = this
if (event && !isString(event)) {
$.each(event, function (type, fn) {
$this.on(type, selector, data, fn, one)
})
return $this
}
// 第二段
if (!isString(selector) && !isFunction(callback) && callback !== false)
callback = data, data = selector, selector = undefined
if (callback === undefined || data === false)
callback = data, data = undefined
if (callback === false) callback = returnFalse
// 以上為針對不同的呼叫形式,做好引數處理
// 第三段
return $this.each(function (_, element) {
// 處理事件只有一次生效的情況
if (one) autoRemove = function (e) {
remove(element, e.type, callback)
return callback.apply(this, arguments)
}
// 新增事件委託處理函式
if (selector) delegator = function (e) {
var evt, match = $(e.target).closest(selector, element).get(0)
if (match && match !== element) {
evt = $.extend(createProxy(e), { currentTarget: match, liveFired: element })
return (autoRemove || callback).apply(match, [evt].concat(slice.call(arguments, 1)))
}
}
// 使用add內部函式真正去給選中的元素註冊事件
add(element, event, callback, data, selector, delegator || autoRemove)
})
}複製程式碼
直接看到這麼一大坨的程式碼不易於理解,我們分段進行閱讀。
第一段
var autoRemove, delegator, $this = this
if (event && !isString(event)) {
$.each(event, function (type, fn) {
$this.on(type, selector, data, fn, one)
})
return $this
}複製程式碼
這段程式碼主要是為了處理下面這種呼叫形式。
$('.list li').on({
click: function () {
console.log($(this).html())
},
mouseover: function () {
$(this).css('backgroundColor', 'red')
},
mouseout: function () {
$(this).css('backgroundColor', 'green')
}
})複製程式碼
這種寫法我們平時寫的比較少一點,但是確實是支援的。而zepto的處理方式則是迴圈呼叫on
方法,以key
為事件名,val
為事件處理函式。
在開始第二段程式碼閱讀前,我們先回顧一下,平時經常使用on
來註冊事件的寫法一般有哪些
// 這種我們使用的也許最多了
on(type, function(e){ ... })
// 可以預先新增資料data,然後在回撥函式中使用e.data來使用新增的資料
on(type, data, function(e){ ... })
// 事件代理形式
on(type, [selector], function(e){ ... })
// 當然事件代理的形式也可以預先新增data
on(type, [selector], data, function(e){ ... })
// 當然也可以只讓事件只有一次起效
on(type, [selector], data, function (e) { ... }, true)複製程式碼
還會有其他的寫法,但是常見的可能就是這些,第二段程式碼就是處理這些引數以讓後續的事件正確新增。
第二段
// selector不是字串形式,callback也不是函式
if (!isString(selector) && !isFunction(callback) && callback !== false)
callback = data, data = selector, selector = undefined
// 處理data沒有傳或者傳了函式
if (callback === undefined || data === false)
callback = data, data = undefined
// callback可以傳false值,將其轉換為returnFalse函式
if (callback === false) callback = returnFalse複製程式碼
三個if語句很好的處理了多種使用情況的引數處理。也許直接看不能知曉到底是如何做到的,可以試試每種使用情況都代入其中,找尋其是如何相容的。
接下來我們第三段
這段函式做了非常重要的兩件事
- 處理one傳入為true,事件只觸發一次的場景
- 處理傳入了selector,進行事件代理處理函式開發
我們一件件看它如何實現。
if (one) autoRemove = function (e) {
remove(element, e.type, callback)
return callback.apply(this, arguments)
}複製程式碼
內部用了一個remove
函式,這裡先不做解析,只要知道他就是移除事件的函式就可以,當移除事件的時候,再執行了傳進來的回撥函式。進而實現只呼叫一次的效果。
那麼事件代理又是怎麼實現咧?
回想一下平常自己是怎麼寫事件代理的,一般是利用事件冒泡(當然也可以使用事件捕獲)的性質,將子元素的事件委託到祖先元素身上,不僅可以實現事件的動態性,還可以減少事件總數,提高效能。
舉個例子
我們把原本要新增到li上的事件委託到父元素ul上。
<ul class="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>複製程式碼
let $list = document.querySelector('.list')
$list.addEventListener('click', function (e) {
e = e || window.event
let target = e.target || e.srcElement
if (target.tagName.toLowerCase() === 'li') {
target.style.background = 'red'
}
}, false)複製程式碼
回到第三段
if (selector) delegator = function (e) {
// 這裡用了closest函式,查詢到最先符合selector條件的元素
var evt, match = $(e.target).closest(selector, element).get(0)
// 查詢到的最近的符合selector條件的節點不能是element元素
if (match && match !== element) {
// 然後將match節點和element節點,擴充套件到事件物件上去
evt = $.extend(createProxy(e), { currentTarget: match, liveFired: element })
// 最後便是執行回撥函式
return (autoRemove || callback).apply(match, [evt].concat(slice.call(arguments, 1)))
}
}複製程式碼
zepto中實現事件代理的基本原理是:以當前目標元素e.target
為起點向上查詢到最先符合selector
選擇器規則的元素,然後擴充套件了事件物件,新增了一些屬性,最後以找到的match元素作為回撥函式的內部this
作用域,並將擴充套件的事件物件作為回撥函式的第一個引數傳進去執行。
這裡需要知道.closest(...)
api的具體使用,如果你不太熟悉,請點選這裡檢視
說道這裡,事件還沒有新增啊!到底在哪裡新增的呢,on函式的最後一句,便是要進入事件新增了。
add(element, event, callback, data, selector, delegator || autoRemove)複製程式碼
引數處理完,開始真正的給元素新增事件了
zepto的內部真正給元素新增事件的地方在add函式。
function add(element, events, fn, data, selector, delegator, capture) {
var id = zid(element),
set = (handlers[id] || (handlers[id] = []))
events.split(/\s/).forEach(function (event) {
if (event == 'ready') return $(document).ready(fn)
var handler = parse(event)
handler.fn = fn
handler.sel = selector
// emulate mouseenter, mouseleave
if (handler.e in hover) fn = function (e) {
var related = e.relatedTarget
if (!related || (related !== this && !$.contains(this, related)))
return handler.fn.apply(this, arguments)
}
handler.del = delegator
var callback = delegator || fn
handler.proxy = function (e) {
e = compatible(e)
if (e.isImmediatePropagationStopped()) return
e.data = data
var result = callback.apply(element, e._args == undefined ? [e] : [e].concat(e._args))
if (result === false) e.preventDefault(), e.stopPropagation()
return result
}
handler.i = set.length
set.push(handler)
if ('addEventListener' in element)
element.addEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))
})
}複製程式碼
我的神,又是這麼長長長長的一大坨,人艱不拆,看著心累啊啊啊啊!!!
不過不用急,只要一步步去看,最終肯定可以看懂的。
開頭有一句話
var id = zid(element)複製程式碼
function zid(element) {
return element._zid || (element._zid = _zid++)
}複製程式碼
zepto中會給新增事件的元素身上加一個唯一的標誌,_zid從1開始不斷往上遞增。後面的事件移除函式都是基於這個id來和元素建立關聯的。
// 程式碼初始地方定義
var handlers = {},
set = (handlers[id] || (handlers[id] = []))複製程式碼
handlers
便是事件緩衝池,以數字0, 1, 2, 3...儲存著一個個元素的事件處理程式。來看看handlers長啥樣。
html
<ul class="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>複製程式碼
javascript
$('.list').on('click', 'li', '', function (e) {
console.log(e)
}, true)複製程式碼
以上截圖便是這段程式碼執行後得到的handlers,其本身是個物件,每個key(1, 2, 3 ...)(這個key也是和元素身上的_zid屬性一一對應的)都儲存著一個陣列,而陣列中的每一專案都儲存著一個與事件型別相關的物件。我們來看看,每個key的陣列都長啥樣
[
{
e: 'click', // 事件名稱
fn: function () {}, // 使用者傳入的回撥函式
i: 0, // 該物件在該陣列中的索引
ns: 'qianlongo', // 名稱空間
proxy: function () {}, // 真正給dom繫結事件時執行的事件處理程式, 為del或者fn
sel: '.qianlongo', // 進行事件代理時傳入的選擇器
del: function () {} // 事件代理函式
},
{
e: 'mouseover', // 事件名稱
fn: function () {}, // 使用者傳入的回撥函式
i: 1, // 該物件在該陣列中的索引
ns: 'qianlongo', // 名稱空間
proxy: function () {}, // 真正給dom繫結事件時執行的事件處理程式, 為del或者fn
sel: '.qianlongo', // 進行事件代理時傳入的選擇器
del: function () {} // 事件代理函式
}
]複製程式碼
這樣的設定給後面事件的移除帶了很大的便利。畫個簡單的圖,看看元素新增的事件和handlers中的對映關係。
明白了他們之間的對映關係,我們再回到原始碼處,繼續看。
events.split(/\s/).forEach(function (event) {
// xxx
})複製程式碼
暫時去除了一些內部程式碼邏輯,我們看到其對event
做了切分,並迴圈新增事件,這也是我們像下面這樣新增事件的原因
$('li').on('click mouseover mouseout', function () {})複製程式碼
那麼接下來我們要關注的就是迴圈的內部細節了。新增了部分註釋
// 如果是ready事件,就直接呼叫ready方法(這裡的return貌似無法結束forEach迴圈吧)
if (event == 'ready') return $(document).ready(fn)
// 得到事件和名稱空間分離的物件 'click.qianlongo' => {e: 'click', ns: 'qianlongo'}
var handler = parse(event)
// 將使用者輸入的回撥函式掛載到handler上
handler.fn = fn
// 將使用者傳入的選擇器掛載到handler上(事件代理有用)
handler.sel = selector
// 用mouseover和mouseout分別模擬mouseenter和mouseleave事件
// https://qianlongo.github.io/zepto-analysis/example/event/mouseEnter-mouseOver.html(mouseenter與mouseover為何這般糾纏不清?)
// emulate mouseenter, mouseleave
if (handler.e in hover) fn = function (e) {
var related = e.relatedTarget
if (!related || (related !== this && !$.contains(this, related)))
return handler.fn.apply(this, arguments)
}
handler.del = delegator
// 注意需要事件代理函式(經過一層處理過後的)和使用者輸入的回撥函式優先使用事件代理函式
var callback = delegator || fn
// proxy是真正繫結的事件處理程式
// 並且改寫了事件物件event
// 新增了一些方法和屬性,最後呼叫使用者傳入的回撥函式,如果該函式返回false,則認為需要阻止預設行為和阻止冒泡
handler.proxy = function (e) {
e = compatible(e)
if (e.isImmediatePropagationStopped()) return
e.data = data
var result = callback.apply(element, e._args == undefined ? [e] : [e].concat(e._args))
// 如果回撥函式返回false,那麼將阻止冒泡和阻止瀏覽器預設行為
if (result === false) e.preventDefault(), e.stopPropagation()
return result
}
// 將該次新增的handler在set中的索引賦值給i
handler.i = set.length
// 把handler儲存起來,注意因為一個元素的同一個事件是可以新增多個事件處理程式的
set.push(handler)
// 最後當然是繫結事件
if ('addEventListener' in element)
element.addEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))複製程式碼
至此,新增事件到這裡告一段落了。讓我們再回到文章初始的問題,
on
bind
delegate
live
click()
這些新增事件的形式,有什麼區別,內部之間又有什麼聯絡呢?其實看他們的原始碼大概就知道區別
// 繫結事件
$.fn.bind = function (event, data, callback) {
return this.on(event, data, callback)
}
// 小範圍冒泡繫結事件
$.fn.delegate = function (selector, event, callback) {
return this.on(event, selector, callback)
}
// 將事件冒泡代理到body上
$.fn.live = function (event, callback) {
$(document.body).delegate(this.selector, event, callback)
return this
}
// 繫結以及觸發事件的快件方式
// 比如 $('li').click(() => {})
; ('focusin focusout focus blur load resize scroll unload click dblclick ' +
'mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave ' +
'change select keydown keypress keyup error').split(' ').forEach(function (event) {
$.fn[event] = function (callback) {
return (0 in arguments) ?
// click() 形式的呼叫內部還是用了bind
this.bind(event, callback) :
this.trigger(event)
}
})複製程式碼
bind和click()函式都是直接將事件繫結到元素身上,live則代理到body元素身上,delegate是小範圍是事件代理,效能在由於live,on就最厲害了,以上函式都可以用on實現呼叫。
事件移除的具體實現
事件移除的實現有賴於事件繫結的實現,繫結的時候,把真正註冊的事件資訊都和dom關聯起來放在了handlers中,那麼移除具體是如何實現的呢?我們一步步來看。
同樣先放一張事件移除的大致流程圖
off函式
$.fn.off = function (event, selector, callback) {
var $this = this
// {click: clickFn, mouseover: mouseoverFn}
// 傳入的是物件,迴圈遍歷呼叫本身解除事件
if (event && !isString(event)) {
$.each(event, function (type, fn) {
$this.off(type, selector, fn)
})
return $this
}
// ('click', fn)
if (!isString(selector) && !isFunction(callback) && callback !== false)
callback = selector, selector = undefined
if (callback === false) callback = returnFalse
// 迴圈遍歷刪除繫結在元素身上的事件,如何解除,可以看remove
return $this.each(function () {
remove(this, event, callback, selector)
})
}複製程式碼
off函式基本上和on函式是一個套路,先做一些基本的引數解析,然後把移除事件的具體工作交給remove函式實現,所以我們主要看remove函式。
remove函式
// 刪除事件,off等方法底層用的該方法
function remove(element, events, fn, selector, capture) {
// 得到新增事件的時候給元素新增的標誌id
var id = zid(element)
// 迴圈遍歷要移除的事件(所以我們用的時候,可以一次性移除多個事件)
; (events || '').split(/\s/).forEach(function (event) {
// findHandlers返回的是符合條件的事件響應集合
findHandlers(element, event, fn, selector).forEach(function (handler) {
// [{}, {}, {}]每個元素新增的事件形如該結構
// 刪除存在handlers上的響應函式
delete handlers[id][handler.i]
// 真正刪除繫結在element上的事件及其事件處理函式
if ('removeEventListener' in element)
element.removeEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))
})
})
}複製程式碼
繼續往下走,一個重要的函式findHandlers
// 根據給定的element、event等引數從handlers中查詢handler,
// 主要用於事件移除(remove)和主動觸發事件(triggerHandler)
function findHandlers(element, event, fn, selector) {
// 解析event,從而得到事件名稱和名稱空間
event = parse(event)
if (event.ns) var matcher = matcherFor(event.ns)
// 讀取新增在element身上的handler(陣列),並根據event等引數帥選
return (handlers[zid(element)] || []).filter(function (handler) {
return handler
&& (!event.e || handler.e == event.e) // 事件名需要相同
&& (!event.ns || matcher.test(handler.ns)) // 名稱空間需要相同
&& (!fn || zid(handler.fn) === zid(fn)) // 回撥函式需要相同(話說為什麼通過zid()這個函式來判斷呢?)
&& (!selector || handler.sel == selector) // 事件代理時選擇器需要相同
})
}複製程式碼
因為註冊事件的時候回撥函式不是使用者傳入的fn,而是自定義之後的proxy函式,所以需要將使用者此時傳入的fn和handler中儲存的fn相比較是否相等。
結尾
羅裡吧嗦說了好多,不知道有沒有把zepto中的事件處理部分說明白說詳細,歡迎大家提意見。
如果對你有一點點幫助,點選這裡,加一個小星星好不好呀
如果對你有一點點幫助,點選這裡,加一個小星星好不好呀
如果對你有一點點幫助,點選這裡,加一個小星星好不好呀