Lazy Loading und 404
Auftrag
Der Freiwillige hat zwei Punkte gefunden, und Martina Hostettler macht einen Auftrag daraus.
Als Mitglied möchte ich bei einer falschen Adresse einen Hinweis und einen Weg zurück finden, damit ich nicht vor einer leeren Seite stehe.
Die Akzeptanzkriterien lauten:
- Eine Adresse ohne Seite zeigt «Seite nicht gefunden» mit einem Link zum Katalog.
- Der Code für den Bereich des Teams lädt erst, wenn jemand den Bereich öffnet.
- Nach jedem Wechsel der Seite stimmt der Titel des Tabs, und der Fokus steht auf der Überschrift der neuen Seite.
Lernziele
Nach diesem Kapitel kannst du
- eine Route für alle unbekannten Adressen festlegen,
- einen Parameter mit einem regulären Ausdruck einschränken,
- Routen bei Bedarf laden und das Ergebnis im Build erkennen,
- die Position beim Scrollen mit
scrollBehaviorsteuern, - nach einer Navigation Titel und Fokus setzen.
Eine Seite für unbekannte Adressen
Abschnitt betitelt „Eine Seite für unbekannte Adressen“Passt keine Route auf eine Adresse, zeigt RouterView nichts, und Vue Router meldet in der Konsole No match found for location with path. Die Dokumentation fängt solche Adressen mit einer Route, deren Parameter einen eigenen regulären Ausdruck hat.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/association">Verein</RouterLink> <RouterLink to="/vereinsabend/2026">Ein alter Link</RouterLink> </nav> <p>Adresse: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>AssociationView.vue
<template> <h1>Verein</h1> <p>Lorraine Commons leiht Dinge aus, die man selten braucht.</p></template>CatalogView.vue
<template> <h1>Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')NotFoundView.vue
<script setup>import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <h1>Seite nicht gefunden</h1> <p>Unter dieser Adresse gibt es keine Seite.</p> <p>pathMatch: {{ JSON.stringify(route.params.pathMatch) }}</p> <RouterLink to="/">Zum Katalog</RouterLink></template>router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import AssociationView from './AssociationView.vue'import CatalogView from './CatalogView.vue'import NotFoundView from './NotFoundView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/association', component: AssociationView }, // (.*)* matches every path that no other route matches, also over several segments { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView }, ],})(.*) passt auf jeden Text, auch über mehrere Abschnitte der Adresse. Das Sternchen danach macht den Parameter optional und wiederholbar, und route.params.pathMatch enthält die Abschnitte als Array, für /vereinsabend/2026 also ["vereinsabend", "2026"]. Die Route darf irgendwo in der Liste stehen, denn laut Dokumentation spielt die Reihenfolge der Routen keine Rolle.
Derselbe Mechanismus schränkt einen Parameter ein. /items/:id(\\d+) passt nur auf Ziffern, /items/abc landet auf der Seite für unbekannte Adressen. Die doppelten Backslashes braucht es, weil der Pfad in einem String von JavaScript steht. Eine Nummer, zu der es kein Ding gibt, erkennt dagegen erst die Seite selbst, denn die Liste der Dinge kennt der Router nicht.
Routen bei Bedarf laden
Abschnitt betitelt „Routen bei Bedarf laden“Ein import am Anfang einer Datei lädt eine Komponente beim Start der App. Schreibt eine Route stattdessen eine Funktion mit import(), lädt der Router die Komponente erst beim ersten Besuch und behält sie danach. Die Dokumentation empfiehlt das für alle Routen.
{ path: '/team', // The code of the team area loads only when someone opens it component: () => import('@/views/TeamView.vue'),},Vite legt jede so geladene Komponente beim Build in eine eigene Datei. Im Übungsprojekt zeigt npm run build danach eine zusätzliche Zeile für den Bereich des Teams, gekürzt etwa so.
dist/index.html 0.40 kBdist/assets/index-tJDboEPA.css 1.02 kBdist/assets/TeamView-toIM6wF9.js 0.27 kBdist/assets/index-Di6fJri9.js 95.09 kBDie Zeichen nach dem Bindestrich und die Grössen sehen bei dir anders aus. Im Tab «Network» der Entwicklertools erscheint die Datei erst, wenn du den Bereich öffnest. Der Playground lädt dagegen alle Dateien eines Beispiels auf einmal, dort ist der Unterschied nicht zu sehen. Die Fallbeispiel-App lädt den Bereich des Teams bei Bedarf. Die übrigen Seiten sind klein und gehören fast alle zum ersten Besuch.
Die Position beim Scrollen
Abschnitt betitelt „Die Position beim Scrollen“Beim Wechsel der Seite lässt der Browser die Position beim Scrollen, wo sie war, denn er lädt keine neue Seite. Die Option scrollBehavior des Routers legt fest, wohin es nach einer Navigation geht.
App.vue
<script setup>import { RouterView } from 'vue-router'</script>
<template> <RouterView /></template>CatalogView.vue
<script setup>import { RouterLink } from 'vue-router'
import { things } from './things.js'</script>
<template> <h1>Katalog</h1> <ul> <li v-for="thing in things" :key="thing.id"> <RouterLink :to="{ name: 'item', params: { id: thing.id } }">{{ thing.name }}</RouterLink> </li> </ul></template>ItemView.vue
<script setup>import { computed } from 'vue'import { RouterLink, useRoute } from 'vue-router'
import { things } from './things.js'
const route = useRoute()
const thing = computed(() => things.find((entry) => entry.id === Number(route.params.id)))const others = computed(() => things.filter((entry) => entry !== thing.value))</script>
<template> <h1>{{ thing?.name }}</h1> <RouterLink :to="{ name: 'catalog' }">Zurück zur Liste</RouterLink> <h2>Weitere Dinge</h2> <ul> <li v-for="entry in others" :key="entry.id"> <RouterLink :to="{ name: 'item', params: { id: entry.id } }">{{ entry.name }}</RouterLink> </li> </ul></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', name: 'catalog', component: CatalogView }, { path: '/items/:id', name: 'item', component: ItemView }, ], // Back and forward return to the old position, every other page starts at the top scrollBehavior(to, from, savedPosition) { return savedPosition ?? { top: 0 } },})things.js
// Every thing of the catalog, long enough to scrollexport const things = [ { id: 1, name: 'Bohrmaschine' }, { id: 2, name: 'Zelt für vier Personen' }, { id: 3, name: 'Raclette-Ofen' }, { id: 4, name: 'Festbankgarnitur' }, { id: 5, name: 'Stichsäge' }, { id: 6, name: 'Beamer' }, { id: 7, name: 'Leiter' }, { id: 8, name: 'Schlafsack' }, { id: 9, name: 'Velo-Anhänger' }, { id: 10, name: 'Rasenmäher' }, { id: 11, name: 'Akkuschrauber' }, { id: 12, name: 'Schleifmaschine' }, { id: 13, name: 'Wasserwaage' }, { id: 14, name: 'Werkzeugkoffer' }, { id: 15, name: 'Kreissäge' }, { id: 16, name: 'Fliesenschneider' }, { id: 17, name: 'Tacker' }, { id: 18, name: 'Heckenschere' }, { id: 19, name: 'Laubbläser' }, { id: 20, name: 'Vertikutierer' }, { id: 21, name: 'Schubkarre' }, { id: 22, name: 'Astschere' }, { id: 23, name: 'Hochdruckreiniger' }, { id: 24, name: 'Häcksler' }, { id: 25, name: 'Spaten' }, { id: 26, name: 'Kompostsieb' }, { id: 27, name: 'Fondue-Set' }, { id: 28, name: 'Einkochtopf' }, { id: 29, name: 'Dörrapparat' }, { id: 30, name: 'Pastamaschine' }, { id: 31, name: 'Waffeleisen' }, { id: 32, name: 'Küchenmaschine' }, { id: 33, name: 'Glacemaschine' }, { id: 34, name: 'Entsafter' }, { id: 35, name: 'Suppentopf für 50 Personen' }, { id: 36, name: 'Zelt für zwei Personen' }, { id: 37, name: 'Campingkocher' }, { id: 38, name: 'Isomatte' }, { id: 39, name: 'Rucksack 65 Liter' }, { id: 40, name: 'Kühlbox' }, { id: 41, name: 'Campingtisch' }, { id: 42, name: 'Stirnlampe' }, { id: 43, name: 'Wanderstöcke' }, { id: 44, name: 'Kinderwagen' }, { id: 45, name: 'Reisebett' }, { id: 46, name: 'Kindersitz fürs Velo' }, { id: 47, name: 'Laufrad' }, { id: 48, name: 'Babytrage' }, { id: 49, name: 'Hochstuhl' }, { id: 50, name: 'Schlitten' }, { id: 51, name: 'Rutschauto' }, { id: 52, name: 'Spielekiste' }, { id: 53, name: 'Leinwand' }, { id: 54, name: 'Lautsprecher' }, { id: 55, name: 'Partyzelt' }, { id: 56, name: 'Lichterkette' }, { id: 57, name: 'Getränkespender' }, { id: 58, name: 'Popcornmaschine' }, { id: 59, name: 'Mikrofon' }, { id: 60, name: 'Kaffeemaschine für 40 Tassen' },]Im Beispiel beginnt die Seite eines Dings oben, auch wenn du weit unten in der Liste geklickt hast. savedPosition gibt es laut Dokumentation nur bei Zurück und Vorwärts in der History des Browsers. Dann kehrt eine Seite an die alte Stelle zurück, jede andere beginnt oben. Die History im Speicher des Playgrounds merkt sich keine Positionen, den Zurück-Knopf mit der gespeicherten Position probierst du darum in der Fallbeispiel-App aus.
Titel und Fokus nach der Navigation
Abschnitt betitelt „Titel und Fokus nach der Navigation“router.afterEach läuft nach jeder Navigation und kann sie nicht mehr aufhalten. Damit setzt die App den Titel des Tabs aus einem Feld title in meta. Ein drittes Argument meldet eine Navigation, die abgebrochen wurde oder zur aktuellen Seite führte, dann ändert die App nichts.
App.vue
<script setup>import { RouterLink, RouterView } from 'vue-router'</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/association">Verein</RouterLink> </nav> <main> <RouterView /> </main></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>AssociationView.vue
<template> <h1 tabindex="-1">Verein</h1> <p>Lorraine Commons leiht Dinge aus, die man selten braucht.</p></template>CatalogView.vue
<template> <!-- tabindex="-1" lets the heading take the focus without a stop for the Tab key --> <h1 tabindex="-1">Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { nextTick } from 'vue'import { createMemoryHistory, createRouter, START_LOCATION } from 'vue-router'
import AssociationView from './AssociationView.vue'import CatalogView from './CatalogView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView, meta: { title: 'Katalog' } }, { path: '/association', component: AssociationView, meta: { title: 'Verein' } }, ],})
// Runs after every navigation, it can no longer stop itrouter.afterEach(async (to, from) => { document.title = `${to.meta.title} · Lorraine Commons` console.log('Titel', document.title) // On the first page the browser starts at the top anyway if (from === START_LOCATION) return // The new page is only in the DOM after Vue has rendered it await nextTick() document.querySelector('main h1')?.focus()})
export default routerBei einer SPA lädt der Browser keine neue Seite, und ein Screenreader bemerkt den Wechsel nicht von selbst. In einer Nutzerstudie von Gatsby mit Menschen mit Behinderungen schnitt der Fokus auf der Überschrift der neuen Seite am besten ab. Die Überschrift bekommt dafür tabindex="-1", damit sie den Fokus annehmen kann, ohne in der Reihenfolge der Tabulatortaste zu erscheinen. Gesetzt wird der Fokus nach await nextTick(), denn afterEach läuft, bevor Vue die neue Seite rendert. Beim ersten Laden steht from auf START_LOCATION, dann bleibt der Fokus, wo der Browser ihn hat.
Die Fallbeispiel-App fragt vorher, ob die neue Seite den Fokus schon selbst in main gesetzt hat. Der Katalog tut das mit seinem Suchfeld aus Kapitel 5.3 und behält es. Die Seite eines Dings setzt ihren Titel selbst, denn den Namen des Dings kennt erst sie. Kapitel 8.5 kommt auf weitere Techniken zurück, etwa eine Live-Region, die den Titel der neuen Seite ansagt.
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.
- Nimm im ersten Beispiel die Route für unbekannte Adressen weg und klick auf «Ein alter Link». Was meldet die Konsole, und was zeigt die Seite?
- Ändere im selben Beispiel den Link auf
/vereinsabend. Was enthältpathMatchjetzt? - Scroll im Beispiel mit der langen Liste nach unten und öffne ein Ding. Nimm danach
scrollBehaviorweg und wiederhole es. Wo beginnt die Seite des Dings? - Nimm im Beispiel zu Titel und Fokus die Zeile mit
await nextTick()weg. Wo steht der Fokus nach einem Wechsel der Seite?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Punkte aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/06-routing/lazy-404/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Ergänze eine Route für alle unbekannten Adressen mit dem Namen not-found. Sie zeigt NotFoundView und hat im Feld meta den Titel «Seite nicht gefunden». Auch /items/abc landet danach dort.
Lösung
// Every path that no other route matches, also over several segments { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Seite nicht gefunden' } },Die Route für ein Ding hat schon (\\d+) und passt nur auf Ziffern. Für /items/abc bleibt darum nur die Route für unbekannte Adressen.
Aufgabe Vertiefung
Lade TeamView erst, wenn jemand den Bereich öffnet. Prüf danach mit npm run build, ob eine eigene Datei für den Bereich entsteht.
Lösung
{ path: '/team', name: 'team', // The code of the team area loads only when someone opens it component: () => import('@/views/TeamView.vue'), meta: { title: 'Team am Tresen' }, },Der import von TeamView am Anfang der Datei fällt weg, sonst landete die Komponente trotz der Funktion im ersten Paket. Nach dem ersten Besuch ersetzt der Router die Funktion durch die geladene Komponente.
Aufgabe Knobelei
Setz nach jeder Navigation den Titel des Tabs auf den Titel aus meta mit « · Lorraine Commons» dahinter. Setz ausserdem den Fokus auf die Überschrift h1 der neuen Seite, nicht aber beim ersten Laden. Die Überschriften brauchen dafür tabindex="-1".
Lösung
// Runs after every navigation and can no longer stop itrouter.afterEach(async (to, from, failure) => { // A cancelled navigation, or one to the current page, changes nothing if (failure) return if (to.meta.title) document.title = `${to.meta.title} · Lorraine Commons` // On the first load the browser starts at the top of the page anyway if (from === START_LOCATION) return // The new page is only in the DOM after Vue has rendered it await nextTick() document.querySelector('main h1')?.focus()})START_LOCATION kommt aus vue-router, nextTick aus vue. In allen Seiten steht die Überschrift jetzt als <h1 tabindex="-1">.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
Vue Router meldet Catch all routes ("*") must now be defined using a param with a custom regexp. | path: '*' stammt aus Vue Router 3. Die Route heisst jetzt /:pathMatch(.*)*. |
Eine Route lädt ihre Komponente mit defineAsyncComponent. | Laut Dokumentation gehört in eine Route eine einfache Funktion mit import(), keine asynchrone Komponente. |
| Der Fokus landet nach einem Wechsel nirgends. | Die Überschrift braucht tabindex="-1", und der Fokus wartet mit nextTick auf die neue Seite. |
| Nach dem Zurück-Knopf steht die Liste oben statt an der alten Stelle. | scrollBehavior gibt savedPosition zurück, wenn es sie gibt. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Was enthält
route.params.pathMatchfür die Adresse/vereinsabend/2026? - Was ändert sich im Build, wenn eine Route ihre Komponente mit
import()lädt? - Wann bekommt
scrollBehavioreine gespeicherte Position? - Warum setzt die App den Fokus nach einer Navigation, aber nicht beim ersten Laden?
Antworten
- Das Array
["vereinsabend", "2026"], ein Eintrag für jeden Abschnitt der Adresse. - Vite legt die Komponente in eine eigene Datei, die der Browser erst beim ersten Besuch der Route lädt.
- Nur bei Zurück und Vorwärts im Browser.
- Beim ersten Laden beginnt der Browser oben auf der neuen Seite, wie bei jeder Website. Bei einem Wechsel in der SPA lädt keine Seite neu, und ohne Fokus bemerkt ein Screenreader den Wechsel nicht.
Auftrag
Die App hat Seiten, Adressen zum Teilen und einen Bereich für das Team. Ihre Daten leben aber noch im Browser. Eine Rückgabe am Tresen sieht nur, wer im selben Browser arbeitet, und nach dem Neuladen ist sie weg. Teil 7 holt die Daten vom Server des Vereins und hält gemeinsamen Zustand mit Pinia. Vorher prüfst du mit dem Kompetenzraster, ob du für Teil 7 bereit bist.
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.