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:
- Öffnet sich der Katalog, steht der Fokus im Suchfeld.
- Escape schliesst einen offenen Dialog.
- Ö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
useTemplateRefauf ein Element im DOM zugreifen, - den Fokus setzen, sobald eine Komponente erscheint,
- Listener und Timer in
onMountedanlegen und inonUnmountedaufräumen, - den Fokus in einem Dialog nach den Regeln für zugängliche Dialoge führen.
Der Lebenszyklus
Abschnitt betitelt „Der Lebenszyklus“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
<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
<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 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.
Template-Refs mit useTemplateRef
Abschnitt betitelt „Template-Refs mit useTemplateRef“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.
<script setup>import { onMounted, useTemplateRef } from 'vue'
// The element with ref="search" in the template, null until the component is mountedconst 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.
Listener und Timer
Abschnitt betitelt „Listener und Timer“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
<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
<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 goneonUnmounted(() => 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
<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
<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 longeronMounted(() => 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.
Fokus in einem Dialog
Abschnitt betitelt „Fokus in einem Dialog“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().
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()}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.
- Verschiebe im Beispiel zum Fokus
search.value.focus()ausonMounteddirekt in<script setup>. Was meldet die Konsole? - 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?
- Nimm im Beispiel mit der Uhr die Zeile mit
clearIntervalweg, entferne die Uhr und zeig sie wieder. Was erscheint jetzt in der Konsole? - Ergänze im Beispiel mit Escape in
onKeydowneinconsole.log(event.key)und nimm die Zeile mitonUnmountedweg. Was erscheint, wenn du nach dem Schliessen des Hinweises Tasten drückst?
Aufgaben
Abschnitt betitelt „Aufgaben“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.
npm installnpm testAufgabe 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
<script setup>import { onMounted, useTemplateRef } from 'vue'
// App binds the search text with v-modelconst query = defineModel({ type: String, default: '' })
// The input field, null until the component is mountedconst input = useTemplateRef('input')
// Whoever opens the catalog can type right awayonMounted(() => { 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
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
<script setup>import { onMounted, onUnmounted, useId, useTemplateRef } from 'vue'
const emit = defineEmits(['close'])
// A unique id connects the dialog with its titleconst titleId = useId()const dialog = useTemplateRef('dialog')
// The element that had the focus before the dialog openedlet 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.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was 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
Abschnitt betitelt „Selbstcheck“Selbstcheck
- In welcher Reihenfolge laufen die Hooks, wenn eine Komponente erscheint, sich ändert und verschwindet?
- Warum ist ein Template-Ref vor
onMountedleer? - Warum braucht ein Listener auf
windowein Gegenstück inonUnmounted? - Wohin gehört der Fokus, wenn ein Dialog erscheint, und wohin, wenn er sich schliesst?
Antworten
setup,onBeforeMount,onMounted, bei jeder ÄnderungonBeforeUpdateundonUpdated, am EndeonBeforeUnmountundonUnmounted.- Das DOM der Komponente entsteht erst beim Einhängen. Vorher gibt es kein Element, auf das der Ref zeigen kann.
windowlebt länger als die Komponente. OhneremoveEventListenerreagiert der Listener weiter und hält den Zustand der Komponente im Speicher.- 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.