Benutzerdefinierte Elemente

Ein benutzerdefiniertes Element ist eine Klasse, die über customElements.define() für einen Tag-Namen registriert wird. Die Registrierung und der grundlegende Lebenszyklus sind gut verständlich. Die folgenden Regeln beziehen sich auf die Teile, die routinemäßig falsch implementiert werden.

Lebenszyklus: die Teile, die falsch implementiert werden

Die Callbacks selbst sind gut dokumentiert. Die folgenden Missverständnisse führen zu Fehlern:

  • connectedCallback kann mehrmals ausgeführt werden. Er wird bei jedem Einfügen ausgelöst. Wenn das Element verschoben wird, wird er also noch einmal ausgeführt. Machen Sie die Einrichtung idempotent – gehen Sie nicht davon aus, dass sie nur einmal ausgeführt wird.
  • connectedMoveCallback überspringt das Trennen und Wiederherstellen der Verbindung bei einer Verschiebung. Wenn ein Element mit Node.moveBefore() verschoben wird, wird dieser Callback anstelle von disconnectedCallback → connectedCallback ausgelöst. So bleiben Status, Fokus und iFrame-/Medienwiedergabe beim Verschieben erhalten. Definieren Sie sie, wenn die Einrichtung oder der Abbau aufwendig ist oder wiederholt ausgeführt wird.
  • In disconnectedCallback abreißen. Entfernen Sie globale Listener, Timer und Beobachter, da sonst ein Datenleck entsteht. Das Element kann jedoch wieder eingefügt werden, also betrachten Sie den Abbau nicht als dauerhaft.
  • Rufen Sie zuerst super() im Konstruktor auf und verlassen Sie sich nicht darauf, dass das vollständige DOM vorhanden ist. Wenn Sie super() nach dem Berühren von this aufrufen, wird ein ReferenceError ausgelöst. Attribute sind in der Regel (aber nicht immer) vorhanden. Lange Listen von untergeordneten Elementen wurden möglicherweise noch nicht vollständig geparst. Verwenden Sie connectedCallback für die einmalige Initialisierung, slotchange, um direkt untergeordnete Elemente zu beobachten, und attributeChangedCallback, um Attribute zu beobachten. connectedCallback kann weiterhin ausgelöst werden, bevor alle untergeordneten Elemente geparst wurden. Ignorieren Sie daher nachfolgende slotchange- / attributeChangedCallback-Aufrufe nicht.
  • attributeChangedCallback wird nur für observedAttributes ausgelöst und sollte für Attribute, die sich nicht auf die Ausgabe auswirken, kostengünstig bleiben.
  • Lebenszyklus-Hooks und observedAttributes werden bei der Registrierung per Snapshot erfasst. customElements.define() liest beide einmal. Wenn Sie einen Hook hinzufügen oder observedAttributes für die Klasse nach dem Aufruf erweitern, wird dies ignoriert. Insbesondere wird attributeChangedCallback nur ausgelöst, wenn observedAttributes zum Zeitpunkt define() bereits vorhanden war.

Zeitpunkt des Upgrades und :defined

Ein Element kann im DOM vorhanden sein, bevor seine Klasse registriert wird (in serverseitig gerendertem HTML oder wenn das Skript spät geladen wird). Bis zur Definition ist es ein inaktives HTMLElement ohne Ihr Verhalten. Wenn customElements.define() ausgeführt wird, aktualisiert der Browser jedes übereinstimmende Element, das sich bereits im Dokument befindet, und führt dann seinen Konstruktor und connectedCallback aus.

Zwei Folgen, die Modelle routinemäßig übersehen:

  1. Attribute und untergeordnete Elemente sind möglicherweise bereits vorhanden, wenn der Konstruktor/connectedCallback beim Upgrade ausgeführt wird. Lesen Sie vorhandene Attribute in connectedCallback, um den Anfangszustand zu synchronisieren, und gehen Sie nie davon aus, dass das Element leer ist.
  2. Untergeordnete Elemente können auch in connectedCallback noch fehlen. Der HTML-Parser kann während des Parsens langer Listen untergeordneter Elemente Ergebnisse liefern, sodass der Callback manchmal ausgeführt wird, bevor alle untergeordneten Elemente angehängt sind. Das Muster (slotchange + attributeChangedCallback) finden Sie oben im Abschnitt Lebenszyklus unter „Konstruktor“.

Blende nicht definierte Elemente aus, um ein Aufblitzen von unformatierten Inhalten zu vermeiden. Verwende aber Fail-Open, damit der Inhalt erreichbar bleibt, wenn das Skript nicht geladen wird. Animieren Sie opacity mit einer Verzögerung, anstatt es mit visibility: hidden vollständig auszublenden. So wird das Element nach einer Zeitüberschreitung angezeigt, auch wenn es nie aktualisiert wird:

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

Beschränken Sie diese Schutzmaßnahmen auf die Elemente, die sie benötigen, oder über :has(:not(:defined)) auf einen bestimmten Unterbaum, anstatt sie auf die gesamte body anzuwenden. Ein langsames, nicht kritisches Widget (z. B. ein Teilen-Button) sollte das Rendern der gesamten Seite nicht verzögern.

Attribut ⇄ Attributreflektion

Halten Sie das HTML-Attribut und die JS-Property synchron, damit das Element sowohl über das Markup als auch über das Skript verwendet werden kann. Attributwerte sind immer Strings. Wandeln Sie sie um.

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

Wo der Wert liegt

