Hoppa till innehåll

Routing i en SPA

En SPA laddar ett enda dokument, och sedan står URL:en still, som du såg i Progressiva webbapplikationer. Användaren kan inte dela en länk till det som syns, och bakåtknappen lämnar applikationen. Den här artikeln visar hur en SPA håller URL:en i takt med det som visas, så att länkar, omladdning och bakåtknappen fungerar som användaren väntar sig. Det kallas routing.

Routing är inget krav i examinationsuppgift B3. Ett skrivbord med flyttbara fönster har ingen självklar URL för varje läge, och det är helt i sin ordning att URL:en står still. Men det är ett verktyg du behöver känna till, och tekniken bakom, History API, finns i nästan varje webbapplikation du använder.

Exemplen bygger på memoryspelet från kursvecka 14, där spelaren väljer storlek på spelplanen.

Tidsåtgång: Ungefär 15 minuter.

I en vanlig webbplats svarar varje URL mot ett dokument. URL:en går att dela, bokmärka och ladda om, och bakåtknappen tar användaren till förra dokumentet. Allt det får du gratis.

I en SPA får du det inte. Väljer spelaren spelplanen 2 × 2 och laddar om sidan, får spelaren 4 × 4 igen. Skickar spelaren länken till en vän ser vännen 4 × 4. Ingen av dem gjorde något fel, URL:en säger bara inte vilken storlek som valdes.

Det som hör hemma i URL:en är det som beskriver vad användaren tittar på: en artikel, ett sökresultat, ett filter, en spelplan. Det som beskriver var användaren är i ett pågående arbete, som vilka brickor som är vända, hör inte hemma där.

location beskriver dokumentets URL och har en egenskap för varje del:

// https://example.com/memory/?size=2x2#rules
location.href // 'https://example.com/memory/?size=2x2#rules'
location.origin // 'https://example.com'
location.pathname // '/memory/'
location.search // '?size=2x2'
location.hash // '#rules'

location.search är URL:ens query string som rå text. Den tolkar du inte för hand med split(), utan med URL och URLSearchParams, som du använde för att bygga URL:er i Nätverksanrop med fetch:

const params = new URL(location.href).searchParams
params.get('size') // '2x2'
params.get('level') // null
params.has('size') // true

get() ger alltid en sträng, eller null om parametern saknas. Användaren kan skriva vad som helst i adressfältet, så kontrollera värdet innan du använder det:

const SIZES = ['4x4', '4x2', '2x2']
/**
* Reads the board size from the URL, or the default if it is missing or unknown.
*
* @returns {string} The size as "rowsxcolumns", e.g. "4x2".
*/
function sizeFromUrl() {
const size = new URL(location.href).searchParams.get('size')
return SIZES.includes(size) ? size : '4x4'
}

Egenskaperna på location går också att ändra, men varje ändring utom av hash är en navigering. location.search = '?size=4x2' hämtar dokumentet på nytt från servern, kör all JavaScript från början och kastar allt som fanns i minnet. Det är vad en SPA ska slippa.

history.pushState() byter URL:en i adressfältet och lägger till en ny post i webbläsarens historik, utan att något laddas:

history.pushState(null, '', '?size=4x2')

Det första argumentet är ett state-objekt som sparas med posten, och som du kan läsa med history.state. Det andra finns bara kvar av historiska skäl, och används inte av någon webbläsare. Skicka en tom sträng. Det tredje är den nya URL:en, som tolkas i förhållande till den nuvarande och måste ha samma origin.

pushState() gör ingenting mer. Ingen förfrågan till servern, ingen händelse och ingen ändring av sidan. Det är din kod som ändrar det som visas, och pushState() som ser till att URL:en stämmer med det.

I memoryspelet byter index.js från att spara storleken i localStorage, som i Web Storage, till att lägga den i URL:en:

js/index.js
const game = document.querySelector('memory-game')
const sizeSelect = document.querySelector('#size')
showSize(sizeFromUrl())
sizeSelect.addEventListener('change', () => {
const url = new URL(location.href)
url.searchParams.set('size', sizeSelect.value)
history.pushState(null, '', url)
applySize(sizeSelect.value)
})
/**
* Shows a board size in the list and on the game.
*
* @param {string} size - The size as "rowsxcolumns", e.g. "4x2".
*/
function showSize(size) {
sizeSelect.value = size
applySize(size)
}

applySize() är samma funktion som i kursvecka 14, och sätter attributen rows och columns. url.searchParams.set() ändrar bara parametern size, och behåller resten av URL:en som den var.

Nu överlever valet en omladdning, och länken ?size=2x2 öppnar spelet med 2 × 2. Men bakåtknappen gör inte vad användaren tror. URL:en byts tillbaka, men spelplanen står kvar.

