Die erste Komponente
Kurz gesagt: Eine Vue-Komponente ist ein Template mit Daten: Es beschreibt, wie der Bildschirm für diese Daten aussieht, und Vue ändert auf der Seite nur den Unterschied.
Problem
Das Feld verliert den Fokus
Das Modul „JavaScript in der Seite“ endete mit einem Warenkorb, den ein einziges render() aus den Daten zeichnet. Hier ruft jede Änderung des Mengenfelds render() auf. Der Kunde möchte 15 Flaschen Milch: Er klickt ins Feld, löscht die 2 und tippt 15. Probier es aus.
Konsole
Nach dem Löschen ist das Feld leer und hat keinen Fokus mehr: Die 1 und die 5 gehen ins Leere. render() hat das Feld schon beim ersten Tastendruck durch ein neues ersetzt. Das neue Feld sieht gleich aus, ist aber ein anderes Element, und der Fokus lag auf dem alten. Einzelne Stellen von Hand anzufassen, vergisst Stellen. Alles neu zu zeichnen, verliert den Fokus. Es gibt einen dritten Weg.
Die Idee
Die Oberfläche als Funktion der Daten
render() hatte eine gute Idee: Der Bildschirm wird nur aus den Daten gebaut. Bei denselben Daten ist der Bildschirm derselbe, egal, was vorher darauf stand. Der Fehler lag im letzten Schritt: render() entfernt und erzeugt die Elemente selbst.
Die Arbeit wird deshalb in zwei Teile geteilt:
- Du schreibst eine Funktion, die sagt, wie der Bildschirm für diese Daten aussieht. Sie fasst die Seite nicht an; sie gibt eine Beschreibung zurück: Hier ist ein
<main>, darin ein<h1>mit dem Text „Der Warenkorb“, dann ein Feld mit dem Wert 2. - Die Bibliothek hält das echte DOM. Beim ersten Mal baut sie es aus der Beschreibung. Bei jeder Änderung der Daten nimmt sie die neue Beschreibung, vergleicht sie mit der alten und ändert auf der Seite nur den Unterschied.
Eine solche Funktion heißt Komponente. Kurz gesagt: Der Bildschirm ist eine Funktion der Daten, und die Komponente ist diese Funktion. React und Vue machen das auf unterschiedliche Weise. Die Idee ist dieselbe, und die Wahl zwischen den beiden ist Sache des Teams, nicht einer „besseren“ Bibliothek.
Sieh es dir an
Die erste Komponente
In Vue ist eine Komponente eine .vue-Datei (Single File Component) mit bis zu drei Teilen: <script setup> mit den Daten und Funktionen, <template> mit der Beschreibung des Bildschirms und optional <style>.
<script setup>const product = { name: 'Frischmilch', price: 1.6, stock: 35 };</script>
<template> <main> <h1>{{ product.name }}</h1> <p>Preis: {{ money(product.price) }}</p> </main></template>- Alles, was
<script setup>deklariert (Variablen, Funktionen, importierte Komponenten), sieht das Template. {{ product.name }}ist ein JavaScript-Ausdruck in doppelten geschweiften Klammern. Sein Ergebnis wird zu Text auf der Seite.- Der Name der Komponente ist der Name der Datei:
App.vueist die KomponenteApp. - Die Sandbox setzt
Appselbst auf die Seite. In einem echten Projekt macht das eine Zeile inmain.js:createApp(App).mount('#app').
Konsole
Ändere den Preis oder den Lagerbestand im Objekt product und klick auf „Ausführen“. Die Beschreibung des Bildschirms fasst du nicht an: Der Bildschirm folgt den Daten.
Die Idee
Die Regeln des Templates
Das Template ist HTML, aber Vue kompiliert es zu JavaScript und liest darin eigene Zeichen. Deshalb hat es eigene Regeln.
{{ Ausdruck }}im Text:<h1>{{ product.name }}</h1>.:vor einem Attribut bedeutet „der Wert ist ein Ausdruck“:<img :src="product.image" :alt="product.name" />.:altist die Kurzform vonv-bind:alt. Ohne:ist der Wert der Text „product.name“, Buchstabe für Buchstabe.{{ }}funktioniert in einem Attribut nicht:alt="{{ product.name }}"ist der Text{{ product.name }}.@vor einem Ereignis:@click="add"(Kurzform vonv-on:click). Ein kurzer Ausdruck geht auch:@click="count++"oder@click="add(product)". Vue packt ihn in eine Funktion, die beim Klick läuft.classundforbleibenclassundfor, wie in HTML.- Mehrere Wurzeln sind erlaubt: Das Template kann ein
<header>und ein<main>nebeneinander haben. - Tags ohne Inhalt dürfen mit
/>geschlossen werden:<input />,<ProductCard />.
Probier es aus
Finde den Fehler
Ein Klick auf „Hinzufügen“ schreibt nichts in die Konsole.
Wähle die Zeile mit dem Fehler.
onclick="add()" ist ein gewöhnliches HTML-Attribut. Vue setzt es unverändert auf die Seite, und beim Klick sucht der Browser eine globale Funktion add. Es gibt keine, also meldet die Konsole „ReferenceError: add is not defined“. Das add aus <script setup> wird mit @click="add" aufgerufen.Probier es aus
Ergänze
Der Screenreader soll den Produktnamen als Beschreibung des Bildes vorlesen.
<img :src="product.image" ="product.name" />:alt="product.name": Der Doppelpunkt sagt, dass der Wert ein Ausdruck ist. Die volle Form ist v-bind:alt.Die Idee
Komponenten aus Komponenten
Die Shop-Seite hat Teile: den oberen Bereich mit dem Menü, die Produktkarte, den Warenkorb. Jeder Teil wird zu einer Komponente in einer eigenen Datei, und eine größere Komponente nutzt sie als Tags. Dann ist App keine lange Funktion, sondern ein paar Zeilen, die sagen, was auf der Seite steht.
<template> <header> <a href="/">Der Shop</a> <nav>…</nav> </header></template><script setup>import ShopHeader from './ShopHeader.vue';import ProductCard from './ProductCard.vue';</script>
<template> <ShopHeader /> <main> <ProductCard /> </main></template>Das Template sieht eine importierte Komponente direkt. Ohne den import weiß Vue nicht, was ShopHeader ist: Die Konsole meldet „[Vue warn]: Failed to resolve component: ShopHeader“, und ein unbekanntes Tag bleibt auf der Seite. Der Name beginnt per Konvention mit einem Großbuchstaben (PascalCase), damit er sich von HTML-Tags unterscheidet. Vue akzeptiert auch <shop-header />.
Probier es aus
Setz die Seite zusammen
ShopHeader und ProductCard sind in ihren Dateien fertig. Setz App zusammen: ShopHeader oben und die Produktkarte in main, unter der Überschrift „Milchprodukte“.
Konsole
Lösung
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Milchprodukte</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">Der Shop</a>
<nav>
<a href="/bread">Brot</a>
<a href="/milk">Milch</a>
<a href="/cart">Warenkorb</a>
</nav>
</header>
</template>
ProductCard.vue<script setup>
const product = { name: 'Frischmilch', price: 1.6, stock: 35 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Preis: {{ money(product.price) }}</p>
</article>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
<script setup> importiert die beiden Komponenten, und das Template nutzt sie als Tags: <ShopHeader /> vor <main>, <ProductCard /> in <main>. Das Template darf zwei Wurzeln nebeneinander haben. Die Prüfung sucht den oberen Bereich über die Rolle: Ein <header> in <main> ist nicht mehr der Kopf der Seite, und ohne den import gibt es gar kein <header>.
Die Idee
Nur der Unterschied
Hier ist der Warenkorb aus dem Problem als Komponente.
ref('2') ist das Gedächtnis der Komponente: ein Wert, den Vue beobachtet. Wenn er sich ändert, zeichnet Vue die Komponenten neu, die ihn lesen. Das ist das Thema des Moduls „Zustand“. Hier ist es nur dafür da, dass sich etwas ändern kann.
Konsole
Mach dasselbe wie im Problem: Klick ins Milchfeld, lösch die 2 und tipp 15. Das Feld behält den Fokus und das Getippte, und die Summe wird 25,20 €. Die Komponente beschreibt bei jedem Tastendruck den ganzen Bildschirm, wie render(). Der Unterschied ist, dass die Seite nicht neu gebaut wird. Die Bibliothek sieht, dass das Feld in der neuen Beschreibung an derselben Stelle mit demselben Tag steht, und ändert nur seinen Wert und den Text der Summe.
Sieh es dir an
Sieh dir den Vergleich an
<script setup>import { ref } from 'vue';const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
const milk = ref('2');const bread = ref('1');</script>
<template> <main> <h1>Der Warenkorb</h1> <label> Frischmilch <input type="number" :value="milk" @input="milk = $event.target.value" /> </label> <label> Weißbrot <input type="number" :value="bread" @input="bread = $event.target.value" /> </label> <p>Gesamt: {{ money(Number(milk) * 1.6 + Number(bread) * 1.2) }}</p> </main></template>Schritt für Schritt, in Worten
- Der Kunde hat die 2 gelöscht und 1 getippt: Die Daten sagen milk = "1". In der Mitte steht die letzte Beschreibung, die die Komponente zurückgegeben hat; rechts steht das DOM, mit dem Fokus im Milchfeld.
- Der Kunde tippt 5. @input schreibt "15" in milk: Die Daten ändern sich, und Vue weiß, dass das Template milk liest.
- Vue hat das Template zu einer Render-Funktion kompiliert. Weil sich milk geändert hat, führt Vue sie noch einmal aus, und sie gibt eine neue Beschreibung zurück: einen Baum aus einfachen Objekten.
- Die Bibliothek vergleicht die neue Beschreibung mit der vorigen. Dieselben Tags in derselben Reihenfolge und zwei Unterschiede: der Wert des Feldes und der Text der Summe.
- Nur die beiden Unterschiede erreichen das DOM. Das <input> ist dasselbe Element wie vorher, deshalb bleiben Fokus und Cursor darin.
- Du beschreibst den ganzen Bildschirm für die gegebenen Daten, wie bei render(). Die Bibliothek findet, was sich geändert hat, und fasst nur das an. Beide Bibliotheken tun das, jede auf ihre Weise.
Die Beschreibung ist billig: Es ist ein Baum aus einfachen Objekten, und die Komponente macht ihn jedes Mal neu. Das DOM ist der teure Teil, deshalb fasst die Bibliothek dort nur die Unterschiede an. Du schreibst wie bei render(): den ganzen Bildschirm aus den Daten. Der Preis von render() (verlorener Fokus, tausende neue Elemente) fällt nicht an.
Genauer, für Neugierige
Virtuelles DOM. Die Beschreibung heißt oft virtuelles DOM, und ihr Vergleich mit der alten heißt Abgleich (Reconciliation in React, Patching in Vue). Ein Knoten der Beschreibung ist ein Objekt wie { type: 'h1', props: { children: 'Der Warenkorb' } }.
Die beiden Wege. Das Template wird zu einer Render-Funktion kompiliert, die die Knoten der Beschreibung zurückgibt. Der Compiler markiert, welche Teile sich nie ändern, und der Vergleich überspringt sie. Vue merkt sich, welche Daten jede Komponente liest (Reaktivität), und führt nur die Komponenten erneut aus, deren Daten sich geändert haben. In beiden Fällen entstehen am Ende eine neue Beschreibung, ein Vergleich und ein paar Änderungen im DOM.
Text ist Text. {{ product.name }} setzt Text ein, wie textContent. Ein Name wie <img src=x onerror=alert(1)> erscheint als Zeichen und wird nicht zu einem Tag. HTML aus einem String kommt nur mit v-html hinein, und der Name mahnt zur Vorsicht.
Options API. In älterem Code ist eine Komponente ein Objekt: export default { data() { … }, methods: { … } }. Es funktioniert noch, aber neuer Code nutzt <script setup> (Composition API).
v-model. Für das Paar :value und @input gibt es die Kurzform v-model. Sie gehört in die Lektion über Formulare.
Übung
Die Produktkarte
In ProductCard steht das Objekt product (Weißkäse). Bau die Karte: den Namen als Überschrift der Ebene zwei, „Preis: 7,90 €“ (mit money), „Auf Lager: 12 Stk.“ und einen Button „Hinzufügen“, dessen Name für den Screenreader „Hinzufügen Weißkäse“ ist. Ein Klick auf den Button schreibt „Hinzugefügt: Weißkäse“ in die Konsole (die Funktion add ist fertig). Setz dann App zusammen: ShopHeader oben und die Karte in main, unter der Überschrift „Milchprodukte“.
Konsole
Lösung
ProductCard.vue<script setup>
const product = { name: 'Weißkäse', price: 7.9, stock: 12 };
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function add() {
console.log(`Hinzugefügt: ${product.name}`);
}
</script>
<template>
<article>
<h2>{{ product.name }}</h2>
<p>Preis: {{ money(product.price) }}</p>
<p>Auf Lager: {{ product.stock }} Stk.</p>
<button type="button" :aria-label="`Hinzufügen ${product.name}`" @click="add">Hinzufügen</button>
</article>
</template>
App.vue<script setup>
import ShopHeader from './ShopHeader.vue';
import ProductCard from './ProductCard.vue';
</script>
<template>
<ShopHeader />
<main>
<h1>Milchprodukte</h1>
<ProductCard />
</main>
</template>
ShopHeader.vue<template>
<header>
<a href="/">Der Shop</a>
<nav>
<a href="/bread">Brot</a>
<a href="/milk">Milch</a>
<a href="/cart">Warenkorb</a>
</nav>
</header>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
nav a { margin-left: 12px; }
main { padding: 0 16px; }
h1 { font-size: 22px; }
label { display: block; margin: 8px 0; }
input { width: 4em; font: inherit; margin-left: 8px; }
article { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 12px 12px; max-width: 320px; }
button { font: inherit; }
Text aus den Daten steht in {{ … }}: {{ product.name }}, {{ money(product.price) }}, {{ product.stock }}. Der Name für den Screenreader ist ein Attribut aus einem Ausdruck, mit Doppelpunkt: :aria-label="`Hinzufügen ${product.name}`". Ohne den Doppelpunkt heißt der Button wörtlich „Hinzufügen ${product.name}“, und die Prüfung findet ihn nicht. Der Button ruft die Funktion mit @click="add" auf.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Barrierefreiheit · Das DOM: die Seite aus JavaScript · Ereignisse · Eine Liste aus Daten
- Geht weiter in: Props: Daten von oben · Listen und Keys · Bedingungen: leer, Fehler, lädt · Zustand
- Aufgaben zum Thema: Komponenten