本文 AI 產出,尚未審核

JavaScript – DOM 與瀏覽器 API

單元:冒泡與捕獲


簡介

在前端開發中,事件是使用者與頁面互動的核心。無論是點擊按鈕、輸入文字,或是滑鼠移動,都會觸發相對應的事件物件(Event),而這些事件會在 DOM 樹 中傳遞。
傳遞的方式分為兩個階段:捕獲階段(capture) 以及 冒泡階段(bubble)。了解這兩個階段的運作原理,能讓你在設計 UI 行為時,精確控制事件的處理時機,避免不必要的衝突與效能問題。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步一步掌握「冒泡與捕獲」的要點,並提供實務上常見的應用情境,讓你在開發過程中更加得心應手。


核心概念

1. 事件流的三個階段

階段 說明 何時觸發
捕獲階段 (capture) 事件從最外層(window)向目標元素 逐層下降。此時可以在父層攔截事件。 addEventListener(type, listener, true) 中的 true 表示使用捕獲。
目標階段 (target) 事件抵達真正被觸發的元素(目標元素)。此階段會同時執行捕獲與冒泡的監聽器(若兩者皆有註冊)。 無論是否使用捕獲,都會在目標元素上觸發。
冒泡階段 (bubble) 事件從目標元素 逐層向上 回傳至最外層(window)。大多數事件預設會冒泡。 addEventListener(type, listener, false)(預設)或省略第三個參數。

重點:不是所有事件都支援冒泡(例如 focus、blur),但大多數 UI 交互事件(click、keydown、input)皆會冒泡。

2. 為什麼需要捕獲?

捕獲階段的主要用途是 在事件到達目標前先行處理,例如:

  • 全域快捷鍵:在最外層捕獲鍵盤事件,先判斷是否為全域指令,再決定是否讓子元素繼續處理。
  • 防止子元素觸發:在父層捕獲並 event.stopPropagation(),可阻止子元素的預設行為(如表單驗證前先檢查整體狀態)。

3. 冒泡的便利性

冒泡讓我們可以 將事件委派(event delegation) 給父層元素,從而減少大量相同監聽器的註冊。例如在動態產生的表格列上,只需要在 <tbody> 上掛一個 click 監聽器,即可處理所有列的點擊事件。


程式碼範例

以下示範 5 個常見且實用的範例,說明捕獲與冒泡的差異、事件委派、以及如何使用 stopPropagation 與 preventDefault。

範例 1:基本冒泡與捕獲的比較

<div id="outer" style="padding:20px;background:#f8f8f8;">
  Outer
  <div id="inner" style="padding:20px;background:#cce5ff;">
    Inner
  </div>
</div>

<script>
  const outer = document.getElementById('outer');
  const inner = document.getElementById('inner');

  // 捕獲階段監聽 (第三個參數 true)
  outer.addEventListener('click', e => {
    console.log('Outer 捕獲階段');
  }, true);

  // 冒泡階段監聽 (預設 false)
  outer.addEventListener('click', e => {
    console.log('Outer 冒泡階段');
  });

  inner.addEventListener('click', e => {
    console.log('Inner 點擊 (目標階段)');
  });
</script>

說明:

  • 點擊 inner 時,控制台會依序印出「Outer 捕獲階段」 → 「Inner 點擊」 → 「Outer 冒泡階段」。
  • 若把捕獲階段的第三個參數改為 false,則捕獲階段不會被觸發,順序變為「Inner 點擊」 → 「Outer 冒泡階段」。

範例 2:使用捕獲階段阻止子元素的預設行為

<form id="myForm">
  <input type="text" placeholder="請輸入文字">
  <button type="submit">送出</button>
</form>

<script>
  // 在 form 捕獲階段阻止提交
  document.getElementById('myForm')
    .addEventListener('submit', e => {
      console.log('捕獲階段:阻止表單提交');
      e.preventDefault();          // 防止預設送出
      e.stopPropagation();         // 阻止冒泡到 window
    }, true);
</script>

重點:使用捕獲可以在表單送出前先做全域驗證,若不符合條件直接 preventDefault,子元素(如 button)就不會執行自己的提交行為。

範例 3:事件委派(在父層冒泡階段處理子元素)

<ul id="menu">
  <li data-id="1">首頁</li>
  <li data-id="2">關於我們</li>
  <li data-id="3">聯絡我們</li>
</ul>

<script>
  const menu = document.getElementById('menu');

  // 只在 <ul> 上掛一次監聽器
  menu.addEventListener('click', e => {
    const li = e.target.closest('li');
    if (!li) return; // 點擊不是 <li>,直接忽略
    console.log(`點擊選單項目 ID=${li.dataset.id}`);
    // 這裡可以執行路由切換或 AJAX 請求
  });
</script>

說明:

  • 無論未來動態新增多少 <li>,都不需要額外註冊監聽器。
  • 這種技巧在 SPA(單頁應用) 中非常常見,能顯著減少記憶體與效能開銷。

