Props & Slots in Vue
Ein praxisnaher Vergleich von Vue.js Props und Slots mit Entscheidungshilfe und Pattern aus der Praxis
Hintergrund
Komponentenbasierte Frameworks sind heute de facto Industriestandard in der Softwareentwicklung. Komponenten strukturieren das User Interface in klar abgegrenzte Bereiche und erhöhen damit die Wiederverwendbarkeit von Code erheblich. Gleichzeitig wird die Frontend-Architektur langfristig wartbarer.
Ändern sich Anforderungen an das Layout, müssen Anpassungen in der Regel nur an einer Stelle erfolgen: in der jeweiligen Komponente selbst. Komponenten kapseln Darstellung und zugehörige Logik und ermöglichen damit auch im Frontend eine saubere Separation of Concerns.
Das Komponenten-Konzept findet sich deshalb in nahezu allen modernen Frontend- und Fullstack-Frameworks wieder. Bekannte Vertreter sind Vue.js, React, Angular, Svelte, Laravel mit Blade oder auch Freemarker. Nicht alle Lösungen bieten jedoch dieselben APIs oder den gleichen Grad an Flexibilität.
Dieser Artikel fokussiert sich auf die Konzeption von Komponenten in Vue.js.
Individualisierbarkeit meistern
Je häufiger Komponenten eingesetzt werden, desto wichtiger werden ihre Schnittstellen. Besonders bei der Umsetzung von Design-Systemen oder beim Aufbau von UI-Libraries für mehrere Projekte sind diese Entscheidungen langfristig relevant. Falsch gewählte APIs lassen sich später meist nur durch umfangreiches (und teures) Refactoring korrigieren.
Von Anfang an die richtigen Schnittstellen zu definieren, ist daher ein entscheidender Faktor für professionelle Frontend-Architektur.
Die zentrale Frage beim Entwurf von Komponenten lautet: Wie fließen Daten zwischen den Komponenten?
Für optimale Wiederverwendbarkeit müssen dieselben Komponenten sehr unterschiedliche Anforderungen erfüllen können. Eine Button-Komponente benötigt wechselnde Labels, eine Link-Komponente unterschiedliche URLs, und eine Card-Komponente zeigt in der Regel nie zweimal exakt denselben Inhalt.
Der Datenfluss ist dabei meist bidirektional: von Parent- zu Child-Komponenten (etwa über props) und von Child- zu Parent-Komponenten (z. B. über Events).
Der Datenfluss von oben nach unten existiert jedoch nahezu immer. In Vue.js stehen dafür zwei APIs zur Verfügung: props und slots.
Props
props sind der üblichste Weg Daten an eine Vue-Komponente zu übergeben. In Ihrer Syntax ähneln sie HTML-Attributen. Mit Data-Bindung (z.B. :title oder v-bind:title) können auch
reaktive Variablen übergeben werden. Reaktiv bedeutet, dass die Veränderung des Wertes dieser Variable Reaktionen nach sich zieht, ohne diese explizit auszulösen, wie etwa die automatische
Aktualisierung des UI, die Aktualisierung von computed properties usw. Innerhalb der Komponente sind Properties dann innerhalb der Logik
verfügbar (also im <script>).
props lassen sich mit TypeScript typisieren. Sie können als optional definiert werden (mit ?) und Standardwerte haben (mit withDefaults)
Folgendes Beispiel einer Card-Komponente illustriert die Verwendung von props.
props sind der klassische und am häufigsten genutzte Weg, um Daten an eine Vue-Komponente zu übergeben. In ihrer Syntax ähneln sie HTML-Attributen.
Über Data Binding (z. B. :title oder v-bind:title) lassen sich auch reaktive Variablen übergeben.
Reaktiv bedeutet dabei, dass Änderungen an diesen Variablen automatisch Folgeaktionen auslösen – etwa eine Aktualisierung des UI oder von computed Properties.
Innerhalb der Komponente stehen props sowohl im <template> als auch in der Logik (<script>) zur Verfügung.
props lassen sich mit TypeScript typisieren, als optional definieren (mit ?) und mit Standardwerten versehen (z. B. über withDefaults).
Das folgende Beispiel zeigt eine einfache Card-Komponente mit props:
<!-- Card.vue -->
<template>
<div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
<h3 v-if="title">{{ title }}</h3>
<p>
{{ content }}
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string;
content: string;
}>()
</script>
Die Komponente definiert zwei Props (title und content), die über Template-Bindings im UI ausgegeben werden.
Übergeben werden können die Props entweder statisch oder reaktiv:
<!-- App.vue -->
<!-- not reactive -->
<Card title="Hello world!" content="Lorem Ipsum" />
<!-- reactive -->
<Card :title="cardTitle" :content="cardContent" />
<script setup lang="ts">
import { ref } from 'vue';
const cardTitle = ref('John Doe');
const cardContent = ref('Turn visitors into customers with a clear message here.');
</script>
Im reaktiven Szenario aktualisiert sich das UI automatisch, sobald sich cardTitle oder cardContent ändern.
Da Props auch im <script> verfügbar sind, können sie dort weiterverarbeitet werden:
<!-- Card.vue -->
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
title?: string;
content: string;
}>()
const titleWithEmoji = computed(() => `${ props.title } 🚀`);
</script>
In diesem Beispiel aktualisiert sich titleWithEmoji immer dann, wenn sich der Wert von props.title ändert.
Die Grenze von Props
Props eignen sich hervorragend, um Komponenten zu konfigurieren oder ihnen einfache Daten zu übergeben.
Unsere <Card />-Komponente ist zwar hinsichtlich ihrer Inhalte dynamisch, legt ihre Präsentation jedoch strikt fest. Sie ist also bewusst opinionated.
Problematisch wird das insbesondere bei props wie content. Da es sich um einen einfachen string handelt, ist die Darstellung komplexerer Inhalte – etwa Buttons, Icons oder Links – nur über Umwege möglich (z. B. über VNodes).
<!-- App.vue -->
<!-- undesirable / broke -->
<Card title="Hello world!" content="<b>Lorem</b> Ipsum" />
Konzeptionell erfüllt <Card /> eine reine Layout-Funktion. Der Komponente ist es "egal" was in ihr dargestellt wird.
Merksatz:
propseignen sich besser zur Konfiguration als zur freien Visualisierung.
An dieser Stelle kommen slots ins Spiel.
Slots
Während props mit HTML-Attributen vergleichbar sind, entsprechen slots eher den Kind-Elementen eines HTML-Tags.
Der Inhalt eines Slots ist nicht in derselben Einfachheit im <script> verfügbar wie Props. Dafür ist aber auch keine explizite Deklaration notwendig. Es reicgenügtht, im <template> festzulegen, wo der Slot gerendert wird.
Die Komponente is agnostisch darüber, was im slot gerendert wird, also hinsichtlich des Inhalts "unopinionated".
<template>
<div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
<h3 v-if="title">{{ title }}</h3>
<p>
<slot />
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string;
}>()
</script>
Die Deklaration von content als prop kann in diesem Beispiel entfallen. Ab jetzt ist es für <Card /> unerheblich was der Inhalt des <p> ist. Dies können
nun mehrere Variablen, HTML-Elemente oder sogar weitere Vue-Komponenten sein, wie der <Hyperlink /> im unteren Beispiel
<!-- Hyperlink.vue -->
<template>
<a :href="href" class="text-blue-500 hover:text-blue-700">
<slot />
</a>
</template>
<script setup lang="ts">
defineProps<{
href: string;
}>()
</script>
<!-- App.vue -->
<Card :title="cardTitle">
{{ cardContent }}
<Hyperlink href="https//nils-siemsen.de">
My Homepage
</Hyperlink>
</Card>
Die Reaktivität der Variable cardContent bleibt hierbei erhalten. Durch Nutzung eines slot wird nun alles innerhalb des öffnenden und schließenden Tags
von <Card></Card> genau dort ausgegeben, wo innerhalb von <Card /> das <slot /> Element platziert wurde. Damit können wir nun auch einen Hyperlink in der
Karte platzierern, ohne zusätzlichen Konfigurationsaufwand über weitere props von <Card /> (bspw. durch eine prop "href") zu erzeugen.
Grundsätzlich lässt sich ein <slot /> beliebig oft platieren. Durch Benennung von slots ist
außerdem die Nutzung mehrerer slots in derselben Komponente möglich.
<!-- Card.vue -->
<template>
<div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
<h3 v-if="$slots.title">
<slot name="title" />
</h3>
<p>
<slot />
</p>
</div>
</template>
Wie bei props lässt sich auch bei slots prüfen, ob sie belegt sind ($slots) und entsprechendes konditionales Rendering umsetzen.
Die Grenze von Slots
Slots sind kein Allheilmittel. Am Beispiel einer <Hyperlink />-Komponente wird schnell klar, warum die korrekte Auswahl der API entscheidend ist.
Stellen wir uns einmal vor <Hyperlink /> würde wie <Card /> alle props durch slots ersetzen:
<!-- Hyperlink.vue-->
<template>
<a :href="$slots.default" class="text-blue-500 hover:text-blue-700">
<slot />
</a>
</template>
Es wird versucht den Inhalt des slot über Datenbindung mit : and das href des <a> Elements zu binden. Dies führt zu folgendem Ergebnis:
<a href="[object Object]" class="text-blue-500 hover:text-blue-700"> My Homepage </a>
Der Vue Compiler wird außerdem diese Warnung ausgeben:
Type 'VNode<RendererNode, RendererElement, { [key: string]: any; }>[] | undefined' is not assignable to type 'string | undefined'.
Die Warnung erklärt auch exakt warum :href="$slots.default" zu href="[object Object]" wird: Ein Slot hat als Wert immer ein Array aus VNode.
Dieses Nodes sind interne Abstraktionen von Vue.js. Es lässt sich zwar auf den textuellen Inhalt eines VNode zugreifen, dieser Weg ist aber deutlich komplexer als einfach props zu nutzen.
Merksatz: Slots eignen sich besser zur Visualisierung als zur Konfiguration.
Die Entscheidung
Das Ziel guter Komponenten ist maximale Wiederverwendbarkeit – idealerweise über mehrere Produkte hinweg. Jede kluge Entscheidung beim API-Design zahlt sich langfristig aus. Jede falsche Entscheidung erhöht die Kosten späterer Refactorings exponentiell für alle Produkte, die diese Komponente einbinden.
Komponenten können nur dann vielseitig eingesetzt werden, wenn sie an klar definierten Stellen über Props oder Slots individualisierbar sind. Beide APIs schließen sich nicht aus, sondern ergänzen sich:
propssteuern Verhalten und Konfigurationslotsermöglichen flexible Darstellung
Da mit steigender Anzahl an Nutzern einer Komponente nicht nur der Nutzen ihrer Entwicklung sondern auch die Anzahl an Use Cases steigt empfehle ich slots allgemein den Vorzug
zu geben, solange ihr Inhalt nicht von der Logik einer Komponente benötigt wird.
Beispiel gefällig?: Es gibt konzeptionell quasi keinen Grund folgendes zu tun:
<Card variant="large" title="Hello world!" content="Lorem Ipsum" />
Man beachte variant, was eine Design-Variante der Karte definieren könnte, die Visualisierug der übrigen Daten aber den slots überlässt
Es gibt hingegen theoretisch unendlich viele Gründe folgendes zu tun:
<Card variant="large">
<template #title>Hello World!</template>
Lorem Ipsum
</Card>
Denn jeder Nutzer der Komponente kann genau das in der Komponente darstellen, was die entsprechende Anforderung erfordert.
Meine Empfehlung lautet also:
Nutze Slots für visuelle Inhalte, die sich stark unterscheiden können. Nutze Props für Daten, die das Verhalten oder die Struktur einer Komponente beeinflussen.
Vorsicht vor Flexibilität
Denkbar sind aber spezifische Fälle, die props vor slots den Vorzug geben könnten. Es kann nämlich in Szenarien, in denen mehrere unabhängige Teams
mit denselben Komponenten arbeiten durchaus sinn ergeben, die Visualisierung durch Verzicht auf props weniger dynamisch zu gestalten.
Stellen wir uns vor wir gestalten in unserer <Card /> Komponente noch gezielt den Inhalt das <p>, weil unser Design-System vorgibt, dass jeder Text in dieser
Karte zwingend die gleiche Schrift haben muss.
<!-- Card.vue -->
<template>
<div class="shadow-xl flex flex-col gap-5 border border-gray-300 p-4 rounded-lg">
<h3 v-if="$slots.title">
<slot name="title" />
</h3>
<p class="text-base text-gray-900 font-regular">
<slot />
</p>
</div>
</template>
Verwenden wir nun slots ergibt sich folgendes Problem:
<!-- App.vue -->
<template>
<Card>
<span class="text-2xl font-bold text-green-200">I solemnly swear that I am up to no good.</span>
</Card>
</template>
Es gibt technisch nichts, das den Nutzer davon abhält, den Text einfach nach eigenem Ermessen zu gestalten. Da bei CSS die Anweisungen auf Kind-Elementen die Anweisungen auf Eltern-Elementen gleicher Spezifität überschreiben, ist ein Abweichen vom UX-Konzept problemlos möglich.
Dieses Problem lösen wir, indem wir slots durch props ersetzen.
<!-- App.vue -->
<template>
<Card :contenr="Crap. You got me" />
</template>
Eine Umgestaltung des Inhalts ist dann nicht mehr möglich.
Hier wird nun also die Individualisierbarkeit bewusst eingeschränkt, um globale Konsistenz sicherzustellen.
Die Entscheidungshilfe lässt sich somit erweitern:
Nutze dann
propsfür visuell dargestellte Inhalte, wenn ein striktes Design-System auch bei Drittparteien sichergestellt werden muss.
Dieses Problem kommt aber erst dann auf, wenn Komponenten über Team- oder Unternehemensgrenzen hinaus geteilt werden und ist damit nur eine Frage für
spezifische Enterprise-Projekte. Der Vorzug von slots zur visuellen Darstellung bleibt damit für mich erhalten.
Bedenke allerdings, dass slots für Drittparteien quasi der Todesstern sind, um Design-Systeme zu zerstören. Vertrauen muss da sein.
Bonus: Headless Components
Zum Abschluss möchte ich ein Design Pattern vorstellen, das zeigt, wie auch slots mit der internen Logik einer Komponente interagieren können.
Headless Components sind deswewegen "headless", weil der "head", also die Darstellung, komplett fehlt oder ersetzt werden kann. Komponenten dieser
Art kapseln also nur Logik und machen höchstens einen Visualisierungsvorschlag, der durch einen slot aber komplett (neu) definiert werden kann.
Das funktioniert, weil slots über scoped slots auf die Logik einer Komponente zugreifen können.
Stellen wir uns eine semantische Komponente für einen Blog vor: <Posts />. Sie hat die Aufgabe eine Menge an Blog-Artikeln als
Liste anzuzeigen.
Sie gibt eine Liste an posts mit v-for aus:
<Card v-for="post in posts">
<template #title>
{{ post.title }} [{{ post.id }}]
</template>
<div class="flex flex-col">
<i class="mb-3">by user {{ post.userId }}</i>
<p>
{{ post.body }}
</p>
</div>
</Card>
Nachdem sie von einer API geladen wurden:
onMounted(async () => {
posts.value = await fetch(props.url)
.then(res => res.json())
.catch(console.error);
})
Stellen wir nun posts über einen scoped slot zur Verfügung:
<!-- Posts.vue -->
<slot :posts="posts">
Dann kann die Parent-Komponente in diesem Slot die Darstellung von reaktiven Daten aus Child-Komponente völlig frei definieren:
<!-- App.vue -->
<Posts>
<template #default="{ posts }">
<nav class="grid grid-cols-1">
<Hyperlink v-for="post in posts" :href="`https://jsonplaceholder.typicode.com/posts/${ post.id }`">
{{ post.title }}
</Hyperlink>
</nav>
</template>
</Posts>
Dabei bleibt die volle Reaktivität von posts erhalten. Dieses Pattern erlaubt es somit auch Filter-, Such- und Paginierungs-logik einmalig zu implementieren und
in beliebig vielen Visualisierungsformen zu nutzen.
Entdeckt gerne in meinem dazu passenden Github-Repository die verschiedenen Anwendungsbereiche von props und slots!
Hi, ich bin Nils!
