本文 AI 產出,尚未審核

JavaScript

單元:效能與最佳化(Performance & Optimization)

主題:事件代理(Event Delegation)


簡介

在前端開發中,我們常常需要為大量的 DOM 元素(例如清單項目、表格列或動態產生的卡片)掛上點擊、滑鼠或鍵盤等事件處理函式。直接在每個元素上使用 addEventListener 看起來直觀,但當元素數量龐大或是會頻繁增減時,這種做法會帶來 記憶體佔用增加、效能下降、程式碼維護困難 等問題。

事件代理(Event Delegation) 是一種透過利用事件冒泡(event bubbling)機制,把事件監聽器掛在共同的父層元素上,讓父層統一處理子元素的事件。這樣不僅能大幅減少監聽器的數量,還能讓動態新增的子元素自動擁有相同的行為,提升程式的彈性與效能。

本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握事件代理的使用方式,讓你的前端程式更輕量、更易維護。


核心概念

1. 事件冒泡與捕獲

  • 冒泡(Bubbling):事件從最深層的目標元素開始觸發,然後逐層向上傳遞到 document。大多數瀏覽器的事件預設都是冒泡的。
  • 捕獲(Capturing):事件從最外層的 windowdocument 開始,向下傳遞到目標元素。需要在 addEventListener 的第三個參數傳入 true 才會啟用。

事件代理主要利用 冒泡 來實現,因為我們希望把監聽器放在「較高層」的父元素上,讓子元素的事件自然「冒」上來。

2. 為什麼要使用事件代理?

直接綁定每個元素 事件代理
每個元素都有一個監聽器,記憶體占用較大 只需要在父層掛一個監聽器,節省資源
動態新增元素需再次 addEventListener 動態元素自動受到代理的影響
需要為大量元素重複寫相同的處理邏輯 只寫一次,統一管理
移除元素時要記得移除監聽器,否則可能產生記憶體泄漏 只要父層不被移除,代理自動失效

3. 事件代理的實作步驟

  1. 選取一個共同的父容器(通常是最接近子元素的容器)。
  2. 在父容器上掛上事件監聽器,使用 addEventListener
  3. 在監聽器內部,透過 event.target 判斷觸發事件的真正子元素,並根據需求執行對應的處理邏輯。
  4. 使用 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);
  }
});

要點:拖放事件同樣會冒泡,我們只需要在最外層容器處理 dragoverdrop,就能支援任意子元素的拖放行為。

範例 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 上,會導致每一次事件都要遍歷大量的條件判斷,影響效能 盡量把代理範圍限制在最近的共同父層,避免全局代理
事件頻率過高 scrollmousemove 這類高頻事件直接代理會造成大量回呼 使用 throttledebounce(例如 Lodash 的 _.throttle)降低觸發頻率

最佳實踐

  1. 選擇最合適的父容器:越靠近目標的共用父層越好,既能減少判斷次數,又能避免不必要的全局監聽。
  2. 使用語意化的 CSS Selectormatches()closest() 搭配 class 或 data 屬性,讓條件判斷更直觀、可維護。
  3. 保持事件處理函式簡潔:只做「判斷」與「觸發」的工作,具體的業務邏輯建議抽離成獨立函式或模組。
  4. 適度使用 event.preventDefault():只在真的需要阻止預設行為時使用,避免影響其他功能。
  5. 測試與除錯:使用瀏覽器的 Event Listener Breakpointsconsole.log(event.target) 觀察實際觸發的元素,確保代理邏輯正確。

實際應用場景

場景 為什麼適合使用事件代理 示範說明
動態清單(Todo List) 列表項目會不斷新增、刪除,直接綁定每個 <li> 會導致頻繁的 addEventListener/removeEventListener 代理點擊、完成、刪除按鈕於 <ul>
表格操作(Data Table) 每列都有編輯、刪除、展開等按鈕,且頁面可能有分頁或無限滾動 把事件掛在 <tbody>,使用 data-id 標示列的唯一鍵
圖片懶加載 滾動時需要檢測大量圖片是否進入視窗,若每張圖片都監聽 scroll 會非常耗資源 scroll 事件掛在 window,在回呼中遍歷仍在視窗外的圖片集合
自訂 UI 元件(如下拉選單) 下拉選單的選項會在開啟時才渲染,且可能在同一頁面有多個實例 把點擊代理掛在下拉選單的根容器,使用 event.target.matches('.option') 判斷
拖放排序(Sortable List) 項目可以自由拖曳排序,項目數量不固定 dragstartdragoverdrop 代理在容器上,避免每個項目都掛監聽器

總結

事件代理是一項 簡單卻威力巨大的技術,它讓我們可以透過單一監聽器,管理大量甚至是動態產生的 DOM 元素事件。透過正確的 父容器選擇、精準的 selector 判斷,以及 避免常見陷阱(如誤判 event.target、過度全局代理),我們不僅能提升前端效能,還能讓程式碼更具可讀性與可維護性。

在日常開發中,當你面臨以下情況時,請立即考慮使用事件代理:

  • 元素數量大且頻繁增減
  • 需要統一管理相同類型的交互行為
  • 想減少記憶體使用與事件綁定的程式碼量

掌握事件代理後,你將能更從容地應對各種 UI 互動需求,寫出 高效、乾淨、易維護 的 JavaScript 程式碼。祝你在開發旅程中玩得開心、寫得順利! 🚀