每次掛載 JavaScript 檢視畫面時,都建立一個新的 AbortController,使用該控制器的 signal 註冊事件監聽器,並在卸載時將其中止。本教學會建立一個小型瀏覽器檢視畫面,並示範如何在 Mac 上使用 Chrome 檢查重複掛載週期。
為什麼檢視畫面重新開啟時事件處理常式會累積?
檢視畫面第一次開啟時可能看似正常,但重新開啟後就開始對同一操作回應兩次。常見原因是設定程式碼每次掛載都建立另一個回呼,卻沒有移除先前註冊的回呼。即使來源文字完全相同,分別建立的函式仍是不同的事件監聽物件。MDN 的 addEventListener() 文件說明了這項回呼識別規則。
對同一個目標而言,DOM 會阻止事件類型、回呼和 capture 值都相同的重複註冊。但若每次設定都建立新的回呼,這項保護就無法解決重複掛載問題。標準也規定,中止事件監聽器所提供的 signal 會移除該監聽器。這些規則支持一種實用設計:由一個控制器管理單次掛載期間的所有原生事件監聽器。DOM Standard:EventTarget。
在本教學中,讀者要開啟、關閉並重新開啟純 JavaScript 計數器檢視畫面,而不額外新增按鈕或 window 事件處理常式。範例使用合成標籤,不需要框架,且計數器會在重新掛載時保留。
檢視畫面應遵循什麼生命週期契約?
實作設定程式碼前,先決定重複呼叫生命週期方法時的行為。以下契約可讓意外的重複呼叫不造成問題,並明確指定清理工作的負責者。
| 操作 | 必要行為 |
|---|---|
第一次 mount() |
附加檢視畫面並註冊一組事件監聽器。 |
已掛載時再次呼叫 mount() |
不做任何變更便返回。 |
unmount() |
移除掛載期間的事件監聽器,再分離檢視畫面。 |
再次呼叫 unmount() |
不做任何變更便返回。 |
卸載後呼叫 mount() |
使用新的控制器註冊一組新事件監聽器;保留計數器。 |
destroy() |
永久卸載;之後再掛載時拋出錯誤。 |
這些是應用程式的設計選擇。瀏覽器提供事件註冊與移除功能;檢視畫面則負責提供生命週期方法及其呼叫順序。在較大型的應用程式中,呼叫 mount() 的元件或路由器也應安排對應的 unmount()。
使用新的控制器很重要,因為已中止的 signal 無法再次啟用。當 addEventListener() 收到已中止的 signal 時,DOM 註冊演算法會直接返回,不會新增事件監聽器。因此,重複使用舊控制器可能導致重新開啟的檢視畫面,其控制項完全沒有作用。DOM Standard:新增事件監聽器。
在 Mac 上使用 Chrome 建立完整範例
開啟新的 Chrome 分頁,在網址列輸入 about:blank,然後按 Return。在頁面取得焦點時,按 Command + Option + J 開啟 Console。Google 在其 Chrome DevTools 鍵盤快捷鍵參考資料中說明了這個 Mac 快捷鍵。
在 Console 中執行以下完整程式。它會透過 JavaScript 建立介面,因此不需要文件標記、安裝套件或建置步驟。Google 的在 Console 中執行 JavaScript 教學示範了 Chrome 的 Console 如何執行 JavaScript 並變更目前頁面的 DOM。
(() => {
// Clean up the previous demo if this entire program is run again.
window.lifecycleDemo?.destroy();
const host = document.body;
const root = document.createElement("section");
const button = document.createElement("button");
const output = document.createElement("p");
button.type = "button";
button.textContent = "Add one";
root.append(button, output);
let controller = null;
let destroyed = false;
let clicks = 0;
let resizes = 0;
function render() {
output.textContent =
`Clicks: ${clicks}; resize events: ${resizes}`;
}
function mount() {
if (destroyed) {
throw new Error("This view has been destroyed.");
}
if (controller !== null) return;
const next = new AbortController();
const options = { signal: next.signal };
try {
button.addEventListener("click", () => {
clicks += 1;
render();
}, options);
window.addEventListener("resize", () => {
resizes += 1;
render();
}, options);
render();
host.append(root);
controller = next;
} catch (error) {
// Roll back any registrations made before setup failed.
next.abort();
root.remove();
throw error;
}
}
function unmount() {
if (controller === null) return;
controller.abort();
controller = null;
root.remove();
}
function destroy() {
unmount();
destroyed = true;
}
window.lifecycleDemo = {
mount,
unmount,
destroy,
button,
get counts() {
return { clicks, resizes };
}
};
mount();
})();
現在頁面上會出現 Add one 按鈕和計數器文字。按下按鈕,點擊次數應增加一。調整 Chrome 視窗大小,resize 事件計數應有所變化。實際調整大小可能產生多個事件,因此這個計數表示事件次數,而不是滑鼠手勢次數。
按鈕事件監聽器和 window 事件監聽器共用同一個 signal。卸載時會先中止該控制器,再分離根節點。下次掛載時會建立另一個控制器和另一組回呼。計數器保存在檢視畫面的閉包中,因此重新開啟時會保留原值;銷毀並重新執行整個程式則會建立新實例。
設定區塊也會處理部分失敗的情況:如果設定在註冊事件監聽器後拋出錯誤,catch 區塊會中止新控制器、分離根節點,然後重新拋出錯誤。這樣可避免掛載失敗後,部分設定仍悄悄保持啟用。
如何驗證多次掛載後的清理效果?
先手動試試生命週期。在執行主程式後,執行以下步驟。檢視畫面會消失;重複卸載不會造成額外影響;重新開啟後會以先前的計數值恢復。
window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();
接著在同一個 Console 中執行以下完整檢查。這段程式依賴上方的主程式。每個週期都刻意將設定和清理各呼叫兩次,並在掛載期間對每個目標派送一個事件,再於卸載期間派送相同事件。若結果不符,程式會立即拋出錯誤。
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
function check(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
for (let cycle = 0; cycle < 20; cycle += 1) {
view.mount();
view.mount();
const before = view.counts;
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, before.clicks + 1, "Mounted click");
check(view.counts.resizes, before.resizes + 1, "Mounted resize");
view.unmount();
view.unmount();
const stopped = view.counts;
// The button reference remains available while detached.
view.button.click();
window.dispatchEvent(new Event("resize"));
check(view.counts.clicks, stopped.clicks, "Unmounted click");
check(view.counts.resizes, stopped.resizes, "Unmounted resize");
}
view.mount();
console.log("Passed 20 mount/unmount cycles.");
})();
合成的 resize 事件不需移動視窗,就能讓預期變化固定可重現。檢查已分離按鈕很有用,因為此範例刻意保留按鈕以供重新掛載。兩項檢查都必須通過:若只測試畫面上可見的按鈕,就不會檢查 window 事件監聽器。測試會讓每個計數器增加 20,最後讓檢視畫面保持掛載狀態。
若要直接檢查,Chrome 在 DevTools Console 中提供 getEventListeners(object)。它會依事件類型分組,回傳指定物件上註冊的事件監聽器。這是 DevTools 工具,請勿將它放進應用程式程式碼中。Chrome Console Utilities API 參考資料。
執行主程式後,使用以下檢查步驟比較清理前後的註冊狀態。在空白示範頁面上,掛載時應有一個按鈕 click 事件監聽器和一個 window resize 事件監聽器;卸載時,兩者都應歸零。
(() => {
const view = window.lifecycleDemo;
if (!view) throw new Error("Run the main program first.");
view.mount();
console.log("Mounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.unmount();
console.log("Unmounted", {
button: (getEventListeners(view.button).click ?? []).length,
window: (getEventListeners(window).resize ?? []).length
});
view.mount();
})();
在既有的應用程式頁面上,其他程式碼可能也管理 window 事件監聽器。請比較清理前後的差異並檢查回呼,不要假設總數一定要降為零。
什麼情況下應改用明確移除?
若整合的現有程式碼已儲存回呼,或必要環境不支援事件監聽器的 signal 選項,請使用 removeEventListener()。清理時要保留原本的目標、事件類型、回呼參照和 capture 值。MDN 說明 capture 值必須相符,而 passive 等選項不會影響移除時的比對條件。MDN 的 removeEventListener() 參考資料。
例如,如果設定程式碼使用 bind(),就將繫結後的回呼儲存一次。在清理時再次呼叫 bind() 會產生不同的函式參照。同樣地,在移除呼叫中重新撰寫相同的箭頭函式,也無法取得先前註冊的函式。請將明確清理程式碼放在設定程式碼旁邊,讓兩者能一起維護。
控制器模式會處理使用其 signal 註冊的原生事件監聽器。如果檢視畫面也建立計時器、觀察器或函式庫訂閱,請在相同的卸載階段呼叫它們各自文件所述的清理操作。將 signal 傳給原生事件監聽器,不會自動清理無關的資源。
如果回呼仍然變多,應檢查什麼?
根據症狀縮小調查範圍,不要一次修改多個生命週期方法。
| 症狀 | 接著檢查 |
|---|---|
| 一次點擊讓計數增加兩次 | 檢查是否存在第二條註冊途徑,或第二個仍在運作的檢視畫面實例。 |
| 重新開啟後控制項停止回應 | 檢查設定程式碼是否重用了已中止的 signal。 |
| 卸載後計數仍改變 | 檢查每個事件目標,以及每項註冊是否收到預期的 signal。 |
| 明確移除沒有作用 | 比對目標、事件類型、回呼識別和 capture 值。 |
| 第一次操作有效,之後就沒有作用 | 檢查事件監聽器是否使用 once: true。 |
once 選項會在事件監聽器第一次執行後將其移除。它適合真正只會使用一次的互動,但無法表達檢視畫面的掛載期間:卸載前可能根本不會發生該事件。MDN 在 addEventListener() 選項參考資料中說明了 once 和使用 signal 移除事件監聽器的方式。
示範完成後,執行 window.lifecycleDemo.destroy() 以分離檢視畫面並移除其事件監聽器。對於自己的檢視畫面,也請遵循相同的實用契約:一次只啟用一組設定、清理可重複執行、重新開啟時重新註冊,並以測試涵蓋可見控制項和生命週期較長的事件目標。
來源
- DOM Standard — WHATWG。
- EventTarget:addEventListener() 方法 — MDN Web Docs。
- EventTarget:removeEventListener() 方法 — MDN Web Docs。
- 鍵盤快捷鍵 — Chrome for Developers,Google。
- 在 Console 中執行 JavaScript — Chrome for Developers,Google。
- Console Utilities API 參考資料 — Chrome for Developers,Google。