[JS] DOM 的事件傳遞機制? 捕獲? 冒泡?
在閱讀本文章前,須先了解 DOM 基本的 addEventListener()
事件添加用法,本文主要記錄 DOM 的事件傳遞機制,以及釐清 stopPropagation()
與 preventDefault()
的使用。
Event Flow 事件流程
關於事件傳遞最常見的情況便是網頁元素中,父元素包含子元素,兩者皆添加事件,當點擊子元素時也相當於點擊了父元素,所以兩個元素的點擊事件都會觸發,那麼哪一個會先執行呢? 這點在瞭解後面的事件流程後相信就會明白了。
事件流程可以分為三種階段
- capture phase 捕獲階段
- target phase 目標階段
- bubbling phase 冒泡階段
這裡以點擊 (click) 事件為例,但事件流程並不限於點擊事件而已
Capture Phase 捕獲階段

圖片來源:Event Flow: capture, target, and bubbling
當點擊網頁某一元素時,會先進行事件捕獲的階段,從最外圍的根節點往下傳遞到點擊的元素,中間經過的父元素都會觸發點擊事件,並依序從最外圍的元素到目標元素。
Target Phase 目標階段
該階段自然是在 點擊的元素 觸發事件的階段,這階段不會區分事件是捕獲還是冒泡,這點在後面會再說明。
Bubbling Phase 冒泡階段

圖片來源:Event Flow: capture, target, and bubbling
與 Capture Phase 相反,從點擊的元素先觸發事件,再往外圍包覆的父元素依序執行。
以上三種階段都是觸發某一事件時的流程,可以看下圖明白整體過程

