Entwurfsmuster in SPAs
Auftrag
Martina Hostettler will die App an der Mitgliederversammlung zeigen.
Als Vorstand möchte ich die App an der Mitgliederversammlung vorführen, auch wenn dort kein Server des Vereins läuft, damit die Mitglieder sie ausprobieren können.
Der Freiwillige möchte ausserdem nach Verfügbarkeit sortieren, und die neue Freiwillige fragt, warum die Karte eines Dings keinen Store kennt. Die Akzeptanzkriterien lauten:
- Die Stores bekommen die API von aussen und laufen mit der Kurs-API und mit einer Attrappe, ohne dass sich ihr Code ändert.
- Eine neue Sortierung braucht keine Änderung der Vorlage.
- Die Karte eines Dings funktioniert auf jeder Seite, ohne einen Store zu kennen.
Lernziele
Nach diesem Kapitel kannst du
- Entwurfsmuster als gemeinsame Sprache verwenden,
- den Beobachter in der Reaktivität von Vue wiedererkennen,
- Darstellung und Container trennen,
- Abhängigkeiten mit
provideundinjectübergeben, - einen Adapter und Strategien schreiben und einen Store als Fassade einsetzen.
Muster als gemeinsame Sprache
Abschnitt betitelt „Muster als gemeinsame Sprache“Ein Entwurfsmuster beschreibt eine bewährte Lösung für ein Problem, das beim Entwerfen von Software immer wieder auftaucht. Bekannt wurden viele davon durch das Buch «Design Patterns» von Gamma, Helm, Johnson und Vlissides aus dem Jahr 1994, das 23 Muster für objektorientierte Software beschreibt. Ihre Namen erleichtern das Gespräch über Code. «Hinter den Stores steckt ein Adapter» sagt in einem Satz, wofür sonst ein Absatz nötig wäre. In einer SPA mit Vue tauchen die Muster selten als Klassen auf, sondern als Funktionen, Module und Komponenten.
Der Beobachter in der Reaktivität
Abschnitt betitelt „Der Beobachter in der Reaktivität“Das Muster Beobachter sorgt dafür, dass alle abhängigen Objekte automatisch erfahren, wenn sich ein Objekt ändert. Genau das tut die Reaktivität von Vue. Laut Vue-Dokumentation merkt sich Vue, welche Effekte einen reaktiven Wert lesen, und führt sie nach einer Änderung erneut aus. Jede Komponente zeichnet sich in einem solchen Effekt, und ein watch ist ein Beobachter, den du selbst schreibst. Der Exkurs 1.5 hat ein kleines System dieser Art mit einem Proxy gebaut.
Darstellung und Container
Abschnitt betitelt „Darstellung und Container“Eine Darstellungskomponente zeigt nur, was sie als Props bekommt, und meldet Klicks als Events. Ein Container kennt Stores und API und reicht die Daten weiter.
App.vue
<script setup>import { ref } from 'vue'
import ItemCard from './ItemCard.vue'import { things } from './things.js'
// The container holds the state and decides what a click meansconst favorites = ref([])
function toggle(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <p>Favoriten: {{ favorites.length }}</p> <ul> <ItemCard v-for="thing in things" :id="thing.id" :key="thing.id" :name="thing.name" :category="thing.category" :favorite="favorites.includes(thing.id)" @toggle-favorite="toggle" /> </ul></template>ItemCard.vue
<script setup>// Presentational, the card only shows what it gets and reports the clickdefineProps({ id: { type: Number, required: true }, name: { type: String, required: true }, category: { type: String, required: true }, favorite: { type: Boolean, default: false },})
const emit = defineEmits(['toggle-favorite'])</script>
<template> <li class="card"> <strong>{{ name }}</strong> <span>{{ category }}</span> <button type="button" :aria-pressed="favorite" @click="emit('toggle-favorite', id)">Favorit</button> </li></template>
<style scoped>.card { display: flex; gap: 1rem; align-items: baseline;}</style>things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', category: 'Feste', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', category: 'Werkzeug', deposit: 40, availableFrom: '2026-11-05' },]Dan Abramov hat diese Trennung 2015 für React beschrieben. 2019 schrieb er dazu, dass er sie nicht mehr empfiehlt, weil Hooks die Logik mit Zustand auch ohne diese Aufteilung auslagern, in Vue tun das Composables. Die Idee bleibt nützlich, wo eine Komponente an mehreren Orten erscheint. In der Fallbeispiel-App zeigen Katalog und Favoriten dieselbe Karte ItemCard, die keinen Store kennt, und die Seiten entscheiden, was ein Klick auf «Favorit» bedeutet. Nicht jede Komponente muss eines von beidem sein.
Abhängigkeiten mit provide und inject
Abschnitt betitelt „Abhängigkeiten mit provide und inject“Eine Abhängigkeit ist etwas, das ein Teil des Codes braucht, um seine Arbeit zu tun, etwa die API für einen Store. Bei der Dependency Injection holt sich ein Teil seine Abhängigkeit nicht selbst, er bekommt sie von aussen. Die Vue-Dokumentation zeigt dafür provide und inject, mit app.provide gilt ein Wert für die ganze App.
App.vue
<script setup>import { onMounted, ref } from 'vue'
import { useApi } from './api.js'
const api = useApi()const items = ref([])const error = ref('')
onMounted(async () => { const { ok, data } = await api.loadItems() if (ok) items.value = data else error.value = data.error})</script>
<template> <p v-if="error" role="alert">{{ error }}</p> <ul v-else> <li v-for="item in items" :key="item.id">{{ item.name }}</li> </ul></template>api.js
import { inject } from 'vue'
import { things } from './things.js'
// The key under which main.js provides the APIexport const apiKey = Symbol('api')
// Two adapters with the same function. The page only knows loadItems, not where the data come from.export function createMemoryApi() { return { loadItems: async () => ({ ok: true, data: things }), }}
export function createBrokenApi() { return { loadItems: async () => ({ ok: false, data: { error: 'Der Server ist nicht erreichbar.' } }), }}
export function useApi() { return inject(apiKey)}main.js
import { createApp } from 'vue'
import App from './App.vue'import { apiKey, createMemoryApi } from './api.js'
// main.js decides which API the app gets, App.vue stays the samecreateApp(App).provide(apiKey, createMemoryApi()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', category: 'Feste', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', category: 'Werkzeug', deposit: 40, availableFrom: '2026-11-05' },]App.vue ruft nur useApi() und weiss nicht, welche Umsetzung main.js bereitgestellt hat. In der Fallbeispiel-App macht main.js dasselbe für die Stores.
const app = createApp(App)
// The stores get the API from here. Development and build talk to the course API, the demo gets// a stand-in in the browser. The API comes first, a guard of the first navigation already needs it.app.provide(apiKey, createApi())app.use(createPinia())app.use(router)
app.mount('#app')Pinia führt einen Setup Store im Kontext der App aus, darum funktioniert inject auch in einem Store. Die Fallbeispiel-App verpackt den Aufruf in eine Funktion, die meldet, wenn jemand die API vergessen hat.
// main.js provides the API under this key. A Symbol cannot clash with another key of the app.export const apiKey = Symbol('api')
// The demo runs without a server, its mode sets VITE_API to memory in .env.demoexport function createApi() { return import.meta.env.VITE_API === 'memory' ? createMemoryApi() : createHttpApi(API_URL)}
// The stores ask for the API instead of importing one, so main.js and the tests decide which one// the app gets. Both adapters answer every call with ok, status and data.export function useApi() { const api = inject(apiKey, null) if (api === null) throw new Error('No API provided. main.js provides one with app.provide(apiKey, createApi()).') return api}Der Schlüssel ist ein Symbol, das andere Dateien importieren, wie die Vue-Dokumentation für grössere Apps rät. Tests stellen dieselbe API bereit oder eine Attrappe und rufen so die echten Stores auf.
Ein Adapter für die API
Abschnitt betitelt „Ein Adapter für die API“Ein Adapter übersetzt eine Schnittstelle in die, die ein anderer Teil des Codes erwartet. Die Stores erwarten Funktionen wie loadItems(), die mit ok, status und data antworten. createHttpApi übersetzt dafür Adressen, Methoden, Statuscodes und JSON der Kurs-API, wie Kapitel 8.1 gezeigt hat. createMemoryApi gibt dieselben Antworten aus dem Speicher des Browsers, ohne Server. createApi wählt eine der beiden, und kein Store merkt den Unterschied. Kapitel 8.6 zeigt, wie die Demo für die Mitgliederversammlung davon Gebrauch macht.
Der Store als Fassade
Abschnitt betitelt „Der Store als Fassade“Eine Fassade gibt einem ganzen Teilsystem einen einfachen Eingang. Der Store des Katalogs bietet die Dinge, einen Status und die Actions load und markReturned. Wie er lädt, wie die API antwortet und welche Meldungen bei einem Fehler entstehen, sehen die Seiten nicht.
export const useCatalogStore = defineStore('catalog', () => { // The store hides where the things come from, the pages only know items, status and actions const api = useApi() const items = ref([]) // loading, success or error, App shows something for each of them const status = ref('loading') const error = ref('') const availableCount = computed(() => items.value.filter((item) => item.availableFrom === null).length)
async function load() { status.value = 'loading' const { ok, data } = await api.loadItems() if (ok) { items.value = data status.value = 'success' } else { error.value = data.error status.value = 'error' } }Pinia nennt seine Stores nicht Fassaden. Ein Store, der seine Daten selbst lädt und nur fertige Werte und Actions zeigt, erfüllt aber genau diese Aufgabe.
Strategien fürs Sortieren
Abschnitt betitelt „Strategien fürs Sortieren“Eine Strategie ist ein austauschbares Verfahren für dieselbe Aufgabe. Jede Sortierung ist eine Funktion, die zwei Dinge vergleicht, und die Seite wählt eine davon, statt jede Sortierung selbst zu kennen.
App.vue
<script setup>import { computed, ref } from 'vue'
import { sorters } from './sorters.js'import { things } from './things.js'
const sort = ref('name')// The page asks the chosen strategy and knows no order itselfconst sorted = computed(() => things.toSorted(sorters[sort.value].compare))</script>
<template> <label for="sort">Sortieren nach</label> <select id="sort" v-model="sort"> <option v-for="(sorter, key) in sorters" :key="key" :value="key">{{ sorter.label }}</option> </select> <ol> <li v-for="thing in sorted" :key="thing.id">{{ thing.name }}, CHF {{ thing.deposit }}, {{ thing.availableFrom ?? 'da' }}</li> </ol></template>sorters.js
const byName = (a, b) => a.name.localeCompare(b.name, 'de-CH')
// Every sort order is a strategy with a label and a function that compares two thingsexport const sorters = { name: { label: 'Name', compare: byName }, deposit: { label: 'Pfand', compare: (a, b) => a.deposit - b.deposit || byName(a, b) }, availability: { label: 'Verfügbarkeit', compare: (a, b) => (a.availableFrom ?? '').localeCompare(b.availableFrom ?? '') || byName(a, b), },}things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', category: 'Werkzeug', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', category: 'Feste', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', category: 'Werkzeug', deposit: 40, availableFrom: '2026-11-05' },]Die Fallbeispiel-App hält ihre Sortierungen in src/features/catalog/sorters.js und baut das Menü daraus. Die Sortierung nach Verfügbarkeit ist ein weiterer Eintrag, die Vorlage des Katalogs blieb dabei gleich.
In deinem Projekt
Abschnitt betitelt „In deinem Projekt“Setz ein Muster dort ein, wo du sein Problem hast, nicht als Schmuck. Hast du eine API, die du später austauschen oder in Tests ersetzen willst, lohnt sich ein Adapter. Nenn das Muster im README oder in einem Architekturentscheid, dann weiss deine Lehrperson im Screencast, wonach sie schauen kann.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Gib im ersten Beispiel jeder
ItemCardeine eigene Liste der Favoriten und nimm die Propfavoriteweg. Was zeigt die Zahl oben? - Ersetze im Beispiel mit dem Adapter in
main.jsden AufrufcreateMemoryApi()durchcreateBrokenApi(). Was ändert sich inApp.vue, und was auf der Seite? - Ergänze im Beispiel mit den Strategien eine Sortierung «Pfand absteigend». In welchen Dateien schreibst du?
- Die Kurs-API liefert die Dinge in einem anderen Format. Welche Dateien der Fallbeispiel-App ändern sich?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen einen Katalog mit Darstellung und Container, einem Adapter und Strategien. Du findest das Projekt im Kursrepo im Ordner exercises/08-architecture/patterns/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
ItemCard.vue holt sich den Store der Favoriten selbst und lässt sich ohne Pinia nicht zeigen. Mach daraus eine Darstellungskomponente mit den Props id, name, category, deposit und favorite, die einen Klick auf «Favorit» als Event toggle-favorite mit der Nummer meldet. Die Seite des Katalogs wird zum Container, der den Store kennt.
Lösung
<script setup>import { formatDeposit } from '@/format.js'
// A presentational component, it gets everything through props and reports clicks as eventsdefineProps({ id: { type: Number, required: true }, name: { type: String, required: true }, category: { type: String, required: true }, deposit: { type: Number, required: true }, favorite: { type: Boolean, default: false },})
const emit = defineEmits(['toggle-favorite'])</script>
<template> <li class="card"> <strong>{{ name }}</strong> <span class="category">{{ category }}</span> <span class="deposit">{{ formatDeposit(deposit) }}</span> <button type="button" :aria-pressed="favorite" @click="emit('toggle-favorite', id)">Favorit</button> </li></template>Die Seite übergibt mit :favorite="favorites.isFavorite(item.id)" den Zustand und ruft mit @toggle-favorite="favorites.toggle" den Store auf.
Aufgabe Vertiefung
Der Verein prüft eine Ausleihsoftware. Sie liefert ihre Dinge in public/things.json in einem eigenen Format, mit itemId, title, group, dem Pfand in Rappen als depositCents und dem Tag der Rückgabe als returnDate. Schreib in src/api/adapter.js eine Funktion toItem(thing), die daraus ein Ding im Format der App macht, und lass fetchItems() nur noch solche Dinge liefern. Keine Komponente kennt danach das fremde Format.
Lösung
// The lending software calls a thing differently. The adapter turns one of its things into an item// as the app knows it, so no component has to know the other format.export function toItem(thing) { return { id: thing.itemId, name: thing.title, category: thing.group, deposit: thing.depositCents / 100, availableFrom: thing.returnDate, }}import { toItem } from './adapter.js'
// The lending software delivers its things as things.json, the adapter turns them into itemsexport async function fetchItems() { const response = await fetch(`${import.meta.env.BASE_URL}things.json`) if (!response.ok) throw new Error(`Die Ausleihsoftware antwortet mit dem Status ${response.status}.`) const things = await response.json() return things.map(toItem)}Ändert die Ausleihsoftware ihr Format, ändert sich nur der Adapter.
Aufgabe Knobelei
Die Seite sortiert mit einem if pro Sortierung, und das Menü steht fest in der Vorlage. Leg in src/sorters.js die Strategien name, deposit und availability an, jede mit label und compare, und bau das Menü aus diesen Einträgen. Nach Verfügbarkeit kommt zuerst, was da ist, danach der Rest nach dem Tag der Rückgabe.
Lösung
const byName = (a, b) => a.name.localeCompare(b.name, 'de-CH')
// Every sort order is a strategy, a function that compares two items. The page builds its menu from// these entries, so a new order is a new entry and nothing else changes.export const sorters = { name: { label: 'Name', compare: byName }, deposit: { label: 'Pfand', compare: (a, b) => a.deposit - b.deposit || byName(a, b) }, // Things that are in come first, then the others by the day they come back availability: { label: 'Verfügbarkeit', compare: (a, b) => (a.availableFrom ?? '').localeCompare(b.availableFrom ?? '') || byName(a, b), },}const sorted = computed(() => items.value.toSorted(sorters[sort.value].compare))</script>
<template> <h1>Katalog</h1> <label for="sort">Sortieren nach</label> <select id="sort" v-model="sort"> <option v-for="(sorter, key) in sorters" :key="key" :value="key">{{ sorter.label }}</option> </select>Ein Ding, das da ist, hat kein Datum. ?? '' macht daraus einen leeren Text, der vor jedem Datum steht.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Ein Muster ohne passendes Problem macht den Code länger und nicht einfacher. | Ein Muster erst einsetzen, wenn das Problem auftaucht, das es löst. |
| Eine Darstellungskomponente ruft einen Store auf und lässt sich nicht mehr allein zeigen oder testen. | Daten als Props übergeben und Klicks als Events melden. |
inject liefert undefined. | provide fehlt oder steht unter einem anderen Schlüssel. Den Schlüssel als Symbol aus einer Datei importieren. |
| Felder der fremden API tauchen in Komponenten auf. | Nur der Adapter kennt das fremde Format. |
Eine neue Sortierung bringt ein weiteres if in die Seite. | Jede Sortierung als Strategie in eine Tabelle legen. |
Ein KI-Assistent schlägt einen globalen Event-Bus mit $on vor. | Vue 3 hat $on laut Migrationsleitfaden entfernt. Props und Events, provide und inject oder einen Store verwenden. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Was beschreibt ein Entwurfsmuster, und wozu dienen seine Namen?
- Wo steckt der Beobachter in Vue?
- Was unterscheidet eine Darstellungskomponente von einem Container?
- Warum ändern sich die Stores nicht, wenn die App eine Attrappe der API bekommt?
Antworten
- Eine bewährte Lösung für ein wiederkehrendes Problem. Mit dem Namen lässt sich eine Lösung in einem Wort benennen.
- In der Reaktivität. Effekte wie das Zeichnen einer Komponente oder ein
watchlesen reaktive Werte und laufen nach einer Änderung erneut. - Die Darstellungskomponente bekommt alles über Props und meldet Events. Der Container kennt Stores und API.
- Sie rufen nur Funktionen der API auf, die beide Adapter gleich anbieten, und bekommen ihre API über
inject.
Auftrag
Die Stores wissen nicht mehr, woher die Daten kommen, und die Karte eines Dings passt auf jede Seite. Die neue Freiwillige fragt jetzt, warum die App Pinia verwendet, warum die API über einen Proxy läuft und warum das Formular beim Verlassen nicht mehr nachfragt. Niemand erinnert sich an alle Gründe. Das nächste Kapitel hält solche Entscheide fest.
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.