Events und v-model
Auftrag
Martina Hostettler hat die Wünsche vom Tresen gesammelt.
Als Mitglied möchte ich ein Ding direkt auf seiner Karte reservieren und als Favorit markieren, damit ich schneller zum Formular komme und meine Lieblingsdinge wiederfinde.
Die Akzeptanzkriterien lauten:
- Der Knopf «Reservieren» auf einer Karte öffnet die Seite des Dings mit dem Formular.
- Ein Knopf «Favorit» markiert ein Ding und hebt die Markierung wieder auf, und ein Screenreader erfährt, ob der Knopf gedrückt ist.
- Im Kopf steht, wie viele Favoriten es gibt.
- Suchfeld und Kategorie sind eigene Komponenten und funktionieren wie bisher.
Lernziele
Nach diesem Kapitel kannst du
- mit
defineEmitsEvents deklarieren und mit Daten auslösen, - in der Elternkomponente auf Events reagieren,
- Events in der Objektschreibweise prüfen,
v-modelauf eigenen Komponenten mitdefineModelumsetzen,- mehrere
v-modelauf einer Komponente verwenden.
Events auslösen
Abschnitt betitelt „Events auslösen“Props tragen Daten von der Elternkomponente zum Kind. Für den Weg zurück löst das Kind ein Event aus, und die Elternkomponente reagiert darauf. Die Karte meldet also nur, dass jemand auf «Reservieren» geklickt hat. Was dann passiert, entscheidet die Komponente, der die Daten gehören.
defineEmits deklariert die Events einer Komponente und gibt eine Funktion zurück, mit der sie sie auslöst. Lies das Beispiel und überleg dir, was nach einem Klick auf die zweite Karte erscheint.
App.vue
<script setup>import { ref } from 'vue'
import ItemCard from './ItemCard.vue'
const message = ref('')
// The parent decides what a click on the button meansfunction reserve(id) { message.value = `Reservation für das Ding mit der id ${id} angefragt.`}</script>
<template> <ItemCard :id="1" name="Bohrmaschine" @reserve="reserve" /> <ItemCard :id="6" name="Beamer" @reserve="reserve" /> <p>{{ message }}</p></template>ItemCard.vue
<script setup>defineProps({ id: { type: Number, required: true }, name: { type: String, required: true },})
// The card only reports the click, it does not know what happens nextconst emit = defineEmits(['reserve'])</script>
<template> <article class="card"> <h2>{{ name }}</h2> <button type="button" @click="emit('reserve', id)">Reservieren</button> </article></template>
<style scoped>.card { display: flex; gap: 1rem; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; padding: 0.5rem 1rem; border: 1px solid #c8c8c8; border-radius: 8px;}h2 { margin: 0; font-size: 1.05rem;}</style>emit('reserve', id) löst das Event reserve aus und gibt die id als Daten mit. App hört mit @reserve="reserve" zu, wie bei einem Klick auf ein HTML-Element, und die Funktion reserve bekommt die id als Argument. Anders als Events im DOM steigen die Events einer Komponente nicht weiter nach oben. Nur die direkte Elternkomponente kann zuhören.
Ein Event heisst nach dem, was passiert ist, etwa reserve oder toggleFavorite, nicht nach dem, was die Elternkomponente damit tun soll. Im Script schreibst du die Namen in camelCase, im Template der Elternkomponente meist in kebab-case, @toggle-favorite. Vue setzt beide Schreibweisen gleich, wie bei Props.
Events prüfen
Abschnitt betitelt „Events prüfen“Statt eines Arrays nimmt defineEmits auch ein Objekt. Zu jedem Event gehört dann eine Funktion, die die Daten prüft und true oder false zurückgibt. Liefert sie false, schreibt Vue während der Entwicklung eine Warnung in die Konsole, löst das Event aber trotzdem aus.
App.vue
<script setup>import ItemCard from './ItemCard.vue'
function reserve(id) { console.log('reserve', id)}</script>
<template> <ItemCard :id="1" name="Bohrmaschine" @reserve="reserve" /> <!-- Without the colon, the id arrives as the text "6" --> <ItemCard id="6" name="Beamer" @reserve="reserve" /></template>ItemCard.vue
<script setup>defineProps({ id: { type: Number, required: true }, name: { type: String, required: true },})
// In the object notation every event gets a check, Vue warns in the console if it failsconst emit = defineEmits({ reserve: (id) => Number.isInteger(id),})</script>
<template> <article class="card"> <h2>{{ name }}</h2> <button type="button" @click="emit('reserve', id)">Reservieren</button> </article></template>
<style scoped>.card { display: flex; gap: 1rem; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; padding: 0.5rem 1rem; border: 1px solid #c8c8c8; border-radius: 8px;}h2 { margin: 0; font-size: 1.05rem;}</style>Die zweite Karte bekommt ihre id als Text «6», weil der Doppelpunkt fehlt. Beim Laden warnt Vue vor dem falschen Typ der Prop, beim Klick vor den Daten des Events mit Invalid event arguments: event validation failed for event "reserve".. Die Deklaration mit defineEmits dokumentiert ausserdem, welche Events eine Komponente kennt. Wer sie verwendet, sieht auf einen Blick, worauf sie hören kann.
v-model auf eigenen Komponenten
Abschnitt betitelt „v-model auf eigenen Komponenten“Das Suchfeld wird zur Komponente SearchBar. Der Suchtext gehört weiter App, die Komponente soll ihn aber lesen und ändern können, wie ein Eingabefeld mit v-model. Dafür gibt es seit Vue 3.4 das Makro defineModel.
App.vue
<script setup>import { computed, ref } from 'vue'
import SearchBar from './SearchBar.vue'
const names = ['Bohrmaschine', 'Stichsäge', 'Kreissäge', 'Leiter', 'Beamer']const query = ref('')
const visibleNames = computed(() => names.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())))</script>
<template> <SearchBar v-model="query" /> <p>Suchtext in App: {{ JSON.stringify(query) }}</p> <ul> <li v-for="name in visibleNames" :key="name">{{ name }}</li> </ul></template>SearchBar.vue
<script setup>// The parent binds a value with v-model, the field reads it and changes itconst query = defineModel({ type: String, default: '' })</script>
<template> <label for="search">Suchen</label> <input id="search" v-model="query" type="search"></template>defineModel gibt ein Ref zurück. Liest SearchBar es, bekommt sie den Wert von App. Ändert sie es, weil jemand tippt, ändert sich der Wert in App. Dahinter stecken eine Prop und ein Event, wie du sie schon kennst. Die beiden Zeilen tun dasselbe.
<SearchBar v-model="query" /><SearchBar :model-value="query" @update:model-value="query = $event" />Die Prop heisst modelValue, das Event update:modelValue. defineModel deklariert beides und spart dir den Code, der sie verbindet.
Mehrere v-model
Abschnitt betitelt „Mehrere v-model“Eine Komponente kann mehrere Werte binden. Jedes Model bekommt dann einen Namen, defineModel('query') und defineModel('category'), und die Elternkomponente bindet sie mit v-model:query und v-model:category.
App.vue
<script setup>import { ref } from 'vue'
import FilterBar from './FilterBar.vue'
const query = ref('')const category = ref('')const categories = ['Camping', 'Garten', 'Werkzeug']</script>
<template> <FilterBar v-model:query="query" v-model:category="category" :categories="categories" /> <p>{{ JSON.stringify({ query, category }) }}</p></template>FilterBar.vue
<script setup>defineProps({ categories: { type: Array, required: true } })
// Each model has a name, the parent binds it with v-model:query and v-model:categoryconst query = defineModel('query', { type: String, default: '' })const category = defineModel('category', { type: String, default: '' })</script>
<template> <div class="filters"> <label for="query">Suchen</label> <input id="query" v-model="query" type="search"> <label for="category">Kategorie</label> <select id="category" v-model="category"> <option value="">Alle Kategorien</option> <option v-for="name in categories" :key="name">{{ name }}</option> </select> </div></template>Die Fallbeispiel-App nutzt das für die Seite mit dem Katalog. App besitzt Suchtext und Kategorie, damit beides einen Besuch auf der Seite eines Dings übersteht, und CatalogView bindet sie mit zwei Models an SearchBar und CategoryFilter.
Favoriten
Abschnitt betitelt „Favoriten“Favoriten zeigen, wie Props und Events zusammenspielen. App besitzt die ids der Favoriten in einem Array. Jede Karte bekommt mit der Prop favorite gesagt, ob sie ein Favorit ist, und meldet einen Klick mit dem Event toggleFavorite. App ändert das Array, und Vue aktualisiert die Karte und den Kopf, der die Favoriten zählt.
Der Knopf «Favorit» ist ein Schalter. :aria-pressed="favorite" sagt einem Screenreader, ob er gedrückt ist, und das CSS kann ihn mit dem Selektor [aria-pressed='true'] hervorheben. Bis Teil 5 vergisst die App die Favoriten beim Neuladen, dann kommt ein Composable, das sie sich merkt.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit dem Beispiel zur Prüfung der Events. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Klick auf beide Knöpfe «Reservieren». Was steht danach in der Konsole des Playgrounds, und welche Zeile stammt von
App? - Setz den Doppelpunkt vor
id="6". Welche Warnungen verschwinden? - Ändere die Prüfung auf
(id) => id > 0und nimm den Doppelpunkt wieder weg. Warum warnt Vue jetzt beim Klick nicht mehr? - Wechsle zum Beispiel mit
defineModelund setz inAppden Startwert vonqueryauf «säge». Was zeigen das Feld und die Liste?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Karte und den Katalog aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/04-components/events/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Ergänze in ItemCard einen Knopf «Reservieren» mit der Klasse reserve. Ein Klick löst das Event reserve mit der id des Dings aus, deklariert mit defineEmits. App zeigt danach in einem Element mit der Klasse message zum Beispiel «Reservation für Bohrmaschine angefragt.».
Lösung
ItemCard deklariert das Event und löst es beim Klick aus.
// The card only reports what happened, App decides what followsconst emit = defineEmits({ reserve: (id) => Number.isInteger(id), toggleFavorite: (id) => Number.isInteger(id),}) <button type="button" class="reserve" @click="emit('reserve', id)">Reservieren</button>App hört mit @reserve="reserve" zu und sucht das Ding zur id.
function reserve(id) { const item = items.find((entry) => entry.id === id) message.value = `Reservation für ${item.name} angefragt.`}Die Lösung deklariert schon das Event der Knobelei mit. Für den Einstieg genügt defineEmits(['reserve']).
Aufgabe Vertiefung
Mach aus dem Suchfeld die Komponente src/components/SearchBar.vue. Sie bindet den Suchtext mit defineModel, und App verwendet sie mit <SearchBar v-model="query" /> statt des Felds. Die Suche funktioniert danach wie vorher.
Lösung
<script setup>// App binds the search text with v-model, the field reads it and changes itconst query = defineModel({ type: String, default: '' })</script>
<template> <div class="search"> <label for="search">Suchen</label> <input id="search" v-model="query" type="search"> </div></template>query ist das Ref aus defineModel, und das Feld bindet es mit v-model wie ein gewöhnliches Ref. App behält const query = ref('') und den berechneten Wert visibleItems und ersetzt nur Beschriftung und Feld durch <SearchBar v-model="query" />.
Aufgabe Knobelei
Ergänze auf der Karte einen Knopf «Favorit» mit der Klasse favorite. Die Karte bekommt die Prop favorite, zeigt sie mit aria-pressed an und meldet einen Klick mit dem Event toggleFavorite und der id. App besitzt die ids der Favoriten und zeigt in einem Element mit der Klasse favorites, wie viele es sind, «2 Favoriten» oder «1 Favorit».
Lösung
<script setup>import { computed, ref } from 'vue'
import ItemCard from '@/components/ItemCard.vue'import SearchBar from '@/components/SearchBar.vue'import items from '../data/items.json'
const query = ref('')const message = ref('')// The ids of the favourite things, App owns them and the cards only show themconst favorites = ref([])
const visibleItems = computed(() => { const text = query.value.trim().toLowerCase() return items.filter((item) => item.name.toLowerCase().includes(text))})
const favoriteCount = computed(() => favorites.value.length === 1 ? '1 Favorit' : `${favorites.value.length} Favoriten`,)
function reserve(id) { const item = items.find((entry) => entry.id === id) message.value = `Reservation für ${item.name} angefragt.`}
function toggleFavorite(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <h1>Katalog</h1> <SearchBar v-model="query" /> <p class="favorites">{{ favoriteCount }}</p> <p class="message" role="status">{{ message }}</p> <div class="grid"> <ItemCard v-for="item in visibleItems" :id="item.id" :key="item.id" :name="item.name" :category="item.category" :available="item.availableFrom === null" :favorite="favorites.includes(item.id)" @reserve="reserve" @toggle-favorite="toggleFavorite" /> </div></template>toggleFavorite ersetzt das Array durch ein neues, mit oder ohne die id. Ein Array mit push und splice zu ändern, ginge auch, das neue Array macht die Absicht aber deutlicher. Die Karte erfährt mit favorites.includes(item.id), ob sie ein Favorit ist. Sie ändert ihre Prop nie selbst, sie meldet nur den Klick.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
In <script setup> steht this.$emit('reserve'). | Es gibt kein this. const emit = defineEmits([...]) liefert die Funktion emit. |
| Die Grosselternkomponente hört auf ein Event des Enkels und bekommt nichts. | Events steigen nicht weiter nach oben. Die Komponente dazwischen gibt das Event weiter, @reserve="emit('reserve', $event)". |
| Die Kindkomponente ändert ihre Prop, und Vue warnt, dass sie schreibgeschützt ist. | Ein Event auslösen oder für einen gebundenen Wert defineModel verwenden. |
| Ein Event kommt in der Elternkomponente ohne Daten an. | Die Daten als zweites Argument von emit mitgeben, emit('reserve', id). |
Ein KI-Assistent schlägt .sync oder die Option model vor. | Beides stammt aus Vue 2. In Vue 3 übernimmt defineModel diese Aufgabe. |
| Ein KI-Assistent verbindet Komponenten über einen Event-Bus. | Zwischen Eltern und Kind genügen Props und Events. Für Zustand, den viele Komponenten teilen, gibt es in Teil 7 Pinia. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Wie teilt eine Kindkomponente ihrer Elternkomponente etwas mit?
- Was passiert, wenn die Prüffunktion eines Events
falsezurückgibt? - Wofür ist
v-modelauf einer Komponente eine Kurzform? - Warum besitzt
Appdie Favoriten und nicht die Karten?
Antworten
- Sie löst mit der Funktion aus
defineEmitsein Event aus, auf Wunsch mit Daten, und die Elternkomponente hört mit@zu. - Vue warnt während der Entwicklung in der Konsole und löst das Event trotzdem aus.
- Für die Prop
modelValuezusammen mit dem Eventupdate:modelValue. - Die Favoriten brauchen alle Karten und der Kopf. Die tiefste Komponente über ihnen allen ist
App.
Auftrag
Reservieren und Favoriten funktionieren, und Martina Hostettler hat schon den nächsten Wunsch. «Bevor eine Anfrage abgeht, möchte ich sie noch einmal sehen und bestätigen.» Der Freiwillige ergänzt, dass alle Seiten denselben Rahmen mit Kopf und Fuss haben sollen. Für beides braucht es Slots, das nächste Kapitel.
Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.