Im obigen Beispiel wird das DOM als „Source of Truth“ beibehalten – jeder Getter liest getAttribute. Das ist die richtige Standardeinstellung für eine Handvoll einfacher, stringähnlicher Attribute: Es gibt nur einen Ort, an dem der Status gespeichert werden kann, sodass er nicht desynchronisiert werden kann.

Bei komplexeren Komponenten sollten Sie die Source of Truth in einem JS-Feld speichern und nur dann nach außen in ein Attribut übertragen, wenn etwas anderes es sehen muss – CSS-Targeting oder ein Consumer, der es liest. Das Lesen eines Felds ist weitaus günstiger als das Parsen und implizite Umwandeln eines Attributs bei jedem Zugriff. Außerdem werden Nicht-String-Werte nie durch Serialisierung hin- und hergeschickt. Aus diesem Grund speichern Bibliotheken wie Lit den Status in JS und spiegeln ihn selektiv wider, anstatt Attribute als Speicher zu behandeln: Es wird nur das widergespiegelt, was die Außenwelt beobachten muss, nicht jede Property.

Fallstricke:

  • observedAttributes-Disziplin: Listen Sie nur Attribute auf, deren Änderung ein erneutes Rendern oder eine Nebenwirkung auslösen muss. Wenn Sie alles beobachten, wird jeder Mutation ein Callback hinzugefügt, ohne dass dies einen Vorteil bringt.
  • Reflexionsschleifen: Ein Setter, der ein Attribut schreibt, gibt attributeChangedCallback noch einmal ein. Schützen Sie die Funktion wie oben mit einer oldVal !== newVal-Prüfung.
  • Nur-Stil-Status: Für interne Status, die nur für das Styling verwendet werden (Laden, Aktiv, Erweitert), sollten Sie benutzerdefinierte Status anstelle eines synthetischen Attributs verwenden. Weitere Informationen finden Sie unten im Abschnitt Benutzerdefinierte Status. Reflektieren Sie nur dann auf ein Attribut, wenn Nutzer den Status über das Markup festlegen können müssen.

Benutzerdefinierte Status

Benutzerdefinierte Zustände sind boolesche Flags, die ein Element über sich selbst für CSS bereitstellt, damit CSS sie als Ziel verwenden kann. Sie werden über ElementInternals festgelegt und mit der Pseudoklasse :state() abgeglichen. Verwenden Sie sie für den internen UI-Status zur Laufzeit – busy, expanded, invalid –, anstatt ein synthetisches Attribut zu reflektieren: Sie können nicht mit den Attributen eines Nutzers kollidieren, erscheinen nie im DOM und sind von außen schreibgeschützt (ein Nutzer kann :state() formatieren, aber nicht fälschen).

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet ist ein Set, sodass unabhängige Zustände (busy plus error) zusammengesetzt werden, ohne dass ein kombiniertes data-status-Enum erfunden werden muss.

Der benutzerdefinierte Status ist nur zur Laufzeit verfügbar. Sie wird nicht im DOM serialisiert und bleibt daher bei einem Neuladen, einer Neuerstellung des Elements oder einem Server-Rendering nicht erhalten. Wenn der Status deklarativ im Markup festgelegt werden muss oder über Ladevorgänge hinweg beibehalten werden soll, ist das eine Aufgabe für ein Attribut (siehe Wo der Wert gespeichert wird). Stellen Sie ihn in connectedCallback aus Ihrer Source of Truth wieder her. Faustregel: Benutzerdefinierter Status für das, was die Komponente zur Laufzeit ableitet und besitzt und nur gestaltet werden muss; ein Attribut für das, was über Markup festgelegt oder serialisiert werden muss.

Informationen zum Gestalten dieser Zustände – sowohl innerhalb der Komponente als auch über das Stylesheet eines Nutzers – finden Sie unter Webkomponenten gestalten.

Namenskonventionen

  • Echter privater Zustand: native #-Felder (#count) für harte Kapselung; ein _-Präfix nur, wenn Unterklassen weiterhin darauf zugreifen müssen. Beachten Sie, dass #-Felder nicht übernommen werden. Eine Unterklasse kann die privaten Felder einer Basisklasse nicht sehen.
  • Eigenschaften mit Symbolschlüsseln sind ein Mittelweg: Sie sind vor der normalen Aufzählung verborgen, können aber von jedem Code erreicht werden, der das Symbol enthält (exportieren Sie es oder stellen Sie es als static-Feld in der Klasse bereit). Da jedes Symbol eindeutig ist, kollidieren zwei Symbole mit derselben Beschreibung nie. Das ist praktisch, wenn eine Unterklasse oder ein gleichgeordnetes Element einen kontrollierten Zugriff benötigt, den #-Felder nicht bieten können.
  • Verdecken Sie globale Attribute (style, class, id, slot, part, title, lang, dir) nicht mit Ihren eigenen Properties. Dadurch wird ihr integriertes Verhalten beeinträchtigt. Beachten Sie, dass disabled nur für Formularsteuerelemente global ist. Bei anderen Elementen müssen Sie den Effekt selbst implementieren.

Wenn Sie eine neue API-Oberfläche benennen, folgen Sie den Konventionen der Plattform: Verwenden Sie für Tag-Namen benutzerdefinierter Elemente die Kebab-Case-Schreibweise in Kleinbuchstaben mit mindestens einem Bindestrich (z. B. my-toggle), lowerCamelCase für Eigenschaften und Methoden sowie Attributnamen in Kleinbuchstaben, die mit den entsprechenden Eigenschaften synchronisiert sind.