Reaktivität im Detail
Auftrag
Der Freiwillige hat den Kopf der App umgebaut. Seither bleibt der Zähler der Favoriten bei der Zahl stehen, die er beim Laden der Seite hatte, und Martina Hostettler meldet den Fehler vom Tresen.
Ich markiere drei Dinge als Favoriten, und im Kopf steht immer noch «0 Favoriten».
Bei der Fehlersuche findet der Freiwillige zwei weitere Stellen, an denen die App die Verbindung zu ihrem Zustand verliert. Die Akzeptanzkriterien für die Korrektur lauten:
- Der Zähler im Kopf folgt jeder Änderung der Favoriten.
- «Filter zurücksetzen» leert Suchfeld und Kategorie, und die Suche funktioniert danach weiter.
- Ein Ding, das zurückgebracht wird, zeigt sich sofort als verfügbar, auch wenn Vue nicht mehr jedes Feld des Katalogs beobachtet.
Lernziele
Nach diesem Kapitel kannst du
- erklären, wie Vue die Abhängigkeiten zwischen reaktiven Werten und Effekten verfolgt,
- die Abhängigkeiten einer Komponente mit
onRenderTrackedundonRenderTriggeredsichtbar machen, - Fehler finden, bei denen ein Wert kopiert oder ein reaktives Objekt ersetzt wird,
- mit
toRefundtoRefseinzelne Felder verbunden halten, shallowRefundtriggerReffür grosse Daten einsetzen,- mit
nextTickauf die aktualisierte Seite warten.
Effekte und Abhängigkeiten
Abschnitt betitelt „Effekte und Abhängigkeiten“In Teil 3 hast du gesehen, dass Vue eine Komponente neu rendert, sobald sich ein Ref ändert, das ihr Template liest. Der Exkurs Proxy und Getter aus Teil 1 hat gezeigt, wie so etwas funktionieren kann. Vue nennt eine Funktion, die es ausführt und dabei beobachtet, einen Effekt. Während ein Effekt läuft, notiert Vue jeden reaktiven Wert, den er liest. Diesen Schritt nennt Vue track. Ändert sich später einer dieser Werte, führt Vue die Effekte erneut aus, die ihn gelesen haben. Diesen Schritt nennt Vue trigger.
In einer Komponente gibt es mehrere Effekte. Die Render-Funktion, die der Compiler aus dem Template erzeugt, ist einer. Jeder berechnete Wert ist ein weiterer, und die Watcher aus dem nächsten Kapitel gehören auch dazu.
Die Abbildung zeigt den Zähler der Favoriten. Die Render-Funktion von AppHeader liest den berechneten Wert favoriteCount, und dieser liest das Ref favorites. Vue merkt sich beide Abhängigkeiten. Ergänzt push einen Favoriten, markiert Vue favoriteCount als veraltet und führt die Render-Funktion erneut aus. Sie liest favoriteCount, der jetzt neu rechnet, und die Seite zeigt den neuen Wert.
Abhängigkeiten sichtbar machen
Abschnitt betitelt „Abhängigkeiten sichtbar machen“Welche Werte eine Komponente beim Rendern liest, zeigt Vue mit zwei Hooks für die Fehlersuche. onRenderTracked meldet jeden Wert, den die Render-Funktion liest, onRenderTriggered jede Änderung, die sie erneut auslöst. Laut der Dokumentation funktionieren beide nur während der Entwicklung. Lies das Beispiel, öffne die Konsole des Playgrounds und klick dann auf beide Knöpfe.
<script setup>import { onRenderTracked, onRenderTriggered, ref } from 'vue'
const favorites = ref([1, 6])const query = ref('')
// Vue reports which values the render function reads and which change makes it run againonRenderTracked((event) => console.log('gelesen', event.type, String(event.key)))onRenderTriggered((event) => console.log('ausgelöst', event.type, String(event.key)))</script>
<template> <p>{{ favorites.length }} Favoriten</p> <button type="button" @click="favorites.push(3)">Raclette-Ofen merken</button> <button type="button" @click="query = 'Zelt'">Suchtext ändern</button></template>Beim ersten Rendern erscheinen zwei Zeilen. Das Template liest value des Refs favorites und danach length des Arrays darin. Ein Klick auf «Raclette-Ofen merken» meldet ausgelöst add 2, denn push hat an der Stelle 2 ein Element ergänzt. Danach erscheinen die beiden Zeilen für das Lesen noch einmal, weil Vue die Abhängigkeiten bei jedem Durchlauf neu sammelt. Ein Klick auf «Suchtext ändern» bleibt ohne Meldung. Das Template liest query nicht, also hängt das Rendern nicht davon ab.
Kopierte Werte
Abschnitt betitelt „Kopierte Werte“Damit lässt sich der Fehler im Kopf erklären. Der Freiwillige hat die Zahl der Favoriten einmal ausgerechnet und in einer Konstanten abgelegt.
const count = props.favorites.lengthDiese Zeile läuft ein einziges Mal, wenn die Komponente entsteht. Sie liest props.favorites, aber in keinem Effekt, also notiert Vue nichts, und count bleibt eine gewöhnliche Zahl. Das Template liest danach nur noch diese Zahl. Das Beispiel zeigt den Unterschied zwischen einer Kopie und einem berechneten Wert.
<script setup>import { computed, reactive } from 'vue'
const state = reactive({ favorites: [1, 6] })
// The number is read once, when the component is created, and never againconst copiedCount = state.favorites.length
// A computed value reads the array again whenever it changesconst favoriteCount = computed(() => state.favorites.length)
function addFavorite() { state.favorites.push(state.favorites.length + 10)}</script>
<template> <button type="button" @click="addFavorite">Favorit hinzufügen</button> <p>Kopie: {{ copiedCount }}</p> <p>computed: {{ favoriteCount }}</p></template>Nach zwei Klicks zeigt die Kopie weiter 2, der berechnete Wert 4. Ein berechneter Wert ist ein Effekt. Vue führt seine Funktion aus, notiert, was sie liest, und rechnet nach einer Änderung neu. Dieselbe Falle steckt in jeder Zeile, die auf oberster Ebene von <script setup> einen reaktiven Wert liest und das Ergebnis behält, etwa const name = props.item.name.
Ersetzte Objekte
Abschnitt betitelt „Ersetzte Objekte“Der zweite Fehler steckt im Knopf «Filter zurücksetzen». Der Filter ist ein reaktives Objekt, und der Freiwillige ersetzt es beim Zurücksetzen durch ein neues.
let filter = reactive({ query: '', category: '' })
function resetFilter() { filter = reactive({ query: '', category: '' })}Das Template und die berechnete Liste haben die Felder des alten Proxys gelesen. Vue verfolgt Zugriffe auf diesen Proxy, die Variable filter ist dagegen eine gewöhnliche Variable. Eine neue Zuweisung an sie löst nichts aus. Die Felder zeigen weiter den alten Text, und weil das Suchfeld danach in das neue Objekt schreibt, folgt die Liste der Suche nicht mehr.
Ein reaktives Objekt behält deshalb seine Identität, nur seine Felder ändern sich. Object.assign schreibt mehrere Felder auf einmal.
const filter = reactive({ query: '', category: '' })
function resetFilter() { Object.assign(filter, { query: '', category: '' })}Mit const lässt JavaScript keine neue Zuweisung zu, darum steht reactive immer mit const. Bei einem Ref ist ein neuer Wert für .value dagegen erlaubt und löst aus, weil Vue das Schreiben von value verfolgt.
Einzelne Felder mit toRef und toRefs
Abschnitt betitelt „Einzelne Felder mit toRef und toRefs“Auch Destrukturieren kopiert, wie du in Teil 3 gesehen hast. Brauchst du ein einzelnes Feld als eigene Variable, liefert toRef ein Ref, das beim Lesen und Schreiben auf das Feld des Objekts zugreift. toRefs macht das für alle Felder eines Objekts auf einmal, und das Ergebnis lässt sich gefahrlos destrukturieren.
<script setup>import { reactive, toRefs } from 'vue'
const filters = reactive({ query: '', category: '' })
// Destructuring copies the current valueconst { query } = filters// toRefs turns every field into a ref that stays connected to the objectconst { category } = toRefs(filters)</script>
<template> <button type="button" @click="filters.query = 'säge'">Suchtext setzen</button> <button type="button" @click="filters.category = 'Werkzeug'">Kategorie setzen</button> <p>Kopie von query: {{ JSON.stringify(query) }}</p> <p>Ref für category: {{ JSON.stringify(category) }}</p></template>Nach beiden Klicks zeigt die Kopie von query weiter einen leeren Text, das Ref für category dagegen «Werkzeug». Gebraucht wird toRefs vor allem dort, wo eine Funktion ein reaktives Objekt zurückgibt und der Aufruf es zerlegen will. Das Kapitel Composables kommt darauf zurück.
Grosse Daten mit shallowRef
Abschnitt betitelt „Grosse Daten mit shallowRef“Ein Ref mit einem Array von Objekten macht auch die Objekte darin reaktiv, sobald jemand sie liest. Jeder Zugriff auf ein Feld läuft dann durch einen Proxy, der Abhängigkeiten notiert. Die Dokumentation nennt den Aufwand spürbar, wenn ein einziges Rendern mehr als 100'000 Felder in grossen, verschachtelten Arrays liest. Für solche Daten gibt es shallowRef. Es verfolgt nur .value selbst, und was darin steckt, bleibt ein gewöhnliches Objekt.
<script setup>import { shallowRef, triggerRef } from 'vue'
// Only a new value for .value triggers an update, changes inside the array do notconst items = shallowRef([{ id: 1, name: 'Bohrmaschine' }])
function pushInside() { items.value.push({ id: items.value.length + 1, name: 'Leiter' })}
function replace() { items.value = [...items.value, { id: items.value.length + 1, name: 'Zelt' }]}
// In the template, items would arrive unwrapped, triggerRef needs the ref itselffunction force() { triggerRef(items)}</script>
<template> <button type="button" @click="pushInside">Im Array ergänzen</button> <button type="button" @click="replace">Array ersetzen</button> <button type="button" @click="force">triggerRef</button> <ul> <li v-for="item in items" :key="item.id">{{ item.name }}</li> </ul></template>Ein Klick auf «Im Array ergänzen» ändert das Array, die Liste bleibt aber gleich, denn push gibt .value keinen neuen Wert. «Array ersetzen» tut das, und die Liste zeigt alle Einträge, auch den aus push. Nach einer Änderung im Innern löst triggerRef die Effekte von Hand aus. Im Template käme items schon ausgepackt an, darum ruft das Beispiel triggerRef in einer Funktion im Script auf. Mit shallowRef behandelst du die Daten darin also wie unveränderlich und ersetzt sie bei jeder Änderung.
Warten auf das DOM mit nextTick
Abschnitt betitelt „Warten auf das DOM mit nextTick“Vue aktualisiert die Seite nicht nach jeder einzelnen Änderung. Es sammelt die ausgelösten Effekte und führt sie gemeinsam aus, wenn der laufende Code fertig ist. Ändert eine Funktion drei Refs, rendert die Komponente einmal statt dreimal. Die Dokumentation begründet das damit, dass jede Komponente nur einmal aktualisiert werden soll. Will Code nach einer Änderung mit der aktualisierten Seite arbeiten, wartet er mit await nextTick().
<script setup>import { nextTick, ref } from 'vue'
const count = ref(0)const log = ref([])
async function add() { count.value++ const before = document.querySelector('#count').textContent // Vue collects changes and updates the page once, after the current code await nextTick() const after = document.querySelector('#count').textContent log.value.push(`vor nextTick ${before}, nach nextTick ${after}`)}</script>
<template> <button type="button" @click="add">+1</button> <p id="count">{{ count }}</p> <ul> <li v-for="(entry, index) in log" :key="index">{{ entry }}</li> </ul></template>Nach einem Klick steht im Protokoll «vor nextTick 0, nach nextTick 1». Direkt nach count.value++ zeigt der Absatz noch die alte Zahl, nach await nextTick() hat Vue gerendert. Gebraucht wird das selten, etwa um ein Element zu fokussieren, das erst durch die Änderung entsteht. Das Kapitel Lebenszyklus und Template-Refs zeigt einen solchen Fall.
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.
- Ergänze im Beispiel zur Fehlersuche im Template einen Absatz mit
{{ query }}. Was meldet die Konsole jetzt beim Klick auf «Suchtext ändern»? - Mach im Beispiel mit der Kopie aus
copiedCounteine Funktion, die die Länge zurückgibt, und ruf sie im Template auf. Was zeigt die Zeile nach zwei Klicks, und warum? - Ersetze im Beispiel mit
shallowRefden Aufruf durchref. Was ändert sich beim Knopf «Im Array ergänzen»? - Ruf im Beispiel zu
nextTickinaddden Zähler dreimal hintereinander mitcount.value++auf. Was steht danach im Protokoll?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben reparieren die Übungsversion der App aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/05-reactivity/reactivity/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Der Zähler im Kopf bleibt bei «0 Favoriten» stehen, auch wenn Knöpfe gedrückt sind. Finde in src/components/AppHeader.vue die Stelle, die den Wert kopiert, und mach aus dem Text einen berechneten Wert. Der Kopf zeigt danach «1 Favorit» oder «2 Favoriten».
Lösung
<script setup>import { computed } from 'vue'
const props = defineProps({ favorites: { type: Array, default: () => [] } })
// computed reads the favourites again whenever they change, a constant keeps the first numberconst label = computed(() => { const count = props.favorites.length return count === 1 ? '1 Favorit' : `${count} Favoriten`})</script>computed liest props.favorites bei jedem Durchlauf und notiert die Abhängigkeit. Ändert App die Favoriten, bekommt AppHeader ein neues Array als Prop, und Vue rechnet den Text neu. Eine Funktion, die das Template aufruft, würde auch funktionieren, denn die Render-Funktion ist selbst ein Effekt.
Aufgabe Vertiefung
Nach «Filter zurücksetzen» bleiben Suchtext und Kategorie stehen, und die Liste folgt der Suche nicht mehr. Repariere resetFilter in src/App.vue so, dass das reaktive Objekt erhalten bleibt.
Lösung
// Search text and category of the filter. The object stays the same, only its fields change.const filter = reactive({ query: '', category: '' })// Object.assign changes the fields, so the template and the list keep their connectionfunction resetFilter() { Object.assign(filter, { query: '', category: '' })}Object.assign schreibt beide Felder in das bestehende Objekt, und Vue löst für jedes Feld aus. Zwei Zuweisungen an filter.query und filter.category tun dasselbe. Mit const statt let scheitert die alte Zeile sofort mit einem Fehler, denn einer Konstanten lässt sich nichts Neues zuweisen.
Aufgabe Knobelei
Der Katalog ändert sich nur als Ganzes, wenn ein Ding zurückkommt. Lege catalog in src/App.vue in ein shallowRef. Danach funktioniert der Knopf «Zurückgebracht» nicht mehr. Bring ihn wieder zum Laufen, ohne zu ref zurückzukehren. Das Ding zeigt sich danach als verfügbar, der Knopf verschwindet, und über der Liste steht «50 von 60 Dingen sind da».
Lösung
// The catalog only changes as a whole, so Vue need not watch every field of every thingconst catalog = shallowRef(items)// shallowRef only notices a new value, so the catalog becomes a new array with a changed copyfunction markReturned(id) { catalog.value = catalog.value.map((item) => (item.id === id ? { ...item, availableFrom: null } : item))}map liefert ein neues Array, in dem das zurückgebrachte Ding eine Kopie mit availableFrom: null ist. Die Zuweisung an .value löst aus, und Vue rendert die Karte und rechnet die Zahl über der Liste neu. Die Kopie mit ...item lässt das Objekt aus den Daten unverändert. Eine Lösung mit triggerRef(catalog) nach der Änderung im Innern besteht die Tests auch, sie hängt aber davon ab, dass niemand den Aufruf vergisst.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
| Ein Wert bleibt beim Stand stehen, den er beim Laden der Seite hatte. | Nach einer Zeile auf oberster Ebene von <script setup> suchen, die einen reaktiven Wert liest und das Ergebnis behält, und daraus computed machen. |
Nach einer neuen Zuweisung an eine Variable mit reactive reagiert die Seite nicht mehr. | Das Objekt behalten und seine Felder ändern, etwa mit Object.assign, und reactive immer mit const deklarieren. |
| Destrukturierte Felder eines reaktiven Objekts folgen ihm nicht. | Vor dem Destrukturieren toRefs aufrufen oder für ein einzelnes Feld toRef verwenden. |
Eine Änderung im Innern eines shallowRef erscheint nicht auf der Seite. | .value ein neues Objekt oder Array geben oder nach der Änderung triggerRef aufrufen. |
| Code direkt nach einer Änderung findet im DOM noch den alten Stand. | Vor dem Lesen des DOM await nextTick() aufrufen. |
onRenderTracked meldet in der gebauten App nichts. | Die Hooks für die Fehlersuche funktionieren nur während der Entwicklung. |
Ein KI-Assistent lässt die Komponente mit $forceUpdate oder einem wechselnden key neu rendern. | Die Ursache suchen, meist ein kopierter Wert oder ein ersetztes Objekt. Ein reaktiver Wert braucht kein erzwungenes Rendern. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Was notiert Vue, während ein Effekt läuft, und was tut es bei einer Änderung?
- Warum folgt
const count = props.favorites.lengthden Favoriten nicht? - Warum löst eine neue Zuweisung an eine Variable mit
reactivenichts aus, eine an.valueeines Refs aber schon? - Wann lohnt sich
shallowRef, und was musst du dann beim Ändern beachten? - Wozu dient
nextTick?
Antworten
- Welche reaktiven Werte der Effekt liest. Ändert sich einer davon, führt Vue die Effekte erneut aus, die ihn gelesen haben.
- Die Zeile läuft einmal beim Erstellen der Komponente und in keinem Effekt.
countist danach eine gewöhnliche Zahl. - Vue verfolgt Zugriffe auf den Proxy eines reaktiven Objekts und auf
.valueeines Refs. Eine Variable ist keins von beidem, ihre neue Zuweisung bemerkt niemand. - Bei grossen Daten, die sich nur als Ganzes ändern. Eine Änderung braucht einen neuen Wert für
.valueoder danachtriggerRef. - Um zu warten, bis Vue die gesammelten Änderungen ins DOM geschrieben hat.
Auftrag
Der Zähler stimmt wieder, und die App hält die Verbindung zu ihrem Zustand. Am Tresen ist der Wunsch aus Teil 4 geblieben. «Nach dem Neuladen fängt die Suche immer von vorne an.» Damit sich die App die Suche merkt, muss sie bei jeder Änderung etwas ausserhalb der Komponente tun und in den Speicher des Browsers schreiben. Dafür gibt es Watcher, das nächste Kapitel.
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.