Zum Inhalt springen

Lebenszyklus und Template-Refs

Auftrag

Am Tresen arbeiten die Freiwilligen meistens mit der Tastatur, und Martina Hostettler hat ihren Wunsch aufgeschrieben.

Als Freiwillige oder Freiwilliger am Tresen möchte ich den Katalog und den Dialog nur mit der Tastatur bedienen, damit ich beim Ausleihen nicht zur Maus greifen muss.

Die Akzeptanzkriterien lauten:

  1. Öffnet sich der Katalog, steht der Fokus im Suchfeld.
  2. Escape schliesst einen offenen Dialog.
  3. Öffnet sich ein Dialog, steht der Fokus auf seinem ersten Knopf. Schliesst er sich, kehrt der Fokus dorthin zurück, wo er vorher war.

Lernziele

Nach diesem Kapitel kannst du

  • die Hooks im Leben einer Komponente in ihrer Reihenfolge nennen,
  • mit useTemplateRef auf ein Element im DOM zugreifen,
  • den Fokus setzen, sobald eine Komponente erscheint,
  • Listener und Timer in onMounted anlegen und in onUnmounted aufräumen,
  • den Fokus in einem Dialog nach den Regeln für zugängliche Dialoge führen.

Eine Komponente entsteht, erscheint im DOM, ändert sich und verschwindet wieder. An jedem dieser Punkte ruft Vue Funktionen auf, die du mit den Hooks des Lebenszyklus registrierst. Lies das Beispiel und überleg dir, in welcher Reihenfolge die Meldungen in der Konsole erscheinen.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import CounterCard from './CounterCard.vue'
const shown = ref(true)
</script>
<template>
<button type="button" @click="shown = !shown">{{ shown ? 'Karte entfernen' : 'Karte zeigen' }}</button>
<CounterCard v-if="shown" />
</template>

CounterCard.vue

CounterCard.vue
<script setup>
import { onBeforeMount, onBeforeUnmount, onBeforeUpdate, onMounted, onUnmounted, onUpdated, ref } from 'vue'
console.log('setup')
const clicks = ref(0)
onBeforeMount(() => console.log('onBeforeMount'))
onMounted(() => console.log('onMounted'))
onBeforeUpdate(() => console.log('onBeforeUpdate'))
onUpdated(() => console.log('onUpdated'))
onBeforeUnmount(() => console.log('onBeforeUnmount'))
onUnmounted(() => console.log('onUnmounted'))
</script>
<template>
<p>
<button type="button" @click="clicks++">Klicks: {{ clicks }}</button>
</p>
</template>

Beim Laden erscheinen setup, onBeforeMount und onMounted. Ein Klick auf den Zähler ändert den Zustand, und Vue meldet onBeforeUpdate und onUpdated. «Karte entfernen» lässt die Komponente mit v-if verschwinden, und es folgen onBeforeUnmount und onUnmounted.

Der Lebenszyklus einer Komponente Eine Zeitachse von oben nach unten. Zuerst läuft setup, dann onBeforeMount, dann onMounted, ab hier steht die Komponente im DOM. Bei jeder Änderung des Zustands folgen onBeforeUpdate und onUpdated, ein Pfeil führt von onUpdated zurück zu onBeforeUpdate. Wenn die Komponente verschwindet, folgen onBeforeUnmount und onUnmounted. Rechts steht neben setup, dass dort der Zustand entsteht und es noch kein DOM gibt, neben onMounted, dass dort Fokus, Messen und Listener hingehören, neben den beiden Hooks für Updates, dass sie bei jeder Änderung laufen, und neben onUnmounted, dass dort Listener und Timer aufgeräumt werden. setup onBeforeMount onMounted onBeforeUpdate onUpdated onBeforeUnmount onUnmounted Zustand anlegen, noch kein DOM DOM ist da, Fokus, Messen, Listener bei jeder Änderung des Zustands aufräumen, Listener und Timer entfernen

Der Code direkt in <script setup> läuft, bevor es das DOM der Komponente gibt. Zustand, berechnete Werte und Watcher gehören dorthin. Alles, was das DOM braucht, etwa der Fokus oder die Grösse eines Elements, gehört in onMounted. Laut Dokumentation gilt eine Komponente als eingehängt, wenn ihr DOM erzeugt und eingefügt ist und ihre Kindkomponenten eingehängt sind. onUnmounted ist der Ort, um Timer, Listener im DOM und Verbindungen zu Servern aufzuräumen. In onUpdated solltest du den Zustand nicht ändern, denn das führt laut Dokumentation leicht zu einer endlosen Folge von Updates.

