前端雜談: DOM event 原理
前端雜談: 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: