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.
Vad URL:en är till för
Section titled “Vad URL:en är till för”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.
Läsa URL:en
Section titled “Läsa URL:en”location beskriver dokumentets URL och har en egenskap för varje del:
// https://example.com/memory/?size=2x2#ruleslocation.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') // nullparams.has('size') // trueget() 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'}Att skriva till location laddar om
Section titled “Att skriva till location laddar om”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 API
Section titled “History API”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:
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.
Bakåtknappen och popstate
Section titled “Bakåtknappen och popstate”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())})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.
pushState() eller replaceState()
Section titled “pushState() eller replaceState()”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.
URL:en eller localStorage?
Section titled “URL:en eller localStorage?”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.
localStoragehö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.
Query string, hash eller sökväg
Section titled “Query string, hash eller sökväg”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.
Query stringen
Section titled “Query stringen”?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.
Hashen
Section titled “Hashen”#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.
Sökvägen
Section titled “Sökvägen”/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.htmlför alla sökvägar. Laddar användaren om på/memory/settingsber webbläsaren servern om just den sökvägen, och en statisk server har ingen sådan fil. Vitesnpm run devochnpm run servesvarar medindex.htmlfö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.jspå sidan/memory/settingsblir/memory/sw.js. Det gäller också./favicon.icooch./manifest.webmanifestiindex.html, som Vite lämnar som de är.
Den andra punkten är lätt att missa, eftersom den inte ger något tydligt fel.
Fråga: vad händer med service workern?
Section titled “Fråga: vad händer med service workern?”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.
Routing och service workern
Section titled “Routing och service workern”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:
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.
Routing i ramverk
Section titled “Routing i ramverk”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.
Att ta med sig
Section titled “Att ta med sig”- 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
URLochURLSearchParams, och kontrollera värdet. Att skriva tilllocationladdar 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.popstateutlö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,
localStoragefö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.
Vidare läsning
Section titled “Vidare läsning”- Läsanvisningarna för veckan, i synnerhet kapitel 11.9 om URL:er och 15.10 om
locationoch historiken. - Working with the History API och URLSearchParams på MDN.
- Navigation API på MDN, om du vill se vad som kommer efter History API. Det är en orientering, inte något kursen kräver.