Hoppa till innehåll

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.

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.

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:

js/index.js
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.

Skillnaden mellan de två är hur länge datan ligger kvar och vem som ser den:

  • localStorage ligger kvar tills den tas bort, och delas av alla flikar och fönster med samma origin.
  • sessionStorage hö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, tom sessionStorage.

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.

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.

Web Storage lagrar bara strängar. Ger du setItem() något annat görs det om till en sträng först, utan felmeddelande:

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.

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).

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.

Med lagring som ligger kvar mellan besöken, och som användaren kan ändra, kan flera saker gå fel:

  • Inget har sparats än. getItem() ger null. JSON.parse(null) ger också null, utan fel, och nästa rad som anropar history.push() kastar ett TypeError.
  • 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 ett SyntaxError om texten inte är giltig JSON, och är den giltig men inte en array går nästa steg fel.
  • Lagringen är full. setItem() kastar ett QuotaExceededError om 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 localStorage ett SecurityError, 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.

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:

js/game-history.js
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:

js/index.js
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.

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.

localStorage delas av alla flikar med samma origin. Det är ofta det du vill, men det kan också ställa till det.

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.

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:

Sekvensdiagram med flik A, localStorage och flik B, alla på samma origin. Flik A anropar setItem med nyckeln memory:history. Webbläsaren utlöser händelsen storage på window i flik B, med key memory:history och newValue, den nya JSON-strängen. Flik B läser historiken och visar den på nytt. En anteckning säger att flik A, som själv ändrade, inte får någon storage-händelse.
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.

Web Storage är rätt val för små mängder data, men har sina gränser:

  • Den är synkron. Varje getItem() och setItem() körs på huvudtråden, och JSON.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.

  • setItem(), getItem() och removeItem() sparar, läser och tar bort strängar under en nyckel. getItem() ger null om nyckeln saknas.
  • localStorage ligger kvar och delas av alla flikar. sessionStorage hö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å storage för att se ändringar från andra flikar.
CCBY