Custom Elements Lifecycle: attributeChangedCallback und typische Fallstricke
AI generated
JS
() =>
JavaScript · Web Components · Custom Elements
Custom Elements Lifecycle: Fallstricke im Detail
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.

16 Min. Lesezeit Web Components Custom Elements Lifecycle

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

11. FAQ: Custom Elements Lifecycle: Das Wichtigste auf einen Blick

1Welche Lifecycle-Callbacks besitzt ein Custom Element?
Die Custom Elements API v1 kennt vier Lifecycle-Callbacks: connectedCallback, disconnectedCallback, attributeChangedCallback und adoptedCallback, die jeweils zu einem klar definierten Zeitpunkt automatisch aufgerufen werden.
2Laeuft connectedCallback nur einmal pro Element?
Nein, connectedCallback laeuft jedes Mal, wenn das Element in ein mit dem DOM verbundenes Dokument eingefuegt wird, auch nach vorherigem Entfernen und erneutem Einfuegen.
3Warum wird mein attributeChangedCallback nie aufgerufen?
Der haeufigste Grund ist ein fehlender oder unvollstaendiger static get observedAttributes Getter. Nur dort gelistete Attributnamen loesen den Callback ueberhaupt aus.
4Welche Parameter erhaelt attributeChangedCallback?
Der Callback erhaelt den Namen des geaenderten Attributs sowie den alten und den neuen Wert, jeweils als String oder null, da HTML-Attribute grundsaetzlich stringbasiert sind.
5Was ist die Reentrancy-Falle bei attributeChangedCallback?
Wenn der Callback selbst per setAttribute ein beobachtetes Attribut setzt, wird er synchron erneut aufgerufen, bevor der urspruengliche Aufruf beendet ist, was zu unerwarteter Rekursion fuehren kann.
6Kann attributeChangedCallback vor connectedCallback laufen?
Ja, attributeChangedCallback kann bereits beim Parsen des HTML-Markups ausgeloest werden, noch bevor connectedCallback ausgefuehrt wird, weshalb DOM-Zugriffe auf Kinder dort riskant sind.
7Wofuer ist disconnectedCallback der richtige Ort?
disconnectedCallback ist der richtige Ort, um Event-Listener, Timer oder Observer aufzuraeumen, die zuvor in connectedCallback registriert wurden, um Speicherlecks zu vermeiden.
8Wann wird adoptedCallback ausgeloest?
adoptedCallback wird ausgeloest, wenn ein Element per document.adoptNode in ein anderes Dokument uebernommen wird, etwa beim Verschieben zwischen einem iframe und dem Hauptdokument.
9Was passiert beim Upgrade bereits vorhandener Elemente?
Wird customElements.define erst nach dem Einfuegen passender Tags aufgerufen, erhalten diese ihre Klassen-Instanz nachtraeglich und durchlaufen fuer jedes beobachtete Attribut einen initialen attributeChangedCallback mit null als oldValue.
10Wo sollten DOM-Zugriffe auf Kindelemente stattfinden?
DOM-Zugriffe auf Kindelemente oder this.shadowRoot sollten in connectedCallback stattfinden, nicht im Konstruktor oder im ersten attributeChangedCallback, da erst dort ein vollstaendiger Aufbau garantiert ist.