Zum Inhalt
wannacode

Gemeinsamer Zustand

Kurz gesagt: Gemeinsamer Zustand kommt in Vue von provide/inject oder einem Pinia-Store, und die Komponenten lesen ihn direkt, ohne Props durch jede Ebene.

Problem

Der Zähler zeigt wieder 0

Der Warenkorb ist nach App hochgereicht, wie in der Lektion über das Hochreichen des Zustands. Von dort geht er als Props nach unten: zu Header und von dort zu CartBadge, zu Catalog, von dort zu ProductList und von dort zu jeder ProductCard. Jemand hat Header umgeschrieben, und der Zähler ist stehen geblieben.

Vue

Leg Weißbrot, Frischmilch und noch einmal Weißbrot hinein. Die Karten zählen, und im Kopfbereich steht „Warenkorb (0)“. App gibt count an Header, aber das neue Header gibt es nicht an CartBadge weiter. Header nutzt den Warenkorb nicht. Es sollte ihn nur weiterreichen, und genau das hat es vergessen.

Die Idee

Ein Provider oben, Konsumenten unten

Daten durch Komponenten zu reichen, die sie nicht nutzen, heißt Prop Drilling. Bei zwei Ebenen ist das normal. Bei fünf hängt jede Komponente auf dem Weg vom Warenkorb ab, und eine vergessene Prop reißt die Kette.

Beide Bibliotheken haben einen Weg, Daten an entfernte Nachkommen zu geben, ohne die Komponenten dazwischen:

  1. Der Provider ist die Komponente, der die Daten gehören. Er sagt: „Alles unter mir darf das lesen.“
  2. Der Konsument ist die Komponente, die sie nutzt. Sie nimmt sie direkt, egal wie viele Ebenen dazwischen liegen.

In Vue sind das provide und inject:

App.vue
<script setup>
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
provide('cart', { items, count, add });
</script>
<!-- CartBadge.vue -->
<script setup>
const { count } = inject('cart');
</script>

provide('cart', …) gibt einen Wert unter einem Schlüssel an alle Nachkommen der Komponente. inject('cart') nimmt ihn vom nächsten Vorfahren, der ihn bereitgestellt hat. Weitergegeben werden die Refs (items, count), nicht ihre Werte, damit die Konsumenten reaktiv bleiben.

Die Daten haben weiterhin einen Besitzer, und die Änderungen laufen weiter über ihn: add ist die Funktion des Providers. Nur der Weg hat sich geändert.

Sieh es dir an

Sieh dir die beiden Wege an

Zuerst geht der Warenkorb Ebene für Ebene nach unten, und die Kette reißt in der Mitte. Dann wird App zum Provider, und beide Konsumenten lesen direkt.

<script setup>
const items = ref([]);
const count = computed(() =>
items.value.reduce((s, i) => s + i.qty, 0));
function add(product) { /* … */ }
provide('cart', { items, count, add });
</script>
<template>
<Header />
<Catalog />
</template>
<!-- CartBadge.vue -->
<script setup>
const { count } = inject('cart');
</script>
<template>
<a href="/cart">Warenkorb ({{ count }})</a>
</template>
<!-- ProductCard.vue -->
<script setup>
const { add } = inject('cart');
</script>
<template>
<button @click="add(product)">Hinzufügen</button>
</template>
Schritt für Schritt, in Worten
  1. Der Warenkorb lebt in App. Zwei Komponenten weit unten brauchen ihn: CartBadge im Kopfbereich und ProductCard im Katalog.
  2. Nur mit Props wandert der Warenkorb Ebene für Ebene nach unten. Header, Catalog und ProductList nutzen ihn nicht. Sie reichen ihn nur weiter.
  3. Jemand schreibt Header um und vergisst count. Die Kette reißt in der Mitte, und der Zähler zeigt 0. Das ist Prop Drilling.
  4. Stattdessen: App ruft provide('cart', …) auf. Jeder Nachkomme von App kann den Warenkorb lesen.
  5. CartBadge und ProductCard lesen ihn mit inject('cart'). Die Komponenten dazwischen wissen nichts vom Warenkorb.
  6. Ein Klick auf „Hinzufügen“ ruft add(product) auf. Die Funktion kommt vom Provider, deshalb passiert die Änderung in App.
  7. Der Warenkorb in App ändert sich, und jede Komponente, die ihn liest, wird neu gezeichnet: Der Zähler zeigt 1, die Karte zeigt 1.
  8. Die Daten haben weiterhin einen Besitzer, App. Nur der Weg hat sich geändert: nicht durch jede Ebene, sondern direkt vom Provider.

Die Komponenten in der Mitte wissen jetzt nichts mehr vom Warenkorb. Das neue Header kannst du so oft umschreiben, wie du willst.

Probier es aus

Entferne das Drilling

Der Warenkorb geht als Props durch Header und Catalog nach unten, und Header lässt count fallen. Gib den Warenkorb von oben. Header, Catalog und ProductList bekommen nichts über den Warenkorb, und CartBadge und ProductCard lesen ihn direkt. Die Prüfung legt Weißbrot, Frischmilch und noch einmal Weißbrot hinein und schaut sich die Karten und den Zähler an.

Vue

Die Idee

Ein Store für den Warenkorb

Der Warenkorb wächst: hinzufügen, entfernen, die Anzahl ändern, leeren. Ist jede Aktion eine Funktion in App, füllt sich App mit Logik, die nicht seine Aufgabe ist. Deshalb zieht der gemeinsame Zustand in eine eigene Datei um, einen Store: die Daten und alles, was sie ändert, an einem Ort.

