Zum Inhalt springen

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:

  1. Der Zähler im Kopf folgt jeder Änderung der Favoriten.
  2. «Filter zurücksetzen» leert Suchfeld und Kategorie, und die Suche funktioniert danach weiter.
  3. 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 onRenderTracked und onRenderTriggered sichtbar machen,
  • Fehler finden, bei denen ein Wert kopiert oder ein reaktives Objekt ersetzt wird,
  • mit toRef und toRefs einzelne Felder verbunden halten,
  • shallowRef und triggerRef für grosse Daten einsetzen,
  • mit nextTick auf die aktualisierte Seite warten.

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.

Abhängigkeiten verfolgen und auslösen Drei Kästchen in einer Reihe, das Ref favorites, der berechnete Wert favoriteCount und die Render-Funktion von AppHeader, dazu rechts die Seite mit dem Text 3 Favoriten. Oben zeigen Pfeile, wer wen liest, die Render-Funktion liest favoriteCount, favoriteCount liest favorites. Vue merkt sich dabei jede Abhängigkeit. Unten zeigen Pfeile, was eine Änderung auslöst. push ändert favorites, Vue markiert favoriteCount als veraltet und lässt die Render-Funktion neu laufen, die Seite zeigt den neuen Wert. favorites ref favoriteCount computed render() AppHeader Seite 3 Favoriten liest, Vue merkt es sich Änderung, Vue löst neu aus favorites.value.push(3)

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.

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.

App.vue
<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 again
onRenderTracked((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.

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.length

Diese 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.

App.vue
<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 again
const copiedCount = state.favorites.length
// A computed value reads the array again whenever it changes
const 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.

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.

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.

App.vue
<script setup>
import { reactive, toRefs } from 'vue'
const filters = reactive({ query: '', category: '' })
// Destructuring copies the current value
const { query } = filters
// toRefs turns every field into a ref that stays connected to the object
const { 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.

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.

App.vue
<script setup>
import { shallowRef, triggerRef } from 'vue'
// Only a new value for .value triggers an update, changes inside the array do not
const 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 itself
function 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.

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().

App.vue
<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.

Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.

  1. Ergänze im Beispiel zur Fehlersuche im Template einen Absatz mit {{ query }}. Was meldet die Konsole jetzt beim Klick auf «Suchtext ändern»?
  2. Mach im Beispiel mit der Kopie aus copiedCount eine Funktion, die die Länge zurückgibt, und ruf sie im Template auf. Was zeigt die Zeile nach zwei Klicks, und warum?
  3. Ersetze im Beispiel mit shallowRef den Aufruf durch ref. Was ändert sich beim Knopf «Im Array ergänzen»?
  4. Ruf im Beispiel zu nextTick in add den Zähler dreimal hintereinander mit count.value++ auf. Was steht danach im Protokoll?

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.

Terminal-Fenster
npm install
npm test

Aufgabe 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
AppHeader.vue
<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 number
const 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
App.vue
// Search text and category of the filter. The object stays the same, only its fields change.
const filter = reactive({ query: '', category: '' })
App.vue
// Object.assign changes the fields, so the template and the list keep their connection
function 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
App.vue
// The catalog only changes as a whole, so Vue need not watch every field of every thing
const catalog = shallowRef(items)
App.vue
// shallowRef only notices a new value, so the catalog becomes a new array with a changed copy
function 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.

FehlerWas 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

  1. Was notiert Vue, während ein Effekt läuft, und was tut es bei einer Änderung?
  2. Warum folgt const count = props.favorites.length den Favoriten nicht?
  3. Warum löst eine neue Zuweisung an eine Variable mit reactive nichts aus, eine an .value eines Refs aber schon?
  4. Wann lohnt sich shallowRef, und was musst du dann beim Ändern beachten?
  5. Wozu dient nextTick?
Antworten
  1. Welche reaktiven Werte der Effekt liest. Ändert sich einer davon, führt Vue die Effekte erneut aus, die ihn gelesen haben.
  2. Die Zeile läuft einmal beim Erstellen der Komponente und in keinem Effekt. count ist danach eine gewöhnliche Zahl.
  3. Vue verfolgt Zugriffe auf den Proxy eines reaktiven Objekts und auf .value eines Refs. Eine Variable ist keins von beidem, ihre neue Zuweisung bemerkt niemand.
  4. Bei grossen Daten, die sich nur als Ganzes ändern. Eine Änderung braucht einen neuen Wert für .value oder danach triggerRef.
  5. 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.