前端雜談: DOM event 原理

ssthouse發表於2018-11-22

DOM 事件是前端開發者習以為常的東西. 事件的監聽和觸發使用起來都非常方便, 但是他們的原理是什麼呢? 瀏覽器是怎樣處理 event繫結觸發的呢?

讓我們通過實現一個簡單的event 處理函式, 來詳細瞭解一下.

首先, 如何註冊 event ?

這個相比大家都很清楚了, 有三種註冊方式:

  1. html 標籤中註冊
<button onclick="alert('hello!');">Say Hello!</button>
複製程式碼
  1. 給 DOM 節點的 onXXX 屬性賦值
document.getElementById('elementId').onclick = function() {
  console.log('I clicked it!')
}
複製程式碼
  1. 使用 addEventListener() 註冊事件 (好處是能註冊多個 event handler)
document.getElementById('elementId').addEventListener(
  'click',
  function() {
    console.log('I clicked it!')
  },
  false
)
複製程式碼

event 在 DOM 節點間是如何傳遞的呢 ?

簡單的來說: event 的傳遞是 先自頂向下, 再自下而上

完整的來說: event 的傳遞分為兩個階段: capture 階段bubble 階段

讓我們來看一個具體的例子:

<html>
  <head> </head>

  <body>
    <div id="parentDiv">
      <a id="childButton" href="https://github.com"> click me! </a>
    </div>
  </body>
</html>
複製程式碼

當我們點選上面這段 html 程式碼中的 a 標籤時. 瀏覽器會首先計算出從 a 標籤到 html 標籤的節點路徑 (即: html => body => div => a).

然後進入 capture 階段: 依次觸發註冊在html => body => div => a上的 capture 型別的 click event handler.

到達 a 節點後. 進入 bubble 階段. 依次出發 a => div => body => html上註冊的 bubble 型別的 click event handler.

最後當 bubble 階段到達 html 節點後, 會出發瀏覽器的預設行為(對於該例的 a 標籤來說, 就是跳轉到指定的網頁.)

從下圖我們可以更直觀的看到 event 的傳遞流程.

image

那麼, 這樣的 event 傳遞流是如何實現的呢?

讓我們來看看 addEventListener的程式碼實現:

HTMLNode.prototype.addEventListener = function(eventName, handler, phase) {
  if (!this.__handlers) this.handlers = {}
  if (!this.__handlers[eventName]) {
    this.__handlers[eventName] = {
      capture: [],
      bubble: []
    }
  }
  this.__handlers[eventName][phase ? 'capture' : 'bubble'].push(handler)
}
複製程式碼

上面的程式碼非常直觀, addEventListener 會根據 eventName 和 phase 將 handler 儲存在 __handler 陣列中, 其中 capture 型別的 handler 和 bubble 型別的 handler 分開儲存.

接下來到了本文的核心部分: event 是如何觸發 handler 的 ?

為了便於理解, 這裡我們嘗試實現一個簡單版本的 event 出發函式 handler() (這並不是瀏覽器處理 event 的原始碼, 但思路是相同的)

首先讓我們理清瀏覽器處理 event 的流程步驟:

  1. 建立 event 物件, 初始化需要的資料
  2. 計算觸發 event 事件的 DOM 節點到 html 節點的節點路徑 (DOM path)
  3. 觸發 capture 型別的 handlers
  4. 觸發繫結在 onXXX 屬性上的 handler
  5. 觸發 bubble 型別的 handlers
  6. 觸發該 DOM 節點的瀏覽器預設行為