Für Stores nutzt Vue Pinia, die offizielle Bibliothek für gemeinsamen Zustand. Ein Store wird mit defineStore erzeugt und wie <script setup> geschrieben: Ein ref ist der Zustand, ein computed wird daraus abgeleitet, die Funktionen sind die Aktionen.

cart.js
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) { /* … */ }
return { items, count, add };
});

Pinia wird einmal installiert, in main.js: createApp(App).use(createPinia()).mount('#app'). Jede Komponente ruft useCartStore() auf und bekommt denselben Store. Ein provide in App ist nicht nötig.

<script setup>
import { storeToRefs } from 'pinia';
const cart = useCartStore();
const { count } = storeToRefs(cart);
</script>

Der Store ist ein reactive-Objekt: Im Template schreibst du cart.count, ohne .value. Willst du ihn in Variablen zerlegen, nimm storeToRefs(cart). Das liefert Refs, die mit dem Store verbunden bleiben. Die Aktionen (cart.add) kannst du direkt nehmen.

Vue
Öffne cart.js: Dort steht die ganze Logik des Warenkorbs. App.vue hat keinen Zustand mehr, und main.js installiert Pinia.

Probier es aus

Finde den Fehler

Die Karten zählen, und der Zähler bleibt bei „Warenkorb (0)“. In der Konsole steht kein Fehler. Welche Zeile ist schuld?

Wähle die Zeile mit dem Fehler.

Die Idee

Wann geteilt und wann nicht

Gemeinsamer Zustand ist praktisch, und deshalb ist es verlockend, alles dorthin zu legen. Aber alles darin kann von überall gelesen und geändert werden, und das macht Fehler schwerer auffindbar. Die Regel aus der Lektion über das Hochreichen gilt: so hoch wie nötig, und nicht höher.

  • Geteilt: was entfernte Teile des Baums brauchen und was lebt, solange der Nutzer auf der Seite ist. Der Warenkorb, der angemeldete Nutzer, die Sprache, das Design.
  • In der Komponente: die Felder eines Formulars, ob das Menü offen ist, welcher Tab gewählt ist, der Text im Suchfeld. Eine Komponente (oder ihr Elternteil) braucht sie, und sie verschwinden mit ihr.
  • Die Daten des Servers sind kein gemeinsamer Zustand. Die Produktliste und die Bestellungen haben einen Besitzer, und der Besitzer ist der Server. In einem Store sind sie eine Kopie, die aufgefrischt werden muss. Die Komponente, die sie zeigt, lädt sie, wie in der Lektion über Daten aus einer API.

Gemeinsamer Zustand ändert die Richtung nicht: Die Daten gehen von einem Besitzer aus, und die Änderungen laufen über seine Funktionen.

Probier es aus

Ergänze die Lücke

Wie nimmt CartBadge count aus dem Store, damit es mit ihm verbunden bleibt?
<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';

const { count } = (useCartStore());
</script>
Genauer, für Neugierige

Ein Store mit Optionen. defineStore nimmt auch ein Objekt: state: () => ({ items: [] }), getters und actions. Das ist dasselbe, geschrieben als Options API. Pinia unterstützt beide Arten.

provide/inject oder Pinia. provide ist eingebaut und eignet sich für Dinge, die ein Teilbaum braucht (die Einstellungen einer Tabelle, ein Formular mit verschachtelten Feldern). Pinia ist für den Zustand der ganzen Anwendung: Es ist in den Vue DevTools sichtbar, lässt sich für sich testen und hängt nicht davon ab, welche Komponente oben steht.

Ein Symbol als Schlüssel. Statt des Strings 'cart' nimmt provide oft ein Symbol(), das aus einer gemeinsamen Datei exportiert wird. Dann können zwei verschiedene Provider nicht aus Versehen denselben Schlüssel haben.

inject mit Standardwert. inject('cart', null) schreibt keine Warnung, wenn niemand den Schlüssel bereitgestellt hat, und liefert null.

Der Store lebt, solange die Seite offen ist. Beim Neuladen ist er weg. Ein Warenkorb, der überleben soll, wird in localStorage oder auf dem Server gespeichert.

Die Prüfungen sehen das Verhalten. Die Aufgaben hier sehen nicht, ob der Warenkorb aus Props, aus einem Context oder aus einem Store kommt. Sie prüfen, dass der Zähler und die Karten dasselbe zeigen.

Übung

Die Warenkorbseite

Im Hauptteil steht CartSummary: eine Überschrift Der Warenkorb, Zeilen Weißbrot × 2 mit einem Button Entfernen, Gesamt: 4,00 €, und Der Warenkorb ist leer, wenn er leer ist. Jetzt hat es einen eigenen Warenkorb, den niemand füllt. Füge dem Store das Entfernen und eine Summe hinzu und lass CartSummary den Warenkorb von dort lesen. Die Prüfung legt drei Produkte hinein, entfernt sie und schaut sich den Zähler, die Karten und die Summe an.

Vue

Test

Teste dich

1. Der Warenkorb geht von App über Catalog und ProductList bis zu ProductCard nach unten. Catalog und ProductList nutzen ihn nicht. Was ist das Problem?
2. CartBadge sagt const { count } = useCartStore();. Was zeigt der Zähler?
3. ProductCard ruft inject('cart') auf, und niemand über ihm hat provide('cart', …) aufgerufen. Was passiert?
4. Was davon gehört in den gemeinsamen Zustand?
5. Die Produktliste kommt von /api/products. Soll sie in den gemeinsamen Store?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·