Manchmal braucht der Code ein bestimmtes Element, etwa um ihm den Fokus zu geben. Ein Template-Ref verbindet ein Element mit einer Variablen im Script. Seit Vue 3.5 gibt es dafür useTemplateRef. Das Element bekommt das Attribut ref mit einem Namen, und useTemplateRef mit demselben Namen liefert ein Ref, das nach dem Einhängen auf das Element zeigt.

App.vue
<script setup>
import { onMounted, useTemplateRef } from 'vue'
// The element with ref="search" in the template, null until the component is mounted
const search = useTemplateRef('search')
onMounted(() => {
search.value.focus()
})
</script>
<template>
<label for="search">Suchen</label>
<input id="search" ref="search" type="search">
</template>

Nach dem Laden der Vorschau steht der Fokus im Suchfeld, und du kannst sofort tippen. Vor onMounted ist search.value noch null, denn das Element gibt es noch nicht. Verschwindet das Element später mit v-if, wird der Wert wieder null. Code, der zu einem anderen Zeitpunkt läuft, prüft darum zuerst, ob das Element da ist. Steht ref an einer Komponente statt an einem Element, zeigt der Template-Ref auf die Komponente. Die Elternkomponente sieht davon nur, was die Kindkomponente mit defineExpose freigibt.

In älteren Beispielen findest du const search = ref(null) mit demselben Namen wie im Attribut. Das funktioniert weiterhin, useTemplateRef macht die Verbindung aber ausdrücklich.

Ein Listener auf window oder ein Timer mit setInterval gehört nicht zur Komponente. Er läuft weiter, wenn die Komponente verschwunden ist, und hält ihren Zustand im Speicher fest. Was du in onMounted anlegst, baust du darum in onUnmounted wieder ab.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import ShopClock from './ShopClock.vue'
const shown = ref(true)
</script>
<template>
<button type="button" @click="shown = !shown">{{ shown ? 'Uhr entfernen' : 'Uhr zeigen' }}</button>
<ShopClock v-if="shown" />
</template>

ShopClock.vue

ShopClock.vue
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const time = ref(new Date().toLocaleTimeString('de-CH'))
let timer
onMounted(() => {
timer = setInterval(() => {
time.value = new Date().toLocaleTimeString('de-CH')
console.log('tick', time.value)
}, 1000)
})
// Without this line the timer keeps running after the clock is gone
onUnmounted(() => clearInterval(timer))
</script>
<template>
<p>Es ist {{ time }}</p>
</template>

Die Uhr meldet jede Sekunde ein tick in der Konsole. Nach «Uhr entfernen» hören die Meldungen auf, weil onUnmounted den Timer mit clearInterval stoppt. Ohne diese Zeile laufen die Meldungen weiter, obwohl keine Uhr mehr zu sehen ist, und jedes neue Zeigen startet einen weiteren Timer.

Für Tasten wie Escape hört eine Komponente auf keydown von window. So wirkt die Taste, wo auch immer der Fokus gerade steht. Klick im Beispiel zuerst in die Vorschau, damit die Tasten dort ankommen.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import NoticeBox from './NoticeBox.vue'
const open = ref(false)
</script>
<template>
<button type="button" @click="open = true">Hinweis öffnen</button>
<NoticeBox v-if="open" @close="open = false" />
</template>

NoticeBox.vue

NoticeBox.vue
<script setup>
import { onMounted, onUnmounted } from 'vue'
const emit = defineEmits(['close'])
function onKeydown(event) {
if (event.key === 'Escape') emit('close')
}
// The listener lives as long as the component, not longer
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="notice" role="dialog" aria-modal="true" aria-label="Hinweis">
<p>Eine Ausleihe dauert höchstens sieben Tage. Escape schliesst den Hinweis.</p>
<button type="button" @click="emit('close')">Schliessen</button>
</div>
</template>
<style scoped>
.notice {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #1f6b4f;
border-radius: 8px;
}
</style>

removeEventListener braucht dieselbe Funktion wie addEventListener. Darum ist onKeydown eine benannte Funktion und keine Pfeilfunktion, die erst im Aufruf entsteht. Eine zweite Pfeilfunktion mit demselben Code wäre für JavaScript eine andere Funktion, und der Listener bliebe bestehen.

