addEventListener 重複觸發怎麼修?用 AbortController 管理事件生命週期
同一顆按鈕第一次點擊正常,換頁回來卻執行兩次,再回來變三次,先檢查初始化函式是否反覆註冊事件。每次呼叫 addEventListener('click', () => ...),都會建立新的函式物件;即使內容相同,也可能累積成不同的 listener。
把同一次掛載的事件接到一個 AbortController,卸載時呼叫 abort(),下一次掛載建立新的 controller。 這適合需要一起移除 click、keydown 等事件的元件,不必逐一保管 callback 參照。
先分辨重複註冊與事件冒泡
MDN 的 addEventListener 文件說明,相同 target、事件型別、callback 與 capture 的註冊不會重複加入;但兩個看起來一樣的匿名函式不是同一個 callback。
因此,看到兩次輸出時,先在 handler 記錄 event.target、event.currentTarget 和初始化次數。如果一次來自按鈕、一次來自外層 document,可能是冒泡與事件委派的設計;如果同一個 currentTarget 執行多份相同 handler,才往掛載清理查。直接加 stopPropagation() 會改變其他元件收到事件的方式,不能代替移除多餘 listener。
讓 mount 回傳清理函式
下面的函式只要求傳入一個 EventTarget 與通知函式,可以先脫離框架測試。正式使用時,把按鈕元素交給它;每次重新初始化前,先呼叫上一次回傳的 cleanup。
function mountAction(target, onAction) { const controller = new AbortController(); target.addEventListener('click', onAction, { signal: controller.signal, }); return () => controller.abort();}
// 可在 Node.js 或瀏覽器 Console 執行的生命週期檢查。const target = new EventTarget();let calls = 0;let cleanup = mountAction(target, () => { calls += 1; });target.dispatchEvent(new Event('click'));if (calls !== 1) throw new Error('第一次掛載應只執行一次');
cleanup();cleanup(); // 重複清理不會重新啟用事件。target.dispatchEvent(new Event('click'));if (calls !== 1) throw new Error('清理後不應再執行');
cleanup = mountAction(target, () => { calls += 1; });target.dispatchEvent(new Event('click'));if (calls !== 2) throw new Error('重新掛載應只有新的 handler');cleanup();console.log('mount / cleanup / remount passed');同一個 controller 也可以交給該元件註冊的其他事件。清理時會移除所有使用此 signal 註冊的 listener;若某個全站事件必須長期存在,就別放進這個短期 controller。
這份範例於 2026-10-20 在 Node.js 24.13.0 執行。它驗證 EventTarget 的掛載、重複清理與重新掛載,不包含 DOM 冒泡、瀏覽器 focus 或框架卸載流程。
AbortController 不能當成可重設的開關
AbortSignal取消後不會回到未取消狀態。把同一個已 aborted 的 signal 拿去註冊新 listener,新 listener 不會被加入。這可以用另一組獨立檢查確認:
const controller = new AbortController();controller.abort();const target = new EventTarget();let calls = 0;target.addEventListener('click', () => { calls += 1; }, { signal: controller.signal,});target.dispatchEvent(new Event('click'));if (calls !== 0) throw new Error('已取消的 signal 不應加入 listener');若重新掛載後完全沒有反應,先看是否沿用了舊 controller。把 controller 放在模組頂層,再於第一次換頁時取消,是容易產生這個問題的寫法。
取消 listener 也不會中斷已經開始執行的非同步工作。handler 已發出的 fetch,需要把 signal 另外傳給 fetch;已經回來的舊結果,仍可能需要序號與 AbortController 處理請求競態。
once 與 removeEventListener 各解決什麼
once: true 讓某一份 listener 在第一次收到事件後移除;如果初始化已註冊三份匿名 listener,第一次點擊仍可能讓三份都執行。它適合「這份 callback 只接受一次事件」,不適合當成重複初始化的修補。
使用 removeEventListener() 也可以正確清理,但要保存原 callback,並對上 capture 值。重新寫一個內容相同的箭頭函式傳進去,無法指向先前的那份 listener。需要支援未提供 listener signal 的執行環境時,可以回到這種明確配對的方式;先查目標瀏覽器的支援資料,不能只用全域存在 AbortController 就推論所有 API 都接受 signal。
在 Astro 或其他 SPA 中,cleanup 應接到實際使用的 router/元件卸載點。Astro 複製按鈕的事件委派範例適合長期存在的 document handler;短期元件的 DOM listener 則由自己的掛載流程負責。兩者可以共存,重點是明確指定誰建立、誰移除。
參考資料: