skip to content
dz
10 min read

Web Components robust schreiben: ein Guide aus dem Firefox-Quellcode

Read this in English →

Web Components tun an ein paar Stellen nicht das, was man erwartet. Der constructor sieht ein Attribut einmal als "Ada" und einmal als null. Eine Komponente bleibt leer, ohne dass die Seite einen Fehler zeigt. Ein padding am <slot> bewirkt nichts. Das sind keine Browser-Bugs, das ist so spezifiziert, und es gilt in jedem Browser.

Anlass für diesen Guide ist ein Artikel von Cassondra Roberts: In How Firefox renders Web Components liest sie den Firefox-Quellcode und zeigt, welche Objekte und Felder in Gecko hinter diesem Verhalten stecken, samt interaktivem Replay der Reactions. Namen wie eFailed sind Gecko-intern, das Verhalten selbst steht in den Spezifikationen. Beim Lesen habe ich mich gefragt, was das für meine Arbeit heißt, und wollte es festhalten, auch für mein zukünftiges Ich. Jeder Abschnitt hat ein Problem, den Grund dafür, die Regeln dazu und Alternativen, falls eine Regel mal nicht passt. Das sind die fünf Regeln:

  1. Nie Attribute oder Child Nodes im constructor anfassen, sondern Attribute im attributeChangedCallback lesen.
  2. Nie Events auslösen oder auf document und window lauschen im constructor, sondern im connectedCallback anmelden und im disconnectedCallback abmelden.
  3. Nie mit setTimeout auf einen Callback warten, sondern direkt nach dem DOM-Aufruf weiterarbeiten.
  4. Nie Code im constructor, der einen Fehler auslösen kann, sondern im connectedCallback.
  5. Nie den <slot> stylen, sondern einen Wrapper drumherum.
  6. Nie Inhalt nur per JavaScript erzeugen, sondern sinnvolles Markup im Light DOM liefern, das die Komponente aufwertet.

Unter jedem Codeblock führt ein Link zum laufenden Beispiel (die Beispielseiten sind englisch). Die Ausgabe steht in der Konsole der Entwicklerwerkzeuge und zusätzlich in einem Kasten auf der Seite.

Regel 1: Im constructor keine Attribute oder Child Nodes anfassen

Stattdessen: Attribute im attributeChangedCallback lesen.

Das Beispiel ist ein <hello-name>, das einen Gruß aus seinem Attribut name baut. Der constructor liest das Attribut und setzt den Text. Das erste Element steht im HTML, das Skript ist ein Modul und läuft deshalb erst nach dem Parsen. Das zweite Element legt das Skript per document.createElement() an und setzt das Attribut danach mit setAttribute(), ein verbreitetes Muster, auch in Frameworks:

<p><hello-name name="Ada"></hello-name></p>

<script type="module">
	class HelloName extends HTMLElement {
		static observedAttributes = ['name'];

		constructor() {
			super();
			console.log('constructor', this.getAttribute('name'));
			// The mistake: the attribute is read in the constructor.
			this.attachShadow({ mode: 'open' }).textContent = `👋 Hello, ${this.getAttribute('name')}!`;
		}

		attributeChangedCallback(name, oldValue, newValue) {
			console.log('attributeChangedCallback', name, oldValue, newValue);
		}

		connectedCallback() {
			console.log('connectedCallback');
		}
	}

	customElements.define('hello-name', HelloName);

	const created = document.createElement('hello-name');
	created.setAttribute('name', 'Grace');
	document.body.append(created);
</script>

Beispiel 01 ausführen →

Die Konsole zeigt zwei Durchläufe: Die ersten drei Zeilen gehören zum Element aus dem HTML, die nächsten drei zu dem, das per document.createElement() entsteht:

constructor Ada
attributeChangedCallback name null Ada
connectedCallback
constructor null
attributeChangedCallback name null Grace
connectedCallback

Beide Elemente nutzen denselben Code. Das erste sagt "👋 Hello, Ada!", das zweite "👋 Hello, null!", obwohl auch für das zweite setAttribute('name', 'Grace') gelaufen ist. Woher der Unterschied kommt, zeigt das Upgrade des ersten Elements.

Beim Aufruf von define() führt der Browser für das vorhandene Element ein Upgrade durch. Der Ablauf:

  1. Der Browser legt zwei Reactions an, also Aufträge für spätere Callbacks: "ruf attributeChangedCallback für label auf" und "ruf connectedCallback auf". Sie landen in einer Queue am Element.
  2. Er ruft den constructor auf und wartet, bis er fertig ist.
  3. Erst danach arbeitet er die Queue ab.