Für Dialoge gibt es Regeln im Dialog-Muster der WAI-ARIA Authoring Practices. Öffnet sich ein Dialog, wandert der Fokus in ihn hinein, meistens auf das erste Element, das den Fokus annehmen kann. Escape schliesst den Dialog. Schliesst er sich, kehrt der Fokus zum Element zurück, das ihn geöffnet hat. Gibt es dieses Element nicht mehr, wählt die App ein anderes, das zum Ablauf der Arbeit passt.

Für den Rückweg muss sich der Dialog merken, wo der Fokus vorher war. document.activeElement nennt das Element, das gerade den Fokus hat. In onMounted ist das noch das Element vor dem Dialog, denn der Fokus wandert erst durch den Aufruf von focus hinein. Ein Template-Ref funktioniert auch innerhalb von Teleport, obwohl das Element im DOM am Ende von body steht.

Im Fallbeispiel gibt es den Fall, dass das öffnende Element verschwindet. Nach dem Bestätigen einer Anfrage entsteht das Formular mit einem neuen key neu, und der Knopf «Anfrage senden», der den Dialog geöffnet hat, ist weg. ItemView setzt den Fokus darum auf die Überschrift des Formulars. Sie bekommt tabindex="-1", damit sie den Fokus annehmen kann, ohne in der Reihenfolge der Tabulatortaste aufzutauchen. Erst nach dem nächsten Rendern sind Dialog und altes Formular weg, deshalb wartet ItemView vorher mit await nextTick().

ItemView.vue
async function confirm() {
const { member, from, to, note } = pending.value
const name = props.item.name
summary.value =
`Reservationsanfrage von ${member} für ${name} vom ${formatDate(from)} bis ${formatDate(to)} erfasst.` +
(note === '' ? '' : ` Notiz: ${note}`)
pending.value = null
formKey.value++
// The form starts over and the button that opened the dialog is gone,
// so the focus moves to the heading of the form once Vue has updated the page
await nextTick()
formHeading.value.focus()
}

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

  1. Verschiebe im Beispiel zum Fokus search.value.focus() aus onMounted direkt in <script setup>. Was meldet die Konsole?
  2. Klick im Beispiel zum Lebenszyklus einige Male auf den Zähler, entferne die Karte und zeig sie wieder. Welchen Stand hat der Zähler, und welche Hooks laufen?
  3. Nimm im Beispiel mit der Uhr die Zeile mit clearInterval weg, entferne die Uhr und zeig sie wieder. Was erscheint jetzt in der Konsole?
  4. Ergänze im Beispiel mit Escape in onKeydown ein console.log(event.key) und nimm die Zeile mit onUnmounted weg. Was erscheint, wenn du nach dem Schliessen des Hinweises Tasten drückst?

Die Aufgaben setzen den Auftrag in der Übungsversion des Katalogs um. Du findest das Projekt im Kursrepo im Ordner exercises/05-reactivity/lifecycle/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.

Terminal-Fenster
npm install
npm test

Aufgabe Einstieg

Setze den Fokus ins Suchfeld, sobald der Katalog erscheint. Hol das Feld in src/components/SearchBar.vue mit useTemplateRef und ruf in onMounted seine Methode focus auf.

Lösung
SearchBar.vue
<script setup>
import { onMounted, useTemplateRef } from 'vue'
// App binds the search text with v-model
const query = defineModel({ type: String, default: '' })
// The input field, null until the component is mounted
const input = useTemplateRef('input')
// Whoever opens the catalog can type right away
onMounted(() => {
input.value.focus()
})
</script>
<template>
<div class="search">
<label for="search">Suchen</label>
<input id="search" ref="input" v-model="query" type="search">
</div>
</template>

input.value zeigt erst in onMounted auf das Feld. Weil SearchBar den Fokus selbst setzt, bekommt ihn jede Seite, die das Suchfeld zeigt, ohne eigenen Code.

Aufgabe Vertiefung

Der Dialog mit den Details eines Dings soll sich mit Escape schliessen. Ergänze in src/components/BaseDialog.vue einen Listener für keydown auf window, der bei Escape das Event close auslöst. Melde ihn wieder ab, wenn der Dialog verschwindet.

Lösung
BaseDialog.vue
function onKeydown(event) {
if (event.key === 'Escape') emit('close')
}

