前端雜談: DOM event 原理
DOM 事件是前端開發者習以爲常的東西. 事件的監聽和觸發使用起來都非常方便, 但是他們的原理是什麼呢? 瀏覽器是怎樣處理 event綁定和觸發的呢?
讓我們通過實現一個簡單的event 處理函數, 來詳細瞭解一下.
首先, 如何註冊 event ?
這個相比大家都很清楚了, 有三種註冊方式:
- html 標籤中註冊
<button onclick="alert('hello!');">Say Hello!</button>
- 給 DOM 節點的
onXXX
屬性賦值
document.getElementById('elementId').onclick = function() {
console.log('I clicked it!')
}
- 使用
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 的傳遞流程.
那麼, 這樣的 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 的流程步驟:
- 創建 event 對象, 初始化需要的數據
- 計算觸發 event 事件的 DOM 節點到 html 節點的節點路徑 (DOM path)
- 觸發 capture 類型的 handlers
- 觸發綁定在 onXXX 屬性上的 handler
- 觸發 bubble 類型的 handlers
- 觸發該 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:
https://www.bitovi.com/blog/a-crash-course-in-how-dom-events-work
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events
想了解更多 前端 / D3.js / 數據可視化 ?
這裏是我的博客的 github 地址, 歡迎 star & fork :tada: