attributeChangedCallback, Reentrancy und Timing-Garantien der vier Lifecycle-Callbacks
Custom Elements bringen vier Lifecycle-Callbacks mit, die auf den ersten Blick recht simpel wirken, in der Praxis aber einige nicht offensichtliche Timing-Garantien und Fallstricke besitzen. Dieser Artikel geht auf connectedCallback, disconnectedCallback, attributeChangedCallback und adoptedCallback im Detail ein, inklusive des haeufig vergessenen observedAttributes und der Reentrancy-Falle.
Inhaltsverzeichnis
- 1. Ueberblick ueber die vier Lifecycle-Callbacks
- 2. connectedCallback im Detail
- 3. disconnectedCallback im Detail
- 4. observedAttributes und attributeChangedCallback
- 5. Die Reentrancy-Falle bei attributeChangedCallback
- 6. Timing-Garantien: Reihenfolge relativ zum Konstruktor
- 7. adoptedCallback: der seltenste der vier Callbacks
- 8. Der Upgrade-Prozess bereits vorhandener Elemente
- 9. Best-Practice-Checkliste und Lifecycle-Uebersicht
- 10. Zusammenfassung
- 11. FAQ
1. Ueberblick ueber die vier Lifecycle-Callbacks
Die Custom Elements API v1 definiert vier Lifecycle-Callbacks, die eine Klasse optional implementieren kann, wenn sie von HTMLElement erbt und mit customElements.define registriert wird: connectedCallback, disconnectedCallback, attributeChangedCallback und adoptedCallback. Jeder dieser Callbacks wird vom Browser zu einem klar definierten Zeitpunkt im Lebenszyklus eines Elements automatisch aufgerufen.
Auf den ersten Blick wirkt das Konzept simpel und aehnlich zu Lifecycle-Hooks in Frameworks, doch die genauen Timing-Garantien, die Reihenfolge relativ zum Konstruktor und einige Sonderfaelle wie Reentrancy sorgen in der Praxis regelmaessig fuer Bugs, die erst bei genauerer Betrachtung der Spezifikation nachvollziehbar werden. Anders als bei Framework-Lifecycle-Hooks gibt es hier keine Dokumentation eines Drittanbieters, die Feinheiten stehen ausschliesslich in der Browser-Spezifikation selbst.
2. connectedCallback im Detail
connectedCallback wird jedes Mal aufgerufen, wenn das Element in ein Dokument eingefuegt wird, das mit dem DOM verbunden ist. Ein haeufiger Fallstrick ist die Annahme, dieser Callback laufe nur einmal pro Element. Tatsaechlich wird er erneut ausgeloest, wenn ein Element aus dem DOM entfernt und anschliessend an anderer Stelle wieder eingefuegt wird, etwa beim Verschieben zwischen zwei Containern.
Initialisierungslogik, die nur einmal pro Element laufen soll, etwa das Erzeugen eines Shadow DOM, sollte deshalb ueber ein privates Flag abgesichert werden, waehrend Logik, die bei jedem Verbinden mit dem DOM sinnvoll erneut laufen soll, etwa das Hinzufuegen von Event-Listenern an document oder window, direkt im Callback stehen darf.
3. disconnectedCallback im Detail
disconnectedCallback wird aufgerufen, sobald das Element aus dem verbundenen DOM entfernt wird, und ist der richtige Ort, um Event-Listener, Timer oder Observer aufzuraeumen, die in connectedCallback registriert wurden. Ohne dieses Aufraeumen entstehen leicht Speicherlecks, insbesondere bei Listenern auf document oder window, die sonst unabhaengig vom eigentlichen Element weiterleben.
Ein weniger bekannter Fallstrick: disconnectedCallback wird auch bei einem rein internen DOM-Reparenting ausgeloest, wenn ein Element kurzzeitig entfernt und sofort wieder eingefuegt wird, etwa durch bestimmte DOM-Manipulationsbibliotheken. Wer dann unnoetig teuren Teardown-Code ausfuehrt, verschwendet Rechenzeit fuer einen Zustand, der ohnehin sofort durch das folgende connectedCallback wieder aufgebaut wird. Eine gaengige Absicherung ist, den tatsaechlichen Teardown per microtask oder kurzem Timeout leicht zu verzoegern und ihn abzubrechen, falls unmittelbar danach ein erneutes connectedCallback folgt.
4. observedAttributes und attributeChangedCallback
attributeChangedCallback wird ausschliesslich fuer Attribute aufgerufen, die im statischen Getter observedAttributes explizit als Array von Attributnamen gelistet sind. Der wohl haeufigste Fallstrick beim Arbeiten mit Custom Elements ist, diesen Getter zu vergessen, wodurch attributeChangedCallback niemals ausgeloest wird, obwohl das Attribut sich im DOM sichtbar aendert und keine Fehlermeldung darauf hinweist.
Der Callback erhaelt drei Parameter: den Namen des geaenderten Attributs, den alten Wert und den neuen Wert, jeweils als String oder null, da HTML-Attribute grundsaetzlich stringbasiert sind. Fuer typisierte interne Zustaende muss der String deshalb explizit in den gewuenschten Typ konvertiert werden, etwa mit Number() oder einem Vergleich gegen die leere Zeichenkette fuer boolesche Attribute.
class StatusBadge extends HTMLElement {
static get observedAttributes() {
return ["status"]; // ohne diesen Getter wuerde der Callback nie feuern
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "status" && oldValue !== newValue) {
this.textContent = `Status: ${newValue}`;
this.className = `badge badge-${newValue}`;
}
}
}
customElements.define("status-badge", StatusBadge);
// <status-badge status="active"></status-badge>
// spaeteres Setzen von setAttribute("status", "closed") loest den Callback aus
5. Die Reentrancy-Falle bei attributeChangedCallback
Eine subtile, aber praxisrelevante Falle entsteht, wenn attributeChangedCallback selbst ein beobachtetes Attribut per setAttribute setzt. Das loest synchron einen erneuten Aufruf von attributeChangedCallback aus, bevor der urspruengliche Aufruf abgeschlossen ist, was zu unerwarteter Rekursion und im schlimmsten Fall zu einem Stack Overflow fuehren kann.
Die uebliche Absicherung ist ein Vergleich von oldValue und newValue am Anfang des Callbacks, kombiniert mit einem fruehen return, sobald sich der Wert nicht tatsaechlich geaendert hat, sowie ein internes Guard-Flag fuer Faelle, in denen der Callback bewusst ein anderes Attribut als Nebeneffekt aktualisieren muss.
class RangeSlider extends HTMLElement {
static get observedAttributes() {
return ["value", "max"];
}
#updating = false;
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue || this.#updating) return;
this.#updating = true;
try {
if (name === "value" && Number(newValue) > Number(this.getAttribute("max"))) {
this.setAttribute("value", this.getAttribute("max")); // loest erneut aus
}
} finally {
this.#updating = false;
}
}
}
6. Timing-Garantien: Reihenfolge relativ zum Konstruktor
attributeChangedCallback laeuft synchron bei jeder Attribut-Aenderung. Wichtig ist, dass er auch fuer Attribute ausgeloest werden kann, die bereits beim Parsen des HTML gesetzt werden, und zwar noch bevor connectedCallback ausgefuehrt wird, in manchen Faellen sogar bevor der Konstruktor vollstaendig durchgelaufen ist, wenn das Element schon vor der Registrierung im Markup stand und spaeter hochgestuft wird.
Aus dieser Reihenfolge folgt eine wichtige Konsequenz fuer Initialisierungscode: Zugriffe auf this.shadowRoot oder auf Kindelemente sollten nicht im Konstruktor oder direkt im ersten attributeChangedCallback erfolgen, sondern erst in connectedCallback, da erst zu diesem Zeitpunkt garantiert ist, dass das Element vollstaendig im DOM verankert und sein interner Aufbau abgeschlossen ist.
class SafeInit extends HTMLElement {
static get observedAttributes() { return ["label"]; }
constructor() {
super();
// Hier NUR Grundzustand setzen, kein DOM-Zugriff auf Kinder
this._label = "";
}
attributeChangedCallback(name, oldValue, newValue) {
this._label = newValue; // kann vor connectedCallback laufen
}
connectedCallback() {
// Sicherer Ort fuer DOM-Aufbau, this._label ist bereits gesetzt
this.textContent = this._label;
}
}
7. adoptedCallback: der seltenste der vier Callbacks
adoptedCallback wird ausgeloest, wenn ein Element per document.adoptNode in ein anderes Dokument uebernommen wird, etwa beim Verschieben eines Elements zwischen einem iframe und dem Hauptdokument. In den allermeisten Anwendungen kommt dieser Fall praktisch nie vor, weshalb der Callback in der Praxis seltener implementiert wird als die anderen drei.
Relevant wird adoptedCallback vor allem bei Multi-Document-Anwendungen, etwa Editoren mit mehreren Fenstern oder Anwendungen, die Inhalte gezielt zwischen mehreren iframes verschieben. Fehlt eine Implementierung, bleibt das Element in der Regel funktional, verliert aber moeglicherweise Referenzen auf dokumentspezifische Ressourcen des urspruenglichen Dokuments, etwa auf ein dort registriertes StyleSheet-Objekt oder einen dokumentgebundenen Observer.
8. Der Upgrade-Prozess bereits vorhandener Elemente
Wird customElements.define erst aufgerufen, nachdem entsprechende Tags bereits im HTML-Markup standen, durchlaeuft der Browser einen sogenannten Upgrade-Prozess: Alle passenden, bereits vorhandenen Elemente erhalten nachtraeglich ihre Klassen-Instanz zugewiesen und ihre Lifecycle-Callbacks werden in der korrekten Reihenfolge nachgeholt.
Ein Fallstrick dabei ist, dass Attribute, die bereits vor dem Upgrade im Markup standen, beim Upgrade fuer jedes in observedAttributes gelistete Attribut einen initialen attributeChangedCallback-Aufruf ausloesen, mit null als oldValue. Code, der davon ausgeht, dass ein Attribut beim ersten Callback-Aufruf bereits eine sinnvolle alte Historie hat, muss diesen Fall explizit beruecksichtigen, insbesondere wenn er den alten Wert fuer eine Differenzberechnung oder eine Animation verwenden will.
9. Best-Practice-Checkliste und Lifecycle-Uebersicht
Als Zusammenfassung gilt: observedAttributes niemals vergessen, wenn attributeChangedCallback genutzt werden soll, DOM-Zugriffe auf Kindelemente immer erst in connectedCallback vornehmen, disconnectedCallback konsequent fuer Aufraeumarbeiten nutzen und bei rekursiven setAttribute-Aufrufen innerhalb von attributeChangedCallback immer einen Wertvergleich oder ein Guard-Flag einsetzen.
Die folgende Tabelle fasst alle vier Callbacks mit ihrem jeweiligen Ausloeser und dem haeufigsten Fallstrick zusammen, als schnelle Referenz fuer die Entwicklung eigener Custom Elements in zukuenftigen Projekten.
class Checklist extends HTMLElement {
static get observedAttributes() { return ["title"]; }
#initialized = false;
connectedCallback() {
if (!this.#initialized) {
this.attachShadow({ mode: "open" });
this.#initialized = true;
}
document.addEventListener("keydown", this.#onKeydown);
}
disconnectedCallback() {
document.removeEventListener("keydown", this.#onKeydown);
}
#onKeydown = (event) => {
if (event.key === "Escape") this.remove();
};
}
| Callback | Ausgeloest durch | Haeufiger Fallstrick | Typische Nutzung |
|---|---|---|---|
| connectedCallback | Element wird ins verbundene DOM eingefuegt | Laeuft mehrfach, nicht nur einmal | Shadow DOM aufbauen, Listener registrieren |
| disconnectedCallback | Element wird aus dem DOM entfernt | Feuert auch bei kurzem Reparenting | Listener und Timer aufraeumen |
| attributeChangedCallback | Beobachtetes Attribut aendert sich | observedAttributes vergessen | Interner Zustand aus Attributen synchronisieren |
| adoptedCallback | Element wird in anderes Dokument uebernommen | Selten getestet, leicht uebersehen | Multi-Document- und iframe-Szenarien |
| constructor | Element wird instanziiert | Kein DOM-Zugriff auf Kinder moeglich | Grundzustand und private Felder initialisieren |
Mironsoft
Moderne Browser-APIs, Performance und wartbares JavaScript
JavaScript, das im echten Browser robust bleibt, nicht nur im Tutorial?
Wir prüfen bestehenden Frontend-Code auf veraltete Patterns, unnötige Bibliotheken und Performance-Fallen und ersetzen sie durch moderne, native Browser-APIs, die weniger Bundle-Gewicht und weniger Wartungslast bedeuten.
Code-Review
Veraltete Patterns, unnötige Dependencies und Memory Leaks systematisch aufspüren.
Performance-Optimierung
Bundle-Größe, Ladezeit und Runtime-Performance mit modernen APIs verbessern.
Modernisierung
Native Browser-APIs statt schwerer Bibliotheken gezielt einführen.
10. Zusammenfassung
Custom Elements Lifecycle: Das Wichtigste auf einen Blick
connectedCallback
Laeuft bei jedem Einfuegen ins DOM, nicht nur beim ersten Mal
attributeChangedCallback
Erfordert observedAttributes, sonst wird der Callback niemals ausgeloest
Reentrancy
setAttribute im Callback kann sich selbst rekursiv erneut ausloesen
Timing
DOM-Zugriffe auf Kinder gehoeren in connectedCallback, nicht in den Konstruktor