Props: Daten von oben
Kurz gesagt: Props sind die Daten, die eine Komponente von ihrer Elternkomponente bekommt, so wie eine Funktion Parameter bekommt; die Kindkomponente ändert sie nicht, und Slots reichen Inhalt von außen herein.
Problem
Dreimal Brot
Der Katalog soll drei Produkte zeigen: Brot, Milch und Käse. Die Produktkarte ist aus der letzten Lektion fertig, also setzt App sie dreimal ein.
Konsole
Alle drei Karten sind Brot. Das Produkt steht in ProductCard selbst, und jede Karte zeigt es. Der andere Weg wären BreadCard, MilkCard und CheeseCard: drei Kopien derselben Komponente, und jede Änderung an der Karte müsste dreimal gemacht werden. Es fehlt ein Weg, der Karte zu sagen, welches Produkt sie zeigen soll.
Die Idee
Props: Daten von oben
Eine Komponente ist eine Funktion. Eine Funktion bekommt ihre Daten als Parameter, und eine Komponente bekommt sie als Attribute von ihrer Elternkomponente: der Komponente, die sie benutzt. Diese Attribute heißen Props (von „properties“). Die Elternkomponente schreibt sie, die Kindkomponente liest sie.
<ProductCard :product="bread" /><ProductCard :product="milk" /><script setup>defineProps({ product: Object });</script>
<template> <h2>{{ product.name }}</h2></template>defineProps sagt, welche Props die Komponente annimmt. Das Template sieht sie direkt, unter ihrem Namen. Im Script stehen sie in dem Objekt, das defineProps zurückgibt: const props = defineProps(…), und dann props.product.name. Der Doppelpunkt in :product ist derselbe wie bei jedem Attribut: Der Wert ist ein Ausdruck, kein Text.
Es gibt eine Karte, und jede Verwendung hat andere Daten. Bei einer Funktion ist es genauso: eine Definition, viele Aufrufe mit unterschiedlichen Argumenten.
Sieh es dir an
Sieh, wie sie fließen
<script setup>defineProps({ product: Object, discount: { type: Number, default: 0 },});</script>
<template> <article> <h2>{{ product.name }}</h2> <p>Preis: {{ money(product.price * (1 - discount / 100)) }}</p> </article></template>
<!-- App.vue --><script setup>const bread = { name: 'Weißbrot', price: 1.2 };const milk = { name: 'Frischmilch', price: 1.6 };const cheese = { name: 'Weißkäse', price: 7.9 };</script>
<template> <main> <h1>Brot und Milch</h1> <div> <ProductCard :product="bread" /> <ProductCard :product="milk" /> <ProductCard :product="cheese" :discount="10" /> </div> </main></template>Schritt für Schritt, in Worten
- App hält die Daten: drei Produkte. Darunter stehen drei ProductCards: dieselbe Komponente dreimal, und keine weiß schon, was sie zeigen soll.
- :product="bread" übergibt das Objekt bread an die erste Karte. Die Attribute einer Komponente heißen Props.
- Die anderen beiden Karten bekommen ihr eigenes Produkt. Die dritte bekommt außerdem discount = 10. Jede Karte hat ihre eigenen Props.
- Jede Karte zeichnet sich aus ihren Props. Brot und Milch haben keinen discount bekommen, deshalb ist er standardmäßig 0. Der Käse ist um 10 % reduziert: 7,11 €.
- Versucht die Karte props.discount = 0, geht nichts nach oben: Vue warnt „target is readonly“ und führt die Änderung nicht aus. Die Daten in App bleiben gleich.
- Daten fließen nur in eine Richtung: von der Elternkomponente nach unten zur Kindkomponente. Um oben etwas zu ändern, muss die Kindkomponente die Elternkomponente bitten. Das ist das Modul „Zustand“.
Jede Karte kennt nur ihre eigenen Props. Sie sieht die anderen Karten nicht und weiß nicht, woher die Daten in App kommen.
Sieh es dir an
Eine Karte, drei Produkte
Konsole
Die Produkte stehen in App, und es gibt eine ProductCard. Füge in App ein viertes Produkt hinzu (das Bild img/apples.svg gibt es) und noch ein <ProductCard … />. Die Karte fasst du nicht an. Bei 30 Produkten willst du eine Liste statt 30 Zeilen: Das ist die nächste Lektion.
Probier es aus
Finde den Fehler
Die Karte erscheint nicht, und in der Konsole steht „TypeError: Cannot read properties of undefined (reading 'toFixed')“.
Wähle die Zeile mit dem Fehler.
product="bread" ohne Doppelpunkt übergibt den Text „bread“, nicht das Objekt. Text hat kein price, also ist product.price undefined, und money scheitert an toFixed. Das Objekt übergibst du mit Doppelpunkt: :product="bread". Hätte die Karte product: Object angegeben, warnt Vue schon vorher: „Invalid prop: type check failed for prop "product". Expected Object, got String“.Die Idee
Ein Standardwert
Manche Produkte sind im Angebot. Die Karte bekommt ein zweites Prop, discount, in Prozent. Die meisten Produkte haben keinen Rabatt, und es ist nicht schön, wenn jede Zeile in App discount={0} schreiben muss. Ein fehlendes Prop ist undefined, wie ein fehlendes Argument einer Funktion. Der Preis wird zu price * (1 - undefined / 100), also NaN.
Deshalb bekommt das Prop einen Standardwert:
defineProps({ product: Object, discount: { type: Number, default: 0 },});default ist der Wert, wenn die Elternkomponente nichts übergibt. type ist eine Prüfung: Übergibst du discount="10" (Text, ohne Doppelpunkt), warnt Vue in der Konsole „Invalid prop: type check failed“. Die Prüfung läuft nur im Entwicklungsmodus und warnt nur.
Probier es aus
Ein Rabatt
App gibt dem Käse einen discount von 10 (Prozent). Sorge dafür, dass die Karte discount annimmt, standardmäßig 0, und den Preis mit Rabatt zeigt: das Brot 1,20 €, die Milch 1,60 €, der Käse 7,11 €.
Konsole
Lösung
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Preis: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const bread = { name: 'Weißbrot', price: 1.2 };
const milk = { name: 'Frischmilch', price: 1.6 };
const cheese = { name: 'Weißkäse', price: 7.9 };
</script>
<template>
<main>
<h1>Brot und Milch</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
discount: { type: Number, default: 0 } in defineProps und der Preis money(product.price * (1 - discount / 100)). Mit discount: Number und ohne default zeigen das Brot und die Milch „NaN €“: Die Prüfung schaut auf alle drei Preise.
Die Idee
Schreibgeschützt
Die Daten gehören dem, der sie hält. Die Karte kann ihre Props lesen, aber nicht ändern. Probier den Button „Noch eins“: Die Kindkomponente versucht, ihr eigenes qty zu erhöhen.
Konsole
Die Menge bleibt 2, und die Konsole meldet „[Vue warn] Set operation on key "qty" failed: target is readonly.“. Vue gibt der Komponente schreibgeschützte Props und warnt bei einem Schreibversuch.
Selbst wenn es durchginge, wäre die Änderung nur in der Kindkomponente: App würde weiter 2 sagen und bei der nächsten Zeichnung wieder 2 übergeben. Zwei Orte mit verschiedenen Wahrheiten sind der Fehler aus „Eine Liste aus Daten“. Die Regel: Daten fließen nach unten, und sie zu ändern ist Sache der Elternkomponente. Wie eine Kindkomponente ihre Elternkomponente bittet, etwas zu ändern, ist das Modul „Zustand“.
Die Idee
Inhalt von außen
Der Shop hat Rahmen, die gleich aussehen und Verschiedenes enthalten: „Lieferung“ mit zwei Absätzen, „Rückgabe“ mit einem. Der Titel ist ein Prop. Der Inhalt ist ein ganzes Stück HTML, und es ist handlicher, es zwischen das öffnende und das schließende Tag zu schreiben, wie bei <section>.
<Card title="Lieferung"> <p>Innerhalb von 2 Werktagen.</p></Card><template> <section class="card"> <h2>{{ title }}</h2> <slot /> </section></template>Der Inhalt zwischen <Card> und </Card> kommt dorthin, wo <slot /> steht. Card entscheidet, wo der Slot ist.
Konsole
Probier es aus
Der Rahmen
Card zeigt nur den Titel. Sorge dafür, dass es unter dem Titel auch den Inhalt zeigt, den App zwischen <Card> und </Card> schreibt.
Konsole
Lösung
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
App.vue<script setup>
import Card from './Card.vue';
</script>
<template>
<main>
<h1>Der Warenkorb</h1>
<Card title="Lieferung">
<p>Innerhalb von 2 Werktagen.</p>
<p>Kostenlos ab 30 €.</p>
</Card>
<Card title="Rückgabe">
<p>Bis zu 14 Tage nach der Lieferung.</p>
</Card>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
<slot /> unter dem Titel. Ein benannter Slot (<slot name="content" />) funktioniert nicht: App gibt keinen Namen an, also landet der Inhalt im Standard-Slot, und den gibt es nicht. Setzt du den Slot in <h2>, werden die Absätze Teil der Überschrift, und sie heißt nicht mehr „Lieferung“.
Genauer, für Neugierige
Benannte Slots. Ein Rahmen kann mehrere Stellen haben: <slot /> für den Hauptteil und <slot name="footer" /> für den unteren Teil. Die Elternkomponente füllt einen benannten Slot mit <template #footer>…</template>.
Namen. Im Script ist ein Prop camelCase (productId), und im Template der Elternkomponente darf es auch kebab-case sein (:product-id="7"). Vue verbindet beide.
Props als Variablen. Seit Vue 3.5 kannst du const { product, discount = 0 } = defineProps(['product', 'discount']) schreiben: Der Standardwert funktioniert wie in JavaScript, und die Variablen bleiben mit der Elternkomponente verbunden.
Nur die erste Ebene ist eingefroren. props.qty = 3 warnt, aber props.product.price = 2 geht still durch: Vue schützt nur die erste Ebene vor dem Schreiben, und das Produktobjekt ist dasselbe wie in App. So ändert die Kindkomponente die Daten der Elternkomponente hinter deren Rücken. Tu das nicht.
Typen. Neben type und default gibt es required: true (eine Warnung, wenn die Elternkomponente das Prop nicht übergibt) und validator (eine Funktion, die den Wert prüft). Alles läuft nur im Entwicklungsmodus. Mit TypeScript schreibt man die Typen in defineProps<{ … }>().
Übung
Das Schaufenster
Bau das Schaufenster von Grund auf. ProductCard nimmt product und discount (standardmäßig 0) und zeigt das Bild (alt ist der Name), den Namen als Überschrift der Ebene zwei und „Preis: …“ mit dem Rabatt. Card nimmt title und zeigt den Titel (Ebene zwei) und den Inhalt von außen. In App, unter der Überschrift: die drei Karten in einem div (der Käse mit discount 10), dann eine Card „Lieferung“ mit „Innerhalb von 2 Werktagen.“ und „Kostenlos ab 30 €.“.
Konsole
Lösung
App.vue<script setup>
import ProductCard from './ProductCard.vue';
import Card from './Card.vue';
const bread = { name: 'Weißbrot', price: 1.2, image: 'img/bread.svg' };
const milk = { name: 'Frischmilch', price: 1.6, image: 'img/milk.svg' };
const cheese = { name: 'Weißkäse', price: 7.9, image: 'img/cheese.svg' };
</script>
<template>
<main>
<h1>Das Schaufenster</h1>
<div>
<ProductCard :product="bread" />
<ProductCard :product="milk" />
<ProductCard :product="cheese" :discount="10" />
</div>
<Card title="Lieferung">
<p>Innerhalb von 2 Werktagen.</p>
<p>Kostenlos ab 30 €.</p>
</Card>
</main>
</template>
ProductCard.vue<script setup>
defineProps({
product: Object,
discount: { type: Number, default: 0 },
});
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<img :src="product.image" :alt="product.name" />
<h2>{{ product.name }}</h2>
<p>Preis: {{ money(product.price * (1 - discount / 100)) }}</p>
</article>
</template>
Card.vue<script setup>
defineProps({ title: String });
</script>
<template>
<section class="card">
<h2>{{ title }}</h2>
<slot />
</section>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
main > div { display: flex; flex-wrap: wrap; gap: 12px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 8px 12px; width: 140px; }
article img { width: 56px; height: 56px; }
h2 { font-size: 16px; margin: 4px 0; }
p { margin: 4px 0; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; background: #fbf8f2; padding: 4px 12px; margin-top: 12px; max-width: 320px; }
button { font: inherit; margin-left: 8px; }
ProductCard: defineProps({ product: Object, discount: { type: Number, default: 0 } }), <img :src="product.image" :alt="product.name" />, <h2>{{ product.name }}</h2> und der Preis mit dem Rabatt. Card: defineProps({ title: String }), <section class="card">, <h2>{{ title }}</h2> und <slot />. App: <ProductCard :product="cheese" :discount="10" /> und der Inhalt zwischen <Card title="Lieferung"> und </Card>. Die Prüfung findet die Bilder über alt, die Preise der drei Karten und die Absätze in .card.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Text, Links, Bilder, Tabellen · Barrierefreiheit · Eine Liste aus Daten · Die erste Komponente
- Geht weiter in: Listen und Keys · Bedingungen: leer, Fehler, lädt · Zustand · Abgeleiteter Zustand
- Aufgaben zum Thema: Komponenten