Die vorgemerkten Callback-Aufrufe, die Reactions, werden also vor dem constructor angelegt, laufen aber erst nach ihm.

JavaScriptRegistryQueueElementdefine()enqueueconstructor()attributeChanged-CallbackconnectedCallback

Cassondra Roberts zeigt diesen Ablauf in ihrem Artikel als interaktives Replay.

Beim Upgrade des ersten Elements hängen die Attribute schon am Element, der constructor sieht "Ada". Nur der attributeChangedCallback ist noch nicht gelaufen. (Das Auslesen im constructor ist der Fehler, den Regel 1 meint, hier geht es nur zufällig gut.)

Beim zweiten Element läuft es anders: document.createElement() ruft den constructor sofort auf, und das Attribut kommt erst danach, also sieht der constructor null. Dasselbe passiert bei new und bei Elementen, die der Parser erzeugt, bevor das Skript define() aufgerufen hat. Die Reihenfolge ist immer dieselbe: constructor, Attribute, connectedCallback. Eine Komponente muss beide Wege aushalten.

Genau deshalb verlangt die Spezifikation, dass der constructor Attribute und Child Nodes nicht inspiziert: Im Fall ohne Upgrade ist beides noch nicht da, und wer sich auf Upgrades verlässt, macht das Element weniger nutzbar. Er soll auch keine Attribute oder Child Nodes hinzufügen, denn wer document.createElement() aufruft, erwartet ein leeres Element. Was der constructor darf: Anfangszustand und Standardwerte aufbauen und einen Shadow Root anlegen. Aufwendige Arbeit gehört in den connectedCallback. Daraus folgt Regel 1: Attribute liest man im attributeChangedCallback, der für jedes beobachtete Attribut feuert. So steht der Gruß auf beiden Wegen richtig da:

class HelloName extends HTMLElement {
	static observedAttributes = ['name'];

	constructor() {
		super();
		this.attachShadow({ mode: 'open' });
	}

	attributeChangedCallback(name, oldValue, newValue) {
		this.shadowRoot.textContent = `👋 Hello, ${newValue ?? 'stranger'}!`;
	}
}

Beispiel 02 ausführen →

Zum Testen genügt es, jede Komponente auf beiden Wegen zu erzeugen: als Element im HTML, das beim Aufruf von define() ein Upgrade bekommt, und per document.createElement(). Zeigt die Konsole in beiden Fällen dasselbe, hängt die Komponente nicht davon ab, wann sie entsteht. Dafür muss kein Skript die Seite blockieren.

Wer einen Wert früh braucht, hat Alternativen:

  • Im connectedCallback lesen: Die Attribute sind dann da. Beim Parsen fehlen aber womöglich noch die Child Nodes: Dort sieht der connectedCallback sie noch nicht. Wer sie braucht, hört auf das slotchange-Event des Slots, es feuert auch bei der ersten Zuweisung.
  • Idempotent rendern: attributeChangedCallback und connectedCallback rufen dieselbe render()-Methode auf, die mit jedem Anfangszustand zurechtkommt.
  • Properties statt Attribute: Getter und Setter verwalten den Wert unabhängig davon, wann das Element entsteht. Wird eine Property vor dem Upgrade gesetzt, überdeckt sie allerdings den Setter der Klasse. Dann muss der connectedCallback den Wert neu setzen:
connectedCallback() {
	if (Object.hasOwn(this, 'value')) {
		const v = this.value;
		delete this.value;
		this.value = v;
	}
}

Beispiel 03 ausführen →

Regel 2: Im constructor keine Events auslösen und nicht auf document lauschen

Stattdessen: im connectedCallback anmelden und im disconnectedCallback abmelden.

Der constructor läuft, bevor das Element Attribute hat, und womöglich bevor es im Dokument hängt. Ein dispatchEvent() dort meldet deshalb einen Zustand, den das Element noch nicht hat. Events kommen frühestens aus dem connectedCallback.

Listener auf document oder window haben ein zweites Problem: Entfernt man das Element, bleibt der Listener hängen und läuft weiter. Im Beispiel zählen zwei Zähler Tastendrücke auf der Seite. Der eine registriert seinen Listener im constructor, der andere im connectedCallback und meldet ihn im disconnectedCallback wieder ab:

class LeakyCounter extends HTMLElement {
	#count = 0;

