Vue 3 in der Praxis: Composables, typisierte Verträge und proportionales State
Wie die Composition API von Vue 3, typisierte Props und Emits sowie Pinia und eine schrittweise Einführung es ermöglichen, dass eine Anwendung nur so komplex wird, wie es notwendig ist – und wann Vue die falsche Wahl ist.
Quellen: Vue.js Is the Frontend Framework More Teams Should Be Using
Wenn ein Team React wählt, fragt niemand nach einer Begründung; wenn ein Team Vue wählt, fragt fast immer jemand danach. Diese Reaktion sagt mehr über Gewohnheit als über das Framework aus, denn Vue 3 ist eine ausgereifte Option mit offizieller Routen- und Zustandsverwaltung, erstklassiger TypeScript-Unterstützung sowie einer Full-Stack-Lösung über Nuxt. Dieser Artikel betrachtet Vue 3 anhand von funktionierendem Code: wie Zustand und abgeleitete Werte gelesen werden, wie Composables Verhalten verpacken, wie typisierte Props und Emits Komponentenverträge offenbaren, wie Pinia hineinpasst – und wo Vue einfach nicht die richtige Wahl ist.
Das wiederkehrende Thema ist das Verhältnis. Vue bietet genügend Struktur für ernsthafte Anwendungen, ohne jede kleine Funktion in eine architektonische Entscheidung zu verwandeln – und diese Eigenschaft lässt sich am besten anhand einer echten Komponente beurteilen, nicht in einem Vergleich von Funktionen.
Eine realistische erste Komponente: gefilterte Suche
Todo-Apps sagen wenig über ein Framework aus. Ein etwas realistischerer Test ist die Produktsuche: eine Eingabe, eine Produktliste sowie der Teilbereich, der mit der Abfrage übereinstimmt.
Die untenstehende Logik verwendet zwei Primitive. ref erzeugt reaktiven Zustand – hier den Suchtext und das Produktempfehl. computed leitet einen Wert aus diesem Zustand ab und speichert ihn, bis eine seiner Abhängigkeiten sich ändert. Beachten Sie, dass der Filter die Abfrage einmal kürzt und in Kleinbuchstaben umwandelt, bei einer leeren Abfrage die vollständige Liste zurückgibt und erst danach filtert.
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
Das Template bindet sich direkt an diese Werte. v-model hält die Eingabe und search in beide Richtungen synchron, v-for rendernt jeweils ein Element pro gefiltertem Produkt, und :key gibt Vue eine stabile Identität für jede Zeile, damit es die Liste effizient aktualisieren kann.
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
Es gibt kaum Formalitäten. Der Zustand ist sichtbar, der abgeleitete Wert ist sichtbar und die Markup-Elemente, die sie verwenden, sind ebenfalls sichtbar. Man kann den Code von oben nach unten lesen, ohne zunächst ein Wörterbuch mit framework-spezifischen Mechanismen lernen zu müssen. Das mag wie eine geringfügige Tugend klingen, doch je größer die Codebasis wird, desto wichtiger wird es, wie schnell jemand eine unbekannte Datei verstehen kann – und das ist einer der wichtigsten Aspekte, die ein Framework beeinflusst.
Ein Detail, das man kennen sollte: In Templates werden Referenzen automatisch entpackt, sodass man search statt search.value schreibt. In Skriptcode hingegen verwendet man weiterhin .value; deshalb liest die berechnete Funktion search.value sowie products.value.
The Composition API: Verhalten wiederverwenden, nicht Komponenten
Der größte Unterschied zwischen der älteren Vue-Version und Vue 3 ist die Composition API. Die Options API gruppierte den Code nach Kategorien des Frameworks (Daten, Methoden, berechnete Werte, Beobachter), wodurch eine Funktion in mehreren Abschnitten verteilt war. Die Composition API ermöglicht es hingegen, den Code nach der Funktion dieser Funktion zu gruppieren.
Nehmen wir eine Produktseite, die Daten abrufen und den Lade- sowie Fehlerzustand verfolgen muss. Dieses Verhalten kann in eine composable Funktion überführt werden – eine einfache Funktion, deren Name traditionell mit use beginnt und die den reaktiven Zustand sowie die Funktionen zur Änderung desselben zurückgibt:
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
Mehrere Aspekte sind hier absichtlich so gestaltet. loading wird in der finally-Blöcke zurückgesetzt, sodass er unabhängig davon bereinigt wird, ob die Anfrage erfolgreich ist oder fehlschlägt. error wird vor jedem Versuch bereinigt, damit keine veralteten Meldungen zurückbleiben. Eine Antwort mit Statuscode außerhalb von 2xx wird in eine Ausnahme umgewandelt, da fetch nur bei Netzwerkfehlern abgelehnt wird, nicht bei HTTP-Fehlerstatusen. Der catch-Blöck prüft außerdem mit instanceof Error, ob err tatsächlich eine Fehlerinstanz ist, bevor message gelesen wird – dies ist notwendig, da TypeScript gefangene Werte als unknown typisiert.
Der wichtige Unterschied liegt darin, was wiederverwendet wird. Man teilt nicht ein Komponenten-Element zusammen mit seiner Markup-Struktur, sondern das Verhalten. Komponenten konzentrieren sich auf das, was der Benutzer sieht, wohingegen composables darauf ausgerichtet sind, wie eine Funktion funktioniert – und keines von beidem muss zu einer globalen Abstraktion werden, nur damit zwei Bildschirme etwas Logik teilen können. Eine Einschränkung: Jeder Aufruf von useProducts() erzeugt seinen eigenen unabhängigen Zustand. Das ist in der Regel genau das, was man möchte, doch wenn zwei Komponenten dieselbe Produktliste sehen müssen, braucht man einen gemeinsamen Zustand – hier kommt später ein Store ins Spiel.
Jeder Aufgabe einen passenden Platz geben
Ein Framework kann mächtig sein, indem es viele Funktionen bietet, oder indem es die Kluft zwischen dem, was man beabsichtigt, und dem, was man einträgt, verringert. Vue neigt eher zur zweiten Variante.
Betrachten wir eine Anforderung, die auf den ersten Blick unbedeutend erscheint: Wenn sich ein Filter ändert, soll dies in der URL widergespiegelt werden, die Daten neu abgerufen, die Paginierung beibehalten und ein Ladeindikator angezeigt werden. Es gibt mehrere beteiligte Komponenten, und in einer sinnvollen Vue-Architektur hat jede davon einen klaren Verantwortlichen:
- die Komponente rendernt die Benutzeroberfläche
- eine Composable-Datei implementiert das Verhalten der Funktion
- der Router verwaltet den Zustand der URL
- ein API-Client kümmert sich um die Kommunikation mit dem Backend
- eine Store-Datei speichert Daten nur dann, wenn sie tatsächlich im gesamten App-Bereich geteilt werden müssen
Niemand Teil muss alles wissen. Diese Trennung ist nicht auf Vue beschränkt, sondern lässt sich in jedem modernen Framework umsetzen. Besonders hervorzuheben ist, wie natürlich Vues Primitiven dazu anregen.
Einzelfail-Komponenten: Ein Ort für eine UI-Einheit
Vues Komponenten mit einzelnem Dateiformat kombinieren Template, Script und Styles in einer .vue-Datei. Kritiker betrachten das manchmal als stilistische Besonderheit, doch die zugrundeliegende Idee ist nicht „Alles in einer Datei unterbringen“. Vielmehr soll eine UI-Einheit an einem Ort vorhanden sein. Entscheidend ist hier das Komponentenmodell und nicht das Dateiformat.
Das Verhalten eines einfachen Schalters zur Anzeige/Verdeckung wird durch einen einzigen Boolean-Wert sowie eine Funktion gesteuert, die diesen Wert ändert:
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
Die Markup-Struktur ist genauso direkt lesbar. @click verbindet den Button mit der Funktion toggle, und v-if fügt das Panel hinzu oder entfernt es je nach Wert von isOpen:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
Die Distanz zwischen dem Zustand und der Benutzeroberfläche ist sehr gering – und bleibt es auch. Wenn man Monate später zu dieser Funktion zurückkehrt, um sie zu debuggen, bleibt die Beziehung zwischen der Flagge und dem, was auf dem Bildschirm angezeigt wird, weiterhin klar erkennbar. Beachten Sie, dass v-if das Element vollständig aus dem DOM entfernt; wenn sich das Panel sehr häufig ändert oder teure Inhalte enthält, kann v-show, das es lediglich mit CSS versteckt, die bessere Wahl sein.
Eine gute Komponentenarchitektur dreht sich nicht darum, die Anzahl der Dateien zu minimieren. Vielmehr geht es darum, verwandte Elemente leicht auffindbar zu machen.
TypeScript – wo die Verträge liegen
Eine Frontend-Anwendung ist voller Verträge: die Props, die eine Komponente entgegennimmt, die Events, die sie auslöst, die Werte, die ein Composable zurückgibt, die Modelle, die ein API-Client erzeugt, sowie der State und die Actions, die ein Store bereitstellt. Wenn diese Verträge implizit sind, bedeutet das Refactoring einer großen Codebasis, dass man sich auf das Gedächtnis der Entwickler verlassen muss, anstatt die Annahmen direkt zu lesen.
Vue 3 ermöglicht es, diese Verträge direkt neben der Komponentenlogik zu platzieren. Innerhalb von <script setup> sind defineProps und defineEmits Compiler-Makros, die ausschließlich Typdeklarationen entgegennehmen:
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
Der Typ der Props besagt, dass die Komponente einen user vom Typ User benötigt. Der Emit-Typ verwendet eine tupleartige Signatur, was bedeutet, dass das updated-Event genau ein Argument enthält – einen User. Die Aufrufung von emit mit dem falschen Eventnamen oder der falschen Payload führt zu einem Typfehler. Jeder, der die Datei öffnet, kann die öffentliche Schnittstelle der Komponente auf einen Blick erkennen.
Das ist der Aspekt von TypeScript, der in großen Frontends am meisten zum Tragen kommt. Autocomplete-Funktionen und Editor-Hinweise sind zwar praktisch, doch explizite Verträge sorgen dafür, dass Änderungen sicher bewertet werden können und Refaktorisierungen weniger riskant sind.
Halten Sie den Zustand so lokal wie möglich
Eine zuverlässige Methode, das Frontend kompliziert zu machen, besteht darin, alles in den globalen Zustand zu legen: einen Speicher für das Modalfenster, einen Speicher für ein Formfeld, einen Speicher für die ausgewählte Tabellenzelle sowie einen Speicher für eine vorübergehende Suchanfrage. Schon bald befindet sich die Hälfte der Anwendung innerhalb einer riesigen Zustandsschicht, in der jeder Code alles ändern kann.
Vue unterstützt einen sanfteren Ansatz:
- halten Sie den lokalen Zustand innerhalb des Komponenten, der ihn verwaltet
- bringen Sie wiederverwendbares Verhalten in Composables unter
- legen Sie wirklich gemeinsam genutzten Zustand in einen Speicher
Zur letzten Schritt ist Pinia der offizielle Store von Vue. Das untenstehende Beispiel zeigt einen Options-Stil Store für einen Warenkorb. state gibt die Anfangsdaten zurück, der total Getter berechnet die Gesamtsumme des Warenkorbs aus Artikeln und Mengen, und die Actions ändern die Liste. addItem erhöht die Menge, wenn das Produkt bereits im Warenkorb ist, andernfalls wird eine Kopie des Artikels hinzugefügt, damit der Store keine Objektreferenz mit dem Aufrufer teilt.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
Der Store ist nicht der interessante Teil – vielmehr ist es die Entscheidung, ihn zu erstellen. Ein Texteingabefeld benötigt fast nie einen globalen Zustand. Ein Warenkorb hingegen, den Header, Produktseiten und die Zahlungsoberfläche alle abrufen, benötigt ihn mit ziemlicher Sicherheit. Vue lässt Raum für diese Beurteilung, anstatt jeden Zustand in denselben Container zu packen. Pinia unterstützt außerdem eine setup-basierte Syntax, die direkt ref und computed verwendet – was einige Teams bevorzugen, da es der Schreibweise von Composables entspricht.
Ein Wachstumspfad statt einer vorzeitigen Verpflichtung
Vue verlangt nicht, dass man bereits am ersten Tag die gesamte Architektur festlegt. Man beginnt mit Vue selbst, fügt Vue Router hinzu, wenn Routing benötigt wird, Pinia, wenn ein Zustand für die gesamte Anwendung erforderlich ist, und wechselt zu Nuxt, wenn man Server-Rendering, Server-Routen sowie eine umfassendere Full-Stack-Einrichtung möchte. Eine kleine Anwendung kann klein bleiben; eine große kann im Laufe der Zeit Struktur gewinnen, je nachdem, welche Anforderungen auftauchen.
Das ist besonders wichtig für bestehende Produkte. Vue kann in einen teilweise serverseitig generierten Anwendungscode, der mit Django, Laravel, Rails oder reinem PHP erstellt wurde, integriert werden, ohne dass der gesamte Frontend neu aufgebaut werden muss. Vielleicht benötigt nur ein Analyse-Dashboard eine hohe Interaktivität. Vielleicht ist ein Konfigurationsbildschirm den Server-Vorlagen überlegen, oder ein Editor erfordert umfangreiche Client-Seiteneffekte. Vue kann sich auf diesen Bereich konzentrieren und den Rest unberührt lassen.
Nicht jedes Frontend muss ein SPA sein
Das Ökosystem betrachtet „Frontend-Anwendung“ und „Single-Page-Anwendung“ manchmal als Synonyme. Das sind sie jedoch nicht. Viele Produkte profitieren mehr von serverseitig generierten Seiten mit wenigen sorgfältig ausgewählten interaktiven Elementen, und Vue passt gut in dieses Modell.
Für Teams, die ausgereifte Anwendungen warten, verändert dies die Sicht auf die Migration völlig. Die Frage lautet nicht mehr „Wie schreiben wir das Produkt in Vue um?“, sondern „Wo kann Vue den größten Widerstand beseitigen?“ Die zweite Frage führt zu kleineren, sichereren Schritten und früherem Nutzen.
Leistung entsteht durch Architektur – nicht durch Benchmarks
Vue leistet sich gut, doch Benchmark-Diagramme sind kein starker Grund, ein bestimmtes Framework zu wählen. Produktivanwendungen laufen nur selten langsam, weil ihr Framework in einem Mikrobenchmark um wenige Millisekunden verloren hat. Sie laufen langsam, weil sie riesige Listen rendern, überdimensionierte Pakete versenden, Netzwerkanfragen wiederholen, zu viel Zustand aktiv halten oder teure Aufgaben an der falschen Stelle ausführen.
Vues Modell macht es einfacher, solche Probleme zu vermeiden:
- Komponenten können klein und fokussiert bleiben
- Zustand kann lokal gehalten werden, anstatt Updates in der gesamten Anwendung auszulösen
- Funktionen und Routen können verzögert geladen werden
- wiederverwendbare Logik kann in Composables isoliert werden
- globaler Zustand kann auf das beschränkt werden, was tatsächlich dorthin gehört
Nichts davon sorgt automatisch für gute Leistung. Es macht es jedoch einfacher, über gute Leistungsmerkmale nachzudenken – was mehr wert ist als ein Benchmark-Sieg.
Ein ausgereiftes Ökosystem
Die Wahl von Vue bedeutete einst die Akzeptanz eines kleineren Ökosystems. Dieser Argument ist heute viel schwächer. Es gibt einen offiziellen Router, Pinia für den Anwendungsstate, ausgereifte Entwicklerwerkzeuge, eine breite Auswahl an UI-Komponentenbibliotheken sowie Nuxt für Teams, die ein vollständiges Anwendungsframework wünschen.
Zusammen decken sie ein breites Spektrum ab: ein einzelnes interaktives Widget, eine große SPA, eine serverseitig gerenderte Website oder ein Full-Stack-Produkt. Man muss nicht bereits vor dem Schreiben der ersten Codezeile wissen, welches dieser Konzepte man umsetzen will – und diese Flexibilität ist an sich schon ein Vorteil.
Das weniger messbare Argument: Entscheidungen folgen dem Bedarf
Der schwierigste Vorteil, den man quantifizieren kann, ist das Gefühl bei den täglichen Entscheidungen. Man öffnet eine Komponente und versteht sie. Da man wiederverwendbares Verhalten benötigt, schreibt man ein komponierbares Element. Der Zustand gehört zu einer Komponente, daher bleibt er dort. Wenn etwas wirklich geteilt werden muss, wird es in einen Store verschoben. Die App benötigt Routing, daher fügt man den Router hinzu. Wenn sie über eine reine Client-Seite-Anwendung hinauswächst, wendet man Nuxt an.
Jeder Schritt richtet sich nach der tatsächlichen Komplexität. Man trifft architektonische Entscheidungen, weil das Produkt sie erfordert – nicht, weil das Framework sie von vornherein vorschreibt. Im Laufe eines Projekts verstärkt sich dieser Unterschied immer weiter.
Wann Vue die falsche Wahl ist
Vue ist nicht automatisch die richtige Lösung. Eine Organisation mit einer umfangreichen React-Codebasis, eigener React-Komponentenbibliothek, tiefgreifender interner Erfahrung mit React sowie Rekrutierungsstrategien, die auf React-Entwickler ausgerichtet sind, wird durch einen Umstieg vermutlich mehr verlieren als gewinnen. Frameworks werden niemals isoliert ausgewählt; Teamkompetenzen, die bestehende Infrastruktur, Personalbeschaffungskosten sowie Migrationskosten spielen genauso eine wichtige Rolle wie das Programmiermodell.
Vue schützt Sie auch nicht vor schlechter Ingenieursarbeit. Es ist durchaus möglich, in Vue genauso wie in jedem anderen Framework riesige Komponenten, überdimensionierte globale Speicherstrukturen, unnötige Abstraktionsebenen sowie langsame Seiten zu erstellen. Es bietet zwar angenehme Standardeinstellungen, ersetzt aber keine eigene Urteilsfähigkeit.
Proportionalität als Gestaltungsprinzip
Was bei Vue hervorsticht, ist sein Gespür für Proportionen. Ein kleines Problem erhält eine kleine Lösung. Wieder verwendbares Verhalten wird zu Composables. Ein gemeinsamer Zustand wird nur dann zu einem Store, wenn es notwendig ist. Die Navigation gehört zum Router – größere Anwendungen können auf Nuxt wechseln – und TypeScript beschreibt die Grenzen, ohne die Codebasis zu übernehmen.
Nichts davon ist revolutionär, und genau das ist der Punkt. Ein Framework muss die Frontend-Entwicklung nicht alle paar Monate neu erfinden; es muss lediglich die bestehenden Probleme einfacher lösbar machen.
Haupterkenntnisse
- Vue 3s
refundcomputedhalten Zustand und abgeleitete Werte explizit, wodurch Komponenten auch bei zunehmender Größe lesbar bleiben. - Composables teilen Verhalten statt Markup; jeder Aufruf erzeugt einen unabhängigen Zustand, daher sollte ein Store verwendet werden, wenn Daten tatsächlich geteilt werden müssen.
defineProps und defineEmits sorgen dafür, dass der öffentliche Vertrag einer Komponente sichtbar und vom Compiler überprüft wird.