In onMounted meldet window.addEventListener('keydown', onKeydown) die Funktion an, in onUnmounted meldet window.removeEventListener('keydown', onKeydown) sie wieder ab. Für den Aufruf von emit im Script braucht es const emit = defineEmits(['close']). Die ganze Datei steht in der Lösung der Knobelei.

Aufgabe Knobelei

Öffnet sich der Dialog, soll der Fokus auf seinem ersten Knopf stehen. Schliesst er sich, kehrt der Fokus zum Element zurück, das ihn vorher hatte, meistens zum Knopf «Details». Ergänze das in BaseDialog.

Lösung
BaseDialog.vue
<script setup>
import { onMounted, onUnmounted, useId, useTemplateRef } from 'vue'
const emit = defineEmits(['close'])
// A unique id connects the dialog with its title
const titleId = useId()
const dialog = useTemplateRef('dialog')
// The element that had the focus before the dialog opened
let opener = null
function onKeydown(event) {
if (event.key === 'Escape') emit('close')
}
onMounted(() => {
// The listener lives as long as the dialog, not longer
window.addEventListener('keydown', onKeydown)
opener = document.activeElement
// The keyboard goes on inside the dialog, at its first button
dialog.value.querySelector('button').focus()
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
// The focus returns to where it was, usually the button that opened the dialog
opener?.focus()
})
</script>

Der Template-Ref dialog zeigt auf das Element mit role="dialog", und querySelector('button') findet darin den ersten Knopf. Ohne eigene Aktionen ist das «Schliessen». opener merkt sich das Element, das vorher den Fokus hatte, und onUnmounted gibt ihm den Fokus zurück. Hätte ein Dialog Felder vor seinen Knöpfen, käme der Fokus auf das erste Feld, dann sucht querySelector besser nach Feldern und Knöpfen zusammen.

FehlerWas hilft
Die Konsole meldet Cannot read properties of null (reading 'focus').Das Element erst in onMounted oder später ansprechen. Vorher ist der Template-Ref null.
Der Template-Ref bleibt auch nach dem Einhängen null.Den Namen in useTemplateRef und im Attribut ref gleich schreiben.
Nach dem Verlassen einer Seite laufen Timer oder Listener weiter.Was onMounted anlegt, in onUnmounted mit clearInterval oder removeEventListener abbauen.
removeEventListener entfernt den Listener nicht.Dieselbe Funktion übergeben wie bei addEventListener, keine neue Pfeilfunktion.
Nach dem Schliessen eines Dialogs landet der Fokus am Anfang der Seite.Das Element mit dem Fokus beim Öffnen merken und ihm den Fokus beim Schliessen zurückgeben.
Ein KI-Assistent holt ein Element einer Komponente mit document.querySelector('#search').useTemplateRef verwenden. Es findet das Element der eigenen Komponente, auch wenn die Komponente mehrmals auf der Seite steht.
Ein KI-Assistent schreibt this.$refs.search.Das ist die Options API. In <script setup> gibt es dafür useTemplateRef.

Selbstcheck

  1. In welcher Reihenfolge laufen die Hooks, wenn eine Komponente erscheint, sich ändert und verschwindet?
  2. Warum ist ein Template-Ref vor onMounted leer?
  3. Warum braucht ein Listener auf window ein Gegenstück in onUnmounted?
  4. Wohin gehört der Fokus, wenn ein Dialog erscheint, und wohin, wenn er sich schliesst?
Antworten
  1. setup, onBeforeMount, onMounted, bei jeder Änderung onBeforeUpdate und onUpdated, am Ende onBeforeUnmount und onUnmounted.
  2. Das DOM der Komponente entsteht erst beim Einhängen. Vorher gibt es kein Element, auf das der Ref zeigen kann.
  3. window lebt länger als die Komponente. Ohne removeEventListener reagiert der Listener weiter und hält den Zustand der Komponente im Speicher.
  4. Beim Öffnen auf ein Element im Dialog, meistens das erste, das den Fokus annehmen kann. Beim Schliessen zurück zum Element, das den Dialog geöffnet hat, oder auf ein passendes anderes, wenn es dieses nicht mehr gibt.

Auftrag

Mit der Tastatur geht jetzt alles. Offen ist noch der zweite Teil des Wunsches aus Teil 4, die Favoriten nach dem Neuladen. Der Freiwillige möchte den Code aus dem Kapitel «Watcher» dafür nicht kopieren. «Speichern und Lesen sollte eine Funktion übernehmen, die jede Komponente verwenden kann.» Solche Funktionen heissen in Vue Composables, 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.