1492 字
7 分鐘

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。

再註冊一次 once,不會更新已存在的 listener#

有時程式已經保存同一份 callback,卻以為再呼叫 addEventListener() 並傳 once: true,能把原本長期存在的 listener 改成只執行一次。依 DOM Standard 的加入步驟,相同 type、callback、capture 已存在時不再附加;這次呼叫也沒有把舊 listener 的 once 選項覆寫。

若確實要改變這份 listener 的生命週期,先以原 callback 與相同 capture 移除,再重新註冊。以下是獨立檢查,存成 listener-once-check.mjs,執行 node listener-once-check.mjs:

import assert from 'node:assert/strict';
const target = new EventTarget();
let calls = 0;
const handler = () => { calls += 1; };
target.addEventListener('click', handler);
target.addEventListener('click', handler, { once: true });
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
assert.equal(calls, 2); // 原本的長期 listener 仍在,但沒有增加第二份。
target.removeEventListener('click', handler, { capture: false });
target.addEventListener('click', handler, { once: true });
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
assert.equal(calls, 3); // 新註冊的這份才只執行一次。
const anonymous = new EventTarget();
let anonymousCalls = 0;
for (let i = 0; i < 3; i += 1) {
anonymous.addEventListener('click', () => { anonymousCalls += 1; }, { once: true });
}
anonymous.dispatchEvent(new Event('click'));
assert.equal(anonymousCalls, 3);
anonymous.dispatchEvent(new Event('click'));
assert.equal(anonymousCalls, 3);
console.log('重複註冊、重新設定 once 與匿名函式檢查通過');

最後一組確認另一個方向:三份匿名 callback 各自只跑一次,總共仍會執行三次。排錯時要同時核對 callback 身分與註冊選項,不能只看程式碼裡是否出現 once: true。

2026-10-29 維護流程在 macOS、Node.js v24.13.0 執行上述檢查與原有兩組生命週期範例通過。測試沒有 DOM 樹,不能據此判斷 capture/冒泡的執行順序;實際頁面仍須核對事件路徑和框架掛載點。

在 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