[JS] DOM 的事件傳遞機制? 捕獲? 冒泡?

在閱讀本文章前,須先了解 DOM 基本的 addEventListener() 事件添加用法,本文主要記錄 DOM 的事件傳遞機制,以及釐清 stopPropagation()preventDefault() 的使用。

Event Flow 事件流程

關於事件傳遞最常見的情況便是網頁元素中,父元素包含子元素,兩者皆添加事件,當點擊子元素時也相當於點擊了父元素,所以兩個元素的點擊事件都會觸發,那麼哪一個會先執行呢? 這點在瞭解後面的事件流程後相信就會明白了。

事件流程可以分為三種階段

  1. capture phase 捕獲階段
  2. target phase 目標階段
  3. 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);

// 添加 stopPropagation() 阻止事件傳遞
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 屬性來做識別,還有很多的作法只要能夠判斷、分別即可。


參考資料

  1. 重新認識 JavaScript: Day 14 事件機制的原理
  2. 重新認識 JavaScript: Day 15 隱藏在 “事件” 之中的秘密
  3. DOM 的事件傳遞機制:捕獲與冒泡
  4. [教學] 瀏覽器事件:Event Bubbling, Event Capturing 及 Event Delegation