Persistenz
Auftrag
Drei Rückmeldungen erreichen Martina Hostettler.
Als Mitglied möchte ich meine Favoriten in jedem Tab sehen, damit ich nicht nachdenken muss, wo ich sie markiert habe.
Als Freiwillige oder Freiwilliger am Tresen möchte ich nach dem Neuladen angemeldet bleiben, aber nicht über Nacht, damit am nächsten Tag niemand mit meiner Anmeldung arbeitet.
Als Mitglied möchte ich eine angefangene Anfrage wiederfinden, wenn ich kurz im Katalog nachschaue, damit ich nicht alles noch einmal eingeben muss.
Die Akzeptanzkriterien lauten:
- Favoriten überstehen ein Neuladen und sind in allen Tabs dieselben.
- Die Anmeldung übersteht ein Neuladen und endet, wenn der Browser schliesst.
- Eine angefangene Anfrage bleibt in ihrem Tab erhalten, auch nach dem Neuladen.
Lernziele
Nach diesem Kapitel kannst du
localStorageundsessionStorageunterscheiden und Werte als JSON speichern,- einen Store mit dem Speicher des Browsers verbinden,
- mehrere Tabs mit dem Event
storageabgleichen, - begründen, was nie in den Speicher des Browsers gehört,
- eine Anmeldung nach dem Neuladen beim Server erfragen.
Speicher im Browser
Abschnitt betitelt „Speicher im Browser“Ein Store lebt im Speicher der Seite und ist nach dem Neuladen leer. Für Werte, die länger halten sollen, hat der Browser die Web Storage API mit zwei Speichern. Laut MDN teilen sich alle Tabs einer Origin denselben localStorage, und er bleibt auch, wenn der Browser schliesst. sessionStorage gehört dagegen zu einem Tab, übersteht das Neuladen und endet, wenn der Tab schliesst. Beide speichern nur Text, darum gehen Listen und Objekte mit JSON.stringify hinein und mit JSON.parse wieder heraus.
Kapitel 5.2 hat den Suchtext im localStorage gespeichert und Kapitel 5.4 dafür das Composable useLocalStorage gebaut. Seit Teil 6 steht der Filter in der Adresse, im Speicher des Browsers bleiben die Favoriten.
Favoriten im localStorage
Abschnitt betitelt „Favoriten im localStorage“Ein Setup Store liest beim Anlegen, was im Speicher steht, und schreibt jede Änderung zurück. Weil ein Eintrag beschädigt sein oder von einer anderen Version der App stammen kann, prüft er, ob eine Liste herauskommt.
App.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <p>Favoriten: {{ favorites.count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorit</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref, watch } from 'vue'
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length) // Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true }) function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },]Die Fallbeispiel-App verwendet im Store das Composable useLocalStorage aus Kapitel 5.4. Ein Setup Store kann Composables nutzen wie eine Komponente.
import { defineStore } from 'pinia'import { computed } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage.js'
export const useFavoritesStore = defineStore('favorites', () => { // The ids of the favourite things, kept across reloads and the same in every tab const ids = useLocalStorage('lorraine-commons-favorites', []) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})Mehrere Tabs
Abschnitt betitelt „Mehrere Tabs“Ändert ein Tab einen Eintrag im localStorage, löst der Browser in den anderen Tabs derselben Origin das Event storage aus. Der Tab, der die Änderung macht, bekommt es laut MDN nicht. Das Event nennt den Schlüssel und den neuen Wert, und der Store liest ihn neu.
App.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <p>Favoriten: {{ favorites.count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorit</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref, watch } from 'vue'
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length) // Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true }) // Another tab of the same origin changed the favorites, this tab follows window.addEventListener('storage', (event) => { if (event.key === KEY) ids.value = read() })
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Bohrmaschine', deposit: 20, availableFrom: null }, { id: 2, name: 'Zelt für vier Personen', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Stichsäge', deposit: 20, availableFrom: null }, { id: 6, name: 'Beamer', deposit: 80, availableFrom: null }, { id: 15, name: 'Kreissäge', deposit: 40, availableFrom: null },]Beide Beispiele dieser Seite laufen in derselben Origin und teilen sich darum den Speicher. Markierst du im ersten Beispiel einen Favoriten, folgt das zweite, denn es hört auf das Event. Das erste Beispiel folgt dem zweiten erst nach einem Neustart.
Ein Entwurf pro Tab
Abschnitt betitelt „Ein Entwurf pro Tab“Ein angefangenes Formular gehört in den sessionStorage. Es übersteht das Neuladen, bleibt aber im Tab, in dem jemand es angefangen hat, und verschwindet, wenn der Tab schliesst.
App.vue
<script setup>import { reactive, ref, watch } from 'vue'
const KEY = 'lorraine-commons-draft-6'
// The draft belongs to this tab only and ends when the tab closesfunction readDraft() { try { return { from: '', to: '', note: '', ...JSON.parse(sessionStorage.getItem(KEY)) } } catch { return { from: '', to: '', note: '' } }}
const form = reactive(readDraft())const sent = ref(false)
// Every change goes into the draft, an empty form removes itwatch( form, (value) => { if (Object.values(value).every((field) => field === '')) sessionStorage.removeItem(KEY) else sessionStorage.setItem(KEY, JSON.stringify(value)) }, { deep: true },)
function submit() { sent.value = true Object.assign(form, { from: '', to: '', note: '' })}</script>
<template> <h1>Beamer reservieren</h1> <form @submit.prevent="submit"> <label for="from">Erster Tag</label> <input id="from" v-model="form.from" type="date"> <label for="to">Letzter Tag</label> <input id="to" v-model="form.to" type="date"> <label for="note">Notiz</label> <textarea id="note" v-model="form.note" maxlength="200" /> <button>Anfrage senden</button> </form> <p v-if="sent" role="status">Gesendet, der Entwurf ist gelöscht.</p></template>
<style scoped>form { display: grid; gap: 0.25rem; max-width: 20rem;}</style>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')Ein leeres Formular löscht den Entwurf, und nach dem Senden ist das Formular leer. In der Fallbeispiel-App ersetzt der Entwurf die Rückfrage beim Verlassen aus Kapitel 6.4, denn wer die Seite verlässt, findet die Eingaben bei der Rückkehr wieder.
Nichts Geheimes im Speicher
Abschnitt betitelt „Nichts Geheimes im Speicher“Was im Speicher des Browsers steht, kann jedes Skript der Seite lesen. Das HTML5 Security Cheat Sheet von OWASP warnt, dass eine einzige Lücke für Cross-Site Scripting alle Daten dieser Speicher stehlen kann. Es rät, keine sensiblen Daten und keine Kennungen von Sitzungen darin abzulegen. Passwörter, Schlüssel für APIs und Kennungen von Sitzungen gehören darum nie hinein, auch nicht in den sessionStorage.
Die Kurs-API hält die Anmeldung deshalb in einem Cookie mit HttpOnly. Laut MDN kann JavaScript ein solches Cookie nicht lesen, fetch schickt es aber mit. Ohne Ablaufdatum ist es ein Cookie für eine Sitzung, das endet, wenn der Browser schliesst. Viele Browser stellen Tabs beim nächsten Start wieder her und solche Cookies mit ihnen. SameSite=Strict schickt das Cookie nur bei Anfragen von derselben Website mit.
Die Anmeldung nach dem Neuladen
Abschnitt betitelt „Die Anmeldung nach dem Neuladen“Nach dem Neuladen ist der Store der Anmeldung leer, das Cookie aber noch da. Die App fragt darum beim Start die Kurs-API mit GET /session, wer angemeldet ist. Die Action merkt sich ihr Promise, so fragt sie nur einmal, auch wenn App und Guard sie beide aufrufen.
// The session cookie of the last login outlives a reload, the store does not. // So the app asks the course API once who is signed in. Every caller gets the same answer. function restore() { restoring ??= callApi('/session').then(({ ok, data }) => { if (ok) member.value = data }) return restoring }Der Guard für den Bereich des Teams wartet auf diese Antwort. Sonst schickte er ein angemeldetes Teammitglied nach dem Neuladen von /team auf die Anmeldung, weil der Store die Antwort noch nicht kennt.
// The team area needs a team account. Everybody else signs in first and comes back afterwards.router.beforeEach(async (to) => { if (!to.meta.requiresTeam) return // The store is called inside the guard, when Pinia is installed. After a reload the guard // waits until the course API has said who is signed in. const auth = useAuthStore() await auth.restore() if (!auth.isTeam) return { name: 'login', query: { redirect: to.fullPath } }})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.
- Markiere im ersten Beispiel zwei Favoriten und ändere danach etwas im Code, damit die Vorschau neu startet. Was bleibt?
- Öffne diese Seite in einem zweiten Tab und markiere dort einen Favoriten. Was zeigen die Beispiele im ersten Tab?
- Tipp im Beispiel mit dem Entwurf eine Notiz, starte die Vorschau neu und öffne die Seite danach in einem neuen Tab. Wo steht die Notiz?
- Schreib im ersten Beispiel in den Entwicklertools unter «Application» den Wert
{in den Eintraglorraine-commons-favoritesund starte die Vorschau neu. Was zeigt die Seite?
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die drei Wünsche für Favoriten und Formular. Die Anmeldung zeigt die Fallbeispiel-App. Du findest das Projekt im Kursrepo im Ordner exercises/07-state/persistence/de. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Der Store favorites vergisst die Favoriten beim Neuladen. Lies sie beim Anlegen aus dem localStorage unter dem Schlüssel lorraine-commons-favorites und schreib jede Änderung als JSON zurück. Ein beschädigter Eintrag zählt wie keiner.
Lösung
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length)
// Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true })JSON.parse(null) ergibt null, und Array.isArray lässt dann wie bei einem beschädigten Eintrag die leere Liste gelten. deep: true braucht es, falls jemand die Liste mit push ändert.
Aufgabe Vertiefung
Ein zweiter Tab ändert die Favoriten, und der erste merkt es nicht. Lass den Store auf das Event storage hören und die Favoriten neu lesen, wenn sich sein Schlüssel ändert.
Lösung
// Another tab of the same origin changed the favorites, this tab follows window.addEventListener('storage', (event) => { if (event.key === KEY) ids.value = read() })Ein Store lebt, solange die App läuft, darum muss er den Listener nicht wieder entfernen. In einem Composable für Komponenten gehört dazu onUnmounted oder onScopeDispose, wie im Composable der Fallbeispiel-App.
Aufgabe Knobelei
Die Seite eines Dings hält einen Entwurf des Formulars im sessionStorage unter lorraine-commons-draft- und der Nummer des Dings. Nach dem Neuladen steht er wieder im Formular, und nach dem Senden ist er weg.
Lösung
// The draft of each thing belongs to this tab only and ends when the tab closesconst key = computed(() => `lorraine-commons-draft-${props.id}`)
function readDraft() { try { return { from: '', to: '', note: '', ...JSON.parse(sessionStorage.getItem(key.value)) } } catch { return { from: '', to: '', note: '' } }}
const form = reactive(readDraft())const sent = ref(false)
// The router keeps the page when only the id changes, the form then takes the draft of the new thingwatch(key, () => Object.assign(form, readDraft()))
// Every change goes into the draft, an empty form removes itwatch( form, (value) => { if (Object.values(value).every((field) => field === '')) sessionStorage.removeItem(key.value) else sessionStorage.setItem(key.value, JSON.stringify(value)) }, { deep: true },)Der Router behält die Seite, wenn nur die Nummer wechselt, wie in Kapitel 6.2. Der Watcher auf key holt dann den Entwurf des neuen Dings.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
Aus dem Speicher kommt [object Object]. | Der Speicher nimmt nur Text. Werte mit JSON.stringify speichern und mit JSON.parse lesen. |
| Ein beschädigter Eintrag lässt die App beim Start abstürzen. | JSON.parse in try und catch setzen und die Form des Werts prüfen. |
| Ein Tab merkt nichts von den Änderungen eines anderen. | Auf das Event storage hören. Der Tab, der schreibt, bekommt es nicht. |
Ein Kennwort oder eine Kennung der Sitzung steht im localStorage. | Nie. Eine Anmeldung gehört in ein Cookie mit HttpOnly, das der Server setzt. |
Ein KI-Assistent speichert das ganze Mitglied im localStorage, damit es nach dem Neuladen angemeldet bleibt. | Ob jemand angemeldet ist, weiss der Server. Die App fragt ihn nach dem Neuladen. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Worin unterscheiden sich
localStorageundsessionStorage? - Wann löst der Browser das Event
storageaus, und wann nicht? - Warum gehört eine Kennung der Sitzung nicht in den Speicher des Browsers?
- Warum wartet der Guard nach dem Neuladen auf die Kurs-API?
Antworten
localStorageteilen alle Tabs einer Origin, und er übersteht das Schliessen des Browsers.sessionStoragegehört zu einem Tab und endet mit ihm.- In den anderen Tabs derselben Origin, wenn ein Tab einen Eintrag ändert. Der Tab, der schreibt, bekommt es nicht.
- Jedes Skript der Seite kann den Speicher lesen, und eine einzige Lücke für Cross-Site Scripting reicht, um sie zu stehlen. Ein Cookie mit
HttpOnlybleibt für Skripte unsichtbar. - Nach dem Neuladen ist der Store leer, und erst die Antwort auf
GET /sessionsagt, wer angemeldet ist. Ohne Warten käme ein angemeldetes Teammitglied auf die Anmeldung.
Auftrag
Die App hält, was sie halten soll, und vergisst, was sie nicht behalten darf. Martina Hostettler will sie im Verein zeigen, sobald sie öffentlich läuft, und das kommt mit Teil 8. Mit diesem Kapitel ist der Stoff der Prüfung komplett, und mit dem Kompetenzraster prüfst du, wo du stehst. Wer wissen will, wie Bibliotheken Daten vom Server zwischenspeichern, liest den Exkurs zu Server-State-Bibliotheken.
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.