Slots, Context und Web Components in Svelte
Drei Kompositions-Werkzeuge in Svelte 3: Slots reichen Inhalt von außen hinein, die Context-API vermeidet Prop-Drilling, und Custom Elements machen eine Komponente framework-neutral wiederverwendbar.
In meinen Svelte-Schulungen kommt der spannendste Moment meist nach den Grundlagen. Die Teilnehmer haben verstanden, wie Komponenten aussehen, wie Reaktivität funktioniert, wie man Daten von oben nach unten reicht. Dann stelle ich eine harmlose Aufgabe: „Baut eine wiederverwendbare Card, in die andere ihren eigenen Header und ihren eigenen Inhalt stecken können." Und plötzlich fällt auf, dass die bisher gelernten Werkzeuge nicht reichen. Props allein lösen das nicht sauber. Genau hier setzen drei Mechanismen an, die in Svelte oft in einen Topf geworfen werden, obwohl sie völlig unterschiedliche Probleme lösen: Slots, die Context-API und Web Components.
Ich behandle die drei bewusst zusammen, weil sie in der Praxis nebeneinander auftreten und weil ihre Verwechslung zu den häufigsten Ratlosigkeiten führt, die ich in Reviews sehe. Jemand nutzt einen Store, wo ein Slot gereicht hätte. Jemand reicht ein Prop durch sieben Komponenten, wo Context der richtige Weg gewesen wäre. Und jemand baut eine Komponentenbibliothek, die nur in Svelte funktioniert, obwohl das Ziel eine framework-neutrale Wiederverwendung war. Wer die drei Werkzeuge und ihre jeweilige Richtung versteht, trifft diese Entscheidungen fast automatisch richtig.
Slots: Inhalt von außen hineinreichen
Ein Slot ist ein Platzhalter in einer Komponente, den der Aufrufer von außen füllt. Die Card-Komponente entscheidet über Rahmen, Abstände und Layout – was innen steht, bestimmt der Aufrufer. Der einfachste Fall ist der Default-Slot:
<!-- Card.svelte -->
<div class="card">
<slot>Nothing here yet</slot>
</div>
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
</style>
Der Inhalt zwischen den <slot>-Tags ist der Fallback: Er erscheint nur, wenn der Aufrufer nichts übergibt. Genutzt wird die Card dann so:
<Card>
<p>This paragraph lands inside the card.</p>
</Card>
Interessant wird es mit benannten Slots. Eine Card hat oft mehr als eine Zone – einen Header und einen Body etwa. In der Kind-Komponente vergibt man dafür Namen, im Aufruf ordnet man Markup diesen Namen zu:
<!-- Card.svelte -->
<div class="card">
{#if $$slots.header}
<header class="card-header">
<slot name="header"></slot>
</header>
{/if}
<div class="card-body">
<slot></slot>
</div>
</div>
<Card>
<h2 slot="header">Release notes</h2>
<p>Everything else fills the default slot.</p>
</Card>
Das Objekt $$slots verdient einen kurzen Blick. Seine Schlüssel sind die tatsächlich übergebenen Slot-Namen, und damit lässt sich die Struktur bedingt rendern. Der <header>-Rahmen erscheint nur, wenn der Aufrufer wirklich einen Header übergeben hat – sonst hätte man einen leeren, umrandeten Kasten. Das ist der Unterschied zwischen einer Komponente, die man gerne benutzt, und einer, die man umständlich findet.
Wichtig ist dabei, dass $$slots nur die Anwesenheit eines Slots meldet, nicht dessen Inhalt zur Laufzeit. Ob ein benannter Slot befüllt ist, entscheidet sich beim Kompilieren anhand des Aufrufs, nicht durch das, was der Aufrufer später hineinrendert. Für die typische Aufgabe – Rahmen weglassen, wenn niemand einen Header liefert – reicht das genau aus, und man sollte $$slots nicht mit einer reaktiven Prüfung auf leeren Inhalt verwechseln. Ein zweiter Punkt, der in Reviews regelmäßig auffällt: Ein benannter Slot lässt sich nur an einer Stelle einer Komponente ausgeben. Wer denselben benannten Slot zweimal rendert, bekommt keinen doppelten Inhalt, sondern ein Verhalten, auf das man sich nicht verlassen sollte. Braucht man denselben übergebenen Inhalt an mehreren Stellen, führt der Weg über slot props oder über eine Umstrukturierung der Komponente, nicht über ein zweites <slot name="…">.
Slot props: Daten von innen nach außen
Bis hierher fließt Inhalt nur in eine Richtung: von außen nach innen. Der zweite Teil dreht diese Richtung um, und genau das wird am häufigsten verwechselt. Slot props erlauben es der Kind-Komponente, Daten nach außen an das eingesetzte Markup zu geben. Der klassische Fall ist eine Liste, die das Iterieren übernimmt, aber die Darstellung jedes Eintrags dem Aufrufer überlässt:
<!-- List.svelte -->
<script>
export let items = [];
</script>
<ul>
{#each items as item, index}
<li>
<slot name="item" {item} {index}>
{item}
</slot>
</li>
{/each}
</ul>
Die Kind-Komponente gibt item und index über den Slot nach außen. Der Aufrufer empfängt diese Werte mit der let:-Directive und darf frei entscheiden, wie ein Eintrag aussieht:
<List items={users}>
<span slot="item" let:item let:index>
{index + 1}. {item.name} – {item.role}
</span>
</List>
Die Liste kümmert sich um Schleife, Schlüssel und Struktur; der Aufrufer bringt die konkrete Darstellung mit und bekommt dafür genau die Daten, die er braucht. Diese beiden Richtungen sauber auseinanderzuhalten ist der Kern des Slot-Verständnisses: Markup fließt von außen hinein, Daten fließen über slot props von innen nach außen. Das folgende Diagramm hält beide Wege nebeneinander fest.
flowchart LR
Parent["Parent<br/><List let:item>"]
Child["List.svelte<br/><slot name='item' {item}>"]
Render["Rendered <li>"]
Parent -- "markup (outside in)" --> Child
Child -- "slot props: item, index (inside out)" --> Parent
Child --> Render
Context: durch den Baum reichen ohne Prop-Drilling
Slots lösen die Frage, wie man Inhalt an eine direkte Kind-Komponente gibt. Sie helfen nicht, wenn ein Wert mehrere Ebenen tief benötigt wird. Stellen wir uns eine Toolbar vor, deren einzelne Buttons sich bei ihr registrieren wollen. Zwischen Toolbar und Button liegen vielleicht noch Gruppen, Trenner, Wrapper. Den Registrierungs-Callback durch jede dieser Ebenen als Prop zu reichen, ist mühsam und macht Zwischenkomponenten von Details abhängig, die sie gar nichts angehen. Dieses Weiterreichen nennt man Prop-Drilling, und die Context-API ist die direkte Antwort darauf.
Die Provider-Komponente legt einen Wert unter einem Schlüssel ab, jede Nachfahr-Komponente kann ihn unter demselben Schlüssel wieder abrufen:
<!-- Toolbar.svelte -->
<script>
import { setContext } from 'svelte';
const buttons = [];
function registerButton(id) {
buttons.push(id);
}
setContext('toolbar', { registerButton });
</script>
<div class="toolbar">
<slot></slot>
</div>
<!-- ToolbarButton.svelte -->
<script>
import { getContext } from 'svelte';
export let id;
const toolbar = getContext('toolbar');
toolbar.registerButton(id);
</script>
<button on:click>
<slot></slot>
</button>
Der Button findet seine Toolbar, egal wie viele Ebenen dazwischenliegen. Kein Prop wandert durch die Zwischenkomponenten. Der Schlüssel darf übrigens ein beliebiger Wert sein – ein String wie hier, ein Symbol oder ein Objekt; ein Symbol schützt zuverlässig vor Namenskollisionen in größeren Repositories.
Der Geltungsbereich von Context ist dabei streng auf den Teilbaum unterhalb des Providers beschränkt. Eine Nachbarkomponente, die nicht unter derselben setContext-Komponente hängt, sieht den Wert nicht – Context fließt ausschließlich nach unten, nie zur Seite und nie nach oben. Ruft eine Kind-Komponente ihrerseits setContext mit demselben Schlüssel auf, überschattet sie den Wert für ihren eigenen Teilbaum, ohne den Wert weiter oben zu verändern; die Auflösung folgt also der Instanzhierarchie, nicht der Reihenfolge im Quelltext. Wer sich nicht sicher ist, ob ein Schlüssel überhaupt gesetzt wurde, kann das mit hasContext prüfen, statt sich auf ein undefined von getContext zu verlassen – das macht den Fehlerfall explizit statt still.
Zwei Fallstricke sehe ich in Schulungen fast garantiert. Der erste: setContext muss synchron während der Initialisierung der Komponente aufgerufen werden. Wer den Aufruf in onMount, einen Event-Handler oder eine async-Funktion verschiebt, bekommt keinen Fehler, sondern etwas Schlimmeres – die Kinder erhalten beim späteren getContext schlicht undefined, und die Ursache ist schwer zu finden. Der zweite Fallstrick betrifft Reaktivität.
graph TD
Provider["Toolbar<br/>setContext('toolbar', …)"]
Group["ButtonGroup<br/>(keine Props)"]
Divider["Divider<br/>(keine Props)"]
Consumer["ToolbarButton<br/>getContext('toolbar')"]
Provider --> Group --> Divider --> Consumer
Context ist nicht dasselbe wie ein Store
Context ist an die Komponenteninstanz gebunden und für sich genommen nicht reaktiv. Lege ich einen einfachen Zahlenwert in den Context und ändere ihn später, aktualisieren sich die Kinder nicht – sie haben ihre Kopie beim Init gelesen, und dabei bleibt es. Ein Store dagegen ist global teilbar und reaktiv; mit $store abonniert man Änderungen und rendert bei jeder Aktualisierung neu. Über den Zustand außerhalb der Komponente und die Store-Mechanik habe ich in Svelte Stores ausführlicher geschrieben, und die zugrundeliegende Reaktivität ist Thema von Svelte-Reaktivität.
Die beiden schließen sich nicht aus, im Gegenteil. Die in der Praxis nützlichste Kombination legt einen Store in den Context. Damit bekommt man beides: die implizite Verteilung durch den Baum von Context und die Reaktivität eines Stores.
<!-- ThemeProvider.svelte -->
<script>
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
const theme = writable('light');
setContext('theme', theme);
</script>
<slot></slot>
<!-- ThemeToggle.svelte -->
<script>
import { getContext } from 'svelte';
const theme = getContext('theme');
</script>
<button on:click={() => theme.update((t) => (t === 'light' ? 'dark' : 'light'))}>
Current theme: {$theme}
</button>
Jetzt reicht Context den Store an jede Nachfahr-Komponente, und weil es ein Store ist, reagiert {$theme} auf jede Änderung – ohne ein einziges Prop durch den Baum zu fädeln.
Web Components: framework-neutrale Wiederverwendung
Slots und Context lösen Kompositionsprobleme innerhalb einer Svelte-Anwendung. Das dritte Werkzeug zielt nach außen. Manchmal soll eine Komponente auch dort funktionieren, wo kein Svelte läuft – in einer bestehenden Seite, in einem anderen Framework, in reinem HTML. Svelte kann eine Komponente als Custom Element kompilieren, also als echtes Web Component nach Browser-Standard.
Zwei Dinge braucht es dafür. In der Komponente vergibt man über <svelte:options> einen Tag-Namen, und beim Kompilieren aktiviert man die Option customElement:
<!-- UserBadge.svelte -->
<svelte:options tag="user-badge" />
<script>
export let name = 'Unknown';
export let status = 'offline';
</script>
<span class="badge" data-status={status}>
{name}
</span>
<style>
.badge {
display: inline-flex;
padding: 0.25rem 0.5rem;
border-radius: 999px;
}
.badge[data-status='online'] {
background: #d1fae5;
}
</style>
Beim Bundling schaltet man den Compiler auf Custom-Element-Ausgabe. Mit Rollup und dem offiziellen Plugin sieht der relevante Teil so aus:
// rollup.config.js
import svelte from 'rollup-plugin-svelte';
export default {
// ...
plugins: [
svelte({
compilerOptions: {
customElement: true,
},
}),
],
};
Danach ist <user-badge> ein ganz normales HTML-Element. Es lässt sich in jeder Seite verwenden, ganz ohne Svelte-Laufzeit im Aufrufer:
<script src="./user-badge.js"></script>
<user-badge name="Ada" status="online"></user-badge>
Die exportierten Props werden zu Element-Properties und -Attributen, sodass man das Element wie jedes andere DOM-Element ansprechen kann. Svelte kapselt die Styles per Shadow DOM – die Badge-Styles greifen nicht auf die umgebende Seite über, und Seiten-Styles dringen nicht unkontrolliert hinein. Das ist meist erwünscht, überrascht aber, wenn man globale Styles erwartet; :global-Regeln verhalten sich hier anders.
An dieser Stelle lohnt ein ehrlicher Blick auf die rauen Kanten, denn Ende 2022 ist der Custom-Element-Modus von Svelte funktional, aber nicht spannungsfrei. Der wichtigste Punkt betrifft die Datenübergabe: HTML-Attribute sind immer Strings. Ein name="Ada" im Markup kommt sauber an, aber ein Objekt oder ein Array lässt sich nicht als Attribut übergeben. Solche Werte muss man als JavaScript-Property auf dem Element setzen, etwa el.items = [...] – ein Detail, das man leicht übersieht, wenn man das Element nur deklarativ im HTML verwendet. Zweitens sind Custom Elements nach dem Definieren global registriert; derselbe Tag-Name lässt sich im selben Dokument nicht zweimal belegen, was beim Einbinden mehrerer Bundles zu Konflikten führen kann. Und drittens ist die Shadow-DOM-Kapselung zwar ein Gewinn für die Isolation, kostet aber die einfache Durchgriffsmöglichkeit über globale CSS-Variablen nur dann nichts, wenn man sie bewusst als Custom Properties von außen hineinreicht. Nichts davon ist ein Ausschlusskriterium, aber es sind die Stellen, an denen die Praxis von der ersten glatten Demo abweicht.
Ein praktischer Hinweis aus Projekten: customElement: true gehört nur auf die Komponenten, die man tatsächlich exportieren will, nicht projektweit. Sonst kompiliert man die ganze App zu Custom Elements und verliert den regulären Svelte-Komponentenmodus mit seinen Vorteilen. In der Praxis pflege ich dafür einen separaten Build-Eintrag, der genau die zu exportierenden Komponenten bündelt.
Wann welches Werkzeug
Die drei Mechanismen lassen sich klar nach ihrem Zweck sortieren:
- Slots wählt man, wenn eine Komponente ihr Layout vorgibt, den Inhalt aber vom Aufrufer bekommen soll – mit slot props auch dann, wenn der Aufrufer für die Darstellung Daten aus dem Inneren braucht.
- Context wählt man, wenn ein Wert oder eine Funktion mehreren Ebenen tiefer benötigt wird und Prop-Drilling die Zwischenkomponenten unnötig belasten würde; für reaktive Werte legt man einen Store in den Context.
- Web Components wählt man, wenn eine Komponente außerhalb der Svelte-Welt laufen soll – in fremden Frameworks oder in reinem HTML.
Ein kurzer Hinweis zur Einordnung Ende 2022: Ich beziehe mich hier durchgehend auf Svelte 3, die aktuelle Version dieser Tage. Die gezeigte Syntax tag="user-badge" ist der Stand von Svelte 3; spätere Versionen haben an dieser Stelle Änderungen gebracht, die hier bewusst außen vor bleiben. Wer das Repository, an dem er arbeitet, auf Svelte 3 hält, ist mit allem hier Gezeigten auf sicherem Boden.
Fazit
Slots, Context und Web Components wirken zunächst wie drei zusammenhanglose Features, sind aber ein zusammengehöriges Set von Kompositions-Werkzeugen, jedes mit einer eigenen Richtung. Slots reichen Markup von außen hinein und über slot props Daten von innen wieder heraus. Context verteilt Werte implizit durch den Komponentenbaum und erspart das Prop-Drilling – reaktiv wird es erst mit einem Store darin. Und Custom Elements lösen eine Komponente aus der Svelte-Welt heraus, sodass sie überall dort läuft, wo Standard-HTML läuft. Wer sich bei jedem Kompositionsproblem kurz fragt, in welche Richtung Inhalt oder Daten fließen sollen und wie weit sie reichen müssen, greift fast von selbst zum passenden der drei Werkzeuge. Genau diese Frage ist es, die aus Ratlosigkeit in der Übung eine ruhige Entscheidung im Projekt macht.
Weiterführende Quellen
- Svelte-Doku, Slots und
let:-Directive: https://svelte.dev/docs#template-syntax-slot - Svelte-Doku, Context-API (
setContext/getContext/hasContext): https://svelte.dev/docs#run-time-svelte-setcontext - Svelte-Doku, Custom-Element-API: https://svelte.dev/docs#run-time-custom-element-api
- Svelte-Doku,
<svelte:options>: https://svelte.dev/docs#template-syntax-svelte-options - Svelte-Tutorial (Slots, Context, Custom Elements): https://svelte.dev/tutorial
Kommentare