	constructor() {
		super();
		// Rule 2: registered in the constructor, never removed
		document.addEventListener('keydown', () => console.log('leaky counter:', ++this.#count));
	}
}

class CleanCounter extends HTMLElement {
	#count = 0;
	#onKey = () => console.log('clean counter:', ++this.#count);

	connectedCallback() {
		document.addEventListener('keydown', this.#onKey);
	}

	disconnectedCallback() {
		document.removeEventListener('keydown', this.#onKey);
	}
}

customElements.define('leaky-counter', LeakyCounter);
customElements.define('clean-counter', CleanCounter);

Beispiel 04 ausführen →

Drückt man eine Taste, zählen beide. Nach dem Entfernen zählt nur noch der undichte Zähler weiter (leaky counter: 2).

Einmalige Initialisierung im connectedCallback braucht einen Guard, denn er kann mehrmals laufen, etwa wenn das Element verschoben wird.

Regel 3: Nicht mit setTimeout auf Callbacks warten

Stattdessen: direkt nach dem DOM-Aufruf weiterarbeiten.

Wer nicht weiß, wann eine Komponente fertig ist, baut gern einen Timer ein. Meist ist er überflüssig, denn Reactions laufen synchron zu dem DOM-Aufruf, der sie auslöst. Die Spezifikation regelt das über die WebIDL-Annotation [CEReactions] an DOM-Methoden wie append(): Was währenddessen anfällt, wird gesammelt und beim Verlassen des Aufrufs in fester Reihenfolge ausgeführt.

Cassondra Roberts bringt es auf den Punkt:

Net effect: from JS you get the guarantee that after any single DOM API call, all resulting reactions have fully run, in a stable order, before the call returns.

Nach append() ist der connectedCallback also schon gelaufen, sofern der Parent im Dokument hängt, und kein Callback sieht den DOM halb geändert. Im Beispiel setzt der connectedCallback ein Attribut ready, und das Skript liest es direkt nach append():

const light = document.createElement('status-light');
document.body.append(light);
console.log('after append, ready:', light.hasAttribute('ready'));

Beispiel 05 ausführen →

Die Konsole zeigt:

connectedCallback
after append, ready: true

Der connectedCallback läuft also, bevor die Zeile nach append() ausgeführt wird, ganz ohne requestAnimationFrame oder setTimeout.

Die Regel dazu: Nie mit setTimeout oder requestAnimationFrame auf den connectedCallback warten, sondern direkt nach dem DOM-Aufruf weiterarbeiten, sofern der Parent im Dokument hängt. Wer im connectedCallback rendert, kann das Ergebnis sofort lesen.

Eine Grenze nennt die Spezifikation im selben Abschnitt, mit einem Beispiel: In Callbacks sollte man den Node-Baum möglichst nicht verändern. Entfernt der connectedCallback eines Parents sein Child, läuft dessen connectedCallback trotzdem noch, und this.isConnected ist dort schon false (Beispiel 06).

Es gibt zwei Fälle, in denen man tatsächlich warten muss:

  • Die Komponente ist noch nicht definiert, weil das Skript später lädt. await customElements.whenDefined('hello-name') wartet, bis define() aufgerufen wurde. Vorhandene Elemente haben ihr Upgrade dann hinter sich, im Test lief der constructor vor dem then.
  • Die Child Nodes sind noch nicht da: Das slotchange-Event des Slots meldet, wenn Nodes zugewiesen werden, auch bei der ersten Zuweisung.

Regel 4: Im constructor keinen Code, der einen Fehler auslösen kann

Stattdessen: Arbeit mit Fehlerpotenzial im connectedCallback erledigen.

Eine Komponente bleibt leer oder ungestylt, und auf der Seite zeigt sich kein Fehler außer einem Eintrag in der Konsole. Löst der constructor eine Exception aus, bleibt das Element dauerhaft im Zustand failed (so nennt ihn die Spezifikation, in Gecko heißt er eFailed), und einen zweiten Versuch gibt es nicht. Ein typischer Auslöser ist localStorage, das gesperrt sein kann, etwa im privaten Modus oder bei blockierten Cookies. Im Beispiel liest <saved-note> im constructor aus localStorage, die Seite simuliert gesperrten Speicher:

<saved-note></saved-note>

<script type="module">
	// Simulates blocked storage (private mode, blocked cookies).
	Storage.prototype.getItem = () => {
		throw new DOMException('Storage is blocked', 'SecurityError');
	};

	class SavedNote extends HTMLElement {
		constructor() {
			super();
			// Rule 4: reads storage in the constructor, which can throw
			const note = localStorage.getItem('note') ?? 'Nothing saved yet';
			this.attachShadow({ mode: 'open' }).innerHTML = `<p>${note}</p>`;
		}
	}

	customElements.define('saved-note', SavedNote);
	console.log('defined:', document.querySelector('saved-note').matches(':defined'));
</script>

Beispiel 07 ausführen →

Uncaught SecurityError: Storage is blocked
defined: false

Die Konsole meldet den Fehler, aber define() löst keinen Fehler aus, das Skript läuft weiter. Das Element bleibt ohne Upgrade, und :defined wählt es nicht aus.

Der constructor baut deshalb nur, was nicht scheitern kann, hier den Shadow Root mit einem Standardtext. Alles mit Fehlerpotenzial (DOM abfragen, Daten parsen, Fremdcode aufrufen, Speicher lesen) gehört in den connectedCallback. Ein Fehler dort wird gemeldet, das Element bleibt aber custom:

class SavedNote extends HTMLElement {
	constructor() {
		super();
		this.attachShadow({ mode: 'open' }).innerHTML = '<p>Nothing saved yet</p>';
	}

	connectedCallback() {
		try {
			const note = localStorage.getItem('note');
			if (note) this.shadowRoot.querySelector('p').textContent = note;
		} catch (error) {
			console.log('storage blocked:', error.name);
		}
	}
}

Beispiel 08 ausführen →

storage blocked: SecurityError
defined: true

Regel 5: Den <slot> nicht stylen

Stattdessen: einen Wrapper drumherum stylen.

Ein padding oder background am <slot> bewirkt nichts, und Farben kommen von Stellen, an denen man sie nicht gesetzt hat. Der Grund: Bevor etwas gestylt wird, baut der Browser einen anderen Baum als den, den ich geschrieben habe, den Flat Tree, in dem die Nodes aus dem Light DOM unter den Slots des Shadow Trees hängen. Den Flat Tree (dort "flattened element tree") definiert das CSS Shadow Module, die Zuweisung der Nodes zu den Slots der DOM-Standard. Er ist also keine Eigenheit eines Browsers. Zeigen lässt es sich an einer kleinen Alert-Komponente, die ihren Inhalt über einen Slot bekommt. Ihren Shadow Root darf sie im constructor anlegen, er gehört zum eigenen Zustand.

Naheliegend ist, den Slot im Shadow Tree zu stylen:

<my-alert> <strong>Warning:</strong> The build is still running. </my-alert>

<script type="module">
	class MyAlert extends HTMLElement {
		constructor() {
			super();
			this.attachShadow({ mode: 'open' }).innerHTML = `
				<style>
					slot { padding: 1rem; background: #fee; }
				</style>
				<slot></slot>
			`;
		}
	}

	customElements.define('my-alert', MyAlert);
</script>

Beispiel 09 ausführen →

Das bewirkt nichts. Ein Slot hat laut Browser-Standardstil display: contents und erzeugt keine eigene Box, also gibt es nichts, das Padding oder Background bekommen könnte. Mit einem Wrapper-Element klappt es. Ersetzt dafür das Template im constructor:

this.attachShadow({ mode: 'open' }).innerHTML = `
	<style>
		.box { padding: 1rem; background: #fee; color: darkred; }
	</style>
	<div class="box"><slot></slot></div>
`;

Beispiel 10 ausführen →

Jetzt hat die Komponente einen rosa Hintergrund, und auch color: darkred greift, obwohl <strong> und der Text aus dem Light DOM kommen und die Regel nur im Shadow Tree steht. Vererbung richtet sich nach dem Flat Tree: Ein Node im Slot hat zwei Parents, im DOM den Host, im Flat Tree den Slot. Der hängt unter .box, also erbt der Inhalt von dort. (Setzt die Seite selbst eine Farbe für strong, gewinnt deren Regel.)

Light DOMShadow TreeFlat Treemy-alertstrongTextShadow Rootdiv.boxslotmy-alertdiv.boxslotstrongText

Der gestrichelte Slot ist im Flat Tree noch da, nur seine Box nicht. Danach laufen Style-Engine, Layout und Renderer auf dem Flat Tree wie bei jedem anderen Element. Nur Selektoren wie :host, ::slotted, ::part und :defined kennen die Grenze zwischen den Trees noch.

Die Regel dazu: Nie padding, border oder background auf den <slot> setzen, sondern auf ein Wrapper-Element um den Slot. Was Slot-Inhalte erben können, etwa color und Schrift, setzt man ebenfalls am Wrapper.

Für alles, was Vererbung nicht abdeckt, gibt es drei Werkzeuge. Im Beispiel soll die Komponente das fette Wort selbst hervorheben, und die Seite soll Hintergrund und Rahmen der Box anpassen können:

this.attachShadow({ mode: 'open' }).innerHTML = `
	<style>
		.box { padding: 1rem; background: var(--alert-bg, #fee); color: darkred; }
		::slotted(strong) { background: gold; }
	</style>
	<div class="box" part="box"><slot></slot></div>
`;

Beispiel 11 ausführen →

my-alert {
	--alert-bg: #eef;
}

my-alert::part(box) {
	border: 2px solid navy;
}

Das CSS der Seite steht im Beispiel in einem <style> im selben Dokument.

  • ::slotted(strong) gestaltet das Element aus dem Light DOM von innen. Es erfasst nur Nodes, die direkt im Slot landen: Ein <b> im <strong> bekommt den Hintergrund nicht, erbt aber vererbbare Eigenschaften wie color. Setzt die Seite dieselbe Eigenschaft für strong, gewinnt ihre Regel gegen ::slotted(), dafür genügt schon ein universeller Reset wie * { border: 0 solid }.
  • --alert-bg setzt die Seite, und die Custom Property geht durch die Shadow-Grenze. var(--alert-bg, #fee) nimmt #fee, wenn die Seite nichts setzt.
  • part="box" gibt die Box nach außen frei. Die Seite gestaltet sie dann gezielt mit ::part(box).

Als Faustregel taugen Custom Properties für die Werte, die jede Seite anpassen soll, etwa Farben und Abstände. ::part() ist für alles, wofür man keine eigene Property anbieten will, und ::slotted() für Nodes, die die Komponente selbst gestaltet. ::part() läuft seit 2020 in allen großen Browsern (MDN).

Regel 6: Keinen Inhalt nur per JavaScript erzeugen

Stattdessen: sinnvolles Markup im Light DOM liefern, das die Komponente aufwertet.

Scheitert der constructor (Regel 4), lädt das Skript spät oder gar nicht, bleibt vom Element ein unbekanntes Inline-Element mit seinem Inhalt. Steht dort sinnvoller Inhalt, bleibt die Seite nutzbar, und die Komponente wertet ihn nur auf. :defined wählt ein Custom Element erst nach erfolgreichem Upgrade aus und trennt so den Zustand davor vom Zustand danach. Ohne Definition ist das Element display: inline, die fertige Komponente ist vermutlich ein Block, und dieser Wechsel lässt das Layout springen. Mit :not(:defined) gestaltet man den Zustand davor:

my-alert:not(:defined) {
	display: block;
}

Im Beispiel 12 wird die Komponente absichtlich erst nach zwei Sekunden definiert: Bis dahin zeigt ein gestrichelter Rahmen den Platzhalter.

Wer den Zwischenzustand ganz verstecken will, macht das nur, wenn JavaScript an ist:

@media (scripting: enabled) {
	my-alert:not(:defined) {
		visibility: hidden;
	}
}

scripting kennen alle großen Browser seit Ende 2023 (MDN), wo die Abfrage fehlt, bleibt der Inhalt sichtbar. Nie display: none, denn dann fehlt der Platz. Lädt das Skript trotz aktivem JavaScript nie, bleibt der Inhalt allerdings unsichtbar.

Manche Komponenten ergeben ohne JavaScript keinen Sinn, etwa eine Karte oder ein Editor. Dort hilft ein Platzhalter, ein <noscript>-Hinweis im Light DOM oder Declarative Shadow DOM: Der Server liefert den Shadow Root im HTML mit, <template shadowrootmode="open"> legt ihn beim Parsen an, ganz ohne JavaScript, und die Komponente zeigt ihren Inhalt schon vor dem Upgrade. Laut caniuse läuft das in Chrome und Edge ab Version 111, in Safari ab 16.4 und in Firefox ab 123. In älteren Browsern bleibt das <template> ungenutzt, dort sieht man nur den Inhalt im Light DOM, bis JavaScript die Komponente aufbaut:

<my-alert>
	<template shadowrootmode="open">
		<span>⚠</span>
		<slot></slot>
	</template>
	The build is still running.
</my-alert>

Beispiel 13 ausführen →

Das funktioniert, wenn die Komponente nach dem HTML definiert wird: Beim Upgrade gibt es den Shadow Root schon, und this.shadowRoot ?? this.attachShadow({ mode: 'open' }) im constructor behält ihn. Wird die Komponente vor dem HTML definiert, läuft der constructor, bevor der Parser das <template> gesehen hat. Chromium meldet dann "A second declarative shadow root cannot be created on a host", das <template> bleibt im Light DOM, und der per JavaScript gebaute Shadow Root gilt (Beispiel 14). Declarative Shadow DOM passt also dort, wo das Skript erst nach dem HTML läuft, etwa mit type="module" oder defer.