JavaScript
單元:效能與最佳化(Performance & Optimization)
主題:事件代理(Event Delegation)
簡介
在前端開發中,我們常常需要為大量的 DOM 元素(例如清單項目、表格列或動態產生的卡片)掛上點擊、滑鼠或鍵盤等事件處理函式。直接在每個元素上使用 addEventListener 看起來直觀,但當元素數量龐大或是會頻繁增減時,這種做法會帶來 記憶體佔用增加、效能下降、程式碼維護困難 等問題。
事件代理(Event Delegation) 是一種透過利用事件冒泡(event bubbling)機制,把事件監聽器掛在共同的父層元素上,讓父層統一處理子元素的事件。這樣不僅能大幅減少監聽器的數量,還能讓動態新增的子元素自動擁有相同的行為,提升程式的彈性與效能。
本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握事件代理的使用方式,讓你的前端程式更輕量、更易維護。
核心概念
1. 事件冒泡與捕獲
- 冒泡(Bubbling):事件從最深層的目標元素開始觸發,然後逐層向上傳遞到
document。大多數瀏覽器的事件預設都是冒泡的。 - 捕獲(Capturing):事件從最外層的
window或document開始,向下傳遞到目標元素。需要在addEventListener的第三個參數傳入true才會啟用。
事件代理主要利用 冒泡 來實現,因為我們希望把監聽器放在「較高層」的父元素上,讓子元素的事件自然「冒」上來。
2. 為什麼要使用事件代理?
| 直接綁定每個元素 | 事件代理 |
|---|---|
| 每個元素都有一個監聽器,記憶體占用較大 | 只需要在父層掛一個監聽器,節省資源 |
動態新增元素需再次 addEventListener |
動態元素自動受到代理的影響 |
| 需要為大量元素重複寫相同的處理邏輯 | 只寫一次,統一管理 |
| 移除元素時要記得移除監聽器,否則可能產生記憶體泄漏 | 只要父層不被移除,代理自動失效 |
3. 事件代理的實作步驟
- 選取一個共同的父容器(通常是最接近子元素的容器)。
- 在父容器上掛上事件監聽器,使用
addEventListener。 - 在監聽器內部,透過
event.target判斷觸發事件的真正子元素,並根據需求執行對應的處理邏輯。 - 使用
event.stopPropagation()或event.preventDefault()(視需求)控制事件的傳遞或預設行為。
程式碼範例
以下示範 5 個常見且實用的事件代理範例,從最基礎的點擊代理到較進階的表單驗證與自訂屬性過濾。
範例 1:最簡單的點擊代理
// 假設有一個<ul id="menu">裡面有多個<li>項目
const menu = document.getElementById('menu');
menu.addEventListener('click', function (event) {
// 只在點擊到 <li> 時才執行
if (event.target && event.target.nodeName === 'LI') {
console.log('點擊了選單項目:', event.target.textContent);
// 可以在這裡加入切換 active class 的程式
menu.querySelectorAll('li').forEach(li => li.classList.remove('active'));
event.target.classList.add('active');
}
});
重點:使用
event.target.nodeName判斷點擊的元素類型,避免誤觸發父容器本身的點擊。
範例 2:使用 classList.contains 篩選目標
// 只處理帶有 .delete-btn 的按鈕點擊
document.body.addEventListener('click', function (e) {
if (e.target.classList.contains('delete-btn')) {
const itemId = e.target.dataset.id; // 取得自訂屬性
console.log('刪除項目 ID:', itemId);
// 呼叫刪除 API 或移除 DOM
document.getElementById(`item-${itemId}`).remove();
}
});
技巧:把需要辨識的資訊放在
data-*屬性上,透過dataset直接取得,避免在 DOM 中進行繁雜的搜尋。
範例 3:表單即時驗證(keyup 代理)
// 假設有多個動態產生的 <input class="validate">
const form = document.querySelector('#myForm');
form.addEventListener('keyup', function (e) {
if (e.target.matches('input.validate')) {
const value = e.target.value.trim();
if (value.length < 3) {
e.target.setCustomValidity('至少需要 3 個字元');
} else {
e.target.setCustomValidity('');
}
// 立即顯示錯誤訊息
e.target.reportValidity();
}
});
說明:
Element.matches()可以一次判斷 CSS selector,寫起來更直觀。此範例示範即時驗證,且不需要為每個input分別綁定事件。
範例 4:拖放(drag & drop)代理
const container = document.querySelector('.drag-container');
container.addEventListener('dragover', e => e.preventDefault()); // 必須阻止預設才能 drop
container.addEventListener('drop', e => {
e.preventDefault();
const draggedId = e.dataTransfer.getData('text/plain');
const draggedEl = document.getElementById(draggedId);
// 把拖曳的元素放到 drop 目標所在的子容器
const dropTarget = e.target.closest('.drop-zone');
if (dropTarget) {
dropTarget.appendChild(draggedEl);
console.log(`將 ${draggedId} 放到`, dropTarget);
}
});
要點:拖放事件同樣會冒泡,我們只需要在最外層容器處理
dragover、drop,就能支援任意子元素的拖放行為。
範例 5:自訂事件代理(使用 CustomEvent)
// 先在子元素觸發自訂事件
function emitCustom(item) {
const evt = new CustomEvent('item:selected', {
bubbles: true, // 必須冒泡才能被父層捕捉
detail: { id: item.dataset.id }
});
item.dispatchEvent(evt);
}
// 父層代理自訂事件
document.querySelector('#list').addEventListener('item:selected', e => {
console.log('選取的項目 ID:', e.detail.id);
// 例如顯示詳細資訊面板
showDetailPanel(e.detail.id);
});
應用:自訂事件讓我們可以在子元素內部完成複雜的邏輯,然後只把結果(payload)傳遞給父層,保持介面與業務的分離。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
誤判 event.target |
點擊的目標可能是子元素的子元素(例如 <span> 包在 <button> 內) |
使用 event.target.closest(selector) 或 matches() 來向上尋找符合條件的父層 |
| 事件冒泡被阻止 | 某些第三方套件或自訂程式會在子元素上呼叫 stopPropagation(),導致代理失效 |
確認不在子元素內部不必要地阻止冒泡;必要時可改用捕獲階段(addEventListener(..., true)) |
| 記憶體泄漏 | 若父容器被移除卻未移除監聽器,仍會保留對已刪除 DOM 的引用 | 在父容器被銷毀前呼叫 removeEventListener,或使用 WeakMap/WeakSet 管理 |
| 過度委派 | 把所有事件都掛在 document 上,會導致每一次事件都要遍歷大量的條件判斷,影響效能 |
盡量把代理範圍限制在最近的共同父層,避免全局代理 |
| 事件頻率過高 | 如 scroll、mousemove 這類高頻事件直接代理會造成大量回呼 |
使用 throttle 或 debounce(例如 Lodash 的 _.throttle)降低觸發頻率 |
最佳實踐
- 選擇最合適的父容器:越靠近目標的共用父層越好,既能減少判斷次數,又能避免不必要的全局監聽。
- 使用語意化的 CSS Selector:
matches()、closest()搭配 class 或 data 屬性,讓條件判斷更直觀、可維護。 - 保持事件處理函式簡潔:只做「判斷」與「觸發」的工作,具體的業務邏輯建議抽離成獨立函式或模組。
- 適度使用
event.preventDefault():只在真的需要阻止預設行為時使用,避免影響其他功能。 - 測試與除錯:使用瀏覽器的 Event Listener Breakpoints 或
console.log(event.target)觀察實際觸發的元素,確保代理邏輯正確。
實際應用場景
| 場景 | 為什麼適合使用事件代理 | 示範說明 |
|---|---|---|
| 動態清單(Todo List) | 列表項目會不斷新增、刪除,直接綁定每個 <li> 會導致頻繁的 addEventListener/removeEventListener |
代理點擊、完成、刪除按鈕於 <ul> |
| 表格操作(Data Table) | 每列都有編輯、刪除、展開等按鈕,且頁面可能有分頁或無限滾動 | 把事件掛在 <tbody>,使用 data-id 標示列的唯一鍵 |
| 圖片懶加載 | 滾動時需要檢測大量圖片是否進入視窗,若每張圖片都監聽 scroll 會非常耗資源 |
把 scroll 事件掛在 window,在回呼中遍歷仍在視窗外的圖片集合 |
| 自訂 UI 元件(如下拉選單) | 下拉選單的選項會在開啟時才渲染,且可能在同一頁面有多個實例 | 把點擊代理掛在下拉選單的根容器,使用 event.target.matches('.option') 判斷 |
| 拖放排序(Sortable List) | 項目可以自由拖曳排序,項目數量不固定 | 把 dragstart、dragover、drop 代理在容器上,避免每個項目都掛監聽器 |
總結
事件代理是一項 簡單卻威力巨大的技術,它讓我們可以透過單一監聽器,管理大量甚至是動態產生的 DOM 元素事件。透過正確的 父容器選擇、精準的 selector 判斷,以及 避免常見陷阱(如誤判 event.target、過度全局代理),我們不僅能提升前端效能,還能讓程式碼更具可讀性與可維護性。
在日常開發中,當你面臨以下情況時,請立即考慮使用事件代理:
- 元素數量大且頻繁增減
- 需要統一管理相同類型的交互行為
- 想減少記憶體使用與事件綁定的程式碼量
掌握事件代理後,你將能更從容地應對各種 UI 互動需求,寫出 高效、乾淨、易維護 的 JavaScript 程式碼。祝你在開發旅程中玩得開心、寫得順利! 🚀