本文 AI 產出,尚未審核

JavaScript – DOM 與瀏覽器 API

單元:冒泡與捕獲


簡介

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

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


核心概念

1. 事件流的三個階段

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

重點:不是所有事件都支援冒泡(例如 focusblur),但大多數 UI 交互事件(clickkeydowninput)皆會冒泡。

2. 為什麼需要捕獲?

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

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

3. 冒泡的便利性

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


程式碼範例

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

範例 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 沒有作用,且可能造成混淆。 先確認事件類型(focusblurload 等)是否支援冒泡。
事件委派時忘記 e.target 可能是子元素 會誤判點擊目標,導致程式錯誤。 使用 e.target.closest('selector') 或檢查 nodeName
在大量元素上重複掛載相同監聽器 記憶體與效能浪費,尤其在動態列表中。 優先考慮 事件委派,僅在必要時才在個別元素上掛載。

最佳實踐清單

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

實際應用場景

  1. 全域快捷鍵管理

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

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

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

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

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

總結

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

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