1090 字
5 分鐘

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 則由自己的掛載流程負責。兩者可以共存,重點是明確指定誰建立、誰移除。

參考資料:

MDN:EventTarget.addEventListener()

MDN:AbortSignal

WHATWG DOM:事件監聽註冊與 signal

addEventListener 重複觸發怎麼修?用 AbortController 管理事件生命週期
https://laplusda.com/posts/javascript-addeventlistener-abortcontroller-cleanup/
作者
Zero
發佈於
2026-10-20
許可協議
CC BY-NC-SA 4.0