圖片來源:W3C, DOM event flow
<td>
為使用者觸發事件的元素,先執行 capture phase 的事件,再來是到目標元素的 target phase,最後則執行 bubbling phase 的事件。
明白以上事件流程的原理後,再來說一說 addEventListener()
與之相關的參數:
- 第一個參數為觸發的事件如: click、mousemove …
- 第二個參數為 event handler 事件處理器就是要執行的 function
- 第三個參數則是 true/false 設定事件是在捕獲/冒泡階段
從第三點可以知道一個元素是可以同時擁有兩個階端的不同事件,會在相對應的時機觸發執行,下面就用實際範例瞭解
HTML 設置三層的父子關係元素
1 2 3 4 5 6 7 8 9
| <div id="grandpa"> 我是爺爺 <div id="father"> 我是爸爸 <div id="son"> 我是兒子 </div> </div> </div>
|
js 添加以下事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const grandpa = document.getElementById('grandpa'); const father = document.getElementById('father'); const son = document.getElementById('son');
grandpa.addEventListener('click', function() { console.log('我是爺爺,capture phase') }, true);
grandpa.addEventListener('click', function() { console.log('我是爺爺,bubbling phase') }, false);
father.addEventListener('click', function() { console.log('我是爸爸,capture phase') }, true);
father.addEventListener('click', function() { console.log('我是爸爸,bubbling phase') }, false);
son.addEventListener('click', function() { console.log('我是兒子,capture phase') }, true);
son.addEventListener('click', function() { console.log('我是兒子,bubbling phase') }, false);
|
點擊「我是兒子」元素,顯示以下結果
1 2 3 4 5 6
| "我是爺爺,capture phase" "我是爸爸,capture phase" "我是兒子,capture phase" "我是兒子,bubbling phase" "我是爸爸,bubbling phase" "我是爺爺,bubbling phase"
|
由最外圍包覆的元素往最裡面的目標元素觸發 capture 階段的事件,之後再以相反的順序執行 bubbling 階段事件。
點擊「我是爸爸」元素,事件傳遞只會到爸爸這裡就不會再往下面傳遞到兒子
1 2 3 4
| "我是爺爺,capture phase" "我是爸爸,capture phase" "我是爸爸,bubbling phase" "我是爺爺,bubbling phase"
|
Target Phase
瞭解以上範例後,還有一些問題要釐清,像是範例中沒有提到 target phase,事實上在範例中點擊「我是兒子」元素則「我是兒子」即為該次事件流程的目標階段,若點擊「我是爸爸」元素則「我是爸爸」為目標階段。這一點我們可以透過 event handler 的事件參數,有一個 eventPhase
的屬性可以用來表示事件是在哪一個 Phase 觸發的。
將範例中的 js 修改如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const grandpa = document.getElementById('grandpa'); const father = document.getElementById('father'); const son = document.getElementById('son');
grandpa.addEventListener('click', function(e) { console.log('我是爺爺,capture phase,' + 'phase:' + e.eventPhase) }, true);
grandpa.addEventListener('click', function(e) { console.log('我是爺爺,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
father.addEventListener('click', function(e) { console.log('我是爸爸,capture phase,' + 'phase:' + e.eventPhase) }, true);
father.addEventListener('click', function(e) { console.log('我是爸爸,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
son.addEventListener('click', function(e) { console.log('我是兒子,capture phase,' + 'phase:' + e.eventPhase) }, true);
son.addEventListener('click', function(e) { console.log('我是兒子,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
|
點擊「我是兒子」元素,顯示以下結果
1 2 3 4 5 6
| "我是爺爺,capture phase,phase:1" "我是爸爸,capture phase,phase:1" "我是兒子,capture phase,phase:2" "我是兒子,bubbling phase,phase:2" "我是爸爸,bubbling phase,phase:3" "我是爺爺,bubbling phase,phase:3"
|
1 是 capture phase,2 是 target phase,3 是 bubbling phase
點擊「我是爸爸」元素
1 2 3 4
| "我是爺爺,capture phase,phase:1" "我是爸爸,capture phase,phase:2" "我是爸爸,bubbling phase,phase:2" "我是爺爺,bubbling phase,phase:3"
|
點擊「我是爺爺」元素,只有一個元素也就是目標元素,所以為 target phase
1 2
| "我是爺爺,capture phase,phase:2" "我是爺爺,bubbling phase,phase:2"
|
這邊要再注意的是前面有說過 target phase 是不區分捕獲或是冒泡事件的,執行順序是依 addEventListener()
事件添加的順序,例如把「我是兒子」元素的事件添加順序相反,先添加冒泡再添加捕獲
1 2 3 4 5 6 7
| son.addEventListener('click', function(e) { console.log('我是兒子,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
son.addEventListener('click', function(e) { console.log('我是兒子,capture phase,' + 'phase:' + e.eventPhase) }, true);
|
這時再點擊「我是兒子」
1 2 3 4 5 6
| "我是爺爺,capture phase,phase:1" "我是爸爸,capture phase,phase:1" "我是兒子,bubbling phase,phase:2" "我是兒子,capture phase,phase:2" "我是爸爸,bubbling phase,phase:3" "我是爺爺,bubbling phase,phase:3"
|
可以看到在 phase 為 2 也就是 target phase,會執行先添加的冒泡事件再執行捕獲事件。
總結一下可以記住先捕獲再冒泡,但在目標階段不區分的原則。
補充: addEventListener()
第三個參數的預設值為 false 也就是 bubbling,所以如果要添加冒泡事件可以省略第三個參數。
阻止事件傳遞 e.stopPropagation()
我們可以透過 e.stopPropagation()
的使用,在事件流程的某一節點中阻止後面的事件繼續傳遞,例如前面的範例中,在「我是爸爸」元素的捕獲事件中阻止事件傳遞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const grandpa = document.getElementById('grandpa'); const father = document.getElementById('father'); const son = document.getElementById('son');
grandpa.addEventListener('click', function(e) { console.log('我是爺爺,capture phase,' + 'phase:' + e.eventPhase) }, true);
grandpa.addEventListener('click', function(e) { console.log('我是爺爺,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
father.addEventListener('click', function(e) { e.stopPropagation(); console.log('我是爸爸,capture phase,' + 'phase:' + e.eventPhase) }, true);
father.addEventListener('click', function(e) { console.log('我是爸爸,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
son.addEventListener('click', function(e) { console.log('我是兒子,capture phase,' + 'phase:' + e.eventPhase) }, true);
son.addEventListener('click', function(e) { console.log('我是兒子,bubbling phase,' + 'phase:' + e.eventPhase) }, false);
|
點擊「我是兒子」,可以看到在爸爸的捕獲階段就阻止了後續事件的傳遞
1 2
| "我是爺爺,capture phase,phase:1" "我是爸爸,capture phase,phase:1"
|
阻止事件的傳遞不限於捕獲階段,冒泡階段也可以使用,不過要注意的是如果在同一元素同一階段添加多個事件,則即使其中一個事件執行阻止事件傳遞的指令,另一個事件仍會執行,如下都設定捕獲事件,都會執行
1 2 3 4 5 6 7 8
| father.addEventListener('click', function(e) { e.stopPropagation(); console.log('我是爸爸,capture phase,' + 'phase:' + e.eventPhase) }, true);
father.addEventListener('click', function(e) { console.log('我是爸爸2,capture phase,' + 'phase:' + e.eventPhase) }, true);
|
取消預設行為 e.preventDefault()
HTML 某些元素會有瀏覽器預設的行為,最常見的為 <a>
,點擊時會前往或是在新分頁開啟連結,有時候我們並不希望這麼做,所以 e.preventDefault()
的作用就是取消該預設行為。
範例如下
有一個欲連結到 Google 的連結
1
| <a id="link" href="https://www.google.com">Google</a>
|
使用 e.preventDefault()
阻止瀏覽器預設跳轉頁面的行為發生
1 2 3 4 5 6
| const link = document.getElementById('link');
link.addEventListener('click', function(e) { e.preventDefault(); alert('我是 Google 連結!'); });
|
這樣點擊 Google 連結時就不會跳轉頁面,並彈出 '我是 Google 連結!'
的警告視窗。
順帶一提的是 e.preventDefault()
與 DOM 事件的傳遞毫無關係,即使阻止了瀏覽器的預設行為也不會阻止事件流程的傳遞。
事件代理 Event Delegation
在有多個元素要執行同一事件,可以直接想到的作法是對每個元素一個一個的添加事件,但如果數量有數千個呢? 這麼做實在是很每效率、程式碼又多,所以有了 event delegation 事件代理的機制。
事件代理的原理就是透過事件傳遞的特性,對多個元素的同一父元素或更高層的元素做事件添加就好,這樣即使點擊子元素也會觸發事件,範例如下
建立一個無序清單,希望點擊每個 <li>
元素可以在控制台印出該元素內的內容
HTML
1 2 3 4 5 6 7
| <ul id="list"> <li>item1</li> <li>item2</li> <li>item3</li> <li>item4</li> <li>item5</li> </ul>
|
JavaScript
1 2 3 4 5 6 7
| const list = document.getElementById('list');
list.addEventListener('click', function(e) { if (e.target.tagName.toLowerCase() === 'li') { console.log(e.target.textContent); } });
|
對所有 <li>
元素的共同父元素 #list
進行事件添加,當點擊 <li>
時便會觸發事件,並且透過事件參數 e.target
表示的是點擊的元素(e.currentTarget
則是執行事件的元素也就是 #list
),並對 tagName.toLowerCase()
比較是否為 'li'
,若判斷是則執行印出 e.target.textContent
也就是點擊元素內的內容。
這裡要注意的是若點擊 #list
元素而不是 li
元素,雖然也會觸發事件,但由於元素標籤不是 li
所以不會執行任何事情。
除了上面的做法也可以修改如下
HTML
1 2 3 4 5 6 7
| <ul id="list"> <li data-num="1">item1</li> <li data-num="2">item2</li> <li data-num="3">item3</li> <li data-num="4">item4</li> <li data-num="5">item5</li> </ul>
|
JavaScript
1 2 3 4 5 6 7
| const list = document.getElementById('list');
list.addEventListener('click', function(e) { if (e.target.getAttribute('data-num')) { console.log(e.target.textContent); } });
|
這裡透過元素是否含有 data-num
屬性來做識別,還有很多的作法只要能夠判斷、分別即可。
參考資料
- 重新認識 JavaScript: Day 14 事件機制的原理
- 重新認識 JavaScript: Day 15 隱藏在 “事件” 之中的秘密
- DOM 的事件傳遞機制:捕獲與冒泡
- [教學] 瀏覽器事件:Event Bubbling, Event Capturing 及 Event Delegation