Gib jeder gemounteten JavaScript-Ansicht einen neuen AbortController, registriere ihre Listener mit dem Signal dieses Controllers und brich ihn beim Unmounten ab. In diesem Tutorial wird eine kleine Browseransicht erstellt und gezeigt, wie du wiederholte Mount-Zyklen in Chrome auf einem Mac überprüfst.
Warum sammeln sich Event-Handler an, wenn eine Ansicht erneut geöffnet wird?
Eine Ansicht kann beim ersten Öffnen korrekt aussehen und nach dem erneuten Öffnen doppelt reagieren. Eine häufige Ursache ist Setup-Code, der bei jedem Mounten einen weiteren Callback erstellt, ohne die vorherige Registrierung zu entfernen. Separat erstellte Funktionen sind unterschiedliche Listener-Objekte, selbst wenn ihr Quelltext identisch ist. Die Dokumentation zu addEventListener() von MDN erläutert diese Regel zur Callback-Identität.
Das DOM verhindert doppelte Registrierungen mit demselben Eventtyp, Callback und Capture-Wert am selben Ziel. Dieser Schutz löst das Problem wiederholten Mountens nicht, wenn jedes Setup einen neuen Callback erstellt. Der Standard legt außerdem fest, dass das Abbrechen des angegebenen Signals eines Listeners diesen Listener entfernt. Diese Regeln ermöglichen ein praktisches Design: Ein Controller verwaltet alle nativen Event-Listener eines Mount-Zeitraums. DOM Standard: EventTarget.
In diesem Tutorial soll eine einfache JavaScript-Zähleransicht geöffnet, geschlossen und erneut geöffnet werden, ohne zusätzliche Button- oder Fenster-Handler hinzuzufügen. Das Beispiel verwendet synthetische Beschriftungen, benötigt kein Framework und behält seine Zähler zwischen den Mounts bei.
Welchem Lifecycle-Vertrag sollte die Ansicht folgen?
Lege vor der Implementierung des Setups fest, was wiederholte Lifecycle-Aufrufe bedeuten. Der folgende Vertrag macht versehentliche wiederholte Aufrufe harmlos und weist dem Cleanup eine klare Zuständigkeit zu.
| Vorgang | Erforderliches Verhalten |
|---|---|
Erstes mount() |
Ansicht anhängen und einen Satz Listener registrieren. |
Weiteres mount() im gemounteten Zustand |
Ohne Änderungen zurückkehren. |
unmount() |
Zuerst die Listener des Mount-Zeitraums entfernen, dann die Ansicht lösen. |
Weiteres unmount() |
Ohne Änderungen zurückkehren. |
mount() nach dem Unmounten |
Mit einem neuen Controller einen neuen Satz Listener registrieren; Zähler beibehalten. |
destroy() |
Dauerhaft unmounten; späteres Mounten löst einen Fehler aus. |
Dies sind Entscheidungen für das Anwendungsdesign. Der Browser stellt die Event-Registrierung und -Entfernung bereit; deine Ansicht stellt die Lifecycle-Methoden und ihre Reihenfolge bereit. In einer größeren Anwendung sollte die Komponente oder der Router, der mount() aufruft, auch das passende unmount() veranlassen.
Ein neuer Controller ist wichtig, weil ein abgebrochenes Signal nicht wieder aktiviert werden kann. Wenn addEventListener() ein bereits abgebrochenes Signal erhält, fügt der DOM-Registrierungsalgorithmus den Listener nicht hinzu. Wird der alte Controller wiederverwendet, reagieren die Steuerelemente der erneut geöffneten Ansicht daher möglicherweise nicht. DOM Standard: Hinzufügen eines Event-Listeners.
Erstelle ein vollständiges Beispiel in Chrome auf deinem Mac
Öffne einen neuen Chrome-Tab, gib about:blank in die Adressleiste ein und drücke Return. Während die Seite fokussiert ist, drücke Command + Option + J, um die Konsole zu öffnen. Google dokumentiert diese Mac-Tastenkombination in der Tastaturreferenz für Chrome DevTools.
Führe das vollständige Programm unten in der Konsole aus. Es erstellt die Oberfläche mit JavaScript, daher sind weder Dokument-Markup noch eine Paketinstallation oder ein Build-Schritt erforderlich. Die Chrome-Konsole kann JavaScript auswerten und das DOM der aktuellen Seite ändern, wie in Googles Tutorial JavaScript in der Konsole ausführen gezeigt wird.
(() => {
// 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();
})();
Die Seite enthält nun eine Schaltfläche Eins hinzufügen und eine Zählerzeile. Klicke auf die Schaltfläche: Der Klickzähler sollte sich um eins erhöhen. Ändere die Größe des Chrome-Fensters: Der Zähler für Resize-Events sollte sich ändern. Eine physische Größenänderung kann mehrere Events auslösen. Der Zähler zählt also Events, keine Mausgesten.
Der Button-Listener und der Fenster-Listener verwenden dasselbe Signal. Beim Unmounten wird dieser Controller abgebrochen, bevor das Root-Element abgehängt wird. Beim nächsten Mounten wird ein weiterer Controller mit einem neuen Satz Callbacks erstellt. Die Zähler bleiben im Closure der Ansicht erhalten, sodass ihre Werte beim erneuten Öffnen bestehen bleiben. Wird das gesamte Programm zerstört und erneut ausgeführt, beginnt eine neue Instanz.
Der Setup-Block behandelt auch einen teilweisen Fehler: Wenn das Setup nach dem Registrieren eines Listeners fehlschlägt, bricht der Catch-Block den neuen Controller ab, hängt das Root-Element ab und löst den Fehler erneut aus. So bleibt nach einem fehlgeschlagenen Mount nicht unbemerkt ein Teil des Setups aktiv.
Wie überprüfst du das Cleanup bei wiederholtem Mounten?
Probiere zuerst den Lifecycle manuell aus. Führe nach der Installation des Hauptprogramms die folgende Sequenz aus. Die Ansicht verschwindet, wiederholtes Unmounten hat keine zusätzliche Wirkung, und beim erneuten Öffnen erscheint sie wieder mit den vorherigen Zählerständen.
window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();
Führe dann diese vollständige Prüfung in derselben Konsole aus. Sie setzt das Hauptprogramm oben voraus. Jeder Zyklus ruft Setup und Cleanup absichtlich zweimal auf, löst im gemounteten Zustand ein Event pro Ziel aus und löst dieselben Events im ungemounteten Zustand aus. Bei einer Abweichung wird sofort ein Fehler ausgelöst.
(() => {
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.");
})();
Das synthetische Resize-Event sorgt für eine erwartete, deterministische Änderung, ohne das Fenster zu bewegen. Die Prüfung mit dem abgehängten Button ist nützlich, weil dieses Beispiel den Button absichtlich für erneutes Mounten beibehält. Beide Prüfungen müssen bestehen: Wer nur den sichtbaren Button testet, lässt den Fenster-Listener ungeprüft. Der Test addiert 20 zu jedem Zähler und lässt die Ansicht gemountet.
Zur direkten Überprüfung stellt Chrome in der DevTools-Konsole getEventListeners(object) bereit. Es gibt die Listener zurück, die für das angegebene Objekt registriert sind, gruppiert nach Eventtyp. Dies ist ein DevTools-Hilfsmittel und gehört daher nicht in den Anwendungscode. Referenz zur Chrome Console Utilities API.
Führe nach dem Hauptprogramm diese Inspektionssequenz aus, um die Registrierungen vor und nach dem Cleanup zu vergleichen. Auf der leeren Demonstrationsseite sollte während des Mountens ein Button-Klick-Listener und ein Fenster-Resize-Listener vorhanden sein, während des Unmountens jeweils keiner.
(() => {
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();
})();
Auf einer bestehenden Anwendungsseite kann anderer Code Fenster-Listener verwalten. Vergleiche die Differenz vor und nach dem Cleanup und prüfe die Callbacks, statt anzunehmen, dass die Gesamtzahl auf null sinken muss.
Wann solltest du stattdessen explizit entfernen?
Verwende removeEventListener(), wenn du vorhandenen Code integrierst, der Callbacks bereits speichert, oder wenn die erforderliche Umgebung die Listener-Option signal nicht unterstützt. Bewahre für das Cleanup das ursprüngliche Ziel, den Eventtyp, die Callback-Referenz und den Capture-Wert auf. MDN dokumentiert, dass Capture übereinstimmen muss, während Optionen wie passive für die Zuordnung beim Entfernen nicht ausschlaggebend sind. Referenz zu removeEventListener() von MDN.
Speichere beispielsweise einen gebundenen Callback einmal, wenn das Setup bind() verwendet. Ein erneuter Aufruf von bind() beim Cleanup erzeugt eine andere Funktionsreferenz. Auch durch erneutes Schreiben derselben Arrow-Funktion im Aufruf zum Entfernen erhältst du nicht die zuvor registrierte Funktion zurück. Halte das explizite Cleanup neben dem Setup, damit sich beide gemeinsam weiterentwickeln.
Das Controller-Muster deckt die nativen Listener ab, die mit seinem Signal registriert werden. Wenn eine Ansicht auch Timer, Observer oder Bibliotheksabonnements erstellt, weise deren dokumentierte Cleanup-Operationen derselben Unmount-Grenze zu. Ein Signal für native Listener legt kein Cleanup für andere Ressourcen fest.
Was solltest du prüfen, wenn sich Callbacks weiterhin vervielfachen?
Nutze das Symptom, um die Suche einzugrenzen, statt mehrere Lifecycle-Methoden auf einmal zu ändern.
| Symptom | Als Nächstes prüfen |
|---|---|
| Ein Klick erhöht den Zähler zweimal | Suche nach einem zweiten Registrierungspfad oder einer zweiten aktiven Ansicht-Instanz. |
| Nach erneutem Öffnen reagieren die Steuerelemente nicht mehr | Prüfe, ob das Setup ein abgebrochenes Signal wiederverwendet hat. |
| Zähler ändern sich nach dem Unmounten | Prüfe jedes Event-Ziel und ob jede Registrierung das vorgesehene Signal erhalten hat. |
| Explizites Entfernen ändert nichts | Vergleiche Ziel, Eventtyp, Callback-Identität und Capture-Wert. |
| Die erste Aktion funktioniert, spätere jedoch nicht | Prüfe, ob der Listener once: true verwendet. |
Die Option once entfernt einen Listener nach dessen erstem Aufruf. Sie eignet sich für eine tatsächlich einmalige Interaktion, beschreibt aber nicht die Mount-Lebensdauer einer Ansicht: Das Event wird möglicherweise vor dem Unmounten nie ausgelöst. MDN beschreibt sowohl once als auch das signalbasierte Entfernen in der Referenz zu den addEventListener()-Optionen.
Wenn du die Demonstration beendet hast, führe window.lifecycleDemo.destroy() aus, um die Ansicht abzuhängen und ihre Listener zu entfernen. Für deine eigene Ansicht gilt derselbe praktische Vertrag: ein aktives Setup, wiederholbares Cleanup, neue Registrierungen beim erneuten Öffnen und ein Test, der sowohl sichtbare Steuerelemente als auch länger bestehende Event-Ziele abdeckt.
Quellen
- DOM Standard — WHATWG.
- EventTarget: addEventListener()-Methode — MDN Web Docs.
- EventTarget: removeEventListener()-Methode — MDN Web Docs.
- Tastenkombinationen — Chrome for Developers, Google.
- JavaScript in der Konsole ausführen — Chrome for Developers, Google.
- Referenz zur Console Utilities API — Chrome for Developers, Google.