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。 |
| 在大量元素上重複掛載相同監聽器 | 記憶體與效能浪費,尤其在動態列表中。 | 優先考慮 事件委派,僅在必要時才在個別元素上掛載。 |
最佳實踐清單
- 明確使用
capture或once、passive選項element.addEventListener('touchmove', handler, { passive: true }); - 盡量在父層做事件委派,減少監聽器數量。
- 在需要阻止預設行為時,同時使用
preventDefault與stopPropagation(視需求而定)。 - 對自訂事件設定
bubbles: true,讓父層能接收到。 - 測試跨瀏覽器,特別是 IE(若仍需支援)不支援
options物件,需要使用布林值。
實際應用場景
全域快捷鍵管理
- 在
window的捕獲階段監聽keydown,判斷是否為特定組合鍵(如Ctrl+S)並阻止預設儲存行為,然後觸發自訂保存流程。
- 在
模態視窗點擊關閉
- 在模態背景(
overlay)上使用冒泡監聽器,點擊背景時關閉視窗;同時在模態內容內部捕獲階段stopPropagation,防止點擊內容時也觸發關閉。
- 在模態背景(
表單驗證與即時提示
- 捕獲階段先檢查整體表單狀態,若不符合條件直接
preventDefault,避免子欄位的blur或change事件產生錯誤訊息。
- 捕獲階段先檢查整體表單狀態,若不符合條件直接
動態清單與拖放功能
- 使用事件委派於父容器(如
<ul>)來處理dragstart、dragover、drop,即使項目在執行期間被新增或移除,仍能正常運作。
- 使用事件委派於父容器(如
跨組件訊息傳遞
- 透過自訂事件(
CustomEvent)在子組件內部dispatchEvent,父層在捕獲或冒泡階段統一處理,保持組件解耦。
- 透過自訂事件(
總結
- 事件流 包含捕獲、目標與冒泡三個階段,掌握它們的順序是處理 UI 互動的基礎。
- 捕獲階段 讓我們在事件到達目標前先行攔截,適合全域控制或阻止子元素行為。
- 冒泡階段 則提供了 事件委派 的威力,減少監聽器數量、提升效能。
- 透過
addEventListener的第三個參數(布林值或options物件)可以精確指定監聽階段與其他屬性(once、passive)。 - 常見的陷阱包括忘記指定捕獲、過度使用
stopPropagation、以及對不支援冒泡的事件誤用。遵循最佳實踐能讓程式碼更乾淨、效能更好。
掌握 冒泡與捕獲,不僅能寫出更靈活的 UI 邏輯,還能在大型專案中保持程式碼的可維護性與可擴充性。祝你在 JavaScript 的事件世界裡玩得開心、寫得順手!