När användaren går bakåt eller framåt mellan två poster som hör till samma dokument laddas ingenting. Webbläsaren byter URL:en och utlöser händelsen popstate på window. Det är ditt jobb att visa det som URL:en nu beskriver:

window.addEventListener('popstate', () => {
showSize(sizeFromUrl())
})
Sekvensdiagram med användaren, index.js, webbläsarens historik och memory-game. Användaren väljer storlek 4x2 och index.js får en change-händelse. index.js anropar history.pushState med query stringen ?size=4x2. En anteckning säger att det blir en ny post i historiken, att URL:en byts, men att ingen förfrågan görs och ingen popstate utlöses. index.js sätter attributen rows och columns. Användaren trycker sedan på bakåtknappen. Webbläsaren utlöser popstate, index.js läser location.href med ?size=2x2 och sätter attributen. En anteckning säger att inget pushState görs här, eftersom användaren redan står på posten.

Lägg märke till att lyssnaren läser URL:en, inte event.state. URL:en finns alltid, också när användaren kommer via en länk eller laddar om, och då har ingen pushState() körts. Är URL:en det enda stället där valet finns behöver du bara en funktion som läser den, och den används både när sidan laddas och vid popstate. event.state är null för en post som inte har fått något state-objekt, till exempel den första.

popstate utlöses inte när sidan laddas, och inte av pushState() eller replaceState(). Den utlöses när användaren, eller history.back() och history.forward(), flyttar mellan posterna, och när hashen, delen efter #, ändras, som du ser längre ner.

Fråga: varför kommer användaren inte tillbaka?

Section titled “Fråga: varför kommer användaren inte tillbaka?”

Någon förenklar koden och låter showSize() göra allt, också pushState():

function showSize(size) {
const url = new URL(location.href)
url.searchParams.set('size', size)
history.pushState(null, '', url)
sizeSelect.value = size
applySize(size)
}
window.addEventListener('popstate', () => showSize(sizeFromUrl()))

Spelaren väljer 4 × 2 och sedan 2 × 2, och trycker två gånger på bakåtknappen. Var hamnar spelaren?

På 4 × 2, och spelaren kommer aldrig längre bakåt. Första tryckningen tar spelaren till posten med 4 × 2 och utlöser popstate. Lyssnaren anropar showSize(), som lägger till en ny post med 4 × 2 och tar bort allt framåt i historiken. Nästa tryckning går tillbaka till posten med 4 × 2 som just blev förra posten, och så vidare.

Vid popstate står användaren redan på rätt post. Där ska koden bara visa, inte lägga till. Därför ligger pushState() i change-lyssnaren, där det är användaren som har valt något nytt.

history.replaceState() tar samma argument som pushState(), men byter ut den nuvarande posten i stället för att lägga till en ny. Frågan att ställa är om användaren vill kunna gå tillbaka hit:

  • pushState() för ett val som användaren ser som ett steg: en ny sida, en ny storlek, ett nytt filter.
  • replaceState() för det som ändras hela tiden, som texten i ett sökfält. En post per tecken gör bakåtknappen oanvändbar, men URL:en kan ändå hålla sökningen så att den överlever en omladdning.

I kursvecka 14 sparades storleken i localStorage. Båda fungerar, men de betyder olika saker:

  • URL:en hör till en flik och en länk. Två flikar kan visa olika storlekar, och den som får länken ser samma sak som den som skickade den.
  • localStorage hör till webbläsaren. Valet följer med till nästa besök, i alla flikar, men går inte att dela.

Det går att kombinera dem: URL:en avgör om den har ett värde, och annars används det som har sparats. Då är ?size=2x2 en länk till en viss storlek, medan en spelare som öppnar spelet utan parameter får sin vanliga.

Det finns tre ställen i URL:en där en SPA kan lägga det som visas, och de skiljer sig i vad de kräver av servern.

?size=2x2, som ovan. Servern får query stringen, men en statisk server bryr sig inte om den, och svarar med samma index.html. Det fungerar med npm run dev, med npm run serve och på vilken statisk server som helst, utan konfiguration.

#settings, delen efter #, heter fragment i specifikationen men kallas nästan alltid hash, efter location.hash. Den skickas aldrig till servern. Det var så SPA:er routade innan History API fanns, med hash routing: koden lyssnade på händelsen hashchange och läste location.hash. Att ändra location.hash, eller följa en länk med href="#settings", laddar inte om sidan och lägger till en post i historiken. Webbläsaren utlöser både popstate och hashchange, så en popstate-lyssnare som den ovan körs också när användaren klickar på en vanlig ankarlänk.

Det fungerar fortfarande, och kräver ingenting av servern. Men hashen är också till för att länka till ett avsnitt i ett dokument, som rubrikerna i den här artikeln, och de två användningarna krockar. I äldre kod ser du ibland #!, en hashbang, som var en överenskommelse med sökmotorerna från tiden då de inte körde JavaScript. Den behövs inte längre.