1. 建立 event 物件, 初始化需要的資料
function initEvent(targetNode) {
  let ev = new Event()
  ev.target = targetNode // ev.target 是當前使用者真正出發的節點
  ;(ev.isPropagationStopped = false), // 是否停止event的傳播
    (ev.isDefaultPrevented = false) // 是否阻止瀏覽器預設的行為

  ev.stopPropagation = function() {
    this.isPropagationStopped = true
  }
  ev.preventDefault = function() {
    this.isDefaultPrevented = true
  }
  return ev
}
複製程式碼
2. 計算觸發 event 事件的 DOM 節點到 html 節點的節點路徑
function calculateNodePath(event) {
  let target = event.target
  let elements = [] // 用於儲存從當前節點到html節點的 節點路徑
  do elements.push(target)
  while ((target = target.parentNode))
  return elements.reverse() // 節點順序為: targetElement ==> html
}
複製程式碼
3. 觸發 capture 型別的 handlers
// 依次觸發 capture型別的handlers, 順序為: html ==> targetElement
function executeCaptureHandlers(elements, ev) {
  for (var i = 0; i < elements.length; i++) {
    if (ev.isPropagationStopped) break

    var curElement = elements[i]
    var handlers =
      (currentElement.__handlers &&
        currentElement.__handlers[ev.type] &&
        currentElement.__handlers[ev.type]['capture']) ||
      []
    ev.currentTarget = curElement
    for (var h = 0; h < handlers.length; h++) {
      handlers[h].call(currentElement, ev)
    }
  }
}
複製程式碼
4. 觸發繫結在 onXXX 屬性上的 handler
function executeInPropertyHandler(ev) {
  if (!ev.isPropagationStopped) {
    ev.target['on' + ev.type].call(ev.target, ev)
  }
}
複製程式碼
5. 觸發 bubble 型別的 handlers
// 基本上和 capture 階段處理方式相同
// 唯一的區別是 handlers 是逆向遍歷的: targetElement ==> html

function executeBubbleHandlers(elements, ev) {
  elements.reverse()
  for (let i = 0; i < elements.length; i++) {
    if (isPropagationStopped) {
      break
    }
    var handlers =
      (currentElement.__handlers &&
        currentElement.__handlers[ev.type] &&
        currentElement.__handelrs[ev.type]['bubble']) ||
      []
    ev.currentTarget = currentElement
    for (var h = 0; h < handlers.length; h++) {
      handlers[h].call(currentElement, ev)
    }
  }
}
複製程式碼
6. 觸發該 DOM 節點的瀏覽器預設行為
function executeNodeDefaultHehavior(ev) {
  if (!isDefaultPrevented) {
    // 對於 a 標籤, 預設行為就是跳轉連結
    if (ev.type === 'click' && ev.tagName.toLowerCase() === 'a') {
      window.location = ev.target.href
    }
    // 對於其他標籤, 瀏覽器會有其他的預設行為
  }
}
複製程式碼
讓我們看看完整的呼叫邏輯:
// 1.建立event物件, 初始化需要的資料
let event = initEvent(currentNode)

function handleEvent(event) {
  // 2.計算觸發 event事件的DOM節點到html節點的**節點路徑
  let elements = calculateNodePath(event)
  // 3.觸發capture型別的handlers
  executeCaptureHandlers(elements, event)
  // 4.觸發繫結在 onXXX 屬性上的 handler
  executeInPropertyHandler(event)
  // 5.觸發bubble型別的handlers
  executeBubbleHandlers(elements, event)
  // 6.觸發該DOM節點的瀏覽器預設行為
  executeNodeDefaultHehavior(event)
}
複製程式碼

以上就是當使用者出發 DOM event 時, 瀏覽器的大致處理流程.

propagation && defaultBehavior

我們知道 event 有 stopPropagation()preventDefault() 兩個方法, 他們的作用分別是:

stopPropagation()
  • 停止 event 的傳播, 從上面程式碼的可以看出, 呼叫 stopPropagation() 後, 後續的 handler 將不會被觸發.
preventDefault()
  • 不觸發瀏覽器的預設行為. 如: <a> 標籤不進行跳轉,<form> 標籤點選 submit 後不自動提交表單.

當我們需要對 event handler 執行流進行精細操控時, 這兩個方法會非常有用.

一些補充~

預設 addEventListener() 最後一個引數為 false

註冊 event handler 時, 瀏覽器預設是註冊的 bubble 型別 (即預設情況下注冊的 event handler 觸發順序為: 從當前節點到 html 節點)

addEventListener() 的實現是 native code

addEventListener是由瀏覽器提供的 api, 並非 JavaScript 原生 api. 使用者觸發 event 時, 瀏覽器會向 message queue 中加入 task, 並通過 Event Loop 執行 task 實現回撥的效果.

reference links:

www.bitovi.com/blog/a-cras…

developer.mozilla.org/en-US/docs/…

想了解更多 前端 / D3.js / 資料視覺化 ?

這裡是我的部落格的 github 地址, 歡迎 star & fork :tada:

D3-blog

如果覺得本文不錯的話, 不妨點選下面的連結關注一下 : )

github 主頁

知乎專欄

掘金

想直接聯絡我 ?

郵箱: ssthouse@163.com

相關文章