0%

[week 7] DOM 事件傳遞機制:捕獲與冒泡、事件代理

本篇為 [FE102] 前端必備:JavaScript 這門課程的學習筆記。如有錯誤歡迎指正。

學習目標:

 P1 你知道捕獲與冒泡是什麼
 P1 你知道什麼是事件代理(delegation)
 P2 你知道怎麼用 JavaScript 更改元素的 style
 P2 你知道 preventDefault 與 stopPropagation 的差異

當我們運用 JavaScript 在網頁進行操作時,主要可分為下列三大面向:

  1. 介面(Interface):如何改變介面
  2. 事件(Event):如何監聽事件並做出反應
  3. 資料(Data):如何和伺服器交換資料

以下主要探討「事件傳遞機制」的部分。

事件傳遞機制

在開始解釋前,先以下列程式碼為範例:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>事件傳遞機制</title>

<style>
  .outer {
    width: 200px;
    height: 200px;
    background-color: orange;
  }

  .inner {
    width: 100px;
    height: 100px;
    background-color: lightseagreen;
  }
</style>
</head>
<body>
  <div class="outer">
    <div class="inner">
      <button class="btn">button</button>
  </div>
</div>
<script>
  addEvent('.outer');
  addEvent('.inner');
  addEvent('.btn');
  
  // 監聽按鈕點擊事件
  function addEvent(className) {
    document.querySelector(className)
      .addEventListener('click', function () {
        console.log(className);
      })
  };
</script>
</body>
</html>

若在區塊都加上監聽 click 事件,會發現點擊內部區塊,同時也會點擊到外層區塊:

  • 點擊 outer 會觸發 outer
  • 點擊 inner 會觸發 inner ➡️ outer
  • 點擊 button 會觸發 button ➡️ inner ➡️ outer

由此可知當點擊內部節點,同時也會點擊到外層節點。

捕獲與冒泡

參考文章:DOM 的事件傳遞機制:捕獲與冒泡

根據上述內容,可知 DOM 事件傳遞機制分成 3 階段:

1:Capturing Phase 捕獲階段
2:Target Phase     傳遞到元素本身
3:Bubbling Phase   冒泡階段

當我們觸發事件時,會從最外層的根結點開始往內傳遞到 target,也就是「捕獲階段」。接著會再由內往外回傳回去,稱為「冒泡階段」。

任何事件在傳遞時,都會按照這個順序下去傳遞。這也是為什麼,當觸發底層節點的事件同時,上層所有的節點也會被觸發。

可參考:W3C - event flow 的示意圖

事件傳遞的兩個原則

  1. 先捕獲,再冒泡
  2. 當事件傳到 target 本身,沒有分捕獲跟冒泡

根據傳遞機制,我們不一定要把監聽的節點設在底層節點,只需設定在外層,就能監聽到所有底層節點的事件。

阻止事件傳遞 e.stopPropagation

當使用 event.stopPropagation(),事件傳遞就會停在設置的地方:

  • 若在捕獲階段:阻止事件往下傳遞
  • 若在冒泡階段:阻止事件向上傳遞

event.stopPropagation()這樣就可以阻止事件繼續往上冒泡,在父元素的監聽器就不會收到孫元素的事件傳遞。

document.querySelector('.btn').addEventListener('click',function(e){
   e.stopPropagation()
   console.log('btn 冒泡');
})

阻止事件傳遞 stopPropagation()

  • 事件傳遞會停在設置的地方
  • 例如:在 window(最上層)的捕獲階段設置 event.stopPropagation(),會阻止後續事件傳遞,造成所有 click 事件均失效。
// 監聽 window 捕獲階段的 click 事件,執行函式內指令
window.addEventListener('click', function(e) {
  e.stopPropagation()
}, true)

stopImmediatePropagation()

如果要讓同一層的事件也要停止,就用 stopImmediatePropagation(),這樣同一層只會觸發這個 listener。

容易搞錯的事件機制問題

迴圈與觸發時間非同步

首先要注意的,是「迴圈與觸發時間非同步」這件事。以下列程式碼為例:

// 在 html 新增兩個 button:
<body>
  <div class="outer">
    <button class="btn">1</button>
    <button class="btn">2</button>
  </div>

