Web Storage
Web Storage är det enklaste sättet att spara data i webbläsaren, och det du behöver för topplistan i examinationsuppgift B2. Den här artikeln går igenom API:et, hur du sparar mer än en sträng och hur du bygger lagringen så att den tål det som kan gå fel. Den bygger på föregående artikel, Lagring i webbläsaren.
Exemplen bygger vidare på veckans övningsuppgift, Memory, som får minnas spelarens val av spelplan och de senaste omgångarna.
Tidsåtgång: Ungefär 15 minuter.
Nycklar och värden
Section titled “Nycklar och värden”Web Storage är två objekt, localStorage och sessionStorage, med samma metoder. Båda lagrar
strängar under en nyckel, som en Map där både nyckel och värde är strängar:
localStorage.setItem('memory:size', '4x2')
console.log(localStorage.getItem('memory:size')) // '4x2'console.log(localStorage.getItem('memory:sound')) // null
localStorage.removeItem('memory:size')setItem() sparar ett värde, och skriver över det som fanns under nyckeln. getItem() läser, och
ger null om nyckeln saknas. removeItem() tar bort en nyckel, och clear() tar bort allt för hela
origin, också det som andra applikationer på samma origin har sparat. Använd den sällan.
API:et är synkront. Värdet är sparat när setItem() har kört klart, och getItem() ger svaret
direkt, utan promise och utan await.
Kom ihåg ett val
Section titled “Kom ihåg ett val”Mer än så behövs inte för att minnas ett enkelt val. Sidan som använder <memory-game> har en lista
där spelaren väljer storlek på spelplanen. index.js sparar valet när det ändras, och läser det när
sidan laddas:
const SIZE_KEY = 'memory:size'
const game = document.querySelector('memory-game')const sizeSelect = document.querySelector('#size')
sizeSelect.value = localStorage.getItem(SIZE_KEY) ?? '4x4'applySize(sizeSelect.value)
sizeSelect.addEventListener('change', () => { localStorage.setItem(SIZE_KEY, sizeSelect.value) applySize(sizeSelect.value)})
/** * Sets the board size on the game, which deals a new round. * * @param {string} size - The size as "rowsxcolumns", e.g. "4x2". */function applySize(size) { const [rows, columns] = size.split('x') game.setAttribute('rows', rows) game.setAttribute('columns', columns)}?? '4x4' ger ett standardvärde första gången, när inget har sparats och getItem() ger null.
Nyckeln har ett prefix, memory:, eftersom alla applikationer på samma origin delar på samma lagring,
som du såg i föregående artikel.
Lägg märke till att <memory-game> inte vet att valet sparas. Den får sina attribut, som vanligt. Det
är sidan, inte komponenten, som bestämmer vad som ska minnas.
localStorage eller sessionStorage
Section titled “localStorage eller sessionStorage”Skillnaden mellan de två är hur länge datan ligger kvar och vem som ser den:
localStorageligger kvar tills den tas bort, och delas av alla flikar och fönster med samma origin.sessionStoragehör till en enda flik. Den ligger kvar när sidan laddas om, men försvinner när fliken stängs. Öppnar användaren sidan i en ny flik börjar den med en egen, tomsessionStorage.
sessionStorage passar för det som hör till ett pågående arbete i en flik och som inte ska störa
andra flikar, som ett halvt ifyllt formulär som ska överleva att sidan laddas om av misstag.
Fråga: topplistan i sessionStorage?
Section titled “Fråga: topplistan i sessionStorage?”Du sparar topplistan i ditt quiz i sessionStorage i stället för localStorage. Allt ser ut att
fungera när du testar: du spelar tre omgångar och alla tre syns i listan. Vad kommer användaren att
märka?
Topplistan är tom varje gång användaren kommer tillbaka. sessionStorage töms när fliken stängs, så
listan lever bara så länge användaren spelar i samma flik. Spelar två personer på samma dator, i var
sin flik, ser de inte heller varandras resultat.
Testet missade det eftersom du aldrig stängde fliken. En topplista ska överleva det, och hör därför
hemma i localStorage.
Strängar, tal och objekt
Section titled “Strängar, tal och objekt”Web Storage lagrar bara strängar. Ger du setItem() något annat görs det om till en sträng först,
utan felmeddelande:
Fråga: vad sparas?
Section titled “Fråga: vad sparas?”localStorage.setItem('memory:best', 12)localStorage.setItem('memory:last', { attempts: 12, size: '4x4' })
const best = localStorage.getItem('memory:best')const last = localStorage.getItem('memory:last')Vad innehåller best och last?
best är strängen '12', inte talet 12. Det märks först när du räknar med den: best + 1 blir
'121'. Gör om till ett tal med Number(best), precis som med attribut.
last är strängen '[object Object]'. Objektet har gjorts om till en sträng med sin toString(),
och allt innehåll är förlorat. Det går inte att få tillbaka.
Objekt och arrayer som JSON
Section titled “Objekt och arrayer som JSON”För att spara mer än en sträng gör du om värdet till JSON med JSON.stringify() när du sparar, och
tillbaka med JSON.parse() när du läser, samma metoder som du använde för nätverksanrop i
HTTP och JSON:
const history = [ { attempts: 12, size: '4x4', playedAt: '2026-12-01T19:42:10.512Z' }, { attempts: 4, size: '2x2', playedAt: '2026-12-01T19:38:57.004Z' }]
localStorage.setItem('memory:history', JSON.stringify(history))
const restoredHistory = JSON.parse(localStorage.getItem('memory:history'))Spara en lista som en array under en nyckel, inte som en nyckel per post (memory:game1,
memory:game2 och så vidare). En array kan du sortera, filtrera och korta av med de vanliga
metoderna, och den sparas och läses i ett enda anrop.
Begränsningarna i JSON gäller även här. Ett datum blir en sträng, och därför sparas playedAt som en
sträng från toISOString() från början. Behöver du ett Date-objekt när du läser skapar du det med
new Date(game.playedAt).
Ett objekt eller flera nycklar?
Section titled “Ett objekt eller flera nycklar?”Allt behöver inte samlas i ett objekt. Värden som är oberoende av varandra, som spelplanens storlek
och om ljudet är på, kan lika gärna sparas under var sin nyckel, memory:size och memory:sound. De
läses och skrivs var för sig, utan JSON, och ändrar två flikar var sin inställning skriver de inte
över varandra.
Data som hör ihop och alltid läses och skrivs tillsammans, som en omgång med antal försök, storlek och tid, eller en hel lista med omgångar, sparas som ett objekt eller en array under en nyckel. Priset är att varje ändring innebär att du läser hela värdet, ändrar det och skriver tillbaka det. Då kan två flikar skriva över varandras ändringar, som du ser i Två flikar.
Vad kan gå fel?
Section titled “Vad kan gå fel?”Med lagring som ligger kvar mellan besöken, och som användaren kan ändra, kan flera saker gå fel:
- Inget har sparats än.
getItem()gernull.JSON.parse(null)ger ocksånull, utan fel, och nästa rad som anroparhistory.push()kastar ettTypeError. - Det som har sparats är inte det du väntar dig. Användaren kan ha ändrat värdet i
utvecklarverktygen, eller så sparade en äldre version av din applikation något annat under samma
nyckel.
JSON.parse()kastar ettSyntaxErrorom texten inte är giltig JSON, och är den giltig men inte en array går nästa steg fel. - Lagringen är full.
setItem()kastar ettQuotaExceededErrorom origin har nått sin kvot. - Lagringen är avstängd. Har användaren blockerat data för webbplatsen i webbläsarens
inställningar kastar redan
localStorageettSecurityError, innan du hunnit anropa någon metod.
Inget av det här är ett skäl att låta applikationen sluta fungera. Ett memoryspel utan historik är fortfarande ett memoryspel. Därför är det värt att samla lagringen på ett ställe och ta hand om felen där.
En modul för lagringen
Section titled “En modul för lagringen”Lägg koden som läser och skriver i en egen modul, med en funktion för varje sak applikationen behöver
göra. Ingen annan del av applikationen behöver veta om datan ligger i localStorage, vilken nyckel
den har eller att den är JSON:
const STORAGE_KEY = 'memory:history'const MAX_GAMES = 10
/** * Reads the saved games. Returns an empty array if nothing is saved, if the * saved value is not a list, or if storage is unavailable. * * @returns {Array<{ attempts: number, size: string, playedAt: string }>} The saved games, the most recent first. */export function loadHistory() { try { const history = JSON.parse(localStorage.getItem(STORAGE_KEY)) return Array.isArray(history) ? history : [] } catch { return [] }}
/** * Saves a finished game first in the history and keeps the most recent ones. * * @param {{ attempts: number, size: string, playedAt: string }} game - The finished game. * @throws {DOMException} If storage is full or unavailable. */export function saveGame(game) { const history = [game, ...loadHistory()].slice(0, MAX_GAMES) localStorage.setItem(STORAGE_KEY, JSON.stringify(history))}loadHistory() tar hand om alla fel själv. Vad som än har hänt är svaret en array, och den som anropar
kan alltid använda den. saveGame() låter däremot felet gå vidare, eftersom det bara är den som
anropar som vet hur användaren ska få veta att resultatet inte sparades.
index.js, som redan kopplar ihop sidan, lyssnar på game-over och använder modulen:
import './components/memory-game/index.js'import { loadHistory, saveGame } from './game-history.js'
const game = document.querySelector('memory-game')const status = document.querySelector('#status')// ... sizeSelect and applySize() as above, and showHistory(), which renders the list
game.addEventListener('game-over', (event) => { try { saveGame({ attempts: event.detail.attempts, size: sizeSelect.value, playedAt: new Date().toISOString() }) } catch { status.textContent = 'Resultatet kunde inte sparas.' }
showHistory(loadHistory())})saveGame() läser historiken på nytt varje gång den sparar, i stället för att hålla en kopia i en
variabel. Varför det spelar roll ser du i nästa avsnitt.
Var hör lagringen hemma?
Section titled “Var hör lagringen hemma?”Det är samma regel som i
Kommunikation mellan komponenter:
<memory-game> gör sitt jobb och berättar med en händelse att omgången är slut. Om resultatet ska
sparas, var och hur länge, bestäms av den som orkestrerar, här index.js. Komponenten går då att
använda på en sida som inte vill spara något alls.
I förslaget på arkitektur i B2 är det quiz-app som läser och skriver topplistan, och leaderboard
som visar den lista den får via showScores(). leaderboard vet inte var listan kommer ifrån.
Två flikar
Section titled “Två flikar”localStorage delas av alla flikar med samma origin. Det är ofta det du vill, men det kan också
ställa till det.
Fråga: vilka omgångar finns kvar?
Section titled “Fråga: vilka omgångar finns kvar?”Anta att index.js läser historiken en gång när sidan laddas, och sedan lägger till varje ny omgång
i den variabeln innan den sparas:
const history = loadHistory()
game.addEventListener('game-over', (event) => { history.unshift({ attempts: event.detail.attempts /* ... */ }) localStorage.setItem('memory:history', JSON.stringify(history))})Historiken är tom. Spelaren öppnar spelet i två flikar, A och B, och spelar klart en omgång i A och sedan en i B. Vilka omgångar finns sparade?
Bara omgången från B. Båda flikarna läste en tom historik när de laddades. A lade till sin omgång i sin kopia och sparade den. B lade sedan till sin omgång i sin kopia, som fortfarande var tom, och sparade över det A hade skrivit.
Därför läser saveGame() historiken på nytt precis innan den sparar. Då är det bara en kopia som
gäller, den i localStorage, och fönstret där två flikar kan skriva över varandra är så kort att det
i praktiken inte händer i ett spel.
Händelsen storage
Section titled “Händelsen storage”Den andra fliken vet fortfarande inte att något har ändrats, och visar en historik som inte längre
stämmer. Webbläsaren berättar det med händelsen storage på window. Den utlöses i alla andra
flikar och fönster med samma origin när något i localStorage ändras, men inte i den flik som ändrade:
window.addEventListener('storage', (event) => { if (event.key === 'memory:history' || event.key === null) { showHistory(loadHistory()) }})event.key är nyckeln som ändrades, och null om någon anropade clear(). Händelsen har också
oldValue och newValue, men det är enklast att läsa på nytt med samma funktion som annars.
När Web Storage inte räcker
Section titled “När Web Storage inte räcker”Web Storage är rätt val för små mängder data, men har sina gränser:
- Den är synkron. Varje
getItem()ochsetItem()körs på huvudtråden, ochJSON.parse()av en stor sträng likaså. Med några kilobyte märks det inte. Med megabyte kan sidan frysa, som du såg i Händelseloopen. - Den rymmer cirka 5 MiB per origin, och bara strängar.
- Den går inte att söka i. Vill du hitta något måste du läsa allt och leta själv.
- Den finns inte i workers, till exempel i den service worker du möter i kursvecka 15.
När någon av de här gränserna blir ett problem är det dags för IndexedDB, som är ämnet för nästa artikel. En topplista med fem poster är långt ifrån gränserna.
Att ta med sig
Section titled “Att ta med sig”setItem(),getItem()ochremoveItem()sparar, läser och tar bort strängar under en nyckel.getItem()gernullom nyckeln saknas.localStorageligger kvar och delas av alla flikar.sessionStoragehör till en flik och försvinner när den stängs.- Spara objekt och arrayer som JSON, och en lista som en array under en nyckel. Oberoende värden kan ligga under var sin nyckel. Ge nycklarna ett prefix för applikationen.
- Räkna med att det sparade saknas, är fel eller inte går att spara. Samla lagringen i en modul och ta hand om felen där.
- Komponenter vet inte om att något sparas. Den som orkestrerar läser och skriver.
- Läs på nytt precis innan du skriver, och lyssna på
storageför att se ändringar från andra flikar.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 15.12.1 i kursboken om
localStorageochsessionStorage. - Using the Web Storage API och Window: storage event på MDN.