/memory/settings ser ut som en vanlig webbplats, och är vad de flesta ramverk använder. Men den kräver mer:

  • Servern måste svara med index.html för alla sökvägar. Laddar användaren om på /memory/settings ber webbläsaren servern om just den sökvägen, och en statisk server har ingen sådan fil. Vites npm run dev och npm run serve svarar med index.html för allt de inte hittar, så det fungerar lokalt. På en riktig server måste samma regel konfigureras, annars går routingen sönder först när applikationen är driftsatt.
  • Relativa URL:er räknas från sökvägen. ./sw.js på sidan /memory/settings blir /memory/sw.js. Det gäller också ./favicon.ico och ./manifest.webmanifest i index.html, som Vite lämnar som de är.

Den andra punkten är lätt att missa, eftersom den inte ger något tydligt fel.

Memoryspelet registrerar service workern med register('./sw.js'), som i Service workers. Du lägger till routing med sökvägar, kör npm run build och npm run serve, och öppnar http://localhost:4173/history/2 direkt. Vad händer?

Registreringen misslyckas. ./sw.js räknas från /history/2 och blir /history/sw.js. Den filen finns inte, men npm run serve svarar ändå, med index.html. Webbläsaren får HTML i stället för JavaScript och vägrar: The script has an unsupported MIME type (‘text/html’).

Felet syns bara i konsolen, och applikationen ser ut att fungera, tills den ska fungera offline. Använder du sökvägar för routing behöver allt som sidan pekar ut räknas från roten, till exempel register('/sw.js').

För en applikation som memoryspelet, eller B3, är query stringen det enklaste valet. Den kräver ingenting av servern och ändrar inte vad relativa URL:er betyder.

Service workern från Offline med Cache API cachar varje förfrågan under sin fullständiga URL, och query stringen räknas. Har spelaren öppnat ?size=2x2 finns dokumentet i cachen under /?size=2x2, men inte under /?size=4x2. Öppnar spelaren ett bokmärke med ?size=4x2 utan uppkoppling, misslyckas cache.match(), och webbläsaren visar sin felsida.

I en SPA är alla URL:er samma dokument. Därför kan service workern svara på varje navigering med dokumentet den cachade i förväg, när nätverket inte svarar. request.mode är 'navigate' när webbläsaren hämtar ett dokument att visa i fliken:

public/sw.js
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
if (request.method !== 'GET' || url.origin !== self.location.origin) {
return
}
if (request.mode === 'navigate') {
event.respondWith(navigate(request))
return
}
event.respondWith(url.pathname.startsWith('/assets/') ? cacheFirst(request) : networkFirst(request))
})
/**
* Answers a navigation like networkFirst(), but falls back to the precached
* document, since every URL in the application is the same document.
*
* @param {Request} request - The navigation request.
* @returns {Promise<Response>} The response.
*/
async function navigate(request) {
try {
return await networkFirst(request)
} catch (error) {
const cache = await caches.open(CACHE_NAME)
const cachedDocument = await cache.match('./')
if (cachedDocument) {
return cachedDocument
}
throw error
}
}

./ räknas från service workerns egen URL, /sw.js, och är alltså /, dokumentet som cachas i förväg. När dokumentet har laddats läser index.js query stringen som vanligt och visar rätt storlek. Det som cachas är detsamma som förut, så CACHE_NAME kan behålla sin version.

En applikation med många vyer får snart en lång kedja av if-satser som jämför URL:en och visar rätt vy. Det är ett av problemen ramverk som React, Vue, Svelte och Angular löser, med en router där du anger vilken komponent som hör till vilken sökväg. Under ytan använder de samma pushState() och popstate som du har sett här. Sedan januari 2026 finns dessutom Navigation API, ett nyare API för samma sak, i alla stora webbläsare, men det är för nytt för att lita på att alla användare har det.

  • Routing håller URL:en i takt med det som visas, så att länkar, omladdning och bakåtknappen fungerar.
  • Läs URL:en med URL och URLSearchParams, och kontrollera värdet. Att skriva till location laddar om sidan.
  • history.pushState() lägger till en post och byter URL:en, utan att något laddas och utan någon händelse. replaceState() byter ut den nuvarande posten.
  • popstate utlöses vid bakåt och framåt. Läs URL:en och visa, men lägg inte till någon ny post.
  • URL:en är för det som går att dela, localStorage för det som ska minnas mellan besöken.
  • Query stringen kräver ingenting av servern. Sökvägar kräver att servern alltid svarar med index.html, och att relativa URL:er räknas från roten.
  • Låt service workern svara på navigeringar med det cachade dokumentet när nätverket inte svarar.
CCBY