什麼捕獲冒泡?難道瀏覽器是魚嗎?

什麼捕獲冒泡?難道瀏覽器是魚嗎?

身爲一個前端小混混,在開發中遇到凡是需要與用戶互動或是需要由用戶觸發的功能,總是離不開事件處理。

今天聊聊瀏覽器的 DOM 事件傳遞機制。

DOM 事件


在瀏覽器的 Javascript 引擎解析 HTML、SVG 時,會將內容分析成一個個的 DOM (Document Object Model),當用戶與 DOM 產生互動時,則是通過 DOM 上註冊的事件監聽器,去觸發某個事件。

例如常見的 onClick、onTouchStart,輸入框的 onInput、onChange、onBlur 等,都是常用的事件類型。

事件監聽


例如我們曾經最熟悉的 jQuery,我們會用這樣的方式去註冊事件監聽:


$('#id').on('click', function(){ ... })

但 jQuery 已經成爲明日黃花;在現代框架中,Vue 對註冊事件監聽器提供了一些語法糖,讓你寫起來很輕鬆:

<button @click="clickHandler">click me!</button>

React 除了語法糖外,底層還將 DOM 事件再封裝一層,並幫你全都代理到 document 上,性能很不錯:

<button onClick={clickHandler}>click me!</button>

當然不管是什麼框架,底層都等同於通過 Javascript 進行操作:

document.querySelector('#id').addEventListener('click', clickHandler)

事件代理


前面說到 React 會幫你把事件代理到 document 上,這是什麼意思呢?

看這個的 簡單小例子,點擊按鈕新增 li 時,會一併註冊事件監聽:


<!--HTML-->
<button id="push">push</button>
<button id="pop">pop</button>

<ul id="list"></ul>
/*JavaScript*/
(function() {
  document.querySelector('#push').addEventListener('click', pushHandler)
  document.querySelector('#pop').addEventListener('click', popHandler)

  const list = document.querySelector('#list')

  function pushHandler() {
    list.appendChild(getNewElem(list.childNodes.length))
  }

  function popHandler() {
    document.querySelectorAll('#list>li')[list.childNodes.length - 1].remove()
  }

  function getNewElem(text) {
    const elem = document.createElement('li')
    elem.innerText = text
    elem.addEventListener('click', eventHandler)
    return elem
  }

  function eventHandler(e) {
    alert(e.target.innerText)
  }
})()

這樣很直觀,但缺點也很明顯;每新增一個元素,都會創建一個事件監聽,當數量增多,造成的內存消耗也會十分可觀:

function pushHandler() {
  list.appendChild(getNewElem(list.childNodes.length))
}function getNewElem(text) {
  const elem = document.createElement('li')
  elem.innerText = text
  elem.addEventListener('click', () => alert(text))
  return elem
}

如果把事件監聽註冊在外層的 ul,並在點擊事件觸發時判斷觸發到到的是誰:

function listClickHandler(e){
  if (e.target.tagName === 'LI') alert(e.target.innerText)
}

通過事件代理,無論內容有多少,事件監聽都只會有一組,效能得到了很大的提升。

移除事件監聽


註冊事件監聽器很方便,但在確定不會再使用監聽器時,要記得通過 removeEventListener 將事件監聽移除。如果留下了無用的事件監聽器,將會造成內存的浪費,對性能有很大的損害。

大家應該注意到了,在前面那個簡易的小例子中並沒有移除事件監聽,而且每創建一個新的子元素,都會同時創建新的函數:

function getNewElem(text) {
  const elem = document.createElement('li')
  elem.innerText = text
 
 // 在這裏創建新的匿名函數
  elem.addEventListener('click', () => alert(text))  return elem
}

比較好的寫法是把匿名函式抽出來,並在移除子元素時一併移除事件監聽器:


function popHandler() {
  const elem = document.querySelectorAll('#list>li')[list.childNodes.length - 1]
  elem.removeEventListener('click', eventHandler) // 移除事件監聽
  elem.remove()
}function getNewElem(text) {
  const elem = document.createElement('li')
  elem.innerText = text
  elem.addEventListener('click', eventHandler)
  return elem
}function eventHandler(e) {
    alert(e.target.innerText)
  }

在 Vue 和 React 等主流網頁框架中,只要是使用內建的語法註冊的事件監聽,它們都會自動在無用的時候移除,可以放心使用;如果是自己實現事件監聽,務必要記得移除。

捕獲與冒泡


跑題太遠了,所以到底什麼是捕獲與冒泡?

根據 W3C 所定義的 Event Flow:
什麼捕獲冒泡?難道瀏覽器是魚嗎?

DOM Event 框架
瀏覽器中的事件傳遞過程分成三個階段:

  • 捕獲階段:由 DOM 樹的最外層依序向內,過程中觸發個別元素的捕獲階段事件監聽。
  • 目標階段:到達事件目標,按照註冊順序觸發事件監聽。
  • 冒泡階段:由事件目標依序向外,過程中觸發個別元素的冒泡階段事件監聽。
    這就是剛剛提到的事件代理的機制了;在事件傳遞過程中,捕獲冒泡階段必然會經過外層元素,因此可以將事件監聽註冊到外層元素上。

另外,當我們在用 addEventListener 註冊事件監聽器時,可以傳遞第三個參數,指定這個事件要在什麼階段觸發:

elem.addEventListener('click', eventHandler) // 未指定,預設爲冒泡
elem.addEventListener('click', eventHandler, false) // 冒泡
elem.addEventListener('click', eventHandler, true) // 捕獲
elem.addEventListener('click', eventHandler, {
  capture: true // 是否爲捕獲。IE、Edge 不支援。其他屬性請參考 MDN
})

如上圖所示, 當一個 DOM 事件發生時,會由最外層的 window 開始依次向內傳遞事件,一直傳到我們的事件目標,觸發完目標上註冊的事件監聽,再進入冒泡階段反向傳遞;由指定觸發的階段,就能確定執行的順序了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章