誰說你只是 "會用"jQuery?

謙龍發表於2017-06-08

前言

套用上篇文章向zepto.js學習如何手動觸發DOM事件 的開頭???

前端在最近幾年實在火爆異常,vue、react、angular各路框架層出不窮,我們們要是不知道個雙向資料繫結,不曉得啥是虛擬DOM,也許就被鄙視了。火熱的背後往往也是無盡的浮躁,學習這些先進流行的類庫或者框架可以讓我們走的更快,但是靜下心來回歸基礎,把基石打牢固,卻可以讓我們走的更穩,更遠。

最近一直在看zepto的原始碼,希望通過學習它掌握一些框架設計的技巧,也將很久不再拾起的js基礎重新溫習鞏固一遍。如果你對這個系列感興趣,歡迎點選watch,隨時關注動態。這篇文章主要想說一下zepto中事件模組(event.js)的新增事件on以及移除事件off實現原理,中間會詳細地講解涉及到的細節方面。

如果你想看event.js全文翻譯版本,請點選這裡檢視

原文地址

倉庫地址

誰說你只是 "會用"jQuery?

說在前面

在沒有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的實現原理,其他的也就差不多那麼回事了。

祭出一張畫了好久的圖

誰說你只是 "會用"jQuery?

上面大概是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語句很好的處理了多種使用情況的引數處理。也許直接看不能知曉到底是如何做到的,可以試試每種使用情況都代入其中,找尋其是如何相容的。

接下來我們第三段

這段函式做了非常重要的兩件事

  1. 處理one傳入為true,事件只觸發一次的場景
  2. 處理傳入了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長啥樣。

誰說你只是 "會用"jQuery?

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中的對映關係。

誰說你只是 "會用"jQuery?

明白了他們之間的對映關係,我們再回到原始碼處,繼續看。

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中,那麼移除具體是如何實現的呢?我們一步步來看。

同樣先放一張事件移除的大致流程圖

誰說你只是 "會用"jQuery?

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中的事件處理部分說明白說詳細,歡迎大家提意見。

如果對你有一點點幫助,點選這裡,加一個小星星好不好呀

如果對你有一點點幫助,點選這裡,加一個小星星好不好呀

如果對你有一點點幫助,點選這裡,加一個小星星好不好呀

相關文章