Listen und Keys
Kurz gesagt: Eine Liste in Vue entsteht mit v-for aus einem Array von Komponenten, und :key sagt Vue, welche alte Zeile welche neue ist; eine Zufallszahl ist ein schlechter Key, und der Index ist es auch, sobald sich die Liste ändert.
Problem
Ich habe das Brot entfernt, und die Milch wurde 3
Der Käufer hat Brot, Milch und Käse im Warenkorb. Er tippt 3 für das Brot und 2 für die Milch ein und entfernt dann das Brot. Probier es aus: Ändere die beiden Zahlen und klick auf „Entfernen“ neben dem Brot.
Konsole
Das Brot ist weg, aber die Milch steht jetzt auf 3 und der Käse auf 2. In der Konsole steht kein Fehler. Die Daten stimmen: Entfernt wurde genau das Brot. Nur die Felder sind durcheinander. Schuld ist ein einziges Attribut in Zeile 22 von App.vue: das, was der Code der Bibliothek über jede Zeile der Liste sagt.
Die Idee
Eine Liste in einer Komponente
Im Modul „JavaScript in der Seite“ wurde eine Liste aus einem Array mit map und createElement gebaut. In einer Komponente ist die Idee dieselbe, aber jedes Element des Arrays wird zu einem Teil des Markups:
<ul> <li v-for="p in items" :key="p.id"> <ProductCard :product="p" /> </li></ul>v-for="p in items" wiederholt <li> für jedes Produkt, und darin ist p das aktuelle Produkt. :key ist der Key, an dem Vue jede Zeile wiedererkennt.
Wenn sich die Daten ändern, gibt die Komponente eine neue Liste zurück. Die Bibliothek löscht die alte nicht, um sie neu zu zeichnen. Sie vergleicht die alte mit der neuen und ändert auf der Seite nur den Unterschied. Deshalb bleiben der Fokus und das in Felder Eingetippte erhalten, statt verloren zu gehen wie bei render() im Modul „JavaScript in der Seite“.
Zum Vergleichen muss sie wissen, welche alte Zeile zu welcher neuen gehört. Genau das sagt der Key.
Die Idee
Zuordnung über den Key
Die Bibliothek ordnet alte und neue Zeilen über den Key zu:
- Derselbe Key ist dieselbe Zeile. Das Element auf der Seite bleibt, und die Bibliothek ändert darin nur, was sich in den Daten unterscheidet: den Namen, den Preis.
- Ein Key, den die neue Liste nicht mehr hat, ist eine entfernte Zeile. Ihr Element wird von der Seite entfernt.
- Ein neuer Key ist eine neue Zeile. Es entsteht ein neues Element.
Das Element, das bleibt, behält alles, was ihm gehört: das in ein <input> Eingetippte, den Fokus, ein geöffnetes Dropdown. Das steht nicht in den Daten, sondern im Element selbst. Deshalb ist es wichtig, dass es bei seinem Produkt bleibt.
Im Beispiel ist der Key der Index: die Position im Array. Nach „Entfernen“ steht die Milch an Position 0. Die alte Zeile mit dem Key 0 gehörte zum Brot. Die Bibliothek behält sie, ändert ihren Namen in „Frischmilch“ und lässt das Feld mit der 3 stehen.
Sieh es dir an
Die Zuordnung ansehen
Links ist die Seite vor „Entfernen“, rechts die neue Liste aus der Komponente. Achte darauf, welche Zeile verschwindet.
<!-- key = die Position in der Liste --><li v-for="(p, index) in items" :key="index"> <ProductCard :product="p" /></li>
<!-- key = die id des Produkts --><li v-for="p in items" :key="p.id"> <ProductCard :product="p" /></li>Schritt für Schritt, in Worten
- Drei Zeilen im Warenkorb, und der Key ist die Position: 0, 1, 2. Der Käufer hat 3, 2 und 1 in die Felder getippt. Das Eingetippte lebt im Element <input>, nicht in den Daten.
- „Entfernen Weißbrot“. Die Daten enthalten jetzt zwei Produkte, und die Komponente gibt eine neue Liste zurück: Key 0 ist die Milch, Key 1 ist der Käse.
- Die Bibliothek ordnet die alte und die neue Liste über den Key zu: 0 mit 0, 1 mit 1. Key 2 fehlt in der neuen Liste, also verschwindet die dritte Zeile: die vom Käse.
- Die behaltenen Zeilen bekommen die neuen Daten, und ihre Überschriften lauten jetzt Milch und Käse. Die Felder sind die alten, mit 3 und 2. Die Anzahl vom Brot steht jetzt bei der Milch.
- Derselbe Warenkorb mit key = id. Die Keys sind 1, 2, 3 und gehören zum Produkt, nicht zur Position.
- Noch einmal „Entfernen Weißbrot“. Die neue Liste hat die Keys 2 und 3.
- Key 2 wird mit Key 2 zugeordnet und 3 mit 3, obwohl sie nach oben gerückt sind. Key 1 fehlt, also verschwindet die Zeile vom Brot, zusammen mit ihrem Feld.
- Jedes Feld ist bei seinem Produkt geblieben: Milch 2, Käse 1. Der Key sagt der Bibliothek, wer wer ist, nicht, wo er steht.
Beim Index verschwindet die letzte Zeile, und die ersten beiden ändern ihre Namen. Bei der id verschwindet die Zeile vom Brot, und die anderen beiden rücken mit ihren Feldern einfach nach oben. In beiden Fällen sind die Daten dieselben. Der Unterschied ist, was der Key sagt: die Position oder das Produkt.
Probier es aus
Repariere den Key
Repariere den Warenkorb so, dass nach „Entfernen“ jede Zahl bei ihrem Produkt bleibt. Die Prüfung tippt 3 für das Brot und 2 für die Milch ein, entfernt das Brot und schaut sich die Felder von Milch und Käse an.
Konsole
Lösung
App.vue<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Weißbrot', price: 1.2 },
{ id: 2, name: 'Frischmilch', price: 1.6 },
{ id: 3, name: 'Weißkäse', price: 7.9 },
];
// Der Zustand (nächstes Modul): die Liste, die die Buttons ändern.
const items = ref(PRODUCTS);
const remove = (id) => {
items.value = items.value.filter((p) => p.id !== id);
};
</script>
<template>
<main>
<h1>Der Warenkorb</h1>
<ul>
<li v-for="p in items" :key="p.id">
<ProductCard :product="p" />
<button type="button" :aria-label="`Entfernen ${p.name}`" @click="remove(p.id)">Entfernen</button>
</li>
</ul>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Anzahl <input type="number" min="1" value="1" :aria-label="`Anzahl: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
Der Key wird die id des Produkts. Sie ist bei jedem Zeichnen dieselbe und für jedes Produkt eine andere. Nach „Entfernen“ fehlt der Key 1, also verschwindet die Zeile vom Brot, und die Zeilen von Milch und Käse bleiben mit ihren Feldern. Ohne Key vergleicht die Bibliothek wieder nach Position, und der Fehler bleibt.
Probier es aus
Ergänze die Lücke
Wie heißt das Attribut, an dem die Bibliothek eine Zeile erkennt?
<li v-for="p in items" ="p.id">
<ProductCard :product="p" />
</li>:key: Der Doppelpunkt sagt, dass der Wert ein Ausdruck ist (p.id), nicht der Text „p.id“.
Die Idee
Ein guter Key
Ein guter Key ist stabil und eindeutig: derselbe für dasselbe Produkt bei jedem Zeichnen und ein anderer für andere Produkte. Am besten ist eine id aus den Daten: Sie kommt aus der Datenbank und ändert sich nicht.
- Der Index ist ein schlechter Key, wenn sich die Liste ändert: beim Entfernen, beim Einfügen am Anfang, beim Sortieren, beim Filtern. Er ist in Ordnung, wenn die Liste nur gelesen wird und ihre Reihenfolge nie ändert.
Math.random()ist immer ein schlechter Key. Bei jedem Zeichnen ist der Key neu, also hat keine alte Zeile ein Gegenstück. Die Bibliothek entfernt alle Zeilen und baut neue. Die Felder verlieren das Eingetippte, und der Fokus verschwindet.- Zwei gleiche Keys bringen die Zuordnung durcheinander. Hier ist der Key der Name, und „Joghurt“ gibt es in zwei Größen. Klick auf „Nach Preis sortieren“ und zähl die Zeilen.
Konsole
Es sind vier Produkte, und die Zeilen werden zu fünf: Ein Joghurt ist doppelt da.
Vue warnt erst beim Update: „Duplicate keys found during update: "Joghurt"“. Beim ersten Zeichnen schweigt es. Wenn du:key vergisst, sagt Vue gar nichts: Es vergleicht die Zeilen nach Position. Deshalb verlangt der Vue-Linter (eslint-plugin-vue) einen Key bei jedem v-for. Die Warnungen gibt es nur im Entwicklungsmodus, und der Fehler auf der Seite bleibt auch auf der echten Seite.Probier es aus
Finde den Fehler
Bei jedem Zeichnen springen alle „Anzahl“-Felder auf 1 zurück, und der Cursor verlässt das Feld.
Wähle die Zeile mit dem Fehler.
Zeile 2: :key="Math.random()" ist bei jedem Zeichnen ein neuer Key. Keine alte Zeile hat ein Gegenstück, und Vue baut alle neu. Der Key muss p.id sein.
Die Idee
Filtern und Sortieren
Der Katalog zeigt nur die Produkte, die verfügbar sind, nach Preis sortiert. Das ist eine abgeleitete Liste: Sie wird nicht getrennt gespeichert, sondern bei jedem Zeichnen aus den Daten gebaut.
const visible = PRODUCTS .filter((p) => p.stock > 0) .sort((a, b) => a.price - b.price);Das steht in <script setup>. Hier ändert sich PRODUCTS nicht, und einmal zu berechnen reicht. Für Daten, die sich ändern, hat Vue computed. Darum geht es in der Lektion „Abgeleiteter Zustand“.
filtergibt ein neues Array nur mit den verfügbaren Produkten zurück. Das Original bleibt ganz.sortsortiert das Array an Ort und Stelle. Hier ist das sicher, weil es nachfilterkommt, das schon eine Kopie geliefert hat. Ohne Filter machst du vorher eine Kopie:[...PRODUCTS].sort(…).- Der Key bleibt
p.id. Beim Sortieren wechseln die Zeilen ihre Plätze, und der Index würde den Key jeder einzelnen ändern.
Probier es aus
Nur das, was verfügbar ist
Zeige nur die verfügbaren Produkte (stock > 0), nach Preis sortiert, das günstigste zuerst. Schreib über die Liste „Verfügbar: 3 von 5“.
Konsole
Lösung
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 3, name: 'Weißkäse', price: 7.9, stock: 4 },
{ id: 4, name: 'Banitsa', price: 2.5, stock: 0 },
{ id: 1, name: 'Weißbrot', price: 1.2, stock: 10 },
{ id: 5, name: 'Gurken', price: 2.2, stock: 0 },
{ id: 2, name: 'Frischmilch', price: 1.6, stock: 6 },
];
const visible = PRODUCTS.filter((p) => p.stock > 0).sort((a, b) => a.price - b.price);
</script>
<template>
<main>
<h1>Der Katalog</h1>
<p>Verfügbar: {{ visible.length }} von {{ PRODUCTS.length }}</p>
<ul>
<li v-for="p in visible" :key="p.id">
<ProductCard :product="p" />
</li>
</ul>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Anzahl <input type="number" min="1" value="1" :aria-label="`Anzahl: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
PRODUCTS.filter((p) => p.stock > 0) wirft die Banitsa und die Gurken raus, und .sort((a, b) => a.price - b.price) ordnet Brot, Milch und Käse. Die Zahlen kommen aus den beiden Arrays: visible.length und PRODUCTS.length. Die Prüfung schaut auf die Reihenfolge der Überschriften. Den Key sieht sie nicht: In dieser Liste ändert sich nach dem Zeichnen nichts. Der Key spielt eine Rolle, sobald sich etwas ändert, wie in der Übung.
Genauer, für Neugierige
key ist keine Prop. Die Bibliothek nimmt den Key für sich und gibt ihn nicht an die Komponente weiter. defineProps(['key']) gibt die Warnung „"key" is a reserved property“. Wenn die Komponente die id braucht, gib sie getrennt weiter: product enthält sie schon.
Ein Key zum Zurücksetzen. Ein neuer Key heißt ein neues Element. Ein geänderter Key ist also eine Möglichkeit, eine Komponente zurückzusetzen: Ein Formular mit der id des Produkts als Key startet für jedes neue Produkt leer.
Mehrere Elemente pro Zeile. Wenn ein Produkt aus zwei Elementen besteht (<dt> und <dd>), kommen v-for und :key an ein <template>: <template v-for="p in items" :key="p.id">.
v-if und v-for am selben Element. In Vue 3 läuft v-if vor v-for und sieht p nicht. Der Filter gehört in die Daten (visible), nicht in ein v-if am selben Element.
Options API. Älterer Vue-Code nutzt data() und methods statt <script setup>. v-for und :key sind dieselben.
toSorted. products.toSorted((a, b) => a.price - b.price) gibt eine sortierte Kopie zurück und rührt das Original nicht an. Es gibt es in Browsern seit 2023.
Warum das Feld „unkontrolliert“ ist. Hier steht die Zahl im Feld nur im DOM. Im Modul „Zustand“ wird das Feld kontrolliert: Die Zahl steht in den Daten und folgt ihnen. Dann spielt der Key trotzdem eine Rolle: für den Fokus, den Cursor und den Zustand, den die Komponente in sich selbst hält.
Übung
Der Warenkorb
Der Zustand und der Button „Nach Preis sortieren“ sind fertig. Baue die Zeile für jedes Produkt: ProductCard und einen Button „Entfernen“, dessen Name für einen Screenreader „Entfernen Weißbrot“ ist. Die Prüfung tippt Anzahlen ein, sortiert, entfernt das Brot und schaut, ob jede Zahl bei ihrem Produkt bleibt.
Konsole
Lösung
App.vue<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 3, name: 'Weißkäse', price: 7.9 },
{ id: 1, name: 'Weißbrot', price: 1.2 },
{ id: 2, name: 'Frischmilch', price: 1.6 },
];
// Der Zustand (nächstes Modul): die Liste, die die Buttons ändern.
const items = ref(PRODUCTS);
const remove = (id) => {
items.value = items.value.filter((p) => p.id !== id);
};
const sortByPrice = () => {
items.value = [...items.value].sort((a, b) => a.price - b.price);
};
</script>
<template>
<main>
<h1>Der Warenkorb ({{ items.length }})</h1>
<ul>
<li v-for="p in items" :key="p.id">
<ProductCard :product="p" />
<button type="button" :aria-label="`Entfernen ${p.name}`" @click="remove(p.id)">Entfernen</button>
</li>
</ul>
<button type="button" @click="sortByPrice">Nach Preis sortieren</button>
</main>
</template>
ProductCard.vue<script setup>
defineProps({ product: Object });
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<label>
Anzahl <input type="number" min="1" value="1" :aria-label="`Anzahl: ${product.name}`" />
</label>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
Die Zeile ist ein <li> mit dem Key p.id, ProductCard und einem Button. Der Button ruft remove(p.id) auf, und sein Name für einen Screenreader kommt aus aria-label. Mit dem Index als Key verschiebt das Sortieren die Zahlen: Der Käse war zuerst, also bleibt die 4 in der ersten Zeile, die jetzt das Brot ist. Mit Math.random() setzt jedes Sortieren die Felder auf 1 zurück.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Ereignisse · Eine Liste aus Daten · Die erste Komponente · Props: Daten von oben
- Geht weiter in: Bedingungen: leer, Fehler, lädt · Zustand · Objekte und Arrays aktualisieren · Abgeleiteter Zustand
- Aufgaben zum Thema: Komponenten