// JS 監控按鈕 click 點擊事件:
<script>
  // querySelectorAll 回傳的值是類陣列
  const btns = document.querySelectorAll('.btn')
  for (i = 0; i < btns.length; i += 1) {
    btns[i].addEventListener('click', () => {
      alert(i)
    })
  }
</script>
</body>

會發現兩個 button 跳出的結果都是 2,而非按照迴圈順序一一跳出相對應的數字。

這是因為 click 事件和迴圈是不一樣時間軸。click 只有在點擊瞬間才會執行,但迴圈會先跑完。因此順序是:click →跑完迴圈→觸發事件結果,所以會跳出一樣的數字。

我們可以把程式碼修改成:

// 在 html 新增兩個 button:
<body>
  <div class="outer">
    // 通常以 `data` 開頭是我們自訂的屬性
    <button class="btn" data-value="1">1</button>
    <button class="btn" data-value="2">2</button>
  </div>

// JS 監控按鈕 click 點擊事件:
<script>
  const btns = document.querySelectorAll('.btn')
  for (i = 0; i < btns.length; i += 1) {
    btns[i].addEventListener('click', (e) => {
      alert(e.target.getAttribute('data-value'))
    })
  }
</script>
</body>
  • e.target:觸發到哪個元素,就可該元素的資料
  • getAttribute:可得到 data-value 的值,也就是新增在 button 元素的屬性

動態新增問題

若想再新增按紐,後來新增的元素並無法擁有已預設好的 addEventListener 的功能:

<div class="outer">
  <button class="add-btn">add</button>
  <button class="btn" data-value="1">1</button>
  <button class="btn" data-value="2">2</button>
</div>

<script>
  // 從數字 3 繼續新增按紐
  let num = 3

  const btns = document.querySelectorAll('.btn')
  for (i = 0; i < btns.length; i += 1) {
    btns[i].addEventListener('click', (e) => {
      alert(e.target.getAttribute('data-value'))
    })
  }

  // 動態新增按紐
  document.querySelector('.add-btn').addEventListener('click', function() {
    const newBtn = document.createElement('button')
    newBtn.classList.add("btn")
    newBtn.setAttribute('data-value', num)
    newBtn.innerText = num
    num += 1
    
    document.querySelector('.outer').appendChild(newBtn)
  })
</script>

當按下 add 確實會新增 button 按鈕,並同樣賦予按鈕 class 與 attribute,但新增的按鈕卻沒有 addEventListener 的效果。

這是因為「程式只會執行一次」。也就是說,第一段的 .querySelectorAll 其實只包含原有的兩顆按鈕,後來新增的按鈕並不會再被加入。我們可以利用「事件代理」來解決動態新增的問題。

事件代理 event delegation

透過事件傳遞機制,我們可以直接對父元素(事件代理)進行事件監聽,就不需對子元素事件一個一個監聽。如此不只能提高效率,也能處理動態新增的問題。

如先前提到:當我們要新增按紐,若把監聽事件設在子元素,那麼新加入的子元素就必須另外處理。若使用 event delegation,直接父元素進行事件監聽,透過冒泡機制,事件會由父元素傳遞到底下的所有子元素。

因此我們可以將先前的程式碼改為:

<!-- 事件代理 -->
<div class="outer">
  <button class="add-btn">add</button>
  <button class="btn" data-value="1">1</button>
  <button class="btn" data-value="2">2</button>
</div>

<script>
  let num = 3

  // 動態新增按紐
  document.querySelector('.add-btn').addEventListener('click', function() {
    const newBtn = document.createElement('button')
    newBtn.classList.add('btn')
    newBtn.setAttribute('data-value', num)
    newBtn.innerText = num
    num += 1;
    document.querySelector('.outer').appendChild(newBtn)
  })
  // 不管有沒有進行事件監聽,事件捕獲冒泡機制都會進行
  // 新增監聽在父節點上
  document.querySelector('.outer').addEventListener('click', function(e) {
    // .classList.contains():判斷是否包含該 class
    if (e.target.classList.contains('btn')) {
      alert(e.target.getAttribute('data-value'))
    }
  })
</script>

參考資料:Introduction to Javascript in Front-End #2