範例 4:同一事件在捕獲與冒泡階段都註冊監聽器

<div id="box" style="width:200px;height:200px;background:#ffebcc;">
  Click me
</div>

<script>
  const box = document.getElementById('box');

  // 捕獲階段
  box.addEventListener('click', e => {
    console.log('捕獲階段 - 首先執行');
  }, true);

  // 冒泡階段
  box.addEventListener('click', e => {
    console.log('冒泡階段 - 最後執行');
  });
</script>

觀察:

  • 點擊 box 時,兩個監聽器都會被觸發,但 捕獲階段的回呼會先執行。
  • 若在捕獲階段呼叫 e.stopPropagation(),則冒泡階段的回呼 不會被執行。

範例 5:自訂事件與冒泡

// 建立自訂事件,設定可冒泡
const customEvent = new CustomEvent('myEvent', {
  bubbles: true,          // 讓事件冒泡
  cancelable: true,
  detail: { msg: 'Hello from custom event' }
});

document.getElementById('inner').addEventListener('myEvent', e => {
  console.log('Inner 接收到自訂事件', e.detail);
});

document.getElementById('outer').addEventListener('myEvent', e => {
  console.log('Outer 接收到自訂事件 (冒泡)', e.detail);
});

// 觸發事件
document.getElementById('inner').dispatchEvent(customEvent);

說明:

  • 自訂事件預設 不會 冒泡,必須在建構子裡設定 bubbles: true。
  • 這讓開發者可以自行設計跨層級的訊息傳遞機制,例如在組件間傳遞狀態變更。

常見陷阱與最佳實踐

陷阱 可能的後果 建議的解決方案
忘記指定第三個參數 事件預設在冒泡階段,若本意是捕獲就會失效。 明確寫 addEventListener('click', fn, true);若使用 options 物件,加入 { capture: true }。
在捕獲階段過度使用 stopPropagation 會阻止所有子元素的事件,導致 UI 行為不完整(例如子表單無法送出)。 只在必要時使用,或改用 條件式 判斷後再停止。
對不支援冒泡的事件使用 stopPropagation 沒有作用,且可能造成混淆。 先確認事件類型(focus、blur、load 等)是否支援冒泡。
事件委派時忘記 e.target 可能是子元素 會誤判點擊目標,導致程式錯誤。 使用 e.target.closest('selector') 或檢查 nodeName。
在大量元素上重複掛載相同監聽器 記憶體與效能浪費,尤其在動態列表中。 優先考慮 事件委派,僅在必要時才在個別元素上掛載。

最佳實踐清單

  1. 明確使用 capture 或 once、passive 選項
    element.addEventListener('touchmove', handler, { passive: true });
    
  2. 盡量在父層做事件委派,減少監聽器數量。
  3. 在需要阻止預設行為時,同時使用 preventDefault 與 stopPropagation(視需求而定)。
  4. 對自訂事件設定 bubbles: true,讓父層能接收到。
  5. 測試跨瀏覽器,特別是 IE(若仍需支援)不支援 options 物件,需要使用布林值。

實際應用場景

  1. 全域快捷鍵管理

    • 在 window 的捕獲階段監聽 keydown,判斷是否為特定組合鍵(如 Ctrl+S)並阻止預設儲存行為,然後觸發自訂保存流程。
  2. 模態視窗點擊關閉

    • 在模態背景(overlay)上使用冒泡監聽器,點擊背景時關閉視窗;同時在模態內容內部捕獲階段 stopPropagation,防止點擊內容時也觸發關閉。
  3. 表單驗證與即時提示

    • 捕獲階段先檢查整體表單狀態,若不符合條件直接 preventDefault,避免子欄位的 blur 或 change 事件產生錯誤訊息。
  4. 動態清單與拖放功能

    • 使用事件委派於父容器(如 <ul>)來處理 dragstart、dragover、drop,即使項目在執行期間被新增或移除,仍能正常運作。
  5. 跨組件訊息傳遞

    • 透過自訂事件(CustomEvent)在子組件內部 dispatchEvent,父層在捕獲或冒泡階段統一處理,保持組件解耦。

總結

  • 事件流 包含捕獲、目標與冒泡三個階段,掌握它們的順序是處理 UI 互動的基礎。
  • 捕獲階段 讓我們在事件到達目標前先行攔截,適合全域控制或阻止子元素行為。
  • 冒泡階段 則提供了 事件委派 的威力,減少監聽器數量、提升效能。
  • 透過 addEventListener 的第三個參數(布林值或 options 物件)可以精確指定監聽階段與其他屬性(once、passive)。
  • 常見的陷阱包括忘記指定捕獲、過度使用 stopPropagation、以及對不支援冒泡的事件誤用。遵循最佳實踐能讓程式碼更乾淨、效能更好。

掌握 冒泡與捕獲,不僅能寫出更靈活的 UI 邏輯,還能在大型專案中保持程式碼的可維護性與可擴充性。祝你在 JavaScript 的事件世界裡玩得開心、寫得順手!