前端雜談: 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!')
}
- 使用
注冊事件 (好處是能注冊多個 event handler)addEventListener()
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
的代碼實作:
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()
stopPropagation()
- 停止 event 的傳播, 從上面代碼的可以看出, 調用
後, 後續的 handler 将不會被觸發.stopPropagation()
preventDefault()
preventDefault()
- 不觸發浏覽器的預設行為. 如:
标簽不進行跳轉,<a>
标簽點選 submit 後不自動送出表單.<form>
當我們需要對 event handler 執行流進行精細操控時, 這兩個方法會非常有用.
一些補充~
預設 addEventListener()
最後一個參數為 false
addEventListener()
注冊 event handler 時, 浏覽器預設是注冊的 bubble 類型 (即預設情況下注冊的 event handler 觸發順序為: 從目前節點到 html 節點)
addEventListener()
的實作是 native code
addEventListener()
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:
D3-blog